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,162 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Covariate Balance for Marginal Structural Models
|
|
3
|
+
=================================================
|
|
4
|
+
|
|
5
|
+
Balance diagnostics for marginal structural models estimated via CBMSM.
|
|
6
|
+
These diagnostics assess whether CBMSM weights achieve covariate balance
|
|
7
|
+
across treatment history groups in longitudinal settings with time-varying
|
|
8
|
+
confounding.
|
|
9
|
+
|
|
10
|
+
The balance_cbmsm function computes weighted covariate means within each
|
|
11
|
+
treatment trajectory pattern (e.g., "0+1+1" for control-treated-treated),
|
|
12
|
+
comparing CBMSM weights against standard GLM-based inverse probability weights.
|
|
13
|
+
|
|
14
|
+
References
|
|
15
|
+
----------
|
|
16
|
+
Imai, K. and Ratkovic, M. (2015). Robust estimation of inverse probability
|
|
17
|
+
weights for marginal structural models. Journal of the American Statistical
|
|
18
|
+
Association, 110(511), 1013-1023.
|
|
19
|
+
"""
|
|
20
|
+
import numpy as np
|
|
21
|
+
from typing import Dict, Any
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def balance_cbmsm(cbmsm_obj: Dict[str, Any]) -> Dict[str, np.ndarray]:
|
|
25
|
+
"""
|
|
26
|
+
Compute covariate balance across treatment history groups.
|
|
27
|
+
|
|
28
|
+
Calculates weighted covariate means within each treatment trajectory
|
|
29
|
+
pattern, enabling assessment of balance in marginal structural models.
|
|
30
|
+
Treatment histories are represented as concatenated strings (e.g.,
|
|
31
|
+
"0+1+1" for a unit that was untreated in period 1, then treated in
|
|
32
|
+
periods 2 and 3).
|
|
33
|
+
|
|
34
|
+
Parameters
|
|
35
|
+
----------
|
|
36
|
+
cbmsm_obj : dict
|
|
37
|
+
Fitted CBMSM result containing:
|
|
38
|
+
|
|
39
|
+
- **y** : ndarray of shape (n_obs,) - Binary treatment at each observation
|
|
40
|
+
- **x** : ndarray of shape (n_obs, k) - Covariates with intercept
|
|
41
|
+
- **weights** : ndarray - CBMSM weights (unit-level)
|
|
42
|
+
- **id** : ndarray - Unit identifiers
|
|
43
|
+
- **time** : ndarray - Time period indicators
|
|
44
|
+
- **glm_weights** : ndarray - Baseline GLM inverse probability weights
|
|
45
|
+
|
|
46
|
+
Returns
|
|
47
|
+
-------
|
|
48
|
+
dict
|
|
49
|
+
Balance statistics with keys:
|
|
50
|
+
|
|
51
|
+
- **Balanced** : ndarray of shape (n_covars, 2*n_hist)
|
|
52
|
+
CBMSM-weighted means (first n_hist columns) and standardized
|
|
53
|
+
means (remaining columns) for each treatment history.
|
|
54
|
+
- **Unweighted** : ndarray of shape (n_covars, 2*n_hist)
|
|
55
|
+
GLM-weighted baseline statistics for comparison.
|
|
56
|
+
- **StatBal** : float
|
|
57
|
+
Summary statistic measuring overall imbalance.
|
|
58
|
+
|
|
59
|
+
Notes
|
|
60
|
+
-----
|
|
61
|
+
Balance is assessed using first-period covariate values, following
|
|
62
|
+
standard practice for evaluating baseline balance in MSM settings.
|
|
63
|
+
|
|
64
|
+
See Also
|
|
65
|
+
--------
|
|
66
|
+
plot_cbmsm : Visualization of CBMSM balance diagnostics.
|
|
67
|
+
"""
|
|
68
|
+
# Extract panel data components
|
|
69
|
+
y = cbmsm_obj['y']
|
|
70
|
+
x = cbmsm_obj['x']
|
|
71
|
+
w = cbmsm_obj.get('weights', cbmsm_obj.get('w'))
|
|
72
|
+
glm_w = cbmsm_obj.get('glm_weights', cbmsm_obj.get('glm.w', cbmsm_obj.get('glm_w')))
|
|
73
|
+
ids = cbmsm_obj['id']
|
|
74
|
+
times = cbmsm_obj['time']
|
|
75
|
+
|
|
76
|
+
unique_ids = np.sort(np.unique(ids))
|
|
77
|
+
unique_times = np.sort(np.unique(times))
|
|
78
|
+
n_units = len(unique_ids)
|
|
79
|
+
n_periods = len(unique_times)
|
|
80
|
+
|
|
81
|
+
# Reconstruct treatment history matrix (n_units x n_periods)
|
|
82
|
+
treat_hist = np.full((n_units, n_periods), np.nan)
|
|
83
|
+
for i, unit_id in enumerate(unique_ids):
|
|
84
|
+
for j, period in enumerate(unique_times):
|
|
85
|
+
mask = (ids == unit_id) & (times == period)
|
|
86
|
+
if mask.any():
|
|
87
|
+
treat_hist[i, j] = y[mask][0]
|
|
88
|
+
|
|
89
|
+
# Create treatment history factor (e.g., "0+1+1" for trajectory)
|
|
90
|
+
treat_hist_fac = []
|
|
91
|
+
for i in range(n_units):
|
|
92
|
+
hist_str = '+'.join(
|
|
93
|
+
[str(int(t)) if not np.isnan(t) else 'NA' for t in treat_hist[i, :]]
|
|
94
|
+
)
|
|
95
|
+
treat_hist_fac.append(hist_str)
|
|
96
|
+
treat_hist_fac = np.array(treat_hist_fac)
|
|
97
|
+
|
|
98
|
+
unique_hist = np.unique(treat_hist_fac)
|
|
99
|
+
n_hist = len(unique_hist)
|
|
100
|
+
|
|
101
|
+
# Initialize balance matrices
|
|
102
|
+
n_covars = x.shape[1] - 1 # Exclude intercept column
|
|
103
|
+
bal = np.zeros((n_covars, 2 * n_hist))
|
|
104
|
+
baseline = np.zeros((n_covars, 2 * n_hist))
|
|
105
|
+
|
|
106
|
+
# Use first-period covariates for balance assessment
|
|
107
|
+
first_period = unique_times[0]
|
|
108
|
+
first_period_mask = (times == first_period)
|
|
109
|
+
x_first = x[first_period_mask, :]
|
|
110
|
+
ids_first = ids[first_period_mask]
|
|
111
|
+
|
|
112
|
+
# Map unit-level weights to first-period observations
|
|
113
|
+
if len(w) == n_units:
|
|
114
|
+
id_to_w = dict(zip(unique_ids, w))
|
|
115
|
+
id_to_glm_w = dict(zip(unique_ids, glm_w))
|
|
116
|
+
w_first = np.array([id_to_w[uid] for uid in ids_first])
|
|
117
|
+
glm_w_first = np.array([id_to_glm_w[uid] for uid in ids_first])
|
|
118
|
+
else:
|
|
119
|
+
w_first = w[first_period_mask]
|
|
120
|
+
glm_w_first = glm_w[first_period_mask]
|
|
121
|
+
|
|
122
|
+
# Compute balance statistics for each treatment history group
|
|
123
|
+
for i, hist in enumerate(unique_hist):
|
|
124
|
+
hist_mask_units = (treat_hist_fac == hist)
|
|
125
|
+
hist_mask_obs = np.array(
|
|
126
|
+
[uid in unique_ids[hist_mask_units] for uid in ids_first]
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# Iterate over covariates (skip intercept at index 0)
|
|
130
|
+
for j in range(1, x.shape[1]):
|
|
131
|
+
idx = j - 1
|
|
132
|
+
|
|
133
|
+
if hist_mask_obs.sum() > 0:
|
|
134
|
+
# CBMSM-weighted mean
|
|
135
|
+
bal[idx, i] = (
|
|
136
|
+
np.sum(hist_mask_obs * x_first[:, j] * w_first) /
|
|
137
|
+
np.sum(w_first * hist_mask_obs)
|
|
138
|
+
)
|
|
139
|
+
# Standardized mean (divided by weighted std)
|
|
140
|
+
bal[idx, i + n_hist] = bal[idx, i] / np.std(w_first * x_first[:, j])
|
|
141
|
+
|
|
142
|
+
# GLM baseline weighted mean
|
|
143
|
+
baseline[idx, i] = (
|
|
144
|
+
np.sum(hist_mask_obs * x_first[:, j] * glm_w_first) /
|
|
145
|
+
np.sum(glm_w_first * hist_mask_obs)
|
|
146
|
+
)
|
|
147
|
+
baseline[idx, i + n_hist] = (
|
|
148
|
+
baseline[idx, i] / np.std(glm_w_first * x_first[:, j])
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Handle numerical edge cases
|
|
152
|
+
bal[np.isnan(bal)] = 0
|
|
153
|
+
baseline[np.isnan(baseline)] = 0
|
|
154
|
+
|
|
155
|
+
# Summary balance statistic
|
|
156
|
+
statbal = np.sum((bal - bal[:, 0:1]) * (bal != 0) ** 2)
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
'Balanced': bal,
|
|
160
|
+
'Unweighted': baseline,
|
|
161
|
+
'StatBal': statbal
|
|
162
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Continuous Treatment CBPS Diagnostic Tools
|
|
3
|
+
==========================================
|
|
4
|
+
|
|
5
|
+
Diagnostic utilities for assessing the quality of CBGPS (Covariate Balancing
|
|
6
|
+
Generalized Propensity Score) estimation results.
|
|
7
|
+
|
|
8
|
+
Author: CBPS Development Team
|
|
9
|
+
Date: 2026-01-28
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import warnings
|
|
14
|
+
from typing import Dict, Tuple, Optional
|
|
15
|
+
import statsmodels.api as sm
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def compute_f_statistic(treat: np.ndarray, X: np.ndarray, weights: np.ndarray) -> Tuple[float, float]:
|
|
19
|
+
"""
|
|
20
|
+
Compute F-statistic from weighted regression of treatment on covariates.
|
|
21
|
+
|
|
22
|
+
This is the primary balance metric used in Fong et al. (2018).
|
|
23
|
+
Lower F-statistic indicates better covariate balance.
|
|
24
|
+
|
|
25
|
+
Parameters
|
|
26
|
+
----------
|
|
27
|
+
treat : np.ndarray
|
|
28
|
+
Treatment variable (n,)
|
|
29
|
+
X : np.ndarray
|
|
30
|
+
Covariate matrix (n, K), should NOT include intercept
|
|
31
|
+
weights : np.ndarray
|
|
32
|
+
CBGPS weights (n,)
|
|
33
|
+
|
|
34
|
+
Returns
|
|
35
|
+
-------
|
|
36
|
+
f_stat : float
|
|
37
|
+
F-statistic from weighted regression T ~ X
|
|
38
|
+
p_value : float
|
|
39
|
+
P-value for the F-test
|
|
40
|
+
"""
|
|
41
|
+
n, K = X.shape
|
|
42
|
+
X_with_const = sm.add_constant(X)
|
|
43
|
+
|
|
44
|
+
# Normalize weights
|
|
45
|
+
weights_norm = weights * n / weights.sum()
|
|
46
|
+
|
|
47
|
+
# Weighted least squares
|
|
48
|
+
model = sm.WLS(treat, X_with_const, weights=weights_norm)
|
|
49
|
+
result = model.fit()
|
|
50
|
+
|
|
51
|
+
return result.fvalue, result.f_pvalue
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def compute_weighted_correlations(treat: np.ndarray, X: np.ndarray,
|
|
55
|
+
weights: np.ndarray) -> np.ndarray:
|
|
56
|
+
"""
|
|
57
|
+
Compute weighted Pearson correlations between treatment and each covariate.
|
|
58
|
+
|
|
59
|
+
Parameters
|
|
60
|
+
----------
|
|
61
|
+
treat : np.ndarray
|
|
62
|
+
Treatment variable (n,)
|
|
63
|
+
X : np.ndarray
|
|
64
|
+
Covariate matrix (n, K)
|
|
65
|
+
weights : np.ndarray
|
|
66
|
+
CBGPS weights (n,)
|
|
67
|
+
|
|
68
|
+
Returns
|
|
69
|
+
-------
|
|
70
|
+
np.ndarray
|
|
71
|
+
Weighted correlations for each covariate (K,)
|
|
72
|
+
"""
|
|
73
|
+
n, K = X.shape
|
|
74
|
+
w_sum = np.sum(weights)
|
|
75
|
+
w_mean_T = np.sum(weights * treat) / w_sum
|
|
76
|
+
|
|
77
|
+
correlations = []
|
|
78
|
+
for j in range(K):
|
|
79
|
+
X_j = X[:, j]
|
|
80
|
+
w_mean_X = np.sum(weights * X_j) / w_sum
|
|
81
|
+
|
|
82
|
+
w_cov = np.sum(weights * (treat - w_mean_T) * (X_j - w_mean_X)) / w_sum
|
|
83
|
+
w_var_T = np.sum(weights * (treat - w_mean_T)**2) / w_sum
|
|
84
|
+
w_var_X = np.sum(weights * (X_j - w_mean_X)**2) / w_sum
|
|
85
|
+
|
|
86
|
+
if w_var_T > 1e-10 and w_var_X > 1e-10:
|
|
87
|
+
corr = w_cov / np.sqrt(w_var_T * w_var_X)
|
|
88
|
+
else:
|
|
89
|
+
corr = np.nan
|
|
90
|
+
|
|
91
|
+
correlations.append(corr)
|
|
92
|
+
|
|
93
|
+
return np.array(correlations)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def diagnose_cbgps_quality(cbgps_result: Dict, treat: np.ndarray,
|
|
97
|
+
X: np.ndarray) -> Dict[str, any]:
|
|
98
|
+
"""
|
|
99
|
+
Diagnose the quality of CBGPS estimation results.
|
|
100
|
+
|
|
101
|
+
This function assesses covariate balance quality and provides
|
|
102
|
+
recommendations for users.
|
|
103
|
+
|
|
104
|
+
Parameters
|
|
105
|
+
----------
|
|
106
|
+
cbgps_result : dict
|
|
107
|
+
Result dictionary from cbps_continuous_fit()
|
|
108
|
+
treat : np.ndarray
|
|
109
|
+
Treatment variable (n,)
|
|
110
|
+
X : np.ndarray
|
|
111
|
+
Covariate matrix (n, K), without intercept
|
|
112
|
+
|
|
113
|
+
Returns
|
|
114
|
+
-------
|
|
115
|
+
dict
|
|
116
|
+
Diagnostic information including:
|
|
117
|
+
- quality_level: 'excellent', 'acceptable', 'moderate', or 'poor'
|
|
118
|
+
- f_statistic: Overall balance F-statistic
|
|
119
|
+
- max_abs_correlation: Maximum absolute weighted correlation
|
|
120
|
+
- mean_abs_correlation: Mean absolute weighted correlation
|
|
121
|
+
- recommendation: User-facing recommendation string
|
|
122
|
+
- j_statistic: GMM objective value (if available)
|
|
123
|
+
|
|
124
|
+
Examples
|
|
125
|
+
--------
|
|
126
|
+
>>> from cbps.core import cbps_continuous_fit
|
|
127
|
+
>>> result = cbps_continuous_fit(treat, X, method='exact')
|
|
128
|
+
>>> diag = diagnose_cbgps_quality(result, treat, X)
|
|
129
|
+
>>> print(f"Balance quality: {diag['quality_level']}")
|
|
130
|
+
>>> print(f"Recommendation: {diag['recommendation']}")
|
|
131
|
+
"""
|
|
132
|
+
weights = cbgps_result.get('weights')
|
|
133
|
+
if weights is None:
|
|
134
|
+
raise ValueError("cbgps_result must contain 'weights' key")
|
|
135
|
+
|
|
136
|
+
# Compute balance metrics
|
|
137
|
+
f_stat, f_pval = compute_f_statistic(treat, X, weights)
|
|
138
|
+
correlations = compute_weighted_correlations(treat, X, weights)
|
|
139
|
+
max_abs_corr = np.max(np.abs(correlations))
|
|
140
|
+
mean_abs_corr = np.mean(np.abs(correlations))
|
|
141
|
+
|
|
142
|
+
# Determine quality level
|
|
143
|
+
if f_stat < 0.1 and max_abs_corr < 0.05:
|
|
144
|
+
quality_level = 'excellent'
|
|
145
|
+
recommendation = (
|
|
146
|
+
"Excellent covariate balance achieved. "
|
|
147
|
+
"Proceed with confidence using these weights."
|
|
148
|
+
)
|
|
149
|
+
elif f_stat < 0.5 and max_abs_corr < 0.15:
|
|
150
|
+
quality_level = 'acceptable'
|
|
151
|
+
recommendation = (
|
|
152
|
+
"Acceptable covariate balance. "
|
|
153
|
+
"Check balance_table() for any concerning imbalances."
|
|
154
|
+
)
|
|
155
|
+
elif f_stat < 2.0:
|
|
156
|
+
quality_level = 'moderate'
|
|
157
|
+
recommendation = (
|
|
158
|
+
"Moderate balance quality. Consider: "
|
|
159
|
+
"(1) using npCBPS for more robust results, "
|
|
160
|
+
"(2) reducing the number of covariates, or "
|
|
161
|
+
"(3) increasing sample size if possible."
|
|
162
|
+
)
|
|
163
|
+
else:
|
|
164
|
+
quality_level = 'poor'
|
|
165
|
+
recommendation = (
|
|
166
|
+
"Poor balance quality detected. Strongly recommend: "
|
|
167
|
+
"(1) switching to npCBPS (method='nonparametric'), "
|
|
168
|
+
"(2) careful variable selection to reduce noise covariates, or "
|
|
169
|
+
"(3) verifying data quality and model specification."
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Collect diagnostic info
|
|
173
|
+
diagnostic = {
|
|
174
|
+
'quality_level': quality_level,
|
|
175
|
+
'f_statistic': float(f_stat),
|
|
176
|
+
'f_pvalue': float(f_pval),
|
|
177
|
+
'max_abs_correlation': float(max_abs_corr),
|
|
178
|
+
'mean_abs_correlation': float(mean_abs_corr),
|
|
179
|
+
'correlations': correlations,
|
|
180
|
+
'recommendation': recommendation,
|
|
181
|
+
'converged': cbgps_result.get('converged', None),
|
|
182
|
+
'j_statistic': cbgps_result.get('J', None),
|
|
183
|
+
'weight_range': (float(np.min(weights)), float(np.max(weights))),
|
|
184
|
+
'weight_sum': float(np.sum(weights)),
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return diagnostic
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def print_balance_diagnosis(diagnostic: Dict) -> None:
|
|
191
|
+
"""
|
|
192
|
+
Print a user-friendly diagnostic report.
|
|
193
|
+
|
|
194
|
+
Parameters
|
|
195
|
+
----------
|
|
196
|
+
diagnostic : dict
|
|
197
|
+
Output from diagnose_cbgps_quality()
|
|
198
|
+
"""
|
|
199
|
+
print("=" * 70)
|
|
200
|
+
print("CBGPS Balance Quality Diagnostic Report")
|
|
201
|
+
print("=" * 70)
|
|
202
|
+
print()
|
|
203
|
+
|
|
204
|
+
# Quality badge
|
|
205
|
+
quality = diagnostic['quality_level']
|
|
206
|
+
badges = {
|
|
207
|
+
'excellent': '✓✓✓ EXCELLENT',
|
|
208
|
+
'acceptable': '✓✓ ACCEPTABLE',
|
|
209
|
+
'moderate': '⚠ MODERATE',
|
|
210
|
+
'poor': '✗ POOR'
|
|
211
|
+
}
|
|
212
|
+
print(f"Overall Quality: {badges.get(quality, quality.upper())}")
|
|
213
|
+
print()
|
|
214
|
+
|
|
215
|
+
# Metrics
|
|
216
|
+
print("Balance Metrics:")
|
|
217
|
+
print(f" F-statistic: {diagnostic['f_statistic']:.4f} (p={diagnostic['f_pvalue']:.4f})")
|
|
218
|
+
print(f" Max |correlation|: {diagnostic['max_abs_correlation']:.4f}")
|
|
219
|
+
print(f" Mean |correlation|: {diagnostic['mean_abs_correlation']:.4f}")
|
|
220
|
+
|
|
221
|
+
if diagnostic['j_statistic'] is not None:
|
|
222
|
+
print(f" J-statistic: {diagnostic['j_statistic']:.6f}")
|
|
223
|
+
|
|
224
|
+
if diagnostic['converged'] is not None:
|
|
225
|
+
conv_str = "Yes" if diagnostic['converged'] else "No"
|
|
226
|
+
print(f" Converged: {conv_str}")
|
|
227
|
+
|
|
228
|
+
print()
|
|
229
|
+
|
|
230
|
+
# Weight diagnostics
|
|
231
|
+
w_min, w_max = diagnostic['weight_range']
|
|
232
|
+
print(f"Weight Diagnostics:")
|
|
233
|
+
print(f" Sum of weights: {diagnostic['weight_sum']:.4f} (should be ~1.0)")
|
|
234
|
+
print(f" Weight range: [{w_min:.4f}, {w_max:.4f}]")
|
|
235
|
+
|
|
236
|
+
if w_max / w_min > 100:
|
|
237
|
+
print(" ⚠ Warning: Extreme weight ratio detected")
|
|
238
|
+
|
|
239
|
+
print()
|
|
240
|
+
|
|
241
|
+
# Recommendation
|
|
242
|
+
print("Recommendation:")
|
|
243
|
+
print(f" {diagnostic['recommendation']}")
|
|
244
|
+
print()
|
|
245
|
+
print("=" * 70)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# Example usage
|
|
249
|
+
if __name__ == "__main__":
|
|
250
|
+
# This is a demonstration - normally called after cbps_continuous_fit()
|
|
251
|
+
print("Continuous CBPS Diagnostic Tools")
|
|
252
|
+
print()
|
|
253
|
+
print("Usage:")
|
|
254
|
+
print(" from cbps.diagnostics.continuous_diagnostics import diagnose_cbgps_quality")
|
|
255
|
+
print(" from cbps.core import cbps_continuous_fit")
|
|
256
|
+
print()
|
|
257
|
+
print(" result = cbps_continuous_fit(treat, X, method='exact')")
|
|
258
|
+
print(" diag = diagnose_cbgps_quality(result, treat, X)")
|
|
259
|
+
print(" print_balance_diagnosis(diag)")
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Normality diagnostics for continuous treatment CBPS.
|
|
2
|
+
|
|
3
|
+
Fong, Hazlett, and Imai (2018) assume the treatment variable is
|
|
4
|
+
conditionally normal: T | X ~ N(X'beta, sigma^2). This module provides
|
|
5
|
+
diagnostic tools to check this assumption.
|
|
6
|
+
|
|
7
|
+
References
|
|
8
|
+
----------
|
|
9
|
+
Fong, C., Hazlett, C., and Imai, K. (2018). Covariate balancing propensity
|
|
10
|
+
score for a continuous treatment. The Annals of Applied Statistics, 12(1):
|
|
11
|
+
156-177.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
from scipy import stats
|
|
16
|
+
from typing import Dict, Any
|
|
17
|
+
import warnings
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_treatment_normality(
|
|
21
|
+
treat: np.ndarray,
|
|
22
|
+
X: np.ndarray,
|
|
23
|
+
alpha: float = 0.05,
|
|
24
|
+
) -> Dict[str, Any]:
|
|
25
|
+
"""Test normality of treatment residuals (T - X'beta_hat).
|
|
26
|
+
|
|
27
|
+
Under the continuous CBPS model, T | X ~ N(X'beta, sigma^2). This
|
|
28
|
+
function fits OLS to obtain residuals e = T - X @ beta_hat, then
|
|
29
|
+
tests whether these residuals follow a normal distribution.
|
|
30
|
+
|
|
31
|
+
For n <= 5000, the Shapiro-Wilk test is used (highest power for
|
|
32
|
+
detecting departures from normality in moderate samples). For n > 5000,
|
|
33
|
+
the D'Agostino-Pearson omnibus test is used (Shapiro-Wilk becomes
|
|
34
|
+
computationally expensive and overly sensitive for very large n).
|
|
35
|
+
|
|
36
|
+
Parameters
|
|
37
|
+
----------
|
|
38
|
+
treat : np.ndarray, shape (n,)
|
|
39
|
+
Continuous treatment variable.
|
|
40
|
+
X : np.ndarray, shape (n, k)
|
|
41
|
+
Covariate matrix (should include intercept if desired).
|
|
42
|
+
alpha : float, default=0.05
|
|
43
|
+
Significance level for the normality test.
|
|
44
|
+
|
|
45
|
+
Returns
|
|
46
|
+
-------
|
|
47
|
+
dict with:
|
|
48
|
+
- statistic : float
|
|
49
|
+
Test statistic value.
|
|
50
|
+
- p_value : float
|
|
51
|
+
p-value of the normality test.
|
|
52
|
+
- test_used : str
|
|
53
|
+
Name of the test applied ('shapiro-wilk' or 'dagostino-pearson').
|
|
54
|
+
- reject_normality : bool
|
|
55
|
+
True if normality is rejected at the given alpha level.
|
|
56
|
+
- skewness : float
|
|
57
|
+
Sample skewness of residuals.
|
|
58
|
+
- kurtosis : float
|
|
59
|
+
Sample excess kurtosis of residuals.
|
|
60
|
+
- warning_message : str or None
|
|
61
|
+
If normality is rejected, suggests using npCBPS.
|
|
62
|
+
|
|
63
|
+
Notes
|
|
64
|
+
-----
|
|
65
|
+
If normality is rejected, the parametric CBPS estimator may be
|
|
66
|
+
misspecified. Consider using the nonparametric variant (npCBPS)
|
|
67
|
+
which does not require distributional assumptions on T | X.
|
|
68
|
+
"""
|
|
69
|
+
treat = np.asarray(treat, dtype=float).ravel()
|
|
70
|
+
X = np.asarray(X, dtype=float)
|
|
71
|
+
n = len(treat)
|
|
72
|
+
|
|
73
|
+
if X.ndim == 1:
|
|
74
|
+
X = X.reshape(-1, 1)
|
|
75
|
+
|
|
76
|
+
# Input validation: NaN/Inf checks
|
|
77
|
+
if np.any(~np.isfinite(treat)):
|
|
78
|
+
return {
|
|
79
|
+
'statistic': np.nan, 'p_value': np.nan, 'test_used': 'none',
|
|
80
|
+
'reject_normality': False, 'skewness': np.nan, 'kurtosis': np.nan,
|
|
81
|
+
'n_observations': n,
|
|
82
|
+
'warning_message': "Treatment variable contains NaN or Inf values. "
|
|
83
|
+
"Cannot perform normality test. Clean data first."
|
|
84
|
+
}
|
|
85
|
+
if np.any(~np.isfinite(X)):
|
|
86
|
+
return {
|
|
87
|
+
'statistic': np.nan, 'p_value': np.nan, 'test_used': 'none',
|
|
88
|
+
'reject_normality': False, 'skewness': np.nan, 'kurtosis': np.nan,
|
|
89
|
+
'n_observations': n,
|
|
90
|
+
'warning_message': "Covariate matrix contains NaN or Inf values. "
|
|
91
|
+
"Cannot perform normality test. Clean data first."
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
# Minimum sample size check
|
|
95
|
+
if n < 3:
|
|
96
|
+
return {
|
|
97
|
+
'statistic': np.nan, 'p_value': np.nan, 'test_used': 'none',
|
|
98
|
+
'reject_normality': False, 'skewness': np.nan, 'kurtosis': np.nan,
|
|
99
|
+
'n_observations': n,
|
|
100
|
+
'warning_message': f"Sample size n={n} too small for normality testing "
|
|
101
|
+
f"(minimum 3 required for Shapiro-Wilk)."
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if X.shape[0] != n:
|
|
105
|
+
raise ValueError(
|
|
106
|
+
f"Dimension mismatch: treat has {n} observations but X has "
|
|
107
|
+
f"{X.shape[0]} rows."
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Fit OLS: beta_hat = (X'X)^{-1} X'T
|
|
111
|
+
# Use lstsq for numerical stability
|
|
112
|
+
beta_hat, _, _, _ = np.linalg.lstsq(X, treat, rcond=None)
|
|
113
|
+
residuals = treat - X @ beta_hat
|
|
114
|
+
|
|
115
|
+
# Near-zero residual variance guard
|
|
116
|
+
resid_std = np.std(residuals, ddof=1)
|
|
117
|
+
if resid_std < 1e-10:
|
|
118
|
+
return {
|
|
119
|
+
'statistic': np.nan, 'p_value': np.nan, 'test_used': 'none',
|
|
120
|
+
'reject_normality': False, 'skewness': 0.0, 'kurtosis': 0.0,
|
|
121
|
+
'n_observations': n,
|
|
122
|
+
'warning_message': "Treatment is a near-perfect linear function of covariates "
|
|
123
|
+
f"(residual std = {resid_std:.2e}). Normality test not applicable."
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
# Compute descriptive statistics
|
|
127
|
+
skewness = float(stats.skew(residuals))
|
|
128
|
+
kurtosis = float(stats.kurtosis(residuals)) # excess kurtosis
|
|
129
|
+
|
|
130
|
+
# Choose test based on sample size
|
|
131
|
+
if n <= 5000:
|
|
132
|
+
test_used = "shapiro-wilk"
|
|
133
|
+
stat, p_value = stats.shapiro(residuals)
|
|
134
|
+
else:
|
|
135
|
+
test_used = "dagostino-pearson"
|
|
136
|
+
stat, p_value = stats.normaltest(residuals)
|
|
137
|
+
|
|
138
|
+
stat = float(stat)
|
|
139
|
+
p_value = float(p_value)
|
|
140
|
+
|
|
141
|
+
# NaN result guard
|
|
142
|
+
if not np.isfinite(stat) or not np.isfinite(p_value):
|
|
143
|
+
return {
|
|
144
|
+
'statistic': stat, 'p_value': p_value, 'test_used': test_used,
|
|
145
|
+
'reject_normality': False, 'skewness': np.nan, 'kurtosis': np.nan,
|
|
146
|
+
'n_observations': n,
|
|
147
|
+
'warning_message': f"Normality test ({test_used}) returned non-finite result. "
|
|
148
|
+
f"This may indicate degenerate data or numerical issues."
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
reject = p_value < alpha
|
|
152
|
+
|
|
153
|
+
# Construct warning message
|
|
154
|
+
warning_message = None
|
|
155
|
+
if reject:
|
|
156
|
+
warning_message = (
|
|
157
|
+
f"Normality of treatment residuals rejected ({test_used} test, "
|
|
158
|
+
f"p={p_value:.4g}, alpha={alpha}). The conditional normality "
|
|
159
|
+
f"assumption T|X ~ N(X'beta, sigma^2) may not hold. Consider "
|
|
160
|
+
f"using npCBPS (nonparametric CBPS) which does not require "
|
|
161
|
+
f"distributional assumptions."
|
|
162
|
+
)
|
|
163
|
+
warnings.warn(warning_message, UserWarning, stacklevel=2)
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
"statistic": stat,
|
|
167
|
+
"p_value": p_value,
|
|
168
|
+
"test_used": test_used,
|
|
169
|
+
"reject_normality": reject,
|
|
170
|
+
"skewness": skewness,
|
|
171
|
+
"kurtosis": kurtosis,
|
|
172
|
+
"warning_message": warning_message,
|
|
173
|
+
}
|