0.4.0: transformation syntax, no backward compat, radical simplification - #41
Open
MArpogaus wants to merge 248 commits into
Open
0.4.0: transformation syntax, no backward compat, radical simplification#41MArpogaus wants to merge 248 commits into
MArpogaus wants to merge 248 commits into
Conversation
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.
…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>
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.
Everything on
dev-marcelsince the 0.3 baseline, plus the framework/research splitfrom #42 (that branch is not merged into
dev-marcelyet — the numbers below aremeasured 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 SCMgenerators, frozen datasets, paper replications and benchmarks — lives in
experiments/, in three areas. The wheel drops from 13 modules to 7 and loses itssimulationssubpackage.Anything deleted is recoverable at the annotated tag
pre-experiments-cut:git checkout pre-experiments-cut -- <path>.Breaking changes
fit(epochs=)is requiredschedule="plateau"+freeze_patience=; raises with the reasonfit(plateau_patience=)default 15 → 30docs/training-speed.mdmeasured; no caller used the defaulttramdag.simulationsremoved from the wheelexperiments/paper/simulations/env.pymerged intoutils.pytramdag.machine_infounchanged;tramdag.utils.config_sectionis newtransform_kwargs=goneI("x1", transform="spline", bins=6)— raises with the replacement namedCI("a","b", allow_interaction=False)Intercept/LinShift/CShift/term()/Term.slotgoneSI/CI/LS/CS/VC, or the pythonic long namesfit(schedule=)keepsNone/"plateau"onecycle/cosinelost to plateau on every workloadStandardLogistic.sample/icdf(eps=),marginal_init_theta(q=),ordinal_marginal_init_theta(eps=),sup_bb_pvalue(terms=)KeyErrorterms=is not removed — it is the first parameter's name, soContinuousNode(terms=[...])andContinuousNode([...])are the same call. Anearlier CHANGELOG entry claimed otherwise and has been corrected.
Bugs fixed
ls_coefficients()crashed on any node mixingLSandCS— it read.weightoff every shift module, and a
CSshift is a network. Verified present onmaintoo. On
mainthe experiments never hit it because theirls_weighthelper read themodule directly; on
dev-marcelthe helper was rewritten to use the method, which iswhat broke
triangle atan-csthere. Fixed at the root, with regression tests.A load-bearing hyperparameter was dropped by the restructure.
mainfits theVACA and CAREFL benchmarks in chunks of 50 epochs; each
fitcall starts a freshAdam, 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_epochsis 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)withsigmoid, 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), withM = 30. Error against the analytic interventional mean,do(x2=a):chunk_epochsrestored)mainThe residual gap in row 2 was not a code difference: running
main's protocolverbatim 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
mainSame protocol, same data seed, same init seed; the only deliberate difference is
explicit minibatch seeding.
mainPaper truth for reference: β12 = 2, β13 = −0.2, β23 = 0.3, odds ratio e² ≈ 7.39. The
validate_lsrow is the external anchor: flow vsstatsmodelsvs Rpolr, 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.mdpresented the mixed-data cutpoints as the paper's; the paper does notstate them (they are the reference implementation's).
n_coeffswas documented as the paper's order M. zuko constrainsnunconstrainedcoefficients into
n + 2control points, son_coeffs=20is degree 21 where thereference's
len_theta=20is degree 19; the free-parameter count is what matches.conditioners.py, README, CLAUDE.md anddocs/code-map.mdall sourced the defaultarchitectures to "the original Keras implementation (
tram_models.pyintensorchiefs/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
fitcalls already passed it, so it is required now. Everything else was documented with
what justifies it — including
activation=, which was missing from the numpydoc ofall six public entry points that accept it.
Paper coverage
experiments/paper/PAPER_COVERAGE.mdmaps all 20 figures. Every figure that is aresult is reproduced. The check found one genuine gap — App. C.3.3 / Fig. 17, the
misspecified case — now the
triangle linear-csvariant. Fig. 10's argument aboutdiscretized 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₂) throughthe 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.yamlruns every replication on each push, derives itsmatrix 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.pytakes two ground-truth forms:{value, atol}two-sided, and{max}forerror measures — previously a better fit failed the run. A
{max}bound is nowkept in a band, 1.5–4× its measurement, and
check.pysays 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 — waspassing while describing a net that no longer runs, having consumed 62% of its
tolerance.
Verification
testgroup alone.tests/explicitlybefore, so
testpathsnever applied and a barepytestfailed at collection.validate_lsrun and passcheck.py.🤖 Generated with Claude Code
Addendum: the quality pass on #42 (2026-08-24)
Since the body above was written,
feat/experiments-restructuregained a fullquality pass (commits
39d4be5..94a2f40) that rolls up into this PR once #42merges. The long-form description lives in #42; the merge-relevant facts:
I,E,D,UP,B,C4,C90,NPY,PERF,PT,RUF,SIMand nine measured-clean guards;complexipy enforces cognitive complexity at 15 for
src/and 10 forexperiments//notebooks//tests/. There are zero suppression markers inthe repo — the 13 grandfathered hotspots were refactored for real
(
fit103 → 10,bench_training.main83 → 6,validate_and_sort65 → 1,_Node.__init__38 → 12,check.compare36 → 5, eight more all ≤ gate).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.
main(semantics, frozen contracts,docs, hygiene) found no critical issue. Notables: the ordinal log-likelihood
rewrite was proven bit-identical to
mainin values and gradients; allsurviving measurement CSVs are byte-identical to
main; the found nits(aliasing
_tensorize, unhashable node specs, a KeyError where a helpfulValueError belonged, stale paths/notation) are fixed on the branch.
# %% imports / global variables / private functions / public functions / private classes / public classes / alias / main, dashes to column 88).pytest --covwithfail_under = 95; the fastsuite measures 97% of
src/. The sdist shipssrc/only; the CI matrix hasits 120-minute timeout back.
94a2f40): full suite 178 passed including theslow fits;
pre-commit run --all-filesfully green; all ten experimentvariants' ground-truth checks unaffected (no measured number changed anywhere
in the pass).
Still open for the release, unchanged from above: the
version = 0.4.0bump(CHANGELOG section is written) and, until it ships, the Colab demo's
pip install tramdagcell intentionally installs the incompatible 0.3.0.