use super::{
Step, Subproblem, SubproblemHvp, model_decrease_from_bd, tau_to_boundary,
};
use crate::core::math::{
Dot, MatVec, NegInPlace, NormSquared, Scalar, ScaleInPlace, ScaledAdd,
VectorLen,
};
#[derive(Debug, Clone, Copy)]
pub struct Steihaug {
max_iter: Option<usize>,
}
impl Steihaug {
pub fn new() -> Self {
Self { max_iter: None }
}
pub fn with_max_iter(mut self, n: usize) -> Self {
assert!(n >= 1, "max_iter must be ≥ 1");
self.max_iter = Some(n);
self
}
pub fn with_forcing_parameters<F: Scalar>(
self,
kappa: F,
theta: F,
) -> SteihaugWithForcing<F> {
assert!(
kappa.is_finite() && kappa >= F::zero() && kappa < F::one(),
"kappa must be finite and in [0, 1)"
);
assert!(
theta.is_finite() && theta >= F::zero(),
"theta must be finite and nonnegative"
);
SteihaugWithForcing {
inner: self,
kappa,
theta,
}
}
pub(crate) fn solve_with<V, F, E>(
&self,
g: &V,
radius: F,
forcing_parameters: Option<(F, F)>,
mut bv: impl FnMut(&V) -> Result<V, E>,
) -> Result<Step<V, F>, E>
where
F: Scalar,
V: Clone
+ Dot<F>
+ NormSquared<F>
+ ScaledAdd<F>
+ ScaleInPlace<F>
+ NegInPlace
+ VectorLen,
{
let n = g.vec_len();
let max_iter = self.max_iter.unwrap_or(n.max(1));
let mut z = g.clone();
z.scale_in_place(F::zero());
let mut r = g.clone();
let mut r_dot = r.dot(&r);
let g_norm = r_dot.sqrt();
if g_norm == F::zero() {
return Ok(Step {
d: z,
predicted_reduction: F::zero(),
hit_boundary: false,
});
}
let half = F::from_f64(0.5).unwrap();
let (kappa, theta) = forcing_parameters.unwrap_or((half, half));
let power = if theta == F::zero() {
F::one()
} else if theta == half {
g_norm.sqrt()
} else if theta == F::one() {
g_norm
} else {
g_norm.powf(theta)
};
let tol = (if power < kappa { power } else { kappa }) * g_norm;
let mut d = r.clone();
d.neg_in_place();
for _ in 0..max_iter {
let bd = bv(&d)?;
let dbd = d.dot(&bd);
if dbd <= F::zero() {
let tau = tau_to_boundary(&z, &d, radius);
z.scaled_add(tau, &d);
let bz = bv(&z)?;
let predicted_reduction = model_decrease_from_bd(g, &z, &bz);
return Ok(Step {
d: z,
predicted_reduction,
hit_boundary: true,
});
}
let alpha = r_dot / dbd;
let mut z_next = z.clone();
z_next.scaled_add(alpha, &d);
if z_next.norm_squared().sqrt() >= radius {
let tau = tau_to_boundary(&z, &d, radius);
z.scaled_add(tau, &d);
let bz = bv(&z)?;
let predicted_reduction = model_decrease_from_bd(g, &z, &bz);
return Ok(Step {
d: z,
predicted_reduction,
hit_boundary: true,
});
}
z = z_next;
r.scaled_add(alpha, &bd);
let r_dot_next = r.dot(&r);
let residual_norm = r_dot_next.sqrt();
let converged = residual_norm == F::zero() || residual_norm < tol;
if converged {
let bz = bv(&z)?;
let predicted_reduction = model_decrease_from_bd(g, &z, &bz);
return Ok(Step {
d: z,
predicted_reduction,
hit_boundary: false,
});
}
let beta = r_dot_next / r_dot;
let mut d_next = r.clone();
d_next.neg_in_place();
d_next.scaled_add(beta, &d);
d = d_next;
r_dot = r_dot_next;
}
let bz = bv(&z)?;
let predicted_reduction = model_decrease_from_bd(g, &z, &bz);
Ok(Step {
d: z,
predicted_reduction,
hit_boundary: false,
})
}
}
impl Default for Steihaug {
fn default() -> Self {
Self::new()
}
}
impl<V, M, F> Subproblem<V, M, F> for Steihaug
where
F: Scalar,
V: Clone
+ Dot<F>
+ NormSquared<F>
+ ScaledAdd<F>
+ ScaleInPlace<F>
+ NegInPlace
+ VectorLen,
M: MatVec<V>,
{
fn solve(&self, g: &V, b: &M, radius: F) -> Step<V, F> {
match self.solve_with(g, radius, None, |v| {
Ok::<_, std::convert::Infallible>(b.matvec(v))
}) {
Ok(step) => step,
Err(never) => match never {},
}
}
}
impl<V, F> SubproblemHvp<V, F> for Steihaug
where
F: Scalar,
V: Clone
+ Dot<F>
+ NormSquared<F>
+ ScaledAdd<F>
+ ScaleInPlace<F>
+ NegInPlace
+ VectorLen,
{
fn solve_hvp<E>(
&self,
g: &V,
radius: F,
bv: impl FnMut(&V) -> Result<V, E>,
) -> Result<Step<V, F>, E> {
self.solve_with(g, radius, None, bv)
}
}
#[derive(Debug, Clone, Copy)]
pub struct SteihaugWithForcing<F: Scalar = f64> {
inner: Steihaug,
kappa: F,
theta: F,
}
impl<F: Scalar> SteihaugWithForcing<F> {
pub fn with_max_iter(mut self, n: usize) -> Self {
self.inner = self.inner.with_max_iter(n);
self
}
}
impl<V, M, F> Subproblem<V, M, F> for SteihaugWithForcing<F>
where
F: Scalar,
V: Clone
+ Dot<F>
+ NormSquared<F>
+ ScaledAdd<F>
+ ScaleInPlace<F>
+ NegInPlace
+ VectorLen,
M: MatVec<V>,
{
fn solve(&self, g: &V, b: &M, radius: F) -> Step<V, F> {
match self.inner.solve_with(
g,
radius,
Some((self.kappa, self.theta)),
|v| Ok::<_, std::convert::Infallible>(b.matvec(v)),
) {
Ok(step) => step,
Err(never) => match never {},
}
}
}
impl<V, F> SubproblemHvp<V, F> for SteihaugWithForcing<F>
where
F: Scalar,
V: Clone
+ Dot<F>
+ NormSquared<F>
+ ScaledAdd<F>
+ ScaleInPlace<F>
+ NegInPlace
+ VectorLen,
{
fn solve_hvp<E>(
&self,
g: &V,
radius: F,
bv: impl FnMut(&V) -> Result<V, E>,
) -> Result<Step<V, F>, E> {
self.inner
.solve_with(g, radius, Some((self.kappa, self.theta)), bv)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::math::DenseMatrix;
#[test]
fn forcing_parameters_control_inner_accuracy() {
let h = DenseMatrix::from_row_slice(2, 2, &[1.0, 0.0, 0.0, 2.0]);
for (norm, kappa, theta, iterations) in [
(10.0_f64, 0.5, 0.5, 1),
(10.0, 0.1, 1.0, 2),
(0.16, 0.5, 0.0, 1),
(0.16, 0.5, 0.5, 1),
(0.16, 0.5, 1.0, 2),
(0.5, 0.5, 1.5, 1),
(0.5, 0.5, 2.0, 2),
(1e-8, 0.5, 0.0, 1),
(1e-8, 0.1, 1.0, 2),
] {
let scale = norm / 2.0_f64.sqrt();
let g = vec![scale, scale];
let sub = Steihaug::new().with_forcing_parameters(kappa, theta);
let mut products = 0;
let free = sub
.solve_hvp(&g, 100.0, |v| {
products += 1;
Ok::<_, ()>(h.matvec(v))
})
.unwrap();
assert_eq!(products, iterations + 1);
let expected = if iterations == 1 {
[-2.0 * scale / 3.0; 2]
} else {
[-scale, -scale / 2.0]
};
for step in [free, sub.solve(&g, &h, 100.0)] {
assert!(!step.hit_boundary);
for (actual, expected) in step.d.iter().zip(expected) {
assert!((actual - expected).abs() < 1e-12 * norm);
}
let expected_reduction = if iterations == 1 {
2.0 * scale * scale / 3.0
} else {
0.75 * scale * scale
};
assert!(
(step.predicted_reduction - expected_reduction).abs()
< 1e-12 * norm * norm
);
}
}
}
#[test]
fn explicit_default_forcing_preserves_steps_and_iteration_caps() {
let h = DenseMatrix::from_row_slice(2, 2, &[1.0, 0.0, 0.0, 3.0]);
for value in [0.0_f64, 0.005, 3.0] {
let g = vec![value, value];
for cap in [1, 2, 5] {
let default = Steihaug::new().with_max_iter(cap);
let mut default_products = 0;
let expected = default
.solve_hvp(&g, 100.0, |v| {
default_products += 1;
Ok::<_, ()>(h.matvec(v))
})
.unwrap();
for configured in [
default.with_forcing_parameters(0.5, 0.5),
Steihaug::new()
.with_forcing_parameters(0.5, 0.5)
.with_max_iter(cap),
] {
let mut products = 0;
let actual = configured
.solve_hvp(&g, 100.0, |v| {
products += 1;
Ok::<_, ()>(h.matvec(v))
})
.unwrap();
assert_eq!(actual.d, expected.d);
assert_eq!(
actual.predicted_reduction,
expected.predicted_reduction
);
assert_eq!(actual.hit_boundary, expected.hit_boundary);
assert_eq!(products, default_products);
}
}
}
}
#[test]
fn fixed_tolerance_needs_more_than_dimension_steps() {
let n = 20;
let diagonal: Vec<_> = (0..n)
.map(|i| 2.0 * 10.0_f64.powf(4.0 * i as f64 / (n - 1) as f64))
.collect();
let g = diagonal.clone();
let run = |cap| {
let mut products = 0;
let step = Steihaug::new()
.with_forcing_parameters(1e-9, 0.0)
.with_max_iter(cap)
.solve_hvp(&g, 100.0, |v: &Vec<f64>| {
products += 1;
Ok::<_, ()>(
v.iter().zip(&diagonal).map(|(v, a)| v * a).collect(),
)
})
.unwrap();
let residual = g
.iter()
.zip(&step.d)
.zip(&diagonal)
.map(|((g, p), a)| (g + a * p).powi(2))
.sum::<f64>()
.sqrt();
assert!(!step.hit_boundary);
(residual / g.norm_squared().sqrt(), products, step)
};
let (capped, _, _) = run(n);
let (converged, products, step) = run(2 * n);
assert!(capped > 1e-9, "{capped}");
assert!(converged < 1e-9, "{converged}");
assert!(products > n + 1 && products <= 2 * n + 1);
assert!(step.d.iter().all(|p| (p + 1.0).abs() < 1e-6));
assert!(
(step.predicted_reduction - diagonal.iter().sum::<f64>() / 2.0)
.abs()
< 1e-6
);
}
#[test]
fn configured_forcing_preserves_boundary_and_negative_curvature() {
for curvature in [2.0_f64, 0.0, -2.0] {
let h = DenseMatrix::from_fn(2, 2, |i, j| {
if i == j { curvature } else { 0.0 }
});
let g = vec![3.0, 4.0];
for (kappa, theta) in [(1e-9, 0.0), (0.1, 1.0)] {
let sub = Steihaug::new().with_forcing_parameters(kappa, theta);
let free = sub
.solve_hvp(&g, 0.5, |v| Ok::<_, ()>(h.matvec(v)))
.unwrap();
for step in [sub.solve(&g, &h, 0.5), free] {
assert!(step.hit_boundary);
assert!((step.d.norm_squared().sqrt() - 0.5).abs() < 1e-12);
assert!(
(step.predicted_reduction - (2.5 - 0.125 * curvature))
.abs()
< 1e-12
);
}
}
}
}
#[test]
fn zero_tolerance_accepts_an_exact_residual_and_zero_gradient() {
for (kappa, theta) in [(0.0_f32, 0.0), (0.0, 1.0), (0.1, 1e6)] {
let sub = Steihaug::new().with_forcing_parameters(kappa, theta);
for g in [vec![0.0_f32, 0.0], vec![0.002, 0.004]] {
let mut products = 0;
let step = sub
.solve_hvp(&g, 10.0, |v: &Vec<f32>| {
products += 1;
Ok::<_, ()>(v.iter().map(|v| 2.0 * v).collect())
})
.unwrap();
assert_eq!(
step.d,
g.iter().map(|g| -g / 2.0).collect::<Vec<_>>()
);
assert_eq!(products, if g[0] == 0.0 { 0 } else { 2 });
}
}
}
#[test]
fn configured_forcing_propagates_product_errors() {
for (kappa, theta) in [(1e-9, 0.0), (0.1, 1.0)] {
let result = Steihaug::new()
.with_forcing_parameters(kappa, theta)
.solve_hvp(&vec![1.0], 10.0, |_| {
Err::<Vec<f64>, _>("product failed")
});
assert!(matches!(result, Err("product failed")));
}
}
#[test]
fn forcing_parameters_reject_invalid_settings() {
for kappa in [-1.0, 1.0, f64::NEG_INFINITY, f64::INFINITY, f64::NAN] {
assert!(
std::panic::catch_unwind(
|| Steihaug::new().with_forcing_parameters(kappa, 0.5)
)
.is_err()
);
}
for theta in [-1.0, f64::NEG_INFINITY, f64::INFINITY, f64::NAN] {
assert!(
std::panic::catch_unwind(
|| Steihaug::new().with_forcing_parameters(0.5, theta)
)
.is_err()
);
}
}
fn check_backend<V, M>(g: V, h: M)
where
V: Clone
+ Dot<f64>
+ NormSquared<f64>
+ ScaledAdd<f64>
+ ScaleInPlace<f64>
+ NegInPlace
+ VectorLen,
M: MatVec<V>,
{
for (kappa, theta) in [(1e-10, 0.0), (0.1, 1.0)] {
let sub = Steihaug::new().with_forcing_parameters(kappa, theta);
let exact = sub.solve(&g, &h, 100.0);
let free = sub
.solve_hvp(&g, 100.0, |v| Ok::<_, ()>(h.matvec(v)))
.unwrap();
for step in [exact, free] {
let mut residual = h.matvec(&step.d);
residual.scaled_add(1.0, &g);
assert!(residual.norm_squared().sqrt() < 1e-8);
assert!((step.predicted_reduction - 7.0).abs() < 1e-8);
assert!(!step.hit_boundary);
}
}
}
#[test]
fn configured_vec_backend() {
check_backend(
vec![2.0, 6.0],
DenseMatrix::from_row_slice(2, 2, &[2.0, 0.0, 0.0, 3.0]),
);
}
#[cfg(feature = "nalgebra_all")]
#[test]
fn configured_nalgebra_backend() {
check_backend(
nalgebra::DVector::from_vec(vec![2.0, 6.0]),
nalgebra::DMatrix::from_row_slice(2, 2, &[2.0, 0.0, 0.0, 3.0]),
);
}
#[cfg(feature = "ndarray_all")]
#[test]
fn configured_ndarray_backend() {
check_backend(
ndarray::Array1::from_vec(vec![2.0, 6.0]),
ndarray::Array2::from_shape_vec((2, 2), vec![2.0, 0.0, 0.0, 3.0])
.unwrap(),
);
}
#[cfg(feature = "faer_all")]
#[test]
fn configured_faer_backend() {
check_backend(
faer::Col::from_fn(2, |i| [2.0, 6.0][i]),
faer::Mat::from_fn(2, 2, |i, j| [[2.0, 0.0], [0.0, 3.0]][i][j]),
);
}
#[test]
fn nonfinite_gradient_does_not_signal_convergence() {
for value in [f64::NAN, f64::INFINITY, f64::MAX] {
let g = vec![value, 1.0];
let h = DenseMatrix::from_row_slice(2, 2, &[2.0, 0.0, 0.0, 3.0]);
assert!(
!Steihaug::new()
.solve(&g, &h, 1.0)
.predicted_reduction
.is_finite()
);
for (kappa, theta) in [(1e-9, 0.0), (0.1, 1.0)] {
assert!(
!Steihaug::new()
.with_forcing_parameters(kappa, theta)
.solve(&g, &h, 1.0)
.predicted_reduction
.is_finite()
);
}
}
}
#[test]
fn tighter_model_solve_reduces_outer_derivative_work() {
use crate::{
BasicState, CostFunction, Executor, Gradient, Hessian, TrustRegion,
};
struct Quadratic;
fn diagonal(i: usize) -> f64 {
2.0 * 10.0_f64.powf(4.0 * i as f64 / 19.0)
}
impl CostFunction for Quadratic {
type Param = Vec<f64>;
type Output = f64;
type Error = std::convert::Infallible;
fn cost(&self, x: &Vec<f64>) -> Result<f64, Self::Error> {
Ok(x.iter()
.enumerate()
.map(|(i, x)| 0.5 * diagonal(i) * x * x)
.sum())
}
}
impl Gradient for Quadratic {
type Gradient = Vec<f64>;
fn gradient(&self, x: &Vec<f64>) -> Result<Vec<f64>, Self::Error> {
Ok(x.iter().enumerate().map(|(i, x)| diagonal(i) * x).collect())
}
}
impl Hessian for Quadratic {
type Hessian = DenseMatrix;
fn hessian(
&self,
_: &Vec<f64>,
) -> Result<DenseMatrix, Self::Error> {
Ok(DenseMatrix::from_fn(20, 20, |i, j| {
if i == j { diagonal(i) } else { 0.0 }
}))
}
}
let result = Executor::new(
Quadratic,
TrustRegion::with_subproblem(
Steihaug::new()
.with_forcing_parameters(1e-9, 0.0)
.with_max_iter(40),
),
BasicState::new(vec![1.0; 20]),
)
.max_iter(100)
.target_cost(1e-6)
.run_with_solver()
.unwrap();
assert!(result.cost() <= 1e-6);
assert!(result.counts.hessian_evals <= 4);
let cost = Quadratic.cost(result.param()).unwrap();
assert!((cost - result.cost()).abs() < 1e-15);
}
}