# 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.
"""
Reporting utilities for causal inference experiments.
This module provides statistical summaries and prose reports for causal effects.
The reporting functions automatically compute appropriate statistics based on the
model type (Bayesian/PyMC or Frequentist/OLS).
For detailed explanations of the reported statistics (HDI, ROPE, p-values, etc.)
and their interpretation, see the documentation:
https://causalpy.readthedocs.io/en/latest/knowledgebase/reporting_statistics.html
"""
from dataclasses import dataclass
from typing import Literal, TypedDict
import numpy as np
import pandas as pd
import xarray as xr
from scipy.stats import t
from causalpy._arviz_compat import hdi_bounds
from causalpy.constants import HDI_PROB
from causalpy.utils import _as_scalar, has_posterior_draws
[docs]
@dataclass
class EffectSummary:
"""Container for effect summary statistics and prose report.
Attributes
----------
table : pd.DataFrame
DataFrame containing summary statistics (mean, median, HDI, tail probabilities)
text : str
Detailed multi-paragraph narrative report with
observed vs counterfactual breakdown, statistical credibility assessment,
and assumptions/guidance.
"""
table: pd.DataFrame
text: str
@dataclass(frozen=True)
class _BayesianDecision:
"""Internal Bayesian HDI/ROPE decision consumed by reporting prose."""
conclusion: Literal[
"practically_significant",
"practically_equivalent_to_zero",
"inconclusive",
"descriptive",
]
framework: Literal["hdi_rope", "descriptive"]
interval: tuple[float, float]
rope: tuple[float, float] | None
tail_label: Literal["increase", "decrease", "two-sided"]
tail_probability: float
posterior_mass_below_rope: float | None
posterior_mass_inside_rope: float | None
posterior_mass_above_rope: float | None
class _ScalarBayesianStats(TypedDict, total=False):
"""Numerical scalar summary fields plus the prose decision."""
mean: float
median: float
hdi_lower: float
hdi_upper: float
p_gt_0: float
p_lt_0: float
p_two_sided: float
prob_of_effect: float
p_rope: float
decision: _BayesianDecision
__all__ = ["EffectSummary"]
# ==============================================================================
# Helper functions for common operations
# ==============================================================================
def _posterior_probability(indicator: xr.DataArray, effect: xr.DataArray) -> float:
"""Return an indicator's posterior mean while excluding non-finite draws."""
return _as_scalar(indicator.where(np.isfinite(effect)).mean(skipna=True))
def _finite_posterior_draws(effect: xr.DataArray) -> xr.DataArray:
"""Mask non-finite posterior draws and reject an empty finite posterior."""
finite_effect = effect.where(np.isfinite(effect))
if not bool(np.isfinite(effect).any()):
raise ValueError("Effect posterior contains no finite draws.")
return finite_effect
def _compute_tail_probabilities(
effect: xr.DataArray, direction: Literal["increase", "decrease", "two-sided"]
) -> dict[str, float]:
"""Compute tail probabilities based on direction.
Parameters
----------
effect : xr.DataArray
Effect posterior draws
direction : {"increase", "decrease", "two-sided"}
Direction for tail probability
Returns
-------
dict[str, float]
Dictionary with keys: 'p_gt_0', 'p_lt_0', or 'p_two_sided'+'prob_of_effect'
"""
if direction == "increase":
return {"p_gt_0": _posterior_probability(effect > 0, effect)}
elif direction == "decrease":
return {"p_lt_0": _posterior_probability(effect < 0, effect)}
else: # two-sided
p_gt = _posterior_probability(effect > 0, effect)
p_lt = _posterior_probability(effect < 0, effect)
p_two_sided = 2 * min(p_gt, p_lt)
return {"p_two_sided": p_two_sided, "prob_of_effect": 1 - p_two_sided}
def _compute_rope_probability(
effect: xr.DataArray,
min_effect: float,
direction: Literal["increase", "decrease", "two-sided"],
) -> float:
"""Compute Region of Practical Equivalence probability.
Parameters
----------
effect : xr.DataArray
Effect posterior draws
min_effect : float
Minimum effect size threshold
direction : {"increase", "decrease", "two-sided"}
Direction for ROPE calculation
Returns
-------
float
Probability that effect exceeds min_effect threshold
"""
if direction == "two-sided":
return _posterior_probability(abs(effect) > min_effect, effect)
elif direction == "increase":
return _posterior_probability(effect > min_effect, effect)
elif direction == "decrease":
return _posterior_probability(effect < -min_effect, effect)
def _validate_min_effect(min_effect: float | None) -> float | None:
"""Validate and normalize a supplied ROPE threshold."""
if min_effect is None:
return None
if not np.isfinite(min_effect) or min_effect < 0:
raise ValueError("min_effect must be finite and non-negative.")
return float(min_effect)
def _make_bayesian_decision(
effect: xr.DataArray,
*,
hdi_lower: float,
hdi_upper: float,
tail_probabilities: dict[str, float],
direction: Literal["increase", "decrease", "two-sided"],
min_effect: float | None,
) -> _BayesianDecision:
"""Construct the immutable decision used by Bayesian prose renderers."""
tail_key = {
"increase": "p_gt_0",
"decrease": "p_lt_0",
"two-sided": "p_two_sided",
}[direction]
interval = (hdi_lower, hdi_upper)
tail_probability = tail_probabilities[tail_key]
min_effect = _validate_min_effect(min_effect)
if min_effect is None:
return _BayesianDecision(
conclusion="descriptive",
framework="descriptive",
interval=interval,
rope=None,
tail_label=direction,
tail_probability=tail_probability,
posterior_mass_below_rope=None,
posterior_mass_inside_rope=None,
posterior_mass_above_rope=None,
)
rope = (-min_effect, min_effect)
posterior_mass_below_rope = _posterior_probability(effect < rope[0], effect)
posterior_mass_inside_rope = _posterior_probability(
(effect >= rope[0]) & (effect <= rope[1]), effect
)
posterior_mass_above_rope = _posterior_probability(effect > rope[1], effect)
conclusion: Literal[
"practically_significant",
"practically_equivalent_to_zero",
"inconclusive",
]
if hdi_upper < rope[0] or hdi_lower > rope[1]:
conclusion = "practically_significant"
elif rope[0] <= hdi_lower and hdi_upper <= rope[1]:
conclusion = "practically_equivalent_to_zero"
else:
conclusion = "inconclusive"
return _BayesianDecision(
conclusion=conclusion,
framework="hdi_rope",
interval=interval,
rope=rope,
tail_label=direction,
tail_probability=tail_probability,
posterior_mass_below_rope=posterior_mass_below_rope,
posterior_mass_inside_rope=posterior_mass_inside_rope,
posterior_mass_above_rope=posterior_mass_above_rope,
)
def _format_number(x: float, decimals: int = 2) -> str:
"""Format number for prose output.
Parameters
----------
x : float
Number to format
decimals : int
Number of decimal places
Returns
-------
str
Formatted number string
"""
return f"{x:.{decimals}f}"
def _format_probability_as_percent(probability: float) -> str:
"""Format a probability as a percentage without truncating its precision."""
return f"{np.format_float_positional(probability * 100, unique=True, trim='-')}%"
def _format_rope_bound(value: float) -> str:
"""Format a ROPE bound using the shortest round-trip-safe representation."""
if value == 0:
return "0"
return repr(float(value)).removesuffix(".0")
def _render_bayesian_decision(decision: _BayesianDecision, coverage: str) -> str:
"""Render the decision-owned Bayesian tail and optional ROPE interpretation."""
if decision.tail_label == "increase":
parts = [
"The posterior probability of an increase is "
f"{_format_number(decision.tail_probability, 3)}."
]
elif decision.tail_label == "decrease":
parts = [
"The posterior probability of a decrease is "
f"{_format_number(decision.tail_probability, 3)}."
]
else:
parts = [
"The two-sided tail probability is "
f"{_format_number(decision.tail_probability, 3)}."
]
if decision.framework == "descriptive":
return " ".join(parts)
rope = decision.rope
if rope is None:
raise ValueError("An HDI/ROPE decision requires ROPE bounds.")
rope_lower, rope_upper = map(_format_rope_bound, rope)
if decision.conclusion == "practically_significant":
parts.append(
f"Using the closed ROPE [{rope_lower}, {rope_upper}], the {coverage} HDI "
"is entirely outside the ROPE; the effect is practically significant."
)
elif decision.conclusion == "practically_equivalent_to_zero":
parts.append(
f"Using the closed ROPE [{rope_lower}, {rope_upper}], the {coverage} HDI "
"is entirely inside the ROPE; the effect is practically equivalent to zero."
)
else:
parts.append(
f"Using the closed ROPE [{rope_lower}, {rope_upper}], the {coverage} HDI "
"overlaps the ROPE; the result is inconclusive."
)
below = decision.posterior_mass_below_rope
inside = decision.posterior_mass_inside_rope
above = decision.posterior_mass_above_rope
if below is None or inside is None or above is None:
raise ValueError("An HDI/ROPE decision requires posterior ROPE masses.")
parts.append(
f"Posterior mass is {_format_number(below, 3)} below, "
f"{_format_number(inside, 3)} inside, and {_format_number(above, 3)} "
"above the ROPE."
)
return " ".join(parts)
# ==============================================================================
# Unified scalar effect statistics (DiD, RD, RKink)
# ==============================================================================
def _compute_statistics_scalar(
effect: xr.DataArray,
hdi_prob: float = 0.95,
direction: Literal["increase", "decrease", "two-sided"] = "increase",
min_effect: float | None = None,
) -> _ScalarBayesianStats:
"""Compute statistics for scalar causal effects (DiD, RD, RKink).
Works for any scalar effect with posterior draws (chain, draw dimensions).
Parameters
----------
effect : xr.DataArray
Scalar effect with posterior draws (must have chain, draw dimensions)
hdi_prob : float
Probability for HDI interval
direction : {"increase", "decrease", "two-sided"}
Direction for tail probability calculation
min_effect : float, optional
Finite, non-negative ROPE half-width. The generated decision uses the
closed interval ``[-min_effect, min_effect]`` when supplied.
Returns
-------
_ScalarBayesianStats
Numerical summary fields plus the internal decision used for prose.
"""
min_effect = _validate_min_effect(min_effect)
effect = _finite_posterior_draws(effect)
stats: _ScalarBayesianStats = {
"mean": _as_scalar(effect.mean(dim=["chain", "draw"])),
"median": _as_scalar(effect.median(dim=["chain", "draw"])),
}
stats["hdi_lower"], stats["hdi_upper"] = hdi_bounds(effect, prob=hdi_prob)
tail_probabilities = _compute_tail_probabilities(effect, direction)
if direction == "increase":
stats["p_gt_0"] = tail_probabilities["p_gt_0"]
elif direction == "decrease":
stats["p_lt_0"] = tail_probabilities["p_lt_0"]
else:
stats["p_two_sided"] = tail_probabilities["p_two_sided"]
stats["prob_of_effect"] = tail_probabilities["prob_of_effect"]
if min_effect is not None:
stats["p_rope"] = _compute_rope_probability(effect, min_effect, direction)
stats["decision"] = _make_bayesian_decision(
effect,
hdi_lower=stats["hdi_lower"],
hdi_upper=stats["hdi_upper"],
tail_probabilities=tail_probabilities,
direction=direction,
min_effect=min_effect,
)
return stats
def _generate_table_scalar(
stats: _ScalarBayesianStats, index_name: str = "effect"
) -> pd.DataFrame:
"""Generate summary table for scalar effects (DiD, RD, RKink)."""
row = {
"mean": stats["mean"],
"median": stats["median"],
"hdi_lower": stats["hdi_lower"],
"hdi_upper": stats["hdi_upper"],
}
if "p_gt_0" in stats:
row["p_gt_0"] = stats["p_gt_0"]
if "p_lt_0" in stats:
row["p_lt_0"] = stats["p_lt_0"]
if "p_two_sided" in stats:
row["p_two_sided"] = stats["p_two_sided"]
if "prob_of_effect" in stats:
row["prob_of_effect"] = stats["prob_of_effect"]
if "p_rope" in stats:
row["p_rope"] = stats["p_rope"]
return pd.DataFrame([row], index=[index_name])
def _generate_prose_scalar(
stats: _ScalarBayesianStats,
effect_name: str,
alpha: float = 0.05,
direction: Literal["increase", "decrease", "two-sided"] = "increase",
) -> str:
"""Generate prose summary for scalar effects."""
hdi_coverage = _format_probability_as_percent(1 - alpha)
decision = stats["decision"]
mean = stats["mean"]
lower, upper = decision.interval
return (
f"The {effect_name} was {_format_number(mean)} "
f"({hdi_coverage} HDI [{_format_number(lower)}, {_format_number(upper)}]). "
f"{_render_bayesian_decision(decision, hdi_coverage)}"
)
def _detect_experiment_type(result):
"""Detect experiment type from result attributes."""
if hasattr(result, "discontinuity_at_threshold"):
return "rd" # Regression Discontinuity
elif hasattr(result, "gradient_change"):
return "rkink" # Regression Kink
elif hasattr(result, "att_event_time_"):
return "staggered_did" # Staggered Difference-in-Differences
elif hasattr(result, "causal_impact") and not hasattr(result, "post_impact"):
return "did" # Difference-in-Differences or ANCOVA/PrePostNEGD
elif hasattr(result, "post_impact"):
return "its_or_sc" # ITS or Synthetic Control
else:
raise ValueError(
"Unknown experiment type. Result must have 'discontinuity_at_threshold' (RD), "
"'gradient_change' (Regression Kink), 'att_event_time_' (Staggered DiD), "
"'causal_impact' (DiD/ANCOVA), or 'post_impact' (ITS/Synthetic Control) attribute."
)
def _effect_summary_did(
result,
direction: Literal["increase", "decrease", "two-sided"] = "increase",
alpha: float = 0.05,
min_effect: float | None = None,
):
"""Generate effect summary for Difference-in-Differences experiments."""
causal_impact = result.causal_impact
# For DiD, causal_impact should be an xarray.DataArray with posterior draws
if not isinstance(causal_impact, xr.DataArray):
raise ValueError(
"For DiD experiments, causal_impact must be an xarray.DataArray with "
"posterior draws. OLS models are not supported for uncertainty quantification. "
"Please use a PyMC model."
)
# Compute statistics using unified function
hdi_prob = 1 - alpha
stats = _compute_statistics_scalar(
causal_impact, hdi_prob=hdi_prob, direction=direction, min_effect=min_effect
)
# Generate table and prose using unified functions
table = _generate_table_scalar(stats, index_name="treatment_effect")
text = _generate_prose_scalar(
stats, "average treatment effect", alpha=alpha, direction=direction
)
return EffectSummary(table=table, text=text)
def _effect_summary_staggered_did(
result,
direction: Literal["increase", "decrease", "two-sided"] = "increase",
alpha: float = 0.06,
min_effect: float | None = None,
):
"""Generate effect summary for Staggered Difference-in-Differences experiments.
Reports event-time ATT estimates with pre-treatment placebo check and
post-treatment dynamic effects.
Parameters
----------
result
StaggeredDifferenceInDifferences experiment result
direction : {"increase", "decrease", "two-sided"}
Direction for interpretation
alpha : float, default=0.06
Probability mass outside the credible interval. The HDI probability
is computed as (1 - alpha). Default 0.06 gives 94% HDI, matching
ArviZ's default. Only used as fallback if the result doesn't store
the HDI probability used during interval computation.
min_effect : float, optional
Not used for staggered DiD, kept for API consistency
Returns
-------
EffectSummary
Summary with table of event-time ATTs and prose interpretation
"""
att_et = result.att_event_time_.copy()
# Separate pre-treatment (placebo) and post-treatment effects
pre_treatment = att_et[att_et["event_time"] < 0]
post_treatment = att_et[att_et["event_time"] >= 0]
if "identified" in post_treatment.columns:
post_treatment = post_treatment[post_treatment["identified"]]
if "identified" in pre_treatment.columns:
pre_treatment = pre_treatment[pre_treatment["identified"]]
# Build summary table with all event-time effects
table = att_et.copy()
# Generate prose summary
prose_parts = []
# Overall ATT (average across all post-treatment periods)
if len(post_treatment) > 0:
avg_post_att = post_treatment["att"].mean()
if "att_lower" in post_treatment.columns:
# Bayesian model - use stored hdi_prob from experiment
avg_lower = post_treatment["att_lower"].mean()
avg_upper = post_treatment["att_upper"].mean()
# Use the HDI probability that was actually used to compute the intervals
hdi_prob = getattr(result, "hdi_prob_", 1 - alpha)
hdi_pct = int(hdi_prob * 100)
prose_parts.append(
f"Staggered DiD analysis: The average post-treatment effect "
f"across event-times was {avg_post_att:.2f} "
f"(average {hdi_pct}% HDI [{avg_lower:.2f}, {avg_upper:.2f}])."
)
else:
# OLS model
prose_parts.append(
f"Staggered DiD analysis: The average post-treatment effect "
f"across event-times was {avg_post_att:.2f}."
)
# Pre-treatment placebo check
if len(pre_treatment) > 0:
avg_pre_att = pre_treatment["att"].mean()
# When post-treatment effects exist and are non-zero, use a relative threshold.
# When the average post-treatment effect is (near) zero, fall back to a small
# absolute threshold for the placebo to avoid spuriously flagging violations.
if len(post_treatment) > 0:
if abs(avg_post_att) > 0:
placebo_ok = abs(avg_pre_att) < 0.1 * abs(avg_post_att)
else:
# No detectable average treatment effect; treat very small pre-treatment
# effects as consistent with parallel trends.
placebo_ok = abs(avg_pre_att) < 1e-6
else:
placebo_ok = True
if placebo_ok:
prose_parts.append(
f"Pre-treatment placebo check: Average pre-treatment effect was "
f"{avg_pre_att:.2f}, consistent with parallel trends assumption."
)
else:
prose_parts.append(
f"Pre-treatment placebo check: Average pre-treatment effect was "
f"{avg_pre_att:.2f}. This may indicate violation of parallel trends."
)
# Number of cohorts
n_cohorts = len(result.cohorts)
prose_parts.append(f"Analysis includes {n_cohorts} treatment cohort(s).")
text = " ".join(prose_parts)
return EffectSummary(table=table, text=text)
def _effect_summary_rd(
result,
direction: Literal["increase", "decrease", "two-sided"] = "increase",
alpha: float = 0.05,
min_effect: float | None = None,
):
"""Generate effect summary for Regression Discontinuity experiments."""
discontinuity = result.discontinuity_at_threshold
if has_posterior_draws(discontinuity):
# Posterior draws present: use unified scalar functions
hdi_prob = 1 - alpha
stats = _compute_statistics_scalar(
discontinuity, hdi_prob=hdi_prob, direction=direction, min_effect=min_effect
)
table = _generate_table_scalar(stats, index_name="discontinuity")
text = _generate_prose_scalar(
stats, "discontinuity at threshold", alpha=alpha, direction=direction
)
else:
# OLS model: calculate from model
stats = _compute_statistics_rd_ols(result, alpha=alpha)
table = _generate_table_rd_ols(stats)
text = _generate_prose_rd_ols(stats, alpha=alpha)
return EffectSummary(table=table, text=text)
# ==============================================================================
# Window and counterfactual extraction helpers
# ==============================================================================
def _select_treated_unit(data: xr.DataArray, treated_unit: str | None) -> xr.DataArray:
"""Select a specific treated unit from multi-unit xarray data.
Parameters
----------
data : xr.DataArray
Data with treated_units dimension
treated_unit : str or None
Name of treated unit to select. If None, selects first unit.
Returns
-------
xr.DataArray
Data for the selected treated unit
"""
# Validate coordinate/dimension size match
if "treated_units" in data.dims:
coord_len = len(data.coords["treated_units"])
dim_size = data.sizes["treated_units"]
if coord_len != dim_size:
# Shape mismatch - slice to match coordinates
data = data.isel(treated_units=slice(0, coord_len))
if treated_unit is not None:
return data.sel(treated_units=treated_unit)
else:
return data.isel(treated_units=0)
def _extract_window(result, window, treated_unit=None):
"""Extract windowed impact data based on window specification.
Assumes ``result.post_impact`` is an :class:`xarray.DataArray` with
canonical prediction dimensions (singleton ``chain``/``draw`` for OLS
backends).
Parameters
----------
result
Experiment result object with post_impact and datapost attributes
window : str, tuple, or slice
Window specification: "post", (start, end) tuple, or slice object
treated_unit : str, optional
For multi-unit experiments, specify which treated unit to analyze
Returns
-------
tuple
(windowed_impact, window_coords) where windowed_impact is the data
and window_coords is the corresponding index
"""
post_impact = result.post_impact
if "treated_units" in post_impact.dims:
post_impact = _select_treated_unit(post_impact, treated_unit)
# Extract window coordinates based on window specification
if window == "post":
# Use all post-treatment time points
window_coords = result.datapost.index
elif isinstance(window, tuple) and len(window) == 2:
# Handle (start, end) tuple
start, end = window
if isinstance(result.datapost.index, pd.DatetimeIndex):
# Datetime index - convert to timestamps if needed
if not isinstance(start, pd.Timestamp):
start = pd.Timestamp(start)
if not isinstance(end, pd.Timestamp):
end = pd.Timestamp(end)
window_coords = result.datapost.index[
(result.datapost.index >= start) & (result.datapost.index <= end)
]
else:
# Integer index - filter by value
start_val = int(start)
end_val = int(end)
mask = (result.datapost.index >= start_val) & (
result.datapost.index <= end_val
)
window_coords = result.datapost.index[mask]
elif isinstance(window, slice):
# Handle slice object
if isinstance(result.datapost.index, pd.DatetimeIndex):
# For datetime, slice works directly
window_coords = result.datapost.index[window]
else:
# For integer indices, convert slice to value-based filtering
start_val = (
int(window.start)
if window.start is not None
else result.datapost.index.min()
)
stop_val = (
int(window.stop)
if window.stop is not None
else result.datapost.index.max() + 1
)
step = int(window.step) if window.step is not None else 1
# Create boolean mask for values in range
mask = (result.datapost.index >= start_val) & (
result.datapost.index < stop_val
)
window_coords = result.datapost.index[mask][::step]
else:
raise ValueError(
f"window must be 'post', a tuple (start, end), or a slice. Got {type(window)}"
)
# Apply window selection to post_impact
if window == "post":
# No filtering needed - use all data
windowed_impact = post_impact
else:
windowed_impact = post_impact.sel(obs_ind=window_coords)
# Validate window is not empty
if len(window_coords) == 0:
raise ValueError("Window contains no time points")
return windowed_impact, window_coords
def _extract_counterfactual(result, window_coords, treated_unit=None):
"""Extract counterfactual predictions for the window.
Assumes ``result.post_pred`` is an :class:`xarray.DataArray` with
canonical prediction dimensions (singleton ``chain``/``draw`` for OLS
backends).
Parameters
----------
result
Experiment result object with post_pred attribute
window_coords : pd.Index
Window coordinates from _extract_window
treated_unit : str, optional
For multi-unit experiments, specify which treated unit to analyze
Returns
-------
xr.DataArray
Counterfactual predictions for the window
"""
post_pred = result.post_pred
if "treated_units" in post_pred.dims:
post_pred = _select_treated_unit(post_pred, treated_unit)
return post_pred.sel(obs_ind=window_coords)
def _effect_summary_timeseries(
windowed_impact: xr.DataArray,
counterfactual: xr.DataArray,
window_coords,
*,
direction: Literal["increase", "decrease", "two-sided"] = "increase",
alpha: float = 0.05,
cumulative: bool = True,
relative: bool = True,
min_effect: float | None = None,
prefix: str = "Post-period",
experiment_type: str | None = None,
) -> EffectSummary:
"""Build an :class:`EffectSummary` for time-series experiments (ITS, SC,
Piecewise ITS) from canonical impact/counterfactual containers.
The single irreducible statistical branch lives here, keyed on the
container itself: predictions carrying posterior draws are summarized
with HDIs, tail probabilities, and ROPE, while single-draw (point
estimate) predictions report frequentist t-based intervals — an HDI
computed from a singleton draw would be silently meaningless.
Parameters
----------
windowed_impact : xr.DataArray
Causal impact in the analysis window with canonical prediction
dimensions.
counterfactual : xr.DataArray
Counterfactual predictions in the analysis window with canonical
prediction dimensions.
window_coords : pd.Index
Window coordinates from :func:`_extract_window`.
direction : {"increase", "decrease", "two-sided"}, default "increase"
Selects the Bayesian tail probability reported in prose and the
direction-sensitive ``p_rope`` table column. It does not change the
symmetric HDI+ROPE conclusion.
alpha : float, default 0.05
Interval tail mass. Bayesian ``effect_summary`` reports an HDI with
probability ``1 - alpha`` (95% by default), independently of the
project-wide :data:`~causalpy.constants.HDI_PROB` setting.
cumulative : bool, default True
Whether to include cumulative effect statistics.
relative : bool, default True
Whether to include relative effect statistics.
min_effect : float, optional
Finite, non-negative ROPE half-width. Supplying it uses the closed
symmetric ROPE ``[-min_effect, min_effect]`` for the three-way
HDI+ROPE conclusion; omitting it leaves prose descriptive.
prefix : str, default "Post-period"
Prefix for prose generation.
experiment_type : str, optional
Experiment tag ("its", "sc", "piecewise_its") for tailored
assumptions text.
"""
if has_posterior_draws(windowed_impact):
hdi_prob = 1 - alpha
stats = _compute_statistics(
windowed_impact,
counterfactual,
hdi_prob=hdi_prob,
direction=direction,
cumulative=cumulative,
relative=relative,
min_effect=min_effect,
)
table = _generate_table(stats, cumulative=cumulative, relative=relative)
cf_avg = _as_scalar(counterfactual.mean(dim=["obs_ind", "chain", "draw"]))
obs_avg = cf_avg + stats["avg"]["mean"]
cf_cum = _as_scalar(
counterfactual.sum(dim="obs_ind").mean(dim=["chain", "draw"])
)
obs_cum = cf_cum + stats["cum"]["mean"] if cumulative else None
text = _generate_prose_detailed(
stats,
window_coords,
alpha=alpha,
direction=direction,
cumulative=cumulative,
relative=relative,
prefix=prefix,
observed_avg=obs_avg,
counterfactual_avg=cf_avg,
observed_cum=obs_cum,
counterfactual_cum=cf_cum if cumulative else None,
experiment_type=experiment_type,
)
else:
impact_array = np.asarray(windowed_impact.isel(chain=0, draw=0))
counterfactual_array = np.asarray(counterfactual.isel(chain=0, draw=0))
stats = _compute_statistics_ols(
impact_array,
counterfactual_array,
alpha=alpha,
cumulative=cumulative,
relative=relative,
)
table = _generate_table_ols(stats, cumulative=cumulative, relative=relative)
cf_avg = float(np.mean(counterfactual_array))
obs_avg = cf_avg + stats["avg"]["mean"]
cf_cum = float(np.sum(counterfactual_array))
obs_cum = cf_cum + stats["cum"]["mean"] if cumulative else None
text = _generate_prose_detailed_ols(
stats,
window_coords,
alpha=alpha,
cumulative=cumulative,
relative=relative,
prefix=prefix,
observed_avg=obs_avg,
counterfactual_avg=cf_avg,
observed_cum=obs_cum,
counterfactual_cum=cf_cum if cumulative else None,
experiment_type=experiment_type,
)
return EffectSummary(table=table, text=text)
def _compute_statistics(
impact,
counterfactual,
hdi_prob=HDI_PROB,
direction="increase",
cumulative=True,
relative=True,
min_effect=None,
time_dim="obs_ind",
):
"""Compute all summary statistics from posterior draws.
Notes
-----
All in-tree callers pass ``hdi_prob`` explicitly (typically derived from
``effect_summary``'s ``alpha`` as ``hdi_prob = 1 - alpha``), so this
default is effectively unused; it is set to :data:`HDI_PROB` to keep the
project-wide convention consistent.
"""
min_effect = _validate_min_effect(min_effect)
stats = {}
# Average effect over window
avg_effect = impact.mean(dim=time_dim)
avg_effect = _finite_posterior_draws(avg_effect)
stats["avg"] = {
"mean": _as_scalar(avg_effect.mean(dim=["chain", "draw"])),
"median": _as_scalar(avg_effect.median(dim=["chain", "draw"])),
}
# HDI for average
stats["avg"]["hdi_lower"], stats["avg"]["hdi_upper"] = hdi_bounds(
avg_effect, prob=hdi_prob
)
# Tail probabilities for average
avg_tail_probabilities = _compute_tail_probabilities(avg_effect, direction)
stats["avg"].update(avg_tail_probabilities)
# ROPE for average
if min_effect is not None:
stats["avg"]["p_rope"] = _compute_rope_probability(
avg_effect, min_effect, direction
)
stats["avg"]["decision"] = _make_bayesian_decision(
avg_effect,
hdi_lower=stats["avg"]["hdi_lower"],
hdi_upper=stats["avg"]["hdi_upper"],
tail_probabilities=avg_tail_probabilities,
direction=direction,
min_effect=min_effect,
)
# Cumulative effect
if cumulative:
# Use cumulative sum over window
cum_effect = impact.cumsum(dim=time_dim)
# Take final value (cumulative over entire window)
cum_final = cum_effect.isel({time_dim: -1})
cum_final = _finite_posterior_draws(cum_final)
stats["cum"] = {
"mean": _as_scalar(cum_final.mean(dim=["chain", "draw"])),
"median": _as_scalar(cum_final.median(dim=["chain", "draw"])),
}
# HDI for cumulative
stats["cum"]["hdi_lower"], stats["cum"]["hdi_upper"] = hdi_bounds(
cum_final, prob=hdi_prob
)
# Tail probabilities for cumulative
cum_tail_probabilities = _compute_tail_probabilities(cum_final, direction)
stats["cum"].update(cum_tail_probabilities)
# ROPE for cumulative
if min_effect is not None:
stats["cum"]["p_rope"] = _compute_rope_probability(
cum_final, min_effect, direction
)
stats["cum"]["decision"] = _make_bayesian_decision(
cum_final,
hdi_lower=stats["cum"]["hdi_lower"],
hdi_upper=stats["cum"]["hdi_upper"],
tail_probabilities=cum_tail_probabilities,
direction=direction,
min_effect=min_effect,
)
# Relative effects
if relative:
epsilon = 1e-8 # Guard against division by zero
counterfactual_mean = counterfactual.mean(dim=time_dim)
rel_avg = (avg_effect / (counterfactual_mean + epsilon)) * 100
stats["avg"]["relative_mean"] = _as_scalar(rel_avg.mean(dim=["chain", "draw"]))
(
stats["avg"]["relative_hdi_lower"],
stats["avg"]["relative_hdi_upper"],
) = hdi_bounds(rel_avg, prob=hdi_prob)
if cumulative:
# Relative cumulative: (cumulative effect / cumulative counterfactual) * 100
counterfactual_cum = counterfactual.cumsum(dim=time_dim).isel(
{time_dim: -1}
)
rel_cum = (cum_final / (counterfactual_cum + epsilon)) * 100
stats["cum"]["relative_mean"] = _as_scalar(
rel_cum.mean(dim=["chain", "draw"])
)
(
stats["cum"]["relative_hdi_lower"],
stats["cum"]["relative_hdi_upper"],
) = hdi_bounds(rel_cum, prob=hdi_prob)
return stats
def _generate_table(stats, cumulative=True, relative=True):
"""Generate DataFrame table from statistics."""
rows = []
row_names = []
# Average row
avg_row = {
"mean": stats["avg"]["mean"],
"median": stats["avg"]["median"],
"hdi_lower": stats["avg"]["hdi_lower"],
"hdi_upper": stats["avg"]["hdi_upper"],
}
# Add tail probabilities
if "p_gt_0" in stats["avg"]:
avg_row["p_gt_0"] = stats["avg"]["p_gt_0"]
if "p_lt_0" in stats["avg"]:
avg_row["p_lt_0"] = stats["avg"]["p_lt_0"]
if "p_two_sided" in stats["avg"]:
avg_row["p_two_sided"] = stats["avg"]["p_two_sided"]
avg_row["prob_of_effect"] = stats["avg"]["prob_of_effect"]
# Add ROPE
if "p_rope" in stats["avg"]:
avg_row["p_rope"] = stats["avg"]["p_rope"]
# Add relative
if relative and "relative_mean" in stats["avg"]:
avg_row["relative_mean"] = stats["avg"]["relative_mean"]
avg_row["relative_hdi_lower"] = stats["avg"]["relative_hdi_lower"]
avg_row["relative_hdi_upper"] = stats["avg"]["relative_hdi_upper"]
rows.append(avg_row)
row_names.append("average")
# Cumulative row
if cumulative:
cum_row = {
"mean": stats["cum"]["mean"],
"median": stats["cum"]["median"],
"hdi_lower": stats["cum"]["hdi_lower"],
"hdi_upper": stats["cum"]["hdi_upper"],
}
# Add tail probabilities
if "p_gt_0" in stats["cum"]:
cum_row["p_gt_0"] = stats["cum"]["p_gt_0"]
if "p_lt_0" in stats["cum"]:
cum_row["p_lt_0"] = stats["cum"]["p_lt_0"]
if "p_two_sided" in stats["cum"]:
cum_row["p_two_sided"] = stats["cum"]["p_two_sided"]
cum_row["prob_of_effect"] = stats["cum"]["prob_of_effect"]
# Add ROPE
if "p_rope" in stats["cum"]:
cum_row["p_rope"] = stats["cum"]["p_rope"]
# Add relative
if relative and "relative_mean" in stats["cum"]:
cum_row["relative_mean"] = stats["cum"]["relative_mean"]
cum_row["relative_hdi_lower"] = stats["cum"]["relative_hdi_lower"]
cum_row["relative_hdi_upper"] = stats["cum"]["relative_hdi_upper"]
rows.append(cum_row)
row_names.append("cumulative")
df = pd.DataFrame(rows, index=row_names)
return df
def _generate_prose_detailed(
stats,
window_coords,
alpha=0.05,
direction="increase",
cumulative=True,
relative=True,
prefix="Post-period",
observed_avg: float | None = None,
counterfactual_avg: float | None = None,
observed_cum: float | None = None,
counterfactual_cum: float | None = None,
experiment_type: str | None = None,
):
"""Generate detailed multi-paragraph narrative report.
This function produces a comprehensive plain-language interpretation of the
causal effect, including observed vs counterfactual values, statistical
credibility assessment, assumptions, and guidance on interpretation.
Parameters
----------
stats : dict
Statistics dictionary from _compute_statistics()
window_coords : pd.Index
Window coordinates for the analysis period
alpha : float, default=0.05
Significance level for HDI interval
direction : {"increase", "decrease", "two-sided"}, default="increase"
Direction for tail probability interpretation.
cumulative : bool, default=True
Whether cumulative effects were computed
relative : bool, default=True
Whether relative effects were computed
prefix : str, default="Post-period"
Prefix describing the analysis window
observed_avg : float, optional
Average observed response in the analysis window
counterfactual_avg : float, optional
Average counterfactual prediction in the analysis window
observed_cum : float, optional
Cumulative observed response in the analysis window
counterfactual_cum : float, optional
Cumulative counterfactual prediction in the analysis window
experiment_type : str, optional
Type of experiment ("its", "sc", "piecewise_its") for tailored
assumptions text
Returns
-------
str
Detailed multi-paragraph narrative report
"""
hdi_coverage = _format_probability_as_percent(1 - alpha)
# Format window string
if len(window_coords) > 0:
start_str = str(window_coords[0])
end_str = str(window_coords[-1])
window_str = f"{start_str} to {end_str}"
else:
window_str = "post-period"
# Format numbers
def fmt_num(x, decimals=2):
return f"{x:.{decimals}f}"
# The attached decision is the sole source for the reported average HDI.
# This keeps the interval, tail, and optional ROPE verdict coherent.
decision = stats["avg"]["decision"]
avg_mean = stats["avg"]["mean"]
avg_lower, avg_upper = decision.interval
# Paragraph 1: Observed vs counterfactual (average)
paragraphs = []
if observed_avg is not None and counterfactual_avg is not None:
# Counterfactual interval: since effect = observed - counterfactual,
# counterfactual = observed - effect, so the HDI of the counterfactual
# is [observed - effect_upper, observed - effect_lower].
cf_interval_lower = observed_avg - avg_upper
cf_interval_upper = observed_avg - avg_lower
para1 = (
f"During the {prefix} ({window_str}), the response variable had "
f"an average value of approx. {fmt_num(observed_avg)}. By contrast, in the "
f"absence of an intervention, we would have expected an average response of "
f"{fmt_num(counterfactual_avg)}. The {hdi_coverage} interval of this counterfactual "
f"prediction is [{fmt_num(cf_interval_lower)}, "
f"{fmt_num(cf_interval_upper)}]. Subtracting this prediction "
f"from the observed response yields an estimate of the causal effect the "
f"intervention had on the response variable. This effect is {fmt_num(avg_mean)} "
f"with a {hdi_coverage} interval of [{fmt_num(avg_lower)}, {fmt_num(avg_upper)}]."
)
else:
para1 = (
f"During the {prefix} ({window_str}), the estimated average causal "
f"effect of the intervention is {fmt_num(avg_mean)} "
f"({hdi_coverage} HDI [{fmt_num(avg_lower)}, {fmt_num(avg_upper)}]). "
f"This represents the difference between the observed response and the "
f"counterfactual prediction of what would have occurred without the intervention."
)
paragraphs.append(para1)
# Paragraph 2: Cumulative effect (if applicable)
if cumulative and "cum" in stats:
cumulative_decision = stats["cum"]["decision"]
cum_mean = stats["cum"]["mean"]
cum_lower, cum_upper = cumulative_decision.interval
if observed_cum is not None and counterfactual_cum is not None:
cum_cf_lower = observed_cum - cum_upper
cum_cf_upper = observed_cum - cum_lower
para2 = (
f"Summing up the individual data points during the {prefix}, "
f"the response variable had an overall value of {fmt_num(observed_cum)}. "
f"By contrast, had the intervention not taken place, we would have expected "
f"a sum of {fmt_num(counterfactual_cum)}. The {hdi_coverage} interval of this "
f"prediction is [{fmt_num(cum_cf_lower)}, {fmt_num(cum_cf_upper)}]. "
f"The cumulative effect is {fmt_num(cum_mean)} with a {hdi_coverage} HDI "
f"[{fmt_num(cum_lower)}, {fmt_num(cum_upper)}]."
)
else:
para2 = (
f"The cumulative effect over the {prefix} "
f"was {fmt_num(cum_mean)} ({hdi_coverage} HDI [{fmt_num(cum_lower)}, "
f"{fmt_num(cum_upper)}])."
)
paragraphs.append(para2)
# Paragraph 3: posterior summaries rendered from attached decisions.
credibility_parts = [_render_bayesian_decision(decision, hdi_coverage)]
if cumulative and "cum" in stats:
credibility_parts.append(
"For the cumulative effect, "
f"{_render_bayesian_decision(cumulative_decision, hdi_coverage)}"
)
if relative and "relative_mean" in stats["avg"]:
rel_mean = stats["avg"]["relative_mean"]
rel_lower = stats["avg"]["relative_hdi_lower"]
rel_upper = stats["avg"]["relative_hdi_upper"]
credibility_parts.append(
f"Relative to the counterfactual, the effect represents a "
f"{fmt_num(rel_mean)}% change ({hdi_coverage} HDI [{fmt_num(rel_lower)}%, "
f"{fmt_num(rel_upper)}%])."
)
para3 = " ".join(credibility_parts)
paragraphs.append(para3)
# Paragraph 4: Assumptions and guidance
para4 = _assumptions_text(experiment_type)
para4 += (
"We recommend inspecting model fit, examining pre-intervention trends, "
"and conducting sensitivity analyses (e.g., placebo tests) to support "
"any causal conclusions drawn from this analysis."
)
paragraphs.append(para4)
return "\n\n".join(paragraphs)
def _assumptions_text(experiment_type: str | None = None) -> str:
"""Return the assumptions preamble tailored to the experiment type.
Parameters
----------
experiment_type : str, optional
One of "its", "sc", "piecewise_its", or None for a generic default.
Returns
-------
str
Assumptions preamble (ends with a trailing space for appending guidance).
"""
if experiment_type == "its":
return (
"This analysis assumes that the relationship between the time-based "
"predictors and the response observed during the pre-intervention period "
"remains stable throughout the post-intervention period. If the formula "
"includes external covariates, it further assumes they were not themselves "
"affected by the intervention. "
)
elif experiment_type == "sc":
return (
"This analysis assumes that the control units used to construct the "
"synthetic counterfactual were not themselves affected by the intervention, "
"and that the pre-treatment relationship between control and treated units "
"remains stable throughout the post-treatment period. "
)
else:
return (
"This analysis assumes that the covariates used to construct the "
"counterfactual were not themselves affected by the intervention. It also "
"assumes that the relationship between the covariates and the response "
"observed during the pre-intervention period remains stable throughout "
"the post-intervention period. "
)
def _generate_prose_detailed_ols(
stats,
window_coords,
alpha=0.05,
cumulative=True,
relative=True,
prefix="Post-period",
observed_avg: float | None = None,
counterfactual_avg: float | None = None,
observed_cum: float | None = None,
counterfactual_cum: float | None = None,
experiment_type: str | None = None,
):
"""Generate detailed multi-paragraph narrative report for OLS models.
This function produces a comprehensive plain-language interpretation of the
causal effect from OLS models, including observed vs counterfactual values,
statistical significance assessment, assumptions, and guidance on interpretation.
Parameters
----------
stats : dict
Statistics dictionary from _compute_statistics_ols()
window_coords : pd.Index
Window coordinates for the analysis period
alpha : float, default=0.05
Significance level for CI interval
cumulative : bool, default=True
Whether cumulative effects were computed
relative : bool, default=True
Whether relative effects were computed
prefix : str, default="Post-period"
Prefix describing the analysis window
observed_avg : float, optional
Average observed response in the analysis window
counterfactual_avg : float, optional
Average counterfactual prediction in the analysis window
observed_cum : float, optional
Cumulative observed response in the analysis window
counterfactual_cum : float, optional
Cumulative counterfactual prediction in the analysis window
experiment_type : str, optional
Type of experiment ("its", "sc", "piecewise_its") for tailored
assumptions text
Returns
-------
str
Detailed multi-paragraph narrative report
"""
ci_pct = int((1 - alpha) * 100)
# Format window string
if len(window_coords) > 0:
start_str = str(window_coords[0])
end_str = str(window_coords[-1])
window_str = f"{start_str} to {end_str}"
else:
window_str = "post-period"
# Format numbers
def fmt_num(x, decimals=2):
return f"{x:.{decimals}f}"
# Extract statistics
avg_mean = stats["avg"]["mean"]
avg_lower = stats["avg"]["ci_lower"]
avg_upper = stats["avg"]["ci_upper"]
p_val = stats["avg"]["p_value"]
# Paragraph 1: Average effect description
paragraphs = []
if observed_avg is not None and counterfactual_avg is not None:
cf_interval_lower = observed_avg - avg_upper
cf_interval_upper = observed_avg - avg_lower
para1 = (
f"During the {prefix} ({window_str}), the response variable had "
f"an average value of approx. {fmt_num(observed_avg)}. By contrast, in the "
f"absence of an intervention, we would have expected an average response of "
f"{fmt_num(counterfactual_avg)}. The {ci_pct}% confidence interval of this "
f"counterfactual prediction is [{fmt_num(cf_interval_lower)}, "
f"{fmt_num(cf_interval_upper)}]. Subtracting this prediction "
f"from the observed response yields an estimate of the causal effect the "
f"intervention had on the response variable. This effect is {fmt_num(avg_mean)} "
f"with a {ci_pct}% confidence interval of [{fmt_num(avg_lower)}, "
f"{fmt_num(avg_upper)}]."
)
else:
para1 = (
f"During the {prefix} ({window_str}), the estimated average causal "
f"effect of the intervention is {fmt_num(avg_mean)} "
f"({ci_pct}% CI [{fmt_num(avg_lower)}, {fmt_num(avg_upper)}]). "
f"This represents the difference between the observed response and the "
f"counterfactual prediction of what would have occurred without the intervention."
)
paragraphs.append(para1)
# Paragraph 2: Cumulative effect (if applicable)
if cumulative and "cum" in stats:
cum_mean = stats["cum"]["mean"]
cum_lower = stats["cum"]["ci_lower"]
cum_upper = stats["cum"]["ci_upper"]
if observed_cum is not None and counterfactual_cum is not None:
cum_cf_lower = observed_cum - cum_upper
cum_cf_upper = observed_cum - cum_lower
para2 = (
f"Summing up the individual data points during the {prefix}, "
f"the response variable had an overall value of {fmt_num(observed_cum)}. "
f"By contrast, had the intervention not taken place, we would have expected "
f"a sum of {fmt_num(counterfactual_cum)}. The {ci_pct}% confidence interval "
f"of this prediction is [{fmt_num(cum_cf_lower)}, {fmt_num(cum_cf_upper)}]."
)
else:
para2 = (
f"The cumulative effect over the {prefix} "
f"was {fmt_num(cum_mean)} ({ci_pct}% CI [{fmt_num(cum_lower)}, "
f"{fmt_num(cum_upper)}])."
)
paragraphs.append(para2)
# Paragraph 3: Statistical summary
ci_excludes_zero = (avg_lower > 0) or (avg_upper < 0)
significance_parts = []
if ci_excludes_zero:
significance_parts.append(
f"The {ci_pct}% confidence interval of the effect [{fmt_num(avg_lower)}, "
f"{fmt_num(avg_upper)}] does not include zero (p-value {fmt_num(p_val, 3)})."
)
else:
significance_parts.append(
f"The {ci_pct}% confidence interval of the effect [{fmt_num(avg_lower)}, "
f"{fmt_num(avg_upper)}] includes zero (p-value {fmt_num(p_val, 3)})."
)
if relative and "relative_mean" in stats["avg"]:
rel_mean = stats["avg"]["relative_mean"]
rel_lower = stats["avg"]["relative_ci_lower"]
rel_upper = stats["avg"]["relative_ci_upper"]
significance_parts.append(
f"Relative to the counterfactual, the effect represents a "
f"{fmt_num(rel_mean)}% change ({ci_pct}% CI [{fmt_num(rel_lower)}%, "
f"{fmt_num(rel_upper)}%])."
)
para3 = " ".join(significance_parts)
paragraphs.append(para3)
# Paragraph 4: Assumptions and guidance
para4 = _assumptions_text(experiment_type)
para4 += (
"We recommend inspecting model fit, examining pre-intervention trends, "
"and conducting sensitivity analyses (e.g., placebo tests) to support "
"any causal conclusions drawn from this analysis."
)
paragraphs.append(para4)
return "\n\n".join(paragraphs)
def _compute_statistics_ols(
impact,
counterfactual,
alpha=0.05,
cumulative=True,
relative=True,
):
"""Compute summary statistics for OLS models (time-series experiments).
Parameters
----------
impact : np.ndarray
Impact values (y_true - y_pred) as 1D numpy array
counterfactual : np.ndarray
Counterfactual predictions as 1D numpy array
alpha : float
Significance level
cumulative : bool
Whether to compute cumulative statistics
relative : bool
Whether to compute relative statistics
Returns
-------
dict
Dictionary of statistics
"""
stats = {}
# Average effect over window
avg_effect = np.mean(impact)
n = len(impact)
# Calculate standard error of mean
se_avg = np.std(impact, ddof=1) / np.sqrt(n)
# Degrees of freedom
df = n - 1
# t-critical value
t_critical = t.ppf(1 - alpha / 2, df=df)
ci_lower = avg_effect - t_critical * se_avg
ci_upper = avg_effect + t_critical * se_avg
# Two-sided p-value
t_stat = avg_effect / se_avg
p_value = 2 * (1 - t.cdf(abs(t_stat), df=df))
stats["avg"] = {
"mean": float(avg_effect),
"ci_lower": float(ci_lower),
"ci_upper": float(ci_upper),
"p_value": float(p_value),
}
# Cumulative effect
if cumulative:
cum_effect = np.sum(impact)
# Standard error of sum (assuming independence)
se_cum = np.std(impact, ddof=1) * np.sqrt(n)
ci_cum_lower = cum_effect - t_critical * se_cum
ci_cum_upper = cum_effect + t_critical * se_cum
t_stat_cum = cum_effect / se_cum if se_cum > 0 else 0
p_value_cum = 2 * (1 - t.cdf(abs(t_stat_cum), df=df))
stats["cum"] = {
"mean": float(cum_effect),
"ci_lower": float(ci_cum_lower),
"ci_upper": float(ci_cum_upper),
"p_value": float(p_value_cum),
}
# Relative effect
if relative:
# Relative effect as percentage change
relative_effect = (impact / counterfactual) * 100
rel_mean = np.mean(relative_effect)
se_rel = np.std(relative_effect, ddof=1) / np.sqrt(n)
ci_rel_lower = rel_mean - t_critical * se_rel
ci_rel_upper = rel_mean + t_critical * se_rel
stats["avg"]["relative_mean"] = float(rel_mean)
stats["avg"]["relative_ci_lower"] = float(ci_rel_lower)
stats["avg"]["relative_ci_upper"] = float(ci_rel_upper)
if cumulative:
# Cumulative relative effect
cum_relative = np.sum(relative_effect)
se_cum_rel = np.std(relative_effect, ddof=1) * np.sqrt(n)
ci_cum_rel_lower = cum_relative - t_critical * se_cum_rel
ci_cum_rel_upper = cum_relative + t_critical * se_cum_rel
stats["cum"]["relative_mean"] = float(cum_relative)
stats["cum"]["relative_ci_lower"] = float(ci_cum_rel_lower)
stats["cum"]["relative_ci_upper"] = float(ci_cum_rel_upper)
return stats
def _point_residuals(result) -> np.ndarray:
"""In-sample point residuals via the canonical prediction container.
Uses the model adapter's canonical ``predict`` output collapsed over
``chain``/``draw``, so the t-based point-summary path works for any
backend. This path is only reached for singleton containers
(``chain * draw == 1``), where the mean is exactly the single point
estimate; taking the mean (rather than the first draw) keeps the helper
well-defined even if a many-draw container ever slips through.
``y`` may have shape ``(n, 1)`` with dims ``(obs_ind, treated_units)``
while the fitted values are conceptually ``(n,)``; both are flattened to
1-D so they align positionally on ``obs_ind`` (letting xarray align them
would broadcast against ``treated_units`` and produce an ``(n, n)``
array).
"""
y = np.asarray(result.design["y"]).reshape(-1)
pred = result._model_backend.predict(X=np.asarray(result.design["X"]))
y_fitted = np.asarray(pred.mean(dim=["chain", "draw"])).reshape(-1)
return y - y_fitted
def _compute_statistics_did_ols(
result,
alpha=0.05,
):
"""Compute statistics for DiD scalar effect with OLS model.
Parameters
----------
result
Experiment result object with OLS model
alpha : float
Significance level
Returns
-------
dict
Dictionary of statistics
"""
causal_impact = _as_scalar(result.causal_impact)
# Calculate standard error from model residuals
residuals = _point_residuals(result)
X_da = result.design["X"]
n, p = X_da.shape
df = n - p
# Unbiased estimator of the residual variance: SSR / (n - p), consistent
# with the degrees of freedom used below for the t-distribution.
mse = np.sum(residuals**2) / df
# Find the interaction term coefficient index. patsy names interaction
# columns by formula order (e.g. "post_treatment[T.True]:group" for a
# formula written as "post_treatment*group"), so match structurally via
# the same helper algorithm() uses to locate the causal_impact
# coefficient, rather than a concatenated "group:post_treatment" string.
coeff_idx = next(
(
i
for i, label in enumerate(result.labels)
if result._is_treatment_interaction(label)
),
None,
)
if coeff_idx is None:
raise ValueError(
f"Could not find interaction term between '{result.group_variable_name}' "
f"and '{result.post_treatment_variable_name}' in model"
)
X = X_da
try:
# Try to get X as numpy array
if hasattr(X, "values"):
X = X.values
elif hasattr(X, "data"):
X = X.data
XtX_inv = np.linalg.inv(X.T @ X)
se = np.sqrt(mse * XtX_inv[coeff_idx, coeff_idx])
except (np.linalg.LinAlgError, AttributeError):
# Fallback: use simple approximation
se = np.std(residuals) / np.sqrt(n)
# t-critical value
t_critical = t.ppf(1 - alpha / 2, df=df)
ci_lower = causal_impact - t_critical * se
ci_upper = causal_impact + t_critical * se
# Two-sided p-value
t_stat = causal_impact / se if se > 0 else 0
p_value = 2 * (1 - t.cdf(abs(t_stat), df=df))
stats = {
"mean": float(causal_impact),
"ci_lower": float(ci_lower),
"ci_upper": float(ci_upper),
"p_value": float(p_value),
}
return stats
def _generate_table_ols(stats, cumulative=True, relative=True):
"""Generate summary table for OLS models."""
rows = []
row_names = []
# Average row
avg_row = {
"mean": stats["avg"]["mean"],
"ci_lower": stats["avg"]["ci_lower"],
"ci_upper": stats["avg"]["ci_upper"],
"p_value": stats["avg"]["p_value"],
}
# Add relative
if relative and "relative_mean" in stats["avg"]:
avg_row["relative_mean"] = stats["avg"]["relative_mean"]
avg_row["relative_ci_lower"] = stats["avg"]["relative_ci_lower"]
avg_row["relative_ci_upper"] = stats["avg"]["relative_ci_upper"]
rows.append(avg_row)
row_names.append("average")
# Cumulative row
if cumulative:
cum_row = {
"mean": stats["cum"]["mean"],
"ci_lower": stats["cum"]["ci_lower"],
"ci_upper": stats["cum"]["ci_upper"],
"p_value": stats["cum"]["p_value"],
}
# Add relative
if relative and "relative_mean" in stats["cum"]:
cum_row["relative_mean"] = stats["cum"]["relative_mean"]
cum_row["relative_ci_lower"] = stats["cum"]["relative_ci_lower"]
cum_row["relative_ci_upper"] = stats["cum"]["relative_ci_upper"]
rows.append(cum_row)
row_names.append("cumulative")
df = pd.DataFrame(rows, index=row_names)
return df
def _generate_table_did_ols(stats):
"""Generate summary table for DiD with OLS model."""
row = {
"mean": stats["mean"],
"ci_lower": stats["ci_lower"],
"ci_upper": stats["ci_upper"],
"p_value": stats["p_value"],
}
df = pd.DataFrame([row], index=["treatment_effect"])
return df
def _generate_prose_did_ols(stats, alpha=0.05):
"""Generate prose summary for DiD with OLS model."""
ci_pct = int((1 - alpha) * 100)
def fmt_num(x, decimals=2):
return f"{x:.{decimals}f}"
mean = stats["mean"]
lower = stats["ci_lower"]
upper = stats["ci_upper"]
p_val = stats["p_value"]
prose = (
f"The treatment effect was {fmt_num(mean)} "
f"({ci_pct}% CI [{fmt_num(lower)}, {fmt_num(upper)}]), "
f"with a p-value of {fmt_num(p_val, 3)}."
)
return prose
def _compute_statistics_rd_ols(result, alpha=0.05):
"""Compute statistics for RD scalar effect with OLS model."""
discontinuity = _as_scalar(result.discontinuity_at_threshold)
# Calculate standard error from model residuals
residuals = _point_residuals(result)
X_da = result.design["X"]
n, p = X_da.shape
df = n - p
# Unbiased estimator of the residual variance: SSR / (n - p), consistent
# with the degrees of freedom used below for the t-distribution.
mse = np.sum(residuals**2) / df
try:
threshold_design = np.asarray(result.x_discon_design, dtype=float)
except AttributeError as err:
raise ValueError(
"Cannot compute the RD threshold-contrast standard error because "
"the threshold design rows are unavailable."
) from err
except (TypeError, ValueError) as err:
raise ValueError(
"RD threshold design must be a finite numeric two-row array."
) from err
if threshold_design.ndim != 2 or threshold_design.shape[0] != 2:
raise ValueError(
"RD threshold design must contain exactly two rows: below threshold "
"followed by above threshold."
)
if not np.isfinite(threshold_design).all():
raise ValueError("RD threshold design must be a finite numeric two-row array.")
X = np.asarray(X_da)
if threshold_design.shape[1] != X.shape[1]:
raise ValueError(
"RD threshold design must have the same number of columns as the "
"fitted design matrix."
)
try:
XtX_inv = np.linalg.inv(X.T @ X)
except np.linalg.LinAlgError as err:
raise ValueError(
"Cannot compute the RD threshold-contrast standard error because "
"X.T @ X is singular."
) from err
# discontinuity_at_threshold is the prediction above the threshold minus
# the prediction below it, so its uncertainty must use that same contrast.
contrast = threshold_design[1] - threshold_design[0]
se = np.sqrt(mse * contrast @ XtX_inv @ contrast)
# t-critical value
t_critical = t.ppf(1 - alpha / 2, df=df)
ci_lower = discontinuity - t_critical * se
ci_upper = discontinuity + t_critical * se
# Two-sided p-value
t_stat = discontinuity / se if se > 0 else 0
p_value = 2 * (1 - t.cdf(abs(t_stat), df=df))
stats = {
"mean": float(discontinuity),
"ci_lower": float(ci_lower),
"ci_upper": float(ci_upper),
"p_value": float(p_value),
}
return stats
def _generate_table_rd_ols(stats):
"""Generate summary table for RD with OLS model."""
row = {
"mean": stats["mean"],
"ci_lower": stats["ci_lower"],
"ci_upper": stats["ci_upper"],
"p_value": stats["p_value"],
}
df = pd.DataFrame([row], index=["discontinuity"])
return df
def _generate_prose_rd_ols(stats, alpha=0.05):
"""Generate prose summary for RD with OLS model."""
ci_pct = int((1 - alpha) * 100)
def fmt_num(x, decimals=2):
return f"{x:.{decimals}f}"
mean = stats["mean"]
lower = stats["ci_lower"]
upper = stats["ci_upper"]
p_val = stats["p_value"]
prose = (
f"The discontinuity at threshold was {fmt_num(mean)} "
f"({ci_pct}% CI [{fmt_num(lower)}, {fmt_num(upper)}]), "
f"with a p-value of {fmt_num(p_val, 3)}."
)
return prose
# ==============================================================================
# Regression Kink handler functions
# ==============================================================================
def _effect_summary_rkink(
result,
direction: Literal["increase", "decrease", "two-sided"] = "increase",
alpha: float = 0.05,
min_effect: float | None = None,
):
"""Generate effect summary for Regression Kink experiments."""
gradient_change = result.gradient_change
# Check if PyMC (xarray) or OLS (scalar)
is_pymc = isinstance(gradient_change, xr.DataArray)
if is_pymc:
# PyMC model: use unified scalar functions
hdi_prob = 1 - alpha
stats = _compute_statistics_scalar(
gradient_change,
hdi_prob=hdi_prob,
direction=direction,
min_effect=min_effect,
)
table = _generate_table_scalar(stats, index_name="gradient_change")
text = _generate_prose_scalar(
stats,
"change in gradient at the kink point",
alpha=alpha,
direction=direction,
)
else:
raise NotImplementedError(
"OLS models are not currently supported for Regression Kink experiments. "
"Please use a PyMC model for full statistical inference. "
"If OLS support is needed, see _compute_statistics_rd_ols() "
"for the implementation pattern."
)
return EffectSummary(table=table, text=text)