forked from pymc-labs/CausalPy
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathprepostnegd.py
254 lines (227 loc) · 8.86 KB
/
prepostnegd.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
# Copyright 2022 - 2025 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.
"""
Pretest/posttest nonequivalent group design
"""
from typing import List
import arviz as az
import numpy as np
import pandas as pd
import seaborn as sns
from matplotlib import pyplot as plt
from patsy import build_design_matrices, dmatrices
from sklearn.base import RegressorMixin
from causalpy.custom_exceptions import (
DataException,
)
from causalpy.plot_utils import plot_xY
from causalpy.pymc_models import PyMCModel
from causalpy.utils import _is_variable_dummy_coded, round_num
from .base import BaseExperiment
LEGEND_FONT_SIZE = 12
class PrePostNEGD(BaseExperiment):
"""
A class to analyse data from pretest/posttest designs
:param data:
A pandas dataframe
:param formula:
A statistical model formula
:param group_variable_name:
Name of the column in data for the group variable, should be either
binary or boolean
:param pretreatment_variable_name:
Name of the column in data for the pretreatment variable
:param model:
A PyMC model
Example
--------
>>> import causalpy as cp
>>> df = cp.load_data("anova1")
>>> seed = 42
>>> result = cp.PrePostNEGD(
... df,
... formula="post ~ 1 + C(group) + pre",
... group_variable_name="group",
... pretreatment_variable_name="pre",
... model=cp.pymc_models.LinearRegression(
... sample_kwargs={
... "target_accept": 0.95,
... "random_seed": seed,
... "progressbar": False,
... }
... ),
... )
>>> result.summary(round_to=1) # doctest: +NUMBER
==================Pretest/posttest Nonequivalent Group Design===================
Formula: post ~ 1 + C(group) + pre
<BLANKLINE>
Results:
Causal impact = 2, $CI_{94%}$[2, 2]
Model coefficients:
Intercept -0.5, 94% HDI [-1, 0.2]
C(group)[T.1] 2, 94% HDI [2, 2]
pre 1, 94% HDI [1, 1]
sigma 0.5, 94% HDI [0.5, 0.6]
"""
supports_ols = False
supports_bayes = True
def __init__(
self,
data: pd.DataFrame,
formula: str,
group_variable_name: str,
pretreatment_variable_name: str,
model=None,
**kwargs,
):
super().__init__(model=model)
self.data = data
self.expt_type = "Pretest/posttest Nonequivalent Group Design"
self.formula = formula
self.group_variable_name = group_variable_name
self.pretreatment_variable_name = pretreatment_variable_name
self.input_validation()
y, X = dmatrices(formula, self.data)
self._y_design_info = y.design_info
self._x_design_info = X.design_info
self.labels = X.design_info.column_names
self.y, self.X = np.asarray(y), np.asarray(X)
self.outcome_variable_name = y.design_info.column_names[0]
# fit the model to the observed (pre-intervention) data
if isinstance(self.model, PyMCModel):
COORDS = {"coeffs": self.labels, "obs_indx": np.arange(self.X.shape[0])}
self.model.fit(X=self.X, y=self.y, coords=COORDS)
elif isinstance(self.model, RegressorMixin):
raise NotImplementedError("Not implemented for OLS model")
else:
raise ValueError("Model type not recognized")
# Calculate the posterior predictive for the treatment and control for an
# interpolated set of pretest values
# get the model predictions of the observed data
self.pred_xi = np.linspace(
np.min(self.data[self.pretreatment_variable_name]),
np.max(self.data[self.pretreatment_variable_name]),
200,
)
# untreated
x_pred_untreated = pd.DataFrame(
{
self.pretreatment_variable_name: self.pred_xi,
self.group_variable_name: np.zeros(self.pred_xi.shape),
}
)
(new_x_untreated,) = build_design_matrices(
[self._x_design_info], x_pred_untreated
)
self.pred_untreated = self.model.predict(X=np.asarray(new_x_untreated))
# treated
x_pred_treated = pd.DataFrame(
{
self.pretreatment_variable_name: self.pred_xi,
self.group_variable_name: np.ones(self.pred_xi.shape),
}
)
(new_x_treated,) = build_design_matrices([self._x_design_info], x_pred_treated)
self.pred_treated = self.model.predict(X=np.asarray(new_x_treated))
# Evaluate causal impact as equal to the trestment effect
self.causal_impact = self.model.idata.posterior["beta"].sel(
{"coeffs": self._get_treatment_effect_coeff()}
)
def input_validation(self) -> None:
"""Validate the input data and model formula for correctness"""
if not _is_variable_dummy_coded(self.data[self.group_variable_name]):
raise DataException(
f"""
There must be 2 levels of the grouping variable
{self.group_variable_name}. I.e. the treated and untreated.
"""
)
def _get_treatment_effect_coeff(self) -> str:
"""Find the beta regression coefficient corresponding to the
group (i.e. treatment) effect.
For example if self.group_variable_name is 'group' and
the labels are `['Intercept', 'C(group)[T.1]', 'pre']`
then we want `C(group)[T.1]`.
"""
for label in self.labels:
if (self.group_variable_name in label) & (":" not in label):
return label
raise NameError("Unable to find coefficient name for the treatment effect")
def _causal_impact_summary_stat(self, round_to) -> str:
"""Computes the mean and 94% credible interval bounds for the causal impact."""
percentiles = self.causal_impact.quantile([0.03, 1 - 0.03]).values
ci = (
r"$CI_{94%}$"
+ f"[{round_num(percentiles[0], round_to)}, {round_num(percentiles[1], round_to)}]"
)
causal_impact = f"{round_num(self.causal_impact.mean(), round_to)}, "
return f"Causal impact = {causal_impact + ci}"
def summary(self, round_to=None) -> None:
"""Print summary of main results and model coefficients.
:param round_to:
Number of decimals used to round results. Defaults to 2. Use "None" to return raw numbers
"""
print(f"{self.expt_type:=^80}")
print(f"Formula: {self.formula}")
print("\nResults:")
# TODO: extra experiment specific outputs here
print(self._causal_impact_summary_stat(round_to))
self.print_coefficients(round_to)
def _bayesian_plot(
self, round_to=None, **kwargs
) -> tuple[plt.Figure, List[plt.Axes]]:
"""Generate plot for ANOVA-like experiments with non-equivalent group designs."""
fig, ax = plt.subplots(
2, 1, figsize=(7, 9), gridspec_kw={"height_ratios": [3, 1]}
)
# Plot raw data
sns.scatterplot(
x="pre",
y="post",
hue="group",
alpha=0.5,
data=self.data,
legend=True,
ax=ax[0],
)
ax[0].set(xlabel="Pretest", ylabel="Posttest")
# plot posterior predictive of untreated
h_line, h_patch = plot_xY(
self.pred_xi,
self.pred_untreated["posterior_predictive"].mu,
ax=ax[0],
plot_hdi_kwargs={"color": "C0"},
label="Control group",
)
handles = [(h_line, h_patch)]
labels = ["Control group"]
# plot posterior predictive of treated
h_line, h_patch = plot_xY(
self.pred_xi,
self.pred_treated["posterior_predictive"].mu,
ax=ax[0],
plot_hdi_kwargs={"color": "C1"},
label="Treatment group",
)
handles.append((h_line, h_patch))
labels.append("Treatment group")
ax[0].legend(
handles=(h_tuple for h_tuple in handles),
labels=labels,
fontsize=LEGEND_FONT_SIZE,
)
# Plot estimated caual impact / treatment effect
az.plot_posterior(self.causal_impact, ref_val=0, ax=ax[1], round_to=round_to)
ax[1].set(title="Estimated treatment effect")
return fig, ax