Skip to content

Commit 53710cf

Browse files
fix: count forecast steps on the calendar grid, not in days
_steps_ahead measured the distance to a requested date in days and divided by the typical gap between training rows. A calendar period is not a fixed number of days: months run 28 to 31, the median lands on 31, and the count falls a whole period behind after roughly two years. On a monthly series with a five year horizon, 34 of 60 rows came out one step short. Two different months then collapsed onto the same forecast and the last real period was never scored, so a seasonal naive forecaster with the right season length got 34 of 60 predictions wrong on a perfectly seasonal series. When the training rows sit on a regular grid, infer_frequency names it and a position on that grid is exact however long the horizon gets. The day count stays as the fallback for irregular series, where no step is well defined anyway, so their behaviour is unchanged.
1 parent 72722d1 commit 53710cf

2 files changed

Lines changed: 142 additions & 3 deletions

File tree

DashAI/back/models/forecasting/base_forecasting_model.py

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
if TYPE_CHECKING:
88
import numpy as np
9+
import pandas as pd
910

1011
from DashAI.back.dataloaders.classes.dashai_dataset import DashAIDataset
1112

@@ -45,6 +46,7 @@ def __init__(self, **kwargs):
4546
# is what lets predict turn a date into a number of steps ahead.
4647
self._last_train_date = None
4748
self._step_delta = None
49+
self._freq_alias = None
4850
self._date_format = None
4951

5052
@staticmethod
@@ -90,7 +92,11 @@ def _remember_dates(self, x_train: "DashAIDataset") -> None:
9092
x_train : DashAIDataset
9193
The training input, holding the date column.
9294
"""
93-
from DashAI.back.types.date_utils import DEFAULT_DATE_FORMAT, parse_date_column
95+
from DashAI.back.types.date_utils import (
96+
DEFAULT_DATE_FORMAT,
97+
infer_frequency,
98+
parse_date_column,
99+
)
94100
from DashAI.back.types.value_types import Date
95101

96102
date_columns = [
@@ -102,6 +108,7 @@ def _remember_dates(self, x_train: "DashAIDataset") -> None:
102108
# Nothing to align against; predict falls back to counting rows.
103109
self._last_train_date = None
104110
self._step_delta = None
111+
self._freq_alias = None
105112
return
106113

107114
self._date_format = (
@@ -120,6 +127,54 @@ def _remember_dates(self, x_train: "DashAIDataset") -> None:
120127
if self._step_delta is not None and self._step_delta.total_seconds() <= 0:
121128
self._step_delta = None
122129

130+
# A calendar period is not a fixed number of days, so measuring in
131+
# days drifts: months run 28 to 31, the median lands on 31, and after
132+
# a couple of years the count is a whole period short. When the rows
133+
# sit on a regular grid the alias names that grid, and a position on
134+
# it is exact however long the horizon gets.
135+
alias = infer_frequency(dates)
136+
self._freq_alias = alias if isinstance(alias, str) else None
137+
138+
def _steps_from_grid(self, dates: "pd.Series") -> "np.ndarray | None":
139+
"""Read each date as a position on the calendar grid of the training rows.
140+
141+
Counting positions rather than dividing durations is what keeps a
142+
monthly or quarterly series aligned: those periods are not a fixed
143+
number of days, so a duration divided by the typical gap drifts by a
144+
whole period over a long enough horizon.
145+
146+
Parameters
147+
----------
148+
dates : pd.Series
149+
The requested dates, already parsed.
150+
151+
Returns
152+
-------
153+
np.ndarray or None
154+
One step number per date, or ``None`` when the grid cannot answer:
155+
no regular frequency, a missing date, or a date that does not land
156+
on the grid. The caller then measures by duration instead.
157+
"""
158+
import pandas as pd
159+
160+
if self._freq_alias is None or dates.isna().any():
161+
return None
162+
163+
grid = pd.date_range(
164+
start=self._last_train_date, end=dates.max(), freq=self._freq_alias
165+
)
166+
# The grid starts at the last training date, so a position on it is
167+
# already a number of steps past the end of training. date_range rolls
168+
# a start that is off the grid forward, which would break that.
169+
if len(grid) == 0 or grid[0] != self._last_train_date:
170+
return None
171+
172+
positions = grid.get_indexer(pd.DatetimeIndex(dates))
173+
if (positions < 0).any():
174+
return None
175+
176+
return positions
177+
123178
def _steps_ahead(self, x: "DashAIDataset") -> "np.ndarray":
124179
"""Work out how many periods past training each requested date falls.
125180
@@ -158,8 +213,13 @@ def _steps_ahead(self, x: "DashAIDataset") -> "np.ndarray":
158213
return np.arange(1, len(x) + 1)
159214

