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,507 @@
1
+ """
2
+ Optimal Covariate Balancing Propensity Score (oCBPS)
3
+ ====================================================
4
+
5
+ This module implements optimal CBPS (oCBPS) that extends the standard
6
+ CBPS by incorporating dual balancing conditions for improved efficiency
7
+ and robustness through the framework of Fan et al. (2022).
8
+
9
+ The implementation achieves double robustness and semiparametric efficiency
10
+ by separating the covariate balancing conditions for baseline outcome models
11
+ and treatment effect heterogeneity models.
12
+
13
+ Key Innovations
14
+ ---------------
15
+ 1. **Dual Balancing Conditions** (Fan 2022 Eq. 3.2-3.3):
16
+ - g1_baseline: Balance covariates h1 related to E(Y(0)|X)
17
+ - g2_diff: Balance covariates h2 related to E(Y(1)-Y(0)|X)
18
+
19
+ 2. **Double Robustness** (Theorem 3.1):
20
+ Consistent if either the propensity score model or outcome model is correct.
21
+
22
+ 3. **Semiparametric Efficiency** (Corollary 3.2):
23
+ Achieves Hahn 1998 efficiency bound when both models are correct and m=q.
24
+
25
+ Implementation Notes
26
+ --------------------
27
+ - Only supports att=0 (ATE estimation)
28
+ - No sample_weights parameter (oCBPS does not support sampling weights)
29
+ - Dual initialization optimization for robust convergence
30
+
31
+ References
32
+ ----------
33
+ .. [1] Fan, Jianqing, Kosuke Imai, Inbeom Lee, Han Liu, Yang Ning,
34
+ and Xiaolin Yang. 2022.
35
+ "Optimal Covariate Balancing Conditions in Propensity Score
36
+ Estimation."
37
+ Journal of Business & Economic Statistics, 41(1), 97-110.
38
+ https://doi.org/10.1080/07350015.2021.2002159
39
+ https://imai.fas.harvard.edu/research/CBPStheory.html
40
+
41
+ .. [2] Imai, Kosuke and Marc Ratkovic. 2014.
42
+ "Covariate Balancing Propensity Score."
43
+ Journal of the Royal Statistical Society, Series B.
44
+ DOI:10.1111/rssb.12027
45
+
46
+ Examples
47
+ --------
48
+ >>> from cbps import CBPS
49
+ >>> from cbps.datasets import load_lalonde
50
+ >>>
51
+ >>> # Load LaLonde data
52
+ >>> lalonde = load_lalonde()
53
+ >>>
54
+ >>> # oCBPS estimation with dual formula specification
55
+ >>> # Note: require m1 + m2 + 1 >= k where k is number of parameters
56
+ >>> fit = CBPS(
57
+ ... formula='treat ~ age + educ + re75 + re74',
58
+ ... data=lalonde,
59
+ ... baseline_formula='~age + educ + re75 + re74',
60
+ ... diff_formula='~I(re75==0)',
61
+ ... att=0 # oCBPS only supports ATE
62
+ ... )
63
+ >>>
64
+ >>> # View results
65
+ >>> print(fit.summary())
66
+ >>> print(f"J-statistic: {fit.J:.6f}")
67
+ """
68
+
69
+ from typing import Any, Dict, Optional
70
+ import warnings
71
+
72
+ import numpy as np
73
+ import scipy.linalg
74
+ import scipy.special
75
+ import scipy.optimize
76
+ import statsmodels.api as sm
77
+
78
+ # Import generalized inverse function from cbps_binary
79
+ from .cbps_binary import _r_ginv
80
+
81
+ # Constants
82
+ PROBS_MIN = 1e-6 # Probability clipping threshold for numerical stability
83
+
84
+
85
+ def _gmm_func1(
86
+ beta_curr: np.ndarray,
87
+ X: np.ndarray,
88
+ treat: np.ndarray,
89
+ baseline_X: np.ndarray,
90
+ diff_X: np.ndarray,
91
+ invV: Optional[np.ndarray] = None,
92
+ option: Optional[str] = None
93
+ ) -> Dict[str, Any]:
94
+ """
95
+ GMM objective function for optimal CBPS with dual balancing conditions.
96
+
97
+ Parameters
98
+ ----------
99
+ beta_curr : np.ndarray
100
+ Current propensity score coefficients (k-dimensional vector).
101
+ X : np.ndarray
102
+ Covariate matrix (n x k, including intercept column).
103
+ treat : np.ndarray
104
+ Binary treatment vector (0/1 encoded).
105
+ baseline_X : np.ndarray
106
+ Design matrix from baseline formula (n x m1).
107
+ diff_X : np.ndarray
108
+ Design matrix from diff formula (n x m2).
109
+ invV : np.ndarray, optional
110
+ Precomputed inverse of V matrix (for two-step GMM).
111
+ option : str, optional
112
+ None for dual balancing (oCBPS standard), "CBPS" for single balancing
113
+ (used in pre-optimization).
114
+
115
+ Returns
116
+ -------
117
+ dict
118
+ Dictionary with keys:
119
+ - 'loss': GMM loss value (quadratic form)
120
+ - 'invV': Generalized inverse of the V matrix
121
+
122
+ Notes
123
+ -----
124
+ The dual balancing conditions (Fan et al. 2022, Eq. 3.2-3.3):
125
+
126
+ - g1_baseline: Balance covariates related to E(Y(0)|X)
127
+ - g2_diff: Balance covariates related to E(Y(1)-Y(0)|X)
128
+
129
+ When option="CBPS", uses standard single balance condition for pre-optimization.
130
+ """
131
+ # Step 1: Sample size
132
+ n = X.shape[0]
133
+
134
+ # Step 2: Compute propensity scores
135
+ theta_curr = X @ beta_curr
136
+ probs_curr = scipy.special.expit(theta_curr)
137
+
138
+ # Sequential clipping (upper bound then lower bound)
139
+ probs_curr = np.minimum(1 - PROBS_MIN, probs_curr)
140
+ probs_curr = np.maximum(PROBS_MIN, probs_curr)
141
+
142
+ # Step 3: Compute ATE weights
143
+ w_curr = treat / probs_curr - (1 - treat) / (1 - probs_curr)
144
+
145
+ # Step 4: Construct moment conditions based on option
146
+ if option is None:
147
+ # Dual balancing conditions (oCBPS standard)
148
+ # Construct X1new: intercept + baseline covariates
149
+ X1new = np.column_stack([X[:, 0], baseline_X])
150
+
151
+ # g1_baseline balance condition
152
+ w_curr_del1 = (1/n) * (X1new.T @ w_curr)
153
+
154
+ # g2_diff weights
155
+ w_curr3 = treat / probs_curr - 1
156
+
157
+ # g2_diff balance condition
158
+ w_curr_del3 = (1/n) * (diff_X.T @ w_curr3)
159
+
160
+ # Concatenate dual balance conditions
161
+ gbar = np.concatenate([w_curr_del1, w_curr_del3])
162
+
163
+ elif option == "CBPS":
164
+ # Single balance condition (for pre-optimization)
165
+ # Standard ATE balance using full X matrix
166
+ w_curr_del = (1/n) * (X.T @ w_curr)
167
+ gbar = w_curr_del
168
+
169
+ else:
170
+ raise ValueError(f"Unknown option: {option}")
171
+
172
+ # Step 5: Compute covariance matrix V and its inverse
173
+ if invV is None:
174
+ # Reconstruct X1new (not defined when option="CBPS")
175
+ X1new = np.column_stack([X[:, 0], baseline_X])
176
+
177
+ # Block 1: V11
178
+ factor_1 = ((1 - probs_curr) * probs_curr)**(-0.5)
179
+ X_1 = X1new * factor_1[:, None]
180
+
181
+ # Block 2: V22
182
+ factor_2 = (1/probs_curr - 1)**0.5
183
+ X_2 = diff_X * factor_2[:, None]
184
+
185
+ # Block 3: V12
186
+ X_1_1 = X1new * (probs_curr**(-0.5))[:, None]
187
+
188
+ # Block 4: V21
189
+ X_1_2 = diff_X * (probs_curr**(-0.5))[:, None]
190
+
191
+ # Assemble V matrix
192
+ V11 = (1/n) * (X_1.T @ X_1)
193
+ V12 = (1/n) * (X_1_1.T @ X_1_2)
194
+ V21 = (1/n) * (X_1_2.T @ X_1_1)
195
+ V22 = (1/n) * (X_2.T @ X_2)
196
+
197
+ V = np.block([[V11, V12],
198
+ [V21, V22]])
199
+
200
+ # Generalized inverse
201
+ invV_g = _r_ginv(V)
202
+ else:
203
+ invV_g = invV
204
+
205
+ # Step 6: Compute GMM loss (quadratic form)
206
+ loss = float(gbar.T @ invV_g @ gbar)
207
+
208
+ return {'loss': loss, 'invV': invV_g}
209
+
210
+
211
+ def _gmm_loss1(beta: np.ndarray, *args, **kwargs) -> float:
212
+ """
213
+ GMM loss function wrapper for scipy.optimize.
214
+
215
+ Parameters
216
+ ----------
217
+ beta : np.ndarray
218
+ Propensity score coefficients.
219
+ *args, **kwargs
220
+ Arguments passed to _gmm_func1.
221
+
222
+ Returns
223
+ -------
224
+ float
225
+ GMM loss value.
226
+ """
227
+ return _gmm_func1(beta, *args, **kwargs)['loss']
228
+
229
+
230
+ def cbps_optimal_2treat(
231
+ treat: np.ndarray,
232
+ X: np.ndarray,
233
+ baseline_X: np.ndarray,
234
+ diff_X: np.ndarray,
235
+ iterations: int = 1000,
236
+ att: int = 0,
237
+ standardize: bool = True
238
+ ) -> Dict[str, Any]:
239
+ """
240
+ Optimal CBPS for binary treatments with double robustness and efficiency.
241
+
242
+ Implements the optimal covariate balancing conditions from Fan et al.
243
+ (2022), achieving double robustness and semiparametric efficiency by
244
+ separating balance conditions for baseline outcome and treatment effect
245
+ heterogeneity models.
246
+
247
+ Parameters
248
+ ----------
249
+ treat : np.ndarray
250
+ Binary treatment vector (0/1 encoded, n-dimensional).
251
+ X : np.ndarray
252
+ Covariate matrix including intercept (n x k).
253
+ baseline_X : np.ndarray
254
+ Design matrix from baseline formula (h1 covariates, n x m1).
255
+ Zero-variance columns should be filtered before calling.
256
+ diff_X : np.ndarray
257
+ Design matrix from diff formula (h2 covariates, n x m2).
258
+ Zero-variance columns should be filtered before calling.
259
+ iterations : int, default 1000
260
+ Maximum BFGS iterations.
261
+ att : int, default 0
262
+ Estimand target. Only att=0 (ATE) is supported for oCBPS.
263
+ standardize : bool, default True
264
+ Whether to standardize weights.
265
+
266
+ Returns
267
+ -------
268
+ dict
269
+ Dictionary containing:
270
+ - coefficients: Coefficient matrix (k x 1)
271
+ - fitted_values: Propensity scores (n-dimensional)
272
+ - linear_predictor: Linear predictor X @ beta (n-dimensional)
273
+ - deviance: Negative 2 times log-likelihood
274
+ - weights: Optimal weights (n-dimensional)
275
+ - y: Treatment vector (n-dimensional)
276
+ - x: Covariate matrix (n x k)
277
+ - converged: Convergence flag (bool)
278
+ - J: J-statistic (float)
279
+ - var: Variance-covariance matrix (k x k)
280
+ - mle_J: MLE baseline J-statistic (float)
281
+
282
+ Raises
283
+ ------
284
+ ValueError
285
+ If baseline and diff model dimensions are incompatible.
286
+
287
+ Notes
288
+ -----
289
+ **Key Features:**
290
+
291
+ - Only supports att=0 (ATE estimation)
292
+ - No sample_weights parameter (oCBPS does not support sampling weights)
293
+ - Dual initialization optimization for robust convergence
294
+
295
+ **Dual Balancing Conditions** (Fan 2022 Eq. 3.2-3.3):
296
+
297
+ - g1_baseline: (T/π - (1-T)/(1-π)) h1(X) = 0, balances E(Y(0)|X)
298
+ - g2_diff: (T/π - 1) h2(X) = 0, balances E(Y(1)-Y(0)|X)
299
+
300
+ **Double Robustness** (Theorem 3.1):
301
+ Consistent if either the propensity score model or outcome model is correct.
302
+
303
+ **Semiparametric Efficiency** (Corollary 3.2):
304
+ Achieves Hahn 1998 efficiency bound when both models are correct and m=q.
305
+
306
+ References
307
+ ----------
308
+ .. [1] Fan et al. (2022). Optimal Covariate Balancing Conditions in
309
+ Propensity Score Estimation. Journal of Business & Economic
310
+ Statistics, 41(1), 97-110. https://doi.org/10.1080/07350015.2021.2002159
311
+
312
+ Examples
313
+ --------
314
+ >>> # See module-level documentation for complete examples
315
+ """
316
+ # Initialize constants
317
+ n = X.shape[0]
318
+
319
+ # Determine identification status
320
+ m1 = baseline_X.shape[1]
321
+ m2 = diff_X.shape[1]
322
+ k = X.shape[1]
323
+
324
+ if m1 + m2 + 1 > k:
325
+ bal_only = 3 # Over-identified: m1 + m2 + 1 > q
326
+ xcov = None
327
+ elif m1 + m2 + 1 == k:
328
+ bal_only = 1 # Exactly identified: m1 + m2 + 1 = q
329
+ xcov = np.eye(m1 + m2 + 1)
330
+ else:
331
+ raise ValueError("Invalid baseline and diff models.")
332
+
333
+ # Dual initialization: GLM and CBPS pre-optimization paths
334
+
335
+ # GLM initial values
336
+ glm_model = sm.GLM(treat, X, family=sm.families.Binomial())
337
+ glm_result = glm_model.fit()
338
+ glm_beta_curr = glm_result.params.copy()
339
+ glm_beta_curr[np.isnan(glm_beta_curr)] = 0
340
+
341
+ # CBPS pre-optimization initial values
342
+ # Precompute simplified inverse matrix for pre-optimization
343
+ invV2 = scipy.linalg.pinv(X.T @ X)
344
+
345
+ # CBPS pre-optimization (single balance condition)
346
+ def gmm_loss_for_preopt(beta):
347
+ return _gmm_func1(beta, X, treat, baseline_X, diff_X,
348
+ invV=invV2, option="CBPS")['loss']
349
+
350
+ cbps_preopt = scipy.optimize.minimize(
351
+ gmm_loss_for_preopt,
352
+ glm_beta_curr,
353
+ method='BFGS'
354
+ )
355
+ cbps_beta_curr = cbps_preopt.x
356
+
357
+ # GMM optimization branching
358
+ gmm_init = glm_beta_curr
359
+
360
+ if bal_only == 1:
361
+ # Exactly identified
362
+ opt_bal = scipy.optimize.minimize(
363
+ lambda b: _gmm_func1(b, X, treat, baseline_X, diff_X, invV=xcov)['loss'],
364
+ gmm_init,
365
+ method='BFGS'
366
+ )
367
+ opt1 = opt_bal
368
+
369
+ elif bal_only == 3:
370
+ # Over-identified
371
+ # GMM loss function (recompute invV each iteration)
372
+ def gmm_loss_std(beta):
373
+ return _gmm_func1(beta, X, treat, baseline_X, diff_X)['loss']
374
+
375
+ # GLM path optimization
376
+ gmm_glm_init = scipy.optimize.minimize(
377
+ gmm_loss_std,
378
+ glm_beta_curr,
379
+ method='BFGS',
380
+ options={'maxiter': iterations}
381
+ )
382
+
383
+ # CBPS pre-optimization path
384
+ gmm_cbps_init = scipy.optimize.minimize(
385
+ gmm_loss_std,
386
+ cbps_beta_curr,
387
+ method='BFGS',
388
+ options={'maxiter': iterations}
389
+ )
390
+
391
+ # Select best initialization
392
+ if gmm_glm_init.fun < gmm_cbps_init.fun:
393
+ opt1 = gmm_glm_init
394
+ else:
395
+ opt1 = gmm_cbps_init
396
+
397
+ # Compute probabilities and weights
398
+
399
+ # Optimal coefficients
400
+ beta_opt = opt1.x
401
+
402
+ # Optimal propensity scores
403
+ theta_opt = X @ beta_opt
404
+ probs_opt = scipy.special.expit(theta_opt)
405
+ probs_opt = np.minimum(1 - PROBS_MIN, probs_opt)
406
+ probs_opt = np.maximum(PROBS_MIN, probs_opt)
407
+
408
+ # ATE weights (simplified form)
409
+ w_opt = np.abs((probs_opt - 1 + treat)**(-1))
410
+
411
+ # Weight standardization
412
+ if standardize:
413
+ norm1 = np.sum((treat == 1) / probs_opt)
414
+ norm2 = np.sum((treat == 0) / (1 - probs_opt))
415
+ else:
416
+ norm1 = norm2 = 1.0
417
+
418
+ w_opt = ((treat == 1) / probs_opt / norm1 +
419
+ (treat == 0) / (1 - probs_opt) / norm2)
420
+
421
+ # Compute variance-covariance matrix
422
+
423
+ # Construct X1new (required for vcov computation)
424
+ X1new = np.column_stack([X[:, 0], baseline_X])
425
+
426
+ # G matrix construction
427
+ factor_1 = np.sqrt(np.abs(treat - probs_opt) / (probs_opt * (1 - probs_opt)))
428
+ XG_1 = -X * factor_1[:, None]
429
+ XG_12 = -X1new * factor_1[:, None]
430
+ XW_1 = X1new * ((probs_opt - 1 + treat)**(-1))[:, None]
431
+
432
+ factor_2 = np.sqrt(treat * (1 - probs_opt) / probs_opt)
433
+ XG_2 = -X * factor_2[:, None]
434
+ XG_22 = -diff_X * factor_2[:, None]
435
+ XW_2 = diff_X * ((treat / probs_opt - 1))[:, None]
436
+
437
+ # W1 matrix
438
+ W1 = np.vstack([XW_1.T, XW_2.T])
439
+
440
+ # G matrix
441
+ G = np.column_stack([
442
+ (XG_1.T @ XG_12) / n,
443
+ (XG_2.T @ XG_22) / n
444
+ ])
445
+
446
+ # Omega outer product
447
+ Omega = (W1 @ W1.T) / n
448
+
449
+ # Sandwich variance formula
450
+ gmm_result = _gmm_func1(beta_opt, X, treat, baseline_X, diff_X, invV=None)
451
+ W = gmm_result['invV']
452
+
453
+ GWG_inv = _r_ginv(G @ W @ G.T)
454
+ vcov = GWG_inv @ G @ W @ Omega @ W.T @ G.T @ GWG_inv
455
+
456
+ # Construct return object
457
+
458
+ # J-statistic
459
+ J_opt = _gmm_func1(beta_opt, X, treat, baseline_X, diff_X, invV=None)['loss']
460
+
461
+ # Deviance
462
+ deviance = -2 * np.sum(
463
+ treat * np.log(probs_opt) + (1 - treat) * np.log(1 - probs_opt)
464
+ )
465
+
466
+ # MLE baseline J-statistic
467
+ glm1_coef = glm_beta_curr
468
+ mle_J = _gmm_func1(glm1_coef, X, treat, baseline_X, diff_X)['loss']
469
+
470
+ # Build output dictionary
471
+ output = {
472
+ 'coefficients': beta_opt.reshape(-1, 1), # k x 1 matrix
473
+ 'fitted_values': probs_opt,
474
+ 'linear_predictor': theta_opt,
475
+ 'deviance': deviance,
476
+ 'weights': w_opt,
477
+ 'y': treat,
478
+ 'x': X,
479
+ 'converged': opt1.success,
480
+ 'J': J_opt,
481
+ 'var': vcov,
482
+ 'mle_J': mle_J
483
+ }
484
+
485
+ # ========== oCBPS Condition Verification (P1-18/21) ==========
486
+ # Verify observable necessary conditions for oCBPS validity.
487
+ try:
488
+ from cbps.diagnostics.ocbps_conditions import verify_ocbps_conditions
489
+ conditions = verify_ocbps_conditions(output, X, treat)
490
+ output['ocbps_conditions'] = conditions
491
+
492
+ if not conditions['all_conditions_met']:
493
+ warn_msgs = conditions.get('warnings', [])
494
+ if warn_msgs:
495
+ warnings.warn(
496
+ "oCBPS efficiency conditions not fully met: "
497
+ + "; ".join(warn_msgs),
498
+ UserWarning
499
+ )
500
+ except (ImportError, Exception) as e:
501
+ # Diagnostics should never block estimation
502
+ output['ocbps_conditions'] = {
503
+ 'error': str(e),
504
+ 'all_conditions_met': None
505
+ }
506
+
507
+ return output