Back to Classical ML
AI/ML · Classical ML

Instrumental Variables & Difference-in-Differences

You want the effect of a treatment on an outcome, but the people who took the treatment differ from those who did not in ways you cannot see. Regression adjusts for what you measured and leaves the rest, so the estimate is biased and collecting more data narrows the interval around the wrong number. Two designs escape it. An instrument is a variable that shifts the treatment but has no other route to the outcome — so the part of the treatment it explains is as good as randomly assigned, and the effect of that part is clean. Difference-in-differences uses time instead: compare the change in a group that got treated with the change in one that did not, so anything that was constant about either group differences away. In both cases the credibility comes from an argument about how the world works, not from anything the estimator does.

SupervisedRegression

Supervised in form and not in purpose. The fitted object is a regression, but the estimand is a coefficient with a confidence interval rather than a prediction, and a model that predicts the outcome better is not a better estimator of the effect — frequently it is a worse one.

Also known as: IV, 2SLS, DiD, LATE, Two-way fixed effects, Natural experiments

Intuition

What it actually does

You want the effect of a treatment on an outcome, but the people who took the treatment differ from those who did not in ways you cannot see. Regression adjusts for what you measured and leaves the rest, so the estimate is biased and collecting more data narrows the interval around the wrong number. Two designs escape it. An instrument is a variable that shifts the treatment but has no other route to the outcome — so the part of the treatment it explains is as good as randomly assigned, and the effect of that part is clean. Difference-in-differences uses time instead: compare the change in a group that got treated with the change in one that did not, so anything that was constant about either group differences away. In both cases the credibility comes from an argument about how the world works, not from anything the estimator does.

Assumptions it makes
  • Relevance: the instrument genuinely moves the treatment — testable, and the first-stage F is the test
  • Exclusion: the instrument affects the outcome only through the treatment — untestable, and the assumption the whole design rests on
  • Independence: the instrument is as good as randomly assigned with respect to unobserved confounders
  • Monotonicity: nobody is a defier, whose treatment the instrument moves in the opposite direction — required for the estimate to be a LATE at all
  • Parallel trends (DiD): without the treatment, treated and control groups would have moved together — supported by pre-period evidence, never established
Objective

Loss function

Fit by least squares, but only using the part of the treatment that the instrument explains — the projection discards the rest, and the rest is where the confounding lives. Read as a moment condition it is cleaner still: the estimator is the value of beta that makes the instruments uncorrelated with the residual, which is exactly the assumption being imposed. That framing also explains what goes wrong: if the instrument barely moves the treatment, the projected variation is tiny, the moment condition is nearly uninformative, and the estimator becomes unstable in a way its standard error understates.

the instruments plus the exogenous controls — variables assumed to be unrelated to the error
projection onto the instrument space: it keeps only the part of the treatment the instruments explain
the regressors, including the endogenous treatment whose coefficient is the estimand
the coefficient vector; one element of it is the causal effect and the rest are controls nobody reads
Optimization

How it is minimized

Two-stage least squares (equivalently, GMM with the instrument moment conditions); OLS with fixed effects for difference-in-differences

the instrumented cross-product; near-singular when the instrument is weak, which is the whole weak-instrument problem in one term
the difference of two differences: the treated group’s change minus the control group’s change
mean outcome for the treated group after treatment; the four cell means are the entire 2x2 estimator
closed-form least squares — the difficulty is never the computation, it is whether the design identifies anything

Both estimators are closed-form least squares and neither is computationally interesting; everything difficult about them is in the design. The implementation trap is worth stating because it is nearly universal: 2SLS is NOT two regressions run in sequence. Fitting the first stage, then regressing the outcome on the fitted values with an ordinary OLS routine, gives the correct point estimate and wrong standard errors, because the second regression computes its residuals against the fitted treatment rather than the actual one. The correct residuals use the original regressors, and every library that implements 2SLS does this internally — which is why hand-rolling it from two OLS calls is the most common way to get the inference wrong while the estimate looks right. For difference-in-differences the analogous trap is the variance: observations within a unit are correlated over time, so standard errors must be clustered at the level treatment was assigned, and failing to do so understates them by a large factor.

