Skip to content

Learn the SCM, phase 2: count and posterior mechanisms, a lean pass, and the RL audit - #35

Merged
raphaelrrcoelho merged 14 commits into
mainfrom
learn-the-scm-p2
Aug 4, 2026
Merged

Learn the SCM, phase 2: count and posterior mechanisms, a lean pass, and the RL audit#35
raphaelrrcoelho merged 14 commits into
mainfrom
learn-the-scm-p2

Conversation

@raphaelrrcoelho

Copy link
Copy Markdown
Owner

Phase 2 of the learn-the-SCM program, on top of the fit_scm core merged in #34. Two new mechanism families, a lean pass, and an audit that answers a direct question about this library's identity.

What this adds

PoissonGLMFit — a log-linear count mechanism fitted by IRLS, for binned point-process data. Requested by a downstream causalrl.neuro consumer: lag-unrolled spike nodes routinely carry 6–12 lagged parents, and at ~3 levels each TabularCPT needs 729 to 531,441 rows, nearly all unobserved and dominated by the Laplace prior. A GLM is linear in the parents where the table is exponential in them, and it keeps the point-process structure a table discards. invertible=False is correct rather than a limitation — a Poisson draw is not recoverable from (parents, count), so counterfactuals at this node are genuinely unidentified and the L3 guard refuses them. Opt-in via families={...}; the dtype heuristic still routes counts to TabularCPT.

BayesianLinearFit — posterior structural coefficients via NUTS, behind the existing causalrl[numpyro] extra. Where fit_scm returns one point-estimated mechanism and fit_scm_mec represents the SCM belief as a discrete list of equivalence-class members, this is the continuous version: the posterior-mean mechanism plus the draws behind it as mechanism.posterior, so mechanism uncertainty can be carried into an estimand instead of discarded. Every invariant its siblings satisfy still holds — invertible=True, residual attached, exact round-trip, scored by R².

A fitted SCM is now a reinforcement-learning environment you can act in. fit_scm returns the library's ordinary StructuralCausalModel, and envs/base.py already accepts one — so this composition worked by construction and is now executed rather than assumed: tests/test_learned_scm_as_env.py fits a world model from a confounded log, runs it as a Gymnasium bandit, and pins that sampled rewards follow the interventional order where the logged conditional reverses.

A lean pass removing dead surface, sharing duplicated logic, and replacing one vacuous test with a discriminating one.

The audit, and its honest answer

The library is called causalrl, and this sub-project added six ways to fit a mechanism from a static table. Fitting a Poisson GLM is supervised learning; E[Y | do(A=1)] is an estimand, not a value function. So the question was asked directly: is any of this reinforcement learning?

The answer recorded in docs/learned_scm/PROGRESS.md is partly, and the confirming evidence is a zero: git grep "fit_scm\|FitReport\|provenance=\"fitted\"" -- src/causalrl/envs src/causalrl/agents returned no matches. Nothing in the library's RL half had ever heard of the learned model. This sub-project grew beside the RL spine rather than into it.

  • No, on its own terms. No policy, no reward, no exploration, no regret inside fit_scm.
  • Yes, for what it produces. A learned SCM is what model-based RL calls a world model, and it now reaches the environment surface with no adapter.
  • Not yet, where it would matter most. CausalMBRLAgent has no scm= parameter — all six of its planners build a per-action adjusted-value table from columnar arrays — so "model-based" is currently true in the causal-identification sense, not the model-based-RL sense. The one SCM-consuming agent, CounterfactualOptimalPolicy, is correctly refused on a default fitted model by the L3 provenance guard. That seam is specified in four items and deliberately left unbuilt: it is sub-project 4.

examples/learned_scm_policy.py closes the loop end to end — learn from confounded logs, plan in the model with Thompson sampling, score the resulting policy in the true world. Mean regret 0.000 (causal structure) vs 0.100 (confounder-blind) across 5 seeds, with both world models fitting E[Y|A] to within 0.008 of each other, so the difference is structure rather than fit quality. A randomized-log control in the same run shows the causal machinery buying nothing (0.000 vs 0.000) when the log was not confounded. That control is the point: the edge is the decision in the confounded regime, not accuracy in general. It is a synthetic world with exact ground truth, not a benchmark result, and the example says so in its own output.

