Learn the SCM, phase 2: count and posterior mechanisms, a lean pass, and the RL audit - #35
Merged
Conversation
… tests, doc drift
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.
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.
Phase 2 of the learn-the-SCM program, on top of the
fit_scmcore 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 downstreamcausalrl.neuroconsumer: lag-unrolled spike nodes routinely carry 6–12 lagged parents, and at ~3 levels eachTabularCPTneeds 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=Falseis 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 viafamilies={...}; the dtype heuristic still routes counts toTabularCPT.BayesianLinearFit— posterior structural coefficients via NUTS, behind the existingcausalrl[numpyro]extra. Wherefit_scmreturns one point-estimated mechanism andfit_scm_mecrepresents 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 asmechanism.posterior, so mechanism uncertainty can be carried into an estimand instead of discarded. Every invariant its siblings satisfy still holds —invertible=True,residualattached, exact round-trip, scored by R².A fitted SCM is now a reinforcement-learning environment you can act in.
fit_scmreturns the library's ordinaryStructuralCausalModel, andenvs/base.pyalready accepts one — so this composition worked by construction and is now executed rather than assumed:tests/test_learned_scm_as_env.pyfits 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.mdis partly, and the confirming evidence is a zero:git grep "fit_scm\|FitReport\|provenance=\"fitted\"" -- src/causalrl/envs src/causalrl/agentsreturned 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.fit_scm.CausalMBRLAgenthas noscm=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.pycloses 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 fittingE[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.
PoissonGLMFit's tests passed against an implementation that discarded the noise argument and returned the fitted mean — the only test calling the mechanism assertedmean(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.BayesianLinearFitstandardisedXbut noty, leavingsigma ~ HalfNormal(1)on the child's raw scale. The MAP conditionσ⁴ + nσ² − SSR = 0gives 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.(n, cap+1), rebuilt per call, so capping columns alone still permitted ~2.4 GB. Now one budget onn * (cap + 1), checked before the first allocation.BayesianLinearFitwas absent from both export tables and from_FAMILY_NAMES, so it was reachable only by full module path andFitReport.summary()printedfamily=BayesianLinearFitbesidefamily=poisson_glm. Its author worked in a worktree cut beforePoissonGLMFitexisted and never saw the pattern.docs/tour.mdadvertised 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 bothdo(X=0)anddo(X=1).Verification
Full CI gate locally: ruff clean, 335 files formatted,
pyright src0 errors / 0 warnings / 0 informations,pytest --cov-fail-under=90exit 0 — 900 passed, 4 skipped, coverage 96.75%.examples/learned_scm_policy.pyrun 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.pyskips entirely and its assertions have never executed locally — CI's[numpyro]lane is their first real run and the sole executor ofbayesian_fit.py, which is coverage-omitted like itsnuts.pysibling. That lane is the gate for this PR.Backward compatibility
No existing behaviour changes.
_is_discretestill routes counts toTabularCPT; both new families are opt-in per node.causalrl.BayesianLinearFitresolves on a core install with no numpyro present — verified — because the export is lazy and numpyro is imported insidefit().