Back to Classical ML
AI/ML · Classical ML

Generalized Linear Models

Linear regression assumes what you are predicting is a real number with constant-variance noise. Counts are not: they cannot go negative, and a count with a mean of 100 is genuinely noisier than one with a mean of 2. A GLM keeps the linear predictor — a weighted sum of features — and changes two things around it. A link function maps that sum onto the range the response actually lives in, and a variance function states how the noise grows with the mean. Choose identity link and constant variance and you have recovered OLS; choose logit and mu(1-mu) and you have logistic regression; choose log and mu and you have Poisson regression. It is one algorithm with the distributional assumption written down instead of assumed away.

SupervisedRegressionClassificationDensity estimationAnomaly detection

Density estimation here is conditional, not marginal: the model returns a full predictive distribution for y at a given x (Poisson, Gamma, binomial), which is what makes tail questions answerable. It does not model the distribution of x.

Also known as: GLM, Poisson regression, Gamma regression, Tweedie regression, IRLS

Intuition

What it actually does

Linear regression assumes what you are predicting is a real number with constant-variance noise. Counts are not: they cannot go negative, and a count with a mean of 100 is genuinely noisier than one with a mean of 2. A GLM keeps the linear predictor — a weighted sum of features — and changes two things around it. A link function maps that sum onto the range the response actually lives in, and a variance function states how the noise grows with the mean. Choose identity link and constant variance and you have recovered OLS; choose logit and mu(1-mu) and you have logistic regression; choose log and mu and you have Poisson regression. It is one algorithm with the distributional assumption written down instead of assumed away.

Assumptions it makes
  • The response follows the chosen exponential-family distribution — which fixes the mean-variance relationship, and that is the assumption that actually binds
  • The link is correct: g(mu) is linear in the parameters, so effects are additive on the link scale and multiplicative on the response scale for a log link
  • Observations are independent given the features — violated by clustered and serially correlated data, which needs GEE, mixed effects, or robust standard errors
  • The dispersion is what the family claims. For Poisson and binomial that is a testable claim fixed at 1, and it is routinely false
  • Features are not near-collinear, and no feature perfectly separates a binary response
Objective

Likelihood

Maximize the probability the model assigns to the data that was actually observed, under a distribution chosen to match the response. The entire exponential family is described by one function, b: its first derivative is the mean, its second is the variance. That is why choosing a family is not only a choice of shape — it fixes the mean-variance relationship too, and that relationship is the assumption that does the work.

natural parameter of the exponential family for example i
the log-partition function — differentiate once for the mean, twice for the variance
dispersion parameter; fixed at 1 for Poisson and binomial, estimated for Gaussian and Gamma
the link function, mapping the mean onto the whole real line where the linear predictor lives
the conditional mean of y given x — what the model actually predicts
Optimization

How it is minimized

Iteratively reweighted least squares (Fisher scoring)

the working response — a locally linearized target that ordinary least squares can fit
the IRLS weight: observations the family says are noisier at their fitted mean count for less
the linear predictor x-transpose-beta, before the inverse link is applied
the variance function fixed by the family — mu for Poisson, mu(1-mu) for binomial, mu^2 for Gamma

Under a canonical link the log-likelihood is concave, so Newton’s method is safe — and the Hessian equals the Fisher information, which makes Newton and Fisher scoring the same update. That update then collapses into a weighted least-squares solve. This is the whole trick and it is worth stating plainly: every iteration of a GLM fit is an OLS fit on a working response with weights, so no new solver is needed, only the one you already have, in a loop. Gradient descent also works and is what you use when the data does not fit in memory, but it gives up quadratic convergence for nothing when it does.

