Skip to main content

fdars_core/
scoring.rs

1//! Functional scoring metrics — MAE, MSE, MAPE, MSLE, explained variance.
2//!
3//! All metrics integrate the pointwise error function over `argvals` using
4//! Simpson's rule, producing a single scalar score per metric. Each metric
5//! averages over all curves (rows of the input matrices).
6//!
7//! # Shape Contract (all five functions)
8//!
9//! - `y_true.shape() == y_pred.shape()` — else `InvalidDimension { parameter: "y_pred" }`
10//! - `argvals.len() == y_true.ncols()` — else `InvalidDimension { parameter: "argvals" }`
11//! - `y_true.nrows() >= 1` and `argvals.len() >= 2` — else `InvalidDimension { parameter: "data" }`
12
13use crate::helpers::{simpsons_weights, NUMERICAL_EPS};
14use crate::matrix::FdMatrix;
15use crate::FdarError;
16
17/// Validate that `y_true`, `y_pred`, and `argvals` have consistent shapes.
18///
19/// Returns `(n, m)` — number of curves and evaluation points — on success.
20fn validate_shapes(
21    y_true: &FdMatrix,
22    y_pred: &FdMatrix,
23    argvals: &[f64],
24) -> Result<(usize, usize), FdarError> {
25    let (n, m) = y_true.shape();
26    if y_pred.shape() != (n, m) {
27        return Err(FdarError::InvalidDimension {
28            parameter: "y_pred",
29            expected: format!("({n}, {m})"),
30            actual: format!("{:?}", y_pred.shape()),
31        });
32    }
33    if argvals.len() != m {
34        return Err(FdarError::InvalidDimension {
35            parameter: "argvals",
36            expected: format!("{m}"),
37            actual: format!("{}", argvals.len()),
38        });
39    }
40    if n == 0 || m < 2 {
41        return Err(FdarError::InvalidDimension {
42            parameter: "data",
43            expected: "n >= 1 and m >= 2".to_string(),
44            actual: format!("n={n}, m={m}"),
45        });
46    }
47    Ok((n, m))
48}
49
50/// Functional Mean Absolute Error integrated over `argvals`.
51///
52/// Computes `functional_mae = (1/n) * sum_i ∫ |y_true_i(t) - y_pred_i(t)| dt`
53/// where the integral is approximated by Simpson's rule over `argvals`.
54///
55/// # Errors
56///
57/// - [`FdarError::InvalidDimension`] if shapes of `y_true`, `y_pred`, or `argvals`
58///   are inconsistent or `n < 1` / `m < 2`.
59pub fn functional_mae(
60    y_true: &FdMatrix,
61    y_pred: &FdMatrix,
62    argvals: &[f64],
63) -> Result<f64, FdarError> {
64    let (n, m) = validate_shapes(y_true, y_pred, argvals)?;
65    let weights = simpsons_weights(argvals);
66    let mut total = 0.0_f64;
67    for i in 0..n {
68        for j in 0..m {
69            total += (y_true[(i, j)] - y_pred[(i, j)]).abs() * weights[j];
70        }
71    }
72    Ok(total / n as f64)
73}
74
75/// Functional Mean Squared Error integrated over `argvals`.
76///
77/// Computes `functional_mse = (1/n) * sum_i ∫ (y_true_i(t) - y_pred_i(t))^2 dt`
78/// where the integral is approximated by Simpson's rule over `argvals`.
79///
80/// # Errors
81///
82/// - [`FdarError::InvalidDimension`] if shapes of `y_true`, `y_pred`, or `argvals`
83///   are inconsistent or `n < 1` / `m < 2`.
84pub fn functional_mse(
85    y_true: &FdMatrix,
86    y_pred: &FdMatrix,
87    argvals: &[f64],
88) -> Result<f64, FdarError> {
89    let (n, m) = validate_shapes(y_true, y_pred, argvals)?;
90    let weights = simpsons_weights(argvals);
91    let mut total = 0.0_f64;
92    for i in 0..n {
93        for j in 0..m {
94            let diff = y_true[(i, j)] - y_pred[(i, j)];
95            total += diff * diff * weights[j];
96        }
97    }
98    Ok(total / n as f64)
99}
100
101/// Functional Mean Absolute Percentage Error integrated over `argvals`.
102///
103/// Computes `functional_mape = (1/n) * sum_i ∫ |y_true_i(t) - y_pred_i(t)| / |y_true_i(t)| dt`
104/// where the integral is approximated by Simpson's rule over `argvals`.
105///
106/// # Errors
107///
108/// - [`FdarError::InvalidDimension`] if shapes are inconsistent.
109/// - [`FdarError::InvalidParameter`] if any value of `y_true` is near zero
110///   (`|y_true| < NUMERICAL_EPS`), which would cause division by zero.
111pub fn functional_mape(
112    y_true: &FdMatrix,
113    y_pred: &FdMatrix,
114    argvals: &[f64],
115) -> Result<f64, FdarError> {
116    let (n, m) = validate_shapes(y_true, y_pred, argvals)?;
117    // Pre-scan for near-zero denominators before computing
118    for i in 0..n {
119        for j in 0..m {
120            if y_true[(i, j)].abs() < NUMERICAL_EPS {
121                return Err(FdarError::InvalidParameter {
122                    parameter: "y_true",
123                    message: format!(
124                        "MAPE is undefined when y_true contains values near zero \
125                         (found |y_true[{i},{j}]| = {} < NUMERICAL_EPS)",
126                        y_true[(i, j)].abs()
127                    ),
128                });
129            }
130        }
131    }
132    let weights = simpsons_weights(argvals);
133    let mut total = 0.0_f64;
134    for i in 0..n {
135        for j in 0..m {
136            let pct_err = (y_true[(i, j)] - y_pred[(i, j)]).abs() / y_true[(i, j)].abs();
137            total += pct_err * weights[j];
138        }
139    }
140    Ok(total / n as f64)
141}
142
143/// Functional Mean Squared Logarithmic Error integrated over `argvals`.
144///
145/// Computes `functional_msle = (1/n) * sum_i ∫ (ln(1+y_true_i(t)) - ln(1+y_pred_i(t)))^2 dt`
146/// where the integral is approximated by Simpson's rule over `argvals`.
147///
148/// MSLE is designed for non-negative targets (e.g. counts, prices). Applying it to
149/// values below -1 yields undefined logarithms.
150///
151/// # Errors
152///
153/// - [`FdarError::InvalidDimension`] if shapes are inconsistent.
154/// - [`FdarError::InvalidParameter`] if any value of `y_true` or `y_pred` is `<= -1`
155///   (making `ln(1+x)` undefined).
156pub fn functional_msle(
157    y_true: &FdMatrix,
158    y_pred: &FdMatrix,
159    argvals: &[f64],
160) -> Result<f64, FdarError> {
161    let (n, m) = validate_shapes(y_true, y_pred, argvals)?;
162    // Pre-scan for domain violations: y > -1 required for ln_1p
163    let threshold = -1.0 + NUMERICAL_EPS;
164    for i in 0..n {
165        for j in 0..m {
166            if y_true[(i, j)] <= threshold {
167                return Err(FdarError::InvalidParameter {
168                    parameter: "y_true",
169                    message: format!(
170                        "MSLE requires y_true > -1; found y_true[{i},{j}] = {}",
171                        y_true[(i, j)]
172                    ),
173                });
174            }
175            if y_pred[(i, j)] <= threshold {
176                return Err(FdarError::InvalidParameter {
177                    parameter: "y_pred",
178                    message: format!(
179                        "MSLE requires y_pred > -1; found y_pred[{i},{j}] = {}",
180                        y_pred[(i, j)]
181                    ),
182                });
183            }
184        }
185    }
186    let weights = simpsons_weights(argvals);
187    let mut total = 0.0_f64;
188    for i in 0..n {
189        for j in 0..m {
190            let log_diff = f64::ln_1p(y_true[(i, j)]) - f64::ln_1p(y_pred[(i, j)]);
191            total += log_diff.powi(2) * weights[j];
192        }
193    }
194    Ok(total / n as f64)
195}
196
197/// Functional Explained Variance Score integrated over `argvals`.
198///
199/// Computes the explained variance per curve as:
200/// `EV_i = 1 - SS_res_i / SS_tot_i`
201/// where:
202/// - `SS_res_i = ∫ (residual_i(t) - mean_residual_i)^2 dt`
203/// - `SS_tot_i = ∫ (y_true_i(t) - mean_true_i)^2 dt`
204/// - Means are computed as the weighted integral divided by the domain length.
205///
206/// The score is then averaged over all curves. Returns 1.0 for perfect prediction,
207/// 0.0 when prediction equals the mean of `y_true`, and can be negative for
208/// predictions worse than the mean baseline.
209///
210/// When `SS_tot_i ≈ 0` (constant true curve) and `SS_res_i ≈ 0` (perfect fit),
211/// returns 1.0 for that curve (trivial perfect prediction).
212/// When `SS_tot_i ≈ 0` but `SS_res_i > 0`, returns 0.0 (prediction adds no value
213/// over a constant).
214///
215/// # Errors
216///
217/// - [`FdarError::InvalidDimension`] if shapes are inconsistent.
218pub fn functional_explained_variance(
219    y_true: &FdMatrix,
220    y_pred: &FdMatrix,
221    argvals: &[f64],
222) -> Result<f64, FdarError> {
223    let (n, m) = validate_shapes(y_true, y_pred, argvals)?;
224    let weights = simpsons_weights(argvals);
225    let domain_len: f64 = weights.iter().sum();
226
227    let mut ev_sum = 0.0_f64;
228    for i in 0..n {
229        // Compute residuals and weighted means
230        let mut res_sum = 0.0_f64;
231        let mut true_sum = 0.0_f64;
232        for j in 0..m {
233            let residual = y_true[(i, j)] - y_pred[(i, j)];
234            res_sum += residual * weights[j];
235            true_sum += y_true[(i, j)] * weights[j];
236        }
237        // Weighted means (integral / domain length)
238        let mean_res = if domain_len > NUMERICAL_EPS {
239            res_sum / domain_len
240        } else {
241            0.0
242        };
243        let mean_true = if domain_len > NUMERICAL_EPS {
244            true_sum / domain_len
245        } else {
246            0.0
247        };
248        // Compute SS_res and SS_tot via Simpson weights
249        let mut ss_res = 0.0_f64;
250        let mut ss_tot = 0.0_f64;
251        for j in 0..m {
252            let res_centered = (y_true[(i, j)] - y_pred[(i, j)]) - mean_res;
253            let true_centered = y_true[(i, j)] - mean_true;
254            ss_res += res_centered.powi(2) * weights[j];
255            ss_tot += true_centered.powi(2) * weights[j];
256        }
257        // Guard near-zero SS_tot (constant true curve).
258        // CR-02: the old inner check `ss_res < NUMERICAL_EPS` was an absolute threshold
259        // comparison that returned 1.0 even when ss_res > ss_tot (both below NUMERICAL_EPS).
260        // Replace with a relative test: "perfect fit" only when the residual variance is
261        // no larger than ss_tot up to a small relative tolerance.
262        let ev_i = if ss_tot < NUMERICAL_EPS {
263            if ss_res <= ss_tot * (1.0 + 1e-6) {
264                1.0
265            } else {
266                0.0
267            }
268        } else {
269            1.0 - ss_res / ss_tot
270        };
271        ev_sum += ev_i;
272    }
273    Ok(ev_sum / n as f64)
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    use crate::test_helpers::uniform_grid;
281
282    // Helper: create an FdMatrix from row-major input (for test convenience).
283    fn mat_from_rows(rows: &[Vec<f64>]) -> FdMatrix {
284        let n = rows.len();
285        let m = rows[0].len();
286        let mut col_major = vec![0.0_f64; n * m];
287        for (i, row) in rows.iter().enumerate() {
288            for (j, &v) in row.iter().enumerate() {
289                col_major[i + j * n] = v;
290            }
291        }
292        FdMatrix::from_column_major(col_major, n, m).unwrap()
293    }
294
295    // ------------------------------------------------------------------ MAE --
296
297    #[test]
298    fn test_functional_mae_constant_error() {
299        // y_true = 0, y_pred = c => |error| = c everywhere
300        // Integral over [0,1] (uniform 5-point grid) = c * 1.0
301        // For 1 curve: mae = c.
302        let c = 2.0_f64;
303        let argvals = uniform_grid(5); // [0, 0.25, 0.5, 0.75, 1.0]
304        let y_true = mat_from_rows(&[vec![0.0; 5]]);
305        let y_pred = mat_from_rows(&[vec![c; 5]]);
306        let mae = functional_mae(&y_true, &y_pred, &argvals).unwrap();
307        // ∫ c dt over [0,1] = c * 1.0 = 2.0
308        assert!((mae - c).abs() < 1e-10, "mae={mae}, expected {c}");
309    }
310
311    #[test]
312    fn test_functional_mae_multi_curve() {
313        // Two curves: errors c=1 and c=2. Average = 1.5
314        let argvals = uniform_grid(5);
315        let y_true = mat_from_rows(&[vec![0.0; 5], vec![0.0; 5]]);
316        let y_pred = mat_from_rows(&[vec![1.0; 5], vec![2.0; 5]]);
317        let mae = functional_mae(&y_true, &y_pred, &argvals).unwrap();
318        // curve1 integral = 1.0, curve2 integral = 2.0, average = 1.5
319        assert!((mae - 1.5).abs() < 1e-10, "mae={mae}, expected 1.5");
320    }
321
322    #[test]
323    fn test_functional_mae_shape_mismatch_y_pred() {
324        let argvals = uniform_grid(5);
325        let y_true = mat_from_rows(&[vec![0.0; 5]]);
326        let y_pred = mat_from_rows(&[vec![0.0; 4]]); // wrong ncols
327        let result = functional_mae(&y_true, &y_pred, &argvals);
328        assert!(matches!(
329            result,
330            Err(FdarError::InvalidDimension {
331                parameter: "y_pred",
332                ..
333            })
334        ));
335    }
336
337    #[test]
338    fn test_functional_mae_shape_mismatch_argvals() {
339        let argvals = uniform_grid(4); // len=4 but matrix has 5 cols
340        let y_true = mat_from_rows(&[vec![0.0; 5]]);
341        let y_pred = mat_from_rows(&[vec![0.0; 5]]);
342        let result = functional_mae(&y_true, &y_pred, &argvals);
343        assert!(matches!(
344            result,
345            Err(FdarError::InvalidDimension {
346                parameter: "argvals",
347                ..
348            })
349        ));
350    }
351
352    // ------------------------------------------------------------------ MSE --
353
354    #[test]
355    fn test_functional_mse_constant_error() {
356        // y_true = 0, y_pred = c => error^2 = c^2 everywhere
357        // Integral over [0,1] (5-point grid) = c^2
358        let c = 3.0_f64;
359        let argvals = uniform_grid(5);
360        let y_true = mat_from_rows(&[vec![0.0; 5]]);
361        let y_pred = mat_from_rows(&[vec![c; 5]]);
362        let mse = functional_mse(&y_true, &y_pred, &argvals).unwrap();
363        assert!((mse - c * c).abs() < 1e-10, "mse={mse}, expected {}", c * c);
364    }
365
366    #[test]
367    fn test_functional_mse_zero_error() {
368        // Perfect prediction => MSE = 0
369        let argvals = uniform_grid(5);
370        let y_true = mat_from_rows(&[vec![1.0, 2.0, 3.0, 4.0, 5.0]]);
371        let y_pred = y_true.clone();
372        let mse = functional_mse(&y_true, &y_pred, &argvals).unwrap();
373        assert!(mse.abs() < 1e-14, "mse={mse}, expected 0");
374    }
375
376    // ---------------------------------------------------------------- MAPE --
377
378    #[test]
379    fn test_functional_mape_constant_error() {
380        // y_true = 4.0, y_pred = 5.0 => |error|/|y_true| = 0.25 everywhere
381        // Integral over [0,1] = 0.25, 1 curve => mape = 0.25
382        let argvals = uniform_grid(5);
383        let y_true = mat_from_rows(&[vec![4.0; 5]]);
384        let y_pred = mat_from_rows(&[vec![5.0; 5]]);
385        let mape = functional_mape(&y_true, &y_pred, &argvals).unwrap();
386        assert!((mape - 0.25).abs() < 1e-10, "mape={mape}, expected 0.25");
387    }
388
389    #[test]
390    fn test_functional_mape_zero_y_true() {
391        // Should return Err(InvalidParameter) when y_true contains near-zero value
392        let argvals = uniform_grid(5);
393        let y_true = mat_from_rows(&[vec![0.0; 5]]);
394        let y_pred = mat_from_rows(&[vec![1.0; 5]]);
395        let result = functional_mape(&y_true, &y_pred, &argvals);
396        assert!(matches!(
397            result,
398            Err(FdarError::InvalidParameter {
399                parameter: "y_true",
400                ..
401            })
402        ));
403    }
404
405    // ---------------------------------------------------------------- MSLE --
406
407    #[test]
408    fn test_functional_msle_constant() {
409        // y_true = 1.0, y_pred = 1.0 => log_diff = 0 everywhere => msle = 0
410        let argvals = uniform_grid(5);
411        let y_true = mat_from_rows(&[vec![1.0; 5]]);
412        let y_pred = y_true.clone();
413        let msle = functional_msle(&y_true, &y_pred, &argvals).unwrap();
414        assert!(msle.abs() < 1e-14, "msle={msle}, expected 0");
415    }
416
417    #[test]
418    fn test_functional_msle_hand_computed() {
419        // y_true = 3.0, y_pred = 1.0 over [0,1] with 5 pts
420        // log_diff = ln(4) - ln(2) = ln(2) everywhere
421        // integral of ln(2)^2 over [0,1] = ln(2)^2 ≈ 0.480453
422        let argvals = uniform_grid(5);
423        let y_true = mat_from_rows(&[vec![3.0; 5]]);
424        let y_pred = mat_from_rows(&[vec![1.0; 5]]);
425        let msle = functional_msle(&y_true, &y_pred, &argvals).unwrap();
426        let expected = f64::ln(2.0).powi(2);
427        assert!(
428            (msle - expected).abs() < 1e-10,
429            "msle={msle}, expected={expected}"
430        );
431    }
432
433    #[test]
434    fn test_functional_msle_domain_y_true() {
435        // y_true = -1.5 should cause Err(InvalidParameter)
436        let argvals = uniform_grid(5);
437        let y_true = mat_from_rows(&[vec![-1.5; 5]]);
438        let y_pred = mat_from_rows(&[vec![1.0; 5]]);
439        let result = functional_msle(&y_true, &y_pred, &argvals);
440        assert!(matches!(
441            result,
442            Err(FdarError::InvalidParameter {
443                parameter: "y_true",
444                ..
445            })
446        ));
447    }
448
449    #[test]
450    fn test_functional_msle_domain_y_pred() {
451        // y_pred = -2.0 should cause Err(InvalidParameter)
452        let argvals = uniform_grid(5);
453        let y_true = mat_from_rows(&[vec![1.0; 5]]);
454        let y_pred = mat_from_rows(&[vec![-2.0; 5]]);
455        let result = functional_msle(&y_true, &y_pred, &argvals);
456        assert!(matches!(
457            result,
458            Err(FdarError::InvalidParameter {
459                parameter: "y_pred",
460                ..
461            })
462        ));
463    }
464
465    // -------------------------------------------------------- Explained Variance --
466
467    #[test]
468    fn test_explained_variance_perfect() {
469        // Perfect prediction => EV = 1.0
470        let argvals = uniform_grid(5);
471        let y_true = mat_from_rows(&[vec![1.0, 2.0, 3.0, 4.0, 5.0]]);
472        let y_pred = y_true.clone();
473        let ev = functional_explained_variance(&y_true, &y_pred, &argvals).unwrap();
474        assert!((ev - 1.0).abs() < 1e-10, "ev={ev}, expected 1.0");
475    }
476
477    #[test]
478    fn test_explained_variance_constant_true() {
479        // Constant y_true => SS_tot ≈ 0; if y_pred == y_true: EV = 1.0
480        let argvals = uniform_grid(5);
481        let y_true = mat_from_rows(&[vec![3.0; 5]]);
482        let y_pred = y_true.clone();
483        let ev = functional_explained_variance(&y_true, &y_pred, &argvals).unwrap();
484        assert!((ev - 1.0).abs() < 1e-10, "ev={ev}, expected 1.0");
485    }
486
487    #[test]
488    fn test_explained_variance_shape_mismatch() {
489        let argvals = uniform_grid(5);
490        let y_true = mat_from_rows(&[vec![1.0; 5]]);
491        let y_pred = mat_from_rows(&[vec![0.0; 4]]); // wrong shape
492        let result = functional_explained_variance(&y_true, &y_pred, &argvals);
493        assert!(matches!(
494            result,
495            Err(FdarError::InvalidDimension {
496                parameter: "y_pred",
497                ..
498            })
499        ));
500    }
501
502    // CR-02 regression test: constant y_true + tiny-amplitude y_pred must NOT return 1.0.
503    // Before the fix, both ss_tot and ss_res fell below NUMERICAL_EPS and the function
504    // returned 1.0 even though ss_res > ss_tot (the prediction had MORE variation than the
505    // constant baseline).
506    #[test]
507    fn test_explained_variance_constant_true_perturbed_pred() {
508        // y_true is a constant curve (all 5.0) => SS_tot = 0.
509        // y_pred = 5.0 + 1e-6 * sin(t) has a tiny positive SS_res > SS_tot.
510        // Correct EV must be <= 0.0 (the pred is strictly worse than the baseline).
511        let m = 100_usize;
512        let argvals: Vec<f64> = (0..m).map(|i| i as f64 / (m - 1) as f64).collect();
513        let y_true_row: Vec<f64> = vec![5.0_f64; m];
514        let y_pred_row: Vec<f64> = argvals
515            .iter()
516            .map(|&t| 5.0 + 1e-6 * (t * std::f64::consts::PI * 2.0).sin())
517            .collect();
518        let y_true = mat_from_rows(&[y_true_row]);
519        let y_pred = mat_from_rows(&[y_pred_row]);
520        let ev = functional_explained_variance(&y_true, &y_pred, &argvals).unwrap();
521        assert!(
522            ev <= 0.0,
523            "EV for constant true + oscillating pred must be <= 0.0, got {ev}"
524        );
525    }
526}