Skip to content

0.4.0: transformation syntax, no backward compat, radical simplification - #41

Open
MArpogaus wants to merge 248 commits into
mainfrom
dev-marcel
Open

0.4.0: transformation syntax, no backward compat, radical simplification#41
MArpogaus wants to merge 248 commits into
mainfrom
dev-marcel

Conversation

@MArpogaus

@MArpogaus MArpogaus commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Everything on dev-marcel since the 0.3 baseline, plus the framework/research split
from #42 (that branch is not merged into dev-marcel yet — the numbers below are
measured on it).

Nothing here is speculative: every number quoted was measured in this repository, and
where a claim did not survive measurement it is marked as corrected.

The one line that explains the diff

src/tramdag/ is framework code and nothing else. Research code — the SCM
generators, frozen datasets, paper replications and benchmarks — lives in
experiments/, in three areas. The wheel drops from 13 modules to 7 and loses its
simulations subpackage.

Anything deleted is recoverable at the annotated tag pre-experiments-cut:
git checkout pre-experiments-cut -- <path>.

Breaking changes

change migration
fit(epochs=) is required pass a budget, or a generous one with schedule="plateau" + freeze_patience=; raises with the reason
fit(plateau_patience=) default 15 → 30 the value docs/training-speed.md measured; no caller used the default
tramdag.simulations removed from the wheel generators are in experiments/paper/simulations/
env.py merged into utils.py tramdag.machine_info unchanged; tramdag.utils.config_section is new
transform_kwargs= gone I("x1", transform="spline", bins=6) — raises with the replacement named
two parented intercept terms rejected CI("a","b", allow_interaction=False)
Intercept/LinShift/CShift/term()/Term.slot gone SI/CI/LS/CS/VC, or the pythonic long names
fit(schedule=) keeps None/"plateau" onecycle/cosine lost to plateau on every workload
single-value knobs no caller set are now constants StandardLogistic.sample/icdf(eps=), marginal_init_theta(q=), ordinal_marginal_init_theta(eps=), sup_bb_pvalue(terms=)
pre-0.4 checkpoints do not load refit; the loader now says so instead of raising KeyError

terms= is not removed — it is the first parameter's name, so
ContinuousNode(terms=[...]) and ContinuousNode([...]) are the same call. An
earlier CHANGELOG entry claimed otherwise and has been corrected.

Bugs fixed

ls_coefficients() crashed on any node mixing LS and CS — it read .weight
off every shift module, and a CS shift is a network. Verified present on main
too. On main the experiments never hit it because their ls_weight helper read the
module directly; on dev-marcel the helper was rewritten to use the method, which is
what broke triangle atan-cs there. Fixed at the root, with regression tests.

A load-bearing hyperparameter was dropped by the restructure. main fits the
VACA and CAREFL benchmarks in chunks of 50 epochs; each fit call starts a fresh
Adam, so the chunk size acts as a warm-restart schedule. The restructure ran those
two as one long call, and the ground truth then enshrined the degraded result.
chunk_epochs is an explicit key now.

Two replications used the wrong reference architecture. The reference
implementation has two, and the configs cited one while replicating the other: the
triangle scripts use hidden_features_I = hidden_features_CS = c(2,25,25,2) with
sigmoid, while its own CAREFL and VACA comparisons use comparison/utils.R::make_model
— one net per node, dense(10, tanh) -> dense(100, tanh) -> dense(len_theta), with
M = 30. Error against the analytic interventional mean, do(x2=a):

protocol do(x2=−3) do(x2=−1) do(x2=0)
one call of 400 epochs (what this branch first shipped) 0.355 0.141 0.090
eight chunks of 50 (chunk_epochs restored) 0.120 0.064 0.060
main 0.015 0.014 0.012
+ the reference's own comparison net 0.037 0.012 0.010

