Files

215 lines
7.1 KiB
Go
Raw Permalink Normal View History

2026-09-03 10:00:00 +02:00
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package stats
import (
"math"
"sync"
"sourcedock.dev/petrbalvin/tensor/internal/base"
"sourcedock.dev/petrbalvin/tensor/internal/core"
"sourcedock.dev/petrbalvin/tensor/internal/engine"
)
// Histogram2D bins the paired samples (x[i], y[i]) into a
// xBins × yBins count matrix over equal-width bins spanning each
// axis's own data range, mirroring Histogram's conventions: bins are
// closed on the left and the last bin absorbs the maximum. The count
// matrix comes back as a (xBins × yBins) array, the edges as the
// bin boundaries on each axis. Real inputs only, and the bin counts
// are bounded by maxHistBins in total, exactly as Histogram's are.
func Histogram2D(x, y *core.Array, xBins, yBins int) (*core.Array, []float64, []float64, error) {
const name = "Histogram2D"
if x.Len() != y.Len() {
return nil, nil, nil, base.Errf("%s: x and y must share their length, got %d and %d", name, x.Len(), y.Len())
}
if x.Len() == 0 {
return nil, nil, nil, base.Errf("%s: empty samples have no histogram", name)
}
if x.Dtype() == core.Complex || y.Dtype() == core.Complex {
return nil, nil, nil, base.Errf("%s: complex inputs are not supported", name)
}
if xBins < 1 || yBins < 1 {
return nil, nil, nil, base.Errf("%s: needs at least one bin per axis, got %d and %d", name, xBins, yBins)
}
// The feasibility check runs before any allocation: the count matrix
// costs xBins*yBins cells, and refused up front it can neither
// overflow the product nor ask the allocator for an unbounded
// buffer.
if xBins > maxHistBins || yBins > maxHistBins || xBins > maxHistBins/yBins {
return nil, nil, nil, base.Errf("%s: the %d x %d bin request exceeds the %d-bin limit",
name, xBins, yBins, maxHistBins)
}
xEdges, err := edgesFor(x, xBins, name, "x")
if err != nil {
return nil, nil, nil, err
}
yEdges, err := edgesFor(y, yBins, name, "y")
if err != nil {
return nil, nil, nil, err
}
counts := core.New(core.Int, xBins, yBins)
ints := counts.RawInts()
// The paired samples are read once: a dense float64 payload is walked
// in place and any other layout is widened into a private slice, so
// the sweep below reads no accessor and every value is the one
// FloatAt returns.
xVals, yVals := histogramAxis(x), histogramAxis(y)
// Counting a cell is exact integer arithmetic and every sample
// carries exactly one increment, so the sweep splits over disjoint
// slices of the sample and the private counters merge in any order
// into the totals a single pass produces.
bins := xBins * yBins
minPerWorker := max(hist2DSerialSamples, hist2DBinsPerWorker*bins)
// The first non-finite pair is the one the serial sweep would name:
// the chunks report their own first, and the lowest index wins.
bad := -1
var mu sync.Mutex
note := func(i int) {
if i < 0 {
return
}
mu.Lock()
if bad < 0 || i < bad {
bad = i
}
mu.Unlock()
}
engine.ParallelMin(x.Len(), minPerWorker, func(start, end int) {
if start == 0 && end == x.Len() {
// The whole sample runs inline, the worker policy having
// found the split uneconomical: count straight into the
// result.
note(histogram2DCount(xVals, yVals, 0, xEdges, yEdges, xBins, yBins, ints))
return
}
local := make([]int64, bins)
first := histogram2DCount(xVals[start:end], yVals[start:end], start, xEdges, yEdges, xBins, yBins, local)
mu.Lock()
for i, c := range local {
ints[i] += c
}
mu.Unlock()
note(first)
})
if bad >= 0 {
return nil, nil, nil, base.Errf("%s: sample %d (%g, %g) is not finite", name, bad, xVals[bad], yVals[bad])
}
return counts, xEdges, yEdges, nil
}
// hist2DBinsPerWorker is the paired-sample count one counting worker
// must carry per cell before the binning splits across goroutines, and
// hist2DSerialSamples the count it must carry whatever the cell count:
// a worker counts into a private matrix of xBins·yBins cells, so the
// split only pays where the samples drained into it outweigh the cells
// it costs.
const (
hist2DSerialSamples = 1 << 12
hist2DBinsPerWorker = 8
)
// histogramAxis returns the axis's values as a plain slice, walking a
// dense float64 payload in place. Element i sits at payload index i,
// and a rebased view's payload may run past its own count, so the view
// is cut to the visible elements.
func histogramAxis(a *core.Array) []float64 {
if fs := rawFloats(a); fs != nil {
return fs[:a.Len()]
}
vals := make([]float64, a.Len())
for i := range vals {
vals[i] = a.FloatAt(i)
}
return vals
}
// histogram2DCount adds one slice of the paired sample to counts and
// returns the index of the first non-finite pair it met, or -1. The bin
// arithmetic is binOf's own, so a split of the sample changes no count.
func histogram2DCount(xv, yv []float64, base int, xEdges, yEdges []float64, xBins, yBins int, counts []int64) int {
xBase, xStep := xEdges[0], xEdges[1]-xEdges[0]
yBase, yStep := yEdges[0], yEdges[1]-yEdges[0]
for i := range xv {
xi := binOf(xv[i], xBase, xStep, xBins)
yi := binOf(yv[i], yBase, yStep, yBins)
if xi < 0 || yi < 0 {
return base + i
}
counts[xi*yBins+yi]++
}
return -1
}
// edgesFor builds one axis's bin edges over the data's own range.
func edgesFor(a *core.Array, bins int, name, axis string) ([]float64, error) {
lo, hi := math.Inf(1), math.Inf(-1)
// The finiteness scan and the range share one pass over a dense
// float64 payload, the values being the ones FloatAt returns; every
// other layout keeps the accessor scan and the range pass after it.
// The payload walk is bounded by Len: a rebased view's payload may
// run past its own count.
if fs := rawFloats(a); fs != nil {
for i, v := range fs[:a.Len()] {
if math.IsNaN(v) || math.IsInf(v, 0) {
return nil, base.Errf("%s: %s sample %d is not finite (%g)", name, axis, i, v)
}
if v < lo {
lo = v
}
if v > hi {
hi = v
}
}
} else {
for i := range a.Len() {
if v := a.FloatAt(i); math.IsNaN(v) || math.IsInf(v, 0) {
return nil, base.Errf("%s: %s sample %d is not finite (%g)", name, axis, i, v)
}
}
for i := range a.Len() {
v := a.FloatAt(i)
if v < lo {
lo = v
}
if v > hi {
hi = v
}
}
}
if lo == hi {
lo -= 0.5
hi += 0.5
}
width := (hi - lo) / float64(bins)
if math.IsInf(width, 0) || math.IsNaN(width) {
// Both float extremes in one axis span more than the float64
// range: no finite edges exist, and the bin arithmetic would
// clamp every sample into bin 0 over ±Inf edges.
return nil, base.Errf("%s: the %s samples span more than the float64 range (%g to %g)", name, axis, lo, hi)
}
edges := make([]float64, bins+1)
for i := range bins + 1 {
edges[i] = lo + float64(i)*width
}
return edges, nil
}
// binOf locates one sample's bin from the axis's base edge and the step
// between its first two edges, closing the last bin on the right; a
// non-finite sample returns −1.
func binOf(v, base, step float64, bins int) int {
if math.IsNaN(v) || math.IsInf(v, 0) {
return -1
}
i := int((v - base) / step)
if i < 0 {
return 0 // cannot happen over the data's own range, kept guarded
}
if i >= bins {
i = bins - 1
}
return i
}