Files
enkly-ai/pkg/mathkit/optimize.go
enkly 79ce8dc4c0
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s
Init Project
2026-10-30 18:06:55 +01:00

136 lines
4.6 KiB
Go

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
}