Files
enkly-ai/pkg/mathkit/ml.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

192 lines
5.5 KiB
Go

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
}