Fits a brms multivariate GAM for each imputation group (see
impute_group()), using a PCA-compressed water-quality (WQ) block as
additional environmental predictors. The engine itself is domain-agnostic:
the leachate-specific groups (a metals group and a catch-all organics
group) are supplied by the default groups = leachate_impute_groups() and
can be swapped for any other chemistry by passing your own list of
impute_group() objects. The returned model object is passed to
impute_chemistry() for prediction on new data.
Usage
fit_imputation_model(
df,
pca_vars = NULL,
required_vars = c("pH", "EC"),
groups = NULL,
exclude = NULL,
no_log_vars = NULL,
min_detect_freq = 0.05,
min_target_detect_freq = 0.05,
min_target_detect_n = 4L,
min_samples = 10L,
min_var_explained = 0.75,
max_pcs = 6L,
family = "gaussian",
impute_method = c("marginal", "rescor_mi", "cens", "cens_factor", "factor"),
iter = 2000,
warmup = 1000,
chains = 4,
cores = parallel::detectCores(),
k = NULL,
seed = NULL,
save_dir = NULL,
...
)Arguments
- df
Long-format chemistry data frame with columns
sample_id,site_id,datetime,analyte,value,detected.- pca_vars
Analyte names to include in the unified chemistry PCA (used as predictors for the brms model via PC scores). Default:
c("pH", "EC", "NH3-N")plus all.WQ_BLOCK_CANDIDATES. Normalisation co-analytes (DOC, Ca, Mg) are included in the default set.- required_vars
Analyte names that must be present in a sample for it to be retained in training and prediction. Default
c("pH", "EC"). Samples missing any of these are dropped entirely.- groups
A list of
impute_group()objects describing which analytes to impute and how each group is hurdled. DefaultNULLusesleachate_impute_groups()(ametalsgroup plus a catch-allorganicsgroup). Supply your own list to impute a different chemistry.- exclude
Analyte names that must never be modelled as response variables in any group (e.g. counts, qualitative descriptors). Default
NULLuses .IMPUTE_EXCLUDED.- no_log_vars
Analyte names that must not be log-transformed before the chemistry PCA (interval-scale or already-logarithmic variables such as pH and temperature). Default
NULLuses .PCA_NO_LOG_VARS.- min_detect_freq
Minimum detection frequency for a PCA variable to be retained. Default
0.05. Required vars are always retained regardless.- min_target_detect_freq
Minimum detection frequency (fraction of samples in which the analyte is detected) for a metal/organic to be included as an imputation target. Targets below this are dropped (they have too few detections to model and would otherwise inflate the model on near-all-BDL panels). Default
0.05. Combined withmin_target_detect_n(both gates must pass).- min_target_detect_n
Minimum absolute number of distinct samples in which a metal/organic is detected for it to be included as an imputation target. The fraction gate above already implies a count floor of
min_target_detect_freq * n_samples, but that scales with dataset size and collapses on small datasets; this absolute floor guarantees enough anchors to constrain the fit regardless of dataset size. Default4L(non-binding on typical panels, where the fraction gate dominates).- min_samples
Minimum training samples after required-var filtering.
- min_var_explained
Target cumulative variance for PCA axis selection. Default
0.75.- max_pcs
Maximum PCA axes to use. Default
6L.- family
brms response family. Must be
"gaussian"(concentrations are log-transformed before fitting; residual correlations require Gaussian family).- impute_method
How below-detection (BDL) values and cross-analyte coupling are handled. Defaults to
"marginal"— the fastest, best- calibrated method on the package benchmark; the others are retained for comparison and for panels with genuinely strong cross-analyte coupling. Seevignette("imputation")for the assumptions, trade-offs and benchmark. One of:"marginal"(default) Per-analyte left-censored
mgcv::gam(spline mean on the PC scores) with an independent Student-t posterior predictive – no cross-analyte borrowing. Uses mgcv only (no brms, no cmdstanr) and is ~400x faster than"rescor_mi". Best MAE and well-calibrated 90% coverage on the B.S01 benchmark; on that panel the cross-metal borrowing the other methods attempt does not help (it mis-conditions sparse analytes). Uncertainty is a proper posterior predictive: GAM parameter uncertainty (beta ~ N(coef, Vp), unconditional) plus residual-variance uncertainty (sigma^2drawn from its scaled-inverse-chi^2 posterior, giving the t-tails), with BDL cells truncated at the detection limit (no post-hoc cap)."rescor_mi"Residual correlation across analytes (
rescor = TRUE) with BDL/missing treated as imputable (mi()); the imputed BDL cells are capped at the detection limit post-hoc byimpute_chemistry()(brms cannot combinerescorwithcens()). Slightly better hold-out RMSE than"marginal"but at ~400x the cost and with over-wide, uninformative intervals; themi()+ correlation geometry is funnel-prone, soadapt_delta = 0.95and anlkj(2)prior on the residual correlation are set by default (override viacontrol/priorin...). The geometry stays hard (tree-depth saturation, low E-BFMI, worst-case R̂ ≈ 1.6 on a hard mask): trust the point estimate, but checkbrms::rhat()before relying on the draws."cens"Proper left-censoring of BDL at the detection limit (
cens("left")), no residual correlation – clean BDL handling but no cross-analyte coupling."cens_factor"Proper left-censoring with cross-analyte coupling. Fitted as a single long-format model with a shared per-sample latent factor
(1 | sample_id)common to all analytes, so an observed metal informs the unobserved/BDL ones at that sample. The factor is well-identified (each sample contributes several analyte observations);adapt_delta = 0.95is set by default to clear the factor's mild funnel (override viacontrolin...)."factor"(Route C) Low-rank left-censored latent factor model (
Sigma = Lambda Lambda' + Psi, rankk = 2by default), resolving findings 1-3 at the source: BDL cells are censored in the likelihood (no post-hoc cap), and the latent factor is inferred from a sample's observed metals at prediction time, so measured metals genuinely condition the missing/BDL ones. Fitted in two stages: a per-analytemgcv::gammean on the PC scores, then a Stan factor model on the residuals (needs cmdstanr). Seedev/plan-route-c.md.
See
vignette("imputation")and the package benchmark for guidance on which to prefer.- iter, warmup, chains, cores
brms MCMC settings.
- k
Number of latent factors, only used when
impute_method = "factor"(Route C).NULL(default) usesmin(2, J - 1)per group, whereJis that group's number of target analytes (capped belowJfor identifiability — seedev/plan-route-c.md). Choosingk = 2vsk = 3by held-out coverage is a deliberate validation step; raise it here once you have evidence a group's analytes need a second/third shared axis. Ignored (with a message) for a single-analyte group, which always falls back to a Stage-1-only marginal fit regardless ofk. Ignored by the brms methods.- seed
Optional integer seed, only used when
impute_method = "factor"(Route C): passed to the Stage-2 Stan sampler (cmdstanr's$sample(seed = ...)) for reproducible factor fits.NULL(default) samples with a random seed each call. The brms methods are already seedable viaseedin...(passed tobrms::brm()); this argument only affects the factor method.- save_dir
If non-NULL, save the returned model object as a
.qsfile in this directory usingqs2::qs_save().- ...
Additional arguments passed to the sampler. For the three brms methods they go to
brms::brm(); the Stanbackenddefaults to"cmdstanr"when the cmdstanr package and a CmdStan install are both available (cached compiled binaries + faster sampling, statistically equivalent to rstan), otherwise"rstan"; passbackend = ...here or setoptions(hydroSense.brms_backend = ...)to override. Forimpute_method = "factor"(Route C) there is no brms call —...is passed to cmdstanr's$sample()instead (e.g.adapt_delta,max_treedepth,parallel_chains), so brms-only arguments do not apply.
Value
A named list of class "imputation_model":
$pca: PCA fit + metadata (loadings, training medians, n_pcs,no_log_vars, …)$groups: a named list (one entry per fitted group, named by the group'sname); each entry has$fit(brmsfit),$analytes,$safe_names,$name,$hurdle. Empty if no group had any modellable analytes.$group_specs: the input list ofimpute_group()objects$required_vars,$pca_vars,$exclude,$impute_method$fit_date,$n_samples: metadata Ifsave_diris supplied, the path to the saved file is returned asattr(result, "save_path").
Details
Model structure
For each group, the mean structure is:
where PC* are the leading principal components of the unified chemistry
PCA (see Chemistry PCA below). A group's target analytes are modelled
jointly with rescor = TRUE, so observed analytes at a given sample
condition the posterior of the missing ones through the residual correlation
matrix. Separate groups are fitted as separate models and do not share
residual correlation.
Why rescor = TRUE — the PCA captures the instantaneous chemical
covariance structure (what is measured together at a single moment), but
it cannot capture the temporal-lag covariance characteristic of
AMD/leachate-impacted aquifers, where conservative tracers move ahead of
redox-controlled metals. At a post-pulse sample the PCA scores have
returned toward baseline but Cu/Pb/Zn/Mn remain elevated together — that
co-elevation is pure residual correlation with no predictor signal driving
it. rescor = TRUE is the right machinery for this and is what makes
multivariate imputation borrow strength across analytes.
Costs of rescor = TRUE — brms cannot combine set_rescor(TRUE) with
cens("left"), so this implementation uses mi() for BDL values and
applies a post-hoc cap (see impute_chemistry()). The cap clips imputed
BDL cells to the original detection limit when the model predicts above
DL. For sites where the chemistry context legitimately suggests high
concentrations the cap can fire frequently; results in that regime should
be inspected. Three alternative configurations are worth benchmarking on
real hold-out data if predictive performance becomes a concern:
rescor = TRUE+mi()(current; expected to win on plume-affected groundwater because cross-analyte residual coupling captures plume dynamics that the predictor PCA misses).rescor = FALSE+cens("left")(statistically clean for BDL; loses cross-analyte residual coupling).rescor = FALSE+cens("left")+ shared(1 | sample_id)(proper BDL handling with rank-1 latent-factor coupling across analytes). Benchmark methodology: mask 10% of detected cells, fit each configuration, compare hold-out RMSE / coverage.
Chemistry PCA
All pca_vars — major ions, pH, EC, NH3-N, DOC, nutrients, redox
indicators — that are present in df and pass a detection-frequency check
are submitted to nipals::nipals(), which handles within-sample missing
cells natively without prior imputation. Using a single unified PCA (rather
than separate driver + WQ-block sets) eliminates predictor collinearity and
ensures normalisation co-analytes (DOC, Ca, Mg) influence the imputed metal
concentrations. Principal components are added until cumulative variance
explained reaches min_var_explained or max_pcs is reached. A minimum
of min(2, available components) PCs is used — i.e. the floor of two
only applies when at least two components exist.
Hurdles (applied at prediction time by impute_chemistry())
Each group may carry a presence hurdle (see impute_group()): a sample is
only imputed for that group if it carries at least one of the hurdle
analytes (detected or BDL). Under the leachate preset the metals group is
hurdled on metal presence and the organics group on DOC-like presence.
BDL required variables
When a required_vars analyte (pH or EC) is below the detection limit for a
sample, the stored detection-limit value is used as-is (conservative upper
bound). A message is issued but the sample is retained.
Examples
if (FALSE) { # \dontrun{
# Requires a Stan toolchain (brms). Fit once, then reuse for imputation.
model <- fit_imputation_model(monitoring_long)
draws <- impute_chemistry(monitoring_long, model, return = "draws")
# A different domain: two custom groups instead of the leachate preset.
model2 <- fit_imputation_model(
monitoring_long,
groups = list(
impute_group("trace_metals", targets = c("As", "Cd", "Pb"),
hurdle = c("As", "Cd", "Pb")),
impute_group("nutrients", targets = NULL, hurdle = c("NO3-N", "P-total"))
)
)
} # }