fix(forecast): project each single-stream fit from its own trajectory - #706
Conversation
…fits `forecast_stream` reads each stream's cut-off daily rate off the chain's cumulative trajectory, falling back to inverting the cumulative total under exponential growth when the chain carries none. Only `bvd_joint` exposes those trajectories, so every individual fit takes the fallback. Red: the four single-stream composers carry no `cumulative_*` alias. Co-authored-by: Sam Abbott <contact@samabbott.co.uk> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XABDXNzg5H61QAxBAT2Tfx
`forecast_stream` reads a stream's cut-off daily rate as the last increment of its cumulative trajectory, and falls back to inverting the cumulative total under exponential growth when the chain carries none. composers never got them, so every individual fit took the fallback. That inversion collapses towards zero as the fitted growth rate reaches zero, which is what the outbreak's streams are now doing, so the individual fits project below the joint and below the data they are scored against in `stream_forecasts.csv`, the validation panels and the by-release overlay. Each composer now exposes its own stream's trajectory under the same un-prefixed name the joint uses, so one `_STREAM_SPEC` entry serves both fit kinds. The confirmed-case baseline re-add moves into `_cumulative_confirmed`, shared by the joint and the confirmed-only composer so the two carry the same quantity under the same name. Co-authored-by: Sam Abbott <contact@samabbott.co.uk> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XABDXNzg5H61QAxBAT2Tfx
There was a problem hiding this comment.
This PR gives each of the four single-stream composers (deaths_only_model, cases_only_model, confirmed_only_model, confirmed_deaths_only_model) the same un-prefixed cumulative-trajectory := alias bvd_joint already exposes, so forecast_stream reads the cut-off daily rate directly instead of falling back to an exponential-growth inversion that collapses toward zero. The confirmed-cases logic is correctly deduplicated into a shared _cumulative_confirmed function used by both bvd_joint and confirmed_only_model, field names match the underlying submodels exactly, and the added test (paired with the prior red commit) demonstrates the behaviour change. Verdict: sound change; one documentation-placement nit.
Automated first pass by seabbs-review-bot (Claude sonnet), triggered by: first pass. Not a human review. Comment @seabbs-review-bot to ask for another pass: @seabbs any time, the author's agent once it has pushed changes. Add the no-review label to opt this PR out. Ping @seabbs with any questions.
| return cumsum(confirmed_daily) .+ [t >= cap ? base : 0 for t in 1:n] | ||
| end | ||
|
|
||
| ## Why every composer exposes its stream's cumulative trajectory as a `:=` |
There was a problem hiding this comment.
suggestion This 9-line rationale comment floats between _cumulative_confirmed and the unrelated exports_only_model docstring, so a reader meets it right before a composer that never gets a trajectory alias (:exports has an empty trajectory list in _STREAM_SPEC). It also restates, at greater length, what src/forecast.jl's _STREAM_SPEC comment already says under the trajectory: bullet, and what each of the four new := lines already says tersely at its own call site. Either delete this block (the forecast.jl doc and the per-line comments already cover it) or fold it into the _STREAM_SPEC docstring where the reader is actually resolving spec.trajectory.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #706 +/- ##
=======================================
Coverage 96.73% 96.74%
=======================================
Files 16 16
Lines 4536 4540 +4
=======================================
+ Hits 4388 4392 +4
Misses 148 148 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
#706 gave the same reason six times: a block comment before the composers, a two-line comment on each of the four composers, and again in bvd_joint. Keep the block, drop the four copies, trim the rest. Co-authored-by: Sam Abbott <contact@samabbott.co.uk> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XABDXNzg5H61QAxBAT2Tfx
#706 gave the same reason six times: a block comment before the composers, a two-line comment on each of the four composers, and again in bvd_joint. Keep the block, drop the four copies, trim the rest. Co-authored-by: Sam Abbott <contact@samabbott.co.uk> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XABDXNzg5H61QAxBAT2Tfx
The V2.0.0 tag points at 7bebf1c, which is main's head and this branch's parent, so every pull request the branch had filed under v2.1.0 already shipped inside that release. Listing them as changes since v2.0.0 told a reader work was new when the V2.0.0 download already carried it. The entries for #706, #707, #709, #710, #712, #714 and #716 move into the v2.0.0 subsection that fits each by content, and the compat-bound entry for #696 to #701 moves with them into a new Dependencies subsection. None of those was already described there. #716 contradicted the Model subsection, which still gave the sampler configuration as 900 draws and 400 adaptation steps. That line now states the 750 draws and 500 adaptation steps the release actually shipped, and the Performance entry gives the reasoning without restating the numbers as a change. The v2.1.0 heading stays, with one line and no entries, as the landing place for the work still in flight. The version bump in Project.toml and CITATION.cff is unchanged.
The V2.0.0 tag points at 7bebf1c, which is main's head and this branch's parent, so every pull request the branch had filed under v2.1.0 already shipped inside that release. Listing them as changes since v2.0.0 told a reader work was new when the V2.0.0 download already carried it. The entries for #706, #707, #709, #710, #712, #714 and #716 move into the v2.0.0 subsection that fits each by content, and the compat-bound entry for #696 to #701 moves with them into a new Dependencies subsection. None of those was already described there. #716 contradicted the Model subsection, which still gave the sampler configuration as 900 draws and 400 adaptation steps. That line now states the 750 draws and 500 adaptation steps the release actually shipped, and the Performance entry gives the reasoning without restating the numbers as a change. The v2.1.0 heading stays, with one line and no entries, as the landing place for the work still in flight. The version bump in Project.toml and CITATION.cff is unchanged.
What
Every single-stream (individual) fit was forecast by inverting its cumulative
total under exponential growth, while the joint was forecast from its own
fitted trajectory.
forecast_streamtakes a stream's cut-off daily rate as the last increment ofits cumulative trajectory, and falls back to
_approx_daily— inverting thecumulative total under exponential growth over the outbreak age — when the
chain carries none. #613 added the four
cumulative_*:=aliases tobvd_jointfor exactly this reason, noting that the inversion "collapsestowards zero once the fitted growth rate is at or below zero, which is not what
a stream still reporting daily is doing". The single-stream composers never got
the same aliases, so every individual fit kept taking the fallback.
That is the individual-versus-joint gap visible in
data/forecast_overlay.csv,where the individual fits sit below both the joint and the observed counts at
release after release (confirmed deaths at 2026-08-24: individual median 136,
joint 315, observed 292).
What changed
cases_only_model,deaths_only_model,confirmed_only_modelandconfirmed_deaths_only_modeleach expose their own stream's cumulativetrajectory under the same un-prefixed name
bvd_jointuses, so one_STREAM_SPECentry keeps serving both fit kinds._cumulative_confirmed, shared by the joint and the confirmed-only composer,so
cumulative_confirmedis the same quantity on both._STREAM_SPEC'strajectorycomment no longer says only the joint carriesone.
No change to the joint's numbers: its
cumulative_confirmedis the samearithmetic, now through the shared helper.
Scope note
This touches
src/models/joint.jl, which is hashed into every fit cache key,so it forces a cold refit of the whole matrix.
Testing
:slowtest item samples each of the four composers from the prior andasserts the trajectory key is present, is length
n, never turns back onitself, and resolves through the same
_daily_at_cutoff_anythe forecasteruses. Red before the fix (
KeyError: key :cumulative_reports not found).test_forecast.jl,test_boxed_captures.jl,test_cases_model.jl,test_confirmed_cases_model.jl,test_confirmed_deaths.jl,test_confirmed_cases_daily.jl,test_reported_cases_daily.jl,test_suspected_daily.jl,test_suspected_daily_deaths.jl: 344 passing.Rebased onto the feat(patch): meta-population model over the three affected provinces #412 patch-model merge and re-run there: the whole of
test_forecast.jl, 213 passing, including feat(patch): meta-population model over the three affected provinces #412's own province-forecastitems.
This was opened by a bot. Please ping @seabbs for any questions.
🤖 Generated with Claude Code
https://claude.ai/code/session_01XABDXNzg5H61QAxBAT2Tfx