Skip to main content

optirs_nas/hyperparameter/
support.rs

1//! Shared helpers used by every hyperparameter search strategy: deterministic
2//! parameter ordering, log-aware normalization, and single-parameter sampling.
3//!
4//! Deterministic ordering matters more than it looks: the search space stores its
5//! parameters in a `HashMap`, so anything that walks them (grid enumeration, the
6//! normalized vector a surrogate model consumes) must impose an explicit order or
7//! it produces a different answer on every run.
8
9use super::{DistributionType, HyperparameterSpace, ParameterRange};
10use scirs2_core::numeric::Float;
11use scirs2_core::random::Random;
12
13/// A seeded RNG of the concrete type every strategy in this module uses.
14pub type StrategyRng = Random<scirs2_core::random::rngs::StdRng>;
15
16/// What a strategy returns: the continuous parameter assignment plus the
17/// categorical one.
18pub type SuggestedParameters<T> = (
19    std::collections::HashMap<String, T>,
20    std::collections::HashMap<String, String>,
21);
22
23/// Continuous parameter names in a fixed (lexicographic) order.
24pub fn sorted_parameter_names<T: Float>(space: &HyperparameterSpace<T>) -> Vec<String> {
25    let mut names: Vec<String> = space.parameter_ranges().keys().cloned().collect();
26    names.sort();
27    names
28}
29
30/// Categorical parameter names in a fixed (lexicographic) order.
31pub fn sorted_categorical_names<T: Float>(space: &HyperparameterSpace<T>) -> Vec<String> {
32    let mut names: Vec<String> = space.categorical_options().keys().cloned().collect();
33    names.sort();
34    names
35}
36
37/// Inclusive `[min, max]` bounds of `range` as `f64`, ordered so `lower <= upper`
38/// even if the range was declared backwards.
39pub fn bounds<T: Float>(range: &ParameterRange<T>) -> (f64, f64) {
40    let a = range.min_value.to_f64().unwrap_or(0.0);
41    let b = range.max_value.to_f64().unwrap_or(0.0);
42    if a <= b {
43        (a, b)
44    } else {
45        (b, a)
46    }
47}
48
49/// Whether `range` should be treated on a logarithmic axis. A log axis requires a
50/// strictly positive lower bound; a declared `log_scale` with a non-positive bound
51/// is silently unusable, so it is reported as linear instead of producing `NaN`.
52pub fn is_log_axis<T: Float>(range: &ParameterRange<T>) -> bool {
53    let (lower, _) = bounds(range);
54    range.log_scale && lower > 0.0
55}
56
57/// Map a raw value onto `[0, 1]` along the parameter's own axis (log-aware).
58/// Degenerate ranges collapse to `0.0`.
59pub fn normalize<T: Float>(range: &ParameterRange<T>, value: f64) -> f64 {
60    let (lower, upper) = bounds(range);
61    if is_log_axis(range) {
62        let log_lower = lower.ln();
63        let log_upper = upper.ln();
64        if log_upper <= log_lower {
65            return 0.0;
66        }
67        ((value.max(f64::MIN_POSITIVE).ln() - log_lower) / (log_upper - log_lower)).clamp(0.0, 1.0)
68    } else {
69        if upper <= lower {
70            return 0.0;
71        }
72        ((value - lower) / (upper - lower)).clamp(0.0, 1.0)
73    }
74}
75
76/// Inverse of [`normalize`]: map `unit` in `[0, 1]` back to a raw value.
77pub fn denormalize<T: Float>(range: &ParameterRange<T>, unit: f64) -> f64 {
78    let (lower, upper) = bounds(range);
79    let unit = unit.clamp(0.0, 1.0);
80    // Endpoints are returned verbatim: `exp(ln(lower))` drifts below `lower` for
81    // typical learning-rate ranges, which would fail range validation.
82    if unit <= 0.0 {
83        return lower;
84    }
85    if unit >= 1.0 {
86        return upper;
87    }
88    if is_log_axis(range) {
89        let log_lower = lower.ln();
90        let log_upper = upper.ln();
91        (log_lower + unit * (log_upper - log_lower)).exp()
92    } else {
93        lower + unit * (upper - lower)
94    }
95}
96
97/// Snap a raw value onto the nearest entry of `discrete_values` when the range
98/// declares one, so a strategy that reasons in continuous space still emits a
99/// legal value.
100pub fn snap_to_discrete<T: Float>(range: &ParameterRange<T>, value: f64) -> f64 {
101    let Some(values) = range.discrete_values.as_ref() else {
102        return value;
103    };
104    let mut best = value;
105    let mut best_distance = f64::INFINITY;
106    for candidate in values {
107        let candidate = candidate.to_f64().unwrap_or(0.0);
108        let distance = (candidate - value).abs();
109        if distance < best_distance {
110            best_distance = distance;
111            best = candidate;
112        }
113    }
114    best
115}
116
117/// Draw one value for `range` from its declared distribution.
118///
119/// Every [`DistributionType`] is sampled for real; none of them silently
120/// degrades to uniform, which is what the previous `_ => uniform` arm did for
121/// four of the five variants. All draws are transformed back into
122/// `[min_value, max_value]`, so a configuration is always in-range.
123pub fn sample_parameter<T: Float>(range: &ParameterRange<T>, rng: &mut StrategyRng) -> f64 {
124    if let Some(values) = range.discrete_values.as_ref() {
125        if !values.is_empty() {
126            let idx = rng.gen_range(0..values.len());
127            return values[idx].to_f64().unwrap_or(0.0);
128        }
129    }
130
131    let unit = match range.distribution {
132        DistributionType::Uniform => rng.gen_range(0.0..1.0),
133        DistributionType::Normal => {
134            // Truncated standard normal mapped onto the unit interval: mean at the
135            // range midpoint, one standard deviation covering a quarter of the
136            // range, resampled (not clamped) so the shape is not distorted by a
137            // spike at the bounds.
138            let mut unit = 0.5 + standard_normal(rng) * 0.25;
139            let mut attempts = 0;
140            while !(0.0..=1.0).contains(&unit) && attempts < 16 {
141                unit = 0.5 + standard_normal(rng) * 0.25;
142                attempts += 1;
143            }
144            unit.clamp(0.0, 1.0)
145        }
146        DistributionType::LogNormal => {
147            // exp(N(0,1)) squashed into the unit interval by its own CDF-like
148            // normalization: values concentrate near the lower bound with a long
149            // upper tail, which is the point of a log-normal prior.
150            let raw = standard_normal(rng).exp();
151            (raw / (raw + 1.0)).clamp(0.0, 1.0)
152        }
153        DistributionType::Beta => {
154            // Beta(2,2) via the order statistic of three uniforms: a symmetric,
155            // centre-weighted draw on [0,1].
156            let mut draws = [
157                rng.gen_range(0.0..1.0),
158                rng.gen_range(0.0..1.0),
159                rng.gen_range(0.0..1.0),
160            ];
161            draws.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
162            draws[1]
163        }
164        DistributionType::Exponential => {
165            // Exponential(1) truncated to [0,1) by inverse transform.
166            let u = rng.gen_range(0.0..1.0_f64).max(f64::MIN_POSITIVE);
167            let raw = -u.ln();
168            (raw / (raw + 1.0)).clamp(0.0, 1.0)
169        }
170    };
171    denormalize(range, unit)
172}
173
174/// Box-Muller standard normal draw.
175pub fn standard_normal(rng: &mut StrategyRng) -> f64 {
176    let u1 = rng.gen_range(0.0..1.0_f64).max(f64::MIN_POSITIVE);
177    let u2 = rng.gen_range(0.0..1.0_f64);
178    (-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos()
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    fn linear_range() -> ParameterRange<f64> {
186        ParameterRange {
187            name: "x".to_string(),
188            min_value: 2.0,
189            max_value: 6.0,
190            distribution: DistributionType::Uniform,
191            log_scale: false,
192            discrete_values: None,
193        }
194    }
195
196    fn log_range() -> ParameterRange<f64> {
197        ParameterRange {
198            name: "lr".to_string(),
199            min_value: 1e-4,
200            max_value: 1e-1,
201            distribution: DistributionType::Uniform,
202            log_scale: true,
203            discrete_values: None,
204        }
205    }
206
207    #[test]
208    fn normalization_round_trips_on_both_axes() {
209        for range in [linear_range(), log_range()] {
210            for unit in [0.0, 0.1, 0.5, 0.9, 1.0] {
211                let raw = denormalize(&range, unit);
212                let back = normalize(&range, raw);
213                assert!(
214                    (back - unit).abs() < 1e-9,
215                    "unit {unit} round-tripped to {back}"
216                );
217            }
218        }
219    }
220
221    #[test]
222    fn log_axis_is_ignored_for_non_positive_bounds() {
223        let range = ParameterRange {
224            name: "x".to_string(),
225            min_value: -1.0,
226            max_value: 1.0,
227            distribution: DistributionType::Uniform,
228            log_scale: true,
229            discrete_values: None,
230        };
231        assert!(!is_log_axis(&range));
232        let value = denormalize(&range, 0.5);
233        assert!(value.is_finite(), "got {value}");
234        assert!((value - 0.0).abs() < 1e-12);
235    }
236
237    #[test]
238    fn every_distribution_stays_in_range_and_is_not_uniform_by_default() {
239        let mut rng = Random::seed(1234);
240        for distribution in [
241            DistributionType::Uniform,
242            DistributionType::Normal,
243            DistributionType::LogNormal,
244            DistributionType::Beta,
245            DistributionType::Exponential,
246        ] {
247            let range = ParameterRange {
248                name: "x".to_string(),
249                min_value: 0.0,
250                max_value: 1.0,
251                distribution,
252                log_scale: false,
253                discrete_values: None,
254            };
255            let samples: Vec<f64> = (0..2000)
256                .map(|_| sample_parameter(&range, &mut rng))
257                .collect();
258            for value in &samples {
259                assert!(
260                    (0.0..=1.0).contains(value),
261                    "{distribution:?} produced {value}"
262                );
263            }
264            let mean = samples.iter().sum::<f64>() / samples.len() as f64;
265            match distribution {
266                // The non-uniform shapes must genuinely differ from uniform.
267                DistributionType::Exponential => {
268                    assert!(mean < 0.45, "Exponential mean {mean} looks uniform")
269                }
270                DistributionType::Uniform => {
271                    assert!((mean - 0.5).abs() < 0.05, "uniform mean {mean}")
272                }
273                _ => {}
274            }
275        }
276    }
277
278    #[test]
279    fn discrete_ranges_only_ever_emit_declared_values() {
280        let mut rng = Random::seed(9);
281        let range = ParameterRange {
282            name: "batch".to_string(),
283            min_value: 8.0,
284            max_value: 128.0,
285            distribution: DistributionType::Uniform,
286            log_scale: false,
287            discrete_values: Some(vec![8.0, 16.0, 32.0, 64.0, 128.0]),
288        };
289        for _ in 0..200 {
290            let value = sample_parameter(&range, &mut rng);
291            assert!(
292                [8.0, 16.0, 32.0, 64.0, 128.0].contains(&value),
293                "got {value}"
294            );
295        }
296        assert_eq!(snap_to_discrete(&range, 30.0), 32.0);
297        assert_eq!(snap_to_discrete(&range, 1000.0), 128.0);
298    }
299
300    #[test]
301    fn ordering_helpers_are_deterministic() {
302        let mut space: HyperparameterSpace<f64> = HyperparameterSpace::new();
303        for name in ["zeta", "alpha", "mu"] {
304            space.add_parameter(
305                name.to_string(),
306                ParameterRange {
307                    name: name.to_string(),
308                    min_value: 0.0,
309                    max_value: 1.0,
310                    distribution: DistributionType::Uniform,
311                    log_scale: false,
312                    discrete_values: None,
313                },
314            );
315        }
316        space.add_categorical_parameter("opt".to_string(), vec!["adam".to_string()]);
317        space.add_categorical_parameter("act".to_string(), vec!["relu".to_string()]);
318
319        assert_eq!(sorted_parameter_names(&space), vec!["alpha", "mu", "zeta"]);
320        assert_eq!(sorted_categorical_names(&space), vec!["act", "opt"]);
321    }
322}