--- title: "Pooled inference and attribute-disclosure diagnostics" output: litedown::html_format: meta: css: ["@default"] vignette: > %\VignetteEngine{litedown::vignette} %\VignetteIndexEntry{Pooled inference and attribute-disclosure diagnostics} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} knitr::opts_chunk$set(collapse = TRUE, comment = "#>") set.seed(1) library(flexsynth) ``` ## Why analyse multiple synthetic datasets `synth()` regenerates every column from a model fitted on the real data, using a freshly drawn skeleton — so its output is *fully synthetic* in the Reiter (2003) sense. Run the synthesiser twice and the analysis estimates can move. Analysing only one synthetic dataset does not quantify that between-synthesis variation; depending on the estimand, synthesis model, sample size, and analysis, its usual standard errors can therefore be misleading. This is why `synth()` can emit `m > 1` datasets. Fit the analysis on each and combine the results using a published fully-synthetic rule. `flexsynth` provides two such rules; their large-sample calibration still relies on the assumptions of the synthesis and analysis models. ```{r data} n <- 800 real <- data.frame( id = seq_len(n), age = round(rnorm(n, 62, 11)), sex = sample(c("F", "M"), n, replace = TRUE, prob = c(0.45, 0.55)) ) real$sbp <- round(0.6 * real$age + ifelse(real$sex == "M", 5, 0) + rnorm(n, 90, 10)) res <- synth(real, structure = ~ id, m = 10, seed = 1) res ``` We asked for `m = 10` synthetic datasets. Increasing `m` reduces Monte Carlo variation in the pooled result; `m >= 5` is a practical starting point, not a universal adequacy threshold. ## `synth_glm()` — the common case For a linear or generalised-linear model, `synth_glm()` fits it on every synthetic dataset and pools the result in one call. ```{r glm} pooled <- synth_glm(res, sbp ~ age + sex) pooled ``` Compare that with the model fitted on the **real** data — the estimand we are trying to recover: ```{r real-fit} summary(lm(sbp ~ age + sex, data = real))$coefficients[, 1:2] ``` In this simulation, the pooled estimates should be close to the generating coefficients (an `age` slope of 0.6 and a male offset of 5). The reported standard errors apply the selected combining rule to the within- and between-synthesis quantities; they are not a guarantee of calibrated coverage for every dataset or model. By default the standard error targets the population the real data was drawn from. If instead you want the standard error an analyst of the *original* sample would have reported, set `population_inference = FALSE`. ## `pool_synth()` — any estimator `synth_glm()` is a thin wrapper. For anything else — a quantile, a survival model, a hand-rolled statistic — pass `pool_synth()` a function that runs on one synthetic `data.frame` and returns either a fitted model (with `coef()` / `vcov()` methods) or an explicit `list(estimate =, variance =)`. ```{r pool} # A proportion and its variance, supplied directly. pool_synth(res, function(d) { p <- mean(d$sex == "M") list(estimate = c(prop_male = p), variance = c(prop_male = p * (1 - p) / nrow(d))) }) ``` Two combining rules are available via `rule =`: the default `"synthpop"` rule matching Raab, Nowok & Dibben (2016), whose implemented variance estimate is non-negative, and the `"reiter"` fully-synthetic rule from Reiter (2003), which needs `m >= 2` and can produce a negative variance estimate (reported as `NA`). Choose the rule to match the release and inferential target rather than by the size of its standard error. ## `compare_estimates()` — analysis-specific utility `diagnose()` (see `vignette("getting-started")`) asks a general question: can a model tell real from synthetic records? `compare_estimates()` asks the question an analyst actually cares about: *does my regression come out the same?* It fits the analysis on both sides — pooling the synthetic side automatically — and reports, per term, the confidence-interval overlap (Karr et al. 2006; 1 means identical intervals, 0 means they just touch, negative means disjoint) and the standardised difference of the estimates. ```{r compare} compare_estimates(real, res, function(d) lm(sbp ~ age + sex, data = d)) ``` Larger interval overlap and smaller standardised differences indicate better agreement for this chosen analysis. They are utility diagnostics, not evidence that every analysis will agree or that the synthetic data is safe to release. ## Attribute disclosure — TCAP Reproducing an analysis well is a *utility* win, but the same key-to-target structure an analyst exploits is what an attacker exploits. `disclosure_risk()` gains a `target =` argument for exactly this: the **Target Correct Attribution Probability** (TCAP; Taub, Elliot et al.). An attacker who knows a real record's quasi-identifier keys looks up the synthetic records sharing those keys and reads off the conditional distribution of the sensitive `target`. TCAP averages the synthetic probability of the true value over real records whose keys occur in the synthetic data. The reported `baseline` evaluates a marginal-only attacker on those same covered records, so `lift` is the *excess* attribution the keys buy. Inspect `coverage` as well: TCAP can otherwise look favourable simply because difficult records have no synthetic key match. The `baseline_unconditional` value provides context across all scored real records. TCAP is meant for categorical keys and target, so we use a small categorical dataset where a sensitive status depends on the quasi-identifiers. ```{r tcap-data} m <- 800 clinic <- data.frame( region = sample(c("N", "S", "E", "W"), m, replace = TRUE), agecat = sample(c("40s", "50s", "60s", "70s"), m, replace = TRUE) ) # hypertension risk rises steeply with age, with a small regional bump. prob_htn <- c("40s" = 0.12, "50s" = 0.40, "60s" = 0.75, "70s" = 0.93) reg_bump <- c("N" = 0, "S" = 0, "E" = 0.03, "W" = 0.05) p <- pmin(0.98, prob_htn[clinic$agecat] + reg_bump[clinic$region]) clinic$hypertension <- ifelse(runif(m) < p, "yes", "no") clinic$id <- seq_len(m) cres <- synth(clinic, ~ id, m = 1, seed = 1) ``` Name the sensitive column as `target`; the remaining quasi-identifiers become the attacker's keys. ```{r tcap} disclosure_risk(clinic, cres, quasi = c("region", "agecat", "hypertension"), target = "hypertension", seed = 1) ``` Read the `Attribute disclosure` block: a `lift` well above zero means the synthetic data lets an attacker attribute the true `hypertension` status meaningfully better than the marginal rate alone — here because `agecat` carries real signal about it. That is a governance signal, not a verdict: some attribute association is the whole point of useful data. Weigh the lift against how sensitive the target is and who receives the release. A `lift` near zero means the keys add almost nothing beyond the margin. ## Where this fits Track A output must never be described as differentially private. The tools here provide evidence for a documented release review, but the release decision must also consider data sensitivity, plausible attackers, access controls, and independent governance review. For a formal privacy guarantee instead, see `vignette("differential-privacy")` and verify all of its stated domain and budget-accounting conditions. Pooled inference (`pool_synth()`, `synth_glm()`) is a Track A tool — inference from a differentially private release must also account for DP noise and is out of scope here.