Question: for the plain quantile tracker on the rate scale (admissions per 100k) with a constant learning rate, how do WIS and coverage change with the learning rate and with the extra delay before an outcome is learned?
Setup.
| disease, base | flu, the submitted hub ensemble (CMU-TimeSeries) |
| seasons | 2023-24 burn-in (its outcomes take no steps; offsets start at zero); 2024-25 and 2025-26 scored |
| locations, horizons | learns on all 53 hub locations; scores are states only (US excluded), h−1 to h3 |
| scoring truth | finalized NHSN |
| tracker | REF-paper (one offset per level, carried across seasons) on the rate scale |
| learning rate | constant, 0.001 to about 3.2 per 100k per step (15 values, a quarter-decade apart) |
| extra delay | 0, 1, 2 or 3 weeks (settle_days 7/14/21/28) |
| learning truth | two designs, below |
The tracker. For each location, horizon and quantile
level, the offset moves by lr × (1{y > q} − (1 − τ))
when an outcome is learned. With a constant lr the step
does not depend on the forecast errors. The reference configs use the
adaptive rate instead: 0.03 × the 90th percentile of the location’s
absolute base errors over its last 20 observed rounds.
Extra delay. The first NHSN report of the week
ending Saturday X is published Wednesday
X + 4, in time for the round with reference date
X + 7. Learning from it at that round is zero extra delay;
each further week of waiting is one week of extra delay. The reference
configs use one week.
Learning truth designs.
Reference configs (marked on the plots):
“WIS reduction %” is
100 × (WIS_base − WIS_cal) / WIS_base, so positive =
calibrated WIS lower than base. “L1 coverage bias” (lower is better) is
the mean over the 23 quantile levels of |empirical coverage − level|.
“Knee” below is the constant rate 0.056.
suppressPackageStartupMessages(source(here::here("R/load_all.R")))
LRS <- 10^seq(-3, 0.5, by = 0.25)
SETTLE <- c(7L, 14L, 21L, 28L)
# smoke = TRUE checks the notebook end to end in a few minutes: a small grid,
# and the re-run design computed with the fast stateful mode.
if (params$smoke) {
LRS <- LRS[c(1, 9, 13)]
SETTLE <- c(7L, 14L)
}
LEARN_LABELS <- c(exact = "re-run (prod)", vintage = "stateful")
archive <- get_nhsn_data_archive("flu")
truth <- nhsn_read_truth("flu", archive)
vintages <- nhsn_read_vintages("flu", archive)
hub_fc <- hub_read_forecasts(hub_dir = here::here(params$hub_dir))
rate_scales <- get_population_data() %>%
distinct(state_code, .keep_all = TRUE) %>%
filter(state_code %in% unique(hub_fc$location)) %>%
transmute(location = state_code, from = as.Date("2000-01-01"), scale = population / 1e5)
stopifnot(setequal(rate_scales$location, unique(hub_fc$location)))
# Each run is cached to its own file, so a change to the grid only computes the
# new runs. Clear cache/calibration/experiments/e09/ after changing R/calibration/.
run_dir <- here::here("cache/calibration/experiments", if (params$smoke) "e09_smoke" else "e09")
dir.create(run_dir, showWarnings = FALSE, recursive = TRUE)
cached_run <- function(key, ..., learn) {
path <- file.path(run_dir, paste0(key, ".rds"))
if (file.exists(path)) return(readRDS(path))
if (params$smoke) learn <- "vintage"
cal <- cal_run(hub_fc, truth, ..., learn = learn, vintages = vintages, workers = params$workers)
# Only what the metrics need, to keep the cache small.
cal <- list(forecasts = cal$forecasts %>%
select(location, horizon, reference_date, season, level, value_base, value_cal, truth, is_burn_in))
saveRDS(cal, path)
cal
}
rate <- list(transform = "identity", scales = rate_scales)
sweep_grid <- tidyr::expand_grid(learn = c("exact", "vintage"), settle_days = SETTLE, lr = LRS)
sweep <- purrr::pmap(sweep_grid, function(learn, settle_days, lr) {
key <- sprintf("sweep_%s_s%d_lr%.4f", learn, settle_days, lr)
do.call(cached_run, c(list(key, "paper", settle_days = settle_days, lr = lr, learn = learn), rate))
})
# Reference points per (learn, settle_days).
ref_grid <- tidyr::expand_grid(learn = c("exact", "vintage"), settle_days = SETTLE)
refs <- purrr::pmap(ref_grid, function(learn, settle_days) {
s <- sprintf("%s_s%d", learn, settle_days)
list(
`REF-paper (count, adaptive)` = cached_run(paste0("paper_", s), "paper", settle_days = settle_days, learn = learn),
`REF-op` = cached_run(paste0("op_", s), "op", settle_days = settle_days, learn = learn)
)
})
pinball <- function(y, q, tau) ifelse(y >= q, tau * (y - q), (1 - tau) * (q - y))
# Per (location, horizon) and pooled over states: WIS reduction % and L1
# coverage bias, for the calibrated forecast and the base.
metrics <- function(cal) {
fc <- cal$forecasts %>% filter(!is_burn_in, !is.na(truth), !is.na(value_base), location != "US")
one <- function(df, by) {
df %>%
group_by(across(all_of(c(by, "horizon", "level")))) %>%
summarize(
cov_base = mean(truth <= value_base), cov_cal = mean(truth <= value_cal),
wis_base = sum(pinball(truth, value_base, level)), wis_cal = sum(pinball(truth, value_cal, level)),
.groups = "drop"
) %>%
group_by(across(all_of(c(by, "horizon")))) %>%
summarize(
bias_base = mean(abs(cov_base - level)), bias_cal = mean(abs(cov_cal - level)),
wis_pct = 100 * (1 - sum(wis_cal) / sum(wis_base)),
.groups = "drop"
)
}
bind_rows(one(fc, "location"), one(fc, character(0)) %>% mutate(location = "pooled"))
}
sweep_m <- purrr::map2(sweep, seq_along(sweep), function(cal, i) metrics(cal) %>% bind_cols(sweep_grid[i, ])) %>%
bind_rows()
ref_m <- purrr::map2(refs, seq_along(refs), function(r, i) {
purrr::imap(r, function(cal, nm) metrics(cal) %>% mutate(ref = nm)) %>% bind_rows() %>% bind_cols(ref_grid[i, ])
}) %>%
bind_rows() %>%
filter(location == "pooled")
label_panels <- function(df) {
df %>% mutate(
extra = factor(sprintf("+%d wk extra delay", (settle_days - 7L) %/% 7L)),
horizon = factor(paste0("h", horizon), levels = paste0("h", -1:3))
)
}
sweep_m <- label_panels(sweep_m)
ref_m <- label_panels(ref_m)
Each thin grey line is one location traced across learning rates; the bold line is the pooled result, labelled with the learning rate at each decade. Learning rates whose pooled WIS is more than 20% worse than the base in a panel are dropped from that panel; the axes are clipped to the bulk of the per-location points, so a few location lines run off the edge. The open circle is the base forecast (no calibration) and the triangles are the reference configs at the same delay.
Per-location coverage comes from about 50 forecasts per horizon, so a location’s L1 bias has a noise floor of a few points even for a perfectly calibrated forecaster. The grey lines show the spread across locations.
ACCENT <- "#1f5fa8"
curve_plot <- function(which_learn) {
d <- sweep_m %>% filter(learn == which_learn)
keep <- d %>%
filter(location == "pooled", wis_pct >= -params$wis_max) %>%
select(horizon, extra, lr)
d <- d %>% semi_join(keep, by = c("horizon", "extra", "lr")) %>% arrange(lr)
pooled <- d %>% filter(location == "pooled")
locs <- d %>% filter(location != "pooled")
base <- pooled %>% distinct(horizon, extra, bias_base)
marks <- ref_m %>% filter(learn == which_learn, ref %in% c("REF-paper (count, adaptive)", "REF-op"))
lab <- pooled %>% filter(abs(log10(lr) - round(log10(lr))) < 1e-9)
xlim <- c(0, max(stats::quantile(locs$bias_cal, 0.97), base$bias_base) * 1.05)
ylim <- range(stats::quantile(locs$wis_pct, c(0.03, 0.97)), pooled$wis_pct, marks$wis_pct, 0)
ggplot() +
geom_hline(yintercept = 0, colour = "grey60", linewidth = 0.3) +
geom_path(data = locs, aes(bias_cal, wis_pct, group = location), colour = "grey45", alpha = 0.25, linewidth = 0.3) +
geom_path(data = pooled, aes(bias_cal, wis_pct), colour = ACCENT, linewidth = 1.1) +
geom_point(data = pooled, aes(bias_cal, wis_pct), colour = ACCENT, size = 1.2) +
geom_text(data = lab, aes(bias_cal, wis_pct, label = format(lr, scientific = FALSE, drop0trailing = TRUE)),
colour = "grey15", size = 2.6, vjust = -0.9) +
geom_point(data = base, aes(bias_base, 0), shape = 21, fill = "white", colour = "black", size = 2.2) +
geom_point(data = marks, aes(bias_cal, wis_pct, shape = ref), colour = "black", size = 2) +
scale_shape_manual(values = c(`REF-paper (count, adaptive)` = 2, `REF-op` = 17), name = NULL) +
facet_grid(horizon ~ extra) +
coord_cartesian(xlim = xlim, ylim = ylim) +
labs(x = "L1 coverage bias (lower is better)", y = "WIS reduction % (positive = calibrated WIS lower than base)") +
theme(legend.position = "bottom")
}
curve_plot("exact")
curve_plot("vintage")
The bold lines from above, one colour per extra delay (darker is longer), so the delays can be compared directly. Solid is the re-run design, dashed the stateful one.
DELAY_COLS <- setNames(RColorBrewer::brewer.pal(9, "Blues")[c(4, 6, 8, 9)], levels(sweep_m$extra))
pooled_all <- sweep_m %>%
filter(location == "pooled") %>%
semi_join(sweep_m %>% filter(location == "pooled", wis_pct >= -params$wis_max) %>% select(horizon, extra, lr, learn),
by = c("horizon", "extra", "lr", "learn")) %>%
arrange(lr) %>%
mutate(design = factor(LEARN_LABELS[learn], levels = LEARN_LABELS))
ggplot(pooled_all, aes(bias_cal, wis_pct, colour = extra, linetype = design, group = interaction(extra, design))) +
geom_hline(yintercept = 0, colour = "grey60", linewidth = 0.3) +
geom_path(linewidth = 0.8) +
geom_point(data = pooled_all %>% distinct(horizon, bias_base), aes(bias_base, 0), inherit.aes = FALSE,
shape = 21, fill = "white", colour = "black", size = 2) +
scale_colour_manual(values = DELAY_COLS, name = NULL) +
scale_linetype_manual(values = c(`re-run (prod)` = "solid", stateful = "22"), name = NULL) +
facet_wrap(~horizon, nrow = 1) +
labs(x = "L1 coverage bias (lower is better)", y = "WIS reduction % (positive = calibrated WIS lower than base)") +
theme(legend.position = "bottom", legend.box = "vertical")
sweep_run <- function(learn, settle_days, lr) {
sweep[[which(sweep_grid$learn == learn & sweep_grid$settle_days == settle_days & abs(log10(sweep_grid$lr / lr)) < 1e-6)]]
}
ref_run <- function(learn, settle_days, nm) refs[[which(ref_grid$learn == learn & ref_grid$settle_days == settle_days)]][[nm]]
mv_cals <- list(
`REF-paper, re-run +1 wk` = ref_run("exact", 14L, "REF-paper (count, adaptive)"),
`REF-op, re-run +1 wk` = ref_run("exact", 14L, "REF-op"),
`0.032, re-run +1 wk` = sweep_run("exact", 14L, 10^-1.5),
`knee 0.056, re-run +0 wk` = sweep_run("exact", 7L, 10^-1.25),
`knee 0.056, re-run +1 wk` = sweep_run("exact", 14L, 10^-1.25),
`knee 0.056, re-run +2 wk` = sweep_run("exact", 21L, 10^-1.25),
`0.1, re-run +1 wk` = sweep_run("exact", 14L, 10^-1),
`0.18, re-run +1 wk` = sweep_run("exact", 14L, 10^-0.75),
`knee 0.056, stateful +0 wk` = sweep_run("vintage", 7L, 10^-1.25),
`knee 0.056, stateful +1 wk` = sweep_run("vintage", 14L, 10^-1.25)
)
mv <- cal_month_view(mv_cals, seasons = c("2024-2025", "2025-2026"), horizons = NULL)
# The plots show REF-op and the constant rates around the knee at one week of delay; the tables also cover the other delays and designs.
mv_plot_cfgs <- c("REF-op, re-run +1 wk", "0.032, re-run +1 wk", "knee 0.056, re-run +1 wk", "0.1, re-run +1 wk", "0.18, re-run +1 wk")
Scores by calendar month of the reference date, one figure per season, states only (US excluded). Rows are the three metrics, columns the horizons:
100 × (WIS_base − WIS_cal) / WIS_base within the month, so
positive = calibrated WIS lower than base. Grey bars are the month’s
share of the season’s base WIS, so a large swing in a month with a small
bar costs little overall.100 × (bias_base − bias_cal) / bias_base, so positive =
calibrated coverage bias lower than base. The bias is computed within
the month: the mean over the 23 levels of |share of outcomes at or below
the quantile − level|. A season-pooled bias lets under-prediction on the
ramp cancel over-prediction after the peak; the monthly one does not.
When the base’s bias is already small, a small absolute change shows as
a large percentage; the month-average column of the coverage table below
gives the absolute values.A month holds one to five rounds of about 52 states, and outcomes in the same round move together, so a single month’s coverage bias is noisy and sits above zero even for a calibrated forecaster. That’s why the plot shows it relative to the base.
mv_plot_month <- mv$month
if (!is.null(get0("mv_plot_cfgs"))) {
mv_plot_month <- mv_plot_month %>% filter(config %in% mv_plot_cfgs) %>% mutate(config = droplevels(config))
}
for (s in sort(unique(mv_plot_month$season))) {
cat("\n\n", cal_h(2), " ", s, "\n\n", sep = "")
print(cal_month_plot(mv_plot_month, s, colours = get0("mv_cols"), linetype = get0("mv_linetype"), colour_by = get0("mv_colour_by") %||% "config"))
}
cat("\n\n", cal_h(2), " By horizon\n\n", sep = "")
cat("WIS reduction % (positive = calibrated WIS lower than base), states only; `both` pools the seasons.\n\n")
WIS reduction % (positive = calibrated WIS lower than base), states
only; both pools the seasons.
print(knitr::kable(cal_month_wis_table(mv)))
| config | 2024-2025 h-1 | 2024-2025 h0 | 2024-2025 h1 | 2024-2025 h2 | 2024-2025 h3 | 2025-2026 h-1 | 2025-2026 h0 | 2025-2026 h1 | 2025-2026 h2 | 2025-2026 h3 | both h-1 | both h0 | both h1 | both h2 | both h3 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| REF-paper, re-run +1 wk | +4.6 | +1.6 | +0.5 | -1.5 | -4.5 | -2.0 | -1.7 | -4.9 | -5.6 | -6.5 | +1.9 | +0.6 | -1.5 | -3.1 | -5.3 |
| REF-op, re-run +1 wk | +7.6 | +3.3 | +2.5 | +2.5 | +3.9 | +0.8 | +4.2 | +0.1 | -0.5 | -0.8 | +4.8 | +3.6 | +1.6 | +1.3 | +2.2 |
| 0.032, re-run +1 wk | +3.5 | +1.2 | +0.9 | +0.7 | +0.3 | +0.1 | +2.0 | +0.4 | +0.1 | +0.0 | +2.1 | +1.5 | +0.7 | +0.5 | +0.2 |
| knee 0.056, re-run +0 wk | +5.5 | +1.8 | +1.4 | +0.9 | +0.4 | -0.4 | +1.7 | -0.1 | -0.3 | -0.4 | +3.1 | +1.7 | +0.9 | +0.4 | +0.1 |
| knee 0.056, re-run +1 wk | +4.8 | +1.6 | +1.2 | +0.8 | +0.2 | -0.9 | +1.3 | -0.2 | -0.4 | -0.4 | +2.4 | +1.5 | +0.7 | +0.3 | -0.0 |
| knee 0.056, re-run +2 wk | +3.8 | +1.5 | +1.1 | +0.7 | +0.1 | -1.2 | +0.8 | -0.3 | -0.5 | -0.6 | +1.7 | +1.3 | +0.6 | +0.2 | -0.2 |
| 0.1, re-run +1 wk | +6.3 | +1.9 | +1.2 | +0.4 | -0.6 | -2.7 | -0.2 | -1.4 | -1.3 | -1.4 | +2.6 | +1.2 | +0.3 | -0.2 | -0.9 |
| 0.18, re-run +1 wk | +7.8 | +1.9 | +0.7 | -0.8 | -2.6 | -5.0 | -3.3 | -3.9 | -3.8 | -3.4 | +2.5 | +0.3 | -1.0 | -2.0 | -2.9 |
| knee 0.056, stateful +0 wk | -0.7 | +0.9 | +0.9 | +0.6 | +0.3 | -1.9 | +1.0 | +0.4 | +0.2 | +0.1 | -1.2 | +0.9 | +0.7 | +0.5 | +0.2 |
| knee 0.056, stateful +1 wk | +4.6 | +1.6 | +1.2 | +0.7 | +0.2 | +0.1 | +1.8 | +0.3 | -0.1 | -0.2 | +2.7 | +1.7 | +0.9 | +0.4 | +0.0 |
print(knitr::kable(cal_month_cov_table(mv), caption = CAL_MONTH_COV_CAPTION))
| config | 2024-2025 h-1 | 2024-2025 h0 | 2024-2025 h1 | 2024-2025 h2 | 2024-2025 h3 | 2025-2026 h-1 | 2025-2026 h0 | 2025-2026 h1 | 2025-2026 h2 | 2025-2026 h3 | both h-1 | both h0 | both h1 | both h2 | both h3 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| base | 0.183 / 0.191 | 0.131 / 0.146 | 0.135 / 0.152 | 0.146 / 0.169 | 0.159 / 0.190 | 0.037 / 0.084 | 0.078 / 0.089 | 0.055 / 0.098 | 0.050 / 0.108 | 0.046 / 0.117 | 0.110 / 0.134 | 0.104 / 0.116 | 0.094 / 0.123 | 0.097 / 0.137 | 0.101 / 0.151 |
| REF-paper, re-run +1 wk | 0.100 / 0.160 | 0.057 / 0.122 | 0.050 / 0.135 | 0.042 / 0.172 | 0.046 / 0.223 | 0.013 / 0.076 | 0.008 / 0.085 | 0.018 / 0.106 | 0.028 / 0.118 | 0.040 / 0.120 | 0.044 / 0.115 | 0.030 / 0.102 | 0.032 / 0.120 | 0.033 / 0.143 | 0.031 / 0.168 |
| REF-op, re-run +1 wk | 0.135 / 0.155 | 0.090 / 0.119 | 0.089 / 0.126 | 0.092 / 0.143 | 0.080 / 0.164 | 0.014 / 0.066 | 0.018 / 0.067 | 0.007 / 0.098 | 0.017 / 0.110 | 0.026 / 0.112 | 0.074 / 0.107 | 0.053 / 0.091 | 0.044 / 0.111 | 0.045 / 0.125 | 0.041 / 0.136 |
| 0.032, re-run +1 wk | 0.132 / 0.167 | 0.103 / 0.126 | 0.108 / 0.132 | 0.113 / 0.146 | 0.116 / 0.167 | 0.030 / 0.083 | 0.025 / 0.105 | 0.027 / 0.122 | 0.028 / 0.135 | 0.028 / 0.141 | 0.051 / 0.122 | 0.046 / 0.114 | 0.042 / 0.126 | 0.044 / 0.140 | 0.045 / 0.153 |
| knee 0.056, re-run +0 wk | 0.114 / 0.159 | 0.087 / 0.122 | 0.088 / 0.124 | 0.091 / 0.139 | 0.089 / 0.178 | 0.031 / 0.084 | 0.028 / 0.107 | 0.032 / 0.128 | 0.035 / 0.140 | 0.029 / 0.143 | 0.042 / 0.119 | 0.033 / 0.114 | 0.029 / 0.126 | 0.029 / 0.140 | 0.031 / 0.159 |
| knee 0.056, re-run +1 wk | 0.110 / 0.163 | 0.085 / 0.123 | 0.087 / 0.124 | 0.089 / 0.140 | 0.086 / 0.181 | 0.031 / 0.085 | 0.031 / 0.112 | 0.037 / 0.133 | 0.037 / 0.144 | 0.034 / 0.150 | 0.040 / 0.122 | 0.031 / 0.117 | 0.026 / 0.129 | 0.026 / 0.142 | 0.027 / 0.164 |
| knee 0.056, re-run +2 wk | 0.108 / 0.166 | 0.087 / 0.123 | 0.088 / 0.124 | 0.088 / 0.141 | 0.085 / 0.184 | 0.032 / 0.086 | 0.033 / 0.115 | 0.039 / 0.138 | 0.042 / 0.149 | 0.036 / 0.153 | 0.039 / 0.123 | 0.031 / 0.119 | 0.025 / 0.131 | 0.024 / 0.145 | 0.025 / 0.167 |
| 0.1, re-run +1 wk | 0.088 / 0.161 | 0.062 / 0.123 | 0.062 / 0.126 | 0.057 / 0.158 | 0.051 / 0.205 | 0.026 / 0.080 | 0.021 / 0.105 | 0.026 / 0.127 | 0.025 / 0.137 | 0.022 / 0.144 | 0.031 / 0.118 | 0.022 / 0.114 | 0.019 / 0.127 | 0.018 / 0.147 | 0.019 / 0.172 |
| 0.18, re-run +1 wk | 0.075 / 0.154 | 0.039 / 0.127 | 0.035 / 0.141 | 0.027 / 0.178 | 0.040 / 0.225 | 0.016 / 0.073 | 0.007 / 0.086 | 0.012 / 0.119 | 0.014 / 0.126 | 0.018 / 0.131 | 0.030 / 0.111 | 0.018 / 0.105 | 0.016 / 0.129 | 0.015 / 0.150 | 0.016 / 0.175 |
| knee 0.056, stateful +0 wk | 0.208 / 0.211 | 0.119 / 0.136 | 0.116 / 0.138 | 0.115 / 0.147 | 0.111 / 0.169 | 0.074 / 0.098 | 0.066 / 0.093 | 0.031 / 0.103 | 0.018 / 0.106 | 0.015 / 0.121 | 0.141 / 0.151 | 0.092 / 0.113 | 0.072 / 0.119 | 0.062 / 0.125 | 0.055 / 0.144 |
| knee 0.056, stateful +1 wk | 0.122 / 0.164 | 0.091 / 0.125 | 0.091 / 0.125 | 0.091 / 0.140 | 0.088 / 0.180 | 0.016 / 0.078 | 0.022 / 0.097 | 0.020 / 0.118 | 0.025 / 0.135 | 0.023 / 0.140 | 0.065 / 0.118 | 0.049 / 0.110 | 0.039 / 0.121 | 0.034 / 0.137 | 0.033 / 0.159 |