Init Project
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s

This commit is contained in:
enkly
2026-10-30 18:06:55 +01:00
parent da8af794bd
commit 79ce8dc4c0
211 changed files with 44434 additions and 1 deletions

115
pkg/mathkit/calculus.go Normal file
View File

@@ -0,0 +1,115 @@
package mathkit
import "fmt"
// defaultStep is the finite-difference step size used when the
// caller doesn't specify one. 1e-5 is the standard sweet spot for
// float64 central differences: small enough for truncation error to
// be negligible, large enough that it doesn't get swallowed by
// float64 rounding error (the classic bias-variance trade-off of
// numerical differentiation).
const defaultStep = 1e-5
// Derivative estimates d/d(variable) of expr at the point `at`
// (variable -> value, including any other free variables the
// expression needs) using the central-difference formula
// (f(x+h)-f(x-h))/(2h), which is second-order accurate — far better
// than the naive forward difference (f(x+h)-f(x))/h for the same h.
// h<=0 uses defaultStep.
func Derivative(expr *Expr, variable string, at map[string]float64, h float64) (float64, error) {
if h <= 0 {
h = defaultStep
}
x0, ok := at[variable]
if !ok {
return 0, fmt.Errorf("derivative: %q is not bound in \"at\"", variable)
}
plus := cloneVars(at)
plus[variable] = x0 + h
minus := cloneVars(at)
minus[variable] = x0 - h
fPlus, err := expr.Eval(plus)
if err != nil {
return 0, fmt.Errorf("derivative: evaluating at %s+h: %w", variable, err)
}
fMinus, err := expr.Eval(minus)
if err != nil {
return 0, fmt.Errorf("derivative: evaluating at %s-h: %w", variable, err)
}
return (fPlus - fMinus) / (2 * h), nil
}
// Gradient estimates ∇f at the point `at` with respect to each name
// in variables, in order — the central operation every ML training
// loop runs millions of times (just normally via backprop's chain
// rule rather than finite differences; this is the numerical
// stand-in that's useful for checking a hand-derived gradient, or for
// expressions where deriving the symbolic gradient isn't worth it).
func Gradient(expr *Expr, variables []string, at map[string]float64, h float64) ([]float64, error) {
if len(variables) == 0 {
return nil, fmt.Errorf("gradient: \"variables\" must list at least one variable")
}
grad := make([]float64, len(variables))
for i, v := range variables {
d, err := Derivative(expr, v, at, h)
if err != nil {
return nil, err
}
grad[i] = d
}
return grad, nil
}
func cloneVars(m map[string]float64) map[string]float64 {
out := make(map[string]float64, len(m))
for k, v := range m {
out[k] = v
}
return out
}
// Integrate estimates the definite integral of expr with respect to
// variable over [from, to] using Simpson's rule, which is exact for
// up to cubic polynomials and converges much faster than the
// trapezoid rule for smooth functions. steps (the number of
// subintervals) is rounded up to the nearest even number, since
// Simpson's rule needs an even number of subintervals; steps<=0
// defaults to 1000.
func Integrate(expr *Expr, variable string, from, to float64, steps int, fixed map[string]float64) (float64, error) {
if steps <= 0 {
steps = 1000
}
if steps%2 != 0 {
steps++
}
h := (to - from) / float64(steps)
vars := cloneVars(fixed)
eval := func(x float64) (float64, error) {
vars[variable] = x
return expr.Eval(vars)
}
f0, err := eval(from)
if err != nil {
return 0, fmt.Errorf("integral: %w", err)
}
fn, err := eval(to)
if err != nil {
return 0, fmt.Errorf("integral: %w", err)
}
sum := f0 + fn
for i := 1; i < steps; i++ {
x := from + float64(i)*h
fx, err := eval(x)
if err != nil {
return 0, fmt.Errorf("integral: %w", err)
}
if i%2 == 0 {
sum += 2 * fx
} else {
sum += 4 * fx
}
}
return sum * h / 3, nil
}

421
pkg/mathkit/expr.go Normal file
View File

@@ -0,0 +1,421 @@
// Package mathkit is a dependency-free math engine for the problems
// that actually come up when building and debugging ML code: linear
// algebra (the shape of every tensor op), calculus (gradients are how
// everything trains), probability/statistics (data, metrics, losses),
// numerical optimization (gradient descent, root finding), and the
// handful of ML-specific primitives (activations, loss functions,
// least-squares linear regression) that sit on top of those four
// pillars. It has no third-party dependency — see the comment on
// pkg/envkit/dotenv.go for why this module keeps go.mod minimal — so
// the whole engine is just the Go standard library's math/sort/fmt.
//
// Every sub-area (linalg.go, calculus.go, statistics.go, optimize.go,
// ml.go) is usable directly from Go code. solve.go adds one more
// layer on top: Solve(operation, params) dispatches a string
// operation name and a loosely-typed params map (exactly the shape
// a JSON tool-call argument object decodes to) to the right function
// and renders a human-readable report — that's the layer the agent
// service's solve_ml_math tool calls (see
// services/agent/internal/infrastructure/toolexec/mlmath.go), because
// an LLM tool call arrives as JSON, not as typed Go arguments.
package mathkit
import (
"fmt"
"math"
"strings"
"unicode"
)
// Expr is a parsed arithmetic expression that can be evaluated
// repeatedly against different variable bindings without re-parsing —
// exactly what Gradient and GradientDescent need, since they evaluate
// the same expression at many points.
type Expr struct {
root node
src string
}
// String returns the original source text the expression was parsed
// from, for error messages and report rendering.
func (e *Expr) String() string { return e.src }
// node is the parsed AST. A tiny tagged union kept as an interface
// rather than a struct-with-a-kind-field: each node type carries only
// the fields it needs, and eval is a single recursive method.
type node interface {
eval(vars map[string]float64) (float64, error)
}
type numNode float64
func (n numNode) eval(map[string]float64) (float64, error) { return float64(n), nil }
type varNode string
func (n varNode) eval(vars map[string]float64) (float64, error) {
v, ok := vars[string(n)]
if !ok {
return 0, fmt.Errorf("unbound variable %q (pass it in \"variables\" or \"at\")", string(n))
}
return v, nil
}
type binNode struct {
op byte // '+', '-', '*', '/', '^'
lhs, rhs node
}
func (n binNode) eval(vars map[string]float64) (float64, error) {
l, err := n.lhs.eval(vars)
if err != nil {
return 0, err
}
r, err := n.rhs.eval(vars)
if err != nil {
return 0, err
}
switch n.op {
case '+':
return l + r, nil
case '-':
return l - r, nil
case '*':
return l * r, nil
case '/':
if r == 0 {
return 0, fmt.Errorf("division by zero")
}
return l / r, nil
case '^':
return math.Pow(l, r), nil
}
return 0, fmt.Errorf("internal: unknown operator %q", n.op)
}
type negNode struct{ x node }
func (n negNode) eval(vars map[string]float64) (float64, error) {
v, err := n.x.eval(vars)
return -v, err
}
type callNode struct {
fn string
args []node
}
// unaryFuncs are functions of exactly one argument — the overwhelming
// majority of what shows up in ML math (activations, logs, norms).
var unaryFuncs = map[string]func(float64) float64{
"sin": math.Sin, "cos": math.Cos, "tan": math.Tan,
"asin": math.Asin, "acos": math.Acos, "atan": math.Atan,
"sinh": math.Sinh, "cosh": math.Cosh, "tanh": math.Tanh,
"exp": math.Exp, "sqrt": math.Sqrt, "abs": math.Abs,
"ln": math.Log, "log": math.Log, "log2": math.Log2, "log10": math.Log10,
"floor": math.Floor, "ceil": math.Ceil, "round": math.Round,
"sigmoid": Sigmoid, "relu": ReLU, "sign": func(x float64) float64 { return math.Copysign(1, x) * boolF(x != 0) },
}
func boolF(b bool) float64 {
if b {
return 1
}
return 0
}
// binaryFuncs are functions of exactly two arguments.
var binaryFuncs = map[string]func(float64, float64) float64{
"max": math.Max, "min": math.Min, "pow": math.Pow,
"mod": math.Mod, "atan2": math.Atan2,
}
func (n callNode) eval(vars map[string]float64) (float64, error) {
switch len(n.args) {
case 1:
if f, ok := unaryFuncs[n.fn]; ok {
v, err := n.args[0].eval(vars)
if err != nil {
return 0, err
}
return f(v), nil
}
case 2:
if f, ok := binaryFuncs[n.fn]; ok {
a, err := n.args[0].eval(vars)
if err != nil {
return 0, err
}
b, err := n.args[1].eval(vars)
if err != nil {
return 0, err
}
return f(a, b), nil
}
}
return 0, fmt.Errorf("unknown function %q with %d argument(s)", n.fn, len(n.args))
}
// Eval evaluates the expression against a variable binding. Missing
// variables are a reported error (never silently treated as zero),
// since a silent zero would turn a typo into a wrong-but-plausible
// answer — exactly the failure mode numerical tools must not have.
func (e *Expr) Eval(vars map[string]float64) (float64, error) {
return e.root.eval(vars)
}
// ParseExpr parses a math expression written in ordinary infix
// notation: + - * / ^ (right-associative power), unary minus,
// parentheses, named variables, the constants pi/e, and function
// calls (sin(x), sigmoid(x), max(a,b), ...). See unaryFuncs and
// binaryFuncs for the full function list.
func ParseExpr(src string) (*Expr, error) {
p := &parser{toks: tokenize(src)}
n, err := p.parseExpr()
if err != nil {
return nil, fmt.Errorf("parsing %q: %w", src, err)
}
if p.pos != len(p.toks) {
return nil, fmt.Errorf("parsing %q: unexpected trailing input near %q", src, p.toks[p.pos].text)
}
return &Expr{root: n, src: src}, nil
}
// --- tokenizer ---
type tokKind int
const (
tokNum tokKind = iota
tokIdent
tokOp
tokLParen
tokRParen
tokComma
)
type token struct {
kind tokKind
text string
num float64
}
func tokenize(src string) []token {
var toks []token
r := []rune(src)
i := 0
for i < len(r) {
c := r[i]
switch {
case unicode.IsSpace(c):
i++
case c == '(':
toks = append(toks, token{kind: tokLParen, text: "("})
i++
case c == ')':
toks = append(toks, token{kind: tokRParen, text: ")"})
i++
case c == ',':
toks = append(toks, token{kind: tokComma, text: ","})
i++
case strings.ContainsRune("+-*/^", c):
toks = append(toks, token{kind: tokOp, text: string(c)})
i++
case unicode.IsDigit(c) || c == '.':
j := i
for j < len(r) && (unicode.IsDigit(r[j]) || r[j] == '.') {
j++
}
// scientific notation, e.g. 1e-3
if j < len(r) && (r[j] == 'e' || r[j] == 'E') {
k := j + 1
if k < len(r) && (r[k] == '+' || r[k] == '-') {
k++
}
if k < len(r) && unicode.IsDigit(r[k]) {
j = k
for j < len(r) && unicode.IsDigit(r[j]) {
j++
}
}
}
text := string(r[i:j])
var f float64
fmt.Sscanf(text, "%g", &f)
toks = append(toks, token{kind: tokNum, text: text, num: f})
i = j
case unicode.IsLetter(c) || c == '_':
j := i
for j < len(r) && (unicode.IsLetter(r[j]) || unicode.IsDigit(r[j]) || r[j] == '_') {
j++
}
toks = append(toks, token{kind: tokIdent, text: string(r[i:j])})
i = j
default:
// Unknown character: emit as its own operator token so the
// parser reports a clear "unexpected token" error instead
// of the tokenizer silently dropping it.
toks = append(toks, token{kind: tokOp, text: string(c)})
i++
}
}
return toks
}
// --- recursive-descent parser ---
//
// expr := term (('+' | '-') term)*
// term := unary (('*' | '/') unary)*
// unary := ('-' | '+') unary | power
// power := primary ('^' unary)? -- right-associative; the
// exponent recurses through unary (not power) so the
// precedence matches ordinary math/Python convention:
// unary minus binds LOOSER than '^' (-2^2 == -(2^2) == -4)
// but the exponent itself may carry its own sign
// (2^-2 == 0.25).
// primary:= number | const | ident '(' args ')' | ident | '(' expr ')'
// args := expr (',' expr)*
type parser struct {
toks []token
pos int
}
func (p *parser) peek() (token, bool) {
if p.pos >= len(p.toks) {
return token{}, false
}
return p.toks[p.pos], true
}
func (p *parser) next() (token, bool) {
t, ok := p.peek()
if ok {
p.pos++
}
return t, ok
}
func (p *parser) parseExpr() (node, error) {
lhs, err := p.parseTerm()
if err != nil {
return nil, err
}
for {
t, ok := p.peek()
if !ok || t.kind != tokOp || (t.text != "+" && t.text != "-") {
return lhs, nil
}
p.next()
rhs, err := p.parseTerm()
if err != nil {
return nil, err
}
lhs = binNode{op: t.text[0], lhs: lhs, rhs: rhs}
}
}
func (p *parser) parseTerm() (node, error) {
lhs, err := p.parseUnary()
if err != nil {
return nil, err
}
for {
t, ok := p.peek()
if !ok || t.kind != tokOp || (t.text != "*" && t.text != "/") {
return lhs, nil
}
p.next()
rhs, err := p.parseUnary()
if err != nil {
return nil, err
}
lhs = binNode{op: t.text[0], lhs: lhs, rhs: rhs}
}
}
func (p *parser) parseUnary() (node, error) {
if t, ok := p.peek(); ok && t.kind == tokOp && (t.text == "-" || t.text == "+") {
p.next()
x, err := p.parseUnary()
if err != nil {
return nil, err
}
if t.text == "-" {
return negNode{x: x}, nil
}
return x, nil
}
return p.parsePower()
}
func (p *parser) parsePower() (node, error) {
lhs, err := p.parsePrimary()
if err != nil {
return nil, err
}
if t, ok := p.peek(); ok && t.kind == tokOp && t.text == "^" {
p.next()
rhs, err := p.parseUnary() // right-associative; lets the exponent carry its own sign
if err != nil {
return nil, err
}
return binNode{op: '^', lhs: lhs, rhs: rhs}, nil
}
return lhs, nil
}
func (p *parser) parsePrimary() (node, error) {
t, ok := p.next()
if !ok {
return nil, fmt.Errorf("unexpected end of expression")
}
switch t.kind {
case tokNum:
return numNode(t.num), nil
case tokLParen:
n, err := p.parseExpr()
if err != nil {
return nil, err
}
if c, ok := p.next(); !ok || c.kind != tokRParen {
return nil, fmt.Errorf("missing closing ')'")
}
return n, nil
case tokIdent:
switch strings.ToLower(t.text) {
case "pi":
return numNode(math.Pi), nil
case "e":
return numNode(math.E), nil
}
if nx, ok := p.peek(); ok && nx.kind == tokLParen {
p.next()
var args []node
if c, ok := p.peek(); !ok || c.kind != tokRParen {
for {
a, err := p.parseExpr()
if err != nil {
return nil, err
}
args = append(args, a)
c, ok := p.peek()
if !ok {
return nil, fmt.Errorf("missing closing ')' in call to %s", t.text)
}
if c.kind == tokComma {
p.next()
continue
}
break
}
}
if c, ok := p.next(); !ok || c.kind != tokRParen {
return nil, fmt.Errorf("missing closing ')' in call to %s", t.text)
}
return callNode{fn: strings.ToLower(t.text), args: args}, nil
}
return varNode(t.text), nil
}
return nil, fmt.Errorf("unexpected token %q", t.text)
}

409
pkg/mathkit/linalg.go Normal file
View File

@@ -0,0 +1,409 @@
package mathkit
import (
"fmt"
"math"
)
// Matrix is a dense matrix stored row-major: Matrix[i][j] is row i,
// column j. Every row must have the same length — functions that take
// a Matrix validate that with Matrix.Dims before doing any math, so a
// ragged input is reported as an error rather than panicking.
type Matrix [][]float64
// Dims returns the matrix's row and column counts and an error if the
// rows aren't all the same length (a ragged "matrix" isn't one).
func (m Matrix) Dims() (rows, cols int, err error) {
rows = len(m)
if rows == 0 {
return 0, 0, fmt.Errorf("empty matrix")
}
cols = len(m[0])
for i, row := range m {
if len(row) != cols {
return 0, 0, fmt.Errorf("ragged matrix: row 0 has %d columns, row %d has %d", cols, i, len(row))
}
}
return rows, cols, nil
}
// Identity returns the n×n identity matrix.
func Identity(n int) Matrix {
m := make(Matrix, n)
for i := range m {
m[i] = make([]float64, n)
m[i][i] = 1
}
return m
}
func sameDims(a, b Matrix) (rows, cols int, err error) {
ra, ca, err := a.Dims()
if err != nil {
return 0, 0, err
}
rb, cb, err := b.Dims()
if err != nil {
return 0, 0, err
}
if ra != rb || ca != cb {
return 0, 0, fmt.Errorf("shape mismatch: %dx%d vs %dx%d", ra, ca, rb, cb)
}
return ra, ca, nil
}
// MatAdd returns a+b, element-wise.
func MatAdd(a, b Matrix) (Matrix, error) {
rows, cols, err := sameDims(a, b)
if err != nil {
return nil, fmt.Errorf("matrix_add: %w", err)
}
out := make(Matrix, rows)
for i := 0; i < rows; i++ {
out[i] = make([]float64, cols)
for j := 0; j < cols; j++ {
out[i][j] = a[i][j] + b[i][j]
}
}
return out, nil
}
// MatSub returns a-b, element-wise.
func MatSub(a, b Matrix) (Matrix, error) {
rows, cols, err := sameDims(a, b)
if err != nil {
return nil, fmt.Errorf("matrix_sub: %w", err)
}
out := make(Matrix, rows)
for i := 0; i < rows; i++ {
out[i] = make([]float64, cols)
for j := 0; j < cols; j++ {
out[i][j] = a[i][j] - b[i][j]
}
}
return out, nil
}
// MatMul returns the matrix product a×b (standard inner-dimension
// matching: a is m×n, b must be n×p).
func MatMul(a, b Matrix) (Matrix, error) {
ra, ca, err := a.Dims()
if err != nil {
return nil, fmt.Errorf("matrix_mul: left operand: %w", err)
}
rb, cb, err := b.Dims()
if err != nil {
return nil, fmt.Errorf("matrix_mul: right operand: %w", err)
}
if ca != rb {
return nil, fmt.Errorf("matrix_mul: inner dimensions must match: %dx%d times %dx%d", ra, ca, rb, cb)
}
out := make(Matrix, ra)
for i := 0; i < ra; i++ {
out[i] = make([]float64, cb)
for j := 0; j < cb; j++ {
var sum float64
for k := 0; k < ca; k++ {
sum += a[i][k] * b[k][j]
}
out[i][j] = sum
}
}
return out, nil
}
// Scale multiplies every element of m by k.
func Scale(m Matrix, k float64) Matrix {
out := make(Matrix, len(m))
for i, row := range m {
out[i] = make([]float64, len(row))
for j, v := range row {
out[i][j] = v * k
}
}
return out
}
// Transpose returns m^T.
func Transpose(m Matrix) (Matrix, error) {
rows, cols, err := m.Dims()
if err != nil {
return nil, fmt.Errorf("matrix_transpose: %w", err)
}
out := make(Matrix, cols)
for j := 0; j < cols; j++ {
out[j] = make([]float64, rows)
for i := 0; i < rows; i++ {
out[j][i] = m[i][j]
}
}
return out, nil
}
// Trace returns the sum of the diagonal of a square matrix.
func Trace(m Matrix) (float64, error) {
rows, cols, err := m.Dims()
if err != nil {
return 0, fmt.Errorf("matrix_trace: %w", err)
}
if rows != cols {
return 0, fmt.Errorf("matrix_trace: matrix must be square, got %dx%d", rows, cols)
}
var sum float64
for i := 0; i < rows; i++ {
sum += m[i][i]
}
return sum, nil
}
// clone makes a deep copy so Gaussian-elimination routines never
// mutate the caller's matrix.
func (m Matrix) clone() Matrix {
out := make(Matrix, len(m))
for i, row := range m {
out[i] = append([]float64(nil), row...)
}
return out
}
// luDecompose runs Gaussian elimination with partial pivoting on a
// clone of m, returning the resulting upper-triangular-ish matrix and
// the sign of the permutation (needed to get the determinant's sign
// right) and the pivots used. Both Determinant and Inverse build on
// this one routine rather than duplicating elimination logic.
func luDecompose(m Matrix) (a Matrix, sign float64, err error) {
rows, cols, err := m.Dims()
if err != nil {
return nil, 0, err
}
if rows != cols {
return nil, 0, fmt.Errorf("matrix must be square, got %dx%d", rows, cols)
}
n := rows
a = m.clone()
sign = 1
for col := 0; col < n; col++ {
// Partial pivot: swap in the row with the largest absolute
// value in this column, for numerical stability.
pivotRow := col
best := math.Abs(a[col][col])
for r := col + 1; r < n; r++ {
if v := math.Abs(a[r][col]); v > best {
best, pivotRow = v, r
}
}
if best < 1e-12 {
return a, 0, nil // singular; determinant is 0, caller decides
}
if pivotRow != col {
a[col], a[pivotRow] = a[pivotRow], a[col]
sign = -sign
}
for r := col + 1; r < n; r++ {
factor := a[r][col] / a[col][col]
for c := col; c < n; c++ {
a[r][c] -= factor * a[col][c]
}
}
}
return a, sign, nil
}
// Determinant computes det(m) via LU (Gaussian elimination with
// partial pivoting), O(n^3) — the standard numerically-stable
// approach, versus the O(n!) cofactor expansion a direct reading of
// the definition would suggest.
func Determinant(m Matrix) (float64, error) {
a, sign, err := luDecompose(m)
if err != nil {
return 0, fmt.Errorf("matrix_determinant: %w", err)
}
n := len(a)
det := sign
for i := 0; i < n; i++ {
det *= a[i][i]
}
return det, nil
}
// Inverse computes m^-1 via Gauss-Jordan elimination on [m | I].
// Returns an error for a non-square or singular (determinant ≈ 0)
// matrix — ML code hitting a singular matrix (e.g. a rank-deficient
// design matrix in least squares) usually wants to know that, not a
// silently garbage answer.
func Inverse(m Matrix) (Matrix, error) {
rows, cols, err := m.Dims()
if err != nil {
return nil, fmt.Errorf("matrix_inverse: %w", err)
}
if rows != cols {
return nil, fmt.Errorf("matrix_inverse: matrix must be square, got %dx%d", rows, cols)
}
n := rows
aug := make(Matrix, n)
for i := 0; i < n; i++ {
aug[i] = make([]float64, 2*n)
copy(aug[i], m[i])
aug[i][n+i] = 1
}
for col := 0; col < n; col++ {
pivotRow := col
best := math.Abs(aug[col][col])
for r := col + 1; r < n; r++ {
if v := math.Abs(aug[r][col]); v > best {
best, pivotRow = v, r
}
}
if best < 1e-12 {
return nil, fmt.Errorf("matrix_inverse: matrix is singular (not invertible)")
}
aug[col], aug[pivotRow] = aug[pivotRow], aug[col]
pivot := aug[col][col]
for c := 0; c < 2*n; c++ {
aug[col][c] /= pivot
}
for r := 0; r < n; r++ {
if r == col {
continue
}
factor := aug[r][col]
for c := 0; c < 2*n; c++ {
aug[r][c] -= factor * aug[col][c]
}
}
}
out := make(Matrix, n)
for i := 0; i < n; i++ {
out[i] = append([]float64(nil), aug[i][n:]...)
}
return out, nil
}
// Rank computes the matrix rank as the number of non-negligible
// pivots found by Gaussian elimination (works for non-square
// matrices too, unlike Determinant/Inverse).
func Rank(m Matrix) (int, error) {
rows, cols, err := m.Dims()
if err != nil {
return 0, fmt.Errorf("matrix_rank: %w", err)
}
a := m.clone()
rank := 0
for col := 0; col < cols && rank < rows; col++ {
pivotRow := -1
best := 1e-9
for r := rank; r < rows; r++ {
if v := math.Abs(a[r][col]); v > best {
best, pivotRow = v, r
}
}
if pivotRow == -1 {
continue
}
a[rank], a[pivotRow] = a[pivotRow], a[rank]
for r := rank + 1; r < rows; r++ {
factor := a[r][col] / a[rank][col]
for c := col; c < cols; c++ {
a[r][c] -= factor * a[rank][c]
}
}
rank++
}
return rank, nil
}
// MatVecMul returns m×v for a column vector v.
func MatVecMul(m Matrix, v []float64) ([]float64, error) {
rows, cols, err := m.Dims()
if err != nil {
return nil, fmt.Errorf("matrix-vector multiply: %w", err)
}
if cols != len(v) {
return nil, fmt.Errorf("matrix-vector multiply: matrix has %d columns but vector has %d entries", cols, len(v))
}
out := make([]float64, rows)
for i := 0; i < rows; i++ {
var sum float64
for j := 0; j < cols; j++ {
sum += m[i][j] * v[j]
}
out[i] = sum
}
return out, nil
}
// Dot returns the dot (inner) product of two equal-length vectors.
func Dot(a, b []float64) (float64, error) {
if len(a) != len(b) {
return 0, fmt.Errorf("dot_product: vectors have different lengths (%d vs %d)", len(a), len(b))
}
var sum float64
for i := range a {
sum += a[i] * b[i]
}
return sum, nil
}
// Norm computes the Lp norm of a vector. p=2 is Euclidean (the
// default any ML codebase means when it just says "norm"); p=1 is
// Manhattan; p=math.Inf(1) is the max-absolute-value norm.
func Norm(v []float64, p float64) float64 {
if math.IsInf(p, 1) {
var m float64
for _, x := range v {
if a := math.Abs(x); a > m {
m = a
}
}
return m
}
var sum float64
for _, x := range v {
sum += math.Pow(math.Abs(x), p)
}
return math.Pow(sum, 1/p)
}
// DominantEigen estimates the eigenvalue of largest absolute value
// and its eigenvector via power iteration — the workhorse behind
// things like PageRank and quick PCA-component estimates, and much
// simpler/more numerically robust to implement from scratch than a
// full QR eigendecomposition, which is the right trade-off for a
// hand-rolled numerical toolkit. iterations<=0 defaults to 200, which
// converges for any matrix with a well-separated dominant eigenvalue
// to within float64 precision.
func DominantEigen(m Matrix, iterations int) (eigenvalue float64, eigenvector []float64, err error) {
rows, cols, err := m.Dims()
if err != nil {
return 0, nil, fmt.Errorf("dominant_eigen: %w", err)
}
if rows != cols {
return 0, nil, fmt.Errorf("dominant_eigen: matrix must be square, got %dx%d", rows, cols)
}
if iterations <= 0 {
iterations = 200
}
n := rows
v := make([]float64, n)
for i := range v {
v[i] = 1 / math.Sqrt(float64(n)) // deterministic, reproducible start
}
var lambda float64
for iter := 0; iter < iterations; iter++ {
next, err := MatVecMul(m, v)
if err != nil {
return 0, nil, err
}
norm := Norm(next, 2)
if norm < 1e-15 {
return 0, nil, fmt.Errorf("dominant_eigen: did not converge (matrix may be nilpotent or zero)")
}
for i := range next {
next[i] /= norm
}
v = next
mv, _ := MatVecMul(m, v)
lambda, _ = Dot(v, mv)
}
return lambda, v, nil
}

