use crate::error::{MetricsError, Result};
pub fn dtw(x: &[f64], y: &[f64]) -> Result<f64> {
dtw_full(x, y, None)
}
pub fn dtw_windowed(x: &[f64], y: &[f64], window: usize) -> Result<f64> {
dtw_full(x, y, Some(window))
}
pub fn dtw_normalized(x: &[f64], y: &[f64], window: Option<usize>) -> Result<f64> {
if x.is_empty() || y.is_empty() {
return Err(MetricsError::InvalidInput(
"time series must not be empty".to_string(),
));
}
let (dist, path_len) = dtw_with_path_length(x, y, window)?;
if path_len == 0 {
return Ok(0.0);
}
Ok(dist / path_len as f64)
}
fn dtw_with_path_length(x: &[f64], y: &[f64], window: Option<usize>) -> Result<(f64, usize)> {
let n = x.len();
let m = y.len();
if n == 0 || m == 0 {
return Err(MetricsError::InvalidInput(
"time series must not be empty".to_string(),
));
}
let w = window.unwrap_or(n.max(m));
let w = w.max((n as isize - m as isize).unsigned_abs());
let inf = f64::INFINITY;
let mut dp = vec![inf; n * m];
let idx = |i: usize, j: usize| i * m + j;
for i in 0..n {
for j in 0..m {
if (i as isize - j as isize).unsigned_abs() > w {
continue;
}
let cost = (x[i] - y[j]).abs();
let prev = if i == 0 && j == 0 {
0.0
} else if i == 0 {
dp[idx(0, j - 1)]
} else if j == 0 {
dp[idx(i - 1, 0)]
} else {
let d_ij = dp[idx(i - 1, j - 1)];
let d_i1j = dp[idx(i - 1, j)];
let d_ij1 = dp[idx(i, j - 1)];
d_ij.min(d_i1j).min(d_ij1)
};
if prev.is_infinite() && !(i == 0 && j == 0) {
dp[idx(i, j)] = inf;
} else {
dp[idx(i, j)] = cost + if i == 0 && j == 0 { 0.0 } else { prev };
}
}
}
let total = dp[idx(n - 1, m - 1)];
if total.is_infinite() {
return Err(MetricsError::CalculationError(
"DTW path not found within Sakoe-Chiba window; increase window size".to_string(),
));
}
let mut path_len = 0usize;
let mut i = n - 1;
let mut j = m - 1;
loop {
path_len += 1;
if i == 0 && j == 0 {
break;
}
if i == 0 {
j -= 1;
} else if j == 0 {
i -= 1;
} else {
let d_ij = dp[idx(i - 1, j - 1)];
let d_i1j = dp[idx(i - 1, j)];
let d_ij1 = dp[idx(i, j - 1)];
let min_prev = d_ij.min(d_i1j).min(d_ij1);
if min_prev == d_ij {
i -= 1;
j -= 1;
} else if min_prev == d_i1j {
i -= 1;
} else {
j -= 1;
}
}
}
Ok((total, path_len))
}
fn dtw_full(x: &[f64], y: &[f64], window: Option<usize>) -> Result<f64> {
if x.is_empty() || y.is_empty() {
return Err(MetricsError::InvalidInput(
"time series must not be empty".to_string(),
));
}
let (dist, _) = dtw_with_path_length(x, y, window)?;
Ok(dist)
}
pub fn brier_score_temporal(forecasts: &[f64], observations: &[f64]) -> Result<f64> {
if forecasts.len() != observations.len() {
return Err(MetricsError::DimensionMismatch(format!(
"forecasts ({}) and observations ({}) must have the same length",
forecasts.len(),
observations.len()
)));
}
if forecasts.is_empty() {
return Err(MetricsError::InvalidInput(
"inputs must not be empty".to_string(),
));
}
let n = forecasts.len();
let bs: f64 = (0..n)
.map(|i| (forecasts[i] - observations[i]).powi(2))
.sum::<f64>()
/ n as f64;
Ok(bs)
}
pub fn brier_skill_score_temporal(forecasts: &[f64], observations: &[f64]) -> Result<f64> {
let bs_model = brier_score_temporal(forecasts, observations)?;
let base_rate = observations.iter().sum::<f64>() / observations.len() as f64;
let ref_fc: Vec<f64> = vec![base_rate; observations.len()];
let bs_ref = brier_score_temporal(&ref_fc, observations)?;
if bs_ref <= f64::EPSILON {
if bs_model <= f64::EPSILON {
return Ok(1.0);
}
return Ok(f64::NEG_INFINITY);
}
Ok(1.0 - bs_model / bs_ref)
}
pub fn crps_gaussian(mu: &[f64], sigma: &[f64], observations: &[f64]) -> Result<f64> {
let n = mu.len();
if n != sigma.len() || n != observations.len() {
return Err(MetricsError::DimensionMismatch(format!(
"mu ({}), sigma ({}), observations ({}) must have the same length",
n,
sigma.len(),
observations.len()
)));
}
if n == 0 {
return Err(MetricsError::InvalidInput(
"inputs must not be empty".to_string(),
));
}
let mut total = 0.0f64;
for i in 0..n {
if sigma[i] <= 0.0 {
return Err(MetricsError::InvalidInput(format!(
"sigma[{i}] must be positive, got {}",
sigma[i]
)));
}
let z = (observations[i] - mu[i]) / sigma[i];
let phi_z = standard_normal_pdf(z);
let big_phi_z = standard_normal_cdf(z);
let crps_i = sigma[i]
* (z * (2.0 * big_phi_z - 1.0) + 2.0 * phi_z - 1.0 / std::f64::consts::PI.sqrt());
total += crps_i;
}
Ok(total / n as f64)
}
pub fn crps_ensemble(ensemble: &[Vec<f64>], observations: &[f64]) -> Result<f64> {
let n = ensemble.len();
if n != observations.len() {
return Err(MetricsError::DimensionMismatch(format!(
"ensemble ({}) and observations ({}) must have the same length",
n,
observations.len()
)));
}
if n == 0 {
return Err(MetricsError::InvalidInput(
"inputs must not be empty".to_string(),
));
}
let mut total = 0.0f64;
for i in 0..n {
let members = &ensemble[i];
let m = members.len();
if m == 0 {
return Err(MetricsError::InvalidInput(format!(
"ensemble[{i}] must not be empty"
)));
}
let e_xy: f64 = members
.iter()
.map(|&x| (x - observations[i]).abs())
.sum::<f64>()
/ m as f64;
let mut sorted = members.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let mut prefix = 0.0f64;
let mut e_xx = 0.0f64;
for (k, &xk) in sorted.iter().enumerate() {
e_xx += xk * k as f64 - prefix;
prefix += xk;
}
let pairs = m as f64 * (m as f64 - 1.0) / 2.0;
let e_xx_mean = if pairs > 0.0 { e_xx / pairs } else { 0.0 };
total += e_xy - 0.5 * e_xx_mean;
}
Ok(total / n as f64)
}
pub fn directional_accuracy(forecasts: &[f64], observations: &[f64]) -> Result<f64> {
let n = forecasts.len();
if n != observations.len() {
return Err(MetricsError::DimensionMismatch(format!(
"forecasts ({}) and observations ({}) must have the same length",
n,
observations.len()
)));
}
if n < 2 {
return Err(MetricsError::InvalidInput(
"at least 2 data points required for directional accuracy".to_string(),
));
}
let correct = (1..n)
.filter(|&i| {
let obs_dir = (observations[i] - observations[i - 1]).signum();
let fc_dir = (forecasts[i] - forecasts[i - 1]).signum();
obs_dir == fc_dir
})
.count();
Ok(correct as f64 / (n - 1) as f64)
}
#[derive(Debug, Clone)]
pub struct DieboldMarianoResult {
pub statistic: f64,
pub p_value: f64,
pub loss_differentials: Vec<f64>,
pub mean_differential: f64,
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum DmLossFunction {
SquaredError,
AbsoluteError,
Asymmetric(f64),
}
pub fn diebold_mariano_test(
actual: &[f64],
forecast1: &[f64],
forecast2: &[f64],
loss_fn: DmLossFunction,
h: usize,
) -> Result<DieboldMarianoResult> {
let n = actual.len();
if n != forecast1.len() || n != forecast2.len() {
return Err(MetricsError::DimensionMismatch(
"actual, forecast1, forecast2 must have the same length".to_string(),
));
}
if n < 3 {
return Err(MetricsError::InvalidInput(
"at least 3 observations required for DM test".to_string(),
));
}
let loss = |e: f64| -> f64 {
match loss_fn {
DmLossFunction::SquaredError => e * e,
DmLossFunction::AbsoluteError => e.abs(),
DmLossFunction::Asymmetric(a) => e * e + a * e,
}
};
let d: Vec<f64> = (0..n)
.map(|i| {
let e1 = actual[i] - forecast1[i];
let e2 = actual[i] - forecast2[i];
loss(e1) - loss(e2)
})
.collect();
let d_mean = d.iter().sum::<f64>() / n as f64;
let m = h.saturating_sub(1);
let gamma_0: f64 = d.iter().map(|&di| (di - d_mean).powi(2)).sum::<f64>() / n as f64;
let mut variance = gamma_0;
for lag in 1..=m {
if lag >= n {
break;
}
let gamma_l: f64 = (lag..n)
.map(|t| (d[t] - d_mean) * (d[t - lag] - d_mean))
.sum::<f64>()
/ n as f64;
let weight = 1.0 - lag as f64 / (m + 1) as f64; variance += 2.0 * weight * gamma_l;
}
if variance <= 0.0 {
variance = gamma_0.max(f64::EPSILON);
}
let se = (variance / n as f64).sqrt();
if se <= f64::EPSILON {
return Err(MetricsError::CalculationError(
"variance of loss differentials is effectively zero".to_string(),
));
}
let dm_stat = d_mean / se;
let p_value = two_sided_t_pvalue(dm_stat, n - 1);
Ok(DieboldMarianoResult {
statistic: dm_stat,
p_value,
loss_differentials: d,
mean_differential: d_mean,
})
}
#[derive(Debug, Clone)]
pub struct ForecastSkillMetrics {
pub brier_score: f64,
pub bss: f64,
pub crps: f64,
pub directional_accuracy: f64,
}
impl ForecastSkillMetrics {
pub fn compute(
prob_forecasts: &[f64],
binary_obs: &[f64],
mu: &[f64],
sigma: &[f64],
point_forecasts: &[f64],
observations: &[f64],
) -> Result<Self> {
let brier_score = brier_score_temporal(prob_forecasts, binary_obs)?;
let bss = brier_skill_score_temporal(prob_forecasts, binary_obs)?;
let crps = crps_gaussian(mu, sigma, observations)?;
let da = directional_accuracy(point_forecasts, observations)?;
Ok(Self {
brier_score,
bss,
crps,
directional_accuracy: da,
})
}
}
fn standard_normal_pdf(z: f64) -> f64 {
(-0.5 * z * z).exp() / (2.0 * std::f64::consts::PI).sqrt()
}
fn standard_normal_cdf(z: f64) -> f64 {
if z >= 0.0 {
1.0 - standard_normal_cdf_positive(z)
} else {
standard_normal_cdf_positive(-z)
}
}
fn standard_normal_cdf_positive(z: f64) -> f64 {
let t = 1.0 / (1.0 + 0.2316419 * z);
let poly = t
* (0.319381530
+ t * (-0.356563782 + t * (1.781477937 + t * (-1.821255978 + t * 1.330274429))));
standard_normal_pdf(z) * poly
}
fn two_sided_t_pvalue(t: f64, df: usize) -> f64 {
let t_abs = t.abs();
if df == 0 {
return 1.0;
}
let p_one_sided = if df as f64 > 30.0 {
standard_normal_cdf(-t_abs)
} else {
let x = df as f64 / (df as f64 + t_abs * t_abs);
0.5 * regularized_incomplete_beta(df as f64 / 2.0, 0.5, x)
};
(2.0 * p_one_sided).min(1.0).max(0.0)
}
fn regularized_incomplete_beta(a: f64, b: f64, x: f64) -> f64 {
if x <= 0.0 {
return 0.0;
}
if x >= 1.0 {
return 1.0;
}
if x > (a + 1.0) / (a + b + 2.0) {
return 1.0 - regularized_incomplete_beta(b, a, 1.0 - x);
}
let lbeta = lgamma(a) + lgamma(b) - lgamma(a + b);
let front = (a * x.ln() + b * (1.0 - x).ln() - lbeta).exp() / a;
let max_iter = 200;
let tol = 1e-10;
let mut c = 1.0f64;
let raw_d = 1.0 - (a + b) * x / (a + 1.0);
let mut d = if raw_d.abs() < f64::MIN_POSITIVE {
f64::MIN_POSITIVE
} else {
1.0 / raw_d
};
let mut f = d;
for m in 1..=max_iter {
let m = m as f64;
let num_even = m * (b - m) * x / ((a + 2.0 * m - 1.0) * (a + 2.0 * m));
d = 1.0 + num_even * d;
d = if d.abs() < f64::MIN_POSITIVE {
f64::MIN_POSITIVE
} else {
d
};
c = 1.0 + num_even / c;
c = if c.abs() < f64::MIN_POSITIVE {
f64::MIN_POSITIVE
} else {
c
};
d = 1.0 / d;
f *= c * d;
let num_odd = -(a + m) * (a + b + m) * x / ((a + 2.0 * m) * (a + 2.0 * m + 1.0));
d = 1.0 + num_odd * d;
d = if d.abs() < f64::MIN_POSITIVE {
f64::MIN_POSITIVE
} else {
d
};
c = 1.0 + num_odd / c;
c = if c.abs() < f64::MIN_POSITIVE {
f64::MIN_POSITIVE
} else {
c
};
d = 1.0 / d;
let delta = c * d;
f *= delta;
if (delta - 1.0).abs() < tol {
break;
}
}
front * f
}
fn lgamma(x: f64) -> f64 {
let g = 7.0;
let c: [f64; 9] = [
0.999_999_999_999_809_9,
676.5203681218851,
-1259.1392167224028,
771.323_428_777_653_1,
-176.615_029_162_140_6,
12.507343278686905,
-0.13857109526572012,
9.984_369_578_019_572e-6,
1.5056327351493116e-7,
];
if x < 0.5 {
std::f64::consts::PI.ln() - ((std::f64::consts::PI * x).sin().abs()).ln() - lgamma(1.0 - x)
} else {
let x = x - 1.0;
let mut s = c[0];
for (i, &ci) in c[1..].iter().enumerate() {
s += ci / (x + (i + 1) as f64);
}
let t = x + g + 0.5;
0.5 * (2.0 * std::f64::consts::PI).ln() + (x + 0.5) * t.ln() - t + s.ln()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dtw_identical() {
let x = vec![1.0, 2.0, 3.0, 2.0, 1.0];
let d = dtw(&x, &x).expect("should succeed");
assert!(d.abs() < 1e-10, "DTW(x,x) should be 0, got {d}");
}
#[test]
fn test_dtw_shift() {
let x = vec![0.0, 1.0, 2.0, 1.0, 0.0];
let y = vec![0.0, 0.0, 1.0, 2.0, 1.0]; let d = dtw(&x, &y).expect("should succeed");
assert!(d >= 0.0, "DTW must be non-negative");
assert!(d < 2.0, "DTW should handle shifted signals: got {d}");
}
#[test]
fn test_dtw_windowed_matches_full_for_large_window() {
let x = vec![1.0, 2.0, 3.0, 2.0, 1.0];
let y = vec![1.5, 2.5, 3.5, 2.5, 1.5];
let d_full = dtw(&x, &y).expect("full DTW");
let d_win = dtw_windowed(&x, &y, 5).expect("windowed DTW");
assert!(
(d_full - d_win).abs() < 1e-10,
"large window should match full DTW"
);
}
#[test]
fn test_dtw_windowed_constraint() {
let x = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let y = vec![1.0, 2.0, 3.0, 4.0, 5.0]; let d = dtw_windowed(&x, &y, 2).expect("windowed DTW");
assert!(d.abs() < 1e-10, "DTW(x,x) with window should be 0");
}
#[test]
fn test_dtw_normalized() {
let x = vec![1.0, 2.0, 3.0];
let y = vec![1.0, 2.0, 3.0];
let d = dtw_normalized(&x, &y, None).expect("normalized DTW");
assert!(d.abs() < 1e-10, "normalized DTW(x,x) should be 0");
}
#[test]
fn test_dtw_different_lengths() {
let x = vec![1.0, 2.0, 3.0];
let y = vec![1.0, 1.5, 2.0, 2.5, 3.0];
let d = dtw(&x, &y).expect("DTW with different lengths");
assert!(d >= 0.0);
}
#[test]
fn test_brier_score_perfect() {
let obs = vec![1.0, 0.0, 1.0, 0.0, 1.0];
let fc = vec![1.0, 0.0, 1.0, 0.0, 1.0]; let bs = brier_score_temporal(&fc, &obs).expect("should succeed");
assert!(
bs.abs() < 1e-10,
"perfect Brier Score should be 0, got {bs}"
);
}
#[test]
fn test_brier_score_worst() {
let obs = vec![1.0, 0.0, 1.0, 0.0];
let fc = vec![0.0, 1.0, 0.0, 1.0]; let bs = brier_score_temporal(&fc, &obs).expect("should succeed");
assert!(
(bs - 1.0).abs() < 1e-10,
"worst Brier Score should be 1.0, got {bs}"
);
}
#[test]
fn test_brier_skill_score_no_skill() {
let obs = vec![1.0, 0.0, 1.0, 0.0, 1.0, 0.0];
let base_rate = 0.5;
let fc = vec![base_rate; obs.len()];
let bss = brier_skill_score_temporal(&fc, &obs).expect("should succeed");
assert!(
bss.abs() < 1e-10,
"climatological BSS should be 0, got {bss}"
);
}
#[test]
fn test_crps_gaussian_perfect() {
let mu = vec![1.0, 2.0, 3.0];
let sigma = vec![0.001, 0.001, 0.001]; let obs = vec![1.0, 2.0, 3.0]; let crps = crps_gaussian(&mu, &sigma, &obs).expect("should succeed");
assert!(crps >= 0.0);
assert!(
crps < 0.01,
"CRPS for near-perfect Gaussian should be small, got {crps}"
);
}
#[test]
fn test_crps_gaussian_nonnegative() {
let mu = vec![0.0, 1.0, 2.0, -1.0];
let sigma = vec![1.0, 2.0, 0.5, 1.5];
let obs = vec![0.5, 1.5, 1.8, -0.5];
let crps = crps_gaussian(&mu, &sigma, &obs).expect("should succeed");
assert!(crps >= 0.0, "CRPS must be non-negative, got {crps}");
}
#[test]
fn test_crps_gaussian_known_value() {
let mu = vec![0.0];
let sigma = vec![1.0];
let obs = vec![0.0];
let crps = crps_gaussian(&mu, &sigma, &obs).expect("should succeed");
let expected = (2.0_f64.sqrt() - 1.0) / std::f64::consts::PI.sqrt();
assert!(
(crps - expected).abs() < 1e-4,
"CRPS(N(0,1), y=0) ≈ {expected:.4}, got {crps:.4}"
);
}
#[test]
fn test_crps_ensemble_identical() {
let ensemble = vec![vec![2.0, 2.0, 2.0], vec![5.0, 5.0, 5.0]];
let obs = vec![2.0, 5.0];
let crps = crps_ensemble(&ensemble, &obs).expect("should succeed");
assert!(crps >= 0.0);
assert!(
crps < 1e-6,
"perfect ensemble CRPS should be ~0, got {crps}"
);
}
#[test]
fn test_directional_accuracy_all_correct() {
let obs = vec![1.0, 2.0, 3.0, 2.0, 1.0];
let fc = vec![1.1, 2.1, 3.1, 1.9, 0.9]; let da = directional_accuracy(&fc, &obs).expect("should succeed");
assert!(
(da - 1.0).abs() < 1e-10,
"all correct DA should be 1.0, got {da}"
);
}
#[test]
fn test_directional_accuracy_all_wrong() {
let obs = vec![1.0, 2.0, 3.0, 4.0];
let fc = vec![4.0, 3.0, 2.0, 1.0]; let da = directional_accuracy(&fc, &obs).expect("should succeed");
assert!(
(da - 0.0).abs() < 1e-10,
"all wrong DA should be 0.0, got {da}"
);
}
#[test]
fn test_directional_accuracy_half() {
let obs = vec![4.0, 2.0, 6.0, 1.0, 5.0];
let fc = vec![4.0, 2.0, 6.0, 8.0, 3.0];
let da = directional_accuracy(&fc, &obs).expect("should succeed");
assert!(
(da - 0.5).abs() < 1e-10,
"half-correct DA should be 0.5, got {da}"
);
}
#[test]
fn test_diebold_mariano_identical_forecasts() {
let actual = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let fc1 = vec![1.1, 2.1, 3.1, 4.1, 5.1];
let fc2 = fc1.clone();
let result = diebold_mariano_test(&actual, &fc1, &fc2, DmLossFunction::SquaredError, 1);
match result {
Err(_) => {} Ok(r) => {
assert!(
r.mean_differential.abs() < 1e-10,
"mean differential for identical forecasts should be 0"
);
}
}
}
#[test]
fn test_diebold_mariano_clearly_different() {
let actual: Vec<f64> = (0..20).map(|i| i as f64).collect();
let fc1: Vec<f64> = actual.iter().map(|&x| x + 5.0).collect();
let fc2: Vec<f64> = actual.iter().map(|&x| x + 0.1).collect();
let result = diebold_mariano_test(&actual, &fc1, &fc2, DmLossFunction::SquaredError, 1)
.expect("should succeed");
assert!(
result.mean_differential > 0.0,
"model1 should have higher loss"
);
assert!(result.p_value >= 0.0 && result.p_value <= 1.0);
}
#[test]
fn test_forecast_skill_metrics_compute() {
let n = 10;
let prob_fc: Vec<f64> = (0..n).map(|i| if i < 5 { 0.9 } else { 0.1 }).collect();
let bin_obs: Vec<f64> = (0..n).map(|i| if i < 5 { 1.0 } else { 0.0 }).collect();
let mu: Vec<f64> = (0..n).map(|i| i as f64).collect();
let sigma = vec![1.0; n];
let point_fc: Vec<f64> = mu.iter().map(|&x| x + 0.1).collect();
let obs: Vec<f64> = mu.clone();
let metrics =
ForecastSkillMetrics::compute(&prob_fc, &bin_obs, &mu, &sigma, &point_fc, &obs)
.expect("should succeed");
assert!(metrics.brier_score >= 0.0);
assert!(metrics.crps >= 0.0);
assert!(metrics.directional_accuracy >= 0.0 && metrics.directional_accuracy <= 1.0);
}
}