Skip to content

Evaluating forecasts end-to-end

This tutorial walks a full forecast-evaluation workflow: score a single parametric forecast, score an ensemble (sample) forecast, compare two competing forecasters, move to a multivariate outcome, and finish by emphasising particular outcomes with weighted scores.

It reworks the spirit of the scoringRules articles by Jordan, Krüger & Lerch (2019) and Allen (2024) as a self-contained Julia workflow (see How to cite). The data here is synthetic and generated in-code; the original articles used real macroeconomic and weather forecasts. Everything runs from a fixed seed, so the page is reproducible.

Setup

We only need ScoringRules, Distributions for the parametric forecasts, and a seeded RNG for the synthetic draws.

julia
using ScoringRules
using Distributions
using Statistics
using Random

rng = Random.MersenneTwister(1234)
Random.MersenneTwister(1234)

One forecast, three scores

Every univariate score is negatively oriented: lower is better. Pass a Distributions.jl distribution as the forecast and a scalar observation.

julia
forecast = Normal(0.0, 1.0)
y = 0.7

(crps = crps(forecast, y), logs = logs(forecast, y), dss = dss(forecast, y))
(crps = 0.42156917007346395, logs = 1.1639385332046728, dss = 0.48999999999999994)

The CRPS is on the scale of the observation, the log score is the negative log-density, and the Dawid–Sebastiani score uses only the forecast's first two moments. A forecast centred closer to the outcome scores lower:

julia
crps(Normal(0.5, 1.0), y), crps(Normal(2.0, 1.0), y)
(0.24959968817879674, 0.8268663406252718)

Ensemble (sample) forecasts

When the forecast is a set of simulation draws rather than a closed-form distribution, pass the vector directly. The CRPS then uses the empirical distribution function (:edf, the default) or a Gaussian kernel estimate (:kde); the log and Dawid–Sebastiani scores follow.

julia
ensemble = rand(rng, Normal(0.0, 1.0), 500)

(
    edf = crps(ensemble, y),
    kde = crps(ensemble, y; method = :kde),
    logs = logs(ensemble, y),
    dss = dss(ensemble, y),
)
(edf = 0.42776800703785206, kde = 0.4310493330053203, logs = 1.1850774576526868, dss = 0.5003484497351507)

Comparing two forecasters

The point of proper scores is ranking. We draw a batch of observations from a "truth", then score two competing predictive distributions over the same observations and compare their mean scores. A calibrated forecaster should win.

julia
truth = Normal(1.0, 2.0)
observations = rand(rng, truth, 200)

calibrated = Normal(1.0, 2.0)          # correct
overconfident = Normal(1.0, 1.0)       # right centre, too sharp

mean_crps_calibrated = mean(crps(calibrated, yi) for yi in observations)
mean_crps_overconfident = mean(crps(overconfident, yi) for yi in observations)

(calibrated = mean_crps_calibrated, overconfident = mean_crps_overconfident)
(calibrated = 1.1743697556701485, overconfident = 1.2703486396141865)

The calibrated forecaster earns the lower mean CRPS. To score a whole vector of forecast/observation pairs at once, broadcast: crps.(distributions, observations) composes with the usual Julia reductions.

Multivariate forecasts

For a d-dimensional outcome, represent the ensemble as a d × m matrix (each column one member) and the observation as a length-d vector. Three multivariate scores are available: the energy score, the variogram score, and the maximum-mean-discrepancy score.

julia
d, m = 3, 300
X = rand(rng, Normal(0.0, 1.0), d, m)   # 3 × 300 ensemble, independent components
obs = [0.2, -0.1, 0.4]

(es = es(X, obs), vs = vs(X, obs), mmds = mmds(X, obs))
(es = 0.5303538359095123, vs = 1.231472003291689, mmds = -0.21721760900524067)

Emphasising particular outcomes

Weighted scores let you concentrate the assessment on the outcomes you care about, for example the upper tail when large values are what matter. The threshold-weighted CRPS transforms forecast and observation through a chaining function; the outcome-weighted CRPS reweights by a region indicator. Both reduce to the ordinary CRPS when the region is the whole line.

julia
tail_ensemble = rand(rng, Normal(0.0, 1.0), 500)
y_tail = 1.5

(
    unweighted = crps(tail_ensemble, y_tail),
    threshold_weighted = twcrps(tail_ensemble, y_tail; a = 1.0),
    outcome_weighted = owcrps(tail_ensemble, y_tail; a = 1.0),
)
(unweighted = 1.045801761641055, threshold_weighted = 0.40404959982082206, outcome_weighted = 0.10450617432783026)

Here a = 1.0 focuses the score on outcomes above 1. The same a/b interval and custom-function conventions carry over to the multivariate weighted scores (twes, owes, twvs, owvs, twmmds, owmmds).

Fitting forecasts by minimising a score

To fit a forecast model rather than just evaluate one (optimum-score estimation, e.g. EMOS), minimise a mean score over training data. Because every score here is an ordinary Julia function of the forecast parameters, you differentiate it with any automatic-differentiation backend:

julia
using ForwardDiff
ForwardDiff.gradient(p -> crps(Normal(p[1], p[2]), y), [0.0, 1.0])

This replaces R's hand-coded gradcrps_* / hesscrps_* family with AD that works for every distribution and score. See the Automatic differentiation backends tutorial for which backends are supported and how to configure them.

Where to go next

If you use these scores in your work, please cite the original scoringRules authors (see How to cite).