fit is one loop: optimizer/callback hooks, calibrate(), init="glorot" — the exact reference protocol - #44
Merged
Merged
Conversation
…librate()
Three independent reviews of flow.py (user code vs framework; duplicates of
torch/zuko; strategies tuned on our own DGPs) agreed: eleven of fit's
eighteen keyword arguments were training strategies, each with a default
the paper replication or the tests had to switch off. fit is now
fit(train_df, *, epochs, learning_rate=1e-2, batch_size=512,
seed=None, optimizer=None, callback=None, vc_ehat=None)
one minibatch Adam loop over all parameters (the per-node NLLs have
independent gradients, so this is per-node training), final weights kept.
optimizer= takes any torch optimizer, which is how a lr_scheduler attaches;
callback(flow, epoch, optimizer) runs after every epoch and stops on True.
flow.history holds the per-node train NLL and nothing else.
Gone, with the replacement: val_df (flow.nll(val) in the callback);
schedule/plateau_*/min_delta (torch's ReduceLROnPlateau on the optimizer
you pass); freeze_patience (a callback; the benchmark keeps the recipe);
restore_best (six lines); verbose and the tramdag.flow logger;
epoch_callback (renamed callback, receives the optimizer); marginal_init
(now calibrate(train_df, marginal_init=True), the data-dependent state
taken once, with a single `calibrated` buffer instead of three per-module
latches that load re-closed by hand); vc_warm_start and its hidden
classical proxy fit (two lines of user code; measured: beta0 0.16 from the
truth without it, recovery corr 0.99 either way); vc_oof_fit, the hidden
five-fold stage-1 fits and VC(center_folds=) — a centered VC term takes the
caller's out-of-fold propensities as fit(vc_ehat=), and the spec refuses
chained centering. fit_classical lost verbose and uses get_total_norm.
save no longer records the machine (machine_info leaves the package).
transforms: the inverse is zuko's own (Transform.inv: bisection inside the
bound, closed-form tail) — the 85-line expanding bisection duplicated it,
identical residuals, spline inverse 166 ms -> 0.3 ms on 4000 rows;
StandardLogistic.icdf is torch.logit.
New: CausalFlowDAG(init="glorot") — Keras' Dense default (glorot-uniform,
zero bias) for every linear layer, the paper's reference init; stored in
the checkpoint. Under the reference's full-batch protocol with its global
plateau rule the init decides the fit (VACA do(x2) error 0.52/0.33/0.13
with torch's init, 0.035/0.006/0.007 with glorot).
flow.py 2089 -> 1606 lines. tests/test_fit_hooks.py carries the exact-MLE
guard through a torch scheduler; test_vc_centered computes its own OOF
propensities; the notebooks run on the hooks (executed end to end).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
helpers.fit_paper calibrates with marginal_init=False, builds the Adam itself and steps torch's ReduceLROnPlateau on the summed validation NLL from the callback — the reference's update_learning_rate exactly (global, reduce at wait >= patience with strict <, reset after a reduction; patience-1 because torch reduces at bad > patience). All four configs set init: glorot, the reference's Keras init, and their headers describe the rule as matched rather than as a per-node deviation. bench_training keeps the plateau+freeze recipe it measures as its own _PerNodePlateau callback over one parameter group per node; perf_machine owns machine_info; validate_ls drops the removed kwargs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fitting.md describes fit as one loop and shows the recipes as callbacks; training-speed.md points its measured recipes at their new homes; varying-coefficients.md shows the six-line out-of-fold propensity step for fit(vc_ehat=); code-map, README, CLAUDE.md and tests/README follow the API; CHANGELOG has the breaking section and the init="glorot" entry. docs/paper-replication.md carries the exact-protocol numbers next to the 2026-08-25 ones, the deviation-by-deviation VACA table and the seed spread. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…otocol
Measured 2026-08-26 with init: glorot and the global plateau rule:
triangle linear-ls beta12 1.987 beta13 -0.170 beta23 0.282
linear-cs cs max err 0.110 atan-cs 0.080 sin-cs 0.997
mixed linear-ls beta13 -0.246 beta23 0.319 OR 7.29 TV 0.044
exp-cs beta13 -0.205 cs max err 0.071 TV 0.019
vaca |E[x3|do(x2)] err| -3: 0.098 -2: 0.159 0: 0.026
(init seeds 8, 9: 0.268 / 0.119 / 0.005 and 0.217 / 0.031 / 0.021)
carefl CF MAE x3 -1.5: 0.181 0: 0.065 +1.5: 0.142
CF MAE x4 -1.5: 0.204 0: 0.134 +1.5: 0.162
Bounds at 2.5x the seed-7 measurement; the vaca note records the seed
spread at the off-manifold do(x2=-3).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… stricter fit inputs A verifier over the cut found init="glorot" re-initializing every nn.Linear, including the VaryingCoef output layer that is zero by design (beta(x) = beta0 at the start) — fixed in _apply_init, which now also offers init="normal" (Keras RandomNormal, sd 0.05 on weights and biases): the initializer of the paper's triangle scripts, whose LinearMasked layers are not glorot. fit validates vc_ehat before calibrate (a rejected call no longer leaves the flow calibrated on the rejected data) and rejects values outside [0, 1]; calibrate raises a clear error when an ordinal column exceeds the declared levels; the calibrate docstring states that later fits reuse the first fit's state. The fit docstring's scheduler snippet uses keywords (positional ReduceLROnPlateau(opt, 0.1, 50) is a factor error). Tests: the VC head stays zero under glorot, the normal init's sd, transform ranges persist across a second fit, the inverse far in the tails. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tch 256 for CI Ground truth re-validated against the paper text and the R code: - VACA intervenes at do(x2) = -3, -1, 0 in the paper's Fig. 5 and in vaca_triangle.r (the text says -2); DO_X2_VALUES follows the code, which is also what the frozen data/vaca/truth.json holds. - The triangle scripts initialize every LinearMasked layer with Keras random_normal (sd 0.05), not glorot: init: normal. Their Bernstein domain comes from the 5%/95% quantiles as ours does — the YAML headers no longer list that as a deviation; what remains is the [0,1] vs [-5,5] reparametrization, the tail rule and order 21 vs 19. - carefl_fig5.r trains on CAREFL's own X.csv with val = train and sd-standardized x3/x4; our fresh 2500-row draw, separate validation draw and raw units are repo choices, now stated in the header. - compare_do_x1 also reports |E[x3|do(x1)] flow - DGP|, so the ground truth can bound the error instead of pinning a noisy flow mean. CI runtime, the one deviation taken: the triangle configs train at batch 256 / lr 0.004 instead of the paper's batch 32 / lr 0.001 — the same 500 epochs in 8x fewer steps. A 24-run grid (batch 128/256/512, lr 1e-3..1e-2, 250/500 epochs) against the ground-truth bands picked it: the only row within 0.02 of the paper protocol on every cs error (linear-cs 0.132 vs 0.110, atan-cs 0.073 vs 0.080, mixed exp-cs 0.072 vs 0.071); batch 512, lr 0.008 and batch 128 / lr 0.001 degrade the cs curves. Early stopping at min_lr gains nothing for VACA/CAREFL (CAREFL never reaches it in 7000 epochs). bench_training: stroke-ls crashed on val=None; per-phase plateau settings so the vaca-ci reference keeps its polish phase; the freeze is rate 0 only, said so. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ies, numbers Ground-truth validator: triangle init and Bernstein domain corrected, the R CAREFL run's val = train and units, the do-value discrepancy between the paper's text and its figure/code, the paper's own fitted numbers in the "paper" column (1.98 / -0.21 / 0.26; 2.07 / -0.203; OR 7.74 [7.16, 8.38]), seeds per script. Docs verifier: CHANGELOG entries that still described intermediate 0.4 designs (schedule, plateau_patience default, marginal_init on fit, logging, epoch_callback, center_folds, vc_center_info, the bisection warning) rewritten to what ships; the glorot numbers quoted at the config's seed (0.098 / 0.159 / 0.026) with the other draw named; CAREFL val NLL x4 1.419; tests/README lists test_density and test_net_input_scaling. The replication report gains the batch/lr grid and both the paper-protocol and the CI-config columns. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… R do-values Triangle at batch 256 / lr 0.004 with init: normal (2026-08-26): linear-ls beta 1.988 / -0.171 / 0.295 linear-cs cs err 0.088 atan-cs 0.077 sin-cs 1.024 mixed linear-ls beta13 -0.243 beta23 0.306 OR 7.30 TV 0.038 mixed exp-cs beta13 -0.202 cs err 0.107 TV 0.020 VACA at do(x2) = -3 / -1 / 0: errors 0.097 / 0.088 / 0.019. CAREFL unchanged. The do(x1) flow means are bounded through their error now; every _note names this branch and date once. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…imings test_recovery_bar_on_hetero_dgp failed on the macOS runners only: beta0 -1.22 against -1.0 +- 0.2 (linux -1.16). The population-mean effect is -0.98 on these rows and a classical warm start lands at the same point, so the gap is the penalized head's shrinkage at this n, not the start; the tolerance is 0.3 with the measurements in the comment, corr >= 0.9 stays the acceptance bar. Docs record the new CI timings: triangle jobs 7-11 min at batch 256 (42-69 at batch 32), workflow about 12 min wall. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… module Revision round with three reviewers over the deck, the framework and the configs. Framework: - Every query and read-out takes (df, node, *, ...): varying_coef and intercept_contributions took the node first and called the frame `data`, unlike pmf/density/scores/design_matrix/effect_modifier_scan. do=, seed=, t=, candidates= and calibrate(marginal_init=) are keyword-only now, as every fit knob already was. - calibrate raises when a continuous node's 5%/95% quantiles coincide (a constant or 95%-constant column used to fit into nan) and when an ordinal column is not an integer index in 0..levels-1 (values were silently truncated). Unknown activation= and init= raise with the valid choices. - pmf and density share one _conditional prelude; abduct keeps the caller's index; fit_classical records its report in history["classical"]; _is_all_ls and _covered_by_classical are one function; the now-unreachable constant-parent guard in set_net_range is gone (the quantile check fires first, with a better message). - tramdag.utils is gone: config_section moved to experiments/common.py, its only caller (machine_info had already left for perf_machine.py). The package is modelling code only. - New known-truth tests: an affine SI at theta = 0 is exactly the standard logistic including the pre-map Jacobian (the continuous path had no closed-form anchor); history across two fit calls; vc_ehat outside [0, 1]; init="normal" on the biases; the degenerate-column errors. Configs and CI: - pyyaml joins the test group. test_configs.py imports it and testpaths collects experiments/, so all 15 CI test jobs would have aborted at collection on main/dev-* (the branch never ran that workflow's push trigger). Verified in an isolated `--no-default-groups --group test` env. - test_configs.py now checks every <area>/<script>.yaml (the same glob the workflow plans from, so validate_ls is covered) and reads only the helpers that sit next to the script. - experiments.yaml: timeout-minutes 300 -> 60 against 11-minute jobs, the plan job gets one, the variants lookup tolerates a config without them, and the result-dir name dashes only the script part, as make_output_dir does. - per-file-ignores keep only the rules the directories actually raise (measured); the dead src/*/__init__.py entry is gone. - The commitizen-branch pre-push hook is removed: pre-commit exports PRE_COMMIT_FROM_REF/TO_REF only when both refs exist, so the first push of a branch hands commitizen the literal string and it aborts. The commit-msg hook already checks every message. Docs and the YAML headers: the VACA do-values are the R code's -3/-1/0 everywhere (the results table still showed -2 rows and the pre-change numbers), the batch/lr grid is labelled as glorot-measured, the "now" hyperparameter columns carry the CI deviation, and the triangle init claim is scoped to the linear layers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ne spread The experiments workflow failed on validate_ls classical: max_abs_diff_named_coefs 0.0165 against a 0.01 bound, ate_abs_diff 0.0032 against 0.003148. Not a regression — the same run is 0.0027 / 0.0013 locally (identical at 2 and 8 OMP threads, and identical to the pinned centers), and the previous CI run measured 0.0018 / 0.0006 on the same image. L-BFGS in float64 is deterministic per machine, so what moves is the BLAS kernel on the flat ridge the file already documents for max_abs_diff_flow_vs_statsmodels (mRS_pre level 5 carries 7 of 1275 rows). Both bounds now sit at ~3x the largest measurement with a "why" naming the three numbers; the precision claim stays coef_*_flow within atol 0.05 of R and statsmodels, plus the ATE. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dag.callbacks fit's one hook becomes three: after_epoch_callbacks (one callable or a list, all run every epoch, any True stops after the epoch), before_fit_callbacks and after_fit_callbacks (cb(flow, opt) around the loop; after-fit runs before the VC re-centering so restored weights get re-centered). The common strategies stop being copy-pasted recipes: - callbacks.RestoreBest replaces the six-line snapshot duplicated in two notebooks, docs/fitting.md and a test — and is the recipe the stroke finding needs (flexible models overfit confounding at the MLE). - callbacks.PerNodePlateau + per_node_adam move in from bench_training.py::_PerNodePlateau, its only copy; the benchmark now imports them and steps the schedule with its own val NLL (step()). - callbacks.Logger prints the epoch line fit itself no longer owns. fit still calls calibrate() itself: it is idempotent, and an uncalibrated fit is silently-garbage numbers, not an error — the explicit call stays the way to pick marginal_init=False. callback= never shipped (0.4.0 unreleased), so this edits the staged CHANGELOG entry in place. demo_tram_dag_colab.ipynb regenerated output-stripped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…and docs Review round on the callback API (three subagents: usability, notebook readability, diff correctness). The one significant finding: a mis-registered callback — RestoreBest itself in after_fit_callbacks instead of its restore — only raised AFTER the last epoch, losing the whole run. fit now signature-checks every callback list up front (_check_callbacks), and refuses epochs < 1 (the loop would silently be skipped while calibrate and the after-fit hooks still ran); the marginal-init test that used epochs=0 as a no-op probe uses lr 0 for the same purpose. Also from the round: - log_prob runs under no_grad like every sibling query; sample() returns ordinal columns as the level indices they are (int64, via _to_frame). - Logger/PerNodePlateau reject every/patience/freeze < 1; docstrings state one-instance-per-fit and the benchmark's patience/freeze pairs. - notebooks: the dead 'module logger' setup blocks are gone (the package never logs — intro now shows callbacks.Logger instead), the demo no longer promises a GPU-comparison section it does not have, the intro's cs-vs-ls NLL comparison trains both models on the same schedule, additive_vs_joint drops its private-API theta rebuild (the exactness is pinned by the test suite), plus small dead-code trims. All three re-executed clean; colab ipynb regenerated. - docs/CHANGELOG: the last fit(callback=) mentions updated, the manual restore recipe notes it skips the VC re-centering. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two verification subagents on the callback commits — an adversarial probe round (23 probes: every legitimate callable form accepted, every misuse rejected up front) and a repo-wide docs/notebook consistency sweep. Neither found a significant issue; the minors they did find: - _check_callbacks also rejects a non-callable entry before training (a string in the list used to surface only after the last epoch), and inspects with follow_wrapped=False so a functools.wraps adapter is judged by its own signature. - CHANGELOG records the two behavior changes vs 0.3.0 the fix commit introduced (sample's int64 ordinal columns, log_prob under no_grad) and drops its stale flow.py line count; fit's Raises section names the new epochs ValueError and the fail-fast TypeError. - PerNodePlateau's patience/freeze pairs now cite the benchmark file that actually holds them; training-speed.md notes the stroke run's min_delta override and closes a dangling backtick; two no-op leftovers in the notebooks (a 50k slice, the pre-0.4 'data' argument name). docs/zuko-upstream.md: the five ranked upstream PR candidates for zuko (analytic Bernstein call_and_ladj, linear spline tails, a public _constrain_theta inverse, the theta-shape docstring off-by-one, a Logistic distribution) with the anti-candidates, linked from CLAUDE.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eatable step calibrate stays inside fit (idempotent, an uncalibrated fit is silent garbage), but the marginal start is now its own public method: flow.init_marginals(train_df) resets every simple intercept to its column's marginal — Bernstein map onto the latent 5%/95% quantiles, ordinal class log-odds — and, unlike calibrate, is not once-guarded, so a loaded or already-trained flow can be restarted at the marginal. A fresh flow takes its ranges from the same rows first. calibrate(marginal_init=True) delegates to it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ause
Example-code review round (two subagents: simplification sweep,
adversarial init_marginals verification). The one significant finding:
the demo's section-6 paragraph attributed the spline's NLL gap to
optimization ('easier to optimize'), contradicting the measured
structural cause CLAUDE.md and code-map pin — zuko's fixed RQS tail
slope misweighting the ~10% of data beyond the pre-scaling range — and
citing this very section as the demonstration. The paragraph now states
the tail mechanism; 'same training' becomes 'same protocol (lr per
family)' since the learning rates differ.
Minors from the round:
- init_marginals docstring states the asymmetry a probe surfaced: on a
calibrated flow the Bernstein start is the canonical map of the
stored range (the df is not read); only ordinal intercepts re-read
the rows. The repeatability test now pins the ordinal root too.
- bench_training: the val-NLL/wall-clock recording is the benchmark
callback's, not fit's (docstring said otherwise); run_lbfgs states
why it is not fit_classical and that the cold-start calibrate is the
same start a fit would take.
- perf_machine: duplicate in-function torch import dropped, --report
example runs from experiments/, the underscore-prefixed sanity value
is labelled as ignored by --report.
- intro: the anatomy cell says _tensorize/_features are internals shown
on purpose; the raw-weight reads point at ls_coefficients().
Not extracted: the triangle/triangle-mixed run() overlap — the shared
building blocks already live in helpers.py; the remaining lines are
per-script narrative (paper figure numbers, titles) a multi-title
helper would only hide.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The confirmation round found no significant issue. Its residuals: the end-of-run print now gives the module invocation from experiments/ (--report ../docs/perf) like the docstring the previous commit fixed; the three init_marginals doc mentions say Bernstein/ordinal (spline and affine have no calibrated start), as the docstring already did; the sanity-value comment names --report's key list instead of implying an underscore-prefix filter. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…erted pdoc list The rebased branch line carried a docs.yaml that still passes tramdag.utils to pdoc; the module is gone, so the reverted short list (pdoc tramdag guides examples) is the working one. The whole workflow is replaced by the MkDocs branch next. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this PR is
CausalFlowDAG.fitis one minibatch Adam loop with two hooks, and the paper replication runs the reference's exact rule with the reference's init. Stacked on #43 (feat/followups); the diff shown is this PR's own.Three independent reviews of
flow.py— user code or framework?, does it duplicate torch/zuko?, which behaviours are strategies tuned on our own DGPs? — agreed on the cut, and a fourth reviewer verified the result. Every training strategy that left had a default the paper replication or the tests had to switch off.1.
fit(breaking)optimizer=takes any torch optimizer — how atorch.optim.lr_schedulerattaches;callback(flow, epoch, optimizer)after every epoch,Truestops.flow.history["train"]is all the loop records.val_df(computeflow.nll(val)in the callback);schedule="plateau"+plateau_*+min_delta(torch'sReduceLROnPlateauon the optimizer you pass);freeze_patienceand the per-node freeze (experiments/benchmarks/bench_training.py::_PerNodePlateau, the recipe that benchmark measures);restore_best(a six-line snapshot callback,docs/fitting.md);verboseand the logger;epoch_callback(nowcallback, receives the optimizer);marginal_init(→calibrate);vc_warm_startand its hidden classical proxy fit (two lines of user code; measured onvc_hetero:beta00.16 from the truth without it, recovery corr 0.99 either way);vc_oof_fit, the hidden five-fold stage-1 fits andVC(center_folds=)(→fit(vc_ehat=), below).flow.calibrate(train_df, marginal_init=True)— the data-dependent state once (transform ranges, network-input min-max, calibrated start); the firstfit/fit_classicalcalls it; onecalibratedbuffer replaces three per-module latches thatloadre-closed by hand.fit(vc_ehat={node: {t: array}})— a centeredVCterm needs its out-of-fold propensities from the caller;fitrefuses a centered spec without them or a mismatch; the spec refuses chained centering (the guard used to live in the deleted stage).fit_classicallostverbose, usestorch.nn.utils.get_total_norm.saveno longer records the machine (machine_info→experiments/benchmarks/perf_machine.py).transforms: the inverse is zuko's own (Transform.inv, closed-form tail) — the 85-line expanding bisection duplicated it (identical residuals, spline inverse 166 ms → 0.3 ms on 4000 rows);icdfistorch.logit.flow.py2089 → 1606 lines. All notebooks,validate_ls, the benchmark and the paper scripts run on the hooks;tests/test_fit_hooks.pycarries the exact-MLE guard through a torch scheduler.2.
CausalFlowDAG(init="glorot")— and why VACA needed itWith
fiton the hooks,helpers.fit_paperruns the reference's globalReduceLROnPlateauon the summed validation NLL (patience 49 / threshold 0 abs / factor 0.1 / min 1e-7 — verified against torch's source to matchupdate_learning_rate'swait >= patience, strict<, reset after reduction). Under that exact rule VACA fell apart with torch's default init — and one deviation at a time, under the exact protocol:Keras'
Densedefault (glorot-uniform, zero bias) is nowCausalFlowDAG(init="glorot"), set in the VACA/CAREFL configs (the triangle configs useinit="normal", see §3); the framework default stays torch's. The glorot row above is one init draw; at the config's seed 7 the errors are 0.098 / 0.159 / 0.026 (do(x2) = −3 / −2 / 0 in this diagnostic; the committed configs intervene at −3 / −1 / 0 per the R code, §3). Under the exact protocol VACA is seed-sensitive at the off-manifold point do(x2 = −3) — 0.03–0.27 over four init draws, ≤ 0.026 at do(x2 = 0) — and CAREFL is stable (± 0.03) with x4 1.5–2× worse than the per-node approximation gave. All of it, per experiment with every hyperparameter and its source, is indocs/paper-replication.md; the ground truths are re-pinned under the exact protocol.3. Second verification round (four fresh small-context reviewers)
Ground truth vs paper text + R code — four corrections, all applied:
LinearMaskedlayers use Kerasrandom_normal(sd 0.05), not glorot →CausalFlowDAG(init="normal"), set in the two triangle configs (glorot stays for VACA/CAREFL'slayer_dense).scale_df) is the comparison scripts' choice only.vaca_triangle.rintervene at do(x2) = −3, −1, 0 (the text says −2) →DO_X2_VALUES = (-3, -1, 0), matching the frozentruth.json.carefl_fig5.rtrains on CAREFL's ownX.csvwithval = trainand sd-standardized x3/x4; our draw, validation split and raw units are stated as repo choices. The paper's own fitted numbers (1.98 / −0.21 / 0.26; 2.07 / −0.203; OR 7.74 [7.16, 8.38]) now sit in the report's "paper" column.Implementation —
init="glorot"had overwritten the VC head's deliberate zero output layer (bug; fixed + tested);fitvalidatesvc_ehat(range, alignment) before calibrating; ordinal levels beyond the spec raise clearly; calibrate's second-dataset semantics documented and tested.Experiments —
bench_trainingstroke-ls crashed onval=None(fixed), per-phase plateau settings, freeze documented as rate 0; the torch plateau mapping was simulated against R'supdate_learning_rateon 500 random loss sequences: identical in 500/500.Docs — stale CHANGELOG entries that still described intermediate 0.4 designs rewritten; numbers quoted at the config's seed; tests/README completed.
4. CI runtime — one documented deviation, chosen by measurement
A 24-run grid (batch 128/256/512 × lr 1e-3…1e-2 × 250/500 epochs, four triangle variants, each scored against the ground-truth bands):
Batch 256 / lr 0.004 is the only row within 0.02 of the paper protocol on every cs error; the triangle configs use it (paper values kept beside it in the YAML), 500 epochs stay (100 → cs errors 2–3×, 250 → mixed exp-cs +60 %). Early stopping at min lr gains nothing for VACA/CAREFL. Ground truth re-pinned from the CI config with
init: normal: cs errors 0.088 / 0.077 / 1.024 / 0.107, β's within one SE of truth.5. Reviews applied earlier
code-map/fitting/training-speed/varying-coefficientsrewritten for the hooks; READMEimport torch; tracked Colab.ipynbregenerated.6. Revision round (three more reviewers: deck, framework, configs)
A CI blocker found:
pyyamlwas missing from thetestdependency group, whiletestpathscollectsexperiments/paper/tests/test_configs.py, which imports it — all 15 CI test jobs would have aborted at collection onmain/dev-*(this branch never triggers that workflow's push filter). Verified in an isolateduv sync --no-default-groups --group testenv, fixed.Framework
(df, node, *, ...):varying_coefandintercept_contributionstook the node first and called the framedata, unlikepmf/density/scores/design_matrix/effect_modifier_scan.do=,seed=,t=,candidates=,calibrate(marginal_init=)are keyword-only.0..levels-1(previously truncated silently); unknownactivation=/init=raise with the valid choices.tramdag.utilsis gone —config_sectionmoved toexperiments/common.py(its only caller), so the package is modelling code only.pmf/densityshare one prelude;abductkeeps the caller's index;fit_classicalrecords its report inhistory["classical"]; two one-expression helpers became one.SIat θ = 0 is the standard logistic including the pre-map Jacobian (the continuous path had no closed-form anchor before),historyacross twofitcalls,vc_ehatoutside [0, 1],init="normal"on the biases, both degenerate-column errors.Configs / CI —
experiments.yamltimeout-minutes300 → 60 (jobs measure ≤ 11 min), theplanjob gets one, thevariantslookup tolerates a config without them, the result-dir name dashes only the script part (asmake_output_dirdoes);test_configs.pynow covers every<area>/<script>.yamlincludingvalidate_ls;per-file-ignorespruned to the rules the directories actually raise; thecommitizen-branchpre-push hook removed (pre-commit exportsPRE_COMMIT_FROM_REF/TO_REFonly when both refs exist, so a branch's first push hands commitizen a literal string and it aborts — thecommit-msghook already checks every message).Docs / YAML headers — the VACA do-values are the R code's −3/−1/0 everywhere (the results table still had −2 rows and pre-change numbers), the batch/lr grid is labelled as glorot-measured, the "now" hyperparameter columns carry the CI deviation, the triangle init claim is scoped to the linear layers.
Checks
ruff (extended set), complexipy 15/10, full
pre-commit run --all-files, fast suite 182 passed at 97 % coverage, all four notebooks run, all eight paper variants re-run and green against the re-pinned ground truth (CI run 32974872751), plus three variants re-run after the signature change —checkgreen on all three.🤖 Generated with Claude Code