Tooling
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
package stats
|
||||
|
||||
import "math"
|
||||
|
||||
// A fixed-bin histogram, which is what lets a planet be judged at all.
|
||||
//
|
||||
// Every statistic in this package used to be a sort: `ComputeHypsometry` copies every land cell into a
|
||||
// `[]float64` and sorts it, `ComputeSlopes` does the same with slopes, and `UpliftBuckets` does it three
|
||||
// times per bucket. On the square canvas that is a few megabytes and nobody noticed. On a planet it is
|
||||
// **28 million land cells**, so the copies alone are several gigabytes before a single number comes out, and
|
||||
// that is why a planet bake has never printed anything but its elevation range - the block that matters most
|
||||
// was the block that could not be afforded.
|
||||
//
|
||||
// A histogram replaces all of it. One pass, no allocation per cell, a quantile out of a running sum, and the
|
||||
// error is bounded by the bin width rather than by anything to do with the data.
|
||||
//
|
||||
// **And it pools, which is the property that actually matters here.** The geology is solved one landmass at a
|
||||
// time (D-53), so a planet-wide statistic has to be assembled from per-region pieces - and a histogram is
|
||||
// *additive*: summing two regions' bins and taking the quantile of the sum gives exactly the number a single
|
||||
// pass over both would have given. A median of medians would not; a mean of means weighted by area would be
|
||||
// right for a mean and wrong for everything else. This is the one structure that makes "statistics pool
|
||||
// across regions rather than being computed per region and averaged" true rather than aspirational.
|
||||
type Histogram struct {
|
||||
Lo, Hi float64 `json:"-"`
|
||||
Bins []int64 `json:"-"`
|
||||
|
||||
// Count, Sum, Min and Max are exact rather than binned. The mean and the extremes cost nothing to carry
|
||||
// and they are the numbers a bin width would spoil - the hypsometric integral is a mean, and reading it
|
||||
// off bin centres would make it a property of the bin count.
|
||||
Count int64 `json:"count"`
|
||||
Sum float64 `json:"sum"`
|
||||
MinV float64 `json:"min"`
|
||||
MaxV float64 `json:"max"`
|
||||
Under int64 `json:"under"` // values below Lo
|
||||
Over int64 `json:"over"` // values at or above Hi
|
||||
}
|
||||
|
||||
// NewHistogram covers lo..hi in n bins. Values outside are counted rather than clamped: a quantile that
|
||||
// silently piled everything on the end bin would be a quantile that lied about a field whose range had
|
||||
// been set wrong.
|
||||
func NewHistogram(lo, hi float64, n int) *Histogram {
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
if hi <= lo {
|
||||
hi = lo + 1
|
||||
}
|
||||
return &Histogram{Lo: lo, Hi: hi, Bins: make([]int64, n),
|
||||
MinV: math.Inf(1), MaxV: math.Inf(-1)}
|
||||
}
|
||||
|
||||
// Add records one value.
|
||||
func (h *Histogram) Add(v float64) {
|
||||
h.Count++
|
||||
h.Sum += v
|
||||
if v < h.MinV {
|
||||
h.MinV = v
|
||||
}
|
||||
if v > h.MaxV {
|
||||
h.MaxV = v
|
||||
}
|
||||
b := int((v - h.Lo) / (h.Hi - h.Lo) * float64(len(h.Bins)))
|
||||
switch {
|
||||
case b < 0:
|
||||
h.Under++
|
||||
case b >= len(h.Bins):
|
||||
h.Over++
|
||||
default:
|
||||
h.Bins[b]++
|
||||
}
|
||||
}
|
||||
|
||||
// Merge folds another histogram of the same shape into this one. Two histograms with different bounds cannot
|
||||
// be merged and the caller is the one place that knows it, so this refuses silently rather than inventing an
|
||||
// answer: every merge in this package is between histograms built by the same constructor.
|
||||
func (h *Histogram) Merge(o *Histogram) {
|
||||
if o == nil || o.Count == 0 || len(o.Bins) != len(h.Bins) || o.Lo != h.Lo || o.Hi != h.Hi {
|
||||
return
|
||||
}
|
||||
for i, n := range o.Bins {
|
||||
h.Bins[i] += n
|
||||
}
|
||||
h.Count += o.Count
|
||||
h.Sum += o.Sum
|
||||
h.Under += o.Under
|
||||
h.Over += o.Over
|
||||
if o.MinV < h.MinV {
|
||||
h.MinV = o.MinV
|
||||
}
|
||||
if o.MaxV > h.MaxV {
|
||||
h.MaxV = o.MaxV
|
||||
}
|
||||
}
|
||||
|
||||
// Mean is exact, not binned.
|
||||
func (h *Histogram) Mean() float64 {
|
||||
if h.Count == 0 {
|
||||
return 0
|
||||
}
|
||||
return h.Sum / float64(h.Count)
|
||||
}
|
||||
|
||||
// Quantile is the value below which p of the distribution sits, interpolated within the bin it lands in.
|
||||
//
|
||||
// The out-of-range counts are part of the walk rather than ignored: a quantile that fell among values below
|
||||
// Lo returns Lo, which is honest, where skipping them would shift every quantile above by however many there
|
||||
// were.
|
||||
func (h *Histogram) Quantile(p float64) float64 {
|
||||
if h.Count == 0 {
|
||||
return 0
|
||||
}
|
||||
if p <= 0 {
|
||||
return h.MinV
|
||||
}
|
||||
if p >= 1 {
|
||||
return h.MaxV
|
||||
}
|
||||
want := p * float64(h.Count)
|
||||
run := float64(h.Under)
|
||||
if run >= want {
|
||||
return h.Lo
|
||||
}
|
||||
width := (h.Hi - h.Lo) / float64(len(h.Bins))
|
||||
for i, n := range h.Bins {
|
||||
if run+float64(n) >= want {
|
||||
frac := 0.0
|
||||
if n > 0 {
|
||||
frac = (want - run) / float64(n)
|
||||
}
|
||||
return h.Lo + (float64(i)+frac)*width
|
||||
}
|
||||
run += float64(n)
|
||||
}
|
||||
return h.Hi
|
||||
}
|
||||
|
||||
// FracBelow is the share of the distribution strictly below x, which is what every "how much of the land is
|
||||
// under fifteen degrees" question is asking.
|
||||
func (h *Histogram) FracBelow(x float64) float64 {
|
||||
if h.Count == 0 {
|
||||
return 0
|
||||
}
|
||||
if x <= h.Lo {
|
||||
return float64(h.Under) / float64(h.Count)
|
||||
}
|
||||
if x >= h.Hi {
|
||||
return float64(h.Count-h.Over) / float64(h.Count)
|
||||
}
|
||||
width := (h.Hi - h.Lo) / float64(len(h.Bins))
|
||||
full := int((x - h.Lo) / width)
|
||||
run := h.Under
|
||||
for i := 0; i < full && i < len(h.Bins); i++ {
|
||||
run += h.Bins[i]
|
||||
}
|
||||
// The part-bin, spread evenly across its own width. Without it every threshold would snap to a bin edge,
|
||||
// which at a bin width of a twentieth of a degree does not matter and at a coarse one would.
|
||||
if full < len(h.Bins) {
|
||||
frac := (x - h.Lo - float64(full)*width) / width
|
||||
run += int64(float64(h.Bins[full]) * frac)
|
||||
}
|
||||
return float64(run) / float64(h.Count)
|
||||
}
|
||||
Reference in New Issue
Block a user