cbps 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cbps/__init__.py +3462 -0
- cbps/constants.py +46 -0
- cbps/core/__init__.py +93 -0
- cbps/core/cbps_binary.py +1943 -0
- cbps/core/cbps_continuous.py +945 -0
- cbps/core/cbps_multitreat.py +1123 -0
- cbps/core/cbps_optimal.py +507 -0
- cbps/core/results.py +1447 -0
- cbps/data/Blackwell.csv +571 -0
- cbps/data/LaLonde.csv +3213 -0
- cbps/data/npcbps_continuous_sim.csv +501 -0
- cbps/data/nsw.csv +723 -0
- cbps/data/nsw_dw.csv +446 -0
- cbps/data/political_ads_urban_niebler.csv +16266 -0
- cbps/data/psid_controls.csv +2491 -0
- cbps/data/psid_controls2.csv +254 -0
- cbps/data/psid_controls3.csv +129 -0
- cbps/data/simulation_dgp1_seed12345.csv +201 -0
- cbps/data/simulation_dgp2_seed12345.csv +201 -0
- cbps/data/simulation_dgp3_seed12345.csv +201 -0
- cbps/data/simulation_dgp4_seed12345.csv +201 -0
- cbps/datasets/__init__.py +78 -0
- cbps/datasets/blackwell.py +112 -0
- cbps/datasets/continuous.py +223 -0
- cbps/datasets/lalonde.py +272 -0
- cbps/datasets/npcbps_sim.py +101 -0
- cbps/diagnostics/__init__.py +101 -0
- cbps/diagnostics/balance.py +760 -0
- cbps/diagnostics/balance_cbmsm_addon.py +162 -0
- cbps/diagnostics/continuous_diagnostics.py +259 -0
- cbps/diagnostics/normality.py +173 -0
- cbps/diagnostics/ocbps_conditions.py +197 -0
- cbps/diagnostics/overlap.py +198 -0
- cbps/diagnostics/plots.py +1193 -0
- cbps/diagnostics/weights_diag.py +205 -0
- cbps/highdim/__init__.py +84 -0
- cbps/highdim/gmm_loss.py +340 -0
- cbps/highdim/hdcbps.py +1078 -0
- cbps/highdim/lasso_utils.py +498 -0
- cbps/highdim/weight_funcs.py +298 -0
- cbps/inference/__init__.py +42 -0
- cbps/inference/asyvar.py +621 -0
- cbps/inference/vcov_outcome.py +217 -0
- cbps/iv/__init__.py +48 -0
- cbps/iv/cbiv.py +2603 -0
- cbps/logging_config.py +45 -0
- cbps/msm/__init__.py +45 -0
- cbps/msm/cbmsm.py +1871 -0
- cbps/msm/rank_diagnostics.py +112 -0
- cbps/nonparametric/__init__.py +58 -0
- cbps/nonparametric/cholesky_whitening.py +232 -0
- cbps/nonparametric/empirical_likelihood.py +339 -0
- cbps/nonparametric/npcbps.py +1036 -0
- cbps/nonparametric/taylor_approx.py +207 -0
- cbps/py.typed +0 -0
- cbps/sklearn/__init__.py +42 -0
- cbps/sklearn/estimator.py +378 -0
- cbps/utils/__init__.py +82 -0
- cbps/utils/formula.py +415 -0
- cbps/utils/helpers.py +378 -0
- cbps/utils/numerics.py +438 -0
- cbps/utils/r_compat.py +109 -0
- cbps/utils/validation.py +224 -0
- cbps/utils/variance_transform.py +483 -0
- cbps/utils/weights.py +586 -0
- cbps-0.2.0.dist-info/METADATA +1090 -0
- cbps-0.2.0.dist-info/RECORD +70 -0
- cbps-0.2.0.dist-info/WHEEL +5 -0
- cbps-0.2.0.dist-info/licenses/LICENSE +661 -0
- cbps-0.2.0.dist-info/top_level.txt +1 -0
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
|
+
|