Skip to content

Commit ac92e9a

Browse files
fix(bayes): v0.9.16 self-review — cohort dropna alignment + poly_u sentinel
Two round-2 fixes caught in self-review after v0.9.16 landed: 1. `bayes_did(cohort=...)` used two independent `.dropna()` passes (one in `_prepare_did_frame`, one in the cohort factoriser) which silently misaligned `cohort_codes` with `DID` whenever the cohort column had NaN rows that y/treat/post/unit/time did not. Synthetic test data never triggered this; real panels with missing cohort info would have either shape-errored in PyMC or, worse, fit with shuffled cohort labels. Fix threads `cohort` through `_prepare_did_frame` so a single `.dropna()` governs every array. 2. `poly_u: int = 2` default forced bivariate_normal callers to eat a spurious "overriding poly_u=2 -> poly_u=1" UserWarning on every default-arg call. Switch to `poly_u: Optional[int] = None` and resolve method-specifically: 2 for polynomial/hv_latent, 1 for bivariate_normal. Explicit non-1 values with bivariate_normal still warn before overriding. New regression test `test_cohort_nan_rows_dropped_consistently` poisons 15 rows' cohort values with NaN and asserts the ordering of per-cohort τ posteriors is still recovered. All 118 bayes_mte/did/iv tests green; full suite 2411/2411 green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 85a6dd8 commit ac92e9a

3 files changed

Lines changed: 96 additions & 33 deletions

File tree

src/statspai/bayes/did.py

