Skip to content

Limited-MC-statistics de-biasing for binned template fits (continuous-M + cross-fit)#141

Draft
bendavid wants to merge 15 commits into
WMass:mainfrom
bendavid:mcstat-debias
Draft

Limited-MC-statistics de-biasing for binned template fits (continuous-M + cross-fit)#141
bendavid wants to merge 15 commits into
WMass:mainfrom
bendavid:mcstat-debias

Conversation

@bendavid

Copy link
Copy Markdown
Collaborator

Limited-MC-statistics de-biasing for binned template fits

Adds opt-in de-biasing of the bias and under-coverage that finite MC template
statistics induce in chi-squared / profile-likelihood template fits (the
errors-in-variables / Neyman–Scott "incidental parameters" effect). Two
complementary methods, both injected at the scalar-NLL level so they flow through
rabbit's existing TF-autodiff grad/Hessian/HVP machinery and SciPy minimizers.

Draft — opening for design review and discussion of the interfaces before polish
(the test scripts are standalone validation drivers, not yet pytest/CI-wired).

Methods

  • continuous-M — a frozen noise-floor matrix M (supplied to the TensorWriter)
    enters the objective as -½ θᵀ M θ, giving the de-biased point (H−M)⁻¹g and
    curvature H−M. Lowest variance; requires the de-biased parameters to enter the
    prediction linearly (e.g. --allowNegativeParam POIs); a warning fires otherwise.
  • two-half / k-fold cross-fit — the user fills histograms with an extra MC-stat
    fold_axis; the writer slices it (full derived by projecting it out). The fitter
    forms the jackknife 2L_full − ½L_A − ½L_B; the --covMode fisher curvature uses
    the complete pairwise U-statistic over all k folds (k-fold averaging, O(k), no
    partition enumeration, → continuous-M as k→N). General / nonlinearity-robust.

Interfaces

  • TensorWriter.add_mc_stat_moment(M, param_names); add_process(..., fold_axis=),
    add_systematic(..., fold_axis=) (split-logk, symmetric/asymmetric, dense/sparse).
  • --mcStatDebias {none,continuousM,twoHalf,kfold},
    --mcStatDebiasCov {curvature,sandwich,dataPropagated},
    --covMode {observed,fisher} (applies to the standard cov too).
  • Robust (sandwich) covariance; BB-lite compatible; the de-biased covariance flows
    through to impacts (a new mcStatDebias impact group) and output histograms.

Validation

  • Per-toy exact match to an independent numpy reference (de-biased point,
    curvature, sandwich) for continuous-M and two-half; k-fold U-statistic == numpy and
    RMS↓ with k; sparse == dense and split-logk sparse == dense to 1e-6.
  • A native template-fluctuating coverage harness (tests/mcstat_coverage.py):
    the de-bias removes the central-value attenuation bias and inflates σ→σ∞; the
    proper sandwich (BB-lite σ_MC in the meat) covers ~0.68 with no calibration,
    while the bare-Hessian meat under-covers and the data-propagated meat over-covers.

Known limitations (documented)

  • continuous-M needs linear parameters; for nonlinear use two-half.
  • BB-lite on pathologically degenerate problems can give a rank-deficient covariance.
  • dataPropagated/sparse-split-logk are continuous-M/dense and log_normal-symmetric
    respectively; large-nparams --noHessian (matrix-free) de-biased covariance is
    not yet implemented (the HVPs are automatic; only a sandwich-aware Hessian-free row
    extractor is missing).

🤖 Generated with Claude Code

bendavid and others added 15 commits June 14, 2026 19:28
- TensorWriter.add_mc_stat_moment(M, param_names): frozen nparams^2 noise-floor
  matrix stored as an external-likelihood term (hess=-M -> objective -1/2 th^T M th).
- Fitter: --mcStatDebias/--mcStatDebiasCov/--mcStatKfold/--covMode options;
  _build_mcstat_M (reconstruct M from the mcstat external term);
  cov_mcstat_sandwich (A^-1 + A^-1 M A^-1, analytic, no 2nd Hessian pass);
  linearity warning (the -1/2 th^T M th penalty only de-biases LINEAR params;
  rabbit's default x^2 POI transform cancels it -- use --allowNegativeParam).
- rabbit_fit.py reports the sandwich covariance when continuousM+sandwich.
- tests/toy_mcstat.py, tests/verify_mcstat.py: 200-bin degenerate toy; rabbit
  matches an independent numpy reference to 4 d.p. (de-biased point, curvature,
  sandwich). See RESULTS.md S9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… + sandwich)

- TensorWriter.add_process(..., fold_axis=<name>): slice a named MC-stat fold
  axis into k folds, derive the full by projecting it out, store hnorm_folds
  [k,nbinsfull,nproc]. Non-folded procs stored as norm_full/k per fold
  (MC-stat-exempt). Common k required.
- inputdata: load norm_folds / mcstat_fold_k.
- Fitter: precompute half templates norm_A/norm_B (=2*sum of fold halves,
  shared logk); _compute_yields_noBBB(templates='A'|'B'); jackknife objective
  L_cf = 2 L_full - 0.5 L_A - 0.5 L_B in _compute_nll_components; meat objective
  loss_val_grad_hess_meat + cov_twohalf_sandwich (A^-1 H A^-1).
- rabbit_fit.py reports the two-half sandwich when twoHalf/kfold + sandwich.
- tests/toy_twohalf.py, tests/verify_twohalf.py: rabbit matches numpy reference
  to 4 d.p.; two-half also de-biases the NONLINEAR (x^2) POI where continuous-M
  is cancelled. See RESULTS.md S9d.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- fisher_curvature / fisher_curvature_full / _fisher_core: Gauss-Newton expected
  information F = H_obj - H_ln_obs + sum_i coeff_i J_i^T D J_i (D=1/V chisq,
  data_cov_inv covFit, 1/nexp Poisson). For the jackknife objective the GN data
  part is exactly the cross-half Fisher F_ch. Drops the residual*d2nexp term,
  leaves constraint/external/BB-lite curvature intact.
- rabbit_fit.py: --covMode fisher uses the GN curvature as the bread for the
  standard cov AND the sandwich (continuous-M and two-half); cov_twohalf_sandwich
  meat follows covMode too.
- Validated: linear -> fisher==observed (5.8e-11); nonlinear -> differs (drops
  residual). CLI runs both modes end-to-end.
- Refined continuous-M linearity warning: _mcstat_M_params_linear() fires only
  when M actually touches nonlinear params (squared POI / log_normal syst / non-
  chisq); silent for the linear-Gaussian case where continuous-M is exact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- TensorWriter.add_systematic(h, ..., fold_axis=<name>): split a systematic's
  logk per fold (per-fold logk vs per-fold nominal), register the full syst by
  projecting the fold axis out. Assemble/write hlogk_folds [k,nbinsfull,nproc,
  nsyst] (folded systs split; others replicated so logk_A==logk_B). Minimal
  scope: dense, symmetric tensor, single-hist (mirror=True).
- inputdata: load logk_folds.
- Fitter: build logk_A/logk_B (k=2: per-fold logk = per-half logk, same rnorm_init
  scaling as shared logk); _compute_yields_noBBB(templates='A'|'B') uses them.
- Validated (tests/toy_splitlogk.py): a systematic reusing the nominal MC sample
  has its template noise de-biased by split logk (tilt sigma 0.0705 -> 0.0907)
  where shared logk does not (0.0705). See RESULTS.md S9f.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Examined composing the de-biasing with Barlow-Beeston-lite; both diverged with
BB-lite ON. Root causes + fixes:

- two-half + BB-lite was UNBOUNDED: beta-profiling flattens L_full's POI
  curvature while -1/2 L_A -1/2 L_B keep full curvature -> A=2H_full,bb-1/2H_A
  -1/2H_B indefinite. Fix: apply the full-sample profiled beta (per-bin factor
  nexp/nexp_full_raw) to the half predictions too, so all three terms are
  consistently profiled and A ~ H_full,bb > 0 (bounded/PD). Composes sensibly:
  BB-lite inflates the baseline, two-half de-biases on top.
- continuous-M + BB-lite: M must use the BB-inflated variance (mu+sumw2) in its
  denominator (else H-M non-PD, divergence). Fitter warns; add_mc_stat_moment
  documents it. Works with the correct M.
- Both --covMode modes work with BB-lite (fisher ~ observed).

tests/verify_bblite.py asserts PD convergence + de-bias for both methods with
BB-lite on. No-BB paths unchanged (verify_mcstat / verify_twohalf exact).
See RESULTS.md S9g.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- fitter.cov holds the reported de-biased cov (sandwich/curvature), so parameter
  errors, expected-hist bin errors (--saveHists), and postfit variations already
  propagate it (verified: sandwich bin errors > curvature).
- Traditional impacts now decompose the de-biased CURVATURE A^-1 with the matching
  covMode-aware bread for total/syst/stat (was: sandwich for syst, raw observed
  Hessian for stat; --covMode fisher unpropagated). impacts_parms gains cov= and
  extra_group_vars=. The sandwich's extra coverage term diag(sandwich-curvature)
  is appended as a new 'mcStatDebias' grouped-impact column (workspace axis label
  added). Verified columns/labels/values consistent.
- global_impacts_parms gains a cov override (decompose the de-biased curvature).
- All --mcStatDebias x --covMode x --mcStatDebiasCov combos run --doImpacts /
  --globalImpacts / --saveHists end-to-end; standard impacts + no-BB/BB-lite
  validation suites unchanged. See RESULTS.md S9h.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Generalize the fisher (GN) cross-half curvature to the complete k-fold
  U-statistic A_k = k/(k-1)[F_full - sum_i F_i_raw] = k/(k-1) sum_{i!=j} cross,
  via the full-minus-self identity: O(k), streamable, no partition enumeration.
  Reduces EXACTLY to the 2-half cross-half Fisher at k=2.
- _fisher_data_terms returns the raw-per-fold U-statistic terms (residual-free GN);
  _fisher_core splits H_ln (objective's rescaled halves -> non-data extraction)
  from F (U-statistic data Fisher). _compute_yields_noBBB gains templates='fold'
  + fold_index; __init__ stores n_folds and per-fold scaled logk.
- Realized in --covMode fisher (auto for k>=2; benefit for k>=3). Observed mode
  k>=3 uses the single 2-half split; fitter logs an INFO -> use fisher. The point
  (objective) keeps the rescaled 2-half jackknife (unbiased). --mcStatKfold
  deprecated (k inferred from folds; U-statistic is parameter-free).
- Validated (tests/verify_kfold.py): rabbit == numpy U-statistic (k=4) to 4 d.p.;
  ensemble median ~ sigma_inf (unbiased), RMS shrinks with k toward the
  continuous-M floor. No regression. See RESULTS.md S9i.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Lift the k=2-only split-logk restriction. Build logk_folds_scaled [k,...] (per-
  fold scaled logk) for any k; k=2 sets the half logk for the objective (full
  treatment), k>2 objective falls back to shared logk (point de-biases nominal
  noise; systematic-template noise de-biased in the curvature) + INFO.
- _fisher_data_terms uses n_sumfold = sum_i n_i as the U-statistic 'full' term and
  the meat, so full-minus-self = sum_{i!=j} cross holds EXACTLY for shared logk
  (sum_i n_i == nexp_full, common case unchanged) AND split logk. BB-lite beta
  applied to every per-fold prediction.
- toy_splitlogk.py generalized to K folds (env K, default 2). Validated: shared-
  logk paths unchanged (verify_kfold/twohalf/bblite pass; k=2 tilt sandwich 0.0907
  unchanged); k=4 split-logk loads per-fold logk [4,...], converges, de-biases the
  tilt systematic more than shared (0.130 vs 0.086). See RESULTS.md S9j.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- add_systematic([up,down], ..., fold_axis=, symmetrize=): _add_systematic_folded
  handles the up/down PAIR; per fold computes logkavg (+ logkhalfdiff for the
  asymmetric symmetrize mode), stored in dict_logkavg_folds / dict_logkhalfdiff_
  folds. write() assembles the fold logk in the full tensor's shape ([k,..,nsyst]
  symmetric or [k,..,2,nsyst] asymmetric; folded systs split, others replicated).
- Fitter: shape-aware rnorm_init scaling of logk_folds_scaled (sym/asym); removed
  the symmetric-tensor guard; per-fold asym logk flows through the existing
  asymmetric interpolation in _compute_yields_noBBB(templates='fold').
- Folded process + asymmetric SHARED syst already worked (verified). 'linear'/
  'quadratic' symmetrize not supported with folds. Validated: k=4 folded asym
  syst converges PD, logk_folds [4,40,1,2,1]. Symmetric/split paths unchanged.
  See RESULTS.md S9k.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Fold norm is stored dense [k,nbinsfull,nproc] even in sparse mode (the full
  template stays sparse CSR); removed the writer's sparse fold_axis raise (folds
  densified).
- Fitter sparse per-fold yield: scatter the shared systematic factor from the
  sparse logk (exp(logsnorm) log_normal / additive logsnorm normal) into a dense
  [nbinsfull,nproc] grid and contract with the dense fold/half norm. The fisher
  U-statistic curvature (dense per-bin yield + dense Jacobian) then works for
  sparse-input + dense-cov. split-logk in sparse not supported (raises).
