Note
-
Download Jupyter notebook:
https://docs.doubleml.org/stable/examples/py_double_ml_cate_plr.ipynb.
Python: Conditional Average Treatment Effects (CATEs) for PLR models#
In this simple example, we illustrate how the DoubleML package can be used to estimate conditional average treatment effects with B-splines for one or two-dimensional effects in the DoubleMLPLR model.
[1]:
import numpy as np
import pandas as pd
import doubleml as dml
from doubleml.datasets import make_heterogeneous_data
Data#
We define a data generating process to create synthetic data to compare the estimates to the true effect. The data generating process is based on the Monte Carlo simulation from Oprescu et al. (2019).
The documentation of the data generating process can be found here.
One-dimensional Example#
We start with an one-dimensional effect and create our training data. In this example the true effect depends only the first covariate \(X_0\) and takes the following form
The generated dictionary also contains a callable with key treatment_effect
to calculate the true treatment effect for new observations.
[2]:
np.random.seed(42)
data_dict = make_heterogeneous_data(
n_obs=2000,
p=10,
support_size=5,
n_x=1,
)
treatment_effect = data_dict['treatment_effect']
data = data_dict['data']
print(data.head())
y d X_0 X_1 X_2 X_3 X_4 \
0 1.564451 0.241064 0.259828 0.886086 0.895690 0.297287 0.229994
1 1.114570 0.040912 0.824350 0.396992 0.156317 0.737951 0.360475
2 8.901013 1.392623 0.988421 0.977280 0.793818 0.659423 0.577807
3 -1.315155 -0.551317 0.427486 0.330285 0.564232 0.850575 0.201528
4 1.314625 0.683487 0.016200 0.818380 0.040139 0.889913 0.991963
X_5 X_6 X_7 X_8 X_9
0 0.411304 0.240532 0.672384 0.826065 0.673092
1 0.671271 0.270644 0.081230 0.992582 0.156202
2 0.866102 0.289440 0.467681 0.619390 0.411190
3 0.934433 0.689088 0.823273 0.556191 0.779517
4 0.294067 0.210319 0.765363 0.253026 0.865562
First, define the DoubleMLData
object.
[3]:
data_dml_base = dml.DoubleMLData(
data,
y_col='y',
d_cols='d'
)
Next, define the learners for the nuisance functions and fit the PLR Model. Remark that linear learners would usually be optimal due to the data generating process.
[4]:
# First stage estimation
from sklearn.ensemble import RandomForestClassifier, RandomForestRegressor
ml_l = RandomForestRegressor(n_estimators=500)
ml_m = RandomForestRegressor(n_estimators=500)
np.random.seed(42)
dml_plr = dml.DoubleMLPLR(data_dml_base,
ml_l=ml_l,
ml_m=ml_m,
n_folds=5)
print("Training PLR Model")
dml_plr.fit()
print(dml_plr.summary)
Training PLR Model
coef std err t P>|t| 2.5 % 97.5 %
d 4.377669 0.043998 99.497422 0.0 4.291434 4.463903
To estimate the CATE, we rely on the best-linear-predictor of the linear score as in Semenova et al. (2021) To approximate the target function \(\theta_0(x)\) with a linear form, we have to define a data frame of basis functions. Here, we rely on patsy to construct a suitable basis of B-splines.
[5]:
import patsy
design_matrix = patsy.dmatrix("bs(x, df=5, degree=2)", {"x": data["X_0"]})
spline_basis = pd.DataFrame(design_matrix)
To estimate the parameters to calculate the CATE estimate call the cate()
method and supply the dataframe of basis elements.
[6]:
cate = dml_plr.cate(spline_basis)
print(cate)
================== DoubleMLBLP Object ==================
------------------ Fit summary ------------------
coef std err t P>|t| [0.025 0.975]
0 1.239313 0.141729 8.744228 4.675733e-18 0.961360 1.517266
1 1.581849 0.237292 6.666259 3.385877e-11 1.116483 2.047215
2 4.178218 0.150136 27.829619 2.742758e-144 3.883778 4.472657
3 4.040919 0.182692 22.118721 3.950131e-97 3.682631 4.399207
4 3.272408 0.186795 17.518682 5.025783e-64 2.906073 3.638742
5 3.796384 0.194232 19.545602 5.614185e-78 3.415465 4.177304
To obtain the confidence intervals for the CATE, we have to call the confint()
method and a supply a dataframe of basis elements. This could be the same basis as for fitting the CATE model or a new basis to e.g. evaluate the CATE model on a grid. Here, we will evaluate the CATE on a grid from 0.1 to 0.9 to plot the final results. Further, we construct uniform confidence intervals by setting the option joint
and providing a number of bootstrap repetitions n_rep_boot
.
[7]:
new_data = {"x": np.linspace(0.1, 0.9, 100)}
spline_grid = pd.DataFrame(patsy.build_design_matrices([design_matrix.design_info], new_data)[0])
df_cate = cate.confint(spline_grid, level=0.95, joint=True, n_rep_boot=2000)
print(df_cate)
2.5 % effect 97.5 %
0 2.199412 2.429057 2.658702
1 2.289357 2.521611 2.753866
2 2.376806 2.613622 2.850439
3 2.462567 2.705090 2.947613
4 2.547324 2.796014 3.044704
.. ... ... ...
95 4.494089 4.734770 4.975450
96 4.502901 4.738065 4.973229
97 4.514173 4.743341 4.972509
98 4.527452 4.750597 4.973741
99 4.542159 4.759833 4.977507
[100 rows x 3 columns]
Finally, we can plot our results and compare them with the true effect.
[8]:
from matplotlib import pyplot as plt
plt.rcParams['figure.figsize'] = 10., 7.5
df_cate['x'] = new_data['x']
df_cate['true_effect'] = treatment_effect(new_data["x"].reshape(-1, 1))
fig, ax = plt.subplots()
ax.plot(df_cate['x'],df_cate['effect'], label='Estimated Effect')
ax.plot(df_cate['x'],df_cate['true_effect'], color="green", label='True Effect')
ax.fill_between(df_cate['x'], df_cate['2.5 %'], df_cate['97.5 %'], color='b', alpha=.3, label='Confidence Interval')
plt.legend()
plt.title('CATE')
plt.xlabel('x')
_ = plt.ylabel('Effect and 95%-CI')
If the effect is not one-dimensional, the estimate still corresponds to the projection of the true effect on the basis functions.
Two-Dimensional Example#
It is also possible to estimate multi-dimensional conditional effects. We will use a similar data generating process but now the effect depends on the first two covariates \(X_0\) and \(X_1\) and takes the following form
With the argument n_x=2
we can specify set the effect to be two-dimensional.
[9]:
np.random.seed(42)
data_dict = make_heterogeneous_data(
n_obs=5000,
p=10,
support_size=5,
n_x=2,
)
treatment_effect = data_dict['treatment_effect']
data = data_dict['data']
print(data.head())
y d X_0 X_1 X_2 X_3 X_4 \
0 -0.359307 -0.479722 0.014080 0.006958 0.240127 0.100807 0.260211
1 0.578557 -0.587135 0.152148 0.912230 0.892796 0.653901 0.672234
2 1.479882 0.172083 0.344787 0.893649 0.291517 0.562712 0.099731
3 4.468072 0.480579 0.619351 0.232134 0.000943 0.757151 0.985207
4 5.949866 0.974213 0.477130 0.447624 0.775191 0.526769 0.316717
X_5 X_6 X_7 X_8 X_9
0 0.177043 0.028520 0.909304 0.008223 0.736082
1 0.005339 0.984872 0.877833 0.895106 0.659245
2 0.921956 0.140770 0.224897 0.558134 0.764093
3 0.809913 0.460207 0.903767 0.409848 0.524934
4 0.258158 0.037747 0.583195 0.229961 0.148134
As univariate example estimate the PLR Model.
[10]:
data_dml_base = dml.DoubleMLData(
data,
y_col='y',
d_cols='d'
)
[11]:
# First stage estimation
from sklearn.ensemble import RandomForestClassifier, RandomForestRegressor
ml_l = RandomForestRegressor(n_estimators=500)
ml_m = RandomForestRegressor(n_estimators=500)
np.random.seed(42)
dml_plr = dml.DoubleMLPLR(data_dml_base,
ml_l=ml_l,
ml_m=ml_m,
n_folds=5)
print("Training PLR Model")
dml_plr.fit()
print(dml_plr.summary)
Training PLR Model
coef std err t P>|t| 2.5 % 97.5 %
d 4.469885 0.049733 89.877289 0.0 4.37241 4.567361
As above, we will rely on the patsy package to construct the basis elements. In the two-dimensional case, we will construct a tensor product of B-splines (for more information see here).
[12]:
design_matrix = patsy.dmatrix("te(bs(x_0, df=7, degree=3), bs(x_1, df=7, degree=3))", {"x_0": data["X_0"], "x_1": data["X_1"]})
spline_basis = pd.DataFrame(design_matrix)
cate = dml_plr.cate(spline_basis)
print(cate)
================== DoubleMLBLP Object ==================
------------------ Fit summary ------------------
coef std err t P>|t| [0.025 0.975]
0 2.785155 0.103184 26.992177 8.032420e-150 2.582869 2.987440
1 -3.327397 0.865496 -3.844496 1.223160e-04 -5.024153 -1.630641
2 3.070172 0.886948 3.461500 5.417145e-04 1.331360 4.808984
3 1.732388 0.768296 2.254844 2.418666e-02 0.226187 3.238590
4 2.016240 0.772250 2.610866 9.058565e-03 0.502289 3.530192
5 -3.887451 0.929517 -4.182226 2.936604e-05 -5.709717 -2.065185
6 -5.447955 1.011405 -5.386520 7.515881e-08 -7.430757 -3.465152
7 -7.346739 0.916634 -8.014906 1.363906e-15 -9.143749 -5.549729
8 -0.628925 0.909099 -0.691811 4.890885e-01 -2.411162 1.153312
9 -0.215756 0.962994 -0.224047 8.227300e-01 -2.103651 1.672139
10 1.906596 0.826349 2.307254 2.108175e-02 0.286586 3.526606
11 0.571864 0.826176 0.692182 4.888556e-01 -1.047807 2.191535
12 -0.869982 1.044030 -0.833293 4.047199e-01 -2.916743 1.176779
13 -1.231931 1.127681 -1.092446 2.746902e-01 -3.442685 0.978824
14 -2.163689 0.955649 -2.264103 2.361088e-02 -4.037185 -0.290192
15 0.083059 0.788372 0.105355 9.160981e-01 -1.462499 1.628617
16 2.891456 0.795531 3.634624 2.812119e-04 1.331863 4.451049
17 1.899296 0.682343 2.783492 5.398116e-03 0.561602 3.236991
18 2.679876 0.666883 4.018509 5.943705e-05 1.372489 3.987263
19 -1.994036 0.845023 -2.359741 1.832630e-02 -3.650656 -0.337416
20 -1.966054 0.842814 -2.332726 1.970240e-02 -3.618343 -0.313765
21 -4.126810 0.734967 -5.614956 2.073389e-08 -5.567672 -2.685948
22 0.162227 0.780091 0.207959 8.352696e-01 -1.367097 1.691551
23 4.427678 0.796196 5.561040 2.822525e-08 2.866781 5.988575
24 2.310201 0.692187 3.337539 8.514763e-04 0.953208 3.667194
25 3.344909 0.656470 5.095295 3.611423e-07 2.057937 4.631882
26 0.030830 0.860246 0.035839 9.714121e-01 -1.655633 1.717294
27 -1.185413 0.951078 -1.246389 2.126806e-01 -3.049947 0.679121
28 -1.139663 0.892272 -1.277260 2.015705e-01 -2.888912 0.609586
29 5.554896 0.996408 5.574920 2.607761e-08 3.601494 7.508298
30 1.414995 0.972671 1.454751 1.458016e-01 -0.491872 3.321862
31 7.391627 0.858471 8.610228 9.669156e-18 5.708645 9.074610
32 2.911582 0.815494 3.570329 3.599180e-04 1.312852 4.510312
33 2.227797 1.040103 2.141899 3.225023e-02 0.188733 4.266860
34 0.200374 1.133278 0.176809 8.596657e-01 -2.021354 2.422102
35 1.053614 1.048277 1.005092 3.149018e-01 -1.001473 3.108702
36 6.565352 1.027969 6.386724 1.849992e-10 4.550078 8.580627
37 5.124503 1.050382 4.878706 1.101333e-06 3.065289 7.183716
38 7.072994 0.836451 8.455961 3.600258e-17 5.433180 8.712808
39 5.940895 0.907147 6.548985 6.380716e-11 4.162484 7.719306
40 4.310847 1.114559 3.867761 1.112495e-04 2.125818 6.495877
41 1.631615 1.254564 1.300543 1.934756e-01 -0.827888 4.091117
42 -0.770954 1.095776 -0.703569 4.817344e-01 -2.919159 1.377252
43 8.383033 0.996913 8.408991 5.348457e-17 6.428642 10.337425
44 7.245953 1.161076 6.240721 4.718746e-10 4.969729 9.522178
45 8.040651 0.825869 9.735989 3.345933e-22 6.421582 9.659721
46 7.017732 0.868965 8.075962 8.337631e-16 5.314175 8.721290
47 4.653178 1.041086 4.469543 8.013681e-06 2.612188 6.694168
48 2.487801 0.956192 2.601779 9.301769e-03 0.613241 4.362362
49 3.652448 0.821852 4.444167 9.016003e-06 2.041253 5.263642
Finally, we create a new grid to evaluate and plot the effects.
[13]:
grid_size = 100
x_0 = np.linspace(0.1, 0.9, grid_size)
x_1 = np.linspace(0.1, 0.9, grid_size)
x_0, x_1 = np.meshgrid(x_0, x_1)
new_data = {"x_0": x_0.ravel(), "x_1": x_1.ravel()}
[14]:
spline_grid = pd.DataFrame(patsy.build_design_matrices([design_matrix.design_info], new_data)[0])
df_cate = cate.confint(spline_grid, joint=True, n_rep_boot=2000)
print(df_cate)
2.5 % effect 97.5 %
0 1.333516 2.035184 2.736852
1 1.349439 2.035353 2.721268
2 1.376551 2.042153 2.707755
3 1.412508 2.055069 2.697630
4 1.454862 2.073587 2.692311
... ... ... ...
9995 3.543515 4.251205 4.958896
9996 3.582029 4.332346 5.082662
9997 3.619083 4.416029 5.212975
9998 3.659377 4.502548 5.345718
9999 3.707256 4.592195 5.477133
[10000 rows x 3 columns]
[15]:
import plotly.graph_objects as go
grid_array = np.array(list(zip(x_0.ravel(), x_1.ravel())))
true_effect = treatment_effect(grid_array).reshape(x_0.shape)
effect = np.asarray(df_cate['effect']).reshape(x_0.shape)
lower_bound = np.asarray(df_cate['2.5 %']).reshape(x_0.shape)
upper_bound = np.asarray(df_cate['97.5 %']).reshape(x_0.shape)
fig = go.Figure(data=[
go.Surface(x=x_0,
y=x_1,
z=true_effect),
go.Surface(x=x_0,
y=x_1,
z=upper_bound, showscale=False, opacity=0.4,colorscale='purp'),
go.Surface(x=x_0,
y=x_1,
z=lower_bound, showscale=False, opacity=0.4,colorscale='purp'),
])
fig.update_traces(contours_z=dict(show=True, usecolormap=True,
highlightcolor="limegreen", project_z=True))
fig.update_layout(scene = dict(
xaxis_title='X_0',
yaxis_title='X_1',
zaxis_title='Effect'),
width=700,
margin=dict(r=20, b=10, l=10, t=10))
fig.show()