Hyperparameters
  • family and link

    The modelling decision, not a tunable. Family fixes the mean-variance relationship; link fixes the scale on which effects are additive

  • offset

    A fixed known term in the linear predictor — log exposure, log time at risk — which is what turns a count model into a rate model

  • dispersion (phi)

    Fixed at 1 for Poisson and binomial, estimated for Gaussian, Gamma, quasi-families. Getting it wrong corrupts every standard error (estimated as Pearson chi-square over residual degrees of freedom)

  • max iterations / tolerance

    IRLS stopping rule, usually on the relative change in deviance (25 iterations, tol 1e-8; a fit that needs more is a fit worth investigating)

  • ridge penalty (optional)

    Added to the weighted normal equations to keep IRLS finite under separation or collinearity

Convergence & cost

Typically four to eight iterations to machine precision, quadratic near the optimum, and with a canonical link the problem is concave so there is a unique maximum. Two failure modes deserve names. Complete separation in a binomial GLM: when some feature perfectly splits the classes the maximum likelihood estimate is infinite, and IRLS marches obligingly toward it — coefficients and standard errors both explode while the in-sample fit looks perfect. Second, a non-canonical link (probit, and especially log-binomial) loses the concavity guarantee, and log-binomial regression regularly fails to converge because the fitted probability wants to exceed one. Step-halving is the standard remedy for both, and a fit that needed many halvings should be reported rather than quietly accepted.

O(nd^2 + d^3) per iteration and a handful of iterations, so the cost of a GLM is a few OLS fits. Memory is O(nd), or O(d^2) if X-transpose-W-X is accumulated in a streaming pass over the data, which is how GLMs are fit on data larger than memory.

Applications

How, where, and why it is used

Time-Series Forecasting

Viable
How

Count and rate series are the natural case: a Poisson or negative-binomial GLM with a log link, lag features, Fourier seasonality, and — critically — an offset for exposure, so what is modelled is a rate rather than a raw count. The log link makes multiplicative seasonality additive on the linear predictor, which is why a GLM often fits a seasonal count series cleanly where OLS on the identical features fits it badly.

Where
  • Call-centre and support-ticket arrival forecasting, where the response is a count and capacity planning needs the tail
  • Insurance claim frequency by period, with exposure varying across periods
  • Low-volume retail unit sales, where a Gaussian model cheerfully forecasts negative demand
  • Incident and failure counts in reliability monitoring
Why

Because it forecasts a distribution rather than a point. A capacity plan needs the probability of exceeding a threshold, and a Poisson GLM answers that directly, where a squared-error model gives a mean plus a standard error premised on symmetric noise the data does not have. It is the wrong choice when the autocorrelation structure itself carries the signal: a GLM assumes observations are independent given the features, so anything the lags do not capture is unmodelled.

Featurization
  • Log exposure as an offset, so periods of unequal length or unequal population are comparable
  • Lagged counts and rolling rates, entered on the log scale to match the link
  • Fourier terms for seasonality rather than one dummy per period
  • An explicit overdispersion check — variance materially above the mean means negative binomial or quasi-Poisson, not Poisson
Evaluation

Rolling-origin backtesting scored with a proper scoring rule for counts — Poisson deviance or CRPS — rather than RMSE, which rewards the mean and ignores whether the shape is right. PIT histograms to check the calibration of the whole predictive distribution, since that distribution is the reason to use this model at all.

Pitfalls
  • Fitting Poisson to overdispersed data: the point forecasts survive, the intervals become fiction, and everything downstream that consumes them inherits false confidence
  • Omitting the exposure offset, so a longer month reads as a demand increase
  • Residual autocorrelation, which leaves the means usable and the standard errors wrong
  • Zero-inflation treated as overdispersion — structurally different, and a negative binomial will not fix it

Anomaly Detection

Adapted
How

Fit the GLM to normal behaviour and score deviance or Pearson residuals. What makes this better than OLS residuals is the variance function: it normalizes each residual by how much variation the family says to expect at that fitted mean, so five events where one was expected scores as extreme while a hundred and five where a hundred was expected does not.

Where
  • Count-based telemetry monitoring — errors per minute, retries per host, rejects per batch
  • Claim-rate and billing-rate outlier detection in insurance and healthcare, where base rates vary hugely by provider
  • Surveillance where the expected rate varies across segments by orders of magnitude
