Skip to content

fix: use validation data and support N-forecaster ensembles in WeightsCombiner - #1062

Draft
majidkhoshrou wants to merge 1 commit into
OpenSTEF:mainfrom
majidkhoshrou:feature/robust-ensemble
Draft

fix: use validation data and support N-forecaster ensembles in WeightsCombiner#1062
majidkhoshrou wants to merge 1 commit into
OpenSTEF:mainfrom
majidkhoshrou:feature/robust-ensemble

Conversation

@majidkhoshrou

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes three bugs in WeightsCombiner (learned-weights ensemble combiner) and one in
StackingCombiner that limit the ensemble machinery to small (2-model), always-trainable
ensembles, even though EnsembleForecastingModel.forecasters is already a generic
dict[str, Forecaster] intended to support any number of base forecasters:

  1. WeightsCombiner.fit() ignored data_val. The method accepted a data_val
    parameter but never referenced it, so the per-quantile "who wins" classifier was always
    trained on in-sample training predictions. In-sample predictions from boosted/tree
    forecasters are systematically over-optimistic, biasing the combiner toward whichever
    forecaster overfits the training window hardest. fit() now trains on data_val when
    supplied, falling back to data otherwise.

  2. The degenerate-label guard didn't generalize past 2 forecasters.
    _validate_labels() only fell back to a DummyClassifier when exactly one forecaster
    ever won (the only degenerate case possible with 2 base models). With 3+ forecasters
    it's common for a subset to never win, which crashed predict_proba with a
    ValueError (column count mismatch) instead of falling back gracefully. The guard now
    triggers whenever fewer forecasters win than are registered, so ensembles of arbitrary
    size no longer crash.

  3. The DummyClassifier fallback silently discarded its own answer.
    _predict_weights() hardcoded weight 1.0 on self._label_encoder.classes_[0] — the
    alphabetically-first registered forecaster name — instead of asking the fitted
    DummyClassifier(strategy="most_frequent") which forecaster actually wins most often.
    This is easy to miss with 2 models (right about half the time by chance) but becomes a
    frequent, silent mis-attribution with 3+ models. The fallback now uses the recorded
    majority winner.

  4. StackingCombiner.fit() also discarded data_val. It accepted data_val but
    always passed data_val=None to the underlying meta-forecaster's fit(). It now passes
    validation data through so meta-forecasters that support early stopping (e.g. LGBM,
    XGBoost) can use it.

Together these make the ensemble machinery robust for ensembles of arbitrary size (not
just the built-in 2-model lgbm/gblinear preset default) and make the learned-weights
combiner actually use held-out validation data as its name/docstring already implied.

No public API changes — all fixes are internal to fit()/_validate_labels()/
_predict_weights() behavior.

Closes #

Type of change

  • Bug fix
  • New feature
  • Breaking change (see checklist below)
  • Documentation
  • Refactor / chore / CI

Breaking changes checklist

N/A — internal fitting behavior only; no public API, config schema, or serialized object
changes.

AI disclosure

  • No AI assistance was used (beyond grammar/spelling)
  • AI assistance was used — tool(s): GitHub Copilot (Claude Sonnet 4.5)
    • I have reviewed, understand, and can explain all AI-generated code in this PR
    • This is disclosed in a commit message (Assisted-by: GitHub Copilot (Claude Sonnet 4.5))

Checklist

  • poe all --check passes locally
  • Tests added/updated for the change
  • Documentation updated (docstrings, user guide, examples) if needed
  • Commits are signed off per our DCO (git commit -s)
  • PR title follows Conventional Commits

…sCombiner

WeightsCombiner previously ignored the data_val argument passed to fit(),
always training its per-quantile 'who wins' classifier on in-sample
training predictions. This structurally biased weights towards whichever
base forecaster overfits the training window hardest. fit() now trains on
data_val when supplied, falling back to data otherwise.

The degenerate-label guard in _validate_labels() only handled the case of
exactly one forecaster ever winning, which is the only degenerate case
possible with 2 base forecasters. With 3+ forecasters it's common for a
proper subset to never win, which crashed predict_proba with a column
count mismatch. The guard now triggers whenever fewer forecasters win than
are registered, enabling ensembles of arbitrary size.

The DummyClassifier fallback used for degenerate quantiles hardcoded
weight 1.0 on the alphabetically-first registered forecaster name instead
of the classifier's actual (most-frequent) prediction, silently
mis-attributing weight whenever that forecaster wasn't the real winner.
The fallback now uses the recorded majority winner.

StackingCombiner.fit() also accepted data_val but always discarded it when
fitting its per-quantile meta-forecaster; it now passes validation data
through so meta-forecasters that support early stopping can use it.

Assisted-by: GitHub Copilot (Claude Sonnet 4.5)
Signed-off-by: majidkhoshrou <majid.khoshrou@gmail.com>
@github-actions github-actions Bot added the feature New feature or request label Aug 15, 2026
@majidkhoshrou
majidkhoshrou requested review from egordm and lschilders and a lite review from Copilot August 15, 2026 14:00

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes internal fitting/prediction issues in the ensemble forecast combiners so they correctly leverage held-out validation predictions and behave robustly with ensembles containing more than two base forecasters.

Changes:

  • Update WeightsCombiner.fit() to train the “best-forecaster” classifier on data_val when provided, rather than always using in-sample training predictions.
  • Generalize WeightsCombiner’s degenerate-label handling for N-forecaster ensembles and ensure the dummy fallback uses the true majority winner.
  • Fix StackingCombiner.fit() to pass validation data through to the meta-forecaster (for early stopping / evaluation).

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
packages/openstef-meta/tests/unit/models/forecast_combiners/test_learned_weights_combiner.py Adds regression tests for N-forecaster edge cases, dummy fallback behavior, and data_val training.
packages/openstef-meta/src/openstef_meta/models/forecast_combiners/stacking_combiner.py Passes prepared validation data to meta-forecaster during stacking fit.
packages/openstef-meta/src/openstef_meta/models/forecast_combiners/learned_weights_combiner.py Trains learned weights on validation predictions when available and improves N-forecaster robustness.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +93 to +102
# Pass validation predictions through so meta-forecasters that support early
# stopping (e.g. LGBM/XGBoost) can use them, instead of always discarding data_val.
input_data_val = None
if data_val is not None:
input_data_val = self._prepare_input(data_val, q)
input_data_val = input_data_val.pipe_pandas(
partial(pd.DataFrame.dropna, subset=[input_data_val.target_column])
)

self._models[q].fit(data=input_data, data_val=input_data_val)
Comment on lines 291 to +296
if isinstance(model, DummyClassifier):
# DummyClassifier has no predict_proba — construct one-hot weights manually
weights_array = pd.DataFrame(0, index=base_predictions.index, columns=self._label_encoder.classes_)
weights_array[self._label_encoder.classes_[0]] = 1.0
# DummyClassifier has no predict_proba — use the recorded majority winner rather than
# assuming the alphabetically-first registered forecaster name is the actual winner.
fallback_label = self._dummy_fallback_label.get(quantile, self._label_encoder.classes_[0])
weights_array = pd.DataFrame(0.0, index=base_predictions.index, columns=self._label_encoder.classes_)
weights_array[fallback_label] = 1.0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants