-
Notifications
You must be signed in to change notification settings - Fork 76
Meta learners #170
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Meta learners #170
Changes from 29 commits
2c0d551
ab999b6
9791b9b
100f8d7
5874281
df90a52
b8a3dff
020a65f
667d3b4
d05c156
542e129
5f8a62f
759b9e2
8c03319
18baff5
f9d9817
9917a83
a8d6467
55b43df
9d5bb61
3f77e76
faf0db5
c1bbf33
2f689dd
b57e31a
483d55b
d62eb18
95e010e
3e1182d
806cd0f
21d0b15
c4f124b
90fddd7
917216c
bb588b9
f39b856
2ca0ebd
48c8105
ddaebb4
3bb16fe
0d98c53
02b78e1
3e845bf
d4830cc
02d592c
2007685
a936306
e682b27
4751aeb
aba9255
5fe6c53
8fd71ec
14fac30
1cbe477
46a33d2
d88472c
c154979
18b6934
1beda78
b43752e
92b655d
9d26c40
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,175 @@ | ||
import pandas as pd | ||
import numpy as np | ||
import pymc as pm | ||
import xarray as xa | ||
|
||
from causalpy.utils import _fit | ||
from causalpy.skl_meta_learners import MetaLearner, SLearner, TLearner, XLearner, DRLearner | ||
from causalpy.pymc_models import LogisticRegression | ||
|
||
class BayesianMetaLearner(MetaLearner): | ||
"Base class for PyMC based meta-learners." | ||
|
||
def fit(self, X: pd.DataFrame, y: pd.Series, treated: pd.Series, coords=None): | ||
"Fits model." | ||
raise NotImplementedError() | ||
|
||
|
||
|
||
|
||
class BayesianSLearner(SLearner, BayesianMetaLearner): | ||
"PyMC version of S-Learner." | ||
|
||
def predict_cate(self, X: pd.DataFrame) -> np.array: | ||
X_untreated = X.assign(treatment=0) | ||
X_treated = X.assign(treatment=1) | ||
m = self.models["model"] | ||
|
||
pred_treated = m.predict(X_treated)["posterior_predictive"].mu | ||
pred_untreated = m.predict(X_untreated)["posterior_predictive"].mu | ||
|
||
cate = pred_treated - pred_untreated | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we can simply return pred_treated - pred_untreated There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (applies to methods below as well) |
||
|
||
return cate | ||
|
||
|
||
class BayesianTLearner(TLearner, BayesianMetaLearner): | ||
"PyMC version of T-Learner." | ||
|
||
def predict_cate(self, X: pd.DataFrame) -> np.array: | ||
treated_model = self.models["treated"] | ||
untreated_model = self.models["untreated"] | ||
|
||
pred_treated = treated_model.predict(X)["posterior_predictive"].mu | ||
pred_untreated = untreated_model.predict(X)["posterior_predictive"].mu | ||
|
||
cate = pred_treated - pred_untreated | ||
|
||
return cate | ||
|
||
|
||
class BayesianXLearner(XLearner, BayesianMetaLearner): | ||
"PyMC version of X-Learner." | ||
|
||
def __init__( | ||
self, | ||
X, | ||
y, | ||
treated, | ||
model=None, | ||
treated_model=None, | ||
untreated_model=None, | ||
treated_cate_estimator=None, | ||
untreated_cate_estimator=None, | ||
propensity_score_model=LogisticRegression() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we add some doc string to explain to the user the meaning of these inputs? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do you want to allow the |
||
) -> None: | ||
|
||
super().__init__( | ||
X, | ||
y, | ||
treated, | ||
model, | ||
treated_model, | ||
untreated_model, | ||
treated_cate_estimator, | ||
untreated_cate_estimator, | ||
propensity_score_model | ||
) | ||
|
||
def fit(self, X: pd.DataFrame, y: pd.Series, treated: pd.Series, coords=None): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. return type hint ? ( There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (applies to other methods without return type hint) |
||
( | ||
treated_model, | ||
untreated_model, | ||
treated_cate_estimator, | ||
untreated_cate_estimator, | ||
propensity_score_model | ||
) = self.models.values() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. maybe we extract them one by one? I think we could get confused about the expected order of the tuple. |
||
|
||
# Split data to treated and untreated subsets | ||
X_t, y_t = X[treated == 1], y[treated == 1] | ||
X_u, y_u = X[treated == 0], y[treated == 0] | ||
|
||
# Estimate response function | ||
_fit(treated_model, X_t, y_t, coords) | ||
_fit(untreated_model, X_u, y_u, coords) | ||
|
||
pred_u_t = ( | ||
untreated_model | ||
.predict(X_t)["posterior_predictive"] | ||
.mu | ||
.mean(dim=["chain", "draw"]) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. you could also use |
||
.to_numpy() | ||
) | ||
pred_t_u = ( | ||
treated_model | ||
.predict(X_u)["posterior_predictive"] | ||
.mu | ||
.mean(dim=["chain", "draw"]) | ||
.to_numpy() | ||
) | ||
|
||
tau_t = y_t - pred_u_t | ||
tau_u = y_u - pred_t_u | ||
|
||
# Estimate CATE separately on treated and untreated subsets | ||
_fit(treated_cate_estimator, X_t, tau_t, coords) | ||
_fit(untreated_cate_estimator, X_u, tau_u, coords) | ||
|
||
# Fit propensity score model | ||
_fit(propensity_score_model, X, treated, coords) | ||
return self | ||
|
||
|
||
def _compute_cate(self, X): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. do you want to allow the possibility to be able "pick" the values of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do you think we should? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. type hint of |
||
cate_t = self.models["treated_cate"].predict(X)["posterior_predictive"].mu | ||
cate_u = self.models["treated_cate"].predict(X)["posterior_predictive"].mu | ||
g = self.models["propensity"].predict(X)["posterior_predictive"].mu | ||
return g * cate_u + (1 - g) * cate_t | ||
|
||
def predict_cate(self, X: pd.DataFrame) -> np.array: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What is the difference between the methods |
||
treated_model = self.models["treated_cate"] | ||
untreated_model = self.models["untreated_cate"] | ||
|
||
cate_estimate_treated = treated_model.predict(X)["posterior_predictive"].mu | ||
cate_estimate_untreated = untreated_model.predict(X)["posterior_predictive"].mu | ||
g = self.models["propensity"].predict(X)["posterior_predictive"].mu | ||
|
||
return g * cate_estimate_untreated + (1 - g) * cate_estimate_treated | ||
|
||
|
||
class BayesianDRLearner(DRLearner, BayesianMetaLearner): | ||
"PyMC version of DR-Learner." | ||
|
||
def __init__( | ||
self, | ||
X, | ||
y, | ||
treated, | ||
model=None, | ||
treated_model=None, | ||
untreated_model=None, | ||
propensity_score_model=LogisticRegression() | ||
): | ||
super().__init__(X, y, treated, model, treated_model, untreated_model, propensity_score_model) | ||
|
||
def predict_cate(self, X: pd.DataFrame) -> np.array: | ||
m1 = self.models["treated"].predict(X) | ||
m0 = self.models["untreated"].predict(X) | ||
return m1 - m0 | ||
|
||
def _compute_cate(self, X, y, treated): | ||
g = self.models["propensity"].predict(X)["posterior_predictive"].mu | ||
m0 = self.models["untreated"].predict(X)["posterior_predictive"].mu | ||
m1 = self.models["treated"].predict(X)["posterior_predictive"].mu | ||
|
||
|
||
# Broadcast target and treated variables to the size of the predictions | ||
y0 = xa.DataArray(y, dims="obs_ind") | ||
y0 = xa.broadcast(y0, m0)[0] | ||
|
||
t0 = xa.DataArray(treated, dims="obs_ind") | ||
t0 = xa.broadcast(t0, m0)[0] | ||
|
||
cate = (t0 * (y0 - m1) / g + m1 - ((1 - t0) * (y0 - m0) / (1 - g) + m0)) | ||
|
||
return cate |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -5,6 +5,8 @@ | |
import pandas as pd | ||
import pymc as pm | ||
from arviz import r2_score | ||
import pymc_bart as pmb | ||
|
||
|
||
|
||
class ModelBuilder(pm.Model): | ||
|
@@ -113,3 +115,30 @@ def build_model(self, X, y, coords): | |
sigma = pm.HalfNormal("sigma", 1) | ||
mu = pm.Deterministic("mu", pm.math.dot(X, beta), dims="obs_ind") | ||
pm.Normal("y_hat", mu, sigma, observed=y, dims="obs_ind") | ||
|
||
|
||
class BARTModel(ModelBuilder): | ||
"Class for building BART based models for meta-learners." | ||
|
||
def __init__(self, sample_kwargs=None, m=20, sigma=1): | ||
self.m = m | ||
self.sigma = sigma | ||
super().__init__(sample_kwargs) | ||
|
||
def build_model(self, X, y, coords=None): | ||
with self: | ||
self.add_coords(coords) | ||
X = pm.MutableData("X", X, dims=["obs_ind", "coeffs"]) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think is better to use another variable name for the Data object (this has been a problem for me in the past). Something like X_ = pm.MutableData("X", X, dims=["obs_ind", "coeffs"]) |
||
mu = pmb.BART("mu", X, y, m=self.m, dims="obs_ind") | ||
pm.Normal("y_hat", mu=mu, sigma=self.sigma, observed=y, dims="obs_ind") | ||
|
||
class LogisticRegression(ModelBuilder): | ||
"Custom PyMC model for logistic regression." | ||
|
||
def build_model(self, X, y, coords) -> None: | ||
with self: | ||
self.add_coords(coords) | ||
X = pm.MutableData("X", X, dims=["obs_ind", "coeffs"]) | ||
beta = pm.Normal("beta", 0, 50, dims="coeffs") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It would be nice to be able to have the ability to specify the prior. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also, shall we add an intercept? |
||
mu = pm.math.sigmoid(pm.math.dot(X, beta)) | ||
pm.Bernoulli("yhat", mu, observed=y) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can we add som type-hints to coords? like
Dict[str, Any]
?