Skip to content

Declare a forecast as a vector of units - #63

Merged
Giovanni3A merged 1 commit into
mainfrom
jg/forecast-api
Sep 13, 2026
Merged

Giovanni3A merged 1 commit into
mainfrom
jg/forecast-api

Conversation

@joaquimg

Copy link
Copy Markdown
Member

PredictiveModel took a vector of networks beside a vector of maps from input
columns to forecast variables, plus the sizes and the column names, as separate
arguments that had to agree. They could disagree, and two of the ways they could
were silent: a map longer than networks dropped its trailing entries, and a
duplicate-plus-gap left rows of the prediction reading uninitialized
Zygote.Buffer memory, surfacing as a cost computed from garbage rather than as
an error.

A forecast is now a vector of ForecastModel units, each one architecture and the
wiring around it:

set_forecast_model(model, [
    ForecastModel(inputs = [:temp, :hour], architecture = Dense(2 => 1), outputs = [demand]),
    ForecastModel(inputs = [:price],       architecture = Dense(1 => 1), outputs = [spill]),
])

There is nothing left to keep aligned, so the checks that were impossible before
are now cheap: every declared variable is predicted, none twice, none foreign, the
architecture's output count matches its outputs, and the input schema is either
determined or refused. Each message names the offending unit or variable, and
test/test_forecast_validation.jl asserts the messages, since "errors gracefully"
is only testable if what the error says is part of the test.

Two units holding the same architecture object is an error rather than a silent
choice between "same shape" and "same weights" -- the two readings also disagreed
about parameter count between extract_params and Optimisers.destructure, so
one spelling of a tied model was untrainable.

The container it assembles is FullForecastModel, holding one ResolvedUnit per
unit with input columns and prediction rows resolved to integers once, at
construction. The prediction loop is now Yhat[unit.rows, :] = unit.architecture(X[unit.inputs, :]) -- previously each forward pass re-derived
the destination rows with findfirst over JuMP variables, inside the loop Zygote
traces, costing 35% of the allocations at 64 units. Resolving once also moved the
deepcopy off the Functors rebuild path, where it had been copying every
weight array on every optimiser step.

outputs says where a variable's realized values are in Y: variable => :column
for a named container, variable => position for a matrix, or nothing at all to
read the column carrying the variable's own name. The position spelling is the only
way to pin the layout of a matrix Y; without it the columns are read in the order
the Forecast variables were declared on the model, which nothing states and
nothing checks, so inserting a @variable above them silently changes what column
1 means.

Co-Authored-By: Claude Opus 5 (1M context) noreply@anthropic.com

@codecov

codecov Bot commented Aug 22, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.03%. Comparing base (9fa0d30) to head (0db5fe9).

Additional details and impacted files
@@               Coverage Diff               @@
##           jg/backends      #63      +/-   ##
===============================================
+ Coverage        98.53%   99.03%   +0.49%     
===============================================
  Files               16       16              
  Lines              887     1136     +249     
===============================================
+ Hits               874     1125     +251     
+ Misses              13       11       -2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@joaquimg
joaquimg force-pushed the jg/forecast-api branch 2 times, most recently from 3483591 to cb49cc7 Compare August 23, 2026 15:25
`PredictiveModel` took a vector of networks beside a vector of maps from input
columns to forecast variables, plus the sizes and the column names, as separate
arguments that had to agree. They could disagree, and two of the ways they could
were silent: a map longer than `networks` dropped its trailing entries, and a
duplicate-plus-gap left rows of the prediction reading uninitialized
`Zygote.Buffer` memory, surfacing as a cost computed from garbage rather than as
an error.

A forecast is now a vector of `ForecastModel` units, each one architecture and the
wiring around it:

    set_forecast_model(model, [
        ForecastModel(inputs = [:temp, :hour], architecture = Dense(2 => 1), outputs = [demand]),
        ForecastModel(inputs = [:price],       architecture = Dense(1 => 1), outputs = [spill]),
    ])

There is nothing left to keep aligned, so the checks that were impossible before
are now cheap: every declared variable is predicted, none twice, none foreign, the
architecture's output count matches its `outputs`, and the input schema is either
determined or refused. Each message names the offending unit or variable, and
`test/test_forecast_validation.jl` asserts the messages, since "errors gracefully"
is only testable if what the error says is part of the test.

Two units holding the same architecture object is an error rather than a silent
choice between "same shape" and "same weights" -- the two readings also disagreed
about parameter count between `extract_params` and `Optimisers.destructure`, so
one spelling of a tied model was untrainable.

The container it assembles is `FullForecastModel`, holding one `ResolvedUnit` per
unit with input columns and prediction rows resolved to integers once, at
construction. The prediction loop is now `Yhat[unit.rows, :] =
unit.architecture(X[unit.inputs, :])` -- previously each forward pass re-derived
the destination rows with `findfirst` over JuMP variables, inside the loop Zygote
traces, costing 35% of the allocations at 64 units. Resolving once also moved the
`deepcopy` off the `Functors` rebuild path, where it had been copying every
weight array on every optimiser step.

`outputs` says where a variable's realized values are in `Y`: `variable => :column`
for a named container, `variable => position` for a matrix, or nothing at all to
read the column carrying the variable's own name. The position spelling is the only
way to pin the layout of a matrix `Y`; without it the columns are read in the order
the `Forecast` variables were declared on the model, which nothing states and
nothing checks, so inserting a `@variable` above them silently changes what column
1 means.

The old name is removed outright rather than kept as a throwing stub. A stub
would name its two replacements, which reads better than an `UndefVarError` --
but it also keeps a removed type in the exported surface and in `@doc`, where
it would outlive everyone it was written for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Base automatically changed from jg/backends to main September 13, 2026 18:57
@Giovanni3A
Giovanni3A merged commit eb39db4 into main Sep 13, 2026
8 checks passed
@Giovanni3A
Giovanni3A deleted the jg/forecast-api branch September 13, 2026 19:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants