Skip to content

Health-zone in-sample checks ​

This page checks whether the health-zone model reproduces the per-zone data it was fitted to. The national checks are on the national in-sample checks page and the provincial ones on the province in-sample checks page. The zone data enter the model as compositions of their patch's totals, so the checks are on each zone's share of its patch. The last section sets each zone's reproduction number from the current fit against the same fit a week earlier.

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. The headline joint
# carries the patch trajectories the zone stage conditions on, and the
# frozen zone fit is the same fit a week earlier.
chn_joint = load_fit("joint");
chn_local = load_fit("local");
frozen_local = load_fit("local_frozen_validation");

Summary ​

The overall bullets come first, then a block per province, from the calibration below. Bias and coverage are defined as on the national in-sample checks page, with a nominal coverage of 0.9.

  • Least well reproduced: Haut-Uele (cases) (bias 0.03, 90% coverage 0.95); Nord-Kivu (cases) (bias -0.01, 90% coverage 0.98); Tshopo (cases) (bias 0.01, 90% coverage 0.97).

  • Coverage: 16 of 16 composition rows have 90% coverage of at least 0.8; the lowest is Haut-Uele (cases) at 0.95.

Ituri

  • Confirmed cases: bias -0.0 and 90% coverage 0.97 over 3388 cells.

  • Confirmed deaths: bias 0.0 and 90% coverage 0.98 over 3304 cells.

Nord-Kivu

  • Confirmed cases: bias -0.01 and 90% coverage 0.98 over 1972 cells.

  • Confirmed deaths: bias -0.0 and 90% coverage 0.98 over 1887 cells.

Haut-Uele

  • Confirmed cases: bias 0.03 and 90% coverage 0.95 over 567 cells.

  • Confirmed deaths: bias 0.01 and 90% coverage 0.98 over 469 cells.

Sud-Kivu

  • Confirmed cases: bias 0.0 and 90% coverage 1.0 over 1 cells.

  • Confirmed deaths: bias 0.0 and 90% coverage 1.0 over 1 cells.

Tshopo

  • Confirmed cases: bias 0.01 and 90% coverage 0.97 over 266 cells.

  • Confirmed deaths: bias 0.0 and 90% coverage 0.99 over 168 cells.

Bas-Uele

  • Confirmed cases: bias 0.01 and 90% coverage 1.0 over 30 cells.

  • Confirmed deaths: bias -0.0 and 90% coverage 1.0 over 12 cells.

Sud-Ubangi

  • Confirmed cases: bias 0.0 and 90% coverage 1.0 over 2 cells.

  • Confirmed deaths: bias 0.0 and 90% coverage 1.0 over 1 cells.

Nord-Ubangi

  • Confirmed cases: bias 0.0 and 90% coverage 1.0 over 2 cells.

  • Confirmed deaths: bias 0.0 and 90% coverage 1.0 over 1 cells.

Zone compositions ​

The zone compositions sit one level below the province compositions. The cases run on the infection-to-report delay and the deaths on the infection-to-confirmed-death delay, each with its own overdispersion, so the two are checked separately.

Load the zone fit's inputs and prior
julia
# `zone_stage_inputs` and `zone_prior_draws` are defined in the shared setup,
# so the zone estimates and forecast pages read the same fixed inputs.
zone_inputs = zone_stage_inputs();
zone_patch = zone_inputs.patch_of_zone;
prior_chn_zone = zone_prior_draws(zone_inputs);

Confirmed cases ​

Zone case composition predictive checks
julia
# Per-draw expected, predictive and observed zone shares at every vintage,
# from the posterior and from the zone prior, per vintage and cumulative.
zone_ppc = zone_composition_draws(chn_local, zone_inputs);
zone_ppc_prior = zone_composition_draws(prior_chn_zone, zone_inputs);
zone_ppc_cum = zone_composition_draws(
    chn_local, zone_inputs;
    cumulative = true
);
zone_ppc_prior_cum = zone_composition_draws(
    prior_chn_zone, zone_inputs;
    cumulative = true
);
zone_ppc_fig = plot_zone_shares(
    zone_ppc.expected, zone_ppc.observed,
    zone_inputs.dates, zone_inputs.zone_labels;
    zone_patch, patch_labels = zone_inputs.patch_labels,
    pred_draws = zone_ppc.predictive_share,
    prior_draws = zone_ppc_prior.predictive_share, top = 15, ncols = 5
);
zone_ppc_cum_fig = plot_zone_shares(
    zone_ppc_cum.expected,
    zone_ppc_cum.observed, zone_inputs.dates, zone_inputs.zone_labels;
    zone_patch, patch_labels = zone_inputs.patch_labels,
    pred_draws = zone_ppc_cum.predictive_share,
    prior_draws = zone_ppc_prior_cum.predictive_share, top = 15, ncols = 5,
    title = "Zone share of the patch's cumulative confirmed cases, " *
        "modelled against observed"
);