Hyperparameters
  • the instrument

    Not a hyperparameter at all — it is the identifying assumption, and it is chosen by an argument about the world rather than by validation

  • controls

    Exogenous covariates included in both stages. Adding a control that is itself affected by the treatment destroys the design rather than sharpening it

  • clustering level

    Where the standard errors are clustered, which must match the level treatment was assigned at. Getting this wrong is a large understatement, not a small one

  • fixed effects (DiD)

    Unit and time effects. Two-way fixed effects is the default and is biased under staggered adoption with heterogeneous effects

  • pre-period window

    How much history enters the parallel-trends check. Long enough to be convincing, short enough that the trend assumption is plausible

Convergence & cost

Nothing iterates. The failures are all about identification, and each has a name. Weak instruments: when the instrument explains little of the treatment, the 2SLS estimate is biased toward OLS — toward exactly the bias it was meant to remove — and its standard errors are unreliable, with a first-stage F below about ten the classical warning and the modern view being that ten is far too lenient. The exclusion restriction is untestable: nothing in the data can tell you the instrument has no other path to the outcome, and with more instruments than endogenous regressors an over-identification test checks consistency between them rather than validity of any. For difference-in-differences, parallel trends is likewise an assumption about a counterfactual, supported by pre-period evidence and never proven. And the two-way fixed-effects estimator, which nearly everyone reaches for, is biased under staggered adoption with heterogeneous effects — already-treated units end up serving as controls, and the estimate can even take the wrong sign.

O(n·d^2 + d^3) for either estimator — the same cost as ordinary least squares, since that is what they are. Clustered standard errors add O(n·d^2). The computation is never the constraint; the constraint is finding a design that identifies anything, which is not a computational activity.

Applications

How, where, and why it is used

Time-Series Forecasting

Not applicable

The estimand is the effect of an intervention that already happened, not a future value; the closest genuine relative is interrupted time-series or synthetic control, which model the counterfactual trajectory explicitly rather than differencing group means.

Anomaly Detection

Not applicable

There is no notion of normality or deviation anywhere in the design — a unit with an unusual outcome is data the estimator uses, not an event to be flagged.

Optimization

Adapted
How

Two-stage least squares is the simplest member of the generalized method of moments family, and reading it that way is where the transferable content is. The estimator is the value of beta minimizing a quadratic form in the sample moment conditions; with more instruments than endogenous regressors the system is over-identified, the weighting matrix becomes a real choice, and the efficient one is the inverse of the moment covariance. That construction — turn assumptions into moment conditions, minimize a weighted quadratic form, and test whether the surplus conditions agree — is a general tool that appears well beyond causal inference.

Where
  • The reference example of GMM, and of why an over-identified system offers a specification test the exactly-identified one cannot
  • The Sargan-Hansen J-test as a worked example of using surplus moment conditions to check consistency
  • Policy evaluation feeding a go/no-go or pricing decision, where the estimated effect is the coefficient in a cost-benefit calculation
Why

It is worth studying because the optimization is trivial and the modelling is everything, which inverts the usual balance and makes the roles unusually visible. The GMM framing also makes the weak-instrument problem legible as an optimization pathology rather than a statistical curiosity: a weak instrument makes the objective nearly flat in the direction of interest, so the minimizer is poorly determined and its curvature-based standard error understates that. What it cannot do is optimize a decision itself — it produces one number with an interval, which is an input to a choice rather than a choice.

Featurization
  • Include every exogenous control in the instrument set as well, or the moment conditions are inconsistent with the model being fitted
  • Use the efficient weighting matrix when over-identified, and report the J-test alongside — a rejected test means the instruments disagree with each other
Evaluation

Coverage under simulation with a known effect, not fit statistics. Report the first-stage F, since the estimator’s behaviour is governed by it, and the J-test where the system is over-identified.

Pitfalls
  • Adding instruments to raise the first-stage F, which mechanically increases it and worsens the finite-sample bias
  • Reading a passed over-identification test as evidence the instruments are valid, when it only says they agree
  • Treating a curvature-based standard error as reliable when the objective is nearly flat in the direction of interest