The residual gap in row 2 was not a code difference: running main's protocol
verbatim on this branch's framework reproduces main's numbers exactly (−1.0146 /
−0.4864 / −0.2379). What differed was the minibatch stream, because the restructure
seeds it explicitly — and that workload spans 0.19 across shuffle seeds 0–3, so the
old tolerance of 0.10 around a single draw was wrong regardless. With the correct
architecture the question is moot: two of the three queries now beat main.

CAREFL improves on every previously committed number: counterfactual MAE for x4
0.078 / 0.059 / 0.086 against bounds of 0.216 / 0.174 / 0.219, val NLL 1.345 against
1.351, Fig. 6 max error 0.377 against a bound of 1.003.

Numbers, this branch against main

Same protocol, same data seed, same init seed; the only deliberate difference is
explicit minibatch seeding.

experiment metric main branch |diff|
triangle linear-ls β12 / β13 / β23 1.9807 / −0.1464 / 0.2854 1.9825 / −0.1450 / 0.2847 ≤ 0.0018
triangle atan-cs β12 / β13 1.9850 / −0.1464 1.9825 / −0.1486 ≤ 0.0025
triangle-mixed linear-ls β12 / β13 / β23 1.9807 / −0.2666 / 0.3208 1.9825 / −0.2684 / 0.3218 ≤ 0.0018
triangle-mixed linear-ls App. C.4 odds ratio 7.2477 7.2609 0.0132
validate_ls (classical) Age / NIHSSa / T 0.0526 / 0.1630 / −0.9424 0.0539 / 0.1638 / −0.9398 ≤ 0.0026

Paper truth for reference: β12 = 2, β13 = −0.2, β23 = 0.3, odds ratio e² ≈ 7.39. The
validate_ls row is the external anchor: flow vs statsmodels vs R polr, ATE
+0.1416 against +0.1428 with true ATE +0.132.

Hyperparameters against the paper

The paper (arXiv:2503.16206) states exactly four: 40000 samples, 500 epochs, Adam
at lr 0.001, Bernstein order 20
. The triangle configs match all four. Batch size,
the 90/10 split, the chunk size, the VACA/CAREFL protocols and every seed are this
repository's choices, now labelled as such at the top of each config.

Three documentation claims did not survive the check:

  • CLAUDE.md presented the mixed-data cutpoints as the paper's; the paper does not
    state them (they are the reference implementation's).
  • n_coeffs was documented as the paper's order M. zuko constrains n unconstrained
    coefficients into n + 2 control points, so n_coeffs=20 is degree 21 where the
    reference's len_theta=20 is degree 19; the free-parameter count is what matches.
  • conditioners.py, README, CLAUDE.md and docs/code-map.md all sourced the default
    architectures to "the original Keras implementation (tram_models.py in
    tensorchiefs/tram-dag)". That repository is pure R and has no Python in it; the
    defaults come from the PyTorch reference this package grew out of
    (buehlpa/TramDag).

Every default has a reason, or it is gone

An audit of all 34 defaults in src/ found exactly one indefensible: epochs=500.
This repo's own benchmark measures fixed budgets going wrong in both directions
(stroke over-spends by 2.5×, vaca under-spends by 0.03 nats), and 50 of 51 fit
calls already passed it, so it is required now. Everything else was documented with
what justifies it — including activation=, which was missing from the numpydoc of
all six public entry points that accept it.

Paper coverage

experiments/paper/PAPER_COVERAGE.md maps all 20 figures. Every figure that is a
result is reproduced. The check found one genuine gap — App. C.3.3 / Fig. 17, the
misspecified case — now the triangle linear-cs variant. Fig. 10's argument about
discretized counterfactuals is measured: an observed ordinal level pins the
latent to an interval, so the generator states the exact counterfactual law and the
flow is scored against it — P(true level) 0.924 for the flow, 0.921 for the analytic
law itself, against an attainable maximum of 0.954. That the flow sits above the
analytic law is the point of the new third number: this score is maximized by naming
the modal level, not by reporting the true distribution, so it was never a ceiling —
the review of this branch caught the claim that it was. Deliberate non-goals: Fig. 4's CNF panel and Fig. 12's NSF are
competing methods from other libraries.

