Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 85 additions & 4 deletions causalml/inference/meta/tlearner.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,20 +62,38 @@ def __init__(

self.ate_alpha = ate_alpha
self.control_name = control_name
self.bootstrap_models_ = None

def __repr__(self):
return "{}(model_c={}, model_t={})".format(
self.__class__.__name__, self.model_c.__repr__(), self.model_t.__repr__()
)

@ignore_warnings(category=ConvergenceWarning)
def fit(self, X, treatment, y, p=None):
def fit(
self,
X,
treatment,
y,
p=None,
store_bootstraps=False,
n_bootstraps=1000,
bootstrap_size=10000,
random_state=None,
):
"""Fit the inference model

Args:
X (np.matrix or np.array or pd.Dataframe): a feature matrix
treatment (np.array or pd.Series): a treatment vector
y (np.array or pd.Series): an outcome vector
p: unused, kept for API consistency
store_bootstraps (bool, optional): if True, trains a bootstrap ensemble
during fit and stores it in self.bootstrap_models_ for post-fit CI
estimation via predict(return_ci=True). Default: False.
n_bootstraps (int, optional): number of bootstrap iterations. Default: 1000.
bootstrap_size (int, optional): number of samples per bootstrap. Default: 10000.
random_state (int, optional): random seed for reproducible bootstrap sampling.
"""
X, treatment, y = convert_pd_to_np(X, treatment, y)
check_treatment_vector(treatment, self.control_name)
Expand All @@ -95,20 +113,64 @@ def fit(self, X, treatment, y, p=None):
self.models_c[group].fit(X_filt[w == 0], y_filt[w == 0])
self.models_t[group].fit(X_filt[w == 1], y_filt[w == 1])

if store_bootstraps:
rng = np.random.RandomState(random_state)
logger.info(
"Storing bootstrap ensemble ({} iterations)".format(n_bootstraps)
)
self.bootstrap_models_ = []
for i in tqdm(range(n_bootstraps)):
idxs = rng.choice(np.arange(X.shape[0]), size=bootstrap_size)
X_b, treatment_b, y_b = X[idxs], treatment[idxs], y[idxs]
models_c_b = {group: deepcopy(self.model_c) for group in self.t_groups}
models_t_b = {group: deepcopy(self.model_t) for group in self.t_groups}
for group in self.t_groups:
mask = (treatment_b == group) | (treatment_b == self.control_name)
treatment_filt = treatment_b[mask]
X_filt = X_b[mask]
y_filt = y_b[mask]
w = (treatment_filt == group).astype(int)
if w.sum() == 0 or (w == 0).sum() == 0:
models_c_b[group] = self.models_c[group]
models_t_b[group] = self.models_t[group]
continue
models_c_b[group].fit(X_filt[w == 0], y_filt[w == 0])
models_t_b[group].fit(X_filt[w == 1], y_filt[w == 1])
self.bootstrap_models_.append((models_c_b, models_t_b))
else:
self.bootstrap_models_ = None

def predict(
self, X, treatment=None, y=None, p=None, return_components=False, verbose=True
self,
X,
treatment=None,
y=None,
p=None,
return_components=False,
verbose=True,
return_ci=False,
):
"""Predict treatment effects.

Args:
X (np.matrix or np.array or pd.Dataframe): a feature matrix
treatment (np.array or pd.Series, optional): a treatment vector
y (np.array or pd.Series, optional): an outcome vector
return_components (bool, optional): whether to return outcome for treatment and control seperately
return_components (bool, optional): whether to return outcome for
treatment and control separately
verbose (bool, optional): whether to output progress logs
return_ci (bool, optional): whether to return confidence intervals
using the stored bootstrap ensemble. Requires fit() to have been
called with store_bootstraps=True. CI width is controlled by
self.ate_alpha set at init time.
Returns:
(numpy.ndarray): Predictions of treatment effects.
(numpy.ndarray): Predictions of treatment effects. If return_ci=True,
returns (te, te_lower, te_upper) each of shape [n_samples, n_treatment].
return_ci=True and return_components=True cannot be used together.
"""
if return_ci and return_components:
raise ValueError("return_ci and return_components cannot both be True.")

X, treatment, y = convert_pd_to_np(X, treatment, y)
yhat_cs = {}
yhat_ts = {}
Expand Down Expand Up @@ -136,6 +198,25 @@ def predict(
for i, group in enumerate(self.t_groups):
te[:, i] = yhat_ts[group] - yhat_cs[group]

if return_ci:
if self.bootstrap_models_ is None:
raise ValueError(
"No bootstrap ensemble found. Call fit(..., store_bootstraps=True) first."
)
te_bootstraps = np.zeros(
(X.shape[0], self.t_groups.shape[0], len(self.bootstrap_models_))
)
for b, (models_c_b, models_t_b) in enumerate(self.bootstrap_models_):
for i, group in enumerate(self.t_groups):
te_bootstraps[:, i, b] = models_t_b[group].predict(X) - models_c_b[
group
].predict(X)
te_lower = np.percentile(te_bootstraps, (self.ate_alpha / 2) * 100, axis=2)
te_upper = np.percentile(
te_bootstraps, (1 - self.ate_alpha / 2) * 100, axis=2
)
return te, te_lower, te_upper

if not return_components:
return te
else:
Expand Down
53 changes: 53 additions & 0 deletions tests/test_meta_learners.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import numpy as np
import pandas as pd
import pytest

from sklearn.linear_model import LinearRegression
from sklearn.linear_model import LogisticRegression
Expand Down Expand Up @@ -1220,3 +1221,55 @@ def test_BaseDRClassifier(generate_classification_data):

te_separate = learner_separate.fit_predict(X=X, treatment=treatment, y=y)
assert te_separate.shape == te.shape


def test_BaseTLearner_predict_return_ci(generate_regression_data):
y, X, treatment, tau, b, e = generate_regression_data()

learner = BaseTRegressor(learner=LinearRegression(), control_name=0)

# Test 1: store_bootstraps=True then predict with return_ci=True
learner.fit(
X,
treatment,
y,
store_bootstraps=True,
n_bootstraps=50,
bootstrap_size=500,
random_state=RANDOM_SEED,
)
tau_pred, lb, ub = learner.predict(X, return_ci=True)

assert tau_pred.shape == (X.shape[0], len(learner.t_groups))
assert lb.shape == tau_pred.shape
assert ub.shape == tau_pred.shape
assert (lb <= ub).all()

# Test 2: ValueError without store_bootstraps
learner2 = BaseTRegressor(learner=LinearRegression(), control_name=0)
learner2.fit(X, treatment, y)
with pytest.raises(ValueError):
learner2.predict(X, return_ci=True)

# Test 3: ValueError when return_ci and return_components both True
with pytest.raises(ValueError):
learner.predict(X, return_ci=True, return_components=True)

# Test 4: old API unchanged
tau_plain = learner.predict(X)
assert tau_plain.shape == (X.shape[0], len(learner.t_groups))

# Test 5: reproducibility via random_state
learner3 = BaseTRegressor(learner=LinearRegression(), control_name=0)
learner3.fit(
X,
treatment,
y,
store_bootstraps=True,
n_bootstraps=50,
bootstrap_size=500,
random_state=RANDOM_SEED,
)
tau2, lb2, ub2 = learner3.predict(X, return_ci=True)
np.testing.assert_array_equal(lb, lb2)
np.testing.assert_array_equal(ub, ub2)