Why

A fixed standard-deviation threshold on raw counts fails precisely where the mean varies across segments, because one global sigma is wrong nearly everywhere. A GLM makes the expected variance a function of the fitted mean, so a single cutoff in standardized-deviance units becomes comparable across segments — which is what makes one alerting rule serve a heterogeneous fleet. It remains a parametric model of normality: for anomalies that are structural rather than magnitude-based, a tree-based or reconstruction-based detector is the honest answer.

Featurization
  • Offset for exposure, so rates rather than counts are being compared
  • Segment and calendar features, so the baseline is conditional rather than global
  • Fit on a confirmed-clean window and persist the fitted coefficients as the definition of normal
Evaluation

Precision@k against confirmed incidents, thresholding on standardized deviance residuals. Before trusting a fixed cutoff, check that the residual distribution on clean data is actually close to standard — if it is not, the family is wrong and the threshold is arbitrary.

Pitfalls
  • Overdispersion makes every segment look anomalous, the detector fires constantly, and it gets muted — which is worse than not having it
  • Zero-inflation misread as an anomaly signal when it is a property of the data-generating process
  • A contaminated training window raises the fitted baseline until the anomaly is inside normal

Optimization

Adapted
How

Two connections, both genuine. The fit itself is a smooth concave maximization solved by Newton’s method, and IRLS is the textbook case where the Newton step has a closed form — it is the cleanest bridge from least squares to general convex optimization. Downstream, GLM outputs are routinely the objective coefficients another optimizer consumes: an expected-cost or expected-demand surface handed to a pricing or allocation program.

Where
  • Insurance rate-making, where a frequency-times-severity GLM produces the cost surface a constrained pricing optimizer works from
  • Capacity planning driven by predicted arrival rates rather than predicted counts
  • Price and bid elasticity surfaces fed into a revenue optimizer
Why

What makes it the estimator of choice upstream of an optimizer is the shape of its output, not its accuracy: a log-link GLM produces coefficients that are multiplicative relativities, which is exactly the structure rating and pricing systems already encode. A more accurate black-box predictor that cannot be expressed as a rating table often cannot be deployed there at all — the constraint is the interface, and this model matches it.

Featurization
  • Encode decision-relevant variables as main effects whose relativities a planner can read directly
  • Keep the offset, so the optimizer consumes rates rather than counts
  • Credibility-weight or group sparse levels before they become coefficients an optimizer can exploit
Evaluation

For the fit: compare the IRLS solution against a general-purpose convex solver on the same likelihood — agreement to tolerance is the correctness check. For the deployment: evaluate the decision rather than the fit, since realized cost under the optimizer’s plan is the only number that settles it.

Pitfalls
  • Optimizing against a fitted surface outside the range where it was estimated — a log link extrapolates exponentially, so a modest extrapolation becomes a large error
  • Sparse levels producing extreme relativities that the optimizer will find and exploit before any human notices
  • Treating an unconverged IRLS fit as a solution because the coefficients look plausible
Also applied in

Risk & Fraud

Primary fit
How

The actuarial standard: model claim frequency with a Poisson GLM and claim severity with a Gamma GLM, both on a log link, and multiply — or fit a Tweedie GLM to pure premium directly. The coefficients become multiplicative relativities in a rating table, which is not a summary of the deployed artefact but literally is the deployed artefact.

Where
  • Personal-lines insurance pricing and reserving
  • Probability-of-default modelling as a binomial GLM inside credit risk
  • Healthcare cost modelling with Gamma or Tweedie responses
  • Fraud rate modelling where exposure varies enormously across merchants
Why

The deliverable is a rating table a regulator will review, and a log-link GLM produces one directly. The multiplicative structure also matches how risk behaves — a young driver and a high-value vehicle compound rather than add — and the family choice matches the response, since claim amounts are non-negative and right-skewed, which Gamma models and a Gaussian does not. Boosting usually wins on raw accuracy and still loses this decision.

