Skip to content

Commit 603aa39

Browse files
committed
Add overlay-dx metric (#3270)
1 parent 4d1f1f1 commit 603aa39

3 files changed

Lines changed: 107 additions & 0 deletions

File tree

src/gluonts/evaluation/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,4 @@
2929
"aggregate_all",
3030
"aggregate_valid",
3131
]
32+
from gluonts.evaluation.metrics import overlay_dx # noqa: F401

src/gluonts/evaluation/metrics.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,3 +185,58 @@ def num_masked_values(target) -> float:
185185
return np.ma.count_masked(target)
186186
else:
187187
return 0
188+
189+
190+
def overlay_dx(
191+
target: np.ndarray,
192+
forecast: np.ndarray,
193+
max_percentage: float = 100.0,
194+
min_percentage: float = 0.1,
195+
step: float = 0.1,
196+
) -> float:
197+
"""Overlay-dx metric: tolerance-sweep visual alignment score.
198+
199+
Measures alignment between target and forecast by computing coverage
200+
at varying tolerance levels and returning the normalized AUC.
201+
202+
Parameters
203+
----------
204+
target : np.ndarray
205+
Ground truth values.
206+
forecast : np.ndarray
207+
Predicted values.
208+
max_percentage : float, default 100.0
209+
Upper bound of tolerance sweep (percentage of value range).
210+
min_percentage : float, default 0.1
211+
Lower bound of tolerance sweep (percentage of value range).
212+
step : float, default 0.1
213+
Step size for tolerance sweep (percentage points).
214+
215+
Returns
216+
-------
217+
float
218+
Normalized AUC score in [0, 1]. Higher is better.
219+
Returns 0.0 if value_range is 0 (constant target).
220+
"""
221+
value_range = np.max(target) - np.min(target)
222+
223+
if value_range == 0:
224+
return 0.0
225+
226+
abs_errors = np.abs(target - forecast)
227+
n = len(forecast)
228+
229+
percentages = np.arange(max_percentage, min_percentage - step, -step)
230+
coverages = np.empty(len(percentages))
231+
232+
for i, pct in enumerate(percentages):
233+
tolerance = pct / 100.0 * value_range / 2.0
234+
coverages[i] = np.sum(abs_errors <= tolerance) / n
235+
236+
area = np.trapz(coverages, dx=step / 100.0 * value_range / 2.0)
237+
max_area = value_range * (max_percentage - min_percentage) / 200.0
238+
239+
if max_area == 0:
240+
return 0.0
241+
242+
return area / max_area

test/evaluation/test_metrics.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
msis,
2727
quantile_loss,
2828
smape,
29+
overlay_dx,
2930
)
3031

3132
ZEROES = np.array([0.0] * 5)
@@ -202,3 +203,53 @@ def test_seasonal_error(past_data, seasonality, expected):
202203
),
203204
expected,
204205
)
206+
207+
208+
class TestOverlayDx:
209+
"""Tests for overlay_dx metric."""
210+
211+
def test_perfect_forecast(self):
212+
target = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
213+
forecast = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
214+
result = overlay_dx(target, forecast)
215+
np.testing.assert_almost_equal(result, 1.0)
216+
217+
def test_constant_target_returns_zero(self):
218+
target = np.array([5.0, 5.0, 5.0, 5.0])
219+
forecast = np.array([4.0, 5.0, 6.0, 7.0])
220+
result = overlay_dx(target, forecast)
221+
np.testing.assert_almost_equal(result, 0.0)
222+
223+
def test_score_in_unit_interval(self):
224+
rng = np.random.default_rng(42)
225+
target = rng.standard_normal(100)
226+
forecast = target + rng.standard_normal(100) * 0.5
227+
result = overlay_dx(target, forecast)
228+
assert 0.0 <= result <= 1.0
229+
230+
def test_better_forecast_scores_higher(self):
231+
target = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
232+
good_forecast = target + 0.1
233+
bad_forecast = target + 2.0
234+
score_good = overlay_dx(target, good_forecast)
235+
score_bad = overlay_dx(target, bad_forecast)
236+
assert score_good > score_bad
237+
238+
def test_custom_parameters(self):
239+
target = np.array([0.0, 1.0, 2.0, 3.0, 4.0])
240+
forecast = np.array([0.1, 1.1, 2.1, 3.1, 4.1])
241+
result = overlay_dx(
242+
target, forecast,
243+
max_percentage=50.0,
244+
min_percentage=1.0,
245+
step=0.5,
246+
)
247+
assert 0.0 <= result <= 1.0
248+
249+
def test_symmetric(self):
250+
target = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
251+
forecast_above = target + 0.5
252+
forecast_below = target - 0.5
253+
score_above = overlay_dx(target, forecast_above)
254+
score_below = overlay_dx(target, forecast_below)
255+
np.testing.assert_almost_equal(score_above, score_below)

0 commit comments

Comments
 (0)