Is that difference real, or could chance have produced it? t-tests, chi-square tests, p-values — and why "significant" doesn't mean "important".
Version B of a page converted better. The new drug group recovered faster. The die came up six a lot. Each time the question is the same: is this a real effect, or the noise you'd expect from a sample this size? Hypothesis tests give a disciplined answer, and they're the backbone of A/B testing, clinical trials, quality control, and the coefficient tables every regression prints.
They're also the most misused tool in statistics, so this module spends as much time on how not to fool yourself as on the formulas.
The null hypothesis. Start by assuming nothing is going on: the means are equal, the die is fair, the variables are independent. That's H₀. The alternative, H₁, is what you suspect.
The test statistic measures how far the data is from what H₀ predicts, in standardized units. A t statistic is "difference ÷ its standard error"; a chi-square statistic adds up squared gaps between observed and expected counts.
The p-value is the probability of a statistic at least as extreme as yours if H₀ were true. A small p-value means the data would be surprising under H₀. It is not the probability that H₀ is true, and 1 − p is not the probability that your effect is real.
α and the two kinds of error. Decide a threshold α (often 0.05) before looking. Rejecting a true H₀ is a Type I error (a false alarm, rate α); failing to reject a false H₀ is a Type II error (a miss). A test's power is its chance of catching a real effect, and it grows with sample size and effect size.
Choosing a t-test. One sample against a fixed value: one-sample t. Two independent groups: Welch's t, which doesn't assume equal variances and is the safe default. The same subjects measured twice: the paired t-test, which works on the differences and is far more sensitive because it removes person-to-person variation.
Counts get chi-square tests. Goodness of fit compares observed counts with expected ones (is the die fair?). The test of independence asks whether two categorical variables are related (does the choice of plan depend on the region?).
Many tests, many false alarms. At α = 0.05, twenty tests of pure noise produce about one "discovery" on average. Bonferroni divides α by the number of tests (strict); Benjamini–Hochberg controls the false discovery rate — the expected share of false positives among your discoveries — and keeps more power.
Effect size. A p-value mixes the size of an effect with the size of the sample. Cohen's d — the difference in means in standard-deviation units — reports the size alone.
| Test | Statistic | Degrees of freedom |
|---|---|---|
| One-sample t | t = (x̄ − μ₀) / (s/√n) | n − 1 |
| Welch two-sample t | t = (x̄a − x̄b) / √(s²a/na + s²b/nb) | (va + vb)² / (va²/(na−1) + vb²/(nb−1)), v = s²/n |
| Paired t | one-sample t on the differences | n − 1 |
| Goodness of fit | χ² = Σ (O − E)² / E | k − 1 |
| Independence | χ² = Σ (O − E)² / E, E = row total × column total / N | (r − 1)(c − 1) |
| Two-sided p (t) | 2*tCDF(-math.Abs(t), df) | |
| Bonferroni | min(1, p·m) | |
| Cohen's d | (x̄a − x̄b) / s_pooled |
2*tCDF(-math.Abs(t), df) keeps a p-value of 1e-12 accurate; 2*(1-tCDF(math.Abs(t), df)) subtracts two numbers near 1 and can round it to 0.Result{Statistic, DF, P} keeps the numbers together and lets you add methods like Significant(alpha).idx := []int{0, 1, …} and sort it with slices.SortFunc comparing ps[a] and ps[b].[]int; expected counts usually aren't whole numbers. Convert with float64(o) before subtracting.package main import ( "fmt" "math" ) // zTest compares two group means when the population sd is known (sigma), // returning the z statistic, the two-sided p-value, and Cohen's d. func zTest(diff, sigma float64, nPerGroup int) (z, p, d float64) { se := sigma * math.Sqrt(2/float64(nPerGroup)) // sd of the difference in means z = diff / se p = math.Erfc(math.Abs(z) / math.Sqrt2) // = 2·P(Z > |z|) d = diff / sigma return z, p, d } func main() { // Two made-up studies of a test whose scores have sd 15. studies := []struct { name string diff float64 // difference between the group means n int // people per group }{ {"huge study, tiny effect ", 0.5, 100000}, {"small study, large effect", 8, 20}, } for _, s := range studies { z, p, d := zTest(s.diff, 15, s.n) fmt.Printf("%s diff %4.1f n %6d z %5.2f p %.2g d %.2f significant at 0.05? %v\n", s.name, s.diff, s.n, z, p, d, p < 0.05) } }
It prints:
The first study's difference — half a point on a test with a standard deviation of 15 — is wildly "significant" because 100,000 people per group can detect almost anything. It is also almost certainly meaningless (d = 0.03). The second shows a sizeable effect (d = 0.53) that misses 0.05 because 20 people per group is too few to be sure. Report the effect size and an interval, not just the p-value.
Part A is the three t-tests, all built on the given tCDF. Part B is the two chi-square tests for counts. Part C adjusts p-values for multiple testing and measures effect size.
go test ./05-hypothesis-testing/
Every expected value comes from SciPy (ttest_1samp, ttest_ind(equal_var=False), ttest_rel, chisquare, chi2_contingency(correction=False)) or statsmodels (multipletests), so passing means your numbers match the standard tools to nine digits.
Everything below is also in the course download, in 05-hypothesis-testing/. Work in exercises.go; the tests decide when you’re done.
// Package hypothesis is Module 5: hypothesis tests. // // Every test here follows one recipe: assume nothing is going on (the null // hypothesis), compute a statistic measuring how far the data is from that, // and turn it into a p-value — the probability of data at least that // extreme if the null were true. package hypothesis import "github.com/bozcode/go-statistics-and-regression/internal/special" // Result is the outcome of a test. type Result struct { Statistic float64 // t or χ² DF float64 // degrees of freedom P float64 // p-value (two-sided for t tests) } // Significant reports whether the p-value is below alpha. func (r Result) Significant(alpha float64) bool { return r.P < alpha } // The helpers below are given. tCDF is what you built in Module 4; // chiSquareCDF is P(X <= x) for a chi-square with k degrees of freedom, // built on the incomplete gamma function in internal/special. func tCDF(t, df float64) float64 { return special.TCDF(t, df) } func chiSquareCDF(x, k float64) float64 { return special.ChiSquareCDF(x, k) } // mean returns the arithmetic mean of xs. func mean(xs []float64) float64 { sum := 0.0 for _, x := range xs { sum += x } return sum / float64(len(xs)) } // variance returns the sample variance of xs (divides by n − 1). func variance(xs []float64) float64 { m, ss := mean(xs), 0.0 for _, x := range xs { ss += (x - m) * (x - m) } return ss / float64(len(xs)-1) }
//go:build !solutions package hypothesis // ============================================================ // Part A — t tests // ============================================================ // OneSampleT tests whether the population mean equals mu0: // t = (x̄ − mu0) / (s/√n), df = n − 1, and a two-sided p-value // 2·P(T <= −|t|). Matches SciPy's ttest_1samp. func OneSampleT(xs []float64, mu0 float64) Result { // TODO return Result{} } // WelchT tests whether two independent groups have the same mean, without // assuming equal variances (Welch's t-test — the safe default): // // va, vb = s²/n for each group // t = (x̄a − x̄b) / √(va + vb) // df = (va + vb)² / (va²/(na−1) + vb²/(nb−1)) // // Matches SciPy's ttest_ind(a, b, equal_var=False). func WelchT(a, b []float64) Result { // TODO return Result{} } // PairedT tests paired measurements (the same subjects, before and after): // it's OneSampleT on the differences a[i] − b[i] with mu0 = 0. Matches // SciPy's ttest_rel(a, b). func PairedT(a, b []float64) Result { // TODO return Result{} } // ============================================================ // Part B — chi-square tests for counts // ============================================================ // ChiSquareGOF is the goodness-of-fit test: do the observed counts match // the expected counts? χ² = Σ (O − E)² / E, df = k − 1, and // p = 1 − chiSquareCDF(χ², df). Matches SciPy's chisquare. func ChiSquareGOF(observed []int, expected []float64) Result { // TODO return Result{} } // ChiSquareIndependence tests whether the rows and columns of a contingency // table are independent. Each cell's expected count is // rowTotal·colTotal / grandTotal; χ² = Σ (O − E)² / E over every cell, // df = (rows − 1)(cols − 1). Matches SciPy's // chi2_contingency(table, correction=False). func ChiSquareIndependence(table [][]int) Result { // TODO return Result{} } // ============================================================ // Part C — many tests, and how big the effect is // ============================================================ // Bonferroni adjusts p-values for running m tests: each becomes // min(1, p·m). Compare the adjusted values with alpha as usual. Returns a // new slice in the same order. func Bonferroni(ps []float64) []float64 { // TODO return nil } // BenjaminiHochberg adjusts p-values to control the false discovery rate. // Sort the p-values ascending; the k-th smallest (k = 1…m) becomes // p·m/k; then, from the largest down, replace each with the minimum of // itself and the one after it, and cap at 1. Return them in the ORIGINAL // order. Matches statsmodels' multipletests(method="fdr_bh"). func BenjaminiHochberg(ps []float64) []float64 { // TODO return nil } // CohenD returns the standardized difference between two group means, // (x̄a − x̄b) / s_pooled, with // s_pooled = √(((na−1)·s²a + (nb−1)·s²b) / (na + nb − 2)). // Roughly: 0.2 is small, 0.5 medium, 0.8 large. func CohenD(a, b []float64) float64 { // TODO return 0 }
package hypothesis import ( "math" "testing" ) // near reports whether a and b agree to about 9 significant digits. // Expected values come from SciPy and statsmodels. func near(a, b float64) bool { return math.Abs(a-b) <= 1e-9*math.Max(1, math.Abs(b)) } func check(t *testing.T, name string, got, want Result) { t.Helper() if !near(got.Statistic, want.Statistic) || !near(got.DF, want.DF) || !near(got.P, want.P) { t.Errorf("%s = %+v, want %+v", name, got, want) } } // bottle fills in ml (made-up data); the label says 500 var fills = []float64{498.2, 501.1, 497.6, 499.3, 498.8, 500.2, 497.9, 499.5, 498.4, 499.0, 497.2, 499.8} // seconds to finish a task on two page designs (made-up data) var designA = []float64{34.1, 29.8, 41.2, 37.5, 30.9, 36.4, 33.0, 39.8} var designB = []float64{28.4, 31.2, 26.9, 30.1, 27.5, 33.8, 29.0, 25.6, 31.7, 28.8} func TestOneSampleT(t *testing.T) { check(t, "OneSampleT(fills, 500)", OneSampleT(fills, 500), Result{Statistic: -3.3033597986209187, DF: 11, P: 0.007035921490324698}) r := OneSampleT([]float64{4.9, 5.1, 5.0, 4.8, 5.2}, 5) if math.Abs(r.Statistic) > 1e-9 || !near(r.P, 1) { t.Errorf("a sample centred exactly on mu0: got %+v, want t = 0, p = 1", r) } } func TestWelchT(t *testing.T) { check(t, "WelchT(designA, designB)", WelchT(designA, designB), Result{Statistic: 3.6755093164374473, DF: 10.89200578962794, P: 0.00371382564279196}) // swapping the groups flips the sign and nothing else r := WelchT(designB, designA) if !near(r.Statistic, -3.6755093164374473) || !near(r.P, 0.00371382564279196) { t.Errorf("WelchT(designB, designA) = %+v", r) } } func TestPairedT(t *testing.T) { before := []float64{142, 138, 150, 145, 160, 155, 139, 148} after := []float64{136, 135, 144, 146, 151, 149, 137, 141} check(t, "PairedT(before, after)", PairedT(before, after), Result{Statistic: 4.203727949252743, DF: 7, P: 0.0040169099978379225}) } func TestChiSquareGOF(t *testing.T) { // is this die fair? 100 rolls fair := []float64{100.0 / 6, 100.0 / 6, 100.0 / 6, 100.0 / 6, 100.0 / 6, 100.0 / 6} check(t, "ChiSquareGOF(die)", ChiSquareGOF([]int{16, 18, 16, 14, 12, 24}, fair), Result{Statistic: 5.12, DF: 5, P: 0.4014115932460956}) expected := []float64{40, 20, 30, 40, 50, 20} check(t, "ChiSquareGOF(uneven)", ChiSquareGOF([]int{30, 14, 34, 45, 57, 20}, expected), Result{Statistic: 6.438333333333333, DF: 5, P: 0.26587161209871035}) } func TestChiSquareIndependence(t *testing.T) { check(t, "2×3 table", ChiSquareIndependence([][]int{{42, 33, 25}, {18, 37, 45}}), Result{Statistic: 15.542857142857141, DF: 2, P: 0.00042161053562412366}) check(t, "2×2 table", ChiSquareIndependence([][]int{{30, 10}, {20, 25}}), Result{Statistic: 8.16269841269841, DF: 1, P: 0.004276070303601571}) } var ps = []float64{0.001, 0.008, 0.039, 0.041, 0.042, 0.06, 0.074, 0.205, 0.212, 0.216} func TestBonferroni(t *testing.T) { want := []float64{0.01, 0.08, 0.39, 0.41, 0.42, 0.6, 0.74, 1, 1, 1} got := Bonferroni(ps) if len(got) != len(want) { t.Fatalf("Bonferroni returned %d values, want %d", len(got), len(want)) } for i := range want { if !near(got[i], want[i]) { t.Fatalf("Bonferroni = %v, want %v", got, want) } } if ps[0] != 0.001 { t.Error("Bonferroni modified its input") } } func TestBenjaminiHochberg(t *testing.T) { // deliberately unsorted: results must come back in input order in := []float64{0.042, 0.205, 0.001, 0.074, 0.039, 0.216, 0.008, 0.06, 0.212, 0.041} want := []float64{0.084, 0.216, 0.01, 0.10571428571428572, 0.084, 0.216, 0.04, 0.1, 0.216, 0.084} got := BenjaminiHochberg(in) if len(got) != len(want) { t.Fatalf("BenjaminiHochberg returned %d values, want %d", len(got), len(want)) } for i := range want { if !near(got[i], want[i]) { t.Fatalf("BenjaminiHochberg = %v, want %v", got, want) } } if in[0] != 0.042 { t.Error("BenjaminiHochberg modified its input") } } func TestCohenD(t *testing.T) { if got := CohenD(designA, designB); !near(got, 1.8438942692909126) { t.Errorf("CohenD(designA, designB) = %v, want 1.8438942692909126", got) } if got := CohenD(designB, designA); !near(got, -1.8438942692909126) { t.Errorf("CohenD(designB, designA) = %v, want -1.8438942692909126", got) } }
Try not to open solution.go until your tests pass, or until you’ve been stuck for a solid half hour. Then compare: different-but-correct is fine; notice where the reference is simpler. go test -tags solutions ./05-hypothesis-testing/ runs the same tests against it.
solutions · 121 lines›//go:build solutions package hypothesis import ( "math" "slices" ) // ============================================================ // Part A — t tests // ============================================================ // twoSidedT is the two-sided p-value for a t statistic: 2·P(T <= −|t|). // Computing the lower tail directly keeps tiny p-values accurate; 1 − tCDF // would round them to 0. func twoSidedT(t, df float64) float64 { return 2 * tCDF(-math.Abs(t), df) } func OneSampleT(xs []float64, mu0 float64) Result { n := float64(len(xs)) t := (mean(xs) - mu0) / math.Sqrt(variance(xs)/n) return Result{Statistic: t, DF: n - 1, P: twoSidedT(t, n-1)} } func WelchT(a, b []float64) Result { na, nb := float64(len(a)), float64(len(b)) va, vb := variance(a)/na, variance(b)/nb t := (mean(a) - mean(b)) / math.Sqrt(va+vb) df := (va + vb) * (va + vb) / (va*va/(na-1) + vb*vb/(nb-1)) return Result{Statistic: t, DF: df, P: twoSidedT(t, df)} } func PairedT(a, b []float64) Result { d := make([]float64, len(a)) for i := range a { d[i] = a[i] - b[i] } return OneSampleT(d, 0) } // ============================================================ // Part B — chi-square tests for counts // ============================================================ func ChiSquareGOF(observed []int, expected []float64) Result { chi := 0.0 for i, o := range observed { d := float64(o) - expected[i] chi += d * d / expected[i] } df := float64(len(observed) - 1) return Result{Statistic: chi, DF: df, P: 1 - chiSquareCDF(chi, df)} } func ChiSquareIndependence(table [][]int) Result { rows := make([]float64, len(table)) cols := make([]float64, len(table[0])) total := 0.0 for i, row := range table { for j, c := range row { rows[i] += float64(c) cols[j] += float64(c) total += float64(c) } } chi := 0.0 for i, row := range table { for j, c := range row { e := rows[i] * cols[j] / total chi += (float64(c) - e) * (float64(c) - e) / e } } df := float64((len(rows) - 1) * (len(cols) - 1)) return Result{Statistic: chi, DF: df, P: 1 - chiSquareCDF(chi, df)} } // ============================================================ // Part C — many tests, and how big the effect is // ============================================================ func Bonferroni(ps []float64) []float64 { m := float64(len(ps)) out := make([]float64, len(ps)) for i, p := range ps { out[i] = math.Min(1, p*m) } return out } func BenjaminiHochberg(ps []float64) []float64 { m := len(ps) idx := make([]int, m) for i := range idx { idx[i] = i } slices.SortFunc(idx, func(a, b int) int { switch { case ps[a] < ps[b]: return -1 case ps[a] > ps[b]: return 1 } return 0 }) out := make([]float64, m) running := 1.0 for k := m; k >= 1; k-- { i := idx[k-1] running = math.Min(running, ps[i]*float64(m)/float64(k)) out[i] = running } return out } func CohenD(a, b []float64) float64 { na, nb := float64(len(a)), float64(len(b)) pooled := math.Sqrt(((na-1)*variance(a) + (nb-1)*variance(b)) / (na + nb - 2)) return (mean(a) - mean(b)) / pooled }
PooledT(a, b) — Student's equal-variance t-test, df = na + nb − 2 — and compare its p-value with Welch's when one group is much more variable.WelchT 2,000 times, and count rejections. How big must n be for 80% power?Four questions. Answer each one in your head — or out loud — before you open the answer.
A test gives p = 0.03. Which statement is correct?
C — A p-value is computed assuming H₀ is true. It says nothing directly about the probability of H₀, or about the size of the effect.
You measure blood pressure on the same 8 patients before and after a drug. Which test fits?
B — The measurements come in pairs. Testing the differences removes the large person-to-person variation and isolates the drug's effect.
You run 20 independent tests at α = 0.05, and every null hypothesis is true. About how many "significant" results should you expect?
B — 20 × 0.05 = 1. The chance of at least one false positive is 1 − 0.95²⁰ ≈ 64%. That's why multiple testing needs a correction.
A huge study finds p < 0.0001 for a difference with Cohen's d = 0.02. What should you conclude?
B — With enough data, even trivial differences become "significant". Effect size tells you whether the difference matters.
Commit your work before you move on to module 6, so git log shows your own progress through the course.
From "the average is 100.2" to "the average is between 98.6 and 101.8, and here's how sure we are" — with the t distribution doing the honest work.
6How strongly do two variables move together? Covariance, Pearson's r, Spearman's rank correlation — and four data sets that prove you must look at the plot.
⌂All ten modules, the course download, and how to track your progress.