Also applied in

Causal Inference & Uplift

Primary fit
How

Two designs for the same problem. Instrumental variables uses a source of variation in the treatment that has no other route to the outcome — a lottery, a distance, a policy discontinuity, an encouragement — and estimates the effect from that variation alone. Difference-in-differences uses timing: with a group that becomes treated and one that does not, the change in the untreated group estimates what would have happened anyway, and the difference between the two changes is the effect.

Where
  • Policy evaluation where randomization was impossible and a policy change created quasi-random variation
  • Encouragement designs, where an offer is randomized but take-up is not, and IV recovers the effect of actually taking it
  • Staggered rollouts across regions or cohorts, which are the natural setting for difference-in-differences
  • Regression discontinuity as a close relative, where the instrument is being just above or below a threshold
Why

These are the workhorses of applied causal inference for a reason: they turn an untestable assumption about unmeasured confounding into a different assumption that is at least arguable from institutional knowledge, and can often be probed with placebo tests and pre-trends. The costs are real and should be stated plainly. IV estimates a local effect — the effect on compliers, the people whose treatment the instrument actually moved — which is not the population average and which changes if you change the instrument. Precision is poor, because only the instrumented variation is used. And difference-in-differences rests on a parallel-trends assumption that pre-period evidence supports and never establishes.

Featurization
  • Report the first stage prominently: if the instrument does not move the treatment, nothing downstream is interpretable regardless of how the second stage looks
  • Cluster standard errors at the level treatment was assigned, not at the observation level, or they are understated by a large factor
  • Plot event-study coefficients around the treatment date; flat pre-period estimates are the evidence for parallel trends, and there is no substitute
  • Under staggered adoption use a modern estimator rather than two-way fixed effects, which uses already-treated units as controls and can return the wrong sign
Evaluation

Coverage under simulation, placebo tests on outcomes that should be unaffected, and pre-trend estimates that should be zero. Never predictive fit — R-squared is irrelevant here, and a specification that predicts the outcome better is frequently a worse estimator because it has absorbed the variation the design depends on.

Pitfalls
  • A weak instrument biasing the estimate back toward OLS while the standard errors say it is precise
  • Reporting a local effect as the population average, and comparing LATEs from different instruments as if they estimated the same thing
  • Two-way fixed effects under staggered adoption with heterogeneous effects, where the estimate is a weighted average with some negative weights
  • Controlling for a variable affected by the treatment, which destroys the design rather than improving it
Deployment

What it costs to run

Training

A least-squares solve — milliseconds. The cost of this method is entirely analytical: finding a credible instrument or a credible comparison group is the work, and it is not compute.

Inference

There is nothing to serve. The output is an estimate, an interval, and a set of diagnostics that go into a document — this is a model that produces an argument rather than a prediction endpoint.

Retraining

Re-estimated when new periods or new units arrive, which for a policy evaluation is typically once or a small number of times. There is no serving path to keep current.

Drift & monitoring
  • Re-check the first-stage F whenever the sample changes; instrument strength varies with the population and a design that worked once may not on new data
  • Re-plot pre-trends when the panel is extended, since additional pre-periods can contradict a parallel-trends claim the original window supported
  • Watch the effective sample the estimate rests on — with a weak instrument or few switchers, most of the data is contributing nothing
  • Track whether the estimate changes materially when controls are added or removed, which is instability rather than robustness
Production gotchas
  • 2SLS is not two regressions: computing the second stage with an ordinary OLS routine on fitted values gives the right estimate and wrong standard errors, because the residuals are taken against the fitted treatment rather than the actual one
  • Standard errors must be clustered at the assignment level; clustering at the observation level understates them by a factor that grows with the number of periods
  • Two-way fixed effects under staggered adoption is biased with heterogeneous effects, and the default implementation in every statistics package will run it happily
  • A shrunk or regularized coefficient is not an effect estimate — the penalty that helps prediction is exactly what destroys the estimand
  • The instrument must be excluded from the second stage; including it by accident, which is easy in a shared feature pipeline, silently reverts the estimator to OLS
Trade-offs

Pros and cons, in context

Strengths
  • Removes bias from unmeasured confounding, which no amount of data can fix

    The only entry here that addresses a bias regression cannot: adjusting for what you measured leaves the rest, and more data narrows the interval around the wrong number. Worth nothing when the treatment was genuinely randomized, where a difference in means suffices.

  • The assumptions are stated and can be argued about

    Exclusion, relevance and parallel trends are claims about institutions and mechanisms that a domain expert can dispute. That is a far better position than an unobserved-confounding assumption that cannot even be articulated.

  • Placebo tests and pre-trends give real, if partial, evidence

    A design where pre-period effects are flat and placebo outcomes are unaffected is genuinely more credible. It is evidence rather than proof, which is more than most causal claims come with.

  • Computationally trivial and completely transparent

    A closed-form least-squares estimate anyone can reproduce, which matters when the deliverable is an argument other people will scrutinize. The transparency is a feature of the method, not an accident of scale.

Limitations
  • The central assumption is untestable

    Nothing in the data can establish that the instrument has no other path to the outcome. Over-identification tests check whether instruments agree with each other, not whether any of them is valid — a distinction routinely elided in write-ups.

  • Weak instruments bias the estimate toward the thing it was meant to fix

    A weak instrument pulls 2SLS back toward OLS while its standard errors continue to look precise. The classical F > 10 rule is now considered far too lenient, and a great deal of published work sits below any reasonable threshold.

  • IV estimates a local effect, not the population average

    The effect on compliers — the people this particular instrument moved — which is a different quantity for every instrument. Two valid instruments can produce genuinely different and both-correct estimates, which is confusing and true.

  • Two-way fixed effects is biased under staggered adoption

    With heterogeneous effects, already-treated units serve as controls and the estimate becomes a weighted average with some negative weights — it can take the wrong sign. This invalidated a large body of prior work and the default implementations still run it.

Implementation

Intuitive, idiomatic, optimized

The same algorithm in three languages, each progressing through Kent Beck's three stages. Every optimization names the lever it pulls and the trade-off it accepts — cost profiles are illustrative, not measured benchmarks.

1. Intuitive

The math, transcribed literally. No dependencies.

"""Two-stage least squares and difference-in-differences - transcribed.

2SLS in its most literal form: regress the treatment on the instruments, then
regress the outcome on the fitted treatment. This gives the correct POINT
ESTIMATE and, as written, the wrong standard errors - which is the single most
common way this method is implemented incorrectly.
"""


def solve_normal_equations(X, y):
    """OLS by Gaussian elimination on X^T X b = X^T y."""
    n = len(X)
    d = len(X[0])

    gram = [[0.0] * d for _ in range(d)]
    rhs = [0.0] * d
    for i in range(n):
        for j in range(d):
            rhs[j] += X[i][j] * y[i]
            for k in range(d):
                gram[j][k] += X[i][j] * X[i][k]

    augmented = [gram[j][:] + [rhs[j]] for j in range(d)]

    for column in range(d):
        pivot = max(range(column, d), key=lambda r: abs(augmented[r][column]))
        if abs(augmented[pivot][column]) < 1e-12:
            raise ValueError("singular system: regressors are collinear")
        augmented[column], augmented[pivot] = augmented[pivot], augmented[column]

        for row in range(column + 1, d):
            factor = augmented[row][column] / augmented[column][column]
            for k in range(column, d + 1):
                augmented[row][k] -= factor * augmented[column][k]

    beta = [0.0] * d
    for row in reversed(range(d)):
        acc = augmented[row][d]
        for k in range(row + 1, d):
            acc -= augmented[row][k] * beta[k]
        beta[row] = acc / augmented[row][row]
    return beta


