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.
236 lines
6.7 KiB
Go
236 lines
6.7 KiB
Go
package main
|
|
|
|
import (
|
|
"math"
|
|
"slices"
|
|
)
|
|
|
|
// observation is one run reduced to what the survival analysis needs: the step
|
|
// count at which it left the risk set, and whether it left because a violation
|
|
// was found (an event) or because the run ended without one (right-censored). A run that failed or timed out is neither and never
|
|
// reaches this type.
|
|
type observation struct {
|
|
Steps float64
|
|
Event bool
|
|
}
|
|
|
|
type survivalPoint struct {
|
|
Steps float64 `json:"steps"`
|
|
AtRisk int `json:"at_risk"`
|
|
Events int `json:"events"`
|
|
Censored int `json:"censored"`
|
|
Survival float64 `json:"survival"`
|
|
}
|
|
|
|
// kaplanMeier is the product-limit estimate of Kaplan and Meier (1958), one row
|
|
// per distinct observed step count. Runs censored at a step count tied with an
|
|
// event are counted in the risk set for that event, which is the standard
|
|
// convention.
|
|
func kaplanMeier(observations []observation) []survivalPoint {
|
|
if len(observations) == 0 {
|
|
return nil
|
|
}
|
|
remaining := len(observations)
|
|
survival := 1.0
|
|
var curve []survivalPoint
|
|
for _, steps := range distinctSteps(observations) {
|
|
events, censored := 0, 0
|
|
for _, item := range observations {
|
|
if item.Steps != steps {
|
|
continue
|
|
}
|
|
if item.Event {
|
|
events++
|
|
} else {
|
|
censored++
|
|
}
|
|
}
|
|
atRisk := remaining
|
|
if events > 0 {
|
|
survival *= 1 - float64(events)/float64(atRisk)
|
|
}
|
|
curve = append(curve, survivalPoint{
|
|
Steps: steps,
|
|
AtRisk: atRisk,
|
|
Events: events,
|
|
Censored: censored,
|
|
Survival: survival,
|
|
})
|
|
remaining -= events + censored
|
|
}
|
|
return curve
|
|
}
|
|
|
|
// medianSurvival is the smallest step count at which the estimate falls to or
|
|
// below one half. It is undefined whenever fewer than half the runs violate,
|
|
// and the second return value says so: substituting a mean there would report a
|
|
// number the data does not contain.
|
|
func medianSurvival(curve []survivalPoint) (float64, bool) {
|
|
return quantileSurvival(curve, 0.5)
|
|
}
|
|
|
|
func distinctSteps(observations []observation) []float64 {
|
|
steps := make([]float64, 0, len(observations))
|
|
for _, item := range observations {
|
|
steps = append(steps, item.Steps)
|
|
}
|
|
slices.Sort(steps)
|
|
return slices.Compact(steps)
|
|
}
|
|
|
|
type logRankResult struct {
|
|
Groups []string `json:"groups"`
|
|
Sizes []int `json:"sizes"`
|
|
Observed []float64 `json:"observed"`
|
|
Expected []float64 `json:"expected"`
|
|
ChiSquare float64 `json:"chi_square"`
|
|
DegreesOfFreedom int `json:"degrees_of_freedom"`
|
|
PValue float64 `json:"p_value"`
|
|
}
|
|
|
|
// logRank is the k-sample Mantel-Haenszel log-rank test. At every distinct
|
|
// event time it contrasts observed with expected events under the null of equal
|
|
// hazards, then combines the k-1 independent differences through their
|
|
// covariance matrix: chi-square = U' V^-1 U on k-1 degrees of freedom.
|
|
// Mantel (1966); Peto and Peto (1972); Klein and Moeschberger, Survival
|
|
// Analysis, 2nd ed., section 7.3.
|
|
func logRank(names []string, groups [][]observation) logRankResult {
|
|
var keptNames []string
|
|
var kept [][]observation
|
|
for index, group := range groups {
|
|
if len(group) == 0 {
|
|
continue
|
|
}
|
|
keptNames = append(keptNames, names[index])
|
|
kept = append(kept, group)
|
|
}
|
|
names, groups = keptNames, kept
|
|
|
|
count := len(groups)
|
|
result := logRankResult{
|
|
Groups: names,
|
|
Sizes: make([]int, count),
|
|
Observed: make([]float64, count),
|
|
Expected: make([]float64, count),
|
|
DegreesOfFreedom: count - 1,
|
|
PValue: math.NaN(),
|
|
}
|
|
if count < 2 {
|
|
return result
|
|
}
|
|
var pooled []observation
|
|
for index, group := range groups {
|
|
result.Sizes[index] = len(group)
|
|
pooled = append(pooled, group...)
|
|
}
|
|
|
|
covariance := make([][]float64, count)
|
|
for index := range covariance {
|
|
covariance[index] = make([]float64, count)
|
|
}
|
|
atRisk := make([]float64, count)
|
|
deaths := make([]float64, count)
|
|
|
|
for _, steps := range distinctSteps(pooled) {
|
|
totalAtRisk, totalDeaths := 0.0, 0.0
|
|
for index, group := range groups {
|
|
atRisk[index], deaths[index] = 0, 0
|
|
for _, item := range group {
|
|
if item.Steps >= steps {
|
|
atRisk[index]++
|
|
}
|
|
if item.Steps == steps && item.Event {
|
|
deaths[index]++
|
|
}
|
|
}
|
|
totalAtRisk += atRisk[index]
|
|
totalDeaths += deaths[index]
|
|
}
|
|
if totalDeaths == 0 {
|
|
continue
|
|
}
|
|
for index := range groups {
|
|
result.Observed[index] += deaths[index]
|
|
result.Expected[index] += totalDeaths * atRisk[index] / totalAtRisk
|
|
}
|
|
if totalAtRisk <= 1 {
|
|
continue
|
|
}
|
|
scale := totalDeaths * (totalAtRisk - totalDeaths) / (totalAtRisk - 1)
|
|
for row := range groups {
|
|
share := atRisk[row] / totalAtRisk
|
|
covariance[row][row] += scale * share * (1 - share)
|
|
for column := range groups {
|
|
if column == row {
|
|
continue
|
|
}
|
|
covariance[row][column] -= scale * share * atRisk[column] / totalAtRisk
|
|
}
|
|
}
|
|
}
|
|
|
|
reduced := make([][]float64, count-1)
|
|
difference := make([]float64, count-1)
|
|
for row := 0; row < count-1; row++ {
|
|
reduced[row] = make([]float64, count-1)
|
|
copy(reduced[row], covariance[row][:count-1])
|
|
difference[row] = result.Observed[row] - result.Expected[row]
|
|
}
|
|
solution, ok := solveLinearSystem(reduced, difference)
|
|
if !ok {
|
|
result.ChiSquare = 0
|
|
result.PValue = 1
|
|
return result
|
|
}
|
|
statistic := 0.0
|
|
for index := range difference {
|
|
statistic += difference[index] * solution[index]
|
|
}
|
|
if statistic < 0 || math.IsNaN(statistic) {
|
|
statistic = 0
|
|
}
|
|
result.ChiSquare = statistic
|
|
result.PValue = chiSquareUpperTail(statistic, result.DegreesOfFreedom)
|
|
return result
|
|
}
|
|
|
|
// solveLinearSystem solves matrix*x = vector by Gaussian elimination with
|
|
// partial pivoting, reporting failure rather than a value when the matrix is
|
|
// singular, which is what a group with no events at all produces.
|
|
func solveLinearSystem(matrix [][]float64, vector []float64) ([]float64, bool) {
|
|
size := len(vector)
|
|
work := make([][]float64, size)
|
|
for row := range work {
|
|
work[row] = make([]float64, size+1)
|
|
copy(work[row], matrix[row])
|
|
work[row][size] = vector[row]
|
|
}
|
|
for column := 0; column < size; column++ {
|
|
pivot := column
|
|
for row := column + 1; row < size; row++ {
|
|
if math.Abs(work[row][column]) > math.Abs(work[pivot][column]) {
|
|
pivot = row
|
|
}
|
|
}
|
|
if math.Abs(work[pivot][column]) < 1e-12 {
|
|
return nil, false
|
|
}
|
|
work[column], work[pivot] = work[pivot], work[column]
|
|
for row := column + 1; row < size; row++ {
|
|
factor := work[row][column] / work[column][column]
|
|
for next := column; next <= size; next++ {
|
|
work[row][next] -= factor * work[column][next]
|
|
}
|
|
}
|
|
}
|
|
solution := make([]float64, size)
|
|
for row := size - 1; row >= 0; row-- {
|
|
total := work[row][size]
|
|
for column := row + 1; column < size; column++ {
|
|
total -= work[row][column] * solution[column]
|
|
}
|
|
solution[row] = total / work[row][row]
|
|
}
|
|
return solution, true
|
|
}
|