1use 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 #[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 let mut meta_grad = self.compute_query_gradients(task, &adaptation.adapted_parameters)?;
68
69 if self.config.second_order {
70 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 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 linear_model::descend(meta_parameters, &meta_gradients, self.config.outer_lr)?;
198 self.meta_steps += 1;
199
200 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 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 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 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 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 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 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 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 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 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 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 #[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, ¶ms);
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, ¶ms);
516 assert!(
517 after < before,
518 "meta-training must improve post-adaptation loss: before={before}, after={after}"
519 );
520
521 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, ¶ms)
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 #[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 ¶ms,
546 )
547 .expect("loss");
548 let result = learner.adapt_to_task(&task, ¶ms, 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 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 #[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 let after = ¶ms[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 #[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 #[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, ¶ms)
633 .expect("meta eval");
634 let adapted = learner
635 .adapt_to_task(&task, ¶ms, 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 #[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, ¶ms, 3).is_err());
659 assert!(learner.meta_train_step(&[], &mut params.clone()).is_err());
660 }
661}