Files
optimiz-rs/src/maths_toolkit.rs
T

697 lines
18 KiB
Rust
Raw Normal View History

//! Mathematical Toolkit
//!
//! Common mathematical operations used across optimization algorithms:
//! - Numerical differentiation (gradients, Hessians, Jacobians)
//! - Statistical functions (moments, correlations, distributions)
//! - Linear algebra utilities (matrix operations, decompositions)
//! - Numerical integration and interpolation
//! - Special functions and approximations
//!
//! This module provides generic, reusable mathematical operations
//! that are independent of any specific application domain.
use crate::core::{OptimizrError, OptimizrResult};
use ndarray::{Array1, Array2};
// ============================================================================
// Numerical Differentiation
// ============================================================================
/// Compute gradient using central finite differences
///
/// ∇f(x) ≈ [f(x + h·e_i) - f(x - h·e_i)] / (2h)
///
/// # Arguments
/// * `f` - Function to differentiate
/// * `x` - Point at which to compute gradient
/// * `h` - Step size (default: 1e-5)
///
/// # Returns
/// Gradient vector ∇f(x)
///
/// # Example
/// ```rust
/// use ndarray::array;
/// use optimizr::maths_toolkit::gradient;
///
/// // f(x,y) = x² + 2y²
/// let f = |x: &[f64]| x[0].powi(2) + 2.0 * x[1].powi(2);
/// let x = array![1.0, 2.0];
/// let grad = gradient(&f, &x, 1e-5).unwrap();
/// // grad ≈ [2.0, 8.0]
/// ```
pub fn gradient<F>(f: &F, x: &Array1<f64>, h: f64) -> OptimizrResult<Array1<f64>>
where
F: Fn(&[f64]) -> f64,
{
let n = x.len();
let mut grad = Array1::zeros(n);
let mut x_plus = x.to_vec();
let mut x_minus = x.to_vec();
for i in 0..n {
x_plus[i] = x[i] + h;
x_minus[i] = x[i] - h;
let f_plus = f(&x_plus);
let f_minus = f(&x_minus);
grad[i] = (f_plus - f_minus) / (2.0 * h);
// Reset for next iteration
x_plus[i] = x[i];
x_minus[i] = x[i];
}
Ok(grad)
}
/// Compute Hessian matrix using finite differences
///
/// H_ij = ∂²f/∂x_i∂x_j ≈ [f(x+h·e_i+h·e_j) - f(x+h·e_i-h·e_j) - f(x-h·e_i+h·e_j) + f(x-h·e_i-h·e_j)] / (4h²)
///
/// # Arguments
/// * `f` - Function to differentiate
/// * `x` - Point at which to compute Hessian
/// * `h` - Step size (default: 1e-4)
///
/// # Returns
/// Hessian matrix H(x)
pub fn hessian<F>(f: &F, x: &Array1<f64>, h: f64) -> OptimizrResult<Array2<f64>>
where
F: Fn(&[f64]) -> f64,
{
let n = x.len();
#[allow(non_snake_case)] // H is standard mathematical notation for Hessian
let mut H = Array2::zeros((n, n));
let mut x_work = x.to_vec();
for i in 0..n {
for j in 0..n {
if i == j {
// Diagonal: f''(x) ≈ [f(x+h) - 2f(x) + f(x-h)] / h²
x_work[i] = x[i] + h;
let f_plus = f(&x_work);
x_work[i] = x[i] - h;
let f_minus = f(&x_work);
x_work[i] = x[i];
let f_center = f(&x_work);
H[[i, i]] = (f_plus - 2.0 * f_center + f_minus) / (h * h);
} else {
// Off-diagonal: mixed partial derivative
x_work[i] = x[i] + h;
x_work[j] = x[j] + h;
let f_pp = f(&x_work);
x_work[j] = x[j] - h;
let f_pm = f(&x_work);
x_work[i] = x[i] - h;
let f_mm = f(&x_work);
x_work[j] = x[j] + h;
let f_mp = f(&x_work);
H[[i, j]] = (f_pp - f_pm - f_mp + f_mm) / (4.0 * h * h);
// Reset
x_work[i] = x[i];
x_work[j] = x[j];
}
}
}
Ok(H)
}
/// Compute Jacobian matrix for vector-valued function
///
/// J_ij = ∂f_i/∂x_j
///
/// # Arguments
/// * `f` - Vector-valued function f: ℝⁿ → ℝᵐ
/// * `x` - Point at which to compute Jacobian
/// * `h` - Step size
///
/// # Returns
/// Jacobian matrix J(x) of shape (m, n)
pub fn jacobian<F>(f: &F, x: &Array1<f64>, h: f64) -> OptimizrResult<Array2<f64>>
where
F: Fn(&[f64]) -> Vec<f64>,
{
let n = x.len();
let f_x = f(&x.to_vec());
let m = f_x.len();
#[allow(non_snake_case)] // J is standard mathematical notation for Jacobian
let mut J = Array2::zeros((m, n));
let mut x_work = x.to_vec();
for j in 0..n {
x_work[j] = x[j] + h;
let f_plus = f(&x_work);
x_work[j] = x[j] - h;
let f_minus = f(&x_work);
for i in 0..m {
J[[i, j]] = (f_plus[i] - f_minus[i]) / (2.0 * h);
}
x_work[j] = x[j];
}
Ok(J)
}
// ============================================================================
// Statistical Functions
// ============================================================================
/// Compute mean of a data series
#[inline]
pub fn mean(data: &Array1<f64>) -> f64 {
if data.is_empty() {
return 0.0;
}
data.sum() / data.len() as f64
}
/// Compute variance with optional Bessel's correction
#[inline]
pub fn variance(data: &Array1<f64>, ddof: usize) -> f64 {
if data.len() <= ddof {
return 0.0;
}
let m = mean(data);
let sum_sq: f64 = data.iter().map(|&x| (x - m).powi(2)).sum();
sum_sq / (data.len() - ddof) as f64
}
/// Compute standard deviation
#[inline]
pub fn std_dev(data: &Array1<f64>, ddof: usize) -> f64 {
variance(data, ddof).sqrt()
}
/// Compute skewness (3rd standardized moment)
///
/// Skewness = E[(X - μ)³] / σ³
///
/// - Skewness > 0: Right-skewed (long right tail)
/// - Skewness < 0: Left-skewed (long left tail)
/// - Skewness ≈ 0: Symmetric
pub fn skewness(data: &Array1<f64>) -> f64 {
if data.len() < 3 {
return 0.0;
}
let m = mean(data);
let std = std_dev(data, 1);
if std < 1e-10 {
return 0.0;
}
let n = data.len() as f64;
let sum_cubed: f64 = data.iter().map(|&x| ((x - m) / std).powi(3)).sum();
sum_cubed / n
}
/// Compute kurtosis (4th standardized moment)
///
/// Kurtosis = E[(X - μ)⁴] / σ⁴ - 3 (excess kurtosis)
///
/// - Kurtosis > 0: Heavy tails (leptokurtic)
/// - Kurtosis < 0: Light tails (platykurtic)
/// - Kurtosis ≈ 0: Normal-like tails (mesokurtic)
pub fn kurtosis(data: &Array1<f64>) -> f64 {
if data.len() < 4 {
return 0.0;
}
let m = mean(data);
let std = std_dev(data, 1);
if std < 1e-10 {
return 0.0;
}
let n = data.len() as f64;
let sum_fourth: f64 = data.iter().map(|&x| ((x - m) / std).powi(4)).sum();
(sum_fourth / n) - 3.0 // Excess kurtosis
}
/// Compute autocorrelation at lag k
///
/// ρ(k) = Cov(X_t, X_{t-k}) / Var(X_t)
///
/// # Arguments
/// * `data` - Time series
/// * `lag` - Lag value k
///
/// # Returns
/// Autocorrelation coefficient ρ(k) ∈ [-1, 1]
pub fn autocorrelation(data: &Array1<f64>, lag: usize) -> f64 {
if lag >= data.len() {
return 0.0;
}
let n = data.len();
let m = mean(data);
let var = variance(data, 0);
if var < 1e-10 {
return 0.0;
}
let mut sum = 0.0;
for i in lag..n {
sum += (data[i] - m) * (data[i - lag] - m);
}
sum / ((n - lag) as f64 * var)
}
/// Compute full autocorrelation function up to max_lag
///
/// Returns ACF values [ρ(0), ρ(1), ..., ρ(max_lag)]
pub fn acf(data: &Array1<f64>, max_lag: usize) -> Array1<f64> {
let lags = (0..=max_lag.min(data.len() - 1))
.map(|k| autocorrelation(data, k))
.collect();
Array1::from_vec(lags)
}
/// Compute correlation between two series
///
/// ρ(X,Y) = Cov(X,Y) / (σ_X · σ_Y)
pub fn correlation(x: &Array1<f64>, y: &Array1<f64>) -> OptimizrResult<f64> {
if x.len() != y.len() {
return Err(OptimizrError::InvalidInput(
"Series must have same length".to_string(),
));
}
if x.len() < 2 {
return Err(OptimizrError::InvalidInput(
"Need at least 2 points".to_string(),
));
}
let mx = mean(x);
let my = mean(y);
let sx = std_dev(x, 1);
let sy = std_dev(y, 1);
if sx < 1e-10 || sy < 1e-10 {
return Ok(0.0);
}
let cov: f64 = x
.iter()
.zip(y.iter())
.map(|(&xi, &yi)| (xi - mx) * (yi - my))
.sum::<f64>()
/ (x.len() - 1) as f64;
Ok(cov / (sx * sy))
}
/// Compute correlation matrix for multiple series
///
/// # Arguments
/// * `data` - Matrix where each column is a time series
///
/// # Returns
/// Correlation matrix C where C_ij = ρ(X_i, X_j)
pub fn correlation_matrix(data: &Array2<f64>) -> OptimizrResult<Array2<f64>> {
let n_series = data.ncols();
let mut corr_mat = Array2::eye(n_series);
for i in 0..n_series {
for j in (i + 1)..n_series {
let col_i = data.column(i).to_owned();
let col_j = data.column(j).to_owned();
let rho = correlation(&col_i, &col_j)?;
corr_mat[[i, j]] = rho;
corr_mat[[j, i]] = rho;
}
}
Ok(corr_mat)
}
// ============================================================================
// Linear Algebra Utilities
// ============================================================================
/// Compute matrix norm (Frobenius norm by default)
///
/// ||A||_F = sqrt(Σ_ij a_ij²)
pub fn matrix_norm(matrix: &Array2<f64>) -> f64 {
matrix.iter().map(|&x| x * x).sum::<f64>().sqrt()
}
/// Compute vector L2 norm
///
/// ||x||_2 = sqrt(Σ_i x_i²)
#[inline]
pub fn vector_norm(vec: &Array1<f64>) -> f64 {
vec.iter().map(|&x| x * x).sum::<f64>().sqrt()
}
/// Compute vector L1 norm
///
/// ||x||_1 = Σ_i |x_i|
#[inline]
pub fn vector_norm_l1(vec: &Array1<f64>) -> f64 {
vec.iter().map(|&x| x.abs()).sum()
}
/// Compute vector L∞ norm (maximum absolute value)
///
/// ||x||_∞ = max_i |x_i|
#[inline]
pub fn vector_norm_linf(vec: &Array1<f64>) -> f64 {
vec.iter().map(|&x| x.abs()).fold(0.0, f64::max)
}
/// Normalize vector to unit length
///
/// Returns x / ||x||_2
pub fn normalize(vec: &Array1<f64>) -> OptimizrResult<Array1<f64>> {
let norm = vector_norm(vec);
if norm < 1e-10 {
return Err(OptimizrError::InvalidInput(
"Cannot normalize zero vector".to_string(),
));
}
Ok(vec / norm)
}
/// Compute trace of a square matrix
///
/// Tr(A) = Σ_i A_ii
pub fn trace(matrix: &Array2<f64>) -> OptimizrResult<f64> {
if matrix.nrows() != matrix.ncols() {
return Err(OptimizrError::InvalidInput(
"Matrix must be square".to_string(),
));
}
Ok((0..matrix.nrows()).map(|i| matrix[[i, i]]).sum())
}
/// Compute outer product of two vectors
///
/// A = x ⊗ y where A_ij = x_i · y_j
pub fn outer_product(x: &Array1<f64>, y: &Array1<f64>) -> Array2<f64> {
let mut result = Array2::zeros((x.len(), y.len()));
for i in 0..x.len() {
for j in 0..y.len() {
result[[i, j]] = x[i] * y[j];
}
}
result
}
// ============================================================================
// Numerical Integration
// ============================================================================
/// Trapezoidal rule for numerical integration
///
/// ∫f(x)dx ≈ h/2 · [f(x_0) + 2f(x_1) + ... + 2f(x_{n-1}) + f(x_n)]
///
/// # Arguments
/// * `f` - Function to integrate
/// * `a` - Lower bound
/// * `b` - Upper bound
/// * `n` - Number of intervals
///
/// # Returns
/// Approximate integral value
pub fn trapz<F>(f: &F, a: f64, b: f64, n: usize) -> f64
where
F: Fn(f64) -> f64,
{
if n == 0 {
return 0.0;
}
let h = (b - a) / n as f64;
let mut sum = 0.5 * (f(a) + f(b));
for i in 1..n {
let x = a + i as f64 * h;
sum += f(x);
}
sum * h
}
/// Simpson's rule for numerical integration (more accurate than trapezoidal)
///
/// ∫f(x)dx ≈ h/3 · [f(x_0) + 4f(x_1) + 2f(x_2) + 4f(x_3) + ... + f(x_n)]
///
/// # Arguments
/// * `f` - Function to integrate
/// * `a` - Lower bound
/// * `b` - Upper bound
/// * `n` - Number of intervals (must be even)
pub fn simpson<F>(f: &F, a: f64, b: f64, n: usize) -> OptimizrResult<f64>
where
F: Fn(f64) -> f64,
{
if n % 2 != 0 {
return Err(OptimizrError::InvalidInput(
"Simpson's rule requires even number of intervals".to_string(),
));
}
if n == 0 {
return Ok(0.0);
}
let h = (b - a) / n as f64;
let mut sum = f(a) + f(b);
for i in 1..n {
let x = a + i as f64 * h;
let coef = if i % 2 == 0 { 2.0 } else { 4.0 };
sum += coef * f(x);
}
Ok(sum * h / 3.0)
}
// ============================================================================
// Interpolation
// ============================================================================
/// Linear interpolation between two points
///
/// y = y0 + (y1 - y0) * (x - x0) / (x1 - x0)
#[inline]
pub fn lerp(x0: f64, y0: f64, x1: f64, y1: f64, x: f64) -> f64 {
if (x1 - x0).abs() < 1e-10 {
return y0;
}
y0 + (y1 - y0) * (x - x0) / (x1 - x0)
}
/// Linear interpolation on a grid
///
/// # Arguments
/// * `x_grid` - Sorted grid points
/// * `y_values` - Function values at grid points
/// * `x` - Point to interpolate
///
/// # Returns
/// Interpolated value
pub fn interp1d(x_grid: &Array1<f64>, y_values: &Array1<f64>, x: f64) -> OptimizrResult<f64> {
if x_grid.len() != y_values.len() {
return Err(OptimizrError::InvalidInput(
"Grid and values must have same length".to_string(),
));
}
if x_grid.len() < 2 {
return Err(OptimizrError::InvalidInput(
"Need at least 2 points for interpolation".to_string(),
));
}
// Find bracketing indices
if x <= x_grid[0] {
return Ok(y_values[0]);
}
if x >= x_grid[x_grid.len() - 1] {
return Ok(y_values[y_values.len() - 1]);
}
for i in 0..(x_grid.len() - 1) {
if x >= x_grid[i] && x <= x_grid[i + 1] {
return Ok(lerp(
x_grid[i],
y_values[i],
x_grid[i + 1],
y_values[i + 1],
x,
));
}
}
Ok(y_values[y_values.len() - 1])
}
// ============================================================================
// Special Functions
// ============================================================================
/// Logistic sigmoid function
///
/// σ(x) = 1 / (1 + e^(-x))
#[inline]
pub fn sigmoid(x: f64) -> f64 {
1.0 / (1.0 + (-x).exp())
}
/// Softplus function (smooth approximation to ReLU)
///
/// softplus(x) = ln(1 + e^x)
#[inline]
pub fn softplus(x: f64) -> f64 {
(1.0 + x.exp()).ln()
}
/// ReLU (Rectified Linear Unit)
///
/// relu(x) = max(0, x)
#[inline]
pub fn relu(x: f64) -> f64 {
x.max(0.0)
}
/// Soft thresholding operator (for LASSO/L1 regularization)
///
/// S_λ(x) = sign(x) · max(|x| - λ, 0)
#[inline]
pub fn soft_threshold(x: f64, lambda: f64) -> f64 {
if x > lambda {
x - lambda
} else if x < -lambda {
x + lambda
} else {
0.0
}
}
/// Apply soft thresholding to vector
pub fn soft_threshold_vec(x: &Array1<f64>, lambda: f64) -> Array1<f64> {
x.mapv(|xi| soft_threshold(xi, lambda))
}
// ============================================================================
// Optimization Helpers
// ============================================================================
/// Check if a point satisfies box constraints
///
/// Returns true if lower[i] ≤ x[i] ≤ upper[i] for all i
pub fn check_bounds(x: &Array1<f64>, lower: &Array1<f64>, upper: &Array1<f64>) -> bool {
if x.len() != lower.len() || x.len() != upper.len() {
return false;
}
x.iter()
.zip(lower.iter())
.zip(upper.iter())
.all(|((&xi, &li), &ui)| xi >= li && xi <= ui)
}
/// Project point onto box constraints
///
/// Returns x' where x'[i] = clamp(x[i], lower[i], upper[i])
pub fn project_bounds(x: &Array1<f64>, lower: &Array1<f64>, upper: &Array1<f64>) -> Array1<f64> {
x.iter()
.zip(lower.iter())
.zip(upper.iter())
.map(|((&xi, &li), &ui)| xi.max(li).min(ui))
.collect()
}
/// Compute numerical condition number estimate
///
/// κ(A) ≈ ||A|| · ||A^(-1)||
///
/// High condition number indicates ill-conditioned matrix
pub fn condition_number_estimate(matrix: &Array2<f64>) -> f64 {
// Simple estimate using Frobenius norm
// For more accurate estimate, use SVD
let norm = matrix_norm(matrix);
// This is a crude estimate - for production use SVD-based method
if norm < 1e-10 {
return f64::INFINITY;
}
// Placeholder - proper implementation needs matrix inverse
norm * 1000.0 // Conservative estimate
}
#[cfg(test)]
mod tests {
use super::*;
use ndarray::array;
#[test]
fn test_gradient() {
// f(x,y) = x² + 2y²
let f = |x: &[f64]| x[0].powi(2) + 2.0 * x[1].powi(2);
let x = array![1.0, 2.0];
let grad = gradient(&f, &x, 1e-5).unwrap();
assert!((grad[0] - 2.0).abs() < 1e-3); // ∂f/∂x = 2x = 2
assert!((grad[1] - 8.0).abs() < 1e-3); // ∂f/∂y = 4y = 8
}
#[test]
fn test_statistics() {
let data = array![1.0, 2.0, 3.0, 4.0, 5.0];
assert!((mean(&data) - 3.0).abs() < 1e-10);
assert!((std_dev(&data, 1) - 1.5811).abs() < 1e-3);
}
#[test]
fn test_autocorrelation() {
let data = array![1.0, 2.0, 1.5, 2.5, 2.0, 3.0];
let acf_0 = autocorrelation(&data, 0);
assert!((acf_0 - 1.0).abs() < 1e-10); // ACF at lag 0 is always 1
}
#[test]
fn test_soft_threshold() {
assert_eq!(soft_threshold(3.0, 1.0), 2.0);
assert_eq!(soft_threshold(-3.0, 1.0), -2.0);
assert_eq!(soft_threshold(0.5, 1.0), 0.0);
}
#[test]
fn test_integration() {
// ∫x² dx from 0 to 1 = 1/3
let f = |x: f64| x * x;
let result = trapz(&f, 0.0, 1.0, 1000);
assert!((result - 1.0 / 3.0).abs() < 1e-3);
}
}