Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
107 commits
Select commit Hold shift + click to select a range
958461d
docs(calibration): findings notebook for bias, staleness, decay and s…
dshemetov Sep 24, 2026
ef4df52
docs(calibration): note effective covid ensemble_mix weights
dshemetov Sep 25, 2026
3ecb52f
docs(calibration): experiment ledger and vintage-truth caveat
dshemetov Sep 30, 2026
efc1851
feat(calibration): vintage-aware learning truth and exact per-round b…
dshemetov Sep 30, 2026
9d84a0c
feat(calibration): named reference configs and standard views for exp…
dshemetov Sep 30, 2026
870ad84
build(calibration): render experiment notebooks and ledger index
dshemetov Sep 30, 2026
314fa40
docs(calibration): E00 vintage vs finalized learning truth
dshemetov Sep 30, 2026
ac1d6c7
docs(calibration): E09 notebook, constant lr x revision delay on the …
dshemetov Sep 30, 2026
c7de2a2
feat(calibration): cached runs, absolute-error, state-panel and galle…
dshemetov Sep 30, 2026
9991ba0
docs(calibration): E03-E05 notebooks on exact learning truth with sta…
dshemetov Sep 30, 2026
bb449ed
docs(calibration): E09 findings in the notebook
dshemetov Sep 30, 2026
dbe7016
docs(calibration): E03-E05 findings; E09 and E03-E05 summaries in not…
dshemetov Sep 30, 2026
d8f8c8f
docs(calibration): E10 notebook, covid at the reference configs
dshemetov Sep 30, 2026
86b24d7
fix(calibration): month view shows every month with forecasts
dshemetov Oct 1, 2026
367a734
docs(calibration): E10 covid findings
dshemetov Oct 1, 2026
3e1e1eb
docs(calibration): notebooks and index readable without the repo; Pac…
dshemetov Oct 1, 2026
3819919
fix(calibration): state panels start at h−1 so each snapshot leads in…
dshemetov Oct 1, 2026
413bba5
fix(calibration): drop spoiled flu submissions (zero h−1 on 2025-11-2…
dshemetov Oct 1, 2026
57ba70e
feat(one_offs): sweep hub submissions for forecasts far from the data
dshemetov Oct 1, 2026
db68877
docs(calibration): causes of three spoiled-submission candidates; cov…
dshemetov Oct 1, 2026
b4d6354
fix(calibration): exclude covid 2024-11-23 round and 2026-04-25 h−1
dshemetov Oct 1, 2026
b8e1d01
doc: record TODOs
dshemetov Oct 1, 2026
7f07d31
docs(calibration): findings refreshed after excluding spoiled submiss…
dshemetov Oct 1, 2026
22a96eb
docs(calibration): h−1 split out of calibration; revision_aware vs ba…
dshemetov Oct 1, 2026
e9f6fe6
docs(calibration): reorganize open threads by priority
dshemetov Oct 1, 2026
6f3bd28
docs: move unexplained spoiled-submission causes to the spoiled-submi…
dshemetov Oct 1, 2026
f239269
docs: run just recipes inside the rocker distrobox
dshemetov Oct 1, 2026
bbdf647
docs(calibration): E11 notebook comparing a constant rate-scale learn…
dshemetov Oct 1, 2026
d82cbe2
docs(calibration): close the E00–E10 render and constant-rate threads
dshemetov Oct 1, 2026
e539855
docs: add notes/ROADMAP.md and fold CALIBRATION2.md into CALIBRATION.md
dshemetov Oct 1, 2026
1f8b7d3
docs: plan the evaluation-project work incrementally; add an incremen…
dshemetov Oct 1, 2026
e475427
docs(roadmap): record ILI+ extras leak and NSSP vintage depth finding…
dshemetov Oct 1, 2026
da649c9
feat(one_offs): build a weekly versioned 2023-24 archive from hhs dai…
dshemetov Oct 1, 2026
328181f
docs(roadmap): record evaluation scope-control results (1a steps a1–a2)
dshemetov Oct 1, 2026
4beb214
docs(roadmap): record the clean windowed_seasonal replay (1a step a3)
dshemetov Oct 1, 2026
1bc0a8a
feat(calibration): harness configs for windowed_seasonal (flu, covid)…
dshemetov Oct 1, 2026
f4c315c
feat(calibration): calibrate the clean windowed_seasonal replay (flu,…
dshemetov Oct 1, 2026
a312c93
docs(calibration): record E12, cold calibration of the clean windowed…
dshemetov Oct 1, 2026
347872b
docs(calibration): calibrate windowed_seasonal at h0–h3 only; note th…
dshemetov Oct 1, 2026
0c1e53a
docs(roadmap): record the US-handling audit and a single-policy design
dshemetov Oct 1, 2026
e65c268
feat(hhs-archive): optional start date and version floor for the HHS …
dshemetov Oct 1, 2026
ecf3fb0
feat(calibration): stitch the HHS archive before NHSN for a 2023-24 b…
dshemetov Oct 1, 2026
9816030
feat(calibration): burn-in configs and states/US score splits in the …
dshemetov Oct 1, 2026
af0b95d
docs(calibration): record E13 (2023-24 HHS burn-in) and park the eval…
dshemetov Oct 1, 2026
a80dc6f
refactor(calibration): factor ws_inputs() out of the windowed_seasona…
dshemetov Oct 1, 2026
56baed1
feat(calibration): E14 notebook, constant learning rate sweep split b…
dshemetov Oct 1, 2026
18f932f
feat(calibration): E15 notebook, where the WIS gains come from (per l…
dshemetov Oct 1, 2026
d78ae89
docs(calibration): record E14 (constant rate by season) and E15 (WIS …
dshemetov Oct 1, 2026
c939e31
feat(calibration): late_decay option, shrink the fast offset each rou…
dshemetov Oct 1, 2026
1310245
feat(calibration): E16 notebook, constant learning rate on the sqrt s…
dshemetov Oct 1, 2026
32f1c11
feat(calibration): E17 notebook, late-season offset decay vs off_afte…
dshemetov Oct 1, 2026
e8b0e5f
docs(calibration): record E16/E17; ledger rows E11–E17; mark E06/E08 …
dshemetov Oct 1, 2026
ab3f57e
feat(calibration): E07 notebook, ensemble vs windowed_seasonal on the…
dshemetov Oct 1, 2026
3b46e30
feat(calibration): E02 notebook, off_after, per-level eta and seasona…
dshemetov Oct 1, 2026
851c8cc
feat(calibration): E01 notebook, adaptive eta multiplier x window x s…
dshemetov Oct 1, 2026
335b7d0
docs(calibration): index rows for E01, E02, E07
dshemetov Oct 1, 2026
4ac93b7
docs(calibration): record the rebuilt E01, E02, E07 and mark the old …
dshemetov Oct 1, 2026
5930043
docs(calibration): summary of E01, E02, E07 and E11–E17; drop a colla…
dshemetov Oct 1, 2026
9e6fdde
docs(calibration): review tracker; E09 WIS axis positive-is-better; E…
dshemetov Oct 1, 2026
86cbe3f
docs(calibration): E07 adds base WIS, month bias and operating-point …
dshemetov Oct 1, 2026
f196fb6
docs(calibration): record the expanded E07 in the review tracker, sum…
dshemetov Oct 1, 2026
8d6dd2a
docs(calibration): review tracker, E01 verified, E02 comments
dshemetov Oct 2, 2026
a305919
docs(calibration): label WIS % as "WIS reduction % (positive = calibr…
dshemetov Oct 2, 2026
ed58f02
docs(calibration): E02 splits season tables into WIS reduction and co…
dshemetov Oct 2, 2026
1a76a7d
docs(calibration): review tracker, E02 fixes done
dshemetov Oct 2, 2026
dae0bfc
feat(calibration): shared month view helper (WIS, within-month covera…
dshemetov Oct 2, 2026
a4e6363
docs(calibration): month view section (WIS, coverage, median error) i…
dshemetov Oct 2, 2026
982acfd
docs(calibration): month-view check of each notebook's coverage findings
dshemetov Oct 2, 2026
bcf432b
docs(calibration): month-view coverage verdicts in the review tracker…
dshemetov Oct 2, 2026
becc39c
docs(calibration): E18 bridge from REF-op to sqrt constant 0.018, one…
dshemetov Oct 2, 2026
d156e67
docs(calibration): record E18 in the ledger, review tracker and notes
dshemetov Oct 2, 2026
ae032dc
feat(calibration): month view shows coverage as bias reduction % vs b…
dshemetov Oct 2, 2026
b773e4e
docs(calibration): E18 adds rate-scale constant references (E14 0.032…
dshemetov Oct 2, 2026
e801a98
docs(calibration): E18 labels step 6 as sqrt constant 0.018
dshemetov Oct 2, 2026
1a04693
docs(calibration): review tracker catches up on E02, E09, E18 and que…
dshemetov Oct 2, 2026
5f29bda
docs(calibration): fold calibration notes into one ledger rebuilt fro…
dshemetov Oct 2, 2026
2930b0a
docs(calibration): neutral notebook prose, archived index section, dr…
dshemetov Oct 2, 2026
fa57205
refactor(calibration): consolidate experiment notebooks into six sect…
dshemetov Oct 2, 2026
dc7dbef
docs(roadmap): calibration finalists, burn-in no longer critical path…
dshemetov Oct 2, 2026
fe0e6dc
docs(ilinet): compare v5 fluview_ilinet against epidatr fluview; v5 m…
dshemetov Oct 2, 2026
0fd7e40
docs(calibration): findings for E18 warm branch and E10 finalists; le…
dshemetov Oct 2, 2026
6a7195f
docs(calibration): roadmap and index state the finalists result
dshemetov Oct 3, 2026
1a11e92
refactor(calibration): restore per-experiment notebooks with the inde…
dshemetov Oct 3, 2026
f74340c
fix(calibration): e10 allows repeated month-view chunk labels
dshemetov Oct 3, 2026
ea72227
docs(calibration): ledger marks the ILI+ backfill script unused
dshemetov Oct 3, 2026
3dae478
feat(calibration): E19 script comparing tracker eta and offsets; ledg…
dshemetov Oct 6, 2026
041ae50
refactor(calibration): notebooks lead with the month view and its by-…
dshemetov Oct 7, 2026
9a15285
refactor(calibration): trim each notebook to the month view, elbow pl…
dshemetov Oct 7, 2026
eb86502
feat(calibration): prod_views notebook with state fans and gallery fo…
dshemetov Oct 7, 2026
10889b2
refactor(calibration): drop E09's bridge from REF-paper
dshemetov Oct 7, 2026
7c02a3e
refactor(calibration): fold E14 into E16, which now covers the rate s…
dshemetov Oct 7, 2026
0fb15ed
refactor(calibration): move the E07b ranking into E10 for both diseas…
dshemetov Oct 7, 2026
1ff8af4
refactor(calibration): drop E18's rate-scale references outside the c…
dshemetov Oct 7, 2026
cdeb63b
refactor(calibration): E15 breaks down the finalists' WIS changes on …
dshemetov Oct 7, 2026
bac60cc
refactor(calibration): prod_views shows states only
dshemetov Oct 7, 2026
77fcae5
docs(calibration): index regroups the finalists after folding E07b an…
dshemetov Oct 7, 2026
b0799e3
feat(calibration): E18 adds matched-pair state scatters (warm vs warm…
dshemetov Oct 7, 2026
970c097
feat(calibration): shared grid configs and E20, the tracker structure…
dshemetov Oct 7, 2026
b8b4fb8
refactor(calibration): E19 becomes a notebook: tracker internals on t…
dshemetov Oct 7, 2026
79cd044
feat(calibration): E21, the two live seasons' data: curves, revisions…
dshemetov Oct 7, 2026
092ee9b
docs(calibration): index and ledger add E19–E21, the Data section and…
dshemetov Oct 7, 2026
0458390
docs(calibration): read the E18–E20 renders into the ledger
dshemetov Oct 7, 2026
abad0f6
feat(calibration): E20 runs the structure grid on covid too; sqrt 0.0…
dshemetov Oct 7, 2026
1c69bfc
docs(calibration): retire E04, E05 and E11 (superseded by E20), park …
dshemetov Oct 7, 2026
a0887fb
feat(calibration): round bootstrap intervals for WIS reduction in E20…
dshemetov Oct 7, 2026
94752e9
docs(calibration): ROADMAP item 1 points at the ledger; the pipeline …
dshemetov Oct 7, 2026
e7c9623
feat(calibration): prod flu switches to sqrt 0.018 cold; settle_days …
dshemetov Oct 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,9 @@ directly on the shared stack whenever it is picked up.

History lives in `notes/`: the annotated commit log
(`notes/2026-07-22-ds-refactor2-annotated-commit-log.md`) is the source of
truth for what was done and how it was verified; `notes/refactor-ideas.md`
holds all open threads and future refactor designs; the remaining dated
truth for what was done and how it was verified; `notes/ROADMAP.md` lists
open tasks and tech debt, `notes/refactor-ideas.md` holds the refactor
designs behind them; the remaining dated
files are experiment records. CLAUDE.md describes only the current state.

## Refactoring practice
Expand All @@ -131,6 +132,19 @@ whole grid a dependency of every branch); `tar_target`'s default
`tidy_eval = FALSE` to defer); `rlang::syms` on the trainer column is
load-bearing.

## Working incrementally

Most work here is a chain of small decisions about data, edge cases and
forecasters, and full runs (explore sweeps, evaluation replays, exact
calibration sweeps) take hours, so a wrong early choice is expensive to find.
Build up from the cheapest check that can answer the current question: a
standalone script on one geo and a few dates, then one forecaster on a few
dates (`EVALUATION_N_DATES`, a filtered grid), then one forecaster on all
dates, and only last the full run. Look at each stage's output (plots, a
diff against a known value) before scaling up, and write each data or
edge-case decision into the relevant note when it is made, so it can be
revisited.

## Conventions

- Testing focuses on utility functions; forecaster quality is assessed by inspecting results/reports, not unit tests.
Expand Down
6 changes: 6 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# R isn't installed on the host; run recipes inside the distrobox: `distrobox enter rocker -- just <recipe>`.
rscript := env_var_or_default("RSCRIPT", "Rscript")

# Render calibration experiment notebooks (all, or the named ones, e.g. e18_ref_op_bridge) and the index.
calibration-experiments *notebooks:
{{rscript}} scripts/calibration/render_experiments.R {{notebooks}}
224 changes: 167 additions & 57 deletions R/calibration/calibrate.R
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,18 @@ hub_series_matrix <- function(series, round_date, n_levels) {
#' Online-calibrate a hub model's submitted forecasts, per (location, horizon).
#'
#' @param forecasts [hub_read_forecasts()] output.
#' @param truth tibble with columns `target_end_date`, `location` (FIPS), `truth` (raw count).
#' @param truth tibble with columns `target_end_date`, `location` (FIPS), `truth`
#' (raw count). Always used for scoring (the `truth` column of the output);
#' also used for learning when `learn_truth` is `NULL`.
#' @param learn_truth what the tracker learns from. `NULL` learns from `truth`
#' (finalized values, not a realistic backtest). A [nhsn_read_vintages()]
#' table (has a `version` column) learns each outcome from its value as of the
#' round it is revealed at, where round `t` sees the data published by
#' `round_date[t] - 3` (the Wednesday submission); as-of dates before the
#' first version use the first version. The warm start then uses the vintage
#' as of the first live round. A table without `version` is used as-is for
#' learning, which is how [calibrate_hub_forecasts_exact()] passes one
#' snapshot per round. See E00 in `notes/calibration-ledger.md`.
#' @param settle_days how long after `target_end_date` the outcome is trusted.
#' 14 gives exactly `horizon + 2` rounds of delay on hub coordinates.
#' @param burn_in_seasons season labels (as produced by [hub_label_seasons()])
Expand All @@ -85,6 +96,12 @@ hub_series_matrix <- function(series, round_date, n_levels) {
#' still step when revealed inside the off window. The hidden offsets carry
#' unchanged into the next season. `"04-01"` switches calibration off for the
#' spring tail, where the peak-tuned offsets are out of regime.
#' @param late_decay `NULL`, or `list(from = "MM-DD", factor = )`. At each round
#' whose reference date falls on or after `from` (and before July), the stored
#' fast offset is multiplied by `factor` in `[0, 1]`, so the shrink persists:
#' later rounds, and the next season under `carry`, start from the shrunk
#' value. Learning continues as usual. The slow term is not decayed. Mutually
#' exclusive with `off_after`.
#' @param lr_seasonal `NULL`, or `list(half_width_weeks = 5)`. Pools into the
#' eta residual window the rounds of every earlier season that fall within
#' `half_width_weeks` of the same point in the season (same calendar date
Expand Down Expand Up @@ -138,6 +155,7 @@ hub_series_matrix <- function(series, round_date, n_levels) {
calibrate_hub_forecasts <- function(
forecasts,
truth,
learn_truth = NULL,
settle_days = 14L,
burn_in_seasons = character(0),
season_policy = c("carry", "reset", "shrink"),
Expand All @@ -148,6 +166,7 @@ calibrate_hub_forecasts <- function(
projection = c("isotonic", "sort", "none"),
nonneg = TRUE,
off_after = NULL,
late_decay = NULL,
lr_seasonal = NULL,
transform = c("identity", "log1p", "sqrt", "quartic_root"),
scales = NULL,
Expand Down Expand Up @@ -178,6 +197,15 @@ calibrate_hub_forecasts <- function(
if (!is.null(off_after) && fast_decay > 0) {
cli::cli_abort("{.arg off_after} and {.arg fast_decay} cannot be combined; choose one way of retiring stale offsets.")
}
if (!is.null(late_decay)) {
if (!is.null(off_after)) {
cli::cli_abort("{.arg off_after} and {.arg late_decay} cannot be combined.")
}
f <- late_decay$factor
if (!is.character(late_decay$from) || !(is.numeric(f) && length(f) == 1L && f >= 0 && f <= 1)) {
cli::cli_abort("{.arg late_decay} must be {.code list(from = \"MM-DD\", factor = <0..1>)}.")
}
}

rounds <- hub_label_seasons(unique(forecasts$reference_date))
round_date <- rounds$round_date
Expand All @@ -197,6 +225,12 @@ calibrate_hub_forecasts <- function(
shrink = shrink_factor
)

if (!is.null(late_decay)) {
mmdd <- format(round_date, "%m-%d")
late <- mmdd >= late_decay$from & mmdd < "07-01"
hidden_scale[late] <- hidden_scale[late] * late_decay$factor
}

# The slow term may train through burn-in; the fast term never does.
slow_update_from <- if (burn_in_learns_slow) rep(TRUE, n) else update_from
apply <- rep(TRUE, n)
Expand Down Expand Up @@ -224,9 +258,17 @@ calibrate_hub_forecasts <- function(
season gap policy {.val {season_policy}}."
)

truth_lookup <- stats::setNames(
truth$truth, paste(truth$location, truth$target_end_date)
)
truth_lookup <- hub_truth_lookup(truth)
vintage_mode <- !is.null(learn_truth) && "version" %in% names(learn_truth)
learn_lookup <- if (is.null(learn_truth)) {
truth_lookup
} else if (!vintage_mode) {
hub_truth_lookup(learn_truth)
}
if (vintage_mode) {
vintages_by_loc <- split(learn_truth, learn_truth$location)
first_version <- min(learn_truth$version)
}

grid <- forecasts %>%
distinct(.data$location, .data$horizon) %>%
Expand All @@ -244,8 +286,14 @@ calibrate_hub_forecasts <- function(
if (!any(rounds$season %in% burn_in_seasons)) {
cli::cli_abort("{.arg slow_init} needs at least one burn-in season.")
}
warm_lookup <- if (vintage_mode) {
first_live <- round_date[which(update_from)[1L]]
hub_truth_lookup(hub_vintage_snapshot(learn_truth, hub_round_asof(first_live)))
} else {
learn_lookup
}
init_slow_by_h <- hub_burn_in_offsets(
chunks, keys, round_date, m, levels, truth_lookup,
chunks, keys, round_date, m, levels, warm_lookup,
burn = rounds$season %in% burn_in_seasons, scales = scales, fwd = fwd
)
}
Expand All @@ -266,13 +314,19 @@ calibrate_hub_forecasts <- function(
# truth yet (the live tail of a season), or no base forecast at that round
# (nothing was played, so there is no coverage). A round pruned here is not
# "revealed late", it is never learned from at all.
learnable <- !is.na(Y) & !apply(is.na(Yhat), 2, any)
delay <- qt_delay_from_dates(round_date, target_end_date, settle_days)
Y_learn <- if (vintage_mode) {
hub_vintage_at_reveal(vintages_by_loc[[loc]], target_end_date, round_date, delay, first_version)
} else {
unname(learn_lookup[paste(loc, target_end_date)])
}
learnable <- !is.na(Y_learn) & !apply(is.na(Yhat), 2, any)
delay <- lapply(delay, function(idx) idx[learnable[idx]])
revealed <- seq_len(n) %in% unlist(delay)

s_t <- hub_round_scales(scales, loc, round_date)
res <- qt_track(
Y = fwd(Y / s_t), Yhat = fwd(sweep(Yhat, 2L, s_t, "/")), levels = levels, delay = delay,
Y = fwd(Y_learn / s_t), Yhat = fwd(sweep(Yhat, 2L, s_t, "/")), levels = levels, delay = delay,
lr = lr, lr_window = lr_window, lr_args = lr_args, projection = projection,
nonneg = FALSE,
update_from = update_from, hidden_scale = hidden_scale,
Expand All @@ -298,7 +352,8 @@ calibrate_hub_forecasts <- function(
slow = as.vector(res$slow),
fast = as.vector(res$fast),
lr_level = as.vector(res$lr_levels),
truth = rep(Y, each = m)
truth = rep(Y, each = m),
truth_learned = rep(ifelse(revealed, Y_learn, NA_real_), each = m)
)
diagnostics[[i]] <- tibble(
location = loc,
Expand Down Expand Up @@ -326,7 +381,7 @@ calibrate_hub_forecasts <- function(
"location", "horizon", "reference_date", "target_end_date", "season",
"season_round", "round_index", "level_index", "level",
"value_base", "value_cal", "offset", "hidden", "slow", "fast", "lr_level",
"truth", "is_burn_in"
"truth", "truth_learned", "is_burn_in"
)

list(
Expand Down Expand Up @@ -420,61 +475,116 @@ hub_quantile_loss <- function(cal, drop_burn_in = TRUE, by = character(0)) {
}


#' Rolling calibration error against rolling quantile loss, the trade-off curve.
#' Calibrate as prod does: re-run the tracker at every round on that round's
#' vintage and keep only the forecast it plays at that round.
#'
#' Both quantities are rolling averages over the last `window` rounds of one
#' series (or of a geo-pooled set of series):
#' calibration error = mean over levels of |rolling coverage at a - a|
#' quantile loss = mean over levels and rounds of the pinball loss
#' Plotting one against the other shows whether calibration is being bought at
#' the cost of sharpness, which is the thing a tracker can quietly get wrong.
#' Prod re-runs [calibrate_hub_forecasts()] from scratch each week on the
#' latest data, so at round `t` every revealed outcome carries its value as of
#' `t`. Passing a vintage table as `learn_truth` to [calibrate_hub_forecasts()]
#' instead fixes each outcome at its value as of the round it was revealed at;
#' this function is the reference that approximation is checked against. It
#' costs one full run per round.
#'
#' @param cal [calibrate_hub_forecasts()] output.
#' @param window number of rounds in the rolling window.
#' @param by grouping columns; `character(0)` pools all locations, which is the
#' default because per-series rolling coverage over 20 rounds is mostly noise.
#' @param forecasts,truth as in [calibrate_hub_forecasts()]; `truth` is used for
#' scoring only.
#' @param vintages [nhsn_read_vintages()] output.
#' @param ... passed to [calibrate_hub_forecasts()].
#' @param workers number of forked processes (rounds are independent runs).
#' @return list with `forecasts` (one round from each run) and `rounds` (from
#' the last run), which the metric functions accept.
#' @export
hub_rolling_tradeoff <- function(cal, window = 20L, by = character(0), drop_burn_in = TRUE) {
fc <- if (is.list(cal) && !is.data.frame(cal)) cal$forecasts else cal
# Rounds a location was not forecast at are all-NA in value_base/value_cal.
fc <- fc %>% filter(!is.na(.data$truth), !is.na(.data$value_base))
if (drop_burn_in) fc <- fc %>% filter(!.data$is_burn_in)
pinball <- function(y, q, tau) ifelse(y >= q, tau * (y - q), (1 - tau) * (q - y))

# Per (round, level) within each group: coverage indicator and pinball loss,
# pooled over whatever `by` does not name (locations, by default).
per_round <- fc %>%
group_by(across(all_of(c(by, "horizon", "round_index", "reference_date", "level")))) %>%
summarize(
cov_base = mean(.data$truth <= .data$value_base),
cov_cal = mean(.data$truth <= .data$value_cal),
loss_base = mean(pinball(.data$truth, .data$value_base, .data$level)),
loss_cal = mean(pinball(.data$truth, .data$value_cal, .data$level)),
.groups = "drop"
calibrate_hub_forecasts_exact <- function(forecasts, truth, vintages, ..., workers = 1L) {
rounds <- hub_label_seasons(unique(forecasts$reference_date))
# A run needs at least one live round, and burn-in rounds are not scored, so
# burn-in rounds are taken from the first live round's run.
burn <- rounds$season %in% (list(...)$burn_in_seasons %||% character(0))
live <- which(!burn)
run_at <- function(t) {
snapshot <- hub_vintage_snapshot(vintages, hub_round_asof(rounds$round_date[t]))
# Later rounds cannot affect what is played at t, so they are dropped to
# save time; the round axis up to t is unchanged.
cal <- suppressMessages(calibrate_hub_forecasts(
forecasts %>% filter(.data$reference_date <= rounds$round_date[t]),
truth,
learn_truth = snapshot, progress = FALSE, ...
))
keep <- if (t == live[1L]) c(which(burn), t) else t
list(
forecasts = cal$forecasts %>% filter(.data$round_index %in% keep),
rounds = cal$rounds
)
}
# Later rounds are the slowest runs, so they are started first.
order <- rev(seq_along(live))
runs <- parallel::mclapply(live[order], run_at, mc.cores = workers, mc.preschedule = FALSE)
failed <- purrr::map_lgl(runs, inherits, "try-error")
if (any(failed)) {
cli::cli_abort("Exact calibration failed at round{?s} {.val {live[order][failed]}}: {runs[[which(failed)[1]]]}")
}
runs <- runs[order(order)]
list(
forecasts = bind_rows(purrr::map(runs, "forecasts")),
rounds = runs[[length(runs)]]$rounds
)
}

roll <- function(x) slider::slide_dbl(x, mean, .before = window - 1L, .complete = TRUE)
per_round %>%
group_by(across(all_of(c(by, "horizon", "level")))) %>%
arrange(.data$round_index, .by_group = TRUE) %>%
mutate(
roll_cov_base = roll(.data$cov_base),
roll_cov_cal = roll(.data$cov_cal),
roll_loss_base = roll(.data$loss_base),
roll_loss_cal = roll(.data$loss_cal)
) %>%

#' The as-of date of a hub round: the Wednesday submission before the Saturday
#' reference date.
#' @keywords internal
hub_round_asof <- function(round_date) round_date - 3L


#' Named lookup of `truth` by `"location target_end_date"`.
#' @keywords internal
hub_truth_lookup <- function(truth) {
stats::setNames(truth$truth, paste(truth$location, truth$target_end_date))
}


#' Values as of one date from a [nhsn_read_vintages()] table; dates before the
#' first version use the first version.
#' @keywords internal
hub_vintage_snapshot <- function(vintages, as_of) {
as_of <- max(as_of, min(vintages$version))
vintages %>%
filter(.data$version <= as_of) %>%
group_by(.data$location, .data$target_end_date) %>%
slice_max(.data$version, n = 1L, with_ties = FALSE) %>%
ungroup() %>%
filter(!is.na(.data$roll_cov_base)) %>%
# Now collapse the level axis: calibration error is the mean over levels of
# |rolling coverage - nominal|.
group_by(across(all_of(c(by, "horizon", "round_index", "reference_date")))) %>%
summarize(
cal_error_base = mean(abs(.data$roll_cov_base - .data$level)),
cal_error_cal = mean(abs(.data$roll_cov_cal - .data$level)),
qloss_base = mean(.data$roll_loss_base),
qloss_cal = mean(.data$roll_loss_cal),
.groups = "drop"
filter(!is.na(.data$truth)) %>%
select("location", "target_end_date", "truth")
}


#' Each round's outcome as of the round that reveals it, for one location.
#'
#' @param vintages one location's rows of a [nhsn_read_vintages()] table.
#' @param delay from [qt_delay_from_dates()], before pruning.
#' @return numeric of length `length(round_date)`; `NA` for rounds that are
#' never revealed or whose value was not yet published at the reveal round.
#' @keywords internal
hub_vintage_at_reveal <- function(vintages, target_date, round_date, delay, first_version) {
n <- length(round_date)
out <- rep(NA_real_, n)
if (is.null(vintages)) {
return(out)
}
reveal_at <- rep(NA_integer_, n)
for (t in seq_len(n)) reveal_at[delay[[t]]] <- t
i <- which(!is.na(reveal_at))
queries <- tibble(
i = i,
target_end_date = target_date[i],
as_of = pmax(hub_round_asof(round_date[reveal_at[i]]), first_version)
)
hits <- queries %>%
left_join(
vintages %>% select("target_end_date", "version", "truth"),
by = dplyr::join_by("target_end_date", closest("as_of" >= "version"))
)
out[hits$i] <- hits$truth
out
}


Expand Down
Loading
Loading