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
cbps/msm/cbmsm.py ADDED
@@ -0,0 +1,1871 @@
1
+ """
2
+ Covariate Balancing Propensity Score for Marginal Structural Models (CBMSM).
3
+
4
+ This module implements the Covariate Balancing Propensity Score (CBPS) methodology
5
+ for marginal structural models, as described in Imai and Ratkovic (2015). The
6
+ approach estimates inverse probability weights that simultaneously maximize
7
+ covariate balance and treatment assignment prediction in longitudinal settings.
8
+
9
+ Key Features
10
+ ------------
11
+ - **Time-invariant coefficients**: Single set of propensity score parameters
12
+ shared across all time periods (default).
13
+ - **Time-varying coefficients**: Period-specific parameter estimation when
14
+ treatment effects on covariates vary over time.
15
+ - **Stabilized weights**: Variance reduction through numerator modeling as
16
+ P(T)/P(T|X) rather than 1/P(T|X).
17
+ - **Low-rank variance approximation**: Computational efficiency for settings
18
+ with many time periods via diagonal covariance assumption.
19
+ - **Hadamard matrix decomposition**: Orthogonal representation of covariate
20
+ balancing conditions based on 2^J factorial design framework.
21
+
22
+ Algorithm Overview
23
+ ------------------
24
+ The estimator solves a Generalized Method of Moments (GMM) problem where
25
+ moment conditions are derived from the covariate balancing property of MSM
26
+ weights. At each time period j, the weights balance covariates across all
27
+ possible current and future treatment sequences, conditional on past treatment
28
+ history.
29
+
30
+ The number of moment conditions grows exponentially with the number of time
31
+ periods. For K covariates and J periods, each period j contributes
32
+ K × (2^J - 2^{j-1}) conditions, yielding a total that scales as O(K × J × 2^J).
33
+ The low-rank approximation addresses this by assuming zero correlation across
34
+ balance conditions.
35
+
36
+ References
37
+ ----------
38
+ Imai, K. and Ratkovic, M. (2015). Robust estimation of inverse probability
39
+ weights for marginal structural models. Journal of the American Statistical
40
+ Association, 110(511), 1013-1023. https://doi.org/10.1080/01621459.2014.956872
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ from dataclasses import dataclass
46
+ from typing import Any, Dict, Tuple, Optional
47
+
48
+ import numpy as np
49
+ import pandas as pd
50
+ import scipy.optimize
51
+ import scipy.special
52
+
53
+ from cbps.utils.formula import parse_formula
54
+ from cbps.core.cbps_binary import _r_ginv
55
+ from cbps.core.cbps_binary import cbps_binary_fit
56
+ import statsmodels.api as sm
57
+ from statsmodels.genmod.families import Binomial
58
+ import re
59
+ import warnings
60
+
61
+ from cbps.constants import DEFAULT_CONFIG
62
+
63
+ PROBS_TRIM = DEFAULT_CONFIG.probs_trim_msm # CBMSM probability clipping threshold
64
+
65
+
66
+ class CBMSMSummary:
67
+ """Summary object for CBMSMResults.
68
+
69
+ Returned by :meth:`CBMSMResults.summary`. Provides a structured
70
+ representation of CBMSM estimation results that can be printed
71
+ via ``print()`` or ``str()``.
72
+
73
+ Attributes
74
+ ----------
75
+ n_obs : int
76
+ Total number of observations (n_units × n_periods).
77
+ n_periods : int
78
+ Number of time periods.
79
+ n_units : int
80
+ Number of unique units.
81
+ time_vary : bool
82
+ Whether period-specific coefficients were estimated.
83
+ converged : bool
84
+ Whether the GMM optimization converged.
85
+ J : float
86
+ Hansen J-statistic.
87
+ weights : np.ndarray
88
+ Propensity score weights P(T|X).
89
+ fitted_values : np.ndarray
90
+ Stabilized MSM weights P(T)/P(T|X).
91
+ coefficients : np.ndarray
92
+ Estimated propensity score model coefficients.
93
+ """
94
+
95
+ def __init__(
96
+ self,
97
+ weights: np.ndarray,
98
+ fitted_values: np.ndarray,
99
+ coefficients: np.ndarray,
100
+ n_periods: int,
101
+ n_units: int,
102
+ time_vary: bool,
103
+ converged: bool,
104
+ J: float,
105
+ ):
106
+ self.weights = weights
107
+ self.fitted_values = fitted_values
108
+ self.coefficients = coefficients
109
+ self.n_periods = n_periods
110
+ self.n_units = n_units
111
+ self.time_vary = time_vary
112
+ self.converged = converged
113
+ self.J = J
114
+
115
+ def __str__(self) -> str:
116
+ """Return formatted summary text.
117
+
118
+ The output is identical to the legacy ``CBMSMResults.summary()``
119
+ string for backward compatibility.
120
+ """
121
+ output = "\n" + "="*70 + "\n"
122
+ output += "CBMSM (Covariate Balancing Propensity Score for MSM) Results\n"
123
+ output += "="*70 + "\n\n"
124
+
125
+ # Basic information
126
+ output += f"Number of observations: {len(self.weights)}\n"
127
+ output += f"Number of time periods: {self.n_periods}\n"
128
+ output += f"Number of units: {self.n_units}\n"
129
+ output += f"Time-varying treatment model: {'Yes' if self.time_vary else 'No'}\n"
130
+ output += f"Convergence: {'Yes' if self.converged else 'No'}\n"
131
+ output += f"J-statistic: {self.J:.6f}\n\n"
132
+
133
+ # Propensity score summary P(T|X)
134
+ output += "Propensity Scores P(T|X) Summary:\n"
135
+ output += "-"*70 + "\n"
136
+ weights = np.array(self.weights)
137
+ output += f" Min: {np.min(weights):.6f}\n"
138
+ output += f" 1Q: {np.percentile(weights, 25):.6f}\n"
139
+ output += f" Median: {np.median(weights):.6f}\n"
140
+ output += f" Mean: {np.mean(weights):.6f}\n"
141
+ output += f" 3Q: {np.percentile(weights, 75):.6f}\n"
142
+ output += f" Max: {np.max(weights):.6f}\n\n"
143
+
144
+ # MSM weight summary (stabilized weights P(T)/P(T|X))
145
+ if self.fitted_values is not None:
146
+ output += "MSM Weights (Stabilized) Summary:\n"
147
+ output += "-"*70 + "\n"
148
+ msm_weights = np.array(self.fitted_values)
149
+ output += f" Min: {np.min(msm_weights):.6f}\n"
150
+ output += f" 1Q: {np.percentile(msm_weights, 25):.6f}\n"
151
+ output += f" Median: {np.median(msm_weights):.6f}\n"
152
+ output += f" Mean: {np.mean(msm_weights):.6f}\n"
153
+ output += f" 3Q: {np.percentile(msm_weights, 75):.6f}\n"
154
+ output += f" Max: {np.max(msm_weights):.6f}\n\n"
155
+
156
+ # Coefficient information
157
+ if self.coefficients is not None:
158
+ output += "Coefficients:\n"
159
+ output += "-"*70 + "\n"
160
+ coefs = np.array(self.coefficients)
161
+ if coefs.ndim == 1:
162
+ # time_vary=False: single column of coefficients
163
+ for i, coef in enumerate(coefs):
164
+ output += f" beta[{i}]: {coef:.6f}\n"
165
+ else:
166
+ # time_vary=True: coefficient matrix (k, n_periods)
167
+ output += f" Shape: {coefs.shape[0]} parameters × {coefs.shape[1]} periods\n"
168
+ for i in range(min(5, coefs.shape[0])): # Show first 5 parameters
169
+ output += f" beta[{i}]: "
170
+ for j in range(coefs.shape[1]):
171
+ output += f"{coefs[i,j]:8.4f} "
172
+ output += "\n"
173
+ if coefs.shape[0] > 5:
174
+ output += f" ... ({coefs.shape[0] - 5} more parameters)\n"
175
+ output += "\n"
176
+
177
+ # Warning message
178
+ if not self.converged:
179
+ output += "WARNING: Optimization did not converge!\n"
180
+ output += " Results may be unreliable. Consider:\n"
181
+ output += " - Increasing the number of iterations\n"
182
+ output += " - Checking data quality and balance\n"
183
+ output += " - Trying different starting values\n"
184
+ output += " - Examining the balance diagnostics\n\n"
185
+
186
+ output += "="*70 + "\n"
187
+ return output
188
+
189
+ def __repr__(self) -> str:
190
+ return f"CBMSMSummary(n_units={self.n_units}, converged={self.converged})"
191
+
192
+
193
+ @dataclass
194
+ class CBMSMResults:
195
+ """Container for CBMSM estimation results.
196
+
197
+ Stores inverse probability weights, model diagnostics, and estimation
198
+ metadata from fitting a Covariate Balancing Propensity Score model for
199
+ Marginal Structural Models.
200
+
201
+ Attributes
202
+ ----------
203
+ weights : np.ndarray, shape (n_units,)
204
+ Inverse probability weights 1/P(T|X), computed as the product of
205
+ per-period weights across all time periods for each unit.
206
+ fitted_values : np.ndarray, shape (n_units,)
207
+ Stabilized MSM weights P(T)/P(T|X), suitable for use in weighted
208
+ outcome regression models.
209
+ glm_g : np.ndarray
210
+ Sample moment conditions under standard logistic regression.
211
+ msm_g : np.ndarray
212
+ Sample moment conditions under the fitted CBMSM model.
213
+ glm_weights : np.ndarray, shape (n_units,)
214
+ Stabilized weights from standard logistic regression for comparison.
215
+ id : np.ndarray
216
+ Unit identifiers corresponding to each observation.
217
+ treat_hist : np.ndarray, shape (n_units, n_periods)
218
+ Binary treatment history matrix. Entry (i, j) indicates whether
219
+ unit i received treatment at time period j.
220
+ treat_cum : np.ndarray, shape (n_units,)
221
+ Cumulative treatment count for each unit across all periods.
222
+ coefficients : np.ndarray
223
+ Estimated propensity score model coefficients. Shape is (k,) when
224
+ ``time_vary=False`` or (k, n_periods) when ``time_vary=True``.
225
+ n_periods : int
226
+ Number of time periods J in the panel.
227
+ n_units : int
228
+ Number of unique units (individuals) N in the sample.
229
+ time_vary : bool
230
+ Whether period-specific coefficients were estimated.
231
+ converged : bool
232
+ Whether the GMM optimization converged successfully.
233
+ J : float
234
+ Hansen J-statistic (normalized GMM objective value), useful for
235
+ assessing model specification.
236
+ var : np.ndarray
237
+ Inverse of the GMM weighting matrix used in optimization. Note
238
+ that this is NOT the variance-covariance matrix of coefficient
239
+ estimates; it has dimension (n_moments, n_moments).
240
+ call : str
241
+ String representation of the function call for reproducibility.
242
+ formula : str
243
+ Model formula specification (None for matrix interface).
244
+ y : np.ndarray
245
+ Treatment vector (observation-level, sorted by time then id).
246
+ x : np.ndarray
247
+ Original covariate matrix from formula parsing (observation-level).
248
+ Note: The actual GMM estimation uses an SVD-processed version with
249
+ intercept; this attribute stores the pre-processed input.
250
+ time : np.ndarray
251
+ Time period indicators (observation-level, sorted).
252
+ model : Any
253
+ Model frame containing all variables.
254
+ data : Any
255
+ Original input DataFrame.
256
+
257
+ See Also
258
+ --------
259
+ CBMSM : Formula interface for CBMSM estimation.
260
+ cbmsm_fit : Matrix interface for CBMSM estimation.
261
+
262
+ Notes
263
+ -----
264
+ The ``fitted_values`` attribute contains the recommended weights for
265
+ outcome model estimation. These are stabilized weights that incorporate
266
+ the marginal treatment probability P(T) in the numerator, reducing
267
+ variance compared to unstabilized weights (Robins, Hernan, and Brumback,
268
+ 2000).
269
+
270
+ The ``var`` attribute stores the inverse weighting matrix from GMM
271
+ optimization, not coefficient standard errors. Computing inference
272
+ for coefficients requires the GMM sandwich variance estimator, which
273
+ is not currently implemented.
274
+ """
275
+ # Core weights (6)
276
+ weights: np.ndarray
277
+ fitted_values: np.ndarray
278
+ glm_g: np.ndarray
279
+ msm_g: np.ndarray
280
+ glm_weights: np.ndarray
281
+ id: np.ndarray
282
+
283
+ # Treatment history (2)
284
+ treat_hist: np.ndarray
285
+ treat_cum: np.ndarray
286
+
287
+ # Diagnostics (7)
288
+ coefficients: np.ndarray
289
+ n_periods: int
290
+ n_units: int
291
+ time_vary: bool
292
+ converged: bool
293
+ J: float
294
+ var: np.ndarray
295
+
296
+ # Metadata (7)
297
+ call: str
298
+ formula: str
299
+ y: np.ndarray
300
+ x: np.ndarray
301
+ time: np.ndarray
302
+ model: Any
303
+ data: Any
304
+
305
+ def __repr__(self) -> str:
306
+ """Concise repr output for interactive environments."""
307
+ converged_str = "Yes" if self.converged else "No"
308
+ return f"CBMSMResults(n_units={self.n_units}, n_periods={self.n_periods}, converged={converged_str}, J={self.J:.6f})"
309
+
310
+ def __str__(self) -> str:
311
+ """Complete string output for print calls."""
312
+ output = "\nCall:\n " + (self.call or "CBMSM()") + "\n\n"
313
+
314
+ # Basic information
315
+ output += f"Sample size: {self.n_units} units × {self.n_periods} periods = {self.n_units * self.n_periods} observations\n"
316
+ output += f"Time-varying treatment model: {'Yes' if self.time_vary else 'No'}\n"
317
+ output += f"Converged: {'Yes' if self.converged else 'No'}\n"
318
+
319
+ # Statistics
320
+ output += f"\nModel Statistics:\n"
321
+ output += f" J-statistic: {self.J:.6f}\n"
322
+
323
+ # Weight information
324
+ if self.fitted_values is not None:
325
+ output += f"\nMSM Weights (stabilized):\n"
326
+ output += f" Min: {self.fitted_values.min():.6f}\n"
327
+ output += f" Max: {self.fitted_values.max():.6f}\n"
328
+ output += f" Mean: {self.fitted_values.mean():.6f}\n"
329
+
330
+ if self.weights is not None:
331
+ output += f"\nPropensity Scores P(T|X):\n"
332
+ output += f" Min: {self.weights.min():.6f}\n"
333
+ output += f" Max: {self.weights.max():.6f}\n"
334
+ output += f" Mean: {self.weights.mean():.6f}\n"
335
+
336
+ # Treatment history
337
+ if self.treat_hist is not None:
338
+ output += f"\nTreatment History: {self.treat_hist.shape[0]} × {self.treat_hist.shape[1]} matrix\n"
339
+
340
+ # Coefficients
341
+ if self.coefficients is not None:
342
+ output += f"\nCoefficients: {self.coefficients.shape[0]} parameters\n"
343
+
344
+ return output
345
+
346
+ def vcov(self) -> np.ndarray:
347
+ """Return the inverse GMM weighting matrix.
348
+
349
+ Returns
350
+ -------
351
+ np.ndarray, shape (n_moments, n_moments)
352
+ Inverse of the weighting matrix used in GMM optimization.
353
+
354
+ Raises
355
+ ------
356
+ ValueError
357
+ If the weighting matrix was not computed during estimation.
358
+
359
+ Warnings
360
+ --------
361
+ This matrix is the inverse of the moment condition covariance, NOT
362
+ the variance-covariance matrix of coefficient estimates. It cannot
363
+ be used directly for computing standard errors or confidence intervals.
364
+
365
+ Notes
366
+ -----
367
+ The GMM weighting matrix W is defined in Imai and Ratkovic (2015)
368
+ equation (25). For coefficient inference, the sandwich variance
369
+ estimator would be required, which involves the Jacobian of moment
370
+ conditions with respect to parameters.
371
+ """
372
+ if self.var is None:
373
+ raise ValueError("GMM weighting matrix inverse not computed.")
374
+ return self.var
375
+
376
+ def summary(self) -> 'CBMSMSummary':
377
+ """Generate a formatted summary of estimation results.
378
+
379
+ Returns
380
+ -------
381
+ CBMSMSummary
382
+ Summary object with ``__str__`` method. Use ``print(result.summary())``
383
+ to display the formatted text.
384
+
385
+ Examples
386
+ --------
387
+ >>> result = CBMSM("treat ~ x1 + x2", id="id", time="time", data=df)
388
+ >>> print(result.summary())
389
+ """
390
+ return CBMSMSummary(
391
+ weights=self.weights,
392
+ fitted_values=self.fitted_values,
393
+ coefficients=self.coefficients,
394
+ n_periods=self.n_periods,
395
+ n_units=self.n_units,
396
+ time_vary=self.time_vary,
397
+ converged=self.converged,
398
+ J=self.J,
399
+ )
400
+
401
+ def balance(self) -> Dict[str, Any]:
402
+ """Compute covariate balance diagnostics across treatment histories.
403
+
404
+ Assesses whether the estimated weights successfully balance baseline
405
+ covariates across all distinct treatment history patterns. This is
406
+ the key diagnostic for MSM weight quality.
407
+
408
+ Returns
409
+ -------
410
+ dict
411
+ Dictionary containing:
412
+
413
+ - ``'Balanced'``: np.ndarray, shape (n_covars, 2*n_patterns)
414
+ Weighted covariate means and standardized means for each
415
+ treatment history pattern.
416
+ - ``'Unweighted'``: np.ndarray, shape (n_covars, 2*n_patterns)
417
+ Corresponding statistics using GLM weights for comparison.
418
+ - ``'StatBal'``: float
419
+ Overall balance statistic (sum of squared deviations).
420
+ - ``'column_names'``: list of str
421
+ Labels in format ``"{pattern}.mean"`` and ``"{pattern}.std.mean"``.
422
+ - ``'row_names'``: list of str
423
+ Covariate names (excluding intercept).
424
+
425
+ Notes
426
+ -----
427
+ Balance is computed using first-period covariates only, as these are
428
+ not affected by treatment. Standardized means divide by the weighted
429
+ standard deviation to facilitate comparison across variables with
430
+ different scales.
431
+ """
432
+ # 1. Construct treatment history matrix
433
+ if self.treat_hist is None:
434
+ raise ValueError("CBMSMResults object missing treat_hist matrix")
435
+
436
+ # 2. Convert treatment history to string factors
437
+ # Each row represents a unit's complete treatment history, joined with "+"
438
+ treat_hist_fac = np.array(['+'.join(map(str, row.astype(int))) for row in self.treat_hist])
439
+
440
+ # Get unique treatment history patterns
441
+ unique_treat_hist = np.unique(treat_hist_fac)
442
+ n_unique = len(unique_treat_hist)
443
+
444
+ # Get time periods
445
+ unique_times = np.unique(self.time)
446
+ times = np.sort(unique_times)
447
+ first_time = times[0]
448
+
449
+ # Get first-period data indices
450
+ first_time_idx = (self.time == first_time)
451
+ X_first = self.x[first_time_idx, :] # First-period covariate matrix (observation-level)
452
+
453
+ # 3. Initialize balance matrices
454
+ n_covars = self.x.shape[1] - 1 # Covariates excluding intercept
455
+ bal = np.full((n_covars, n_unique * 2), np.nan)
456
+ baseline = np.full((n_covars, n_unique * 2), np.nan)
457
+
458
+ # 4. Compute balance statistics for each treatment history and covariate
459
+ # Note: treat_hist_fac is unit-level (N units), needs mapping to observation-level
460
+ unique_ids = np.unique(self.id)
461
+ id_to_treathist = dict(zip(unique_ids, treat_hist_fac))
462
+
463
+ # For each first-period observation, get its corresponding treatment history
464
+ id_first = self.id[first_time_idx]
465
+ treat_hist_fac_first = np.array([id_to_treathist[uid] for uid in id_first])
466
+
467
+ for i, th in enumerate(unique_treat_hist):
468
+ # Find first-period observations with this treatment history
469
+ obs_mask = (treat_hist_fac_first == th)
470
+
471
+ for j in range(1, self.x.shape[1]): # Skip intercept column (j=0)
472
+ # Get first-period covariate values and weights
473
+ X_col_first = X_first[:, j]
474
+
475
+ # Expand unit-level weights to observation-level (first period)
476
+ w_unit = self.weights # unit-level
477
+ id_to_weight = dict(zip(unique_ids, w_unit))
478
+ w_first = np.array([id_to_weight[uid] for uid in id_first])
479
+
480
+ # GLM weights are also unit-level, need expansion
481
+ glm_w_unit = self.glm_weights # unit-level
482
+ id_to_glm_weight = dict(zip(unique_ids, glm_w_unit))
483
+ glm_w_first = np.array([id_to_glm_weight[uid] for uid in id_first])
484
+
485
+ # Compute weighted mean
486
+ numerator = np.sum(obs_mask * X_col_first * w_first)
487
+ denominator = np.sum(w_first * obs_mask)
488
+ bal[j-1, i] = numerator / denominator if denominator > 0 else 0.0
489
+
490
+ # Standardized mean
491
+ weighted_X_std = np.std(w_first * X_col_first, ddof=1)
492
+ bal[j-1, i + n_unique] = bal[j-1, i] / weighted_X_std if weighted_X_std > 0 else 0.0
493
+
494
+ # Unweighted (GLM) mean
495
+ numerator_glm = np.sum(obs_mask * X_col_first * glm_w_first)
496
+ denominator_glm = np.sum(glm_w_first * obs_mask)
497
+ baseline[j-1, i] = numerator_glm / denominator_glm if denominator_glm > 0 else 0.0
498
+
499
+ # Unweighted standardized mean
500
+ glm_weighted_X_std = np.std(glm_w_first * X_col_first, ddof=1)
501
+ baseline[j-1, i + n_unique] = baseline[j-1, i] / glm_weighted_X_std if glm_weighted_X_std > 0 else 0.0
502
+
503
+ # 5. Set NA values to 0
504
+ bal[np.isnan(bal)] = 0.0
505
+ baseline[np.isnan(baseline)] = 0.0
506
+
507
+ # 6. Set column names
508
+ cnames = []
509
+ for th in unique_treat_hist:
510
+ cnames.append(f"{th}.mean")
511
+ for th in unique_treat_hist:
512
+ cnames.append(f"{th}.std.mean")
513
+
514
+ # 7. Set row names (covariate names, excluding intercept)
515
+ if hasattr(self, 'covariate_names') and self.covariate_names is not None:
516
+ rnames = [name for name in self.covariate_names if name != '(Intercept)']
517
+ else:
518
+ rnames = [f"X{i}" for i in range(1, self.x.shape[1])]
519
+
520
+ # 8. Compute StatBal statistic
521
+ # sum((bal - bal[,1]) * (bal != 0)^2)
522
+ bal_diff = bal - bal[:, 0:1] # Difference from first column
523
+ statbal = np.sum(bal_diff * (bal != 0)**2)
524
+
525
+ # 9. Return results
526
+ return {
527
+ 'Balanced': bal,
528
+ 'Unweighted': baseline,
529
+ 'StatBal': statbal,
530
+ 'column_names': cnames,
531
+ 'row_names': rnames
532
+ }
533
+
534
+
535
+ def _sort_by_time(
536
+ id_arr: np.ndarray,
537
+ time_arr: np.ndarray,
538
+ y: np.ndarray,
539
+ X: np.ndarray
540
+ ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
541
+ """Sort panel data by (time, id) for consistent processing.
542
+
543
+ Parameters
544
+ ----------
545
+ id_arr : np.ndarray, shape (n,)
546
+ Unit identifiers.
547
+ time_arr : np.ndarray, shape (n,)
548
+ Time period indicators.
549
+ y : np.ndarray, shape (n,)
550
+ Treatment vector.
551
+ X : np.ndarray, shape (n, k)
552
+ Covariate matrix.
553
+
554
+ Returns
555
+ -------
556
+ id_sorted : np.ndarray
557
+ Sorted unit identifiers.
558
+ time_sorted : np.ndarray
559
+ Sorted time indicators.
560
+ y_sorted : np.ndarray
561
+ Sorted treatment vector.
562
+ X_sorted : np.ndarray
563
+ Sorted covariate matrix.
564
+ order : np.ndarray
565
+ Permutation indices for reconstructing original order.
566
+ """
567
+ order = np.lexsort((id_arr, time_arr))
568
+ return id_arr[order], time_arr[order], y[order], X[order, :], order
569
+
570
+
571
+ def _build_treat_hist(
572
+ y_sorted: np.ndarray,
573
+ id_sorted: np.ndarray,
574
+ time_sorted: np.ndarray
575
+ ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
576
+ """Construct the treatment history matrix from sorted panel data.
577
+
578
+ Parameters
579
+ ----------
580
+ y_sorted : np.ndarray, shape (n,)
581
+ Treatment vector sorted by (time, id).
582
+ id_sorted : np.ndarray, shape (n,)
583
+ Unit identifiers sorted by (time, id).
584
+ time_sorted : np.ndarray, shape (n,)
585
+ Time indicators sorted by (time, id).
586
+
587
+ Returns
588
+ -------
589
+ treat_hist : np.ndarray, shape (N, J)
590
+ Treatment history matrix. Entry (i, j) is the treatment status
591
+ for unit i at time period j.
592
+ name_cands : np.ndarray, shape (N,)
593
+ Sorted unique unit identifiers.
594
+ unique_time : np.ndarray, shape (J,)
595
+ Sorted unique time periods.
596
+
597
+ Warns
598
+ -----
599
+ UserWarning
600
+ If the panel is unbalanced (missing id×time combinations detected).
601
+ """
602
+ import warnings
603
+ name_cands = np.array(sorted(pd.unique(id_sorted)))
604
+ unique_time = np.array(sorted(pd.unique(time_sorted)))
605
+ N = len(name_cands)
606
+ J = len(unique_time)
607
+ hist = np.full((N, J), np.nan, dtype=float) # Initialize with NaN for missing combinations
608
+ # Construct (id, time) -> y lookup table
609
+ key = pd.Series(y_sorted, index=pd.MultiIndex.from_arrays([id_sorted, time_sorted]))
610
+ missing_combinations = [] # Track missing combinations
611
+ for i, name in enumerate(name_cands):
612
+ for j, t in enumerate(unique_time):
613
+ try:
614
+ hist[i, j] = float(key.loc[(name, t)])
615
+ except KeyError:
616
+ # Keep NaN value for missing combinations
617
+ missing_combinations.append((name, t))
618
+ pass # Keep initial NaN value
619
+
620
+ # Warn if there are missing combinations (defensive programming)
621
+ if missing_combinations:
622
+ warnings.warn(
623
+ f"Unbalanced panel detected: {len(missing_combinations)} missing id×time combinations. "
624
+ f"This should have been caught by the balanced panel check. "
625
+ f"Setting treat_hist to NaN for missing combinations.\n"
626
+ f"First few missing: {missing_combinations[:5]}",
627
+ UserWarning
628
+ )
629
+
630
+ return hist, name_cands, unique_time
631
+
632
+
633
+ def _scale_all_columns(X_in: np.ndarray) -> np.ndarray:
634
+ """Standardize matrix columns to zero mean and unit variance.
635
+
636
+ Parameters
637
+ ----------
638
+ X_in : np.ndarray, shape (n, k)
639
+ Input matrix.
640
+
641
+ Returns
642
+ -------
643
+ np.ndarray, shape (n, k)
644
+ Standardized matrix. Constant columns are set to zero.
645
+ """
646
+ X = X_in.astype(np.float64, copy=True)
647
+ if X.size == 0:
648
+ return X
649
+ mean = X.mean(axis=0)
650
+ sd = X.std(axis=0, ddof=1)
651
+ with np.errstate(divide='ignore', invalid='ignore'):
652
+ X = (X - mean) / sd
653
+ X[~np.isfinite(X)] = 0.0
654
+ return X
655
+
656
+
657
+ def _svd_boolean_trunc_global(X_mat: np.ndarray, svd_threshold: float = DEFAULT_CONFIG.svd_threshold_msm) -> np.ndarray:
658
+ """Apply SVD-based dimension reduction for numerical stability.
659
+
660
+ Removes near-collinear directions from covariates before constructing
661
+ moment conditions. This is a numerical stability measure, distinct from
662
+ the low-rank covariance approximation in Imai & Ratkovic (2015) Eq. 27.
663
+
664
+ Parameters
665
+ ----------
666
+ X_mat : np.ndarray, shape (n, k)
667
+ Covariate matrix (may include intercept).
668
+ svd_threshold : float, default=DEFAULT_CONFIG.svd_threshold_msm
669
+ Singular values below this threshold are discarded.
670
+ Default matches R implementation behavior.
671
+
672
+ Returns
673
+ -------
674
+ np.ndarray, shape (n, r)
675
+ Reduced-dimension representation where r <= k.
676
+
677
+ Notes
678
+ -----
679
+ The threshold is based on numerical stability heuristics,
680
+ not explicit theoretical guidance from the CBPS literature.
681
+ Users should verify sensitivity of results to this choice.
682
+
683
+ References
684
+ ----------
685
+ Imai, K. & Ratkovic, M. (2015). Eq. 27 describes W matrix low-rank
686
+ approximation (zero correlation assumption), not X dimension reduction.
687
+ """
688
+ Z_full = _scale_all_columns(X_mat)
689
+ if Z_full.shape[1] == 0:
690
+ return Z_full[:, :0]
691
+ U, s, _Vt = np.linalg.svd(Z_full, full_matrices=False)
692
+ mask = (s > svd_threshold)
693
+ X_svd = U @ np.diag(mask.astype(float))
694
+ col_sd = X_svd.std(axis=0, ddof=1)
695
+ X_svd = X_svd[:, col_sd > 0]
696
+ return X_svd
697
+
698
+
699
+ def _svd_boolean_trunc_timevary(
700
+ X_mat: np.ndarray,
701
+ time_sorted: np.ndarray,
702
+ unique_time: np.ndarray,
703
+ svd_threshold: float = DEFAULT_CONFIG.svd_threshold_msm
704
+ ) -> np.ndarray:
705
+ """Apply period-specific SVD dimension reduction for numerical stability.
706
+
707
+ Performs SVD truncation independently for each time period, then
708
+ vertically concatenates results. Used when ``time_vary=True``.
709
+
710
+ Parameters
711
+ ----------
712
+ X_mat : np.ndarray, shape (n, k)
713
+ Covariate matrix.
714
+ time_sorted : np.ndarray, shape (n,)
715
+ Time period indicators (sorted).
716
+ unique_time : np.ndarray, shape (J,)
717
+ Unique time periods.
718
+ svd_threshold : float, default=DEFAULT_CONFIG.svd_threshold_msm
719
+ Singular values below this threshold are discarded.
720
+ Default matches R implementation behavior.
721
+
722
+ Returns
723
+ -------
724
+ np.ndarray, shape (n, r)
725
+ Stacked reduced-dimension matrices.
726
+
727
+ Raises
728
+ ------
729
+ ValueError
730
+ If periods yield different numbers of retained components.
731
+
732
+ Notes
733
+ -----
734
+ The threshold is based on numerical stability heuristics,
735
+ not explicit theoretical guidance from the CBPS literature.
736
+ """
737
+ pieces = []
738
+ expected_cols: Optional[int] = None
739
+ for t in unique_time:
740
+ rows = (time_sorted == t)
741
+ Z_full = _scale_all_columns(X_mat[rows, :])
742
+ if Z_full.shape[1] == 0:
743
+ X_sub = np.zeros((rows.sum(), 0), dtype=float)
744
+ else:
745
+ U, s, _Vt = np.linalg.svd(Z_full, full_matrices=False)
746
+ mask = (s > svd_threshold)
747
+ X_sub = U @ np.diag(mask.astype(float))
748
+ col_sd = X_sub.std(axis=0, ddof=1)
749
+ X_sub = X_sub[:, col_sd > 0]
750
+ if expected_cols is None:
751
+ expected_cols = X_sub.shape[1]
752
+ if X_sub.shape[1] != expected_cols:
753
+ raise ValueError(
754
+ f"Inconsistent SVD column counts across time slices; "
755
+ f"check data & threshold {svd_threshold}."
756
+ )
757
+ pieces.append(X_sub)
758
+ return np.vstack(pieces) if pieces else np.zeros((0, 0), dtype=float)
759
+
760
+
761
+ def _integer_base_b(x: int, b: int = 2) -> np.ndarray:
762
+ """Convert integer to base-b digit array.
763
+
764
+ Parameters
765
+ ----------
766
+ x : int
767
+ Non-negative integer.
768
+ b : int, default=2
769
+ Base for representation.
770
+
771
+ Returns
772
+ -------
773
+ np.ndarray
774
+ Digits in base-b, most significant first.
775
+ """
776
+ if x <= 0:
777
+ return np.array([0], dtype=int)
778
+ bits = []
779
+ while x > 0:
780
+ bits.append(x % b)
781
+ x //= b
782
+ return np.array(list(reversed(bits)), dtype=int)
783
+
784
+
785
+ def msm_loss_func(
786
+ betas: np.ndarray,
787
+ X_c: np.ndarray,
788
+ treat_vec: np.ndarray,
789
+ time_vec: np.ndarray,
790
+ id_vec: Optional[np.ndarray] = None,
791
+ name_cands: Optional[np.ndarray] = None,
792
+ bal_only: bool = False,
793
+ time_sub: Optional[int] = None,
794
+ twostep: bool = False,
795
+ Vcov_inv: Optional[np.ndarray] = None,
796
+ full_var: bool = False,
797
+ ) -> Dict[str, Any]:
798
+ """Evaluate the GMM objective function for CBMSM.
799
+
800
+ Computes moment conditions based on covariate balancing constraints
801
+ derived from the 2^J factorial design framework (Imai and Ratkovic, 2015).
802
+
803
+ Parameters
804
+ ----------
805
+ betas : np.ndarray
806
+ Propensity score coefficients. Length k for time-invariant model,
807
+ or k*J for time-varying coefficients.
808
+ X_c : np.ndarray, shape (n, k)
809
+ Design matrix including intercept.
810
+ treat_vec : np.ndarray, shape (n,)
811
+ Binary treatment indicators.
812
+ time_vec : np.ndarray, shape (n,)
813
+ Time period indicators.
814
+ id_vec : np.ndarray, optional
815
+ Unit identifiers (reserved for future use).
816
+ name_cands : np.ndarray, optional
817
+ Unique unit identifiers for determining sample size N.
818
+ bal_only : bool, default=False
819
+ If True, exclude propensity score moment conditions.
820
+ time_sub : int, optional
821
+ Number of initial periods to exclude from constraints. Default 0.
822
+ twostep : bool, default=False
823
+ Use pre-computed weighting matrix (two-step GMM).
824
+ Vcov_inv : np.ndarray, optional
825
+ Pre-computed inverse weighting matrix for two-step estimation.
826
+ full_var : bool, default=False
827
+ Use full (non-approximated) covariance matrix.
828
+
829
+ Returns
830
+ -------
831
+ dict
832
+ ``'loss'``: Scalar GMM criterion value.
833
+ ``'Var.inv'``: Inverse weighting matrix.
834
+ ``'probs'``: Propensity scores, shape (N, J).
835
+ ``'w_all'``: Cross-period weight products, shape (N,).
836
+ ``'g.all'``: Stacked moment conditions.
837
+ """
838
+ k0 = X_c.shape[1]
839
+ time0 = time_vec - np.min(time_vec) + 1
840
+ uniq_t = np.array(sorted(pd.unique(time0)))
841
+ n_t = len(uniq_t)
842
+ if time_sub is None:
843
+ # Default: time_sub = 0 (all i>0 constraints active, including first period)
844
+ time_sub = 0
845
+ # Process betas: single set or one per period
846
+ if betas.size == k0:
847
+ beta_mat = np.tile(betas.reshape(k0, 1), (1, n_t))
848
+ else:
849
+ beta_mat = np.asarray(betas, dtype=float).reshape((n_t, k0)).T # (k0, n_t)
850
+
851
+ # N = number of individuals (defined by name_cands); otherwise inferred from data
852
+ if name_cands is not None:
853
+ N = len(name_cands)
854
+ else:
855
+ N = (X_c.shape[0] // len(uniq_t))
856
+ n = N
857
+
858
+ # Reshape to N×J (maintain original row order within time==i subset)
859
+ # Keep data in original order (already sorted by time)
860
+ treat_mat = []
861
+ X_blocks = []
862
+
863
+ for i, t in enumerate(uniq_t, start=1):
864
+ idx = (time0 == t)
865
+ X_sub = X_c[idx, :]
866
+ y_sub = treat_vec[idx].astype(float)
867
+ treat_mat.append(y_sub)
868
+ X_blocks.append(X_sub)
869
+
870
+ treat_mat = np.column_stack(treat_mat) # (N, n_t)
871
+
872
+ # thetas & probs (strict two-step clipping: lower bound first, then upper bound)
873
+ thetas = np.column_stack([X_blocks[j] @ beta_mat[:, j] for j in range(n_t)])
874
+ probs = 1.0 / (1.0 + np.exp(-thetas))
875
+ probs = np.maximum(probs, PROBS_TRIM)
876
+ probs = np.minimum(probs, 1.0 - PROBS_TRIM)
877
+ probs_obs = treat_mat * probs + (1.0 - treat_mat) * (1.0 - probs)
878
+
879
+ # Cross-period weight product w_all (unit-level)
880
+ w_each = treat_mat / probs + (1.0 - treat_mat) / (1.0 - probs)
881
+ w_all = np.prod(w_each, axis=1)
882
+
883
+ # =========================================================================
884
+ # Hadamard Matrix Construction for 2^J Factorial Treatment Sequences
885
+ # =========================================================================
886
+ #
887
+ # Theory (Imai & Ratkovic, 2015, Eq. 18-22):
888
+ # ------------------------------------------
889
+ # With J binary treatment periods, there are 2^J possible treatment
890
+ # sequences. The Hadamard matrix H of order 2^J provides an orthogonal
891
+ # encoding of these sequences. Specifically:
892
+ #
893
+ # H is a (2^J × 2^J) matrix satisfying H'H = 2^J · I
894
+ #
895
+ # Each row of H (excluding the all-ones first row) corresponds to a
896
+ # "contrast" across treatment sequences. The binary matrix bin_mat below
897
+ # encodes these contrasts: row i represents the binary expansion of
898
+ # integer i (i = 1, ..., 2^J - 1), and the sign assigned to each unit
899
+ # is (-1)^(T · bin_mat[i,:]), where T is the unit's observed treatment
900
+ # history (Eq. 19).
901
+ #
902
+ # Selection Matrix R (Eq. 20-21):
903
+ # --------------------------------
904
+ # Not all 2^J - 1 contrasts yield informative moment conditions at every
905
+ # time period j. A contrast row b is "valid" at period j only if
906
+ # sum(bin_mat[b, j:]) > 0, meaning it involves at least one treatment
907
+ # assignment from period j onward. This implements the selection matrix R
908
+ # that reduces the full Hadamard contrasts to the subset relevant for
909
+ # each period's balancing constraints (Eq. 21).
910
+ #
911
+ # The resulting constr_outer matrix stacks the valid weighted contrasts
912
+ # across all periods, forming the moment condition matrix g(theta) whose
913
+ # expectation equals zero under correct specification (Eq. 22).
914
+ # =========================================================================
915
+
916
+ # Construct binary matrix bin_mat (2^J-1 × J)
917
+ # Fills from right to left in Yates order, producing:
918
+ # i=1: [0,0,1], i=2: [0,1,0], i=3: [0,1,1], i=4: [1,0,0], etc.
919
+ # Column j corresponds to time period j (1-indexed in R, 0-indexed here)
920
+ n_bins = 2 ** n_t - 1
921
+ bin_mat = np.zeros((n_bins, n_t), dtype=int)
922
+ for i in range(1, n_bins + 1):
923
+ bits = _integer_base_b(i, 2)
924
+ bin_mat[i - 1, (n_t - len(bits)):] = bits
925
+
926
+ # NOTE: Do NOT reverse column order - R's bin.mat is already in correct order
927
+ # Column 0 corresponds to time 1, column 1 to time 2, etc.
928
+ bin_mat_time = bin_mat
929
+
930
+ # Construct constr_outer and num_valid counts per period (keep fixed n_bins columns; invalid columns set to 0)
931
+ constr_blocks = []
932
+ num_valid_counts = [] # Number of valid columns (one scalar per period)
933
+ num_valid_outer_seq: list[int] = [] # num_valid_outer: append n-length constant vectors by time
934
+ first_valid_per_time: list[int] = [] # First valid column index per period
935
+ for j in range(n_t): # j: 0..n_t-1, corresponds to time 1..n_t
936
+ constr = np.zeros((n, n_bins), dtype=float)
937
+ valid_count = 0
938
+ first_valid = None
939
+ for b in range(n_bins):
940
+ is_valid = np.sum(bin_mat_time[b, j:]) > 0
941
+ if is_valid:
942
+ if first_valid is None:
943
+ first_valid = b
944
+ expo = (treat_mat @ bin_mat_time[b, :]).astype(int)
945
+ sign = (-1.0) ** expo
946
+ constr[:, b] = w_all * sign
947
+ valid_count += 1
948
+ else:
949
+ constr[:, b] = 0.0
950
+ num_valid_counts.append(valid_count)
951
+ num_valid_outer_seq.extend([valid_count] * n)
952
+ first_valid_per_time.append(0 if first_valid is None else first_valid)
953
+ constr_blocks.append(constr)
954
+ constr_outer = np.vstack(constr_blocks) # (n*n_t, n_bins)
955
+
956
+ # Retain binary natural order (1 through 2^J-1) for column indexing
957
+
958
+ # g and design weight matrices
959
+ g_prop_list = []
960
+ g_wt_list = []
961
+ X_prop_list = []
962
+ X_wt_list = []
963
+ # Deduplicate in order of appearance, then take value per period
964
+ uniq_seq: list[int] = []
965
+ for val in num_valid_outer_seq:
966
+ if val not in uniq_seq:
967
+ uniq_seq.append(val)
968
+ scale_per_time = [uniq_seq[j] if j < len(uniq_seq) else num_valid_counts[j] for j in range(n_t)]
969
+
970
+ for j in range(n_t):
971
+ Xj = X_blocks[j]
972
+ gj = (1.0 / n) * (Xj.T @ (treat_mat[:, j] - probs[:, j])) # (k0,)
973
+ g_prop_list.append(gj)
974
+ rows = slice(j * n, (j + 1) * n)
975
+ weight_mat = constr_outer[rows, :] * float((j + 1) > time_sub)
976
+ g_wt_list.append((1.0 / n) * (Xj.T @ weight_mat)) # (k0, n_bins)
977
+ X_prop_list.append((1.0 / (n ** 0.5)) * (Xj * np.sqrt(probs_obs[:, j] * (1.0 - probs_obs[:, j]))[:, None]).T)
978
+ if bal_only:
979
+ X_wt_list.append((1.0 / (n ** 0.5)) * (Xj * (float(scale_per_time[j]) ** 0.5)) .T)
980
+ else:
981
+ X_wt_list.append((1.0 / (n ** 0.5)) * (Xj * (np.sqrt(w_all) * (float(scale_per_time[j]) ** 0.5))[:, None]).T)
982
+
983
+ g_prop = np.concatenate(g_prop_list, axis=0) # (k0*n_t,)
984
+ g_wt = np.vstack(g_wt_list) # (k0*n_t, n_bins)
985
+ X_prop = np.vstack(X_prop_list) # (k0*n_t, n)
986
+ X_wt = np.vstack(X_wt_list) # (k0*n_t, n)
987
+
988
+ # Force first column as g.prop alignment column, other columns keep reordered sequence
989
+ if g_wt.shape[1] >= 1:
990
+ g_prop_all = np.zeros_like(g_wt)
991
+ g_prop_all[:, 0] = g_prop
992
+ else:
993
+ g_prop_all = np.zeros_like(g_wt)
994
+ if bal_only:
995
+ g_prop_all[:] = 0.0
996
+ g_all = np.vstack([g_prop_all, g_wt]) # ((2*k0*n_t) × n_bins)
997
+ X_all = np.vstack([X_prop * (1.0 - float(bal_only)), X_wt])
998
+
999
+ # Inverse variance matrix
1000
+ if twostep:
1001
+ if Vcov_inv is None:
1002
+ raise ValueError("Vcov_inv must be provided when twostep=True")
1003
+ var_X_inv = Vcov_inv
1004
+ else:
1005
+ if not full_var:
1006
+ var_X_inv = _r_ginv(X_all @ X_all.T)
1007
+ else:
1008
+ # Construct full variance var_big (Kronecker sum), then vectorize g_all
1009
+ # X_t_big: stack X_t repeatedly (same for each period)
1010
+ X_t = np.hstack(X_blocks) # (n, k0*n_t)
1011
+ X_t_big = np.vstack([X_t for _ in range(n_t)])
1012
+ var_big = None
1013
+ for i in range(X_t_big.shape[0]):
1014
+ mat1 = np.outer(X_t_big[i, :], X_t_big[i, :])
1015
+ vrow = constr_outer[i, :]
1016
+ mat2 = np.outer(vrow, vrow)
1017
+ kron = np.kron(mat2, mat1)
1018
+ var_big = kron if var_big is None else (var_big + kron)
1019
+ var_X_inv = _r_ginv(var_big / n)
1020
+
1021
+ # full_var=True: keep only weighted balance conditions (vectorized)
1022
+ if full_var:
1023
+ g_use = g_wt.reshape(-1, order='F') # Column-major vectorization
1024
+ var_mat = var_X_inv
1025
+ loss_mat = (g_use.T @ var_mat @ g_use)
1026
+ loss_scalar = float(loss_mat) * n
1027
+ return {"loss": loss_scalar, "Var.inv": var_X_inv, "probs": probs, "w_all": w_all, "g.all": g_use}
1028
+
1029
+ # Objective: tr(g' V g) * n
1030
+ prod_mat = g_all.T @ var_X_inv @ g_all
1031
+ loss_scalar = float(np.trace(prod_mat)) * n
1032
+ return {"loss": loss_scalar, "Var.inv": var_X_inv, "probs": probs, "w_all": w_all, "g.all": g_all}
1033
+
1034
+ def cbmsm_fit(
1035
+ treat: np.ndarray,
1036
+ X: np.ndarray,
1037
+ id: np.ndarray,
1038
+ time: np.ndarray,
1039
+ type: str = "MSM",
1040
+ twostep: bool = True,
1041
+ msm_variance: str = "approx",
1042
+ time_vary: bool = False,
1043
+ init: str = "opt",
1044
+ sample_weights: Optional[np.ndarray] = None,
1045
+ iterations: Optional[int] = None,
1046
+ **kwargs: Any,
1047
+ ) -> CBMSMResults:
1048
+ """Fit CBMSM using the matrix interface.
1049
+
1050
+ Low-level function accepting preprocessed numpy arrays. For most users,
1051
+ the formula interface ``CBMSM()`` is recommended.
1052
+
1053
+ Parameters
1054
+ ----------
1055
+ treat : np.ndarray, shape (n,)
1056
+ Binary treatment vector for N units over J time periods.
1057
+ X : np.ndarray, shape (n, k)
1058
+ Design matrix including intercept column.
1059
+ id : np.ndarray, shape (n,)
1060
+ Unit identifiers.
1061
+ time : np.ndarray, shape (n,)
1062
+ Time period indicators.
1063
+ type : {'MSM', 'MultiBin'}, default='MSM'
1064
+ ``'MSM'`` for marginal structural models (longitudinal),
1065
+ ``'MultiBin'`` for multiple binary treatments (cross-sectional).
1066
+ twostep : bool, default=True
1067
+ Use two-step GMM (faster) vs. continuous updating.
1068
+ msm_variance : {'approx', 'full'}, default='approx'
1069
+ ``'approx'`` uses low-rank approximation (Imai and Ratkovic, 2015,
1070
+ equation 27). ``'full'`` uses complete covariance (slower).
1071
+ time_vary : bool, default=False
1072
+ Estimate separate coefficients for each time period.
1073
+ init : {'opt', 'glm', 'CBPS'}, default='opt'
1074
+ Initialization method. ``'opt'`` selects the better of GLM and CBPS.
1075
+ sample_weights : np.ndarray, optional
1076
+ Observation weights (not currently used).
1077
+ iterations : int, optional
1078
+ Maximum optimization iterations.
1079
+ **kwargs
1080
+ Additional arguments (reserved).
1081
+
1082
+ Returns
1083
+ -------
1084
+ CBMSMResults
1085
+ Fitted model with weights, coefficients, and diagnostics.
1086
+
1087
+ Notes
1088
+ -----
1089
+ Data must form a balanced panel: each unit appears exactly once per
1090
+ time period. The function automatically sorts by (time, id) internally.
1091
+
1092
+ References
1093
+ ----------
1094
+ Imai, K. and Ratkovic, M. (2015). Robust estimation of inverse probability
1095
+ weights for marginal structural models. Journal of the American Statistical
1096
+ Association, 110(511), 1013-1023.
1097
+
1098
+ See Also
1099
+ --------
1100
+ CBMSM : Formula interface (recommended).
1101
+
1102
+ Examples
1103
+ --------
1104
+ >>> import numpy as np
1105
+ >>> from cbps import cbmsm_fit
1106
+ >>> # 10 units, 3 time periods
1107
+ >>> n, J = 10, 3
1108
+ >>> treat = np.random.binomial(1, 0.5, n * J)
1109
+ >>> X = np.column_stack([np.ones(n * J), np.random.randn(n * J, 2)])
1110
+ >>> id_vec = np.tile(np.arange(n), J)
1111
+ >>> time_vec = np.repeat(np.arange(J), n)
1112
+ >>> result = cbmsm_fit(treat, X, id_vec, time_vec)
1113
+ >>> print(result.converged)
1114
+ """
1115
+ # Parameter validation
1116
+ valid_types = {'MSM', 'MultiBin'}
1117
+ if type not in valid_types:
1118
+ raise ValueError(
1119
+ f"Invalid type='{type}'. Must be one of {valid_types}. "
1120
+ f"Use 'MSM' for marginal structural models or 'MultiBin' for multiple binary treatments."
1121
+ )
1122
+
1123
+ valid_inits = {'glm', 'opt', 'CBPS'}
1124
+ if init not in valid_inits:
1125
+ raise ValueError(
1126
+ f"Invalid init='{init}'. Must be one of {valid_inits}. "
1127
+ f"Use 'glm' for GLM initialization, 'CBPS' for CBPS initialization, "
1128
+ f"or 'opt' for choosing the best between CBPS and GLM (recommended)."
1129
+ )
1130
+
1131
+ valid_variances = {'approx', 'full', None}
1132
+ if msm_variance not in valid_variances:
1133
+ raise ValueError(
1134
+ f"Invalid msm_variance='{msm_variance}'. "
1135
+ f"Must be one of {{'approx', 'full', None}}. "
1136
+ f"Use 'approx' for low-rank approximation (recommended) or 'full' for full variance matrix."
1137
+ )
1138
+
1139
+ # Convert to numpy arrays
1140
+ treat = np.asarray(treat).ravel()
1141
+ X = np.asarray(X)
1142
+ id_arr = np.asarray(id).ravel()
1143
+ time_arr = np.asarray(time).ravel()
1144
+
1145
+ # Validate dimensions
1146
+ n_obs = len(treat)
1147
+ if X.shape[0] != n_obs:
1148
+ raise ValueError(f"X.shape[0]={X.shape[0]} must equal len(treat)={n_obs}")
1149
+ if len(id_arr) != n_obs:
1150
+ raise ValueError(f"len(id)={len(id_arr)} must equal len(treat)={n_obs}")
1151
+ if len(time_arr) != n_obs:
1152
+ raise ValueError(f"len(time)={len(time_arr)} must equal len(treat)={n_obs}")
1153
+
1154
+ # Zero-variance column filtering
1155
+ std = X.std(axis=0, ddof=1)
1156
+ X_mat = X[:, std > 0]
1157
+
1158
+ # Sort by time in ascending order
1159
+ id_sorted, time_sorted, y_sorted, X_sorted, order = _sort_by_time(id_arr, time_arr, treat, X_mat)
1160
+
1161
+ # Balanced panel check
1162
+ name_cands_check = np.array(sorted(pd.unique(id_sorted)))
1163
+ unique_time_check = np.array(sorted(pd.unique(time_sorted)))
1164
+ N_expected = len(name_cands_check)
1165
+ J_expected = len(unique_time_check)
1166
+ N_total_expected = N_expected * J_expected
1167
+ N_actual = len(id_sorted)
1168
+
1169
+ if N_actual != N_total_expected:
1170
+ id_time_counts = pd.DataFrame({
1171
+ 'id': id_sorted,
1172
+ 'time': time_sorted
1173
+ }).groupby(['id', 'time']).size()
1174
+
1175
+ all_combinations = pd.MultiIndex.from_product(
1176
+ [name_cands_check, unique_time_check],
1177
+ names=['id', 'time']
1178
+ )
1179
+ missing = all_combinations.difference(id_time_counts.index)
1180
+ duplicates = id_time_counts[id_time_counts > 1]
1181
+
1182
+ error_msg = (
1183
+ f"CBMSM requires a balanced panel: each id must appear exactly once at each time point.\n"
1184
+ f"Expected {N_expected} units × {J_expected} time periods = {N_total_expected} observations, "
1185
+ f"but found {N_actual} observations.\n"
1186
+ )
1187
+
1188
+ if len(missing) > 0:
1189
+ missing_sample = missing[:5].tolist()
1190
+ error_msg += f"\nMissing {len(missing)} id×time combinations (showing first 5): {missing_sample}"
1191
+
1192
+ if len(duplicates) > 0:
1193
+ dup_sample = duplicates.head(5).to_dict()
1194
+ error_msg += f"\nDuplicate id×time combinations (showing first 5): {dup_sample}"
1195
+
1196
+ raise ValueError(error_msg)
1197
+
1198
+ # Verify at least 2 individuals and 2 time periods
1199
+ if N_expected < 2:
1200
+ raise ValueError(
1201
+ f"CBMSM requires at least 2 individuals (units), but found only {N_expected} unique id."
1202
+ )
1203
+
1204
+ if J_expected < 2:
1205
+ raise ValueError(
1206
+ f"CBMSM requires at least 2 time periods, but found only {J_expected} unique time value."
1207
+ )
1208
+
1209
+ # Build treatment history matrix
1210
+ treat_hist, name_cands, unique_time = _build_treat_hist(y_sorted, id_sorted, time_sorted)
1211
+ treat_cum = treat_hist.sum(axis=1)
1212
+
1213
+ # Design matrix: MSM uses SVD boolean truncation; MultiBin uses zero-variance removal + intercept
1214
+ if type == "MultiBin":
1215
+ X_nozero = X_sorted[:, 1:] if X_sorted.shape[1] > 1 else X_sorted[:, :0]
1216
+ std2 = X_nozero.std(axis=0, ddof=1)
1217
+ X_nz = X_nozero[:, std2 > 0]
1218
+ X_c = np.column_stack([np.ones(X_sorted.shape[0]), X_nz])
1219
+ else:
1220
+ if not time_vary:
1221
+ X_svd = _svd_boolean_trunc_global(X_sorted)
1222
+ else:
1223
+ X_svd = _svd_boolean_trunc_timevary(X_sorted, time_sorted, unique_time)
1224
+ X_c = np.column_stack([np.ones(X_svd.shape[0]), X_svd]) if X_svd.size else np.ones((X_sorted.shape[0], 1))
1225
+
1226
+ # Determine full variance matrix computation mode
1227
+ full_var = (msm_variance == "full")
1228
+
1229
+ # Starting points: GLM and CBPS
1230
+ if not time_vary:
1231
+ glm_model = sm.GLM(y_sorted, X_c, family=Binomial())
1232
+ glm_fit_res = glm_model.fit(maxiter=50, tol=1e-8)
1233
+ glm1 = glm_fit_res.params.astype(float)
1234
+ cbps_res = cbps_binary_fit(
1235
+ treat=y_sorted,
1236
+ X=X_c,
1237
+ att=0,
1238
+ method='exact',
1239
+ two_step=True,
1240
+ standardize=True,
1241
+ iterations=200,
1242
+ )
1243
+ glm_cb = cbps_res['coefficients'].ravel()
1244
+ else:
1245
+ # Time-varying: per-period GLM and CBPS, vectorized
1246
+ glm_cols = []
1247
+ cbps_cols = []
1248
+ for t in unique_time:
1249
+ mask = (time_sorted == t)
1250
+ X_sub = X_c[mask, :]
1251
+ y_sub = y_sorted[mask]
1252
+ glm_sub = sm.GLM(y_sub, X_sub, family=Binomial()).fit(maxiter=50, tol=1e-8).params
1253
+ cbps_sub = cbps_binary_fit(
1254
+ treat=y_sub,
1255
+ X=X_sub,
1256
+ att=0,
1257
+ method='exact',
1258
+ two_step=True,
1259
+ standardize=True,
1260
+ iterations=200,
1261
+ )['coefficients'].ravel()
1262
+ glm_cols.append(glm_sub)
1263
+ cbps_cols.append(cbps_sub)
1264
+ glm_mat = np.column_stack(glm_cols)
1265
+ cbps_mat = np.column_stack(cbps_cols)
1266
+ glm_mat[np.isnan(glm_mat)] = 0.0
1267
+ cbps_mat[np.isnan(cbps_mat)] = 0.0
1268
+ glm1 = glm_mat.ravel(order='F')
1269
+ glm_cb = cbps_mat.ravel(order='F')
1270
+
1271
+ # Select best: compare initial loss in twostep=FALSE setting
1272
+ glm_fit_dict = msm_loss_func(glm1, X_c, y_sorted, time_sorted, id_vec=id_sorted, name_cands=name_cands, twostep=False, full_var=full_var)
1273
+ cb_fit_dict = msm_loss_func(glm_cb, X_c, y_sorted, time_sorted, id_vec=id_sorted, name_cands=name_cands, twostep=False, full_var=full_var)
1274
+ loss_glm = glm_fit_dict['loss']
1275
+ loss_cb = cb_fit_dict['loss']
1276
+ type_fit = "Returning Estimates from Logistic Regression\n"
1277
+ if ((loss_cb < loss_glm and init == 'opt') or init == 'CBPS'):
1278
+ init_betas = glm_cb
1279
+ init_fit = cb_fit_dict
1280
+ type_fit = "Returning Estimates from CBPS\n"
1281
+ else:
1282
+ init_betas = glm1
1283
+ init_fit = glm_fit_dict
1284
+
1285
+ # R always runs twostep first as warm start, then optionally runs
1286
+ # continuous updating. This two-stage strategy is standard GMM practice.
1287
+ glm_fit0 = msm_loss_func(init_betas, X_c, y_sorted, time_sorted, id_vec=id_sorted, name_cands=name_cands, twostep=False, full_var=full_var)
1288
+ Vcov_inv = glm_fit0['Var.inv'] if 'Var.inv' in glm_fit0 else None
1289
+ if Vcov_inv is None:
1290
+ Vcov_inv = _r_ginv(X_c.T @ X_c)
1291
+
1292
+ # Stage 1: Always run twostep optimization (warm start)
1293
+ def twostep_loss(b: np.ndarray) -> float:
1294
+ return msm_loss_func(b, X_c, y_sorted, time_sorted, id_vec=id_sorted, name_cands=name_cands, bal_only=True, twostep=True, Vcov_inv=Vcov_inv, full_var=full_var)['loss']
1295
+ opt1 = scipy.optimize.minimize(
1296
+ twostep_loss, init_betas, method='BFGS', options={'maxiter': iterations or 500}
1297
+ )
1298
+ msm_fit = msm_loss_func(opt1.x, X_c, y_sorted, time_sorted, id_vec=id_sorted, name_cands=name_cands, bal_only=True, twostep=True, Vcov_inv=Vcov_inv, full_var=full_var)
1299
+ l3 = msm_loss_func(init_betas, X_c, y_sorted, time_sorted, id_vec=id_sorted, name_cands=name_cands, bal_only=True, twostep=True, Vcov_inv=Vcov_inv, full_var=full_var)
1300
+ if (l3['loss'] < msm_fit['loss']) and (init == 'opt'):
1301
+ msm_fit = l3
1302
+ warnings.warn("Warning: Optimization did not improve over initial estimates\n")
1303
+ print(type_fit, end='')
1304
+
1305
+ # Stage 2: If continuous updating requested, refine from twostep result
1306
+ if not twostep:
1307
+ def cont_loss(b: np.ndarray) -> float:
1308
+ return msm_loss_func(b, X_c, y_sorted, time_sorted, id_vec=id_sorted, name_cands=name_cands, bal_only=True, twostep=False, Vcov_inv=None, full_var=full_var)['loss']
1309
+ # Use twostep result as starting point (warm start)
1310
+ cont_start = opt1.x
1311
+ opt1 = scipy.optimize.minimize(
1312
+ cont_loss, cont_start, method='BFGS', options={'maxiter': iterations or 500}
1313
+ )
1314
+ msm_fit = msm_loss_func(opt1.x, X_c, y_sorted, time_sorted, id_vec=id_sorted, name_cands=name_cands, bal_only=True, twostep=False, Vcov_inv=None, full_var=full_var)
1315
+ l3 = msm_loss_func(init_betas, X_c, y_sorted, time_sorted, id_vec=id_sorted, name_cands=name_cands, bal_only=True, twostep=False, Vcov_inv=None, full_var=full_var)
1316
+ if (l3['loss'] < msm_fit['loss']) and (init == 'opt'):
1317
+ msm_fit = l3
1318
+ print("\nWarning: Optimization did not improve over initial estimates\n", end='')
1319
+ print(type_fit, end='')
1320
+
1321
+ # Unconditional frequencies and final weights (first period subset)
1322
+ n_obs = len(np.unique(id_sorted))
1323
+ n_time = len(np.unique(time_sorted))
1324
+ treat_hist2, name_cands2, uniq_t2 = _build_treat_hist(y_sorted, id_sorted, time_sorted)
1325
+ rows_as_str = ["|".join(map(str, row.tolist())) for row in treat_hist2]
1326
+ uniq_rows, counts = np.unique(rows_as_str, return_counts=True)
1327
+ freq_map = {u: c / n_obs for u, c in zip(uniq_rows, counts)}
1328
+ uncond_probs = np.array([freq_map[s] for s in rows_as_str])
1329
+
1330
+ # w_all = ∏_j 1/P(T_j|X_j) = 1/P(T̄|X̄) (unstabilized IPW)
1331
+ # fitted_values = uncond_probs / w_all = P(T̄) × P(T̄|X̄)
1332
+ # weights = w_all = 1/P(T̄|X̄)
1333
+ # The fitted_values are used as WLS weights for outcome regression
1334
+ # (reproduces Imai & Ratkovic 2015, Table 3).
1335
+ probs_out = msm_fit['w_all']
1336
+ wts_out_all = np.tile(uncond_probs / probs_out, n_time)
1337
+ wts_out = wts_out_all[time_sorted == np.min(time_sorted)]
1338
+
1339
+ glm_weights_all = np.tile(uncond_probs / glm_fit_dict['w_all'], n_time)
1340
+ glm_weights = glm_weights_all[time_sorted == np.min(time_sorted)]
1341
+
1342
+ # Compute loss values
1343
+ loss_glm_final = glm_fit_dict['loss']
1344
+ loss_msm_final = msm_fit['loss']
1345
+
1346
+ # Compute diagnostics
1347
+ final_coefficients = opt1.x if 'opt1' in locals() else init_betas
1348
+
1349
+ if time_vary:
1350
+ k = X_c.shape[1]
1351
+ n_periods = len(unique_time)
1352
+ final_coefficients = final_coefficients.reshape(k, n_periods, order='F')
1353
+
1354
+ J_statistic = loss_msm_final / n_obs
1355
+ var_matrix = msm_fit.get('Var.inv', Vcov_inv)
1356
+ if var_matrix is None:
1357
+ var_matrix = np.array([[]])
1358
+
1359
+ converged_status = opt1.success if 'opt1' in locals() else True
1360
+
1361
+ # Assemble return object
1362
+ out = CBMSMResults(
1363
+ weights=probs_out,
1364
+ fitted_values=wts_out,
1365
+ glm_g=glm_fit_dict['g.all'],
1366
+ msm_g=msm_fit['g.all'],
1367
+ glm_weights=glm_weights,
1368
+ id=id_sorted,
1369
+ treat_hist=treat_hist,
1370
+ treat_cum=treat_cum,
1371
+ coefficients=final_coefficients,
1372
+ n_periods=n_time,
1373
+ n_units=n_obs,
1374
+ time_vary=time_vary,
1375
+ converged=converged_status,
1376
+ J=J_statistic,
1377
+ var=var_matrix,
1378
+ call=f"cbmsm_fit(type='{type}', twostep={twostep}, msm_variance='{msm_variance}', time_vary={time_vary}, init='{init}')",
1379
+ formula=None, # Matrix interface has no formula
1380
+ y=y_sorted,
1381
+ x=X_sorted,
1382
+ time=time_sorted,
1383
+ model=None, # Matrix interface has no model frame
1384
+ data=None, # Matrix interface has no original data
1385
+ )
1386
+
1387
+ # Final override with first period subset
1388
+ mask_first = (time_sorted == np.min(time_sorted))
1389
+ if out.weights.shape[0] == time_sorted.shape[0]:
1390
+ out.weights = out.weights[mask_first]
1391
+
1392
+ # Warning
1393
+ if loss_glm_final < loss_msm_final:
1394
+ warnings.warn(
1395
+ f"CBMSM fails to improve covariate balance relative to MLE. \n GLM loss: {loss_glm_final} \n CBMSM loss: {loss_msm_final} \n"
1396
+ )
1397
+
1398
+ return out
1399
+
1400
+
1401
+ def CBMSM(
1402
+ formula: str,
1403
+ id: str | pd.Series | np.ndarray,
1404
+ time: str | pd.Series | np.ndarray,
1405
+ data: pd.DataFrame,
1406
+ type: str = "MSM",
1407
+ twostep: bool = True,
1408
+ msm_variance: str = "approx",
1409
+ time_vary: bool = False,
1410
+ init: str = "opt",
1411
+ iterations: Optional[int] = None,
1412
+ **kwargs: Any,
1413
+ ) -> CBMSMResults:
1414
+ """Covariate Balancing Propensity Score for Marginal Structural Models.
1415
+
1416
+ Estimates inverse probability weights for longitudinal causal inference
1417
+ by jointly optimizing treatment prediction and covariate balance across
1418
+ all time periods. The method is robust to treatment model misspecification
1419
+ because it directly targets the covariate balancing property of IPW.
1420
+
1421
+ Parameters
1422
+ ----------
1423
+ formula : str
1424
+ Model specification in Patsy format, e.g., ``"treat ~ x1 + x2"``.
1425
+ The same formula applies to all time periods.
1426
+ id : str or array-like
1427
+ Unit (individual) identifiers. Either a column name in ``data``
1428
+ or an array of length n.
1429
+ time : str or array-like
1430
+ Time period indicators. Either a column name or an array.
1431
+ data : pd.DataFrame
1432
+ Panel data containing all model variables.
1433
+ type : {'MSM', 'MultiBin'}, default='MSM'
1434
+ ``'MSM'`` for longitudinal treatment sequences, ``'MultiBin'``
1435
+ for multiple simultaneous binary treatments.
1436
+ twostep : bool, default=True
1437
+ Use two-step GMM estimator (faster, recommended) or continuous
1438
+ updating GMM.
1439
+ msm_variance : {'approx', 'full'}, default='approx'
1440
+ Covariance approximation method. ``'approx'`` uses the low-rank
1441
+ approximation from Imai and Ratkovic (2015, eq. 27), which assumes
1442
+ zero correlation across balance conditions. ``'full'`` computes
1443
+ the complete covariance but is computationally expensive.
1444
+ time_vary : bool, default=False
1445
+ If True, estimate separate propensity score coefficients for each
1446
+ time period. Default shares coefficients across periods.
1447
+ init : {'opt', 'glm', 'CBPS'}, default='opt'
1448
+ Starting value selection. ``'opt'`` tries both GLM and CBPS
1449
+ initializations and selects the one with better balance.
1450
+ iterations : int, optional
1451
+ Maximum optimization iterations.
1452
+ **kwargs
1453
+ Additional arguments (reserved for future use).
1454
+
1455
+ Returns
1456
+ -------
1457
+ CBMSMResults
1458
+ Fitted model object containing:
1459
+
1460
+ - ``weights``: Inverse probability weights 1/P(T|X)
1461
+ - ``fitted_values``: Stabilized MSM weights P(T)/P(T|X)
1462
+ - ``coefficients``: Estimated propensity score parameters
1463
+ - ``treat_hist``: Treatment history matrix (N × J)
1464
+ - ``converged``: Optimization convergence indicator
1465
+ - ``J``: Hansen J-statistic for model diagnostics
1466
+
1467
+ Notes
1468
+ -----
1469
+ The estimator solves a GMM problem with moment conditions derived from
1470
+ the covariate balancing property of MSM weights. At time period j,
1471
+ the weights should balance covariates across all 2^{J-j+1} possible
1472
+ current and future treatment sequences, conditional on past treatment.
1473
+
1474
+ The ``fitted_values`` (stabilized weights) are recommended for outcome
1475
+ regression to reduce variance. These incorporate the marginal treatment
1476
+ probability in the numerator: w* = P(T) / P(T|X).
1477
+
1478
+ Data must form a balanced panel where each unit appears exactly once
1479
+ at each time period.
1480
+
1481
+ References
1482
+ ----------
1483
+ Imai, K. and Ratkovic, M. (2015). Robust estimation of inverse probability
1484
+ weights for marginal structural models. Journal of the American Statistical
1485
+ Association, 110(511), 1013-1023.
1486
+
1487
+ See Also
1488
+ --------
1489
+ cbmsm_fit : Matrix interface for advanced users.
1490
+ CBPS : Cross-sectional propensity score estimation.
1491
+
1492
+ Examples
1493
+ --------
1494
+ >>> from cbps import CBMSM
1495
+ >>> from cbps.datasets import load_blackwell
1496
+ >>> blackwell = load_blackwell()
1497
+ >>> fit = CBMSM(
1498
+ ... formula="d.gone.neg ~ d.gone.neg.l1 + camp.length",
1499
+ ... id="demName",
1500
+ ... time="time",
1501
+ ... data=blackwell,
1502
+ ... )
1503
+ >>> # Use stabilized weights for outcome analysis
1504
+ >>> import statsmodels.api as sm
1505
+ >>> y = blackwell.groupby("demName")["demprcnt"].last()
1506
+ >>> # ... weighted outcome regression with fit.fitted_values
1507
+ """
1508
+ # Parameter validation
1509
+
1510
+ # Validate formula parameter
1511
+ if not isinstance(formula, str):
1512
+ raise TypeError(
1513
+ f"formula must be a string, got {type(formula).__name__}. "
1514
+ f"CBMSM uses a single formula applied to all time periods. "
1515
+ f"Example: 'treat ~ x1 + x2'. "
1516
+ f"Set time_vary=True for period-specific coefficients."
1517
+ )
1518
+
1519
+ # Validate type parameter
1520
+ valid_types = {'MSM', 'MultiBin'}
1521
+ if type not in valid_types:
1522
+ raise ValueError(
1523
+ f"Invalid type='{type}'. Must be one of {valid_types}. "
1524
+ f"Note: parameter values are case-sensitive. "
1525
+ f"Use 'MSM' for marginal structural models or 'MultiBin' for multiple binary treatments."
1526
+ )
1527
+
1528
+ # Validate init parameter
1529
+ valid_inits = {'glm', 'opt', 'CBPS'}
1530
+ if init not in valid_inits:
1531
+ raise ValueError(
1532
+ f"Invalid init='{init}'. Must be one of {valid_inits}. "
1533
+ f"Use 'glm' for GLM initialization, 'CBPS' for CBPS initialization, "
1534
+ f"or 'opt' for choosing the best between CBPS and GLM (recommended)."
1535
+ )
1536
+
1537
+ # Validate msm_variance parameter
1538
+ valid_variances = {'approx', 'full', None}
1539
+ if msm_variance not in valid_variances:
1540
+ raise ValueError(
1541
+ f"Invalid msm_variance='{msm_variance}'. "
1542
+ f"Must be one of {{'approx', 'full', None}}. "
1543
+ f"Use 'approx' for low-rank approximation (recommended) or 'full' for full variance matrix."
1544
+ )
1545
+
1546
+ # Preprocessing: auto-quote column names with special characters for patsy compatibility
1547
+ def _quote_formula_variables(formula_str: str, columns: pd.Index) -> str:
1548
+ # Only quote column names containing non-alphanumeric characters (except underscore)
1549
+ special_cols = [c for c in columns if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", str(c))]
1550
+ # Sort by length descending to avoid prefix replacement issues
1551
+ special_cols.sort(key=lambda s: len(str(s)), reverse=True)
1552
+ out = formula_str
1553
+ for col in special_cols:
1554
+ name = re.escape(str(col))
1555
+ pattern = rf"(?<![A-Za-z0-9_\"]){name}(?![A-Za-z0-9_\"])"
1556
+ out = re.sub(pattern, f'Q("{str(col)}")', out)
1557
+ return out
1558
+
1559
+ safe_formula = _quote_formula_variables(formula, data.columns)
1560
+
1561
+ # Save original data
1562
+ data_original = data.copy()
1563
+
1564
+ # Extract terms object
1565
+ from patsy import dmatrices
1566
+ _, X_design = dmatrices(safe_formula, data, return_type='dataframe')
1567
+ terms_obj = X_design.design_info
1568
+
1569
+ # Formula parsing (generates y, X matrices, parse_formula includes intercept)
1570
+ y_raw, X_raw = parse_formula(safe_formula, data)
1571
+ # Zero variance filtering (removes intercept column since sd=0)
1572
+ std = X_raw.std(axis=0, ddof=1)
1573
+ X_mat = X_raw[:, std > 0] # Don't manually add intercept (intercept column sd=0 is filtered)
1574
+
1575
+ # Input vectorization - supports column name string or array
1576
+ if isinstance(id, str):
1577
+ id_arr = data[id].values
1578
+ else:
1579
+ id_arr = np.asarray(id)
1580
+
1581
+ if isinstance(time, str):
1582
+ time_arr = data[time].values
1583
+ else:
1584
+ time_arr = np.asarray(time)
1585
+
1586
+ # Sort by time in ascending order for temporal consistency
1587
+ id_sorted, time_sorted, y_sorted, X_sorted, order = _sort_by_time(id_arr, time_arr, y_raw, X_mat)
1588
+
1589
+ # Balanced panel check: verify each id appears exactly once at each time
1590
+ # This is a hard constraint for CBMSM algorithm, must be checked before _build_treat_hist
1591
+ # Otherwise np.column_stack in msm_loss_func will fail
1592
+ name_cands_check = np.array(sorted(pd.unique(id_sorted)))
1593
+ unique_time_check = np.array(sorted(pd.unique(time_sorted)))
1594
+ N_expected = len(name_cands_check)
1595
+ J_expected = len(unique_time_check)
1596
+ N_total_expected = N_expected * J_expected
1597
+ N_actual = len(id_sorted)
1598
+
1599
+ if N_actual != N_total_expected:
1600
+ # Check for duplicate or missing id×time combinations
1601
+ id_time_counts = pd.DataFrame({
1602
+ 'id': id_sorted,
1603
+ 'time': time_sorted
1604
+ }).groupby(['id', 'time']).size()
1605
+
1606
+ # Find missing combinations
1607
+ all_combinations = pd.MultiIndex.from_product(
1608
+ [name_cands_check, unique_time_check],
1609
+ names=['id', 'time']
1610
+ )
1611
+ missing = all_combinations.difference(id_time_counts.index)
1612
+ duplicates = id_time_counts[id_time_counts > 1]
1613
+
1614
+ error_msg = (
1615
+ f"CBMSM requires a balanced panel: each id must appear exactly once at each time point.\n"
1616
+ f"Expected {N_expected} units × {J_expected} time periods = {N_total_expected} observations, "
1617
+ f"but found {N_actual} observations.\n"
1618
+ )
1619
+
1620
+ if len(missing) > 0:
1621
+ missing_sample = missing[:5].tolist()
1622
+ error_msg += f"\nMissing {len(missing)} id×time combinations (showing first 5): {missing_sample}"
1623
+ if len(missing) > 5:
1624
+ error_msg += f" ... and {len(missing) - 5} more"
1625
+
1626
+ if len(duplicates) > 0:
1627
+ dup_sample = duplicates.head(5).to_dict()
1628
+ error_msg += f"\nDuplicate id×time combinations (showing first 5): {dup_sample}"
1629
+ if len(duplicates) > 5:
1630
+ error_msg += f" ... and {len(duplicates) - 5} more"
1631
+
1632
+ error_msg += (
1633
+ "\n\nPlease ensure your data is a complete balanced panel before calling CBMSM.\n"
1634
+ "You may need to:\n"
1635
+ " 1. Filter to only include units with complete time series, or\n"
1636
+ " 2. Impute/fill missing time points with appropriate values."
1637
+ )
1638
+
1639
+ raise ValueError(error_msg)
1640
+
1641
+ # Verify at least 2 individuals and 2 time periods (MSM minimum requirement)
1642
+ if N_expected < 2:
1643
+ raise ValueError(
1644
+ f"CBMSM requires at least 2 individuals (units), but found only {N_expected} unique id. "
1645
+ f"Marginal Structural Models are designed for panel data with multiple units over time.\n\n"
1646
+ f"Reason: MSM estimates treatment effects using within-unit variation over time "
1647
+ f"and across-unit comparison. With only 1 unit, there is no cross-sectional variation.\n\n"
1648
+ f"If you have cross-sectional data (one time point), use CBPS() instead."
1649
+ )
1650
+
1651
+ if J_expected < 2:
1652
+ raise ValueError(
1653
+ f"CBMSM requires at least 2 time periods, but found only {J_expected} unique time value. "
1654
+ f"Marginal Structural Models are designed for longitudinal data with multiple time points.\n\n"
1655
+ f"Reason: MSM estimates treatment effects using temporal variation in treatment assignment. "
1656
+ f"With only 1 time period, this reduces to standard cross-sectional CBPS.\n\n"
1657
+ f"If you have cross-sectional data, use CBPS() instead."
1658
+ )
1659
+
1660
+ # Build treatment history matrix (N×J)
1661
+ treat_hist, name_cands, unique_time = _build_treat_hist(y_sorted, id_sorted, time_sorted)
1662
+ treat_cum = treat_hist.sum(axis=1)
1663
+
1664
+ # Design matrix: MSM uses SVD boolean truncation; MultiBin uses zero-variance removal + intercept
1665
+ if type == "MultiBin":
1666
+ X_nozero = X_sorted[:, 1:] if X_sorted.shape[1] > 1 else X_sorted[:, :0]
1667
+ std2 = X_nozero.std(axis=0, ddof=1)
1668
+ X_nz = X_nozero[:, std2 > 0]
1669
+ X_c = np.column_stack([np.ones(X_sorted.shape[0]), X_nz])
1670
+ else:
1671
+ if not time_vary:
1672
+ # SVD-based dimension reduction: scale, decompose, and threshold
1673
+ X_svd = _svd_boolean_trunc_global(X_sorted)
1674
+ else:
1675
+ X_svd = _svd_boolean_trunc_timevary(X_sorted, time_sorted, unique_time)
1676
+ X_c = np.column_stack([np.ones(X_svd.shape[0]), X_svd]) if X_svd.size else np.ones((X_sorted.shape[0], 1))
1677
+
1678
+ # Determine full variance matrix computation mode
1679
+ full_var = (msm_variance == "full")
1680
+
1681
+ # Starting points: GLM and CBPS
1682
+ if not time_vary:
1683
+ glm_model = sm.GLM(y_sorted, X_c, family=Binomial())
1684
+ glm_fit_res = glm_model.fit(maxiter=50, tol=1e-8)
1685
+ glm1 = glm_fit_res.params.astype(float)
1686
+ cbps_res = cbps_binary_fit(
1687
+ treat=y_sorted,
1688
+ X=X_c,
1689
+ att=0,
1690
+ method='exact',
1691
+ two_step=True,
1692
+ standardize=True,
1693
+ iterations=200,
1694
+ )
1695
+ glm_cb = cbps_res['coefficients'].ravel()
1696
+ else:
1697
+ # Time-varying: per-period GLM and CBPS, vectorized
1698
+ glm_cols = []
1699
+ cbps_cols = []
1700
+ for t in unique_time:
1701
+ mask = (time_sorted == t)
1702
+ X_sub = X_c[mask, :]
1703
+ y_sub = y_sorted[mask]
1704
+ glm_sub = sm.GLM(y_sub, X_sub, family=Binomial()).fit(maxiter=50, tol=1e-8).params
1705
+ cbps_sub = cbps_binary_fit(
1706
+ treat=y_sub,
1707
+ X=X_sub,
1708
+ att=0,
1709
+ method='exact',
1710
+ two_step=True,
1711
+ standardize=True,
1712
+ iterations=200,
1713
+ )['coefficients'].ravel()
1714
+ glm_cols.append(glm_sub)
1715
+ cbps_cols.append(cbps_sub)
1716
+ glm_mat = np.column_stack(glm_cols)
1717
+ cbps_mat = np.column_stack(cbps_cols)
1718
+ glm_mat[np.isnan(glm_mat)] = 0.0
1719
+ cbps_mat[np.isnan(cbps_mat)] = 0.0
1720
+ glm1 = glm_mat.ravel(order='F') # Column-major flattening
1721
+ glm_cb = cbps_mat.ravel(order='F')
1722
+
1723
+ # Select best: compare initial loss in twostep=FALSE setting
1724
+ glm_fit_dict = msm_loss_func(glm1, X_c, y_sorted, time_sorted, id_vec=id_sorted, name_cands=name_cands, twostep=False, full_var=full_var)
1725
+ cb_fit_dict = msm_loss_func(glm_cb, X_c, y_sorted, time_sorted, id_vec=id_sorted, name_cands=name_cands, twostep=False, full_var=full_var)
1726
+ loss_glm = glm_fit_dict['loss']
1727
+ loss_cb = cb_fit_dict['loss']
1728
+ type_fit = "Returning Estimates from Logistic Regression\n"
1729
+ if ((loss_cb < loss_glm and init == 'opt') or init == 'CBPS'):
1730
+ init_betas = glm_cb
1731
+ init_fit = cb_fit_dict
1732
+ type_fit = "Returning Estimates from CBPS\n"
1733
+ else:
1734
+ init_betas = glm1
1735
+ init_fit = glm_fit_dict
1736
+
1737
+ # R always runs twostep first as warm start, then optionally runs
1738
+ # continuous updating. This two-stage strategy is standard GMM practice.
1739
+ glm_fit0 = msm_loss_func(init_betas, X_c, y_sorted, time_sorted, id_vec=id_sorted, name_cands=name_cands, twostep=False, full_var=full_var)
1740
+ Vcov_inv = glm_fit0['Var.inv'] if 'Var.inv' in glm_fit0 else None
1741
+ if Vcov_inv is None:
1742
+ Vcov_inv = _r_ginv(X_c.T @ X_c)
1743
+
1744
+ # Stage 1: Always run twostep optimization (warm start)
1745
+ def twostep_loss(b: np.ndarray) -> float:
1746
+ return msm_loss_func(b, X_c, y_sorted, time_sorted, id_vec=id_sorted, name_cands=name_cands, bal_only=True, twostep=True, Vcov_inv=Vcov_inv, full_var=full_var)['loss']
1747
+ opt1 = scipy.optimize.minimize(
1748
+ twostep_loss, init_betas, method='BFGS', options={'maxiter': iterations or 500}
1749
+ )
1750
+ msm_fit = msm_loss_func(opt1.x, X_c, y_sorted, time_sorted, id_vec=id_sorted, name_cands=name_cands, bal_only=True, twostep=True, Vcov_inv=Vcov_inv, full_var=full_var)
1751
+ l3 = msm_loss_func(init_betas, X_c, y_sorted, time_sorted, id_vec=id_sorted, name_cands=name_cands, bal_only=True, twostep=True, Vcov_inv=Vcov_inv, full_var=full_var)
1752
+ if (l3['loss'] < msm_fit['loss']) and (init == 'opt'):
1753
+ msm_fit = l3
1754
+ warnings.warn("Warning: Optimization did not improve over initial estimates\n")
1755
+ print(type_fit, end='')
1756
+
1757
+ # Stage 2: If continuous updating requested, refine from twostep result
1758
+ if not twostep:
1759
+ def cont_loss(b: np.ndarray) -> float:
1760
+ return msm_loss_func(b, X_c, y_sorted, time_sorted, id_vec=id_sorted, name_cands=name_cands, bal_only=True, twostep=False, Vcov_inv=None, full_var=full_var)['loss']
1761
+ # Use twostep result as starting point (warm start)
1762
+ cont_start = opt1.x
1763
+ opt1 = scipy.optimize.minimize(
1764
+ cont_loss, cont_start, method='BFGS', options={'maxiter': iterations or 500}
1765
+ )
1766
+ msm_fit = msm_loss_func(opt1.x, X_c, y_sorted, time_sorted, id_vec=id_sorted, name_cands=name_cands, bal_only=True, twostep=False, Vcov_inv=None, full_var=full_var)
1767
+ l3 = msm_loss_func(init_betas, X_c, y_sorted, time_sorted, id_vec=id_sorted, name_cands=name_cands, bal_only=True, twostep=False, Vcov_inv=None, full_var=full_var)
1768
+ if (l3['loss'] < msm_fit['loss']) and (init == 'opt'):
1769
+ msm_fit = l3
1770
+ print("\nWarning: Optimization did not improve over initial estimates\n", end='')
1771
+ print(type_fit, end='')
1772
+
1773
+ # Unconditional frequencies and final weights (first period subset)
1774
+ n_obs = len(np.unique(id_sorted))
1775
+ n_time = len(np.unique(time_sorted))
1776
+ # Build treat_hist and unique row frequencies from sorted id/time
1777
+ treat_hist2, name_cands2, uniq_t2 = _build_treat_hist(y_sorted, id_sorted, time_sorted)
1778
+ # Compute frequency of each row in unique set
1779
+ # Use string key comparison for row equivalence (unique row identification)
1780
+ rows_as_str = ["|".join(map(str, row.tolist())) for row in treat_hist2]
1781
+ uniq_rows, counts = np.unique(rows_as_str, return_counts=True)
1782
+ freq_map = {u: c / n_obs for u, c in zip(uniq_rows, counts)}
1783
+ uncond_probs = np.array([freq_map[s] for s in rows_as_str])
1784
+
1785
+ probs_out = msm_fit['w_all'] # Length n (unit-level)
1786
+ # w_all = ∏_j 1/P(T_j|X_j) = 1/P(T̄|X̄) (unstabilized IPW)
1787
+ # fitted_values = uncond_probs / w_all = P(T̄) × P(T̄|X̄)
1788
+ # weights = w_all = 1/P(T̄|X̄)
1789
+ # The fitted_values are used as WLS weights for outcome regression
1790
+ # (reproduces Imai & Ratkovic 2015, Table 3).
1791
+ wts_out_all = np.tile(uncond_probs / probs_out, n_time)
1792
+ wts_out = wts_out_all[time_sorted == np.min(time_sorted)]
1793
+
1794
+ glm_weights_all = np.tile(uncond_probs / glm_fit_dict['w_all'], n_time)
1795
+ glm_weights = glm_weights_all[time_sorted == np.min(time_sorted)]
1796
+
1797
+ # Compute loss values (for warnings and J-statistic)
1798
+ loss_glm_final = glm_fit_dict['loss']
1799
+ loss_msm_final = msm_fit['loss']
1800
+
1801
+ # Compute diagnostics
1802
+ # 1. coefficients: final optimized treatment model coefficients
1803
+ final_coefficients = opt1.x if 'opt1' in locals() else init_betas
1804
+
1805
+ # When time_vary=True, reshape to (k, n_periods) matrix
1806
+ # Each column represents coefficients for one time period
1807
+ if time_vary:
1808
+ k = X_c.shape[1]
1809
+ n_periods = len(unique_time)
1810
+ final_coefficients = final_coefficients.reshape(k, n_periods, order='F')
1811
+ # When time_vary=False, keep (k,) vector format (all periods share same coefficients)
1812
+
1813
+ # 2. J-statistic: GMM loss value (Hansen J-statistic)
1814
+ J_statistic = loss_msm_final / n_obs # Normalized J-statistic
1815
+
1816
+ # 3. Inverse of variance-covariance matrix
1817
+ var_matrix = msm_fit.get('Var.inv', Vcov_inv)
1818
+ if var_matrix is None:
1819
+ var_matrix = np.array([[]]) # Empty matrix as placeholder
1820
+
1821
+ # 4. Convergence status
1822
+ converged_status = opt1.success if 'opt1' in locals() else True
1823
+
1824
+ # Assemble return object
1825
+ out = CBMSMResults(
1826
+ # Core weights (6)
1827
+ weights=probs_out, # Will be overwritten to first-period subset
1828
+ fitted_values=wts_out,
1829
+ glm_g=glm_fit_dict['g.all'],
1830
+ msm_g=msm_fit['g.all'],
1831
+ glm_weights=glm_weights,
1832
+ id=id_sorted, # Full length vector (aligned with y/time)
1833
+ # Treatment history (2)
1834
+ treat_hist=treat_hist,
1835
+ treat_cum=treat_cum,
1836
+ # Diagnostics (7)
1837
+ coefficients=final_coefficients,
1838
+ n_periods=n_time,
1839
+ n_units=n_obs,
1840
+ time_vary=time_vary,
1841
+ converged=converged_status,
1842
+ J=J_statistic,
1843
+ var=var_matrix,
1844
+ # Metadata (7)
1845
+ call=f"CBMSM(formula='{formula}', type='{type}', twostep={twostep}, msm_variance='{msm_variance}', time_vary={time_vary}, init='{init}')",
1846
+ formula=formula,
1847
+ y=y_sorted,
1848
+ x=X_sorted,
1849
+ time=time_sorted,
1850
+ # Model frame construction
1851
+ model=data_original, # Use original data as model frame
1852
+ data=data_original, # Use saved original data
1853
+ )
1854
+
1855
+ # Final override to first-period subset
1856
+ mask_first = (time_sorted == np.min(time_sorted))
1857
+ if out.weights.shape[0] == time_sorted.shape[0]:
1858
+ out.weights = out.weights[mask_first]
1859
+ else:
1860
+ # Already unit-level length (N), keep unchanged
1861
+ pass
1862
+
1863
+ # Warn if GLM loss is less than MSM loss
1864
+ if loss_glm_final < loss_msm_final:
1865
+ warnings.warn(
1866
+ f"CBMSM fails to improve covariate balance relative to MLE. \n GLM loss: {loss_glm_final} \n CBMSM loss: {loss_msm_final} \n"
1867
+ )
1868
+
1869
+ return out
1870
+
1871
+