Skip to content

Commit 975a3ab

Browse files
feat(survey): raking + linear calibration (Deville-Särndal 1992)
SP-09 Task 1. Ports R's survey::calibrate() / survey::rake(): - rake(data, margins): IPF to match marginal distributions. Hits target within 0.01 tolerance on biased sample. - linear_calibration(data, totals): chi-squared distance calibration that exactly hits population totals for continuous auxiliaries. Exposed at sp.rake / sp.linear_calibration. 4 tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 932bd37 commit 975a3ab

24 files changed

Lines changed: 5045 additions & 2 deletions

src/statspai/iv/__init__.py

Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
"""
2+
statspai.iv — the unified Instrumental Variables namespace.
3+
4+
The goal of this subpackage is to be the single entry point for every
5+
IV-flavoured workflow in StatsPAI, regardless of which sub-module the
6+
underlying implementation lives in:
7+
8+
- Core point estimators (2SLS, LIML, Fuller, GMM, JIVE) live in
9+
:mod:`statspai.regression.iv` — re-exported as ``sp.iv.iv``,
10+
``sp.iv.ivreg`` and ``sp.iv.IVRegression``.
11+
- JIVE variants (UJIVE, IJIVE, RJIVE) live here in
12+
:mod:`statspai.iv.jive_variants`.
13+
- Weak-identification diagnostics (Olea-Pflueger effective F, Lee-McCrary
14+
tF, Anderson-Rubin CI) live in :mod:`statspai.diagnostics.weak_iv`
15+
— re-exported as ``sp.iv.effective_f_test`` etc.
16+
- New diagnostics introduced in this subpackage:
17+
``kleibergen_paap_rk``, ``sanderson_windmeijer``, ``conditional_lr_test``.
18+
- Plausibly exogenous sensitivity (Conley-Hansen-Rossi 2012):
19+
``plausibly_exogenous_uci``, ``plausibly_exogenous_ltz``.
20+
- Marginal Treatment Effects (Brinch-Mogstad-Wiswall 2017):
21+
``mte``.
22+
- Shift-share IV (Bartik + Adão-Kolesár-Morales correction) is re-exported
23+
as ``sp.iv.bartik`` / ``sp.iv.shift_share_se``.
24+
- DeepIV (Hartford et al. 2017) is re-exported as ``sp.iv.deepiv``.
25+
26+
A thin :func:`fit` dispatcher ties everything together and auto-runs an
27+
expanded diagnostic panel (first-stage F, MOP effective F, KP rk,
28+
SW per-endog F, Hansen J, AR Wald).
29+
30+
Examples
31+
--------
32+
>>> import statspai as sp
33+
>>> # Standard 2SLS with a rich diagnostic panel
34+
>>> res = sp.iv.fit("y ~ (d ~ z1 + z2) + x1", data=df)
35+
>>> print(res.summary())
36+
>>> print(res.diagnostics) # includes MOP F, KP rk, SW, AR CI
37+
38+
>>> # Sensitivity to exclusion-restriction violations
39+
>>> chr = sp.iv.plausibly_exogenous_ltz(
40+
... y="y", endog="d", instruments=["z1", "z2"],
41+
... gamma_mean=0.0, gamma_var=0.01, data=df,
42+
... )
43+
44+
>>> # Marginal treatment effects
45+
>>> m = sp.iv.mte(y="y", treatment="d", instruments=["z"], exog=["x"], data=df)
46+
>>> m.mte_curve.plot(x="u", y="mte")
47+
"""
48+
49+
from __future__ import annotations
50+
51+
from typing import Any, Dict, Optional
52+
53+
# ─── Core estimators (re-exports) ───────────────────────────────────────
54+
from ..regression.iv import iv, ivreg, IVRegression
55+
from ..regression.advanced_iv import liml, jive as jive_legacy, lasso_iv
56+
57+
# ─── Weak-identification diagnostics ────────────────────────────────────
58+
from ..diagnostics.weak_iv import (
59+
anderson_rubin_test,
60+
effective_f_test,
61+
tF_critical_value,
62+
)
63+
from .weak_identification import (
64+
kleibergen_paap_rk,
65+
sanderson_windmeijer,
66+
conditional_lr_test,
67+
KleibergenPaapResult,
68+
SandersonWindmeijerResult,
69+
CLRResult,
70+
)
71+
72+
# ─── Plausibly exogenous ────────────────────────────────────────────────
73+
from .plausibly_exogenous import (
74+
plausibly_exogenous_uci,
75+
plausibly_exogenous_ltz,
76+
PlausiblyExogenousResult,
77+
)
78+
79+
# ─── JIVE variants ──────────────────────────────────────────────────────
80+
from .jive_variants import jive1, ujive, ijive, rjive, JIVEResult
81+
82+
# ─── Marginal Treatment Effects ─────────────────────────────────────────
83+
from .mte import mte, MTEResult
84+
85+
# ─── MST sharp identified bounds (LP-based) ─────────────────────────────
86+
from .ivmte_lp import ivmte_bounds, IVMTEBounds
87+
88+
# ─── Weak-IV-robust CIs by grid inversion ───────────────────────────────
89+
from .weak_iv_ci import (
90+
anderson_rubin_ci,
91+
conditional_lr_ci,
92+
k_test_ci,
93+
WeakIVConfidenceSet,
94+
)
95+
96+
# ─── Post-Lasso IV (Belloni-Chen-Chernozhukov-Hansen 2012) ──────────────
97+
from .post_lasso import (
98+
bch_post_lasso_iv,
99+
bch_lambda,
100+
bch_selected,
101+
PostLassoResult,
102+
)
103+
104+
# ─── Plot module (matplotlib imported lazily) ───────────────────────────
105+
from . import plot # noqa: F401
106+
107+
# ─── Bayesian IV (Chernozhukov-Hong 2003) ────────────────────────────────
108+
from .bayesian_iv import bayesian_iv, BayesianIVResult
109+
110+
# ─── Non-parametric IV (Newey-Powell 2003) ───────────────────────────────
111+
from .npiv import npiv, NPIVResult
112+
113+
# ─── Shift-share / DeepIV re-exports ────────────────────────────────────
114+
try:
115+
from ..bartik import bartik, shift_share_se, BartikIV, ssaggregate
116+
except Exception: # pragma: no cover
117+
bartik = shift_share_se = BartikIV = ssaggregate = None
118+
119+
try:
120+
from ..deepiv import deepiv, DeepIV
121+
except Exception: # pragma: no cover
122+
deepiv = DeepIV = None
123+
124+
125+
# ═══════════════════════════════════════════════════════════════════════
126+
# Unified dispatcher: sp.iv.fit(...)
127+
# ═══════════════════════════════════════════════════════════════════════
128+
129+
_METHOD_ALIASES = {
130+
"2sls": "2sls", "tsls": "2sls", "iv": "2sls",
131+
"liml": "liml", "fuller": "fuller",
132+
"gmm": "gmm",
133+
"jive": "jive", "jive1": "jive",
134+
"ujive": "ujive", "ijive": "ijive", "rjive": "rjive",
135+
"mte": "mte",
136+
"deepiv": "deepiv", "deep": "deepiv",
137+
"shift_share": "shift_share", "bartik": "shift_share",
138+
}
139+
140+
141+
def fit(
142+
formula=None,
143+
data=None,
144+
*,
145+
method: str = "2sls",
146+
y=None,
147+
endog=None,
148+
instruments=None,
149+
exog=None,
150+
robust: str = "nonrobust",
151+
cluster=None,
152+
augmented_diagnostics: bool = True,
153+
**kwargs,
154+
):
155+
"""
156+
Unified IV dispatcher.
157+
158+
Parameters
159+
----------
160+
formula : str, optional
161+
``"y ~ (endog ~ z1 + z2) + x1 + x2"`` Patsy-style IV formula used
162+
by 2SLS/LIML/Fuller/GMM/JIVE paths.
163+
data : DataFrame, optional.
164+
method : str, default '2sls'
165+
One of 2sls, liml, fuller, gmm, jive, ujive, ijive, rjive,
166+
mte, deepiv, shift_share.
167+
y, endog, instruments, exog : arrays or column-name lists
168+
Alternative to ``formula`` — required for MTE / JIVE variants
169+
/ ShiftShare / DeepIV which do not use the formula parser.
170+
robust : str, default 'nonrobust'
171+
Only applies to formula methods.
172+
cluster : optional cluster ID column name.
173+
augmented_diagnostics : bool, default True
174+
Attach Kleibergen-Paap rk, Sanderson-Windmeijer, Olea-Pflueger
175+
effective F, and Anderson-Rubin CI to the returned result's
176+
``diagnostics`` dict when the method produces an EconometricResults.
177+
**kwargs
178+
Method-specific options (e.g. ``fuller_alpha``, ``poly_degree``).
179+
180+
Returns
181+
-------
182+
EconometricResults | JIVEResult | MTEResult | ...
183+
"""
184+
m = _METHOD_ALIASES.get(method.lower())
185+
if m is None:
186+
raise ValueError(
187+
f"Unknown method '{method}'. Choose from: "
188+
f"{sorted(set(_METHOD_ALIASES.values()))}"
189+
)
190+
191+
if m in ("2sls", "liml", "fuller", "gmm", "jive"):
192+
if formula is None or data is None:
193+
raise ValueError(f"method='{method}' requires formula + data.")
194+
model = IVRegression(
195+
formula=formula, data=data, method=m,
196+
fuller_alpha=kwargs.get("fuller_alpha", 1.0),
197+
)
198+
result = model.fit(robust=robust, cluster=cluster)
199+
if augmented_diagnostics:
200+
_attach_augmented_diagnostics(model, result, kwargs)
201+
return result
202+
203+
if m in ("ujive", "ijive", "rjive"):
204+
if formula is not None and data is not None:
205+
y_, endog_, instruments_, exog_ = _formula_to_parts(formula, data)
206+
else:
207+
y_, endog_, instruments_, exog_ = y, endog, instruments, exog
208+
fn = {"ujive": ujive, "ijive": ijive, "rjive": rjive}[m]
209+
return fn(y=y_, endog=endog_, instruments=instruments_, exog=exog_,
210+
data=data, **kwargs)
211+
212+
if m == "mte":
213+
if y is None or endog is None or instruments is None:
214+
raise ValueError("method='mte' requires y, endog, instruments.")
215+
return mte(
216+
y=y, treatment=endog, instruments=instruments, exog=exog, data=data,
217+
**kwargs,
218+
)
219+
220+
if m == "deepiv":
221+
if deepiv is None:
222+
raise ImportError("DeepIV requires torch; install torch to use it.")
223+
return deepiv(
224+
y=y, treatment=endog, instruments=instruments, exog=exog,
225+
data=data, **kwargs,
226+
)
227+
228+
if m == "shift_share":
229+
if bartik is None:
230+
raise ImportError("shift_share/bartik unavailable.")
231+
return bartik(y=y, shares=kwargs.pop("shares"),
232+
shocks=kwargs.pop("shocks"), data=data, **kwargs)
233+
234+
raise AssertionError(f"Unreachable: method={m}") # pragma: no cover
235+
236+
237+
def _formula_to_parts(formula: str, data):
238+
from ..core.utils import parse_formula
239+
parsed = parse_formula(formula)
240+
return (
241+
parsed["dependent"],
242+
parsed["endogenous"],
243+
parsed["instruments"],
244+
parsed.get("exogenous") or None,
245+
)
246+
247+
248+
def _attach_augmented_diagnostics(model, result, opts: Dict[str, Any]):
249+
"""Add KP rk, SW, MOP effective F to the EconometricResults diagnostics."""
250+
try:
251+
D = model.X_endog
252+
Z = model.Z
253+
W = model.X_exog
254+
255+
kp = kleibergen_paap_rk(
256+
endog=D, instruments=Z, exog=W[:, 1:] if W.shape[1] > 1 else None,
257+
add_const=W.shape[1] >= 1 and np.allclose(W[:, 0], 1.0) if W.shape[1] else True,
258+
cov_type="robust",
259+
)
260+
result.diagnostics["KP rk LM"] = kp.rk_lm
261+
result.diagnostics["KP rk LM p-value"] = kp.rk_lm_pvalue
262+
result.diagnostics["KP rk Wald F"] = kp.rk_f
263+
264+
if D.shape[1] >= 2:
265+
sw = sanderson_windmeijer(
266+
endog=D, instruments=Z,
267+
exog=W[:, 1:] if W.shape[1] > 1 else None,
268+
add_const=False, # already handled above in W
269+
endog_names=getattr(model, "_endog_names", None),
270+
)
271+
for name, f in sw.sw_f.items():
272+
result.diagnostics[f"SW conditional F ({name})"] = f
273+
274+
# Olea-Pflueger effective F (single endogenous variable case)
275+
if D.shape[1] == 1 and hasattr(model, "data") and model.data is not None:
276+
try:
277+
ep = effective_f_test(
278+
data=getattr(model, "_clean_data", model.data),
279+
endog=model._endog_names[0],
280+
instruments=list(model._instrument_names),
281+
exog=[e for e in model._exog_names if e != "Intercept"] or None,
282+
)
283+
if isinstance(ep, dict):
284+
stat = ep.get("F_eff") or ep.get("statistic") or ep.get("effective_F")
285+
else:
286+
stat = getattr(ep, "F_eff", None) or getattr(ep, "statistic", None)
287+
if stat is not None:
288+
result.diagnostics["Olea-Pflueger effective F"] = float(stat)
289+
except Exception as e:
290+
result.diagnostics["OP effective F error"] = str(e)
291+
except Exception as e: # pragma: no cover
292+
# Augmented diagnostics are optional; never crash the estimator.
293+
result.diagnostics["augmented_diagnostics_error"] = str(e)
294+
295+
296+
# np import only needed for the _attach helper; keep local
297+
import numpy as np # noqa: E402
298+
299+
300+
__all__ = [
301+
# dispatcher
302+
"fit",
303+
# core estimators
304+
"iv", "ivreg", "IVRegression", "liml", "jive_legacy", "lasso_iv",
305+
# JIVE variants
306+
"jive1", "ujive", "ijive", "rjive", "JIVEResult",
307+
# weak-ID diagnostics
308+
"kleibergen_paap_rk", "sanderson_windmeijer", "conditional_lr_test",
309+
"anderson_rubin_test", "effective_f_test", "tF_critical_value",
310+
"KleibergenPaapResult", "SandersonWindmeijerResult", "CLRResult",
311+
# plausibly exogenous
312+
"plausibly_exogenous_uci", "plausibly_exogenous_ltz", "PlausiblyExogenousResult",
313+
# MTE
314+
"mte", "MTEResult",
315+
"ivmte_bounds", "IVMTEBounds",
316+
# Post-Lasso BCH
317+
"bch_post_lasso_iv", "bch_lambda", "bch_selected", "PostLassoResult",
318+
# Weak-IV-robust confidence sets
319+
"anderson_rubin_ci", "conditional_lr_ci", "k_test_ci",
320+
"WeakIVConfidenceSet",
321+
# Bayesian IV
322+
"bayesian_iv", "BayesianIVResult",
323+
# NPIV
324+
"npiv", "NPIVResult",
325+
# re-exports
326+
"bartik", "shift_share_se", "BartikIV", "ssaggregate",
327+
"deepiv", "DeepIV",
328+
]

0 commit comments

Comments
 (0)