Skip to content

Categorical Data (Chi-square & Fisher)

Not every variable is a number. Mutation status, treatment response, cell type, and sex are categories. To ask whether two categories are associated, you build a contingency table and test it. This page asks whether a mutation is associated with treatment response, in R and Python.

Every code block on this page runs in a container in the companion repository. The numbers and figure come from those real runs.

Both tests ask the same question: are the two categories independent, or associated?

  • Chi-square test. The default for larger tables and larger counts.
  • Fisher’s exact test. Exact, and the right choice when any expected count is small, roughly under 5. Always safe for a 2x2 table.

patients.csv has 50 patients, each with a mutation status (mutant or wildtype) and a response (responder or non-responder).

df <- read.csv("patients.csv")
tab <- table(df$mutation, df$response)
tab
non_responder responder
mutant 7 18
wildtype 16 9

The table already tells a story. Among mutants, 18 of 25 responded (72%). Among wild-type, only 9 of 25 responded (36%). A stacked bar makes the gap obvious, with the chi-square p-value printed on top (the test itself is the next section).

library(ggplot2)
ggplot(df, aes(x = mutation, fill = response)) +
geom_bar(position = "fill") +
labs(y = "Proportion", fill = "Response",
subtitle = sprintf("Chi-square p = %.3f", chisq.test(tab)$p.value))

Stacked proportion bar of response by mutation (R)

The chi-square test compares the counts you observed with the counts you would expect if mutation and response were independent. A large statistic, and small p-value, means they are not independent.

chisq.test(tab)
Pearson's Chi-squared test with Yates' continuity correction
data: tab
X-squared = 5.153, df = 1, p-value = 0.02321

Both give X squared = 5.15 and p = 0.023. Mutation and response are associated. Both apply Yates’ continuity correction for the 2x2 table by default, which is why the numbers match exactly.

For a 2x2 table, especially with modest counts, Fisher’s exact test gives an exact p-value rather than an approximation.

fisher.test(tab)
Fisher's Exact Test for Count Data
data: tab
p-value = 0.02224
sample estimates:
odds ratio
0.2261644

The exact p-value, 0.022, matches across languages. The odds ratio does not match exactly, and that is not a bug. R’s fisher.test reports the conditional maximum likelihood odds ratio (0.226), while scipy reports the plain sample odds ratio (0.219). They estimate the same association with different conventions and tell the same story. An odds ratio below 1 here reflects the table orientation: the odds of non-response are lower in mutants, which is the same as saying mutants respond more.

  • Use Fisher for small counts. When an expected cell count drops below about 5, the chi-square approximation is unreliable. Fisher’s exact test is safe.
  • Test counts, not proportions. Both tests need the raw counts. Feeding in percentages loses the sample size and breaks the test.
  • Association is not direction. A significant test says the variables are linked. Read the table or the odds ratio to see which way.
  • Watch the odds-ratio convention. R and Python report different odds-ratio estimators for Fisher’s test. Quote the p-value, and state which odds ratio you used.
  • A contingency table plus a chi-square or Fisher test asks whether two categories are associated.
  • Chi-square suits larger counts; Fisher’s exact test is safe for small counts and 2x2 tables.
  • The p-values match across R and Python; the Fisher odds-ratio estimate differs by convention.
  • STHDA, chi-square test of independence and Fisher’s exact test.
  • The next guide, Correlation, for association between two numeric variables.