use crate::linalg::generic::{mat_inv, mat_quadratic_form, mat_solve, mat_symmetrize};
#[derive(Clone, Debug)]
pub struct NLLSEvaluation<const N: usize> {
pub residuals: Vec<f64>,
pub jacobian: Vec<[f64; N]>,
pub cost: f64,
}
#[derive(Clone, Debug)]
pub struct NLLSEvaluation2<const N: usize> {
pub first_order: NLLSEvaluation<N>,
pub hessians: Vec<[[f64; N]; N]>,
}
pub trait NLLSProblem<const N: usize> {
fn evaluate(&mut self, x: &[f64; N]) -> NLLSEvaluation<N>;
fn constrain_step(&mut self, _x: &[f64; N], _delta: &mut [f64; N]) {}
}
pub trait NLLSProblem2<const N: usize>: NLLSProblem<N> {
fn evaluate2(&mut self, x: &[f64; N]) -> NLLSEvaluation2<N>;
}
#[derive(Clone, Debug)]
pub struct NLLSPrior<const N: usize> {
pub mean: [f64; N],
pub covariance_inv: [[f64; N]; N],
}
#[derive(Clone, Debug)]
pub struct NLLSConfig {
pub max_iterations: usize,
pub step_tolerance: f64,
pub method: NLLSMethod,
}
impl Default for NLLSConfig {
fn default() -> Self {
Self {
max_iterations: 20,
step_tolerance: 0.1,
method: NLLSMethod::LevenbergMarquardt {
lambda_initial: 1.0,
},
}
}
}
#[derive(Clone, Debug)]
pub enum NLLSMethod {
GaussNewton,
LevenbergMarquardt {
lambda_initial: f64,
},
}
#[derive(Clone, Debug)]
pub struct NLLSSolution<const N: usize> {
pub x: [f64; N],
pub covariance: [[f64; N]; N],
pub cost: f64,
pub iterations: usize,
pub converged: bool,
pub reason: ConvergenceReason,
}
#[derive(Clone, Debug, PartialEq)]
pub enum ConvergenceReason {
StepTolerance,
MaxIterations,
SingularMatrix,
}
#[derive(Clone, Debug)]
pub enum NLLSError {
SingularMatrix { iteration: usize },
EmptyResiduals,
DimensionMismatch { residuals: usize, jacobian: usize },
}
impl std::fmt::Display for NLLSError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::SingularMatrix { iteration } => {
write!(f, "singular normal matrix at iteration {iteration}")
}
Self::EmptyResiduals => write!(f, "evaluation returned empty residuals"),
Self::DimensionMismatch {
residuals,
jacobian,
} => write!(
f,
"dimension mismatch: {residuals} residuals vs {jacobian} Jacobian rows"
),
}
}
}
impl std::error::Error for NLLSError {}
pub fn solve<const N: usize>(
problem: &mut impl NLLSProblem<N>,
x0: [f64; N],
config: &NLLSConfig,
prior: Option<&NLLSPrior<N>>,
) -> Result<NLLSSolution<N>, NLLSError> {
let mut x = x0;
let mut lambda = match &config.method {
NLLSMethod::GaussNewton => 0.0,
NLLSMethod::LevenbergMarquardt { lambda_initial } => *lambda_initial,
};
let use_lm = matches!(config.method, NLLSMethod::LevenbergMarquardt { .. });
let mut last_cost = f64::INFINITY;
let mut final_cost = 0.0;
let mut converged = false;
let mut reason = ConvergenceReason::MaxIterations;
let mut iterations = 0;
let mut undamped_normal = [[0.0_f64; N]; N];
for iter in 0..config.max_iterations {
iterations = iter + 1;
let eval = problem.evaluate(&x);
validate_evaluation::<N>(&eval)?;
final_cost = eval.cost;
let (normal, rhs) = assemble_normal_equations(&eval);
undamped_normal = mat_symmetrize(&normal);
let (mut solve_normal, mut solve_rhs) = (undamped_normal, rhs);
if let Some(p) = prior {
augment_with_prior(&mut solve_normal, &mut solve_rhs, &x, p);
}
if use_lm {
for i in 0..N {
solve_normal[i][i] += lambda * undamped_normal[i][i];
}
}
let mut delta_x = match mat_solve(&solve_normal, &solve_rhs) {
Some(dx) => dx,
None => {
reason = ConvergenceReason::SingularMatrix;
converged = false;
break;
}
};
problem.constrain_step(&x, &mut delta_x);
let step_norm = mat_quadratic_form(&delta_x, &undamped_normal);
if step_norm < config.step_tolerance {
for i in 0..N {
x[i] += delta_x[i];
}
converged = true;
reason = ConvergenceReason::StepTolerance;
break;
}
if use_lm {
if eval.cost < last_cost {
lambda = (lambda / 3.0).max(1e-6);
} else {
lambda = (lambda * 3.0).min(1e4);
}
last_cost = eval.cost;
}
for i in 0..N {
x[i] += delta_x[i];
}
}
let mut cov_normal = undamped_normal;
if let Some(p) = prior {
for i in 0..N {
for j in 0..N {
cov_normal[i][j] += p.covariance_inv[i][j];
}
}
}
let covariance = mat_inv(&cov_normal).unwrap_or([[0.0; N]; N]);
let covariance = mat_symmetrize(&covariance);
Ok(NLLSSolution {
x,
covariance,
cost: final_cost,
iterations,
converged,
reason,
})
}
pub fn solve2<const N: usize>(
problem: &mut impl NLLSProblem2<N>,
x0: [f64; N],
config: &NLLSConfig,
prior: Option<&NLLSPrior<N>>,
) -> Result<NLLSSolution<N>, NLLSError> {
let mut x = x0;
let mut lambda = match &config.method {
NLLSMethod::GaussNewton => 0.0,
NLLSMethod::LevenbergMarquardt { lambda_initial } => *lambda_initial,
};
let use_lm = matches!(config.method, NLLSMethod::LevenbergMarquardt { .. });
let mut last_cost = f64::INFINITY;
let mut final_cost = 0.0;
let mut converged = false;
let mut reason = ConvergenceReason::MaxIterations;
let mut iterations = 0;
let mut undamped_normal = [[0.0_f64; N]; N];
for iter in 0..config.max_iterations {
iterations = iter + 1;
let eval2 = problem.evaluate2(&x);
let eval = &eval2.first_order;
validate_evaluation::<N>(eval)?;
final_cost = eval.cost;
let (gn_normal, rhs) = assemble_normal_equations(eval);
let mut normal = gn_normal;
for (i, r_i) in eval.residuals.iter().enumerate() {
if let Some(h_i) = eval2.hessians.get(i) {
for j in 0..N {
for k in 0..N {
normal[j][k] += r_i * h_i[j][k];
}
}
}
}
undamped_normal = mat_symmetrize(&normal);
let (mut solve_normal, mut solve_rhs) = (undamped_normal, rhs);
if let Some(p) = prior {
augment_with_prior(&mut solve_normal, &mut solve_rhs, &x, p);
}
if use_lm {
for i in 0..N {
solve_normal[i][i] += lambda * undamped_normal[i][i];
}
}
let mut delta_x = match mat_solve(&solve_normal, &solve_rhs) {
Some(dx) => dx,
None => {
reason = ConvergenceReason::SingularMatrix;
converged = false;
break;
}
};
problem.constrain_step(&x, &mut delta_x);
let step_norm = mat_quadratic_form(&delta_x, &undamped_normal);
if step_norm < config.step_tolerance {
for i in 0..N {
x[i] += delta_x[i];
}
converged = true;
reason = ConvergenceReason::StepTolerance;
break;
}
if use_lm {
if eval.cost < last_cost {
lambda = (lambda / 3.0).max(1e-6);
} else {
lambda = (lambda * 3.0).min(1e4);
}
last_cost = eval.cost;
}
for i in 0..N {
x[i] += delta_x[i];
}
}
let mut cov_normal = undamped_normal;
if let Some(p) = prior {
for i in 0..N {
for j in 0..N {
cov_normal[i][j] += p.covariance_inv[i][j];
}
}
}
let covariance = mat_inv(&cov_normal).unwrap_or([[0.0; N]; N]);
let covariance = mat_symmetrize(&covariance);
Ok(NLLSSolution {
x,
covariance,
cost: final_cost,
iterations,
converged,
reason,
})
}
pub fn solve_nlls<const N: usize>(
f: impl FnMut(&[f64; N]) -> NLLSEvaluation<N>,
x0: [f64; N],
config: &NLLSConfig,
prior: Option<&NLLSPrior<N>>,
) -> Result<NLLSSolution<N>, NLLSError> {
struct ClosureProblem<F>(F);
impl<const N: usize, F: FnMut(&[f64; N]) -> NLLSEvaluation<N>> NLLSProblem<N>
for ClosureProblem<F>
{
fn evaluate(&mut self, x: &[f64; N]) -> NLLSEvaluation<N> {
(self.0)(x)
}
}
solve(&mut ClosureProblem(f), x0, config, prior)
}
fn validate_evaluation<const N: usize>(eval: &NLLSEvaluation<N>) -> Result<(), NLLSError> {
if eval.residuals.is_empty() {
return Err(NLLSError::EmptyResiduals);
}
if eval.residuals.len() != eval.jacobian.len() {
return Err(NLLSError::DimensionMismatch {
residuals: eval.residuals.len(),
jacobian: eval.jacobian.len(),
});
}
Ok(())
}
fn assemble_normal_equations<const N: usize>(
eval: &NLLSEvaluation<N>,
) -> ([[f64; N]; N], [f64; N]) {
let mut normal = [[0.0_f64; N]; N];
let mut rhs = [0.0_f64; N];
for (r_i, j_i) in eval.residuals.iter().zip(eval.jacobian.iter()) {
for j in 0..N {
for k in 0..N {
normal[j][k] += j_i[j] * j_i[k];
}
rhs[j] -= j_i[j] * r_i;
}
}
(normal, rhs)
}
fn augment_with_prior<const N: usize>(
normal: &mut [[f64; N]; N],
rhs: &mut [f64; N],
x: &[f64; N],
prior: &NLLSPrior<N>,
) {
for i in 0..N {
for j in 0..N {
normal[i][j] += prior.covariance_inv[i][j];
}
let mut delta_weighted = 0.0;
for j in 0..N {
delta_weighted += prior.covariance_inv[i][j] * (x[j] - prior.mean[j]);
}
rhs[i] -= delta_weighted;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_linear_system() {
let solution = solve_nlls(
|x: &[f64; 2]| NLLSEvaluation {
residuals: vec![x[0] - 3.0, x[1] - 7.0],
jacobian: vec![[1.0, 0.0], [0.0, 1.0]],
cost: (x[0] - 3.0).powi(2) + (x[1] - 7.0).powi(2),
},
[0.0; 2],
&NLLSConfig {
method: NLLSMethod::GaussNewton,
..Default::default()
},
None,
)
.unwrap();
assert!(solution.converged);
assert!(
(solution.x[0] - 3.0).abs() < 1e-12,
"x[0]={}",
solution.x[0]
);
assert!(
(solution.x[1] - 7.0).abs() < 1e-12,
"x[1]={}",
solution.x[1]
);
assert!(
solution.iterations <= 2,
"iterations={}",
solution.iterations
);
}
#[test]
fn test_overdetermined_linear() {
let xs = [0.0, 1.0, 2.0, 3.0];
let ys = [1.0, 3.0, 5.0, 7.0];
let solution = solve_nlls(
|p: &[f64; 2]| {
let (a, b) = (p[0], p[1]);
let residuals: Vec<f64> = xs
.iter()
.zip(ys.iter())
.map(|(&x, &y)| a * x + b - y)
.collect();
let jacobian: Vec<[f64; 2]> = xs.iter().map(|&x| [x, 1.0]).collect();
let cost = residuals.iter().map(|r| r * r).sum();
NLLSEvaluation {
residuals,
jacobian,
cost,
}
},
[0.0; 2],
&NLLSConfig {
method: NLLSMethod::GaussNewton,
..Default::default()
},
None,
)
.unwrap();
assert!(solution.converged);
assert!((solution.x[0] - 2.0).abs() < 1e-10, "a={}", solution.x[0]);
assert!((solution.x[1] - 1.0).abs() < 1e-10, "b={}", solution.x[1]);
}
#[test]
fn test_rosenbrock() {
let solution = solve_nlls(
|x: &[f64; 2]| {
let r1 = 10.0 * (x[1] - x[0] * x[0]);
let r2 = 1.0 - x[0];
NLLSEvaluation {
residuals: vec![r1, r2],
jacobian: vec![[-20.0 * x[0], 10.0], [-1.0, 0.0]],
cost: r1 * r1 + r2 * r2,
}
},
[-1.0, 1.0],
&NLLSConfig {
max_iterations: 50,
step_tolerance: 1e-10,
method: NLLSMethod::LevenbergMarquardt {
lambda_initial: 1.0,
},
},
None,
)
.unwrap();
assert!(solution.converged, "reason: {:?}", solution.reason);
assert!((solution.x[0] - 1.0).abs() < 1e-6, "x[0]={}", solution.x[0]);
assert!((solution.x[1] - 1.0).abs() < 1e-6, "x[1]={}", solution.x[1]);
}
#[test]
fn test_circle_fit() {
let angles: [f64; 8] = [0.0, 0.7, 1.4, 2.1, 2.8, 3.5, 4.2, 4.9];
let data: Vec<(f64, f64)> = angles
.iter()
.map(|&a| (2.0 + 5.0 * a.cos(), 3.0 + 5.0 * a.sin()))
.collect();
let solution = solve_nlls(
|p: &[f64; 3]| {
let (cx, cy, r) = (p[0], p[1], p[2]);
let mut residuals = Vec::new();
let mut jacobian = Vec::new();
for &(x, y) in &data {
let dx = x - cx;
let dy = y - cy;
let dist = (dx * dx + dy * dy).sqrt();
residuals.push(dist - r);
jacobian.push([-dx / dist, -dy / dist, -1.0]);
}
let cost = residuals.iter().map(|r| r * r).sum();
NLLSEvaluation {
residuals,
jacobian,
cost,
}
},
[0.0, 0.0, 1.0],
&NLLSConfig {
max_iterations: 30,
step_tolerance: 1e-12,
method: NLLSMethod::LevenbergMarquardt {
lambda_initial: 1.0,
},
},
None,
)
.unwrap();
assert!(solution.converged, "reason: {:?}", solution.reason);
assert!((solution.x[0] - 2.0).abs() < 1e-8, "cx={}", solution.x[0]);
assert!((solution.x[1] - 3.0).abs() < 1e-8, "cy={}", solution.x[1]);
assert!((solution.x[2] - 5.0).abs() < 1e-8, "r={}", solution.x[2]);
}
#[test]
fn test_prior_augmentation() {
let config = NLLSConfig {
method: NLLSMethod::GaussNewton,
..Default::default()
};
let eval = |x: &[f64; 1]| NLLSEvaluation {
residuals: vec![x[0] - 10.0],
jacobian: vec![[1.0]],
cost: (x[0] - 10.0).powi(2),
};
let sol1 = solve_nlls(eval, [0.0; 1], &config, None).unwrap();
assert!((sol1.x[0] - 10.0).abs() < 1e-10);
let prior = NLLSPrior {
mean: [0.0],
covariance_inv: [[1.0]],
};
let sol2 = solve_nlls(eval, [0.0; 1], &config, Some(&prior)).unwrap();
assert!(
(sol2.x[0] - 5.0).abs() < 1e-10,
"x={} (expected 5.0)",
sol2.x[0]
);
}
#[test]
fn test_singular_matrix() {
let result = solve_nlls(
|_x: &[f64; 2]| NLLSEvaluation {
residuals: vec![1.0],
jacobian: vec![[0.0, 0.0]],
cost: 1.0,
},
[0.0; 2],
&NLLSConfig::default(),
None,
);
let sol = result.unwrap();
assert!(!sol.converged);
assert_eq!(sol.reason, ConvergenceReason::SingularMatrix);
}
#[test]
fn test_empty_residuals_error() {
let result = solve_nlls(
|_x: &[f64; 2]| NLLSEvaluation {
residuals: vec![],
jacobian: vec![],
cost: 0.0,
},
[0.0; 2],
&NLLSConfig::default(),
None,
);
assert!(result.is_err());
}
#[test]
fn test_covariance_output() {
let sol = solve_nlls(
|x: &[f64; 1]| NLLSEvaluation {
residuals: vec![x[0] - 3.0],
jacobian: vec![[1.0]],
cost: (x[0] - 3.0).powi(2),
},
[0.0],
&NLLSConfig {
method: NLLSMethod::GaussNewton,
..Default::default()
},
None,
)
.unwrap();
assert!((sol.covariance[0][0] - 1.0).abs() < 1e-10);
}
#[test]
fn test_constrain_step_bounds_overshoot() {
let eval = |x: &[f64; 1]| {
let d = x[0] - 5.0;
let r = (1.0 + d * d).ln();
let j = 2.0 * d / (1.0 + d * d);
NLLSEvaluation {
residuals: vec![r],
jacobian: vec![[j]],
cost: r * r,
}
};
let config = NLLSConfig {
max_iterations: 20,
step_tolerance: 1e-12,
method: NLLSMethod::LevenbergMarquardt {
lambda_initial: 0.0,
},
};
let sol_free = solve_nlls(eval, [100.0], &config, None).unwrap();
let initial_cost = {
let d = 100.0_f64 - 5.0;
let r = (1.0 + d * d).ln();
r * r
};
assert!(
sol_free.cost > initial_cost,
"unconstrained LM did not overshoot: cost={} vs initial={}",
sol_free.cost,
initial_cost,
);
assert!(
(sol_free.x[0] - 5.0).abs() > 100.0,
"unconstrained LM unexpectedly stayed near minimum: x={}",
sol_free.x[0],
);
struct Clamped;
impl NLLSProblem<1> for Clamped {
fn evaluate(&mut self, x: &[f64; 1]) -> NLLSEvaluation<1> {
let d = x[0] - 5.0;
let r = (1.0 + d * d).ln();
let j = 2.0 * d / (1.0 + d * d);
NLLSEvaluation {
residuals: vec![r],
jacobian: vec![[j]],
cost: r * r,
}
}
fn constrain_step(&mut self, x: &[f64; 1], delta: &mut [f64; 1]) {
let max_step = 0.5 * x[0].abs().max(0.1);
if delta[0].abs() > max_step {
delta[0] *= max_step / delta[0].abs();
}
}
}
let sol_bound = solve(&mut Clamped, [100.0], &config, None).unwrap();
assert!(
sol_bound.converged,
"bounded LM failed: x={}, cost={}, reason={:?}",
sol_bound.x[0], sol_bound.cost, sol_bound.reason,
);
assert!(
(sol_bound.x[0] - 5.0).abs() < 1e-3,
"bounded LM did not reach minimum: x={}",
sol_bound.x[0],
);
}
#[test]
fn test_constrain_step_default_is_noop() {
let sol = solve_nlls(
|x: &[f64; 2]| NLLSEvaluation {
residuals: vec![x[0] - 3.0, x[1] - 7.0],
jacobian: vec![[1.0, 0.0], [0.0, 1.0]],
cost: (x[0] - 3.0).powi(2) + (x[1] - 7.0).powi(2),
},
[0.0; 2],
&NLLSConfig {
method: NLLSMethod::GaussNewton,
..Default::default()
},
None,
)
.unwrap();
assert!(sol.converged);
assert!((sol.x[0] - 3.0).abs() < 1e-10);
assert!((sol.x[1] - 7.0).abs() < 1e-10);
}
#[test]
fn test_lm_vs_gn() {
let eval = |x: &[f64; 2]| NLLSEvaluation {
residuals: vec![x[0] - 1.0, x[1] - 2.0, x[0] + x[1] - 3.0],
jacobian: vec![[1.0, 0.0], [0.0, 1.0], [1.0, 1.0]],
cost: (x[0] - 1.0).powi(2) + (x[1] - 2.0).powi(2) + (x[0] + x[1] - 3.0).powi(2),
};
let sol_gn = solve_nlls(
eval,
[0.0; 2],
&NLLSConfig {
method: NLLSMethod::GaussNewton,
..Default::default()
},
None,
)
.unwrap();
let sol_lm = solve_nlls(
eval,
[0.0; 2],
&NLLSConfig {
max_iterations: 50,
step_tolerance: 1e-10,
method: NLLSMethod::LevenbergMarquardt {
lambda_initial: 1.0,
},
},
None,
)
.unwrap();
assert!(sol_gn.converged, "GN: {:?}", sol_gn.reason);
assert!(sol_lm.converged, "LM: {:?}", sol_lm.reason);
assert!(
(sol_gn.x[0] - sol_lm.x[0]).abs() < 1e-4,
"x[0]: GN={} LM={}",
sol_gn.x[0],
sol_lm.x[0]
);
assert!(
(sol_gn.x[1] - sol_lm.x[1]).abs() < 1e-4,
"x[1]: GN={} LM={}",
sol_gn.x[1],
sol_lm.x[1]
);
}
}