mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
A run stops at whichever comes first, the step budget or --duration, so a clean run that reached the wall clock exited with fewer steps than the budget and was still credited with the whole of it. The model arm pays a network call and a screenshot per step, so it reaches the wall sooner and was handed exposure it never had. Nothing checked that two arms shared a budget either. Thirty identical clean runs under budgets of 400 and 100 read a12 0.000 and p 1.685e-14 from the rank-sum while the log-rank in the same report read p 1.0000. groupArms already refused this within one arm. The claims the old convention left in comments and report lines are corrected rather than left standing beside the new behaviour.
212 lines
7.1 KiB
Go
212 lines
7.1 KiB
Go
package main
|
|
|
|
import (
|
|
"math"
|
|
"testing"
|
|
)
|
|
|
|
// R's wilcox.test on the Hollander and Wolfe (1973), 69f chorioamnion data
|
|
// reports W = 35 with an exact two-sided p-value of 0.2544; the one-sided
|
|
// greater alternative that the help page uses reports the same W with
|
|
// p-value = 0.1272.
|
|
func TestRankSum_MatchesPublishedChorioamnionResult(t *testing.T) {
|
|
result := rankSum(chorioamnionTerm, chorioamnionEarly)
|
|
|
|
if result.Statistic != 35 {
|
|
t.Errorf("statistic %v, want 35", result.Statistic)
|
|
}
|
|
if !result.Exact {
|
|
t.Error("expected the exact null distribution for untied samples this small")
|
|
}
|
|
if math.Abs(result.PValue-0.2544) > 5e-5 {
|
|
t.Errorf("two-sided p-value %.6f, want 0.2544", result.PValue)
|
|
}
|
|
upper := exactRankSumUpperTail(35, len(chorioamnionTerm), len(chorioamnionEarly))
|
|
if math.Abs(upper-0.1272) > 5e-5 {
|
|
t.Errorf("one-sided p-value %.6f, want 0.1272", upper)
|
|
}
|
|
}
|
|
|
|
// A12 is P(X > Y) + 0.5 P(X = Y), which is U/(mn). With the published W = 35
|
|
// and sample sizes 10 and 5 the effect size is 35/50 = 0.70. The expected value
|
|
// is therefore the published Mann-Whitney statistic combined with the published
|
|
// definition in Vargha and Delaney (2000), not a number this tool produced.
|
|
func TestVargaDelaneyA12_MatchesPublishedChorioamnionStatistic(t *testing.T) {
|
|
got := vargaDelaneyA12(chorioamnionTerm, chorioamnionEarly)
|
|
if math.Abs(got-0.70) > 1e-12 {
|
|
t.Errorf("A12 = %v, want 0.70", got)
|
|
}
|
|
if reversed := vargaDelaneyA12(chorioamnionEarly, chorioamnionTerm); math.Abs(reversed-0.30) > 1e-12 {
|
|
t.Errorf("reversed A12 = %v, want 0.30", reversed)
|
|
}
|
|
}
|
|
|
|
// Identical samples are stochastically equal, which Vargha and Delaney define
|
|
// as A = 0.5, and complete separation gives 1 and 0.
|
|
func TestVargaDelaneyA12_BoundaryCases(t *testing.T) {
|
|
same := []float64{1, 2, 3, 4}
|
|
if got := vargaDelaneyA12(same, same); got != 0.5 {
|
|
t.Errorf("A12 of a sample against itself = %v, want 0.5", got)
|
|
}
|
|
if got := vargaDelaneyA12([]float64{5, 6, 7}, []float64{1, 2}); got != 1 {
|
|
t.Errorf("A12 with complete dominance = %v, want 1", got)
|
|
}
|
|
if got := vargaDelaneyA12([]float64{1, 2}, []float64{5, 6, 7}); got != 0 {
|
|
t.Errorf("A12 with complete subordination = %v, want 0", got)
|
|
}
|
|
}
|
|
|
|
// The counting definition and the rank-sum route must agree, including when the
|
|
// samples are tied against each other, which is the case the evaluation data is
|
|
// usually in because censored runs pile up on the step they stopped at.
|
|
func TestVargaDelaneyA12_AgreesWithRankSumStatistic(t *testing.T) {
|
|
cases := [][2][]float64{
|
|
{{1, 2, 3}, {2, 3, 4}},
|
|
{{40, 40, 40, 12}, {40, 7, 3}},
|
|
{{5}, {5, 5, 5}},
|
|
{{9, 9, 9}, {9, 9, 9}},
|
|
}
|
|
for _, test := range cases {
|
|
result := rankSum(test[0], test[1])
|
|
expected := result.Statistic / float64(len(test[0])*len(test[1]))
|
|
if math.Abs(result.A12-expected) > 1e-12 {
|
|
t.Errorf("A12 %v for %v vs %v, want U/(mn) = %v", result.A12, test[0], test[1], expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The tie-corrected variance is checked against the exact permutation variance
|
|
// of the statistic, computed here by enumerating every way to split the pooled
|
|
// midranks. That is an independent calculation, not a second call into the
|
|
// implementation under test.
|
|
func TestRankSumVariance_MatchesExactPermutationVariance(t *testing.T) {
|
|
cases := [][]float64{
|
|
{1, 2, 3, 4, 5, 6, 7, 8},
|
|
{40, 40, 40, 40, 12, 7, 3, 3},
|
|
{5, 5, 5, 5, 5, 5, 5, 9},
|
|
{2, 2, 3, 3, 3, 4, 9, 9, 9},
|
|
}
|
|
for _, pooled := range cases {
|
|
firstSize := len(pooled) / 2
|
|
ranks, tieGroups := midRanks(pooled)
|
|
mean, variance := permutationMomentsOfRankSum(ranks, firstSize)
|
|
expectedMean := float64(firstSize*(len(pooled)-firstSize)) / 2
|
|
if math.Abs(mean-expectedMean) > 1e-9 {
|
|
t.Errorf("%v: permutation mean %v, want %v", pooled, mean, expectedMean)
|
|
}
|
|
got := rankSumVariance(firstSize, len(pooled)-firstSize, tieGroups)
|
|
if math.Abs(got-variance) > 1e-9 {
|
|
t.Errorf("%v: tie-corrected variance %v, want the permutation variance %v", pooled, got, variance)
|
|
}
|
|
}
|
|
}
|
|
|
|
// permutationMomentsOfRankSum enumerates every subset of the given size and
|
|
// returns the mean and variance of the Mann-Whitney statistic over them.
|
|
func permutationMomentsOfRankSum(ranks []float64, firstSize int) (float64, float64) {
|
|
offset := float64(firstSize) * float64(firstSize+1) / 2
|
|
var values []float64
|
|
chosen := make([]int, 0, firstSize)
|
|
var walk func(start int)
|
|
walk = func(start int) {
|
|
if len(chosen) == firstSize {
|
|
total := 0.0
|
|
for _, index := range chosen {
|
|
total += ranks[index]
|
|
}
|
|
values = append(values, total-offset)
|
|
return
|
|
}
|
|
for index := start; index < len(ranks); index++ {
|
|
chosen = append(chosen, index)
|
|
walk(index + 1)
|
|
chosen = chosen[:len(chosen)-1]
|
|
}
|
|
}
|
|
walk(0)
|
|
mean := 0.0
|
|
for _, value := range values {
|
|
mean += value
|
|
}
|
|
mean /= float64(len(values))
|
|
variance := 0.0
|
|
for _, value := range values {
|
|
variance += (value - mean) * (value - mean)
|
|
}
|
|
return mean, variance / float64(len(values))
|
|
}
|
|
|
|
func TestMidRanks_AveragesTiedGroups(t *testing.T) {
|
|
ranks, tieGroups := midRanks([]float64{3, 1, 3, 2, 3})
|
|
expected := []float64{4, 1, 4, 2, 4}
|
|
for index, want := range expected {
|
|
if ranks[index] != want {
|
|
t.Errorf("rank %d = %v, want %v", index, ranks[index], want)
|
|
}
|
|
}
|
|
if len(tieGroups) != 1 || tieGroups[0] != 3 {
|
|
t.Errorf("tie groups %v, want [3]", tieGroups)
|
|
}
|
|
}
|
|
|
|
func TestRankSum_TiedSamplesUseTheNormalApproximation(t *testing.T) {
|
|
result := rankSum([]float64{1, 2, 3, 4}, []float64{3, 4, 5, 6})
|
|
if result.Exact {
|
|
t.Error("used the exact null distribution despite ties")
|
|
}
|
|
if math.IsNaN(result.PValue) || result.PValue < 0 || result.PValue > 1 {
|
|
t.Errorf("p-value %v", result.PValue)
|
|
}
|
|
}
|
|
|
|
// Every observation identical carries no information, and the test must say so
|
|
// rather than dividing by a zero variance.
|
|
func TestRankSum_AllValuesIdentical(t *testing.T) {
|
|
result := rankSum([]float64{40, 40, 40}, []float64{40, 40, 40, 40})
|
|
if result.PValue != 1 {
|
|
t.Errorf("p-value %v, want 1", result.PValue)
|
|
}
|
|
if result.A12 != 0.5 {
|
|
t.Errorf("A12 %v, want 0.5", result.A12)
|
|
}
|
|
}
|
|
|
|
func TestRankSum_SingleObservationPerSample(t *testing.T) {
|
|
result := rankSum([]float64{3}, []float64{9})
|
|
if result.Statistic != 0 {
|
|
t.Errorf("statistic %v, want 0", result.Statistic)
|
|
}
|
|
if result.A12 != 0 {
|
|
t.Errorf("A12 %v, want 0", result.A12)
|
|
}
|
|
if math.IsNaN(result.PValue) || result.PValue > 1 {
|
|
t.Errorf("p-value %v", result.PValue)
|
|
}
|
|
}
|
|
|
|
func TestRankSum_EmptySampleHasNoStatistic(t *testing.T) {
|
|
result := rankSum(nil, []float64{1, 2, 3})
|
|
if !math.IsNaN(result.PValue) || !math.IsNaN(result.A12) {
|
|
t.Errorf("result %+v, want everything undefined", result)
|
|
}
|
|
}
|
|
|
|
// The exact null distribution must be a proper distribution: the counts sum to
|
|
// the binomial coefficient and the distribution is symmetric about mn/2.
|
|
func TestExactRankSumCounts_FormAProperSymmetricDistribution(t *testing.T) {
|
|
counts := exactRankSumCounts(4, 6)
|
|
total := 0.0
|
|
for _, count := range counts {
|
|
total += count
|
|
}
|
|
if total != 210 {
|
|
t.Errorf("counts sum to %v, want C(10,4) = 210", total)
|
|
}
|
|
for index := range counts {
|
|
mirrored := counts[len(counts)-1-index]
|
|
if counts[index] != mirrored {
|
|
t.Errorf("count at %d is %v but %v at the mirrored point", index, counts[index], mirrored)
|
|
}
|
|
}
|
|
}
|