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.
61///
62/// On failure the evaluations spent are discarded with the `None`; use
63/// [`backtracking_armijo_with_evals`] when the caller accounts for every
64/// objective evaluation (as the solvers do).
65pub fn backtracking_armijo<F: Float, O: Objective<F>>(
66    obj: &mut O,
67    x: &[F],
68    d: &[F],
69    f_x: F,
70    grad_x: &[F],
71    params: &ArmijoParams<F>,
72) -> Option<LineSearchResult<F>> {
73    let mut discarded = 0;
74    backtracking_armijo_with_evals(obj, x, d, f_x, grad_x, params, &mut discarded)
75}
76
77/// [`backtracking_armijo`] with an evaluation accumulator.
78///
79/// Every objective evaluation is added to `*func_evals` as it happens, so
80/// the count survives the failure paths — a search that backtracks all the
81/// way to `alpha_min` and returns `None` has still spent its evaluations,
82/// and a solver reporting total work must include them. On success,
83/// [`LineSearchResult::evals`] still carries this search's own count (for
84/// backtrack diagnostics); it is already included in `*func_evals`.
85pub fn backtracking_armijo_with_evals<F: Float, O: Objective<F>>(
86    obj: &mut O,
87    x: &[F],
88    d: &[F],
89    f_x: F,
90    grad_x: &[F],
91    params: &ArmijoParams<F>,
92    func_evals: &mut usize,
93) -> Option<LineSearchResult<F>> {
94    let n = x.len();
95
96    // Reject misconfigured params: with `rho` outside `(0, 1)` the step length
97    // never shrinks (`rho >= 1` → infinite loop), and `alpha_min <= 0` lets `alpha`
98    // underflow to `0` and return a degenerate zero-step "success". The whole check
99    // is a positive-range predicate so a NaN `rho`/`alpha_min` is rejected too (a
100    // negated `<= 0` form would let NaN slip through). Returning `None` routes to
101    // `LineSearchFailed` in the callers (L-BFGS, Newton).
102    if !(params.rho > F::zero() && params.rho < F::one() && params.alpha_min > F::zero()) {
103        return None;
104    }
105
106    let dg = dot(grad_x, d);
107
108    // Not a descent direction — caller should handle this
109    if dg >= F::zero() {
110        return None;
111    }
112
113    let mut alpha = params.alpha_init;
114    let mut x_new = vec![F::zero(); n];
115    let mut evals = 0;
116
117    loop {
118        if alpha < params.alpha_min {
119            return None;
120        }
121
122        for i in 0..n {
123            x_new[i] = x[i] + alpha * d[i];
124        }
125
126        let (f_new, g_new) = obj.eval_grad(&x_new);
127        evals += 1;
128        *func_evals += 1;
129
130        // Reject infeasible trial points: `-Inf <= anything` is trivially
131        // true, so the Armijo check would accept a step off the domain and
132        // the solver would walk toward -Inf indefinitely. A NaN `f_new`
133        // falls through (`NaN <= x` is false) but is rejected here for
134        // symmetry. Either case is treated as "backtrack past this α".
135        if !f_new.is_finite() || !g_new.iter().all(|g| g.is_finite()) {
136            alpha = alpha * params.rho;
137            continue;
138        }
139
140        // Armijo condition: f(x + alpha*d) <= f(x) + c * alpha * g^T d
141        if f_new <= f_x + params.c * alpha * dg {
142            return Some(LineSearchResult {
143                alpha,
144                value: f_new,
145                gradient: g_new,
146                evals,
147            });
148        }
149
150        alpha = alpha * params.rho;
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    /// Simple quadratic objective for testing: f(x) = 0.5 * (x0^2 + x1^2)
159    struct Quadratic;
160
161    impl Objective<f64> for Quadratic {
162        fn dim(&self) -> usize {
163            2
164        }
165
166        fn eval_grad(&mut self, x: &[f64]) -> (f64, Vec<f64>) {
167            let f = 0.5 * (x[0] * x[0] + x[1] * x[1]);
168            let g = vec![x[0], x[1]];
169            (f, g)
170        }
171    }
172
173    #[test]
174    fn armijo_quadratic_descent() {
175        let mut obj = Quadratic;
176        let x = vec![2.0, 3.0];
177        let (f_x, grad) = obj.eval_grad(&x);
178        // Steepest descent direction
179        let d: Vec<f64> = grad.iter().map(|&g| -g).collect();
180
181        let result =
182            backtracking_armijo(&mut obj, &x, &d, f_x, &grad, &ArmijoParams::default()).unwrap();
183
184        assert!(result.alpha > 0.0);
185        assert!(result.value < f_x, "line search should decrease objective");
186    }
187
188    #[test]
189    fn armijo_full_step_on_quadratic() {
190        let mut obj = Quadratic;
191        let x = vec![2.0, 3.0];
192        let (f_x, grad) = obj.eval_grad(&x);
193        let d: Vec<f64> = grad.iter().map(|&g| -g).collect();
194
195        let result =
196            backtracking_armijo(&mut obj, &x, &d, f_x, &grad, &ArmijoParams::default()).unwrap();
197
198        // For a quadratic, steepest descent with alpha=1 satisfies Armijo with c=1e-4
199        assert!(
200            (result.alpha - 1.0).abs() < 1e-12,
201            "full step should be accepted on quadratic, got alpha={}",
202            result.alpha
203        );
204    }
205
206    #[test]
207    fn armijo_non_descent_returns_none() {
208        let mut obj = Quadratic;
209        let x = vec![2.0, 3.0];
210        let (f_x, grad) = obj.eval_grad(&x);
211        // Ascent direction (same as gradient)
212        let d = grad.clone();
213
214        let result = backtracking_armijo(&mut obj, &x, &d, f_x, &grad, &ArmijoParams::default());
215        assert!(result.is_none());
216    }
217
218    // Termination guards (green-only-observable — the pre-fix `rho >= 1` bug is an
219    // infinite loop, so it cannot be run as an assertion-flip red-first).
220    #[test]
221    fn armijo_rejects_rho_ge_one() {
222        let mut obj = Quadratic;
223        let x = vec![2.0, 3.0];
224        let (f_x, grad) = obj.eval_grad(&x);
225        let d: Vec<f64> = grad.iter().map(|&g| -g).collect();
226        // rho >= 1 would spin forever (alpha never shrinks); must be rejected.
227        let params = ArmijoParams {
228            rho: 1.0,
229            ..Default::default()
230        };
231        let result = backtracking_armijo(&mut obj, &x, &d, f_x, &grad, &params);
232        assert!(result.is_none(), "rho >= 1 must be rejected, not looped");
233    }
234
235    #[test]
236    fn armijo_rejects_nonpositive_alpha_min() {
237        let mut obj = Quadratic;
238        let x = vec![2.0, 3.0];
239        let (f_x, grad) = obj.eval_grad(&x);
240        let d: Vec<f64> = grad.iter().map(|&g| -g).collect();
241        // alpha_min <= 0 would let alpha underflow to a degenerate zero-step.
242        let params = ArmijoParams {
243            alpha_min: 0.0,
244            ..Default::default()
245        };
246        let result = backtracking_armijo(&mut obj, &x, &d, f_x, &grad, &params);
247        assert!(result.is_none(), "alpha_min <= 0 must be rejected");
248    }
249
250    #[test]
251    fn armijo_rejects_nan_params() {
252        let mut obj = Quadratic;
253        let x = vec![2.0, 3.0];
254        let (f_x, grad) = obj.eval_grad(&x);
255        let d: Vec<f64> = grad.iter().map(|&g| -g).collect();
256        // NaN rho / alpha_min must be rejected by the positive-range guard, not
257        // slip through a negated `<= 0` comparison.
258        let nan_rho = ArmijoParams {
259            rho: f64::NAN,
260            ..Default::default()
261        };
262        assert!(backtracking_armijo(&mut obj, &x, &d, f_x, &grad, &nan_rho).is_none());
263        let nan_amin = ArmijoParams {
264            alpha_min: f64::NAN,
265            ..Default::default()
266        };
267        assert!(backtracking_armijo(&mut obj, &x, &d, f_x, &grad, &nan_amin).is_none());
268    }
269}