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.
Files changed (70) hide show
  1. cbps/__init__.py +3462 -0
  2. cbps/constants.py +46 -0
  3. cbps/core/__init__.py +93 -0
  4. cbps/core/cbps_binary.py +1943 -0
  5. cbps/core/cbps_continuous.py +945 -0
  6. cbps/core/cbps_multitreat.py +1123 -0
  7. cbps/core/cbps_optimal.py +507 -0
  8. cbps/core/results.py +1447 -0
  9. cbps/data/Blackwell.csv +571 -0
  10. cbps/data/LaLonde.csv +3213 -0
  11. cbps/data/npcbps_continuous_sim.csv +501 -0
  12. cbps/data/nsw.csv +723 -0
  13. cbps/data/nsw_dw.csv +446 -0
  14. cbps/data/political_ads_urban_niebler.csv +16266 -0
  15. cbps/data/psid_controls.csv +2491 -0
  16. cbps/data/psid_controls2.csv +254 -0
  17. cbps/data/psid_controls3.csv +129 -0
  18. cbps/data/simulation_dgp1_seed12345.csv +201 -0
  19. cbps/data/simulation_dgp2_seed12345.csv +201 -0
  20. cbps/data/simulation_dgp3_seed12345.csv +201 -0
  21. cbps/data/simulation_dgp4_seed12345.csv +201 -0
  22. cbps/datasets/__init__.py +78 -0
  23. cbps/datasets/blackwell.py +112 -0
  24. cbps/datasets/continuous.py +223 -0
  25. cbps/datasets/lalonde.py +272 -0
  26. cbps/datasets/npcbps_sim.py +101 -0
  27. cbps/diagnostics/__init__.py +101 -0
  28. cbps/diagnostics/balance.py +760 -0
  29. cbps/diagnostics/balance_cbmsm_addon.py +162 -0
  30. cbps/diagnostics/continuous_diagnostics.py +259 -0
  31. cbps/diagnostics/normality.py +173 -0
  32. cbps/diagnostics/ocbps_conditions.py +197 -0
  33. cbps/diagnostics/overlap.py +198 -0
  34. cbps/diagnostics/plots.py +1193 -0
  35. cbps/diagnostics/weights_diag.py +205 -0
  36. cbps/highdim/__init__.py +84 -0
  37. cbps/highdim/gmm_loss.py +340 -0
  38. cbps/highdim/hdcbps.py +1078 -0
  39. cbps/highdim/lasso_utils.py +498 -0
  40. cbps/highdim/weight_funcs.py +298 -0
  41. cbps/inference/__init__.py +42 -0
  42. cbps/inference/asyvar.py +621 -0
  43. cbps/inference/vcov_outcome.py +217 -0
  44. cbps/iv/__init__.py +48 -0
  45. cbps/iv/cbiv.py +2603 -0
  46. cbps/logging_config.py +45 -0
  47. cbps/msm/__init__.py +45 -0
  48. cbps/msm/cbmsm.py +1871 -0
  49. cbps/msm/rank_diagnostics.py +112 -0
  50. cbps/nonparametric/__init__.py +58 -0
  51. cbps/nonparametric/cholesky_whitening.py +232 -0
  52. cbps/nonparametric/empirical_likelihood.py +339 -0
  53. cbps/nonparametric/npcbps.py +1036 -0
  54. cbps/nonparametric/taylor_approx.py +207 -0
  55. cbps/py.typed +0 -0
  56. cbps/sklearn/__init__.py +42 -0
  57. cbps/sklearn/estimator.py +378 -0
  58. cbps/utils/__init__.py +82 -0
  59. cbps/utils/formula.py +415 -0
  60. cbps/utils/helpers.py +378 -0
  61. cbps/utils/numerics.py +438 -0
  62. cbps/utils/r_compat.py +109 -0
  63. cbps/utils/validation.py +224 -0
  64. cbps/utils/variance_transform.py +483 -0
  65. cbps/utils/weights.py +586 -0
  66. cbps-0.2.0.dist-info/METADATA +1090 -0
  67. cbps-0.2.0.dist-info/RECORD +70 -0
  68. cbps-0.2.0.dist-info/WHEEL +5 -0
  69. cbps-0.2.0.dist-info/licenses/LICENSE +661 -0
  70. 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
+