--- title: "Nested and longitudinal data" output: litedown::html_format: meta: css: ["@default"] vignette: > %\VignetteEngine{litedown::vignette} %\VignetteIndexEntry{Nested and longitudinal data} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} knitr::opts_chunk$set(collapse = TRUE, comment = "#>") set.seed(1) library(flexsynth) ``` ## Long format, natively Repeated-measures and nested designs are the reason `flexsynth` works in long format. You never pivot to wide: the `structure` formula declares the hierarchy, for example `~ id / visit` (patients, each with a sequence of visits). We simulate a longitudinal cohort. Each patient has a fixed baseline (`age`, `sex`) and a time-varying systolic blood pressure that drifts over visits — an autocorrelated series with an unequal number of visits per patient. ```{r data} n_pat <- 120 base <- data.frame( id = seq_len(n_pat), age = round(rnorm(n_pat, 60, 10)), sex = sample(c("F", "M"), n_pat, replace = TRUE) ) n_visits <- 1 + rpois(n_pat, 2) # unequal length units rows <- lapply(seq_len(n_pat), function(i) { k <- n_visits[i] sbp <- numeric(k) sbp[1] <- 0.6 * base$age[i] + rnorm(1, 95, 8) for (t in seq_len(k)[-1]) sbp[t] <- 0.8 * sbp[t - 1] + rnorm(1, 26, 6) data.frame(id = base$id[i], visit = seq_len(k), age = base$age[i], sex = base$sex[i], sbp = round(sbp)) }) real <- do.call(rbind, rows) head(real, 8) ``` ## Synthesising the hierarchy ```{r synth} res <- synth(real, structure = ~ id / visit, seed = 1) res ``` The engine detects that `age` and `sex` are constant within a patient (*subject-invariant*) and synthesises them **once per unit**, broadcasting them across that unit's visits, so a synthetic patient stays internally consistent. The number of visits per patient is drawn from the learned count distribution, and `sbp` is synthesised with an initial-state model plus a Markov transition that conditions on the previous visit — so the within-patient autocorrelation survives. ```{r structure-checks} syn <- as.data.frame(res) # unequal-length units are reproduced, not fixed-width table(table(syn$id)) # baseline stays constant within each synthetic patient all(tapply(syn$age, syn$id, function(a) length(unique(a)) == 1)) ``` ## Within-unit temporal constraints `rule()` with `scope = "unit"` evaluates a rule per unit, which is how you express temporal logic. Suppose visits must be numbered consecutively from 1 and `sbp` must stay in a plausible range on every visit: ```{r rules} res_c <- synth( real, ~ id / visit, constraints = list( rule(sbp >= 60 & sbp <= 260), # row scope (default) rule(all(diff(visit) == 1), scope = "unit") # consecutive visits ), seed = 1 ) syn_c <- as.data.frame(res_c) # every synthetic unit's visits are 1, 2, 3, ... all(tapply(syn_c$visit, syn_c$id, function(v) identical(v, seq_along(v)))) ``` Units that violate any rule are regenerated (bounded by `constraint_max_tries` in `synth_control()`), so the nested structure is never broken to satisfy a constraint. ## Diagnostics The utility and risk diagnostics work unchanged on long data. ```{r diagnose} diagnose(real, res, vars = c("age", "sex", "sbp")) ``` Because visits share a patient, treat the diagnostics as descriptive: the pMSE here mixes baseline and time-varying columns rather than modelling the dependence explicitly.