Skip to content
The Lumenary

Math laboratory

Least squares and correlation: Anscombe's quartet and confounding

Computes exactly, with fractions, the means, variances, correlation and least-squares line of Anscombe's quartet: four data sets with the same summaries (r = 0.816, y = 3.00 + 0.500x) but very different shapes. Shows r = 0 for y = x^2 on a symmetric range, and simulates (seeded) a confounded correlation between ice-cream sales and sea rescues that vanishes once temperature is accounted for.

Back to the labExact computationExperimental evidence
Least squares and correlation: Anscombe's quartet and confoundingPythonExact computationExperimental evidenceComputationally tested
"""Least squares, correlation and their limits: Anscombe's quartet and more.

Francis Anscombe's four data sets (1973) share their means, their variances,
their correlation r = 0.816 and their least-squares line y = 3.00 + 0.500x to
two or three decimals, yet look completely different when plotted - so a
summary is no substitute for a picture. All statistics are computed exactly
with fractions from the lesson's formulas Sxx, Sxy, Syy. Then y = x^2 on a
symmetric range has r = 0 although y is a function of x, and a simulated
town shows ice-cream sales and sea rescues correlated only because both
follow the temperature: the correlation of what is left after removing
temperature's linear effect is close to zero.
"""

import math
import random
from fractions import Fraction

X123 = [10, 8, 13, 9, 11, 14, 6, 4, 12, 7, 5]
ANSCOMBE = {
    "I": (X123, ["8.04", "6.95", "7.58", "8.81", "8.33", "9.96", "7.24",
                 "4.26", "10.84", "4.82", "5.68"]),
    "II": (X123, ["9.14", "8.14", "8.74", "8.77", "9.26", "8.10", "6.13",
                  "3.10", "9.13", "7.26", "4.74"]),
    "III": (X123, ["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 sums(x, y):
    """(mean x, mean y, Sxx, Sxy, Syy), exactly if the data are Fractions."""
    n = len(x)
    mx, my = sum(x, Fraction(0)) / n, sum(y, Fraction(0)) / n
    sxx = sum((a - mx) ** 2 for a in x)
    sxy = sum((a - mx) * (b - my) for a, b in zip(x, y))
    syy = sum((b - my) ** 2 for b in y)
    return mx, my, sxx, sxy, syy

def least_squares(x, y):
    """(intercept, slope) of the least-squares line: b = Sxy/Sxx."""
    mx, my, sxx, sxy, _ = sums(x, y)
    slope = sxy / sxx
    return my - slope * mx, slope

def correlation(x, y):
    """r = Sxy / sqrt(Sxx Syy) (a float: the square root is irrational)."""
    _, _, sxx, sxy, syy = sums(x, y)
    return float(sxy) / math.sqrt(float(sxx) * float(syy))

def residuals(x, y):
    """y minus the least-squares prediction, for each point."""
    a, b = least_squares(x, y)
    return [v - (a + b * u) for u, v in zip(x, y)]

def confounded_town(rng, days):
    """Temperatures and two quantities that each depend only on them."""
    temp = [rng.uniform(15, 35) for _ in range(days)]
    ice = [20 * t + rng.gauss(0, 60) for t in temp]
    rescues = [0.3 * t + rng.gauss(0, 1.5) for t in temp]
    return temp, ice, rescues

def main():
    print("Anscombe's quartet (exact arithmetic, rounded for printing)")
    print("  set   mean x  mean y   var x   var y      r     line")
    for name, (x, ys) in ANSCOMBE.items():
        y = [Fraction(v) for v in ys]
        mx, my, sxx, _, syy = sums(x, y)
        a, b = least_squares(x, y)
        print(f"  {name:>3}   {float(mx):6.2f}  {float(my):6.2f}  "
              f"{float(sxx / 10):6.2f}  {float(syy / 10):6.2f}  "
              f"{correlation(x, y):6.3f}   y = {float(a):.2f} + "
              f"{float(b):.3f}x")
    print("Same summaries; plotted, they look nothing alike.")
    print()
    xs = list(range(-3, 4))
    sxy = sums(xs, [v * v for v in xs])[3]
    print(f"y = x^2 for x = -3..3: Sxy = {sxy}, so r = 0 exactly.")
    print("  No linear association, yet y is completely determined by x.")
    print()
    temp, ice, rescues = confounded_town(random.Random(1973), 730)
    print("A simulated town, 730 days:")
    print(f"  r(ice-cream sales, sea rescues) = {correlation(ice, rescues):.2f}")
    r_left = correlation(residuals(temp, ice), residuals(temp, rescues))
    print(f"  after removing temperature's linear effect from both: "
          f"r = {r_left:.2f}")
    print()
    print("Epistemic status: the quartet's statistics are exact computations")
    print("from the published data; the town is a simulation built so that")
    print("temperature causes both quantities. Correlation alone could not")
    print("have told us that.")

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

Used in