use scirs2_core::ndarray::{Array1, Array2};
use scirs2_core::numeric::Float;
use scirs2_core::random::Random;
use std::collections::{HashMap, VecDeque};
use std::fmt::Debug;
use crate::error::{OptimError, Result};
use crate::nas_engine::{OptimizerArchitecture, SearchResult, SearchSpaceConfig};
use crate::EvaluationMetric;
use super::random::RandomSearch;
use super::{SearchStrategy, SearchStrategyStatistics};
pub struct NeuralPredictorSearch<T: Float + Debug + Send + Sync + 'static> {
predictor_network: PredictorNetwork<T>,
architecture_encoder: ArchitectureEncoder<T>,
search_optimizer: SearchOptimizer<T>,
confidence_threshold: T,
statistics: SearchStrategyStatistics<T>,
uncertainty_sampling: bool,
rng: Random<scirs2_core::random::rngs::StdRng>,
training_epochs: usize,
}
#[derive(Debug)]
pub struct PredictorNetwork<T: Float + Debug + Send + Sync + 'static> {
layers: Vec<PredictorLayer<T>>,
dropout_rates: Vec<T>,
architecture: Vec<usize>,
mc_samples: usize,
}
#[derive(Debug)]
pub struct PredictorLayer<T: Float + Debug + Send + Sync + 'static> {
weights: Array2<T>,
bias: Array1<T>,
activation: ActivationFunction,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActivationFunction {
ReLU,
GELU,
Swish,
Tanh,
Sigmoid,
Identity,
}
#[derive(Debug)]
pub struct ArchitectureEncoder<T: Float + Debug + Send + Sync + 'static> {
embedding_dim: usize,
max_components: usize,
_phantom: std::marker::PhantomData<T>,
}
#[derive(Debug)]
pub struct SearchOptimizer<T: Float + Debug + Send + Sync + 'static> {
optimizer_type: SearchOptimizerType,
learning_rate: T,
momentum: T,
weight_decay: T,
parameters: HashMap<String, Array1<T>>,
step: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SearchOptimizerType {
Adam,
SGD,
RMSprop,
AdamW,
}
impl<
T: Float + Debug + Default + Clone + Send + Sync + std::fmt::Debug + 'static + std::iter::Sum,
> NeuralPredictorSearch<T>
{
pub fn new(
predictor_architecture: Vec<usize>,
embeddingdim: usize,
confidence_threshold: f64,
) -> Self {
Self {
predictor_network: PredictorNetwork::new(predictor_architecture),
architecture_encoder: ArchitectureEncoder::new(embeddingdim),
search_optimizer: SearchOptimizer::new(
SearchOptimizerType::Adam,
scirs2_core::numeric::NumCast::from(DEFAULT_PREDICTOR_LEARNING_RATE)
.unwrap_or_else(|| T::zero()),
),
confidence_threshold: scirs2_core::numeric::NumCast::from(confidence_threshold)
.unwrap_or_else(|| T::zero()),
statistics: SearchStrategyStatistics::default(),
uncertainty_sampling: true,
rng: Random::seed(scirs2_core::random::random::<u64>()),
training_epochs: DEFAULT_PREDICTOR_EPOCHS,
}
}
pub fn confidence_threshold(&self) -> T {
self.confidence_threshold
}
pub fn set_confidence_threshold(&mut self, threshold: T) -> Result<()> {
if !(threshold >= T::zero() && threshold <= T::one()) {
return Err(OptimError::InvalidParameter(format!(
"confidence threshold must lie in [0, 1], got {:?}",
threshold
)));
}
self.confidence_threshold = threshold;
Ok(())
}
pub fn predictions_are_trusted(&self, mean_uncertainty: T) -> bool {
let spread = if mean_uncertainty.is_finite() {
mean_uncertainty.max(T::zero())
} else {
return false;
};
let confidence = T::one() / (T::one() + spread);
confidence >= self.confidence_threshold
}
pub fn set_seed(&mut self, seed: u64) {
self.rng = Random::seed(seed);
}
pub fn set_optimizer(&mut self, optimizer_type: SearchOptimizerType, learning_rate: T) {
self.search_optimizer = SearchOptimizer::new(optimizer_type, learning_rate);
}
pub fn set_training_epochs(&mut self, epochs: usize) {
self.training_epochs = epochs;
}
pub fn training_loss(
&self,
architectures: &[OptimizerArchitecture<T>],
performances: &[T],
) -> Result<T> {
if architectures.len() != performances.len() || architectures.is_empty() {
return Ok(T::zero());
}
let mut total = T::zero();
for (architecture, &target) in architectures.iter().zip(performances.iter()) {
let encoded = self.architecture_encoder.encode(architecture)?;
let prediction = self.predictor_network.predict(&encoded)?;
let error = prediction - target;
total = total + error * error;
}
Ok(total
/ scirs2_core::numeric::NumCast::from(architectures.len() as f64)
.unwrap_or_else(T::one))
}
fn predict_performance(&mut self, architecture: &OptimizerArchitecture<T>) -> Result<(T, T)> {
let encoded = self.architecture_encoder.encode(architecture)?;
self.predictor_network
.forward_with_uncertainty(&encoded, &mut self.rng)
}
fn train_predictor(
&mut self,
architectures: &[OptimizerArchitecture<T>],
performances: &[T],
) -> Result<()> {
if architectures.len() != performances.len() || architectures.is_empty() {
return Ok(());
}
let encoded_archs: std::result::Result<Vec<_>, _> = architectures
.iter()
.map(|arch| self.architecture_encoder.encode(arch))
.collect();
let encoded_archs = encoded_archs?;
let mut order: Vec<usize> = (0..encoded_archs.len()).collect();
for _ in 0..self.training_epochs.max(1) {
for i in (1..order.len()).rev() {
let j = self.rng.gen_range(0..=i);
order.swap(i, j);
}
for &idx in &order {
let pass = self
.predictor_network
.forward_train(&encoded_archs[idx], &mut self.rng)?;
self.predictor_network.backward_update(
&pass,
performances[idx],
&mut self.search_optimizer,
)?;
}
}
Ok(())
}
fn generate_candidate_with_uncertainty(
&mut self,
searchspace: &SearchSpaceConfig,
) -> Result<OptimizerArchitecture<T>> {
let num_candidates = 50;
let mut candidates = Vec::new();
let mut random_search = RandomSearch::<T>::new(None);
random_search.initialize(searchspace)?;
for _ in 0..num_candidates {
candidates.push(random_search.generate_architecture(searchspace, &VecDeque::new())?);
}
let mut scored: Vec<(OptimizerArchitecture<T>, T, T)> =
Vec::with_capacity(candidates.len());
let mut total_uncertainty = T::zero();
for candidate in candidates {
let (predicted_perf, uncertainty) = self.predict_performance(&candidate)?;
total_uncertainty = total_uncertainty + uncertainty;
scored.push((candidate, predicted_perf, uncertainty));
}
let count: T =
scirs2_core::numeric::NumCast::from(scored.len() as f64).unwrap_or_else(T::one);
let mean_uncertainty = if count > T::zero() {
total_uncertainty / count
} else {
T::zero()
};
let exploit = !self.uncertainty_sampling || self.predictions_are_trusted(mean_uncertainty);
let mut best_candidate = scored[0].0.clone();
let mut best_score = T::neg_infinity();
for (candidate, predicted_perf, uncertainty) in scored {
let score = if exploit {
predicted_perf
} else {
predicted_perf + uncertainty
};
if score > best_score {
best_score = score;
best_candidate = candidate;
}
}
Ok(best_candidate)
}
}
impl<
T: Float + Debug + Default + Clone + Send + Sync + std::fmt::Debug + 'static + std::iter::Sum,
> SearchStrategy<T> for NeuralPredictorSearch<T>
{
fn initialize(&mut self, _searchspace: &SearchSpaceConfig) -> Result<()> {
self.predictor_network.initialize()?;
Ok(())
}
fn generate_architecture(
&mut self,
searchspace: &SearchSpaceConfig,
history: &VecDeque<SearchResult<T>>,
) -> Result<OptimizerArchitecture<T>> {
if history.len() > 10 {
let architectures: Vec<_> = history.iter().map(|r| r.architecture.clone()).collect();
let performances: Vec<_> = history
.iter()
.filter_map(|r| {
r.evaluation_results
.metric_scores
.get(&EvaluationMetric::FinalPerformance)
})
.cloned()
.collect();
if architectures.len() == performances.len() {
self.train_predictor(&architectures, &performances)?;
}
}
let architecture = if history.len() > 5 {
self.generate_candidate_with_uncertainty(searchspace)?
} else {
let mut random_search = RandomSearch::<T>::new(None);
random_search.initialize(searchspace)?;
random_search.generate_architecture(searchspace, history)?
};
self.statistics.total_architectures_generated += 1;
Ok(architecture)
}
fn update_with_results(&mut self, results: &[SearchResult<T>]) -> Result<()> {
if results.is_empty() {
return Ok(());
}
let architectures: Vec<_> = results.iter().map(|r| r.architecture.clone()).collect();
let performances: Vec<_> = results
.iter()
.filter_map(|r| {
r.evaluation_results
.metric_scores
.get(&EvaluationMetric::FinalPerformance)
})
.cloned()
.collect();
if architectures.len() == performances.len() && !performances.is_empty() {
self.statistics.best_performance = performances
.iter()
.max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.cloned()
.unwrap_or(T::zero());
let sum: T = performances.iter().cloned().sum();
let count: T = scirs2_core::numeric::NumCast::from(performances.len() as f64)
.unwrap_or_else(T::one);
self.statistics.average_performance = sum / count;
self.train_predictor(&architectures, &performances)?;
}
Ok(())
}
fn name(&self) -> &str {
"NeuralPredictorSearch"
}
fn get_statistics(&self) -> SearchStrategyStatistics<T> {
let mut stats = self.statistics.clone();
stats.exploration_rate = if self.uncertainty_sampling {
scirs2_core::numeric::NumCast::from(0.7).unwrap_or_else(|| T::zero())
} else {
scirs2_core::numeric::NumCast::from(0.3).unwrap_or_else(|| T::zero())
};
stats.exploitation_rate = T::one() - stats.exploration_rate;
stats
}
}
const DEFAULT_PREDICTOR_LEARNING_RATE: f64 = 0.01;
const DEFAULT_PREDICTOR_EPOCHS: usize = 8;
const DEFAULT_PREDICTOR_DROPOUT: f64 = 0.1;
const DEFAULT_MC_SAMPLES: usize = 16;
const OPTIMIZER_EPSILON: f64 = 1e-8;
#[derive(Debug)]
pub struct TrainingPass<T: Float + Debug + Send + Sync + 'static> {
inputs: Vec<Array1<T>>,
pre_activations: Vec<Array1<T>>,
dropout_masks: Vec<Array1<T>>,
output: Array1<T>,
}
impl<T: Float + Debug + Send + Sync + 'static> TrainingPass<T> {
pub fn prediction(&self) -> T {
self.output.first().copied().unwrap_or_else(T::zero)
}
}
impl<T: Float + Debug + Default + Clone + 'static + std::iter::Sum + Send + Sync>
PredictorNetwork<T>
{
fn new(architecture: Vec<usize>) -> Self {
let mut layers = Vec::new();
for i in 1..architecture.len() {
let mut layer = PredictorLayer::new(architecture[i - 1], architecture[i]);
if i == architecture.len() - 1 {
layer.activation = ActivationFunction::Identity;
}
layers.push(layer);
}
let dropout: T = scirs2_core::numeric::NumCast::from(DEFAULT_PREDICTOR_DROPOUT)
.unwrap_or_else(|| T::zero());
Self {
dropout_rates: vec![dropout; layers.len()],
layers,
architecture,
mc_samples: DEFAULT_MC_SAMPLES,
}
}
pub fn num_layers(&self) -> usize {
self.layers.len()
}
pub fn set_mc_samples(&mut self, samples: usize) {
self.mc_samples = samples.max(1);
}
pub fn set_dropout_rate(&mut self, rate: T) {
for entry in self.dropout_rates.iter_mut() {
*entry = rate;
}
}
fn require_layers(&self) -> Result<()> {
if self.layers.is_empty() {
return Err(crate::error::OptimError::InvalidConfig(format!(
"predictor architecture {:?} needs at least an input and an output width",
self.architecture
)));
}
Ok(())
}
fn initialize(&mut self) -> Result<()> {
self.require_layers()?;
for layer in &mut self.layers {
layer.initialize()?;
}
Ok(())
}
fn is_hidden(&self, index: usize) -> bool {
index + 1 < self.layers.len()
}
fn forward_eval(&self, input: &Array1<T>) -> Result<Array1<T>> {
self.require_layers()?;
let mut current = input.clone();
for layer in &self.layers {
current = layer.forward(¤t)?;
}
Ok(current)
}
fn predict(&self, input: &Array1<T>) -> Result<T> {
let output = self.forward_eval(input)?;
Ok(output.first().copied().unwrap_or_else(T::zero))
}
fn forward_train(
&self,
input: &Array1<T>,
rng: &mut Random<scirs2_core::random::rngs::StdRng>,
) -> Result<TrainingPass<T>> {
self.require_layers()?;
let mut inputs = Vec::with_capacity(self.layers.len());
let mut pre_activations = Vec::with_capacity(self.layers.len());
let mut dropout_masks = Vec::with_capacity(self.layers.len());
let mut current = input.clone();
for (index, layer) in self.layers.iter().enumerate() {
inputs.push(current.clone());
let pre = layer.pre_activation(¤t);
let mut activated = layer.apply_activation(&pre);
let mask = if self.is_hidden(index) {
let rate = self
.dropout_rates
.get(index)
.copied()
.unwrap_or_else(T::zero);
let mask = dropout_mask(activated.len(), rate, rng);
activated = &activated * &mask;
mask
} else {
Array1::from_elem(activated.len(), T::one())
};
pre_activations.push(pre);
dropout_masks.push(mask);
current = activated;
}
Ok(TrainingPass {
inputs,
pre_activations,
dropout_masks,
output: current,
})
}
fn forward_with_uncertainty(
&self,
input: &Array1<T>,
rng: &mut Random<scirs2_core::random::rngs::StdRng>,
) -> Result<(T, T)> {
self.require_layers()?;
let dropout_active = self
.dropout_rates
.iter()
.enumerate()
.any(|(i, rate)| self.is_hidden(i) && *rate > T::zero());
if !dropout_active || self.mc_samples <= 1 {
return Ok((self.predict(input)?, T::zero()));
}
let mut samples = Vec::with_capacity(self.mc_samples);
for _ in 0..self.mc_samples {
let pass = self.forward_train(input, rng)?;
samples.push(pass.prediction());
}
let count: T =
scirs2_core::numeric::NumCast::from(samples.len() as f64).unwrap_or_else(T::one);
let mut sum = T::zero();
for value in &samples {
sum = sum + *value;
}
let mean = sum / count;
let mut variance = T::zero();
for value in &samples {
let diff = *value - mean;
variance = variance + diff * diff;
}
Ok((mean, (variance / count).sqrt()))
}
fn backward_update(
&mut self,
pass: &TrainingPass<T>,
target: T,
optimizer: &mut SearchOptimizer<T>,
) -> Result<T> {
self.require_layers()?;
if pass.inputs.len() != self.layers.len() {
return Err(crate::error::OptimError::InvalidParameter(format!(
"forward pass recorded {} layers but the network has {}",
pass.inputs.len(),
self.layers.len()
)));
}
let prediction = pass.prediction();
let error = prediction - target;
let half: T = scirs2_core::numeric::NumCast::from(0.5).unwrap_or_else(T::one);
let loss = half * error * error;
let mut delta_out = Array1::zeros(pass.output.len());
if !delta_out.is_empty() {
delta_out[0] = error;
}
optimizer.begin_step();
let mut upstream = delta_out;
for index in (0..self.layers.len()).rev() {
let masked = &upstream * &pass.dropout_masks[index];
let derivative = self.layers[index].activation_derivative(&pass.pre_activations[index]);
let delta = &masked * &derivative;
let input = &pass.inputs[index];
let rows = self.layers[index].weights.nrows();
let cols = self.layers[index].weights.ncols();
let mut weight_grad = Array2::zeros((rows, cols));
for r in 0..rows {
let d = delta[r];
if d == T::zero() {
continue;
}
for c in 0..cols {
weight_grad[[r, c]] = d * input[c];
}
}
if index > 0 {
upstream = self.layers[index].weights.t().dot(&delta);
}
optimizer.step_matrix(
&format!("layer{}_w", index),
&mut self.layers[index].weights,
&weight_grad,
);
optimizer.step_vector(
&format!("layer{}_b", index),
&mut self.layers[index].bias,
&delta,
);
}
Ok(loss)
}
}
fn dropout_mask<T: Float>(
len: usize,
rate: T,
rng: &mut Random<scirs2_core::random::rngs::StdRng>,
) -> Array1<T> {
let rate_f64 = rate.to_f64().unwrap_or(0.0).clamp(0.0, 0.999_999);
if rate_f64 <= 0.0 {
return Array1::from_elem(len, T::one());
}
let keep_scale: T =
scirs2_core::numeric::NumCast::from(1.0 / (1.0 - rate_f64)).unwrap_or_else(T::one);
Array1::from_shape_fn(len, |_| {
if rng.gen_range(0.0..1.0) < rate_f64 {
T::zero()
} else {
keep_scale
}
})
}
impl<T: Float + Debug + Default + Clone + 'static + Send + Sync> PredictorLayer<T> {
fn new(input_size: usize, outputsize: usize) -> Self {
Self {
weights: Array2::zeros((outputsize, input_size)),
bias: Array1::zeros(outputsize),
activation: ActivationFunction::ReLU,
}
}
fn initialize(&mut self) -> Result<()> {
let fan_in = self.weights.ncols() as f64;
let fan_out = self.weights.nrows() as f64;
let scale = if fan_in + fan_out > 0.0 {
(6.0 / (fan_in + fan_out)).sqrt()
} else {
0.0
};
let mut rng = Random::seed(scirs2_core::random::random::<u64>());
self.weights = Array2::from_shape_fn(self.weights.raw_dim(), |_| {
scirs2_core::numeric::NumCast::from(rng.gen_range(-scale..=scale))
.unwrap_or_else(|| T::zero())
});
self.bias = Array1::zeros(self.bias.len());
Ok(())
}
fn pre_activation(&self, input: &Array1<T>) -> Array1<T> {
self.weights.dot(input) + &self.bias
}
fn forward(&self, input: &Array1<T>) -> Result<Array1<T>> {
Ok(self.apply_activation(&self.pre_activation(input)))
}
fn apply_activation(&self, x: &Array1<T>) -> Array1<T> {
match self.activation {
ActivationFunction::ReLU => x.mapv(|xi| if xi > T::zero() { xi } else { T::zero() }),
ActivationFunction::GELU => x.mapv(|xi| {
let x_f64 = xi.to_f64().unwrap_or(0.0);
scirs2_core::numeric::NumCast::from(gelu(x_f64)).unwrap_or_else(|| T::zero())
}),
ActivationFunction::Swish => x.mapv(|xi| {
let sigmoid = T::one() / (T::one() + (-xi).exp());
xi * sigmoid
}),
ActivationFunction::Tanh => x.mapv(|xi| xi.tanh()),
ActivationFunction::Sigmoid => x.mapv(|xi| T::one() / (T::one() + (-xi).exp())),
ActivationFunction::Identity => x.clone(),
}
}
fn activation_derivative(&self, pre_activation: &Array1<T>) -> Array1<T> {
match self.activation {
ActivationFunction::ReLU => {
pre_activation.mapv(|xi| if xi > T::zero() { T::one() } else { T::zero() })
}
ActivationFunction::GELU => pre_activation.mapv(|xi| {
let x_f64 = xi.to_f64().unwrap_or(0.0);
scirs2_core::numeric::NumCast::from(gelu_derivative(x_f64))
.unwrap_or_else(|| T::zero())
}),
ActivationFunction::Swish => pre_activation.mapv(|xi| {
let sigmoid = T::one() / (T::one() + (-xi).exp());
sigmoid + xi * sigmoid * (T::one() - sigmoid)
}),
ActivationFunction::Tanh => pre_activation.mapv(|xi| {
let t = xi.tanh();
T::one() - t * t
}),
ActivationFunction::Sigmoid => pre_activation.mapv(|xi| {
let s = T::one() / (T::one() + (-xi).exp());
s * (T::one() - s)
}),
ActivationFunction::Identity => Array1::from_elem(pre_activation.len(), T::one()),
}
}
}
fn gelu(x: f64) -> f64 {
0.5 * x * (1.0 + (x * 0.797_884_560_802_865_4 * (1.0 + 0.044_715 * x * x)).tanh())
}
fn gelu_derivative(x: f64) -> f64 {
let c = 0.797_884_560_802_865_4;
let inner = c * (x + 0.044_715 * x * x * x);
let tanh_inner = inner.tanh();
let sech_squared = 1.0 - tanh_inner * tanh_inner;
let d_inner = c * (1.0 + 3.0 * 0.044_715 * x * x);
0.5 * (1.0 + tanh_inner) + 0.5 * x * sech_squared * d_inner
}
impl<T: Float + Debug + Send + Sync + 'static + Default + Clone> ArchitectureEncoder<T> {
fn new(embeddingdim: usize) -> Self {
Self {
embedding_dim: embeddingdim,
max_components: 64,
_phantom: std::marker::PhantomData,
}
}
fn encode(&self, architecture: &OptimizerArchitecture<T>) -> Result<Array1<T>> {
let mut encoding = encode_component_block::<T, _>(
architecture.components.iter().take(self.max_components),
);
encoding.resize(self.embedding_dim, T::zero());
Ok(Array1::from_vec(encoding))
}
}
const COMPONENT_VOCABULARY: [&str; 41] = [
"SGD",
"Adam",
"AdamW",
"RMSprop",
"AdaGrad",
"AdaDelta",
"Momentum",
"Nesterov",
"LRScheduler",
"GradientClipping",
"BatchNorm",
"Dropout",
"LAMB",
"LARS",
"Lion",
"RAdam",
"Lookahead",
"SAM",
"LBFGS",
"SparseAdam",
"GroupedAdam",
"MAML",
"L1Regularizer",
"L2Regularizer",
"ElasticNetRegularizer",
"DropoutRegularizer",
"WeightDecay",
"AdaptiveLR",
"AdaptiveMomentum",
"AdaptiveRegularization",
"LSTMOptimizer",
"TransformerOptimizer",
"AttentionOptimizer",
"MetaSGD",
"ConstantLR",
"ExponentialLR",
"StepLR",
"CosineAnnealingLR",
"OneCycleLR",
"CyclicLR",
"Reptile",
];
const COMPONENT_DESCRIPTOR_COUNT: usize = 3;
const COMPONENT_BLOCK_LEN: usize = COMPONENT_VOCABULARY.len() + 1 + COMPONENT_DESCRIPTOR_COUNT;
fn component_vocab_index(name: &str) -> usize {
COMPONENT_VOCABULARY
.iter()
.position(|known| *known == name)
.unwrap_or(COMPONENT_VOCABULARY.len())
}
fn encode_component_block<'a, T, I>(components: I) -> Vec<T>
where
T: Float + Debug + Send + Sync + 'static + Default + Clone,
I: Iterator<Item = &'a String>,
{
let mut block = vec![T::zero(); COMPONENT_BLOCK_LEN];
let one: T = scirs2_core::numeric::NumCast::from(1.0).unwrap_or_else(|| T::zero());
let mut total: usize = 0;
let mut name_len_sum: usize = 0;
let mut adam_family: usize = 0;
for component in components {
let idx = component_vocab_index(component);
block[idx] = block[idx] + one;
total += 1;
name_len_sum += component.len();
if component.contains("Adam") {
adam_family += 1;
}
}
let descriptor_base = COMPONENT_VOCABULARY.len() + 1;
if total > 0 {
let total_t: T =
scirs2_core::numeric::NumCast::from(total as f64).unwrap_or_else(|| T::zero());
block[descriptor_base] =
scirs2_core::numeric::NumCast::from(total as f64 / 16.0).unwrap_or_else(|| T::zero());
let mean_len = (name_len_sum as f64 / total as f64) / 24.0;
block[descriptor_base + 1] =
scirs2_core::numeric::NumCast::from(mean_len).unwrap_or_else(|| T::zero());
let adam_frac: T =
scirs2_core::numeric::NumCast::from(adam_family as f64).unwrap_or_else(|| T::zero());
block[descriptor_base + 2] = adam_frac / total_t;
}
block
}
impl<T: Float + Debug + Send + Sync + 'static + Default + Clone> SearchOptimizer<T> {
fn new(optimizer_type: SearchOptimizerType, learningrate: T) -> Self {
Self {
optimizer_type,
learning_rate: learningrate,
momentum: scirs2_core::numeric::NumCast::from(0.9).unwrap_or_else(|| T::zero()),
weight_decay: scirs2_core::numeric::NumCast::from(0.01).unwrap_or_else(|| T::zero()),
parameters: HashMap::new(),
step: 0,
}
}
pub fn optimizer_type(&self) -> SearchOptimizerType {
self.optimizer_type
}
pub fn learning_rate(&self) -> T {
self.learning_rate
}
pub fn set_learning_rate(&mut self, learning_rate: T) {
self.learning_rate = learning_rate;
}
fn begin_step(&mut self) {
self.step = self.step.saturating_add(1);
}
pub fn steps_taken(&self) -> u64 {
self.step
}
fn step_matrix(&mut self, key: &str, weights: &mut Array2<T>, gradients: &Array2<T>) {
let len = weights.len();
let mut flat: Vec<T> = weights.iter().copied().collect();
let grad: Vec<T> = gradients.iter().copied().collect();
if grad.len() != len {
return;
}
self.apply(key, &mut flat, &grad);
for (target, value) in weights.iter_mut().zip(flat) {
*target = value;
}
}
fn step_vector(&mut self, key: &str, bias: &mut Array1<T>, gradients: &Array1<T>) {
let len = bias.len();
let mut flat: Vec<T> = bias.iter().copied().collect();
let grad: Vec<T> = gradients.iter().copied().collect();
if grad.len() != len {
return;
}
self.apply(key, &mut flat, &grad);
for (target, value) in bias.iter_mut().zip(flat) {
*target = value;
}
}
fn state(&mut self, key: &str, len: usize) -> Array1<T> {
self.parameters
.entry(key.to_string())
.or_insert_with(|| Array1::zeros(len))
.clone()
}
fn apply(&mut self, key: &str, params: &mut [T], gradients: &[T]) {
let len = params.len();
let lr = self.learning_rate;
let epsilon: T =
scirs2_core::numeric::NumCast::from(OPTIMIZER_EPSILON).unwrap_or_else(|| T::zero());
match self.optimizer_type {
SearchOptimizerType::SGD => {
let momentum_key = format!("{}_momentum", key);
let mut buffer = self.state(&momentum_key, len);
for i in 0..len {
buffer[i] = self.momentum * buffer[i] + gradients[i];
params[i] = params[i] - lr * buffer[i];
}
self.parameters.insert(momentum_key, buffer);
}
SearchOptimizerType::RMSprop => {
let square_key = format!("{}_sq", key);
let mut squares = self.state(&square_key, len);
let one = T::one();
for i in 0..len {
squares[i] = self.momentum * squares[i]
+ (one - self.momentum) * gradients[i] * gradients[i];
params[i] = params[i] - lr * gradients[i] / (squares[i].sqrt() + epsilon);
}
self.parameters.insert(square_key, squares);
}
SearchOptimizerType::Adam | SearchOptimizerType::AdamW => {
let beta1: T =
scirs2_core::numeric::NumCast::from(0.9).unwrap_or_else(|| T::zero());
let beta2: T =
scirs2_core::numeric::NumCast::from(0.999).unwrap_or_else(|| T::zero());
let one = T::one();
let step = self.step.max(1) as i32;
let bias1 = one - beta1.powi(step);
let bias2 = one - beta2.powi(step);
let first_key = format!("{}_m", key);
let second_key = format!("{}_v", key);
let mut first = self.state(&first_key, len);
let mut second = self.state(&second_key, len);
let decoupled_decay = self.optimizer_type == SearchOptimizerType::AdamW;
for i in 0..len {
first[i] = beta1 * first[i] + (one - beta1) * gradients[i];
second[i] = beta2 * second[i] + (one - beta2) * gradients[i] * gradients[i];
let m_hat = if bias1 > T::zero() {
first[i] / bias1
} else {
first[i]
};
let v_hat = if bias2 > T::zero() {
second[i] / bias2
} else {
second[i]
};
if decoupled_decay {
params[i] = params[i] - lr * self.weight_decay * params[i];
}
params[i] = params[i] - lr * m_hat / (v_hat.sqrt() + epsilon);
}
self.parameters.insert(first_key, first);
self.parameters.insert(second_key, second);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn trainable_predictor(optimizer: SearchOptimizerType) -> NeuralPredictorSearch<f64> {
let embedding_dim = COMPONENT_BLOCK_LEN;
let mut search =
NeuralPredictorSearch::<f64>::new(vec![embedding_dim, 24, 12, 1], embedding_dim, 0.5);
search.set_seed(0xF19_0000_0001);
search.set_optimizer(optimizer, 0.02);
search.set_training_epochs(60);
search.predictor_network.set_dropout_rate(0.0);
search
.initialize(&SearchSpaceConfig::default())
.expect("initialize predictor");
search
}
fn training_set() -> (Vec<OptimizerArchitecture<f64>>, Vec<f64>) {
let architectures = vec![
make_arch(&["SGD"]),
make_arch(&["Adam"]),
make_arch(&["Adam", "Adam"]),
make_arch(&["RMSprop", "Dropout"]),
make_arch(&["Lion", "WeightDecay", "StepLR"]),
make_arch(&["AdaGrad"]),
];
let targets = vec![0.10, 0.40, 0.55, 0.25, 0.80, 0.15];
(architectures, targets)
}
#[test]
fn training_reduces_the_predictor_loss() {
let mut search = trainable_predictor(SearchOptimizerType::Adam);
let (architectures, targets) = training_set();
let before = search
.training_loss(&architectures, &targets)
.expect("loss before training");
search
.train_predictor(&architectures, &targets)
.expect("training must succeed");
let after = search
.training_loss(&architectures, &targets)
.expect("loss after training");
assert!(
after < before,
"training must reduce the loss: before = {before}, after = {after}"
);
assert!(
after < before * 0.5,
"expected a substantial reduction, before = {before}, after = {after}"
);
assert!(after.is_finite(), "loss diverged to {after}");
}
#[test]
fn every_optimizer_variant_reduces_the_loss() {
for optimizer in [
SearchOptimizerType::SGD,
SearchOptimizerType::Adam,
SearchOptimizerType::AdamW,
SearchOptimizerType::RMSprop,
] {
let mut search = trainable_predictor(optimizer);
let (architectures, targets) = training_set();
let before = search
.training_loss(&architectures, &targets)
.expect("loss before");
search
.train_predictor(&architectures, &targets)
.expect("training");
let after = search
.training_loss(&architectures, &targets)
.expect("loss after");
assert!(
after < before,
"{optimizer:?} did not reduce the loss: {before} -> {after}"
);
assert!(
search.search_optimizer.steps_taken() > 0,
"{optimizer:?} recorded no update steps"
);
}
}
#[test]
fn training_changes_the_network_weights() {
let mut search = trainable_predictor(SearchOptimizerType::Adam);
let (architectures, targets) = training_set();
let before: Vec<f64> = search
.predictor_network
.layers
.iter()
.flat_map(|layer| layer.weights.iter().copied().collect::<Vec<_>>())
.collect();
let biases_before: Vec<f64> = search
.predictor_network
.layers
.iter()
.flat_map(|layer| layer.bias.iter().copied().collect::<Vec<_>>())
.collect();
search
.train_predictor(&architectures, &targets)
.expect("training");
let after: Vec<f64> = search
.predictor_network
.layers
.iter()
.flat_map(|layer| layer.weights.iter().copied().collect::<Vec<_>>())
.collect();
let biases_after: Vec<f64> = search
.predictor_network
.layers
.iter()
.flat_map(|layer| layer.bias.iter().copied().collect::<Vec<_>>())
.collect();
assert_ne!(before, after, "weights must move during training");
assert_ne!(
biases_before, biases_after,
"biases must move during training (they start at zero and stayed there before)"
);
}
#[test]
fn the_predictor_learns_to_rank_architectures() {
let mut search = trainable_predictor(SearchOptimizerType::Adam);
let (architectures, targets) = training_set();
search
.train_predictor(&architectures, &targets)
.expect("training");
let best_idx = 4; let worst_idx = 0; let best = search
.predict_performance(&architectures[best_idx])
.expect("predict")
.0;
let worst = search
.predict_performance(&architectures[worst_idx])
.expect("predict")
.0;
assert!(
best > worst,
"expected the 0.80-target architecture to outrank the 0.10 one: {best} vs {worst}"
);
}
#[test]
fn the_output_layer_is_linear_so_negative_targets_are_reachable() {
let mut search = trainable_predictor(SearchOptimizerType::Adam);
assert_eq!(
search
.predictor_network
.layers
.last()
.expect("at least one layer")
.activation,
ActivationFunction::Identity,
"a ReLU output layer clamps every prediction to >= 0"
);
let architectures = vec![make_arch(&["SGD"]), make_arch(&["Lion"])];
let targets = vec![-0.75, 0.75];
search
.train_predictor(&architectures, &targets)
.expect("training");
let negative = search
.predict_performance(&architectures[0])
.expect("predict")
.0;
assert!(
negative < 0.0,
"a linear output must be able to reach a negative target, got {negative}"
);
}
#[test]
fn mc_dropout_uncertainty_is_zero_without_dropout_and_positive_with_it() {
let embedding_dim = COMPONENT_BLOCK_LEN;
let mut search =
NeuralPredictorSearch::<f64>::new(vec![embedding_dim, 16, 8, 1], embedding_dim, 0.5);
search.set_seed(4242);
search
.initialize(&SearchSpaceConfig::default())
.expect("initialize");
let arch = make_arch(&["Adam", "Dropout"]);
search.predictor_network.set_dropout_rate(0.3);
search.predictor_network.set_mc_samples(64);
let (_, stochastic) = search.predict_performance(&arch).expect("predict");
assert!(
stochastic > 0.0,
"MC dropout must report a non-zero standard deviation, got {stochastic}"
);
search.predictor_network.set_dropout_rate(0.0);
let (mean, deterministic) = search.predict_performance(&arch).expect("predict");
assert_eq!(deterministic, 0.0);
let (mean_again, _) = search.predict_performance(&arch).expect("predict");
assert_eq!(mean, mean_again, "eval mode must be deterministic");
}
#[test]
fn a_degenerate_architecture_spec_is_an_error_not_a_silent_no_op() {
let mut empty = NeuralPredictorSearch::<f64>::new(Vec::new(), 8, 0.5);
assert!(
empty.initialize(&SearchSpaceConfig::default()).is_err(),
"an empty predictor spec must be rejected"
);
let mut single = NeuralPredictorSearch::<f64>::new(vec![8], 8, 0.5);
assert!(single.initialize(&SearchSpaceConfig::default()).is_err());
}
#[test]
fn dropout_masks_preserve_the_expected_activation_scale() {
let mut rng = Random::seed(7);
let mask = dropout_mask::<f64>(10_000, 0.25, &mut rng);
let mean = mask.iter().sum::<f64>() / mask.len() as f64;
assert!(
(mean - 1.0).abs() < 0.05,
"inverted dropout must keep E[mask] = 1, got {mean}"
);
let no_dropout = dropout_mask::<f64>(16, 0.0, &mut rng);
assert!(no_dropout.iter().all(|v| *v == 1.0));
}
fn make_arch(components: &[&str]) -> OptimizerArchitecture<f64> {
OptimizerArchitecture {
components: components.iter().map(|s| s.to_string()).collect(),
parameters: HashMap::new(),
connections: Vec::new(),
metadata: HashMap::new(),
hyperparameters: HashMap::new(),
architecture_id: "test".to_string(),
}
}
#[test]
fn encode_is_deterministic_and_fixed_length() {
let embedding_dim = 96;
let encoder = ArchitectureEncoder::<f64>::new(embedding_dim);
let arch = make_arch(&["Adam", "SGD", "RMSprop"]);
let first = encoder.encode(&arch).expect("encode should succeed");
let second = encoder.encode(&arch).expect("encode should succeed");
assert_eq!(first.len(), embedding_dim);
assert_eq!(second.len(), embedding_dim);
assert_eq!(first, second, "encoding must be deterministic");
}
#[test]
fn different_known_types_differ_same_type_matches() {
let encoder = ArchitectureEncoder::<f64>::new(COMPONENT_BLOCK_LEN);
let adam = encoder.encode(&make_arch(&["Adam"])).expect("encode");
let adam_again = encoder.encode(&make_arch(&["Adam"])).expect("encode");
let sgd = encoder.encode(&make_arch(&["SGD"])).expect("encode");
assert_eq!(adam, adam_again, "same type must encode identically");
assert_ne!(adam, sgd, "different known types must differ");
let adam_idx = component_vocab_index("Adam");
let sgd_idx = component_vocab_index("SGD");
assert_ne!(adam_idx, sgd_idx);
assert_eq!(adam[adam_idx], 1.0);
assert_eq!(sgd[sgd_idx], 1.0);
}
#[test]
fn unknown_type_maps_to_oov_slot() {
let encoder = ArchitectureEncoder::<f64>::new(COMPONENT_BLOCK_LEN);
let oov_index = COMPONENT_VOCABULARY.len();
let encoded = encoder
.encode(&make_arch(&["TotallyUnknownOptimizer"]))
.expect("encode must not panic on unknown type");
assert_eq!(encoded.len(), COMPONENT_BLOCK_LEN);
assert_eq!(
encoded[oov_index], 1.0,
"unknown name must land in the out-of-vocabulary slot"
);
for (i, value) in encoded.iter().enumerate().take(oov_index) {
assert_eq!(*value, 0.0, "known slot {} must stay zero", i);
}
}
#[test]
fn repeated_types_accumulate_counts() {
let block = encode_component_block::<f64, _>(
["Adam".to_string(), "Adam".to_string(), "SGD".to_string()].iter(),
);
let adam_idx = component_vocab_index("Adam");
let sgd_idx = component_vocab_index("SGD");
assert_eq!(block[adam_idx], 2.0);
assert_eq!(block[sgd_idx], 1.0);
}
#[test]
fn the_confidence_threshold_decides_whether_predictions_are_trusted() {
let mut search = NeuralPredictorSearch::<f64>::new(vec![64, 16, 1], 64, 0.5);
assert!((search.confidence_threshold() - 0.5).abs() < 1e-12);
assert!(search.predictions_are_trusted(0.0));
assert!(search.predictions_are_trusted(1.0));
assert!(!search.predictions_are_trusted(1.5));
assert!(!search.predictions_are_trusted(f64::NAN));
assert!(!search.predictions_are_trusted(f64::INFINITY));
search
.set_confidence_threshold(0.0)
.expect("0 is a valid threshold");
assert!(search.predictions_are_trusted(1000.0));
search
.set_confidence_threshold(1.0)
.expect("1 is a valid threshold");
assert!(search.predictions_are_trusted(0.0));
assert!(!search.predictions_are_trusted(1e-6));
for bad in [-0.1, 1.1, f64::NAN] {
assert!(
search.set_confidence_threshold(bad).is_err(),
"threshold {bad} must be rejected"
);
}
assert!((search.confidence_threshold() - 1.0).abs() < 1e-12);
}
}