Skip to content

RL recentering: act() as a policy, the conformal gate, the CGFA critic, and a no-regret learner - #39

Merged
raphaelrrcoelho merged 7 commits into
mainfrom
rl-recentering
Aug 7, 2026
Merged

RL recentering: act() as a policy, the conformal gate, the CGFA critic, and a no-regret learner#39
raphaelrrcoelho merged 7 commits into
mainfrom
rl-recentering

Conversation

@raphaelrrcoelho

Copy link
Copy Markdown
Owner

Four conversions from the library-wide RL-recentering audit. Each had to pass the audit's own test — can something under agents/ or envs/ actually reach it? — demonstrated by a test that exercises the path, not by an import that merely exists.

act() returns a policy, not a constant

The audit's worst finding was inside the RL half: five of six back-door planners' act() returned a constant and discarded the observationreturn int(self._best_action), identical at five sites. The parameter existed only to satisfy the ABC.

Meanwhile interop/econml.py:34 already did tau = 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 for FunctionApproxBackdoorAgent, and the per-action T-learner at the observed covariate row for GFormulaBackdoorAgent — where act is asserted row-by-row to equal sign(cate), the per-unit decision the T-learner already estimated and threw away.

Two tests say something the library could not say before:

  • 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;
  • on TransportableConfoundedBandit the transported marginal says "never arm 1" in every phase-diagram cell, yet act({"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 gate

conformal/ was orphaned statistics: nothing under agents/ or envs/ could reach it. But conformal_quantile(weights=…) already accepted likelihood ratios dP_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 new DecisionCertificate.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's query parameter is removed; it always emits query="see". It previously accepted query="counterfactual" — a string label with no counterfactual mathematics behind it.

The CGFA-PPO K-head critic

factored_advantage.py was named for CGFA-PPO (Cunha, Mian, French & Liu 2026, arXiv:2605.06066) but the entire computation was V_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: FactoredCritic with 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-NumPy factor_rewards / factor_gae / blend_advantages stay 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:

  1. 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.
  2. The gate and mixture logits have no gradient path under Algorithm 1 as written: line 12 computes A_used outside the epoch loop and Table 6 sets c_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 learner

magames/ 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.py hand-rolls a Hedge population purely to feed cce_regret.

Adds RegretMatching (Hart & Mas-Colell 2000) and MultiplicativeWeights (Freund & Schapire 1997) as real Agent subclasses, and run_no_regret(...) producing the empirical joint that cce_regret / certify_cce_do already 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.

PopulationAgentViewLinearGaussianPopulationEnv (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 under agents/ imports magames, so on the audit's forward definition it remains unreached. Re-exporting it from causalrl.agents to 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.md entry (the docs had the functions, not the classes the lazy map resolves to). And PopulationAgentView / agent_causal_env_view were still listed in API_TIERS and api.md after the rename — the test working in the direction it was not written for: not a missing export, a stale one.

Verification

ruff check clean · ruff format --check 360 files · pyright src 0 errors, 0 warnings, 0 informations · pytest --cov-fail-under=90 exit 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.

…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
…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.
@raphaelrrcoelho
raphaelrrcoelho merged commit 13f0522 into main Aug 7, 2026
13 checks passed
@raphaelrrcoelho
raphaelrrcoelho deleted the rl-recentering branch August 7, 2026 16:55
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