1use crate::utils::enorm;
2use crate::{LeastSquaresProblem, SparseJacobian};
3use nalgebra::{DefaultAllocator, Dim, OVector, RealField, Vector, allocator::Allocator, convert};
4use num_traits::Float;
5#[cfg(feature = "tracing")]
6use tracing::debug;
7
8#[cfg(test)]
9#[allow(
10 clippy::float_cmp,
11 clippy::excessive_precision,
12 clippy::redundant_clone
13)]
14pub(crate) mod test_examples;
15#[cfg(test)]
16mod test_helpers;
17#[cfg(test)]
18mod test_init_step;
19
20#[derive(PartialEq, Eq, Debug)]
21pub enum TerminationReason {
23 User(&'static str),
25 Numerical(&'static str),
27 ResidualsZero,
29 Orthogonal,
33 Converged { ftol: bool, xtol: bool },
35 NoImprovementPossible(&'static str),
39 LostPatience,
41 NoParameters,
43 NoResiduals,
45 WrongDimensions(&'static str),
47}
48
49impl TerminationReason {
50 pub fn was_successful(&self) -> bool {
57 matches!(
58 self,
59 TerminationReason::ResidualsZero
60 | TerminationReason::Orthogonal
61 | TerminationReason::Converged { .. }
62 )
63 }
64
65 pub fn was_usage_issue(&self) -> bool {
69 matches!(
70 self,
71 TerminationReason::NoParameters
72 | TerminationReason::NoResiduals
73 | TerminationReason::NoImprovementPossible(_)
74 | TerminationReason::WrongDimensions(_)
75 )
76 }
77}
78
79#[derive(Debug)]
80pub struct MinimizationReport<F: RealField> {
85 pub termination: TerminationReason,
86 pub number_of_evaluations: usize,
88 pub objective_function: F,
90}
91
92#[derive(Copy, Clone, Debug, PartialEq, Eq)]
98pub struct LevenbergMarquardt<F> {
99 ftol: F,
100 xtol: F,
101 gtol: F,
102 stepbound: F,
103 patience: usize,
104 scale_diag: bool,
105}
106
107impl<F: RealField + Float> Default for LevenbergMarquardt<F> {
108 fn default() -> Self {
109 Self::new()
110 }
111}
112
113impl<F: RealField + Float> LevenbergMarquardt<F> {
114 pub fn new() -> Self {
115 let user_tol = F::default_epsilon() * convert(30.0);
116 Self {
117 ftol: user_tol,
118 xtol: user_tol,
119 gtol: user_tol,
120 stepbound: convert(100.0),
121 patience: 100,
122 scale_diag: true,
123 }
124 }
125
126 #[must_use]
135 pub fn with_ftol(self, ftol: F) -> Self {
136 assert!(!ftol.is_negative(), "ftol must be >= 0");
137 Self { ftol, ..self }
138 }
139
140 #[must_use]
149 pub fn with_xtol(self, xtol: F) -> Self {
150 assert!(!xtol.is_negative(), "xtol must be >= 0");
151 Self { xtol, ..self }
152 }
153
154 #[must_use]
175 pub fn with_gtol(self, gtol: F) -> Self {
176 assert!(!gtol.is_negative(), "gtol must be >= 0");
177 Self { gtol, ..self }
178 }
179
180 #[must_use]
188 pub fn with_tol(self, tol: F) -> Self {
189 assert!(tol.is_positive(), "tol must > 0");
190 Self {
191 ftol: tol,
192 xtol: tol,
193 gtol: F::zero(),
194 ..self
195 }
196 }
197
198 #[must_use]
208 pub fn with_stepbound(self, stepbound: F) -> Self {
209 assert!(stepbound.is_positive(), "stepbound must be > 0");
210 Self { stepbound, ..self }
211 }
212
213 #[must_use]
222 pub fn with_patience(self, patience: usize) -> Self {
223 assert!(patience > 0, "patience must be > 0");
224 Self { patience, ..self }
225 }
226
227 #[must_use]
229 pub fn with_scale_diag(self, scale_diag: bool) -> Self {
230 Self { scale_diag, ..self }
231 }
232
233 pub fn minimize<N, M, O>(&self, target: O) -> (O, MinimizationReport<F>)
238 where
239 N: Dim,
240 M: Dim,
241 O: LeastSquaresProblem<F, M, N>,
242 DefaultAllocator: Allocator<N>,
243 {
244 let (mut lm, mut residuals) = match LM::new(self, target) {
245 Err(report) => return report,
246 Ok(res) => res,
247 };
248 let n = lm.x.nrows();
249 let mut lambda = Float::max(F::default_epsilon(), convert(1.0e-6f64));
250 let mut exhausted_outer_loops = 0usize;
251 let exhausted_outer_limit = 8usize;
252 let mut stagnation_count = 0usize;
253 let stagnation_limit = 12usize;
254
255 loop {
256 let jacobian = match lm.jacobian() {
257 Err(reason) => return lm.into_report(reason),
258 Ok(jacobian) => jacobian,
259 };
260 if jacobian.cols != n || jacobian.rows != lm.m {
261 return lm.into_report(TerminationReason::WrongDimensions("jacobian"));
262 }
263
264 let col_norms = sparse_column_norms::<F, N>(&jacobian, n);
265 let jt_r = sparse_jt_mul::<F, N>(&jacobian, residuals.as_slice(), n);
266
267 if lm.first_update {
268 lm.xnorm = if lm.config.scale_diag {
269 for (d, col_norm) in lm.diag.iter_mut().zip(col_norms.iter()) {
270 *d = if col_norm.is_zero() {
271 F::one()
272 } else {
273 *col_norm
274 };
275 }
276 lm.tmp.cmpy(F::one(), &lm.diag, &lm.x, F::zero());
277 enorm(&lm.tmp)
278 } else {
279 enorm(&lm.x)
280 };
281
282 lm.delta = if lm.xnorm.is_zero() {
283 lm.config.stepbound
284 } else {
285 lm.config.stepbound * lm.xnorm
286 };
287 lm.first_update = false;
288 } else if lm.config.scale_diag {
289 for (d, norm) in lm.diag.iter_mut().zip(col_norms.iter()) {
290 *d = Float::max(*norm, *d);
291 }
292 }
293
294 lm.gnorm = max_scaled_gradient(&jt_r, &col_norms, lm.residuals_norm);
295 if lm.gnorm <= lm.config.gtol {
296 return lm.into_report(TerminationReason::Orthogonal);
297 }
298
299 #[cfg(feature = "tracing")]
300 debug!(
301 evals = lm.report.number_of_evaluations,
302 obj = format_args!("{:?}", lm.report.objective_function),
303 gnorm = format_args!("{:?}", lm.gnorm),
304 lambda = format_args!("{:?}", lambda),
305 "sparse outer iteration start"
306 );
307
308 let mut accepted = false;
309 let mut accepted_residuals = None;
310 let max_inner = 12usize;
311 #[cfg(feature = "tracing")]
312 let mut inner_tries = 0usize;
313
314 for _ in 0..max_inner {
315 #[cfg(feature = "tracing")]
316 {
317 inner_tries += 1;
318 }
319 let step = solve_damped_normal_equations::<F, N>(
320 &jacobian, &jt_r, &lm.diag, &col_norms, lambda, n,
321 );
322
323 let pnorm = scaled_norm(&step, &lm.diag);
324 if !pnorm.is_finite() {
325 return lm.into_report(TerminationReason::Numerical("subproblem ||Dp||"));
326 }
327
328 lm.tmp.copy_from(&lm.x);
329 lm.tmp.axpy(-F::one(), &step, F::one());
330
331 lm.target.set_params(&lm.tmp);
332 lm.report.number_of_evaluations += 1;
333
334 let new_objective_function;
335 let (new_residuals, new_residuals_norm) = if let Some(res) = lm.target.residuals() {
336 if res.nrows() != lm.m {
337 return lm.into_report(TerminationReason::WrongDimensions("residuals"));
338 }
339 let norm = enorm(&res);
340 new_objective_function = norm * norm * convert(0.5);
341 (res, norm)
342 } else {
343 return lm.into_report(TerminationReason::User("residuals"));
344 };
345
346 let actual_reduction = if new_residuals_norm * convert(0.1f64) < lm.residuals_norm {
347 F::one() - Float::powi(new_residuals_norm / lm.residuals_norm, 2)
348 } else {
349 -F::one()
350 };
351
352 let j_step = sparse_j_mul::<F, N>(&jacobian, &step);
353 let predicted_reduction = {
356 let rn_sq = lm.residuals_norm * lm.residuals_norm;
357 if rn_sq.is_zero() {
358 F::zero()
359 } else {
360 let r_dot_jp = residuals
361 .as_slice()
362 .iter()
363 .zip(j_step.iter())
364 .fold(F::zero(), |acc, (&a, &b)| acc + a * b);
365 let jp_sq = j_step.iter().fold(F::zero(), |acc, &v| acc + v * v);
366 (convert::<f64, F>(2.0) * r_dot_jp - jp_sq) / rn_sq
367 }
368 };
369 let ratio = if predicted_reduction <= F::zero() {
370 F::zero()
371 } else {
372 actual_reduction / predicted_reduction
373 };
374
375 if ratio > convert(0.0001f64) {
376 accepted = true;
377 accepted_residuals = Some((
378 new_residuals,
379 new_residuals_norm,
380 new_objective_function,
381 step,
382 pnorm,
383 actual_reduction,
384 predicted_reduction,
385 ratio,
386 ));
387 #[cfg(feature = "tracing")]
388 debug!(
389 evals = lm.report.number_of_evaluations,
390 inner_tries,
391 obj = format_args!("{:?}", lm.report.objective_function),
392 "sparse step accepted"
393 );
394 lambda = Float::max(lambda * convert(0.333333333333f64), F::default_epsilon());
395 break;
396 }
397
398 #[cfg(feature = "tracing")]
399 debug!(
400 evals = lm.report.number_of_evaluations,
401 inner_try = inner_tries,
402 ratio = format_args!("{:?}", ratio),
403 "sparse step rejected, increasing lambda"
404 );
405 lambda *= convert(2.0f64);
406 lm.target.set_params(&lm.x);
407 }
408
409 if !accepted {
410 exhausted_outer_loops += 1;
415 #[cfg(feature = "tracing")]
416 debug!(
417 evals = lm.report.number_of_evaluations,
418 exhausted_outer_loops,
419 max_inner,
420 "all inner tries exhausted, retrying outer iteration with larger lambda"
421 );
422 if lm.report.number_of_evaluations >= lm.max_fev {
423 return lm.into_report(TerminationReason::LostPatience);
424 }
425 if exhausted_outer_loops >= exhausted_outer_limit {
426 return lm.into_report(TerminationReason::Converged {
430 ftol: true,
431 xtol: true,
432 });
433 }
434 continue;
435 }
436
437 exhausted_outer_loops = 0;
438
439 let prev_objective = lm.report.objective_function;
440
441 let (
442 new_residuals,
443 new_residuals_norm,
444 new_objective_function,
445 _step,
446 pnorm,
447 actual_reduction,
448 predicted_reduction,
449 ratio,
450 ) = accepted_residuals.expect("accepted step must exist");
451
452 core::mem::swap(&mut lm.x, &mut lm.tmp);
453 lm.residuals_norm = new_residuals_norm;
454 lm.report.objective_function = new_objective_function;
455 residuals = new_residuals;
456
457 let objective_scale = Float::max(prev_objective, F::one());
458 let rel_obj_change =
459 Float::abs(prev_objective - new_objective_function) / objective_scale;
460 let stagnation_tol = Float::max(
461 lm.config.ftol,
462 Float::sqrt(Float::max(F::default_epsilon(), convert(1.0e-16f64))),
463 );
464 if rel_obj_change <= stagnation_tol {
465 stagnation_count += 1;
466 } else {
467 stagnation_count = 0;
468 }
469 if stagnation_count >= stagnation_limit {
470 return lm.into_report(TerminationReason::Converged {
471 ftol: true,
472 xtol: false,
473 });
474 }
475
476 if lm.config.scale_diag {
477 lm.tmp.cmpy(F::one(), &lm.diag, &lm.x, F::zero());
478 lm.xnorm = enorm(&lm.tmp);
479 } else {
480 lm.xnorm = enorm(&lm.x);
481 }
482
483 if lm.residuals_norm <= F::min_positive_value() {
484 return lm.into_report(TerminationReason::ResidualsZero);
485 }
486
487 let xtol_check = lm.delta <= lm.config.xtol * lm.xnorm
488 || pnorm <= lm.config.xtol * (lm.xnorm + lm.config.xtol);
489 let ftol_check = Float::abs(actual_reduction) <= lm.config.ftol
493 && predicted_reduction <= lm.config.ftol
494 && convert::<f64, F>(0.5) * ratio <= F::one();
495 if ftol_check || xtol_check {
496 return lm.into_report(TerminationReason::Converged {
497 ftol: ftol_check,
498 xtol: xtol_check,
499 });
500 }
501
502 if lm.report.number_of_evaluations >= lm.max_fev {
503 return lm.into_report(TerminationReason::LostPatience);
504 }
505 }
506 }
507}
508
509fn sparse_column_norms<F, N>(jacobian: &SparseJacobian<F>, n: usize) -> OVector<F, N>
510where
511 F: RealField + Float + Copy,
512 N: Dim,
513 DefaultAllocator: Allocator<N>,
514{
515 let mut out = OVector::<F, N>::zeros_generic(Dim::from_usize(n), Dim::from_usize(1));
516 for &(_, j, v) in jacobian.entries.iter() {
517 if j < n {
518 out[j] += v * v;
519 }
520 }
521 out.apply(|x| *x = Float::sqrt(*x));
522 out
523}
524
525fn sparse_jt_mul<F, N>(jacobian: &SparseJacobian<F>, y: &[F], n: usize) -> OVector<F, N>
526where
527 F: RealField + Float + Copy,
528 N: Dim,
529 DefaultAllocator: Allocator<N>,
530{
531 let mut out = OVector::<F, N>::zeros_generic(Dim::from_usize(n), Dim::from_usize(1));
532 for &(i, j, v) in jacobian.entries.iter() {
533 if i < y.len() && j < n {
534 out[j] += v * y[i];
535 }
536 }
537 out
538}
539
540fn sparse_j_mul<F, N>(jacobian: &SparseJacobian<F>, x: &OVector<F, N>) -> alloc::vec::Vec<F>
541where
542 F: RealField + Float + Copy,
543 N: Dim,
544 DefaultAllocator: Allocator<N>,
545{
546 let mut out = alloc::vec![F::zero(); jacobian.rows];
547 for &(i, j, v) in jacobian.entries.iter() {
548 if i < out.len() && j < x.nrows() {
549 out[i] += v * x[j];
550 }
551 }
552 out
553}
554
555fn max_scaled_gradient<F, N>(jt_r: &OVector<F, N>, col_norms: &OVector<F, N>, rnorm: F) -> F
556where
557 F: RealField + Float + Copy,
558 N: Dim,
559 DefaultAllocator: Allocator<N>,
560{
561 let mut out = F::zero();
562 if rnorm.is_zero() {
563 return out;
564 }
565 for i in 0..jt_r.nrows() {
566 let denom = col_norms[i] * rnorm;
567 if denom.is_positive() {
568 out = Float::max(out, Float::abs(jt_r[i] / denom));
569 }
570 }
571 out
572}
573
574fn scaled_norm<F, N>(x: &OVector<F, N>, diag: &OVector<F, N>) -> F
575where
576 F: RealField + Float + Copy,
577 N: Dim,
578 DefaultAllocator: Allocator<N>,
579{
580 let mut s = F::zero();
581 for i in 0..x.nrows() {
582 let v = x[i] * diag[i];
583 s += v * v;
584 }
585 Float::sqrt(s)
586}
587
588fn solve_damped_normal_equations<F, N>(
589 jacobian: &SparseJacobian<F>,
590 jt_r: &OVector<F, N>,
591 diag: &OVector<F, N>,
592 col_norms: &OVector<F, N>,
593 lambda: F,
594 n: usize,
595) -> OVector<F, N>
596where
597 F: RealField + Float + Copy,
598 N: Dim,
599 DefaultAllocator: Allocator<N>,
600{
601 let mut m_inv = OVector::<F, N>::zeros_generic(Dim::from_usize(n), Dim::from_usize(1));
604 for i in 0..n {
605 let d = col_norms[i] * col_norms[i] + lambda * diag[i] * diag[i];
606 m_inv[i] = if d > F::default_epsilon() {
607 F::one() / d
608 } else {
609 F::one()
610 };
611 }
612
613 let mut x = OVector::<F, N>::zeros_generic(Dim::from_usize(n), Dim::from_usize(1));
614 let mut r = jt_r.clone_owned();
615
616 let mut z = OVector::<F, N>::zeros_generic(Dim::from_usize(n), Dim::from_usize(1));
618 for i in 0..n {
619 z[i] = r[i] * m_inv[i];
620 }
621
622 let mut p = z.clone_owned();
623 let bz0 = Float::max(jt_r.dot(&z), F::default_epsilon()); let tol_sq = Float::powi(
625 Float::max(convert(1.0e-10f64), F::default_epsilon() * convert(10.0f64)),
626 2,
627 );
628 let mut rz_old = r.dot(&z); let max_iter = 2 * n + 20;
631 for _ in 0..max_iter {
632 if rz_old <= tol_sq * bz0 {
633 break;
634 }
635
636 let ap = sparse_normal_op_mul(jacobian, &p, diag, lambda, n);
637 let denom = p.dot(&ap);
638 if !denom.is_finite() || Float::abs(denom) <= F::default_epsilon() {
639 break;
640 }
641 let alpha = rz_old / denom;
642
643 x.axpy(alpha, &p, F::one());
644 r.axpy(-alpha, &ap, F::one());
645 for i in 0..n {
646 z[i] = r[i] * m_inv[i];
647 }
648
649 let rz_new = r.dot(&z);
650 if rz_new <= tol_sq * bz0 {
651 break;
652 }
653 let beta = rz_new / rz_old;
654 p *= beta;
655 p += &z;
656 rz_old = rz_new;
657 }
658
659 x
660}
661
662fn sparse_normal_op_mul<F, N>(
663 jacobian: &SparseJacobian<F>,
664 x: &OVector<F, N>,
665 diag: &OVector<F, N>,
666 lambda: F,
667 n: usize,
668) -> OVector<F, N>
669where
670 F: RealField + Float + Copy,
671 N: Dim,
672 DefaultAllocator: Allocator<N>,
673{
674 let jx = sparse_j_mul(jacobian, x);
675 let mut out = sparse_jt_mul(jacobian, jx.as_slice(), n);
676 if !lambda.is_zero() {
677 for i in 0..out.nrows() {
678 out[i] += lambda * diag[i] * diag[i] * x[i];
679 }
680 }
681 out
682}
683
684struct LM<'a, F, N, M, O>
686where
687 F: RealField + Copy,
688 N: Dim,
689 M: Dim,
690 O: LeastSquaresProblem<F, M, N>,
691 DefaultAllocator: Allocator<N>,
692{
693 config: &'a LevenbergMarquardt<F>,
694 x: Vector<F, N, O::ParameterStorage>,
696 tmp: Vector<F, N, O::ParameterStorage>,
697 target: O,
699 report: MinimizationReport<F>,
701 delta: F,
703 xnorm: F,
705 gnorm: F,
706 residuals_norm: F,
707 diag: OVector<F, N>,
709 first_update: bool,
711 max_fev: usize,
712 m: usize,
713}
714
715impl<'a, F, N, M, O> LM<'a, F, N, M, O>
716where
717 F: RealField + Float + Copy,
718 N: Dim,
719 M: Dim,
720 O: LeastSquaresProblem<F, M, N>,
721 DefaultAllocator: Allocator<N>,
722{
723 #[allow(clippy::type_complexity)]
724 fn new(
725 config: &'a LevenbergMarquardt<F>,
726 target: O,
727 ) -> Result<(Self, Vector<F, M, O::ResidualStorage>), (O, MinimizationReport<F>)> {
728 let mut report = MinimizationReport {
729 termination: TerminationReason::ResidualsZero,
730 number_of_evaluations: 1,
731 objective_function: <F as Float>::nan(),
732 };
733
734 let x = target.params();
736 let (residuals, residuals_norm) = if let Some(residuals) = target.residuals() {
737 let norm = enorm(&residuals);
738 report.objective_function = norm * norm * convert(0.5);
739 (residuals, norm)
740 } else {
741 return Err((
742 target,
743 MinimizationReport {
744 termination: TerminationReason::User("residuals"),
745 ..report
746 },
747 ));
748 };
749
750 let n = x.shape_generic().0;
752 let diag = OVector::<F, N>::from_element_generic(n, Dim::from_usize(1), F::one());
753 if diag.nrows() == 0 {
755 return Err((
756 target,
757 MinimizationReport {
758 termination: TerminationReason::NoParameters,
759 ..report
760 },
761 ));
762 }
763
764 let m = residuals.nrows();
765 if m == 0 {
766 return Err((
767 target,
768 MinimizationReport {
769 termination: TerminationReason::NoResiduals,
770 ..report
771 },
772 ));
773 }
774
775 if !residuals_norm.is_finite() {
776 return Err((
777 target,
778 MinimizationReport {
779 termination: TerminationReason::Numerical("residuals norm"),
780 ..report
781 },
782 ));
783 }
784
785 if residuals_norm <= Float::min_positive_value() {
786 return Err((target, report));
788 }
789
790 Ok((
791 Self {
792 config,
793 target,
794 report,
795 tmp: x.clone(),
796 x,
797 diag,
798 delta: F::zero(),
799 xnorm: F::zero(),
800 gnorm: F::zero(),
801 residuals_norm,
802 first_update: true,
803 max_fev: config.patience * (n.value() + 1),
804 m,
805 },
806 residuals,
807 ))
808 }
809
810 fn into_report(self, termination: TerminationReason) -> (O, MinimizationReport<F>) {
811 (
812 self.target,
813 MinimizationReport {
814 termination,
815 ..self.report
816 },
817 )
818 }
819
820 fn jacobian(&self) -> Result<SparseJacobian<F>, TerminationReason> {
821 match self.target.jacobian() {
822 Some(jacobian) => Ok(jacobian),
823 None => Err(TerminationReason::User("jacobian")),
824 }
825 }
826}