optirs_learned/es_meta_training.rs
1//! Meta-training learned optimizers by evolution strategies.
2//!
3//! # What finding F75 was about
4//!
5//! [`GnnOptimizer`](crate::gnn_optimizer::GnnOptimizer) and
6//! [`NtmOptimizer`](crate::ntm_optimizer::NtmOptimizer) both have real forward
7//! maths — a genuine message-passing round with a GRU node update, and genuine
8//! NTM content/location addressing with erase-add memory writes. What was missing
9//! is that **nothing ever trained their weights**. Both drew their controller
10//! weights from the configured seed at construction and kept that draw forever, so
11//! "learned optimizer" meant "randomly initialised optimizer" in every use. The
12//! module docs disclosed it honestly, but a disclosed gap is still a gap.
13//!
14//! # Why evolution strategies and not backpropagation
15//!
16//! Meta-training a learned optimizer by gradients means differentiating through
17//! the unrolled inner optimization: `∂L_meta/∂w` has to flow back through
18//! `horizon` applications of
19//! [`AdvancedOptimizer::step`],
20//! each of which contains a recurrent cell, a nonlinear addressing or aggregation
21//! stage and a nonlinear readout. That is the truncated-BPTT machinery
22//! `lstm::bptt` (behind the `lstm` feature) implements for the LSTM controller,
23//! and doing it here
24//! would mean hand-deriving and maintaining a separate adjoint for each of two
25//! quite different architectures.
26//!
27//! Evolution strategies ([Salimans et al., 2017](https://arxiv.org/abs/1703.03864))
28//! estimate the same `∂L_meta/∂w` from forward rollouts alone:
29//!
30//! ```text
31//! ∇_w E[L(w + σ·ε)] = (1/σ) · E[ L(w + σ·ε) · ε ], ε ~ N(0, I)
32//! ```
33//!
34//! evaluated here with **antithetic (mirrored) pairs**, which cancels the
35//! leading term of the estimator's variance:
36//!
37//! ```text
38//! ĝ = (1 / (pairs · σ)) · Σₖ ½·(L(w + σ·εₖ) − L(w − σ·εₖ)) · εₖ
39//! ```
40//!
41//! This is a real gradient estimator of a real objective, not a surrogate: every
42//! loss fed into it comes from actually running the optimizer on an actual task.
43//! It is far less sample-efficient than BPTT and is **not** presented as
44//! equivalent — [`MetaTrainingReport`] reports the measured meta-loss before and
45//! after, so a caller can see exactly what the training bought.
46//!
47//! # Meta-objective
48//!
49//! For each task the trainer rolls the optimizer out for `horizon` steps from the
50//! task's own start point and accumulates the loss curve, normalised by the
51//! initial loss:
52//!
53//! ```text
54//! L_task = (1 / horizon) · Σ_{t=1..horizon} loss_t / loss_0
55//! ```
56//!
57//! so `1.0` means "no progress", `0.0` means "solved immediately", and tasks of
58//! very different scale contribute comparably. `L_meta` is the mean over tasks. A
59//! non-finite rollout (a diverged optimizer) scores a large finite penalty rather
60//! than poisoning the average with `NaN`.
61
62use scirs2_core::ndarray::{Array1, Array2};
63use scirs2_core::numeric::Float;
64use scirs2_core::random::{Random, Rng};
65use std::fmt::Debug;
66
67use crate::domain_objectives::MetaObjective;
68use crate::domain_optimizers::AdvancedOptimizer;
69use crate::error::{OptimError, Result};
70
71/// Score assigned to a rollout that produced a non-finite loss.
72///
73/// Large enough to be strictly worse than any sane rollout (whose normalised
74/// score is bounded by ~1 unless the optimizer actively increases the loss), and
75/// finite so it cannot poison the population mean.
76const DIVERGENCE_PENALTY: f64 = 1.0e3;
77
78/// A learned optimizer whose weights an [`EsMetaTrainer`] can train.
79///
80/// The three requirements beyond [`AdvancedOptimizer`] are exactly what an ES
81/// loop needs: read the weights out as a flat vector, write a candidate vector
82/// back, and clear the persistent per-rollout state so tasks cannot contaminate
83/// each other.
84pub trait MetaTrainable<T>: AdvancedOptimizer<T> + Clone
85where
86 T: Float + Debug + Send + Sync + 'static,
87{
88 /// Every learned weight, flattened in an order that is stable across calls
89 /// for a given architecture, so that
90 /// `set_weight_vector(&weight_vector())` is the identity.
91 fn weight_vector(&self) -> Vec<f64>;
92
93 /// Overwrite every learned weight from a vector laid out like
94 /// [`Self::weight_vector`]'s output.
95 ///
96 /// # Errors
97 /// Must return an error — never partially apply — if `flat` is not exactly
98 /// the right length for this architecture.
99 fn set_weight_vector(&mut self, flat: &[f64]) -> Result<()>;
100
101 /// Clear the persistent optimization state (momentum, hidden vectors,
102 /// memory, step count) while keeping the learned weights.
103 fn reset_state(&mut self);
104
105 /// Number of learned weights.
106 fn weight_count(&self) -> usize {
107 self.weight_vector().len()
108 }
109}
110
111/// Hyper-parameters for [`EsMetaTrainer`].
112#[derive(Debug, Clone)]
113pub struct MetaTrainingConfig {
114 /// Number of weight perturbations evaluated per iteration. Rounded **up** to
115 /// the next even number, because perturbations are drawn in mirrored pairs.
116 pub population: usize,
117 /// Number of ES iterations.
118 pub iterations: usize,
119 /// Perturbation standard deviation `σ`. Too small and the finite-difference
120 /// signal drowns in rollout noise; too large and the estimate stops being the
121 /// gradient of anything local.
122 pub sigma: f64,
123 /// Adam step size applied to the estimated meta-gradient.
124 pub meta_learning_rate: f64,
125 /// Inner-loop rollout length: how many optimizer steps each evaluation runs.
126 pub horizon: usize,
127 /// Seed for the perturbation draws, so a training run is reproducible.
128 pub seed: u64,
129}
130
131impl Default for MetaTrainingConfig {
132 fn default() -> Self {
133 Self {
134 population: 8,
135 iterations: 20,
136 sigma: 0.05,
137 meta_learning_rate: 0.05,
138 horizon: 20,
139 seed: 0x11E7_A5EE,
140 }
141 }
142}
143
144impl MetaTrainingConfig {
145 /// Validate the configuration.
146 fn validate(&self) -> Result<()> {
147 if self.population < 2 {
148 return Err(OptimError::InvalidConfig(
149 "population must be at least 2 (perturbations come in mirrored pairs)".to_string(),
150 ));
151 }
152 if self.iterations == 0 {
153 return Err(OptimError::InvalidConfig(
154 "iterations must be at least 1".to_string(),
155 ));
156 }
157 if !(self.sigma.is_finite() && self.sigma > 0.0) {
158 return Err(OptimError::InvalidConfig(
159 "sigma must be finite and positive".to_string(),
160 ));
161 }
162 if !(self.meta_learning_rate.is_finite() && self.meta_learning_rate > 0.0) {
163 return Err(OptimError::InvalidConfig(
164 "meta_learning_rate must be finite and positive".to_string(),
165 ));
166 }
167 if self.horizon == 0 {
168 return Err(OptimError::InvalidConfig(
169 "horizon must be at least 1".to_string(),
170 ));
171 }
172 Ok(())
173 }
174
175 /// Number of mirrored pairs actually evaluated.
176 fn pairs(&self) -> usize {
177 self.population.div_ceil(2)
178 }
179}
180
181/// Which meta-loss the trainer used to decide which weights to keep.
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
183pub enum SelectionMetric {
184 /// No validation tasks were supplied, so the training meta-loss chose the
185 /// weights. This measures fit, not generalisation: an ES run can drive the
186 /// training score down while making the optimizer *worse* on tasks outside
187 /// the training family, and selecting on this metric cannot detect that.
188 TrainingLoss,
189 /// A held-out validation set chose the weights, so the kept weights are the
190 /// ones that generalised best among those visited.
191 ValidationLoss,
192}
193
194/// What a meta-training run measured.
195///
196/// Every field is an observation, not a target: `final_meta_loss` is the value the
197/// kept weights actually achieve on the training tasks, and [`Self::improved`] is
198/// the honest answer to "did this help", judged by whichever criterion
199/// [`Self::selection_metric`] names.
200#[derive(Debug, Clone)]
201pub struct MetaTrainingReport {
202 /// ES iterations completed.
203 pub iterations: usize,
204 /// Training meta-loss of the weights the trainer started from.
205 pub initial_meta_loss: f64,
206 /// Training meta-loss of the weights the trainer kept.
207 pub final_meta_loss: f64,
208 /// Training meta-loss after each iteration, in order.
209 pub loss_history: Vec<f64>,
210 /// Validation meta-loss of the starting weights, if validation tasks were
211 /// supplied.
212 pub initial_validation_loss: Option<f64>,
213 /// Validation meta-loss of the weights the trainer kept.
214 pub final_validation_loss: Option<f64>,
215 /// Validation meta-loss after each iteration, if validation tasks were
216 /// supplied.
217 pub validation_history: Vec<f64>,
218 /// Which loss chose the kept weights.
219 pub selection_metric: SelectionMetric,
220 /// Euclidean norm of the estimated meta-gradient at each iteration.
221 pub gradient_norms: Vec<f64>,
222 /// Number of learned weights being optimized.
223 pub weight_count: usize,
224}
225
226impl MetaTrainingReport {
227 /// The initial and final values of whichever loss chose the weights.
228 fn selection_pair(&self) -> (f64, f64) {
229 match self.selection_metric {
230 SelectionMetric::TrainingLoss => (self.initial_meta_loss, self.final_meta_loss),
231 SelectionMetric::ValidationLoss => (
232 self.initial_validation_loss
233 .unwrap_or(self.initial_meta_loss),
234 self.final_validation_loss.unwrap_or(self.final_meta_loss),
235 ),
236 }
237 }
238
239 /// Whether the run left the optimizer better than it found it, judged by the
240 /// selection metric.
241 ///
242 /// # This is weaker than it sounds under [`SelectionMetric::TrainingLoss`]
243 ///
244 /// With no validation tasks there is nothing to compare the training loss
245 /// against, so `improved() == true` means only "it fits the training tasks
246 /// better" — which is exactly what an *overfitting* run also reports. The NTM
247 /// controller was measured driving its training meta-loss down ~30% while
248 /// making a held-out task 40x worse; that run returns `true` here, and under
249 /// `TrainingLoss` [`Self::relative_improvement`] and
250 /// [`Self::relative_training_improvement`] are the same number, so the report
251 /// carries no signal that separates the two cases.
252 ///
253 /// To get a claim about generalisation, train through
254 /// [`EsMetaTrainer::train_with_validation`]: the metric becomes
255 /// [`SelectionMetric::ValidationLoss`], and the gap between
256 /// `relative_improvement()` and `relative_training_improvement()` becomes the
257 /// overfitting measurement. Check [`Self::selection_metric`] before trusting
258 /// this method as a generalisation result.
259 pub fn improved(&self) -> bool {
260 let (initial, final_) = self.selection_pair();
261 final_ < initial
262 }
263
264 /// Fractional reduction in the selection metric (negative if training made it
265 /// worse).
266 pub fn relative_improvement(&self) -> f64 {
267 let (initial, final_) = self.selection_pair();
268 if initial.abs() <= f64::EPSILON {
269 return 0.0;
270 }
271 (initial - final_) / initial.abs()
272 }
273
274 /// Fractional reduction in the *training* meta-loss specifically.
275 ///
276 /// Comparing this against [`Self::relative_improvement`] under
277 /// [`SelectionMetric::ValidationLoss`] is how a caller sees overfitting: a
278 /// large training improvement next to a small validation one means the run
279 /// specialised to the training family.
280 pub fn relative_training_improvement(&self) -> f64 {
281 if self.initial_meta_loss.abs() <= f64::EPSILON {
282 return 0.0;
283 }
284 (self.initial_meta_loss - self.final_meta_loss) / self.initial_meta_loss.abs()
285 }
286}
287
288/// Evolution-strategies meta-trainer for any [`MetaTrainable`] optimizer.
289#[derive(Debug, Clone)]
290pub struct EsMetaTrainer {
291 config: MetaTrainingConfig,
292}
293
294impl EsMetaTrainer {
295 /// Build a trainer.
296 ///
297 /// # Errors
298 /// Returns [`OptimError::InvalidConfig`] for an out-of-range hyper-parameter.
299 pub fn new(config: MetaTrainingConfig) -> Result<Self> {
300 config.validate()?;
301 Ok(Self { config })
302 }
303
304 /// The configuration in use.
305 pub fn config(&self) -> &MetaTrainingConfig {
306 &self.config
307 }
308
309 /// Roll `optimizer` out on every task and return the mean normalised loss
310 /// curve — the meta-objective, measured, with no training involved.
311 ///
312 /// The optimizer's persistent state is reset before each task, so tasks do not
313 /// contaminate each other and the score depends only on the weights. The
314 /// optimizer passed in is never mutated.
315 ///
316 /// # Errors
317 /// Returns an error if `tasks` is empty, if a task is internally inconsistent,
318 /// or if the objective cannot be evaluated.
319 pub fn meta_loss<T, O>(&self, optimizer: &O, tasks: &[&dyn MetaObjective<T>]) -> Result<f64>
320 where
321 T: Float + Debug + Send + Sync + 'static,
322 O: MetaTrainable<T>,
323 {
324 if tasks.is_empty() {
325 return Err(OptimError::InsufficientData(
326 "meta_loss needs at least one task".to_string(),
327 ));
328 }
329 let mut total = 0.0;
330 for task in tasks {
331 total += self.rollout_score(optimizer, *task)?;
332 }
333 Ok(total / tasks.len() as f64)
334 }
335
336 /// One task's normalised loss-curve score.
337 fn rollout_score<T, O>(&self, optimizer: &O, task: &dyn MetaObjective<T>) -> Result<f64>
338 where
339 T: Float + Debug + Send + Sync + 'static,
340 O: MetaTrainable<T>,
341 {
342 let mut runner = optimizer.clone();
343 runner.reset_state();
344
345 let mut params = task.initial_parameters();
346 if params.len() != task.dimension() {
347 return Err(OptimError::InvalidConfig(format!(
348 "task '{}' reported dimension {} but produced {} initial parameters",
349 task.name(),
350 task.dimension(),
351 params.len()
352 )));
353 }
354
355 let (initial_loss, _) = task.loss_and_gradient(¶ms)?;
356 let initial = initial_loss.to_f64().unwrap_or(f64::NAN);
357 if !initial.is_finite() || initial.abs() <= f64::EPSILON {
358 // Nothing to improve on (or an unusable task): score it neutrally
359 // rather than dividing by ~0 and manufacturing an enormous gradient.
360 return Ok(1.0);
361 }
362
363 let mut accumulated = 0.0;
364 for _ in 0..self.config.horizon {
365 let (_, gradient) = task.loss_and_gradient(¶ms)?;
366 params = runner.step(¶ms, &gradient)?;
367 let (loss, _) = task.loss_and_gradient(¶ms)?;
368 let value = loss.to_f64().unwrap_or(f64::NAN);
369 if !value.is_finite() {
370 return Ok(DIVERGENCE_PENALTY);
371 }
372 accumulated += value / initial;
373 }
374 let score = accumulated / self.config.horizon as f64;
375 if score.is_finite() {
376 Ok(score)
377 } else {
378 Ok(DIVERGENCE_PENALTY)
379 }
380 }
381
382 /// Meta-train `optimizer`'s weights in place, selecting on the training loss.
383 ///
384 /// Equivalent to [`Self::train_with_validation`] with no validation tasks.
385 /// **Prefer the validation form**: selecting on the training loss measures fit,
386 /// not generalisation, and an ES run can drive the training score down while
387 /// making the optimizer strictly worse outside the training family. The NTM
388 /// controller does exactly that on a family of quadratics — training loss down
389 /// ~30%, held-out loss up by a factor of 40 — which is why the validation
390 /// entry point exists.
391 ///
392 /// # Errors
393 /// Returns an error if `tasks` is empty, if the optimizer reports no learned
394 /// weights, if a task is inconsistent, or if the objective cannot be
395 /// evaluated.
396 pub fn train<T, O>(
397 &self,
398 optimizer: &mut O,
399 tasks: &[&dyn MetaObjective<T>],
400 ) -> Result<MetaTrainingReport>
401 where
402 T: Float + Debug + Send + Sync + 'static,
403 O: MetaTrainable<T>,
404 {
405 self.train_with_validation(optimizer, tasks, &[])
406 }
407
408 /// Meta-train `optimizer`'s weights in place, selecting the kept weights by
409 /// their score on `validation`.
410 ///
411 /// The ES gradient is always estimated from `tasks`; `validation` only decides
412 /// *which* of the visited weight vectors is kept. That separation is what makes
413 /// the result a generalisation claim: the trainer never optimizes against the
414 /// validation tasks, it only measures against them.
415 ///
416 /// The best weights *seen* are kept, never simply the last ones visited — an ES
417 /// run on a noisy objective can wander uphill, and silently returning weights
418 /// worse than the caller handed in would be the same class of dishonesty this
419 /// module exists to remove. [`MetaTrainingReport::improved`] reports which
420 /// happened, and comparing it with
421 /// [`MetaTrainingReport::relative_training_improvement`] exposes overfitting.
422 ///
423 /// Pass an empty `validation` slice to select on the training loss instead.
424 ///
425 /// # Errors
426 /// Returns an error if `tasks` is empty, if the optimizer reports no learned
427 /// weights, if a task is inconsistent, or if an objective cannot be evaluated.
428 pub fn train_with_validation<T, O>(
429 &self,
430 optimizer: &mut O,
431 tasks: &[&dyn MetaObjective<T>],
432 validation: &[&dyn MetaObjective<T>],
433 ) -> Result<MetaTrainingReport>
434 where
435 T: Float + Debug + Send + Sync + 'static,
436 O: MetaTrainable<T>,
437 {
438 if tasks.is_empty() {
439 return Err(OptimError::InsufficientData(
440 "meta-training needs at least one task".to_string(),
441 ));
442 }
443
444 let baseline_weights = optimizer.weight_vector();
445 let dimension = baseline_weights.len();
446 if dimension == 0 {
447 return Err(OptimError::InvalidState(
448 "the optimizer reported zero learned weights".to_string(),
449 ));
450 }
451
452 let selection_metric = if validation.is_empty() {
453 SelectionMetric::TrainingLoss
454 } else {
455 SelectionMetric::ValidationLoss
456 };
457
458 let initial_meta_loss = self.meta_loss(optimizer, tasks)?;
459 let initial_validation_loss = if validation.is_empty() {
460 None
461 } else {
462 Some(self.meta_loss(optimizer, validation)?)
463 };
464
465 let mut weights = baseline_weights.clone();
466 let mut best_weights = baseline_weights;
467 let mut best_training = initial_meta_loss;
468 let mut best_validation = initial_validation_loss;
469 // The value being minimised for selection purposes.
470 let mut best_selection = match selection_metric {
471 SelectionMetric::TrainingLoss => initial_meta_loss,
472 SelectionMetric::ValidationLoss => initial_validation_loss.unwrap_or(initial_meta_loss),
473 };
474 let mut validation_history = Vec::with_capacity(self.config.iterations);
475
476 // Adam moments over the ES gradient estimate.
477 let mut moment1 = vec![0.0; dimension];
478 let mut moment2 = vec![0.0; dimension];
479 const BETA1: f64 = 0.9;
480 const BETA2: f64 = 0.999;
481 const EPSILON: f64 = 1e-8;
482
483 let mut rng = Random::seed(self.config.seed);
484 let pairs = self.config.pairs();
485 let mut loss_history = Vec::with_capacity(self.config.iterations);
486 let mut gradient_norms = Vec::with_capacity(self.config.iterations);
487 let mut probe = optimizer.clone();
488
489 for iteration in 0..self.config.iterations {
490 let mut perturbations: Vec<Vec<f64>> = Vec::with_capacity(pairs);
491 let mut differences: Vec<f64> = Vec::with_capacity(pairs);
492 let mut evaluations: Vec<f64> = Vec::with_capacity(2 * pairs);
493
494 for _ in 0..pairs {
495 let epsilon = standard_normal_vector(&mut rng, dimension);
496
497 let mut plus = weights.clone();
498 let mut minus = weights.clone();
499 for i in 0..dimension {
500 plus[i] += self.config.sigma * epsilon[i];
501 minus[i] -= self.config.sigma * epsilon[i];
502 }
503
504 probe.set_weight_vector(&plus)?;
505 let loss_plus = self.meta_loss(&probe, tasks)?;
506 probe.set_weight_vector(&minus)?;
507 let loss_minus = self.meta_loss(&probe, tasks)?;
508
509 evaluations.push(loss_plus);
510 evaluations.push(loss_minus);
511 differences.push(0.5 * (loss_plus - loss_minus));
512 perturbations.push(epsilon);
513 }
514
515 // Fitness normalisation (Salimans et al. §2): dividing the centred
516 // differences by their spread makes the step size independent of the
517 // objective's scale, which matters because the normalised loss curve
518 // shrinks as training succeeds.
519 let spread = population_std(&evaluations);
520 let scale = if spread > 1e-12 { 1.0 / spread } else { 1.0 };
521
522 let mut gradient = vec![0.0; dimension];
523 for (epsilon, difference) in perturbations.iter().zip(differences.iter()) {
524 let weight = difference * scale;
525 for i in 0..dimension {
526 gradient[i] += weight * epsilon[i];
527 }
528 }
529 let normaliser = 1.0 / (pairs as f64 * self.config.sigma);
530 for value in gradient.iter_mut() {
531 *value *= normaliser;
532 }
533 gradient_norms.push(gradient.iter().map(|g| g * g).sum::<f64>().sqrt());
534
535 // Adam step. ES estimates ∇L and we are minimising, so we descend.
536 let step = iteration as i32 + 1;
537 let bias1 = 1.0 - BETA1.powi(step);
538 let bias2 = 1.0 - BETA2.powi(step);
539 for i in 0..dimension {
540 moment1[i] = BETA1 * moment1[i] + (1.0 - BETA1) * gradient[i];
541 moment2[i] = BETA2 * moment2[i] + (1.0 - BETA2) * gradient[i] * gradient[i];
542 let m_hat = moment1[i] / bias1;
543 let v_hat = moment2[i] / bias2;
544 weights[i] -= self.config.meta_learning_rate * m_hat / (v_hat.sqrt() + EPSILON);
545 }
546
547 probe.set_weight_vector(&weights)?;
548 let training_loss = self.meta_loss(&probe, tasks)?;
549 loss_history.push(training_loss);
550
551 let validation_loss = if validation.is_empty() {
552 None
553 } else {
554 let value = self.meta_loss(&probe, validation)?;
555 validation_history.push(value);
556 Some(value)
557 };
558
559 let selection = match selection_metric {
560 SelectionMetric::TrainingLoss => training_loss,
561 SelectionMetric::ValidationLoss => validation_loss.unwrap_or(training_loss),
562 };
563 if selection < best_selection {
564 best_selection = selection;
565 best_training = training_loss;
566 best_validation = validation_loss;
567 best_weights = weights.clone();
568 }
569 }
570
571 optimizer.set_weight_vector(&best_weights)?;
572 optimizer.reset_state();
573
574 Ok(MetaTrainingReport {
575 iterations: self.config.iterations,
576 initial_meta_loss,
577 final_meta_loss: best_training,
578 loss_history,
579 initial_validation_loss,
580 final_validation_loss: best_validation,
581 validation_history,
582 selection_metric,
583 gradient_norms,
584 weight_count: dimension,
585 })
586 }
587}
588
589/// Population standard deviation of `values` (`0` for fewer than two samples).
590fn population_std(values: &[f64]) -> f64 {
591 if values.len() < 2 {
592 return 0.0;
593 }
594 let n = values.len() as f64;
595 let mean = values.iter().sum::<f64>() / n;
596 let variance = values.iter().map(|v| (v - mean) * (v - mean)).sum::<f64>() / n;
597 variance.sqrt()
598}
599
600/// Draw `len` independent `N(0, 1)` samples by the Box–Muller transform.
601///
602/// `scirs2_core::random` gives uniforms, and Box–Muller turns a pair of them into
603/// an exact pair of standard normals — no approximation, no extra dependency. The
604/// lower bound is nudged off zero so `ln(u)` is always finite.
605pub(crate) fn standard_normal_vector<R: Rng>(rng: &mut Random<R>, len: usize) -> Vec<f64> {
606 let mut out = Vec::with_capacity(len);
607 while out.len() < len {
608 let u1: f64 = rng.random_range(f64::MIN_POSITIVE..1.0);
609 let u2: f64 = rng.random_range(0.0..1.0);
610 let radius = (-2.0 * u1.ln()).sqrt();
611 let angle = std::f64::consts::TAU * u2;
612 out.push(radius * angle.cos());
613 if out.len() < len {
614 out.push(radius * angle.sin());
615 }
616 }
617 out
618}
619
620// ---------------------------------------------------------------------------
621// Flat weight-vector plumbing, shared by every `MetaTrainable` implementation.
622// ---------------------------------------------------------------------------
623
624/// Append every element of `matrix` to `out`, row-major.
625pub(crate) fn push_matrix<T: Float>(matrix: &Array2<T>, out: &mut Vec<f64>) {
626 out.extend(matrix.iter().map(|v| v.to_f64().unwrap_or(0.0)));
627}
628
629/// Append every element of `vector` to `out`.
630pub(crate) fn push_vector<T: Float>(vector: &Array1<T>, out: &mut Vec<f64>) {
631 out.extend(vector.iter().map(|v| v.to_f64().unwrap_or(0.0)));
632}
633
634/// Append a single scalar to `out`.
635pub(crate) fn push_scalar<T: Float>(scalar: T, out: &mut Vec<f64>) {
636 out.push(scalar.to_f64().unwrap_or(0.0));
637}
638
639/// Read `matrix.len()` values out of `flat` at `*cursor` into `matrix`, row-major.
640pub(crate) fn pull_matrix<T: Float>(
641 matrix: &mut Array2<T>,
642 flat: &[f64],
643 cursor: &mut usize,
644) -> Result<()> {
645 let end = *cursor + matrix.len();
646 let slice = take(flat, *cursor, end)?;
647 for (target, value) in matrix.iter_mut().zip(slice.iter()) {
648 *target = scirs2_core::numeric::NumCast::from(*value).unwrap_or_else(|| T::zero());
649 }
650 *cursor = end;
651 Ok(())
652}
653
654/// Read `vector.len()` values out of `flat` at `*cursor` into `vector`.
655pub(crate) fn pull_vector<T: Float>(
656 vector: &mut Array1<T>,
657 flat: &[f64],
658 cursor: &mut usize,
659) -> Result<()> {
660 let end = *cursor + vector.len();
661 let slice = take(flat, *cursor, end)?;
662 for (target, value) in vector.iter_mut().zip(slice.iter()) {
663 *target = scirs2_core::numeric::NumCast::from(*value).unwrap_or_else(|| T::zero());
664 }
665 *cursor = end;
666 Ok(())
667}
668
669/// Read one value out of `flat` at `*cursor` into `scalar`.
670pub(crate) fn pull_scalar<T: Float>(
671 scalar: &mut T,
672 flat: &[f64],
673 cursor: &mut usize,
674) -> Result<()> {
675 let slice = take(flat, *cursor, *cursor + 1)?;
676 *scalar = scirs2_core::numeric::NumCast::from(slice[0]).unwrap_or_else(|| T::zero());
677 *cursor += 1;
678 Ok(())
679}
680
681/// Borrow `flat[start..end]`, or report exactly how short the vector is.
682fn take(flat: &[f64], start: usize, end: usize) -> Result<&[f64]> {
683 flat.get(start..end).ok_or_else(|| {
684 OptimError::InvalidConfig(format!(
685 "weight vector is too short: needed {end} values, got {}",
686 flat.len()
687 ))
688 })
689}
690
691/// Fail unless the cursor consumed the whole vector.
692///
693/// A partial load would leave the network in a state no caller could reason
694/// about, so an over-long vector is an error just like a short one.
695pub(crate) fn expect_fully_consumed(flat: &[f64], cursor: usize) -> Result<()> {
696 if cursor != flat.len() {
697 return Err(OptimError::InvalidConfig(format!(
698 "weight vector has {} values but this architecture needs exactly {cursor}",
699 flat.len()
700 )));
701 }
702 Ok(())
703}
704
705#[cfg(test)]
706mod tests {
707 use super::*;
708
709 #[test]
710 fn box_muller_produces_standard_normals() {
711 let mut rng = Random::seed(7);
712 let samples = standard_normal_vector(&mut rng, 4000);
713 assert_eq!(samples.len(), 4000);
714 assert!(samples.iter().all(|v| v.is_finite()));
715 let mean = samples.iter().sum::<f64>() / samples.len() as f64;
716 let variance =
717 samples.iter().map(|v| (v - mean) * (v - mean)).sum::<f64>() / samples.len() as f64;
718 assert!(mean.abs() < 0.08, "mean {mean} is not ~0");
719 assert!(
720 (variance - 1.0).abs() < 0.12,
721 "variance {variance} is not ~1"
722 );
723 // An odd length must still be exact.
724 assert_eq!(standard_normal_vector(&mut rng, 7).len(), 7);
725 }
726
727 #[test]
728 fn population_std_matches_the_definition() {
729 assert_eq!(population_std(&[]), 0.0);
730 assert_eq!(population_std(&[3.0]), 0.0);
731 // Values 1,2,3,4: mean 2.5, variance 1.25, std sqrt(1.25).
732 let observed = population_std(&[1.0, 2.0, 3.0, 4.0]);
733 assert!((observed - 1.25_f64.sqrt()).abs() < 1e-12, "{observed}");
734 }
735
736 #[test]
737 fn bad_configurations_are_rejected() {
738 for config in [
739 MetaTrainingConfig {
740 population: 1,
741 ..Default::default()
742 },
743 MetaTrainingConfig {
744 iterations: 0,
745 ..Default::default()
746 },
747 MetaTrainingConfig {
748 sigma: 0.0,
749 ..Default::default()
750 },
751 MetaTrainingConfig {
752 sigma: f64::NAN,
753 ..Default::default()
754 },
755 MetaTrainingConfig {
756 meta_learning_rate: -1.0,
757 ..Default::default()
758 },
759 MetaTrainingConfig {
760 horizon: 0,
761 ..Default::default()
762 },
763 ] {
764 assert!(EsMetaTrainer::new(config).is_err());
765 }
766 let trainer = EsMetaTrainer::new(MetaTrainingConfig::default()).expect("default is valid");
767 assert_eq!(trainer.config().population, 8);
768 }
769
770 #[test]
771 fn odd_populations_round_up_to_whole_mirrored_pairs() {
772 let config = MetaTrainingConfig {
773 population: 7,
774 ..Default::default()
775 };
776 assert_eq!(config.pairs(), 4, "7 perturbations means 4 mirrored pairs");
777 }
778
779 #[test]
780 fn flat_plumbing_round_trips_and_rejects_bad_lengths() {
781 use scirs2_core::ndarray::{Array1, Array2};
782
783 let matrix = Array2::from_shape_fn((2, 3), |(i, j)| (i * 3 + j) as f64);
784 let vector = Array1::from_vec(vec![10.0, 11.0]);
785 let scalar = 12.5_f64;
786
787 let mut flat = Vec::new();
788 push_matrix(&matrix, &mut flat);
789 push_vector(&vector, &mut flat);
790 push_scalar(scalar, &mut flat);
791 assert_eq!(flat.len(), 6 + 2 + 1);
792
793 let mut out_matrix = Array2::<f64>::zeros((2, 3));
794 let mut out_vector = Array1::<f64>::zeros(2);
795 let mut out_scalar = 0.0_f64;
796 let mut cursor = 0usize;
797 pull_matrix(&mut out_matrix, &flat, &mut cursor).expect("matrix");
798 pull_vector(&mut out_vector, &flat, &mut cursor).expect("vector");
799 pull_scalar(&mut out_scalar, &flat, &mut cursor).expect("scalar");
800 expect_fully_consumed(&flat, cursor).expect("consumed");
801
802 assert_eq!(out_matrix, matrix);
803 assert_eq!(out_vector, vector);
804 assert_eq!(out_scalar, scalar);
805
806 // Short vector -> error, not a partial load.
807 let mut cursor = 0usize;
808 let mut target = Array2::<f64>::zeros((2, 3));
809 assert!(pull_matrix(&mut target, &flat[..4], &mut cursor).is_err());
810 // Over-long vector -> error too.
811 assert!(expect_fully_consumed(&flat, 3).is_err());
812 }
813}