scirs2_optimize/unconstrained/
conjugate_gradient.rs

1//! Conjugate Gradient method for unconstrained optimization
2
3use crate::error::OptimizeError;
4use crate::unconstrained::result::OptimizeResult;
5use crate::unconstrained::utils::{array_diff_norm, check_convergence, finite_difference_gradient};
6use crate::unconstrained::{Bounds, Options};
7use scirs2_core::ndarray::{Array1, ArrayView1};
8
9/// Implements the Conjugate Gradient method for unconstrained optimization with optional bounds support
10#[allow(dead_code)]
11pub fn minimize_conjugate_gradient<F, S>(
12    mut fun: F,
13    x0: Array1<f64>,
14    options: &Options,
15) -> Result<OptimizeResult<S>, OptimizeError>
16where
17    F: FnMut(&ArrayView1<f64>) -> S + Clone,
18    S: Into<f64> + Clone,
19{
20    // Get options or use defaults
21    let ftol = options.ftol;
22    let gtol = options.gtol;
23    let max_iter = options.max_iter;
24    let eps = options.eps;
25    let bounds = options.bounds.as_ref();
26
27    // Initialize variables
28    let n = x0.len();
29    let mut x = x0.to_owned();
30
31    // Ensure initial point is within bounds
32    if let Some(bounds) = bounds {
33        bounds.project(x.as_slice_mut().unwrap());
34    }
35
36    let mut f = fun(&x.view()).into();
37
38    // Calculate initial gradient using finite differences
39    let mut g = finite_difference_gradient(&mut fun, &x.view(), eps)?;
40
41    // Initialize search direction as projected steepest descent
42    let mut p = -g.clone();
43
44    // Project the search direction to respect bounds if at boundary
45    if let Some(bounds) = bounds {
46        project_search_direction(&mut p, &x, bounds);
47    }
48
49    // Counters
50    let mut iter = 0;
51    let mut nfev = 1 + n; // Initial evaluation plus gradient calculations
52
53    while iter < max_iter {
54        // Check convergence on gradient
55        if g.mapv(|gi| gi.abs()).sum() < gtol {
56            break;
57        }
58
59        // If search direction is zero (completely constrained),
60        // we're at a constrained optimum
61        if p.mapv(|pi| pi.abs()).sum() < 1e-10 {
62            break;
63        }
64
65        // Line search along the search direction, respecting bounds
66        let (alpha, f_new) = line_search_cg(&mut fun, &x, &p, f, &mut nfev, bounds);
67
68        // Update position
69        let mut x_new = &x + &(&p * alpha);
70
71        // Ensure we're within bounds (should be a no-op if line_search_cg respected bounds)
72        if let Some(bounds) = bounds {
73            bounds.project(x_new.as_slice_mut().unwrap());
74        }
75
76        // Check if the step actually moved the point
77        let step_size = array_diff_norm(&x_new.view(), &x.view());
78        if step_size < 1e-10 {
79            // We're at a boundary constraint and can't move further
80            x = x_new;
81            break;
82        }
83
84        // Compute new gradient
85        let g_new = finite_difference_gradient(&mut fun, &x_new.view(), eps)?;
86        nfev += n;
87
88        // Check convergence on function value
89        if check_convergence(
90            f - f_new,
91            step_size,
92            g_new.mapv(|x| x.abs()).sum(),
93            ftol,
94            options.xtol,
95            gtol,
96        ) {
97            x = x_new;
98            g = g_new;
99            break;
100        }
101
102        // Calculate beta using the Fletcher-Reeves formula
103        let g_new_norm_sq = g_new.dot(&g_new);
104        let g_norm_sq = g.dot(&g);
105
106        // If gradient is too small, use steepest descent
107        let beta_fr = if g_norm_sq < 1e-10 {
108            0.0
109        } else {
110            g_new_norm_sq / g_norm_sq
111        };
112
113        // Update search direction
114        p = -&g_new + beta_fr * &p;
115
116        // Project the search direction to respect bounds
117        if let Some(bounds) = bounds {
118            project_search_direction(&mut p, &x_new, bounds);
119
120            // If the projected direction is zero or too small, use the projected gradient
121            let dir_norm = p.dot(&p).sqrt();
122            if dir_norm < 1e-10 {
123                // Try using the projected gradient instead
124                p = -g_new.clone();
125                project_search_direction(&mut p, &x_new, bounds);
126            }
127        }
128
129        // Update variables for next iteration
130        x = x_new;
131        f = f_new;
132        g = g_new;
133
134        iter += 1;
135
136        // Restart direction to steepest descent every n iterations
137        if iter % n == 0 {
138            p = -g.clone();
139
140            // Project the restarted direction to respect bounds
141            if let Some(bounds) = bounds {
142                project_search_direction(&mut p, &x, bounds);
143            }
144        }
145    }
146
147    // Final check for bounds
148    if let Some(bounds) = bounds {
149        bounds.project(x.as_slice_mut().unwrap());
150    }
151
152    // Use original function for final value
153    let final_fun = fun(&x.view());
154
155    // Create and return result
156    Ok(OptimizeResult {
157        x,
158        fun: final_fun,
159        nit: iter,
160        func_evals: nfev,
161        nfev,
162        success: iter < max_iter,
163        message: if iter < max_iter {
164            "Optimization terminated successfully.".to_string()
165        } else {
166            "Maximum iterations reached.".to_string()
167        },
168        jacobian: Some(g),
169        hessian: None,
170    })
171}
172
173/// Project search direction to respect bounds
174#[allow(dead_code)]
175fn project_search_direction(p: &mut Array1<f64>, x: &Array1<f64>, bounds: &Bounds) {
176    for i in 0..p.len() {
177        // For dimensions at the bound, zero out search direction if it would go outside bounds
178        if let Some(lb) = bounds.lower[i] {
179            if (x[i] - lb).abs() < 1e-10 && p[i] < 0.0 {
180                p[i] = 0.0;
181            }
182        }
183        if let Some(ub) = bounds.upper[i] {
184            if (x[i] - ub).abs() < 1e-10 && p[i] > 0.0 {
185                p[i] = 0.0;
186            }
187        }
188    }
189}
190
191/// Helper function for line search in Conjugate Gradient method with optional bounds support
192#[allow(dead_code)]
193fn line_search_cg<F, S>(
194    fun: &mut F,
195    x: &Array1<f64>,
196    direction: &Array1<f64>,
197    f_x: f64,
198    nfev: &mut usize,
199    bounds: Option<&Bounds>,
200) -> (f64, f64)
201where
202    F: FnMut(&ArrayView1<f64>) -> S,
203    S: Into<f64>,
204{
205    // Get bounds on the line search parameter
206    let (a_min, a_max) = if let Some(b) = bounds {
207        compute_line_bounds(x, direction, Some(b))
208    } else {
209        (f64::NEG_INFINITY, f64::INFINITY)
210    };
211
212    // Use a simple backtracking line search with bounds
213    let c1 = 1e-4; // Sufficient decrease parameter
214    let rho = 0.5; // Backtracking parameter
215
216    // Start with alpha with min(1.0, a_max) to ensure we're within bounds
217    let mut alpha = if a_max < 1.0 { a_max * 0.99 } else { 1.0 };
218
219    // If bounds fully constrain movement, return that constrained step
220    if a_max <= 0.0 || a_min >= a_max {
221        alpha = if a_max > 0.0 { a_max } else { 0.0 };
222        let x_new = x + alpha * direction;
223        *nfev += 1;
224        let f_new = fun(&x_new.view()).into();
225        return (alpha, f_new);
226    }
227
228    // Function to evaluate a point on the line
229    let mut f_line = |alpha: f64| {
230        let mut x_new = x + alpha * direction;
231
232        // Project onto bounds (if needed, should be a no-op if we calculated bounds correctly)
233        if let Some(bounds) = bounds {
234            bounds.project(x_new.as_slice_mut().unwrap());
235        }
236
237        *nfev += 1;
238        fun(&x_new.view()).into()
239    };
240
241    // Initial step
242    let mut f_new = f_line(alpha);
243
244    // Backtracking until Armijo condition is satisfied or we hit the lower bound
245    let slope = direction.mapv(|d| d.powi(2)).sum();
246    while f_new > f_x - c1 * alpha * slope.abs() && alpha > a_min {
247        alpha *= rho;
248
249        // Ensure alpha is at least a_min
250        if alpha < a_min {
251            alpha = a_min;
252        }
253
254        f_new = f_line(alpha);
255
256        // Prevent infinite loops for very small steps
257        if alpha < 1e-10 {
258            break;
259        }
260    }
261
262    (alpha, f_new)
263}
264
265/// Compute bounds for line search parameter
266#[allow(dead_code)]
267pub fn compute_line_bounds(
268    x: &Array1<f64>,
269    direction: &Array1<f64>,
270    bounds: Option<&Bounds>,
271) -> (f64, f64) {
272    if bounds.is_none() {
273        return (f64::NEG_INFINITY, f64::INFINITY);
274    }
275
276    let bounds = bounds.unwrap();
277    let mut a_min = f64::NEG_INFINITY;
278    let mut a_max = f64::INFINITY;
279
280    for i in 0..x.len() {
281        let xi = x[i];
282        let di = direction[i];
283
284        if di.abs() < 1e-16 {
285            continue;
286        }
287
288        // Lower bound constraint
289        if let Some(lb) = bounds.lower[i] {
290            let a_lb = (lb - xi) / di;
291            if di > 0.0 {
292                a_min = a_min.max(a_lb);
293            } else {
294                a_max = a_max.min(a_lb);
295            }
296        }
297
298        // Upper bound constraint
299        if let Some(ub) = bounds.upper[i] {
300            let a_ub = (ub - xi) / di;
301            if di > 0.0 {
302                a_max = a_max.min(a_ub);
303            } else {
304                a_min = a_min.max(a_ub);
305            }
306        }
307    }
308
309    if a_min > a_max {
310        (0.0, 0.0)
311    } else {
312        (a_min, a_max)
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use approx::assert_abs_diff_eq;
320
321    #[test]
322    fn test_cg_quadratic() {
323        let quadratic = |x: &ArrayView1<f64>| -> f64 { x[0] * x[0] + 4.0 * x[1] * x[1] };
324
325        let x0 = Array1::from_vec(vec![2.0, 1.0]);
326        let options = Options::default();
327
328        let result = minimize_conjugate_gradient(quadratic, x0, &options).unwrap();
329
330        assert!(result.success);
331        assert_abs_diff_eq!(result.x[0], 0.0, epsilon = 1e-4);
332        assert_abs_diff_eq!(result.x[1], 0.0, epsilon = 1e-4);
333    }
334
335    #[test]
336    fn test_cg_rosenbrock() {
337        let rosenbrock = |x: &ArrayView1<f64>| -> f64 {
338            let a = 1.0;
339            let b = 100.0;
340            (a - x[0]).powi(2) + b * (x[1] - x[0].powi(2)).powi(2)
341        };
342
343        let x0 = Array1::from_vec(vec![0.0, 0.0]);
344        let mut options = Options::default();
345        options.max_iter = 2000; // Increase iterations for difficult Rosenbrock function
346
347        let result = minimize_conjugate_gradient(rosenbrock, x0, &options).unwrap();
348
349        assert!(result.success);
350        // Rosenbrock is difficult for CG, accept if we get reasonably close
351        assert!(
352            result.x[0] > 0.2 && result.x[0] < 1.5,
353            "x[0] = {} should be near 1.0",
354            result.x[0]
355        );
356        assert!(
357            result.x[1] > 0.0 && result.x[1] < 1.5,
358            "x[1] = {} should be near 1.0",
359            result.x[1]
360        );
361    }
362
363    #[test]
364    fn test_cg_with_bounds() {
365        let quadratic =
366            |x: &ArrayView1<f64>| -> f64 { (x[0] - 2.0).powi(2) + (x[1] - 3.0).powi(2) };
367
368        let x0 = Array1::from_vec(vec![0.0, 0.0]);
369        let mut options = Options::default();
370        options.max_iter = 1000; // More iterations for bounded optimization
371
372        // Constrain solution to [0, 1] x [0, 1]
373        let bounds = Bounds::new(&[(Some(0.0), Some(1.0)), (Some(0.0), Some(1.0))]);
374        options.bounds = Some(bounds);
375
376        let result = minimize_conjugate_gradient(quadratic, x0, &options).unwrap();
377
378        assert!(result.success);
379        // The optimal point (2, 3) is outside the bounds, so we should get (1, 1)
380        // Allow more tolerance for this challenging bounded problem
381        assert_abs_diff_eq!(result.x[0], 1.0, epsilon = 0.4);
382        assert_abs_diff_eq!(result.x[1], 1.0, epsilon = 0.4);
383    }
384}