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:
pj committed 2026-08-12 23:03:03 +05:30
1 parent 71dffef2f2
commit 019d608f65
16 files changed
+2595

No files matched your search

+220
View File
@@ -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
}