use crate::numerical::optimization::problem_LM::LeastSquaresProblem;
use crate::numerical::optimization::qr_LM::{LinearLeastSquaresDiagonalProblem, PivotedQR};
use crate::numerical::optimization::trust_region_LM::{
LMParameter, determine_lambda_and_parameter_update,
};
use crate::numerical::optimization::utils::{enorm, epsmch};
use nalgebra::{DMatrix, DVector};
use num_traits::Float;
pub const MINPACK_COMPAT: bool = false;
#[derive(PartialEq, Eq, Debug)]
pub enum TerminationReason {
User(&'static str),
Numerical(&'static str),
ResidualsZero,
Orthogonal,
Converged { ftol: bool, xtol: bool },
NoImprovementPossible(&'static str),
LostPatience,
NoParameters,
NoResiduals,
WrongDimensions(&'static str),
}
impl TerminationReason {
pub fn was_successful(&self) -> bool {
matches!(
self,
TerminationReason::ResidualsZero
| TerminationReason::Orthogonal
| TerminationReason::Converged { .. }
)
}
pub fn was_usage_issue(&self) -> bool {
matches!(
self,
TerminationReason::NoParameters
| TerminationReason::NoResiduals
| TerminationReason::NoImprovementPossible(_)
| TerminationReason::WrongDimensions(_)
)
}
}
#[derive(Debug)]
pub struct MinimizationReport {
pub termination: TerminationReason,
pub number_of_evaluations: usize,
pub objective_function: f64,
}
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct LevenbergMarquardt {
ftol: f64,
xtol: f64,
gtol: f64,
stepbound: f64,
patience: usize,
scale_diag: bool,
}
impl Default for LevenbergMarquardt {
fn default() -> Self {
Self::new()
}
}
impl LevenbergMarquardt {
pub fn new() -> Self {
if MINPACK_COMPAT {
let user_tol = 1.49012e-08;
Self {
ftol: user_tol,
xtol: user_tol,
gtol: 0.0,
stepbound: 100.0,
patience: 100,
scale_diag: true,
}
} else {
let user_tol = f64::EPSILON * 30.0;
Self {
ftol: user_tol,
xtol: user_tol,
gtol: user_tol,
stepbound: 100.0,
patience: 100,
scale_diag: true,
}
}
}
#[must_use]
pub fn with_ftol(self, ftol: f64) -> Self {
assert!(!ftol.is_sign_negative(), "ftol must be >= 0");
Self { ftol, ..self }
}
#[must_use]
pub fn with_xtol(self, xtol: f64) -> Self {
assert!(!xtol.is_sign_negative(), "xtol must be >= 0");
Self { xtol, ..self }
}
#[must_use]
pub fn with_gtol(self, gtol: f64) -> Self {
assert!(!gtol.is_sign_negative(), "gtol must be >= 0");
Self { gtol, ..self }
}
#[must_use]
pub fn with_tol(self, tol: f64) -> Self {
assert!(tol.is_sign_positive(), "tol must > 0");
Self {
ftol: tol,
xtol: tol,
gtol: 0.0,
..self
}
}
#[must_use]
pub fn with_stepbound(self, stepbound: f64) -> Self {
assert!(stepbound.is_sign_positive(), "stepbound must be > 0");
Self { stepbound, ..self }
}
#[must_use]
pub fn with_patience(self, patience: usize) -> Self {
assert!(patience > 0, "patience must be > 0");
Self { patience, ..self }
}
#[must_use]
pub fn with_scale_diag(self, scale_diag: bool) -> Self {
Self { scale_diag, ..self }
}
pub fn minimize<O>(&self, target: O) -> (O, MinimizationReport)
where
O: LeastSquaresProblem,
{
let (mut lm, mut residuals) = match LM::new(self, target) {
Err(report) => return report,
Ok(res) => res,
};
let n = lm.x.nrows();
loop {
let mut lls = {
let jacobian = match lm.jacobian() {
Err(reason) => return lm.into_report(reason),
Ok(jacobian) => jacobian,
};
if jacobian.ncols() != n || jacobian.nrows() != lm.m {
return lm.into_report(TerminationReason::WrongDimensions("jacobian"));
}
let qr = PivotedQR::new(jacobian);
qr.into_least_squares_diagonal_problem(residuals)
};
if let Err(reason) = lm.update_diag(&mut lls) {
return lm.into_report(reason);
};
residuals = loop {
let param =
determine_lambda_and_parameter_update(&mut lls, &lm.diag, lm.delta, lm.lambda);
let tr_iteration = lm.trust_region_iteration(&mut lls, param);
match tr_iteration {
Ok(Some(residuals)) => break residuals,
Err(reason) => return lm.into_report(reason),
Ok(None) => (),
}
};
}
}
}
struct LM<'a, O>
where
O: LeastSquaresProblem,
{
config: &'a LevenbergMarquardt,
x: DVector<f64>,
tmp: DVector<f64>,
target: O,
report: MinimizationReport,
delta: f64,
lambda: f64,
xnorm: f64,
gnorm: f64,
residuals_norm: f64,
diag: DVector<f64>,
first_trust_region_iteration: bool,
first_update: bool,
max_fev: usize,
m: usize,
}
impl<'a, O> LM<'a, O>
where
O: LeastSquaresProblem,
{
fn new(
config: &'a LevenbergMarquardt,
target: O,
) -> Result<(Self, DVector<f64>), (O, MinimizationReport)> {
let mut report = MinimizationReport {
termination: TerminationReason::ResidualsZero,
number_of_evaluations: 1,
objective_function: f64::NAN,
};
let x = target.params();
let (residuals, residuals_norm) = if let Some(residuals) = target.residuals() {
let norm = enorm(&residuals);
report.objective_function = norm * norm * 0.5;
(residuals, norm)
} else {
return Err((
target,
MinimizationReport {
termination: TerminationReason::User("residuals"),
..report
},
));
};
let n = x.nrows();
let diag = DVector::from_element(n, 1.0);
if diag.nrows() == 0 {
return Err((
target,
MinimizationReport {
termination: TerminationReason::NoParameters,
..report
},
));
}
let m: usize = residuals.nrows();
if m == 0 {
return Err((
target,
MinimizationReport {
termination: TerminationReason::NoResiduals,
..report
},
));
}
if !residuals_norm.is_finite() && !MINPACK_COMPAT {
return Err((
target,
MinimizationReport {
termination: TerminationReason::Numerical("residuals norm"),
..report
},
));
}
if residuals_norm <= f64::MIN_POSITIVE && !MINPACK_COMPAT {
return Err((target, report));
}
Ok((
Self {
config,
target,
report,
tmp: x.clone(),
x,
diag,
delta: 0.0,
lambda: 0.0,
xnorm: 0.0,
gnorm: 0.0,
residuals_norm,
first_trust_region_iteration: true,
first_update: true,
max_fev: config.patience * (n + 1),
m,
},
residuals,
))
}
fn into_report(self, termination: TerminationReason) -> (O, MinimizationReport) {
(
self.target,
MinimizationReport {
termination,
..self.report
},
)
}
fn jacobian(&self) -> Result<DMatrix<f64>, TerminationReason> {
match self.target.jacobian() {
Some(jacobian) => Ok(jacobian),
None => Err(TerminationReason::User("jacobian")),
}
}
fn update_diag(
&mut self,
lls: &mut LinearLeastSquaresDiagonalProblem,
) -> Result<(), TerminationReason> {
self.gnorm = match lls.max_a_t_b_scaled(self.residuals_norm) {
Some(max_at_b) => max_at_b,
None if !MINPACK_COMPAT => return Err(TerminationReason::Numerical("jacobian")),
None => 0.0,
};
if self.gnorm <= self.config.gtol {
return Err(TerminationReason::Orthogonal);
}
if self.first_update {
self.xnorm = if self.config.scale_diag {
for (d, col_norm) in self.diag.iter_mut().zip(lls.column_norms.iter()) {
*d = if *col_norm == 0.0 { 1.0 } else { *col_norm };
}
self.tmp.copy_from(&self.x);
self.tmp.component_mul_assign(&self.diag);
enorm(&self.tmp)
} else {
enorm(&self.x)
};
if !self.xnorm.is_finite() && !MINPACK_COMPAT {
return Err(TerminationReason::Numerical("subproblem x"));
}
self.delta = if self.xnorm == 0.0 {
self.config.stepbound
} else {
self.config.stepbound * self.xnorm
};
self.first_update = false;
} else if self.config.scale_diag {
for (d, norm) in self.diag.iter_mut().zip(lls.column_norms.iter()) {
*d = Float::max(*norm, *d);
}
}
Ok(())
}
fn trust_region_iteration(
&mut self,
lls: &mut LinearLeastSquaresDiagonalProblem,
param: LMParameter,
) -> Result<Option<DVector<f64>>, TerminationReason> {
const P1: f64 = 0.1;
const P0001: f64 = 1.0e-4;
self.lambda = param.lambda;
let pnorm = param.dp_norm;
if !pnorm.is_finite() && !MINPACK_COMPAT {
return Err(TerminationReason::Numerical("subproblem ||Dp||"));
}
let predicted_reduction;
let dir_der;
{
let temp1 = (lls.a_x_norm(¶m.step) / self.residuals_norm).powi(2);
if !temp1.is_finite() && !MINPACK_COMPAT {
return Err(TerminationReason::Numerical("trust-region reduction"));
}
let temp2 = ((self.lambda.sqrt() * pnorm) / self.residuals_norm).powi(2);
if !temp2.is_finite() && !MINPACK_COMPAT {
return Err(TerminationReason::Numerical("trust-region reduction"));
}
predicted_reduction = temp1 + temp2 / 0.5;
dir_der = -(temp1 + temp2);
}
if self.first_trust_region_iteration && pnorm < self.delta {
self.delta = pnorm;
}
self.first_trust_region_iteration = false;
self.tmp.copy_from(&self.x);
self.tmp -= ¶m.step;
self.target.set_params(&self.tmp);
self.report.number_of_evaluations += 1;
let new_objective_function;
let (residuals, new_residuals_norm) = if let Some(residuals) = self.target.residuals() {
if residuals.nrows() != self.m {
return Err(TerminationReason::WrongDimensions("residuals"));
}
let norm = enorm(&residuals);
new_objective_function = norm * norm * 0.5;
(residuals, norm)
} else {
return Err(TerminationReason::User("residuals"));
};
let actual_reduction = if new_residuals_norm * P1 < self.residuals_norm {
1.0 - (new_residuals_norm / self.residuals_norm).powi(2)
} else {
-1.0
};
let ratio = if predicted_reduction == 0.0 {
0.0
} else {
actual_reduction / predicted_reduction
};
let half = 0.5;
if ratio <= 0.25 {
let mut temp = if !actual_reduction.is_sign_negative() {
half
} else {
half * dir_der / (dir_der + half * actual_reduction)
};
if new_residuals_norm * P1 >= self.residuals_norm || temp < P1 {
temp = P1;
};
self.delta = temp * f64::min(self.delta, pnorm * 10.0);
self.lambda /= temp;
} else if self.lambda == 0.0 || ratio >= 0.75 {
self.delta = pnorm / 0.5;
self.lambda *= half;
}
let update_considered_good = ratio >= P0001;
if update_considered_good {
core::mem::swap(&mut self.x, &mut self.tmp);
self.xnorm = if self.config.scale_diag {
self.tmp.copy_from(&self.x);
self.tmp.component_mul_assign(&self.diag);
enorm(&self.tmp)
} else {
enorm(&self.x)
};
if !self.xnorm.is_finite() && !MINPACK_COMPAT {
return Err(TerminationReason::Numerical("new x"));
}
self.residuals_norm = new_residuals_norm;
self.report.objective_function = new_objective_function;
}
if !MINPACK_COMPAT && self.residuals_norm <= f64::MIN_POSITIVE {
self.reset_params_if(!update_considered_good);
return Err(TerminationReason::ResidualsZero);
}
let ftol_check = f64::abs(actual_reduction) <= self.config.ftol
&& predicted_reduction <= self.config.ftol
&& ratio * 0.5 <= 1.0;
let xtol_check = self.delta <= self.config.xtol * self.xnorm;
if ftol_check || xtol_check {
self.reset_params_if(!update_considered_good);
return Err(TerminationReason::Converged {
ftol: ftol_check,
xtol: xtol_check,
});
}
if self.report.number_of_evaluations >= self.max_fev {
self.reset_params_if(!update_considered_good);
return Err(TerminationReason::LostPatience);
}
if f64::abs(actual_reduction) <= epsmch()
&& predicted_reduction <= epsmch()
&& ratio * 0.5 <= 1.0
{
self.reset_params_if(!update_considered_good);
return Err(TerminationReason::NoImprovementPossible("ftol"));
}
if self.delta <= epsmch::<f64>() * self.xnorm {
self.reset_params_if(!update_considered_good);
return Err(TerminationReason::NoImprovementPossible("xtol"));
}
if self.gnorm <= epsmch() {
self.reset_params_if(!update_considered_good);
return Err(TerminationReason::NoImprovementPossible("gtol"));
}
if update_considered_good {
Ok(Some(residuals))
} else {
Ok(None)
}
}
#[inline]
fn reset_params_if(&mut self, reset: bool) {
if reset {
self.target.set_params(&self.x);
}
}
}