use scirs2_core::ndarray::{Array1, Array2};
use scirs2_core::numeric::Float;
use scirs2_core::random::{Random, Rng};
use std::fmt::Debug;
use crate::domain_objectives::MetaObjective;
use crate::domain_optimizers::AdvancedOptimizer;
use crate::error::{OptimError, Result};
const DIVERGENCE_PENALTY: f64 = 1.0e3;
pub trait MetaTrainable<T>: AdvancedOptimizer<T> + Clone
where
T: Float + Debug + Send + Sync + 'static,
{
fn weight_vector(&self) -> Vec<f64>;
fn set_weight_vector(&mut self, flat: &[f64]) -> Result<()>;
fn reset_state(&mut self);
fn weight_count(&self) -> usize {
self.weight_vector().len()
}
}
#[derive(Debug, Clone)]
pub struct MetaTrainingConfig {
pub population: usize,
pub iterations: usize,
pub sigma: f64,
pub meta_learning_rate: f64,
pub horizon: usize,
pub seed: u64,
}
impl Default for MetaTrainingConfig {
fn default() -> Self {
Self {
population: 8,
iterations: 20,
sigma: 0.05,
meta_learning_rate: 0.05,
horizon: 20,
seed: 0x11E7_A5EE,
}
}
}
impl MetaTrainingConfig {
fn validate(&self) -> Result<()> {
if self.population < 2 {
return Err(OptimError::InvalidConfig(
"population must be at least 2 (perturbations come in mirrored pairs)".to_string(),
));
}
if self.iterations == 0 {
return Err(OptimError::InvalidConfig(
"iterations must be at least 1".to_string(),
));
}
if !(self.sigma.is_finite() && self.sigma > 0.0) {
return Err(OptimError::InvalidConfig(
"sigma must be finite and positive".to_string(),
));
}
if !(self.meta_learning_rate.is_finite() && self.meta_learning_rate > 0.0) {
return Err(OptimError::InvalidConfig(
"meta_learning_rate must be finite and positive".to_string(),
));
}
if self.horizon == 0 {
return Err(OptimError::InvalidConfig(
"horizon must be at least 1".to_string(),
));
}
Ok(())
}
fn pairs(&self) -> usize {
self.population.div_ceil(2)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SelectionMetric {
TrainingLoss,
ValidationLoss,
}
#[derive(Debug, Clone)]
pub struct MetaTrainingReport {
pub iterations: usize,
pub initial_meta_loss: f64,
pub final_meta_loss: f64,
pub loss_history: Vec<f64>,
pub initial_validation_loss: Option<f64>,
pub final_validation_loss: Option<f64>,
pub validation_history: Vec<f64>,
pub selection_metric: SelectionMetric,
pub gradient_norms: Vec<f64>,
pub weight_count: usize,
}
impl MetaTrainingReport {
fn selection_pair(&self) -> (f64, f64) {
match self.selection_metric {
SelectionMetric::TrainingLoss => (self.initial_meta_loss, self.final_meta_loss),
SelectionMetric::ValidationLoss => (
self.initial_validation_loss
.unwrap_or(self.initial_meta_loss),
self.final_validation_loss.unwrap_or(self.final_meta_loss),
),
}
}
pub fn improved(&self) -> bool {
let (initial, final_) = self.selection_pair();
final_ < initial
}
pub fn relative_improvement(&self) -> f64 {
let (initial, final_) = self.selection_pair();
if initial.abs() <= f64::EPSILON {
return 0.0;
}
(initial - final_) / initial.abs()
}
pub fn relative_training_improvement(&self) -> f64 {
if self.initial_meta_loss.abs() <= f64::EPSILON {
return 0.0;
}
(self.initial_meta_loss - self.final_meta_loss) / self.initial_meta_loss.abs()
}
}
#[derive(Debug, Clone)]
pub struct EsMetaTrainer {
config: MetaTrainingConfig,
}
impl EsMetaTrainer {
pub fn new(config: MetaTrainingConfig) -> Result<Self> {
config.validate()?;
Ok(Self { config })
}
pub fn config(&self) -> &MetaTrainingConfig {
&self.config
}
pub fn meta_loss<T, O>(&self, optimizer: &O, tasks: &[&dyn MetaObjective<T>]) -> Result<f64>
where
T: Float + Debug + Send + Sync + 'static,
O: MetaTrainable<T>,
{
if tasks.is_empty() {
return Err(OptimError::InsufficientData(
"meta_loss needs at least one task".to_string(),
));
}
let mut total = 0.0;
for task in tasks {
total += self.rollout_score(optimizer, *task)?;
}
Ok(total / tasks.len() as f64)
}
fn rollout_score<T, O>(&self, optimizer: &O, task: &dyn MetaObjective<T>) -> Result<f64>
where
T: Float + Debug + Send + Sync + 'static,
O: MetaTrainable<T>,
{
let mut runner = optimizer.clone();
runner.reset_state();
let mut params = task.initial_parameters();
if params.len() != task.dimension() {
return Err(OptimError::InvalidConfig(format!(
"task '{}' reported dimension {} but produced {} initial parameters",
task.name(),
task.dimension(),
params.len()
)));
}
let (initial_loss, _) = task.loss_and_gradient(¶ms)?;
let initial = initial_loss.to_f64().unwrap_or(f64::NAN);
if !initial.is_finite() || initial.abs() <= f64::EPSILON {
return Ok(1.0);
}
let mut accumulated = 0.0;
for _ in 0..self.config.horizon {
let (_, gradient) = task.loss_and_gradient(¶ms)?;
params = runner.step(¶ms, &gradient)?;
let (loss, _) = task.loss_and_gradient(¶ms)?;
let value = loss.to_f64().unwrap_or(f64::NAN);
if !value.is_finite() {
return Ok(DIVERGENCE_PENALTY);
}
accumulated += value / initial;
}
let score = accumulated / self.config.horizon as f64;
if score.is_finite() {
Ok(score)
} else {
Ok(DIVERGENCE_PENALTY)
}
}
pub fn train<T, O>(
&self,
optimizer: &mut O,
tasks: &[&dyn MetaObjective<T>],
) -> Result<MetaTrainingReport>
where
T: Float + Debug + Send + Sync + 'static,
O: MetaTrainable<T>,
{
self.train_with_validation(optimizer, tasks, &[])
}
pub fn train_with_validation<T, O>(
&self,
optimizer: &mut O,
tasks: &[&dyn MetaObjective<T>],
validation: &[&dyn MetaObjective<T>],
) -> Result<MetaTrainingReport>
where
T: Float + Debug + Send + Sync + 'static,
O: MetaTrainable<T>,
{
if tasks.is_empty() {
return Err(OptimError::InsufficientData(
"meta-training needs at least one task".to_string(),
));
}
let baseline_weights = optimizer.weight_vector();
let dimension = baseline_weights.len();
if dimension == 0 {
return Err(OptimError::InvalidState(
"the optimizer reported zero learned weights".to_string(),
));
}
let selection_metric = if validation.is_empty() {
SelectionMetric::TrainingLoss
} else {
SelectionMetric::ValidationLoss
};
let initial_meta_loss = self.meta_loss(optimizer, tasks)?;
let initial_validation_loss = if validation.is_empty() {
None
} else {
Some(self.meta_loss(optimizer, validation)?)
};
let mut weights = baseline_weights.clone();
let mut best_weights = baseline_weights;
let mut best_training = initial_meta_loss;
let mut best_validation = initial_validation_loss;
let mut best_selection = match selection_metric {
SelectionMetric::TrainingLoss => initial_meta_loss,
SelectionMetric::ValidationLoss => initial_validation_loss.unwrap_or(initial_meta_loss),
};
let mut validation_history = Vec::with_capacity(self.config.iterations);
let mut moment1 = vec![0.0; dimension];
let mut moment2 = vec![0.0; dimension];
const BETA1: f64 = 0.9;
const BETA2: f64 = 0.999;
const EPSILON: f64 = 1e-8;
let mut rng = Random::seed(self.config.seed);
let pairs = self.config.pairs();
let mut loss_history = Vec::with_capacity(self.config.iterations);
let mut gradient_norms = Vec::with_capacity(self.config.iterations);
let mut probe = optimizer.clone();
for iteration in 0..self.config.iterations {
let mut perturbations: Vec<Vec<f64>> = Vec::with_capacity(pairs);
let mut differences: Vec<f64> = Vec::with_capacity(pairs);
let mut evaluations: Vec<f64> = Vec::with_capacity(2 * pairs);
for _ in 0..pairs {
let epsilon = standard_normal_vector(&mut rng, dimension);
let mut plus = weights.clone();
let mut minus = weights.clone();
for i in 0..dimension {
plus[i] += self.config.sigma * epsilon[i];
minus[i] -= self.config.sigma * epsilon[i];
}
probe.set_weight_vector(&plus)?;
let loss_plus = self.meta_loss(&probe, tasks)?;
probe.set_weight_vector(&minus)?;
let loss_minus = self.meta_loss(&probe, tasks)?;
evaluations.push(loss_plus);
evaluations.push(loss_minus);
differences.push(0.5 * (loss_plus - loss_minus));
perturbations.push(epsilon);
}
let spread = population_std(&evaluations);
let scale = if spread > 1e-12 { 1.0 / spread } else { 1.0 };
let mut gradient = vec![0.0; dimension];
for (epsilon, difference) in perturbations.iter().zip(differences.iter()) {
let weight = difference * scale;
for i in 0..dimension {
gradient[i] += weight * epsilon[i];
}
}
let normaliser = 1.0 / (pairs as f64 * self.config.sigma);
for value in gradient.iter_mut() {
*value *= normaliser;
}
gradient_norms.push(gradient.iter().map(|g| g * g).sum::<f64>().sqrt());
let step = iteration as i32 + 1;
let bias1 = 1.0 - BETA1.powi(step);
let bias2 = 1.0 - BETA2.powi(step);
for i in 0..dimension {
moment1[i] = BETA1 * moment1[i] + (1.0 - BETA1) * gradient[i];
moment2[i] = BETA2 * moment2[i] + (1.0 - BETA2) * gradient[i] * gradient[i];
let m_hat = moment1[i] / bias1;
let v_hat = moment2[i] / bias2;
weights[i] -= self.config.meta_learning_rate * m_hat / (v_hat.sqrt() + EPSILON);
}
probe.set_weight_vector(&weights)?;
let training_loss = self.meta_loss(&probe, tasks)?;
loss_history.push(training_loss);
let validation_loss = if validation.is_empty() {
None
} else {
let value = self.meta_loss(&probe, validation)?;
validation_history.push(value);
Some(value)
};
let selection = match selection_metric {
SelectionMetric::TrainingLoss => training_loss,
SelectionMetric::ValidationLoss => validation_loss.unwrap_or(training_loss),
};
if selection < best_selection {
best_selection = selection;
best_training = training_loss;
best_validation = validation_loss;
best_weights = weights.clone();
}
}
optimizer.set_weight_vector(&best_weights)?;
optimizer.reset_state();
Ok(MetaTrainingReport {
iterations: self.config.iterations,
initial_meta_loss,
final_meta_loss: best_training,
loss_history,
initial_validation_loss,
final_validation_loss: best_validation,
validation_history,
selection_metric,
gradient_norms,
weight_count: dimension,
})
}
}
fn population_std(values: &[f64]) -> f64 {
if values.len() < 2 {
return 0.0;
}
let n = values.len() as f64;
let mean = values.iter().sum::<f64>() / n;
let variance = values.iter().map(|v| (v - mean) * (v - mean)).sum::<f64>() / n;
variance.sqrt()
}
pub(crate) fn standard_normal_vector<R: Rng>(rng: &mut Random<R>, len: usize) -> Vec<f64> {
let mut out = Vec::with_capacity(len);
while out.len() < len {
let u1: f64 = rng.random_range(f64::MIN_POSITIVE..1.0);
let u2: f64 = rng.random_range(0.0..1.0);
let radius = (-2.0 * u1.ln()).sqrt();
let angle = std::f64::consts::TAU * u2;
out.push(radius * angle.cos());
if out.len() < len {
out.push(radius * angle.sin());
}
}
out
}
pub(crate) fn push_matrix<T: Float>(matrix: &Array2<T>, out: &mut Vec<f64>) {
out.extend(matrix.iter().map(|v| v.to_f64().unwrap_or(0.0)));
}
pub(crate) fn push_vector<T: Float>(vector: &Array1<T>, out: &mut Vec<f64>) {
out.extend(vector.iter().map(|v| v.to_f64().unwrap_or(0.0)));
}
pub(crate) fn push_scalar<T: Float>(scalar: T, out: &mut Vec<f64>) {
out.push(scalar.to_f64().unwrap_or(0.0));
}
pub(crate) fn pull_matrix<T: Float>(
matrix: &mut Array2<T>,
flat: &[f64],
cursor: &mut usize,
) -> Result<()> {
let end = *cursor + matrix.len();
let slice = take(flat, *cursor, end)?;
for (target, value) in matrix.iter_mut().zip(slice.iter()) {
*target = scirs2_core::numeric::NumCast::from(*value).unwrap_or_else(|| T::zero());
}
*cursor = end;
Ok(())
}
pub(crate) fn pull_vector<T: Float>(
vector: &mut Array1<T>,
flat: &[f64],
cursor: &mut usize,
) -> Result<()> {
let end = *cursor + vector.len();
let slice = take(flat, *cursor, end)?;
for (target, value) in vector.iter_mut().zip(slice.iter()) {
*target = scirs2_core::numeric::NumCast::from(*value).unwrap_or_else(|| T::zero());
}
*cursor = end;
Ok(())
}
pub(crate) fn pull_scalar<T: Float>(
scalar: &mut T,
flat: &[f64],
cursor: &mut usize,
) -> Result<()> {
let slice = take(flat, *cursor, *cursor + 1)?;
*scalar = scirs2_core::numeric::NumCast::from(slice[0]).unwrap_or_else(|| T::zero());
*cursor += 1;
Ok(())
}
fn take(flat: &[f64], start: usize, end: usize) -> Result<&[f64]> {
flat.get(start..end).ok_or_else(|| {
OptimError::InvalidConfig(format!(
"weight vector is too short: needed {end} values, got {}",
flat.len()
))
})
}
pub(crate) fn expect_fully_consumed(flat: &[f64], cursor: usize) -> Result<()> {
if cursor != flat.len() {
return Err(OptimError::InvalidConfig(format!(
"weight vector has {} values but this architecture needs exactly {cursor}",
flat.len()
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn box_muller_produces_standard_normals() {
let mut rng = Random::seed(7);
let samples = standard_normal_vector(&mut rng, 4000);
assert_eq!(samples.len(), 4000);
assert!(samples.iter().all(|v| v.is_finite()));
let mean = samples.iter().sum::<f64>() / samples.len() as f64;
let variance =
samples.iter().map(|v| (v - mean) * (v - mean)).sum::<f64>() / samples.len() as f64;
assert!(mean.abs() < 0.08, "mean {mean} is not ~0");
assert!(
(variance - 1.0).abs() < 0.12,
"variance {variance} is not ~1"
);
assert_eq!(standard_normal_vector(&mut rng, 7).len(), 7);
}
#[test]
fn population_std_matches_the_definition() {
assert_eq!(population_std(&[]), 0.0);
assert_eq!(population_std(&[3.0]), 0.0);
let observed = population_std(&[1.0, 2.0, 3.0, 4.0]);
assert!((observed - 1.25_f64.sqrt()).abs() < 1e-12, "{observed}");
}
#[test]
fn bad_configurations_are_rejected() {
for config in [
MetaTrainingConfig {
population: 1,
..Default::default()
},
MetaTrainingConfig {
iterations: 0,
..Default::default()
},
MetaTrainingConfig {
sigma: 0.0,
..Default::default()
},
MetaTrainingConfig {
sigma: f64::NAN,
..Default::default()
},
MetaTrainingConfig {
meta_learning_rate: -1.0,
..Default::default()
},
MetaTrainingConfig {
horizon: 0,
..Default::default()
},
] {
assert!(EsMetaTrainer::new(config).is_err());
}
let trainer = EsMetaTrainer::new(MetaTrainingConfig::default()).expect("default is valid");
assert_eq!(trainer.config().population, 8);
}
#[test]
fn odd_populations_round_up_to_whole_mirrored_pairs() {
let config = MetaTrainingConfig {
population: 7,
..Default::default()
};
assert_eq!(config.pairs(), 4, "7 perturbations means 4 mirrored pairs");
}
#[test]
fn flat_plumbing_round_trips_and_rejects_bad_lengths() {
use scirs2_core::ndarray::{Array1, Array2};
let matrix = Array2::from_shape_fn((2, 3), |(i, j)| (i * 3 + j) as f64);
let vector = Array1::from_vec(vec![10.0, 11.0]);
let scalar = 12.5_f64;
let mut flat = Vec::new();
push_matrix(&matrix, &mut flat);
push_vector(&vector, &mut flat);
push_scalar(scalar, &mut flat);
assert_eq!(flat.len(), 6 + 2 + 1);
let mut out_matrix = Array2::<f64>::zeros((2, 3));
let mut out_vector = Array1::<f64>::zeros(2);
let mut out_scalar = 0.0_f64;
let mut cursor = 0usize;
pull_matrix(&mut out_matrix, &flat, &mut cursor).expect("matrix");
pull_vector(&mut out_vector, &flat, &mut cursor).expect("vector");
pull_scalar(&mut out_scalar, &flat, &mut cursor).expect("scalar");
expect_fully_consumed(&flat, cursor).expect("consumed");
assert_eq!(out_matrix, matrix);
assert_eq!(out_vector, vector);
assert_eq!(out_scalar, scalar);
let mut cursor = 0usize;
let mut target = Array2::<f64>::zeros((2, 3));
assert!(pull_matrix(&mut target, &flat[..4], &mut cursor).is_err());
assert!(expect_fully_consumed(&flat, 3).is_err());
}
}