BaronWolfenstein/causal_bench

Monte Carlo benchmarking of causal estimators for clinical trial survival data — censoring, positivity, collider bias, competing risks, RMST.

0

stars

431

commits

Python

primary language

Aug 5, 2026

updated

causal-inference
clinical-trials
monte-carlo
python
survival-analysis
tmle

README

causal_bench

Monte Carlo benchmarking of causal estimators for clinical trials with survival outcomes.

Generates synthetic randomized and observational trial data under controlled assumption violations — informative censoring, positivity violations, unmeasured confounding, time-varying post-treatment confounders, treatment crossover, enrollment drift, competing risks, and stratified randomization — then measures each estimator's bias, RMSE, coverage, and SE calibration across 38 experiments.

The core finding: the "right" estimator depends entirely on what's wrong with your data. This framework makes that concrete.

Designed for biostatisticians working on device trials (ENCIRCLE-scale, n≈700) who need to decide between TMLE, IPCW, LTMLE, and McCoy's concrete package. Estimand coverage includes risk difference, RMST difference, win ratio, restricted mean time in favorable state (RMT-IF), priority-standardized net benefit, and — for ordinal patient-reported outcomes — the cumulative log-OR.

ENCIRCLE's pre-specified primary estimand (Guerrero et al., Lancet 2025, SAP Section C) is a non-hierarchical KM composite event rate at 1 year tested against a 45% performance goal (one-sided Wald/Greenwood, α = 0.025) — not a hierarchical composite or hazard model. The synthetic control arm (SCA) augments this with an external TVT Registry comparator; the two estimands are reported side by side in exp16_encircle_calibrated.py.


Quick start

git clone <repo> && cd causal_bench
pip install -e ".[dev,storage]"        # storage adds pyarrow for result persistence
pip install -e ".[bayes]"              # bambi/PyMC for the ordinal CLMM estimator (optional)

# Single scenario, 100 sims, MVP estimators
python -m causal_bench --scenario edwards_realistic --n-sims 100

# With diagnostics and sensitivity flags
python -m causal_bench --scenario edwards_realistic --n-sims 100 \
    --diagnostics --tipping-point --ess --convergence --overlap-map

# Export last sim's data for R/concrete benchmarking
python -m causal_bench --scenario edwards_realistic --n-sims 50 --export-r

# Full experiments (each ~2–5 min on 8 cores at n_sims=200)
python experiments/exp1_censoring.py --n-sims 200
python experiments/exp2_positivity.py --n-sims 200
python experiments/exp3_unmeasured.py --n-sims 200
python experiments/exp7_edwards.py    --n-sims 200
python experiments/exp8_mccoy.py      --n-sims 200   # R + concrete required for concrete_RMST
python experiments/exp11_strata.py    --n-sims 200   # R + concrete required for SE correction

Estimators (30)

Risk difference estimators (Python)

KeyMethodDRIPCWNotes
naiveUnadjusted mean differenceMaximum bias under informative censoring
kmKaplan-Meier risk differenceMarginal, no covariate adjustment
coxCox G-computationBiased under informative censoring
cox_l1Cox + L1 covariate⚠ Intentionally biased — collider trap (Exp 5 only)
ipwHorvitz-Thompson IPWWeight truncation at 1st/99th pct
overlapOverlap weightingTargets ATO; stable near positivity violations
aipwAugmented IPWDoubly robust, no targeting step
tmle_ipcwTMLE + IPCWOne-step Newton targeting, cross-fitted IC
tmle_ipcw_complyTMLE + IPCW + complianceCompliance score in censoring model
ltmleLongitudinal TMLESequential regression over L1; no collider bias
tmle_ipcw_cvCV-TMLE (cross-fitted)Cross-fitted censoring model → calibrated SE at finite n
tmle_ipcw_cv_complyCV-TMLE + complianceCross-fitted, with compliance score in the censoring model
tmle_ipcw_bootTMLE + IPCW (bootstrap SE)Nonparametric bootstrap SE instead of the IC-based SE

RMST estimators (R bridge — requires concrete)

