optirs_nas/hyperparameter/
support.rs1use super::{DistributionType, HyperparameterSpace, ParameterRange};
10use scirs2_core::numeric::Float;
11use scirs2_core::random::Random;
12
13pub type StrategyRng = Random<scirs2_core::random::rngs::StdRng>;
15
16pub type SuggestedParameters<T> = (
19 std::collections::HashMap<String, T>,
20 std::collections::HashMap<String, String>,
21);
22
23pub 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
30pub 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
37pub 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
49pub fn is_log_axis<T: Float>(range: &ParameterRange<T>) -> bool {
53 let (lower, _) = bounds(range);
54 range.log_scale && lower > 0.0
55}
56
57pub 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
76pub 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 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
97pub 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
117pub 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 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 let raw = standard_normal(rng).exp();
151 (raw / (raw + 1.0)).clamp(0.0, 1.0)
152 }
153 DistributionType::Beta => {
154 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 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
174pub 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 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}