ML Orchestration — Design Decisions
This page records the design decisions behind the Dagster/MLflow cross-validation layer (registration → training → forecasting → metrics) and the alternatives we rejected, so the reasoning stays auditable. The step-by-step how-to is Running the cross-validation pipeline end-to-end.
Key concepts
| Concept | Definition |
|---|---|
| Cross-validation run (CV run) | One specific combination of model class + config (features, hyperparameters), trained and scored on one or more CV folds, identified by a human-readable experiment_name. Stamped onto every forecast row in a dedicated experiment_name column — not overloaded onto power_fcst_model_name, which keeps its meaning as the model family (MODEL_NAME). |
| CV fold | One expanding-window train/validation split, identified by a short fold_id label. Date ranges live in conf/cv/default.yaml and are immutable per leaderboard epoch (see Cross-validation folds). |
| Dagster partition key | "{experiment_name}__{fold_id}" on a DynamicPartitionsDefinition. Encodes both dimensions; unique by construction because MLflow enforces unique MLflow experiment names. |
| MLflow experiment | One per experiment_name; carries the resolved config as tags and holds the parent run and the fold runs of that CV run. |
| MLflow parent run | One per CV run (cv_summary, tagged cv_role=parent). Holds the flattened config params and the mean-across-folds aggregate metrics — the row the leaderboard sorts on. |
| MLflow fold run | One per fold, nested under the parent (tagged cv_role=fold, fold_id=…). Holds per-fold training tags (train_start, train_end, n_eligible_time_series, n_trained_time_series — tags, not params, because the run is reused on every re-materialisation), per-fold metrics, and the trained model artifacts. |
Cross-process run resolution: discover by tag, never pass handles
trained_cv_model, cv_power_forecasts, and metrics are separate Dagster assets running in
separate processes (and, for retries, at separate times). An MLflow run handle cannot cross a
process boundary, so the assets discover and resume runs by tag through three idempotent
get-or-create helpers in ml_core.mlflow_runs:
get_or_create_experiment(experiment_name)— wrapsmlflow.get_experiment_by_name.get_or_create_parent_run(experiment_id)—search_runsfortags.cv_role = 'parent'.get_or_create_fold_run(experiment_id, parent_run_id, fold_id)—search_runsfortags.cv_role = 'fold' and tags.fold_id = '…'; creates nested under the parent if absent.
Each asset opens the resolved run by ID, logs, and closes it within its own process. Because lookup
is by tag, re-running any fold (or re-running metrics) resumes the same run instead of creating
a duplicate — exactly the behaviour Dagster retries need.
Concurrency note: two parallel processes could in principle both find "no parent run" and each
create one. In practice the parent run is created once by register_experiment_job — before any
fold can run — so the get-or-create in the assets is only a self-healing fallback. Fold runs never
collide because each fold is a distinct Dagster partition.
The complementary decision — the resolved config is stamped onto the MLflow experiment at registration and read back by the assets, never re-read from YAML — is explained in Running the cross-validation pipeline end-to-end.
Re-registering a CV run under a changed config is rejected
A CV run's identity is its config, not its experiment_name alone, so a changed config is
treated as a new CV run: register_experiment_job rejects a re-registration that would change
it. Why a CV run carries one config, and what the operator should do instead, is in A CV
run's identity is its
config.
The rejection compares two identity tags: the canonical JSON dump of the resolved config, and the
forecaster's class target. Both are compared against what MLflow already has stored for that
experiment_name. A tag absent from the stored MLflow experiment does not count as a change,
because an absent tag is what get_or_create_experiment leaves behind when its self-healing
fallback creates an untagged MLflow experiment, and this registration is entitled to complete that
MLflow experiment.
A rejected re-registration leaves the CV run exactly as it found it. The comparison runs, and any rejection happens, before the registration writes a single MLflow param or tag.
The description tag is excluded from the comparison, so a CV run's prose stays freely
editable by re-registering. What a CV run is called and what it is for are not part of what
makes a CV run that CV run — only the config and the forecaster class are.
Model artifacts: one replaceable archive, no local cache
Trained models live in MLflow's artifact store, wrapped by two concrete BaseForecaster methods
(save_to_mlflow / load_from_mlflow) that delegate to each subclass's own disk save/load —
subclasses stay MLflow-free. load_from_mlflow downloads straight into a temporary directory and
loads from there; there is no local-disk cache.
One archive file, not a directory of files
save_to_mlflow adds time_series_metadata.parquet to the model directory — the metadata table
rows the model trained against, which production inference locates its series by — then packs the
whole directory into a single model.tar.gz and logs that one file to the run's artifact root.
load_from_mlflow and the production download path
(ml_core.production_helpers.fetch_model_artifacts) each unpack it into a temporary directory.
Subclass save/load never see the archive: they stay directory-based and MLflow-free, so the
Docker bake path (load_forecaster_from_dir) is unaffected.
The archive exists because MLflow's directory upload merges rather than replaces, and MLflow
exposes no public artifact-delete API. Verified empirically against MLflow 3.15.1: re-uploading
{a.ubj, meta.json} with log_artifacts over an existing {a.ubj, b.ubj, meta.json} leaves
b.ubj in place, while re-logging a same-name single artifact overwrites it outright. A fold run is
reused across re-materialisations of its partition (see "Cross-process run resolution" above),
so with a directory upload, re-training a fold on a smaller population would strand the dropped
series' model files in the run permanently — downloaded on every subsequent load and baked into the
container image. One replaceable file makes that accumulation impossible at the source (issue #470).
Two properties are worth keeping in mind when reading the code:
-
The run holding exactly one model file is the load-bearing invariant, not an implementation detail. Logging any second per-model file alongside the archive would reopen the merge problem for that file.
-
A model's population is still read from its own
meta.json, never from a directory listing — the archive removes the accumulation, but the population contract stands on its own (the train==predict invariant; seeBaseForecaster.trained_time_series_ids).
Two costs come with this. The first is losing per-file browsing of model artifacts in the MLflow UI,
which is negligible for a directory of opaque model blobs. The second is that both sides now hold
the model twice at once in their temporary directory — the model directory plus the archive when
saving, the archive plus its unpacked contents when loading — so a machine running these assets
needs roughly twice the model's size in free temp space. At V2 scale (~2,500 series) that is a few
GB, which matters mainly if TMPDIR is a memory-backed filesystem inside a container.
The archive is written at gzip level 1 rather than tarfile's default of 9. Measured on 40 real
boosters (77 MB of .ubj), level 1 takes 0.7 s for a 2.7x reduction and level 9 takes 14.9 s for
3.5x, so the default would spend about fifteen minutes of CPU per fold at V2 scale to save a fifth
of the bytes. Boosters are already dense and the archive is transient — it exists to be one
replaceable object, not to be small.
Why there is no local cache
load_from_mlflow downloads on every call. The obvious cache — keyed by the MLflow run ID, which
looks immutable — is unsound here, because a CV fold run is reused across re-materialisations of
its partition (see "Cross-process run resolution" above), so the same run ID can legitimately hold a
different model after re-training. The key would not be unique for its contents, and keeping such a
cache honest would need write-side invalidation in save_to_mlflow: machinery serving no consumer,
because production inference makes no MLflow call on the runtime path at all — the champion model is
baked directly into the container image at build time and loaded via the subclass's own load.
If a future consumer does need to keep serving through an MLflow outage, adding a cache scoped to that consumer's own invalidation needs is tracked in issue #472.
Idempotent writes and concurrency
power_forecasts is a Delta table partitioned by (experiment_name, fold_id). Re-materialising a
fold overwrites its partition rather than appending — an append would silently duplicate every
row on retry and double-count in metrics. Two consequences:
- Parallel CV runs are safe: they write to disjoint
experiment_namepartition directories and never touch each other. - The only residual risk is the same fold of the same CV run launched twice simultaneously, which delta-rs surfaces as a transaction-log conflict; Dagster's per-partition run concurrency normally prevents this, and a retry resolves it.
Partitioning by experiment_name (not power_fcst_model_name) is deliberate: many CV runs
share one model family, and the partition must isolate CV runs, not families.
Complete validation windows only
A fold enters conf/cv/default.yaml only once its validation window is complete. A fold with a
still-open window would silently validate on partial data and corrupt the mean-across-folds
leaderboard number. Mid-window performance on an incomplete window is obtained through the metrics
asset's ad_hoc evaluation scope, which never feeds the leaderboard.
Fold-design alternatives considered
We considered three other ways to slice the limited honest data and chose the single fold. These are recorded so the decision is auditable and so we can revisit them as more data lands.
Monthly expanding CV — rejected (redundant folds)
Slide a 12-month validation window forward by 1 month per fold, expanding training by 1 month each time (fold 1 validates 2025-04→2026-03, fold 2 validates 2025-05→2026-04, …). This "buys" several folds from today's data, but consecutive folds share 11/12 of the validation window and
90% of the training data, so their metrics are correlated ~0.9+. The effective number of independent folds is barely more than one; the small spread across them understates true sampling variance (false confidence in stability), at N× the compute for almost-duplicate information. One month is too small a change to make the folds meaningfully different.
Quarterly non-overlapping walk-forward — deferred (the sound multi-fold option)
Expanding training, but validate on the next 3 months, non-overlapping:
| Fold | Train | Validate (3 mo, non-overlapping) |
|---|---|---|
| 2025-Q2 | 2024-04-01 → 2025-03-31 | 2025-04 → 2025-06 |
| 2025-Q3 | 2024-04-01 → 2025-06-30 | 2025-07 → 2025-09 |
| 2025-Q4 | 2024-04-01 → 2025-09-30 | 2025-10 → 2025-12 |
| 2026-Q1 | 2024-04-01 → 2025-12-31 | 2026-01 → 2026-03 |
Because the validation windows do not overlap, the folds are genuinely independent measurements, and the set covers all four seasons (so you see seasonal skill variation, then report per-season and the mean). This is the statistically sound version of what monthly CV reaches for. We deferred it to keep the initial CV setup minimal; it is the recommended next step if we want multiple folds before the ECMWF back-fill enables the full yearly protocol.
Yearly folds backed by ERA5 — rejected for validation
Keep the yearly 2022–2025 folds now by training on ERA5 reanalysis for the pre-2024-04-01 years. Rejected: ERA5 is reanalysis (it ingests future observations), so validating on it measures the model's response to near-perfect weather, not forecast skill — systematically misleading — and a leaderboard mixing reanalysis folds and ECMWF-forecast folds is apples-to-oranges. Reanalysis is valuable, but for pre-training (see Cross-validation folds: Target), not for validation.
Rejecting ERA5-backed folds excludes reanalysis only as a promotion criterion, not as a
measurement. Scoring against an estimate of past weather is legitimate as a diagnostic — it
decomposes total error into the weather-to-power response and the implicit hedging against forecast
error — and lands as its own evaluation_scope, leaving the leaderboard folds ENS-only. The planned
estimates of past weather are CAMS for irradiance and ERA5 for other variables. See Extending the
training history.
Two metric stores, one division of labour
Metrics live in both MLflow and the forecast_metrics Delta table, deliberately, because they
answer different questions:
| MLflow | forecast_metrics Delta |
|
|---|---|---|
| Granularity | Coarse: per-time_series_type + "all" aggregates |
Full cube: per time_series_id × slice × fold × scope |
| Volume | Tens of scalars per run | Thousands of rows per CV run |
| Consumers | Leaderboard UI, sorting/grouping | Dashboards, drill-down analysis |
| Shape | Key→value metric store | Queryable table (Polars/SQL) |
MLflow is the leaderboard; the Delta table is the analysis cube. The aggregates are intentionally
duplicated; the fine-grained per-series metrics live only in Delta. forecast_metrics is
internal-only — it is not one of the tables delivered to NGED.
Rejected designs
Recorded so we don't re-litigate them:
- Single CV asset with an internal fold loop — no per-fold observability or retry; mixes orchestration with ML logic; the whole asset re-runs on any failure.
- Dagster
DynamicOutjob instead of assets — job run history would be the only record; no stable, addressable per-fold artifact in the asset catalog; poor lineage. - Static fold partitions shared across CV runs — two CV runs would appear to materialise the same partition, corrupting history; static keys can't support dynamically created CV runs (hyperparameter sweeps).
- One YAML config file per CV run in git — thousands of files; programmatic sweeps would have to commit files. MLflow is the canonical resolved-config store instead.
- MLflow pyfunc / model registry for persistence — significant complexity (environment
snapshotting, custom loaders, registry states) for no benefit over the simple
save/load+ artifact wrapper. A registry-based champion-promotion flow remains an option later without changing the artifact format. - Integer MLflow experiment ID as partition-key prefix — unreadable in the Dagster UI; every partition would need cross-referencing against MLflow.
Known limitation: forecast cadence under-sampling in CV
We receive one NWP run per day (00 UTC) but new NGED power data every 6 hours, and the
production service will issue a forecast every 6 hours — so three of the four daily production
forecasts reuse the day's NWP with fresher power lags. Bulk-mode CV derives one
power_fcst_init_time per NWP run, so it backtests only the once-daily forecast and does not score
the 06/12/18 intraday runs — a power_fcst_init_time − nwp_init_time regime the model rarely sees
in training. For V1 the once-daily forecast is treated as representative; a future refinement is for
bulk mode to emit forecasts on the full 6-hourly grid per NWP run so CV mirrors the production
cadence exactly.