Skip to content

Province forecast evaluation ​

How the province forecasts on the province forecasts page have scored against what each province went on to report. Scoring follows the national forecast evaluation, against the same persistence baseline. The in-sample province checks are on the province in-sample checks page.

Load packages, data and fitted chains
julia
# Shared setup: packages, observations and the fit registry. See
# `docs/pages/_setup.jl`.
using BVDOutbreakSize
include(joinpath(pkgdir(BVDOutbreakSize), "docs", "pages", "_setup.jl"))
validation_forecast_from (generic function with 1 method)
julia
# The fits this page reads, loaded from the cache here.
frozen_lastweek = load_fit("frozen_validation");
chn_joint = load_fit("joint");

Summary ​

The overall bullets come first, then a short block per province, from the scores below.

  • Provinces: 0 of 3 provinces with scored forecasts beat the baseline on every stream scored for them.

  • Province streams: 1 of 8 beat the baseline.

Ituri

  • confirmed cases: relative skill 1.2, 90% coverage 1.0 over 22 forecasts.

  • confirmed deaths: relative skill 0.72, 90% coverage 1.0 over 22 forecasts.

Nord-Kivu

  • confirmed cases: relative skill 1.33, 90% coverage 0.95 over 22 forecasts.

  • confirmed deaths: relative skill 1.43, 90% coverage 0.95 over 22 forecasts.

Haut-Uele

  • confirmed cases: relative skill 2.05, 90% coverage 1.0 over 22 forecasts.

  • confirmed deaths: relative skill 1.32, 90% coverage 1.0 over 22 forecasts.

Sud-Kivu

  • No scored forecast yet.

Tshopo

  • No scored forecast yet.

Bas-Uele

  • No scored forecast yet.

Sud-Ubangi

  • No scored forecast yet.

Nord-Ubangi

  • No scored forecast yet.

Forecast by province ​

The forecast is defined in the province forecast Methods section. Every release's archived province forecast is scored against what has since been observed in Forecast by province across releases.

Province forecast against observed
julia
# The frozen fit's one-week-ahead national forecast, the same one the
# forecast evaluation page validates. `validation_forecast_from` is defined
# in the shared setup.
validation_forecast = validation_forecast_from("frozen_validation");

# Per-province cumulative confirmed cases and deaths at the frozen cut-off
# and at the current one, so the truth for the week is their difference.
# Read off the same increment matrices the compositions are scored on, so
# the clamped revision is treated identically on both sides.
province_truth = let
    cur_c = province_increment_matrix(
        obs.province_confirmed_history,
        PROVINCE_NAMES, N_PATCHES
    )
    cur_d = province_increment_matrix(
        obs.province_death_history,
        PROVINCE_NAMES, N_PATCHES
    )
    froz_c = province_increment_matrix(
        frozen_lastweek.o.province_confirmed_history,
        PROVINCE_NAMES, N_PATCHES
    )
    froz_d = province_increment_matrix(
        frozen_lastweek.o.province_death_history, PROVINCE_NAMES, N_PATCHES
    )
    (;
        observed = vec(sum(cur_c.increments; dims = 2)),
        baseline = vec(sum(froz_c.increments; dims = 2)),
        death_observed = vec(sum(cur_d.increments; dims = 2)),
        death_baseline = vec(sum(froz_d.increments; dims = 2)),
    )
end

province_validation_table = province_forecast_vs_truth(
    fit_forecast("frozen_validation"), validation_forecast;
    observed = province_truth.observed,
    baseline = province_truth.baseline,
    death_observed = province_truth.death_observed,
    death_baseline = province_truth.death_baseline,
    n_patches = N_PATCHES
);
ProvinceStreamLower 90%Upper 90%ObservedWithin 90% PI
IturiConfirmed cases181481265true
IturiConfirmed deaths68195140true
Nord-KivuConfirmed cases139393183true
Nord-KivuConfirmed deaths6719082true
Haut-UeleConfirmed cases87528true
Haut-UeleConfirmed deaths13318true
Sud-KivuConfirmed cases010true
Sud-KivuConfirmed deaths010true
TshopoConfirmed cases23113true
TshopoConfirmed deaths0125true
Bas-UeleConfirmed cases080true
Bas-UeleConfirmed deaths040true
Sud-UbangiConfirmed cases020true
Sud-UbangiConfirmed deaths010true
Nord-UbangiConfirmed cases003false
Nord-UbangiConfirmed deaths001false
The frozen fit's one-week-ahead forecast of each province's confirmed cases and deaths, against what each province went on to report.
Build the forecast-versus-observed figures
julia
frozen_province_draws = fit_forecast("frozen_validation");
frozen_province_projection = forecast_provinces(
    frozen_province_draws; horizon = 7, n_patches = N_PATCHES
);
# The patients in isolation each province printed on the target day, the
# current cut-off, or `NaN` where it printed nothing that day.
province_isolation_now = let
    rows = province_care_observations(
        obs.province_isolation_history, PROVINCE_NAMES
    )
    v = fill(NaN, N_PATCHES)
    for (d, p, c) in zip(rows.days, rows.patches, rows.counts)
        d == obs.n && p <= N_PATCHES && (v[p] = c)
    end
    v