364
pkg/mathkit/mathkit_test.go Normal file
View File

@@ -0,0 +1,364 @@
package mathkit
import (
"math"
"strings"
"testing"
)
func almostEqual(a, b, tol float64) bool { return math.Abs(a-b) <= tol }
func mustExpr(t *testing.T, src string) *Expr {
t.Helper()
e, err := ParseExpr(src)
if err != nil {
t.Fatalf("ParseExpr(%q): %v", src, err)
}
return e
}
func TestExprEval(t *testing.T) {
cases := []struct {
src string
vars map[string]float64
want float64
}{
{"2 + 3 * 4", nil, 14},
{"(2 + 3) * 4", nil, 20},
{"2 ^ 3 ^ 2", nil, 512}, // right-associative: 2^(3^2) = 2^9
{"-2 ^ 2", nil, -4}, // unary minus binds looser than ^: -(2^2)
{"x * x + 2*x + 1", map[string]float64{"x": 3}, 16},
{"sigmoid(0)", nil, 0.5},
{"relu(-5)", nil, 0},
{"relu(5)", nil, 5},
{"max(3, 7)", nil, 7},
{"sqrt(16)", nil, 4},
{"pi", nil, math.Pi},
}
for _, c := range cases {
e := mustExpr(t, c.src)
got, err := e.Eval(c.vars)
if err != nil {
t.Fatalf("Eval(%q): %v", c.src, err)
}
if !almostEqual(got, c.want, 1e-9) {
t.Errorf("Eval(%q) = %v, want %v", c.src, got, c.want)
}
}
}
func TestExprUnboundVariable(t *testing.T) {
e := mustExpr(t, "x + 1")
if _, err := e.Eval(nil); err == nil {
t.Fatal("expected an error for an unbound variable, got nil")
}
}
func TestMatMul(t *testing.T) {
a := Matrix{{1, 2}, {3, 4}}
b := Matrix{{5, 6}, {7, 8}}
got, err := MatMul(a, b)
if err != nil {
t.Fatal(err)
}
want := Matrix{{19, 22}, {43, 50}}
for i := range want {
for j := range want[i] {
if got[i][j] != want[i][j] {
t.Errorf("MatMul[%d][%d] = %v, want %v", i, j, got[i][j], want[i][j])
}
}
}
}
func TestMatMulShapeMismatch(t *testing.T) {
a := Matrix{{1, 2, 3}}
b := Matrix{{1, 2}}
if _, err := MatMul(a, b); err == nil {
t.Fatal("expected a shape-mismatch error")
}
}
func TestDeterminantAndInverse(t *testing.T) {
a := Matrix{{4, 7}, {2, 6}}
det, err := Determinant(a)
if err != nil {
t.Fatal(err)
}
if !almostEqual(det, 10, 1e-9) {
t.Errorf("det = %v, want 10", det)
}
inv, err := Inverse(a)
if err != nil {
t.Fatal(err)
}
// A * A^-1 should be the identity.
prod, err := MatMul(a, inv)
if err != nil {
t.Fatal(err)
}
for i := 0; i < 2; i++ {
for j := 0; j < 2; j++ {
want := 0.0
if i == j {
want = 1
}
if !almostEqual(prod[i][j], want, 1e-9) {
t.Errorf("A*A^-1[%d][%d] = %v, want %v", i, j, prod[i][j], want)
}
}
}
}
func TestInverseSingular(t *testing.T) {
a := Matrix{{1, 2}, {2, 4}} // row 2 = 2 * row 1
if _, err := Inverse(a); err == nil {
t.Fatal("expected a singular-matrix error")
}
}
func TestDominantEigen(t *testing.T) {
// [[2,0],[0,1]] has eigenvalues 2 and 1 — dominant is 2, eigenvector (1,0).
a := Matrix{{2, 0}, {0, 1}}
lambda, v, err := DominantEigen(a, 0)
if err != nil {
t.Fatal(err)
}
if !almostEqual(lambda, 2, 1e-6) {
t.Errorf("eigenvalue = %v, want 2", lambda)
}
if !almostEqual(math.Abs(v[0]), 1, 1e-6) || !almostEqual(v[1], 0, 1e-6) {
t.Errorf("eigenvector = %v, want ~(1,0)", v)
}
}
func TestDerivative(t *testing.T) {
// d/dx[x^2] at x=3 is 6.
e := mustExpr(t, "x^2")
d, err := Derivative(e, "x", map[string]float64{"x": 3}, 0)
if err != nil {
t.Fatal(err)
}
if !almostEqual(d, 6, 1e-4) {
t.Errorf("derivative = %v, want 6", d)
}
}
func TestGradient(t *testing.T) {
// f(x,y) = x^2 + y^2 ; gradient at (3,4) is (6,8).
e := mustExpr(t, "x^2 + y^2")
g, err := Gradient(e, []string{"x", "y"}, map[string]float64{"x": 3, "y": 4}, 0)
if err != nil {
t.Fatal(err)
}
if !almostEqual(g[0], 6, 1e-4) || !almostEqual(g[1], 8, 1e-4) {
t.Errorf("gradient = %v, want [6 8]", g)
}
}
func TestIntegrate(t *testing.T) {
// ∫0..1 x^2 dx = 1/3.
e := mustExpr(t, "x^2")
v, err := Integrate(e, "x", 0, 1, 100, nil)
if err != nil {
t.Fatal(err)
}
if !almostEqual(v, 1.0/3.0, 1e-9) {
t.Errorf("integral = %v, want 1/3", v)
}
}
func TestGradientDescentConvergesToMinimum(t *testing.T) {
// f(x) = (x-3)^2 has its minimum at x=3.
e := mustExpr(t, "(x - 3)^2")
res, err := GradientDescent(e, []string{"x"}, []float64{0}, 0.1, 200, 1e-10)
if err != nil {
t.Fatal(err)
}
final := res.Path[len(res.Path)-1][0]
if !almostEqual(final, 3, 1e-3) {
t.Errorf("gradient descent converged to x=%v, want ~3", final)
}
}
func TestNewtonRootFindsSquareRoot(t *testing.T) {
// Root of x^2 - 2 = 0 is sqrt(2).
e := mustExpr(t, "x^2 - 2")
res, err := NewtonRoot(e, "x", 1, 0, 0, nil)
if err != nil {
t.Fatal(err)
}
root := res.Path[len(res.Path)-1]
if !almostEqual(root, math.Sqrt2, 1e-6) {
t.Errorf("newton root = %v, want %v", root, math.Sqrt2)
}
}
func TestStatistics(t *testing.T) {
data := []float64{2, 4, 4, 4, 5, 5, 7, 9}
mean, _ := Mean(data)
if !almostEqual(mean, 5, 1e-9) {
t.Errorf("mean = %v, want 5", mean)
}
popVar, _ := Variance(data, true)
if !almostEqual(popVar, 4, 1e-9) {
t.Errorf("population variance = %v, want 4", popVar)
}
std, _ := StdDev(data, true)
if !almostEqual(std, 2, 1e-9) {
t.Errorf("population std_dev = %v, want 2", std)
}
}
func TestCorrelationPerfect(t *testing.T) {
x := []float64{1, 2, 3, 4, 5}
y := []float64{2, 4, 6, 8, 10}
c, err := Correlation(x, y)
if err != nil {
t.Fatal(err)
}
if !almostEqual(c, 1, 1e-9) {
t.Errorf("correlation = %v, want 1", c)
}
}
func TestNormalDistribution(t *testing.T) {
// Standard normal at x=0: pdf = 1/sqrt(2*pi), cdf = 0.5.
pdf, _ := NormalPDF(0, 0, 1)
if !almostEqual(pdf, 1/math.Sqrt(2*math.Pi), 1e-9) {
t.Errorf("pdf(0) = %v", pdf)
}
cdf, _ := NormalCDF(0, 0, 1)
if !almostEqual(cdf, 0.5, 1e-9) {
t.Errorf("cdf(0) = %v, want 0.5", cdf)
}
}
func TestSigmoidNumericallyStable(t *testing.T) {
if v := Sigmoid(-1000); v != 0 {
t.Errorf("sigmoid(-1000) = %v, want 0", v)
}
if v := Sigmoid(1000); !almostEqual(v, 1, 1e-12) {
t.Errorf("sigmoid(1000) = %v, want ~1", v)
}
if v := Sigmoid(0); v != 0.5 {
t.Errorf("sigmoid(0) = %v, want 0.5", v)
}
}
func TestSoftmaxSumsToOne(t *testing.T) {
out := Softmax([]float64{1, 2, 3})
var sum float64
for _, v := range out {
sum += v
}
if !almostEqual(sum, 1, 1e-9) {
t.Errorf("softmax sums to %v, want 1", sum)
}
// Largest logit should get the largest probability.
if out[2] <= out[1] || out[1] <= out[0] {
t.Errorf("softmax ordering wrong: %v", out)
}
}
func TestMSELoss(t *testing.T) {
v, err := MSELoss([]float64{1, 2, 3}, []float64{1, 2, 4})
if err != nil {
t.Fatal(err)
}
// errors: 0, 0, -1 -> squared: 0,0,1 -> mean = 1/3
if !almostEqual(v, 1.0/3.0, 1e-9) {
t.Errorf("mse = %v, want 1/3", v)
}
}
func TestLinearRegressionPerfectFit(t *testing.T) {
// y = 2 + 3x, exactly.
X := Matrix{{1}, {2}, {3}, {4}, {5}}
y := []float64{5, 8, 11, 14, 17}
res, err := LinearRegression(X, y)
if err != nil {
t.Fatal(err)
}
if !almostEqual(res.Coefficients[0], 2, 1e-6) {
t.Errorf("intercept = %v, want 2", res.Coefficients[0])
}
if !almostEqual(res.Coefficients[1], 3, 1e-6) {
t.Errorf("slope = %v, want 3", res.Coefficients[1])
}
if !almostEqual(res.RSquared, 1, 1e-6) {
t.Errorf("R^2 = %v, want 1", res.RSquared)
}
}
// --- Solve dispatcher: the JSON-boundary layer the agent tool calls ---
func TestSolveEvaluate(t *testing.T) {
out, err := Solve("evaluate", map[string]any{
"expression": "x^2 + y^2",
"variables": map[string]any{"x": 3.0, "y": 4.0},
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, "25") {
t.Errorf("Solve(evaluate) = %q, want it to contain 25", out)
}
}
func TestSolveMatrixMul(t *testing.T) {
out, err := Solve("matrix_mul", map[string]any{
"a": []any{[]any{1.0, 2.0}, []any{3.0, 4.0}},
"b": []any{[]any{5.0, 6.0}, []any{7.0, 8.0}},
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, "19") || !strings.Contains(out, "50") {
t.Errorf("Solve(matrix_mul) = %q, missing expected entries", out)
}
}
func TestSolveUnknownOperation(t *testing.T) {
if _, err := Solve("not_a_real_operation", nil); err == nil {
t.Fatal("expected an error for an unknown operation")
}
}
func TestSolveGradientDescent(t *testing.T) {
out, err := Solve("gradient_descent", map[string]any{
"expression": "(x - 3)^2",
"variables": []any{"x"},
"start": []any{0.0},
"learning_rate": 0.1,
"iterations": 200.0,
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, "converged=true") {
t.Errorf("Solve(gradient_descent) = %q, expected convergence", out)
}
}
func TestSolveMissingField(t *testing.T) {
_, err := Solve("mean", map[string]any{})
if err == nil {
t.Fatal("expected a missing-field error")
}
if !strings.Contains(err.Error(), "data") {
t.Errorf("error %q should mention the missing field", err.Error())
}
}
func TestOperationsListIsSorted(t *testing.T) {
ops := Operations()
if len(ops) == 0 {
t.Fatal("Operations() returned nothing")
}
for i := 1; i < len(ops); i++ {
if ops[i-1] > ops[i] {
t.Fatalf("Operations() not sorted: %q before %q", ops[i-1], ops[i])
}
}
}

191
pkg/mathkit/ml.go Normal file
View File

@@ -0,0 +1,191 @@
package mathkit
import (
"fmt"
"math"
)
// Sigmoid is the logistic function 1 / (1 + e^-x), computed in the
// numerically stable branch-on-sign form so it doesn't overflow
// math.Exp for very negative x (a plain 1/(1+exp(-x)) blows up
// exp(-x) to +Inf for x below about -745 and silently returns 0
// instead of propagating the overflow visibly).
func Sigmoid(x float64) float64 {
if x >= 0 {
z := math.Exp(-x)
return 1 / (1 + z)
}
z := math.Exp(x)
return z / (1 + z)
}
// ReLU is max(0, x).
func ReLU(x float64) float64 {
if x > 0 {
return x
}
return 0
}
// Softmax turns a vector of raw scores ("logits") into a probability
// distribution: non-negative entries that sum to 1. Subtracts the max
// before exponentiating (the standard numerically-stable formulation)
// so large logits don't overflow math.Exp.
func Softmax(x []float64) []float64 {
if len(x) == 0 {
return nil
}
max := x[0]
for _, v := range x[1:] {
if v > max {
max = v
}
}
out := make([]float64, len(x))
var sum float64
for i, v := range x {
e := math.Exp(v - max)
out[i] = e
sum += e
}
for i := range out {
out[i] /= sum
}
return out
}
// MSELoss returns the mean squared error between predictions and
// targets: mean((pred - target)^2), the default regression loss.
func MSELoss(predictions, targets []float64) (float64, error) {
if len(predictions) != len(targets) {
return 0, fmt.Errorf("mse_loss: predictions and targets have different lengths (%d vs %d)", len(predictions), len(targets))
}
if len(predictions) == 0 {
return 0, fmt.Errorf("mse_loss: empty input")
}
var sum float64
for i := range predictions {
d := predictions[i] - targets[i]
sum += d * d
}
return sum / float64(len(predictions)), nil
}
// crossEntropyEps clamps predicted probabilities away from exactly 0
// or 1 before taking a log — ln(0) is -Inf, which would make a single
// confidently-wrong prediction blow up the whole averaged loss to
// +Inf instead of just contributing a large-but-finite penalty. Every
// production cross-entropy implementation (e.g. scikit-learn's
// log_loss) clips for exactly this reason.
const crossEntropyEps = 1e-12
// CrossEntropyLoss returns the mean binary cross-entropy between
// predicted probabilities and 0/1 (or soft, in [0,1]) targets:
// mean(-(y*ln(p) + (1-y)*ln(1-p))), the default classification loss.
func CrossEntropyLoss(predictions, targets []float64) (float64, error) {
if len(predictions) != len(targets) {
return 0, fmt.Errorf("cross_entropy_loss: predictions and targets have different lengths (%d vs %d)", len(predictions), len(targets))
}
if len(predictions) == 0 {
return 0, fmt.Errorf("cross_entropy_loss: empty input")
}
var sum float64
for i, p := range predictions {
if p < 0 || p > 1 {
return 0, fmt.Errorf("cross_entropy_loss: predictions must be probabilities in [0, 1], got %g at index %d", p, i)
}
p = math.Min(math.Max(p, crossEntropyEps), 1-crossEntropyEps)
y := targets[i]
sum += -(y*math.Log(p) + (1-y)*math.Log(1-p))
}
return sum / float64(len(predictions)), nil
}
// LinearRegressionResult is a fitted ordinary-least-squares linear
// model.
type LinearRegressionResult struct {
// Coefficients[0] is the intercept, Coefficients[1:] line up with
// the feature columns of X in order.
Coefficients []float64
Predictions []float64
Residuals []float64
RSquared float64
}
// LinearRegression fits y ≈ X·β + intercept by ordinary least squares
// via the normal equations β = (XᵀX)^-1 Xᵀy, with an intercept column
// of 1s prepended automatically (the caller supplies only the real
// feature columns in X). The normal-equation approach is the direct,
// closed-form solution to minimizing squared error — the textbook
// ML-101 regression fit, as opposed to reaching it iteratively via
// GradientDescent on the MSE loss.
func LinearRegression(X Matrix, y []float64) (*LinearRegressionResult, error) {
rows, cols, err := X.Dims()
if err != nil {
return nil, fmt.Errorf("linear_regression: %w", err)
}
if rows != len(y) {
return nil, fmt.Errorf("linear_regression: X has %d rows but y has %d entries", rows, len(y))
}
if rows <= cols {
return nil, fmt.Errorf("linear_regression: need more observations (%d) than features+1 (%d) to fit uniquely", rows, cols+1)
}
// Prepend the intercept column of 1s.
design := make(Matrix, rows)
for i := 0; i < rows; i++ {
row := make([]float64, cols+1)
row[0] = 1
copy(row[1:], X[i])
design[i] = row
}
designT, err := Transpose(design)
if err != nil {
return nil, fmt.Errorf("linear_regression: %w", err)
}
xtx, err := MatMul(designT, design)
if err != nil {
return nil, fmt.Errorf("linear_regression: %w", err)
}
xtxInv, err := Inverse(xtx)
if err != nil {
return nil, fmt.Errorf("linear_regression: features are collinear (XᵀX is singular): %w", err)
}
xty, err := MatVecMul(designT, y)
if err != nil {
return nil, fmt.Errorf("linear_regression: %w", err)
}
beta, err := MatVecMul(xtxInv, xty)
if err != nil {
return nil, fmt.Errorf("linear_regression: %w", err)
}
preds, err := MatVecMul(design, beta)
if err != nil {
return nil, fmt.Errorf("linear_regression: %w", err)
}
residuals := make([]float64, rows)
var ssRes float64
for i := range preds {
residuals[i] = y[i] - preds[i]
ssRes += residuals[i] * residuals[i]
}
meanY, _ := Mean(y)
var ssTot float64
for _, v := range y {
d := v - meanY
ssTot += d * d
}
rSquared := 1.0
if ssTot > 0 {
rSquared = 1 - ssRes/ssTot
}
return &LinearRegressionResult{
Coefficients: beta,
Predictions: preds,
Residuals: residuals,
RSquared: rSquared,
}, nil
}

135
pkg/mathkit/optimize.go Normal file
View File

@@ -0,0 +1,135 @@
package mathkit
import (
"fmt"
"math"
)
// GradientDescentResult is the trace of one gradient-descent run.
type GradientDescentResult struct {
// Path holds the point visited at the start of every iteration,
// Path[0] being the caller's starting point and the last entry
// being the final point — so len(Path) == Iterations+1. Kept in
// full (rather than just the final point) because seeing the
// trajectory is usually the point of running this: it's how you
// tell "converging nicely" apart from "diverging" or
// "oscillating because the learning rate is too high".
Path [][]float64
// Values holds f(Path[i]) for each point in Path, same length.
Values []float64
// Converged is true if the gradient norm dropped below the
// requested tolerance before Iterations ran out.
Converged bool
}
// GradientDescent runs plain (batch) gradient descent on expr, over
// the variables listed (in order — that order defines the vector
// space the descent happens in), starting at `start`, for at most
// `iterations` steps of size `learningRate`. tol<=0 disables the
// early-stop check (it always runs the full iteration count).
//
// This is deliberately vanilla gradient descent — no momentum, no
// Adam-style adaptive rates — because the point of this tool is to
// let a user see and reason about the bare update rule
// x := x - lr * ∇f(x) that every fancier optimizer builds on, not to
// re-implement a training framework.
func GradientDescent(expr *Expr, variables []string, start []float64, learningRate float64, iterations int, tol float64) (*GradientDescentResult, error) {
if len(variables) != len(start) {
return nil, fmt.Errorf("gradient_descent: %d variable name(s) but start point has %d coordinate(s)", len(variables), len(start))
}
if learningRate <= 0 {
return nil, fmt.Errorf("gradient_descent: learning_rate must be positive")
}
if iterations <= 0 {
iterations = 100
}
point := append([]float64(nil), start...)
res := &GradientDescentResult{}
addPoint := func(p []float64) error {
vars := make(map[string]float64, len(variables))
for i, name := range variables {
vars[name] = p[i]
}
v, err := expr.Eval(vars)
if err != nil {
return err
}
res.Path = append(res.Path, append([]float64(nil), p...))
res.Values = append(res.Values, v)
return nil
}
if err := addPoint(point); err != nil {
return nil, fmt.Errorf("gradient_descent: evaluating start point: %w", err)
}
vars := make(map[string]float64, len(variables))
for iter := 0; iter < iterations; iter++ {
for i, name := range variables {
vars[name] = point[i]
}
grad, err := Gradient(expr, variables, vars, 0)
if err != nil {
return nil, fmt.Errorf("gradient_descent: iteration %d: %w", iter, err)
}
if tol > 0 && Norm(grad, 2) < tol {
res.Converged = true
break
}
for i := range point {
point[i] -= learningRate * grad[i]
}
if err := addPoint(point); err != nil {
return nil, fmt.Errorf("gradient_descent: iteration %d: %w", iter, err)
}
if math.IsNaN(point[0]) || math.IsInf(point[0], 0) {
return nil, fmt.Errorf("gradient_descent: diverged (values went to NaN/Inf) — try a smaller learning_rate")
}
}
return res, nil
}
// NewtonRootResult is the trace of one Newton's-method root-finding
// run on a single-variable expression.
type NewtonRootResult struct {
Path []float64 // x_0, x_1, ..., x_final
Converged bool
}
// NewtonRoot finds a root of expr(variable) = 0 via Newton's method:
// x_{n+1} = x_n - f(x_n)/f'(x_n), with f' estimated by central
// difference (see Derivative). Any other free variables in expr must
// be supplied in `fixed`. tol<=0 defaults to 1e-10.
func NewtonRoot(expr *Expr, variable string, start float64, iterations int, tol float64, fixed map[string]float64) (*NewtonRootResult, error) {
if iterations <= 0 {
iterations = 100
}
if tol <= 0 {
tol = 1e-10
}
x := start
res := &NewtonRootResult{Path: []float64{x}}
vars := cloneVars(fixed)
for iter := 0; iter < iterations; iter++ {
vars[variable] = x
fx, err := expr.Eval(vars)
if err != nil {
return nil, fmt.Errorf("newton_root: iteration %d: %w", iter, err)
}
if math.Abs(fx) < tol {
res.Converged = true
break
}
dfx, err := Derivative(expr, variable, vars, 0)
if err != nil {
return nil, fmt.Errorf("newton_root: iteration %d: %w", iter, err)
}
if math.Abs(dfx) < 1e-14 {
return nil, fmt.Errorf("newton_root: derivative vanished near x=%g (flat spot) — try a different start", x)
}
x = x - fx/dfx
res.Path = append(res.Path, x)
if math.IsNaN(x) || math.IsInf(x, 0) {
return nil, fmt.Errorf("newton_root: diverged (x went to NaN/Inf) — try a different start")
}
}
return res, nil
}

835
pkg/mathkit/solve.go Normal file
View File

@@ -0,0 +1,835 @@
package mathkit
import (
"fmt"
"math"
"sort"
"strings"
)
// Solve dispatches a math problem by string operation name against a
// loosely-typed params map — exactly the shape a JSON object (an LLM
// tool-call argument blob, decoded with encoding/json into
// map[string]any) takes once decoded. It exists as one layer above
// the typed functions in linalg.go/calculus.go/statistics.go/
// optimize.go/ml.go specifically so a caller at the JSON boundary
// (services/agent/internal/infrastructure/toolexec/mlmath.go) has one
// function to call and one error type to handle, instead of a type
// switch per operation.
//
// Returns a human-readable report (not raw JSON or a bare float) on
// success, because the caller here is ultimately an LLM deciding what
// to do next — a labeled, explained answer is directly usable in its
// response, where a bare number would have to be re-explained.
func Solve(operation string, params map[string]any) (string, error) {
op := strings.ToLower(strings.TrimSpace(operation))
switch op {
// --- general ---
case "evaluate":
return solveEvaluate(params)
// --- linear algebra ---
case "matrix_add", "matrix_sub", "matrix_mul":
return solveMatrixBinOp(op, params)
case "matrix_transpose":
return solveMatrixTranspose(params)
case "matrix_determinant":
return solveMatrixDeterminant(params)
case "matrix_inverse":
return solveMatrixInverse(params)
case "matrix_rank":
return solveMatrixRank(params)
case "matrix_trace":
return solveMatrixTrace(params)
case "dot_product":
return solveDotProduct(params)
case "vector_norm":
return solveVectorNorm(params)
case "dominant_eigen":
return solveDominantEigen(params)
// --- calculus ---
case "derivative":
return solveDerivative(params)
case "gradient":
return solveGradient(params)
case "integral":
return solveIntegral(params)
// --- optimization ---
case "gradient_descent":
return solveGradientDescent(params)
case "newton_root":
return solveNewtonRoot(params)
// --- statistics & probability ---
case "mean":
return solveReduce1(params, "mean", func(d []float64) (float64, error) { return Mean(d) })
case "variance":
return solveReduce1Pop(params, "variance", Variance)
case "std_dev":
return solveReduce1Pop(params, "std_dev", StdDev)
case "median":
return solveReduce1(params, "median", Median)
case "covariance":
return solveReduce2Pop(params, "covariance", Covariance)
case "correlation":
return solveReduce2(params, "correlation", Correlation)
case "z_score_normalize":
return solveNormalize(params, "z_score_normalize", ZScoreNormalize)
case "min_max_normalize":
return solveNormalize(params, "min_max_normalize", MinMaxNormalize)
case "normal_pdf":
return solveNormalDist(params, "normal_pdf", NormalPDF)
case "normal_cdf":
return solveNormalDist(params, "normal_cdf", NormalCDF)
case "binomial_pmf":
return solveBinomialPMF(params)
// --- ML primitives ---
case "sigmoid":
return solveElementwise(params, "sigmoid", Sigmoid)
case "relu":
return solveElementwise(params, "relu", ReLU)
case "tanh":
return solveElementwise(params, "tanh", math.Tanh)
case "softmax":
return solveSoftmax(params)
case "mse_loss":
return solveLoss(params, "mse_loss", MSELoss)
case "cross_entropy_loss":
return solveLoss(params, "cross_entropy_loss", CrossEntropyLoss)
case "linear_regression":
return solveLinearRegression(params)
default:
return "", fmt.Errorf("unknown operation %q — see the tool description for the full list of supported operations", operation)
}
}
// ---------- param extraction helpers ----------
//
// Every helper below reports a specific, actionable error (which
// field was wrong and why) rather than a generic type-assertion
// panic recovery message, because these errors are fed straight back
// to the calling model as the tool result — see toolexec's rpcFail /
// IsError convention — and "field X must be a number, got a string"
// is something the model can act on; "interface conversion failed"
// is not.
func reqFloat(params map[string]any, key string) (float64, error) {
v, ok := params[key]
if !ok {
return 0, fmt.Errorf("missing required field %q", key)
}
f, ok := toFloat(v)
if !ok {
return 0, fmt.Errorf("field %q must be a number", key)
}
return f, nil
}
func optFloat(params map[string]any, key string, fallback float64) float64 {
v, ok := params[key]
if !ok {
return fallback
}
if f, ok := toFloat(v); ok {
return f
}
return fallback
}
func optInt(params map[string]any, key string, fallback int) int {
v, ok := params[key]
if !ok {
return fallback
}
if f, ok := toFloat(v); ok {
return int(f)
}
return fallback
}
func optBool(params map[string]any, key string, fallback bool) bool {
v, ok := params[key]
if !ok {
return fallback
}
if b, ok := v.(bool); ok {
return b
}
return fallback
}
func toFloat(v any) (float64, bool) {
switch n := v.(type) {
case float64:
return n, true
case int:
return float64(n), true
case int64:
return float64(n), true
}
return 0, false
}
func reqString(params map[string]any, key string) (string, error) {
v, ok := params[key]
if !ok {
return "", fmt.Errorf("missing required field %q", key)
}
s, ok := v.(string)
if !ok || strings.TrimSpace(s) == "" {
return "", fmt.Errorf("field %q must be a non-empty string", key)
}
return s, nil
}
func optString(params map[string]any, key, fallback string) string {
v, ok := params[key]
if !ok {
return fallback
}
s, ok := v.(string)
if !ok {
return fallback
}
return s
}
func reqFloatSlice(params map[string]any, key string) ([]float64, error) {
v, ok := params[key]
if !ok {
return nil, fmt.Errorf("missing required field %q (expected an array of numbers)", key)
}
raw, ok := v.([]any)
if !ok {
return nil, fmt.Errorf("field %q must be an array of numbers", key)
}
out := make([]float64, len(raw))
for i, x := range raw {
f, ok := toFloat(x)
if !ok {
return nil, fmt.Errorf("field %q: element %d is not a number", key, i)
}
out[i] = f
}
return out, nil
}
func reqStringSlice(params map[string]any, key string) ([]string, error) {
v, ok := params[key]
if !ok {
return nil, fmt.Errorf("missing required field %q (expected an array of strings)", key)
}
raw, ok := v.([]any)
if !ok {
return nil, fmt.Errorf("field %q must be an array of strings", key)
}
out := make([]string, len(raw))
for i, x := range raw {
s, ok := x.(string)
if !ok {
return nil, fmt.Errorf("field %q: element %d is not a string", key, i)
}
out[i] = s
}
return out, nil
}
func reqMatrix(params map[string]any, key string) (Matrix, error) {
v, ok := params[key]
if !ok {
return nil, fmt.Errorf("missing required field %q (expected a 2D array of numbers)", key)
}
rows, ok := v.([]any)
if !ok {
return nil, fmt.Errorf("field %q must be a 2D array of numbers, e.g. [[1,2],[3,4]]", key)
}
m := make(Matrix, len(rows))
for i, r := range rows {
rowRaw, ok := r.([]any)
if !ok {
return nil, fmt.Errorf("field %q: row %d is not an array", key, i)
}
row := make([]float64, len(rowRaw))
for j, x := range rowRaw {
f, ok := toFloat(x)
if !ok {
return nil, fmt.Errorf("field %q: row %d, column %d is not a number", key, i, j)
}
row[j] = f
}
m[i] = row
}
return m, nil
}
// varMap builds the variable bindings an expression needs from an
// "at" (or "variables") object field — {"x": 1, "y": 2} — falling
// back to an empty map if the field is absent (a single-variable
// expression evaluated via the "variable"/"at" pair still works).
func varMap(params map[string]any, key string) (map[string]float64, error) {
v, ok := params[key]
if !ok {
return map[string]float64{}, nil
}
raw, ok := v.(map[string]any)
if !ok {
return nil, fmt.Errorf("field %q must be an object mapping variable names to numbers, e.g. {\"x\": 1.5}", key)
}
out := make(map[string]float64, len(raw))
for k, x := range raw {
f, ok := toFloat(x)
if !ok {
return nil, fmt.Errorf("field %q: value for %q is not a number", key, k)
}
out[k] = f
}
return out, nil
}
func parseExprField(params map[string]any) (*Expr, error) {
src, err := reqString(params, "expression")
if err != nil {
return nil, err
}
expr, err := ParseExpr(src)
if err != nil {
return nil, err
}
return expr, nil
}
func fmtFloat(f float64) string {
if math.IsNaN(f) {
return "NaN"
}
if math.IsInf(f, 1) {
return "+Inf"
}
if math.IsInf(f, -1) {
return "-Inf"
}
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.10f", f), "0"), ".")
}
func fmtFloats(fs []float64) string {
parts := make([]string, len(fs))
for i, f := range fs {
parts[i] = fmtFloat(f)
}
return "[" + strings.Join(parts, ", ") + "]"
}
func fmtMatrix(m Matrix) string {
var sb strings.Builder
for _, row := range m {
sb.WriteString(fmtFloats(row))
sb.WriteByte('\n')
}
return strings.TrimRight(sb.String(), "\n")
}
// ---------- operation implementations ----------
func solveEvaluate(params map[string]any) (string, error) {
expr, err := parseExprField(params)
if err != nil {
return "", err
}
vars, err := varMap(params, "variables")
if err != nil {
return "", err
}
result, err := expr.Eval(vars)
if err != nil {
return "", err
}
return fmt.Sprintf("%s = %s", expr.String(), fmtFloat(result)), nil
}
func solveMatrixBinOp(op string, params map[string]any) (string, error) {
a, err := reqMatrix(params, "a")
if err != nil {
return "", err
}
b, err := reqMatrix(params, "b")
if err != nil {
return "", err
}
var out Matrix
switch op {
case "matrix_add":
out, err = MatAdd(a, b)
case "matrix_sub":
out, err = MatSub(a, b)
case "matrix_mul":
out, err = MatMul(a, b)
}
if err != nil {
return "", err
}
return fmt.Sprintf("%s result:\n%s", op, fmtMatrix(out)), nil
}
func solveMatrixTranspose(params map[string]any) (string, error) {
a, err := reqMatrix(params, "a")
if err != nil {
return "", err
}
out, err := Transpose(a)
if err != nil {
return "", err
}
return fmt.Sprintf("transpose result:\n%s", fmtMatrix(out)), nil
}
func solveMatrixDeterminant(params map[string]any) (string, error) {
a, err := reqMatrix(params, "a")
if err != nil {
return "", err
}
det, err := Determinant(a)
if err != nil {
return "", err
}
return fmt.Sprintf("det(A) = %s", fmtFloat(det)), nil
}
func solveMatrixInverse(params map[string]any) (string, error) {
a, err := reqMatrix(params, "a")
if err != nil {
return "", err
}
inv, err := Inverse(a)
if err != nil {
return "", err
}
return fmt.Sprintf("A^-1:\n%s", fmtMatrix(inv)), nil
}
func solveMatrixRank(params map[string]any) (string, error) {
a, err := reqMatrix(params, "a")
if err != nil {
return "", err
}
r, err := Rank(a)
if err != nil {
return "", err
}
return fmt.Sprintf("rank(A) = %d", r), nil
}
func solveMatrixTrace(params map[string]any) (string, error) {
a, err := reqMatrix(params, "a")
if err != nil {
return "", err
}
tr, err := Trace(a)
if err != nil {
return "", err
}
return fmt.Sprintf("trace(A) = %s", fmtFloat(tr)), nil
}
func solveDotProduct(params map[string]any) (string, error) {
a, err := reqFloatSlice(params, "a")
if err != nil {
return "", err
}
b, err := reqFloatSlice(params, "b")
if err != nil {
return "", err
}
d, err := Dot(a, b)
if err != nil {
return "", err
}
return fmt.Sprintf("a · b = %s", fmtFloat(d)), nil
}
func solveVectorNorm(params map[string]any) (string, error) {
a, err := reqFloatSlice(params, "a")
if err != nil {
return "", err
}
p := optFloat(params, "p", 2)
n := Norm(a, p)
label := fmt.Sprintf("L%g", p)
if math.IsInf(p, 1) {
label = "L∞"
}
return fmt.Sprintf("%s norm = %s", label, fmtFloat(n)), nil
}
func solveDominantEigen(params map[string]any) (string, error) {
a, err := reqMatrix(params, "a")
if err != nil {
return "", err
}
iters := optInt(params, "iterations", 0)
lambda, v, err := DominantEigen(a, iters)
if err != nil {
return "", err
}
return fmt.Sprintf("dominant eigenvalue ≈ %s\neigenvector ≈ %s", fmtFloat(lambda), fmtFloats(v)), nil
}
func solveDerivative(params map[string]any) (string, error) {
expr, err := parseExprField(params)
if err != nil {
return "", err
}
variable, err := reqString(params, "variable")
if err != nil {
return "", err
}
at, err := varMap(params, "at")
if err != nil {
return "", err
}
if _, ok := at[variable]; !ok {
at[variable] = optFloat(params, "value", 0)
}
h := optFloat(params, "h", 0)
d, err := Derivative(expr, variable, at, h)
if err != nil {
return "", err
}
return fmt.Sprintf("d/d%s[%s] at %s=%s ≈ %s", variable, expr.String(), variable, fmtFloat(at[variable]), fmtFloat(d)), nil
}
func solveGradient(params map[string]any) (string, error) {
expr, err := parseExprField(params)
if err != nil {
return "", err
}
variables, err := reqStringSlice(params, "variables")
if err != nil {
return "", err
}
at, err := varMap(params, "at")
if err != nil {
return "", err
}
h := optFloat(params, "h", 0)
grad, err := Gradient(expr, variables, at, h)
if err != nil {
return "", err
}
return fmt.Sprintf("∇[%s] with respect to %s ≈ %s", expr.String(), strings.Join(variables, ","), fmtFloats(grad)), nil
}
func solveIntegral(params map[string]any) (string, error) {
expr, err := parseExprField(params)
if err != nil {
return "", err
}
variable, err := reqString(params, "variable")
if err != nil {
return "", err
}
from, err := reqFloat(params, "from")
if err != nil {
return "", err
}
to, err := reqFloat(params, "to")
if err != nil {
return "", err
}
steps := optInt(params, "steps", 0)
fixed, err := varMap(params, "variables")
if err != nil {
return "", err
}
result, err := Integrate(expr, variable, from, to, steps, fixed)
if err != nil {
return "", err
}
return fmt.Sprintf("∫[%s to %s] %s d%s ≈ %s", fmtFloat(from), fmtFloat(to), expr.String(), variable, fmtFloat(result)), nil
}
func solveGradientDescent(params map[string]any) (string, error) {
expr, err := parseExprField(params)
if err != nil {
return "", err
}
variables, err := reqStringSlice(params, "variables")
if err != nil {
return "", err
}
start, err := reqFloatSlice(params, "start")
if err != nil {
return "", err
}
lr, err := reqFloat(params, "learning_rate")
if err != nil {
return "", err
}
iterations := optInt(params, "iterations", 100)
tol := optFloat(params, "tolerance", 1e-8)
res, err := GradientDescent(expr, variables, start, lr, iterations, tol)
if err != nil {
return "", err
}
last := len(res.Path) - 1
var sb strings.Builder
fmt.Fprintf(&sb, "gradient descent on f(%s) = %s\n", strings.Join(variables, ","), expr.String())
fmt.Fprintf(&sb, "start: %s -> f = %s\n", fmtFloats(res.Path[0]), fmtFloat(res.Values[0]))
fmt.Fprintf(&sb, "ran %d step(s), converged=%v\n", last, res.Converged)
fmt.Fprintf(&sb, "final: %s -> f = %s\n", fmtFloats(res.Path[last]), fmtFloat(res.Values[last]))
// Show a short trajectory sample so the model can see the descent
// shape without a huge wall of numbers for large iteration counts.
sampleN := 5
if last+1 <= sampleN*2 {
for i := 0; i <= last; i++ {
fmt.Fprintf(&sb, " step %d: x=%s f=%s\n", i, fmtFloats(res.Path[i]), fmtFloat(res.Values[i]))
}
} else {
for i := 0; i < sampleN; i++ {
fmt.Fprintf(&sb, " step %d: x=%s f=%s\n", i, fmtFloats(res.Path[i]), fmtFloat(res.Values[i]))
}
sb.WriteString(" ...\n")
for i := last - sampleN + 1; i <= last; i++ {
fmt.Fprintf(&sb, " step %d: x=%s f=%s\n", i, fmtFloats(res.Path[i]), fmtFloat(res.Values[i]))
}
}
return strings.TrimRight(sb.String(), "\n"), nil
}
func solveNewtonRoot(params map[string]any) (string, error) {
expr, err := parseExprField(params)
if err != nil {
return "", err
}
variable, err := reqString(params, "variable")
if err != nil {
return "", err
}
start, err := reqFloat(params, "start")
if err != nil {
return "", err
}
iterations := optInt(params, "iterations", 0)
tol := optFloat(params, "tolerance", 0)
fixed, err := varMap(params, "variables")
if err != nil {
return "", err
}
res, err := NewtonRoot(expr, variable, start, iterations, tol, fixed)
if err != nil {
return "", err
}
last := res.Path[len(res.Path)-1]
return fmt.Sprintf("newton's method on %s(%s)=0: converged=%v after %d step(s), root ≈ %s=%s",
expr.String(), variable, res.Converged, len(res.Path)-1, variable, fmtFloat(last)), nil
}
func solveReduce1(params map[string]any, name string, f func([]float64) (float64, error)) (string, error) {
data, err := reqFloatSlice(params, "data")
if err != nil {
return "", err
}
v, err := f(data)
if err != nil {
return "", err
}
return fmt.Sprintf("%s(%s) = %s", name, fmtFloats(data), fmtFloat(v)), nil
}
func solveReduce1Pop(params map[string]any, name string, f func([]float64, bool) (float64, error)) (string, error) {
data, err := reqFloatSlice(params, "data")
if err != nil {
return "", err
}
population := optBool(params, "population", false)
v, err := f(data, population)
if err != nil {
return "", err
}
kind := "sample"
if population {
kind = "population"
}
return fmt.Sprintf("%s %s(%s) = %s", kind, name, fmtFloats(data), fmtFloat(v)), nil
}
func solveReduce2(params map[string]any, name string, f func([]float64, []float64) (float64, error)) (string, error) {
x, err := reqFloatSlice(params, "x")
if err != nil {
return "", err
}
y, err := reqFloatSlice(params, "y")
if err != nil {
return "", err
}
v, err := f(x, y)
if err != nil {
return "", err
}
return fmt.Sprintf("%s(x, y) = %s", name, fmtFloat(v)), nil
}
func solveReduce2Pop(params map[string]any, name string, f func([]float64, []float64, bool) (float64, error)) (string, error) {
x, err := reqFloatSlice(params, "x")
if err != nil {
return "", err
}
y, err := reqFloatSlice(params, "y")
if err != nil {
return "", err
}
population := optBool(params, "population", false)
v, err := f(x, y, population)
if err != nil {
return "", err
}
kind := "sample"
if population {
kind = "population"
}
return fmt.Sprintf("%s %s(x, y) = %s", kind, name, fmtFloat(v)), nil
}
func solveNormalize(params map[string]any, name string, f func([]float64) ([]float64, error)) (string, error) {
data, err := reqFloatSlice(params, "data")
if err != nil {
return "", err
}
out, err := f(data)
if err != nil {
return "", err
}
return fmt.Sprintf("%s(%s) = %s", name, fmtFloats(data), fmtFloats(out)), nil
}
func solveNormalDist(params map[string]any, name string, f func(float64, float64, float64) (float64, error)) (string, error) {
x, err := reqFloat(params, "x")
if err != nil {
return "", err
}
mean := optFloat(params, "mean", 0)
std := optFloat(params, "std_dev", 1)
v, err := f(x, mean, std)
if err != nil {
return "", err
}
return fmt.Sprintf("%s(x=%s, mean=%s, std_dev=%s) = %s", name, fmtFloat(x), fmtFloat(mean), fmtFloat(std), fmtFloat(v)), nil
}
func solveBinomialPMF(params map[string]any) (string, error) {
k := optInt(params, "k", -1)
if k < 0 {
return "", fmt.Errorf("missing required field %q", "k")
}
n := optInt(params, "n", -1)
if n < 0 {
return "", fmt.Errorf("missing required field %q", "n")
}
p, err := reqFloat(params, "p")
if err != nil {
return "", err
}
v, err := BinomialPMF(k, n, p)
if err != nil {
return "", err
}
return fmt.Sprintf("P(X=%d) for X~Binomial(n=%d, p=%s) = %s", k, n, fmtFloat(p), fmtFloat(v)), nil
}
func solveElementwise(params map[string]any, name string, f func(float64) float64) (string, error) {
if raw, ok := params["x"].([]any); ok {
data := make([]float64, len(raw))
for i, v := range raw {
fv, ok := toFloat(v)
if !ok {
return "", fmt.Errorf("field \"x\": element %d is not a number", i)
}
data[i] = fv
}
out := make([]float64, len(data))
for i, v := range data {
out[i] = f(v)
}
return fmt.Sprintf("%s(%s) = %s", name, fmtFloats(data), fmtFloats(out)), nil
}
x, err := reqFloat(params, "x")
if err != nil {
return "", fmt.Errorf(`field "x" must be a number or an array of numbers: %w`, err)
}
return fmt.Sprintf("%s(%s) = %s", name, fmtFloat(x), fmtFloat(f(x))), nil
}
func solveSoftmax(params map[string]any) (string, error) {
x, err := reqFloatSlice(params, "x")
if err != nil {
return "", err
}
out := Softmax(x)
sum := 0.0
for _, v := range out {
sum += v
}
return fmt.Sprintf("softmax(%s) = %s (sums to %s)", fmtFloats(x), fmtFloats(out), fmtFloat(sum)), nil
}
func solveLoss(params map[string]any, name string, f func([]float64, []float64) (float64, error)) (string, error) {
pred, err := reqFloatSlice(params, "predictions")
if err != nil {
return "", err
}
target, err := reqFloatSlice(params, "targets")
if err != nil {
return "", err
}
v, err := f(pred, target)
if err != nil {
return "", err
}
return fmt.Sprintf("%s(predictions, targets) = %s", name, fmtFloat(v)), nil
}
func solveLinearRegression(params map[string]any) (string, error) {
X, err := reqMatrix(params, "x")
if err != nil {
return "", err
}
y, err := reqFloatSlice(params, "y")
if err != nil {
return "", err
}
res, err := LinearRegression(X, y)
if err != nil {
return "", err
}
var sb strings.Builder
fmt.Fprintf(&sb, "ordinary least squares fit: y ≈ %s", fmtFloat(res.Coefficients[0]))
for i, c := range res.Coefficients[1:] {
fmt.Fprintf(&sb, " + %s*x%d", fmtFloat(c), i+1)
}
fmt.Fprintf(&sb, "\nR² = %s\n", fmtFloat(res.RSquared))
fmt.Fprintf(&sb, "predictions: %s\n", fmtFloats(res.Predictions))
fmt.Fprintf(&sb, "residuals: %s", fmtFloats(res.Residuals))
return sb.String(), nil
}
// Operations returns the sorted list of every operation name Solve
// accepts — used by the agent's tool-catalog description so the
// supported operation list is generated from the real dispatch table
// instead of hand-copied prose that can drift out of sync with it.
func Operations() []string {
ops := []string{
"evaluate",
"matrix_add", "matrix_sub", "matrix_mul", "matrix_transpose", "matrix_determinant",
"matrix_inverse", "matrix_rank", "matrix_trace", "dot_product", "vector_norm", "dominant_eigen",
"derivative", "gradient", "integral",
"gradient_descent", "newton_root",
"mean", "variance", "std_dev", "median", "covariance", "correlation",
"z_score_normalize", "min_max_normalize", "normal_pdf", "normal_cdf", "binomial_pmf",
"sigmoid", "relu", "tanh", "softmax", "mse_loss", "cross_entropy_loss", "linear_regression",
}
sort.Strings(ops)
return ops
}

224
pkg/mathkit/statistics.go Normal file
View File

@@ -0,0 +1,224 @@
package mathkit
import (
"fmt"
"math"
)
// Mean returns the arithmetic mean of data.
func Mean(data []float64) (float64, error) {
if len(data) == 0 {
return 0, fmt.Errorf("mean: empty data set")
}
var sum float64
for _, v := range data {
sum += v
}
return sum / float64(len(data)), nil
}
// Variance returns the variance of data. population=true divides by
// n (the population variance); population=false (the default for ML
// work, where `data` is almost always a sample) divides by n-1
// (Bessel's correction, the unbiased sample-variance estimator).
func Variance(data []float64, population bool) (float64, error) {
n := len(data)
if n == 0 {
return 0, fmt.Errorf("variance: empty data set")
}
if !population && n < 2 {
return 0, fmt.Errorf("variance: sample variance needs at least 2 points (use population variance for a single point)")
}
mean, _ := Mean(data)
var ss float64
for _, v := range data {
d := v - mean
ss += d * d
}
denom := float64(n)
if !population {
denom = float64(n - 1)
}
return ss / denom, nil
}
// StdDev returns the standard deviation (sqrt of Variance).
func StdDev(data []float64, population bool) (float64, error) {
v, err := Variance(data, population)
if err != nil {
return 0, err
}
return math.Sqrt(v), nil
}
// Median returns the median of data (average of the two middle
// values for an even-length slice). Does not mutate the input.
func Median(data []float64) (float64, error) {
if len(data) == 0 {
return 0, fmt.Errorf("median: empty data set")
}
sorted := append([]float64(nil), data...)
insertionSort(sorted)
n := len(sorted)
if n%2 == 1 {
return sorted[n/2], nil
}
return (sorted[n/2-1] + sorted[n/2]) / 2, nil
}
// insertionSort is O(n^2) but this package only ever sorts small
// stats samples, where it beats sort.Float64s' overhead and keeps
// the package allocation-free apart from the clone itself.
func insertionSort(a []float64) {
for i := 1; i < len(a); i++ {
key := a[i]
j := i - 1
for j >= 0 && a[j] > key {
a[j+1] = a[j]
j--
}
a[j+1] = key
}
}
// Covariance returns the sample (or population) covariance of two
// equal-length data sets — the building block of both Correlation and
// a PCA covariance matrix.
func Covariance(x, y []float64, population bool) (float64, error) {
if len(x) != len(y) {
return 0, fmt.Errorf("covariance: data sets have different lengths (%d vs %d)", len(x), len(y))
}
n := len(x)
if n == 0 {
return 0, fmt.Errorf("covariance: empty data set")
}
if !population && n < 2 {
return 0, fmt.Errorf("covariance: sample covariance needs at least 2 points")
}
mx, _ := Mean(x)
my, _ := Mean(y)
var sum float64
for i := 0; i < n; i++ {
sum += (x[i] - mx) * (y[i] - my)
}
denom := float64(n)
if !population {
denom = float64(n - 1)
}
return sum / denom, nil
}
// Correlation returns the Pearson correlation coefficient of x and y,
// in [-1, 1].
func Correlation(x, y []float64) (float64, error) {
cov, err := Covariance(x, y, true)
if err != nil {
return 0, fmt.Errorf("correlation: %w", err)
}
sx, err := StdDev(x, true)
if err != nil {
return 0, fmt.Errorf("correlation: %w", err)
}
sy, err := StdDev(y, true)
if err != nil {
return 0, fmt.Errorf("correlation: %w", err)
}
if sx == 0 || sy == 0 {
return 0, fmt.Errorf("correlation: a constant data set has undefined correlation")
}
return cov / (sx * sy), nil
}
// ZScoreNormalize returns (x - mean) / stddev for each point — the
// standardization every ML feature-scaling pipeline applies before
// training a distance- or gradient-sensitive model.
func ZScoreNormalize(data []float64) ([]float64, error) {
mean, err := Mean(data)
if err != nil {
return nil, fmt.Errorf("z_score_normalize: %w", err)
}
std, err := StdDev(data, true)
if err != nil {
return nil, fmt.Errorf("z_score_normalize: %w", err)
}
if std == 0 {
return nil, fmt.Errorf("z_score_normalize: a constant data set has zero standard deviation")
}
out := make([]float64, len(data))
for i, v := range data {
out[i] = (v - mean) / std
}
return out, nil
}
// MinMaxNormalize rescales data to [0, 1].
func MinMaxNormalize(data []float64) ([]float64, error) {
if len(data) == 0 {
return nil, fmt.Errorf("min_max_normalize: empty data set")
}
lo, hi := data[0], data[0]
for _, v := range data {
if v < lo {
lo = v
}
if v > hi {
hi = v
}
}
if hi == lo {
return nil, fmt.Errorf("min_max_normalize: a constant data set has zero range")
}
out := make([]float64, len(data))
for i, v := range data {
out[i] = (v - lo) / (hi - lo)
}
return out, nil
}
// NormalPDF is the probability density function of N(mean, stdDev^2)
// at x.
func NormalPDF(x, mean, stdDev float64) (float64, error) {
if stdDev <= 0 {
return 0, fmt.Errorf("normal_pdf: std_dev must be positive")
}
z := (x - mean) / stdDev
return math.Exp(-0.5*z*z) / (stdDev * math.Sqrt(2*math.Pi)), nil
}
// NormalCDF is the cumulative distribution function of N(mean,
// stdDev^2) at x, computed via math.Erf (the standard closed form:
// Φ(x) = (1 + erf((x-μ)/(σ√2))) / 2).
func NormalCDF(x, mean, stdDev float64) (float64, error) {
if stdDev <= 0 {
return 0, fmt.Errorf("normal_cdf: std_dev must be positive")
}
return 0.5 * (1 + math.Erf((x-mean)/(stdDev*math.Sqrt2))), nil
}
// BinomialPMF returns P(X = k) for X ~ Binomial(n, p).
func BinomialPMF(k, n int, p float64) (float64, error) {
if n < 0 || k < 0 || k > n {
return 0, fmt.Errorf("binomial_pmf: require 0 <= k <= n, got k=%d n=%d", k, n)
}
if p < 0 || p > 1 {
return 0, fmt.Errorf("binomial_pmf: p must be in [0, 1], got %g", p)
}
return binomialCoeff(n, k) * math.Pow(p, float64(k)) * math.Pow(1-p, float64(n-k)), nil
}
// binomialCoeff computes C(n, k) via the multiplicative formula,
// which stays in float64 range far longer than computing n!/(k!(n-k)!)
// from raw factorials would.
func binomialCoeff(n, k int) float64 {
if k < 0 || k > n {
return 0
}
if k > n-k {
k = n - k
}
result := 1.0
for i := 0; i < k; i++ {
result *= float64(n-i) / float64(i+1)
}
return result
}