Skip to content
The Lumenary

Math laboratory

Normal equations and Anscombe's quartet: fit, then check residuals

Solves the normal equations for a straight-line fit exactly with fractions and compares with numpy.linalg.lstsq; residuals sum to zero and are orthogonal to x. Then fits Anscombe's four data sets: identical slope, intercept, correlation and R^2 to two decimals, yet residual diagnostics reveal curvature, an outlier and a high-leverage point.

Back to the labExact computationNumerical approximation
Normal equations and Anscombe's quartet: fit, then check residualsPythonExact computationNumerical approximationComputationally tested
"""Least squares by the normal equations, and why you must look at residuals.

Part 1 fits a straight line to a small (illustrative) data set of spring
extensions by solving the normal equations X^T X b = X^T y exactly in
rational arithmetic, and checks the answer against numpy's least-squares
solver. Part 2 uses Anscombe's quartet (1973): four data sets with the same
means, variances, correlation and fitted line, but completely different
residual patterns - curvature, an outlier, and a single point of high
leverage.
"""

from fractions import Fraction

import numpy as np

#: Illustrative data: mass on a spring (hundreds of grams) and extension (cm).
MASS = [0, 1, 2, 3, 4, 5]
EXTENSION = ["0.1", "2.0", "4.1", "5.9", "8.2", "9.9"]

#: Anscombe, "Graphs in statistical analysis", The American Statistician (1973).
ANSCOMBE_X = [10, 8, 13, 9, 11, 14, 6, 4, 12, 7, 5]
ANSCOMBE = {
    "I": (ANSCOMBE_X, [8.04, 6.95, 7.58, 8.81, 8.33, 9.96, 7.24, 4.26, 10.84,
                       4.82, 5.68]),
    "II": (ANSCOMBE_X, [9.14, 8.14, 8.74, 8.77, 9.26, 8.10, 6.13, 3.10, 9.13,
                        7.26, 4.74]),
    "III": (ANSCOMBE_X, [7.46, 6.77, 12.74, 7.11, 7.81, 8.84, 6.08, 5.39,
                         8.15, 6.42, 5.73]),
    "IV": ([8, 8, 8, 8, 8, 8, 8, 19, 8, 8, 8],
           [6.58, 5.76, 7.71, 8.84, 8.47, 7.04, 5.25, 12.50, 5.56, 7.91, 6.89]),
}

def normal_equations_line(xs, ys):
    """Exact (intercept, slope) solving X^T X b = X^T y for X = [1, x].

    The 2 x 2 system [[n, Sx], [Sx, Sxx]] b = [Sy, Sxy] is solved by
    Cramer's rule in rational arithmetic.
    """
    xs = [Fraction(x) for x in xs]
    ys = [Fraction(y) for y in ys]
    n = len(xs)
    sx, sy = sum(xs), sum(ys)
    sxx = sum(x * x for x in xs)
    sxy = sum(x * y for x, y in zip(xs, ys))
    det = n * sxx - sx * sx
    if det == 0:
        raise ValueError("all x values are equal: the slope is not determined")
    return (sxx * sy - sx * sxy) / det, (n * sxy - sx * sy) / det

def residuals(xs, ys, intercept, slope):
    """y_i - (intercept + slope x_i)."""
    return [Fraction(y) - (intercept + slope * Fraction(x))
            for x, y in zip(xs, ys)]

def summary(xs, ys):
    """Mean of x, mean of y, slope, intercept, correlation r and R^2."""
    x, y = np.asarray(xs, float), np.asarray(ys, float)
    design = np.column_stack([np.ones_like(x), x])
    (b0, b1), *_ = np.linalg.lstsq(design, y, rcond=None)
    r = float(np.corrcoef(x, y)[0, 1])
    return float(x.mean()), float(y.mean()), float(b1), float(b0), r, r * r

def diagnostics(xs, ys):
    """Largest |residual|, its index, and the largest leverage h_ii."""
    x, y = np.asarray(xs, float), np.asarray(ys, float)
    design = np.column_stack([np.ones_like(x), x])
    hat = design @ np.linalg.inv(design.T @ design) @ design.T
    b = np.linalg.solve(design.T @ design, design.T @ y)
    res = y - design @ b
    i = int(np.argmax(np.abs(res)))
    return float(abs(res[i])), i, float(np.max(np.diag(hat)))

def curvature(xs, ys):
    """Correlation between residuals and (x - mean x)^2: large if curved."""
    x, y = np.asarray(xs, float), np.asarray(ys, float)
    _, _, b1, b0, _, _ = summary(xs, ys)
    res = y - (b0 + b1 * x)
    sq = (x - x.mean()) ** 2
    if np.std(sq) == 0:
        return 0.0
    return float(np.corrcoef(res, sq)[0, 1])

def main():
    b0, b1 = normal_equations_line(MASS, EXTENSION)
    res = residuals(MASS, EXTENSION, b0, b1)
    print("Part 1: spring extension (cm) against mass (100 g)")
    print(f"  exact normal-equation fit: y = {b0} + {b1} x"
          f" = {float(b0):.4f} + {float(b1):.4f} x")
    _, _, nb1, nb0, _, r2 = summary(MASS, [float(e) for e in EXTENSION])
    print(f"  numpy lstsq:               y = {nb0:.4f} + {nb1:.4f} x,"
          f" R^2 = {r2:.4f}")
    print(f"  sum of residuals = {sum(res)}, sum of x * residual ="
          f" {sum(Fraction(x) * e for x, e in zip(MASS, res))}")
    print()
    print("Part 2: Anscombe's quartet")
    print(" set  mean x  mean y  slope  intercept     r   R^2 | max|res|"
          " leverage curve")
    for name, (xs, ys) in ANSCOMBE.items():
        mx, my, s, c, r, r2 = summary(xs, ys)
        big, _, lev = diagnostics(xs, ys)
        print(f" {name:>3} {mx:7.2f} {my:7.2f} {s:6.3f} {c:10.2f} {r:5.3f}"
              f" {r2:5.2f} | {big:8.2f} {lev:8.2f} {curvature(xs, ys):5.2f}")
    print("Same summary statistics; different stories. II is curved (residuals")
    print("track (x - mean)^2), III has one outlier, and in IV a single point")
    print("with leverage 1 determines the slope by itself.")
    print()
    print("Epistemic status: part 1 is exact rational arithmetic, confirmed")
    print("numerically; part 2 is floating-point computation on published data.")
    print("The diagnostics illustrate what residual plots show; they are not")
    print("formal tests, and the spring data are illustrative, not measured.")

if __name__ == "__main__":
    main()
Runs entirely in your browser, in an isolated worker. Nothing is sent to a server.

Used in