def two_stage_least_squares(y, X, Z):
    """X holds the endogenous treatment plus controls; Z the instruments plus
    the same controls."""
    # First stage: project every column of X onto the instrument space.
    fitted = [[0.0] * len(X[0]) for _ in range(len(X))]
    for column in range(len(X[0])):
        target = [X[i][column] for i in range(len(X))]
        coefficients = solve_normal_equations(Z, target)
        for i in range(len(X)):
            fitted[i][column] = sum(Z[i][j] * coefficients[j] for j in range(len(Z[0])))

    # Second stage: outcome on the FITTED treatment. Correct estimate.
    beta = solve_normal_equations(fitted, y)

    # The residuals below are wrong for inference. They are computed against
    # the FITTED treatment, but the model is about the ACTUAL one - so any
    # standard error built from them understates the true uncertainty. Every
    # real 2SLS implementation recomputes residuals against the original X,
    # which is exactly why hand-rolling this from two OLS calls is the common
    # way to get a right-looking estimate with wrong inference.
    residuals = []
    for i in range(len(y)):
        prediction = sum(fitted[i][j] * beta[j] for j in range(len(beta)))
        residuals.append(y[i] - prediction)

    return beta, residuals


def difference_in_differences(outcomes, treated, post):
    """The 2x2 estimator: four cell means, and a difference of differences."""
    sums = {(0, 0): 0.0, (0, 1): 0.0, (1, 0): 0.0, (1, 1): 0.0}
    counts = {(0, 0): 0, (0, 1): 0, (1, 0): 0, (1, 1): 0}

    for i in range(len(outcomes)):
        cell = (treated[i], post[i])
        sums[cell] += outcomes[i]
        counts[cell] += 1

    for cell, count in counts.items():
        if count == 0:
            raise ValueError(f"cell {cell} is empty; the design needs all four")

    treated_change = sums[(1, 1)] / counts[(1, 1)] - sums[(1, 0)] / counts[(1, 0)]
    control_change = sums[(0, 1)] / counts[(0, 1)] - sums[(0, 0)] / counts[(0, 0)]

    # The control group's change estimates what would have happened anyway -
    # which is the parallel-trends assumption, and it is untestable.
    return treated_change - control_change

O(n*d^2 + d^3) per stage in interpreter loops, and the standard errors implied by the returned residuals are wrong.

2. IdiomaticNumPy / LAPACK

How this language actually wants it written.

"""2SLS - correct residuals, clustered errors, first-stage diagnostics."""

from dataclasses import dataclass

import numpy as np
from numpy.typing import NDArray

Vector = NDArray[np.float64]
Matrix = NDArray[np.float64]


@dataclass(frozen=True)
class IvEstimate:
    """The estimate, its uncertainty, and the diagnostic that decides whether
    either is interpretable."""

    coefficients: Vector
    standard_errors: Vector
    first_stage_f: float
    n_clusters: int

    @property
    def is_weak(self) -> bool:
        """First-stage F below 10 is the classical warning, and the modern
        view is that 10 is far too lenient. A weak instrument biases 2SLS back
        toward OLS - toward exactly the bias it was meant to remove - while the
        standard errors continue to look precise."""
        return self.first_stage_f < 10.0

    def confidence_interval(self, index: int, z: float = 1.96) -> tuple[float, float]:
        centre = float(self.coefficients[index])
        half = z * float(self.standard_errors[index])
        return centre - half, centre + half