What review caught that tests did not

Every defect below passed a green test suite.

  • A mechanism that ignored its own noise. PoissonGLMFit's tests passed against an implementation that discarded the noise argument and returned the fitted mean — the only test calling the mechanism asserted mean(drawn) ≈ e^0.8, which a mean predictor satisfies exactly. The entire inverse-CDF construction was pinned by nothing. Now pinned by integer-valuedness and Poisson dispersion, mutation-verified.
  • A prior on the wrong scale. BayesianLinearFit standardised X but not y, leaving sigma ~ HalfNormal(1) on the child's raw scale. The MAP condition σ⁴ + nσ² − SSR = 0 gives posterior sigma ≈ 668 for a true residual sd of 10 000 — a 15× underestimate that would make every downstream rollout and counterfactual band silently overconfident.
  • A guard that bounded the wrong quantity. The Poisson support table is (n, cap+1), rebuilt per call, so capping columns alone still permitted ~2.4 GB. Now one budget on n * (cap + 1), checked before the first allocation.
  • A class that did not ship. BayesianLinearFit was absent from both export tables and from _FAMILY_NAMES, so it was reachable only by full module path and FitReport.summary() printed family=BayesianLinearFit beside family=poisson_glm. Its author worked in a worktree cut before PoissonGLMFit existed and never saw the pattern.
  • A documentation snippet that demonstrated nothing. docs/tour.md advertised a persistent intervention as "a lever no arm exposes", then pinned the mediator with the chosen arm — d-separating the outcome from the lever. Measured: 0.9020 under both do(X=0) and do(X=1).
  • A headline number with no test. The example's regret claim could be inverted by scoring each policy inside the model that chose it, and the smoke test stayed green.

Verification

Full CI gate locally: ruff clean, 335 files formatted, pyright src 0 errors / 0 warnings / 0 informations, pytest --cov-fail-under=90 exit 0 — 900 passed, 4 skipped, coverage 96.75%. examples/learned_scm_policy.py run end to end and its numbers reproduced independently.

One thing this machine cannot verify. numpyro and JAX are not installed here, so tests/test_bayesian_fit.py skips entirely and its assertions have never executed locally — CI's [numpyro] lane is their first real run and the sole executor of bayesian_fit.py, which is coverage-omitted like its nuts.py sibling. That lane is the gate for this PR.

Backward compatibility

No existing behaviour changes. _is_discrete still routes counts to TabularCPT; both new families are opt-in per node. causalrl.BayesianLinearFit resolves on a core install with no numpyro present — verified — because the export is lazy and numpyro is imported inside fit().

The Step-2 command was broken twice over: a collection error in the first
file aborts the session before the second is reached, and -k is a single-
valued option so the trailing -k lag silently overrode -k poisson and
filtered both files. Split into two commands.

Task 15 asks whether this sub-project is reinforcement learning at all --
it is allowed to conclude no. Six mechanism families are point-estimate
machinery; the library's RL surface (envs, agents, OPE, certify_policy)
currently references fit_scm zero times.
…key-collision fixes

Address code review on 2aecd28:
- Standardise y (not just X) so HalfNormal(1) stays weakly-informative on the
  child's native scale too; unstandardise weights/intercept/sigma accordingly.
- mechanism.coefficients is now dict[str, float] keyed "intercept" + parent
  names, matching PoissonGLMFit's shape instead of a positional vector.
- Test 1 uses an x scale far from 1 (mean 100, sd 5) so standardisation is
  exercised rather than a numerical no-op; asserts posterior["X"] is a real
  np.ndarray (not a JAX array slipping through via __array__).
- New test pins sigma's unstandardisation at a large child scale.
- test_credible_interval_covers_the_truth_and_narrows_with_data gives each
  width() call its own seeded rng instead of sharing one stream, so the two
  calls can't silently re-roll each other's 95% coverage check under a
  future refactor.
