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
|
@@ -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
|
+
|