160215
dates = parse_date_column(x.to_pandas()[date_columns[0]], self._date_format)
161-
offsets = (dates - self._last_train_date) / self._step_delta
162-
steps = np.rint(offsets.to_numpy(dtype=float)).astype(int)
216+
217+
steps = self._steps_from_grid(dates)
218+
if steps is None:
219+
# No regular grid to count on, so the best available reading is
220+
# how many typical gaps each date lies past the end of training.
221+
offsets = (dates - self._last_train_date) / self._step_delta
222+
steps = np.rint(offsets.to_numpy(dtype=float)).astype(int)
163223

164224
if (steps < 1).any():
165225
raise ValueError(

tests/back/models/test_forecasting_horizon.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,14 @@ def _actual(y_split):
4949
return np.asarray(y_split.to_pandas().iloc[:, 0], dtype=float)
5050

5151

52+
def _rows(dataset, start, stop):
53+
"""A contiguous slice of a dataset, keeping its column types."""
54+
return to_dashai_dataset(
55+
dataset.to_pandas().iloc[start:stop].reset_index(drop=True),
56+
types=dict(dataset.types),
57+
)
58+
59+
5260
def test_the_test_partition_is_forecast_at_its_own_dates():
5361
# The regression this file exists for. On a perfectly linear series ARIMA
5462
# should land on the test values; before the fix it returned the
@@ -129,3 +137,74 @@ def test_asking_for_a_date_inside_the_training_range_is_refused(model_class):
129137

130138
with pytest.raises(ValueError, match="inside the training data"):
131139
model.predict(xs["train"])
140+
141+
142+
@pytest.mark.parametrize("freq", ["MS", "ME", "QS"])
143+
def test_a_calendar_series_stays_aligned_over_a_long_horizon(freq):
144+
# A calendar period is not a fixed number of days. Reading the distance to
145+
# a date in days and dividing by the typical gap drifts: months run 28 to
146+
# 31, the median lands on 31, and after roughly two years the count is a
147+
# whole period short. Two different months then collapse onto the same
148+
# forecast and every later row is off by one.
149+
xs, ys, _ = _split(n=180, freq=freq)
150+
model = NaiveForecaster()
151+
model.train(xs["train"], ys["train"])
152+
153+
steps = model._steps_ahead(xs["test"])
154+
155+
first = len(xs["validation"]) + 1
156+
assert list(steps) == list(range(first, first + len(xs["test"])))
157+
158+
159+
def test_a_seasonal_monthly_series_is_forecast_exactly():
160+
# The value level version of the same thing: a seasonal naive forecaster
161+
# given the right season length on a perfectly seasonal series should get
162+
# every month right, and the drift used to spoil more than half of them.
163+
dates = pd.date_range("2016-01-01", periods=120, freq="MS").strftime("%Y-%m-%d")
164+
series = [float(10 * (i % 12)) for i in range(120)]
165+
dataset = transform_dataset_with_schema(
166+
to_dashai_dataset(pd.DataFrame({"date": dates.tolist(), "v": series})),
167+
{
168+
"date": {"type": "Date", "dtype": "%Y-%m-%d"},
169+
"v": {"type": "Float", "dtype": "float64"},
170+
},
171+
)
172+
x, y = select_columns(dataset, ["date"], ["v"])
173+
cut = 60
174+
175+
model = SeasonalNaiveForecaster(season_length=12)
176+
model.train(_rows(x, 0, cut), _rows(y, 0, cut))
177+
178+
forecast = np.asarray(model.predict(_rows(x, cut, 120)), dtype=float)
179+
180+
assert forecast == pytest.approx(series[cut:])
181+
182+
183+
def test_an_irregular_series_still_forecasts_by_typical_gap():
184+
# No regular grid to count positions on, so the reading falls back to how
185+
# many typical gaps each date lies past the end of training. That is the
186+
# best available answer rather than a refusal.
187+
dates = [
188+
"2026-01-01",
189+
"2026-01-05",
190+
"2026-01-06",
191+
"2026-02-01",
192+
"2026-02-15",
193+
"2026-03-02",
194+
]
195+
dataset = transform_dataset_with_schema(
196+
to_dashai_dataset(
197+
pd.DataFrame({"date": dates, "v": [float(i) for i in range(6)]})
198+
),
199+
{
200+
"date": {"type": "Date", "dtype": "%Y-%m-%d"},
201+
"v": {"type": "Float", "dtype": "float64"},
202+
},
203+
)
204+
x, y = select_columns(dataset, ["date"], ["v"])
205+
206+
model = NaiveForecaster()
207+
model.train(_rows(x, 0, 4), _rows(y, 0, 4))
208+
209+
assert model._freq_alias is None
210+
assert len(model.predict(_rows(x, 4, 6))) == 2

0 commit comments

Comments
 (0)