1use crate::models::Sequential;
7use crate::NeuralResult;
8use scirs2_core::ndarray::{Array2, ScalarOperand};
9use sklears_core::error::SklearsError;
10use sklears_core::types::FloatBounds;
11use std::collections::HashMap;
12use std::iter::Sum;
13
14#[cfg(feature = "serde")]
15use serde::{Deserialize, Serialize};
16
17#[derive(Debug, Clone, PartialEq)]
19#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
20pub enum SharingStrategy {
21 HardSharing {
23 shared_layers: usize,
25 task_specific_layers: Vec<usize>,
27 },
28 SoftSharing {
30 l2_penalty: f64,
32 similarity_threshold: f64,
34 },
35 CrossStitch {
37 num_units: Vec<usize>,
39 },
40 AttentionSharing {
42 attention_dim: usize,
44 },
45}
46
47impl Default for SharingStrategy {
48 fn default() -> Self {
49 SharingStrategy::HardSharing {
50 shared_layers: 2,
51 task_specific_layers: vec![64, 32],
52 }
53 }
54}
55
56#[derive(Debug, Clone, PartialEq, Default)]
58#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
59pub enum TaskWeightingStrategy {
60 #[default]
62 Equal,
63 Manual(Vec<f64>),
65 UncertaintyWeighting,
67 DynamicWeighting {
69 adaptation_rate: f64,
71 min_weight: f64,
73 max_weight: f64,
75 },
76 GradNorm {
78 alpha: f64,
80 initial_weights: Vec<f64>,
82 },
83}
84
85#[derive(Debug, Clone)]
87#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
88#[cfg_attr(
89 feature = "serde",
90 serde(bound = "T: FloatBounds + serde::Serialize + serde::de::DeserializeOwned")
91)]
92pub struct MultiTaskLoss<T: FloatBounds> {
93 pub task_losses: Vec<String>, pub weighting_strategy: TaskWeightingStrategy,
97 pub task_weights: Vec<T>,
99 pub regularization_strength: Option<T>,
101}
102
103impl<T: FloatBounds> Default for MultiTaskLoss<T> {
104 fn default() -> Self {
105 Self {
106 task_losses: vec!["mse".to_string()],
107 weighting_strategy: TaskWeightingStrategy::Equal,
108 task_weights: vec![T::from(1.0).unwrap_or_else(|| T::zero())],
109 regularization_strength: None,
110 }
111 }
112}
113
114#[derive(Debug, Clone)]
116#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
117#[cfg_attr(
118 feature = "serde",
119 serde(bound = "T: FloatBounds + serde::Serialize + serde::de::DeserializeOwned")
120)]
121pub struct MultiTaskConfig<T: FloatBounds> {
122 pub num_tasks: usize,
124 pub input_dim: usize,
126 pub output_dims: Vec<usize>,
128 pub sharing_strategy: SharingStrategy,
130 pub loss_config: MultiTaskLoss<T>,
132 pub task_names: Vec<String>,
134 pub use_task_embeddings: bool,
136 pub task_embedding_dim: usize,
138}
139
140impl<T: FloatBounds> MultiTaskConfig<T> {
141 pub fn new(num_tasks: usize, input_dim: usize, output_dims: Vec<usize>) -> NeuralResult<Self> {
143 if output_dims.len() != num_tasks {
144 return Err(SklearsError::InvalidParameter {
145 name: "output_dims".to_string(),
146 reason: "number of output dimensions must match number of tasks".to_string(),
147 });
148 }
149
150 Ok(Self {
151 num_tasks,
152 input_dim,
153 output_dims,
154 sharing_strategy: SharingStrategy::default(),
155 loss_config: MultiTaskLoss::default(),
156 task_names: (0..num_tasks).map(|i| format!("task_{}", i)).collect(),
157 use_task_embeddings: false,
158 task_embedding_dim: 8,
159 })
160 }
161
162 pub fn with_task_names(mut self, names: Vec<String>) -> NeuralResult<Self> {
164 if names.len() != self.num_tasks {
165 return Err(SklearsError::InvalidParameter {
166 name: "task_names".to_string(),
167 reason: "number of task names must match number of tasks".to_string(),
168 });
169 }
170 self.task_names = names;
171 Ok(self)
172 }
173
174 pub fn with_sharing_strategy(mut self, strategy: SharingStrategy) -> Self {
176 self.sharing_strategy = strategy;
177 self
178 }
179
180 pub fn with_task_weighting(mut self, strategy: TaskWeightingStrategy) -> Self {
182 self.loss_config.weighting_strategy = strategy;
183 self
184 }
185
186 pub fn with_task_embeddings(mut self, embedding_dim: usize) -> Self {
188 self.use_task_embeddings = true;
189 self.task_embedding_dim = embedding_dim;
190 self
191 }
192}
193
194pub struct MultiTaskNetwork<T: FloatBounds> {
196 config: MultiTaskConfig<T>,
198 shared_model: Option<Sequential<T>>,
200 task_heads: HashMap<String, Sequential<T>>,
202 task_embeddings: Option<Array2<T>>,
204 cross_stitch_units: Option<Vec<Array2<T>>>,
206 is_fitted: bool,
208}
209
210impl<T: FloatBounds + ScalarOperand + Sum> MultiTaskNetwork<T> {
211 pub fn new(config: MultiTaskConfig<T>) -> Self {
213 Self {
214 config,
215 shared_model: None,
216 task_heads: HashMap::new(),
217 task_embeddings: None,
218 cross_stitch_units: None,
219 is_fitted: false,
220 }
221 }
222
223 pub fn build_architecture(&mut self) -> NeuralResult<()> {
225 let strategy = self.config.sharing_strategy.clone();
226 match strategy {
227 SharingStrategy::HardSharing {
228 shared_layers,
229 task_specific_layers,
230 } => {
231 self.build_hard_sharing_architecture(shared_layers, &task_specific_layers)?;
232 }
233 SharingStrategy::SoftSharing { .. } => {
234 self.build_soft_sharing_architecture()?;
235 }
236 SharingStrategy::CrossStitch { num_units } => {
237 self.build_cross_stitch_architecture(&num_units)?;
238 }
239 SharingStrategy::AttentionSharing { attention_dim } => {
240 self.build_attention_sharing_architecture(attention_dim)?;
241 }
242 }
243
244 if self.config.use_task_embeddings {
246 self.initialize_task_embeddings()?;
247 }
248
249 Ok(())
250 }
251
252 fn build_hard_sharing_architecture(
254 &mut self,
255 shared_layers: usize,
256 task_specific_layers: &[usize],
257 ) -> NeuralResult<()> {
258 let shared_model = Sequential::new();
260 let mut current_dim = self.config.input_dim;
261
262 for _ in 0..shared_layers {
263 let layer_dim = current_dim / 2; current_dim = layer_dim.max(8); }
268
269 self.shared_model = Some(shared_model);
270
271 for (task_idx, task_name) in self.config.task_names.iter().enumerate() {
273 let task_head = Sequential::new();
274 let mut _head_dim = current_dim;
275
276 for &layer_size in task_specific_layers {
277 _head_dim = layer_size;
279 }
280
281 let _output_dim = self.config.output_dims[task_idx];
283 self.task_heads.insert(task_name.clone(), task_head);
286 }
287
288 Ok(())
289 }
290
291 fn build_soft_sharing_architecture(&mut self) -> NeuralResult<()> {
293 for (task_idx, task_name) in self.config.task_names.iter().enumerate() {
295 let task_network = Sequential::new();
296 let _output_dim = self.config.output_dims[task_idx];
297
298 self.task_heads.insert(task_name.clone(), task_network);
302 }
303
304 Ok(())
305 }
306
307 fn build_cross_stitch_architecture(&mut self, num_units: &[usize]) -> NeuralResult<()> {
309 let mut units = Vec::new();
311 for &_unit_size in num_units {
312 let unit = Array2::eye(self.config.num_tasks)
313 * T::from(0.8).unwrap_or_else(|| T::zero())
314 + Array2::from_elem(
315 (self.config.num_tasks, self.config.num_tasks),
316 T::from(0.2).unwrap_or_else(|| T::zero())
317 / T::from(self.config.num_tasks as f64).unwrap_or_else(|| T::zero()),
318 );
319 units.push(unit);
320 }
321 self.cross_stitch_units = Some(units);
322
323 for (task_idx, task_name) in self.config.task_names.iter().enumerate() {
325 let task_network = Sequential::new();
326 let _output_dim = self.config.output_dims[task_idx];
327
328 self.task_heads.insert(task_name.clone(), task_network);
332 }
333
334 Ok(())
335 }
336
337 fn build_attention_sharing_architecture(&mut self, _attention_dim: usize) -> NeuralResult<()> {
339 let shared_model = Sequential::new();
341 self.shared_model = Some(shared_model);
344
345 for (task_idx, task_name) in self.config.task_names.iter().enumerate() {
347 let task_head = Sequential::new();
348 let _output_dim = self.config.output_dims[task_idx];
350
351 self.task_heads.insert(task_name.clone(), task_head);
355 }
356
357 Ok(())
358 }
359
360 fn initialize_task_embeddings(&mut self) -> NeuralResult<()> {
362 let mut rng = scirs2_core::random::thread_rng();
363
364 let mut embeddings = Array2::zeros((self.config.num_tasks, self.config.task_embedding_dim));
365 for mut row in embeddings.rows_mut() {
366 for elem in row.iter_mut() {
367 *elem = T::from(rng.gen_range(-0.1..0.1)).unwrap_or_else(|| T::zero());
368 }
369 }
370
371 self.task_embeddings = Some(embeddings);
372 Ok(())
373 }
374
375 pub fn forward_task(
377 &mut self,
378 input: &Array2<T>,
379 task_name: &str,
380 training: bool,
381 ) -> NeuralResult<Array2<T>> {
382 if !self.is_fitted {
383 return Err(SklearsError::InvalidParameter {
384 name: "model".to_string(),
385 reason: "Model must be fitted before prediction".to_string(),
386 });
387 }
388
389 let features = if let Some(ref mut shared_model) = self.shared_model {
391 shared_model.forward(input, training)?
392 } else {
393 input.clone()
394 };
395
396 if let Some(task_head) = self.task_heads.get_mut(task_name) {
398 task_head.forward(&features, training)
399 } else {
400 Err(SklearsError::InvalidParameter {
401 name: "task_name".to_string(),
402 reason: format!("Unknown task: {}", task_name),
403 })
404 }
405 }
406
407 pub fn forward_all_tasks(
409 &mut self,
410 input: &Array2<T>,
411 training: bool,
412 ) -> NeuralResult<HashMap<String, Array2<T>>> {
413 let mut outputs = HashMap::new();
414
415 for task_name in &self.config.task_names.clone() {
416 let output = self.forward_task(input, task_name, training)?;
417 outputs.insert(task_name.clone(), output);
418 }
419
420 Ok(outputs)
421 }
422
423 pub fn compute_multi_task_loss(
425 &self,
426 predictions: &HashMap<String, Array2<T>>,
427 targets: &HashMap<String, Array2<T>>,
428 ) -> NeuralResult<T> {
429 let mut total_loss = T::from(0.0).unwrap_or_else(|| T::zero());
430 let mut valid_tasks = 0;
431
432 for (task_idx, task_name) in self.config.task_names.iter().enumerate() {
433 if let (Some(pred), Some(target)) = (predictions.get(task_name), targets.get(task_name))
434 {
435 let task_loss = self.compute_task_loss(pred, target, task_idx)?;
436 let weight = if task_idx < self.config.loss_config.task_weights.len() {
437 self.config.loss_config.task_weights[task_idx]
438 } else {
439 T::from(1.0).unwrap_or_else(|| T::zero())
440 };
441 total_loss += weight * task_loss;
442 valid_tasks += 1;
443 }
444 }
445
446 if valid_tasks == 0 {
447 return Err(SklearsError::InvalidParameter {
448 name: "targets".to_string(),
449 reason: "No valid task targets provided".to_string(),
450 });
451 }
452
453 Ok(total_loss / T::from(valid_tasks as f64).unwrap_or_else(|| T::zero()))
454 }
455
456 fn compute_task_loss(
458 &self,
459 predictions: &Array2<T>,
460 targets: &Array2<T>,
461 task_idx: usize,
462 ) -> NeuralResult<T> {
463 if predictions.shape() != targets.shape() {
464 return Err(SklearsError::InvalidParameter {
465 name: "shape".to_string(),
466 reason: "Predictions and targets must have the same shape".to_string(),
467 });
468 }
469
470 let loss_type = self
471 .config
472 .loss_config
473 .task_losses
474 .get(task_idx)
475 .map(|s| s.as_str())
476 .unwrap_or("mse");
477
478 match loss_type {
479 "mse" => {
480 let diff = predictions - targets;
481 let squared_diff = &diff * &diff;
482 Ok(squared_diff
483 .mean()
484 .expect("mean should not fail on non-empty array"))
485 }
486 "mae" => {
487 let diff = predictions - targets;
488 let abs_diff = diff.mapv(|x| x.abs());
489 Ok(abs_diff
490 .mean()
491 .expect("mean should not fail on non-empty array"))
492 }
493 _ => Err(SklearsError::InvalidParameter {
494 name: "loss_type".to_string(),
495 reason: format!("Unsupported loss type: {}", loss_type),
496 }),
497 }
498 }
499
500 pub fn update_task_weights(&mut self, task_losses: &[T], _epoch: usize) -> NeuralResult<()> {
502 let strategy = self.config.loss_config.weighting_strategy.clone();
503 match strategy {
504 TaskWeightingStrategy::Equal => {
505 self.config.loss_config.task_weights =
506 vec![T::from(1.0).unwrap_or_else(|| T::zero()); self.config.num_tasks];
507 }
508 TaskWeightingStrategy::Manual(weights) => {
509 self.config.loss_config.task_weights = weights
510 .iter()
511 .map(|&w| T::from(w).unwrap_or_else(|| T::zero()))
512 .collect();
513 }
514 TaskWeightingStrategy::DynamicWeighting {
515 adaptation_rate,
516 min_weight,
517 max_weight,
518 } => {
519 self.update_dynamic_weights(task_losses, adaptation_rate, min_weight, max_weight)?;
520 }
521 TaskWeightingStrategy::UncertaintyWeighting => {
522 self.update_uncertainty_weights(task_losses)?;
523 }
524 TaskWeightingStrategy::GradNorm {
525 alpha,
526 initial_weights,
527 } => {
528 self.update_gradnorm_weights(task_losses, alpha, &initial_weights)?;
529 }
530 }
531
532 Ok(())
533 }
534
535 fn update_dynamic_weights(
537 &mut self,
538 task_losses: &[T],
539 adaptation_rate: f64,
540 min_weight: f64,
541 max_weight: f64,
542 ) -> NeuralResult<()> {
543 if task_losses.is_empty() {
544 return Ok(());
545 }
546
547 let total_loss: T = task_losses
549 .iter()
550 .copied()
551 .fold(T::from(0.0).unwrap_or_else(|| T::zero()), |a, b| a + b);
552 let avg_loss = total_loss / T::from(task_losses.len() as f64).unwrap_or_else(|| T::zero());
553
554 for (i, &task_loss) in task_losses.iter().enumerate() {
555 let current_weight = self
556 .config
557 .loss_config
558 .task_weights
559 .get(i)
560 .copied()
561 .unwrap_or(T::from(1.0).unwrap_or_else(|| T::zero()));
562
563 let difficulty_ratio = task_loss / avg_loss;
565 let target_weight = T::from(1.0).unwrap_or_else(|| T::zero())
566 + (difficulty_ratio - T::from(1.0).unwrap_or_else(|| T::zero()))
567 * T::from(adaptation_rate).unwrap_or_else(|| T::zero());
568
569 let new_weight = current_weight * T::from(0.9).unwrap_or_else(|| T::zero())
571 + target_weight * T::from(0.1).unwrap_or_else(|| T::zero());
572 let clamped_weight = T::from(
573 new_weight
574 .to_f64()
575 .unwrap_or(0.0)
576 .clamp(min_weight, max_weight),
577 )
578 .unwrap_or_else(|| T::zero());
579
580 if i < self.config.loss_config.task_weights.len() {
581 self.config.loss_config.task_weights[i] = clamped_weight;
582 } else {
583 self.config.loss_config.task_weights.push(clamped_weight);
584 }
585 }
586
587 Ok(())
588 }
589
590 fn update_uncertainty_weights(&mut self, task_losses: &[T]) -> NeuralResult<()> {
592 if task_losses.len() < 2 {
594 return Ok(());
595 }
596
597 let total_loss = task_losses
598 .iter()
599 .copied()
600 .fold(T::from(0.0).unwrap_or_else(|| T::zero()), |acc, value| {
601 acc + value
602 });
603 let mean_loss = total_loss / T::from(task_losses.len() as f64).unwrap_or_else(|| T::zero());
604 let mut weights = Vec::new();
605
606 for &loss in task_losses {
607 let uncertainty = (loss - mean_loss).abs() + T::from(1e-8).unwrap_or_else(|| T::zero());
609 weights.push(T::from(1.0).unwrap_or_else(|| T::zero()) / uncertainty);
610 }
611
612 let total_weight = weights
614 .iter()
615 .copied()
616 .fold(T::from(0.0).unwrap_or_else(|| T::zero()), |acc, value| {
617 acc + value
618 });
619 let normalization =
620 T::from(weights.len() as f64).unwrap_or_else(|| T::zero()) / total_weight;
621 for weight in &mut weights {
622 *weight *= normalization;
623 }
624
625 self.config.loss_config.task_weights = weights;
626 Ok(())
627 }
628
629 fn update_gradnorm_weights(
631 &mut self,
632 task_losses: &[T],
633 alpha: f64,
634 initial_weights: &[f64],
635 ) -> NeuralResult<()> {
636 let mut weights = initial_weights
639 .iter()
640 .map(|&w| T::from(w).unwrap_or_else(|| T::zero()))
641 .collect::<Vec<_>>();
642
643 if task_losses.len() != weights.len() {
644 return Err(SklearsError::InvalidParameter {
645 name: "weights".to_string(),
646 reason: "Number of weights must match number of tasks".to_string(),
647 });
648 }
649
650 let total_loss: T = task_losses
652 .iter()
653 .copied()
654 .fold(T::from(0.0).unwrap_or_else(|| T::zero()), |a, b| a + b);
655 let avg_loss = total_loss / T::from(task_losses.len() as f64).unwrap_or_else(|| T::zero());
656
657 for (&loss, weight) in task_losses.iter().zip(weights.iter_mut()) {
658 let relative_rate = loss / avg_loss;
659 let target_rate = T::from(1.0).unwrap_or_else(|| T::zero());
660 let adjustment =
661 (relative_rate / target_rate).powf(T::from(alpha).unwrap_or_else(|| T::zero()));
662 *weight *= adjustment;
663 }
664
665 let total_weight: T = weights
667 .iter()
668 .copied()
669 .fold(T::from(0.0).unwrap_or_else(|| T::zero()), |a, b| a + b);
670 let weights_len = weights.len();
671 for weight in &mut weights {
672 *weight =
673 *weight / total_weight * T::from(weights_len as f64).unwrap_or_else(|| T::zero());
674 }
675
676 self.config.loss_config.task_weights = weights;
677 Ok(())
678 }
679
680 pub fn config(&self) -> &MultiTaskConfig<T> {
682 &self.config
683 }
684
685 pub fn task_names(&self) -> &[String] {
687 &self.config.task_names
688 }
689
690 pub fn num_tasks(&self) -> usize {
692 self.config.num_tasks
693 }
694
695 pub fn is_fitted(&self) -> bool {
697 self.is_fitted
698 }
699
700 pub fn set_fitted(&mut self, fitted: bool) {
702 self.is_fitted = fitted;
703 }
704
705 pub fn task_weights(&self) -> &[T] {
707 &self.config.loss_config.task_weights
708 }
709}
710
711impl<T: FloatBounds> std::fmt::Debug for MultiTaskNetwork<T> {
712 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
713 f.debug_struct("MultiTaskNetwork")
714 .field("num_tasks", &self.config.num_tasks)
715 .field("task_names", &self.config.task_names)
716 .field("sharing_strategy", &self.config.sharing_strategy)
717 .field("is_fitted", &self.is_fitted)
718 .finish()
719 }
720}
721
722#[allow(non_snake_case)]
723#[cfg(test)]
724mod tests {
725 use super::*;
726 use scirs2_core::ndarray::Array2;
727
728 #[test]
729 fn test_multi_task_config_creation() {
730 let config = MultiTaskConfig::<f64>::new(3, 10, vec![2, 3, 1]).expect("valid parameter");
731 assert_eq!(config.num_tasks, 3);
732 assert_eq!(config.input_dim, 10);
733 assert_eq!(config.output_dims, vec![2, 3, 1]);
734 assert_eq!(config.task_names, vec!["task_0", "task_1", "task_2"]);
735 }
736
737 #[test]
738 fn test_multi_task_config_with_task_names() {
739 let config = MultiTaskConfig::<f64>::new(2, 5, vec![1, 1])
740 .expect("valid parameter")
741 .with_task_names(vec!["classification".to_string(), "regression".to_string()])
742 .expect("valid parameter");
743
744 assert_eq!(config.task_names, vec!["classification", "regression"]);
745 }
746
747 #[test]
748 fn test_sharing_strategies() {
749 let hard_sharing = SharingStrategy::HardSharing {
750 shared_layers: 3,
751 task_specific_layers: vec![64, 32],
752 };
753 assert!(matches!(hard_sharing, SharingStrategy::HardSharing { .. }));
754
755 let soft_sharing = SharingStrategy::SoftSharing {
756 l2_penalty: 0.01,
757 similarity_threshold: 0.8,
758 };
759 assert!(matches!(soft_sharing, SharingStrategy::SoftSharing { .. }));
760 }
761
762 #[test]
763 fn test_task_weighting_strategies() {
764 let equal = TaskWeightingStrategy::Equal;
765 assert!(matches!(equal, TaskWeightingStrategy::Equal));
766
767 let manual = TaskWeightingStrategy::Manual(vec![1.0, 2.0, 0.5]);
768 assert!(matches!(manual, TaskWeightingStrategy::Manual(_)));
769
770 let dynamic = TaskWeightingStrategy::DynamicWeighting {
771 adaptation_rate: 0.1,
772 min_weight: 0.1,
773 max_weight: 5.0,
774 };
775 assert!(matches!(
776 dynamic,
777 TaskWeightingStrategy::DynamicWeighting { .. }
778 ));
779 }
780
781 #[test]
782 fn test_multi_task_network_creation() {
783 let config = MultiTaskConfig::<f64>::new(2, 10, vec![3, 1]).expect("valid parameter");
784 let network = MultiTaskNetwork::new(config);
785
786 assert_eq!(network.num_tasks(), 2);
787 assert_eq!(network.task_names(), &["task_0", "task_1"]);
788 assert!(!network.is_fitted());
789 }
790
791 #[test]
792 fn test_multi_task_loss_computation() {
793 let config = MultiTaskConfig::<f64>::new(2, 5, vec![2, 1]).expect("valid parameter");
794 let network = MultiTaskNetwork::new(config);
795
796 let mut predictions = HashMap::new();
798 predictions.insert(
799 "task_0".to_string(),
800 Array2::from_shape_vec((2, 2), vec![1.0, 2.0, 3.0, 4.0]).expect("array shape mismatch"),
801 );
802 predictions.insert(
803 "task_1".to_string(),
804 Array2::from_shape_vec((2, 1), vec![0.5, 1.5]).expect("array shape mismatch"),
805 );
806
807 let mut targets = HashMap::new();
808 targets.insert(
809 "task_0".to_string(),
810 Array2::from_shape_vec((2, 2), vec![1.1, 1.9, 3.1, 3.9]).expect("array shape mismatch"),
811 );
812 targets.insert(
813 "task_1".to_string(),
814 Array2::from_shape_vec((2, 1), vec![0.6, 1.4]).expect("array shape mismatch"),
815 );
816
817 let loss = network
818 .compute_multi_task_loss(&predictions, &targets)
819 .expect("operation should succeed");
820 assert!(loss > 0.0);
821 }
822
823 #[test]
824 fn test_task_weight_updates() {
825 let config = MultiTaskConfig::<f64>::new(3, 5, vec![1, 1, 1])
826 .expect("valid parameter")
827 .with_task_weighting(TaskWeightingStrategy::DynamicWeighting {
828 adaptation_rate: 0.1,
829 min_weight: 0.1,
830 max_weight: 5.0,
831 });
832
833 let mut network = MultiTaskNetwork::new(config);
834 let task_losses = vec![1.0, 2.0, 0.5];
835
836 network
837 .update_task_weights(&task_losses, 1)
838 .expect("operation should succeed");
839 let weights = network.task_weights();
840
841 assert_eq!(weights.len(), 3);
842 assert!(weights[1] > weights[2]); }
845
846 #[test]
847 fn test_multi_task_serialization() {
848 let _config = MultiTaskConfig::<f64>::new(2, 10, vec![3, 1])
849 .expect("valid parameter")
850 .with_task_names(vec!["classification".to_string(), "regression".to_string()])
851 .expect("valid parameter")
852 .with_sharing_strategy(SharingStrategy::HardSharing {
853 shared_layers: 2,
854 task_specific_layers: vec![64, 32],
855 });
856
857 #[cfg(feature = "serde")]
859 {
860 let json = serde_json::to_string(&_config).expect("operation should succeed");
861 let deserialized: MultiTaskConfig<f64> =
862 serde_json::from_str(&json).expect("operation should succeed");
863 assert_eq!(deserialized.num_tasks, _config.num_tasks);
864 assert_eq!(deserialized.task_names, _config.task_names);
865 }
866 }
867
868 #[test]
869 fn test_forward_pass_error_handling() {
870 let config = MultiTaskConfig::<f64>::new(2, 5, vec![2, 1]).expect("valid parameter");
871 let mut network = MultiTaskNetwork::new(config);
872
873 let input = Array2::zeros((1, 5));
874
875 let result = network.forward_task(&input, "task_0", false);
877 assert!(result.is_err());
878
879 network.set_fitted(true);
881 let result = network.forward_task(&input, "unknown_task", false);
882 assert!(result.is_err());
883 }
884}