37. Chi-squared tests

🎬 Video · 13 min
💡 Every code box below is live — edit it and hit Run.

The t-test compares means of numeric data. When the data is counts in categories — how many people chose each option, how many defects of each type — the \chi^2 family takes over.

Two tests, one statistic.

The statistic

Both tests compare observed counts against what a hypothesis predicts:

\chi^2 = \sum_{\text{cells}} \frac{(O - E)^2}{E}

where O is observed and E is expected under H_0.

Every part of that formula earns its place:

  • (O - E) — the discrepancy. Squared, so over- and under-shoots both count.
  • Divided by E — this is the crucial bit. A discrepancy of 10 is enormous if you expected 5 and trivial if you expected 10,000. Dividing by E standardises: each term is roughly a squared z-score, since a count with mean E has variance about E.

Summing squared standard Normals gives a chi-squared distribution (§1) — which is where the name and the reference distribution come from.

Large \chi^2 means the observed counts are far from what H_0 predicts. The test is always one-sided in the upper tail, because only large discrepancies are evidence against H_0. (A suspiciously small \chi^2 means the data fits too well — historically a sign of fabricated data, but not what the test is designed to detect.)

Test 1: goodness of fit

Question: does the data follow a claimed distribution?

H_0: the proportions are p_1, \dots, p_k.

E_i = n p_i, \qquad df = k - 1 - (\text{number of parameters estimated from the data})

Why k - 1? Because the counts must sum to n, so once you know k-1 of them the last is determined. One constraint, one degree of freedom lost.

And every parameter you estimate from the data costs another. Testing whether data is Poisson without knowing \lambda means estimating it, so df = k - 1 - 1.

Test 2: independence (contingency tables)

Question: are two categorical variables related?

H_0: the row and column variables are independent.

Under independence, $P(\text{row } i \text{ and col } j) = P(\text{row } i) \times P(\text{col } j)$, and estimating those probabilities from the margins gives

E_{ij} = \frac{(\text{row total}_i)(\text{column total}_j)}{n}

df = (r - 1)(c - 1)

The degrees of freedom count the free cells: fix r-1 rows and c-1 columns and every remaining cell is determined by the margins.

The assumptions

The \chi^2 distribution is an approximation, valid when counts are large enough. The usual rules:

  • All expected counts E_i \ge 5 — or at least 80% of cells \ge 5 and none below 1.
  • Observations independent; each observation in exactly one cell.
  • Use raw counts, never percentages. Feeding in percentages silently pretends n = 100, which fabricates or destroys evidence.

When expected counts are too small, use Fisher's exact test (for 2×2 tables) or pool categories.

Worked example 1: goodness of fit

A die is rolled 120 times: 15, 22, 18, 25, 20, 20. Is it fair?

Under H_0 each face has probability 1/6, so E_i = 120/6 = 20 for all six.

\chi^2 = \frac{(15-20)^2}{20} + \frac{(22-20)^2}{20} + \frac{(18-20)^2}{20} + \frac{(25-20)^2}{20} + \frac{(20-20)^2}{20} + \frac{(20-20)^2}{20}

= \frac{25 + 4 + 4 + 25 + 0 + 0}{20} = \frac{58}{20} = 2.9

df = 6 - 1 = 5, and the critical value is \chi^2_{5,\,0.05} = 11.07.

Since 2.9 < 11.07, fail to reject. The p-value is about 0.715 — this data is entirely consistent with a fair die.

Worked example 2: independence

Is treatment related to recovery?

Recovered Not recovered Total
Treatment 45 15 60
Placebo 30 30 60
Total 75 45 120

Expected counts under independence:

E_{11} = \frac{60 \times 75}{120} = 37.5, \qquad E_{12} = \frac{60 \times 45}{120} = 22.5

and the same for row 2, by symmetry of the margins.

\chi^2 = \frac{(45-37.5)^2}{37.5} + \frac{(15-22.5)^2}{22.5} + \frac{(30-37.5)^2}{37.5} + \frac{(30-22.5)^2}{22.5}

= 1.5 + 2.5 + 1.5 + 2.5 = 8.0

df = (2-1)(2-1) = 1, critical value \chi^2_{1,\,0.05} = 3.84.

8.0 > 3.84, so reject — treatment and recovery are associated (p \approx 0.0047).

But note what the test does not tell you. It says the variables are associated; it doesn't give the direction or size. For that, report the proportions: 75% recovered on treatment against 50% on placebo, a difference of 25 percentage points. The \chi^2 statistic is a detector, not a description.

Doing it in Python

Both tests, with SciPy:

import numpy as np
from scipy.stats import chisquare, chi2_contingency, chi2

# Goodness of fit: is the die fair?
observed = np.array([15, 22, 18, 25, 20, 20])
result = chisquare(observed)                  # expects uniform by default
print(f"goodness of fit: chi2 = {result.statistic:.4f}  p = {result.pvalue:.4f}")
print(f"  critical value at 5%: {chi2.ppf(0.95, df=5):.4f}")

# Independence: treatment vs recovery
table = np.array([[45, 15],
                  [30, 30]])
stat, p, dof, expected = chi2_contingency(table, correction=False)
print(f"\nindependence: chi2 = {stat:.4f}  df = {dof}  p = {p:.6f}")
print("expected counts under independence:\n", expected)

Building the statistic by hand, so nothing is a black box:

import numpy as np
from scipy.stats import chi2

table = np.array([[45, 15],
                  [30, 30]], dtype=float)

n = table.sum()
row_totals = table.sum(axis=1, keepdims=True)
col_totals = table.sum(axis=0, keepdims=True)
expected = row_totals @ col_totals / n

