use super::support::{
bounds, is_log_axis, sorted_categorical_names, sorted_parameter_names, StrategyRng,
SuggestedParameters,
};
use super::{HyperparameterSpace, ParameterRange};
use crate::error::{OptimError, Result};
use scirs2_core::numeric::Float;
use std::collections::HashMap;
pub const DEFAULT_GRID_RESOLUTION: usize = 5;
pub fn axis_values<T: Float>(range: &ParameterRange<T>, resolution: usize) -> Vec<f64> {
if let Some(values) = range.discrete_values.as_ref() {
if !values.is_empty() {
return values.iter().map(|v| v.to_f64().unwrap_or(0.0)).collect();
}
}
let (lower, upper) = bounds(range);
if resolution <= 1 || upper <= lower {
return vec![lower];
}
let steps = resolution - 1;
(0..resolution)
.map(|i| {
if i == 0 {
lower
} else if i == steps {
upper
} else if is_log_axis(range) {
let log_lower = lower.ln();
let log_upper = upper.ln();
(log_lower + (log_upper - log_lower) * i as f64 / steps as f64).exp()
} else {
lower + (upper - lower) * i as f64 / steps as f64
}
})
.collect()
}
pub fn grid_size<T: Float>(space: &HyperparameterSpace<T>, resolution: usize) -> Option<usize> {
let mut total: usize = 1;
for name in sorted_parameter_names(space) {
let Some(range) = space.parameter_ranges().get(&name) else {
continue;
};
total = total.checked_mul(axis_values(range, resolution).len().max(1))?;
}
for name in sorted_categorical_names(space) {
let count = space
.categorical_options()
.get(&name)
.map(|options| options.len())
.unwrap_or(0)
.max(1);
total = total.checked_mul(count)?;
}
Some(total)
}
pub fn grid_point<T: Float>(
space: &HyperparameterSpace<T>,
index: usize,
resolution: usize,
) -> Result<SuggestedParameters<T>> {
let total = grid_size(space, resolution).ok_or_else(|| {
OptimError::InvalidConfig(
"grid search space is too large to enumerate (point count overflows usize)".to_string(),
)
})?;
let mut parameters = HashMap::new();
let mut categorical = HashMap::new();
let mut continuous_axes: Vec<(String, Vec<f64>)> = Vec::new();
for name in sorted_parameter_names(space) {
if let Some(range) = space.parameter_ranges().get(&name) {
let mut values = axis_values(range, resolution);
if values.is_empty() {
values.push(bounds(range).0);
}
continuous_axes.push((name, values));
}
}
let mut categorical_axes: Vec<(String, Vec<String>)> = Vec::new();
for name in sorted_categorical_names(space) {
if let Some(options) = space.categorical_options().get(&name) {
if !options.is_empty() {
categorical_axes.push((name, options.clone()));
}
}
}
if continuous_axes.is_empty() && categorical_axes.is_empty() {
return Err(OptimError::InvalidConfig(
"grid search requires at least one parameter axis; the search space declares none"
.to_string(),
));
}
let mut remaining = index % total.max(1);
for (name, options) in categorical_axes.iter().rev() {
let radix = options.len();
let position = remaining % radix;
remaining /= radix;
categorical.insert(name.clone(), options[position].clone());
}
for (name, values) in continuous_axes.iter().rev() {
let radix = values.len();
let position = remaining % radix;
remaining /= radix;
parameters.insert(
name.clone(),
scirs2_core::numeric::NumCast::from(values[position]).unwrap_or_else(T::zero),
);
}
Ok((parameters, categorical))
}
pub fn suggest<T: Float>(
space: &HyperparameterSpace<T>,
index: usize,
resolution: usize,
_rng: &mut StrategyRng,
) -> Result<SuggestedParameters<T>> {
grid_point(space, index, resolution)
}
#[cfg(test)]
mod tests {
use super::super::DistributionType;
use super::*;
use scirs2_core::random::Random;
fn space_with_two_axes() -> HyperparameterSpace<f64> {
let mut space: HyperparameterSpace<f64> = HyperparameterSpace::new();
space.add_parameter(
"alpha".to_string(),
ParameterRange {
name: "alpha".to_string(),
min_value: 0.0,
max_value: 1.0,
distribution: DistributionType::Uniform,
log_scale: false,
discrete_values: None,
},
);
space.add_categorical_parameter(
"optimizer".to_string(),
vec!["adam".to_string(), "sgd".to_string()],
);
space
}
#[test]
fn continuous_axis_spans_the_range_inclusively() {
let range = ParameterRange {
name: "x".to_string(),
min_value: 0.0,
max_value: 1.0,
distribution: DistributionType::Uniform,
log_scale: false,
discrete_values: None,
};
let values = axis_values(&range, 5);
assert_eq!(values, vec![0.0, 0.25, 0.5, 0.75, 1.0]);
}
#[test]
fn log_axis_is_geometrically_spaced() {
let range = ParameterRange {
name: "lr".to_string(),
min_value: 1e-4,
max_value: 1e-1,
distribution: DistributionType::Uniform,
log_scale: true,
discrete_values: None,
};
let values = axis_values(&range, 4);
assert_eq!(values.len(), 4);
for expected in [1e-4, 1e-3, 1e-2, 1e-1] {
assert!(
values.iter().any(|v| (v - expected).abs() < 1e-12),
"missing {expected} in {values:?}"
);
}
}
#[test]
fn discrete_values_define_the_axis_verbatim() {
let range = ParameterRange {
name: "batch".to_string(),
min_value: 8.0,
max_value: 128.0,
distribution: DistributionType::Uniform,
log_scale: false,
discrete_values: Some(vec![8.0, 32.0, 128.0]),
};
assert_eq!(axis_values(&range, 17), vec![8.0, 32.0, 128.0]);
}
#[test]
fn degenerate_range_yields_exactly_one_point() {
let range = ParameterRange {
name: "fixed".to_string(),
min_value: 3.0,
max_value: 3.0,
distribution: DistributionType::Uniform,
log_scale: false,
discrete_values: None,
};
assert_eq!(axis_values(&range, 5), vec![3.0]);
}
#[test]
fn the_grid_is_enumerated_exhaustively_and_without_repeats() {
let space = space_with_two_axes();
let resolution = 3;
assert_eq!(grid_size(&space, resolution), Some(6));
let mut seen = std::collections::HashSet::new();
for index in 0..6 {
let (parameters, categorical) =
grid_point(&space, index, resolution).expect("grid point");
let key = (
parameters["alpha"].to_bits(),
categorical["optimizer"].clone(),
);
assert!(seen.insert(key), "grid point {index} repeated");
}
assert_eq!(seen.len(), 6, "the grid must be covered exactly once");
let (first, first_cat) = grid_point(&space, 0, resolution).expect("point");
let (wrapped, wrapped_cat) = grid_point(&space, 6, resolution).expect("point");
assert_eq!(first, wrapped);
assert_eq!(first_cat, wrapped_cat);
}
#[test]
fn grid_points_are_reproducible_and_rng_independent() {
let space = space_with_two_axes();
let mut rng_a = Random::seed(1);
let mut rng_b = Random::seed(999_999);
let a = suggest(&space, 4, 3, &mut rng_a).expect("suggest");
let b = suggest(&space, 4, 3, &mut rng_b).expect("suggest");
assert_eq!(a, b, "grid search must not depend on the RNG at all");
}
#[test]
fn an_empty_space_is_an_error_not_a_random_draw() {
let space: HyperparameterSpace<f64> = HyperparameterSpace::new();
let mut rng = Random::seed(1);
assert!(suggest(&space, 0, 3, &mut rng).is_err());
}
}