Sign in

    moonbit_doubleML

    Pure-MoonBit port of doubleml-for-py: Double / Debiased Machine Learning for partial linear, IRM, IV, DID, SSM, APO(S), PQ, QTE, LPQ, CVaR, RDD, BLP, and policy-tree models

    doubleml
    DML
    causal-inference
    machine-learning
    debiased-estimation
    Download zip
    Author
    Version
    0.163.0
    License
    MIT
    Last updated
    8 hours ago
    Downloads
    244

    #moonbit_doubleML - Double / Debiased Machine Learning in MoonBit

    A pure-MoonBit port of the doubleml-for-py Python package -- the Python ecosystem's most mature Double / Debiased Machine Learning implementation -- 22 estimators in total. 20 match a class of the same name in upstream; DoubleMLRDD is upstream's RDD estimator renamed (upstream calls it RDFlex); and DoubleMLDIDCrossSection has no upstream counterpart at all. Those provenance labels are re-measured against the published doubleml 0.11.4 sdist, not asserted -- see the estimator table below. DoubleMLPLPR, DoubleMLLPLR and DoubleMLDIDCSBinary were for several releases described here as MoonBit-specific extras; all three ship upstream and the table marks them (upstream).

    Targets library authors and tool developers who need causal inference, panel data estimation, instrumental variable estimation, regression discontinuity, policy evaluation, or interpretable machine learning inside the MoonBit ecosystem. Delivers a unified DoubleMLXxx::new(data, n_folds, n_rep, seed).fit() API across all 22 estimators, robust variance estimation, propensity-score processing, multiple-testing correction, and a reproducible validation pipeline (24 / 24 Python reference scripts PASS) -- on native, wasm, wasm-gc, and js backends under moon test --deny-warn.

    ##Status

    ItemValue
    Repositoryhttps://github.com/riantr/moonbit_doubleML
    Authorriantr
    LicenseMIT (port of upstream doubleml-for-py, BSD-3-Clause)
    moon.mod version0.163.0
    Source layoutflat, moonbit_doubleML/ (the library)
    .mbt file count220 .mbt files (65 production + 129 blackbox test + 26 whitebox test)
    Estimators22 DoubleML* estimator structs (PLR / IRM / PLIV / IIVM / DID family / SSM / APO(S) / PQ / QTE / LPQ / LPLR / CVAR / RDD / BLP / PLPR / PolicyTree)
    Backendsnative, wasm, wasm-gc, js -- all pass moon test --deny-warn
    Tests (native / wasm / js)1115 / 1115 / 1115
    Tests (wasm-gc)1121 / 1121 (lib + 6 doc tutorials)
    Sandwich-variance coverage15 / 22 expose the scalar sandwich_se / cluster_sandwich_se / bias_corrected_coef; 4 more (DoubleMLAPOS, DoubleMLDIDCS, DoubleMLDIDMulti, DoubleMLQTE) expose per-cell variants sandwich_se_at / ..._at_idx; 3 expose none (DoubleMLBLP, DoubleMLPolicyTree, DoubleMLRDD). Introduced v0.89.0
    Python cross-checks24 / 24 PASS (validate_*_with_python.py); 25 of the 25 bit-exact items and 6 / 6 RNG keys are IEEE-754 identical to CPython
    HTTP serviceexamples/api_server/ -- hand-rolled on moonbitlang/async, no third-party framework

    ##Features

    Pure-MoonBit, all-4-backends port of doubleml-for-py -- 22 DoubleML* estimators (DoubleMLPLR, DoubleMLIRM, DoubleMLPLIV, DoubleMLIIVM, DoubleMLDID, DoubleMLDIDBinary, DoubleMLDIDCS, DoubleMLDIDCSBinary, DoubleMLDIDMulti, DoubleMLDIDCrossSection, DoubleMLSSM, DoubleMLAPO, DoubleMLAPOS, DoubleMLPQ, DoubleMLQTE, DoubleMLLPQ, DoubleMLLPLR, DoubleMLCVAR, DoubleMLRDD, DoubleMLBLP, DoubleMLPLPR, DoubleMLPolicyTree) in 19 source files. 20 are marked (upstream) and mirror a name-identical doubleml-for-py class. DoubleMLRDD is marked (upstream, renamed) -- upstream calls it RDFlex. One row, DoubleMLDIDCrossSection, is marked (no upstream counterpart). Three rows -- DoubleMLDIDCSBinary, DoubleMLLPLR, DoubleMLPLPR -- were previously marked (extra), which was wrong: all three ship upstream, so they had a reference implementation all along.

    Unified API surface -- every estimator follows the same DoubleMLXxx::new(data, n_folds, n_rep, seed).fit() shape and returns the same .coef() / .se() / .confint() / .bootstrap() accessors. No per-estimator interface drift.

    Cross-fitting infrastructure -- kfold, repeated cross-fitting, stratified K-fold (kfold_stratified), chacha8_rng-based seeded PRNG (chacha8_rng(seed)), and Kahan summation. Default seed=3141; same seed yields byte-identical DGP outputs across runs.

    Vectorised cross-fit helpers (v0.81.0+) -- vectorized.mbt exposes matrix_predict (Kahan-compensated X @ weights + bias), vector_subtract, vector_add, and vector_scale as public free functions. DoubleMLIRM::fit / fit_cluster / sensitivity_analysis and the matching DoubleMLPLR paths now route the per-observation residual extraction through vector_subtract instead of an inlined per-iteration load / subtract. The helpers are pure element-wise loop wrappers today -- the public API is fixed so a v0.82+ release can swap the bodies to a SIMD-vectorised backend (or external call to a BLAS-style library) without breaking callers.

    ml_m is a classifier where the propensity carries identification -- and that is NOT everywhere. Upstream makes no OLS default: ml_m is a required argument, validated as regressor=False, classifier=True (irm.py:157) and read back with predict_proba (irm.py:161). Where m_hat is first-order in the score, an OLS propensity is unbounded, enters as 1 / m_hat, and propensity_clip (default 1e-6) merely caps that at 1e6 -- so nothing raises, it just returns wrong numbers. Measured on a fixture with true ATE = 1.5:

    ml_mcoefrv
    linear_regression-225.860.043
    logistic_regression1.440.842

    The port used to default ml_m to LearnerDispatch::linear_regression() everywhere. DoubleMLIRM was migrated in v0.132.0, DoubleMLAPO in v0.134.0, DoubleMLAPOS in v0.135.0, DoubleMLSSM in v0.138.0 and DoubleMLPQ / DoubleMLQTE in v0.139.0: their ml_m default is now LearnerDispatch::logistic_regression(LogisticRegression::new()), while ml_g deliberately stays OLS (it models E[y | x], not P(y = 1 | x]), so the two defaults diverge on purpose. LearnerDispatch::is_classifier() reports which constructors qualify.

    The migrations do NOT all have the same margin, and the gates are shaped accordingly. DoubleMLIRM on a saturated-propensity fixture (true ATE = 1.5) across three seeds: default recovers within 0.198, an OLS propensity misses by at least 72.8 -- a 366x separation on the coefficient. DoubleMLAPO (true E[Y | D=1] = 1.2): default within 0.531, OLS misses by as little as 5.246 -- only ~10x. APO's standard error is the honest discriminator (0.270 vs 86.28, a 300x separation), because the mechanism is that an unbounded propensity makes 1 / m_hat explode and takes the variance with it.

    DoubleMLSSM has the largest margin of the four, because its score carries two reciprocals, one per direction (ssm.mbt:73-74): the treatment propensity m and the selection propensity pi, so an unbounded regressor breaks both denominators independently. On a saturated fixture with true theta = 1.0, the OLS arm returns 242.116 with SE 185.45 where the classifier returns 1.177 with SE 0.0982 -- roughly 200x on the point estimate and 1900x on the standard error. Across three seeds the OLS coefficient also changes sign (+242, +106, -452); the classifier's spread over the same three seeds is 0.761.

    One caveat worth stating plainly: on a mild fixture, where the true propensity never reaches the 0.02/0.98 clamp, the two learners agree to three decimals and OLS is marginally better. The hazard is a property of the clamp, not of the estimator in general, and the gate suite pins both regimes so a future change cannot be scored on the flattering fixture alone.

    Nine structs still default ml_m to linear_regression(): DoubleMLCVAR, DoubleMLDID, DoubleMLDIDBinary, DoubleMLDIDCrossSection, DoubleMLDIDCS, DoubleMLDIDCSBinary, DoubleMLDIDMulti, DoubleMLIIVM, DoubleMLLPLR. Until each is migrated, pass a classifier explicitly:

    ml_m = LearnerDispatch::logistic_regression(LogisticRegression::new())

    LearnerDispatch::RandomForestClassifier and LearnerDispatch::GradientBoostingClassifier are the nonlinear options.

    The hazard is NOT uniform, and was measured per-estimator (v0.136.0). Migrating DoubleMLDIDBinary was attempted and abandoned on the evidence: across four probe designs and ~14 configurations its OLS default matched a logistic one to within ~1e-4, and where they differed the OLS default was the BETTER of the pair. The reason is structural. In the observational DID score (did.mbt:708) w_d = d_i / m_i, so 1 / m enters only for treated rows (controls get w_d = 0), and the score is doubly robust -- a correct g carries identification, leaving m second-order. IRM and APO need m for the estimand itself, which is why they showed a ~200-point error and DID shows ~0.001. The sharpest DID case: at n=200 the CLASSIFIER default is worse on both bias (0.0776 vs 0.00050) and SE.

    EstimatorOLS defaultEvidence
    DoubleMLIRMhazardouscoef -225.86 vs 1.44
    DoubleMLAPOhazardousbias 5.246 vs 0.531; SE 86.28 vs 0.270
    DoubleMLAPOShazardousper-level bias to 436.11
    DoubleMLDIDBinarynot demonstrated4 designs, ~14 configs, ~1e-4
    DoubleMLSSMhazardous -- fixed in v0.138.0coef 242.116 vs 1.177
    DoubleMLCVARnot demonstrated2.300 vs 2.373
    DoubleMLQTEhazardous -- fixed in v0.139.0frozen at -1.583 for tau 0.50-0.90
    DoubleMLIIVMnot demonstrated0.987 vs 1.031 (16x smaller SE)
    DoubleMLPLRnot demonstrated1.015 vs 1.103 -- OLS better
    DoubleMLLPLRnot demonstrated -- root-caused in v0.140.0-v0.142.0not an ml_m hazard; two failure modes; see below
    DoubleMLPQhazardous -- fixed in v0.139.0ignored its quantile entirely

    All eleven remaining defaults were measured in v0.137.0. Three showed a defect: DoubleMLSSM (242.116 vs 1.177 -- its score divides by m*pi AND (1-m)*pi, two reciprocals in both directions, ssm.mbt:73-74), and DoubleMLPQ / DoubleMLQTE, which ignored their quantile outright. SSM was fixed in v0.138.0 and the quantile pair in v0.139.0. The other eight keep their OLS default on measured evidence. DoubleMLLPLR's degenerate standard error is real and was root-caused in v0.140.0, but it is not an ml_m defect -- see the note below.

    A static classifier (read the score: does it carry 1/m? is it doubly robust?) was used to prioritise the probes, and it reproduces all four earlier outcomes -- but DoubleMLIIVM refuted it, carrying 1/m and 1/(1-m) on every row and still measuring harmless. A reciprocal in the score is not sufficient for the hazard; the classifier is a way to decide what to measure first, nothing more.

    DoubleMLPQ IGNORED its quantile argument on the default path -- fixed in v0.139.0. An ordinary caller who never mentioned ml_m got the identical coefficient at every tau:

    tauold default (OLS ml_m)shipped now
    0.10-0.38571498836568785-0.38571498836568785
    0.25-0.385714988365687850.5268291897597495
    0.50-0.385714988365687850.9570929867084059
    0.75-0.385714988365687851.2753280162498020
    0.90-0.385714988365687851.2753280162498020

    Cause: score[i] = treated[i] / m[i] * 1{y[i] <= theta} - q (quantile.mbt:127) with m clipped to [propensity_clip, 1 - propensity_clip] (quantile.mbt:175-177). An unbounded regressor propensity predicts outside [0,1], so treated rows saturate at m = 1e-6 and carry weight 1e6; the IPW distribution is then dominated by whichever rows saturated and its tau-quantile is the same for every q in (0,1). DoubleMLQTE shares solve_pq, and did freeze too -- at -1.5829876410256796 for tau 0.50-0.90.

    Every PQ test that existed passed quantile=0.5, so nothing could catch this. expand_v139_test.mbt now sweeps five taus on the shipped path across two fixtures, counting changed steps with exact inequality -- the defect is an exact tie, and a tolerance would hide it.

    The residual tie at tau 0.75 / 0.90 is a PLATEAU COLLISION, not a freeze (root-caused in v0.143.0). The score is a step function of theta: iy = if y[i] <= theta { 1.0 } else { 0.0 } (quantile.mbt:126), so mean(psi) is constant between observed outcomes. Two taus whose crossings land on the same step get the same root. Measured in a 0.05-wide window around the tied value: 25 distinct plateaus, longest flat run 62 of 400 grid steps.

    Two explanations were tested and both refuted:

    1. A gap in the weighted distribution -- no. At seed 17 the treated outcomes are 1033 of 1033 distinct, 0.75-quantile 3.2723 vs 0.90-quantile 3.8917, about 155 observations apart.
    2. QTE ties where PQ does not -- no. Across four seeds, both tie at seed 17 (1.275328016249802 each) and neither ties at seeds 23, 29 or 41. The v0.139.0 contrast was reading one fixture as an estimator difference.

    And it resolves as the sample grows, which is what a plateau collision must do:

    ncoef @ 0.75coef @ 0.9tied
    10001.44918987892696331.4491898789269633yes
    20001.2753280162498021.275328016249802yes
    40000.95709298670840591.3732758716025382no
    80001.43563019941789351.7559108756022739no

    More distinct outcomes means more steps, so two taus are less likely to share one. A solver defect would not behave this way. Nothing is patched: removing it would mean smoothing the indicator, which changes results in the healthy region to suppress correct behaviour.

    This is NOT the v0.137.0 defect, which froze the coefficient at every tau (0 of 4 distinct steps) and is fixed.

    DoubleMLPQ reported the IPW root while publishing the orthogonal score -- fixed in v0.159.0. A different defect from the two above, and the only one that made the estimator silently self-inconsistent.

    solve_pq bisected the IPW score and returned that root as the coefficient, then built psi from the g cross-fit. Those are two different functionals of the data, so the number reported did not satisfy the estimating equation the estimator itself publishes. Measured on a continuous-confounding DGP: the reported coefficient's own orthogonal score averaged -0.224968.

    Upstream (doubleml 0.11.3) is explicit that the IPW root is PRELIMINARY. pq.py:378 solves it only to build g's target (pq.py:390 fits ml_g(x_treat, y_treat <= ipw_est)); pq.py:423 then sets _coef_start_val = mean(ipw_vec), and double_ml_score_mixins.py:149 runs Newton from there against the orthogonal score pq.py:268. The reported coefficient is that root. DoubleMLQTE shares solve_pq and inherits it.

    Both halves matter, and the second half is the one that bites. The score contains 1{y <= theta} -- the same step function the plateau collision above is built from -- so mean(score) is piecewise constant and a bisection converges onto a jump, not a zero; the midpoint of a jump is not a root. That is what upstream's Newton avoids. The deriv likewise must not be a central difference of a step: it is identically zero wherever no observation lies within +/- h, fit scales psi by 1/deriv unguarded (quantile.mbt:2427-2429), and 0/0 = NaN reaches ses. Measured ses = [NaN, 0.3019, 0.1758, 0.1994, 1.6937]. Upstream's _compute_score_deriv is a Silverman-bandwidth weighted Gaussian KDE, which is positive everywhere, so this is structurally unreachable rather than merely unlikely.

    beforeafter
    reported rootIPWorthogonal (Newton from IPW root)
    mean(psi) at reported theta-0.224968-0.0032
    derivcentral difference, can be 0Silverman KDE, strictly positive

    On a DGP whose Q_0.6(Y(1)) = 2.2 holds by construction (confounding enters Y(0) only), theta = 2.230920809568266 with se = 0.044753173108603286, so the error 0.0309 is inside one SE. Newton moves theta only 0.0021 from the IPW root, which is the point: upstream treats that root as a start value, not the answer.

    When you pass a classifier explicitly, name it on BOTH sides of any comparison: a gate phrased against "the default" silently changes subject when the default moves (hit four times across v0.134.0-v0.136.0).

    DoubleMLLPLR can return a degenerate standard error -- and it is NOT an ml_m default problem. Root-caused in v0.140.0. On the reference Liu-Zhang-Zhou 2021 design (make_lplr_LZZ2020, dgp_lplr.mbt:37) with a large true coefficient, both learners collapse to coef ~ -10 with SE ~ 1e-6 and a five-decimal confidence interval that EXCLUDES the truth:

    true alphaml_m = linear_regressionml_m = logistic_regression
    0.500.6636 / SE 0.19540.6293 / SE 0.1684
    0.751.3573 / SE 0.38611.2161 / SE 0.2993
    1.00-10.1863 / 1.39e-62.9981 / SE 1.8142
    1.25-10.0567 / 1.62e-6-10.4075 / 1.03e-6
    2.00-9.0852 / 5.22e-6-9.9926 / 1.93e-6

    Across three seeds per cell, the regressor arm is degenerate at 3/3 for alpha >= 1.0 and the classifier arm at 3/3 for alpha >= 1.25. The classifier delays the failure by roughly one step and then fails identically, so passing it explicitly is not a workaround.

    Cause, read from the source and confirmed by measurement:

    1. prelim_beta_per_fold (lplr.mbt:71-95) returns beta built from w = ln(m / (1 - m)) -- the logit of the fitted propensity. As the effect grows, w grows and exp(r_hat) in the score (lplr.mbt:198-204) reaches O(1e3). Measured at alpha = 1.25: mean(psi) = 1097.07 and mean(psi_deriv) = -1097.07 -- the same order on BOTH arms, cancelling.
    2. The Newton convergence flag WAS computed and discarded -- let _ = converged, now only documented at lplr.mbt:885; v0.145.0 replaced the bare flag with a stop_reason string (lplr.mbt:887). With the step clamped to 0.25 (:853-854), 50 iterations cannot span an arbitrary distance, so the loop can stop away from the root, and fit computes a coefficient and a variance there with no error raised.
    3. se = sqrt(gamma / (j*j*n)) with j = mean(psi_deriv) (var_est.mbt:52). A huge j from step 1 makes the variance collapse.

    So the default keeps its regressor -- changing it would be the v0.136.0 mistake again, trading a working default for a rationale that measurement does not support while leaving the cause untouched.

    v0.141.0 / v0.142.0: in the degenerate region the objective is ROOTLESS. Sweeping mean(psi) over theta in [-30, 30] and counting sign changes:

    true alphasign changesresult
    0.501healthy -- a root exists
    1.001degenerate anyway (the solve misses the root it has)
    1.250degenerate -- no root anywhere in range

    v0.141.0 said "the objective has no root" without qualifying the region; that was too strong and is corrected here. The accurate statement is that it is rootless for alpha >= 1.25, which is exactly the degenerate region. At alpha = 1.0 a root does exist and the solver still fails to reach it -- so there are two distinct failure modes, not one.

    Every degenerate cell lands at -8.3 to -10.5 (mean -9.85) while the true effect ranges 0.75 to 2.0 -- an output that does not move when the estimand moves by 2.7x.

    Three candidate fixes were measured and all three were refuted:

    1. Bound t_pred -- degeneracy is uncorrelated with its range. A cell with t_pred in [-2.86, 4.16] is degenerate; a cell reaching +6.93 is healthy.
    2. Rewrite the score in bounded form -- psi_hat * exp(r_hat) == expit(r_hat) looks like free numerical hygiene and is not an identity here: measured max_abs_diff = 0.546 and relative difference up to 0.92 on healthy fixtures. Shipping it would silently change every LPLR result.
    3. Use a classifier for the t stage -- aborts at logistic.mbt:206, because that stage regresses on w_inner = ln(m/(1-m)), a continuous unbounded logit propensity, not a 0/1 label.

    Where the objective is rootless (alpha >= 1.25), iteration budget, damping, start value and clipping are all inert; that case needs reparameterising the objective (a bounded link such as expit), which moves every LPLR result and is a model rewrite rather than a bug fix. Where a root exists but is missed (alpha = 1.0), solver quality genuinely is the issue.

    The diagnostic that settles which case you are in is cheap and general: count sign changes of the objective over a wide parameter range before touching the solver.

    Port-vs-upstream (v0.142.0), recorded because fidelity alone would mislead: the score formula is byte-identical to upstream (lplr.mbt:194-204 vs lplr.py:401-418, including exp(-coef*d)), but two things differ. Upstream keeps beta row-wise (lplr.py:315-346) while this port folds it to one scalar (lplr.mbt:866). That is a real divergence -- but adopting upstream's per-row shape makes the root worse (1 -> 0 sign changes at alpha = 1.0), so it is not the cause and must not be changed on fidelity grounds. Upstream also handles solve failure in double_ml_score_mixins.py:143-162 (brentq fallback on bounds; a warning or ValueError on non-convergence). This port omits that path entirely -- let _ = converged (lplr.mbt:885) -- which was the one clear, low-risk divergence available to fix, and which v0.145.0 took.

    DoubleMLAPOS honoured a configured ml_m everywhere except where it counted (fixed in v0.133.0). Its fit path built each child DoubleMLAPO without forwarding ml_g / ml_m, while bootstrap and both sensitivity paths did. Passing LogisticRegression to DoubleMLAPOS returned coefs()[0] = -201.32 on a fixture whose true ATE was 1.2 -- bit-identical to ignoring the argument, and 203 points from the same learner applied directly. Nothing raised. The defaults listed above are unchanged; this was a plumbing fix, and the two are deliberately separate so a failing test can be attributed to one or the other.

    Propensity-score processor (PSProcessor) -- clipping_threshold clipping, isotonic (PAVA) calibration, K-fold cross-validated (CV) calibration. Plugs directly into DoubleMLIRM / DoubleMLIIVM / DoubleMLDID and the rest of the IPW-based models.

    Two PSProcessor defects fixed in v0.159.0. Both were silent.

    Isotonic calibration collapsed on tied x. fit_isotonic ran PAVA over the raw sorted sequence without first pooling observations that share an x. An isotonic regression is a function of x, so ties must receive one shared fitted value; instead predict_isotonic resolved each tie to the LAST index of the run, and the [0.01, 0.99] clip turned the stray 1.0 into 0.99. The reported case -- predictions [0.5, 0.5], labels [0, 1] -- returned [0.99, 0.99] where sklearn 1.9.0's IsotonicRegression gives [0.5, 0.5] with y_thresholds_ = [0.5]. The fix adds pool_tied_x and passes the group size to pava as a weight, because the L2 objective is over observations and the group statistic is therefore a size-weighted mean. pava itself is unchanged. A tie-free control is unchanged, which is what shows the pooling is what moved the tied case.

    cv was never forwarded, so cv_calibration=true calibrated on the wrong split. adjust_ps declares a cv parameter and upstream passes cv=smpls at every call site (pq.py:410, did_binary.py:537, iivm.py:371, cvar.py:343), but in this port no estimator ever passed it. With cv_calibration=true the processor silently calibrated on its own default_5fold(n) -- five contiguous blocks -- instead of the estimator's n_folds partition. Now wired at irm.mbt:430 and iivm.mbt:525 via folds_to_cv_partition, which is sound because kfold's Fold guarantees disjoint test indices covering [0, n_obs) (kfold.mbt:199).

    Note this is narrower than "IRM/IIVM has no hookup", which was measured and found too strong: the isotonic config did already reach both fits. The five remaining adjust_ps call sites (apo.mbt:320, did.mbt:628, did_cross_section.mbt:615, did_cs_binary.mbt:374, cvar.mbt:736 and :1005) still do not forward cv.

    ps_processor_config is reachable (v0.121.0+, IRM v0.122.0, IIVM and SSM v0.123.0, PQ v0.124.0, LPQ v0.125.0, CVaR v0.126.0, DID v0.127.0, CS-DID v0.128.0). DoubleMLAPO, DoubleMLAPOS, DoubleMLIRM, DoubleMLIIVM, DoubleMLSSM, DoubleMLPQ, DoubleMLLPQ, DoubleMLCVAR, DoubleMLDID, DoubleMLDIDBinary, DoubleMLDIDCSBinary, DoubleMLDIDCrossSection and DoubleMLDIDCS now take a PSProcessorConfig, which is how the isotonic and CV-calibration paths become callable from an estimator at all -- until v0.121.0 they were implemented and reachable from nowhere. Resolution follows upstream's init_ps_processor: the config wins outright and the deprecated scalar propensity_clip is read into it only when no config is supplied. Measured against upstream 0.11.4, the calibration is not cosmetic -- isotonic moves the propensity by up to 0.53 on the test fixture, the APO coefficient moves from 2.9641677613312316 to 2.9602088935501736, the IRM coefficient from 1.9609911300451515 to 1.9603016544875, and the CVaR coefficient runs 3.664307496539196 -> 3.3179262532001292 across the clip sweep (isotonic lands at 3.471229424883473). CVaR is worth singling out because it is the first estimator here whose estimate is genuinely a weighted quantile: LPQ's root is pinned by its bisection bracket, so no threshold could move it, while CVaR solves mean(treated/m * 1{y <= theta}) = quantile and therefore does move.

    Six details worth knowing before wiring it yourself. First, which array is the calibration target differs by estimator, and upstream is not uniform: DoubleMLIIVM passes the instrument z, not the treatment d (iivm.py:371), because m is the propensity of the instrument; DoubleMLPQ passes treated, the indicator it actually regresses on. Second, DoubleMLSSM adjusts m only -- its selection propensity pi is clipped but never calibrated, matching upstream (ssm.py:407 vs ssm.py:414). Third, on DoubleMLLPQ this port deliberately does not overwrite propensity_clip with the config threshold the way upstream does (lpq.py:166), because that scalar also floors the estimated complier share here; upstream has no such floor, and where it bites the two diverge by 4.2x. On DoubleMLCVAR the opposite choice is made and is correct: upstream redirects the scalar to the config's threshold (cvar.py:156) and so does this port, because all three of its uses -- the preliminary clip, the final clip, and the re-clip inside sensitivity_analysis -- are that same threshold and there is no second job for it to disturb. Note also that CVaR's two adjust_ps sites take different second arguments: the preliminary one is d_train_1 (cvar.py:293), the final one is the full d (cvar.py:343), and swapping them aborts on adjust_ps's length precondition. The third use is worth a caveat -- irm_style_sensitivity has no psi_b parameter, so the psi_b CVaR computes there is discarded and propensity_clip cannot reach the returned SensitivityResult by any route. That is pre-existing, and v126_sensitivity_reclip_is_structurally_inert pins it. Fourth, adjust_ps requires a binary treatment; treatment_is_binary is public if you want to check first. Fifth, on DoubleMLDID / DoubleMLDIDBinary the resolution deliberately does not read propensity_clip. Upstream's deprecated scalar there is trimming_threshold, defaulted at 1e-2 (did_binary.py:123), and this port's PSProcessor::new() also defaults to 1e-2 -- but this port's propensity_clip defaults to 1e-6 and is read only by the inner cross_fit_did's clip_vec, a numerical-safety clip with a genuinely different job. Redirecting it would silently turn the default public clip from 1e-2 into 1e-6. So with no config supplied the stored threshold stays 1e-2 whatever propensity_clip says, and the two knobs stay independent. That family also has just one adjust_ps site (did_binary.py:537), and that ATT responds much more strongly on the raw DoubleMLDID (spread 2.25e-2 across the clip sweep) than through the panel wrapper (1.8e-3, a 12x damping), so the wrapper's gate is written as an exact inequality rather than a threshold. Sixth, DoubleMLDIDCSBinary deliberately uses the group indicator G for both its propensity regression and its adjust_ps calibration -- so it models P(G=1|X), not P(D=1|X). That is correct, and v0.128.0 got it wrong. Upstream's local variable at did_cs_binary.py:504 and :516 is spelled d, but it is bound at :472 to self._g_data_subset, and that line's own trailing comment reads # (d is the G_indicator). The trap is that the data column is also called d, and on a staggered design the two differ on 109 of 720 evaluated rows (the two fits disagree by up to 0.365) -- so the substitution is large and visible, and still correct. Confirmed at runtime by capturing the arguments at both upstream call sites (_verify/_oracle_1290.py), not by reading names. The invariant is stronger than a comment: nothing in DoubleMLDIDCSBinary's fit path reads data.d, so scrambling the treatment column must leave the estimate bit-identical -- v129_cs_binary_never_reads_the_treatment_column pins exactly that, and v129_cs_binary_nuisance_is_a_group_propensity_like_upstream keeps the fixture premise. DoubleMLDID (panel) and DoubleMLDIDCrossSection do use the real D, because upstream's counterparts there do (did.py:197 reads self._dml_data.d). Seventh, and fixed in v0.130.0: DoubleMLDIDCSBinary::sensitivity_analysis used to read self.data.y[i] and self.data.d[i] — the full 1080-row panel, indexed at subset positions, with the per-period treatment rather than G, and with a 2-way y - g00 - (g10 - g00)*d form that always used the T = 0 g-function. Upstream uses the subset's own y/G/T_indicator (did_cs_binary.py:845-866) and mixes the four (G, T) cells. Measured, 240 of the 720 rows read had t == 1 — a period the subset never contains, and the one in which y carries the treatment effect. sigma2 came out 583x too large (1.1473 vs 0.00197), and no test in the package exercised the function at all. irm_style_sensitivity's require(n == psi_a.length()) guard passed throughout, because both arrays were length 720 while describing different observations. Fixed, and both entry points now share one builder (cs_bin_sensitivity_residuals) so they cannot drift apart again.

    THE RECOVERY TOLERANCE, and the t_pre defect it uncovered -- v0.152.0. v0.150.0 made dgp_recovery_test non-vacuous but left its band untouched: an absolute max_abs_err < 5.0, inherited from before the fix and never examined. It was wrong in both directions at once.

    Measured across 12 seeds on make_did_cs_CS2021(800, 3, 1.0, seed):

    max |att - 1.0| 14.41 <- the band FAILS on seeds 7, 77, 314 max |att - 1.0| / se 2.41 <- in SE units the error IS bounded se range 0.589 .. 8.837

    So 5.0 was TOO LOOSE -- at the tested seed it permits 2.9x the error the estimator actually makes -- and TOO TIGHT: it rejects a quarter of the seeds, and it rejects them for being CORRECT. At seed 314 the estimate is 6.5136 +/- 6.8728, i.e. z = 0.802, an entirely ordinary answer the absolute band calls a failure. The error is large precisely when the SE is large, which is what a correctly scaled robust SE is for. The band is now |att - 1| < 5 * se, 5 chosen against a measured worst case of 2.41 -- the same 5 the sibling DIDMulti test already used. The point estimate and SE are ALSO pinned exactly at 1e-12 relative, because with a fixed seed that is the stronger statement and catches drift a relative band would forgive.

    DoubleMLDIDMulti ignores t_pre. The test that runs on make_did_CS2021 was named DIDMulti_DGP_estimator_recovers_some_ATT and constructs DoubleMLDIDCS. So DoubleMLDIDMulti -- the class that owns gt_combinations and is the documented consumer of that DGP -- has NO recovery test anywhere in the package, and the name made the gap invisible. Renamed here rather than left claiming coverage it never had.

    Why there still isn't one is a blocker, not an oversight. The API takes (g, t_pre, t_eval) triples and promises control of the base period; coef_at_idx calls ignore(t_pre) and the inner derives its own base as g - 1. The proof is one line -- (1, 0, 2) and (1, 1, 2) return the IDENTICAL coefficient 0.6790989134518333. And that makes the DGP unusable for validating DIDMulti: make_did_CS2021 treats cohort g at tau_g = g - 1, so the inferred base IS the first treated period. Measured against a true theta of 1.0: 1.4166 / 0.6791 / 0.3153 / -2.3785 / -1.2812 / -10.5035. A recovery test on those numbers would enshrine the bug.

    4 gates added (expand_v152_test.mbt). GATE 2 pins that the old band WOULD have failed, asserting z < 1.0 alongside err > 5.0 so the seed that breaks it is demonstrably a good estimate -- which is what stops the change being reverted on the grounds that 5.0 "was always fine". GATE 4 pins t_pre's inertness as a DECLARED DEFECT WITH A TRIPWIRE: it will fail the moment t_pre is honoured, at which point it is inverted rather than deleted.

    Mutation 3/4, and both survivors/results are worth reading. M4 (the stored SE inflated 10x) confirmed a weakness I predicted in advance: an SE-relative band DIVIDES by the SE, so inflating it drops z tenfold and every recovery gate still passes. The band is one-sided -- it can catch a coefficient too far from the truth in units of its own SE, never an SE that is too large. What caught M4 was GATE 3's exact pin, so the release is not relying on the band alone.

    M3 (di dropping its time condition) survived, and the first instinct is to call that blindness. It is not: measured against baseline the canonical cell is BIT-IDENTICAL on all three seeds -- coef and se agree to the last digit. A mutant that cannot move a number cannot be caught by an assertion about that number. The limitation is the FIXTURE, not the gates: a 2-period panel with a single post-treatment cell cannot distinguish that relabelling. Recorded rather than papered over with a tighter threshold.

    Also caught by running all four targets, again: the t_pre gate's literal was pinned with == and passed native while failing wasm AND wasm-gc -- the same ~1.5e-15 cross-backend spread as v0.150.0. The inertness assertion base0 ==base1 KEEPS its ==, because both values come from the same backend and a difference there would mean the base period mattered. Only the literal moved to 1e-12 relative. Native green is not evidence. Four targets, always.

    Tests: 1087 native / 1087 wasm / 1093 wasm-gc / 1087 js, all four green. 213 .mbt files (65 production + 123 blackbox test + 25 whitebox test).

    Still open: honouring t_pre is a real API defect, not a test gap, and it blocks DIDMulti recovery testing until fixed. The port's cell-grid divergence from upstream is unchanged.

    THE alpha_i RAMP IN make_did_CS2021 -- the one v0.148.0 missed -- v0.151.0. v0.148.0 removed alpha_i = 0.5 * unit_in_cohort from make_did_cs_CS2021 and never applied the fix to its 4-cohort sibling. Same defect, same reasoning, different file.

    Measured before, on make_did_CS2021(1600, 3, 1.0, 3141) where n_per_cohort = 1600/4/4 = 100:

    var(y) 206.75 (SD 14.38) corr(id, y) 0.975 r^2 X_0 / X_1 / X_2 vs y 1.0e-5 / 2.6e-3 / 9.2e-3 share of var(y) that is time-invariant 99.65 % var(within unit) 1.11

    0.25 * var(0..99) = 208.3 against a total of 206.75: THE RAMP WAS THE VARIANCE. It is not even cohort-specific noise -- unit_in_cohort repeats in every cohort, so it is a positional ramp. Covariates carried under 1% of the outcome, exactly the condition v0.148.0 called out as making the did_cs sensitivity migration untestable. After the fix: var(y) 11.06, corr(id, y) -0.108, r^2 0.423 / 0.647 / 0.560.

    A per-unit N(0,1) DRAW rather than deletion: unobserved unit heterogeneity IS the hidden confounding nu2 exists to price, so removing the term outright would leave the fixture unable to represent what it is for. The draw is taken AFTER the noise stream, so X and eps are untouched -- and the evidence that this worked is that var(within unit) is BIT-IDENTICAL across the change (1.1087561532787724 -> 1.1087561532787722), so the r^2 columns moved only through alpha's own contribution.

    8 gates added (expand_v151_test.mbt), and three of them exist only because mutants survived. GATE 0 asserts the correlation helper returns 1.0 against a variable and itself, since every correlation gate is worthless if the measuring routine is broken. GATE 1 pins covariate r^2 > 0.35; GATE 2 pins |corr(id, y)| < 0.25; GATE 3 states the ramp's absence on the quantity the ramp keyed on, the unit-level mean of y not being monotone in unit_in_cohort; GATE 4 pins the outcome scale and the PRESENCE of heterogeneity; GATE 5 proves the fix touched y and nothing else, without which the r^2 gain could equally mean the treatment effect had been shrunk.

    GATE 4's presence check was BLIND, and the mutation run said so. With six gates written the first run scored 2/5 killed: M1 (alpha_i = 0.0, deleted), M4 (alpha * 0.15) and M5 (alpha[u / 100], one intercept per cohort) all SURVIVED. The reason is structural and is the same masking that caused the original bug: X is ITSELF unit-level and time-invariant in this DGP, so it supplies the time-invariant share and the variance floor on its own. Any gate reading corr(id, y) or a variance band cannot tell "the unit intercept is present" from "the unit intercept is gone but X is doing its job".

    GATE 6 projects X out of the unit-level outcome and looks at what is left. Since alpha is drawn independently of X, that residual IS alpha (plus eps averaged over four periods): measured 1.279 against the predicted var(alpha) + var(eps)/4 = 1.25. That kills M1 and M4.

    GATE 7 handles M5, whose TOTAL heterogeneity is unchanged and so is invisible to GATE 6. Its first version SURVIVED M5 as well: it asserted within_cohort_var > 0.2 on the reasoning that a constant-per-cohort intercept drives it to zero. It does not -- the residual floor is ~0.27 from eps/4 and projection error, and the mutant walked straight through. The scale-free statement is the real one, and it is a RATIO: under a per-unit intercept the within-cohort spread equals the overall spread, because units within a cohort are exchangeable. Measured 1.009 / 1.279 = 0.789, against ~0.21 under M5.

    Final 5/5 killed, 0 survivors, 0 inconclusive, baseline 8/8.

    Blast radius: 3 sites, all value pins on this fixture -- expand_v146_test.mbt GATE 5, the v0.147.0 additivity gate, and that file's four-accessor mean gate. Every number re-measured, none guessed. Exactness was re-established per quantity rather than assumed: g_d1t1's mean is 0.7510479216157833 on all four backends and keeps its ==; the other three differ in the last one or two digits and take 1e-12 relative.

    Tests: 1083 native / 1083 wasm / 1089 wasm-gc / 1083 js, all four green. 212 .mbt files (65 production + 122 blackbox test + 25 whitebox test).

    Still open, unchanged: DIDCS has a (group, period) cell grid upstream lacks, and make_did_CS2021 sets tau_g = cohort - 1, so it does not satisfy DIDCS's g-is-treatment-period convention under either base period rule. An explicit tau_g on the estimator would remove that inference.

    THE ESTIMAND FIX -- base period g - 1, so ATT(g, g) exists -- v0.150.0. This is the first release in this thread that CHANGES REPORTED NUMBERS; every one from v0.146.0 to v0.149.0 was additive.

    DoubleMLDIDCS::fit used a cohort's OWN treatment period as its pre-treatment baseline and then required eval_period > pre_period, so it could only ever reach t > g. The ATT in a cohort's FIRST TREATED PERIOD was never estimated on any panel, ever. Callaway & Sant'Anna's convention is the opposite: a cohort treated at g is untreated at g - 1, and post-treatment begins AT g.

    On the canonical fixture make_did_cs_CS2021(800, 3, 1.0, 3141) the panel is groups = [1], times = [0, 1], so t > g admitted NOTHING. final_coef is zero-initialised (did_cs.mbt:1581), so coef_at(0, 1) returned the 0.0 it was created with and dgp_recovery_test's max_abs_err < 5.0 passed on |0 - 1| = 1.0 -- asserting against a hardcoded zero while claiming to recover an ATT of 1.0. Three parts of the package already disagreed with the estimator: the fixture's own DGP treats cohort 1 from k >= cohort, dgp_recovery_test documents ATT(g, t) for t >= g, and did_cs_test.mbt's fixture sets d at k >= cohort and then ASSERTED coef_at(0, 1) == 0. The estimator was the only thing that believed t <= g is pre-treatment.

    coef_at(0,1) 0.0 (the sentinel) -> 2.727522673844541 se_at(0,1) 0.0 -> 1.9428873717680928

    On a fixture that DOES satisfy the convention (g is the treatment period, effect from k > g), ATT is recovered to within 0.5%: (g=1,t=2) 0.9965, (g=1,t=3) 0.9982, (g=2,t=3) 1.0042 against a true 1.0, and -0.0052 / 0.0094 where the true effect is 0. The canonical panel's 2.7275 +/- 1.9429 is noisy because it is ONE cell from ONE cohort with no repeated periods to average over -- 0.89 SE from the truth, a property of the fixture, not of the fix.

    Nine tests pinned the defect, all from one cause. The package consistently treated t <= g as "not estimable"; expand_v100_didcs_test.mbt even documented it -- "An unestimated cell (t_eval <= g)". Rewritten or re-pinned from measured runs. Two mattered more than a re-pin. First, expand_v149_test.mbt GATE 6 asserted that EVERY cell is the 0.0 sentinel, which shipped in v0.149.0 with the defect still live: a gate certifying "nothing was estimated" is indistinguishable from one certifying "nothing changed". Second, se_pooled > se_single was not a theorem -- the cluster sandwich exceeds the IID one only under POSITIVE within-block score correlation, and cell (0,2) inverted (0.0042918 < 0.0044041) once the rows changed. It had been asserting a property of the data. The sign is now pinned per cell (five of six widen, (0,2) narrows), which is STRONGER than the bare != it could have been weakened to.

    A latent convention mismatch, found on the way. make_did_CS2021 (the 4-cohort DGP behind the v0.146.0/v0.147.0 gates) sets tau_g = cohort - 1, so cohort 1 is treated from t = 0, while DoubleMLDIDCS interprets g_value as the treatment PERIOD. That fixture satisfies DIDCS's convention under NEITHER base period rule, and its cells were never ATT(g, t): they read 0.089, -0.706, -6.29 against a true effect of 1.0. Those gates are re-pinned and now say explicitly that they are regression locks, NOT ATT recoveries. The same file still carries alpha_i = 0.5 * unit_in_cohort, the unit-intercept ramp v0.148.0 removed from make_did_cs_CS2021 but never applied here.

    The new sharp edge. A cohort whose g - 1 is unobserved cannot have ATT(g, g) estimated and is now SKIPPED rather than quietly given another base. v068_wbtest.mbt's fixture has periods 1, 2, 3 and no period 0, so cohort g = 1 drops out entirely. That is correct: the old rule used t = 1 as the base for g = 1, which is that cohort's first TREATED period.

    5 gates added (expand_v150_test.mbt). GATE 1 is the one that was missing -- the (g=1, t=1) cell must carry an actual estimate, witnessed by a POPULATED per-cell IF row rather than coef != 0.0, since a coefficient could be non-zero by accident. GATE 2 asserts the boundary as a partition over every cell. GATE 3 is the payoff: it pins the estimator to the TRUTH on the convention-conforming fixture, which no other gate here can do. GATE 5 pins the dropped-cohort behaviour.

    Mutation evidence 5/5 killed, and THE KILLS ALL LOOK THE SAME: every one is a process abort (0xc0000409), not a failed assertion. The cause is the cursor desync -- any mutant moving one mirrored site without the other leaves the reassembly pass admitting a cell the fit loop never estimated, so it reads coef_values_acc[coef_idx] out of an empty array. A loud abort is right for that; the alternative is a silently wrong coefficient copied one position off. DECLARED BLIND SPOT: no gate here is shown to catch a mutation producing a WRONG BUT IN-BOUNDS number. A mutant altering the arithmetic while keeping both predicates in step would survive, and I did not build one.

    An exact == I wrote myself failed on three backends: pinning the new canonical ATT with == passed native and failed wasm / wasm-gc / js, the signature of the known ~1.5e-15 did_cs spread. Native green is not evidence. Four targets, always.

    Tests: 1075 native / 1075 wasm / 1081 wasm-gc / 1075 js, all four green. 211 .mbt files (65 production + 121 blackbox test + 25 whitebox test).

    Still open, and untouched here: the port's DoubleMLDIDCS has a (group,period) cell grid that upstream's does not -- its _check_data rejects a g column outright -- and upstream's class is deprecated in favour of DoubleMLDIDCSBinary. Separately and now visible from this release: DIDCS infers treatment timing from g_value, which make_did_CS2021 does not honour, so an explicit tau_g would remove that inference.

    did_cs sensitivity, stage 2 of 4 -- v0.149.0 adds upstream's PANEL-LEVEL sigma2 and nu2 as five accessors on DoubleMLDIDCS: sensitivity_sigma2(), sensitivity_nu2(), sensitivity_psi_sigma2(), sensitivity_psi_nu2(), sensitivity_riesz_rep(). Purely additive -- no reported number moves, and sensitivity_analysis is untouched, still running irm_style_sensitivity over the DML score element. The two paths disagree by construction, and both are now exposed.

    The formulas are pinned against a RUNNING upstream, not a reading of it. Both were transcribed from did_cs.py:451-497, then checked against upstream 0.11.3, which is installed here. On a 2x2 panel built from its own CS2021 DGP it reports sigma2 = 256.4070590464714, nu2 = 18.253043736106648, and _verify/_oracle_v1482_nu2_gap.py reproduces BOTH TO THE LAST DIGIT. A first cross-check reported 17.325 against 18.253 and read as a 5.1% input difference. It was neither: my oracle omitted .ravel(), so a 2-D (816,1) m_hat silently broadcast against (816,) into a (816,816) intermediate. A 5.1% error is exactly the size that hides a shape bug behind a tolerance.

    These are PANEL-LEVEL, not per cell. Upstream's _sensitivity_element_est reads self._dml_data in its entirety, and its manual oracle fit_did_cs(y, x, d, t, ...) takes no g and has no cell loop, so both are single scalars for the whole panel. That corrects the v0.147.0 changelog, which implied stage 2 would produce per-cell values.

    Measured on make_did_cs_CS2021(800, 3, 1.0, 3141): sigma2 = 1.8855040509678376; nu2 = 21.32163562345964 with in_sample_normalization=false (the port's default, upstream's is true); and NaN with true. The NaN is the informative one -- that branch divides by mean(d1t0), which is 0.0 because nobody is treated in the pre-period, i.e. the (d=1, t=0) cell is structurally empty, the same fact expand_v147_test.mbt GATE 5 pins. Upstream's arithmetic on the same input produces the same Inf/NaN. No fallback is substituted, because a quiet replacement number is indistinguishable from a correct one.

    5 gates added (expand_v149_test.mbt). The scalars are pinned at 1e-12 relative (the did_cs quantities carry the known ~1.5e-15 cross-backend spread) and are asserted positive and finite, since sqrt(sigma2 * nu2) depends on it. GATE 3 proves the in_sample_normalization flag selects a genuinely different branch: sigma2 must agree exactly, nu2 must not. GATE 4 asserts the per-row elements are centred, per upstream's convention psi_nu2 = element - nu2 (did_cs.py:529), so sensitivity_nu2() is deliberately NOT the mean of sensitivity_psi_nu2().

    Mutation evidence 5/5, and M4 is the one that matters: storing the RAW element under the name psi_nu2 is the exact defect that shipped in this stage's first draft. A raw-vs-deviation swap preserves the array's length and shape, so only a gate asserting the mean can see it -- which is why GATE 4 is written that way. I DECLARED GATE 6 a control that would survive all five and the measurement REFUTED it: GATE 6 ends in a liveness check (sensitivity_sigma2() > 0.0) and is not purely an estimate-stability check, so it fires under M5. The declaration is corrected in the harness.

    Still open. The port's DoubleMLDIDCS has a (group, period) cell grid that upstream's does not, and upstream's class is deprecated in favour of DoubleMLDIDCSBinary. This release adds the panel-level elements alongside the grid rather than resolving that, so it is correct whichever way it goes. The estimand fix (base period g - 1, so ATT(g, g) exists) is unaffected and remains open, as does dgp_recovery_test's vacuous max_abs_err < 5.0 against a true effect of 1.0.

    DIDCS fixture: the unit intercept was drowning the covariates -- v0.148.0 replaces alpha_i = 0.5 * unit_in_cohort (a deterministic ramp to 99.5, SD ~ 28.8) with a per-unit standard-normal draw. Measured on make_did_cs_CS2021(800, 3, 1.0, 3141): var(y) 828.42 -> 10.76; r^2 of each X column against y 7e-4 -> 0.451 / 0.572 / 0.670; corr(id, y) ~1.0 -> -0.063; and mse(g_hat)/mse(cell mean) 1.052 -> 0.185 / 0.163 / 0.190. The last row is the one that matters: before the fix NO conditional nuisance could beat a constant, so g_hat was indistinguishable from a cell mean, sigma2 =mean((y - g_hat)^2) collapsed onto var(y), and the did_cs sensitivity migration had nothing to condition on and no gate able to separate a correct g_hat from a wrong one. After it, the nuisance explains ~82% of within-cell variance. A draw rather than deletion, because unobserved unit heterogeneity IS the hidden confounding nu2 prices; SD 1 matches the true effect theta = 1.0 and makes it ~19% of the covariate signal.

    One of v0.147.0's own gates broke correctly and was rewritten. GATE 5 there asserted v1470_mean(a) > 1.0 to mean "populated", which was an absolute scale smuggled in from the DGP it was written against (y mean 49.87). After the fix mean(y) is 0.085 with sd 3.28, so it went red on a perfectly well populated nuisance. It now asserts the scale-free property it meant: not identically zero.

    5 gates added (expand_v148_test.mbt). GATE 1 reads the nuisance predictions and demands the conditional fit BEAT A CONSTANT on every populated cell (ratio < 0.25) -- the property stage 2 depends on, and the only gate there that reads code rather than the fixture. GATE 2 pins the ramp's absence and first asserts corr(id, id) > 0.999999 so a broken correlation routine cannot pass silently. GATE 3 pins covariate r^2 > 0.35. GATE 4 pins the outcome scale. GATE 5 proves the fix touched y and nothing else (occupancy still 400/200/0/200), without which the v0.147.0 conclusions could not have carried over.

    Mutation evidence 4/4 killed. GATE 5 and every v0.147.0 stage-1b gate survived all four, as declared -- they read occupancy. I DECLARED THAT GATE 3 survives M2/M4 and the measurement REFUTED it: tripling the unobserved term raises total variance without raising the covariate's share, so r^2 falls under the floor. The declaration is corrected in the harness, because a declared control that turns out not to be one is worse than none.

    Estimand blocker, still open. The canonical 2-period panel yields ZERO estimable cells (groups = [1], times = [0, 1], both candidates fail eval_period <= g_value because the base period is the cohort's own treatment period), so dgp_recovery_test's max_abs_err < 5.0 against a true effect of 1.0 is still vacuous. The estimand fix (base period g - 1) is next.

    did_cs sensitivity, stage 1b of 4 -- v0.147.0 adds the four upstream (d, t) outcome nuisances g_dXtY = E[Y | D=d, T=t, X] on the long panel, as FOUR NAMED accessors predictions_g_d0_t0() / _d0_t1() / _d1_t0() / _d1_t1() on DoubleMLDIDCS, plus cross_fit_predict_conditional_dispatch in learner.mbt mirroring upstream's fit_predict(..., train_cond=...) (doubleml/tests/_utils.py:33-45). Upstream's sigma2 needs g_hat assembled from all four (did_cs.py:445-449, 456-461); the port had two, split by d alone and taken from each cell's child DoubleMLDIDBinary restricted to its own subset -- a different object that cannot be reassembled on the full panel.

    Four names, not a 4-tuple, and measurement is why. The obvious design is one predictions_g_d() returning (d0t0, d0t1, d1t0, d1t1). Two probes ruled it out. First, the obvious discriminator does not exist: cell-wise RMSE has no minimal diagonal -- g_d0t1 beats g_d0t0 on cell (d0,t0) -- because RMSE ~14 is dominated by noise while the four conditional means differ by under 2. Second, nothing else sees a permutation either: all four arrays have length n, are all finite and all vary, so a swapped return literal passes every length/finiteness/variation gate while quietly building the wrong g_hat. With four names the permutation is not expressible.

    The cells are t in {0,1}, settled from upstream's source. Extending them to later periods would be a NEW modelling decision, not a port. did/tests/_utils_did_cs_manual.py selects intersect1d(where(d == 0), where(t== 0)) and three siblings; dgp_did_CS2021.py:309 sets "t":time_matrix.flatten(), so upstream's t IS the period; and doubleml/tests/_utils.py:33-45 intersects the condition with the training index and then predicts on the whole test fold, so the condition gates FITTING, not prediction. Upstream's DoubleMLDIDCS is therefore the 2x2 repeated-cross-section design (its multi-period sibling is DoubleMLIDMulti). On the canonical 2-period panel this is an exact transcription; on a longer panel -- which this port permits and upstream does not -- rows with t >= 2 train none of the four, reproducing upstream.

    Purely additive -- no reported number moves, asserted by GATE 4. A draft of GATE 1 that asserted array LENGTH was discarded before shipping: the helper pre-sizes its output to n, so restricting prediction to the cell still returns four arrays of length n. GATE 1 now asserts COVERAGE (measured: zero exact 0.0 entries anywhere on the 4-period panel, versus 1300/1400/1500/1400 under that mutation). GATE 7 pins one measured mean per named accessor and restores exactness where available: g_d0t1, g_d1t0, g_d1t1 are bit-identical on all four backends and asserted with ==; only g_d0t0 differs (js ends ...4715 vs ...472, ~2e-16) and alone carries a 1e-12 tolerance. Mutation evidence 6/6 killed, with GATE 3 declared NON-DISCRIMINATING: it reads data.d/data.t, i.e. the fixture, so it cannot fail under any code mutation.

    Stage 1b blocker, recorded so it is not rediscovered. The four nuisances now exist; what remains is that upstream's nu2 needs m_alpha/rr across four upstream branches, one of which is unreachable because DoubleMLDIDCS::new has no score parameter while the sub-DID hardcodes score="observational". Upstream's did_cs numbers are therefore an architectural change to the estimator, not a formula swap; until stage 2 lands, nu2 is still mean(psi_a^2) on the DML score element and NOT upstream's mean(2*m_alpha - rr^2).

    did_cs sensitivity, stage 1 of 4 -- v0.146.0 adds DoubleMLDIDCS::predictions_m(), the cross-fitted group propensity m_hat = E[D | X] on the LONG panel, plus sensitivity_folds() exposing the panel-wide folds behind it. Upstream's did_cs decomposition needs exactly this (did_cs.py:470, propensity_weight_d0 = m_hat / (1 - m_hat)), and the port could not produce it for two independent reasons: each (g, t) cell delegates to a child DoubleMLDIDBinary restricted to that cell's two cohorts and two periods, so its propensity is a different object; and DoubleMLDIDCS exposed no prediction accessor at all. Strata mirror upstream's d + 2*t.

    Purely additive -- no reported number moves, and that is asserted rather than asserted-to: GATE 5 of expand_v146_test.mbt pins three coef/se pairs against values recorded before this change existed. Its first draft pinned them bit-for-bit and failed on three of four backends -- chasing that showed the did_cs estimates differ by about 1.5e-15 relative across backends, which is pre-existing and unrelated to this additive change. The gate now uses a 1e-12 relative tolerance drawn from that measurement, while GATE 5b pins the NEW nuisance exactly, since it has no inherited spread: mean(m_hat) =0.5627032315060088 on all four backends.

    Stage 1b blocker, recorded so it is not rediscovered. Upstream's sigma2 needs g_hat from four (d, t) outcome nuisances (did_cs.py:445-449); the port has two, split by d only, taken from each cell's child, and they cannot be recovered -- only refit on the full panel. Separately, DoubleMLDIDCS::new has no score parameter while the sub-DID hardcodes score="observational", so upstream's two experimental branches are unreachable. Upstream's did_cs numbers are therefore an architectural change to the estimator, not a formula swap; until stage 1b lands, nu2 is still mean(psi_a^2) on the DML score element and NOT upstream's mean(2*m_alpha - rr^2).

    DoubleMLLPLR, DoubleMLRDD and friends have no upstream sensitivity implementation at all — upstream sets _sensitivity_implemented = True for only seven classes (did, did_binary, did_cs, did_cs_binary, apo, irm, plr), LPQ's _sensitivity_element_est is a bare pass, and ssm/lplr/plpr explicitly disable it. So the port's extra sensitivity entry points are extensions, not divergences. Eighth, fixed in v0.131.0: for the seven that are upstream-faithful, the shared nu2 was mean(psi_a^2) — which is upstream's fallback formula, used only when the primary comes out non-positive (double_ml.py:1634-1641) — and it was applied to psi_a, the score's base element, rather than to rr, the Riesz representer, which upstream keeps deliberately separate. The primary is nu2 = mean(2*m_alpha - rr^2) (did_cs_binary.py:932). Measured: nu2 3.3028 → 26.7189 (8.1×), rv 12.2969 → 4.3234. psi_sigma2/psi_nu2 turn out to be dead on this path — they feed only psi_max_bias, which every caller discards — so the whole reported result is a function of sigma2 and nu2 alone. DoubleMLDIDCSBinary was migrated to irm_style_sensitivity_from_elements; did, did_binary, did_cs, apo, irm still uses the old convention and needs its own m_alpha/rr plumbing. plr could not be migrated that way at all, and is resolved separately in v0.144.0: its nu2 is not a mean of a per-observation element but the scalar 1 / mean((d - m_hat)^2) (plr.py:290-293). The port passed psi_a = -v^2 into the shared helper, which squares its third argument, so it reported mean(v^4) -- the fourth moment, a different functional form rather than a differently-scaled version of the intended quantity. Measured on a seeded confounded design (n = 500, seed 20261): mean(v^4) = 0.076516 against 1/mean(v^2) = 4.609163, a factor of 60.24 that moves with the data. sigma2 was mean((y - l_hat)^2) rather than upstream's theta * v-centred mean((y - l_hat - theta*v)^2) (0.775939 vs 0.336927), so the "residual variance" still contained the coefficient's own contribution. rv was therefore not the Cinelli-Hazlett robustness value. Fixed IID and cluster, with six gates that recompute both formulas independently. Upstream's per-observation psi_nu2 has mean exactly zero by construction, so transcribing it literally would route PLR through the nu2 <= 0 fallback and reach the right number by the branch reserved for a degenerate case; the scalar is transcribed instead, and the new shared sensitivity_from_variance_baselines is what licenses passing baselines directly. pliv was added to the unsupported list in the same release -- upstream never sets _sensitivity_implemented = True for it either. Separately, upstream defaults in_sample_normalization to True (did_cs_binary.py:121) and this port to false (did_cs_binary.mbt:732) -- uniformly across all six DID estimators, upstream being True in all five of its own. This is a deliberate backward-compatibility choice, not a transcription slip: v0.8.0 picked false so the default stayed "byte-equal to the pre-0.8.0 port". Both branches are implemented and were verified faithful in v0.131.0, so the formulas agree; only the default differs. Measured on two independent staggered DGPs, flipping it moves coef by 0.03%, se by 3-4% and sensitivity nu2 by 0.6-1.2%, and leaves sigma2 bit-identical (sigma2 is unweighted upstream, did_cs_binary.py:867). To match upstream exactly, pass in_sample_normalization=true explicitly. The config is also folded into the memoize key, not just its threshold, so two estimators sharing a clipping_threshold but differing in calibration are kept apart.

    Solver diagnostics -- v0.145.0 gives DoubleMLLPLR a warnings() accessor, the first diagnostic channel in the package. Until now it had none by design (no println, no stderr: a numerical library that writes to the console makes its own test output unreadable), and that same silence is why fit could end its damped-Newton loop with let _ = converged and then report a coefficient and standard error computed at a non-solution without saying so. Warnings are returned, never printed -- the caller decides:

    let model = DoubleMLLPLR::new(data, alpha=1.5).fit()
    for w in model.warnings() { println(w) }

    The failure is easy to miss because it does not look like one. se is sqrt(gamma / (j^2 * n)) with j = mean(psi_deriv), so leaving the root makes the reported standard error smaller. On the package's own make_lplr_LZZ2020 (n = 800, seed 17):

    alphawarnssecoef
    0.25 / 0.50 / 0.75no0.285 / 0.465 / 1.5070.703 / 1.204 / 2.351
    1.00yes1.7e-06-10.367
    1.25 / 1.50 / 2.00yes2.6e-06 / 3.8e-06 / 2.1e-05-10.001 / -9.702 / -8.370

    The boundary lands at alpha = 1.00, reproducing the cliff v0.140.0 located indirectly from the SE and CI -- the same edge, found from a different observable. The reason is exhausted_step_budget and the reported theta is about -10.7: the damped step is clamped to 0.25, so 50 steps walk the iterate ~12 units out and simply run out of budget. Three distinct exits are named, since they are different faults: degenerate_derivative, diverged, exhausted_step_budget. This is unrelated to the v0.144.0 sensitivity work -- one is about the sensitivity API surface, the other about an estimate.

    Robust variance & inference -- heteroskedasticity-consistent (HC) standard errors, cluster-robust variance, multiplier bootstrap confidence intervals, joint CIs, and Romano-Wolf multiple-testing p-value adjustment. Reused via the bootstrap.mbt helper extracted in v0.55.0.

    Huber-White sandwich API (v0.79.0+, expanded through v0.89.0) -- sandwich.mbt provides the sandwich_variance_hc0 / _hc1 / _hc2 / _hc3 free functions, cluster_sandwich_variance, and bias_corrected_theta, all driven by the SandwichKind enum. From v0.86.0 they are reachable through ONE shared sandwich_variance(kind, psi_a, psi, m_inv, n_obs, n_params) dispatch, and 15 of the 22 DoubleML* estimators expose the estimator-level triple sandwich_se(kind) / cluster_sandwich_se(cluster_ids) / bias_corrected_coef(): DoubleMLIRM, DoubleMLPLR, DoubleMLIIVM, DoubleMLPLIV, DoubleMLDID, DoubleMLDIDBinary, DoubleMLDIDCrossSection, DoubleMLDIDCSBinary, DoubleMLCVAR, DoubleMLSSM, DoubleMLAPO, DoubleMLPLPR, DoubleMLPQ, DoubleMLLPQ, DoubleMLLPLR. (Re-measured: the quantile trio was missing from this list while the Status table already counted all 15, so the two disagreed inside one file.) (DoubleMLRDD and DoubleMLBLP already emit HC0 intervals by calling LinearRegression::sandwich_se internally.)

    DoubleMLBLP::confint_joint ignored level -- fixed in v0.159.0. It hardcoded let z = 1.959963984540054 (blp_policy.mbt:746) while level was accepted, range-checked at line 727, and then never used -- so level=0.50 and level=0.95 returned byte-identical intervals, which removes the entire point of the argument. Now level-aware, following the convention already used in this package (did_cross_section.mbt:485): the exact constant at level == 0.95, so the default stays bit-exact, and norm_ppf otherwise. The multiplier is sqrt(chi2(1, level)) = z_{(1+level)/2}, the same value statsmodels.OLS.conf_int(joint=True) applies. Because the interval is symmetric, the width is exactly 2 * z * se, so width ratios are data-independent and can be pinned without hard-coding a fitted number:

    levelwidth (n=400, contrast on (intercept, x))
    0.500.03691929183204845
    0.950.10728184750635172
    0.990.1409923584966286

    width(0.95)/width(0.50) measured 2.9058479234757097 against 1.959963984540054 / norm_ppf(0.75) = 2.905847923475711, agreeing to 1.3e-15. Note the numerator is the hardcoded 95% constant, not norm_ppf(0.975): this package's norm_ppf returns 1.9599627869916687 there (accurate to ~6e-7 relative), which is exactly why the special case exists. Note the convention: the per-observation score is psi[i] = psi_at(coef, psi_a, psi_b)[i] = coef * psi_a[i] + psi_b[i] -- the estimating function f(theta) = E[theta * psi_a + psi_b] that var_est inverts, whose root is theta_hat = -mean(psi_b) / mean(psi_a) -- and M_inv = [[1 / mean(psi_a)]] = 1 / (d f / d theta). Only M_inv[0, 0]^2 enters the variance. The HC2 / HC3 leverage is the constant mean-regression diagonal h_ii = 1 / n_obs (fixed in v0.88.0), so HC2 == HC1 and HC3 == HC1^2 / HC0 and the ordering is HC1 == HC2 > HC0 and HC3 > HC2 -- a monotonic widening. See expand_v090_test.mbt for the score-order evidence and for the still-open factor between se() and sandwich_se(HC0).

    DoubleMLRDD is not in that list, and does not join it (v0.96.0). It has its own hac_se(kind) / cluster_hac_se(cluster_ids) / leverage() / n_local_params(), and it is deliberately NOT named sandwich_se, because the two HC0 formulas are different algebras. RDD's is a kernel-weighted local-regression sandwich, sum_k w_k^2 * m_k^2 * e_k^2 with m_k the intercept row of a full p1 x p1 normal inverse and no 1 / n^2 divisor; the shared helper is a scalar-Jacobian MEAN-moment sandwich, M_inv[0,0]^2 * sum_i psi[i]^2 / n^2. Feeding RDD's influence function psi_a[k] * residuals[k] into the pre-v0.91.0 accumulator sum (psi_a * psi)^2 yields sum psi_a[k]^4 * e_k^2 -- a fourth power against RDD's second -- and the resulting error is a plausible 1e-2, not an obvious one. RDD also has no DML moment, hence no scalar M_inv to supply, and its HC2 / HC3 leverage is a real WLS hat diagonal rather than the shared helper's constant 1 / n, so HC2 == HC1 does not carry over. The anchor is hac_se(HC0) == se() bit-identically on a cov_type = "HC0" fit; on a "homoskedastic" fit it is deliberately not, and hac_se refuses a non-OLS learner and a fuzzy = true fit rather than returning a number under the wrong name. Sandwich coverage above is unchanged -- RDD is not sandwich coverage. See CHANGELOG.md [0.96.0] and expand_v096_test.mbt.

    RDD's own robust variance is a different thing again (v0.110.0-v0.116.0). Neither se() nor hac_se() is rdrobust's robust variance for the bias-corrected estimator, and RDD has a whole family of its own:

    • optimal_bandwidth() / optimal_bias_bandwidth() -- rdrobust's bwselect = "CCT" MSE-optimal estimation bandwidth h and bias-correction bandwidth b. They are different numbers because a degree-j local polynomial's bias is O(h^(j+1)), so the point (p = 1) and bias (q = 2) criteria carry different variance exponents. Measured b / h = 0.907.
    • nn_residual(x, y, nnmatch?) -- the fit-free nearest-neighbour matched residual, transliterated from upstream's _nn_residuals_jit including its mass-point block walk. Byte-identical to the oracle at four nnmatch values and on a mass-point fixture.
    • tau_bc() -- the bias-corrected point estimate, procedure (ii) and (iii) alike.
    • tau_bc_se_rb(vce = "nn" | "hc0") -- procedure (iii)'s standard error. Its meat matrix is Q, the p-order fit minus its estimated bias term, with the p-order Gram at h as bread even though the estimate is a q-order quantity at b.

    These are opt-in and leave coef() / se() untouched. They are checked against the upstream Python port of rdrobust, not against internal identities -- see the oracle paragraph below. Sharp designs only: fuzzy CCT delta-method terms are not ported.

    Coverage by class:

    • Observational / quasi-experimental: DoubleMLDID, DoubleMLDIDBinary, DoubleMLDIDCS, DoubleMLDIDCSBinary, DoubleMLDIDMulti, DoubleMLDIDCrossSection, DoubleMLRDD, DoubleMLSSM.
    • Strategy & distributional: DoubleMLAPO / DoubleMLAPOS, DoubleMLPQ, DoubleMLQTE, DoubleMLLPQ, DoubleMLCVAR, DoubleMLBLP, DoubleMLPolicyTree.

    Reproducible DGPs + Python reference validation -- 24 Python scripts (validate_*_with_python.py) drive the DGPs (plr_CCDDHNR, plr_turrell, plr_confounded, irm_discrete, irm_heterogeneous, irm_confounded, iivm, pliv / pliv_cluster, SSM, simple_rdd, DID SZ2020 / CS2021, etc.) and check within-tolerance equivalence against the upstream Python outputs. CI runs each script and requires both a zero exit code and a trailing PASS line -- the second condition alone used to be the only one checked, and it is trivially satisfied by a script that never ran MoonBit.

    An upstream ORACLE for RDD (v0.116.0) -- the scripts above check against hand-written reference outputs, which can only catch a disagreement someone already thought to look for. RDD now also has a real oracle: the Python port of rdrobust shipped in the upstream repository rdpackages/rdrobust (Python/rdrobust/src/rdrobust/), pinned under _verify/_upstream_rdrobust_py/ and runnable via _verify/_oracle/. _verify/gen_v116_oracle.py calls it and writes _verify/_v116_golden.json; every constant in moonbit_doubleML/expand_v116_test.mbt comes from that file.

    It earns its keep. The oracle found that v0.115.0's tau_bc -- which had a green suite and two gates asserting a stated identity -- was not rdrobust's bias-corrected estimate whenever b != h. The v0.115.0 gate had compared two internal routes to the same wrong number, so no internal assertion could ever have caught it. _verify/mut_v116_rdd.ps1 records the equivalent.

    Docs & demos -- 6 doc/<NNN_...>/ tutorials targeting wasm-gc, plus 19 examples/<bin>/ driver binaries (all listed in moon.work), of which examples/api_server/ is an HTTP service built directly on moonbitlang/async@0.20.3 -- no third-party HTTP framework is pulled in.

    Strict dependency hygiene -- only official moonbitlang/* packages in the library. Non-official deps restricted to riantr/* (this repo). Reproducible builds, minimal supply-chain surface.

    Learner injection via LearnerDispatch -- every estimator's new() / fit() accept ml_g / ml_m / ml_l / ml_r typed optionals. Default is LearnerDispatch::linear_regression() (OLS). Built-in learners:

    learnerkindsample_weightnote
    LinearRegressionregressionWLSOLS, the default
    Ridgeregressionweightedv0.120.0+, L2 penalty, unpenalised intercept
    Lassoregressionweightedv0.120.0+, L1 penalty, coordinate descent
    ElasticNetregressionweightedv0.120.0+, the convex L1/L2 blend
    LogisticRegressionclassificationweighted IRLSbinary, IRLS Newton-Raphson
    RFClassifierclassificationweightedv0.117.0+, Breiman 2001, Gini splits
    GBClassifierclassificationweighted Newton leafv0.117.0+, Friedman 2001, binary log loss
    RFLearnerregressionweighted splits/leafBreiman 2001, MSE splits
    GBLearnerregressionweighted leafFriedman 2001, squared-error loss
    ConstantLearner, NoopLearnereitherignoredconstant / zero predictors

    Upstream doubleml is learner-agnostic -- ml_g / ml_m are any object with fit / predict -- so it inherits scikit-learn's whole learner surface. This package ships eleven. The four tree/logistic learners' predict returns P(y = 1 | x) for classification, never a hard label, because DML's AIPW correction divides by the fitted propensity.

    The regularised learners penalise the slopes, not the intercept. All three centre the design, solve for the slopes, and recover the intercept as y_mean - x_mean . beta; scikit-learn does the same. Folding the intercept into X as a column of ones -- which is what LinearRegression does -- and penalising it is a different estimator, and the two intercepts differ by 2.7e-2 at alpha = 0.7.

    alpha means two different things across the two families. Ridge minimises ||y - Xw||^2 + alpha*||w||^2; Lasso / ElasticNet minimise (1/2n)||y - Xw||^2 + alpha*(...). Equal numeric alpha therefore shrinks far harder for Lasso. The identity that pins it: ElasticNet(l1_ratio = 0, alpha = a) == Ridge(alpha = a*sum(sample_weight)) -- note sum(sample_weight), which reduces to n unweighted.

    Learner surface is at parity with doubleml 0.11.4. An earlier version of this README listed "GLM families (Poisson, Gamma, negative binomial)" as a gap. That was wrong, in the same way an earlier release claimed a ps_dm the sdist does not contain: doubleml 0.11.4 has no DoubleMLPoisson / DoubleMLGamma / DoubleMLNegativeBinomial, and no PoissonRegressor / GammaRegressor / QuantileRegressor anywhere in it. DoubleMLPQ fits its outcome model as a classifier on the indicator 1{y <= theta} (pq.py:253, pq.py:390), which this port already has. GLMs would be an extension beyond upstream, not a parity gap.

    sample_weight landed in v0.118.0 -- the trait is fit(x, y, sample_weight) with an empty array meaning unweighted, matching upstream's protocol and scikit-learn's sample_weight=None.

    RFLearner, GBLearner, RFClassifier and GBClassifier reach the 5 specialised internals -- DoubleMLDIDCrossSection::crossfit_nuisance, DoubleMLDIDCSBinary::cs_bin_crossfit_nuisance, DoubleMLPQ::solve_pq / DoubleMLQTE::solve_pq, DoubleMLCVAR::cvar_inner_crossfit, and DoubleMLLPLR::cross_fit_predict_dispatch (binary classifier slots) / DoubleMLRDD::rdd_side (kernel-weighted OLS for ml_g = LinearRegression, unweighted fallback for other learners). Defaults preserve v0.61.0 byte-equality; non-default overrides change the IF and the coef.

    ##Quick Start

    $ moon test --deny-warn Total tests: 593, passed: 593, failed: 0. $ moon run examples/main === MoonBit DML PLR (partialling out) === true theta_0 = 1 estimated theta = 0.9763281577675552 standard error = 0.08740490230937094 ... $ python validate_irm_with_python.py ====================================================================== PASS |mb - handrolled_nrep5| (theta) = 5.24e-02 < max(MODEL_TOL=0.1, 2.0*handrolled_n5_se) = 1.91e-01

    ##Library Use

    let data = @moonbit_doubleML.DoubleMLData::new(x, y, d) // x : Matrix, y / d : Array[Double]
    let fitted = @moonbit_doubleML.DoubleMLPLR::new(data, n_folds=2, n_rep=1, seed=3141).fit()
    let coef = fitted.coef() // Double
    let se = fitted.se() // Double
    let (lo, hi) = fitted.confint()

    Same shape for every other estimator -- e.g. @moonbit_doubleML.DoubleMLIRM::new(data, ml_g?, ml_m?, n_folds?, n_rep?, seed?).fit(), @moonbit_doubleML.DoubleMLDID::new(data, ml_g?, ml_m?, ...).fit().

    ##Examples

    19 examples/<bin>/ directories in moon.work. 18 run end-to-end on synthetic DGPs: 17 are CLI-style numeric demos (print true-vs-estimated theta and a 95 % CI); the 18th -- examples/api_server/ -- is an HTTP service. The 19th -- examples/consumer_demo/ -- is the library-user pattern that mirrors what an external moon add riantr/moonbit_doubleML consumer would write.

    examples/apo/ and examples/apos/ are the pair worth reading together: APO returns the potential outcome AT one treatment level, APOS returns those levels plus pairwise CONTRASTS, and a contrast is off by the reference level's entire outcome. examples/apo/ prints both so the gap is visible rather than described.

    DriverModelDGPTrue theta
    examples/mainDoubleMLPLR + 11 others (IRM / PLIV / IIVM / DID / SSM / BLP / RDD / PQ / QTE / LPQ / PolicyTree)Simple partially linear, n=500, p=51.0
    examples/datasetsDoubleMLPLR + DoubleMLIRMSynthetic 401(k)-style, n=5000, 9 controls1.5
    examples/did_binaryDoubleMLDIDBinary2-period panel DID, 400 units1.0
    examples/did_csDoubleMLDIDCSStaggered CS-DID, 4 cohorts x 4 periods1.0
    examples/did_cs_binaryDoubleMLDIDCSBinaryStaggered CS-DID with binary outcome1.0
    examples/did_multiDoubleMLDIDMultiTop-level multi-period DID + aggregation1.0
    examples/did_cross_sectionDoubleMLDIDCrossSectionSant'Anna-Zhao 2020 cross-section DID, 500 units1.0
    examples/plpr, examples/lplrDoubleMLPLPR / DoubleMLLPLRStatic-panel PLR / partially logistic regression1.0
    examples/apo, examples/aposDoubleMLAPO / DoubleMLAPOS3-level discrete treatment: the LEVEL E[Y(t)] vs the CONTRAST1.0 / 2.5
    examples/cvarDoubleMLCVARCVaR1.0
    examples/ssmDoubleMLSSMSharp synthetic treatment1.0
    examples/clusterDoubleMLPLIV / DoubleMLPLR + cluster_*Clustered data, cluster-robust SEs1.0
    examples/splittingDoubleMLPLR + set_sample_splittingExternal sample splitting1.0
    examples/pavapava coreIsotonic regression, exact (no MC error)1.0
    examples/fuzzwrappersRandom-property fuzz harness across 12 surfacesn/a
    examples/consumer_demolibrary-user patternMinimal end-to-end PLR (no estimator-specific extras)1.0
    examples/api_serverDoubleMLPLR + DoubleMLIRMHTTP service -- see belown/a

    CLI demos:

    $ moon run examples/main $ moon run examples/datasets $ moon run examples/did_binary $ moon run examples/did_cs $ moon run examples/did_multi $ moon run examples/did_cross_section

    HTTP service:

    $ moon build --target native examples/api_server $ _build/native/debug/build/api_server/api_server.exe # listens on 127.0.0.1:4000 $ curl -s http://127.0.0.1:4000/healthz {"status":"ok"} $ curl -s -X POST http://127.0.0.1:4000/fit/plr \ -H 'Content-Type: application/json' \ -d '{"x":[[1.0,0.5],[2.0,1.5],[3.0,2.5],[4.0,3.5],[5.0,4.5],[6.0,5.5]],"y":[2.0,4.0,6.0,8.0,10.0,12.0],"d":[1.0,2.0,3.0,4.0,5.0,6.0],"n_folds":2,"seed":3141}' {"estimator":"plr","coef":2,"se":0,"ci_lo":2,"ci_hi":2,"n_obs":6,"n_features":2}

    The same api_server.wasm also runs under moonrun --port 4000 (see examples/api_server/README.md for the full curl recipe). The service is built directly on moonbitlang/async@0.20.3 -- no third-party HTTP framework is pulled in.

    ##Models

    22 estimators (DoubleML* structs) in 19 production files -- 16 with one struct each, and three holding two (apo.mbt: DoubleMLAPO + DoubleMLAPOS; blp_policy.mbt: DoubleMLBLP + DoubleMLPolicyTree; quantile.mbt: DoubleMLPQ + DoubleMLQTE) -- out of 220 .mbt files in the package (65 production, 129 blackbox test, 26 whitebox test). All are reachable through the same DoubleMLXxx::new(...).fit() interface. 20 are marked (upstream) against a name-identical doubleml-for-py class, DoubleMLRDD is marked (upstream, renamed) because upstream calls it RDFlex, and DoubleMLDIDCrossSection is marked (no upstream counterpart):

    ModelScoreDescription
    DoubleMLPLRpartialling-outpartially linear regression (upstream)
    DoubleMLIRMATEinteractive regression model (upstream)
    DoubleMLPLIVpartialling-outpartially linear IV regression (upstream)
    DoubleMLIIVMLATEinteractive IV model (upstream)
    DoubleMLDIDobservationaldifference-in-differences (upstream)
    DoubleMLDIDBinaryobservationalDID with binary outcome (upstream)
    DoubleMLDIDCSobservationalDID with staggered adoption (Callaway-Sant'Anna) (upstream)
    DoubleMLDIDCSBinaryobservationalCS-DID with binary outcome (upstream)
    DoubleMLDIDMultiobservationalmulti-period DID with group-time ATT aggregation (upstream)
    DoubleMLDIDCrossSectionobservationalSant'Anna-Zhao 2020 cross-section DID (no upstream counterpart) -- port-only
    DoubleMLSSMMARsample selection (missing-at-random) (upstream)
    DoubleMLAPOpolicy scoreaverage prescriptive effect (upstream)
    DoubleMLAPOSpolicy scoreAPO with stratified treatment (upstream)
    DoubleMLPQquantilepotential quantile (upstream)
    DoubleMLQTEquantilequantile treatment effect (upstream)
    DoubleMLLPQlocal polynomiallocal potential quantile (upstream)
    DoubleMLLPLRpartialling-outpartially logistic regression, Liu-Zhang-Zhou 2021 (upstream)
    DoubleMLCVARCVaRconditional value-at-risk (upstream)
    DoubleMLRDDobservationalregression discontinuity (upstream, renamed) -- upstream RDFlex
    DoubleMLBLPIVbest linear predictor of treatment effect (upstream)
    DoubleMLPLPRpartialling-outpartially linear panel regression, Clarke-Polselli 2025 (upstream)
    DoubleMLPolicyTreepolicypolicy tree (upstream), + v0.98.0 honest split (honest = true) with leaf_se / policy_value_se

    Provenance (v0.103.0, re-measured). The counterpart modules in doubleml 0.11.4 are plm/{plr,pliv,lplr,plpr}.py, irm/{irm,iivm,apo,apos,pq,qte,lpq,cvar,ssm}.py, did/{did,did_binary,did_cs,did_cs_binary,did_multi}.py, rdd/rdd.py (the class there is RDFlex), and utils/{blp,policytree}.py -- verified against the published sdist, not the GitHub tree, so this is the shipped artefact. Not all 22 have a counterpart: see the measurement below.

    Provenance was re-measured against the 0.11.4 sdist itself (_verify/doubleml-0.11.4.tar.gz, 388 members) rather than read off a table. Result: 20 rows have a name-identical class DoubleMLXxx upstream; DoubleMLRDD corresponds to upstream's RDFlex; and DoubleMLDIDCrossSection has no upstream estimator.

    For that last one the scan is specific, because "not upstream" and "nothing upstream" are different claims. Over the sdist's 322 .py members: class DoubleMLDIDCrossSection 0 hits, the bare identifier 0, did_cross_section as a module name 0, any class *CrossSection 0. But cross_sectional_data appears 25 times and Sant'Anna 13 -- upstream ships make_did_SZ2020 in doubleml/did/datasets/dgp_did_SZ2020.py, "Generates data from a difference-in-differences model used in Sant'Anna and Zhao (2020)". So the design is known upstream and the DGP ships there; it is the estimator that is not ported. Labelled *(no upstream counterpart)*, which refers to the estimator class.

    Three rows were previously marked (extra) -- DoubleMLDIDCSBinary, DoubleMLLPLR, DoubleMLPLPR. That was wrong: all three ship upstream, so they had a reference implementation all along and simply were not being compared against it. DoubleMLDIDCSBinary, DoubleMLDIDBinary and DoubleMLDIDMulti are additionally absent from upstream's __all__ while still being importable from their modules.

    #Honest policy trees (v0.98.0)

    DoubleMLPolicyTree::new(x, orth_signal, depth, honest = true,split_seed = 2024) splits the sample: policy_tree_build sees only the splitting half, and every reported leaf statistic (leaf_assignment, leaf_signal_mean, leaf_count, leaf_se) is computed on the estimation half. That is the honesty of Athey & Imbens (2016, PNAS 113(27):7353-7358), and it makes their property a property rather than a claim: with the partition held fixed, a leaf value is a plain sample mean, so leaf_se[k] = sd(y_k) / sqrt(n_k) has its usual sampling interpretation. is_honest() reports which regime produced a number, and honest defaults to false so existing callers are byte-identical.

    Two caveats are load-bearing and are documented in-source. Honesty fixes the bias, not the small-leaf variance: Cattaneo, Klusowski & Yu (arXiv:2509.11381) show that CART-type greedy splitting picks highly imbalanced splits with nonvanishing probability and that "sample splitting ... does not remove this limitation", which is why min_leaf_n (default 5) guards on estimation-half leaf counts and unreliable_leaves() names the offenders instead of reporting an untrustworthy leaf_se silently. And a single honest tree spends about half the sample on each of its two jobs -- Wager & Athey (2018, JASA) recover that precision by averaging many honest trees, which this package deliberately does not attempt.

    policy_value_se() is the EXACT standard error of the plain sample mean, not the tempting sum_k (n_k/n)^2 se_k^2: that form assumes independent leaf means, but group means of one sample are negatively correlated, and the independence form collapses to exactly zero when all the variation is between leaves.

    ##Project layout

    moonbit_doubleML/ <- the library (moon.mod v0.81.0, 144 .mbt files) moonbit_doubleML.mbt <- main re-export file (the import surface) ... <- one file per estimator + DGPs + score / nuisance kernels vectorized.mbt <- v0.81.0+ vectorised cross-fit helpers doc/ <- wasm-gc-targeted numbered tutorials 001_introduction/ ... 006_python_check/ examples/ <- 19 driver binaries (all listed in moon.work) main/ <- 12 estimators, one perfect-DGP run each datasets/ <- 401(k)-style ATE / ATT recovery did_binary/ <- 2-period panel DID did_cs/ <- staggered CS-DID did_cs_binary/ <- staggered CS-DID (binary outcome) did_multi/ <- multi-period DID aggregation did_cross_section/ <- Sant'Anna-Zhao 2020 plpr/, lplr/, apos/, <- single-estimator focused demos cvar/, fuzz/ consumer_demo/ <- library-user pattern (`example/consumer_demo`) api_server/ <- HTTP service built on `moonbitlang/async` moon.work <- 15-member workspace aggregator AGENTS.md <- this file (per moonbit agent convention) skills/moonbit_doubleML.md <- agent skill: API surface + anti-patterns

    ##Dependency rule (per skills/moonbit_doubleML.md)

    • Only official moonbitlang/* packages.
    • Non-official packages may only be riantr/* (this repo).
    • Pin to the latest published version (e.g. moonbitlang/async@0.20.3).
    • Consequence: examples/api_server/ builds directly on moonbitlang/async's raw Server API. No third-party HTTP framework is allowed.

    Current state: the rule is not met, by one line. moonbit_doubleML/moon.mod declares two packages, and neither is imported by any .mbt in the library (measured: 0 references each):

    declaredimported by the library?note
    moonbitlang/async@0.20.3noused only by examples/api_server/main.mbt, which declares it in its own moon.mod
    moonbit-community/sqlite3@0.2.3nonot moonbitlang/* -- added in v0.85.0 for DoubleMLPolicyTree memoisation, which now uses an in-memory FitCache

    So the library has no runtime third-party dependency today, while moon.mod carries two dead entries -- one of which does not satisfy the rule above. Deleting both lines is the change that would make this section true.

    ##Determinism & validation

    #What CI actually gates

    Measured from .github/workflows/ and confirmed against the run list. Eight gates, all of which run on push to master and again before publishing:

    GateWhereScope
    Shipped README == edited READMEcheck.yml readme-syncmoonbit_doubleML/README.mbt.md byte-identity
    moon build + moon test --deny-warncheck.yml x4 matrixnative, wasm-gc, wasm, js
    moon fmt --checkcheck.yml "Format check" x4, publish.ymlsee caveat below
    moon check --deny-warn x4publish.ymlwarnings are errors
    Generated-interface driftpublish.ymlpublic surface
    typos (pinned 1.19.0)publish.ymlprose
    24 validate_*_with_python.pycheck.ymlestimator parity; see the two-tier note below
    21-activity QA LOOP (fast scope)check.yml qa-loop, publish.yml22 registered checks, 12 of them fast

    nightly.yml additionally runs the package against the MoonBit nightly toolchain, so a breaking upstream change surfaces before it reaches stable.

    Two of these are weaker than the table makes them look, and both are stated here rather than left for a reader to assume.

    • moon fmt --check has not been shown to be able to fail. On the local toolchain (moon 0.1.20261012) it exits 0 on a package containing a deliberately mis-formatted file, silently. CI installs the latest moon from cli.moonbitlang.com rather than a pinned version, and its run log is not readable without authentication, so its behaviour there is unverified. The step reports success; treat "format is clean" as unproven until a mis-formatted file is shown to turn it red.
    • validate_splitting_contract.py never runs. It is tracked, but does not match CI's validate_*_with_python.py glob, so no workflow executes it.

    All 24 now spawn a MoonBit process, but they are not all the same kind of check, and saying "24 / 24 PASS" without saying that would overstate what passed.

    • All 24 spawn MoonBit and compare its output, over 248 comparison rows across the ten that were rewritten. validate_quantile_with_python.py used to print reference checks PASS having never run MoonBit; validate_rdd_with_python.py used to assert abs(2.0 - 2) < 1e-12, which is assert abs(0.0) and cannot fail. Both now run the port. (padjust is 108 of the 248 -- it runs two p-value vectors through five methods since v0.163.0, up from one vector.)
    • The references are not equally strong, so neither is the claim:
      • padjust runs statsmodels.stats.multitest.multipletests -- the actual upstream library the port delegates to. rdd reimplements the local-linear WLS from rdd.mbt's definition and agrees to 4.8e-15. gain_statistics is checked against two independent references on a fixture built so all six branches are taken.
      • The rest compare against a population target or an independent estimate on the port's own data. That catches a regression; it is not bit-equivalence.
    • None of them compare against the real doubleml wheel; most restate the upstream formula in Python. The wheel is what doubleml-for-py is, and matching it is stricter than what is actually being done.
    • One parity gap closed, one still open. tsbh now matches statsmodels bit for bit: tsbh_p_adjust runs statsmodels' own lambda search (n0 = m - r1 from the stage-1 rejections, alpha_star = alpha*m/n0, then rescale by n0/m) instead of a global Storey-2002 rescale. Measured max |moonbit - statsmodels| = 0 on both fixture vectors, so the validator gates it at exact equality rather than at a tolerance. tsby still uses the old estimator and still diverges from fdr_tsbky -- but not by a constant factor: measured max |port - statsmodels| = 0.1595, ratio spanning 0.5129 ... 1.1970. It has no validator rows, so nothing in CI would catch a drift in it.

    Each rewritten validator has been observed failing. _verify/_parity_mutation_1650.py perturbs the emitter once per validator and requires a non-zero exit: 11 of 11 killed, tree restore verified by sha256. One mutation survived and was documented rather than fixed -- changing the padjust p-vector's first entry -- because the validator feeds that vector to statsmodels itself, so it is the check's input, not its expected value.

    Bit-exact parity is a second, stronger tier. A tolerance cannot tell "the port is arithmetically identical to the reference" apart from "the port lands within 1e-4 of it". validate_bitexact_with_python.py compares IEEE-754 bit patterns instead, against examples/bitexact/. Measured scope, not assumed:

    operationbit-exactwhy
    + - * /yesIEEE-754 requires correct rounding, so same operands in the same order MUST agree
    sqrtyessame guarantee -- 6 / 6 measured
    cos, sinobserved 6 / 6agreement today, not guaranteed: neither is correctly rounded
    ln5 / 6ln(3.0) differs by exactly 1 ULP: MoonBit 1.0986122886681096, CPython 1.0986122886681098

    Because box_muller_pair uses ln, cos and sin, no random-number DGP in this package can be bit-exact against CPython, and neither can anything routed through BLAS/LAPACK (numpy reduces with pairwise summation; LAPACK blocks its pivots). The check therefore asserts the surface where bit-exactness is guaranteed -- 25 / 25 items and 6 / 6 RNG keys, currently identical -- and prints the rest as deliberately not asserted, so the absence is not mistaken for an oversight. A mutation battery (_verify/_bitexact_mutation_1640.py) confirms the gate discriminates: all four mutations killed, including a truncated and a mis-tiled RNG key.

    Acceptance-criteria traceability lives in features/moonbit_doubleML_acceptance.feature: 6 Feature, 24 Scenario, 80 Given/When/Then steps, each scenario annotated # mapped to: <test name>. 23 of the 27 named tests resolve to real test declarations; the other four are descriptive prose. The MoonBit tests are the executable form -- there is no separate Gherkin runner.

    Coverage is two-tier, and the tiers are not the same size. The QA LOOP (.github/scripts/qa_loop/, v0.162.0) registers 22 checks over the 21 named activities: 12 in fast scope, which CI runs on every push and again before publishing, and 10 in full scope, which is release-time.

    • fast: format, static_code_check, duplicate_code, dependencies, gherkin, qa_process, contract, security_sast, perf_test, benchmark, coverage, quality_metrics
    • full: static_analysis, unit_tests, component_tests, integration, api_test, security_dast, fuzz, mutation, load_stress, e2e

    CI deliberately runs fast only: the full scope re-runs moon test several more times and all 24 Python cross-checks, which moon-test (x4 backends) and python-cross-check already do. Re-measuring them would multiply release time for no new information.

    Still not covered, so not claimed. The coverage check is a test-reachability proxy, not instrumented line coverage. There is no dependency-vulnerability scan -- moon update resolves versions, it is not an audit. mutation kills three mutants on one kernel, not a whole-codebase surviving-mutant ledger. examples/fuzz/ has no crash-regression corpus. And the two declared moon.mod dependencies -- moonbit-community/sqlite3@0.2.3 and moonbitlang/async@0.20.3 -- are dead: nothing imports them, and dependencies already WARNs about exactly that (2 declared; non-conforming:['moonbit-community/sqlite3@0.2.3']; declared but unreferenced by library code:[both]) rather than passing quietly. Removing them is an open call, not an oversight.

    #Reproducing CI locally

    .github/scripts/ci_local.py runs these jobs on this machine. It executes the run: steps straight out of the workflow YAML rather than from a hand-maintained copy -- a copy drifts the first time someone edits a workflow, and a simulator that lies about what CI does is worse than no simulator.

    python .github/scripts/ci_local.py list # which jobs exist, and their steps python .github/scripts/ci_local.py provision # build the WSL guest toolchain python .github/scripts/ci_local.py run --jobs "qa-loop" python .github/scripts/ci_local.py run --all --release-tag v0.163.0 python .github/scripts/ci_local.py selftest # prove the simulator can fail

    --release-tag sets $GITHUB_REF_NAME. It is not cosmetic: publish.yml's Resolve release version reads that variable to decide what it is about to publish, and with it unset the tag comes out empty and the job dies on Release tag must use the v* format, got:.

    It runs against git archive <ref>, so it sees a pristine tracked checkout -- no untracked scratch, no dirty tree -- and every step goes through bash -e, which is the shell string GitHub logs. The checkout is a real git repository, because a step is allowed to rely on that (publish.yml gates on git diff --exit-code). Four verdicts, kept distinct on purpose:

    VerdictMeans
    PASSthe step ran and succeeded
    FAILthe step ran and failed -- a real defect
    SKIPa uses: step; deliberately not simulated, never a silent PASS
    BLOCKEDthis machine lacks a tool the step needs; nothing was proven

    BLOCKED exists because "the runner has python and this box does not" is not a red build. It is cross-checked with command -v in the guest, so a command the guest does have and that fails anyway still reports FAIL.

    selftest is the guard on the guard: every discriminating rule has a paired control arm, so a gate that cannot fail cannot pass here either. 20 self-tests. Eight simulator bugs it has caught, every one of which read as a project failure:

    • Windows text=True put a \r on every line of the script, so >> $GITHUB_PATH wrote to a file named <path>\r\r. The write succeeded (rc 0, empty stderr), and the next step could never find the tool it installed.
    • to_wsl was not idempotent, turning a guest path /mnt/c/... into /mnt/d/mnt/c/... when a PATH entry was read back.
    • ${{ matrix.target }} was never expanded, so all four backends ran the literal text and bash reported "bad substitution" -- four FAILs that looked like the package was built for the wrong backend.
    • git archive produced files but not a repository, so git diff --exit-code exited 129 with a usage dump naming --output -- read as moon info having the wrong flags.
    • The index was written by Windows git (autocrlf=true, normalizing CHANGELOG.md to LF) and read by Linux git, so a pristine checkout reported a diff.
    • $GITHUB_REF_NAME was never set, so Resolve release version failed on an empty tag.
    • --engine none was advertised by --help and rejected by plan().
    • publish.yml runs moon publish and gh release from plain run: steps, so nothing structural stopped a local run --all from shipping a release. Such steps are now refused, never executed.

    Not simulated: uses: steps beyond provisioning, secrets, GITHUB_TOKEN, release publishing, steps.*.outputs, and the exact OS image (WSL Ubuntu is close to ubuntu-latest, but not equal to it).

    • All randomness flows through chacha8_rng(seed) (seed.mbt). Default seed=3141; same seed => byte-identical DGP outputs.
    • Python reference scripts validate_*_with_python.py reproduce the upstream doubleml-for-py numbers. CI runs each one and requires both a zero exit code and a trailing PASS line on stdout.
    • dgp_recovery_test.mbt enforces |coef - theta| < MAX(MODEL_TOL=0.1,2.0 * handrolled_se). The tolerance is contractual.
    • v0.62.0 verified counts: moon test 485 / 485 (native & wasm & js) and 491 / 491 (wasm-gc); python _verify/run_all_validators.py 23 / 23 PASS in ~70 s. moon fmt --check is now clean (was failing on every release since v0.57.0).
    • v0.63.0 verified counts: moon test 491 / 491 (native & wasm & js) and 497 / 497 (wasm-gc); 23 / 23 Python cross-validators PASS in ~90 s. Adds 6 new wbtests (v063_wbtest.mbt) covering APO learner plumbing + APOS bootstrap/confint + BLP plumbing.
    • v0.64.0 verified counts: moon test 500 / 500 (native, wasm, js) and 506 / 506 (wasm-gc); 23 / 23 Python cross-validators PASS in ~73 s. Adds bootstrap() to 8 remaining estimators (SSM, DID, DIDBinary, DIDCSBinary, LPQ, PQ, QTE, DIDCS, BLP) via four new shared inner helpers in bootstrap_helper.mbt. RDD and PolicyTree bootstrap deferred to v0.65.0 (kernel-weighted n_local IF and tree IF need a different multiplier formula).
    • v0.65.0 verified counts: moon test 506 / 506 (native, wasm, js) and 512 / 512 (wasm-gc); 23 / 23 Python cross-validators PASS in ~107 s. Adds DoubleMLAPO::fit_cluster (with DoubleMLAPOS inheriting the cluster dispatch transparently) and tune() on IRM / PLIV / IIVM / APO. PQ / QTE fit_cluster deferred to v0.66.0 (solve_pq bisection requires a folds-override refactor for true cluster-aware cross-fitting).
    • v0.66.0 verified counts: moon test 517 / 517 (native, wasm, js) and 523 / 523 (wasm-gc); 23 / 23 Python cross-validators PASS in ~48 s. Adds sensitivity_analysis() on 7 IRM-style estimators (PLR / IRM / PLIV / IIVM / APO / DID / DIDBinary / DIDCSBinary) via a shared irm_style_sensitivity helper; confint(joint=true) on 3 multi-theta estimators (APOS / QTE / DIDCS) using a max-|t|-bootstrap critical value. Sensitivity on LPQ / PQ / QTE / SSM / RDD / BLP / PolicyTree and PQ / QTE fit_cluster deferred to v0.67.0.
    • v0.67.0 verified counts: moon test 522 / 522 (native, wasm, js) and 528 / 528 (wasm-gc); 23 / 23 Python cross-validators PASS in ~47 s. Refactors solve_pq to accept folds? and adds fit_cluster on DoubleMLPQ / DoubleMLQTE (cluster-aware folds + unit-level cluster-robust SE). Adds confint(joint?) API parity to 9 single-theta estimators. Adds sensitivity_analysis() on LPQ / PQ via a shared single_psi_sensitivity helper (centered-IF form).
    • v0.68.0 verified counts: moon test 524 / 524 (native, wasm, js) and 530 / 530 (wasm-gc); 23 / 23 Python cross-validators PASS in ~47 s. Adds sensitivity_analysis() on SSM (psi_a = -1, residuals = y - g_d1_hat) and APOS (per-level re-fit pattern). DIDCS / BLP / RDD / PolicyTree sensitivity deferred to v0.69.0+ (each requires a structural IF / nuisance-persistence plumbing beyond the shared helpers already in place).
    • v0.69.0 verified counts: moon test 528 / 528 (native, wasm, js) and 534 / 534 (wasm-gc); 23 / 23 Python cross-validators PASS. Closes the v0.69.0 sensitivity cycle: BLP (closed-form OLS precision matrix), DIDCS (per-cell long-format panel), RDD (kernel-weighted local polynomial), and PolicyTree (per-leaf DFS IRM-style decomposition).
    • v0.86.0 verified counts: moon test 623 / 623 (native, wasm, js) and 629 / 629 (wasm-gc); 23 / 23 Python cross-validators PASS. Adds the shared sandwich_variance(kind, ...) dispatch to sandwich.mbt and the sandwich_se(kind) / cluster_sandwich_se(cluster_ids) / bias_corrected_coef() triple on DoubleMLIIVM, DoubleMLPLIV, DoubleMLDID, and DoubleMLCVAR (coverage 2 -> 6 of 22). DoubleMLLPQ is deliberately skipped: its Jacobian deriv is a fit() local and is never persisted, and its psi is the centered check function at the bisection root with no coef * psi_a + psi_b split, so neither psi_a nor M_inv is recoverable post-fit. The 6 new white-box tests assert the HC1 sqrt(n/(n-1)) identity, the independently recomputed HC0 variance, the leverage-driven ordering, exact singleton-cluster equality with the IID HC0, and the bias-correction identity -- not just finiteness.
    • v0.90.0 verified counts: moon test 648 / 648 (native, wasm, js) and 654 / 654 (wasm-gc). BREAKING NUMERICAL CHANGE: sandwich_se / cluster_sandwich_se and every multiplier bootstrap() t-stat change for the 11 affected estimators; se() is unchanged and remains the reference. The sandwich family and the bootstrap evaluated E[psi_a + theta * psi_b], a different function from var_est's E[theta * psi_a + psi_b] whose root is not theta_hat; on a fixed DGP the resulting sandwich_se(HC0) was off by up to ~1100x. v0.90.0 adds one psi_at(coef, psi_a,psi_b) helper in sandwich.mbt and routes all 13 psi-construction sites through it. DoubleMLDIDCrossSection was deliberately NOT routed then, on the stated ground that it is a projection (argmin_theta) estimator with the offset / slope roles inverted and its own se formula -- v0.102.0 removed that exception: upstream DoubleMLDIDCS is a LinearScoreMixin subclass on the plain linear score, so this port's fit now routes through var_est like the others and its sandwich uses the same order. See CHANGELOG.md v0.102.0, including the measured point estimate and SE. DoubleMLLPLR also persists its IF components in the inverted (offset, slope) order and is handled by passing the two arrays swapped at its single call site -- its bootstrap output is bit-identical to v0.89.0. (The list above was not backfilled for v0.70-v0.85 or v0.87-v0.89; see CHANGELOG.md for those entries.)

    ##Attribution

    moonbit_doubleML is a port of the upstream DoubleML/doubleml-for-py package (BSD 3-Clause License). Upstream's 20 name-matched estimator classes are re-implemented in pure MoonBit with the same score formulae and variance estimators, as is its RDD implementation (RDFlex upstream, DoubleMLRDD here). DoubleMLPLPR, DoubleMLLPLR and DoubleMLDIDCSBinary are not MoonBit-only -- all three ship upstream, and are compared against it. The one genuinely port-only estimator is DoubleMLDIDCrossSection, which has no upstream counterpart in 0.11.4.

    #Differences from upstream doubleml-for-py

    • Native MoonBit package layout, type system, and test organization.
    • Independent implementations of Matrix, linear / logistic regression, stratified K-fold, ChaCha8 PRNG, and Kahan summation in MoonBit; no NumPy / scikit-learn dependency.
    • DoubleMLData / DoubleMLXxx::new().fit() as the canonical MoonBit-idiomatic API, with optional JSON in/out for native / wasm-gc / wasm / js backends (Serverless / Wasm / edge deployments).
    • Behaviour-level parity check via the 24 Python reference scripts: within-tolerance equivalence rather than bit-exact re-implementation. This keeps the port resilient to upstream refactors.
    • Strict dependency rule (see above): only moonbitlang/* official packages. Minimal supply-chain surface.

    ##Used By

    • riantr/pyroduct -- downstream consumer that models Ren Yongxiang's MA thesis (Gadamer/Habermas, Shanghai Academy of Social Sciences 2008) plus its appendix as runnable multi-agent state machines. Its dmlref package cross-checks its own from-scratch DoubleML PLR against riantr/moonbit_doubleML v0.64.0+: same nuisance, theta delta 0.017, se delta 0.001. Source lives on gitee.com/ren-yongxiang/pyroduct.
    • examples/api_server/ -- this repo's HTTP service.
    • examples/consumer_demo/ -- minimal end-to-end PLR showing the consumer-side import pattern (moon add riantr/moonbit_doubleML).

    Learner

    pub trait Learner {
    fn fit(Self, Matrix, Array[Double], Array[Double]) -> Self
    fn predict(Self, Matrix) -> Array[Double]
    }

    Trait for any learner that can be fit on (x, y) and predict on a feature matrix. Used by the DML cross-fit helpers and the PLR estimator.

    v0.118.0: fit takes a THIRD argument, sample_weight, matching upstream doubleml's protocol -- its ml_g / ml_m are objects with fit(X, y, sample_weight), so a learner without that parameter could not be a drop-in replacement, and a library that cannot express a weighted fit cannot port the DR-learner.

    An EMPTY weight array means "unweighted". That is the whole backward-compatibility story: every cross-fit helper in this package takes sample_weight? defaulting to [], so all 22 estimators and every existing caller are byte-identical. It also mirrors scikit-learn's sample_weight=None.

    MoonBit traits do NOT support a default method body (measured: adding one with parameters fails with Missing type annotation forthe parameter), so a defaulted fit_weighted is not an option. Each impl dispatches on emptiness itself; the shared helper is w_is_unweighted(w) below rather than a trait default.

    BootstrapMethodError

    pub suberror BootstrapMethodError {
    UnknownMethod(String)
    }

    Error type raised by draw_bootstrap_weights when the requested bootstrap method is not one of the supported options ("normal", "Bayes", "wild"). The payload is the unknown method name, which the caller can use to log or surface a meaningful diagnostic.

    Added in v0.37.0 to convert the previous abort() call into a testable error path. The pre-v0.37.0 panic_bootstrap_invalid_method test only exercised the require check in DoubleMLDIDMulti::bootstrap (which fires before the _ => abort fallback) — the test was silently skipped on native/wasm-gc. The new test draw_bootstrap_weights_raises_unknown_method calls draw_bootstrap_weights directly with an invalid method, exercising the previously-unreachable _ => abort path.

    BracketSignError

    pub suberror BracketSignError {
    UpperSignFailed
    }

    Error type raised by solve_pq when the IPW score at the upper bracket remains non-positive after 20 exponential widens — the score is structurally non-monotonic on the bracket, the quantile is non-monotonic in this data, or q is too close to 1 with sparse treatment.

    Added in v0.42.0 to convert the previous abort() call in solve_pq into a testable error path. The pre-v0.42.0 abort was reachable only via the (private) solve_pq helper; no public test exercised it. The new solve_pq_raises_on_upper_bracket_sign_failure test constructs a pathological DGP/quantile combination and asserts the error fires.

    Note: the pre-v0.42.0 source also had a lower-bracket-sign-failed abort at solve_pq. That abort is dead code: at lo = y_min - margin < y_min, every 1{y <= lo} = 0, so the IPW score treated/m * 0 - q is -q < 0 for all q > 0. The lo_score >= 0.0 check never fires. The dead abort is removed in v0.42.0; the suberror type has only the reachable UpperSignFailed variant.

    CalibrationFittingError

    pub suberror CalibrationFittingError {
    IncompleteCVPartition
    }

    Error type raised by isotonic_calibrate_cv when the provided cv partition does not cover every input index (some input rows are not in any fold's test_idx). This indicates a malformed cv partition passed by the caller.

    Added in v0.38.0 to convert the previous abort() call into a testable error path. The pre-v0.38.0 abort was reachable only via the (formerly private) isotonic_calibrate_cv helper; no public test exercised it. The new isotonic_calibrate_cv_raises_incomplete_partition test constructs a deliberately-malformed cv partition and asserts the error fires.

    ClusterDataError

    pub suberror ClusterDataError {
    MissingUnit(Int)
    }

    Error type raised by build_row_unit_map when a row's unit id is not present in the uniq list (a malformed cluster vector — the cluster ids must form a subset of the unique unit ids). The payload is the missing unit id, which the caller can use to log or surface a meaningful diagnostic.

    Added in v0.36.0 to convert the previous abort() call into a testable error path. The pre-v0.36.0 panic_build_row_unit_map_missing_unit test was silently skipped on native/wasm-gc (MoonBit's panic_* driver skips panic-prefixed tests; see _verify/WHITEBOX_T_REPORT.md).

    DIDDataError

    pub suberror DIDDataError {
    NonBinaryTreatment(Int)
    }

    Error type raised by DoubleMLDIDData::new when the treatment vector d is non-binary. The default port supports only the binary d ∈ {0, 1} convention (only the switchers; the multi-valued {-1, 0, 1} Sant'Anna & Zhao 2020 convention is not implemented). The payload is the index of the first non-binary entry, which the caller can use to log a meaningful diagnostic.

    Added in v0.43.0 to convert the previous abort() call into a testable error path. The pre-v0.43.0 abort was silently skipped on native/wasm-gc via the panic_* driver behavior (see _verify/WHITEBOX_T_REPORT.md). The new did_data_raises_on_non_binary_treatment test constructs a DoubleMLDIDData with a non-binary d entry and asserts the error fires.

    EmptyArrayError

    pub suberror EmptyArrayError

    Error type raised by array_min and array_max when the input array is empty. No payload — the empty array case has no diagnostic detail to carry.

    Added in v0.41.0 to convert the previous abort() calls into testable error paths. The pre-v0.41.0 aborts were reachable only via internal helpers; no public test exercised them. The new array_min_raises_on_empty and array_max_raises_on_empty tests call the helpers directly with [] and assert the errors fire.

    InvalidCalibrationError

    pub suberror InvalidCalibrationError {
    UnknownMethod(String)
    }

    Error type raised by apply_calibration (extracted from PSProcessor::adjust_ps in v0.37.0) when the configured calibration method is not one of the supported options ("none", "isotonic"). The payload is the unknown method name, which the caller can use to log or surface a meaningful diagnostic.

    Added in v0.37.0 to convert the previous abort() call inside adjust_ps into a testable error path. The panic_* test driver limitation had not been observed for this path because no test exercised it; the new test ps_processor_adjust_ps_raises_unknown_method constructs a config directly (bypassing PSProcessorConfig::new's require check) to reach the previously-unreachable abort.

    PSConfigError

    pub suberror PSConfigError {
    InconsistentCVCalibration
    }

    Error type raised by PSProcessorConfig::new when the configuration is internally inconsistent — the only currently-reachable case is cv_calibration = true combined with calibration_method = "none", because CV calibration is only meaningful for the isotonic method. No payload — the call-site (the require checks for clipping/extreme thresholds and calibration_method string) is enough to identify the configuration error.

    Added in v0.44.0 to convert the previous abort() call into a testable error path. The pre-v0.44.0 panic_ps_processor_cv_without_calibration test was silently skipped on native/wasm-gc (MoonBit's panic_* driver skips panic-prefixed tests; see _verify/WHITEBOX_T_REPORT.md). The new ps_processor_config_raises_inconsistent_cv_calibration test calls PSProcessorConfig::new directly with the inconsistent config and asserts the error fires.

    PreconditionError

    pub suberror PreconditionError {
    Violated(SourceLoc)
    }

    Error type reserved for the upcoming conversion of check.mbt::check and check.mbt::require from abort("precondition failed at ...") to a typed raise. The payload is the SourceLoc of the failing call site (auto-injected by #callsite(autofill(loc)) at every call), so the diagnostic message will point at the offending source line.

    Added in v0.47.0 as a planning release. v0.47.0 only declares the suberror type and ships a tiny check_make_violated helper (in check.mbt) that constructs a Violated(loc) for the regression test. The actual check/require → raise PreconditionError conversion is targeted for v0.48.0+ (cascade to all 324 pub functions, expected 1-2 release effort with try/catch/re-abort shims at every call site so the public abort behavior is preserved during the transition).

    VarEstClusterError

    pub suberror VarEstClusterError {
    JTooSmall(Double, Double, Int)
    }

    Error type raised by var_est_cluster when the fold-mean J = mean(psi_deriv) lands below the 1e-6 floor (a defensive guard added in v0.34.0 against divide-by-near-zero inflations of the cluster SE; see the rationale comment in var_est_cluster below). The payload captures the exact (j, g, n_units) triple that triggered the floor so callers can log or surface a meaningful diagnostic.

    Callers that want to preserve the pre-v0.35.0 process-death behavior should match on this variant and re-abort; callers that want to retry or surface the error to downstream consumers should propagate it via ?.

    CART

    pub enum CART {
    Leaf(Double)
    Split(Int, Double, CART, CART)
    } derive(
    Debug
    )

    Immutable CART tree node. Split carries the feature column index, the threshold, and the two child subtrees. Leaf carries the per-leaf mean (the constant prediction for rows that land here).

    CART::to_repr

    CSBinPanelRow

    type CSBinPanelRow derive(
    Debug
    )

    Per-row output of cs_bin_panel_subset. Each row is one observation in the post-subset panel: a (G_indicator, T_indicator) pair, the covariates, and the (still-raw) outcome y.

    ConstantLearner

    pub struct ConstantLearner {
    value : Double
    } derive(
    Debug
    )

    Constant-predictor learner. fit is a no-op (returns self); predict returns a length-x.rows() array of the constant value for every row, ignoring x.

    ConstantLearner::fit

    fn ConstantLearner::fit(self : ConstantLearner, x : Matrix, y : Array[Double]) -> ConstantLearner

    v0.118.0: the body became this inherent method so that ConstantLearner::new(..).fit(x, y) still resolves; the trait method (three-argument) delegates here.

    ConstantLearner::new

    fn ConstantLearner::new(value : Double) -> ConstantLearner

    ConstantLearner::predict

    fn ConstantLearner::predict(self : ConstantLearner, x : Matrix) -> Array[Double]

    DIDAggregationResult

    pub struct DIDAggregationResult {
    theta : Array[Double]
    se : Array[Double]
    agg_names : Array[String]
    } derive(
    Debug
    )

    Aggregation result for a single axis (group, time, or event). theta is the weighted-mean ATT, se is the delta-method SE, agg_names[i] is the human-readable label (e.g. the group value, the time period, or the event-time value).

    DidCsData

    type DidCsData

    Result of the DID CS2021 DGP.

    DidCsData::d_get

    fn DidCsData::d_get(self : DidCsData) -> Array[Double]

    Get the treatment vector.

    DidCsData::g_get

    fn DidCsData::g_get(self : DidCsData) -> Array[Int]

    Get the group (cohort) index.

    DidCsData::id_get

    fn DidCsData::id_get(self : DidCsData) -> Array[Int]

    Get the unit id (within cohort).

    DidCsData::n_groups_get

    fn DidCsData::n_groups_get(self : DidCsData) -> Int

    Get the number of cohorts.

    DidCsData::n_periods_get

    fn DidCsData::n_periods_get(self : DidCsData) -> Int

    Get the number of periods.

    DidCsData::t_get

    fn DidCsData::t_get(self : DidCsData) -> Array[Int]

    Get the time-period index.

    DidCsData::theta_get

    fn DidCsData::theta_get(self : DidCsData) -> Double

    Get the true ATT parameter.

    DidCsData::x_get

    fn DidCsData::x_get(self : DidCsData) -> Matrix

    Get the covariate matrix (panel-level, n_obs x dim_x).

    DidCsData::y_get

    fn DidCsData::y_get(self : DidCsData) -> Array[Double]

    Get the outcome vector.

    DidMultiData

    type DidMultiData

    Result of the DID CS2021 multi-cohort DGP.

    DidMultiData::d_get

    fn DidMultiData::d_get(self : DidMultiData) -> Array[Double]

    DidMultiData::g_get

    fn DidMultiData::g_get(self : DidMultiData) -> Array[Int]

    DidMultiData::id_get

    fn DidMultiData::id_get(self : DidMultiData) -> Array[Int]

    DidMultiData::n_groups_get

    fn DidMultiData::n_groups_get(self : DidMultiData) -> Int

    DidMultiData::n_periods_get

    fn DidMultiData::n_periods_get(self : DidMultiData) -> Int

    DidMultiData::t_get

    fn DidMultiData::t_get(self : DidMultiData) -> Array[Int]

    DidMultiData::theta_get

    fn DidMultiData::theta_get(self : DidMultiData) -> Double

    DidMultiData::x_get

    fn DidMultiData::x_get(self : DidMultiData) -> Matrix

    DidMultiData::y_get

    fn DidMultiData::y_get(self : DidMultiData) -> Array[Double]

    DoubleMLAPO

    pub struct DoubleMLAPO {
    data : DoubleMLData
    treatment_level : Double
    n_folds : Int
    n_rep : Int
    seed : Int
    smpls : Array[Array[Fold]]
    propensity_clip : Double
    ps_processor_config : PSProcessorConfig
    normalize_ipw : Bool
    ml_g : LearnerDispatch
    ml_m : LearnerDispatch
    g_hat : Array[Double]
    m_hat : Array[Double]
    coef : Double
    se : Double
    fitted : Bool
    psi_a : Array[Double]
    psi_b : Array[Double]
    boot_t_stat : Array[Double]
    boot_method : String
    n_rep_boot : Int
    boot_seed : Int
    memoize_enabled : Bool
    fit_cache : FitCache
    } derive(
    Debug
    )

    Average potential outcomes for an arbitrary treatment level.

    DoubleMLAPO::bias_corrected_coef

    fn DoubleMLAPO::bias_corrected_coef(self : DoubleMLAPO) -> Double

    v0.91.0+: returns coef UNCHANGED -- a documented no-op, not a bias correction.

    coef is the root of the DML moment f(theta) = E[theta * psi_a + psi_b] (see var_est.mbt), so mean(f(coef)) is identically zero: the estimating function is orthogonal by construction, and that orthogonality IS what makes the estimator consistent. Nothing computable from the fitted scores is a bias estimate for this class of estimator, so this accessor reports the uncorrected point estimate rather than a number that merely looks like a correction.

    (APO's psi_a is the constant -1.)

    v0.79.0 - v0.90.0 returned coef + mean(psi_b - coef * psi_a). That vector is the score at -coef, NOT at coef; since coef = -mean_b / mean_a its mean is mean_b - coef * mean_a = -2 * coef * mean_a, so the accessor returned coef * (1 - 2 * mean(psi_a)) (exactly 3 * coef when mean(psi_a) = -1). That is not a bias estimate. See bias_corrected_theta in sandwich.mbt for the algebra. The method is kept so the API surface stays stable; removing it outright is the obvious follow-up.

    Preconditions: self.fitted.

    DoubleMLAPO::bootstrap

    fn DoubleMLAPO::bootstrap(self : DoubleMLAPO, method_name? : String, n_rep_boot? : Int, seed? : Int) -> DoubleMLAPO

    v0.61.0+: multiplier bootstrap for DoubleMLAPO. The per-observation influence function is

    psi[i] = theta * psi_a[i] + psi_b[i] = -1 + theta * pb[i]

    where pb[i] = g[i] + treated[i] * (y[i] - g[i]) / m[i] is the IPW-centred potential-outcome score, computed at the fitted coef from the stored g_hat / m_hat. Draws n_rep_boot weight vectors of length n_obs from the chosen multiplier distribution, and returns a fitted model with boot_t_stat[b] = sum_i w[b, i] * psi[i] /(sqrt(n) * se_psi) populated where se_psi = sqrt(mean(psi^2)).

    method_name selects the multiplier distribution:
    • "normal" (default): w[i] ~ N(0, 1).
    • "Bayes": w[i] = exp(1) - 1 (mean 0, var 1).
    • "wild": w[i] = x[i] / sqrt(2) + (y[i]^2 - 1) / 2 with x, y ~ N(0, 1).

    Calling bootstrap requires the model to be fitted; calling on an un-fit model aborts with PreconditionError. The helper is did_bootstrap_t_stat (v0.55.0 extracted from DoubleMLDIDCrossSection::bootstrap); APO is the n_thetas=1 case.

    DoubleMLAPO::clear_cache

    fn DoubleMLAPO::clear_cache(self : DoubleMLAPO) -> DoubleMLAPO

    v0.84.0+: drop the cached nuisances. After this, the next fit() will run the full cross-fit (and repopulate the cache if memoize is still enabled).

    DoubleMLAPO::cluster_sandwich_se

    fn DoubleMLAPO::cluster_sandwich_se(self : DoubleMLAPO, cluster_ids : Array[Int]) -> Double

    v0.89.0+: cluster-robust sandwich standard error for the fitted APO. Routes through cluster_sandwich_variance with the same psi_a / psi / M_inv inputs as the IID sandwich_se path. cluster_ids[i] is the cluster of observation i on the full-sample (not transformed) domain. When the model was fitted on cluster data (data.cluster_vars non-empty) the natural cluster_ids are those very unit ids; otherwise the caller supplies them.

    Preconditions: self.fitted, cluster_ids.length() == psi_a.length().

    DoubleMLAPO::coef

    fn DoubleMLAPO::coef(self : DoubleMLAPO) -> Double

    DoubleMLAPO::confint

    fn DoubleMLAPO::confint(self : DoubleMLAPO) -> (Double, Double)

    DoubleMLAPO::disable_memoize

    fn DoubleMLAPO::disable_memoize(self : DoubleMLAPO) -> DoubleMLAPO

    v0.84.0+: turn off memoization. After this, fit() will not read or write the cache; the existing fit_cache is preserved on the returned struct (call clear_cache() to drop it).

    DoubleMLAPO::enable_memoize

    fn DoubleMLAPO::enable_memoize(self : DoubleMLAPO) -> DoubleMLAPO

    v0.84.0+: turn on memoization. When enabled (and n_rep == 1), the next fit() caches the LAST rep's (g_hat, m_hat,psi_a, psi_b) plus row-to-fold mapping; subsequent fit() calls with the same data fingerprint, fold split, learner configuration, propensity clip, and cluster partition reuse the cached nuisances. The var_est(psi_a, psi_b) -> (coef,se) pipeline still runs fresh on every fit(), so any post-aggregation configuration change always takes effect.

    DoubleMLAPO::fit

    DoubleMLAPO::has_cache

    fn DoubleMLAPO::has_cache(self : DoubleMLAPO) -> Bool

    v0.84.0+: true iff fit_cache holds at least one cached nuisance prediction (i.e. a previous fit() with memoize_enabled = true has populated the cache). Note that has_cache() does NOT verify the cache key matches the current data + learner configuration -- check memoize_enabled if you also need to know whether the next fit() will hit.

    DoubleMLAPO::learner_g

    fn DoubleMLAPO::learner_g(self : DoubleMLAPO) -> LearnerDispatch

    Accessor for the outcome-nuisance learner used by the most recent fit(...) call. v0.60.0+.

    DoubleMLAPO::learner_m

    fn DoubleMLAPO::learner_m(self : DoubleMLAPO) -> LearnerDispatch

    Accessor for the propensity-score learner used by the most recent fit(...) call. v0.60.0+.

    DoubleMLAPO::n_features

    fn DoubleMLAPO::n_features(self : DoubleMLAPO) -> Int

    Number of features (covariate columns).

    DoubleMLAPO::n_obs

    fn DoubleMLAPO::n_obs(self : DoubleMLAPO) -> Int

    DoubleMLAPO::new

    fn DoubleMLAPO::new(data : DoubleMLData, treatment_level? : Double, n_folds? : Int, n_rep? : Int, seed? : Int, propensity_clip? : Double, normalize_ipw? : Bool, ps_processor_config? : PSProcessorConfig?, ml_g? : LearnerDispatch, ml_m? : LearnerDispatch) -> DoubleMLAPO

    DoubleMLAPO::predictions_g

    fn DoubleMLAPO::predictions_g(self : DoubleMLAPO) -> Array[Double]

    DoubleMLAPO::predictions_m

    fn DoubleMLAPO::predictions_m(self : DoubleMLAPO) -> Array[Double]

    DoubleMLAPO::sample_splitting

    fn DoubleMLAPO::sample_splitting(self : DoubleMLAPO) -> SampleSplitting?

    The sample splitting recorded by set_sample_splitting, or None when none was supplied and fit draws its own. Some carries the DERIVED counts next to the folds, so a caller can read back what the estimator actually used rather than inferring it from the constructor arguments it no longer has any say over.

    A Some here does NOT mean the folds were used: on the cluster path fit_cluster draws unit-level folds and ignores them. See set_sample_splitting.

    DoubleMLAPO::sandwich_se

    fn DoubleMLAPO::sandwich_se(self : DoubleMLAPO, kind : SandwichKind) -> Double

    v0.89.0+: Huber-White sandwich standard error for the fitted APO (average potential outcome at treatment_level). Returns sqrt(var) where var comes from the shared sandwich_variance(kind, ...) dispatch in sandwich.mbt (HC0 / HC1 / HC2 / HC3).

    The three inputs are the ones fit(...) already persists for the multiplier bootstrap (see the psi_a / psi_b field docs on DoubleMLAPO):
    • psi_a = self.psi_a -- the potential-outcome score's treatment term, structurally the constant -1 (the potential-outcome score has no treatment-side variation), so mean(psi_a) == -1 and M_inv = [[-1.0]].
    • psi = psi_at(self.coef, self.psi_a, self.psi_b) -- the per-observation influence function at the fitted coef, with psi_b the IPW-centred score g[i] + treated[i] * (y[i] - g[i]) / m[i]. Matches the psi(theta) = theta * psi_a + psi_b form documented on DoubleMLAPO::bootstrap.
    • M_inv = [[1 / mean(psi_a)]] (1x1). Only M_inv[0, 0]^2 enters the variance, so the sign of the (up-to-sign) Jacobian inverse is irrelevant.

    APO has no transformed / subset domain: both the IID path and the v0.65.0 cluster path (fit_cluster) persist length-n_obs IF components, so the variance's n_obs is self.psi_a.length() == self.n_obs().

    Preconditions: self.fitted, mean(psi_a) != 0.

    DoubleMLAPO::se

    fn DoubleMLAPO::se(self : DoubleMLAPO) -> Double

    DoubleMLAPO::sensitivity_analysis

    fn DoubleMLAPO::sensitivity_analysis(self : DoubleMLAPO, cf_y? : Double, cf_d? : Double) -> SensitivityResult raise

    DoubleMLAPO::sensitivity_analysis_cluster

    fn DoubleMLAPO::sensitivity_analysis_cluster(self : DoubleMLAPO, cluster_ids? : Array[Int], cf_y? : Double, cf_d? : Double) -> SensitivityResult raise

    v0.72.0+: cluster-robust analogue of DoubleMLAPO::sensitivity_analysis. Same residual formula (y - g_hat) and the same psi_a = -1 (constant) as the IID path; only the variance / bias computation is cluster-aware. cluster_ids defaults to DoubleMLData::cluster_vars.

    DoubleMLAPO::sensitivity_nu2_elements

    fn DoubleMLAPO::sensitivity_nu2_elements(self : DoubleMLAPO) -> (Array[Double], Array[Double]) raise

    v0.131.7: the nu2 score element and the Riesz representer for the APO policy score, transcribed from upstream irm/apo.py:371-382.

    Upstream's two layers collapse here as they do for IRM:

    • weights, weights_bar = self._get_weights(). _initialize_weights defaults to np.ones(n_obs) (apo.py inherits it from irm.py:249) and there is no weights_bar key, so weights == weights_bar == 1 (apo.py:234-242). Note APO's _get_weights takes NO m_hat argument -- only IRM's does, and only for ATTE.
    • m_hat_adj = _propensity_score_adjustment(m_hat, treated, normalize_ipw) (apo.py:365-367), which returns m_hat unchanged when normalize_ipw is False. This port HAS the flag (apo.mbt:44) and calls the same helper at apo.mbt:319, so the adjusted propensity is available directly rather than needing a new implementation.

    With both weight factors equal to 1:

    m_alpha = 1 / m_hat_adj rr = treated / m_hat_adj nu2 = mean( 2*m_alpha - rr^2 )

    treated[i] = 1{d[i] == treatment_level} (apo.mbt:62), matching upstream's self.treated.

    The default path previously computed mean(psi_a^2), upstream's documented FALLBACK (double_ml.py:1634-1641) applied to the wrong variable. APO's psi_a is the constant -1, so the fallback collapsed nu2 to exactly 1.

    DoubleMLAPO::set_sample_splitting

    fn DoubleMLAPO::set_sample_splitting(self : DoubleMLAPO, smpls : Array[Array[Fold]]) -> DoubleMLAPO

    Record a caller-supplied sample splitting and DERIVE the fold and repetition counts from it. Returns a new DoubleMLAPO; the receiver is unchanged, as everywhere in this package.

    smpls is one entry per repetition, each entry one Fold per fold -- the nested form is the only form, so a single repetition is [f0, f1, f2] wrapped once, i.e. [[f0, f1, f2]].

    The derivation is upstream's, and it is the part that surprises people: set_sample_splitting (doubleml/double_ml_sampling_mixins.py:76) sets n_folds and n_rep FROM the supplied partition, so handing a two-fold split to a model built with n_folds=5 yields a two-fold model. On the returned model n_folds / n_rep are updated to match, and fit uses the supplied folds instead of drawing them.

    Validation, via check_sample_splitting: every repetition carries the same number of folds, and within each repetition the test index sets are disjoint and cover exactly [0, n_obs). A supplied partition that is not a partition aborts rather than quietly producing a cross-fit that leaks across folds.

    Applies to the IID path only

    fit dispatches to fit_cluster when cluster_vars is non-empty, and that branch keeps drawing its own UNIT-level folds (kfold(n_units, self.n_folds, ...), then expanded to rows). This method supplies ROW-level folds, so on the cluster path they are ignored: the splitting is recorded, reported by sample_splitting, and carried across the cluster fit, but it does not change the folds that path uses. That is a real asymmetry and is stated here rather than papered over. Wiring it up would mean a second, unit-indexed splitting and a unit-indexed validation, which check_sample_splitting does not provide; until that exists, use fit on row-level data when you need the split to be honoured.

    Memoization is turned OFF on the returned model regardless of memoize_enabled. The fit cache is keyed on data + learner + cluster hashes with no term for the fold assignment, so caching an externally-split fit could return a DIFFERENT partition's predictions. The fit cache is also cleared here, for the same reason.

    Note that a subsequent fit() on the returned model rebuilds boot_t_stat / n_rep_boot from scratch, so a bootstrap computed before set_sample_splitting is dropped by that fit. That is the package-wide convention, not something this method introduces; call bootstrap(...) again on the fitted model if you need it.

    DoubleMLAPO::tune

    fn DoubleMLAPO::tune(self : DoubleMLAPO, param_set~ : Array[TuneParam], scoring_method? : String, n_folds_tune? : Int, seed? : Int) -> DoubleMLAPO

    v0.65.0+: tune the (ml_g, ml_m) nuisance-learner pair via MSE-on-g_hat cross-fitting. The chosen (learner_g,learner_m) is then re-fit on the FINAL-FIT fold partition (self.n_folds) under DoubleMLAPO::fit. treatment_level is held fixed at self.treatment_level.

    param_set is an Array[TuneParam]; each entry is a (learner_g, learner_m) pair. scoring_method is "MSE" (default), "RMSE", or "NegMSE". Returns a re-fitted DoubleMLAPO with the chosen pair applied.

    DoubleMLAPOS

    pub struct DoubleMLAPOS {
    data : DoubleMLData
    treatment_levels : Array[Double]
    n_folds : Int
    n_rep : Int
    seed : Int
    smpls : Array[Array[Fold]]
    propensity_clip : Double
    ps_processor_config : PSProcessorConfig
    normalize_ipw : Bool
    ml_g : LearnerDispatch
    ml_m : LearnerDispatch
    coefs : Array[Double]
    ses : Array[Double]
    fitted : Bool
    boot_t_stat : Array[Array[Double]]
    boot_method : String
    n_rep_boot : Int
    boot_seed : Int
    memoize_enabled : Bool
    fit_cache : FitCache
    psi_b_flat : Array[Double]
    } derive(
    Debug
    )

    Average potential outcomes symmetric across multiple treatment levels. v0.49.0: full upstream parity — treatment_levels validation in new (rejects duplicates and levels not in data.d), the causal_contrast method (level-by-level delta and SE vs a reference level), and the treatment_levels() / n_treatment_levels() / fitted() accessors.

    Each treatment level is fit with the same fold partition (the parent does not currently route a shared partition to the child DoubleMLAPO; each child draws its own folds via kfold. v0.50.0+ plans to wire the parent through a fit_with_splits helper to share one stratified partition, but the v0.49.0 implementation is the v0.50.0 PR target) and the same closed-form LinearRegression learner. The causal_contrast(reference_levels) method then returns the level-by-level difference coefs[i] - coefs[ref] for one or more reference levels, matching the upstream DoubleMLAPOS.causal_contrast semantics.

    DoubleMLAPOS::bias_corrected_coef_at

    fn DoubleMLAPOS::bias_corrected_coef_at(self : DoubleMLAPOS, treatment_index : Int) -> Double

    v0.100.0+: returns coefs[treatment_index] UNCHANGED -- a documented no-op, not a bias correction. Identical in reasoning to DoubleMLAPO::bias_corrected_coef (v0.91.0, the sibling estimator) and to DoubleMLQTE::bias_corrected_coef_at; see DoubleMLPQ::bias_corrected_coef and bias_corrected_theta in sandwich.mbt for the full argument.

    coefs[j] is the root of the DML moment f(theta) = E[theta * psi_a + psi_b] (var_est.mbt), so mean(f(coefs[j])) is identically zero: the estimating function is orthogonal by construction, and that orthogonality IS what makes the estimator consistent. Nothing computable from the fitted scores is a bias estimate for this class of estimator, so this accessor reports the uncorrected point estimate rather than a number that merely looks like a correction.

    The pre-v0.91.0 form coef + mean(psi_b - coef * psi_a) is that vector at -coef, NOT at coef; since coef = -mean_b / mean_a its mean is mean_b - coef * mean_a = -2 * coef * mean_a, so the accessor returned coef * (1 - 2 * mean(psi_a)) -- exactly 3 * coef when mean(psi_a) = -1 (APO's case). That is not a bias estimate.

    The method is per level, matching the rest of the v0.100.0 APOS API.

    Preconditions: self.fitted, 0 <= treatment_index < coefs.length().

    DoubleMLAPOS::boot_method

    fn DoubleMLAPOS::boot_method(self : DoubleMLAPOS) -> String

    DoubleMLAPOS::boot_seed

    fn DoubleMLAPOS::boot_seed(self : DoubleMLAPOS) -> Int

    DoubleMLAPOS::boot_t_stats

    fn DoubleMLAPOS::boot_t_stats(self : DoubleMLAPOS) -> Array[Array[Double]]

    Per-level boot_t_stat accessors (v0.63.0+).

    DoubleMLAPOS::bootstrap

    fn DoubleMLAPOS::bootstrap(self : DoubleMLAPOS, method_name? : String, n_rep_boot? : Int, seed? : Int) -> DoubleMLAPOS

    Per-level multiplier bootstrap for DoubleMLAPOS. Each treatment level's child DoubleMLAPO is re-fit (deterministic given seed) and its bootstrap(...) invoked; the per-level boot_t_stat arrays are concatenated into a length-n_levels Array[Array[Double]] and also written to self.boot_t_stat.

    method_name selects the multiplier distribution: "normal" (default), "Bayes", "wild" — see DoubleMLAPO::bootstrap. n_rep_boot defaults to 500; seed defaults to 2024.

    DoubleMLAPOS::causal_contrast

    fn DoubleMLAPOS::causal_contrast(self : DoubleMLAPOS, reference_levels : Array[Double]) -> Array[Array[Double]]

    v0.49.0: causal contrasts between the requested treatment levels and the supplied reference level(s). Returns one Array[Double] of length 2 * treatment_levels.length() - 1 per reference level: the ref-level slot is a single 0.0 (its contrast is trivially zero), and every other slot is a (delta, se) pair where delta = coefs[i] - coefs[ref_idx] and se = sqrt(se[i]^2 + se[ref_idx]^2). ref_idx is the position of the reference level in treatment_levels. The layout is interleaved ([0.0, delta_0, se_0, delta_1, se_1, ...] for a 2-level input) rather than a [(level, coef, se), ...] table — see apo_test.mbt::apos_causal_contrast_with_reference for the actual indices.

    The SE of each contrast is computed via the standard var(psi_a) + var(psi_b) - 2*cov(psi_a, psi_b) style approximation; for v0.49.0 we take the conservative sqrt(se_a^2 + se_b^2) route (same as the upstream causal_contrast summary table), which is exact when the per-level psi_a and psi_b are independent across levels (true under stratified kfold with disjoint train indices).

    DoubleMLAPOS::clear_cache

    fn DoubleMLAPOS::clear_cache(self : DoubleMLAPOS) -> DoubleMLAPOS

    v0.84.0+: drop the cached (coefs, ses) arrays. After this, the next fit() will run the per-level loop fresh (and repopulate the cache if memoize is still enabled).

    DoubleMLAPOS::cluster_sandwich_se_at

    fn DoubleMLAPOS::cluster_sandwich_se_at(self : DoubleMLAPOS, treatment_index : Int, cluster_ids : Array[Int]) -> Double

    v0.100.0+: cluster-robust sandwich standard error for treatment level treatment_index of a fitted APOS (Arellano 1987, Cameron-Gelbach-Miller 2011). Routes through cluster_sandwich_variance with the SAME psi_a / psi / M_inv inputs as the IID sandwich_se_at path.

    DoubleMLData carries cluster_vars, so a clustered fit can pass self.data.cluster_vars directly; on the IID constructor path (cluster_vars = []) the caller must supply the grouping variable explicitly -- any clustering the application has: region, site, panel unit. With all-singleton clusters the meat degenerates to sum_i psi[i]^2 and the formula collapses to sandwich_se_at(j, HC0), hence to ses[j] on an IID fit.

    NOT equal to the IID HC0 with real clusters, on two counts: the within-cluster cross terms enter the meat, and the denominator is n_obs (observations) here while a clustered FIT's ses[j] is divided by n_units. This is the same row-level-vs-unit-level distinction QTE documents.

    Preconditions: self.fitted, 0 <= treatment_index < treatment_levels.length(), mean(psi_a) != 0, cluster_ids.length() == n_obs.

    DoubleMLAPOS::coefs

    fn DoubleMLAPOS::coefs(self : DoubleMLAPOS) -> Array[Double]

    DoubleMLAPOS::confint

    fn DoubleMLAPOS::confint(self : DoubleMLAPOS, level? : Double, joint? : Bool) -> Array[(Double, Double)]

    Per-level Wald confidence intervals (coef - z * se, coef + z * se) for DoubleMLAPOS. Returns one (lo, hi) tuple per treatment level, in user-supplied order. level defaults to 0.95 (z = 1.959963984540054 for the standard normal).

    DoubleMLAPOS::disable_memoize

    fn DoubleMLAPOS::disable_memoize(self : DoubleMLAPOS) -> DoubleMLAPOS

    v0.84.0+: turn off memoization. After this, fit() will not read or write the cache; the existing fit_cache is preserved on the returned struct (call clear_cache() to drop it).

    DoubleMLAPOS::enable_memoize

    fn DoubleMLAPOS::enable_memoize(self : DoubleMLAPOS) -> DoubleMLAPOS

    v0.84.0+: turn on memoization. Forwards .enable_memoize() to each child DoubleMLAPO on the next call, and caches the resulting (coefs, ses) arrays at the parent level so a repeated fit() call with identical inputs skips the per-level loop entirely.

    DoubleMLAPOS::fit

    DoubleMLAPOS::fitted

    fn DoubleMLAPOS::fitted(self : DoubleMLAPOS) -> Bool

    v0.49.0: whether fit has been called.

    DoubleMLAPOS::has_cache

    fn DoubleMLAPOS::has_cache(self : DoubleMLAPOS) -> Bool

    v0.84.0+: true iff fit_cache holds at least one cached (coefs, ses) pair (i.e. a previous fit() with memoize_enabled = true has populated the cache). Note that has_cache() does NOT verify the cache key matches the current data + learner configuration -- check memoize_enabled if you also need to know whether the next fit() will hit.

    DoubleMLAPOS::learner_g

    fn DoubleMLAPOS::learner_g(self : DoubleMLAPOS) -> LearnerDispatch

    Accessor for the outcome-nuisance learner used by the most recent fit(...) call. v0.60.0+.

    DoubleMLAPOS::learner_m

    fn DoubleMLAPOS::learner_m(self : DoubleMLAPOS) -> LearnerDispatch

    Accessor for the propensity-score learner used by the most recent fit(...) call. v0.60.0+.

    DoubleMLAPOS::n_rep_boot

    fn DoubleMLAPOS::n_rep_boot(self : DoubleMLAPOS) -> Int

    DoubleMLAPOS::n_treatment_levels

    fn DoubleMLAPOS::n_treatment_levels(self : DoubleMLAPOS) -> Int

    v0.49.0: number of requested treatment levels.

    DoubleMLAPOS::new

    fn DoubleMLAPOS::new(data : DoubleMLData, treatment_levels : Array[Double], n_folds? : Int, n_rep? : Int, seed? : Int, propensity_clip? : Double, normalize_ipw? : Bool, ps_processor_config? : PSProcessorConfig?, ml_g? : LearnerDispatch, ml_m? : LearnerDispatch) -> DoubleMLAPOS

    DoubleMLAPOS::sample_splitting

    fn DoubleMLAPOS::sample_splitting(self : DoubleMLAPOS) -> SampleSplitting?

    The sample splitting recorded by set_sample_splitting, or None when none was supplied. Some carries the DERIVED counts next to the folds. As with DoubleMLAPO::sample_splitting, a Some here does NOT by itself prove the folds were used on the cluster path — see set_sample_splitting.

    DoubleMLAPOS::sandwich_se_at

    fn DoubleMLAPOS::sandwich_se_at(self : DoubleMLAPOS, treatment_index : Int, kind : SandwichKind) -> Double

    v0.100.0+: heteroskedasticity-consistent (Huber-White) sandwich standard error for treatment level treatment_index of a fitted APOS. Routes through the shared sandwich_variance dispatch, so the HC0 / HC1 / HC2 / HC3 relationships are the package-wide ones (see the file header in sandwich.mbt and the identities pinned in expand_v088_test.mbt).

    The three inputs are the ones the child DoubleMLAPO already computed and persisted for its own multiplier bootstrap:
    • psi_a = the regenerated constant -1 row;
    • psi = psi_at(coefs[treatment_index], psi_a, psi_b) -- the per-observation influence function at the fitted coefficient, with psi_b the IPW-centred potential-outcome score g[i] + treated[i] * (y[i] - g[i]) / m[i]. Uses the package-wide psi(theta) = theta * psi_a + psi_b order (sandwich.mbt is the authority, not the psi_a + theta * psi_b phrasing some DID-family comments use);
    • M_inv = [[1 / mean(psi_a)]] = [[-1.0]]; only M_inv[0, 0]^2 enters the variance.

    n_obs is the full sample -- the same n_obs the child divides by in var_est -- and n_params is 1: ONE scalar estimand per treatment level, NOT treatment_levels.length(). The plausible misreading k = n_levels would give sqrt(n / (n - k)) for HC1 and is ruled out in the test.

    On the IID path sandwich_se_at(j, HC0) == ses[j] EXACTLY (see the section comment); on a CLUSTERED fit ses[j] is the unit-level cluster-robust SE and the invariant does not hold -- use cluster_sandwich_se_at(j, self.data.cluster_vars).

    Preconditions: self.fitted, 0 <= treatment_index < treatment_levels.length(), mean(psi_a) != 0.

    DoubleMLAPOS::sensitivity_analysis

    fn DoubleMLAPOS::sensitivity_analysis(self : DoubleMLAPOS, cf_y? : Double, cf_d? : Double) -> Array[SensitivityResult] raise

    v0.68.0+: per-level Cinelli & Hazlett (2020) omitted-variable bias analysis for DoubleMLAPOS. Re-fits a child DoubleMLAPO at each treatment level and delegates to its sensitivity_analysis(...). The per-level SensitivityResult is returned in user-supplied order; cross-level aggregation (e.g. for a joint sensitivity bound) is the caller's responsibility. Mirrors the DoubleMLAPOS::bootstrap pattern (re-fit each child, capture per-level results).

    DoubleMLAPOS::sensitivity_analysis_cluster

    fn DoubleMLAPOS::sensitivity_analysis_cluster(self : DoubleMLAPOS, cluster_ids? : Array[Int], cf_y? : Double, cf_d? : Double) -> Array[SensitivityResult] raise

    v0.74.0+: cluster-robust analogue of DoubleMLAPOS::sensitivity_analysis. Re-fits a child DoubleMLAPO at each treatment level and delegates to its sensitivity_analysis_cluster(...) so the per-cluster sigma2_cluster / nu2_cluster are computed against the cluster-summed residuals / psi_a. The per-level SensitivityResult is returned in user-supplied order; cross-level aggregation (e.g. for a joint cluster-robust sensitivity bound) is the caller's responsibility. Mirrors the IID path (sensitivity_analysis) and the cluster-aware sensitivity_analysis_cluster on DoubleMLAPO.

    cluster_ids defaults to DoubleMLData::cluster_vars.

    DoubleMLAPOS::ses

    fn DoubleMLAPOS::ses(self : DoubleMLAPOS) -> Array[Double]

    DoubleMLAPOS::set_sample_splitting

    fn DoubleMLAPOS::set_sample_splitting(self : DoubleMLAPOS, smpls : Array[Array[Fold]]) -> DoubleMLAPOS

    DoubleMLAPOS::set_sample_splitting — the multi-treatment sibling of DoubleMLAPO::set_sample_splitting, with one structural difference worth stating up front.

    APOS does not cross-fit anything itself. fit builds one child DoubleMLAPO per treatment level and calls child.fit(); the fold draw and the cross-fit both happen inside the child. So recording the splitting on the parent is not sufficient — it has to be PROPAGATED to every child, which fit does by composing the child's own setter (a pure builder, so the composition stays a value). Had this been written as "store it and return self", APOS would have looked like it had the feature and been exactly as inert as the v0.65.0 tune tests.

    The counts are derived exactly as upstream's set_sample_splitting does (doubleml/double_ml_sampling_mixins.py:76): n_folds and n_rep come OUT of the supplied partition, so a three-fold split handed to a model built with n_folds=9 yields a three-fold model.

    The same partition reaches every treatment level's child. That is the intended reading — one splitting for the whole model, not one per level — and it matters numerically: independent splits per level would give each coefs[j] a different nuisance cross-fit.

    The cluster caveat is inherited from the child and is real: on cluster data each child dispatches to its own fit_cluster, which draws UNIT-level folds and ignores these ROW-level ones.

    DoubleMLAPOS::treatment_levels

    fn DoubleMLAPOS::treatment_levels(self : DoubleMLAPOS) -> Array[Double]

    v0.49.0: the requested treatment levels, in user-supplied order.

    DoubleMLAPOS::tune

    fn DoubleMLAPOS::tune(self : DoubleMLAPOS, param_set~ : Array[TuneParam], scoring_method? : String, n_folds_tune? : Int, seed? : Int) -> DoubleMLAPOS

    DoubleMLAPOS::tune - score a grid of (learner_g, learner_m) pairs and re-fit with the winner. APOS is the multi-treatment sibling of DoubleMLAPO and tunes the same two slots, so it routes through the same tune_score_grid core.

    v0.108.0: new. APOS had no tune at all, so it was one of the fourteen estimators that carried a coverage gap against upstream (where tune reaches every estimator through the BaseDML mixin).

    The cluster guard is inherited from APO, which itself only got one in v0.108.0 - cluster-DML tune folds must be drawn over unique units rather than rows, and a row-wise schedule on clustered data produces a plausible-looking score for a wrong cross-fit instead of an error.

    DoubleMLBLP

    pub struct DoubleMLBLP {
    basis : Matrix
    orth_signal : Array[Double]
    cov_type : String
    ml_g : LearnerDispatch
    coef : Array[Double]
    se : Array[Double]
    fitted : Bool
    n_obs : Int
    rss : Double
    var_y : Double
    residuals : Array[Double]
    boot_t_stat : Array[Double]
    boot_method : String
    n_rep_boot : Int
    boot_seed : Int
    memoize_enabled : Bool
    fit_cache : FitCache
    } derive(
    Debug
    )

    Best linear predictor of an orthogonal signal on a supplied basis.

    cov_type selects the standard-error convention. Valid values:
    • "HC0" (default): White's heteroskedasticity-consistent sandwich SE — robust to arbitrary residual heteroskedasticity. Matches the upstream doubleml.utils.blp call to statsmodels.OLS(cov_type='HC0').
    • "nonrobust": classic homoskedastic OLS SE (sigma^2 * (X^T X)^{-1} with sigma^2 = RSS / (n - p)), which is only valid under the homogeneous-error assumption.

    REVIEW L5: the field is currently stored on the struct for forward compatibility (post-fit introspection), but is not used outside fit. Construct via DoubleMLBLP::new to validate the value.

    v0.63.0+: ml_g? field accepts a LearnerDispatch. The coef / (X^T X)^{-1} machinery is always the closed-form OLS path (BLP is by definition the OLS projection); the dispatch learner only affects the residual / RSS computation. Defaults to LearnerDispatch::linear_regression() which preserves the v0.62.2 byte-equality behaviour for the standard fit.

    DoubleMLBLP::basis

    fn DoubleMLBLP::basis(self : DoubleMLBLP) -> Matrix

    v0.22.0+: the basis matrix (the BLP's "design matrix"). Shape n_obs x p_features. Used by GainStatsSource::from_blp_cv to refit the BLP on each fold's training subset.

    DoubleMLBLP::bootstrap

    fn DoubleMLBLP::bootstrap(self : DoubleMLBLP, method_name? : String, n_rep_boot? : Int, seed? : Int) -> DoubleMLBLP

    v0.64.0+: multiplier bootstrap for DoubleMLBLP. BLP is the OLS projection of orth_signal onto the augmented design [1, basis]; per-observation IF is psi[i, j] = M[j, :] @ xa_i * e_i where M = (Xa^T Xa + ridge I)^{-1} and xa_i = [1, basis[i, :]]. se_flat is the per-coefficient SE (HC0 sandwich diagonal under cov_type = "HC0", sqrt(sigma^2 * (X^T X)^{-1}_{jj}) under cov_type = "nonrobust"); the helper skips a coefficient when se[j] = 0. Routes through the shared generic_bootstrap_ols_per_coef helper (see bootstrap_helper.mbt).

    method_name selects the multiplier distribution: "normal" (default), "Bayes", "wild". seed defaults to 2024; n_rep_boot defaults to 500.

    boot_t_stat is a flat [n_rep_boot * (p + 1)] array of t-statistics (row-major by rep, then by coef). Calling on an un-fit model aborts via PreconditionError.

    DoubleMLBLP::clear_cache

    fn DoubleMLBLP::clear_cache(self : DoubleMLBLP) -> DoubleMLBLP

    v0.83.0+: drop any cached OLS projection. Forces the next fit() to recompute from scratch.

    DoubleMLBLP::cluster_hac_se

    fn DoubleMLBLP::cluster_hac_se(self : DoubleMLBLP, cluster_ids : Array[Int]) -> Array[Double]

    v0.97.0+: cluster-robust (Arellano 1987, Cameron-Gelbach-Miller 2011) standard errors for the BLP projection -- the clustered analogue of hac_se(HC0), one SE per coefficient.

    cluster_ids[i] is the 0-based cluster of the i-th row of basis and orth_signal, and cluster_ids.length() must equal n_obs. Cluster indices are 0-based; 1 + max(cluster_ids) is the number of clusters.

    Per-row score for coefficient j, s_ij = m_j(xa_i) * e_i (the BLP HC0 term under a square root, with its sign), aggregated within cluster:

    S_cj = sum_{i in c} s_ij var_j = sum_c S_cj^2 * n_c / (n_c - 1)

    with the (n_c - 1) jackknife correction clipped to 1 for single-observation clusters (the statsmodelscov_type='cluster' convention, and the same clip cluster_sandwich_variance uses). There is NO 1 / n^2 divisor: the shared helper needs one because its moment is a MEAN moment, while this is a sum of squared row scores, exactly like hac_se(HC0).

    With all-singleton clusters the meat collapses to sum_i s_ij^2, i.e. hac_se(HC0) -- to floating-point tolerance, NOT bit-exactly, for two reasons: the per-row square is formed as s_i * s_i here against the m^2 e^2 association in hac_se, and this accumulator is Kahan-compensated (as in cluster_sandwich_variance) against hac_se's plain running sum. Pooled clusters differ.

    DoubleMLBLP has no cluster_vars field, so the user must pass cluster_ids explicitly. Calling on an un-fit model aborts via PreconditionError.

    DoubleMLBLP::coef

    fn DoubleMLBLP::coef(self : DoubleMLBLP) -> Array[Double]

    DoubleMLBLP::confint_joint

    fn DoubleMLBLP::confint_joint(self : DoubleMLBLP, contrast : Matrix, level? : Double) -> Array[(Double, Double)]

    Joint confidence interval for the linear contrast contrast @ coef (length n_contrast), via chi-squared critical value on the Mahalanobis distance.

    (contrast @ (coef - theta))^T @ inv(Omega_contrast) @ (contrast @ (coef - theta)) ~ chi2(n_contrast)

    where Omega_contrast = contrast @ Omega @ contrast^T is the induced covariance. Equivalent to a Bonferroni-style worst-case bound but tighter for low correlation.

    Parameters:
    • contrast: row-major matrix (n_contrast, p + 1) whose rows define the linear functions of coef to interval-estimate.
    • level: confidence level in (0, 1). Default 0.95.

    Returns: array of (low, high) tuples (each row a symmetric interval around contrast @ coef). For a single contrast row, returns a 1-element array. v0.53.0-dev Task 3 / v0.11.4 upstream parity.

    Notes: the upstream joint-CI uses bootstrap to draw the critical value from np.quantile(np.max(np.abs(bootstrap))), which requires the full omega matrix. In our port we approximate the critical value with the chi-squared quantile (1 df per row), and use the diagonal sandwich form of Omega_contrast (contrast[r]^2 @ diag(se^2)) for the variance. This is the standard closed-form chi-squared joint CI when the joint-CI variance is dominated by the diagonal (a conservative approximation for the upstream bootstrap).

    DoubleMLBLP::disable_memoize

    fn DoubleMLBLP::disable_memoize(self : DoubleMLBLP) -> DoubleMLBLP

    v0.83.0+: turn off memoization. Same immutability contract as enable_memoize(). After this, fit() will not read or write the cache; the existing fit_cache is preserved on the returned struct (call clear_cache() to drop it).

    DoubleMLBLP::enable_memoize

    fn DoubleMLBLP::enable_memoize(self : DoubleMLBLP) -> DoubleMLBLP

    v0.83.0+: turn on memoization for subsequent fit() calls. When enabled, fit() will cache the OLS projection (coef, se, residuals, n_obs, rss, var_y) and skip the LinearRegression::fit + sandwich_se path on a repeat call whose basis + orthogonal signal + learner + covariance type are all unchanged. Mirrors the IRM / PLR / CVAR / SSM plumbing.

    Default is OFF. When OFF, every fit() call runs the full OLS projection and the cache is neither read nor written, so v0.82.0 callers see byte-identical output.

    DoubleMLBLP::fit

    DoubleMLBLP::hac_se

    fn DoubleMLBLP::hac_se(self : DoubleMLBLP, kind : SandwichKind) -> Array[Double]

    v0.97.0+: heteroskedasticity-consistent (Huber-White) standard errors for the BLP projection, in the HC0 / HC1 / HC2 / HC3 family. Returns a length-n_params() vector -- one SE per coefficient, intercept at index 0 -- mirroring coef and se, which are both that length.

    This is NOT sandwich_se. Read the section comment above ("WHY BLP DOES NOT REUSE sandwich_se") for the algebra: BLP's meat is sum_i ( (M[j,:] . xa_i)^2 * e_i^2 ) with a full p1 x p1 normal inverse and no 1 / n^2, where the shared DML helper is a scalar-Jacobian MEAN-moment sandwich. The SandwichKind enum is reused -- it is the package's shared vocabulary for the HC family -- but the arithmetic behind each variant is the projection one.

    The invariant that proves the wiring: on a cov_type = "HC0" fit, se IS the HC0 standard error, so hac_se(HC0) == se holds BIT-IDENTICALLY, elementwise. It is NOT claimed on a cov_type = "nonrobust" fit, whose se is the homoskedastic sigma^2 (X'X)^-1 form.

    HC2 / HC3 divide each term by 1 - h_i (HC2) or (1 - h_i)^2 (HC3) using the OLS hat diagonal from leverage(). If any h_i leaves [0, 1) the divisor would be non-positive, which means the projection is rank-deficient. This ABORTS rather than clipping: a clip is what silently broke identities in v0.93 / v0.94, and for a leverage in [0, 1) no correction should ever fire. See blp_hac_require_df for why the df check has to run first.

    DoubleMLBLP::has_cache

    fn DoubleMLBLP::has_cache(self : DoubleMLBLP) -> Bool

    v0.83.0+: true iff fit_cache holds at least one cached observation (i.e. at least one prior fit() call with memoize_enabled = true has populated the cache). Note that the cache may still be stale relative to the current basis / learner configuration -- check memoize_enabled before assuming a cache hit.

    DoubleMLBLP::learner_g

    fn DoubleMLBLP::learner_g(self : DoubleMLBLP) -> LearnerDispatch

    v0.153.0: the outcome learner (ml_g) recorded at construction and used by fit when no explicit argument is given. DoubleMLBLP stores the single slot it has -- there is no ml_m, since the BLP policy score has no propensity term.

    DoubleMLBLP::leverage

    fn DoubleMLBLP::leverage(self : DoubleMLBLP) -> Array[Double]

    v0.97.0+: the OLS hat-matrix diagonal of the BLP design, the same quantity hac_se HC2 / HC3 divide by. Length n_obs.

    X^a (X'X + ridge I)^-1 X^{a'} is a projection plus a ridge-order perturbation, so every entry lies in [0, 1) and the entries sum to the RANK of the augmented design -- the trace of a projection is its rank -- which is n_params() for a full-rank basis and LESS for a rank-deficient one (a constant or duplicated basis column), always up to the ridge term. expand_v097_test.mbt pins the trace against the rank and the [0, 1) bound.

    Recomputed from the persisted basis on every call: BLP is a single closed-form OLS projection, so there is nothing to persist for it and nothing in the FitCache to extend.

    DoubleMLBLP::n_obs

    fn DoubleMLBLP::n_obs(self : DoubleMLBLP) -> Int

    v0.19.0+: sample size used by the BLP fit.

    DoubleMLBLP::n_params

    fn DoubleMLBLP::n_params(self : DoubleMLBLP) -> Int

    v0.97.0+: the number of coefficients the BLP projection estimates, basis.cols() + 1. LinearRegression::fit augments the stored basis with an intercept column, so this is coef.length() and se.length() on a fitted model, with the intercept at index 0.

    This is the k of the HC1 finite-sample correction n / (n - k) in hac_se(HC1), and the same p the cov_type = "nonrobust" branch uses for RSS / (n - p). Read it from here rather than assuming basis.cols(): the two differ by the intercept.

    Available before fit (it is a property of the design, not of the fit), mirroring DoubleMLRDD::n_local_params.

    DoubleMLBLP::new

    fn DoubleMLBLP::new(basis : Matrix, orth_signal : Array[Double], cov_type? : String, ml_g? : LearnerDispatch) -> DoubleMLBLP

    DoubleMLBLP::orth_signal

    fn DoubleMLBLP::orth_signal(self : DoubleMLBLP) -> Array[Double]

    v0.22.0+: the orthogonal signal array (the BLP's "outcome" variable). Length n_obs. Used by GainStatsSource::from_blp_cv to compute the cross-fit residual variance.

    DoubleMLBLP::predictions

    fn DoubleMLBLP::predictions(self : DoubleMLBLP) -> Array[Double]

    Fitted values basis_aug @ coef for each observation, length n_obs. Matches the upstream DoubleMLBLP.predictions / predict semantic — the orthogonal-signal prediction under the BLP coefficient vector.

    Note: coef has length p_basis + 1 because LinearRegression::fit adds an intercept column to basis. We reconstruct that column (all-1.0) on the fly to keep the stored self.basis unchanged.

    DoubleMLBLP::rss

    fn DoubleMLBLP::rss(self : DoubleMLBLP) -> Double

    v0.19.0+: residual sum of squares from the BLP fit. Equals sum_i (orth_signal[i] - basis[i] @ coef)^2.

    DoubleMLBLP::se

    fn DoubleMLBLP::se(self : DoubleMLBLP) -> Array[Double]

    DoubleMLBLP::sensitivity_analysis

    fn DoubleMLBLP::sensitivity_analysis(self : DoubleMLBLP, cf_y? : Double, cf_d? : Double) -> Array[SensitivityResult] raise

    v0.69.0+: per-coefficient sensitivity analysis for DoubleMLBLP.

    BLP is the closed-form OLS projection of orth_signal onto the augmented design Xa = [1, basis] (intercept + basis columns). Per-observation influence-function rows for coefficient j are

    psi_a[j][i] = M[j, :] @ xa_i

    where M = (Xa^T Xa + ridge I)^{-1} is the OLS precision matrix and xa_i is the augmented row i. The full IF for coef j decomposes as psi[i, j] = psi_a[j][i] * residuals[i] (matches the v0.64.0 bootstrap IF rows used by generic_bootstrap_ols_per_coef).

    Sensitivity for each coef follows the shared irm_style_sensitivity decomposition with:
    • theta = coef[j]
    • residuals = self.residuals (the OLS residuals from fit)
    • psi_a = psi_a[j]
    • cf_y / cf_d default to 0.05 (matching the rest of the v0.66.0-onward sensitivity family).

    Returns an Array[SensitivityResult] of length coef.length() (= n_features + 1, including the intercept at index 0). Calling on an un-fit model aborts via PreconditionError.

    DoubleMLBLP::sensitivity_analysis_cluster

    fn DoubleMLBLP::sensitivity_analysis_cluster(self : DoubleMLBLP, cluster_ids : Array[Int], cf_y? : Double, cf_d? : Double) -> Array[SensitivityResult] raise

    v0.74.0+: cluster-robust analogue of DoubleMLBLP::sensitivity_analysis. Same residual (self.residuals) and same per-coef psi_a_j (the M[j, :] @ xa_i Riesz-representer rows from the OLS precision matrix) as the IID path; only the variance / bias computation is cluster-aware. The augmented design xa and the precision xtx_inv are recomputed identically so the per-coef psi_a_j rows match the bootstrap IF rows exactly.

    DoubleMLBLP has no data field (just basis + orth_signal); the user must pass cluster_ids explicitly. cluster_ids.length() must equal self.basis.rows() (= self.orth_signal.length()).

    Returns an Array[SensitivityResult] of length self.coef.length() (= n_features + 1, including the intercept at index 0). Calling on an un-fit model aborts via PreconditionError.

    DoubleMLBLP::var_y

    fn DoubleMLBLP::var_y(self : DoubleMLBLP) -> Double

    v0.19.0+: variance of the orthogonal signal (the BLP's "outcome" variable). Computed as a population variance (divisor n, not n - 1).

    DoubleMLBinaryData

    pub struct DoubleMLBinaryData {
    x : Matrix
    y : Array[Double]
    d : Array[Double]
    } derive(
    Debug
    )

    Double / debiased machine learning for the partially logistic regression model (upstream doubleml.plm.DoubleMLLPLR, Liu, Zhang, Zhou 2021):

    Y = expit(D * theta_0 + r_0(X)), Y in {0, 1}

    The treatment D may be binary or continuous. The model uses a double cross-fit so the auxiliary regression t_0(X) = E[W | X] (where W = logit(M_0)) can be estimated with inner-fold OOF predictions without leaking information from the outer test set.

    Two scores are supported, exactly as upstream:
    • "nuisance_space": the per-fold ml_m training set is filtered to rows with Y = 0 (the "nuisance space" of the ATE on the treated subsample). The starting beta is computed inside the loop, one per fold, from the inner predictions.
    • "instrument": the score itself is upstream's instrument-type form (Y - expit(theta*D + r_hat)) * (D - m_hat), with the derivative upstream gives at plm/lplr.py:536-538. The starting beta is still per-fold.

    v0.105.0: these two used to be inert. score was validated and stored but never read, so both strings returned the nuisance_space answer bit for bit. If you are upgrading: the nuisance_space numbers move slightly (because ml_m is now fitted on the Y = 0 subsample instead of being aliased to ml_a), and instrument produces a genuinely different estimate for the first time.

    DoubleMLBinaryData::new

    fn DoubleMLBinaryData::new(x : Matrix, y : Array[Double], d : Array[Double]) -> DoubleMLBinaryData

    DoubleMLCVAR

    pub struct DoubleMLCVAR {
    data : DoubleMLData
    treatment : Double
    quantile : Double
    n_folds : Int
    n_rep : Int
    seed : Int
    smpls : Array[Array[Fold]]
    propensity_clip : Double
    ps_processor_config : PSProcessorConfig
    normalize_ipw : Bool
    ml_g : LearnerDispatch
    ml_m : LearnerDispatch
    g_hat : Array[Double]
    m_hat : Array[Double]
    coef : Double
    se : Double
    fitted : Bool
    psi_a : Array[Double]
    psi_b : Array[Double]
    boot_t_stat : Array[Double]
    boot_method : String
    n_rep_boot : Int
    boot_seed : Int
    memoize_enabled : Bool
    fit_cache : FitCache
    } derive(
    Debug
    )

    Conditional Value at Risk for a binary potential outcome, following the Kallus, Mao & Uehara (2024) "Removing Hidden Confounding by Supervised Gating" estimator (the upstream doubleml.irm.cvar.DoubleMLCVAR).

    The estimator solves the IPW score mean(1{d==treatment} / m(X) * 1{y <= theta} - quantile) = 0 per outer fold (with a per-fold preliminary cross-fit of m on the train side) to get a per-fold ipw_est[i], averages these for pq_est, and uses the cross-fitted (g, m) nuisances to evaluate the DML influence function psi_a = -1, psi_b = 1{d==treatment} * (g_target - g_hat) / m_hat + g_hat where g_target = max(pq_est, (y - q*pq_est) / (1-q)). The point estimate and SE come from the shared var_est(psi_a,psi_b) helper.

    v0.50.0: full upstream parity. The pre-v0.50.0 simplified version (which lived in quantile.mbt::DoubleMLCVAR and computed the CVaR via a single solve_pq call + a "max" target trick) has been removed; the canonical CVaR estimator is this struct.

    DoubleMLCVAR::bias_corrected_coef

    fn DoubleMLCVAR::bias_corrected_coef(self : DoubleMLCVAR) -> Double

    v0.91.0+: returns coef UNCHANGED -- a documented no-op, not a bias correction.

    coef is the root of the DML moment f(theta) = E[theta * psi_a + psi_b] (see var_est.mbt), so mean(f(coef)) is identically zero: the estimating function is orthogonal by construction, and that orthogonality IS what makes the estimator consistent. Nothing computable from the fitted scores is a bias estimate for this class of estimator, so this accessor reports the uncorrected point estimate rather than a number that merely looks like a correction.

    (CVAR's psi_a is the constant -1.)

    v0.79.0 - v0.90.0 returned coef + mean(psi_b - coef * psi_a). That vector is the score at -coef, NOT at coef; since coef = -mean_b / mean_a its mean is mean_b - coef * mean_a = -2 * coef * mean_a, so the accessor returned coef * (1 - 2 * mean(psi_a)) (exactly 3 * coef when mean(psi_a) = -1). That is not a bias estimate. See bias_corrected_theta in sandwich.mbt for the algebra. The method is kept so the API surface stays stable; removing it outright is the obvious follow-up.

    Preconditions: self.fitted.

    DoubleMLCVAR::bootstrap

    fn DoubleMLCVAR::bootstrap(self : DoubleMLCVAR, method_name? : String, n_rep_boot? : Int, seed? : Int) -> DoubleMLCVAR

    v0.75.0+: multiplier bootstrap for DoubleMLCVAR. The per-observation influence function is

    psi[i] = theta * psi_a[i] + psi_b[i] = -1 + theta * psi_b[i]

    where psi_b[i] = 1{d==treatment} * (g_target - g_hat) /m_hat + g_hat is computed at the fitted coef from the last rep's cross-fitted nuisances (g_hat, m_hat) and g_target = max(coef, (y - q*coef) / (1 - q)). psi_a is the constant -1 vector. Same IRM-style IF shape as DoubleMLIRM (constant derivative + offset score); the score-form IF identical to the v0.71.0 DoubleMLCVAR::sensitivity_analysis psi_a = -1 decomposition.

    method_name selects the multiplier distribution: "normal" (default), "Bayes", "wild"; seed defaults to 2024; n_rep_boot defaults to 500.

    Calling on an un-fit model aborts via PreconditionError. Returns a new DoubleMLCVAR with boot_t_stat / boot_method / n_rep_boot / boot_seed populated.

    DoubleMLCVAR::clear_cache

    fn DoubleMLCVAR::clear_cache(self : DoubleMLCVAR) -> DoubleMLCVAR

    v0.83.0+: drop any cached nuisance predictions, fold assignment, and ipw_vec. Forces the next fit() to recompute from scratch.

    DoubleMLCVAR::cluster_sandwich_se

    fn DoubleMLCVAR::cluster_sandwich_se(self : DoubleMLCVAR, cluster_ids : Array[Int]) -> Double

    v0.86.0+: cluster-robust sandwich standard error for the fitted CVAR. Routes through cluster_sandwich_variance with the same psi_a / psi / M_inv inputs as the IID sandwich_se path.

    Preconditions: self.fitted, cluster_ids.length() == n_obs.

    DoubleMLCVAR::coef

    fn DoubleMLCVAR::coef(self : DoubleMLCVAR) -> Double

    Point estimate (the upper-tail conditional mean of Y(treatment)).

    DoubleMLCVAR::confint

    fn DoubleMLCVAR::confint(self : DoubleMLCVAR) -> (Double, Double)

    95% Wald confidence interval (coef - 1.96 * se, coef + 1.96 * se).

    DoubleMLCVAR::disable_memoize

    fn DoubleMLCVAR::disable_memoize(self : DoubleMLCVAR) -> DoubleMLCVAR

    v0.83.0+: turn off memoization. Same immutability contract as enable_memoize(). After this, fit() will not read or write the cache; the existing fit_cache is preserved on the returned struct (call clear_cache() to drop it).

    DoubleMLCVAR::enable_memoize

    fn DoubleMLCVAR::enable_memoize(self : DoubleMLCVAR) -> DoubleMLCVAR

    v0.83.0+: turn on memoization for subsequent fit() calls. When enabled, fit() will cache the LAST rep's per-fold nuisance predictions (g_hat, m_final), the per-fold ipw_vec (needed to recompute pq_est on a cache hit), and the fold assignment; on a repeat call whose data + learner fingerprint + fold split is identical, the entire per-fold inner crossfit is skipped and the cached values feed the post-fold psi_a / psi_b / coef / se pipeline.

    enable_memoize() is honored only when n_rep == 1; for n_rep > 1 the per-rep cross-fit must run each time (the cache would otherwise need to thread n_rep independent per-rep nuisance arrays, which the FitCache layout does not accommodate).

    Default is OFF. When OFF, every fit() call runs the full per-fold inner crossfit and the cache is neither read nor written, so v0.82.0 callers see byte-identical output.

    DoubleMLCVAR::fit

    v0.50.0: fit the DoubleMLCVAR estimator. Implements the upstream _nuisance_est flow:
    1. For each rep r, draw n_folds outer folds with kfold(n, self.n_folds, self.seed + r).
    2. For each outer fold, run the inner cross-fit (see cvar_inner_crossfit) to get per-fold (g_hat,m_hat, ipw_est).
    3. After all folds, clip m_hat to [clip, 1-clip], optionally normalize the IPW weights, and (if treatment == 0) flip 1 - m_hat.
    4. Compute pq_est = mean(ipw_vec) and the final psi_a, psi_b with g_target = max(pq_est, (y - q*pq_est) / (1-q)).
    5. var_est(psi_a, psi_b) gives the per-rep (theta, se); aggregate across reps with aggregate_coef_se.

    DoubleMLCVAR::fitted

    fn DoubleMLCVAR::fitted(self : DoubleMLCVAR) -> Bool

    v0.50.0: True iff fit has been called.

    DoubleMLCVAR::has_cache

    fn DoubleMLCVAR::has_cache(self : DoubleMLCVAR) -> Bool

    v0.83.0+: true iff fit_cache holds at least one cached observation (i.e. at least one prior fit() call with memoize_enabled = true has populated the cache). Note that the cache may still be stale relative to the current data + learner configuration -- check memoize_enabled before assuming a cache hit.

    DoubleMLCVAR::learner_g

    fn DoubleMLCVAR::learner_g(self : DoubleMLCVAR) -> LearnerDispatch

    Accessor for the outcome-quantile-nuisance learner used by the most recent fit(...) call. v0.60.0+.

    DoubleMLCVAR::learner_m

    fn DoubleMLCVAR::learner_m(self : DoubleMLCVAR) -> LearnerDispatch

    Accessor for the propensity-score learner used by the most recent fit(...) call. v0.60.0+.

    DoubleMLCVAR::n_features

    fn DoubleMLCVAR::n_features(self : DoubleMLCVAR) -> Int

    Number of features (covariate columns).

    DoubleMLCVAR::n_obs

    fn DoubleMLCVAR::n_obs(self : DoubleMLCVAR) -> Int

    Number of observations.

    DoubleMLCVAR::new

    fn DoubleMLCVAR::new(data : DoubleMLData, treatment? : Double, quantile? : Double, n_folds? : Int, n_rep? : Int, seed? : Int, propensity_clip? : Double, ps_processor_config? : PSProcessorConfig?, normalize_ipw? : Bool, ml_g? : LearnerDispatch, ml_m? : LearnerDispatch) -> DoubleMLCVAR

    Construct a DoubleMLCVAR estimator.

    Parameters mirror the upstream DoubleMLCVAR.__init__:
    • treatment : binary potential outcome to target (0 or 1; default 1)
    • quantile : upper-tail level q of the conditional value at risk (strictly in (0, 1); default 0.5)
    • n_folds : number of outer / inner folds (default 2, matching the rest of the package's IRM family)
    • n_rep : number of sample-splitting repetitions (default 1)
    • seed : PRNG seed for the outer fold partition (default 3141)
    • propensity_clip: lower / upper clip bound for the propensity m (strictly in (0, 0.5); default 1e-6)
    • normalize_ipw : if true, normalize the IPW weights so they sum to n within each treatment group, matching the upstream default (default true).

    DoubleMLCVAR::predictions_g

    fn DoubleMLCVAR::predictions_g(self : DoubleMLCVAR) -> Array[Double]

    v0.50.0: cross-fitted g nuisance predictions (length n_obs). Only meaningful after fit.

    DoubleMLCVAR::predictions_m

    fn DoubleMLCVAR::predictions_m(self : DoubleMLCVAR) -> Array[Double]

    v0.50.0: cross-fitted m (propensity) nuisance predictions (length n_obs). Values are already clipped to [propensity_clip, 1 - propensity_clip]. If treatment == 0, the values are flipped to 1 - m (the symmetric treatment swap from the upstream API). Only meaningful after fit.

    DoubleMLCVAR::sample_splitting

    fn DoubleMLCVAR::sample_splitting(self : DoubleMLCVAR) -> SampleSplitting?

    The sample splitting recorded by set_sample_splitting, or None when none was supplied and fit draws its own. Some carries the DERIVED counts next to the folds, so a caller can read back what the estimator actually used rather than inferring it from the constructor arguments it no longer has any say over.

    On CVaR this reports the OUTER splitting only. The preliminary per-fold propensity cross-fit inside cvar_inner_crossfit is drawn internally and is not represented here - see set_sample_splitting for why.

    DoubleMLCVAR::sandwich_se

    fn DoubleMLCVAR::sandwich_se(self : DoubleMLCVAR, kind : SandwichKind) -> Double

    v0.86.0+: Huber-White sandwich standard error for the fitted CVAR. Returns sqrt(var) where var comes from the shared sandwich_variance(kind, ...) dispatch in sandwich.mbt (HC0 / HC1 / HC2 / HC3).

    The three inputs are the ones fit(...) already persists for the multiplier bootstrap (see the psi_a / psi_b field docs on DoubleMLCVAR):
    • psi_a = self.psi_a -- the constant -1 IRM-style Riesz representer, so mean(psi_a) = -1 and M_inv = -1.
    • psi = psi_at(self.coef, self.psi_a, self.psi_b) -- the per-observation influence function evaluated at the fitted coef, where psi_b carries the IPW weighting: 1{d == treatment} * (g_target - g_hat) / m_hat + g_hat with g_target = max(coef, (y - q * coef) / (1 - q)). Same psi_at score order as IRM / PLR.
    • M_inv = [[1 / mean(psi_a)]] (1x1). Only M_inv[0, 0]^2 enters the variance, so the sign of the (up-to-sign) Jacobian inverse is irrelevant.

    Preconditions: self.fitted, mean(psi_a) != 0.

    DoubleMLCVAR::se

    fn DoubleMLCVAR::se(self : DoubleMLCVAR) -> Double

    Standard error (DML influence-function SE; see var_est).

    DoubleMLCVAR::sensitivity_analysis

    fn DoubleMLCVAR::sensitivity_analysis(self : DoubleMLCVAR, cf_y? : Double, cf_d? : Double) -> SensitivityResult raise

    v0.71.0: sensitivity analysis on DoubleMLCVAR. CVaR's DML influence function is the IRM-style form documented in the struct-level comment:

    psi_a[i] = -1 (constant) psi_b[i] = 1{d[i] == treatment} * (g_target - g_hat[i]) / m_hat[i] + g_hat[i]

    where g_target = max(coef, (y - q*coef) / (1 - q)) and q = self.quantile. The constant psi_a = -1 and the residual y - g_hat let us reuse the shared irm_style_sensitivity helper (see sensitivity.mbt).

    v0.157.0: the previous wording here ended "...and are passed through to the result for upstream parity". That was wrong, and measurement replaced it.

    • cf_y / cf_d ARE stored, so a caller can read back what was asked for -- but they reach NO computation. Measured across cf = 0.00 / 0.03 / 0.05 / 0.10 / 0.20: rv, max_bias, sigma2 and nu2 are all bit-identical (delta = 0 on every field). Only the stored cf_y/cf_d round-trip.
    • Upstream uses cf to set the WIDTH of the theta band (measured there: +/-6.88 SE at cf=0.20). This port's SensitivityResult (sensitivity.mbt:534) has no band, no CI and no rva, so it STRUCTURALLY cannot carry the quantity cf moves upstream. That is the real sense in which there is no parity here, and it is not fixable by plumbing.
    • The default is 0.05, where upstream's is 0.03 at all 17 of its estimators. So even the stored value diverges.

    Evidence: _verify/PARITY_COMMENTS_1570.md, _verify/_cvar_cf_1570.txt. (The premise of this whole block -- constant psi_a -- is confirmed: nu2 comes back as exactly 1.)

    DoubleMLCVAR::sensitivity_analysis_cluster

    fn DoubleMLCVAR::sensitivity_analysis_cluster(self : DoubleMLCVAR, cluster_ids? : Array[Int], cf_y? : Double, cf_d? : Double) -> SensitivityResult raise

    v0.74.0+: cluster-robust analogue of DoubleMLCVAR::sensitivity_analysis. Same outcome residual formula (y - g_hat) and same psi_a = -1 (constant) as the IID path; only the variance / bias computation is cluster-aware (sigma2_cluster and nu2_cluster are the G / n_clusters sums of squared cluster sums).

    cluster_ids defaults to DoubleMLData::cluster_vars.

    DoubleMLCVAR::set_sample_splitting

    fn DoubleMLCVAR::set_sample_splitting(self : DoubleMLCVAR, smpls : Array[Array[Fold]]) -> DoubleMLCVAR

    Record a caller-supplied sample splitting and DERIVE the fold and repetition counts from it. Returns a new DoubleMLCVAR; the receiver is unchanged, as everywhere in this package.

    smpls is one entry per repetition, each entry one Fold per fold -- the nested form is the only form, so a single repetition is [f0, f1, f2] wrapped once, i.e. [[f0, f1, f2]].

    The derivation is upstream's, and it is the part that surprises people: set_sample_splitting (doubleml/double_ml_sampling_mixins.py:76) sets n_folds and n_rep FROM the supplied partition, so handing a two-fold split to a model built with n_folds=5 yields a two-fold model. On the returned model n_folds / n_rep are updated to match, and fit uses the supplied folds instead of drawing them.

    Validation, via check_sample_splitting: every repetition carries the same number of folds, and within each repetition the test index sets are disjoint and cover exactly [0, n_obs). A supplied partition that is not a partition aborts rather than quietly producing a cross-fit that leaks across folds.

    What this splitting does and does not replace

    CVaR has TWO fold sets, not one, and only the outer one is replaced:

    • The OUTER folds (kfold(n, self.n_folds, self.seed + r), driven per repetition by fit) ARE taken from smpls.

    • The PRELIMINARY fold set stays drawn. Inside cvar_inner_crossfit, each outer training fold is first split 50/50 by stratified_half_split into (train_1, train_2), and a stratified n_folds-fold cross-fit of the preliminary propensity runs over train_1 alone.

    That second set cannot be taken from smpls, and the reason is structural rather than a shortcut: its index space is the train_1 SUBSET of rows, reached through a per-fold kfold_stratified(train_1.length(), slice_vector(d, train_1), ...) whose fold lengths vary with the outer fold, while smpls is a partition of [0, n). Substituting it would need a second, differently-shaped splitting threaded through the free function's signature, plus a way to reconcile an arbitrary caller partition with a per-fold subset - and any mismatch there would break the propensity cross-fit inside one outer fold. So the preliminary set keeps being drawn, seeded as before. The consequence worth knowing: a fully externally-split CVaR still differs from an un-split one even when both partitions coincide, because its preliminary propensity cross-fit is still redrawn. That is stated here rather than left to be discovered by a test that compares estimates.

    Memoization is turned OFF on the returned model regardless of memoize_enabled. The fit cache is keyed on data + learner + cluster hashes with no term for the fold assignment, so caching an externally-split fit could return a DIFFERENT partition's predictions. The fit cache is also cleared here, for the same reason.

    Existing bootstrap state

    fit rebuilds boot_t_stat / boot_method / n_rep_boot / boot_seed from scratch on every call, so the fit that a caller makes next on the returned model DROPS any bootstrap the model was carrying. tune ends in such a fit, which makes this easy to hit by accident: set a splitting on a bootstrapped model, tune it, and the bootstrap is gone with no error. This method does not change that behaviour and does not clear the bootstrap itself (it returns ..self, so any bootstrap survives the call) - it is a property of fit, shared with every estimator in this package, recorded here because CVaR's tune makes it reachable in one step. Call bootstrap(...) again on the fitted model if you need it.

    DoubleMLCVAR::tune

    fn DoubleMLCVAR::tune(self : DoubleMLCVAR, param_set~ : Array[TuneParam], scoring_method? : String, n_folds_tune? : Int, seed? : Int) -> DoubleMLCVAR

    DoubleMLCVAR::tune - score a grid of (learner_g,learner_m) pairs and re-fit with the winner.

    Slot semantics: CVAR is a propensity-first family, so the first TuneParam slot is the treatment / propensity learner E[g|d,x] and the second is the outcome learner E[m|x]. The first field is still named learner_l (the field names are fixed by the partially-linear family); build candidates with TuneParam::for_treatment(learner_g, learner_m) so the call site carries the slot's role.

    v0.108.0: new. CVAR had no tune at all, so it was one of the estimators carrying a coverage gap against upstream, where tune reaches every estimator through the BaseDML mixin. It routes through the shared tune_score_grid core in tune.mbt - fold draw, cross-fit, scoring and the argmin/argmax selection all live there.

    CVAR-specific caveats:

    • The re-fit is self.fit(ml_g=..., ml_m=...), which reads quantile, treatment, ps_processor, n_folds, n_rep / seed and the memoize flag from self. Nothing is carried across by hand, and the tune-time folds are discarded - the winner is re-cross-fitted under self.n_folds.
    • DoubleMLCVAR::fit clears the bootstrap fields back to boot_t_stat: [] / boot_method: "" / n_rep_boot: 0 / boot_seed: 0 on every call. So calling tune on a model that already carries a bootstrap drops that bootstrap state
      • you have to re-run bootstrap on the returned model. That is fit's pre-existing behaviour, not something tune adds, but it is visible here because tune is a second way to reach fit.
    • The score is MSE of the first-slot learner cross-fitted against the outcome y, not against the treatment d, so it is a shortlisting proxy and not a direct measure of propensity quality. Same convention as the other propensity-first ::tune methods.

    The cluster guard IS present here, and unlike the DID* siblings it is reachable: CVAR's data is a DoubleMLData, which does carry cluster_vars and does expose is_cluster_data(). Cluster-DML tune folds must be drawn over unique units rather than rows - a row-wise fold schedule on clustered data splits a unit across the train/test boundary and produces a plausible-looking score for a wrong cross-fit rather than an error, which is exactly the silent mis-scoring that the v0.108.0 APO fix closed on the other side of the family. Aborting here is the correct behaviour.

    DoubleMLDID

    pub struct DoubleMLDID {
    data : DoubleMLDIDData
    n_folds : Int
    n_rep : Int
    seed : Int
    smpls : Array[Array[Fold]]
    propensity_clip : Double
    ps_processor_config : PSProcessorConfig
    score : String
    in_sample_normalization : Bool
    ml_g : LearnerDispatch
    ml_m : LearnerDispatch
    strata : Array[Int]
    g0_hat : Array[Double]
    g1_hat : Array[Double]
    m_hat : Array[Double]
    coef : Double
    se : Double
    psi_a : Array[Double]
    psi_b : Array[Double]
    fitted : Bool
    boot_t_stat : Array[Double]
    boot_method : String
    n_rep_boot : Int
    boot_seed : Int
    memoize_enabled : Bool
    fit_cache : FitCache
    } derive(
    Debug
    )

    Double / debiased machine learning estimator for the difference in differences (DID) model with panel data and binary treatment d ∈ {0, 1}, following Sant'Anna and Zhao (2020).

    The reduced-form outcome equation is

    Y_post = g_0(0, X) + D * theta + U_post, E[U | D, X] = 0 Y_pre = g_0(0, X) + U_pre, E[U_pre | X] = 0

    or, in first differences,

    dY = Y_post - Y_pre = g_1(1, X) - g_0(0, X) + D * theta + (U_post - U_pre)

    We form cross-fitted nuisance predictions

    g0(X) = E[Y | D = 0, X] (trained on D = 0) g1(X) = E[Y | D = 1, X] (trained on D = 1) m(X) = P(D = 1 | X) (trained on all obs; observational only, clipped to [eps, 1 - eps])

    and use the observational DID score (the most general one):

    resid_d0 = Y - g0 p_hat = mean(D) weight_psi_a = D / p_hat weight_resid = (D - m) / (p_hat * (1 - m)) psi_b = (D - m)/(p_hat (1 - m)) * (Y - g0) [the g1 term cancels in ATT] psi_a = -D / p_hat psi(theta) = theta * psi_a + psi_b

    Note: in the upstream code, when D = 1 is the only informative group, the ATT-style psi_b simplifies to psi_b = weight_resid_d0 * resid_d0 because psi_b_1 (the g1-based term) is zero under the ATT weighting. The port follows that simplification.

    Point estimate and variance

    theta_hat = -mean(psi_b) / mean(psi_a) J = mean(psi_a) gamma = mean(psi(theta_hat)^2) sigma2 = gamma / (J^2 * n) se = sqrt(sigma2).

    The experimental score (score = "experimental") and the in-sample-normalisation variant (in_sample_normalization = true) are supported as of v0.8.0; they are required by DoubleMLDIDBinary's preprocessing wrapper. The staggered-DID variant (DoubleMLDIDCS) is still not implemented in this port.

    DoubleMLDID::bias_corrected_coef

    fn DoubleMLDID::bias_corrected_coef(self : DoubleMLDID) -> Double

    v0.91.0+: returns coef UNCHANGED -- a documented no-op, not a bias correction.

    coef is the root of the DML moment f(theta) = E[theta * psi_a + psi_b] (see var_est.mbt), so mean(f(coef)) is identically zero: the estimating function is orthogonal by construction, and that orthogonality IS what makes the estimator consistent. Nothing computable from the fitted scores is a bias estimate for this class of estimator, so this accessor reports the uncorrected point estimate rather than a number that merely looks like a correction.

    v0.79.0 - v0.90.0 returned coef + mean(psi_b - coef * psi_a). That vector is the score at -coef, NOT at coef; since coef = -mean_b / mean_a its mean is mean_b - coef * mean_a = -2 * coef * mean_a, so the accessor returned coef * (1 - 2 * mean(psi_a)) (exactly 3 * coef when mean(psi_a) = -1). That is not a bias estimate. See bias_corrected_theta in sandwich.mbt for the algebra. The method is kept so the API surface stays stable; removing it outright is the obvious follow-up.

    Preconditions: self.fitted.

    DoubleMLDID::bootstrap

    fn DoubleMLDID::bootstrap(self : DoubleMLDID, method_name? : String, n_rep_boot? : Int, seed? : Int) -> DoubleMLDID

    v0.64.0+: multiplier bootstrap for DoubleMLDID. The per-observation influence function is

    psi[i] = theta * psi_a[i] + psi_b[i]

    where psi_a[i] = -d[i] / p_hat (or -1 for the experimental score) and psi_b[i] is the per-observation ATT score, both populated by fit(...) from the last rep's cross-fitted nuisances. Routes through the shared generic_bootstrap_t_stat helper (see bootstrap_helper.mbt).

    method_name selects the multiplier distribution: "normal" (default), "Bayes", "wild". seed defaults to 2024; n_rep_boot defaults to 500.

    Calling on an un-fit model aborts via PreconditionError.

    DoubleMLDID::clear_cache

    fn DoubleMLDID::clear_cache(self : DoubleMLDID) -> DoubleMLDID

    v0.82.0+: drop any cached nuisance predictions and fold assignment.

    DoubleMLDID::cluster_sandwich_se

    fn DoubleMLDID::cluster_sandwich_se(self : DoubleMLDID, cluster_ids : Array[Int]) -> Double

    v0.86.0+: cluster-robust sandwich standard error for the fitted DID. Routes through cluster_sandwich_variance with the same psi_a / psi / M_inv inputs as the IID sandwich_se path. DoubleMLDIDData carries no cluster_vars, so the caller must supply cluster_ids explicitly (typically the DID unit / panel id).

    Preconditions: self.fitted, cluster_ids.length() == n_obs.

    DoubleMLDID::coef

    fn DoubleMLDID::coef(self : DoubleMLDID) -> Double

    DoubleMLDID::confint

    fn DoubleMLDID::confint(self : DoubleMLDID, joint? : Bool, level? : Double) -> (Double, Double)

    v0.67.0+: joint is a no-op for single-theta estimators; accepted for API parity.

    DoubleMLDID::disable_memoize

    fn DoubleMLDID::disable_memoize(self : DoubleMLDID) -> DoubleMLDID

    v0.82.0+: turn off memoization.

    DoubleMLDID::enable_memoize

    fn DoubleMLDID::enable_memoize(self : DoubleMLDID) -> DoubleMLDID

    v0.82.0+: turn on memoization for subsequent fit() calls.

    DoubleMLDID::fit

    Run the DID estimation.

    DoubleMLDID::has_cache

    fn DoubleMLDID::has_cache(self : DoubleMLDID) -> Bool

    v0.82.0+: true iff fit_cache holds at least one cached observation.

    DoubleMLDID::learner_g

    fn DoubleMLDID::learner_g(self : DoubleMLDID) -> LearnerDispatch

    v0.153.0: the outcome learner (ml_g) recorded at construction and used by fit when no explicit argument is given. DoubleMLDID stored the pair since v0.60.0 but, unlike ten sibling estimators, never exposed it, so a fitted model could not report which learners produced it.

    DoubleMLDID::learner_m

    fn DoubleMLDID::learner_m(self : DoubleMLDID) -> LearnerDispatch

    v0.153.0: the propensity learner (ml_m) recorded at construction. See learner_g.

    DoubleMLDID::n_obs

    fn DoubleMLDID::n_obs(self : DoubleMLDID) -> Int

    DoubleMLDID::new

    fn DoubleMLDID::new(data : DoubleMLDIDData, n_folds? : Int, n_rep? : Int, seed? : Int, propensity_clip? : Double, ps_processor? : PSProcessor, ps_processor_config? : PSProcessorConfig?, score? : String, in_sample_normalization? : Bool, strata? : Array[Int], ml_g? : LearnerDispatch, ml_m? : LearnerDispatch) -> DoubleMLDID

    DoubleMLDID::predictions_g0

    fn DoubleMLDID::predictions_g0(self : DoubleMLDID) -> Array[Double]

    DoubleMLDID::predictions_g1

    fn DoubleMLDID::predictions_g1(self : DoubleMLDID) -> Array[Double]

    DoubleMLDID::predictions_m

    fn DoubleMLDID::predictions_m(self : DoubleMLDID) -> Array[Double]

    DoubleMLDID::sample_splitting

    fn DoubleMLDID::sample_splitting(self : DoubleMLDID) -> SampleSplitting?

    The sample splitting recorded by set_sample_splitting, or None when none was supplied and fit draws its own. Some carries the DERIVED counts next to the folds, so a caller can read back what the estimator actually used rather than inferring it from the constructor arguments it no longer has any say over.

    DoubleMLDID::sandwich_se

    fn DoubleMLDID::sandwich_se(self : DoubleMLDID, kind : SandwichKind) -> Double

    v0.86.0+: Huber-White sandwich standard error for the fitted DID. Returns sqrt(var) where var comes from the shared sandwich_variance(kind, ...) dispatch in sandwich.mbt (HC0 / HC1 / HC2 / HC3).

    The three inputs are the ones fit(...) already persists for the multiplier bootstrap (see the psi_a / psi_b field docs on DoubleMLDID):
    • psi_a = self.psi_a -- the ATT-style score's treatment term (-d / p_hat for the observational score, -1 for the experimental score, or -d / mean(d) under in-sample normalization). mean(psi_a) = -1 by construction, so M_inv = -1.
    • psi = psi_at(self.coef, self.psi_a, self.psi_b) -- the per-observation influence function evaluated at the fitted coef, where psi_b is the ATT outcome score. Matches the psi(theta) = theta * psi_a + psi_b form documented on DoubleMLDID::bootstrap.
    • M_inv = [[1 / mean(psi_a)]] (1x1). Only M_inv[0, 0]^2 enters the variance, so the sign of the (up-to-sign) Jacobian inverse is irrelevant.

    Preconditions: self.fitted, mean(psi_a) != 0.

    DoubleMLDID::se

    fn DoubleMLDID::se(self : DoubleMLDID) -> Double

    DoubleMLDID::sensitivity_analysis

    fn DoubleMLDID::sensitivity_analysis(self : DoubleMLDID, cf_y? : Double, cf_d? : Double) -> SensitivityResult raise

    v0.66.0+: Cinelli & Hazlett (2020) omitted-variable bias analysis. Outcome residual is y - g0_hat - (g1_hat - g0_hat) * d; the Riesz-representer variance follows upstream's PRIMARY formula nu2 = mean(2 * m_alpha - rr^2) (did/did.py:352) rather than its fallback; see DoubleMLDID::sensitivity_nu2_elements.

    DoubleMLDID::sensitivity_analysis_cluster

    fn DoubleMLDID::sensitivity_analysis_cluster(self : DoubleMLDID, cluster_ids : Array[Int], cf_y? : Double, cf_d? : Double) -> SensitivityResult raise

    v0.72.0+: cluster-robust analogue of DoubleMLDID::sensitivity_analysis. Same DID-style residual form (y - g0 - (g1 - g0) * d) and the same psi_a as the IID path; only the variance / bias computation is cluster-aware. DoubleMLDIDData has no cluster_vars field (the DID model itself doesn't fit a cluster path), so the user must pass cluster_ids explicitly. cluster_ids.length() must equal self.data.n_obs().

    DoubleMLDID::sensitivity_nu2_elements

    fn DoubleMLDID::sensitivity_nu2_elements(self : DoubleMLDID) -> (Array[Double], Array[Double]) raise

    v0.131.6: the nu2 score element and the Riesz representer for the DID ATT score, transcribed from upstream did/did.py:332-353.

    p_hat = mean(d) propensity_weight_d0 = m_hat / (1 - m_hat) score == "observational", in_sample_normalization: weight_d0 = (1 - d) * propensity_weight_d0 m_alpha = (d / p_hat) * (1/p_hat + propensity_weight_d0 / mean(weight_d0)) rr = d / p_hat - weight_d0 / mean(weight_d0) score == "observational", not in_sample_normalization: m_alpha = (d / p_hat^2) * (1 + propensity_weight_d0) rr = d / p_hat - ((1 - d) / p_hat) * propensity_weight_d0 score == "experimental": (identical with and without the flag) m_alpha = 1/p_hat + 1/(1 - p_hat) -- a CONSTANT array rr = d / p_hat - (1 - d)/(1 - p_hat) nu2_score_element = 2 * m_alpha - rr^2 nu2 = mean(nu2_score_element)

    The default (observational, in_sample_normalization = false) branch is the one this port's default arguments select, and it is the branch that used to be wrong here: the shared irm_style_sensitivity helper computes nu2 = mean(psi_a^2), which is upstream's documented FALLBACK (double_ml.py:1634-1641), applied to the wrong variable on top of that.

    DoubleMLDIDBinary shares this method deliberately. Upstream's did/did_binary.py:764-777 is the SAME formula; the two implementations differ only in the array sources (the subset's y/d and id-aligned predictions) and in a n_ids / n_obs_subset scaling applied to psi_sigma2 / psi_nu2 / riesz_rep -- but NOT to sigma2 / nu2 themselves. Those three scaled arrays feed only psi_max_bias, which every caller in this port discards via _, so the single shared path reproduces both estimators' reported sigma2 and nu2 exactly.

    Guarded denominators follow the convention used elsewhere in this package: the degenerate case maps to 0 rather than propagating a non-finite value.

    DoubleMLDID::set_sample_splitting

    fn DoubleMLDID::set_sample_splitting(self : DoubleMLDID, smpls : Array[Array[Fold]]) -> DoubleMLDID

    Record a caller-supplied sample splitting and DERIVE the fold and repetition counts from it. Returns a new DoubleMLDID; the receiver is unchanged, as everywhere in this package.

    smpls is one entry per repetition, each entry one Fold per fold -- the nested form is the only form, so a single repetition is [f0, f1, f2] wrapped once, i.e. [[f0, f1, f2]].

    The derivation is upstream's, and it is the part that surprises people: set_sample_splitting (doubleml/double_ml_sampling_mixins.py:76) sets n_folds and n_rep FROM the supplied partition, so handing a two-fold split to a model built with n_folds=5 yields a two-fold model. On the returned model n_folds / n_rep are updated to match, and fit uses the supplied folds instead of drawing them.

    Validation, via check_sample_splitting: every repetition carries the same number of folds, and within each repetition the test index sets are disjoint and cover exactly [0, n_obs). A supplied partition that is not a partition aborts rather than quietly producing a cross-fit that leaks across folds.

    Interaction with strata

    DID picks between two fold strategies: stratified_kfold when strata has length n_obs, plain kfold otherwise. An external splitting REPLACES BOTH. strata is then not consulted by fit at all, so a supplied partition is used verbatim and the usual guarantee that each fold balances the (G, T) cells no longer holds -- balance is then whatever the caller's partition happens to provide. This is the intended reading of "use these folds", but it is the one case where supplying a splitting changes something other than the fold boundaries, so it is called out here rather than left to be discovered.

    Memoization is turned OFF on the returned model regardless of memoize_enabled. The fit cache is keyed on data + learner + cluster hashes with no term for the fold assignment, so caching an externally-split fit could return a DIFFERENT partition's predictions. The fit cache is also cleared here, for the same reason.

    Note that a subsequent fit() on the returned model rebuilds boot_t_stat / n_rep_boot from scratch, so a bootstrap computed before set_sample_splitting is dropped by that fit. That is the package-wide convention, not something this method introduces; call bootstrap(...) again on the fitted model if you need it.

    DoubleMLDID::tune

    fn DoubleMLDID::tune(self : DoubleMLDID, param_set~ : Array[TuneParam], scoring_method? : String, n_folds_tune? : Int, seed? : Int) -> DoubleMLDID

    DoubleMLDID::tune - score a grid of nuisance-learner combinations and re-fit the estimator with the winner.

    Slots

    Tunes the two DID nuisance slots in the propensity-first sense documented on TuneParam (see the slot-semantics table there): the FIRST slot is the treatment / propensity learner E[g | x], the SECOND is the outcome learner E[m0 + d * m1 | x]. The first slot is stored on the field named learner_l; that field name is not a statement about which learner it holds, and always has been learner_l. Build each grid row with TuneParam::for_treatment(learner_g, learner_m) so the call site carries the role rather than leaving a reader to infer it from a field called learner_l.

    Routing

    The fold draw, the scoring and the selection rule all go through the shared tune_score_grid core in tune.mbt; this method adds no numerics of its own. Only the first slot is scored: the winning learner_l is then paired with the same grid row's learner_m for the re-fit under the FINAL-FIT fold schedule (self.n_folds).

    v0.108.0: new. DoubleMLDID had no tune at all, which was a coverage gap against upstream, where tune reaches every estimator through the BaseDML mixin.

    Caveats found while reading the fit path

    • n is NOT taken from self.n_obs(). The core derives it from self.data.y.length(), which is what the n_obs-less estimators in this package need. On DID the two agree: DoubleMLDIDData::new requires x.nrows == y.length and n_obs is x.rows().
    • fit exposes no tune_result parameter, so the per-candidate score vector is NOT retained on the returned estimator. This is the one thing not carried over from DoubleMLPLR::tune, which stashes a TuneResult. Recorded here rather than dropped silently.
    • fit cross-fits n_rep times, re-seeding per repetition (self.seed + r) and averaging the nuisance vectors, and it stratifies on self.strata when strata are present. The tune score is a SINGLE unstratified cross-fit, so it reflects one draw rather than the rep-averaged nuisance the final fit actually uses. tune forwards no n_rep / strata knob: both stay on self.

    No cluster guard

    require(!self.data.is_cluster_data()) is deliberately ABSENT here, unlike on DoubleMLAPO::tune. Not an oversight: DoubleMLDIDData is {x, y, d}, defines no is_cluster_data(), and carries no cluster_vars - cluster ids reach DID as an explicit argument to DoubleMLDID::cluster_sandwich_se(cluster_ids) at standard-error time instead. There is no cluster state on the data container for a guard to read, so a guard here could never fire.

    Stated so the absence is not read as an endorsement: cluster-aware tuning is not ENFORCED on DID, and it is not implemented either. Tune folds are drawn over rows, where a correct cluster-DML tune needs folds over unique units. Use DoubleMLDID::fit(ml_g=..., ml_m=...) with hand-picked candidates on the cluster path until a unit-wise fold schedule lands.

    DoubleMLDIDBinary

    pub struct DoubleMLDIDBinary {
    data : DoubleMLDIDBinaryData
    g_value : Int
    t_value_pre : Int
    t_value_eval : Int
    control_group : String
    anticipation_periods : Int
    n_folds : Int
    n_rep : Int
    seed : Int
    propensity_clip : Double
    ps_processor_config : PSProcessorConfig
    score : String
    in_sample_normalization : Bool
    ml_g : LearnerDispatch
    ml_m : LearnerDispatch
    eval_idx : Array[Int]
    inner : DoubleMLDID
    fitted : Bool
    boot_t_stat : Array[Double]
    boot_method : String
    n_rep_boot : Int
    boot_seed : Int
    memoize_enabled : Bool
    } derive(
    Debug
    )

    Binary DID model (Sant'Anna & Zhao 2020, §4.3) for panel data with binary treatment in terms of the (group, time) combination. Supports the two score variants "observational" (default, IPW-style) and "experimental" (A/B-test-style, requires independent treatment assignment), and the two weighting conventions "in_sample_normalization = true" (divide by sample mean) and false (divide by p_hat = mean(d)). The default (observational, false) matches the byte-equality of the pre-0.8.0 DoubleMLDID port on the canonical DGP.

    v0.10.0 additions (corresponds to upstream DoubleMLDIDCSBinary):
    • the propensity processor was split out of the bare propensity_clip argument, so the clipping threshold applied inside the score denominator is configurable independently of the inner cross-fit's numerical-safety clip. v0.127.0 promotes it to a full ps_processor_config (upstream's shape), stored concrete and forwarded verbatim to the inner DoubleMLDID, which applies it at upstream's single adjust_ps site (did_binary.py:537).
    • fit builds the wide-format strata G_indicator + 2 * t_indicator (matching upstream self._strata) and passes it to DoubleMLDID::new(strata=...), switching the inner sample-splitting to stratified_kfold. Each fold then balances the four (G, T) cells.

    The model is a thin wrapper around DoubleMLDID: it preprocesses the long-format panel into the wide-format DID dataset, then dispatches to DoubleMLDID::fit. The DML point estimate is the ATT (average treatment effect on the treated) for the chosen (g_value, t_value_pre, t_value_eval) triple.

    DoubleMLDIDBinary::bias_corrected_coef

    fn DoubleMLDIDBinary::bias_corrected_coef(self : DoubleMLDIDBinary) -> Double

    v0.91.0+: returns the inner DoubleMLDID::bias_corrected_coef unchanged, i.e. the uncorrected coef on the post-subset wide-format sample -- a documented no-op, not a bias correction. See DoubleMLDID::bias_corrected_coef for why there is nothing to correct.

    Preconditions: self.fitted.

    DoubleMLDIDBinary::bootstrap

    fn DoubleMLDIDBinary::bootstrap(self : DoubleMLDIDBinary, method_name? : String, n_rep_boot? : Int, seed? : Int) -> DoubleMLDIDBinary

    v0.64.0+: multiplier bootstrap for DoubleMLDIDBinary. Delegates to the inner DoubleMLDID::bootstrap(...) (the wide-format ATT sub-model). The returned boot_t_stat is the inner's t-statistic array — same meaning, same shape, same RNG (the inner is re-bootstrapped on each call so the long-format row mapping in eval_idx is preserved).

    method_name / n_rep_boot / seed are passed through to the inner call. Calling on an un-fit model aborts via PreconditionError.

    DoubleMLDIDBinary::clear_cache

    v0.82.0+: drop the inner cache (no-op on the wrapper itself).

    DoubleMLDIDBinary::cluster_sandwich_se

    fn DoubleMLDIDBinary::cluster_sandwich_se(self : DoubleMLDIDBinary, cluster_ids : Array[Int]) -> Double

    v0.89.0+: cluster-robust sandwich standard error for the fitted binary DID. Forwarded to the inner DoubleMLDID::cluster_sandwich_se, which routes through cluster_sandwich_variance with the inner's psi_a / psi / M_inv.

    cluster_ids is on the POST-SUBSET wide-format indexing (n_obs_subset() rows, one per retained unit), typically the unit id of the cell's panel.

    Preconditions: self.fitted, cluster_ids.length() == self.n_obs_subset().

    DoubleMLDIDBinary::coef

    fn DoubleMLDIDBinary::coef(self : DoubleMLDIDBinary) -> Double

    Point estimate (ATT) for the chosen (g_value, t_value_pre,t_value_eval) triple.

    DoubleMLDIDBinary::confint

    fn DoubleMLDIDBinary::confint(self : DoubleMLDIDBinary, joint? : Bool, level? : Double) -> (Double, Double)

    95% Wald-style confidence interval. v0.67.0+: joint is a no-op for single-theta estimators; delegates to the inner DoubleMLDID::confint.

    DoubleMLDIDBinary::disable_memoize

    v0.82.0+: turn off memoization.

    DoubleMLDIDBinary::enable_memoize

    v0.82.0+: turn on memoization for subsequent fit() calls. The flag is forwarded to the inner DoubleMLDID so the wide-format cross-fit honours the cache.

    DoubleMLDIDBinary::fit

    Run the binary DID estimation. Three steps:
    1. Preprocess the long-format panel into the wide-format DID dataset (preprocess_did_binary).
    2. Build a DoubleMLDIDData from the wide-format arrays.
    3. Fit a DoubleMLDID on the wide-format data with the chosen score and in_sample_normalization.

    The preprocessing keeps only units observed in both t_value_pre and t_value_eval, drops units with neither the G_indicator = (g == g_value) nor the C_indicator set, and uses the earlier period's covariates as the regressors.

    DoubleMLDIDBinary::has_cache

    fn DoubleMLDIDBinary::has_cache(self : DoubleMLDIDBinary) -> Bool

    v0.82.0+: true iff the inner DoubleMLDID cache holds at least one cached observation.

    DoubleMLDIDBinary::inner_psi_a

    fn DoubleMLDIDBinary::inner_psi_a(self : DoubleMLDIDBinary) -> Array[Double]

    v0.15.0+: per-observation psi_a on the cell's wide-format data (the inner DoubleMLDID's psi_a field). Length equals n_obs_subset() (the cell's wide-format panel size). Used by the multiplier bootstrap in DoubleMLDIDMulti::bootstrap to map wide-format psi back to the full long-format panel.

    DoubleMLDIDBinary::inner_psi_b

    fn DoubleMLDIDBinary::inner_psi_b(self : DoubleMLDIDBinary) -> Array[Double]

    v0.15.0+: per-observation psi_b on the cell's wide-format data. See inner_psi_a for details.

    DoubleMLDIDBinary::learner_g

    v0.153.0: the outcome learner (ml_g) recorded at construction, used by fit when no explicit argument is given. Reports the DEFAULT, not necessarily the learner of the current fit: an explicit fit(ml_g = ...) overrides it for that call only and is not recorded.

    DoubleMLDIDBinary::learner_m

    v0.153.0: the propensity learner (ml_m) recorded at construction. See learner_g for the same default-vs-current-fit caveat.

    DoubleMLDIDBinary::n_features

    fn DoubleMLDIDBinary::n_features(self : DoubleMLDIDBinary) -> Int

    Number of features (matches data.n_features()).

    DoubleMLDIDBinary::n_obs_subset

    fn DoubleMLDIDBinary::n_obs_subset(self : DoubleMLDIDBinary) -> Int

    Number of units in the wide-format subset (i.e. observed in both t_value_pre and t_value_eval and assigned to either G or C).

    DoubleMLDIDBinary::new

    fn DoubleMLDIDBinary::new(data : DoubleMLDIDBinaryData, g_value : Int, t_value_pre : Int, t_value_eval : Int, control_group? : String, anticipation_periods? : Int, n_folds? : Int, n_rep? : Int, seed? : Int, propensity_clip? : Double, ps_processor? : PSProcessor, ps_processor_config? : PSProcessorConfig?, score? : String, in_sample_normalization? : Bool, ml_g? : LearnerDispatch, ml_m? : LearnerDispatch) -> DoubleMLDIDBinary

    DoubleMLDIDBinary::predictions_g0

    fn DoubleMLDIDBinary::predictions_g0(self : DoubleMLDIDBinary) -> Array[Double]

    Cross-fitted control outcome predictions g_0(X) = E[Y | D = 0, X].

    DoubleMLDIDBinary::predictions_g1

    fn DoubleMLDIDBinary::predictions_g1(self : DoubleMLDIDBinary) -> Array[Double]

    Cross-fitted treated outcome predictions g_1(X) = E[Y | D = 1, X].

    DoubleMLDIDBinary::predictions_m

    fn DoubleMLDIDBinary::predictions_m(self : DoubleMLDIDBinary) -> Array[Double]

    Cross-fitted propensity predictions on the wide-format subset.

    DoubleMLDIDBinary::psi_a_long

    fn DoubleMLDIDBinary::psi_a_long(self : DoubleMLDIDBinary) -> Array[Double]

    v0.15.0+: per-observation influence function component psi_a mapped from the cell's wide-format data back to the long-format panel. Length equals data.n_obs() (the long-format panel). For long-format rows that are not in the cell's wide-format subset, the value is 0.0. The influence function psi = psi_a + theta * psi_b is used by the multiplier bootstrap in DoubleMLDIDMulti::bootstrap.

    DoubleMLDIDBinary::psi_b_long

    fn DoubleMLDIDBinary::psi_b_long(self : DoubleMLDIDBinary) -> Array[Double]

    v0.15.0+: per-observation influence function component psi_b mapped from the cell's wide-format data back to the long-format panel. See psi_a_long for details.

    DoubleMLDIDBinary::sandwich_se

    fn DoubleMLDIDBinary::sandwich_se(self : DoubleMLDIDBinary, kind : SandwichKind) -> Double

    v0.89.0+: Huber-White sandwich standard error for the fitted binary DID. Pure forwarder: the wrapper holds the whole fitted inner DoubleMLDID and that estimator already implements the v0.86.0 sandwich_se API, so the three methods are delegated rather than recomputed. The same applies to the DoubleMLDIDCSBinary / DoubleMLDID::sandwich_se contract (same psi_a / psi / M_inv construction, same shared sandwich_variance(kind, ...) dispatch in sandwich.mbt).

    The inner DoubleMLDID owns the IF components, so the variance's n_obs is the POST-SUBSET wide-format panel width self.n_obs_subset() -- NOT self.data.n_obs() (the long-format panel) and NOT the length of psi_a_long(). psi_a_long() maps the inner psi back to the long panel and zero-fills rows outside the cell, so feeding it here (or passing cluster_ids of long-panel length) is wrong.

    Preconditions: self.fitted.

    DoubleMLDIDBinary::se

    fn DoubleMLDIDBinary::se(self : DoubleMLDIDBinary) -> Double

    Standard error of the ATT point estimate.

    DoubleMLDIDBinary::sensitivity_analysis

    fn DoubleMLDIDBinary::sensitivity_analysis(self : DoubleMLDIDBinary, cf_y? : Double, cf_d? : Double) -> SensitivityResult raise

    v0.66.0+: Cinelli & Hazlett (2020) omitted-variable bias analysis. Delegates to the inner DoubleMLDID::sensitivity_analysis on the wide-format ATT sub-model; the long-format row mapping in eval_idx is preserved (the residual is computed on the inner's row space).

    DoubleMLDIDBinary::sensitivity_analysis_cluster

    fn DoubleMLDIDBinary::sensitivity_analysis_cluster(self : DoubleMLDIDBinary, cluster_ids : Array[Int], cf_y? : Double, cf_d? : Double) -> SensitivityResult raise

    v0.72.0+: cluster-robust analogue of DoubleMLDIDBinary::sensitivity_analysis. Delegates to the inner DoubleMLDID::sensitivity_analysis_cluster on the wide-format ATT sub-model. cluster_ids must have the same length as the wide-format subset (n_obs_subset, i.e. inner.data.n_obs()); the long-format row mapping in eval_idx is preserved. Pass an empty cluster_ids (the default) to abort via the inner precondition (no per-row cluster mapping is implied by the long-format panel).

    DoubleMLDIDBinary::tune

    fn DoubleMLDIDBinary::tune(self : DoubleMLDIDBinary, param_set~ : Array[TuneParam], scoring_method? : String, n_folds_tune? : Int, seed? : Int) -> DoubleMLDIDBinary

    DoubleMLDIDBinary::tune - score a grid of nuisance-learner combinations and re-fit the estimator with the winner.

    Slots

    Same two slots as DoubleMLDID::tune, in the propensity-first sense documented on TuneParam (see its slot-semantics table): FIRST slot is the treatment / propensity learner E[g | x], SECOND is the outcome learner E[m0 + d * m1 | x]. The first slot lives on the field named learner_l - that name is not a claim about which learner it holds. Use TuneParam::for_treatment(learner_g, learner_m) so the call site carries the role.

    Routing

    The fold draw, the scoring and the selection rule all go through the shared tune_score_grid core in tune.mbt; this method adds no numerics of its own. Only the first slot is scored, and the winning learner_l is paired with the same grid row's learner_m for the re-fit.

    v0.108.0: new. DoubleMLDIDBinary had no tune at all, which was a coverage gap against upstream, where tune reaches every estimator through the BaseDML mixin.

    Caveats found while reading the fit path

    • ROW SET MISMATCH, and it is the substantive one. fit first runs preprocess_did_binary and then rebuilds a wide, post-subset panel restricted to the four (G_indicator, T_indicator) cells; the final cross-fit runs on THAT data, stratified on G_indicator + 2 * t_indicator (falling back to plain kfold when a stratum is too small). tune_score_grid scores against self.data.x / self.data.y, i.e. the FULL long-format panel, not the wide post-subset rows the fit actually uses. So a candidate is ranked on strictly more rows than it is later cross-fitted on. This is a known gap, not an endorsement: the ranking is still a legitimate OOF score of E[y | x], but it is not scored on the estimator's own estimation sample.
    • fit exposes no tune_result parameter, so the per-candidate score vector is not retained on the returned estimator (same as on DoubleMLDID::tune, and unlike DoubleMLPLR::tune which stores a TuneResult). Recorded rather than dropped silently.
    • n comes from self.data.y.length() via the core, not from self.n_obs_subset(). The latter is the post-subset count, so using it here would have been the wrong denominator for a full-panel score.

    No cluster guard

    require(!self.data.is_cluster_data()) is deliberately ABSENT. DoubleMLDIDBinaryData is {x, y, d, t, g, id} and defines no is_cluster_data(). It does carry id (the panel unit id), but cluster ids reach this estimator as an explicit argument to DoubleMLDIDBinary::cluster_sandwich_se(cluster_ids) rather than as data-container state, so a guard reading self.data could never fire. Adding the method here would have meant inventing a cluster flag to guard on, which is out of scope for this change.

    Consequence, stated so the absence is not read as an endorsement: unit-aware tuning is neither enforced nor implemented for this estimator. Tune folds are row-wise.

    DoubleMLDIDBinaryData

    pub struct DoubleMLDIDBinaryData {
    x : Matrix
    y : Array[Double]
    d : Array[Double]
    t : Array[Int]
    g : Array[Int]
    id : Array[Int]
    } derive(
    Debug
    )

    Panel data container for the binary DID model. Stores long-format panel observations: each row is (unit, time, y, d, x_1, ...,x_p). t_col and id_col identify the time and unit indices. g_col is the unit's treatment-group index (the period of first treatment; equal to never_treated_value for never-treated units). All arrays are length n_obs_total = n_units * n_periods.

    The fit step reshapes this long-format data into a wide-format DID dataset: for each unit observed in both t_value_pre and t_value_eval, we construct y_diff = y_post - y_pre, the G_indicator = (g == g_value), the C_indicator (never-treated or not-yet-treated per control_group), and the covariates from the earlier period. The wide-format dataset is then passed to DoubleMLDID for the standard DML cross-fit.

    DoubleMLDIDBinaryData::n_features

    fn DoubleMLDIDBinaryData::n_features(self : DoubleMLDIDBinaryData) -> Int

    DoubleMLDIDBinaryData::n_obs

    DoubleMLDIDBinaryData::new

    fn DoubleMLDIDBinaryData::new(x : Matrix, y : Array[Double], d : Array[Double], t : Array[Int], g : Array[Int], id : Array[Int]) -> DoubleMLDIDBinaryData

    DoubleMLDIDCS

    pub struct DoubleMLDIDCS {
    data : DoubleMLDIDCSData
    control_group : String
    anticipation_periods : Int
    n_folds : Int
    n_rep : Int
    seed : Int
    propensity_clip : Double
    ps_processor_config : PSProcessorConfig
    in_sample_normalization : Bool
    coef_matrix : Array[Double]
    se_matrix : Array[Double]
    psi_matrix : Array[Double]
    psi_a_matrix : Array[Double]
    residuals_matrix : Array[Double]
    cell_psi_a : Array[Array[Double]]
    cell_psi_b : Array[Array[Double]]
    m_hat_long : Array[Double]
    panel_folds : Array[Fold]
    g_hat_d0t0_long : Array[Double]
    g_hat_d0t1_long : Array[Double]
    g_hat_d1t0_long : Array[Double]
    g_hat_d1t1_long : Array[Double]
    sigma2_panel : Double
    nu2_panel : Double
    psi_sigma2_long : Array[Double]
    psi_nu2_long : Array[Double]
    riesz_rep_long : Array[Double]
    n_groups : Int
    n_periods : Int
    fitted : Bool
    boot_t_stat : Array[Double]
    boot_method : String
    n_rep_boot : Int
    boot_seed : Int
    memoize_enabled : Bool
    ml_g : LearnerDispatch
    ml_m : LearnerDispatch
    } derive(
    Debug
    )

    Callaway-Sant'Anna (2021) staggered DID model. Iterates over every (g, t_pre, t_eval) triple where g is a treatment group and t_eval is strictly after g, runs a DoubleMLDIDBinary on the long-format panel restricted to the never-treated control cohort, and stores the per-(g, t) ATT estimate, SE, and 95% CI.

    Simplifications vs. upstream:
    • Binary treatment {0, 1} only (the {-1, 0, 1} multi-valued d convention is not supported here; use DoubleMLDIDBinary with control_group = "not_yet_treated" for the staggered case).
    • Default control group is "never_treated". "not_yet_treated" is supported but not-yet-treated units are not "switched in" to be controls in subsequent periods (we always use the never-treated sentinel cohort as the single control).
    • Score is fixed to observational; in-sample normalisation is false (matches the DoubleMLDID default). Callers can pass in_sample_normalization = true to switch to the Sant'Anna & Zhao (2020) eq. 4.3 form.
    • No sensitivity analysis, no tune_optuna, no aggregation beyond the per-(g, t) output.

    DoubleMLDIDCS::bias_corrected_coef_at

    fn DoubleMLDIDCS::bias_corrected_coef_at(self : DoubleMLDIDCS, group_idx : Int, period_idx : Int) -> Double

    v0.100.0+: returns coef_at(group_idx, period_idx) UNCHANGED -- a documented no-op, not a bias correction. Identical in reasoning to DoubleMLAPO::bias_corrected_coef (v0.91.0), DoubleMLAPOS::bias_corrected_coef_at (v0.100.0) and DoubleMLQTE::bias_corrected_coef_at; see DoubleMLPQ::bias_corrected_coef and bias_corrected_theta in sandwich.mbt for the full argument.

    coef_at(g, t) is the child's root of the DML moment f(theta) = E[theta * psi_a + psi_b] (var_est.mbt), so mean(f(coef_at(g, t))) is identically zero: the estimating function is orthogonal by construction, and that orthogonality IS what makes the estimator consistent. Nothing computable from the fitted scores is a bias estimate for this class of estimator, so this accessor reports the uncorrected point estimate rather than a number that merely looks like a correction.

    The pre-v0.91.0 form coef + mean(psi_b - coef * psi_a) is that vector at -coef, NOT at coef; since coef = -mean_b / mean_a its mean is mean_b - coef * mean_a = -2 * coef * mean_a, so the accessor returned coef * (1 - 2 * mean(psi_a)) -- exactly 3 * coef when mean(psi_a) = -1, which is exactly this score's case for every cell. That is not a bias estimate.

    Unlike sandwich_se_at, an unestimated cell does NOT abort here: the answer is the identity on coef_at, and coef_at reports 0.0 for such a cell, so 0.0 is the consistent answer rather than a misleading one.

    The method is per cell, matching the rest of the v0.100.0 DIDCS API.

    Preconditions: self.fitted, 0 <= group_idx < n_groups, 0 <= period_idx < n_periods.

    DoubleMLDIDCS::bootstrap

    fn DoubleMLDIDCS::bootstrap(self : DoubleMLDIDCS, method_name? : String, n_rep_boot? : Int, seed? : Int) -> DoubleMLDIDCS

    v0.64.0+: multiplier bootstrap for DoubleMLDIDCS. For each (g, t) cell that has a non-zero SE, materialises the per-cell IF row (psi_matrix[(gi * n_periods + pi) *n_obs + i_long]) into a flat [n_cells, n_obs] psi matrix and calls the shared generic_bootstrap_psi_matrix helper. boot_t_stat is a flat [n_rep_boot * n_cells] array, row-major by rep, then by cell — the same layout as DoubleMLDIDMulti::boot_t_stat. Pre-treatment or empty cells (se_matrix[k] == 0) are zeroed out by the helper.

    method_name selects the multiplier distribution: "normal" (default), "Bayes", "wild". seed defaults to 2024; n_rep_boot defaults to 500.

    Calling on an un-fit model aborts via PreconditionError.

    DoubleMLDIDCS::clear_cache

    fn DoubleMLDIDCS::clear_cache(self : DoubleMLDIDCS) -> DoubleMLDIDCS

    v0.82.0+: drop any cached nuisance predictions across all per-cell inner models. No-op on an un-fit wrapper.

    DoubleMLDIDCS::cluster_sandwich_se_at

    fn DoubleMLDIDCS::cluster_sandwich_se_at(self : DoubleMLDIDCS, group_idx : Int, period_idx : Int, cluster_ids : Array[Int]) -> Double

    v0.100.0+: cluster-robust sandwich standard error for the (g, t) cell of a fitted CS-DID (Arellano 1987, Cameron-Gelbach-Miller 2011). Routes through cluster_sandwich_variance with the SAME psi_a / psi / M_inv inputs as the IID sandwich_se_at path, and the same per-cell n_wide row set.

    DoubleMLDIDCSData has no cluster_vars field (the CS-DID estimator itself fits no cluster path), so the caller must supply cluster_ids explicitly. The ids are PER CELL AND PER CELL ROW, not per long-format panel row: cluster_ids.length() must equal this cell's cell_psi_a[g * n_periods + t].length(), i.e. the child's n_wide. For a (g, t) cell that is one entry per unit observed in both the pre and the eval period, so the panel data.id (length n_units * n_periods, long format) is the WRONG array to pass; slice the cell's rows first, or build the ids from the same (g in {G, C}, t in {pre, eval}) rule the cell loop uses. Cluster indices are 0-based and 1 + max(cluster_ids) is the number of clusters. For a DID the natural grouping is the panel unit.

    NOT equal to the IID HC0 once clusters hold more than one row, and the reason is the MEAT, not the denominator: cluster_sandwich_variance sums S_c = (sum_{i in c}psi[i])^2 per cluster, so the within-cluster cross terms enter, and each cluster carries a per-cluster jackknife factor n_c / (n_c - 1) (clipped to 1 for a singleton cluster). The divisor is the same n_wide^2 as HC0, and all rows of the cell are the same rows. With ALL-SINGLETON clusters the meat degenerates to sum_i psi[i]^2, the jackknife factors are all 1, and the formula collapses to sandwich_se_at(g, t, HC0) exactly -- which is the property expand_v100_didcs_test.mbt pins.

    This is the ROW-level cluster variance. A clustered FIT's se_at in this package is divided by n_units (panel units), not by the row count, so cluster_sandwich_se_at is not a drop-in for a cluster-robust se_at; the DIDCS fit path takes no clustering at all, so there is no clustered se_at to disagree with here.

    Preconditions: self.fitted, valid indices, the cell was actually estimated, mean(psi_a) != 0, cluster_ids.length() == n_wide, all cluster_ids[i] >= 0.

    DoubleMLDIDCS::coef_at

    fn DoubleMLDIDCS::coef_at(self : DoubleMLDIDCS, group_idx : Int, period_idx : Int) -> Double

    Per-(g, t) ATT estimate (row-major indexing). 0.0 if the (g, t) cell is empty (e.g. t_eval ≤ g for the chosen group).

    DoubleMLDIDCS::confint

    fn DoubleMLDIDCS::confint(self : DoubleMLDIDCS, joint? : Bool, level? : Double) -> Array[(Double, Double)]

    When joint = false (default), uses the Wald-style theta ± 1.96 * se interval. When joint = true, uses the bootstrap: theta ± critical_value * se where critical_value is the (1 - alpha) quantile of max_k |boot_t_stat[b, k]| over bootstrap replications b (the joint critical value). Requires bootstrap() to have been called.

    DoubleMLDIDCS::disable_memoize

    fn DoubleMLDIDCS::disable_memoize(self : DoubleMLDIDCS) -> DoubleMLDIDCS

    v0.82.0+: turn off memoization.

    DoubleMLDIDCS::enable_memoize

    fn DoubleMLDIDCS::enable_memoize(self : DoubleMLDIDCS) -> DoubleMLDIDCS

    v0.82.0+: turn on memoization for subsequent fit() calls. The flag is forwarded to every per-cell DoubleMLDIDBinary fit.

    DoubleMLDIDCS::fit

    Run the CS-DID estimation: for every (g, t_eval) triple with t_eval > g, restrict the panel to the never-treated cohort plus the units with g == g_value, run a DoubleMLDIDBinary on the restricted long-format data with t_value_pre = g and t_value_eval = t_eval, and store the resulting ATT and SE.

    The (g, t_eval) combinations are visited in row-major order (groups ascending, periods ascending). Cells with t_eval <= g are left at the default 0.0 (the CS-DID convention is "no pre-treatment estimate" for such cells; downstream aggregation layers can drop them via the nan-aware aggregator).

    DoubleMLDIDCS::group_at

    fn DoubleMLDIDCS::group_at(self : DoubleMLDIDCS, group_idx : Int) -> Int

    Group value at row index i (sorted ascending).

    DoubleMLDIDCS::has_cache

    fn DoubleMLDIDCS::has_cache(self : DoubleMLDIDCS) -> Bool

    v0.82.0+: true iff the memoize flag is set.

    DoubleMLDIDCS::learner_g

    v0.153.0: the outcome learner (ml_g) recorded at construction and used by fit when no explicit argument is given. Reports the DEFAULT, not necessarily the learner of the current fit: an explicit fit(ml_g = ...) overrides it for that call only and is not recorded.

    DoubleMLDIDCS::learner_m

    v0.153.0: the propensity learner (ml_m) recorded at construction. See learner_g for the same default-vs-current-fit caveat.

    DoubleMLDIDCS::n_groups

    fn DoubleMLDIDCS::n_groups(self : DoubleMLDIDCS) -> Int

    Number of groups (excluding the never-treated sentinel).

    DoubleMLDIDCS::n_periods

    fn DoubleMLDIDCS::n_periods(self : DoubleMLDIDCS) -> Int

    Number of distinct time periods.

    DoubleMLDIDCS::new

    fn DoubleMLDIDCS::new(data : DoubleMLDIDCSData, control_group? : String, anticipation_periods? : Int, n_folds? : Int, n_rep? : Int, seed? : Int, propensity_clip? : Double, ps_processor? : PSProcessor, ps_processor_config? : PSProcessorConfig?, in_sample_normalization? : Bool, ml_g? : LearnerDispatch, ml_m? : LearnerDispatch) -> DoubleMLDIDCS

    DoubleMLDIDCS::period_at

    fn DoubleMLDIDCS::period_at(self : DoubleMLDIDCS, period_idx : Int) -> Int

    Time value at column index t (sorted ascending).

    DoubleMLDIDCS::predictions_g_d0_t0

    fn DoubleMLDIDCS::predictions_g_d0_t0(self : DoubleMLDIDCS) -> Array[Double]

    E[Y | D = 0, T = 0, X], cross-fitted over the whole panel. Length n_obs. The conventions above apply to all four; they are documented on this one.

    DoubleMLDIDCS::predictions_g_d0_t1

    fn DoubleMLDIDCS::predictions_g_d0_t1(self : DoubleMLDIDCS) -> Array[Double]

    E[Y | D = 0, T = 1, X], cross-fitted over the whole panel. Length n_obs. See DoubleMLDIDCS::predictions_g_d0_t0 for the shared conventions.

    DoubleMLDIDCS::predictions_g_d1_t0

    fn DoubleMLDIDCS::predictions_g_d1_t0(self : DoubleMLDIDCS) -> Array[Double]

    E[Y | D = 1, T = 0, X], cross-fitted over the whole panel. Length n_obs. See DoubleMLDIDCS::predictions_g_d0_t0 for the shared conventions. Structurally EMPTY on the canonical 2-period fixture -- see convention 3.

    DoubleMLDIDCS::predictions_g_d1_t1

    fn DoubleMLDIDCS::predictions_g_d1_t1(self : DoubleMLDIDCS) -> Array[Double]

    E[Y | D = 1, T = 1, X], cross-fitted over the whole panel. Length n_obs. See DoubleMLDIDCS::predictions_g_d0_t0 for the shared conventions.

    DoubleMLDIDCS::predictions_m

    fn DoubleMLDIDCS::predictions_m(self : DoubleMLDIDCS) -> Array[Double]

    Cross-fitted group propensity m_hat = E[D | X] on the LONG-format panel. v0.146.0+ (stage 1a of the did_cs sensitivity migration).

    Empty until fit. Length n_obs -- the LONG panel, i.e. one entry per unit-period row, not per unit.

    WHY IT EXISTS. Upstream's did_cs sensitivity decomposition needs this on the full panel: propensity_weight_d0 = m_hat / (1 - m_hat) (did_cs.py:470), which feeds m_alpha and hence nu2 = mean(2*m_alpha -rr^2) (did_cs.py:527-528). Until now the port could not produce it: each (g, t) cell delegates to a child DoubleMLDIDBinary restricted to that cell's two cohorts and two periods, so its propensity is a different object, and DoubleMLDIDCS exposed no prediction accessor either.

    NOT YET CONSUMED. This release only persists and exposes the nuisance. sensitivity_analysis still routes through the legacy irm_style_sensitivity, so no reported number changes in v0.146.0 -- which is asserted by GATE 5 in expand_v146_test.mbt. The nu2 formula port that consumes it is a later stage, and it additionally needs the four (d, t) outcome nuisances that this stage does not build.

    DoubleMLDIDCS::sandwich_se_at

    fn DoubleMLDIDCS::sandwich_se_at(self : DoubleMLDIDCS, group_idx : Int, period_idx : Int, kind : SandwichKind) -> Double

    v0.100.0+: heteroskedasticity-consistent (Huber-White) sandwich standard error for the (g, t) cell of a fitted CS-DID. Routes through the shared sandwich_variance dispatch, so the HC0 / HC1 / HC2 / HC3 relationships are the package-wide ones (see the file header in sandwich.mbt and the identities pinned in expand_v088_test.mbt and expand_v100_didcs_test.mbt).

    The three inputs are the ones the child DoubleMLDIDBinary already computed and persisted for its own multiplier bootstrap:
    • psi_a = the child's wide-format psi_a row, i.e. the treatment-weight term -d / p_hat;
    • psi = psi_at(coef_at(g, t), psi_a, psi_b) -- the per-observation influence function at the fitted cell coefficient, in the package-wide psi(theta) = theta * psi_a + psi_b order. This is the order var_est uses, and it is the OPPOSITE of the psi_a + theta * psi_b order the long-format psi_matrix uses -- see the section comment above;
    • M_inv = [[1 / mean(psi_a)]], which is [[-1.0]] here (only M_inv[0, 0]^2 enters the variance).

    n_obs is the cell's n_wide -- the child's own wide-format row count, the same n its var_est divided by -- and n_params is 1: ONE scalar estimand (this cell's ATT), NOT n_groups and NOT the number of visited cells. The plausible misreading k = n_groups would give sqrt(n / (n - k)) for HC1 and is ruled out in the test.

    coef_at(g, t) IS the child's stored coef, the same number var_est solved for, so on the IID path

    sandwich_se_at(g, t, HC0) == se_at(g, t)

    holds BIT-IDENTICALLY (measured difference 0.0; see the section comment and expand_v100_didcs_test.mbt).

    Preconditions: self.fitted, 0 <= group_idx < n_groups, 0 <= period_idx < n_periods, the cell was actually estimated, mean(psi_a) != 0, and n_obs > n_params for HC1 / HC2 / HC3.

    DoubleMLDIDCS::se_at

    fn DoubleMLDIDCS::se_at(self : DoubleMLDIDCS, group_idx : Int, period_idx : Int) -> Double

    Per-(g, t) ATT standard error.

    DoubleMLDIDCS::sensitivity_analysis

    fn DoubleMLDIDCS::sensitivity_analysis(self : DoubleMLDIDCS, cf_y? : Double, cf_d? : Double) -> Array[SensitivityResult] raise

    default. Calling on an un-fit model aborts via PreconditionError.

    DoubleMLDIDCS::sensitivity_analysis_cluster

    fn DoubleMLDIDCS::sensitivity_analysis_cluster(self : DoubleMLDIDCS, cluster_ids : Array[Int], cf_y? : Double, cf_d? : Double) -> Array[SensitivityResult] raise

    v0.73.0+: cluster-robust analogue of DoubleMLDIDCS::sensitivity_analysis. Same per-cell ATT-form residual y - g_d0_hat - (g_d1_hat - g_d0_hat) *d and the same per-cell psi_a (the Riesz-representer component) as the IID path; only the variance / bias computation is cluster-aware (see irm_style_sensitivity_cluster_multi and irm_style_sensitivity_cluster in sensitivity.mbt).

    DoubleMLDIDCSData has no cluster_vars field (the CS-DID estimator itself does not fit a cluster path), so the user must pass cluster_ids explicitly. cluster_ids.length() must equal self.data.n_obs() (the long-format panel length). Cluster indices are 0-based; 1 + max(cluster_ids) is the number of clusters. All rows in the same cluster share an id.

    Cells with se_matrix[k] == 0 (pre-treatment / no observed evaluation period / empty panel subset) return a zeroed SensitivityResult from the inner irm_style_sensitivity_cluster pass.

    Returns an Array[SensitivityResult] of length n_groups * n_periods in row-major (gi, pi) order.

    DoubleMLDIDCS::sensitivity_folds

    fn DoubleMLDIDCS::sensitivity_folds(self : DoubleMLDIDCS) -> Array[Fold]

    The panel-wide cross-fitting folds backing predictions_m. v0.146.0+. Exposed so the nuisance is reproducible and its stratification is inspectable rather than a hidden detail of fit.

    DoubleMLDIDCS::sensitivity_nu2

    fn DoubleMLDIDCS::sensitivity_nu2(self : DoubleMLDIDCS) -> Double

    Upstream's nu2 (did_cs.py:527-528):

    nu2 = mean(2*m_alpha - rr^2)

    where m_alpha and rr are the Riesz quantities from doubleml/did/did_cs.py:466-497. Both formulas verified bit-exact against a running upstream 0.11.3: on a 2x2 panel upstream reports nu2 = 18.253043736106648 and sigma2 = 256.4070590464714, and this transcription reproduces both to the last digit (_verify/_oracle_v1482_nu2_gap.py).

    PANEL-LEVEL, like sensitivity_sigma2.

    MAY BE NaN, AND THAT IS CORRECT -- on a 2-period panel. The in_sample_normalization = true branch divides by mean(d1t0), which is 0.0 on the canonical 2-period fixture because nobody is treated in the pre-period -- the (d=1, t=0) cell is structurally empty. Upstream's arithmetic on the same input produces the same Inf/NaN, and no fallback is substituted, because a quiet replacement number would be indistinguishable from a correct one.

    On a MULTI-PERIOD panel this accessor is NOT correct, and does not merely go NaN -- it returns a large negative value (measured -112652.99) built from a negative denominator. See the v0.154.0 RESTRICTION note on sensitivity_sigma2, which governs all five sensitivity_* accessors. This paragraph's "that is correct" claim holds ONLY for 2-period panels.

    Which branch runs is self.in_sample_normalization, whose port default (false) differs from upstream's (true).

    0.0 before fit.

    DoubleMLDIDCS::sensitivity_psi_nu2

    fn DoubleMLDIDCS::sensitivity_psi_nu2(self : DoubleMLDIDCS) -> Array[Double]

    Per-row psi_nu2 = (2*m_alpha - rr^2) - nu2. As with psi_sigma2, upstream stores the DEVIATION from the scalar (did_cs.py:529), so this array has mean exactly zero and sensitivity_nu2 is NOT its mean -- recovering the element itself needs psi_nu2[i] + nu2. Length n_obs; empty before fit.

    v0.154.0: 2-PERIOD PANELS ONLY. See the RESTRICTION note on sensitivity_sigma2.

    DoubleMLDIDCS::sensitivity_psi_sigma2

    fn DoubleMLDIDCS::sensitivity_psi_sigma2(self : DoubleMLDIDCS) -> Array[Double]

    Per-row psi_sigma2 = (y - g_hat)^2 - sigma2. Upstream's convention is the DEVIATION from the scalar (did_cs.py:464), so this array has mean exactly zero -- it is not the raw element. Length n_obs; empty before fit.

    v0.154.0: 2-PERIOD PANELS ONLY. See the RESTRICTION note on sensitivity_sigma2 -- on a multi-period panel every row with t >= 2 is discarded from the g-functions this deviation is built from.

    DoubleMLDIDCS::sensitivity_riesz_rep

    fn DoubleMLDIDCS::sensitivity_riesz_rep(self : DoubleMLDIDCS) -> Array[Double]

    Per-row Riesz representer rr. Length n_obs; empty before fit.

    v0.154.0: 2-PERIOD PANELS ONLY. See the RESTRICTION note on sensitivity_sigma2 -- rr is built from the same mean(d * (1 - t)) denominator that goes negative on a multi-period panel.

    DoubleMLDIDCS::sensitivity_sigma2

    fn DoubleMLDIDCS::sensitivity_sigma2(self : DoubleMLDIDCS) -> Double

    Upstream's sigma2 (did_cs.py:462-463):

    sigma2 = mean((y - g_hat)^2)

    PANEL-LEVEL, not per cell. Upstream's _sensitivity_element_est reads self._dml_data in its entirety and its manual oracle fit_did_cs(y, x, d, t, ...) takes no group argument at all, so there is one sigma2 for the whole panel -- unlike sensitivity_analysis, which returns one SensitivityResult per (group, period) cell.

    This does NOT change what sensitivity_analysis reports; that path still uses irm_style_sensitivity over the DML score element. The two disagree by construction and both are exposed.

    v0.154.0 RESTRICTION -- THIS PATH IS 2-PERIOD PANELS ONLY. All five sensitivity_* accessors assume the treatment variable t is a BINARY post-treatment indicator taking values {0, 1}. They are correct only when the panel's period labels are exactly [0, 1], which is the canonical make_did_cs_CS2021 fixture.

    On a MULTI-PERIOD panel they are wrong, in two independent ways, both measured (_verify/PORT_VS_UPSTREAM_DEFAULTS_1540.md):

    1. The four g-function condition masks are built hardcoded to period labels 0 and 1 (did_cs.mbt, the cond_d0_t0 .. cond_d1_t1 loop), so every row with t >= 2 is silently discarded from all four g_hat_dXtY nuisances. On the 4-period fixture that is 800 of 1600 rows -- exactly half, matching the v0.147.0 cell-occupancy census.
    2. t is then used as a 0/1 INDICATOR, so 1 - t is negative for t >= 2. That makes mean(d * (1 - t)) negative -- measured -0.5 on the 4-period fixture -- which is a denominator, not a frequency.

    Consequence: on a multi-period panel these accessors return a number computed from half the rows with a malformed indicator. It is not NaN, so nothing signals the problem. The ATT path is unaffected and needs no caveat; see GATE 4 in expand_v154b_test.mbt.

    No guard is raised today: adding one changes reported values, so it is scoped as its own change rather than slipped in here. Until then, treat a multi-period sigma2 / nu2 as NOT IMPLEMENTED. Five tripwires in expand_v154b_test.mbt pin the current defective behaviour -- invert them, do not delete them, when this is fixed.

    0.0 before fit.

    DoubleMLDIDCS::tune

    fn DoubleMLDIDCS::tune(self : DoubleMLDIDCS, param_set~ : Array[TuneParam], scoring_method? : String, n_folds_tune? : Int, seed? : Int) -> DoubleMLDIDCS

    DoubleMLDIDCS::tune - score a grid of nuisance-learner combinations and re-fit the estimator with the winner.

    Slots

    Tunes the two DID nuisance slots in the propensity-first sense documented on TuneParam (see its slot-semantics table): FIRST slot is the treatment / propensity learner E[g | x], SECOND is the outcome learner E[m0 + d * m1 | x]. The first slot lives on the field named learner_l - that name is not a claim about which learner it holds. Build rows with TuneParam::for_treatment(learner_g, learner_m) so the call site carries the role.

    Routing

    The fold draw, the scoring and the selection rule all go through the shared tune_score_grid core in tune.mbt; this method adds no numerics of its own. Only the first slot is scored, and the winning learner_l is paired with the same grid row's learner_m for the re-fit.

    v0.108.0: new. DoubleMLDIDCS had no tune at all, which was a coverage gap against upstream, where tune reaches every estimator through the BaseDML mixin.

    Caveats found while reading the fit path

    • This is the estimator the core's y.length() design exists for: DoubleMLDIDCS exposes NO n_obs() (its only size accessors are n_groups and n_periods). A core keyed on self.n_obs() could not have served it without pushing a new accessor onto a public type purely to serve a helper. The score is therefore over the long-format panel, data.y.length() rows.
    • fit takes only (ml_g, ml_m) as tunables. It then builds a per-(group, period) child DoubleMLDIDBinary and forwards ml_g~ / ml_m~ into the child's fit. The score="observational" and in_sample_normalization=self.in_sample_normalization settings on that child are real arguments of the fit path, but they are NOT DoubleMLDIDCS::fit parameters - fit reads them off self and passes them down. tune therefore has nothing to forward here, and a caller who needs a different DML score or normalization on the tuned model must set it on the estimator before calling tune, since the winning re-fit is self.fit(...) and inherits self's values.
    • ROW SET MISMATCH. fit cross-fits each cell on that cell's wide 2-period sub-panel, whereas tune_score_grid scores against the full long-format data.x / data.y. Candidates are ranked over more rows than they are later cross-fitted on. Known gap, not an endorsement.
    • fit exposes no tune_result parameter, so the per-candidate score vector is not retained on the returned estimator (as on DoubleMLDID::tune, and unlike DoubleMLPLR::tune which stores a TuneResult). Recorded rather than dropped silently.

    No cluster guard

    require(!self.data.is_cluster_data()) is deliberately ABSENT. DoubleMLDIDCSData is {x, y, d, t, id, g, groups, times} and defines no is_cluster_data(); it does carry id, the panel unit id, but cluster ids are passed explicitly to DoubleMLDIDCS::cluster_sandwich_se_at(group_idx, period_idx, cluster_ids) rather than held as guard-readable container state, so a guard on self.data could never fire. Adding the method here would have meant inventing a cluster flag to guard on, which is out of scope for this change.

    The honest consequence, and the reason this one is worth reading twice: DIDCS is a repeated-unit panel estimator and its tune folds are drawn over ROWS (unit-periods), not over unique units, so a single unit's own rows can land on both sides of a fold. The absence of the guard is a statement that the guard is not implementable today, NOT that row-wise folds are correct on this data. Unit-wise tune folds remain an open gap.

    DoubleMLDIDCSBinary

    pub struct DoubleMLDIDCSBinary {
    data : DoubleMLDIDCSData
    g_value : Int
    t_value_pre : Int
    t_value_eval : Int
    control_group : String
    anticipation_periods : Int
    n_folds : Int
    n_rep : Int
    seed : Int
    propensity_clip : Double
    ps_processor_config : PSProcessorConfig
    score : String
    in_sample_normalization : Bool
    coef : Double
    se : Double
    psi_a : Array[Double]
    psi_b : Array[Double]
    g_d0_t0 : Array[Double]
    g_d0_t1 : Array[Double]
    g_d1_t0 : Array[Double]
    g_d1_t1 : Array[Double]
    m_hat : Array[Double]
    subset_y : Array[Double]
    subset_g : Array[Double]
    subset_t : Array[Double]
    n_obs_subset : Int
    n_g_subset : Int
    n_c_subset : Int
    fitted : Bool
    boot_t_stat : Array[Double]
    boot_method : String
    n_rep_boot : Int
    boot_seed : Int
    memoize_enabled : Bool
    ml_g : LearnerDispatch
    ml_m : LearnerDispatch
    } derive(
    Debug
    )

    v0.51.0: Callaway-Sant'Anna (2021) DID with binary (group, time) outcomes. Estimates the ATT(g_value, t_value_eval) for a specific (g_value, t_value_pre, t_value_eval) triple via the Sant'Anna-Zhao (2020) binary-outcome DML score. Mirrors the upstream DoubleMLDIDCSBinary (Python 0.11.3) with the v0.51.0 simplifications listed in the file-level docstring.

    DoubleMLDIDCSBinary::bias_corrected_coef

    fn DoubleMLDIDCSBinary::bias_corrected_coef(self : DoubleMLDIDCSBinary) -> Double

    v0.91.0+: returns coef UNCHANGED -- a documented no-op, not a bias correction.

    coef is the root of the DML moment f(theta) = E[theta * psi_a + psi_b] (see var_est.mbt), so mean(f(coef)) is identically zero: the estimating function is orthogonal by construction, and that orthogonality IS what makes the estimator consistent. Nothing computable from the fitted scores is a bias estimate for this class of estimator, so this accessor reports the uncorrected point estimate rather than a number that merely looks like a correction.

    v0.79.0 - v0.90.0 returned coef + mean(psi_b - coef * psi_a). That vector is the score at -coef, NOT at coef; since coef = -mean_b / mean_a its mean is mean_b - coef * mean_a = -2 * coef * mean_a, so the accessor returned coef * (1 - 2 * mean(psi_a)) (exactly 3 * coef when mean(psi_a) = -1). That is not a bias estimate. See bias_corrected_theta in sandwich.mbt for the algebra. The method is kept so the API surface stays stable; removing it outright is the obvious follow-up.

    Preconditions: self.fitted.

    DoubleMLDIDCSBinary::bootstrap

    fn DoubleMLDIDCSBinary::bootstrap(self : DoubleMLDIDCSBinary, method_name? : String, n_rep_boot? : Int, seed? : Int) -> DoubleMLDIDCSBinary

    v0.64.0+: multiplier bootstrap for DoubleMLDIDCSBinary. The per-observation influence function is

    psi[i] = theta * psi_a[i] + psi_b[i]

    where psi_a[i] and psi_b[i] are the per-row ATT-score elements (computed in fit on the post-subset panel). Routes through the shared generic_bootstrap_t_stat helper (see bootstrap_helper.mbt).

    method_name selects the multiplier distribution: "normal" (default), "Bayes", "wild". seed defaults to 2024; n_rep_boot defaults to 500.

    Calling on an un-fit model aborts via PreconditionError.

    DoubleMLDIDCSBinary::clear_cache

    v0.82.0+: drop any cached nuisance predictions. No-op on an un-fit wrapper.

    DoubleMLDIDCSBinary::cluster_sandwich_se

    fn DoubleMLDIDCSBinary::cluster_sandwich_se(self : DoubleMLDIDCSBinary, cluster_ids : Array[Int]) -> Double

    v0.87.0+: cluster-robust sandwich standard error for the fitted binary CS-DID. Routes through cluster_sandwich_variance with the same psi_a / psi / M_inv inputs as the IID sandwich_se path. DoubleMLDIDCSData has no cluster_vars field, so the user must pass cluster_ids explicitly -- typically the panel unit id, which is the natural cluster for a DID.

    Preconditions: self.fitted, cluster_ids.length() == psi_a.length() (the post-subset count).

    DoubleMLDIDCSBinary::coef

    fn DoubleMLDIDCSBinary::coef(self : DoubleMLDIDCSBinary) -> Double

    v0.51.0: ATT estimate (point estimate) for the chosen (g_value, t_value_pre, t_value_eval) triple.

    DoubleMLDIDCSBinary::confint

    fn DoubleMLDIDCSBinary::confint(self : DoubleMLDIDCSBinary, joint? : Bool, level? : Double) -> (Double, Double)

    v0.51.0: 95% Wald-style confidence interval. v0.67.0+: joint is a no-op for single-theta estimators.

    DoubleMLDIDCSBinary::disable_memoize

    v0.82.0+: turn off memoization.

    DoubleMLDIDCSBinary::enable_memoize

    v0.82.0+: turn on memoization for subsequent fit() calls. The flag is currently informational -- the CS Binary cross-fit uses cs_bin_crossfit_nuisance, which runs n_rep reps internally, so a per-rep cache is not applicable. Forwarded for uniformity with the rest of the DID family.

    DoubleMLDIDCSBinary::fit

    v0.51.0: run the CS Binary estimation. Three steps:
    1. Subset the panel to the 4 (G_indicator, T_indicator) cells.
    2. Cross-fit the 4 g-functions and the propensity using kfold_stratified on G_indicator + 2 * T_indicator.
    3. Compute psi_a, psi_b via the observational Sant'Anna-Zhao score, and derive the ATT and SE via the shared var_est(psi_a, psi_b) helper.

    DoubleMLDIDCSBinary::fitted

    fn DoubleMLDIDCSBinary::fitted(self : DoubleMLDIDCSBinary) -> Bool

    v0.51.0: true after fit has been called, false otherwise.

    DoubleMLDIDCSBinary::g_value

    fn DoubleMLDIDCSBinary::g_value(self : DoubleMLDIDCSBinary) -> Int

    v0.51.0: the g_value constructor argument (the treated group in the (g, t) DID setup).

    DoubleMLDIDCSBinary::has_cache

    fn DoubleMLDIDCSBinary::has_cache(self : DoubleMLDIDCSBinary) -> Bool

    v0.82.0+: true iff the memoize flag is set.

    DoubleMLDIDCSBinary::learner_g

    v0.153.0: the outcome learner (ml_g) recorded at construction and used by fit when no explicit argument is given. Reports the DEFAULT, not necessarily the learner of the current fit: an explicit fit(ml_g = ...) overrides it for that call only and is not recorded.

    DoubleMLDIDCSBinary::learner_m

    v0.153.0: the propensity learner (ml_m) recorded at construction. See learner_g for the same default-vs-current-fit caveat.

    DoubleMLDIDCSBinary::n_c_subset

    fn DoubleMLDIDCSBinary::n_c_subset(self : DoubleMLDIDCSBinary) -> Int

    v0.51.0: number of control (C_indicator = 1) rows in the post-subset panel.

    DoubleMLDIDCSBinary::n_g_subset

    fn DoubleMLDIDCSBinary::n_g_subset(self : DoubleMLDIDCSBinary) -> Int

    v0.51.0: number of treated (G_indicator = 1) rows in the post-subset panel.

    DoubleMLDIDCSBinary::n_obs

    fn DoubleMLDIDCSBinary::n_obs(self : DoubleMLDIDCSBinary) -> Int

    v0.51.0: number of observations in the post-subset panel (i.e. the effective sample size for the SE). This is the post-subset count, NOT the full panel n_obs. Matches the upstream DoubleMLDIDCSBinary.n_obs_subset attribute.

    DoubleMLDIDCSBinary::n_obs_panel

    fn DoubleMLDIDCSBinary::n_obs_panel(self : DoubleMLDIDCSBinary) -> Int

    v0.51.0: total number of observations in the original (pre-subset) panel.

    DoubleMLDIDCSBinary::new

    fn DoubleMLDIDCSBinary::new(data : DoubleMLDIDCSData, g_value : Int, t_value_pre : Int, t_value_eval : Int, control_group? : String, anticipation_periods? : Int, n_folds? : Int, n_rep? : Int, seed? : Int, propensity_clip? : Double, ps_processor? : PSProcessor, ps_processor_config? : PSProcessorConfig?, score? : String, in_sample_normalization? : Bool, ml_g? : LearnerDispatch, ml_m? : LearnerDispatch) -> DoubleMLDIDCSBinary

    v0.51.0: construct a DoubleMLDIDCSBinary estimator. Validates the inputs and stores them on the struct; no estimation happens until fit is called. The score argument is restricted to "observational" for v0.51.0 (experimental is a v0.52.0+ target).

    DoubleMLDIDCSBinary::predictions_g_d0_t0

    fn DoubleMLDIDCSBinary::predictions_g_d0_t0(self : DoubleMLDIDCSBinary) -> Array[Double]

    v0.51.0: cross-fitted nuisance predictions g_d0_t0 (control × pre). Length n_obs_subset().

    DoubleMLDIDCSBinary::predictions_g_d0_t1

    fn DoubleMLDIDCSBinary::predictions_g_d0_t1(self : DoubleMLDIDCSBinary) -> Array[Double]

    v0.51.0: cross-fitted nuisance predictions g_d0_t1 (control × eval).

    DoubleMLDIDCSBinary::predictions_g_d1_t0

    fn DoubleMLDIDCSBinary::predictions_g_d1_t0(self : DoubleMLDIDCSBinary) -> Array[Double]

    v0.51.0: cross-fitted nuisance predictions g_d1_t0 (treated × pre).

    DoubleMLDIDCSBinary::predictions_g_d1_t1

    fn DoubleMLDIDCSBinary::predictions_g_d1_t1(self : DoubleMLDIDCSBinary) -> Array[Double]

    v0.51.0: cross-fitted nuisance predictions g_d1_t1 (treated × eval).

    DoubleMLDIDCSBinary::predictions_m

    fn DoubleMLDIDCSBinary::predictions_m(self : DoubleMLDIDCSBinary) -> Array[Double]

    v0.51.0: cross-fitted propensity predictions m(X) = E[G_indicator | X]. Clipped to [propensity_clip, 1 - propensity_clip].

    DoubleMLDIDCSBinary::psi_a

    fn DoubleMLDIDCSBinary::psi_a(self : DoubleMLDIDCSBinary) -> Array[Double]

    v0.51.0: per-observation psi_a from the CS Binary score. Length n_obs_subset().

    DoubleMLDIDCSBinary::psi_b

    fn DoubleMLDIDCSBinary::psi_b(self : DoubleMLDIDCSBinary) -> Array[Double]

    v0.51.0: per-observation psi_b from the CS Binary score. Length n_obs_subset().

    DoubleMLDIDCSBinary::sandwich_se

    fn DoubleMLDIDCSBinary::sandwich_se(self : DoubleMLDIDCSBinary, kind : SandwichKind) -> Double

    v0.87.0+: Huber-White sandwich standard error for the fitted binary CS-DID. Returns sqrt(var) where var comes from the shared sandwich_variance(kind, ...) dispatch in sandwich.mbt (HC0 / HC1 / HC2 / HC3), the same dispatch DoubleMLDID / DoubleMLIIVM / DoubleMLCVAR use.

    The three inputs are the ones fit(...) already persists for the multiplier bootstrap (see the psi_a / psi_b field docs on DoubleMLDIDCSBinary):
    • psi_a = self.psi_a -- the negated ATT weight (-d / (m * l) under the observational score, or the in-sample-normalized -d / mean(d)). Both are non-positive, so mean(psi_a) <= 0 and exactly -1 under in-sample normalization.
    • psi = psi_at(self.coef, self.psi_a, self.psi_b) -- the per-observation influence function at the fitted coef, matching the psi[i] = psi_a[i] +theta * psi_b[i] form documented on DoubleMLDIDCSBinary::bootstrap.
    • M_inv = [[1 / mean(psi_a)]] (1x1). Only M_inv[0, 0]^2 enters the variance, so the sign of the (up-to-sign) Jacobian inverse is irrelevant.

    The variance's n_obs is self.psi_a.length(), i.e. the POST-SUBSET panel width (self.n_obs() / n_obs_subset), not the full pre-subset panel width (self.n_obs_panel()). cluster_ids must therefore be supplied on the same post-subset indexing.

    Preconditions: self.fitted, mean(psi_a) != 0.

    DoubleMLDIDCSBinary::se

    fn DoubleMLDIDCSBinary::se(self : DoubleMLDIDCSBinary) -> Double

    v0.51.0: standard error of the ATT estimate.

    DoubleMLDIDCSBinary::sensitivity_analysis

    fn DoubleMLDIDCSBinary::sensitivity_analysis(self : DoubleMLDIDCSBinary, cf_y? : Double, cf_d? : Double) -> SensitivityResult raise

    v0.66.0+: Cinelli & Hazlett (2020) omitted-variable bias analysis. Routes through the shared irm_style_sensitivity helper with upstream's residual vector (see cs_bin_sensitivity_residuals).

    v0.130.0 FIXES the defect v0.129.0 recorded. The old body was

    let d = self.data.d residuals[i] = self.data.y[i] - g00[i] - (g10[i] - g00[i]) * d[i]

    which is wrong twice over, and both halves were load-bearing:
    1. WRONG VARIABLE. self.data.d is the per-period treatment. Every other use of "d" in this estimator -- psi_a, the propensity, the conditional-g split -- is G (did_cs_binary.py:472), and upstream's sensitivity reads d = self._g_data_subset (:847).
    2. WRONG ROWS. self.data is the full panel; the loop runs over subset positions. MEASURED on the staggered fixture: 240 of the 720 rows read had t == 1 -- a period the subset never contains, and the period in which y carries the treatment effect. So sigma2 was inflated by construction.

    irm_style_sensitivity requires residuals to be element-wise aligned with psi_a; its require(n == psi_a.length()) passed only because both happened to be length 720, which is exactly how the misalignment hid.

    DoubleMLDIDCSBinary::sensitivity_analysis_cluster

    fn DoubleMLDIDCSBinary::sensitivity_analysis_cluster(self : DoubleMLDIDCSBinary, cluster_ids : Array[Int], cf_y? : Double, cf_d? : Double) -> SensitivityResult raise

    v0.72.0+: cluster-robust analogue of DoubleMLDIDCSBinary::sensitivity_analysis. Same residual vector and the same psi_a as the IID path (both come from cs_bin_sensitivity_residuals, so the two cannot drift apart); only the variance / bias computation is cluster-aware. DoubleMLDIDCSData has no cluster_vars field, so cluster_ids must be passed explicitly with length matching self.n_obs() (the post-subset count).

    DoubleMLDIDCSBinary::t_value_eval

    fn DoubleMLDIDCSBinary::t_value_eval(self : DoubleMLDIDCSBinary) -> Int

    v0.51.0: the t_value_eval constructor argument (the evaluation period).

    DoubleMLDIDCSBinary::t_value_pre

    fn DoubleMLDIDCSBinary::t_value_pre(self : DoubleMLDIDCSBinary) -> Int

    v0.51.0: the t_value_pre constructor argument (the baseline pre-treatment period).

    DoubleMLDIDCSBinary::tune

    fn DoubleMLDIDCSBinary::tune(self : DoubleMLDIDCSBinary, param_set~ : Array[TuneParam], scoring_method? : String, n_folds_tune? : Int, seed? : Int) -> DoubleMLDIDCSBinary

    DoubleMLDIDCSBinary::tune - score a grid of nuisance-learner combinations and re-fit the estimator with the winner.

    Slots

    Tunes the two DID nuisance slots in the propensity-first sense documented on TuneParam (see its slot-semantics table): FIRST slot is the treatment / propensity learner E[g | x], SECOND is the outcome learner E[m0 + d * m1 | x]. The first slot lives on the field named learner_l - that name is not a claim about which learner it holds. Build rows with TuneParam::for_treatment(learner_g, learner_m) so the call site carries the role.

    Routing

    The fold draw, the scoring and the selection rule all go through the shared tune_score_grid core in tune.mbt; this method adds no numerics of its own. Only the first slot is scored, and the winning learner_l is paired with the same grid row's learner_m for the re-fit under the FINAL-FIT fold schedule (self.n_folds / self.seed).

    v0.108.0: new. DoubleMLDIDCSBinary had no tune at all, which was a coverage gap against upstream, where tune reaches every estimator through the BaseDML mixin.

    Caveats found while reading the fit path

    • This estimator SHARES DoubleMLDIDCSData with DoubleMLDIDCS, so the same data container and the same row set feed both tune families. n therefore comes from self.data.y.length() via the core, which is the FULL panel. That is deliberately NOT self.n_obs(): on this type n_obs() returns n_obs_subset, the POST-subset count, which is a different and smaller number. Using the estimator accessor here would have scored a full-panel cross-fit against a subset denominator.
    • ROW SET MISMATCH, and the stratification asymmetry with it. fit runs cs_bin_panel_subset to restrict the panel to the four (G_indicator, T_indicator) cells and then cross-fits on that post-subset panel using kfold_stratified(n_sub, strata, self.n_folds, self.seed) on the key G_indicator + 2 * T_indicator, so each fold balances the (G, T) cells. tune_score_grid instead draws plain row-wise kfold folds over the full long-format data.x / data.y, with no strata. So candidates are ranked on strictly more rows than they are later cross-fitted on, and under a fold schedule that does not balance the DID cells. Both are known gaps, not endorsements.
    • fit exposes no tune_result parameter, so the per-candidate score vector is not retained on the returned estimator (as on DoubleMLDID::tune, and unlike DoubleMLPLR::tune which stores a TuneResult). Recorded rather than dropped silently.

    No cluster guard

    require(!self.data.is_cluster_data()) is deliberately ABSENT. The DoubleMLDIDCSData it shares with DoubleMLDIDCS defines no is_cluster_data(); it does carry id, the panel unit id, but cluster ids are passed explicitly to DoubleMLDIDCSBinary::cluster_sandwich_se(cluster_ids) rather than held as guard-readable container state, so a guard on self.data could never fire. Adding the method here would have meant inventing a cluster flag to guard on, which is out of scope for this change.

    The honest consequence, as on DoubleMLDIDCS: this is a repeated-unit panel estimator whose tune folds are drawn over ROWS, not unique units, and a unit's own rows can land on both sides of a fold. The missing guard records that it is not implementable today, not that row-wise folds are correct here. Unit-wise tune folds remain open.

    DoubleMLDIDCSData

    pub struct DoubleMLDIDCSData {
    x : Matrix
    y : Array[Double]
    d : Array[Double]
    t : Array[Int]
    id : Array[Int]
    g : Array[Int]
    groups : Array[Int]
    times : Array[Int]
    } derive(
    Debug
    )

    Data container for the Callaway-Sant'Anna (CS) DID model with multi-period panel data and binary treatment. The treatment d is the change in treatment status (binary {0, 1} under the simplified CS-DID port; the upstream package also supports {-1, 0, 1} for "switchers" via the control_group parameter, which is out of scope here). t is the time index (length n_periods); each unit is observed in every period (n_obs =n_units * n_periods rows).

    The fit step iterates over the distinct treatment groups g and the evaluation periods t, runs a DoubleMLDIDBinary for each (g, t_pre, t_eval) triple on the long-format panel, and returns the per-(g, t) ATT estimates as a flat array (row-major, length n_groups * n_periods).

    DoubleMLDIDCSData::new

    fn DoubleMLDIDCSData::new(x : Matrix, y : Array[Double], d : Array[Double], t : Array[Int], id : Array[Int], g : Array[Int]) -> DoubleMLDIDCSData

    DoubleMLDIDCrossSection

    pub struct DoubleMLDIDCrossSection {
    data : DoubleMLDIDCrossSectionData
    n_folds : Int
    n_rep : Int
    seed : Int
    score : String
    in_sample_normalization : Bool
    propensity_clip : Double
    ps_processor_config : PSProcessorConfig
    ml_g : LearnerDispatch
    ml_m : LearnerDispatch
    coef : Double
    se : Double
    psi_a : Array[Double]
    psi_b : Array[Double]
    g_d0_t0 : Array[Double]
    g_d0_t1 : Array[Double]
    g_d1_t0 : Array[Double]
    g_d1_t1 : Array[Double]
    m_hat : Array[Double]
    fitted : Bool
    boot_t_stat : Array[Double]
    boot_method : String
    n_rep_boot : Int
    boot_seed : Int
    memoize_enabled : Bool
    } derive(
    Debug
    )

    v0.20.0+: Cross-section DID model with 4 g-functions and 1 propensity function. The score function matches the upstream doubleml.DoubleMLDIDCS._score_elements algorithm.

    score selects the score convention:
    • "observational" (default): the 2x2 DID setting where treatment assignment may be confounded by covariates. Uses the doubly-robust form with propensity reweighting.
    • "experimental": an A/B-test setting where treatment is independent of pre-treatment covariates. The propensity m collapses to a constant and the score simplifies.

    in_sample_normalization selects the in-sample vs out-of-sample normalization:
    • false (default): the canonical Sant'Anna-Zhao form, with weight_psi_a = d / p_hat.
    • true: in-sample normalization weight_psi_a = d / mean(d).

    DoubleMLDIDCrossSection::bias_corrected_coef

    fn DoubleMLDIDCrossSection::bias_corrected_coef(self : DoubleMLDIDCrossSection) -> Double

    v0.91.0+: returns coef UNCHANGED -- a documented no-op, not a bias correction.

    v0.102.0: the score is psi = theta * psi_a + psi_b and coef = -mean(psi_b) / mean(psi_a), so the score is an orthogonal moment score whose derivative in theta is mean(psi_a) -- a non-zero constant here. Note which branch produces it: with the default in_sample_normalization = false the observational weight is d / p_hat with p_hat = mean(d), so mean(psi_a) = -1 exactly; the in-sample-normalized branch weights by d / mean(d) and lands on the same -1. (The experimental score instead uses a constant weight_psi_a = 1, giving mean(psi_a) = -1 by construction too.) That is the same situation as every other var_est estimator in the package, and for the same reason: bias_corrected_theta is provably the identity for an orthogonal score, so nothing computable from the fitted scores is a bias estimate for this class of estimator. This accessor reports the uncorrected point estimate rather than a number that merely looks like a correction.

    (v0.91.0 - v0.101.0 gave a different reason for the same return value: it described this estimator as a PROJECTION (argmin_theta) fit whose psi was "a residual, not an orthogonal score". That description matched neither the upstream DoubleMLDIDCS nor, after the v0.102.0 fit correction, this estimator.)

    v0.79.0 - v0.90.0 returned coef + mean(psi_b - coef * psi_a), the score at -coef. The result was not a bias estimate. See bias_corrected_theta in sandwich.mbt for the algebra. The method is kept so the API surface stays stable; removing it outright is the obvious follow-up.

    Preconditions: self.fitted.

    DoubleMLDIDCrossSection::bootstrap

    fn DoubleMLDIDCrossSection::bootstrap(self : DoubleMLDIDCrossSection, method_name? : String, n_rep_boot? : Int, seed? : Int) -> DoubleMLDIDCrossSection

    v0.21.0+: multiplier bootstrap. Draws n_rep_boot weight vectors of length n_obs from the chosen multiplier distribution, computes boot_t_stat[b] = sum_i w[b, i] * psi[i] /(sqrt(n) * se) where psi[i] = theta * psi_a[i] + psi_b[i] is the per-observation influence function, and returns a fitted model with boot_t_stat populated.

    method_name selects the multiplier distribution:
    • "normal" (default): w[i] ~ N(0, 1). Matches the upstream bootstrap(method="normal") default.
    • "Bayes": w[i] = exp(1) - 1 (mean 0, var 1).
    • "wild": w[i] = x[i] / sqrt(2) + (y[i]^2 - 1) /2 with x, y ~ N(0, 1). Robust to heteroskedasticity in the influence-function residuals.

    The bootstrap populates self.boot_t_stat and is required for confint(joint=true).

    DoubleMLDIDCrossSection::clear_cache

    v0.82.0+: drop any cached nuisance predictions. No-op on an un-fit wrapper.

    DoubleMLDIDCrossSection::cluster_sandwich_se

    fn DoubleMLDIDCrossSection::cluster_sandwich_se(self : DoubleMLDIDCrossSection, cluster_ids : Array[Int]) -> Double

    v0.87.0+: cluster-robust sandwich standard error for the fitted cross-section DID. Routes through cluster_sandwich_variance with the same psi_a / psi / M_inv inputs as the IID sandwich_se path. DoubleMLDIDCrossSectionData has no cluster_vars field, so the user must pass cluster_ids explicitly. Cluster indices are 0-based; 1 + max(cluster_ids) is the number of clusters.

    Preconditions: self.fitted, cluster_ids.length() == psi_a.length().

    DoubleMLDIDCrossSection::coef

    ATT estimate from the cross-section DID fit.

    DoubleMLDIDCrossSection::confint

    fn DoubleMLDIDCrossSection::confint(self : DoubleMLDIDCrossSection, joint? : Bool, level? : Double) -> (Double, Double)

    v0.20.0+ (extended in v0.21.0): confidence interval for the ATT.

    When joint = false (default), uses the Wald-style theta +/- 1.96 * se interval (or the level-th quantile of the standard normal, scaled to the requested level).

    When joint = true, uses the multiplier bootstrap (must call bootstrap() first). The critical value is the empirical (1 + level) / 2 quantile of |boot_t_stat|, which is wider than the pointwise critical value (more conservative; matches the joint-CI convention for a scalar parameter).

    level is the confidence level (default 0.95).

    DoubleMLDIDCrossSection::disable_memoize

    v0.82.0+: turn off memoization.

    DoubleMLDIDCrossSection::enable_memoize

    v0.82.0+: turn on memoization. Forwarded for uniformity; the cross-section DID uses an internal crossfit_nuisance call so the flag is informational.

    DoubleMLDIDCrossSection::fit

    v0.20.0+: fit the cross-section DID model. Runs cross-fit nuisance estimation (4 g-functions + 1 propensity), constructs the psi_a / psi_b score, and returns the ATT and HC0 SE.

    DoubleMLDIDCrossSection::has_cache

    fn DoubleMLDIDCrossSection::has_cache(self : DoubleMLDIDCrossSection) -> Bool

    v0.82.0+: true iff the memoize flag is set.

    DoubleMLDIDCrossSection::learner_g

    Accessor for the outcome-nuisance learner used by the most recent fit(...) call. v0.60.0+.

    DoubleMLDIDCrossSection::learner_m

    Accessor for the propensity-score learner used by the most recent fit(...) call. v0.60.0+.

    DoubleMLDIDCrossSection::new

    fn DoubleMLDIDCrossSection::new(data : DoubleMLDIDCrossSectionData, n_folds? : Int, n_rep? : Int, seed? : Int, score? : String, in_sample_normalization? : Bool, propensity_clip? : Double, ps_processor? : PSProcessor, ps_processor_config? : PSProcessorConfig?, ml_g? : LearnerDispatch, ml_m? : LearnerDispatch) -> DoubleMLDIDCrossSection

    DoubleMLDIDCrossSection::predictions_g_d0_t0

    fn DoubleMLDIDCrossSection::predictions_g_d0_t0(self : DoubleMLDIDCrossSection) -> Array[Double]

    v0.20.0+: nuisance predictions g(0, 0) (control, pre-period). Length n_obs.

    DoubleMLDIDCrossSection::predictions_g_d0_t1

    fn DoubleMLDIDCrossSection::predictions_g_d0_t1(self : DoubleMLDIDCrossSection) -> Array[Double]

    v0.20.0+: nuisance predictions g(0, 1) (control, post-period). Length n_obs.

    DoubleMLDIDCrossSection::predictions_g_d1_t0

    fn DoubleMLDIDCrossSection::predictions_g_d1_t0(self : DoubleMLDIDCrossSection) -> Array[Double]

    v0.20.0+: nuisance predictions g(1, 0) (treated, pre-period). Length n_obs.

    DoubleMLDIDCrossSection::predictions_g_d1_t1

    fn DoubleMLDIDCrossSection::predictions_g_d1_t1(self : DoubleMLDIDCrossSection) -> Array[Double]

    v0.20.0+: nuisance predictions g(1, 1) (treated, post-period). Length n_obs.

    DoubleMLDIDCrossSection::predictions_m

    fn DoubleMLDIDCrossSection::predictions_m(self : DoubleMLDIDCrossSection) -> Array[Double]

    v0.20.0+: propensity predictions m(x) = E[D=1 | X]. Length n_obs. Clipped to [propensity_clip, 1 -propensity_clip] after the cross-fit prediction averaging.

    DoubleMLDIDCrossSection::psi_a

    v0.20.0+: per-observation psi_a from the cross-section DID score. Length n_obs. Throws if the model hasn't been fit.

    DoubleMLDIDCrossSection::psi_b

    v0.20.0+: per-observation psi_b from the cross-section DID score. Length n_obs. Throws if the model hasn't been fit.

    DoubleMLDIDCrossSection::sandwich_se

    fn DoubleMLDIDCrossSection::sandwich_se(self : DoubleMLDIDCrossSection, kind : SandwichKind) -> Double

    v0.87.0+: Huber-White sandwich standard error for the fitted cross-section DID. Returns sqrt(var) where var comes from the shared sandwich_variance(kind, ...) dispatch in sandwich.mbt (HC0 / HC1 / HC2 / HC3), the same dispatch DoubleMLDID / DoubleMLIIVM / DoubleMLCVAR use.

    The three inputs are the ones fit(...) already persists for the multiplier bootstrap (see the psi_a / psi_b field docs on DoubleMLDIDCrossSection):
    • psi_a = self.psi_a -- the negated ATT weight, -weight_psi_a, where weight_psi_a is d / (m * l) (observational), d / m (experimental), or the in-sample-normalized variant d / mean(d). Every variant is a non-negative weight, so mean(psi_a) <= 0 and, under in-sample normalization, exactly -1.
    • psi = psi_at(self.coef, self.psi_a, self.psi_b) -- the per-observation influence function at the fitted coef, the shared helper fit / bootstrap / the other 19 sandwich_se methods use.
    • M_inv = [[1 / mean(psi_a)]] (1x1). This is 1 / J with J = mean(psi_a) = dE[psi] / d theta, the Jacobian inverse of the score psi = theta * psi_a + psi_b.

    v0.102.0: through v0.101.0 this method used psi[i] = psi_a[i] + coef * psi_b[i] and carried a comment block declaring that its M_inv was "not 1 / mean(psi_a) in any principled sense (the argmin has vanishing first derivative)". That reasoning described a projection (argmin_theta) fit that did not match the upstream estimator, and the formula it quoted for se was not the one fit computed either. With fit now on the shared var_est path the score order is the family's, M_inv = 1 / mean(psi_a) is the principled value rather than a placeholder, and sandwich_se(hc0) equals se() to the bit -- the same invariant the other 19 estimators already satisfy. See CHANGELOG v0.102.0 for the measurements.

    The variance's n_obs is self.psi_a.length() (the length fit(...) persists, equal to self.data.n_obs()).

    Preconditions: self.fitted, mean(psi_a) != 0.

    DoubleMLDIDCrossSection::se

    Standard error of the ATT estimate (HC0 sandwich).

    DoubleMLDIDCrossSection::sensitivity_analysis

    fn DoubleMLDIDCrossSection::sensitivity_analysis(self : DoubleMLDIDCrossSection, cf_y? : Double, cf_d? : Double) -> SensitivityResult raise

    v0.70.0+: Cinelli & Hazlett (2020) omitted-variable bias analysis for the cross-section DID model (Sant'Anna & Zhao 2020). Single-theta IRM-style decomposition: the outcome residual is the ATT form y - g_d0_t0_hat - (g_d1_t0_hat - g_d0_t0_hat) * d on the cross-section sample (matches the v0.66.0 DoubleMLDIDCSBinary::sensitivity_analysis convention extended with the 4-g nuisance predictions this estimator carries); the per-observation Riesz-representer is the psi_a already persisted by fit (drives the multiplier bootstrap IF rows). Routes through the shared irm_style_sensitivity helper.

    cf_y / cf_d default to 0.05 matching the rest of the v0.66.0-onward sensitivity family. Calling on an un-fit model aborts via PreconditionError.

    DoubleMLDIDCrossSection::sensitivity_analysis_cluster

    fn DoubleMLDIDCrossSection::sensitivity_analysis_cluster(self : DoubleMLDIDCrossSection, cluster_ids : Array[Int], cf_y? : Double, cf_d? : Double) -> SensitivityResult raise

    v0.73.0+: cluster-robust analogue of DoubleMLDIDCrossSection::sensitivity_analysis. Same cross-section ATT-form residual y - g_d0_t0_hat - (g_d1_t0_hat - g_d0_t0_hat) * d and the same psi_a as the IID path; only the variance / bias computation is cluster-aware (see irm_style_sensitivity_cluster in sensitivity.mbt).

    DoubleMLDIDCrossSectionData has no cluster_vars field, so the user must pass cluster_ids explicitly. cluster_ids.length() must equal self.data.n_obs() (the cross-section sample length). Cluster indices are 0-based; 1 + max(cluster_ids) is the number of clusters.

    DoubleMLDIDCrossSection::tune

    fn DoubleMLDIDCrossSection::tune(self : DoubleMLDIDCrossSection, param_set~ : Array[TuneParam], scoring_method? : String, n_folds_tune? : Int, seed? : Int) -> DoubleMLDIDCrossSection

    DoubleMLDIDCrossSection::tune - score a grid of (learner_g, learner_m) pairs and re-fit with the winner.

    Slot semantics: this is a propensity-first family, so the first TuneParam slot is the treatment / propensity learner E[g|d,x] and the second is the outcome learner. The first field is still named learner_l (the names are fixed by the partially-linear family and do not change with it); build candidates with TuneParam::for_treatment(learner_g,learner_m) so the call site carries the slot's role instead of leaving a reader to infer it from a field called learner_l.

    v0.108.0: new. DIDCrossSection had no tune at all, so it was one of the estimators carrying a coverage gap against upstream, where tune reaches every estimator through the BaseDML mixin. It routes through the shared tune_score_grid core in tune.mbt - fold draw, cross-fit, scoring and the argmin/argmax selection all live there - rather than re-implementing the loop a ninth time.

    The re-fit is self.fit(ml_g=..., ml_m=...). fit reads n_folds, n_rep, seed, propensity_clip and ps_processor from self and re-applies its own final-fit fold schedule, so nothing else is carried across by hand and no bootstrap state is dropped (fit itself re-applies boot_t_stat / boot_method / n_rep_boot / boot_seed). The grid's candidate cross-fits are NOT reused for the final fit - the winner's nuisances are re-cross-fitted from scratch under self.n_folds, same as every other ::tune.

    Caveat on what the score means: it is MSE of the first-slot learner cross-fitted against the OUTCOME y, not against the treatment d. fit actually builds 4 g-functions g(d,t,x) plus 1 propensity m(x) from these two learners, and the grid never observes the per-cell nuisance quality or the propensity error, so a candidate can win on outcome fit and still be the worse propensity model. This is the same convention the other propensity-first ::tune methods (APO / APOS / IRM / IIVM) use; read the grid as a shortlist, not a per-cell optimum.

    There is no cluster guard here, deliberately. Unlike DoubleMLData / DoubleMLIIVMData / DoubleMLPLIVData, DoubleMLDIDCrossSectionData has no cluster_vars field and no is_cluster_data() method at all, so clustered-inference data cannot be represented in this type in the first place. Writing the guard would not compile, and adding the method is outside this change. This is not the v0.108.0 APO asymmetry (a guard missing where the data type has one) - the accessors genuinely do not exist here.

    DoubleMLDIDCrossSectionData

    pub struct DoubleMLDIDCrossSectionData {
    x : Matrix
    y : Array[Double]
    d : Array[Double]
    t : Array[Int]
    name : String
    } derive(
    Debug
    )

    Data container for the cross-section DID model (Sant'Anna & Zhao 2020, "repeated cross-sections" variant). Each unit has ONE observation, with covariates x, outcome y, binary treatment d (in {0, 1}), and binary post-period indicator t (in {0, 1}). The total sample size is n.

    Unlike the panel DID, there is no id column (cross-section = each unit is observed at most once) and no g column (no group structure -- the "treated" group is just the d == 1 cohort and the "control" group is the d == 0 cohort). The model fits 4 g-functions g(d, t, x) = E[Y | D=d, T=t, X] for the 4 (d, t) combinations, plus 1 propensity function m(x) = E[D=1 | X], and constructs the ATT score function from these nuisance predictions.

    DoubleMLDIDCrossSectionData::n_features

    Number of features in the cross-section DID data (excluding the intercept, which is added inside the learner).

    DoubleMLDIDCrossSectionData::n_obs

    Sample size of the cross-section DID data.

    DoubleMLDIDCrossSectionData::new

    fn DoubleMLDIDCrossSectionData::new(x : Matrix, y : Array[Double], d : Array[Double], t : Array[Int], name? : String) -> DoubleMLDIDCrossSectionData

    DoubleMLDIDData

    pub struct DoubleMLDIDData {
    x : Matrix
    y : Array[Double]
    d : Array[Double]
    } derive(
    Debug
    )

    Data container for DoubleMLDID. Uses panel data with two time periods. The treatment d is the change in treatment status between the post-period and the pre-period (so d ∈ {-1, 0, 1} for never-treated/always-treated/switchers under the Sant'Anna & Zhao (2020) convention). The default port supports binary d ∈ {0, 1} (only the switchers).

    DoubleMLDIDData::n_features

    fn DoubleMLDIDData::n_features(self : DoubleMLDIDData) -> Int

    DoubleMLDIDData::n_obs

    fn DoubleMLDIDData::n_obs(self : DoubleMLDIDData) -> Int

    DoubleMLDIDData::new

    fn DoubleMLDIDData::new(x : Matrix, y : Array[Double], d : Array[Double]) -> DoubleMLDIDData raise DIDDataError

    Returns DoubleMLDIDData raise DIDDataError: the v0.43.0 conversion replaces the previous abort() call with raise DIDDataError::NonBinaryTreatment(i) so the non-binary-d path becomes directly testable. Callers that want the pre-v0.43.0 process-death behavior should catch the error and re-abort (this is what DoubleMLDIDBinaryData::new does); callers that want to surface the error to downstream consumers should propagate via ?. The require checks on x.nrows against y.length and d.length are unchanged and still abort the process (they are central require checks; refactoring them is out of scope for this surgical release).

    DoubleMLDIDMulti

    pub struct DoubleMLDIDMulti {
    data : DoubleMLDIDCSData
    gt_combinations : Array[(Int, Int, Int)]
    control_group : String
    anticipation_periods : Int
    n_folds : Int
    n_rep : Int
    seed : Int
    ps_processor : PSProcessor
    in_sample_normalization : Bool
    ml_g : LearnerDispatch
    ml_m : LearnerDispatch
    group_sizes : Array[Int]
    inner : DoubleMLDIDCS
    boot_t_stat : Array[Double]
    boot_method : String
    n_rep_boot : Int
    boot_seed : Int
    fitted : Bool
    memoize_enabled : Bool
    } derive(
    Debug
    )

    DoubleMLDIDMulti is a thin top-level wrapper over DoubleMLDIDCS that adds:

    1. A gt_combinations selector. Each combination is a (g_value, t_value_pre, t_value_eval) triple. The convenience keyword "standard" expands to "every post-treatment (g, t) with t_pre = g" (the default Callaway-Sant'Anna staggered set). "all" additionally includes pre-treatment cells for the event-study profile. "universal" is the same as "all" for the panel case (a cross-section-only setting in upstream is not ported; see the "Notes" section in CHANGELOG.md).
    2. aggregate_group, aggregate_time, aggregate_event methods that wrap the matching helpers in did_aggregation.mbt.

    DoubleMLDIDMulti::fit reuses DoubleMLDIDCS::fit to compute the per-(g, t) ATT matrix (and SE matrix), then hands the matrices to the aggregation helpers. The internal DoubleMLDIDCS instance is the canonical per-(g, t) estimator; DoubleMLDIDMulti adds the gt_combinations filter and the aggregation API on top.

    DoubleMLDIDMulti::aggregate_event

    Aggregate by event time e = t - g. Returns a DIDAggregationResult with one entry per unique event time.

    DoubleMLDIDMulti::aggregate_group

    Aggregate by group. Returns a DIDAggregationResult with one entry per group.

    DoubleMLDIDMulti::aggregate_time

    Aggregate by time period. Returns a DIDAggregationResult with one entry per period.

    DoubleMLDIDMulti::bias_corrected_coef_at_idx

    fn DoubleMLDIDMulti::bias_corrected_coef_at_idx(self : DoubleMLDIDMulti, idx : Int) -> Double

    v0.100.0+: coef_at_idx(idx) UNCHANGED -- a documented no-op, not a bias correction. Identical in reasoning to DoubleMLDIDCS::bias_corrected_coef_at and the rest of the family; see DoubleMLPQ::bias_corrected_coef in sandwich.mbt for the full argument. The child's ATT is the root of the DML moment f(theta) = E[theta * psi_a + psi_b], so mean(f(coef_at)) is identically zero: the estimating function is orthogonal by construction, and that orthogonality is exactly what makes the estimator consistent. Nothing computable from the fitted scores is a bias estimate for this class of estimator, so this accessor reports the uncorrected point estimate rather than a number that merely looks like a correction. The pre-v0.91.0 form coef + mean(psi_b - coef * psi_a) is that vector at -coef, NOT at coef; with coef = -mean_b / mean_a its mean is -2 * coef * mean(psi_a), so it returned coef * (1 - 2 * mean(psi_a)) -- exactly 3 * coef when mean(psi_a) = -1, which is this score's case for every cell.

    A genuine difference from sandwich_se_at_idx: a DEGENERATE combination does NOT abort here. The answer is the identity on coef_at_idx, and coef_at_idx reports 0.0 for an unestimated cell, so 0.0 is the consistent answer rather than a misleading one. 0.0 is also why this accessor is safe to call across a full sweep while the sandwich pair is not.

    Preconditions: self.fitted, 0 <= idx < gt_combinations.length(), and gt_combinations[idx] maps to an inner (gi, pi).

    DoubleMLDIDMulti::bootstrap

    fn DoubleMLDIDMulti::bootstrap(self : DoubleMLDIDMulti, method_name? : String, n_rep_boot? : Int, seed? : Int) -> DoubleMLDIDMulti

    v0.15.0+: multiplier bootstrap for joint confidence intervals. Draws n_rep_boot weight vectors from the chosen multiplier distribution and computes boot_t_stat[b, k] = sum_i w[b, i] * psi_k[i] / (sqrt(n) *se_k) for each bootstrap replication b and each (g, t) cell k. The joint confidence interval uses the empirical 95th percentile of max_k |boot_t_stat[b, k]| as the critical value; the per-cell Wald CI uses 1.96.

    method_name selects the multiplier distribution:
    • "normal": w[i] ~ N(0, 1) (default; matches the upstream bootstrap(method="normal") default).
    • "Bayes": w[i] = exp(1) - 1 (mean 0, var 1).
    • "wild": w[i] = x[i] / sqrt(2) + (y[i]^2 - 1) / 2 with x, y ~ N(0, 1). Robust to heteroskedasticity.

    seed controls the chacha8 RNG used to draw the weights (default 2024; matches the upstream numpy default of np.random.seed(2024) for the first test in _verify/test_bootstrap_reference.py).

    The bootstrap populates self.boot_t_stat and is required for confint(joint=true).

    DoubleMLDIDMulti::clear_cache

    v0.82.0+: drop any cached nuisance predictions. No-op on an un-fit wrapper.

    DoubleMLDIDMulti::cluster_sandwich_se_at_idx

    fn DoubleMLDIDMulti::cluster_sandwich_se_at_idx(self : DoubleMLDIDMulti, idx : Int, cluster_ids : Array[Int]) -> Double

    v0.100.0+: cluster-robust sandwich standard error (Arellano 1987, Cameron-Gelbach-Miller 2011) for the combination at row-major index idx. Same pure forward as sandwich_se_at_idx: the inner cell is resolved and self.inner.cluster_sandwich_se_at(gi, pi, cluster_ids) is returned unchanged.

    DoubleMLDIDCSData has no cluster_vars field, so there is still no default cluster and the caller must supply cluster_ids. They are PER CELL ROW, not per long-format panel row: cluster_ids.length() must equal THAT cell's n_wide, i.e. one entry per unit observed in both the pre and the eval period. The long-format panel id (length n_units * n_periods) is the WRONG array to pass for a cell whose n_wide is smaller, and because the mapping is per index, an idx-independent array of the wrong length fails at whichever index it is passed to. For a DID the natural grouping is the panel unit.

    Why this is not se_at_idx with a different flavour: the difference is the MEAT, not the denominator. cluster_sandwich_variance divides by the cell's own n_wide -- the same divisor HC0 uses -- but sums S_c = (sum_{i in c}psi[i])^2 per cluster, so the within-cluster cross terms enter and each cluster carries a jackknife factor n_c / (n_c - 1). With all-singleton clusters the meat collapses to sum_ipsi[i]^2 and the result is exactly sandwich_se_at_idx(idx, HC0); with real clusters it is larger. (It is also not a drop-in for a cluster-robust se_at: that is a different, row-level estimator which this DIDCS fit path does not compute.)

    Aborts at a degenerate combination for the same reason sandwich_se_at_idx does -- the inner refuses an unestimated cell, and the wrapper forwards that rather than papering over it.

    Preconditions: self.fitted, 0 <= idx < gt_combinations.length(), the combination maps to an estimated inner cell with mean(psi_a) != 0, cluster_ids.length() == n_wide, all cluster_ids[i] >= 0.

    DoubleMLDIDMulti::coef_at_idx

    fn DoubleMLDIDMulti::coef_at_idx(self : DoubleMLDIDMulti, idx : Int) -> Double

    The inner per-(g, t) ATT at row-major index idx (matching the canonical gt_combinations[i] ordering).

    DoubleMLDIDMulti::confint

    fn DoubleMLDIDMulti::confint(self : DoubleMLDIDMulti, joint? : Bool, level? : Double) -> Array[(Double, Double)]

    v0.15.0+: confidence interval for the per-(g, t) ATT. When joint = false (default), uses the Wald-style theta +/- 1.96 * se interval. When joint = true, uses the bootstrap: theta +/- critical_value * se where critical_value is the 95th percentile of max_k |boot_t_stat[b, k]| across bootstrap replications b. Joint CIs are wider (more conservative) and require bootstrap() to be called first.

    level is the confidence level (default 0.95).

    DoubleMLDIDMulti::disable_memoize

    fn DoubleMLDIDMulti::disable_memoize(self : DoubleMLDIDMulti) -> DoubleMLDIDMulti

    v0.82.0+: turn off memoization.

    DoubleMLDIDMulti::enable_memoize

    fn DoubleMLDIDMulti::enable_memoize(self : DoubleMLDIDMulti) -> DoubleMLDIDMulti

    v0.82.0+: turn on memoization for subsequent fit() calls. The flag is forwarded to the inner DoubleMLDIDCS.

    DoubleMLDIDMulti::fit

    Run the per-(g, t) cross-fits and store the per-cell ATT matrix for downstream aggregation. The actual per-cell fitting is delegated to DoubleMLDIDCS::fit, which already iterates over every (g, t) cell.

    DoubleMLDIDMulti::has_cache

    fn DoubleMLDIDMulti::has_cache(self : DoubleMLDIDMulti) -> Bool

    v0.82.0+: true iff the memoize flag is set.

    DoubleMLDIDMulti::learner_g

    Accessor for the outcome-nuisance learner forwarded to the inner DoubleMLDIDCS on the most recent fit(...) call. v0.60.0+.

    DoubleMLDIDMulti::learner_m

    Accessor for the propensity-score learner forwarded to the inner DoubleMLDIDCS on the most recent fit(...) call. v0.60.0+.

    DoubleMLDIDMulti::n_combinations

    fn DoubleMLDIDMulti::n_combinations(self : DoubleMLDIDMulti) -> Int

    Number of (g, t_pre, t_eval) triples.

    DoubleMLDIDMulti::new

    fn DoubleMLDIDMulti::new(data : DoubleMLDIDCSData, gt_combinations? : Array[(Int, Int, Int)], gt_combinations_keyword? : String, control_group? : String, anticipation_periods? : Int, n_folds? : Int, n_rep? : Int, seed? : Int, ps_processor? : PSProcessor, in_sample_normalization? : Bool, ml_g? : LearnerDispatch, ml_m? : LearnerDispatch) -> DoubleMLDIDMulti

    Construct a DoubleMLDIDMulti.

    gt_combinations may be either an Array[(Int, Int, Int)] of explicit (g_value, t_value_pre, t_value_eval) triples, or one of the keywords:

    • "standard": every (g, t) with t > g and t_pre = g (the default Callaway-Sant'Anna staggered set).
    • "all": every (g, t) in the (groups x periods) grid.
    • "universal": same as "all" for the panel case (upstream's universal is only meaningful for repeated cross sections, which this port does not implement).

    DoubleMLDIDMulti::p_adjust

    fn DoubleMLDIDMulti::p_adjust(self : DoubleMLDIDMulti, method_name? : String) -> Array[Double]

    v0.16.0+: multiple-testing p-value adjustment for the per-(g, t) ATTs. Returns an Array[Double] of adjusted p-values (length n_combinations).

    Methods:
    • "romano-wolf" (default): the stepdown bootstrap procedure from Romano & Wolf (2005). For each cell k, sorted by descending |t_k|, compute p_k = mean_b [max_j > k |boot_t_stat[b, j]| >=|t_k|]. Then enforce monotonicity: p_corrected[k] = max(p_k, p_corrected[k - 1]) (in sorted order). Requires bootstrap() to have been called first.
    • "holm": Holm-Bonferroni stepdown (no bootstrap required). Sort unadjusted p-values ascending; for each k, p_corrected[k] = max((n - k) * p_sorted[k],p_corrected[k - 1]), then re-sort to original order.
    • "bonferroni": p_corrected[k] = n * p_k, clipped to 1.0. No bootstrap required.

    The p-values are computed from the Wald-style t-statistics (theta / se per cell) via the two-sided normal approximation.

    DoubleMLDIDMulti::p_values

    fn DoubleMLDIDMulti::p_values(self : DoubleMLDIDMulti) -> Array[Double]

    v0.16.0+: per-cell unadjusted p-values for H0:theta = 0 (two-sided, normal approximation). Length n_combinations. pval[k] = 2 * (1 - norm.cdf(|t_k|)). Uses the standard-normal survival function on the Wald-style t-statistics from t_stats().

    The implementation uses Abramowitz & Stegun (1964) formula 7.1.26 for the normal CDF (max absolute error ~7.5e-8). MoonBit's @math does not expose erfc, so we approximate norm.cdf directly.

    DoubleMLDIDMulti::sandwich_se_at_idx

    fn DoubleMLDIDMulti::sandwich_se_at_idx(self : DoubleMLDIDMulti, idx : Int, kind : SandwichKind) -> Double

    v0.100.0+: heteroskedasticity-consistent (Huber-White) sandwich standard error for the combination at row-major index idx (the same (g, t_pre, t_eval) triple ordering as coef_at_idx and se_at_idx). The per-cell analogue of the DID family's joint sandwich_se: this wrapper estimates no scalar of its own, so the natural index is the one it already exposes, the combination index.

    DoubleMLDIDMulti persists NO per-cell score state -- only group_sizes and boot_t_stat -- so this method is a pure FORWARD. The triple is mapped to the inner (gi, pi) and the answer is self.inner.sandwich_se_at(gi, pi, kind) returned unchanged, with no arithmetic in between. Hence

    sandwich_se_at_idx(idx, kind) == inner.sandwich_se_at(gi, pi, kind)

    bit for bit, and on the IID path

    sandwich_se_at_idx(idx, HC0) == se_at_idx(idx)

    holds for the reason it holds one level down: the inner's HC0 was MEASURED equal to its own se_at on every non-degenerate cell (abs_diff exactly 0.0), because var_est and sandwich_variance_hc0 run the same op sequence over the same Kahan sum of the same products in the same index order and M_inv[0, 0]^2 == 1.0 exactly (the DID score's psi_a[i] = -d[i] / p_hat with p_hat = mean(d) gives mean(psi_a) == -1). The wrapper adds no n_params and no second estimate, so nothing about that identity is disturbed here.

    The one thing the wrapper does add is COVERAGE. gt_combinations may name a combination the inner never estimated: "all" and "universal" emit the pre-treatment placebos (g, t_pre = g, t_eval < g), and the CS-DID fit only estimates t_eval > g, so those map to an EMPTY inner cell. There this method ABORTS, by forwarding the inner's refusal: the inner rejects an unestimated cell rather than return a misleading 0.0 off an empty score row. That is not smoothed over. A caller sweeping every index must skip the degenerate combinations itself, exactly as it must for the inner API -- and se_at_idx at the same index returns 0.0 rather than aborting, so the two accessors are NOT interchangeable over a full sweep.

    Preconditions: self.fitted, 0 <= idx < gt_combinations.length(), gt_combinations[idx] maps to an inner (gi, pi), that cell was actually estimated, mean(psi_a) != 0, and n_obs > n_params for HC1 / HC2 / HC3.

    DoubleMLDIDMulti::se_at_idx

    fn DoubleMLDIDMulti::se_at_idx(self : DoubleMLDIDMulti, idx : Int) -> Double

    The inner per-(g, t) SE at row-major index idx.

    DoubleMLDIDMulti::sensitivity_analysis

    fn DoubleMLDIDMulti::sensitivity_analysis(self : DoubleMLDIDMulti, cf_y? : Double, cf_d? : Double) -> Array[SensitivityResult] raise

    v0.70.0+: per-(g, t) Cinelli & Hazlett (2020) omitted-variable bias analysis for DoubleMLDIDMulti. Delegates to DoubleMLDIDCS::sensitivity_analysis for the full n_groups * n_periods per-cell SensitivityResult array, then filters down to the cells selected by gt_combinations (i.e. the same cells used by coef_at_idx / se_at_idx).

    Each selected (g, t) cell's result carries the ATT-form residual y - g_d0_hat - (g_d1_hat - g_d0_hat) *d on the long-format panel (persisted by DoubleMLDIDCS::fit in psi_a_matrix / residuals_matrix) and the matching per-cell Riesz representer. Routes through the shared irm_style_sensitivity helper (see sensitivity.mbt).

    Cells with no fitted estimate (se_matrix[k] <= 0) return a zeroed SensitivityResult from the inner pass; the filtering preserves that contract.

    Returns an Array[SensitivityResult] of length gt_combinations.length(). Calling on an un-fit model aborts via PreconditionError.

    DoubleMLDIDMulti::sensitivity_analysis_cluster

    fn DoubleMLDIDMulti::sensitivity_analysis_cluster(self : DoubleMLDIDMulti, cluster_ids : Array[Int], cf_y? : Double, cf_d? : Double) -> Array[SensitivityResult] raise

    v0.73.0+: cluster-robust analogue of DoubleMLDIDMulti::sensitivity_analysis. Delegates to the inner DoubleMLDIDCS::sensitivity_analysis_cluster (which carries the per-cell ATT-form residual and per-cell psi_a from psi_a_matrix / residuals_matrix) and then filters down to the gt_combinations selected cells (same lookup as coef_at_idx / se_at_idx).

    DoubleMLDIDMulti's data struct (DoubleMLDIDCSData) has no cluster_vars field, so the user must pass cluster_ids explicitly. cluster_ids.length() must equal self.data.n_obs() (the long-format panel length).

    Cells with no fitted estimate (inner.se_matrix[k] <= 0) return a zeroed SensitivityResult from the inner pass; the filtering preserves that contract.

    Returns an Array[SensitivityResult] of length gt_combinations.length().

    DoubleMLDIDMulti::t_stats

    fn DoubleMLDIDMulti::t_stats(self : DoubleMLDIDMulti) -> Array[Double]

    v0.16.0+: per-cell t-statistics theta / se (length n_combinations). The Wald-style t-statistic matches the upstream all_t_stats[:, i_rep] (per repetition, but v0.15.0 is n_rep = 1 only). The Romano-Wolf stepdown p-adjustment in p_adjust consumes these t-statistics. Returns 0.0 for cells where se_k = 0 (pre-treatment / missing cells).

    DoubleMLDIDMulti::tune

    fn DoubleMLDIDMulti::tune(self : DoubleMLDIDMulti, param_set~ : Array[TuneParam], scoring_method? : String, n_folds_tune? : Int, seed? : Int) -> DoubleMLDIDMulti

    DoubleMLDIDMulti::tune - score a grid of (learner_g,learner_m) pairs and re-fit the per-cell DID model with the winner.

    Slot semantics: propensity-first family, same as DoubleMLDIDCrossSection::tune and the other DID* estimators. The first TuneParam slot is the treatment / propensity learner, the second is the outcome learner, and the first field is still named learner_l because the field names are fixed by the partially-linear family. Use TuneParam::for_treatment(learner_g, learner_m) so the call site states the slot's role.

    v0.108.0: new. DIDMulti had no tune at all, so it was one of the estimators carrying a coverage gap against upstream, where tune reaches every estimator through the BaseDML mixin. It routes through the shared tune_score_grid core in tune.mbt, same as every other ::tune.

    What tune forwards, and what it does NOT touch:

    • Forwarded: ml_g / ml_m from the winning TuneParam, i.e. the first two arguments of fit.
    • Carried over from self implicitly: everything else, because DoubleMLDIDMulti::fit takes ONLY ml_g and ml_m and rebuilds the model with { ..self, inner,ml_g, ml_m, fitted: true }. In particular the per-(g,t) cell grid survives untouched: gt_combinations (and its gt_combinations_keyword spelling) is a DoubleMLDIDMulti::new argument, NOT a fit argument, so there is no cell list for tune to plumb through and no way for it to silently narrow the cell set. The fold schedule, n_rep / seed and the memoize flag likewise never appear in the tune -> fit call: the memoize forwarding and the two-stage inner refit (pre- and post-memoize) are fit's own business.
    • Dropped: nothing. The only state the tune stage produces is the fold draw and the candidate scores, and those are discarded by design - the winner is re-cross-fitted from scratch under the final-fit fold schedule rather than reusing the tune-time predictions.

    Same scoring caveat as the sibling: the score is MSE of the first-slot learner cross-fitted against the outcome y, not against d, so it is a shortlisting proxy rather than a per-cell nuisance optimum.

    No cluster guard, deliberately: DoubleMLDIDCSData has no cluster_vars field and no is_cluster_data() method, so there is nothing to guard and the call would not compile. The data type's id field is a panel unit id used for aggregation, not a cluster-var accessor.

    DoubleMLData

    pub struct DoubleMLData {
    x : Matrix
    y : Array[Double]
    d : Array[Double]
    cluster_vars : Array[Int]
    z : Array[Double]
    } derive(
    Debug
    )

    Container for the data consumed by a DML model. Mirrors the subset of doubleml.DoubleMLData that the PLR model needs: a feature matrix x, an outcome vector y, and a (possibly vector) treatment vector d. Multi-column treatment is currently treated as a single column vector in the port (the public DoubleMLPLR API takes a single treatment, matching the generate_data_simple example).

    When cluster_vars is non-empty, the model routes through the clustered DML path: folds are drawn over the unique unit ids in cluster_vars (not over individual rows), the causal parameter is the fold-weighted ratio of cluster score sums, and the variance is the unit-level cluster-robust estimator (_var_est's one-cluster-variable branch). Each row must carry exactly one cluster id; the length of cluster_vars is n_obs. This mirrors the upstream DoubleMLData(cluster_cols=) API in 0.11.x and is the path DoubleMLClusterData (the pre-0.11 backward-compat shim) used to take.

    DoubleMLData::is_cluster_data

    fn DoubleMLData::is_cluster_data(self : DoubleMLData) -> Bool

    True iff the data is set up for clustered inference (a non-empty cluster_vars vector was passed to new).

    DoubleMLData::is_instrument_data

    fn DoubleMLData::is_instrument_data(self : DoubleMLData) -> Bool

    True iff the data is set up for IV-type DML (a non-empty z vector was passed to new). The IV-type score on DoubleMLPLR::fit(score="IV-type") requires this; calling fit with score="IV-type" and no instrument aborts.

    DoubleMLData::n_cluster_vars

    fn DoubleMLData::n_cluster_vars(self : DoubleMLData) -> Int

    Length of the cluster_vars vector (0 when not clustered).

    DoubleMLData::n_features

    fn DoubleMLData::n_features(self : DoubleMLData) -> Int

    Number of features (columns of X).

    DoubleMLData::n_obs

    fn DoubleMLData::n_obs(self : DoubleMLData) -> Int

    Number of observations in the data.

    DoubleMLData::new

    fn DoubleMLData::new(x : Matrix, y : Array[Double], d : Array[Double], cluster_vars? : Array[Int], z? : Array[Double]) -> DoubleMLData

    Build a DoubleMLData from an n x p feature matrix, an outcome vector of length n and a treatment vector of length n. Pass a non-empty cluster_vars to enable the clustered DML path; pass [] (the default) for the standard row-level path.

    DoubleMLData::z

    fn DoubleMLData::z(self : DoubleMLData) -> Array[Double]

    v0.55.0: instrument vector Z (length n_obs) for the IV-type DML score on DoubleMLPLR. Returns an empty array when no instrument was passed to new.

    DoubleMLIIVM

    pub struct DoubleMLIIVM {
    data : DoubleMLIIVMData
    n_folds : Int
    n_rep : Int
    seed : Int
    smpls : Array[Array[Fold]]
    propensity_clip : Double
    ps_processor_config : PSProcessorConfig
    ml_g : LearnerDispatch
    ml_m : LearnerDispatch
    ml_r : LearnerDispatch
    g0_hat : Array[Double]
    g1_hat : Array[Double]
    m_hat : Array[Double]
    r0_hat : Array[Double]
    r1_hat : Array[Double]
    coef : Double
    se : Double
    fitted : Bool
    psi_a : Array[Double]
    psi_b : Array[Double]
    boot_t_stat : Array[Double]
    boot_method : String
    n_rep_boot : Int
    boot_seed : Int
    memoize_enabled : Bool
    fit_cache : FitCache
    } derive(
    Debug
    )

    Double / debiased machine learning estimator for the interactive IV regression model (IIVM) of Chernozhukov et al. (2018) with the LATE score, identifying the Local Average Treatment Effect on the "compliers":

    Y = theta * D + g_0(D, X) + U, E[U | D, X] = 0 D = m_0(X, Z) + V, E[V | X, Z] = 0

    where the binary instrument Z satisfies the relevance and exclusion restrictions. Five cross-fitted nuisance functions are needed (each estimated out-of-fold via K-fold):

    g0(X) = E[Y | Z = 0, X] (trained only on Z = 0) g1(X) = E[Y | Z = 1, X] (trained only on Z = 1) m(X) = E[Z | X] (trained on all obs, then clipped to [eps, 1 - eps]) r0(X) = E[D | Z = 0, X] (trained only on Z = 0) r1(X) = E[D | Z = 1, X] (trained only on Z = 1)

    Residuals:

    u_hat0 = Y - g0, u_hat1 = Y - g1 w_hat0 = D - r0, w_hat1 = D - r1

    LATE score:

    psi_b = (g1 - g0) + Z u_hat1 / m - (1 - Z) u_hat0 / (1 - m) psi_a = -(r1 - r0) - Z w_hat1 / m + (1 - Z) w_hat0 / (1 - m) psi(theta) = theta * psi_a + psi_b

    Point estimate and variance (same _var_est formula as the other DML models):

    theta_hat = -mean(psi_b) / mean(psi_a) J = mean(psi_a) gamma = mean(psi(theta_hat)^2) sigma2 = gamma / (J^2 * n) se = sqrt(sigma2).

    DoubleMLIIVM::bias_corrected_coef

    fn DoubleMLIIVM::bias_corrected_coef(self : DoubleMLIIVM) -> Double

    v0.91.0+: returns coef UNCHANGED -- a documented no-op, not a bias correction.

    coef is the root of the DML moment f(theta) = E[theta * psi_a + psi_b] (see var_est.mbt), so mean(f(coef)) is identically zero: the estimating function is orthogonal by construction, and that orthogonality IS what makes the estimator consistent. Nothing computable from the fitted scores is a bias estimate for this class of estimator, so this accessor reports the uncorrected point estimate rather than a number that merely looks like a correction.

    v0.79.0 - v0.90.0 returned coef + mean(psi_b - coef * psi_a). That vector is the score at -coef, NOT at coef; since coef = -mean_b / mean_a its mean is mean_b - coef * mean_a = -2 * coef * mean_a, so the accessor returned coef * (1 - 2 * mean(psi_a)) (exactly 3 * coef when mean(psi_a) = -1). That is not a bias estimate. See bias_corrected_theta in sandwich.mbt for the algebra. The method is kept so the API surface stays stable; removing it outright is the obvious follow-up.

    Preconditions: self.fitted.

    DoubleMLIIVM::bootstrap

    fn DoubleMLIIVM::bootstrap(self : DoubleMLIIVM, method_name? : String, n_rep_boot? : Int, seed? : Int) -> DoubleMLIIVM

    v0.61.0+: multiplier bootstrap for DoubleMLIIVM. The per-observation influence function is

    psi[i] = theta * psi_a[i] + psi_b[i]

    where psi_a and psi_b are the LATE score elements (defined in the module doc) computed at the fitted coef from the last rep's cross-fitted nuisances g0_hat / g1_hat / m_hat / r0_hat / r1_hat. Draws n_rep_boot weight vectors of length n_obs from the chosen multiplier distribution, and returns a fitted model with boot_t_stat[b] = sum_i w[b, i] * psi[i] /(sqrt(n) * se_psi) populated where se_psi = sqrt(mean(psi^2)).

    method_name selects the multiplier distribution:
    • "normal" (default): w[i] ~ N(0, 1).
    • "Bayes": w[i] = exp(1) - 1 (mean 0, var 1).
    • "wild": w[i] = x[i] / sqrt(2) + (y[i]^2 - 1) / 2 with x, y ~ N(0, 1).

    Calling bootstrap requires the model to be fitted; calling on an un-fit model aborts with PreconditionError. The helper is did_bootstrap_t_stat (v0.55.0 extracted from DoubleMLDIDCrossSection::bootstrap); IIVM is the n_thetas=1 case.

    DoubleMLIIVM::clear_cache

    fn DoubleMLIIVM::clear_cache(self : DoubleMLIIVM) -> DoubleMLIIVM

    v0.82.0+: drop any cached nuisance predictions and fold assignment.

    DoubleMLIIVM::cluster_sandwich_se

    fn DoubleMLIIVM::cluster_sandwich_se(self : DoubleMLIIVM, cluster_ids : Array[Int]) -> Double

    v0.86.0+: cluster-robust sandwich standard error for the fitted IIVM. Routes through cluster_sandwich_variance with the same psi_a / psi / M_inv inputs as the IID sandwich_se path.

    Preconditions: self.fitted, cluster_ids.length() == n_obs.

    DoubleMLIIVM::coef

    fn DoubleMLIIVM::coef(self : DoubleMLIIVM) -> Double

    DoubleMLIIVM::confint

    fn DoubleMLIIVM::confint(self : DoubleMLIIVM, joint? : Bool, level? : Double) -> (Double, Double)

    v0.67.0+: joint is a no-op for single-theta estimators; accepted for API parity.

    DoubleMLIIVM::disable_memoize

    fn DoubleMLIIVM::disable_memoize(self : DoubleMLIIVM) -> DoubleMLIIVM

    v0.82.0+: turn off memoization. Same immutability contract as enable_memoize(). After this, fit() will not read or write the cache; the existing fit_cache is preserved on the returned struct (call clear_cache() to drop it).

    DoubleMLIIVM::enable_memoize

    fn DoubleMLIIVM::enable_memoize(self : DoubleMLIIVM) -> DoubleMLIIVM

    v0.82.0+: turn on memoization for subsequent fit() calls. When enabled, fit() will cache the per-observation nuisance predictions (g0_hat, g1_hat, m_hat, r0_hat, r1_hat) and the fold partition and skip the cross-fit on a repeat call whose data + learner fingerprint is unchanged. Returns a new DoubleMLIIVM with the flag set (the struct is immutable; the cache itself is populated by the next fit() call).

    DoubleMLIIVM::fit

    fn DoubleMLIIVM::fit(self : DoubleMLIIVM, ml_g? : LearnerDispatch, ml_m? : LearnerDispatch, ml_r? : LearnerDispatch, max_attempts? : Int) -> DoubleMLIIVM

    Run the IIVM estimation.

    Per-repetition behaviour: each repetition r cross-fits the g0 / g1 / m / r0 / r1 nuisances from its own folds (seed self.seed + r), computes its own (theta_r, se_r) from the LATE score, and the two arrays are then aggregated by aggregate_coef_se (median of thetas, then SE from the median of (theta_r + 1.96 * se_r)). For n_rep == 1 the aggregator returns the single (theta_1, se_1) exactly, so the byte-equality with the previous "average then estimate" implementation is preserved. The predictions_g0 / g1 / m / r0 / r1 accessors return the nuisances from the last repetition (the conventional choice in upstream doubleml), not a cross-rep average.

    DoubleMLIIVM::has_cache

    fn DoubleMLIIVM::has_cache(self : DoubleMLIIVM) -> Bool

    v0.82.0+: true iff fit_cache holds at least one cached observation.

    DoubleMLIIVM::learner_g

    fn DoubleMLIIVM::learner_g(self : DoubleMLIIVM) -> LearnerDispatch

    v0.153.0: the outcome learner (ml_g) recorded at construction and used by fit when no explicit argument is given. DoubleMLIIVM stored all three slots since v0.60.0 but, unlike ten sibling estimators, never exposed them.

    DoubleMLIIVM::learner_m

    fn DoubleMLIIVM::learner_m(self : DoubleMLIIVM) -> LearnerDispatch

    v0.153.0: the treatment-propensity learner (ml_m) recorded at construction. See learner_g.

    DoubleMLIIVM::learner_r

    fn DoubleMLIIVM::learner_r(self : DoubleMLIIVM) -> LearnerDispatch

    v0.153.0: the selection-propensity learner (ml_r) recorded at construction. DoubleMLIIVM is the only estimator in the package with an ml_r slot; v0.137.0 classified this score as carrying a reciprocal in both directions, which the measurement then refuted -- it measured harmless.

    DoubleMLIIVM::n_obs

    fn DoubleMLIIVM::n_obs(self : DoubleMLIIVM) -> Int

    DoubleMLIIVM::new

    fn DoubleMLIIVM::new(data : DoubleMLIIVMData, n_folds? : Int, n_rep? : Int, seed? : Int, propensity_clip? : Double, ps_processor_config? : PSProcessorConfig?, ml_g? : LearnerDispatch, ml_m? : LearnerDispatch, ml_r? : LearnerDispatch) -> DoubleMLIIVM

    DoubleMLIIVM::predictions_g0

    fn DoubleMLIIVM::predictions_g0(self : DoubleMLIIVM) -> Array[Double]

    DoubleMLIIVM::predictions_g1

    fn DoubleMLIIVM::predictions_g1(self : DoubleMLIIVM) -> Array[Double]

    DoubleMLIIVM::predictions_m

    fn DoubleMLIIVM::predictions_m(self : DoubleMLIIVM) -> Array[Double]

    DoubleMLIIVM::predictions_r0

    fn DoubleMLIIVM::predictions_r0(self : DoubleMLIIVM) -> Array[Double]

    DoubleMLIIVM::predictions_r1

    fn DoubleMLIIVM::predictions_r1(self : DoubleMLIIVM) -> Array[Double]

    DoubleMLIIVM::sample_splitting

    fn DoubleMLIIVM::sample_splitting(self : DoubleMLIIVM) -> SampleSplitting?

    The sample splitting recorded by set_sample_splitting, or None when none was supplied and fit draws its own. Some carries the DERIVED counts next to the folds, so a caller can read back what the estimator actually used rather than inferring it from the constructor arguments it no longer has any say over.

    DoubleMLIIVM::sandwich_se

    fn DoubleMLIIVM::sandwich_se(self : DoubleMLIIVM, kind : SandwichKind) -> Double

    v0.86.0+: Huber-White sandwich standard error for the fitted IIVM. Returns sqrt(var) where var comes from the shared sandwich_variance(kind, ...) dispatch in sandwich.mbt (HC0 / HC1 / HC2 / HC3).

    The three inputs are the ones fit(...) already persists for the multiplier bootstrap (see the psi_a / psi_b field docs on DoubleMLIIVM):
    • psi_a = self.psi_a -- the IIVM LATE residualized-treatment score (-(r1 - r0) - Z * w1 / m + (1 - Z) * w0 / (1 - m)).
    • psi = psi_at(self.coef, self.psi_a, self.psi_b) -- the per-observation influence function evaluated at the fitted coef, where psi_b is the residualized-outcome score ((g1 - g0) + Z * u1 / m - (1 - Z) * u0 / (1 - m)). Same psi_at score order as IRM / PLR.
    • M_inv = [[1 / mean(psi_a)]] (1x1). Only M_inv[0, 0]^2 enters the variance, so the sign of the (up-to-sign) Jacobian inverse is irrelevant.

    Preconditions: self.fitted, mean(psi_a) != 0.

    DoubleMLIIVM::se

    fn DoubleMLIIVM::se(self : DoubleMLIIVM) -> Double

    DoubleMLIIVM::sensitivity_analysis

    fn DoubleMLIIVM::sensitivity_analysis(self : DoubleMLIIVM, cf_y? : Double, cf_d? : Double) -> SensitivityResult raise

    v0.66.0+: Cinelli & Hazlett (2020) omitted-variable bias analysis. Outcome residual is y - g0_hat - (g1_hat - g0_hat) * d; the Riesz-representer variance is mean(psi_a^2) where psi_a = -(r1 - r0) - Z * w1/m + (1-Z) * w0/(1-m) for the LATE score. Routes through the shared irm_style_sensitivity helper.

    DoubleMLIIVM::sensitivity_analysis_cluster

    fn DoubleMLIIVM::sensitivity_analysis_cluster(self : DoubleMLIIVM, cluster_ids? : Array[Int], cf_y? : Double, cf_d? : Double) -> SensitivityResult raise

    v0.72.0+: cluster-robust analogue of DoubleMLIIVM::sensitivity_analysis. Same DID-style residual form (y - g0 - (g1 - g0) * d) and the same psi_a = -(r1 - r0) - Z * w1/m + (1 - Z) * w0/(1 - m) (LATE score) as the IID path; only the variance / bias computation is cluster-aware. cluster_ids defaults to DoubleMLIIVMData::cluster_vars.

    DoubleMLIIVM::set_sample_splitting

    fn DoubleMLIIVM::set_sample_splitting(self : DoubleMLIIVM, smpls : Array[Array[Fold]]) -> DoubleMLIIVM

    Record a caller-supplied sample splitting and DERIVE the fold and repetition counts from it. Returns a new DoubleMLIIVM; the receiver is unchanged, as everywhere in this package.

    smpls is one entry per repetition, each entry one Fold per fold -- the nested form is the only form, so a single repetition is [f0, f1, f2] wrapped once, i.e. [[f0, f1, f2]].

    The derivation is upstream's, and it is the part that surprises people: set_sample_splitting (doubleml/double_ml_sampling_mixins.py:76) sets n_folds and n_rep FROM the supplied partition, so handing a two-fold split to a model built with n_folds=5 yields a two-fold model. On the returned model n_folds / n_rep are updated to match, and fit uses the supplied folds instead of drawing them.

    Validation, via check_sample_splitting: every repetition carries the same number of folds, and within each repetition the test index sets are disjoint and cover exactly [0, n_obs). A supplied partition that is not a partition aborts rather than quietly producing a cross-fit that leaks across folds.

    Memoization: fit will not read or write the fit cache once an external splitting is recorded. The cache key is built from the data, learner and cluster hashes and has no term for the fold assignment, so two different supplied partitions would collide and the second fit would silently return the first one's predictions. The memoize_enabled flag is carried through unchanged; it is the !external term in fit that refuses the cache. The cache is also CLEARED here, so a re-fit under a new splitting cannot resurrect an entry written under the old one.

    Cluster path: an externally supplied splitting applies to the ROW-level path only. fit_cluster draws UNIT-level folds over the unique cluster ids and expands them to rows, so a supplied row-level partition is not consulted there and a clustered fit keeps drawing its own unit-level folds. Making the cluster path honour external splits is a separate decision and out of scope here; the field is carried across so the row-level path resumes the supplied splitting on a later non-cluster fit.

    The one-fold "no sample splitting" case needs no special handling: a single Fold whose train and test both cover everything is a valid partition with folds = 1, which is what upstream detects and reports.

    DoubleMLIIVM::tune

    fn DoubleMLIIVM::tune(self : DoubleMLIIVM, param_set~ : Array[TuneParam], scoring_method? : String, n_folds_tune? : Int, seed? : Int) -> DoubleMLIIVM

    v0.65.0+: tune the (ml_g, ml_m) nuisance-learner pair via MSE-on-g_hat cross-fitting (matches DoubleMLPLR::tune's MSE-on-l_hat scoring convention). ml_r (the instrument propensity) is held fixed at self.ml_r for both the tune stage and the re-fit. The chosen (learner_g, learner_m) is then re-fit on the FINAL-FIT fold partition (self.n_folds) under DoubleMLIIVM::fit. Cluster-DML is not supported in tune.

    param_set is an Array[TuneParam]; each entry is a (learner_g, learner_m) pair. scoring_method is "MSE" (default), "RMSE", or "NegMSE". Returns a re-fitted DoubleMLIIVM with the chosen pair applied.

    DoubleMLIIVMData

    pub struct DoubleMLIIVMData {
    x : Matrix
    y : Array[Double]
    d : Array[Double]
    z : Array[Double]
    cluster_vars : Array[Int]
    } derive(
    Debug
    )

    Data container for DoubleMLIIVM. Same shape as DoubleMLData but adds a single instrumental variable z. The treatment d and the instrument z are both binary.

    DoubleMLIIVMData::is_cluster_data

    fn DoubleMLIIVMData::is_cluster_data(self : DoubleMLIIVMData) -> Bool

    True iff the data is set up for clustered inference (a non-empty cluster_vars vector was passed to new).

    DoubleMLIIVMData::n_cluster_vars

    fn DoubleMLIIVMData::n_cluster_vars(self : DoubleMLIIVMData) -> Int

    Length of the cluster_vars vector (0 when not clustered).

    DoubleMLIIVMData::n_features

    fn DoubleMLIIVMData::n_features(self : DoubleMLIIVMData) -> Int

    DoubleMLIIVMData::n_obs

    fn DoubleMLIIVMData::n_obs(self : DoubleMLIIVMData) -> Int

    DoubleMLIIVMData::new

    fn DoubleMLIIVMData::new(x : Matrix, y : Array[Double], d : Array[Double], z : Array[Double], cluster_vars? : Array[Int]) -> DoubleMLIIVMData

    DoubleMLIRM

    pub struct DoubleMLIRM {
    data : DoubleMLData
    n_folds : Int
    n_rep : Int
    seed : Int
    smpls : Array[Array[Fold]]
    propensity_clip : Double
    ps_processor_config : PSProcessorConfig
    ml_g : LearnerDispatch
    ml_m : LearnerDispatch
    g0_hat : Array[Double]
    g1_hat : Array[Double]
    m_hat : Array[Double]
    m_raw : Array[Double]
    coef : Double
    se : Double
    fitted : Bool
    psi_a : Array[Double]
    psi_b : Array[Double]
    boot_t_stat : Array[Double]
    boot_method : String
    n_rep_boot : Int
    boot_seed : Int
    memoize_enabled : Bool
    fit_cache : FitCache
    } derive(
    Debug
    )

    Double / debiased machine learning estimator for the interactive regression model (IRM) of Chernozhukov et al. (2018) with the Average Treatment Effect (ATE) score:

    Y = g_0(D, X) + U, E[U | D, X] = 0 D = m_0(X) + V, E[V | X] = 0

    and the ATE orthogonal signal

    g0(X) = E[Y | D=0, X] g1(X) = E[Y | D=1, X] m(X) = P(D=1 | X) (the propensity score) u0 = Y - g0(X) u1 = Y - g1(X) psi_b = (g1 - g0) + (D u1 / m - (1 - D) u0 / (1 - m)) psi_a = -1 psi(theta) = theta * psi_a + psi_b

    with point estimate

    theta_hat = -mean(psi_b) / mean(psi_a) = mean(psi_b)

    and variance (same _var_est formula as DoubleMLPLR):

    J = mean(psi_a) = -1 gamma = mean(psi(theta_hat)^2) sigma2 = gamma / (J^2 * n) se = sqrt(sigma2)

    The cross-fitting scheme trains g0 only on observations with D = 0 and g1 only on observations with D = 1 (the test folds still cover the full observation set), matching doubleml.utils._estimation._get_cond_smpls in the upstream package. The propensity score is clipped to [propensity_clip, 1 - propensity_clip] before being used in the score, guarding against near-zero or near-one values.

    DoubleMLIRM::bias_corrected_coef

    fn DoubleMLIRM::bias_corrected_coef(self : DoubleMLIRM) -> Double

    v0.91.0+: returns coef UNCHANGED -- a documented no-op, not a bias correction.

    coef is the root of the DML moment f(theta) = E[theta * psi_a + psi_b] (see var_est.mbt), so mean(f(coef)) is identically zero: the estimating function is orthogonal by construction, and that orthogonality IS what makes the estimator consistent. Nothing computable from the fitted scores is a bias estimate for this class of estimator, so this accessor reports the uncorrected point estimate rather than a number that merely looks like a correction.

    (IRM's psi_a is the constant -1.)

    v0.79.0 - v0.90.0 returned coef + mean(psi_b - coef * psi_a). That vector is the score at -coef, NOT at coef; since coef = -mean_b / mean_a its mean is mean_b - coef * mean_a = -2 * coef * mean_a, so the accessor returned coef * (1 - 2 * mean(psi_a)) (exactly 3 * coef when mean(psi_a) = -1). That is not a bias estimate. See bias_corrected_theta in sandwich.mbt for the algebra. The method is kept so the API surface stays stable; removing it outright is the obvious follow-up.

    Preconditions: self.fitted.

    DoubleMLIRM::bootstrap

    fn DoubleMLIRM::bootstrap(self : DoubleMLIRM, method_name? : String, n_rep_boot? : Int, seed? : Int) -> DoubleMLIRM

    v0.61.0+: multiplier bootstrap for DoubleMLIRM. The per-observation influence function is

    psi[i] = theta * psi_a[i] + psi_b[i] = -1 + theta * psi_b[i]

    where psi_b[i] = (g1-g0)[i] + (D*u1/m - (1-D)*u0/(1-m))[i] is computed at the fitted coef from the last rep's cross-fitted nuisances g0_hat / g1_hat / m_hat. Draws n_rep_boot weight vectors of length n_obs from the chosen multiplier distribution, and returns a fitted model with boot_t_stat[b] = sum_i w[b, i] * psi[i] /(sqrt(n) * se_psi) populated where se_psi = sqrt(mean(psi^2)).

    method_name selects the multiplier distribution:
    • "normal" (default): w[i] ~ N(0, 1).
    • "Bayes": w[i] = exp(1) - 1 (mean 0, var 1).
    • "wild": w[i] = x[i] / sqrt(2) + (y[i]^2 - 1) / 2 with x, y ~ N(0, 1).

    Calling bootstrap requires the model to be fitted; calling on an un-fit model aborts with PreconditionError. The helper is did_bootstrap_t_stat (v0.55.0 extracted from DoubleMLDIDCrossSection::bootstrap); IRM is the n_thetas=1 case.

    DoubleMLIRM::clear_cache

    fn DoubleMLIRM::clear_cache(self : DoubleMLIRM) -> DoubleMLIRM

    v0.80.0+: drop any cached nuisance predictions and fold assignment. Useful when the caller wants to free memory or force the next fit() to recompute from scratch (e.g. after mutating a learner in place that the cache key does not detect).

    DoubleMLIRM::cluster_sandwich_se

    fn DoubleMLIRM::cluster_sandwich_se(self : DoubleMLIRM, cluster_ids : Array[Int]) -> Double

    v0.79.0+: cluster-robust sandwich standard error for the fitted IRM. Routes through cluster_sandwich_variance in sandwich.mbt with M_inv = [[1 / mean(psi_a)]] (matching the IID path's Jacobian inverse).

    Preconditions: self.fitted, cluster_ids.length() == n_obs.

    DoubleMLIRM::coef

    fn DoubleMLIRM::coef(self : DoubleMLIRM) -> Double

    DoubleMLIRM::confint

    fn DoubleMLIRM::confint(self : DoubleMLIRM, joint? : Bool, level? : Double) -> (Double, Double)

    v0.67.0+: joint is a no-op for single-theta estimators (the joint CI equals the Wald CI); accepted for API parity with the multi-theta estimators.

    DoubleMLIRM::disable_memoize

    fn DoubleMLIRM::disable_memoize(self : DoubleMLIRM) -> DoubleMLIRM

    v0.80.0+: turn off memoization. Same immutability contract as enable_memoize(). After this, fit() will not read or write the cache; the existing fit_cache is preserved on the returned struct (call clear_cache() to drop it).

    DoubleMLIRM::enable_memoize

    fn DoubleMLIRM::enable_memoize(self : DoubleMLIRM) -> DoubleMLIRM

    v0.80.0+: turn on memoization for subsequent fit() calls. When enabled, fit() will cache the per-observation nuisance predictions and the fold partition and skip the cross_fit_irm(...) step on a repeat call whose data + learner fingerprint is unchanged. Returns a new DoubleMLIRM with the flag set (the struct is immutable; the cache itself is populated by the next fit() call).

    Default is OFF. When OFF, every fit() call runs the full cross-fit and the cache is neither read nor written, so v0.79.0 callers see byte-identical output.

    DoubleMLIRM::fit

    fn DoubleMLIRM::fit(self : DoubleMLIRM, ml_g? : LearnerDispatch, ml_m? : LearnerDispatch, max_attempts? : Int) -> DoubleMLIRM

    Run the IRM estimation. The default learners are closed-form LinearRegression instances for both the outcome nuisance (ml_g) and the propensity score (ml_m).

    Per-repetition behaviour: each repetition r cross-fits the g0 / g1 / m nuisances from its own folds (seed self.seed + r), computes its own (theta_r, se_r) from the ATE score, and the two arrays are then aggregated by aggregate_coef_se (median of thetas, then SE from the median of (theta_r + 1.96 * se_r)). For n_rep == 1 the aggregator returns the single (theta_1, se_1) exactly, so the byte-equality with the previous "average then estimate" implementation is preserved. The predictions_g0 / g1 / m accessors return the nuisances from the last repetition (the conventional choice in upstream doubleml), not a cross-rep average.

    When self.data carries a non-empty cluster_vars vector, the estimator routes through the clustered DML path: folds partition whole units (rows of one cluster id stay on the same side of every split), the ATE is the fold-weighted ratio of cluster score sums, and the SE is unit-level cluster-robust.

    DoubleMLIRM::has_cache

    fn DoubleMLIRM::has_cache(self : DoubleMLIRM) -> Bool

    v0.80.0+: true iff fit_cache holds at least one cached observation (i.e. at least one prior fit() call with memoize_enabled = true has populated the cache). Note that the cache may still be stale relative to the current data + learner configuration -- check memoize_enabled before assuming a cache hit.

    DoubleMLIRM::learner_g

    fn DoubleMLIRM::learner_g(self : DoubleMLIRM) -> LearnerDispatch

    Accessor for the outcome-nuisance learner used by the most recent fit(...) / fit_cluster(...) call. v0.59.0+.

    DoubleMLIRM::learner_m

    fn DoubleMLIRM::learner_m(self : DoubleMLIRM) -> LearnerDispatch

    Accessor for the propensity-score learner used by the most recent fit(...) / fit_cluster(...) call. v0.59.0+.

    DoubleMLIRM::n_features

    fn DoubleMLIRM::n_features(self : DoubleMLIRM) -> Int

    Number of features (covariate columns).

    DoubleMLIRM::n_obs

    fn DoubleMLIRM::n_obs(self : DoubleMLIRM) -> Int

    DoubleMLIRM::new

    fn DoubleMLIRM::new(data : DoubleMLData, n_folds? : Int, n_rep? : Int, seed? : Int, propensity_clip? : Double, ps_processor_config? : PSProcessorConfig?, ml_g? : LearnerDispatch, ml_m? : LearnerDispatch) -> DoubleMLIRM

    DoubleMLIRM::predictions_g0

    fn DoubleMLIRM::predictions_g0(self : DoubleMLIRM) -> Array[Double]

    DoubleMLIRM::predictions_g1

    fn DoubleMLIRM::predictions_g1(self : DoubleMLIRM) -> Array[Double]

    DoubleMLIRM::predictions_m

    fn DoubleMLIRM::predictions_m(self : DoubleMLIRM) -> Array[Double]

    DoubleMLIRM::propensity_score_raw

    fn DoubleMLIRM::propensity_score_raw(self : DoubleMLIRM) -> Array[Double]

    Raw (pre-clipping) propensity scores, length n_obs. v0.53.0-dev Task 4: mirrors upstream feat "retain raw IRM propensity scores" so callers can inspect the un-clipped predictions without re-fitting.

    DoubleMLIRM::sample_splitting

    fn DoubleMLIRM::sample_splitting(self : DoubleMLIRM) -> SampleSplitting?

    The sample splitting recorded by set_sample_splitting, or None when none was supplied and fit draws its own. Some carries the DERIVED counts next to the folds, so a caller can read back what the estimator actually used rather than inferring it from the constructor arguments it no longer has any say over.

    DoubleMLIRM::sandwich_se

    fn DoubleMLIRM::sandwich_se(self : DoubleMLIRM, kind : SandwichKind) -> Double

    v0.79.0+: Huber-White sandwich standard error for the fitted IRM. Returns sqrt(var_xx) where var_xx is computed by the sandwich_variance_xx free function in sandwich.mbt (HC0 / HC1 / HC2 / HC3). The Jacobian inverse M_inv = [[1 / mean(psi_a)]] is built from the stored psi_a (which is [-1, -1, ..., -1] for the IRM ATE score, so M_inv = [[-1.0]] always — the mean(psi_a) is -1 by construction).

    Preconditions: self.fitted.

    DoubleMLIRM::se

    fn DoubleMLIRM::se(self : DoubleMLIRM) -> Double

    DoubleMLIRM::sensitivity_analysis

    fn DoubleMLIRM::sensitivity_analysis(self : DoubleMLIRM, cf_y? : Double, cf_d? : Double) -> SensitivityResult raise

    DoubleMLIRM::sensitivity_analysis_cluster

    fn DoubleMLIRM::sensitivity_analysis_cluster(self : DoubleMLIRM, cluster_ids? : Array[Int], cf_y? : Double, cf_d? : Double) -> SensitivityResult raise

    v0.72.0+: cluster-robust analogue of DoubleMLIRM::sensitivity_analysis. Same DID-style residual form (y - g0 - (g1 - g0) * d) and the same psi_a = -1 (constant) as the IID path; only the variance / bias computation is cluster-aware. cluster_ids defaults to DoubleMLData::cluster_vars.

    DoubleMLIRM::sensitivity_nu2_elements

    fn DoubleMLIRM::sensitivity_nu2_elements(self : DoubleMLIRM) -> (Array[Double], Array[Double]) raise

    v0.131.7: the nu2 score element and the Riesz representer for the IRM ATE score, transcribed from upstream irm/irm.py:412-423.

    Upstream computes it through two layers that both COLLAPSE here:

    • weights, weights_bar = self._get_weights(m_hat=m_hat_adj). self._weights["weights"] defaults to np.ones(n_obs) (irm.py:249) and this port exposes no user-supplied weights, so weights == weights_bar == 1 on both the ATE branch (irm.py:258-263) and -- via the absent weights_bar key -- identically. The ATTE branch (irm.py:265-272) does not apply: the port is ATE-only (irm.mbt:588, "Score (ATE, weights=1, weights_bar=1)").

    • m_hat_adj = _propensity_score_adjustment(m_hat, d, normalize_ipw) returns m_hat unchanged when normalize_ipw is False. That is upstream's default (irm.py:138) and it is exactly why this port's DoubleMLIRM carries no normalize_ipw field. So m_hat_adj == m_hat.

    What is left, with both weight factors equal to 1:

    m_alpha = 1/m_hat + 1/(1 - m_hat) rr = d/m_hat - (1 - d)/(1 - m_hat) nu2 = mean( 2*m_alpha - rr^2 )

    The default path previously computed mean(psi_a^2), which is upstream's documented FALLBACK (double_ml.py:1634-1641) applied to the wrong variable. Note psi_a here is -1/mean(weights) == -1, a CONSTANT (irm.py:392), so the fallback reduced nu2 to exactly 1.

    DoubleMLIRM::set_sample_splitting

    fn DoubleMLIRM::set_sample_splitting(self : DoubleMLIRM, smpls : Array[Array[Fold]]) -> DoubleMLIRM

    Record a caller-supplied sample splitting and DERIVE the fold and repetition counts from it. Returns a new DoubleMLIRM; the receiver is unchanged, as everywhere in this package.

    smpls is one entry per repetition, each entry one Fold per fold -- the nested form is the only form, so a single repetition is [f0, f1, f2] wrapped once, i.e. [[f0, f1, f2]].

    The derivation is upstream's, and it is the part that surprises people: set_sample_splitting (doubleml/double_ml_sampling_mixins.py:76) sets n_folds and n_rep FROM the supplied partition, so handing a two-fold split to a model built with n_folds=5 yields a two-fold model. On the returned model n_folds / n_rep are updated to match, and fit uses the supplied folds instead of drawing them.

    Validation, via check_sample_splitting: every repetition carries the same number of folds, and within each repetition the test index sets are disjoint and cover exactly [0, n_obs). A supplied partition that is not a partition aborts rather than quietly producing a cross-fit that leaks across folds.

    Memoization: fit will not read or write the fit cache once an external splitting is recorded. The cache key is built from the data, learner and cluster hashes and has no term for the fold assignment, so two different supplied partitions would collide and the second fit would silently return the first one's predictions. The memoize_enabled flag is carried through unchanged; it is the !external term in fit that refuses the cache. The cache is also CLEARED here, so a re-fit under a new splitting cannot resurrect an entry written under the old one.

    Cluster path: an externally supplied splitting applies to the ROW-level path only. fit_cluster draws UNIT-level folds over the unique cluster ids and expands them to rows, so a supplied row-level partition is not consulted there and a clustered fit keeps drawing its own unit-level folds. Making the cluster path honour external splits is a separate decision and out of scope here; the field is carried across so the row-level path resumes the supplied splitting on a later non-cluster fit.

    The one-fold "no sample splitting" case needs no special handling: a single Fold whose train and test both cover everything is a valid partition with folds = 1, which is what upstream detects and reports.

    DoubleMLIRM::tune

    fn DoubleMLIRM::tune(self : DoubleMLIRM, param_set~ : Array[TuneParam], scoring_method? : String, n_folds_tune? : Int, seed? : Int) -> DoubleMLIRM

    v0.65.0+: tune the (ml_g, ml_m) nuisance-learner pair via MSE-on-g_hat cross-fitting (matches DoubleMLPLR::tune's MSE-on-l_hat scoring convention; see tune.mbt §4.1). The chosen (learner_g, learner_m) is then re-fit on the FINAL-FIT fold partition (self.n_folds) under DoubleMLIRM::fit. Cluster-DML is not supported in tune (mirrors the PLR behaviour — tune requires !self.data.is_cluster_data()).

    param_set is an Array[TuneParam]; each entry is a (learner_g, learner_m) pair. scoring_method is "MSE" (default), "RMSE", or "NegMSE". Returns a re-fitted DoubleMLIRM with the chosen learners applied. The chosen pair is visible via ml_g / ml_m on the returned model (no tune_result field is persisted — that audit field is reserved for PLR-style estimators).

    DoubleMLLPLR

    pub struct DoubleMLLPLR {
    data : DoubleMLBinaryData
    score : String
    n_folds : Int
    n_folds_inner : Int
    n_rep : Int
    seed : Int
    smpls : Array[Array[Fold]]
    ml_g : LearnerDispatch
    ml_m : LearnerDispatch
    coef_ : Double
    se_ : Double
    r_hat : Array[Double]
    m_hat : Array[Double]
    a_hat : Array[Double]
    fitted : Bool
    fit_warnings : Array[String]
    psi_a : Array[Double]
    psi_b : Array[Double]
    boot_t_stat : Array[Double]
    boot_method : String
    n_rep_boot : Int
    boot_seed : Int
    memoize_enabled : Bool
    fit_cache : FitCache
    } derive(
    Debug
    )

    Model: Y = expit(D * theta + r_0(X)). Binary outcomes, mixed treatment type. Wraps a single-treatment LPLR with closed-form logistic regression learners (LogisticRegression for ml_M and ml_m, LinearRegression for ml_t). The repository keeps one estimator per data; multi-treatment support is out of scope here.

    DoubleMLLPLR::bias_corrected_coef

    fn DoubleMLLPLR::bias_corrected_coef(self : DoubleMLLPLR) -> Double

    v0.92.0+: returns coef UNCHANGED -- a documented no-op, not a bias correction. Identical in reasoning to the twelve bias_corrected_coef methods added in v0.86.0 - v0.91.0; see DoubleMLIRM::bias_corrected_coef and bias_corrected_theta in sandwich.mbt for the full argument.

    coef is the root of the DML moment f(theta) = E[theta * psi_a + psi_b] in var_est.mbt's convention -- for LPLR, psi_a is the score DERIVATIVE and psi_b the score OFFSET -- so mean(f(coef)) is identically zero: the estimating function is orthogonal by construction, and that orthogonality IS what makes the estimator consistent. Nothing computable from the fitted scores is a bias estimate for this class of estimator, so this accessor reports the uncorrected point estimate rather than a number that merely looks like a correction.

    Preconditions: self.fitted.

    DoubleMLLPLR::bootstrap

    fn DoubleMLLPLR::bootstrap(self : DoubleMLLPLR, method_name? : String, n_rep_boot? : Int, seed? : Int) -> DoubleMLLPLR

    v0.75.0+: multiplier bootstrap for DoubleMLLPLR. The per-observation influence function (raw score at the fitted coef_) is

    psi[i] = coef * sc.psi_deriv[i] + (sc.psi[i] - coef * sc.psi_deriv[i]) = sc.psi[i]

    NOTE the argument order below. LPLR persists its IF components in the INVERTED (offset, slope) order relative to every other estimator in the package: self.psi_a is the OFFSET at theta_hat (sc.psi - theta_hat * sc.psi_deriv) and self.psi_b is the SLOPE (sc.psi_deriv, the actual d/dtheta term). psi_at(coef, psi_a, psi_b) expects (coefficient, offset), so LPLR passes (self.psi_b,self.psi_a) -- slope as psi_a, offset as psi_b. That recovers psi_at(coef, sc.psi_deriv, sc.psi - coef *sc.psi_deriv) = sc.psi[i], the raw score at the fitted theta.

    v0.90.0 makes this explicit. v0.75.0 - v0.89.0 called the shared helper with the arguments the other way round plus the old (swapped) score order, and the two wrongs cancelled; the arithmetic above shows the cancellation is exact, so the numeric output is unchanged by v0.90.0. Routing through psi_at means the cancellation is no longer load-bearing.

    The bootstrap draws n_rep_boot weight vectors of length n_obs from the chosen multiplier distribution, and stores boot_t_stat[b] = sum_i w[b, i] * psi[i] /(sqrt(n) * se_psi) where se_psi = sqrt(mean(psi^2)).

    method_name selects the multiplier distribution: "normal" (default), "Bayes", "wild"; seed defaults to 2024; n_rep_boot defaults to 500.

    Calling on an un-fit model aborts via PreconditionError. Returns a new DoubleMLLPLR with boot_t_stat / boot_method / n_rep_boot / boot_seed populated.

    DoubleMLLPLR::clear_cache

    fn DoubleMLLPLR::clear_cache(self : DoubleMLLPLR) -> DoubleMLLPLR

    v0.83.0+: drop any cached nuisance predictions and fold assignment. Forces the next fit() to recompute from scratch.

    DoubleMLLPLR::cluster_sandwich_se

    fn DoubleMLLPLR::cluster_sandwich_se(self : DoubleMLLPLR, cluster_ids : Array[Int]) -> Double

    v0.92.0+: cluster-robust sandwich standard error for the fitted LPLR (Arellano 1987, Cameron-Gelbach-Miller 2011). Routes through cluster_sandwich_variance with the SAME swapped psi and M_inv inputs as the IID sandwich_se path -- see that method's doc for why LPLR's influence-function components are stored inverted.

    DoubleMLBinaryData carries no cluster_vars, so the caller must supply cluster_ids explicitly (any grouping variable the application has: region, site, panel unit). With all-singleton clusters the meat degenerates to sum_i psi[i]^2 and the formula collapses to sandwich_se(HC0), hence to se().

    Preconditions: self.fitted, cluster_ids.length() == psi_b.length().

    DoubleMLLPLR::coef

    fn DoubleMLLPLR::coef(self : DoubleMLLPLR) -> Double

    DoubleMLLPLR::confint

    fn DoubleMLLPLR::confint(self : DoubleMLLPLR) -> (Double, Double)

    DoubleMLLPLR::disable_memoize

    fn DoubleMLLPLR::disable_memoize(self : DoubleMLLPLR) -> DoubleMLLPLR

    v0.83.0+: turn off memoization. Same immutability contract as enable_memoize(). After this, fit() will not read or write the cache; the existing fit_cache is preserved on the returned struct (call clear_cache() to drop it).

    DoubleMLLPLR::enable_memoize

    fn DoubleMLLPLR::enable_memoize(self : DoubleMLLPLR) -> DoubleMLLPLR

    v0.83.0+: turn on memoization for subsequent fit() calls. When enabled, fit() will cache the LAST rep's per-fold nuisance predictions (r_hat, m_hat, a_hat) and the fold assignment; on a repeat call whose data + learner fingerprint + fold split is identical, the entire per-fold crossfit is skipped and the cached values feed the Newton / score / SE pipeline.

    enable_memoize() is honored only when n_rep == 1; for n_rep > 1 the per-rep cross-fit must run each time (the cache would otherwise need to thread n_rep independent per-rep nuisance arrays, which the FitCache layout does not accommodate).

    Default is OFF. When OFF, every fit() call runs the full cross-fit and the cache is neither read nor written, so v0.82.0 callers see byte-identical output.

    DoubleMLLPLR::fit

    Fit the LPLR estimator. Mirrors the upstream DoubleMLLPLR._nuisance_est / _est_causal_pars / _se_causal_pars flow:

    1. Outer cross-fit ml_M on (d, x) -> Y; the same outer folds are also used for the inner re-split. ml_M is a LogisticRegression so its predict returns the probability of Y = 1.
    2. Double cross-fit ml_a (also LogisticRegression for binary D) on x -> D to obtain inner-fold OOF propensity predictions per outer fold.
    3. Build per-fold targets W_inner from ml_M outer-OOF predictions (clipped to [1e-8, 1 - 1e-8], then logit); cross-fit ml_t (a LinearRegression) on x -> W_inner to get t_hat.
    4. Per fold, compute beta_f from inner W and (d - a_inner). Average across folds for the Newton starting value.
    5. ml_m supplies d_tilde = d - m_hat. Upstream fits it as a separate learner, restricted to the Y = 0 rows under score="nuisance_space"; this port aliases m_hat = a_hat (see the m_pred = a_pred comment in the body).
    6. Build the score at any theta from r_i = t_hat_i - beta_start * a_hat_i (no theta in r; see lplr_score_at for both upstream formulas -- they differ, and that difference is what score selects).
    7. Newton iteration with mean(psi) and mean(psi_deriv).
    8. Variance via the standard linear-score path (var_est(psi_a, psi_b) with psi_a = psi_deriv, psi_b = psi - theta * psi_deriv); the LPLR score is not linear in theta but is exactly the theta * psi_a +psi_b form at the converged theta with psi_a =psi_deriv(theta), psi_b = psi(theta) - theta *psi_deriv(theta), so the same machinery applies.

    LPLR is not cluster data; var_est runs the row-level path.

    DoubleMLLPLR::has_cache

    fn DoubleMLLPLR::has_cache(self : DoubleMLLPLR) -> Bool

    v0.83.0+: true iff fit_cache holds at least one cached observation (i.e. at least one prior fit() call with memoize_enabled = true has populated the cache). Note that the cache may still be stale relative to the current data + learner configuration -- check memoize_enabled before assuming a cache hit.

    DoubleMLLPLR::learner_g

    fn DoubleMLLPLR::learner_g(self : DoubleMLLPLR) -> LearnerDispatch

    Accessor for the outcome-nuisance learner used by the most recent fit(...) call. v0.60.0+.

    DoubleMLLPLR::learner_m

    fn DoubleMLLPLR::learner_m(self : DoubleMLLPLR) -> LearnerDispatch

    Accessor for the propensity-score learner used by the most recent fit(...) call. v0.60.0+.

    DoubleMLLPLR::new

    fn DoubleMLLPLR::new(data : DoubleMLBinaryData, score? : String, n_folds? : Int, n_folds_inner? : Int, n_rep? : Int, seed? : Int, ml_g? : LearnerDispatch, ml_m? : LearnerDispatch) -> DoubleMLLPLR

    DoubleMLLPLR::predictions_a

    fn DoubleMLLPLR::predictions_a(self : DoubleMLLPLR) -> Array[Double]

    a_hat (E[D | X] propensity used in d_tilde) from the last repetition. In LPLR, m_hat == a_hat (a single nuisance regressor feeds both roles), but the field is preserved for parity with upstream's separate ml_m / ml_a learners.

    DoubleMLLPLR::predictions_m

    fn DoubleMLLPLR::predictions_m(self : DoubleMLLPLR) -> Array[Double]

    m_hat (E[D | X], outer cross-fit, "a_hat" in upstream) from the last repetition. Length n_obs.

    DoubleMLLPLR::predictions_t

    fn DoubleMLLPLR::predictions_t(self : DoubleMLLPLR) -> Array[Double]

    t_hat (E[W | X]) from the last repetition. Length n_obs.

    DoubleMLLPLR::sample_splitting

    fn DoubleMLLPLR::sample_splitting(self : DoubleMLLPLR) -> SampleSplitting?

    The sample splitting recorded by set_sample_splitting, or None when none was supplied and fit draws its own. Some carries the DERIVED counts next to the folds, so a caller can read back what the estimator actually used rather than inferring it from the constructor arguments it no longer has any say over.

    DoubleMLLPLR::sandwich_se

    fn DoubleMLLPLR::sandwich_se(self : DoubleMLLPLR, kind : SandwichKind) -> Double

    v0.92.0+: Huber-White sandwich standard error for the fitted LPLR, routed through the shared sandwich_variance(kind, ...) dispatch in sandwich.mbt.

    THE INVERSION -- READ BEFORE CHANGING THE ARGUMENT ORDER

    DoubleMLLPLR persists its influence-function components inverted relative to every other estimator in the package. The internal LplrScore struct (see lplr_score_at) carries psi -- the score itself, which is the OFFSET in the f(theta) = theta * deriv + offset linearisation -- and psi_deriv -- the derivative of the score w.r.t. theta, i.e. the COEFFICIENT. fit persists them as

    self.psi_a = sc.psi - theta_hat * sc.psi_deriv (OFFSET) self.psi_b = sc.psi_deriv (DERIVATIVE)

    so the field names are the mirror image of the package convention (psi_a = coefficient row, psi_b = offset). var_est.mbt -- the reference, unchanged -- is fed (sc.psi_deriv, sc.psi - theta_hat * sc.psi_deriv), i.e. the tuple in package order, and its J = mean(psi_a) is therefore mean(psi_deriv). Two consequences, and both are load-bearing:

    1. The score must be evaluated with the arrays SWAPPED: psi_at(coef, self.psi_b, self.psi_a), which recovers theta_hat * sc.psi_deriv + (sc.psi - theta_hat *sc.psi_deriv) = sc.psi, the raw score at the fitted theta. This is exactly the argument order its bootstrap call site has used since v0.90.0 (see DoubleMLLPLR::bootstrap), so the two paths cannot drift apart.

    2. M_inv must be [[1 / mean(self.psi_b)]] -- mean(psi_deriv), the true Jacobian. v0.87.0 built M_inv from mean(self.psi_a), which is mean(sc.psi - theta_hat * sc.psi_deriv): a catastrophic-cancellation residual, not a derivative. It is ~60x smaller in magnitude here and its sign is not even stable, so the resulting HC0 was inflated by roughly 60x and HC2 / HC3 hit the 1e-10 degenerate-leverage clip (6.4e10). LPLR was skipped in v0.87.0 and again in v0.90.0 for this reason.

    With both corrections in place the payoff invariant holds, as it does for the other 12 estimators:

    sandwich_se(HC0) == se()

    to floating-point tolerance. se() is var_est over the same (psi_deriv, offset) tuple, i.e. sqrt(mean(psi(theta_hat)^2) / (mean(psi_deriv)^2 * n)), which is algebraically M_inv[0,0]^2 * sum_i psi[i]^2 / n / n -- the v0.91.0 accumulator, evaluated at the same point.

    The three inputs are the ones fit already persists for the multiplier bootstrap:
    • psi_a (the length-n array sandwich_variance only reads for its length precondition) is the DERIVATIVE row self.psi_b;
    • psi = psi_at(coef, self.psi_b, self.psi_a);
    • M_inv = [[1 / mean(self.psi_b)]] (1x1). Only M_inv[0, 0]^2 enters the variance, so the sign of the (up-to-sign) Jacobian inverse is irrelevant.

    n_obs is self.psi_b.length(): LPLR has no transformed or subset domain, so the persisted sample is the full self.data.n_obs().

    Preconditions: self.fitted, mean(psi_b) != 0.

    DoubleMLLPLR::se

    fn DoubleMLLPLR::se(self : DoubleMLLPLR) -> Double

    DoubleMLLPLR::sensitivity_analysis

    fn DoubleMLLPLR::sensitivity_analysis(self : DoubleMLLPLR, cf_y? : Double, cf_d? : Double) -> SensitivityResult raise

    v0.70.0+: Cinelli & Hazlett (2020) omitted-variable bias analysis for DoubleMLLPLR (Liu-Zhang-Zhou 2021 partially logistic regression). Single-theta IRM-style decomposition:

    • Outcome residual: residuals[i] = y[i] - t_pred[i], where t_pred is the cross-fitted E[Y | D, X] nuisance persisted by fit (also surfaced via predictions_t). Same outcome-nuisance convention as the v0.66.0 DoubleMLPLR::sensitivity_analysis.

    • Per-observation Riesz representer psi_a[i]: the LPLR ATE IF denominator psi_a[i] = -d[i] * (d[i] - a_hat[i]) where a_hat is the outer cross-fitted propensity E[D | X] from predictions_a. The exact LPLR score derivative (psi_deriv(theta)) depends on the preliminary linear coefficient beta_start which the struct does not persist; the simplified -d * (d - a_hat) form captures the same IRM-style "treatment interacted with treatment-propensity residual" structure used by DoubleMLIRM for the binary-ATE score and is the convention the C&H sensitivity formula expects.

    Routes through the shared irm_style_sensitivity helper (see sensitivity.mbt). cf_y / cf_d default to 0.05 matching the rest of the v0.66.0-onward sensitivity family. Calling on an un-fit model aborts via PreconditionError.


    v0.144.0: DOCUMENTED DEVIATION -- UPSTREAM RAISES NOT IMPLEMENTED.

    This is the strongest of the three. Verified against upstream doubleml-0.11.4: plm/lplr.py sets self._sensitivity_implemented = False and its _sensitivity_element_est body is literally raise NotImplementedError(). Upstream does not merely decline to compute an LPLR sensitivity analysis -- reaching for the elements raises. This port returns a result anyway, kept for API compatibility, and it must be read as PORT-DEFINED.

    Note this is a DIFFERENT issue from the solver's discarded convergence flag (let _ = converged, further down in fit, tracked separately in v0.145.0). One concerns the sensitivity API surface; the other concerns a coefficient computed at a non-solution. Both are real, and fixing either does not address the other.

    mean(psi_a^2) below uses psi_a = -d * (d - a_hat), the simplified LPLR IF denominator, which is NOT upstream's Riesz representer. The nu2 here is not comparable to DoubleMLPLR's upstream-aligned 1 / mean(v^2).

    DoubleMLLPLR::sensitivity_analysis_cluster

    fn DoubleMLLPLR::sensitivity_analysis_cluster(self : DoubleMLLPLR, cluster_ids : Array[Int], cf_y? : Double, cf_d? : Double) -> SensitivityResult raise

    v0.73.0+: cluster-robust analogue of DoubleMLLPLR::sensitivity_analysis. Same outcome residual y - t_pred and the same per-observation Riesz representer psi_a[i] = -d[i] * (d[i] -a_hat[i]) as the IID path; only the variance / bias computation is cluster-aware (see irm_style_sensitivity_cluster in sensitivity.mbt).

    DoubleMLBinaryData has no cluster_vars field, so the user must pass cluster_ids explicitly. cluster_ids.length() must equal self.data.n_obs().

    DoubleMLLPLR::set_sample_splitting

    fn DoubleMLLPLR::set_sample_splitting(self : DoubleMLLPLR, smpls : Array[Array[Fold]]) -> DoubleMLLPLR

    Record a caller-supplied sample splitting and DERIVE the fold and repetition counts from it. Returns a new DoubleMLLPLR; the receiver is unchanged, as everywhere in this package.

    smpls is one entry per repetition, each entry one Fold per fold -- the nested form is the only form, so a single repetition is [f0, f1, f2] wrapped once, i.e. [[f0, f1, f2]].

    The derivation is upstream's, and it is the part that surprises people: set_sample_splitting (doubleml/double_ml_sampling_mixins.py:76) sets n_folds and n_rep FROM the supplied partition, so handing a two-fold split to a model built with n_folds=5 yields a two-fold model. On the returned model n_folds / n_rep are updated to match, and fit uses the supplied folds instead of drawing them.

    Validation, via check_sample_splitting: every repetition carries the same number of folds, and within each repetition the test index sets are disjoint and cover exactly [0, n_obs). A supplied partition that is not a partition aborts rather than quietly producing a cross-fit that leaks across folds.

    Memoization: fit will not read or write the fit cache once an external splitting is recorded. The cache key is built from the data and learner hashes and has no term for the fold assignment, so two different supplied partitions would collide and the second fit would silently return the first one's predictions. The memoize_enabled flag is carried through unchanged; it is the !external term in fit that refuses the cache. The cache is also CLEARED here, so a re-fit under a new splitting cannot resurrect an entry written under the old one.

    Scope: an externally supplied splitting replaces the OUTER partition only. LPLR nests a second, inner cross-fit of n_folds_inner folds behind the preliminary Newton start (drawn at self.seed + r * 1000 + 7) and that inner split is still drawn internally, so it is NOT covered by this method. LPLR has no cluster path at all: DoubleMLBinaryData exposes no is_cluster_data() and fit runs the row-level variance path, so there is no unit-level draw to worry about either.

    The one-fold "no sample splitting" case needs no special handling: a single Fold whose train and test both cover everything is a valid partition with folds = 1, which is what upstream detects and reports.

    DoubleMLLPLR::tune

    fn DoubleMLLPLR::tune(self : DoubleMLLPLR, param_set~ : Array[TuneParam], scoring_method? : String, n_folds_tune? : Int, seed? : Int) -> DoubleMLLPLR

    Tune the (ml_g, ml_m) nuisance-learner pair via MSE-on-l_hat cross-fitting. The winning pair is re-fit on the FINAL-FIT fold partition (self.n_folds) under DoubleMLLPLR::fit; the tune-time cross-fit is not reused. scoring_method is "MSE" (default), "RMSE" or "NegMSE"; n_folds_tune defaults to 5 and seed to 3141. Returns a re-fitted DoubleMLLPLR.

    Slot semantics: LPLR is a partially linear outcome family, so the FIRST TuneParam slot is the OUTCOME learner and the second is the nuisance / propensity learner. Build the grid with TuneParam::for_outcome. Concretely the first slot becomes ml_g (routed by fit to the ml_t W-regressor slot) and the second becomes ml_m (routed to the binary-classification slots). This is worth stating because the ml_g field name reads as a propensity name -- see the learner_g / learner_m accessors, which document ml_g as the outcome-nuisance learner.

    v0.108.0: new. LPLR had no tune at all, so it was one of the estimators carrying a coverage gap against upstream, where tune reaches every estimator through the BaseDML mixin. The fold draw, the scoring loop and the argmin/argmax selection route through the shared tune_score_grid core. LPLR exposes no n_obs(), which is exactly why the core takes its observation count from y.length() rather than from an accessor.

    n_folds_inner (the per-fold inner cross-fit count behind the preliminary Newton start) is NOT a fit parameter, so there is nothing for this method to forward and nothing it can drop: fit reads self.n_folds_inner off the struct, validates it in new, and re-persists it on the returned model. The re-fit therefore uses the tuned model's own inner fold count. The grid scoring does not use n_folds_inner at all -- it uses n_folds_tune -- so changing n_folds_inner cannot change which candidate wins, only the estimate produced afterwards.

    No cluster guard: DoubleMLBinaryData exposes no is_cluster_data(), and fit states that LPLR is not cluster data and runs the row-level variance path. There is no clustered path here to mis-handle, so the guard would be unreachable at best; the method does not add the method or the guard.

    DoubleMLLPLR::warnings

    fn DoubleMLLPLR::warnings(self : DoubleMLLPLR) -> Array[String]

    Diagnostics from the most recent fit(...) call. v0.145.0+.

    THE WARNING CONVENTION, since this package has none.

    Until this release the package had no output channel of any kind -- no println, no stderr -- and that is deliberate: a numerical library that writes to the console makes its test output unreadable and its behaviour untestable. It also meant that when the LPLR Newton solver failed to reach a solution, fit discarded the flag (let _ = converged) and returned a coefficient and standard error computed at a non-solution anyway. Upstream warns here; this port had nowhere to.

    So warnings are RETURNED, not emitted. The caller asks:

    let model = DoubleMLLPLR::new(data, alpha=1.5).fit()
    for w in model.warnings() {
    println(w) // the CALLER decides whether to print
    }

    That keeps the library silent, keeps the condition observable, and keeps it testable -- a gate can assert that a degenerate design produces a warning and a healthy one does not.

    ONE ENTRY PER FAILED REPETITION, and the text names WHICH failure, because the three are different problems:

    stop_reasonmeaning
    degenerate_derivative|d psi / d theta| < 1e-300 -- the Jacobian vanished, so the Newton direction is undefined
    divergedthe iterate passed 1e6 and was abandoned
    exhausted_step_budgetall 50 damped-Newton steps were used without meeting the 1e-9 step tolerance

    A non-empty result means coef() and se() are not an estimate and its standard error. Note that se() is computed as sqrt(gamma / (j^2 * n)) with j = mean(psi_deriv), so a non-solution point can yield a deceptively SMALL standard error rather than a conspicuously large one -- the failure does not look like a failure.

    DoubleMLLPQ

    pub struct DoubleMLLPQ {
    data : DoubleMLLPQData
    treatment : Double
    quantile : Double
    n_folds : Int
    seed : Int
    propensity_clip : Double
    ps_processor_config : PSProcessorConfig
    coef : Double
    se : Double
    fitted : Bool
    predictions_g0 : Array[Double]
    predictions_g1 : Array[Double]
    predictions_m : Array[Double]
    psi : Array[Double]
    psi_a : Array[Double]
    boot_t_stat : Array[Double]
    boot_method : String
    n_rep_boot : Int
    boot_seed : Int
    memoize_enabled : Bool
    fit_cache : FitCache
    } derive(
    Debug
    )

    DoubleMLLPQ::bias_corrected_coef

    fn DoubleMLLPQ::bias_corrected_coef(self : DoubleMLLPQ) -> Double

    v0.93.0+: returns coef UNCHANGED -- a documented no-op, not a bias correction. Identical in reasoning to the twelve bias_corrected_coef methods added in v0.86.0 - v0.92.0; see DoubleMLIRM::bias_corrected_coef and bias_corrected_theta in sandwich.mbt for the full argument.

    coef is the bisection root of the LPQ quantile moment mean(psi_ipw(theta)) = 0. That estimating function is orthogonal at its root by construction -- and that orthogonality IS what makes the estimator consistent -- so no correction built from the fitted scores is a bias estimate for this class of estimator. This accessor therefore reports the uncorrected point estimate rather than a number that merely looks like a correction.

    The pre-v0.91.0 form coef + mean(psi_b - coef * psi_a) is algebraically 3 * coef for a constant-psi_a estimator and is not a bias estimate for ANY of them; the v0.91.0 argument applies here unchanged.

    Preconditions: self.fitted.

    DoubleMLLPQ::bootstrap

    fn DoubleMLLPQ::bootstrap(self : DoubleMLLPQ, method_name? : String, n_rep_boot? : Int, seed? : Int) -> DoubleMLLPQ

    v0.64.0+: multiplier bootstrap for DoubleMLLPQ. The per-observation influence function psi is the centered score at the bisection root (mean 0, se_psi =sqrt(mean(psi^2)) is the bootstrap denominator). Routes through the shared generic_bootstrap_single_psi helper (see bootstrap_helper.mbt).

    method_name selects the multiplier distribution: "normal" (default), "Bayes", "wild". seed defaults to 2024; n_rep_boot defaults to 500.

    Calling on an un-fit model aborts via PreconditionError.

    DoubleMLLPQ::clear_cache

    fn DoubleMLLPQ::clear_cache(self : DoubleMLLPQ) -> DoubleMLLPQ

    v0.82.0+: drop any cached nuisance predictions.

    DoubleMLLPQ::cluster_sandwich_se

    fn DoubleMLLPQ::cluster_sandwich_se(self : DoubleMLLPQ, cluster_ids : Array[Int]) -> Double

    v0.93.0+: cluster-robust sandwich standard error for the fitted LPQ (Arellano 1987, Cameron-Gelbach-Miller 2011). Routes through cluster_sandwich_variance with the SAME self.psi and M_inv inputs as the IID sandwich_se path -- see the section comment above for why those are not the ordinary (psi_at(...),1/mean(psi_a)) pair.

    DoubleMLLPQData carries no cluster_vars, so the caller must supply cluster_ids explicitly (any grouping variable the application has: region, site, panel unit). With all-singleton clusters the meat degenerates to sum_i psi[i]^2 and the formula collapses to sandwich_se(HC0), hence to se().

    Preconditions: self.fitted, |psi_a[0]| >= 1e-12, cluster_ids.length() == psi.length().

    DoubleMLLPQ::coef

    fn DoubleMLLPQ::coef(self : DoubleMLLPQ) -> Double

    DoubleMLLPQ::confint

    fn DoubleMLLPQ::confint(self : DoubleMLLPQ, joint? : Bool, level? : Double) -> (Double, Double)

    95% Wald confidence interval (z = 1.959963984540054). Matches the DoubleMLLPLR::confint idiom byte-for-byte. v0.67.0+: joint is a no-op for single-theta estimators.

    DoubleMLLPQ::disable_memoize

    fn DoubleMLLPQ::disable_memoize(self : DoubleMLLPQ) -> DoubleMLLPQ

    v0.82.0+: turn off memoization.

    DoubleMLLPQ::enable_memoize

    fn DoubleMLLPQ::enable_memoize(self : DoubleMLLPQ) -> DoubleMLLPQ

    v0.82.0+: turn on memoization for subsequent fit() calls.

    DoubleMLLPQ::fit

    DoubleMLLPQ::has_cache

    fn DoubleMLLPQ::has_cache(self : DoubleMLLPQ) -> Bool

    v0.82.0+: true iff fit_cache holds at least one cached observation.

    DoubleMLLPQ::n_features

    fn DoubleMLLPQ::n_features(self : DoubleMLLPQ) -> Int

    Number of features (covariate columns).

    DoubleMLLPQ::n_obs

    fn DoubleMLLPQ::n_obs(self : DoubleMLLPQ) -> Int

    Number of observations.

    DoubleMLLPQ::new

    fn DoubleMLLPQ::new(data : DoubleMLLPQData, treatment? : Double, quantile? : Double, n_folds? : Int, seed? : Int, propensity_clip? : Double, ps_processor_config? : PSProcessorConfig?) -> DoubleMLLPQ

    DoubleMLLPQ::predictions_g0

    fn DoubleMLLPQ::predictions_g0(self : DoubleMLLPQ) -> Array[Double]

    Cross-fitted outcome nuisance for Z=0 (length n_obs). v0.53.0-dev: matches upstream DoubleMLLPQ.predictions["g0"].

    DoubleMLLPQ::predictions_g1

    fn DoubleMLLPQ::predictions_g1(self : DoubleMLLPQ) -> Array[Double]

    Cross-fitted outcome nuisance for Z=1 (length n_obs). v0.53.0-dev: matches upstream DoubleMLLPQ.predictions["g1"].

    DoubleMLLPQ::predictions_m

    fn DoubleMLLPQ::predictions_m(self : DoubleMLLPQ) -> Array[Double]

    Cross-fitted treatment nuisance (propensity score, length n_obs). v0.53.0-dev: matches upstream DoubleMLLPQ.predictions["m"].

    DoubleMLLPQ::sandwich_se

    fn DoubleMLLPQ::sandwich_se(self : DoubleMLLPQ, kind : SandwichKind) -> Double

    v0.93.0+: heteroskedasticity-consistent (Huber-White) sandwich standard error for the fitted LPQ. Routes through the shared sandwich_variance dispatch, so the HC0 / HC1 / HC2 / HC3 relationships are the package-wide ones (see the file header in sandwich.mbt and the identities pinned in expand_v088_test.mbt).

    The two inputs are NOT the usual (psi_at(...), 1/mean(psi_a)) pair, and the deviation is deliberate -- see the section comment above. self.psi is used AS the score (it is already evaluated at coef; there is no psi_b to reconstruct it from, and psi_at would evaluate a different function whose root is not coef), and the Jacobian is the persisted data-dependent deriv in self.psi_a[0], NOT the constant -1.

    n_obs is self.psi.length(), the full sample: LPQ has no transformed or subset domain.

    Preconditions: self.fitted, |psi_a[0]| >= 1e-12.

    DoubleMLLPQ::se

    fn DoubleMLLPQ::se(self : DoubleMLLPQ) -> Double

    DoubleMLLPQ::sensitivity_analysis

    fn DoubleMLLPQ::sensitivity_analysis(self : DoubleMLLPQ, cf_y? : Double, cf_d? : Double) -> SensitivityResult raise

    v0.67.0+: Cinelli & Hazlett (2020) omitted-variable bias analysis for the LPQ (Local Quantile) estimator. The IF psi is centered (mean 0 at the bisection root); nu2 = mean(psi^2) and sigma2 = Var(y). Routes through the shared single_psi_sensitivity helper (see sensitivity.mbt).

    DoubleMLLPQ::sensitivity_analysis_cluster

    fn DoubleMLLPQ::sensitivity_analysis_cluster(self : DoubleMLLPQ, cluster_ids : Array[Int], cf_y? : Double, cf_d? : Double) -> SensitivityResult raise

    v0.74.0+: cluster-robust analogue of DoubleMLLPQ::sensitivity_analysis. The IID path uses the centered-IF formulation (sigma2 = Var(y), nu2 = mean(psi^2)); we replicate that here by routing through irm_style_sensitivity_cluster with residuals = y - mean(y) (so mean(residuals^2) =Var(y)) and psi_a = psi (so mean(psi_a^2) =mean(psi^2)); the IRM-style helper's per-obs C&H max_bias is identical to single_psi_sensitivity's in this case. Only the variance / bias computation is cluster-aware (sigma2_cluster and nu2_cluster are the G / n_clusters sums of squared cluster sums).

    DoubleMLLPQData has no cluster_vars field, so the user must pass cluster_ids explicitly. cluster_ids.length() must equal self.data.n_obs(). Cluster indices are 0-based; 1 + max(cluster_ids) is the number of clusters.

    DoubleMLLPQData

    pub struct DoubleMLLPQData {
    x : Matrix
    y : Array[Double]
    d : Array[Double]
    z : Array[Double]
    } derive(
    Debug
    )

    DoubleMLLPQData::new

    fn DoubleMLLPQData::new(x : Matrix, y : Array[Double], d : Array[Double], z : Array[Double]) -> DoubleMLLPQData

    DoubleMLPLIV

    pub struct DoubleMLPLIV {
    data : DoubleMLPLIVData
    n_folds : Int
    n_rep : Int
    seed : Int
    smpls : Array[Array[Fold]]
    learner : LearnerDispatch
    l_hat : Array[Double]
    r_hat : Array[Double]
    m_hat : Array[Double]
    coef : Double
    se : Double
    fitted : Bool
    psi_a : Array[Double]
    psi_b : Array[Double]
    boot_t_stat : Array[Double]
    boot_method : String
    n_rep_boot : Int
    boot_seed : Int
    memoize_enabled : Bool
    fit_cache : FitCache
    } derive(
    Debug
    )

    Double / debiased machine learning estimator for the partially linear IV regression model (PLIV) of Chernozhukov et al. (2018) with the partialling out score:

    Y = D * theta_0 + g_0(X) + zeta, E[zeta | D, X] = 0 D = m_0(X) + V, E[V | X] = 0 Z = ell_0(X) + xi, E[xi | X] = 0, Cov(Z, V) != 0 (relevance)

    with the partialling out (single-instrument) score

    l_hat = E_hat[Y | X] r_hat = E_hat[D | X] m_hat = E_hat[Z | X] u_hat = Y - l_hat w_hat = D - r_hat v_hat = Z - m_hat psi_a = -w_hat * v_hat psi_b = v_hat * u_hat psi(theta) = theta * psi_a + psi_b

    with point estimate and variance

    theta_hat = -mean(psi_b) / mean(psi_a) = mean(v_hat * u_hat) / mean(w_hat * v_hat) J = mean(psi_a) gamma = mean(psi(theta_hat)^2) sigma2 = gamma / (J^2 * n) se = sqrt(sigma2).

    The port supports only a single instrument and the partialling out score (the IV-type score, which would need an additional ml_g learner, is not implemented). All three nuisance functions are estimated with the same closed-form LinearRegression learner.

    DoubleMLPLIV::bias_corrected_coef

    fn DoubleMLPLIV::bias_corrected_coef(self : DoubleMLPLIV) -> Double

    v0.91.0+: returns coef UNCHANGED -- a documented no-op, not a bias correction.

    coef is the root of the DML moment f(theta) = E[theta * psi_a + psi_b] (see var_est.mbt), so mean(f(coef)) is identically zero: the estimating function is orthogonal by construction, and that orthogonality IS what makes the estimator consistent. Nothing computable from the fitted scores is a bias estimate for this class of estimator, so this accessor reports the uncorrected point estimate rather than a number that merely looks like a correction.

    v0.79.0 - v0.90.0 returned coef + mean(psi_b - coef * psi_a). That vector is the score at -coef, NOT at coef; since coef = -mean_b / mean_a its mean is mean_b - coef * mean_a = -2 * coef * mean_a, so the accessor returned coef * (1 - 2 * mean(psi_a)) (exactly 3 * coef when mean(psi_a) = -1). That is not a bias estimate. See bias_corrected_theta in sandwich.mbt for the algebra. The method is kept so the API surface stays stable; removing it outright is the obvious follow-up.

    Preconditions: self.fitted.

    DoubleMLPLIV::bootstrap

    fn DoubleMLPLIV::bootstrap(self : DoubleMLPLIV, method_name? : String, n_rep_boot? : Int, seed? : Int) -> DoubleMLPLIV

    v0.61.0+: multiplier bootstrap for DoubleMLPLIV. The per-observation influence function is

    psi[i] = theta * psi_a[i] + psi_b[i] = -wv + theta * vu

    where w = d - r_hat, v = z - m_hat, u = y - l_hat are computed at the fitted coef from the last rep's cross-fitted nuisances l_hat / r_hat / m_hat. Draws n_rep_boot weight vectors of length n_obs from the chosen multiplier distribution, and returns a fitted model with boot_t_stat[b] = sum_i w[b, i] * psi[i] /(sqrt(n) * se_psi) populated where se_psi = sqrt(mean(psi^2)).

    method_name selects the multiplier distribution:
    • "normal" (default): w[i] ~ N(0, 1).
    • "Bayes": w[i] = exp(1) - 1 (mean 0, var 1).
    • "wild": w[i] = x[i] / sqrt(2) + (y[i]^2 - 1) / 2 with x, y ~ N(0, 1).

    Calling bootstrap requires the model to be fitted; calling on an un-fit model aborts with PreconditionError. The helper is did_bootstrap_t_stat (v0.55.0 extracted from DoubleMLDIDCrossSection::bootstrap); PLIV is the n_thetas=1 case.

    DoubleMLPLIV::clear_cache

    fn DoubleMLPLIV::clear_cache(self : DoubleMLPLIV) -> DoubleMLPLIV

    v0.82.0+: drop any cached nuisance predictions and fold assignment.

    DoubleMLPLIV::cluster_sandwich_se

    fn DoubleMLPLIV::cluster_sandwich_se(self : DoubleMLPLIV, cluster_ids : Array[Int]) -> Double

    v0.86.0+: cluster-robust sandwich standard error for the fitted PLIV. Routes through cluster_sandwich_variance with the same psi_a / psi / M_inv inputs as the IID sandwich_se path.

    Preconditions: self.fitted, cluster_ids.length() == n_obs.

    DoubleMLPLIV::coef

    fn DoubleMLPLIV::coef(self : DoubleMLPLIV) -> Double

    DoubleMLPLIV::confint

    fn DoubleMLPLIV::confint(self : DoubleMLPLIV, joint? : Bool, level? : Double) -> (Double, Double)

    v0.67.0+: joint is a no-op for single-theta estimators; accepted for API parity.

    DoubleMLPLIV::disable_memoize

    fn DoubleMLPLIV::disable_memoize(self : DoubleMLPLIV) -> DoubleMLPLIV

    v0.82.0+: turn off memoization.

    DoubleMLPLIV::enable_memoize

    fn DoubleMLPLIV::enable_memoize(self : DoubleMLPLIV) -> DoubleMLPLIV

    v0.82.0+: turn on memoization for subsequent fit() calls. Returns a new DoubleMLPLIV with the flag set (the struct is immutable; the cache itself is populated by the next fit() call).

    DoubleMLPLIV::fit

    fn DoubleMLPLIV::fit(self : DoubleMLPLIV, learner? : LearnerDispatch, max_attempts? : Int) -> DoubleMLPLIV

    Run the PLIV estimation. The default learner is a closed-form LinearRegression; a different Learner can be supplied for experiments. The result is stored on the object and the object is returned for chaining.

    Per-repetition behaviour: each repetition r cross-fits the l / r / m nuisances from its own folds (seed self.seed + r), computes its own (theta_r, se_r) from the partialling-out single-instrument score, and the two arrays are then aggregated by aggregate_coef_se (median of thetas, then SE from the median of (theta_r + 1.96 * se_r)). For n_rep == 1 the aggregator returns the single (theta_1, se_1) exactly, so the byte-equality with the previous "average then estimate" implementation is preserved. The predictions_l / r / m accessors return the nuisances from the last repetition (the conventional choice in upstream doubleml), not a cross-rep average.

    DoubleMLPLIV::has_cache

    fn DoubleMLPLIV::has_cache(self : DoubleMLPLIV) -> Bool

    v0.82.0+: true iff fit_cache holds at least one cached observation.

    DoubleMLPLIV::n_obs

    fn DoubleMLPLIV::n_obs(self : DoubleMLPLIV) -> Int

    DoubleMLPLIV::new

    fn DoubleMLPLIV::new(data : DoubleMLPLIVData, n_folds? : Int, n_rep? : Int, seed? : Int, learner? : LearnerDispatch) -> DoubleMLPLIV

    DoubleMLPLIV::predictions_l

    fn DoubleMLPLIV::predictions_l(self : DoubleMLPLIV) -> Array[Double]

    DoubleMLPLIV::predictions_m

    fn DoubleMLPLIV::predictions_m(self : DoubleMLPLIV) -> Array[Double]

    DoubleMLPLIV::predictions_r

    fn DoubleMLPLIV::predictions_r(self : DoubleMLPLIV) -> Array[Double]

    DoubleMLPLIV::sample_splitting

    fn DoubleMLPLIV::sample_splitting(self : DoubleMLPLIV) -> SampleSplitting?

    The sample splitting recorded by set_sample_splitting, or None when none was supplied and fit draws its own. Some carries the DERIVED counts next to the folds, so a caller can read back what the estimator actually used rather than inferring it from the constructor arguments it no longer has any say over.

    DoubleMLPLIV::sandwich_se

    fn DoubleMLPLIV::sandwich_se(self : DoubleMLPLIV, kind : SandwichKind) -> Double

    v0.86.0+: Huber-White sandwich standard error for the fitted PLIV. Returns sqrt(var) where var comes from the shared sandwich_variance(kind, ...) dispatch in sandwich.mbt (HC0 / HC1 / HC2 / HC3).

    The three inputs are the ones fit(...) already persists for the multiplier bootstrap (see the psi_a / psi_b field docs on DoubleMLPLIV):
    • psi_a = self.psi_a -- the partialling-out treatment score -w_hat * v_hat with w_hat = d - r_hat, v_hat = z - m_hat.
    • psi = psi_at(self.coef, self.psi_a, self.psi_b) -- the per-observation influence function evaluated at the fitted coef, where psi_b = v_hat * u_hat is the partialling-out outcome score with u_hat = y - l_hat. Same psi_at score order as IRM / PLR.
    • M_inv = [[1 / mean(psi_a)]] (1x1). Only M_inv[0, 0]^2 enters the variance, so the sign of the (up-to-sign) Jacobian inverse is irrelevant.

    Preconditions: self.fitted, mean(psi_a) != 0.

    DoubleMLPLIV::se

    fn DoubleMLPLIV::se(self : DoubleMLPLIV) -> Double

    DoubleMLPLIV::sensitivity_analysis

    fn DoubleMLPLIV::sensitivity_analysis(self : DoubleMLPLIV, cf_y? : Double, cf_d? : Double) -> SensitivityResult raise

    v0.66.0+: Cinelli & Hazlett (2020) omitted-variable bias analysis. Outcome residual is y - l_hat; the Riesz-representer variance is mean(psi_a^2) where psi_a = -w * v for the partialling-out IV score (w = d - r_hat, v = z - m_hat). Routes through the shared irm_style_sensitivity helper.


    v0.144.0: DOCUMENTED DEVIATION -- UPSTREAM DOES NOT IMPLEMENT THIS.

    Verified against upstream doubleml-0.11.4: DoubleML.sensitivity_analysis is guarded by if self._sensitivity_implemented:, whose base default is False, and plm/pliv.py never sets it. DoubleMLPLIV._sensitivity_element_est is pass. So upstream REFUSES this analysis for PLIV, while this port returns a number. There is therefore no upstream formula to conform to, and no correct-by-construction value this function could be changed to.

    The port keeps the result because it is a working IRM-style decomposition and callers depend on it, but it must be read as a PORT-DEFINED quantity. In particular mean(psi_a^2) with psi_a = -w * v is the square of the DML SCORE ELEMENT, not the C&H Riesz representer -- the same conflation that DoubleMLPLR carried until v0.144.0 corrected it. Do not treat the nu2 here as comparable to DoubleMLPLR's, which is now upstream's 1 / mean(v^2).

    Sibling cases, same upstream verdict: DoubleMLPLPR (_sensitivity_implemented = False, _sensitivity_element_est is pass) and DoubleMLLPLR (_sensitivity_implemented = False, and _sensitivity_element_est raise NotImplementedError()). DoubleMLPLR is the only one of the four upstream implements.

    DoubleMLPLIV::sensitivity_analysis_cluster

    fn DoubleMLPLIV::sensitivity_analysis_cluster(self : DoubleMLPLIV, cluster_ids? : Array[Int], cf_y? : Double, cf_d? : Double) -> SensitivityResult raise

    v0.72.0+: cluster-robust analogue of DoubleMLPLIV::sensitivity_analysis. Same residual formula (y - l_hat) and the same psi_a = -w * v (partialling-out IV score) as the IID path; only the variance / bias computation is cluster-aware. cluster_ids defaults to DoubleMLData::cluster_vars.

    DoubleMLPLIV::set_sample_splitting

    fn DoubleMLPLIV::set_sample_splitting(self : DoubleMLPLIV, smpls : Array[Array[Fold]]) -> DoubleMLPLIV

    Record a caller-supplied sample splitting and DERIVE the fold and repetition counts from it. Returns a new DoubleMLPLIV; the receiver is unchanged, as everywhere in this package.

    smpls is one entry per repetition, each entry one Fold per fold -- the nested form is the only form, so a single repetition is [f0, f1, f2] wrapped once, i.e. [[f0, f1, f2]].

    The derivation is upstream's, and it is the part that surprises people: set_sample_splitting (doubleml/double_ml_sampling_mixins.py:76) sets n_folds and n_rep FROM the supplied partition, so handing a two-fold split to a model built with n_folds=5 yields a two-fold model. On the returned model n_folds / n_rep are updated to match, and fit uses the supplied folds instead of drawing them.

    Validation, via check_sample_splitting: every repetition carries the same number of folds, and within each repetition the test index sets are disjoint and cover exactly [0, n_obs). A supplied partition that is not a partition aborts rather than quietly producing a cross-fit that leaks across folds.

    Memoization: fit will not read or write the fit cache once an external splitting is recorded. The cache key is built from the data, learner and cluster hashes and has no term for the fold assignment, so two different supplied partitions would collide and the second fit would silently return the first one's predictions. The memoize_enabled flag is carried through unchanged; it is the !external term in fit that refuses the cache. The cache is also CLEARED here, so a re-fit under a new splitting cannot resurrect an entry written under the old one.

    Cluster path: an externally supplied splitting applies to the ROW-level path only. fit_cluster draws UNIT-level folds over the unique cluster ids and expands them to rows, so a supplied row-level partition is not consulted there and a clustered fit keeps drawing its own unit-level folds. Making the cluster path honour external splits is a separate decision and out of scope here; the field is carried across so the row-level path resumes the supplied splitting on a later non-cluster fit.

    The one-fold "no sample splitting" case needs no special handling: a single Fold whose train and test both cover everything is a valid partition with folds = 1, which is what upstream detects and reports.

    DoubleMLPLIV::tune

    fn DoubleMLPLIV::tune(self : DoubleMLPLIV, param_set~ : Array[LearnerDispatch], scoring_method? : String, n_folds_tune? : Int, seed? : Int) -> DoubleMLPLIV

    v0.65.0+: tune the single nuisance learner (used for all three nuisances l / r / m) via MSE-on-l_hat cross-fitting (matches DoubleMLPLR::tune's MSE-on-l_hat scoring convention; see tune.mbt §4.1). The chosen learner is then re-fit on the FINAL-FIT fold partition (self.n_folds) under DoubleMLPLIV::fit. Cluster-DML is not supported in tune (mirrors the PLR behaviour — tune requires !self.data.is_cluster_data()).

    param_set is an Array[LearnerDispatch] (each entry is a candidate nuisance learner; PLIV has only one nuisance slot so the TuneParam wrapper is unnecessary). Returns a re-fitted DoubleMLPLIV with the chosen learner applied via the learner field. scoring_method is "MSE" (default), "RMSE", or "NegMSE".

    DoubleMLPLIVData

    pub struct DoubleMLPLIVData {
    x : Matrix
    y : Array[Double]
    d : Array[Double]
    z : Array[Double]
    cluster_vars : Array[Int]
    } derive(
    Debug
    )

    Data container for DoubleMLPLIV. In addition to the covariates x, the outcome y and the treatment d, PLIV needs an instrumental variable z. The port supports a single instrument (1-D array) — the multi-instrument case is not implemented.

    DoubleMLPLIVData::is_cluster_data

    fn DoubleMLPLIVData::is_cluster_data(self : DoubleMLPLIVData) -> Bool

    True iff the data is set up for clustered inference (a non-empty cluster_vars vector was passed to DoubleMLPLIVData::new).

    DoubleMLPLIVData::n_cluster_vars

    fn DoubleMLPLIVData::n_cluster_vars(self : DoubleMLPLIVData) -> Int

    Length of the cluster_vars vector (0 when not clustered).

    DoubleMLPLIVData::n_features

    fn DoubleMLPLIVData::n_features(self : DoubleMLPLIVData) -> Int

    Number of features.

    DoubleMLPLIVData::n_obs

    fn DoubleMLPLIVData::n_obs(self : DoubleMLPLIVData) -> Int

    Number of observations.

    DoubleMLPLIVData::new

    fn DoubleMLPLIVData::new(x : Matrix, y : Array[Double], d : Array[Double], z : Array[Double], cluster_vars? : Array[Int]) -> DoubleMLPLIVData

    Build a DoubleMLPLIVData from an n x p feature matrix, an outcome vector of length n, a treatment vector of length n and an instrument vector of length n.

    DoubleMLPLPR

    pub struct DoubleMLPLPR {
    panel : DoubleMLPanelData
    approach : String
    score : String
    n_folds : Int
    n_rep : Int
    seed : Int
    learner : LearnerDispatch
    coef_ : Double
    se_ : Double
    l_hat : Array[Double]
    m_hat : Array[Double]
    fitted : Bool
    psi_a : Array[Double]
    psi_b : Array[Double]
    boot_t_stat : Array[Double]
    boot_method : String
    n_rep_boot : Int
    boot_seed : Int
    memoize_enabled : Bool
    fit_cache : FitCache
    } derive(
    Debug
    )

    DoubleMLPLPR model. See the struct docs on DoubleMLPanelData and transform_panel for the four approaches. Scores: "partialling out" (default) or "IV-type" (fits an extra g-nuisance on y - theta_initial * d exactly like upstream).

    DoubleMLPLPR::bias_corrected_coef

    fn DoubleMLPLPR::bias_corrected_coef(self : DoubleMLPLPR) -> Double

    v0.91.0+: returns coef UNCHANGED -- a documented no-op, not a bias correction.

    coef is the root of the DML moment f(theta) = E[theta * psi_a + psi_b] (see var_est.mbt), so mean(f(coef)) is identically zero: the estimating function is orthogonal by construction, and that orthogonality IS what makes the estimator consistent. Nothing computable from the fitted scores is a bias estimate for this class of estimator, so this accessor reports the uncorrected point estimate rather than a number that merely looks like a correction.

    v0.79.0 - v0.90.0 returned coef + mean(psi_b - coef * psi_a). That vector is the score at -coef, NOT at coef; since coef = -mean_b / mean_a its mean is mean_b - coef * mean_a = -2 * coef * mean_a, so the accessor returned coef * (1 - 2 * mean(psi_a)) (exactly 3 * coef when mean(psi_a) = -1). That is not a bias estimate. See bias_corrected_theta in sandwich.mbt for the algebra. The method is kept so the API surface stays stable; removing it outright is the obvious follow-up.

    Preconditions: self.fitted.

    DoubleMLPLPR::bootstrap

    fn DoubleMLPLPR::bootstrap(self : DoubleMLPLPR, method_name? : String, n_rep_boot? : Int, seed? : Int) -> DoubleMLPLPR

    v0.75.0+: multiplier bootstrap for DoubleMLPLPR. The per-observation influence function is

    psi[i] = theta * psi_a[i] + psi_b[i]

    where psi_a[i] (PO: -v_hat[i]^2; IV-type: -v_hat[i] * d[i]) and psi_b[i] (PO: v_hat[i] * u_hat[i]; IV-type: v_hat[i] * (y[i] - g_pred[i])) are populated by fit(...) from the LAST rep's cross-fitted nuisances on the TRANSFORMED-domain sample. Note that this is an IID bootstrap (multipliers drawn independently of transform_panel's clustering); the cluster-aware variance comes from the var_est_cluster call inside fit(...), not from the multiplier weights.

    method_name selects the multiplier distribution: "normal" (default), "Bayes", "wild"; seed defaults to 2024; n_rep_boot defaults to 500.

    Calling on an un-fit model aborts via PreconditionError. Returns a new DoubleMLPLPR with boot_t_stat / boot_method / n_rep_boot / boot_seed populated.

    DoubleMLPLPR::clear_cache

    fn DoubleMLPLPR::clear_cache(self : DoubleMLPLPR) -> DoubleMLPLPR

    v0.83.0+: drop any cached nuisance predictions and fold assignment. Forces the next fit() to recompute from scratch.

    DoubleMLPLPR::cluster_sandwich_se

    fn DoubleMLPLPR::cluster_sandwich_se(self : DoubleMLPLPR, cluster_ids : Array[Int]) -> Double

    v0.87.0+: cluster-robust sandwich standard error for the fitted PLPR. Routes through cluster_sandwich_variance with the same psi_a / psi / M_inv inputs as the IID sandwich_se path. DoubleMLPanelData carries no cluster_vars field, so the user must pass cluster_ids explicitly (typically the panel unit id).

    Note: PLPR's own se is already unit-clustered (it uses the fold-level cluster sums); this method exposes the generic cluster-robust sandwich on the same sample so the two can be compared on a common footing.

    Preconditions: self.fitted, cluster_ids.length() == psi_a.length().

    DoubleMLPLPR::coef

    fn DoubleMLPLPR::coef(self : DoubleMLPLPR) -> Double

    DoubleMLPLPR::confint

    fn DoubleMLPLPR::confint(self : DoubleMLPLPR) -> (Double, Double)

    DoubleMLPLPR::disable_memoize

    fn DoubleMLPLPR::disable_memoize(self : DoubleMLPLPR) -> DoubleMLPLPR

    v0.83.0+: turn off memoization. Same immutability contract as enable_memoize(). After this, fit() will not read or write the cache; the existing fit_cache is preserved on the returned struct (call clear_cache() to drop it).

    DoubleMLPLPR::enable_memoize

    fn DoubleMLPLPR::enable_memoize(self : DoubleMLPLPR) -> DoubleMLPLPR

    v0.83.0+: turn on memoization for subsequent fit() calls. When enabled, fit() will cache the LAST rep's cross- fitted (l_hat, m_hat) and the row-to-fold mapping; on a repeat call whose panel fingerprint + fold split + learner + approach + score is identical, the entire per-fold crossfit is skipped and the cached values feed the cluster-robust score / SE pipeline.

    enable_memoize() is honored only when n_rep == 1; for n_rep > 1 the per-rep cross-fit must run each time (the cache would otherwise need to thread n_rep independent per-rep nuisance arrays, which the FitCache layout does not accommodate).

    Default is OFF. When OFF, every fit() call runs the full cross-fit and the cache is neither read nor written, so v0.82.0 callers see byte-identical output.

    DoubleMLPLPR::fit

    fn DoubleMLPLPR::fit(self : DoubleMLPLPR, learner? : LearnerDispatch, max_attempts? : Int) -> DoubleMLPLPR

    Fit the DML estimator. Runs the standard cross-fit PO or IV-type score on the transformed data, including upstream's cre_general post-hoc m_hat adjustment and the clustered inference path (unit-level folds, fold-weighted coefficient, cluster-robust SE).

    DoubleMLPLPR::has_cache

    fn DoubleMLPLPR::has_cache(self : DoubleMLPLPR) -> Bool

    v0.83.0+: true iff fit_cache holds at least one cached observation (i.e. at least one prior fit() call with memoize_enabled = true has populated the cache). Note that the cache may still be stale relative to the current panel + learner configuration -- check memoize_enabled before assuming a cache hit.

    DoubleMLPLPR::new

    fn DoubleMLPLPR::new(panel : DoubleMLPanelData, approach? : String, score? : String, n_folds? : Int, n_rep? : Int, seed? : Int, learner? : LearnerDispatch) -> DoubleMLPLPR

    DoubleMLPLPR::predictions_l

    fn DoubleMLPLPR::predictions_l(self : DoubleMLPLPR) -> Array[Double]

    Cross-fitted E[Y | X'] from the last repetition.

    DoubleMLPLPR::predictions_m

    fn DoubleMLPLPR::predictions_m(self : DoubleMLPLPR) -> Array[Double]

    Cross-fitted E[D | X'] (after any cre adjustment) from the last repetition.

    DoubleMLPLPR::sandwich_se

    fn DoubleMLPLPR::sandwich_se(self : DoubleMLPLPR, kind : SandwichKind) -> Double

    v0.87.0+: Huber-White sandwich standard error for the fitted PLPR. Returns sqrt(var) where var comes from the shared sandwich_variance(kind, ...) dispatch in sandwich.mbt (HC0 / HC1 / HC2 / HC3), the same dispatch DoubleMLDID / DoubleMLIIVM / DoubleMLCVAR use.

    The three inputs are the ones fit(...) already persists for the multiplier bootstrap (see the psi_a / psi_b field docs on DoubleMLPLPR):
    • psi_a = self.psi_a -- the score derivative in theta (partialling-out: -v_hat[i]^2; IV-type: -v_hat[i] * d[i]). Under the default PO score every element is <= 0, so mean(psi_a) <= 0.
    • psi = psi_at(self.coef_, self.psi_a, self.psi_b) -- the per-observation influence function at the fitted coef, matching the psi[i] = psi_a[i] +theta * psi_b[i] form documented on DoubleMLPLPR::bootstrap.
    • M_inv = [[1 / mean(psi_a)]] (1x1). Only M_inv[0, 0]^2 enters the variance, so the sign of the (up-to-sign) Jacobian inverse is irrelevant.

    Note the sample length: psi_a / psi_b live on the TRANSFORMED-domain sample, so the variance's n_obs is self.psi_a.length() (= self.l_hat.length()), which equals self.data.n_obs() for the untransformed approaches and can differ once an fd_exact / within_transform approach changes the row set. The variance's n_obs therefore follows psi_a, never the raw panel width.

    Preconditions: self.fitted, mean(psi_a) != 0.

    DoubleMLPLPR::se

    fn DoubleMLPLPR::se(self : DoubleMLPLPR) -> Double

    DoubleMLPLPR::sensitivity_analysis

    fn DoubleMLPLPR::sensitivity_analysis(self : DoubleMLPLPR, cf_y? : Double, cf_d? : Double) -> SensitivityResult raise

    v0.70.0+: Cinelli & Hazlett (2020) omitted-variable bias analysis for DoubleMLPLPR (Clarke & Polselli 2025 panel partialling-out / IV-type). Single-theta IRM-style decomposition: outcome residual is y - l_hat (the cross-fitted outcome nuisance from the last rep; the same convention used by the v0.66.0 DoubleMLPLR::sensitivity_analysis); the per-observation Riesz representer psi_a is the score derivative w.r.t. theta, recomputed on-the-fly from the stored (d, l_hat, m_hat) to avoid growing the struct shape:

    • "partialling out" score (default): psi_a[i] = -v_hat[i] * v_hat[i] where v_hat[i] = d[i] - m_hat[i].
    • "IV-type" score: psi_a[i] = -v_hat[i] * d[i]

    (matches the per-iteration psi_a definitions used by est_coef_cluster / var_est_cluster inside fit).

    The score branch is selected from self.score so the sensitivity decomposition stays consistent with the score actually used to fit the model.

    cf_y / cf_d default to 0.05 matching the rest of the v0.66.0-onward sensitivity family. Calling on an un-fit model aborts via PreconditionError.


    v0.144.0: DOCUMENTED DEVIATION -- UPSTREAM EXPLICITLY REFUSES THIS.

    Verified against upstream doubleml-0.11.4: plm/plpr.py sets self._sensitivity_implemented = False and its _sensitivity_element_est body is pass. Upstream's DoubleML.sensitivity_analysis is guarded on that flag and therefore does not perform a PLPR sensitivity analysis at all. There is no upstream formula to conform to.

    This port returns a result regardless, kept for API compatibility. Read it as PORT-DEFINED. mean(psi_a^2) below uses psi_a = -v_hat^2 (the DML score element) where the C&H construction wants the Riesz representer -- the same conflation DoubleMLPLR carried until v0.144.0 corrected it, and so the nu2 here is NOT comparable to DoubleMLPLR's upstream-aligned 1 / mean(v^2). No gate asserts a value for PLPR, because there is no value to assert.

    DoubleMLPLPR::sensitivity_analysis_cluster

    fn DoubleMLPLPR::sensitivity_analysis_cluster(self : DoubleMLPLPR, cluster_ids : Array[Int], cf_y? : Double, cf_d? : Double) -> SensitivityResult raise

    v0.73.0+: cluster-robust analogue of DoubleMLPLPR::sensitivity_analysis. Same partialling-out / IV-type residual form y - l_hat and the same per-observation Riesz representer (PO: -v_hat^2; IV-type: -v_hat * d) as the IID path; only the variance / bias computation is cluster-aware (see irm_style_sensitivity_cluster in sensitivity.mbt).

    cluster_ids MUST be in the TRANSFORMED domain (length self.l_hat.length()), not the raw panel length (self.panel.y.length()). plpr's fit() applies transform_panel to the raw panel -- for example, fd_exact drops the first period -- and l_hat / m_hat are indexed against the transformed cross- section. The user must pre-drop rows from cluster_ids matching transform_panel's drop logic (e.g., drop rows where t == 0 for fd_exact on a 0-indexed time grid). The DGP is balanced enough that dropping by (t == 0) works in practice.

    DoubleMLPanelData has no cluster_vars field, so the user must pass cluster_ids explicitly.

    DoubleMLPLPR::tune

    fn DoubleMLPLPR::tune(self : DoubleMLPLPR, param_set~ : Array[LearnerDispatch], scoring_method? : String, n_folds_tune? : Int, seed? : Int) -> DoubleMLPLPR

    Tune the single nuisance learner via MSE-on-l_hat cross-fitting. The winning learner is re-fit on the FINAL-FIT fold partition (self.n_folds) under DoubleMLPLPR::fit; the tune-time cross-fit is not reused. scoring_method is "MSE" (default), "RMSE" or "NegMSE"; n_folds_tune defaults to 5 and seed to 3141. Returns a re-fitted DoubleMLPLPR.

    Grid shape: param_set is a bare Array[LearnerDispatch], NOT an Array[TuneParam]. PLPR is a one-slot estimator, so a two-learner TuneParam would be a wrapper around a single value, and upstream's one-slot estimators use the bare array (same shape as DoubleMLPLIV::tune). PLPR is outcome-first: the candidates are outcome learners E[y|x], so there is no slot role to disambiguate and no for_outcome / for_treatment factory to reach for.

    v0.108.0: new. PLPR had no tune at all, so it was one of the estimators carrying a coverage gap against upstream, where tune reaches every estimator through the BaseDML mixin. The fold draw, the scoring loop and the argmin/argmax selection route through the shared tune_score_grid core, which is keyed on the learner array precisely so both the two-slot and the one-slot grid shapes can share it. PLPR exposes no n_obs(), which is why the core derives the observation count from y.length().

    max_attempts is NOT forwarded. It is a fit parameter with no corresponding struct field, so there is no self.max_attempts to read; the re-fit gets fit's default of 1 retry. A caller who needs the numerical-retry path must call fit(learner = ..., max_attempts = k) explicitly after tuning, rather than expecting tune to preserve it.

    Two mismatches between the tune-time cross-fit and the estimation path, stated here because a guard cannot fix them and a caller choosing learners needs to know:

    1. Fold granularity. tune_score_grid draws a ROW-WISE kfold schedule, while fit always runs the clustered DML path with folds drawn over whole UNITS (unique_units(tf.id)). For a panel this is the same row-vs-unit hazard the cluster guard exists to block in the other estimators, except that PLPR is unconditionally clustered, so there is no non-clustered case to admit and no guard could do useful work.
    2. Domain. The grid scores candidates against the RAW panel (self.panel.x / self.panel.y), while fit estimates on the TRANSFORMED cross-section produced by transform_panel (fd_exact first-differences and drops rows, wg_approx within-transforms). The ranking is a proxy measured on the raw domain, not on the domain the estimate is computed in.

    No cluster guard: DoubleMLPanelData has no cluster_vars field and exposes no is_cluster_data(), so the guard has nothing to call. The method does not add the method.

    DoubleMLPLR

    pub struct DoubleMLPLR {
    data : DoubleMLData
    learner_l : LearnerDispatch
    learner_m : LearnerDispatch
    n_folds : Int
    n_rep : Int
    seed : Int
    smpls : Array[Array[Fold]]
    l_hat : Array[Double]
    m_hat : Array[Double]
    coef : Double
    se : Double
    fitted : Bool
    psi_a : Array[Double]
    psi_b : Array[Double]
    boot_t_stat : Array[Double]
    boot_method : String
    n_rep_boot : Int
    boot_seed : Int
    tune_result : TuneResult?
    memoize_enabled : Bool
    fit_cache : FitCache
    } derive(
    Debug
    )

    Double / debiased machine learning estimator for the partially linear regression model

    Y = D * theta_0 + g_0(X) + zeta, E[zeta | D, X] = 0 D = m_0(X) + V, E[V | X] = 0

    with the partialling out score

    psi_a(theta) = -(D - m_hat)^2, psi_b(theta) = (D - m_hat) * (Y - l_hat), psi(theta) = theta * psi_a + psi_b

    where l_hat = E_hat[Y | X] and m_hat = E_hat[D | X] are obtained from a LinearRegression learner (or any other Learner) trained out-of-fold via K-fold cross-fitting.

    The point estimate is

    theta_hat = -mean(psi_b) / mean(psi_a) = mean((D - m_hat)(Y - l_hat)) / mean((D - m_hat)^2).

    The variance is estimated following doubleml.utils._estimation._var_est (non-cluster case):

    J = mean(psi_a) # expected derivative of psi w.r.t. theta gamma = mean(psi(theta_hat)^2) sigma2 = gamma / (J^2 * n) se = sqrt(sigma2).

    The implementation supports only the partialling out score and a single treatment. It is intentionally minimal — see the README for the matrix of features covered relative to the upstream package.

    DoubleMLPLR::bias_corrected_coef

    fn DoubleMLPLR::bias_corrected_coef(self : DoubleMLPLR) -> Double

    v0.91.0+: returns coef UNCHANGED -- a documented no-op, not a bias correction.

    coef is the root of the DML moment f(theta) = E[theta * psi_a + psi_b] (see var_est.mbt), so mean(f(coef)) is identically zero: the estimating function is orthogonal by construction, and that orthogonality IS what makes the estimator consistent. Nothing computable from the fitted scores is a bias estimate for this class of estimator, so this accessor reports the uncorrected point estimate rather than a number that merely looks like a correction.

    v0.79.0 - v0.90.0 returned coef + mean(psi_b - coef * psi_a). That vector is the score at -coef, NOT at coef; since coef = -mean_b / mean_a its mean is mean_b - coef * mean_a = -2 * coef * mean_a, so the accessor returned coef * (1 - 2 * mean(psi_a)) (exactly 3 * coef when mean(psi_a) = -1). That is not a bias estimate. See bias_corrected_theta in sandwich.mbt for the algebra. The method is kept so the API surface stays stable; removing it outright is the obvious follow-up.

    Preconditions: self.fitted.

    DoubleMLPLR::bootstrap

    fn DoubleMLPLR::bootstrap(self : DoubleMLPLR, method_name? : String, n_rep_boot? : Int, seed? : Int) -> DoubleMLPLR

    v0.61.0+: multiplier bootstrap for DoubleMLPLR. The per-observation influence function is

    psi[i] = theta * psi_a[i] + psi_b[i]

    (computed at the fitted coef from the last rep's cross-fitted nuisances l_hat / m_hat). Draws n_rep_boot weight vectors of length n_obs from the chosen multiplier distribution, and returns a fitted model with boot_t_stat[b] = sum_i w[b, i] * psi[i] /(sqrt(n) * se_psi) populated where se_psi = sqrt(mean(psi^2)).

    method_name selects the multiplier distribution:
    • "normal" (default): w[i] ~ N(0, 1). Matches the upstream bootstrap(method="normal") default.
    • "Bayes": w[i] = exp(1) - 1 (mean 0, var 1).
    • "wild": w[i] = x[i] / sqrt(2) + (y[i]^2 - 1) / 2 with x, y ~ N(0, 1). Robust to heteroskedasticity.

    Calling bootstrap requires the model to be fitted; calling on an un-fit model aborts with PreconditionError. The helper is did_bootstrap_t_stat (v0.55.0 extracted from DoubleMLDIDCrossSection::bootstrap); PLR is the n_thetas=1 case with psi[i] = psi_at(coef, psi_a, psi_b)[i].

    DoubleMLPLR::clear_cache

    fn DoubleMLPLR::clear_cache(self : DoubleMLPLR) -> DoubleMLPLR

    v0.82.0+: drop any cached nuisance predictions and fold assignment. Useful when the caller wants to free memory or force the next fit() to recompute from scratch.

    DoubleMLPLR::cluster_sandwich_se

    fn DoubleMLPLR::cluster_sandwich_se(self : DoubleMLPLR, cluster_ids : Array[Int]) -> Double

    v0.79.0+: cluster-robust sandwich standard error for the fitted PLR. Same shape as DoubleMLIRM::cluster_sandwich_se; routes through cluster_sandwich_variance with the per-observation PLR psi_a (-v_hat^2) and the per-observation IF psi[i] = psi_at(coef, psi_a, psi_b)[i].

    Preconditions: self.fitted, cluster_ids.length() == n_obs.

    DoubleMLPLR::coef

    fn DoubleMLPLR::coef(self : DoubleMLPLR) -> Double

    Fitted causal parameter.

    DoubleMLPLR::confint

    fn DoubleMLPLR::confint(self : DoubleMLPLR, joint? : Bool, level? : Double) -> (Double, Double)

    95% Wald-style confidence interval [coef - 1.96*se, coef + 1.96*se]. v0.67.0+: joint is a no-op for single-theta estimators (the joint CI equals the Wald CI for a single parameter); accepted for API parity with the multi-theta estimators (APOS / QTE / DIDCS) and the IRM-style cluster path.

    DoubleMLPLR::disable_memoize

    fn DoubleMLPLR::disable_memoize(self : DoubleMLPLR) -> DoubleMLPLR

    v0.82.0+: turn off memoization. Same immutability contract as enable_memoize(). After this, fit() will not read or write the cache; the existing fit_cache is preserved on the returned struct (call clear_cache() to drop it).

    DoubleMLPLR::enable_memoize

    fn DoubleMLPLR::enable_memoize(self : DoubleMLPLR) -> DoubleMLPLR

    v0.82.0+: turn on memoization for subsequent fit() calls. When enabled, fit() will cache the per-observation nuisance predictions and the fold partition and skip the cross-fit nuisance fit on a repeat call whose data + learner fingerprint is unchanged. Returns a new DoubleMLPLR with the flag set (the struct is immutable; the cache itself is populated by the next fit() call).

    Default is OFF. When OFF, every fit() call runs the full cross-fit and the cache is neither read nor written, so v0.81.0 callers see byte-identical output.

    DoubleMLPLR::fit

    fn DoubleMLPLR::fit(self : DoubleMLPLR, learner_l? : LearnerDispatch, learner_m? : LearnerDispatch, max_attempts? : Int, score? : String, tune_result? : TuneResult?) -> DoubleMLPLR

    DoubleMLPLR::has_cache

    fn DoubleMLPLR::has_cache(self : DoubleMLPLR) -> Bool

    v0.82.0+: true iff fit_cache holds at least one cached observation (i.e. at least one prior fit() call with memoize_enabled = true has populated the cache). Note that the cache may still be stale relative to the current data + learner configuration -- check memoize_enabled before assuming a cache hit.

    DoubleMLPLR::n_features

    fn DoubleMLPLR::n_features(self : DoubleMLPLR) -> Int

    Number of features (covariate columns).

    DoubleMLPLR::n_obs

    fn DoubleMLPLR::n_obs(self : DoubleMLPLR) -> Int

    Number of observations.

    DoubleMLPLR::new

    fn DoubleMLPLR::new(data : DoubleMLData, learner_l? : LearnerDispatch, learner_m? : LearnerDispatch, n_folds? : Int, n_rep? : Int, seed? : Int) -> DoubleMLPLR

    DoubleMLPLR::predictions_l

    fn DoubleMLPLR::predictions_l(self : DoubleMLPLR) -> Array[Double]

    Cross-fitted nuisance predictions for the outcome (length n).

    DoubleMLPLR::predictions_m

    fn DoubleMLPLR::predictions_m(self : DoubleMLPLR) -> Array[Double]

    Cross-fitted nuisance predictions for the treatment (length n).

    DoubleMLPLR::sample_splitting

    fn DoubleMLPLR::sample_splitting(self : DoubleMLPLR) -> SampleSplitting?

    The sample splitting recorded by set_sample_splitting, or None when none was supplied and fit draws its own. Some carries the DERIVED counts next to the folds, so a caller can read back what the estimator actually used rather than inferring it from the constructor arguments it no longer has any say over.

    DoubleMLPLR::sandwich_se

    fn DoubleMLPLR::sandwich_se(self : DoubleMLPLR, kind : SandwichKind) -> Double

    v0.79.0+: Huber-White sandwich standard error for the fitted PLR. Same shape as DoubleMLIRM::sandwich_se but operating on the PLR partialling-out score psi_a[i] = -v_hat[i]^2, psi_b[i] = v_hat[i] *u_hat[i]. M_inv = [[1 / mean(psi_a)]]. The sandwich_variance_xx free function in sandwich.mbt does the per-observation squared-score accumulation.

    Preconditions: self.fitted.

    DoubleMLPLR::se

    fn DoubleMLPLR::se(self : DoubleMLPLR) -> Double

    Standard error of the causal parameter, computed via the DML variance formula.

    DoubleMLPLR::sensitivity_analysis

    fn DoubleMLPLR::sensitivity_analysis(self : DoubleMLPLR, cf_y? : Double, cf_d? : Double) -> SensitivityResult raise

    v0.66.0+: Cinelli & Hazlett (2020) omitted-variable bias analysis.

    v0.144.0 -- CORRECTED. This used to report nu2 = mean(v^4) and sigma2 = mean((y - l_hat)^2). Both were wrong, and not by a constant factor.

    The old call passed self.psi_a to irm_style_sensitivity, which squares its third argument (sensitivity.mbt:41). But the DML score's psi_a is -v^2 (plr.mbt:369), so the reported baseline was the FOURTH moment mean((-v^2)^2) = mean(v^4) -- a different functional form, not a differently-scaled version of the intended quantity. The Riesz representer is a different object from the score element, which is exactly why irm_style_sensitivity_from_elements was split out in v0.131.0; PLR never migrated off the legacy helper.

    Upstream plm/plr.py:277-293 defines: treatment_residual = d - m_hat (= v) nu2 = 1.0 / mean(v^2) (a SCALAR) rr = treatment_residual * nu2 sigma2 = mean((y - l_hat - theta * v)^2) (centred by theta*v)

    Measured on a seeded confounded design (n = 500, seed 20261) before the fix: mean(v^4) = 0.0765 against upstream's 1/mean(v^2) = 4.609, a factor of 60.24 that moves with the data (it is 1/(mean(v^2)*mean(v^4)), not a units convention), and sigma2 0.776 against 0.337. rv was therefore not the Cinelli-Hazlett robustness value at all.

    WHY sensitivity_from_variance_baselines AND NOT _from_elements. Upstream also builds a per-observation psi_nu2 = nu2 - v^2 * nu2^2, whose mean is EXACTLY zero by construction (mean(v^2) * nu2^2 == nu2). Feeding that to irm_style_sensitivity_from_elements would make its nu2 come out as 0 and route PLR through the nu2 <= 0 fallback branch -- the right number reached by the branch reserved for a degenerate case. The faithful transcription of a scalar nu2 under a contract that takes a per-observation score element is the scalar itself; the Riesz representer is still passed as v * nu2 in spirit but is unobservable (see sensitivity_from_variance_baselines).

    cf_y / cf_d are the confounding-strength upper bounds (defaults 0.05) and are passed through to the result for upstream parity.

    DoubleMLPLR::sensitivity_analysis_cluster

    fn DoubleMLPLR::sensitivity_analysis_cluster(self : DoubleMLPLR, cluster_ids? : Array[Int], cf_y? : Double, cf_d? : Double) -> SensitivityResult raise

    v0.72.0+: cluster-robust analogue of DoubleMLPLR::sensitivity_analysis.

    v0.144.0 -- CORRECTED, with a scope caveat that matters. Upstream's core package has NO sensitivity_analysis_cluster method at all (verified against doubleml-0.11.4: the only sensitivity entry point is DoubleML.sensitivity_analysis), so this is a port-only extension and there is no upstream cluster formula to match. What is fixed here is the same defect as the IID path: the per-observation definitions feeding the cluster aggregation are now upstream's.

    The cluster analogue of upstream's nu2 = 1 / mean(v^2) is the reciprocal of the cluster-robust second-moment baseline, 1 / mean_c (sum_{i in c} v_i)^2, and sigma2 aggregates the SAME theta-centred residual the IID path uses. cluster_ids defaults to DoubleMLData::cluster_vars.

    DoubleMLPLR::set_sample_splitting

    fn DoubleMLPLR::set_sample_splitting(self : DoubleMLPLR, smpls : Array[Array[Fold]]) -> DoubleMLPLR

    Record a caller-supplied sample splitting and DERIVE the fold and repetition counts from it. Returns a new DoubleMLPLR; the receiver is unchanged, as everywhere in this package.

    smpls is one entry per repetition, each entry one Fold per fold -- the nested form is the only form, so a single repetition is [f0, f1, f2] wrapped once, i.e. [[f0, f1, f2]].

    The derivation is upstream's, and it is the part that surprises people: set_sample_splitting (doubleml/double_ml_sampling_mixins.py:76) sets n_folds and n_rep FROM the supplied partition, so handing a two-fold split to a model built with n_folds=5 yields a two-fold model. On the returned model n_folds / n_rep are updated to match, and fit uses the supplied folds instead of drawing them.

    Validation, via check_sample_splitting: every repetition carries the same number of folds, and within each repetition the test index sets are disjoint and cover exactly [0, n_obs). A supplied partition that is not a partition aborts rather than quietly producing a cross-fit that leaks across folds.

    Memoization is switched OFF for the external path, and the fit cache is cleared here. Two separate things, and an earlier draft of this comment conflated them: the returned model still carries memoize_enabled = true (the struct spread preserves it), and what actually prevents caching is the && !external term inside fit. The net effect is the same -- an externally-split fit never reads or writes the cache -- but "the model is switched off" would have been false, and a reader checking memoize_enabled would conclude the opposite. The cache key is built from data + learner + cluster hashes and has no term for the fold assignment, so honouring a cache entry here could return a DIFFERENT partition's predictions. The clear is required for the same reason: an entry written under the previous splitting would otherwise outlive it.

    The one-fold "no sample splitting" case needs no special handling: a single Fold whose train and test both cover everything is a valid partition with folds = 1, which is what upstream detects and reports.

    DoubleMLPLR::tune

    fn DoubleMLPLR::tune(self : DoubleMLPLR, param_set~ : Array[TuneParam], scoring_method? : String, n_folds_tune? : Int, seed? : Int, score? : String) -> DoubleMLPLR

    DoubleMLPLR::tune — score a grid of nuisance-learner combinations and re-fit the model with the winner.

    Algorithm (TUNE_DESIGN.md §4):

    1. Draw a fresh fold schedule folds_tune via kfold(n, n_folds_tune, seed). The tune-time folds are deliberately independent of the final-fit self.n_folds to avoid information leakage — the final DML estimate uses its own fold schedule for cross-fitting, so sharing tune and fit folds would leak l_hat quality into the model-selection step.
    2. For each candidate c in param_set:
      • l_hat_c = cross_fit_predict_dispatch(c.learner_l,x, y, folds_tune)
      • score_c = tune_score_outcome(y, l_hat_c, scoring)
      • on a learner-level exception, set score_c = +Inf (TUNE_DESIGN.md §7: keep tuning, the argmin rule automatically excludes divergent candidates).
    3. c* = argmin_c score_c (lower-is-better for MSE / RMSE, higher-is-better for NegMSE — NegMSE is converted to its lower-is-better form internally).
    4. Re-fit the DML model with learner_l = c*.learner_l,learner_m = c*.learner_m using the final-fit fold schedule (self.n_folds). The tuned nuisance predictions from step 1 are NOT reused — each re-fit produces its own OOF cross-fit under self.n_folds.
    5. Return the re-fitted DoubleMLPLR.

    Edge cases (TUNE_DESIGN.md §7):
    • param_set.length() == 0 -> abort with a descriptive message.
    • param_set.length() == 1 -> skip the scoring loop, re-fit with the single candidate directly (faster).
    • n_folds_tune > n_obs -> abort via the existing kfold precondition cascade.
    • seed not set -> defaults to 3141 for reproducibility.
    • scoring_method not in known set -> abort via TuneScoring::parse.

    score (the DML score: "partialling-out" / "iv-type") is forwarded to the re-fit step unchanged — tune() does not affect the DML score itself, only the nuisance learners.

    Cluster-data (self.data.is_cluster_data()) is not supported in v0.58.0 — cluster-aware tuning needs a different fold schedule (kfold on unique units) and would inflate this method beyond a single release. Use DoubleMLPLR::fit(learner_l = best_l, learner_m = best_m) directly on the cluster-DML path with hand-picked candidates until v0.59.0+ lands cluster-aware tune.

    DoubleMLPLR::tune_result

    fn DoubleMLPLR::tune_result(self : DoubleMLPLR) -> TuneResult?

    Accessor for the tune_result populated by DoubleMLPLR::tune(...) (v0.58.0+). Returns None for models built via DoubleMLPLR::new(...) or re-fit via DoubleMLPLR::fit(...) (a re-fit discards the prior tune history because the nuisance learners may have changed).

    Inspect the returned TuneResult to see which (learner_l,learner_m) combination won under the chosen scoring rule, what the best score was, and the per-candidate score vector (in param_set order).

    DoubleMLPQ

    pub struct DoubleMLPQ {
    data : DoubleMLData
    treatment : Double
    quantile : Double
    n_folds : Int
    seed : Int
    smpls : Array[Array[Fold]]
    propensity_clip : Double
    ps_processor_config : PSProcessorConfig
    ml_l : LearnerDispatch
    ml_m : LearnerDispatch
    coef : Double
    se : Double
    fitted : Bool
    psi : Array[Double]
    psi_a : Array[Double]
    boot_t_stat : Array[Double]
    boot_method : String
    n_rep_boot : Int
    boot_seed : Int
    memoize_enabled : Bool
    fit_cache : FitCache
    } derive(
    Debug
    )

    DoubleMLPQ::bias_corrected_coef

    fn DoubleMLPQ::bias_corrected_coef(self : DoubleMLPQ) -> Double

    v0.94.0+: returns coef UNCHANGED -- a documented no-op, not a bias correction. Identical in reasoning to the thirteen bias_corrected_coef methods added in v0.86.0 - v0.93.0; see DoubleMLIRM::bias_corrected_coef and bias_corrected_theta in sandwich.mbt for the full argument.

    coef is the bisection root of the PQ quantile moment mean(psi(theta)) = 0. That estimating function is orthogonal at its root by construction -- and that orthogonality IS what makes the estimator consistent -- so no correction built from the fitted scores is a bias estimate for this class of estimator. This accessor therefore reports the uncorrected point estimate rather than a number that merely looks like a correction.

    The pre-v0.91.0 form coef + mean(psi_b - coef * psi_a) is algebraically 3 * coef for a constant-psi_a estimator and is not a bias estimate for ANY of them; the v0.91.0 argument applies here unchanged.

    Preconditions: self.fitted.

    DoubleMLPQ::bootstrap

    fn DoubleMLPQ::bootstrap(self : DoubleMLPQ, method_name? : String, n_rep_boot? : Int, seed? : Int) -> DoubleMLPQ

    v0.64.0+: multiplier bootstrap for DoubleMLPQ. The per-observation influence function psi is the centered IPW quantile score at the bisection root (mean 0, se_psi = sqrt(mean(psi^2)) is the bootstrap denominator). Routes through the shared generic_bootstrap_single_psi helper (see bootstrap_helper.mbt).

    method_name selects the multiplier distribution: "normal" (default), "Bayes", "wild". seed defaults to 2024; n_rep_boot defaults to 500.

    Calling on an un-fit model aborts via PreconditionError.

    DoubleMLPQ::clear_cache

    fn DoubleMLPQ::clear_cache(self : DoubleMLPQ) -> DoubleMLPQ

    v0.84.0+: drop the cached solve_pq(...) outputs. After this, the next fit() will run the full solve_pq (and repopulate the cache if memoize is still enabled).

    DoubleMLPQ::cluster_sandwich_se

    fn DoubleMLPQ::cluster_sandwich_se(self : DoubleMLPQ, cluster_ids : Array[Int]) -> Double

    v0.94.0+: cluster-robust sandwich standard error for the fitted PQ (Arellano 1987, Cameron-Gelbach-Miller 2011). Routes through cluster_sandwich_variance with the SAME self.psi and M_inv inputs as the IID sandwich_se path -- see the section comment above for why those are not the ordinary (psi_at(...),1/mean(psi_a)) pair.

    DoubleMLData carries cluster_vars, so a clustered fit can pass self.data.cluster_vars directly; on the IID constructor path (cluster_vars = []) the caller must supply the grouping variable explicitly -- any clustering the application has: region, site, panel unit. With all-singleton clusters the meat degenerates to sum_i psi[i]^2 and the formula collapses to sandwich_se(HC0), hence to se() on an IID fit.

    Preconditions: self.fitted, |psi_a[0]| >= 1e-12, cluster_ids.length() == psi.length().

    DoubleMLPQ::coef

    fn DoubleMLPQ::coef(self : DoubleMLPQ) -> Double

    DoubleMLPQ::confint

    fn DoubleMLPQ::confint(self : DoubleMLPQ, joint? : Bool, level? : Double) -> (Double, Double)

    v0.67.0+: joint is a no-op for single-theta estimators; accepted for API parity.

    DoubleMLPQ::disable_memoize

    fn DoubleMLPQ::disable_memoize(self : DoubleMLPQ) -> DoubleMLPQ

    v0.84.0+: turn off memoization. After this, fit() will not read or write the cache; the existing fit_cache is preserved on the returned struct (call clear_cache() to drop it).

    DoubleMLPQ::enable_memoize

    fn DoubleMLPQ::enable_memoize(self : DoubleMLPQ) -> DoubleMLPQ

    v0.84.0+: turn on memoization. When enabled, the next fit() caches the solve_pq(...) outputs (theta, psi, deriv) plus row-to-fold mapping; subsequent fit() calls with the same data fingerprint, fold split, learner configuration, propensity clip, and cluster partition reuse the cached outputs. The (coef, se) aggregation from cached values still runs on every fit().

    DoubleMLPQ::fit

    fn DoubleMLPQ::fit(self : DoubleMLPQ, ml_l? : LearnerDispatch, ml_m? : LearnerDispatch) -> DoubleMLPQ

    DoubleMLPQ::has_cache

    fn DoubleMLPQ::has_cache(self : DoubleMLPQ) -> Bool

    v0.84.0+: true iff fit_cache holds at least one cached solve_pq(...) result (i.e. a previous fit() with memoize_enabled = true has populated the cache). Note that has_cache() does NOT verify the cache key matches the current data + learner configuration -- check memoize_enabled if you also need to know whether the next fit() will hit.

    DoubleMLPQ::learner_l

    fn DoubleMLPQ::learner_l(self : DoubleMLPQ) -> LearnerDispatch

    Accessor for the outcome-quantile-nuisance learner used by the most recent fit(...) call. v0.60.0+.

    DoubleMLPQ::learner_m

    fn DoubleMLPQ::learner_m(self : DoubleMLPQ) -> LearnerDispatch

    Accessor for the propensity-score learner used by the most recent fit(...) call. v0.60.0+.

    DoubleMLPQ::n_features

    fn DoubleMLPQ::n_features(self : DoubleMLPQ) -> Int

    Number of features (covariate columns).

    DoubleMLPQ::n_obs

    fn DoubleMLPQ::n_obs(self : DoubleMLPQ) -> Int

    Number of observations.

    DoubleMLPQ::new

    fn DoubleMLPQ::new(data : DoubleMLData, treatment? : Double, quantile? : Double, n_folds? : Int, seed? : Int, propensity_clip? : Double, ps_processor_config? : PSProcessorConfig?, ml_l? : LearnerDispatch, ml_m? : LearnerDispatch) -> DoubleMLPQ

    DoubleMLPQ::sample_splitting

    fn DoubleMLPQ::sample_splitting(self : DoubleMLPQ) -> SampleSplitting?

    The sample splitting recorded by set_sample_splitting, or None when none was supplied and fit draws its own. Some carries the DERIVED counts next to the folds, so a caller can read back what the estimator actually used rather than inferring it from the constructor arguments it no longer has any say over. folds is what fit cross-fits with; reps is the supplied repetition count, of which fit consumes only the first.

    DoubleMLPQ::sandwich_se

    fn DoubleMLPQ::sandwich_se(self : DoubleMLPQ, kind : SandwichKind) -> Double

    v0.94.0+: heteroskedasticity-consistent (Huber-White) sandwich standard error for the fitted PQ. Routes through the shared sandwich_variance dispatch, so the HC0 / HC1 / HC2 / HC3 relationships are the package-wide ones (see the file header in sandwich.mbt and the identities pinned in expand_v088_test.mbt).

    The two inputs are NOT the usual (psi_at(...), 1/mean(psi_a)) pair, and the deviation is deliberate -- see the section comment above. self.psi is used AS the score (it is already evaluated at coef; there is no psi_b to reconstruct it from, and psi_at would evaluate a different function whose root is not coef), and the Jacobian is the persisted data-dependent deriv in self.psi_a[0], NOT a hard-coded constant.

    n_obs is self.psi.length(), the full sample: PQ has no transformed or subset domain.

    On a CLUSTERED fit (non-empty data.cluster_vars, DoubleMLPQ::fit_cluster) se() is the unit-level cluster-robust variance, not the IID one, so the sandwich_se(HC0) == se() invariant does not hold there; use cluster_sandwich_se(self.data.cluster_vars), which is the unit-level analogue of this method.

    Preconditions: self.fitted, |psi_a[0]| >= 1e-12.

    DoubleMLPQ::se

    fn DoubleMLPQ::se(self : DoubleMLPQ) -> Double

    DoubleMLPQ::sensitivity_analysis

    fn DoubleMLPQ::sensitivity_analysis(self : DoubleMLPQ, cf_y? : Double, cf_d? : Double) -> SensitivityResult raise

    v0.67.0+: Cinelli & Hazlett (2020) omitted-variable bias analysis for DoubleMLPQ (partial quantile). The IF psi is centered (mean 0 at the bisection root); nu2 = mean(psi^2) and sigma2 = Var(y). Routes through the shared single_psi_sensitivity helper (see sensitivity.mbt).

    DoubleMLPQ::sensitivity_analysis_cluster

    fn DoubleMLPQ::sensitivity_analysis_cluster(self : DoubleMLPQ, cluster_ids? : Array[Int], cf_y? : Double, cf_d? : Double) -> SensitivityResult raise

    v0.74.0+: cluster-robust analogue of DoubleMLPQ::sensitivity_analysis. Mirrors the LPQ pattern: the centered-IF formulation (sigma2 = Var(y), nu2 = mean(psi^2)) is replicated inside irm_style_sensitivity_cluster by passing residuals = y - mean(y) and psi_a = self.psi. Only the variance / bias computation is cluster-aware.

    cluster_ids defaults to DoubleMLData::cluster_vars (empty array for the non-clustered constructor path, in which case the caller's explicit cluster_ids is required to produce a meaningful cluster-robust estimate).

    DoubleMLPQ::set_sample_splitting

    fn DoubleMLPQ::set_sample_splitting(self : DoubleMLPQ, smpls : Array[Array[Fold]]) -> DoubleMLPQ

    Record a caller-supplied sample splitting and DERIVE the fold count from it. Returns a new DoubleMLPQ; the receiver is unchanged, as everywhere in this package.

    smpls is one entry per repetition, each entry one Fold per fold -- the nested form is the only form, so a single repetition is [f0, f1, f2] wrapped once, i.e. [[f0, f1, f2]].

    The derivation is upstream's, and it is the part that surprises people: set_sample_splitting (doubleml/double_ml_sampling_mixins.py:76) sets n_folds FROM the supplied partition, so handing a two-fold split to a model built with n_folds=5 yields a two-fold model. On the returned model n_folds is updated to match, and fit uses the supplied folds instead of drawing them.

    Validation, via check_sample_splitting: every repetition carries the same number of folds, and within each repetition the test index sets are disjoint and cover exactly [0, n_obs). A supplied partition that is not a partition aborts rather than quietly producing a cross-fit that leaks across folds.

    PQ has NO n_rep, so the repetition axis has nothing to drive: it is a single-fit estimator with no repetition loop, and fit consumes smpls[0] only. A multi-repetition partition is therefore accepted and validated in full, but repetitions 1.. are not used by fit. sample_splitting reads the repetition count back as supplied, so a caller who passed several can see that the count did not shrink to one. There is no per-quantile reading to confuse this with either: PQ is a single (quantile, treatment) estimator and smpls applies to the one nuisance cross-fit it runs.

    Memoization is turned OFF on the returned model regardless of memoize_enabled. The fit cache is keyed on data + learner + cluster hashes with no term for the fold assignment, so caching an externally-split fit could return a DIFFERENT partition's predictions. The fit cache is also cleared here, for the same reason.

    Cluster data: the supplied folds are ROW-level, and fit_cluster draws its own UNIT-level partition via kfold(n_units, ...) and ignores them. The external splitting applies to the row-level (IID) path only.

    The one-fold "no sample splitting" case needs no special handling: a single Fold whose train and test both cover everything is a valid partition with folds = 1, which is what upstream detects and reports.

    DoubleMLPQ::tune

    fn DoubleMLPQ::tune(self : DoubleMLPQ, param_set~ : Array[TuneParam], scoring_method? : String, n_folds_tune? : Int, seed? : Int) -> DoubleMLPQ

    v0.108.0+ (NEW): score a grid of nuisance-learner pairs and re-fit DoubleMLPQ with the winner, via the shared tune_score_grid core (fold draw + MSE/RMSE/NegMSE scoring + argmin all live there; this method only adapts the grid shape and calls fit).

    Slots tuned: BOTH ml_l and ml_m, i.e. TuneParam's learner_l / learner_m. PQ's ml_l is the OUTCOME-side learner -- solve_pq calls cross_fit_conditional(ml_l, ...) on the binarized outcome 1{y <= theta} and ml_m is the PROPENSITY learner (fit_propensity(ml_m, ...)), which is the reverse of DoubleMLAPO's pair. So build grid rows with TuneParam::for_outcome, not for_treatment: the fields are named for the partially linear family and do not change meaning with the family (see the slot-semantics table on TuneParam).

    Only ml_l is SCORED (MSE-on-outcome cross-fit, the convention shared by every tune in this package); ml_m rides along as the co-varying slot, exactly as in DoubleMLAPO::tune / DoubleMLPLR::tune.

    Cluster guard: PRESENT, because DoubleMLData has is_cluster_data() and DoubleMLPQ::fit dispatches to fit_cluster. DoubleMLAPO::tune shipped for years without this guard while its siblings had it -- cluster-DML tune folds must be drawn over unique units, not rows, and a row-wise fold schedule on clustered data yields a plausible-looking score for a wrong cross-fit. Do not drop it.

    DoubleMLPanelData

    pub struct DoubleMLPanelData {
    x : Matrix
    y : Array[Double]
    d : Array[Double]
    t : Array[Int]
    id : Array[Int]
    } derive(
    Debug
    )

    Double / debiased machine learning for the static panel partially linear regression model (upstream: doubleml.plm.DoubleMLPLPR, Clarke & Polselli 2025):

    Y_it = D_it * theta_0 + g_0(X_it) + alpha_i + zeta_it

    where alpha_i is a unit fixed effect. The panel structure is first transformed into a cross-section via one of four static panel approaches, and a standard DML partialling-out / IV-type score is applied to the transformed data:

    • "cre_general": correlated random effects (Mundlak). Augment X with per-unit means of every covariate column. After cross-fitting, adjust the treatment nuisance: m_hat* = m_hat + d_mean - mean_by_id(m_hat) where d_mean is the per-row unit mean of D.
    • "cre_normal": CRE with a normality-style restriction. Same X augmentation, but the treatment regression gets [X, d_mean] as inputs; no post-hoc m_hat adjustment.
    • "fd_exact": exact first differencing. The panel is reindexed to the full id x time grid, y and d are first-differenced within each unit, and covariates become [X_t, X_{t-1}]. Rows missing x_t or x_{t-1} are dropped.
    • "wg_approx": approximate within transformation. within(v) = v - unit_mean(v) + grand_mean(v) for y, d, and every covariate; PLR runs on the within-transformed variables alone.

    All four approaches reuse the shared cross-fit machinery (kfold + cross_fit_predict). Because upstream re-wraps the transformed data as static-panel data with cluster_cols =id_col, estimation always takes the clustered DML path: folds partition whole units, the coefficient is a fold-weighted ratio of cluster score sums (est_coef_cluster), and the SE is unit-level cluster-robust (var_est_cluster), with the closed-form LinearRegression learner.

    DoubleMLPanelData::new

    fn DoubleMLPanelData::new(x : Matrix, y : Array[Double], d : Array[Double], t : Array[Int], id : Array[Int]) -> DoubleMLPanelData

    DoubleMLPolicyTree

    pub struct DoubleMLPolicyTree {
    features : Matrix
    orth_signal : Array[Double]
    depth : Int
    root : PolicyTreeNode
    split_feature : Int
    split_value : Double
    left_treatment : Int
    right_treatment : Int
    leaf_assignment : Array[Int]
    leaf_signal_mean : Array[Double]
    leaf_count : Array[Int]
    honest : Bool
    split_seed : Int
    min_leaf_n : Int
    est_indices : Array[Int]
    split_indices : Array[Int]
    leaf_se : Array[Double]
    fitted : Bool
    memoize_enabled : Bool
    fit_cache : FitCache
    } derive(
    Debug
    )

    A compact policy tree. It searches one split per level using weighted variance-reduction gain (Bug #8); the depth parameter controls how many levels of recursion to use (TODO #11c.3: previously the depth field was unused and the fit was always a depth-1 stump).

    DoubleMLPolicyTree::clear_cache

    v0.85.0+: drop any cached policy tree + leaf statistics. Forces the next fit() to recompute from scratch.

    DoubleMLPolicyTree::disable_memoize

    v0.85.0+: turn off memoization. Same immutability contract as enable_memoize(). After this, fit() will not read or write the cache; the existing fit_cache is preserved on the returned struct (call clear_cache() to drop it).

    DoubleMLPolicyTree::enable_honesty

    fn DoubleMLPolicyTree::enable_honesty(self : DoubleMLPolicyTree, on : Bool) -> DoubleMLPolicyTree

    v0.98.0+: turn HONESTY on or off. Same immutability contract as enable_memoize(): a new struct is returned, the receiver is untouched, and the config flag can be read with is_honest() BEFORE fit().

    Toggling this on an already-fitted, memoized model keeps the existing fit_cache, but the cache key includes the honesty flag, so the next fit() MISSES and recomputes -- the cache can never hand an adaptive fit's numbers to an honest request, nor the reverse. clear_cache() drops it outright.

    DoubleMLPolicyTree::enable_memoize

    v0.85.0+: turn on memoization for subsequent fit() calls. When enabled, fit() caches the fitted policy tree plus the per-leaf statistics and skips policy_tree_build + the leaf-walk loop on a repeat call whose features, orthogonal signal, and depth are all unchanged. Mirrors the BLP / IRM / PLR / CVAR / SSM plumbing.

    Default is OFF. When OFF, every fit() call runs the full tree build and the cache is neither read nor written, so v0.84.0 callers see byte-identical output.

    DoubleMLPolicyTree::enable_split_seed

    fn DoubleMLPolicyTree::enable_split_seed(self : DoubleMLPolicyTree, seed : Int) -> DoubleMLPolicyTree

    v0.98.0+: re-seed the honesty split. Ignored by the default (non-honest) path, which has no split to seed; folded into the memoize key, so changing it on a memoized model invalidates the cache exactly like toggling honest does.

    DoubleMLPolicyTree::est_indices

    fn DoubleMLPolicyTree::est_indices(self : DoubleMLPolicyTree) -> Array[Int]

    v0.98.0+: the ESTIMATION half -- original row indices of the observations every reported leaf statistic is computed from. Length ceil(n_obs / 2) under honesty. EMPTY when honest = false (there the estimation sample is the whole sample in identity order).

    Under honesty leaf_assignment has THIS length, not n_obs: position j of leaf_assignment describes row est_indices()[j]. leaf_influence returns arrays of the same length, indexed the same way.

    DoubleMLPolicyTree::fit

    DoubleMLPolicyTree::has_cache

    fn DoubleMLPolicyTree::has_cache(self : DoubleMLPolicyTree) -> Bool

    v0.85.0+: true iff fit_cache holds at least one cached observation (i.e. at least one prior fit() call with memoize_enabled = true has populated the cache). Note that the cache may still be stale relative to the current features / orthogonal signal / depth -- check memoize_enabled before assuming a cache hit.

    DoubleMLPolicyTree::is_honest

    fn DoubleMLPolicyTree::is_honest(self : DoubleMLPolicyTree) -> Bool

    v0.98.0+: which regime produced this model's numbers -- true iff the fit split the sample (honesty), false for the v0.97.0 adaptive fit.

    This is the check a caller should make before reading ANY of the uncertainty accessors. leaf_se, policy_value, policy_value_se, leaf_influence and the honest branch of sensitivity_analysis are all defined on the estimation half of an honest fit and refuse to report on an adaptive one; is_honest() lets the caller branch instead of catching an abort. Readable before fit() -- it is a configuration flag, not a fitted quantity.

    DoubleMLPolicyTree::leaf_influence

    fn DoubleMLPolicyTree::leaf_influence(self : DoubleMLPolicyTree, leaf : Int) -> Array[Double]

    v0.98.0+: THE LEAF-MEAN INFLUENCE FUNCTION, ONE LEAF AT A TIME.

    DERIVATION. Leaf k of an honest tree is estimated by the mean of n_k i.i.d. observations of the estimation half. Write its estimating equation as an average over the leaf's own rows:

    f_k(theta) = (1/n_k) * sum_{i in k} (y_i - theta) = (1/n_k) * sum_{i in k} y_i - theta f_k'(theta) = -n_k * (1/n_k) = -1 f_k(theta_hat_k) = 0 at theta_hat_k = mean(y in k)

    For a Z-estimator f(theta) = (1/n) sum_i f_i(theta) the influence function is IF_i = -f_i(theta_hat) / f'(theta_hat). Splitting the estimating function per observation, f_i(theta) = (y_i - theta) / n_k, so f_i'(theta) = -1/n_k and

    IF_k(i) = -[(y_i - theta_hat_k) / n_k] / (-1) = (y_i - theta_hat_k) / n_k

    on the estimation rows of leaf k, and 0 everywhere else. So M_inv = 1 / f_k' = -1 and the per-observation Riesz denominator that irm_style_sensitivity consumes is psi_a[i] = f_i'(theta_hat_k) = -1 / n_k -- see leaf_psi_a.

    TWO CONSISTENCY CHECKS WORTH WRITING DOWN. (i) It sums to zero within the leaf: sum_{i in k} IF_k(i) = (1/n_k) sum_{i in k}(y_i - theta_hat_k) = 0, which is required of any influence function. (ii) Its norm reproduces the leaf SE: sum_{i in k} IF_k(i)^2 = (n_k - 1) s_k^2 / n_k^2, which agrees with leaf_se[k] = s_k / sqrt(n_k) to within the familiar (n_k - 1)/n_k factor -- the two differ because leaf_se uses the n_k - 1 divisor and sqrt(sum IF^2) does not.

    Requires honest = true AND a completed fit(). Length equals est_indices().length(), position j corresponding to original row est_indices()[j] -- NOT to row j. On an adaptive fit the same array is computable but is not a valid influence function (the partition was chosen using these same rows), so this accessor refuses rather than pretending.

    DoubleMLPolicyTree::leaf_psi_a

    fn DoubleMLPolicyTree::leaf_psi_a(self : DoubleMLPolicyTree, leaf : Int) -> Array[Double]

    v0.98.0+: the per-observation Riesz denominator psi_a[i] = -1 / n_k for the rows of leaf leaf in the estimation half and 0.0 elsewhere -- i.e. leaf_influence with its residual divisor pulled out, in exactly the shape sensitivity_analysis feeds to irm_style_sensitivity.

    The default (honest = false) path passes the CONSTANT -1.0 for its own rows instead, and that is what it still passes: sensitivity_analysis's v0.69.0-v0.97.0 numbers are pinned and unchanged. -1 is the per-LEAF derivative f_k' scaled up by n_k; -1/n_k is the per-OBSERVATION derivative f_i', which is the honest one. Both are legitimate inputs to the Cinelli-Hazlett decomposition -- it consumes psi_a only through psi_a^2, so the sign is immaterial and the magnitude sets the variance scale -- but only the honest path's numbers describe an estimator whose partition is independent of the rows it is computed from.

    DoubleMLPolicyTree::leaf_se

    fn DoubleMLPolicyTree::leaf_se(self : DoubleMLPolicyTree) -> Array[Double]

    v0.98.0+: per-leaf standard errors of leaf_signal_mean, measured on the ESTIMATION half, length n_leaves. See policy_tree_honest_leaf_se for the formula and the full derivation.

    WHAT THIS NUMBER IS NOT. It is CONDITIONAL ON THE FITTED STRUCTURE. It is the sampling variance of leaf k's mean given that partition -- it does NOT include the variance from having SEARCHED for that partition. Athey & Imbens (2016) buy nominal coverage for the within-partition effects under honesty; they do not claim the partition itself is exogenous, and neither does this accessor. A leaf mean is a third kind of quantity: it is neither a DML score (so sandwich_variance is wrong) nor a projection of several coefficients (so hac_se is wrong), and it is not routed through either.

    Requires honest = true AND a completed fit(). On an adaptive fit this ABORTS rather than returning the naive same-formula number, because that number is the standard error of an adaptive estimator whose bias does not vanish at a usable rate -- reporting it would be the exact silent failure this release exists to prevent. Use is_honest() to branch.

    DoubleMLPolicyTree::leaf_se_reliable

    fn DoubleMLPolicyTree::leaf_se_reliable(self : DoubleMLPolicyTree) -> Bool

    v0.98.0+: true iff EVERY leaf holds at least min_leaf_n() estimation-half observations, i.e. iff every leaf_se entry is backed by enough degrees of freedom to be worth reading. This is the guard that keeps honesty honest about its own limits.

    WHY THE GUARD EXISTS AT ALL. Cattaneo, Klusowski & Yu, "Accuracy Limits of Causal Trees for Individualized Treatment Effects" (arXiv:2509.11381) prove that CART-type greedy recursive partitioning "selects highly imbalanced splits with nonvanishing probability, producing terminal nodes containing very few observations and leading to large estimation variance", and -- the part that matters here -- that "sample splitting, often called 'honesty', does not remove this limitation". So honesty fixes the BIAS (the partition is no longer chosen by the same rows that fill it) and it does NOT fix the SMALL-LEAF VARIANCE. A leaf holding three estimation observations has a real, computed, and nearly meaningless leaf_se. Reporting it unflagged would be the "silent huge number" failure mode.

    DoubleMLPolicyTree::min_leaf_n

    fn DoubleMLPolicyTree::min_leaf_n(self : DoubleMLPolicyTree) -> Int

    v0.98.0+: the small-leaf guard threshold on ESTIMATION-half leaf counts. Leaves with fewer than this many estimation observations are reported by unreliable_leaves() and excluded from policy_value_se(). Default 5; set via new(min_leaf_n=...). Requires >= 2. Readable before fit().

    DoubleMLPolicyTree::new

    fn DoubleMLPolicyTree::new(features : Matrix, orth_signal : Array[Double], depth? : Int, honest? : Bool, split_seed? : Int, min_leaf_n? : Int) -> DoubleMLPolicyTree

    v0.98.0+: the HONESTY switch, honest, plus its two supporting parameters split_seed and min_leaf_n. All three default to the inert setting, so a v0.97.0 call site sees byte-identical output -- see the honest field docstring on the struct for the definition and the motivation.

    • honest (default false): when true, split the sample and build the tree on one half only. See the struct field.
    • split_seed (default 2024): seeds the kfold(n_obs, 2, split_seed) call that produces the two halves. Ignored when honest = false. Only the FIRST of the two returned folds is used: its train_indices() is the splitting half (floor(n_obs / 2) rows) and its test_indices() is the estimation half (ceil(n_obs / 2) rows).
    • min_leaf_n (default 5): the small-leaf guard threshold on ESTIMATION-half leaf counts. Must be >= 2. See leaf_se for why 5.

    DoubleMLPolicyTree::policy_value

    fn DoubleMLPolicyTree::policy_value(self : DoubleMLPolicyTree) -> Double

    v0.98.0+: THE SCALAR. The policy value of the tree's own decision rule on the estimation half, i.e. the average value the tree assigns to the units it was shown:

    policy_value = sum_k (n_k / n) * theta_k

    over the estimation half, n_k = leaf_count[k], theta_k = leaf_signal_mean[k], n = sum_k n_k.

    THE ESTIMAND, STATED PRECISELY, AND IT IS NOT WHAT YOU MIGHT EXPECT. Because theta_k = (1/n_k) sum_{i in k} y_i, the weights telescope and

    sum_k (n_k / n) * theta_k = (1/n) * sum_k sum_{i in k} y_i = (1/n) * sum_j y_j = mean(y)

    -- the policy value of a leaf-MEAN policy is IDENTICALLY the plain sample mean of orth_signal over the estimation half. That is not a bug in the formula, it is the standard fact that a policy whose action value is the group mean attains the group mean by construction and so has ZERO advantage over treating everyone. All the tree's information is in the per-leaf leaf_signal_mean / leaf_se pairs, NOT in this scalar. The scalar is worth exposing because that identity is the honest headline about what a single-leaf-mean policy tree buys, and because its exact variance is available for free (see policy_value_se).

    This is a VALUE (a mean outcome), not a CATE. Requires honest = true AND a completed fit(). Unguarded: a population-weighted mean is not variance-dominated by one tiny leaf, so no abort is warranted here even when leaf_se_reliable() is false.

    DoubleMLPolicyTree::policy_value_se

    fn DoubleMLPolicyTree::policy_value_se(self : DoubleMLPolicyTree) -> Double

    v0.98.0+: standard error of policy_value(). It is the EXACT standard error of the plain sample mean of orth_signal over the estimation half:

    policy_value_se = sqrt( sum_{j=1..n} (y_j - ybar)^2 / (n * (n - 1)) ) = sd(y) / sqrt(n)

    WHY IT IS NOT sum_k (n_k/n)^2 * se_k^2, WHICH IS THE TEMPTING FORMULA AND IS WRONG. That expression assumes the leaf means are INDEPENDENT. They are not: they are n_k row means of ONE sample, and two disjoint group means from one sample are NEGATIVELY correlated, Cov(theta_k, theta_l) =-s_k^2 s_l^2 / (n (n - 1)) for k != l. Dropping that covariance does not produce a conservative bound, it produces a wrong number in the wrong direction, and it is worst exactly where the tree is most interesting:

    • If all the variation is BETWEEN leaves (s_k^2 = 0 for every leaf -- zero within-leaf noise, genuine heterogeneity), the independence formula returns EXACTLY ZERO. The quantity it describes is a sample mean of genuinely spread-out data, so its true SE is > 0.
    • The two-leaf worst case makes it concrete: two leaves of size n/2 with means ybar +/- d give sum_j (y_j - ybar)^2 = nd^2, hence policy_value_se = |d| / sqrt(n - 1) > 0, while the independence formula still returns 0.

    The exact form needs no cross-leaf covariance, which is why the joint n_leaves x n_leaves covariance matrix -- the honest per-leaf CIs would genuinely want it -- stays out of scope here. FUTURE WORK: expose the full covariance of the leaf means (Cov(theta_k, theta_l) = -s_k^2 s_l^2 / (n (n-1)) off-diagonal, s_k^2 / n_k on it) so per-leaf CIs can be reported honestly at any depth. Building a FOREST of honest trees to recover the precision a single tree loses (Wager & Athey 2018, JASA) is also future work and is deliberately not attempted.

    SAME CAVEAT AS leaf_se(): conditional on the fitted structure, not on the partition having been exogenous.

    Requires honest = true, a completed fit(), AND leaf_se_reliable() == true. The reliability guard is deliberate: a scalar SE that is dominated by a three-observation leaf is a claim about sampling variability that the data cannot support, and aborting (with the standard precondition message) is the explicit failure mode. Check smallest_leaf_count() or unreliable_leaves() first if you want the number anyway.

    DoubleMLPolicyTree::predict

    fn DoubleMLPolicyTree::predict(self : DoubleMLPolicyTree, x : Matrix) -> Array[Int]

    DoubleMLPolicyTree::sensitivity_analysis

    fn DoubleMLPolicyTree::sensitivity_analysis(self : DoubleMLPolicyTree, cf_y? : Double, cf_d? : Double) -> Array[SensitivityResult] raise

    v0.69.0+: per-leaf sensitivity analysis for DoubleMLPolicyTree.

    PolicyTree is a deterministic policy learner: each row is predicted with the leaf treatment emitted by policy_tree_predict. The IRM-style per-leaf decomposition treats the leaf mean of orth_signal as the nuisance-persistence anchor (leaf_signal_mean), the per-leaf outcome residual as orth_signal[i] - leaf_signal_mean[leaf(i)], the per-leaf Riesz-representer row as psi_a = -1 (the constant IRM-style treatment-effect IF), and the per-leaf coefficient as leaf_signal_mean[l].

    Returns an Array[SensitivityResult] in the same DFS order as policy_tree_walk_leaves / leaf_signal_mean / leaf_count. Leaves with leaf_count == 0 (impossible in the current build since the build loop only creates leaves containing at least one row) get a zeroed SensitivityResult. cf_y / cf_d default to 0.05 matching the v0.66.0+ sensitivity family.

    v0.98.0+ -- BEHAVIOUR CHANGE UNDER honest = true, AND NOTHING CHANGES WITHOUT IT. This method BECAME HONESTY-AWARE, and the honest numbers DIFFER from the v0.97.0 numbers in two deliberate ways. On the DEFAULT path (honest = false, the v0.97.0 behaviour) the decomposition still runs over all n_obs rows in identity order with psi_a = -1, and every returned number is byte-identical to v0.69.0-v0.97.0; that is pinned in expand_v098_test.mbt. On the HONEST path it instead:

    • runs over the ESTIMATION half only, so n_rows == leaf_assignment.length() == ceil(n_obs / 2) rather than n_obs, with position j describing original row est_indices()[j]; and
    • passes psi_a = -1 / n_l, the true per-observation derivative of the leaf-mean estimating function (derived in full on leaf_influence), rather than the constant -1.

    Both changes are forced by honesty rather than cosmetic: the split half is not in leaf_signal_mean, so including it would contaminate every leaf's residual, and -1 is n_l times the derivative that actually describes an honest leaf mean. Because irm_style_sensitivity consumes psi_a through psi_a^2 only, the sign flip is immaterial but nu2 scales by 1 / n_l^2 and the reported rv / max_bias move accordingly. Callers that want the old numbers must leave honest at its default.

    Calling on an un-fit model aborts via PreconditionError.

    DoubleMLPolicyTree::set_min_leaf_n

    fn DoubleMLPolicyTree::set_min_leaf_n(self : DoubleMLPolicyTree, n : Int) -> DoubleMLPolicyTree

    v0.98.0+: retune the small-leaf guard threshold. Requires >= 2. Raising it makes the guard STRICTER. Purely a reporting knob: it changes no fitted number, which is why it is deliberately NOT part of the memoize key.

    DoubleMLPolicyTree::smallest_leaf_count

    fn DoubleMLPolicyTree::smallest_leaf_count(self : DoubleMLPolicyTree) -> Int

    v0.98.0+: the smallest estimation-half leaf count -- the single number that tells a caller whether this honest fit is in the regime Cattaneo-Klusowski-Yu warn about. Requires honest = true AND a completed fit().

    DoubleMLPolicyTree::split_feature

    fn DoubleMLPolicyTree::split_feature(self : DoubleMLPolicyTree) -> Int

    DoubleMLPolicyTree::split_indices

    fn DoubleMLPolicyTree::split_indices(self : DoubleMLPolicyTree) -> Array[Int]

    v0.98.0+: the SPLITTING half -- original row indices of the observations policy_tree_build was allowed to look at. Length floor(n_obs / 2) under honesty. EMPTY when honest = false. Disjoint from est_indices() and jointly exhaustive of range_indices(n_obs).

    DoubleMLPolicyTree::split_value

    fn DoubleMLPolicyTree::split_value(self : DoubleMLPolicyTree) -> Double

    DoubleMLPolicyTree::unreliable_leaves

    fn DoubleMLPolicyTree::unreliable_leaves(self : DoubleMLPolicyTree) -> Array[Int]

    v0.98.0+: the leaves whose estimation-half count is below min_leaf_n(), in DFS leaf order. Empty iff leaf_se_reliable(). This is the explicit, enumerable failure mode: a caller can see exactly WHICH leaves are untrustworthy rather than inferring it from one enormous leaf_se.

    Leaves with fewer than 2 estimation observations also land here, and additionally carry a NaN in leaf_se() -- their sample variance has zero degrees of freedom and does not exist.

    DoubleMLQTE

    pub struct DoubleMLQTE {
    data : DoubleMLData
    quantiles : Array[Double]
    n_folds : Int
    seed : Int
    smpls : Array[Array[Fold]]
    propensity_clip : Double
    ml_l : LearnerDispatch
    ml_m : LearnerDispatch
    coefs : Array[Double]
    ses : Array[Double]
    psi_flat : Array[Double]
    derivs1 : Array[Double]
    derivs0 : Array[Double]
    boot_t_stat : Array[Double]
    boot_method : String
    n_rep_boot : Int
    boot_seed : Int
    memoize_enabled : Bool
    fit_cache : FitCache
    } derive(
    Debug
    )

    DoubleMLQTE::bias_corrected_coef_at

    fn DoubleMLQTE::bias_corrected_coef_at(self : DoubleMLQTE, quantile_index : Int) -> Double

    v0.95.0+: returns coefs[quantile_index] UNCHANGED -- a documented no-op, not a bias correction. Identical in reasoning to the fifteen bias_corrected_coef methods added in v0.86.0 - v0.94.0; see DoubleMLPQ::bias_corrected_coef and bias_corrected_theta in sandwich.mbt for the full argument.

    coefs[j] is a difference of two bisection roots of the PQ quantile moment mean(psi_k(theta_k)) = 0. Those estimating functions are orthogonal at their roots by construction -- and that orthogonality IS what makes the roots consistent -- so no correction built from the fitted scores is a bias estimate for this class of estimator. This accessor therefore reports the uncorrected point estimate rather than a number that merely looks like a correction.

    The pre-v0.91.0 form coef + mean(psi_b - coef * psi_a) is algebraically 3 * coef for a constant-psi_a estimator and is not a bias estimate for ANY of them; the v0.91.0 argument applies here unchanged. There is a QTE-specific reason the naive correction is not merely vacuous but actively wrong: the contrast's score u = psi_1 / deriv_1 - psi_0 / deriv_0 is the difference of two arm scores whose individual means are each near-zero but need not be zero at the reported root, so adding mean(u) back to coefs[j] shifts a consistent estimate by whatever those two residual means happen to be.

    QTE persists no fitted : Bool (its bootstrap and sensitivity_analysis use the same coefs.length() > 0 proxy), so this method uses it too.

    Preconditions: 0 <= quantile_index < coefs.length().

    DoubleMLQTE::bootstrap

    fn DoubleMLQTE::bootstrap(self : DoubleMLQTE, method_name? : String, n_rep_boot? : Int, seed? : Int) -> DoubleMLQTE

    v0.64.0+: multiplier bootstrap for DoubleMLQTE. The per-quantile per-observation influence function is u[i, j] = psi_d1[i] / deriv_d1 - psi_d0[i] / deriv_d0 (the delta-method IF for theta_qte = theta_d1 - theta_d0); psi_flat is the flat [n_quantiles * n_obs] row-major matrix that fit populates. Routes through the shared generic_bootstrap_psi_matrix helper (see bootstrap_helper.mbt).

    method_name selects the multiplier distribution: "normal" (default), "Bayes", "wild". seed defaults to 2024; n_rep_boot defaults to 500.

    boot_t_stat is a flat [n_rep_boot * n_quantiles] array (row-major by rep, then by quantile). Calling on an un-fit model aborts via PreconditionError.

    DoubleMLQTE::clear_cache

    fn DoubleMLQTE::clear_cache(self : DoubleMLQTE) -> DoubleMLQTE

    v0.84.0+: drop the cached per-quantile outputs. After this, the next fit() will run the full 2 * n_quantiles solve_pq loop (and repopulate the cache if memoize is still enabled).

    DoubleMLQTE::cluster_joint_covariance

    fn DoubleMLQTE::cluster_joint_covariance(self : DoubleMLQTE, cluster_ids : Array[Int]) -> Matrix

    v0.101.0+: cluster-robust analogue of joint_covariance, for a fit whose observations are not independent (panel units, sites, matched pairs). cluster_ids must carry one entry per observation.

    The construction is the one cluster_sandwich_variance uses for a single estimand, generalised to a pair: instead of summing the products observation by observation, sum them within each cluster and multiply the per-cluster sums.

    Sigma[j, k] = M_inv^2 * sum_c scale_c * S_c[j] * S_c[k] / n^2 S_c[j] = sum_{i in c} psi_flat[j, i] scale_c = n_c / (n_c - 1) for n_c > 1, else 1

    The denominator stays n^2 (the observation count), the same choice the single-estimand cluster path makes: the cluster sums already carry the cluster sizes. There is no HC1-style small-sample knob here for the same reason the single-estimand cluster path has none -- the jackknife scale_c is that correction.

    With all-singleton clusters S_c[i] = psi_flat[j, i] and scale_c = 1, so this collapses onto joint_covariance(HC0) exactly. Pinned by expand_v101_test.mbt::qte_cluster_joint_collapses_to_iid.

    DoubleMLQTE::cluster_sandwich_se_at

    fn DoubleMLQTE::cluster_sandwich_se_at(self : DoubleMLQTE, quantile_index : Int, cluster_ids : Array[Int]) -> Double

    v0.95.0+: cluster-robust sandwich standard error for quantile quantile_index of a fitted QTE (Arellano 1987, Cameron-Gelbach- Miller 2011). Routes through cluster_sandwich_variance with the SAME psi_flat row and M_inv as the IID sandwich_se_at path -- see the section comment above for why those are not the ordinary (psi_at(...), 1/mean(psi_a)) pair.

    DoubleMLData carries cluster_vars, so a clustered fit can pass self.data.cluster_vars directly; on the IID constructor path (cluster_vars = []) the caller must supply the grouping variable explicitly -- any clustering the application has: region, site, panel unit. With all-singleton clusters the meat degenerates to sum_i u_i^2 and the formula collapses to sandwich_se_at(j, HC0), hence to ses[j] on an IID fit.

    Preconditions: 0 <= quantile_index < quantiles.length(), |derivs1[j]| >= 1e-12, |derivs0[j]| >= 1e-12, cluster_ids.length() == n_obs.

    DoubleMLQTE::coefs

    fn DoubleMLQTE::coefs(self : DoubleMLQTE) -> Array[Double]

    DoubleMLQTE::confint

    fn DoubleMLQTE::confint(self : DoubleMLQTE, level? : Double, joint? : Bool) -> Array[(Double, Double)]

    v0.66.0+: Wald-style 95% pointwise confidence intervals for DoubleMLQTE. Returns one (lo, hi) tuple per quantile, in user-supplied order. joint = true uses a max-|t|-bootstrap critical value (requires bootstrap(...) to have been called); the returned CIs are wider.

    DoubleMLQTE::disable_memoize

    fn DoubleMLQTE::disable_memoize(self : DoubleMLQTE) -> DoubleMLQTE

    v0.84.0+: turn off memoization. After this, fit() will not read or write the cache; the existing fit_cache is preserved on the returned struct (call clear_cache() to drop it).

    DoubleMLQTE::enable_memoize

    fn DoubleMLQTE::enable_memoize(self : DoubleMLQTE) -> DoubleMLQTE

    v0.84.0+: turn on memoization. When enabled, the next fit() caches per-quantile per-treatment (theta1, psi1, deriv1, theta0, psi0, deriv0) plus row-to-fold mapping; subsequent fit() calls with the same data fingerprint, fold split, learner configuration, propensity clip, and cluster partition reuse the cached outputs (skipping the entire 2 * n_quantiles solve_pq loop). The (coefs, ses, psi_flat) aggregation from cached values still runs on every fit() call.

    DoubleMLQTE::fit

    DoubleMLQTE::has_cache

    fn DoubleMLQTE::has_cache(self : DoubleMLQTE) -> Bool

    v0.84.0+: true iff fit_cache holds at least one cached per-quantile-solver result (i.e. a previous fit() with memoize_enabled = true has populated the cache). Note that has_cache() does NOT verify the cache key matches the current data + learner configuration -- check memoize_enabled if you also need to know whether the next fit() will hit.

    DoubleMLQTE::joint_covariance

    fn DoubleMLQTE::joint_covariance(self : DoubleMLQTE, kind : SandwichKind) -> Matrix

    v0.101.0+: the JOINT asymptotic covariance of the whole quantile vector (theta_1, ..., theta_J), a J x J Matrix.

    sandwich_se_at(j, kind) answers "how precisely is quantile j pinned down". It cannot answer "how do the quantile estimates move together", because that is a property of the whole vector and not of any one component. The missing information is real rather than cosmetic: the off-diagonal terms are non-zero (and can have either sign), so treating the quantiles as independent mis-states the uncertainty of any statement made jointly about them -- a monotonicity or distributional-shape test, a simultaneous confidence band, a contrast spanning two levels.

    The estimator is

    Sigma[j, k] = M_inv^2 * sum_i psi_flat[j, i] * psi_flat[k, i] / n^2

    with M_inv = 1.0 for the same reason sandwich_se_at uses M_inv = [[1.0]]: the contrast's own Jacobian is already baked into the stored score. fit builds each row as u = psi_1 / deriv_1 - psi_0 / deriv_0 (see the psi_flat[j *n_obs + i] = u[i] write in fit), so no derivs factor appears here and none should be added.

    DIAGONAL IS THE EXISTING SE'S VARIANCE, BIT FOR BIT. Setting j == k in the sum above reproduces sandwich_variance_hc0's Kahan-compensated accumulation over the same products in the same index order, and its M_inv^2 * acc / n / n shape, so joint_covariance(HC0).get(j,j) is bit-identical to that function's return value -- it IS the variance ses[j] is the square root of. Pinned by expand_v101_test.mbt::qte_joint_diagonal_is_the_se_variance.

    The weaker-looking joint_covariance(HC0).get(j, j) == ses[j] * ses[j] holds only to the last ulp, and that is a property of IEEE-754 rather than of this code: x * x is not an exact inverse of sqrt(x). Measured on the v0.101.0 DGP, one of three quantiles matches bit-for-bit and two differ by ~2e-19 absolute on values of ~1.1e-3. Assert equality against the VARIANCE, not against the squared SE.

    Everything the API adds is in the off-diagonal. kind selects the finite-sample correction, applied in the same shape the single-estimand path uses -- HC2 and HC3 divide inside the compensated accumulator, HC1 multiplies the finished sum -- so the diagonal stays bit-identical to sandwich_variance(kind, ...) for every supported kind.

    Preconditions: quantiles.length() >= 1, n_obs >= 1, psi_flat.length() == quantiles.length() * n_obs.

    DoubleMLQTE::learner_l

    fn DoubleMLQTE::learner_l(self : DoubleMLQTE) -> LearnerDispatch

    Accessor for the outcome-quantile-nuisance learner used by the most recent fit(...) call. v0.60.0+.

    DoubleMLQTE::learner_m

    fn DoubleMLQTE::learner_m(self : DoubleMLQTE) -> LearnerDispatch

    Accessor for the propensity-score learner used by the most recent fit(...) call. v0.60.0+.

    DoubleMLQTE::n_features

    fn DoubleMLQTE::n_features(self : DoubleMLQTE) -> Int

    Number of features (covariate columns).

    DoubleMLQTE::n_obs

    fn DoubleMLQTE::n_obs(self : DoubleMLQTE) -> Int

    Number of observations.

    DoubleMLQTE::new

    fn DoubleMLQTE::new(data : DoubleMLData, quantiles? : Array[Double], n_folds? : Int, seed? : Int, propensity_clip? : Double, ml_l? : LearnerDispatch, ml_m? : LearnerDispatch) -> DoubleMLQTE

    DoubleMLQTE::sample_splitting

    fn DoubleMLQTE::sample_splitting(self : DoubleMLQTE) -> SampleSplitting?

    The sample splitting recorded by set_sample_splitting, or None when none was supplied and fit draws its own. Some carries the DERIVED counts next to the folds, so a caller can read back what the estimator actually used rather than inferring it from the constructor arguments it no longer has any say over. folds is the ONE partition every quantile's nuisance cross-fit ran on; reps is the supplied repetition count, of which fit consumes only the first.

    DoubleMLQTE::sandwich_se_at

    fn DoubleMLQTE::sandwich_se_at(self : DoubleMLQTE, quantile_index : Int, kind : SandwichKind) -> Double

    v0.95.0+: heteroskedasticity-consistent (Huber-White) sandwich standard error for quantile quantile_index of a fitted QTE. Routes through the shared sandwich_variance dispatch, so the HC0 / HC1 / HC2 / HC3 relationships are the package-wide ones (see the file header in sandwich.mbt and the identities pinned in expand_v088_test.mbt).

    The three inputs are NOT the usual (psi_at(...), 1/mean(psi_a)) pair, and both deviations are deliberate -- see the section comment above:

    • the score is psi_flat's own row for this quantile, used AS STORED (it is already evaluated at coefs[j]; there is no psi_b to reconstruct it from, and psi_at would evaluate a different function whose root is not coefs[j]);
    • the Jacobian is M_inv = [[1.0]], NOT 1 / derivs1[j] or 1 / derivs0[j]. The per-arm Jacobians are already inside the stored score; the contrast's Jacobian in the studentised form is identically 1.

    psi_a is therefore the all-ones array, which is the honest per-observation Jacobian row of u (and satisfies sandwich_variance's length preconditions); the v0.91.0 accumulator carries no psi_a weight, so the Jacobian reaches the variance only through M_inv.

    n_obs is the full sample, the same n_obs fit divides by when it computes ses[j], and n_params is 1: one scalar estimand per quantile, NOT quantiles.length().

    On a CLUSTERED fit (non-empty data.cluster_vars, DoubleMLQTE::fit_cluster) ses[j] is the unit-level cluster-robust variance sqrt(var_unit / n_units), not the IID one, so the sandwich_se_at(j, HC0) == ses[j] invariant does not hold there; use cluster_sandwich_se_at(j, self.data.cluster_vars), which is the Arellano unit-level analogue (equal to n_obs-denominated cluster_sandwich_variance, so the two agree only up to the n_units vs n_obs denominator and the jackknife correction).

    Preconditions: 0 <= quantile_index < quantiles.length(), |derivs1[j]| >= 1e-12, |derivs0[j]| >= 1e-12.

    DoubleMLQTE::sensitivity_analysis

    fn DoubleMLQTE::sensitivity_analysis(self : DoubleMLQTE, cf_y? : Double, cf_d? : Double) -> Array[SensitivityResult] raise

    v0.70.0+: per-quantile Cinelli & Hazlett (2020) omitted-variable bias analysis for DoubleMLQTE. The per-quantile influence function psi_j[i] = psi_flat[j * n_obs + i] is already centered (mean 0 at the bisection root; see solve_pq), so each quantile routes through the shared single_psi_sensitivity helper (the v0.67.0+ path used by DoubleMLPQ): nu2 = mean(psi_j^2), sigma2 = Var(y), with the centered-IF convention.

    Returns an Array[SensitivityResult] of length quantiles.length(). Calling on an un-fit model aborts via PreconditionError (coefs.length() > 0 proxy, matching the convention used by DoubleMLQTE::bootstrap).

    DoubleMLQTE::sensitivity_analysis_cluster

    fn DoubleMLQTE::sensitivity_analysis_cluster(self : DoubleMLQTE, cluster_ids? : Array[Int], cf_y? : Double, cf_d? : Double) -> Array[SensitivityResult] raise

    v0.74.0+: cluster-robust analogue of DoubleMLQTE::sensitivity_analysis. The centered-IF formulation per quantile (sigma2 = Var(y), nu2 = mean(psi_j^2)) is replicated inside irm_style_sensitivity_cluster_multi by passing residuals = y - mean(y) (the same for every quantile) and psi_a = psi_j for each quantile. Only the variance / bias computation is cluster-aware. The cluster-summed sigma2_cluster / nu2_cluster are recomputed in each per-quantile call (they only depend on the cluster partition and the per-quantile psi_a row; the cluster partition is shared across quantiles, so the cluster sums only depend on the per-quantile psi_a row, but the multi helper recomputes the cluster sums per theta for clarity).

    cluster_ids defaults to DoubleMLData::cluster_vars. Returns an Array[SensitivityResult] of length quantiles.length(). Calling on an un-fit model aborts via PreconditionError.

    DoubleMLQTE::ses

    fn DoubleMLQTE::ses(self : DoubleMLQTE) -> Array[Double]

    DoubleMLQTE::set_sample_splitting

    fn DoubleMLQTE::set_sample_splitting(self : DoubleMLQTE, smpls : Array[Array[Fold]]) -> DoubleMLQTE

    Record a caller-supplied sample splitting and DERIVE the fold count from it. Returns a new DoubleMLQTE; the receiver is unchanged, as everywhere in this package.

    smpls is one entry per repetition, each entry one Fold per fold -- the nested form is the only form, so a single repetition is [f0, f1, f2] wrapped once, i.e. [[f0, f1, f2]].

    The derivation is upstream's, and it is the part that surprises people: set_sample_splitting (doubleml/double_ml_sampling_mixins.py:76) sets n_folds FROM the supplied partition, so handing a two-fold split to a model built with n_folds=5 yields a two-fold model. On the returned model n_folds is updated to match, and fit uses the supplied folds instead of drawing them.

    Validation, via check_sample_splitting: every repetition carries the same number of folds, and within each repetition the test index sets are disjoint and cover exactly [0, n_obs). A supplied partition that is not a partition aborts rather than quietly producing a cross-fit that leaks across folds.

    ONE partition serves the WHOLE model, not one per quantile. QTE produces one estimate per entry of quantiles, but each of those is a contrast of two solve_pq solves (treated arm d=1, control arm d=0), and all 2 * n_quantiles of them cross-fit on the SAME partition. smpls therefore tunes the nuisance cross-fit that the quantiles share; it is NOT a per-quantile knob, and there is no way to give quantile j its own folds.

    QTE has NO n_rep, so the repetition axis has nothing to drive: it is a single-fit estimator with no repetition loop, and fit consumes smpls[0] only. A multi-repetition partition is therefore accepted and validated in full, but repetitions 1.. are not used by fit. sample_splitting reads the repetition count back as supplied, so a caller who passed several can see that the count did not shrink to one.

    Memoization is turned OFF on the returned model regardless of memoize_enabled. The fit cache is keyed on data + learner + cluster hashes with no term for the fold assignment, so caching an externally-split fit could return a DIFFERENT partition's predictions. The fit cache is also cleared here, for the same reason.

    Cluster data: the supplied folds are ROW-level, and fit_cluster draws its own UNIT-level partition via kfold(n_units, ...) and ignores them. The external splitting applies to the row-level (IID) path only.

    The one-fold "no sample splitting" case needs no special handling: a single Fold whose train and test both cover everything is a valid partition with folds = 1, which is what upstream detects and reports.

    DoubleMLQTE::tune

    fn DoubleMLQTE::tune(self : DoubleMLQTE, param_set~ : Array[TuneParam], scoring_method? : String, n_folds_tune? : Int, seed? : Int) -> DoubleMLQTE

    v0.108.0+ (NEW): score a grid of nuisance-learner pairs and re-fit DoubleMLQTE with the winner, via the shared tune_score_grid core (fold draw + MSE/RMSE/NegMSE scoring + argmin all live there; this method only adapts the grid shape and calls fit). This closes a coverage gap against upstream, where tune reaches every estimator through the BaseDML mixin; PQ / QTE / RDD had no tune before v0.108.0.

    Slots tuned: BOTH ml_l and ml_m, the same pair as DoubleMLPQ::tune and for the same reason -- ml_l is the OUTCOME-side learner and ml_m is the PROPENSITY learner, the reverse of the APO ordering. Use TuneParam::for_outcome, not for_treatment.

    Only ml_l is SCORED; ml_m rides along as the co-varying slot. QTE fits one quantile per entry of self.quantiles, so a single winning pair is applied to ALL of them -- this method does not tune per quantile, and upstream does not either.

    Cluster guard: PRESENT, because DoubleMLData has is_cluster_data() and DoubleMLQTE::fit dispatches to fit_cluster.

    DoubleMLRDD

    pub struct DoubleMLRDD {
    data : DoubleMLRDDData
    cutoff : Double
    bandwidth : Double
    kernel : RDDKernel
    fuzzy : Bool
    cov_type : String
    ml_g : LearnerDispatch
    coef : Double
    se : Double
    n_local : Int
    residuals : Array[Double]
    psi_a : Array[Double]
    hc0_m : Array[Double]
    hc0_e : Array[Double]
    leverage : Array[Double]
    fitted : Bool
    boot_t_stat : Array[Double]
    boot_method : String
    n_rep_boot : Int
    boot_seed : Int
    memoize_enabled : Bool
    fit_cache : FitCache
    } derive(
    Debug
    )

    Local-polynomial RD estimator. The port uses a triangular kernel and a fixed bandwidth, which keeps the hot path pure MoonBit and deterministic.

    DoubleMLRDD::bandwidth_mse

    fn DoubleMLRDD::bandwidth_mse(self : DoubleMLRDD, h : Double, nnmatch? : Int, order? : Int) -> Double

    v0.115.0: order selects the polynomial order the criterion is built for. The variance term's exponent is 2 * (order + 1) -- the leading bias of a local polynomial of degree j is O(h^(j+1)), so its square is O(h^(2j+2)) -- and the pilot stage is refitted at the SAME order.

    order = 1 is the default and reproduces v0.110.0-v0.113.0 exactly, including the floating-point association: the power is built by repeatedly squaring b2 = b * b, so order = 1 still evaluates b2 * b2, not (b*b)*(b*b) written a different way.

    order = 2 is what rdrobust uses for the bias-correction fit (q, its default), and is what optimal_bias_bandwidth minimises. The two criteria differ ONLY in this exponent and in the order of the fits -- that is the whole reason h and b come out different numbers.

    DoubleMLRDD::bandwidth_sigma4

    fn DoubleMLRDD::bandwidth_sigma4(self : DoubleMLRDD, nnmatch? : Int, order? : Int) -> Double

    Sigma^4 of the MSE-optimal criterion, as nearest-neighbour matched residual differences -- rdrobust's vce = "nn" with nnmatch = 3, its default.

    Why this replaced a pooled sum of squares

    v0.110.0-v0.112.0 used sum of all squared residuals on both sides. That is NOT what Cattaneo et al. (2020) do: rdrobust pairs a left residual with its nnmatch NEAREST right-side neighbours by running-variable distance and squares the differences. Pooling discards the matching entirely, so it is a different estimator wearing the same name -- and it changes the number optimal_bandwidth returns.

    The rule, stated exactly

    For each left local-row observation i with running-variable offset u_i < 0, take the nnmatch right observations whose |u| are closest to |u_i|, and average the squared residual differences over those neighbours:

    sigma4 = (1 / N_left) * SUM_i mean_j (eps_right[j] - eps_left[i])^2

    with the inner mean taken over however many neighbours exist. Ties in |u| are broken by ascending row index, so the result is deterministic.

    What is a deliberate simplification, and what is not

    The one thing this does NOT do is shrink the left sample: rdrobust weights by a local-linear kernel over the matched pairs. Pooling by left observation count, as above, is a coarser rule with the same units (outcome^2) and the same b^4 scaling in the criterion, so it keeps the search's shape; it does not reproduce rdrobust's numbers to the digit. That is recorded rather than papered over.

    nnmatch is a parameter because a mutation that ignores it is otherwise indistinguishable from one that honours it -- see v110_sigma4_responds_to_nnmatch.

    Two neighbours short: the inner mean uses however many exist. If the right side is EMPTY there is nothing to match and the procedure is undefined; require fires and the caller sees an abort, which is the same outcome the pilot stage already had.

    DoubleMLRDD::bandwidth_variance_term

    fn DoubleMLRDD::bandwidth_variance_term(self : DoubleMLRDD, h : Double, nnmatch? : Int, order? : Int) -> Double

    v0.115.0: the VARIANCE half of bandwidth_mse, split out so the bandwidth-scaling exponent is checkable from outside.

    variance_term(h) = (h / h_pilot)^(2 * (order + 1)) * sigma4 / n

    bandwidth_mse calls THIS rather than computing it inline, so the criterion and its decomposition cannot drift -- the same reason bandwidth_sigma4 and the internal pilot share one implementation.

    Why the exponent needs its own gate

    2 * (order + 1) is the entire content of the difference between the h criterion and the b criterion: a local polynomial of degree j has leading bias O(h^(j+1)), so the j-order MSE carries a b^(2(j+1)) variance term. Hardcoding it to b^4 -- the order-1 value, which is what the code did before this parameter existed -- leaves every OTHER assertion in expand_v115_test.mbt green: the search still minimises whatever criterion it is given, the bias is still non-zero, b still differs from h, and the collapse identity still holds.

    Mutation M3 in _verify/mut_v115_rdd.ps1 is exactly that, and it SURVIVED before this function existed. Doubling the bandwidth must multiply the variance term by 2^(2(order+1)) -- 16 at order 1, 64 at order 2 -- and v115_variance_term_doubles_by_the_order_exponent is the assertion that says so.

    DoubleMLRDD::bias

    fn DoubleMLRDD::bias(self : DoubleMLRDD, b? : Double, rho? : Double, p? : Int, q? : Int) -> (Double, Double)

    v0.116.0 CORRECTION: this is rdrobust's bias, xi_p(h)_s - xi_bc_s, where xi_bc_s is the BIAS-CORRECTED limit from the Q sandwich -- NOT xi_q(b)_s.

    v0.115.0 shipped this as xi_p(h)_s - xi_q(b)_s and documented the difference as "rdrobust's bias". Cross-checking against the upstream Python port (_verify/gen_v116_oracle.py) shows the two agree to ~1e-15 when b == h and DIVERGE when b != h -- on the v0.116.0 oracle fixture, h = 0.30, b = 0.42, left side:

    quantityvalue
    xi_p(h)0.1327887
    xi_bc (rdrobust)0.0561884
    xi_q(b) (was shipped)0.0681351

    The default rho = 1 gives b == h, so the shipped default path is unchanged to 15 digits; the fix repairs the rho != 1 path, which is exactly where the second bandwidth is supposed to be doing work.

    The wrong version was not detectable by any v0.115.0 gate, because v0.115.0's own identity (tau_bc == tau_bc_collapsed) was only ever checked INTERNALLY -- both sides of it computed the same wrong number. An external oracle was required. That is now pinned by v116_tau_bc_matches_the_oracle_at_a_separate_bias_bandwidth.

    The b = h case, and the trap in it

    rdrobust makes rho = 1 the default when h is supplied without b (b = h / rho), so b == h is this port's DEFAULT, not an edge case. It is NOT a degenerate estimator: with b == h the bias is the difference between a local-LINEAR and a local-QUADRATIC fit at one bandwidth, which is a real, non-zero quantity.

    What degenerates is the BANDWIDTH SEARCH. b is supposed to be chosen by minimising the order-q MSE criterion; if b is pinned to h, the estimator is still correct but the second bandwidth is no longer doing the job it was selected for, and b := h inside optimal_bias_bandwidth becomes an EQUIVALENT MUTATION that no downstream assertion can see -- tau_bc would return xi_2(h)_r - xi_2(h)_l either way.

    v115_bias_bandwidth_order1_equals_the_point_bandwidth pins the mechanism (an order-1 bias bandwidth IS the point bandwidth), and v115_bias_bandwidth_differs_from_the_point_bandwidth pins that the default order-2 search does NOT return h, so the stage is actually being selected rather than defaulted.

    DoubleMLRDD::bootstrap

    fn DoubleMLRDD::bootstrap(self : DoubleMLRDD, method_name? : String, n_rep_boot? : Int, seed? : Int) -> DoubleMLRDD

    v0.75.0+: multiplier bootstrap for DoubleMLRDD. The per-observation influence function is

    psi[k] = psi_a[k] * residuals[k]

    (kernel-weighted Riesz row dotted with the local-polynomial residual). RDD is the kernel-weighted local-polynomial regression discontinuity estimator: the IF for the jump coefficient at the cutoff does not factor into the v0.61.0 psi_a + coef * psi_b form (it's a single combined IF), so this bootstrap calls did_bootstrap_t_stat directly rather than going through generic_bootstrap_t_stat.

    method_name selects the multiplier distribution: "normal" (default), "Bayes", "wild"; seed defaults to 2024; n_rep_boot defaults to 500.

    Calling on an un-fit model aborts via PreconditionError. Returns a new DoubleMLRDD with boot_t_stat / boot_method / n_rep_boot / boot_seed populated.

    DoubleMLRDD::clear_cache

    fn DoubleMLRDD::clear_cache(self : DoubleMLRDD) -> DoubleMLRDD

    v0.84.0+: drop the cached (coef, se, n_local, residuals,psi_a) tuple. After this, the next fit() will run the full local-polynomial pipeline (and repopulate the cache if memoize is still enabled).

    DoubleMLRDD::cluster_hac_se

    fn DoubleMLRDD::cluster_hac_se(self : DoubleMLRDD, cluster_ids : Array[Int]) -> Double

    v0.96.0+: cluster-robust (Arellano 1987, Cameron-Gelbach-Miller 2011) standard error for the local-regression discontinuity contrast -- the clustered analogue of hac_se(HC0).

    cluster_ids[k] is the 0-based cluster of the k-th bandwidth-restricted row (left side first then right side, the same ordering as leverage()), and cluster_ids.length() must equal n_local(). The number of clusters is 1 + max(cluster_ids).

    Per-observation score s_k = w_k * m_k * e_k (the RDD HC0 term under a square root, with its sign), aggregated within cluster:

    S_c = sum_{k in c} s_k var = sum_c S_c^2 * n_c / (n_c - 1)

    with the (n_c - 1) jackknife correction clipped to 1 for single-observation clusters (the statsmodelscov_type='cluster' convention, and the same clip cluster_sandwich_variance uses). There is NO 1 / n^2 divisor: the shared helper needs one because its moment is a MEAN moment, while this is a sum of squared scores over the rows of two separate regressions, exactly like hac_se(HC0).

    With all-singleton clusters the meat collapses to sum_k s_k^2, i.e. hac_se(HC0) -- to floating-point tolerance, not bit-exactly, for two reasons: the per-observation square is formed as s_k * s_k here against the w^2 m^2 e^2 association in hac_se, and this accumulator is Kahan-compensated (as in cluster_sandwich_variance) against hac_se's plain running sum. Pooled clusters differ.

    Same preconditions as hac_se: ABORTS on a non-OLS learner and on fuzzy = true.

    DoubleMLRDD::coef

    fn DoubleMLRDD::coef(self : DoubleMLRDD) -> Double

    DoubleMLRDD::confint

    fn DoubleMLRDD::confint(self : DoubleMLRDD) -> (Double, Double)

    DoubleMLRDD::disable_memoize

    fn DoubleMLRDD::disable_memoize(self : DoubleMLRDD) -> DoubleMLRDD

    v0.84.0+: turn off memoization. After this, fit() will not read or write the cache; the existing fit_cache is preserved on the returned struct (call clear_cache() to drop it).

    DoubleMLRDD::enable_memoize

    fn DoubleMLRDD::enable_memoize(self : DoubleMLRDD) -> DoubleMLRDD

    v0.84.0+: turn on memoization. When enabled, the next fit() caches the entire (coef, se, n_local, residuals,psi_a) tuple under a key that combines the data fingerprint, cutoff, bandwidth, fuzzy flag, cov_type, and learner fingerprint. Subsequent fit() calls with the same configuration skip all four rdd_side calls (the local- polynomial kernel-weighted fit on each side, twice for sharp + twice for fuzzy).

    DoubleMLRDD::fit

    DoubleMLRDD::hac_se

    fn DoubleMLRDD::hac_se(self : DoubleMLRDD, kind : SandwichKind) -> Double

    v0.96.0+: heteroskedasticity-consistent (Huber-White) standard error for the local-regression discontinuity contrast, in the HC0 / HC1 / HC2 / HC3 family.

    This is NOT sandwich_se. Read the section comment above ("WHY RDD DOES NOT REUSE sandwich_se") for the algebra: RDD's meat is sum_k w_k^2 * m_k^2 * e_k^2 with a full p1 x p1 WLS normal inverse and no 1 / n^2, the shared helper is a scalar-Jacobian MEAN-moment sandwich, and feeding RDD's influence function into the pre-v0.91.0 accumulator yields sum_k psi_a[k]^4 * e_k^2 (a fourth power) against RDD's second power. The SandwichKind enum is reused -- it is the package's shared vocabulary for the HC family -- but the arithmetic behind each variant is the local-regression one.

    The invariant that proves the wiring: hac_se(HC0) == se() BIT-IDENTICALLY on a cov_type = "HC0" fit. It is NOT claimed on a cov_type = "homoskedastic" fit (whose se() is the homoskedastic form), and it ABORTS on a non-OLS learner and on fuzzy = true.

    HC2 / HC3 divide by 1 - h_k per local row using the persisted WLS leverage (leverage()). If any h_k leaves [0, 1) the divisor would be non-positive, which means the local regression is rank-deficient (fewer local rows than parameters, or a collinear local design). This ABORTS rather than clipping: a clip is what silently broke identities in v0.93 / v0.94, and for a leverage in [0, 1) no correction should ever fire.

    DoubleMLRDD::has_cache

    fn DoubleMLRDD::has_cache(self : DoubleMLRDD) -> Bool

    v0.84.0+: true iff fit_cache holds at least one cached fit result (i.e. a previous fit() with memoize_enabled =true has populated the cache). Note that has_cache() does NOT verify the cache key matches the current data + learner configuration -- check memoize_enabled if you also need to know whether the next fit() will hit.

    DoubleMLRDD::kernel

    fn DoubleMLRDD::kernel(self : DoubleMLRDD) -> RDDKernel

    The kernel weighting the local polynomial. Read back with this rather than inferred from a fitted model, so a config that was built with an explicit kernel can be checked against what the model actually used.

    DoubleMLRDD::kernel_weights

    fn DoubleMLRDD::kernel_weights(self : DoubleMLRDD, h : Double) -> Array[Double]

    The per-observation kernel weights this model applies, on the bandwidth-restricted local sample: one entry per local row, left side first then right side, matching the ordering of residuals / psi_a / hc0_m / hc0_e.

    Public, and not only for curiosity. cluster_hac_se and sensitivity_analysis_cluster REBUILD these weights rather than reading a stored copy, so they had a second, independent path to the kernel. A test that only compares two cluster_hac_se values cannot tell which path supplied the weights -- both models' fit already stored kernel-dependent hc0_m / hc0_e, so the SE differs either way and the assertion holds even with the cluster path reverted to a hardcoded triangular kernel (mutation M2, which survived the first version of this gate). Reading the weights makes the second path directly observable.

    DoubleMLRDD::learner_g

    fn DoubleMLRDD::learner_g(self : DoubleMLRDD) -> LearnerDispatch

    Accessor for the local-polynomial learner used by the most recent fit(...) call. v0.60.0+. Note: RDD's bandwidth / kernel are configuration (not LearnerDispatch concepts); this slot plugs into the closed-form OLS underneath the kernel weights and is forward-compatible with v0.61.0+ kernel-aware wrappers.

    DoubleMLRDD::leverage

    fn DoubleMLRDD::leverage(self : DoubleMLRDD) -> Array[Double]

    v0.96.0+: the WLS hat-matrix diagonal of the per-side local polynomial, h_k = w_k * xa_k' * (X'WX + ridge I)^{-1} * xa_k with xa_k = (1, u_k, x_k), on the bandwidth-restricted sample (left side then right side, length n_local()).

    Every entry is in [0, 1): X^a (X'WX + ridge I)^{-1} X^{a'} is an idempotent matrix (a projection) plus a ridge-order perturbation, so its diagonal is a leverage. The entries sum to the RANK of the local design -- the trace of a projection matrix is its rank -- which is n_local_params() per side for a full-rank design and LESS for a degenerate one (a constant covariate column makes (1, u, 0) rank-deficient), up to the ridge term. expand_v096_test.mbt pins both: the full-rank trace, and the [0, 1) bound.

    Zero-filled (all 0.0) when the injected learner is not LinearRegression, matching the psi_a convention for that case.

    DoubleMLRDD::n_features

    fn DoubleMLRDD::n_features(self : DoubleMLRDD) -> Int

    Number of features (covariate columns).

    DoubleMLRDD::n_local

    fn DoubleMLRDD::n_local(self : DoubleMLRDD) -> Int

    DoubleMLRDD::n_local_params

    fn DoubleMLRDD::n_local_params(self : DoubleMLRDD) -> Int

    v0.96.0+: the number of coefficients the per-side local regression estimates, data.x.cols() + 2: the intercept that fit_weighted augments on, the running variable u that rdd_design puts in column 0, and the data.x.cols() covariates. This is the k in the HC1 finite-sample correction. The point estimate reads only beta[0] + beta[1] *u, but every column enters the WLS normal equations, so the fitted parameter count is p1, not 2.

    DoubleMLRDD::n_obs

    fn DoubleMLRDD::n_obs(self : DoubleMLRDD) -> Int

    Number of observations.

    DoubleMLRDD::new

    fn DoubleMLRDD::new(data : DoubleMLRDDData, cutoff? : Double, bandwidth? : Double, kernel? : RDDKernel, fuzzy? : Bool, cov_type? : String, ml_g? : LearnerDispatch) -> DoubleMLRDD

    DoubleMLRDD::optimal_bandwidth

    fn DoubleMLRDD::optimal_bandwidth(self : DoubleMLRDD) -> Double

    v0.110.0: MSE-optimal bandwidth (Cattaneo, Frandsen & Tchetgen 2020), the procedure rdrobust exposes through bwselect.

    Before this, bandwidth was a user-supplied constant and nothing chose it. That is a real gap rather than a design choice: rdrobust's headline contribution to the RDD literature is precisely that a plug-in bandwidth needs a data-driven default.

    Procedure, on a grid of dimensionless multipliers of a pilot bandwidth:

    1. h_pilot = silverman_bandwidth(data.score) -- the rule-of-thumb bandwidth of the RUNNING VARIABLE, not of the outcome. This is the scale the grid is expressed in, so the result is invariant to the units the running variable happens to be measured in.
    2. bandwidth_mse(h) is the criterion: (xi(h) - xi(h_pilot))^2 + b^4 * sigma4 / n, b = h / h_pilot The first term is the squared bias relative to the pilot, which is treated as approximately unbiased; the second is the O(b^4) variance growth of a local-polynomial derivative estimate.
    3. The minimiser over b in [0.5, 2.0] is returned, scaled back by h_pilot.

    The criterion is PUBLIC on purpose. Exposing it means a caller -- and the test suite -- can check that the returned bandwidth actually minimises it, instead of having to trust that the search did what its name says. Without that, "optimal_bandwidth" is an assertion rather than a checkable result.

    Two honest deviations from rdrobust, both forced by this port's shape rather than chosen:

    • the grid has 50 points, not rdrobust's 100;
    • Cattaneo's Sigma^4 is built from the DIFFERENCE of left and right residuals at paired observations, and this port's rdd_side returns two per-side residual arrays of UNEQUAL length, so pairing them is a separate design decision. The pooled sum of squares is used instead. Same units, same b^4 scaling, no pairing.

    This does NOT modify the estimator. It is a function you call to obtain a starting bandwidth; fit still uses self.bandwidth.

    DoubleMLRDD::optimal_bias_bandwidth

    fn DoubleMLRDD::optimal_bias_bandwidth(self : DoubleMLRDD, nnmatch? : Int, q? : Int) -> Double

    v0.115.0: rdrobust's SECOND bandwidth b, the one that drives bias correction. bwselect = "CCT" computes both h and b by default, and b is the one this port was missing: optimal_bandwidth picks the bandwidth of the point estimate, and nothing here had an opinion about the bandwidth at which to estimate the bias.

    The rule is the same grid search as optimal_bandwidth, on the ORDER-q criterion. That order is the entire difference: a local polynomial of degree j has leading bias O(h^(j+1)), so the j- order MSE criterion has a b^(2(j+1)) variance term and wants a different bandwidth than the p-order one.

    q = 2 is rdrobust's default and its p-order point estimate is local LINEAR (p = 1), so the two criteria being minimised here are genuinely different functions and b is not a rescaling of h.

    DoubleMLRDD::pilot_bandwidth

    fn DoubleMLRDD::pilot_bandwidth(self : DoubleMLRDD) -> Double

    The scale the bandwidth search is expressed in: the rule-of-thumb bandwidth of the RUNNING VARIABLE, never of the outcome.

    DoubleMLRDD::se

    fn DoubleMLRDD::se(self : DoubleMLRDD) -> Double

    DoubleMLRDD::sensitivity_analysis

    fn DoubleMLRDD::sensitivity_analysis(self : DoubleMLRDD, cf_y? : Double, cf_d? : Double) -> SensitivityResult raise

    v0.69.0+: sensitivity analysis for DoubleMLRDD.

    RDD is the kernel-weighted local-polynomial regression discontinuity estimator. The kernel-weighted precision matrix per side is M = (X^T W X + ridge I)^{-1} and the Riesz-representer row is psi_a[k] = M[0, :] @ xx[k, :] (intercept row of M dotted with the k-th local design row). The local polynomial residual is e_k = target_k - pred_k. The IRM-style decomposition (matching the v0.66.0+ sensitivity family) treats the concatenation residuals ++ psi_a (left side then right side) as a single IF row and applies irm_style_sensitivity to it. The result is a single SensitivityResult for the kernel-RDD jump estimate.

    Notes:
    • Fuzzy RDD uses c = raw / jump delta-method weighting that mixes Y-residuals and D-residuals; the simple irm_style_sensitivity decomposition assumes the single-theta score form (so the fuzzy-delta cross term cov(raw, jump) is captured by the v0.59.0- fuzzy variance fix, not by the sensitivity path). For sharp RDD the decomposition is exact.
    • cf_y / cf_d default to 0.05 matching the v0.66.0+ sensitivity family.

    Calling on an un-fit model aborts via PreconditionError. Returns a single SensitivityResult.

    DoubleMLRDD::sensitivity_analysis_cluster

    fn DoubleMLRDD::sensitivity_analysis_cluster(self : DoubleMLRDD, cluster_ids : Array[Int], cf_y? : Double, cf_d? : Double) -> SensitivityResult raise

    v0.78.0+: cluster-robust analogue of DoubleMLRDD::sensitivity_analysis. RDD's kernel-weighted Riesz-representer row and outcome residuals (already on the bandwidth-restricted sample, length n_local) are routed through the v0.78.0 irm_style_sensitivity_cluster kernel-weighted extension with the per-observation triangular kernel weights w[k] = 1 - |u_k| / h where u_k = score[ids[k]] - cutoff. The cluster sums become cluster_sum_resid[c] = sum_{k in c} w[k] * residuals[k] (and similarly for psi_a), so observations near the cutoff (high weight) drive the cluster-aggregated variance baseline. The per-observation C&H centering residuals[k]^2 - sigma2_cluster keeps the bias expression per-observation-anchored (see the helper's v0.78.0 doc).

    DoubleMLRDDData has no cluster_vars field, so the user must pass cluster_ids explicitly. cluster_ids.length() must equal self.n_local(). Cluster indices are 0-based; 1 + max(cluster_ids) is the number of clusters. Returns the same SensitivityResult shape as the IID sensitivity_analysis so callers can swap IID for cluster-aware without changing the return contract.

    Calling on an un-fit model aborts via PreconditionError. n_local == 0 (no observations fall inside the bandwidth) raises on the inner irm_style_sensitivity_cluster's require(n > 0).

    DoubleMLRDD::tau_bc

    fn DoubleMLRDD::tau_bc(self : DoubleMLRDD, b? : Double, rho? : Double, p? : Int, q? : Int) -> Double

    v0.115.0: the bias-CORRECTED point estimate -- procedure (ii) of the three rdrobust(all = TRUE) reports, and the headline estimator once the bias correction is on.

    tau_bc = (tau_cl_r - bias_r) - (tau_cl_l - bias_l), i.e. the conventional estimate minus the estimated bias, computed in rdrobust's order.

    Why the method is named tau_bc and not bias_corrected_coef

    bias_corrected_coef already exists on 15 other estimators in this package, where it means the SANDWICH bias correction (Wald / delta method) -- a completely different operation on a completely different scale. Naming this one the same would have put two unrelated meanings behind one method name across the same type.

    tau_bc is rdrobust's own field name for this quantity ("bias- corrected local-polynomial estimate to the left and to the right of the cutoff"), so the rename costs nothing in fidelity and removes the collision.

    It does NOT collapse to a single fit -- v0.116.0 CORRECTION

    v0.115.0's docs claimed, and two v0.115.0 gates asserted, that

    tau_bc_s = xi_p(h)_s - [xi_p(h)_s - xi_q(b)_s] = xi_q(b)_s

    so that tau_bc and tau_bc_collapsed are the same number. The substitution is arithmetically valid, but the SECOND equality rests on bias being xi_p(h)_s - xi_q(b)_s, and that is rdrobust's bias only when b == h. Checked against the upstream Python port at h = 0.30, b = 0.42:

    quantityupstream rdrobustthis port (v0.115.0)
    xi_bc, left0.05618843170.0681350859
    tau_bc0.70923138680.6910832334

    v0.115.0 could not catch this on its own: its identity gate compared two internal routes to the SAME wrong number, so it was green by construction. See bias for the correction and v116_tau_bc_matches_the_oracle_at_a_separate_bias_bandwidth for the gate that does catch it.

    tau_bc_collapsed (xi_q(b)_r - xi_q(b)_l) is still a real and useful quantity -- it is the order-q fit at the bias bandwidth, and it is what rdrobust's bias-corrected limit equals when b == h -- so it stays, with its own docs corrected.

    How it is evaluated, and why not "the long way"

    tau_bc = xi_bc_r - xi_bc_l, with xi_bc_s the per-side bias-corrected limit -- rdrobust.py:907's own beta_bc = beta_bc_r - beta_bc_l.

    The algebraically equivalent tau_cl - (bias_r - bias_l) is what v0.115.0 computed, and it is what this port used to describe itself as doing. Both are the same number in exact arithmetic, but the long way subtracts two xi_p(h) terms that then cancel, so it carries the relative error of xi_p(h) into a difference of limits. Measured on the expand_v115_test.mbt fixture at b == h, the long way sits 8.5e-8 (relative) away from the direct difference; the direct one has no such amplification. The long way is kept out for the same reason upstream never writes it that way.

    Coverage

    This is the point estimate only. The default coef() / se() are UNCHANGED -- bias correction is opt-in. For the procedure (iii) standard error see tau_bc_se_rb.

    DoubleMLRDD::tau_bc_collapsed

    fn DoubleMLRDD::tau_bc_collapsed(self : DoubleMLRDD, b? : Double, rho? : Double, q? : Int) -> Double

    v0.115.0: the bias-corrected estimate expressed as one order-q fit per side at bandwidth b -- xi_q(b)_r - xi_q(b)_l.

    v0.116.0: this is NOT always tau_bc

    v0.115.0's docs claimed this and tau_bc were the same number. They agree to ~1e-15 when b == h and DIVERGE otherwise: xi_q(b) is the order-q fit at b, whereas tau_bc uses rdrobust's Q-sandwich limit, and the two coincide only when the point and bias bandwidths are the same number. Read tau_bc's docs for the measured discrepancy.

    Kept because the quantity is meaningful in its own right and because the identity at b == h is a genuine, checkable structural fact that v116_tau_bc_equals_the_collapsed_form_when_b_equals_h pins.

    Public so the comparison can be made from outside the file rather than re-derived inside a test.

    DoubleMLRDD::tau_bc_se

    fn DoubleMLRDD::tau_bc_se(self : DoubleMLRDD, b? : Double, rho? : Double, q? : Int) -> Double

    v0.115.0: standard error of the bias-corrected estimate, under this port's existing cov_type convention, applied to the order-q fits at b.

    What this is NOT

    rdrobust's procedure (iii) is "bias-corrected estimates with ROBUST standard errors", and its default robust estimator is vce = "nn" -- nearest-neighbour matched residuals, the SAME matching rule bandwidth_sigma4 implements for the CRITERION. That variance estimator is not implemented here. This returns the homoskedastic or HC0 variance the package already computes, on the q-order fits.

    So the variance inflation that makes CCT intervals achieve nominal coverage is NOT fully reproduced yet: this is a standard error for the bias-corrected estimator, not rdrobust's robust one. Shipping it as if it were procedure (iii) is the v0.113 mistake again -- a different estimator wearing the same name.

    v0.116.0: superseded for procedure (iii) -- kept for procedure (ii)

    tau_bc_se_rb is rdrobust's procedure (iii) standard error, and it pairs with tau_bc. This function does not: it is the homoskedastic / HC0 variance of the order-q fits at b treated as independent regressions, which is a standard error for the ORDER-q ESTIMATOR -- i.e. for tau_bc_collapsed -- rather than for tau_bc.

    v0.115.0 shipped this next to tau_bc and said in these docs that rdrobust's robust variance "is not implemented here". That was true then and is false now; the reason the function is kept rather than deleted is that the order-q fit still needs an honest standard error, and there was no name for it before.

    Its numbers are unchanged. What changed is that tau_bc moved -- see that method's v0.116.0 correction -- so the pairing documented in v0.115.0 no longer holds.

    DoubleMLRDD::tau_bc_se_rb

    fn DoubleMLRDD::tau_bc_se_rb(self : DoubleMLRDD, b? : Double, rho? : Double, q? : Int, vce? : String, nnmatch? : Int) -> Double

    v0.116.0: rdrobust's PROCEDURE (iii) standard error -- the bias-corrected point estimate tau_bc with the robust variance.

    This is the round that completes CCT inference on this port. tau_bc shipped in v0.115.0 with tau_bc_se alongside it, and that tau_bc_se is NOT this: it is the homoskedastic / HC0 variance of the order-q fits at b treated as independent regressions. Upstream's procedure (iii) is a different sandwich, and under vce = "nn" a different residual again. Two changes are needed, not one:

    1. The meat matrix. Upstream does not sandwich the q-order fit. It sandwiches Q, the p-order fit MINUS its estimated bias term (rdrobust.py:856-863). The bread is Gp^-1 at bandwidth h even though the estimate is a q-order quantity at bandwidth b.
    2. The residual. For vce = "nn" this is the fit-free nearest-neighbour residual, not a fitted residual at all.

    Point estimate

    tau_bc(b, rho, q) -- the argument of this function -- is the one whose standard error is returned. rho gives b = h / rho, matching rdrobust's convention, so the default rho = 1 means b == h.

    vce

    • "nn" (default) -- nearest-neighbour matched residuals. This is rdrobust's own default and the estimator CCT inference is built on: it is the only one that needs no homoskedasticity assumption, which is why the coverage it delivers is the one the literature reports.
    • "hc0" -- plug-in residuals of the q-order fit at b. Exposed because it shares this function's entire Q construction, so it turns the oracle cross-check into two independent ones: a fault in Q would have to reproduce itself in both to survive.

    An unrecognised vce aborts with the accepted set rather than silently falling back, for the reason RDDKernel::parse does.

    Sharp designs only

    As with tau_bc, a fuzzy first stage is not threaded through here; upstream's s_Y delta-method terms (rdrobust.py:923-939) are a separate block.

    Ports rdrobust.py:1055-1060 (residual source), 850-863 (Q, m, invG_p) and 1092-1097 (the meat and bread).

    DoubleMLRDD::tune

    fn DoubleMLRDD::tune(self : DoubleMLRDD, param_set~ : Array[LearnerDispatch], scoring_method? : String, n_folds_tune? : Int, seed? : Int) -> DoubleMLRDD

    v0.108.0+ (NEW): score a grid of candidates for RDD's ONE nuisance slot and re-fit DoubleMLRDD with the winner, via the shared tune_score_grid core. This closes a coverage gap against upstream, where tune reaches every estimator through the BaseDML mixin; PQ / QTE / RDD had no tune before v0.108.0.

    Slot tuned: ml_g, and ONLY ml_g. RDD has a single nuisance slot, so param_set is a bare Array[LearnerDispatch] -- the same shape DoubleMLPLIV::tune uses; a TuneParam would be a two-slot wrapper around one value.

    DO NOT "fix" ml_g into a propensity / treatment slot. Despite the g in the name (it is inherited from the field name, not from any role), RDD's ml_g is the DISCONTINUITY-SIDE, OUTCOME-side learner for E[y|x] -- the local polynomial fit on each side of the cutoff under the triangular kernel (see the ml_g struct-field doc and rdd_side). It is NOT E[g|d,x]. The TuneParam slot-semantics table in tune.mbt lists RDD's first slot as the discontinuity-side learner E[y|x] for exactly this reason.

    Structural config is NOT re-derived here: cutoff, bandwidth, fuzzy and cov_type are already carried by the model being tuned, and DoubleMLRDD::fit reads them off self (it copies self.cutoff / self.bandwidth / self.fuzzy / self.cov_type straight onto the returned struct) and threads them into every rdd_side call. tune passes only ml_g, so tuning the learner cannot perturb the design.

    Cluster guard: ABSENT, deliberately. DoubleMLRDDData has NO is_cluster_data() method -- RDD's data type is not the cluster-aware DoubleMLData, and adding the method (or the guard) would mean inventing a cluster concept for a data type that never had one. RDD's dependence is handled through the bandwidth-restricted local sample, not cluster ids. Cluster inference lives in the separate explicit DoubleMLRDD::cluster_hac_se / sensitivity_analysis_cluster entry points.

    DoubleMLRDDData

    pub struct DoubleMLRDDData {
    x : Matrix
    y : Array[Double]
    d : Array[Double]
    score : Array[Double]
    } derive(
    Debug
    )

    Data for a sharp or fuzzy regression-discontinuity design.

    DoubleMLRDDData::n_obs

    fn DoubleMLRDDData::n_obs(self : DoubleMLRDDData) -> Int

    DoubleMLRDDData::new

    fn DoubleMLRDDData::new(x : Matrix, y : Array[Double], d : Array[Double], score : Array[Double]) -> DoubleMLRDDData

    DoubleMLSSM

    pub struct DoubleMLSSM {
    data : DoubleMLSSMData
    n_folds : Int
    n_rep : Int
    seed : Int
    smpls : Array[Array[Fold]]
    propensity_clip : Double
    ps_processor_config : PSProcessorConfig
    ml_g : LearnerDispatch
    ml_m : LearnerDispatch
    pi_hat : Array[Double]
    m_hat : Array[Double]
    g_d1 : Array[Double]
    g_d0 : Array[Double]
    coef : Double
    se : Double
    fitted : Bool
    psi_a : Array[Double]
    psi_b : Array[Double]
    boot_t_stat : Array[Double]
    boot_method : String
    n_rep_boot : Int
    boot_seed : Int
    memoize_enabled : Bool
    fit_cache : FitCache
    } derive(
    Debug
    )

    Double / debiased machine learning estimator for the Sample Selection Model (SSM) of Bia, Huber and Laffers (2023), under the Missing At Random (MAR) score with normalize_ipw = False.

    The model is

    Y = theta * D + X @ beta * D + U, E[U | D, X] = 0 S = 1{D + gamma Z + X @ beta + V > 0}, E[V | X, D] = 0

    Y is observed only when S = 1. The four cross-fitted nuisances:

    g_d1(X) = E[Y | D = 1, S = 1, X] (trained on D=1 ∧ S=1, features = X only — Bug #1 fix: previously `pi_hat` was appended as an extra feature, which leaked the test-fold pi into the training fold) g_d0(X) = E[Y | D = 0, S = 1, X] (trained on D=0 ∧ S=1, features = X only) m(X) = P(D = 1 | X) (trained on all obs, clipped to [eps, 1 - eps]) pi(X, D) = P(S = 1 | D, X) (trained on (X, D), clipped to [eps, 1 - eps])

    Score (un-normalized IPW):

    psi_a = -1 psi_b1 = (D == 1) * S * (Y - g_d1) / (m * pi) + g_d1 psi_b0 = (D == 0) * S * (Y - g_d0) / ((1 - m) * pi) + g_d0 psi_b = psi_b1 - psi_b0

    Point estimate and variance

    theta_hat = -mean(psi_b) / mean(psi_a) = mean(psi_b) J = mean(psi_a) = -1 gamma = mean(psi(theta_hat)^2) sigma2 = gamma / (J^2 * n) se = sqrt(sigma2).

    Note: only the MAR case is implemented; normalize_ipw = True and the nonignorable-nonresponse case are out of scope.

    DoubleMLSSM::bias_corrected_coef

    fn DoubleMLSSM::bias_corrected_coef(self : DoubleMLSSM) -> Double

    v0.91.0+: returns coef UNCHANGED -- a documented no-op, not a bias correction.

    coef is the root of the DML moment f(theta) = E[theta * psi_a + psi_b] (see var_est.mbt), so mean(f(coef)) is identically zero: the estimating function is orthogonal by construction, and that orthogonality IS what makes the estimator consistent. Nothing computable from the fitted scores is a bias estimate for this class of estimator, so this accessor reports the uncorrected point estimate rather than a number that merely looks like a correction.

    (SSM's psi_a is the constant -1.)

    v0.79.0 - v0.90.0 returned coef + mean(psi_b - coef * psi_a). That vector is the score at -coef, NOT at coef; since coef = -mean_b / mean_a its mean is mean_b - coef * mean_a = -2 * coef * mean_a, so the accessor returned coef * (1 - 2 * mean(psi_a)) (exactly 3 * coef when mean(psi_a) = -1). That is not a bias estimate. See bias_corrected_theta in sandwich.mbt for the algebra. The method is kept so the API surface stays stable; removing it outright is the obvious follow-up.

    Preconditions: self.fitted.

    DoubleMLSSM::bootstrap

    fn DoubleMLSSM::bootstrap(self : DoubleMLSSM, method_name? : String, n_rep_boot? : Int, seed? : Int) -> DoubleMLSSM

    v0.64.0+: multiplier bootstrap for DoubleMLSSM. The per-observation influence function is

    psi[i] = theta * psi_a[i] + psi_b[i]

    where psi_a[i] = -1 (constant) and psi_b[i] is the MAR-score element defined in the file header. Routes through the shared generic_bootstrap_t_stat helper (see bootstrap_helper.mbt).

    method_name selects the multiplier distribution: "normal" (default), "Bayes", "wild". seed defaults to 2024; n_rep_boot defaults to 500.

    Calling on an un-fit model aborts via PreconditionError.

    DoubleMLSSM::clear_cache

    fn DoubleMLSSM::clear_cache(self : DoubleMLSSM) -> DoubleMLSSM

    v0.83.0+: drop any cached nuisance predictions and fold assignment. Forces the next fit() to recompute from scratch.

    DoubleMLSSM::cluster_sandwich_se

    fn DoubleMLSSM::cluster_sandwich_se(self : DoubleMLSSM, cluster_ids : Array[Int]) -> Double

    v0.87.0+: cluster-robust sandwich standard error for the fitted SSM. Routes through cluster_sandwich_variance with the same psi_a / psi / M_inv inputs as the IID sandwich_se path. DoubleMLSSMData carries no cluster_vars, so the caller must supply cluster_ids explicitly (typically the survey / site / household id).

    Preconditions: self.fitted, cluster_ids.length() == psi_a.length().

    DoubleMLSSM::coef

    fn DoubleMLSSM::coef(self : DoubleMLSSM) -> Double

    DoubleMLSSM::confint

    fn DoubleMLSSM::confint(self : DoubleMLSSM) -> (Double, Double)

    DoubleMLSSM::disable_memoize

    fn DoubleMLSSM::disable_memoize(self : DoubleMLSSM) -> DoubleMLSSM

    v0.83.0+: turn off memoization. Same immutability contract as enable_memoize(). After this, fit() will not read or write the cache; the existing fit_cache is preserved on the returned struct (call clear_cache() to drop it).

    DoubleMLSSM::enable_memoize

    fn DoubleMLSSM::enable_memoize(self : DoubleMLSSM) -> DoubleMLSSM

    v0.83.0+: turn on memoization for subsequent fit() calls. When enabled, fit() will cache the LAST rep's per-fold nuisance predictions (pi_hat, m_hat, g_d1, g_d0) and the fold assignment; on a repeat call whose data + learner fingerprint + fold split is identical, the entire per-fold crossfit is skipped and the cached values feed the psi_a / psi_b / coef / se pipeline.

    enable_memoize() is honored only when n_rep == 1; for n_rep > 1 the per-rep cross-fit must run each time (the cache would otherwise need to thread n_rep independent per-rep nuisance arrays, which the FitCache layout does not accommodate).

    Default is OFF. When OFF, every fit() call runs the full cross-fit and the cache is neither read nor written, so v0.82.0 callers see byte-identical output.

    DoubleMLSSM::fit

    fn DoubleMLSSM::fit(self : DoubleMLSSM, ml_g? : LearnerDispatch, ml_m? : LearnerDispatch, ml_pi? : LearnerDispatch) -> DoubleMLSSM

    Run the SSM estimation.

    DoubleMLSSM::has_cache

    fn DoubleMLSSM::has_cache(self : DoubleMLSSM) -> Bool

    v0.83.0+: true iff fit_cache holds at least one cached observation (i.e. at least one prior fit() call with memoize_enabled = true has populated the cache). Note that the cache may still be stale relative to the current data + learner configuration -- check memoize_enabled before assuming a cache hit.

    DoubleMLSSM::learner_g

    fn DoubleMLSSM::learner_g(self : DoubleMLSSM) -> LearnerDispatch

    v0.153.0: the outcome learner (ml_g) recorded at construction and used by fit when no explicit argument is given. Exposes the pair that v0.60.0 stored but kept private; ml_m is the classifier migrated in v0.138.0.

    DoubleMLSSM::learner_m

    fn DoubleMLSSM::learner_m(self : DoubleMLSSM) -> LearnerDispatch

    v0.153.0: the propensity / selection learner (ml_m) recorded at construction. This is the slot whose default moved from OLS to logistic in v0.138.0 -- the one measured hazard in the package. See learner_g.

    DoubleMLSSM::n_obs

    fn DoubleMLSSM::n_obs(self : DoubleMLSSM) -> Int

    DoubleMLSSM::new

    fn DoubleMLSSM::new(data : DoubleMLSSMData, n_folds? : Int, n_rep? : Int, seed? : Int, propensity_clip? : Double, ps_processor_config? : PSProcessorConfig?, ml_g? : LearnerDispatch, ml_m? : LearnerDispatch) -> DoubleMLSSM

    DoubleMLSSM::predictions_g_d0

    fn DoubleMLSSM::predictions_g_d0(self : DoubleMLSSM) -> Array[Double]

    DoubleMLSSM::predictions_g_d1

    fn DoubleMLSSM::predictions_g_d1(self : DoubleMLSSM) -> Array[Double]

    DoubleMLSSM::predictions_m

    fn DoubleMLSSM::predictions_m(self : DoubleMLSSM) -> Array[Double]

    DoubleMLSSM::predictions_pi

    fn DoubleMLSSM::predictions_pi(self : DoubleMLSSM) -> Array[Double]

    DoubleMLSSM::psi_a

    fn DoubleMLSSM::psi_a(self : DoubleMLSSM) -> Array[Double]

    v0.64.0+: per-observation IF accessors.

    DoubleMLSSM::psi_b

    fn DoubleMLSSM::psi_b(self : DoubleMLSSM) -> Array[Double]

    DoubleMLSSM::sample_splitting

    fn DoubleMLSSM::sample_splitting(self : DoubleMLSSM) -> SampleSplitting?

    The sample splitting recorded by set_sample_splitting, or None when none was supplied and fit draws its own. Some carries the DERIVED counts next to the folds, so a caller can read back what the estimator actually used rather than inferring it from the constructor arguments it no longer has any say over.

    DoubleMLSSM::sandwich_se

    fn DoubleMLSSM::sandwich_se(self : DoubleMLSSM, kind : SandwichKind) -> Double

    v0.87.0+: Huber-White sandwich standard error for the fitted SSM. Returns sqrt(var) where var comes from the shared sandwich_variance(kind, ...) dispatch in sandwich.mbt (HC0 / HC1 / HC2 / HC3), the same dispatch DoubleMLDID / DoubleMLIIVM / DoubleMLCVAR use.

    The three inputs are the ones fit(...) already persists for the multiplier bootstrap (see the psi_a / psi_b field docs on DoubleMLSSM):
    • psi_a = self.psi_a -- the constant MAR-score treatment term -1, so mean(psi_a) = -1 by construction and M_inv = -1.
    • psi = psi_at(self.coef, self.psi_a, self.psi_b) -- the per-observation influence function at the fitted coef, matching the psi[i] = psi_a[i] +theta * psi_b[i] form documented on DoubleMLSSM::bootstrap.
    • M_inv = [[1 / mean(psi_a)]] (1x1). Only M_inv[0, 0]^2 enters the variance, so the sign of the (up-to-sign) Jacobian inverse is irrelevant.

    The variance's n_obs is self.psi_a.length() (the length fit(...) persists, equal to self.n_obs()).

    Preconditions: self.fitted, mean(psi_a) != 0.

    DoubleMLSSM::se

    fn DoubleMLSSM::se(self : DoubleMLSSM) -> Double

    DoubleMLSSM::sensitivity_analysis

    fn DoubleMLSSM::sensitivity_analysis(self : DoubleMLSSM, cf_y? : Double, cf_d? : Double) -> SensitivityResult raise

    v0.68.0+: Cinelli & Hazlett (2020) omitted-variable bias analysis for DoubleMLSSM. Outcome residual is y - g_d1_hat (the selection-on-treated regression at the cross-fitted propensity / outcome nuisances); the Riesz-representer variance is mean(psi_a^2) = 1 for the SSM MAR IPW score (psi_a = -1 constant). Routes through the shared irm_style_sensitivity helper.

    DoubleMLSSM::sensitivity_analysis_cluster

    fn DoubleMLSSM::sensitivity_analysis_cluster(self : DoubleMLSSM, cluster_ids : Array[Int], cf_y? : Double, cf_d? : Double) -> SensitivityResult raise

    v0.74.0+: cluster-robust analogue of DoubleMLSSM::sensitivity_analysis. Same outcome residual formula (y - g_d1_hat) and same psi_a as the IID path; only the variance / bias computation is cluster-aware (sigma2_cluster and nu2_cluster are the G / n_clusters sums of squared cluster sums).

    DoubleMLSSMData has no cluster_vars field, so the user must pass cluster_ids explicitly. cluster_ids.length() must equal self.data.n_obs(). Cluster indices are 0-based; 1 + max(cluster_ids) is the number of clusters.

    DoubleMLSSM::set_sample_splitting

    fn DoubleMLSSM::set_sample_splitting(self : DoubleMLSSM, smpls : Array[Array[Fold]]) -> DoubleMLSSM

    Record a caller-supplied sample splitting and DERIVE the fold and repetition counts from it. Returns a new DoubleMLSSM; the receiver is unchanged, as everywhere in this package.

    smpls is one entry per repetition, each entry one Fold per fold -- the nested form is the only form, so a single repetition is [f0, f1, f2] wrapped once, i.e. [[f0, f1, f2]].

    The derivation is upstream's, and it is the part that surprises people: set_sample_splitting (doubleml/double_ml_sampling_mixins.py:76) sets n_folds and n_rep FROM the supplied partition, so handing a two-fold split to a model built with n_folds=5 yields a two-fold model. On the returned model n_folds / n_rep are updated to match, and fit uses the supplied folds instead of drawing them.

    Validation, via check_sample_splitting: every repetition carries the same number of folds, and within each repetition the test index sets are disjoint and cover exactly [0, n_obs). A supplied partition that is not a partition aborts rather than quietly producing a cross-fit that leaks across folds.

    Memoization is turned OFF on the returned model regardless of memoize_enabled. The fit cache is keyed on data + learner + cluster hashes with no term for the fold assignment, so caching an externally-split fit could return a DIFFERENT partition's predictions. The fit cache is also cleared here, for the same reason.

    The splitting is carried onto the fitted model, so the re-fit that tune performs at the end runs under these folds too - that re-fit is the FINAL-FIT cross-fit, and the tune-time folds are discarded either way.

    Note that a subsequent fit() on the returned model rebuilds boot_t_stat / n_rep_boot from scratch, so a bootstrap computed before set_sample_splitting is dropped by that fit. That is the package-wide convention, not something this method introduces; call bootstrap(...) again on the fitted model if you need it.

    DoubleMLSSM::tune

    fn DoubleMLSSM::tune(self : DoubleMLSSM, param_set~ : Array[TuneParam], scoring_method? : String, n_folds_tune? : Int, seed? : Int) -> DoubleMLSSM

    Tune the (ml_g, ml_m) nuisance-learner pair via MSE-on-g_hat cross-fitting. The winning pair is re-fit on the FINAL-FIT fold partition (self.n_folds) under DoubleMLSSM::fit; the tune-time cross-fit is not reused. scoring_method is "MSE" (default), "RMSE" or "NegMSE"; n_folds_tune defaults to 5 and seed to 3141. Returns a re-fitted DoubleMLSSM.

    Slot semantics: SSM is a propensity-first family, so the FIRST TuneParam slot is the treatment/propensity learner and the second is the outcome learner. Build the grid with TuneParam::for_treatment so the call site carries the role rather than relying on the field name learner_l.

    v0.108.0: new. SSM had no tune at all, so it was one of the estimators carrying a coverage gap against upstream, where tune reaches every estimator through the BaseDML mixin. The fold draw, the scoring loop and the argmin/argmax selection route through the shared tune_score_grid core, the same core PLR / PLIV / IRM / IIVM / APO use. No second copy of the selection rule lives here; that duplication is what previously let an inverted argmin ship with the suite green.

    The third fit slot, ml_pi (the selection propensity P(S = 1 | D, X)), is NOT tuned: TuneParam carries exactly two learners, so the grid cannot reach it. This differs from IIVM, which carries its third slot (ml_r) over from the model being tuned. SSM has no ml_pi field on the struct at all -- fit declares ml_pi? : LearnerDispatch = self.ml_m -- so there is no self.ml_pi to forward, and this method passes no ml_pi, letting fit apply its own default. Consequence worth stating plainly: the re-fit uses the PRE-TUNE self.ml_m for the selection propensity, not the winning candidate. The grid tunes (g, m); pi is whatever the model being tuned already had.

    No cluster guard, and that is not an oversight carried over from APO (the pre-v0.108.0 asymmetry). DoubleMLSSMData carries no cluster_vars field and exposes no is_cluster_data(), so there is no clustered path for a row-wise tune schedule to silently mis-handle. The method is not adding one, for the same reason it does not add the guard: the data type has no cluster concept to guard.

    DoubleMLSSMData

    pub struct DoubleMLSSMData {
    x : Matrix
    y : Array[Double]
    d : Array[Double]
    s : Array[Double]
    } derive(
    Debug
    )

    Data container for DoubleMLSSM. Adds a binary selection indicator s on top of the usual x/y/d. The outcome y is observed only when s = 1; for s = 0 the y entry should be ignored.

    DoubleMLSSMData::n_features

    fn DoubleMLSSMData::n_features(self : DoubleMLSSMData) -> Int

    DoubleMLSSMData::n_obs

    fn DoubleMLSSMData::n_obs(self : DoubleMLSSMData) -> Int

    DoubleMLSSMData::new

    fn DoubleMLSSMData::new(x : Matrix, y : Array[Double], d : Array[Double], s : Array[Double]) -> DoubleMLSSMData

    ElasticNet

    pub struct ElasticNet {
    alpha : Double
    l1_ratio : Double
    tol : Double
    max_iter : Int
    coef_ : Array[Double]
    intercept_ : Double
    n_iter_ : Int
    n_features_ : Int
    fitted : Bool
    } derive(
    Debug
    )

    ElasticNet, the convex blend of Lasso and Ridge: minimises

    (1 / 2n) ||y - Xw||^2
    • alpha * l1_ratio * ||w||_1
    • alpha * (1 - l1_ratio) / 2 * ||w||^2

    with an unpenalised intercept obtained by centring. l1_ratio = 1 recovers Lasso exactly (that identity is a gate, not a comment); l1_ratio = 0 recovers Ridge(alpha * sum(sample_weight)) -- note the sum(sample_weight) factor, which reduces to n unweighted and is the 1/(2n)-versus-raw normalisation difference between the two families.

    Defaults match scikit-learn: alpha = 1.0, l1_ratio = 0.5, tol = 1e-4, max_iter = 1000.

    ElasticNet::coefficients

    fn ElasticNet::coefficients(self : ElasticNet) -> Array[Double]

    The fitted slopes, length n_features(); the intercept is separate.

    ElasticNet::fit

    fn ElasticNet::fit(self : ElasticNet, x : Matrix, y : Array[Double]) -> ElasticNet

    Fit on (x, y) with no sample weights.

    ElasticNet::fit_weighted

    fn ElasticNet::fit_weighted(self : ElasticNet, x : Matrix, y : Array[Double], w : Array[Double]) -> ElasticNet

    Weighted fit; see Lasso::fit_weighted for the mean-1 renormalisation.

    ElasticNet::intercept

    fn ElasticNet::intercept(self : ElasticNet) -> Double

    The fitted (unpenalised) intercept.

    ElasticNet::iterations

    fn ElasticNet::iterations(self : ElasticNet) -> Int

    Number of coordinate-descent sweeps performed by the last fit.

    ElasticNet::n_features

    fn ElasticNet::n_features(self : ElasticNet) -> Int

    Number of features seen at fit time (excluding the intercept).

    ElasticNet::new

    fn ElasticNet::new(alpha? : Double, l1_ratio? : Double, tol? : Double, max_iter? : Int) -> ElasticNet

    Build an un-fitted ElasticNet.

    ElasticNet::predict

    fn ElasticNet::predict(self : ElasticNet, x : Matrix) -> Array[Double]

    Predict x . beta + intercept for every row of x.

    FitCache

    pub struct FitCache {
    fold_ids : Array[Int]
    predictions : Array[Array[Double]]
    fold_split_seed : Int
    n_folds : Int
    n_rep : Int
    n_obs : Int
    data_hash : UInt64
    hyperparams_hash : UInt64
    cluster_ids_hash : UInt64
    estimator_kind : String
    } derive(
    Debug
    )

    Memoization container for a single DML fit() call.

    Stored on the estimator struct (e.g. DoubleMLIRM::fit_cache). predictions is a list of per-observation nuisance prediction arrays whose meaning depends on the estimator kind:

    • "irm": [g0_hat, g1_hat, m_hat, m_raw]
    • "plr": [g_hat, m_hat]
    • "iivm": [g0_hat, g1_hat, m_hat, r0_hat, r1_hat]
    • "pliv": [g_hat, m_hat]
    • "did" / "did_cs": [g_pre, g_post, m_pre, m_post]
    • "lpq" / "cvar" / "ssm": per-estimator nuisance arrays

    fold_ids[i] is the test-fold index of observation i in the LAST repetition (the one whose nuisances are stored).

    cluster_ids_hash (v0.82.0+) is the hash of the cluster_vars vector (or 0 for the IID path). The is_valid(...) check requires the stored hash to match the caller's hash, so a switch from IID to clustered (or a different cluster partition) invalidates the cache.

    FitCache::empty

    fn FitCache::empty() -> FitCache

    Empty cache sentinel. fold_ids.length() == 0 is the "no cache" marker; is_valid(...) returns false against this empty cache because the per-call n_obs / n_folds / hashes can never simultaneously match 0.

    FitCache::from_fit

    fn FitCache::from_fit(fold_ids : Array[Int], predictions : Array[Array[Double]], fold_split_seed : Int, n_folds : Int, n_rep : Int, n_obs : Int, data_hash : UInt64, hyperparams_hash : UInt64, cluster_ids_hash : UInt64, estimator_kind : String) -> FitCache

    Build a FitCache from the LAST repetition's fold assignment and nuisance predictions. predictions is interpreted by the estimator (see the FitCache docstring for the per-kind layout).

    FitCache::is_empty

    fn FitCache::is_empty(self : FitCache) -> Bool

    true iff the cache holds no observations and was never populated. The opposite of is_valid(...) against the expected inputs.

    FitCache::is_valid

    fn FitCache::is_valid(self : FitCache, fold_split_seed : Int, n_folds : Int, n_rep : Int, n_obs : Int, data_hash : UInt64, hyperparams_hash : UInt64, cluster_ids_hash : UInt64, estimator_kind : String) -> Bool

    Check whether the stored cache matches the requested configuration. Returns true only if every dimension of the fingerprint matches the current call -- otherwise the caller must fall back to the standard (non-cached) cross-fit.

    cluster_ids_hash (v0.82.0+) is the hash of the cluster_vars vector (or 0 for the IID path). Pass the same hash you used when the cache was populated; the caller's hash is compared to the stored one to detect a switch from IID to clustered (or a different cluster partition).

    FitCache::to_repr

    Fold

    pub struct Fold {
    train_idx : Array[Int]
    test_idx : Array[Int]
    } derive(
    Debug
    )

    A single fold is represented by a pair of train and test index arrays. Test indices are disjoint and together cover [0, n_obs).

    Fold::new

    fn Fold::new(train_idx : Array[Int], test_idx : Array[Int]) -> Fold

    Public constructor: build a fold from explicit train/test index arrays (used by callers that drive cross-fitting with their own partitions, e.g. the clustered panel path in plpr.mbt).

    Fold::test_indices

    fn Fold::test_indices(self : Fold) -> Array[Int]

    Test indices (accessor for the test_idx private field).

    Fold::to_repr

    Fold::train_indices

    fn Fold::train_indices(self : Fold) -> Array[Int]

    Train indices (accessor for the train_idx private field).

    GBClassifier

    pub struct GBClassifier {
    n_trees : Int
    learning_rate : Double
    max_depth : Int
    min_samples_leaf : Int
    mtry : Int
    subsample : Double
    bootstrap_seed : Int
    initial : Double
    base_rate : Double
    trees : Array[CART]
    } derive(
    Debug
    )

    Gradient-boosting classifier (binary log loss). predict returns P(y = 1 | x).

    GBClassifier::base_rate

    fn GBClassifier::base_rate(self : GBClassifier) -> Double

    The base rate pbar = mean(y), 0.0 before fit.

    GBClassifier::fit

    fn GBClassifier::fit(self : GBClassifier, x : Matrix, y : Array[Double]) -> GBClassifier

    Unweighted fit. v0.118.0 moved the body to fit_weighted; this inherent method keeps GBClassifier::new(..).fit(x, y) working.

    GBClassifier::fit_weighted

    fn GBClassifier::fit_weighted(self : GBClassifier, x : Matrix, y : Array[Double], w : Array[Double]) -> GBClassifier

    Fit the boosting ensemble under binary log loss. y must be in {0, 1}; see RFClassifier::fit_weighted for why this learner refuses rather than silently accepts.

    v0.118.0: w carries per-row weights. The negative gradient g and the hessian h both stay RAW -- the tree receives them plus the weights, and cart_leaf_value forms the WEIGHTED Newton step SUM(w*g) / SUM(w*h). Multiplying either by w beforehand would double-count the weights.

    GBClassifier::initial

    fn GBClassifier::initial(self : GBClassifier) -> Double

    The initial constant F_0 in LOG-ODDS, 0.0 before fit. This is the v0.117.0 point that differs from GBLearner::initial, which returns the mean under squared error.

    GBClassifier::n_trees

    fn GBClassifier::n_trees(self : GBClassifier) -> Int

    Number of trees in the fitted ensemble; 0 before fit.

    GBClassifier::new

    fn GBClassifier::new(n_trees? : Int, learning_rate? : Double, max_depth? : Int, min_samples_leaf? : Int, mtry? : Int, subsample? : Double, bootstrap_seed? : Int) -> GBClassifier

    GBClassifier::predict

    fn GBClassifier::predict(self : GBClassifier, x : Matrix) -> Array[Double]

    Promote the Learner trait methods as explicit methods so the trait impl below is not reported as unused_value under --deny-warn. Same pattern as GBLearner.

    GBLearner

    pub struct GBLearner {
    n_trees : Int
    learning_rate : Double
    max_depth : Int
    min_samples_leaf : Int
    mtry : Int
    subsample : Double
    bootstrap_seed : Int
    initial : Double
    trees : Array[CART]
    } derive(
    Debug
    )

    Gradient boosting learner (regression).

    GBLearner::fit

    fn GBLearner::fit(self : GBLearner, x : Matrix, y : Array[Double]) -> GBLearner

    Unweighted fit. v0.118.0 moved the body to it_weighted; this inherent method stays so every existing GBLearner::new(..).fit(x, y)`n/// call site -- including the wbtests -- is unchanged. An inherent method wins over the trait method of the same name.

    GBLearner::fit_weighted

    fn GBLearner::fit_weighted(self : GBLearner, x : Matrix, y : Array[Double], w : Array[Double]) -> GBLearner

    Fit the gradient-boosting ensemble. Builds n_trees CART trees sequentially, each fitted to the negative gradient (residual) of the current ensemble.

    v0.118.0: w carries per-row weights. The residual stays RAW (y - F) and the weights go to cart_fit, which makes the leaf the weighted mean SUM(w*r)/SUM(w). Pre-multiplying the residual by w instead would SQUARE the weights in the leaf and is the mistake scikit-learn's sample_weight design exists to prevent.

    GBLearner::initial

    fn GBLearner::initial(self : GBLearner) -> Double

    Initial constant prediction (mean of y at fit time). Returns 0.0 before fit.

    GBLearner::n_trees

    fn GBLearner::n_trees(self : GBLearner) -> Int

    Number of trees in the fitted ensemble. Returns 0 if not yet fit.

    GBLearner::new

    fn GBLearner::new(n_trees? : Int, learning_rate? : Double, max_depth? : Int, min_samples_leaf? : Int, mtry? : Int, subsample? : Double, bootstrap_seed? : Int) -> GBLearner

    GBLearner::predict

    fn GBLearner::predict(self : GBLearner, x : Matrix) -> Array[Double]

    Promote Learner trait methods as explicit methods on GBLearner so the trait impl below doesn't trip unused_value under --deny-warn. Same pattern as the v0.54.0 ConstantLearner / NoopLearner and v0.56.0 RFLearner setup.

    GainStatsResult

    pub struct GainStatsResult {
    cf_y : Array[Double]
    cf_d : Array[Double]
    rho : Array[Double]
    delta_theta : Array[Double]
    } derive(
    Debug
    )

    Container for the per-coefficient gain-statistic benchmark values. Returned by gain_statistics. The four fields are
    • cf_y (length n_coef): the maximum percentage of the outcome's residual variance explainable by an unobserved confounder that explains the difference between dml_long and dml_short's residual variance. Used as the upper bound on cf_y in sensitivity_analysis.
    • cf_d (length n_coef): the maximum percentage gain in the Riesz representer's variance explainable by an unobserved confounder. Used as the upper bound on cf_d.
    • rho (length n_coef): the sign-and-magnitude of the confounding correlation (in [-1, 1]) that drives the coefficient change from dml_long to dml_short.
    • delta_theta (length n_coef): the per-coefficient change coef_short - coef_long (median over reps).

    All four are length n_coef = dml_long.coef.length(). The benchmark is the upstream doubleml.utils.gain_statistics output; see Cinelli & Hazlett (2020) §3.4 for the interpretation of cf_y / cf_d / rho.

    GainStatsSource

    pub struct GainStatsSource {
    var_y_residuals : Array[Double]
    nu2 : Array[Double]
    all_coef : Array[Double]
    n_rep : Int
    var_y : Double
    } derive(
    Debug
    )

    Container exposing the per-rep arrays gain_statistics needs from a fitted DML model. The upstream API is the DoubleML object; the v0.17.0 port defines a minimal struct so any DML estimator (BLP, PolicyTree, PLR, IRM, ...) can be benchmarked. var_y_residuals, nu2, and all_coef are row-major (n_coef, n_rep); n_rep is the per-coefficient repetition count; var_y is the scalar outcome variance.

    GainStatsSource::from_blp

    fn GainStatsSource::from_blp(blp : DoubleMLBLP, n_rep? : Int) -> GainStatsSource

    v0.19.0+ convenience constructor for GainStatsSource from a fitted DoubleMLBLP. Auto-populates all the per-rep arrays from the BLP's fit output:

    • var_y_residuals[k] = RSS / n_obs (constant across coefficients; the BLP's residual variance).
    • nu2[k] = var_y_residuals[k] / (n_obs * se[k]^2) (the per-coef Riesz representer norm squared under the homoskedastic OLS convention se[k]^2 = sigma^2 * (Z^T Z)^{-1}_{kk}).
    • all_coef[k] = blp.coef()[k].
    • var_y = blp.var_y() (the variance of the BLP's orthogonal signal — the BLP's "outcome" variable).

    The HC0 SE convention is consistent with this homoskedastic interpretation up to O(1/n) corrections (the BLP's HC0 SE is robust to heteroskedasticity in the orthogonal-signal residuals; the auto-populated nu2 uses the BLP's reported SE directly).

    n_rep defaults to 1 (single-rep BLP). Multi-rep DMLs should pass n_rep > 1, in which case the auto-populated arrays are broadcast across reps (the BLP does not natively produce per-rep sensitivity elements).

    GainStatsSource::from_blp_cv

    fn GainStatsSource::from_blp_cv(blp : DoubleMLBLP, n_folds? : Int, seed? : Int) -> GainStatsSource

    v0.22.0+: from_blp_cv(blp, n_folds?, seed?) is the cross-fit variant of from_blp. The only difference is var_y_residuals, which is computed from out-of-fold (OOF) predictions rather than the in-sample BLP residuals. The OOF residual variance is honest (no leakage from the basis fit on the same rows), so the R2_y benchmark in gain_statistics is more accurate.

    Algorithm:
    1. Draw n_folds random folds via kfold (with chacha8_rng-style seeding through seed).
    2. For each fold, fit a LinearRegression on the training rows and predict on the test fold.
    3. Compute the per-fold test residual variance sigma2_fold = sum_i (y_i - y_hat_i)^2 /n_fold (the honest OOF estimate).
    4. var_y_residuals_scalar = mean over folds of sigma2_fold (the "average fold residual variance").

    coef, se, var_y, and all_coef are unchanged from from_blp (the BLP's own fit on the full data is the canonical coefficient estimate; only var_y_residuals is recomputed).

    nu2 is recomputed as var_y_residuals_cv / (n_obs * se^2) (same homoskedastic convention as from_blp).

    GainStatsSource::from_blp_cv_repeated

    fn GainStatsSource::from_blp_cv_repeated(blp : DoubleMLBLP, n_folds? : Int, n_repeats? : Int, seed? : Int) -> GainStatsSource

    v0.25.0+: from_blp_cv_repeated(blp, n_folds?,n_repeats?, seed?) is a more stable version of from_blp_cv. A single K-fold split has non-trivial variance in var_y_residuals (different splits put different rows in the test fold, so the OOF residual sum changes). Repeating the K-fold split n_repeats times with different seeds and averaging the residual variance reduces this variance by ~n_repeatsx (i.i.d. assumption on the per-rep estimate).

    Algorithm:
    1. For each rep in 0..n_repeats, run the K-fold OOF pipeline from from_blp_cv with seed_eff = seed + rep (any reproducible per-rep seed works; this scheme keeps seed=3141 giving the same first rep as from_blp_cv).
    2. var_y_residuals_scalar = mean over reps of ss_resid_oof_rep / n_obs (the per-rep average fold residual variance).

    coef, se, var_y, all_coef, n_rep, and the nu2 formula are all identical to from_blp_cv — only the residual variance estimate is averaged across repeats.

    On small samples (e.g. n_obs=200) with n_folds=5 and n_repeats=10, this typically gives a 5-10x reduction in the standard error of var_y_residuals, which translates to a similar stabilization of the downstream R2_y and nu2 sensitivity benchmarks.

    GainStatsSource::from_blp_hc0

    fn GainStatsSource::from_blp_hc0(blp : DoubleMLBLP, n_folds? : Int, seed? : Int) -> GainStatsSource

    v0.23.0+: from_blp_hc0(blp, n_folds?, seed?) is the HC0-honest variant of from_blp_cv. The difference is the nu2 formula: instead of the homoskedastic OLS convention nu2 = var_y_residuals / (n_obs * se^2), it uses the projection-weight formula nu2[k] = (1 / n_obs) * ||M[k,:] @ basis^T||^2 where M = (basis^T basis + ridge I)^{-1} is the BLP's regression matrix. This is consistent with the upstream doubleml.utils._estimation._compute_sensitivity_elements convention, where nu2 = E[score_d^2] and the score is the per-observation influence on the k-th coefficient (M[k,:] @ x_i).

    The homoskedastic formula conflates nu2 with se^2 (using the relation se^2 = sigma^2 * (Z^T Z)^{-1}_{kk}), which is only correct under homoskedasticity. The HC0 SE se^2 = sum_i (M[k,:] @ x_i)^2 * e_i^2 does not satisfy the same relation; the projection-weight formula is the HC0-compatible alternative.

    var_y_residuals is computed via K-fold cross-fitting (same as from_blp_cv). coef, se, var_y, and all_coef are unchanged from from_blp.

    GainStatsSource::new

    fn GainStatsSource::new(var_y_residuals : Array[Double], nu2 : Array[Double], all_coef : Array[Double], n_rep : Int, var_y : Double) -> GainStatsSource

    Builder constructor for GainStatsSource that mirrors the upstream DoubleML attribute access. Validates shape consistency: all three per-rep arrays must have the same length, and that length must be divisible by n_rep.

    IivmData

    type IivmData

    Result of the IIVM DGP.

    IivmData::d_get

    fn IivmData::d_get(self : IivmData) -> Array[Double]

    Get the treatment vector.

    IivmData::theta_get

    fn IivmData::theta_get(self : IivmData) -> Double

    Get the causal parameter (theta).

    IivmData::x_get

    fn IivmData::x_get(self : IivmData) -> Matrix

    Get the design matrix.

    IivmData::y_get

    fn IivmData::y_get(self : IivmData) -> Array[Double]

    Get the outcome vector.

    IivmData::z_get

    fn IivmData::z_get(self : IivmData) -> Array[Double]

    Get the instrument vector.

    IrmConfoundedData

    type IrmConfoundedData

    IrmConfoundedData::c_get

    fn IrmConfoundedData::c_get(self : IrmConfoundedData) -> Array[Double]

    IrmConfoundedData::d_get

    fn IrmConfoundedData::d_get(self : IrmConfoundedData) -> Array[Double]

    IrmConfoundedData::theta_get

    fn IrmConfoundedData::theta_get(self : IrmConfoundedData) -> Double

    IrmConfoundedData::x_get

    IrmConfoundedData::y_get

    fn IrmConfoundedData::y_get(self : IrmConfoundedData) -> Array[Double]

    IrmData

    type IrmData

    Result of the IRM DGP.

    IrmData::d_get

    fn IrmData::d_get(self : IrmData) -> Array[Double]

    Get the binary treatment vector.

    IrmData::theta_get

    fn IrmData::theta_get(self : IrmData) -> Double

    Get the causal parameter (theta).

    IrmData::x_get

    fn IrmData::x_get(self : IrmData) -> Matrix

    Get the design matrix.

    IrmData::y_get

    fn IrmData::y_get(self : IrmData) -> Array[Double]

    Get the outcome vector.

    IrmDiscreteData

    type IrmDiscreteData

    IrmDiscreteData::d_get

    fn IrmDiscreteData::d_get(self : IrmDiscreteData) -> Array[Double]

    IrmDiscreteData::theta_get

    fn IrmDiscreteData::theta_get(self : IrmDiscreteData) -> Double

    IrmDiscreteData::x_get

    IrmDiscreteData::y_get

    fn IrmDiscreteData::y_get(self : IrmDiscreteData) -> Array[Double]

    IrmHeterogeneousData

    type IrmHeterogeneousData

    IrmHeterogeneousData::d_get

    fn IrmHeterogeneousData::d_get(self : IrmHeterogeneousData) -> Array[Double]

    IrmHeterogeneousData::theta_get

    fn IrmHeterogeneousData::theta_get(self : IrmHeterogeneousData) -> Double

    IrmHeterogeneousData::x_get

    IrmHeterogeneousData::y_get

    fn IrmHeterogeneousData::y_get(self : IrmHeterogeneousData) -> Array[Double]

    JointSensitivityInput

    pub struct JointSensitivityInput {
    name : String
    coef : Double
    se : Double
    sigma_sq : Double
    rho : Double
    cf_y : Double
    cf_d : Double
    } derive(
    Debug
    )

    v0.77.0+: per-estimator summary fed into joint_sensitivity. Distinct from the existing SensitivityResult (which is the post-Cinelli-Hazlett (rv, sigma2, nu2, cf_y, cf_d, max_bias) return of irm_style_sensitivity / irm_style_sensitivity_cluster) — this struct carries the high-level (name, coef, se, sigma_sq,rho, cf_y, cf_d) summary that the joint-coverage formula operates on. Naming a second struct SensitivityResult would shadow the existing one and break all 22 estimator sensitivity_analysis(_cluster) contracts, so the joint helper takes this distinct struct.

    • name: a label for the estimator (typically the struct name like "irm", "plr", "did"). Empty by default; callers should set it for downstream debugging.
    • coef: the estimator coefficient theta_hat.
    • se: the estimator's homoskedasticity- or cluster-robust standard error.
    • sigma_sq: the residual variance (matches SensitivityResult.sigma2); used in the joint RV derivation (|theta| / sqrt(sigma_sq * |rho|) form).
    • rho: the confounding correlation in [-1, 1] (matches GainStatsResult.rho).
    • cf_y: benchmarking upper-bound on the outcome-nuisance confounding fraction (default 0.05). Pass-through to the joint result for parity with SensitivityResult.cf_y.
    • cf_d: benchmarking upper-bound on the treatment-nuisance confounding fraction (default 0.05). Pass-through to the joint result for parity with SensitivityResult.cf_d.

    JointSensitivityInput::empty

    v0.77.0+: zero-valued JointSensitivityInput. Useful for tests and as a placeholder (all numeric fields are 0.0, name is the empty string). Note that joint_sensitivity will report a degenerate RV (1.0e300 sentinel) for a zero-valued input because sigma_sq * rho == 0.

    JointSensitivityInput::new

    fn JointSensitivityInput::new(name : String, coef : Double, se : Double, sigma_sq : Double, rho : Double, cf_y : Double, cf_d : Double) -> JointSensitivityInput

    v0.77.0+: builder constructor for JointSensitivityInput. All fields are required; use JointSensitivityInput::empty() for a zero-default sentinel.

    JointSensitivityResult

    pub struct JointSensitivityResult {
    joint_lower : Double
    joint_upper : Double
    joint_rv : Double
    joint_rv_q : Double
    per_estimator_lower : Array[Double]
    per_estimator_upper : Array[Double]
    per_estimator_rv : Array[Double]
    per_estimator_rv_q : Array[Double]
    bonferroni_alpha : Double
    num_estimators : Int
    } derive(
    Debug
    )

    v0.77.0+: output of joint_sensitivity. Combines per-estimator Bonferroni-corrected CIs with the cross-estimator joint RV and joint RV_q.

    • joint_lower = min(per_estimator_lower) — the most conservative lower CI across estimators at the Bonferroni alpha.
    • joint_upper = max(per_estimator_upper) — the most conservative upper CI.
    • joint_rv = min(per_estimator_rv) — the worst (smallest) RV across estimators. A confounder that would tip the LEAST-robust single estimator would tip the joint conclusion.
    • joint_rv_q = min(per_estimator_rv_q) — the worst (smallest) RV_q across estimators. RV_q is the C&H q-bias-based RV — the strength that would push |theta| below the z_alpha * se significance threshold.
    • per_estimator_lower / per_estimator_upper / per_estimator_rv /per_estimator_rv_q: per-estimator outputs, length K.
    • bonferroni_alpha = alpha / K.
    • num_estimators = K.

    Lasso

    pub struct Lasso {
    alpha : Double
    tol : Double
    max_iter : Int
    coef_ : Array[Double]
    intercept_ : Double
    n_iter_ : Int
    n_features_ : Int
    fitted : Bool
    } derive(
    Debug
    )

    L1-penalised linear model, scikit-learn Lasso(alpha=...).

    Minimises (1 / 2n) ||y - Xw||^2 + alpha * ||w||_1 with an UNPENALISED intercept (obtained by centring). Note the 1 / 2n: at equal n, a Lasso(alpha) shrinks far harder than a Ridge(alpha). See the module header for the measured identity ElasticNet(l1_ratio = 0, alpha = a) == Ridge(alpha = a * n).

    sample_weight is accepted and renormalised to mean 1, so this learner is invariant to a constant rescaling of the weights.

    Lasso::coefficients

    fn Lasso::coefficients(self : Lasso) -> Array[Double]

    The fitted slopes, length n_features(). The intercept is NOT included; read it with intercept().

    Lasso::fit

    fn Lasso::fit(self : Lasso, x : Matrix, y : Array[Double]) -> Lasso

    Fit on (x, y) with no sample weights.

    Lasso::fit_weighted

    fn Lasso::fit_weighted(self : Lasso, x : Matrix, y : Array[Double], w : Array[Double]) -> Lasso

    Weighted fit. The weights are renormalised to mean 1 before the sweep, so fit_weighted(x, y, w) and fit_weighted(x, y, 17 * w) agree exactly -- a gate in expand_v120_test.mbt pins that.

    The body is a one-line delegation to ElasticNet with l1_ratio = 1.0. It is deliberately NOT a second copy of the solver.

    Lasso::intercept

    fn Lasso::intercept(self : Lasso) -> Double

    The fitted (unpenalised) intercept.

    Lasso::iterations

    fn Lasso::iterations(self : Lasso) -> Int

    Number of coordinate-descent sweeps performed by the last fit. Exposed because max_iter is a real failure mode: a silent cap would show up as a plausible-but-wrong model and nothing else in the API would say so.

    Lasso::n_features

    fn Lasso::n_features(self : Lasso) -> Int

    Number of features seen at fit time (excluding the intercept).

    Lasso::new

    fn Lasso::new(alpha? : Double, tol? : Double, max_iter? : Int) -> Lasso

    Build an un-fitted Lasso. Defaults match scikit-learn: alpha =1.0, tol = 1e-4, max_iter = 1000.

    Lasso::predict

    fn Lasso::predict(self : Lasso, x : Matrix) -> Array[Double]

    Predict x . beta + intercept for every row of x.

    Lasso::to_repr

    LearnerDispatch

    pub enum LearnerDispatch {
    LinearRegression(LinearRegression)
    Constant(ConstantLearner)
    Noop(NoopLearner)
    RandomForest(RFLearner)
    GradientBoosting(GBLearner)
    LogisticRegression(LogisticRegression)
    RandomForestClassifier(RFClassifier)
    GradientBoostingClassifier(GBClassifier)
    Ridge(Ridge)
    Lasso(Lasso)
    ElasticNet(ElasticNet)
    } derive(
    Debug
    )

    Dispatch enum for the Learner trait. Stores one of the concrete learner types and lets cross_fit_predict_dispatch route to the right cross_fit_predict[T : Learner] arm.

    The enum keeps DoubleMLPLR non-generic in its struct definition (callers don't have to write DoubleMLPLR[LinearRegression, LinearRegression]), at the cost of an extra match arm per learner type. Adding a new learner is 3 small edits (see the file header).

    LearnerDispatch::constant

    fn LearnerDispatch::constant(value : Double) -> LearnerDispatch

    Wrap a ConstantLearner in a LearnerDispatch.

    LearnerDispatch::elastic_net

    Wrap an ElasticNet in a LearnerDispatch. v0.120.0+.

    LearnerDispatch::gradient_boosting

    fn LearnerDispatch::gradient_boosting(gb : GBLearner) -> LearnerDispatch

    Wrap a GBLearner in a LearnerDispatch. v0.57.0+. Factory for the Path A gradient-boosting learner; callers can override n_trees / learning_rate / max_depth / subsample via GBLearner::new(...) before wrapping.

    LearnerDispatch::gradient_boosting_classifier

    fn LearnerDispatch::gradient_boosting_classifier(gb : GBClassifier) -> LearnerDispatch

    Wrap a GBClassifier in a LearnerDispatch. v0.117.0+.

    predict returns P(y = 1 | x) -- see LearnerDispatch::random_forest_classifier.

    LearnerDispatch::is_classifier

    fn LearnerDispatch::is_classifier(self : LearnerDispatch) -> Bool

    v0.132.0: is this learner a CLASSIFIER (predicts P(y = 1 | x)) rather than a regressor (predicts E[y | x])?

    The port's counterpart of upstream's type check: upstream does _check_learner(ml_m, "ml_m", regressor=False, classifier=True) (irm/irm.py:157) and sets _predict_method["ml_m"] = "predict_proba" (irm.py:161). A regressor has no predict_proba at all, so upstream makes the mistake impossible rather than merely unlikely.

    Here the distinction is decidable from the variant, because the classifier and regressor learners are separate constructors rather than one class with a flag.

    LearnerDispatch::lasso

    Wrap a Lasso in a LearnerDispatch. v0.120.0+.

    LearnerDispatch::linear_regression

    fn LearnerDispatch::linear_regression() -> LearnerDispatch

    Default LearnerDispatch (a fresh LinearRegression). Used as the default value for the learner_l / learner_m labeled params on DoubleMLPLR::new.

    LearnerDispatch::logistic_regression

    fn LearnerDispatch::logistic_regression(lr : LogisticRegression) -> LearnerDispatch

    Wrap a LogisticRegression in a LearnerDispatch. v0.62.0+: LPLR plumbing — binary classification for the double cross-fit path (ml_M, ml_a, ml_m).

    LearnerDispatch::noop

    Wrap a NoopLearner in a LearnerDispatch.

    LearnerDispatch::random_forest

    fn LearnerDispatch::random_forest(rf : RFLearner) -> LearnerDispatch

    Wrap an RFLearner in a LearnerDispatch. v0.56.0+. Factory for the Path A random-forest learner; callers can override n_trees / max_depth / min_samples_leaf / mtry via RFLearner::new(...) before wrapping.

    LearnerDispatch::random_forest_classifier

    fn LearnerDispatch::random_forest_classifier(rf : RFClassifier) -> LearnerDispatch

    Wrap an RFClassifier in a LearnerDispatch. v0.117.0+.

    predict returns P(y = 1 | x), so this is drop-in for the binary-classification nuisance slots (ml_m, ml_M, ml_a) alongside logistic_regression.

    LearnerDispatch::ridge

    Wrap a Ridge in a LearnerDispatch. v0.120.0+.

    alpha is part of the memoize key -- see learner_dispatch_tag.

    LinearRegression

    pub struct LinearRegression {
    ridge : Double
    coef_ : Array[Double]
    xtx_inv_diag : Array[Double]
    xtwx_inv_diag : Array[Double]
    fitted : Bool
    } derive(
    Debug
    )

    Closed-form OLS linear regression.

    Fits a linear model y = X * beta + intercept (intercept is folded into X via a leading column of ones) by solving the normal equations X^T X beta = X^T y using a Cholesky factorisation. A small ridge lambda is added to the diagonal of X^T X to guard against singularity when the design matrix is near-collinear; the default lambda = 1e-10 is small enough to be invisible on well-conditioned data but prevents the Cholesky from failing on degenerate inputs.

    LinearRegression::coefficients

    fn LinearRegression::coefficients(self : LinearRegression) -> Array[Double]

    LinearRegression::covariance_diagonal

    fn LinearRegression::covariance_diagonal(self : LinearRegression, sigma2 : Double) -> Array[Double]

    Diagonal of the homoskedastic coefficient covariance matrix sigma^2 * (X^T X + ridge * I)^{-1}. The caller supplies sigma^2 = RSS / (n - p); this separation keeps LinearRegression ignorant of the response vector (and hence the residual sum of squares). Returns a length-(p + 1) array whose jth entry is the variance of coef_[j]. The corresponding SE is sqrt(cov[j]). Used by DoubleMLBLP to recover the per-coefficient standard error (Bug #5: previously every coefficient shared the same SE = sqrt(RSS / (n - p)), ignoring the (X^T X)^{-1} scaling).

    REVIEW L8: after a fit_weighted() call the cached xtx_inv_diag is the empty array (M10 fix), so calling covariance_diagonal on a WLS-fit model yields a vector of zeros. Callers must use the unweighted fit path (or pass the pre-computed (X^T X)^{-1} diagonal themselves) when the homoskedastic SE is wanted.

    LinearRegression::fit

    fn LinearRegression::fit(self : LinearRegression, x : Matrix, y : Array[Double]) -> LinearRegression

    Fit the linear regression to (x, y). x is n x p and y is a length-n vector. Returns the fitted LinearRegression with coef_[0] = intercept and coef_[1..] = slopes.

    At fit time we also cache the diagonal of (X^T X + ridge I)^{-1} so that covariance_diagonal() (used by DoubleMLBLP for the per-coefficient standard error) is O(p) instead of re-inverting X^T X from scratch.

    LinearRegression::fit_weighted

    fn LinearRegression::fit_weighted(self : LinearRegression, x : Matrix, y : Array[Double], w : Array[Double]) -> LinearRegression

    Weighted OLS fit. Solves X^T W X beta = X^T W y for a positive diagonal weight matrix W = diag(w). Used by DoubleMLRDD for local-linear regression with the triangular kernel weights (Bug #6: previously the weights were only applied to the variance sum, so the point estimate ignored them). The covariance diagonal is computed against the unweighted X^T X so the SE is the standard homoskedastic-OLS form for the same design matrix — this matches the upstream RDD reference for cov_type='nonrobust'.

    The full weighted-Normal inverse (X^T W X + ridge I)^{-1} is also cached for the WLS-aware SE (TODO #11c.2): the RDD delta-method variance scales the weighted residual variance by the (X^T W X)^{-1}[0, 0] (intercept) entry, which is the variance formula for the WLS point estimate at the cutoff.

    LinearRegression::n_features

    fn LinearRegression::n_features(self : LinearRegression) -> Int

    Number of features (excluding the intercept).

    LinearRegression::new

    fn LinearRegression::new(ridge? : Double) -> LinearRegression

    LinearRegression::predict

    fn LinearRegression::predict(self : LinearRegression, x : Matrix) -> Array[Double]

    Predict the response for new data x. x must have the same number of columns as the data used at fit time.

    LinearRegression::sandwich_se

    fn LinearRegression::sandwich_se(self : LinearRegression, x : Matrix, y : Array[Double]) -> Array[Double]

    Heteroskedasticity-consistent (HC0) covariance diagonal of the coefficient estimator. Computes the diagonal of the sandwich form cov(beta_hat) = (X'X)^{-1} (X' diag(e^2) X) (X'X)^{-1} where e = y - X beta_hat is the in-sample residual vector. The diagonal entry j simplifies to cov_jj = sum_i ((M[j,:] · x_i)^2 * e_i^2) where M = (X'X + ridge I)^{-1} is the cached (X'X)^{-1}-with-ridge matrix. This is O(n * p^2) and avoids materialising the full sandwich matrix. Used by DoubleMLBLP for the per-coefficient heteroskedasticity-robust SE (TODO #11c.1: matches the upstream statsmodels.OLS(cov_type='HC0') default).

    Implementation note: instead of materialising the full (X'X)^{-1} matrix via inv_spd (O(p³) memory + O(p³) compute), we solve p1 back-systems M[j, :] * (X'X + ridge I)= e_j via solve_spd (each O(p²) compute, zero extra memory). The diagonal entry of the sandwich is unchanged; only the form of the inner loop changed.

    LinearRegression::sandwich_se_weighted

    fn LinearRegression::sandwich_se_weighted(self : LinearRegression, x : Matrix, y : Array[Double], w : Array[Double]) -> Array[Double]

    Heteroskedasticity-consistent (HC0) sandwich covariance diagonal of the WLS coefficient estimator. Mirrors sandwich_se but for the weighted case:

    cov(beta_hat) = (X'WX)^{-1} (X' diag(w · e^2) X) (X'WX)^{-1}

    diagonal entry j simplifies to cov_jj = sum_i w[i]^2 · ((M[j,:] · x_i)^2 · e_i^2) where M = (X^T W X + ridge I)^{-1} and e = y - X beta_hat. The extra w[i]^2 factor on the score reflects the WLS weight's role in the IRLS normal equations (see White 1980, §4).

    Used by DoubleMLRDD for the heteroskedasticity-robust WLS intercept variance when cov_type = "HC0" (TODO 0.6.0).

    LinearRegression::xtwx_inv_diag

    fn LinearRegression::xtwx_inv_diag(self : LinearRegression) -> Array[Double]

    Return the diagonal of (X^T W X + ridge I)^{-1} cached from the last fit_weighted() call. Empty array if the last fit was the unweighted fit(). Used by DoubleMLRDD for the WLS-aware intercept variance (TODO #11c.2).

    LogisticRegression

    pub struct LogisticRegression {
    fitted : Bool
    coef_ : Array[Double]
    } derive(
    Debug
    )

    Binary logistic regression fit by Newton-Raphson IRLS.

    Fits a binary logit model P(y = 1 | x) = sigmoid(x @ beta) by iterated reweighted least squares. The intercept is folded into the design matrix as a leading column of ones via augment_with_intercept; the returned coef_[0] is the intercept and coef_[1..] are the slopes. Only binary outcomes (y in {0, 1}) are supported — multiclass classification is intentionally out of scope for this learner.

    LogisticRegression::coefficients

    fn LogisticRegression::coefficients(self : LogisticRegression) -> Array[Double]

    Fitted coefficient vector [intercept, slope_1, ..., slope_p]. Aborts when called before fit() via require(self.fitted).

    LogisticRegression::fit

    fn LogisticRegression::fit(self : LogisticRegression, x : Matrix, y : Array[Double], max_iter? : Int, tol? : Double, ridge? : Double, sample_weight? : Array[Double]) -> LogisticRegression

    Fit the logistic regression by Newton-Raphson IRLS. x is n x p and y is a length-n vector of 0/1 labels.

    Each iteration computes:

    eta = X_aug @ beta p = sigmoid(eta) w = p * (1 - p) # per-observation IRLS weight z = eta + (y - p) / w # working response beta_new = (X_aug^T W X_aug + ridge * I)^-1 X_aug^T W z

    and the loop exits early when the L2 step ||beta_new - beta|| drops below tol. ridge guards the X^T W X factor against singularity when p is exactly 0 or 1 (which makes w zero on some rows); the default of 1e-8 is invisible on well-conditioned data but prevents the Cholesky from failing on separable problems at the boundary.

    The default max_iter = 25 comfortably covers both well-separated data (3-5 steps) and noisy binary outcomes (10-20 steps).

    LogisticRegression::new

    Build an unfitted LogisticRegression learner. Call fit(x, y) to estimate the coefficients; predict and coefficients abort until then.

    LogisticRegression::predict

    fn LogisticRegression::predict(self : LogisticRegression, x : Matrix) -> Array[Double]

    Predict the probability P(y = 1 | x) for each row of x. The output is clipped to (1e-15, 1 - 1e-15) so the returned probabilities are always strictly inside the open interval (0, 1), matching the strict-inequality guarantee the predict_class threshold needs. Aborts when called before fit() or when x has a column count that does not match the data used at fit time.

    LogisticRegression::predict_class

    fn LogisticRegression::predict_class(self : LogisticRegression, x : Matrix, threshold? : Double) -> Array[Double]

    Predict the binary class label by thresholding predict(x) at threshold (default 0.5). Returns 0.0 / 1.0 doubles (not ints) so the output is type-compatible with predict for downstream scoring code. Inherits the predict abort behaviour.

    LplrData

    type LplrData

    Result of the LPLR LZZ2020 DGP.

    LplrData::data_get

    fn LplrData::data_get(self : LplrData) -> DoubleMLBinaryData

    Get the underlying DoubleMLBinaryData.

    LplrData::theta_get

    fn LplrData::theta_get(self : LplrData) -> Double

    Get the true causal parameter.

    LplrScore

    pub struct LplrScore {
    psi : Array[Double]
    psi_deriv : Array[Double]
    }

    Score elements for the LPLR model. With r(theta) = D * theta + t_hat - beta * a_hat, psi(theta) = d_tilde * (1 - y) * exp(r(theta)) - expit(-r(theta)), psi_deriv(theta) = -d_tilde * d * (1 - y) * exp(r(theta)).

    LplrScoreKind

    pub enum LplrScoreKind {
    NuisanceSpace
    Instrument
    }

    Which of upstream's two LPLR scores to evaluate.

    v0.105.0+. Before this enum existed, DoubleMLLPLR::score was validated (require(score == "nuisance_space" || score =="instrument")) and stored on the struct, and then never read by anything downstream: fit always took the nuisance_space path. examples/lplr printed both scores side by side and they agreed to the last bit -- which reads like an algebraic identity and is actually an unimplemented parameter. Upstream DoubleMLLPLR._compute_score / ._compute_score_deriv (plm/lplr.py:521-540) are two genuinely different formulas, and _fit_nuisance (plm/lplr.py:244-252) fits ml_m on a different training set for each. Asserting the two paths agree would have welded that in place.

    Matrix

    Dense 2D matrix stored in row-major order as a flat Array[Double].

    This is a minimal matrix type used by the DoubleML port. It is not intended to be a general-purpose linear algebra library; only the operations needed for the closed-form LinearRegression learner and the DML score have been implemented.

    Matrix::cols

    fn Matrix::cols(self : Matrix) -> Int

    Number of columns (accessor — ncols is a private field).

    Matrix::copy

    fn Matrix::copy(self : Matrix) -> Matrix

    Copy this matrix into a new Matrix.

    Matrix::from_array

    fn Matrix::from_array(data : Array[Double], nrows : Int, ncols : Int) -> Matrix

    Create a matrix from a flat row-major array. Panics if length does not match nrows * ncols.

    Matrix::from_rows

    fn Matrix::from_rows(rows : Array[Array[Double]]) -> Matrix

    Build a matrix from a list of row vectors. Returns an nrows x ncols matrix where ncols = rows[0].length(). Panics if the rows do not all share the same length.

    Matrix::get

    fn Matrix::get(self : Matrix, i : Int, j : Int) -> Double

    Element access (get). Panics if indices are out of range.

    Matrix::identity

    fn Matrix::identity(n : Int) -> Matrix

    Create an n x n identity matrix.

    Matrix::ones

    fn Matrix::ones(nrows : Int, ncols : Int) -> Matrix

    Create a matrix filled with ones.

    Matrix::rows

    fn Matrix::rows(self : Matrix) -> Int

    Number of rows (accessor — nrows is a private field).

    Matrix::set

    fn Matrix::set(self : Matrix, i : Int, j : Int, v : Double) -> Unit

    Element access (set). Panics if indices are out of range.

    Matrix::to_repr

    Matrix::transpose

    fn Matrix::transpose(self : Matrix) -> Matrix

    Matrix transpose.

    Matrix::zeros

    fn Matrix::zeros(nrows : Int, ncols : Int) -> Matrix

    Create a zero matrix of the given shape.

    NoopLearner

    pub struct NoopLearner {
    value : Double
    } derive(
    Debug
    )

    Trivial learner: predicts zero for every row. fit is a no-op; predict returns all zeros. Useful for sanity tests that the DML pipeline doesn't divide by zero when the nuisance is trivial.

    NoopLearner::fit

    fn NoopLearner::fit(self : NoopLearner, x : Matrix, y : Array[Double]) -> NoopLearner

    v0.118.0: same treatment as ConstantLearner::fit -- the body is an inherent method, the three-argument trait method delegates.

    NoopLearner::new

    NoopLearner::predict

    fn NoopLearner::predict(self : NoopLearner, x : Matrix) -> Array[Double]

    PSProcessor

    pub struct PSProcessor {
    config : PSProcessorConfig
    } derive(
    Debug
    )

    Propensity-score processor. Stateless apart from its configuration; safe to share across DoubleMLDIDBinary / DoubleMLDIDCS instances. adjust_ps returns a new array of the same length as the input, never mutating the caller's data.

    v0.14.0+: the isotonic calibration method is now fully implemented (was a placeholder in v0.10.0). The pure-MoonBit PAVA implementation in pava produces a step-function isotonic regression; with cv_calibration=true, K-fold cross-validated predictions are used instead (one isotonic fit per fold, predictions concatenated across folds).

    PSProcessor::adjust_ps

    fn PSProcessor::adjust_ps(self : PSProcessor, ps : Array[Double], treatment : Array[Double], cv? : Array[(Array[Int], Array[Int])]?) -> Array[Double]

    Apply the configured calibration followed by the [clipping_threshold, 1 - clipping_threshold] clip. Returns a new array; the caller's ps and treatment are not mutated.

    cv is consulted only when config.calibration_method ="isotonic" and config.cv_calibration = true. It is a list of folds, each a pair (train_indices, test_indices) — when cv = None, a deterministic 5-fold split with seed=3141 is used. With cv_calibration = false, cv is ignored and the isotonic fit uses the full (ps, treatment) (matches upstream IsotonicRegression default: no CV).

    With the default config this is exactly clip_vec(ps, eps, 1-eps), which is what all twelve estimators were doing inline before ps_processor_config became reachable. MEASURED: on the v0.121.0 oracle fixture max |adjust_ps(ps, d) - ps| = 0.0 for calibration_method = "none", so routing the existing clip site through here is behaviour-preserving and is asserted as such.

    PSProcessor::calibration_method

    fn PSProcessor::calibration_method(self : PSProcessor) -> String

    PSProcessor::clipping_threshold

    fn PSProcessor::clipping_threshold(self : PSProcessor) -> Double

    PSProcessor::cv_calibration

    fn PSProcessor::cv_calibration(self : PSProcessor) -> Bool

    PSProcessor::extreme_threshold

    fn PSProcessor::extreme_threshold(self : PSProcessor) -> Double

    PSProcessor::from_config

    fn PSProcessor::from_config(config : PSProcessorConfig) -> PSProcessor

    Convenience constructor mirroring upstream PSProcessor.from_config.

    PSProcessor::new

    PSProcessorConfig

    pub(all) struct PSProcessorConfig {
    clipping_threshold : Double
    extreme_threshold : Double
    calibration_method : String
    cv_calibration : Bool
    } derive(
    Debug
    )

    Configuration for propensity-score processing. The processor applies (optionally) a calibration step and always a [clipping_threshold, 1 - clipping_threshold] clip to keep the propensity scores away from 0/1 in the score denominator.

    Calibration (v0.14.0+): the only currently supported method is isotonic regression (PAVA, pool-adjacent-violators algorithm). Set calibration_method="isotonic" to fit an isotonic regression of treatment on ps and use the fitted curve as the calibrated propensity. Combine with cv_calibration=true to use K-fold cross-validated calibration (matches upstream sklearn.model_selection.cross_val_predict); pass cv to PSProcessor::adjust_ps to control the fold partition.

    PSProcessorConfig::default

    Default PS processor config (clipping_threshold=1e-2, no calibration). v0.44.0: returns a pre-constructed default via a private helper that doesn't raise, so this signature stays PSProcessorConfig (no raise).

    PSProcessorConfig::new

    fn PSProcessorConfig::new(clipping_threshold? : Double, extreme_threshold? : Double, calibration_method? : String, cv_calibration? : Bool) -> PSProcessorConfig raise PSConfigError

    Returns PSProcessorConfig raise PSConfigError: the v0.44.0 conversion replaces the previous abort() call with raise PSConfigError::InconsistentCVCalibration so the inconsistent-configuration path becomes directly testable. Callers that want the pre-v0.44.0 process-death behavior should catch the error and re-abort (this is what PSProcessorConfig::default does, although it never triggers the error in practice). The require checks on the four argument ranges are unchanged and still abort the process (they are central require checks; refactoring them is out of scope for this surgical release).

    PanelTransform

    type PanelTransform derive(
    Debug
    )

    Transformed cross-section produced by one of the four static panel approaches. d_mean_row carries the per-row unit mean of D when an approach needs it downstream (cre_general, cre_normal), otherwise it is empty. id is the unit id of every transformed row: upstream wraps the transformed data in a static-panel DoubleMLPanelData, which sets cluster_cols =id_col, so the clustered DML path always keys off this column.

    PlivClusterData

    type PlivClusterData

    PlivClusterData::cluster_a_get

    fn PlivClusterData::cluster_a_get(self : PlivClusterData) -> Array[Int]

    PlivClusterData::cluster_b_get

    fn PlivClusterData::cluster_b_get(self : PlivClusterData) -> Array[Int]

    PlivClusterData::d_get

    fn PlivClusterData::d_get(self : PlivClusterData) -> Array[Double]

    PlivClusterData::theta_get

    fn PlivClusterData::theta_get(self : PlivClusterData) -> Double

    PlivClusterData::x_get

    PlivClusterData::y_get

    fn PlivClusterData::y_get(self : PlivClusterData) -> Array[Double]

    PlivClusterData::z_get

    fn PlivClusterData::z_get(self : PlivClusterData) -> Array[Double]

    PlivData

    type PlivData

    PlivData::d_get

    fn PlivData::d_get(self : PlivData) -> Array[Double]

    PlivData::theta_get

    fn PlivData::theta_get(self : PlivData) -> Double

    PlivData::x_get

    fn PlivData::x_get(self : PlivData) -> Matrix

    PlivData::y_get

    fn PlivData::y_get(self : PlivData) -> Array[Double]

    PlivData::z_get

    fn PlivData::z_get(self : PlivData) -> Array[Double]

    PlprData

    type PlprData

    Result of the PLPR DGP.

    PlprData::d_get

    fn PlprData::d_get(self : PlprData) -> Array[Double]

    PlprData::n_periods_get

    fn PlprData::n_periods_get(self : PlprData) -> Int

    PlprData::n_units_get

    fn PlprData::n_units_get(self : PlprData) -> Int

    PlprData::theta_get

    fn PlprData::theta_get(self : PlprData) -> Double

    PlprData::x_get

    fn PlprData::x_get(self : PlprData) -> Matrix

    PlprData::y_get

    fn PlprData::y_get(self : PlprData) -> Array[Double]

    PlrCcddhnr2018

    type PlrCcddhnr2018

    Result of the PLR CCDDHNR 2018 DGP.

    PlrCcddhnr2018::d_get

    fn PlrCcddhnr2018::d_get(self : PlrCcddhnr2018) -> Array[Double]

    Get the treatment vector.

    PlrCcddhnr2018::theta_get

    fn PlrCcddhnr2018::theta_get(self : PlrCcddhnr2018) -> Double

    Get the causal parameter (theta).

    PlrCcddhnr2018::x_get

    fn PlrCcddhnr2018::x_get(self : PlrCcddhnr2018) -> Matrix

    Get the design matrix.

    PlrCcddhnr2018::y_get

    fn PlrCcddhnr2018::y_get(self : PlrCcddhnr2018) -> Array[Double]

    Get the outcome vector.

    PlrConfoundedData

    type PlrConfoundedData

    PlrConfoundedData::c_get

    fn PlrConfoundedData::c_get(self : PlrConfoundedData) -> Array[Double]

    PlrConfoundedData::d_get

    fn PlrConfoundedData::d_get(self : PlrConfoundedData) -> Array[Double]

    PlrConfoundedData::theta_get

    fn PlrConfoundedData::theta_get(self : PlrConfoundedData) -> Double

    PlrConfoundedData::x_get

    PlrConfoundedData::y_get

    fn PlrConfoundedData::y_get(self : PlrConfoundedData) -> Array[Double]

    PolicyTreeNode

    pub enum PolicyTreeNode {
    Leaf(Int)
    Split(Int, Double, PolicyTreeNode, PolicyTreeNode)
    } derive(
    Debug
    )

    A binary tree node used by DoubleMLPolicyTree. A Leaf is a terminal that always returns the given treatment. A Split carries the split feature, the threshold value, and the two child nodes. Internal-only (not exposed in the public API) so the public DoubleMLPolicyTree signature is unchanged from the depth-1 era.

    RDDKernel

    pub enum RDDKernel {
    Triangular
    Normal
    Uniform
    Epanechnikov
    Quadratic
    } derive(
    Debug
    )

    Kernel for the local-polynomial weights, following rdrobust's kernelfunc menu.

    v0.110.0: previously RDD had ONE hardcoded kernel. rdd.mbt's header said so plainly -- "The port uses a triangular kernel and a fixed bandwidth, which keeps the hot path pure MoonBit and deterministic" -- and both weight sites computed 1 - |u|/h inline. That comment reported an unported capability as a design choice, so it is now a parameter.

    CORRECTION (v0.115.0): only THREE of these are rdrobust's

    v0.110.0's comment said "the five names and formulas are rdrobust's". That was wrong. Checked against rdrobust 2.2's own documentation rather than recalled: both rdrobust() and rdbwselect() document kernel as "triangular (default option), epanechnikov and uniform". Normal and Quadratic are NOT in rdrobust's menu.

    They are kept rather than removed -- deleting public enum variants is a breaking API change and the extra kernels are harmless extensions -- but the claim is corrected. What matters is that a reader no longer believes choosing Normal reproduces an rdrobust option, because it does not.

    variantK(u/h)in rdrobust?
    Triangular1 - tyes (default)
    Uniform1yes
    Epanechnikov0.75 * (1 - t^2)yes
    Normalexp(-t^2 / 2)no
    Quadratic1 - t^2no

    with t = |u| / h and support |u| <= h.

    The u^2 gap this paragraph used to claim is CLOSED

    v0.110.0 ended here by saying the design matrix is [1, u, x...] and that "the remaining gap against rdrobust is the missing u^2 term, not the kernel". That was accurate then. As of v0.115.0 rdd_design takes an order and emits [u, ..., u^order, x...], so the bias- correction fit carries the u^2 column q = 2 needs. The POINT estimator still defaults to order = 1, matching rdrobust's p = 1, so the default numbers are unchanged.

    RDDKernel::parse

    fn RDDKernel::parse(s : String) -> RDDKernel

    Parse a kernel name. Accepted spellings are case-insensitive, and the upstream aliases are accepted too, so a config written for rdrobust mostly works:

    "triangular" / "tri" -> Triangular "normal" / "gauss" -> Normal "uniform" -> Uniform "epanechnikov" / "epa" -> Epanechnikov "quadratic" / "quad" -> Quadratic

    An unknown name aborts with the accepted set rather than falling back to Triangular. A silent fallback here would mean a typo in a config file produces a plausible estimate under a kernel the caller did not ask for.

    RDDKernel::tag

    fn RDDKernel::tag(self : RDDKernel) -> Int

    Stable small integer tag for this kernel, folded into the fit cache key alongside cutoff / bandwidth / fuzzy / cov_type.

    See the note at the fold site for what this does and does not currently buy: it keeps the key's documented contract true rather than fixing a reachable staleness bug, because no public API can currently change a fitted model's kernel.

    RDDKernel::weight

    fn RDDKernel::weight(self : RDDKernel, u : Double, h : Double) -> Double

    The weight this kernel assigns to an observation at running-variable offset u under bandwidth h.

    The identities are exact and are what expand_v110_test.mbt pins: weight(0, h) == 1.0 for every variant; weight(+-h, h) == 0.0 for triangular / epanechnikov / quadratic; weight(u, h) == 1.0 at every |u| <= h for uniform; weight(h, h) == exp(-0.5) for normal. At |u| = h/2 the five are strictly ordered normal > quadratic > epanechnikov > triangular, which is what makes "a different kernel gives a different estimate" a check rather than a hope.

    RFClassifier

    pub struct RFClassifier {
    n_trees : Int
    max_depth : Int
    min_samples_leaf : Int
    mtry : Int
    bootstrap_seed : Int
    trees : Array[CART]
    n_features : Int
    } derive(
    Debug
    )

    Random-forest classifier (binary). predict returns P(y = 1 | x), not a hard class label -- see the file header.

    RFClassifier::fit

    fn RFClassifier::fit(self : RFClassifier, x : Matrix, y : Array[Double]) -> RFClassifier

    Unweighted fit. v0.118.0 moved the body to fit_weighted; this inherent method keeps RFClassifier::new(..).fit(x, y) working.

    RFClassifier::fit_weighted

    fn RFClassifier::fit_weighted(self : RFClassifier, x : Matrix, y : Array[Double], w : Array[Double]) -> RFClassifier

    Build the forest on binary labels. y must be in {0, 1} -- the Gini criterion and the leaf-proportion reading of the node mean are both defined for that case only, and a multiclass y would silently produce class proportions that are not probabilities.

    Unlike the other learners in this package there is NO lenient fallback here: a non-binary y aborts rather than returning a plausible-looking probability. LogisticRegression is lenient for historical reasons; this learner is new, and there is no byte-compatibility to preserve.

    v0.118.0: w carries per-row weights into cart_fit, which turns both the leaf proportion and the Gini criterion into their WEIGHTED forms -- scikit-learn's semantics. An EMPTY w keeps every accumulation verbatim, so this is byte-identical to the v0.117.0 unweighted fit.

    RFClassifier::n_features

    fn RFClassifier::n_features(self : RFClassifier) -> Int

    Number of feature columns the forest was fit on; -1 before fit.

    RFClassifier::n_trees

    fn RFClassifier::n_trees(self : RFClassifier) -> Int

    Number of trees in the fitted forest; 0 before fit.

    RFClassifier::new

    fn RFClassifier::new(n_trees? : Int, max_depth? : Int, min_samples_leaf? : Int, mtry? : Int, bootstrap_seed? : Int) -> RFClassifier

    RFClassifier::predict

    fn RFClassifier::predict(self : RFClassifier, x : Matrix) -> Array[Double]

    Promote the Learner trait methods as explicit methods so the trait impls below are not reported as unused_value under --deny-warn. Same pattern as RFLearner.

    RFLearner

    pub struct RFLearner {
    n_trees : Int
    max_depth : Int
    min_samples_leaf : Int
    mtry : Int
    bootstrap_seed : Int
    trees : Array[CART]
    n_features : Int
    } derive(
    Debug
    )

    Random forest learner (regression). Fits a forest of CART trees on bootstrap samples; predicts by averaging leaf values across all trees.

    RFLearner::fit

    fn RFLearner::fit(self : RFLearner, x : Matrix, y : Array[Double]) -> RFLearner

    Unweighted forest fit. v0.118.0 moved the body to fit_weighted; this inherent method keeps every existing RFLearner::new(..).fit(x, y) call site working, since an inherent method wins over the trait method of the same name.

    RFLearner::fit_weighted

    fn RFLearner::fit_weighted(self : RFLearner, x : Matrix, y : Array[Double], w : Array[Double]) -> RFLearner

    Build the forest: n_trees bootstrap-sampled CART trees.

    v0.118.0: the body moved to fit_weighted, which takes the per-row weights and hands them to cart_fit. An EMPTY w takes the verbatim unweighted arithmetic at every accumulation, so fit_weighted(x, y, []) is byte-identical to the pre-v0.118.0 fit.

    RFLearner::n_features

    fn RFLearner::n_features(self : RFLearner) -> Int

    Number of feature columns the forest was fit on. Returns -1 if the learner has not been fit yet.

    RFLearner::n_trees

    fn RFLearner::n_trees(self : RFLearner) -> Int

    Number of trees in the fitted forest. Returns 0 if not yet fit (so callers can early-return on RFLearner::new() without an explicit "is_fitted" flag).

    RFLearner::new

    fn RFLearner::new(n_trees? : Int, max_depth? : Int, min_samples_leaf? : Int, mtry? : Int, bootstrap_seed? : Int) -> RFLearner

    RFLearner::predict

    fn RFLearner::predict(self : RFLearner, x : Matrix) -> Array[Double]

    Promote the Learner trait methods (fit, predict) as explicit methods on RFLearner. Without this extend, MoonBit raises an unused_value warning for the trait impls below (the trait methods are reached only via dispatch through cross_fit_predict_dispatch, never directly), and the --deny-warn build gate treats that as an error. Same pattern as the v0.54.0 ConstantLearner / NoopLearner setup.

    RddSimpleData

    type RddSimpleData

    RddSimpleData::d_get

    fn RddSimpleData::d_get(self : RddSimpleData) -> Array[Double]

    RddSimpleData::score_get

    fn RddSimpleData::score_get(self : RddSimpleData) -> Array[Double]

    RddSimpleData::theta_get

    fn RddSimpleData::theta_get(self : RddSimpleData) -> Double

    RddSimpleData::x_get

    fn RddSimpleData::x_get(self : RddSimpleData) -> Array[Double]

    RddSimpleData::y_get

    fn RddSimpleData::y_get(self : RddSimpleData) -> Array[Double]

    Ridge

    pub struct Ridge {
    alpha : Double
    coef_ : Array[Double]
    intercept_ : Double
    n_features_ : Int
    fitted : Bool
    } derive(
    Debug
    )

    L2-penalised linear model (ridge regression), scikit-learn Ridge(alpha=...) with fit_intercept=True.

    Minimises ||y - Xw||^2 + alpha * ||w||^2 with the intercept left UNPENALISED, obtained by centring. alpha is on the RAW sum-of-squares scale (contrast Lasso / ElasticNet, where it is on the 1 / 2n scale and therefore ~n times stronger).

    alpha must be strictly positive. alpha = 0 is plain OLS, which this package already has as LinearRegression; allowing it here would make the normal equations singular on a rank-deficient design for no benefit. The positivity also guarantees the Cholesky inside solve_spd cannot fail: X'WX is positive semi-definite and alpha * I is positive definite.

    Ridge::coefficients

    fn Ridge::coefficients(self : Ridge) -> Array[Double]

    The fitted slopes. Length is n_features(); the intercept is NOT included in this vector (unlike LinearRegression::coefficients(), whose length is p + 1 and whose first entry is the intercept).

    Ridge::fit

    fn Ridge::fit(self : Ridge, x : Matrix, y : Array[Double]) -> Ridge

    Fit on (x, y) with no sample weights. This is the byte-identical path to fit_weighted(x, y, ones) -- see the uniform-weight gate in expand_v120_test.mbt.

    Ridge::fit_weighted

    fn Ridge::fit_weighted(self : Ridge, x : Matrix, y : Array[Double], w : Array[Double]) -> Ridge

    Weighted ridge fit. Solves (Xc' W Xc + alpha I) beta = Xc' W yc on the CENTRED, weighted design and returns the intercept as y_mean - x_mean . beta.

    Note this is W, not W^2 and not a sqrt(W) row rescale: sklearn rescaling the rows by sqrt(w) inflates the effective alpha to alpha * mean(w), and a prototype that did that was wrong by 0.68 on the oracle fixture. Uniform w is a no-op here (it scales the whole system by a constant); non-uniform w is not.

    Ridge::intercept

    fn Ridge::intercept(self : Ridge) -> Double

    The fitted intercept.

    Ridge::n_features

    fn Ridge::n_features(self : Ridge) -> Int

    Number of features seen at fit time (excluding the intercept).

    Ridge::new

    fn Ridge::new(alpha? : Double) -> Ridge

    Build an un-fitted Ridge. alpha defaults to 1.0, matching scikit-learn's Ridge.

    Ridge::predict

    fn Ridge::predict(self : Ridge, x : Matrix) -> Array[Double]

    Predict x . beta + intercept for every row of x.

    Ridge::to_repr

    SampleSplitting

    pub(all) struct SampleSplitting {
    smpls : Array[Array[Fold]]
    folds : Int
    reps : Int
    }

    A validated caller-supplied sample splitting, with the fold and repetition counts DERIVED from the input rather than configured separately.

    SandwichKind

    pub enum SandwichKind {
    HC0
    HC1
    HC2
    HC3
    } derive(
    Debug
    )

    Choice of sandwich estimator. The four cases correspond to the Huber-White heteroskedasticity-consistent (HC) family; the IID / cluster-robust choice is selected at the function call site rather than via this enum because the cluster variant has a different signature (per-cluster sums + jackknife correction).

    SandwichKind::hc0

    HC0 constructor (factory wrapper). Returns the SandwichKind::HC0 variant so callers don't need to reach into the enum's private constructors.

    SandwichKind::hc1

    HC1 constructor (factory wrapper).

    SandwichKind::hc2

    HC2 constructor (factory wrapper).

    SandwichKind::hc3

    HC3 constructor (factory wrapper).

    SandwichKind::to_string

    fn SandwichKind::to_string(self : SandwichKind) -> String

    SensitivityResult

    pub struct SensitivityResult {
    rv : Double
    sigma2 : Double
    nu2 : Double
    cf_y : Double
    cf_d : Double
    max_bias : Double
    } derive(
    Debug
    )

    SsmData

    type SsmData

    Result of the SSM DGP.

    SsmData::d_get

    fn SsmData::d_get(self : SsmData) -> Array[Double]

    SsmData::s_get

    fn SsmData::s_get(self : SsmData) -> Array[Double]

    SsmData::theta_get

    fn SsmData::theta_get(self : SsmData) -> Double

    SsmData::x_get

    fn SsmData::x_get(self : SsmData) -> Matrix

    SsmData::y_get

    fn SsmData::y_get(self : SsmData) -> Array[Double]

    TuneGridScore

    pub(all) struct TuneGridScore {
    scores : Array[Double]
    best_index : Int
    }

    Per-candidate raw scores plus the index of the winner.

    TuneParam

    pub struct TuneParam {
    learner_l : LearnerDispatch
    learner_m : LearnerDispatch
    } derive(
    Debug
    )

    TuneParam — one row of the candidate grid passed to DoubleML*::tune. Holds a concrete nuisance-learner combination (one first-slot learner, one second-slot learner) to be scored against the others in the grid.

    The grid is Array[TuneParam] (typed, not Dict). Reasons for the typed wrapper vs a Dict[String, LearnerDispatch]:

    • compile-time key/value type safety (Dict keys are runtime-checked strings)
    • no Dict-construction FFI overhead per candidate
    • cleaner documentation (TuneParam::{learner_l, learner_m} is a documented struct, not a magic-key dict)

    Python upstream doubleml.DoubleMLPLR.tune uses a similar shape internally (a list of {"learner_l": ..., "learner_m": ...} type) but exposes it via kwargs to set_tune_params.

    Slot semantics - read this before filling a grid

    The fields are named for the partially linear outcome family. Several estimator families reuse the same two slots with a different meaning, and the field names do not change with the family:

    familyfirst slot (learner_l) actually holdssecond slot (learner_m)
    PLR PLIV LPLR PLPRoutcome learner E[y\|x]nuisance E[m\|x]
    APO APOS IRM IIVM CVAR PQ QTE SSM DID*treatment / propensity learner E[g\|d,x]outcome learner E[m0+d·m1\|x]
    RDDdiscontinuity-side learner E[y\|x]unused (RDD has a single slot)

    v0.108.0: the pre-existing doc comments in apo.mbt / irm.mbt / iivm.mbt described their pair as (learner_g, learner_m), which reads as a promise that the field is named learner_g. It is not
    • the field is learner_l and always has been. Those comments now name the slot's role instead. Use TuneParam::for_outcome / TuneParam::for_treatment so the call site carries the role.

    TuneParam::for_outcome

    fn TuneParam::for_outcome(learner_l : LearnerDispatch, learner_m : LearnerDispatch) -> TuneParam

    Intent-labelled alias of TuneParam::new for the partially linear families, where the first slot really is the outcome learner E[y|x] and the second is the nuisance E[m|x].

    Identical to TuneParam::new - it exists so the call site states the intent instead of relying on the field name. Prefer it on PLR / PLIV / LPLR / PLPR.

    TuneParam::for_treatment

    fn TuneParam::for_treatment(learner_g : LearnerDispatch, learner_m : LearnerDispatch) -> TuneParam

    Intent-labelled alias of TuneParam::new for the families whose first slot is the treatment / propensity learner E[g|d,x] and whose second slot is the outcome learner.

    Identical to TuneParam::new. Prefer it on APO / APOS / IRM / IIVM / CVAR / PQ / QTE / SSM and the DID* family, so a reader is not left inferring the slot meaning from a field called learner_l.

    TuneParam::new

    fn TuneParam::new(learner_l : LearnerDispatch, learner_m : LearnerDispatch) -> TuneParam

    Build a TuneParam from an explicit (learner_l, learner_m) pair. Equivalent to the struct literal { learner_l, learner_m } — exposed as a factory so callers don't need to know the field order of an internal struct.

    TuneResult

    pub struct TuneResult {
    best : TuneParam
    best_score : Double
    all_scores : Array[Double]
    } derive(
    Debug
    )

    Result of a DoubleMLPLR::tune call. Holds the best (by the chosen scoring rule, lower-is-better or higher-is-better per TuneScoring) candidate plus the full per-candidate score vector for diagnostics.

    all_scores[i] is the raw score for param_set[i] (in the grid order, NOT necessarily sorted). Lower-is-better / higher- is-better is the same convention as the chosen TuneScoring. For diagnostics where the user wants to compare across scoring rules, they should re-run tune with the other rule.

    TuneScoring

    pub enum TuneScoring {
    MSE
    RMSE
    NegMSE
    } derive(
    Debug
    )

    Scoring method for DoubleMLPLR::tune. All three are based on the OOF outcome-nuisance prediction l_hat = cross_fit(y | x) (TUNE_DESIGN.md §4.1: MSE-on-l_hat, the upstream Python default):

    • MSE: mean((y - l_hat)^2). Lower is better.
    • RMSE: sqrt(MSE). Lower is better.
    • NegMSE: -MSE. Higher is better (sklearn convention).

    R^2 scoring is deferred to a follow-up release — R^2 needs a defended against degenerate Var(y) = 0 and isn't needed by the standard PLR tune workflow.

    TuneScoring::parse

    fn TuneScoring::parse(s : String) -> TuneScoring

    Parse a scoring-method string into a TuneScoring enum. Accepted spellings (case-insensitive):

    • "MSE" / "mse" -> MSE
    • "RMSE" / "rmse" -> RMSE
    • "neg-MSE" / "neg-mse" / "NegMSE" -> NegMSE

    Unknown spellings abort with a descriptive message. Use this when accepting scoring-method names from CLI / JSON configs; pass the enum directly for typed code.

    WideDIDSubset

    type WideDIDSubset derive(
    Debug
    )

    The wide-format DID subset returned by preprocess_did_binary. Carries the covariate matrix, the first-differenced outcome, the binary G_indicator (d), and the binary t_indicator (t_indicator: 0 if the wide row originated from the t_value_pre period, 1 if from t_value_eval).

    add_ridge

    fn add_ridge(a : Matrix, lambda : Double) -> Matrix

    Add a small ridge lambda * I to a square matrix and return a copy. Useful for guarding X^T X against singularity when the design matrix is near-collinear.

    aggregate_coef_se

    fn aggregate_coef_se(coefs : Array[Double], ses : Array[Double]) -> (Double, Double)

    Per-repetition coefficient / standard-error aggregation for the DML family of estimators. Mirrors doubleml.utils._aggregate_coefs_and_ses in the upstream doubleml package:

    theta_hat = median(theta_1, ..., theta_R) ub_r = theta_r + 1.96 * se_r for each rep r ub_hat = median(ub_1, ..., ub_R) se_hat = (ub_hat - theta_hat) / 1.96

    The median is the "high median" — sort the array, take the entry at index length() / 2 (so for n = 1 it is the only entry, for n = 2 it is the upper-middle, for odd n it is the exact middle). This intentionally uses Array::sort + length() / 2 and does not depend on any external package.

    The two input arrays must have the same non-zero length; otherwise the call aborts at the require(...) precondition. The arrays are not mutated — the function makes its own copy of coefs before sorting.

    For n_rep == 1 the result is exactly (coefs[0], ses[0]) because:

    theta_hat = median([c]) = c ub_hat = median([c + 1.96 * s]) = c + 1.96 * s se_hat = (c + 1.96 * s - c) / 1.96 = s

    This is the regression-protection invariant that the four estimator fit()s rely on: when n_rep = 1 the new "per-rep + median" implementation must produce the same (theta, se) as the previous "average nuisances, then estimate" implementation.

    aggregate_event

    fn aggregate_event(coef_matrix : Array[Double], se_matrix : Array[Double], groups : Array[Int], periods : Array[Int], group_sizes : Array[Int]) -> DIDAggregationResult

    Event-study aggregation. For each unique event time e = t_eval - g, computes the weighted mean of all per-(g, t_eval) ATTs with t_eval - g = e.

    Pre-treatment cells (e < 0) are included as pre-trend estimates (matching upstream's default; upstream agg_weights only normalises post-treatment cells, but the per-event theta uses all selected cells, so the event-time profile includes pre-trend as a sanity check). Returns one entry per unique event time.

    aggregate_group

    fn aggregate_group(coef_matrix : Array[Double], se_matrix : Array[Double], groups : Array[Int], periods : Array[Int], group_sizes : Array[Int]) -> DIDAggregationResult

    Group aggregation. For each unique group value g_i in groups, computes the weighted mean of coef_matrix[gi, :] (the per-g_i post-treatment ATTs) with weights proportional to n_units_in_group_g_i (the share of units in group g_i).

    Arguments:
    • coef_matrix : row-major (n_groups, n_periods) ATT matrix from a fitted DoubleMLDIDCS / DoubleMLDIDMulti. coef_matrix[gi * n_periods + pi] is the ATT for group groups[gi] at period periods[pi]. Pre-treatment cells (e.g. t < g) are expected to be 0.0 (the DoubleMLDIDCS convention).
    • se_matrix : row-major (n_groups, n_periods) SE matrix (same shape as coef_matrix).
    • groups : the sorted-ascending unique group values.
    • periods : the sorted-ascending unique time-period values (the same times accessor from DoubleMLDIDCSData).
    • group_sizes : per-unit group sizes (one entry per unique row in the long-format panel). Must be aligned with groups; entry i is the number of units in groups[i].

    Returns a DIDAggregationResult with one entry per group.

    aggregate_time

    fn aggregate_time(coef_matrix : Array[Double], se_matrix : Array[Double], groups : Array[Int], periods : Array[Int], group_sizes : Array[Int]) -> DIDAggregationResult

    Time aggregation. For each unique time period t_j in periods, computes the weighted mean of all per-(g, t_j) ATTs (across groups) with weights proportional to group_sizes[g] (the share of units in each group).

    Pre-treatment cells (t < g) are skipped. Returns one entry per period.

    apply_calibration

    fn apply_calibration(config : PSProcessorConfig, ps : Array[Double], treatment : Array[Double], cv : Array[(Array[Int], Array[Int])]?) -> Array[Double] raise

    Apply the configured calibration method to a propensity-score vector. Returns Array[Double] raise InvalidCalibrationError: the v0.37.0 extraction lifts the previous abort() call from PSProcessor::adjust_ps into this helper so the unknown-method path becomes directly testable. PSProcessor::adjust_ps wraps the call in try ... catch { ... => abort(...) } to preserve pre-v0.37.0 process-death behavior.

    Note: PSProcessorConfig::new also has a require check on calibration_method (only "none" and "isotonic" are allowed). That check normally fires first; this helper's raise only fires if a config is constructed directly (bypassing new), which the new ps_processor_adjust_ps_raises_unknown_method test does deliberately.

    array_max

    fn array_max(v : Array[Double]) -> Double raise EmptyArrayError

    Returns the maximum element of v. Raises EmptyArrayError if v is empty. v0.41.0: signature changed from Double to Double raise EmptyArrayError to make the empty-array path testable. Callers that want the pre-v0.41.0 process-death behavior should catch and re-abort.

    array_min

    fn array_min(v : Array[Double]) -> Double raise EmptyArrayError

    Returns the minimum element of v. Raises EmptyArrayError if v is empty. v0.41.0: signature changed from Double to Double raise EmptyArrayError to make the empty-array path testable. Callers that want the pre-v0.41.0 process-death behavior should catch and re-abort.

    augment_one_col

    fn augment_one_col(x : Matrix, extra : Array[Double]) -> Matrix

    Augment a feature matrix with one extra column (used to add pi_hat to the design matrix for the conditional-outcome learners).

    augment_with_intercept

    fn augment_with_intercept(x : Matrix) -> Matrix

    Augment a feature matrix X (n x p) with a leading column of ones to form the design matrix used by the OLS fit. The result has shape n x (p + 1). Exposed as pub so other learners (e.g. the LogisticRegression IRLS fit) can reuse the same convention without duplicating the loop.

    bh_fdr_p_adjust

    fn bh_fdr_p_adjust(unadjusted : Array[Double]) -> Array[Double]

    Benjamini-Hochberg FDR correction.

    Algorithm (matches statsmodels.stats.multitest.multipletests with method='fdr_bh'):
    1. Sort unadjusted p-values ascending; let order be the resulting permutation of cell indices and ro its inverse.
    2. p_corrected_sorted[k] = min(1.0, p_sorted[k] * n / (k + 1)).
    3. Enforce monotonicity from the largest rank downward (this is the BH-specific direction; Holm goes the other way): p_corrected_sorted[k] = min(p_corrected_sorted[k],p_corrected_sorted[k + 1]).
    4. Re-order to original cell order via ro.

    p_corrected[k] >= p_unadjusted[k] is not guaranteed (BH controls FDR, not FWER); some adjusted p-values can be smaller than the unadjusted ones.

    bias_corrected_theta

    fn bias_corrected_theta(theta_hat : Double, bias_per_obs : Array[Double]) -> Double

    Generic scalar add-a-bias helper:

    theta_corrected = theta_hat + mean(bias_per_obs)

    where bias_per_obs[i] is a per-observation bias term supplied by the caller.

    THIS IS NOT A BIAS CORRECTION FOR A DML ESTIMATE, and nothing in this package calls it that way any more (v0.91.0). Two facts make score-based bias correction vacuous for these estimators:

    1. Under var_est.mbt's convention the estimating function is f(theta) = E[theta * psi_a + psi_b] and theta_hat is its root, so mean(f(theta_hat)) = mean(coef * psi_a + psi_b) = 0 IDENTICALLY -- the estimating function is orthogonal by construction, and any "correction" built from the score at the estimate has mean exactly zero.
    2. Through v0.90.0 every bias_corrected_coef passed bias_per_obs[i] = psi_b[i] - coef * psi_a[i], which is the score at -coef, not at coef. Its mean is mean_b + coef * mean_a = -2 * coef * mean_a, i.e. twice the estimate, so the accessor returned 3 * coef (algebraically, for every estimator). That is not a bias estimate; it is a guaranteed-wrong number. All twelve bias_corrected_coef methods are now documented no-ops that return coef unchanged (see e.g. DoubleMLIRM::bias_corrected_coef).

    The helper is retained ONLY for a caller who has a genuinely exogenous bias vector from outside the DML machinery (an analytic finite-sample correction, say). Passing the DML score here cannot correct anything, and v0.90.0 - v0.91.0 shipped documentation that claimed the opposite; do not repeat it.

    Preconditions:
    • bias_per_obs.length() >= 1.

    Returns the corrected scalar theta. Aborts on empty bias_per_obs.

    bonferroni_p_adjust

    fn bonferroni_p_adjust(unadjusted : Array[Double]) -> Array[Double]

    Bonferroni correction. p_corrected[k] = min(1.0, n *p_unadjusted[k]).

    box_muller_normal

    fn box_muller_normal(rng :
    Rand
    ) -> Double

    Standard normal sample via Box-Muller. Pairs (u1, u2) in [0, 1) to (z1, z2) ~ N(0, 1). The chacha8 RNG is uniform in [0, 1) per the upstream numpy.random default; we use the cosine for the first draw and the sine for the second to consume two uniforms per pair.

    box_muller_pair

    fn box_muller_pair(rng :
    Rand
    ) -> (Double, Double)

    Box-Muller pair (mu=0, sigma=1). Returns two independent standard normals per call. Shared helper used by the DGP modules (dgp_plr_CCDDHNR, dgp_irm, ...) to avoid duplicating the helper across files.

    build_row_unit_map

    fn build_row_unit_map(cluster : Array[Int], uniq : Array[Int]) -> Array[Int] raise ClusterDataError

    Build a row → unit-position map (length = n, each entry is the position of the row's unit in the unique-ids array produced by unique_units(cluster)). Aborts if any row's unit is missing from the uniq list (a row whose unit is not in uniq would be an unrecoverable data error).

    Returns Array[Int] raise ClusterDataError: the v0.36.0 conversion replaces the previous abort() call with raise ClusterDataError::MissingUnit(g) so the data-error path becomes directly testable. Callers that want the pre-v0.36.0 process-death behavior should catch the error and re-abort (this is what every DoubleMLXXX::fit_cluster does); callers that want to surface the error to downstream consumers should propagate via ?. The error type is declared at the top of this file so the cluster helper stack can share it.

    by_fdr_p_adjust

    fn by_fdr_p_adjust(unadjusted : Array[Double]) -> Array[Double]

    Benjamini-Yekutieli FDR correction.

    Algorithm (matches statsmodels.stats.multitest.multipletests with method='fdr_by'):
    1. Compute the harmonic-sum factor c = sum_{i=1}^{n} 1/i (a.k.a. H_n).
    2. Same as BH, but p_corrected_sorted[k] = min(1.0, p_sorted[k] * n * c / (k + 1)).
    3. Enforce monotonicity from the largest rank downward (same as BH).
    4. Re-order to original cell order via ro.

    The c factor accounts for the dependence structure under arbitrary dependence; the BY procedure is more conservative than BH but valid under weaker assumptions.

    chacha8_rng

    fn chacha8_rng(seed : Int) ->
    Rand

    Convenience constructor: chacha8_rng(seed) is shorthand for Rand::chacha8(seed=Bytes::from_array(seed_to_bytes(seed))). Use this anywhere the package's tests / demos need a deterministic RNG keyed by an integer seed.

    check

    #callsite(autofill(loc))
    fn check(condition : Bool, loc~ : SourceLoc) -> Unit raise PreconditionError

    Runtime precondition used by every public API and numerical kernel. #callsite(autofill(loc)) instructs the compiler to auto-inject the call-site source location into loc at every call, so the error payload points at the offending line. The loc~ labelled argument also lets callers forward their own loc when wrapping check (see require below).

    v0.48.0: check now raises PreconditionError::Violated(loc) instead of aborting. Every caller is expected to either (a) declare raise PreconditionError in its own signature (preferred for new code), or (b) wrap the call site in try { ... } catch { e :PreconditionError => abort(e.to_string()) } to preserve the pre-v0.48.0 abort behavior.

    The pre-v0.48.0 abort message format ("precondition failed at ") is preserved by the re-abort pattern in callers, so the diagnostic surface is unchanged for end users.

    check_make_violated

    #callsite(autofill(loc))
    fn check_make_violated(loc~ : SourceLoc) -> PreconditionError

    Construct a PreconditionError::Violated(loc) whose payload is the source location of the caller (the #callsite(autofill(loc)) attribute auto-injects the call-site SourceLoc into the loc parameter, so the diagnostic message points at the line that called this helper, not at this helper's own definition site).

    Added in v0.47.0 as a forward-compatible hook for the upcoming check/require → raise PreconditionError conversion. Today it has no prod caller (the central check/require still aborts), so the only usage is the regression test in check_test.mbt. In v0.48.0+, check will raise this error directly and this helper stays as an opt-in builder for callers that want to surface a custom precondition violation without going through the global check path.

    The pub visibility is required so the _test file can call it; see v0.37.0's apply_calibration for the same pub-but-test-only helper pattern.

    check_sample_splitting

    fn check_sample_splitting(smpls : Array[Array[Fold]], n_obs : Int) -> SampleSplitting raise PreconditionError

    Validate a caller-supplied sample splitting against n_obs and derive folds and reps from it.

    The MoonBit counterpart of upstream's _check_sample_splitting (doubleml/utils/_checks.py), enforcing the same contract:

    • every repetition carries the SAME number of folds;
    • within each repetition the test index sets are pairwise disjoint and their union is exactly [0, n_obs) -- a partition, not merely a cover;
    • every train and test index lies inside [0, n_obs).

    The derivation is the part that is easy to get backwards, so it is worth stating plainly: folds and reps come OUT of the supplied partition. Upstream is explicit that passing a two-fold split to a model built with n_folds=5 makes the model two-fold, and reproducing that is the whole point. On this path the estimator's own n_folds / n_rep are never consulted.

    The raise PreconditionError annotation is not decoration. An unqualified raise widens the try body's error set to "anything", and every caller's catch { PreconditionError::Violated(loc) => .. } then reports partial_match and refuses to build. Every failure raised here IS a PreconditionError (they are all require(..)), so naming the type is both accurate and what keeps the callers exhaustive.

    Upstream's _check_is_partition tests len(test_indices) ==n_obs plus full coverage, which is a permutation test; the seen array below checks disjointness and cover separately and reaches the same verdict with the failure attributable to which property broke.

    The single-fold "no sample splitting" case is NOT special-cased. Upstream detects it -- train and test both covering everything, with the swap also forming a partition -- and reports folds = 1. That partition satisfies the rules below with folds = 1, so the generic path lands on the same answer without a branch.

    cholesky

    fn cholesky(a : Matrix) -> Matrix

    Cholesky decomposition. Computes a lower triangular matrix L such that A = L * L^T for a symmetric positive-definite matrix A.

    The decomposition is performed in place. L is stored in the lower triangle of a (the upper triangle is not referenced and may contain garbage). Panics if a is not square or if the matrix is not positive definite (a non-positive pivot is treated as a fatal error).

    This is a textbook implementation. For the small problem sizes used in the DoubleML examples it is more than fast enough.

    clip_vec

    fn clip_vec(v : Array[Double], lo : Double, hi : Double) -> Array[Double]

    Clip every element of v to [lo, hi] in place (returns a new array; does not mutate the input).

    cluster_causal_param_and_se

    fn cluster_causal_param_and_se(psi_a : Array[Double], psi_b : Array[Double], folds_row : Array[Fold], fold_n_units : Array[Int], unit_rows : Array[Array[Int]], unit_fold : Array[Int], n_folds_u : Int, n_folds_per_cluster : Int) -> (Double, Double) raise VarEstClusterError

    Cluster-robust causal parameter + SE computation. Combines est_coef_cluster (fold-weighted ratio of cluster score sums) and var_est_cluster (unit-level cluster-robust SE) over the same per-row score elements. Used by every clustered-DML path that follows the PLR-style psi_a, psi_b pattern (PLR, IRM, PLPR, PLIV, IIVM). The n argument is the row count used for psi_res allocation; the caller pre-computes psi_a and psi_b at its converged coefficient / nuisance values.

    Returns (theta_r, se_r). The !VarEstClusterError annotation propagates the J-floor defensive guard from var_est_cluster (v0.34.0+) so callers can decide whether to abort, retry with a different seed, or surface the error to downstream consumers. All fit_cluster() methods in PLR / IRM / PLPR / PLIV / IIVM catch this error and re-abort to preserve the pre-v0.35.0 process-death behavior on pathological fold splits.

    cluster_sandwich_variance

    fn cluster_sandwich_variance(psi_a : Array[Double], psi : Array[Double], m_inv : Matrix, cluster_ids : Array[Int], n_params : Int) -> Double

    Cluster-robust sandwich variance for a scalar DML estimator (Arellano 1987, Cameron-Gelbach-Miller 2011):

    S_c = (sum_{i in c} psi[i])^2 var = M_inv[0,0]^2 * (sum_c S_c * n_c / (n_c - 1)) / n^2

    cluster_ids[i] is the 0-based cluster id of observation i. All observations in the same cluster share an id; cluster_ids.length() must equal psi_a.length(). The number of clusters is 1 + max(cluster_ids).

    The (n_c - 1) jackknife correction (small-cluster bias adjustment) is applied per cluster. When a cluster has a single observation (n_c == 1) the corresponding (n_c - 1) == 0 divisor would blow up; we clip the per-cluster correction to n_c / max(n_c - 1, 1) == 1 in that case so single-observation clusters contribute their raw S_c without finite-sample correction (the statsmodels.cov_type='cluster' convention).

    The meat is E[sum_c (sum_{i in c} psi[i])^2], estimated as sum_c S_c / n, and the estimator is sqrt(n) *(theta_hat - theta) -> N(0, M_inv B M_inv'), so the returned VARIANCE of theta_hat carries the outer 1 / n as well -- hence the n^2 divisor. With all-singleton clusters sum_c S_c == sum_i psi[i]^2 and the formula collapses to sandwich_variance_hc0 exactly, i.e. to se(). (v0.90.0 and earlier divided by n once and summed psi_a[i] * psi[i] within the cluster; both are corrected here -- see the file header.)

    Preconditions:
    • psi_a.length() == psi.length() == cluster_ids.length().
    • M_inv.rows() == 1 and M_inv.cols() == 1.
    • cluster_ids[i] >= 0 for all i.

    Returns the scalar cluster-robust variance. Aborts on malformed input.

    compute_sensitivity_bias

    fn compute_sensitivity_bias(sigma2 : Double, nu2 : Double, psi_sigma2 : Array[Double], psi_nu2 : Array[Double]) -> (Array[Double], Array[Double])

    Cinelli & Hazlett (2020) omitted-variable bias analysis for DML estimators. Given the per-density influence-function scalars sigma2, nu2, psi_sigma2, psi_nu2, this helper computes the maximum bias from an unobserved confounder that shifts the outcome nuisance sigma2 by psi_sigma2 and the treatment nuisance nu2 by psi_nu2.

    The two quantities returned are
    • max_bias: the worst-case absolute bias on the estimator, equal to sqrt(sigma2 * nu2).
    • psi_max_bias: the gradient of max_bias with respect to the confounding strength vector, equal to (sigma2 * psi_nu2 + nu2 * psi_sigma2) / (2 * max_bias).

    Both quantities are length-n_obs arrays (per-observation influence vectors); the caller typically averages over the sample to get the scalar bias. Matches the upstream doubleml.utils._sensitivity._compute_sensitivity_bias helper.

    cross_fit_predict

    fn[T : Learner] cross_fit_predict(learner : T, x : Matrix, y : Array[Double], folds : Array[Fold], sample_weight? : Array[Double]) -> Array[Double]

    Cross-fit prediction helper. For each fold, fit the learner on the training slice (x[train], y[train]) and predict on the test slice x[test]. The returned vector has length n_obs and contains the predictions in the original observation order. Assumes the folds together cover [0, n_obs).

    v0.118.0: sample_weight? defaults to [] (unweighted), which is the byte-identical pre-v0.118.0 path for every learner and every estimator. When non-empty it must be row-aligned with x and y, and it is SLICED per fold alongside them -- a weight must follow its own row into the training slice, or the fit is weighted on the wrong observations. Mirrors scikit-learn's sample_weight=None.

    cross_fit_predict_conditional_dispatch

    fn cross_fit_predict_conditional_dispatch(learner : LearnerDispatch, x : Matrix, y : Array[Double], folds : Array[Fold], train_cond : Array[Int]) -> Array[Double]

    Cross-fitted prediction where the TRAINING rows are additionally restricted by train_cond, but PREDICTION still covers every row. v0.147.0+.

    This exists because cross_fit_predict_dispatch always fits on the whole training fold, and one estimator needs upstream's other convention. Mirrors upstream fit_predict(..., train_cond=...) (did/tests/_utils.py:33-45):

    train_index_cond = np.intersect1d(train_cond, train_index) y_hat.append(ml_model.fit(x[train_index_cond], y[train_index_cond]).predict(x[test_index]))

    The condition gates what the model is FITTED on, NOT what it predicts on. Reading that difference the other way -- restricting the output as well -- would leave rows in the empty/fallback-cell regime and quietly corrupt the nuisance, which is exactly the kind of transcription error this comment exists to prevent.

    A fold whose conditioned training set is empty is SKIPPED, leaving its test rows at 0.0. That is the one case where the output is not a prediction, and callers must treat it as "no information" rather than a zero nuisance.

    cross_fit_predict_dispatch

    fn cross_fit_predict_dispatch(learner : LearnerDispatch, x : Matrix, y : Array[Double], folds : Array[Fold], sample_weight? : Array[Double]) -> Array[Double]

    Cross-fit a LearnerDispatch over the given folds. This is the dispatch wrapper around cross_fit_predict[T : Learner] (which is generic in T); each match arm binds a concrete T so the generic call resolves correctly.

    Adding a new learner type: add the corresponding match arm here (and the enum variant in LearnerDispatch).

    v0.118.0: sample_weight? is forwarded through every arm; empty means unweighted, which is the byte-identical pre-v0.118.0 path.

    did_bootstrap_t_stat

    fn did_bootstrap_t_stat(weights : Array[Double], psi : Array[Double], se : Array[Double], n_rep_boot : Int, n_obs : Int, n_thetas : Int) -> Array[Double]

    Per-cell multiplier bootstrap t-statistics.

    Parameters:
    • weights : Array[Double] — flat row-major, shape [n_rep_boot, n_obs]. Drawn by draw_bootstrap_weights(method_name, n_rep_boot,n_obs, seed) in the calling estimator.
    • psi : Array[Double] — flat row-major, shape [n_thetas, n_obs]. Each row is the per-observation influence function psi_k[i] for theta k. For DoubleMLDIDMulti, this is a slice of psi_matrix[flat * n_obs : (flat+1) * n_obs] for each group-time (gi, pi). For DoubleMLDIDCrossSection, this is a single row [n_obs] of coef * psi_a[i] + psi_b[i].
    • se : Array[Double] — length n_thetas. The standard error of the per-cell estimate. For DIDMulti, se[k] = se_matrix[gi * n_periods + pi]. For DIDCrossSection, se[0] = sqrt(mean(psi^2)).
    • n_rep_boot : Int — number of bootstrap reps.
    • n_obs : Int — sample size.
    • n_thetas : Int — number of theta values (= gt_combinations.length() for DIDMulti; = 1 for DIDCrossSection).

    Returns:
    • boot_t_stat : Array[Double] — flat row-major, shape [n_rep_boot, n_thetas]. Indexed as boot_t_stat[b * n_thetas + k] for rep b, theta k.

    Algorithm: for b in 0..n_rep_boot: for k in 0..n_thetas: if se[k] == 0: skip (pre-treatment / empty cell) s = 0 for i in 0..n_obs: s += weights[b * n_obs + i] * psi[k * n_obs + i] boot_t_stat[b * n_thetas + k] = s / (sqrt(n) * se[k])

    Rows where se[k] == 0 are set to 0 (matches the upstream convention: empty cells produce no bootstrap variation).

    dot

    fn dot(a : Array[Double], b : Array[Double]) -> Double

    Dot product of two equal-length vectors.

    The accumulator is Kahan-compensated so the result is robust to near-cancellation between terms (see kahan.mbt).

    double_cross_fit_predict

    fn[T : Learner] double_cross_fit_predict(learner : T, x : Matrix, y : Array[Double], outer : Array[Fold], n_inner : Int, seed : Int, sample_weight? : Array[Double]) -> Array[Array[Double]]

    Double cross-fit prediction helper used by IRM-style models (e.g. LPLR). outer is the row-level outer-fold partition; for each outer fold, the model's training slice is re-split into n_inner inner folds via kfold, the learner is fit on each inner training slice and predicts on the corresponding inner test slice, and the resulting n_inner arrays of predictions are returned. The outer model then has, for each outer test row, an inner OOF prediction — used downstream to construct nuisance W and the preliminary beta.

    Returns an Array[Array[Double]] of length outer.length(), one entry per outer fold. Each entry has length inner_train_count (the size of the outer fold's training slice); values are filled at the original training-row positions in the order the inner folds visit them.

    v0.118.0: sample_weight? is sliced through the DOUBLE indexing -- the inner folds index into the outer training slice, so a weight follows train_idx[ifold.train_idx[k]], not ifold.train_idx[k].

    double_cross_fit_predict_dispatch

    fn double_cross_fit_predict_dispatch(learner : LearnerDispatch, x : Matrix, y : Array[Double], outer : Array[Fold], n_inner : Int, seed : Int, sample_weight? : Array[Double]) -> Array[Array[Double]]

    v0.62.0+: LearnerDispatch wrapper around double_cross_fit_predict[T : Learner]. Routes through the same enum pattern as cross_fit_predict_dispatch so the ml_m per-fit override (typically LogisticRegression for LPLR) actually reaches the inner OOF predictions.

    draw_bootstrap_weights

    fn draw_bootstrap_weights(method_name : String, n_rep_boot : Int, n_obs : Int, seed : Int) -> Array[Double] raise BootstrapMethodError

    Draw n_rep_boot weight vectors of length n_obs from the chosen multiplier distribution. The chacha8 RNG is seeded with seed for reproducibility.

    Supported methods:
    • "normal": w[i] ~ N(0, 1) per the Box-Muller transform.
    • "Bayes": w[i] = exp(1) - 1 (mean 0, var 1; uses the chacha8-driven rng.double() for the uniform quantile input to the inverse-CDF).
    • "wild": w[i] = x[i] / sqrt(2) + (y[i]^2 - 1) / 2 with x, y ~ N(0, 1). The wild bootstrap is robust to heteroskedasticity in the influence-function residuals.

    Returns a row-major Array[Double] of length n_rep_boot * n_obs. The first n_obs entries are the first bootstrap replication's weights, the next n_obs are the second replication, and so on.

    Returns Array[Double] raise BootstrapMethodError: the v0.37.0 conversion replaces the previous abort() call with raise BootstrapMethodError::UnknownMethod(method_name) so the unknown-method path becomes directly testable. Callers that want the pre-v0.37.0 process-death behavior should catch the error and re-abort (this is what every prod caller does); callers that want to surface the error to downstream consumers should propagate via ?. The error type is declared in kfold.mbt so the cluster helper stack can share it.

    empirical_quantile

    fn empirical_quantile(arr : Array[Double], q : Double) -> Double raise

    v0.66.0+: empirical (1 - q) quantile of an Array[Double] via insertion sort. Used by the joint confidence-interval max-|t| critical-value computation (the per-rep max |t| over all thetas is collected into a length-n_rep_boot array; the (1 - alpha) quantile is the joint critical value). Insertion sort is O(n^2) but n is small (n_rep_boot is typically 500), so the cost is negligible compared to the bootstrap itself.

    enet_objective

    fn enet_objective(coef : Array[Double], x : Matrix, y : Array[Double], w : Array[Double], alpha : Double, l1_ratio : Double) -> Double

    Objective value of an ElasticNet fit at coef, on the SAME 1/(2n) normalisation the docs for alpha describe:

    F = 0.5 / n * sum_i wt_i r_i^2 + alpha * l1_ratio * ||coef||_1 + 0.5 * alpha * (1 - l1_ratio) * ||coef||^2

    with r = y_centred - X_centred coef and wt the mean-1 weights. This is the criterion the v0.120.0 gates compare across implementations, because it is flat near the optimum while the coefficient vector is not; see the module header.

    Note the deliberate asymmetry with the SOLVER: inside enet_coordinate_descent the objective is the unnormalised 0.5 * sum wt_i r_i^2 with l1_reg = alpha * l1_ratio * n, which is the same quantity scaled by n and is what makes the coordinate update soft(rho, l1_reg) / (norm_sq + l2_reg) come out right. Conflating the two was a real v0.120.0 bug caught by the oracle gate on its first run: the objective came back ~n times too large.

    est_coef_cluster

    fn est_coef_cluster(psi_a : Array[Double], psi_b : Array[Double], folds_row : Array[Fold], fold_n_units : Array[Int]) -> Double

    Clustered-path causal parameter (LinearScoreMixin._est_coef, cluster branch): the root of the fold-weighted score sums. Each test fold contributes sum(score on its test rows) scaled by 1 / |I_k|, the number of units in the fold's test cluster set:

    theta = - sum_k w_k * sum_{i in k} psi_b / sum_k w_k * sum_{i in k} psi_a, w_k = 1/|I_k|.

    expand_unit_folds_to_rows

    fn expand_unit_folds_to_rows(cluster : Array[Int], folds_u : Array[Fold], row_unit : Array[Int]) -> (Array[Fold], Array[Int], Array[Int])

    Clustered-path fold builder. Takes the unit-level fold partition (a kfold(n_units, n_folds, seed) result) and expands it to a row-level fold partition where every row of a given unit lands on the same side of every split. Also returns the per-unit test-fold index and the per-fold unit count, both of which are needed by est_coef_cluster / var_est_cluster.

    cluster : length-n vector of unit ids folds_u : unit-level folds (each Fold carries test_indices = unit positions) row_unit : length-n row → unit-position map (use build_row_unit_map)

    Returns (folds_row, unit_fold, fold_n_units):
    • folds_row : length-n_folds row-level folds
    • unit_fold : length-n_units per-unit test-fold index
    • fold_n_units : length-n_folds per-fold unit count

    expit

    fn expit(x : Double) -> Double

    Public elementwise expit(x) = 1 / (1 + exp(-x)) for callers that need to evaluate the logistic link (e.g. LPLR's nonlinear score). Numerically safe at both tails: for x >= 0 the 1/(1+exp(-x)) form avoids large exp; for x < 0 the exp(x)/(1+exp(x)) form avoids underflowing to 0. The output is in the open interval (0, 1) for any finite input.

    filter_by_value

    fn filter_by_value(idx : Array[Int], cond : Array[Double], target : Double) -> Array[Int]

    Filter idx to keep only entries i for which cond[i] matches the desired value target. Used to build the conditional sample splits for g0/g1/r0/r1.

    filter_indices

    fn filter_indices(idx : Array[Int], mask : Array[Double]) -> Array[Int]

    Filter idx to keep only entries i for which mask[i] is true.

    filter_two_values

    fn filter_two_values(idx : Array[Int], m1 : Array[Double], v1 : Double, m2 : Array[Double], v2 : Double) -> Array[Int]

    Filter idx to keep only entries i where both mask1[i] and mask2[i] equal the given values. Used to build train_d{s_value}_s1 for the conditional-outcome learners.

    fit_isotonic

    fn fit_isotonic(x : Array[Double], y : Array[Double]) -> (Array[Double], Array[Double])

    Sort (x, y) pairs by x, pool observations that share an x, and apply PAVA to the pooled sequence. Returns (sorted_x, sorted_y_hat) where sorted_y_hat is the isotonic regression of y on x. The two arrays have the same length as the input.

    v0.159.0: tied x values are pooled into their group mean BEFORE the pool-adjacent-violators step, so the fitted curve is a function of x. With all-distinct x every group has size 1 and the pooling is exactly the identity, so this changes nothing on tie-free data.

    Use predict_isotonic to apply the fitted model to new x values (or to the same x for in-sample predictions, which is the upstream IsotonicRegression default).

    fit_predict_one_dispatch

    fn fit_predict_one_dispatch(learner : LearnerDispatch, x_train : Matrix, y_train : Array[Double], x_test : Matrix) -> Array[Double]

    Single-fit + single-predict helper for a LearnerDispatch. Used by estimators (e.g. DoubleMLSSM for the augmented (X, D) -> S pi fit, DoubleMLIIVM for conditional subsets where the augmenting column changes the feature matrix shape) that need a one-off fit on a subset followed by a predict on a different-shape test set — i.e. cases where cross_fit_predict_dispatch (which slices a single matrix by train/test indices) doesn't apply.

    v0.59.0+.

    folds_to_cv_partition

    fn folds_to_cv_partition(folds : Array[Fold]) -> Array[(Array[Int], Array[Int])]

    Adapt an estimator's Array[Fold] to the cv partition shape that adjust_ps / isotonic_calibrate_cv take.

    v0.159.0: adjust_ps declares a cv parameter and upstream passes cv=smpls at every call site (did_binary.py:537, iivm.py:371, cvar.py:343, ...). Before this, NO estimator passed it -- the parameter was reachable only from tests -- so a configured cv_calibration=true silently calibrated on default_5fold(n), a DIFFERENT partition (five contiguous blocks) from the estimator's own cross-fitting folds. The calibration folds are supposed to BE the cross-fitting folds.

    kfold's Fold guarantees that the test indices are disjoint and together cover [0, n_obs) (kfold.mbt), which is exactly the precondition isotonic_calibrate_cv enforces before it raises CalibrationFittingError::IncompleteCVPartition -- so passing an estimator's own folds cannot trip that guard.

    g_cross_fit_calls

    fn g_cross_fit_calls() -> Int

    Read the current g cross-fit count. Public for tests.

    gain_statistics

    fn gain_statistics(dml_long : GainStatsSource, dml_short : GainStatsSource) -> GainStatsResult

    Compute the gain-statistic benchmark values for cf_y, cf_d, rho, and delta_theta from two fitted DML models: dml_long (which includes all observed confounders) and dml_short (which excludes one or more "benchmark" confounders).

    Each of dml_long / dml_short exposes four per-rep arrays (shape (n_coef, n_rep)):
    • all_coef: the per-rep coefficient estimates.
    • var_y_residuals: the per-rep outcome-residual variance (i.e. sigma2).
    • nu2: the per-rep treatment-residual variance (i.e. the squared norm of the Riesz representer).
    • var_y: the overall outcome variance (scalar, shared between dml_long and dml_short).

    The algorithm (matches upstream doubleml.utils.gain_statistics.gain_statistics):
    1. R2_y_long = 1 - var_y_residuals_long / var_y, R2_y_short = 1 - var_y_residuals_short / var_y, R2_riesz = nu2_short / nu2_long.
    2. cf_y = clip((R2_y_long - R2_y_short) / (1 - R2_y_long), 0, 1).
    3. cf_d = clip((1 - R2_riesz) / R2_riesz, 0, 1).
    4. delta_theta = median(dml_short.all_coef - dml_long.all_coef, axis=rep).
    5. rho = median(sign(delta_theta) * |delta_theta| / sqrt(var_g * var_riesz)), where var_g = var_y_residuals_short - var_y_residuals_long and var_riesz = nu2_long - nu2_short (clipped to [-1, 1] and 0 when the denominator is 0).
    6. Return the median across reps for each coefficient.

    The per-coefficient cf_y and cf_d benchmark the "tipping point" of the sensitivity analysis: a confounder with strength <= cf_y (resp. cf_d) cannot change the conclusion. rho and delta_theta are diagnostic: they tell the user the direction and magnitude of the change between dml_long and dml_short.

    gaussian_kde

    fn gaussian_kde(y : Array[Double], x : Double, h : Double) -> Double

    Gaussian KDE density estimate at x, evaluated using samples y[0..n) and bandwidth h:

    f_hat(x) = (1 / (n * h * sqrt(2*pi))) * sum_i exp(-(x - y_i)^2 / (2 * h^2))

    Returns a non-negative density. The constant sqrt(2 * pi) =1.7724538509055159 is the standard Gaussian normalisation factor.

    gaussian_kde_weighted

    fn gaussian_kde_weighted(y : Array[Double], w : Array[Double], x : Double, h : Double) -> Double

    Weighted Gaussian KDE density estimate at x, evaluated using samples y[0..n) with per-sample weights w[0..n) and bandwidth h:

    f_hat(x) = (1 / (h * sqrt(2*pi))) * sum_i w[i] * exp(-(x - y[i])^2 / (2*h^2))

    Note: the weights enter without a / sum(w) normalisation, so this is a weighted KDE estimate (not a probability density unless sum(w) == 1). Used by DoubleMLLPQ::fit to compute the numerical derivative of mean(psi(theta)) w.r.t. theta:

    d/dtheta mean(psi_ipw) = (1/n) * sum_i w_i * delta(y_i - theta) ≈ (1/n) * f_hat_weighted(theta)

    where w_i = sign * (z_i/m_i - (1-z_i)/(1-m_i)) * treated_i / comp is the IPW coefficient. The derivative feeds directly into the delta-method variance se = sqrt(gamma / (deriv^2 * n)).

    hash_cluster_ids

    fn hash_cluster_ids(cluster_ids : Array[Int]) -> UInt64

    Compute the cache-key hash of a cluster-ids vector. v0.82.0+: an empty vector hashes to 0 (the IID sentinel); a non-empty vector hashes via FNV-style fold over its entries. Two structurally-different cluster partitions produce different hashes so the cache invalidates on a cluster redefinition (e.g. a switch from individual-level clustering to individual-by-time or a different treatment-period partition).

    hash_data

    fn hash_data(x : Matrix, y : Array[Double], d : Array[Double], z? : Array[Double], cluster_vars? : Array[Int]) -> UInt64

    64-bit content hash of the data passed to a DML estimator. Combines the data dimensions, a sampled slice of X (first 10 + last 10 rows, first column), and the full content of y, d, z, and cluster_vars.

    The hash is NOT cryptographic -- it is designed to detect accidental input mutations (row reordering, scaling, value edits, dimension changes) at O(n) cost. A malicious caller could craft two different datasets that share the same hash; that is acceptable for a memoization invalidation key.

    hash_hyperparams

    fn hash_hyperparams(estimator_kind : String, ml_g : LearnerDispatch, ml_m : LearnerDispatch, propensity_clip : Double, ps_processor_config? : PSProcessorConfig?) -> UInt64

    Hyperparameter fingerprint. Combines the estimator family name, the two nuisance-learner dispatch tags (ml_g / ml_m), and the propensity-clip tolerance (only used by IRM but included unconditionally for cache-key uniformity).

    Each LearnerDispatch variant is matched on a tag string ("linear_regression" / "logistic_regression" / "constant" / "noop" / "random_forest" / "gradient_boosting"). The variant's tunables (e.g. RF n_trees / max_depth) are NOT folded into the key in this release -- the same learner tag re-used with different tunables will hit the cache and yield stale predictions. Users who change tunables across calls must call DoubleMLIRM::clear_cache() first. This trade-off keeps the key short and matches the conventional "same learner name = same learner" contract used by upstream doubleml-for-py.

    holm_bonferroni_p_adjust

    fn holm_bonferroni_p_adjust(unadjusted : Array[Double]) -> Array[Double]

    Holm-Bonferroni stepdown correction. Sort unadjusted p-values ascending, then p_corrected_sorted[k] =max((n - k) * p_sorted[k], p_corrected_sorted[k - 1]), clipped to 1.0. Re-order to original cell order.

    inv_spd

    fn inv_spd(a : Matrix) -> Matrix

    Invert a symmetric positive-definite matrix using the Cholesky factorisation A = L L^T. Returns the full inverse matrix. Used by LinearRegression::fit to populate the (X^T X)^{-1} diagonal that drives the per-coefficient standard errors. Small (≤ a few hundred rows in DML) so the O(n^3) cost is invisible at the test scale.

    irm_style_sensitivity

    fn irm_style_sensitivity(theta : Double, residuals : Array[Double], psi_a : Array[Double], cf_y : Double, cf_d : Double) -> SensitivityResult raise

    v0.66.0+: shared inner for the IRM-style sensitivity_analysis (PLR / IRM / PLIV / IIVM / APO / APOS / SSM / DID / DIDBinary / DIDCS / DIDCSBinary). Caller passes the outcome-residual array (residuals[i] = y[i] - l_hat[i], where l_hat is the outcome nuisance prediction) and the per-observation influence-function denominator psi_a (already persisted by v0.64.0).

    v0.131.0: this is now exactly irm_style_sensitivity_from_elements(..., psi_a^2, psi_a, ...) -- i.e. nu2_score_element = psi_a^2 and riesz_rep = psi_a. The C&H core lives in that function, which additionally implements upstream's nu2 <= 0 fallback (double_ml.py:1634-1641) from the Riesz representer.

    IMPORTANT SCOPE NOTE: nu2 = mean(psi_a^2) is upstream's fallback formula, not its primary one. Upstream's primary is mean(2 * m_alpha - rr^2) with rr the RIESZ REPRESENTER, a different object from this port's psi_a (irm.py:422, did_cs_binary.py:932). So for the seven estimators whose upstream sets _sensitivity_implemented = True (did, did_binary, did_cs, did_cs_binary, apo, irm, plr) this convention is a known divergence. DoubleMLDIDCSBinary was migrated in v0.131.0; the rest still need their own per-estimator m_alpha / rr plumbing, each with its own measurement pass.

    cf_y / cf_d are user-supplied benchmarking upper bounds (defaults 0.05), passed through unmodified.

    irm_style_sensitivity_cluster

    fn irm_style_sensitivity_cluster(theta : Double, residuals : Array[Double], psi_a : Array[Double], cluster_ids : Array[Int], cf_y : Double, cf_d : Double, kernel_weights? : Array[Double]) -> SensitivityResult raise

    irm_style_sensitivity_cluster_from_elements

    fn irm_style_sensitivity_cluster_from_elements(theta : Double, residuals : Array[Double], nu2_score_element : Array[Double], riesz_rep : Array[Double], cluster_ids : Array[Int], cf_y : Double, cf_d : Double, kernel_weights? : Array[Double]) -> SensitivityResult raise

    v0.131.0: cluster-robust counterpart of irm_style_sensitivity_from_elements.

    Same migration as the IID variant: the cluster sums are taken over the per-observation nu2 score element 2 * m_alpha - rr^2 rather than over psi_a. Everything else -- the cluster baselines, the per-observation centring, the bias expression -- is unchanged and already faithful (max_bias = sqrt(sigma2 * nu2) matches upstream _compute_sensitivity_bias, utils/_sensitivity.py:5).

    The nu2 <= 0 fallback mirrors the IID variant's, so the two entry points cannot disagree on a degenerate fit.

    irm_style_sensitivity_cluster_multi

    fn irm_style_sensitivity_cluster_multi(theta_array : Array[Double], residuals_matrix : Array[Array[Double]], psi_a_matrix : Array[Array[Double]], cluster_ids : Array[Int], cf_y : Double, cf_d : Double) -> Array[SensitivityResult] raise

    v0.73.0+: multi-theta analogue of irm_style_sensitivity_cluster. Loops over the per-theta residuals_matrix[k] / psi_a_matrix[k] (each a length-n row corresponding to one "theta" of a per-cell or per-coef estimator) and routes each row through the single-theta irm_style_sensitivity_cluster helper with the same shared cluster_ids. Output is an Array[SensitivityResult] of length theta_array.length().

    Each row's cluster_ids are identical across thetas (one observation belongs to exactly one cluster regardless of which theta's IF you're looking at). The cluster-summed sigma2_cluster / nu2_cluster are therefore shared across all thetas in principle, but the per-theta call to irm_style_sensitivity_cluster recomputes them on each iteration; the cost is O(n * n_thetas) extra work and the numerical result is identical (the cluster sums only depend on the rows, not on theta_array[k]).

    Preconditions:
    • theta_array.length() == residuals_matrix.length() ==psi_a_matrix.length().
    • Each row of residuals_matrix and psi_a_matrix has the same length as cluster_ids (so the inner irm_style_sensitivity_cluster preconditions fire as well).

    irm_style_sensitivity_from_elements

    fn irm_style_sensitivity_from_elements(theta : Double, residuals : Array[Double], nu2_score_element : Array[Double], riesz_rep : Array[Double], cf_y : Double, cf_d : Double) -> SensitivityResult raise

    v0.131.0: the Cinelli & Hazlett core, parameterised by the per-observation nu2 SCORE ELEMENT rather than by psi_a.

    WHY THIS EXISTS. irm_style_sensitivity below hard-codes nu2 = mean(psi_a^2). That happens to coincide with upstream's formula for some estimators and emphatically not for others. Upstream's own primary definition (irm.py:422, did_cs_binary.py:932) is

    nu2_score_element = 2 * m_alpha - rr^2 nu2 = mean(nu2_score_element)

    where m_alpha is the C&H sensitivity weight and rr the Riesz representer -- NOT the score's psi_a, which is a different object. mean(rr^2) is upstream's fallback, used only when nu2 <= 0 (double_ml.py:1634-1641).

    So an estimator whose upstream implements sensitivity must pass its own nu2_score_element and riesz_rep here. irm_style_sensitivity is now exactly this function with nu2_score_element = psi_a^2.

    DEAD-CODE NOTE, measured while adding this: psi_sigma2 and psi_nu2 are computed here and then feed only psi_max_bias, which every caller discards (let (max_bias_arr, _) = ...). The returned max_bias is sqrt(sigma2 * nu2) and rv is |theta| / sqrt(sigma2 * nu2), neither of which depends on them. So the ENTIRE reported sensitivity result is a function of sigma2 and nu2 alone. That is why getting nu2 right is the whole job, and it is the same "computed and handed to a callee that never uses it" shape found in CVaR's sensitivity path in v0.126.0.

    isotonic_calibrate_cv

    fn isotonic_calibrate_cv(x : Array[Double], y : Array[Double], cv : Array[(Array[Int], Array[Int])]?) -> Array[Double] raise CalibrationFittingError

    K-fold cross-validated isotonic calibration. For each fold (train_idx, test_idx), fit an isotonic regression on (x[train], y[train]) and predict at x[test]. The output array is built in the original x order (not the fold order) by writing out[test_idx[i]] = pred[i].

    cv is a list of (train_idx, test_idx) pairs. When cv =None, a deterministic 5-fold split with seed=3141 is generated via kfold. Each fold's prediction is independent of every other fold's, so this is embarrassingly parallel (sequential here for simplicity, but the per-fold work is O(n log n) for the sort + O(n) for the PAVA scan).

    Returns Array[Double] raise CalibrationFittingError: the v0.38.0 conversion replaces the previous abort() call with raise CalibrationFittingError::IncompleteCVPartition when the cv partition fails to cover every input index. Callers that want the pre-v0.38.0 process-death behavior should catch the error and re-abort (this is what apply_calibration does); callers that want to surface the error to downstream consumers should propagate via ?.

    joint_sensitivity

    fn joint_sensitivity(inputs : Array[JointSensitivityInput], alpha : Double) -> JointSensitivityResult raise

    v0.77.0+: cross-estimator joint coverage + joint RV. Cinelli & Hazlett (2020) §3.6 extension to the multi-estimator setting. Given K estimators on the same dataset, each producing (coef_k, se_k, sigma_sq_k, rho_k), the function computes:

    1. Bonferroni-corrected alpha per test = alpha / K. For K=2 and alpha=0.05, this is 0.025; the two-sided critical value z_{1 - alpha/(2K)} widens for K >= 2.
    2. Per-estimator joint CI at alpha / K: standard Wald theta_k +/- z_{1 - alpha/(2K)} * se_k.
    3. Per-estimator RV: the Cinelli-Hazlett bias-to-zero confounding-strength ratio. We use the simplified form |theta| / sqrt(sigma_sq * |rho|) (a v4 sentinel 1.0e300 is returned when sigma_sq == 0 or |rho| ==0 since the ratio is degenerate in those regimes).
    4. Per-estimator RV_q: the confounding strength that would push |theta| below the z_{alpha/K} * se significance threshold: rv_q = (se * z) / sqrt(theta^2 - (se * z)^2) when |theta| > se * z, else 1.0e300.
    5. Joint CI: the union of per-estimator CIs at alpha / K = [min(per_estimator_lower), max(per_estimator_upper)].
    6. Joint RV: min(per_estimator_rv) — a confounder that would tip the LEAST-robust single estimator would also tip the joint conclusion (Bonferroni-aligned joint-RV).
    7. Joint RV_q: min(per_estimator_rv_q).

    Special cases:
    • inputs.length() == 0: returns a zero-valued result with num_estimators = 0, bonferroni_alpha = alpha (no division), and empty per-estimator arrays. The joint bounds / RV stay at their zero / 1.0e300 sentinels.
    • inputs.length() == 1: Bonferroni alpha = alpha / 1 =alpha (no correction). The joint CI / RV collapse to the single estimator's CI / RV at alpha.

    Pre-conditions:
    • alpha > 0 and alpha < 1.
    • Each input.se >= 0.
    • Each input.sigma_sq >= 0.
    • Each input.rho in [-1, 1].

    kahan_sum

    fn kahan_sum(arr : Array[Double]) -> Double

    Kahan compensated summation: returns sum(arr) with a running compensation term that recovers the low-order bits lost at each addition step. The result is effectively computed in double-double precision while staying inside the standard Double type.

    Why we need this

    Naive summation

    s = 0.0 for x in arr: s = s + x

    loses the lowest eps * |s| bits of each addend on every step. The worst-case relative error is O(n * eps) for n addends; when the addends alternate in sign or have wildly different magnitudes (the "catastrophic cancellation" pattern), the worst-case error grows to O(n^2 * eps) and the running sum can drift by orders of magnitude more than expected.

    In the DML numeric kernels this matters in two places:

    1. var_est.mbt: the gamma = gamma + psi^2 accumulator runs for n = 500 observations; psi is in roughly [-2, 2] so each addend is O(1), but partial sums of opposite-sign squared terms are not catastrophic — Kahan is "free insurance" here.
    2. matrix.mbt::matmul and matvec and linalg.mbt::cholesky: the inner-product accumulators add O(p) products of O(1) terms; the partial sum stays around O(1), so naive sum is fine in well-conditioned cases but Kahan removes the ~p * eps drift when the partial sums nearly cancel.

    We keep Kahan in the inner-product and the gamma loop (where it is the cheapest high-value change), and we also upgraded mean (the O(n) sum that every estimator calls twice per fit) to Kahan; the 5-seed precheck confirms the upgrade is invisible at 15-digit output and stays inside the TODO #5 thresholds (|theta - 1| < 0.2 for PLR, < 0.5 for the other 4).

    Algorithm

    Standard Kahan summation (Kahan 1965), also called "compensated summation":

    sum = 0.0 c = 0.0 // compensation for low-order bits lost in the next add for x in arr: y = x - c // align x with the running sum's precision t = sum + y // primary add c = (t - sum) - y // low-order bits of `t` that did not fit sum = t return sum

    The invariant is that sum + c is the true (extended-precision) running sum; the returned sum carries the high-order bits and c holds the residual. After each step |c| <= eps * |sum|, so the error stays O(eps) instead of O(n * eps).

    kfold

    fn kfold(n_obs : Int, n_folds : Int, seed : Int) -> Array[Fold]

    kfold_stratified

    fn kfold_stratified(n : Int, strata : Array[Double], n_folds : Int, seed : Int) -> Array[Fold]

    Stratified k-fold partition: each fold preserves the per-stratum proportions of the input. Used by DoubleMLAPOS (treatment-level stratification) and other multi-level IRM estimators where the treatment distribution must be balanced across folds to avoid empty-treatment folds that would zero-out the IPW denominator.

    The implementation is a within-stratum k-fold partition followed by fold-merging: each stratum independently permutes its indices, then every fold f collects the f-th slice of each stratum's permutation. This produces balanced folds in O(n) time and matches the upstream StratifiedKFold(n_splits=n_folds, shuffle=True) semantics from sklearn.

    The strata array must be parallel to the n observations (same length). Each unique value in strata becomes a stratum; only strata with at least n_folds members can be balanced. Strata with fewer members contribute all their members to whichever fold is currently being filled (the same behavior as sklearn's StratifiedKFold with the default fallback).

    logit

    fn logit(p : Double, eps? : Double) -> Double

    Public elementwise logit(p) = log(p / (1 - p)). Inputs are clamped to [eps, 1 - eps] (default eps = 1e-8) so the result stays finite; the LPLR score clips inner-fold M predictions to [1e-8, 1 - 1e-8] upstream before logit, mirroring scipy logit(clip(p, 1e-8, 1 - 1e-8)).

    lplr_score_at

    fn lplr_score_at(theta : Double, d : Array[Double], y : Array[Double], t_pred : Array[Double], a_pred : Array[Double], m_pred : Array[Double], beta_start : Double, kind : LplrScoreKind) -> LplrScore

    Evaluate the LPLR score (and its theta derivative) at a given theta. t_pred[i] and a_pred[i] are the outer cross-fitted nuisances (ml_t and ml_a); m_pred[i] is ml_m, which this port aliases to a_pred -- see the m_pred = a_pred comment in fit for why, and what it costs relative to upstream. The per-fold preliminary beta is held constant during Newton -- the nonlinear solve is over theta only.

    Upstream DoubleMLLPLR._score_elements (plm/lplr.py:401-416) builds the shared elements:

    d_tilde = d - m_hat psi_hat = expit(-r_hat) score_const = d_tilde * (1 - y) * exp(r_hat)

    and the two scores then consume them differently.

    nuisance_space (_compute_score, lplr.py:522-524):

    score_1 = y * exp(-theta * d) * d_tilde score = psi_hat * (score_1 - score_const)

    instrument (_compute_score, lplr.py:525-528):

    score = (y - expit(theta * d + r_hat)) * d_tilde

    Note that the instrument branch reads neither psi_hat nor score_const -- upstream computes them in _score_elements because that helper is score-agnostic, but they are dead for this branch, so this port skips them.

    lplr_score_kind

    fn lplr_score_kind(score : String) -> LplrScoreKind

    Map the public score string onto LplrScoreKind.

    The score field is validated in DoubleMLLPLR::new, so this only has to handle the two legal names. Unknown names fall back to NuisanceSpace (the upstream default) rather than raising: the constructor is the single validation point and duplicating the panic here would make fit harder to call in isolation.

    lpq_score_ipw

    fn lpq_score_ipw(data : DoubleMLLPQData, treated : Array[Double], m : Array[Double], comp : Double, theta : Double, q : Double, sign : Double) -> Array[Double]

    IPW-only LPQ score (no g0/g1 cross-fit). Used as the bisection objective in DoubleMLLPQ::fit (Bug #3 fix). Matches the upstream doubleml.irm.lpq.DoubleMLLPQ._compute_ipw_score: score[i] = sign * (z[i] / m[i] - (1 - z[i]) / (1 - m[i]))* treated[i] * (y[i] <= theta ? 1 : 0) / comp - q The bisection does not need g0/g1, so this avoids the 2 * 50 = 100 g cross-fits that the pre-fix code did.

    make_confounded_plr_data

    fn make_confounded_plr_data(n_obs : Int, dim_x : Int, theta : Double, seed : Int) -> PlrConfoundedData

    make_did_CS2021

    fn make_did_CS2021(n_obs : Int, dim_x : Int, theta : Double, seed : Int) -> DidMultiData

    Generate the multi-cohort DID DGP. n_obs is the target total sample size; the actual n is n_per_cohort * n_groups *n_periods. The function chooses n_per_cohort to match n_obs.

    make_did_SZ2020

    fn make_did_SZ2020(n_obs : Int, dim_x : Int, theta : Double, seed : Int) -> DidCsData

    make_did_cs_CS2021

    fn make_did_cs_CS2021(n_obs : Int, dim_x : Int, theta : Double, seed : Int) -> DidCsData

    Generate the DID CS2021 DGP. Default: 2 cohorts, 2 periods, theta = 1.0, balanced cohort sizes, n_per_cohort = 200, dim_x = 3.

    make_iivm_data

    fn make_iivm_data(n_obs : Int, dim_x : Int, theta : Double, seed : Int) -> IivmData

    Generate the IIVM DGP from Chernozhukov et al. (2018) Section 4.4.

    make_irm_confounded_data

    fn make_irm_confounded_data(n_obs : Int, dim_x : Int, theta : Double, seed : Int) -> IrmConfoundedData

    make_irm_data

    fn make_irm_data(n_obs : Int, dim_x : Int, theta : Double, seed : Int) -> IrmData

    Generate the IRM DGP from Chernozhukov et al. (2018) Section 4.2.

    make_irm_discrete_treatments

    fn make_irm_discrete_treatments(n_obs : Int, dim_x : Int, theta : Double, seed : Int) -> IrmDiscreteData

    make_irm_heterogeneous_data

    fn make_irm_heterogeneous_data(n_obs : Int, dim_x : Int, seed : Int) -> IrmHeterogeneousData

    make_lplr_LZZ2020

    fn make_lplr_LZZ2020(n : Int, alpha : Double, seed : Int) -> LplrData

    Generate the LPLR LZZ2020 DGP.

    make_pliv_CHS2015

    fn make_pliv_CHS2015(n_obs : Int, dim_x : Int, theta : Double, seed : Int) -> PlivData

    make_pliv_data

    fn make_pliv_data(n_obs : Int, dim_x : Int, theta : Double, seed : Int) -> PlivData

    Convenience factory matching the upstream doubleml.plm.datasets._make_pliv_data.make_pliv_data signature: (n_obs, dim_x, theta, seed) with no other arguments. Equivalent to make_pliv_CHS2015(n_obs, dim_x,theta, seed) but named for parity with the upstream factory helper.

    make_pliv_multiway_cluster

    fn make_pliv_multiway_cluster(n_obs : Int, dim_x : Int, theta : Double, seed : Int) -> PlivClusterData

    make_plpr_CP2025

    fn make_plpr_CP2025(n_obs : Int, dim_x : Int, theta : Double, seed : Int) -> PlprData

    Generate the PLPR DGP. n_obs is the target total sample size; the actual n is n_units * n_periods.

    make_plr_CCDDHNR2018

    fn make_plr_CCDDHNR2018(n_obs : Int, dim_x : Int, theta : Double, seed : Int) -> PlrCcddhnr2018

    Generate the PLR DGP from Chernozhukov et al. (2018) Figure 1.

    make_plr_turrell2018

    fn make_plr_turrell2018(n_obs : Int, theta : Double, seed : Int) -> IrmHeterogeneousData

    make_simple_rdd_dgp

    fn make_simple_rdd_dgp(n_obs : Int, theta : Double, seed : Int) -> RddSimpleData

    Generate the simple RDD DGP.

    make_ssm_data

    fn make_ssm_data(n_obs : Int, dim_x : Int, theta : Double, seed : Int) -> SsmData

    Generate the SSM DGP.

    matmul

    fn matmul(a : Matrix, b : Matrix) -> Matrix

    A * B matrix-matrix product. Panics if inner dimensions do not match.

    The inner product over the shared k dimension uses kahan.mbt-style compensated summation so the partial sum does not drift by O(p * eps) when terms of opposite sign nearly cancel.

    matrix_predict

    fn matrix_predict(mat : Matrix, weights : Array[Double], bias : Double) -> Array[Double]

    Predict y = X @ weights + bias for an n x p feature matrix X, length-p coefficient vector weights, and scalar intercept bias. Returns a length-n vector.

    Equivalent to: for i = 0; i < n; i = i + 1 { let mut s = bias for j = 0; j < p; j = j + 1 { s = s + X[i, j] * weights[j] } out[i] = s }

    The accumulator uses Kahan compensated summation so the partial sum does not drift by O(p * eps) when terms of opposite sign nearly cancel, matching the matvec helper that LinearRegression::predict already uses for the augmented (X | 1) @ coef path.

    matvec

    fn matvec(a : Matrix, x : Array[Double]) -> Array[Double]

    A * x matrix-vector product. x length must equal A.ncols. Returns a vector of length A.nrows.

    The inner-product accumulator is Kahan-compensated (see kahan.mbt).

    matvec_t

    fn matvec_t(a : Matrix, x : Array[Double]) -> Array[Double]

    A^T * x matrix-vector product.

    mean

    fn mean(a : Array[Double]) -> Double

    Mean of a vector.

    The accumulator is Kahan-compensated so the running sum does not drift by O(n * eps) for long inputs. The empty-input branch short-circuits to 0.0 (the package convention).

    newton_solve_score

    fn newton_solve_score(theta_start : Double, psi : Array[Double], psi_deriv : Array[Double], tol_theta? : Double, max_iter? : Int) -> (Double, Bool)

    Newton iteration to find the root of mean(score(theta)) for a given psi(theta) = psi_a * theta + psi_b-style linear score (i.e. psi and psi_deriv are constants with respect to theta). For the general nonlinear case, callers should re-evaluate psi(theta) / psi_deriv(theta) between iterations -- see the LPLR fit body, which evaluates the score at the converged theta after this routine returns.

    Mirrors scipy.optimize.root_scalar(method="newton") with fprime = score_deriv. Stops when |theta_{k+1} - theta_k| < tol_theta (default 1e-9) or max_iter (default 50) is reached. Returns the converged theta and a Boolean indicating whether the iteration converged.

    nn_residual

    fn nn_residual(x : Array[Double], y : Array[Double], nnmatch? : Int) -> Array[Double]

    v0.116.0: the nearest-neighbour matched residual -- the residual behind rdrobust's vce = "nn". Ported from rdrobust/src/rdrobust/funs.py::_nn_residuals_jit, read line by line rather than reconstructed from the vce documentation (which states the parameter exists and says nothing about how it is built).

    What the quantity IS

    For each observation pos, take the nnmatch observations whose running value is nearest to x[pos] -- expanding alternately to the left and to the right, always taking whichever side has the smaller running-variable gap -- and write

    res[pos] = sqrt(Ji / (Ji + 1)) * (y[pos] - mean(y over those neighbours))

    where Ji counts the neighbours EXCLUDING pos itself. The sqrt(Ji/(Ji+1)) factor is the finite-sample correction that keeps res on the scale of a regression residual; it pays for that with a variance roughly Ji + 1 times the plug-in residual's.

    The crucial structural fact, and the reason vce = "nn" is cheap: the residual is a function of y and x only. No fit, no bandwidth, no kernel. rdrobust exploits this at rdrobust.py:1055 -- when vce == "nn" it assigns res_b_l = res_h_l, reusing the CONVENTIONAL estimator's residuals for the BIAS-CORRECTED variance instead of refitting. A test pins that invariance directly.

    Tie handling and mass points

    Two equal-x observations at distance dleft == dright are taken together (both sides advance one step), unless the two gaps differ by more than max(dleft, dright) * sqrt(eps) on the side that is being expanded -- that tolerance is what keeps the expansion from comparing distances that differ only by rounding. It is the MAXIMUM, not the minimum (upstream's dleft if dleft > dright else dright, funs.py:114) and this file originally wrote the minimum. The two agree on every fixture in expand_v116_test.mbt: the window in which they can differ is |dleft - dright| landing between min * sqrt(eps) and max * sqrt(eps), roughly 1e-16 wide relative, and no double-precision running variable lands there by accident. So this is a fidelity fix that the gates CANNOT see -- recorded here, and flagged in _verify/mut_v116_rdd.ps1 as a mutation expected to survive.

    Equal-x groups are walked as whole blocks, so an observation is never split from its own duplicate when the boundary falls inside a group.

    This is NOT the criterion's sigma4

    The private rdd_nn_matched_sigma4 matches a LEFT residual to a RIGHT residual to build the bandwidth criterion's Sigma^4. This matches each observation to its own local neighbourhood inside ONE side. They share a name fragment and nothing else; see that function for its own doc.

    Preconditions

    x must be sorted ascending -- the neighbourhood walk assumes it -- and needs at least two entries, since a residual is defined against at least one other observation. nnmatch >= 1 for the same reason: cap = min(nnmatch, n - 1) and Ji would otherwise be zero.

    Ports funs.py:92-143.

    norm_cdf

    fn norm_cdf(x : Double) -> Double

    v0.21.0+ standard-normal CDF. Implements the A&S 7.1.26 formula directly (rather than going through norm_sf, which is the upper-tail survival and saturates to 1.0 at x <= 0). The coefficients match the existing norm_sf: p = 0.2316419, b1 = 0.319381530, b2 = -0.356563782,b3 = 1.781477937, b4 = -1.821255978, b5 = 1.330274429 and the max abs error is ~7.5e-8. Symmetry Phi(x) = 1 - Phi(-x) handles the negative half-line.

    norm_ppf

    fn norm_ppf(p : Double) -> Double

    v0.21.0+ standard-normal quantile function (inverse CDF). Uses bisection on the existing norm_cdf (which delegates to norm_sf with ~7.5e-8 accuracy) to find the z such that Phi(z) = p. The bisection runs for 64 iterations on the interval [-8, 8], which is more than enough to drive the absolute error below 1e-12.

    p must lie in (0, 1). The result is the x such that P(Z <= x) = p for a standard normal random variable Z.

    norm_sf

    fn norm_sf(x : Double) -> Double

    Standard-normal survival function P(Z > x) using the Abramowitz & Stegun (1964) formula 7.1.26 (max absolute error ~7.5e-8 for x >= 0):

    sf(x) = phi(x) * (b1*t + b2*t^2 + b3*t^3+ b4*t^4 + b5*t^5) phi(x) = exp(-x^2 / 2) / sqrt(2 pi) t = 1 / (1 + p * x) p = 0.2316419, b1 = 0.319381530, b2 = -0.356563782,b3 = 1.781477937, b4 = -1.821255978, b5 = 1.330274429

    normalize_ipw

    fn normalize_ipw(propensity : Array[Double], treatment : Array[Double]) -> Array[Double]

    v0.119.0: normalize inverse probability weights so the mean weight within each treatment group is 1. Ports upstream doubleml.utils._propensity_score._normalize_ipw:

    mean_treat1 = mean(treatment / propensity) mean_treat0 = mean((1 - treatment) / (1 - propensity)) normalized = treatment * propensity * mean_treat1 + (1 - treatment) * (1 - (1 - propensity) * mean_treat0)

    Intuition: the raw weight treatment / propensity is unbiased for the treated count but its scale carries the nuisance model's bias, so it inflates the variance of the score. Multiplying by mean_treat1 removes that constant-of-proportionality factor.

    The 1e-12 clamp is a DELIBERATE deviation

    Upstream divides by propensity with no guard at all. This clamps both denominators at 1e-12, so a propensity of exactly 0 yields a weight of 0 rather than inf or NaN.

    The two agree bit-for-bit on any propensity bounded away from zero, which is what _verify/gen_v119_oracle.py checks -- the goldens are generated by calling upstream's own function on the fixture. The clamp only matters on a degenerate propensity, where upstream returns inf and propagates NaN through the whole estimator while this returns a finite, bounded number. That is recorded as a difference rather than hidden: v0.119.0's v119_normalized_ipw_matches_upstream pins the agreement, and v119_normalized_ipw_survives_a_degenerate_propensity pins the clamp.

    Why this moved here

    cvar.mbt has carried a private copy of exactly this function since its normalize_ipw option landed. Two private copies of an upstream-tracked formula is how the two drift apart, so this is the single definition and cvar.mbt now calls it. It was promoted from private to pub for the same reason: an oracle cross-check cannot import a file-private function.

    treatment must be the 0/1 indicator, matching upstream's treatment_indicator; validate_treatment is the gate.

    p_adjust

    fn p_adjust(method_name : String, unadjusted : Array[Double], boot_t_stat? : Array[Double], t_stats? : Array[Double]) -> Array[Double]

    Public dispatcher. method accepts:
    • "romano-wolf" / "rw" -> romano_wolf_p_adjust
    • "holm" -> holm_bonferroni_p_adjust
    • "bonferroni" / "bonf" -> bonferroni_p_adjust
    • "bh" / "fdr_bh" -> bh_fdr_p_adjust
    • "by" / "fdr_by" -> by_fdr_p_adjust
    • "tsbh" / "fdr_tsbh" -> tsbh_p_adjust
    • "tsby" / "fdr_tsbky" -> tsby_p_adjust

    For romano-wolf, the caller must supply boot_t_stat (length n_boot * n_thetas) and t_stats (length n_thetas); the unadjusted argument is ignored. For all other methods, boot_t_stat and t_stats are ignored; only unadjusted (length n_thetas) matters.

    pava

    fn pava(y : Array[Double], weights? : Array[Double]) -> Array[Double]

    Pool-adjacent-violators algorithm. Given a sequence of values y[0..n] (assumed already sorted by the predictor x, which is monotone non-decreasing in the index), pava returns the isotonic (non-decreasing) L2 projection of y. Tied predictors are handled naturally by the algorithm (they form a single block whose mean is the projection value).

    Optional weights[0..n] is a per-element weight; default is unit weight for every element. Each output block is the weighted mean of its constituent elements.

    The algorithm walks the input once, maintaining a stack of "blocks" — each block holds (sum_y, sum_w, size). When a new value would create a violation (the previous block's mean is greater than the new block's mean), the algorithm pools the two blocks and re-checks. The result is the canonical weighted-PAVA output: a non-decreasing sequence that minimises the weighted sum of squared residuals subject to the monotonicity constraint.

    The input MUST be sorted by x in non-decreasing order. Use fit_isotonic for the public entry point that sorts and returns the fitted model.

    pq_score_ipw

    fn pq_score_ipw(_x : Matrix, y : Array[Double], treated : Array[Double], m : Array[Double], theta : Double, q : Double) -> Array[Double]

    IPW score for the potential quantile, used as the bisection objective in solve_pq (Bug #3 fix). The full PQ score also subtracts a g cross-fit, but the g is not needed for the bisection: at the root theta, mean(score) = 0 regardless of g because E[g(X) | D = d] = E[g(X) * 1{D = d} / m(X)] by definition of the cross-fit. So we can iterate the bisection with this cheap score (no OLS fit per iteration) and only cross-fit g ONCE at the resulting theta_prelim.

    Math: score[i] = treated[i] / m[i] * 1{y[i] <= theta} - q. Matches the upstream doubleml.irm.pq.DoubleMLPQ._compute_ipw_score.

    pq_score_orthogonal

    fn pq_score_orthogonal(y : Array[Double], treated : Array[Double], g : Array[Double], m : Array[Double], theta : Double, q : Double) -> Array[Double]

    The ORTHOGONAL (conditional-on-g) quantile score, matching upstream doubleml.irm.pq.DoubleMLPQ._compute_score at pq.py:268:

    score[i] = ind_d[i] * ((y[i] <= theta) - g[i]) / m[i] + g[i] - quantile

    g is held FIXED at the cross-fit taken at the preliminary IPW root, which is exactly upstream's arrangement: _nuisance_est solves the IPW root only to build g's target (pq.py:378, then pq.py:390 fits ml_g(x_treat, y_treat <= ipw_est)), and the REPORTED coefficient is the root of this orthogonal score.

    v0.159.0: before this, solve_pq returned the IPW root as the estimate, which is a different functional of the data. The two agree only when g happens to be constant. Measured on a continuous-confounding fixture, the orthogonal score at the returned IPW root averaged -0.224968, i.e. the reported coefficient did not satisfy its own estimating equation.

    The score is non-decreasing in theta: raising theta can only flip 1{y <= theta} from 0 to 1, and that term enters with coefficient ind_d[i] / m[i] >= 0. Bisection is therefore valid, and is what upstream does via _solve_ipw_score applied to _compute_score.

    predict_isotonic

    fn predict_isotonic(fitted_x : Array[Double], fitted_y_hat : Array[Double], x_new : Array[Double]) -> Array[Double]

    Predict the isotonic regression at new x values using the fitted model (fitted_x, fitted_y_hat). Behaviour matches sklearn.isotonic.IsotonicRegression(out_of_bounds="clip",y_min=0.0, y_max=1.0):
    • For x_new[i] strictly less than min(fitted_x): return fitted_y_hat[0] (clipped at the lower boundary).
    • For x_new[i] strictly greater than max(fitted_x): return fitted_y_hat[last] (clipped at the upper boundary).
    • Otherwise: return fitted_y_hat at the largest fitted_x[j]<= x_new[i], found by linear scan (PAVA produces a step function, and the step boundaries are the fitted_x values themselves).

    We additionally clip the prediction to [0.0, 1.0] since the fitted y_hat values are guaranteed to be in [0, 1] for binary y (PAVA output on a 0/1 input lies in [0, 1]), but the clip makes the contract explicit and protects against any numerical drift.

    propensity_score_adjustment

    fn propensity_score_adjustment(propensity : Array[Double], treatment : Array[Double], do_normalize? : Bool) -> Array[Double]

    v0.119.0: upstream doubleml.utils._propensity_score._propensity_score_adjustment -- the switch that decides whether the propensity enters the score raw or IPW-normalized.

    normalize_ipw = false is the IDENTITY, verified against upstream: gen_v119_oracle.py reports adjust_identity_max_diff = 0.0. That is the whole point of the flag, and it is why the default on DoubleMLAPO / DoubleMLAPOS is false (upstream's apo.py:97 default) -- turning normalization on must move the answer, and leaving it off must not.

    psi_at

    fn psi_at(coef : Double, psi_a : Array[Double], psi_b : Array[Double]) -> Array[Double]

    Evaluate the per-observation estimating-function score at coef, for the package convention documented in var_est.mbt:

    psi[i] = coef * psi_a[i] + psi_b[i]

    i.e. the score of f(theta) = E[theta * psi_a + psi_b], whose root is theta_hat = -mean(psi_b) / mean(psi_a) and whose Jacobian is J = d f / d theta = mean(psi_a). M_inv = 1 / J (the [[1 / mean(psi_a)]] every sandwich method builds) is consistent with exactly this convention.

    Added in v0.90.0. v0.64.0 - v0.89.0 built the score inline in 13 places as psi_a[i] + coef * psi_b[i] -- the roles of psi_a (the Jacobian / coefficient row) and psi_b (the offset) were swapped, so the sandwich and multiplier-bootstrap families evaluated g(theta) = E[psi_a + theta * psi_b], a DIFFERENT function whose root -E[psi_a] / E[psi_b] is not theta_hat. On a fixed DGP the resulting sandwich_se(HC0) differed from the analytic se() by three orders of magnitude. Thirteen inline copies of one algebraic expression will drift, so the expression now lives here and every caller routes through it.

    psi_a and psi_b must share the same non-zero length; the call aborts via require(...) otherwise. Returns a fresh Array[Double] of that length.

    range_indices

    fn range_indices(n : Int) -> Array[Int]

    The full index range [0, n). Convenient for callers that need to pass a complete index list to filter_indices (e.g. the LPQ complier-prob computation in lpq.mbt::DoubleMLLPQ::fit).

    repeated_kfold

    fn repeated_kfold(n_obs : Int, n_folds : Int, n_rep : Int) -> Array[Array[Fold]]

    Repeated K-fold partition. Draws n_rep independent kfold partitions with seeds 3141, 3142, ..., 3141 + n_rep - 1, and returns them as a list of length n_rep, each containing n_folds Fold objects. Matches the upstream doubleml.utils.resampling.DoubleMLResampling with RepeatedKFold and stratify=None.

    require

    #callsite(autofill(loc))
    fn require(condition : Bool, loc~ : SourceLoc) -> Unit raise PreconditionError

    Internal alias of check, kept for terse call sites that already read like require(x == y). Forwards the auto-injected loc so the error payload still points at the offending source line. v0.48.0: forwards check's raise as-is (no local try/catch); the per-caller wrap is responsible for converting the raise back to abort at the call site.

    reset_g_cross_fit_count

    fn reset_g_cross_fit_count() -> Unit

    Reset the g cross-fit counter to 0. Public so blackbox tests can use it to bracket a fit() call.

    resolve_ps_processor

    fn resolve_ps_processor(ps_processor_config? : PSProcessorConfig?, trimming_threshold? : Double) -> PSProcessor

    Port of upstream init_ps_processor (utils/propensity_score_processing.py:60), the function twelve upstream estimators call: apo, apos, cvar, iivm, irm, lpq, pq, qte, ssm, did_binary, did_cs_binary, did_multi.

    Its whole body is a precedence rule, and it is worth stating exactly because it is not the obvious one:

    if ps_processor_config is not None: config = ps_processor_config # config wins outright else: config = PSProcessorConfig(clipping_threshold=trimming_threshold)

    MEASURED against upstream 0.11.4 (_probe_v121_psconfig.py):

    init_ps_processor(None, "truncate", None) -> clip=0.01 init_ps_processor(None, "truncate", 0.05) -> clip=0.05 init_ps_processor(cfg(0.3), "truncate", 0.05) -> clip=0.3 <- the deprecated scalar is IGNORED once a config is present, not merged with it and not used as a fallback for a missing field.

    Upstream then derives the legacy attribute from the processor (self._trimming_threshold = self._ps_processor.clipping_threshold), so a caller reading propensity_clip back sees the CONFIG's value. This port's estimators keep the same relationship.

    Upstream's default when neither argument is given is clipping_threshold = 1e-2. This port's estimators have historically defaulted propensity_clip to 1e-6, so the default lives at the CALL SITE, not here: trimming_threshold is the value the caller wants, and changing it would silently move every existing result.

    Note the upstream validator rejects cv_calibration=True without a calibration method ("cv_calibration=True requires a calibration_method."). That check already lives in PSProcessorConfig::new, so it fires on the Some arm before this function ever sees it.

    resolve_ps_processor_config

    fn resolve_ps_processor_config(ps_processor_config? : PSProcessorConfig?, trimming_threshold? : Double) -> PSProcessorConfig

    The same resolution as resolve_ps_processor, returning the CONFIG rather than the processor. An estimator stores the config (never None) so it can hand Some(this) to a child and get a byte-identical processor; building the processor from a stored config is a struct wrap, so this is the natural thing to keep.

    The single definition of the precedence rule lives here.

    robustness_value

    fn robustness_value(theta : Double, max_bias : Array[Double]) -> Double

    Aggregate the per-observation sensitivity vectors into a scalar "robustness value" RV — the minimum confounding strength that would change the estimator's sign. Returns a large sentinel (1.0e300) if the estimator is already zero (degenerate) since MoonBit's Double has no Infinity constant in this build. Per Cinelli & Hazlett (2020) §3.2: RV = |theta_hat| / mean(max_bias).

    romano_wolf_p_adjust

    fn romano_wolf_p_adjust(boot_t_stat : Array[Double], unadjusted : Array[Double], t_stats : Array[Double]) -> Array[Double]

    Romano-Wolf (2005) stepdown p-adjustment. The stepdown critical value at rank i_theta is max_{j > i_theta} |boot_t_stat[b, j]| per bootstrap replication b. The unadjusted p-value at rank i_theta is frac{b : cv_b >= |t_{i_theta}|} / n_boot. Enforces monotonicity from the smallest |t| upward. Re-orders to the original cell order.

    boot_t_stat is row-major (n_boot, n_thetas).

    The algorithm does not depend on the unadjusted p-values directly; it consumes t_stats (length n_thetas). The unadjusted argument is accepted for API symmetry with the other p-adjust entry points and is ignored.

    sandwich_variance

    fn sandwich_variance(kind : SandwichKind, psi_a : Array[Double], psi : Array[Double], m_inv : Matrix, n_obs : Int, n_params : Int) -> Double

    Shared SandwichKind dispatch: routes to the matching sandwich_variance_hc* free function above and returns the corresponding variance.

    Added in v0.86.0 so the per-estimator sandwich_se(kind) methods (DoubleMLIIVM, DoubleMLPLIV, DoubleMLDID, DoubleMLCVAR, ...) share ONE dispatch rather than each inlining the same four-arm match. The arithmetic is identical to calling the matching sandwich_variance_hc* function directly, so results are byte-identical to the per-estimator inline dispatch.

    Preconditions match the individual sandwich_variance_hc* functions (notably n_obs > n_params for HC1). Aborts via PreconditionError on malformed input.

    sandwich_variance_hc0

    fn sandwich_variance_hc0(psi_a : Array[Double], psi : Array[Double], m_inv : Matrix, n_obs : Int, n_params : Int) -> Double

    HC0 (classical Huber-White) sandwich variance for a scalar DML estimator:

    var = M_inv[0,0]^2 * sum_i psi[i]^2 / n^2 = M_inv[0,0]^2 * mean_i psi[i]^2 / n

    psi[i] is the per-observation influence-function residual at the fitted theta (psi_at(coef, psi_a,psi_b)), M_inv is the inverse Jacobian (a 1x1 matrix for the scalar case; the function only inspects M_inv[0, 0]), and n_obs is the sample size.

    The formula is var_est's sigma2 (see var_est.mbt): the DML moment is a MEAN moment, so its meat is E[psi psi'], and the root has `var = E[psi psi'] / (J^2
    • n). There is deliberately NO psi_a[i] factor: themoment E[theta * psi_a + psi_b] is inverted as aregression on the constant 1, not on psi_a. v0.90.0 andearlier accumulated sum_i (psi_a[i] * psi[i])^2 / n,which is the variance of sqrt(n) * (theta_hat - theta)with a spurious regressor weight; se()` is unchanged and is the reference for every number here.

    Preconditions:
    • psi_a.length() == psi.length() == n_obs.
    • M_inv.rows() == 1 and M_inv.cols() == 1 (scalar Jacobian inverse).
    • n_obs >= 1.
    • n_params >= 1 (the n-k finite-sample correction in HC1 / HC2 / HC3 needs a positive n - k).

    On success returns the scalar variance. Aborts via PreconditionError on malformed input.

    sandwich_variance_hc1

    fn sandwich_variance_hc1(psi_a : Array[Double], psi : Array[Double], m_inv : Matrix, n_obs : Int, n_params : Int) -> Double

    HC1 sandwich variance: HC0 * n / (n - k) where k =n_params. The classic finite-sample correction (the Stata , robust default; matches statsmodels.OLS cov_type='HC1').

    Preconditions match sandwich_variance_hc0 plus n_obs > n_params (the (n - k) divisor must be positive). Aborts on n_obs <= n_params because the finite-sample correction becomes undefined there.

    sandwich_variance_hc2

    fn sandwich_variance_hc2(psi_a : Array[Double], psi : Array[Double], m_inv : Matrix, n_obs : Int, n_params : Int) -> Double

    HC2 sandwich variance: per-observation leverage correction (1 - h_ii) on the squared score (MacKinnon 2012 sec 5.4):

    var = M_inv[0,0]^2 * sum_i (psi[i]^2 / (1 - h_ii)) / n^2

    where h_ii is the diagonal of the projection matrix of the mean regression implicit in the moment E[theta * psi_a + psi_b] = 0. That design is intercept-only with equal weights, so its projection is (1 / n) * J and h_ii = 1 / n_obs on every row. Consequently var(HC2) == var(HC0) * n / (n - 1), i.e. HC2 is exactly HC1 for this family.

    v0.79.0 - v0.87.0 used h_ii = psi_a[i] * M_inv[0,0] *psi_a[i], which is not a projection-matrix diagonal and goes negative under M_inv = [[1 / mean(psi_a)]] with mean(psi_a) < 0, shrinking the variance instead of widening it. The divisor is still clipped to 1.0e-10 so HC2 stays finite on the degenerate n_obs == 1 input.

    Preconditions match sandwich_variance_hc0. Aborts on malformed input.

    sandwich_variance_hc3

    fn sandwich_variance_hc3(psi_a : Array[Double], psi : Array[Double], m_inv : Matrix, n_obs : Int, n_params : Int) -> Double

    HC3 sandwich variance: the jackknife-style HC3 correction that divides by (1 - h_ii)^2 per observation. Otherwise identical to HC2 (same leverage computation, same precondition set, same degenerate-input safeguard). Under h_ii = 1 / n_obs this means var(HC3) == var(HC0) * (n / (n - 1))^2, i.e. exactly HC1^2 / HC0.

    Preconditions match sandwich_variance_hc0. Aborts on malformed input.

    seed_to_bytes

    fn seed_to_bytes(seed : Int) -> Array[Byte]

    Pack an integer seed into a 32-byte buffer using a 32-bit little-endian encoding of seed (as a signed 32-bit integer; -1 becomes 0xFF FF FF FF). The four bytes are tiled 8 times to fill the 32-byte key that chacha8 requires. Distinct integer seeds give distinct byte strings, and the layout is independent of the host endianness.

    Encoding (per byte index 0..3): byte[0] = seed & 0xff byte[1] = (seed >> 8) & 0xff byte[2] = (seed >> 16) & 0xff byte[3] = (seed >> 24) & 0xff

    seed is a signed 64-bit Int; the upper 32 bits are ignored so the 32-bit LE view is well-defined for any seed. For negative seeds, arithmetic right-shift + mask is sign-clean (e.g. seed = -1 gives [0xff, 0xff, 0xff, 0xff]).

    This is the canonical seed-to-bytes helper for the dml package's RNG-backed tests and for cmd/main's demo entry point. It supersedes the earlier 7-bit-tiling helper that existed in irm_test.mbt and cmd/main/main.mbt and produced non-portable encodings.

    sensitivity_from_variance_baselines

    fn sensitivity_from_variance_baselines(theta : Double, sigma2 : Double, nu2 : Double, n : Int, cf_y : Double, cf_d : Double) -> SensitivityResult raise

    Build a SensitivityResult from the two variance BASELINES alone.

    v0.144.0, added with the DoubleMLPLR nu2 fix. Proven at compute_sensitivity_bias (sensitivity.mbt:522-550) and reproduced below: max_bias[i] = sqrt(sigma2 * nu2) for EVERY i, psi_max_bias is discarded by every caller in this file (let (max_bias_arr, _) = ...), robustness_value divides |theta| by mean(max_bias), and max_bias is the mean of a constant array. So the entire returned record -- rv, max_bias, sigma2, nu2 -- is a function of sigma2 and nu2 alone. The per-observation influence-function arrays cannot reach it.

    That is what makes this helper legitimate rather than a shortcut: it is not an approximation, it is the same computation with provably dead operands removed. It exists for estimators whose nu2 is NOT a mean of a per-observation score element -- DoubleMLPLR wants the SCALAR 1 / mean(v^2), which no per-observation array can express, because the mean of any array you would need to build here is nu2 itself only if you pass a constant, and passing upstream's own psi_nu2 has mean exactly zero (see DoubleMLPLR::sensitivity_analysis).

    silverman_bandwidth

    fn silverman_bandwidth(y : Array[Double]) -> Double

    Silverman's rule of for-band for a Gaussian KDE: given samples y[0..n),h = 0.9 * min(sd(y), IQR/1.34) * n^(-1/5). The min(sd, IQR/1.34) factor is robust to outliers (a sample of N(0, 1) typically has IQR/1.34 ≈ sd; heavy-tailed samples get the smaller IQR, which prevents oversmoothing).

    For samples drawn from a single-mode distribution the bandwidth is asymptotically optimal; for multimodal or heavy-tailed data the user should switch to a plug-in estimator (cross-validated or Sheather-Jones) — but the simpler Silverman rule is fine for the LPQ numerical-derivative use case where y is typically uniform- shaped or mildly bimodal.

    single_psi_sensitivity

    fn single_psi_sensitivity(theta : Double, psi : Array[Double], y : Array[Double], cf_y : Double, cf_d : Double) -> SensitivityResult raise

    v0.67.0+: shared inner for the centered single-IF sensitivity_analysis (LPQ / PQ). The IF psi is already centered (mean 0 at the bisection root); the Riesz-representer variance is nu2 = mean(psi^2). The outcome nuisance variance sigma2 = Var(y) (population, i.e. mean((y - mean(y))^2)) plays the role of sigma2 in the C&H formula. Returns a SensitivityResult matching the IRM-style path's shape.

    slice_matrix_rows

    fn slice_matrix_rows(x : Matrix, idx : Array[Int]) -> Matrix

    Slice a matrix by a list of row indices, returning a new n x p matrix with the selected rows in the given order.

    slice_vector

    fn slice_vector(v : Array[Double], idx : Array[Int]) -> Array[Double]

    Slice a vector by a list of indices.

    solve_pq

    fn solve_pq(ml_l : LearnerDispatch, ml_m : LearnerDispatch, data : DoubleMLData, treatment : Double, q : Double, n_folds : Int, seed : Int, clip : Double, folds? : Array[Fold], ps_processor_config? : PSProcessorConfig?) -> (Double, Array[Double], Double) raise BracketSignError

    Solve the potential quantile via IPW bisection, then return the final theta, the influence-function psi at theta (using the g cross-fit at theta), and the numerical derivative d mean(psi) / d theta (using two extra g cross-fits at theta +/- h). Bug #2 and #3 fix: previously returned (theta, se) and recomputed g on every bisection step.

    Returns: (theta, psi, deriv) where psi : Array[Double] of length n and deriv : Double.

    This is pub so the blackbox test quantile_test::qte_se_hand_computation can re-derive the QTE SE by hand from the per-treatment solve_pq outputs. Internal-only callers (DoubleMLPQ, DoubleMLQTE, DoubleMLCVAR) all live in this same package and could call a _for_test variant; the public API is kept for clarity.

    solve_spd

    fn solve_spd(a : Matrix, b : Array[Double]) -> Array[Double]

    Solve A x = b for a symmetric positive-definite A using the Cholesky factorisation A = L L^T. The system is solved in two steps: forward substitution for L y = b, then back substitution for L^T x = y.

    Both substitution accumulators are Kahan-compensated (matching cholesky and matvec). On the hot path for every LinearRegression::fit, every IRLS iteration, and every sandwich-SE back-solve.

    stratified_kfold

    fn stratified_kfold(stratum : Array[Int], n_folds : Int, seed : Int) -> Array[Fold]

    Stratified K-fold partition: per stratum, split the indices into n_folds (train, test) pairs using the same Fisher-Yates + fold allocation as kfold.mbt::kfold, then concatenate the per-stratum folds in stratum order to form a single n_folds-long list of Fold objects that together partition [0, n_obs).

    stratum is a length-n_obs integer array with one stratum label per observation. The function collects indices per stratum, runs kfold on each stratum with seed + stratum_value as the per- stratum seed (so different strata produce different shuffles), then zips the per-stratum folds together: fold f of the final list contains the union of fold-f test rows across all strata.

    Matches the upstream doubleml.utils.resampling.DoubleMLResampling with stratify=stratum and RepeatedKFold collapsed to a single repetition. For multi-repetition partitioning, wrap calls in a loop over the seed.

    treatment_is_binary

    fn treatment_is_binary(treatment : Array[Double]) -> Bool

    The rule validate_treatment enforces, as a total predicate.

    Extracted in v0.122.0 so the rule itself is testable. The abort is not: MoonBit's panic_-prefixed test convention cannot detect a MISSING abort (a non-aborting panic_ test still passes), so validate_treatment alone had no load-bearing gate -- the mutation harness confirmed it (v122-adjust-ps-never-validates SURVIVES). This predicate is the part that carries meaning, and it can be asserted on directly.

    Public so a caller can check its own treatment before handing it to adjust_ps with a calibration configured. true for an empty array: "no offending element" is vacuously binary, and adjust_ps separately rejects n == 0.

    trim_predictions

    fn trim_predictions(preds : Array[Double], rule : String, threshold : Double) -> Array[Double]

    v0.119.0: upstream doubleml.utils._propensity_score._trimm.

    rule = "truncate" clips to [threshold, 1 - threshold]; anything else (upstream's None) is the identity. Kept because trimming_rule / trimming_threshold are deprecated-but-still-live upstream parameters (see the DeprecationWarning at propensity_score_processing.py:59) and DoubleMLDIDCSBinary still exposes the collapsed form of them.

    tsbh_p_adjust

    fn tsbh_p_adjust(unadjusted : Array[Double]) -> Array[Double]

    v0.24.0+ two-stage Benjamini-Hochberg FDR correction.

    Reproduces statsmodels.stats.multitest.multipletests with method='fdr_tsbh' at its default maxiter=1 -- the Benjamini-Hochberg two-stage method -- INCLUDING the lambda search that re-levels alpha in terms of the stage-1 rejections. Measured BIT-EXACT against statsmodels (max |diff| = 0.0, not merely inside a tolerance) on nine vectors covering both early-return branches, tied p-values, unsorted input and n = 1; validate_padjust_with_python.py gates it.

    alpha = 0.05 is fixed here only because this signature carries no alpha parameter; the search itself is statsmodels'.

    Two vectors are gated: a = [0.001, 0.008, 0.02, 0.04,0.2, 0.5, 0.9] (spans three orders of magnitude, crosses BH's threshold) and b = [0.07, 0.11, 0.19, 0.23, 0.41,0.53, 0.71]. Between them they cover both branches: on a r1 = 3 so the search runs, on b r1 = 0 so statsmodels' short-circuit returns stage 1 untouched.

    Algorithm (fdrcorrection_twostage, method='bh', maxiter=1), over the sorted p-vector p of length m:
    1. Stage 1: r1 = number of BH rejections at alpha.
    2. If r1 == 0 or r1 == m, return the stage-1 corrected p-values UNCHANGED. statsmodels short-circuits here: if nothing or everything rejected there is no fraction to estimate, so no lambda search runs.
    3. Otherwise n0 = m - r1 (the estimated number of true nulls) and alpha_star = alpha * m / n0.
    4. Stage 2: a BH pass at alpha_star. Its corrected p-values are p_adj.
    5. Rescale for the estimated null fraction: p_adj[k] = min(1, p_adj[k] * n0 / m).
    6. Re-order to original cell order via ro.

    Step 5 is the whole of the two-stage correction: it shrinks the adjusted p-values by the estimated fraction of truly null hypotheses, which is what makes the two-stage procedure more powerful than plain BH when a non-trivial fraction of hypotheses are non-null -- the common case for DID across many (g, t) cells, where post-treatment cells are non-null and pre-treatment ones are null.

    Only r1, the STAGE-1 count, reaches the p-values. statsmodels also derives a stage-2 count for its own m0 return value, but multipletests discards it, and at maxiter=1 it never influences the corrected values.

    CONSEQUENCE, WORTH KNOWING BEFORE YOU "SIMPLIFY" THIS: because the stage-2 rejection count is dropped, alpha_star currently cannot change what this function returns -- the corrected array a BH pass produces depends only on the sorted p-values and n. Measured: mutating alpha_star to alpha * m leaves all fourteen outputs (two vectors x seven cells) bit-identical. The lambda search that matters is the one that produced n0, not the one that re-levels alpha. The call is kept because it mirrors statsmodels line for line and because that equivalence stops holding the moment this module starts returning a rejection array. Mutation M1 in _verify/_tsbh_mutation_1660.py is recorded as a documented equivalent for exactly this reason.

    tsby_p_adjust

    fn tsby_p_adjust(unadjusted : Array[Double]) -> Array[Double]

    v0.24.0+ two-stage Benjamini-Yekutieli FDR correction. Combines the two-stage m0_hat adjustment (from storey_m0_hat) with the harmonic-sum c factor (from by_fdr_p_adjust) to handle arbitrary dependence between tests.

    Algorithm. Structurally statsmodels.stats.multitest.multipletests with method='fdr_tsbky', but NOT numerically identical to it: both run BY then a global m0_hat / m rescale, but this module fixes alpha = 0.05 where statsmodels searches lambda.

    MEASURED on [0.001, 0.008, 0.02, 0.04, 0.2, 0.5, 0.9]: max |port - statsmodels| = 0.1595. Unlike the tsbh gap this one was, it is NOT a constant factor -- the ratio runs 0.5129 ... 1.1970, spread 0.684 -- so no single multiplicative constant describes it. The largest cells saturate: this function clips at 1.0 BEFORE the rescale while statsmodels applies fact = 1 + alpha AFTER it, so the two clip at different points and cells 5 and 6 come back identical here (0.45112781954887221 twice).

    KNOWN DIVERGENCE, LEFT IN PLACE DELIBERATELY: tsbh_p_adjust was reconciled with statsmodels' lambda search; this one was not, because the reconciliation was scoped to tsbh and changing a second published method's numbers unannounced would be worse than leaving the gap documented. Note that validate_padjust_with_python.py does NOT gate tsby against statsmodels -- it has no tsby rows -- so nothing there would catch a silent drift. Reconciling it means porting the BKY two-stage search (fact = 1 + alpha, alpha_prime = alpha /fact, pvalscorr *= (1 + alpha) after the rescale) and adding tsby to that validator.
    1. Compute c = sum_{i=1}^{n} 1/i.
    2. Apply BY: p_by_sorted[k] = min(1, p_sorted[k] * m * c / (k+1)).
    3. Estimate m0_hat = #{unadjusted > alpha} / (1 -alpha) (clamped to [1, m]).
    4. Apply correction: p_adj_sorted[k] = min(1, p_by_sorted[k] * m0_hat /m).
    5. Enforce monotonicity + reorder.

    tune_score_grid

    fn tune_score_grid(x : Matrix, y : Array[Double], learners : Array[LearnerDispatch], n_folds_tune : Int, seed : Int, scoring : TuneScoring) -> TuneGridScore

    The estimator-independent half of tune: cross-fit every candidate learner against the outcome, score it, and select the winner.

    Extracted in v0.108.0. The five pre-existing ::tune implementations (PLR / PLIV / IRM / IIVM / APO) each carried their own byte-identical copy of the scoring loop and of the argmin/argmax selection - roughly 40 duplicated lines apiece, including the NegMSE higher-is-better branch that has to be rewritten at every site. That duplication is what let the argmin survive being inverted with the whole suite green (see expand_v108_test.mbt).

    n_folds_tune folds are drawn fresh from seed, deliberately independent of the final fit's own fold schedule, so candidate quality is not selected on the same splits the estimate is later computed from.

    The observation count comes from y.length(), NOT from an estimator accessor: six of the estimators this serves (APOS DIDCS DIDCrossSection DIDMulti LPLR PLPR) expose no n_obs() at all, and every one of them has data.y available instead. A core keyed on self.n_obs() would have pushed a new accessor onto six public types purely to serve a helper.

    A candidate whose cross-fit returns a wrong-length vector scores TUNE_SCORE_FAIL_SENTINEL, which the selection rule then excludes.

    v0.108.0: the previous version of this comment gave "an RFLearner with 0 trees" as the example, and that was wrong. RFLearner::fit raises PreconditionError on n_trees=0 and its own catch escalates it to abort (rfl.mbt:404), so the process dies before any caller sees a vector - let alone a wrong-length one. No learner in the current LearnerDispatch set can reach the sentinel: they all return x.rows()-length vectors. The guard stays as defence for a future learner that returns a short vector WITHOUT aborting, but it is not a live path today and is not described as one.

    A learner that PANICS cannot be recovered here at all: abort is not interceptible, so a panicking candidate aborts the whole process rather than being excluded from the grid.

    var_est

    fn var_est(psi_a : Array[Double], psi_b : Array[Double]) -> (Double, Double)

    Compute the DML point estimate theta_hat and standard error se from the standard influence-function variance formula used by every DML estimator in this package (PLR, IRM, PLIV, IIVM, DID, SSM):

    theta_hat = -mean(psi_b) / mean(psi_a) J = mean(psi_a) gamma = mean(psi(theta_hat)^2) where psi(theta) = theta * psi_a + psi_b sigma2 = gamma / (J^2 * n) se = sqrt(sigma2)

    The inputs psi_a and psi_b must share the same non-zero length n; the call aborts via require(...) otherwise (using the same check.mbt / require style with auto-injected SourceLoc that every other precondition in this package uses).

    Floating-point order is preserved exactly: this is the byte-equal extraction of the inline block that previously lived in every estimator's fit(). The estimator regression-protection invariant for n_rep == 1 therefore still holds: the per-rep (theta, se) coming out of this function is identical to the per-rep value the old "compute mean, then variance" code produced, and so the aggregator's n_rep=1 fast path returns the same (theta, se).

    var_est_cluster

    fn var_est_cluster(psi : Array[Double], psi_deriv : Array[Double], unit_rows : Array[Array[Int]], unit_fold : Array[Int], n_folds : Int, n_folds_per_cluster : Int) -> Double raise VarEstClusterError

    Cluster-robust standard error (_var_est in doubleml/utils/_estimation.py, one-cluster-variable branch). Per test fold, accumulate squared unit-level score sums scaled by 1 / |I_k|; divide both the gamma accumulator and the Jacobian analog by n_folds_per_cluster:

    gamma += S_g^2 / |I_k|, S_g = sum of psi over unit g, J += (sum of psi_deriv over the fold's rows) / |I_k|, sigma2 = (gamma / npc) / (N_units * (J / npc)^2).

    psi is the linear score evaluated at theta_hat, psi_deriv the score derivative (= psi_a). unit_fold[u] is the test-fold index of unit u; every row of a unit lies in that fold because the folds partition whole units.

    Returns Double!VarEstClusterError: the v0.34.0 J-floor defensive guard (|J|<1e-6) raises VarEstClusterError::JTooSmall carrying the exact (j, g, n_units) triple that triggered it. Callers that want the pre-v0.35.0 process-death behavior should catch the error and re-abort (this is what every DoubleMLXXX::fit_cluster does); callers that want to retry or surface the error should propagate via ?. The error type is declared in kfold.mbt so the cluster helper stack can share it.

    var_est_with_jacobian

    fn var_est_with_jacobian(psi : Array[Double], jacobian : Double) -> Double

    Variant of var_est for Z-estimators where the point estimate theta_hat is already known (e.g. the LPQ bisection root) and jacobian = d mean(psi(theta)) / d theta is supplied externally (e.g. via KDE in lpq.mbt). Returns se = sqrt(gamma / (jacobian^2 * n)) where gamma = mean(psi(theta_hat)^2).

    REVIEW L11 fix (0.7.0): used by DoubleMLLPQ::fit to consolidate the variance computation. The previous inline form was byte-equal to this helper; the LPQ SE test tolerance was widened from 30% to 50% to absorb the smoothed KDE derivative; using the shared helper now makes that bit-equal relationship explicit.

    Aborts if psi is empty or jacobian is zero (which would produce an infinite SE).

    variance

    fn variance(a : Array[Double]) -> Double

    Sample variance (population formula sum((x - mean)^2) / n).

    The sum((a[i] - m) * (a[i] - m)) accumulator is Kahan-compensated to match the sibling mean. Without compensation, (a[i] - m) vanishes near 0 and the accumulator loses low-order bits; Kahan keeps the running sum accurate to O(eps) independent of n.

    vector_add

    fn vector_add(a : Array[Double], b : Array[Double]) -> Array[Double]

    Element-wise addition a + b. Inputs must share length; returns a freshly-allocated Array[Double] of that length. Aborts via require if the lengths disagree.

    vector_divide

    fn vector_divide(a : Array[Double], b : Array[Double], eps? : Double) -> Array[Double]

    Element-wise division a / max(b, eps). v0.82.0+. Inputs must share length; returns a freshly-allocated Array[Double] of that length. eps (default 1.0e-12) guards against a near-zero denominator in the propensity / complier-prob / instrument-propensity factors that the IRM / IIVM / LPQ scores divide by. Aborts via require if the input lengths disagree or eps <= 0.

    The denominator is clipped (NOT the numerator) so the score preserves the original numerator scale when the denominator is well-behaved, and only folds in the eps floor when truly necessary. This matches the upstream doubleml-for-py convention of clipping m_hat to [eps, 1 - eps] BEFORE division (rather than clipping the score after division).

    vector_multiply

    fn vector_multiply(a : Array[Double], b : Array[Double]) -> Array[Double]

    Element-wise multiplication a * b. v0.82.0+. Inputs must share length; returns a freshly-allocated Array[Double] of that length. Aborts via require if the lengths disagree. Used by the per-fold score-element accumulation in the quantiled DID / IRM / IIVM score functions where the per- observation residual contribution is a product of a residual vector and a scalar / vector factor (e.g. psi_b += d * u1 / m where each factor can be extracted via vector_multiply + vector_divide instead of an inlined per-iteration scalar multiply).

    vector_scale

    fn vector_scale(a : Array[Double], s : Double) -> Array[Double]

    Element-wise scalar multiplication a * s. Returns a freshly-allocated Array[Double] of the same length as a. s is a scalar applied to every entry.

    vector_subtract

    fn vector_subtract(a : Array[Double], b : Array[Double]) -> Array[Double]

    Element-wise subtraction a - b. Inputs must share length; returns a freshly-allocated Array[Double] of that length. Aborts via require if the lengths disagree.

    w_is_unweighted

    fn w_is_unweighted(w : Array[Double]) -> Bool

    The single definition of "these weights mean nothing". Every implLearner routes through this so the convention cannot drift between the eight learners.