Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ but cannot always guarantee backwards compatibility. Changes that may **break co
**Fixed**

- Fixed `_ScaledDotProductAttention` float16 overflow in `masked_fill` under mixed precision training. [#3087](https://github.com/unit8co/darts/pull/3087) by [Robert Ruidisch](https://github.com/robrui).
- Fixed `ope` to accept `actual_series` with a strictly negative sum; the previous `sum > 0` check incorrectly rejected valid inputs such as financial return series. Also corrected the `wmape` docstring which inaccurately claimed it raised on zeros in `actual_series`. by [Mahimn](https://github.com/mahimn01).

**Improved**

- πŸš€πŸš€ Added new forecasting model `PatchTSTFMModel` : IBM's pre-trained ~260M-parameter foundational model for zero-shot forecasting. It supports univariate, multivariate, and multiple time series forecasting without training and can output deterministic or probabilistic forecasts. [#3120](https://github.com/unit8co/darts/pull/3120) by [Dennis Bader](https://github.com/dennisbader).
- Added `use_longer_projection_head` to `TimesFM2p5Model` to enable longer non-autoregressive prediction horizons (up to 1024 steps for `output_chunk_length + output_chunk_shift`). [#3121](https://github.com/unit8co/darts/pull/3121) by [Zhihao Dai](https://github.com/daidahao).
- πŸ”΄ Percentage and range-based metrics (`wmape`, `ope`, `arre`, `marre`, `coefficient_of_variation`) now expose a `zero_division` parameter (mirroring [#3059](https://github.com/unit8co/darts/pull/3059)) controlling the behavior when the denominator is zero: `"warn"` (default) returns `np.nan` and emits a warning, `"raise"` preserves the legacy `ValueError`. by [Mahimn](https://github.com/mahimn01).

**Fixed**

Expand Down
90 changes: 62 additions & 28 deletions darts/metrics/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
_get_values_or_raise,
_get_wrapped_metric,
_LabelReduction,
_safe_pct_divide,
_safe_scaled_divide,
classification_support,
interval_support,
Expand Down Expand Up @@ -1725,6 +1726,7 @@ def wmape(
intersect: bool = True,
*,
q: float | list[float] | tuple[np.ndarray, pd.Index] | None = None,
zero_division: str = "warn",
component_reduction: Callable[[np.ndarray], float] | None = np.nanmean,
series_reduction: Callable[[np.ndarray], float | np.ndarray] | None = None,
n_jobs: int = 1,
Expand Down Expand Up @@ -1753,6 +1755,12 @@ def wmape(
will consider the values only over their common time interval (intersection in time).
q
Optionally, the quantile (float [0, 1]) or list of quantiles of interest to compute the metric on.
zero_division
Controls behavior when the denominator :math:`\\sum_{t=1}^T |y_t|` is zero (i.e. ``actual_series`` is
all zeros for a given component).

* ``"warn"`` (default) – returns ``np.nan`` and emits a warning.
* ``"raise"`` – raises a ``ValueError``.
component_reduction
Optionally, a function to aggregate the metrics over the component/column axis. It must reduce a `np.ndarray`
of shape `(t, c)` to a `np.ndarray` of shape `(t,)`. The function takes as input a ``np.ndarray`` and a
Expand All @@ -1776,7 +1784,7 @@ def wmape(
Raises
------
ValueError
If `actual_series` contains some zeros.
If `zero_division="raise"` and the denominator :math:`\\sum_{t=1}^T |y_t|` is zero for some component.

Returns
-------
Expand Down Expand Up @@ -1812,10 +1820,10 @@ def wmape(
q=q,
)

return (
100.0
* np.nansum(np.abs(y_true - y_pred), axis=TIME_AX)
/ np.nansum(np.abs(y_true), axis=TIME_AX)
return 100.0 * _safe_pct_divide(
np.nansum(np.abs(y_true - y_pred), axis=TIME_AX),
np.nansum(np.abs(y_true), axis=TIME_AX),
zero_division=zero_division,
)


Expand Down Expand Up @@ -2029,6 +2037,7 @@ def ope(
intersect: bool = True,
*,
q: float | list[float] | tuple[np.ndarray, pd.Index] | None = None,
zero_division: str = "warn",
component_reduction: Callable[[np.ndarray], float] | None = np.nanmean,
series_reduction: Callable[[np.ndarray], float | np.ndarray] | None = None,
n_jobs: int = 1,
Expand Down Expand Up @@ -2058,6 +2067,14 @@ def ope(
will consider the values only over their common time interval (intersection in time).
q
Optionally, the quantile (float [0, 1]) or list of quantiles of interest to compute the metric on.
zero_division
Controls behavior when the denominator :math:`\\sum_{t=1}^{T}{y_t}` is zero.

* ``"warn"`` (default) – returns ``np.nan`` and emits a warning.
* ``"raise"`` – raises a ``ValueError``.

Note: a negative sum is a valid denominator (e.g. financial return series). Only an exact
zero sum triggers the zero-division handling.
component_reduction
Optionally, a function to aggregate the metrics over the component/column axis. It must reduce a `np.ndarray`
of shape `(t, c)` to a `np.ndarray` of shape `(t,)`. The function takes as input a ``np.ndarray`` and a
Expand All @@ -2081,7 +2098,7 @@ def ope(
Raises
------
ValueError
If :math:`\\sum_{t=1}^{T}{y_t} = 0`.
If `zero_division="raise"` and :math:`\\sum_{t=1}^{T}{y_t} = 0` for some component.

Returns
-------
Expand Down Expand Up @@ -2116,14 +2133,16 @@ def ope(
np.nansum(y_true, axis=TIME_AX),
np.nansum(y_pred, axis=TIME_AX),
)
if not (y_true_sum > 0).all():
raise_log(
ValueError(
"The series of actual value cannot sum to zero when computing OPE."
),
logger=logger,
return (
np.abs(
_safe_pct_divide(
y_true_sum - y_pred_sum,
y_true_sum,
zero_division=zero_division,
)
)
return np.abs((y_true_sum - y_pred_sum) / y_true_sum) * 100.0
* 100.0
)


@multi_ts_support
Expand All @@ -2134,6 +2153,7 @@ def arre(
intersect: bool = True,
*,
q: float | list[float] | tuple[np.ndarray, pd.Index] | None = None,
zero_division: str = "warn",
time_reduction: Callable[..., np.ndarray] | None = None,
component_reduction: Callable[[np.ndarray], float] | None = np.nanmean,
series_reduction: Callable[[np.ndarray], float | np.ndarray] | None = None,
Expand Down Expand Up @@ -2163,6 +2183,12 @@ def arre(
will consider the values only over their common time interval (intersection in time).
q
Optionally, the quantile (float [0, 1]) or list of quantiles of interest to compute the metric on.
zero_division
Controls behavior when the denominator :math:`\\max_t{y_t} - \\min_t{y_t}` is zero (i.e.
``actual_series`` is constant for a given component).

* ``"warn"`` (default) – returns ``np.nan`` for affected components and emits a warning.
* ``"raise"`` – raises a ``ValueError``.
time_reduction
Optionally, a function to aggregate the metrics over the time axis. It must reduce a `np.ndarray`
of shape `(t, c)` to a `np.ndarray` of shape `(c,)`. The function takes as input a ``np.ndarray`` and a
Expand Down Expand Up @@ -2191,7 +2217,7 @@ def arre(
Raises
------
ValueError
If :math:`\\max_t{y_t} = \\min_t{y_t}`.
If `zero_division="raise"` and :math:`\\max_t{y_t} = \\min_t{y_t}` for some component.

Returns
-------
Expand Down Expand Up @@ -2226,16 +2252,10 @@ def arre(
q=q,
)
y_max, y_min = np.nanmax(y_true, axis=TIME_AX), np.nanmin(y_true, axis=TIME_AX)
if not (y_max > y_min).all():
raise_log(
ValueError(
"The difference between the max and min values must "
"be strictly positive to compute the MARRE."
),
logger=logger,
)
true_range = y_max - y_min
return 100.0 * np.abs((y_true - y_pred) / true_range)
return 100.0 * np.abs(
_safe_pct_divide(y_true - y_pred, true_range, zero_division=zero_division)
)


@multi_ts_support
Expand All @@ -2246,6 +2266,7 @@ def marre(
intersect: bool = True,
*,
q: float | list[float] | tuple[np.ndarray, pd.Index] | None = None,
zero_division: str = "warn",
component_reduction: Callable[[np.ndarray], float] | None = np.nanmean,
series_reduction: Callable[[np.ndarray], float | np.ndarray] | None = None,
n_jobs: int = 1,
Expand Down Expand Up @@ -2275,6 +2296,12 @@ def marre(
will consider the values only over their common time interval (intersection in time).
q
Optionally, the quantile (float [0, 1]) or list of quantiles of interest to compute the metric on.
zero_division
Controls behavior when the denominator :math:`\\max_t{y_t} - \\min_t{y_t}` is zero (i.e.
``actual_series`` is constant for a given component).

* ``"warn"`` (default) – returns ``np.nan`` for affected components and emits a warning.
* ``"raise"`` – raises a ``ValueError``.
component_reduction
Optionally, a function to aggregate the metrics over the component/column axis. It must reduce a `np.ndarray`
of shape `(t, c)` to a `np.ndarray` of shape `(t,)`. The function takes as input a ``np.ndarray`` and a
Expand All @@ -2298,7 +2325,7 @@ def marre(
Raises
------
ValueError
If :math:`\\max_t{y_t} = \\min_t{y_t}`.
If `zero_division="raise"` and :math:`\\max_t{y_t} = \\min_t{y_t}` for some component.

float
A single metric score for:
Expand All @@ -2322,6 +2349,7 @@ def marre(
pred_series,
intersect,
q=q,
zero_division=zero_division,
),
axis=TIME_AX,
)
Expand Down Expand Up @@ -2433,6 +2461,7 @@ def coefficient_of_variation(
intersect: bool = True,
*,
q: float | list[float] | tuple[np.ndarray, pd.Index] | None = None,
zero_division: str = "warn",
component_reduction: Callable[[np.ndarray], float] | None = np.nanmean,
series_reduction: Callable[[np.ndarray], float | np.ndarray] | None = None,
n_jobs: int = 1,
Expand Down Expand Up @@ -2464,6 +2493,11 @@ def coefficient_of_variation(
will consider the values only over their common time interval (intersection in time).
q
Optionally, the quantile (float [0, 1]) or list of quantiles of interest to compute the metric on.
zero_division
Controls behavior when the denominator :math:`\\bar{y}` (the mean of ``actual_series``) is zero.

* ``"warn"`` (default) – returns ``np.nan`` for affected components and emits a warning.
* ``"raise"`` – raises a ``ValueError``.
component_reduction
Optionally, a function to aggregate the metrics over the component/column axis. It must reduce a `np.ndarray`
of shape `(t, c)` to a `np.ndarray` of shape `(t,)`. The function takes as input a ``np.ndarray`` and a
Expand Down Expand Up @@ -2514,10 +2548,10 @@ def coefficient_of_variation(
q=q,
)
# not calling rmse as y_true and y_pred are np.ndarray
return (
100
* np.sqrt(np.nanmean((y_true - y_pred) ** 2, axis=TIME_AX))
/ np.nanmean(y_true, axis=TIME_AX)
return 100 * _safe_pct_divide(
np.sqrt(np.nanmean((y_true - y_pred) ** 2, axis=TIME_AX)),
np.nanmean(y_true, axis=TIME_AX),
zero_division=zero_division,
)


Expand Down
67 changes: 67 additions & 0 deletions darts/metrics/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -940,6 +940,73 @@ def _safe_scaled_divide(
return result


def _safe_pct_divide(
errors: np.ndarray,
scale: np.ndarray,
zero_division: str = "warn",
) -> np.ndarray:
"""Divides ``errors`` by ``scale`` for percentage-style metrics, returning
``np.nan`` where ``scale`` is zero.

Unlike :func:`_safe_scaled_divide` β€” which fills the ``0/0`` case with
``1.0`` to express "on par with naive baseline" for scaled-error metrics
β€” this helper always fills zero-scale entries with ``np.nan`` because
percentage metrics multiply the ratio by ``100``; a fill of ``1.0`` would
surface as a ``100 %`` error and be misleading.

Parameters
----------
errors
Numerator array. Broadcasts against ``scale``.
scale
Denominator array (e.g. the sum, mean, or range of ``actual_series``).
zero_division
Controls behavior when ``scale`` is (near) zero.

* ``"warn"`` (default) – fill zero-scale entries with ``np.nan`` and
emit a warning.
* ``"raise"`` – raise a ``ValueError`` (the legacy behavior).

Returns
-------
np.ndarray
The result of ``errors / scale`` with zero-scale entries replaced by
``np.nan``.
"""
if zero_division not in ["warn", "raise"]:
raise_log(
ValueError(
f"`zero_division` must be 'warn' or 'raise'. Received {zero_division}."
),
logger=logger,
)

zero_mask = np.isclose(scale, 0.0)
if not zero_mask.any():
return errors / scale

if zero_division == "raise":
raise_log(
ValueError(
"Cannot compute percentage metric: the denominator "
"(e.g. sum, mean, or range of `actual_series`) is zero "
"for some components."
),
logger=logger,
)

# Avoid runtime warnings from the masked divide
safe_scale = np.where(zero_mask, 1.0, scale)
result = np.where(zero_mask, np.nan, errors / safe_scale)

logger.warning(
"The denominator (e.g. sum, mean, or range of `actual_series`) is "
"zero for some components in the percentage metric. Those entries "
"are set to NaN."
)
return result


def _unique_labels(y_true: np.ndarray, y_pred: np.ndarray) -> list[np.ndarray]:
"""Returns unique labels for each component in the true and predicted labels."""
labels = []
Expand Down
Loading