Skip to main content

scirs2_optimize/multi_fidelity/
types.rs

1//! Types for multi-fidelity optimization (Hyperband / Successive Halving).
2//!
3//! Provides configuration, sampling strategies, and result types used by both
4//! [`super::successive_halving::SuccessiveHalving`] and
5//! [`super::hyperband::Hyperband`].
6
7use crate::error::OptimizeResult;
8
9// ---------------------------------------------------------------------------
10// Configuration
11// ---------------------------------------------------------------------------
12
13/// Configuration for multi-fidelity optimization algorithms.
14///
15/// The budget parameters control how resources (e.g. training epochs) are
16/// allocated.  `eta` is the *reduction factor*: at each successive halving
17/// round the number of surviving configurations is divided by `eta` while
18/// the per-configuration budget is multiplied by `eta`.
19#[derive(Debug, Clone)]
20pub struct MultiFidelityConfig {
21    /// Maximum resource budget per configuration (e.g. epochs).
22    pub max_budget: f64,
23    /// Minimum resource budget per configuration.
24    pub min_budget: f64,
25    /// Reduction factor (default 3).
26    pub eta: usize,
27    /// Number of initial configurations.  When zero the algorithm computes a
28    /// sensible default from the budget ratio and `eta`.
29    pub n_initial: usize,
30}
31
32impl Default for MultiFidelityConfig {
33    fn default() -> Self {
34        Self {
35            max_budget: 81.0,
36            min_budget: 1.0,
37            eta: 3,
38            n_initial: 0, // auto-compute
39        }
40    }
41}
42
43impl MultiFidelityConfig {
44    /// Validate configuration parameters.
45    pub fn validate(&self) -> OptimizeResult<()> {
46        if self.max_budget <= 0.0 {
47            return Err(crate::error::OptimizeError::InvalidParameter(
48                "max_budget must be positive".into(),
49            ));
50        }
51        if self.min_budget <= 0.0 {
52            return Err(crate::error::OptimizeError::InvalidParameter(
53                "min_budget must be positive".into(),
54            ));
55        }
56        if self.min_budget > self.max_budget {
57            return Err(crate::error::OptimizeError::InvalidParameter(
58                "min_budget must not exceed max_budget".into(),
59            ));
60        }
61        if self.eta < 2 {
62            return Err(crate::error::OptimizeError::InvalidParameter(
63                "eta must be >= 2".into(),
64            ));
65        }
66        Ok(())
67    }
68
69    /// Compute `s_max = floor(log_eta(max_budget / min_budget))`.
70    pub(crate) fn s_max(&self) -> usize {
71        let ratio = self.max_budget / self.min_budget;
72        let eta_f = self.eta as f64;
73        ratio.ln().div_euclid(eta_f.ln()) as usize
74    }
75}
76
77// ---------------------------------------------------------------------------
78// Sampling
79// ---------------------------------------------------------------------------
80
81/// Strategy used to generate initial configurations.
82#[derive(Debug, Clone, Copy)]
83#[non_exhaustive]
84pub enum ConfigSampler {
85    /// Uniform random sampling inside bounds.
86    Random,
87    /// Latin Hypercube Sampling (space-filling).
88    LatinHypercube,
89}
90
91impl Default for ConfigSampler {
92    fn default() -> Self {
93        Self::Random
94    }
95}
96
97/// Simple xorshift64 PRNG step (pure, no external deps).
98pub(crate) fn xorshift64(state: &mut u64) -> u64 {
99    let mut s = *state;
100    s ^= s << 13;
101    s ^= s >> 7;
102    s ^= s << 17;
103    *state = s;
104    s
105}
106
107/// Return a `f64` in `[0, 1)` from the PRNG.
108pub(crate) fn rand_f64(state: &mut u64) -> f64 {
109    let bits = xorshift64(state);
110    (bits >> 11) as f64 / ((1u64 << 53) as f64)
111}
112
113/// Generate `n` configurations inside `bounds` using the chosen sampler.
114pub(crate) fn sample_configs(
115    n: usize,
116    bounds: &[(f64, f64)],
117    sampler: &ConfigSampler,
118    rng: &mut u64,
119) -> Vec<Vec<f64>> {
120    match sampler {
121        ConfigSampler::Random => sample_random(n, bounds, rng),
122        ConfigSampler::LatinHypercube => sample_lhs(n, bounds, rng),
123    }
124}
125
126fn sample_random(n: usize, bounds: &[(f64, f64)], rng: &mut u64) -> Vec<Vec<f64>> {
127    (0..n)
128        .map(|_| {
129            bounds
130                .iter()
131                .map(|(lo, hi)| lo + rand_f64(rng) * (hi - lo))
132                .collect()
133        })
134        .collect()
135}
136
137fn sample_lhs(n: usize, bounds: &[(f64, f64)], rng: &mut u64) -> Vec<Vec<f64>> {
138    if n == 0 {
139        return Vec::new();
140    }
141    let d = bounds.len();
142    // For each dimension build a permutation of strata indices.
143    let mut configs: Vec<Vec<f64>> = vec![vec![0.0; d]; n];
144    for dim in 0..d {
145        let (lo, hi) = bounds[dim];
146        let mut indices: Vec<usize> = (0..n).collect();
147        // Fisher-Yates shuffle
148        for i in (1..n).rev() {
149            let j = (xorshift64(rng) as usize) % (i + 1);
150            indices.swap(i, j);
151        }
152        for (i, &idx) in indices.iter().enumerate() {
153            let low = lo + (hi - lo) * (idx as f64) / (n as f64);
154            let high = lo + (hi - lo) * ((idx + 1) as f64) / (n as f64);
155            configs[i][dim] = low + rand_f64(rng) * (high - low);
156        }
157    }
158    configs
159}
160
161// ---------------------------------------------------------------------------
162// Results
163// ---------------------------------------------------------------------------
164
165/// A single evaluation record.
166#[derive(Debug, Clone)]
167pub struct EvaluationResult {
168    /// Unique identifier for this configuration.
169    pub config_id: usize,
170    /// Hyperparameter values.
171    pub config: Vec<f64>,
172    /// Resource level used for this evaluation.
173    pub budget: f64,
174    /// Objective value (lower is better).
175    pub objective: f64,
176}
177
178/// Aggregated result of a multi-fidelity optimization run.
179#[derive(Debug, Clone)]
180pub struct MultiFidelityResult {
181    /// Best configuration found.
182    pub best_config: Vec<f64>,
183    /// Objective value of the best configuration.
184    pub best_objective: f64,
185    /// Total resource budget consumed across all evaluations.
186    pub total_budget_used: f64,
187    /// Full evaluation log.
188    pub evaluations: Vec<EvaluationResult>,
189    /// Number of Hyperband brackets (1 for plain Successive Halving).
190    pub n_brackets: usize,
191}
192
193// ---------------------------------------------------------------------------
194// Tests
195// ---------------------------------------------------------------------------
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn test_default_config_valid() {
203        let cfg = MultiFidelityConfig::default();
204        assert!(cfg.validate().is_ok());
205        assert_eq!(cfg.eta, 3);
206        assert!(cfg.max_budget > cfg.min_budget);
207    }
208
209    #[test]
210    fn test_invalid_config_eta() {
211        let cfg = MultiFidelityConfig {
212            eta: 1,
213            ..Default::default()
214        };
215        assert!(cfg.validate().is_err());
216    }
217
218    #[test]
219    fn test_invalid_config_budget() {
220        let cfg = MultiFidelityConfig {
221            min_budget: 100.0,
222            max_budget: 10.0,
223            ..Default::default()
224        };
225        assert!(cfg.validate().is_err());
226    }
227
228    #[test]
229    fn test_s_max_computation() {
230        // 81 / 1 = 81, log_3(81) = 4
231        let cfg = MultiFidelityConfig::default();
232        assert_eq!(cfg.s_max(), 4);
233    }
234
235    #[test]
236    fn test_random_sampling_bounds() {
237        let bounds = vec![(0.0, 1.0), (-5.0, 5.0)];
238        let mut rng = 42u64;
239        let configs = sample_configs(20, &bounds, &ConfigSampler::Random, &mut rng);
240        assert_eq!(configs.len(), 20);
241        for c in &configs {
242            assert_eq!(c.len(), 2);
243            assert!(c[0] >= 0.0 && c[0] <= 1.0);
244            assert!(c[1] >= -5.0 && c[1] <= 5.0);
245        }
246    }
247
248    #[test]
249    fn test_lhs_fills_space() {
250        let bounds = vec![(0.0, 1.0)];
251        let n = 10;
252        let mut rng = 123u64;
253        let configs = sample_configs(n, &bounds, &ConfigSampler::LatinHypercube, &mut rng);
254        assert_eq!(configs.len(), n);
255        // Each sample should be in a different stratum [i/n, (i+1)/n).
256        // Collect strata and verify all n strata are covered.
257        let mut strata: Vec<usize> = configs
258            .iter()
259            .map(|c| (c[0] * n as f64).floor() as usize)
260            .collect();
261        strata.sort();
262        strata.dedup();
263        assert_eq!(strata.len(), n, "LHS must cover all {n} strata");
264    }
265
266    #[test]
267    fn test_lhs_empty() {
268        let bounds = vec![(0.0, 1.0)];
269        let mut rng = 1u64;
270        let configs = sample_configs(0, &bounds, &ConfigSampler::LatinHypercube, &mut rng);
271        assert!(configs.is_empty());
272    }
273
274    #[test]
275    fn test_evaluation_result_tracks_fields() {
276        let er = EvaluationResult {
277            config_id: 7,
278            config: vec![1.0, 2.0],
279            budget: 27.0,
280            objective: 0.5,
281        };
282        assert_eq!(er.config_id, 7);
283        assert!((er.budget - 27.0).abs() < f64::EPSILON);
284    }
285}