Index Ai Academy
Phase 2 · Math, when you need it · Probability & statistics

More tests: groups, categories, relationships

Choose a test, ANOVA by hand, chi-square on counts, Pearson vs Spearman, one-sided tests.

About 40 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

A/B tests and t-tests compare two groups. Real questions are often messier: three landing pages at once, whether the sign-up rate differs by region, whether customers who get more emails spend more. Each has its own standard test, and using the wrong one, or running ten t-tests where one test would do, is how false "findings" get into reports. This lesson gives you a short map from question to test, and builds each test by hand before checking it against SciPy, so the numbers stop being magic.

What you'll be able to do

  • Pick the right test from the question: groups, categories or a relationship
  • Compare three or more groups with one ANOVA instead of many t-tests, and compute it by hand
  • Test whether two categories are related with a chi-square test on a table of counts
  • Measure a relationship with Pearson or Spearman correlation, and know when an outlier or a confounder misleads

What it covers

  1. Which test answers this question?
  2. Three groups: why not three t-tests?
  3. Two categories: the chi-square test
  4. The counts you'd expect if nothing were going on
  5. Relationships: Pearson and Spearman
  6. Spearman is Pearson on ranks
  7. One-sided tests, used honestly
  8. Worked example: four questions, four tests
Quick check
You want to know whether the sign-up rate differs across four regions. What are you comparing?
  1. Counts in categories: signed up or not, in each region
  2. The average of one number across two groups
  3. Two numbers measured on the same people
  4. One group against a fixed target
Show the answer

Counts in categories: signed up or not, in each region

Signed up or not, by region, is a table of counts, which calls for a chi-square test rather than a t-test. Start from what you're comparing, and the right test follows.

Which test answers this question?

Start from what you're comparing, not from the test's name:

your questionwhat you havethe usual test
Is B's average different from A's?a number per person, two groupsWelch's t-test (the comparing-means lesson)
Is B's rate different from A's?yes/no per person, two groupstwo-proportion z-test (the A/B testing lesson)
Do three or more groups' averages differ?a number per person, three or more groupsone-way ANOVA
Are two categories related?counts in a table, such as region by signed up or notchi-square test of independence
Do two numbers rise and fall together?two numbers per personPearson or Spearman correlation

Each test still asks the same question you've met before: if nothing were going on (the null hypothesis), how surprising would data like this be? The p-value is that surprise, and the usual threshold is still 0.05.

Try it now

Ask: how many groups, and is each person's answer a number, a yes/no, or a category?

Three groups: why not three t-tests?

Three landing pages give three pairs to compare (A-B, A-C, B-C). Each t-test has a 5% chance of a false alarm, so running all three gives about a 14% chance that at least one "wins" by luck alone. With ten groups there are 45 pairs, and the chance of at least one false alarm is about 90% (treating the tests as independent). ANOVA (analysis of variance) asks one question instead: is there more spread between the group averages than the spread within the groups would explain?

It divides two numbers. The spread between groups: how far each group's mean sits from the overall mean, weighted by group size. The spread within groups: how far each person sits from their own group's mean. Each is divided by its degrees of freedom, the number of values that are free to vary once the totals are fixed: groups minus 1 between groups, and people minus groups within them. That makes the two spreads comparable. Their ratio is F. If the groups are really alike, F is around 1; a big F means the means differ more than chance explains.

import numpy as np
from scipy import stats

a = np.array([42, 51, 38, 47, 55, 44, 49, 40])     # seconds on page, design A
b = np.array([58, 49, 61, 54, 66, 52, 57, 60])     # design B
c = np.array([45, 50, 43, 52, 48, 46, 51, 44])     # design C
groups = [a, b, c]

everyone = np.concatenate(groups)
grand = everyone.mean()
between = sum(len(g) * (g.mean() - grand) ** 2 for g in groups) / (len(groups) - 1)
within = sum(((g - g.mean()) ** 2).sum() for g in groups) / (len(everyone) - len(groups))
F = between / within
p = stats.f.sf(F, len(groups) - 1, len(everyone) - len(groups))
print("means:", [round(float(g.mean()), 1) for g in groups])
print("F =", round(F, 2), " p =", round(p, 5))
print(stats.f_oneway(a, b, c))
It runs right here in your browser, with nothing to install.

F is 12.2 and p is about 0.0003, matching SciPy's f_oneway: the designs differ. But ANOVA only says some group differs, not which. The next step is to compare pairs with a correction for testing several, such as the Bonferroni correction from the A/B testing lesson, and here B is clearly ahead of both.

Quick check
Four ad designs are compared with t-tests on every pair (six tests) at the 0.05 level, and nothing really differs. About how likely is at least one "significant" result?
print(round(1 - 0.95 ** 6, 2))
  1. 0.5
  2. 0.05
  3. 0.26
  4. 0.01
Show the answer

0.26

The chance that all six tests stay quiet is 0.95 to the sixth power, about 0.74, so the chance of at least one false alarm is about 0.26. One ANOVA keeps it at 0.05.

Two categories: the chi-square test

Do sign-ups depend on the region a visitor came from? Count them in a table, one row per region and one column per outcome. If region and signing up were unrelated, each row would sign up at the overall rate, so every cell has an expected count: its row's total times its column's total, divided by the grand total. The chi-square statistic adds up how far each observed count is from its expected count, squared and scaled by the expected count: the sum of (observed - expected)² / expected. In the code, keepdims=True keeps the row totals as a column (shape 3 × 1), so multiplying by the row of column totals makes every row-and-column pair at once.

import numpy as np
import pandas as pd
from scipy import stats

table = pd.DataFrame({"signed_up": [48, 30, 22], "did_not": [152, 170, 78]}, index=["west", "mountain", "south"])
observed = table.to_numpy()
expected = observed.sum(axis=1, keepdims=True) * observed.sum(axis=0) / observed.sum()
chi2 = ((observed - expected) ** 2 / expected).sum()
dof = (observed.shape[0] - 1) * (observed.shape[1] - 1)
print(pd.DataFrame(expected, index=table.index, columns=table.columns))
print("chi-square =", round(chi2, 2), " degrees of freedom =", dof, " p =", round(stats.chi2.sf(chi2, dof), 4))
print(stats.chi2_contingency(observed, correction=False)[:3])

The west signs up at 24% and the mountain region at 15%, which looks like a real gap, but chi-square is 5.38 with a p of 0.068: with these counts, a spread like this turns up by chance more than 5% of the time. Two rules for using it: it works on counts, never percentages, and every expected count should be at least about 5, or the p-value isn't trustworthy (merge small categories, or use an exact test such as Fisher's).

Drill

The counts you'd expect if nothing were going on

Write expected_counts(observed) for a 2-D NumPy array of counts: each cell is its row total times its column total, divided by the grand total. Then chi_square(observed), returning the statistic, the sum of (observed - expected)² / expected. Use keepdims=True on the row totals so they broadcast across the columns.

import numpy as np


def expected_counts(observed):
    return np.full(observed.shape, observed.mean())


def chi_square(observed):
    return 0.0


observed = np.array([[48, 152], [30, 170], [22, 78]])
print(expected_counts(observed))
print(round(chi_square(observed), 2))
In the lesson, your code is checked as you write it.

Relationships: Pearson and Spearman

Correlation measures how two numbers move together, from -1 (one always falls as the other rises) through 0 (no pattern) to 1 (they rise together). Pearson's r measures a straight-line relationship using the values themselves. Spearman's rho first replaces each value by its rank (1st, 2nd, 3rd...) and then computes Pearson's r on the ranks, so it measures whether the relationship always goes the same way, straight or curved, and one wild value can't drag it far.

import numpy as np
from scipy import stats

rng = np.random.default_rng(4)
emails = np.arange(1, 21)                                 # emails sent to 20 members
spend = 20 + 3 * emails + rng.normal(0, 6, 20)            # dollars spent, roughly rising

print("Pearson ", round(stats.pearsonr(emails, spend)[0], 3), " Spearman", round(stats.spearmanr(emails, spend)[0], 3))
spend[0] = 400                                            # one member made a huge one-off purchase
print("Pearson ", round(stats.pearsonr(emails, spend)[0], 3), " Spearman", round(stats.spearmanr(emails, spend)[0], 3))

One extreme purchase turns Pearson's 0.93 into -0.18, a negative relationship that isn't there; Spearman only drops from 0.91 to 0.63. So plot the data first, and prefer Spearman when values are skewed or have outliers.

Then remember what correlation can't tell you. Members who get more emails may spend more because the shop emails its keenest customers most: keenness drives both, a confounder. Only an experiment, such as emailing a random half, separates cause from coincidence.

Drill

Spearman is Pearson on ranks

Write spearman(x, y): rank each list with scipy.stats.rankdata (it gives tied values the average of their ranks), then return the Pearson correlation of the two rank lists, computed with np.corrcoef(rx, ry)[0, 1]. The checks compare it with SciPy's own spearmanr.

import numpy as np
from scipy import stats


def spearman(x, y):
    return float(np.corrcoef(x, y)[0, 1])


miles = [3, 7, 12, 5, 20, 9]
rentals = [1, 2, 4, 2, 30, 3]
print(round(spearman(miles, rentals), 3))
In the lesson, your code is checked as you write it.

One-sided tests, used honestly

A reminder from the A/B testing lesson, because it matters for every test here. The tests so far are two-sided: they flag a difference in either direction. If, before you collect data, you only care about one direction (the new page is better, or you'll keep the old one either way), a one-sided test looks only there, and its p-value is half the two-sided one when the data lean that way. In SciPy, pass alternative="greater" or "less".

The honest rule is about timing: choose one-sided in your plan, before seeing results. Switching after a two-sided p of 0.08 to get a one-sided 0.04 is a classic way to fool yourself, and reviewers know to ask.

Quick check
A two-sided test gives p = 0.08 for "design B differs from A", with B ahead. The team now wants to report a one-sided p = 0.04 for "B is better". What's the problem?
  1. One-sided tests only work for proportions
  2. Nothing: with B ahead, one-sided is right
  3. One-sided p-values are always the larger ones
  4. Picking the direction after seeing the data
Show the answer

Picking the direction after seeing the data

A one-sided test is legitimate when the plan said so before the data arrived. Picking it afterward, because B happened to be ahead, is a way of moving the goalposts.

Worked example

Worked example: four questions, four tests

The trail club has four questions about one season's data. Each gets its own test, and each answer is reported with the number behind it.

import numpy as np
from scipy import stats

rng = np.random.default_rng(7)
# 1. Did the new booking page change the average group size? Two groups, numbers: Welch's t-test.
old, new = rng.normal(4.1, 1.5, 60), rng.normal(4.6, 1.5, 60)
print(f"1. t-test p = {stats.ttest_ind(new, old, equal_var=False).pvalue:.2g}")

# 2. Do the three regions' trips differ in miles? Three groups, numbers: one-way ANOVA.
w, m, s = rng.normal(8, 2, 30), rng.normal(8.4, 2, 30), rng.normal(10, 2, 30)
print(f"2. ANOVA p = {stats.f_oneway(w, m, s).pvalue:.2g}")

# 3. Does preferred difficulty depend on age band? Two categories, counts: chi-square.
counts = np.array([[30, 25, 10], [20, 30, 25]])        # rows: under 40, 40 and over; columns: easy, moderate, hard
print(f"3. chi-square p = {stats.chi2_contingency(counts, correction=False)[1]:.2g}")

# 4. Do members who hike more also rent more gear? Two numbers, skewed: Spearman.
hikes = rng.poisson(6, 50)
rentals = np.maximum(0, hikes + rng.poisson(1, 50) - 3)
rho, p = stats.spearmanr(hikes, rentals)
print(f"4. Spearman rho = {rho:.2f}, p = {p:.2g}")
In the lesson you can change this and run it.

Each line pairs a question with the test its data calls for (:.2g prints a tiny p-value like 1.2e-05 instead of rounding it to 0). Read the results with the earlier warnings in mind: a small p says the pattern is unlikely to be chance, not that it's big or that one thing causes another. Report the size too, such as the difference in means, the sign-up rates by row, or rho, and for question 2, follow the ANOVA with corrected pair comparisons to say which region differs.

Practice what you just read

  • 4 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 5 review cards for spaced repetition

Create a free account

Try a quiz question

An ANOVA on five regions gives p = 0.002. What can you conclude?

  1. At least one region's average differs from the rest
  2. Every region differs from every other region
  3. The highest region beats all the rest significantly
  4. Region causes the differences in the averages

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson