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,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
+ }