use crate::core::constraint::evaluation::BoxProblem;
use crate::core::inner::InitialState;
use crate::core::least_squares::evaluation::{BoundedEvaluation, NllsStep};
use crate::core::math::{
AddDiagonalVectorInPlace, BoxAffineScaling, Dot, GramMatrix,
LinearSolveSpd, MatTransposeVec, MaxDiagonal, NegInPlace, NormSquared,
Scalar, ScaledAdd,
};
use crate::core::problem::{Jacobian, Problem, Residual};
use crate::core::solver::Solver;
use crate::core::state::{PointState, State};
use crate::core::termination::Termination;
use crate::{
LossFunction, RobustLeastSquares, ScaleRowsInPlace, VectorIndex, VectorLen,
};
use super::least_squares_diagnostics::{
LeastSquaresCheck, LeastSquaresDiagnostics, LeastSquaresObservation, Trace,
};
pub struct BoundedLevenbergMarquardt<V, M, F: Scalar = f64> {
tol_grad: Option<F>,
tau: F,
rstep: F,
theta: F,
max_inner_attempts: u32,
mu: Option<F>,
nu: F,
rejected_step: bool,
r_cache: Option<V>,
j_cache: Option<M>,
model_r_cache: Option<V>,
failed: bool,
trace: Option<Trace<F>>,
trace_vector: Option<fn(&V) -> Vec<F>>,
}
impl<V, M, F: Scalar> LeastSquaresDiagnostics<F>
for BoundedLevenbergMarquardt<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 BoundedLevenbergMarquardt<V, M, F> {
fn default() -> Self {
Self {
tol_grad: Some(F::from_f64(1e-8).unwrap()),
tau: F::from_f64(1e-3).unwrap(),
rstep: F::from_f64(1e-10).unwrap(),
theta: F::from_f64(0.99995).unwrap(),
max_inner_attempts: 50,
mu: None,
nu: F::from_f64(2.0).unwrap(),
rejected_step: false,
r_cache: None,
j_cache: None,
model_r_cache: None,
failed: false,
trace: None,
trace_vector: None,
}
}
}
impl<V, M> BoundedLevenbergMarquardt<V, M> {
pub fn new() -> Self {
Self::default()
}
}
impl<V, M, F: Scalar> BoundedLevenbergMarquardt<V, M, F> {
pub fn with_trial_diagnostics(mut self, enabled: bool) -> Self
where
V: VectorLen + VectorIndex<F>,
{
self.trace = enabled.then(Trace::default);
self.trace_vector = enabled.then_some(|v: &V| {
(0..v.vec_len()).map(|i| v.get_scalar(i)).collect()
});
self
}
pub fn with_absolute_scaled_gradient_tolerance(
mut self,
value: impl Into<Option<F>>,
) -> Self {
self.tol_grad = crate::core::convergence::optional_tolerance(value);
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_rstep(mut self, rstep: F) -> Self {
assert!(rstep > F::zero(), "rstep must be > 0");
self.rstep = rstep;
self
}
pub fn with_theta(mut self, theta: F) -> Self {
assert!(
theta > F::zero() && theta < F::one(),
"theta must be in (0, 1), got {:?}",
theta
);
self.theta = theta;
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<V, M, F> InitialState<V> for BoundedLevenbergMarquardt<V, M, F>
where
F: Scalar,
V: Clone,
{
type State = PointState<V, F>;
fn seed(&self, x: &V) -> Self::State {
PointState::new(x.clone())
}
}
impl<P, V, M, F> Solver<P, PointState<V, F>>
for BoundedLevenbergMarquardt<V, M, F>
where
F: Scalar,
P: Residual<Param = V, Output = V>
+ Jacobian<Jacobian = M>
+ BoxProblem<Param = V>,
V: ScaledAdd<F>
+ NormSquared<F>
+ NegInPlace
+ Dot<F>
+ BoxAffineScaling<F>
+ Clone,
M: GramMatrix
+ MatTransposeVec<V>
+ LinearSolveSpd<V>
+ AddDiagonalVectorInPlace<V>
+ MaxDiagonal<F>
+ Clone,
V: crate::VectorLen + std::ops::Index<usize, Output = F>,
{
type Error = crate::ConstraintSolveError<<P as Residual>::Error>;
fn should_check_iterate_change(&self) -> bool {
!self.rejected_step
}
fn init(
&mut self,
problem: &mut Problem<P>,
state: PointState<V, F>,
) -> Result<PointState<V, F>, Self::Error> {
crate::core::constraint::evaluation::validate_box_problem(
problem.inner(),
Some(state.param().vec_len()),
false,
)
.map_err(crate::core::constraint::evaluation::invalid)?;
self.init_evaluated(problem, state)
.map_err(crate::ConstraintSolveError::Evaluation)
}
fn next_iter(
&mut self,
problem: &mut Problem<P>,
state: PointState<V, F>,
) -> Result<crate::SolverStep<PointState<V, F>>, Self::Error> {
self.next_evaluated(problem, state)
.map_err(crate::ConstraintSolveError::Evaluation)
}
}
impl<P, L, V, M, F> Solver<RobustLeastSquares<P, L, F>, PointState<V, F>>
for BoundedLevenbergMarquardt<V, M, F>
where
F: Scalar,
P: crate::Constraints<Set = crate::ConstraintSet<crate::BoxBounds<V>>>,
P: Residual<Param = V, Output = V>
+ Jacobian<Jacobian = M>
+ BoxProblem<Param = V>,
V: ScaledAdd<F>
+ NormSquared<F>
+ NegInPlace
+ Dot<F>
+ BoxAffineScaling<F>
+ Clone,
M: GramMatrix
+ MatTransposeVec<V>
+ LinearSolveSpd<V>
+ AddDiagonalVectorInPlace<V>
+ MaxDiagonal<F>
+ Clone,
L: LossFunction<F>,
V: VectorLen + VectorIndex<F>,
M: ScaleRowsInPlace<F>,
V: crate::VectorLen + std::ops::Index<usize, Output = F>,
{
type Error = crate::ConstraintSolveError<<P as Residual>::Error>;
fn should_check_iterate_change(&self) -> bool {
!self.rejected_step
}
fn init(
&mut self,
problem: &mut Problem<RobustLeastSquares<P, L, F>>,
state: PointState<V, F>,
) -> Result<PointState<V, F>, Self::Error> {
crate::core::constraint::evaluation::validate_box_problem(
problem.inner(),
Some(state.param().vec_len()),
false,
)
.map_err(crate::core::constraint::evaluation::invalid)?;
self.init_evaluated(problem, state)
.map_err(crate::ConstraintSolveError::Evaluation)
}
fn next_iter(
&mut self,
problem: &mut Problem<RobustLeastSquares<P, L, F>>,
state: PointState<V, F>,
) -> Result<crate::SolverStep<PointState<V, F>>, Self::Error> {
self.next_evaluated(problem, state)
.map_err(crate::ConstraintSolveError::Evaluation)
}
}
impl<V, M, F: Scalar> BoundedLevenbergMarquardt<V, M, F>
where
V: ScaledAdd<F>
+ NormSquared<F>
+ NegInPlace
+ Dot<F>
+ BoxAffineScaling<F>
+ Clone,
M: GramMatrix
+ MatTransposeVec<V>
+ LinearSolveSpd<V>
+ AddDiagonalVectorInPlace<V>
+ MaxDiagonal<F>
+ Clone,
{
fn init_evaluated<E: BoundedEvaluation<V, M, F>>(
&mut self,
problem: &mut E,
mut state: PointState<V, F>,
) -> Result<PointState<V, F>, E::Error> {
state.reset();
let mut param = state.param().clone();
param.project_strictly_inside(
problem.lower(),
problem.upper(),
self.rstep,
);
if let Some(trace) = &mut self.trace {
trace.reset();
}
self.failed = false;
self.rejected_step = false;
self.r_cache = None;
self.j_cache = None;
self.model_r_cache = None;
self.mu = None;
self.nu = F::from_f64(2.0).unwrap();
let (r, j) = problem.residual_and_jacobian(¶m)?;
state.replace(
param,
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 g = j.mat_transpose_vec(model_r.as_ref().unwrap_or(&r));
if !problem.valid_vector(&g) {
self.failed = true;
return Ok(state);
}
let mut d_sq = state.param().clone();
let mut c_diag = state.param().clone();
state.param().compute_cl_scaling(
&g,
problem.lower(),
problem.upper(),
&mut d_sq,
&mut c_diag,
);
let mut a = j.gram();
a.add_diagonal_vector_in_place(&c_diag);
let max_diag = a.max_diagonal().max(F::one());
self.mu = Some(self.tau * max_diag);
self.nu = F::from_f64(2.0).unwrap();
self.r_cache = Some(r);
self.j_cache = Some(j);
self.model_r_cache = model_r;
Ok(state)
}
fn next_evaluated<E: BoundedEvaluation<V, M, F>>(
&mut self,
problem: &mut E,
mut state: PointState<V, F>,
) -> NllsStep<V, F, E::Error> {
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(
"Bounded LM could not initialize a finite residual model.",
)),
)));
}
let r = match self.r_cache.take() {
Some(r) => r,
None => problem.residual(state.param())?,
};
let (model_r, j) = match self.j_cache.take() {
Some(j) => (self.model_r_cache.take(), j),
None => {
let j = problem.jacobian(state.param())?;
let Some(model) = 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(
"Bounded LM could not construct the residual model at the current point.",
)),
)));
};
model
}
};
let g = j.mat_transpose_vec(model_r.as_ref().unwrap_or(&r));
if !problem.valid_vector(&g) {
self.failed = true;
if let Some(trace) = &mut self.trace {
let index = trace.event(
state.cost(),
"construction",
"invalid_gradient",
);
trace.observations[index].gradient =
self.trace_vector.unwrap()(&g);
}
return Ok(crate::SolverStep::from((
state,
Some(Termination::numerical_failure(
"The bounded LM model gradient is invalid.",
)),
)));
}
let mut d_sq = state.param().clone();
let mut c_diag = state.param().clone();
state.param().compute_cl_scaling(
&g,
problem.lower(),
problem.upper(),
&mut d_sq,
&mut c_diag,
);
if let Some(trace) = &mut self.trace {
let mut row = LeastSquaresObservation::model(state.cost());
let collect = self.trace_vector.unwrap();
row.gradient = collect(&g);
row.diagonal = collect(&d_sq);
row.curvature = collect(&c_diag);
let value = g.cl_kkt_inf_norm(&d_sq);
row.checks.push(LeastSquaresCheck {
name: "scaled_gradient",
tolerance: self.tol_grad,
passed: self.tol_grad.map(|t| value <= t),
evidence: self.tol_grad.map(|tolerance| {
crate::ConvergenceEvidence::UpperBound {
value,
bound: tolerance,
tolerance,
reference: None,
}
}),
});
trace.push(row);
}
if self
.tol_grad
.is_some_and(|tol| g.cl_kkt_inf_norm(&d_sq) <= tol)
{
self.r_cache = Some(r);
self.j_cache = Some(j);
self.model_r_cache = model_r;
return Ok(crate::SolverStep::from((
state,
Some(Termination::custom(
"bounded_lm.scaled_gradient",
"The bounded LM Coleman-Li KKT gradient metric is at most the absolute tolerance.",
vec![
crate::Measurement {
name: "scaled_gradient".into(),
value: g.cl_kkt_inf_norm(&d_sq),
},
crate::Measurement {
name: "tolerance".into(),
value: self.tol_grad.unwrap(),
},
],
)),
)));
}
let mut neg_g = g.clone();
neg_g.neg_in_place();
let m = j.gram();
let mut mu = self
.mu
.expect("mu not set: Solver::init must run before next_iter");
let mut nu = self.nu;
macro_rules! fail_model {
($outcome:literal, $message:literal) => {{
self.failed = true;
if let Some(trace) = &mut self.trace {
let index =
trace.event(state.cost(), "selection", $outcome);
let row = &mut trace.observations[index];
row.damping = Some(mu);
row.damping_after = Some(mu);
row.gradient = self.trace_vector.unwrap()(&g);
row.diagonal = self.trace_vector.unwrap()(&d_sq);
row.curvature = self.trace_vector.unwrap()(&c_diag);
row.arithmetic.push(("retry_multiplier", nu));
}
self.mu = Some(mu);
self.nu = nu;
self.r_cache = Some(r);
self.j_cache = Some(j);
self.model_r_cache = model_r;
return Ok(crate::SolverStep::stopped(
state,
Termination::numerical_failure($message),
));
}};
}
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 h;
let mut attempts: u32 = 0;
loop {
if !mu.is_finite() || !nu.is_finite() {
fail_model!(
"nonfinite_damping",
"Bounded LM produced non-finite damping."
);
}
let mut a_damped = m.clone();
let mut damping_vec = c_diag.clone();
damping_vec.scaled_add(mu, &d_sq);
a_damped.add_diagonal_vector_in_place(&damping_vec);
let result = a_damped.solve_spd(&neg_g);
let solve_record = self.trace.as_mut().map(|trace| {
let mut row = LeastSquaresObservation::model(state.cost());
let collect = self.trace_vector.unwrap();
row.event = "subproblem";
row.outcome =
Some(if result.is_ok() { "solved" } else { "retry" });
row.damping = Some(mu);
row.damping_after = Some(mu);
row.gradient = collect(&g);
row.diagonal = collect(&d_sq);
row.curvature = collect(&c_diag);
row.model_solves = 1;
row.arithmetic.push(("retry_multiplier", nu));
if let Ok(step) = &result {
row.step = collect(step);
row.step_norm =
Some(step.weighted_norm_squared(&d_sq).sqrt());
}
trace.push(row)
});
match result {
Ok(step) => {
h = step;
break;
}
Err(_) => {
attempts += 1;
if attempts >= self.max_inner_attempts || !mu.is_finite() {
if let Some(index) = solve_record {
self.trace.as_mut().unwrap().observations[index]
.outcome = Some("exhausted");
}
self.mu = Some(mu);
self.nu = nu;
self.r_cache = Some(r);
self.j_cache = Some(j);
self.model_r_cache = model_r;
return Ok(crate::SolverStep::from((
state,
Some(Termination::numerical_failure(
"Bounded LM exhausted damped linear-solve attempts or produced non-finite damping.",
)),
)));
}
mu = mu * nu;
nu = nu * two;
if let Some(index) = solve_record {
self.trace.as_mut().unwrap().observations[index]
.damping_after = Some(mu);
}
}
}
}
let tau_max = state.param().max_feasible_step(
&h,
problem.lower(),
problem.upper(),
);
let alpha = if tau_max >= F::one() {
F::one()
} else {
self.theta * tau_max
};
let h_t_g = h.dot(&g);
let dh_norm_sq = h.weighted_norm_squared(&d_sq);
let predicted = -alpha * (F::one() - half * alpha) * h_t_g
+ half * alpha * alpha * mu * dh_norm_sq;
let half_s_t_c_s =
half * alpha * alpha * h.weighted_norm_squared(&c_diag);
if !alpha.is_finite()
|| !predicted.is_finite()
|| !half_s_t_c_s.is_finite()
{
if let Some(trace) = &mut self.trace {
let index = trace.event(
state.cost(),
"selection",
"nonfinite_prediction",
);
let row = &mut trace.observations[index];
row.predicted_reduction = Some(predicted);
row.arithmetic = vec![
("feasible_fraction", alpha),
("curvature_correction", half_s_t_c_s),
("step_gradient", h_t_g),
("weighted_step_square", dh_norm_sq),
];
}
fail_model!(
"nonfinite_trial_model",
"Bounded LM produced a non-finite trial model."
);
}
let mut x_trial = state.param().clone();
x_trial.scaled_add(alpha, &h);
let trial_record = self.trace.as_mut().map(|trace| {
let mut row = LeastSquaresObservation::model(state.cost());
let collect = self.trace_vector.unwrap();
row.event = "trial";
row.trial = true;
row.step = collect(&h).into_iter().map(|x| alpha * x).collect();
row.gradient = collect(&g);
row.diagonal = collect(&d_sq);
row.curvature = collect(&c_diag);
row.damping = Some(mu);
row.model_solves = attempts + 1;
row.step_norm = Some(alpha * dh_norm_sq.sqrt());
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 = prev_cost - f_trial - half_s_t_c_s;
let rho = if predicted > F::zero() {
actual / predicted
} else {
F::zero()
};
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);
row.predicted_reduction = Some(predicted);
row.gain_ratio = Some(rho);
row.accepted = Some(rho > F::zero());
}
if rho > F::zero() {
self.rejected_step = false;
state.replace(x_trial, f_trial);
let factor = F::one() - (two * rho - F::one()).powi(3);
mu = mu * factor.max(one_third);
nu = two;
self.r_cache = Some(r_trial);
self.j_cache = None;
} else {
self.rejected_step = true;
mu = mu * nu;
nu = nu * two;
self.r_cache = Some(r);
self.j_cache = Some(j);
self.model_r_cache = model_r;
}
if let Some(index) = trial_record {
let row = &mut self.trace.as_mut().unwrap().observations[index];
row.damping_after = Some(mu);
row.outcome = Some(if rho > F::zero() {
"accepted"
} else {
"rejected"
});
}
self.mu = Some(mu);
self.nu = nu;
Ok(crate::SolverStep::from((state, None)))
}
}