Skip to main content

ipfrs_tensorlogic/
loss_function.rs

1//! TensorLossFunction — common loss functions for tensor computations.
2//!
3//! Provides MSE, MAE, Cross-Entropy, Huber, and Hinge loss with configurable
4//! reduction modes (Mean, Sum, None) and per-element gradient computation.
5
6/// Type of loss function to compute.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum LossType {
9    /// Mean Squared Error: (pred - target)^2
10    MSE,
11    /// Mean Absolute Error: |pred - target|
12    MAE,
13    /// Binary cross-entropy: -(t*ln(p+eps) + (1-t)*ln(1-p+eps))
14    CrossEntropy,
15    /// Huber loss (smooth L1): quadratic near zero, linear far from zero
16    Huber,
17    /// Hinge loss for SVM: max(0, 1 - target*pred)
18    Hinge,
19}
20
21/// How to aggregate per-element losses into a scalar.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Reduction {
24    /// Average of all element losses.
25    Mean,
26    /// Sum of all element losses.
27    Sum,
28    /// No reduction — return per-element losses (used by `compute`).
29    None,
30}
31
32/// Configuration for a [`TensorLossFunction`].
33#[derive(Debug, Clone)]
34pub struct LossConfig {
35    /// Which loss formula to use.
36    pub loss_type: LossType,
37    /// Delta threshold for Huber loss (default 1.0).
38    pub huber_delta: f64,
39    /// Small constant for numerical stability (default 1e-7).
40    pub epsilon: f64,
41    /// Reduction mode (default [`Reduction::Mean`]).
42    pub reduction: Reduction,
43}
44
45impl Default for LossConfig {
46    fn default() -> Self {
47        Self {
48            loss_type: LossType::MSE,
49            huber_delta: 1.0,
50            epsilon: 1e-7,
51            reduction: Reduction::Mean,
52        }
53    }
54}
55
56/// Statistics snapshot for a [`TensorLossFunction`].
57#[derive(Debug, Clone)]
58pub struct LossFunctionStats {
59    /// The configured loss type.
60    pub loss_type: LossType,
61    /// Total number of forward/compute/gradient calls (element-level operations).
62    pub computations: u64,
63}
64
65/// Common loss functions for tensor computations with gradient support.
66///
67/// # Examples
68///
69/// ```
70/// use ipfrs_tensorlogic::loss_function::{TensorLossFunction, LossConfig, LossType, Reduction};
71///
72/// let config = LossConfig {
73///     loss_type: LossType::MSE,
74///     reduction: Reduction::Mean,
75///     ..LossConfig::default()
76/// };
77/// let mut loss_fn = TensorLossFunction::new(config);
78///
79/// let preds = vec![1.0, 2.0, 3.0];
80/// let targets = vec![1.5, 2.5, 3.5];
81///
82/// let loss = loss_fn.forward(&preds, &targets).expect("example: should succeed in docs");
83/// assert!((loss - 0.25).abs() < 1e-10); // mean of [0.25, 0.25, 0.25]
84/// ```
85pub struct TensorLossFunction {
86    config: LossConfig,
87    computations: u64,
88}
89
90impl TensorLossFunction {
91    /// Create a new loss function with the given configuration.
92    pub fn new(config: LossConfig) -> Self {
93        Self {
94            config,
95            computations: 0,
96        }
97    }
98
99    /// Compute per-element loss values.
100    ///
101    /// Returns an error if `predictions` and `targets` have different lengths.
102    pub fn compute(&mut self, predictions: &[f64], targets: &[f64]) -> Result<Vec<f64>, String> {
103        if predictions.len() != targets.len() {
104            return Err(format!(
105                "length mismatch: predictions={} vs targets={}",
106                predictions.len(),
107                targets.len()
108            ));
109        }
110
111        let eps = self.config.epsilon;
112        let delta = self.config.huber_delta;
113
114        let losses: Vec<f64> = predictions
115            .iter()
116            .zip(targets.iter())
117            .map(|(&p, &t)| match self.config.loss_type {
118                LossType::MSE => {
119                    let d = p - t;
120                    d * d
121                }
122                LossType::MAE => (p - t).abs(),
123                LossType::CrossEntropy => -(t * (p + eps).ln() + (1.0 - t) * (1.0 - p + eps).ln()),
124                LossType::Huber => {
125                    let d = (p - t).abs();
126                    if d <= delta {
127                        0.5 * d * d
128                    } else {
129                        delta * (d - 0.5 * delta)
130                    }
131                }
132                LossType::Hinge => {
133                    let margin = 1.0 - t * p;
134                    if margin > 0.0 {
135                        margin
136                    } else {
137                        0.0
138                    }
139                }
140            })
141            .collect();
142
143        self.computations += losses.len() as u64;
144        Ok(losses)
145    }
146
147    /// Apply the configured reduction to a slice of per-element losses.
148    pub fn reduce(&self, losses: &[f64]) -> f64 {
149        match self.config.reduction {
150            Reduction::Sum => losses.iter().sum(),
151            Reduction::Mean => {
152                if losses.is_empty() {
153                    0.0
154                } else {
155                    let sum: f64 = losses.iter().sum();
156                    sum / losses.len() as f64
157                }
158            }
159            Reduction::None => {
160                // When "None" reduction is used in a scalar context, return sum
161                // (the caller should use `compute` directly for per-element values).
162                losses.iter().sum()
163            }
164        }
165    }
166
167    /// Compute loss and reduce in one call.
168    pub fn forward(&mut self, predictions: &[f64], targets: &[f64]) -> Result<f64, String> {
169        let losses = self.compute(predictions, targets)?;
170        Ok(self.reduce(&losses))
171    }
172
173    /// Compute dL/d(prediction) per element.
174    ///
175    /// Returns an error if `predictions` and `targets` have different lengths.
176    pub fn gradient(&mut self, predictions: &[f64], targets: &[f64]) -> Result<Vec<f64>, String> {
177        if predictions.len() != targets.len() {
178            return Err(format!(
179                "length mismatch: predictions={} vs targets={}",
180                predictions.len(),
181                targets.len()
182            ));
183        }
184
185        let eps = self.config.epsilon;
186        let delta = self.config.huber_delta;
187
188        let grads: Vec<f64> = predictions
189            .iter()
190            .zip(targets.iter())
191            .map(|(&p, &t)| match self.config.loss_type {
192                LossType::MSE => 2.0 * (p - t),
193                LossType::MAE => {
194                    let d = p - t;
195                    if d > 0.0 {
196                        1.0
197                    } else if d < 0.0 {
198                        -1.0
199                    } else {
200                        0.0
201                    }
202                }
203                LossType::CrossEntropy => -(t / (p + eps)) + (1.0 - t) / (1.0 - p + eps),
204                LossType::Huber => {
205                    let d = p - t;
206                    let abs_d = d.abs();
207                    if abs_d <= delta {
208                        d
209                    } else if d > 0.0 {
210                        delta
211                    } else {
212                        -delta
213                    }
214                }
215                LossType::Hinge => {
216                    if t * p < 1.0 {
217                        -t
218                    } else {
219                        0.0
220                    }
221                }
222            })
223            .collect();
224
225        self.computations += grads.len() as u64;
226        Ok(grads)
227    }
228
229    /// Static helper: compute mean squared error between two slices.
230    ///
231    /// Returns 0.0 if slices are empty or have different lengths.
232    pub fn mse(a: &[f64], b: &[f64]) -> f64 {
233        if a.len() != b.len() || a.is_empty() {
234            return 0.0;
235        }
236        let sum: f64 = a.iter().zip(b.iter()).map(|(x, y)| (x - y).powi(2)).sum();
237        sum / a.len() as f64
238    }
239
240    /// Static helper: compute mean absolute error between two slices.
241    ///
242    /// Returns 0.0 if slices are empty or have different lengths.
243    pub fn mae(a: &[f64], b: &[f64]) -> f64 {
244        if a.len() != b.len() || a.is_empty() {
245            return 0.0;
246        }
247        let sum: f64 = a.iter().zip(b.iter()).map(|(x, y)| (x - y).abs()).sum();
248        sum / a.len() as f64
249    }
250
251    /// Return a snapshot of accumulated statistics.
252    pub fn stats(&self) -> LossFunctionStats {
253        LossFunctionStats {
254            loss_type: self.config.loss_type,
255            computations: self.computations,
256        }
257    }
258}
259
260// ---------------------------------------------------------------------------
261// Tests
262// ---------------------------------------------------------------------------
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    fn cfg(loss_type: LossType) -> LossConfig {
269        LossConfig {
270            loss_type,
271            ..LossConfig::default()
272        }
273    }
274
275    fn cfg_with_reduction(loss_type: LossType, reduction: Reduction) -> LossConfig {
276        LossConfig {
277            loss_type,
278            reduction,
279            ..LossConfig::default()
280        }
281    }
282
283    // ---- MSE ----
284
285    #[test]
286    fn mse_basic() {
287        let mut f = TensorLossFunction::new(cfg(LossType::MSE));
288        let losses = f.compute(&[1.0, 2.0, 3.0], &[1.0, 2.0, 3.0]).expect("ok");
289        assert!(losses.iter().all(|&v| v.abs() < 1e-15));
290    }
291
292    #[test]
293    fn mse_nonzero() {
294        let mut f = TensorLossFunction::new(cfg(LossType::MSE));
295        let losses = f.compute(&[1.0, 2.0], &[2.0, 4.0]).expect("ok");
296        assert!((losses[0] - 1.0).abs() < 1e-15);
297        assert!((losses[1] - 4.0).abs() < 1e-15);
298    }
299
300    #[test]
301    fn mse_forward_mean() {
302        let mut f = TensorLossFunction::new(cfg(LossType::MSE));
303        let val = f.forward(&[1.0, 2.0, 3.0], &[1.5, 2.5, 3.5]).expect("ok");
304        assert!((val - 0.25).abs() < 1e-10);
305    }
306
307    #[test]
308    fn mse_gradient() {
309        let mut f = TensorLossFunction::new(cfg(LossType::MSE));
310        let g = f.gradient(&[3.0], &[1.0]).expect("ok");
311        assert!((g[0] - 4.0).abs() < 1e-15); // 2*(3-1)=4
312    }
313
314    // ---- MAE ----
315
316    #[test]
317    fn mae_basic() {
318        let mut f = TensorLossFunction::new(cfg(LossType::MAE));
319        let losses = f.compute(&[1.0, 5.0], &[3.0, 2.0]).expect("ok");
320        assert!((losses[0] - 2.0).abs() < 1e-15);
321        assert!((losses[1] - 3.0).abs() < 1e-15);
322    }
323
324    #[test]
325    fn mae_gradient_positive() {
326        let mut f = TensorLossFunction::new(cfg(LossType::MAE));
327        let g = f.gradient(&[5.0], &[3.0]).expect("ok");
328        assert!((g[0] - 1.0).abs() < 1e-15);
329    }
330
331    #[test]
332    fn mae_gradient_negative() {
333        let mut f = TensorLossFunction::new(cfg(LossType::MAE));
334        let g = f.gradient(&[1.0], &[3.0]).expect("ok");
335        assert!((g[0] - (-1.0)).abs() < 1e-15);
336    }
337
338    #[test]
339    fn mae_gradient_zero() {
340        let mut f = TensorLossFunction::new(cfg(LossType::MAE));
341        let g = f.gradient(&[3.0], &[3.0]).expect("ok");
342        assert!(g[0].abs() < 1e-15);
343    }
344
345    // ---- CrossEntropy ----
346
347    #[test]
348    fn cross_entropy_perfect_prediction() {
349        let mut f = TensorLossFunction::new(cfg(LossType::CrossEntropy));
350        // pred≈1 for target=1 should give loss≈0
351        let losses = f.compute(&[0.9999999], &[1.0]).expect("ok");
352        assert!(losses[0] < 0.001);
353    }
354
355    #[test]
356    fn cross_entropy_bad_prediction() {
357        let mut f = TensorLossFunction::new(cfg(LossType::CrossEntropy));
358        // pred≈0 for target=1 should give large loss
359        let losses = f.compute(&[0.01], &[1.0]).expect("ok");
360        assert!(losses[0] > 1.0);
361    }
362
363    #[test]
364    fn cross_entropy_gradient() {
365        let mut f = TensorLossFunction::new(cfg(LossType::CrossEntropy));
366        let eps = 1e-7;
367        let p = 0.7;
368        let t = 1.0;
369        let g = f.gradient(&[p], &[t]).expect("ok");
370        let expected = -(t / (p + eps)) + (1.0 - t) / (1.0 - p + eps);
371        assert!((g[0] - expected).abs() < 1e-10);
372    }
373
374    #[test]
375    fn cross_entropy_symmetry() {
376        // For target=0, loss should behave symmetrically
377        let mut f = TensorLossFunction::new(cfg(LossType::CrossEntropy));
378        let losses = f.compute(&[0.01], &[0.0]).expect("ok");
379        assert!(losses[0] < 0.02); // small loss for correct prediction
380    }
381
382    // ---- Huber ----
383
384    #[test]
385    fn huber_quadratic_region() {
386        let mut f = TensorLossFunction::new(cfg(LossType::Huber));
387        // |d|=0.5 <= delta=1.0 => 0.5 * 0.5^2 = 0.125
388        let losses = f.compute(&[1.5], &[1.0]).expect("ok");
389        assert!((losses[0] - 0.125).abs() < 1e-15);
390    }
391
392    #[test]
393    fn huber_linear_region() {
394        let mut f = TensorLossFunction::new(cfg(LossType::Huber));
395        // |d|=2.0 > delta=1.0 => 1.0*(2.0 - 0.5) = 1.5
396        let losses = f.compute(&[3.0], &[1.0]).expect("ok");
397        assert!((losses[0] - 1.5).abs() < 1e-15);
398    }
399
400    #[test]
401    fn huber_transition_at_delta() {
402        // At exactly |d|=delta, both branches should give same result
403        let mut f = TensorLossFunction::new(cfg(LossType::Huber));
404        let losses = f.compute(&[2.0], &[1.0]).expect("ok");
405        // quadratic: 0.5 * 1^2 = 0.5
406        // linear:    1*(1 - 0.5) = 0.5
407        assert!((losses[0] - 0.5).abs() < 1e-15);
408    }
409
410    #[test]
411    fn huber_custom_delta() {
412        let config = LossConfig {
413            loss_type: LossType::Huber,
414            huber_delta: 0.5,
415            ..LossConfig::default()
416        };
417        let mut f = TensorLossFunction::new(config);
418        // |d|=1.0 > delta=0.5 => 0.5*(1.0 - 0.25) = 0.375
419        let losses = f.compute(&[2.0], &[1.0]).expect("ok");
420        assert!((losses[0] - 0.375).abs() < 1e-15);
421    }
422
423    #[test]
424    fn huber_gradient_quadratic() {
425        let mut f = TensorLossFunction::new(cfg(LossType::Huber));
426        let g = f.gradient(&[1.3], &[1.0]).expect("ok");
427        assert!((g[0] - 0.3).abs() < 1e-14);
428    }
429
430    #[test]
431    fn huber_gradient_linear_positive() {
432        let mut f = TensorLossFunction::new(cfg(LossType::Huber));
433        let g = f.gradient(&[5.0], &[1.0]).expect("ok");
434        assert!((g[0] - 1.0).abs() < 1e-15); // delta * sign(d) = 1.0
435    }
436
437    #[test]
438    fn huber_gradient_linear_negative() {
439        let mut f = TensorLossFunction::new(cfg(LossType::Huber));
440        let g = f.gradient(&[1.0], &[5.0]).expect("ok");
441        assert!((g[0] - (-1.0)).abs() < 1e-15);
442    }
443
444    // ---- Hinge ----
445
446    #[test]
447    fn hinge_correct_large_margin() {
448        let mut f = TensorLossFunction::new(cfg(LossType::Hinge));
449        // target=1, pred=2 => max(0, 1-2)=0
450        let losses = f.compute(&[2.0], &[1.0]).expect("ok");
451        assert!(losses[0].abs() < 1e-15);
452    }
453
454    #[test]
455    fn hinge_violation() {
456        let mut f = TensorLossFunction::new(cfg(LossType::Hinge));
457        // target=1, pred=0.5 => max(0, 1-0.5)=0.5
458        let losses = f.compute(&[0.5], &[1.0]).expect("ok");
459        assert!((losses[0] - 0.5).abs() < 1e-15);
460    }
461
462    #[test]
463    fn hinge_negative_target() {
464        let mut f = TensorLossFunction::new(cfg(LossType::Hinge));
465        // target=-1, pred=-2 => max(0, 1-(-1*-2))=max(0,-1)=0
466        let losses = f.compute(&[-2.0], &[-1.0]).expect("ok");
467        assert!(losses[0].abs() < 1e-15);
468    }
469
470    #[test]
471    fn hinge_gradient_active() {
472        let mut f = TensorLossFunction::new(cfg(LossType::Hinge));
473        let g = f.gradient(&[0.5], &[1.0]).expect("ok");
474        assert!((g[0] - (-1.0)).abs() < 1e-15); // -target
475    }
476
477    #[test]
478    fn hinge_gradient_inactive() {
479        let mut f = TensorLossFunction::new(cfg(LossType::Hinge));
480        let g = f.gradient(&[2.0], &[1.0]).expect("ok");
481        assert!(g[0].abs() < 1e-15);
482    }
483
484    // ---- Reduction modes ----
485
486    #[test]
487    fn reduction_sum() {
488        let mut f = TensorLossFunction::new(cfg_with_reduction(LossType::MSE, Reduction::Sum));
489        let val = f.forward(&[1.0, 2.0], &[2.0, 4.0]).expect("ok");
490        assert!((val - 5.0).abs() < 1e-15); // 1+4=5
491    }
492
493    #[test]
494    fn reduction_mean() {
495        let mut f = TensorLossFunction::new(cfg_with_reduction(LossType::MSE, Reduction::Mean));
496        let val = f.forward(&[1.0, 2.0], &[2.0, 4.0]).expect("ok");
497        assert!((val - 2.5).abs() < 1e-15); // (1+4)/2=2.5
498    }
499
500    #[test]
501    fn reduction_none_returns_sum_in_forward() {
502        let mut f = TensorLossFunction::new(cfg_with_reduction(LossType::MSE, Reduction::None));
503        let val = f.forward(&[1.0, 2.0], &[2.0, 4.0]).expect("ok");
504        // None reduction in scalar context falls back to sum
505        assert!((val - 5.0).abs() < 1e-15);
506    }
507
508    #[test]
509    fn reduce_empty() {
510        let f = TensorLossFunction::new(cfg(LossType::MSE));
511        assert!(f.reduce(&[]).abs() < 1e-15);
512    }
513
514    // ---- Length mismatch ----
515
516    #[test]
517    fn compute_length_mismatch() {
518        let mut f = TensorLossFunction::new(cfg(LossType::MSE));
519        let res = f.compute(&[1.0, 2.0], &[1.0]);
520        assert!(res.is_err());
521        let msg = res.expect_err("should fail");
522        assert!(msg.contains("length mismatch"));
523    }
524
525    #[test]
526    fn gradient_length_mismatch() {
527        let mut f = TensorLossFunction::new(cfg(LossType::MSE));
528        let res = f.gradient(&[1.0], &[1.0, 2.0]);
529        assert!(res.is_err());
530    }
531
532    #[test]
533    fn forward_length_mismatch() {
534        let mut f = TensorLossFunction::new(cfg(LossType::MSE));
535        let res = f.forward(&[1.0, 2.0, 3.0], &[1.0]);
536        assert!(res.is_err());
537    }
538
539    // ---- Edge cases ----
540
541    #[test]
542    fn all_zeros() {
543        let mut f = TensorLossFunction::new(cfg(LossType::MSE));
544        let losses = f.compute(&[0.0, 0.0], &[0.0, 0.0]).expect("ok");
545        assert!(losses.iter().all(|&v| v.abs() < 1e-15));
546    }
547
548    #[test]
549    fn all_ones_mse() {
550        let mut f = TensorLossFunction::new(cfg(LossType::MSE));
551        let losses = f.compute(&[1.0, 1.0], &[1.0, 1.0]).expect("ok");
552        assert!(losses.iter().all(|&v| v.abs() < 1e-15));
553    }
554
555    #[test]
556    fn empty_inputs() {
557        let mut f = TensorLossFunction::new(cfg(LossType::MSE));
558        let losses = f.compute(&[], &[]).expect("ok");
559        assert!(losses.is_empty());
560    }
561
562    #[test]
563    fn single_element() {
564        let mut f = TensorLossFunction::new(cfg(LossType::MAE));
565        let val = f.forward(&[5.0], &[3.0]).expect("ok");
566        assert!((val - 2.0).abs() < 1e-15);
567    }
568
569    // ---- Static helpers ----
570
571    #[test]
572    fn static_mse() {
573        let val = TensorLossFunction::mse(&[1.0, 2.0], &[2.0, 4.0]);
574        assert!((val - 2.5).abs() < 1e-15);
575    }
576
577    #[test]
578    fn static_mae() {
579        let val = TensorLossFunction::mae(&[1.0, 2.0], &[3.0, 5.0]);
580        assert!((val - 2.5).abs() < 1e-15);
581    }
582
583    #[test]
584    fn static_mse_length_mismatch() {
585        let val = TensorLossFunction::mse(&[1.0], &[1.0, 2.0]);
586        assert!(val.abs() < 1e-15);
587    }
588
589    #[test]
590    fn static_mae_empty() {
591        let val = TensorLossFunction::mae(&[], &[]);
592        assert!(val.abs() < 1e-15);
593    }
594
595    // ---- Stats ----
596
597    #[test]
598    fn stats_initial() {
599        let f = TensorLossFunction::new(cfg(LossType::Huber));
600        let s = f.stats();
601        assert_eq!(s.loss_type, LossType::Huber);
602        assert_eq!(s.computations, 0);
603    }
604
605    #[test]
606    fn stats_after_compute() {
607        let mut f = TensorLossFunction::new(cfg(LossType::MSE));
608        let _ = f.compute(&[1.0, 2.0, 3.0], &[0.0, 0.0, 0.0]);
609        assert_eq!(f.stats().computations, 3);
610    }
611
612    #[test]
613    fn stats_accumulate() {
614        let mut f = TensorLossFunction::new(cfg(LossType::MSE));
615        let _ = f.compute(&[1.0, 2.0], &[0.0, 0.0]);
616        let _ = f.gradient(&[1.0, 2.0, 3.0], &[0.0, 0.0, 0.0]);
617        assert_eq!(f.stats().computations, 5); // 2 + 3
618    }
619
620    // ---- Numerical gradient verification ----
621
622    #[test]
623    fn numerical_gradient_mse() {
624        verify_numerical_gradient(LossType::MSE, &[1.5, 2.5, 0.3], &[1.0, 3.0, 0.1]);
625    }
626
627    #[test]
628    fn numerical_gradient_mae() {
629        // MAE gradient is discontinuous at 0, so avoid exact matches
630        verify_numerical_gradient(LossType::MAE, &[1.5, 2.5, 0.3], &[1.0, 3.0, 0.1]);
631    }
632
633    #[test]
634    fn numerical_gradient_cross_entropy() {
635        verify_numerical_gradient(LossType::CrossEntropy, &[0.7, 0.3, 0.9], &[1.0, 0.0, 1.0]);
636    }
637
638    #[test]
639    fn numerical_gradient_huber() {
640        verify_numerical_gradient(LossType::Huber, &[1.5, 4.0, 0.3], &[1.0, 1.0, 0.1]);
641    }
642
643    /// Verify analytical gradient against finite-difference approximation.
644    fn verify_numerical_gradient(loss_type: LossType, preds: &[f64], targets: &[f64]) {
645        let config = cfg(loss_type);
646        let mut f = TensorLossFunction::new(config.clone());
647        let analytical = f.gradient(preds, targets).expect("ok");
648
649        let h = 1e-5;
650        for i in 0..preds.len() {
651            let mut p_plus = preds.to_vec();
652            let mut p_minus = preds.to_vec();
653            p_plus[i] += h;
654            p_minus[i] -= h;
655
656            let mut f1 = TensorLossFunction::new(config.clone());
657            let mut f2 = TensorLossFunction::new(config.clone());
658            let l_plus = f1.compute(&p_plus, targets).expect("ok");
659            let l_minus = f2.compute(&p_minus, targets).expect("ok");
660
661            let numerical = (l_plus[i] - l_minus[i]) / (2.0 * h);
662            let tol = 1e-4;
663            assert!(
664                (analytical[i] - numerical).abs() < tol,
665                "{:?} grad[{}]: analytical={}, numerical={}",
666                loss_type,
667                i,
668                analytical[i],
669                numerical
670            );
671        }
672    }
673}