Skip to content

Fitted planners, continuous states, and set-valued interventions - #37

Merged
raphaelrrcoelho merged 8 commits into
mainfrom
kaggle-integration
Aug 7, 2026
Merged

Fitted planners, continuous states, and set-valued interventions#37
raphaelrrcoelho merged 8 commits into
mainfrom
kaggle-integration

Conversation

@raphaelrrcoelho

Copy link
Copy Markdown
Owner

Four commits developed from 29ec740, rebased onto current main, 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). Its act() 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 because max and conditional expectation are both monotone.

Continuous states. causalrl.state supplies StateEncoder, OneHotEncoder, IdentityEncoder, RBFEncoder, FeatureTransition and a shared unpack_transitions/TransitionBatch. 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, 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) is causal_q_bounds where 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. OverlapDiagnostic separates 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 a Mapping and pomis() has always returned 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 context and to which values; assignments() turns an intervention set into arms; InterventionalAgent returns an intervention; ScalarAgentAdapter lifts the existing arm-indexed agents in unchanged. AdmissibleInterventions recomputes 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 under manipulable={D} is [{}, {D}], so a filtering implementation returns nothing and loses optimality.

Pinned mechanisms. PinnedMechanism deploys a known structural equation while fit_scm learns the rest. Mixed models carry a new provenance="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 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.

Documentation completeness, enforced. 127 of 253 exported names had no API-reference entry — among them Agent, CausalMBRLAgent and CausalThompsonSampling, 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 written, and test_every_export_appears_in_the_api_reference keeps 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 to provenance in ("fitted", "mixed") and reports which provenance in the message; main's lean pass had bound sorted(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 with witness=['X']; an all-pinned model is "specified" and abducts normally; a fitted model with three bad nodes yields witness=['A','Y','Z'], which actually discriminates the sorted binding.
  • scm/fit.py_FAMILY_NAMES gained poisson_glm/bayesian_linear on main and pinned on the branch. All three kept; _family_name verified 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: PoissonGLMFit and BayesianLinearFit reached __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 PinnedMechanism raised ImportError — 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 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_].

Verification

ruff check clean · ruff format --check 353 files · pyright src 0 errors, 0 warnings, 0 informations · pytest --cov-fail-under=90 1052 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 to main. CI's py3.11 numpyro lane is the gate for one assertion here: the bayesian_linear family name, one of the three the fit.py conflict kept, is only exercised there.

Known boundary

causalrl.interference (Aronow & Samii direct/spillover/total effects) is included but is not reachable from agents/ or envs/ — 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.

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.
@raphaelrrcoelho
raphaelrrcoelho merged commit 3b142c1 into main Aug 7, 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