Skip to content

Add time-varying covariate sampling, and anonymization function - #12

Open
roninsightrx wants to merge 20 commits into
mainfrom
feature/time-varying-mice-sampling
Open

Add time-varying covariate sampling, and anonymization function#12
roninsightrx wants to merge 20 commits into
mainfrom
feature/time-varying-mice-sampling

Conversation

@roninsightrx

@roninsightrx roninsightrx commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add various options for sampling of longitudinal covariates (LME, MICE, copulas)
  • clone observed covariate observation-time/update designs, with optional propensity-score matching without replacement
  • add dispatcher support via sample_covariates(method = "mice_timevarying")
  • add documentation and focused test coverage
  • add vignette

Tests

  • devtools::test() (396 passing, 0 failures)

@roninsightrx roninsightrx left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review — time-varying MICE sampling

Reviewed the diff and ran the real (unmocked) MICE transition path in R, since every test mocks both sample_covariates_mice and impute_mice_transition_step — so the actual mice path is never exercised end-to-end. Two crashes on realistic default-setting inputs that the mocked tests cannot catch (#1, #2 inline), plus efficiency/reuse cleanups.

Top priority: add at least one un-mocked end-to-end test through the real mice path.

Conventions clean: DESCRIPTION bumped 9010→9011, man/ regenerated and matches roxygen, NAMESPACE export added.

time_varying_covs
) {
lag_covs <- paste0(time_varying_covs, "_lag")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Crash (confirmed live): default measurement_pattern="change" + single-row subject + ≥2 time-varying covs.

sapply(profile[time_varying_covs], ...) over a 1-row profile returns a length-k vector with no dim. The is.null(dim(update)) branch reshapes it to a k×1 matrix, then colnames(update) <- time_varying_covs (line 481) assigns k names to 1 column → length of 'dimnames' [2] not equal to array extent.

Reproduced with a dataset mixing multi-row and single-row subjects and time_varying_covs=c("WT","SCR") — crashes with default settings. Force matrix shape explicitly (as the nonmissing/all branches do).

previous_time,
time_var,
static_covs,
time_varying_covs,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Crash / silent corruption (confirmed live): internal column names collide with user covariates.

delta_time (here), Type (line 545), and <cov>_lag are introduced as working columns with no guard. A user covariate named delta_time crashes with Can't transform a data frame with duplicate names; Type / X_lag (alongside a real X) silently overwrite or zero out a legitimate predictor.

Namespace these with a . prefix, matching the existing .previous_time.

current[active_subjects, ] <- active_current
replicate_rows[[time_idx]] <- active_current
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Efficiency: full mice::mice() refit every timepoint.

impute_mice_transition_step() is called once per time_idx inside this loop, and each call refits mice from scratch on the entire transition_data. The transition model X(t) ~ X(t-1), time, delta_time, statics is identical at every step.

With n=1000 over 20 timepoints that's 19 full chained-equation fits over ~20k rows instead of one reused fit. Inside impute_mice_transition_step the factor re-coercion and make.predictorMatrix/make.method are also rebuilt every step though the structure never changes.

method = method,
...
)
)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reuse: duplicated mice-runner block.

This re-implements the exact block from sample_covariates_mice (R/sample_covariates_mice.R:86-118): bind Original/Simulated via a Type column, make.predictorMatrix + zero Type, make.method, mice(m=1, ...) under suppressWarnings, then complete(action="long") |> filter(Type=="Simulated") |> select().

Extract a shared helper so the two paths can't drift (e.g. the tidyr::complete(action=) usage, Type bookkeeping, warning handling).

...
)

subject_profiles <- if (is.null(time_grid)) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Altitude: ... forwarded to two destinations; conditional not a real parameter.

... goes to both sample_covariates_mice (baseline) and mice::mice() (every transition step). sample_covariates(method="mice_timevarying", conditional=...) sends conditional into both — it works today only because mice() silently swallows unknown args, and transitions end up unconstrained. The other sample_covariates_* methods expose conditional as a formal parameter; this one should too.

