///|
pub(all) struct HoldoutCell {
cutoff : Int32
origin : Int32
age : Int32
predicted : Double
observed : Double
error : Double
} derive(Debug)
///|
pub extend HoldoutCell with @debug.Debug::{to_repr}
///|
/// Fit solely to calendar<=cutoff; predict the next observed diagonal.
/// New origins with no observation at cutoff are excluded.
pub fn holdout(
triangle : Triangle,
cutoff : Int32,
average~ : Average = Volume,
) -> Array[HoldoutCell] raise RunoffError {
if cutoff < 1 || cutoff >= triangle.order() - 1 {
raise Shape("holdout cutoff must be 1..order-2")
}
let historical = triangle.at_calendar(cutoff)
let fitted = development_factors(historical, average~)
let out : Array[HoldoutCell] = []
for origin in 1..<=cutoff {
let age = cutoff - origin + 1
let base = historical.rows[origin][age - 1]
let predicted = checked(
base * fitted[age - 1].factor,
"holdout prediction overflow",
)
let observed = triangle.rows[origin][age]
out.push({
cutoff,
origin,
age,
predicted,
observed,
error: predicted - observed,
})
}
out
}
///|
pub fn rolling_holdout(
triangle : Triangle,
average~ : Average = Volume,
) -> Array[HoldoutCell] raise RunoffError {
if triangle.order() < 3 {
raise Shape("rolling holdout requires at least order three")
}
let out : Array[HoldoutCell] = []
for cutoff in 1..<(triangle.order() - 1) {
for cell in holdout(triangle, cutoff, average~) {
out.push(cell)
}
}
out
}
///|
pub(all) struct BacktestScore {
count : Int32
bias : Double
mean_absolute_error : Double
root_mean_square_error : Double
weighted_absolute_percentage_error : Double?
} derive(Debug)
///|
pub extend BacktestScore with @debug.Debug::{to_repr}
///|
pub fn backtest_score(
cells : Array[HoldoutCell],
) -> BacktestScore raise RunoffError {
if cells.is_empty() {
raise Shape("cannot score an empty holdout")
}
let mut bias = 0.0
let mut absolute = 0.0
let mut square = 0.0
let mut observed = 0.0
for c in cells {
nonnegative(c.predicted, "holdout prediction must be finite nonnegative")
nonnegative(c.observed, "holdout observed must be finite nonnegative")
let err = checked(c.predicted - c.observed, "nonfinite holdout error")
if c.error != err {
raise Value("holdout error differs from prediction minus observation")
}
bias = checked(bias + err, "holdout bias overflow")
absolute = checked(absolute + err.abs(), "holdout absolute error overflow")
square = checked(
square + checked(err * err, "holdout square overflow"),
"holdout squares overflow",
)
nonnegative(c.observed, "holdout observed must be nonnegative")
observed = checked(observed + c.observed, "holdout observed total overflow")
}
let count = cells.length().to_double()
{
count: cells.length(),
bias: bias / count,
mean_absolute_error: absolute / count,
root_mean_square_error: (square / count).sqrt(),
weighted_absolute_percentage_error: if observed == 0.0 {
None
} else {
Some(checked(absolute / observed, "WAPE overflow"))
},
}
}