--- title: "Linked multi-table cardiac data" output: litedown::html_format: meta: css: ["@default"] vignette: > %\VignetteEngine{litedown::vignette} %\VignetteIndexEntry{Linked multi-table cardiac data} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} knitr::opts_chunk$set(collapse = TRUE, comment = "#>") set.seed(20260815) library(flexsynth) ``` ## A linked cardiac schema Real clinical data rarely lives in one table. Here we build a small, **fully synthetic** cardiac example (never real patient data): patients, their cardiac admissions, and the labs taken during each admission. ``` patients (id) └─ admissions (id / admission_id) └─ labs (id / admission_id / lab_number) ``` ```{r data} n_pat <- 50 patients <- data.frame( id = seq_len(n_pat), sex = sample(c("F", "M"), n_pat, replace = TRUE, prob = c(0.45, 0.55)), age = round(rnorm(n_pat, 64, 11)), smoker = sample(c(FALSE, TRUE), n_pat, replace = TRUE, prob = c(0.7, 0.3)) ) adm <- do.call(rbind, lapply(patients$id, function(pid) { k <- 1 + rpois(1, 0.6) data.frame(id = pid, admission_id = seq_len(k), los_days = 1 + rpois(k, 3)) })) labs <- do.call(rbind, lapply(seq_len(nrow(adm)), function(i) { k <- 1 + rpois(1, 2) data.frame(id = adm$id[i], admission_id = adm$admission_id[i], lab_number = seq_len(k), analyte = sample(c("troponin", "LDL", "creatinine"), k, replace = TRUE), value = round(abs(rnorm(k, 2, 1.5)), 2)) })) tables <- list(patients = patients, admissions = adm, labs = labs) vapply(tables, nrow, integer(1)) ``` ## Joint synthesis with `synth_linked()` `synth_linked()` takes a `structures` formula per table and the `keys`, and reads the hierarchy from them: each table's key is its parent's key plus one more column. Root tables are synthesised with the single-table engine; child tables are generated from their **synthetic** parent, with foreign keys copied over so referential integrity holds by construction. ```{r synth} structures <- list( patients = ~ id, admissions = ~ id / admission_id, labs = ~ id / admission_id / lab_number ) keys <- list( patients = "id", admissions = c("id", "admission_id"), labs = c("id", "admission_id", "lab_number") ) res <- synth_linked(tables, structures = structures, keys = keys, seed = 1) res ``` The number of children per parent is drawn from a learned count model (including parents with no children), and child variables are conditioned on the parent's synthesised attributes. ## Verifying linkage `check_linkage()` confirms key uniqueness and the absence of orphan child rows. ```{r check} syn <- as.list(res) check_linkage(res) # every synthetic lab points at a real (synthetic) admission — no orphans all(paste(syn$labs$id, syn$labs$admission_id) %in% paste(syn$admissions$id, syn$admissions$admission_id)) ``` ## Per-table diagnostics `diagnose()` and `disclosure_risk()` accept the whole result and report one block per table. ```{r diagnose} diagnose(tables, res) ``` `disclosure_risk()` is most meaningful per table with the genuinely identifying columns named (excluding surrogate keys such as `id`, which are regenerated): ```{r risk} disclosure_risk(patients, syn$patients, quasi = c("age", "sex", "smoker"), seed = 1) ``` Passing the whole objects (`disclosure_risk(tables, res)`) also works and defaults to all shared columns per table — quick, but it will include surrogate keys, so prefer naming `quasi` for a real release. Constraints (`rule()`) currently apply to single-table `synth()`; enforce cross-table logic on the parent table before linking. The example data here is synthetic. Track A output is high-utility but carries no formal privacy guarantee — always read the disclosure diagnostics before sharing.