mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
Steps to first violation with clean runs right-censored at the budget, since per-run yield is a binary at 11 to 45 percent and separating two arms on it would need roughly 80 runs per arm. Kaplan-Meier, log-rank, Wilcoxon rank-sum with Vargha-Delaney A12, Holm within each family. A hand-rolled log-rank that is subtly wrong is a silent-wrong-number generator and would be believed, so every statistic is validated against a published worked example with the source named in the test: R survdiff on aml, Freireich 6-MP, Hollander and Wolfe 1973 for the rank sum, printed p.adjust output for Holm. Two could not be: the k>2 log-rank, guarded by calibration instead, and the tie-corrected variance, checked against an exact permutation variance. Failed and timed-out runs are excluded as missing data and counted by reason, never treated as censored observations, which would bias the result. Claude-Session: https://claude.ai/code/session_01A5KmftdEJ49A9z5mF5ESrX
221 lines
6.5 KiB
Go
221 lines
6.5 KiB
Go
package main
|
|
|
|
import (
|
|
"math"
|
|
"slices"
|
|
)
|
|
|
|
type rankSumResult struct {
|
|
FirstSize int `json:"first_size"`
|
|
SecondSize int `json:"second_size"`
|
|
Statistic float64 `json:"mann_whitney_u"`
|
|
A12 float64 `json:"a12"`
|
|
PValue float64 `json:"p_value"`
|
|
Exact bool `json:"exact"`
|
|
}
|
|
|
|
// exactRankSumLimit matches R's wilcox.test: the exact null distribution is
|
|
// used only when both samples are below this size and nothing is tied.
|
|
const exactRankSumLimit = 50
|
|
|
|
// vargaDelaneyA12 is the probability that a value drawn from first exceeds one
|
|
// drawn from second, counting a tie as half:
|
|
//
|
|
// A = P(X > Y) + 0.5 * P(X = Y)
|
|
//
|
|
// Vargha and Delaney (2000), "A Critique and Improvement of the CL Common
|
|
// Language Effect Size Statistics of McGraw and Wong", Journal of Educational
|
|
// and Behavioral Statistics 25(2), 101-132.
|
|
func vargaDelaneyA12(first, second []float64) float64 {
|
|
if len(first) == 0 || len(second) == 0 {
|
|
return math.NaN()
|
|
}
|
|
total := 0.0
|
|
for _, left := range first {
|
|
for _, right := range second {
|
|
switch {
|
|
case left > right:
|
|
total++
|
|
case left == right:
|
|
total += 0.5
|
|
}
|
|
}
|
|
}
|
|
return total / float64(len(first)*len(second))
|
|
}
|
|
|
|
// rankSum is the two-sided Wilcoxon rank-sum (Mann-Whitney) test. The reported
|
|
// statistic is U for the first sample, the same quantity R's wilcox.test calls
|
|
// W. The exact null distribution is used when there are no ties and both
|
|
// samples are small; otherwise the normal approximation is used with the
|
|
// continuity correction and the tie correction to the variance.
|
|
func rankSum(first, second []float64) rankSumResult {
|
|
firstSize, secondSize := len(first), len(second)
|
|
result := rankSumResult{
|
|
FirstSize: firstSize,
|
|
SecondSize: secondSize,
|
|
Statistic: math.NaN(),
|
|
A12: math.NaN(),
|
|
PValue: math.NaN(),
|
|
}
|
|
if firstSize == 0 || secondSize == 0 {
|
|
return result
|
|
}
|
|
pooled := make([]float64, 0, firstSize+secondSize)
|
|
pooled = append(pooled, first...)
|
|
pooled = append(pooled, second...)
|
|
ranks, tieGroups := midRanks(pooled)
|
|
|
|
rankTotal := 0.0
|
|
for index := 0; index < firstSize; index++ {
|
|
rankTotal += ranks[index]
|
|
}
|
|
statistic := rankTotal - float64(firstSize)*float64(firstSize+1)/2
|
|
result.Statistic = statistic
|
|
result.A12 = vargaDelaneyA12(first, second)
|
|
|
|
if len(tieGroups) == 0 && firstSize < exactRankSumLimit && secondSize < exactRankSumLimit {
|
|
result.Exact = true
|
|
result.PValue = exactRankSumTwoSided(statistic, firstSize, secondSize)
|
|
return result
|
|
}
|
|
result.PValue = normalRankSumTwoSided(statistic, firstSize, secondSize, tieGroups)
|
|
return result
|
|
}
|
|
|
|
// normalRankSumTwoSided follows the large-sample branch of R's wilcox.test:
|
|
//
|
|
// sigma^2 = (m*n/12) * ((N+1) - sum(t^3 - t) / (N*(N-1)))
|
|
//
|
|
// where t runs over the sizes of the tied groups. The 0.5 shift toward the null
|
|
// mean is the continuity correction.
|
|
func normalRankSumTwoSided(statistic float64, firstSize, secondSize int, tieGroups []int) float64 {
|
|
sizeProduct := float64(firstSize) * float64(secondSize)
|
|
variance := rankSumVariance(firstSize, secondSize, tieGroups)
|
|
if variance <= 0 {
|
|
return 1
|
|
}
|
|
centered := statistic - sizeProduct/2
|
|
correction := 0.0
|
|
switch {
|
|
case centered > 0:
|
|
correction = 0.5
|
|
case centered < 0:
|
|
correction = -0.5
|
|
}
|
|
z := (centered - correction) / math.Sqrt(variance)
|
|
tail := math.Min(standardNormalUpperTail(z), standardNormalUpperTail(-z))
|
|
return math.Min(2*tail, 1)
|
|
}
|
|
|
|
func rankSumVariance(firstSize, secondSize int, tieGroups []int) float64 {
|
|
sizeProduct := float64(firstSize) * float64(secondSize)
|
|
total := float64(firstSize + secondSize)
|
|
tieAdjustment := 0.0
|
|
for _, size := range tieGroups {
|
|
count := float64(size)
|
|
tieAdjustment += count*count*count - count
|
|
}
|
|
return (sizeProduct / 12) * ((total + 1) - tieAdjustment/(total*(total-1)))
|
|
}
|
|
|
|
// exactRankSumTwoSided doubles the smaller exact tail, as R's wilcox.test does.
|
|
func exactRankSumTwoSided(statistic float64, firstSize, secondSize int) float64 {
|
|
counts := exactRankSumCounts(firstSize, secondSize)
|
|
total := 0.0
|
|
for _, count := range counts {
|
|
total += count
|
|
}
|
|
value := int(math.Round(statistic))
|
|
tail := 0.0
|
|
if statistic > float64(firstSize*secondSize)/2 {
|
|
for u := value; u < len(counts); u++ {
|
|
tail += counts[u]
|
|
}
|
|
} else {
|
|
for u := 0; u <= value && u < len(counts); u++ {
|
|
tail += counts[u]
|
|
}
|
|
}
|
|
return math.Min(2*tail/total, 1)
|
|
}
|
|
|
|
// exactRankSumUpperTail is P(U >= statistic) under the null with no ties.
|
|
func exactRankSumUpperTail(statistic float64, firstSize, secondSize int) float64 {
|
|
counts := exactRankSumCounts(firstSize, secondSize)
|
|
total, tail := 0.0, 0.0
|
|
for u, count := range counts {
|
|
total += count
|
|
if float64(u) >= statistic {
|
|
tail += count
|
|
}
|
|
}
|
|
return tail / total
|
|
}
|
|
|
|
// exactRankSumCounts returns the number of untied assignments producing each
|
|
// value of U from 0 to firstSize*secondSize. U equals the sum of the zero-based
|
|
// pooled positions held by the first sample, less firstSize*(firstSize-1)/2, so
|
|
// the count is a subset-sum tally over those positions.
|
|
func exactRankSumCounts(firstSize, secondSize int) []float64 {
|
|
maximum := firstSize * secondSize
|
|
offset := firstSize * (firstSize - 1) / 2
|
|
high := maximum + offset
|
|
table := make([][]float64, firstSize+1)
|
|
for index := range table {
|
|
table[index] = make([]float64, high+1)
|
|
}
|
|
table[0][0] = 1
|
|
for position := 0; position < firstSize+secondSize; position++ {
|
|
for chosen := min(position+1, firstSize); chosen >= 1; chosen-- {
|
|
row, previous := table[chosen], table[chosen-1]
|
|
for sum := high; sum >= position; sum-- {
|
|
if previous[sum-position] != 0 {
|
|
row[sum] += previous[sum-position]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
counts := make([]float64, maximum+1)
|
|
for u := range counts {
|
|
counts[u] = table[firstSize][u+offset]
|
|
}
|
|
return counts
|
|
}
|
|
|
|
// midRanks ranks values from 1, averaging the ranks within a tied group, and
|
|
// also returns the size of every group of size two or more.
|
|
func midRanks(values []float64) ([]float64, []int) {
|
|
order := make([]int, len(values))
|
|
for index := range order {
|
|
order[index] = index
|
|
}
|
|
slices.SortStableFunc(order, func(left, right int) int {
|
|
switch {
|
|
case values[left] < values[right]:
|
|
return -1
|
|
case values[left] > values[right]:
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
})
|
|
ranks := make([]float64, len(values))
|
|
var tieGroups []int
|
|
for start := 0; start < len(order); {
|
|
end := start + 1
|
|
for end < len(order) && values[order[end]] == values[order[start]] {
|
|
end++
|
|
}
|
|
shared := float64(start+1+end) / 2
|
|
for index := start; index < end; index++ {
|
|
ranks[order[index]] = shared
|
|
}
|
|
if end-start > 1 {
|
|
tieGroups = append(tieGroups, end-start)
|
|
}
|
|
start = end
|
|
}
|
|
return ranks, tieGroups
|
|
}
|