Forecasts, scoring and counterfactuals
Forecasting each stream from the fitted model run past its cut-off, scoring those forecasts against what was later observed, the counterfactual and delay-corrected case-fatality calculations, and the check that the model recovers what it simulates. Scoring is continuous ranked probability score and its decomposition, with a persistence baseline for relative skill.
Index
BVDOutbreakSize.PROVINCE_FORECAST_METHODBVDOutbreakSize.SUPERSEDED_FROZEN_FORECASTSBVDOutbreakSize.confirmed_cfr_tableBVDOutbreakSize.crps_decompositionBVDOutbreakSize.crps_sampleBVDOutbreakSize.delay_corrected_cfrBVDOutbreakSize.delay_corrected_confirmed_cfrBVDOutbreakSize.drop_degenerate_fit_columnBVDOutbreakSize.drop_individual_fit_columnsBVDOutbreakSize.drop_rescanned_onset_windowsBVDOutbreakSize.drop_superseded_forecastsBVDOutbreakSize.forecast_archiveBVDOutbreakSize.forecast_drawsBVDOutbreakSize.forecast_onsetsBVDOutbreakSize.forecast_provincesBVDOutbreakSize.forecast_recovery_tableBVDOutbreakSize.forecast_reportedBVDOutbreakSize.forecast_score_by_horizonBVDOutbreakSize.forecast_score_by_releaseBVDOutbreakSize.forecast_score_by_vintageBVDOutbreakSize.forecast_score_overviewBVDOutbreakSize.forecast_streamBVDOutbreakSize.forecast_tableBVDOutbreakSize.forecast_vs_truthBVDOutbreakSize.generator_jointBVDOutbreakSize.log_crps_sampleBVDOutbreakSize.onset_forecast_tableBVDOutbreakSize.predict_no_onward_deathsBVDOutbreakSize.province_forecast_archiveBVDOutbreakSize.recovery_dataBVDOutbreakSize.recovery_density_checkBVDOutbreakSize.recovery_fitBVDOutbreakSize.recovery_observed_varnamesBVDOutbreakSize.recovery_overallBVDOutbreakSize.recovery_seed_verdictsBVDOutbreakSize.recovery_summaryBVDOutbreakSize.recovery_tableBVDOutbreakSize.recovery_verdictBVDOutbreakSize.score_drawsBVDOutbreakSize.scored_overlayBVDOutbreakSize.select_fit_roleBVDOutbreakSize.simulate_recoveryBVDOutbreakSize.with_horizon
Reference
BVDOutbreakSize.PROVINCE_FORECAST_METHOD Constant
PROVINCE_FORECAST_METHODThe method province_forecast_archive records on each row, and the only one the release scoring scores. It names the method of forecast_provinces, so archives of another method are never scored alongside it.
BVDOutbreakSize.forecast_archive Method
forecast_archive(fcs; made_date, thin)forecast_archive(fcs; made_date, thin = 1) -> DataFrameLong-format archive of one or more forecast_reported results made from a single cut-off, for the observed streams scored across releases. fcs is an iterable of (horizon, fc) pairs and made_date is the cut-off Date the forecasts were made from. Returns one row per (stream, horizon, draw) with columns made_date, horizon, target_date (made_date plus the horizon), stream, draw and value.
Only the incident and level quantities are archived: confirmed cases and confirmed deaths new over the horizon, recovered new over the horizon, onset reports new over the horizon, and the supply-limited isolation beds occupancy. The cumulative totals are revised across data vintages, so they are not archived. The reporting triangle's own total is revised by more than the rest, since the printed total falls between consecutive vintages (2531 to 2523, and 2018 to 1996), which late reporting cannot produce.
When the forecast carries the confirmed/suspect ward split (confirmed_occupancy / suspect_occupancy, the occupancy partitioned by the cut-off confirmed share), the two ward occupancy levels are archived too as treatment beds (confirmed) and isolation beds (suspected), each scored against its own Tableau 6 occupancy sub-stock. The total isolation beds occupancy stays as its own stream. Nothing produces those two columns yet: forecast_reported projects the total occupancy alone, so the two ward entries are reachable only from a forecast a caller has partitioned itself, which today is the tests. Streams a forecast does not carry are skipped, so this costs a real forecast nothing. thin keeps every thin-th draw so the archive stays compact when it is saved as a release asset.
BVDOutbreakSize.forecast_draws Method
forecast_draws(
model::DynamicPPL.Model,
chn;
horizon,
seed
) -> Union{AbstractMCMC.SamplingOutput{_A, I, Missing, Missing} where {_A, I<:(AbstractRange{<:Integer})}, FlexiChains.FlexiChain{AbstractPPL.VarName, FlexiChains.FlexiChainMetadata{var"#s185", var"#s1851", Vector{Missing}, Vector{Missing}}} where {var"#s185"<:DimensionalData.Dimensions.Lookups.Lookup{Int64, 1}, var"#s1851"<:DimensionalData.Dimensions.Lookups.Lookup{Int64, 1}}}forecast_draws(model, chn; horizon = 28, seed = 20260520)Posterior-predictive draws past the cut-off from a fitted model and its chain chn: predict on with_horizon(model, horizon). Each draw keeps its fitted parameters. The future walk innovations, the future latent series and every stream's future counts are drawn from the model. The result is a chain with one draw per fitted draw, which forecast_reported, forecast_stream, forecast_onsets and forecast_provinces read at any horizon up to horizon. seed fixes the draws, so the national and the province forecasts read one set of draws.
BVDOutbreakSize.forecast_onsets Method
forecast_onsets(
pp;
horizon,
obs_value,
strict
) -> Union{Nothing, DataFrames.DataFrame}forecast_onsets(pp; horizon = 7, obs_value = missing, strict = true)
-> DataFrameNowcast and forecast of symptom onsets and of the reporting triangle that observes them, one row per draw, read from the posterior-predictive draws pp of a fit carrying the triangle (forecast_draws). It separates cases that have had their onset but are not yet reported from cases whose onset has not happened. The model runs the fitted reporting hazard, ascertainment and scan noise forward to a future vintage horizon days past the cut-off, which must be a whole number of weeks (see onset_forecast_model). Columns, all per draw:
onsets_to_date: symptom onsets that have happened by the cut-off.onset_reports_to_date: of those, the number the triangle should already have printed.onsets_unreported: their difference, the reporting backlog and the cases ascertainment will never pick up.onsets_new: onsets over the horizon.onset_reports_backfill: reports arriving over the horizon of onsets on or before the cut-off.onset_reports_future: reports arriving over the horizon of onsets after the cut-off.onset_reports_new: the drawn new reported count, the scored forecast, rounded and floored at zero as the observed increment is.onset_reports_cum:obs_value + onset_reports_newwhenobs_value(the triangle's own total at the cut-off) is supplied, so the forecast can be plotted on the observed scale.
With strict = false it returns nothing rather than erroring when the draws carry no onset forecast or horizon is not a future vintage.
BVDOutbreakSize.forecast_provinces Method
forecast_provinces(
pp;
horizon,
n_patches,
patch_labels
) -> DataFrames.DataFrameforecast_provinces(pp; horizon = 7, n_patches, patch_labels) -> DataFrameThe per-province forecast horizon days past the cut-off, read from the posterior-predictive draws pp of the patch joint (forecast_draws). Each province's reproduction number continues the fitted national walk and the province's correlated, mean-reverting deviation, and each province renews with the fitted importation. Its confirmed cases and confirmed deaths are the national forecast counts split each week by the fitted province compositions, with the province's own delays, relative ascertainment and severity, so the provinces add up to the national forecast in every draw (see bvd_joint).
Returns one row per province and draw, with columns patch, province (its label in patch_labels), draw, infections_new, rt_forecast (the province's reproduction number on the last day), and confirmed_new and confirmed_deaths_new when the fit carries the matching composition, and isolation_level, bed_capacity and admissions_new when it carries the province occupancy and bed splits: the province's patients in isolation and beds on the last day, and its admissions over the horizon (treatment_forecast_model), each adding up to the national forecast. The weekly counts need horizon to be a whole number of weeks.
BVDOutbreakSize.forecast_reported Method
forecast_reported(
pp;
horizon,
obs_cases,
obs_deaths,
obs_confirmed,
obs_confirmed_deaths,
obs_recovered
)forecast_reported(pp; horizon = 7, obs_cases, obs_deaths, ...) -> DataFrameThe national forecast horizon days past the cut-off, one row per draw, read from the posterior-predictive draws pp of the joint (forecast_draws). Every count is a draw of the fitted model's own future observations: the renewal run on, each stream's delays and ascertainment, and each stream's own likelihood. Columns:
:cases_cum,:deaths_cum: cumulative suspected reported cases and deaths by the cut-off plus the horizon, the observed cut-off cumulative plus the drawn new counts.:confirmed_cum,:confirmed_deaths_cum: the laboratory-confirmed counterparts, present whenobs_confirmedandobs_confirmed_deathsare supplied.:cases_new, …:confirmed_deaths_new: new counts over the horizon.:isolation_level: the reported isolation occupancy on the last day, the sum over the patches of the occupancy censored at each patch's beds (treatment_forecast_model).:bed_demandis the modelled demand that day and:bed_shortfallits excess over the beds, summed over the patches.:admissions_fc,:incare_deaths_fc,:ruleouts_fc: the daily isolation flows on the last day.:recovered_cum,:recovered_new: recovered among confirmed. The cut-off cumulative isobs_recoveredwhen supplied, otherwise the fittedexpected_recovered_T, andrecovered_newneedsobs_recovered.:infections_new,:onsets_new,:deaths_latent_new: new latent infections, symptom onsets and deaths over the horizon, which carry parameter uncertainty only.:rt_forecast: the reproduction number on the last day.the
forecast_onsetscolumns, when the fit carries the onset triangle andhorizonis a whole number of weeks.
BVDOutbreakSize.forecast_stream Method
forecast_stream(pp, stream::Symbol; horizon) -> Anyforecast_stream(pp, stream; horizon = 7) -> VectorThe forecast of one observed stream horizon days past the cut-off, one value per draw, from the posterior-predictive draws pp of either the joint or the stream's own single-stream fit (forecast_draws). Every composer draws its streams' future counts under the same names, so one reader serves both.
stream is one of :reported_cases, :suspected_deaths, :confirmed_cases, :confirmed_deaths, :recovered, :exports, :isolation_beds and :onset_reports. The incident streams return the new count over the horizon, the convention forecast_archive scores. :isolation_beds returns the reported occupancy on the last day. :onset_reports returns the new count the triangle prints over the horizon (forecast_onsets), which needs horizon to be a whole number of weeks.
BVDOutbreakSize.forecast_table Method
forecast_table(
fc::DataFrames.DataFrame;
digits
) -> DataFrames.DataFrameSummarise a forecast_reported result into a DataFrame with one row per confirmed stream (laboratory-confirmed cases and confirmed deaths) and quantity (cumulative total by the cut-off plus the horizon, or new this week), reporting the equal-tailed 30/60/90% credible interval endpoints (lower_90 … upper_90) used by the other summary tables. The suspected reported-case and suspected-death streams are no longer reported, so they are not shown as forecast targets.
BVDOutbreakSize.forecast_vs_truth Method
forecast_vs_truth(
fc::DataFrames.DataFrame;
observed,
baseline,
breaks,
isolation,
digits
)Validate a forecast_reported projection against the counts that were later observed. observed is a NamedTuple mapping each stream's cumulative column (:confirmed_cum, :cases_cum, …) to its observed cumulative count at the forecast target date. baseline maps the same columns to the cumulative count at the forecast origin (default 0). A stream is scored only when its cumulative column is in the forecast and a key for it is in observed. The scored streams are the reported cases, suspected deaths, laboratory-confirmed cases, confirmed deaths and recovered (the same set plot_forecast_vs_truth draws), so the call site can pass the same observed/baseline NamedTuples it builds for the plot.
Each scored stream gets two rows, mirroring the plot's two panels and the Quantity split of forecast_table: a cumulative by T+7 row scoring the projected cumulative against observed, and a new this week row scoring the projected new count against max(observed − breaks − baseline, 0). breaks is keyed like observed and carries each stream's retrospective harmonisation correction over the forecast window (see confirmed_break_correction), defaulting to zero. It comes out of both truths, because it lands in the reported cumulative without having been notified in that week and a projection cannot contain it.
When isolation (the observed bed occupancy at the target date) is supplied and the forecast carries the beds, the projected supply-limited occupancy is scored against it too as a single level row. Returns a DataFrame with the observed count, the equal-tailed 30/60/90% predictive intervals (the same endpoints as the other summary tables), and whether the observed count falls inside the 90% interval.
Note that at a one-week-back freeze the bed capacity is weakly informed (the reported occupancy rate starts only on 9 June), so the projected bed occupancy rides the capacity random walk back to the freeze date and its interval is wide.
sourceBVDOutbreakSize.onset_forecast_table Method
onset_forecast_table(
fc::DataFrames.DataFrame;
digits
) -> DataFrames.DataFrameonset_forecast_table(fc; digits = 0) -> DataFrameSummarise a forecast_onsets result into the report's usual 30/60/90% credible-interval table, one row per quantity, ordered so the nowcast is read before the forecast. The Quantity labels name what each row is a statement about rather than which column it came from, since "onsets" and "onsets reported" are different numbers:
symptom onsets to dateandof those, reported by T: the nowcast pair. Their difference is the next row.onsets not yet reported at T: already happened, not yet in the figure (reporting backlog and never-ascertained cases together, seeforecast_onsets).reports this week of onsets before T: the backlog the coming week should clear.reports this week of onsets after T: reports of cases that have not yet had their symptom onset.new onset reports this week: the replicated total of the two, the quantity scored against the triangle's own increment.new symptom onsets this week: the latent forecast, unobserved.
BVDOutbreakSize.province_forecast_archive Method
province_forecast_archive(
pp,
fcs;
made_date,
n_patches,
patch_labels,
thin
)province_forecast_archive(pp, fcs; made_date, thin = 1) -> DataFrameLong-format archive of one or more per-province forecasts made from a single cut-off, in the forecast_archive schema plus a province column. pp is the patch joint's posterior-predictive draws (forecast_draws), fcs an iterable of (horizon, fc) pairs and made_date the cut-off Date. Returns one row per (province, stream, horizon, draw) with columns made_date, horizon, target_date, province, stream, draw, value and method. method is PROVINCE_FORECAST_METHOD, so scoring can tell these rows from archives of another method or with no method column.
The two incident streams the spatial tables report are archived, under the same confirmed cases and confirmed deaths labels forecast_archive gives the national streams. province is the patch key from PROVINCE_NAMES, which PROVINCE_MEMBERS maps to the source provinces a patch pools, so a scorer can build each patch's truth from the per-province histories in the same release's observations.toml.
Each province's values are the forecast_provinces forecast from pp at that horizon. An fc that is already a province forecast is archived as it is. A national forecast_reported result is replaced by the province forecast from the same draws. thin keeps every thin-th draw so the archive stays compact as a release asset.
BVDOutbreakSize.with_horizon Method
with_horizon(
model::DynamicPPL.Model,
horizon::Integer
) -> Anywith_horizon(model, horizon) -> DynamicPPL.ModelThe fitted model rebuilt to run horizon days past its cut-off: the same arguments and keywords with forecast = ForecastHorizon(horizon), or the fitted model itself (forecast = nothing) for a horizon of zero. The extended model keeps every fitted variable and adds only the future ones, so predict on it with the fitted chain keeps each draw's parameters and draws the future (see ForecastHorizon). Any conditioning or fixing on model is kept.
BVDOutbreakSize.SUPERSEDED_FROZEN_FORECASTS Constant
SUPERSEDED_FROZEN_FORECASTSThe frozen archive's confirmed-death rows that a superseded forecaster produced, named as a stream and the window of frozen cut-offs the defect shows in.
Those reconstructions were built before the forecaster projected each stream from its own cumulative trajectory. Without one it inferred the cut-off daily rate by inverting the cumulative total under exponential growth, which collapses towards zero as the fitted growth rate reaches zero. Confirmed deaths was the stream carrying no trajectory, and from mid-July the reproduction number sits at one, so the two together floor the projection: every one of the fourteen reconstructions cut between these dates carries a confirmed-death median of exactly zero at the one-week horizon against an observed 250 to 370, and every reconstruction cut after the window projects the stream normally (318 against 295 at the first of them).
The same releases' May and June cut-offs are kept. The outbreak was growing then, so the same code returned a rate rather than a floor, and those rows carry no signature of the defect.
This lapses when those reconstructions are rebuilt with the current forecaster, which is what the frozen evaluation claims to be: the current model frozen at earlier cut-offs.
sourceBVDOutbreakSize.crps_decomposition Method
crps_decomposition(
obs::Real,
samples::AbstractVector{<:Real}
) -> NamedTuple{(:dispersion, :overprediction, :underprediction), <:Tuple{Any, Any, Any}}Sample-based CRPS decomposition of a predictive samples ensemble at a single observation obs, into (; dispersion, overprediction, underprediction), following the convention scoringutils uses for the weighted interval score, applied here to an ensemble's own order statistics rather than a fixed set of quantiles.
dispersion is the ensemble's spread on its own terms. Pair the i-th smallest and i-th largest draw as a central interval and sum each pair's width, weighted so the sum matches the CRPS the ensemble would score against its own median. overprediction and underprediction are the extra cost of scoring against obs rather than that best case, split by which side of each pair obs falls on. All three are non-negative and sum exactly to crps_sample at the same obs and samples, being an exact re-partition of the same order-statistic sum rather than an approximation.
With no draws (isempty(samples)), every component is NaN.
BVDOutbreakSize.crps_sample Method
crps_sample(
obs::Real,
samples::AbstractVector{<:Real}
) -> AnyContinuous ranked probability score of a predictive samples ensemble at a single observation obs. Lower is better, and it is zero for a perfect point forecast. For a point-mass (constant) ensemble the CRPS reduces to the absolute error abs(obs - samples[1]).
BVDOutbreakSize.drop_degenerate_fit_column Method
drop_degenerate_fit_column(
table::DataFrames.DataFrame
) -> DataFrames.DataFrametable (from forecast_score_overview, forecast_score_by_horizon or forecast_score_by_release) with its fit column dropped. Only for a table whose fit column is single-valued by construction rather than by the data currently on hand. No table in this report is structurally single-fit any more. The frozen evaluation was, until its archive gained a fit column and began carrying each stream's own frozen single-stream fit alongside the joint.
Every other column is left untouched. table is returned with fit still present when it is empty, since an empty table carries no evidence either way. State which model a de-columned table refers to in the surrounding prose, so the table stays self-describing once the column is gone.
Errors when table's fit column carries more than one distinct value. A column that vanishes only when it happens to be constant, and reappears once a second model is scored, is worse than one that never varies.
BVDOutbreakSize.drop_individual_fit_columns Method
drop_individual_fit_columns(
table::DataFrames.DataFrame
) -> DataFrames.DataFrametable (a table from forecast_score_overview, forecast_score_by_horizon or forecast_score_by_release) with the individual-fit comparison columns dropped rather than kept as an all-missing column. Use this to display a table built from an evaluation that never carries an individual single-stream fit, such as a frozen-fit evaluation, which scores only the joint model at past cut-offs.
BVDOutbreakSize.drop_rescanned_onset_windows Method
drop_rescanned_onset_windows(
tbl::DataFrames.DataFrame;
vintage_dates,
vintage_totals,
stream
)tbl (a scores or overlay table keyed by stream, made_date and target_date) with the onset-report windows spanning a reread of the digitised triangle removed. vintage_dates and vintage_totals are that triangle's per-vintage cumulative total (onset_report_history, as dates rather than grid days), in date order.
The onset truth is the increment between the vintages at the two ends of a window. Each vintage rereads the whole figure, and on fourteen of them the total comes back lower than the one before, which a cumulative onset curve cannot do. A window containing such a vintage is scored against an increment the situation reports did not add, so it is left unscored rather than charged to the forecast. The fit itself needs no such rule: it carries a per-vintage scan level.
A vintage inside the window matters as much as one at its end, which is why the test is on the window rather than on the target vintage alone. A reread that loses cases depresses the printed level and the vintages after it carry that level forward, so the loss lands in the target total whether or not the target vintage is itself the one that fell. The worst window in the archive is of exactly that shape: an increment of 82 against a typical 400 to 500, where the fall sits two days inside the window and neither endpoint is a falling vintage.
This is the rule province windows holding a harmonisation-break day already follow. It bites hardest at the longer horizons, a four-week window being more likely to contain a reread than a one-week one. A window anchored on a falling vintage but containing none is kept: its increment is measured from the reread rather than across it.
Every fit over a dropped window goes, the persistence baseline included, so a relative skill is never taken against a baseline whose own window was withheld.
Nothing is dropped from the archive; this selects what is summarised and drawn, as scored_overlay does.
BVDOutbreakSize.drop_superseded_forecasts Method
drop_superseded_forecasts(tbl::DataFrames.DataFrame) -> Anytbl (a frozen scores or overlay table, keyed by stream and made_date) with the rows a superseded forecaster produced removed.
One exclusion is in force, SUPERSEDED_FROZEN_FORECASTS: the confirmed-death rows of the fourteen frozen reconstructions cut between 16 July and 15 August 2026, whose forecaster could not project that stream and floored it at zero. They are dropped rather than read as the model forecasting no further deaths.
Every fit over a dropped window goes, the persistence baseline included, so a relative skill is never taken against a baseline whose own window was withheld.
Nothing is dropped from the archive itself. data/forecast_scores_frozen.csv and data/forecast_overlay_frozen.csv record what was scored; this selects what is summarised and drawn, as scored_overlay does.
Returns tbl unchanged when it is empty or carries no such row.
BVDOutbreakSize.forecast_score_by_horizon Method
forecast_score_by_horizon(
scores::DataFrames.DataFrame;
joint_fit,
baseline_fit
) -> DataFrames.DataFrameThe same columns as forecast_score_overview, one row per (stream, horizon, fit) rather than pooled across every horizon, so a fit that beats the baseline on average but not at every cut-off is visible.
BVDOutbreakSize.forecast_score_by_release Method
forecast_score_by_release(
scores::DataFrames.DataFrame;
joint_fit,
baseline_fit
) -> DataFrames.DataFrameThe same columns as forecast_score_overview, one row per (stream, fit, made_date), averaged across horizons rather than pooled over releases too, baseline rows excluded.
BVDOutbreakSize.forecast_score_by_vintage Method
forecast_score_by_vintage(
scores::DataFrames.DataFrame;
joint_fit,
baseline_fit,
min_releases
) -> DataFrames.DataFrameThe same columns as forecast_score_overview, one row per (stream, release, fit), plus the release_date rows are ordered by.
Restricted to made dates that min_releases or more releases forecast, the cut-offs every release re-forecasts. A made date one release carries has nothing to compare against and is dropped, which on the frozen table drops each release's own validation cut-off.
release_date is the release's latest made date, which orders releases in time where the tag names do not.
Returns a typed zero-row frame when scores is empty or carries no repeated made date.
BVDOutbreakSize.forecast_score_overview Method
forecast_score_overview(
scores::DataFrames.DataFrame;
joint_fit,
baseline_fit
) -> DataFrames.DataFrameOne row per (stream, fit), pooled over every horizon and release in scores, a data/forecast_scores.csv-shaped table, baseline rows excluded. The headline scoring table. Carries n, the mean CRPS and log-scale CRPS, the CRPS decomposition, the 50% and 90% coverage and bias, the relative skill against the stream's persistence baseline on both scales, and, on the joint row of a stream with an individual single-stream fit, the relative skill against that fit.
Every relative skill is a ratio of aggregate means over the matched set
It is not a mean of per-forecast ratios, so one forecast whose comparator scored zero cannot make it infinite. A ratio is missing, never Inf or NaN, when the denominator is zero or the ratio is not finite.
Returns a typed zero-row frame when scores is empty, so the report renders before any release carries a forecast.
BVDOutbreakSize.log_crps_sample Method
log_crps_sample(
obs::Real,
samples::AbstractVector{<:Real}
) -> AnyCRPS on the log scale. Both obs and every element of samples are log1p-transformed before scoring, so this is crps_sample(log1p(obs), log1p.(samples)) and not the logarithm of crps_sample. Log-scale scoring downweights large counts, which suits accuracy that matters proportionally rather than in absolute terms.
BVDOutbreakSize.score_draws Method
score_draws(
obs::Real,
samples::AbstractVector{<:Real}
) -> NamedTuple{(:crps, :log_crps, :dispersion, :overprediction, :underprediction, :coverage_50, :coverage_90, :bias, :n), <:NTuple{9, Any}}Score a predictive samples ensemble against a single observation obs, returning (; crps, log_crps, dispersion, overprediction, underprediction, coverage_50, coverage_90, bias, n):
crps:crps_sample, the ensemble CRPS.log_crps:log_crps_sample, CRPS scored on the log scale.dispersion,overprediction,underprediction:crps_decomposition, summing tocrps.coverage_50,coverage_90: whetherobsfalls inside the central 50% / 90% predictive interval (see_covered).bias:bias_sample, signed forecast bias in[-1, 1].n: number of predictive draws.
With no draws (isempty(samples)), the scores are NaN, the coverage flags are false, and n = 0.
BVDOutbreakSize.scored_overlay Method
scored_overlay(
overlay::DataFrames.DataFrame;
baseline_fit
) -> Anyoverlay (a data/forecast_overlay.csv-shaped table) restricted to the streams that carry a persistence baseline, dropping every row of a stream that carries none.
This is the rule the score summaries already apply. _stream_fit_stats drops a fit whose matched set against the baseline is empty, so a stream with no baseline row never reaches forecast_score_overview or its by-horizon and by-release counterparts. Applying it to the overlay figure too puts the tables and the figure on one definition of what has been scored. A stream the situation reports have stopped publishing then leaves both together, rather than holding a row of near-empty panels open long after its last scored window.
The rule is stated on the baseline rather than on a count of made dates because the baseline is what makes a window scoreable at all. A stream with no baseline has nothing to be scored against, whatever its history.
Nothing is dropped from the scored data itself. The rows stay in data/forecast_scores.csv and data/forecast_overlay.csv, which record what was scored; this selects what is drawn.
Returns overlay unchanged when it is empty, so the report renders before any release carries a forecast.
BVDOutbreakSize.select_fit_role Method
select_fit_role(
table::DataFrames.DataFrame,
role::AbstractString
) -> Anytable (a table from forecast_score_overview, forecast_score_by_horizon or forecast_score_by_release) restricted to the rows of one role.
"joint" selects the joint model, including a frozen row, which is the same model re-fit at a past cut-off. "individual" selects each stream's own single-stream fit, whatever its id. "baseline" selects the persistence baseline, which the three summaries above exclude, so it is only ever non-empty on a table that kept it.
Use this to render one table per role. A figure comparing the roles against each other reads the unfiltered table instead. Column names, order and types are unchanged, as is the order of the rows that survive.
Errors on an unknown role rather than returning a zero-row table, since a misspelt role and a role with nothing scored are otherwise indistinguishable in the rendered report.
BVDOutbreakSize.predict_no_onward_deaths Method
predict_no_onward_deaths(chn; obs_deaths)Per-draw projection of cumulative deaths under the counterfactual that every onward transmission stops at the cut-off. Reads :CFR, :C_T and :expected_deaths_T from the posterior chn and forms the committed future deaths
with C_T the cumulative infections and E[D_T] the deaths already expected by the cut-off, returning a DataFrame with one row per draw:
:delta_deathsadditional future expected deaths beyondobs_deaths:total_projectedobs_deaths + delta_deaths
obs_deaths is the number of deaths already observed at the cut-off (e.g. obs.total_deaths from the bundled observations).
BVDOutbreakSize.confirmed_cfr_table Method
confirmed_cfr_table(res; digits) -> DataFrames.DataFrameOne-row DataFrame summarising the confirmed-CFR comparison from a delay_corrected_confirmed_cfr result res. Carries the equal-tailed 90% credible interval of the delay-corrected confirmed CFR, the structural CFR and the uncorrected modelled confirmed ratio, and the naive observed confirmed ratio. No central estimate is reported: the three modelled rows give an interval, and the naive row gives the observed value, which carries no uncertainty. Percentages rounded to digits decimal places.
BVDOutbreakSize.delay_corrected_cfr Method
delay_corrected_cfr(
c_daily::AbstractVector,
Kc::AbstractVector,
Kd::AbstractVector,
deaths_total::Real
) -> AnyDelay-corrected confirmed case-fatality ratio for one posterior draw.
c_daily is the modelled daily confirmed-case incidence over the day grid (day length(c_daily) is the cut-off), Kc the onset-to-confirmation delay PMF (lag 0 at index 1), Kd the onset-to-death-confirmation delay PMF, and deaths_total the cumulative confirmed deaths at the cut-off. The corrected denominator is Σ_t c_daily[t] · P(outcome resolved by the cut-off | confirmed at t), smaller than the raw cumulative confirmed cases, so the corrected ratio deaths_total / denominator lifts the naive ratio toward the eventual confirmed CFR. Returns NaN when no confirmed cases have had time to resolve.
BVDOutbreakSize.delay_corrected_confirmed_cfr Method
delay_corrected_confirmed_cfr(
chn;
obs_confirmed,
obs_confirmed_deaths
)Delay-corrected confirmed case-fatality ratio across the posterior, read off a joint bvd_joint chain. For each draw the modelled daily confirmed-case incidence (rescaled so its total matches the scored expected confirmed cases), the onset-to-confirmation and onset-to-death-confirmation delay PMFs, and the cumulative confirmed deaths give the corrected ratio (delay_corrected_cfr).
obs_confirmed and obs_confirmed_deaths are the observed cumulative laboratory-confirmed cases and confirmed deaths at the cut-off, for the naive observed confirmed ratio.
Returns a NamedTuple with the per-draw vectors corrected (delay-corrected confirmed CFR), modelled_naive (uncorrected modelled confirmed deaths over confirmed cases) and structural (the model's infection/onset-level CFR), and the scalar naive_observed = obs_confirmed_deaths / obs_confirmed.
BVDOutbreakSize.forecast_recovery_table Method
forecast_recovery_table(
truth::AbstractDict{<:AbstractString, <:Real},
draws::AbstractDict{<:AbstractString, <:AbstractVector{<:Real}},
baseline::AbstractDict{<:AbstractString, <:Real}
) -> Anyforecast_recovery_table(
truth::AbstractDict{<:AbstractString, <:Real},
draws::AbstractDict{<:AbstractString, <:AbstractVector{<:Real}},
baseline::AbstractDict{<:AbstractString, <:Real}
) -> AnyForecasts from a fit to simulated data, scored against the simulated future and against a persistence baseline. truth maps each forecast quantity to its simulated value, draws maps it to the fitted forecast's draws and baseline maps it to the persistence forecast, the last observed stretch of that stream carried forward. Returns one row per quantity with the forecast's CRPS (score_draws), the baseline's absolute error (a point forecast's CRPS), their ratio (relative_crps, below one when the model beats the baseline) and whether the forecast's 90% interval covers the truth.
BVDOutbreakSize.generator_joint Method
generator_joint(obs; breakpoint, simulated)generator_joint(obs; breakpoint, simulated)The headline patch bvd_joint for a load_observations() result with every stream's counts dropped and its observation grid kept, so the model draws the counts itself (the predictive-generator path). Its sampled parameters match production_joint's one for one, so a chain from either replays through the other.
The confirmed-case, confirmed-death and laboratory histories keep their counts: they define the confirmed windows, the positivity random effect and the published break discrepancies, while the missing cut-off totals gate their generator paths. The onset triangle and the province compositions keep their cell grids with missing increments.
With simulated (a recovery_data result) the same model is built with that simulated dataset in place of each missing observation: each stream's counts through simulated_data, and the onset triangle and the province compositions as their own arguments. Its structure is unchanged, so its density at the simulated data is the generator's.
BVDOutbreakSize.recovery_data Method
recovery_data(
sim
) -> NamedTuple{(:streams, :onsets, :province_cases, :province_deaths, :province_lab), <:Tuple{Dict{Symbol, Any}, Vararg{Any, 4}}}recovery_data(
sim
) -> NamedTuple{(:streams, :onsets, :province_cases, :province_deaths, :province_lab), <:Tuple{Dict{Symbol, Any}, Vararg{Any, 4}}}The observations of a simulate_recovery dataset in the form generator_joint takes them: each stream's counts by observation name (streams), the onset triangle increments (onsets) and the province case, death and laboratory compositions as province-by-vintage count matrices (province_cases, province_deaths, province_lab). Each composition's last province is the remainder of its recorded totals, as the predictive path fills it in.
BVDOutbreakSize.recovery_density_check Method
recovery_density_check(
generator::DynamicPPL.Model,
model::DynamicPPL.Model,
truth;
rtol
) -> NamedTuple{(:from_generator, :from_data), <:Tuple{Any, Any}}recovery_density_check(
generator::DynamicPPL.Model,
model::DynamicPPL.Model,
truth;
rtol
) -> NamedTuple{(:from_generator, :from_data), <:Tuple{Any, Any}}Check that model, the generator rebuilt with a simulated dataset (generator_joint with simulated), scores the true draw truth exactly as generator does. The generator's log joint at the true draw counts the simulated observations as draws; the rebuilt model counts them as data. The two agree only when every simulated observation reached the stream it came from, so a mismatch throws rather than letting a recovery fit run on data the model did not simulate. Returns the two log joints.
BVDOutbreakSize.recovery_fit Method
recovery_fit(
model::DynamicPPL.Model;
samples,
n_adapts,
chains,
max_depth,
seed,
kwargs...
) -> NamedTuple{(:model, :chain), <:Tuple{DynamicPPL.Model, Any}}recovery_fit(
model::DynamicPPL.Model;
samples,
n_adapts,
chains,
max_depth,
seed,
kwargs...
) -> NamedTuple{(:model, :chain), <:Tuple{DynamicPPL.Model, Any}}Fit the headline joint to a simulate_recovery dataset with NUTS: model is the generator rebuilt with the simulated observations (generator_joint with simulated = recovery_data(sim)), after recovery_density_check. Returns (; model, chain); forecast_draws runs the model past its cut-off. The sampler settings default to the headline joint's (two chains of 1000 draws after 500 warmup steps, tree depth at most 10): a shorter run does not converge on this model, so its recovery would test the sampler rather than the model. Other keywords pass to nuts_sample.
BVDOutbreakSize.recovery_observed_varnames Method
recovery_observed_varnames(
generator::DynamicPPL.Model,
fitted::DynamicPPL.Model
) -> Vectorrecovery_observed_varnames(
generator::DynamicPPL.Model,
fitted::DynamicPPL.Model
) -> VectorThe variables generator samples that fitted observes: the names in a draw of the generator that a draw of the fitted model does not carry. These are the simulated data a recovery fit is fitted to.
BVDOutbreakSize.recovery_overall Method
recovery_overall(statuses::AbstractVector{Symbol}) -> Symbolrecovery_overall(statuses::AbstractVector{Symbol}) -> SymbolOne verdict over several seeds' statuses (from recovery_seed_verdicts): the worst of them, in the order :fail, :unconverged, :warn, :pass.
BVDOutbreakSize.recovery_seed_verdicts Method
recovery_seed_verdicts(
params::DataFrames.DataFrame;
kwargs...
) -> Anyrecovery_seed_verdicts(
params::DataFrames.DataFrame;
kwargs...
) -> AnyThe recovery_verdict of each seed in params, the recovery_table rows of every seed stacked with the seed, fit_minutes, max_rhat, min_ess_bulk and divergences columns that scripts/recovery.jl adds. Returns one row per seed with its status, 90% coverage, the quantities outside the 99% interval and its convergence diagnostics. Other keywords pass to recovery_verdict.
BVDOutbreakSize.recovery_summary Method
recovery_summary(params::DataFrames.DataFrame; outer) -> Anyrecovery_summary(params::DataFrames.DataFrame; outer) -> AnyRecovery across seeds, one row per quantity, from the stacked recovery_table rows of every seed (with a seed column). Each row has the number of seeds, the range of the true values, the posterior median's error relative to the truth ((median - truth) / |truth|, missing for a true value of zero) and the z-score as their median and range across seeds, how many seeds have the truth inside the 90% interval (covered_90) and outside the central outer interval (outside), and the seed with the largest absolute z (worst_seed, worst_z).
BVDOutbreakSize.recovery_table Method
recovery_table(
truth::AbstractDict{<:AbstractString, <:Real},
draws::AbstractDict{<:AbstractString, <:AbstractVector{<:Real}}
) -> Anyrecovery_table(
truth::AbstractDict{<:AbstractString, <:Real},
draws::AbstractDict{<:AbstractString, <:AbstractVector{<:Real}}
) -> AnyHow well posterior draws recover known values. truth maps each quantity's name to the value that generated the data, and draws maps the same names to that quantity's posterior draws. Returns one row per quantity with its true value, the posterior median and 50% and 90% equal-tailed intervals, whether each interval covers the truth, the truth's quantile in the posterior (truth_quantile, 0.5 when it sits at the median) and its z-score against the posterior mean and standard deviation.
BVDOutbreakSize.recovery_verdict Method
recovery_verdict(
tab::DataFrames.DataFrame;
diagnostics,
outer,
outside_quantile,
coverage_fail,
coverage_warn,
max_rhat,
min_ess
) -> NamedTuple{(:status, :pass, :outside, :outside_allowed, :coverage_90, :converged), <:Tuple{Symbol, Bool, Vector{String}, Int64, Any, Bool}}recovery_verdict(
tab::DataFrames.DataFrame;
diagnostics,
outer,
outside_quantile,
coverage_fail,
coverage_warn,
max_rhat,
min_ess
) -> NamedTuple{(:status, :pass, :outside, :outside_allowed, :coverage_90, :converged), <:Tuple{Symbol, Bool, Vector{String}, Int64, Any, Bool}}The verdict on a recovery_table, and on the fit behind it when diagnostics (a fit_diagnostics result) is given. status is one of:
:unconverged: the fit's worst R-hat is abovemax_rhat(1.1, the fit gate's fail tier) or its smallest bulk ESS belowmin_ess(30, just above the gate's fail tier of 25), so its intervals say nothing about the model and the recovery is not judged.:fail: more quantities have the true value outside their posterior's centralouterinterval (99% by default) than chance allows, or fewer thancoverage_failof the quantities have the truth inside their 90% interval. Chance allows theoutside_quantilequantile (99%) of a Binomial over the quantities checked with probability1 - outer: with 32 quantities up to two may lie outside. One miss is then expected about one seed in four of a calibrated model, and a failure about one seed in 1.:warn: fewer thancoverage_warndo. A correct model misses a 90% interval about one time in ten by chance, so the bar sits below 0.9.:passotherwise.
Returns (; status, pass, outside, outside_allowed, coverage_90, converged), where pass is status being :pass or :warn, outside lists the quantities outside the outer interval and outside_allowed is how many may be before the seed fails.
BVDOutbreakSize.simulate_recovery Method
simulate_recovery(
generator::DynamicPPL.Model,
observed::AbstractVector;
seed,
horizon,
accept,
attempts
)simulate_recovery(
generator::DynamicPPL.Model,
observed::AbstractVector;
seed,
horizon,
accept,
attempts
)One simulated dataset from generator (see generator_joint): a single prior draw of the model run horizon days past its cut-off, with a fixed seed, redrawn up to attempts times until accept(truth) holds. One draw carries the parameters, every deterministic, the in-window observations and the future counts, all from one latent path.
Returns (; truth, data): the draw as a one-draw chain, which the chain readers and forecast_reported read like a fitted chain, and its in-window observations (the names in observed) keyed by VarName, which recovery_data arranges for generator_joint.