Skip to content

RL recentering, part 2: causalrl.ope, the real-data suite, and v3.0.0 - #40

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

RL recentering, part 2: causalrl.ope, the real-data suite, and v3.0.0#40
raphaelrrcoelho merged 8 commits into
mainfrom
rl-recentering-2

Conversation

@raphaelrrcoelho

Copy link
Copy Markdown
Owner

The last five conversions from docs/rl_recentering/PLAN.md, and the breaking release they were aimed at. Part 1 (#39) fixed the agents; this fixes everything that pointed at them.

What changed

T4 — dropped the statistics tooling (07cec3c). Every symbol was tested against one question: can anything under agents/ or envs/ reach it? These could not. Gone: certify_mean, certify_quantile, moment_diagnostic, tail_index_hill, weighted_quantile, bootstrap_quantile_ci, MomentDiagnostic, stream_quantile_certificate, backends/quantile_sketch.py, experimental/ope.py, and the whole meanfield/ package — ~900 lines, 262 → 258 exports. certify_mean was emitting EstimandSpec(query="do", …) for a computation containing no intervention. The MSM kernels in the same modules stay; estimate/nuisance.py is kept but demoted to internal, since four modules depend on it.

T2 — the real-data suite goes through the front door (cc182d9). .act() was called nowhere in it, causal_mbrl_coat.py imported no causalrl function at all, and causal_mbrl_obd.py binarised 80 bandit arms to fit a signature. Now: certify_policy over all 80 arms natively, and .act() per unit on Twins/LaLonde/NHEFS feeding the policy that gets valued and certified.

This also found a bug in published output. Coat's hand-rolled sensitivity loop rescaled every propensity by the same Γ without self-normalising — not Tan's MSM, which lets each unit's odds move independently within [1/Γ, Γ] and takes the sharp extremum. The correct bands are tighter: Γ=1.3 was reported as [1.86, 3.15], is [2.07, 2.61]. REAL_DATA.md is corrected with the reason recorded, not silently overwritten.

T5 — the decision front door names actions and rewards (8ed0400). certify_decision(outcomes=, treated=)(rewards=, actions=); an 80-arm bandit no longer comes back with a "prefer treated" verdict. Plus estimate_sequential_value(actions=, reward=), policy_contrast_from_dowhy, policy_from_econml_cate, certify_transported_policy_value. No numerics changed.

T3 — causalrl.ope (2c863e1). The OPE surface was scattered across five packages. Now one home: ipw.py, sequential.py, bounds.py, certify.py. identification/bounds.py drops 587 → 305 lines and is finally what its name says. All 258 top-level exports resolve unchanged.

Two import cycles had to be designed around, both of which only fail on some import orders. identification/decision.py imports msm_contribution_bounds, so importing any ope submodule ran ope/__init__ope.certifyidentification.decision → back into a half-initialised ope; a second ran through estimate/__init__'s re-export of stream_policy_value. Fixed with the lazy __getattr__ pattern the top-level __init__ already uses. All fourteen entry-point modules verified importing first from a cold cache.

T8 — the docs say what the library is (2f74294). Deliberately last; the plan's own note is "worthless before Tasks 1-2 make it true." One description sentence replaces three different ones across pyproject.toml, README.md, mkdocs.yml, docs/index.md, CITATION.cff and the package docstring. docs/api.md leads with agents and environments instead of burying them at slots 14-16 of 28. A sixth guide — the first that trains an agent — runs OnlineCausalMBRL from a 3,000-row confounded log through its own randomized experiments: the I-MEC collapses 6 DAGs → 1 and regret goes 0.220 → 0.000 in ~0.3s.

Release 3.0.0 (9a72071) and a test fix (b653177).

The results cut against us, and the README now says so

Scored as an RL practitioner would — .act() per unit, then regret against ground truth — the causal point estimates lose:

dataset our policy verdict
Twins (11,984 pairs, both potential outcomes known) 0.8316 vs oracle 0.8747 — regret 0.0431, worst of the learned policies, behind the trivial "always the heavier twin" abstain, Γ≈1.07
LaLonde (priced by the NSW experiment) enrols 73.5% for $476/person, where its own marginal rule leaves $0; off-policy value −$453 has the wrong sign abstain, Γ≈1.10, conformal gate returns no finite bound
NHEFS contextual policy is degenerate at 100% abstain, Γ≈1.42

Both abstentions are right, and on LaLonde the randomized experiment vindicates the refusal. This is the recorded finding and it is now the README's positioning: the defensible edge is the decision and certificate layer, not the number.

What I could not verify

The claim that these layers run on real data at production scale was cut. The datasets here are real but modest — LaLonde n=614, Twins n=11,984, a 10k slice of OBD — and the only scale evidence anywhere is a synthetic 600k-row log. The README says "run on real data" and stops there.

mkdocs build --strict is not runnable in this venv, so I ran it in an ephemeral overlay: builds clean in 20.6s.

Gate

ruff clean, pyright 0/0/0, coverage 97.3%, mkdocs --strict clean, all six guides and the columnar-sim example run, and causalrl-3.0.0-py3-none-any.whl verified from a throwaway install — 3.0.0, 258 exports, causalrl.ope intact. The only ruff hits are 14 E501s in the untracked examples/causal_corr2cause_prompted.py, which belongs to another project and does not exist on CI.

The library-wide audit tested every symbol against one question: can anything
under `agents/` or `envs/` reach it? These could not, and no RL concept is an
instance of them.

Removed, with migration notes in the CHANGELOG:

- `bounds/continuous.py`: `certify_mean`, `certify_quantile`, `moment_diagnostic`,
  `tail_index_hill`, `weighted_quantile`, `bootstrap_quantile_ci`,
  `MomentDiagnostic`. `certify_mean` emitted `EstimandSpec(query="do", ...)` for a
  computation containing no intervention at all -- misleading provenance on a
  plain sample statistic. The marginal-sensitivity-model kernels in the same
  module, `msm_sensitivity_bounds` and `certify_sensitivity_bounds`, stay.
- `estimate/streaming.py`: `stream_quantile_certificate`, whose own docstring said
  "not a causal effect". `stream_policy_value` stays.
- `backends/quantile_sketch.py`: existed only to back the above.
- `experimental/ope.py`: 19 lines, explicitly not the published MSM bound, and its
  own test asserted it was not public.
- `meanfield/`: "Evaluation-only (no learning)" by its own docstring, zero
  consumers outside its test.

`estimate/nuisance.py` is kept but demoted to internal: `agents/fitted.py`,
`agents/bounded_fitted.py` and both `bounds/` modules depend on it, so it stays
importable while leaving `causalrl.estimate`'s re-export surface.

258 exports, down from 262.
`.act()` was called nowhere in the real-data suite, one script imported no
causalrl function at all, and another mangled an 80-arm bandit into a binary
treatment to fit a signature. Now that `act()` returns a policy instead of a
constant, all five go through the library's own surface.

- obd: drops the 80-arm -> binary collapse. `certify_policy` over a
  `ConfoundedTrajectoryDataset` with 80 actions native; the certified contrast is
  now the two on-policy numbers the script already printed (+0.02252), not an
  ad-hoc proxy. SNIPS comes from `msm_policy_value_bounds` and agrees with the
  hand-rolled Hajek value to 1e-15.
- coat: the hand-rolled sensitivity loop is replaced by `msm_policy_value_bounds`
  and `msm_contribution_bounds`. The old block rescaled every propensity by the
  same Gamma without self-normalising, which is not Tan's MSM -- that lets each
  unit's odds move independently within [1/G, G] and takes the sharp extremum
  over that set. The correct bands are tighter: Gamma=1.3 was reported as
  [1.86, 3.15], is [2.07, 2.61]. Point estimates unchanged.
- twins, lalonde, nhefs: `.act()` per unit builds the policy, and that policy --
  not an effect size -- is what gets valued and certified.

The results cut against the point-estimate layer, as the recorded finding says
they should. On twins our policy scores 0.8316, the worst of the learned
policies and behind the trivial "always the heavier twin". On lalonde it enrols
73.5% for $476/person of regret where its own marginal rule leaves $0, and its
off-policy value has the wrong sign. On nhefs the contextual policy is
degenerate, flagging 100%. All three abstain.

REAL_DATA.md's Coat band is corrected with the reason, not silently overwritten.
The docstrings already said the RL thing -- `certify_decision`'s says "`outcomes`
are logged rewards" and "the off-policy (IPS) value contrast" -- and only the
parameter names disagreed. An 80-arm bandit log came back with a "prefer treated"
verdict.

- `certify_decision(outcomes=, treated=)` -> `(rewards=, actions=)`.
- `DecisionCertificate.decision` says "prefer action 1" / "prefer action 0"
  instead of "prefer treated" / "prefer control".
- `estimate_sequential_value` / `certify_sequential_value`
  `(treatments=, outcome=)` -> `(actions=, reward=)`, followed through every
  internal helper so the module is consistent rather than renamed at the seam.
- `interop.dowhy.from_dowhy_estimate` -> `policy_contrast_from_dowhy`;
  `interop.econml.from_econml_cate` -> `policy_from_econml_cate`.
- `transport.estimate.certify_sequential_transport` ->
  `certify_transported_policy_value`. It already emitted
  `EstimandSpec(query="policy_value")`, so the new name is the true one.

No numerics, defaults, or control flow changed. 258 exports, unchanged.
Migration entries per rename are in the CHANGELOG.
The OPE surface was scattered across five packages with no single place to
import it from. It now has one home. This became honest only once `act()`
returned a real policy and `agents/primitives.py` and `agents/offline_online.py`
started calling `causal_q_bounds` -- before that, consolidating would have been
naming without a caller.

- `ope/ipw.py`: `ipw_value` (was `eval/ope.py`) and `stream_policy_value` (was
  `estimate/streaming.py`).
- `ope/sequential.py`: all of `estimate/sequential.py`, byte-identical.
- `ope/bounds.py`: the IPW/MSM off-policy *value* bounds lifted out of
  `identification/bounds.py` -- `causal_q_bounds`, `ipw_sensitivity_bounds`,
  `msm_policy_value_bounds`, `msm_contribution_bounds`, `msm_per_step_bounds`,
  `msm_stratified_bounds`.
- `ope/certify.py`: `certify_policy` (was `scale/__init__.py`, which keeps the
  re-export so the train-elsewhere-certify-here story still reads).

`identification/bounds.py` drops 587 to 305 lines and is now what its name says:
identification and sign-robustness -- `Interval`, `manski_bounds`,
`pivotality_certificate`, `mi_flip_threshold`, `tipping_gamma`. `tipping_gamma`
takes a callable, so it did not follow the bounds out.

No public name changed: all 258 top-level exports resolve exactly as before, and
`causalrl.bounds`, `causalrl.estimate` and `causalrl.scale` keep their
re-exports.

`ope/__init__.py` uses the lazy `__getattr__` pattern the top-level `__init__`
already uses, because eager submodule imports deadlock:
`identification/decision.py` imports `msm_contribution_bounds`, so importing any
`ope` submodule runs `ope/__init__` -> `ope.certify` -> `identification.decision`
-> back into a half-initialised `ope`. A second cycle ran through
`estimate/__init__`'s re-export of `stream_policy_value`; that shim is lazy for
the same reason. All fourteen entry-point modules import cleanly from cold.
Left until last on purpose. The plan's own note on this task: "Worthless before
Tasks 1-2 make it true. Do this last." Reframing the README ahead of the code
would have been the same deviation the recentering exists to fix, better
disguised.

- One description, not three. `pyproject.toml`'s sentence -- "Causal
  reinforcement learning: the 9-task causal RL taxonomy, made runnable" -- now
  also stands in `README.md`, `mkdocs.yml`, `docs/index.md`, `CITATION.cff` and
  the package docstring, which each carried a different, superseded claim.
- The README no longer disclaims RL outright ("learning agents are
  tabular/demo-scale, not production RL"). It splits the claim where the split
  actually is: the planners and environments are demo-scale; the decision,
  certificate and OPE layers run on real data, with the five real-data scripts
  named as the evidence.
- "How it compares" gains the RL half of the map -- where this sits against
  offline-RL libraries, and `scale/d3rlpy.py` as the bridge -- alongside the
  causal-inference comparisons it already had.
- A new section reports the negative result rather than burying it: on Twins our
  policy is the worst learned policy at regret 0.0431, behind the trivial
  constant; on LaLonde it costs $476/person against its own marginal rule and its
  off-policy value has the wrong sign. Both abstain, and on LaLonde the
  randomized experiment vindicates the refusal.
- `docs/api.md` leads with agents, environments, the Gymnasium wrapper and
  CGFA-PPO instead of burying them at slots 14-16 of 28. All 258 directives
  preserved and unique.
- `docs/guides.md` gains a sixth guide -- the first that trains an agent.
  `examples/guides/06_learn_the_scm_while_acting.py` runs `OnlineCausalMBRL` from
  a 3,000-row confounded log through its own randomized experiments: the I-MEC
  collapses from 6 DAGs to 1 and regret goes 0.220 to 0.000 in ~0.3s. It also
  prints that the most informative probe available is one the agent cannot run,
  because nothing it controls sets the confounder.
- `docs/tour.md` opens with the library rather than with a comparison table of
  what it is not.

No RL classifier was invented: PyPI's trove list has no reinforcement-learning
entry, so only the registered `Topic :: Scientific/Engineering :: Information
Analysis` was added, with `offline-rl` and `off-policy-evaluation` as keywords.
The claim that these layers run at production *scale* was cut, because nothing
in the tree demonstrates it -- the real datasets here are real but modest.
The breaking release the RL recentering was aimed at. `[Unreleased]` becomes
`[3.0.0]`, and the two differently-cased `Changed (BREAKING)` sections that had
accumulated are merged into one.

CITATION.cff still claimed 1.0.0 from 2026-06-08, five releases stale; it now
tracks the package.

Verified from a built wheel rather than the editable install, whose metadata is
stale locally: causalrl-3.0.0-py3-none-any.whl reports Version 3.0.0, the
reconciled summary, 258 exports and an intact causalrl.ope.
`test_version_is_stamped` compared `causalrl.__version__` against
`pyproject.toml`. But `__version__` reads the *installed* distribution's
metadata, so the assertion measured how recently the environment was installed
rather than anything about this source tree: it fails on any editable checkout
where the version was bumped after the last install, and passes automatically in
CI, where the install is always fresh. It failed on this branch for exactly that
reason while a built wheel reported the right version.

It now pins what is actually worth pinning, and catches four defects the old
assertion could not. Each was mutated and confirmed to fail:

- pyproject's version drifting from the newest CHANGELOG release,
- CITATION.cff drifting from either,
- `__version__` written as a string literal instead of derived from metadata,
- the CHANGELOG losing its released section.

Two of those are not hypothetical. CITATION.cff had been stuck at 1.0.0 since
2026-06-08, five releases stale, and cutting `[Unreleased]` into a numbered
release was a manual step nothing enforced.
Both Windows jobs failed on PR #40 with `UnicodeDecodeError: 'charmap' codec
can't decode byte 0x81`. `Path.read_text()` without an encoding uses the locale
encoding, which is cp1252 on Windows, and CHANGELOG.md is full of `Gamma`, em
dashes and arrows.

The four calls I added in the previous commit caused it, but the four that were
already there -- on pyproject.toml, docs/api.md and __init__.py -- only worked
because those files happen to be ASCII today. One non-ASCII character in an API
docstring would have broken the same jobs later, further from the cause. All
eight now pass `encoding="utf-8"`.

Reproduced on Linux with `LC_ALL=C PYTHONUTF8=0`, which gives the same class of
failure ('ascii' codec, byte 0xe2), and both test files pass under that locale
after the fix.
@raphaelrrcoelho
raphaelrrcoelho merged commit 7553a1f into main Aug 7, 2026
13 checks passed
@raphaelrrcoelho
raphaelrrcoelho deleted the rl-recentering-2 branch August 7, 2026 20:53
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