end
province_rt_now = province_map_summary(
    chn_joint, :R_T_patch, N_PATCHES
).values
province_observed = (;
    confirmed_new = province_truth.observed .- province_truth.baseline,
    confirmed_deaths_new = province_truth.death_observed .-
        province_truth.death_baseline,
    isolation_level = province_isolation_now,
    rt_forecast = province_rt_now,
)
province_vs_observed_fig = plot_province_forecast(
    frozen_province_draws, frozen_province_projection;
    n_patches = N_PATCHES, observed = province_observed,
    title = "Last week's forecast against what was observed"
);
province_vs_observed_detail(p) = plot_province_forecast_detail(
    frozen_province_draws, frozen_province_projection;
    province = p, n_patches = N_PATCHES,
    observed = NamedTuple(
        k => v[p] for (k, v) in pairs(province_observed) if isfinite(v[p])
    )
);

The same forecast against what each province went on to report, one panel per target. A cross marks the observed count over the week for cases and deaths, the patients in isolation on the target day where the province printed them, and the current fit's reproduction number at the cut-off.

Each province's forecast, with the dashed rule at what it went on to report.

Ituri

Nord-Kivu

Haut-Uele

Sud-Kivu

Tshopo

Bas-Uele

Sud-Ubangi

Nord-Ubangi

Forecast by province across releases ​

The archived province forecast of each release is scored against what each province went on to report. A window holding a harmonisation-break day is left unscored, because that day's backfill is published for the country and not by province. The scores fill in as releases carrying them become old enough for their targets to have been observed. The joint patch model is the only model that forecasts the provinces, so every table here is the joint model's, one row per stream and province.

Load and summarise the province forecast scores
julia
province_scores_df = _release_data(
    "province_forecast_scores.csv",
    (;
        release = String, made_date = Date, stream = String, horizon = Int,
        target_date = Date, fit = String, crps = Float64,
        log_crps = Float64, dispersion = Float64, overprediction = Float64,
        underprediction = Float64, coverage_50 = Float64,
        coverage_90 = Float64,
        bias = Float64, n_samples = Int,
        log_rel_to_baseline = Float64,
    )
)
# There is no individual single-stream fit to compare against, and `fit` is
# single-valued by construction once the baseline is set aside. Both are
# dropped rather than rendered as columns that cannot vary. The figures
# keep the full tables, since they compare the joint against the baseline.
province_score_by_horizon_table = forecast_score_by_horizon(
    province_scores_df
)
province_score_by_release_table = forecast_score_by_release(
    province_scores_df
)
_province_display(tbl) = drop_degenerate_fit_column(
    drop_individual_fit_columns(tbl)
)
province_score_overview_display = _province_display(
    forecast_score_overview(province_scores_df)
)
province_score_by_horizon_display = _province_display(
    province_score_by_horizon_table
)
# See the comment on `joint_score_by_release_table` on the forecast
# evaluation page for why this setup chunk's last statement needs a
# trailing `;`.
province_score_by_release_display = _province_display(
    province_score_by_release_table
);

_province_empty = "No scored province forecasts yet. This fills in as " *
    "releases carrying the per-province projection accumulate.";
# A plain statement above the tables when nothing is scored yet, and
# nothing otherwise.
province_scores_note = size(province_scores_df, 1) == 0 ?
    Markdown.parse(
        "No province forecast has been scored yet. This section fills in " *
        "as releases carrying the per-province projection accumulate."
    ) : nothing;
streamncrpsrel_to_baselinelog_crpslog_rel_to_baselinedispersionoverpredictionunderpredictioncoverage_50coverage_90bias
confirmed cases [haut_uele]2211.322.050.1751.638.892.360.06110.27
confirmed cases [ituri]2249.881.20.0911.1937.7712.020.090.7710.3
confirmed cases [nord_kivu]2244.381.330.1431.1528.457.698.240.640.950.01
confirmed cases [other]229.972.340.3331.285.484.270.210.510.36
confirmed deaths [haut_uele]223.991.320.1711.23.480.20.3211-0.01
confirmed deaths [ituri]2216.610.720.0710.8514.690.681.230.951-0.06
confirmed deaths [nord_kivu]2227.081.430.1641.2213.2913.70.080.590.950.45
confirmed deaths [other]224.212.110.4781.12.052.060.090.5510.46
julia
province_relative_skill_fig = plot_forecast_relative_skill(
    province_score_by_horizon_table; empty_message = _province_empty
);

