# Linked multi-table cardiac data

## 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)

    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))

    #>   patients admissions       labs
    #>         50         80        244

## 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.

    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

    #> <synth_linked_result>
    #>   track        : A (high-utility; NOT differentially private)
    #>   datasets (m) : 1
    #>   tables       : 3
    #>     - patients           50 rows  (input 50)  (root)
    #>     - admissions         82 rows  (input 80)  child of patients
    #>     - labs              234 rows  (input 244)  child of admissions
    #>   method       : cart
    #>
    #> Get the tables with as.list(x)
    #> Verify linkage with check_linkage(x)

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.

    syn <- as.list(res)
    check_linkage(res)

    #> Linkage OK: keys unique, no orphan child rows.

    # 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))

    #> [1] TRUE

## Per-table diagnostics

`diagnose()` and `disclosure_risk()` accept the whole result and report one block per table.

    diagnose(tables, res)

    #> <flexsynth_diagnostics_list> 3 tables
    #>
    #> == patients ==
    #> <flexsynth_diagnostics>
    #>   rows        : real 50  synthetic 50
    #>   variables   : 4
    #>
    #> Univariate fit (smaller = closer):
    #>  variable        type metric distance
    #>        id     numeric     ks     0.00
    #>       sex categorical    tvd     0.08
    #>       age     numeric     ks     0.10
    #>    smoker     logical    tvd     0.10
    #>   mean distance: 0.0700   worst: age (0.1000)
    #>
    #> Correlation structure (2 numeric vars):
    #>   Frobenius diff: 0.6521   mean |diff|: 0.4611   max |diff|: 0.4611
    #>
    #> Categorical association (Cramer's V, 2 vars):
    #>   mean |diff|: 0.0000   max |diff|: 0.0000
    #>
    #> Propensity utility (pMSE, logistic; descriptive, in-sample):
    #>   pMSE: 0.00470   expected: 0.00500   ratio: 0.94 (1 = indistinguishable)
    #>
    #> == admissions ==
    #> <flexsynth_diagnostics>
    #>   rows        : real 80  synthetic 82
    #>   variables   : 3
    #>
    #> Univariate fit (smaller = closer):
    #>      variable    type metric distance
    #>            id numeric     ks   0.0564
    #>  admission_id numeric     ks   0.0152
    #>      los_days numeric     ks   0.0680
    #>   mean distance: 0.0465   worst: los_days (0.0680)
    #>
    #> Correlation structure (3 numeric vars):
    #>   Frobenius diff: 0.3669   mean |diff|: 0.1243   max |diff|: 0.2419
    #>
    #> Propensity utility (pMSE, logistic; descriptive, in-sample):
    #>   pMSE: 0.00023   expected: 0.00229   ratio: 0.10 (1 = indistinguishable)
    #>
    #> == labs ==
    #> <flexsynth_diagnostics>
    #>   rows        : real 244  synthetic 234
    #>   variables   : 5
    #>
    #> Univariate fit (smaller = closer):
    #>      variable        type metric distance
    #>            id     numeric     ks   0.0711
    #>  admission_id     numeric     ks   0.0450
    #>    lab_number     numeric     ks   0.0345
    #>       analyte categorical    tvd   0.0879
    #>         value     numeric     ks   0.0894
    #>   mean distance: 0.0656   worst: value (0.0894)
    #>
    #> Correlation structure (4 numeric vars):
    #>   Frobenius diff: 0.1646   mean |diff|: 0.0359   max |diff|: 0.1019
    #>
    #> Propensity utility (pMSE, logistic; descriptive, in-sample):
    #>   pMSE: 0.00348   expected: 0.00160   ratio: 2.17 (1 = indistinguishable)
    #>

`disclosure_risk()` is most meaningful per table with the genuinely identifying columns named (excluding surrogate keys such as `id`, which are regenerated):

    disclosure_risk(patients, syn$patients,
                    quasi = c("age", "sex", "smoker"), seed = 1)

    #> <flexsynth_disclosure>
    #>   rows            : real 50  synthetic 50
    #>   quasi-identifiers: age, sex, smoker
    #>
    #> Replicated uniques (identity risk):
    #>   real sample-uniques : 33
    #>   reproduced in syn   : 19  (57.58% of uniques, 38.00% of real rows)
    #>   syn rows copying a real row: 37  (74.00% of syn)
    #>
    #> Distance to closest record (Gower, 0 = exact copy):
    #>   syn->real  : median 0.0000   5th pct 0.0000   exact copies 74.00%
    #>   real->real : median 0.0081   (baseline)
    #>   median syn distance is smaller than the real-neighbour baseline
    #>   descriptive only: inspect lower-tail distances and exact copies; this is not a safety guarantee
    #>
    #> Membership inference: not run (supply `holdout` of non-training records).
    #>
    #> Attribute disclosure: not run (supply `target` = a sensitive column).

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.