- fit() now raises on a parent named "intercept" or "sigma" (collides with
  the reserved posterior keys) instead of silently overwriting them.
…erance back to 0.1

The mean_x=100 scale from the prior round left the intercept assertion only
~1.4 SD from its 0.3 tolerance (theoretical SD ~0.22 at that scale) --
correct but fragile to a future numpyro/jax version bump, and a loosened
tolerance is something to avoid on principle when a better fix exists.

mean_x=10 keeps the standardisation genuinely exercised (scale_x=5 != 1,
mean_x=10 != 0) while dropping the amplified intercept SD to ~0.022, so the
original 0.1 tolerance is back to ~4.5 SD of headroom. Re-verified both
named mutations are still caught at this scale (weight ~10 vs true 2;
intercept ~21 vs true 1) before reverting.
…nd parent-tensor maps

The brief's item list predates PoissonGLMFit and BayesianLinearFit, which each
re-derived a construction fitters.py already had:

- the affine mean map [1, columns] @ [intercept, *weights], written once in
  LinearGaussianFit and again in BayesianLinearFit -> _affine_mean_fn
- the numpy-to-torch parent-column map, written once in evaluate_holdout and
  again in PoissonGLMFit's in-sample score -> _parent_tensors

Also drops _require_numpyro, which imported numpyro, discarded it, and let fit()
import it again three lines later; the guard is now the real import block, so
every imported name is used and the ImportError still names the extra.

orient() bound two cycle paths only to compare them against None, and probed
tier membership through .get(...) is not None; fit_scm rebuilt the families
fallback dict once per node.
…ssertion

Restores test_tabular_cpt_smooths_an_unseen_parent_configuration, which the lean
pass removed for being vacuous: its only assertion was that draws are fitted
levels, which value_tensor[picked] guarantees by construction for every possible
table -- it passed unchanged with the pseudo-count zeroed and the unseen row NaN.

Deleting a vacuous test for reachable behaviour leaves a hole rather than closing
one. alpha's documented purpose is that an unobserved parent configuration is
still a distribution, so that is what is now asserted: both levels reachable at
the prior's 50/50 on the unseen row, and the observed row NOT flattened toward
the prior. Neither mutation is caught by both assertions, so neither is
redundant:

  np.full(..., 0.0) for the counts     -> unseen row collapses to one level
  table = uniform, ignoring the data   -> observed row's mean goes 0.01 -> 0.5

Uses a deterministic linspace sweep of the inverse CDF rather than a random
sample, so the proportions are exact and no seeding is needed.

This is not the NaN-path regression test the brief declined: that path is
unreachable behind the alpha > 0 constructor guard, and is still untested.
…a gap

The gap list was written when the vacuous test had been deleted and not yet
replaced. d86ffda pinned the behaviour with a discriminating test, so it is a
decision, not a gap. Remaining gaps are unchanged.
…env rollout, worked example

Task 15 RL audit. The honest finding first: nothing under envs/ or agents/ referenced
fit_scm, FitReport or provenance="fitted" -- zero matches. This sub-project grew beside
the library's RL spine, not into it.

What does connect, verified by running it: fit_scm returns the ordinary
StructuralCausalModel that envs/base.py:19 already takes, so a fitted world model IS a
Gymnasium environment you can act in. Pinned by tests/test_learned_scm_as_env.py --
interventional rollout rewards follow E[Y|do(A)] where the confounded log's E[Y|A]
reverses, and set_intervention reroutes sampling through the mutilated fitted model.

examples/learned_scm_policy.py closes the loop end to end: learn from confounded logs,
plan inside the model with Thompson sampling, score the policy in the true world. Mean
regret 0.000 (causal structure) vs 0.100 (confounder-blind) across 5 seeds, with both
models fitting E[Y|A] to within 0.008 -- structure, not fit quality. A randomized-log
control shows the causal structure buying nothing when the log was not confounded.

What does not connect, recorded as a known boundary: CausalMBRLAgent takes no
StructuralCausalModel (all six planners build a per-action value table from columnar
arrays), and certify_policy / PolicyValueContrast take a dataset and flat arrays. That
seam is sub-project 4 (plan-in-model) and is deliberately not built here.