Featurization
  • Log exposure as an offset on the frequency model
  • Cap or separately price large losses, which otherwise dominate a Gamma severity fit
  • Credibility-weighted or grouped encodings for sparse levels, so a rare category cannot produce an extreme relativity
  • Explicit interaction terms — a GLM will not discover them, and in rating they are often where the money is
Evaluation

Out-of-time deviance and lift charts by decile, plus actual-versus-expected by every rating factor individually. That last check is the one that catches a mis-specified interaction, which any aggregate metric will happily average away.

Pitfalls
  • Fitting frequency and severity on different exposure bases and multiplying them anyway
  • Overdispersed frequency giving false confidence in the intervals a pricing committee reads
  • Extrapolating a relativity to a level that barely appeared in training data

Causal Inference & Uplift

Viable
How

The outcome model in most adjustment-based designs is a GLM, and the link is chosen to match the estimand: a logit link yields odds ratios, a log link risk ratios, an identity link risk differences. That is not a cosmetic choice — it determines which quantity is being held constant across subgroups, and therefore what "no interaction" even means.

Where
  • Propensity score models, where a binomial GLM is the default first stage
  • Risk-ratio estimation in epidemiology via log-binomial regression, or Poisson with robust standard errors
  • Regression adjustment of a randomized experiment with a binary or count outcome
Why

It is the model where the estimand and the link are the same decision, which forces the reporting to be explicit. Where it goes wrong is non-collapsibility: a conditional odds ratio from a logistic GLM is not the marginal odds ratio, so adding a covariate shifts the coefficient even when that covariate is not a confounder — a subtlety that has generated a large and confused literature.

Featurization
  • Include the covariates the identification argument requires, not the ones that improve fit
  • Interaction terms where effect heterogeneity is the actual question
  • Never adjust for post-treatment variables, regardless of how much they improve the model
Evaluation

Covariate balance diagnostics rather than fit statistics for a propensity model; placebo outcomes and negative controls for the outcome model; robust or cluster-robust standard errors wherever the variance assumption is doing real work.

Pitfalls
  • Reading a conditional odds ratio as a marginal effect
  • Poisson regression on a binary outcome without robust standard errors — the point estimate is fine and the interval is not
  • Separation in the propensity model producing fitted probabilities at zero or one, and therefore unbounded inverse-probability weights
Deployment

What it costs to run

Training

A handful of IRLS iterations over the data — seconds for a million rows and a few hundred columns on one core. The expensive part of a GLM project is never the fit; it is the family and link decision and the actual-versus-expected review that follows it.

Inference

A dot product and one scalar function call for the inverse link. Sub-microsecond, and expressible as a rating table or a SQL expression — which in pricing and underwriting is frequently the real deployment target rather than a served model.

Retraining

Quarterly to annually in regulated pricing, where changing a coefficient is a filed change with a review cycle attached; nightly or weekly in operational forecasting. Cheap either way, so the cadence is a governance decision rather than a compute one.

Drift & monitoring
  • Actual-versus-expected by every factor, refreshed on new data — aggregate deviance can be perfectly stable while two factors drift in opposite directions
  • Dispersion: if the observed variance-to-mean ratio moves away from what the family assumes, the intervals are wrong before the means are
  • Coefficient and relativity stability across refits; on a rating factor a sign flip is a governance event, not a metric wobble
  • Share of predictions pressed against the boundary of the link range — probabilities at zero or one, means at zero — which is the early signature of separation
Production gotchas
  • The offset must be carried into inference; dropping it silently converts a rate model into a count model and the error scales with exposure
  • Unseen categorical levels have no relativity, so the fallback must be an explicit documented base level rather than a crash or an implicit zero
  • A log link exponentiates, so an extrapolated linear predictor produces an enormous mean rather than a merely wrong one — clamp the linear predictor, not the output
  • A log-link GLM gives E[y|x] directly, unlike log-transformed OLS which needs a smearing correction; teams that learned the correction on the latter routinely apply it to the former and introduce a bias that was not there
