# 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.
"""Regression discontinuity design."""
import re # noqa: I001
import warnings
from typing import Any, Literal
import numpy as np
import pandas as pd
from matplotlib import pyplot as plt
from matplotlib.lines import Line2D
from plotnine import (
aes,
geom_line,
geom_point,
ggplot,
guides,
labs,
scale_color_manual,
)
from patsy import ModelDesc
from sklearn.base import RegressorMixin
from causalpy.formula_utils import build_design_matrices, build_formula_matrices
from causalpy.input_data import DataFrameLike, to_pandas
from causalpy.experiments.model_adapter import build_coords
from causalpy.custom_exceptions import (
DataException,
FormulaException,
)
from causalpy.constants import HDI_PROB, LEGEND_FONT_SIZE
from causalpy.plot_utils import (
_PosteriorPlotStyle,
format_r2_score,
has_posterior_draws,
plot_posterior_over_x,
)
from causalpy.pymc_models import LinearRegression, PyMCModel
from causalpy.reporting import EffectSummary, _effect_summary_rd
from causalpy.utils import (
_as_scalar,
_is_variable_dummy_coded,
convert_to_string,
round_num,
)
from .base import BaseExperiment
[docs]
class RegressionDiscontinuity(BaseExperiment):
"""
A class to analyse sharp regression discontinuity experiments.
Parameters
----------
data : dataframe-like
Any eager dataframe Narwhals supports, such as pandas, Polars, or
PyArrow. Converted to pandas internally.
formula : str
A statistical model formula.
treatment_threshold : float
A scalar threshold value at which the treatment is applied.
model : PyMCModel, RegressorMixin, or None, default None
A PyMC or sklearn model. Defaults to :class:`LinearRegression`.
running_variable_name : str, default "x"
The name of the predictor variable that the treatment threshold is
based upon.
epsilon : float, default 0.001
A small scalar value which determines how far above and below the
treatment threshold to evaluate the causal impact.
bandwidth : float, default np.inf
Data outside of the bandwidth (relative to the discontinuity) is not
used to fit the model.
donut_hole : float, default 0.0
Observations within this distance from the treatment threshold are
excluded from model fitting. Used as a robustness check when
observations closest to the threshold may be problematic (e.g., due
to manipulation or heaping). Must be non-negative and less than
``bandwidth`` if ``bandwidth`` is finite.
Notes
-----
**Estimate extraction**
After fitting the regression on the selected bandwidth, the class predicts the conditional expectation immediately below the threshold with ``treated=0`` and immediately above it with ``treated=1``. ``discontinuity_at_threshold`` is the upper prediction minus the lower prediction, evaluated at ``threshold ± epsilon``. This is a local prediction contrast, not a population-standardized effect.
Examples
--------
>>> import causalpy as cp
>>> df = cp.load_data("rd")
>>> seed = 42
>>> result = cp.RegressionDiscontinuity(
... df,
... formula="y ~ 1 + x + treated + x:treated",
... model=cp.pymc_models.LinearRegression(
... sample_kwargs={
... "draws": 100,
... "target_accept": 0.95,
... "random_seed": seed,
... "progressbar": False,
... },
... ),
... treatment_threshold=0.5,
... )
"""
supports_ols = True
supports_bayes = True
_default_model_class = LinearRegression
[docs]
def __init__(
self,
data: DataFrameLike,
formula: str,
treatment_threshold: float,
model: PyMCModel | RegressorMixin | None = None,
running_variable_name: str = "x",
epsilon: float = 0.001,
bandwidth: float = np.inf,
donut_hole: float = 0.0,
) -> None:
super().__init__(model=model)
self.expt_type = "Regression Discontinuity"
# to_pandas returns a copy, so the treated indicator is normalized on
# an owned frame rather than the caller's.
self.data = to_pandas(data)
self.data.index.name = "obs_ind"
self.formula = formula
self.running_variable_name = running_variable_name
self.treatment_threshold = treatment_threshold
self.epsilon = epsilon
self.bandwidth = bandwidth
self.donut_hole = donut_hole
self.input_validation()
self._build_design_matrices()
self._prepare_data()
self.algorithm()
def _build_design_matrices(self) -> None:
"""Build design matrices from formula and data, applying bandwidth and donut hole filtering."""
x_vals = self.data[self.running_variable_name]
c = self.treatment_threshold
mask = pd.Series(True, index=self.data.index)
if self.bandwidth is not np.inf:
mask &= np.abs(x_vals - c) <= self.bandwidth
if self.donut_hole > 0:
mask &= np.abs(x_vals - c) >= self.donut_hole
self._fit_mask = mask.to_numpy(dtype=bool)
self.fit_data = self.data.loc[mask]
if len(self.fit_data) <= 10:
filter_desc = []
if self.bandwidth is not np.inf:
filter_desc.append(f"bandwidth={self.bandwidth}")
if self.donut_hole > 0:
filter_desc.append(f"donut_hole={self.donut_hole}")
if filter_desc:
msg = (
f"Choice of {' and '.join(filter_desc)} parameters has led to only "
f"{len(self.fit_data)} remaining datapoints. "
f"Consider adjusting these parameters."
)
else:
msg = f"Only {len(self.fit_data)} datapoints in the dataset."
warnings.warn(msg, UserWarning, stacklevel=2)
y, X = build_formula_matrices(self.formula, self.fit_data)
self._y_design_info = y.design_info
self._x_design_info = X.design_info
self.labels = X.design_info.column_names
self._y_raw, self._X_raw = np.asarray(y), np.asarray(X)
self.outcome_variable_name = y.design_info.column_names[0]
def _prepare_data(self) -> None:
"""Bundle design matrices into an ``xr.Dataset``."""
n = self._X_raw.shape[0]
self.design = self._build_design_dataset(
self._X_raw,
self._y_raw,
obs_ind=np.arange(n),
coeffs=self.labels,
)
del self._X_raw, self._y_raw
[docs]
def algorithm(self) -> None:
"""Run the experiment algorithm: fit model, predict, and calculate discontinuity."""
X = self.design["X"]
y = self.design["y"]
self._model_backend.fit(
X=X,
y=y,
coords=build_coords(self.labels, X.shape[0]),
)
self.score = self._model_backend.score(X=X, y=y)
# get the model predictions of the observed data
if self.bandwidth is not np.inf:
fmin = self.treatment_threshold - self.bandwidth
fmax = self.treatment_threshold + self.bandwidth
xi = np.linspace(fmin, fmax, 200)
else:
xi = np.linspace(
np.min(self.data[self.running_variable_name]),
np.max(self.data[self.running_variable_name]),
200,
)
self.x_pred = pd.DataFrame(
{self.running_variable_name: xi, "treated": self._is_treated(xi)}
)
(new_x,) = build_design_matrices([self._x_design_info], self.x_pred)
self.pred = self._model_backend.predict(X=np.asarray(new_x))
# calculate discontinuity by evaluating the difference in model expectation on
# either side of the discontinuity
# NOTE: `"treated": np.array([0, 1])`` assumes treatment is applied above
# (not below) the threshold
self.x_discon = pd.DataFrame(
{
self.running_variable_name: np.array(
[
self.treatment_threshold - self.epsilon,
self.treatment_threshold + self.epsilon,
]
),
"treated": np.array([0, 1]),
}
)
(new_x,) = build_design_matrices([self._x_design_info], self.x_discon)
# Preserve the design rows used for the threshold prediction contrast:
# row 0 is below the threshold and row 1 is above it.
self.x_discon_design = np.asarray(new_x)
self.pred_discon = self._model_backend.predict(X=np.asarray(new_x))
self.discontinuity_at_threshold = self.pred_discon.isel(
obs_ind=1, treated_units=0
) - self.pred_discon.isel(obs_ind=0, treated_units=0)
def _is_treated(self, x: np.ndarray | pd.Series) -> np.ndarray:
"""Returns ``True`` if `x` is greater than or equal to the treatment threshold.
.. warning::
Assumes treatment is given to those ABOVE the treatment threshold.
"""
return np.greater_equal(x, self.treatment_threshold)
[docs]
def summary(self, round_to: int | None = None) -> None:
"""
Print summary of main results and model coefficients.
Parameters
----------
round_to : int, optional
Number of decimals used to round results. Defaults to 2. Use
``None`` to return raw numbers.
"""
print("Regression Discontinuity experiment")
print(f"Formula: {self.formula}")
print(f"Running variable: {self.running_variable_name}")
print(f"Threshold on running variable: {self.treatment_threshold}")
print(f"Bandwidth: {self.bandwidth}")
print(f"Donut hole: {self.donut_hole}")
print(f"Observations used for fit: {len(self.fit_data)}")
print("\nResults:")
discontinuity = (
self.discontinuity_at_threshold
if has_posterior_draws(self.discontinuity_at_threshold)
else _as_scalar(self.discontinuity_at_threshold)
)
print(f"Discontinuity at threshold = {convert_to_string(discontinuity)}")
print("\n")
self.print_coefficients(round_to)
[docs]
def plot(
self,
*,
round_to: int | None = 2,
ci_prob: float = HDI_PROB,
kind: Literal["ribbon", "histogram", "spaghetti"] = "ribbon",
ci_kind: Literal["hdi", "eti"] = "hdi",
num_samples: int = 50,
figsize: tuple[float, float] | None = None,
show: bool = True,
legend_kwargs: dict[str, Any] | None = None,
) -> tuple[plt.Figure, plt.Axes]:
"""Plot the regression discontinuity results.
Parameters
----------
round_to : int, optional
Number of decimals used to round numerical results in the figure
title (e.g. the Bayesian :math:`R^2`). Defaults to 2. Use
``None`` to render raw numbers.
ci_prob : float
Probability mass of the highest density interval drawn around the
posterior predictive band, and the central credible interval
reported in the figure title for the discontinuity at threshold.
Must be in ``(0, 1]``. Ignored for OLS models. Defaults to
:data:`~causalpy.constants.HDI_PROB` (currently 0.94).
kind : {"ribbon", "histogram", "spaghetti"}, optional
How posterior uncertainty is rendered via
:func:`~causalpy.plot_utils.plot_posterior_over_x`. Defaults to ``"ribbon"``.
For ``"spaghetti"``, legends use draw lines rather than a shaded
band. For ``"histogram"``, uncertainty is shown as a 2D density
heatmap with a mean line overlay (no ribbon patch for legends).
ci_kind : {"hdi", "eti"}, optional
Credible interval type when ``kind="ribbon"``. Defaults to
``"hdi"``.
num_samples : int, optional
Number of posterior draws when ``kind="spaghetti"``. Defaults
to 50. Ignored for other kinds.
figsize : tuple of (float, float), optional
Width and height of the figure in inches, passed to
:func:`matplotlib.pyplot.subplots`. Defaults to ``None`` (use
matplotlib's default).
show : bool
Whether to automatically display the plot. Defaults to ``True``.
legend_kwargs : dict, optional
Keyword arguments to adjust legend placement and styling.
Supported keys: ``loc``, ``bbox_to_anchor``, ``fontsize``,
``frameon``, ``title`` (``bbox_transform`` is accepted alongside
``bbox_to_anchor``). The existing legend is modified **in
place** so that custom handles are preserved.
Returns
-------
fig : matplotlib.figure.Figure
The figure that was created.
ax : matplotlib.axes.Axes
The axes object containing the plot.
"""
return self._render_plot(
show=show,
legend_kwargs=legend_kwargs,
round_to=round_to,
ci_prob=ci_prob,
kind=kind,
ci_kind=ci_kind,
num_samples=num_samples,
figsize=figsize,
)
def _plot(
self,
round_to: int | None = 2,
ci_prob: float = HDI_PROB,
kind: Literal["ribbon", "histogram", "spaghetti"] = "ribbon",
ci_kind: Literal["hdi", "eti"] = "hdi",
num_samples: int = 50,
figsize: tuple[float, float] | None = None,
**kwargs: Any,
) -> tuple[plt.Figure, plt.Axes]:
"""Generate plot for regression discontinuity designs.
Parameters
----------
round_to : int, optional
Number of decimals used to round results. Defaults to 2. Use ``None``
to return raw numbers.
ci_prob : float, optional
Probability mass of the highest density interval drawn around the
posterior predictive band, and the central credible interval
reported in the figure title for the discontinuity at threshold.
Must be in ``(0, 1]``. Ignored for point-estimate models. Defaults
to :data:`~causalpy.constants.HDI_PROB` (currently 0.94).
kind : {"ribbon", "histogram", "spaghetti"}, optional
How posterior uncertainty is rendered. Defaults to ``"ribbon"``.
ci_kind : {"hdi", "eti"}, optional
Credible interval type when ``kind="ribbon"``. Defaults to ``"hdi"``.
num_samples : int, optional
Number of posterior draws when ``kind="spaghetti"``. Defaults to 50.
figsize : tuple of (float, float), optional
Width and height of the figure in inches. Defaults to ``None``
(use matplotlib's default).
"""
with_uncertainty = has_posterior_draws(self.pred)
has_exclusion = len(self.fit_data) < len(self.data)
xcol = self.running_variable_name
ycol = self.outcome_variable_name
plot_x = "__causalpy_plot_x"
plot_y = "__causalpy_plot_y"
plot_series = "__causalpy_plot_series"
points = pd.DataFrame(
{
plot_x: self.data[xcol],
plot_y: self.data[ycol],
plot_series: (
np.where(self._fit_mask, "fit data", "excluded data")
if has_exclusion
else "data"
),
}
)
color_values = (
{"fit data": "k", "excluded data": "lightgray"}
if has_exclusion
else {"data": "k"}
)
# Plotnine provides the equivalent base geometry. Materialize it once so
# the posterior helper can retain its Matplotlib-only rendering modes.
p = ggplot(points, aes(x=plot_x, y=plot_y, color=plot_series)) + geom_point()
# create strings to compose title
r2 = format_r2_score(self.score, round_to=round_to, context="on fit data")
if with_uncertainty:
percentiles = self.discontinuity_at_threshold.quantile(
[(1 - ci_prob) / 2, 1 - (1 - ci_prob) / 2]
).values
ci = (
rf"$CI_{{{ci_prob * 100:.0f}\%}}$"
+ f"[{round_num(percentiles[0], round_to)}, {round_num(percentiles[1], round_to)}]"
)
discon = f"""
Discontinuity at threshold = {round_num(self.discontinuity_at_threshold.mean(), round_to)},
"""
title = r2 + "\n" + discon + ci
else:
discon = f"Discontinuity at threshold = {round_num(_as_scalar(self.discontinuity_at_threshold), round_to)}"
title = r2 + "\n" + discon
model_fit = pd.DataFrame(
{
plot_x: self.x_pred[xcol],
plot_y: self.pred.isel(chain=0, draw=0, treated_units=0),
plot_series: "model fit",
}
)
p += geom_line(model_fit, aes(x=plot_x, y=plot_y, color=plot_series))
color_values["model fit"] = "k"
# Plotnine cannot provide the Axes-resident rule artists that the
# existing public plot contract exposes, so retain these components.
color_values["treatment threshold"] = "r"
if self.donut_hole > 0:
color_values["donut boundary"] = "orange"
fig = (
p
+ scale_color_manual(values=color_values)
+ guides(color=False)
+ labs(title=title, x=xcol, y=ycol)
).draw()
if figsize is not None:
fig.set_size_inches(figsize)
ax = fig.axes[0]
ax.axvline(
x=self.treatment_threshold,
ls="-",
lw=3,
color="r",
label="treatment threshold",
)
if self.donut_hole > 0:
for boundary in (
self.treatment_threshold - self.donut_hole,
self.treatment_threshold + self.donut_hole,
):
ax.axvline(
x=boundary,
ls="--",
lw=2,
color="orange",
label="donut boundary",
)
# Plot model fit to data
if with_uncertainty:
style: _PosteriorPlotStyle = {
"ci_prob": ci_prob,
"kind": kind,
"ci_kind": ci_kind,
"num_samples": num_samples,
}
plot_posterior_over_x(
self.x_pred[self.running_variable_name],
self.pred.isel(treated_units=0),
ax=ax,
**style,
plot_hdi_kwargs={"color": "C1"},
label="Posterior mean",
)
rule_labels = {"treatment threshold", "donut boundary"}
legend_handles = [
Line2D(
[],
[],
color=color,
label=label,
linestyle="None"
if label in {"data", "fit data", "excluded data"}
else "-",
marker="o" if label in {"data", "fit data", "excluded data"} else None,
)
for label, color in color_values.items()
if label not in rule_labels
]
handles, labels = ax.get_legend_handles_labels()
ax.legend(
handles=[*legend_handles, *handles],
labels=[
*(label for label in color_values if label not in rule_labels),
*labels,
],
fontsize=LEGEND_FONT_SIZE,
)
return (fig, ax)
[docs]
def effect_summary(
self,
*,
direction: Literal["increase", "decrease", "two-sided"] = "increase",
alpha: float = 0.05,
min_effect: float | None = None,
) -> EffectSummary:
"""
Generate a decision-ready summary of causal effects for Regression Discontinuity.
Parameters
----------
direction : {"increase", "decrease", "two-sided"}, default="increase"
Direction for tail probability calculation (PyMC only, ignored for OLS).
alpha : float, default=0.05
Significance level for HDI/CI intervals (1-alpha confidence level).
min_effect : float, optional
Region of Practical Equivalence (ROPE) threshold (PyMC only, ignored for OLS).
Returns
-------
EffectSummary
Object with .table (DataFrame) and .text (str) attributes
"""
return _effect_summary_rd(
self,
direction=direction,
alpha=alpha,
min_effect=min_effect,
)