Skip to content

fit is one loop: optimizer/callback hooks, calibrate(), init="glorot" — the exact reference protocol - #44

Merged
MArpogaus merged 18 commits into
feat/followupsfrom
refactor/lean-fit
Aug 31, 2026
Merged

fit is one loop: optimizer/callback hooks, calibrate(), init="glorot" — the exact reference protocol#44
MArpogaus merged 18 commits into
feat/followupsfrom
refactor/lean-fit

Conversation

@MArpogaus

@MArpogaus MArpogaus commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

What this PR is

CausalFlowDAG.fit is 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.pyuser 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)

flow.fit(train_df, *, epochs, learning_rate=1e-2, batch_size=512,
         seed=None, optimizer=None, callback=None, vc_ehat=None)
  • One optimizer over all parameters (the per-node NLLs have independent gradients, so this is per-node training); optimizer= takes any torch optimizer — how a torch.optim.lr_scheduler attaches; callback(flow, epoch, optimizer) after every epoch, True stops. flow.history["train"] is all the loop records.
  • Gone, with the replacement: val_df (compute flow.nll(val) in the callback); schedule="plateau" + plateau_* + min_delta (torch's ReduceLROnPlateau on the optimizer you pass); freeze_patience and 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); verbose and the logger; epoch_callback (now callback, receives the optimizer); marginal_init (→ calibrate); vc_warm_start and its hidden classical proxy fit (two lines of user code; measured on vc_hetero: 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=) (→ 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 first fit/fit_classical calls it; one calibrated buffer replaces three per-module latches that load re-closed by hand.
  • fit(vc_ehat={node: {t: array}}) — a centered VC term needs its out-of-fold propensities from the caller; fit refuses a centered spec without them or a mismatch; the spec refuses chained centering (the guard used to live in the deleted stage).
  • fit_classical lost verbose, uses torch.nn.utils.get_total_norm. save no longer records the machine (machine_infoexperiments/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); icdf is torch.logit.
  • flow.py 2089 → 1606 lines. All notebooks, validate_ls, the benchmark and the paper scripts run on the hooks; tests/test_fit_hooks.py carries the exact-MLE guard through a torch scheduler.

2. CausalFlowDAG(init="glorot") — and why VACA needed it

With fit on the hooks, helpers.fit_paper runs the reference's global ReduceLROnPlateau on the summed validation NLL (patience 49 / threshold 0 abs / factor 0.1 / min 1e-7 — verified against torch's source to match update_learning_rate's wait >= 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:

VACA, exact R protocol do(x2) error at −3 / −2 / 0
per-node plateau, torch init (#43) 0.026 / 0.034 / 0.077
global plateau (R), torch init 0.523 / 0.334 / 0.129
… no plateau at all 0.562 / 0.354 / 0.142
… Adam eps 1e-7 0.523 / 0.334 / 0.129
… Bernstein on train min/max 0.154 / 0.012 / 0.062
glorot init 0.035 / 0.006 / 0.007

Keras' Dense default (glorot-uniform, zero bias) is now CausalFlowDAG(init="glorot"), set in the VACA/CAREFL configs (the triangle configs use init="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 in docs/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:

  • The triangle scripts' LinearMasked layers use Keras random_normal (sd 0.05), not glorot → CausalFlowDAG(init="normal"), set in the two triangle configs (glorot stays for VACA/CAREFL's layer_dense).
  • The triangle scripts take the Bernstein domain from the 5 %/95 % quantiles as we do — no deviation there; min/max (scale_df) is the comparison scripts' choice only.
  • Fig. 5 and vaca_triangle.r intervene at do(x2) = −3, −1, 0 (the text says −2) → DO_X2_VALUES = (-3, -1, 0), matching the frozen truth.json.
  • carefl_fig5.r trains on CAREFL's own X.csv with val = train and 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.

Implementationinit="glorot" had overwritten the VC head's deliberate zero output layer (bug; fixed + tested); fit validates vc_ehat (range, alignment) before calibrating; ordinal levels beyond the spec raise clearly; calibrate's second-dataset semantics documented and tested.

Experimentsbench_training stroke-ls crashed on val=None (fixed), per-phase plateau settings, freeze documented as rate 0; the torch plateau mapping was simulated against R's update_learning_rate on 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 / lr / epochs linear-cs atan-cs mixed exp-cs steps
32 / 0.001 / 500 (paper) 0.110 0.080 0.071
128 / 0.001 / 500 0.170 0.041 0.126 1/4
256 / 0.004 / 500 0.132 0.073 0.072 1/8
256 / 0.008 / 500 0.163 0.113 0.073 1/8
512 / 0.010 / 500 0.183 0.104 0.119 1/16

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

  • Cut verifier: chained-centering guard restored (spec level, tested); code-map/fitting/training-speed/varying-coefficients rewritten for the hooks; README import torch; tracked Colab .ipynb regenerated.
  • Notebooks executed end to end after the change (four of four).

6. Revision round (three more reviewers: deck, framework, configs)

A CI blocker found: pyyaml was missing from the test dependency group, while testpaths collects experiments/paper/tests/test_configs.py, which imports it — all 15 CI test jobs would have aborted at collection on main/dev-* (this branch never triggers that workflow's push filter). Verified in an isolated uv sync --no-default-groups --group test env, fixed.

Framework

  • Every query and read-out is now (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=, calibrate(marginal_init=) are keyword-only.
  • Degenerate data fails loudly instead of training into NaN: coinciding 5 %/95 % quantiles (a constant or 95 %-constant column) and ordinal columns outside 0..levels-1 (previously truncated silently); unknown activation=/init= raise with the valid choices.
  • tramdag.utils is gone — config_section moved to experiments/common.py (its only caller), so the package is modelling code only. pmf/density share one prelude; abduct keeps the caller's index; fit_classical records its report in history["classical"]; two one-expression helpers became one.
  • New known-truth tests: an affine SI at θ = 0 is the standard logistic including the pre-map Jacobian (the continuous path had no closed-form anchor before), history across two fit calls, vc_ehat outside [0, 1], init="normal" on the biases, both degenerate-column errors.

Configs / CIexperiments.yaml timeout-minutes 300 → 60 (jobs measure ≤ 11 min), the plan job gets one, the variants lookup tolerates a config without them, the result-dir name dashes only the script part (as make_output_dir does); test_configs.py now covers every <area>/<script>.yaml including validate_ls; per-file-ignores pruned to the rules the directories actually raise; the commitizen-branch pre-push hook removed (pre-commit exports PRE_COMMIT_FROM_REF/TO_REF only when both refs exist, so a branch's first push hands commitizen a literal string and it aborts — the commit-msg hook 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 — check green on all three.

🤖 Generated with Claude Code

MArpogaus and others added 18 commits August 26, 2026 10:05
…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>
@MArpogaus
MArpogaus merged commit acd53c0 into feat/followups Aug 31, 2026
18 checks passed
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