def two_stage_least_squares(
    y: Vector,
    X: Matrix,
    Z: Matrix,
    clusters: NDArray,
) -> IvEstimate:
    """Fit by 2SLS with cluster-robust standard errors.

    Raises ValueError on malformed input or an under-identified system.
    """
    if X.ndim != 2 or Z.ndim != 2:
        raise ValueError(f"X and Z must be 2-D, got {X.shape} and {Z.shape}")
    if not (X.shape[0] == Z.shape[0] == y.shape[0] == clusters.shape[0]):
        raise ValueError("y, X, Z and clusters must have the same number of rows")
    if Z.shape[1] < X.shape[1]:
        raise ValueError(
            f"under-identified: {Z.shape[1]} instruments for {X.shape[1]} regressors"
        )

    n = y.size

    # Project X onto the instrument space. lstsq rather than the normal
    # equations: forming Z^T Z squares the condition number, and instrument
    # matrices are frequently ill-conditioned because the controls appear in
    # both X and Z.
    first_stage, *_ = np.linalg.lstsq(Z, X, rcond=None)
    projected = Z @ first_stage

    coefficients, *_ = np.linalg.lstsq(projected, y, rcond=None)

    # THE CORRECTION. Residuals are computed against the ORIGINAL X, not the
    # projected one. Using the projected regressors gives the right point
    # estimate and understates the variance, because the model is about the
    # actual treatment rather than its fitted value. This one line is the
    # difference between correct and incorrect inference, and it is why 2SLS
    # should never be assembled from two calls to an OLS routine.
    residuals = y - X @ coefficients

    # Cluster-robust variance: observations within a cluster are correlated,
    # so the meat matrix sums cluster-level score vectors rather than
    # individual ones. Clustering at the observation level instead understates
    # the errors by a factor that grows with cluster size.
    bread = np.linalg.inv(projected.T @ projected)
    meat = np.zeros_like(bread)
    unique = np.unique(clusters)
    for cluster in unique:
        rows = clusters == cluster
        score = projected[rows].T @ residuals[rows]
        meat += np.outer(score, score)

    correction = (len(unique) / (len(unique) - 1)) * ((n - 1) / (n - X.shape[1]))
    covariance = correction * bread @ meat @ bread

    # First-stage F on the excluded instruments: the diagnostic that decides
    # whether anything above is interpretable.
    excluded = Z.shape[1] - (X.shape[1] - 1)
    treatment = X[:, 0]
    residual_full = treatment - Z @ np.linalg.lstsq(Z, treatment, rcond=None)[0]
    controls = Z[:, excluded:]
    residual_reduced = treatment - controls @ np.linalg.lstsq(controls, treatment, rcond=None)[0]
    numerator = (residual_reduced @ residual_reduced - residual_full @ residual_full) / excluded
    denominator = (residual_full @ residual_full) / (n - Z.shape[1])

    return IvEstimate(
        coefficients=coefficients,
        standard_errors=np.sqrt(np.diag(covariance)),
        first_stage_f=float(numerator / denominator),
        n_clusters=len(unique),
    )

What changed: One line carries most of the value: residuals are computed against the original regressors rather than the projected ones. The previous stage’s second regression used the fitted treatment, which gives the correct point estimate and understates the variance — and that is the single most common way 2SLS is implemented incorrectly, precisely because assembling it from two OLS calls feels natural. Around that: standard errors become cluster-robust, since observations within a unit are correlated and clustering at the observation level understates them badly; the normal equations become lstsq, because instrument matrices are routinely ill-conditioned when controls appear in both X and Z; and the first-stage F is returned as a first-class field, since without it nothing else in the object is interpretable.

Explicit type hints on every public signatureDataclasses or NamedTuples over ad-hoc dictsRaise specific exceptions, never bare exceptGuard clauses over nested conditionals

O(n*d^2 + d^3) per stage in LAPACK, plus a Python loop over clusters for the variance.

3. OptimizedNumPy / LAPACK

Faster, and the justification for each change.

"""2SLS - QR projections reused, cluster sums without a Python loop."""

import numpy as np
from numpy.typing import NDArray

Vector = NDArray[np.float64]
Matrix = NDArray[np.float64]


