Skip to main content

sklears_neural/
multi_task.rs

1//! Multi-Task Learning for Neural Networks
2//!
3//! This module provides comprehensive multi-task learning capabilities, allowing models
4//! to learn multiple related tasks simultaneously with shared representations.
5
6use 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/// Multi-task learning strategies for parameter sharing
18#[derive(Debug, Clone, PartialEq)]
19#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
20pub enum SharingStrategy {
21    /// Hard parameter sharing: shared backbone, task-specific heads
22    HardSharing {
23        /// Number of shared bottom layers before task-specific heads branch off
24        shared_layers: usize,
25        /// Sizes (number of neurons) of each task-specific layer after the shared trunk
26        task_specific_layers: Vec<usize>,
27    },
28    /// Soft parameter sharing: task-specific networks with regularization
29    SoftSharing {
30        /// L2 penalty encouraging the task-specific weight matrices to remain similar
31        l2_penalty: f64,
32        /// Cosine similarity threshold below which the penalty is applied more aggressively
33        similarity_threshold: f64,
34    },
35    /// Cross-stitch networks: linear combinations of task-specific features
36    CrossStitch {
37        /// Number of units in each cross-stitch layer
38        num_units: Vec<usize>,
39    },
40    /// Attention-based sharing: learn what to share between tasks
41    AttentionSharing {
42        /// Dimensionality of the attention keys and queries used for sharing decisions
43        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/// Task weighting strategies for multi-task loss balancing
57#[derive(Debug, Clone, PartialEq, Default)]
58#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
59pub enum TaskWeightingStrategy {
60    /// Equal weights for all tasks
61    #[default]
62    Equal,
63    /// Manual task weights
64    Manual(Vec<f64>),
65    /// Uncertainty-based weighting (homoscedastic uncertainty)
66    UncertaintyWeighting,
67    /// Dynamic weight adjustment based on task difficulty
68    DynamicWeighting {
69        /// Rate of adaptation
70        adaptation_rate: f64,
71        /// Minimum allowed weight
72        min_weight: f64,
73        /// Maximum allowed weight
74        max_weight: f64,
75    },
76    /// Gradient normalization based weighting
77    GradNorm {
78        /// Balancing factor alpha for gradient normalization
79        alpha: f64,
80        /// Initial weight values per task
81        initial_weights: Vec<f64>,
82    },
83}
84
85/// Multi-task loss configuration
86#[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    /// Task-specific loss functions
94    pub task_losses: Vec<String>, // "mse", "cross_entropy", "binary_cross_entropy"
95    /// Task weighting strategy
96    pub weighting_strategy: TaskWeightingStrategy,
97    /// Current task weights
98    pub task_weights: Vec<T>,
99    /// Regularization strength for soft sharing
100    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/// Multi-task neural network configuration
115#[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    /// Number of tasks
123    pub num_tasks: usize,
124    /// Input dimension
125    pub input_dim: usize,
126    /// Output dimensions for each task
127    pub output_dims: Vec<usize>,
128    /// Parameter sharing strategy
129    pub sharing_strategy: SharingStrategy,
130    /// Loss configuration
131    pub loss_config: MultiTaskLoss<T>,
132    /// Task names for identification
133    pub task_names: Vec<String>,
134    /// Whether to use task embeddings
135    pub use_task_embeddings: bool,
136    /// Task embedding dimension
137    pub task_embedding_dim: usize,
138}
139
140impl<T: FloatBounds> MultiTaskConfig<T> {
141    /// Create a new multi-task configuration; `output_dims.len()` must equal `num_tasks`
142    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    /// Set task names
163    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    /// Set sharing strategy
175    pub fn with_sharing_strategy(mut self, strategy: SharingStrategy) -> Self {
176        self.sharing_strategy = strategy;
177        self
178    }
179
180    /// Set task weighting strategy
181    pub fn with_task_weighting(mut self, strategy: TaskWeightingStrategy) -> Self {
182        self.loss_config.weighting_strategy = strategy;
183        self
184    }
185
186    /// Enable task embeddings
187    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
194/// Multi-task neural network model
195pub struct MultiTaskNetwork<T: FloatBounds> {
196    /// Model configuration
197    config: MultiTaskConfig<T>,
198    /// Shared backbone model
199    shared_model: Option<Sequential<T>>,
200    /// Task-specific heads
201    task_heads: HashMap<String, Sequential<T>>,
202    /// Task embeddings for conditional computation
203    task_embeddings: Option<Array2<T>>,
204    /// Cross-stitch units for soft sharing
205    cross_stitch_units: Option<Vec<Array2<T>>>,
206    /// Training state
207    is_fitted: bool,
208}
209
210impl<T: FloatBounds + ScalarOperand + Sum> MultiTaskNetwork<T> {
211    /// Create a new multi-task network
212    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    /// Build the network architecture based on configuration
224    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        // Initialize task embeddings if enabled
245        if self.config.use_task_embeddings {
246            self.initialize_task_embeddings()?;
247        }
248
249        Ok(())
250    }
251
252    /// Build hard parameter sharing architecture
253    fn build_hard_sharing_architecture(
254        &mut self,
255        shared_layers: usize,
256        task_specific_layers: &[usize],
257    ) -> NeuralResult<()> {
258        // Build shared backbone
259        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; // Simple reduction strategy
264                                             // Note: In a real implementation, we would add actual Dense layers here
265                                             // This is a simplified version for demonstration
266            current_dim = layer_dim.max(8); // Minimum layer size
267        }
268
269        self.shared_model = Some(shared_model);
270
271        // Build task-specific heads
272        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                // Add task-specific layers
278                _head_dim = layer_size;
279            }
280
281            // Final output layer
282            let _output_dim = self.config.output_dims[task_idx];
283            // Add final layer with output_dim neurons
284
285            self.task_heads.insert(task_name.clone(), task_head);
286        }
287
288        Ok(())
289    }
290
291    /// Build soft parameter sharing architecture
292    fn build_soft_sharing_architecture(&mut self) -> NeuralResult<()> {
293        // Create separate networks for each task with shared structure
294        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            // Build task-specific network
299            // (Implementation would add actual layers here)
300
301            self.task_heads.insert(task_name.clone(), task_network);
302        }
303
304        Ok(())
305    }
306
307    /// Build cross-stitch architecture
308    fn build_cross_stitch_architecture(&mut self, num_units: &[usize]) -> NeuralResult<()> {
309        // Initialize cross-stitch units
310        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        // Build task-specific networks
324        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            // Build network with cross-stitch connections
329            // (Implementation would add actual layers here)
330
331            self.task_heads.insert(task_name.clone(), task_network);
332        }
333
334        Ok(())
335    }
336
337    /// Build attention-based sharing architecture
338    fn build_attention_sharing_architecture(&mut self, _attention_dim: usize) -> NeuralResult<()> {
339        // Build shared encoder with attention mechanism
340        let shared_model = Sequential::new();
341        // (Implementation would add attention layers here)
342
343        self.shared_model = Some(shared_model);
344
345        // Build task-specific decoders
346        for (task_idx, task_name) in self.config.task_names.iter().enumerate() {
347            let task_head = Sequential::new();
348            // Placeholder for output dimension - used when adding final layer
349            let _output_dim = self.config.output_dims[task_idx];
350
351            // Build attention-based task head
352            // (Implementation would add actual layers here)
353
354            self.task_heads.insert(task_name.clone(), task_head);
355        }
356
357        Ok(())
358    }
359
360    /// Initialize task embeddings
361    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    /// Forward pass for a specific task
376    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        // Get shared features if using hard sharing
390        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        // Get task-specific output
397        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    /// Forward pass for all tasks
408    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    /// Compute multi-task loss
424    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    /// Compute loss for a specific task
457    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    /// Update task weights based on strategy
501    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    /// Update weights using dynamic weighting strategy
536    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        // Compute relative task difficulties
548        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            // Increase weight for harder tasks (higher loss)
564            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            // Smooth update
570            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    /// Update weights using uncertainty weighting
591    fn update_uncertainty_weights(&mut self, task_losses: &[T]) -> NeuralResult<()> {
592        // Simplified uncertainty weighting based on loss variance
593        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            // Higher uncertainty (variance) gets higher weight
608            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        // Normalize weights
613        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    /// Update weights using GradNorm algorithm
630    fn update_gradnorm_weights(
631        &mut self,
632        task_losses: &[T],
633        alpha: f64,
634        initial_weights: &[f64],
635    ) -> NeuralResult<()> {
636        // Simplified GradNorm implementation
637        // In practice, this would need gradient information
638        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        // Compute relative training rates
651        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        // Normalize weights
666        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    /// Get configuration
681    pub fn config(&self) -> &MultiTaskConfig<T> {
682        &self.config
683    }
684
685    /// Get task names
686    pub fn task_names(&self) -> &[String] {
687        &self.config.task_names
688    }
689
690    /// Get number of tasks
691    pub fn num_tasks(&self) -> usize {
692        self.config.num_tasks
693    }
694
695    /// Check if model is fitted
696    pub fn is_fitted(&self) -> bool {
697        self.is_fitted
698    }
699
700    /// Set fitted state
701    pub fn set_fitted(&mut self, fitted: bool) {
702        self.is_fitted = fitted;
703    }
704
705    /// Get current task weights
706    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        // Create sample predictions and targets
797        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        // Higher loss should get higher weight
843        assert!(weights[1] > weights[2]); // task_1 (loss=2.0) > task_2 (loss=0.5)
844    }
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        // Test that the config can be serialized if serde feature is enabled
858        #[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        // Should fail when model is not fitted
876        let result = network.forward_task(&input, "task_0", false);
877        assert!(result.is_err());
878
879        // Should fail with unknown task
880        network.set_fitted(true);
881        let result = network.forward_task(&input, "unknown_task", false);
882        assert!(result.is_err());
883    }
884}