Skip to content

Commit 594461d

Browse files
fix(iv): 3 critical + 6 high-priority bugs from code review
CRITICAL fixes: - KP rk: Kronecker order inverted in robust meat (kron(v⊗v,z⊗z) → kron(z⊗z,v⊗v)) - KP rk LM: use SVD min singular value instead of tr(A²) for correct chi² df - ivmte_lp: remove dead-code tautology in ATT weight path HIGH fixes: - IJIVE: remove incorrect D-shrinkage (AD09 differs only in SE, not point est) - NPIV: multi-Z now uses linear projection index instead of ‖Z‖₂ - Bayesian IV: avoid n×n PZ matrix, use ZtZ_inv for O(nk) computation - weak_iv_ci: avoid n×n M_Z in CLR/K-test loops via YD'YD - (Zs'YD)'(Zs'YD) - KP rk: add try/pinv fallback for singular Z'Z - SW: correct df_denom to n-kW-k-(p-1) per SW (2016) eq. 7 MEDIUM fixes: - plausibly_exogenous: validate gamma_grid length divisibility - __init__.py: move numpy import to top of file Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2d8ae3e commit 594461d

10 files changed

Lines changed: 77 additions & 56 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "StatsPAI"
7-
version = "0.7.1"
7+
version = "0.8.0"
88
description = "The Agent-Native Causal Inference & Econometrics Toolkit for Python"
99
readme = "README.md"
1010
license = {text = "MIT"}

src/statspai/__init__.py

Lines changed: 1 addition & 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.7.1"
25+
__version__ = "0.8.0"
2626
__author__ = "Bryce Wang"
2727
__email__ = "bryce@copaper.ai"
2828

src/statspai/iv/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@
5050

5151
from typing import Any, Dict, Optional
5252

53+
import numpy as np
54+
5355
# ─── Core estimators (re-exports) ───────────────────────────────────────
5456
from ..regression.iv import iv, ivreg, IVRegression
5557
from ..regression.advanced_iv import liml, jive as jive_legacy, lasso_iv
@@ -293,8 +295,6 @@ def _attach_augmented_diagnostics(model, result, opts: Dict[str, Any]):
293295
result.diagnostics["augmented_diagnostics_error"] = str(e)
294296

295297

296-
# np import only needed for the _attach helper; keep local
297-
import numpy as np # noqa: E402
298298

299299

300300
__all__ = [

src/statspai/iv/bayesian_iv.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -190,25 +190,26 @@ def bayesian_iv(
190190
Zt = _residualize(Z, W)
191191
k = Zt.shape[1]
192192

193-
# Projection matrix for instruments
194-
PZ = Zt @ np.linalg.solve(Zt.T @ Zt + 1e-10 * np.eye(k), Zt.T)
195-
MZ = np.eye(n) - PZ
193+
# Precompute ZtZ_inv for fast u'PZu = u'Z(Z'Z)^{-1}Z'u = ||Z'u||²_{inv}
194+
ZtZ = Zt.T @ Zt + 1e-10 * np.eye(k)
195+
ZtZ_inv = np.linalg.inv(ZtZ)
196196

197197
def log_posterior(beta: float) -> float:
198198
u = Yt - beta * Dt
199-
u_Pz = float(u @ PZ @ u)
200-
u_Mz = float(u @ MZ @ u)
199+
Ztu = Zt.T @ u # (k,)
200+
u_Pz = float(Ztu @ ZtZ_inv @ Ztu)
201+
u_u = float(u @ u)
202+
u_Mz = u_u - u_Pz
201203
if u_Mz <= 0:
202204
return -np.inf
203205
# AR quasi-log-likelihood: -AR/2 where AR = n * u'PZu / u'MZu
204-
# Maximised when u orthogonal to Z (correct β)
205206
ar = n * u_Pz / u_Mz
206207
log_lik = -0.5 * ar
207208
log_prior = -0.5 * (beta / prior_sd) ** 2
208209
return log_lik + log_prior
209210

210211
# --- Initialize at 2SLS estimate ---
211-
D_hat = PZ @ Dt
212+
D_hat = Zt @ (ZtZ_inv @ (Zt.T @ Dt))
212213
denom = float(D_hat @ Dt)
213214
if abs(denom) > 1e-12:
214215
beta_init = float(D_hat @ Yt) / denom

src/statspai/iv/ivmte_lp.py

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -215,18 +215,11 @@ def _target_weights(
215215
return c
216216

217217
if target == "att":
218-
# ATT = ∫_0^1 MTE(u) · Pr(U≤u | D=1) du ≈ ∫_0^1 (m_1-m_0) * (p̄≥u) ...
219-
# We approximate using observed propensity distribution among treated.
220-
# Concretely: weight_k = ∫_0^1 u^k * F̄_P|D=1(u) du
221-
# F̄_P|D=1(u) = Pr(P ≥ u | D=1).
222-
if target == "att":
223-
p_t = p_hat # treated filter passed externally
224-
p_sample = late_bounds if late_bounds is not None else p_hat
225-
# simplification: use uniform plug-in via empirical CDF samples
218+
# ATT = ∫_0^1 MTE(u) · Pr(P ≥ u | D=1) du / E[P | D=1]
219+
# p_hat is already the treated subsample (caller passes p_hat[D==1])
226220
u_grid = np.linspace(0.01, 0.99, 101)
227-
w = np.array([(p_sample >= u).mean() for u in u_grid])
221+
w = np.array([(p_hat >= u).mean() for u in u_grid])
228222
w /= max(np.trapezoid(w, u_grid), 1e-12)
229-
# For each k, ∫ u^k * w(u) du
230223
wk = np.array([np.trapezoid(u_grid ** k * w, u_grid) for k in range(K + 1)])
231224
return np.concatenate([wk, -wk])
232225

src/statspai/iv/jive_variants.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -185,14 +185,11 @@ def _jive_estimate(
185185
D_hat = ((Pz @ D) - hz[:, None] * D) / (1 - hz)[:, None]
186186

187187
elif method == "ijive":
188-
# Ackerberg-Devereux 2009 IJIVE: drop own-observation and re-scale
188+
# Ackerberg-Devereux 2009 IJIVE: leave-one-out projection
189+
# identical to JIVE1 in point estimate; differs in SE.
189190
D_hat = np.empty_like(D)
190191
for j in range(p):
191192
D_hat[:, j] = (D_hat_full[:, j] - h * D[:, j]) / (1 - h)
192-
# IJIVE additional degrees-of-freedom correction:
193-
# scale by (n - k - W.shape[1]) / n (AD09 eq 10 variant)
194-
scale = (n - k - W.shape[1]) / n if n > k + W.shape[1] else 1.0
195-
D_hat = scale * D_hat + (1 - scale) * D
196193

197194
elif method == "rjive":
198195
if ridge <= 0:

src/statspai/iv/npiv.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -197,8 +197,14 @@ def npiv(
197197
Zt = _residualize(Z, W)
198198

199199
# === Stage 1: sieve regression of D on Z ===
200-
Phi_Z = _build_basis(Zt[:, 0] if Zt.shape[1] == 1 else np.linalg.norm(Zt, axis=1),
201-
k_z, basis)
200+
if Zt.shape[1] == 1:
201+
z_scalar = Zt[:, 0]
202+
else:
203+
# Multi-instrument: build tensor-product basis would be complex;
204+
# use first-stage fitted value from linear projection as the index.
205+
pi_lin, *_ = np.linalg.lstsq(Zt, Dt, rcond=None)
206+
z_scalar = Zt @ pi_lin # scalar index, preserves instrument info
207+
Phi_Z = _build_basis(z_scalar, k_z, basis)
202208
pi_hat, *_ = np.linalg.lstsq(Phi_Z, Dt, rcond=None)
203209
Dt_hat = Phi_Z @ pi_hat
204210
resid_fs = Dt - Dt_hat

src/statspai/iv/plausibly_exogenous.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,15 @@ def plausibly_exogenous_uci(
153153

154154
gamma_arr = np.asarray(list(gamma_grid), dtype=float)
155155
if gamma_arr.ndim == 1:
156-
gamma_arr = gamma_arr.reshape(-1, Z.shape[1]) if Z.shape[1] > 1 else gamma_arr.reshape(-1, 1)
156+
if Z.shape[1] > 1:
157+
if len(gamma_arr) % Z.shape[1] != 0:
158+
raise ValueError(
159+
f"gamma_grid length {len(gamma_arr)} is not divisible "
160+
f"by n_instruments={Z.shape[1]}"
161+
)
162+
gamma_arr = gamma_arr.reshape(-1, Z.shape[1])
163+
else:
164+
gamma_arr = gamma_arr.reshape(-1, 1)
157165

158166
beta_hat, _, var0 = _tsls(Y, D, Z, W)
159167
# When p_endog > 1, focus on the "target_endog" coefficient (default: column 0).

src/statspai/iv/weak_identification.py

Lines changed: 31 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,11 @@ def kleibergen_paap_rk(
235235
Z_tilde = _residualize(Z, W)
236236

237237
# Reduced form coefficients: D_tilde = Z_tilde @ Pi + V
238-
ZZ_inv = np.linalg.inv(Z_tilde.T @ Z_tilde)
238+
ZtZ = Z_tilde.T @ Z_tilde
239+
try:
240+
ZZ_inv = np.linalg.inv(ZtZ)
241+
except np.linalg.LinAlgError:
242+
ZZ_inv = np.linalg.pinv(ZtZ)
239243
Pi = ZZ_inv @ (Z_tilde.T @ D_tilde) # k x p
240244
V = D_tilde - Z_tilde @ Pi
241245

@@ -246,13 +250,16 @@ def kleibergen_paap_rk(
246250
cov_vec = np.kron(Sigma, ZZ_inv)
247251
cov_label = "nonrobust"
248252
elif cov_type == "robust":
249-
# Meat: sum_i (Z_i Z_i') ⊗ (V_i V_i')
253+
# Meat: Σ_i kron(z_i z_i', v_i v_i') — KP (2006) eq. 13
254+
# Convention: vec(Pi) stacks columns of Pi (k×p), so Var is (kp × kp)
255+
# with blocks (Z'Z)^{-1} ⊗ Σ_VV under homoskedasticity.
256+
# Robust sandwich: bread = kron(ZZ_inv, I_p) on both sides.
250257
meat = np.zeros((k * p, k * p))
251258
for i in range(n):
252-
zi = Z_tilde[i:i + 1].T # k x 1
253-
vi = V[i:i + 1].T # p x 1
254-
meat += np.kron(vi @ vi.T, zi @ zi.T)
255-
bread = np.kron(np.eye(p), ZZ_inv)
259+
zi = Z_tilde[i] # (k,)
260+
vi = V[i] # (p,)
261+
meat += np.kron(np.outer(zi, zi), np.outer(vi, vi))
262+
bread = np.kron(ZZ_inv, np.eye(p))
256263
cov_vec = bread @ meat @ bread
257264
cov_label = "HC robust"
258265
elif cov_type == "cluster":
@@ -264,17 +271,14 @@ def kleibergen_paap_rk(
264271
meat = np.zeros((k * p, k * p))
265272
for cid in groups:
266273
idx = np.where((g == cid).values)[0]
267-
Zc = Z_tilde[idx]
268-
Vc = V[idx]
269-
score = np.kron(Vc, Zc).sum(axis=0)
270-
# score is p*k (row-major kron of per-obs kron sum); rebuild properly
274+
# Cluster score: Σ_{t∈cluster} kron(z_t, v_t) → vectorised form
271275
score_mat = np.zeros((k, p))
272276
for t in idx:
273277
score_mat += np.outer(Z_tilde[t], V[t])
274-
v = score_mat.flatten(order='F') # matches vec(Pi) stacking cols
278+
v = score_mat.flatten(order='F') # vec(score) with col-stacking
275279
meat += np.outer(v, v)
276280
meat *= G / max(G - 1, 1)
277-
bread = np.kron(np.eye(p), ZZ_inv)
281+
bread = np.kron(ZZ_inv, np.eye(p))
278282
cov_vec = bread @ meat @ bread
279283
cov_label = f"cluster ({G} groups)"
280284
else:
@@ -291,19 +295,26 @@ def kleibergen_paap_rk(
291295
rk_f = rk_wald / df_num
292296
rk_wald_pvalue = float(1 - stats.chi2.cdf(rk_wald, df=k - p + 1))
293297

294-
# KP rk LM statistic (Kleibergen 2002 / Kleibergen-Paap 2006 eq. 17)
295-
# Equivalent to Wald when homoskedastic, more stable under weak identification
296-
# KP rk LM = n * tr( (D'M_W D)^{-1} D'P_Z D ) after robust-ification
298+
# KP rk LM statistic (Kleibergen-Paap 2006, Theorem 1)
299+
# Tests H0: rank(Pi) <= p-1 vs H1: rank(Pi) = p.
300+
# The rk LM is based on the *smallest* canonical correlation /
301+
# singular value of the whitened reduced-form matrix A = Zs' Ds.
302+
# Under H0, rk_lm ~ chi²((k - p + 1)).
297303
Sigma = (V.T @ V) / n
298304
try:
299305
Sigma_half_inv = np.linalg.inv(np.linalg.cholesky(Sigma))
300306
except np.linalg.LinAlgError:
301307
Sigma_half_inv = np.linalg.pinv(_sqrtm_sym(Sigma))
302-
Zs = Z_tilde @ np.linalg.cholesky(ZZ_inv) # orthonormalised instruments
308+
try:
309+
ZZ_chol = np.linalg.cholesky(Z_tilde.T @ Z_tilde / n)
310+
Zs = Z_tilde @ np.linalg.inv(ZZ_chol.T) # orthonormalised instruments
311+
except np.linalg.LinAlgError:
312+
Zs = Z_tilde
303313
Ds = D_tilde @ Sigma_half_inv.T # whitened endog
304-
A = Zs.T @ Ds # k x p
305-
rk_lm = float(np.sum(A ** 2))
306-
rk_lm_pvalue = float(1 - stats.chi2.cdf(rk_lm, df=(k - p + 1) * p))
314+
A = Zs.T @ Ds / np.sqrt(n) # k x p
315+
sv = np.linalg.svd(A, compute_uv=False)
316+
rk_lm = float(n * sv[-1] ** 2) # smallest sv², scaled by n
317+
rk_lm_pvalue = float(1 - stats.chi2.cdf(rk_lm, df=(k - p + 1)))
307318

308319
return KleibergenPaapResult(
309320
rk_wald=rk_wald,
@@ -412,7 +423,7 @@ def sanderson_windmeijer(
412423
tss = float(y_j @ y_j)
413424

414425
df1 = k - (p - 1) # SW adjusted numerator df
415-
df2 = n - n_W - k # denominator df (net of all controls + instruments)
426+
df2 = n - n_W - k - (p - 1) # SW (2016) eq. 7 denominator df
416427
if df1 <= 0:
417428
raise ValueError(
418429
f"Not enough instruments: k - (p-1) = {df1} for endogenous '{names[j]}'."

src/statspai/iv/weak_iv_ci.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -250,13 +250,18 @@ def conditional_lr_ci(
250250
stat_arr = np.empty(len(beta_grid))
251251
crit_arr = np.empty(len(beta_grid))
252252

253+
df_r = max(n - kW - k, 1)
254+
# Precompute Zs'Dt (invariant across loop)
255+
ZsDt = Zs.T @ Dt # (k,)
256+
DtDt = float(Dt @ Dt)
257+
DtZsZsDt = float(ZsDt @ ZsDt)
258+
253259
for i, b0 in enumerate(beta_grid):
254260
ustar = Yt - b0 * Dt
255-
# Reduced-form Sigma under H0
256-
M_Z = np.eye(n) - Zs @ Zs.T
261+
# Sigma = YD' M_Z YD / df, avoiding n×n M_Z via YD'YD - (Zs'YD)'(Zs'YD)
257262
YD = np.column_stack([ustar, Dt])
258-
df_r = max(n - kW - k, 1)
259-
Sigma = YD.T @ M_Z @ YD / df_r
263+
ZsYD = Zs.T @ YD # (k, 2)
264+
Sigma = (YD.T @ YD - ZsYD.T @ ZsYD) / df_r
260265
suu = float(Sigma[0, 0])
261266
svv = float(Sigma[1, 1])
262267
suv = float(Sigma[0, 1])
@@ -333,13 +338,13 @@ def k_test_ci(
333338
crit = stats.chi2.ppf(level, df=1)
334339

335340
stat_arr = np.empty(len(beta_grid))
336-
M_Z = np.eye(n) - Zs @ Zs.T
341+
df_r = max(n - kW - k, 1)
337342

338343
for i, b0 in enumerate(beta_grid):
339344
ustar = Yt - b0 * Dt
340345
YD = np.column_stack([ustar, Dt])
341-
df_r = max(n - kW - k, 1)
342-
Sigma = YD.T @ M_Z @ YD / df_r
346+
ZsYD = Zs.T @ YD
347+
Sigma = (YD.T @ YD - ZsYD.T @ ZsYD) / df_r
343348
suu = float(Sigma[0, 0])
344349
svv = float(Sigma[1, 1])
345350
suv = float(Sigma[0, 1])

0 commit comments

Comments
 (0)