Skip to main content

sklears_multioutput/optimization/
joint_loss_optimization.rs

1//! Joint Loss Optimization for Multi-Output Learning
2//!
3//! This module provides joint loss optimization techniques for multi-output learning problems,
4//! where multiple output losses are combined using various strategies to optimize all outputs
5//! simultaneously rather than independently.
6//!
7//! ## Key Features
8//!
9//! - **Multiple Loss Functions**: Support for MSE, MAE, Huber, Cross-entropy, and Hinge losses
10//! - **Flexible Loss Combination**: Sum, weighted sum, max, geometric mean, and adaptive strategies
11//! - **Gradient-Based Optimization**: Efficient gradient computation for joint loss minimization
12//! - **Regularization**: L2 regularization to prevent overfitting
13//! - **Configurable Training**: Customizable learning rate, iterations, and convergence criteria
14#![allow(non_snake_case)] // Standard ML notation: X for feature matrices, K for kernels
15
16// Use SciRS2-Core for arrays and random number generation (SciRS2 Policy)
17use scirs2_core::ndarray::{Array1, Array2, ArrayView1, ArrayView2};
18use scirs2_core::random::thread_rng;
19use scirs2_core::random::RandNormal;
20use sklears_core::{
21    error::{Result as SklResult, SklearsError},
22    traits::{Estimator, Fit, Predict, Untrained},
23    types::Float,
24};
25
26/// Loss function types for joint optimization
27#[derive(Debug, Clone, PartialEq)]
28pub enum LossFunction {
29    /// Mean Squared Error
30    MSE,
31    /// Mean Absolute Error
32    MAE,
33    /// Huber Loss with configurable delta
34    Huber(Float),
35    /// Cross-entropy loss
36    CrossEntropy,
37    /// Hinge loss
38    Hinge,
39    /// Custom loss function
40    Custom(String),
41}
42
43/// Loss combination strategies for joint optimization
44#[derive(Debug, Clone, PartialEq)]
45pub enum LossCombination {
46    /// Simple sum of individual losses
47    Sum,
48    /// Weighted sum of individual losses
49    WeightedSum(Vec<Float>),
50    /// Maximum of individual losses
51    Max,
52    /// Geometric mean of individual losses
53    GeometricMean,
54    /// Adaptive weighting based on loss magnitudes
55    Adaptive,
56}
57
58/// Joint Loss Optimization configuration
59#[derive(Debug, Clone)]
60pub struct JointLossConfig {
61    /// Loss function for each output
62    pub output_losses: Vec<LossFunction>,
63    /// Loss combination strategy
64    pub combination: LossCombination,
65    /// Regularization strength
66    pub regularization: Float,
67    /// Maximum number of iterations
68    pub max_iter: usize,
69    /// Convergence tolerance
70    pub tol: Float,
71    /// Learning rate
72    pub learning_rate: Float,
73    /// Random state for reproducibility
74    pub random_state: Option<u64>,
75}
76
77impl Default for JointLossConfig {
78    fn default() -> Self {
79        Self {
80            output_losses: vec![LossFunction::MSE],
81            combination: LossCombination::Sum,
82            regularization: 0.01,
83            max_iter: 1000,
84            tol: 1e-6,
85            learning_rate: 0.01,
86            random_state: None,
87        }
88    }
89}
90
91/// Joint Loss Optimizer for multi-output learning
92#[derive(Debug, Clone)]
93pub struct JointLossOptimizer<S = Untrained> {
94    state: S,
95    config: JointLossConfig,
96}
97
98/// Trained state for Joint Loss Optimizer
99#[derive(Debug, Clone)]
100pub struct JointLossOptimizerTrained {
101    /// Model weights
102    pub weights: Array2<Float>,
103    /// Bias terms
104    pub bias: Array1<Float>,
105    /// Number of features
106    pub n_features: usize,
107    /// Number of outputs
108    pub n_outputs: usize,
109    /// Training history
110    pub loss_history: Vec<Float>,
111    /// Configuration used for training
112    pub config: JointLossConfig,
113}
114
115impl JointLossOptimizer<Untrained> {
116    /// Create a new Joint Loss Optimizer
117    pub fn new() -> Self {
118        Self {
119            state: Untrained,
120            config: JointLossConfig::default(),
121        }
122    }
123
124    /// Set the configuration
125    pub fn config(mut self, config: JointLossConfig) -> Self {
126        self.config = config;
127        self
128    }
129
130    /// Set the output loss functions
131    pub fn output_losses(mut self, losses: Vec<LossFunction>) -> Self {
132        self.config.output_losses = losses;
133        self
134    }
135
136    /// Set the loss combination strategy
137    pub fn combination(mut self, combination: LossCombination) -> Self {
138        self.config.combination = combination;
139        self
140    }
141
142    /// Set the regularization strength
143    pub fn regularization(mut self, regularization: Float) -> Self {
144        self.config.regularization = regularization;
145        self
146    }
147
148    /// Set the maximum number of iterations
149    pub fn max_iter(mut self, max_iter: usize) -> Self {
150        self.config.max_iter = max_iter;
151        self
152    }
153
154    /// Set the convergence tolerance
155    pub fn tol(mut self, tol: Float) -> Self {
156        self.config.tol = tol;
157        self
158    }
159
160    /// Set the learning rate
161    pub fn learning_rate(mut self, learning_rate: Float) -> Self {
162        self.config.learning_rate = learning_rate;
163        self
164    }
165
166    /// Set the random state
167    pub fn random_state(mut self, random_state: Option<u64>) -> Self {
168        self.config.random_state = random_state;
169        self
170    }
171}
172
173impl Default for JointLossOptimizer<Untrained> {
174    fn default() -> Self {
175        Self::new()
176    }
177}
178
179impl Estimator for JointLossOptimizer<Untrained> {
180    type Config = JointLossConfig;
181    type Error = SklearsError;
182    type Float = Float;
183
184    fn config(&self) -> &Self::Config {
185        &self.config
186    }
187}
188
189impl Fit<ArrayView2<'_, Float>, ArrayView2<'_, Float>> for JointLossOptimizer<Untrained> {
190    type Fitted = JointLossOptimizer<JointLossOptimizerTrained>;
191
192    fn fit(self, X: &ArrayView2<'_, Float>, y: &ArrayView2<'_, Float>) -> SklResult<Self::Fitted> {
193        let (n_samples, n_features) = X.dim();
194        let (y_samples, n_outputs) = y.dim();
195
196        if n_samples != y_samples {
197            return Err(SklearsError::InvalidInput(
198                "X and y must have the same number of samples".to_string(),
199            ));
200        }
201
202        if n_outputs != self.config.output_losses.len() {
203            return Err(SklearsError::InvalidInput(format!(
204                "Number of outputs ({}) must match number of loss functions ({})",
205                n_outputs,
206                self.config.output_losses.len()
207            )));
208        }
209
210        let mut rng = thread_rng();
211
212        // Initialize weights using Xavier initialization
213        let std_dev = (2.0 / (n_features + n_outputs) as Float).sqrt();
214        let normal_dist = RandNormal::new(0.0, std_dev).expect("operation should succeed");
215        let mut weights = Array2::<Float>::zeros((n_features, n_outputs));
216        for i in 0..n_features {
217            for j in 0..n_outputs {
218                weights[[i, j]] = rng.sample(normal_dist);
219            }
220        }
221        let mut bias = Array1::<Float>::zeros(n_outputs);
222
223        let mut loss_history = Vec::new();
224        let mut prev_loss = Float::INFINITY;
225
226        for _iteration in 0..self.config.max_iter {
227            // Forward pass
228            let predictions = X.dot(&weights) + &bias;
229
230            // Compute joint loss
231            let joint_loss = self.compute_joint_loss(&predictions, y)?;
232            loss_history.push(joint_loss);
233
234            // Check convergence
235            if (prev_loss - joint_loss).abs() < self.config.tol {
236                break;
237            }
238            prev_loss = joint_loss;
239
240            // Compute gradients
241            let (weight_gradients, bias_gradients) = self.compute_gradients(X, y, &predictions)?;
242
243            // Update weights and bias
244            weights = weights - self.config.learning_rate * weight_gradients;
245            bias = bias - self.config.learning_rate * bias_gradients;
246
247            // Apply regularization
248            if self.config.regularization > 0.0 {
249                weights *= 1.0 - self.config.regularization * self.config.learning_rate;
250            }
251        }
252
253        Ok(JointLossOptimizer {
254            state: JointLossOptimizerTrained {
255                weights,
256                bias,
257                n_features,
258                n_outputs,
259                loss_history,
260                config: self.config.clone(),
261            },
262            config: self.config,
263        })
264    }
265}
266
267impl JointLossOptimizer<Untrained> {
268    /// Compute joint loss based on the combination strategy
269    fn compute_joint_loss(
270        &self,
271        predictions: &Array2<Float>,
272        y: &ArrayView2<'_, Float>,
273    ) -> SklResult<Float> {
274        let mut individual_losses = Vec::new();
275
276        for (i, loss_fn) in self.config.output_losses.iter().enumerate() {
277            let pred_col = predictions.column(i);
278            let y_col = y.column(i);
279            let loss = self.compute_individual_loss(loss_fn, &pred_col, &y_col)?;
280            individual_losses.push(loss);
281        }
282
283        let joint_loss = match &self.config.combination {
284            LossCombination::Sum => individual_losses.iter().sum(),
285            LossCombination::WeightedSum(weights) => {
286                if weights.len() != individual_losses.len() {
287                    return Err(SklearsError::InvalidInput(
288                        "Weight vector length must match number of outputs".to_string(),
289                    ));
290                }
291                individual_losses
292                    .iter()
293                    .zip(weights.iter())
294                    .map(|(loss, weight)| loss * weight)
295                    .sum()
296            }
297            LossCombination::Max => individual_losses.iter().cloned().fold(0.0, Float::max),
298            LossCombination::GeometricMean => {
299                let product: Float = individual_losses.iter().product();
300                product.powf(1.0 / individual_losses.len() as Float)
301            }
302            LossCombination::Adaptive => {
303                // Adaptive weighting based on loss magnitudes
304                let total_loss: Float = individual_losses.iter().sum();
305                if total_loss > 0.0 {
306                    let weights: Vec<Float> = individual_losses
307                        .iter()
308                        .map(|&loss| loss / total_loss)
309                        .collect();
310                    individual_losses
311                        .iter()
312                        .zip(weights.iter())
313                        .map(|(loss, weight)| loss * weight)
314                        .sum()
315                } else {
316                    0.0
317                }
318            }
319        };
320
321        Ok(joint_loss)
322    }
323
324    /// Compute individual loss for a specific output
325    fn compute_individual_loss(
326        &self,
327        loss_fn: &LossFunction,
328        predictions: &ArrayView1<'_, Float>,
329        y: &ArrayView1<'_, Float>,
330    ) -> SklResult<Float> {
331        match loss_fn {
332            LossFunction::MSE => {
333                let diff = predictions - y;
334                Ok(diff.mapv(|x| x * x).mean().unwrap_or(0.0))
335            }
336            LossFunction::MAE => {
337                let diff = predictions - y;
338                Ok(diff.mapv(|x| x.abs()).mean().unwrap_or(0.0))
339            }
340            LossFunction::Huber(delta) => {
341                let diff = predictions - y;
342                let huber_loss = diff.mapv(|x| {
343                    if x.abs() <= *delta {
344                        0.5 * x * x
345                    } else {
346                        delta * x.abs() - 0.5 * delta * delta
347                    }
348                });
349                Ok(huber_loss.mean().unwrap_or(0.0))
350            }
351            LossFunction::CrossEntropy => {
352                // Assuming binary classification with sigmoid activation
353                let epsilon = 1e-15;
354                let clipped_preds = predictions.mapv(|x| x.max(epsilon).min(1.0 - epsilon));
355                let loss = y
356                    .iter()
357                    .zip(clipped_preds.iter())
358                    .map(|(y_true, y_pred)| {
359                        -(y_true * y_pred.ln() + (1.0 - y_true) * (1.0 - y_pred).ln())
360                    })
361                    .sum::<Float>()
362                    / y.len() as Float;
363                Ok(loss)
364            }
365            LossFunction::Hinge => {
366                let loss = predictions
367                    .iter()
368                    .zip(y.iter())
369                    .map(|(pred, true_val)| {
370                        let margin = true_val * pred;
371                        if margin < 1.0 {
372                            1.0 - margin
373                        } else {
374                            0.0
375                        }
376                    })
377                    .sum::<Float>()
378                    / y.len() as Float;
379                Ok(loss)
380            }
381            LossFunction::Custom(_) => Err(SklearsError::InvalidInput(
382                "Custom loss functions are not yet implemented".to_string(),
383            )),
384        }
385    }
386
387    /// Compute gradients for weights and bias
388    fn compute_gradients(
389        &self,
390        X: &ArrayView2<'_, Float>,
391        y: &ArrayView2<'_, Float>,
392        predictions: &Array2<Float>,
393    ) -> SklResult<(Array2<Float>, Array1<Float>)> {
394        let (n_samples, n_features) = X.dim();
395        let n_outputs = y.ncols();
396
397        let mut weight_gradients = Array2::<Float>::zeros((n_features, n_outputs));
398        let mut bias_gradients = Array1::<Float>::zeros(n_outputs);
399
400        for (i, loss_fn) in self.config.output_losses.iter().enumerate() {
401            let pred_col = predictions.column(i);
402            let y_col = y.column(i);
403
404            // Compute gradient for this output
405            let output_gradient = self.compute_output_gradient(loss_fn, &pred_col, &y_col)?;
406
407            // Update weight gradients
408            for j in 0..n_features {
409                weight_gradients[(j, i)] = X.column(j).dot(&output_gradient) / n_samples as Float;
410            }
411
412            // Update bias gradients
413            bias_gradients[i] = output_gradient.mean().unwrap_or(0.0);
414        }
415
416        Ok((weight_gradients, bias_gradients))
417    }
418
419    /// Compute gradient for a specific output
420    fn compute_output_gradient(
421        &self,
422        loss_fn: &LossFunction,
423        predictions: &ArrayView1<'_, Float>,
424        y: &ArrayView1<'_, Float>,
425    ) -> SklResult<Array1<Float>> {
426        let gradient = match loss_fn {
427            LossFunction::MSE => 2.0 * (predictions - y),
428            LossFunction::MAE => (predictions - y).mapv(|x| {
429                if x > 0.0 {
430                    1.0
431                } else if x < 0.0 {
432                    -1.0
433                } else {
434                    0.0
435                }
436            }),
437            LossFunction::Huber(delta) => {
438                let diff = predictions - y;
439                diff.mapv(|x| {
440                    if x.abs() <= *delta {
441                        x
442                    } else {
443                        delta * x.signum()
444                    }
445                })
446            }
447            LossFunction::CrossEntropy => {
448                // Gradient for binary cross-entropy with sigmoid
449                let epsilon = 1e-15;
450                let clipped_preds = predictions.mapv(|x| x.max(epsilon).min(1.0 - epsilon));
451                &clipped_preds - y
452            }
453            LossFunction::Hinge => predictions
454                .iter()
455                .zip(y.iter())
456                .map(|(pred, true_val)| {
457                    let margin = true_val * pred;
458                    if margin < 1.0 {
459                        -true_val
460                    } else {
461                        0.0
462                    }
463                })
464                .collect::<Array1<Float>>(),
465            LossFunction::Custom(_) => {
466                return Err(SklearsError::InvalidInput(
467                    "Custom loss functions are not yet implemented".to_string(),
468                ));
469            }
470        };
471
472        Ok(gradient)
473    }
474}
475
476impl Predict<ArrayView2<'_, Float>, Array2<Float>>
477    for JointLossOptimizer<JointLossOptimizerTrained>
478{
479    fn predict(&self, X: &ArrayView2<'_, Float>) -> SklResult<Array2<Float>> {
480        let (_n_samples, n_features) = X.dim();
481
482        if n_features != self.state.n_features {
483            return Err(SklearsError::InvalidInput(format!(
484                "Expected {} features, got {}",
485                self.state.n_features, n_features
486            )));
487        }
488
489        let predictions = X.dot(&self.state.weights) + &self.state.bias;
490        Ok(predictions)
491    }
492}
493
494impl Estimator for JointLossOptimizer<JointLossOptimizerTrained> {
495    type Config = JointLossConfig;
496    type Error = SklearsError;
497    type Float = Float;
498
499    fn config(&self) -> &Self::Config {
500        &self.state.config
501    }
502}
503
504impl JointLossOptimizer<JointLossOptimizerTrained> {
505    /// Get the training loss history
506    pub fn loss_history(&self) -> &[Float] {
507        &self.state.loss_history
508    }
509
510    /// Get the model weights
511    pub fn weights(&self) -> &Array2<Float> {
512        &self.state.weights
513    }
514
515    /// Get the bias terms
516    pub fn bias(&self) -> &Array1<Float> {
517        &self.state.bias
518    }
519
520    /// Get the number of features
521    pub fn n_features(&self) -> usize {
522        self.state.n_features
523    }
524
525    /// Get the number of outputs
526    pub fn n_outputs(&self) -> usize {
527        self.state.n_outputs
528    }
529}