Skip to content

Commit 1a8d89d

Browse files
feat(smart): compare_estimators method_hints + sensitivity Sprint-B dims + g_computation error compaction
Three smart-layer enhancements closing the remaining Sprint-B ergonomic gaps, plus five fixes from a fresh-eyes code review. 1. sp.compare_estimators(method_hints={...}) New kwarg forwards per-method kwargs to each estimator when the shared (data, y, treatment, covariates, id, time, instrument) signature is too narrow. Enables six Sprint-B methods in batch comparison: - proximal — needs {proxy_z, proxy_w} - msm — needs {time_varying} + id + time - principal_strat — needs {strata} - front_door — needs {mediator} - mediate — needs {mediator} - mediate_interventional — needs {mediator, tv_confounders?} _resolve_hints() merges shared + hint per ROADMAP §6: per-method hints take precedence, UserWarning fires on conflict. List comparison is set-based (order-insensitive) so a reordered-but-set-equal list doesn't trigger spurious warnings. 2. sp.sensitivity_dashboard — Sprint-B-aware dimensions - 'first_stage_f' (proximal): surfaces the IV-weakness check from model_info as its own dashboard row with F ≥ 10 Stock-Yogo pass logic. - 'trim_sweep' (msm): max-stabilized-weight positivity watchdog against the same 50 threshold as the diagnostic battery. - 'monotonicity' (principal_strat): monotonicity-violation fraction, 5% tolerance. When dimensions= is left at default None, the list now auto-expands with the matching Sprint-B token so callers don't need to know the token. Baseline extraction handles PrincipalStratResult (no .estimate / .params) by pulling the complier row from .effects by label, not by .iloc[0] — so an upstream reorder of the effects DataFrame can't silently make the dashboard report an always-taker effect. Method label prefers model_info['estimator'] for PSR. 3. sp.g_computation error message compaction Non-binary D previously dumped the full sorted list of unique values (~20 KB for n=400 continuous D) which exploded to a multi-KB UserWarning when compare_estimators wrapped it. The message now truncates to 5 values + "(N more)" count, filters NaNs out of the sort (NaN sort order is undefined), and notes NaN presence separately. Five code-review findings applied: - BLOCKER: hint-wins test now verifies the hint VALUE (via a second run with a different hint set that produces a different estimate), not just that a warning fired. - MAJOR: list equality in _resolve_hints is set-based (reorder-tolerant). - MAJOR: complier row extraction uses stratum name filter, not .iloc[0] positional indexing, in both sensitivity.py and compare.py. - MINOR: sorted(uniq) in g_computation filters NaN pre-sort and annotates separately. - MINOR: sensitivity_dashboard default dimensions auto-expand with Sprint-B tokens so callers without the explicit token still see the method-specific rows. Regression: 200/200 Sprint-B tests pass (12 new this round). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 577f66d commit 1a8d89d

4 files changed

Lines changed: 732 additions & 21 deletions

File tree

