RL recentering: act() as a policy, the conformal gate, the CGFA critic, and a no-regret learner - #39
Merged
Merged
Conversation
…onfidence-bound gate conformal/ was 177 orphan lines: nothing under agents/ or envs/ could reach it, and it was plain split conformal on an outcome -- a statistics utility inside an RL library. It was one step from being RL. conformal_quantile(weights=...) already accepted the likelihood ratio dP_test/dP_cal, and for off-policy evaluation that ratio is exactly pi_target/pi_behavior; what was missing was a caller that computes it and a path from an agent to it. conformal_action_value(dataset, target_actions, alpha) reads the propensity ratio off a ConfoundedTrajectoryDataset -- the agent half's hub type -- from the same per-transition target_actions certify_policy already takes (None scores the logging policy, which needs no reweighting), and feeds the existing weighted path. Deliberate choices, each of them about what the number is licensed to claim: - What is certified is the return of ONE decision under the policy, not V(pi) = E[return]. Conformal gives individual-outcome coverage; a mean confidence interval is different mathematics. value is None, the estimand is (query=policy_value, target=quantile), and the claim string ends "a fresh return, not E[return]". - Scores are the returns themselves (negated for the lower end), not residuals of a fitted predictor: centring on a value estimate fitted from the same rows would break the split-conformal requirement that the predictor not see the calibration fold. With no predictor there is nothing to violate. alpha/2 per end, union bound for the two-sided level. - Weighted conformal needs the likelihood ratio AT THE TEST POINT, which for a deterministic policy is 1/e0(s, pi(s)) and varies by state. Rather than the calibration mean (the conformal_quantile default, not valid here), bound it by the largest ratio the policy can produce on the logged state marginal: a larger test weight only widens the quantile, so the band is conservative-but-valid uniformly over test states instead of average-case. - A (state, action) the policy reaches that the logs never played makes that bound infinite: the band comes back vacuous and hedged, not a finite number computed from absent rows. - Assumptions recorded: weighted exchangeability (with the effective sample size), no-unmeasured-confounding (without it the ratio is not dP_target/dP_behavior), positivity. certify_policy(..., alpha=...) gates certified on lcb(pi) >= lcb(behaviour) and reports the bound in the new DecisionCertificate.conformal_lcb. An uninformative -inf bound refuses rather than passes: no evidence is not evidence of safety. alpha=None reproduces the previous behaviour exactly, so the M0 results are untouched. CertifiedPolicyAgent(..., alpha=...) threads it through -- the agent-side path into conformal/. It is opt-in because turning it on by default would silently change published M0 results and would make the agent abstain on every log too small to calibrate; the path is proven live, not merely importable, by a test in which the ungated agent ships action 1 on a log and the gated agent ships action 0. examples/guides/01 (CI-executed) calls it too. certify_conformal_interval loses its `query` parameter and always emits query="see" (BREAKING, migration in CHANGELOG). The "counterfactual" default was a string label with no counterfactual mathematics behind it: residuals of a fitted prediction carry no intervention, and `weights` only move the observational law to a shifted one. Keeping the parameter would have preserved the fabrication vector, so the label is now fixed by the mathematics that produced it; the causal label lives with the causal assumptions, in conformal_action_value. The module docstring's matching prose claim went with it. as_certificate now reports downside-not-certified rather than not-robust-to-confounding when the finite-sample gate is what refused -- otherwise the adapter emits a false reason, the same species of bug. Every new test was mutation-checked. Ignoring the weights (an on-policy interval silently labelled off-policy -- the failure this guards) fails 7 tests, including the two directional ones: under "always take the paying action" the band is [1.0, 1.0] against the logged mixture's [0.0, 1.0], and under "always take the loser" it is [0.0, 0.0] with the upper end pulled down. Forcing the gate to pass, restoring query="counterfactual", and forcing the adapter's hedge reason each fail exactly their own named tests (5, predicted before running).
… CGFA-PPO
`factored_advantage.py` was named for CGFA-PPO (Cunha, Mian, French & Liu 2026,
arXiv:2605.06066) but the whole computation was `V_i - baseline` plus a matvec;
`examples/cgfa_ppo_example.py` conceded "in a full CGFA-PPO, you'd have K value
heads; here we use the same value". Four of the algorithm's five parts existed
nowhere. Implemented from the paper -- no reference code consulted or ported.
New, pure NumPy, still with no RL-framework dependency (a subprocess test pins
that importing the module never pulls in torch):
* `factor_rewards` -- r_k,t = phi_k(s_{t+1}) - phi_k(s_t) (S E.1)
* `factor_gae` -- G_k (Eq. 8) and A_k = G_k - V_k(s_t) (Eq. 10)
* `blend_advantages`-- A_used = (1-g) A_scalar + g sum_k w_k A_k (Eq. 11)
New, behind the existing [torch] extra (`agents/cgfa_critic.py`, lazily imported
so the module stays importable and `causalrl.FactoredCritic` stays resolvable
without torch; construction raises an ImportError naming the extra):
* `FactoredCritic` -- K value heads and the scalar critic on one trunk (S E.1),
learnable mixture logits w = softmax(beta), state-conditional residual gate
g(s), the per-factor MSE (Eq. 9), the intervention-calibration loss against
the SCM-predicted effect (Eq. 12), and Eq. 13 assembly that accepts the RL
framework's surrogate so actor and critic step jointly (Alg. 1 line 22).
* `CGFACriticConfig` / `CGFALosses` / `CGFAAdvantages` / `CGFAUpdateStats`,
with Table 6's coefficients as defaults.
`factored_advantage()` and `FactoredAdvantageConfig` are unchanged in behaviour;
only their docstrings are corrected, since the old one claimed to implement a
critic target it does not (its shared scalar baseline cannot express Eq. 10).
The tests pin head DIFFERENTIATION, not liveness: on data where the factors
depend on different observation coordinates each head fits its own return >20x
better than its neighbour's, and swapping the target columns inflates the
per-factor residual >10x. Both ratios are pinned near 1 by construction when the
heads are tied, which is the defect being fixed. Every behavioural test was
verified by mutating the behaviour it names and confirming failure.
Documented paper ambiguities (also in the docstrings):
* S E.2 stores the per-factor advantages, which would leave Eq. 12 with no
gradient and make its own ablation a no-op; A_k is recomputed under the
current parameters instead.
* Alg. 1 line 12 computes A_used outside the epoch loop, leaving g(s) and beta
-- the two parameters the paper calls learnable -- without a gradient path at
the Table 6 default c_e = 0. `blend()` supplies the differentiable reading;
`advantages()` keeps the literal one.
* Eq. 8/10 are Monte-Carlo but the prose says GAE truncation; they coincide
only at lambda = 1, which is the default.
* The S E.4 standard-deviation clamp and Eq. 12's delta are unspecified;
both are exposed on the config.
magames/ computed the quantity a no-regret population drives to zero and shipped no such population: cce_regret's own docstring says "it is what a no-regret population drives to 0" and nothing in src/ drove it. The learner had already been written outside the library — experiments/eqcf/common.py hand-rolls a Hedge population purely to feed cce_regret — so this is a missing library function, not a mislabelled package. - agents/no_regret.py: NoRegretLearner / RegretMatching / MultiplicativeWeights, all causalrl.agents.base.Agent subclasses. Implementations of published algorithms, cited: regret matching on external regrets (Hart & Mas-Colell, Econometrica 2000, via Blackwell approachability) and Hedge (Freund & Schapire, JCSS 1997) with the theory rate by default. observe(payoffs) is the full-information update; Agent.update(obs, action, reward) is the bandit case via inverse-propensity weighting (EXP3, Auer et al. 2002). - magames/learning.py: run_no_regret(population, rounds, do=..., ...) plays the game and returns the realized empirical joint in exactly the format the certificate layer already accepts — NoRegretRun.weights is aligned with cce_polytope(...).profiles and NoRegretRun.empirical_joint is the mapping form, both fed straight to cce_regret; run.regret is the measured epsilon for certify_cce_do's finite-time route. regret_trace records the fall. - First import edge between magames/ and agents/. Convergence is pinned, not asserted: the null everywhere is a payoff-blind population (explore=1.0 runs the same loop with the payoffs disconnected). On the dominant-strategy game the blind regret is 0.75 and the learners reach 0.000 / 0.029; on an asymmetric matching-pennies with no pure equilibrium, regret falls from 0.25-0.85 at 20 rounds to ~0.01 at 5000. Mutating both update rules to ignore the payoffs fails 13 of the 23 tests, including every convergence test for both algorithms. BREAKING: PopulationAgentView -> LinearGaussianPopulationEnv and agent_causal_env_view -> linear_gaussian_population_env. The class is a hand-written linear-Gaussian DGP that never updates from experience; "agent" was a variable name in it. Migration in CHANGELOG.md.
…bservation
Five of the six back-door planners in agents/mbrl.py returned int(self._best_action)
and discarded the observation entirely -- the argmax of E[Y|do(a)] fixed at fit time,
with the parameter present only to satisfy the ABC. Each now caches its fitted
interventional outcome model and reads it AT the observation: the tabular agents look
the action values up in the observed back-door stratum, FunctionApproxBackdoorAgent
evaluates qhat(a, z) at the observed confounder, and GFormulaBackdoorAgent evaluates its
per-action T-learner at the observed covariate row -- so its act() is exactly the sign of
the cate() it already computed twenty lines earlier and threw away. That is the
CATE-to-policy conversion interop/econml.py:34 already performed for a third-party
estimator and not for ourselves.
_action_predictions took the training arrays as parameters and so refit on every call,
which is precisely why act() could not use it. Fitting now lives in _fit_action_models
and yields a _RidgeOutcomeModel carrying its standardization statistics, so a fitted
model can score an unseen covariate row rather than only its training sample. cate()
keeps its self-contained semantics.
An observation carrying none of the conditioning variables still returns the marginal
argmax -- the correct decision when no context is observed, and what every existing
caller passing {"state": 0} already gets. A PARTIAL set raises rather than silently
marginalizing away a covariate the model conditions on. An empty adjustment set is
documented as a necessarily constant decision instead of being disguised as a policy.
Discriminating tests, one per converted agent: two observations whose conditional
contrast has opposite signs must get different actions. On ContinuousConfoundedBandit the
best constant action is arm 0 (E[Y|do(1)] ~ 0.38 < 0.5), yet act({"Z": 0.85}) plays arm 1
inside the reward bump where it truly wins; on TransportableConfoundedBandit the
transported marginal says "never arm 1" in every cell, yet act({"Z": 1, "W": 0}) plays
it. Reverting all five bodies to `return int(self._best_action)` fails exactly those
five tests.
# Conflicts: # CHANGELOG.md # src/causalrl/__init__.py # src/causalrl/agents/mbrl.py
# Conflicts: # CHANGELOG.md
…d stale ones The docs-completeness and tier-partition tests caught three things the four conversions left behind: - 16 new exports (FactoredCritic and the CGFA config/stat types, conformal_action_value, run_no_regret and the no-regret learners, LinearGaussianPopulationEnv, the factored-advantage helpers) had no API tier. All belong to the decision layer, beside certify_policy. - Four names had no api.md entry: the docs had the FUNCTIONS (run_no_regret, linear_gaussian_population_env) but not the classes the lazy export map resolves to. - PopulationAgentView and agent_causal_env_view were STILL listed in API_TIERS and api.md after T7 renamed them. A magames-local rename left the public registry pointing at symbols that no longer exist. That last one is the test working in the direction it was not written for: not a missing export, a stale one.
This was referenced Aug 7, 2026
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 conversions from the library-wide RL-recentering audit. Each had to pass the audit's own test — can something under
agents/orenvs/actually reach it? — demonstrated by a test that exercises the path, not by an import that merely exists.act()returns a policy, not a constantThe audit's worst finding was inside the RL half: five of six back-door planners'
act()returned a constant and discarded the observation —return int(self._best_action), identical at five sites. The parameter existed only to satisfy the ABC.Meanwhile
interop/econml.py:34already didtau = effect(X); pi = (tau > 0)for a third-party CATE model. The library performed CATE→policy for EconML and not for itself.All five now read their own fitted interventional outcome model at the observation: tabular stratum lookup for
BackdoorAdjustedAgent/DiscoveryBackdoorAgent/TransportBackdoorAgent,qhat(a, z)at the observed confounder forFunctionApproxBackdoorAgent, and the per-action T-learner at the observed covariate row forGFormulaBackdoorAgent— whereactis asserted row-by-row to equalsign(cate), the per-unit decision the T-learner already estimated and threw away.Two tests say something the library could not say before:
ContinuousConfoundedBanditthe best constant action is arm 0 (E[Y|do(1)] ≈ 0.38 < 0.5), yetact({"Z": 0.85})plays arm 1 inside the reward bump;TransportableConfoundedBanditthe transported marginal says "never arm 1" in every phase-diagram cell, yetact({"Z": 1, "W": 0})plays it.An observation carrying none of the conditioning variables returns the marginal argmax (correct, backwards compatible); a partial one raises rather than silently answering a different query.
conformal_action_value— the conformal layer becomes a safety gateconformal/was orphaned statistics: nothing underagents/orenvs/could reach it. Butconformal_quantile(weights=…)already accepted likelihood ratiosdP_test/dP_cal, and for off-policy that ratio isπ_target/π_behavior.The path now runs
CertifiedPolicyAgent(alpha=0.1).ingest_offline()→certify_policy(alpha=…)→conformal_action_value(...)→ the existing weighted path, reported in the newDecisionCertificate.conformal_lcb. Proven live rather than merely importable: on a log where action 1 pays 1.5 with probability 0.95 and −5.0 otherwise, its mean is higher (1.175 vs 0.5) so the ungated agent ships it — and the calibrated downside gate picks up the tail the mean hides, so the gated agent abstains to action 0.Stated plainly in the docs: this bounds a quantile, not
V(π). HCOPE-style safe policy improvement bounds the mean, which split conformal cannot.Honesty fix:
certify_conformal_interval'squeryparameter is removed; it always emitsquery="see". It previously acceptedquery="counterfactual"— a string label with no counterfactual mathematics behind it.The CGFA-PPO K-head critic
factored_advantage.pywas named for CGFA-PPO (Cunha, Mian, French & Liu 2026, arXiv:2605.06066) but the entire computation wasV_i − baseline— a subtraction. The example itself conceded "in a full CGFA-PPO, you'd have K value heads; here we use the same value."Implemented from the paper, no code ported:
FactoredCriticwith one value head per SCM parent of the return on a trunk shared with the scalar critic, each regressed on its own per-factor return (Eq. 8–9); learnable mixture logits; a state-conditional residual gate (Eq. 11); and the intervention-calibration loss (Eq. 12). Pure-NumPyfactor_rewards/factor_gae/blend_advantagesstay torch-free; the critic sits behind the existing[torch]extra.Tests pin head differentiation — each head fits its own target >20× better than its neighbour's, and swapping target columns inflates the residual >10×. Both ratios collapse to 1 under a shared value head, which is the defect being fixed.
Two holes found in the paper, both documented as deviations rather than silently patched:
L_cal's advantages are read from the rollout buffer — detached — so Eq. 12 would be a pure diagnostic and the paper's own ablation of it a no-op. Recomputed under current parameters instead.A_usedoutside the epoch loop and Table 6 setsc_e = 0, zeroing the only other term — so the two parameters the paper calls learnable never move. Both readings ship, with opposing tests.run_no_regret— the multi-agent vocabulary gets a learnermagames/was 874 lines of RL vocabulary with no learner:cce_regret's own docstring says "it is what a no-regret population drives to 0", and the package contained no such population. The evidence that this was a missing function rather than a mislabelled package:experiments/eqcf/common.pyhand-rolls a Hedge population purely to feedcce_regret.Adds
RegretMatching(Hart & Mas-Colell 2000) andMultiplicativeWeights(Freund & Schapire 1997) as realAgentsubclasses, andrun_no_regret(...)producing the empirical joint thatcce_regret/certify_cce_doalready accept. Convergence is pinned against a payoff-blind null available as a first-class knob: blind regret 0.75 versus 0.000 / 0.029 for the learners; zeroing both update bodies fails 13 of 23 tests.PopulationAgentView→LinearGaussianPopulationEnv(it was a linear-Gaussian DGP, not an agent).Honest boundary: the new edge runs
magames/ → agents/, so the halves touch through real code — but nothing underagents/importsmagames, so on the audit's forward definition it remains unreached. Re-exporting it fromcausalrl.agentsto fake an edge would need an import cycle and would be the disguised conversion this programme exists to eliminate.Registry drift the enforcement tests caught
Sixteen new exports had no API tier; four had no
api.mdentry (the docs had the functions, not the classes the lazy map resolves to). AndPopulationAgentView/agent_causal_env_viewwere still listed inAPI_TIERSandapi.mdafter the rename — the test working in the direction it was not written for: not a missing export, a stale one.Verification
ruff checkclean ·ruff format --check360 files ·pyright src0 errors, 0 warnings, 0 informations ·pytest --cov-fail-under=90exit 0, coverage 97.33%.Not included
T2 (real-data examples calling the RL front door), T3 (
causalrl.ope), T4 (deleting the statistics tooling), T5 (parameter renames) and T8 (docs framing) remain. T8 is deliberately last: reframing the README before the code is true would be the same deviation, better disguised.