cbps 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cbps/__init__.py +3462 -0
- cbps/constants.py +46 -0
- cbps/core/__init__.py +93 -0
- cbps/core/cbps_binary.py +1943 -0
- cbps/core/cbps_continuous.py +945 -0
- cbps/core/cbps_multitreat.py +1123 -0
- cbps/core/cbps_optimal.py +507 -0
- cbps/core/results.py +1447 -0
- cbps/data/Blackwell.csv +571 -0
- cbps/data/LaLonde.csv +3213 -0
- cbps/data/npcbps_continuous_sim.csv +501 -0
- cbps/data/nsw.csv +723 -0
- cbps/data/nsw_dw.csv +446 -0
- cbps/data/political_ads_urban_niebler.csv +16266 -0
- cbps/data/psid_controls.csv +2491 -0
- cbps/data/psid_controls2.csv +254 -0
- cbps/data/psid_controls3.csv +129 -0
- cbps/data/simulation_dgp1_seed12345.csv +201 -0
- cbps/data/simulation_dgp2_seed12345.csv +201 -0
- cbps/data/simulation_dgp3_seed12345.csv +201 -0
- cbps/data/simulation_dgp4_seed12345.csv +201 -0
- cbps/datasets/__init__.py +78 -0
- cbps/datasets/blackwell.py +112 -0
- cbps/datasets/continuous.py +223 -0
- cbps/datasets/lalonde.py +272 -0
- cbps/datasets/npcbps_sim.py +101 -0
- cbps/diagnostics/__init__.py +101 -0
- cbps/diagnostics/balance.py +760 -0
- cbps/diagnostics/balance_cbmsm_addon.py +162 -0
- cbps/diagnostics/continuous_diagnostics.py +259 -0
- cbps/diagnostics/normality.py +173 -0
- cbps/diagnostics/ocbps_conditions.py +197 -0
- cbps/diagnostics/overlap.py +198 -0
- cbps/diagnostics/plots.py +1193 -0
- cbps/diagnostics/weights_diag.py +205 -0
- cbps/highdim/__init__.py +84 -0
- cbps/highdim/gmm_loss.py +340 -0
- cbps/highdim/hdcbps.py +1078 -0
- cbps/highdim/lasso_utils.py +498 -0
- cbps/highdim/weight_funcs.py +298 -0
- cbps/inference/__init__.py +42 -0
- cbps/inference/asyvar.py +621 -0
- cbps/inference/vcov_outcome.py +217 -0
- cbps/iv/__init__.py +48 -0
- cbps/iv/cbiv.py +2603 -0
- cbps/logging_config.py +45 -0
- cbps/msm/__init__.py +45 -0
- cbps/msm/cbmsm.py +1871 -0
- cbps/msm/rank_diagnostics.py +112 -0
- cbps/nonparametric/__init__.py +58 -0
- cbps/nonparametric/cholesky_whitening.py +232 -0
- cbps/nonparametric/empirical_likelihood.py +339 -0
- cbps/nonparametric/npcbps.py +1036 -0
- cbps/nonparametric/taylor_approx.py +207 -0
- cbps/py.typed +0 -0
- cbps/sklearn/__init__.py +42 -0
- cbps/sklearn/estimator.py +378 -0
- cbps/utils/__init__.py +82 -0
- cbps/utils/formula.py +415 -0
- cbps/utils/helpers.py +378 -0
- cbps/utils/numerics.py +438 -0
- cbps/utils/r_compat.py +109 -0
- cbps/utils/validation.py +224 -0
- cbps/utils/variance_transform.py +483 -0
- cbps/utils/weights.py +586 -0
- cbps-0.2.0.dist-info/METADATA +1090 -0
- cbps-0.2.0.dist-info/RECORD +70 -0
- cbps-0.2.0.dist-info/WHEEL +5 -0
- cbps-0.2.0.dist-info/licenses/LICENSE +661 -0
- cbps-0.2.0.dist-info/top_level.txt +1 -0
cbps/highdim/hdcbps.py
ADDED
|
@@ -0,0 +1,1078 @@
|
|
|
1
|
+
"""
|
|
2
|
+
High-Dimensional Covariate Balancing Propensity Score (hdCBPS)
|
|
3
|
+
==============================================================
|
|
4
|
+
|
|
5
|
+
This module implements the hdCBPS estimator for robust causal inference in
|
|
6
|
+
high-dimensional settings where the number of covariates may exceed the
|
|
7
|
+
sample size (p >> n).
|
|
8
|
+
|
|
9
|
+
Algorithm
|
|
10
|
+
---------
|
|
11
|
+
The hdCBPS algorithm proceeds in four steps:
|
|
12
|
+
|
|
13
|
+
**Step 1 (Propensity Score LASSO)**: Obtain initial propensity score estimates
|
|
14
|
+
via penalized M-estimation with cross-validated regularization parameter.
|
|
15
|
+
|
|
16
|
+
**Step 2 (Outcome Model LASSO)**: Fit penalized outcome models separately for
|
|
17
|
+
treated and control groups to identify relevant predictors of the outcome.
|
|
18
|
+
|
|
19
|
+
**Step 3 (Covariate Balancing)**: Calibrate propensity score coefficients by
|
|
20
|
+
minimizing the GMM objective that balances covariates selected in Step 2. This
|
|
21
|
+
achieves the weak covariate balancing property (Equation 9 in the paper).
|
|
22
|
+
|
|
23
|
+
**Step 4 (Treatment Effect Estimation)**: Compute ATE/ATT using the
|
|
24
|
+
Horvitz-Thompson estimator and derive asymptotic standard errors using the
|
|
25
|
+
sandwich variance estimator (Equation 11 in the paper).
|
|
26
|
+
|
|
27
|
+
Key Properties
|
|
28
|
+
--------------
|
|
29
|
+
- **Double Robustness**: Root-n consistent and asymptotically normal when
|
|
30
|
+
either the propensity score model or outcome model is correctly specified
|
|
31
|
+
(Propositions 1 and 2 in the paper).
|
|
32
|
+
- **Semiparametric Efficiency**: Achieves the semiparametric efficiency bound
|
|
33
|
+
under correct specification of both models (Theorem 1 in the paper).
|
|
34
|
+
- **Sample Boundedness**: Estimated treatment effects lie within the range
|
|
35
|
+
of observed outcomes due to the covariate balancing constraint (Remark 2
|
|
36
|
+
in the paper).
|
|
37
|
+
|
|
38
|
+
References
|
|
39
|
+
----------
|
|
40
|
+
Ning, Y., Peng, S., and Imai, K. (2020). Robust estimation of causal effects
|
|
41
|
+
via a high-dimensional covariate balancing propensity score. Biometrika,
|
|
42
|
+
107(3), 533-554. https://doi.org/10.1093/biomet/asaa020
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
import warnings
|
|
46
|
+
from typing import Optional, Union, Dict, Any
|
|
47
|
+
|
|
48
|
+
import numpy as np
|
|
49
|
+
import pandas as pd
|
|
50
|
+
from scipy.optimize import minimize
|
|
51
|
+
|
|
52
|
+
from .lasso_utils import (cv_glmnet, predict_glmnet_fortran, select_variables,
|
|
53
|
+
HAS_GLMNETFORPYTHON)
|
|
54
|
+
from .weight_funcs import ate_wt_func, ate_wt_nl_func, att_wt_func, att_wt_nl_func
|
|
55
|
+
from .gmm_loss import gmm_func, att_gmm_func
|
|
56
|
+
|
|
57
|
+
# Optimization options
|
|
58
|
+
NELDER_MEAD_OPTIONS = {'maxfev': 50000}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class HDCBPSSummary:
|
|
62
|
+
"""Summary object for HDCBPSResults.
|
|
63
|
+
|
|
64
|
+
Returned by :meth:`HDCBPSResults.summary`. Provides a structured
|
|
65
|
+
representation of hdCBPS estimation results that can be printed
|
|
66
|
+
via ``print()`` or ``str()``.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
ATE: float,
|
|
72
|
+
ATT: Optional[float],
|
|
73
|
+
s: float,
|
|
74
|
+
w: Optional[float],
|
|
75
|
+
fitted_values: Optional[np.ndarray],
|
|
76
|
+
fitted_y: Optional[np.ndarray],
|
|
77
|
+
fitted_x: Optional[np.ndarray],
|
|
78
|
+
converged: bool,
|
|
79
|
+
iterations_used: Optional[Dict[str, int]],
|
|
80
|
+
selected_indices_treat: Optional[np.ndarray],
|
|
81
|
+
selected_indices_control: Optional[np.ndarray],
|
|
82
|
+
n_selected_treat: Optional[int],
|
|
83
|
+
n_selected_control: Optional[int],
|
|
84
|
+
):
|
|
85
|
+
self.ATE = ATE
|
|
86
|
+
self.ATT = ATT
|
|
87
|
+
self.s = s
|
|
88
|
+
self.w = w
|
|
89
|
+
self.fitted_values = fitted_values
|
|
90
|
+
self.fitted_y = fitted_y
|
|
91
|
+
self.fitted_x = fitted_x
|
|
92
|
+
self.converged = converged
|
|
93
|
+
self.iterations_used = iterations_used
|
|
94
|
+
self.selected_indices_treat = selected_indices_treat
|
|
95
|
+
self.selected_indices_control = selected_indices_control
|
|
96
|
+
self.n_selected_treat = n_selected_treat
|
|
97
|
+
self.n_selected_control = n_selected_control
|
|
98
|
+
|
|
99
|
+
def __str__(self) -> str:
|
|
100
|
+
"""Return formatted summary text (matches HDCBPSResults.__str__())."""
|
|
101
|
+
output = "\nhdCBPS Estimation Results\n"
|
|
102
|
+
output += "=" * 60 + "\n"
|
|
103
|
+
|
|
104
|
+
if self.fitted_y is not None:
|
|
105
|
+
output += f"Sample size: {len(self.fitted_y)}\n"
|
|
106
|
+
if self.fitted_x is not None:
|
|
107
|
+
output += f"Covariates: {self.fitted_x.shape[1]}\n"
|
|
108
|
+
output += f"Converged: {'Yes' if self.converged else 'No'}\n"
|
|
109
|
+
|
|
110
|
+
output += f"\nTreatment Effects:\n"
|
|
111
|
+
output += f" ATE (Average Treatment Effect): {self.ATE:.6f}\n"
|
|
112
|
+
output += f" Standard Error: {self.s:.6f}\n"
|
|
113
|
+
output += f" 95% CI: [{self.ATE - 1.96*self.s:.6f}, {self.ATE + 1.96*self.s:.6f}]\n"
|
|
114
|
+
|
|
115
|
+
if self.ATT is not None:
|
|
116
|
+
output += f"\n ATT (Average Treatment on Treated): {self.ATT:.6f}\n"
|
|
117
|
+
if self.w is not None:
|
|
118
|
+
output += f" Standard Error: {self.w:.6f}\n"
|
|
119
|
+
output += f" 95% CI: [{self.ATT - 1.96*self.w:.6f}, {self.ATT + 1.96*self.w:.6f}]\n"
|
|
120
|
+
|
|
121
|
+
if self.fitted_values is not None:
|
|
122
|
+
output += f"\nPropensity Scores:\n"
|
|
123
|
+
output += f" Min: {self.fitted_values.min():.4f}\n"
|
|
124
|
+
output += f" Max: {self.fitted_values.max():.4f}\n"
|
|
125
|
+
output += f" Mean: {self.fitted_values.mean():.4f}\n"
|
|
126
|
+
|
|
127
|
+
if self.iterations_used is not None:
|
|
128
|
+
output += f"\nIterations:\n"
|
|
129
|
+
for key, val in self.iterations_used.items():
|
|
130
|
+
output += f" {key}: {val}\n"
|
|
131
|
+
|
|
132
|
+
if self.selected_indices_treat is not None or self.selected_indices_control is not None:
|
|
133
|
+
output += f"\nVariable Selection (LASSO):\n"
|
|
134
|
+
if self.n_selected_treat is not None:
|
|
135
|
+
output += f" Treatment group: {self.n_selected_treat} variables selected\n"
|
|
136
|
+
if len(self.selected_indices_treat) <= 10:
|
|
137
|
+
output += f" Indices: {self.selected_indices_treat.tolist()}\n"
|
|
138
|
+
else:
|
|
139
|
+
output += f" Indices: {self.selected_indices_treat[:5].tolist()}...{self.selected_indices_treat[-3:].tolist()} (showing first 5 and last 3)\n"
|
|
140
|
+
if self.n_selected_control is not None:
|
|
141
|
+
output += f" Control group: {self.n_selected_control} variables selected\n"
|
|
142
|
+
if len(self.selected_indices_control) <= 10:
|
|
143
|
+
output += f" Indices: {self.selected_indices_control.tolist()}\n"
|
|
144
|
+
else:
|
|
145
|
+
output += f" Indices: {self.selected_indices_control[:5].tolist()}...{self.selected_indices_control[-3:].tolist()} (showing first 5 and last 3)\n"
|
|
146
|
+
|
|
147
|
+
output += "=" * 60 + "\n"
|
|
148
|
+
return output
|
|
149
|
+
|
|
150
|
+
def __repr__(self) -> str:
|
|
151
|
+
return f"HDCBPSSummary(ATE={self.ATE:.6f}, converged={self.converged})"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class HDCBPSResults:
|
|
155
|
+
"""
|
|
156
|
+
Results container for high-dimensional CBPS estimation.
|
|
157
|
+
|
|
158
|
+
This class stores the output of :func:`hdCBPS`, including treatment effect
|
|
159
|
+
estimates, standard errors, propensity scores, and diagnostic information.
|
|
160
|
+
|
|
161
|
+
Attributes
|
|
162
|
+
----------
|
|
163
|
+
ATE : float
|
|
164
|
+
Average Treatment Effect estimate computed using the Horvitz-Thompson
|
|
165
|
+
estimator: :math:`\\hat{\\mu}_1 - \\hat{\\mu}_0`.
|
|
166
|
+
ATT : float or None
|
|
167
|
+
Average Treatment Effect on the Treated. Only computed if ``ATT=1``
|
|
168
|
+
was specified in the call to :func:`hdCBPS`.
|
|
169
|
+
s : float
|
|
170
|
+
Standard error of ATE computed using the sandwich variance estimator
|
|
171
|
+
(Equation 11 in Ning et al., 2020).
|
|
172
|
+
w : float or None
|
|
173
|
+
Standard error of ATT (if ATT was estimated).
|
|
174
|
+
fitted_values : np.ndarray, shape (n,)
|
|
175
|
+
Calibrated propensity scores from Step 3 of the algorithm.
|
|
176
|
+
weights : np.ndarray, shape (n,)
|
|
177
|
+
Inverse probability weights for outcome modeling.
|
|
178
|
+
coefficients1 : np.ndarray
|
|
179
|
+
Calibrated propensity score coefficients (treatment group optimization).
|
|
180
|
+
coefficients0 : np.ndarray
|
|
181
|
+
Calibrated propensity score coefficients (control group optimization).
|
|
182
|
+
fitted_y : np.ndarray, shape (n,)
|
|
183
|
+
Outcome variable used in estimation.
|
|
184
|
+
fitted_x : np.ndarray, shape (n, p+1)
|
|
185
|
+
Covariate matrix with intercept column.
|
|
186
|
+
converged : bool
|
|
187
|
+
Whether the GMM optimization converged within tolerance.
|
|
188
|
+
iterations_used : dict
|
|
189
|
+
Number of Nelder-Mead iterations for each optimization step.
|
|
190
|
+
selected_indices_treat : np.ndarray
|
|
191
|
+
Indices of variables selected by LASSO for the treated outcome model.
|
|
192
|
+
selected_indices_control : np.ndarray
|
|
193
|
+
Indices of variables selected by LASSO for the control outcome model.
|
|
194
|
+
n_selected_treat : int
|
|
195
|
+
Number of variables selected for the treated outcome model.
|
|
196
|
+
n_selected_control : int
|
|
197
|
+
Number of variables selected for the control outcome model.
|
|
198
|
+
data : pd.DataFrame
|
|
199
|
+
Original input data.
|
|
200
|
+
formula : str
|
|
201
|
+
Model formula specification.
|
|
202
|
+
terms : object
|
|
203
|
+
Patsy design information.
|
|
204
|
+
call : str
|
|
205
|
+
String representation of the function call.
|
|
206
|
+
na_action : dict or None
|
|
207
|
+
Missing value handling information.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
def __init__(self):
|
|
211
|
+
self.ATE: Optional[float] = None
|
|
212
|
+
self.ATT: Optional[float] = None
|
|
213
|
+
self.s: Optional[float] = None
|
|
214
|
+
self.w: Optional[float] = None
|
|
215
|
+
self.fitted_values: Optional[np.ndarray] = None
|
|
216
|
+
self.coefficients1: Optional[np.ndarray] = None
|
|
217
|
+
self.coefficients0: Optional[np.ndarray] = None
|
|
218
|
+
self.fitted_y: Optional[np.ndarray] = None
|
|
219
|
+
self.fitted_x: Optional[np.ndarray] = None
|
|
220
|
+
self.test1: Optional[np.ndarray] = None
|
|
221
|
+
self.test0: Optional[np.ndarray] = None
|
|
222
|
+
self.converged: Optional[bool] = None
|
|
223
|
+
self.iterations_used: Optional[Dict[str, int]] = None
|
|
224
|
+
self.weights: Optional[np.ndarray] = None
|
|
225
|
+
|
|
226
|
+
# Variable selection info
|
|
227
|
+
self.selected_indices_treat: Optional[np.ndarray] = None
|
|
228
|
+
self.selected_indices_control: Optional[np.ndarray] = None
|
|
229
|
+
self.n_selected_treat: Optional[int] = None
|
|
230
|
+
self.n_selected_control: Optional[int] = None
|
|
231
|
+
|
|
232
|
+
# Metadata
|
|
233
|
+
self.data: Optional[pd.DataFrame] = None
|
|
234
|
+
self.formula: Optional[str] = None
|
|
235
|
+
self.terms: Optional[object] = None
|
|
236
|
+
self.call: Optional[str] = None
|
|
237
|
+
self.na_action: Optional[dict] = None
|
|
238
|
+
|
|
239
|
+
# Debug data (stored in private dict; access via result._debug['key'])
|
|
240
|
+
self._debug: Dict[str, Any] = {}
|
|
241
|
+
|
|
242
|
+
def __getattr__(self, name: str):
|
|
243
|
+
"""Backward compatibility: emit deprecation warning when accessing debug data via legacy attribute names."""
|
|
244
|
+
if name.startswith('debug_') and '_debug' in self.__dict__:
|
|
245
|
+
warnings.warn(
|
|
246
|
+
f"Direct access to '{name}' is deprecated. Use result._debug['{name}'] instead."
|
|
247
|
+
" This attribute will be removed in a future version.",
|
|
248
|
+
DeprecationWarning,
|
|
249
|
+
stacklevel=2
|
|
250
|
+
)
|
|
251
|
+
if name in self._debug:
|
|
252
|
+
return self._debug[name]
|
|
253
|
+
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
|
|
254
|
+
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
|
|
255
|
+
|
|
256
|
+
def __dir__(self):
|
|
257
|
+
"""Ensure dir() does not expose debug_* attributes."""
|
|
258
|
+
default = super().__dir__()
|
|
259
|
+
return [attr for attr in default if not attr.startswith('debug_')]
|
|
260
|
+
|
|
261
|
+
@property
|
|
262
|
+
def ate(self) -> Optional[float]:
|
|
263
|
+
"""Lowercase alias for ATE (Python naming convention)."""
|
|
264
|
+
return self.ATE
|
|
265
|
+
|
|
266
|
+
@property
|
|
267
|
+
def se_ate(self) -> Optional[float]:
|
|
268
|
+
"""Standard error of ATE (alias for ``s``)."""
|
|
269
|
+
return self.s
|
|
270
|
+
|
|
271
|
+
@property
|
|
272
|
+
def att(self) -> Optional[float]:
|
|
273
|
+
"""Lowercase alias for ATT (Python naming convention)."""
|
|
274
|
+
return self.ATT
|
|
275
|
+
|
|
276
|
+
@property
|
|
277
|
+
def se_att(self) -> Optional[float]:
|
|
278
|
+
"""Standard error of ATT (alias for ``w``)."""
|
|
279
|
+
return self.w
|
|
280
|
+
|
|
281
|
+
@property
|
|
282
|
+
def coefficients(self) -> Optional[np.ndarray]:
|
|
283
|
+
"""
|
|
284
|
+
Alias for coefficients0 to maintain API consistency with other CBPS results.
|
|
285
|
+
|
|
286
|
+
Returns
|
|
287
|
+
-------
|
|
288
|
+
np.ndarray
|
|
289
|
+
Coefficients for the control group model.
|
|
290
|
+
"""
|
|
291
|
+
return self.coefficients0
|
|
292
|
+
|
|
293
|
+
@property
|
|
294
|
+
def x(self) -> Optional[np.ndarray]:
|
|
295
|
+
"""Alias for fitted_x."""
|
|
296
|
+
return self.fitted_x
|
|
297
|
+
|
|
298
|
+
@property
|
|
299
|
+
def y(self) -> Optional[np.ndarray]:
|
|
300
|
+
"""Alias for fitted_y."""
|
|
301
|
+
return self.fitted_y
|
|
302
|
+
|
|
303
|
+
def vcov(self):
|
|
304
|
+
"""
|
|
305
|
+
Variance-covariance matrix of coefficients.
|
|
306
|
+
|
|
307
|
+
Raises
|
|
308
|
+
------
|
|
309
|
+
ValueError
|
|
310
|
+
hdCBPS does not provide a standard variance-covariance matrix for coefficients
|
|
311
|
+
due to the nature of LASSO variable selection. Standard errors for ATE and ATT
|
|
312
|
+
are available via the 's' and 'w' attributes.
|
|
313
|
+
"""
|
|
314
|
+
raise ValueError(
|
|
315
|
+
"hdCBPS does not provide a variance-covariance matrix for coefficients "
|
|
316
|
+
"due to the nature of LASSO variable selection. "
|
|
317
|
+
"Standard errors for ATE and ATT are available as 's' and 'w' attributes."
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
def summary(self) -> 'HDCBPSSummary':
|
|
321
|
+
"""Generate a formatted summary of hdCBPS estimation results.
|
|
322
|
+
|
|
323
|
+
Returns
|
|
324
|
+
-------
|
|
325
|
+
HDCBPSSummary
|
|
326
|
+
Summary object with ``__str__`` method. Use ``print(result.summary())``
|
|
327
|
+
to display the formatted text.
|
|
328
|
+
"""
|
|
329
|
+
return HDCBPSSummary(
|
|
330
|
+
ATE=self.ATE,
|
|
331
|
+
ATT=self.ATT,
|
|
332
|
+
s=self.s,
|
|
333
|
+
w=self.w,
|
|
334
|
+
fitted_values=self.fitted_values,
|
|
335
|
+
fitted_y=self.fitted_y,
|
|
336
|
+
fitted_x=self.fitted_x,
|
|
337
|
+
converged=self.converged,
|
|
338
|
+
iterations_used=self.iterations_used,
|
|
339
|
+
selected_indices_treat=self.selected_indices_treat,
|
|
340
|
+
selected_indices_control=self.selected_indices_control,
|
|
341
|
+
n_selected_treat=self.n_selected_treat,
|
|
342
|
+
n_selected_control=self.n_selected_control,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
def __repr__(self) -> str:
|
|
346
|
+
if self.ATE is not None:
|
|
347
|
+
return f"HDCBPSResults(ATE={self.ATE:.6f}, s={self.s:.6f}, converged={self.converged})"
|
|
348
|
+
else:
|
|
349
|
+
return "HDCBPSResults(not fitted)"
|
|
350
|
+
|
|
351
|
+
def __str__(self) -> str:
|
|
352
|
+
if self.ATE is None:
|
|
353
|
+
return "HDCBPSResults(not fitted)"
|
|
354
|
+
|
|
355
|
+
output = "\nhdCBPS Estimation Results\n"
|
|
356
|
+
output += "=" * 60 + "\n"
|
|
357
|
+
|
|
358
|
+
# Basic Info
|
|
359
|
+
if self.fitted_y is not None:
|
|
360
|
+
output += f"Sample size: {len(self.fitted_y)}\n"
|
|
361
|
+
if self.fitted_x is not None:
|
|
362
|
+
output += f"Covariates: {self.fitted_x.shape[1]}\n"
|
|
363
|
+
output += f"Converged: {'Yes' if self.converged else 'No'}\n"
|
|
364
|
+
|
|
365
|
+
# Treatment Effects
|
|
366
|
+
output += f"\nTreatment Effects:\n"
|
|
367
|
+
output += f" ATE (Average Treatment Effect): {self.ATE:.6f}\n"
|
|
368
|
+
output += f" Standard Error: {self.s:.6f}\n"
|
|
369
|
+
output += f" 95% CI: [{self.ATE - 1.96*self.s:.6f}, {self.ATE + 1.96*self.s:.6f}]\n"
|
|
370
|
+
|
|
371
|
+
if self.ATT is not None:
|
|
372
|
+
output += f"\n ATT (Average Treatment on Treated): {self.ATT:.6f}\n"
|
|
373
|
+
if self.w is not None:
|
|
374
|
+
output += f" Standard Error: {self.w:.6f}\n"
|
|
375
|
+
output += f" 95% CI: [{self.ATT - 1.96*self.w:.6f}, {self.ATT + 1.96*self.w:.6f}]\n"
|
|
376
|
+
|
|
377
|
+
# Propensity Scores
|
|
378
|
+
if self.fitted_values is not None:
|
|
379
|
+
output += f"\nPropensity Scores:\n"
|
|
380
|
+
output += f" Min: {self.fitted_values.min():.4f}\n"
|
|
381
|
+
output += f" Max: {self.fitted_values.max():.4f}\n"
|
|
382
|
+
output += f" Mean: {self.fitted_values.mean():.4f}\n"
|
|
383
|
+
|
|
384
|
+
# Iterations
|
|
385
|
+
if self.iterations_used is not None:
|
|
386
|
+
output += f"\nIterations:\n"
|
|
387
|
+
for key, val in self.iterations_used.items():
|
|
388
|
+
output += f" {key}: {val}\n"
|
|
389
|
+
|
|
390
|
+
# Variable Selection
|
|
391
|
+
if self.selected_indices_treat is not None or self.selected_indices_control is not None:
|
|
392
|
+
output += f"\nVariable Selection (LASSO):\n"
|
|
393
|
+
if self.n_selected_treat is not None:
|
|
394
|
+
output += f" Treatment group: {self.n_selected_treat} variables selected\n"
|
|
395
|
+
if len(self.selected_indices_treat) <= 10:
|
|
396
|
+
output += f" Indices: {self.selected_indices_treat.tolist()}\n"
|
|
397
|
+
else:
|
|
398
|
+
output += f" Indices: {self.selected_indices_treat[:5].tolist()}...{self.selected_indices_treat[-3:].tolist()} (showing first 5 and last 3)\n"
|
|
399
|
+
if self.n_selected_control is not None:
|
|
400
|
+
output += f" Control group: {self.n_selected_control} variables selected\n"
|
|
401
|
+
if len(self.selected_indices_control) <= 10:
|
|
402
|
+
output += f" Indices: {self.selected_indices_control.tolist()}\n"
|
|
403
|
+
else:
|
|
404
|
+
output += f" Indices: {self.selected_indices_control[:5].tolist()}...{self.selected_indices_control[-3:].tolist()} (showing first 5 and last 3)\n"
|
|
405
|
+
|
|
406
|
+
output += "=" * 60 + "\n"
|
|
407
|
+
|
|
408
|
+
return output
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def hdCBPS(
|
|
412
|
+
formula: str,
|
|
413
|
+
data: pd.DataFrame,
|
|
414
|
+
y: Union[str, np.ndarray],
|
|
415
|
+
ATT: int = 0,
|
|
416
|
+
iterations: int = 1000,
|
|
417
|
+
method: str = 'linear',
|
|
418
|
+
seed: Optional[int] = None,
|
|
419
|
+
na_action: Optional[str] = None,
|
|
420
|
+
verbose: int = 0
|
|
421
|
+
) -> HDCBPSResults:
|
|
422
|
+
"""
|
|
423
|
+
High-dimensional covariate balancing propensity score estimation.
|
|
424
|
+
|
|
425
|
+
Estimate average treatment effects in high-dimensional settings (p >> n)
|
|
426
|
+
using the hdCBPS methodology of Ning, Peng, and Imai (2020). The method
|
|
427
|
+
combines LASSO variable selection with covariate balancing to achieve
|
|
428
|
+
double robustness and semiparametric efficiency.
|
|
429
|
+
|
|
430
|
+
Parameters
|
|
431
|
+
----------
|
|
432
|
+
formula : str
|
|
433
|
+
Model formula specifying treatment variable and covariates in Patsy
|
|
434
|
+
format. The left-hand side should be the binary treatment variable.
|
|
435
|
+
|
|
436
|
+
Example: ``'treat ~ age + educ + black + hisp + married + nodegr + re74 + re75'``
|
|
437
|
+
|
|
438
|
+
data : pd.DataFrame
|
|
439
|
+
Dataset containing all variables referenced in the formula and
|
|
440
|
+
the outcome variable.
|
|
441
|
+
y : str or np.ndarray
|
|
442
|
+
Outcome variable. Can be either:
|
|
443
|
+
|
|
444
|
+
- Column name (str) in ``data``
|
|
445
|
+
- NumPy array of outcome values
|
|
446
|
+
|
|
447
|
+
ATT : int, default=0
|
|
448
|
+
Target estimand:
|
|
449
|
+
|
|
450
|
+
- 0: Average Treatment Effect (ATE)
|
|
451
|
+
- 1: Average Treatment Effect on the Treated (ATT)
|
|
452
|
+
|
|
453
|
+
iterations : int, default=1000
|
|
454
|
+
Maximum number of Nelder-Mead iterations for the GMM optimization
|
|
455
|
+
in Step 3.
|
|
456
|
+
method : str, default='linear'
|
|
457
|
+
Outcome model family:
|
|
458
|
+
|
|
459
|
+
- ``'linear'``: Gaussian (continuous outcomes)
|
|
460
|
+
- ``'binomial'``: Logistic (binary outcomes)
|
|
461
|
+
- ``'poisson'``: Poisson (count outcomes)
|
|
462
|
+
|
|
463
|
+
seed : int, optional
|
|
464
|
+
Random seed for reproducibility of cross-validation folds.
|
|
465
|
+
na_action : str, optional
|
|
466
|
+
Missing value handling:
|
|
467
|
+
|
|
468
|
+
- ``None`` or ``'warn'``: Drop missing rows with warning (default)
|
|
469
|
+
- ``'drop'``: Drop missing rows silently
|
|
470
|
+
- ``'fail'``: Raise error if missing values present
|
|
471
|
+
|
|
472
|
+
verbose : int, default=0
|
|
473
|
+
Verbosity level (currently unused, reserved for future use).
|
|
474
|
+
|
|
475
|
+
Returns
|
|
476
|
+
-------
|
|
477
|
+
HDCBPSResults
|
|
478
|
+
Results object containing:
|
|
479
|
+
|
|
480
|
+
- ``ATE``: Average treatment effect estimate
|
|
481
|
+
- ``s``: Standard error of ATE
|
|
482
|
+
- ``ATT``: ATT estimate (if ``ATT=1``)
|
|
483
|
+
- ``w``: Standard error of ATT (if ``ATT=1``)
|
|
484
|
+
- ``fitted_values``: Calibrated propensity scores
|
|
485
|
+
- ``weights``: Inverse probability weights
|
|
486
|
+
- ``converged``: Convergence status
|
|
487
|
+
- ``selected_indices_*``: LASSO-selected variable indices
|
|
488
|
+
|
|
489
|
+
Raises
|
|
490
|
+
------
|
|
491
|
+
ImportError
|
|
492
|
+
If glmnetforpython is not installed.
|
|
493
|
+
ValueError
|
|
494
|
+
If ``iterations`` is not a positive integer, or if ``na_action='fail'``
|
|
495
|
+
and missing values are present.
|
|
496
|
+
|
|
497
|
+
Examples
|
|
498
|
+
--------
|
|
499
|
+
>>> from cbps import hdCBPS
|
|
500
|
+
>>> from cbps.datasets import load_lalonde
|
|
501
|
+
>>> df = load_lalonde(dehejia_wahba_only=True)
|
|
502
|
+
>>> result = hdCBPS(
|
|
503
|
+
... formula='treat ~ age + educ + black + hisp + married + nodegr + re74 + re75',
|
|
504
|
+
... data=df,
|
|
505
|
+
... y='re78',
|
|
506
|
+
... ATT=0,
|
|
507
|
+
... method='linear'
|
|
508
|
+
... )
|
|
509
|
+
>>> print(f"ATE: {result.ATE:.4f} (SE: {result.s:.4f})")
|
|
510
|
+
|
|
511
|
+
Notes
|
|
512
|
+
-----
|
|
513
|
+
The algorithm achieves double robustness: the estimator is consistent
|
|
514
|
+
and asymptotically normal if either the propensity score model or the
|
|
515
|
+
outcome model is correctly specified (Propositions 1 and 2 in the paper).
|
|
516
|
+
|
|
517
|
+
References
|
|
518
|
+
----------
|
|
519
|
+
Ning, Y., Peng, S., and Imai, K. (2020). Robust estimation of causal
|
|
520
|
+
effects via a high-dimensional covariate balancing propensity score.
|
|
521
|
+
Biometrika, 107(3), 533-554.
|
|
522
|
+
"""
|
|
523
|
+
if iterations is None:
|
|
524
|
+
iterations = 1000
|
|
525
|
+
|
|
526
|
+
if not isinstance(iterations, (int, np.integer)) or iterations <= 0:
|
|
527
|
+
raise ValueError(
|
|
528
|
+
f"iterations must be a positive integer, got {iterations}. Default is 1000."
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
if seed is not None:
|
|
532
|
+
np.random.seed(seed)
|
|
533
|
+
|
|
534
|
+
# Handle missing values
|
|
535
|
+
n_orig = len(data)
|
|
536
|
+
if data.isnull().any().any():
|
|
537
|
+
if na_action == 'fail':
|
|
538
|
+
raise ValueError(
|
|
539
|
+
f"Data contains missing values and na_action='fail'. "
|
|
540
|
+
f"Found {data.isnull().sum().sum()} missing values."
|
|
541
|
+
)
|
|
542
|
+
data = data.dropna()
|
|
543
|
+
if na_action != 'drop':
|
|
544
|
+
n_dropped = n_orig - len(data)
|
|
545
|
+
warnings.warn(
|
|
546
|
+
f"Removed {n_dropped} observations ({n_dropped/n_orig*100:.1f}%) with missing values.",
|
|
547
|
+
UserWarning
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
from cbps.utils.formula import parse_formula
|
|
551
|
+
from patsy import dmatrices
|
|
552
|
+
|
|
553
|
+
data_original = data.copy()
|
|
554
|
+
|
|
555
|
+
_, X_design = dmatrices(formula, data, return_type='dataframe')
|
|
556
|
+
terms_obj = X_design.design_info
|
|
557
|
+
|
|
558
|
+
treat, X = parse_formula(formula, data)
|
|
559
|
+
|
|
560
|
+
if isinstance(y, str):
|
|
561
|
+
y_array = data[y].values
|
|
562
|
+
else:
|
|
563
|
+
y_array = np.asarray(y)
|
|
564
|
+
|
|
565
|
+
if y_array is None or len(y_array) == 0:
|
|
566
|
+
raise ValueError("Outcome variable y is required and cannot be None or empty.")
|
|
567
|
+
|
|
568
|
+
y_var = np.var(y_array)
|
|
569
|
+
if y_var < 1e-10:
|
|
570
|
+
warnings.warn(
|
|
571
|
+
f"Outcome variable y has near-zero variance ({y_var:.2e}). ATE estimates may be close to zero.",
|
|
572
|
+
UserWarning
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
# Preprocess X: remove zero-variance columns and add intercept
|
|
576
|
+
X_nonzero_var = X[:, X.std(axis=0) > 0]
|
|
577
|
+
X_with_intercept = np.column_stack([np.ones(len(X_nonzero_var)), X_nonzero_var])
|
|
578
|
+
|
|
579
|
+
result = hdCBPS_fit(
|
|
580
|
+
x=X_with_intercept,
|
|
581
|
+
y=y_array,
|
|
582
|
+
treat=treat,
|
|
583
|
+
ATT=ATT,
|
|
584
|
+
iterations=iterations,
|
|
585
|
+
method=method
|
|
586
|
+
)
|
|
587
|
+
|
|
588
|
+
result.data = data_original
|
|
589
|
+
result.formula = formula
|
|
590
|
+
result.terms = terms_obj
|
|
591
|
+
result.call = f"hdCBPS(formula='{formula}', data=..., y=..., ATT={ATT}, method='{method}')"
|
|
592
|
+
result.na_action = None
|
|
593
|
+
|
|
594
|
+
return result
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def hdCBPS_fit(
|
|
598
|
+
x: np.ndarray,
|
|
599
|
+
y: np.ndarray,
|
|
600
|
+
treat: np.ndarray,
|
|
601
|
+
ATT: int,
|
|
602
|
+
iterations: int = 1000,
|
|
603
|
+
method: str = "linear"
|
|
604
|
+
) -> HDCBPSResults:
|
|
605
|
+
"""
|
|
606
|
+
Core fitting routine for hdCBPS estimation.
|
|
607
|
+
|
|
608
|
+
This function implements the four-step hdCBPS algorithm on preprocessed
|
|
609
|
+
data. It is called internally by :func:`hdCBPS` after formula parsing
|
|
610
|
+
and missing value handling.
|
|
611
|
+
|
|
612
|
+
Parameters
|
|
613
|
+
----------
|
|
614
|
+
x : np.ndarray, shape (n, p+1)
|
|
615
|
+
Covariate matrix with intercept column prepended.
|
|
616
|
+
y : np.ndarray, shape (n,)
|
|
617
|
+
Outcome variable.
|
|
618
|
+
treat : np.ndarray, shape (n,)
|
|
619
|
+
Binary treatment indicator (0/1).
|
|
620
|
+
ATT : int
|
|
621
|
+
Estimand selection:
|
|
622
|
+
|
|
623
|
+
- 0: Estimate ATE only
|
|
624
|
+
- 1: Estimate both ATE and ATT
|
|
625
|
+
|
|
626
|
+
iterations : int, default=1000
|
|
627
|
+
Maximum Nelder-Mead iterations for GMM optimization.
|
|
628
|
+
method : str, default='linear'
|
|
629
|
+
Outcome model family: ``'linear'``, ``'binomial'``, or ``'poisson'``.
|
|
630
|
+
|
|
631
|
+
Returns
|
|
632
|
+
-------
|
|
633
|
+
HDCBPSResults
|
|
634
|
+
Fitted results object with treatment effect estimates and diagnostics.
|
|
635
|
+
|
|
636
|
+
Notes
|
|
637
|
+
-----
|
|
638
|
+
The algorithm steps are:
|
|
639
|
+
|
|
640
|
+
1. LASSO for outcome models (treated and control groups separately)
|
|
641
|
+
2. LASSO for propensity score model
|
|
642
|
+
3. GMM optimization to calibrate propensity scores
|
|
643
|
+
4. Horvitz-Thompson estimation and variance calculation
|
|
644
|
+
"""
|
|
645
|
+
n, p = x.shape
|
|
646
|
+
|
|
647
|
+
y1hat = y[treat == 1]
|
|
648
|
+
x1hat = x[treat == 1, :]
|
|
649
|
+
y0hat = y[treat == 0]
|
|
650
|
+
x0hat = x[treat == 0, :]
|
|
651
|
+
|
|
652
|
+
# ===================================================================
|
|
653
|
+
# Steps 1-2: LASSO Variable Selection
|
|
654
|
+
# Paper: Step 1 = propensity score (Eq.5), Step 2 = outcome model (Eq.6)
|
|
655
|
+
# Implementation: outcome models first, then propensity score
|
|
656
|
+
# (order is interchangeable when w_2(u)=1, i.e., unweighted LASSO)
|
|
657
|
+
# ===================================================================
|
|
658
|
+
|
|
659
|
+
# Outcome model LASSO (Step 2 in paper, Equation 6 with w_2=1)
|
|
660
|
+
# Fit separately for treated and control groups to select predictive covariates
|
|
661
|
+
if method == "linear":
|
|
662
|
+
# Gaussian family for continuous outcomes
|
|
663
|
+
model1, coef1, lambda_min_1 = cv_glmnet(x1hat, y1hat, family='gaussian', intercept=True)
|
|
664
|
+
model0, coef0, lambda_min_0 = cv_glmnet(x0hat, y0hat, family='gaussian', intercept=True)
|
|
665
|
+
elif method == "binomial":
|
|
666
|
+
# Logistic family for binary outcomes (Section 4 of paper)
|
|
667
|
+
# Note: intercept=False for outcome model in R's cv.glmnet
|
|
668
|
+
# cv_glmnet returns full coefficient vector including intercept position (set to 0)
|
|
669
|
+
model1, coef1, lambda_min_1 = cv_glmnet(x1hat, y1hat, family='binomial', intercept=False)
|
|
670
|
+
model0, coef0, lambda_min_0 = cv_glmnet(x0hat, y0hat, family='binomial', intercept=False)
|
|
671
|
+
elif method == "poisson":
|
|
672
|
+
# Poisson family for count outcomes (Section 4 of paper)
|
|
673
|
+
# Note: intercept=False for outcome model
|
|
674
|
+
model1, coef1, lambda_min_1 = cv_glmnet(x1hat, y1hat, family='poisson', intercept=False)
|
|
675
|
+
model0, coef0, lambda_min_0 = cv_glmnet(x0hat, y0hat, family='poisson', intercept=False)
|
|
676
|
+
else:
|
|
677
|
+
raise ValueError(f"method '{method}' not supported")
|
|
678
|
+
|
|
679
|
+
# Propensity score LASSO (Step 1 in paper, Equation 5)
|
|
680
|
+
# Logistic regression with L1 penalty for initial propensity score coefficients
|
|
681
|
+
modelb, coef_b, lambda_min_b = cv_glmnet(x, treat, family='binomial', intercept=True)
|
|
682
|
+
|
|
683
|
+
using_glmnetforpython = HAS_GLMNETFORPYTHON
|
|
684
|
+
|
|
685
|
+
# Extract selected variable indices: S_tilde in paper notation (Equation 7)
|
|
686
|
+
S1 = select_variables(coef1) # Support of treated outcome model coefficients
|
|
687
|
+
S0 = select_variables(coef0) # Support of control outcome model coefficients
|
|
688
|
+
|
|
689
|
+
# ===================================================================
|
|
690
|
+
# Step 3: Covariate Balancing Calibration (Equation 7)
|
|
691
|
+
# Minimize ||g_n(gamma)||^2 to achieve weak covariate balance
|
|
692
|
+
# ===================================================================
|
|
693
|
+
|
|
694
|
+
tol = 1e-5 # Convergence tolerance for GMM objective
|
|
695
|
+
kk0 = 0
|
|
696
|
+
kk1 = 0
|
|
697
|
+
ATT_kk0 = 0
|
|
698
|
+
diff0 = 1.0
|
|
699
|
+
diff1 = 1.0
|
|
700
|
+
ATT_diff0 = 1.0
|
|
701
|
+
|
|
702
|
+
def gmm_loss0(beta_curr):
|
|
703
|
+
return gmm_func(
|
|
704
|
+
beta_curr, S=S0, tt=0, X_gmm=x, method=method,
|
|
705
|
+
cov1_coef=coef1, cov0_coef=coef0, treat=treat, beta_ini=coef_b
|
|
706
|
+
)
|
|
707
|
+
|
|
708
|
+
def gmm_loss1(beta_curr):
|
|
709
|
+
return gmm_func(
|
|
710
|
+
beta_curr, S=S1, tt=1, X_gmm=x, method=method,
|
|
711
|
+
cov1_coef=coef1, cov0_coef=coef0, treat=treat, beta_ini=coef_b
|
|
712
|
+
)
|
|
713
|
+
|
|
714
|
+
# ===================================================================
|
|
715
|
+
# Step 3.1: ATE Calibration
|
|
716
|
+
# Optimize gamma separately for treated (mu_1) and control (mu_0)
|
|
717
|
+
# ===================================================================
|
|
718
|
+
|
|
719
|
+
if method == "linear":
|
|
720
|
+
# Control group: calibrate to balance X_{S0}
|
|
721
|
+
if len(S0) > 0:
|
|
722
|
+
beta0_init = coef_b[S0]
|
|
723
|
+
beta0_result = minimize(gmm_loss0, beta0_init, method='Nelder-Mead',
|
|
724
|
+
options=NELDER_MEAD_OPTIONS)
|
|
725
|
+
|
|
726
|
+
while diff0 > tol and kk0 < iterations:
|
|
727
|
+
beta0_result = minimize(gmm_loss0, beta0_result.x, method='Nelder-Mead',
|
|
728
|
+
options=NELDER_MEAD_OPTIONS)
|
|
729
|
+
diff0 = beta0_result.fun
|
|
730
|
+
kk0 += 1
|
|
731
|
+
|
|
732
|
+
beta0_par = beta0_result.x
|
|
733
|
+
else:
|
|
734
|
+
beta0_par = np.array([])
|
|
735
|
+
diff0 = 0.0
|
|
736
|
+
|
|
737
|
+
# Treatment group optimization
|
|
738
|
+
if len(S1) > 0:
|
|
739
|
+
beta1_init = coef_b[S1]
|
|
740
|
+
beta1_result = minimize(gmm_loss1, beta1_init, method='Nelder-Mead', options=NELDER_MEAD_OPTIONS)
|
|
741
|
+
|
|
742
|
+
while diff1 > tol and kk1 < iterations:
|
|
743
|
+
beta1_result = minimize(gmm_loss1, beta1_result.x, method='Nelder-Mead', options=NELDER_MEAD_OPTIONS)
|
|
744
|
+
diff1 = beta1_result.fun
|
|
745
|
+
kk1 += 1
|
|
746
|
+
|
|
747
|
+
beta1_par = beta1_result.x
|
|
748
|
+
else:
|
|
749
|
+
beta1_par = np.array([])
|
|
750
|
+
diff1 = 0.0
|
|
751
|
+
|
|
752
|
+
w_curr1 = ate_wt_func(beta1_par, S1, 1, x, coef_b, treat)
|
|
753
|
+
w_curr0 = ate_wt_func(beta0_par, S0, 0, x, coef_b, treat)
|
|
754
|
+
|
|
755
|
+
else:
|
|
756
|
+
# Nonlinear (binomial/poisson) optimization
|
|
757
|
+
# Control group
|
|
758
|
+
if len(S0) > 0:
|
|
759
|
+
beta0_init = coef_b[np.r_[0, S0]]
|
|
760
|
+
beta0_result = minimize(gmm_loss0, beta0_init, method='Nelder-Mead', options=NELDER_MEAD_OPTIONS)
|
|
761
|
+
|
|
762
|
+
while diff0 > tol and kk0 < iterations:
|
|
763
|
+
beta0_result = minimize(gmm_loss0, beta0_result.x, method='Nelder-Mead', options=NELDER_MEAD_OPTIONS)
|
|
764
|
+
diff0 = beta0_result.fun
|
|
765
|
+
kk0 += 1
|
|
766
|
+
|
|
767
|
+
beta0_par = beta0_result.x
|
|
768
|
+
else:
|
|
769
|
+
beta0_init = coef_b[[0]]
|
|
770
|
+
beta0_result = minimize(gmm_loss0, beta0_init, method='Nelder-Mead', options=NELDER_MEAD_OPTIONS)
|
|
771
|
+
|
|
772
|
+
while diff0 > tol and kk0 < iterations:
|
|
773
|
+
beta0_result = minimize(gmm_loss0, beta0_result.x, method='Nelder-Mead', options=NELDER_MEAD_OPTIONS)
|
|
774
|
+
diff0 = beta0_result.fun
|
|
775
|
+
kk0 += 1
|
|
776
|
+
|
|
777
|
+
beta0_par = beta0_result.x
|
|
778
|
+
|
|
779
|
+
# Treatment group
|
|
780
|
+
if len(S1) > 0:
|
|
781
|
+
beta1_init = coef_b[np.r_[0, S1]]
|
|
782
|
+
beta1_result = minimize(gmm_loss1, beta1_init, method='Nelder-Mead', options=NELDER_MEAD_OPTIONS)
|
|
783
|
+
|
|
784
|
+
while diff1 > tol and kk1 < iterations:
|
|
785
|
+
beta1_result = minimize(gmm_loss1, beta1_result.x, method='Nelder-Mead', options=NELDER_MEAD_OPTIONS)
|
|
786
|
+
diff1 = beta1_result.fun
|
|
787
|
+
kk1 += 1
|
|
788
|
+
|
|
789
|
+
beta1_par = beta1_result.x
|
|
790
|
+
else:
|
|
791
|
+
beta1_init = coef_b[[0]]
|
|
792
|
+
beta1_result = minimize(gmm_loss1, beta1_init, method='Nelder-Mead', options=NELDER_MEAD_OPTIONS)
|
|
793
|
+
|
|
794
|
+
while diff1 > tol and kk1 < iterations:
|
|
795
|
+
beta1_result = minimize(gmm_loss1, beta1_result.x, method='Nelder-Mead', options=NELDER_MEAD_OPTIONS)
|
|
796
|
+
diff1 = beta1_result.fun
|
|
797
|
+
kk1 += 1
|
|
798
|
+
|
|
799
|
+
beta1_par = beta1_result.x
|
|
800
|
+
|
|
801
|
+
w_curr1 = ate_wt_nl_func(beta1_par, S1, 1, x, coef_b, treat)
|
|
802
|
+
w_curr0 = ate_wt_nl_func(beta0_par, S0, 0, x, coef_b, treat)
|
|
803
|
+
|
|
804
|
+
ATE = (1.0 / n) * (
|
|
805
|
+
y1hat.T @ (w_curr1[treat == 1] + 1.0) +
|
|
806
|
+
y0hat.T @ (w_curr0[treat == 0] - 1.0)
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
# ===================================================================
|
|
810
|
+
# Step 3.2: ATT Optimization Loop
|
|
811
|
+
# ===================================================================
|
|
812
|
+
|
|
813
|
+
if ATT != 0:
|
|
814
|
+
def ATT_gmm_loss(beta_curr):
|
|
815
|
+
return att_gmm_func(
|
|
816
|
+
beta_curr, S=S0, X_gmm=x, method=method,
|
|
817
|
+
cov0_coef=coef0, treat=treat, beta_ini=coef_b
|
|
818
|
+
)
|
|
819
|
+
|
|
820
|
+
if method == "linear":
|
|
821
|
+
if len(S0) > 0:
|
|
822
|
+
ATT_beta0_init = coef_b[S0]
|
|
823
|
+
ATT_beta0_result = minimize(ATT_gmm_loss, ATT_beta0_init, method='Nelder-Mead', options=NELDER_MEAD_OPTIONS)
|
|
824
|
+
|
|
825
|
+
while ATT_diff0 > tol and ATT_kk0 < iterations:
|
|
826
|
+
ATT_beta0_result = minimize(ATT_gmm_loss, ATT_beta0_result.x, method='Nelder-Mead', options=NELDER_MEAD_OPTIONS)
|
|
827
|
+
ATT_diff0 = ATT_beta0_result.fun
|
|
828
|
+
ATT_kk0 += 1
|
|
829
|
+
|
|
830
|
+
ATT_beta0_par = ATT_beta0_result.x
|
|
831
|
+
else:
|
|
832
|
+
ATT_beta0_par = np.array([])
|
|
833
|
+
ATT_diff0 = 0.0
|
|
834
|
+
|
|
835
|
+
ATT_w_curr0 = att_wt_func(ATT_beta0_par, S0, x, coef_b, treat)
|
|
836
|
+
|
|
837
|
+
else:
|
|
838
|
+
if len(S0) > 0:
|
|
839
|
+
ATT_beta0_init = coef_b[np.r_[0, S0]]
|
|
840
|
+
else:
|
|
841
|
+
ATT_beta0_init = coef_b[[0]]
|
|
842
|
+
|
|
843
|
+
ATT_beta0_result = minimize(ATT_gmm_loss, ATT_beta0_init, method='Nelder-Mead', options=NELDER_MEAD_OPTIONS)
|
|
844
|
+
|
|
845
|
+
while ATT_diff0 > tol and ATT_kk0 < iterations:
|
|
846
|
+
ATT_beta0_result = minimize(ATT_gmm_loss, ATT_beta0_result.x, method='Nelder-Mead', options=NELDER_MEAD_OPTIONS)
|
|
847
|
+
ATT_diff0 = ATT_beta0_result.fun
|
|
848
|
+
ATT_kk0 += 1
|
|
849
|
+
|
|
850
|
+
ATT_beta0_par = ATT_beta0_result.x
|
|
851
|
+
|
|
852
|
+
ATT_w_curr0 = att_wt_nl_func(ATT_beta0_par, S0, x, coef_b, treat)
|
|
853
|
+
|
|
854
|
+
ATT_value = (
|
|
855
|
+
(1.0 / np.sum(treat)) * np.sum(y1hat) -
|
|
856
|
+
(1.0 / np.sum(ATT_w_curr0[treat == 0])) * (y0hat.T @ ATT_w_curr0[treat == 0])
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
X_full = np.column_stack([np.ones(n), x])
|
|
860
|
+
ATT_beta_0 = coef_b.copy()
|
|
861
|
+
if method == "linear":
|
|
862
|
+
if len(S0) > 0:
|
|
863
|
+
ATT_beta_0[S0] = ATT_beta0_par
|
|
864
|
+
else:
|
|
865
|
+
if len(S0) > 0:
|
|
866
|
+
ATT_beta_0[np.r_[0, S0]] = ATT_beta0_par
|
|
867
|
+
else:
|
|
868
|
+
ATT_beta_0[[0]] = ATT_beta0_par
|
|
869
|
+
|
|
870
|
+
ATT_theta_0 = X_full @ ATT_beta_0
|
|
871
|
+
ATT_r_yhatb0 = 1.0 - 1.0 / (1.0 + np.exp(ATT_theta_0))
|
|
872
|
+
else:
|
|
873
|
+
ATT_value = None
|
|
874
|
+
ATT_w_curr0 = None
|
|
875
|
+
ATT_r_yhatb0 = None
|
|
876
|
+
|
|
877
|
+
if ATT != 0:
|
|
878
|
+
converged = (diff0 <= tol and diff1 <= tol and ATT_diff0 <= tol)
|
|
879
|
+
iterations_used = {'kk0': kk0, 'kk1': kk1, 'ATT_kk0': ATT_kk0}
|
|
880
|
+
else:
|
|
881
|
+
converged = (diff0 <= tol and diff1 <= tol)
|
|
882
|
+
iterations_used = {'kk0': kk0, 'kk1': kk1}
|
|
883
|
+
|
|
884
|
+
# ===================================================================
|
|
885
|
+
# Step 4: Variance Estimation
|
|
886
|
+
# Extended from Equation 11 in Ning et al. (2020) to estimate Var(ATE):
|
|
887
|
+
# V_hat = (1/n) * [delta_K + term1 + term2]
|
|
888
|
+
# where:
|
|
889
|
+
# delta_K = sum((alpha1'X - alpha0'X - ATE)^2) [prediction variance]
|
|
890
|
+
# term1 = sum((Y - alpha1'X)^2 / pi^2) for treated [treated residuals]
|
|
891
|
+
# term2 = sum((Y - alpha0'X)^2 / (1-pi)^2) for control [control residuals]
|
|
892
|
+
# Standard error: s = sqrt(V_hat / n)
|
|
893
|
+
# ===================================================================
|
|
894
|
+
|
|
895
|
+
if method == "linear":
|
|
896
|
+
X_full = np.column_stack([np.ones(n), x])
|
|
897
|
+
beta_0 = coef_b.copy()
|
|
898
|
+
if len(S0) > 0:
|
|
899
|
+
beta_0[S0] = beta0_par
|
|
900
|
+
theta_0 = X_full @ beta_0
|
|
901
|
+
r_yhatb0 = 1.0 - 1.0 / (1.0 + np.exp(theta_0))
|
|
902
|
+
|
|
903
|
+
beta_1 = coef_b.copy()
|
|
904
|
+
if len(S1) > 0:
|
|
905
|
+
beta_1[S1] = beta1_par
|
|
906
|
+
theta_1 = X_full @ beta_1
|
|
907
|
+
r_yhatb1 = 1.0 - 1.0 / (1.0 + np.exp(theta_1))
|
|
908
|
+
|
|
909
|
+
if using_glmnetforpython:
|
|
910
|
+
r_yhat1 = predict_glmnet_fortran(model1, x1hat)
|
|
911
|
+
r_yhat0 = predict_glmnet_fortran(model0, x0hat)
|
|
912
|
+
r_yhat1full = predict_glmnet_fortran(model1, x)
|
|
913
|
+
r_yhat0full = predict_glmnet_fortran(model0, x)
|
|
914
|
+
else:
|
|
915
|
+
r_yhat1 = x1hat @ coef1[1:] + coef1[0] if len(coef1) > 1 else np.full(len(y1hat), coef1[0])
|
|
916
|
+
r_yhat0 = x0hat @ coef0[1:] + coef0[0] if len(coef0) > 1 else np.full(len(y0hat), coef0[0])
|
|
917
|
+
r_yhat1full = x @ coef1[1:] + coef1[0] if len(coef1) > 1 else np.full(n, coef1[0])
|
|
918
|
+
r_yhat0full = x @ coef0[1:] + coef0[0] if len(coef0) > 1 else np.full(n, coef0[0])
|
|
919
|
+
|
|
920
|
+
else:
|
|
921
|
+
X_full = np.column_stack([np.ones(n), x])
|
|
922
|
+
beta_0 = coef_b.copy()
|
|
923
|
+
if len(S0) > 0:
|
|
924
|
+
beta_0[np.r_[0, S0]] = beta0_par
|
|
925
|
+
else:
|
|
926
|
+
beta_0[[0]] = beta0_par
|
|
927
|
+
theta_0 = X_full @ beta_0
|
|
928
|
+
r_yhatb0 = 1.0 - 1.0 / (1.0 + np.exp(theta_0))
|
|
929
|
+
|
|
930
|
+
beta_1 = coef_b.copy()
|
|
931
|
+
if len(S1) > 0:
|
|
932
|
+
beta_1[np.r_[0, S1]] = beta1_par
|
|
933
|
+
else:
|
|
934
|
+
beta_1[[0]] = beta1_par
|
|
935
|
+
theta_1 = X_full @ beta_1
|
|
936
|
+
r_yhatb1 = 1.0 - 1.0 / (1.0 + np.exp(theta_1))
|
|
937
|
+
|
|
938
|
+
if using_glmnetforpython:
|
|
939
|
+
r_yhat1 = predict_glmnet_fortran(model1, x1hat)
|
|
940
|
+
r_yhat0 = predict_glmnet_fortran(model0, x0hat)
|
|
941
|
+
r_yhat1full = predict_glmnet_fortran(model1, x)
|
|
942
|
+
r_yhat0full = predict_glmnet_fortran(model0, x)
|
|
943
|
+
else:
|
|
944
|
+
if method == "binomial":
|
|
945
|
+
eta1 = x1hat @ coef1[1:] + coef1[0] if len(coef1) > 1 else np.full(len(y1hat), coef1[0])
|
|
946
|
+
eta0 = x0hat @ coef0[1:] + coef0[0] if len(coef0) > 1 else np.full(len(y0hat), coef0[0])
|
|
947
|
+
eta1full = x @ coef1[1:] + coef1[0] if len(coef1) > 1 else np.full(n, coef1[0])
|
|
948
|
+
eta0full = x @ coef0[1:] + coef0[0] if len(coef0) > 1 else np.full(n, coef0[0])
|
|
949
|
+
r_yhat1 = 1.0 / (1.0 + np.exp(-eta1))
|
|
950
|
+
r_yhat0 = 1.0 / (1.0 + np.exp(-eta0))
|
|
951
|
+
r_yhat1full = 1.0 / (1.0 + np.exp(-eta1full))
|
|
952
|
+
r_yhat0full = 1.0 / (1.0 + np.exp(-eta0full))
|
|
953
|
+
else: # poisson
|
|
954
|
+
eta1 = x1hat @ coef1[1:] + coef1[0] if len(coef1) > 1 else np.full(len(y1hat), coef1[0])
|
|
955
|
+
eta0 = x0hat @ coef0[1:] + coef0[0] if len(coef0) > 1 else np.full(len(y0hat), coef0[0])
|
|
956
|
+
eta1full = x @ coef1[1:] + coef1[0] if len(coef1) > 1 else np.full(n, coef1[0])
|
|
957
|
+
eta0full = x @ coef0[1:] + coef0[0] if len(coef0) > 1 else np.full(n, coef0[0])
|
|
958
|
+
r_yhat1 = np.exp(eta1)
|
|
959
|
+
r_yhat0 = np.exp(eta0)
|
|
960
|
+
r_yhat1full = np.exp(eta1full)
|
|
961
|
+
r_yhat0full = np.exp(eta0full)
|
|
962
|
+
|
|
963
|
+
# Compute variance components (extended from Equation 11 for ATE)
|
|
964
|
+
# Following R implementation exactly:
|
|
965
|
+
# delta_K <- sum((r_yhat1full - r_yhat0full - rep(ATE, n))^2)
|
|
966
|
+
# sigma_1 <- sum((r_yhat1 - y1hat)^2/r_yhatb1[treat == 1])/n # scalar
|
|
967
|
+
# sigma_0 <- sum((r_yhat0 - y0hat)^2/(1 - r_yhatb0[treat == 0]))/n # scalar
|
|
968
|
+
# s = sqrt((delta_K + sum(sigma_1/r_yhatb1) + sum(sigma_0/r_yhatb0))/n)/sqrt(n)
|
|
969
|
+
delta_K = np.sum((r_yhat1full - r_yhat0full - ATE)**2)
|
|
970
|
+
|
|
971
|
+
# Treatment group variance term: denominator is π̂² (squared)
|
|
972
|
+
term1 = np.sum((r_yhat1 - y1hat)**2 / (r_yhatb1[treat == 1]**2))
|
|
973
|
+
# Control group variance term: denominator is (1-π̂)² (squared)
|
|
974
|
+
term2 = np.sum((r_yhat0 - y0hat)**2 / ((1.0 - r_yhatb0[treat == 0])**2))
|
|
975
|
+
|
|
976
|
+
V_hat = (delta_K + term1 + term2) / n
|
|
977
|
+
s_value = np.sqrt(V_hat) / np.sqrt(n) # Standard error of ATE
|
|
978
|
+
|
|
979
|
+
if ATT != 0:
|
|
980
|
+
ATT_delta_K = np.sum(ATT_r_yhatb0 * (r_yhat1full - r_yhat0full - ATT_value)**2)
|
|
981
|
+
term_treat = np.sum(ATT_r_yhatb0[treat == 1] * (r_yhat1 - y1hat)**2)
|
|
982
|
+
term_control = np.sum(
|
|
983
|
+
ATT_r_yhatb0[treat == 0]**2 * (r_yhat0 - y0hat)**2 /
|
|
984
|
+
(1.0 - ATT_r_yhatb0[treat == 0])
|
|
985
|
+
)
|
|
986
|
+
w_value = (n / np.sum(treat)) * np.sqrt(
|
|
987
|
+
(ATT_delta_K + term_treat + term_control) / n
|
|
988
|
+
) / np.sqrt(n)
|
|
989
|
+
else:
|
|
990
|
+
w_value = None
|
|
991
|
+
|
|
992
|
+
# ===================================================================
|
|
993
|
+
# Compute Calibrated Propensity Scores
|
|
994
|
+
# Recover pi from weights: W = T/pi - 1 => pi = T/(W+1)
|
|
995
|
+
# ===================================================================
|
|
996
|
+
|
|
997
|
+
fitted_values = np.ones(n)
|
|
998
|
+
fitted_values[treat == 1] = 1.0 / (w_curr1[treat == 1] + 1.0) # pi for treated
|
|
999
|
+
fitted_values[treat == 0] = 1.0 - 1.0 / (1.0 - w_curr0[treat == 0]) # pi for control
|
|
1000
|
+
|
|
1001
|
+
# ===================================================================
|
|
1002
|
+
# Construct Result Object
|
|
1003
|
+
# ===================================================================
|
|
1004
|
+
|
|
1005
|
+
if method == "linear":
|
|
1006
|
+
beta_0_final = coef_b.copy()
|
|
1007
|
+
if len(S0) > 0:
|
|
1008
|
+
beta_0_final[S0] = beta0_par
|
|
1009
|
+
|
|
1010
|
+
beta_1_final = coef_b.copy()
|
|
1011
|
+
if len(S1) > 0:
|
|
1012
|
+
beta_1_final[S1] = beta1_par
|
|
1013
|
+
else:
|
|
1014
|
+
beta_0_final = coef_b.copy()
|
|
1015
|
+
if len(S0) > 0:
|
|
1016
|
+
beta_0_final[np.r_[0, S0]] = beta0_par
|
|
1017
|
+
else:
|
|
1018
|
+
beta_0_final[[0]] = beta0_par
|
|
1019
|
+
|
|
1020
|
+
beta_1_final = coef_b.copy()
|
|
1021
|
+
if len(S1) > 0:
|
|
1022
|
+
beta_1_final[np.r_[0, S1]] = beta1_par
|
|
1023
|
+
else:
|
|
1024
|
+
beta_1_final[[0]] = beta1_par
|
|
1025
|
+
|
|
1026
|
+
result = HDCBPSResults()
|
|
1027
|
+
result.ATE = ATE
|
|
1028
|
+
result.ATT = ATT_value
|
|
1029
|
+
result.s = s_value
|
|
1030
|
+
result.w = w_value
|
|
1031
|
+
result.fitted_values = fitted_values
|
|
1032
|
+
result.coefficients1 = beta_1_final
|
|
1033
|
+
result.coefficients0 = beta_0_final
|
|
1034
|
+
result.fitted_y = y
|
|
1035
|
+
result.fitted_x = x
|
|
1036
|
+
result.test1 = w_curr1
|
|
1037
|
+
result.test0 = w_curr0
|
|
1038
|
+
result.converged = converged
|
|
1039
|
+
result.iterations_used = iterations_used
|
|
1040
|
+
|
|
1041
|
+
pi = fitted_values
|
|
1042
|
+
if ATT == 0:
|
|
1043
|
+
weights = treat / pi + (1 - treat) / (1 - pi)
|
|
1044
|
+
elif ATT == 1:
|
|
1045
|
+
weights = treat + (1 - treat) * pi / (1 - pi)
|
|
1046
|
+
else:
|
|
1047
|
+
weights = treat * (1 - pi) / pi + (1 - treat)
|
|
1048
|
+
result.weights = weights
|
|
1049
|
+
|
|
1050
|
+
result.selected_indices_treat = S1
|
|
1051
|
+
result.selected_indices_control = S0
|
|
1052
|
+
result.n_selected_treat = len(S1)
|
|
1053
|
+
result.n_selected_control = len(S0)
|
|
1054
|
+
|
|
1055
|
+
if ATT != 0:
|
|
1056
|
+
result.ATT_w_curr0 = ATT_w_curr0
|
|
1057
|
+
|
|
1058
|
+
# Debug information
|
|
1059
|
+
result._debug['debug_r_yhat1'] = r_yhat1
|
|
1060
|
+
result._debug['debug_r_yhat0'] = r_yhat0
|
|
1061
|
+
result._debug['debug_r_yhat1full'] = r_yhat1full
|
|
1062
|
+
result._debug['debug_r_yhat0full'] = r_yhat0full
|
|
1063
|
+
result._debug['debug_r_yhatb1'] = r_yhatb1
|
|
1064
|
+
result._debug['debug_r_yhatb0'] = r_yhatb0
|
|
1065
|
+
result._debug['debug_delta_K'] = delta_K
|
|
1066
|
+
result._debug['debug_sigma_1'] = term1
|
|
1067
|
+
result._debug['debug_sigma_0'] = term2
|
|
1068
|
+
result._debug['debug_y1hat'] = y1hat
|
|
1069
|
+
result._debug['debug_y0hat'] = y0hat
|
|
1070
|
+
result._debug['debug_coef1'] = coef1
|
|
1071
|
+
result._debug['debug_coef0'] = coef0
|
|
1072
|
+
result._debug['debug_coef_b'] = coef_b
|
|
1073
|
+
result._debug['debug_lambda_min_1'] = lambda_min_1
|
|
1074
|
+
result._debug['debug_lambda_min_0'] = lambda_min_0
|
|
1075
|
+
result._debug['debug_lambda_min_b'] = lambda_min_b
|
|
1076
|
+
|
|
1077
|
+
return result
|
|
1078
|
+
|