99from joblib import Parallel , delayed
1010from tqdm import tqdm
1111import pprint
12+ import warnings
1213
1314from sklearn .model_selection import KFold , ShuffleSplit
1415
@@ -18,13 +19,18 @@ def _get_func_ps(ps_model, **kwargs):
1819 func_ps = lambda X , Y , X_test :fit_rf_ind_ps (X , Y [:,None ], X_test = X_test , ** params_ps )[:,0 ]
1920 elif ps_model == 'logistic' :
2021 clf_ps = LogisticRegression
21- kwargs = {** {'fit_intercept' :False , 'C' :1e0 , 'class_weight' :'balanced' , 'random_state' :0 }, ** kwargs }
22+ kwargs = {** {'fit_intercept' :False , 'C' :1e0 , 'class_weight' :None , 'random_state' :0 }, ** kwargs }
2223 params_ps = _filter_params (clf_ps ().get_params (), kwargs )
2324 func_ps = lambda X , Y , X_test : clf_ps (** params_ps ).fit (X , Y ).predict_proba (X_test )[:,1 ]
2425 elif ps_model == 'ensemble' :
26+ kwargs = dict (kwargs )
27+ logistic_class_weight = kwargs .pop ('class_weight' , None )
2528 params_ps_rf = _filter_params (fit_rf , kwargs )
2629 clf_ps = LogisticRegression
27- kwargs = {** {'fit_intercept' :False , 'C' :1e0 , 'class_weight' :'balanced' , 'random_state' :0 }, ** kwargs }
30+ kwargs = {** {
31+ 'fit_intercept' : False , 'C' : 1e0 ,
32+ 'class_weight' : logistic_class_weight , 'random_state' : 0 ,
33+ }, ** kwargs }
2834 params_ps_lr = _filter_params (clf_ps ().get_params (), kwargs )
2935 params_ps = {'params_ps_rf' :params_ps_rf , 'params_ps_lr' :params_ps_lr }
3036 func_ps = lambda X , Y , X_test :(fit_rf_ind (X , Y [:,None ], X_test = X_test , ** params_ps_rf )[:,0 ] + clf_ps (** params_ps_lr ).fit (X , Y ).predict_proba (X_test )[:,1 ])/ 2
@@ -34,10 +40,149 @@ def _get_func_ps(ps_model, **kwargs):
3440 return func_ps , params_ps
3541
3642
43+ def estimate_propensity_scores (
44+ A , X_A , K = 1 , ps_model = 'logistic' , mask = None , clip = None ,
45+ random_state = 0 , verbose = False , class_weight = 'balanced' , ** kwargs ,
46+ ):
47+ """Estimate per-treatment propensity scores.
48+
49+ Each treatment is compared with the shared all-zero control group. With
50+ ``K > 1``, every returned score is predicted by a model that did not train
51+ on that cell. Logistic models use ``class_weight='balanced'`` by default,
52+ matching :func:`LFC` and historical causarray fits. Pass
53+ ``class_weight=None`` for calibrated treatment probabilities.
54+
55+ Parameters
56+ ----------
57+ A : array-like, shape (n,) or (n, a)
58+ Binary treatment indicators. Rows containing only zeros are controls.
59+ X_A : array-like, shape (n, d_A)
60+ Covariates used by the propensity model, including an intercept column
61+ when ``fit_intercept=False``.
62+ K : int, optional
63+ Number of folds. ``1`` fits and predicts on all eligible cells;
64+ values greater than one produce out-of-fold predictions.
65+ ps_model : {'logistic', 'random_forest_cv', 'ensemble'}, optional
66+ Propensity model.
67+ mask : array-like or None, shape (n,) or (n, a)
68+ Optional per-treatment eligibility mask for model fitting.
69+ clip : tuple(float, float) or None, optional
70+ Bounds applied after prediction. ``None`` returns raw probabilities.
71+ random_state : int, optional
72+ Random seed used for fold construction and supported estimators.
73+ class_weight : str, dict or None, optional
74+ Class weighting for logistic propensity estimation. The default
75+ ``'balanced'`` matches :func:`LFC`; pass ``None`` for calibrated
76+ probabilities.
77+
78+ Returns
79+ -------
80+ pi_hat : ndarray, shape (n, a)
81+ Estimated probabilities ``P(A_j=1 | X_A)``.
82+ """
83+ A = np .asarray (A )
84+ if A .ndim == 1 :
85+ A = A [:, None ]
86+ X_A = np .asarray (X_A )
87+ if A .ndim != 2 or X_A .ndim != 2 or A .shape [0 ] != X_A .shape [0 ]:
88+ raise ValueError ('A and X_A must be two-dimensional with matching rows' )
89+ if not np .all (np .isin (A , (0 , 1 ))):
90+ raise ValueError ('A must contain only binary treatment indicators' )
91+
92+ try :
93+ K_int = int (K )
94+ except (TypeError , ValueError ) as exc :
95+ raise ValueError ('K must be a positive integer' ) from exc
96+ if K_int != K :
97+ raise ValueError ('K must be a positive integer' )
98+ K = K_int
99+ if K < 1 :
100+ raise ValueError ('K must be a positive integer' )
101+ if K > A .shape [0 ]:
102+ raise ValueError ('K cannot exceed the number of samples' )
103+
104+ if mask is not None :
105+ mask = np .asarray (mask , dtype = bool )
106+ if mask .ndim == 1 :
107+ mask = mask [:, None ]
108+ if mask .shape != A .shape :
109+ raise ValueError ('Mask must have the same shape as the treatment matrix' )
110+
111+ func_ps , params_ps = _get_func_ps (
112+ ps_model , verbose = False , random_state = random_state ,
113+ class_weight = class_weight , ** kwargs )
114+ if verbose :
115+ pprint .pprint (params_ps )
116+
117+ if ps_model == 'random_forest_cv' :
118+ info_ecv = run_ecv (X_A , A , ** params_ps )
119+ func_ps , params_ps = _get_func_ps (
120+ ps_model , verbose = False , ecv = False ,
121+ kwargs_ensemble = info_ecv ['best_params_ensemble' ],
122+ kwargs_regr = info_ecv ['best_params_regr' ],
123+ )
124+ if verbose :
125+ pprint .pprint ('Best parameters for the regression model:' )
126+ pprint .pprint (info_ecv ['best_params_regr' ])
127+ pprint .pprint ('Best parameters for the ensemble model:' )
128+ pprint .pprint (info_ecv ['best_params_ensemble' ])
129+
130+ n = A .shape [0 ]
131+ if K == 1 :
132+ folds = [(np .arange (n ), np .arange (n ))]
133+ elif K == n :
134+ folds = [
135+ (np .delete (np .arange (n ), j ), np .array ([j ])) for j in range (n )
136+ ]
137+ else :
138+ folds = KFold (
139+ n_splits = K , random_state = random_state , shuffle = True ,
140+ ).split (X_A )
141+
142+ pi_hat = np .zeros (A .shape , dtype = float )
143+ for train_index , test_index in folds :
144+ A_train = A [train_index ]
145+ XA_train , XA_test = X_A [train_index ], X_A [test_index ]
146+ i_ctrl = np .sum (A_train , axis = 1 ) == 0
147+
148+ for j in range (A .shape [1 ]):
149+ i_case = A_train [:, j ] == 1
150+ eligible = mask [train_index , j ] if mask is not None else (i_ctrl | i_case )
151+ y_train = A_train [eligible , j ]
152+ if y_train .size == 0 or np .unique (y_train ).size != 2 :
153+ raise ValueError (
154+ f'Treatment { j } needs at least one eligible control and case '
155+ 'in every training fold'
156+ )
157+
158+ x_train = XA_train [eligible ]
159+ constant_design = np .all (np .ptp (x_train , axis = 0 ) == 0 )
160+ if ps_model == 'logistic' and constant_design :
161+ if class_weight == 'balanced' :
162+ pi_hat [test_index , j ] = 0.5
163+ elif isinstance (class_weight , dict ):
164+ sample_weight = np .asarray ([
165+ class_weight .get (int (value ), 1.0 ) for value in y_train
166+ ])
167+ pi_hat [test_index , j ] = np .average (
168+ y_train , weights = sample_weight )
169+ else :
170+ pi_hat [test_index , j ] = np .mean (y_train )
171+ else :
172+ pi_hat [test_index , j ] = func_ps (x_train , y_train , XA_test )
173+
174+ if clip is not None :
175+ if len (clip ) != 2 or not 0 <= clip [0 ] < clip [1 ] <= 1 :
176+ raise ValueError ('clip must be None or a pair 0 <= lower < upper <= 1' )
177+ pi_hat = np .clip (pi_hat , clip [0 ], clip [1 ])
178+ return pi_hat
179+
180+
37181def cross_fitting (
38182 Y , A , X , X_A , family = 'poisson' , K = 1 , glm_alpha = 1e-4 ,
39- ps_model = 'logistic' ,
40- Y_hat = None , pi_hat = None , mask = None , verbose = False , ** kwargs ):
183+ ps_model = 'logistic' , ps_class_weight = 'balanced' ,
184+ Y_hat = None , pi_hat = None , mask = None , ps_clip = (0.01 , 0.99 ),
185+ return_raw_pi = False , verbose = False , ** kwargs ):
41186 '''
42187 Cross-fitting for causal estimands.
43188
@@ -59,15 +204,22 @@ def cross_fitting(
59204 The regularization parameter for the generalized linear model. The default is 1e-4.
60205 ps_model : str, optional
61206 The propensity score model. The default is 'logistic'.
207+ ps_class_weight : str, dict or None, optional
208+ Class weighting used by the propensity model. ``'balanced'`` preserves
209+ the established ``LFC`` nuisance fit; pass ``None`` for calibrated
210+ treatment probabilities.
62211
63212 Y_hat : array, optional
64213 Estimated potential outcome of shape (n, p, a, 2). The default is None.
65214 pi_hat : array, optional
66215 Propensity score of shape (n, a). The default is None.
67216 mask : array, optional
68217 Boolean mask of shape (n, a) for the treatment, indicating which samples are used for
69- the estimation of the estimand. This does not affect the estimation of pseudo-outcomes
70- and propensity scores.
218+ propensity-model fitting and the downstream estimand.
219+ ps_clip : tuple(float, float) or None, optional
220+ Bounds applied to scores used by AIPW. ``None`` disables clipping.
221+ return_raw_pi : bool, optional
222+ Return raw scores as a third result when true.
71223
72224 **kwargs : dict
73225 Additional arguments to pass to the model.
@@ -78,12 +230,22 @@ def cross_fitting(
78230 Estimated potential outcome under control.
79231 pi_hat : array
80232 Estimated propensity score.
233+ pi_hat_raw : array
234+ Unclipped propensity score, returned only when ``return_raw_pi=True``.
81235 '''
82- func_ps , params_ps = _get_func_ps (ps_model , verbose = False , ** kwargs )
236+ kwargs = dict (kwargs )
237+ if 'class_weight' in kwargs :
238+ legacy_class_weight = kwargs .pop ('class_weight' )
239+ warnings .warn (
240+ 'Passing class_weight through LFC/cross_fitting is deprecated; '
241+ 'use ps_class_weight instead.' ,
242+ FutureWarning , stacklevel = 2 ,
243+ )
244+ ps_class_weight = legacy_class_weight
245+
83246 params_glm = _filter_params (fit_glm , {** kwargs , 'verbose' : verbose })
84247
85248 if verbose :
86- pprint .pprint (params_ps )
87249 pprint .pprint (params_glm )
88250
89251 if K > 1 :
@@ -99,14 +261,31 @@ def cross_fitting(
99261 folds = [(np .arange (X .shape [0 ]), np .arange (X .shape [0 ]))]
100262
101263 # Initialize lists to store results
102- fit_pi = True if pi_hat is None else False
103- pi_hat = np .zeros_like (A , dtype = float ) if fit_pi else pi_hat
264+ if pi_hat is None :
265+ if verbose :
266+ pprint .pprint ('Fit propensity score models...' )
267+ ps_kwargs = {k : v for k , v in kwargs .items () if k != 'random_state' }
268+ if ps_model in ('logistic' , 'ensemble' ):
269+ ps_kwargs ['class_weight' ] = ps_class_weight
270+ pi_hat_raw = estimate_propensity_scores (
271+ A , X_A , K = K , ps_model = ps_model , mask = mask ,
272+ random_state = kwargs .get ('random_state' , 0 ), verbose = verbose ,
273+ ** ps_kwargs ,
274+ )
275+ else :
276+ pi_hat_raw = np .asarray (pi_hat , dtype = float ).reshape (A .shape )
277+ if ps_clip is None :
278+ pi_hat = pi_hat_raw .copy ()
279+ else :
280+ if len (ps_clip ) != 2 or not 0 <= ps_clip [0 ] < ps_clip [1 ] <= 1 :
281+ raise ValueError (
282+ 'ps_clip must be None or a pair 0 <= lower < upper <= 1' )
283+ pi_hat = np .clip (pi_hat_raw , ps_clip [0 ], ps_clip [1 ])
104284 fit_Y = True if Y_hat is None else False
105285 if fit_Y :
106286 _yhat_gb = Y .shape [0 ] * Y .shape [1 ] * A .shape [1 ] * 2 * 8 / 1e9
107287 _mem_limit_gb = kwargs .get ('mem_limit_gb' , None )
108288 if _mem_limit_gb is not None and _yhat_gb > _mem_limit_gb :
109- import warnings
110289 warnings .warn (
111290 f"Y_hat allocation ({ _yhat_gb :.1f} GB as float64) exceeds "
112291 f"mem_limit_gb={ _mem_limit_gb } GB; using float32 to halve peak memory." ,
@@ -116,16 +295,6 @@ def cross_fitting(
116295 else :
117296 Y_hat = np .zeros ((Y .shape [0 ], Y .shape [1 ], A .shape [1 ], 2 ), dtype = float )
118297
119- # perform ECV at once
120- if fit_pi and ps_model == 'random_forest_cv' :
121- info_ecv = run_ecv (X_A , A , ** params_ps )
122- func_ps , params_ps = _get_func_ps (ps_model , verbose = False , ecv = False ,
123- kwargs_ensemble = info_ecv ['best_params_ensemble' ], kwargs_regr = info_ecv ['best_params_regr' ])
124- pprint .pprint ('Best parameters for the regression model:' )
125- pprint .pprint (info_ecv ['best_params_regr' ])
126- pprint .pprint ('Best parameters for the ensemble model:' )
127- pprint .pprint (info_ecv ['best_params_ensemble' ])
128-
129298 # Perform cross-fitting
130299 for train_index , test_index in folds :
131300 # Split data
@@ -134,28 +303,6 @@ def cross_fitting(
134303 A_train , A_test = A [train_index ], A [test_index ]
135304 Y_train , Y_test = Y [train_index ], Y [test_index ]
136305
137- if fit_pi :
138- if verbose : pprint .pprint ('Fit propensity score models...' )
139- i_ctrl = (np .sum (A_train , axis = 1 ) == 0. )
140-
141- pi = np .zeros_like (A_test , dtype = float )
142- for j in range (A .shape [1 ]):
143- i_case = (A_train [:,j ] == 1. )
144-
145- if mask is not None :
146- i_cells = mask [:, j ]
147- else :
148- i_ctrl = (np .sum (A_train , axis = 1 ) == 0. )
149- i_cells = i_ctrl | i_case
150-
151- if ps_model == 'logistic' and XA_train .shape [1 ]== 1 and np .all (XA_train == 1 ):
152- prob = np .sum (i_case )/ np .sum (i_cells )
153- pi [A_train [:,j ] == 1. , j ] = prob
154- pi [A_train [:,j ] == 0. , j ] = 1 - prob
155- else :
156- pi [:,j ] = func_ps (XA_train [i_cells ], A_train [i_cells ][:,j ], XA_test )
157- pi_hat [test_index ] = pi
158-
159306 if fit_Y :
160307 if verbose : pprint .pprint ('Fit outcome models...' )
161308 # Subset offset to training fold (for fitting) and test fold (for
@@ -174,15 +321,16 @@ def cross_fitting(
174321 Y_hat [test_index ,:,:,0 ] = res [1 ][0 ]
175322 Y_hat [test_index ,:,:,1 ] = res [1 ][1 ]
176323
177- pi_hat = np .clip (pi_hat , 0.01 , 0.99 )
178324 Y_hat = np .clip (Y_hat , None , 1e5 )
325+ if return_raw_pi :
326+ return Y_hat , pi_hat , pi_hat_raw
179327 return Y_hat , pi_hat
180328
181329
182330
183331
184332
185- def AIPW_mean (Y , A , mu , pi , positive = False ):
333+ def AIPW_mean (Y , A , mu , pi ):
186334 '''
187335 Augmented inverse probability weighted estimator (AIPW)
188336
@@ -196,9 +344,6 @@ def AIPW_mean(Y, A, mu, pi, positive=False):
196344 Conditional outcome distribution estimate of shape (n, p, a, 2).
197345 pi : array
198346 Propensity score of shape (n, a, 2).
199- positive : bool, optional
200- Whether to restrict the pseudo-outcome to be positive.
201-
202347 Returns
203348 -------
204349 tau : array
@@ -207,16 +352,18 @@ def AIPW_mean(Y, A, mu, pi, positive=False):
207352 Pseudo-outcome of shape (n, p, a, 2).
208353 '''
209354
210- weight = A / pi
355+ with np .errstate (divide = 'ignore' , invalid = 'ignore' , over = 'ignore' ):
356+ weight = A / pi
211357 weight = weight [:, None , ...]
212358 Y = Y [:, :, None , None ]
213359
360+ # Influence-function values are intentionally left unconstrained. Even
361+ # for a nonnegative outcome, individual AIPW pseudo-outcomes may be
362+ # negative; projecting them cell by cell changes their mean and biases the
363+ # estimator. Parameter-space constraints belong after aggregation.
214364 pseudo_y = weight * (Y - mu ) + mu
215-
216- if positive :
217- pseudo_y = np .clip (pseudo_y , 0 , None )
218365
219- tau = np .mean (pseudo_y , axis = 0 )
366+ tau = np .mean (pseudo_y , axis = 0 , dtype = np . float64 )
220367
221368 return tau , pseudo_y
222369
@@ -350,4 +497,4 @@ def fit_rf_ind_outcome(W, Y, A, *args, **kwargs):
350497 Y_pred = Y_pred .reshape (X .shape [0 ],1 + a ,Y .shape [1 ])
351498 Yhat_1 = Y_pred [:,1 :,:].transpose (0 ,2 ,1 )
352499 Yhat_0 = np .tile (Y_pred [:,0 ,:][:,:,None ], (1 ,1 ,a ))
353- return Yhat_0 , Yhat_1
500+ return Yhat_0 , Yhat_1
0 commit comments