sklears_multioutput/optimization/
joint_loss_optimization.rs1#![allow(non_snake_case)] use 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#[derive(Debug, Clone, PartialEq)]
28pub enum LossFunction {
29 MSE,
31 MAE,
33 Huber(Float),
35 CrossEntropy,
37 Hinge,
39 Custom(String),
41}
42
43#[derive(Debug, Clone, PartialEq)]
45pub enum LossCombination {
46 Sum,
48 WeightedSum(Vec<Float>),
50 Max,
52 GeometricMean,
54 Adaptive,
56}
57
58#[derive(Debug, Clone)]
60pub struct JointLossConfig {
61 pub output_losses: Vec<LossFunction>,
63 pub combination: LossCombination,
65 pub regularization: Float,
67 pub max_iter: usize,
69 pub tol: Float,
71 pub learning_rate: Float,
73 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#[derive(Debug, Clone)]
93pub struct JointLossOptimizer<S = Untrained> {
94 state: S,
95 config: JointLossConfig,
96}
97
98#[derive(Debug, Clone)]
100pub struct JointLossOptimizerTrained {
101 pub weights: Array2<Float>,
103 pub bias: Array1<Float>,
105 pub n_features: usize,
107 pub n_outputs: usize,
109 pub loss_history: Vec<Float>,
111 pub config: JointLossConfig,
113}
114
115impl JointLossOptimizer<Untrained> {
116 pub fn new() -> Self {
118 Self {
119 state: Untrained,
120 config: JointLossConfig::default(),
121 }
122 }
123
124 pub fn config(mut self, config: JointLossConfig) -> Self {
126 self.config = config;
127 self
128 }
129
130 pub fn output_losses(mut self, losses: Vec<LossFunction>) -> Self {
132 self.config.output_losses = losses;
133 self
134 }
135
136 pub fn combination(mut self, combination: LossCombination) -> Self {
138 self.config.combination = combination;
139 self
140 }
141
142 pub fn regularization(mut self, regularization: Float) -> Self {
144 self.config.regularization = regularization;
145 self
146 }
147
148 pub fn max_iter(mut self, max_iter: usize) -> Self {
150 self.config.max_iter = max_iter;
151 self
152 }
153
154 pub fn tol(mut self, tol: Float) -> Self {
156 self.config.tol = tol;
157 self
158 }
159
160 pub fn learning_rate(mut self, learning_rate: Float) -> Self {
162 self.config.learning_rate = learning_rate;
163 self
164 }
165
166 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 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 let predictions = X.dot(&weights) + &bias;
229
230 let joint_loss = self.compute_joint_loss(&predictions, y)?;
232 loss_history.push(joint_loss);
233
234 if (prev_loss - joint_loss).abs() < self.config.tol {
236 break;
237 }
238 prev_loss = joint_loss;
239
240 let (weight_gradients, bias_gradients) = self.compute_gradients(X, y, &predictions)?;
242
243 weights = weights - self.config.learning_rate * weight_gradients;
245 bias = bias - self.config.learning_rate * bias_gradients;
246
247 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 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 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 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 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 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 let output_gradient = self.compute_output_gradient(loss_fn, &pred_col, &y_col)?;
406
407 for j in 0..n_features {
409 weight_gradients[(j, i)] = X.column(j).dot(&output_gradient) / n_samples as Float;
410 }
411
412 bias_gradients[i] = output_gradient.mean().unwrap_or(0.0);
414 }
415
416 Ok((weight_gradients, bias_gradients))
417 }
418
419 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 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 pub fn loss_history(&self) -> &[Float] {
507 &self.state.loss_history
508 }
509
510 pub fn weights(&self) -> &Array2<Float> {
512 &self.state.weights
513 }
514
515 pub fn bias(&self) -> &Array1<Float> {
517 &self.state.bias
518 }
519
520 pub fn n_features(&self) -> usize {
522 self.state.n_features
523 }
524
525 pub fn n_outputs(&self) -> usize {
527 self.state.n_outputs
528 }
529}