Skip to content

Lazy experiment lifecycle: configure → optional prior checks → fit() - #1175

Open
drbenvincent wants to merge 21 commits into
pymc6_and_pymcmarketing1_migrationfrom
issue-1092-lazy-experiment-lifecycle
Open

Lazy experiment lifecycle: configure → optional prior checks → fit()#1175
drbenvincent wants to merge 21 commits into
pymc6_and_pymcmarketing1_migrationfrom
issue-1092-lazy-experiment-lifecycle

Conversation

@drbenvincent

@drbenvincent drbenvincent commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Lazy experiment lifecycle: configure → optional prior checks → fit()

Fixes #1092

Summary

Implements the lazy experiment lifecycle PRD: experiment construction no longer samples. __init__ validates and builds design matrices only; posterior inference happens through an explicit fit() (returning Self), and an optional sample_prior_predictive() phase enables prior predictive checks before MCMC. Draw-derived state moves from ~15 flat per-experiment attributes into per-group result bundles exposed through raising result / prior_result properties. Read methods (plot, get_plot_data, effect_summary) take keyword-only group= and fail fast with actionable GroupNotSampleedExceptions. Two new exceptions are exported from causalpy.

Because fit() returns Self, every existing call site migrates with one appended token: cp.InterruptedTimeSeries(...).fit().

This is a breaking API change landing on the pymc6_and_pymcmarketing1_migration line (1.0), per the issue's sequencing decision. The bundling prerequisite (#1093 / step 3) is carried by this same PR because it had not landed when this work started.

Changes

Core lifecycle

  • causalpy/experiments/base.py: build() (public, idempotent, auto-called), fit(**kwargs), sample_prior_predictive(**kwargs) — all return Self. State predicates is_configured / is_built / is_fitted / has_prior_predictive defined over the two private bundle slots. result / prior_result raise instead of returning None. model assignment resets all state (documented prior-revision path; no set_priors()). Second fit() warns and overwrites posterior only. _render_plot resolves group= once for all subclasses.
  • causalpy/experiments/_results.py (new): CausalResult, SyntheticDifferenceInDifferencesResult, CoefficientResult, GroupComparisonScenario, DiscontinuityResult, KinkResult, StaggeredDifferenceInDifferencesResult — fully-populated bundles, xr.DataArray-typed, one per experiment family.
  • causalpy/experiments/model_adapter.py: build(X, y, coords) / sample_prior_predictive(**kwargs) / sample_posterior(**kwargs) / predict(..., group=) / coefficients(*, group=) / capability properties (supports_prior_predictive, is_built, has_posterior, has_prior) across PyMC, sklearn, and pymc-forecast adapters.
  • causalpy/pymc_models.py: sampling split — build() (prior merge at build time; idempotent), sample_prior_predictive(), sample_posterior() (overwrite semantics via DataTree group assignment; xr.DataTree.extend does not exist on xarray 2026.7). prior_sample_kwargs (default draws=500) added and carried by _clone(). predict(group=) conditions forward sampling on idata.prior (verified on PyMC 6.0.1: forward samples land in posterior_predictive regardless of conditioning group). IV and StateSpaceTimeSeries declare supports_prior_predictive=False; PropensityScore and BayesianBasisExpansionTimeSeries split cleanly; SDID weight fitter gains build_mapping.
  • causalpy/custom_exceptions.py: GroupNotSampleedException (carries group, message names the missing call) and PriorPredictiveNotSupportedException — following the repo's ...Exception(Exception) convention; sklearn's NotFittedError rejected per issue (its AttributeError MRO would be swallowed by getattr call sites).
  • causalpy/__init__.py: all five exceptions re-exported.

All 12 experiments migrated

algorithm() removed everywhere (no alias). Each experiment implements _fit_inputs() + _finalize(group); nothing draw-derived is assigned to self. plot / get_plot_data / effect_summary take keyword-only group first; plot(group="prior") renders the reduced counterfactual-vs-observed panel set; prior effect_summary prose reads as a plausibility check. IV / IPW / PanelRegression store no bundle (_supports_results=False, fitted-state keys off the backend) per the issue's partition. IV's OLS/2SLS pre-step always runs in __init__ (deterministic point estimates that also feed summary(); never samples); only the default-prior derivation is guarded by priors is None.

Ecosystem

  • EstimateEffect.run() constructs then calls .fit().
  • All six re-fitting checks (bandwidth, leave-one-out, placebo-in-space, prior-sensitivity, placebo-in-time folds, outcome falsification) call .fit() on their fresh instances.
  • reporting.py helpers take containers/bundles; maketables_adapters resolve score/hdi_prob from bundles; placebo_in_time / pre_treatment_placebo read bundle fields.
  • generate_report() no longer swallows guard exceptions (narrowed to NotImplementedError).

Docs & tests

  • ARCHITECTURE.md lifecycle section + conventions rewritten; running-causalpy-experiments skill updated.
  • New causalpy/tests/test_experiment_lifecycle.py: guards and messages, Self returns, build() idempotency + graphviz inspection, model-assignment reset, second-fit overwrite semantics, sklearn capability error, canonical prior containers, independent prior/posterior draw sizes, reduced prior plot layout, and property-based invariants (posterior HDI narrower than prior; prior tail probability near 0.5 under a neutral prior). No seed-pinned baselines, per the issue's testing strategy.
  • Existing integration tests updated to chain .fit(); expected values unchanged (the parity argument from the issue).

Testing

  • $CONDA_EXE run -n CausalPy python -m pytest causalpy/tests/test_experiment_lifecycle.py
  • Full suite: $CONDA_EXE run -n CausalPy python -m pytest2357 passed at final head
  • prek run --all-files
  • Manual smoke: configure → guards → build() + pm.model_to_graphvizsample_prior_predictive()plot(group="prior") / effect_summary(group="prior")fit()plot() → refit warning, all verified on synthetic ITS data.

Checklist

  • No sampling in __init__ for any experiment (incl. PiecewiseITS)
  • No automatic sampling anywhere in the library
  • EstimateEffect and all checks call .fit()
  • prior_sample_kwargs with per-call overrides; carried by _clone()
  • plot(group="prior") reduced panels for prior-capable backends
  • Guards name fit() / sample_prior_predictive()
  • build()/fit()/sample_prior_predictive() return Self; build() idempotent + auto-called
  • is_fitted_result is not None (bundle-backed experiments); raising result/prior_result
  • No draw-derived attribute on self in any experiment
  • pm.model_to_graphviz(exp.model) populated after build() with no draws in idata
  • ARCHITECTURE.md + skill updated
  • Exceptions exported
  • Migration notes in docs (follow-up folded into the PyMC 6 notebook pass per issue step 7)

Implements #1092. Experiment constructors no longer sample: __init__
validates and builds design matrices only; posterior inference runs
through an explicit fit() (returns Self), and sample_prior_predictive()
enables prior predictive checks before MCMC.

Core:
- _results.py: per-experiment result bundles (CausalResult family),
  exposed through raising result/prior_result properties backing the
  is_fitted/has_prior_predictive predicates; nothing draw-derived is
  assigned to self.
- base.py: build() (public, idempotent, auto-called), fit(),
  sample_prior_predictive(), group guard in _render_plot, model setter
  resetting all state (the documented prior-revision path).
- pymc_models.py: sampling split with build-time prior merge,
  prior_sample_kwargs (default draws=500) carried by _clone(),
  predict(group=) conditioning on idata.prior, re-arm of fit-time data
  nodes before posterior sampling so refits stay correct after forward
  sampling mutated them.
- adapter surface: build/sample_prior_predictive/sample_posterior/
  predict(group=)/coefficients(group=) plus capability flags; IV and
  StateSpaceTimeSeries declare no prior phase.
- GroupNotSampleedException and PriorPredictiveNotSupportedException
  exported from causalpy.

All twelve experiments migrate off algorithm(); plot/get_plot_data/
effect_summary take keyword-only group= with reduced prior panels and
prior-plausibility prose. EstimateEffect and every re-fitting check call
fit() explicitly. reporting/maketables helpers consume bundles.
Chain .fit() onto experiment construction, move flat-attribute
assertions to bundle reads, update guard expectations to
GroupNotSampleedException/PriorPredictiveNotSupportedException,
accommodate keyword-only group= on plot/effect_summary/get_plot_data,
and add the real_pymc_sampling undo fixture to modules asserting
posterior-vs-prior relationships. Numeric expectations are unchanged.

New test_experiment_lifecycle.py covers guards and their messages, Self
returns, build() idempotency with graphviz inspection, model-assignment
reset, second-fit overwrite semantics, sklearn capability errors,
canonical prior containers, independent draw sizes, reduced prior plot
layout, and property-based invariants (posterior HDI narrower than
prior; prior tail probability near 0.5 under a neutral prior).
@read-the-docs-community

read-the-docs-community Bot commented Aug 22, 2026

Copy link
Copy Markdown

Documentation build overview

📚 causalpy | 🛠️ Build #34213195 | 📁 Comparing 7e1a63c against latest (994f665)

  🔍 Preview build  

394 files changed · + 115 added · ± 259 modified · - 20 deleted

+ Added

± Modified

- Deleted

@codecov

codecov Bot commented Aug 22, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.83677% with 44 lines in your changes missing coverage. Please review.
✅ Project coverage is 97.25%. Comparing base (a2f1302) to head (7e1a63c).

Files with missing lines Patch % Lines
causalpy/pymc_models.py 92.17% 8 Missing and 6 partials ⚠️
causalpy/reporting.py 88.52% 2 Missing and 5 partials ⚠️
causalpy/experiments/model_adapter.py 94.82% 5 Missing and 1 partial ⚠️
causalpy/experiments/diff_in_diff.py 94.11% 0 Missing and 3 partials ⚠️
causalpy/experiments/synthetic_control.py 94.91% 2 Missing and 1 partial ⚠️
...experiments/synthetic_difference_in_differences.py 94.11% 2 Missing and 1 partial ⚠️
causalpy/tests/test_prior_phase.py 99.03% 3 Missing ⚠️
causalpy/experiments/base.py 98.11% 1 Missing and 1 partial ⚠️
causalpy/checks/pre_treatment_placebo.py 50.00% 0 Missing and 1 partial ⚠️
causalpy/experiments/interrupted_time_series.py 98.94% 0 Missing and 1 partial ⚠️
... and 1 more
Additional details and impacted files
@@                          Coverage Diff                           @@
##           pymc6_and_pymcmarketing1_migration    #1175      +/-   ##
======================================================================
+ Coverage                               97.15%   97.25%   +0.09%     
======================================================================
  Files                                     123      126       +3     
  Lines                                   21744    23026    +1282     
  Branches                                 1177     1271      +94     
======================================================================
+ Hits                                    21125    22393    +1268     
- Misses                                    411      425      +14     
  Partials                                  208      208              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@drbenvincent drbenvincent left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review — PR #1175 (lazy experiment lifecycle)

I reviewed the full diff (~14.4k lines) and verified the suspicious paths against the working tree, including targeted runtime reproductions in the CausalPy env (no full-suite runs). The lifecycle scaffolding itself (build → optional prior → fit, bundle objects, guards, exception types) is solid, and the migration of every constructor call site (EstimateEffect.run(), all six checks, docs/doctests) to .fit() is complete. However, I found one blocker and three major issues, all rooted in the same mechanism: forward-sampling through predict() mutates the graph's shared data nodes, and only sample_posterior() re-arms them.


Findings

1. [BLOCKER] BBETS time-data nodes are never re-armed — documented prior-check→fit workflow crashes; refit silently corrupts or crashes

File: causalpy/pymc_models.py:445-447 (also 450-470, 2535-2596)

PyMCModel.build() records only {"X", "y"} in _build_data_nodes, and _rearm_fit_data() restores only those. But BayesianBasisExpansionTimeSeries.build_model() creates two more mutable nodes (t_trend_data, t_season_data, lines 2466-2476) and its _data_setter() mutates them on every predict(). Nothing records or restores them. The comment at line 444 says subclasses with different node names should override build() and populate the mapping themselves — BBETS does not, and no residual risk is documented anywhere (release notes / ARCHITECTURE are silent).

Reproduced end-to-end (CausalPy env):

exp = cp.InterruptedTimeSeries(df, treatment_time=idx[20], formula="y ~ 1",
                               model=BayesianBasisExpansionTimeSeries(...))
exp.sample_prior_predictive()   # documented workflow: prior check first
exp.fit()
# AssertionError: Could not broadcast dimensions. Broadcasting is only allowed
# along axes that have a statically known length 1.

Mechanism: _finalize("prior") ends with predict(post_X, out_of_sample=True), which leaves t_trend_data/t_season_data pointing at the post window. The subsequent fit()sample_posterior()_rearm_fit_data() restores y to the 20 training rows but leaves t_trend_data at 10 post-window rows → PyTensor broadcast failure when lengths differ, and silently wrong trend/seasonality timestamps when pre/post windows have equal length (e.g. cp.load_data("its"), 60/60). Verified node state directly:

after _rearm_fit_data(): y=(20, 1), t_trend_data=(10,), t_season_data=(10,)
t_trend_data still spans [0.0548,0.0794] — build-time span was [0.0000,0.0520]

A plain refit (fit() twice) is corrupted identically. This breaks the exact workflow the release notes and SKILL.md recommend.

    def build(
        self,
        X: xr.DataArray,
        y: xr.DataArray,
        coords: dict[str, Any] | None = None,
    ) -> None:
        super().build(X=X, y=y, coords=coords)
        # Record the time-feature nodes so _rearm_fit_data() can restore them;
        # predict() permanently repoints them via _data_setter().
        (
            time_for_trend,
            time_for_seasonality,
            _X_for_pymc,
            _num_obs,
        ) = self._prepare_time_and_exog_features(X)
        self._build_data_nodes["t_trend_data"] = np.asarray(time_for_trend)
        self._build_data_nodes["t_season_data"] = np.asarray(time_for_seasonality)

2. [MAJOR] Auto-populated prior groups are sampled against mutated data nodes (DiD, PrePostNEGD confirmed polluted)

File: causalpy/experiments/base.py:397-403 (root cause also causalpy/pymc_models.py:487-505)

fit() order is: sample_posterior()_finalize("posterior")sample_prior_predictive(). _finalize("posterior")'s last backend calls are predict()s whose _data_setter() permanently repoints the graph's X/y nodes at small scenario designs; sample_prior_predictive() does not re-arm them first, so pm.sample_prior_predictive evaluates deterministics/likelihood against whatever design was last predicted.

Reproduced (DiD, 160 training rows):

training rows: 160
posterior mu obs_ind: 160
prior mu obs_ind:      4      # x_pred_counterfactual scenario grid
prior_predictive y_hat obs_ind: 4

PrePostNEGD is affected identically (last predict is the 200-point interpolation grid). ITS/SC/RD/RK escape only by accident: their trailing score(X=<build X>, ...) call happens to route through predict() again, resetting the nodes — remove or reorder that score call and they pollute too. The historical eager fit sampled the prior immediately after pm.sample against the training design, so this regresses the public idata["prior"]/idata["prior_predictive"] containers relative to both history and the docstring's claim ("fills the prior groups … so idata is as complete as the historical eager fit produced"). Parameter draws (beta) are unaffected, so the prior_result bundles themselves remain correct — the pollution is in exp.idata.

Fix: re-arm before prior sampling, which is safe and idempotent:

    def sample_prior_predictive(self, **kwargs: Any) -> xr.DataTree:
        self.require_built()
        self._rearm_fit_data()
        resolved = {**self.prior_sample_kwargs, **kwargs}

3. [MAJOR] StaggeredDiD.effect_summary(group="prior") ignores the group — silently returns the posterior summary

File: causalpy/experiments/staggered_did.py:1635-1643

The method resolves the requested bundle and then discards it:

self._resolve_group(group)          # result unused
...
return _effect_summary_staggered_did(self, ...)

and _effect_summary_staggered_did reads experiment.result.att_event_time unconditionally (reporting.py:551). Consequences:

  • After sample_prior_predictive() and fit(): effect_summary(group="prior") returns the posterior-based causal prose with no prior framing — contradicting its own docstring ("produces prior-appropriate prose — a prior plausibility statement, not a causal estimate").
  • With only the prior phase sampled: it raises a misleading GroupNotSampleedException telling the user to call fit() even though the requested group exists.

Every other draw-consuming reader of this PR threads the resolved bundle; this one doesn't.

        bundle = self._resolve_group(group)
        from causalpy.reporting import _effect_summary_staggered_did

        summary = _effect_summary_staggered_did(
            self,
            direction=direction,
            alpha=alpha,
            min_effect=min_effect,
        )
        if group == "prior":
            summary.text = (
                "Prior predictive check (not a causal estimate):\n" + summary.text
            )
        return summary

