Skip to main content

echidna_optim/
line_search.rs

1use num_traits::Float;
2
3use crate::convergence::dot;
4use crate::objective::Objective;
5
6/// Parameters for the backtracking Armijo line search.
7#[derive(Debug, Clone)]
8pub struct ArmijoParams<F> {
9    /// Sufficient decrease parameter (default: 1e-4).
10    pub c: F,
11    /// Backtracking factor (default: 0.5).
12    pub rho: F,
13    /// Initial step size (default: 1.0).
14    pub alpha_init: F,
15    /// Minimum step size before declaring failure (default: 1e-16).
16    pub alpha_min: F,
17}
18
19impl Default for ArmijoParams<f64> {
20    fn default() -> Self {
21        ArmijoParams {
22            c: 1e-4,
23            rho: 0.5,
24            alpha_init: 1.0,
25            alpha_min: 1e-16,
26        }
27    }
28}
29
30impl Default for ArmijoParams<f32> {
31    fn default() -> Self {
32        ArmijoParams {
33            c: 1e-4,
34            rho: 0.5,
35            alpha_init: 1.0,
36            alpha_min: 1e-8,
37        }
38    }
39}
40
41/// Result of a successful line search.
42#[derive(Debug)]
43pub struct LineSearchResult<F> {
44    /// The accepted step size.
45    pub alpha: F,
46    /// Objective value at `x + alpha * d`.
47    pub value: F,
48    /// Gradient at `x + alpha * d`.
49    pub gradient: Vec<F>,
50    /// Number of function evaluations used.
51    pub evals: usize,
52}
53
54/// Backtracking line search satisfying the Armijo (sufficient decrease) condition.
55///
56/// Searches for `alpha` such that `f(x + alpha*d) <= f(x) + c * alpha * g^T d`.
57///
58/// Returns `None` if `alpha` falls below `alpha_min` (line search failure),
59/// which includes the case where every trial point returned a non-finite
60/// objective value or gradient — treated as infeasible and backtracked past.
61pub fn backtracking_armijo<F: Float, O: Objective<F>>(
62    obj: &mut O,
63    x: &[F],
64    d: &[F],
65    f_x: F,
66    grad_x: &[F],
67    params: &ArmijoParams<F>,
68) -> Option<LineSearchResult<F>> {
69    let n = x.len();
70
71    // Reject misconfigured params: with `rho` outside `(0, 1)` the step length
72    // never shrinks (`rho >= 1` → infinite loop), and `alpha_min <= 0` lets `alpha`
73    // underflow to `0` and return a degenerate zero-step "success". The whole check
74    // is a positive-range predicate so a NaN `rho`/`alpha_min` is rejected too (a
75    // negated `<= 0` form would let NaN slip through). Returning `None` routes to
76    // `LineSearchFailed` in the callers (L-BFGS, Newton).
77    if !(params.rho > F::zero() && params.rho < F::one() && params.alpha_min > F::zero()) {
78        return None;
79    }
80
81    let dg = dot(grad_x, d);
82
83    // Not a descent direction — caller should handle this
84    if dg >= F::zero() {
85        return None;
86    }
87
88    let mut alpha = params.alpha_init;
89    let mut x_new = vec![F::zero(); n];
90    let mut evals = 0;
91
92    loop {
93        if alpha < params.alpha_min {
94            return None;
95        }
96
97        for i in 0..n {
98            x_new[i] = x[i] + alpha * d[i];
99        }
100
101        let (f_new, g_new) = obj.eval_grad(&x_new);
102        evals += 1;
103
104        // Reject infeasible trial points: `-Inf <= anything` is trivially
105        // true, so the Armijo check would accept a step off the domain and
106        // the solver would walk toward -Inf indefinitely. A NaN `f_new`
107        // falls through (`NaN <= x` is false) but is rejected here for
108        // symmetry. Either case is treated as "backtrack past this α".
109        if !f_new.is_finite() || !g_new.iter().all(|g| g.is_finite()) {
110            alpha = alpha * params.rho;
111            continue;
112        }
113
114        // Armijo condition: f(x + alpha*d) <= f(x) + c * alpha * g^T d
115        if f_new <= f_x + params.c * alpha * dg {
116            return Some(LineSearchResult {
117                alpha,
118                value: f_new,
119                gradient: g_new,
120                evals,
121            });
122        }
123
124        alpha = alpha * params.rho;
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    /// Simple quadratic objective for testing: f(x) = 0.5 * (x0^2 + x1^2)
133    struct Quadratic;
134
135    impl Objective<f64> for Quadratic {
136        fn dim(&self) -> usize {
137            2
138        }
139
140        fn eval_grad(&mut self, x: &[f64]) -> (f64, Vec<f64>) {
141            let f = 0.5 * (x[0] * x[0] + x[1] * x[1]);
142            let g = vec![x[0], x[1]];
143            (f, g)
144        }
145    }
146
147    #[test]
148    fn armijo_quadratic_descent() {
149        let mut obj = Quadratic;
150        let x = vec![2.0, 3.0];
151        let (f_x, grad) = obj.eval_grad(&x);
152        // Steepest descent direction
153        let d: Vec<f64> = grad.iter().map(|&g| -g).collect();
154
155        let result =
156            backtracking_armijo(&mut obj, &x, &d, f_x, &grad, &ArmijoParams::default()).unwrap();
157
158        assert!(result.alpha > 0.0);
159        assert!(result.value < f_x, "line search should decrease objective");
160    }
161
162    #[test]
163    fn armijo_full_step_on_quadratic() {
164        let mut obj = Quadratic;
165        let x = vec![2.0, 3.0];
166        let (f_x, grad) = obj.eval_grad(&x);
167        let d: Vec<f64> = grad.iter().map(|&g| -g).collect();
168
169        let result =
170            backtracking_armijo(&mut obj, &x, &d, f_x, &grad, &ArmijoParams::default()).unwrap();
171
172        // For a quadratic, steepest descent with alpha=1 satisfies Armijo with c=1e-4
173        assert!(
174            (result.alpha - 1.0).abs() < 1e-12,
175            "full step should be accepted on quadratic, got alpha={}",
176            result.alpha
177        );
178    }
179
180    #[test]
181    fn armijo_non_descent_returns_none() {
182        let mut obj = Quadratic;
183        let x = vec![2.0, 3.0];
184        let (f_x, grad) = obj.eval_grad(&x);
185        // Ascent direction (same as gradient)
186        let d = grad.clone();
187
188        let result = backtracking_armijo(&mut obj, &x, &d, f_x, &grad, &ArmijoParams::default());
189        assert!(result.is_none());
190    }
191
192    // Termination guards (green-only-observable — the pre-fix `rho >= 1` bug is an
193    // infinite loop, so it cannot be run as an assertion-flip red-first).
194    #[test]
195    fn armijo_rejects_rho_ge_one() {
196        let mut obj = Quadratic;
197        let x = vec![2.0, 3.0];
198        let (f_x, grad) = obj.eval_grad(&x);
199        let d: Vec<f64> = grad.iter().map(|&g| -g).collect();
200        // rho >= 1 would spin forever (alpha never shrinks); must be rejected.
201        let params = ArmijoParams {
202            rho: 1.0,
203            ..Default::default()
204        };
205        let result = backtracking_armijo(&mut obj, &x, &d, f_x, &grad, &params);
206        assert!(result.is_none(), "rho >= 1 must be rejected, not looped");
207    }
208
209    #[test]
210    fn armijo_rejects_nonpositive_alpha_min() {
211        let mut obj = Quadratic;
212        let x = vec![2.0, 3.0];
213        let (f_x, grad) = obj.eval_grad(&x);
214        let d: Vec<f64> = grad.iter().map(|&g| -g).collect();
215        // alpha_min <= 0 would let alpha underflow to a degenerate zero-step.
216        let params = ArmijoParams {
217            alpha_min: 0.0,
218            ..Default::default()
219        };
220        let result = backtracking_armijo(&mut obj, &x, &d, f_x, &grad, &params);
221        assert!(result.is_none(), "alpha_min <= 0 must be rejected");
222    }
223
224    #[test]
225    fn armijo_rejects_nan_params() {
226        let mut obj = Quadratic;
227        let x = vec![2.0, 3.0];
228        let (f_x, grad) = obj.eval_grad(&x);
229        let d: Vec<f64> = grad.iter().map(|&g| -g).collect();
230        // NaN rho / alpha_min must be rejected by the positive-range guard, not
231        // slip through a negated `<= 0` comparison.
232        let nan_rho = ArmijoParams {
233            rho: f64::NAN,
234            ..Default::default()
235        };
236        assert!(backtracking_armijo(&mut obj, &x, &d, f_x, &grad, &nan_rho).is_none());
237        let nan_amin = ArmijoParams {
238            alpha_min: f64::NAN,
239            ..Default::default()
240        };
241        assert!(backtracking_armijo(&mut obj, &x, &d, f_x, &grad, &nan_amin).is_none());
242    }
243}