make_mice_update_matrix <- function(
profile,
time_varying_covs,
measurement_pattern

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor efficiency: O(n²) propensity matching loop.

available <- setdiff(available, candidates) rebuilds the candidate set and recomputes abs(score) over the full remaining pool each iteration. With ~1000 observed subjects that's ~500k comparisons. A logical used mask (used[candidates] <- TRUE; subset by !used) makes it linear.

roninsightrx and others added 2 commits June 22, 2026 15:37
Address review on PR #12:

- Fix crash on default measurement_pattern="change" with single-row
  subjects and >=2 time-varying covariates: sapply() over a 1-row
  profile dropped the matrix dims; force explicit nrow/ncol so the
  colnames<- assignment no longer fails.
- Namespace internal working columns (.delta_time, .Type, .<cov>_lag)
  with a "." prefix, matching .previous_time, so they cannot collide
  with or silently overwrite user covariates.
- Extract shared run_mice_simulation() helper used by both
  sample_covariates_mice() and the transition sampler so the Type
  bookkeeping and predictor/method wiring cannot drift.
- Expose conditional as a formal parameter forwarded only to the
  baseline sample, instead of leaking through ... into mice::mice().
- Replace the O(n^2) setdiff-based propensity matching loop with a
  logical "used" mask.
- Add un-mocked end-to-end tests through the real mice path, plus
  regression tests for the single-row crash and internal-name
  collisions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@roninsightrx

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review — and especially for running the real mice path. Addressed in f2a75ba:

#1 — crash on measurement_pattern="change" + single-row subject + ≥2 covs. Fixed. make_mice_update_matrix() now reshapes the sapply() result with explicit nrow = nrow(profile)/ncol = length(time_varying_covs) instead of forcing ncol = 1, matching the nonmissing/all branches. Added a regression test mixing multi-row and single-row subjects with c("WT","SCR").

#2 — internal column name collisions. Fixed. delta_time.delta_time, Type.Type, and <cov>_lag.<cov>_lag, all .-prefixed like the existing .previous_time. Added a regression test with user covariates named delta_time and WT_lag.

#4 — duplicated mice-runner block. Extracted run_mice_simulation() (binds Original/Simulated via .Type, builds predictor matrix + method, runs mice(m, ...) under suppressWarnings, returns the completed Simulated rows). Both sample_covariates_mice() and the transition sampler now call it, so they can't drift.

#5... to two destinations / conditional not a real parameter. conditional is now a formal parameter forwarded only to the baseline sample_covariates_mice() call; transitions stay unconstrained by design (documented). It no longer leaks through ... into mice::mice().

#6 — O(n²) propensity loop. Replaced the setdiff-based available rebuild with a logical used mask.

Top priority — un-mocked end-to-end test. Added one that runs the full real mice path (baseline + sequential transitions, categorical covariate, time_grid) and asserts valid output with no internal columns leaking. Suite now 409 passing, 0 failures.

#3 — full mice::mice() refit every timepoint. Partially addressed and partially deferred. The transition is sequential — X(t) depends on the sampled X(t-1) — so the synthetic rows can't be batched into one call. Truly reusing one fit across timepoints needs the fitted transition model applied to new data, which base mice doesn't expose (no predict-on-new-data); doing it correctly means reimplementing pmm donor sampling outside mice, which I didn't want to slip into this PR. The structural rebuild (factor coercion, make.predictorMatrix/make.method) is now centralized in run_mice_simulation but still runs per step. Happy to open a follow-up issue to lift the donor model out of the loop if you think it's worth it.

roninsightrx and others added 3 commits June 23, 2026 10:06
Address review findings on PR #13:

- apply_simulated_concentrations: match FeRx output to observation rows by
  ID and TIME instead of positional copy, which could silently scramble
  concentrations when FeRx reorders rows
- apply_anonymize_lloq: guard against NA simulated DV (avoids all-NA rows on
  the drop path and a subscript error on the cens path)
- sample_covariates_mice_timevarying: reject design_id_var that collides with
  id_var, time_var, or a covariate name
- simulate_anonymized_concentrations: stop forcing a fixed seed of 42 when
  seed is NULL; draw independently instead
- build_anonymized_simulation_input: warn and skip mis-aligned subjects rather
  than aborting the whole run; match designs on character-coerced IDs; use
  pre-split lookups instead of per-subject full-table scans
- remove dead dictionary args and unused local bindings; use NA_character_
  for the time_grid profile placeholder
- add regression tests; bump version to 0.0.0.9013

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add sample_covariates_lme_timevarying(), a parametric counterpart to the
chained-equations sampler from #12. Each continuous time-varying
covariate is modelled with a per-covariate linear mixed-effects
transition model fitted to the observed first-order transitions:

  X_j(t) ~ X_1(t-1) + ... + X_p(t-1) + delta_time [+ time] + static + (1 | ID)

Lagged values of all time-varying covariates capture both
autocorrelation and between-covariate relationships; a subject-level
random intercept captures within-subject correlation, with fresh random
effects drawn per simulated subject and residual noise added at each
timepoint. The `trend` argument switches between a pure Markov/AR
transition ("previous") and adding an absolute-time fixed effect
("time").

Reuses the baseline sampling, observation-time profile, clone/propensity
design-matching and update-matrix machinery introduced in #12, so only
the transition engine differs.

Robustness:
- models fitted once and reused across replicates
- failed lme fit falls back to OLS (warns); unfittable covariate is
  carried forward (warns)
- random_intercept = FALSE uses OLS directly
- na.exclude in prediction skips rows with missing predictors instead of
  erroring, leaving those covariates unchanged

Adds the `lme_timevarying` dispatcher method, nlme to Imports, docs and a
test file (mocked structural tests plus end-to-end tests through the real
nlme path). Bumps version to 0.0.0.9013.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
roninsightrx and others added 5 commits June 23, 2026 14:42
Address two correctness bugs from review of #14:

1. A static categorical level that appears only in single-timepoint
   subjects is dropped from the transition data (those subjects
   contribute no transitions), so it is never a fitting-time factor
   level. The baseline sampler can still draw it, which made predict()
   abort the whole simulation with "factor has new levels". Now the
   fitting-time factor levels are stored on the models object and any
   unseen baseline level is mapped to NA at prediction time, so those
   rows carry forward instead of crashing.

2. predict.lme/predict.lm with na.action = na.exclude drop rows with
   missing predictors rather than padding them back to NA, so
   `mu + ranef + residual` silently recycled a short prediction vector
   against the full-length random-effect/residual vectors, writing
   values to the wrong subjects. Now we predict only on complete-
   predictor rows (via the stored predictor names) and leave the rest at
   their carried-forward value. Removed the incorrect na.exclude comment.

Add regression tests: a categorical level seen only at baseline, and a
direct simulate_lme_transition_step call with an NA lag predictor.
Bump version to 0.0.0.9014.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add time-varying linear mixed-effects covariate sampler
…ng' into feature/anonymize-nonmem-dataset

# Conflicts:
#	DESCRIPTION
ferx is a private repo; CI's repo-scoped GITHUB_TOKEN cannot install it,
which hard-fails pak dependency resolution for pkgdown and test-coverage.
ferx is only used via a requireNamespace-guarded ferx::ferx_simulate call
and is mocked in tests, so it is not needed for CI or the test suite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@roninsightrx roninsightrx changed the title Add time-varying MICE covariate sampling Add time-varying covariate sampling, and anonymization function Jun 23, 2026
roninsightrx and others added 9 commits June 24, 2026 15:52
Add copula-based covariate simulation (Zwep et al. 2024, CPT
doi:10.1002/cpt.3099) and obscuring/widening options for the existing
samplers.

New functions:
- sample_covariates_copulas(): cross-sectional vine-copula sampler
  (rvinecopulib, KDE marginals) reproducing the observed covariate
  dependence structure. Continuous covariates only.
- sample_covariates_copulas_timevarying(): longitudinal variant. Per
  time-varying covariate, fits a random-effects polynomial trajectory,
  fits a vine copula to the per-subject coefficients plus static
  covariates, then reconstructs simulated trajectories. Supports degree,
  family_set, selcrit, bw_mult, truncate, and output-level multiplicative
  noise.

Existing samplers:
- sample_covariates_bootstrap() gains cat_covs (categorical-aware
  conditional filtering) and noise (multiplicative log-normal jitter on
  continuous covariates).
- sample_covariates_mice_timevarying() and
  sample_covariates_lme_timevarying() gain baseline_method
  ("mice"/"bootstrap") and noise. Bootstrap baselines reproduce the
  observed joint baseline distribution instead of shrinking it toward the
  multivariate mean, addressing the too-narrow baseline distributions.

Shared apply_covariate_noise() helper; sample_covariates() dispatcher and
docs updated; rvinecopulib added to Suggests. Tests added for all new
behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Previously `noise` on sample_covariates_mice_timevarying() and
sample_covariates_lme_timevarying() was applied only to the baseline draw
(the first timepoint), so the bulk of the trajectory was unchanged and the
argument appeared to do nothing.

Move noise to the assembled long-format output via a shared
apply_timevarying_noise() helper: time-varying covariates are jittered
independently at every timepoint, static covariates once per subject (kept
constant over time). sample_tv_baseline() no longer jitters; the
standalone sample_covariates_bootstrap() keeps its own noise argument.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Round the sampled covariates and the simulated concentrations (DV) to
`sigdig` significant figures via signif() to further obscure the exact
values. Default 4; NULL disables. Applied before the lloq comparison so
censoring uses the rounded concentrations. Categorical/non-numeric
covariates are left untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Optionally draw several covariate samples and keep the one whose
distribution best matches the observed covariate data, before running the
(single) PK simulation.

- n_candidates (default 1 = unchanged): number of covariate samples drawn
  with derived reproducible seeds; the closest match is selected.
- similarity (default "energy"): population-level energy distance between
  observed and simulated per-subject covariate-trajectory feature matrices
  (baseline, mean, slope and within-subject SD per continuous covariate;
  per-subject time fraction in each level per categorical covariate),
  standardized by the observed spread. Near-constant features are dropped
  with a relative threshold to avoid catastrophic cancellation.

Scoring is distributional, not per-subject, so it does not pull individual
simulated subjects toward specific real subjects. The selected candidate's
score is returned as the "similarity_score" attribute.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add score_on to anonymize_dataset(), folding the PK simulation into the
candidate loop. "concentration" (default) fully simulates every candidate
and selects the one whose simulated (time, log-concentration) observation
cloud is closest (energy distance) to the observed concentrations.
"covariate" keeps the cheaper prior behaviour: score the covariate
trajectories and simulate only the selected sample once.

n_candidates = 1 is unchanged (single draw + simulate, no scoring).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace lloq/censoring with loq and blq_method = "remove"|"cens" (default
"remove"). "remove" drops below-LOQ observation rows; "cens" keeps them,
sets DV to 0, and flags them with CENS = 1 (0 otherwise) -- the prior
"cens" path left the simulated DV in place.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ulas)

Add a `method` argument selecting which time-varying covariate sampler
resamples the covariates: "mice" (default), "lme", or "copulas". To make
the LME and copula samplers usable in the anonymization pipeline, give
both a `design_id_var` argument that records, per simulated subject, the
observed subject whose observation-time design was assigned/cloned -- the
`.design_id` bookkeeping that build_anonymized_simulation_input() needs to
attach the dosing/observation events.

Candidate sampling is routed through a shared draw_anonymize_candidate()
dispatcher used by both the concentration- and covariate-scoring paths.
"lme" and "copulas" support only continuous covariates and error early on
categorical ones.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add copula covariate samplers; bootstrap baseline and noise options
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant