Files
sanderling/cmd/internal-tools/analyze/survival.go
T
pj b99da0be0e feat(analyze): time an event at the step it was detected and report the quartiles
an obligation that never discharges is reported when the run ends, and timing it by the step that armed it recorded a liveness failure flushed at the budget as a violation found on step 1. the survival analysis now measures the detected step, falling back to the origin for campaigns written before the field existed, and says how many events that moved. the report gains the first and third quartiles beside the median.
2026-08-16 17:45:20 +05:30

237 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 it exhausted the step budget 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
}