Relative skill against the baseline by horizon, one panel per stream and province, on a log scale with the reference line at one.
julia
province_crps_by_horizon_fig = plot_forecast_crps_by_horizon(
    province_score_by_horizon_table;
    title = "CRPS decomposition by horizon, by province",
    empty_message = _province_empty
);

The mean CRPS by horizon split into its width, overprediction and underprediction.
Province scores by horizon
streamhorizonncrpsrel_to_baselinelog_crpslog_rel_to_baselinedispersionoverpredictionunderpredictioncoverage_50coverage_90bias
confirmed cases [haut_uele]785.321.690.1831.44.111.080.12110.21
confirmed cases [haut_uele]14710.31.910.171.677.582.650.07110.32
confirmed cases [haut_uele]21515.872.70.1622.2712.942.920110.29
confirmed cases [haut_uele]28227.511.890.1951.622.445.070110.28
confirmed cases [ituri]7829.551.330.1061.1519.1810.310.060.6210.34
confirmed cases [ituri]14748.871.590.0861.4534.7614.1200.7110.34
confirmed cases [ituri]21564.591.220.0781.2253.6310.640.33110.19
confirmed cases [ituri]28297.940.770.0860.7883.0214.920110.25
confirmed cases [nord_kivu]7832.51.230.1851.1215.1414.313.040.380.880.27
confirmed cases [nord_kivu]14744.40.880.1260.7926.997.799.610.711-0.08
confirmed cases [nord_kivu]21555.852.210.122.5238.180.0117.650.81-0.26
confirmed cases [nord_kivu]28263.242.980.0943.1162.5300.7211-0.06
confirmed cases [other]783.761.020.3270.912.211.260.30.3810.27
confirmed cases [other]1477.91.240.3261.014.273.30.330.7110.3
confirmed cases [other]21513.586.810.34.467.855.7300.610.46
confirmed cases [other]28232.976.920.4693.5816.916.070010.69
confirmed deaths [haut_uele]782.671.380.2211.272.170.150.3511-0.08
confirmed deaths [haut_uele]1474.091.360.1631.253.310.190.5911-0.01
confirmed deaths [haut_uele]2154.81.290.1221.054.570.230110.07
confirmed deaths [haut_uele]2826.941.190.1260.976.560.290.08110.03
confirmed deaths [ituri]7810.910.920.0880.919.141.430.340.8810.01
confirmed deaths [ituri]14715.870.950.064113.830.361.6711-0.09
confirmed deaths [ituri]21521.240.640.0580.7219.360.191.711-0.1
confirmed deaths [ituri]28230.410.480.0610.5628.2802.1211-0.15
confirmed deaths [nord_kivu]7818.881.820.2231.488.2810.600.620.880.47
confirmed deaths [nord_kivu]14724.660.950.1350.7712.6312.020.020.7110.45
confirmed deaths [nord_kivu]21531.721.640.1161.5217.1614.230.330.610.35
confirmed deaths [nord_kivu]28256.722.040.151.8926.0130.710010.62
confirmed deaths [other]781.870.790.5540.7610.70.160.6210.39
confirmed deaths [other]1473.21.140.4080.891.681.410.110.7110.32
confirmed deaths [other]2155.878.070.4115.22.87300.410.62
confirmed deaths [other]28212.9714.530.5847.575.547.430010.78
julia
province_skill_by_cutoff_fig = plot_forecast_skill_by_cutoff(
    province_score_by_release_table;
    title = "Relative skill against the baseline by release, by province",
    empty_message = _province_empty
);

Relative skill against the baseline release by release, so a run of releases that lost to the baseline reads as a run rather than as an average.
Province scores by release
made_datestreamncrpsrel_to_baselinelog_crpslog_rel_to_baselinedispersionoverpredictionunderpredictioncoverage_50coverage_90bias
2026-09-21confirmed cases [haut_uele]418.912.150.2141.7813.685.230110.42
2026-09-22confirmed cases [haut_uele]413.661.970.1661.6511.542.120110.26
2026-09-25confirmed cases [haut_uele]310.742.30.1741.788.622.120110.33
2026-09-26confirmed cases [haut_uele]310.132.480.1792.017.432.70110.39
2026-09-27confirmed cases [haut_uele]38.512.150.1461.86.881.630110.32
2026-10-02confirmed cases [haut_uele]27.462.170.1831.85.861.60110.33
2026-10-06confirmed cases [haut_uele]25.671.020.1540.825.1100.5611-0.21
2026-10-10confirmed cases [haut_uele]14.241.720.161.733.9600.2911-0.17
2026-09-21confirmed cases [ituri]454.930.810.0820.9148.586.350110.25
2026-09-22confirmed cases [ituri]475.91.260.1091.3349.6526.2500.510.48
2026-09-25confirmed cases [ituri]333.570.980.0620.7431.532.040110.15
2026-09-26confirmed cases [ituri]360.771.790.1281.7536.7324.0400.3310.52
2026-09-27confirmed cases [ituri]332.331.120.0650.9731.6400.6911-0.08
2026-10-02confirmed cases [ituri]237.691.450.0941.3225.8611.8300.510.45
2026-10-06confirmed cases [ituri]246.892.270.1051.8331.8315.060110.4
2026-10-10confirmed cases [ituri]124.821.350.0841.2122.891.920110.15
2026-09-21confirmed cases [nord_kivu]441.631.50.1221.5836.165.130.340.7510.19
2026-09-22confirmed cases [nord_kivu]439.682.320.1112.535.574.050.060.7510.16
2026-09-25confirmed cases [nord_kivu]329.891.040.0940.8322.760.027.1111-0.22
2026-09-26confirmed cases [nord_kivu]336.241.040.1160.9627.490.468.311-0.17
2026-09-27confirmed cases [nord_kivu]365.341.190.2160.9622.17043.1701-0.68
2026-10-02confirmed cases [nord_kivu]222.50.410.0860.420.50211-0.14
2026-10-06confirmed cases [nord_kivu]268.93.70.2042.6429.9538.940010.67
2026-10-10confirmed cases [nord_kivu]1741.380.3631.2420.8553.150000.91
2026-09-21confirmed cases [other]418.653.260.4841.489.19.560010.7
2026-09-22confirmed cases [other]418.973.740.5261.658.3510.620010.75
2026-09-25confirmed cases [other]36.693.180.2722.324.452.2400.6710.49
2026-09-26confirmed cases [other]36.353.540.2892.354.531.8200.6710.45
2026-09-27confirmed cases [other]34.082.630.1792.233.730.360110.25
2026-10-02confirmed cases [other]23.130.460.1960.432.6500.4711-0.24
2026-10-06confirmed cases [other]24.320.440.2630.422.4701.850.51-0.47
2026-10-10confirmed cases [other]12.463.710.1633.72.4600110
2026-09-21confirmed deaths [haut_uele]44.971.620.1491.254.480.480.01110.13
2026-09-22confirmed deaths [haut_uele]44.121.120.141.1140.010.1111-0.06
2026-09-25confirmed deaths [haut_uele]33.411.110.1410.873.380.020110.03
2026-09-26confirmed deaths [haut_uele]34.041.270.1941.13.350.690110.26
2026-09-27confirmed deaths [haut_uele]33.481.540.1461.443.420.060110.08
2026-10-02confirmed deaths [haut_uele]23.031.090.1711.12.5200.5211-0.21
2026-10-06confirmed deaths [haut_uele]24.361.370.2621.462.5101.8511-0.39
2026-10-10confirmed deaths [haut_uele]13.941.630.3051.832.0401.911-0.5
2026-09-21confirmed deaths [ituri]418.310.550.0610.6817.390.010.9111-0.07
2026-09-22confirmed deaths [ituri]419.340.680.0620.7918.920.110.31110
2026-09-25confirmed deaths [ituri]316.440.860.0690.7613.6102.8311-0.27
2026-09-26confirmed deaths [ituri]319.40.70.0980.8514.654.7400.6710.33
2026-09-27confirmed deaths [ituri]315.420.80.0670.9713.290.082.0511-0.14
2026-10-02confirmed deaths [ituri]211.911.050.071.1211.1700.7411-0.17
2026-10-06confirmed deaths [ituri]214.051.230.0771.1611.0203.0311-0.2
2026-10-10confirmed deaths [ituri]19.10.730.0750.839.0200.0811-0.05
2026-09-21confirmed deaths [nord_kivu]454.992.280.3122.4716.7838.21000.750.83
2026-09-22confirmed deaths [nord_kivu]440.652.640.2332.9916.5124.140010.7
2026-09-25confirmed deaths [nord_kivu]315.661.030.0940.7511.813.850110.38
2026-09-26confirmed deaths [nord_kivu]316.690.940.1060.7112.594.10110.35
2026-09-27confirmed deaths [nord_kivu]312.490.40.0760.2911.9100.5811-0.08
2026-10-02confirmed deaths [nord_kivu]212.970.870.0950.89.953.020110.34
2026-10-06confirmed deaths [nord_kivu]215.251.090.0960.9310.774.480110.25
2026-10-10confirmed deaths [nord_kivu]122.142.670.2182.398.9313.210010.66
2026-09-21confirmed deaths [other]48.63.330.741.383.335.270010.83
2026-09-22confirmed deaths [other]46.433.080.5621.372.843.5900.2510.71
2026-09-25confirmed deaths [other]33.282.880.5351.421.781.5100.3310.64
2026-09-26confirmed deaths [other]32.823.270.491.791.741.0800.6710.59
2026-09-27confirmed deaths [other]32.223.20.32.211.520.70110.46
2026-10-02confirmed deaths [other]21.850.40.3190.321.100.7611-0.4
2026-10-06confirmed deaths [other]21.310.450.2280.431.0300.2811-0.28
2026-10-10confirmed deaths [other]11.250.60.2310.61.140.110110.23
Load the archived province forecasts and their outcomes
julia
# Written by `scripts/score_releases.jl` from each release's
# `province_forecast.csv`, projection rows only, in the national overlay's
# schema. A missing file reads as an empty table.
evaluation_province_overlay_df = _release_data(
    joinpath("province", "forecast_overlay.csv"),
    (;
        release = String, made_date = Date, stream = String, horizon = Int,
        target_date = Date, fit = String, observed = Float64,
        median = Float64, lo30 = Float64, hi30 = Float64, lo60 = Float64,
        hi60 = Float64, lo90 = Float64, hi90 = Float64,
    )
)
evaluation_province_overlay_fig = plot_forecast_overlay(
    scored_overlay(evaluation_province_overlay_df);
    empty_message = _province_empty
);

Each release's province forecasts against what each province went on to report, one panel per stream and horizon, by the cut-off each was made from. Each forecast shows its median and 90% predictive interval beside the persistence baseline and the observed count.

Saving province forecast outputs ​

Write the summary bullets
julia
# The bullets under the summary heading at the top of the page. They read
# tables built further down, so they are written here and read back when
# the site is assembled.
evaluation_forecast_province_summary = let
    fmt(x) = ismissing(x) || !isfinite(x) ? "n/a" :
        string(round(x; digits = 2))
    overview = forecast_score_overview(province_scores_df)
    # The archive labels each province stream `<stream> [<province name>]`.
    function scored(p)
        tag = string(" [", PROVINCE_NAMES[p], "]")
        rows = filter(
            r -> endswith(r.stream, tag) && !ismissing(r.rel_to_baseline),
            overview
        )
        return (; tag, rows)
    end
    with_scores = [p for p in 1:N_PATCHES if size(scored(p).rows, 1) > 0]
    all_rows = filter(r -> !ismissing(r.rel_to_baseline), overview)
    n_beat = count(
        p -> all(<(1), scored(p).rows.rel_to_baseline), with_scores
    )
    overall = if isempty(with_scores)
        [
            "- **Forecasts:** no province forecast has been scored yet. " *
                "This fills in as releases carrying the per-province " *
                "projection accumulate.",
        ]
    else
        [
            string(
                "- **Provinces:** ", n_beat, " of ", length(with_scores),
                " provinces with scored forecasts beat the baseline on ",
                "every stream scored for them."
            ),
            string(
                "- **Province streams:** ",
                count(<(1), all_rows.rel_to_baseline), " of ",
                size(all_rows, 1), " beat the baseline."
            ),
        ]
    end
    function detail(p)
        sc = scored(p)
        bullets = [
            string(
                "- ", replace(r.stream, sc.tag => ""), ": relative skill ",
                fmt(r.rel_to_baseline), ", 90% coverage ",
                fmt(r.coverage_90), " over ", r.n, " forecasts."
            )
                for r in eachrow(sc.rows)
        ]
        isempty(bullets) && (bullets = ["- No scored forecast yet."])
        return join(
            vcat([string("**", PROVINCE_LABELS[p], "**"), ""], bullets), "\n"
        )
    end
    join(
        vcat([join(overall, "\n")], [detail(p) for p in 1:N_PATCHES]),
        "\n\n"
    )
end
dashboard_dir = joinpath(
    pkgdir(BVDOutbreakSize), "docs", "src", "summary_assets"
)
mkpath(dashboard_dir)
write(
    joinpath(dashboard_dir, "evaluation_forecast_province.md"),
    evaluation_forecast_province_summary
);