Skip to content

[FEAT] Add forecast volatility metrics (sEV, sFPC) - #326

Open
PotosnakW wants to merge 8 commits into
Nixtla:mainfrom
PotosnakW:fcst_volatility_metrics
Open

PotosnakW wants to merge 8 commits into
Nixtla:mainfrom
PotosnakW:fcst_volatility_metrics

Conversation

@PotosnakW

Copy link
Copy Markdown

Motivation

As a multi-horizon forecasting system operates over time, it generates multiple overlapping forecasts for the same future target date, one from each new forecast creation date (FCD) as more data becomes available. This sequence of updates is a forecast revision, and how consistent (or erratic) those revisions are is defined as forecast volatility. This PR adds two metrics that measure forecast volatility, introduced in [1]: scaled Excess Volatility (sEV) and Symmetric Forecast Percentage Change (sFPC).

They were first proposed for NeuralForecast in Nixtla/neuralforecast#1641, and moved here since neuralforecast.losses.numpy is being deprecated.

[1] Willa Potosnak, Malcolm Wolff, Mengfei Cao, Ruijun Ma, Tatiana Konstantinova, Dmitry Efimov, Michael W. Mahoney, Boris Oreshkin, Kin G. Olivares. Forking-Sequences: Statistically and Computationally Efficient Multi-Horizon Forecasting with Reduced Volatility. Transactions on Machine Learning Research (2026).

What this adds

Two losses in utilsforecast.losses, working on pandas and polars cross-validation output like the existing ones:

  • forecast_percentage_change (sFPC) measures the relative size of revisions, bounded in [0, 200].

  • excess_volatility (sEV) measures harmful revisions: the revision cost, as the quantile loss between the older and newer forecasts, minus the accuracy improvement it produced, scaled by |y|:

    EV = QL(ŷ_update, ŷ_before) − (QL(y, ŷ_before) − QL(y, ŷ_update))

Both pair each forecast with the forecast from the next cutoff of the same series for the same target date, so they work with any step_size smaller than the horizon and raise a clear error when windows do not overlap.

Usage

import numpy as np
from utilsforecast.evaluation import evaluate
from utilsforecast.losses import excess_volatility, forecast_percentage_change, mae

# cross-validation output with overlapping windows (step_size < h)
evaluate(cv_df, metrics=[mae, excess_volatility, forecast_percentage_change], level=[80])

# or directly, e.g. to include the median among the quantiles
excess_volatility(
    cv_df,
    models={"model": ["model-lo-80", "model", "model-hi-80"]},
    quantiles=np.array([0.1, 0.5, 0.9]),
)

Output

Like the other losses, both return one row per series and cutoff. A row scores the revisions made at that cutoff, so the first cutoff of each series is null.

sEV divides every cutoff of a series by the same scale rather than by that cutoff's own sum of |y|, so that averaging a series' rows over cutoffs gives exactly the sEV of [1] for that series. The trade-off is that adding or removing a cutoff changes the other cutoffs' values.

Docs

  • Adds nbs/docs/tutorials/forecast_volatility.ipynb, listed under Tutorials. It motivates forecast volatility, then applies the metrics to StatsForecast and NeuralForecast cross_validation output.
  • Adds both functions to the losses API reference (docs/losses.html.md).

Tests

31 cases in tests/test_losses.py, covering the new code fully: hand-computed values for both metrics, the EV ≥ 0 property with asymmetric quantiles, pandas/polars parity, step_size > 1, per-cutoff output matching mae's layout, rows averaging to the overall sEV, NaN/null forecasts, series with fewer or missing cutoffs, and the error cases.

Notes for reviewers

  • Averaging sEV across series weights each series equally, as for the other per-series losses, whereas the pooled sEV in [1] weights series by scale. This is noted in the docstring.
  • excess_volatility takes the quantile columns and their quantiles as arguments. Inside evaluate, these are built from level, as for scaled_crps, so only the interval columns are used (-lo-80/-hi-80 → 0.1/0.9) and the median is left out. To include it, call excess_volatility directly and list the median column with 0.5.
  • If a series skips a cutoff, the next available one is taken as the revision on the dates both forecast, and a warning is raised.

Add `excess_volatility` (scaled excess volatility, sEV) and
`forecast_percentage_change` (symmetric forecast percentage change, sFPC)
to `utilsforecast.losses`. Both compare the forecasts that consecutive
cross-validation cutoffs make for the same target date, and work with
pandas and polars.

- Forecasts are paired by (next cutoff, same date), so any `step_size`
  smaller than the horizon works; non-overlapping windows raise.
- Like the other losses, both return one row per series and cutoff. A row
  scores the revisions made at that cutoff; the first cutoff is null.
- sEV rows share a per-series scale, so averaging a series' rows over
  cutoffs gives its sEV over the whole cross-validation.

Also add a forecast volatility tutorial using StatsForecast and
NeuralForecast cross-validation output.
- Treat NaN like null when choosing the pairs to score, so a polars NaN
  forecast drops only its own pairs instead of turning whole cutoffs NaN.
- Escape `\sum` in the `excess_volatility` docstring and rewrap the
  `forecast_percentage_change` docstring.
- Add both metrics to the losses API reference.
- Show how to include the median in sEV in the tutorial.
@review-notebook-app

Copy link
Copy Markdown

Check out this pull request on  ReviewNB

See visual diffs & provide feedback on Jupyter Notebooks.


Powered by ReviewNB

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

This branch has not been deployed

No deployments
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