Trade-offs

Pros and cons, in context

Strengths
  • One framework covers regression, classification, counts, rates, and skewed positive responses

    The practical value is that moving from a Gaussian to a Poisson response is a two-line change and all the inference machinery follows automatically. Worth little if you only ever fit one family — then it is just that model with extra vocabulary attached.

  • Coefficients land on a scale practitioners already use

    A log link gives multiplicative relativities, a logit link gives odds ratios. Decisive in insurance pricing and epidemiology, where the coefficient is the deliverable. Irrelevant when only the ranking is consumed.

  • Models the full conditional distribution, not just the mean

    This is what lets you answer "what is the probability demand exceeds capacity" instead of "what is expected demand". It is only as good as the family assumption — a wrong variance function gives confidently wrong intervals, which is worse than no intervals.

  • Converges in a handful of iterations with convexity guarantees under a canonical link

    No learning rate, no seed, reproducible fits — which matters far more in an audited pipeline than a point of AUC, and is a large part of why GLMs remain the deployed model in regulated domains.

Limitations
  • The family and link are assumptions you must make, and a wrong variance function corrupts every interval

    Overdispersion is the common case: point estimates survive, intervals shrink to fiction, and no standard accuracy metric detects it. If nobody checked the dispersion, the intervals should not be quoted.

  • Still linear in the parameters — no interactions or curvature unless you write them in

    The same ceiling as OLS, and the reason GAMs and boosting exist. A GLM is the right choice when the linear structure is what makes it deployable, not when it is what makes it accurate.

  • Separation in a binomial GLM has no finite maximum likelihood solution

    A perfectly predictive feature sends the estimate to infinity while the in-sample fit looks flawless. Firth-penalized likelihood is the fix, and this arises far more often than expected in small or heavily imbalanced samples.

  • Assumes independence, which time-series and clustered data violate by default

    The means usually survive and the standard errors do not — the dangerous combination, because the model looks right and the confidence intervals are quietly too narrow.

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.

"""Poisson GLM by iteratively reweighted least squares - the math, transcribed.

Every IRLS step is a weighted least-squares fit to the working response
z = eta + (y - mu) * g'(mu), with weights w = 1 / (V(mu) * g'(mu)^2). For a log
link on Poisson data, g'(mu) = 1/mu and V(mu) = mu, so the weight is simply mu
and the working response is eta + (y - mu) / mu.
"""

import math


