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
@@ -0,0 +1,760 @@
1
+ """
2
+ Covariate Balance Assessment
3
+ ============================
4
+
5
+ Functions for computing covariate balance statistics before and after CBPS
6
+ weighting. Balance diagnostics are essential for evaluating whether propensity
7
+ score methods have successfully removed confounding due to observed covariates.
8
+
9
+ For binary and multi-valued treatments, balance is measured via standardized
10
+ mean differences (SMD). For continuous treatments, balance is assessed via
11
+ weighted Pearson correlations between covariates and the treatment variable.
12
+
13
+ References
14
+ ----------
15
+ Imai, K. and Ratkovic, M. (2014). Covariate balancing propensity score.
16
+ Journal of the Royal Statistical Society, Series B, 76(1), 243-263.
17
+
18
+ Fong, C., Hazlett, C., and Imai, K. (2018). Covariate balancing propensity
19
+ score for a continuous treatment. The Annals of Applied Statistics, 12(1),
20
+ 156-177.
21
+
22
+ Stuart, E.A. (2010). Matching methods for causal inference: A review and a
23
+ look forward. Statistical Science, 25(1), 1-21.
24
+
25
+ Austin, P.C. (2009). Balance diagnostics for comparing the distribution of
26
+ baseline covariates between treatment groups in propensity-score matched
27
+ samples. Statistics in Medicine, 28(25), 3083-3107.
28
+ """
29
+ from typing import Dict, Any, Optional
30
+ import numpy as np
31
+ import pandas as pd
32
+ import scipy.stats
33
+
34
+
35
+ def omnibus_balance_test(
36
+ X: np.ndarray,
37
+ treat: np.ndarray,
38
+ weights: np.ndarray,
39
+ method: str = 'hotelling'
40
+ ) -> Dict[str, Any]:
41
+ """Omnibus test for overall covariate balance after weighting.
42
+
43
+ Tests H0: All weighted standardized mean differences are jointly zero.
44
+
45
+ This is a general diagnostic tool for weighted covariate balance assessment,
46
+ complementing the variable-by-variable SMD diagnostics. Not specific to
47
+ CBPS methodology.
48
+
49
+ Parameters
50
+ ----------
51
+ X : np.ndarray, shape (n, k)
52
+ Covariate matrix (no intercept).
53
+ treat : np.ndarray, shape (n,)
54
+ Binary treatment indicator (0/1).
55
+ weights : np.ndarray, shape (n,)
56
+ IPW weights from CBPS estimation.
57
+ method : {'hotelling', 'chi2'}, default='hotelling'
58
+ - 'hotelling': Hotelling's T² test (accounts for correlation)
59
+ - 'chi2': Sum of squared SMDs (assumes independence)
60
+
61
+ Returns
62
+ -------
63
+ dict with:
64
+ - statistic: test statistic value
65
+ - p_value: p-value under null
66
+ - df: degrees of freedom
67
+ - method: test method used
68
+ - interpretation: string describing result
69
+
70
+ Notes
71
+ -----
72
+ This is a general balance diagnostic tool (Austin 2009), not specific
73
+ to the CBPS methodology of Imai & Ratkovic (2014).
74
+
75
+ The Hotelling T² test statistic is:
76
+ T² = n_eff * d' @ S^{-1} @ d
77
+ where d is the vector of weighted mean differences and S is their
78
+ estimated covariance matrix.
79
+
80
+ Under H0 with large n: T² ~ chi2(k)
81
+
82
+ The chi2 method uses:
83
+ Q = sum(SMD_j²) * n_eff
84
+ which assumes independence across covariates (conservative when
85
+ covariates are correlated).
86
+
87
+ References
88
+ ----------
89
+ Hotelling, H. (1931). The generalization of Student's ratio.
90
+ Annals of Mathematical Statistics, 2(3), 360-378.
91
+
92
+ Austin, P.C. (2009). Balance diagnostics for comparing the distribution
93
+ of baseline covariates between treatment groups in propensity-score
94
+ matched samples. Statistics in Medicine, 28(25), 3083-3107.
95
+ """
96
+ if method not in ('hotelling', 'chi2'):
97
+ raise ValueError(
98
+ f"method must be 'hotelling' or 'chi2', got '{method}'"
99
+ )
100
+
101
+ X = np.asarray(X, dtype=np.float64)
102
+ treat = np.asarray(treat, dtype=np.float64).ravel()
103
+ weights = np.asarray(weights, dtype=np.float64).ravel()
104
+
105
+ n, k = X.shape
106
+
107
+ # Separate by treatment group
108
+ mask1 = treat == 1
109
+ mask0 = treat == 0
110
+ w1 = weights[mask1]
111
+ w0 = weights[mask0]
112
+ X1 = X[mask1]
113
+ X0 = X[mask0]
114
+
115
+ # Compute weighted means per group
116
+ sum_w1 = w1.sum()
117
+ sum_w0 = w0.sum()
118
+ mean1 = (w1[:, None] * X1).sum(axis=0) / sum_w1
119
+ mean0 = (w0[:, None] * X0).sum(axis=0) / sum_w0
120
+
121
+ # Weighted mean difference vector
122
+ d = mean1 - mean0
123
+
124
+ # Effective sample size (Kish's formula per group)
125
+ n_eff1 = sum_w1**2 / (w1**2).sum()
126
+ n_eff0 = sum_w0**2 / (w0**2).sum()
127
+ n_eff = (n_eff1 * n_eff0) / (n_eff1 + n_eff0) # harmonic-mean style
128
+
129
+ if method == 'hotelling':
130
+ # Compute pooled weighted covariance matrix
131
+ # Group 1 covariance
132
+ X1_centered = X1 - mean1[None, :]
133
+ S1 = (w1[:, None] * X1_centered).T @ X1_centered / sum_w1
134
+
135
+ # Group 0 covariance
136
+ X0_centered = X0 - mean0[None, :]
137
+ S0 = (w0[:, None] * X0_centered).T @ X0_centered / sum_w0
138
+
139
+ # Pooled covariance (weighted average)
140
+ S = (S1 / n_eff1 + S0 / n_eff0)
141
+
142
+ # Regularize for numerical stability
143
+ S += np.eye(k) * 1e-10
144
+
145
+ # Hotelling T² = d' @ S^{-1} @ d * n_eff
146
+ try:
147
+ S_inv = np.linalg.inv(S)
148
+ T2 = float(d @ S_inv @ d)
149
+ except np.linalg.LinAlgError:
150
+ # Fallback to pseudo-inverse for singular matrices
151
+ S_inv = np.linalg.pinv(S)
152
+ T2 = float(d @ S_inv @ d)
153
+
154
+ statistic = T2
155
+ df = k
156
+ p_value = 1.0 - scipy.stats.chi2.cdf(statistic, df)
157
+
158
+ else: # method == 'chi2'
159
+ # Compute pooled standard deviations for standardization
160
+ pooled_var = np.var(X, axis=0, ddof=1)
161
+ pooled_sd = np.sqrt(np.maximum(pooled_var, 1e-10))
162
+
163
+ # Standardized mean differences
164
+ smd = d / pooled_sd
165
+
166
+ # Sum of squared SMDs * n_eff ~ chi2(k) under H0
167
+ statistic = float(n_eff * np.sum(smd**2))
168
+ df = k
169
+ p_value = 1.0 - scipy.stats.chi2.cdf(statistic, df)
170
+
171
+ # Interpretation
172
+ if p_value > 0.1:
173
+ interpretation = (
174
+ f"No evidence against overall balance (p={p_value:.4f}). "
175
+ f"Weighted covariates appear jointly balanced."
176
+ )
177
+ elif p_value > 0.05:
178
+ interpretation = (
179
+ f"Marginal evidence of imbalance (p={p_value:.4f}). "
180
+ f"Some covariates may remain unbalanced."
181
+ )
182
+ else:
183
+ interpretation = (
184
+ f"Significant overall imbalance detected (p={p_value:.4f}). "
185
+ f"Weighted covariates are not jointly balanced."
186
+ )
187
+
188
+ return {
189
+ 'statistic': statistic,
190
+ 'p_value': p_value,
191
+ 'df': df,
192
+ 'method': method,
193
+ 'interpretation': interpretation
194
+ }
195
+
196
+
197
+ def balance_cbps(cbps_obj: Dict[str, Any]) -> Dict[str, np.ndarray]:
198
+ """
199
+ Compute covariate balance statistics for binary or multi-valued treatments.
200
+
201
+ Calculates weighted and unweighted covariate means within each treatment
202
+ group, along with their standardized versions (divided by pooled standard
203
+ deviation). These statistics enable assessment of covariate balance before
204
+ and after CBPS weighting.
205
+
206
+ Parameters
207
+ ----------
208
+ cbps_obj : dict
209
+ Fitted CBPS result containing:
210
+
211
+ - **weights** : ndarray of shape (n,) - CBPS weights
212
+ - **x** : ndarray of shape (n, k) - Covariate matrix with intercept
213
+ - **y** : ndarray of shape (n,) - Treatment indicator
214
+
215
+ Returns
216
+ -------
217
+ dict
218
+ Balance statistics with keys:
219
+
220
+ - **balanced** : ndarray of shape (n_covars, 2*n_treats)
221
+ Weighted covariate means (first n_treats columns) and
222
+ standardized weighted means (remaining columns).
223
+ - **original** : ndarray of shape (n_covars, 2*n_treats)
224
+ Unweighted covariate means and standardized means.
225
+
226
+ Notes
227
+ -----
228
+ The standardized mean difference (SMD) between treatment groups can be
229
+ computed from the output as the difference in standardized means across
230
+ columns. For binary treatments with groups 0 and 1:
231
+
232
+ .. math::
233
+
234
+ \\text{SMD} = |\\bar{X}_1 - \\bar{X}_0| / s
235
+
236
+ where :math:`s` is the full-sample standard deviation (computed across
237
+ all observations regardless of treatment). This choice ensures the
238
+ standardization denominator remains constant before and after weighting.
239
+
240
+ Following Austin (2009), SMD < 0.1 indicates acceptable balance.
241
+
242
+ Examples
243
+ --------
244
+ >>> import cbps
245
+ >>> from cbps.datasets import load_lalonde
246
+ >>> df = load_lalonde(dehejia_wahba_only=True)
247
+ >>> fit = cbps.CBPS('treat ~ age + educ + re74', data=df, att=1)
248
+ >>> cbps_dict = {'weights': fit.weights, 'x': fit.x, 'y': fit.y}
249
+ >>> from cbps.diagnostics.balance import balance_cbps
250
+ >>> result = balance_cbps(cbps_dict)
251
+ >>> print(result['balanced'].shape)
252
+ (3, 4)
253
+ """
254
+ # Detect npCBPS object (has log_el attribute)
255
+ is_npcbps = 'log_el' in cbps_obj
256
+
257
+ # Step 1: Extract input data
258
+ treats = pd.Categorical(cbps_obj['y'])
259
+ treat_levels = treats.categories
260
+ n_treats = len(treat_levels)
261
+
262
+ # Extract X matrix and weights
263
+ X = cbps_obj['x'] # Covariate matrix
264
+ w = cbps_obj['weights'] # CBPS weights
265
+
266
+ # Step 2: Initialize result matrices
267
+ # npCBPS: X has no intercept, all columns are covariates
268
+ # CBPS: X has intercept in column 0, skip it
269
+ if is_npcbps:
270
+ n_covars = X.shape[1] # All columns are covariates
271
+ j_start = 0 # Start from column 0
272
+ else:
273
+ n_covars = X.shape[1] - 1 # Exclude intercept column
274
+ j_start = 1 # Skip intercept column X[:, 0]
275
+
276
+ bal = np.zeros((n_covars, 2 * n_treats), dtype=np.float64)
277
+ baseline = np.zeros((n_covars, 2 * n_treats), dtype=np.float64)
278
+
279
+ # Step 3: Compute weighted means and standardized values
280
+ for i, level in enumerate(treat_levels):
281
+ # Create treatment group mask
282
+ mask = (treats == level).values if hasattr(treats, 'values') else (treats == level)
283
+
284
+ for j_col in range(j_start, X.shape[1]):
285
+ idx = j_col - j_start
286
+
287
+ # Weighted mean
288
+ bal[idx, i] = np.sum(mask * X[:, j_col] * w) / np.sum(w * mask)
289
+
290
+ # Standardized mean (using pooled std)
291
+ std_pooled = np.std(X[:, j_col], ddof=1)
292
+ bal[idx, i + n_treats] = bal[idx, i] / std_pooled
293
+
294
+ # Original mean (unweighted baseline)
295
+ baseline[idx, i] = np.mean(X[mask, j_col])
296
+
297
+ # Standardized original mean
298
+ baseline[idx, i + n_treats] = baseline[idx, i] / std_pooled
299
+
300
+ # Step 4: Return results
301
+ return {"balanced": bal, "original": baseline}
302
+
303
+
304
+ def _compute_smd_binary(bal: np.ndarray, baseline: np.ndarray, n_treats: int) -> tuple:
305
+ """
306
+ Compute standardized mean differences from balance matrices.
307
+
308
+ Parameters
309
+ ----------
310
+ bal : ndarray of shape (n_covars, 2*n_treats)
311
+ Weighted balance matrix from balance_cbps.
312
+ baseline : ndarray of shape (n_covars, 2*n_treats)
313
+ Unweighted balance matrix from balance_cbps.
314
+ n_treats : int
315
+ Number of treatment levels.
316
+
317
+ Returns
318
+ -------
319
+ smd_weighted : ndarray of shape (n_covars, n_treats-1)
320
+ Absolute SMD after weighting.
321
+ smd_unweighted : ndarray of shape (n_covars, n_treats-1)
322
+ Absolute SMD before weighting (baseline).
323
+
324
+ Notes
325
+ -----
326
+ For binary treatments, SMD is computed as the absolute difference
327
+ between standardized means of the two groups. For multi-valued
328
+ treatments, SMD is computed for each non-reference group versus
329
+ the reference group.
330
+ """
331
+ n_covars = bal.shape[0]
332
+
333
+ # Extract standardized means (last n_treats columns)
334
+ std_means_weighted = bal[:, n_treats:] # (n_covars, n_treats)
335
+ std_means_unweighted = baseline[:, n_treats:]
336
+
337
+ # Compute SMD: compare first group vs other groups
338
+ if n_treats == 2:
339
+ # Binary treatment: group1 - group0
340
+ smd_weighted = np.abs(std_means_weighted[:, 1] - std_means_weighted[:, 0]).reshape(-1, 1)
341
+ smd_unweighted = np.abs(std_means_unweighted[:, 1] - std_means_unweighted[:, 0]).reshape(-1, 1)
342
+ else:
343
+ # Multi-valued treatment: each group vs first group
344
+ smd_weighted = np.abs(std_means_weighted[:, 1:] - std_means_weighted[:, [0]])
345
+ smd_unweighted = np.abs(std_means_unweighted[:, 1:] - std_means_unweighted[:, [0]])
346
+
347
+ return smd_weighted, smd_unweighted
348
+
349
+
350
+ def balance_cbps_enhanced(
351
+ cbps_obj: Dict[str, Any],
352
+ threshold: float = 0.1,
353
+ covariate_names: Optional[list] = None
354
+ ) -> Dict[str, Any]:
355
+ """
356
+ Extended balance diagnostics with summary statistics and reports.
357
+
358
+ Augments the basic balance_cbps output with improvement metrics,
359
+ threshold-based assessments, and a formatted text report suitable
360
+ for publication or diagnostic review.
361
+
362
+ Parameters
363
+ ----------
364
+ cbps_obj : dict
365
+ Fitted CBPS result object.
366
+ threshold : float, default=0.1
367
+ SMD threshold for acceptable balance. The conventional threshold
368
+ of 0.1 follows Stuart (2010) and Austin (2009).
369
+ covariate_names : list of str, optional
370
+ Names for covariates. If provided, included in detailed report.
371
+
372
+ Returns
373
+ -------
374
+ dict
375
+ Extended diagnostics containing:
376
+
377
+ - **balanced** : ndarray - Weighted balance matrix
378
+ - **original** : ndarray - Unweighted balance matrix
379
+ - **smd_weighted** : ndarray - SMD after weighting
380
+ - **smd_unweighted** : ndarray - SMD before weighting
381
+ - **improvement_pct** : ndarray - Percent reduction in SMD
382
+ - **n_imbalanced_before** : int - Count exceeding threshold before
383
+ - **n_imbalanced_after** : int - Count exceeding threshold after
384
+ - **summary** : dict - Aggregate statistics
385
+ - **report** : str - Formatted text report
386
+ """
387
+ # Call the base balance function
388
+ result = balance_cbps(cbps_obj)
389
+ bal = result['balanced']
390
+ baseline = result['original']
391
+
392
+ # Extract number of treatment levels
393
+ treats = pd.Categorical(cbps_obj['y'])
394
+ n_treats = len(treats.categories)
395
+
396
+ # Compute SMD
397
+ smd_weighted, smd_unweighted = _compute_smd_binary(bal, baseline, n_treats)
398
+
399
+ # Compute improvement percentage
400
+ improvement_pct = np.zeros_like(smd_weighted)
401
+ nonzero_mask = smd_unweighted > 1e-10
402
+ improvement_pct[nonzero_mask] = (
403
+ (smd_unweighted[nonzero_mask] - smd_weighted[nonzero_mask]) /
404
+ smd_unweighted[nonzero_mask] * 100
405
+ )
406
+
407
+ # Count imbalanced covariates
408
+ n_imbalanced_before = np.sum(smd_unweighted > threshold)
409
+ n_imbalanced_after = np.sum(smd_weighted > threshold)
410
+
411
+ # Summary statistics
412
+ summary = {
413
+ 'mean_smd_before': float(np.mean(smd_unweighted)),
414
+ 'mean_smd_after': float(np.mean(smd_weighted)),
415
+ 'max_smd_before': float(np.max(smd_unweighted)),
416
+ 'max_smd_after': float(np.max(smd_weighted)),
417
+ 'n_imbalanced_before': int(n_imbalanced_before),
418
+ 'n_imbalanced_after': int(n_imbalanced_after),
419
+ 'pct_imbalanced_before': float(n_imbalanced_before / smd_unweighted.size * 100),
420
+ 'pct_imbalanced_after': float(n_imbalanced_after / smd_weighted.size * 100),
421
+ 'mean_improvement_pct': float(np.mean(improvement_pct[nonzero_mask])) if nonzero_mask.any() else 0.0
422
+ }
423
+
424
+ # Generate text report
425
+ n_covars = bal.shape[0]
426
+ report = f"""
427
+ Covariate Balance Diagnostic Report
428
+ {'=' * 60}
429
+
430
+ Sample Statistics:
431
+ Number of covariates: {n_covars}
432
+ Number of treatment groups: {n_treats}
433
+ SMD threshold: {threshold}
434
+
435
+ Balance Before Weighting:
436
+ Mean SMD: {summary['mean_smd_before']:.4f}
437
+ Max SMD: {summary['max_smd_before']:.4f}
438
+ Imbalanced covariates: {summary['n_imbalanced_before']} ({summary['pct_imbalanced_before']:.1f}%)
439
+
440
+ Balance After CBPS Weighting:
441
+ Mean SMD: {summary['mean_smd_after']:.4f}
442
+ Max SMD: {summary['max_smd_after']:.4f}
443
+ Imbalanced covariates: {summary['n_imbalanced_after']} ({summary['pct_imbalanced_after']:.1f}%)
444
+
445
+ Improvement:
446
+ Mean improvement: {summary['mean_improvement_pct']:.1f}%
447
+ Reduced imbalanced covariates: {summary['n_imbalanced_before'] - summary['n_imbalanced_after']}
448
+
449
+ Interpretation:
450
+ SMD < 0.1: Excellent balance
451
+ SMD < 0.25: Acceptable balance
452
+ SMD > 0.25: Poor balance (consider model adjustment)
453
+ {'=' * 60}
454
+ """
455
+
456
+ # Detailed covariate report
457
+ if covariate_names is not None and len(covariate_names) == n_covars:
458
+ report += "\nDetailed Covariate Balance:\n"
459
+ report += f"{'Covariate':<20} {'Before':>10} {'After':>10} {'Improve':>10} {'Status':>10}\n"
460
+ report += "-" * 65 + "\n"
461
+
462
+ for i in range(n_covars):
463
+ name = covariate_names[i][:18]
464
+ before = smd_unweighted[i, 0]
465
+ after = smd_weighted[i, 0]
466
+ improve = improvement_pct[i, 0]
467
+
468
+ if after < 0.1:
469
+ status = "Excellent"
470
+ elif after < 0.25:
471
+ status = "Good"
472
+ else:
473
+ status = "⚠ Poor"
474
+
475
+ report += f"{name:<20} {before:>10.4f} {after:>10.4f} {improve:>9.1f}% {status:>10}\n"
476
+
477
+ # Return enhanced results
478
+ return {
479
+ # Core output
480
+ 'balanced': bal,
481
+ 'original': baseline,
482
+ # Enhanced output
483
+ 'smd_weighted': smd_weighted,
484
+ 'smd_unweighted': smd_unweighted,
485
+ 'improvement_pct': improvement_pct,
486
+ 'n_imbalanced_before': int(n_imbalanced_before),
487
+ 'n_imbalanced_after': int(n_imbalanced_after),
488
+ 'summary': summary,
489
+ 'report': report
490
+ }
491
+
492
+
493
+ def balance_cbps_continuous_enhanced(
494
+ cbps_obj: Dict[str, Any],
495
+ threshold: float = 0.1,
496
+ covariate_names: Optional[list] = None
497
+ ) -> Dict[str, Any]:
498
+ """
499
+ Extended balance diagnostics for continuous treatments.
500
+
501
+ Augments balance_cbps_continuous with improvement metrics and a
502
+ formatted text report for continuous treatment settings.
503
+
504
+ Parameters
505
+ ----------
506
+ cbps_obj : dict
507
+ Fitted continuous treatment CBPS result object.
508
+ threshold : float, default=0.1
509
+ Absolute correlation threshold for imbalance detection.
510
+ covariate_names : list of str, optional
511
+ Names for covariates. If provided, included in detailed report.
512
+
513
+ Returns
514
+ -------
515
+ dict
516
+ Extended diagnostics containing:
517
+
518
+ - **balanced** : ndarray - Weighted correlations
519
+ - **unweighted** : ndarray - Unweighted correlations
520
+ - **abs_corr_weighted** : ndarray - Absolute weighted correlations
521
+ - **abs_corr_unweighted** : ndarray - Absolute unweighted correlations
522
+ - **improvement_pct** : ndarray - Percent reduction in |correlation|
523
+ - **n_imbalanced_before** : int - Count exceeding threshold before
524
+ - **n_imbalanced_after** : int - Count exceeding threshold after
525
+ - **summary** : dict - Aggregate statistics
526
+ - **report** : str - Formatted text report
527
+ """
528
+ # Call the base balance function
529
+ result = balance_cbps_continuous(cbps_obj)
530
+ bal = result['balanced']
531
+ baseline = result['unweighted']
532
+
533
+ # Compute absolute correlations
534
+ abs_corr_weighted = np.abs(bal)
535
+ abs_corr_unweighted = np.abs(baseline)
536
+
537
+ # Compute improvement
538
+ improvement = abs_corr_unweighted - abs_corr_weighted
539
+ improvement_pct = np.zeros_like(improvement)
540
+ nonzero_mask = abs_corr_unweighted > 1e-10
541
+ improvement_pct[nonzero_mask] = (
542
+ improvement[nonzero_mask] / abs_corr_unweighted[nonzero_mask] * 100
543
+ )
544
+
545
+ # Count imbalanced covariates
546
+ n_imbalanced_before = np.sum(abs_corr_unweighted > threshold)
547
+ n_imbalanced_after = np.sum(abs_corr_weighted > threshold)
548
+
549
+ # Summary statistics
550
+ n_covars = bal.shape[0]
551
+ summary = {
552
+ 'mean_abs_corr_before': float(np.mean(abs_corr_unweighted)),
553
+ 'mean_abs_corr_after': float(np.mean(abs_corr_weighted)),
554
+ 'max_abs_corr_before': float(np.max(abs_corr_unweighted)),
555
+ 'max_abs_corr_after': float(np.max(abs_corr_weighted)),
556
+ 'n_imbalanced_before': int(n_imbalanced_before),
557
+ 'n_imbalanced_after': int(n_imbalanced_after),
558
+ 'pct_imbalanced_before': float(n_imbalanced_before / n_covars * 100),
559
+ 'pct_imbalanced_after': float(n_imbalanced_after / n_covars * 100),
560
+ 'mean_improvement_pct': float(np.mean(improvement_pct[nonzero_mask])) if nonzero_mask.any() else 0.0
561
+ }
562
+
563
+ # Generate text report
564
+ report = f"""
565
+ Covariate Balance Diagnostic Report (Continuous Treatment)
566
+ {'=' * 60}
567
+
568
+ Sample Statistics:
569
+ Number of covariates: {n_covars}
570
+ Correlation threshold: {threshold}
571
+
572
+ Balance Before Weighting:
573
+ Mean |correlation|: {summary['mean_abs_corr_before']:.4f}
574
+ Max |correlation|: {summary['max_abs_corr_before']:.4f}
575
+ Imbalanced covariates: {summary['n_imbalanced_before']} ({summary['pct_imbalanced_before']:.1f}%)
576
+
577
+ Balance After CBGPS Weighting:
578
+ Mean |correlation|: {summary['mean_abs_corr_after']:.4f}
579
+ Max |correlation|: {summary['max_abs_corr_after']:.4f}
580
+ Imbalanced covariates: {summary['n_imbalanced_after']} ({summary['pct_imbalanced_after']:.1f}%)
581
+
582
+ Improvement:
583
+ Mean improvement: {summary['mean_improvement_pct']:.1f}%
584
+ Reduced imbalanced covariates: {summary['n_imbalanced_before'] - summary['n_imbalanced_after']}
585
+
586
+ Interpretation:
587
+ |correlation| ≈ 0: Excellent balance
588
+ |correlation| < 0.1: Good balance
589
+ |correlation| > 0.2: Poor balance (consider model adjustment)
590
+ {'=' * 60}
591
+ """
592
+
593
+ # Detailed covariate report
594
+ if covariate_names is not None and len(covariate_names) == n_covars:
595
+ report += "\nDetailed Covariate Balance:\n"
596
+ report += f"{'Covariate':<20} {'Before':>10} {'After':>10} {'Improve':>10} {'Status':>10}\n"
597
+ report += "-" * 65 + "\n"
598
+
599
+ for i in range(n_covars):
600
+ name = covariate_names[i][:18]
601
+ before = abs_corr_unweighted[i, 0]
602
+ after = abs_corr_weighted[i, 0]
603
+ improve = improvement_pct[i, 0]
604
+
605
+ if after < 0.05:
606
+ status = "Excellent"
607
+ elif after < 0.1:
608
+ status = "Good"
609
+ else:
610
+ status = "⚠ Poor"
611
+
612
+ report += f"{name:<20} {before:>10.4f} {after:>10.4f} {improve:>9.1f}% {status:>10}\n"
613
+
614
+ return {
615
+ # Standard balance output
616
+ 'balanced': bal,
617
+ 'unweighted': baseline,
618
+ # Enhanced diagnostics
619
+ 'abs_corr_weighted': abs_corr_weighted,
620
+ 'abs_corr_unweighted': abs_corr_unweighted,
621
+ 'improvement': improvement,
622
+ 'improvement_pct': improvement_pct,
623
+ 'n_imbalanced_before': int(n_imbalanced_before),
624
+ 'n_imbalanced_after': int(n_imbalanced_after),
625
+ 'summary': summary,
626
+ 'report': report
627
+ }
628
+
629
+
630
+ def balance_cbps_continuous(cbps_obj: Dict[str, Any]) -> Dict[str, np.ndarray]:
631
+ """
632
+ Compute weighted Pearson correlations for continuous treatments.
633
+
634
+ For continuous treatments, covariate balance is assessed by the correlation
635
+ between each covariate and the treatment variable. Effective weighting
636
+ should reduce these correlations toward zero, indicating that covariates
637
+ are no longer predictive of treatment assignment.
638
+
639
+ Parameters
640
+ ----------
641
+ cbps_obj : dict
642
+ Fitted continuous treatment CBPS result containing:
643
+
644
+ - **weights** : ndarray of shape (n,) - Stabilized inverse probability weights
645
+ - **x** : ndarray of shape (n, k) - Covariate matrix with intercept
646
+ - **y** : ndarray of shape (n,) - Continuous treatment variable
647
+
648
+ Returns
649
+ -------
650
+ dict
651
+ Correlation statistics with keys:
652
+
653
+ - **balanced** : ndarray of shape (n_covars, 1)
654
+ Weighted Pearson correlations between treatment and each covariate.
655
+ - **unweighted** : ndarray of shape (n_covars, 1)
656
+ Unweighted Pearson correlations (baseline).
657
+
658
+ Notes
659
+ -----
660
+ The weighted correlation is computed using the formula:
661
+
662
+ .. math::
663
+
664
+ r_w = \\frac{\\sum w_i (X_i - \\bar{X}_w)(T_i - \\bar{T}_w)}
665
+ {\\sqrt{\\sum w_i (X_i - \\bar{X}_w)^2}
666
+ \\sqrt{\\sum w_i (T_i - \\bar{T}_w)^2}}
667
+
668
+ where :math:`\\bar{X}_w` and :math:`\\bar{T}_w` are weighted means.
669
+
670
+ A correlation near zero after weighting indicates that the covariate
671
+ is balanced with respect to the treatment.
672
+
673
+ References
674
+ ----------
675
+ Fong, C., Hazlett, C., and Imai, K. (2018). Covariate balancing propensity
676
+ score for a continuous treatment. The Annals of Applied Statistics, 12(1),
677
+ 156-177.
678
+
679
+ Examples
680
+ --------
681
+ >>> import cbps
682
+ >>> import numpy as np
683
+ >>> import pandas as pd
684
+ >>> np.random.seed(42)
685
+ >>> n = 200
686
+ >>> df = pd.DataFrame({
687
+ ... 'dose': np.random.uniform(0, 100, n),
688
+ ... 'age': np.random.normal(45, 12, n),
689
+ ... 'income': np.random.lognormal(10, 0.5, n)
690
+ ... })
691
+ >>> fit = cbps.CBPS('dose ~ age + income', data=df, att=0) # doctest: +SKIP
692
+ >>> cbps_dict = {'weights': fit.weights, 'x': fit.x, 'y': fit.y} # doctest: +SKIP
693
+ >>> from cbps.diagnostics.balance import balance_cbps_continuous
694
+ >>> result = balance_cbps_continuous(cbps_dict) # doctest: +SKIP
695
+ >>> print(result['balanced'].shape) # doctest: +SKIP
696
+ (2, 1)
697
+ """
698
+ # Step 1: Extract input data
699
+ treat = cbps_obj['y'] # Continuous treatment vector
700
+ X = cbps_obj['x'] # Covariate matrix (with or without intercept)
701
+ w = cbps_obj['weights'] # Optimal weights
702
+ n = len(w) # Sample size
703
+
704
+ # Step 2: Detect npCBPS vs CBPS
705
+ # npCBPS: X matrix does NOT have intercept column (all columns are covariates)
706
+ # CBPS: X matrix HAS intercept column (first column is all 1s)
707
+ is_npcbps = 'log_el' in cbps_obj
708
+
709
+ if is_npcbps:
710
+ # npCBPS path: X has no intercept, start from column 0
711
+ j_start = 0
712
+ n_covars = X.shape[1]
713
+ else:
714
+ # CBPS path: X has intercept, skip first column
715
+ j_start = 1
716
+ n_covars = X.shape[1] - 1
717
+
718
+ # Initialize result vectors
719
+ bal = np.zeros((n_covars, 1), dtype=np.float64)
720
+ baseline = np.zeros((n_covars, 1), dtype=np.float64)
721
+
722
+ # Step 3: Compute weighted Pearson correlations
723
+ # R code reference (balance.CBPSContinuous):
724
+ # bal[j,1]<-(mean(w*X[,j]*treat) - mean(w*X[,j])*mean(w*treat)*n/sum(w))/
725
+ # (sqrt(mean(w*X[,j]^2) - mean(w*X[,j])^2*n/sum(w))*
726
+ # sqrt(mean(w*treat^2) - mean(w*treat)^2*n/sum(w)))
727
+ # baseline[j,1]<-cor(treat, X[,j], method = "pearson")
728
+
729
+ for j_col in range(j_start, X.shape[1]):
730
+ idx = j_col - j_start # Row index: 0, 1, 2, ..., n_covars-1
731
+
732
+ # Weighted Pearson correlation 6-term formula
733
+ # 1. Compute 3 weighted means
734
+ mean_wXT = np.mean(w * X[:, j_col] * treat) # mean(w*X*T)
735
+ mean_wX = np.mean(w * X[:, j_col]) # mean(w*X)
736
+ mean_wT = np.mean(w * treat) # mean(w*T)
737
+ sum_w = np.sum(w) # sum(w)
738
+
739
+ # 2. Numerator: weighted covariance (with correction n/sum(w))
740
+ numerator = mean_wXT - mean_wX * mean_wT * n / sum_w
741
+
742
+ # 3. Denominator term 1: weighted variance of X (with correction)
743
+ var_wX = np.mean(w * X[:, j_col]**2) - mean_wX**2 * n / sum_w
744
+
745
+ # 4. Denominator term 2: weighted variance of T (with correction)
746
+ var_wT = np.mean(w * treat**2) - mean_wT**2 * n / sum_w
747
+
748
+ # 5. Denominator: product of standard deviations
749
+ denominator = np.sqrt(var_wX) * np.sqrt(var_wT)
750
+
751
+ # 6. Weighted correlation coefficient
752
+ bal[idx, 0] = numerator / denominator
753
+
754
+ # Baseline: unweighted standard Pearson correlation
755
+ baseline[idx, 0] = np.corrcoef(treat, X[:, j_col])[0, 1]
756
+
757
+ # Step 4: Return results
758
+ # Note: key is "unweighted" rather than "original" (differs from binary treatment)
759
+ return {"balanced": bal, "unweighted": baseline}
760
+