Skip to content

Commit f77be22

Browse files
feat(smart): DAG-based bad controls + strict mode + IV first-stage F in check_identification
Closes trade-offs #1 and #2 from v0.9.3+1 retrospective. New capabilities on sp.check_identification(): 1. DAG-based bad-control detection (trade-off #2) - New ``dag=`` parameter accepting an sp.DAG. - When supplied, runs dag.bad_controls(treatment, outcome) (Cinelli-Forney-Pearl 2022) and flags mediators, descendants, colliders, and M-bias structural problems as BLOCKERS. - Also verifies the covariate set satisfies one of the DAG's adjustment sets; suggests the shortest valid adjustment set when the user's covariate list fails. - Catches the M-bias case where the correlation heuristic is silent (collider with low treatment correlation). 2. Strict mode - New ``strict=True`` parameter. When the report's verdict is 'BLOCKERS', raises IdentificationError with the full report attached on ``err.report`` so downstream code can still inspect findings without re-running. - IdentificationError exposed at sp.IdentificationError. - Default strict=False preserves existing return-a-report behaviour. 3. IV first-stage F check - When design='iv' with an instrument column, computes the first-stage F (OLS of d on z) and flags: F < 5 -> blocker (weak identification fails) F < 10 -> warning (Staiger-Stock 1997) F < 30 -> info (moderate strength) F >= 30 -> silent (comfortable) 6 new tests (4 DAG + strict mode + IV-strength). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent dd22d69 commit f77be22

4 files changed

Lines changed: 502 additions & 1 deletion

File tree