Framing only in README/tour/fit.py docstring: this is the model-learning half of
model-based RL; the acting half here is a rollout, not a planner. No public name added,
removed or changed.
… it deploys

BayesianLinearFit was reachable only by its full module path: PoissonGLMFit was added to
five export/registry sites and the Bayesian family to none, because its author worked from
a checkout cut before PoissonGLMFit existed. Add it to all five --
causalrl.__init__ (_EXPORTS + __all__), causalrl.scm.__init__ (_LAZY + TYPE_CHECKING +
__all__), fit.py's _FAMILY_NAMES (user-visible: FitReport.summary() printed
family=BayesianLinearFit beside family=poisson_glm), tests/test_public_api.py, and
DESIGN.md's family table.

The export is lazy, following the sibling optional backend abduct_nuts: bayesian_fit's
module scope imports only numpy/torch/causalrl and numpyro is imported inside fit(), so
`python -c "import causalrl; causalrl.BayesianLinearFit"` resolves with no numpyro
installed (verified on a machine that has none).

Its tests asserted on posterior[...] and the residual round-trip but on neither
fitted.noise nor fitted.score -- and fitted.noise is what fit.py installs as the node's
exogenous distribution, so every see()/do() draw comes from it. Dropping the
`* safe_scale_y` unstandardisation from the noise alone kept all five tests green while
the deployed SCM drew residuals ~200x too small. Add the noise assertion at a large child
scale, a score assertion, and an end-to-end fit_scm(..., families={"Y":
BayesianLinearFit()}) test covering the wiring the direct-fit tests miss: _FAMILY_NAMES,
mechanism.invertible, the L3 guard, evaluate_holdout's dispatch, and do()/see() sampling.

Also fix MechanismFitter's docstring (both new families were missing from its
invertible / non-invertible lists) and reword PoissonGLMFit's _MAX_POISSON_CELLS error,
which told the user to act "before raising the cap" when the cap is a module constant with
no parameter to raise it.
…n the example's number

tour.md's fitted-SCM-as-environment snippet paired set_intervention({"X": 0.0}) with
env.step(2). Arm 2 is do(Z=1), and the graph is X -> Z <- Y, Z -> W, so pinning Z
d-separates W from X: do(X=0) had exactly zero effect on the printed reward, in the one
snippet whose point is that a learned model gives you a lever the arm table does not.
Switch to env.step(0), the observational arm, where do(X=0) propagates X -> Z -> W
(measured: ~0.67 unintervened, ~0.50 under do(X=0), ~0.83 under do(X=1)), and say why the
do(Z=...) arms are the ones that show nothing.

examples/learned_scm_policy.py's headline claim -- policies scored in the true world,
never in the model that chose them -- had no test: the smoke test asserted on
empirical_conditional, reward_parents and arm_values, and on nothing in chosen_arms,
mean_true_value or mean_regret. Scoring the policy in its own model instead passed the
whole test while inverting the thesis (the blind model then reports regret ~= -0.02
instead of +0.100). Pin mean_true_value to SimpsonBandit's exact ground-truth grid
(0.40 / status_quo / 0.50, recomputed from the bandit) and mean_regret to [0, 0.10] for
every model in both regimes -- assertions that survive the 40-step budget because they do
not depend on which arm Thompson sampling picks. Verified by running the mutation: it
fails on the first model, at the first new assertion.

Minors: the example claimed both world models came from "the same fit_scm call" (there are
two; the rows are shared via seed=0, the call is not); record on true_arm_value that
reusing status_quo across both regimes is sound only because SimpsonBandit's treatment
effect is constant across Z and P(A=1) is 0.5 under either policy; use sorted() on
reward_parents, which is networkx insertion order and not a contract; and give
[Unreleased] its missing entry for the two mechanism families and the
fitted-SCM-as-environment path.
@raphaelrrcoelho
raphaelrrcoelho merged commit a73e0e7 into main Aug 4, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant