Skip to content

ENH: Add an opt-in QR solver to IV2SLS and IVLIML - #870

Open
bashtage wants to merge 21 commits into
mainfrom
iv-qr-2sls
Open

bashtage wants to merge 21 commits into
mainfrom
iv-qr-2sls

Conversation

@bashtage

@bashtage bashtage commented Nov 6, 2026 •

Copy link
Copy Markdown
Owner

Adds an opt-in QR method to IV2SLS and IVLIML for 7.2. It supersedes #850, which is closed. The default computation does not change in 7.2; a FutureWarning announces that the default becomes "qr" in 8.0.

What changes

  • New keyword: IV2SLS, IVLIML and their from_formula take a keyword-only method, as in statsmodels, with values "pinv" and "qr" (not case sensitive).
  • method="qr":
    • Tests the rank of [exog, endog] and [exog, instruments] after scaling the columns to unit length. They are rank deficient if the condition number is at least 1e9 (relative tolerance 1e-9).
    • Computes the k-class estimates from one Householder QR of the instruments. For 2SLS this is the QR least-squares solve of the projected problem, as in R's ivreg. For kappa < 1 it is a stacked least-squares problem. For kappa > 1 (LIML) it uses R'GR with G = I - (kappa-1)F'F. The cross products are never formed.
    • Computes the covariance (all four estimators) from R⁻¹, with scores in orthonormal coordinates, so robust, clustered and kernel SEs keep the cond·eps accuracy.
    • Computes the LIML kappa from a QR plus an SVD (σ_min(M₁W R⁻¹)²). This removes case 2 of IV: LIML kappa with an endogenous regressor spanned by the instruments, kappa's unit dependence, scale-dependent instrument rank check #823: the old kappa changed with the units when there were 2 or more endogenous regressors.
  • method="pinv" gives today's results bit for bit: raw matrix_rank, pinv, inverted cross products and the eigenvalue kappa. It no longer repeats work within a fit: pinv(z) is computed once instead of three times, and the covariance reuses the estimator's products. Terms multiplied by exactly zero at kappa 0 or 1 (such as 2SLS's x'x) are skipped. That is exact because the rank check rejects non-finite data. With these changes the default path takes about half the time of main.
  • The default, None, behaves as "pinv", plus:
    • a FutureWarning whenever method is not given. It says that the default changes from "pinv" to "qr" in linearmodels 8.0, that method="qr" gives the new method now, and that method="pinv" keeps the current one and silences the warning. For data that "qr" would refuse (the cond ≥ 1e9 band, where "pinv" estimates have few or no correct digits), the warning also says so. It points at the caller's line, also through from_formula and linearmodels.formula.iv_2sls/iv_liml. Models built internally by the diagnostics pass the model's method and don't warn.
    • When today's check refuses columns only because of their units, the error message suggests method="qr".
  • Lower-level functions: the IV covariance classes, estimate_parameters, cragg_donald(_f), kleibergen_paap(_f) and FirstStageResults take method too, with default None (= "pinv"). Called directly from outside linearmodels, they issue the same kind of FutureWarning. A frame check stays silent when linearmodels itself calls them: the models pass their own method, and AbsorbingLS and IVGMM use the default. The first-stage results and the tests of IVResults (Sargan, Basmann, Wooldridge, Durbin, Wu-Hausman, Anderson-Rubin, C) use the model's method.
  • Docs:
    • The model and fit docstrings and the 7.2 notes explain the accuracy below the 1e9 tolerance. With "pinv" about 16 − 2·log10(c) digits are correct, e.g. 6 at c = 1e5 and none at 1e8; with "qr" about 16 − log10(c).
    • The notes say briefly why IVGMM, IVGMMCUE and IVJIVE don't have method.
    • The IV example notebooks pass method="qr" (they run without warnings), and iv_basic-examples has a new cell on the two methods, their trade-offs, and why "qr" is recommended.
  • Not changed: IVGMM, IVGMMCUE, IVJIVE and AbsorbingLS keep today's unscaled rank check and computation. GMM inverts Z'Z and needs its own follow-up.

Evidence

  • Exact references (tests): the k-class estimates and all four 2SLS covariances are exact on Hadamard-built data, with the references computed with fractions. Fixed kappas use data without an error term, since a residual adds a cond² term for any method. Each case also runs with permuted rows, so that the result can't depend on the BLAS summation order.

    Collinearity δ "qr" params "qr" SEs "pinv" params
    2⁻²⁰ 5e-11 9e-12 1.2e-4
    2⁻²⁷ 6e-9 1.2e-9 300% off
    2⁻³⁰ — — LinAlgError
  • Scale invariance (tests): rescaling y, an exog, an endog or an instrument by 2^±60 leaves the "qr" params and SEs bit-identical after mapping back, for all four covariance types and for 2SLS, LIML, Fuller and kappa=0.5. So are all the diagnostics listed above, kappa included. The one exception is the automatic kernel bandwidth, which sums score columns and so depends on units by definition, with "pinv" too.

  • Simulated data, 7,560 data sets from the BUG: Test the rank of the IV inputs after scaling the columns #850 study. Scored against a 120-digit reference at tolerance 1e-6. A refusal is "needed" if the exact estimate is undefined or QR can't get within 1e-6. The grid is deliberately heavy in collinear and extreme-scale data, so compare the rows rather than reading the shares as frequencies.

    Accurate Refused, needed Refused, unneeded Loud failure Silently wrong
    main = branch default 19.7% 41.6% 28.7% 2.1% 7.9%
    "qr" 50.0% 45.0% 5.0% 0.0% 0.0%

    The "qr" row is the same for the 2SLS params, all four SE types and LIML; the main row varies by at most 0.2 points. Median error with collinear regressors:

    δ main params "qr" params main robust SE "qr" robust SE
    1e-6 5.3e-4 4.1e-11 7.7e-4 2.3e-11
    1e-7 4.7e-2 3.6e-10 7.8e-2 2.5e-10
    1e-8 fails 4.0e-9 fails 2.2e-9
  • Default path = main, bit for bit: 10,484 numbers from 477 fits compare equal with exact equality. The fits cover 2SLS, LIML, Fuller, kappa 0/0.5, OLS, IVGMM and AbsorbingLS; unadjusted, robust, debiased, one- and two-way clustered, and automatic and fixed kernel covariances; weighted and unweighted data; scaled, collinear and just-identified designs; and every test statistic and the first-stage results. The same holds on all 7,560 simulated data sets.

  • Large models: seconds (best of 1 to 3 runs), as main → branch default / "qr", with 1 BLAS thread. With 12 threads the ratios are similar: default 0.43–0.69× main, "qr" 0.33–0.59×. The "qr" estimates are within 1.2e-13 of main's on these well-conditioned data.

n=1,000,000, k=12, k_z=18 n=250,000, k=55, k_z=70 n=50,000, k=210, k_z=240 n=10,000, k=610, k_z=630
construct IV2SLS 2.32 → 2.17 / 1.97 1.97 → 1.95 / 1.94 1.21 → 1.21 / 1.20 0.79 → 0.70 / 0.76
fit unadjusted 4.04 → 1.98 / 1.39 7.44 → 3.31 / 2.62 6.08 → 2.73 / 2.08 5.24 → 2.46 / 1.40
fit robust 4.15 → 2.15 / 1.46 7.71 → 3.44 / 2.78 6.00 → 2.92 / 2.26 5.23 → 2.63 / 1.65
fit clustered 4.43 → 2.30 / 1.58 7.58 → 3.42 / 2.90 6.06 → 3.05 / 2.42 6.66 → 3.88 / 3.02
fit kernel 4.28 → 2.29 / 1.65 8.26 → 4.11 / 3.70 6.94 → 4.08 / 3.23 6.81 → 4.19 / 3.17
construct + fit IVLIML robust 6.63 → 4.51 / 3.83 9.85 → 5.57 / 4.98 7.96 → 4.51 / 4.02 6.05 → 3.72 / 2.87
fit + first_stage.diagnostics 45.23 → 26.45 / 22.87 101.75 → 50.95 / 46.65 — —
peak memory, fit robust 511 → 511 / 397 MB 508 → 508 / 385 MB 358 → 358 / 268 MB 209 → 209 / 150 MB
  • Test suite: pytest -m "not slow" linearmodels gives 23,350 passed, 2,473 skipped and 47 xfailed. That is main's 23,095 plus the 255 new tests in linearmodels/tests/iv/test_numerical_method.py, with the same skips and the same 992 warnings (all from pyhdfe's scipy.sparse use). The tests pass method explicitly everywhere except in the tests of the warning, and the suite also passes with the default-method warning promoted to an error. Where a test's estimator is a parameter that can also be IVGMM, JIVE or a panel model, the helper linearmodels.tests._utility.method_kwargs adds method only for those that take it.
  • Coverage: every added line and branch runs in the fast suite (checked with branch coverage against the diff). Edge cases are tested through the public API: rank-deficient exog in the rank tests (the SVD fallback of proj), fewer observations than regressors, and no regressors in estimate_parameters and the covariance estimators. Branches that no input reaches were removed.
  • Style and types: black, isort, flake8, ruff and numpydoc lint are clean. mypy shows no new errors.

Decided

  • 7.2.
  • The keyword is method (as in statsmodels), with values "pinv" and "qr", and both methods stay for now.
  • The models always warn when method is not given. The lower-level functions warn only when they are called directly.
  • The tests and the notebooks pass method explicitly.

Builds on the unit-length rank check proposed by inchang-ing in #850.

🤖 Generated with Claude Code

Add the keyword solver to IV2SLS, IVLIML and their from_formula. solver="qr"
tests the rank of [exog, endog] and [exog, instruments] after scaling the
columns to unit length, with a relative tolerance of 1e-9, and computes the
k-class estimates, their covariance and the kappa of LIML from QR
decompositions, as R's ivreg does. The results do not depend on the units of
the variables, and their rounding error grows with the condition number of
the scaled data, not with its square.

The default is solver="legacy", which computes as before. If solver is not
given, a FutureWarning is issued for data that solver="qr" finds rank
deficient, and the error for columns that are only rank deficient because of
their units suggests solver="qr". linearmodels 8.0 will use "qr" by default.

The covariance estimators, proj/annihilate and the Cragg-Donald and
Kleibergen-Paap statistics take the same keyword, and the first stage
results and the tests of IVResults use the solver of the model.

Builds on the unit-length rank check of #850. Fixes case 2 of #823 (kappa
depends on the units with 2 or more endogenous regressors) with solver="qr".
Rename solver="legacy" to solver="pinv", so that both values name the
numerical method. The default, None, still computes as "pinv" and warns.
The covariance estimators accepted solver positionally after extra_df, and
the private _first_stage_rank and _rank did too. Every function that takes
solver now requires it as a keyword, and a test checks the public ones.
…heck

The default rank check computed the singular values of the columns scaled to
unit length for every model, to decide whether to warn, which made creating a
model up to twice as slow. The rank is now computed from the singular values
of the columns as they are, as matrix_rank computes it, and the scaled columns
are only tested if the condition number is at least 1e9 / (2 sqrt(ncol)).
Scaling the columns cannot increase the condition number by more than
sqrt(ncol) (van der Sluis), so the decision does not change.

With solver="qr", fit decomposes the instruments once for kappa, the estimate
and the covariance, OLS (kappa = 0) does not decompose the instruments, and
the covariance uses one QR decomposition of x when z is x.
@codecov

codecov Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.69%. Comparing base (d29628d) to head (dc5a8d7).

Additional details and impacted files
@@           Coverage Diff            @@
##             main     #870    +/-   ##
========================================
  Coverage   99.68%   99.69%            
========================================
  Files         201      202     +1     
  Lines       38176    38950   +774     
  Branches     2442     2521    +79     
========================================
+ Hits        38057    38831   +774     
  Misses         51       51            
  Partials       68       68            
Flag Coverage Δ
adder 99.68% <100.00%> (+<0.01%) ⬆️
subtractor 99.68% <100.00%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

solver="pinv" (bit-for-bit the same results):
- fit computes pinv(z) once for kappa, the estimate and the covariance,
  which each computed it before, and kappa reuses it for x1 if x1 is z
- the covariance reuses pinv(z) @ x, x'z pinv(z) x and V
- the terms that are multiplied by exactly 0 when kappa is 0 or 1, such as
  the x'x of 2SLS, are not computed (the inputs are finite)
- the kernel covariance computes the projected regressors once

solver="qr":
- the covariance reuses the factors of the estimate, and computes U from R
  instead of decomposing x again for OLS
- kappa < 1 applies the reflections of the QR of x to y without forming Q
- the rank check and kappa use QR without forming Q, and proj uses QR and
  only falls back to the SVD if the scaled columns are rank deficient
Sharing pinv(z) between kappa, the estimate and the covariance kept it alive
while kappa decomposed x1, which raised the peak memory of fit by up to 19%.
Kappa now computes the residuals on x1 first, so that their decomposition is
freed before the one of z is computed and kept. The residuals are
independent, so the results are the same.
With kappa other than 1, the k-class estimator fits a residual of y - Xb,
whose error grows with the square of the condition number. The Hadamard
data only gave exact estimates with kappa = 1.25 because the rounding error
of their products cancelled in the order of the sums of OpenBLAS on x86. It
did not on Windows ARM64 (6.4e-7 and 1.0e-2), and does not on x86 with the
rows permuted. Fixed kappa is now tested on data without the error, where
the error grows with the condition number, with the rows in both orders.
The tolerance of the Fuller test, which has a residual, follows its bound.
With solver="pinv", kappa only uses the cross product of the residuals on
x1, which is now computed before z is decomposed, so that the residuals are
freed. This removes the last 2-4% of extra peak memory of fit compared with
main; the results are the same. Also narrow the optional basis of
kclass_factors for mypy.
The keyword of the numerical method of IV2SLS and IVLIML is now method, as
in statsmodels, with the values "pinv" and "qr". The internal attribute is
_numerical_method, since _method holds the name of the estimator.

