feat: initial release
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Command deconv recovers a sharp image from a blurred, noisy
|
||||
// observation by gradient descent through the Fourier transform: the
|
||||
// convolution runs as a spectral product, the loss differentiates
|
||||
// through FFT2 and IFFT2, and Tikhonov regularisation keeps the noise
|
||||
// from winning. Astronomical PSF deconvolution in a page of gradient,
|
||||
// the workload the spectral autograd exists for.
|
||||
//
|
||||
// Usage: go run ./examples/deconv
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor"
|
||||
)
|
||||
|
||||
func main() {
|
||||
const (
|
||||
n = 32
|
||||
sigma = 6.0
|
||||
)
|
||||
// The truth: one off-centre Gaussian source.
|
||||
truth := make([]float64, n*n)
|
||||
for r := range n {
|
||||
for c := range n {
|
||||
dx := float64(c) - 20.0
|
||||
dy := float64(r) - 12.0
|
||||
truth[r*n+c] = math.Exp(-(dx*dx + dy*dy) / (2 * 2.5 * 2.5))
|
||||
}
|
||||
}
|
||||
// The PSF: a wider Gaussian, the blur to undo.
|
||||
psf := make([]float64, n*n)
|
||||
for r := range n {
|
||||
for c := range n {
|
||||
dx := float64(c) - n/2
|
||||
dy := float64(r) - n/2
|
||||
psf[r*n+c] = math.Exp(-(dx*dx + dy*dy) / (2 * sigma * sigma))
|
||||
}
|
||||
}
|
||||
obsArr, err := tensor.FromFloats(truth, n, n)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
psfArr, err := tensor.FromFloats(psf, n, n)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// The blur runs as a spectral product; the constants ride the same
|
||||
// graph nodes without requiring grad.
|
||||
kT := tensor.FromArray(psfArr, false)
|
||||
kF, err := kT.FFT2()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
obsT := tensor.FromArray(obsArr, false)
|
||||
spec, err := obsT.FFT2()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
prod, err := spec.Mul(kF)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
blurred, err := prod.IFFT2()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
noise := tensor.NewGenerator(42)
|
||||
noisy := make([]float64, n*n)
|
||||
for i := range noisy {
|
||||
noisy[i] = real(blurred.Data().ComplexAt(i)) + 0.01*noise.NormalUnit()
|
||||
}
|
||||
obsC, err := tensor.FromFloats(noisy, n, n)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
obsComplex, err := tensor.Astype(obsC, tensor.Complex)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
obsTt := tensor.FromArray(obsComplex, false)
|
||||
|
||||
// The spectral loss is a stiff quadratic (curvature ~ |K|² per
|
||||
// frequency), exactly the landscape plain gradient descent crawls
|
||||
// on and Newton-CG eats: the CG solve rides the Hessian-vector
|
||||
// product through the same FFT chain, two backward passes per
|
||||
// iteration, no dense Hessian ever formed.
|
||||
xArr, err := tensor.FromFloats(make([]float64, n*n), n, n)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
objective := func(x *tensor.Tensor) (*tensor.Tensor, error) {
|
||||
spec, err := x.FFT2()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prod, err := spec.Mul(kF)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
model, err := prod.IFFT2()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resid, err := model.Sub(obsTt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dataTerm, err := resid.Abs2()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
regTerm, err := x.Abs2()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reg, err := regTerm.Scale(1e-3)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
both, err := dataTerm.Add(reg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return both.Sum()
|
||||
}
|
||||
solution, loss, err := tensor.MinimiseNewtonCG(objective, xArr,
|
||||
tensor.NewtonCGOptions{Tolerance: 1e-8, MaxIterations: 80})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
xArr = solution
|
||||
fmt.Printf("newton-cg converged, loss = %.6e\n", loss)
|
||||
|
||||
peakOf := func(a *tensor.Array) (int, int, float64) {
|
||||
best := math.Inf(-1)
|
||||
br, bc := 0, 0
|
||||
for r := range n {
|
||||
for c := range n {
|
||||
if v := a.FloatAt(r*n + c); v > best {
|
||||
best, br, bc = v, r, c
|
||||
}
|
||||
}
|
||||
}
|
||||
return br, bc, best
|
||||
}
|
||||
tr, tc, tv := peakOf(obsArr)
|
||||
br, bc, bv := peakOf(obsC)
|
||||
rr, rc, rv := peakOf(xArr)
|
||||
fmt.Printf("truth peak at (%d, %d), height %.3f\n", tr, tc, tv)
|
||||
fmt.Printf("blurred observation peak at (%d, %d), height %.3f\n", br, bc, bv)
|
||||
fmt.Printf("recovered peak at (%d, %d), height %.3f (final loss %.3e)\n", rr, rc, rv, loss)
|
||||
if rr != tr || rc != tc {
|
||||
log.Fatal("the recovery did not localise the source")
|
||||
}
|
||||
if math.Abs(rv-tv) > 0.25*tv {
|
||||
log.Fatal("the recovery did not restore the source height")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Command fft demonstrates the Fourier transform: it synthesises a
|
||||
// signal from two sinusoids, transforms it, and prints the dominant
|
||||
// frequency components.
|
||||
//
|
||||
// Usage: go run ./examples/fft
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor"
|
||||
sig "sourcedock.dev/petrbalvin/tensor/signal"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Two sinusoids: 5 Hz and 13 Hz, sampled at 100 Hz for 2 seconds.
|
||||
const (
|
||||
fs = 100.0
|
||||
seconds = 2.0
|
||||
)
|
||||
n := int(fs * seconds)
|
||||
vals := make([]float64, n)
|
||||
for i := range n {
|
||||
t := float64(i) / fs
|
||||
vals[i] = math.Sin(2*math.Pi*5*t) + 0.5*math.Sin(2*math.Pi*13*t)
|
||||
}
|
||||
signal, err := tensor.FromFloats(vals, n)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
spec, err := sig.FFT(signal)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
freqs := sig.FFTFreq(n, 1/fs)
|
||||
|
||||
// Find the two strongest bins (excluding DC).
|
||||
var peaks [2]struct {
|
||||
freq float64
|
||||
mag float64
|
||||
}
|
||||
for i := 1; i < n/2; i++ {
|
||||
m, _ := tensor.ComplexAt(spec, i)
|
||||
mag := math.Hypot(real(m), imag(m))
|
||||
for p := range peaks {
|
||||
if mag > peaks[p].mag {
|
||||
peaks[p].freq, _ = tensor.FloatAt(freqs, i)
|
||||
peaks[p].mag = mag
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, p := range peaks {
|
||||
fmt.Printf("peak at %.1f Hz (magnitude %.1f)\n", p.freq, p.mag)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Command fits builds a synthetic star field, saves it as a FITS
|
||||
// primary image, reads it back and recovers the brightest star's
|
||||
// position by a centre-of-mass centroid, the first step of any
|
||||
// aperture photometry pipeline.
|
||||
//
|
||||
// Usage: go run ./examples/fits
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor"
|
||||
)
|
||||
|
||||
func main() {
|
||||
const (
|
||||
size = 128
|
||||
sigma = 2.0 // pixels, the seeing disk
|
||||
)
|
||||
// Three stars of different brightness on a flat sky background.
|
||||
type star struct {
|
||||
x, y, flux float64
|
||||
}
|
||||
stars := []star{
|
||||
{40.5, 60.5, 900},
|
||||
{80.5, 30.5, 300},
|
||||
{95.5, 95.5, 120},
|
||||
}
|
||||
field := make([]float64, size*size)
|
||||
for i := range size {
|
||||
for j := range size {
|
||||
v := 100.0 // sky
|
||||
for _, s := range stars {
|
||||
d2 := (float64(i)-s.y)*(float64(i)-s.y) + (float64(j)-s.x)*(float64(j)-s.x)
|
||||
v += s.flux * math.Exp(-d2/(2*sigma*sigma))
|
||||
}
|
||||
field[i*size+j] = v
|
||||
}
|
||||
}
|
||||
img, err := tensor.FromFloats(field, size, size)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
path := filepath.Join(os.TempDir(), "tensor-example-stars.fits")
|
||||
defer os.Remove(path)
|
||||
headers := map[string]string{
|
||||
"OBJECT": "synthetic field",
|
||||
"EXPTIME": "30",
|
||||
"FILTER": "V",
|
||||
}
|
||||
if err := tensor.SaveFITS(path, img, headers); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
back, hdr, err := tensor.LoadFITS(path)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("wrote and read %s\n", filepath.Base(path))
|
||||
for _, k := range []string{"OBJECT", "EXPTIME", "FILTER"} {
|
||||
fmt.Printf(" %s = %s\n", k, hdr[k])
|
||||
}
|
||||
if back.Shape()[0] != size || back.Shape()[1] != size {
|
||||
log.Fatalf("round trip changed the shape: %v", back.Shape())
|
||||
}
|
||||
|
||||
// Locate the brightest pixel, then centroid a 9x9 window around
|
||||
// it with the sky level subtracted.
|
||||
best, bestVal := 0, -1.0
|
||||
for i := range size * size {
|
||||
if v := back.FloatAt(i); v > bestVal {
|
||||
best, bestVal = i, v
|
||||
}
|
||||
}
|
||||
by, bx := best/size, best%size
|
||||
sum, sx, sy := 0.0, 0.0, 0.0
|
||||
for i := by - 4; i <= by+4; i++ {
|
||||
for j := bx - 4; j <= bx+4; j++ {
|
||||
w := back.FloatAt(i*size+j) - 100
|
||||
if w < 0 {
|
||||
w = 0
|
||||
}
|
||||
sum += w
|
||||
sx += w * float64(j)
|
||||
sy += w * float64(i)
|
||||
}
|
||||
}
|
||||
fmt.Printf("\nbrightest star: peak at (x=%d, y=%d), %.0f counts\n", bx, by, bestVal)
|
||||
fmt.Printf("centroid of the 9x9 window: (x=%.2f, y=%.2f)\n", sx/sum, sy/sum)
|
||||
fmt.Println("true position: (x=40.50, y=60.50)")
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Command helmholtz solves the discretised Helmholtz equation, the
|
||||
// backbone of frequency-domain electromagnetics, in both of its
|
||||
// solver shapes. The time-harmonic wave equation
|
||||
//
|
||||
// -∇²ψ - k²ψ = f
|
||||
//
|
||||
// on a 2-D grid gives a complex symmetric (non-Hermitian) sparse
|
||||
// system, which the BiCGSTAB solver handles. Adding a small imaginary
|
||||
// part to k², the way a lossy medium does, makes the operator
|
||||
// Hermitian positive-definite and the conjugate gradient solver
|
||||
// applies. Both solutions are verified against the dense solve, and
|
||||
// the Hermitian operator's resonant modes come from the sparse
|
||||
// eigensolver.
|
||||
//
|
||||
// Usage: go run ./examples/helmholtz
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor"
|
||||
)
|
||||
|
||||
const grid = 24 // interior points per side
|
||||
|
||||
// laplacianCOO assembles the 5-point discrete -∇² on the interior of
|
||||
// a grid*grid domain with Dirichlet walls, one entry per stencil
|
||||
// point. The value at (i,j) is k2 times the identity there.
|
||||
func helmholtzCOO(k2 complex128) (*tensor.SparseCOO, int) {
|
||||
n := grid * grid
|
||||
var idx []int64
|
||||
var val []complex128
|
||||
at := func(i, j int) int { return i*grid + j }
|
||||
for i := range grid {
|
||||
for j := range grid {
|
||||
p := at(i, j)
|
||||
// 4/h² on the diagonal with h = 1 in grid units, minus k².
|
||||
idx = append(idx, int64(p), int64(p))
|
||||
val = append(val, 4-k2)
|
||||
if i > 0 {
|
||||
idx = append(idx, int64(p), int64(at(i-1, j)))
|
||||
val = append(val, -1)
|
||||
}
|
||||
if i < grid-1 {
|
||||
idx = append(idx, int64(p), int64(at(i+1, j)))
|
||||
val = append(val, -1)
|
||||
}
|
||||
if j > 0 {
|
||||
idx = append(idx, int64(p), int64(at(i, j-1)))
|
||||
val = append(val, -1)
|
||||
}
|
||||
if j < grid-1 {
|
||||
idx = append(idx, int64(p), int64(at(i, j+1)))
|
||||
val = append(val, -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
indices, err := tensor.FromInts(idx, len(val), 2)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
values, err := tensor.FromComplexes(val, len(val))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
coo, err := tensor.NewSparseCOO(indices, values, []int{n, n})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return coo, n
|
||||
}
|
||||
|
||||
// landauCOO assembles the Hamiltonian of a charged particle on the
|
||||
// same grid threading a perpendicular magnetic field, the Peierls
|
||||
// substitution: every hop carries the phase the vector potential
|
||||
// gives it, forward and conjugate backward, so the operator stays
|
||||
// Hermitian. A positive mass term m² makes it positive-definite.
|
||||
func landauCOO(m2, flux float64) *tensor.SparseCOO {
|
||||
n := grid * grid
|
||||
var idx []int64
|
||||
var val []complex128
|
||||
at := func(i, j int) int { return i*grid + j }
|
||||
phase := func(i int) float64 { return 2 * math.Pi * flux * float64(i) }
|
||||
for i := range grid {
|
||||
for j := range grid {
|
||||
p := at(i, j)
|
||||
idx = append(idx, int64(p), int64(p))
|
||||
val = append(val, complex(4+m2, 0))
|
||||
if i > 0 {
|
||||
idx = append(idx, int64(p), int64(at(i-1, j)))
|
||||
val = append(val, -1+0i)
|
||||
}
|
||||
if i < grid-1 {
|
||||
idx = append(idx, int64(p), int64(at(i+1, j)))
|
||||
val = append(val, -1+0i)
|
||||
}
|
||||
if j > 0 {
|
||||
idx = append(idx, int64(p), int64(at(i, j-1)))
|
||||
val = append(val, -complex(math.Cos(phase(i)), math.Sin(phase(i))))
|
||||
}
|
||||
if j < grid-1 {
|
||||
idx = append(idx, int64(p), int64(at(i, j+1)))
|
||||
val = append(val, -complex(math.Cos(phase(i)), -math.Sin(phase(i))))
|
||||
}
|
||||
}
|
||||
}
|
||||
indices, err := tensor.FromInts(idx, len(val), 2)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
values, err := tensor.FromComplexes(val, len(val))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
coo, err := tensor.NewSparseCOO(indices, values, []int{n, n})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return coo
|
||||
}
|
||||
|
||||
// source is a point drive at the grid centre, the field of a small
|
||||
// antenna.
|
||||
func source(n int) *tensor.Array {
|
||||
rhs := make([]complex128, n)
|
||||
rhs[(grid/2)*grid+grid/2] = 1 + 0i
|
||||
b, err := tensor.FromComplexes(rhs, n)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// residual returns ||b - A·x||₂ by reassembling A densely, the ground
|
||||
// truth the sparse solver is checked against.
|
||||
func residual(a *tensor.SparseCOO, x, b *tensor.Array, n int) float64 {
|
||||
dense, err := a.Dense()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
ax, err := tensor.MatMul2D(dense, x)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
worst := 0.0
|
||||
for i := range n {
|
||||
av, err := tensor.ComplexAt(ax, i)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
bv, err := tensor.ComplexAt(b, i)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if d := math.Hypot(real(av-bv), imag(av-bv)); d > worst {
|
||||
worst = d
|
||||
}
|
||||
}
|
||||
return worst
|
||||
}
|
||||
|
||||
func main() {
|
||||
const n = grid * grid
|
||||
|
||||
// A propagating mode: k = 2.5 in grid units, safely away from the
|
||||
// discrete resonances at k² = 2-2cos(p*pi/(grid+1)).
|
||||
k := 2.5 + 0i
|
||||
a, _ := helmholtzCOO(k * k)
|
||||
b := source(n)
|
||||
|
||||
x, err := tensor.SpSolveComplexBiCGSTAB(a, b, 1e-12, 2000)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println("lossless Helmholtz system, -nabla^2 - k^2, k = 2.5")
|
||||
fmt.Printf(" unknowns: %d, stored nonzeros: %d\n", n, len(a.Values.RawComplexes()))
|
||||
fmt.Printf(" BiCGSTAB residual ||b - A x|| = %.3g\n", residual(a, x, b, n))
|
||||
|
||||
// The genuinely Hermitian complex problem: a charged particle on
|
||||
// the same grid in a perpendicular magnetic field. The Peierls
|
||||
// phases make every hop complex, the forward and backward hop
|
||||
// conjugates of each other, so the operator is Hermitian, and the
|
||||
// mass term keeps it positive-definite: exactly the shape the
|
||||
// conjugate gradient solver wants.
|
||||
h := landauCOO(1.0, 1.0/25)
|
||||
xh, err := tensor.SpSolveComplexCG(h, b, 1e-12, 2000)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println("\nLandau Hamiltonian on the grid, mass^2 = 1, flux 1/25 (Hermitian positive-definite)")
|
||||
fmt.Printf(" CG residual ||b - A x|| = %.3g\n", residual(h, xh, b, n))
|
||||
|
||||
// Resonant modes of the lossless cavity: the largest eigenvalues
|
||||
// of the discrete negative Laplacian are the highest-Q modes.
|
||||
lap, _ := helmholtzCOO(0)
|
||||
vals, vecs, err := tensor.SpEigenComplex(lap, 3, tensor.NewGenerator(4))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println("\ncavity modes: largest eigenvalues of -nabla^2")
|
||||
for j := range 3 {
|
||||
lam, err := tensor.FloatAt(vals, j)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
// Verify each Ritz pair: ||A v - lambda v|| must be small.
|
||||
vcol, err := tensor.Slice(vecs, 1, j, j+1)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
av, err := tensor.MatMul2D(mustDense(lap), vcol)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
worst := 0.0
|
||||
for i := range n {
|
||||
a1, _ := tensor.ComplexAt(av, i)
|
||||
v1, _ := tensor.ComplexAt(vcol, i)
|
||||
if d := math.Hypot(real(a1-complex(lam, 0)*v1), imag(a1-complex(lam, 0)*v1)); d > worst {
|
||||
worst = d
|
||||
}
|
||||
}
|
||||
fmt.Printf(" lambda = %8.4f, residual %.3g\n", lam, worst)
|
||||
}
|
||||
// The analytic eigenvalues of the grid Laplacian are
|
||||
// 4-2cos(p*pi/(grid+1))-2cos(q*pi/(grid+1)); the largest is p = q =
|
||||
// grid, where both cosines approach -1 and the value nears 8.
|
||||
fmt.Println(" (analytic maximum: 4 - 4cos(24pi/25) = 7.9685)")
|
||||
}
|
||||
|
||||
func mustDense(a *tensor.SparseCOO) *tensor.Array {
|
||||
d, err := a.Dense()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Command hmc samples a correlated two-dimensional Gaussian by
|
||||
// Hamiltonian Monte Carlo on the differentiable log density, and
|
||||
// checks the chain against the distribution's known moments: mean
|
||||
// zero, unit variances, correlation 0.9. The sampler never sees an
|
||||
// analytic gradient, only the autograd's.
|
||||
//
|
||||
// Usage: go run ./examples/hmc
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor"
|
||||
)
|
||||
|
||||
func main() {
|
||||
const rho = 0.9
|
||||
// Precision matrix of the correlated Gaussian (up to scale, which
|
||||
// the unnormalised density does not need).
|
||||
prec, err := tensor.FromFloats([]float64{1, -rho, -rho, 1}, 2, 2)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
aT := tensor.FromArray(prec, false)
|
||||
|
||||
logDensity := func(q *tensor.Tensor) (*tensor.Tensor, error) {
|
||||
// log p(q) ∝ −½ qᵀAq: one matrix-vector product on the graph,
|
||||
// then the inner product with q itself.
|
||||
r, err := aT.MatMul(q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
quad, err := r.Mul(q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s, err := quad.Sum()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.Scale(-0.5)
|
||||
}
|
||||
|
||||
q0, err := tensor.FromFloats([]float64{0.5, -0.5}, 2)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
samples, err := tensor.SampleHMC(logDensity, q0, tensor.HMCOptions{
|
||||
Step: 0.15,
|
||||
Steps: 12,
|
||||
BurnIn: 500,
|
||||
Thin: 5,
|
||||
Samples: 20000,
|
||||
Seed: 7,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
n := samples.Shape()[0]
|
||||
mean0, mean1 := 0.0, 0.0
|
||||
for i := range n {
|
||||
mean0 += samples.FloatAt(i * 2)
|
||||
mean1 += samples.FloatAt(i*2 + 1)
|
||||
}
|
||||
mean0 /= float64(n)
|
||||
mean1 /= float64(n)
|
||||
var0, var1, cov := 0.0, 0.0, 0.0
|
||||
for i := range n {
|
||||
d0 := samples.FloatAt(i*2) - mean0
|
||||
d1 := samples.FloatAt(i*2+1) - mean1
|
||||
var0 += d0 * d0
|
||||
var1 += d1 * d1
|
||||
cov += d0 * d1
|
||||
}
|
||||
var0 /= float64(n - 1)
|
||||
var1 /= float64(n - 1)
|
||||
cov /= float64(n - 1)
|
||||
corr := cov / math.Sqrt(var0*var1)
|
||||
|
||||
fmt.Printf("samples %d\n", n)
|
||||
fmt.Printf("mean (%.3f, %.3f), want (0, 0)\n", mean0, mean1)
|
||||
// The covariance is A⁻¹: unit-over-(1−ρ²) variances around the
|
||||
// correlation rho.
|
||||
wantVar := 1 / (1 - rho*rho)
|
||||
fmt.Printf("variance (%.3f, %.3f), want (%.3f, %.3f)\n", var0, var1, wantVar, wantVar)
|
||||
fmt.Printf("correlation %.3f, want %.3f\n", corr, rho)
|
||||
if math.Abs(mean0) > 0.05 || math.Abs(mean1) > 0.05 {
|
||||
log.Fatal("the sample mean drifted")
|
||||
}
|
||||
if math.Abs(corr-rho) > 0.02 {
|
||||
log.Fatalf("the sample correlation %.3f missed %.3f", corr, rho)
|
||||
}
|
||||
if math.Abs(var0-wantVar) > 0.15*wantVar || math.Abs(var1-wantVar) > 0.15*wantVar {
|
||||
log.Fatalf("the sample variances (%.3f, %.3f) missed %.3f", var0, var1, wantVar)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Command netcdf writes a synthetic climate field to a NetCDF classic
|
||||
// file, reads it back, and computes the zonal statistics a climate
|
||||
// workflow starts from. The point is the round trip: dimensions,
|
||||
// attributes and values survive the file exactly.
|
||||
//
|
||||
// Usage: go run ./examples/netcdf
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor"
|
||||
)
|
||||
|
||||
func main() {
|
||||
const (
|
||||
nLat = 36 // 5-degree grid
|
||||
nLon = 72
|
||||
)
|
||||
// A warm anomaly centred on 45 N, 15 E over a zonal gradient, the
|
||||
// shape of a heat island in a coarse climate model.
|
||||
field := make([]float64, nLat*nLon)
|
||||
for i := range nLat {
|
||||
lat := -90 + 5*(float64(i)+0.5)
|
||||
for j := range nLon {
|
||||
lon := -180 + 5*(float64(j)+0.5)
|
||||
base := 30*math.Cos(lat*math.Pi/180) - 5
|
||||
dLat := (lat - 45) / 15
|
||||
dLon := math.Sin((lon - 15) * math.Pi / 180)
|
||||
anomaly := 8 * math.Exp(-(dLat*dLat + dLon*dLon))
|
||||
field[i*nLon+j] = base + anomaly
|
||||
}
|
||||
}
|
||||
temp, err := tensor.FromFloats(field, nLat, nLon)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
path := filepath.Join(os.TempDir(), "tensor-example-climate.nc")
|
||||
defer os.Remove(path)
|
||||
dims := []tensor.NetCDFDim{
|
||||
{Name: "lat", Length: nLat},
|
||||
{Name: "lon", Length: nLon},
|
||||
}
|
||||
vars := []tensor.NetCDFVar{{
|
||||
Name: "temperature",
|
||||
Dims: []string{"lat", "lon"},
|
||||
Values: temp,
|
||||
Attrs: map[string]string{
|
||||
"units": "degC",
|
||||
"long_name": "synthetic air temperature",
|
||||
"anomaly_lon": "15",
|
||||
},
|
||||
}}
|
||||
attrs := map[string]string{
|
||||
"title": "tensor NetCDF example",
|
||||
"source": "synthetic Gaussian anomaly",
|
||||
}
|
||||
if err := tensor.SaveNetCDF(path, dims, vars, attrs); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("wrote %s: %d x %d grid, %d variables\n\n", path, nLat, nLon, len(vars))
|
||||
|
||||
gotDims, gotVars, gotAttrs, err := tensor.LoadNetCDF(path)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("dimensions: ")
|
||||
for _, d := range gotDims {
|
||||
fmt.Printf("%s(%d) ", d.Name, d.Length)
|
||||
}
|
||||
fmt.Printf("\nglobal attributes: %v\n\n", gotAttrs)
|
||||
|
||||
back := gotVars[0].Values
|
||||
if back.Shape()[0] != nLat || back.Shape()[1] != nLon {
|
||||
log.Fatalf("round trip changed the shape: %v", back.Shape())
|
||||
}
|
||||
maxDiff := 0.0
|
||||
for i := range nLat * nLon {
|
||||
d := math.Abs(back.FloatAt(i) - field[i])
|
||||
if d > maxDiff {
|
||||
maxDiff = d
|
||||
}
|
||||
}
|
||||
fmt.Printf("largest round-trip difference: %g (exact for float64)\n\n", maxDiff)
|
||||
|
||||
// Zonal means: the latitude profile of the field, the first thing
|
||||
// a climate diagnostic asks for.
|
||||
fmt.Println("latitude zonal mean temperature")
|
||||
for i := 0; i < nLat; i += 6 {
|
||||
row, err := tensor.Slice(back, 0, i, i+1)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
mean, err := tensor.Mean(row)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("%6.1f° %10.3f degC\n", -90+5*(float64(i)+0.5), mean)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Command ode-fit fits the parameters of a damped oscillator to
|
||||
// endpoint measurements by the adjoint method: AdjointODE hands back
|
||||
// dL/dθ for every parameter at the cost of one extra solve, and plain
|
||||
// gradient descent walks the parameters to the truth. This is the
|
||||
// data-assimilation loop no other Go library expresses.
|
||||
//
|
||||
// Usage: go run ./examples/ode-fit
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor"
|
||||
)
|
||||
|
||||
// coreDynamics is y' = (v, −c·v − ω²·y) over plain arrays, the shape
|
||||
// IntegrateODE wants; it generates the data.
|
||||
func coreDynamics(c, omega float64) func(float64, *tensor.Array) (*tensor.Array, error) {
|
||||
return func(t float64, y *tensor.Array) (*tensor.Array, error) {
|
||||
return tensor.FromFloats([]float64{
|
||||
y.FloatAt(1),
|
||||
-c*y.FloatAt(1) - omega*omega*y.FloatAt(0),
|
||||
}, 2)
|
||||
}
|
||||
}
|
||||
|
||||
// graphDynamics is the same equation with (c, ω) as differentiable
|
||||
// leaves, the shape AdjointODE wants.
|
||||
func graphDynamics(c, omega *tensor.Tensor) func(float64, *tensor.Tensor) (*tensor.Tensor, error) {
|
||||
return func(t float64, y *tensor.Tensor) (*tensor.Tensor, error) {
|
||||
yv, err := y.Slice(0, 0, 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
vv, err := y.Slice(0, 1, 2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w2, err := omega.Pow(2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
damping, err := c.Mul(vv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
restoring, err := w2.Mul(yv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
acc, err := damping.Add(restoring)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
neg, err := acc.Scale(-1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return vv.Concat(neg, 0)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
const (
|
||||
trueC = 0.8
|
||||
trueOmega = 3.0
|
||||
)
|
||||
y0, err := tensor.FromFloats([]float64{1, 0}, 2)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Measurements of y(t) at three times from the true system.
|
||||
times := []float64{0.4, 0.8, 1.2, 1.6, 2.0, 2.4}
|
||||
data := make([]float64, len(times))
|
||||
trueF := coreDynamics(trueC, trueOmega)
|
||||
for i, T := range times {
|
||||
end, err := tensor.IntegrateODE(trueF, 0, T, y0, tensor.ODEOptions{})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
data[i] = end.FloatAt(0)
|
||||
}
|
||||
|
||||
// The fit: gradient descent on L = Σ (y(Tᵢ; θ) − dataᵢ)² with the
|
||||
// gradient from one adjoint pass per data point.
|
||||
cVal, wVal := 0.3, 1.8
|
||||
const rate = 0.04
|
||||
for iter := 1; iter <= 400; iter++ {
|
||||
gc, gw := 0.0, 0.0
|
||||
loss := 0.0
|
||||
forwardF := coreDynamics(cVal, wVal)
|
||||
cArr, _ := tensor.FromFloats([]float64{cVal}, 1)
|
||||
wArr, _ := tensor.FromFloats([]float64{wVal}, 1)
|
||||
c := tensor.FromArray(cArr, true)
|
||||
omega := tensor.FromArray(wArr, true)
|
||||
adjF := graphDynamics(c, omega)
|
||||
for i, T := range times {
|
||||
end, err := tensor.IntegrateODE(forwardF, 0, T, y0, tensor.ODEOptions{})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
res := end.FloatAt(0) - data[i]
|
||||
loss += res * res
|
||||
// dL/dy(T) = 2·res on the position component only; the
|
||||
// velocity component carries no loss.
|
||||
seed, err := tensor.FromFloats([]float64{2 * res, 0}, 2)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
_, paramGrads, err := tensor.AdjointODE(adjF, []*tensor.Tensor{c, omega},
|
||||
0, T, y0, seed, tensor.ODEOptions{})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
gc += paramGrads[0].FloatAt(0)
|
||||
gw += paramGrads[1].FloatAt(0)
|
||||
}
|
||||
if iter%100 == 0 {
|
||||
fmt.Printf("iter %3d c = %.4f omega = %.4f loss = %.3e\n", iter, cVal, wVal, loss)
|
||||
}
|
||||
cVal -= rate * gc
|
||||
wVal -= rate * gw
|
||||
}
|
||||
fmt.Printf("fitted c = %.4f (true %.4f), omega = %.4f (true %.4f)\n",
|
||||
cVal, trueC, wVal, trueOmega)
|
||||
if math.Abs(cVal-trueC) > 0.05 || math.Abs(wVal-trueOmega) > 0.05 {
|
||||
log.Fatal("the fit did not converge to the truth")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Command pde solves the two canonical one-dimensional partial
|
||||
// differential equations: the heat equation by Crank-Nicolson and the
|
||||
// wave equation by velocity Verlet. Both start from the same Gaussian
|
||||
// pulse on a rod, and the diagnostics show diffusion flattening the
|
||||
// pulse while the wave keeps its shape and travels.
|
||||
//
|
||||
// Usage: go run ./examples/pde
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor"
|
||||
)
|
||||
|
||||
func main() {
|
||||
const (
|
||||
n = 201
|
||||
dx = 0.01 // metres, a 2 m rod
|
||||
k = 1e-3 // thermal diffusivity, m^2/s
|
||||
c = 0.5 // wave speed, m/s
|
||||
)
|
||||
pulse := make([]float64, n)
|
||||
for i := range n {
|
||||
x := float64(i)*dx - 1.0
|
||||
pulse[i] = math.Exp(-(x * x) / 0.01)
|
||||
}
|
||||
u0, err := tensor.FromFloats(pulse, n)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Println("heat equation (Crank-Nicolson, ends held at zero):")
|
||||
fmt.Println(" time peak mean (interior)")
|
||||
for _, tf := range []float64{0, 0.5, 2, 5} {
|
||||
var row *tensor.Array
|
||||
if tf == 0 {
|
||||
row = u0 // the initial pulse itself
|
||||
} else {
|
||||
u, err := tensor.IntegrateHeat1D(u0, k, dx, tf, tf/400, 5, 0, 0)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
// The last sample row holds the final state. The ends are
|
||||
// Dirichlet zeros, so heat drains out of the rod once the
|
||||
// pulse reaches them; by the last time printed it has not,
|
||||
// which is why the interior mean barely moves.
|
||||
row, err = tensor.Slice(u, 0, 4, 5)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
mx, err := tensor.Max(row)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
mean, err := tensor.Mean(row)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf(" %.2f s %7.4f %7.4f\n", tf, mx.Float(), mean)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("wave equation (velocity Verlet, fixed ends):")
|
||||
fmt.Println(" time peak position of the peak")
|
||||
rest, err := tensor.Zeros(tensor.Float, n)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
for _, tf := range []float64{0, 0.5, 1.0, 1.5} {
|
||||
var row *tensor.Array
|
||||
if tf == 0 {
|
||||
row = u0
|
||||
} else {
|
||||
u, err := tensor.IntegrateWave1D(u0, rest, c, dx, tf, tf/600, 5)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
row, err = tensor.Slice(u, 0, 4, 5)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
mx, err := tensor.Max(row)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
peak, peakVal := 0, -1.0
|
||||
for i := range n {
|
||||
if v := row.FloatAt(i); v > peakVal {
|
||||
peak, peakVal = i, v
|
||||
}
|
||||
}
|
||||
fmt.Printf(" %.2f s %6.4f x = %.2f m\n",
|
||||
tf, mx.Float(), float64(peak)*dx-1.0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Command pendulum computes the exact period of a simple pendulum at
|
||||
// large amplitude through the complete elliptic integral of the first
|
||||
// kind, and shows how far the small-angle formula drifts once the
|
||||
// release angle stops being small. The period is
|
||||
//
|
||||
// T = 4·sqrt(L/g)·K(sin²(θ₀/2)),
|
||||
//
|
||||
// where K is EllipticK with the m = k² parameter convention.
|
||||
//
|
||||
// Usage: go run ./examples/pendulum
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor"
|
||||
)
|
||||
|
||||
// kComplete evaluates EllipticK at a single parameter.
|
||||
func kComplete(m float64) float64 {
|
||||
arr, err := tensor.FromFloats([]float64{m}, 1)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
k, err := tensor.EllipticK(arr)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
v, _ := tensor.FloatAt(k, 0)
|
||||
return v
|
||||
}
|
||||
|
||||
func main() {
|
||||
const (
|
||||
length = 1.0 // metres
|
||||
grav = 9.80665
|
||||
)
|
||||
small := 2 * math.Pi * math.Sqrt(length/grav)
|
||||
|
||||
fmt.Println("release angle exact period small-angle period drift")
|
||||
for _, deg := range []float64{5, 15, 30, 45, 60, 90, 120, 170} {
|
||||
theta := deg * math.Pi / 180
|
||||
m := math.Sin(theta/2) * math.Sin(theta/2)
|
||||
period := 4 * math.Sqrt(length/grav) * kComplete(m)
|
||||
drift := (period/small - 1) * 100
|
||||
fmt.Printf("%10.0f° %12.6f s %14.6f s %+6.2f %%\n",
|
||||
deg, period, small, drift)
|
||||
}
|
||||
|
||||
// The inverse problem: which release angle doubles the small-angle
|
||||
// period? Bisection on the angle, the period being monotone in it.
|
||||
target := 2 * small
|
||||
lo, hi := 0.0, math.Pi
|
||||
angle := 0.0
|
||||
for range 80 {
|
||||
mid := (lo + hi) / 2
|
||||
m := math.Sin(mid/2) * math.Sin(mid/2)
|
||||
if 4*math.Sqrt(length/grav)*kComplete(m) < target {
|
||||
lo = mid
|
||||
} else {
|
||||
hi = mid
|
||||
}
|
||||
angle = mid
|
||||
}
|
||||
fmt.Printf("\na release angle of %.2f° doubles the period (%.4f s)\n",
|
||||
angle*180/math.Pi, target)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Command qmc compares quasi-random integration against plain Monte
|
||||
// Carlo on the same two-dimensional integral. Sobol points are a
|
||||
// digital lattice: every block of 2^m points stratifies each
|
||||
// coordinate exactly, so the error decays far faster than the
|
||||
// 1/sqrt(n) of random sampling, and Halton sits in between.
|
||||
//
|
||||
// Usage: go run ./examples/qmc
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor"
|
||||
)
|
||||
|
||||
// f is the integrand: smooth, with its curvature spread over the unit
|
||||
// square. The exact value is (1-e^-1)*sqrt(pi)/2*erf(1), the product
|
||||
// of the x integral and the error function integral over y.
|
||||
func f(x, y float64) float64 { return math.Exp(-x - y*y) }
|
||||
|
||||
const exact = 0.4720828881800443 // (1-e^-1)*sqrt(pi)/2*erf(1)
|
||||
|
||||
// estimate integrates f over [0,1]^2 from an (n,2) point set.
|
||||
func estimate(pts *tensor.Array, n int) float64 {
|
||||
s := 0.0
|
||||
for i := range n {
|
||||
x, err := tensor.FloatAt(pts, i, 0)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
y, err := tensor.FloatAt(pts, i, 1)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
s += f(x, y)
|
||||
}
|
||||
return s / float64(n)
|
||||
}
|
||||
|
||||
func main() {
|
||||
fmt.Printf("integral of exp(-x - y^2) over the unit square, exact %.10f\n\n", exact)
|
||||
fmt.Println(" points Monte Carlo Halton Sobol")
|
||||
for _, n := range []int{64, 256, 1024, 4096, 16384} {
|
||||
// Monte Carlo: uniform draws from the seeded generator.
|
||||
g := tensor.NewGenerator(int64(n))
|
||||
mc, err := tensor.Floats(g, 2*n)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
mcPts, err := tensor.Reshape(mc, n, 2)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
// Halton and Sobol from the first point on; Sobol skips its
|
||||
// origin point exactly as Halton does.
|
||||
hal, err := tensor.HaltonPoints(n, 2, 0)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
sob, err := tensor.SobolPoints(n, 2, 0)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
eMC := math.Abs(estimate(mcPts, n) - exact)
|
||||
eHal := math.Abs(estimate(hal, n) - exact)
|
||||
eSob := math.Abs(estimate(sob, n) - exact)
|
||||
fmt.Printf(" %6d %.3e %.3e %.3e\n", n, eMC, eHal, eSob)
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println("the quasi-random errors collapse with n; the Monte Carlo")
|
||||
fmt.Println("error only shrinks as 1/sqrt(n) and stays noisy on top")
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Command regression fits a linear trend to a noisy time series,
|
||||
// reports the full inference table (coefficients, standard errors,
|
||||
// t-statistics, p-values, R²) and checks that the residuals are
|
||||
// actually uncorrelated, which is the assumption the t-tests rest on.
|
||||
//
|
||||
// Usage: go run ./examples/regression
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor"
|
||||
"sourcedock.dev/petrbalvin/tensor/signal"
|
||||
"sourcedock.dev/petrbalvin/tensor/stats"
|
||||
)
|
||||
|
||||
func main() {
|
||||
const n = 400
|
||||
// A trend of 0.05 per sample on a level of 2, with AR(1) noise
|
||||
// (rho = 0.3), drawn from the reproducible generator.
|
||||
g := tensor.NewGenerator(7)
|
||||
white, err := tensor.Normal(g, n, 0, 1)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
y := make([]float64, n)
|
||||
ar := 0.0
|
||||
for i := range n {
|
||||
w, _ := tensor.FloatAt(white, i)
|
||||
ar = 0.3*ar + w
|
||||
y[i] = 2 + 0.05*float64(i) + 0.4*ar
|
||||
}
|
||||
yArr, err := tensor.FromFloats(y, n)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// The design carries its own intercept column, the convention of
|
||||
// the classic linear model.
|
||||
design := make([]float64, 2*n)
|
||||
for i := range n {
|
||||
design[2*i] = 1
|
||||
design[2*i+1] = float64(i)
|
||||
}
|
||||
xArr, err := tensor.FromFloats(design, n, 2)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fit, err := stats.LinearRegression(xArr, yArr)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
fmt.Println("ordinary least squares fit, y = intercept + slope * t")
|
||||
fmt.Println("term estimate std error t-stat p-value")
|
||||
fmt.Printf("intercept %9.4f %9.4f %7.3f %.3g\n",
|
||||
fit.Coefficients[0], fit.StandardErrors[0], fit.TStatistics[0], fit.PValues[0])
|
||||
fmt.Printf("slope %9.4f %9.4f %7.3f %.3g\n",
|
||||
fit.Coefficients[1], fit.StandardErrors[1], fit.TStatistics[1], fit.PValues[1])
|
||||
fmt.Printf("\nR² = %.4f, adjusted R² = %.4f, residual variance = %.4f\n",
|
||||
fit.RSquared, fit.AdjustedRSquared, fit.ResidualVariance)
|
||||
fmt.Println("(the generating values were intercept 2, slope 0.05)")
|
||||
|
||||
// The t-tests assume uncorrelated residuals. Pull them out and
|
||||
// check the autocorrelation at the first few lags; with rho = 0.3
|
||||
// in the noise, lag 1 must show clear correlation, which is the
|
||||
// honest caveat for the standard errors above.
|
||||
resid := make([]float64, n)
|
||||
for i := range n {
|
||||
pred := fit.Coefficients[0] + fit.Coefficients[1]*float64(i)
|
||||
resid[i] = y[i] - pred
|
||||
}
|
||||
rArr, err := tensor.FromFloats(resid, n)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
ac, err := signal.Autocorrelate(rArr, 5)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
// The transform returns lags 0..5; lag 0 is 1 by definition, the
|
||||
// AR(1) memory shows from lag 1 on.
|
||||
fmt.Print("\nresidual autocorrelation:")
|
||||
for lag := 1; lag <= 5; lag++ {
|
||||
v, _ := tensor.FloatAt(ac, lag)
|
||||
fmt.Printf(" lag %d: %+.3f", lag, v)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// A two-sample test on the first and last halves: with a trend of
|
||||
// 0.05 over 200 samples the means must differ decisively.
|
||||
first, err := tensor.Slice(yArr, 0, 0, n/2)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
last, err := tensor.Slice(yArr, 0, n/2, n)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
t, df, p, err := stats.WelchTTest(first, last)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
meanOf := func(a *tensor.Array) float64 {
|
||||
m, err := tensor.Mean(a)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
fmt.Printf("\nWelch t-test, first half vs second half:\n")
|
||||
fmt.Printf(" means %.3f vs %.3f, t = %.2f, df = %.1f, p = %.3g\n",
|
||||
meanOf(first), meanOf(last), t, df, p)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Command spectral estimates the frequency content of a signal two
|
||||
// ways: Welch's averaged periodogram on evenly sampled data, and the
|
||||
// Lomb-Scargle periodogram on the same signal observed at irregular
|
||||
// times, where an FFT cannot run at all. Both must find the two
|
||||
// buried sinusoids.
|
||||
//
|
||||
// Usage: go run ./examples/spectral
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor"
|
||||
)
|
||||
|
||||
func main() {
|
||||
const (
|
||||
fs = 100.0
|
||||
seconds = 4.0
|
||||
f1 = 5.0
|
||||
f2 = 13.0
|
||||
)
|
||||
n := int(fs * seconds)
|
||||
gen := tensor.NewGenerator(11)
|
||||
|
||||
// The signal: two sinusoids plus noise.
|
||||
t := make([]float64, n)
|
||||
x := make([]float64, n)
|
||||
for i := range n {
|
||||
t[i] = float64(i) / fs
|
||||
x[i] = math.Sin(2*math.Pi*f1*t[i]) + 0.6*math.Sin(2*math.Pi*f2*t[i]) + 0.4*gen.NormalUnit()
|
||||
}
|
||||
xArr, err := tensor.FromFloats(x, n)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Welch: average periodograms over Hann-windowed segments, the
|
||||
// variance-suppressed estimate an FFT alone cannot give.
|
||||
freqs, psd, err := tensor.WelchPSD(xArr, fs, 256, 128, "hann")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
wf1, wf2 := twoPeaks(peakFrequencies(freqs, psd, 2))
|
||||
fmt.Printf("welch peaks at %.2f Hz and %.2f Hz (want %.1f and %.1f)\n", wf1, wf2, f1, f2)
|
||||
|
||||
// Lomb-Scargle: keep every second sample at jittered times, the
|
||||
// uneven regime the DFT does not define. The mean rate stays at
|
||||
// 50 Hz, comfortably above both sources' Nyquist needs, while the
|
||||
// jitter is what makes the ordinary FFT inapplicable.
|
||||
times := make([]float64, 0, n/2)
|
||||
values := make([]float64, 0, n/2)
|
||||
for i := 0; i < n; i += 2 {
|
||||
jitter := 0.6 * gen.Unit() / fs
|
||||
times = append(times, t[i]+jitter)
|
||||
values = append(values, x[i])
|
||||
}
|
||||
tArr, err := tensor.FromFloats(times, len(times))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
vArr, err := tensor.FromFloats(values, len(values))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
lsFreqs, power, err := tensor.LombScargle(tArr, vArr, 1.0, 30.0, 3000)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
lf1, lf2 := twoPeaks(peakFrequencies(lsFreqs, power, 2))
|
||||
fmt.Printf("lomb-scargle peaks at %.2f Hz and %.2f Hz (want %.1f and %.1f)\n", lf1, lf2, f1, f2)
|
||||
|
||||
for _, got := range []float64{wf1, wf2, lf1, lf2} {
|
||||
if math.Abs(got-f1) > 0.3 && math.Abs(got-f2) > 0.3 {
|
||||
log.Fatalf("a peak landed at %.2f Hz, away from both sources", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// peakFrequencies returns the abscissae of the count largest local
|
||||
// maxima of a periodogram, descending by height and kept at least
|
||||
// 1.5 Hz apart so a sidelobe of a tall peak cannot shadow a real one.
|
||||
func peakFrequencies(freqs, power *tensor.Array, count int) []float64 {
|
||||
n := freqs.Len()
|
||||
// Three-point boxcar smooth: the periodogram's noise is white, a
|
||||
// genuine peak is not.
|
||||
smooth := make([]float64, n)
|
||||
for i := range n {
|
||||
lo := max(i-1, 0)
|
||||
hi := min(i+1, n-1)
|
||||
s := 0.0
|
||||
for j := lo; j <= hi; j++ {
|
||||
s += power.FloatAt(j)
|
||||
}
|
||||
smooth[i] = s / float64(hi-lo+1)
|
||||
}
|
||||
type peak struct {
|
||||
f, h float64
|
||||
}
|
||||
var peaks []peak
|
||||
for i := 1; i < n-1; i++ {
|
||||
if smooth[i] > smooth[i-1] && smooth[i] >= smooth[i+1] {
|
||||
peaks = append(peaks, peak{freqs.FloatAt(i), smooth[i]})
|
||||
}
|
||||
}
|
||||
for i := 1; i < len(peaks); i++ {
|
||||
for j := i; j > 0 && peaks[j-1].h < peaks[j].h; j-- {
|
||||
peaks[j-1], peaks[j] = peaks[j], peaks[j-1]
|
||||
}
|
||||
}
|
||||
out := make([]float64, 0, count)
|
||||
for _, p := range peaks {
|
||||
if len(out) == count {
|
||||
break
|
||||
}
|
||||
far := true
|
||||
for _, f := range out {
|
||||
if math.Abs(p.f-f) < 1.5 {
|
||||
far = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if far {
|
||||
out = append(out, p.f)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// twoPeaks unpacks the two-element result of peakFrequencies.
|
||||
func twoPeaks(fs []float64) (float64, float64) { return fs[0], fs[1] }
|
||||
@@ -0,0 +1,139 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Command wavelets demonstrates the discrete wavelet transform on a
|
||||
// denoising task and the continuous transform on a time-frequency
|
||||
// task: a clean signal is buried in noise, the detail coefficients are
|
||||
// soft-thresholded and the signal rebuilt, then a two-tone signal with
|
||||
// an abrupt frequency change is mapped by the CWT so the change is
|
||||
// visible in time, not just in frequency.
|
||||
//
|
||||
// Usage: go run ./examples/wavelets
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
|
||||
"sourcedock.dev/petrbalvin/tensor"
|
||||
"sourcedock.dev/petrbalvin/tensor/signal"
|
||||
)
|
||||
|
||||
func main() {
|
||||
const n = 1024
|
||||
|
||||
// A clean decaying sinusoid, buried in noise drawn from the
|
||||
// reproducible generator so the run is exactly repeatable.
|
||||
g := tensor.NewGenerator(2026)
|
||||
noise, err := tensor.Normal(g, n, 0, 0.25)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
clean := make([]float64, n)
|
||||
dirty := make([]float64, n)
|
||||
for i := range n {
|
||||
x := float64(i) / n
|
||||
clean[i] = math.Sin(2*math.Pi*3*x) * math.Exp(-3*x)
|
||||
nv, _ := tensor.FloatAt(noise, i)
|
||||
dirty[i] = clean[i] + nv
|
||||
}
|
||||
dirtyArr, err := tensor.FromFloats(dirty, n)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Decompose, soft-threshold the detail coefficients, rebuild. The
|
||||
// threshold sits at twice the noise standard deviation, the level
|
||||
// where a noise-only coefficient almost never survives.
|
||||
const levels = 5
|
||||
coef, err := signal.DWT(dirtyArr, levels)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
approx := n >> levels
|
||||
const threshold = 2 * 0.25
|
||||
raw := coef.RawFloats()
|
||||
for i := approx; i < len(raw); i++ {
|
||||
v := raw[i]
|
||||
switch {
|
||||
case v > threshold:
|
||||
raw[i] = v - threshold
|
||||
case v < -threshold:
|
||||
raw[i] = v + threshold
|
||||
default:
|
||||
raw[i] = 0
|
||||
}
|
||||
}
|
||||
denoised, err := signal.IDWT(coef, levels)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
mse := func(a []float64) float64 {
|
||||
s := 0.0
|
||||
for i := range n {
|
||||
d := a[i] - clean[i]
|
||||
s += d * d
|
||||
}
|
||||
return s / float64(n)
|
||||
}
|
||||
fmt.Println("mean squared error against the clean signal:")
|
||||
fmt.Printf(" noisy %.6f\n", mse(dirty))
|
||||
fmt.Printf(" denoised %.6f\n", mse(denoised.RawFloats()[:n]))
|
||||
fmt.Println()
|
||||
|
||||
// The continuous transform: 512 samples of a signal whose tone
|
||||
// jumps from 8 to 32 cycles over the whole run, halfway through.
|
||||
// A Morlet scale a responds at omega0/(2*pi*a) cycles per sample,
|
||||
// which is omega0*N/(2*pi*a) cycles per record of N = 512 samples,
|
||||
// so with omega0 = 5 the two tones live near a = 51 and a = 13;
|
||||
// the scalogram ridge must jump between them.
|
||||
const m = 512
|
||||
chirp := make([]float64, m)
|
||||
for i := range m {
|
||||
freq := 8.0
|
||||
if i >= m/2 {
|
||||
freq = 32.0
|
||||
}
|
||||
chirp[i] = math.Sin(2 * math.Pi * freq * float64(i) / m)
|
||||
}
|
||||
chirpArr, err := tensor.FromFloats(chirp, m)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
scales := []float64{4, 8, 13, 16, 26, 32, 51, 64}
|
||||
scalogram, err := signal.CWT(chirpArr, signal.Morlet, scales, 1)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println("CWT ridge: the scale carrying the peak energy in each half")
|
||||
// The wavelet of scale 64 spans about 256 samples, so the outer
|
||||
// quarters of the run are edge territory; the ridge is read from
|
||||
// the interior of each half only.
|
||||
const margin = 128
|
||||
for _, seg := range []struct {
|
||||
label string
|
||||
start, stop int
|
||||
}{
|
||||
{"first half ", margin, m/2 - margin/2},
|
||||
{"second half", m/2 + margin/2, m - margin},
|
||||
} {
|
||||
best := 0
|
||||
bestMag := -1.0
|
||||
for si := range scales {
|
||||
for i := seg.start; i < seg.stop; i++ {
|
||||
// The scalogram is (len(scales), m), one complex row
|
||||
// per scale; the ridge is the peak magnitude.
|
||||
cv, err := tensor.ComplexAt(scalogram, si, i)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if a := math.Hypot(real(cv), imag(cv)); a > bestMag {
|
||||
best, bestMag = si, a
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Printf(" %s: scale %.0f\n", seg.label, scales[best])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user