Skip to main content

optirs_learned/meta_learning/
mamllearner_traits.rs

1//! # MAMLLearner - Trait Implementations
2//!
3//! Model-Agnostic Meta-Learning over the shared linear task model defined in
4//! [`super::linear_model`].
5//!
6//! * **Inner loop** — plain gradient descent on the task's *support-set* loss,
7//!   using analytic gradients.
8//! * **Outer loop** — FOMAML by default: the meta-gradient is the query-set
9//!   gradient evaluated at the *adapted* parameters. When
10//!   `MAMLConfig::second_order` is set, the exact MAML meta-gradient
11//!   `prod_j (I - alpha H_support) grad L_query(theta')` is used instead. The model
12//!   is linear in its parameters, so its MSE Hessian is constant and the
13//!   Hessian-vector product is closed-form — the second-order path is exact,
14//!   not an approximation.
15//! * **Update contract** — the learner owns the meta-update: `meta_train_step`
16//!   applies `theta <- theta - outer_lr * meta_gradient` to the map it is given
17//!   and reports the gradient it used. Callers must not apply it a second time.
18//!
19//! 🤖 Generated with [SplitRS](https://github.com/cool-japan/splitrs)
20
21use crate::error::{OptimError, Result};
22use scirs2_core::ndarray::{Array1, Dimension};
23use scirs2_core::numeric::Float;
24use std::collections::HashMap;
25use std::fmt::Debug;
26
27use super::functions::MetaLearner;
28use super::linear_model;
29use super::metrics::{mean_of, BatchObservations};
30use super::types::{
31    AdaptationStep, MAMLLearner, MetaLearningAlgorithm, MetaTask, MetaTrainingResult,
32    QueryEvaluationMetrics, QueryEvaluationResult, TaskAdaptationMetrics, TaskAdaptationResult,
33    TaskType, MAML_HISTORY_CAPACITY,
34};
35
36impl<
37        T: Float
38            + Debug
39            + 'static
40            + Default
41            + Clone
42            + Send
43            + Sync
44            + std::iter::Sum
45            + scirs2_core::ndarray::ScalarOperand,
46        D: Dimension,
47    > MAMLLearner<T, D>
48{
49    /// Meta-gradient for a single task.
50    ///
51    /// Returns `(meta_gradient, adaptation_result, query_result)`.
52    #[allow(clippy::type_complexity)]
53    fn task_meta_gradient(
54        &mut self,
55        task: &MetaTask<T>,
56        meta_parameters: &HashMap<String, Array1<T>>,
57    ) -> Result<(
58        HashMap<String, Array1<T>>,
59        TaskAdaptationResult<T>,
60        QueryEvaluationResult<T>,
61    )> {
62        let inner_steps = self.config.inner_steps;
63        let adaptation = self.adapt_to_task(task, meta_parameters, inner_steps)?;
64        let query = self.evaluate_query_set(task, &adaptation.adapted_parameters)?;
65
66        // FOMAML: the meta-gradient is the query gradient at the adapted point.
67        let mut meta_grad = self.compute_query_gradients(task, &adaptation.adapted_parameters)?;
68
69        if self.config.second_order {
70            // Exact MAML: back-propagate through the `inner_steps` gradient
71            // descent updates. For theta' = theta - alpha * grad L_s(theta) the
72            // Jacobian is (I - alpha * H_s), and H_s is constant here.
73            let alpha = self.config.inner_lr;
74            for _ in 0..inner_steps {
75                let hvp = linear_model::mse_hessian_vector_product(
76                    &task.support_set.features,
77                    &adaptation.adapted_parameters,
78                    &meta_grad,
79                )?;
80                for (name, g) in meta_grad.iter_mut() {
81                    if let Some(h) = hvp.get(name) {
82                        let n = g.len().min(h.len());
83                        for i in 0..n {
84                            g[i] = g[i] - alpha * h[i];
85                        }
86                    }
87                }
88            }
89        }
90
91        if let Some(clip) = self.config.gradient_clip {
92            if let Some(max_norm) = scirs2_core::numeric::NumCast::from(clip) {
93                linear_model::clip_gradients(&mut meta_grad, max_norm);
94            }
95        }
96
97        Ok((meta_grad, adaptation, query))
98    }
99}
100
101impl<
102        T: Float
103            + Debug
104            + 'static
105            + Default
106            + Clone
107            + Send
108            + Sync
109            + std::iter::Sum
110            + scirs2_core::ndarray::ScalarOperand,
111        D: Dimension,
112    > MetaLearner<T> for MAMLLearner<T, D>
113{
114    /// One meta-training step over a batch of tasks.
115    ///
116    /// The meta-update is applied here (see the module docs): `meta_parameters`
117    /// comes back already advanced by `-outer_lr * meta_gradient`.
118    fn meta_train_step(
119        &mut self,
120        task_batch: &[MetaTask<T>],
121        meta_parameters: &mut HashMap<String, Array1<T>>,
122    ) -> Result<MetaTrainingResult<T>> {
123        if task_batch.is_empty() {
124            return Err(OptimError::InsufficientData(
125                "meta_train_step requires at least one task".to_string(),
126            ));
127        }
128        if meta_parameters.is_empty() {
129            return Err(OptimError::InvalidConfig(
130                "meta_train_step requires non-empty meta-parameters".to_string(),
131            ));
132        }
133
134        let mut total_meta_loss = T::zero();
135        let mut task_losses: Vec<T> = Vec::with_capacity(task_batch.len());
136        let mut pre_adaptation_losses: Vec<T> = Vec::with_capacity(task_batch.len());
137        let mut convergence_steps: Vec<usize> = Vec::with_capacity(task_batch.len());
138        let mut per_task_gradients: Vec<HashMap<String, Array1<T>>> =
139            Vec::with_capacity(task_batch.len());
140        let mut parameter_changes: Vec<T> = Vec::with_capacity(task_batch.len());
141        let mut gradient_norms: Vec<T> = Vec::with_capacity(task_batch.len());
142
143        let mut meta_gradients: HashMap<String, Array1<T>> = meta_parameters
144            .iter()
145            .map(|(name, p)| (name.clone(), Array1::zeros(p.len())))
146            .collect();
147
148        for task in task_batch {
149            let before = linear_model::mse_loss(
150                &task.query_set.features,
151                &task.query_set.targets,
152                meta_parameters,
153            )?;
154            pre_adaptation_losses.push(before);
155
156            let (task_grad, adaptation, query) = self.task_meta_gradient(task, meta_parameters)?;
157
158            task_losses.push(query.query_loss);
159            total_meta_loss = total_meta_loss + query.query_loss;
160            convergence_steps.push(adaptation.adaptation_trajectory.len());
161            parameter_changes.push(
162                adaptation
163                    .adaptation_trajectory
164                    .last()
165                    .map(|s| s.parameter_change_norm)
166                    .unwrap_or_else(T::zero),
167            );
168            gradient_norms.push(linear_model::map_norm(&task_grad));
169
170            for (name, grad) in &task_grad {
171                if let Some(acc) = meta_gradients.get_mut(name) {
172                    let n = acc.len().min(grad.len());
173                    for i in 0..n {
174                        acc[i] = acc[i] + grad[i];
175                    }
176                }
177            }
178            per_task_gradients.push(task_grad);
179
180            if self.adaptation_history.len() == MAML_HISTORY_CAPACITY {
181                self.adaptation_history.pop_front();
182            }
183            self.adaptation_history.push_back(adaptation);
184        }
185
186        let batch_size = T::from(task_batch.len()).ok_or_else(|| {
187            OptimError::ComputationError("failed to convert task batch size".to_string())
188        })?;
189        let meta_loss = total_meta_loss / batch_size;
190        for gradient in meta_gradients.values_mut() {
191            for g in gradient.iter_mut() {
192                *g = *g / batch_size;
193            }
194        }
195
196        // The learner owns the update.
197        linear_model::descend(meta_parameters, &meta_gradients, self.config.outer_lr)?;
198        self.meta_steps += 1;
199
200        // Real metrics, derived from what this batch actually observed.
201        let observations = BatchObservations {
202            pre_losses: pre_adaptation_losses,
203            post_losses: task_losses.clone(),
204            convergence_steps,
205            parameter_changes,
206            gradient_norms,
207            gradients: per_task_gradients,
208        };
209
210        Ok(MetaTrainingResult {
211            meta_loss,
212            task_losses,
213            meta_gradients,
214            metrics: observations.training_metrics(),
215            adaptation_stats: observations.adaptation_statistics(),
216        })
217    }
218
219    /// Inner loop: descend the support-set loss from the meta-parameters.
220    fn adapt_to_task(
221        &mut self,
222        task: &MetaTask<T>,
223        meta_parameters: &HashMap<String, Array1<T>>,
224        adaptation_steps: usize,
225    ) -> Result<TaskAdaptationResult<T>> {
226        if meta_parameters.is_empty() {
227            return Err(OptimError::InvalidConfig(
228                "adaptation requires non-empty meta-parameters".to_string(),
229            ));
230        }
231        if task.support_set.features.is_empty() {
232            return Err(OptimError::InsufficientData(format!(
233                "task '{}' has an empty support set",
234                task.id
235            )));
236        }
237
238        let mut adapted_parameters = meta_parameters.clone();
239        let mut adaptation_trajectory = Vec::with_capacity(adaptation_steps);
240        let learning_rate = self.config.inner_lr;
241        let initial_loss = self.compute_support_loss(task, &adapted_parameters)?;
242
243        for step in 0..adaptation_steps {
244            let loss = self.compute_support_loss(task, &adapted_parameters)?;
245            let gradients = self.compute_support_gradients(task, &adapted_parameters)?;
246            let gradient_norm = linear_model::map_norm(&gradients);
247            let parameter_change_norm =
248                linear_model::descend(&mut adapted_parameters, &gradients, learning_rate)?;
249
250            adaptation_trajectory.push(AdaptationStep {
251                step,
252                loss,
253                gradient_norm,
254                parameter_change_norm,
255                learning_rate,
256            });
257        }
258
259        let final_loss = self.compute_support_loss(task, &adapted_parameters)?;
260        let steps_t = T::from(adaptation_steps.max(1)).ok_or_else(|| {
261            OptimError::ComputationError("failed to convert adaptation steps".to_string())
262        })?;
263        let improvement = initial_loss - final_loss;
264        let convergence_speed = improvement / steps_t;
265        let final_performance = if initial_loss > T::zero() {
266            (improvement / initial_loss).max(T::zero()).min(T::one())
267        } else {
268            T::zero()
269        };
270        let travel = adaptation_trajectory
271            .iter()
272            .map(|s| s.parameter_change_norm)
273            .fold(T::zero(), |a, b| a + b);
274        let efficiency = if travel > T::zero() {
275            improvement / travel
276        } else {
277            T::zero()
278        };
279        // Robustness: how monotone the descent was (fraction of steps that did
280        // not increase the loss).
281        let mut non_increasing = 0usize;
282        for pair in adaptation_trajectory.windows(2) {
283            if pair[1].loss <= pair[0].loss {
284                non_increasing += 1;
285            }
286        }
287        let robustness = if adaptation_trajectory.len() > 1 {
288            let denom = T::from(adaptation_trajectory.len() - 1).unwrap_or_else(T::one);
289            T::from(non_increasing).unwrap_or_else(T::zero) / denom
290        } else {
291            T::one()
292        };
293
294        Ok(TaskAdaptationResult {
295            adapted_parameters,
296            adaptation_trajectory,
297            final_loss,
298            metrics: TaskAdaptationMetrics {
299                convergence_speed,
300                final_performance,
301                efficiency,
302                robustness,
303            },
304        })
305    }
306
307    /// Evaluate the query set **at the adapted parameters**.
308    fn evaluate_query_set(
309        &self,
310        task: &MetaTask<T>,
311        adapted_parameters: &HashMap<String, Array1<T>>,
312    ) -> Result<QueryEvaluationResult<T>> {
313        if task.query_set.features.is_empty() {
314            return Err(OptimError::InsufficientData(format!(
315                "task '{}' has an empty query set",
316                task.id
317            )));
318        }
319
320        let predictions = linear_model::predict_all(&task.query_set.features, adapted_parameters)?;
321        let query_loss = linear_model::mse_loss(
322            &task.query_set.features,
323            &task.query_set.targets,
324            adapted_parameters,
325        )?;
326
327        // Confidence: squashed per-sample residual, 1 for an exact prediction.
328        let confidence_scores: Vec<T> = predictions
329            .iter()
330            .zip(task.query_set.targets.iter())
331            .map(|(p, y)| {
332                let err = (*p - *y).abs();
333                T::one() / (T::one() + err)
334            })
335            .collect();
336
337        let classification_accuracy = match task.task_type {
338            TaskType::Classification => {
339                linear_model::label_accuracy(&predictions, &task.query_set.targets)
340            }
341            _ => None,
342        };
343        let regression_r2 = linear_model::r_squared(&predictions, &task.query_set.targets);
344
345        // Full-curve AUC-ROC over the retained predictions, which double as the
346        // ranking scores. `roc_auc` returns `None` unless the targets really are
347        // binary labels with both classes present, so a regression task keeps
348        // reporting no AUC instead of a meaningless number.
349        let auc = match task.task_type {
350            TaskType::Classification => {
351                linear_model::roc_auc(&predictions, &task.query_set.targets)
352            }
353            _ => None,
354        };
355
356        // `accuracy` is the task-appropriate goodness score: label accuracy for
357        // classification, R^2 clamped to [0, 1] for regression, 0 when neither
358        // is defined.
359        let accuracy = classification_accuracy
360            .or_else(|| regression_r2.map(|r| r.max(T::zero()).min(T::one())))
361            .unwrap_or_else(T::zero);
362
363        // Uncertainty quality: mean confidence, i.e. how tight the residuals are.
364        let uncertainty_quality = mean_of(&confidence_scores).unwrap_or_else(T::zero);
365
366        Ok(QueryEvaluationResult {
367            query_loss,
368            accuracy,
369            predictions,
370            confidence_scores,
371            metrics: QueryEvaluationMetrics {
372                mse: Some(query_loss),
373                classification_accuracy,
374                auc,
375                uncertainty_quality,
376            },
377        })
378    }
379
380    fn get_algorithm(&self) -> MetaLearningAlgorithm {
381        if self.config.second_order {
382            MetaLearningAlgorithm::MAML
383        } else {
384            MetaLearningAlgorithm::FOMAML
385        }
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use crate::meta_learning::types::{
393        DatasetMetadata, MAMLConfig, MetaTask, TaskDataset, TaskMetadata,
394    };
395    use scirs2_core::ndarray::Ix1;
396
397    /// Fixed polynomial basis. The model is linear in its parameters, so the
398    /// non-linearity has to live in the features for a sine-regression task to
399    /// be learnable at all.
400    fn basis(x: f64) -> Array1<f64> {
401        let u = x / 3.0;
402        Array1::from_vec(vec![1.0, u, u * u, u * u * u, u * u * u * u])
403    }
404
405    /// One sine-regression task: `y = amplitude * sin(x + phase)`.
406    fn sine_task(id: usize, amplitude: f64, phase: f64) -> MetaTask<f64> {
407        let mut support_features = Vec::new();
408        let mut support_targets = Vec::new();
409        let mut query_features = Vec::new();
410        let mut query_targets = Vec::new();
411        for k in 0..12 {
412            let x = -3.0 + 6.0 * (k as f64) / 11.0;
413            let y = amplitude * (x + phase).sin();
414            if k % 2 == 0 {
415                support_features.push(basis(x));
416                support_targets.push(y);
417            } else {
418                query_features.push(basis(x));
419                query_targets.push(y);
420            }
421        }
422        MetaTask {
423            id: format!("sine_{id}"),
424            support_set: TaskDataset {
425                features: support_features,
426                targets: support_targets,
427                weights: Vec::new(),
428                metadata: DatasetMetadata::default(),
429            },
430            query_set: TaskDataset {
431                features: query_features,
432                targets: query_targets,
433                weights: Vec::new(),
434                metadata: DatasetMetadata::default(),
435            },
436            metadata: TaskMetadata::default(),
437            difficulty: amplitude,
438            domain: "sine".to_string(),
439            task_type: crate::meta_learning::types::TaskType::Regression,
440        }
441    }
442
443    fn sine_task_family(count: usize) -> Vec<MetaTask<f64>> {
444        (0..count)
445            .map(|i| {
446                let amplitude = 0.5 + 1.5 * (i as f64) / (count as f64);
447                let phase = std::f64::consts::PI * (i as f64) / (count as f64);
448                sine_task(i, amplitude, phase)
449            })
450            .collect()
451    }
452
453    fn zero_params(dim: usize) -> HashMap<String, Array1<f64>> {
454        let mut p = HashMap::new();
455        p.insert(linear_model::WEIGHTS_KEY.to_string(), Array1::zeros(dim));
456        p.insert(linear_model::BIAS_KEY.to_string(), Array1::zeros(1));
457        p
458    }
459
460    fn maml(second_order: bool) -> MAMLLearner<f64, Ix1> {
461        MAMLLearner::new(MAMLConfig {
462            second_order,
463            inner_lr: 0.4,
464            outer_lr: 0.3,
465            inner_steps: 5,
466            allow_unused: true,
467            gradient_clip: Some(10.0),
468        })
469        .expect("maml learner")
470    }
471
472    /// Mean query loss over `tasks` after running the inner loop from `params`.
473    fn mean_adapted_query_loss(
474        learner: &mut MAMLLearner<f64, Ix1>,
475        tasks: &[MetaTask<f64>],
476        params: &HashMap<String, Array1<f64>>,
477    ) -> f64 {
478        let mut total = 0.0;
479        for task in tasks {
480            let adapted = learner
481                .adapt_to_task(task, params, 5)
482                .expect("adapt")
483                .adapted_parameters;
484            total += learner
485                .evaluate_query_set(task, &adapted)
486                .expect("evaluate")
487                .query_loss;
488        }
489        total / tasks.len() as f64
490    }
491
492    /// F8/F9: meta-training must genuinely improve post-adaptation performance.
493    #[test]
494    fn test_sine_regression_meta_learning_improves_adaptation() {
495        let tasks = sine_task_family(16);
496        let holdout: Vec<MetaTask<f64>> = sine_task_family(5)
497            .into_iter()
498            .map(|mut t| {
499                t.id = format!("holdout_{}", t.id);
500                t
501            })
502            .collect();
503
504        let mut learner = maml(false);
505        let mut params = zero_params(5);
506
507        let before = mean_adapted_query_loss(&mut learner, &holdout, &params);
508
509        for _ in 0..400 {
510            learner
511                .meta_train_step(&tasks, &mut params)
512                .expect("meta train step");
513        }
514
515        let after = mean_adapted_query_loss(&mut learner, &holdout, &params);
516        assert!(
517            after < before,
518            "meta-training must improve post-adaptation loss: before={before}, after={after}"
519        );
520
521        // At the meta-trained initialisation, adapting must beat not adapting.
522        let mut pre_adaptation = 0.0;
523        for task in &holdout {
524            pre_adaptation +=
525                linear_model::mse_loss(&task.query_set.features, &task.query_set.targets, &params)
526                    .expect("loss");
527        }
528        pre_adaptation /= holdout.len() as f64;
529        assert!(
530            after < pre_adaptation,
531            "adaptation must reduce query loss: pre={pre_adaptation}, post={after}"
532        );
533    }
534
535    /// The inner loop must descend the *support* loss, not a weight-decay proxy.
536    #[test]
537    fn test_inner_loop_descends_the_support_loss() {
538        let task = sine_task(0, 1.0, 0.0);
539        let mut learner = maml(false);
540        let params = zero_params(5);
541
542        let before = linear_model::mse_loss(
543            &task.support_set.features,
544            &task.support_set.targets,
545            &params,
546        )
547        .expect("loss");
548        let result = learner.adapt_to_task(&task, &params, 5).expect("adapt");
549        assert!(
550            result.final_loss < before,
551            "support loss must decrease: before={before}, after={}",
552            result.final_loss
553        );
554        assert_eq!(result.adaptation_trajectory.len(), 5);
555        // The adapted parameters must actually differ from the meta-parameters.
556        let moved = result
557            .adaptation_trajectory
558            .iter()
559            .any(|s| s.parameter_change_norm > 0.0);
560        assert!(moved, "the inner loop never moved any parameter");
561    }
562
563    /// F9: the reported meta-gradient must be non-zero and must actually be the
564    /// direction the meta-parameters moved along.
565    #[test]
566    fn test_meta_gradients_are_real_and_applied_once() {
567        let tasks = sine_task_family(4);
568        let mut learner = maml(false);
569        let mut params = zero_params(5);
570        let before = params[linear_model::WEIGHTS_KEY].clone();
571
572        let result = learner
573            .meta_train_step(&tasks, &mut params)
574            .expect("meta train step");
575
576        let grad = &result.meta_gradients[linear_model::WEIGHTS_KEY];
577        assert!(
578            linear_model::map_norm(&result.meta_gradients) > 0.0,
579            "meta-gradients must not be zeros"
580        );
581        // theta_new = theta_old - outer_lr * grad, applied exactly once.
582        let after = &params[linear_model::WEIGHTS_KEY];
583        for i in 0..after.len() {
584            approx::assert_abs_diff_eq!(after[i], before[i] - 0.3 * grad[i], epsilon = 1e-12);
585        }
586    }
587
588    /// The second-order path must produce a different (and still descending)
589    /// meta-gradient than FOMAML.
590    #[test]
591    fn test_second_order_differs_from_first_order() {
592        let tasks = sine_task_family(4);
593
594        let mut first = maml(false);
595        let mut second = maml(true);
596        let mut p1 = zero_params(5);
597        let mut p2 = zero_params(5);
598
599        let r1 = first.meta_train_step(&tasks, &mut p1).expect("fomaml");
600        let r2 = second.meta_train_step(&tasks, &mut p2).expect("maml");
601
602        let g1 = &r1.meta_gradients[linear_model::WEIGHTS_KEY];
603        let g2 = &r2.meta_gradients[linear_model::WEIGHTS_KEY];
604        let diff: f64 = g1
605            .iter()
606            .zip(g2.iter())
607            .map(|(a, b)| (a - b) * (a - b))
608            .sum::<f64>()
609            .sqrt();
610        assert!(
611            diff > 1e-9,
612            "the second-order meta-gradient must differ from the first-order one"
613        );
614        assert!(matches!(
615            first.get_algorithm(),
616            MetaLearningAlgorithm::FOMAML
617        ));
618        assert!(matches!(
619            second.get_algorithm(),
620            MetaLearningAlgorithm::MAML
621        ));
622    }
623
624    /// Query evaluation must use the *adapted* parameters.
625    #[test]
626    fn test_query_evaluation_uses_adapted_parameters() {
627        let task = sine_task(0, 1.5, 0.3);
628        let mut learner = maml(false);
629        let params = zero_params(5);
630
631        let at_meta = learner
632            .evaluate_query_set(&task, &params)
633            .expect("meta eval");
634        let adapted = learner
635            .adapt_to_task(&task, &params, 5)
636            .expect("adapt")
637            .adapted_parameters;
638        let at_adapted = learner
639            .evaluate_query_set(&task, &adapted)
640            .expect("adapted eval");
641
642        assert!(
643            at_adapted.query_loss < at_meta.query_loss,
644            "adapted evaluation must differ from (and beat) the un-adapted one"
645        );
646        assert_eq!(at_adapted.predictions.len(), task.query_set.features.len());
647        assert!(at_adapted.metrics.auc.is_none());
648    }
649
650    /// Empty support/query sets must be errors, not NaN losses.
651    #[test]
652    fn test_empty_task_is_an_error() {
653        let mut empty = sine_task(0, 1.0, 0.0);
654        empty.support_set.features.clear();
655        empty.support_set.targets.clear();
656        let mut learner = maml(false);
657        let params = zero_params(5);
658        assert!(learner.adapt_to_task(&empty, &params, 3).is_err());
659        assert!(learner.meta_train_step(&[], &mut params.clone()).is_err());
660    }
661}