The modelled share of the patch's confirmed cases at each vintage against the observed share, for the zones with the largest observed shares. A zone with a handful of cases per vintage has an observed share that jumps between zero and one, and its band spans the same range.

The same check on each zone's share of its patch's cumulative allocated cases at every vintage.

Confirmed deaths ​

Zone death composition predictive check
julia
zone_death_ppc_cum = zone_composition_draws(
    chn_local, zone_inputs;
    stream = :deaths, cumulative = true
);
zone_death_ppc_prior_cum = zone_composition_draws(
    prior_chn_zone, zone_inputs;
    stream = :deaths, cumulative = true
);
zone_death_ppc_cum_fig = plot_zone_shares(
    zone_death_ppc_cum.expected, zone_death_ppc_cum.observed,
    zone_inputs.dates, zone_inputs.zone_labels;
    zone_patch, patch_labels = zone_inputs.patch_labels,
    pred_draws = zone_death_ppc_cum.predictive_share,
    prior_draws = zone_death_ppc_prior_cum.predictive_share,
    top = 15, ncols = 5,
    title = "Zone share of the patch's cumulative confirmed deaths, " *
        "modelled against observed"
);

Each zone's share of its patch's cumulative allocated deaths at every vintage, for the zones with the largest observed death shares. Most per-vintage death cells are empty, so the check uses the cumulative allocation.

Zone composition calibration ​

Both compositions are scored the way the stream calibration scores the national streams, over every zone and vintage of each patch.

Zone composition calibration
julia
# The two compositions in one table, each patch row named by the
# composition it belongs to so the calibration plot reads them apart.
zone_calibration_table = let
    function tagged(stream, what)
        tbl = zone_composition_calibration(
            chn_local, zone_inputs; stream = stream
        )
        tbl[!, "Stream"] = string.(tbl[!, "Stream"], " ($what)")
        return tbl
    end
    vcat(tagged(:cases, "cases"), tagged(:deaths, "deaths"))
end;
zone_calibration_fig = plot_stream_calibration(zone_calibration_table);

StreamVintagesBias50% coverage90% coverage
Ituri (cases)338800.830.97
Nord-Kivu (cases)1972-0.010.870.98
Haut-Uele (cases)5670.030.810.95
Sud-Kivu (cases)1011
Tshopo (cases)2660.010.890.97
Bas-Uele (cases)300.010.91
Sud-Ubangi (cases)2011
Nord-Ubangi (cases)2011
All zones (cases)622800.850.97
Ituri (deaths)330400.90.98
Nord-Kivu (deaths)188700.890.98
Haut-Uele (deaths)4690.010.90.98
Sud-Kivu (deaths)1011
Tshopo (deaths)16800.910.99
Bas-Uele (deaths)1200.921
Sud-Ubangi (deaths)1011
Nord-Ubangi (deaths)1011
All zones (deaths)584300.90.98
The mean bias of the predictive allocation and the fractions of observed zone counts inside the central 50% and 90% predictive intervals. A cell with no case or death is covered by any interval that includes zero, so the coverage is read with the share of empty cells in mind.

Change over the past week ​

The comparison reads each zone's reproduction number on the frozen cut-off day from the frozen and live fits, alongside the live estimate at the current cut-off. The two fits condition on different weeks of data and on different parent fits. A zone carries its own walk only once it has reported 30 confirmed cases, so the comparison is restricted to zones walking in both fits.

Zone cut-off summaries shared by the comparisons
julia
# The frozen fit's fixed inputs, from the shared setup.
frozen_zone_inputs = frozen_zone_stage_inputs();

