Files
tensor/stats/contingency_test.go
T

514 lines
16 KiB
Go
Raw Normal View History

2026-09-03 10:00:00 +02:00
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package stats
import (
"math"
"math/big"
"testing"
"time"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// The contingency tests against exact referents. The hypergeometric
// and the central binomial probabilities are rational numbers, so the
// Fisher and McNemar p-values are compared against a big.Rat
// evaluation of the same definition, not against a tolerance quote.
// exactFactorial returns n! as a big.Int.
func exactFactorial(n int64) *big.Int {
out := big.NewInt(1)
for i := int64(2); i <= n; i++ {
out.Mul(out, big.NewInt(i))
}
return out
}
// exactChoose returns C(n, k) as a big.Int.
func exactChoose(n, k int64) *big.Int {
return new(big.Int).Div(exactFactorial(n), new(big.Int).Mul(exactFactorial(k), exactFactorial(n-k)))
}
// exactFisherTwoSided evaluates the two-sided Fisher p-value of the
// 2×2 table (a b / c d) exactly: the sum of every table probability
// that does not exceed the observed one's, over the hypergeometric
// support the margins induce.
func exactFisherTwoSided(a, b, c, d int64) *big.Rat {
row1, row2 := a+b, c+d
col1 := a + c
col2 := b + d
lo := max(int64(0), row1-col2)
hi := min(row1, col1)
den := new(big.Rat).SetInt(exactChoose(row1+row2, row1))
observed := new(big.Rat)
terms := make([]*big.Rat, 0, hi-lo+1)
for x := lo; x <= hi; x++ {
p := new(big.Rat).SetFrac(
new(big.Int).Mul(exactChoose(col1, x), exactChoose(col2, row1-x)),
den.Num())
terms = append(terms, p)
if x == a {
observed.Set(p)
}
}
sum := new(big.Rat)
for _, p := range terms {
if p.Cmp(observed) <= 0 {
sum.Add(sum, p)
}
}
return sum
}
// exactMcNemar evaluates the exact McNemar p-value of the discordant
// pair (b, c) exactly: twice the binomial(m, ½) tail at min(b, c).
func exactMcNemar(b, c int64) *big.Rat {
m := b + c
if m == 0 {
return big.NewRat(1, 1)
}
k := min(b, c)
den := new(big.Int).Lsh(big.NewInt(1), uint(m))
tail := new(big.Rat)
for i := int64(0); i <= k; i++ {
term := new(big.Rat).SetFrac(exactChoose(m, i), den)
tail.Add(tail, term)
}
// The doubled tail is clamped at 1 exactly as the entry point
// clamps it: at the middle pair min(b, c) = m/2 the sum already
// covers the median and doubling overruns.
if tail.Cmp(big.NewRat(1, 2)) > 0 {
return big.NewRat(1, 1)
}
return tail.Add(tail, tail)
}
// table2x2 builds a 2×2 float array from four counts.
func table2x2(t *testing.T, a, b, c, d float64) *core.Array {
t.Helper()
tab, err := core.FromFloats([]float64{a, b, c, d}, 2, 2)
if err != nil {
t.Fatal(err)
}
return tab
}
func TestFisherExactAgainstRationalReferent(t *testing.T) {
tables := [][4]int64{
{12, 5, 7, 10},
{8, 2, 1, 5},
{3, 1, 1, 3},
{1, 9, 9, 1},
{0, 6, 6, 0},
{17, 0, 0, 23},
{35, 12, 41, 9},
}
for _, tab := range tables {
p, or, err := FisherExactTest(table2x2(t, float64(tab[0]), float64(tab[1]), float64(tab[2]), float64(tab[3])), TwoSided)
if err != nil {
t.Fatalf("FisherExactTest(%v): %v", tab, err)
}
want := exactFisherTwoSided(tab[0], tab[1], tab[2], tab[3])
wantF, _ := want.Float64()
if math.Abs(p-wantF) > 1e-12 {
t.Fatalf("FisherExactTest(%v) two-sided = %.16f, want the exact %.16f", tab, p, wantF)
}
wantOR := float64(tab[0]) * float64(tab[3]) / (float64(tab[1]) * float64(tab[2]))
if math.IsNaN(wantOR) {
if !math.IsNaN(or) {
t.Fatalf("FisherExactTest(%v): odds ratio %g, want NaN", tab, or)
}
} else if or != wantOR {
t.Fatalf("FisherExactTest(%v): odds ratio %g, want %g", tab, or, wantOR)
}
}
}
func TestFisherExactTeaTablePin(t *testing.T) {
// The classical 3/1 versus 1/3 table: the two-sided answer is
// 34/70 by hand, every table but the central one being at most as
// probable as the observed one.
p, or, err := FisherExactTest(table2x2(t, 3, 1, 1, 3), TwoSided)
if err != nil {
t.Fatal(err)
}
if math.Abs(p-34.0/70.0) > 1e-12 {
t.Fatalf("the tea table p = %.16f, want 34/70 = %.16f", p, 34.0/70.0)
}
if or != 9 {
t.Fatalf("the tea table odds ratio = %g, want 9", or)
}
}
func TestFisherExactOneSidedTails(t *testing.T) {
tab := table2x2(t, 12, 5, 7, 10)
less, _, err := FisherExactTest(tab, Less)
if err != nil {
t.Fatal(err)
}
greater, _, err := FisherExactTest(tab, Greater)
if err != nil {
t.Fatal(err)
}
// The observed first cell (12) sits above its expectation 9.5: the
// greater tail must be the shorter one. The two-sided sum carries
// no ordering guarantee against the tails, because it collects
// only the tables at most as probable as the observed one while a
// one-sided tail sweeps the whole arm including the mode.
if !(greater < less) {
t.Fatalf("the greater tail %g does not fall below the less tail %g on an upper-tail table", greater, less)
}
// The greater tail, recomputed from the exact binomial
// coefficients over the support's upper arm: the margins are
// row1 = 17, row2 = 17, col1 = 19, col2 = 15, the support runs
// 2..17, and the tail collects x = 12..17.
sum := new(big.Rat)
den := new(big.Rat).SetInt(exactChoose(34, 17))
for x := int64(12); x <= 17; x++ {
term := new(big.Rat).SetInt(new(big.Int).Mul(exactChoose(19, x), exactChoose(15, 17-x)))
term.Quo(term, den)
sum.Add(sum, term)
}
want, _ := sum.Float64()
if math.Abs(greater-want) > 1e-12 {
t.Fatalf("the greater tail = %.16f, want the exact %.16f", greater, want)
}
}
func TestFisherExactDegenerateMargins(t *testing.T) {
// A zero margin admits one table only: p = 1 with the odds ratio
// the formula speaks.
p, or, err := FisherExactTest(table2x2(t, 0, 5, 0, 7), TwoSided)
if err != nil {
t.Fatal(err)
}
if p != 1 {
t.Fatalf("a zero-row table answered p = %g, want 1", p)
}
if !math.IsNaN(or) {
t.Fatalf("a zero-row table answered odds ratio %g, want NaN", or)
}
// A zero cell against a full one inside living margins: the odds
// ratio goes infinite and the two-sided p-value is the observed
// corner's own probability, 1/210 by the exact coefficients.
p, or, err = FisherExactTest(table2x2(t, 6, 0, 0, 4), TwoSided)
if err != nil {
t.Fatal(err)
}
if math.Abs(p-1.0/210.0) > 1e-12 {
t.Fatalf("the corner table answered p = %.16f, want 1/210 = %.16f", p, 1.0/210.0)
}
if or != math.Inf(1) {
t.Fatalf("the corner table answered odds ratio %g, want +Inf", or)
}
}
func TestFisherExactRefusals(t *testing.T) {
if _, _, err := FisherExactTest(mustStatArray(t, []float64{1, 2, 3}, 3), TwoSided); err == nil {
t.Fatal("a rank-1 table was accepted")
}
if _, _, err := FisherExactTest(table2x2(t, 1, 2, 3, 4), Alternative(7)); err == nil {
t.Fatal("an unknown alternative was accepted")
}
if _, _, err := FisherExactTest(table2x2(t, 1, 2, 3, -4), TwoSided); err == nil {
t.Fatal("a negative count was accepted")
}
if _, _, err := FisherExactTest(table2x2(t, 1, 2.5, 3, 4), TwoSided); err == nil {
t.Fatal("a fractional count was accepted")
}
if _, _, err := FisherExactTest(table2x2(t, 1, math.NaN(), 3, 4), TwoSided); err == nil {
t.Fatal("a NaN count was accepted")
}
// Margins wide enough to push the support past the cap: the refusal
// names the cap instead of enumerating millions of tables.
if _, _, err := FisherExactTest(table2x2(t, 3e6, 3e6, 3e6, 3e6), TwoSided); err == nil {
t.Fatal("an over-cap support was accepted")
}
}
// mustStatArray builds a float array for the refusal probes.
func mustStatArray(t *testing.T, vals []float64, shape ...int) *core.Array {
t.Helper()
a, err := core.FromFloats(vals, shape...)
if err != nil {
t.Fatal(err)
}
return a
}
func TestMcNemarAgainstRationalReferent(t *testing.T) {
pairs := [][2]int64{
{5, 15},
{0, 10},
{10, 0},
{7, 7},
{1, 1},
{0, 0},
{23, 41},
}
for _, pr := range pairs {
p, err := McNemarTest(table2x2(t, 10, float64(pr[0]), float64(pr[1]), 12))
if err != nil {
t.Fatalf("McNemarTest(%v): %v", pr, err)
}
wantF, _ := exactMcNemar(pr[0], pr[1]).Float64()
if math.Abs(p-wantF) > 1e-12 {
t.Fatalf("McNemarTest(b=%d, c=%d) = %.16f, want the exact %.16f", pr[0], pr[1], p, wantF)
}
}
// Large discordant counts: the incomplete-beta route must keep
// answering well past where an m-term sum would have become too
// expensive to run.
b, c := 2000.0, 3000.0
p, err := McNemarTest(table2x2(t, 100, b, c, 100))
if err != nil {
t.Fatal(err)
}
if !(0 < p && p < 1) {
t.Fatalf("McNemarTest(2000, 3000) = %g, want a probability in (0, 1)", p)
}
pSmall, err := McNemarTest(table2x2(t, 100, 0, 900, 100))
if err != nil {
t.Fatal(err)
}
if !(pSmall < 1e-15) {
t.Fatalf("McNemarTest(0, 900) = %g, want a vanishing tail", pSmall)
}
}
func TestMcNemarRefusals(t *testing.T) {
if _, err := McNemarTest(mustStatArray(t, []float64{1, 2, 3, 4, 5, 6}, 2, 3)); err == nil {
t.Fatal("a 2×3 table was accepted")
}
if _, err := McNemarTest(table2x2(t, 1, 2, 3, math.Inf(1))); err == nil {
t.Fatal("an infinite count was accepted")
}
}
func TestChiSquareIndependenceTwoByTwoIdentity(t *testing.T) {
// For a 2×2 table the definitional sum collapses algebraically to
// n(ad−bc)² over the product of the four margins, an independent
// route the sum must reproduce bit for bit.
tables := [][4]float64{
{12, 7, 5, 16},
{10, 20, 20, 30},
{1, 9, 9, 1},
{153, 71, 44, 232},
}
for _, tab := range tables {
chi2, df, p, err := ChiSquareIndependence(table2x2(t, tab[0], tab[1], tab[2], tab[3]))
if err != nil {
t.Fatalf("ChiSquareIndependence(%v): %v", tab, err)
}
if df != 1 {
t.Fatalf("ChiSquareIndependence(%v): df = %d, want 1", tab, df)
}
n := tab[0] + tab[1] + tab[2] + tab[3]
want := n * math.Pow(tab[0]*tab[3]-tab[1]*tab[2], 2) /
((tab[0] + tab[1]) * (tab[2] + tab[3]) * (tab[0] + tab[2]) * (tab[1] + tab[3]))
if math.Abs(chi2-want) > 1e-12*math.Max(1, want) {
t.Fatalf("ChiSquareIndependence(%v): χ² = %.16f, want the closed form %.16f", tab, chi2, want)
}
if !(0 < p && p < 1) {
t.Fatalf("ChiSquareIndependence(%v): p = %g is not a probability in (0, 1)", tab, p)
}
// Cramér's V for a 2×2 table is |φ| = |ad−bc| over the square
// root of the same product of margins.
v, err := CramersV(table2x2(t, tab[0], tab[1], tab[2], tab[3]))
if err != nil {
t.Fatal(err)
}
phi := math.Abs(tab[0]*tab[3]-tab[1]*tab[2]) /
math.Sqrt((tab[0]+tab[1])*(tab[2]+tab[3])*(tab[0]+tab[2])*(tab[1]+tab[3]))
if math.Abs(v-phi) > 1e-12 {
t.Fatalf("CramersV(%v) = %.16f, want |φ| = %.16f", tab, v, phi)
}
}
}
func TestChiSquareIndependenceLargerTable(t *testing.T) {
// A 3×3 table with the statistic recomputed from the definition
// through an independent walk over the expected counts.
counts := []float64{
21, 8, 3,
9, 15, 11,
4, 12, 30,
}
tab := mustStatArray(t, counts, 3, 3)
chi2, df, p, err := ChiSquareIndependence(tab)
if err != nil {
t.Fatal(err)
}
if df != 4 {
t.Fatalf("df = %d, want 4", df)
}
rows := make([]float64, 3)
cols := make([]float64, 3)
total := 0.0
for i := range 3 {
for j := range 3 {
rows[i] += counts[i*3+j]
cols[j] += counts[i*3+j]
total += counts[i*3+j]
}
}
want := 0.0
for i := range 3 {
for j := range 3 {
e := rows[i] * cols[j] / total
want += math.Pow(counts[i*3+j]-e, 2) / e
}
}
if math.Abs(chi2-want) > 1e-12*math.Max(1, want) {
t.Fatalf("χ² = %.16f, want %.16f", chi2, want)
}
v, err := CramersV(tab)
if err != nil {
t.Fatal(err)
}
wantV := math.Sqrt(want / (total * 2))
if math.Abs(v-wantV) > 1e-12 {
t.Fatalf("Cramér's V = %.16f, want %.16f", v, wantV)
}
// Perfect independence answers χ² = 0, p = 1 and V = 0.
independent := []float64{10, 20, 10, 20, 40, 20, 10, 20, 10}
chi2, _, p, err = ChiSquareIndependence(mustStatArray(t, independent, 3, 3))
if err != nil {
t.Fatal(err)
}
if chi2 != 0 || p != 1 {
t.Fatalf("a perfectly independent table answered χ² = %g, p = %g", chi2, p)
}
}
func TestChiSquareIndependenceRefusals(t *testing.T) {
if _, _, _, err := ChiSquareIndependence(mustStatArray(t, []float64{1, 2, 3, 4}, 4)); err == nil {
t.Fatal("a rank-1 table was accepted")
}
if _, _, _, err := ChiSquareIndependence(mustStatArray(t, []float64{1, 2, 3}, 3, 1)); err == nil {
t.Fatal("a single-column table was accepted")
}
if _, _, _, err := ChiSquareIndependence(table2x2(t, 1, 0, 3, 0)); err == nil {
t.Fatal("a zero-column margin was accepted")
}
if _, _, _, err := ChiSquareIndependence(table2x2(t, 1, 2, 3, 2.5)); err == nil {
t.Fatal("a fractional count was accepted")
}
if _, err := CramersV(table2x2(t, 1, 2, 3, -4)); err == nil {
t.Fatal("a negative count was accepted")
}
if _, _, _, err := ChiSquareIndependence(table2x2(t, 0, 0, 0, 0)); err == nil {
t.Fatal("an all-zero table was accepted")
}
}
func TestContingencyDtypeAgreement(t *testing.T) {
// A table carried in an integer dtype must answer the same numbers
// as the float array of the widened values.
vals := []float64{12, 5, 7, 10}
fv, _, err := FisherExactTest(table2x2(t, vals[0], vals[1], vals[2], vals[3]), TwoSided)
if err != nil {
t.Fatal(err)
}
iv := make([]int64, len(vals))
for i, v := range vals {
iv[i] = int64(v)
}
ia, err := core.FromInts(iv, 2, 2)
if err != nil {
t.Fatal(err)
}
pv, or, err := FisherExactTest(ia, TwoSided)
if err != nil {
t.Fatal(err)
}
if pv != fv || or != 120.0/35.0 {
t.Fatalf("the int table answered (%g, %g), want (%g, %g)", pv, or, fv, 120.0/35.0)
}
}
func TestFisherExactLopsidedOverflow(t *testing.T) {
// A corner table with living margins: the observed table sits
// hundreds of nats below the hypergeometric mode, so the ratio of
// the mode's probability to the observed one's overflows the
// format. The one-sided tail over the whole support is the certain
// event and must answer exactly 1, the opposite tail and the
// two-sided sum underflow to the correctly rounded zero, and none
// of the three may turn into the NaN a quotient of infinities
// produces.
greater, _, err := FisherExactTest(table2x2(t, 0, 1000, 1000, 0), Greater)
if err != nil {
t.Fatal(err)
}
if greater != 1 {
t.Fatalf("the certain Greater tail answered %g, want 1", greater)
}
less, _, err := FisherExactTest(table2x2(t, 0, 1000, 1000, 0), Less)
if err != nil {
t.Fatal(err)
}
twoSided, _, err := FisherExactTest(table2x2(t, 0, 1000, 1000, 0), TwoSided)
if err != nil {
t.Fatal(err)
}
if math.IsNaN(less) || math.IsNaN(twoSided) {
t.Fatalf("the far tails answered NaN (%g, %g)", less, twoSided)
}
if less != 0 || twoSided != 0 {
// 1/C(2000,1000) and 2/C(2000,1000) sit near 1e-600: the
// correctly rounded float64 of both is 0.
t.Fatalf("the underflowed tails answered (%g, %g), want (0, 0)", less, twoSided)
}
// A table whose two-sided answer lands in the representable
// subnormal window while the mode's ratio still overflows: the
// exact referent is 2/C(1040,520), near 2e-313.
p, _, err := FisherExactTest(table2x2(t, 0, 520, 520, 0), TwoSided)
if err != nil {
t.Fatal(err)
}
want, _ := new(big.Rat).SetFrac(
big.NewInt(2),
exactChoose(1040, 520)).Float64()
if !(p > 0) || math.Abs(p-want) > 1e-318 {
t.Fatalf("the subnormal two-sided tail answered %.16e, want the exact %.16e", p, want)
}
}
func TestFisherExactUnknownAlternativeBeforeDegenerateMargins(t *testing.T) {
// The alternative is refused whatever the margins: a zero-margin
// table must not short-circuit the refusal behind a p-value of 1.
if _, _, err := FisherExactTest(table2x2(t, 0, 5, 0, 7), Alternative(7)); err == nil {
t.Fatal("an unknown alternative was accepted on a zero-margin table")
}
}
func TestFisherExactCountBeyondTheFormatsIntegers(t *testing.T) {
// A count past 2^51 can push the margins past 2^52, where float64
// holds only every second integer: the support walk's increment
// stops advancing and the enumeration cannot leave its first table.
// The table below answers a support of one rounded point, so the
// cap check cannot catch it; the refusal must, and it must come
// back rather than hang.
var err error
pinWatchdog(t, 10*time.Second, "FisherExactTest on [[1e18,0],[0,3]]", func() {
_, _, err = FisherExactTest(table2x2(t, 1e18, 0, 0, 3), TwoSided)
})
if err == nil {
t.Fatal("a count past the format's integers was accepted")
}
if _, err := McNemarTest(table2x2(t, 1, 1e18, 1, 1)); err == nil {
t.Fatal("McNemarTest accepted a count past the format's integers")
}
// The last count the format holds stepwise stays answerable: the
// bound refuses nothing a legal enumeration can run on.
p, _, err := FisherExactTest(table2x2(t, float64(maxExactCount-2), 0, 0, 3), TwoSided)
if err != nil {
t.Fatalf("a count at the bound's inside was refused: %v", err)
}
if !(p >= 0 && p <= 1) {
t.Fatalf("the boundary table answered p = %g", p)
}
}