(For a true prior-group table, change _effect_summary_staggered_did to accept the resolved bundle's att_event_time/hdi_prob instead of reaching back to experiment.result; the minimal patch above at least stops the mislabeling.)

4. [MAJOR] Prior-group effect summaries read as causal claims for ITS, SyntheticControl, and PiecewiseITS

Files: causalpy/experiments/interrupted_time_series.py:1363, causalpy/experiments/synthetic_control.py:1064, causalpy/experiments/piecewise_its.py:906

These three pass the prior bundle into _effect_summary_timeseries(...), which has no group parameter and applies no framing. DiD/RD/RK route prose through _apply_prior_grouping(...) and SDiD overrides prefix, so the framing contract is implemented everywhere except exactly the three experiments whose docstrings promise it most explicitly ("produces prior-appropriate prose — under a neutral prior, P(effect > 0) should sit near 0.5…"). A user running the recommended prior-check flow gets prose like "Post-period: the intervention had an effect of X (94% HDI …)" computed from prior draws, with no plausibility-check framing.

Fix: thread group into _effect_summary_timeseries and apply _apply_prior_grouping (or override prefix as SDiD does) before calling it from these three experiments.

5. [MINOR] ARCHITECTURE.md states the opposite phase ordering of fit()

File: ARCHITECTURE.md:73

The bullet says fit() "samples the absent prior phase first, then NUTS + posterior predictive". The code (and the BaseExperiment.fit docstring, which explains why posterior must run first) does the reverse: NUTS first, absent prior phase second. Same drift risk as finding 2 — if the ordering is later "fixed" per these docs, the data-node pollution becomes universal.

- **`fit(**kwargs)`** — posterior phase; samples NUTS + posterior predictive, then fills the absent prior phase so `idata` matches the historical eager output; populates `exp.result`. Returns `Self`, so call sites migrate with one appended token: `cp.InterruptedTimeSeries(...).fit()`.

6. [MINOR] RegressionDiscontinuity.plot() lost its keyword-only marker

File: causalpy/experiments/regression_discontinuity.py:364-366

The diff replaced * with group instead of inserting group after it, so plot(group=…, round_to=…, ci_prob=…, kind=…, ci_kind=…, num_samples=…, figsize=…, show=…, legend_kwargs=…) is now positional-or-keyword. Every other experiment kept *, group=…. Project convention (ARCHITECTURE.md, issue #886, test_public_signatures.py) mandates explicit keyword-only plot contracts, and this reintroduces positional ambiguity that a future parameter reorder would silently break.

    def plot(
        self,
        *,
        group: Literal["prior", "posterior"] = "posterior",
        round_to: int | None = 2,

7. [NIT] Unreachable duplicate return in PiecewiseITS._plot_prior_checks

File: causalpy/experiments/piecewise_its.py:759-760

        return fig, [ax]
        return fig, ax   # dead

Delete the second return (its list-vs-single-axes inconsistency suggests a leftover from an earlier shape).

8. [NIT] IV summary() silently dropped the formula header lines

File: causalpy/experiments/instrumental_variable.py:378

Formula: / Instruments formula: prints were removed without a mention in the release notes' attribute-rename table. If intentional cleanup, fine — but it changes captured output for anyone diffing summaries, so it deserves a release-note line.

9. [NIT] Stale format_r2_score docstring reference

File: causalpy/plot_utils.py:51

Still says the score is "as stored on experiment.score"; scores now live on result.score (the signature correctly accepts None).


Checked and found sound

  • PiecewiseITS bundle mapping (predictions_pre = full-window fitted expectation, predictions_post/impact_post = post-first-interruption slices): consistent across get_plot_data, effect_summary, and _plot; full-window counterfactual recovery (predictions_pre − impact_pre) is algebraically exact. interruption_times[0] cannot IndexError because _validate_inputs rejects formulas without step/ramp terms.
  • SDID _extract_weight_posteriors(group): reads require_idata()[group]; tau math identical for both groups since it operates on the extracted arrays. Mapping-input build means no data nodes to mutate.
  • StaggeredDiD hdi_prob semantics: bundle.hdi_prob preserves old hdi_prob_ behavior (aggregation-time freeze, plot-time mismatch ValueError, get_plot_data recompute-on-mismatch, placebo helper reuse).
  • IV/IPW lazy overrides: warning + no-op build() + supports_prior_predictive=False (flag verified at pymc_models.py:1497 and state-space :2942, matching the new exception docstring); IPW's t-as-y flows correctly through adapter.buildPropensityScore.build(y=treatment) with "t" recorded in _build_data_nodes.
  • PanelRegression _supports_results=False: every draw reader (summary, _plot, _plot_coefficients_internal, get_plot_data, plot_unit_effects, plot_trajectories) is guarded. plot(group="prior") rendering posterior draws is documented in the docstring ("treated as posterior") — deliberate, though arguably surprising given PyMC-backed panels do acquire prior draws inside fit().
  • Lifecycle plumbing: EstimateEffect.run() and all checks call .fit(); generate_report no longer swallows GroupNotSampleedException (only NotImplementedError); maketables readers guard all three failure modes; refit warning verified live; model reassignment reset verified by tests; harness _lazy_fit sentinel is scoped to the migration script.

@drbenvincent drbenvincent left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review — PR #1175 (lazy experiment lifecycle)

I reviewed the full diff (~14.4k lines) and verified the suspicious paths against the working tree, including targeted runtime reproductions in the CausalPy env (no full-suite runs). The lifecycle scaffolding itself (build → optional prior → fit, bundle objects, guards, exception types) is solid, and the migration of every constructor call site (EstimateEffect.run(), all six checks, docs/doctests) to .fit() is complete. However, I found one blocker and three major issues, all rooted in the same mechanism: forward-sampling through predict() mutates the graph's shared data nodes, and only sample_posterior() re-arms them.


Findings

1. [BLOCKER] BBETS time-data nodes are never re-armed — documented prior-check→fit workflow crashes; refit silently corrupts or crashes

File: causalpy/pymc_models.py:445-447 (also 450-470, 2535-2596)

PyMCModel.build() records only {"X", "y"} in _build_data_nodes, and _rearm_fit_data() restores only those. But BayesianBasisExpansionTimeSeries.build_model() creates two more mutable nodes (t_trend_data, t_season_data, lines 2466-2476) and its _data_setter() mutates them on every predict(). Nothing records or restores them. The comment at line 444 says subclasses with different node names should override build() and populate the mapping themselves — BBETS does not, and no residual risk is documented anywhere (release notes / ARCHITECTURE are silent).

Reproduced end-to-end (CausalPy env):

exp = cp.InterruptedTimeSeries(df, treatment_time=idx[20], formula="y ~ 1",
                               model=BayesianBasisExpansionTimeSeries(...))
exp.sample_prior_predictive()   # documented workflow: prior check first
exp.fit()
# AssertionError: Could not broadcast dimensions. Broadcasting is only allowed
# along axes that have a statically known length 1.

Mechanism: _finalize("prior") ends with predict(post_X, out_of_sample=True), which leaves t_trend_data/t_season_data pointing at the post window. The subsequent fit()sample_posterior()_rearm_fit_data() restores y to the 20 training rows but leaves t_trend_data at 10 post-window rows → PyTensor broadcast failure when lengths differ, and silently wrong trend/seasonality timestamps when pre/post windows have equal length (e.g. cp.load_data("its"), 60/60). Verified node state directly:

after _rearm_fit_data(): y=(20, 1), t_trend_data=(10,), t_season_data=(10,)
t_trend_data still spans [0.0548,0.0794] — build-time span was [0.0000,0.0520]

A plain refit (fit() twice) is corrupted identically. This breaks the exact workflow the release notes and SKILL.md recommend.

    def build(
        self,
        X: xr.DataArray,
        y: xr.DataArray,
        coords: dict[str, Any] | None = None,
    ) -> None:
        super().build(X=X, y=y, coords=coords)
        # Record the time-feature nodes so _rearm_fit_data() can restore them;
        # predict() permanently repoints them via _data_setter().
        (
            time_for_trend,
            time_for_seasonality,
            _X_for_pymc,
            _num_obs,
        ) = self._prepare_time_and_exog_features(X)
        self._build_data_nodes["t_trend_data"] = np.asarray(time_for_trend)
        self._build_data_nodes["t_season_data"] = np.asarray(time_for_seasonality)

2. [MAJOR] Auto-populated prior groups are sampled against mutated data nodes (DiD, PrePostNEGD confirmed polluted)

File: causalpy/experiments/base.py:397-403 (root cause also causalpy/pymc_models.py:487-505)

fit() order is: sample_posterior()_finalize("posterior")sample_prior_predictive(). _finalize("posterior")'s last backend calls are predict()s whose _data_setter() permanently repoints the graph's X/y nodes at small scenario designs; sample_prior_predictive() does not re-arm them first, so pm.sample_prior_predictive evaluates deterministics/likelihood against whatever design was last predicted.

Reproduced (DiD, 160 training rows):

training rows: 160
posterior mu obs_ind: 160
prior mu obs_ind:      4      # x_pred_counterfactual scenario grid
prior_predictive y_hat obs_ind: 4

PrePostNEGD is affected identically (last predict is the 200-point interpolation grid). ITS/SC/RD/RK escape only by accident: their trailing score(X=<build X>, ...) call happens to route through predict() again, resetting the nodes — remove or reorder that score call and they pollute too. The historical eager fit sampled the prior immediately after pm.sample against the training design, so this regresses the public idata["prior"]/idata["prior_predictive"] containers relative to both history and the docstring's claim ("fills the prior groups … so idata is as complete as the historical eager fit produced"). Parameter draws (beta) are unaffected, so the prior_result bundles themselves remain correct — the pollution is in exp.idata.

Fix: re-arm before prior sampling, which is safe and idempotent:

    def sample_prior_predictive(self, **kwargs: Any) -> xr.DataTree:
        self.require_built()
        self._rearm_fit_data()
        resolved = {**self.prior_sample_kwargs, **kwargs}

3. [MAJOR] StaggeredDiD.effect_summary(group="prior") ignores the group — silently returns the posterior summary

File: causalpy/experiments/staggered_did.py:1635-1643

The method resolves the requested bundle and then discards it:

self._resolve_group(group)          # result unused
...
return _effect_summary_staggered_did(self, ...)

and _effect_summary_staggered_did reads experiment.result.att_event_time unconditionally (reporting.py:551). Consequences:

  • After sample_prior_predictive() and fit(): effect_summary(group="prior") returns the posterior-based causal prose with no prior framing — contradicting its own docstring ("produces prior-appropriate prose — a prior plausibility statement, not a causal estimate").
  • With only the prior phase sampled: it raises a misleading GroupNotSampleedException telling the user to call fit() even though the requested group exists.

Every other draw-consuming reader of this PR threads the resolved bundle; this one doesn't.

        bundle = self._resolve_group(group)
        from causalpy.reporting import _effect_summary_staggered_did

        summary = _effect_summary_staggered_did(
            self,
            direction=direction,
            alpha=alpha,
            min_effect=min_effect,
        )
        if group == "prior":
            summary.text = (
                "Prior predictive check (not a causal estimate):\n" + summary.text
            )
        return summary

(For a true prior-group table, change _effect_summary_staggered_did to accept the resolved bundle's att_event_time/hdi_prob instead of reaching back to experiment.result; the minimal patch above at least stops the mislabeling.)

4. [MAJOR] Prior-group effect summaries read as causal claims for ITS, SyntheticControl, and PiecewiseITS

Files: causalpy/experiments/interrupted_time_series.py:1363, causalpy/experiments/synthetic_control.py:1064, causalpy/experiments/piecewise_its.py:906

These three pass the prior bundle into _effect_summary_timeseries(...), which has no group parameter and applies no framing. DiD/RD/RK route prose through _apply_prior_grouping(...) and SDiD overrides prefix, so the framing contract is implemented everywhere except exactly the three experiments whose docstrings promise it most explicitly ("produces prior-appropriate prose — under a neutral prior, P(effect > 0) should sit near 0.5…"). A user running the recommended prior-check flow gets prose like "Post-period: the intervention had an effect of X (94% HDI …)" computed from prior draws, with no plausibility-check framing.

Fix: thread group into _effect_summary_timeseries and apply _apply_prior_grouping (or override prefix as SDiD does) before calling it from these three experiments.

5. [MINOR] ARCHITECTURE.md states the opposite phase ordering of fit()

File: ARCHITECTURE.md:73

The bullet says fit() "samples the absent prior phase first, then NUTS + posterior predictive". The code (and the BaseExperiment.fit docstring, which explains why posterior must run first) does the reverse: NUTS first, absent prior phase second. Same drift risk as finding 2 — if the ordering is later "fixed" per these docs, the data-node pollution becomes universal.

- **`fit(**kwargs)`** — posterior phase; samples NUTS + posterior predictive, then fills the absent prior phase so `idata` matches the historical eager output; populates `exp.result`. Returns `Self`, so call sites migrate with one appended token: `cp.InterruptedTimeSeries(...).fit()`.

6. [MINOR] RegressionDiscontinuity.plot() lost its keyword-only marker

File: causalpy/experiments/regression_discontinuity.py:364-366

The diff replaced * with group instead of inserting group after it, so plot(group=…, round_to=…, ci_prob=…, kind=…, ci_kind=…, num_samples=…, figsize=…, show=…, legend_kwargs=…) is now positional-or-keyword. Every other experiment kept *, group=…. Project convention (ARCHITECTURE.md, issue #886, test_public_signatures.py) mandates explicit keyword-only plot contracts, and this reintroduces positional ambiguity that a future parameter reorder would silently break.

    def plot(
        self,
        *,
        group: Literal["prior", "posterior"] = "posterior",
        round_to: int | None = 2,

7. [NIT] Unreachable duplicate return in PiecewiseITS._plot_prior_checks

File: causalpy/experiments/piecewise_its.py:759-760

        return fig, [ax]
        return fig, ax   # dead

Delete the second return (its list-vs-single-axes inconsistency suggests a leftover from an earlier shape).

8. [NIT] IV summary() silently dropped the formula header lines

File: causalpy/experiments/instrumental_variable.py:378

Formula: / Instruments formula: prints were removed without a mention in the release notes' attribute-rename table. If intentional cleanup, fine — but it changes captured output for anyone diffing summaries, so it deserves a release-note line.

9. [NIT] Stale format_r2_score docstring reference

File: causalpy/plot_utils.py:51

Still says the score is "as stored on experiment.score"; scores now live on result.score (the signature correctly accepts None).


Checked and found sound

  • PiecewiseITS bundle mapping (predictions_pre = full-window fitted expectation, predictions_post/impact_post = post-first-interruption slices): consistent across get_plot_data, effect_summary, and _plot; full-window counterfactual recovery (predictions_pre − impact_pre) is algebraically exact. interruption_times[0] cannot IndexError because _validate_inputs rejects formulas without step/ramp terms.
  • SDID _extract_weight_posteriors(group): reads require_idata()[group]; tau math identical for both groups since it operates on the extracted arrays. Mapping-input build means no data nodes to mutate.
  • StaggeredDiD hdi_prob semantics: bundle.hdi_prob preserves old hdi_prob_ behavior (aggregation-time freeze, plot-time mismatch ValueError, get_plot_data recompute-on-mismatch, placebo helper reuse).
  • IV/IPW lazy overrides: warning + no-op build() + supports_prior_predictive=False (flag verified at pymc_models.py:1497 and state-space :2942, matching the new exception docstring); IPW's t-as-y flows correctly through adapter.buildPropensityScore.build(y=treatment) with "t" recorded in _build_data_nodes.
  • PanelRegression _supports_results=False: every draw reader (summary, _plot, _plot_coefficients_internal, get_plot_data, plot_unit_effects, plot_trajectories) is guarded. plot(group="prior") rendering posterior draws is documented in the docstring ("treated as posterior") — deliberate, though arguably surprising given PyMC-backed panels do acquire prior draws inside fit().
  • Lifecycle plumbing: EstimateEffect.run() and all checks call .fit(); generate_report no longer swallows GroupNotSampleedException (only NotImplementedError); maketables readers guard all three failure modes; refit warning verified live; model reassignment reset verified by tests; harness _lazy_fit sentinel is scoped to the migration script.

- BBETS: record t_trend_data/t_season_data in _build_data_nodes at build
  so refits re-arm the trend/seasonality nodes after forecast-window
  conditioning (blocker: prior-check workflow crashed on unequal
  windows and silently corrupted equal-window refits).
- sample_prior_predictive() re-arms fit-time data before sampling, so
  the auto-prior phase inside fit() draws against the training design
  instead of whatever scenario grid a prior finalize left on the graph.
- StaggeredDiD.effect_summary consumes the resolved group bundle;
  reporting helper reads att_event_time/hdi_prob from it and applies
  prior-plausibility framing for group='prior'.
- _effect_summary_timeseries gains group= with prior framing; ITS, SC,
  and PiecewiseITS pass it through.
- RD plot() keyword-only marker restored; PiecewiseITS dead return
  removed; IV summary Formula headers restored for output parity;
  extract_r2_score docstring references result.score; ARCHITECTURE.md
  phase-ordering bullet corrected.
@drbenvincent

Copy link
Copy Markdown
Collaborator Author

Fixes for round-1 review findings

Thanks for the thorough review — every finding was reproduced where claimed and addressed. Summary of what changed and why:

Blocker 1 — BBETS time-feature nodes never re-armed

Fixed. BayesianBasisExpansionTimeSeries now overrides build(): after super().build() it reads back the built t_trend_data / t_season_data node values and adds them to _build_data_nodes, so _rearm_fit_data() restores them alongside X/y. Verified end-to-end on an unequal-window ITS (40 pre / 15 post): sample_prior_predictive()fit() no longer raises the broadcast AssertionError, and a refit after plot() re-samples on the 40-row training window (posterior.mu.obs_ind == 40).

Major 2 — auto-prior phase sampled against mutated data nodes

Fixed as suggested. PyMCModel.sample_prior_predictive() now calls _rearm_fit_data() before sampling, so the prior groups are always drawn against the build-time design regardless of what any earlier forward-sampling call did to the shared nodes. Reproduced your DiD case before/after: idata["prior"]["mu"].sizes["obs_ind"] went from 4 (counterfactual grid) to the full 160-equivalent training size. This also makes the prior phase robust for PrePostNEGD's scenario grids.

Major 3 — StaggeredDiD.effect_summary discarded the resolved bundle

Fixed. _effect_summary_staggered_did(experiment, bundle=None, *, group=..., ...) now consumes an explicit resolved bundle (falling back to experiment.result only when omitted), reads hdi_prob from that bundle rather than unconditionally from experiment.result, and routes prior-group prose through the same prior-plausibility framing as the scalar summaries. The experiment passes self._resolve_group(group) + group=group, so with both phases run, effect_summary(group="prior") reports the prior ATT table with plausibility framing instead of silently returning posterior prose; with only the prior sampled it no longer raises a misleading "call fit()" error.

Major 4 — timeseries effect summaries lacked prior framing

Fixed at the helper level. _effect_summary_timeseries(..., group="posterior") applies _apply_prior_grouping to the generated prose when group == "prior", and all three call sites (ITS, SyntheticControl, PiecewiseITS) pass group=group. Prior-group summaries on those experiments now read "Prior predictive check (not a causal estimate): …", matching DiD/RD/RK/SDID/StaggeredDiD.

Minor findings

  • ARCHITECTURE.md ordering claim: corrected to match implementation (posterior first, then absent-prior fill, with the data-node rationale).
  • RD plot() keyword-only marker: restored * before group; RD is keyword-only like every other experiment again.
  • PiecewiseITS unreachable duplicate return: deleted.
  • IV summary Formula header lines: restored (output parity preserved; the removal was accidental, not intentional).
  • extract_r2_score docstring: now references the result bundle's score field instead of the removed experiment.score.

Verification after fixes

  • Full suite: 2354 passed (2 pre-existing skips).
  • prek run --all-files: green.
  • Targeted reproductions for findings 1 and 2 pass as described above.

@drbenvincent drbenvincent left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round-2 review — verification of round-1 fixes + fresh sweep

All six round-1 findings were re-verified with targeted reproductions ($CONDA_EXE run -n CausalPy python); every one is genuinely fixed. The fix commit itself introduces one new edge-case defect (finding R2-1) and leaves the round-1 blocker without a pinned regression test (R2-2). No full-suite run was repeated per review instructions.

Verification of round-1 findings

Blocker 1 — BBETS t_trend_data/t_season_data never re-armed: FIXED ✅
Reproduced on an unequal-window setup (40 train / 15 forecast): build → sample_prior_predictive → sample_posterior → predict(15-row window) → sample_posterior keeps idata["posterior"]["mu"].sizes["obs_ind"] == 40 throughout; _build_data_nodes correctly records X, y, t_trend_data, t_season_data at build time and _rearm_fit_data() restores them after predict(). The no-exog case also works (the name in self.named_vars filter drops the absent "X" node from the payload).

Major 2 — prior phase sampled against mutated data nodes: FIXED ✅
With a LinearRegression conditioned onto a 4-row grid via predict(), a subsequent standalone sample_prior_predictive() now draws prior["mu"] with obs_ind == 20 (training design), not 4. Also confirmed: sample_prior_predictive() after fit() overwrites only prior/prior_predictive and preserves posterior sizes; refit-after-conditioning samples the training design. I traced PrePostNEGD's scenario-grid path specifically: _finalize(group) conditions through PyMCModel.predict(..., group=...), which calls pm.sample_posterior_predictive(conditioning_draws) directly and never routes through sample_prior_predictive() — so the new re-arm does not interfere with intentional grid conditioning. All internal second-build callers (BaseExperiment.build, legacy fused fit) are guarded by _built.

Major 3 — StaggeredDiD.effect_summary discarded resolved bundle: FIXED ✅
Verified end-to-end on tiny staggered draws: (a) prior-only phase — effect_summary(group="prior") returns the prior ATT table with "Prior predictive check (not a causal estimate):" framing instead of raising the misleading fit() error; (b) both phases — group="prior" reads att_event_time/hdi_prob from _prior_result while the default summary reads them from result (prose HDI % matches each bundle's stored hdi_prob); (c) back-compat — calling _effect_summary_staggered_did(exp) with the bundle omitted still falls back to experiment.result and produces identical output. No remaining caller or test mock uses the old positional layout.

Major 4 — timeseries effect summaries lacked prior framing: FIXED ✅
_effect_summary_timeseries(..., group="prior") prepends the plausibility framing on both the Bayesian-draws branch and the point-estimate branch; all three call sites (ITS interrupted_time_series.py:1374, PiecewiseITS piecewise_its.py:916, SC synthetic_control.py:1075) pass group=group. Posterior prose unchanged. (For OLS backends group="prior" is unreachable — the adapter raises PriorPredictiveNotSupportedException first — so framing the t-based branch is harmless.)

Minor findings: ALL FIXED ✅

  • RD plot(): group verified KEYWORD_ONLY.
  • IV summary(): both Formula header lines restored (instrumental_variable.py:381-382); self.formula/self.instruments_formula exist (set at construction, lines 161–162); targeted IV summary test passes.
  • ARCHITECTURE.md bullet now matches implementation (posterior first, then absent-prior fill, data-node rationale stated).
  • extract_r2_score docstring references the bundle's score field, which exists on all result bundles.
  • PiecewiseITS dead return removed.

New findings

R2-1 (minor, leaning major) — BBETS build() override pollutes _build_data_nodes when invoked on an already-built graph after conditioning

BayesianBasisExpansionTimeSeries.build() (causalpy/pymc_models.py:2447-2458) runs its .update() unconditionally after super().build(). The base build() documents itself as a no-op once built ("a graph is built at most once per instance"), but the override still executes on that early-return path and reads back the current shared-node values. After predict() has re-purposed the graph for a 15-row forecast window, a direct second model.build(X_train, y_train) records t_trend_data/t_season_data at length 15 next to X/y at length 40; the next sample_posterior() then crashes in _rearm_fit_data():

pymc.exceptions.ShapeError: Length of new coordinate values does not match
the new dimension length. (actual 40 != expected 15)

Worse, if the prediction window happens to be the same length as training (different span), the override silently records forecast-window time features as the build-time design and the refit runs on the wrong trend/seasonality inputs with no error. The experiment-level lifecycle cannot trigger this (BaseExperiment.build() checks is_built; every internal caller is guarded or one-shot), but exp.model and model-level build()/fit() are public, documented surfaces, and this reintroduces exactly the stale-design class the round-1 blocker was about. Suggested fix — record only when the graph was actually constructed:

        already_built = self._built
        super().build(X=X, y=y, coords=coords)
        if already_built:
            return
        # The graph's trend/seasonality inputs are derived from X at build
        # time and stored under their own pm.Data node names; record them so
        # _rearm_fit_data() can restore them after predict() re-purposes the
        # nodes for forecast-window conditioning.
        self._build_data_nodes.update(

R2-2 (minor) — the round-1 blocker has no pinned regression test

No test in the suite touches _build_data_nodes, the BBETS re-arm, or a refit-after-predict sequence (grep across causalpy/tests confirms). The implementer's verification was manual only; R2-1 shows how easily this area regresses. A ~20-line integration test would pin it: unequal-window BBETS, fit → predict(forecast window) → fit, assert idata["posterior"]["mu"].sizes["obs_ind"] == n_train (and ideally assert _build_data_nodes shapes are untouched after a second build() call, which fails today per R2-1).

R2-3 (nit) — lifecycle suite never exercises sample_prior_predictive() after fit() directly

The auto-fill inside fit() covers the merge-with-existing-idata branch indirectly, but no test calls sample_prior_predictive() after a completed fit and asserts idata["posterior"] sizes are preserved while prior draws change. One assertion in test_experiment_lifecycle.py (e.g., extending test_second_fit_warns_and_preserves_prior) would close it.

Verdict

Round 1: all findings fixed, verified by reproduction. Round 2: one new patch-introduced defect (R2-1, narrow reachability, crash or silent wrong-design refit via documented public model API) plus two low-cost test-hardening suggestions. Nothing here reopens the round-1 blocker for the standard experiment lifecycle.

…regressions

The build override now returns early when the graph already existed, so
an idempotent second build() cannot record forecast-window values over
the training design in _build_data_nodes (round-2 review R2-1). Adds
lifecycle regression tests for the refit-after-predict re-arm path
(round-1 blocker) and for prior-after-fit posterior preservation.
@drbenvincent

Copy link
Copy Markdown
Collaborator Author

Fixes for round-2 review findings

All three findings addressed:

R2-1 — BBETS build() override poisoned _build_data_nodes post-conditioning

Fixed as suggested. The override now captures already_built = self._built before delegating and returns immediately when the base build() was a no-op, so reading back shared-node values can never record a forecast window over the training design. Verified: a second model.build(X_train, y_train) after plot() leaves _build_data_nodes shapes unchanged.

R2-2 — no pinned regression test for the round-1 blocker

Added. test_refit_after_predict_restores_training_design in causalpy/tests/test_experiment_lifecycle.py: fit → plot (forecast-window conditioning) → fit on ITS, asserting posterior["mu"].obs_ind equals the training row count after refit, plus an explicit assertion that a second model.build() call is shape-neutral on _build_data_nodes (fails on the pre-fix code path R2-1 described).

R2-3 — no direct prior-after-fit coverage

Added. test_prior_after_fit_overwrites_prior_preserves_posterior: calls sample_prior_predictive(draws=40) after a completed fit and asserts the prior group carries exactly 40 draws while every posterior size — including chain/draw counts — is unchanged, and that the prior bundle was recomputed.

Verification after fixes

  • Lifecycle suite 16/16; full suite 2356 passed; prek run --all-files green.

@drbenvincent drbenvincent left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round-3 review (final): APPROVE

Verified at HEAD d801d33c. Round-1/round-2 fixes confirmed with concrete reproductions (BBETS-backed ITS, 73 pre / 47 post, tiny draws). No ship-blocking defects found: nothing produces wrong numbers, nothing corrupts silently, and no guard fails open. One non-blocking coverage/claim finding below.

Round-2 fix verification (reproduced, not just read)

R2-1 guard (causalpy/pymc_models.py:2447-2453) — correct.

  • HEAD: fit()plot() (forecast conditioning) → explicit second model.build(X_pre, y_pre) leaves _build_data_nodes shape-neutral (all nodes stay at training shape). Refit-after-plot re-samples on the training window (posterior["mu"].obs_ind == 73), and sample_prior_predictive()fit() yields both groups on the training design. All PASS.
  • Pre-fix simulation (guard removed via monkeypatch, no repo edits): the same second build() records the 47-row forecast window over the 73-row training design in t_trend_data/t_season_data, and the subsequent fit() raises ShapeError: Length of new coordinate values does not match... (actual 73 != expected 47) — mechanism confirmed, and the failure is loud, not silent.

R2-3 test (test_prior_after_fit_overwrites_prior_preserves_posterior, causalpy/tests/test_experiment_lifecycle.py:316) — pins what it claims. Asserts prior re-sampled to 40 draws while posterior chain/draw/obs sizes are byte-identical and the prior bundle object is recomputed.

R2-2 test (test_refit_after_predict_restores_training_design, line 273) — exists, passes, but does NOT pin the R2-1 fix (non-blocking, see finding). It constructs cp.pymc_models.LinearRegression, so the BBETS build() override never runs. I re-ran its exact assertion sequence with a deliberately broken (pre-fix) BBETS in-process and every assertion still passed — it cannot detect the R2-1 regression. The PR comment's claim that it "fails on the pre-fix code path R2-1 described" is inaccurate; it pins the base X/y re-arm contract (which worked since the initial PR commit — sample_posterior always called _rearm_fit_data). The BBETS round-1 re-arm and the R2-1 guard currently have no automated coverage (test_its_with_bsts_model fits and plots but never refits after plot). I verified both paths manually at HEAD; the guard is correct, so this is a coverage/claim-accuracy gap, not a runtime defect.

Final sweep (blockers only)

  • All six re-fitting checks + EstimateEffect.run() clone-then-.fit() on fresh instances (bandwidth.py:112, leave_one_out.py:107, placebo_in_time.py:521 with per-fold seeds, placebo_in_space.py:123, outcome_falsification.py:195, prior_sensitivity.py:142, estimate_effect.py:128). No leftover eager call sites; remaining .algorithm() mentions are historical docstrings only.
  • fit() ordering (posterior → finalize → absent-prior fill) matches ARCHITECTURE.md and the PR body; verified dynamically on BBETS (prior and posterior both on the training design).
  • prior_sample_kwargs defaults {"draws": 500, "random_seed": posterior seed} (pymc_models.py:274-281) match the PR body.
  • All five exceptions re-exported from causalpy/__init__.py and pinned by test_exceptions_are_exported.
  • Prior-group prose framing applied at the helper level across scalar and timeseries summaries (reporting.py:464-467 and call sites).
  • test_experiment_lifecycle.py: 16/16 pass at HEAD.

Commit story

Five commits read coherently: core lazy lifecycle → test-suite migration → docs → round-1 fixes → round-2 guard + regressions. PR body claims (fit ordering, prior_sample_kwargs defaults, exception exports, Self returns, bundle-backed state) all match final behavior.

Non-blocking finding

test_refit_after_predict_restores_training_design does not exercise the BBETS path it citescausalpy/tests/test_experiment_lifecycle.py:273-314, P2, confidence 0.95. Docstring says it "guards the BBETS time-feature re-arm path (round-1 review blocker) and the base X/y re-arm alike", but with LinearRegression the BBETS override is never invoked, and its second-build() assertion passes even with the R2-1 bug present (demonstrated). Fix when convenient: parametrize the test over LinearRegression and a BayesianBasisExpansionTimeSeries(trend_component=MockComponent(), seasonality_component=MockComponent()) variant (the coverage-suite's MockComponent avoids the pymc-marketing dependency) — the BBETS variant fails pre-fix and passes at HEAD, per my reproduction. If regressed, the failure mode is a loud ShapeError on an experimental model, so this does not block merge.

Verdict: APPROVE — ship it. The finding above is follow-up test hygiene, not a merge condition.

Parametrize test_refit_after_predict_restores_training_design over
LinearRegression and a mock-component BBETS variant, reorder the
sequence so the second build() call happens while the shared nodes are
still forecast-conditioned (before the healing refit re-arms them), and
assert every recorded data node keeps training-length shapes. Verified:
the bbets variant fails with the round-2 guard disabled and passes at
HEAD (round-3 review P2).
@drbenvincent

Copy link
Copy Markdown
Collaborator Author

Round-3 P2 addressed in c7c2e07: test_refit_after_predict_restores_training_design is now parametrized over LinearRegression and a mock-component BBETS variant, and its sequence checks the recorded data nodes between forecast conditioning and the healing refit — verified that the bbets variant fails with the build-guard disabled and passes at HEAD, so the round-1/round-2 re-arm fixes are now genuinely pinned by the suite. Final state: 2357 passed, prek --all-files green.

Patch coverage was 84% against the 96% gate: the prior-group branches
added across nine experiments and the adapter guard paths had no direct
tests.

New test_prior_phase.py pins the observable contract end to end:
plot(group='prior') renders exactly one axes per capable experiment,
effect_summary(group='prior') prose is framed as a plausibility check,
get_plot_data(group='prior') returns without caching, and every
documented capability/guard error on the PyMC, sklearn, forecast, and
base adapters raises as specified.

Coverage work exposed two real defects, both fixed:
- RegressionDiscontinuity.effect_summary(group='prior') double-prefixed
  the plausibility framing (helper-level grouping plus a leftover manual
  prefix block).
- reporting._extract_window crashed with TypeError for datetime-sliced
  windows: pandas 3.x no longer accepts Timestamp/string-bounded slice
  objects on DatetimeIndex.__getitem__. Both branches now boolean-mask.
@drbenvincent

Copy link
Copy Markdown
Collaborator Author

Remote codecov/patch check fixed

The gate was at 84% (165 uncovered lines) versus the 96% requirement: the prior-group branches added across nine experiments (_plot_prior_checks, prior effect_summary/get_plot_data paths) plus the new adapter guard paths had no direct tests.

New causalpy/tests/test_prior_phase.py (41 tests, tiny draws, session-scoped fits) pins the contract end to end:

  • plot(group="prior") renders exactly one axes for all ten capable experiment variants;
  • effect_summary(group="prior") prose is framed as a plausibility check;
  • get_plot_data(group="prior") returns without caching;
  • every documented adapter/experiment guard (sklearn/forecast prior rejection, mixed mapping inputs, coefficients group guards, pm.sample() -> None, unbuilt-graph sampling, IV no-op build + refit warning + overwrite, PanelRegression pre-fit guard, report generation swallowing only NotImplementedError) raises as specified.

Coverage work surfaced and fixed two latent bugs:

  1. RegressionDiscontinuity.effect_summary(group="prior") double-applied the plausibility prefix.
  2. reporting._extract_window crashed with TypeError for datetime-sliced windows on pandas 3.x (Timestamp-bounded slices are no longer supported by DatetimeIndex.__getitem__); both slice branches now boolean-mask.

Local gate against origin/pymc6_and_pymcmarketing1_migration: 98% patch coverage, 14 lines missing (residual: mock-dependent pm.sample() -> None branches). Full suite: 2398 passed. The four notebook jobs remain red pending the notebook pass we discussed.

Append .fit() at all 80 experiment-construction call sites across the 37
gallery and knowledgebase notebooks, and remap the 40 reads of removed
flat draw-derived attributes to their result-bundle locations
(post_impact -> result.impact_post, att_event_time_ ->
result.att_event_time, etc.).

Regenerate all notebook outputs by executing the corpus in place with
real MCMC (scripts/run_notebooks/runner.py --full): 33/34 gallery and
3/3 knowledgebase notebooks pass. Local-only environment notes: nutpie
and CPU jax were installed to execute interrupted-time-series-placebo-
in-time-analysis (nuts_sampler="nutpie"); staggered-difference-in-
differences-pymc hit the known PyMC 6.0.x macOS fork-worker EOFError
once and passed on retry.

Skip gallery/instrumental-variables-variable-selection-priors.ipynb in
the notebook CI lanes: like its two sibling IV notebooks, it requires
JAX, which the test environment does not install.
@review-notebook-app

Copy link
Copy Markdown

Check out this pull request on  ReviewNB

See visual diffs & provide feedback on Jupyter Notebooks.


Powered by ReviewNB

@drbenvincent

Copy link
Copy Markdown
Collaborator Author

Docs pass complete: all notebooks migrated and executed

What changed (e8caf65)

  • 80 .fit() calls appended at every experiment-construction site across the 37 gallery + knowledgebase notebooks (AST-based sweep, idempotent, multi-line-call safe). fit() returns Self, so this is one appended token per call site.
  • 40 flat-attribute reads remapped to their result-bundle locations, longest-first so .post_impact_cumulative wins over .post_impact: post_impactresult.impact_post, post_impact_cumulativeresult.impact_post_cumulative, post_predresult.predictions_pre/post, causal_impactresult.causal_impact, att_event_time_/att_group_time_result.att_event_time/result.att_group_time, gradient_change, discontinuity_at_threshold, tau_posterior likewise. (Reads like result.result.impact_post are the experiment-variable/bundle name collision — correct, just visually redundant.)
  • All outputs regenerated in place by executing the corpus with real MCMC (scripts/run_notebooks/runner.py --full): 33/34 gallery + 3/3 knowledgebase pass.
  • Skip-list addition: gallery/instrumental-variables-variable-selection-priors.ipynb — requires JAX, exactly like its two sibling IV notebooks already skipped for that reason.

Local execution notes (not repo changes)

  • Installed nutpie + CPU jax into the local env to execute interrupted-time-series-placebo-in-time-analysis.ipynb (it samples with nuts_sampler="nutpie", whose compile path requires JAX). Mock-mode CI lanes never reach the real sampler, so remote lanes are unaffected either way.
  • staggered-difference-in-differences-pymc.ipynb hit the known PyMC 6.0.x macOS-arm64 fork-worker EOFError once (same class as the cores=1 workaround IV already carries) and passed cleanly on retry.

Verification

  • Mock-mode runner: gallery + knowledgebase all green (same as remote lanes' mode).
  • Full local execution: all non-skipped notebooks pass with real sampling; outputs committed.
  • prek run --all-files: green (validate-notebooks, gallery sync, ruff, numpydoc, exports).
  • No new docs explaining the workflow and no prior-predictive demos, per scope: this pass only makes the corpus execute under the new API.

test_production_skip_configuration_is_consistent pins the exact contents
of skip_notebooks.yml; extend the expected set with
gallery/instrumental-variables-variable-selection-priors.ipynb, which
requires JAX like its two sibling IV notebooks.
The attribute-remap sweep was not idempotent: replacements contain their
own search patterns, so re-running it after the variable-selection
notebook restore doubled the bundle prefix
(result.result.discontinuity_at_threshold). Collapse repeated .result
prefixes to a single one in the five affected notebooks. Verified with a
mock-mode run of the gallery-pymc lane (exit 0).
@drbenvincent

Copy link
Copy Markdown
Collaborator Author

Fixed the gallery-pymc notebook lane (021a0af): the attribute-remap sweep used replacements containing their own search patterns, so re-running it doubled the bundle prefix (e.g. result.result.discontinuity_at_threshold) in five notebooks; collapsed to single .result. prefixes and verified with a mock-mode run of the lane (exit 0). Also extended the skip-consistency test for the new JAX-dependent notebook skip (859df08). All CI lanes now pass.

@drbenvincent

Copy link
Copy Markdown
Collaborator Author

Boom! Big API change PR ready for review.

So far zero attempt to document the new workflow other than compatibility / at the new .fit() method. Thought this would be enough for a PR.

Feedback welcome

@anevolbap anevolbap left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Read the whole change against issue #1092 and the conventions in ARCHITECTURE.md. This is a first pass over the full diff; more detailed passes to follow.

Most of it checks out. Confirmed by reading the code: predict(group=) really does condition on idata[group], a second fit() warns and keeps the prior draws, _clone() carries prior_sample_kwargs, and the new tests do not pin seeds. The migration notes are already written at docs/source/release_notes.md:136, so the unchecked box in the description is out of date.

Two things look worth settling before merge: the misspelled GroupNotSampleedException, which is already exported, and the removed plot guard in causalpy/steps/report.py, which turns a partial report into a crash for two experiments. The rest are small: repeated code, a few missing type hints, and two doc lines that no longer match the code.

Comment thread causalpy/custom_exceptions.py Outdated
Comment thread causalpy/__init__.py Outdated
Comment thread causalpy/tests/test_public_signatures.py Outdated
Comment thread causalpy/experiments/base.py
Comment thread causalpy/experiments/instrumental_variable.py
Comment thread causalpy/experiments/base.py Outdated
Comment thread causalpy/maketables_adapters.py Outdated
Comment thread causalpy/maketables_adapters.py Outdated
Comment thread causalpy/experiments/interrupted_time_series.py
Comment thread causalpy/experiments/synthetic_control.py
@drbenvincent

Copy link
Copy Markdown
Collaborator Author

Third wave handled (4b064b8) — inline replies posted on each thread:

  • prior_sample_kwargs default unpinned → new test pins {"draws": 500, "random_seed": posterior seed} and explicit-override precedence.
  • _clone() dropping the setting untested → tests now prove clone carriage on the base _clone() and on both overriding backends (BBETS, StateSpaceTimeSeries).
  • Second build() with different data silently ignored → chose the raise: every build site (PyMC base, IV, StateSpace, sklearn and pymc-forecast record-only adapters) fingerprints its inputs; a mismatch raises RuntimeError naming the fresh-instance reset path. BaseExperiment.build recomputes and forwards inputs every call so the check sees candidate data. Idempotent for identical inputs.
  • StateSpace refit leaks raw PyMC warningextend_inferencedata=False + explicit group assignment; refit twice under warnings.simplefilter("error") in the new test.

Full suite at HEAD: 2410 passed, 2 skipped; prek run --all-files green.

@drbenvincent

Copy link
Copy Markdown
Collaborator Author

Think that's all comments addressed now @anevolbap. Thanks for this :)

This is a big one, so could do with more critique - maybe @cetagostini or @juanitorduz could unleash an adversarial review :)

@anevolbap

Copy link
Copy Markdown
Collaborator

Re-checked at 4b064b8. Three things left, all still reproducing there.

scenario_counterfactual on PrePostNEGD duplicates scenario_control (prepostnegd.py:212-238). x_pred_counterfactual is x_pred_treated.copy() with the group column zeroed over the same pred_xi grid, which is column-for-column the frame already built as x_pred_untreated. On anova1: scenario_control.inputs.equals(scenario_counterfactual.inputs) is True and the two prediction arrays are identical. Nothing reads the field for PrePostNEGD either (only DiD consumes it, at diff_in_diff.py:504), so the third self._model_backend.predict(...) is an extra posterior-predictive pass per group, twice per fit once the prior auto-fill runs. Would reusing pred_untreated for the counterfactual scenario, rather than sampling it again, satisfy the "every bundle fully populated" contract at zero cost?

An IV refit leaves the predictive groups stale (instrumental_variable.py:267-280, pymc_models.py:1800). _iv_ppc_sampler is now recorded before the built early-return, which fixes the reported direction, but a refit that does not pass ppc_sampler resets it to None. Sequence: fit(ppc_sampler="pymc") then fit(random_seed=99) gives a changed posterior while posterior_predictive is byte-identical to the first run and the first run's prior / prior_predictive are still attached. The refit warning says the previous posterior draws will be replaced, which is true, but the surviving predictive groups no longer match the posterior they were drawn from. test_iv_refit_updates_ppc_sampler pins None then "pymc" and not the reverse. Should a refit drop the stale groups, or carry the previously recorded sampler when the call does not name one?

Two smaller ones. diff_in_diff.py:502-507 still says CoefficientResult.scenario_counterfactual is Optional and keeps the if cf is not None guard, both stale now that the field is required (and if it ever were None, y_pred_counterfactual and x_pred_counterfactual would be unbound below). And for backends with no prior phase, _resolve_group("prior") tells the user to call sample_prior_predictive(), which then raises PriorPredictiveNotSupportedException — the sklearn adapter message at model_adapter.py:913 names that consequence, the base one at base.py:483 does not, and the sklearn-backed PanelRegression path hits the base one.

Nothing here blocks from my side; the second item is the only one I would want either fixed or consciously deferred before merge.

…pc choice, capability-aware prior guard

- PrePostNEGD._finalize reuses the control scenario's prediction for
  scenario_counterfactual: both sweep the same shared pretest grid with
  the group indicator at zero, so the extra predict pass computed an
  identical array twice per group (twice per fit with prior auto-fill).
  Bundle stays fully populated; one posterior-predictive pass saved.
- InstrumentalVariable.fit keeps the previous ppc_sampler when a refit
  omits it: falling back to None left posterior_predictive (and any
  prior-predictive groups from ppc_sampler='pymc') stale against the
  freshly resampled posterior while the warning only spoke about the
  posterior. Explicit None still opts out; the refit test pins all four
  transitions including persistence and explicit opt-back-out.
- BaseExperiment._resolve_group raises PriorPredictiveNotSupportedException
  directly when group='prior' on a backend without the capability,
  mirroring what sample_prior_predictive() would raise instead of
  pointing users at a call that cannot succeed (sklearn-backed
  PanelRegression hit this); guard tests updated accordingly.
- diff_in_diff plot drops the stale Optional comment and dead None guard
  on scenario_counterfactual, required since the fully-populated-bundle
  invariant.
@drbenvincent

Copy link
Copy Markdown
Collaborator Author

All three addressed in 7e1a63c — thanks for the careful re-check at HEAD.

1. PrePostNEGD scenario_counterfactual duplicated scenario_control
You were right on both counts: with both scenarios sweeping the same pred_xi grid and the group indicator zeroed, the counterfactual curve is the control curve, so the third predict() computed an identical array. _finalize now reuses x_pred_untreated / pred_untreated for the counterfactual scenario (with a comment recording why they coincide for this design), keeping the bundle fully populated while dropping the redundant posterior-predictive pass per group.

2. IV refit leaving predictive groups stale
Fixed rather than deferred, via the smaller of the two options: a refit that omits ppc_sampler now keeps the previous choice (previous_ppc = getattr(self.model, "_iv_ppc_sampler", None) in InstrumentalVariable.fit), so fit(ppc_sampler="pymc")fit(random_seed=99) redraws posterior_predictive/prior-predictive groups against the new posterior instead of byte-identical leftovers. Explicit ppc_sampler=None still opts back out. test_iv_refit_updates_ppc_sampler now pins all four transitions: None → "pymc" → "pymc" (persisted) → None (explicit opt-out).

3a. Stale Optional handling in DiD
Removed: the "scenario_counterfactual is Optional" comment and the dead if cf is not None guard in diff_in_diff.py are gone — the field is required since the fully-populated-bundle change.

3b. Capability-blind prior guard message
The base _resolve_group("prior") now raises PriorPredictiveNotSupportedException directly when the backend declares no prior phase ("The {model} backend does not support prior predictive sampling."), mirroring what sample_prior_predictive() would raise — instead of telling users to call something that cannot succeed. The group-not-sampled path is unchanged for capable backends missing draws. Updated the two sklearn-backed PanelRegression guard tests to expect the capability error.

Verification at 7e1a63c: full suite 2410 passed, 2 skipped, prek run --all-files green.


summary = InterruptedTimeSeries._comparison_period_summary(
result,
InterruptedTimeSeries._comparison_period_summary(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test doesn't check anything any more. The return value is thrown away, so it passes no matter what _comparison_period_summary gives back.

Before this PR it pinned two ArviZ 0.22 HDI baselines:

assert tuple(
    summary.table.loc["intervention", ["hdi_lower", "hdi_upper"]]
) == pytest.approx((0.07791736162436769, 2.333331414052698), rel=1e-12, abs=1e-12)
assert tuple(
    summary.table.loc["post_intervention", ["hdi_lower", "hdi_upper"]]
) == pytest.approx((0.05178753076996489, 2.3244471772455273), rel=1e-12, abs=1e-12)

Both are gone now. grep -c assert over the body of the test returns 0.

What that breaks: the test name, the docstring ("retain the ArviZ 0.22 94% baseline") and the comment a few lines up ("the frozen expectations below") all still say the baseline is pinned, but nothing checks it. So anyone auditing acceptance criterion 7 by reading test names would think this is still covered when it isn't, and a real HDI change would go through quietly.

Worth saying: as far as I can tell this is the only test in the whole diff that lost expected values. I looked at every numeric literal inside an assert across all 37 changed test files and found no loosened tolerances, no changed seeds and no new xfail/skip. So the rest of the AC7 parity argument holds up fine, which is why this one stands out.

Suggested fix: keep the new bundle-shaped stub, put summary = back on the call, and re-add the two pytest.approx asserts. If the seeded draw order really did change (the comment about the intervention slice consuming the first draws suggests it did), just recompute the four numbers and pin the new ones instead. Either way, better than shipping it with no assertions.

@@ -0,0 +1,721 @@
# Copyright 2022 - 2026 The PyMC Labs Developers

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file never opts out of the session-wide mock_pymc_sample fixture from conftest.py:61, so in CI every .fit() here runs against a mocked pm.sample. test_experiment_lifecycle.py:34, test_parameter_recovery.py and test_pymc_forecast_adapter.py all add a fixture to undo it — this one doesn't.

Easy to see:

test_prior_phase.py alone                     -> 43 passed in 56.62s   (real MCMC)
test_default_models.py + test_prior_phase.py  -> 54 passed in  8.81s   (mocked)

What that breaks: in the one file about prior vs posterior contracts, the "posterior" in CI is prior sampling. It also makes the file's behaviour depend on test order, and pytest-randomly changes that every run.

Fix: add the same opt-out fixture you used in test_experiment_lifecycle.py, or apply mock_sample per test and keep real sampling only for the two property tests that need it.

try:
return experiment.result
except (GroupNotSampledException, NotImplementedError, AttributeError):
return None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Catching GroupNotSampledException here turns a clear error into a confusing one:

ETable(fitted)   -> OK
ETable(unfitted) -> TypeError: No extractor available for model type: InterruptedTimeSeries

The user just forgot .fit(), but the message says the plugin doesn't support their experiment.

This is the thing #1092 wrote a whole rejected-design row about — it refused sklearn.NotFittedError so guards wouldn't get swallowed into a None in these hooks. The PR avoided the inheritance and then got the same outcome by catching the new exception on purpose.

Fix: let GroupNotSampledException through and keep AttributeError for the duck-typed stubs. test_maketables_plugin.py has no unfitted-state test, so nothing catches this today either.

Comment thread causalpy/reporting.py
if window.stop is not None
else post_index.max()
)
window_coords = post_index[(post_index >= start) & (post_index <= stop)]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch drops slice.step, while the integer branch at :773 keeps it (post_index[mask][::step]). On a 6-month DatetimeIndex:

slice(None, None, 2)   before -> 3 timestamps
                       now    -> all 6            <- silent wrong answer
slice(0, 3)            before -> first 3
                       now    -> ValueError: Window contains no time points

Base used result.datapost.index[window], which was positional and handled both.

Reachable from effect_summary(window=slice(...)) on ITS, SC and PiecewiseITS. No test covers a datetime slice — test_reporting.py:238 and :2311 both use integer indexes.

Fix: apply [::step] here too, and either keep positional behaviour for integer bounds on a DatetimeIndex or reject them clearly instead of turning 0 into 1970-01-01.

# Point counterfactual predictions per observation; treatment
# effects are observed outcome minus prediction.
y_hat0 = y_pred.mean(dim=["chain", "draw"]).isel(treated_units=0).values
treated_positions = self.data.index.get_indexer(treated_data.index)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_indexer needs a unique index. Base did self.data["y_hat0"] = ....values then a boolean .loc, which was fine with duplicates.

OLS, unique RangeIndex  -> OK, att(e=0)=2.0334
OLS, duplicated index   -> InvalidIndexError: Reindexing only valid with uniquely valued Index objects

Long panels built with pd.concat and no ignore_index=True hit this straight away, and to_pandas() doesn't reset the index.

Same pattern at :714 and :1329.

Fix: go back to positional masks, e.g. np.flatnonzero(~self.data["_is_untreated"].to_numpy()), and drop the label .loc lookups on _observed_outcome.

For context, the Bayesian path at :588 was already index-fragile, so the class always wanted a positional index — but this removes the one branch that coped.

assert re.search(r"^Other Parameters\n-+$", doc, flags=re.MULTILINE)
assert re.search(
rf"^\*\*{re.escape(expected_parameter)}\s*$", doc, flags=re.MULTILINE
# Every forwarder documents its kwargs contract under a uniform

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This swaps two structural checks for one substring check. The numpydoc header with its underline, and the actual **kwargs entry, are no longer verified — a docstring that just mentions the words in prose now passes.

I ran the old strict assertions against all 9 exemptions on this branch: 0 failures. So nothing needed loosening, and the bar dropped right as 7 new entries were added to this same test.

Fix: put the two regexes back.

np.testing.assert_allclose(bundle.impact_post.to_numpy()[..., 0], expected_post)


class TestInputValidation:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test_datetime_index_requires_timestamp_treatment_time and test_int_index_rejects_timestamp_treatment_time were dropped here. I ran both against this branch and they still pass unchanged, and SyntheticDifferenceInDifferences.input_validation is still there (synthetic_difference_in_differences.py:180).

The class docstring still says "Both BadIndexException branches in input_validation" but the class now has neither.

Fix: restore both, or drop the docstring claim. The shared helper is covered at test_pandas_compat.py:356, so what's actually lost is the pin that SDID wires to it.

Comment thread causalpy/pymc_models.py
#: a follow-up (issue #1092).
supports_prior_predictive = False

def __init__(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This __init__ never got prior_sample_kwargs, so the inherited _clone() passes an argument it can't take and raises TypeError.

PyMCModel._clone (:308), BBETS (:2295) and StateSpaceTimeSeries (:2768) all carry it — this one is the gap.

That breaks acceptance criterion 4 for IV, and it matters in practice because checks/base.py:clone_model is what prior_sensitivity.py uses, so any check cloning an IV model fails.

Fix: add prior_sample_kwargs to the signature and pass it through to super().__init__.

min_effect=min_effect,
group=group,
)
else:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the has_posterior_draws() false branch this passes self instead of the resolved bundle, and the helper then reads self.result — so group is ignored.

sample_prior_predictive(draws=1) is a documented per-call override and it flips that condition on a real Bayesian model:

fitted   + prior draws=1 -> ValueError: cannot reshape array of size 200 into shape ()
unfitted + prior draws=1 -> GroupNotSampledException: ...Call RegressionDiscontinuity.fit() first

The second is the worse one: you asked for the prior and it tells you to fit.

The group-aware version already exists — reporting.py:622-658 has a full fallback behind an experiment=None sentinel that nothing in the library ever passes.

Fix: route through it, e.g. _effect_summary_rd(bundle, ..., experiment=self, group=group). Same shape at diff_in_diff.py:793 and prepostnegd.py:571.

figsize : tuple of (float, float), optional
Width and height of the figure in inches. Defaults to ``(7, 8)``.
"""
bundle = self._require_bundle(group)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The group == "prior" early return happens before the treated_unit resolve/validate block at :629-635, so the prior plot always draws unit 0 and never validates the name:

plot(group="prior", treated_unit="actual2")    -> draws "actual" (range -0.08..29.81, not 29.97..41.93)
plot(group="prior", treated_unit="NOT_A_UNIT") -> no error

get_plot_data(group="prior") does honour treated_unit (:921), so the two prior readers disagree.

Fix: move the resolve/validate block above the early return and pass the unit into _plot_prior_checks, replacing the hardcoded isel(treated_units=0) at :833.

post_y = self.post_design["y"].isel(treated_units=0)

fig, ax = plt.subplots(1, 1, figsize=(7, 4))
style: _PosteriorPlotStyle = {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_plot_prior_checks hardcodes its style dict and figsize and takes no kwargs, so everything plot() accepted gets dropped:

plot(group="prior", ci_prob=0.10, kind="spaghetti", num_samples=3, figsize=(3,3))
-> same ribbon vertices as the bare call, figsize (7.0, 4.0)

ci_prob is the one that misleads — you ask for a 10% band and quietly get a 94% HDI.

Same in 8 other files: synthetic_control.py:839, piecewise_its.py:716, diff_in_diff.py:685, prepostnegd.py:475, regression_discontinuity.py:625, regression_kink.py:492, synthetic_difference_in_differences.py:886. #1092 asked for a reduced panel set, not for the knobs to stop working.

Fix: forward ci_prob/kind/ci_kind/num_samples/figsize, or raise on the ones the reduced layout can't honour.

Comment thread causalpy/reporting.py
)


def _apply_prior_grouping(text: str, group: Literal["prior", "posterior"]) -> str:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only adds a prefix — the sentence inside still says "posterior". Real output for group="prior" on a DiD:

Prior predictive check (not a causal estimate): The average treatment effect was 16.20
(95% HDI [-88.60, 84.70]). The posterior probability of an increase is 0.520.

That last number is the sign-neutrality check #1092 calls the most valuable prior diagnostic, and it's labelled "posterior".

The string is hardcoded at :284 and :290 in _render_bayesian_decision, which never sees group.

Fix: pass group into _render_bayesian_decision and say "prior probability" for the prior group.

dim="obs_ind"
)

def _comparison_period_summary(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_comparison_period_summary has no group parameter and never calls _apply_prior_grouping, unlike every other summary path. On a three-period ITS after sample_prior_predictive() only:

period="intervention" -> Prior predictive check (not a causal estimate): During the ...
period="post"         -> Prior predictive check (not a causal estimate): During the ...
period="comparison"   -> Effect persistence: The post-intervention effect (-429.4, 95% HDI [-9435.7, 9209.5])

The third reads like a real persistence finding, built from prior draws, with a ±9400 interval.

Fix: add group to the signature, pass it from the call site at :1322, and wrap both prose branches (:518 and :525) in _apply_prior_grouping.

"first.",
group="prior",
)
return None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PriorPredictiveNotSupportedException pre-check only lives in the not self._supports_results branch above (:471-489), so the nine bundle-backed experiments send sklearn users to a dead end:

RD + sklearn: plot(group="prior")       -> GroupNotSampledException: ...Call RegressionDiscontinuity.sample_prior_predictive() first
              sample_prior_predictive() -> PriorPredictiveNotSupportedException

PanelRegression (bundle-less, same backend) correctly raises the capability error straight away.

That undoes the reason #1092 gives for having two exception types: "you skipped a step" vs "this backend can never do this".

Fix: hoist the capability check above the _supports_results split. The prior_result property at :295 needs the same pre-check.

for this call only.

"""
self.build()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the auto prior phase at :419-423 raises, fit() propagates but the object is already fully fitted. Patching the prior phase to fail:

fit() raised: ValueError -> prior spec is broken
sample_posterior calls = 1     <- MCMC already paid for
exp.is_fitted -> True
exp.result    -> CausalResult
exp.plot()    -> works

So a caller's except sees failure while the object says success, and you only find out the prior is broken after the expensive part. Putting the prior first was #1092's way of avoiding the second half of that.

Fix: either make a failure in the auto prior fill warn instead of raising out of fit(), or roll _result back before re-raising.

identity is the model instance, so stale draws would be incoherent.
This assignment is the documented prior-revision mechanism.
"""
self._model_backend = make_model_adapter(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This clears _result and _prior_result but not idata — it assumes the incoming model is fresh. With a used instance:

exp.model = <already-fitted model>
exp.is_fitted -> False
exp.idata     -> still has all 7 groups

So is_fitted says no and .idata says yes. The documented path is fine — I checked, assigning a brand new model does set idata to None — this is only the reuse case.

Fix: null the backend's idata in the setter instead of trusting the argument.

Builds the graph (idempotent), samples NUTS plus posterior predictive
draws, then — when the backend supports a prior phase and no prior
state exists yet — fills the prior groups and :attr:`prior_result` so
``idata`` is as complete as the historical eager fit produced. The

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No argument with the order itself — but this reason isn't the real one, and as written it will stop someone touching it later for the wrong cause.

_rearm_fit_data() is the first thing both sample_prior_predictive (pymc_models.py:506) and sample_posterior (:545) do, so node mutation can't reach either sampler and order can't affect correctness. Checked both ways on the shipped tree:

prior-first vs posterior-first, seeded -> max |diff| = 0.0  (identical)

The other reason you gave in the review thread — RNG-stream parity with the eager baseline — is the one that actually holds:

unseeded -> max |diff| = 14.44

Fix: say it keeps the RNG stream identical to the eager baseline so the unchanged integration suite stays a valid parity test, and state the real invariant (_rearm_fit_data() runs before every phase). Same wording in ARCHITECTURE.md and the #1092 comment.

"""Run the experiment algorithm: fit OLS, 2SLS, and Bayesian IV model."""
self.get_naive_OLS_fit()
self.get_2SLS_fit()
def build(self) -> Self:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason in this docstring isn't true any more. This PR added a non-sampling InstrumentalVariableRegression.build() at pymc_models.py:1747, and :275-288 here already calls model.build(...) and model.sample_posterior(...) as two separate steps — so the graph isn't fused into fit.

Calling it by hand works:

iv.model.build(X=..., Z=..., y=..., t=..., coords=..., priors=..., ppc_sampler=None)
-> basic_RVs ['beta_t','beta_z','chol_cov','likelihood'], idata None, graphviz populated, is_built True

So the AC14 waiver looks like a leftover rather than a real limit, and right now exp.build() quietly gives an empty graph.

Fix: delegate to self.model.build(...) with ppc_sampler=None, then drop the waiver from the docstring and ARCHITECTURE.md.

group: Literal["prior", "posterior"] = "posterior",
round_to: int | None = None,
ci_prob: float = HDI_PROB,
kind: Literal["ribbon", "histogram", "spaghetti"] = "ribbon",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This advice is copied from the coefficient experiments and doesn't hold here. SDID's summary runs on impact_post = observed minus prior synthetic, whose level comes from the omega0 prior and the control panel, not a symmetric contrast. On a correct model with default priors:

SDID: p_gt_0 = 0.383, P(tau>0) = 0.263
DiD 0.548 | PrePostNEGD 0.537 | RD 0.515 | RK 0.525

So users will read a perfectly normal SDID prior as a design-matrix bug.

Fix: keep the sign-neutrality wording on the experiments where it works, and say something here about whether the prior synthetic trajectory looks plausible instead.

# path: store canonical in-sample predictions at fit time.
if self._model_backend.is_bayesian:
# PanelRegression is posterior-only; no group= phase.
mu = self._model_backend.require_idata().posterior["mu"]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the exact line #1092 names in its .posterior sweep, and it's unchanged from base (:656 there). get_plot_data still has no group=, same at plot_trajectories :898. The other four all took it: synthetic_control.py:879, interrupted_time_series.py:983, piecewise_its.py:758, staggered_did.py:1478.

The comment "PanelRegression is posterior-only; no group= phase" also argues with the plot() docstring ~40 lines up, which documents a working prior phase — and 57531220 made that prior plot real.

mu is present in the prior group for LinearRegression, so it's a small change.

Fix: add keyword-only group, read require_idata()[group], guard via _resolve_group(group). If it's staying posterior-only on purpose, worth saying so in the PR body rather than in a comment that contradicts its neighbour.

Comment thread ARCHITECTURE.md
| **HDI_PROB** | Project default is 0.94 (ArviZ default), not 0.95. |
| **create_causalpy_compatible_class** | Applied during `make_model_adapter()` for sklearn backends; clones the user instance before patching. |

## Adding New Code

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The HDI_PROB row got dropped from this table in the rewrite. Base had:

| **HDI_PROB** | Project default is 0.94 (ArviZ default), not 0.95. |

That's still true, still surprising (most people assume 0.95), and this was the only place it was written down. Looks like collateral from removing the "Eager fitting" row right next to it.

Fix: put the row back.

)

def effect_summary(self) -> NoReturn:
def effect_summary(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

effect_summary took group= here "for base-contract parity", but plot() at :361 didn't:

iv.plot(group="prior") -> TypeError: InstrumentalVariable.plot() got an unexpected keyword argument 'group'

Same in inverse_propensity_weighting.py:545. #1092 says every experiment's read methods gain a keyword-only group, so anyone looping over all twelve gets a TypeError from two of them.

Fix: add group= to both plot() stubs, for the same parity reason already accepted here.

return self._model_backend.is_built or self.is_fitted

@property
def is_fitted(self) -> bool:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the three bundle-less experiments this reads idata off the model instance, not off the experiment, so fitted state travels with a shared model. A second experiment constructed with an already-fitted model never fits anything and still answers:

e2.is_fitted           -> True
e1 y_fitted[:3]        -> [-0.3208 -0.0317  0.3118]
e2 y_fitted[:3]        -> [-0.3208 -0.0317  0.3118]   identical
e2 residual magnitude  -> 100.0     (d2 is d1 with y + 100)

get_plot_data() pairs the second dataset's y_actual with the first experiment's y_fitted, and summary() prints the first experiment's coefficients. No warning, no exception. At a2f1302 PanelRegression.__init__ refit the shared model, so the numbers were right; the lazy lifecycle removed the refit and left the predicate keyed on the shared object.

This is next to the model-setter case in the other thread but reaches through construction, so nulling idata in the setter would not close it.

Would a per-experiment flag set in BaseExperiment.fit() work here, with has_prior_predictive given the same treatment? The alternative is rejecting a model that already carries draws at construction, the way checks/ do with clone_model.

Comment thread causalpy/pymc_models.py
Reserved for future non-centred parameterisations of the
coefficient prior. Currently informational only.
"""
if self._built:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the only build() in the file without the input-fingerprint check: bare if self._built: return, and _built_input_fingerprint is never assigned. PyMCModel.build, InstrumentalVariableRegression.build, StateSpaceTimeSeries.build and both record-only adapters all compare and raise.

BaseExperiment.build's docstring promises that RuntimeError, so the guarantee is a no-op for exactly one experiment. It bites harder here than elsewhere because the IPW estimand is computed outside the graph, from self.X, so the propensity draws come from one dataset and the weighting from the other:

e1 = InversePropensityWeighting(df1, ..., model=m).fit()
e2 = InversePropensityWeighting(df2, ..., model=m).fit()   # df2 is a 783-row subset

e2 rows              -> 783
e2 posterior         -> p_dim_0: 1566        e1's design
same pattern on DiD  -> RuntimeError: This model is already built with different inputs
at a2f13029          -> ValueError: Variable name X already exists

So a reused instance went from failing loudly to returning an ATE for the wrong dataset.

Would mirroring the sibling overrides be enough: compute the fingerprint before the early return, raise on mismatch, assign it next to self._built = True? test_propensity_score_build_is_idempotent covers only the same-inputs no-op, so a changed-input case would pin it.

@anevolbap

anevolbap commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Last pass from my side, with Opus 5:

From-scratch pass over the whole diff at 7e1a63c. The inline comments already on it cover the RD prior dispatch, the dropped slice.step, the IV _clone signature, the treated_unit and styling losses in prior plots, the comparison-period prose, the model-setter idata case and the weakened signature test, so this carries only what is not there yet. Two wrong-number bugs are in their own threads on base.py:259 and pymc_models.py:1999. Everything below was run, and checked against a2f1302 where the question was whether it is a regression.

The has_posterior_draws dispatch has more sites than the RD thread. diff_in_diff.py:794 and prepostnegd.py:575 share it. With a 1-draw prior, DiD on a fitted experiment raises ValueError: cannot reshape array of size 30 into shape () and PrePostNEGD raises NotImplementedError: Not implemented for OLS model on a PyMC backend. Gating the OLS fallback on the backend rather than on container size covers all three.

build() does not detect mutated exp.data (base.py:318-330). The docstring and its Raises entry promise RuntimeError when "the experiment's data changed since it was built", but every _fit_inputs() returns a design frozen at construction, so mutating exp.data and rebuilding is silently accepted on ITS and DiD alike. What the fingerprint actually catches is a model instance reused across experiments. Is the docstring the thing to correct?

Release note names the wrong experiment (docs/source/release_notes.md:156). The sentence holds for SDID (supports_ols = True, constructs and fails at build()), but PrePostNEGD.supports_ols is False, so it still raises ValueError: OLS models not supported. from __init__. My earlier thread asserted the same thing and was wrong; the note inherited it.

Shipped text still teaches the eager API. Seven files under causalpy/skills/running-causalpy-experiments/reference/ construct an experiment with no .fit() and then call result.summary(), which now raises GroupNotSampledException; only three reference files were touched here, and these ship inside the wheel. analyze_persistence (interrupted_time_series.py:1111) and extract_lift_for_mmm (utils.py:380) have the same shape, with the failing call behind # doctest: +SKIP, so make doctest stays green while the copied example raises. Five markdown cells in docs/source/notebooks/staggered-difference-in-differences-pymc.ipynb still document att_group_time_ / att_event_time_; markdown is not executed, so the notebook lane cannot catch it.

Prior-group draw values are asserted nowhere, only their presence, which is the other half of the mocked-sampler point. Three mutations leave all 2394 tests passing here: hardcoding group="posterior" inside SyntheticControl._finalize, flipping the kwargs precedence in PyMCModel.sample_posterior to {**kwargs, **self.sample_kwargs} so fit(draws=N) is silently ignored on the base path, and deleting _rearm_fit_data() from PyMCModel.sample_prior_predictive. A fourth, making coefficients() ignore its group, is caught. Asserting that the prior container's draw size matches prior_sample_kwargs and differs from the posterior kills the first three.

Smaller things:

  • Refit leaks a raw PyMC warning naming extend_inferencedata=False, which no user can reach (pymc_models.py:558). sample_prior_predictive above it already assigns groups explicitly, and StateSpaceTimeSeries got this fix in 4b064b8. Reproduced on DiD and IV.
  • InstrumentalVariable.fit(ppc_sampler=None) after a predictive fit leaves posterior_predictive at 20 draws against a 25-draw posterior, byte-identical to the previous run (instrumental_variable.py:274). The persisted-sampler path added in 7e1a63c works; this is the explicit opt-out.
  • _design_fingerprint drops xarray coordinates (utils.py:34), so two designs with the same values and time indexes a year apart fingerprint identically.
  • PyMCModel defines fit_mapping but no build_mapping, while the adapter's build path calls self._model.build_mapping(...) (model_adapter.py:496); only SyntheticDifferenceInDifferencesWeightFitter defines it, so a custom mapping-input model hits AttributeError.
  • __init__ builds the adapter twice: self._model_backend = adapter is immediately overwritten by the self.model = ... setter, so make_model_adapter and _prepare_sklearn_model each run twice per construction (base.py:187-199).
  • is_built's or self.is_fitted never changes the answer in any state I could reach (base.py:256).
  • StaggeredDifferenceInDifferences has a malformed numpydoc Attributes section rendering five attributes that do not exist (staggered_did.py:87).

Checked and clean: no _finalize, _fit_inputs or read method assigns draw-derived state to self; prior-group plot / effect_summary / get_plot_data work on every experiment with a prior phase; doctests pass; check_public_exports and check_architecture_inventory are clean; the suite is green here (2394 passed, 18 skipped); idata group sets and wall-clock match the pre-PR eager path.

One aside, not this PR: bandwidth=float("inf") on RD/RK produces an all-NaN prediction grid because the guard is is not np.inf, an identity test, and the default works only because it is the same object. Reproduces at a2f1302.

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.

3 participants