# One row per zone in `zs` with the median and the 50% and 90% intervals
# of its draws (one vector per zone), in the schema the zone dot plots
# read, plus the zone's key to match variants by and its confirmed cases
# to rank by. A zone with no finite draw is left out.
function _zone_cutoff_summary(draws, inputs, zs = eachindex(draws))
    keep = [i for (i, v) in enumerate(draws) if any(isfinite, v)]
    z = zs[keep]
    tbl = zone_summary_table(
        [filter(isfinite, draws[i]) for i in keep],
        inputs.zone_labels[z], inputs.patch_of_zone[z]
    )
    tbl.key = inputs.zone_keys[z]
    tbl.cases = inputs.cumulative[z]
    return tbl
end

# The `top` zones by confirmed cases in the first variant, one column per
# variant for the reproduction number. Each cell is a median with its 90%
# interval. Variants are matched by zone key.
function _zone_comparison_table(variants; top::Integer = 10)
    function cell(t, key, d, scale)
        i = findfirst(==(key), t.key)
        i === nothing && return ""
        f(x) = string(round(scale * x; digits = d))
        return string(f(t.median[i]), " (", f(t.lo90[i]), "–", f(t.hi90[i]), ")")
    end
    base = first(last(first(variants)))
    order = sortperm(base.cases; rev = true)[1:min(top, size(base, 1))]
    df = DataFrame(
        "Zone" => base.label[order],
        "Province" => [PROVINCE_LABELS[p] for p in base.patch[order]],
        "Cases" => base.cases[order]
    )
    for (q, name, d, scale) in ((:R, "R", 2, 1),),
            (label, s) in variants

        haskey(s, q) || continue
        df[!, "$name ($label)"] = [
            cell(s[q], k, d, scale)
                for k in base.key[order]
        ]
    end
    return df
end
_zone_comparison_table (generic function with 1 method)
Zone reproduction numbers from the frozen and live fits
julia
# Daily reproduction numbers rebuilt from both fits, and the zones walking
# in both as live index => frozen index, matched by key. The summaries
# take their labels and cases from the live inputs.
zone_rt_live = reconstruct_zone_rt(chn_local, zone_inputs)
zone_rt_frozen = reconstruct_zone_rt(frozen_local.chn, frozen_zone_inputs)
zone_week_pairs = [
    z => j
        for (z, k) in enumerate(zone_inputs.zone_keys)
        for j in (findfirst(==(k), frozen_zone_inputs.zone_keys),)
        if j !== nothing && zone_inputs.walking[z] &&
        frozen_zone_inputs.walking[j]
]
zone_week_variants = let zs = first.(zone_week_pairs), js = last.(zone_week_pairs),
        n_f = frozen_zone_inputs.n

    [
        "frozen fit at its cut-off" => (;
            R = _zone_cutoff_summary(
                [zone_rt_frozen[j][:, n_f] for j in js],
                zone_inputs, zs
            ),
        ),
        "live fit on the same day" => (;
            R = _zone_cutoff_summary(
                [zone_rt_live[z][:, n_f] for z in zs],
                zone_inputs, zs
            ),
        ),
        "live fit at its cut-off" => (;
            R = _zone_cutoff_summary(
                [zone_rt_live[z][:, end] for z in zs],
                zone_inputs, zs
            ),
        ),
    ]
end
zone_week_table = _zone_comparison_table(zone_week_variants);
zone_week_fig = plot_zone_comparison(
    [l => s.R for (l, s) in zone_week_variants];
    xlabel = "Reproduction number", reference_line = 1.0,
    title = "Zone reproduction number from the frozen and live fits"
);

# The frozen trajectories padded onto the live grid, undefined past the
# frozen cut-off, behind the live ones over the zone grid. The plot reads
# an undefined day as `missing`, where the reconstruction writes `NaN`.
zone_week_rt_fig = let grid = zone_inputs.t0:obs.n, n_f = frozen_zone_inputs.n
    asmissing(m) = replace(m, NaN => missing)
    frozen = map(zone_week_pairs) do (z, j)
        m = fill(NaN, size(zone_rt_frozen[j], 1), obs.n)
        m[:, 1:n_f] .= zone_rt_frozen[j]
        asmissing(m[:, grid])
    end
    zs = first.(zone_week_pairs)
    plot_rt_zones(
        [asmissing(zone_rt_live[z][:, grid]) for z in zs],
        zone_inputs.zone_labels[zs], zone_inputs.patch_of_zone[zs];
        patch_labels = zone_inputs.patch_labels,
        dates = grid_date.(grid), as_of_date = obs.cutoff,
        ranking = zone_inputs.cumulative[zs],
        ranking_label = "confirmed cases to date", top = 12,
        reference_rt = frozen, reference_label = "Frozen fit",
        title = "Zone reproduction number from the live fit, " *
            "with the frozen fit behind"
    )
end

ZoneProvinceCasesR (frozen fit at its cut-off)R (live fit on the same day)R (live fit at its cut-off)
BuniaIturi19140.85 (0.33–1.65)0.99 (0.54–1.51)0.97 (0.4–1.72)
RwamparaIturi11730.8 (0.32–1.65)1.0 (0.57–1.62)1.01 (0.41–1.92)
NiziIturi8320.88 (0.35–1.87)0.85 (0.43–1.48)0.87 (0.31–1.84)
MongbwaluIturi7170.63 (0.22–1.79)0.71 (0.31–1.68)0.8 (0.27–2.12)
KatwaNord-Kivu6480.92 (0.51–1.6)0.86 (0.54–1.27)1.01 (0.57–1.68)
BeniNord-Kivu5531.07 (0.67–1.59)1.12 (0.84–1.47)1.11 (0.75–1.55)
MangalaIturi3820.74 (0.26–1.95)0.76 (0.35–1.49)0.83 (0.32–2.06)
MandimaIturi3620.72 (0.24–1.67)1.11 (0.59–1.73)0.98 (0.34–1.79)
Nia-NiaIturi3340.73 (0.21–2.18)0.65 (0.28–1.37)0.72 (0.26–1.97)
ButemboNord-Kivu3190.91 (0.49–1.63)0.89 (0.55–1.33)1.04 (0.58–1.7)
The reproduction number from the frozen and live fits for the ten zones with most confirmed cases.

The fifteen zones the frozen fit ranks highest.

Reproduction number trajectories for the twelve zones with most confirmed cases, with the frozen fit drawn behind the live one.

Saving zone in-sample outputs ​

Write the summary bullets
julia
# The bullets under the summary heading at the top of the page. They read
# the calibration table built further down, so they are written here and
# read back when the site is assembled.
evaluation_insample_zone_summary = let
    fmt(x) = ismissing(x) || !isfinite(x) ? "n/a" :
        string(round(x; digits = 2))
    # The per-patch rows only: the table's "All zones" rows pool them and
    # would be counted twice.
    fitted = filter(
        r -> isfinite(r["Bias"]) && !startswith(r["Stream"], "All zones"),
        zone_calibration_table
    )
    worst = first(
        sort(fitted, "Bias"; by = abs, rev = true), min(3, size(fitted, 1))
    )
    low = first(sort(fitted, "90% coverage"), min(1, size(fitted, 1)))
    overall = [
        string(
            "- **Least well reproduced:** ",
            join(
                [
                    string(
                        r["Stream"], " (bias ", fmt(r["Bias"]),
                        ", 90% coverage ", fmt(r["90% coverage"]), ")"
                    )
                        for r in eachrow(worst)
                ], "; "
            ), "."
        ),
        string(
            "- **Coverage:** ", count(>=(0.8), fitted[!, "90% coverage"]),
            " of ", size(fitted, 1),
            " composition rows have 90% coverage of at least 0.8",
            size(low, 1) == 0 ? "." :
                string(
                    "; the lowest is ", low[1, "Stream"], " at ",
                    fmt(low[1, "90% coverage"]), "."
                )
        ),
    ]
    rows(tbl) = Dict(r["Stream"] => r for r in eachrow(tbl))
    cal = rows(zone_calibration_table)
    function calibration(p, what, label)
        r = get(cal, string(PROVINCE_LABELS[p], " (", what, ")"), nothing)
        r === nothing && return string("- ", label, ": not scored.")
        return string(
            "- ", label, ": bias ", fmt(r["Bias"]), " and 90% coverage ",
            fmt(r["90% coverage"]), " over ", r["Vintages"], " cells."
        )
    end
    detail = [
        join(
            [
                string("**", PROVINCE_LABELS[p], "**"), "",
                calibration(p, "cases", "Confirmed cases"),
                calibration(p, "deaths", "Confirmed deaths"),
            ], "\n"
        )
            for p in 1:N_PATCHES
    ]
    join(vcat([join(overall, "\n")], detail), "\n\n")
end
dashboard_dir = joinpath(
    pkgdir(BVDOutbreakSize), "docs", "src", "summary_assets"
)
mkpath(dashboard_dir)
write(
    joinpath(dashboard_dir, "evaluation_insample_zone.md"),
    evaluation_insample_zone_summary
);