///|
/// Mean signed forecast error; positive values indicate over-prediction.
pub fn forecast_bias(points : Array[ForecastPoint]) -> Double {
if points.length() == 0 {
0.0
} else {
let errors : Array[Double] = []
for point in points {
errors.push(point.predicted - point.actual)
}
robust_mean(errors)
}
}
///|
/// Symmetric mean absolute percentage error.
pub fn smape(points : Array[ForecastPoint]) -> Double {
if points.length() == 0 {
0.0
} else {
let values : Array[Double] = []
for point in points {
values.push(
safe_div(
2.0 * abs_double(point.predicted - point.actual),
abs_double(point.predicted) + abs_double(point.actual),
0.0,
),
)
}
robust_mean(values)
}
}
///|
/// Median absolute forecast error.
pub fn median_absolute_error(points : Array[ForecastPoint]) -> Double {
let values : Array[Double] = []
for point in points {
values.push(abs_double(point.predicted - point.actual))
}
median(values)
}
///|
/// Fraction of predictions within an absolute tolerance.
pub fn prediction_coverage(
points : Array[ForecastPoint],
tolerance~ : Double,
) -> Double {
if points.length() == 0 {
0.0
} else {
let mut passed = 0
for point in points {
if abs_double(point.predicted - point.actual) <= abs_double(tolerance) {
passed += 1
}
}
passed.to_double() / points.length().to_double()
}
}
///|
/// Compute confidence/error bins from results and scored points.
pub fn metric_bins(
results : Array[EtaResult],
points : Array[ForecastPoint],
bin_count? : Int = 5,
) -> Array[MetricBin] {
let count = if bin_count < 1 { 1 } else { bin_count }
let bins : Array[MetricBin] = []
for index in 0..= lower &&
(result.confidence < upper || index == count - 1) {
if result_index < points.length() {
errors.push(
abs_double(
points[result_index].predicted - points[result_index].actual,
),
)
}
confidences.push(result.confidence)
}
}
bins.push({
lower,
upper,
count: errors.length(),
mean_error: robust_mean(errors),
mean_confidence: robust_mean(confidences),
})
}
bins
}
///|
/// Produce a complete metric report for a result/point pair.
pub fn metric_report(
results : Array[EtaResult],
points : Array[ForecastPoint],
tolerance? : Double = 60.0,
) -> MetricReport {
{
summary: summarize_errors(points),
bias: forecast_bias(points),
smape: smape(points),
median_absolute_error: median_absolute_error(points),
coverage: prediction_coverage(points, tolerance~),
bins: metric_bins(results, points),
}
}
///|
/// Return the mean calibration gap across non-empty bins.
pub fn calibration_gap(report : MetricReport) -> Double {
let gaps : Array[Double] = []
for bin in report.bins {
if bin.count > 0 {
gaps.push(abs_double(bin.mean_error - bin.mean_confidence))
}
}
robust_mean(gaps)
}
///|
/// Return metric values in a stable order for tabular output.
pub fn metric_vector(report : MetricReport) -> Array[Double] {
[
report.summary.mae,
report.summary.rmse,
report.summary.mape,
report.bias,
report.smape,
report.median_absolute_error,
report.coverage,
]
}
///|
/// Compare two metric reports by a weighted score.
pub fn metric_score(
report : MetricReport,
coverage_weight? : Double = 0.2,
) -> Double {
report.summary.mae +
report.summary.rmse +
report.summary.mape * 100.0 -
abs_double(coverage_weight) * report.coverage
}