Skip to main content

optirs_core/second_order/
mod.rs

1// Second-order optimization methods
2//
3// This module provides implementations of second-order optimization methods
4// that use curvature information (Hessian matrix) to improve convergence.
5
6pub mod kfac;
7pub mod newton_cg;
8
9use crate::error::{OptimError, Result};
10use scirs2_core::ndarray::{Array, Array1, Array2, Dimension, ScalarOperand};
11use scirs2_core::numeric::Float;
12use std::collections::VecDeque;
13use std::fmt::Debug;
14
15pub use self::kfac::{KFACConfig, KFACLayerState, KFACStats, LayerInfo, LayerType, KFAC};
16pub use self::newton_cg::NewtonCG;
17
18/// Trait for second-order optimization methods
19pub trait SecondOrderOptimizer<A: Float + ScalarOperand + Debug + Send + Sync, D: Dimension> {
20    /// Update parameters using second-order information
21    fn step_second_order(
22        &mut self,
23        params: &Array<A, D>,
24        gradients: &Array<A, D>,
25        hessian_info: &HessianInfo<A, D>,
26    ) -> Result<Array<A, D>>;
27
28    /// Reset optimizer state
29    fn reset(&mut self);
30}
31
32/// Hessian information for second-order methods
33#[derive(Debug, Clone)]
34pub enum HessianInfo<A: Float, D: Dimension> {
35    /// Full Hessian matrix (expensive, rarely used in practice)
36    Full(Array2<A>),
37    /// Diagonal approximation of Hessian
38    Diagonal(Array<A, D>),
39    /// L-BFGS style quasi-Newton approximation
40    QuasiNewton {
41        /// Parameter differences history
42        s_history: VecDeque<Array<A, D>>,
43        /// Gradient differences history
44        y_history: VecDeque<Array<A, D>>,
45    },
46    /// Gauss-Newton approximation for least squares problems
47    GaussNewton(Array2<A>),
48}
49
50/// Approximated Hessian computation methods
51pub mod hessian_approximation {
52    use super::*;
53
54    /// Compute diagonal Hessian approximation using finite differences (1D only)
55    pub fn diagonal_finite_difference<A, F>(
56        params: &Array1<A>,
57        gradient_fn: F,
58        epsilon: A,
59    ) -> Result<Array1<A>>
60    where
61        A: Float + ScalarOperand + Debug + Copy,
62        F: Fn(&Array1<A>) -> Result<Array1<A>>,
63    {
64        let mut hessian_diag = Array1::zeros(params.len());
65        let _original_grad = gradient_fn(params)?;
66
67        for i in 0..params.len() {
68            let mut param_plus = params.clone();
69            let mut param_minus = params.clone();
70
71            // Forward difference: f(x + h) - f(x)
72            param_plus[i] = params[i] + epsilon;
73            let grad_plus = gradient_fn(&param_plus)?;
74
75            // Backward difference: f(x) - f(x - h)
76            param_minus[i] = params[i] - epsilon;
77            let grad_minus = gradient_fn(&param_minus)?;
78
79            // Hessian diagonal: derivative of gradient using central difference
80            let two = A::from(2.0).ok_or_else(|| {
81                OptimError::InvalidConfig(
82                    "diagonal_finite_difference: integer literal 2.0 must fit in A".to_string(),
83                )
84            })?;
85            let second_deriv = (grad_plus[i] - grad_minus[i]) / (two * epsilon);
86            hessian_diag[i] = second_deriv;
87        }
88
89        Ok(hessian_diag)
90    }
91
92    /// Relative threshold used by the curvature (positive-definiteness) test.
93    ///
94    /// A curvature pair `(s, y)` is only usable by the L-BFGS two-loop recursion when
95    /// `y·s > 0`. Accepting a pair with `y·s <= 0` destroys the positive-definiteness
96    /// of the implicit inverse-Hessian approximation and can turn the resulting
97    /// "search direction" into an ascent direction. We use the standard relative
98    /// test `y·s > eps * ||s|| * ||y||` so the check is scale invariant.
99    fn curvature_threshold<A: Float>() -> A {
100        A::from(1e-8).unwrap_or_else(A::epsilon)
101    }
102
103    /// Euclidean norm of an array, computed without allocating.
104    fn euclidean_norm<A, D>(v: &Array<A, D>) -> A
105    where
106        A: Float,
107        D: Dimension,
108    {
109        v.iter().fold(A::zero(), |acc, &x| acc + x * x).sqrt()
110    }
111
112    /// Dot product of two arrays of identical shape.
113    fn dot_product<A, D>(a: &Array<A, D>, b: &Array<A, D>) -> A
114    where
115        A: Float,
116        D: Dimension,
117    {
118        a.iter()
119            .zip(b.iter())
120            .fold(A::zero(), |acc, (&x, &y)| acc + x * y)
121    }
122
123    /// Returns `true` when the curvature pair `(s, y)` satisfies `y·s > eps·||s||·||y||`
124    /// and is therefore safe to store in the L-BFGS history.
125    pub fn is_curvature_pair_acceptable<A, D>(
126        param_diff: &Array<A, D>,
127        grad_diff: &Array<A, D>,
128    ) -> bool
129    where
130        A: Float,
131        D: Dimension,
132    {
133        if param_diff.len() != grad_diff.len() {
134            return false;
135        }
136        let ys = dot_product(param_diff, grad_diff);
137        if !ys.is_finite() || ys <= A::zero() {
138            return false;
139        }
140        let threshold =
141            curvature_threshold::<A>() * euclidean_norm(param_diff) * euclidean_norm(grad_diff);
142        ys > threshold
143    }
144
145    /// Update L-BFGS Hessian approximation.
146    ///
147    /// Curvature pairs that fail the positive-curvature test `y·s > eps·||s||·||y||`
148    /// are **skipped** (not stored): storing them would destroy the positive
149    /// definiteness of the implicit inverse-Hessian approximation.
150    ///
151    /// # Returns
152    ///
153    /// `true` if the pair was accepted and stored, `false` if it was skipped.
154    pub fn update_lbfgs_approximation<A, D>(
155        s_history: &mut VecDeque<Array<A, D>>,
156        y_history: &mut VecDeque<Array<A, D>>,
157        param_diff: Array<A, D>,
158        grad_diff: Array<A, D>,
159        max_history: usize,
160    ) -> bool
161    where
162        A: Float + ScalarOperand + Debug,
163        D: Dimension,
164    {
165        if !is_curvature_pair_acceptable(&param_diff, &grad_diff) {
166            return false;
167        }
168
169        // Add new differences to the history
170        s_history.push_back(param_diff);
171        y_history.push_back(grad_diff);
172
173        // Maintain maximum history size
174        while s_history.len() > max_history {
175            s_history.pop_front();
176            y_history.pop_front();
177        }
178        true
179    }
180
181    /// Compute the L-BFGS initial inverse-Hessian scaling `gamma_k = (s·y) / (y·y)`
182    /// from the most recent *acceptable* curvature pair.
183    ///
184    /// Returns `None` when no stored pair passes the curvature test (in which case the
185    /// caller should fall back to a user-supplied scale).
186    pub fn initial_hessian_scaling<A, D>(
187        s_history: &VecDeque<Array<A, D>>,
188        y_history: &VecDeque<Array<A, D>>,
189    ) -> Option<A>
190    where
191        A: Float,
192        D: Dimension,
193    {
194        let m = s_history.len().min(y_history.len());
195        for i in (0..m).rev() {
196            let s_i = &s_history[i];
197            let y_i = &y_history[i];
198            if !is_curvature_pair_acceptable(s_i, y_i) {
199                continue;
200            }
201            let yy = dot_product(y_i, y_i);
202            if yy <= A::zero() || !yy.is_finite() {
203                continue;
204            }
205            let gamma = dot_product(s_i, y_i) / yy;
206            if gamma.is_finite() && gamma > A::zero() {
207                return Some(gamma);
208            }
209        }
210        None
211    }
212
213    /// Apply the L-BFGS two-loop recursion to approximate `H^(-1) * grad`.
214    ///
215    /// # Curvature filtering
216    ///
217    /// Pairs that fail the positive-curvature test `y·s > eps·||s||·||y||` are skipped:
218    /// they do not correspond to a positive-definite update and including them can turn
219    /// the result into an ascent direction. `s_history` / `y_history` populated through
220    /// [`update_lbfgs_approximation`] are already filtered, but a caller may also build a
221    /// [`super::HessianInfo::QuasiNewton`] history by hand, so the filter is applied here
222    /// as well.
223    ///
224    /// # Initial inverse-Hessian scaling
225    ///
226    /// `H_0 = gamma_k * I` with `gamma_k = (s·y) / (y·y)` computed from the most recent
227    /// acceptable curvature pair (Nocedal & Wright, eq. 7.20). `initial_hessian_scale` is
228    /// used as the fallback when no acceptable pair exists (including an empty history).
229    pub fn lbfgs_two_loop_recursion<A, D>(
230        gradient: &Array<A, D>,
231        s_history: &VecDeque<Array<A, D>>,
232        y_history: &VecDeque<Array<A, D>>,
233        initial_hessian_scale: A,
234    ) -> Result<Array<A, D>>
235    where
236        A: Float + ScalarOperand + Debug,
237        D: Dimension,
238    {
239        if s_history.len() != y_history.len() {
240            return Err(OptimError::InvalidConfig(
241                "History sizes don't match in L-BFGS".to_string(),
242            ));
243        }
244
245        let m = s_history.len();
246        if m == 0 {
247            // No history, return scaled gradient
248            return Ok(gradient * initial_hessian_scale);
249        }
250
251        // Precompute which pairs are usable and their rho values, so both loops
252        // agree and the `alphas` indices stay aligned with the history indices.
253        let mut rhos: Vec<Option<A>> = Vec::with_capacity(m);
254        for i in 0..m {
255            let s_i = &s_history[i];
256            let y_i = &y_history[i];
257            if s_i.len() != gradient.len() || y_i.len() != gradient.len() {
258                return Err(OptimError::DimensionMismatch(format!(
259                    "L-BFGS history entry {} has length {}/{}, expected {}",
260                    i,
261                    s_i.len(),
262                    y_i.len(),
263                    gradient.len()
264                )));
265            }
266            if is_curvature_pair_acceptable(s_i, y_i) {
267                rhos.push(Some(A::one() / dot_product(y_i, s_i)));
268            } else {
269                rhos.push(None);
270            }
271        }
272
273        // H_0 = gamma_k * I from the latest acceptable pair; fall back to the
274        // caller-supplied scale when every pair was rejected.
275        let scale = initial_hessian_scaling(s_history, y_history).unwrap_or(initial_hessian_scale);
276
277        let mut q = gradient.clone();
278        let mut alphas = vec![A::zero(); m];
279
280        // First loop (newest -> oldest): compute alphas and update q
281        for i in (0..m).rev() {
282            let rho_i = match rhos[i] {
283                Some(rho) => rho,
284                None => continue,
285            };
286            let s_i = &s_history[i];
287            let y_i = &y_history[i];
288
289            // alpha_i = rho_i * s_i^T * q
290            let alpha_i = rho_i * dot_product(s_i, &q);
291            alphas[i] = alpha_i;
292
293            // q = q - alpha_i * y_i
294            for (q_val, &y_val) in q.iter_mut().zip(y_i.iter()) {
295                *q_val = *q_val - alpha_i * y_val;
296            }
297        }
298
299        // Scale by the initial inverse-Hessian approximation
300        q.mapv_inplace(|x| x * scale);
301
302        // Second loop (oldest -> newest): compute the final result
303        for i in 0..m {
304            let rho_i = match rhos[i] {
305                Some(rho) => rho,
306                None => continue,
307            };
308            let s_i = &s_history[i];
309            let y_i = &y_history[i];
310
311            // beta = rho_i * y_i^T * q
312            let beta = rho_i * dot_product(y_i, &q);
313
314            // q = q + (alpha_i - beta) * s_i
315            let coeff = alphas[i] - beta;
316            for (q_val, &s_val) in q.iter_mut().zip(s_i.iter()) {
317                *q_val = *q_val + coeff * s_val;
318            }
319        }
320
321        Ok(q)
322    }
323
324    /// Gauss-Newton Hessian approximation for least squares problems
325    pub fn gauss_newton_approximation<A>(jacobian: &Array2<A>) -> Result<Array2<A>>
326    where
327        A: Float + ScalarOperand + Debug,
328    {
329        // Gauss-Newton approximation: H ≈ J^T * J
330        let j_transpose = jacobian.t();
331        let hessian_approx = j_transpose.dot(jacobian);
332        Ok(hessian_approx)
333    }
334}
335
336/// Newton's method optimizer
337///
338/// # Descent safeguarding
339///
340/// A raw Newton step `-H^{-1} g` is only a descent direction when `H` is positive
341/// definite. For a diagonal Hessian approximation this optimizer therefore uses
342/// `|h_ii|` (floored at [`Newton::min_curvature`]) as the denominator, which keeps the
343/// update a descent direction even where the curvature is negative or vanishing.
344#[derive(Debug, Clone)]
345pub struct Newton<A: Float> {
346    learning_rate: A,
347    regularization: A, // For numerical stability
348    min_curvature: A,  // Lower bound on |h_ii| used as the step denominator
349}
350
351impl<A: Float + ScalarOperand + Debug + Send + Sync + Send + Sync> Newton<A> {
352    /// Default lower bound on the absolute diagonal curvature.
353    fn default_min_curvature() -> A {
354        A::from(1e-8).unwrap_or_else(A::epsilon)
355    }
356
357    /// Create a new Newton optimizer
358    pub fn new(learning_rate: A) -> Self {
359        Self {
360            learning_rate,
361            regularization: A::from(1e-6).unwrap_or_else(A::epsilon),
362            min_curvature: Self::default_min_curvature(),
363        }
364    }
365
366    /// Set regularization parameter for numerical stability
367    pub fn with_regularization(mut self, regularization: A) -> Self {
368        self.regularization = regularization;
369        self
370    }
371
372    /// Set the lower bound applied to `|h_ii|` before it is used as the step denominator.
373    ///
374    /// Values `<= 0` are ignored and the default is kept, since a non-positive floor
375    /// would re-admit division by (near-)zero curvature.
376    pub fn with_min_curvature(mut self, min_curvature: A) -> Self {
377        if min_curvature > A::zero() {
378            self.min_curvature = min_curvature;
379        }
380        self
381    }
382
383    /// Get the lower bound applied to `|h_ii|`.
384    pub fn min_curvature(&self) -> A {
385        self.min_curvature
386    }
387}
388
389impl<A: Float + ScalarOperand + Debug + Send + Sync + Send + Sync>
390    SecondOrderOptimizer<A, scirs2_core::ndarray::Ix1> for Newton<A>
391{
392    fn step_second_order(
393        &mut self,
394        params: &Array1<A>,
395        gradients: &Array1<A>,
396        hessian_info: &HessianInfo<A, scirs2_core::ndarray::Ix1>,
397    ) -> Result<Array1<A>> {
398        match hessian_info {
399            HessianInfo::Diagonal(hessian_diag) => {
400                if params.len() != hessian_diag.len() || params.len() != gradients.len() {
401                    return Err(OptimError::DimensionMismatch(
402                        "Parameter, gradient, and Hessian dimensions must match".to_string(),
403                    ));
404                }
405
406                let mut update = Array1::zeros(params.len());
407                for i in 0..params.len() {
408                    // Use |h_ii| (floored at `min_curvature`) as the denominator.
409                    //
410                    // Dividing by a *signed* curvature flips the sign of the update
411                    // wherever `h_ii < 0`, which turns the step into an ascent step at
412                    // exactly the points (saddles / concave regions) where a descent
413                    // step matters most. The absolute value keeps `-lr * g_i / |h_ii|`
414                    // a descent direction for every coordinate, and the floor removes
415                    // the division-by-(near-)zero case without silently switching to a
416                    // differently-scaled fallback.
417                    let h_ii = hessian_diag[i] + self.regularization;
418                    let denom = h_ii.abs().max(self.min_curvature);
419                    update[i] = gradients[i] / denom;
420                }
421
422                Ok(params - &(update * self.learning_rate))
423            }
424            HessianInfo::QuasiNewton {
425                s_history,
426                y_history,
427            } => {
428                // Use L-BFGS approximation
429                let search_direction = hessian_approximation::lbfgs_two_loop_recursion(
430                    gradients,
431                    s_history,
432                    y_history,
433                    A::one(), // Initial Hessian scale
434                )?;
435
436                Ok(params - &(search_direction * self.learning_rate))
437            }
438            _ => Err(OptimError::InvalidConfig(
439                "Unsupported Hessian information type for Newton method".to_string(),
440            )),
441        }
442    }
443
444    fn reset(&mut self) {
445        // Newton method is stateless, nothing to reset
446    }
447}
448
449/// Quasi-Newton L-BFGS optimizer
450#[derive(Debug)]
451pub struct LBFGS<A: Float, D: Dimension> {
452    learning_rate: A,
453    max_history: usize,
454    s_history: VecDeque<Array<A, D>>,
455    y_history: VecDeque<Array<A, D>>,
456    previous_params: Option<Array<A, D>>,
457    previous_grad: Option<Array<A, D>>,
458}
459
460impl<A: Float + ScalarOperand + Debug + Send + Sync, D: Dimension + Send + Sync> LBFGS<A, D> {
461    /// Create a new L-BFGS optimizer
462    pub fn new(learning_rate: A) -> Self {
463        Self {
464            learning_rate,
465            max_history: 10,
466            s_history: VecDeque::new(),
467            y_history: VecDeque::new(),
468            previous_params: None,
469            previous_grad: None,
470        }
471    }
472
473    /// Set maximum history size
474    pub fn with_max_history(mut self, max_history: usize) -> Self {
475        self.max_history = max_history;
476        self
477    }
478
479    /// Perform L-BFGS step
480    pub fn step(&mut self, params: &Array<A, D>, gradients: &Array<A, D>) -> Result<Array<A, D>> {
481        // Update history if we have previous step information
482        if let (Some(prev_params), Some(prev_grad)) = (&self.previous_params, &self.previous_grad) {
483            let s = params - prev_params; // Parameter difference
484            let y = gradients - prev_grad; // Gradient difference
485
486            // Pairs failing the curvature test `y·s > eps·||s||·||y||` are skipped by
487            // `update_lbfgs_approximation` to preserve positive definiteness.
488            let _accepted = hessian_approximation::update_lbfgs_approximation(
489                &mut self.s_history,
490                &mut self.y_history,
491                s,
492                y,
493                self.max_history,
494            );
495        }
496
497        // Compute search direction using two-loop recursion
498        let search_direction = if self.s_history.is_empty() {
499            // No history, use gradient descent
500            gradients.clone()
501        } else {
502            hessian_approximation::lbfgs_two_loop_recursion(
503                gradients,
504                &self.s_history,
505                &self.y_history,
506                A::one(),
507            )?
508        };
509
510        // Update parameters
511        let new_params = params - &(search_direction * self.learning_rate);
512
513        // Store current information for next iteration
514        self.previous_params = Some(params.clone());
515        self.previous_grad = Some(gradients.clone());
516
517        Ok(new_params)
518    }
519}
520
521impl<A: Float + ScalarOperand + Debug + Send + Sync, D: Dimension + Send + Sync>
522    SecondOrderOptimizer<A, D> for LBFGS<A, D>
523{
524    fn step_second_order(
525        &mut self,
526        params: &Array<A, D>,
527        gradients: &Array<A, D>,
528        _hessian_info: &HessianInfo<A, D>, // L-BFGS maintains its own history
529    ) -> Result<Array<A, D>> {
530        self.step(params, gradients)
531    }
532
533    fn reset(&mut self) {
534        self.s_history.clear();
535        self.y_history.clear();
536        self.previous_params = None;
537        self.previous_grad = None;
538    }
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544    use approx::assert_relative_eq;
545    use scirs2_core::ndarray::Array1;
546
547    #[test]
548    fn test_diagonal_hessian_approximation() {
549        // Test on a simple quadratic function: f(x) = x^2
550        let params = Array1::from_vec(vec![1.0]);
551
552        // Gradient function for quadratic: grad = 2*x
553        let gradient_fn =
554            |x: &Array1<f64>| -> Result<Array1<f64>> { Ok(Array1::from_vec(vec![2.0 * x[0]])) };
555
556        let hessian_diag =
557            hessian_approximation::diagonal_finite_difference(&params, gradient_fn, 1e-5)
558                .expect("hessian_approximation::diagonal_finite_difference succeeds in test_diagonal_hessian_approximation");
559
560        // For quadratic function f(x) = x^2, second derivative should be 2.0
561        assert_relative_eq!(hessian_diag[0], 2.0, epsilon = 1e-1);
562    }
563
564    #[test]
565    fn test_lbfgs_two_loop_recursion() {
566        let gradient = Array1::from_vec(vec![1.0, 2.0, 3.0]);
567        let mut s_history = VecDeque::new();
568        let mut y_history = VecDeque::new();
569
570        // Add some history
571        s_history.push_back(Array1::from_vec(vec![0.1, 0.1, 0.1]));
572        y_history.push_back(Array1::from_vec(vec![0.2, 0.3, 0.4]));
573
574        let result =
575            hessian_approximation::lbfgs_two_loop_recursion(&gradient, &s_history, &y_history, 1.0)
576                .expect("hessian_approximation::lbfgs_two_loop_recursion succeeds in test_lbfgs_two_loop_recursion");
577
578        // Result should be different from original gradient due to curvature information
579        assert_ne!(result, gradient);
580        assert_eq!(result.len(), gradient.len());
581    }
582
583    #[test]
584    fn test_newton_method() {
585        let mut optimizer = Newton::new(0.1);
586        let params = Array1::from_vec(vec![1.0, 2.0]);
587        let gradients = Array1::from_vec(vec![0.1, 0.2]);
588        let hessian_diag = Array1::from_vec(vec![2.0, 4.0]);
589
590        let hessian_info = HessianInfo::Diagonal(hessian_diag);
591        let new_params = optimizer
592            .step_second_order(&params, &gradients, &hessian_info)
593            .expect("step_second_order succeeds in test_newton_method");
594
595        // Verify parameters were updated
596        assert!(new_params[0] < params[0]);
597        assert!(new_params[1] < params[1]);
598    }
599
600    #[test]
601    fn test_lbfgs_optimizer() {
602        let mut optimizer = LBFGS::new(0.01).with_max_history(5);
603        let mut params = Array1::from_vec(vec![1.0, 2.0, 3.0]);
604        let gradients1 = Array1::from_vec(vec![0.1, 0.2, 0.3]);
605        let gradients2 = Array1::from_vec(vec![0.05, 0.15, 0.25]);
606
607        // First step
608        params = optimizer
609            .step(&params, &gradients1)
610            .expect("optimizer.step succeeds in test_lbfgs_optimizer");
611
612        // Second step (should use history)
613        let new_params = optimizer
614            .step(&params, &gradients2)
615            .expect("optimizer.step succeeds in test_lbfgs_optimizer");
616
617        // Verify parameters were updated
618        assert_ne!(new_params, params);
619        assert_eq!(optimizer.s_history.len(), 1);
620        assert_eq!(optimizer.y_history.len(), 1);
621    }
622
623    #[test]
624    fn test_gauss_newton_approximation() {
625        let jacobian = Array2::from_shape_vec((3, 2), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0])
626            .expect("Array2::from_shape_vec succeeds in test_gauss_newton_approximation");
627        let hessian_approx =
628            hessian_approximation::gauss_newton_approximation(&jacobian).expect("hessian_approximation::gauss_newton_approximation succeeds in test_gauss_newton_approximation");
629
630        // Should be a 2x2 matrix (J^T * J)
631        assert_eq!(hessian_approx.dim(), (2, 2));
632
633        // Verify it's positive semidefinite by checking diagonal elements are non-negative
634        assert!(hessian_approx[(0, 0)] >= 0.0);
635        assert!(hessian_approx[(1, 1)] >= 0.0);
636    }
637}