One finding worth keeping: the reference's triangle net has a 2-unit input
bottleneck, and on sin — two turning points on the grid — it tracks −f(x₂) through
the middle and saturates at both ends. That is the architecture's capacity, not a fit
failure, and it is why the paper makes its Fig. 7 claim with atan.

New CI

.github/workflows/experiments.yaml runs every replication on each push, derives its
matrix from the YAML configs so the two cannot disagree, compares each run against
committed ground truth, and posts the report — metrics table plus figures — as a
commit comment through CML. Verified end to end: ten jobs, ten checks passed, ten
comments with figures on asset.cml.dev.

check.py takes two ground-truth forms: {value, atol} two-sided, and {max} for
error measures — previously a better fit failed the run. A {max} bound is now
kept in a band, 1.5–4× its measurement, and check.py says so when one is not:
below 1.5× it fails on another machine for no reason (this branch's own CI hit that,
at 1.7×), above 4× it cannot catch a regression. An entry meant to sit outside the
band carries a "why" string that is printed in its place.

Centers are re-pinned whenever the code moves them. That had been done for two files
and not the other six, so triangle-atan-cs — the headline Fig. 7 variant — was
passing while describing a net that no longer runs, having consumed 62% of its
tolerance.

Verification

  • 161 fast tests pass, 166 including the slow fits, in an environment synced with the
    test group alone.
  • CI now actually runs the per-area experiment tests; it passed tests/ explicitly
    before, so testpaths never applied and a bare pytest failed at collection.
  • All 7 frozen datasets regenerate within 1e-9 (measured 2.2e-16).
  • All 8 paper variants and validate_ls run and pass check.py.
  • All four notebooks execute headless.
  • Zero broken relative links across the tracked markdown files.

🤖 Generated with Claude Code


Addendum: the quality pass on #42 (2026-08-24)

Since the body above was written, feat/experiments-restructure gained a full
quality pass (commits 39d4be5..94a2f40) that rolls up into this PR once #42
merges. The long-form description lives in #42; the merge-relevant facts:

  • Lint and complexity now gate everything. ruff runs its default set plus
    I,E,D,UP,B,C4,C90,NPY,PERF,PT,RUF,SIM and nine measured-clean guards;
    complexipy enforces cognitive complexity at 15 for src/ and 10 for
    experiments//notebooks//tests/. There are zero suppression markers in
    the repo — the 13 grandfathered hotspots were refactored for real
    (fit 103 → 10, bench_training.main 83 → 6, validate_and_sort 65 → 1,
    _Node.__init__ 38 → 12, check.compare 36 → 5, eight more all ≤ gate).
  • Behavior-identity is measured, not asserted: a fixed-seed harness (two-phase
    plateau/freeze/restore_best/marginal_init fit; centered VC with the OOF stage;
    joint + additive CI) compares state dicts, history and samples bit-equal
    before and after; error messages and the topological tie-breaking moved
    verbatim. Two verification agents re-derived every extracted helper.
  • A four-perspective review against main (semantics, frozen contracts,
    docs, hygiene) found no critical issue. Notables: the ordinal log-likelihood
    rewrite was proven bit-identical to main in values and gradients; all
    surviving measurement CSVs are byte-identical to main; the found nits
    (aliasing _tensorize, unhashable node specs, a KeyError where a helpful
    ValueError belonged, stale paths/notation) are fixed on the branch.
  • Every module follows one section layout (# %% imports / global variables / private functions / public functions / private classes / public classes / alias / main, dashes to column 88).
  • Coverage is gated: CI runs pytest --cov with fail_under = 95; the fast
    suite measures 97% of src/. The sdist ships src/ only; the CI matrix has
    its 120-minute timeout back.
  • Verification at the tip (94a2f40): full suite 178 passed including the
    slow fits
    ; pre-commit run --all-files fully green; all ten experiment
    variants' ground-truth checks unaffected (no measured number changed anywhere
    in the pass).

