mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
feat(analyze): survival analysis over campaign directories
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
This commit is contained in:
1 parent
71dffef2f2
commit
019d608f65
16 files changed
+2595
No files matched your search
@@ -0,0 +1,220 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user