src/statspai/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
>>> sp.outreg2(result, filename="results.xlsx")
2323
"""
2424

25-
__version__ = "0.9.3"
25+
__version__ = "0.9.4"
2626
__author__ = "Biaoyue Wang"
2727
__email__ = "bryce@copaper.ai"
2828

@@ -131,6 +131,7 @@
131131
from .gmm import xtabond
132132
from .metalearners import metalearner, SLearner, TLearner, XLearner, RLearner, DRLearner
133133
from .metalearners import cate_summary, cate_by_group, cate_plot, cate_group_plot, predict_cate, compare_metalearners, gate_test, blp_test
134+
from .metalearners import auto_cate, AutoCATEResult
134135
from .regression.heckman import heckman
135136
from .regression.quantile import qreg, sqreg
136137
from .regression.tobit import tobit
@@ -237,6 +238,7 @@
237238
pub_ready, PubReadyResult,
238239
replicate, list_replications,
239240
check_identification, IdentificationReport, DiagnosticFinding,
241+
IdentificationError,
240242
)
241243

242244
# verify / verify_recommendation / verify_benchmark are loaded lazily via
@@ -553,6 +555,8 @@
553555
"compare_metalearners",
554556
"gate_test",
555557
"blp_test",
558+
"auto_cate",
559+
"AutoCATEResult",
556560
# Neural Causal Models
557561
"tarnet",
558562
"cfrnet",
@@ -720,6 +724,7 @@
720724
# === Smart Workflow Engine (unique to StatsPAI) ===
721725
"recommend", "RecommendationResult",
722726
"check_identification", "IdentificationReport", "DiagnosticFinding",
727+
"IdentificationError",
723728
"compare_estimators", "ComparisonResult",
724729
"assumption_audit", "AssumptionResult",
725730
"sensitivity_dashboard", "SensitivityDashboard",

src/statspai/smart/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
check_identification,
2222
IdentificationReport,
2323
DiagnosticFinding,
24+
IdentificationError,
2425
)
2526

2627
# verify_recommendation / verify_benchmark are lazily imported via
@@ -37,6 +38,7 @@
3738
"replicate", "list_replications",
3839
"verify_recommendation", "verify_benchmark",
3940
"check_identification", "IdentificationReport", "DiagnosticFinding",
41+
"IdentificationError",
4042
]
4143

4244

src/statspai/smart/identification.py

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,27 @@
5757
import pandas as pd
5858

5959

60+
# ---------------------------------------------------------------------------
61+
# Exception type for strict mode
62+
# ---------------------------------------------------------------------------
63+
64+
class IdentificationError(Exception):
65+
"""Raised by ``check_identification(strict=True)`` when a blocker is found.
66+
67+
Carries the full :class:`IdentificationReport` on ``self.report`` so
68+
downstream code can still inspect findings without re-running.
69+
"""
70+
71+
def __init__(self, report: 'IdentificationReport'):
72+
self.report = report
73+
blockers = [f for f in report.findings if f.severity == 'blocker']
74+
header = (f"Identification has {len(blockers)} blocker(s) "
75+
f"({report.design} design, N={report.n_obs})")
76+
body = '\n'.join(f' - {f.category}: {f.message}'
77+
for f in blockers)
78+
super().__init__(f"{header}\n{body}" if body else header)
79+
80+
6081
# ---------------------------------------------------------------------------
6182
# Result type
6283
# ---------------------------------------------------------------------------
@@ -427,6 +448,188 @@ def _check_clustering(
427448
))
428449

429450

451+
def _check_dag_bad_controls(
452+
dag,
453+
treatment: str,
454+
outcome: str,
455+
covariates: Sequence[str],
456+
findings: List[DiagnosticFinding],
457+
) -> None:
458+
"""DAG-based bad-control detection (Cinelli-Forney-Pearl 2022).
459+
460+
Unlike the correlation heuristic, this catches *M-bias* colliders,
461+
mediators, and descendants of the treatment that look pre-treatment
462+
but violate backdoor adjustment.
463+
"""
464+
if dag is None:
465+
return
466+
# Flag any requested covariate that is itself a bad control
467+
try:
468+
dag_bad = dag.bad_controls(treatment, outcome)
469+
except Exception as e:
470+
findings.append(DiagnosticFinding(
471+
severity='info',
472+
category='bad_controls',
473+
message=f'DAG bad-control analysis skipped ({e}).',
474+
))
475+
return
476+
477+
requested = set(covariates or [])
478+
hit = {v: r for v, r in dag_bad.items() if v in requested}
479+
if hit:
480+
for v, reasons in hit.items():
481+
findings.append(DiagnosticFinding(
482+
severity='blocker',
483+
category='bad_controls',
484+
message=(f"Covariate '{v}' is a DAG-flagged bad control: "
485+
f"{'; '.join(reasons)}."),
486+
suggestion=f"Remove '{v}' from the covariate set; use "
487+
f"DAG.adjustment_sets('{treatment}', "
488+
f"'{outcome}') for a valid alternative.",
489+
evidence={'covariate': v, 'reasons': reasons},
490+
))
491+
492+
# Also check that covariates form a valid adjustment set
493+
try:
494+
adj_sets = dag.adjustment_sets(treatment, outcome)
495+
except Exception:
496+
adj_sets = []
497+
if adj_sets:
498+
valid = any(set(a).issubset(requested) for a in adj_sets)
499+
if not valid and requested:
500+
shortest = min(adj_sets, key=len)
501+
findings.append(DiagnosticFinding(
502+
severity='warning',
503+
category='bad_controls',
504+
message=('Covariate set does not satisfy any DAG '
505+
'adjustment criterion; backdoor paths may be '
506+
'open.'),
507+
suggestion=f'Use adjustment set: {sorted(shortest)}.',
508+
evidence={'valid_adjustment_sets': [sorted(s)
509+
for s in adj_sets[:3]]},
510+
))
511+
512+
513+
def _check_iv_strength(
514+
data: pd.DataFrame,
515+
treatment: str,
516+
instrument: str,
517+
findings: List[DiagnosticFinding],
518+
covariates: Optional[Sequence[str]] = None,
519+
) -> None:
520+
"""Check instrument strength via first-stage F against Staiger-Stock rule.
521+
522+
Runs a first-stage OLS of
523+
``treatment ~ intercept + covariates + instrument`` and reports
524+
the F-statistic on the instrument coefficient (squared t-stat
525+
under homoskedasticity). When covariates are supplied they are
526+
partialled out before computing the F — this matches the
527+
Staiger-Stock (1997) definition, which is conditional on
528+
exogenous controls.
529+
530+
Flags:
531+
- blocker if F < 5 (strongly underidentified)
532+
- warning if F < 10 (Staiger-Stock 1997 rule-of-thumb)
533+
- info if F in [10, 30) ("moderate" strength)
534+
- silent if F >= 30 (comfortable)
535+
536+
Skipped gracefully if columns are missing or non-numeric.
537+
"""
538+
if treatment not in data.columns or instrument not in data.columns:
539+
return
540+
541+
# Restrict to numeric covariates; silently drop non-numeric to avoid
542+
# leaking a type error from a diagnostic helper.
543+
cov_cols: List[str] = []
544+
if covariates:
545+
cov_cols = [c for c in covariates
546+
if c in data.columns
547+
and c not in (treatment, instrument)
548+
and pd.api.types.is_numeric_dtype(data[c])]
549+
550+
needed = [treatment, instrument] + cov_cols
551+
sub = data[needed].apply(pd.to_numeric, errors='coerce').dropna()
552+
n = len(sub)
553+
if n < 20:
554+
return # too small to say anything useful
555+
556+
t_vec = sub[treatment].to_numpy(dtype=float)
557+
z_vec = sub[instrument].to_numpy(dtype=float)
558+
559+
if cov_cols:
560+
# Partial out intercept + covariates from both t and z via OLS,
561+
# then run the single-regressor first stage on the residuals.
562+
# This yields the correct first-stage F on z after covariates.
563+
W = np.column_stack([np.ones(n), sub[cov_cols].to_numpy(dtype=float)])
564+
try:
565+
WtW_inv = np.linalg.pinv(W.T @ W)
566+
H_proj = W @ WtW_inv @ W.T # projection onto span(W)
567+
except np.linalg.LinAlgError:
568+
return
569+
t_res = t_vec - H_proj @ t_vec
570+
z_res = z_vec - H_proj @ z_vec
571+
# k_controls = len(cov_cols) + 1 (intercept) — degrees-of-freedom
572+
# adjustment below accounts for them.
573+
k_controls = W.shape[1]
574+
else:
575+
t_res = t_vec - t_vec.mean()
576+
z_res = z_vec - z_vec.mean()
577+
k_controls = 1 # intercept only
578+
579+
denom = float((z_res ** 2).sum())
580+
if denom <= 0 or not np.isfinite(denom):
581+
findings.append(DiagnosticFinding(
582+
severity='blocker',
583+
category='variation',
584+
message=f"Instrument '{instrument}' has no residual variance "
585+
f"after partialling out covariates; first stage is "
586+
f"undefined.",
587+
))
588+
return
589+
590+
b = float((z_res * t_res).sum() / denom)
591+
resid = t_res - b * z_res
592+
ss_res = float((resid ** 2).sum())
593+
df_resid = n - k_controls - 1 # controls + intercept + instrument
594+
if df_resid <= 0:
595+
return
596+
sigma2 = ss_res / df_resid
597+
var_b = sigma2 / denom
598+
if var_b <= 0 or not np.isfinite(var_b):
599+
return
600+
f_stat = float((b ** 2) / var_b)
601+
602+
if f_stat < 5.0:
603+
findings.append(DiagnosticFinding(
604+
severity='blocker',
605+
category='variation',
606+
message=f"Weak instrument: first-stage F = {f_stat:.2f} "
607+
f"(< 5). Point identification effectively fails.",
608+
suggestion="Use weak-IV-robust inference "
609+
"(statspai.iv.anderson_rubin_ci / conditional_lr_ci) "
610+
"or find a stronger instrument.",
611+
evidence={'first_stage_F': f_stat},
612+
))
613+
elif f_stat < 10.0:
614+
findings.append(DiagnosticFinding(
615+
severity='warning',
616+
category='variation',
617+
message=f"Weak instrument: first-stage F = {f_stat:.2f} "
618+
f"(< 10, Staiger-Stock 1997 rule).",
619+
suggestion="Use LIML / Fuller or weak-IV-robust CIs "
620+
"(statspai.iv.anderson_rubin_ci) instead of 2SLS.",
621+
evidence={'first_stage_F': f_stat},
622+
))
623+
elif f_stat < 30.0:
624+
findings.append(DiagnosticFinding(
625+
severity='info',
626+
category='variation',
627+
message=f"First-stage F = {f_stat:.2f} "
628+
f"(moderate instrument strength).",
629+
evidence={'first_stage_F': f_stat},
630+
))
631+
632+
430633
def _check_rd_density(
431634
data: pd.DataFrame,
432635
running_var: str,
@@ -485,6 +688,8 @@ def check_identification(
485688
cutoff: Optional[float] = None,
486689
design: Optional[str] = None,
487690
cohort: Optional[str] = None,
691+
dag=None,
692+
strict: bool = False,
488693
) -> IdentificationReport:
489694
"""Run design-level identification diagnostics before fitting an estimator.
490695
@@ -516,6 +721,16 @@ def check_identification(
516721
'rct', 'did', 'rd', 'iv', 'observational', 'panel'.
517722
cohort : str, optional
518723
First-treatment-period column (for staggered DID).
724+
dag : sp.DAG, optional
725+
Causal DAG. If supplied, runs Cinelli-Forney-Pearl (2022)
726+
bad-control detection (mediator, descendant, collider, M-bias)
727+
and verifies the covariate set satisfies a valid adjustment
728+
criterion. Upgrades correlation heuristic to a principled check.
729+
strict : bool, default False
730+
If True, raise :class:`IdentificationError` when the report's
731+
verdict is ``'BLOCKERS'``. Use in CI / automated pipelines
732+
where you want a hard failure when the design is broken.
733+
The exception carries ``.report`` for post-mortem inspection.
519734
520735
Returns
521736
-------
@@ -561,6 +776,9 @@ def check_identification(
561776
if treatment is not None:
562777
_check_bad_controls(data, treatment, covariates, y, time,
563778
findings=findings)
779+
if dag is not None:
780+
_check_dag_bad_controls(dag, treatment, y, covariates,
781+
findings=findings)
564782
_check_treatment_variation(data, treatment, findings)
565783
if covariates:
566784
_check_overlap(data, treatment, covariates, findings)
@@ -572,6 +790,13 @@ def check_identification(
572790
if design == 'rd' and running_var is not None:
573791
_check_rd_density(data, running_var, cutoff or 0.0, findings)
574792

793+
if design == 'iv' and instrument is not None and treatment is not None:
794+
_check_iv_strength(data, treatment, instrument, findings,
795+
covariates=covariates)
796+
575797
_check_clustering(data, id, time, cluster, findings)
576798

799+
if strict and report.verdict == 'BLOCKERS':
800+
raise IdentificationError(report)
801+
577802
return report

0 commit comments

Comments
 (0)