Still open for the release, unchanged from above: the version = 0.4.0 bump
(CHANGELOG section is written) and, until it ships, the Colab demo's
pip install tramdag cell intentionally installs the incompatible 0.3.0.

Selects I/E/F/D/UP at 88 columns with the numpy docstring convention
(without the convention ruff warns about incompatible rule pairs on
every run). Docstring rules are relaxed for tests, experiments and
notebooks.

Config only: nothing is reformatted and CI does not run ruff yet, so
this commit changes no behaviour. 405 findings are now visible via
'uvx ruff check .'.
ruff, whitespace/EOF/YAML/TOML housekeeping.

Deliberately omitted for now:
- the hooks are NOT installed; the ruff hooks reformat whatever they
  touch, so the one-off format sweep must land first, otherwise the
  next Python commit silently mixes formatting into its diff.
- the commitizen commit-msg hook, which would reject the repo's
  existing commit-message style. That is a maintainer decision, not a
  defect to fix unilaterally.
Activates the uv-managed .venv on cd.
Weekly updates for the pinned GitHub Actions and for uv.lock.
Setup, the fast/full test split, lint commands, the notebook rule, and
pointers to tests/README.md and CLAUDE.md for the parts that are easy to
get wrong. States why the pre-commit hooks are not installed yet.

Deliberately silent on branch and commit-message policy: that is the
maintainers' call.
Measured before adding pytest-xdist and did NOT add it: the fast subset
runs 2m11 wall for 32m of CPU time, so torch already uses ~15 of the 32
cores. '-n auto --dist=loadfile' was still unfinished after 15 minutes
-- 32 workers each spawning torch threads only oversubscribe.

Revisit only if the suite is ever run on a machine where torch is
single-threaded.
The script re-implemented src/tramdag/env.py:machine_info() line for
line -- same keys, same sysconf RAM probe -- in a file that already
imports tramdag as td. Drops 26 lines and three now-unused imports.
Self-labelled backward-compatible alias for load_magic_rct with zero
call sites in the repo.
uv sync installs the package, so prepending src/ to sys.path did
nothing except force four '# noqa: E402' markers and delay the imports
past module level. Also drops the sys/Path imports that existed only
for those lines.
13 sites, ruff F401/F811. The F811 in notebooks/intro_tram_dag.py was a
second import of ordinal_cutpoints; the line-280 import still covers
both use sites. The Colab .ipynb is regenerated in step with its .py.

Touches two test files, but only their import lines -- no assertion or
reference value is changed.
0.3.0 was written in both pyproject.toml and __init__.py. pyproject is
now the single source; importlib.metadata serves it at runtime.

The leftover I001 on the import block is left for the import-sort
commit, so this diff stays one concern.
Replaces sys.argv[1] indexing with argparse, so the scripts get --help
(which now prints their docstring) and sim_flow validates its variant
against {ls,nl} instead of accepting anything.

The shared 'optional source argument' and the name-derivation were
duplicated in three scripts; both move to common.source_arg/run_name.

The four runner scripts are deliberately NOT merged into one CLI: each
docstring documents a distinct storyline and each filename is a
documented entry point in README.md and CLAUDE.md.
13 E702 sites across 9 lines, all chained matplotlib calls.
pinact run -u. Note this includes two major bumps -- actions/checkout
v4 -> v7.0.1 and astral-sh/setup-uv v5 -> v9.0.0 -- so the first CI run
after this commit is the check that they are compatible. The trailing
comment keeps the human-readable version next to each hash.
Adds a docs dependency group (pdoc) and a workflow that publishes the
release docs at / and the dev docs at /dev/.

