163 lines
5.3 KiB
Go
163 lines
5.3 KiB
Go
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)
|
|
}
|