src/statspai/inference/g_computation.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,9 +129,25 @@ def g_computation(
129129
if estimand in ('ATE', 'ATT'):
130130
uniq = set(np.unique(D))
131131
if not uniq.issubset({0, 1}):
132+
# Truncate the value dump: a continuous treatment can have
133+
# hundreds of unique values and the full list turns the
134+
# error message into a ~20 KB wall of floats — this
135+
# explodes warning-message size when a caller (e.g. the
136+
# compare_estimators batch loop) wraps the ValueError in
137+
# a UserWarning. Filter NaNs before sorting (a lone NaN
138+
# would sit in a non-deterministic position in Python's
139+
# sort and omit real values from the preview).
140+
finite = [v for v in uniq
141+
if not (isinstance(v, float) and np.isnan(v))]
142+
has_nan = len(finite) < len(uniq)
143+
vals = sorted(finite)
144+
preview = ', '.join(f'{v:.4g}' for v in vals[:5])
145+
tail = f', ... ({len(vals) - 5} more)' if len(vals) > 5 else ''
146+
nan_note = ' (plus NaN values present)' if has_nan else ''
132147
raise ValueError(
133148
f"estimand='{estimand}' requires binary treatment (0/1); "
134-
f"found values {sorted(uniq)}. For multi-valued or "
149+
f"treatment has {len(vals)} unique values "
150+
f"[{preview}{tail}]{nan_note}. For multi-valued or "
135151
f"continuous D, use estimand='dose_response'."
136152
)
137153
grid = np.array([0.0, 1.0])

src/statspai/smart/compare.py

Lines changed: 195 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ def compare_estimators(
115115
time: str = None,
116116
instrument: str = None,
117117
alpha: float = 0.05,
118+
method_hints: Optional[Dict[str, Dict[str, Any]]] = None,
118119
) -> ComparisonResult:
119120
"""
120121
Run multiple estimators on the same data and compare.
@@ -131,16 +132,31 @@ def compare_estimators(
131132
Treatment variable (binary).
132133
methods : list of str, optional
133134
Estimators to compare. Default auto-selects based on data.
134-
Options: 'ols', 'matching', 'ipw', 'aipw', 'dml',
135-
'g_computation', 'causal_forest', 'did', 'panel_fe'.
136-
137-
Not supported here (require extra arguments beyond the shared
138-
signature): ``'proximal'`` (needs proxy_z / proxy_w),
139-
``'msm'`` (needs time_varying / id / time),
140-
``'principal_strat'`` (needs a post-treatment strata variable),
141-
and the mediation family (needs a mediator). Call those
142-
estimators directly; see docs/ROADMAP.md for the planned
143-
hint-aware multi-method comparison surface.
135+
Classical options: ``'ols'``, ``'matching'``, ``'ipw'``,
136+
``'aipw'``, ``'dml'``, ``'g_computation'``,
137+
``'causal_forest'``, ``'did'``, ``'panel_fe'``.
138+
139+
Hint-driven Sprint-B options (require ``method_hints``):
140+
``'proximal'``, ``'msm'``, ``'principal_strat'``, ``'mediate'``,
141+
``'mediate_interventional'``, ``'front_door'``. Each needs
142+
method-specific kwargs the shared signature does not expose
143+
(proxy_z/proxy_w, time_varying, strata, mediator, etc.) — pass
144+
them through ``method_hints``.
145+
method_hints : dict, optional
146+
Per-method keyword overrides, merged with the shared kwargs
147+
when dispatching each estimator. Structure::
148+
149+
{'proximal': {'proxy_z': ['z'], 'proxy_w': ['w']},
150+
'msm': {'time_varying': ['L_lag']},
151+
'principal_strat': {'strata': 's'}}
152+
153+
**Collision rule** (docs/ROADMAP.md §6): per-method hints
154+
take precedence over the shared kwargs for the method they
155+
name. If the top-level ``covariates=['age']`` disagrees with
156+
``method_hints={'proximal': {'covariates': ['age', 'educ']}}``,
157+
proximal uses the hint and every other method uses the
158+
shared arg. A ``UserWarning`` fires on conflict so the
159+
override is visible in the log.
144160
covariates : list of str, optional
145161
id : str, optional
146162
Panel unit ID.
@@ -181,6 +197,44 @@ def compare_estimators(
181197
n = len(df)
182198
z_crit = stats.norm.ppf(1 - alpha / 2)
183199

200+
# Resolve per-method hints with the ROADMAP §6 collision rule:
201+
# hint-supplied args win over shared args for the method they name,
202+
# with a UserWarning on explicit conflict.
203+
method_hints = method_hints or {}
204+
205+
def _values_differ(a, b):
206+
"""
207+
Compare two hint values, with list equality being set-based so
208+
that pure reordering (e.g. ``['x1','x2']`` vs ``['x2','x1']``)
209+
does not trigger a spurious conflict warning. Tuple / set
210+
callers get the same treatment; everything else falls back
211+
to standard ``!=``.
212+
"""
213+
if isinstance(a, (list, tuple, set)) and isinstance(b, (list, tuple, set)):
214+
try:
215+
return set(a) != set(b)
216+
except TypeError:
217+
# Unhashable elements (e.g. nested lists) — fall back
218+
# to strict element-wise comparison, still
219+
# order-sensitive, but better than crashing.
220+
return list(a) != list(b)
221+
return a != b
222+
223+
def _resolve_hints(method_name, shared_kwargs):
224+
"""Merge shared kwargs with per-method hints; hint wins on conflict."""
225+
hint = method_hints.get(method_name, {}) or {}
226+
merged = dict(shared_kwargs)
227+
for k, v in hint.items():
228+
if k in merged and _values_differ(merged[k], v):
229+
warnings.warn(
230+
f"compare_estimators: method_hints['{method_name}']"
231+
f"['{k}'] overrides the shared {k}={shared_kwargs[k]!r} "
232+
f"with {v!r}.",
233+
UserWarning, stacklevel=2,
234+
)
235+
merged[k] = v
236+
return merged
237+
184238
results = {}
185239
rows = []
186240

@@ -279,6 +333,137 @@ def compare_estimators(
279333
results['DID'] = r
280334
name = 'Difference-in-Differences'
281335

336+
# ----- Sprint-B hint-driven branches -------------------- #
337+
# Each requires per-method kwargs supplied via
338+
# method_hints[method]; we raise a clean ValueError if the
339+
# mandatory hint is missing so the outer try/except turns
340+
# it into a compact UserWarning rather than silent skip.
341+
342+
elif method == 'proximal':
343+
hint = method_hints.get('proximal', {}) or {}
344+
proxy_z = hint.get('proxy_z')
345+
proxy_w = hint.get('proxy_w')
346+
if not proxy_z or not proxy_w:
347+
raise ValueError(
348+
"method='proximal' requires "
349+
"method_hints['proximal']={'proxy_z': [...], "
350+
"'proxy_w': [...]}."
351+
)
352+
merged = _resolve_hints('proximal', {
353+
'covariates': [c for c in covariates[:10]
354+
if c not in proxy_z + proxy_w],
355+
})
356+
r = sp.proximal(df, y=y, treat=treatment,
357+
proxy_z=list(proxy_z),
358+
proxy_w=list(proxy_w),
359+
covariates=merged['covariates'])
360+
est = r.estimate
361+
se = r.se
362+
results['Proximal'] = r
363+
name = 'Proximal Causal Inference (bridge 2SLS)'
364+
365+
elif method == 'msm':
366+
hint = method_hints.get('msm', {}) or {}
367+
tv = hint.get('time_varying')
368+
if not tv or not id or not time:
369+
raise ValueError(
370+
"method='msm' requires id + time + "
371+
"method_hints['msm']={'time_varying': [...]}."
372+
)
373+
merged = _resolve_hints('msm', {
374+
'baseline': [c for c in covariates[:5] if c not in tv],
375+
})
376+
r = sp.msm(df, y=y, treat=treatment, id=id, time=time,
377+
time_varying=list(tv),
378+
baseline=merged.get('baseline'))
379+
est = r.estimate
380+
se = r.se
381+
results['MSM'] = r
382+
name = 'Marginal Structural Model (IPTW)'
383+
384+
elif method == 'principal_strat':
385+
hint = method_hints.get('principal_strat', {}) or {}
386+
strata = hint.get('strata')
387+
if not strata:
388+
raise ValueError(
389+
"method='principal_strat' requires "
390+
"method_hints['principal_strat']={'strata': <col>}."
391+
)
392+
merged = _resolve_hints('principal_strat', {
393+
'covariates': covariates[:10],
394+
'method': 'principal_score' if covariates else 'monotonicity',
395+
})
396+
r = sp.principal_strat(
397+
df, y=y, treat=treatment, strata=strata,
398+
covariates=merged['covariates'] if merged['method'] == 'principal_score' else None,
399+
method=merged['method'],
400+
)
401+
# PrincipalStratResult is not a CausalResult; pull
402+
# the complier PCE (= LATE) by stratum NAME rather
403+
# than .iloc[0] so an upstream reordering of the
404+
# effects table can't silently make us report the
405+
# always-taker effect instead.
406+
effects = getattr(r, 'effects', None)
407+
if effects is not None and len(effects):
408+
_complier = effects['stratum'].astype(str).str.lower().str.contains('complier')
409+
_row = effects[_complier].iloc[0] if _complier.any() else effects.iloc[0]
410+
est = float(_row['estimate'])
411+
se = float(_row['se']) if 'se' in effects.columns else 0.0
412+
else:
413+
est, se = np.nan, np.nan
414+
results['Principal Strat'] = r
415+
name = 'Principal Stratification (complier PCE)'
416+
417+
elif method == 'front_door':
418+
hint = method_hints.get('front_door', {}) or {}
419+
mediator = hint.get('mediator')
420+
if not mediator:
421+
raise ValueError(
422+
"method='front_door' requires "
423+
"method_hints['front_door']={'mediator': <col>}."
424+
)
425+
merged = _resolve_hints('front_door', {
426+
'covariates': [c for c in covariates[:10] if c != mediator],
427+
})
428+
r = sp.front_door(df, y=y, treat=treatment,
429+
mediator=mediator,
430+
covariates=merged['covariates'])
431+
est = r.estimate
432+
se = r.se
433+
results['Front-door'] = r
434+
name = 'Front-door adjustment'
435+
436+
elif method in ('mediate', 'mediate_interventional'):
437+
hint = method_hints.get(method, {}) or {}
438+
mediator = hint.get('mediator')
439+
if not mediator:
440+
raise ValueError(
441+
f"method='{method}' requires "
442+
f"method_hints['{method}']={{'mediator': <col>}}."
443+
)
444+
merged = _resolve_hints(method, {
445+
'covariates': [c for c in covariates[:10] if c != mediator],
446+
})
447+
if method == 'mediate':
448+
r = sp.mediate(df, y=y, treat=treatment,
449+
mediator=mediator,
450+
covariates=merged['covariates'] or None)
451+
label = 'Causal Mediation (ACME)'
452+
key = 'Mediation (natural)'
453+
else:
454+
tv_c = hint.get('tv_confounders')
455+
r = sp.mediate_interventional(
456+
df, y=y, treat=treatment, mediator=mediator,
457+
covariates=merged['covariates'] or None,
458+
tv_confounders=list(tv_c) if tv_c else None,
459+
)
460+
label = 'Interventional Mediation (IIE)'
461+
key = 'Mediation (interventional)'
462+
est = r.estimate
463+
se = r.se
464+
results[key] = r
465+
name = label
466+
282467
else:
283468
continue
284469

0 commit comments

Comments
 (0)