12-02: Chi-Square Test of Independence¶
The same χ² statistic as 12-01, applied to a two-way contingency table. It answers the question raised informally in 05-02: are these two categorical variables related, or is the pattern just sampling noise?
The Hypotheses¶
H₀: The two variables are INDEPENDENT (not associated)
H₁: The two variables are DEPENDENT (associated)
Note there is no direction and no "greater than" — the alternative is simply "some association exists".
Expected Counts Under Independence¶
If two events are independent, P(A and B) = P(A)·P(B) (05-02). Multiply that joint probability by n and the n's cancel into the row-by-column rule:
r = number of rows, c = number of columns. Always right-tailed.
Conditions¶
1. Random sample; each observation falls in exactly ONE cell
2. Observations independent of one another
3. All EXPECTED counts ≥ 5
(a common relaxation: all E ≥ 1, and no more than 20% of cells below 5)
4. COUNTS, not percentages
For a 2×2 table with small counts, use Fisher's exact test instead, or apply Yates' continuity correction (R's chisq.test applies it by default on 2×2 tables).
Worked Example¶
Is exercise habit associated with self-reported health? A survey of 200 people:
Observed
| Excellent | Good | Poor | Row total | |
|---|---|---|---|---|
| Exercises | 45 | 55 | 20 | 120 |
| Does not | 15 | 30 | 35 | 80 |
| Column total | 60 | 85 | 55 | 200 |
STEP 2 — expected counts
E(Exercises, Excellent) = 120 × 60 / 200 = 36.0
E(Exercises, Good) = 120 × 85 / 200 = 51.0
E(Exercises, Poor) = 120 × 55 / 200 = 33.0
E(Does not, Excellent) = 80 × 60 / 200 = 24.0
E(Does not, Good) = 80 × 85 / 200 = 34.0
E(Does not, Poor) = 80 × 55 / 200 = 22.0
| Expected | Excellent | Good | Poor |
|---|---|---|---|
| Exercises | 36.0 | 51.0 | 33.0 |
| Does not | 24.0 | 34.0 | 22.0 |
All expected counts ≥ 5 ✓ · df = (2−1)(3−1) = 2
STEP 3 — the statistic
| Cell | O |
E |
(O−E)²/E |
|---|---|---|---|
| Ex, Excellent | 45 | 36.0 | 2.250 |
| Ex, Good | 55 | 51.0 | 0.314 |
| Ex, Poor | 20 | 33.0 | 5.121 |
| No, Excellent | 15 | 24.0 | 3.375 |
| No, Good | 30 | 34.0 | 0.471 |
| No, Poor | 35 | 22.0 | 7.682 |
| χ² = 19.213 |
STEP 4 — decide
Critical value: χ²(0.05, 2) = 5.991
19.213 > 5.991 → REJECT H₀
p-value: P(χ²₂ > 19.213) = 0.0000672 → REJECT H₀
STEP 5 — conclude
There is very strong evidence at the 5% level of an association between exercise habit and self-reported health rating.
Where the association lives. The two biggest contributions are "Does not exercise / Poor health" (7.68, observed 35 vs. expected 22) and "Exercises / Poor health" (5.12, observed 20 vs. expected 33). Non-exercisers report poor health far more often than independence would predict.
Warning
A significant chi-square shows association, not causation. This was an observational study (08-01) — healthy people may simply find it easier to exercise, and a confounder such as age could drive both.
Measuring the Strength of the Association¶
χ² grows with n, so it is not an effect size on its own. Report one of these:
______ ______
/ χ² / χ²
Phi (2×2 only) φ = √ ──── Cramér's V = √ ──────────
n n · min(r−1, c−1)
______
/ χ²
Contingency C = √ ────────
χ² + n
Interpreting Cramér's V (for df* = min(r−1, c−1) = 1):
| V | Strength |
|---|---|
| 0.10 | Small |
| 0.30 | Medium |
| 0.50 | Large |
For our example: V = √(19.213 / (200 × 1)) = √0.09607 = 0.310 — a medium association.
Test of Homogeneity — Same Arithmetic, Different Design¶
| Test of independence | Test of homogeneity | |
|---|---|---|
| Sampling | One sample, two variables recorded | Several samples, one from each population |
| Row totals | Random | Fixed by design |
| Question | "Are these two variables related?" | "Do these populations have the same distribution?" |
| Example | Survey 200 people, record exercise and health | Sample 100 from each of 3 hospitals, record outcome |
| Computation | Identical | Identical |
The χ² statistic, expected counts, and df are exactly the same. Only the wording of the hypotheses and conclusion changes.
Excel¶
' ══ OBSERVED TABLE in B2:D3, with totals ════════════════════════════
=SUM(B2:D2) ' E2: row total -> 120
=SUM(B2:B3) ' B4: column total -> 60
=SUM(B2:D3) ' E4: grand total -> 200
' ══ EXPECTED TABLE in B7:D8 ═════════════════════════════════════════
=$E2*B$4/$E$4 ' B7: row × col / grand -> 36
' copy across B7:D8 — the mixed references make it fill correctly
' ══ CHI-SQUARE STATISTIC ════════════════════════════════════════════
=(B2-B7)^2/B7 ' B11: one cell's contribution
=SUM(B11:D12) ' χ² -> 19.2130
=SUMPRODUCT((B2:D3-B7:D8)^2/B7:D8) ' the same, in one formula
' ══ DEGREES OF FREEDOM, CRITICAL VALUE, p-VALUE ═════════════════════
=(ROWS(B2:D3)-1)*(COLUMNS(B2:D3)-1) ' df -> 2
=CHISQ.INV.RT(0.05, 2) ' critical value -> 5.9915
=CHISQ.DIST.RT(19.213, 2) ' p-value -> 0.0000672
=CHISQ.TEST(B2:D3, B7:D8) ' p-value, one step (df inferred)
=IF(CHISQ.TEST(B2:D3,B7:D8)<=0.05, "Reject H0 — associated", "Fail to reject")
' ══ CONDITION CHECK ═════════════════════════════════════════════════
=MIN(B7:D8)>=5 ' all expected ≥ 5? -> TRUE
=COUNTIF(B7:D8,"<5") ' how many small cells
' ══ EFFECT SIZE ═════════════════════════════════════════════════════
=SQRT(F11/E4/MIN(ROWS(B2:D3)-1, COLUMNS(B2:D3)-1)) ' Cramér's V -> 0.3100
=SQRT(F11/E4) ' phi (2×2 only)
=SQRT(F11/(F11+E4)) ' contingency coefficient
' ══ BUILDING THE TABLE FROM RAW DATA ════════════════════════════════
' Insert ▸ PivotTable → Rows = exercise, Columns = health, Values = Count
=COUNTIFS($H:$H, $A2, $I:$I, B$1) ' or COUNTIFS directly
R¶
tab <- matrix(c(45, 55, 20,
15, 30, 35), nrow = 2, byrow = TRUE,
dimnames = list(exercise = c("Yes", "No"),
health = c("Excellent", "Good", "Poor")))
addmargins(tab)
# ══ THE TEST ════════════════════════════════════════════════════════
test <- chisq.test(tab)
test
# Pearson's Chi-squared test
# X-squared = 19.213, df = 2, p-value = 6.723e-05
test$expected # the E matrix: 36 51 33 / 24 34 22
test$observed
test$residuals # (O − E)/√E — signed standardized residuals
test$residuals^2 # the cell contributions
test$stdres # adjusted residuals; |value| > 2 flags an influential cell
# ══ BY HAND ═════════════════════════════════════════════════════════
E <- outer(rowSums(tab), colSums(tab)) / sum(tab)
chi <- sum((tab - E)^2 / E); chi # 19.2130
df <- (nrow(tab) - 1) * (ncol(tab) - 1); df # 2
qchisq(0.95, df) # 5.9915
pchisq(chi, df, lower.tail = FALSE) # 6.723e-05
# ══ EFFECT SIZE ═════════════════════════════════════════════════════
cramers_v <- function(tab) {
chi <- suppressWarnings(chisq.test(tab)$statistic)
as.numeric(sqrt(chi / (sum(tab) * min(dim(tab) - 1))))
}
cramers_v(tab) # 0.3100
# library(effectsize); cramers_v(tab)
# ══ FROM RAW DATA ═══════════════════════════════════════════════════
# tab <- table(df$exercise, df$health)
# chisq.test(tab)
# ══ 2×2 TABLES ══════════════════════════════════════════════════════
small <- matrix(c(8, 2, 3, 7), nrow = 2)
chisq.test(small) # Yates correction applied by DEFAULT
chisq.test(small, correct = FALSE) # uncorrected
fisher.test(small) # EXACT — preferred for small counts
# ══ VISUALISING ═════════════════════════════════════════════════════
barplot(prop.table(tab, 1), beside = TRUE,
col = c("#5B2A86", "#0FA3A3"), ylim = c(0, 0.6),
legend.text = rownames(tab), args.legend = list(title = "Exercises"),
main = "Health rating by exercise habit", ylab = "Proportion within row")
mosaicplot(tab, shade = TRUE, main = "Mosaic plot with residual shading")
# blue = observed far ABOVE expected
# red = observed far BELOW expected
# ══ TEST OF HOMOGENEITY — identical call, different design ══════════
# hospitals <- matrix(c(60,25,15, 55,30,15, 40,35,25), nrow = 3, byrow = TRUE)
# chisq.test(hospitals)
Python¶
import numpy as np
import pandas as pd
from scipy import stats
tab = pd.DataFrame([[45, 55, 20],
[15, 30, 35]],
index=["Exercises", "Does not"],
columns=["Excellent", "Good", "Poor"])
# ══ THE TEST ════════════════════════════════════════════════════════
chi2, p, dof, expected = stats.chi2_contingency(tab)
chi2, dof, p
# 19.2130, 2, 6.7231e-05
pd.DataFrame(expected, index=tab.index, columns=tab.columns)
# Excellent Good Poor
# Exercises 36.0 51.0 33.0
# Does not 24.0 34.0 22.0
# Cell contributions and residuals
contrib = (tab - expected) ** 2 / expected
contrib.round(3)
residuals = (tab - expected) / np.sqrt(expected) # signed
# ══ BY HAND ═════════════════════════════════════════════════════════
n = tab.values.sum()
E = np.outer(tab.sum(axis=1), tab.sum(axis=0)) / n
chi = ((tab.values - E) ** 2 / E).sum() # 19.2130
df = (tab.shape[0] - 1) * (tab.shape[1] - 1) # 2
stats.chi2.ppf(0.95, df) # 5.9915
stats.chi2.sf(chi, df) # 6.7231e-05
# ══ EFFECT SIZE ═════════════════════════════════════════════════════
def cramers_v(table):
chi2 = stats.chi2_contingency(table)[0]
n = np.asarray(table).sum()
return np.sqrt(chi2 / (n * (min(np.asarray(table).shape) - 1)))
cramers_v(tab) # 0.3100
# ══ FROM RAW DATA ═══════════════════════════════════════════════════
# tab = pd.crosstab(df["exercise"], df["health"])
# stats.chi2_contingency(tab)
# ══ 2×2 TABLES ══════════════════════════════════════════════════════
small = np.array([[8, 2], [3, 7]])
stats.chi2_contingency(small) # Yates ON by default
stats.chi2_contingency(small, correction=False)
stats.fisher_exact(small) # EXACT
# ══ VISUALISING ═════════════════════════════════════════════════════
import matplotlib.pyplot as plt
row_pct = tab.div(tab.sum(axis=1), axis=0)
row_pct.T.plot(kind="bar", color=["#5B2A86", "#0FA3A3"], rot=0)
plt.ylabel("Proportion within row")
plt.title("Health rating by exercise habit")
plt.show()
# Heatmap of the cell contributions — shows WHERE the association is
fig, ax = plt.subplots()
im = ax.imshow(contrib, cmap="Purples")
ax.set_xticks(range(3), tab.columns); ax.set_yticks(range(2), tab.index)
for i in range(2):
for j in range(3):
ax.text(j, i, f"{contrib.iloc[i, j]:.2f}", ha="center", va="center")
fig.colorbar(im); plt.show()
Quick Reference¶
| Task | Excel | R | Python |
|---|---|---|---|
| Build the table | PivotTable / COUNTIFS |
table(x, y) |
pd.crosstab(x, y) |
| Expected counts | row*col/grand |
chisq.test(tab)$expected |
chi2_contingency(tab)[3] |
| χ² statistic | SUMPRODUCT((O-E)^2/E) |
chisq.test(tab)$statistic |
chi2_contingency(tab)[0] |
| df | (r-1)*(c-1) |
$parameter |
chi2_contingency(tab)[2] |
| Critical value | CHISQ.INV.RT(α, df) |
qchisq(1-α, df) |
chi2.ppf(1-α, df) |
| p-value | CHISQ.TEST(O, E) |
chisq.test(tab)$p.value |
chi2_contingency(tab)[1] |
| Cell contributions | (O-E)^2/E |
$residuals^2 |
(O-E)**2/E |
| Cramér's V | SQRT(χ²/(n*min(r-1,c-1))) |
effectsize::cramers_v |
user function |
| Small 2×2 | — | fisher.test(tab) |
stats.fisher_exact(tab) |
| Mosaic plot | — | mosaicplot(tab, shade=TRUE) |
manual heatmap |
Common Mistakes¶
- Computing expected counts from the wrong margins. It is
row total × column total ÷ grand total— nevern/k. df = rc − 1. It is(r−1)(c−1).- Including the totals row/column in the ranges you feed to
CHISQ.TEST. Only the interior cells. - Reporting
χ²as an effect size. Use Cramér's V. - Concluding causation from an association.
- Running the test on percentages, or on a table where one person appears in two cells.
- Using the ordinary chi-square on a 2×2 table with expected counts below 5 — use Fisher's exact test.
Exercises: 12-02: Exercises — Chi-Square Test of Independence
⬅️ Previous: 12-01: Chi-Square Goodness-of-Fit Test ➡️ Next: 12-03: One-Way ANOVA