Skip to content

Commit d03fbf3

Browse files
Audit-round polish: 5 real bugs found in the v0.7.1 work, all fixed
Full audit of the 17 commits landed today surfaced five genuine issues plus a dozen purely cosmetic ones. This commit fixes the real five and adds targeted regression tests for each. 1. `_df_to_booktabs` LaTeX escape was non-idempotent. Old code replaced `\` with `\textbackslash{}` first, then replaced `{` → `\{` and `}` → `\}` in a second pass — which re-escaped the braces just inserted, producing `\textbackslash\{\}` instead of the correct `\textbackslash{}`. Same failure mode for `~` and `^`. Fix: single-pass `re.sub` with a lookup table so each character is escaped exactly once. (Latent in the DiD report path — our tables never carried backslashes — but would bite any user passing string labels.) 2. `cs_report(save_to='~/study/cs_v1')` didn't expand `~`. `os.path.dirname('~/study/cs_v1')` returns `"~/study"`, so the pre-fix code created a literal `./~/study` directory. Fix: `os.path.expanduser()` before anything else. 3. `_save_report_bundle` forced `matplotlib.use('Agg')` unconditionally, which prints a warning when pyplot is already imported (common in Jupyter). Fix: only switch backend if pyplot hasn't been loaded yet; otherwise honour whatever backend the user had. 4. `bjs_pretrend_joint` swallowed `except Exception` silently. If `did_imputation` had a real bug, users would just see "not enough reps succeeded" with no clue why. Fix: narrow to `(ValueError, RuntimeError, LinAlgError)` for expected pathological resamples; track the last unexpected exception and include its type + message in the failure. 5. `sp.did()` silently ignored `aggregation=`, `panel=False`, and `anticipation=` when `method` resolved to anything other than CS. Fix: (a) auto-upgrade `method='auto'` → `'callaway_santanna'` when `aggregation` is supplied with an `id`; (b) raise `ValueError` if any CS-only argument is paired with a non-CS `method`. Regression tests (4 new): - LaTeX backslash round-trip (brace mangling guard) - `save_to` expands `~` to `$HOME` - `sp.did(method='2x2', aggregation='dynamic')` raises - `sp.did(method='2x2', anticipation=1)` raises Full DiD suite: 165/165 (was 161). Also noted but left alone (cosmetic / not correctness): - SA's `(g_val, e) in {m for m in interact_meta}` builds a throwaway set comprehension; `in interact_meta` would suffice. Performance only matters on huge event windows. - SA's `XtX + 1e-10 * I` ridge term slightly biases β̂ in well-conditioned cases (~1 ppm); matches common practice. - `_two_way_demean(x)` calls `x.astype(float).copy()` — `.copy()` is redundant once `.astype()` already returns a fresh array. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 56ef82a commit d03fbf3

5 files changed

Lines changed: 148 additions & 18 deletions

File tree

src/statspai/did/__init__.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,34 @@ def did(
215215
+ (" ..." if len(data.columns) > 10 else "")
216216
)
217217

218+
# If CS-specific arguments are passed, force the method to CS rather
219+
# than silently ignoring them. This is the "do what I mean" path
220+
# when a user writes
221+
# sp.did(..., aggregation='dynamic')
222+
# and expects CS to run under the hood.
223+
if aggregation is not None and method == 'auto' and id is not None:
224+
method = 'callaway_santanna'
225+
226+
# Validate that CS-only arguments were not paired with a non-CS
227+
# method — fail loudly rather than swallow the argument.
228+
_cs_methods = {'callaway_santanna', 'cs', 'auto'}
229+
if aggregation is not None and method not in _cs_methods:
230+
raise ValueError(
231+
f"`aggregation={aggregation!r}` is only supported with the "
232+
f"Callaway-Sant'Anna estimator (method='cs'); got "
233+
f"method={method!r}."
234+
)
235+
if (not panel) and method not in _cs_methods:
236+
raise ValueError(
237+
f"`panel=False` is only supported with Callaway-Sant'Anna; "
238+
f"got method={method!r}."
239+
)
240+
if anticipation != 0 and method not in _cs_methods:
241+
raise ValueError(
242+
f"`anticipation={anticipation}` is only supported with "
243+
f"Callaway-Sant'Anna; got method={method!r}."
244+
)
245+
218246
# Auto-detect if subgroup is provided → DDD
219247
if method == 'auto' and subgroup is not None:
220248
method = 'ddd'

src/statspai/did/bjs_inference.py

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,10 @@ def bjs_pretrend_joint(
114114
rng = np.random.default_rng(seed)
115115

116116
boot_mat = np.full((n_boot, K), np.nan)
117+
# Remember the most recent unexpected error so that if many / all
118+
# bootstrap replications fail we can surface it to the user rather
119+
# than silently claim "not enough reps succeeded".
120+
last_unexpected: Optional[Exception] = None
117121
for b in range(n_boot):
118122
picks = rng.choice(clusters, size=G, replace=True)
119123
frames = []
@@ -129,22 +133,36 @@ def bjs_pretrend_joint(
129133
first_treat=first_treat, controls=controls,
130134
horizon=horizon, cluster=cluster_col,
131135
)
132-
es_b = r_b.model_info.get('event_study')
133-
if es_b is None or len(es_b) == 0:
134-
continue
135-
sub = es_b[es_b['relative_time'].isin(pre_k)].sort_values(
136-
'relative_time',
137-
)
138-
if len(sub) == K:
139-
boot_mat[b] = sub['att'].to_numpy()
140-
except Exception:
136+
except (ValueError, RuntimeError, np.linalg.LinAlgError):
137+
# Expected failures on pathological resamples (e.g. a draw
138+
# that happens to drop all eventually-treated units). Skip.
139+
continue
140+
except Exception as exc:
141+
# Anything else is likely a genuine bug in BJS — keep the
142+
# last one around so we can report it below if the run fails.
143+
last_unexpected = exc
144+
continue
145+
146+
es_b = r_b.model_info.get('event_study')
147+
if es_b is None or len(es_b) == 0:
141148
continue
149+
sub = es_b[es_b['relative_time'].isin(pre_k)].sort_values(
150+
'relative_time',
151+
)
152+
if len(sub) == K:
153+
boot_mat[b] = sub['att'].to_numpy()
142154

143155
valid = ~np.any(np.isnan(boot_mat), axis=1)
144156
if valid.sum() < K + 1:
157+
extra = (
158+
f" Last unexpected error: {type(last_unexpected).__name__}: "
159+
f"{last_unexpected}"
160+
if last_unexpected is not None else ""
161+
)
145162
raise RuntimeError(
146163
f"Only {int(valid.sum())} / {n_boot} bootstrap replications "
147164
f"succeeded — not enough to estimate the {K}×{K} covariance."
165+
+ extra
148166
)
149167
boot_valid = boot_mat[valid]
150168
# Centre around the bootstrap mean so the cov estimator is unbiased

src/statspai/did/report.py

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -560,15 +560,30 @@ def _df_to_booktabs(df: pd.DataFrame, float_format: str = "%.4f") -> str:
560560
aligns.append("l")
561561
col_spec = "".join(aligns)
562562

563+
# Single-pass LaTeX escape. Sequential replace() calls are unsafe
564+
# because `\` → `\textbackslash{}` inserts `{` and `}` that later
565+
# passes would re-escape, producing broken output like
566+
# `\textbackslash\{\}` instead of `\textbackslash{}`. Using re.sub
567+
# with a lookup table guarantees each input character is escaped
568+
# exactly once.
569+
import re as _re
570+
_LATEX_ESCAPES = {
571+
'\\': r'\textbackslash{}',
572+
'~': r'\textasciitilde{}',
573+
'^': r'\textasciicircum{}',
574+
'&': r'\&',
575+
'%': r'\%',
576+
'$': r'\$',
577+
'#': r'\#',
578+
'_': r'\_',
579+
'{': r'\{',
580+
'}': r'\}',
581+
}
582+
_LATEX_RE = _re.compile(r'[\\~^&%$#_{}]')
583+
563584
def _escape(v: object) -> str:
564585
text = "" if (isinstance(v, float) and pd.isna(v)) else str(v)
565-
for old, new in (("\\", r"\textbackslash{}"), ("&", r"\&"),
566-
("%", r"\%"), ("$", r"\$"), ("#", r"\#"),
567-
("_", r"\_"), ("{", r"\{"), ("}", r"\}"),
568-
("~", r"\textasciitilde{}"),
569-
("^", r"\textasciicircum{}")):
570-
text = text.replace(old, new)
571-
return text
586+
return _LATEX_RE.sub(lambda m: _LATEX_ESCAPES[m.group(0)], text)
572587

573588
header = " & ".join(_escape(c) for c in formatted.columns) + " \\\\"
574589
body_rows = [
@@ -759,8 +774,14 @@ def _save_report_bundle(
759774
skipping any format whose optional dependency is missing.
760775
"""
761776
import os
777+
import sys
762778

763-
prefix = str(prefix)
779+
def _sys_modules():
780+
return sys.modules
781+
782+
# Expand a leading "~" / "~user" so `save_to='~/study/cs_v1'` works,
783+
# otherwise os.makedirs would create a literal "./~/study" directory.
784+
prefix = os.path.expanduser(str(prefix))
764785
parent = os.path.dirname(prefix)
765786
if parent:
766787
os.makedirs(parent, exist_ok=True)
@@ -791,7 +812,15 @@ def _save_report_bundle(
791812

792813
try:
793814
import matplotlib
794-
matplotlib.use("Agg")
815+
# Only switch to a non-interactive backend if nothing has been
816+
# set yet — calling matplotlib.use() after pyplot has been
817+
# imported is a warning-only no-op in modern matplotlib but
818+
# can surprise users running inside Jupyter.
819+
if "matplotlib.pyplot" not in _sys_modules():
820+
try:
821+
matplotlib.use("Agg")
822+
except Exception:
823+
pass # already set — honour caller's choice
795824
fig, _ = report.plot()
796825
png_path = f"{prefix}.png"
797826
fig.savefig(png_path, dpi=110, bbox_inches="tight")

tests/test_cs_report.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,3 +253,33 @@ def test_save_to_files_contain_expected_content(tmp_path):
253253
assert "\\begin{table}" in tex and "\\bottomrule" in tex
254254
txt = (tmp_path / "cs.txt").read_text()
255255
assert "Overall ATT" in txt
256+
257+
258+
# --------------------------------------------------------------------------- #
259+
# Audit-round bug regressions #
260+
# --------------------------------------------------------------------------- #
261+
262+
def test_to_latex_backslash_is_single_escaped(report):
263+
"""Bug: \\\\textbackslash{} followed by { → \\{ mangled the brace.
264+
265+
A single backslash in a cell should render as \\textbackslash{}
266+
verbatim, not \\textbackslash\\{\\}."""
267+
import importlib
268+
mod = importlib.import_module('statspai.did.report')
269+
df = pd.DataFrame({'label': ['a\\b', 'x^y'], 'val': [1, 2]})
270+
tex = mod._df_to_booktabs(df)
271+
assert r'\textbackslash{}' in tex
272+
assert r'\textbackslash\{\}' not in tex
273+
assert r'\textasciicircum{}' in tex
274+
275+
276+
def test_save_to_expands_tilde(tmp_path, monkeypatch):
277+
"""`save_to='~/foo/bar'` should expand the home directory."""
278+
df = _staggered_panel(seed=31)
279+
monkeypatch.setenv("HOME", str(tmp_path))
280+
cs_report(df, y='y', g='g', t='t', i='i',
281+
n_boot=50, random_state=0, verbose=False,
282+
save_to='~/cs_study/rpt')
283+
# If ~ was not expanded, we'd see a literal "~" subdirectory under CWD.
284+
assert (tmp_path / "cs_study" / "rpt.md").exists()
285+
assert (tmp_path / "cs_study" / "rpt.txt").exists()

tests/test_sp_did_aggregation.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,28 @@ def test_rcs_path_through_top_level():
6565
estimator='reg', panel=False)
6666
assert r.model_info['panel'] is False
6767
assert 'RCS' in r.model_info['estimator']
68+
69+
70+
def test_aggregation_with_incompatible_method_raises():
71+
"""Silent-ignore bug: `aggregation=` must be rejected for 2x2 / DDD."""
72+
rng = np.random.default_rng(0)
73+
df = pd.DataFrame({
74+
'y': rng.normal(size=200),
75+
'treat': rng.integers(0, 2, size=200),
76+
'time': rng.integers(0, 2, size=200),
77+
})
78+
with pytest.raises(ValueError, match='Callaway'):
79+
sp.did(df, y='y', treat='treat', time='time',
80+
method='2x2', aggregation='dynamic')
81+
82+
83+
def test_anticipation_rejected_for_non_cs_method():
84+
rng = np.random.default_rng(0)
85+
df = pd.DataFrame({
86+
'y': rng.normal(size=200),
87+
'treat': rng.integers(0, 2, size=200),
88+
'time': rng.integers(0, 2, size=200),
89+
})
90+
with pytest.raises(ValueError, match='anticipation'):
91+
sp.did(df, y='y', treat='treat', time='time',
92+
method='2x2', anticipation=1)

0 commit comments

Comments
 (0)