KeyMethodNotes
concrete_RMSTDirect RMST targetingMcCoy (2026); iid SE
concrete_RMST_strataDirect RMST + BCS SE correctionBugni-Canay-Shaikh / Ye-Shao correction for stratified randomization (concrete PR #29)
rmst_k2 / k5 / k10 / k20Pointwise-then-integrate RMSTBias O(1/K); K=20 near-exact
concrete_simultMulti-horizon RMST, simultaneous bandsJoint TMLE across horizons (e.g. t=1,2) with simultaneous confidence bands

Win ratio estimators (R bridge — requires concrete)

KeyMethodNotes
concrete_WR_directWin ratio direct TMLEtargetWinRatio() (McCoy PR #30); jointly fluctuates both arms' cause-specific hazards
concrete_WR_pluginWin ratio plug-ingetWinRatio() after doConcrete(); ~5× more bias than direct

True win ratio benchmark: compute_true_win_ratio(config) computes P(T₁>T₀)/P(T₁<T₀) via U-statistic on 50k potential-outcome pairs using np.searchsorted (O(n log n)).

Clinical composite estimators (R bridge — requires concrete)

KeyMethodNotes
clinical_RMTIFRestricted mean time in favorable stateclinicalRMTIF() multistate engine (concrete PR #33)
clinical_PSNBPriority-standardized net benefitclinicalPSNB() priority-ranked win-ratio variant (concrete PR #34)

HTE / subgroup estimators (CATE)

KeyMethodNotes
effectxshiftPost-selection HTE subgroupsMcCoy's EffectXShift R package; CV-TMLE subgroup effects
bcf_bartBCF/BART CATEBayesian Causal Forest + rpart summary tree (Hahn et al. 2020)

Ordinal PRO estimator (Bayesian, Python)

KeyMethodNotes
clmm_ordinalBayesian cumulative-link mixed modelbambi/PyMC; partial pooling (random site intercept); posterior credible intervals; surfaces the site SD (τ) in convergence_info. Needs pip install -e ".[bayes]". Assumption-adversary to the PRO win ratio — the head-to-head benchmark (exp25) is gated on concrete#36
clmm_ordinal_slopeCLMM, (A | site) random slopeCorrelated random intercept and treatment slope per site; surfaces τ_A. The stronger adversary when the DGP has site-varying treatment effects
clmm_ordinal_nopoolCLMM, no pooling (fixed site effects)Reference arm: one intercept per site, no shrinkage
clmm_ordinal_cpoolCLMM, complete pooling (no site term)Reference arm: ignores site clustering entirely

Experiments (38)

ScriptSwept parameterKey storyEstimators
exp1_censoring.pycensoring_informativeness 0→1Naive/KM degrade; TMLE+IPCW stays flatMVP
exp2_positivity.pypositivity_severity 0→3IPW weight variance explodes; overlap stays stableAll Python
exp3_unmeasured.pyunmeasured_confounding_strength 0→0.8ALL estimators biased — identification failureAll Python
exp4_crossover.pycrossover_rate 0→0.3ITT attenuation; IPCW censoring at crossover helpsMVP
exp5_collider.pycollider_strength 0→1Opposite-direction biases; only LTMLE correctcox, cox_l1, ltmle, tmle_ipcw
exp6_drift.pyenrollment_drift 0→0.5Learning-curve bias; Cox/TMLE adjust, KM/naive don'tMVP
exp7_edwards.pyEdwards scenarios (3)Full benchmark, realistic device trial conditionsAll Python
exp8_mccoy.pyRMST grid density K=2–20Direct targeting eliminates discretisation biastmle_ipcw, aipw, ltmle, rmst_k2–k20, concrete_RMST
exp9_sample_size.pyn 100→2000Where TMLE asymptotics hold for ENCIRCLE (n=700)MVP
exp10_win_ratio.pyDirect TMLE cuts WR bias ~5× vs plug-inconcrete_WR_direct, concrete_WR_plugin
exp11_strata.pyBCS SE correction narrows CIs under stratified RCTconcrete_RMST, concrete_RMST_strata, tmle_ipcw

Extended experiments (design & operating-characteristics studies):

ScriptFocus
exp12_simultaneous.pySimultaneous coverage across a multi-estimand family
exp13_censoring_sweep.pyCensoring mechanism sweep (MAR / MNAR / informative)
exp14_synthetic_augmentation.pyProvenance-linked synthetic augmentation — cross-fitting independence
exp15_sequential_monitoring.pySequential CED monitoring — anytime-valid vs alpha-spending vs naive
exp16_encircle_calibrated.pyENCIRCLE-calibrated replication — 14 estimators vs published marginals
exp17_transport.pyTransport decomposition — trial-to-commercial generalizability
exp18_hawthorne.pyHawthorne decomposition — durable vs transient monitoring artifact
exp19_hierarchical_oc.pyHierarchical borrowing operating characteristics
exp20_tipping_point_borrowing.pyTipping-point sweep × borrowing strength
exp21_hte_subgroup.pyHTE subgroup benchmark — EffectXShift CV-TMLE vs BCF/BART posterior tree
exp22_mbias_sensitivity.pyM-bias sensitivity — adjusting for a pre-treatment collider introduces bias (coverage → 0); collider-aware backdoor set stays unbiased. Estimand-side handling of the zero-flow-CI collider caveat
exp24_site_clustering.pySite clustering in registry comparator — undercoverage demonstration
exp25_ordinal_pro.pyWin ratio vs Bayesian CLMM on ordinal PROs — efficiency & coverage under PO violation
exp26_user_sim_detection.pyExogenous-shock detection in a user simulator
exp27_dialogue_mnar.pyMNAR turn-missingness in dialogue (exp13 sibling)
exp28_q2_adaptation.pyQ2 three-arm adaptation contrast
exp29_balance_diagnostics.pyBaseline balance + region-R overlap diagnostics for a weighted SCA
exp30_registry_balance.pyRegistry-path balance table with HAL propensity, R-as-output
exp31_covariate_measurement_error.pyCovariate measurement-error sensitivity (Σ_x) — residual confounding
exp32_clever_covariate_me.pyΣ_x measurement error propagated into the TMLE clever covariate
exp33_donsker_learners.pyDo Donsker-class learners license AIPW/TMLE without cross-fitting?
exp33b_donsker_nuisance_tmle.pyTMLE+IPCW with Donsker-class nuisance learners (phase-2 wiring)
exp36_three_level_fidelity.pyTwo-vs-three-level OC fidelity (#40): conjugate two-level kernel (drops between-subgroup τ) vs three-level BHM via MCMC (PyMC + NumPyro/JAX). Shows the conjugate approximation is anti-conservative under heterogeneity. --dgp exp19 runs on exp19's real registry DGP. Requires the 3.12 [bayes]+[bayes-gpu] stack
exp37_compounding_shift.pyCompounding covariate shift — unmeasured confounding × enrollment drift
exp38_frozen_model_shift.pyPositivity/propensity under train-vs-deploy covariate shift
exp39_zero_flow_ci.pyZero-flow conditional-independence test + Markov-blanket recovery
exp39_ci_calibration.pyType-I / power calibration of the zero-flow CI test
exp41_borrowing_calibration.pyBorrowing calibration (#144): identifiability-set tau_sd OCs on the joint DGP with BP-decoded labels. Policies flat / oracle / canonical / empirical (fixed van Zwet CDSR τ prior) over level × θ₀ × K × scenario; multi-GPU sharded via scripts/exp41_multigpu.py. Requires the 3.12 [bayes] stack
exp34_pooled_q_rmst.pyPooled-Q subgroup estimator (#77 event rate + #189 RMST + #188 RP-spline nuisance): (A) under within-subgroup informative censoring the IPCW-adjusted RMST corrects the covariate-dependent censoring KM ignores; (B) borrowing lowers RMSE for the event rate but is a wash for RMST (efficient targeting makes the RMST point robust to the initial nuisance); (C) logistic vs RP-spline (flexsurvspline) nuisance under crossing hazards. Self-validating against MC truth
exp43_mmrm_mnar.pyMMRM (REML, unstructured) under MNAR dropout: unbiased in the MAR control, bias growing monotonically with the MNAR channel; IPCW-observed fails identically (nothing recovers MNAR from observables) while IPCW-oracle recovers, identifying the unobserved-dropout channel. Bias reported paired against a complete-data benchmark
exp42_hazard_selection.pyBuilt-in selection bias of the hazard ratio: randomized A + unobserved Gamma frailty ⇒ the Cox HR attenuates toward the null and drifts early→late, while KM risk-difference and RMST stay unbiased on the same replicates. Closed-form marginal truths make it self-validating

Other components

Beyond the estimator/experiment suite, the package includes supporting subsystems:

Module / scriptWhat it is
causal_bench/diagnostics/localization.pyRare-detail localization decision procedure (Tests A/B/B′/B″/C) for the synthetic-control-arm architecture; CPU-only. Demo: experiments/demo_localization.py
causal_bench/sampling/Twisted-diffusion SMC core with IPCW survival-weight bookkeeping (numpy, CPU-first; multi-GPU port specced in the A100 deployment spec). Demo: experiments/demo_smc_ipcw.py
causal_bench/detectors/zero_flow_ci.pyZero-flow conditional-independence test + Markov-blanket recovery (numpy/sklearn, no torch). Note the collider caveat: the MB includes collider-induced spouses and the CI oracle is fooled by a collider in the conditioning set, so the MB is never an adjustment set — see exp22_mbias_sensitivity.py
causal_bench/diagnostics/struct_s.pySTRUCT-S stratification battery — S1 event-aligned displacement bimodality (decisive; size-invariant Z-Dip principle), S2 spectral eigengap, S3 local intrinsic dimension, S4 MST density gap. Flags whether an embedding is stratified / event-jump-structured before geometry-aware modelling. CPU. Demo: experiments/demo_struct_s.py
causal_bench/generative/tangent_dsm.pyTangent-space-penalty DSM + gap-sampler on synthetic curved manifolds (arc R², Swiss roll R³, helix R³ codim-2) with learned-metric (local-PCA) normals — closed-form, numpy-only. Cuts gap-region off-manifold error ~70–90% vs plain DSM. Demo: experiments/demo_tangent_dsm.py
causal_bench/generative/score_net.py, checkpoint.pyTorch DDPM score net (diffuse_directly T8) + training checkpoints (save/load/resume/rollback; cross-device-safe). Device-agnostic (resolve_device cuda→mps→cpu); [gpu] extra, lazy-imported
causal_bench/estimators/three_level_bhm.pyThree-level BHM (PyMC + NumPyro/JAX) + two-vs-three-level OC fidelity harness (#40, exp36). [bayes]+[bayes-gpu], 3.12-only
causal_bench/diagnostics/tree_reconstruction.pyBroadcasting-on-trees reconstruction (Sclocchi–Favero–Wyart / Kesten–Stigum): magnetization order parameter, susceptibility, linear-stability multiplier b·λ²; q-ary Potts + KS-vs-reconstruction gap; D3PM large-K note. numpy, exact belief propagation (no torch)
causal_bench/diagnostics/rhm_grammar.pyRandom Hierarchy Model grammar + exact rule-BP — the canonical SFW class-overlap phase transition (#131): FSS collapse + exponent (#136), density-evolution threshold, structured / grammar-aligned (product-grammar) corruption channels (#138). numpy
causal_bench/diagnostics/hierarchy_probe.pyGaussian multiscale crossover probe (phenomenological, honestly not BP): coarse/fine MAP-recovery transition along the VP-SDE — the embedding-channel diagnostic used where no discrete grammar is available
causal_bench/diagnostics/theta_time_map.pyθ ↔ VP-SDE-time mapping (#137): token channel (closed-form θ = alpha_bar(t)) vs frozen-encoder embedding channel; class-overlap order parameters (nearest-class-mean + linear-probe posterior)
causal_bench/diagnostics/borrowing_informativeness.pyPer-level identifiability report → manual hierarchical-borrowing tau_sd suggestions (#137): embedding t_star map + correctly-signed canonical decode-accuracy map (#144). Informs, does not set shrinkage (that stays the hierarchical fit's job)
causal_bench/dgp/joint_hierarchy.pyJoint hierarchical DGP (#144 prereq): product-grammar identifiability (exact rule-BP thresholds) + per-level effect heterogeneity + coupling knob + BP-decoded subgroup labels at working corruption θ₀. numpy
causal_bench/estimators/projected_clever.pyProjected clever covariate (#182): `E[H(A,W_true)
causal_bench/validation/joint_fidelity.pyBorrowing-calibration fidelity engine (exp41/#144): BP-decoded-labels pipeline → per-subgroup summaries → three-level BHM under a τ-prior policy; reports coverage / CI-width / decode-accuracy OCs
causal_bench/estimators/mmrm.pyMMRM by REML with an unstructured within-subject covariance (log-Cholesky parameterisation, GLS-profiled beta, L-BFGS-B). Implemented directly — statsmodels.MixedLM fits random effects (compound symmetry at best), and a random-intercept model is not an MMRM
causal_bench/validation/mnar_dropout.pyMNAR-dropout falsification harness (exp43): longitudinal DGP whose dropout depends on the unrecorded outcome, with MAR control, IPCW-oracle and IPCW-observed arms
causal_bench/validation/hazard_selection.pyHazard-ratio built-in-selection-bias harness (exp42): Gamma-frailty survival with closed-form marginal survival / HR / RMST truths; Cox vs KM risk-difference vs RMST on shared replicates. Evidence for preferring cumulative-risk estimands
causal_bench/validation/pooled_q_rmst.pyPooled-Q subgroup OC harness (exp34): single-arm survival DGP with MC truths for both estimands; IPCW-vs-KM under within-subgroup informative censoring, pooled-vs-subgroup-only borrowing, and logistic-vs-RP-spline nuisance under crossing hazards
causal_bench/validation/rct_blinding.pyRCT-blinding validation of the OC-sim / synthetic comparator (#139): does the counterfactual control branch recover a held-out RCT's effect / survival curves? Flags naive or unmeasured-confounded comparators. Generator-agnostic, numpy

Numbering note. The count is built experiment scripts — exp39 ships two (exp39_zero_flow_ci.py, exp39_ci_calibration.py), so 40 distinct numbers → 41 files. Experiment numbers are non-contiguous; several are claimed by open candidate issues but not yet built:

NumberStatus
exp22Built — M-bias sensitivity (exp22_mbias_sensitivity.py, #104)
exp23Immortal-time-bias honest-null — design-level, unbuilt (#21; renumbered from exp22)
exp34Built — pooled-Q subgroup event rate (#77) + RMST (#189) + RP-spline nuisance (#188) (exp34_pooled_q_rmst.py)
exp35App-cohort second comparator (IPCW-light) — candidate, unbuilt (#71)
exp36Built — two-vs-three-level OC fidelity (exp36_three_level_fidelity.py, #40); reclaimed from the released z_anatomy slot (#73 dropped its exp number)
exp40Hypothetical-estimand bake-off under intercurrent events (Bartlett & Daniel 2026) — specced, unbuilt (#89)
exp41Built — borrowing calibration (exp41_borrowing_calibration.py, #144)
exp42Built — hazard-ratio built-in selection bias vs cumulative-risk estimands (exp42_hazard_selection.py, #181)
exp43Built — MMRM under MNAR dropout (exp43_mmrm_mnar.py, #183)

Key findings

Exp 1 (censoring): Naive and KM bias grows monotonically with censoring_informativeness. TMLE+IPCW stays near zero. Adding compliance to the censoring model gives further advantage in the MNAR regime (informativeness > 0.5).

Exp 2 (positivity): IPW SE inflates and coverage collapses at positivity_severity ≥ 2. Overlap weighting targets a different estimand (ATO) and is robust by construction. TMLE+IPCW degrades less than IPW but is not immune.

Exp 3 (unmeasured confounding — THE HONESTY EXPERIMENT): Every estimator is biased. The bias grows linearly with unmeasured_confounding_strength regardless of how sophisticated the method is. Negative control outcome bias tracks primary outcome bias, confirming U as the source. Semiparametric efficiency is irrelevant when identification fails.

Exp 4 (crossover): As-treated analysis attenuates the apparent effect as crossover_rate rises. TMLE+IPCW censors at crossover and partially recovers the per-protocol effect. The compliance-based censoring model (TMLE+IPCW+comply) gives further improvement because compliance predicts who will switch.

Exp 5 (collider trap): At high collider_strength, Cox without L1 is biased toward the null (omitted-variable bias) and Cox with L1 is biased away from the null in the opposite direction (collider bias). There is no correct naive choice — both versions of Cox are wrong, in opposite directions. LTMLE marginalises over L1 rather than conditioning on it and stays near unbiased.

Exp 6 (enrollment drift): Under learning-curve conditions, early enrollees have worse outcomes independent of treatment. KM and naive diverge as enrollment_drift rises because they don't adjust for enrollment time. Cox and TMLE include enrollment_time as a covariate (the Senn fix) and maintain near-zero bias.

Exp 7 (Edwards combined): Under edwards_realistic, LTMLE and TMLE+IPCW have the smallest bias and best coverage. IPW and AIPW degrade under positivity stress. Naive and KM are unreliable across all but the optimistic scenario.

Exp 8 (McCoy RMST): Direct RMST targeting via concrete eliminates discretisation bias accumulated by pointwise estimators at coarse grids. The concrete_RMST estimator shows residual bias against the benchmark ATE, but this is an estimand mismatchcompute_true_effects() returns an all-cause counterfactual risk difference, while concrete_RMST estimates the cause-specific CIF difference for event 1. Python TMLE/AIPW treat competing events as independent censoring, inflating cause-1 risk toward the all-cause number. In a single-event scenario the three estimators converge.

Exp 9 (sample size): On edwards_realistic (the hardest scenario), TMLE+IPCW approaches near-unbiasedness only at n ≥ 700. At n=100 the Super Learner has too little data for nuisance model quality. Naive/KM bias is invariant to n — no asymptotic rescue for misspecification.

Exp 10 (win ratio): concrete_WR_direct (McCoy PR #30 — targetWinRatio()) cuts win ratio bias ~5× relative to the plug-in approach by solving the win/loss EIF estimating equations jointly rather than substituting targeted risk curves into the win functional.

Exp 11 (stratified SE correction): Under stratified block randomization (W2 × W4, block size 4), the iid SE is conservative (se_ratio > 1, wide CIs). The BCS-corrected SE from concrete_RMST_strata restores calibration (se_ratio ≈ 1) while maintaining coverage ≥ 0.95. The power gain is meaningful at trial-scale n.


Data-generating process

AFT model with Gumbel noise (Weibull survival), unmeasured confounder U, post-treatment time-varying variable L1, informative censoring, optional competing risks, and optional stratified block randomization:

W1 ~ N(0,1)   W2 ~ Bern(0.5)   W3 ~ N(0,1)   W4 ~ Bern(0.3)   U ~ N(0,1) [latent]

Treatment (default Bernoulli):
  logit P(A=1|W,U) = logit(prev) + 0.3W1 + 0.2W2 - 0.2W3 + 0.1W4
                   + 0.5U·unmeasured_strength + 0.8W1·W3·positivity_severity

Treatment (stratified block, when strata_cols set):
  Permuted blocks of size strata_block_size within W2×W4 strata

Survival time (AFT):
  log T = 0 + 0.4W1 - 0.3W2 + 0.2W3 - 0.2W4 + 0.3U + τA
        + enrollment_drift·enrollment_time + nonlinearity·(W1²-1)
        + heterogeneity·A·W1 + ε     ε ~ Gumbel(0,1)

Post-treatment confounder (when collider_strength > 0):
  L1 = 0.5A + 0.4W3 + 0.3U·collider_strength + noise   (observed at t_L1)

Censoring:
  log C = 1.5 - 0.2W1 + 0.1W3 - 0.1A + 0.4U·censoring_informativeness
        + MNAR component for informativeness > 0.5

Competing risks (when enabled):
  log T2 = 0.3 + 0.2W1 - 0.1W3 + 0.2U + cause2_effect·A + ε2
  First event (T, T2, C, horizon) determines observed time and event type.

True ATE/ATT: G-computation on n=50,000 with shared Gumbel noise. True RMST: same population, trapezoidal integration. True win ratio: U-statistic on 50k potential-outcome pairs via np.searchsorted.

Ordinal PRO DGP (dgp/ordinal_pro.py)

A separate thresholded-latent cumulative-logistic model for an ordinal patient-reported outcome (NYHA I–IV, KCCQ tertiles), used by the ordinal-PRO benchmark (issue #26):

P(Y<=j | W,A,site) = logistic(c_j + δ_site,j − f(W) − b_site − τ_eff,j·A),   j = 1..K-1
  f(W)      = 0.4W1 − 0.3W2 + 0.2W3 − 0.2W4     (shared with the survival DGP)
  b_site    ~ N(0, σ²_site)                      site random intercept (ICC-parameterized)
  τ_eff,j   = τ + offset_j                       proportional odds ⟺ offsets/floor/ceiling = 0

Both PO-respecting and PO-violating modes are supported so a Bayesian CLMM (targets the cumulative log-OR) and a GPC win ratio (targets the ordinal win ratio) can each be scored against known truth via compute_true_cumulative_logOR and compute_true_ordinal_win_ratio. Emits an ordinal_pro marker column consumable by ConcretePROWinRatioEstimator. Full writeup in the index.qmd Replication Data appendix.


Scenarios

Scenarioncensor_infopositivitycolliderunmeasurednotes
clean5000.00.00.00.0Baseline
censor_mild5000.30.00.00.0
censor_moderate5000.60.00.00.0
censor_severe5001.00.00.00.0
positivity_mild/moderate/severe5000.01/2/30.00.0
unmeasured_mild/mod/strong5000.00.00.00.2/0.5/0.8
edwards_optimistic7000.30.50.20.1
edwards_realistic7000.61.50.40.2ENCIRCLE-like
edwards_pessimistic7000.92.50.70.4
competing_risks_base6000.30.00.00.0event_type ∈ {0,1,2}
stratified_base5000.00.00.00.0W2×W4 strata, block=4

R integration (concrete)

# Install concrete (McCoy's package — includes PR #29 BCS correction, PR #30 win ratio, PR #33 RMT-IF, PR #34 PSNB)
remotes::install_github("blind-contours/concrete", upgrade = "always")
install.packages(c("reticulate", "data.table"))

# SuperLearner libraries (may not be auto-installed from concrete's DESCRIPTION):
install.packages(c("glmnet", "ranger", "xgboost", "hal9001"))

# Direct use from RStudio via reticulate
source("r_scripts/concrete_bridge.R")
library(reticulate)
use_virtualenv(".venv")
cb  <- import("causal_bench.dgp.survival")
cfg <- import("causal_bench.dgp.config")$DGPConfig(n=600L, competing_risks=TRUE)
df  <- as.data.frame(cb$generate_data(cfg))
df$event_type <- as.integer(df$Delta)

result      <- run_concrete_bridge(df, horizon=1.0)           # RMST (iid SE)
result_bcs  <- run_concrete_bridge(df, horizon=1.0,           # RMST + BCS SE
                                   strata_cols=c("W2","W4"))
result_wr   <- run_concrete_win_ratio(df, horizon=1.0,        # win ratio (direct TMLE)
                                       method="direct")

From Python (requires pip install -e ".[r]"):

from causal_bench.estimators.concrete_rmst import ConcreteRMSTEstimator
from causal_bench.estimators.concrete_win_ratio import ConcreteWinRatioEstimator

# Returns [] with warning if R unavailable — experiments handle this gracefully
rmst = ConcreteRMSTEstimator().estimate(df)
rmst_bcs = ConcreteRMSTEstimator(strata_cols=["W2", "W4"]).estimate(df)
wr   = ConcreteWinRatioEstimator(method="direct").estimate(df, estimand="WR")

Diagnostics

All functions live in causal_bench.diagnostics. CLI flags activate them automatically after a run.

Always-on (via --diagnostics)

FunctionOutput
plot_overlap(df)Propensity score histogram by arm, extreme weight %, ESS
plot_love(df)Love plot: |SMD| before/after IPW weighting
plot_se_calibration(results)Scatter: empirical SE vs median reported SE per estimator

Flag-enabled

CLI flagFunctionOutput
--tipping-pointtipping_point_table, plot_tipping_pointAdditive bias needed to explain away each estimate
--essess_across_sims, plot_ess_distributionIPW ESS histogram across 50 draws; flags ESS < 50% of n
--convergenceconvergence_tableIC-based TMLE convergence: ic_mean (≈ε), ic_sd, ic_ratio per estimator
--overlap-mapplot_overlap_map"Who are we borrowing for?" — treated patients in (W1,W3) space, size ∝ 1/g, control density in background
--mnar-tipping-pointtipping_point_mnar, plot_tipping_point_mnarMNAR sensitivity grid: imputes censored outcomes across (δ_treated, δ_control) grid
--export-rexport_for_rCSV + metadata JSON for loading into R/concrete directly
python -m causal_bench --scenario edwards_realistic --n-sims 100 \
    --diagnostics \
    --tipping-point \
    --ess \
    --convergence \
    --overlap-map \
    --export-r

MNAR and concrete sensitivity (Python API)

from causal_bench.diagnostics import (
    tipping_point_mnar, plot_tipping_point_mnar,
    tipping_point_concrete, plot_tipping_point_concrete,
    convergence_table, plot_overlap_map, export_for_r,
)

# MNAR sensitivity grid
r = tipping_point_mnar(df, "km", horizon=cfg.horizon, n_grid=15)
plot_tipping_point_mnar(r, save_path="tipping_mnar.png")
r.to_parquet("tipping_mnar.parquet")    # attrs (MAR reference) survive roundtrip

# concrete MAR sensitivity (requires R + concrete)
r2 = tipping_point_concrete(df, horizon=cfg.horizon, deltas=[0, 0.05, 0.10, 0.15, 0.20])
plot_tipping_point_concrete(r2, save_path="tipping_concrete.png")
print(f"Tipping delta: {r2.attrs['tipping_delta']:.2f}")

# TMLE convergence (IC-based; no re-run needed)
conv = convergence_table(df, estimator_names=["tmle_ipcw", "tmle_ipcw_comply"])
print(conv)

# Overlap map
plot_overlap_map(df, save_path="overlap_map.png")

# Export for R/concrete benchmarking
paths = export_for_r(df, cfg, out_dir="results/export")

Win ratio estimand

from causal_bench.dgp.survival import compute_true_win_ratio
from causal_bench.estimators.concrete_win_ratio import ConcreteWinRatioEstimator

# True win ratio: P(T_treated > T_control) / P(T_treated < T_control)
wr_true = compute_true_win_ratio(cfg)
# Returns: {"ATE": wr, "ATT": wr_att, "p_win": ..., "p_loss": ..., "net_benefit": ...}

# Estimate (requires R + concrete PR #30)
est = ConcreteWinRatioEstimator(method="direct")   # or method="plugin"
results = est.estimate(df, estimand="WR")

Sign convention: true_tau=-0.5 shortens T → T₁ < T₀ → WR < 1 (treated loses more often). Opposite sign from risk difference.


Stratified randomization

from causal_bench.dgp.config import DGPConfig
from causal_bench.dgp.survival import generate_data

# Permuted-block randomization within W2×W4 strata (4 strata, block size 4)
cfg = DGPConfig(n=500, strata_cols=("W2", "W4"), strata_block_size=4)
df  = generate_data(cfg)
# df.attrs["strata_cols"] == ["W2", "W4"]  — passed automatically to concrete bridge

# BCS SE correction: pass strata_cols to ConcreteRMSTEstimator
from causal_bench.estimators.concrete_rmst import ConcreteRMSTEstimator
est = ConcreteRMSTEstimator(strata_cols=["W2", "W4"])
results = est.estimate(df)

Strata are defined by binarising each column at its median (binary columns need no transformation). Two strata_cols → 2² = 4 strata. Within each stratum, half-and-half blocks are shuffled; a partial final block gets Bernoulli draws.


IC bootstrap CIs

TMLE+IPCW, LTMLE, and AIPW store influence curve values on EstimatorResult.ic. These can be bootstrapped cheaply without re-fitting:

from causal_bench.bootstrap import ic_bootstrap_ci

result = TMLEIPCWEstimator().estimate(df)[0]
lo, hi = ic_bootstrap_ci(result, B=2000, method="bca")         # bias-corrected + accelerated
lo, hi = ic_bootstrap_ci(result, B=2000, method="t")           # Studentized
lo, hi = ic_bootstrap_ci(result, B=2000, method="percentile")  # plain quantiles
MethodSkewness correctionRecommended for
percentileNoneLarge n (> 1000), symmetric IC
tEmpirical t-quantiles from per-resample SE*Small–moderate n, asymmetric IC
bcaBias-correction z₀ + jackknife acceleration aSkewed estimators; best coverage in theory

Result persistence

# Save
sr.to_parquet("results/exp1/tmle_ipcw.parquet")

# Load (next session, no re-run needed)
from causal_bench.metrics import SimResult
sr = SimResult.from_parquet("results/exp1/tmle_ipcw.parquet")
print(sr.summary())

CLI reference

python -m causal_bench [OPTIONS]

  --scenario              Named DGP scenario (default: edwards_realistic)
  --n-sims                Monte Carlo replicates (default: 100)
  --n-jobs                Parallel workers, -1 = all CPUs (default: -1)
  --estimand              ATE or ATT (default: ATE)
  --estimators            Space-separated estimator keys
  --seed                  Random seed (default: 42)
  --out-dir               Output directory (default: results/)
  --no-plots              Skip plot generation

  Diagnostics (can combine freely):
  --diagnostics           Overlap, Love plot, SE calibration
  --tipping-point         Tipping-point sensitivity table + plot
  --ess                   ESS distribution (50 draws) + plot
  --convergence           IC-based TMLE convergence table (single dataset)
  --overlap-map           Propensity overlap map in (W1, W3) space
  --export-r              CSV + metadata JSON for R/concrete benchmarking
  --mnar-tipping-point    MNAR sensitivity grid (skipped if cens_informativeness=0)
  --mnar-estimator        Estimator for MNAR grid (default: km)
  --mnar-grid             Grid points per axis (default: 10, total = n²)

References

  • van der Laan & Rose (2011). Targeted Learning. Springer.
  • van der Laan & Gruber (2012). Targeted minimum loss-based estimation of causal effects. Int J Biostatistics.
  • Robins, Hernán & Brumback (2000). Marginal structural models and causal inference in epidemiology. Epidemiology.
  • Li, Morgan & Zaslavsky (2018). Balancing covariates via propensity score weighting. JASA.
  • Bugni, Canay & Shaikh (2018). Inference under covariate-adaptive randomization. JASA.
  • McCoy (2026). Direct RMST targeting for competing-risks TMLE. concrete R package.
  • Hernán & Robins (2020). Causal Inference: What If. Chapman & Hall/CRC.

Contributors

BaronWolfenstein

431 commits

BaronWolfenstein/causal_bench

Monte Carlo benchmarking of causal estimators for clinical trial survival data — censoring, positivity, collider bias, competing risks, RMST.

0

stars

431

commits

Python

primary language

Aug 5, 2026

updated

causal-inference
clinical-trials
monte-carlo
python
survival-analysis
tmle

README

causal_bench

Monte Carlo benchmarking of causal estimators for clinical trials with survival outcomes.

Generates synthetic randomized and observational trial data under controlled assumption violations — informative censoring, positivity violations, unmeasured confounding, time-varying post-treatment confounders, treatment crossover, enrollment drift, competing risks, and stratified randomization — then measures each estimator's bias, RMSE, coverage, and SE calibration across 38 experiments.

The core finding: the "right" estimator depends entirely on what's wrong with your data. This framework makes that concrete.

Designed for biostatisticians working on device trials (ENCIRCLE-scale, n≈700) who need to decide between TMLE, IPCW, LTMLE, and McCoy's concrete package. Estimand coverage includes risk difference, RMST difference, win ratio, restricted mean time in favorable state (RMT-IF), priority-standardized net benefit, and — for ordinal patient-reported outcomes — the cumulative log-OR.

ENCIRCLE's pre-specified primary estimand (Guerrero et al., Lancet 2025, SAP Section C) is a non-hierarchical KM composite event rate at 1 year tested against a 45% performance goal (one-sided Wald/Greenwood, α = 0.025) — not a hierarchical composite or hazard model. The synthetic control arm (SCA) augments this with an external TVT Registry comparator; the two estimands are reported side by side in exp16_encircle_calibrated.py.


Quick start

git clone <repo> && cd causal_bench
pip install -e ".[dev,storage]"        # storage adds pyarrow for result persistence
pip install -e ".[bayes]"              # bambi/PyMC for the ordinal CLMM estimator (optional)

# Single scenario, 100 sims, MVP estimators
python -m causal_bench --scenario edwards_realistic --n-sims 100

# With diagnostics and sensitivity flags
python -m causal_bench --scenario edwards_realistic --n-sims 100 \
    --diagnostics --tipping-point --ess --convergence --overlap-map

# Export last sim's data for R/concrete benchmarking
python -m causal_bench --scenario edwards_realistic --n-sims 50 --export-r

# Full experiments (each ~2–5 min on 8 cores at n_sims=200)
python experiments/exp1_censoring.py --n-sims 200
python experiments/exp2_positivity.py --n-sims 200
python experiments/exp3_unmeasured.py --n-sims 200
python experiments/exp7_edwards.py    --n-sims 200
python experiments/exp8_mccoy.py      --n-sims 200   # R + concrete required for concrete_RMST
python experiments/exp11_strata.py    --n-sims 200   # R + concrete required for SE correction

Estimators (30)

Risk difference estimators (Python)

KeyMethodDRIPCWNotes
naiveUnadjusted mean differenceMaximum bias under informative censoring
kmKaplan-Meier risk differenceMarginal, no covariate adjustment
coxCox G-computationBiased under informative censoring
cox_l1Cox + L1 covariate⚠ Intentionally biased — collider trap (Exp 5 only)
ipwHorvitz-Thompson IPWWeight truncation at 1st/99th pct
overlapOverlap weightingTargets ATO; stable near positivity violations
aipwAugmented IPWDoubly robust, no targeting step
tmle_ipcwTMLE + IPCWOne-step Newton targeting, cross-fitted IC
tmle_ipcw_complyTMLE + IPCW + complianceCompliance score in censoring model
ltmleLongitudinal TMLESequential regression over L1; no collider bias
tmle_ipcw_cvCV-TMLE (cross-fitted)Cross-fitted censoring model → calibrated SE at finite n
tmle_ipcw_cv_complyCV-TMLE + complianceCross-fitted, with compliance score in the censoring model
tmle_ipcw_bootTMLE + IPCW (bootstrap SE)Nonparametric bootstrap SE instead of the IC-based SE

RMST estimators (R bridge — requires concrete)

KeyMethodNotes
concrete_RMSTDirect RMST targetingMcCoy (2026); iid SE
concrete_RMST_strataDirect RMST + BCS SE correctionBugni-Canay-Shaikh / Ye-Shao correction for stratified randomization (concrete PR #29)
rmst_k2 / k5 / k10 / k20Pointwise-then-integrate RMSTBias O(1/K); K=20 near-exact
concrete_simultMulti-horizon RMST, simultaneous bandsJoint TMLE across horizons (e.g. t=1,2) with simultaneous confidence bands

Win ratio estimators (R bridge — requires concrete)

KeyMethodNotes
concrete_WR_directWin ratio direct TMLEtargetWinRatio() (McCoy PR #30); jointly fluctuates both arms' cause-specific hazards
concrete_WR_pluginWin ratio plug-ingetWinRatio() after doConcrete(); ~5× more bias than direct

True win ratio benchmark: compute_true_win_ratio(config) computes P(T₁>T₀)/P(T₁<T₀) via U-statistic on 50k potential-outcome pairs using np.searchsorted (O(n log n)).

Clinical composite estimators (R bridge — requires concrete)

KeyMethodNotes
clinical_RMTIFRestricted mean time in favorable stateclinicalRMTIF() multistate engine (concrete PR #33)
clinical_PSNBPriority-standardized net benefitclinicalPSNB() priority-ranked win-ratio variant (concrete PR #34)

HTE / subgroup estimators (CATE)

KeyMethodNotes
effectxshiftPost-selection HTE subgroupsMcCoy's EffectXShift R package; CV-TMLE subgroup effects
bcf_bartBCF/BART CATEBayesian Causal Forest + rpart summary tree (Hahn et al. 2020)

Ordinal PRO estimator (Bayesian, Python)

KeyMethodNotes
clmm_ordinalBayesian cumulative-link mixed modelbambi/PyMC; partial pooling (random site intercept); posterior credible intervals; surfaces the site SD (τ) in convergence_info. Needs pip install -e ".[bayes]". Assumption-adversary to the PRO win ratio — the head-to-head benchmark (exp25) is gated on concrete#36
clmm_ordinal_slopeCLMM, (A | site) random slopeCorrelated random intercept and treatment slope per site; surfaces τ_A. The stronger adversary when the DGP has site-varying treatment effects
clmm_ordinal_nopoolCLMM, no pooling (fixed site effects)Reference arm: one intercept per site, no shrinkage
clmm_ordinal_cpoolCLMM, complete pooling (no site term)Reference arm: ignores site clustering entirely

Experiments (38)

ScriptSwept parameterKey storyEstimators
exp1_censoring.pycensoring_informativeness 0→1Naive/KM degrade; TMLE+IPCW stays flatMVP
exp2_positivity.pypositivity_severity 0→3IPW weight variance explodes; overlap stays stableAll Python
exp3_unmeasured.pyunmeasured_confounding_strength 0→0.8ALL estimators biased — identification failureAll Python
exp4_crossover.pycrossover_rate 0→0.3ITT attenuation; IPCW censoring at crossover helpsMVP
exp5_collider.pycollider_strength 0→1Opposite-direction biases; only LTMLE correctcox, cox_l1, ltmle, tmle_ipcw
exp6_drift.pyenrollment_drift 0→0.5Learning-curve bias; Cox/TMLE adjust, KM/naive don'tMVP
exp7_edwards.pyEdwards scenarios (3)Full benchmark, realistic device trial conditionsAll Python
exp8_mccoy.pyRMST grid density K=2–20Direct targeting eliminates discretisation biastmle_ipcw, aipw, ltmle, rmst_k2–k20, concrete_RMST
exp9_sample_size.pyn 100→2000Where TMLE asymptotics hold for ENCIRCLE (n=700)MVP
exp10_win_ratio.pyDirect TMLE cuts WR bias ~5× vs plug-inconcrete_WR_direct, concrete_WR_plugin
exp11_strata.pyBCS SE correction narrows CIs under stratified RCTconcrete_RMST, concrete_RMST_strata, tmle_ipcw

Extended experiments (design & operating-characteristics studies):

ScriptFocus
exp12_simultaneous.pySimultaneous coverage across a multi-estimand family
exp13_censoring_sweep.pyCensoring mechanism sweep (MAR / MNAR / informative)
exp14_synthetic_augmentation.pyProvenance-linked synthetic augmentation — cross-fitting independence
exp15_sequential_monitoring.pySequential CED monitoring — anytime-valid vs alpha-spending vs naive
exp16_encircle_calibrated.pyENCIRCLE-calibrated replication — 14 estimators vs published marginals
exp17_transport.pyTransport decomposition — trial-to-commercial generalizability
exp18_hawthorne.pyHawthorne decomposition — durable vs transient monitoring artifact
exp19_hierarchical_oc.pyHierarchical borrowing operating characteristics
exp20_tipping_point_borrowing.pyTipping-point sweep × borrowing strength
exp21_hte_subgroup.pyHTE subgroup benchmark — EffectXShift CV-TMLE vs BCF/BART posterior tree
exp22_mbias_sensitivity.pyM-bias sensitivity — adjusting for a pre-treatment collider introduces bias (coverage → 0); collider-aware backdoor set stays unbiased. Estimand-side handling of the zero-flow-CI collider caveat
exp24_site_clustering.pySite clustering in registry comparator — undercoverage demonstration
exp25_ordinal_pro.pyWin ratio vs Bayesian CLMM on ordinal PROs — efficiency & coverage under PO violation
exp26_user_sim_detection.pyExogenous-shock detection in a user simulator
exp27_dialogue_mnar.pyMNAR turn-missingness in dialogue (exp13 sibling)
exp28_q2_adaptation.pyQ2 three-arm adaptation contrast
exp29_balance_diagnostics.pyBaseline balance + region-R overlap diagnostics for a weighted SCA
exp30_registry_balance.pyRegistry-path balance table with HAL propensity, R-as-output
exp31_covariate_measurement_error.pyCovariate measurement-error sensitivity (Σ_x) — residual confounding
exp32_clever_covariate_me.pyΣ_x measurement error propagated into the TMLE clever covariate
exp33_donsker_learners.pyDo Donsker-class learners license AIPW/TMLE without cross-fitting?
exp33b_donsker_nuisance_tmle.pyTMLE+IPCW with Donsker-class nuisance learners (phase-2 wiring)
exp36_three_level_fidelity.pyTwo-vs-three-level OC fidelity (#40): conjugate two-level kernel (drops between-subgroup τ) vs three-level BHM via MCMC (PyMC + NumPyro/JAX). Shows the conjugate approximation is anti-conservative under heterogeneity. --dgp exp19 runs on exp19's real registry DGP. Requires the 3.12 [bayes]+[bayes-gpu] stack
exp37_compounding_shift.pyCompounding covariate shift — unmeasured confounding × enrollment drift
exp38_frozen_model_shift.pyPositivity/propensity under train-vs-deploy covariate shift
exp39_zero_flow_ci.pyZero-flow conditional-independence test + Markov-blanket recovery
exp39_ci_calibration.pyType-I / power calibration of the zero-flow CI test
exp41_borrowing_calibration.pyBorrowing calibration (#144): identifiability-set tau_sd OCs on the joint DGP with BP-decoded labels. Policies flat / oracle / canonical / empirical (fixed van Zwet CDSR τ prior) over level × θ₀ × K × scenario; multi-GPU sharded via scripts/exp41_multigpu.py. Requires the 3.12 [bayes] stack
exp34_pooled_q_rmst.pyPooled-Q subgroup estimator (#77 event rate + #189 RMST + #188 RP-spline nuisance): (A) under within-subgroup informative censoring the IPCW-adjusted RMST corrects the covariate-dependent censoring KM ignores; (B) borrowing lowers RMSE for the event rate but is a wash for RMST (efficient targeting makes the RMST point robust to the initial nuisance); (C) logistic vs RP-spline (flexsurvspline) nuisance under crossing hazards. Self-validating against MC truth
exp43_mmrm_mnar.pyMMRM (REML, unstructured) under MNAR dropout: unbiased in the MAR control, bias growing monotonically with the MNAR channel; IPCW-observed fails identically (nothing recovers MNAR from observables) while IPCW-oracle recovers, identifying the unobserved-dropout channel. Bias reported paired against a complete-data benchmark
exp42_hazard_selection.pyBuilt-in selection bias of the hazard ratio: randomized A + unobserved Gamma frailty ⇒ the Cox HR attenuates toward the null and drifts early→late, while KM risk-difference and RMST stay unbiased on the same replicates. Closed-form marginal truths make it self-validating

Other components

Beyond the estimator/experiment suite, the package includes supporting subsystems:

Module / scriptWhat it is
causal_bench/diagnostics/localization.pyRare-detail localization decision procedure (Tests A/B/B′/B″/C) for the synthetic-control-arm architecture; CPU-only. Demo: experiments/demo_localization.py
causal_bench/sampling/Twisted-diffusion SMC core with IPCW survival-weight bookkeeping (numpy, CPU-first; multi-GPU port specced in the A100 deployment spec). Demo: experiments/demo_smc_ipcw.py
causal_bench/detectors/zero_flow_ci.pyZero-flow conditional-independence test + Markov-blanket recovery (numpy/sklearn, no torch). Note the collider caveat: the MB includes collider-induced spouses and the CI oracle is fooled by a collider in the conditioning set, so the MB is never an adjustment set — see exp22_mbias_sensitivity.py
causal_bench/diagnostics/struct_s.pySTRUCT-S stratification battery — S1 event-aligned displacement bimodality (decisive; size-invariant Z-Dip principle), S2 spectral eigengap, S3 local intrinsic dimension, S4 MST density gap. Flags whether an embedding is stratified / event-jump-structured before geometry-aware modelling. CPU. Demo: experiments/demo_struct_s.py
causal_bench/generative/tangent_dsm.pyTangent-space-penalty DSM + gap-sampler on synthetic curved manifolds (arc R², Swiss roll R³, helix R³ codim-2) with learned-metric (local-PCA) normals — closed-form, numpy-only. Cuts gap-region off-manifold error ~70–90% vs plain DSM. Demo: experiments/demo_tangent_dsm.py
causal_bench/generative/score_net.py, checkpoint.pyTorch DDPM score net (diffuse_directly T8) + training checkpoints (save/load/resume/rollback; cross-device-safe). Device-agnostic (resolve_device cuda→mps→cpu); [gpu] extra, lazy-imported
causal_bench/estimators/three_level_bhm.pyThree-level BHM (PyMC + NumPyro/JAX) + two-vs-three-level OC fidelity harness (#40, exp36). [bayes]+[bayes-gpu], 3.12-only
causal_bench/diagnostics/tree_reconstruction.pyBroadcasting-on-trees reconstruction (Sclocchi–Favero–Wyart / Kesten–Stigum): magnetization order parameter, susceptibility, linear-stability multiplier b·λ²; q-ary Potts + KS-vs-reconstruction gap; D3PM large-K note. numpy, exact belief propagation (no torch)
causal_bench/diagnostics/rhm_grammar.pyRandom Hierarchy Model grammar + exact rule-BP — the canonical SFW class-overlap phase transition (#131): FSS collapse + exponent (#136), density-evolution threshold, structured / grammar-aligned (product-grammar) corruption channels (#138). numpy
causal_bench/diagnostics/hierarchy_probe.pyGaussian multiscale crossover probe (phenomenological, honestly not BP): coarse/fine MAP-recovery transition along the VP-SDE — the embedding-channel diagnostic used where no discrete grammar is available
causal_bench/diagnostics/theta_time_map.pyθ ↔ VP-SDE-time mapping (#137): token channel (closed-form θ = alpha_bar(t)) vs frozen-encoder embedding channel; class-overlap order parameters (nearest-class-mean + linear-probe posterior)
causal_bench/diagnostics/borrowing_informativeness.pyPer-level identifiability report → manual hierarchical-borrowing tau_sd suggestions (#137): embedding t_star map + correctly-signed canonical decode-accuracy map (#144). Informs, does not set shrinkage (that stays the hierarchical fit's job)
causal_bench/dgp/joint_hierarchy.pyJoint hierarchical DGP (#144 prereq): product-grammar identifiability (exact rule-BP thresholds) + per-level effect heterogeneity + coupling knob + BP-decoded subgroup labels at working corruption θ₀. numpy
causal_bench/estimators/projected_clever.pyProjected clever covariate (#182): `E[H(A,W_true)
causal_bench/validation/joint_fidelity.pyBorrowing-calibration fidelity engine (exp41/#144): BP-decoded-labels pipeline → per-subgroup summaries → three-level BHM under a τ-prior policy; reports coverage / CI-width / decode-accuracy OCs
causal_bench/estimators/mmrm.pyMMRM by REML with an unstructured within-subject covariance (log-Cholesky parameterisation, GLS-profiled beta, L-BFGS-B). Implemented directly — statsmodels.MixedLM fits random effects (compound symmetry at best), and a random-intercept model is not an MMRM
causal_bench/validation/mnar_dropout.pyMNAR-dropout falsification harness (exp43): longitudinal DGP whose dropout depends on the unrecorded outcome, with MAR control, IPCW-oracle and IPCW-observed arms
causal_bench/validation/hazard_selection.pyHazard-ratio built-in-selection-bias harness (exp42): Gamma-frailty survival with closed-form marginal survival / HR / RMST truths; Cox vs KM risk-difference vs RMST on shared replicates. Evidence for preferring cumulative-risk estimands
causal_bench/validation/pooled_q_rmst.pyPooled-Q subgroup OC harness (exp34): single-arm survival DGP with MC truths for both estimands; IPCW-vs-KM under within-subgroup informative censoring, pooled-vs-subgroup-only borrowing, and logistic-vs-RP-spline nuisance under crossing hazards
causal_bench/validation/rct_blinding.pyRCT-blinding validation of the OC-sim / synthetic comparator (#139): does the counterfactual control branch recover a held-out RCT's effect / survival curves? Flags naive or unmeasured-confounded comparators. Generator-agnostic, numpy

Numbering note. The count is built experiment scripts — exp39 ships two (exp39_zero_flow_ci.py, exp39_ci_calibration.py), so 40 distinct numbers → 41 files. Experiment numbers are non-contiguous; several are claimed by open candidate issues but not yet built:

NumberStatus
exp22Built — M-bias sensitivity (exp22_mbias_sensitivity.py, #104)
exp23Immortal-time-bias honest-null — design-level, unbuilt (#21; renumbered from exp22)
exp34Built — pooled-Q subgroup event rate (#77) + RMST (#189) + RP-spline nuisance (#188) (exp34_pooled_q_rmst.py)
exp35App-cohort second comparator (IPCW-light) — candidate, unbuilt (#71)
exp36Built — two-vs-three-level OC fidelity (exp36_three_level_fidelity.py, #40); reclaimed from the released z_anatomy slot (#73 dropped its exp number)
exp40Hypothetical-estimand bake-off under intercurrent events (Bartlett & Daniel 2026) — specced, unbuilt (#89)
exp41Built — borrowing calibration (exp41_borrowing_calibration.py, #144)
exp42Built — hazard-ratio built-in selection bias vs cumulative-risk estimands (exp42_hazard_selection.py, #181)
exp43Built — MMRM under MNAR dropout (exp43_mmrm_mnar.py, #183)

Key findings

Exp 1 (censoring): Naive and KM bias grows monotonically with censoring_informativeness. TMLE+IPCW stays near zero. Adding compliance to the censoring model gives further advantage in the MNAR regime (informativeness > 0.5).

Exp 2 (positivity): IPW SE inflates and coverage collapses at positivity_severity ≥ 2. Overlap weighting targets a different estimand (ATO) and is robust by construction. TMLE+IPCW degrades less than IPW but is not immune.

Exp 3 (unmeasured confounding — THE HONESTY EXPERIMENT): Every estimator is biased. The bias grows linearly with unmeasured_confounding_strength regardless of how sophisticated the method is. Negative control outcome bias tracks primary outcome bias, confirming U as the source. Semiparametric efficiency is irrelevant when identification fails.

Exp 4 (crossover): As-treated analysis attenuates the apparent effect as crossover_rate rises. TMLE+IPCW censors at crossover and partially recovers the per-protocol effect. The compliance-based censoring model (TMLE+IPCW+comply) gives further improvement because compliance predicts who will switch.

Exp 5 (collider trap): At high collider_strength, Cox without L1 is biased toward the null (omitted-variable bias) and Cox with L1 is biased away from the null in the opposite direction (collider bias). There is no correct naive choice — both versions of Cox are wrong, in opposite directions. LTMLE marginalises over L1 rather than conditioning on it and stays near unbiased.

Exp 6 (enrollment drift): Under learning-curve conditions, early enrollees have worse outcomes independent of treatment. KM and naive diverge as enrollment_drift rises because they don't adjust for enrollment time. Cox and TMLE include enrollment_time as a covariate (the Senn fix) and maintain near-zero bias.

Exp 7 (Edwards combined): Under edwards_realistic, LTMLE and TMLE+IPCW have the smallest bias and best coverage. IPW and AIPW degrade under positivity stress. Naive and KM are unreliable across all but the optimistic scenario.

Exp 8 (McCoy RMST): Direct RMST targeting via concrete eliminates discretisation bias accumulated by pointwise estimators at coarse grids. The concrete_RMST estimator shows residual bias against the benchmark ATE, but this is an estimand mismatchcompute_true_effects() returns an all-cause counterfactual risk difference, while concrete_RMST estimates the cause-specific CIF difference for event 1. Python TMLE/AIPW treat competing events as independent censoring, inflating cause-1 risk toward the all-cause number. In a single-event scenario the three estimators converge.

Exp 9 (sample size): On edwards_realistic (the hardest scenario), TMLE+IPCW approaches near-unbiasedness only at n ≥ 700. At n=100 the Super Learner has too little data for nuisance model quality. Naive/KM bias is invariant to n — no asymptotic rescue for misspecification.

Exp 10 (win ratio): concrete_WR_direct (McCoy PR #30 — targetWinRatio()) cuts win ratio bias ~5× relative to the plug-in approach by solving the win/loss EIF estimating equations jointly rather than substituting targeted risk curves into the win functional.

Exp 11 (stratified SE correction): Under stratified block randomization (W2 × W4, block size 4), the iid SE is conservative (se_ratio > 1, wide CIs). The BCS-corrected SE from concrete_RMST_strata restores calibration (se_ratio ≈ 1) while maintaining coverage ≥ 0.95. The power gain is meaningful at trial-scale n.


Data-generating process

AFT model with Gumbel noise (Weibull survival), unmeasured confounder U, post-treatment time-varying variable L1, informative censoring, optional competing risks, and optional stratified block randomization:

W1 ~ N(0,1)   W2 ~ Bern(0.5)   W3 ~ N(0,1)   W4 ~ Bern(0.3)   U ~ N(0,1) [latent]

Treatment (default Bernoulli):
  logit P(A=1|W,U) = logit(prev) + 0.3W1 + 0.2W2 - 0.2W3 + 0.1W4
                   + 0.5U·unmeasured_strength + 0.8W1·W3·positivity_severity

Treatment (stratified block, when strata_cols set):
  Permuted blocks of size strata_block_size within W2×W4 strata

Survival time (AFT):
  log T = 0 + 0.4W1 - 0.3W2 + 0.2W3 - 0.2W4 + 0.3U + τA
        + enrollment_drift·enrollment_time + nonlinearity·(W1²-1)
        + heterogeneity·A·W1 + ε     ε ~ Gumbel(0,1)

Post-treatment confounder (when collider_strength > 0):
  L1 = 0.5A + 0.4W3 + 0.3U·collider_strength + noise   (observed at t_L1)

Censoring:
  log C = 1.5 - 0.2W1 + 0.1W3 - 0.1A + 0.4U·censoring_informativeness
        + MNAR component for informativeness > 0.5

Competing risks (when enabled):
  log T2 = 0.3 + 0.2W1 - 0.1W3 + 0.2U + cause2_effect·A + ε2
  First event (T, T2, C, horizon) determines observed time and event type.

True ATE/ATT: G-computation on n=50,000 with shared Gumbel noise. True RMST: same population, trapezoidal integration. True win ratio: U-statistic on 50k potential-outcome pairs via np.searchsorted.

Ordinal PRO DGP (dgp/ordinal_pro.py)

A separate thresholded-latent cumulative-logistic model for an ordinal patient-reported outcome (NYHA I–IV, KCCQ tertiles), used by the ordinal-PRO benchmark (issue #26):

P(Y<=j | W,A,site) = logistic(c_j + δ_site,j − f(W) − b_site − τ_eff,j·A),   j = 1..K-1
  f(W)      = 0.4W1 − 0.3W2 + 0.2W3 − 0.2W4     (shared with the survival DGP)
  b_site    ~ N(0, σ²_site)                      site random intercept (ICC-parameterized)
  τ_eff,j   = τ + offset_j                       proportional odds ⟺ offsets/floor/ceiling = 0

Both PO-respecting and PO-violating modes are supported so a Bayesian CLMM (targets the cumulative log-OR) and a GPC win ratio (targets the ordinal win ratio) can each be scored against known truth via compute_true_cumulative_logOR and compute_true_ordinal_win_ratio. Emits an ordinal_pro marker column consumable by ConcretePROWinRatioEstimator. Full writeup in the index.qmd Replication Data appendix.


Scenarios

Scenarioncensor_infopositivitycolliderunmeasurednotes
clean5000.00.00.00.0Baseline
censor_mild5000.30.00.00.0
censor_moderate5000.60.00.00.0
censor_severe5001.00.00.00.0
positivity_mild/moderate/severe5000.01/2/30.00.0
unmeasured_mild/mod/strong5000.00.00.00.2/0.5/0.8
edwards_optimistic7000.30.50.20.1
edwards_realistic7000.61.50.40.2ENCIRCLE-like
edwards_pessimistic7000.92.50.70.4
competing_risks_base6000.30.00.00.0event_type ∈ {0,1,2}
stratified_base5000.00.00.00.0W2×W4 strata, block=4

R integration (concrete)

# Install concrete (McCoy's package — includes PR #29 BCS correction, PR #30 win ratio, PR #33 RMT-IF, PR #34 PSNB)
remotes::install_github("blind-contours/concrete", upgrade = "always")
install.packages(c("reticulate", "data.table"))

# SuperLearner libraries (may not be auto-installed from concrete's DESCRIPTION):
install.packages(c("glmnet", "ranger", "xgboost", "hal9001"))

# Direct use from RStudio via reticulate
source("r_scripts/concrete_bridge.R")
library(reticulate)
use_virtualenv(".venv")
cb  <- import("causal_bench.dgp.survival")
cfg <- import("causal_bench.dgp.config")$DGPConfig(n=600L, competing_risks=TRUE)
df  <- as.data.frame(cb$generate_data(cfg))
df$event_type <- as.integer(df$Delta)

result      <- run_concrete_bridge(df, horizon=1.0)           # RMST (iid SE)
result_bcs  <- run_concrete_bridge(df, horizon=1.0,           # RMST + BCS SE
                                   strata_cols=c("W2","W4"))
result_wr   <- run_concrete_win_ratio(df, horizon=1.0,        # win ratio (direct TMLE)
                                       method="direct")

From Python (requires pip install -e ".[r]"):

from causal_bench.estimators.concrete_rmst import ConcreteRMSTEstimator
from causal_bench.estimators.concrete_win_ratio import ConcreteWinRatioEstimator

# Returns [] with warning if R unavailable — experiments handle this gracefully
rmst = ConcreteRMSTEstimator().estimate(df)
rmst_bcs = ConcreteRMSTEstimator(strata_cols=["W2", "W4"]).estimate(df)
wr   = ConcreteWinRatioEstimator(method="direct").estimate(df, estimand="WR")

Diagnostics

All functions live in causal_bench.diagnostics. CLI flags activate them automatically after a run.

Always-on (via --diagnostics)

FunctionOutput
plot_overlap(df)Propensity score histogram by arm, extreme weight %, ESS
plot_love(df)Love plot: |SMD| before/after IPW weighting
plot_se_calibration(results)Scatter: empirical SE vs median reported SE per estimator

Flag-enabled

CLI flagFunctionOutput
--tipping-pointtipping_point_table, plot_tipping_pointAdditive bias needed to explain away each estimate
--essess_across_sims, plot_ess_distributionIPW ESS histogram across 50 draws; flags ESS < 50% of n
--convergenceconvergence_tableIC-based TMLE convergence: ic_mean (≈ε), ic_sd, ic_ratio per estimator
--overlap-mapplot_overlap_map"Who are we borrowing for?" — treated patients in (W1,W3) space, size ∝ 1/g, control density in background
--mnar-tipping-pointtipping_point_mnar, plot_tipping_point_mnarMNAR sensitivity grid: imputes censored outcomes across (δ_treated, δ_control) grid
--export-rexport_for_rCSV + metadata JSON for loading into R/concrete directly
python -m causal_bench --scenario edwards_realistic --n-sims 100 \
    --diagnostics \
    --tipping-point \
    --ess \
    --convergence \
    --overlap-map \
    --export-r

MNAR and concrete sensitivity (Python API)

from causal_bench.diagnostics import (
    tipping_point_mnar, plot_tipping_point_mnar,
    tipping_point_concrete, plot_tipping_point_concrete,
    convergence_table, plot_overlap_map, export_for_r,
)

# MNAR sensitivity grid
r = tipping_point_mnar(df, "km", horizon=cfg.horizon, n_grid=15)
plot_tipping_point_mnar(r, save_path="tipping_mnar.png")
r.to_parquet("tipping_mnar.parquet")    # attrs (MAR reference) survive roundtrip

# concrete MAR sensitivity (requires R + concrete)
r2 = tipping_point_concrete(df, horizon=cfg.horizon, deltas=[0, 0.05, 0.10, 0.15, 0.20])
plot_tipping_point_concrete(r2, save_path="tipping_concrete.png")
print(f"Tipping delta: {r2.attrs['tipping_delta']:.2f}")

# TMLE convergence (IC-based; no re-run needed)
conv = convergence_table(df, estimator_names=["tmle_ipcw", "tmle_ipcw_comply"])
print(conv)

# Overlap map
plot_overlap_map(df, save_path="overlap_map.png")

# Export for R/concrete benchmarking
paths = export_for_r(df, cfg, out_dir="results/export")

Win ratio estimand

from causal_bench.dgp.survival import compute_true_win_ratio
from causal_bench.estimators.concrete_win_ratio import ConcreteWinRatioEstimator

# True win ratio: P(T_treated > T_control) / P(T_treated < T_control)
wr_true = compute_true_win_ratio(cfg)
# Returns: {"ATE": wr, "ATT": wr_att, "p_win": ..., "p_loss": ..., "net_benefit": ...}

# Estimate (requires R + concrete PR #30)
est = ConcreteWinRatioEstimator(method="direct")   # or method="plugin"
results = est.estimate(df, estimand="WR")

Sign convention: true_tau=-0.5 shortens T → T₁ < T₀ → WR < 1 (treated loses more often). Opposite sign from risk difference.


Stratified randomization

from causal_bench.dgp.config import DGPConfig
from causal_bench.dgp.survival import generate_data

# Permuted-block randomization within W2×W4 strata (4 strata, block size 4)
cfg = DGPConfig(n=500, strata_cols=("W2", "W4"), strata_block_size=4)
df  = generate_data(cfg)
# df.attrs["strata_cols"] == ["W2", "W4"]  — passed automatically to concrete bridge

# BCS SE correction: pass strata_cols to ConcreteRMSTEstimator
from causal_bench.estimators.concrete_rmst import ConcreteRMSTEstimator
est = ConcreteRMSTEstimator(strata_cols=["W2", "W4"])
results = est.estimate(df)

Strata are defined by binarising each column at its median (binary columns need no transformation). Two strata_cols → 2² = 4 strata. Within each stratum, half-and-half blocks are shuffled; a partial final block gets Bernoulli draws.


IC bootstrap CIs

TMLE+IPCW, LTMLE, and AIPW store influence curve values on EstimatorResult.ic. These can be bootstrapped cheaply without re-fitting:

from causal_bench.bootstrap import ic_bootstrap_ci

result = TMLEIPCWEstimator().estimate(df)[0]
lo, hi = ic_bootstrap_ci(result, B=2000, method="bca")         # bias-corrected + accelerated
lo, hi = ic_bootstrap_ci(result, B=2000, method="t")           # Studentized
lo, hi = ic_bootstrap_ci(result, B=2000, method="percentile")  # plain quantiles
MethodSkewness correctionRecommended for
percentileNoneLarge n (> 1000), symmetric IC
tEmpirical t-quantiles from per-resample SE*Small–moderate n, asymmetric IC
bcaBias-correction z₀ + jackknife acceleration aSkewed estimators; best coverage in theory

Result persistence

# Save
sr.to_parquet("results/exp1/tmle_ipcw.parquet")

# Load (next session, no re-run needed)
from causal_bench.metrics import SimResult
sr = SimResult.from_parquet("results/exp1/tmle_ipcw.parquet")
print(sr.summary())

CLI reference

python -m causal_bench [OPTIONS]

  --scenario              Named DGP scenario (default: edwards_realistic)
  --n-sims                Monte Carlo replicates (default: 100)
  --n-jobs                Parallel workers, -1 = all CPUs (default: -1)
  --estimand              ATE or ATT (default: ATE)
  --estimators            Space-separated estimator keys
  --seed                  Random seed (default: 42)
  --out-dir               Output directory (default: results/)
  --no-plots              Skip plot generation

  Diagnostics (can combine freely):
  --diagnostics           Overlap, Love plot, SE calibration
  --tipping-point         Tipping-point sensitivity table + plot
  --ess                   ESS distribution (50 draws) + plot
  --convergence           IC-based TMLE convergence table (single dataset)
  --overlap-map           Propensity overlap map in (W1, W3) space
  --export-r              CSV + metadata JSON for R/concrete benchmarking
  --mnar-tipping-point    MNAR sensitivity grid (skipped if cens_informativeness=0)
  --mnar-estimator        Estimator for MNAR grid (default: km)
  --mnar-grid             Grid points per axis (default: 10, total = n²)

References

  • van der Laan & Rose (2011). Targeted Learning. Springer.
  • van der Laan & Gruber (2012). Targeted minimum loss-based estimation of causal effects. Int J Biostatistics.
  • Robins, Hernán & Brumback (2000). Marginal structural models and causal inference in epidemiology. Epidemiology.
  • Li, Morgan & Zaslavsky (2018). Balancing covariates via propensity score weighting. JASA.
  • Bugni, Canay & Shaikh (2018). Inference under covariate-adaptive randomization. JASA.
  • McCoy (2026). Direct RMST targeting for competing-risks TMLE. concrete R package.
  • Hernán & Robins (2020). Causal Inference: What If. Chapman & Hall/CRC.

Contributors

BaronWolfenstein

431 commits

Languages

Python

97.1%

R

2.9%