def solve(matrix_a, vector_b):
    """Gaussian elimination with partial pivoting - the d x d system per step."""
    d = len(vector_b)
    augmented = [list(row) + [vector_b[i]] for i, row in enumerate(matrix_a)]

    for column in range(d):
        pivot = column
        for row in range(column + 1, d):
            if abs(augmented[row][column]) > abs(augmented[pivot][column]):
                pivot = row
        if abs(augmented[pivot][column]) < 1e-12:
            raise ValueError("singular system: features 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]

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


def fit(X, y, steps=25):
    n = len(X)
    d = len(X[0])
    beta = [0.0] * d

    for _ in range(steps):
        # Accumulate X^T W X and X^T W z one observation at a time.
        hessian = [[0.0] * d for _ in range(d)]
        rhs = [0.0] * d

        for i in range(n):
            eta = 0.0
            for j in range(d):
                eta += X[i][j] * beta[j]

            mu = math.exp(eta)                 # inverse link
            weight = mu                        # 1 / (V(mu) * g'(mu)^2) = mu
            working = eta + (y[i] - mu) / mu   # z_i

            for j in range(d):
                rhs[j] += weight * X[i][j] * working
                for k in range(d):
                    hessian[j][k] += weight * X[i][j] * X[i][k]

        beta = solve(hessian, rhs)

    return beta

O(steps * n * d^2) to accumulate plus an O(d^3) elimination per step, all in interpreter loops.

2. IdiomaticNumPy

How this language actually wants it written.

"""Generalized linear models by IRLS - typed, family-parameterized, validated."""

from collections.abc import Callable
from dataclasses import dataclass

import numpy as np
from numpy.typing import NDArray

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

_MU_FLOOR = 1e-10


@dataclass(frozen=True)
class Family:
    """An exponential-family member, defined by exactly what IRLS needs of it.

    Everything separating Poisson from binomial from Gamma regression lives in
    these three functions. The fitting loop below never changes.
    """

    name: str
    inverse_link: Callable[[Vector], Vector]
    link_derivative: Callable[[Vector], Vector]    # g'(mu)
    variance: Callable[[Vector], Vector]           # V(mu)


POISSON_LOG = Family(
    name="poisson(log)",
    inverse_link=np.exp,
    link_derivative=lambda mu: 1.0 / mu,
    variance=lambda mu: mu,
)

BINOMIAL_LOGIT = Family(
    name="binomial(logit)",
    inverse_link=lambda eta: 1.0 / (1.0 + np.exp(-eta)),
    link_derivative=lambda mu: 1.0 / (mu * (1.0 - mu)),
    variance=lambda mu: mu * (1.0 - mu),
)


@dataclass(frozen=True)
class GlmModel:
    coefficients: Vector
    family: Family
    iterations: int
    converged: bool

    def predict(self, X: Matrix) -> Vector:
        """Returns the conditional mean, not the linear predictor."""
        if X.ndim != 2 or X.shape[1] != self.coefficients.size:
            raise ValueError(
                f"expected (n, {self.coefficients.size}) design matrix, got {X.shape}"
            )
        return self.family.inverse_link(X @ self.coefficients)


def fit(
    X: Matrix,
    y: Vector,
    family: Family = POISSON_LOG,
    offset: Vector | None = None,
    max_iter: int = 25,
    tol: float = 1e-8,
) -> GlmModel:
    """Fit by Fisher scoring. Raises ValueError on malformed input."""
    if X.ndim != 2:
        raise ValueError(f"X must be 2-D, got shape {X.shape}")
    if X.shape[0] != y.shape[0]:
        raise ValueError(f"X has {X.shape[0]} rows but y has {y.shape[0]}")
    if offset is not None and offset.shape != y.shape:
        raise ValueError(f"offset must match y, got {offset.shape} vs {y.shape}")

    # None, not np.zeros(...): a mutable default argument would be shared
    # across every call to this function.
    baseline = np.zeros_like(y) if offset is None else offset

    beta = np.zeros(X.shape[1], dtype=np.float64)
    converged = False
    iteration = 0

    for iteration in range(1, max_iter + 1):
        eta = X @ beta + baseline
        mu = np.clip(family.inverse_link(eta), _MU_FLOOR, None)
        g_prime = family.link_derivative(mu)

        weights = 1.0 / (family.variance(mu) * g_prime * g_prime)
        working = eta - baseline + (y - mu) * g_prime

        weighted = X * weights[:, None]
        beta_next = np.linalg.solve(X.T @ weighted, weighted.T @ working)

        if np.max(np.abs(beta_next - beta)) < tol:
            beta = beta_next
            converged = True
            break
        beta = beta_next

    return GlmModel(
        coefficients=beta,
        family=family,
        iterations=iteration,
        converged=converged,
    )

What changed: The family stops being hard-coded Poisson arithmetic scattered through the loop and becomes a frozen dataclass of three functions, so switching to binomial or Gamma changes an argument rather than the algorithm — which is the actual claim GLMs make, now expressed in the types. The hand-rolled elimination is replaced by a single solve on the vectorized weighted normal equations, the offset is threaded through properly (defaulting to None rather than a shared mutable array), and the returned model records whether IRLS actually converged instead of leaving the caller to assume it did.

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

O(n*d^2 + d^3) per iteration, executed in BLAS/LAPACK rather than the interpreter.

3. OptimizedNumPy / LAPACK

Faster, and the justification for each change.

"""GLM by IRLS - QR on the whitened design, reused buffers, no normal equations."""

from collections.abc import Callable

import numpy as np
from numpy.typing import NDArray

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

_MU_FLOOR = 1e-10


def fit_fast(
    X: Matrix,
    y: Vector,
    inverse_link: Callable[[Vector], Vector],
    link_derivative: Callable[[Vector], Vector],
    variance: Callable[[Vector], Vector],
    max_iter: int = 25,
    tol: float = 1e-8,
) -> Vector:
    """Same fixed point, different linear algebra.

    Forming X^T W X squares the condition number of the design and throws away
    roughly half the available precision. Solving the equivalent least-squares
    problem min ||sqrt(W) (X beta - z)|| by QR does not. This is not a
    micro-optimization: on a Poisson fit the weights ARE the fitted means, so a
    response spanning several orders of magnitude puts several orders of
    magnitude into W - which is exactly the case where the normal equations
    lose the fit and the factorization keeps it.
    """
    design = np.ascontiguousarray(X, dtype=np.float64)
    target = np.ascontiguousarray(y, dtype=np.float64)
    n, d = design.shape
    beta = np.zeros(d, dtype=np.float64)

    # Buffers allocated once and overwritten in place on every iteration.
    eta = np.empty(n, dtype=np.float64)
    whitened = np.empty_like(design)
    working = np.empty(n, dtype=np.float64)

    for _ in range(max_iter):
        np.matmul(design, beta, out=eta)
        mu = np.clip(inverse_link(eta), _MU_FLOOR, None)
        g_prime = link_derivative(mu)

        # sqrt of the IRLS weight, kept in root form so W is never materialized.
        root_w = 1.0 / np.sqrt(variance(mu) * g_prime * g_prime)

        np.subtract(y, mu, out=working)
        np.multiply(working, g_prime, out=working)
        np.add(working, eta, out=working)              # z = eta + (y - mu) g'(mu)

        np.multiply(design, root_w[:, None], out=whitened)
        beta_next, *_ = np.linalg.lstsq(whitened, root_w * working, rcond=None)

        if np.max(np.abs(beta_next - beta)) < tol:
            return beta_next
        beta = beta_next

    return beta

What changed: The solve changes from the weighted normal equations to a QR factorization of the whitened design. The answer is the same in exact arithmetic and materially different in floating point, because forming X-transpose-W-X squares the condition number — and in a GLM the weights are functions of the fitted means, so an ill-conditioned W is the normal situation rather than an edge case. The rest is allocation discipline: the per-iteration temporaries are hoisted into buffers rewritten in place, so a twenty-five-iteration fit allocates once instead of a hundred times.

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

    lstsq factorizes sqrt(W) X directly instead of forming X^T W X, which squares the condition number — the difference shows up precisely when the IRLS weights span orders of magnitude, which on a log link they routinely do.

    Trade-off: QR costs roughly twice the flops of the normal equations and needs the full n x d whitened matrix resident, so on very tall well-conditioned data the normal equations are the faster correct answer.

  • Pre-allocate output arrays and use in-place operations

    eta, the whitened design, and the working response are written into reused buffers, so a 25-iteration fit performs one round of allocation rather than one per iteration per temporary.

    Trade-off: The function is no longer reentrant over its buffers and the in-place chain is order-dependent — a reordered line silently corrupts the working response rather than failing.

  • Ensure contiguous memory layout and a single dtype

    ascontiguousarray with an explicit float64 lets LAPACK operate on the arrays directly, and the row-wise scaling by root_w walks memory sequentially.

    Trade-off: Copies the input when the caller passed Fortran-ordered or float32 data, which doubles peak memory for the duration of the fit.

O(n*d^2) per iteration for the QR, four to eight iterations typical. Illustrative, not a measured benchmark.