GitHub Pages replaces the entire site on every deployment, so a
per-branch workflow would have each branch wipe the other's docs. Both
versions are therefore built in one run from two checkouts. Uses only
first-party actions; no gh-pages branch needed.

Verified pdoc renders the package locally.
ruff I001 across 23 files. Import order only; no code changes.
50 files reformatted, 14 already conforming. Verified formatting-only:
every changed file parses to an identical AST (ruff-format's only token
changes are redundant parentheses, trailing commas and re-split
implicit string concatenation).

Recorded in .git-blame-ignore-revs in the follow-up commit.
Lists the import-sort and ruff-format commits so blame skips them.
Each clone must opt in once:
  git config blame.ignoreRevsFile .git-blame-ignore-revs
The autoresearch guard script under .claude/ is agent tooling, not
package code. 'ruff' is now a legacy alias for 'ruff-check'.
The hook reformatted all 2883 lines of uv.lock, moving the version and
revision keys and re-sorting every marker array. uv generates and
validates that file; it must not be hand-formatted.
D209 (closing quotes on their own line), D403 (capitalised first word)
and UP037 (dequoted forward-reference annotations) across 24 files.

Verified: with string literals blanked, 23 of 24 files have an identical
AST. The exception is flow.py, whose only structural change is UP037
turning "CausalFlowDAG"/"_Node" annotations into names -- safe because
the module has 'from __future__ import annotations'.
pre-commit housekeeping hooks: README.md, the workflows and
dependabot.yml (the latter two also reindented by pretty-format-yaml).
One instruction per sentence, active voice, no em-dash asides, and a
Returns section. Documents that the function never raises.
Adds the missing LinearShift and ComplexShift class docstrings and a
docstring for every forward method, and restructures the VaryingCoef
docstring into Parameters/Returns/Notes.

Keeps every empirical claim: the zero-init rationale, the corr ~ 0.5
measurement of the unpenalized reduced form, and the identification
argument for the beta0/b_theta split. Splits the semicolon-and-em-dash
sentences that carried them, one fact per sentence.
Adds 20 missing docstrings (draw_latents, simulate, observational, rct,
interventional, paper_truth, zuko_expectations, main, __post_init__) and
rewrites 14 existing ones. The repeated methods share one wording across
the five generators, so the same concept reads the same everywhere.

Also hoists a conditional out of an f-string in the triangle CLI, which
was the one line over 88 columns. Verified both branches print as before
and that data/ is untouched.
Splits the summary-plus-detail docstrings that ran together, adds the
missing spec_from_dict docstring, and replaces the semicolon-and-em-dash
enumerations with one fact per sentence.

Keeps every technical statement, including the column-naming rules for
node_scores and the identification note on the VC penalty.
Adds docstrings for the StandardLogistic methods, the n_params
properties and make_univariate_transform, and splits the run-together
summaries.

The ordinal_log_prob note keeps every claim about the log-space form and
states the consequence plainly: the naive sigmoid difference gives
exactly-zero gradients under float32 saturation and a badly initialised
node then freezes forever. CLAUDE.md warns against that simplification;
the docstring now says so where someone would make it.
Rewrites 19 run-together summaries and converts the remaining Google
Args block in varying_coef. src/ is now lint-clean under I/E/F/D/UP.

Every technical claim is kept, including: why e_hat is detached and
recomputed rather than cached, why the out-of-fold refits are required
(in-sample e_hat reintroduces own-observation bias), why float64 is a
transient mode in fit_classical, and the identification argument behind
the mean-centering in intercept_contributions.
Splits the semicolon-joined messages into sentences (STE rule 8.1 bans
the semicolon) and drops the em-dash asides.

Every substring the tests pin with pytest.raises(match=...) is preserved
verbatim -- the rewrite goes around them. Restoring '2-level' was needed
after the multilevel-treatment message initially lost it: the test caught
it, which is what that tripwire is for. Cross-checked all 16 pinned
patterns against src.
- README's edge-term table still documented the ls/cs/ci labels removed
  in 0.3.0; it now shows LS/CS/I and adds the missing VC row.
