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
305 lines
11 KiB
Go
305 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"math"
|
|
"math/rand"
|
|
"slices"
|
|
"strconv"
|
|
"testing"
|
|
)
|
|
|
|
// The product-limit estimates for the 6-MP arm of Freireich et al. (1963) are
|
|
// the worked example reproduced in Collett, Modelling Survival Data in Medical
|
|
// Research, and in the standard course treatments of the gehan data:
|
|
//
|
|
// t: 6 7 10 13 16 22 23
|
|
// S(t): 0.857 0.807 0.753 0.690 0.627 0.538 0.448
|
|
func TestKaplanMeier_MatchesPublishedGehanEstimates(t *testing.T) {
|
|
curve := kaplanMeier(gehanSixMercaptopurine)
|
|
expected := map[float64]float64{
|
|
6: 0.857, 7: 0.807, 10: 0.753, 13: 0.690, 16: 0.627, 22: 0.538, 23: 0.448,
|
|
}
|
|
seen := 0
|
|
for _, point := range curve {
|
|
want, ok := expected[point.Steps]
|
|
if !ok {
|
|
continue
|
|
}
|
|
seen++
|
|
if math.Abs(point.Survival-want) > 5e-4 {
|
|
t.Errorf("S(%v) = %.4f, want %v", point.Steps, point.Survival, want)
|
|
}
|
|
}
|
|
if seen != len(expected) {
|
|
t.Fatalf("matched %d of %d published times", seen, len(expected))
|
|
}
|
|
}
|
|
|
|
// The risk set at each time is the count of runs still under observation, with
|
|
// runs censored at a tied time counted as at risk for that event.
|
|
func TestKaplanMeier_RiskSetHandlesTiesAndCensoring(t *testing.T) {
|
|
curve := kaplanMeier(gehanSixMercaptopurine)
|
|
expected := map[float64]struct {
|
|
atRisk int
|
|
events int
|
|
censored int
|
|
}{
|
|
6: {21, 3, 1},
|
|
7: {17, 1, 0},
|
|
9: {16, 0, 1},
|
|
10: {15, 1, 1},
|
|
13: {12, 1, 0},
|
|
23: {6, 1, 0},
|
|
}
|
|
for _, point := range curve {
|
|
want, ok := expected[point.Steps]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if point.AtRisk != want.atRisk || point.Events != want.events || point.Censored != want.censored {
|
|
t.Errorf("at %v: risk=%d events=%d censored=%d, want risk=%d events=%d censored=%d",
|
|
point.Steps, point.AtRisk, point.Events, point.Censored, want.atRisk, want.events, want.censored)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Published medians: 23 weeks for 6-MP against 8 weeks for placebo (Gehan and
|
|
// Freireich, "The 6-MP versus placebo clinical trial in acute leukemia",
|
|
// Clinical Trials 8(3), 2011), and 31 against 23 weeks for the aml arms as
|
|
// reported by survfit in R's survival package.
|
|
func TestMedianSurvival_MatchesPublishedMedians(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
observations []observation
|
|
expected float64
|
|
}{
|
|
{"gehan 6-MP", gehanSixMercaptopurine, 23},
|
|
{"gehan placebo", gehanPlacebo, 8},
|
|
{"aml maintained", amlMaintained, 31},
|
|
{"aml nonmaintained", amlNonmaintained, 23},
|
|
}
|
|
for _, test := range cases {
|
|
median, ok := medianSurvival(kaplanMeier(test.observations))
|
|
if !ok {
|
|
t.Errorf("%s: median undefined, want %v", test.name, test.expected)
|
|
continue
|
|
}
|
|
if median != test.expected {
|
|
t.Errorf("%s: median %v, want %v", test.name, median, test.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
// R's survival package documents this log-rank on the aml data:
|
|
//
|
|
// N Observed Expected (O-E)^2/E (O-E)^2/V
|
|
// x=Maintained 11 7 10.69 1.27 3.4
|
|
// x=Nonmaintained 12 11 7.31 1.86 3.4
|
|
// Chisq= 3.4 on 1 degrees of freedom, p= 0.0653
|
|
func TestLogRank_MatchesPublishedAmlResult(t *testing.T) {
|
|
result := logRank([]string{"maintained", "nonmaintained"}, [][]observation{amlMaintained, amlNonmaintained})
|
|
|
|
if result.Observed[0] != 7 || result.Observed[1] != 11 {
|
|
t.Errorf("observed %v, want [7 11]", result.Observed)
|
|
}
|
|
if math.Abs(result.Expected[0]-10.69) > 5e-3 || math.Abs(result.Expected[1]-7.31) > 5e-3 {
|
|
t.Errorf("expected %v, want [10.69 7.31]", result.Expected)
|
|
}
|
|
for index, want := range []float64{1.27, 1.86} {
|
|
difference := result.Observed[index] - result.Expected[index]
|
|
got := difference * difference / result.Expected[index]
|
|
if math.Abs(got-want) > 5e-3 {
|
|
t.Errorf("(O-E)^2/E for group %d = %.4f, want %v", index, got, want)
|
|
}
|
|
}
|
|
if math.Abs(result.ChiSquare-3.4) > 5e-2 {
|
|
t.Errorf("chi-square %.4f, want 3.4", result.ChiSquare)
|
|
}
|
|
if math.Abs(result.PValue-0.0653) > 5e-4 {
|
|
t.Errorf("p-value %.6f, want 0.0653", result.PValue)
|
|
}
|
|
if result.DegreesOfFreedom != 1 {
|
|
t.Errorf("degrees of freedom %d, want 1", result.DegreesOfFreedom)
|
|
}
|
|
}
|
|
|
|
// The log-rank on the Freireich 6-MP trial is the textbook worked example:
|
|
// observed 9 against 19.25 expected in the treated arm and 21 against 10.75 in
|
|
// the control arm, Mantel-Haenszel chi-square 16.79 on 1 degree of freedom,
|
|
// p = 4.17e-05. Reported for instance in Rodriguez, Kaplan-Meier and
|
|
// Mantel-Haenszel, https://grodri.github.io/survival/gehan, and in Collett.
|
|
func TestLogRank_MatchesPublishedGehanResult(t *testing.T) {
|
|
result := logRank([]string{"6-MP", "placebo"}, [][]observation{gehanSixMercaptopurine, gehanPlacebo})
|
|
|
|
if result.Observed[0] != 9 || result.Observed[1] != 21 {
|
|
t.Errorf("observed %v, want [9 21]", result.Observed)
|
|
}
|
|
if math.Abs(result.Expected[0]-19.25) > 5e-3 || math.Abs(result.Expected[1]-10.75) > 5e-3 {
|
|
t.Errorf("expected %v, want [19.25 10.75]", result.Expected)
|
|
}
|
|
if math.Abs(result.ChiSquare-16.79) > 5e-3 {
|
|
t.Errorf("chi-square %.4f, want 16.79", result.ChiSquare)
|
|
}
|
|
if math.Abs(result.PValue-4.17e-5) > 5e-8 {
|
|
t.Errorf("p-value %.3e, want 4.17e-05", result.PValue)
|
|
}
|
|
}
|
|
|
|
// An arm with no usable runs contributes nothing and must not consume a degree
|
|
// of freedom or make the covariance matrix singular.
|
|
func TestLogRank_EmptyGroupIsDropped(t *testing.T) {
|
|
two := logRank([]string{"a", "b"}, [][]observation{amlMaintained, amlNonmaintained})
|
|
three := logRank([]string{"a", "b", "c"}, [][]observation{amlMaintained, amlNonmaintained, nil})
|
|
if math.Abs(two.ChiSquare-three.ChiSquare) > 1e-12 {
|
|
t.Errorf("chi-square %.10f with an empty third group, want %.10f", three.ChiSquare, two.ChiSquare)
|
|
}
|
|
if three.DegreesOfFreedom != 1 {
|
|
t.Errorf("degrees of freedom %d, want 1", three.DegreesOfFreedom)
|
|
}
|
|
if len(three.Groups) != 2 {
|
|
t.Errorf("groups %v, want the empty arm dropped", three.Groups)
|
|
}
|
|
}
|
|
|
|
// No published multi-arm dataset with a printed log-rank chi-square was found
|
|
// small enough to embed, so the k-group covariance algebra is checked against
|
|
// its own null distribution instead: under the null of equal hazards the
|
|
// statistic is asymptotically chi-square on k-1 degrees of freedom, so its mean
|
|
// over random relabellings has to sit near k-1. A wrong variance term or a wrong
|
|
// degrees-of-freedom count moves this badly.
|
|
func TestLogRank_NullMeanTracksDegreesOfFreedom(t *testing.T) {
|
|
pooled := make([]observation, 0, 36)
|
|
for index := 0; index < 36; index++ {
|
|
pooled = append(pooled, observation{Steps: float64(index%17 + 1), Event: index%5 != 0})
|
|
}
|
|
for _, groupCount := range []int{2, 3, 4} {
|
|
generator := rand.New(rand.NewSource(20260812))
|
|
total := 0.0
|
|
const replicates = 4000
|
|
for replicate := 0; replicate < replicates; replicate++ {
|
|
shuffled := slices.Clone(pooled)
|
|
generator.Shuffle(len(shuffled), func(left, right int) {
|
|
shuffled[left], shuffled[right] = shuffled[right], shuffled[left]
|
|
})
|
|
names := make([]string, groupCount)
|
|
groups := make([][]observation, groupCount)
|
|
for index, item := range shuffled {
|
|
groups[index%groupCount] = append(groups[index%groupCount], item)
|
|
}
|
|
for index := range names {
|
|
names[index] = strconv.Itoa(index)
|
|
}
|
|
total += logRank(names, groups).ChiSquare
|
|
}
|
|
mean := total / replicates
|
|
expected := float64(groupCount - 1)
|
|
if math.Abs(mean-expected) > 0.15*expected {
|
|
t.Errorf("%d groups: null mean chi-square %.3f, want near %v", groupCount, mean, expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A three-group split of one homogeneous sample must not look significant, and
|
|
// the statistic must be finite on 2 degrees of freedom.
|
|
func TestLogRank_ThreeIdenticalGroupsAreNotSignificant(t *testing.T) {
|
|
group := []observation{{4, true}, {7, true}, {9, false}, {12, true}, {20, false}}
|
|
result := logRank([]string{"a", "b", "c"}, [][]observation{group, group, group})
|
|
if result.DegreesOfFreedom != 2 {
|
|
t.Fatalf("degrees of freedom %d, want 2", result.DegreesOfFreedom)
|
|
}
|
|
if result.ChiSquare > 1e-9 {
|
|
t.Errorf("chi-square %.10f for three identical groups, want 0", result.ChiSquare)
|
|
}
|
|
if math.Abs(result.PValue-1) > 1e-9 {
|
|
t.Errorf("p-value %v, want 1", result.PValue)
|
|
}
|
|
}
|
|
|
|
func TestKaplanMeier_EveryObservationCensored(t *testing.T) {
|
|
observations := []observation{{40, false}, {40, false}, {40, false}}
|
|
curve := kaplanMeier(observations)
|
|
if len(curve) != 1 {
|
|
t.Fatalf("curve has %d points, want 1", len(curve))
|
|
}
|
|
if curve[0].Survival != 1 || curve[0].Events != 0 || curve[0].Censored != 3 {
|
|
t.Errorf("point %+v, want survival 1 with 3 censored", curve[0])
|
|
}
|
|
if _, ok := medianSurvival(curve); ok {
|
|
t.Error("median defined for an arm where nothing violated")
|
|
}
|
|
}
|
|
|
|
func TestKaplanMeier_EveryObservationAnEvent(t *testing.T) {
|
|
curve := kaplanMeier([]observation{{2, true}, {4, true}, {6, true}, {8, true}})
|
|
last := curve[len(curve)-1]
|
|
if last.Survival != 0 {
|
|
t.Errorf("final survival %v, want 0", last.Survival)
|
|
}
|
|
median, ok := medianSurvival(curve)
|
|
if !ok || median != 4 {
|
|
t.Errorf("median %v ok=%v, want 4", median, ok)
|
|
}
|
|
}
|
|
|
|
func TestKaplanMeier_TiedEventTimesDropOnce(t *testing.T) {
|
|
curve := kaplanMeier([]observation{{5, true}, {5, true}, {5, true}, {9, true}})
|
|
if len(curve) != 2 {
|
|
t.Fatalf("curve has %d points, want 2", len(curve))
|
|
}
|
|
if curve[0].Events != 3 || math.Abs(curve[0].Survival-0.25) > 1e-12 {
|
|
t.Errorf("first point %+v, want 3 events and survival 0.25", curve[0])
|
|
}
|
|
if curve[1].Survival != 0 {
|
|
t.Errorf("second point %+v, want survival 0", curve[1])
|
|
}
|
|
}
|
|
|
|
func TestKaplanMeier_SingleObservation(t *testing.T) {
|
|
event := kaplanMeier([]observation{{11, true}})
|
|
if len(event) != 1 || event[0].Survival != 0 {
|
|
t.Fatalf("single event curve %+v", event)
|
|
}
|
|
median, ok := medianSurvival(event)
|
|
if !ok || median != 11 {
|
|
t.Errorf("median %v ok=%v, want 11", median, ok)
|
|
}
|
|
censored := kaplanMeier([]observation{{11, false}})
|
|
if len(censored) != 1 || censored[0].Survival != 1 {
|
|
t.Fatalf("single censored curve %+v", censored)
|
|
}
|
|
if _, ok := medianSurvival(censored); ok {
|
|
t.Error("median defined for a single censored observation")
|
|
}
|
|
}
|
|
|
|
func TestKaplanMeier_NoObservations(t *testing.T) {
|
|
if curve := kaplanMeier(nil); curve != nil {
|
|
t.Errorf("curve %v for no observations, want nil", curve)
|
|
}
|
|
if _, ok := medianSurvival(nil); ok {
|
|
t.Error("median defined for an empty curve")
|
|
}
|
|
}
|
|
|
|
func TestLogRank_SingleObservationPerGroup(t *testing.T) {
|
|
result := logRank([]string{"a", "b"}, [][]observation{{{3, true}}, {{9, true}}})
|
|
if math.IsNaN(result.ChiSquare) || result.ChiSquare < 0 {
|
|
t.Errorf("chi-square %v", result.ChiSquare)
|
|
}
|
|
if math.IsNaN(result.PValue) || result.PValue > 1 || result.PValue < 0 {
|
|
t.Errorf("p-value %v", result.PValue)
|
|
}
|
|
}
|
|
|
|
// With no events anywhere the covariance matrix is singular and there is
|
|
// nothing to test, which must report no difference rather than a divide by zero.
|
|
func TestLogRank_NoEventsAnywhere(t *testing.T) {
|
|
result := logRank([]string{"a", "b"}, [][]observation{
|
|
{{40, false}, {40, false}},
|
|
{{40, false}, {40, false}, {40, false}},
|
|
})
|
|
if result.ChiSquare != 0 || result.PValue != 1 {
|
|
t.Errorf("chi-square %v p-value %v, want 0 and 1", result.ChiSquare, result.PValue)
|
|
}
|
|
}
|