- Validated (tests/verify_sparsefold.py): sparse k=4 fold de-bias == dense to 1e-6
  (point, fisher curvature, sandwich). Sparse non-fold path unchanged; verify_*
  pass. See RESULTS.md S9l.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- The sparse fold-yield uses the shared systematic factor; split-logk needs per-
  fold logk. Instead of densifying the full [k,nbins,nproc,nsyst] logk, store a
  per-fold DELTA over the FOLDED systs only: delta=logk_fold-logk_full, dense
  [k,nbinsfull,nproc,n_folded], + folded global syst indices.
- Writer: _add_systematic_folded computes logk_full densely and stores the delta;
  write() assembles hlogk_folds_delta + hmcstat_folded_syst_idx (sparse only).
  inputdata loads them.
- Fitter: sparse fold-yield multiplies the shared log_normal factor by
  exp(delta.theta) for the active fold (curvature path); shared_logk+delta=fold_logk
  exactly. Objective/halves use shared logk (point de-biases nominal noise).
  log_normal symmetric only (asymmetric sparse split-logk raises).
- Validated (tests/verify_sparse_splitlogk.py): sparse k=4 split-logk == dense to
  1e-6 (point, fisher, sandwich). All other fold paths unchanged. See RESULTS.md S9m.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
rabbit's built-in toys fluctuate DATA only; MC-stat coverage needs the TEMPLATES
fluctuated. tests/mcstat_coverage.py fluctuates both per pseudo-experiment, builds
a rabbit tensor, fits, and measures POI coverage (configurable debias x covMode x
debiasCov x BB-lite x chisq/Poisson). Near-degenerate slides toy with NONZERO
degenerate-direction truth (rnorm_true=[1.3,0.7]) so the attenuation bias is real.

Findings: de-bias REMOVES the central-value bias (naive med_bias ~ -0.29 on a true
0.42 -> ~0) and inflates sigma toward sigma_inf (validates the first two original
criteria); the raw curvature/sandwich sigma still underestimates the de-biased
point's ENSEMBLE scatter by ~20-25% (MC-noise variance in the score not in the
single-toy meat) -> coverage ~0.4-0.55 not 0.683 -> the Bartlett/calibration factor
is needed for exact coverage (CONFIRMS the known limitation, now measured natively).

tests/verify_coverage.py asserts the robust facts (bias removed, sigma inflated,
coverage improved). See RESULTS.md S9n.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…concile proper sandwich

- run_coverage: require positive variance in the MEASURED directions (not full PD),
  so BB-lite's rank-deficient-other-direction covariance on near-degenerate toys is
  not spuriously rejected; return nan instead of crashing when n=0.
- Reconciliation (RESULTS S9o): S9n measured the BARE sandwich (--noBinByBinStat,
  meat omits sigma_MC) -> undercovers (cov 0.51), calibration gap. The PROPER sandwich
  with BB-lite (meat = A+M = H_bb includes sigma_MC) COVERS 0.67 ~ 0.683 with NO
  calibration (med_sig 0.041 matches the ensemble scatter 0.040) -- matches the numpy
  result. rabbit's two-half sandwich uses the Hessian meat, not the cross-fold score
  variance, so it is not yet self-calibrating without BB-lite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…sandwich)

- Fitter.cov_dataprop_sandwich: delta-method sandwich (RESULTS S7d/demo23)
  propagating the per-bin DATA variance and per-(bin,proc) TEMPLATE variance
  (sumw2) through the de-biased estimator by implicit diff
  (Sigma = dx/dnobs diag(Var_nobs) dx/dnobs^T + dx/dnorm diag(sumw2) dx/dnorm^T).
  Continuous-M, dense (watches indata.norm).
- --mcStatDebiasCov gains 'dataPropagated'; rabbit_fit wires it.
- Validated: conservative / OVER-covers (slides toy: cov 0.79 vs analytic 0.50,
  none 0.06) -- matches S7d (~0.81). Corrects S9o: the score-variance meat is NOT
  self-calibrating, it over-covers (needs a downward k); the calibration-free meat
  is the analytic/BB-lite one. CLI runs end-to-end. See RESULTS.md S9p.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…CI lint)

- isort (--profile black --line-length 88) + black across the mcstat source and
  test files; remove an unused 'import tensorflow as tf' (F401) and a dead
  'bias_dif' list. No functional change (verify_twohalf still matches numpy).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant