use std::borrow::Cow;
use nalgebra::{DMatrix, DVector};
use crate::numerical::Nonlinear_systems::engine::{
eval_residual_with_runtime, measure_linear_operation, measure_linear_system_operation_owned,
scaled_norm, scaling_vector, IterationState, MethodWorkspace, NonlinearMethod,
RuntimeDiagnostics, SolveOptions, StepOutcome,
};
use crate::numerical::Nonlinear_systems::error::{SolveError, TerminationReason};
use crate::numerical::Nonlinear_systems::problem::JacobianProvider;
use crate::numerical::Nonlinear_systems::trust_region_LM::solve_trust_region_subproblem;
use crate::numerical::Nonlinear_systems::LM_utils::TrustRegionScaling;
#[derive(Debug, Clone, Copy)]
pub struct LevenbergMarquardtMethod {
pub lambda_init: f64,
pub diag_scaling: bool,
pub increase_factor: f64,
pub decrease_factor: f64,
pub min_lambda: f64,
pub max_lambda: f64,
}
impl Default for LevenbergMarquardtMethod {
fn default() -> Self {
Self {
lambda_init: 1e-3,
diag_scaling: true,
increase_factor: 3.0,
decrease_factor: 10.0,
min_lambda: 1e-6,
max_lambda: 1e3,
}
}
}
#[derive(Debug, Clone)]
pub struct LevenbergMarquardtState {
lambda: f64,
}
impl NonlinearMethod for LevenbergMarquardtMethod {
type MethodState = LevenbergMarquardtState;
fn init<P: JacobianProvider>(
&self,
_problem: &P,
_x0: &DVector<f64>,
_options: &SolveOptions,
_residual: &DVector<f64>,
_jacobian: &DMatrix<f64>,
) -> Result<Self::MethodState, SolveError> {
if self.lambda_init <= 0.0 {
return Err(SolveError::InvalidConfig(
"lambda_init must be positive".to_string(),
));
}
if self.increase_factor <= 1.0 || self.decrease_factor <= 1.0 {
return Err(SolveError::InvalidConfig(
"increase_factor and decrease_factor must be greater than 1".to_string(),
));
}
if self.min_lambda <= 0.0 || self.max_lambda < self.min_lambda {
return Err(SolveError::InvalidConfig(
"invalid lambda bounds".to_string(),
));
}
Ok(LevenbergMarquardtState {
lambda: self.lambda_init,
})
}
fn step<P: JacobianProvider>(
&self,
problem: &P,
state: &IterationState,
method_state: &mut Self::MethodState,
options: &SolveOptions,
runtime: &mut RuntimeDiagnostics,
) -> Result<StepOutcome, SolveError> {
self.step_impl(problem, state, method_state, options, runtime, None)
}
fn supports_step_workspace(&self) -> bool {
true
}
fn step_with_workspace<P: JacobianProvider>(
&self,
problem: &P,
state: &IterationState,
method_state: &mut Self::MethodState,
options: &SolveOptions,
runtime: &mut RuntimeDiagnostics,
workspace: Option<&mut MethodWorkspace>,
) -> Result<StepOutcome, SolveError> {
self.step_impl(problem, state, method_state, options, runtime, workspace)
}
}
impl LevenbergMarquardtMethod {
fn step_impl<P: JacobianProvider>(
&self,
problem: &P,
state: &IterationState,
method_state: &mut LevenbergMarquardtState,
options: &SolveOptions,
runtime: &mut RuntimeDiagnostics,
mut workspace: Option<&mut MethodWorkspace>,
) -> Result<StepOutcome, SolveError> {
let mut regularized = state.jacobian.transpose() * &state.jacobian;
if self.diag_scaling {
TrustRegionScaling::add_jtj_diagonal_regularization_in_place(
&mut regularized,
method_state.lambda,
);
} else {
TrustRegionScaling::add_identity_regularization_in_place(
&mut regularized,
method_state.lambda,
);
}
runtime.linear_solves += 1;
let rhs = -state.jacobian.transpose() * &state.residual;
let step = measure_linear_system_operation_owned(
options.linear_solver,
regularized,
&rhs,
runtime,
options.diagnostics.collect_statistics,
)?;
if step.norm() < options.tolerance {
return Ok(StepOutcome::Terminated(TerminationReason::StepTooSmall));
}
let trial_x = if let Some(workspace) = workspace.as_deref_mut() {
workspace.set_affine_trial(&state.x, 1.0, &step)?;
if let Some(bounds) = &options.bounds {
bounds.project_in_place(workspace.trial_x_mut());
}
Cow::Borrowed(workspace.trial_x())
} else {
let mut trial_x = &state.x + &step;
if let Some(bounds) = &options.bounds {
bounds.project_in_place(&mut trial_x);
}
Cow::Owned(trial_x)
};
let trial_residual = eval_residual_with_runtime(
problem,
&trial_x,
runtime,
options.diagnostics.collect_statistics,
)?;
let trial_is_converged =
trial_residual.norm_squared() < options.tolerance * options.tolerance;
let actual = state.residual.norm_squared() - trial_residual.norm_squared();
let predicted = state.residual.norm_squared()
- (&state.residual + &state.jacobian * &step).norm_squared();
let rho = if predicted.abs() > 1e-12 {
actual / predicted
} else {
0.0
};
if trial_is_converged || rho > 0.0 {
method_state.lambda = (method_state.lambda / self.decrease_factor).max(self.min_lambda);
runtime.accepted_steps += 1;
Ok(StepOutcome::Continue {
next_x: trial_x.into_owned(),
accepted: true,
})
} else {
method_state.lambda = (method_state.lambda * self.increase_factor).min(self.max_lambda);
runtime.rejected_steps += 1;
if method_state.lambda >= self.max_lambda {
return Ok(StepOutcome::Terminated(TerminationReason::Stagnation));
}
Ok(StepOutcome::Continue {
next_x: state.x.clone(),
accepted: false,
})
}
}
}
#[derive(Debug, Clone)]
pub struct LevenbergMarquardtMinpack {
pub ftol: f64,
pub xtol: f64,
pub gtol: f64,
pub maxfev: usize,
pub mode: i32,
pub factor: f64,
pub diag: Option<DVector<f64>>,
}
impl Default for LevenbergMarquardtMinpack {
fn default() -> Self {
Self {
ftol: 1e-8,
xtol: 1e-8,
gtol: 1e-8,
maxfev: 1000,
mode: 1,
factor: 100.0,
diag: None,
}
}
}
#[derive(Debug, Clone)]
pub struct LMMinpackState {
par: f64,
delta: f64,
diag: DVector<f64>,
nfev: usize,
njev: usize,
}
impl LevenbergMarquardtMinpack {
fn update_trust_region(
method_state: &mut LMMinpackState,
ratio: f64,
actred: f64,
pnorm: f64,
fnorm: f64,
fnorm1: f64,
dirder: f64,
) {
const P1: f64 = 0.1;
const P5: f64 = 0.5;
const P25: f64 = 0.25;
const P75: f64 = 0.75;
if ratio <= P25 {
let mut temp = P5;
if actred < 0.0 {
temp = P5 * dirder / (dirder + P5 * actred);
}
if P1 * fnorm1 >= fnorm || temp < P1 {
temp = P1;
}
method_state.delta = temp * method_state.delta.min(pnorm / P1);
method_state.par /= temp;
} else if method_state.par == 0.0 || ratio >= P75 {
method_state.delta = pnorm / P5;
method_state.par *= P5;
}
}
fn scaled_gradient_norm(jacobian: &DMatrix<f64>, residual: &DVector<f64>) -> f64 {
let fnorm = residual.norm();
if fnorm == 0.0 {
return 0.0;
}
let gradient = jacobian.transpose() * residual;
jacobian
.column_iter()
.enumerate()
.filter_map(|(index, column)| {
let column_norm = column.norm();
(column_norm > 0.0).then(|| (gradient[index] / fnorm / column_norm).abs())
})
.fold(0.0, f64::max)
}
}
impl NonlinearMethod for LevenbergMarquardtMinpack {
type MethodState = LMMinpackState;
fn init<P: JacobianProvider>(
&self,
_problem: &P,
x0: &DVector<f64>,
_options: &SolveOptions,
_residual: &DVector<f64>,
jacobian: &DMatrix<f64>,
) -> Result<Self::MethodState, SolveError> {
if !self.ftol.is_finite()
|| self.ftol < 0.0
|| !self.xtol.is_finite()
|| self.xtol < 0.0
|| !self.gtol.is_finite()
|| self.gtol < 0.0
|| self.maxfev == 0
|| !self.factor.is_finite()
|| self.factor <= 0.0
{
return Err(SolveError::InvalidConfig(
"MINPACK tolerances, maxfev and factor must be finite and valid".to_string(),
));
}
let n = jacobian.ncols();
let diag = if self.mode == 2 {
if let Some(d) = &self.diag {
if d.len() != n || d.iter().any(|value| *value <= 0.0 || !value.is_finite()) {
return Err(if d.len() != n {
SolveError::DimensionMismatch {
expected: n,
actual: d.len(),
context: "lm diag",
}
} else {
SolveError::InvalidConfig(
"mode=2 requires finite positive diag entries".to_string(),
)
});
}
d.clone()
} else {
return Err(SolveError::InvalidConfig(
"mode=2 requires diag to be set".to_string(),
));
}
} else {
scaling_vector(jacobian, true)
};
let xscaled = diag.component_mul(x0);
let xnorm = xscaled.norm();
let delta = self.factor * if xnorm > 0.0 { xnorm } else { 1.0 };
Ok(LMMinpackState {
par: 0.0,
delta,
diag,
nfev: 1,
njev: 0,
})
}
fn step<P: JacobianProvider>(
&self,
problem: &P,
state: &IterationState,
method_state: &mut Self::MethodState,
options: &SolveOptions,
runtime: &mut RuntimeDiagnostics,
) -> Result<StepOutcome, SolveError> {
let p1 = 0.1;
let p5 = 0.5;
let p0001 = 1e-4;
let epsmch = f64::EPSILON;
if method_state.nfev >= self.maxfev {
return Ok(StepOutcome::Terminated(TerminationReason::MaxIterations));
}
let fnorm = state.residual.norm();
let j = &state.jacobian;
let gnorm = Self::scaled_gradient_norm(j, &state.residual);
if gnorm <= self.gtol {
if state.residual_norm <= options.tolerance {
return Ok(StepOutcome::Converged);
}
return Ok(StepOutcome::Terminated(TerminationReason::Stagnation));
}
if self.mode != 2 {
for jcol in 0..j.ncols() {
let colnorm = j.column(jcol).norm();
method_state.diag[jcol] = method_state.diag[jcol].max(colnorm);
if method_state.diag[jcol] == 0.0 {
method_state.diag[jcol] = 1.0;
}
}
}
runtime.linear_solves += 1;
let subproblem =
measure_linear_operation(runtime, options.diagnostics.collect_statistics, || {
solve_trust_region_subproblem(
&state.jacobian,
&state.residual,
&method_state.diag,
method_state.delta,
method_state.par,
)
.map_err(|message| SolveError::LinearSolveFailure(message.to_string()))
})?;
let pvec = subproblem.step;
let par = subproblem.lambda;
method_state.par = par;
let pnorm = scaled_norm(&method_state.diag, &pvec);
let xscaled = method_state.diag.component_mul(&state.x);
let xnorm = xscaled.norm();
if state.iteration == 0 {
method_state.delta = method_state.delta.min(pnorm);
}
let mut trial_x = &state.x - &pvec;
if let Some(bounds) = &options.bounds {
bounds.project_in_place(&mut trial_x);
}
let trial_residual = eval_residual_with_runtime(
problem,
&trial_x,
runtime,
options.diagnostics.collect_statistics,
)?;
method_state.nfev += 1;
let fnorm1 = trial_residual.norm();
let actred = if p1 * fnorm1 < fnorm {
1.0 - (fnorm1 / fnorm).powi(2)
} else {
-1.0
};
let j_p = &state.jacobian * &pvec;
let temp1 = j_p.norm() / fnorm.max(1e-300);
let temp2 = (par.sqrt() * pnorm) / fnorm.max(1e-300);
let prered = temp1 * temp1 + (temp2 * temp2) / p5;
let dirder = -(temp1 * temp1 + temp2 * temp2);
let ratio = if prered != 0.0 { actred / prered } else { 0.0 };
Self::update_trust_region(method_state, ratio, actred, pnorm, fnorm, fnorm1, dirder);
let accepted = ratio >= p0001;
let trial_xnorm = scaled_norm(&method_state.diag, &trial_x);
let termination_xnorm = if accepted { trial_xnorm } else { xnorm };
let function_converged =
actred.abs() <= self.ftol && prered <= self.ftol && p5 * ratio <= 1.0;
let parameter_converged = method_state.delta <= self.xtol * termination_xnorm;
let stringent_function = actred.abs() <= epsmch && prered <= epsmch && p5 * ratio <= 1.0;
let stringent_parameter =
p1 * (p1 * method_state.delta).max(pnorm) <= epsmch * termination_xnorm;
let stringent_gradient = gnorm <= epsmch;
if accepted {
runtime.accepted_steps += 1;
if function_converged
|| parameter_converged
|| method_state.nfev >= self.maxfev
|| stringent_function
|| stringent_parameter
|| stringent_gradient
{
let reason = if fnorm1 <= options.tolerance {
TerminationReason::Converged
} else if function_converged || parameter_converged {
TerminationReason::Stagnation
} else if method_state.nfev >= self.maxfev {
TerminationReason::MaxIterations
} else {
TerminationReason::Stagnation
};
return Ok(StepOutcome::AcceptedAndTerminated {
next_x: trial_x,
reason,
});
}
return Ok(StepOutcome::Continue {
next_x: trial_x,
accepted: true,
});
} else {
runtime.rejected_steps += 1;
}
if method_state.nfev >= self.maxfev {
return Ok(StepOutcome::Terminated(TerminationReason::MaxIterations));
}
if function_converged || parameter_converged || stringent_function {
return Ok(StepOutcome::Terminated(TerminationReason::Stagnation));
}
if stringent_parameter || stringent_gradient {
return Ok(StepOutcome::Terminated(TerminationReason::Stagnation));
}
Ok(StepOutcome::Continue {
next_x: state.x.clone(),
accepted: false,
})
}
}
#[cfg(test)]
mod lm_minpack_tests {
use super::*;
use crate::numerical::Nonlinear_systems::engine::{SolveOptions, SolverEngine};
use crate::numerical::Nonlinear_systems::error::TerminationReason;
use crate::numerical::Nonlinear_systems::problem::NonlinearProblem;
use approx::assert_relative_eq;
use nalgebra::{DMatrix, DVector};
struct ScalarQuadratic; impl crate::numerical::Nonlinear_systems::problem::NonlinearProblem for ScalarQuadratic {
fn dimension(&self) -> usize {
1
}
fn residual(&self, x: &DVector<f64>) -> Result<DVector<f64>, SolveError> {
Ok(DVector::from_vec(vec![x[0] * x[0] - 2.0]))
}
}
impl crate::numerical::Nonlinear_systems::problem::JacobianProvider for ScalarQuadratic {
fn jacobian(&self, x: &DVector<f64>) -> Result<DMatrix<f64>, SolveError> {
Ok(DMatrix::from_row_slice(1, 1, &[2.0 * x[0]]))
}
}
#[test]
fn lm_minpack_scalar_quadratic_converges() {
let method = LevenbergMarquardtMinpack {
ftol: 1e-10,
xtol: 1e-10,
gtol: 1e-10,
maxfev: 200,
mode: 1,
factor: 10.0,
diag: None,
};
let options = SolveOptions {
tolerance: 1e-8,
max_iterations: 50,
..Default::default()
};
let engine = SolverEngine::new(method, options);
let x0 = DVector::from_vec(vec![1.5]);
let res = engine.solve(&ScalarQuadratic, x0).expect("solve failed");
assert_eq!(res.termination, TerminationReason::Converged);
let root = res.x[0];
assert!(
(root - 2f64.sqrt()).abs() < 1e-6,
"root ~= sqrt(2): got {}",
root
);
}
struct TwoEq;
impl crate::numerical::Nonlinear_systems::problem::NonlinearProblem for TwoEq {
fn dimension(&self) -> usize {
2
}
fn residual(&self, x: &DVector<f64>) -> Result<DVector<f64>, SolveError> {
Ok(DVector::from_vec(vec![
x[0] * x[0] + x[1] * x[1] - 10.0,
x[0] - x[1] - 4.0,
]))
}
}
impl crate::numerical::Nonlinear_systems::problem::JacobianProvider for TwoEq {
fn jacobian(&self, x: &DVector<f64>) -> Result<DMatrix<f64>, SolveError> {
Ok(DMatrix::from_row_slice(
2,
2,
&[2.0 * x[0], 2.0 * x[1], 1.0, -1.0],
))
}
}
#[test]
fn lm_minpack_two_eq_converges() {
let method = LevenbergMarquardtMinpack::default();
let options = SolveOptions {
tolerance: 1e-8,
max_iterations: 100,
..Default::default()
};
let engine = SolverEngine::new(method, options);
let x0 = DVector::from_vec(vec![1.0, 1.0]);
let res = engine.solve(&TwoEq, x0).expect("solve failed");
assert_eq!(res.termination, TerminationReason::Converged);
let x = res.x;
assert!((x[0] - 3.0).abs() < 1e-4, "x[0] close to 3: got {}", x[0]);
assert!((x[1] + 1.0).abs() < 1e-4, "x[1] close to -1: got {}", x[1]);
}
struct Coupled;
impl crate::numerical::Nonlinear_systems::problem::NonlinearProblem for Coupled {
fn dimension(&self) -> usize {
2
}
fn residual(&self, x: &DVector<f64>) -> Result<DVector<f64>, SolveError> {
Ok(DVector::from_vec(vec![
x[0] * x[0] + x[1] - 37.0,
x[0] - x[1] * x[1] - 5.0,
]))
}
}
impl crate::numerical::Nonlinear_systems::problem::JacobianProvider for Coupled {
fn jacobian(&self, x: &DVector<f64>) -> Result<DMatrix<f64>, SolveError> {
Ok(DMatrix::from_row_slice(
2,
2,
&[2.0 * x[0], 1.0, 1.0, -2.0 * x[1]],
))
}
}
#[test]
fn lm_minpack_coupled_converges() {
let method = LevenbergMarquardtMinpack {
ftol: 1e-10,
xtol: 1e-10,
gtol: 1e-10,
maxfev: 500,
mode: 1,
factor: 10.0,
diag: None,
};
let options = SolveOptions {
tolerance: 1e-8,
max_iterations: 200,
..Default::default()
};
let engine = SolverEngine::new(method, options);
let x0 = DVector::from_vec(vec![6.0, 1.0]);
let res = engine.solve(&Coupled, x0).expect("solve failed");
assert_eq!(res.termination, TerminationReason::Converged);
let x = res.x;
println!("x = {:?}", x);
assert_relative_eq!(x[0], 6.0, epsilon = 1e-6);
assert_relative_eq!(x[1], 1.0, epsilon = 1e-6);
assert!((x[0] - 6.0).abs() < 1e-4, "x[0] close to 6: got {}", x[0]);
}
#[test]
fn lm_minpack_trust_region_update_matches_fortran_branch_ordering() {
let mut state = LMMinpackState {
par: 4.0,
delta: 10.0,
diag: DVector::from_element(1, 1.0),
nfev: 0,
njev: 0,
};
LevenbergMarquardtMinpack::update_trust_region(&mut state, 0.5, 0.5, 2.0, 1.0, 0.1, -0.25);
assert_eq!(state.delta, 10.0);
assert_eq!(state.par, 4.0);
LevenbergMarquardtMinpack::update_trust_region(&mut state, 0.9, 0.8, 2.0, 1.0, 0.1, -0.25);
assert_eq!(state.delta, 4.0);
assert_eq!(state.par, 2.0);
LevenbergMarquardtMinpack::update_trust_region(&mut state, 0.1, 0.1, 2.0, 1.0, 0.1, -0.25);
assert_eq!(state.delta, 2.0);
assert_eq!(state.par, 4.0);
}
#[test]
fn lm_minpack_gradient_norm_uses_jacobian_transpose_residual() {
let jacobian = DMatrix::from_diagonal(&DVector::from_vec(vec![2.0, 3.0]));
let residual = DVector::from_vec(vec![1.0, 1.0]);
let expected = 1.0 / 2.0_f64.sqrt();
assert!(
(LevenbergMarquardtMinpack::scaled_gradient_norm(&jacobian, &residual) - expected)
.abs()
< 1e-15
);
}
struct StationaryNonRoot;
impl NonlinearProblem for StationaryNonRoot {
fn dimension(&self) -> usize {
1
}
fn residual(&self, _x: &DVector<f64>) -> Result<DVector<f64>, SolveError> {
Ok(DVector::from_element(1, 1.0))
}
}
impl JacobianProvider for StationaryNonRoot {
fn jacobian(&self, _x: &DVector<f64>) -> Result<DMatrix<f64>, SolveError> {
Ok(DMatrix::zeros(1, 1))
}
}
#[test]
fn lm_minpack_does_not_report_stationary_nonroot_as_converged() {
let result = SolverEngine::new(
LevenbergMarquardtMinpack::default(),
SolveOptions {
tolerance: 1e-10,
max_iterations: 8,
..SolveOptions::default()
},
)
.solve(&StationaryNonRoot, DVector::from_element(1, 0.0))
.expect("stationary problem should terminate with a typed result");
assert_eq!(result.termination, TerminationReason::Stagnation);
assert!(result.residual_norm > 1e-10);
}
#[test]
fn lm_minpack_ftol_stops_after_returning_the_accepted_trial() {
let result = SolverEngine::new(
LevenbergMarquardtMinpack {
ftol: 1.0e9,
xtol: 0.0,
gtol: 0.0,
maxfev: 100,
..LevenbergMarquardtMinpack::default()
},
SolveOptions {
tolerance: 1.0e-12,
max_iterations: 20,
..SolveOptions::default()
},
)
.solve(&ScalarQuadratic, DVector::from_element(1, 1.5))
.expect("ftol termination should return a typed result");
assert_eq!(result.termination, TerminationReason::Stagnation);
assert_ne!(result.x[0], 1.5);
assert!(result.residual_norm < (1.5_f64 * 1.5 - 2.0).abs());
}
#[test]
fn lm_minpack_xtol_stops_after_returning_the_accepted_trial() {
let result = SolverEngine::new(
LevenbergMarquardtMinpack {
ftol: 0.0,
xtol: 1.0e9,
gtol: 0.0,
maxfev: 100,
..LevenbergMarquardtMinpack::default()
},
SolveOptions {
tolerance: 1.0e-12,
max_iterations: 20,
..SolveOptions::default()
},
)
.solve(&ScalarQuadratic, DVector::from_element(1, 1.5))
.expect("xtol termination should return a typed result");
assert_eq!(result.termination, TerminationReason::Stagnation);
assert_ne!(result.x[0], 1.5);
assert!(result.residual_norm < (1.5_f64 * 1.5 - 2.0).abs());
}
#[test]
fn lm_minpack_maxfev_stops_after_returning_the_accepted_trial() {
let result = SolverEngine::new(
LevenbergMarquardtMinpack {
maxfev: 2,
..LevenbergMarquardtMinpack::default()
},
SolveOptions {
tolerance: 1.0e-12,
max_iterations: 20,
..SolveOptions::default()
},
)
.solve(&ScalarQuadratic, DVector::from_element(1, 1.5))
.expect("maxfev termination should return a typed result");
assert_eq!(result.termination, TerminationReason::MaxIterations);
assert_ne!(result.x[0], 1.5);
assert!(result.residual_norm < (1.5_f64 * 1.5 - 2.0).abs());
}
}