Lines changed: 41 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,19 @@ def _prepare_did_frame(
2929
unit: Optional[str],
3030
time: Optional[str],
3131
covariates: Optional[List[str]],
32+
cohort: Optional[str] = None,
3233
) -> dict:
33-
"""Validate, drop NA, and extract arrays for DID."""
34+
"""Validate, drop NA, and extract arrays for DID.
35+
36+
``cohort`` is threaded through here (rather than being handled
37+
separately in ``bayes_did``) so its NaN rows get dropped in the
38+
same pass as ``y / treat / post / unit / time / covariates``.
39+
Using two independent ``dropna`` calls — which is what v0.9.16's
40+
first draft did — caused a silent row-alignment bug whenever
41+
``cohort`` had NaN rows that the other columns did not: the
42+
cohort-codes array and ``DID`` array ended up with different
43+
lengths (or same length but different rows).
44+
"""
3445
required = [y, treat, post]
3546
for c in required:
3647
if c not in data.columns:
@@ -50,6 +61,10 @@ def _prepare_did_frame(
5061
if c not in data.columns:
5162
raise ValueError(f"Covariate '{c}' not found in data")
5263
cols.extend(covariates)
64+
if cohort is not None:
65+
if cohort not in data.columns:
66+
raise ValueError(f"Column '{cohort}' (cohort) not found in data")
67+
cols.append(cohort)
5368

5469
clean = data[cols].dropna().reset_index(drop=True)
5570
n = len(clean)
@@ -94,6 +109,18 @@ def _prepare_did_frame(
94109
"time random effect."
95110
)
96111

112+
cohort_codes: Optional[np.ndarray] = None
113+
cohort_labels: list = []
114+
if cohort is not None:
115+
codes, uniques = pd.factorize(clean[cohort], sort=True)
116+
cohort_codes = np.asarray(codes, dtype=np.int64)
117+
cohort_labels = list(uniques)
118+
if len(cohort_labels) < 2:
119+
raise ValueError(
120+
f"cohort column '{cohort}' has < 2 distinct values; "
121+
"per-cohort ATT requires at least 2 cohorts."
122+
)
123+
97124
X: Optional[np.ndarray] = None
98125
if covariates:
99126
X = clean[covariates].to_numpy(dtype=float)
@@ -108,6 +135,8 @@ def _prepare_did_frame(
108135
'n_units': n_units,
109136
'time_idx': time_idx,
110137
'n_times': n_times,
138+
'cohort_idx': cohort_codes,
139+
'cohort_labels': cohort_labels,
111140
'X': X,
112141
'covariates': list(covariates) if covariates else [],
113142
}
@@ -207,7 +236,15 @@ def bayes_did(
207236
"""
208237
pm, _ = _require_pymc()
209238

210-
prep = _prepare_did_frame(data, y, treat, post, unit, time, covariates)
239+
# Thread `cohort` into `_prepare_did_frame` so every NaN gets
240+
# dropped in a single pass and `cohort_codes` is guaranteed to
241+
# align row-for-row with `DID`. A prior implementation did two
242+
# independent dropna() calls and silently mis-aligned the two
243+
# arrays whenever `cohort` had NaN rows the other columns did
244+
# not (not caught by synthetic-data tests that have no NaN).
245+
prep = _prepare_did_frame(
246+
data, y, treat, post, unit, time, covariates, cohort=cohort,
247+
)
211248
n = prep['n']
212249
Y = prep['Y']
213250
T = prep['T']
@@ -218,34 +255,8 @@ def bayes_did(
218255
use_unit_re = prep['unit_idx'] is not None
219256
use_time_re = prep['time_idx'] is not None
220257

221-
# Cohort wiring: factorise once so the model gets integer codes and
222-
# the result carries back the user's original labels for tidy(). We
223-
# factorise on the dropna'd frame to keep index alignment with DID.
224-
cohort_codes: Optional[np.ndarray] = None
225-
cohort_labels: list = []
226-
if cohort is not None:
227-
if cohort not in data.columns:
228-
raise ValueError(f"Column '{cohort}' (cohort) not found in data")
229-
cols_for_factor = [y, treat, post]
230-
if unit is not None:
231-
cols_for_factor.append(unit)
232-
if time is not None:
233-
cols_for_factor.append(time)
234-
if covariates:
235-
cols_for_factor.extend(covariates)
236-
cols_for_factor.append(cohort)
237-
clean_for_cohort = (
238-
data[cols_for_factor].dropna().reset_index(drop=True)
239-
)
240-
codes, uniques = pd.factorize(clean_for_cohort[cohort], sort=True)
241-
cohort_codes = np.asarray(codes, dtype=np.int64)
242-
cohort_labels = list(uniques)
243-
n_cohorts = len(cohort_labels)
244-
if n_cohorts < 2:
245-
raise ValueError(
246-
f"cohort column '{cohort}' has < 2 distinct values; "
247-
"per-cohort ATT requires at least 2 cohorts."
248-
)
258+
cohort_codes = prep['cohort_idx']
259+
cohort_labels = prep['cohort_labels']
249260

250261
mu_ate, sigma_ate = prior_ate
251262

src/statspai/bayes/mte.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ def bayes_mte(
115115
mte_method: str = 'polynomial',
116116
selection: str = 'uniform',
117117
u_grid: Optional[np.ndarray] = None,
118-
poly_u: int = 2,
118+
poly_u: Optional[int] = None,
119119
prior_coef_sigma: float = 10.0,
120120
prior_mte_sigma: float = 5.0,
121121
prior_noise: float = 5.0,
@@ -188,10 +188,15 @@ def bayes_mte(
188188
u_grid : np.ndarray, optional
189189
Grid of propensity-to-be-treated values on which to evaluate
190190
the MTE posterior. Default: ``np.linspace(0.05, 0.95, 19)``.
191-
poly_u : int, default 2
191+
poly_u : int, optional
192192
Polynomial order of the MTE in ``U_D``. ``poly_u=0`` reduces
193193
to a constant treatment effect; ``poly_u=2`` captures
194-
U-shaped or inverted-U selection on gains.
194+
U-shaped or inverted-U selection on gains. Default resolves
195+
per ``mte_method``: 2 for ``'polynomial'`` / ``'hv_latent'``,
196+
and 1 for ``'bivariate_normal'`` (the model is inherently
197+
linear in ``V`` there and ignores any other value). Passing
198+
an explicit non-1 value with ``mte_method='bivariate_normal'``
199+
emits a UserWarning before the override.
195200
prior_mte_sigma : float, default 5.0
196201
SD on each MTE polynomial coefficient (Normal prior).
197202
prior_coef_sigma, prior_noise : float
@@ -258,6 +263,13 @@ def bayes_mte(
258263
f"got {selection!r}"
259264
)
260265

266+
# Resolve `poly_u=None` to the method-appropriate default so that
267+
# the signature default no longer requires a fixed integer and
268+
# 'bivariate_normal' stops emitting a spurious "poly_u=2 -> 1"
269+
# warning on every default-arg call.
270+
if poly_u is None:
271+
poly_u = 1 if mte_method == 'bivariate_normal' else 2
272+
261273
# Bivariate-normal HV is inherently a V-scale joint-first-stage
262274
# model; raise loudly instead of silently mis-specifying. The
263275
# model also fixes poly_u=1 because MTE(v) is closed-form linear

tests/test_bayes_did_cohort.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,3 +178,43 @@ def test_cohort_weight_recorded_in_model_info(staggered_did_data):
178178
# treated cohorts; control cohort gets weight 0).
179179
assert all(wi >= 0 for wi in w)
180180
assert abs(sum(w) - 1.0) < 1e-9
181+
182+
183+
def test_cohort_nan_rows_dropped_consistently(staggered_did_data):
184+
"""Regression test for a row-alignment bug caught in self-review:
185+
if ``cohort`` has NaN rows that ``y/treat/post`` do not, the
186+
cohort-codes array and DID array must still be length-aligned.
187+
Both are produced by a single ``dropna()`` pass inside
188+
``_prepare_did_frame``, so any extra NaN in the cohort column
189+
removes the row for ALL arrays uniformly."""
190+
df = staggered_did_data.copy()
191+
# Poison 15 rows' cohort values with NaN while leaving y/treat/post
192+
# intact. If dropna were handled separately for cohort vs DID, this
193+
# would desynchronise the arrays and either (a) error with a
194+
# length-mismatch from PyMC or (b) silently fit with shuffled cohorts.
195+
rng = np.random.default_rng(77)
196+
poison_idx = rng.choice(df.index, size=15, replace=False)
197+
df.loc[poison_idx, 'cohort'] = np.nan
198+
199+
r = bayes_did(df, y='y', treat='treat', post='post',
200+
cohort='cohort',
201+
draws=150, tune=150, chains=2, progressbar=False,
202+
random_state=78)
203+
# Sanity: the model fits without shape errors (the bug manifested
204+
# as a PyMC shape-mismatch exception).
205+
assert len(r.cohort_labels) == 3
206+
# Setting a cell to NaN promotes the whole column from int64 to
207+
# float64 in pandas, so cohort labels become floats (-1.0, 2019.0,
208+
# 2020.0). The tidy infrastructure stringifies them consistently —
209+
# look up posteriors via whatever stringification the result uses.
210+
summaries = r.cohort_summaries
211+
label_2019 = next(
212+
k for k in summaries if str(k) in ('2019', '2019.0')
213+
)
214+
label_2020 = next(
215+
k for k in summaries if str(k) in ('2020', '2020.0')
216+
)
217+
m_2019 = summaries[label_2019]['posterior_mean']
218+
m_2020 = summaries[label_2020]['posterior_mean']
219+
# Ordering (2.0 > 0.5) is preserved after dropping 15 rows.
220+
assert m_2019 > m_2020

0 commit comments

Comments
 (0)