use crate::core::convergence::{
NativeConvergenceDiagnostics, NativeConvergenceTest,
};
use crate::core::least_squares::evaluation::Evaluation;
use crate::core::math::{
AddDiagonalVectorInPlace, ComponentDivAssign, ComponentMaxAssign,
ComponentMulAssign, ComponentZip, Dot, FactorizePivotedQr,
FloorZerosInPlace, GramMatrix, LinearSolveSpd, MatDiagonal,
MatTransposeVec, NegInPlace, NormInfinity, NormSquared, QrSolveError,
RegularizedQrSolve, Scalar, ScaleInPlace, ScaledAdd,
};
use crate::core::problem::Problem;
#[cfg(test)]
use crate::core::problem::{Jacobian, Residual};
use crate::core::solver::Solver;
use crate::core::state::{PointState, State};
use crate::core::termination::Termination;
use super::least_squares_diagnostics::{
LeastSquaresCheck, LeastSquaresDiagnostics, LeastSquaresObservation, Trace,
vector,
};
#[cfg(test)]
mod accounting;
mod damping;
mod stopping;
use damping::{
rescale_robust_damping, scaled_norm, trust_region_step, update_radius,
};
use stopping::{
all_finite, finite_unchanged_trial, orthogonality_converged,
relative_step_converged, relative_trust_radius_converged,
};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum LmDamping {
Nielsen,
#[default]
TrustRegion,
}
pub struct LevenbergMarquardt<V, M, F: Scalar = f64> {
tol_grad: Option<F>,
tol_grad_rel: Option<F>,
tol_cost_rel: Option<F>,
tol_step_rel: Option<F>,
tol_radius_rel: Option<F>,
tol_progress_grad: Option<F>,
numerical_no_progress: bool,
tau: F,
damping: LmDamping,
initial_step_bound: F,
radius: Option<F>,
first_trust_step: bool,
max_inner_attempts: u32,
mu: Option<F>,
nu: F,
rejected_step: bool,
diag: Option<V>,
r_cache: Option<V>,
model_cache: Option<Result<M, QrSolveError>>,
jtr_cache: Option<V>,
failed: bool,
native_convergence: Vec<NativeConvergenceTest>,
trace: Option<Trace<F>>,
}
impl<V, M, F: Scalar> NativeConvergenceDiagnostics
for LevenbergMarquardt<V, M, F>
{
fn native_convergence_tests(&self) -> &[NativeConvergenceTest] {
&self.native_convergence
}
}
impl<V, M, F: Scalar> LeastSquaresDiagnostics<F>
for LevenbergMarquardt<V, M, F>
{
fn least_squares_observations(&self) -> &[LeastSquaresObservation<F>] {
self.trace.as_ref().map_or(&[], |trace| &trace.observations)
}
}
impl<V, M, F: Scalar> Default for LevenbergMarquardt<V, M, F> {
fn default() -> Self {
Self::defaults()
}
}
impl<V, M> LevenbergMarquardt<V, M> {
pub fn new() -> Self {
Self::defaults()
}
}
impl<V, M, F: Scalar> LevenbergMarquardt<V, M, F> {
fn defaults() -> Self {
Self {
tol_grad: Some(F::from_f64(1e-8).unwrap()),
tol_grad_rel: None,
tol_cost_rel: None,
tol_step_rel: None,
tol_radius_rel: None,
tol_progress_grad: None,
numerical_no_progress: true,
tau: F::from_f64(1e-3).unwrap(),
damping: LmDamping::default(),
initial_step_bound: F::from_f64(100.0).unwrap(),
radius: None,
first_trust_step: true,
max_inner_attempts: 50,
mu: None,
nu: F::from_f64(2.0).unwrap(),
rejected_step: false,
diag: None,
r_cache: None,
model_cache: None,
jtr_cache: None,
failed: false,
native_convergence: Vec::new(),
trace: None,
}
}
pub fn with_trial_diagnostics(mut self, enabled: bool) -> Self {
self.trace = enabled.then(Trace::default);
self
}
pub fn with_absolute_gradient_tolerance(
mut self,
value: impl Into<Option<F>>,
) -> Self {
self.tol_grad = crate::core::convergence::optional_tolerance(value);
self
}
pub fn with_absolute_progress_gradient_tolerance(
mut self,
value: impl Into<Option<F>>,
) -> Self {
self.tol_progress_grad =
crate::core::convergence::optional_tolerance(value);
self
}
pub fn with_gradient_orthogonality_tolerance(
mut self,
value: impl Into<Option<F>>,
) -> Self {
self.tol_grad_rel = crate::core::convergence::optional_tolerance(value);
self
}
pub fn with_relative_model_reduction_tolerance(
mut self,
value: impl Into<Option<F>>,
) -> Self {
self.tol_cost_rel = crate::core::convergence::optional_tolerance(value);
self
}
pub fn with_relative_step_tolerance(
mut self,
value: impl Into<Option<F>>,
) -> Self {
self.tol_step_rel = crate::core::convergence::optional_tolerance(value);
self
}
pub fn with_relative_trust_radius_tolerance(
mut self,
value: impl Into<Option<F>>,
) -> Self {
self.tol_radius_rel =
crate::core::convergence::optional_tolerance(value);
self
}
pub fn with_no_progress_check(mut self, enabled: bool) -> Self {
self.numerical_no_progress = enabled;
self
}
pub fn with_damping(mut self, damping: LmDamping) -> Self {
self.damping = damping;
self
}
pub fn with_initial_step_bound(mut self, factor: F) -> Self {
assert!(
factor.is_finite() && factor > F::zero(),
"initial step bound must be finite and > 0"
);
self.initial_step_bound = factor;
self
}
pub fn with_tau(mut self, tau: F) -> Self {
assert!(tau > F::zero(), "tau must be > 0");
self.tau = tau;
self
}
pub fn with_max_inner_attempts(mut self, n: u32) -> Self {
assert!(n > 0, "max_inner_attempts must be > 0");
self.max_inner_attempts = n;
self
}
}
impl<P, V, M, F, E> Solver<P, PointState<V, F>> for LevenbergMarquardt<V, M, F>
where
F: Scalar,
Problem<P>: Evaluation<V, M, F, Error = E>,
V: ScaledAdd<F>
+ NormSquared<F>
+ NormInfinity<F>
+ NegInPlace
+ Dot<F>
+ ScaleInPlace<F>
+ ComponentMulAssign
+ ComponentDivAssign
+ ComponentZip<F>
+ ComponentMaxAssign
+ FloorZerosInPlace<F>
+ Clone,
M: GramMatrix
+ MatTransposeVec<V>
+ LinearSolveSpd<V>
+ AddDiagonalVectorInPlace<V>
+ MatDiagonal<V>
+ Clone,
{
type Error = E;
fn should_check_iterate_change(&self) -> bool {
!self.rejected_step
}
fn reset_convergence(&mut self) {
self.native_convergence.clear();
if let Some(trace) = &mut self.trace {
trace.reset();
}
}
fn init(
&mut self,
problem: &mut Problem<P>,
state: PointState<V, F>,
) -> Result<PointState<V, F>, Self::Error> {
self.init_model::<_, M, NormalEquations>(problem, state)
}
fn next_iter(
&mut self,
problem: &mut Problem<P>,
state: PointState<V, F>,
) -> Result<crate::SolverStep<PointState<V, F>>, Self::Error> {
self.next_iter_model::<_, M, NormalEquations>(problem, state, None)
}
}
impl<V, C, F: Scalar> LevenbergMarquardt<V, C, F> {
fn init_model<E, M, Model>(
&mut self,
problem: &mut E,
mut state: PointState<V, F>,
) -> Result<PointState<V, F>, E::Error>
where
E: Evaluation<V, M, F>,
M: MatTransposeVec<V>,
Model: LinearModel<M, V, F, Cache = C>,
V: ScaledAdd<F>
+ NormSquared<F>
+ NormInfinity<F>
+ NegInPlace
+ Dot<F>
+ ScaleInPlace<F>
+ ComponentMulAssign
+ ComponentDivAssign
+ ComponentZip<F>
+ ComponentMaxAssign
+ FloorZerosInPlace<F>
+ Clone,
{
state.reset();
self.failed = false;
self.rejected_step = false;
self.native_convergence.clear();
if let Some(trace) = &mut self.trace {
trace.reset();
}
self.r_cache = None;
self.model_cache = None;
self.jtr_cache = None;
self.diag = None;
self.radius = None;
self.mu = None;
self.nu = F::from_f64(2.0).unwrap();
self.first_trust_step = true;
let (r, j) = problem.residual_and_jacobian(state.param())?;
state.replace(
state.param().clone(),
problem.cost(&r, |r| F::from_f64(0.5).unwrap() * r.norm_squared()),
);
let Some((model_r, j)) = problem.model(&r, j) else {
self.failed = true;
return Ok(state);
};
let a = Model::prepare(&j, model_r.as_ref().unwrap_or(&r));
self.diag = a.as_ref().ok().map(|a| {
let mut d = Model::diagonal(a);
d.floor_zeros_in_place(F::one());
d
});
self.radius = if self.damping == LmDamping::TrustRegion {
self.diag.as_ref().map(|d| {
let xnorm = scaled_norm(state.param(), d);
self.initial_step_bound
* if xnorm == F::zero() { F::one() } else { xnorm }
})
} else {
None
};
self.first_trust_step = true;
self.mu = Some(if self.damping == LmDamping::Nielsen {
self.tau
} else {
F::zero()
});
self.nu = F::from_f64(2.0).unwrap();
let gradient = j.mat_transpose_vec(model_r.as_ref().unwrap_or(&r));
self.failed = !problem.valid_vector(&gradient);
self.jtr_cache = Some(gradient);
self.model_cache = Some(a);
self.r_cache = Some(r);
Ok(state)
}
fn next_iter_model<E, M, Model>(
&mut self,
problem: &mut E,
mut state: PointState<V, F>,
rank_tolerance: Option<F>,
) -> LmStep<V, F, E::Error>
where
E: Evaluation<V, M, F>,
M: MatTransposeVec<V>,
Model: LinearModel<M, V, F, Cache = C>,
V: ScaledAdd<F>
+ NormSquared<F>
+ NormInfinity<F>
+ NegInPlace
+ Dot<F>
+ ScaleInPlace<F>
+ ComponentMulAssign
+ ComponentDivAssign
+ ComponentZip<F>
+ ComponentMaxAssign
+ FloorZerosInPlace<F>
+ Clone,
{
self.native_convergence.clear();
if let Some(trace) = &mut self.trace {
trace.clear();
}
if self.failed {
if let Some(trace) = &mut self.trace {
trace.event(
state.cost(),
"construction",
"invalid_initial_model",
);
}
return Ok(crate::SolverStep::from((
state,
Some(Termination::numerical_failure(
"LM initialization produced an invalid residual model.",
)),
)));
}
let r = match self.r_cache.take() {
Some(r) => r,
None => problem.residual(state.param())?,
};
let (a, g) = match (self.model_cache.take(), self.jtr_cache.take()) {
(Some(a), Some(g)) => (a, g),
_ => {
let j = problem.jacobian(state.param())?;
let Some((model_r, j)) = problem.model(&r, j) else {
self.failed = true;
if let Some(trace) = &mut self.trace {
trace.event(
state.cost(),
"construction",
"invalid_residual_model",
);
}
return Ok(crate::SolverStep::from((
state,
Some(Termination::numerical_failure(
"The least-squares objective cannot produce a valid local model.",
)),
)));
};
(
Model::prepare(&j, model_r.as_ref().unwrap_or(&r)),
j.mat_transpose_vec(model_r.as_ref().unwrap_or(&r)),
)
}
};
let a = match a {
Ok(a) => a,
Err(error) => {
if let Some(trace) = &mut self.trace {
let index = trace.event(
state.cost(),
"construction",
"factorization_failed",
);
trace.observations[index].gradient = vector(&g);
}
self.model_cache = Some(Err(error));
self.r_cache = Some(r);
self.jtr_cache = Some(g);
return Ok(crate::SolverStep::from((
state,
Some(Termination::numerical_failure(
"LM model factorization failed.",
)),
)));
}
};
if (Model::CHECK_FINITE
|| self.damping == LmDamping::TrustRegion
|| E::ROBUST)
&& ((!E::ROBUST && !r.norm_squared().is_finite())
|| !state.cost().is_finite()
|| !g.norm_infinity().is_finite()
|| !problem.valid_vector(&g))
{
if let Some(trace) = &mut self.trace {
let index = trace.event(
state.cost(),
"construction",
"nonfinite_model",
);
trace.observations[index].gradient = vector(&g);
trace.observations[index].arithmetic = vec![
("residual_square", r.norm_squared()),
("gradient_infinity", g.norm_infinity()),
];
}
self.model_cache = Some(Ok(a));
self.r_cache = Some(r);
self.jtr_cache = Some(g);
return Ok(crate::SolverStep::from((
state,
Some(Termination::numerical_failure(
"The residual, objective, or gradient is non-finite.",
)),
)));
}
let diag_cur = Model::diagonal(&a);
let abs_converged =
self.tol_grad.is_some_and(|tol| g.norm_infinity() <= tol);
let rel_converged = self.tol_grad_rel.is_some_and(|tol| {
if E::ROBUST {
stopping::robust_gradient_converged(
&g,
&diag_cur,
state.cost(),
tol,
)
} else {
orthogonality_converged(&g, &diag_cur, &r, tol)
}
});
if let Some(trace) = &mut self.trace {
let mut row = LeastSquaresObservation::model(state.cost());
row.gradient = vector(&g);
row.diagonal = vector(&diag_cur);
row.checks.push(LeastSquaresCheck {
name: "absolute_gradient",
tolerance: self.tol_grad,
passed: self.tol_grad.map(|_| abs_converged),
evidence: self.tol_grad.map(|tolerance| {
crate::ConvergenceEvidence::UpperBound {
value: g.norm_infinity(),
bound: tolerance,
tolerance,
reference: None,
}
}),
});
row.checks.push(LeastSquaresCheck {
name: "orthogonality",
tolerance: self.tol_grad_rel,
passed: self.tol_grad_rel.map(|_| rel_converged),
evidence: self.tol_grad_rel.and_then(|tolerance| {
stopping::try_orthogonality_evidence(
&g,
&diag_cur,
&r,
E::ROBUST.then_some(state.cost()),
tolerance,
)
}),
});
trace.push(row);
}
if abs_converged || rel_converged {
let absolute = abs_converged.then(|| {
Termination::upper_bound(
crate::ConvergenceTest::AbsoluteGradientInfinity,
g.norm_infinity(),
self.tol_grad.unwrap(),
self.tol_grad.unwrap(),
None,
)
});
let relative = rel_converged.then(|| {
Termination::converged(crate::ConvergenceCriterion {
test: if E::ROBUST {
crate::ConvergenceTest::RobustGradientOrthogonality
} else {
crate::ConvergenceTest::GradientOrthogonality
},
evidence: stopping::orthogonality_evidence(
&g,
&diag_cur,
&r,
E::ROBUST.then_some(state.cost()),
self.tol_grad_rel.unwrap(),
),
})
});
let termination = absolute
.into_iter()
.chain(relative)
.reduce(Termination::merge)
.unwrap();
if abs_converged {
self.native_convergence
.push(NativeConvergenceTest::AbsoluteGradient);
}
if rel_converged {
self.native_convergence.push(if E::ROBUST {
NativeConvergenceTest::RobustGradientOrthogonality
} else {
NativeConvergenceTest::GradientOrthogonality
});
}
self.r_cache = Some(r);
self.model_cache = Some(Ok(a));
self.jtr_cache = Some(g);
return Ok(crate::SolverStep::from((state, Some(termination))));
}
let mut d = self
.diag
.take()
.expect("diag not set: Solver::init must run before next_iter");
let mut mu = self
.mu
.expect("mu not set: Solver::init must run before next_iter");
let mut nu = self.nu;
if E::ROBUST && self.damping == LmDamping::Nielsen {
mu = rescale_robust_damping(mu, &d, &diag_cur);
}
d.component_max_assign(&diag_cur);
let two = F::from_f64(2.0).unwrap();
let half = F::from_f64(0.5).unwrap();
let one_third = F::from_f64(1.0 / 3.0).unwrap();
let mut model_solves = 0;
let mut last_solve: Option<usize> = None;
let mut search_gradient = None;
let step = if self.damping == LmDamping::TrustRegion {
let radius = self.radius.expect("trust radius not initialized");
let mut scaled_gradient = g.clone();
scaled_gradient.component_div_assign(&d);
let gradient_norm = g.dot(&scaled_gradient).sqrt();
search_gradient = Some(gradient_norm);
trust_region_step(
radius,
&mut mu,
self.max_inner_attempts,
gradient_norm,
|mu| {
model_solves += 1;
let result = Model::solve(&a, &g, &d, mu, rank_tolerance);
if let Some(trace) = &mut self.trace {
if let Some(index) = last_solve {
trace.observations[index].damping_after = Some(mu);
}
let mut row =
LeastSquaresObservation::model(state.cost());
row.event = "subproblem";
row.outcome = Some(match &result {
Ok(_) => "solved",
Err(ModelSolveError::Retry) => "retry",
Err(ModelSolveError::Failed) => "failed",
});
row.damping = Some(mu);
row.radius_before = self.radius;
row.model_solves = 1;
row.gradient = vector(&g);
row.diagonal = vector(&d);
if let Ok(h) = &result {
row.step = vector(h);
row.step_norm = Some(scaled_norm(h, &d));
}
last_solve = Some(trace.push(row));
}
result
},
|h| scaled_norm(h, &d),
)
} else {
let mut attempts = 0;
loop {
model_solves += 1;
let result = Model::solve(&a, &g, &d, mu, rank_tolerance);
if let Some(trace) = &mut self.trace {
let mut row = LeastSquaresObservation::model(state.cost());
row.event = "subproblem";
row.outcome = Some(match &result {
Ok(_) => "solved",
Err(ModelSolveError::Retry) => "retry",
Err(ModelSolveError::Failed) => "failed",
});
row.damping = Some(mu);
row.damping_after = Some(mu);
row.model_solves = 1;
row.gradient = vector(&g);
row.diagonal = vector(&d);
row.arithmetic.push(("retry_multiplier", nu));
if let Ok(h) = &result {
row.step = vector(h);
row.step_norm = Some(scaled_norm(h, &d));
}
last_solve = Some(trace.push(row));
}
match result {
Ok(step) => break Ok(step),
Err(failure) => {
attempts += 1;
if failure == ModelSolveError::Failed
|| attempts >= self.max_inner_attempts
|| !mu.is_finite()
{
break Err(failure);
}
mu = mu * nu;
nu = nu * two;
if let (Some(trace), Some(index)) =
(&mut self.trace, last_solve)
{
trace.observations[index].damping_after = Some(mu);
}
}
}
}
};
if self.damping == LmDamping::TrustRegion {
if let Some(trace) = &mut self.trace {
let index = trace.event(
state.cost(),
"damping_search",
if step.is_ok() { "selected" } else { "failed" },
);
let row = &mut trace.observations[index];
row.damping_after = Some(mu);
row.arithmetic
.push(("scaled_gradient_norm", search_gradient.unwrap()));
row.radius_before = self.radius;
row.radius_after = self.radius;
if let Some(previous) = last_solve {
trace.observations[previous].damping_after = Some(mu);
}
}
}
let h = match step {
Ok(h) => h,
Err(_) => {
if let (Some(trace), Some(index)) =
(&mut self.trace, last_solve)
{
if trace.observations[index].outcome == Some("retry") {
trace.observations[index].outcome = Some("exhausted");
}
}
self.mu = Some(mu);
self.nu = nu;
self.diag = Some(d);
self.r_cache = Some(r);
self.model_cache = Some(Ok(a));
self.jtr_cache = Some(g);
return Ok(crate::SolverStep::from((
state,
Some(Termination::numerical_failure(
"LM could not solve the damped model within its retry limit.",
)),
)));
}
};
let mut dh = d.clone();
dh.component_mul_assign(&h);
let l_diff = half * (mu * h.dot(&dh) - h.dot(&g));
let mut x_trial = state.param().clone();
x_trial.scaled_add(F::one(), &h);
let trial_record = self.trace.as_mut().map(|trace| {
let mut row = LeastSquaresObservation::model(state.cost());
row.event = "trial";
row.trial = true;
row.step = vector(&h);
row.gradient = vector(&g);
row.diagonal = vector(&d);
row.damping = Some(mu);
row.radius_before = self.radius;
row.step_norm = Some(scaled_norm(&h, &d));
row.model_solves = model_solves;
row.predicted_reduction = Some(l_diff);
trace.push(row)
});
let r_trial = match problem.residual(&x_trial) {
Ok(r) => r,
Err(error) => {
if let Some(index) = trial_record {
self.trace.as_mut().unwrap().observations[index].outcome =
Some("callback_failed");
}
return Err(error);
}
};
let f_trial = problem.cost(&r_trial, |r| half * r.norm_squared());
let prev_cost = state.cost();
let actual_diff = prev_cost - f_trial;
let rho = if l_diff > F::zero() {
actual_diff / l_diff
} else {
F::zero()
};
let numerical_no_progress = self.numerical_no_progress
&& rho <= F::zero()
&& finite_unchanged_trial(state.param(), &x_trial)
&& [prev_cost, f_trial, actual_diff, l_diff, rho]
.iter()
.all(|value| value.is_finite())
&& [&h, &r, &r_trial, &g].into_iter().all(all_finite);
let radius_tolerance = self.tol_radius_rel.filter(|_| {
self.damping == LmDamping::TrustRegion
&& [prev_cost, f_trial, actual_diff, l_diff, rho]
.into_iter()
.all(|value| value.is_finite())
&& [state.param(), &x_trial, &h, &r, &r_trial, &g]
.into_iter()
.all(all_finite)
});
if self.damping == LmDamping::TrustRegion {
let pnorm = h.dot(&dh).sqrt();
let radius =
self.radius.as_mut().expect("trust radius not initialized");
if self.first_trust_step && pnorm > F::zero() {
*radius = radius.min(pnorm);
}
update_radius(radius, &mut mu, pnorm, rho, actual_diff, h.dot(&g));
}
if rho > F::zero() {
self.rejected_step = false;
state.replace(x_trial, f_trial);
if self.damping == LmDamping::Nielsen {
let factor = F::one() - (two * rho - F::one()).powi(3);
mu = mu * factor.max(one_third);
nu = two;
}
self.first_trust_step = false;
self.r_cache = Some(r_trial);
self.model_cache = None;
self.jtr_cache = None;
} else {
self.rejected_step = true;
if self.damping == LmDamping::Nielsen {
mu = mu * nu;
nu = nu * two;
}
self.r_cache = Some(r);
self.model_cache = Some(Ok(a));
self.jtr_cache = Some(g);
}
let radius_rel_converged = radius_tolerance.is_some_and(|tol| {
relative_trust_radius_converged(
self.radius.expect("trust radius not initialized"),
state.param(),
&d,
tol,
)
});
self.mu = Some(mu);
self.nu = nu;
self.diag = Some(d);
let cost_rel_converged = self.tol_cost_rel.is_some_and(|tol| {
actual_diff.abs() <= tol * prev_cost
&& l_diff <= tol * prev_cost
&& rho <= two
});
let step_rel_converged = self
.tol_step_rel
.is_some_and(|tol| relative_step_converged(&h, state.param(), tol));
if let Some(index) = trial_record {
let row = &mut self.trace.as_mut().unwrap().observations[index];
row.trial_cost = Some(f_trial);
row.actual_reduction = Some(actual_diff);
row.gain_ratio = Some(rho);
row.accepted = Some(rho > F::zero());
row.radius_after = self.radius;
row.damping_after = Some(mu);
row.outcome = Some(if rho > F::zero() {
"accepted"
} else {
"rejected"
});
row.checks.push(LeastSquaresCheck {
name: "model_reduction",
tolerance: self.tol_cost_rel,
passed: self.tol_cost_rel.map(|_| cost_rel_converged),
evidence: self.tol_cost_rel.map(|tolerance| {
crate::ConvergenceEvidence::ModelReduction {
actual: actual_diff,
predicted: l_diff,
reference_cost: prev_cost,
gain_ratio: rho,
tolerance,
}
}),
});
row.checks.push(LeastSquaresCheck {
name: "trial_step",
tolerance: self.tol_step_rel,
passed: self.tol_step_rel.map(|_| step_rel_converged),
evidence: self.tol_step_rel.and_then(|tolerance| {
stopping::try_step_evidence(&h, state.param(), tolerance)
}),
});
row.checks.push(LeastSquaresCheck {
name: "trust_radius",
tolerance: radius_tolerance,
passed: radius_tolerance.map(|_| radius_rel_converged),
evidence: radius_tolerance.and_then(|tolerance| {
stopping::try_radius_evidence(
self.radius.unwrap(),
state.param(),
self.diag.as_ref().unwrap(),
tolerance,
)
}),
});
}
let progress_converged =
cost_rel_converged || step_rel_converged || radius_rel_converged;
let mut progress_gradient = None;
if progress_converged {
if let Some(tolerance) = self.tol_progress_grad {
if rho > F::zero() {
let j = match problem.jacobian(state.param()) {
Ok(j) => j,
Err(error) => {
if let Some(index) = trial_record {
self.trace.as_mut().unwrap().observations
[index]
.outcome = Some("accepted_jacobian_failed");
}
return Err(error);
}
};
let retained_r = self.r_cache.as_ref().unwrap();
let Some((model_r, j)) = problem.model(retained_r, j)
else {
self.failed = true;
if let Some(trace) = &mut self.trace {
trace.event(
state.cost(),
"construction",
"invalid_retained_model",
);
}
return Ok(
crate::SolverStep::completed_with_termination(
state,
Termination::numerical_failure(
"The retained least-squares point has an invalid local model.",
),
),
);
};
self.jtr_cache = Some(j.mat_transpose_vec(
model_r.as_ref().unwrap_or(retained_r),
));
self.model_cache = Some(Model::prepare(
&j,
model_r.as_ref().unwrap_or(retained_r),
));
}
let gradient = self.jtr_cache.as_ref().unwrap();
let valid = all_finite(gradient)
&& problem.valid_vector(gradient)
&& state.cost().is_finite()
&& (!E::ROBUST
|| all_finite(self.r_cache.as_ref().unwrap()));
if !valid || self.model_cache.as_ref().unwrap().is_err() {
if let Some(trace) = &mut self.trace {
trace.event(
state.cost(),
"construction",
"invalid_retained_model",
);
}
return Ok(crate::SolverStep::completed_with_termination(
state,
Termination::numerical_failure(
"The retained least-squares point has an invalid gradient or model.",
),
));
}
let value = gradient.norm_infinity();
progress_gradient = Some((value, tolerance));
if let Some(index) = trial_record {
self.trace.as_mut().unwrap().observations[index]
.checks
.push(LeastSquaresCheck {
name: "progress_gradient",
tolerance: Some(tolerance),
passed: Some(value <= tolerance),
evidence: Some(
crate::ConvergenceEvidence::UpperBound {
value,
bound: tolerance,
tolerance,
reference: None,
},
),
});
}
}
}
if progress_converged
&& progress_gradient
.is_none_or(|(value, tolerance)| value <= tolerance)
{
let model = cost_rel_converged.then(|| {
Termination::converged(crate::ConvergenceCriterion {
test: crate::ConvergenceTest::RelativeModelReduction,
evidence: crate::ConvergenceEvidence::ModelReduction {
actual: actual_diff,
predicted: l_diff,
reference_cost: prev_cost,
gain_ratio: rho,
tolerance: self.tol_cost_rel.unwrap(),
},
})
});
let step = step_rel_converged.then(|| {
Termination::converged(crate::ConvergenceCriterion {
test: crate::ConvergenceTest::RelativeTrialStep,
evidence: stopping::step_evidence(
&h,
state.param(),
self.tol_step_rel.unwrap(),
),
})
});
let radius = radius_rel_converged.then(|| {
Termination::converged(crate::ConvergenceCriterion {
test: crate::ConvergenceTest::RelativeTrustRadius,
evidence: stopping::radius_evidence(
self.radius.unwrap(),
state.param(),
self.diag.as_ref().unwrap(),
self.tol_radius_rel.unwrap(),
),
})
});
let termination = model
.into_iter()
.chain(step)
.chain(radius)
.map(|termination| match progress_gradient {
Some((value, tolerance)) => stopping::guarded_progress(
termination,
value,
tolerance,
),
None => termination,
})
.reduce(Termination::merge)
.unwrap();
if cost_rel_converged {
self.native_convergence
.push(NativeConvergenceTest::RelativeModelReduction);
}
if step_rel_converged {
self.native_convergence
.push(NativeConvergenceTest::RelativeTrialStep);
}
if radius_rel_converged {
self.native_convergence
.push(NativeConvergenceTest::RelativeTrustRadius);
}
return Ok(crate::SolverStep::completed_with_termination(
state,
termination,
));
}
if numerical_no_progress {
return Ok(crate::SolverStep::from((
state,
Some(Termination::numerical_stall(
"A finite rejected LM trial changes no parameter in floating-point arithmetic.",
)),
)));
}
Ok(crate::SolverStep::from((state, None)))
}
}
type LmStep<V, F, E> = Result<crate::SolverStep<PointState<V, F>>, E>;
#[derive(PartialEq)]
enum ModelSolveError {
Retry,
Failed,
}
trait LinearModel<M, V, F: Scalar> {
type Cache;
const CHECK_FINITE: bool;
fn prepare(j: &M, r: &V) -> Result<Self::Cache, QrSolveError>;
fn diagonal(cache: &Self::Cache) -> V;
fn solve(
cache: &Self::Cache,
g: &V,
d: &V,
mu: F,
tolerance: Option<F>,
) -> Result<V, ModelSolveError>;
}
struct NormalEquations;
impl<M, V, F: Scalar> LinearModel<M, V, F> for NormalEquations
where
M: GramMatrix
+ MatDiagonal<V>
+ LinearSolveSpd<V>
+ AddDiagonalVectorInPlace<V>
+ Clone,
V: Clone + NegInPlace + ScaleInPlace<F>,
{
type Cache = M;
const CHECK_FINITE: bool = false;
fn prepare(j: &M, _: &V) -> Result<M, QrSolveError> {
Ok(j.gram())
}
fn diagonal(cache: &M) -> V {
cache.diagonal()
}
fn solve(
cache: &M,
g: &V,
d: &V,
mu: F,
_: Option<F>,
) -> Result<V, ModelSolveError> {
let mut a = cache.clone();
let mut diagonal = d.clone();
diagonal.scale_in_place(mu);
a.add_diagonal_vector_in_place(&diagonal);
let mut rhs = g.clone();
rhs.neg_in_place();
a.solve_spd(&rhs).map_err(|_| ModelSolveError::Retry)
}
}
struct PivotedQr;
impl<M, V, F: Scalar> LinearModel<M, V, F> for PivotedQr
where
M: FactorizePivotedQr<V, F>,
V: Clone + NegInPlace,
{
type Cache = M::Factorization;
const CHECK_FINITE: bool = true;
fn prepare(j: &M, r: &V) -> Result<Self::Cache, QrSolveError> {
let mut rhs = r.clone();
rhs.neg_in_place();
j.factorize_pivoted_qr(&rhs)
}
fn diagonal(cache: &Self::Cache) -> V {
cache.column_norms_squared()
}
fn solve(
cache: &Self::Cache,
_: &V,
d: &V,
mu: F,
tolerance: Option<F>,
) -> Result<V, ModelSolveError> {
cache.solve_regularized(mu, d, tolerance).map_err(|e| {
if e == QrSolveError::RankDeficient {
ModelSolveError::Retry
} else {
ModelSolveError::Failed
}
})
}
}
pub struct LevenbergMarquardtQr<V, M, F: Scalar = f64>
where
M: FactorizePivotedQr<V, F>,
{
inner: LevenbergMarquardt<V, M::Factorization, F>,
rank_tolerance: Option<F>,
}
impl<V, M: FactorizePivotedQr<V, F>, F: Scalar> NativeConvergenceDiagnostics
for LevenbergMarquardtQr<V, M, F>
{
fn native_convergence_tests(&self) -> &[NativeConvergenceTest] {
self.inner.native_convergence_tests()
}
}
impl<V, M: FactorizePivotedQr<V, F>, F: Scalar> LeastSquaresDiagnostics<F>
for LevenbergMarquardtQr<V, M, F>
{
fn least_squares_observations(&self) -> &[LeastSquaresObservation<F>] {
self.inner.least_squares_observations()
}
}
impl<V, M, F: Scalar> LevenbergMarquardt<V, M, F> {
pub fn with_pivoted_qr(self) -> LevenbergMarquardtQr<V, M, F>
where
M: FactorizePivotedQr<V, F>,
{
LevenbergMarquardtQr {
inner: LevenbergMarquardt {
tol_grad: self.tol_grad,
tol_grad_rel: self.tol_grad_rel,
tol_cost_rel: self.tol_cost_rel,
tol_step_rel: self.tol_step_rel,
tol_radius_rel: self.tol_radius_rel,
tol_progress_grad: self.tol_progress_grad,
numerical_no_progress: self.numerical_no_progress,
trace: self.trace.map(|_| Trace::default()),
tau: self.tau,
damping: self.damping,
initial_step_bound: self.initial_step_bound,
max_inner_attempts: self.max_inner_attempts,
..LevenbergMarquardt::defaults()
},
rank_tolerance: None,
}
}
}
impl<V, M, F: Scalar> Default for LevenbergMarquardtQr<V, M, F>
where
M: FactorizePivotedQr<V, F>,
{
fn default() -> Self {
Self::new()
}
}
impl<V, M, F: Scalar> LevenbergMarquardtQr<V, M, F>
where
M: FactorizePivotedQr<V, F>,
{
pub fn new() -> Self {
Self {
inner: LevenbergMarquardt::defaults(),
rank_tolerance: None,
}
}
pub fn with_trial_diagnostics(mut self, enabled: bool) -> Self {
self.inner = self.inner.with_trial_diagnostics(enabled);
self
}
pub fn with_relative_rank_tolerance(mut self, tol: F) -> Self {
assert!(
tol.is_finite() && tol >= F::zero() && tol < F::one(),
"rank tolerance must be finite and in [0,1)"
);
self.rank_tolerance = Some(tol);
self
}
pub fn with_absolute_gradient_tolerance(
mut self,
value: impl Into<Option<F>>,
) -> Self {
self.inner = self.inner.with_absolute_gradient_tolerance(value);
self
}
pub fn with_absolute_progress_gradient_tolerance(
mut self,
value: impl Into<Option<F>>,
) -> Self {
self.inner =
self.inner.with_absolute_progress_gradient_tolerance(value);
self
}
pub fn with_gradient_orthogonality_tolerance(
mut self,
value: impl Into<Option<F>>,
) -> Self {
self.inner = self.inner.with_gradient_orthogonality_tolerance(value);
self
}
pub fn with_relative_model_reduction_tolerance(
mut self,
value: impl Into<Option<F>>,
) -> Self {
self.inner = self.inner.with_relative_model_reduction_tolerance(value);
self
}
pub fn with_relative_step_tolerance(
mut self,
value: impl Into<Option<F>>,
) -> Self {
self.inner = self.inner.with_relative_step_tolerance(value);
self
}
pub fn with_relative_trust_radius_tolerance(
mut self,
value: impl Into<Option<F>>,
) -> Self {
self.inner = self.inner.with_relative_trust_radius_tolerance(value);
self
}
pub fn with_no_progress_check(mut self, enabled: bool) -> Self {
self.inner = self.inner.with_no_progress_check(enabled);
self
}
pub fn with_damping(mut self, damping: LmDamping) -> Self {
self.inner = self.inner.with_damping(damping);
self
}
pub fn with_initial_step_bound(mut self, factor: F) -> Self {
self.inner = self.inner.with_initial_step_bound(factor);
self
}
pub fn with_tau(mut self, value: F) -> Self {
self.inner = self.inner.with_tau(value);
self
}
pub fn with_max_inner_attempts(mut self, value: u32) -> Self {
self.inner = self.inner.with_max_inner_attempts(value);
self
}
}
impl<P, V, M, F, E> Solver<P, PointState<V, F>>
for LevenbergMarquardtQr<V, M, F>
where
F: Scalar,
Problem<P>: Evaluation<V, M, F, Error = E>,
V: ScaledAdd<F>
+ NormSquared<F>
+ NormInfinity<F>
+ NegInPlace
+ Dot<F>
+ ScaleInPlace<F>
+ ComponentMulAssign
+ ComponentDivAssign
+ ComponentZip<F>
+ ComponentMaxAssign
+ FloorZerosInPlace<F>
+ Clone,
M: FactorizePivotedQr<V, F> + MatTransposeVec<V>,
{
type Error = E;
fn should_check_iterate_change(&self) -> bool {
!self.inner.rejected_step
}
fn reset_convergence(&mut self) {
self.inner.native_convergence.clear();
if let Some(trace) = &mut self.inner.trace {
trace.reset();
}
}
fn init(
&mut self,
problem: &mut Problem<P>,
state: PointState<V, F>,
) -> Result<PointState<V, F>, Self::Error> {
self.inner.init_model::<_, M, PivotedQr>(problem, state)
}
fn next_iter(
&mut self,
problem: &mut Problem<P>,
state: PointState<V, F>,
) -> Result<crate::SolverStep<PointState<V, F>>, Self::Error> {
self.inner.next_iter_model::<_, M, PivotedQr>(
problem,
state,
self.rank_tolerance,
)
}
}
impl<V: Clone, M, F: Scalar> crate::core::inner::InitialState<V>
for LevenbergMarquardtQr<V, M, F>
where
M: FactorizePivotedQr<V, F>,
{
type State = PointState<V, F>;
fn seed(&self, x: &V) -> Self::State {
PointState::new(x.clone())
}
}
impl<V: Clone, M, F: Scalar> crate::core::inner::WarmStart<V>
for LevenbergMarquardtQr<V, M, F>
where
M: FactorizePivotedQr<V, F>,
{
}
impl<V: Clone, M, F: Scalar> super::cma_inject::MemeticInner<V, F>
for LevenbergMarquardtQr<V, M, F>
where
M: FactorizePivotedQr<V, F>,
{
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{DenseMatrix, Executor};
#[derive(Clone)]
struct CholeskyOnly(DenseMatrix);
impl GramMatrix for CholeskyOnly {
fn gram(&self) -> Self {
Self(self.0.gram())
}
}
impl MatDiagonal<Vec<f64>> for CholeskyOnly {
fn diagonal(&self) -> Vec<f64> {
self.0.diagonal()
}
}
impl MatTransposeVec<Vec<f64>> for CholeskyOnly {
fn mat_transpose_vec(&self, v: &Vec<f64>) -> Vec<f64> {
self.0.mat_transpose_vec(v)
}
}
impl AddDiagonalVectorInPlace<Vec<f64>> for CholeskyOnly {
fn add_diagonal_vector_in_place(&mut self, d: &Vec<f64>) {
self.0.add_diagonal_vector_in_place(d);
}
}
impl LinearSolveSpd<Vec<f64>> for CholeskyOnly {
fn solve_spd(
&self,
b: &Vec<f64>,
) -> Result<Vec<f64>, crate::LinearSolveError> {
self.0.solve_spd(b)
}
}
struct Fit;
impl crate::Constraints for Fit {
type Set = crate::NoConstraints;
fn constraints(
&self,
) -> <Self::Set as crate::ConstraintDescription>::Borrowed<'_> {
crate::ConstraintSet::new()
}
}
impl Residual for Fit {
type Param = Vec<f64>;
type Output = Vec<f64>;
type Error = std::convert::Infallible;
fn residual(&self, x: &Vec<f64>) -> Result<Vec<f64>, Self::Error> {
Ok(vec![x[0] - 1.])
}
}
impl Jacobian for Fit {
type Jacobian = CholeskyOnly;
fn jacobian(&self, _: &Vec<f64>) -> Result<CholeskyOnly, Self::Error> {
Ok(CholeskyOnly(DenseMatrix::from_row_slice(1, 1, &[1.])))
}
}
#[test]
fn legacy_annotations_and_cholesky_only_capabilities_still_work() {
let solver: LevenbergMarquardt<Vec<f64>, CholeskyOnly> =
LevenbergMarquardt::new();
let result = Executor::from_start(Fit, solver, vec![0.])
.max_iter(50)
.run()
.unwrap();
assert_eq!(
result.report.code(),
crate::TerminationCode::SolverConverged
);
assert!((result.param()[0] - 1.).abs() < 1e-8);
}
}