RL recentering, part 2: causalrl.ope, the real-data suite, and v3.0.0 - #40
Merged
Conversation
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.
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.
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 underagents/orenvs/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 wholemeanfield/package — ~900 lines, 262 → 258 exports.certify_meanwas emittingEstimandSpec(query="do", …)for a computation containing no intervention. The MSM kernels in the same modules stay;estimate/nuisance.pyis 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.pyimported no causalrl function at all, andcausal_mbrl_obd.pybinarised 80 bandit arms to fit a signature. Now:certify_policyover 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.mdis 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. Plusestimate_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.pydrops 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.pyimportsmsm_contribution_bounds, so importing anyopesubmodule ranope/__init__→ope.certify→identification.decision→ back into a half-initialisedope; a second ran throughestimate/__init__'s re-export ofstream_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 acrosspyproject.toml,README.md,mkdocs.yml,docs/index.md,CITATION.cffand the package docstring.docs/api.mdleads with agents and environments instead of burying them at slots 14-16 of 28. A sixth guide — the first that trains an agent — runsOnlineCausalMBRLfrom 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: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 --strictis 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 --strictclean, all six guides and the columnar-sim example run, andcausalrl-3.0.0-py3-none-any.whlverified from a throwaway install — 3.0.0, 258 exports,causalrl.opeintact. The only ruff hits are 14 E501s in the untrackedexamples/causal_corr2cause_prompted.py, which belongs to another project and does not exist on CI.