print("expected:\n", expected)
print("\ncontribution of each cell to chi2:")
contrib = (table - expected) ** 2 / expected
print(contrib.round(4))

stat = contrib.sum()
dof = (table.shape[0] - 1) * (table.shape[1] - 1)
print(f"\nchi2 = {stat:.4f}, df = {dof}, p = {1 - chi2.cdf(stat, dof):.6f}")

# And the effect size the test itself doesn't give you
p_treat = table[0, 0] / table[0].sum()
p_placebo = table[1, 0] / table[1].sum()
print(f"\nrecovery: {p_treat:.1%} on treatment vs {p_placebo:.1%} on placebo")
print(f"difference: {p_treat - p_placebo:.1%} points")

When the approximation breaks — small expected counts:

import numpy as np
from scipy.stats import chi2_contingency, fisher_exact

small = np.array([[8, 2],
                  [1, 5]])
stat, p_chi, dof, expected = chi2_contingency(small, correction=False)
_, p_fisher = fisher_exact(small)

print("table:\n", small)
print("expected counts:\n", expected.round(3))
print(f"\nsmallest expected count: {expected.min():.3f}  (rule of thumb wants >= 5)")
print(f"chi2 p-value  : {p_chi:.4f}")
print(f"Fisher exact p: {p_fisher:.4f}   <- trust this one here")

And a demonstration that the reference distribution really is \chi^2:

import numpy as np
from scipy.stats import chi2

rng = np.random.default_rng(0)
n, k, trials = 200, 6, 50_000

# Sample from a genuinely fair die, so H0 is TRUE
counts = rng.multinomial(n, [1/k] * k, size=trials)
expected = n / k
stats_ = ((counts - expected) ** 2 / expected).sum(axis=1)

print(f"simulated mean {stats_.mean():.4f}   chi2_{k-1} mean {k-1}")
print(f"simulated var  {stats_.var():.4f}   chi2_{k-1} var  {2*(k-1)}")
for q in (0.9, 0.95, 0.99):
    print(f"  q={q}: simulated {np.quantile(stats_, q):.4f}   theory {chi2.ppf(q, k-1):.4f}")
print(f"\nfalse positive rate at 5%: {(stats_ > chi2.ppf(0.95, k-1)).mean():.4f}")

Your turn

1. 100 people choose among 4 options: 30, 20, 25, 25. Test whether all options are equally popular.

2. What are the degrees of freedom for a 3×4 contingency table?

3. Why can't you run a \chi^2 test on percentages?

Solutions

1. Under H_0 each option has probability 1/4, so E_i = 25 for all four.

\chi^2 = \frac{(30-25)^2 + (20-25)^2 + (25-25)^2 + (25-25)^2}{25} = \frac{25 + 25 + 0 + 0}{25} = 2.0

df = 4 - 1 = 3, and \chi^2_{3,\,0.05} = 7.81.

Since 2.0 < 7.81, fail to reject (p \approx 0.572). The data is consistent with equal popularity.

All expected counts are 25, comfortably above 5, so the approximation is fine.

2.

df = (r-1)(c-1) = (3-1)(4-1) = 6

3. Because the \chi^2 statistic depends on the sample size, and percentages destroy that information.

Suppose you observe a 60/40 split. As raw counts:

  • 6 vs 4 out of 10: \chi^2 = \frac{1 + 1}{5} = 0.4, clearly not significant.
  • 600 vs 400 out of 1000: \chi^2 = \frac{100 + 100}{500} = 40, overwhelmingly significant.

Same percentages, hundredfold difference in evidence. Entering "60 and 40" as if they were counts silently claims n = 100 — inventing evidence if your real sample was smaller, discarding it if larger.

The same reasoning explains why you must not enter counts that are already aggregated over repeated measures on the same subject: the test assumes each count is one independent observation.

Check yourself in code

Run both \chi^2 tests — the fair-die goodness of fit and the treatment independence test — and confirm the by-hand statistic matches SciPy.

Print exactly this:

die chi2 2.9
die p 0.7154
table chi2 8.0
table p 0.0047
by hand matches: True

Round the statistics to 4 decimal places and the p-values to 4. Use correction=False for the contingency test.

import numpy as np
from scipy.stats import chisquare, chi2_contingency

observed = np.array([15, 22, 18, 25, 20, 20])
die = chisquare(observed)
print("die chi2", round(die.statistic, 4))
print("die p", round(die.pvalue, 4))

table = np.array([[45, 15],
                  [30, 30]], dtype=float)

# Run chi2_contingency with correction=False, print the statistic and p-value,
# then recompute the statistic by hand from the margins and compare.
import numpy as np
from scipy.stats import chisquare, chi2_contingency

observed = np.array([15, 22, 18, 25, 20, 20])
die = chisquare(observed)
print("die chi2", round(die.statistic, 4))
print("die p", round(die.pvalue, 4))

table = np.array([[45, 15],
                  [30, 30]], dtype=float)
stat, p, dof, expected = chi2_contingency(table, correction=False)
print("table chi2", round(stat, 4))
print("table p", round(p, 4))

n = table.sum()
by_hand = (((table - table.sum(axis=1, keepdims=True)
             @ table.sum(axis=0, keepdims=True) / n) ** 2)
           / (table.sum(axis=1, keepdims=True)
              @ table.sum(axis=0, keepdims=True) / n)).sum()
print("by hand matches:", round(by_hand, 4) == round(stat, 4))

The \chi^2 statistic sums (O-E)^2/E across cells, standardising each discrepancy by how big it was expected to be. Use it for goodness of fit against a claimed distribution, or for independence in a contingency table, with degrees of freedom counting the genuinely free cells. It needs raw counts and expected values of at least about 5 — and it detects association without describing it.

Next: comparing more than two groups at once, where the F distribution appears.