If method is not given, a FutureWarning says that the default changes from
"pinv" to "qr" in linearmodels 8.0, that method="qr" gives the new method
now and that method="pinv" keeps the current one without the warning. For
data that method="qr" finds rank deficient, the warning also says so. Only
the models that the user creates warn: the models of the diagnostics and the
covariance estimators get the method of the model.

The test suite ignores the warning in pyproject.toml; the tests of the
warning use pytest.warns. The kappa tests that turn every warning into an
error ignore it too.
The IV covariance estimators, estimate_parameters, cragg_donald,
cragg_donald_f, kleibergen_paap, kleibergen_paap_f and FirstStageResults
default to method=None, which is "pinv". If they are called directly from
outside linearmodels, a FutureWarning says that the default will be "qr" in
linearmodels 8.0. They do not warn when linearmodels calls them: the models
pass their own method, and AbsorbingLS and IVGMM use the default without a
warning. The tests of linearmodels count as callers outside linearmodels.

The docstrings of the models and of fit explain that with "pinv" the
estimates lose digits well below the condition number of 1e9 at which "qr"
rejects the data, about 16 - 2 log10(c) correct digits against 16 - log10(c).
The tests pass method="pinv" wherever they create IV2SLS or IVLIML or call a
lower-level function that takes method, except the tests of the warning,
which use pytest.warns. Where the estimator is a parameter that can also be
IVGMM, IVGMMCUE, IVJIVE or a panel model, the new test helper method_kwargs
adds method only for those that take it. pyproject.toml no longer ignores
the warning, and the kappa tests turn every warning into an error again. The
suite passes with the warning promoted to an error.
The IV examples pass method="qr", so that they do not show the warning, and
the basic examples have a new cell on the two numerical methods: what each
does, the trade-offs (accuracy and units, the stricter rank check, the exact
results of 7.1 with "pinv") and why "qr" is recommended.
The 7.2 notes explain the digits that "pinv" loses below the tolerance of
"qr", why IVGMM, IVGMMCUE and IVJIVE do not have method, and that the
lower-level functions warn only when they are called directly.
Codecov found added lines that no test runs. Those that no input can
reach, or that change nothing, are removed:

- _rank_and_condition_number: the fallback to matrix_rank. NumPy 1
  computes the tolerance as (smax * m) * eps and NumPy 2 as
  smax * (m * eps), which are the same number since eps is a power of 2,
  unless smax * m overflows.
- _estimate_kappa: the checks of the keys of shared, which is always
  empty when kappa is estimated.
- kclass_solve: the check that the factors have t, which every caller
  computes, and the guard for no regressors, since solve_triangular
  without trans handles empty arrays.
- proj: the separate branch for fewer rows than columns. The QR
  decomposition then has a square Q, which is the projection if x has
  full row rank.
- The OLS shortcut of the covariance estimators tests the shape in
  array_equal.

The guards for no regressors in kclass_factors and in the covariance are
kept, with comments: qr_multiply fails if x has no columns, and
solve_triangular with trans="T" fails with empty arrays in SciPy 2.0.dev.
- The rank tests with a constant and a full set of dummies as exog,
  which proj projects on with the SVD. "qr" agrees with pinv, and
  Kleibergen-Paap does not change without the redundant dummy. Without
  the SVD, Cragg-Donald was 307.7 instead of 303.9.
- A model with fewer observations than regressors is rank deficient
  with both methods.
- estimate_parameters and the covariance estimators with no regressors
  return empty arrays with both methods.
- The covariance estimators agree when the model is just identified, so
  that z has the shape of x, which tests that the OLS shortcut is only
  used if z is x.
- proj agrees with pinv for full rank, rank deficient and wide x.

method_kwargs no longer catches errors of inspect.signature, which
cannot fail for the IV models and functions.
bc96124 removed it after checking SciPy 1.18 and 2.0.dev, where
solve_triangular returns an empty array. SciPy 1.13 and earlier raise
LinAlgError for empty arrays, which made test_no_regressors fail in the
minimums (SciPy 1.8), NumPy 1.26 (1.11) and Cython coverage (1.13) jobs.
Checked with SciPy 1.13.1 and 1.11.4, where all 255 tests of
test_numerical_method.py now pass.
@bashtage
bashtage marked this pull request as ready for review November 7, 2026 10:31

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant