Skip to content

Commit 51dea8c

Browse files
committed
Fix AIPW inference and add propensity diagnostics
1 parent 7f9fdcf commit 51dea8c

8 files changed

Lines changed: 837 additions & 111 deletions

causarray/DR_estimation.py

Lines changed: 201 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from joblib import Parallel, delayed
1010
from tqdm import tqdm
1111
import pprint
12+
import warnings
1213

1314
from 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+
37181
def 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

Comments
 (0)