use crate::models::Sequential;
use crate::NeuralResult;
use scirs2_core::ndarray::{Array2, ScalarOperand};
use sklears_core::error::SklearsError;
use sklears_core::types::FloatBounds;
use std::collections::HashMap;
use std::iter::Sum;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum SharingStrategy {
HardSharing {
shared_layers: usize,
task_specific_layers: Vec<usize>,
},
SoftSharing {
l2_penalty: f64,
similarity_threshold: f64,
},
CrossStitch {
num_units: Vec<usize>,
},
AttentionSharing {
attention_dim: usize,
},
}
impl Default for SharingStrategy {
fn default() -> Self {
SharingStrategy::HardSharing {
shared_layers: 2,
task_specific_layers: vec![64, 32],
}
}
}
#[derive(Debug, Clone, PartialEq, Default)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub enum TaskWeightingStrategy {
#[default]
Equal,
Manual(Vec<f64>),
UncertaintyWeighting,
DynamicWeighting {
adaptation_rate: f64,
min_weight: f64,
max_weight: f64,
},
GradNorm {
alpha: f64,
initial_weights: Vec<f64>,
},
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(
feature = "serde",
serde(bound = "T: FloatBounds + serde::Serialize + serde::de::DeserializeOwned")
)]
pub struct MultiTaskLoss<T: FloatBounds> {
pub task_losses: Vec<String>, pub weighting_strategy: TaskWeightingStrategy,
pub task_weights: Vec<T>,
pub regularization_strength: Option<T>,
}
impl<T: FloatBounds> Default for MultiTaskLoss<T> {
fn default() -> Self {
Self {
task_losses: vec!["mse".to_string()],
weighting_strategy: TaskWeightingStrategy::Equal,
task_weights: vec![T::from(1.0).unwrap_or_else(|| T::zero())],
regularization_strength: None,
}
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(
feature = "serde",
serde(bound = "T: FloatBounds + serde::Serialize + serde::de::DeserializeOwned")
)]
pub struct MultiTaskConfig<T: FloatBounds> {
pub num_tasks: usize,
pub input_dim: usize,
pub output_dims: Vec<usize>,
pub sharing_strategy: SharingStrategy,
pub loss_config: MultiTaskLoss<T>,
pub task_names: Vec<String>,
pub use_task_embeddings: bool,
pub task_embedding_dim: usize,
}
impl<T: FloatBounds> MultiTaskConfig<T> {
pub fn new(num_tasks: usize, input_dim: usize, output_dims: Vec<usize>) -> NeuralResult<Self> {
if output_dims.len() != num_tasks {
return Err(SklearsError::InvalidParameter {
name: "output_dims".to_string(),
reason: "number of output dimensions must match number of tasks".to_string(),
});
}
Ok(Self {
num_tasks,
input_dim,
output_dims,
sharing_strategy: SharingStrategy::default(),
loss_config: MultiTaskLoss::default(),
task_names: (0..num_tasks).map(|i| format!("task_{}", i)).collect(),
use_task_embeddings: false,
task_embedding_dim: 8,
})
}
pub fn with_task_names(mut self, names: Vec<String>) -> NeuralResult<Self> {
if names.len() != self.num_tasks {
return Err(SklearsError::InvalidParameter {
name: "task_names".to_string(),
reason: "number of task names must match number of tasks".to_string(),
});
}
self.task_names = names;
Ok(self)
}
pub fn with_sharing_strategy(mut self, strategy: SharingStrategy) -> Self {
self.sharing_strategy = strategy;
self
}
pub fn with_task_weighting(mut self, strategy: TaskWeightingStrategy) -> Self {
self.loss_config.weighting_strategy = strategy;
self
}
pub fn with_task_embeddings(mut self, embedding_dim: usize) -> Self {
self.use_task_embeddings = true;
self.task_embedding_dim = embedding_dim;
self
}
}
pub struct MultiTaskNetwork<T: FloatBounds> {
config: MultiTaskConfig<T>,
shared_model: Option<Sequential<T>>,
task_heads: HashMap<String, Sequential<T>>,
task_embeddings: Option<Array2<T>>,
cross_stitch_units: Option<Vec<Array2<T>>>,
is_fitted: bool,
}
impl<T: FloatBounds + ScalarOperand + Sum> MultiTaskNetwork<T> {
pub fn new(config: MultiTaskConfig<T>) -> Self {
Self {
config,
shared_model: None,
task_heads: HashMap::new(),
task_embeddings: None,
cross_stitch_units: None,
is_fitted: false,
}
}
pub fn build_architecture(&mut self) -> NeuralResult<()> {
let strategy = self.config.sharing_strategy.clone();
match strategy {
SharingStrategy::HardSharing {
shared_layers,
task_specific_layers,
} => {
self.build_hard_sharing_architecture(shared_layers, &task_specific_layers)?;
}
SharingStrategy::SoftSharing { .. } => {
self.build_soft_sharing_architecture()?;
}
SharingStrategy::CrossStitch { num_units } => {
self.build_cross_stitch_architecture(&num_units)?;
}
SharingStrategy::AttentionSharing { attention_dim } => {
self.build_attention_sharing_architecture(attention_dim)?;
}
}
if self.config.use_task_embeddings {
self.initialize_task_embeddings()?;
}
Ok(())
}
fn build_hard_sharing_architecture(
&mut self,
shared_layers: usize,
task_specific_layers: &[usize],
) -> NeuralResult<()> {
let shared_model = Sequential::new();
let mut current_dim = self.config.input_dim;
for _ in 0..shared_layers {
let layer_dim = current_dim / 2; current_dim = layer_dim.max(8); }
self.shared_model = Some(shared_model);
for (task_idx, task_name) in self.config.task_names.iter().enumerate() {
let task_head = Sequential::new();
let mut _head_dim = current_dim;
for &layer_size in task_specific_layers {
_head_dim = layer_size;
}
let _output_dim = self.config.output_dims[task_idx];
self.task_heads.insert(task_name.clone(), task_head);
}
Ok(())
}
fn build_soft_sharing_architecture(&mut self) -> NeuralResult<()> {
for (task_idx, task_name) in self.config.task_names.iter().enumerate() {
let task_network = Sequential::new();
let _output_dim = self.config.output_dims[task_idx];
self.task_heads.insert(task_name.clone(), task_network);
}
Ok(())
}
fn build_cross_stitch_architecture(&mut self, num_units: &[usize]) -> NeuralResult<()> {
let mut units = Vec::new();
for &_unit_size in num_units {
let unit = Array2::eye(self.config.num_tasks)
* T::from(0.8).unwrap_or_else(|| T::zero())
+ Array2::from_elem(
(self.config.num_tasks, self.config.num_tasks),
T::from(0.2).unwrap_or_else(|| T::zero())
/ T::from(self.config.num_tasks as f64).unwrap_or_else(|| T::zero()),
);
units.push(unit);
}
self.cross_stitch_units = Some(units);
for (task_idx, task_name) in self.config.task_names.iter().enumerate() {
let task_network = Sequential::new();
let _output_dim = self.config.output_dims[task_idx];
self.task_heads.insert(task_name.clone(), task_network);
}
Ok(())
}
fn build_attention_sharing_architecture(&mut self, _attention_dim: usize) -> NeuralResult<()> {
let shared_model = Sequential::new();
self.shared_model = Some(shared_model);
for (task_idx, task_name) in self.config.task_names.iter().enumerate() {
let task_head = Sequential::new();
let _output_dim = self.config.output_dims[task_idx];
self.task_heads.insert(task_name.clone(), task_head);
}
Ok(())
}
fn initialize_task_embeddings(&mut self) -> NeuralResult<()> {
let mut rng = scirs2_core::random::thread_rng();
let mut embeddings = Array2::zeros((self.config.num_tasks, self.config.task_embedding_dim));
for mut row in embeddings.rows_mut() {
for elem in row.iter_mut() {
*elem = T::from(rng.gen_range(-0.1..0.1)).unwrap_or_else(|| T::zero());
}
}
self.task_embeddings = Some(embeddings);
Ok(())
}
pub fn forward_task(
&mut self,
input: &Array2<T>,
task_name: &str,
training: bool,
) -> NeuralResult<Array2<T>> {
if !self.is_fitted {
return Err(SklearsError::InvalidParameter {
name: "model".to_string(),
reason: "Model must be fitted before prediction".to_string(),
});
}
let features = if let Some(ref mut shared_model) = self.shared_model {
shared_model.forward(input, training)?
} else {
input.clone()
};
if let Some(task_head) = self.task_heads.get_mut(task_name) {
task_head.forward(&features, training)
} else {
Err(SklearsError::InvalidParameter {
name: "task_name".to_string(),
reason: format!("Unknown task: {}", task_name),
})
}
}
pub fn forward_all_tasks(
&mut self,
input: &Array2<T>,
training: bool,
) -> NeuralResult<HashMap<String, Array2<T>>> {
let mut outputs = HashMap::new();
for task_name in &self.config.task_names.clone() {
let output = self.forward_task(input, task_name, training)?;
outputs.insert(task_name.clone(), output);
}
Ok(outputs)
}
pub fn compute_multi_task_loss(
&self,
predictions: &HashMap<String, Array2<T>>,
targets: &HashMap<String, Array2<T>>,
) -> NeuralResult<T> {
let mut total_loss = T::from(0.0).unwrap_or_else(|| T::zero());
let mut valid_tasks = 0;
for (task_idx, task_name) in self.config.task_names.iter().enumerate() {
if let (Some(pred), Some(target)) = (predictions.get(task_name), targets.get(task_name))
{
let task_loss = self.compute_task_loss(pred, target, task_idx)?;
let weight = if task_idx < self.config.loss_config.task_weights.len() {
self.config.loss_config.task_weights[task_idx]
} else {
T::from(1.0).unwrap_or_else(|| T::zero())
};
total_loss += weight * task_loss;
valid_tasks += 1;
}
}
if valid_tasks == 0 {
return Err(SklearsError::InvalidParameter {
name: "targets".to_string(),
reason: "No valid task targets provided".to_string(),
});
}
Ok(total_loss / T::from(valid_tasks as f64).unwrap_or_else(|| T::zero()))
}
fn compute_task_loss(
&self,
predictions: &Array2<T>,
targets: &Array2<T>,
task_idx: usize,
) -> NeuralResult<T> {
if predictions.shape() != targets.shape() {
return Err(SklearsError::InvalidParameter {
name: "shape".to_string(),
reason: "Predictions and targets must have the same shape".to_string(),
});
}
let loss_type = self
.config
.loss_config
.task_losses
.get(task_idx)
.map(|s| s.as_str())
.unwrap_or("mse");
match loss_type {
"mse" => {
let diff = predictions - targets;
let squared_diff = &diff * &diff;
Ok(squared_diff
.mean()
.expect("mean should not fail on non-empty array"))
}
"mae" => {
let diff = predictions - targets;
let abs_diff = diff.mapv(|x| x.abs());
Ok(abs_diff
.mean()
.expect("mean should not fail on non-empty array"))
}
_ => Err(SklearsError::InvalidParameter {
name: "loss_type".to_string(),
reason: format!("Unsupported loss type: {}", loss_type),
}),
}
}
pub fn update_task_weights(&mut self, task_losses: &[T], _epoch: usize) -> NeuralResult<()> {
let strategy = self.config.loss_config.weighting_strategy.clone();
match strategy {
TaskWeightingStrategy::Equal => {
self.config.loss_config.task_weights =
vec![T::from(1.0).unwrap_or_else(|| T::zero()); self.config.num_tasks];
}
TaskWeightingStrategy::Manual(weights) => {
self.config.loss_config.task_weights = weights
.iter()
.map(|&w| T::from(w).unwrap_or_else(|| T::zero()))
.collect();
}
TaskWeightingStrategy::DynamicWeighting {
adaptation_rate,
min_weight,
max_weight,
} => {
self.update_dynamic_weights(task_losses, adaptation_rate, min_weight, max_weight)?;
}
TaskWeightingStrategy::UncertaintyWeighting => {
self.update_uncertainty_weights(task_losses)?;
}
TaskWeightingStrategy::GradNorm {
alpha,
initial_weights,
} => {
self.update_gradnorm_weights(task_losses, alpha, &initial_weights)?;
}
}
Ok(())
}
fn update_dynamic_weights(
&mut self,
task_losses: &[T],
adaptation_rate: f64,
min_weight: f64,
max_weight: f64,
) -> NeuralResult<()> {
if task_losses.is_empty() {
return Ok(());
}
let total_loss: T = task_losses
.iter()
.copied()
.fold(T::from(0.0).unwrap_or_else(|| T::zero()), |a, b| a + b);
let avg_loss = total_loss / T::from(task_losses.len() as f64).unwrap_or_else(|| T::zero());
for (i, &task_loss) in task_losses.iter().enumerate() {
let current_weight = self
.config
.loss_config
.task_weights
.get(i)
.copied()
.unwrap_or(T::from(1.0).unwrap_or_else(|| T::zero()));
let difficulty_ratio = task_loss / avg_loss;
let target_weight = T::from(1.0).unwrap_or_else(|| T::zero())
+ (difficulty_ratio - T::from(1.0).unwrap_or_else(|| T::zero()))
* T::from(adaptation_rate).unwrap_or_else(|| T::zero());
let new_weight = current_weight * T::from(0.9).unwrap_or_else(|| T::zero())
+ target_weight * T::from(0.1).unwrap_or_else(|| T::zero());
let clamped_weight = T::from(
new_weight
.to_f64()
.unwrap_or(0.0)
.clamp(min_weight, max_weight),
)
.unwrap_or_else(|| T::zero());
if i < self.config.loss_config.task_weights.len() {
self.config.loss_config.task_weights[i] = clamped_weight;
} else {
self.config.loss_config.task_weights.push(clamped_weight);
}
}
Ok(())
}
fn update_uncertainty_weights(&mut self, task_losses: &[T]) -> NeuralResult<()> {
if task_losses.len() < 2 {
return Ok(());
}
let total_loss = task_losses
.iter()
.copied()
.fold(T::from(0.0).unwrap_or_else(|| T::zero()), |acc, value| {
acc + value
});
let mean_loss = total_loss / T::from(task_losses.len() as f64).unwrap_or_else(|| T::zero());
let mut weights = Vec::new();
for &loss in task_losses {
let uncertainty = (loss - mean_loss).abs() + T::from(1e-8).unwrap_or_else(|| T::zero());
weights.push(T::from(1.0).unwrap_or_else(|| T::zero()) / uncertainty);
}
let total_weight = weights
.iter()
.copied()
.fold(T::from(0.0).unwrap_or_else(|| T::zero()), |acc, value| {
acc + value
});
let normalization =
T::from(weights.len() as f64).unwrap_or_else(|| T::zero()) / total_weight;
for weight in &mut weights {
*weight *= normalization;
}
self.config.loss_config.task_weights = weights;
Ok(())
}
fn update_gradnorm_weights(
&mut self,
task_losses: &[T],
alpha: f64,
initial_weights: &[f64],
) -> NeuralResult<()> {
let mut weights = initial_weights
.iter()
.map(|&w| T::from(w).unwrap_or_else(|| T::zero()))
.collect::<Vec<_>>();
if task_losses.len() != weights.len() {
return Err(SklearsError::InvalidParameter {
name: "weights".to_string(),
reason: "Number of weights must match number of tasks".to_string(),
});
}
let total_loss: T = task_losses
.iter()
.copied()
.fold(T::from(0.0).unwrap_or_else(|| T::zero()), |a, b| a + b);
let avg_loss = total_loss / T::from(task_losses.len() as f64).unwrap_or_else(|| T::zero());
for (&loss, weight) in task_losses.iter().zip(weights.iter_mut()) {
let relative_rate = loss / avg_loss;
let target_rate = T::from(1.0).unwrap_or_else(|| T::zero());
let adjustment =
(relative_rate / target_rate).powf(T::from(alpha).unwrap_or_else(|| T::zero()));
*weight *= adjustment;
}
let total_weight: T = weights
.iter()
.copied()
.fold(T::from(0.0).unwrap_or_else(|| T::zero()), |a, b| a + b);
let weights_len = weights.len();
for weight in &mut weights {
*weight =
*weight / total_weight * T::from(weights_len as f64).unwrap_or_else(|| T::zero());
}
self.config.loss_config.task_weights = weights;
Ok(())
}
pub fn config(&self) -> &MultiTaskConfig<T> {
&self.config
}
pub fn task_names(&self) -> &[String] {
&self.config.task_names
}
pub fn num_tasks(&self) -> usize {
self.config.num_tasks
}
pub fn is_fitted(&self) -> bool {
self.is_fitted
}
pub fn set_fitted(&mut self, fitted: bool) {
self.is_fitted = fitted;
}
pub fn task_weights(&self) -> &[T] {
&self.config.loss_config.task_weights
}
}
impl<T: FloatBounds> std::fmt::Debug for MultiTaskNetwork<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MultiTaskNetwork")
.field("num_tasks", &self.config.num_tasks)
.field("task_names", &self.config.task_names)
.field("sharing_strategy", &self.config.sharing_strategy)
.field("is_fitted", &self.is_fitted)
.finish()
}
}
#[allow(non_snake_case)]
#[cfg(test)]
mod tests {
use super::*;
use scirs2_core::ndarray::Array2;
#[test]
fn test_multi_task_config_creation() {
let config = MultiTaskConfig::<f64>::new(3, 10, vec![2, 3, 1]).expect("valid parameter");
assert_eq!(config.num_tasks, 3);
assert_eq!(config.input_dim, 10);
assert_eq!(config.output_dims, vec![2, 3, 1]);
assert_eq!(config.task_names, vec!["task_0", "task_1", "task_2"]);
}
#[test]
fn test_multi_task_config_with_task_names() {
let config = MultiTaskConfig::<f64>::new(2, 5, vec![1, 1])
.expect("valid parameter")
.with_task_names(vec!["classification".to_string(), "regression".to_string()])
.expect("valid parameter");
assert_eq!(config.task_names, vec!["classification", "regression"]);
}
#[test]
fn test_sharing_strategies() {
let hard_sharing = SharingStrategy::HardSharing {
shared_layers: 3,
task_specific_layers: vec![64, 32],
};
assert!(matches!(hard_sharing, SharingStrategy::HardSharing { .. }));
let soft_sharing = SharingStrategy::SoftSharing {
l2_penalty: 0.01,
similarity_threshold: 0.8,
};
assert!(matches!(soft_sharing, SharingStrategy::SoftSharing { .. }));
}
#[test]
fn test_task_weighting_strategies() {
let equal = TaskWeightingStrategy::Equal;
assert!(matches!(equal, TaskWeightingStrategy::Equal));
let manual = TaskWeightingStrategy::Manual(vec![1.0, 2.0, 0.5]);
assert!(matches!(manual, TaskWeightingStrategy::Manual(_)));
let dynamic = TaskWeightingStrategy::DynamicWeighting {
adaptation_rate: 0.1,
min_weight: 0.1,
max_weight: 5.0,
};
assert!(matches!(
dynamic,
TaskWeightingStrategy::DynamicWeighting { .. }
));
}
#[test]
fn test_multi_task_network_creation() {
let config = MultiTaskConfig::<f64>::new(2, 10, vec![3, 1]).expect("valid parameter");
let network = MultiTaskNetwork::new(config);
assert_eq!(network.num_tasks(), 2);
assert_eq!(network.task_names(), &["task_0", "task_1"]);
assert!(!network.is_fitted());
}
#[test]
fn test_multi_task_loss_computation() {
let config = MultiTaskConfig::<f64>::new(2, 5, vec![2, 1]).expect("valid parameter");
let network = MultiTaskNetwork::new(config);
let mut predictions = HashMap::new();
predictions.insert(
"task_0".to_string(),
Array2::from_shape_vec((2, 2), vec![1.0, 2.0, 3.0, 4.0]).expect("array shape mismatch"),
);
predictions.insert(
"task_1".to_string(),
Array2::from_shape_vec((2, 1), vec![0.5, 1.5]).expect("array shape mismatch"),
);
let mut targets = HashMap::new();
targets.insert(
"task_0".to_string(),
Array2::from_shape_vec((2, 2), vec![1.1, 1.9, 3.1, 3.9]).expect("array shape mismatch"),
);
targets.insert(
"task_1".to_string(),
Array2::from_shape_vec((2, 1), vec![0.6, 1.4]).expect("array shape mismatch"),
);
let loss = network
.compute_multi_task_loss(&predictions, &targets)
.expect("operation should succeed");
assert!(loss > 0.0);
}
#[test]
fn test_task_weight_updates() {
let config = MultiTaskConfig::<f64>::new(3, 5, vec![1, 1, 1])
.expect("valid parameter")
.with_task_weighting(TaskWeightingStrategy::DynamicWeighting {
adaptation_rate: 0.1,
min_weight: 0.1,
max_weight: 5.0,
});
let mut network = MultiTaskNetwork::new(config);
let task_losses = vec![1.0, 2.0, 0.5];
network
.update_task_weights(&task_losses, 1)
.expect("operation should succeed");
let weights = network.task_weights();
assert_eq!(weights.len(), 3);
assert!(weights[1] > weights[2]); }
#[test]
fn test_multi_task_serialization() {
let _config = MultiTaskConfig::<f64>::new(2, 10, vec![3, 1])
.expect("valid parameter")
.with_task_names(vec!["classification".to_string(), "regression".to_string()])
.expect("valid parameter")
.with_sharing_strategy(SharingStrategy::HardSharing {
shared_layers: 2,
task_specific_layers: vec![64, 32],
});
#[cfg(feature = "serde")]
{
let json = serde_json::to_string(&_config).expect("operation should succeed");
let deserialized: MultiTaskConfig<f64> =
serde_json::from_str(&json).expect("operation should succeed");
assert_eq!(deserialized.num_tasks, _config.num_tasks);
assert_eq!(deserialized.task_names, _config.task_names);
}
}
#[test]
fn test_forward_pass_error_handling() {
let config = MultiTaskConfig::<f64>::new(2, 5, vec![2, 1]).expect("valid parameter");
let mut network = MultiTaskNetwork::new(config);
let input = Array2::zeros((1, 5));
let result = network.forward_task(&input, "task_0", false);
assert!(result.is_err());
network.set_fitted(true);
let result = network.forward_task(&input, "unknown_task", false);
assert!(result.is_err());
}
}