Skip to main content

optirs_learned/ntm_optimizer/
meta_training.rs

1//! [`crate::es_meta_training::MetaTrainable`] implementation for [`NtmOptimizer`] (finding F75).
2//!
3//! The generic evolution-strategies loop lives in [`crate::es_meta_training`],
4//! which also explains why ES rather than backpropagation. This file supplies the
5//! three architecture-specific pieces the loop needs: flatten the NTM controller's
6//! 19 learned weight blocks into one vector, load them back, and clear the
7//! persistent per-rollout state.
8//!
9//! # What counts as a weight and what counts as state
10//!
11//! This distinction matters more for the NTM than for any other optimizer in the
12//! crate, because the *memory matrix* looks like a parameter and is not one:
13//!
14//! * **Learned weights** (trained here): the controller's input encoder, the seven
15//!   head projections (key, β, gate, shift, γ, erase, add) and the output-scale
16//!   readout. These are what the meta-gradient acts on.
17//! * **State** (reset between rollouts): the memory matrix, the previous
18//!   addressing weights, the previous read vector, the gradient EMAs, the chunk
19//!   layout and the step count. The memory is *written by* the erase-add rule
20//!   during a rollout, so carrying it between tasks would let one task's memory
21//!   contents leak into another's score — and would make the meta-objective depend
22//!   on task ordering. [`NtmOptimizer::reset_state`] restores it to the value the
23//!   seed produced at construction, kept for exactly this purpose.
24//!
25//! Before this existed, the controller weights were drawn once from the configured
26//! seed and never changed again, so every "learned" update came out of a
27//! randomly-initialised controller.
28
29use scirs2_core::ndarray::Array1;
30use scirs2_core::numeric::Float;
31use std::fmt::Debug;
32
33use super::{NtmController, NtmOptimizer};
34use crate::error::Result;
35use crate::es_meta_training::{
36    expect_fully_consumed, pull_matrix, pull_scalar, pull_vector, push_matrix, push_scalar,
37    push_vector, MetaTrainable,
38};
39
40/// Convenience alias: the ES trainer, named for the architecture it is usually
41/// pointed at here. It is not NTM-specific — see [`crate::es_meta_training`].
42pub use crate::es_meta_training::EsMetaTrainer as NtmMetaTrainer;
43pub use crate::es_meta_training::{MetaTrainingConfig, MetaTrainingReport, SelectionMetric};
44
45impl<T: Float + Debug + Send + Sync + 'static> NtmController<T> {
46    /// Flatten all 19 learned blocks into one vector, in a stable order.
47    fn to_flat(&self) -> Vec<f64> {
48        let mut out = Vec::new();
49        push_matrix(&self.w_in, &mut out);
50        push_vector(&self.b_in, &mut out);
51        push_matrix(&self.w_key, &mut out);
52        push_vector(&self.b_key, &mut out);
53        push_vector(&self.w_beta, &mut out);
54        push_scalar(self.b_beta, &mut out);
55        push_vector(&self.w_gate, &mut out);
56        push_scalar(self.b_gate, &mut out);
57        push_matrix(&self.w_shift, &mut out);
58        push_vector(&self.b_shift, &mut out);
59        push_vector(&self.w_gamma, &mut out);
60        push_scalar(self.b_gamma, &mut out);
61        push_matrix(&self.w_erase, &mut out);
62        push_vector(&self.b_erase, &mut out);
63        push_matrix(&self.w_add, &mut out);
64        push_vector(&self.b_add, &mut out);
65        push_vector(&self.w_out, &mut out);
66        push_vector(&self.v_out, &mut out);
67        push_scalar(self.b_out, &mut out);
68        out
69    }
70
71    /// Load all 19 learned blocks from a vector produced by [`Self::to_flat`].
72    fn load_flat(&mut self, flat: &[f64]) -> Result<()> {
73        let mut cursor = 0usize;
74        pull_matrix(&mut self.w_in, flat, &mut cursor)?;
75        pull_vector(&mut self.b_in, flat, &mut cursor)?;
76        pull_matrix(&mut self.w_key, flat, &mut cursor)?;
77        pull_vector(&mut self.b_key, flat, &mut cursor)?;
78        pull_vector(&mut self.w_beta, flat, &mut cursor)?;
79        pull_scalar(&mut self.b_beta, flat, &mut cursor)?;
80        pull_vector(&mut self.w_gate, flat, &mut cursor)?;
81        pull_scalar(&mut self.b_gate, flat, &mut cursor)?;
82        pull_matrix(&mut self.w_shift, flat, &mut cursor)?;
83        pull_vector(&mut self.b_shift, flat, &mut cursor)?;
84        pull_vector(&mut self.w_gamma, flat, &mut cursor)?;
85        pull_scalar(&mut self.b_gamma, flat, &mut cursor)?;
86        pull_matrix(&mut self.w_erase, flat, &mut cursor)?;
87        pull_vector(&mut self.b_erase, flat, &mut cursor)?;
88        pull_matrix(&mut self.w_add, flat, &mut cursor)?;
89        pull_vector(&mut self.b_add, flat, &mut cursor)?;
90        pull_vector(&mut self.w_out, flat, &mut cursor)?;
91        pull_vector(&mut self.v_out, flat, &mut cursor)?;
92        pull_scalar(&mut self.b_out, flat, &mut cursor)?;
93        expect_fully_consumed(flat, cursor)
94    }
95}
96
97impl<T: Float + Debug + Send + Sync + 'static> NtmOptimizer<T> {
98    /// Every learned controller weight, flattened in a stable order.
99    ///
100    /// The **memory matrix is deliberately excluded**: it is written by the
101    /// erase-add rule during a rollout, so it is state, not a parameter. Including
102    /// it would let the trainer optimize a starting memory that the forward path
103    /// immediately overwrites.
104    pub fn weight_vector(&self) -> Vec<f64> {
105        self.controller.to_flat()
106    }
107
108    /// Number of learned controller weights.
109    pub fn weight_count(&self) -> usize {
110        self.controller.to_flat().len()
111    }
112
113    /// Overwrite every learned controller weight from a vector laid out like
114    /// [`Self::weight_vector`]'s output.
115    ///
116    /// # Errors
117    /// Returns [`crate::error::OptimError::InvalidConfig`] if `flat` is not
118    /// exactly the right length for this architecture — a silent partial load
119    /// would leave the controller in a state no caller could reason about.
120    pub fn set_weight_vector(&mut self, flat: &[f64]) -> Result<()> {
121        self.controller.load_flat(flat)
122    }
123
124    /// Clear the persistent state — memory, addressing weights, read vector,
125    /// gradient EMAs, chunk layout, step count — while keeping the learned
126    /// controller weights.
127    ///
128    /// The memory is restored to the value the construction seed produced, not
129    /// zeroed: a zero memory makes every cosine similarity in
130    /// content addressing degenerate, which is a different (and worse) starting
131    /// condition than the one the optimizer was built with.
132    pub fn reset_state(&mut self) {
133        self.memory = self.initial_memory.clone();
134        let uniform: T = scirs2_core::numeric::NumCast::from(1.0 / self.num_locations as f64)
135            .unwrap_or_else(|| T::zero());
136        self.prev_weights = Array1::from_elem(self.num_locations, uniform);
137        self.last_weights = self.prev_weights.clone();
138        self.prev_read = Array1::zeros(self.mem_width);
139        self.param_len = 0;
140        self.num_slots = 0;
141        self.chunk_bounds.clear();
142        self.grad_ema = Array1::zeros(0);
143        self.grad_sq_ema = Array1::zeros(0);
144        self.step_count = 0;
145        self.grad_norm_ema = T::zero();
146        self.current_lr = self.base_lr;
147    }
148}
149
150impl<T: Float + Debug + Send + Sync + 'static> MetaTrainable<T> for NtmOptimizer<T> {
151    fn weight_vector(&self) -> Vec<f64> {
152        NtmOptimizer::weight_vector(self)
153    }
154
155    fn set_weight_vector(&mut self, flat: &[f64]) -> Result<()> {
156        NtmOptimizer::set_weight_vector(self, flat)
157    }
158
159    fn reset_state(&mut self) {
160        NtmOptimizer::reset_state(self)
161    }
162
163    fn weight_count(&self) -> usize {
164        NtmOptimizer::weight_count(self)
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use crate::domain_objectives::{MetaObjective, QuadraticObjective};
172    use crate::domain_optimizers::AdvancedOptimizer;
173    use crate::ntm_optimizer::NtmOptimizerConfig;
174
175    fn optimizer(seed: u64) -> NtmOptimizer<f64> {
176        NtmOptimizer::new(NtmOptimizerConfig {
177            num_slots: 4,
178            num_locations: 6,
179            mem_width: 4,
180            hidden_dim: 6,
181            shift_range: 1,
182            base_lr: 0.2,
183            seed,
184            ..Default::default()
185        })
186        .expect("optimizer")
187    }
188
189    fn training_tasks() -> Vec<QuadraticObjective<f64>> {
190        vec![
191            QuadraticObjective::isotropic(8, 1.0, 0.7).expect("task"),
192            QuadraticObjective::from_curvature(vec![1.0, 4.0, 1.0, 4.0, 1.0, 4.0, 1.0, 4.0], 0.5)
193                .expect("task"),
194        ]
195    }
196
197    #[test]
198    fn weight_vector_round_trips() {
199        let mut opt = optimizer(13);
200        let flat = opt.weight_vector();
201        assert!(!flat.is_empty());
202        assert_eq!(flat.len(), opt.weight_count());
203        assert!(
204            flat.iter().any(|v| v.abs() > 1e-9),
205            "the weight vector is all zeros, so it is not reading real weights"
206        );
207
208        opt.set_weight_vector(&flat).expect("round trip");
209        assert_eq!(opt.weight_vector(), flat);
210
211        assert!(opt.set_weight_vector(&flat[..flat.len() - 1]).is_err());
212        let mut too_long = flat.clone();
213        too_long.push(0.0);
214        assert!(opt.set_weight_vector(&too_long).is_err());
215    }
216
217    /// The memory matrix is state, not a weight, and must not appear in the
218    /// flattened vector.
219    #[test]
220    fn the_memory_matrix_is_not_part_of_the_weight_vector() {
221        let opt = optimizer(13);
222        let weights = opt.weight_count();
223        let memory_cells = opt.memory().len();
224        assert!(memory_cells > 0);
225
226        // The controller is (hidden × input) + head projections; the memory is
227        // (num_locations × mem_width) = 24 cells. If the memory were included the
228        // count would change when only the memory does, so compare against a
229        // second optimizer whose memory differs (different seed changes both, so
230        // instead check the count is invariant to memory *mutation*).
231        let mut mutated = opt.clone();
232        let params = Array1::from_vec(vec![1.0; 8]);
233        let gradient = Array1::from_vec(vec![0.3; 8]);
234        for _ in 0..4 {
235            mutated.step(&params, &gradient).expect("step");
236        }
237        assert!(
238            (mutated.memory() - opt.memory()).mapv(f64::abs).sum() > 1e-12,
239            "stepping did not write to memory, so there is nothing to distinguish"
240        );
241        assert_eq!(
242            mutated.weight_count(),
243            weights,
244            "the weight count changed when only the memory did"
245        );
246        assert_eq!(
247            mutated.weight_vector(),
248            opt.weight_vector(),
249            "writing to memory changed the weight vector, so memory is being \
250             treated as a learned parameter"
251        );
252    }
253
254    /// `reset_state` must restore the seeded memory and clear momentum, so the
255    /// first step after a reset matches a fresh optimizer's first step.
256    #[test]
257    fn reset_state_restores_the_seeded_memory_and_clears_momentum() {
258        let params = Array1::from_vec(vec![1.0; 8]);
259        let gradient = Array1::from_vec(vec![0.5; 8]);
260
261        let mut fresh = optimizer(29);
262        let pristine_memory = fresh.memory().clone();
263        let baseline = fresh.step(&params, &gradient).expect("step");
264
265        let mut used = optimizer(29);
266        for _ in 0..5 {
267            used.step(&params, &gradient).expect("warm-up step");
268        }
269        assert!(
270            (used.memory() - &pristine_memory).mapv(f64::abs).sum() > 1e-12,
271            "the warm-up left the memory untouched"
272        );
273
274        used.reset_state();
275        assert!(
276            (used.memory() - &pristine_memory).mapv(f64::abs).sum() < 1e-12,
277            "reset_state did not restore the seeded memory"
278        );
279        let after_reset = used.step(&params, &gradient).expect("step");
280        assert!(
281            (&after_reset - &baseline).mapv(f64::abs).sum() < 1e-12,
282            "reset_state did not restore the fresh-optimizer behaviour"
283        );
284        assert_eq!(used.weight_vector(), fresh.weight_vector());
285    }
286
287    /// `reset_state` is public and clears the chunk layout and the gradient EMAs to
288    /// zero length. Every *other* public accessor must stay callable in between,
289    /// with no intervening `step` to rebuild them — a caller can write
290    /// `reset_state(); get_state();` and must not get a panic or a division by
291    /// zero. `last_address_weights` is the one that would return an empty array if
292    /// the reset zeroed it instead of restoring the uniform distribution.
293    #[test]
294    fn every_accessor_stays_safe_after_a_bare_reset() {
295        let mut opt = optimizer(37);
296        let params = Array1::from_vec(vec![1.0; 8]);
297        let gradient = Array1::from_vec(vec![0.25; 8]);
298        for _ in 0..3 {
299            opt.step(&params, &gradient).expect("warm-up");
300        }
301
302        opt.reset_state();
303
304        let state = opt.get_state();
305        assert_eq!(state.step_count, 0);
306        assert!(state.current_lr.is_finite());
307        assert!(state.grad_norm_ema.is_finite());
308        assert_eq!(opt.get_learning_rate(), state.current_lr);
309        // Documented to be 0 before the first step.
310        assert_eq!(opt.num_slots(), 0);
311        assert_eq!(opt.num_locations(), 6);
312        assert_eq!(opt.mem_width(), 4);
313        assert!(opt.hidden_dim() > 0);
314        assert_eq!(opt.shift_range(), 1);
315        assert_eq!(opt.seed(), 37);
316        assert_eq!(opt.name(), "NtmOptimizer");
317        // The addressing state must be a usable distribution, not an empty array.
318        let weights = opt.last_address_weights();
319        assert_eq!(weights.len(), opt.num_locations());
320        let total: f64 = weights.iter().sum();
321        assert!(
322            (total - 1.0).abs() < 1e-12,
323            "addressing weights should sum to 1 after a reset, got {total}"
324        );
325        assert_eq!(opt.memory().dim(), (6, 4));
326        assert!(!opt.weight_vector().is_empty());
327
328        // Idempotent, and still usable afterwards.
329        opt.reset_state();
330        assert_eq!(opt.num_slots(), 0);
331        assert!(opt.step(&params, &gradient).is_ok());
332        assert!(opt.num_slots() > 0);
333    }
334
335    #[test]
336    fn meta_loss_is_a_real_measurement() {
337        let tasks = training_tasks();
338        let refs: Vec<&dyn MetaObjective<f64>> =
339            tasks.iter().map(|t| t as &dyn MetaObjective<f64>).collect();
340        let trainer = NtmMetaTrainer::new(MetaTrainingConfig::default()).expect("trainer");
341
342        let a = trainer.meta_loss(&optimizer(1), &refs).expect("loss a");
343        let b = trainer.meta_loss(&optimizer(2), &refs).expect("loss b");
344        assert!(a.is_finite() && b.is_finite() && a > 0.0 && b > 0.0);
345        assert!(
346            (a - b).abs() > 1e-9,
347            "two different controller draws scored identically ({a} vs {b})"
348        );
349
350        let opt = optimizer(1);
351        let snapshot = opt.weight_vector();
352        let repeat = trainer.meta_loss(&opt, &refs).expect("repeat");
353        assert_eq!(opt.weight_vector(), snapshot);
354        assert!((repeat - a).abs() < 1e-12, "meta_loss is not deterministic");
355    }
356}