Fitted planners, continuous states, and set-valued interventions - #37
Merged
Conversation
Five additive capabilities, each a gap the library already implied but could
not express. Nothing existing changes behaviour; Agent and fit_scm keep their
signatures.
1. Set-valued interventions (causalrl.intervention, agents/interventional).
do() takes a Mapping and pomis() returns sets, but Agent.act returned an arm
index -- so a multi-variable intervention could be identified and never
executed. InterventionSpace states which variables are manipulable in a
given context and to which values; assignments() turns an intervention set
into arms; InterventionalAgent returns an intervention; ScalarAgentAdapter
lifts existing arm-indexed agents into the contract unchanged.
2. AdmissibleInterventions -- POMIS recomputed per context, memoised on the
manipulable set. Restricting that set is a latent projection (r40 Thm. 4),
NOT a filter: the test pins a witness graph where the unconstrained POMIS is
[{C,D}] but the correct answer under manipulable={D} is [{}, {D}], so a
filtering implementation returns nothing and loses optimality.
3. Interference (causalrl.interference) -- direct / spillover / total effects
under an exposure mapping (Aronow & Samii 2017), the case every other module
rules out by assumption. Positivity failures raise NotIdentifiableError
naming the empty cell instead of extrapolating. No standard errors: under
interference the rows are dependent and the variance must come from the
randomisation design, not the outcome column.
4. PinnedMechanism -- deploy a known structural equation while fit_scm learns
the rest. Additive noise keeps a pinned node invertible. Mixed models carry
the new provenance="mixed", gated for L3 exactly like "fitted"; all-pinned
is "specified". Pinned nodes are still scored, and that score tests the
asserted equation rather than measuring a fit.
5. Deadline -- a monotonic per-decision budget for agents in a live loop.
Cooperative and advisory; nothing interrupts a running computation.
968 tests pass (80 new), coverage 97%, ruff/pyright/generality-lint clean and
mkdocs --strict builds.
Closes the seam left by the set-valued action work. Actions became assignments; states were still `int`. Both sides now carry a real type. Tier 1 -- causalrl.state. A StateEncoder maps an observation to a feature vector, and everything downstream works in feature space. The tabular case is CONTAINED, not discarded: an indicator basis spans every function on a finite state set, so OneHotEncoder plus a least-squares learner reproduces the tabular backup to ~1e-8. That property is asserted directly against a hand-rolled tabular recursion, because a generalisation that does not contain its own special case is not trustworthy. Ships OneHotEncoder, IdentityEncoder, RBFEncoder (the same basis FunctionApproxBackdoorAgent already uses for a continuous confounder -- a continuous state and a continuous confounder want the same machinery, which is why the tabular state interface was the odd one out) and FeatureTransition. Tier 2 -- FittedQIteration. DOVI's backward induction with the (H,S,A) table and (S,A,S) transition tensor replaced by one fitted regressor per (step, action). The regressor is the duck-typed estimate.nuisance.Regressor protocol the DML estimators already use, so the control layer went continuous without a new dependency. What it gives up, stated rather than inherited: DOVI caps optimism with a Manski bound per (state, action) cell. Feature space has no cells, so the cap falls back to a global one -- with per-step reward bounded by reward_max, the return from step h cannot exceed reward_max * (H - h + 1). Valid with no function-class assumption, and much weaker. certificate() returns Kind.EMPIRICAL with an explicit downgraded_from="bounded" hedge; is_certified is False where DOVI's can be True; and the inherited int-typed observe_transition hook raises rather than silently discarding a transition it cannot represent. Tests cover both directions: one-hot features match the tabular Q and policy exactly, and an RBF encoder recovers a threshold policy that a single-bucket encoder cannot represent at all (100% vs chance). 1002 tests pass (34 new), ruff/pyright/generality-lint clean, mkdocs --strict builds.
Tier 3: the part I argued against on scope grounds, built to the shape that
does not compromise the library's identity. It turns out neither half needs a
generative model.
FunctionalManskiBounds (causalrl.bounds.functional). causal_q_bounds bounds
E[R|do(a),s] for a discrete s from that cell's propensity and mean. Feature
space has no cells, so both become fitted functions:
lower(x,a) = mu(x,a)*e(x,a) + r_min*(1-e(x,a))
upper(x,a) = mu(x,a)*e(x,a) + r_max*(1-e(x,a))
Same logic -- the logged fraction contributes its observed mean, the unlogged
fraction is bounded only by the reward range -- and with indicator features it
reproduces causal_q_bounds to ~1e-4, asserted against the tabular function.
Nuisances are cross-fitted. This is not decoration: an in-sample plug-in makes
the interval optimistically TIGHT, which is the anti-conservative direction and
the dangerous one for a bound. An OverlapDiagnostic separates the two regimes
that look alike from the outside -- propensities near zero (wide, honest,
uninformative) from propensities a flexible model drove near one (tight, and
only as good as that model).
BoundedFittedQIteration (causalrl.agents.bounded_fitted). Recovers the causal
bound FittedQIteration had to give up:
U_h(x,a) = upper_reward(x,a) + E[ max_a' U_{h+1}(X',a') | X=x, A=a ]
L_h(x,a) = lower_reward(x,a) + E[ max_a' L_{h+1}(X',a') | X=x, A=a ]
The recursion preserves the envelope because max and conditional expectation
are both monotone. The transition tensor I said had no continuous analogue
turns out not to be needed: the successor distribution enters only through an
expectation, and an expectation is a regression. No generative model, and no
drift toward model-based deep RL.
Verified on a confounded-action / unconfounded-transition MDP where the true
interventional Q* is computable: the envelope contains it at every cell and
every step, at ~38% of the vacuous width. A separate test guards the fixture
itself -- the naive confounded mean has 0.245 bias and picks the wrong action
at state 0 -- because a bound containing the truth is only interesting when a
point estimate would have missed.
Gated as DOVI is. Multi-step propagation raises UnverifiedAssumptionError
unless transition_assumption='unconfounded': the continuation expectation runs
over the LOGGED successor distribution, so a confounder driving the dynamics
makes the interval a bound on nothing. allow_heuristic=True runs it and the
certificate drops to EMPIRICAL with downgraded_from='bounded'. Even a certified
run keeps a hedge -- correct specification of mu and e is an assumption the
tabular bound never needed, and every certificate records it with the overlap
diagnostic attached.
1037 tests pass (35 new), new modules 97-100% covered, ruff/pyright/
generality-lint clean, mkdocs --strict builds.
Acting on the end-to-end review. Four items, all additive; no exported name
changed meaning and no test was weakened to make something pass.
1. Documentation completeness. 127 of 253 exported names had no API-reference
entry -- among them Agent, CausalMBRLAgent and CausalThompsonSampling, which
is the README's own headline example. mkdocs --strict never caught this
because mkdocstrings validates the references that exist, never the ones
that are absent. All 127 are now written, grouped by tier, and
test_every_export_appears_in_the_api_reference keeps it closed -- resolving
through the lazy export map so a renamed export (canonical ->
canonical_intervention) is checked against the attribute it points at.
2. Two defects from the continuous-state work, both mine.
- bounded_fitted.py redefined TransitionAssumption as bare `str` while
dovi.py already had Literal["unknown", "unconfounded"]. The sibling
definition silently discarded the narrowing; it now imports DOVI's.
- The two fitted agents carried hand-written copies of the same
transition unpacking and validation, with paraphrased error messages --
worse than duplication that reads identically, since the two could drift
and no reader could tell whether a wording difference was meaningful. Now
one unpack_transitions/TransitionBatch in state.py.
Fixing the first surfaced a third: FunctionalManskiBounds.fit annotated
`actions` as a float array when it has always required and coerced ints.
3. Agent interface split. update() was an empty body in ten subclasses --
CertifiedPolicyAgent, all five *BackdoorAgents, CounterfactualOptimalPolicy,
NaiveOffline, and both fitted agents. These are batch learners whose policy
comes from fit/ingest_offline; a single reward carries nothing they can act
on, so the ABC was asserting something untrue of a third of its implementers.
BatchAgent supplies the no-op once and says why. Additive: it subclasses
Agent, so isinstance checks and online harnesses are untouched.
4. API tiering. 253 names in a flat alphabetical __all__ said nothing about
where to start. API_TIERS partitions them by intent -- core (14),
identification, modelling, decision, inference, integration -- and is tested
to be a true partition, so a new export cannot be added without being placed.
Verified: 1040 tests pass (3 new), coverage 97%, ruff/pyright/generality-lint
clean, mkdocs --strict builds, and the torch-free core still imports in a fresh
venv without torch.
The four cherry-picked commits were written against 29ec740, before main's learn-the-SCM phase 2 added PoissonGLMFit and BayesianLinearFit. The branch's two new surface-curation gates therefore failed on the rebase, both naming exactly those two names: test_api_tiers_partition_the_public_surface set(tiered) != set(__all__) - {__version__, API_TIERS} untiered: ['BayesianLinearFit', 'PoissonGLMFit'] test_every_export_appears_in_the_api_reference 2 exported name(s) absent from docs/api.md: ['BayesianLinearFit', 'PoissonGLMFit'] That is the gates working: a name reached __all__ without being placed or documented, which is the omission they exist to catch. Both are mechanism fitters, so they join the modelling tier beside ANMFit/LinearGaussianFit/ NeuralFit/TabularCPT/PinnedMechanism, and both get an entry in the 'Structural Models & Data' complete reference in the section's ASCII order. BayesianLinearFit is documented at causalrl.scm.continuous.bayesian_fit, the attribute the lazy export map points at; its module imports without numpyro, so mkdocstrings resolves it on the main matrix. Also restores the CHANGELOG's [Unreleased] sectioning, which the cherry-pick scrambled: the branch's '### Fixed' heading landed at the top of main's '### Added' run, filing nine Added entries -- main's own cce_polytope and PoissonGLMFit/BayesianLinearFit among them -- under Fixed. The heading moves below the Added block with its three genuine entries, and the two counts that described the pre-rebase surface (253 names, 127 undocumented) become the post-rebase ones (255, 129). No test was weakened and no assertion removed.
Gate results, the two integration failures and their fix, verification of both hand-resolved conflicts, and four pre-existing defects reported but not touched.
…ted' Three defects the rebase gate surfaced as pre-existing rather than introduced. PinnedMechanism was the only fitter missing from causalrl.scm's lazy export map, so the documented `from causalrl.scm import <Fitter>` pattern raised ImportError for exactly one name. _provenance counted pinned nodes and fell through to "fitted" when there were none -- including when there were no nodes at all, which asserts a provenance the model does not have. An empty model has no non-invertible node, so the L3 guard is unaffected either way; this is honest labelling, not safety. The library already deleted certify_mean for emitting query="do" on a computation with no intervention. FunctionalManskiBounds._fit_fold still annotated its actions array FloatArray after fit was corrected to NDArray[np.int_].
…ement learning Aronow & Samii (2017) direct/spillover/total effects, correctly implemented, but an island from both halves of the library. Its entire coupling is one NotIdentifiableError import; it returns a bare NamedTuple rather than a Certificate, and the project's own audited meta-lesson is that the defensible edge is the decision-and-certificate layer, not the point estimate. Four RL readings were tested and each refuted against code, not opinion: - run_no_regret's empirical joint -- magames/cce.py:63's deviation_gains IS the direct effect, in closed form, with no strata and no positivity requirement. A probe on a 5-agent congestion population, in the case MOST favourable to keeping the module (population_share exactly correct by construction), measured the exact own-action gap at +2.000000 on every profile while the estimator refused 4 of 5 exposure strata -- no-regret dynamics converge and stop visiting them. - LinearGaussianPopulationEnv -- that DGP has no path from ego action to co-player outcome, so spillover_effect estimates zero by construction. - exposure-as-sufficient-statistic -- meanfield.MeanFieldPayoff is already f(own_action, population_share) as a KNOWN function, and magames is small-N by construction. - a SUTVA guard on certify_policy, the strongest reading -- ConfoundedTrajectoryDataset carries no unit index, and grep for entity_id|unit_id|n_units|n_agents across envs/ returns nothing. Building that path means manufacturing a caller for an island. Added earlier in this same unreleased cycle and never shipped, so no released API is affected. Full decision record in the CHANGELOG.
5 tasks
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.
Four commits developed from
29ec740, rebased onto currentmain, plus the integration fixes that rebase required. This is the RL surface the library was missing — real planners, continuous states, and set-valued actions — and it closes an interface dishonesty that a separate library-wide audit found independently.What it adds
Real fitted planners.
FittedQIteration(agents/fitted.py) is DOVI's backward induction with the(H,S,A)table and(S,A,S)transition tensor replaced by one fitted regressor per(step, action). Itsact()reads the step from the observation, computes Q-values and argmaxes with random tie-breaking — it conditions on the observation, and it refuses to act before fitting rather than returning the optimism cap for every action.BoundedFittedQIteration(agents/bounded_fitted.py) recovers the causal bound the fitted backup had to give up, propagating a Manski envelope through the recursion — valid becausemaxand conditional expectation are both monotone.Continuous states.
causalrl.statesuppliesStateEncoder,OneHotEncoder,IdentityEncoder,RBFEncoder,FeatureTransitionand a sharedunpack_transitions/TransitionBatch. The tabular case is contained, not discarded: an indicator basis spans every function on a finite state set, soOneHotEncoderplus a least-squares learner reproduces the tabular backup to ~1e-8, asserted directly against a hand-rolled tabular recursion. A generalisation that does not contain its own special case is not trustworthy, so that property is a test rather than a claim.Function-valued bounds.
FunctionalManskiBounds(bounds/functional.py) iscausal_q_boundswhere feature space has no cells:lower(x,a) = mu·e + r_min·(1−e),upper(x,a) = mu·e + r_max·(1−e), reproducing the tabular function to ~1e-4 with indicator features. Nuisances are cross-fitted, which is load-bearing rather than decorative — an in-sample plug-in makes the interval optimistically tight, the anti-conservative direction and the dangerous one for a bound.OverlapDiagnosticseparates the two regimes that look alike from outside: propensities near zero (wide, honest, uninformative) from propensities a flexible model drove near one (tight, and only as good as that model).Set-valued interventions.
do()has always taken aMappingandpomis()has always returned sets, butAgent.actreturned an arm index — so a multi-variable intervention could be identified and never executed.InterventionSpacestates which variables are manipulable in a context and to which values;assignments()turns an intervention set into arms;InterventionalAgentreturns an intervention;ScalarAgentAdapterlifts the existing arm-indexed agents in unchanged.AdmissibleInterventionsrecomputes POMIS per context, and its test pins a witness graph where restricting the manipulable set is a latent projection, not a filter — unconstrained POMIS is[{C,D}]but the correct answer undermanipulable={D}is[{}, {D}], so a filtering implementation returns nothing and loses optimality.Pinned mechanisms.
PinnedMechanismdeploys a known structural equation whilefit_scmlearns the rest. Mixed models carry a newprovenance="mixed", gated for L3 exactly like"fitted"— a model is only as identified as its weakest node. Pinned nodes are still scored, and that score tests the asserted equation rather than measuring a fit.An honest agent interface.
update()was an empty body in ten subclasses — every*BackdoorAgent,CertifiedPolicyAgent,CounterfactualOptimalPolicy,NaiveOffline, and both fitted agents. These are batch learners whose policy comes fromfit/ingest_offline; a single reward carries nothing they can act on, so the ABC was asserting something untrue of a third of its implementers.BatchAgentsupplies the no-op once and says why.Documentation completeness, enforced. 127 of 253 exported names had no API-reference entry — among them
Agent,CausalMBRLAgentandCausalThompsonSampling, the README's own headline example.mkdocs --strictnever caught this because mkdocstrings validates the references that exist, never the ones that are absent. All 127 are written, andtest_every_export_appears_in_the_api_referencekeeps it closed, resolving through the lazy export map so a renamed export is checked against the attribute it points at.What the rebase required
The branch predates phase 2 (
PoissonGLMFit,BayesianLinearFit, a lean pass), so two conflicts needed semantic resolution rather than a mechanical pick:scm/scm.py— the branch widened the L3 abduction guard toprovenance in ("fitted", "mixed")and reports which provenance in the message; main's lean pass had boundsorted(non_invertible_nodes())once instead of twice. Merged version keeps both. Verified against the built library: a"mixed"model with a non-invertible learned node refuses withwitness=['X']; an all-pinned model is"specified"and abducts normally; a fitted model with three bad nodes yieldswitness=['A','Y','Z'], which actually discriminates the sorted binding.scm/fit.py—_FAMILY_NAMESgainedpoisson_glm/bayesian_linearon main andpinnedon the branch. All three kept;_family_nameverified to return the snake_case name for all seven shipped fitters.Then the branch's own new test caught the integration gap it was written to catch:
PoissonGLMFitandBayesianLinearFitreached__all__after that test existed, so they had no API tier and no:::entry. Fixed by placing them, not by relaxing the test.Pre-existing defects fixed
Surfaced by the gate as pre-existing rather than introduced:
from causalrl.scm import PinnedMechanismraisedImportError— it was the only fitter missing from that lazy export map._provenance([])returned"fitted"— it counted pinned nodes and fell through, so a model with no nodes asserted its equations were learned from data. An empty model has no non-invertible node so the L3 guard is unaffected; this is honest labelling, and this library already deletedcertify_meanfor emittingquery="do"on a computation with no intervention.FunctionalManskiBounds._fit_foldstill annotated its actions arrayFloatArrayafterfitwas corrected toNDArray[np.int_].Verification
ruff checkclean ·ruff format --check353 files ·pyright src0 errors, 0 warnings, 0 informations ·pytest --cov-fail-under=901052 passed, 4 skipped, coverage 97.04% (main: 929 passed, 96.79%).The 4 skips are module-scope
importorskips for extras the dev+data matrix omits (numpyro ×2, jax, stable_baselines3) — identical tomain. CI's py3.11 numpyro lane is the gate for one assertion here: thebayesian_linearfamily name, one of the three thefit.pyconflict kept, is only exercised there.Known boundary
causalrl.interference(Aronow & Samii direct/spillover/total effects) is included but is not reachable fromagents/orenvs/— it is causal inference rather than RL, and a concurrent audit is removing exactly that shape of island. It is flagged for a separate decision rather than quietly kept or quietly dropped.