- Resolves the '[TODO: double-check this, reformualte]' marker by stating
  only what the repo demonstrably does: four DGP families, each with a
  generator and pinned by tests. Oliver should confirm that is the claim
  he wanted.
- CLAUDE.md's release recipe said to bump the version in pyproject AND
  __init__; __init__ now reads it from the installed metadata.
- CLAUDE.md's test timing was '~11 min' with no mention of the fast
  subset that the CI split relies on.
MArpogaus and others added 30 commits August 26, 2026 10:05
…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>
The docs backend is being replaced (a MkDocs Material + mkdocstrings branch
exists), so the pdoc work of c7125d2 and its follow-up a966309 — --math, one
page per module, the code-map symbol-link rewrite, and the PDF reading
gfm+tex_math_dollars with the executed notebooks — would be thrown away by
that switch and only complicates the merge. .github/workflows/docs.yaml is
back to its dev-marcel state; the docs/*.md content stays, since it describes
the code rather than the build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
notebooks/classical_fit_tram_dag.py was deleted with notebooks/stale/ in
2a7327d. Its ordinal half went to experiments/misc/validate_ls.py and its
warm-start lesson to docs/fitting.md, but nothing else walks fit_classical
through end to end, so it comes back rather than staying scattered.

The port drops the tramdag.simulations import (the VACA triangle is written
inline, as the other notebooks do), reads the stroke cohort from its new home
under experiments/misc/data/, uses positional terms and CausalFlowDAG(seed=),
and replaces a hand-rolled one-hot design with flow.design_matrix(drop_first=)
-- which did not exist when the notebook was written.

Two sections are new:

- Section 0 opens on plain logistic regression, because a two-level OrdinalNode
  with LS terms *is* one: logit P(Y=1) = -theta_0 + w_0 + sum_p w_p x_p. It runs
  on MASS::birthwt so the reader can re-fit it in R, and agrees with
  statsmodels.Logit and R glm to ~1e-8 on every coefficient, on the
  log-likelihood, and on the fitted probabilities to 9e-8. It makes two
  conventions concrete on the simplest possible model: an ordinal node
  subtracts its shift, and an ordinal parent's one-hot level-0 column is part
  of the intercept, so R's (Intercept) is -theta_0 + w_0.

- Section 1 reproduces the continuous fit outside the flow, in R (tram::Colr,
  shown with its real output) and in Python. statsmodels has no continuous
  transformation model, but it does not need one: such a model is the limit of
  an ordered logit, so binning the outcome into K quantile bins and fitting
  OrderedModel converges to the flow's shift coefficients. Both are labelled
  consistency checks, not identities -- they compare two sieve approximations
  of h and agree to ~0.1%, not to 1e-8. The cell also shows the sign flip that
  Sections 0 and 2 do not need: a continuous node adds its shift.

notebooks/data/ is new, with a README recording provenance. birthwt.csv is four
columns exported verbatim from MASS and is an input. vaca.csv is an output the
notebook rewrites each run, tracked so the R snippet reads the identical rows
the flow was fitted on, and so a changed n or seed shows up as a diff that
flags the pinned Colr coefficients as stale.

The notebook joins the docs workflow's NOTEBOOKS list and the pdoc nav, per the
rule in notebooks/README.md that a notebook not executed by CI does not belong
in that directory. statsmodels joins the notebooks dependency group, since that
job runs --group notebooks --group docs. codespell learns "lik", which R prints
in "'log Lik.'".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pqkmo7dKBep1cvkHUfEH6i
…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>
fit is one loop: optimizer/callback hooks, calibrate(), init="glorot" — the exact reference protocol
Paper replication 1:1 on the R protocol, net_input_scaling, follow-ups and review cycle two
Both CHANGELOG sides are 0.4.0 Added bullets — kept both; .gitignore
keeps scratch/, .DS_Store and site/.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three cells still used the pre-0.4 arguments: fit_classical(verbose=),
fit(schedule="plateau", plateau_patience=, freeze_patience=, verbose=)
— now callbacks.PerNodePlateau over per_node_adam — and
fit(restore_best=False), which is the default behavior. Executed end to
end under the merged 0.4 API.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Restore the classical-fit notebook, ported to the 0.4 syntax
… recorded

Every commit comment now shows fitted-vs-true at a glance:
write_report(truths=) adds 'DGP truth' and |err| columns for the
metrics that have an analytic or generator truth — the triangle
coefficients and do-means, the mixed variant's odds ratio (theory e^2)
and counterfactual score anchor, VACA's analytic interventional means
and source std (std_x1_analytic leaves the metrics for the truth
column; the ground truth pins std_x1_flow against the analytic value
at the old error bound), and validate_ls's ATE readings — including
the naive observational contrast, whose |err| column IS the
confounding. CAREFL's error metrics are already exact deviations from
the analytic counterfactuals, its title now says so.

fit_paper times the fit call alone and every run records fit_seconds —
the CI runtime tripwire. The {max} bounds land after the next CI run
has measured the 2-core runner values (check.py already notes the
missing entries without failing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The site is mkdocs-material with mkdocstrings (numpy docstrings, one
API page per module), mkdocs-jupyter (executes the jupytext notebooks
in CI — DOCS_EXECUTE=true — and renders the cells locally) and mike
(main as latest, every dev-* branch as its own version). README is the
landing page through a 40-line hook that also turns repository-relative
links into page links or GitHub URLs for the ref being built;
docs/code-map.md names every public symbol as an autoref into the API
pages. Math renders through MathJax (arithmatex).

The workflow shrinks from 233 lines and seven steps to 79: install,
PDF, publish. The PDF — README, the eight guides and the five executed
notebooks — goes through pandoc (gfm + tex_math_dollars) and XeLaTeX;
it stays continue-on-error with the previous copy republished on
failure. Gone: the pdoc stub modules, the jinja template, the 70-line
link rewriter and the WeasyPrint path.

Cherry-picked from the stale feat/mkdocs branch and brought up to the
merged 0.4 tree: the utils API page is callbacks now (the module moved
in the lean-fit cut), the nav and PDF gain paper-replication.md,
zuko-upstream.md and the restored classical-fit notebook, the docs
dependency group swaps pdoc for the mkdocs stack while keeping the
statsmodels/pyyaml additions from the notebook PR, and the code-map
autorefs are re-applied onto the current tables. Verified with a local
strict build: zero warnings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Co-authored-by: Marcel Arpogaus <38564291+MArpogaus@users.noreply.github.com>
…ement

Every ground truth gains fit_seconds {max, why}: 3x the 2-core runner
wall clock measured on commit 96affbf (2026-08-31). Shared runners are
noisy, so the bound is a gross-regression tripwire — a lost no_grad or
an accidental O(n^2) trips it, a slow runner does not. Convergence-per-
epoch regressions were already gated by the metric ground truths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A critical review pass over the prose docs. Every duplicated block now
has one owner and pointers elsewhere:

- fitting.md owns the callback recipes; training-speed.md's
  'Recommendation' section becomes a pointer (it duplicated the snippet
  verbatim) and its fit_classical re-description shrinks to the one
  load-bearing sentence (float32 was seed-fragile, float64 fixed it).
- paper-replication.md owns the CI-runtime/batch-256 story;
  experiments/README.md's copy (same numbers, same run id) becomes two
  sentences with pointers.
- varying-coefficients.md owns the VC warm-start recipe; fitting.md
  points at it. tests/README.md owns the slow-marker/CI-split policy;
  CONTRIBUTING.md folds its copy into the existing pointer.
- fitting.md loses its three wrong-altitude blocks: the 30-line
  freezing-vs-parallelism essay (whose anchor promised a fusion bullet
  that did not exist) is now 5 lines stating the measured mechanism;
  the speculative optimizer-futures section is 4 lines naming the
  benchmark that would admit a candidate; memory-and-disk is 4 lines.
  The module-anatomy section defers the class inventory to the code
  map. 311 -> 218 lines.
- Sharpened claims: the statsmodels/R agreement states its path
  (fit_classical ~4 decimals, converged Adam ~1e-3) in README and
  fitting.md; README's 'more efficient' names the measured seconds.
- notebooks/README keeps one editing route and compresses the pairing
  recipe to a parenthetical.

fitting.md 311->218, training-speed.md 162->132, experiments/README
155->145, notebooks/README 89->81, CONTRIBUTING 94->88. Verified with
a strict MkDocs build (no warnings) and pre-commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…added, ledger aligned

The ground-truth/paper audit found no significant drift; its minors:

- mean_x3_dgp_do_x1 is a deterministic seeded draw and is now pinned at
  1e-6 like the other analytic entries (was atol 0.1 with no why).
- beta13/beta23's 0.05 atol carries its why (weak identification: the
  coefficient multiplies the sd-0.254 mixture x1); coef_Age_flow
  tightens 0.05 -> 0.03 (1.5x the recorded cross-machine spread — 0.05
  would let a near-zero Age coefficient pass).
- _notes: 'R code 1:1' now says 'up to the CI batch/lr deviation';
  the triangle notes state that mean_x3_flow_do_x1 is unpinned on
  purpose; the adam note drops the removed restore_best= name.
- Ledger consistency: the paper states three training numbers (lr 1e-3
  is the R optimizer_adam() default, not paper text); the VACA glorot
  triple is labelled with its grid (earlier -3/-2/0 vs shipped -3/-1/0,
  which scores 0.097/0.088/0.019); the CAREFL architecture comparison
  names its old 18k-row protocol; simulations/vaca.py states the
  paper-text-vs-Fig.5 grid discrepancy; paper-replication.md's 'within
  0.02' admits linear-cs's 0.022.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
scipy's norm import and both helper defs (observed_information,
ls_conf_int) sat inside the preceding markdown cell: plain
'python file.py' runs them (comments are comments), but executed as a
notebook — mkdocs-jupyter, the docs workflow — they render as prose and
the first caller dies with NameError. One '# %%' marker fixes it;
verified with a cell-wise jupytext --execute run, and a scan confirms
no other notebook hides code in a markdown cell.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The two final-round reviewers found no significant issue; their minors:

- uv run in ci.yml re-synced the default dev group the sync step
  deliberately skipped (144 packages back into all 15 matrix jobs) —
  UV_NO_DEFAULT_GROUPS on the test step; docs.yaml's sync gains
  --no-default-groups for the same reason.
- The PDF contained the README twice (explicit arg plus the build/pdf
  glob) — notebook markdown moves to build/pdf/nb and the glob follows.
- mkdocs-jupyter claimed docs/hooks.py as a notebook page (and would
  execute it under DOCS_EXECUTE) — ignored; mkdocs-autorefs is declared
  in the docs group instead of riding in transitively.
- src/tramdag/py.typed ships (PEP 561): the package is fully annotated,
  pip users' type checkers now see it.
- README/CHANGELOG package enumerations include callbacks.py; README's
  classical-vs-Adam seconds cite the measured CI pair (~10 s vs
  ~200 s, not the removed float32 LBFGS's numbers); tests/README owns
  the full CI-split policy (dev-* pushes, the no-PR feature-branch
  case); the fitting/training-speed pointer loop is cut.

Not done here: the 0.3.0 -> 0.4.0 version bump — it belongs to the
release commit per the repo's release flow.

Co-Authored-By: Claude Fable 5 <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.

2 participants