scirs2_optimize/least_squares/
bounded.rs

1//! Bounded-variable least squares optimization
2//!
3//! This module provides least squares methods with box constraints on variables.
4//! It implements trust-region reflective algorithm adapted for bounds.
5//!
6//! # Example
7//!
8//! ```
9//! use scirs2_core::ndarray::{array, Array1, Array2};
10//! use scirs2_optimize::{Bounds, least_squares::bounded::{bounded_least_squares, BoundedOptions}};
11//!
12//! // Define a function that returns the residuals
13//! fn residual(x: &[f64], data: &[f64]) -> Array1<f64> {
14//!     array![
15//!         x[0] + 2.0 * x[1] - 2.0,
16//!         x[0] - x[1] - 1.0,
17//!         x[0] + x[1] - 1.5
18//!     ]
19//! }
20//!
21//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
22//! // Initial guess
23//! let x0 = array![0.0, 0.0];
24//!
25//! // Define bounds: 0 <= x[0] <= 2, -1 <= x[1] <= 1
26//! let bounds = Bounds::new(&[(Some(0.0), Some(2.0)), (Some(-1.0), Some(1.0))]);
27//!
28//! // Solve using bounded least squares
29//! let result = bounded_least_squares(
30//!     residual,
31//!     &x0,
32//!     Some(bounds),
33//!     None::<fn(&[f64], &[f64]) -> Array2<f64>>,
34//!     &array![],
35//!     None
36//! )?;
37//!
38//! assert!(result.success);
39//! # Ok(())
40//! # }
41//! ```
42
43use crate::error::OptimizeResult;
44use crate::result::OptimizeResults;
45use crate::unconstrained::Bounds;
46use scirs2_core::ndarray::{Array1, Array2, ArrayBase, Data, Ix1};
47
48/// Options for bounded least squares optimization
49#[derive(Debug, Clone)]
50pub struct BoundedOptions {
51    /// Maximum number of iterations
52    pub max_iter: usize,
53
54    /// Maximum number of function evaluations
55    pub max_nfev: Option<usize>,
56
57    /// Tolerance for termination by the change of parameters
58    pub xtol: f64,
59
60    /// Tolerance for termination by the change of cost function
61    pub ftol: f64,
62
63    /// Tolerance for termination by the norm of gradient
64    pub gtol: f64,
65
66    /// Initial trust region radius
67    pub initial_trust_radius: f64,
68
69    /// Maximum trust region radius
70    pub max_trust_radius: f64,
71
72    /// Step size for finite difference approximation
73    pub diff_step: f64,
74}
75
76impl Default for BoundedOptions {
77    fn default() -> Self {
78        BoundedOptions {
79            max_iter: 100,
80            max_nfev: None,
81            xtol: 1e-8,
82            ftol: 1e-8,
83            gtol: 1e-8,
84            initial_trust_radius: 1.0,
85            max_trust_radius: 1000.0,
86            diff_step: 1e-8,
87        }
88    }
89}
90
91/// Solve a bounded least squares problem
92///
93/// This function minimizes the sum of squares of residuals subject to
94/// box constraints on the variables.
95///
96/// # Arguments
97///
98/// * `residuals` - Function that returns the residuals
99/// * `x0` - Initial guess for the parameters
100/// * `bounds` - Optional bounds on variables
101/// * `jacobian` - Optional Jacobian function
102/// * `data` - Additional data to pass to residuals and jacobian
103/// * `options` - Options for the optimization
104#[allow(dead_code)]
105pub fn bounded_least_squares<F, J, D, S1, S2>(
106    residuals: F,
107    x0: &ArrayBase<S1, Ix1>,
108    bounds: Option<Bounds>,
109    jacobian: Option<J>,
110    data: &ArrayBase<S2, Ix1>,
111    options: Option<BoundedOptions>,
112) -> OptimizeResult<OptimizeResults<f64>>
113where
114    F: Fn(&[f64], &[D]) -> Array1<f64>,
115    J: Fn(&[f64], &[D]) -> Array2<f64>,
116    D: Clone,
117    S1: Data<Elem = f64>,
118    S2: Data<Elem = D>,
119{
120    let options = options.unwrap_or_default();
121
122    // If no bounds provided, use regular least squares
123    if bounds.is_none() {
124        // Call regular least squares (would need to import and use it)
125        // For now, proceed with unbounded algorithm
126    }
127
128    // Use trust region reflective algorithm adapted for least squares
129    trust_region_reflective(residuals, x0, bounds, jacobian, data, &options)
130}
131
132/// Trust region reflective algorithm for bounded least squares
133#[allow(dead_code)]
134fn trust_region_reflective<F, J, D, S1, S2>(
135    residuals: F,
136    x0: &ArrayBase<S1, Ix1>,
137    bounds: Option<Bounds>,
138    jacobian: Option<J>,
139    data: &ArrayBase<S2, Ix1>,
140    options: &BoundedOptions,
141) -> OptimizeResult<OptimizeResults<f64>>
142where
143    F: Fn(&[f64], &[D]) -> Array1<f64>,
144    J: Fn(&[f64], &[D]) -> Array2<f64>,
145    D: Clone,
146    S1: Data<Elem = f64>,
147    S2: Data<Elem = D>,
148{
149    let mut x = x0.to_owned();
150    let m = x.len();
151
152    // Project initial point to bounds
153    if let Some(ref b) = bounds {
154        x = project_to_bounds(&x, b);
155    }
156
157    let max_nfev = options.max_nfev.unwrap_or(options.max_iter * m * 10);
158    let mut nfev = 0;
159    let mut njev = 0;
160    let mut iter = 0;
161
162    let mut trust_radius = options.initial_trust_radius;
163    let max_trust_radius = options.max_trust_radius;
164
165    // Numerical gradient helper
166    let compute_numerical_jacobian =
167        |x_val: &Array1<f64>, res_val: &Array1<f64>| -> (Array2<f64>, usize) {
168            let eps = options.diff_step;
169            let n = res_val.len();
170            let mut jac = Array2::zeros((n, m));
171            let mut count = 0;
172
173            for j in 0..m {
174                let mut x_h = x_val.clone();
175                x_h[j] += eps;
176
177                // Project perturbed point to bounds
178                if let Some(ref b) = bounds {
179                    x_h = project_to_bounds(&x_h, b);
180                }
181
182                let res_h = residuals(x_h.as_slice().unwrap(), data.as_slice().unwrap());
183                count += 1;
184
185                for i in 0..n {
186                    jac[[i, j]] = (res_h[i] - res_val[i]) / eps;
187                }
188            }
189
190            (jac, count)
191        };
192
193    // Main optimization loop
194    while iter < options.max_iter && nfev < max_nfev {
195        // Compute residuals
196        let res = residuals(x.as_slice().unwrap(), data.as_slice().unwrap());
197        nfev += 1;
198        let _n = res.len();
199
200        // Compute cost function
201        let cost = 0.5 * res.iter().map(|&r| r * r).sum::<f64>();
202
203        // Compute Jacobian
204        let (jac, jac_evals) = match &jacobian {
205            Some(jac_fn) => {
206                let j = jac_fn(x.as_slice().unwrap(), data.as_slice().unwrap());
207                njev += 1;
208                (j, 0)
209            }
210            None => {
211                let (j, count) = compute_numerical_jacobian(&x, &res);
212                nfev += count;
213                (j, count)
214            }
215        };
216
217        // Compute gradient: g = J^T * r
218        let gradient = jac.t().dot(&res);
219
220        // Compute projected gradient for convergence check
221        let proj_grad = compute_projected_gradient(&x, &gradient, &bounds);
222
223        // Check convergence on projected gradient
224        if proj_grad.iter().all(|&g| g.abs() < options.gtol) {
225            let mut result = OptimizeResults::<f64>::default();
226            result.x = x;
227            result.fun = cost;
228            result.nfev = nfev;
229            result.njev = njev;
230            result.nit = iter;
231            result.success = true;
232            result.message = "Optimization terminated successfully.".to_string();
233            return Ok(result);
234        }
235
236        // Solve trust region subproblem with bounds
237        let step = solve_trust_region_bounds(&jac, &res, &gradient, trust_radius, &x, &bounds);
238
239        // Check step size for convergence
240        let step_norm = step.iter().map(|&s| s * s).sum::<f64>().sqrt();
241        if step_norm < options.xtol {
242            let mut result = OptimizeResults::<f64>::default();
243            result.x = x;
244            result.fun = cost;
245            result.nfev = nfev;
246            result.njev = njev;
247            result.nit = iter;
248            result.success = true;
249            result.message = "Converged (step size tolerance)".to_string();
250            return Ok(result);
251        }
252
253        // Try the step
254        let mut x_new = &x + &step;
255
256        // Project to bounds
257        if let Some(ref b) = bounds {
258            x_new = project_to_bounds(&x_new, b);
259        }
260
261        // Evaluate at new point
262        let res_new = residuals(x_new.as_slice().unwrap(), data.as_slice().unwrap());
263        nfev += 1;
264        let cost_new = 0.5 * res_new.iter().map(|&r| r * r).sum::<f64>();
265
266        // Compute actual vs predicted reduction
267        let actual_reduction = cost - cost_new;
268        let predicted_reduction = compute_predicted_reduction(&jac, &res, &step);
269
270        // Compute ratio
271        let rho = if predicted_reduction.abs() > 1e-10 {
272            actual_reduction / predicted_reduction
273        } else {
274            0.0
275        };
276
277        // Update trust region based on performance
278        if rho < 0.25 {
279            trust_radius *= 0.25;
280        } else if rho > 0.75 && step_norm >= 0.9 * trust_radius {
281            trust_radius = (2.0 * trust_radius).min(max_trust_radius);
282        }
283
284        // Accept or reject step
285        if rho > 0.01 {
286            // Check convergence on cost function
287            if actual_reduction.abs() < options.ftol * cost {
288                let mut result = OptimizeResults::<f64>::default();
289                result.x = x_new;
290                result.fun = cost_new;
291                result.nfev = nfev;
292                result.njev = njev;
293                result.nit = iter;
294                result.success = true;
295                result.message = "Converged (function tolerance)".to_string();
296                return Ok(result);
297            }
298
299            x = x_new;
300        }
301
302        iter += 1;
303    }
304
305    // Max iterations reached
306    let res_final = residuals(x.as_slice().unwrap(), data.as_slice().unwrap());
307    let final_cost = 0.5 * res_final.iter().map(|&r| r * r).sum::<f64>();
308
309    let mut result = OptimizeResults::<f64>::default();
310    result.x = x;
311    result.fun = final_cost;
312    result.nfev = nfev;
313    result.njev = njev;
314    result.nit = iter;
315    result.success = false;
316    result.message = "Maximum iterations reached".to_string();
317
318    Ok(result)
319}
320
321/// Project point to bounds
322#[allow(dead_code)]
323fn project_to_bounds(x: &Array1<f64>, bounds: &Bounds) -> Array1<f64> {
324    let mut x_proj = x.clone();
325
326    for i in 0..x.len() {
327        if let Some(lb) = bounds.lower[i] {
328            x_proj[i] = x_proj[i].max(lb);
329        }
330        if let Some(ub) = bounds.upper[i] {
331            x_proj[i] = x_proj[i].min(ub);
332        }
333    }
334
335    x_proj
336}
337
338/// Compute projected gradient for bounded problems
339#[allow(dead_code)]
340fn compute_projected_gradient(
341    x: &Array1<f64>,
342    gradient: &Array1<f64>,
343    bounds: &Option<Bounds>,
344) -> Array1<f64> {
345    let mut proj_grad = gradient.clone();
346
347    if let Some(b) = bounds {
348        for i in 0..x.len() {
349            // At lower bound with positive gradient
350            if let Some(lb) = b.lower[i] {
351                if (x[i] - lb).abs() < 1e-10 && gradient[i] > 0.0 {
352                    proj_grad[i] = 0.0;
353                }
354            }
355
356            // At upper bound with negative gradient
357            if let Some(ub) = b.upper[i] {
358                if (x[i] - ub).abs() < 1e-10 && gradient[i] < 0.0 {
359                    proj_grad[i] = 0.0;
360                }
361            }
362        }
363    }
364
365    proj_grad
366}
367
368/// Solve trust region subproblem with bounds
369#[allow(dead_code)]
370fn solve_trust_region_bounds(
371    jac: &Array2<f64>,
372    _res: &Array1<f64>,
373    gradient: &Array1<f64>,
374    trust_radius: f64,
375    x: &Array1<f64>,
376    bounds: &Option<Bounds>,
377) -> Array1<f64> {
378    let m = x.len();
379
380    // Compute Gauss-Newton step
381    let jt_j = jac.t().dot(jac);
382    let neg_gradient = -gradient;
383
384    // Try to solve normal equations
385    let gn_step = if let Some(step) = solve(&jt_j, &neg_gradient) {
386        step
387    } else {
388        // Use gradient descent as fallback
389        -gradient / gradient.iter().map(|&g| g * g).sum::<f64>().sqrt()
390    };
391
392    // Apply bounds constraints
393    let mut step = gn_step;
394
395    if let Some(b) = bounds {
396        for i in 0..m {
397            // Clip step to respect bounds
398            if let Some(lb) = b.lower[i] {
399                let max_step_down = x[i] - lb;
400                if step[i] < -max_step_down {
401                    step[i] = -max_step_down;
402                }
403            }
404
405            if let Some(ub) = b.upper[i] {
406                let max_step_up = ub - x[i];
407                if step[i] > max_step_up {
408                    step[i] = max_step_up;
409                }
410            }
411        }
412    }
413
414    // Apply trust region constraint
415    let step_norm = step.iter().map(|&s| s * s).sum::<f64>().sqrt();
416    if step_norm > trust_radius {
417        step *= trust_radius / step_norm;
418    }
419
420    step
421}
422
423/// Compute predicted reduction in cost
424#[allow(dead_code)]
425fn compute_predicted_reduction(jac: &Array2<f64>, res: &Array1<f64>, step: &Array1<f64>) -> f64 {
426    let jac_step = jac.dot(step);
427    let linear_term = res.dot(&jac_step);
428    let quadratic_term = 0.5 * jac_step.dot(&jac_step);
429
430    -(linear_term + quadratic_term)
431}
432
433/// Simple linear system solver (same as in other modules)
434#[allow(dead_code)]
435fn solve(a: &Array2<f64>, b: &Array1<f64>) -> Option<Array1<f64>> {
436    use scirs2_linalg::solve;
437
438    solve(&a.view(), &b.view(), None).ok()
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444    use scirs2_core::ndarray::array;
445
446    #[test]
447    fn test_bounded_least_squares_simple() {
448        // Overdetermined system with bounds
449        fn residual(x: &[f64], data: &[f64]) -> Array1<f64> {
450            array![
451                x[0] + 2.0 * x[1] - 2.0,
452                x[0] - x[1] - 1.0,
453                x[0] + x[1] - 1.5
454            ]
455        }
456
457        let x0 = array![0.0, 0.0];
458
459        // Define bounds: 0 <= x[0] <= 2, -1 <= x[1] <= 1
460        let bounds = Bounds::new(&[(Some(0.0), Some(2.0)), (Some(-1.0), Some(1.0))]);
461
462        let result = bounded_least_squares(
463            residual,
464            &x0,
465            Some(bounds),
466            None::<fn(&[f64], &[f64]) -> Array2<f64>>,
467            &array![],
468            None,
469        )
470        .unwrap();
471
472        assert!(result.success);
473        // Check that solution respects bounds
474        assert!(result.x[0] >= 0.0 && result.x[0] <= 2.0);
475        assert!(result.x[1] >= -1.0 && result.x[1] <= 1.0);
476    }
477
478    #[test]
479    fn test_bounded_vs_unbounded() {
480        // Problem where bounds affect the solution
481        fn residual(x: &[f64], data: &[f64]) -> Array1<f64> {
482            array![
483                x[0] - 5.0, // Wants x[0] = 5.0
484                x[1] - 3.0  // Wants x[1] = 3.0
485            ]
486        }
487
488        let x0 = array![0.0, 0.0];
489
490        // Bounds that constrain the solution
491        let bounds = Bounds::new(&[(Some(0.0), Some(1.0)), (Some(0.0), Some(1.0))]);
492
493        let result = bounded_least_squares(
494            residual,
495            &x0,
496            Some(bounds),
497            None::<fn(&[f64], &[f64]) -> Array2<f64>>,
498            &array![],
499            None,
500        )
501        .unwrap();
502
503        assert!(result.success);
504        // Solution should be at the boundary
505        assert!((result.x[0] - 1.0).abs() < 1e-6);
506        assert!((result.x[1] - 1.0).abs() < 1e-6);
507    }
508
509    #[test]
510    fn test_project_to_bounds() {
511        let x = array![2.5, -0.5, 0.5];
512        let bounds = Bounds::new(&[
513            (Some(0.0), Some(2.0)),
514            (Some(-1.0), Some(1.0)),
515            (None, None),
516        ]);
517
518        let x_proj = project_to_bounds(&x, &bounds);
519
520        assert_eq!(x_proj[0], 2.0); // Clipped to upper bound
521        assert_eq!(x_proj[1], -0.5); // Within bounds
522        assert_eq!(x_proj[2], 0.5); // No bounds
523    }
524}