Skip to main content

scirs2_optimize/global/
simulated_annealing.rs

1//! Simulated Annealing algorithm for global optimization
2//!
3//! Simulated annealing is a probabilistic optimization algorithm inspired by
4//! the physical process of annealing in metallurgy. It accepts worse solutions
5//! with a probability that decreases over time as the "temperature" cools.
6
7use crate::error::OptimizeError;
8use crate::unconstrained::OptimizeResult;
9use scirs2_core::ndarray::{Array1, ArrayView1};
10use scirs2_core::random::prelude::SliceRandom;
11use scirs2_core::random::rngs::StdRng;
12use scirs2_core::random::{rng, Rng, RngExt, SeedableRng};
13
14/// Options for Simulated Annealing
15#[derive(Debug, Clone)]
16pub struct SimulatedAnnealingOptions {
17    /// Maximum number of iterations
18    pub maxiter: usize,
19    /// Initial temperature
20    pub initial_temp: f64,
21    /// Final temperature
22    pub final_temp: f64,
23    /// Temperature reduction rate (0 < alpha < 1)
24    pub alpha: f64,
25    /// Maximum steps at each temperature
26    pub max_steps_per_temp: usize,
27    /// Step size for neighbor generation
28    pub step_size: f64,
29    /// Random seed for reproducibility
30    pub seed: Option<u64>,
31    /// Cooling schedule: "exponential", "linear", or "adaptive"
32    pub schedule: String,
33    /// Whether to print progress
34    pub verbose: bool,
35}
36
37impl Default for SimulatedAnnealingOptions {
38    fn default() -> Self {
39        Self {
40            maxiter: 10000,
41            initial_temp: 100.0,
42            final_temp: 1e-8,
43            alpha: 0.95,
44            max_steps_per_temp: 100,
45            step_size: 0.5,
46            seed: None,
47            schedule: "exponential".to_string(),
48            verbose: false,
49        }
50    }
51}
52
53/// Bounds for variables
54pub type Bounds = Vec<(f64, f64)>;
55
56/// Simulated Annealing solver
57pub struct SimulatedAnnealing<F>
58where
59    F: Fn(&ArrayView1<f64>) -> f64 + Clone,
60{
61    func: F,
62    x0: Array1<f64>,
63    bounds: Option<Bounds>,
64    options: SimulatedAnnealingOptions,
65    ndim: usize,
66    current_x: Array1<f64>,
67    current_value: f64,
68    best_x: Array1<f64>,
69    best_value: f64,
70    temperature: f64,
71    rng: StdRng,
72    nfev: usize,
73    nit: usize,
74    /// Number of accepted moves during the most recent temperature level
75    recent_accepted: usize,
76    /// Number of attempted moves during the most recent temperature level
77    recent_total: usize,
78}
79
80impl<F> SimulatedAnnealing<F>
81where
82    F: Fn(&ArrayView1<f64>) -> f64 + Clone,
83{
84    /// Create new Simulated Annealing solver
85    pub fn new(
86        func: F,
87        x0: Array1<f64>,
88        bounds: Option<Bounds>,
89        options: SimulatedAnnealingOptions,
90    ) -> Self {
91        let ndim = x0.len();
92        let seed = options.seed.unwrap_or_else(|| rng().random());
93        let rng = StdRng::seed_from_u64(seed);
94
95        // Evaluate initial point
96        let initial_value = func(&x0.view());
97
98        Self {
99            func,
100            x0: x0.clone(),
101            bounds,
102            options: options.clone(),
103            ndim,
104            current_x: x0.clone(),
105            current_value: initial_value,
106            best_x: x0,
107            best_value: initial_value,
108            temperature: options.initial_temp,
109            rng,
110            nfev: 1,
111            nit: 0,
112            recent_accepted: 0,
113            recent_total: 0,
114        }
115    }
116
117    /// Generate a random neighbor
118    fn generate_neighbor(&mut self) -> Array1<f64> {
119        let mut neighbor = self.current_x.clone();
120
121        // Randomly perturb one or more dimensions
122        let num_dims_to_perturb = self.rng.random_range(1..=self.ndim);
123        let mut dims: Vec<usize> = (0..self.ndim).collect();
124        dims.shuffle(&mut self.rng);
125
126        for &i in dims.iter().take(num_dims_to_perturb) {
127            let perturbation = self
128                .rng
129                .random_range(-self.options.step_size..self.options.step_size);
130            neighbor[i] += perturbation;
131
132            // Apply bounds if specified
133            if let Some(ref bounds) = self.bounds {
134                let (lb, ub) = bounds[i];
135                neighbor[i] = neighbor[i].max(lb).min(ub);
136            }
137        }
138
139        neighbor
140    }
141
142    /// Calculate acceptance probability
143    fn acceptance_probability(&self, new_value: f64) -> f64 {
144        if new_value < self.current_value {
145            1.0
146        } else {
147            let delta = new_value - self.current_value;
148            (-delta / self.temperature).exp()
149        }
150    }
151
152    /// Update temperature according to cooling schedule
153    fn update_temperature(&mut self) {
154        match self.options.schedule.as_str() {
155            "exponential" => {
156                self.temperature *= self.options.alpha;
157            }
158            "linear" => {
159                let temp_range = self.options.initial_temp - self.options.final_temp;
160                let temp_decrement = temp_range / self.options.maxiter as f64;
161                self.temperature = (self.temperature - temp_decrement).max(self.options.final_temp);
162            }
163            "adaptive" => {
164                // Adaptive cooling based on acceptance rate
165                let acceptance_rate = self.calculate_acceptance_rate();
166                if acceptance_rate > 0.8 {
167                    self.temperature *= 0.9; // Cool faster if accepting too many
168                } else if acceptance_rate < 0.2 {
169                    self.temperature *= 0.99; // Cool slower if accepting too few
170                } else {
171                    self.temperature *= self.options.alpha;
172                }
173            }
174            _ => {
175                self.temperature *= self.options.alpha; // Default to exponential
176            }
177        }
178    }
179
180    /// Calculate the acceptance rate over the most recent temperature level
181    fn calculate_acceptance_rate(&self) -> f64 {
182        if self.recent_total == 0 {
183            // No moves have been attempted yet (e.g. before the first
184            // temperature level completes); report zero accepted rather than
185            // fabricating a value.
186            0.0
187        } else {
188            self.recent_accepted as f64 / self.recent_total as f64
189        }
190    }
191
192    /// Run one step of the algorithm
193    fn step(&mut self) -> bool {
194        self.nit += 1;
195
196        let mut accepted = 0usize;
197        for _ in 0..self.options.max_steps_per_temp {
198            // Generate neighbor
199            let neighbor = self.generate_neighbor();
200            let neighbor_value = (self.func)(&neighbor.view());
201            self.nfev += 1;
202
203            // Accept or reject
204            let acceptance_prob = self.acceptance_probability(neighbor_value);
205            if self.rng.random_range(0.0..1.0) < acceptance_prob {
206                accepted += 1;
207                self.current_x = neighbor;
208                self.current_value = neighbor_value;
209
210                // Update best if improved
211                if neighbor_value < self.best_value {
212                    self.best_x = self.current_x.clone();
213                    self.best_value = neighbor_value;
214                }
215            }
216        }
217
218        // Record acceptance statistics for this temperature level so the
219        // adaptive cooling schedule can react to the true acceptance rate.
220        self.recent_accepted = accepted;
221        self.recent_total = self.options.max_steps_per_temp;
222
223        // Update temperature
224        self.update_temperature();
225
226        // Check convergence
227        self.temperature < self.options.final_temp
228    }
229
230    /// Run the simulated annealing algorithm
231    pub fn run(&mut self) -> OptimizeResult<f64> {
232        let mut converged = false;
233
234        while self.nit < self.options.maxiter {
235            converged = self.step();
236
237            if converged {
238                break;
239            }
240
241            if self.options.verbose && self.nit.is_multiple_of(100) {
242                println!(
243                    "Iteration {}: T = {:.6}..best = {:.6}",
244                    self.nit, self.temperature, self.best_value
245                );
246            }
247        }
248
249        OptimizeResult {
250            x: self.best_x.clone(),
251            fun: self.best_value,
252            nfev: self.nfev,
253            func_evals: self.nfev,
254            nit: self.nit,
255            success: converged,
256            message: if converged {
257                "Temperature reached minimum"
258            } else {
259                "Maximum iterations reached"
260            }
261            .to_string(),
262            ..Default::default()
263        }
264    }
265}
266
267/// Perform global optimization using simulated annealing
268#[allow(dead_code)]
269pub fn simulated_annealing<F>(
270    func: F,
271    x0: Array1<f64>,
272    bounds: Option<Bounds>,
273    options: Option<SimulatedAnnealingOptions>,
274) -> Result<OptimizeResult<f64>, OptimizeError>
275where
276    F: Fn(&ArrayView1<f64>) -> f64 + Clone,
277{
278    let options = options.unwrap_or_default();
279    let mut solver = SimulatedAnnealing::new(func, x0, bounds, options);
280    Ok(solver.run())
281}