Files
tensor/stats/rolling.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

254 lines
8.8 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"
"sourcedock.dev/petrbalvin/tensor/internal/base"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// Windowed (rolling) reductions over a series: each output element
// summarises one window of consecutive samples. The result holds
// n − window + 1 elements, one per full window, aligned so element i
// summarises samples [i, i+window). Series of every real dtype are
// accepted, and the extrema follow the package's NaN rule: a NaN never
// wins a comparison, and a window that holds nothing but NaN answers
// NaN, exactly as core.Min and core.Max do.
func rollingCheck(a *core.Array, window int) ([]float64, int, error) {
const name = "Rolling"
if a.NDim() != 1 {
return nil, 0, base.Errf("%s: the series must be a vector, got shape %s", name, base.ShapeText(a.Shape()))
}
if a.Dtype() == core.Complex {
return nil, 0, base.Errf("%s: complex series have no ordering to reduce", name)
}
n := a.Len()
if window < 1 || window > n {
return nil, 0, base.Errf("%s: the window must lie in [1, %d], got %d", name, n, window)
}
// Promote through FloatAt, never through RawFloats alone: the
// float64 payload is empty for an int or float32 array, and a
// strided view would be read at the wrong stride. A dense float64
// array copies its payload directly, the same elements the
// accessor walk returned. The copy also means the fold below
// costs no per-element bounds check.
src := make([]float64, n)
if fs := rawFloats(a); fs != nil {
copy(src, fs)
} else {
for i := range n {
src[i] = a.FloatAt(i)
}
}
return src, n - window + 1, nil
}
// RollingMean averages each window of the series.
func RollingMean(a *core.Array, window int) (*core.Array, error) {
src, outLen, err := rollingCheck(a, window)
if err != nil {
return nil, err
}
out := core.New(core.Float, outLen)
rollingTotals(src, out.RawFloats(), outLen, window, true)
return out, nil
}
// RollingSum totals each window of the series.
func RollingSum(a *core.Array, window int) (*core.Array, error) {
src, outLen, err := rollingCheck(a, window)
if err != nil {
return nil, err
}
out := core.New(core.Float, outLen)
rollingTotals(src, out.RawFloats(), outLen, window, false)
return out, nil
}
// rollingTotals fills the first outLen entries of vals with each full
// window's total of src: the sum, or the mean for mean.
//
// Past a short window the total moves through the series instead of
// rescanning it: it is carried between positions as a two-float pair,
// so one position costs the entering element plus the negated leaving
// one, whatever the window's length, where the rescan costs a fold
// over the whole window at every position. The pair, not a bare
// running sum, is what keeps that honest: a subtractive update sheds
// the low bits of every add and subtract, and on a long window of
// mixed magnitudes the drift ends up wider than what a per-window
// rescan loses, while the pair carries every bit the format holds.
//
// Short windows stay with the rescan, and that is a measured choice,
// not a concession: the rescan's fold costs a couple of cycles a
// sample while the carried walk pays its compensation chain at every
// position whatever the window, so below the crossover the rescan is
// the faster walk by two to three times, and equally accurate.
//
// A window that answers a non-finite total, through a non-finite
// sample or an overflowed sum, answers exactly what the per-window
// rescan answered: the carried pair turns non-finite with it, the
// window is folded from scratch, and the refolded pair becomes the
// carried state for the windows that follow.
func rollingTotals(src []float64, vals []float64, outLen, window int, mean bool) {
if window < rollingIncrementalWindow {
for i := range outLen {
total := 0.0
for _, v := range src[i : i+window] {
total += v
}
if mean {
total /= float64(window)
}
vals[i] = total
}
return
}
hi, lo := 0.0, 0.0
for _, v := range src[:window] {
hi, lo = rollingAdd(hi, lo, v)
}
total := hi + lo
if !rollingFinite(total) {
for _, v := range src[:window] {
total += v
}
}
if mean {
total /= float64(window)
}
vals[0] = total
for i := 1; i < outLen; i++ {
hi, lo = rollingAdd(hi, lo, src[i+window-1])
hi, lo = rollingAdd(hi, lo, -src[i-1])
total = hi + lo
if !rollingFinite(total) {
// The window answers what the per-window rescan answered,
// and the carried state restarts from the window itself,
// refolded with the same two-float care the walk carries:
// seeding the state from the rescan's rounded total would
// leak that rounding into every window after.
hi, lo, total = 0, 0, 0
for _, v := range src[i : i+window] {
total += v
hi, lo = rollingAdd(hi, lo, v)
}
}
if mean {
total /= float64(window)
}
vals[i] = total
}
}
// rollingIncrementalWindow is the window length the rolling totals
// switch from the per-window rescan to the carried update at. The
// rescan's cost per position grows with the window while the carried
// walk's is flat, and the crossover sits near a window of forty;
// sixty-four is the power of two above it, where the carried walk
// already answers twice as fast.
const rollingIncrementalWindow = 64
// rollingAdd returns the two-float pair for hi + lo + b. Knuth's
// TwoSum catches the rounding error of the wide add, and the
// renormalisation folds the pair back so the low word stays at the
// rounding level of the high one; the pair then represents the carried
// total to double-double precision across an unbounded walk of adds
// and subtracts.
func rollingAdd(hi, lo, b float64) (float64, float64) {
s := hi + b
bb := s - hi
lo += (hi - (s - bb)) + (b - bb)
t := s + lo
return t, lo - (t - s)
}
// rollingFinite reports whether v is a finite number: the carried
// total is trusted only while it stays one.
func rollingFinite(v float64) bool {
return math.Abs(v) <= math.MaxFloat64
}
// RollingMax tracks each window's largest sample. A NaN never wins a
// comparison and an all-NaN window answers NaN, the package's rule.
func RollingMax(a *core.Array, window int) (*core.Array, error) {
src, outLen, err := rollingCheck(a, window)
if err != nil {
return nil, err
}
return rollingExtreme(src, outLen, window, true), nil
}
// RollingMin tracks each window's smallest sample, with the same NaN
// rule as RollingMax.
func RollingMin(a *core.Array, window int) (*core.Array, error) {
src, outLen, err := rollingCheck(a, window)
if err != nil {
return nil, err
}
return rollingExtreme(src, outLen, window, false), nil
}
// rollingExtreme folds each window with a monotonic deque: an index
// leaves the back of the deque only when a later sample is strictly
// better, so the front always holds the window's extreme, and each
// index enters and leaves the deque once. The scan is O(n) where a
// rescan of every window is O(n·window), and it selects the sample the
// rescan selected: a comparison is strict in both, so a tie, the ±0
// pair included, keeps the earlier index, and a window whose samples
// are all NaN answers its last element, exactly the value the rescan's
// own seed walk ends on.
func rollingExtreme(src []float64, outLen, window int, greater bool) *core.Array {
out := core.New(core.Float, outLen)
vals := out.RawFloats()
// The deque holds indices in ascending order, improving towards the
// back; head is its front, and every entry before head has expired.
// The buffer is compacted once the dead prefix outgrows the live
// region, which keeps it proportional to the deque's depth rather
// than to the series length: each compaction copies at most the
// entries it drops, and every entry is dropped once.
deque := make([]int, 0, min(window, rollingDequeCompact))
head := 0
for i, v := range src {
if head >= rollingDequeCompact && head >= len(deque)-head {
deque = deque[:copy(deque, deque[head:])]
head = 0
}
if !math.IsNaN(v) {
if greater {
for len(deque) > head && src[deque[len(deque)-1]] < v {
deque = deque[:len(deque)-1]
}
} else {
for len(deque) > head && src[deque[len(deque)-1]] > v {
deque = deque[:len(deque)-1]
}
}
deque = append(deque, i)
}
if i+1 < window {
continue
}
oldest := i - window + 1
for head < len(deque) && deque[head] < oldest {
head++
}
if head == len(deque) {
// Every sample of the window is NaN; the rescan answers the
// window's last element, and so does this.
vals[oldest] = v
continue
}
vals[oldest] = src[deque[head]]
}
return out
}
// rollingDequeCompact is the dead-prefix length at which the windowed
// extrema compact their deque buffer: eight kilobytes of indices, past
// which the copy pays for itself on any series long enough to reach it.
const rollingDequeCompact = 1 << 10