# Copyright 2022 - 2026 The PyMC Labs Developers
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Base class for quasi experimental designs.
"""
from __future__ import annotations
import logging
import warnings
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Any, Literal, Self
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import xarray as xr
from sklearn.base import RegressorMixin
from causalpy.custom_exceptions import (
GroupNotSampledException,
PriorPredictiveNotSupportedException,
)
from causalpy.experiments._results import ResultBundle
from causalpy.experiments.model_adapter import ModelAdapter, make_model_adapter
from causalpy.maketables_adapters import coefficient_table, get_maketables_adapter
from causalpy.pymc_forecast_models import PyMCForecastModel
from causalpy.pymc_models import PyMCModel
from causalpy.reporting import EffectSummary
logger = logging.getLogger(__name__)
def _apply_legend_kwargs(legend: Any, kwargs: dict[str, Any]) -> None:
"""Mutate an existing Legend in place without recreating it.
This preserves custom handles (e.g. ``(Line2D, PolyCollection)`` tuples
built by :func:`~causalpy.plot_utils.plot_posterior_over_x` with
``kind="ribbon"``) that would be lost if the legend were rebuilt with
``ax.legend()``.
Supported keys: ``loc``, ``bbox_to_anchor``, ``bbox_transform`` (only
with ``bbox_to_anchor``), ``fontsize``, ``frameon``, ``title``.
Raises
------
TypeError
If *kwargs* contains keys that cannot be applied in place.
"""
_SUPPORTED = {
"loc",
"bbox_to_anchor",
"bbox_transform",
"fontsize",
"frameon",
"title",
}
unsupported = set(kwargs) - _SUPPORTED
if unsupported:
raise TypeError(
f"legend_kwargs keys not supported for in-place mutation: "
f"{sorted(unsupported)}. Supported keys: {sorted(_SUPPORTED)}"
)
if "bbox_transform" in kwargs and "bbox_to_anchor" not in kwargs:
raise TypeError(
"bbox_transform requires bbox_to_anchor to be specified as well"
)
if "loc" in kwargs:
loc = kwargs["loc"]
# set_loc is public in matplotlib >= 3.8; fall back to the stable
# private helper for older versions, converting string names to
# numeric codes since _set_loc may not accept strings.
if hasattr(legend, "set_loc"):
legend.set_loc(loc)
else:
if isinstance(loc, str): # pragma: no cover
loc = legend.codes.get(loc, loc)
legend._set_loc(loc) # pragma: no cover
if "bbox_to_anchor" in kwargs:
legend.set_bbox_to_anchor(
kwargs["bbox_to_anchor"], kwargs.get("bbox_transform")
)
if "fontsize" in kwargs:
for text in legend.get_texts():
text.set_fontsize(kwargs["fontsize"])
if "frameon" in kwargs:
legend.set_frame_on(kwargs["frameon"])
if "title" in kwargs:
legend.set_title(kwargs["title"])
[docs]
class BaseExperiment[ResultT: ResultBundle](ABC):
"""Base class for quasi experimental designs.
Subclasses should set ``_default_model_class`` to a PyMC model class
(e.g. ``LinearRegression``) so that ``model=None`` instantiates a sensible
Bayesian default. To use an OLS/sklearn model — or, for experiments that
declare ``supports_pymc_forecast``, a
:class:`~causalpy.pymc_forecast_models.PyMCForecastModel` — pass one
explicitly.
Parameters
----------
model : PyMCModel, RegressorMixin, PyMCForecastModel, or None, default None
Model instance to use. If ``None`` and ``_default_model_class`` is set,
an instance of that default class is constructed.
Notes
-----
Optional ``maketables`` integration is exposed through ``__maketables_*``
hooks. Users can control the HDI interval level used by
``ETable(result)`` via :meth:`set_maketables_options`, for example:
``result.set_maketables_options(hdi_prob=0.95)``.
"""
labels: list[str]
data: pd.DataFrame
supports_bayes: bool
supports_ols: bool
supports_pymc_forecast: bool = False
_default_model_class: type[PyMCModel] | None = None
@staticmethod
def _build_design_dataset(
X_raw: np.ndarray,
y_raw: np.ndarray,
*,
obs_ind: np.ndarray | pd.Index,
coeffs: list[str],
treated_units: list[str] | None = None,
) -> xr.Dataset:
"""Build a standard ``xr.Dataset`` from raw design matrices.
Parameters
----------
X_raw : np.ndarray
Predictor matrix, shape ``(n_obs, n_coeffs)``.
y_raw : np.ndarray
Outcome matrix, shape ``(n_obs, n_units)``.
obs_ind : array-like
Observation index coordinates.
coeffs : list[str]
Coefficient / column names for ``X_raw``.
treated_units : list[str], optional
Names for the treated-unit dimension of ``y_raw``.
Defaults to ``["unit_0"]``.
"""
if treated_units is None:
treated_units = ["unit_0"]
return xr.Dataset(
{
"X": xr.DataArray(
X_raw,
dims=["obs_ind", "coeffs"],
coords={"obs_ind": obs_ind, "coeffs": coeffs},
),
"y": xr.DataArray(
y_raw,
dims=["obs_ind", "treated_units"],
coords={"obs_ind": obs_ind, "treated_units": treated_units},
),
}
)
_model_backend: ModelAdapter
#: Whether this experiment produces grouped result bundles. Experiments
#: that store no draw-derived state (IV, IPW, PanelRegression) set False
#: and key their fitted-state off the backend instead.
_supports_results: bool = True
[docs]
def __init__(
self, model: PyMCModel | RegressorMixin | PyMCForecastModel | None = None
) -> None:
adapter = make_model_adapter(
model,
default_model_class=self._default_model_class,
supports_bayes=self.supports_bayes,
supports_ols=self.supports_ols,
supports_pymc_forecast=self.supports_pymc_forecast,
)
self._model_backend = adapter
self.model = adapter.model
self._result: ResultT | None = None
self._prior_result: ResultT | None = None
@property
def model(self) -> PyMCModel | RegressorMixin | PyMCForecastModel:
"""The underlying model instance.
Assigning a new model is the documented reset: it swaps in the fresh
instance and clears ``idata``, ``result``, and ``prior_result``,
because graph identity *is* the model instance. Prior revision works
through assignment (``exp.model = Model(priors={...})``); there is no
``set_priors()``.
"""
return self._model_backend.model
@model.setter
def model(self, value: PyMCModel | RegressorMixin | PyMCForecastModel) -> None:
"""Install *value* as the backend model and reset all lifecycle state.
Parameters
----------
value : PyMCModel, RegressorMixin, or PyMCForecastModel
The new backend model instance.
Notes
-----
The swap clears ``idata``, ``result``, and ``prior_result``: graph
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(
value,
default_model_class=self._default_model_class,
supports_bayes=self.supports_bayes,
supports_ols=self.supports_ols,
supports_pymc_forecast=self.supports_pymc_forecast,
)
self._result = None
self._prior_result = None
@property
def idata(self) -> xr.DataTree | None:
"""Return fitted DataTree when the model backend supports it."""
return self._model_backend.idata
@property
def is_configured(self) -> bool:
"""Whether construction succeeded: design matrices are ready.
Always ``True`` on a successfully constructed experiment; sampling has
not happened unless the other lifecycle predicates say so.
"""
return True
@property
def is_built(self) -> bool:
"""Whether the model graph / fit design exists (no draws implied)."""
return self._model_backend.is_built or self.is_fitted
@property
def is_fitted(self) -> bool:
"""Whether posterior draws and the posterior result bundle exist."""
if self._supports_results:
return self._result is not None
return self._model_backend.has_posterior
@property
def has_prior_predictive(self) -> bool:
"""Whether the prior phase has run (draws, and bundle where kept)."""
if self._supports_results:
return self._prior_result is not None
# Bundle-less experiments key off the backend's idata groups, but a
# backend that cannot run the phase at all must never report True —
# e.g. IV with ppc_sampler="pymc" incidentally writes a prior group.
return (
self._model_backend.supports_prior_predictive
and self._model_backend.has_prior
)
@property
def result(self) -> ResultT:
"""Posterior-group result bundle; raises before :meth:`fit`."""
if not self._supports_results:
raise NotImplementedError(
f"{type(self).__name__} does not produce a grouped result "
"bundle; inspect the backend draws via .idata instead."
)
if self._result is None:
raise GroupNotSampledException(
f"No posterior draws are available. Call "
f"{type(self).__name__}.fit() first.",
group="posterior",
)
return self._result
@property
def prior_result(self) -> ResultT:
"""Prior-group result bundle; raises before prior sampling."""
if not self._supports_results:
raise NotImplementedError(
f"{type(self).__name__} does not produce a grouped result "
"bundle; inspect the backend draws via .idata instead."
)
if self._prior_result is None:
raise GroupNotSampledException(
f"No prior predictive draws are available. Call "
f"{type(self).__name__}.sample_prior_predictive() first.",
group="prior",
)
return self._prior_result
[docs]
def build(self) -> Self:
"""Construct the model graph without sampling anything.
Public, idempotent, and auto-called by both sampling verbs, so users
never have to call it — but calling it explicitly makes the spec
inspectable (``pm.model_to_graphviz(exp.model)``,
``exp.model.basic_RVs``, merged priors) before any compute is spent.
The inputs are recomputed from the experiment's data on every call:
for an already-built graph they are verified against the build-time
fingerprint, so mutating ``exp.data`` and rebuilding raises
:class:`RuntimeError` instead of silently keeping a stale graph. The
documented reset path is assigning a fresh model instance.
Returns
-------
Self
The same experiment, for chaining.
Raises
------
RuntimeError
If the graph exists and the experiment's data changed since it
was built.
"""
X, y, coords = self._fit_inputs()
self._model_backend.build(X=X, y=y, coords=coords)
return self
[docs]
def sample_prior_predictive(self, **kwargs: Any) -> Self:
"""Run the optional prior phase and populate :attr:`prior_result`.
Keyword arguments override ``model.prior_sample_kwargs`` for this call
only. Re-running overwrites the previous prior groups and
``prior_result`` without touching any posterior state.
Returns
-------
Self
The same experiment, for chaining.
Other Parameters
----------------
**kwargs
Forwarded to :func:`pymc.sample_prior_predictive`, overriding
the model's stored ``prior_sample_kwargs`` for this call only.
Raises
------
PriorPredictiveNotSupportedException
If the model backend declares no prior predictive capability.
"""
if not self._model_backend.supports_prior_predictive:
raise PriorPredictiveNotSupportedException(
f"The {type(self.model).__name__} backend does not support "
"prior predictive sampling."
)
self.build()
resolved = {
**getattr(self.model, "prior_sample_kwargs", {}),
**kwargs,
}
self._model_backend.sample_prior_predictive(**resolved)
if self._supports_results:
self._finalize("prior")
return self
[docs]
def fit(self, **kwargs: Any) -> Self:
"""Run the posterior phase and populate :attr:`result`.
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
posterior runs FIRST because forward-sampling machinery conditions
through the graph's mutable data nodes; re-arming them for every
sampling call keeps each phase's draws computed from the right design.
Standalone prior checks stay cheap: call
:meth:`sample_prior_predictive` directly before :meth:`fit`.
Re-running overwrites posterior state only and warns; prior state is
preserved.
Returns
-------
Self
The same experiment, for chaining. This turns every pre-1.0 call
site into a one-token migration:
``cp.InterruptedTimeSeries(...).fit()``.
Other Parameters
----------------
**kwargs
Forwarded to the posterior sampler (:func:`pymc.sample` for
PyMC backends), overriding the model's stored ``sample_kwargs``
for this call only.
"""
self.build()
if self._model_backend.has_posterior:
warnings.warn(
f"Refitting {type(self).__name__}: the previous posterior "
"draws will be replaced. Prior-phase state, if any, is "
"preserved.",
UserWarning,
stacklevel=2,
)
self._model_backend.sample_posterior(**kwargs)
if self._supports_results:
self._finalize("posterior")
if (
self._model_backend.supports_prior_predictive
and not self.has_prior_predictive
):
self.sample_prior_predictive()
return self
def _fit_inputs(self) -> tuple[Any, Any, dict[str, Any] | None]:
"""Return ``(X, y, coords)`` handed to the backend at build time.
Not ``@abstractmethod``: ``InstrumentalVariable`` and
``InversePropensityWeighting`` legitimately bypass the standard
build/``_finalize`` pipeline and override the lifecycle verbs
instead. Ordinary subclasses must implement it (see
``ARCHITECTURE.md``); a missing implementation still fails fast at
the first sampling call with a message naming what to implement.
"""
raise NotImplementedError(
f"{type(self).__name__} must implement _fit_inputs() or override "
"the lifecycle verbs."
)
def _finalize(self, group: Literal["prior", "posterior"]) -> None:
"""Compute the group's result bundle from its draws and assign it
through :meth:`_assign_bundle`.
Not ``@abstractmethod`` for the same reason as :meth:`_fit_inputs`.
"""
raise NotImplementedError(
f"{type(self).__name__} must implement _finalize(group)."
)
def _assign_bundle(
self, group: Literal["prior", "posterior"], bundle: ResultT
) -> None:
"""Store *bundle* in the slot backing *group*'s raising property."""
if group == "prior":
self._prior_result = bundle
else:
self._result = bundle
def _resolve_group(self, group: Literal["prior", "posterior"]) -> ResultT | None:
"""Guard and resolve the read-method draw group.
Raises :class:`~causalpy.custom_exceptions.GroupNotSampledException`
naming the missing lifecycle verb; returns ``None`` for experiments
without result bundles (their read methods guard on fitted state
instead).
Parameters
----------
group : {"prior", "posterior"}
Requested draw group.
"""
if group not in ("prior", "posterior"):
raise ValueError(f"group must be 'prior' or 'posterior', got {group!r}")
if not self._supports_results:
# Experiments without bundles still honor the requested group:
# "prior" keys off the backend's prior draws so a prior-only
# phase is readable without a fit, never silently treated as
# posterior (issue #1092: no smart inference of group).
if group == "prior":
if not self.has_prior_predictive:
if not self._model_backend.supports_prior_predictive:
# Mirror what sample_prior_predictive() would raise,
# instead of pointing the user at a call that cannot
# succeed on this backend.
raise PriorPredictiveNotSupportedException(
f"The {type(self.model).__name__} backend does "
"not support prior predictive sampling."
)
raise GroupNotSampledException(
f"No prior predictive draws are available. Call "
f"{type(self).__name__}.sample_prior_predictive() "
"first.",
group="prior",
)
return None
if not self.is_fitted:
raise GroupNotSampledException(
f"No posterior draws are available. Call "
f"{type(self).__name__}.fit() first.",
group="posterior",
)
return None
if group == "prior":
if self._prior_result is None:
raise GroupNotSampledException(
f"No prior predictive draws are available. Call "
f"{type(self).__name__}.sample_prior_predictive() first.",
group="prior",
)
return self._prior_result
if self._result is None:
raise GroupNotSampledException(
f"No posterior draws are available. Call "
f"{type(self).__name__}.fit() first.",
group="posterior",
)
return self._result
def _require_bundle(self, group: Literal["prior", "posterior"]) -> ResultT:
"""Guard and return *group*'s bundle for bundle-backed readers.
:meth:`_resolve_group` yields ``None`` only on experiments without
result bundles, whose read methods never consume one — so a
``None`` here is statically unreachable.
"""
bundle = self._resolve_group(group)
assert bundle is not None
return bundle
[docs]
def print_coefficients(self, round_to: int | None = None) -> None:
"""Ask the model to print its posterior coefficients.
Posterior-only by design: prior coefficient draws include merged
data-driven defaults the user never typed. To inspect those, call
:meth:`build` first and read ``exp.model.priors`` /
``exp.model.basic_RVs`` directly — that is a better tool for prior
inspection than a second printing path.
Parameters
----------
round_to : int, optional
Number of significant figures to round to. Defaults to None,
in which case 2 significant figures are used.
Raises
------
GroupNotSampledException
If the experiment has not been fitted yet.
"""
if not self.is_fitted:
raise GroupNotSampledException(
f"No posterior draws are available. Call "
f"{type(self).__name__}.fit() before printing coefficients.",
group="posterior",
)
self._model_backend.print_coefficients(self.labels, round_to)
[docs]
def set_maketables_options(self, *, hdi_prob: float | None = None) -> None:
"""Set optional maketables rendering options for this experiment.
Parameters
----------
hdi_prob : float, optional
Bayesian HDI probability used for PyMC coefficient interval columns in
``__maketables_coef_table__`` and therefore in ``ETable(result)``.
Must satisfy ``0 < hdi_prob < 1``.
Examples
--------
>>> result.set_maketables_options(hdi_prob=0.95) # doctest: +SKIP
>>> # Subsequent ETable(result) calls use 95% HDI bounds
"""
if hdi_prob is not None:
hdi_prob = float(hdi_prob)
if not 0 < hdi_prob < 1:
msg = f"hdi_prob must be in (0, 1), got {hdi_prob!r}"
raise ValueError(msg)
self._maketables_hdi_prob = hdi_prob
@property
def __maketables_coef_table__(self) -> pd.DataFrame:
"""Optional maketables plugin hook for coefficient tables.
Interval columns use the HDI probability set by
:meth:`set_maketables_options` when the canonical coefficient container
carries posterior draws.
"""
return coefficient_table(self)
def __maketables_stat__(self, key: str) -> Any:
"""Optional maketables plugin hook for model-level statistics."""
return get_maketables_adapter(self._model_backend).stat(self, key)
@property
def __maketables_depvar__(self) -> str:
"""Optional maketables plugin hook for dependent variable name."""
return str(
getattr(
self,
"outcome_variable_name",
getattr(self, "outcome_variable", "y"),
)
)
@property
def __maketables_vcov_info__(self) -> dict[str, Any]:
"""Optional maketables plugin hook for variance-covariance info."""
return get_maketables_adapter(self._model_backend).vcov_info(self)
@property
def __maketables_stat_labels__(self) -> dict[str, str] | None:
"""Optional maketables plugin hook for statistic labels."""
return get_maketables_adapter(self._model_backend).stat_labels(self)
@property
def __maketables_default_stat_keys__(self) -> list[str] | None:
"""Optional maketables plugin hook for default statistic rows."""
return get_maketables_adapter(self._model_backend).default_stat_keys(self)
def _render_plot(
self,
*,
show: bool,
legend_kwargs: dict[str, Any] | None,
group: Literal["prior", "posterior"] = "posterior",
**draw_kwargs: Any,
) -> tuple:
"""Template Method shared by every subclass's public ``plot``.
Each :class:`BaseExperiment` subclass exposes its own explicit,
kwarg-only public ``plot()`` (issue
`#886 <https://github.com/pymc-labs/CausalPy/issues/886>`_) and
forwards the call here. This helper:
1. Applies the ``arviz-darkgrid`` style for the duration of the
draw call.
2. Calls the subclass's backend-agnostic :meth:`_plot`.
3. Mutates the resulting legend(s) in place when *legend_kwargs*
is supplied, preserving custom handles built by the subclass.
4. Optionally calls :func:`matplotlib.pyplot.show`.
``BaseExperiment`` deliberately does **not** define a public
``plot()`` method: that would inherit a generic
``*args, **kwargs`` signature into every subclass and re-introduce
the discoverability problem described in #886. Subclasses are
instead required to declare their own ``plot()`` with an explicit
keyword-only signature and call ``self._render_plot(...)``.
The ``group`` keyword is guarded here — once for every subclass —
via :meth:`_resolve_group`: the group-not-sampled error is raised in
exactly one place. The literal group is forwarded to the subclass's
``_plot(group=..., ...)`` which resolves its own bundle; prior-group
plots render the reduced panel set (counterfactual vs observations
only).
Parameters
----------
show : bool
Whether to call :func:`matplotlib.pyplot.show` after drawing.
legend_kwargs : dict, optional
Keyword arguments to adjust legend placement and styling. The
existing legend is modified **in place** so that custom
handles (e.g. ``(Line2D, PolyCollection)`` tuples built by
:func:`~causalpy.plot_utils.plot_posterior_over_x` with
``kind="ribbon"``) are preserved.
Supported keys: ``loc``, ``bbox_to_anchor``, ``fontsize``,
``frameon``, ``title``. ``bbox_transform`` is accepted
alongside ``bbox_to_anchor``.
**draw_kwargs
Subclass-specific drawing parameters forwarded verbatim to
``_plot``. May include ``kind``, ``ci_kind``, ``ci_prob``, and
``num_samples`` for
:func:`~causalpy.plot_utils.plot_posterior_over_x`.
Notes
-----
**Legend handling and ``plot_posterior_over_x`` return types:** :func:`~causalpy.plot_utils.plot_posterior_over_x`
returns ``(Line2D, PolyCollection)`` for ``kind="ribbon"`` but
``(list[Line2D], None)`` for ``kind="histogram"`` or ``"spaghetti"``.
Subclass ``_plot`` implementations that assemble
matplotlib legends from those return values should only pack
``(line, patch)`` tuples when calling ``plot_posterior_over_x`` with ``kind="ribbon"``
(the default). Many current experiment plots always use the ribbon
default and never forward ``kind``; if a subclass forwards non-ribbon
kinds, it must build legend handles accordingly. The base class applies
``legend_kwargs`` by mutating an existing legend in place, which preserves
whatever handle objects the subclass attached (including tuple handles
used for ribbon mean+band).
Examples
--------
Move the legend outside the plot area to avoid overlap:
>>> fig, ax = result.plot( # doctest: +SKIP
... show=False,
... legend_kwargs={"loc": "upper left", "bbox_to_anchor": (1.04, 1)},
... )
"""
# The guard runs here, once for every subclass; the subclass's
# ``_plot`` resolves its own bundle from the group so that override
# signatures stay keyword-only with defaults (LSP-clean).
self._resolve_group(group)
with plt.style.context("arviz-darkgrid"):
fig, ax = self._plot(group=group, **draw_kwargs)
# Apply legend customization if requested. We mutate the existing
# Legend object in place so that custom handles — especially the
# (Line2D, PolyCollection) tuples built by plot_posterior_over_x with
# kind="ribbon" — are preserved
# exactly as the subclass created them.
if legend_kwargs is not None:
# Normalise ax to a flat list so we can iterate uniformly.
if hasattr(ax, "flat"):
axes = list(ax.flat)
elif isinstance(ax, list):
axes = ax
else:
axes = [ax]
for a in axes:
legend = a.get_legend()
if legend is not None:
_apply_legend_kwargs(legend, legend_kwargs)
# Recompute layout when the legend is placed outside the axes
# so it is not clipped (some subclass plots already call
# tight_layout before we get here).
if "bbox_to_anchor" in legend_kwargs:
fig.tight_layout()
if show:
plt.show()
return fig, ax
def _plot(self, **kwargs: Any) -> tuple:
"""Draw the experiment figure; called by :meth:`_render_plot`.
Subclasses implement a single backend-agnostic ``_plot`` that declares
``group`` among its keyword-only parameters and resolves the group's
result bundle itself via ``self._resolve_group(group)`` — never flat
draw-derived attributes. ``group == "prior"`` renders the reduced
panel set: the prior-implied counterfactual against observed data
only, dropping impact and cumulative-impact panels, whose axis
scaling is meaningless under a prior. Uncertainty rendering should
key on data properties (e.g.
:func:`~causalpy.utils.has_posterior_draws`), not backend identity.
"""
raise NotImplementedError("_plot method not yet implemented")
[docs]
@abstractmethod
def effect_summary(
self, *, group: Literal["prior", "posterior"] = "posterior"
) -> EffectSummary:
"""Generate a decision-ready summary of causal effects.
Parameters
----------
group : {"prior", "posterior"}, default "posterior"
``"prior"`` resolves the prior bundle (after
:meth:`sample_prior_predictive`) with prior-appropriate prose;
``"posterior"`` (default) requires :meth:`fit`. Concrete
experiments declare additional keyword-only parameters that their
own effect-summary implementation supports.
Returns
-------
EffectSummary
Object with ``.table`` (DataFrame) and ``.text`` (str) attributes.
"""
raise NotImplementedError("effect_summary method not yet implemented")
[docs]
def generate_report(
self,
*,
include_plots: bool = True,
include_effect_summary: bool = True,
output_file: str | Path | None = None,
) -> str:
"""Generate a self-contained HTML report for this experiment.
This is a convenience wrapper around
:class:`~causalpy.steps.report.GenerateReport` that does not require
a full pipeline.
Parameters
----------
include_plots : bool, default True
Embed diagnostic plots in the report.
include_effect_summary : bool, default True
Include the effect-summary section.
output_file : str or Path, optional
If provided, write the HTML report to this path.
Returns
-------
str
The rendered HTML report.
"""
from causalpy.pipeline import PipelineContext
from causalpy.steps.report import GenerateReport
ctx = PipelineContext(data=self.data)
ctx.experiment = self
if include_effect_summary:
try:
ctx.effect_summary = self.effect_summary()
except NotImplementedError:
# Experiments without an effect-summary implementation keep
# reporting; a missing draw group must NOT be swallowed —
# GroupNotSampledException propagates so the report tells
# the user to call fit() first.
logger.debug(
"effect_summary() not available for %s",
type(self).__name__,
)
step = GenerateReport(
include_plots=include_plots,
include_effect_summary=include_effect_summary,
include_sensitivity=False,
output_file=output_file,
)
step.run(ctx)
return ctx.report