Files
tensor/stats/histogram2d.go
petrbalvin af4ee19703
Release / gates (push) Successful in 4m38s
Test / test (push) Successful in 5m16s
Release / release (push) Successful in 35s
feat: initial release
Assisted-by: GLM 5.3 Flash
2026-09-03 10:00:00 +02:00

215 lines
7.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
}