Skip to content

Commit d4100b1

Browse files
authored
Move calibrate() inside the PropensityModel class (#839)
* move calibrate() inside the PropensityModel class * move output clipping out of compute_propensity_score() * temporarily pin scipy to be < 1.16.0 * replace sklearn.base.BaseEstimator._validate_data with utils.validation.validate_data and pin sklearn to be >= 1.6.0 * update _check_sample_weight() input argument to reflect scikit-learn/scikit-learn#30908
1 parent 8216447 commit d4100b1

File tree

9 files changed

+332
-357
lines changed

9 files changed

+332
-357
lines changed

causalml/inference/meta/tmle.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
check_p_conditions,
1212
convert_pd_to_np,
1313
)
14-
from causalml.propensity import calibrate
1514

1615

1716
logger = logging.getLogger("causalml")
@@ -105,7 +104,6 @@ def __init__(
105104
ate_alpha=0.05,
106105
control_name=0,
107106
cv=None,
108-
calibrate_propensity=True,
109107
):
110108
"""Initialize a TMLE learner.
111109
@@ -119,7 +117,6 @@ def __init__(
119117
self.ate_alpha = ate_alpha
120118
self.control_name = control_name
121119
self.cv = cv
122-
self.calibrate_propensity = calibrate_propensity
123120

124121
def __repr__(self):
125122
return "{}(model={}, cv={})".format(
@@ -165,10 +162,6 @@ def estimate_ate(self, X, treatment, y, p, segment=None, return_ci=False):
165162
w_group = (treatment == group).astype(int)
166163
p_group = p[group]
167164

168-
if self.calibrate_propensity:
169-
logger.info("Calibrating propensity scores.")
170-
p_group = calibrate(p_group, w_group)
171-
172165
yhat_c = np.zeros_like(y, dtype=float)
173166
yhat_t = np.zeros_like(y, dtype=float)
174167
if self.cv:

causalml/inference/tree/_tree/_classes.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
_check_sample_weight,
4141
assert_all_finite,
4242
check_is_fitted,
43+
validate_data,
4344
)
4445
from . import _criterion, _splitter, _tree
4546
from ._criterion import Criterion
@@ -242,8 +243,8 @@ def _fit(
242243
dtype=DTYPE, accept_sparse="csc", force_all_finite=False
243244
)
244245
check_y_params = dict(ensure_2d=False, dtype=None)
245-
X, y = self._validate_data(
246-
X, y, validate_separately=(check_X_params, check_y_params)
246+
X, y = validate_data(
247+
self, X, y, validate_separately=(check_X_params, check_y_params)
247248
)
248249

249250
missing_values_in_feature_mask = (
@@ -479,7 +480,8 @@ def _validate_X_predict(self, X, check_input):
479480
force_all_finite = "allow-nan"
480481
else:
481482
force_all_finite = True
482-
X = self._validate_data(
483+
X = validate_data(
484+
self,
483485
X,
484486
dtype=DTYPE,
485487
accept_sparse="csr",

causalml/inference/tree/causal/_tree.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import numpy as np
77
from scipy.sparse import issparse
88
from sklearn.utils import check_random_state
9-
from sklearn.utils.validation import _check_sample_weight
9+
from sklearn.utils.validation import _check_sample_weight, validate_data
1010

1111
from .._tree._classes import DTYPE, DOUBLE, INT
1212
from .._tree._classes import SPARSE_SPLITTERS, DENSE_SPLITTERS
@@ -61,8 +61,8 @@ def fit(
6161
# csr.
6262
check_X_params = dict(dtype=DTYPE, accept_sparse="csc")
6363
check_y_params = dict(ensure_2d=False, dtype=None)
64-
X, y = self._validate_data(
65-
X, y, validate_separately=(check_X_params, check_y_params)
64+
X, y = validate_data(
65+
self, X, y, validate_separately=(check_X_params, check_y_params)
6666
)
6767
if issparse(X):
6868
X.sort_indices()
@@ -184,7 +184,7 @@ def fit(
184184
)
185185

186186
if sample_weight is not None:
187-
sample_weight = _check_sample_weight(sample_weight, X, DOUBLE)
187+
sample_weight = _check_sample_weight(sample_weight, X, dtype=DOUBLE)
188188

189189
if expanded_class_weight is not None:
190190
if sample_weight is not None:

causalml/inference/tree/causal/causalforest.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@
66
from warnings import catch_warnings, simplefilter, warn
77

88
from sklearn.exceptions import DataConversionWarning
9-
from sklearn.utils.validation import check_random_state, _check_sample_weight
9+
from sklearn.utils.validation import (
10+
check_random_state,
11+
_check_sample_weight,
12+
validate_data,
13+
)
1014
from sklearn.utils.multiclass import type_of_target
1115
from sklearn import __version__ as sklearn_version
1216
from sklearn.ensemble._forest import DOUBLE, DTYPE, MAX_INT
@@ -246,8 +250,8 @@ def _fit(
246250
# Validate or convert input data
247251
if issparse(y):
248252
raise ValueError("sparse multilabel-indicator for y is not supported.")
249-
X, y = self._validate_data(
250-
X, y, multi_output=True, accept_sparse="csc", dtype=DTYPE
253+
X, y = validate_data(
254+
self, X, y, multi_output=True, accept_sparse="csc", dtype=DTYPE
251255
)
252256
if sample_weight is not None:
253257
sample_weight = _check_sample_weight(sample_weight, X)

causalml/metrics/visualize.py

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,6 @@ def get_tmlegain(
342342
p_col="p",
343343
n_segment=5,
344344
cv=None,
345-
calibrate_propensity=True,
346345
ci=False,
347346
):
348347
"""Get TMLE based average uplifts of model estimates of segments.
@@ -356,7 +355,6 @@ def get_tmlegain(
356355
p_col (str, optional): the column name for propensity score
357356
n_segment (int, optional): number of segment that TMLE will estimated for each
358357
cv (sklearn.model_selection._BaseKFold, optional): sklearn CV object
359-
calibrate_propensity (bool, optional): whether calibrate propensity score or not
360358
ci (bool, optional): whether return confidence intervals for ATE or not
361359
Returns:
362360
(pandas.DataFrame): cumulative gains of model estimates based of TMLE
@@ -374,7 +372,7 @@ def get_tmlegain(
374372
inference_col = [x for x in inference_col if x in df.columns]
375373

376374
# Initialize TMLE
377-
tmle = TMLELearner(learner, cv=cv, calibrate_propensity=calibrate_propensity)
375+
tmle = TMLELearner(learner, cv=cv)
378376
ate_all, ate_all_lb, ate_all_ub = tmle.estimate_ate(
379377
X=df[inference_col], p=df[p_col], treatment=df[treatment_col], y=df[outcome_col]
380378
)
@@ -454,7 +452,6 @@ def get_tmleqini(
454452
p_col="p",
455453
n_segment=5,
456454
cv=None,
457-
calibrate_propensity=True,
458455
ci=False,
459456
normalize=False,
460457
):
@@ -469,7 +466,6 @@ def get_tmleqini(
469466
p_col (str, optional): the column name for propensity score
470467
n_segment (int, optional): number of segment that TMLE will estimated for each
471468
cv (sklearn.model_selection._BaseKFold, optional): sklearn CV object
472-
calibrate_propensity (bool, optional): whether calibrate propensity score or not
473469
ci (bool, optional): whether return confidence intervals for ATE or not
474470
Returns:
475471
(pandas.DataFrame): cumulative gains of model estimates based of TMLE
@@ -487,7 +483,7 @@ def get_tmleqini(
487483
inference_col = [x for x in inference_col if x in df.columns]
488484

489485
# Initialize TMLE
490-
tmle = TMLELearner(learner, cv=cv, calibrate_propensity=calibrate_propensity)
486+
tmle = TMLELearner(learner, cv=cv)
491487
ate_all, ate_all_lb, ate_all_ub = tmle.estimate_ate(
492488
X=df[inference_col], p=df[p_col], treatment=df[treatment_col], y=df[outcome_col]
493489
)
@@ -696,7 +692,6 @@ def plot_tmlegain(
696692
p_col="tau",
697693
n_segment=5,
698694
cv=None,
699-
calibrate_propensity=True,
700695
ci=False,
701696
figsize=(8, 8),
702697
):
@@ -711,7 +706,6 @@ def plot_tmlegain(
711706
p_col (str, optional): the column name for propensity score
712707
n_segment (int, optional): number of segment that TMLE will estimated for each
713708
cv (sklearn.model_selection._BaseKFold, optional): sklearn CV object
714-
calibrate_propensity (bool, optional): whether calibrate propensity score or not
715709
ci (bool, optional): whether return confidence intervals for ATE or not
716710
"""
717711

@@ -728,7 +722,6 @@ def plot_tmlegain(
728722
p_col=p_col,
729723
n_segment=n_segment,
730724
cv=cv,
731-
calibrate_propensity=calibrate_propensity,
732725
)
733726

734727

@@ -741,7 +734,6 @@ def plot_tmleqini(
741734
p_col="tau",
742735
n_segment=5,
743736
cv=None,
744-
calibrate_propensity=True,
745737
ci=False,
746738
figsize=(8, 8),
747739
):
@@ -756,7 +748,6 @@ def plot_tmleqini(
756748
p_col (str, optional): the column name for propensity score
757749
n_segment (int, optional): number of segment that TMLE will estimated for each
758750
cv (sklearn.model_selection._BaseKFold, optional): sklearn CV object
759-
calibrate_propensity (bool, optional): whether calibrate propensity score or not
760751
ci (bool, optional): whether return confidence intervals for ATE or not
761752
"""
762753

@@ -773,7 +764,6 @@ def plot_tmleqini(
773764
p_col=p_col,
774765
n_segment=n_segment,
775766
cv=cv,
776-
calibrate_propensity=calibrate_propensity,
777767
)
778768

779769

causalml/propensity.py

Lines changed: 47 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,19 @@
1212

1313

1414
class PropensityModel(metaclass=ABCMeta):
15-
def __init__(self, clip_bounds=(1e-3, 1 - 1e-3), **model_kwargs):
15+
def __init__(self, clip_bounds=(1e-3, 1 - 1e-3), calibrate=True, **model_kwargs):
1616
"""
1717
Args:
1818
clip_bounds (tuple): lower and upper bounds for clipping propensity scores. Bounds should be implemented
1919
such that: 0 < lower < upper < 1, to avoid division by zero in BaseRLearner.fit_predict() step.
20+
calibrate (bool): whether calibrate the propensity score
2021
model_kwargs: Keyword arguments to be passed to the underlying classification model.
2122
"""
2223
self.clip_bounds = clip_bounds
24+
self.calibrate = calibrate
2325
self.model_kwargs = model_kwargs
2426
self.model = self._model
27+
self.calibrator = None
2528

2629
@property
2730
@abstractmethod
@@ -40,6 +43,15 @@ def fit(self, X, y):
4043
y (numpy.ndarray): a binary target vector
4144
"""
4245
self.model.fit(X, y)
46+
if self.calibrate:
47+
# Fit a calibrator to the propensity scores with IsotonicRegression.
48+
# Ref: https://scikit-learn.org/stable/modules/isotonic.html
49+
self.calibrator = IsotonicRegression(
50+
out_of_bounds="clip",
51+
y_min=self.clip_bounds[0],
52+
y_max=self.clip_bounds[1],
53+
)
54+
self.calibrator.fit(self.model.predict_proba(X)[:, 1], y)
4355

4456
def predict(self, X):
4557
"""
@@ -51,7 +63,11 @@ def predict(self, X):
5163
Returns:
5264
(numpy.ndarray): Propensity scores between 0 and 1.
5365
"""
54-
return np.clip(self.model.predict_proba(X)[:, 1], *self.clip_bounds)
66+
p = self.model.predict_proba(X)[:, 1]
67+
if self.calibrate:
68+
p = self.calibrator.transform(p)
69+
70+
return np.clip(p, *self.clip_bounds)
5571

5672
def fit_predict(self, X, y):
5773
"""
@@ -66,7 +82,6 @@ def fit_predict(self, X, y):
6682
"""
6783
self.fit(X, y)
6884
propensity_scores = self.predict(X)
69-
logger.info("AUC score: {:.6f}".format(auc(y, propensity_scores)))
7085
return propensity_scores
7186

7287

@@ -112,12 +127,15 @@ class GradientBoostedPropensityModel(PropensityModel):
112127
https://xgboost.readthedocs.io/en/latest/python/python_api.html
113128
"""
114129

115-
def __init__(self, early_stop=False, clip_bounds=(1e-3, 1 - 1e-3), **model_kwargs):
130+
def __init__(
131+
self,
132+
early_stop=False,
133+
clip_bounds=(1e-3, 1 - 1e-3),
134+
calibrate=True,
135+
**model_kwargs,
136+
):
116137
self.early_stop = early_stop
117-
118-
super(GradientBoostedPropensityModel, self).__init__(
119-
clip_bounds, **model_kwargs
120-
)
138+
super().__init__(clip_bounds, calibrate, **model_kwargs)
121139

122140
@property
123141
def _model(self):
@@ -156,50 +174,25 @@ def fit(self, X, y, stop_val_size=0.2):
156174
y_train,
157175
eval_set=[(X_val, y_val)],
158176
)
177+
if self.calibrate:
178+
self.calibrator = IsotonicRegression(
179+
out_of_bounds="clip",
180+
y_min=self.clip_bounds[0],
181+
y_max=self.clip_bounds[1],
182+
)
183+
self.calibrator.fit(self.model.predict_proba(X)[:, 1], y)
159184
else:
160-
super(GradientBoostedPropensityModel, self).fit(X, y)
161-
162-
def predict(self, X):
163-
"""
164-
Predict propensity scores.
165-
166-
Args:
167-
X (numpy.ndarray): a feature matrix
168-
169-
Returns:
170-
(numpy.ndarray): Propensity scores between 0 and 1.
171-
"""
172-
if self.early_stop:
173-
return np.clip(
174-
self.model.predict_proba(X)[:, 1],
175-
*self.clip_bounds,
176-
)
177-
else:
178-
return super(GradientBoostedPropensityModel, self).predict(X)
179-
180-
181-
def calibrate(ps, treatment):
182-
"""Calibrate propensity scores with IsotonicRegression.
183-
184-
Ref: https://scikit-learn.org/stable/modules/isotonic.html
185-
186-
Args:
187-
ps (numpy.array): a propensity score vector
188-
treatment (numpy.array): a binary treatment vector (0: control, 1: treated)
189-
190-
Returns:
191-
(numpy.array): a calibrated propensity score vector
192-
"""
193-
194-
two_eps = 2.0 * np.finfo(float).eps
195-
pm_ir = IsotonicRegression(out_of_bounds="clip", y_min=two_eps, y_max=1.0 - two_eps)
196-
ps_ir = pm_ir.fit_transform(ps, treatment)
197-
198-
return ps_ir
185+
super().fit(X, y)
199186

200187

201188
def compute_propensity_score(
202-
X, treatment, p_model=None, X_pred=None, treatment_pred=None, calibrate_p=True
189+
X,
190+
treatment,
191+
p_model=None,
192+
X_pred=None,
193+
treatment_pred=None,
194+
calibrate_p=True,
195+
clip_bounds=(1e-3, 1 - 1e-3),
203196
):
204197
"""Generate propensity score if user didn't provide and optionally calibrate.
205198
@@ -210,16 +203,20 @@ def compute_propensity_score(
210203
X_pred (np.matrix, optional): features for prediction
211204
treatment_pred (np.array or pd.Series, optional): a treatment vector for prediciton
212205
calibrate_p (bool, optional): whether calibrate the propensity score
206+
clip_bounds (tuple, optional): lower and upper bounds for clipping propensity scores. Bounds should be implemented
207+
such that: 0 < lower < upper < 1, to avoid division by zero in BaseRLearner.fit_predict() step.
213208
214209
Returns:
215210
(tuple)
216211
- p (numpy.ndarray): propensity score
217-
- p_model (PropensityModel): either the original p_model, a trained ElasticNetPropensityModel, or None if calibrate_p=True
212+
- p_model (PropensityModel): either the original p_model or a trained ElasticNetPropensityModel
218213
"""
219214
if treatment_pred is None:
220215
treatment_pred = treatment.copy()
221216
if p_model is None:
222-
p_model = ElasticNetPropensityModel()
217+
p_model = ElasticNetPropensityModel(
218+
clip_bounds=clip_bounds, calibrate=calibrate_p
219+
)
223220

224221
p_model.fit(X, treatment)
225222

@@ -231,14 +228,4 @@ def compute_propensity_score(
231228
logger.info("predict_proba not available, using predict instead")
232229
p = p_model.predict(X_pred)
233230

234-
if calibrate_p:
235-
logger.info("Calibrating propensity scores. Returning p_model=None.")
236-
p = calibrate(p, treatment_pred)
237-
p_model = None
238-
239-
# force the p values within the range
240-
eps = np.finfo(float).eps
241-
p = np.where(p < 0 + eps, 0 + eps * 1.001, p)
242-
p = np.where(p > 1 - eps, 1 - eps * 1.001, p)
243-
244231
return p, p_model

0 commit comments

Comments
 (0)