137 lines
4.5 KiB
Go
137 lines
4.5 KiB
Go
package stats
|
|
|
|
import (
|
|
"math"
|
|
"math/rand/v2"
|
|
"sort"
|
|
"testing"
|
|
)
|
|
|
|
// A histogram has to answer the same questions a sort did, closely enough that no verdict changes. The bound
|
|
// is the bin width, so the test is against a real sort of the same data.
|
|
func TestHistogramMatchesASort(t *testing.T) {
|
|
r := rand.New(rand.NewPCG(7, 11))
|
|
vals := make([]float64, 200000)
|
|
h := NewHistogram(0, 90, 2048)
|
|
for i := range vals {
|
|
// A slope-like distribution: mostly gentle, with a tail.
|
|
v := math.Abs(r.NormFloat64()) * 7
|
|
if v > 89.9 {
|
|
v = 89.9
|
|
}
|
|
vals[i] = v
|
|
h.Add(v)
|
|
}
|
|
sort.Float64s(vals)
|
|
q := func(p float64) float64 { return vals[int(p*float64(len(vals)-1))] }
|
|
|
|
width := 90.0 / 2048
|
|
for _, p := range []float64{0.1, 0.25, 0.5, 0.75, 0.9, 0.99} {
|
|
got, want := h.Quantile(p), q(p)
|
|
if math.Abs(got-want) > width {
|
|
t.Errorf("quantile %.2f: histogram %.4f, sort %.4f, wider than one %.4f bin", p, got, want, width)
|
|
}
|
|
}
|
|
for _, x := range []float64{1, 5, 15, 30, 50} {
|
|
want := float64(sort.SearchFloat64s(vals, x)) / float64(len(vals))
|
|
if got := h.FracBelow(x); math.Abs(got-want) > 0.002 {
|
|
t.Errorf("fraction below %.0f: histogram %.4f, sort %.4f", x, got, want)
|
|
}
|
|
}
|
|
// The mean and the extremes are carried exactly, not read off bins.
|
|
var sum float64
|
|
for _, v := range vals {
|
|
sum += v
|
|
}
|
|
if math.Abs(h.Mean()-sum/float64(len(vals))) > 1e-9 {
|
|
t.Errorf("mean %v against %v", h.Mean(), sum/float64(len(vals)))
|
|
}
|
|
if h.MinV != vals[0] || h.MaxV != vals[len(vals)-1] {
|
|
t.Errorf("extremes %v..%v against %v..%v", h.MinV, h.MaxV, vals[0], vals[len(vals)-1])
|
|
}
|
|
}
|
|
|
|
// The property the whole per-region design rests on: summing two regions' bins and taking the quantile of the
|
|
// sum is exactly the quantile of the two together. A median of medians would not be, which is why the
|
|
// histogram is here and not a smaller summary.
|
|
func TestMergingIsExactlyPooling(t *testing.T) {
|
|
r := rand.New(rand.NewPCG(3, 5))
|
|
a, b, both := NewHistogram(0, 90, 512), NewHistogram(0, 90, 512), NewHistogram(0, 90, 512)
|
|
for i := 0; i < 30000; i++ {
|
|
v := r.Float64() * 40
|
|
a.Add(v)
|
|
both.Add(v)
|
|
}
|
|
for i := 0; i < 70000; i++ {
|
|
// A different distribution, so a mean of the two would not do.
|
|
v := 50 + r.Float64()*30
|
|
b.Add(v)
|
|
both.Add(v)
|
|
}
|
|
a.Merge(b)
|
|
if a.Count != both.Count {
|
|
t.Fatalf("merged count %d against %d", a.Count, both.Count)
|
|
}
|
|
for i := range a.Bins {
|
|
if a.Bins[i] != both.Bins[i] {
|
|
t.Fatalf("bin %d: merged %d against %d", i, a.Bins[i], both.Bins[i])
|
|
}
|
|
}
|
|
for _, p := range []float64{0.1, 0.5, 0.9} {
|
|
if got, want := a.Quantile(p), both.Quantile(p); got != want {
|
|
t.Errorf("quantile %.1f: merged %v, together %v", p, got, want)
|
|
}
|
|
}
|
|
// The extremes pool exactly; the mean is a float sum and so is associativity-bound, which is a
|
|
// 1e-16 effect and not a property worth asserting to the bit.
|
|
if a.MinV != both.MinV || a.MaxV != both.MaxV {
|
|
t.Errorf("the extremes did not pool: %v..%v against %v..%v", a.MinV, a.MaxV, both.MinV, both.MaxV)
|
|
}
|
|
if rel := math.Abs(a.Mean()-both.Mean()) / both.Mean(); rel > 1e-12 {
|
|
t.Errorf("the mean did not pool: %v against %v", a.Mean(), both.Mean())
|
|
}
|
|
}
|
|
|
|
// Out of range is counted, not clamped: a range set wrong has to be visible rather than piling up on an end
|
|
// bin and quietly moving every quantile.
|
|
func TestOutOfRangeIsCountedRatherThanClamped(t *testing.T) {
|
|
h := NewHistogram(0, 10, 10)
|
|
for _, v := range []float64{-5, -1, 3, 3, 3, 12, 20} {
|
|
h.Add(v)
|
|
}
|
|
if h.Under != 2 || h.Over != 2 {
|
|
t.Fatalf("under %d over %d, want 2 and 2", h.Under, h.Over)
|
|
}
|
|
if h.Count != 7 {
|
|
t.Fatalf("count %d", h.Count)
|
|
}
|
|
if h.MinV != -5 || h.MaxV != 20 {
|
|
t.Errorf("extremes %v..%v", h.MinV, h.MaxV)
|
|
}
|
|
// Three of seven are below 4, plus the two under the bottom: five sevenths.
|
|
if got := h.FracBelow(4); math.Abs(got-5.0/7) > 1e-9 {
|
|
t.Errorf("FracBelow(4) = %v, want %v", got, 5.0/7)
|
|
}
|
|
// An empty histogram answers zero rather than dividing by nothing.
|
|
e := NewHistogram(0, 1, 4)
|
|
if e.Quantile(0.5) != 0 || e.Mean() != 0 || e.FracBelow(0.5) != 0 {
|
|
t.Error("an empty histogram should answer zero everywhere")
|
|
}
|
|
}
|
|
|
|
// Merging refuses a mismatch rather than inventing an answer, because every real merge here is between
|
|
// histograms one constructor made.
|
|
func TestMergeRefusesADifferentShape(t *testing.T) {
|
|
a := NewHistogram(0, 10, 10)
|
|
a.Add(5)
|
|
for _, b := range []*Histogram{NewHistogram(0, 10, 20), NewHistogram(0, 20, 10), nil} {
|
|
if b != nil {
|
|
b.Add(5)
|
|
}
|
|
a.Merge(b)
|
|
}
|
|
if a.Count != 1 {
|
|
t.Errorf("a mismatched merge changed the histogram: count %d", a.Count)
|
|
}
|
|
}
|