use nalgebra::{DMatrix, DVector};
pub const FD_REL_STEP_2POINT: f64 = 1.4901161193847656e-8;
const TRF_DEFAULT_GTOL: f64 = 1e-10;
const TRF_DEFAULT_FTOL: f64 = 1e-8;
const TRF_DEFAULT_XTOL: f64 = 1e-8;
const TRF_DEFAULT_MAX_NFEV: usize = 300;
const TRF_INITIAL_DAMPING_SCALE: f64 = 1e-3;
#[derive(Debug, Clone, PartialEq)]
pub struct FdStep {
pub param_index: usize,
pub sign_x0: f64,
pub h: f64,
pub dx: f64,
pub x_perturbed: DVector<f64>,
}
pub fn fd_steps(x0: &DVector<f64>, rel_step: f64) -> Vec<FdStep> {
(0..x0.len())
.map(|i| {
let xi = x0[i];
let sign_x0 = if xi >= 0.0 { 1.0 } else { -1.0 };
let h = rel_step * sign_x0 * xi.abs().max(1.0);
let mut x_perturbed = x0.clone();
x_perturbed[i] = xi + h;
let dx = x_perturbed[i] - xi;
FdStep {
param_index: i,
sign_x0,
h,
dx,
x_perturbed,
}
})
.collect()
}
pub fn jacobian_2point<F>(residual: F, x0: &DVector<f64>, f0: &DVector<f64>) -> DMatrix<f64>
where
F: Fn(&DVector<f64>) -> DVector<f64>,
{
let m = f0.len();
let n = x0.len();
let steps = fd_steps(x0, FD_REL_STEP_2POINT);
let mut jac = DMatrix::zeros(m, n);
for step in &steps {
let f1 = residual(&step.x_perturbed);
let i = step.param_index;
for row in 0..m {
jac[(row, i)] = (f1[row] - f0[row]) / step.dx;
}
}
jac
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Status {
GradientTolerance,
CostTolerance,
StepTolerance,
MaxEvaluations,
}
#[derive(Debug, Clone, Copy)]
pub struct SolveOptions {
pub gtol: f64,
pub ftol: f64,
pub xtol: f64,
pub max_nfev: usize,
}
impl Default for SolveOptions {
fn default() -> Self {
Self {
gtol: TRF_DEFAULT_GTOL,
ftol: TRF_DEFAULT_FTOL,
xtol: TRF_DEFAULT_XTOL,
max_nfev: TRF_DEFAULT_MAX_NFEV,
}
}
}
#[derive(Debug, Clone)]
pub struct LeastSquaresReport {
pub x: DVector<f64>,
pub residual: DVector<f64>,
pub cost: f64,
pub jacobian: DMatrix<f64>,
pub optimality_inf: f64,
pub iterations: usize,
pub status: Status,
}
#[derive(Debug, Clone, thiserror::Error)]
pub enum SolveError {
#[error("singular or rank-deficient Jacobian: no usable descent direction")]
SingularJacobian,
}
pub fn cost(residual: &DVector<f64>) -> f64 {
0.5 * residual.dot(residual)
}
pub struct LeastSquaresProblem<F> {
residual: F,
sqrt_weights: Option<DVector<f64>>,
x0: DVector<f64>,
}
impl<F> LeastSquaresProblem<F>
where
F: Fn(&DVector<f64>) -> DVector<f64>,
{
pub fn new(residual: F, x0: DVector<f64>) -> Self {
Self {
residual,
sqrt_weights: None,
x0,
}
}
pub fn with_weights(residual: F, x0: DVector<f64>, weights: DVector<f64>) -> Self {
let sqrt_weights = weights.map(f64::sqrt);
Self {
residual,
sqrt_weights: Some(sqrt_weights),
x0,
}
}
fn weighted_residual(&self, x: &DVector<f64>) -> DVector<f64> {
let r = (self.residual)(x);
match &self.sqrt_weights {
Some(sw) => r.component_mul(sw),
None => r,
}
}
}
pub fn solve_trf<F>(
problem: &LeastSquaresProblem<F>,
opts: &SolveOptions,
) -> Result<LeastSquaresReport, SolveError>
where
F: Fn(&DVector<f64>) -> DVector<f64>,
{
let n = problem.x0.len();
let mut x = problem.x0.clone();
let mut r = problem.weighted_residual(&x);
let mut f0 = r.clone();
let mut jac = jacobian_2point(|p| problem.weighted_residual(p), &x, &f0);
let mut nfev = 1usize; let mut cur_cost = cost(&r);
let jtj0 = jac.transpose() * &jac;
let mut mu = TRF_INITIAL_DAMPING_SCALE
* (0..n)
.map(|i| jtj0[(i, i)])
.fold(0.0_f64, f64::max)
.max(1.0);
let mut iterations = 0usize;
loop {
let jt = jac.transpose();
let grad = &jt * &r;
let optimality_inf = grad.amax();
if optimality_inf < opts.gtol {
return Ok(finish(
x,
r,
cur_cost,
jac,
iterations,
Status::GradientTolerance,
));
}
if nfev >= opts.max_nfev {
return Ok(finish(
x,
r,
cur_cost,
jac,
iterations,
Status::MaxEvaluations,
));
}
let jtj = &jt * &jac;
let mut accepted = false;
for _ in 0..30 {
let mut lhs = jtj.clone();
for i in 0..n {
lhs[(i, i)] += mu;
}
let rhs = -&grad;
let step = match lhs.clone().lu().solve(&rhs) {
Some(s) => s,
None => return Err(SolveError::SingularJacobian),
};
let x_trial = &x + &step;
let r_trial = problem.weighted_residual(&x_trial);
nfev += 1;
let cost_trial = cost(&r_trial);
if cost_trial < cur_cost {
let cost_reduction = (cur_cost - cost_trial) / cur_cost.max(f64::MIN_POSITIVE);
let step_norm = step.norm();
let x_norm = x.norm();
let rel_step = step_norm / x_norm.max(f64::MIN_POSITIVE);
x = x_trial;
r = r_trial;
cur_cost = cost_trial;
f0 = r.clone();
jac = jacobian_2point(|p| problem.weighted_residual(p), &x, &f0);
nfev += n; iterations += 1;
mu *= 0.5;
accepted = true;
if cost_reduction < opts.ftol {
return Ok(finish(
x,
r,
cur_cost,
jac,
iterations,
Status::CostTolerance,
));
}
if rel_step < opts.xtol {
return Ok(finish(
x,
r,
cur_cost,
jac,
iterations,
Status::StepTolerance,
));
}
break;
} else {
mu *= 2.0;
}
}
if !accepted {
return Ok(finish(
x,
r,
cur_cost,
jac,
iterations,
Status::StepTolerance,
));
}
}
}
fn finish(
x: DVector<f64>,
residual: DVector<f64>,
cost_value: f64,
jacobian: DMatrix<f64>,
iterations: usize,
status: Status,
) -> LeastSquaresReport {
let optimality_inf = (jacobian.transpose() * &residual).amax();
LeastSquaresReport {
x,
residual,
cost: cost_value,
jacobian,
optimality_inf,
iterations,
status,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fd_rel_step_is_sqrt_eps() {
assert_eq!(FD_REL_STEP_2POINT, (2.0_f64.powi(-52)).sqrt());
assert_eq!(FD_REL_STEP_2POINT, 2.0_f64.powi(-26));
}
#[test]
fn fd_step_sign_convention() {
let x0 = DVector::from_vec(vec![5.0, -2.0, 0.0]);
let steps = fd_steps(&x0, FD_REL_STEP_2POINT);
assert_eq!(steps[0].sign_x0, 1.0);
assert_eq!(steps[1].sign_x0, -1.0);
assert_eq!(steps[2].sign_x0, 1.0); }
#[test]
fn exp_fit_converges() {
let t = vec![0.0, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0];
let y = vec![
3.0123, 2.2083, 1.6889, 1.3713, 1.0903, 0.9302, 0.8104, 0.6303,
];
let tt = t.clone();
let yy = y.clone();
let residual = move |p: &DVector<f64>| {
let (a, b, c) = (p[0], p[1], p[2]);
DVector::from_iterator(
tt.len(),
tt.iter()
.zip(&yy)
.map(|(&tk, &yk)| a * (b * tk).exp() + c - yk),
)
};
let problem = LeastSquaresProblem::new(residual, DVector::from_vec(vec![5.0, -2.0, 2.0]));
let report = solve_trf(&problem, &SolveOptions::default()).unwrap();
assert!(report.cost < 1.0, "cost did not reduce: {}", report.cost);
}
}