Skip to content

Commit 984163e

Browse files
Consolidate experiment design-matrix attributes into xr.Dataset (#849)
* Make _data_setter customizable via class attributes Add _predictor_data_name and _target_data_name class attributes to PyMCModel so subclasses using non-default data node names can customize without re-implementing _data_setter. Validate at predict time and raise a clear ValueError if expected nodes are missing. Also fix pre-existing mypy type: ignore codes in panel_regression.py (attr-defined -> union-attr). Made-with: Cursor * Simplify _data_setter: drop class attributes, keep validation Remove _predictor_data_name / _target_data_name class attributes (added complexity for a case no existing subclass needs). Keep the validation that raises a clear ValueError when expected data nodes are missing. Revert undeclared y_dtype behavioral change. Improve test coverage with separate X-missing and y-missing error paths. Made-with: Cursor * Consolidate experiment design-matrix attributes into xr.Dataset Bundle loose xr.DataArray attributes on experiment classes into xr.Dataset objects to reduce attribute sprawl. Pre/post classes (ITS, SC) use pre_design/post_design; formula-based classes use a single design Dataset. Deprecated @Property accessors preserve backward compatibility. Closes #199. Made-with: Cursor * Centralize deprecated design-alias boilerplate and fix internal callers Move _build_design_dataset helper and __getattr__ deprecation forwarding into BaseExperiment, replacing per-class @Property blocks with a declarative _deprecated_design_aliases dict. Fix convex_hull.py and maketables_adapters.py to use the new API directly. Add parametrized backward-compatibility tests covering all deprecated aliases. Made-with: Cursor * Address review comments and migrate SDiD to design datasets - Migrate SyntheticDifferenceInDifferences (added on main in #823) to the pre_design/post_design Dataset pattern with deprecated aliases, matching SyntheticControl, and extend the alias test suite to cover it. - Normalize convex-hull call sites on xarray inputs and document that check_convex_hull_violation accepts numpy or xarray. - Replace type: ignore escape hatches in ConvexHullCheck.run with an isinstance assertion that narrows the type for mypy. - Point users at BayesianBasisExpansionTimeSeries in the _data_setter error message. Co-authored-by: Cursor <cursoragent@cursor.com> * Update UML diagrams after design-dataset consolidation Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 0197f17 commit 984163e

22 files changed

Lines changed: 709 additions & 333 deletions

causalpy/checks/convex_hull.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,11 @@ def run(
6363
context : PipelineContext
6464
Pipeline context (unused; required by the check protocol).
6565
"""
66+
self.validate(experiment)
67+
assert isinstance(experiment, SyntheticControl) # narrows type for mypy
6668
sc = experiment
67-
datapre_control = sc.datapre_control # type: ignore[attr-defined]
68-
datapre_treated = sc.datapre_treated # type: ignore[attr-defined]
69+
datapre_control = sc.pre_design["control"]
70+
datapre_treated = sc.pre_design["treated"]
6971

7072
all_results = []
7173
total_violations = 0
@@ -82,7 +84,7 @@ def run(
8284
rows = []
8385
treated_units = getattr(
8486
sc, "treated_units", [f"unit_{i}" for i in range(len(all_results))]
85-
) # type: ignore[attr-defined]
87+
)
8688
for unit_name, res in zip(treated_units, all_results, strict=True):
8789
rows.append(
8890
{

causalpy/experiments/base.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@
2626

2727
import arviz as az
2828
import matplotlib.pyplot as plt
29+
import numpy as np
2930
import pandas as pd
31+
import xarray as xr
3032
from sklearn.base import RegressorMixin, clone
3133

3234
from causalpy.maketables_adapters import get_maketables_adapter
@@ -122,6 +124,67 @@ class BaseExperiment(ABC):
122124

123125
_default_model_class: type[PyMCModel] | None = None
124126

127+
_deprecated_design_aliases: dict[str, tuple[str, str]] = {}
128+
"""Mapping of ``old_attr -> (dataset_attr, key)`` for deprecated design
129+
matrix accessors. Subclasses populate this so that
130+
``__getattr__`` can forward accesses with a deprecation warning."""
131+
132+
def __getattr__(self, name: str) -> Any:
133+
aliases = type(self)._deprecated_design_aliases
134+
if name in aliases:
135+
dataset_attr, key = aliases[name]
136+
warnings.warn(
137+
f"{name} is deprecated, use {dataset_attr}['{key}']",
138+
DeprecationWarning,
139+
stacklevel=2,
140+
)
141+
return getattr(self, dataset_attr)[key]
142+
raise AttributeError(
143+
f"'{type(self).__name__}' object has no attribute '{name}'"
144+
)
145+
146+
@staticmethod
147+
def _build_design_dataset(
148+
X_raw: np.ndarray,
149+
y_raw: np.ndarray,
150+
*,
151+
obs_ind: np.ndarray | pd.Index,
152+
coeffs: list[str],
153+
treated_units: list[str] | None = None,
154+
) -> xr.Dataset:
155+
"""Build a standard ``xr.Dataset`` from raw design matrices.
156+
157+
Parameters
158+
----------
159+
X_raw : np.ndarray
160+
Predictor matrix, shape ``(n_obs, n_coeffs)``.
161+
y_raw : np.ndarray
162+
Outcome matrix, shape ``(n_obs, n_units)``.
163+
obs_ind : array-like
164+
Observation index coordinates.
165+
coeffs : list[str]
166+
Coefficient / column names for ``X_raw``.
167+
treated_units : list[str], optional
168+
Names for the treated-unit dimension of ``y_raw``.
169+
Defaults to ``["unit_0"]``.
170+
"""
171+
if treated_units is None:
172+
treated_units = ["unit_0"]
173+
return xr.Dataset(
174+
{
175+
"X": xr.DataArray(
176+
X_raw,
177+
dims=["obs_ind", "coeffs"],
178+
coords={"obs_ind": obs_ind, "coeffs": coeffs},
179+
),
180+
"y": xr.DataArray(
181+
y_raw,
182+
dims=["obs_ind", "treated_units"],
183+
coords={"obs_ind": obs_ind, "treated_units": treated_units},
184+
),
185+
}
186+
)
187+
125188
def __init__(self, model: PyMCModel | RegressorMixin | None = None) -> None:
126189
# Ensure we've made any provided Scikit Learn model (as identified as being type
127190
# RegressorMixin) compatible with CausalPy by appending our custom methods.

causalpy/experiments/diff_in_diff.py

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ class DifferenceInDifferences(BaseExperiment):
9898
supports_ols = True
9999
supports_bayes = True
100100
_default_model_class = LinearRegression
101+
_deprecated_design_aliases = {"X": ("design", "X"), "y": ("design", "y")}
101102

102103
def __init__(
103104
self,
@@ -130,37 +131,34 @@ def _build_design_matrices(self) -> None:
130131
self._y_design_info = y.design_info
131132
self._x_design_info = X.design_info
132133
self.labels = X.design_info.column_names
133-
self.y, self.X = np.asarray(y), np.asarray(X)
134+
self._y_raw, self._X_raw = np.asarray(y), np.asarray(X)
134135
self.outcome_variable_name = y.design_info.column_names[0]
135136

136137
def _prepare_data(self) -> None:
137-
"""Convert design matrices to xarray DataArrays."""
138-
self.X = xr.DataArray(
139-
self.X,
140-
dims=["obs_ind", "coeffs"],
141-
coords={
142-
"obs_ind": np.arange(self.X.shape[0]),
143-
"coeffs": self.labels,
144-
},
145-
)
146-
self.y = xr.DataArray(
147-
self.y,
148-
dims=["obs_ind", "treated_units"],
149-
coords={"obs_ind": np.arange(self.y.shape[0]), "treated_units": ["unit_0"]},
138+
"""Bundle design matrices into an ``xr.Dataset``."""
139+
n = self._X_raw.shape[0]
140+
self.design = self._build_design_dataset(
141+
self._X_raw,
142+
self._y_raw,
143+
obs_ind=np.arange(n),
144+
coeffs=self.labels,
150145
)
146+
del self._X_raw, self._y_raw
151147

152148
def algorithm(self) -> None:
153149
"""Run the experiment algorithm: fit model, predict, and calculate causal impact."""
154-
# fit model
150+
X = self.design["X"]
151+
y = self.design["y"]
152+
155153
if isinstance(self.model, PyMCModel):
156154
COORDS = {
157155
"coeffs": self.labels,
158-
"obs_ind": np.arange(self.X.shape[0]),
156+
"obs_ind": np.arange(X.shape[0]),
159157
"treated_units": ["unit_0"],
160158
}
161-
self.model.fit(X=self.X, y=self.y, coords=COORDS)
159+
self.model.fit(X=X, y=y, coords=COORDS)
162160
elif isinstance(self.model, RegressorMixin):
163-
self.model.fit(X=self.X, y=self.y)
161+
self.model.fit(X=X, y=y)
164162
else:
165163
raise ValueError("Model type not recognized")
166164

causalpy/experiments/interrupted_time_series.py

Lines changed: 47 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,12 @@ class InterruptedTimeSeries(BaseExperiment):
127127
supports_ols = True
128128
supports_bayes = True
129129
_default_model_class = LinearRegression
130+
_deprecated_design_aliases = {
131+
"pre_X": ("pre_design", "X"),
132+
"pre_y": ("pre_design", "y"),
133+
"post_X": ("post_design", "X"),
134+
"post_y": ("post_design", "y"),
135+
}
130136

131137
def __init__(
132138
self,
@@ -138,9 +144,8 @@ def __init__(
138144
**kwargs: Any,
139145
) -> None:
140146
super().__init__(model=model)
141-
self.pre_y: xr.DataArray
142-
self.post_y: xr.DataArray
143-
# rename the index to "obs_ind"
147+
self.pre_design: xr.Dataset
148+
self.post_design: xr.Dataset
144149
data.index.name = "obs_ind"
145150
self.data = data
146151
self.input_validation(data, treatment_time, treatment_end_time)
@@ -154,96 +159,76 @@ def __init__(
154159

155160
def _build_design_matrices(self) -> None:
156161
"""Build design matrices for pre and post intervention periods using patsy."""
157-
# set things up with pre-intervention data
158162
y, X = dmatrices(self.formula, self.datapre)
159163
self.outcome_variable_name = y.design_info.column_names[0]
160164
self._y_design_info = y.design_info
161165
self._x_design_info = X.design_info
162166
self.labels = X.design_info.column_names
163-
self.pre_y, self.pre_X = np.asarray(y), np.asarray(X)
164-
# process post-intervention data
167+
self._pre_y_raw, self._pre_X_raw = np.asarray(y), np.asarray(X)
165168
(new_y, new_x) = build_design_matrices(
166169
[self._y_design_info, self._x_design_info], self.datapost
167170
)
168-
self.post_X = np.asarray(new_x)
169-
self.post_y = np.asarray(new_y)
171+
self._post_X_raw = np.asarray(new_x)
172+
self._post_y_raw = np.asarray(new_y)
170173

171174
def _prepare_data(self) -> None:
172-
"""Convert design matrices to xarray DataArrays for pre and post periods."""
173-
self.pre_X = xr.DataArray(
174-
self.pre_X,
175-
dims=["obs_ind", "coeffs"],
176-
coords={
177-
"obs_ind": self.datapre.index,
178-
"coeffs": self.labels,
179-
},
180-
)
181-
self.pre_y = xr.DataArray(
182-
self.pre_y, # Keep 2D shape
183-
dims=["obs_ind", "treated_units"],
184-
coords={"obs_ind": self.datapre.index, "treated_units": ["unit_0"]},
185-
)
186-
self.post_X = xr.DataArray(
187-
self.post_X,
188-
dims=["obs_ind", "coeffs"],
189-
coords={
190-
"obs_ind": self.datapost.index,
191-
"coeffs": self.labels,
192-
},
175+
"""Bundle design matrices into ``xr.Dataset`` objects for pre and post periods."""
176+
self.pre_design = self._build_design_dataset(
177+
self._pre_X_raw,
178+
self._pre_y_raw,
179+
obs_ind=self.datapre.index,
180+
coeffs=self.labels,
193181
)
194-
self.post_y = xr.DataArray(
195-
self.post_y, # Keep 2D shape
196-
dims=["obs_ind", "treated_units"],
197-
coords={"obs_ind": self.datapost.index, "treated_units": ["unit_0"]},
182+
self.post_design = self._build_design_dataset(
183+
self._post_X_raw,
184+
self._post_y_raw,
185+
obs_ind=self.datapost.index,
186+
coeffs=self.labels,
198187
)
188+
del self._pre_X_raw, self._pre_y_raw, self._post_X_raw, self._post_y_raw
199189

200190
def algorithm(self) -> None:
201191
"""Run the experiment algorithm: fit model, predict, and calculate causal impact."""
202-
# fit the model to the observed (pre-intervention) data
203-
# All PyMC models now accept xr.DataArray with consistent API
192+
pre_X = self.pre_design["X"]
193+
pre_y = self.pre_design["y"]
194+
post_X = self.post_design["X"]
195+
post_y = self.post_design["y"]
196+
204197
if isinstance(self.model, PyMCModel):
205198
COORDS: dict[str, Any] = {
206199
"coeffs": self.labels,
207-
"obs_ind": np.arange(self.pre_X.shape[0]),
200+
"obs_ind": np.arange(pre_X.shape[0]),
208201
"treated_units": ["unit_0"],
209-
"datetime_index": self.datapre.index, # For time series models
202+
"datetime_index": self.datapre.index,
210203
}
211-
self.model.fit(X=self.pre_X, y=self.pre_y, coords=COORDS)
204+
self.model.fit(X=pre_X, y=pre_y, coords=COORDS)
212205
elif isinstance(self.model, RegressorMixin):
213-
# For OLS models, use 1D y data
214-
self.model.fit(X=self.pre_X, y=self.pre_y.isel(treated_units=0))
206+
self.model.fit(X=pre_X, y=pre_y.isel(treated_units=0))
215207
else:
216208
raise ValueError("Model type not recognized")
217209

218-
# score the goodness of fit to the pre-intervention data
219210
if isinstance(self.model, PyMCModel):
220-
self.score = self.model.score(X=self.pre_X, y=self.pre_y)
211+
self.score = self.model.score(X=pre_X, y=pre_y)
221212
elif isinstance(self.model, RegressorMixin):
222-
self.score = self.model.score(
223-
X=self.pre_X, y=self.pre_y.isel(treated_units=0)
224-
)
213+
self.score = self.model.score(X=pre_X, y=pre_y.isel(treated_units=0))
225214

226-
# get the model predictions of the observed (pre-intervention) data
227215
if isinstance(self.model, PyMCModel | RegressorMixin):
228-
self.pre_pred = self.model.predict(X=self.pre_X)
216+
self.pre_pred = self.model.predict(X=pre_X)
229217

230-
# calculate the counterfactual (post period)
231218
if isinstance(self.model, PyMCModel):
232-
self.post_pred = self.model.predict(X=self.post_X, out_of_sample=True)
219+
self.post_pred = self.model.predict(X=post_X, out_of_sample=True)
233220
elif isinstance(self.model, RegressorMixin):
234-
self.post_pred = self.model.predict(X=self.post_X)
221+
self.post_pred = self.model.predict(X=post_X)
235222

236-
# calculate impact - all PyMC models now use 2D data with treated_units
237223
if isinstance(self.model, PyMCModel):
238-
self.pre_impact = self.model.calculate_impact(self.pre_y, self.pre_pred)
239-
self.post_impact = self.model.calculate_impact(self.post_y, self.post_pred)
224+
self.pre_impact = self.model.calculate_impact(pre_y, self.pre_pred)
225+
self.post_impact = self.model.calculate_impact(post_y, self.post_pred)
240226
elif isinstance(self.model, RegressorMixin):
241-
# SKL models work with 1D data
242227
self.pre_impact = self.model.calculate_impact(
243-
self.pre_y.isel(treated_units=0), self.pre_pred
228+
pre_y.isel(treated_units=0), self.pre_pred
244229
)
245230
self.post_impact = self.model.calculate_impact(
246-
self.post_y.isel(treated_units=0), self.post_pred
231+
post_y.isel(treated_units=0), self.post_pred
247232
)
248233

249234
self.post_impact_cumulative = self.model.calculate_cumulative_impact(
@@ -740,9 +725,7 @@ def _bayesian_plot(
740725

741726
(h,) = ax[0].plot(
742727
self.datapre.index,
743-
self.pre_y.isel(treated_units=0)
744-
if hasattr(self.pre_y, "isel")
745-
else self.pre_y[:, 0],
728+
self.pre_design["y"].isel(treated_units=0),
746729
"k.",
747730
label="Observations",
748731
)
@@ -779,9 +762,7 @@ def _bayesian_plot(
779762

780763
ax[0].plot(
781764
self.datapost.index,
782-
self.post_y.isel(treated_units=0)
783-
if hasattr(self.post_y, "isel")
784-
else self.post_y[:, 0],
765+
self.post_design["y"].isel(treated_units=0),
785766
"k.",
786767
zorder=3,
787768
)
@@ -797,9 +778,7 @@ def _bayesian_plot(
797778
h = ax[0].fill_between(
798779
self.datapost.index,
799780
y1=post_pred_mu,
800-
y2=self.post_y.isel(treated_units=0)
801-
if hasattr(self.post_y, "isel")
802-
else self.post_y[:, 0],
781+
y2=self.post_design["y"].isel(treated_units=0),
803782
color="C0",
804783
alpha=0.25,
805784
)
@@ -959,10 +938,10 @@ def _ols_plot(
959938

960939
fig, ax = plt.subplots(3, 1, sharex=True, figsize=figsize)
961940

962-
ax[0].plot(self.datapre.index, self.pre_y, "k.")
941+
ax[0].plot(self.datapre.index, self.pre_design["y"], "k.")
963942
ax[0].plot(self.datapre.index, self.pre_pred, c="k", label="model fit")
964943

965-
ax[0].plot(self.datapost.index, self.post_y, "k.")
944+
ax[0].plot(self.datapost.index, self.post_design["y"], "k.")
966945
ax[0].plot(
967946
self.datapost.index,
968947
self.post_pred,
@@ -974,7 +953,7 @@ def _ols_plot(
974953
ax[0].fill_between(
975954
self.datapost.index,
976955
y1=np.squeeze(self.post_pred),
977-
y2=np.squeeze(self.post_y),
956+
y2=np.squeeze(self.post_design["y"]),
978957
color="C0",
979958
alpha=0.25,
980959
label="Causal impact",

0 commit comments

Comments
 (0)