5757import 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+
430633def _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