class TwoStageLeastSquares:
    """Both stages share one factorization, and the clustering is a scatter-add.

    The instrument matrix is factorized ONCE. Every column of X projects
    through the same Q, so the first stage is a single triangular solve rather
    than one regression per endogenous regressor - and the same factor answers
    the first-stage F test afterwards.
    """

    def __init__(self, Z: Matrix) -> None:
        # One contiguous float64 block: the factorization and every subsequent
        # product read it, and a non-contiguous array forces a copy on each.
        self._Z = np.ascontiguousarray(Z, dtype=np.float64)
        # QR of the instruments, not Z^T Z. Forming the cross-product squares
        # the condition number, and instrument matrices are routinely
        # ill-conditioned because the controls appear in both X and Z - which
        # makes the columns nearly collinear by construction.
        self._q, self._r = np.linalg.qr(self._Z, mode="reduced")

    def project(self, X: Matrix) -> Matrix:
        """P_Z X, for every column at once: Q (Q^T X), never forming P_Z."""
        return self._q @ (self._q.T @ X)

    def fit(self, y: Vector, X: Matrix, clusters: NDArray) -> tuple[Vector, Vector, float]:
        design = np.ascontiguousarray(X, dtype=np.float64)
        target = np.ascontiguousarray(y, dtype=np.float64)
        n, d = design.shape

        projected = self.project(design)
        coefficients = np.linalg.lstsq(projected, target, rcond=None)[0]

        # Residuals against the ORIGINAL regressors - the correction that
        # separates valid inference from the naive two-regression version.
        residuals = target - design @ coefficients

        # Cluster scores without iterating over clusters. Each row's score is
        # projected[i] * residual[i]; summing them within a cluster is a
        # scatter-add over a dense index, which np.add.at performs in one pass.
        codes = np.unique(clusters, return_inverse=True)[1]
        n_clusters = int(codes.max()) + 1

        scores = np.empty((n_clusters, d), dtype=np.float64)      # allocated once
        scores.fill(0.0)
        np.add.at(scores, codes, projected * residuals[:, None])

        # meat = sum_g s_g s_g^T over clusters, which is one GEMM once the
        # per-cluster scores exist as a matrix.
        meat = scores.T @ scores
        bread = np.linalg.inv(projected.T @ projected)

        correction = (n_clusters / (n_clusters - 1)) * ((n - 1) / (n - d))
        covariance = correction * bread @ meat @ bread

        # First-stage F reuses the same factorization: the residual sum of
        # squares from the full instrument set is available from Q^T without
        # solving again.
        treatment = design[:, 0]
        fitted = self._q @ (self._q.T @ treatment)
        residual_full = treatment - fitted
        excluded = self._Z.shape[1] - (d - 1)
        controls = self._Z[:, excluded:]
        reduced = controls @ np.linalg.lstsq(controls, treatment, rcond=None)[0]
        residual_reduced = treatment - reduced

        numerator = (residual_reduced @ residual_reduced - residual_full @ residual_full) / excluded
        denominator = (residual_full @ residual_full) / (n - self._Z.shape[1])

        return coefficients, np.sqrt(np.diag(covariance)), float(numerator / denominator)

What changed: The instrument matrix is factorized once and every projection reuses it, so the first stage becomes a single pair of products for all columns of X rather than one regression per endogenous regressor — and the same factor answers the first-stage F test without solving again. QR rather than the normal equations is a conditioning choice that matters here specifically: the controls appear in both X and Z, which makes the instrument columns nearly collinear by construction, and forming the cross-product squares that condition number. The clustered variance stops looping over clusters: per-row scores are scatter-added into a per-cluster matrix in one pass, after which the meat matrix is a single GEMM.

  • Replace a closed-form solve with a numerically stabler factorization

    QR of Z is used in place of Z-transpose-Z, which squares the condition number — and instrument designs are routinely ill-conditioned because the same controls appear on both sides.

    Trade-off: QR costs about twice the flops of the normal equations and keeps an n-by-k factor resident, which on a very tall panel is real memory for an estimator that is not compute-bound anyway.

  • Eliminate Python-level loops over samples

    The clustered meat matrix is built by scatter-adding per-row scores into a per-cluster array in one pass, replacing an iteration over clusters with an outer product each.

    Trade-off: np.add.at is notoriously slower than a plain fancy-index assignment and only wins because the indices repeat; with very few, very large clusters the explicit loop is competitive and clearer.

  • Ensure contiguous memory layout and a single dtype

    The instrument matrix and design are stored C-contiguous in float64, so the QR and every subsequent product read them directly rather than through an internal LAPACK copy.

    Trade-off: Copies the caller’s arrays when they arrive Fortran-ordered or as float32, which doubles peak memory during construction for a computation whose cost is not the constraint.

O(n*k^2) once for the factorization, then O(n*d) per projection. Illustrative, not a measured benchmark.