Skip to content
Closed
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ but cannot always guarantee backwards compatibility. Changes that may **break co
**Fixed**

- Fixed compatibility with pandas 3.1: fitting and predicting models on series with a daily frequency failed with a `TypeError`, and `MIDAS` accepted a series whose frequency was already the target low frequency. [#3289](https://github.com/unit8co/darts/pull/3289) by [Nicolò Angileri](https://github.com/nicoloangileri).
- Fixed a silent fallback of `predict()` to `training_series` when the last `fit()` was called with a `val_series`. Calling `predict()` without `series` still forecasts from the training series (behaviour unchanged), but now emits a warning explaining that the validation part is not included in the forecast. [#3278](https://github.com/unit8co/darts/issues/3278) by [XXXXXQ.](https://github.com/XXXXXQ-0206).

**Dependencies**

Expand Down
41 changes: 39 additions & 2 deletions darts/models/forecasting/torch_forecasting_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,10 @@ def encode_year(idx):
self.n_epochs = n_epochs
self.batch_size: int = batch_size

# whether the last `fit()` call got a `val_series` reaching beyond `training_series`;
# used to warn when `predict()` falls back to that training series
self._fit_used_val_series: bool = False

# get model name and work dir
if model_name is None:
current_time = datetime.datetime.now().strftime("%Y-%m-%d_%H_%M_%S")
Expand Down Expand Up @@ -1077,7 +1081,27 @@ def fit(
future_covariates=seq2series(future_covariates),
verbose=verbose,
)
return self.fit_from_dataset(**params)
# `predict()` without `series` falls back to `training_series`. Record whether a
# `val_series` reaches beyond the training series, so that `predict()` can warn about
# a fallback that skips part of the history the user has already seen. This is done
# before `fit_from_dataset()`, which saves the model (see `_create_model()`), so that
# the information is also available after `load_from_checkpoint()`.
self._fit_used_val_series = False
if self.training_series is not None and val_series is not None:
val_series_seq = (
val_series if isinstance(val_series, list | tuple) else [val_series]
)
try:
self._fit_used_val_series = any(
isinstance(val, TimeSeries)
and val.end_time() > self.training_series.end_time()
for val in val_series_seq
)
except TypeError:
# non-comparable time indexes -> keep the previous behaviour (no warning)
self._fit_used_val_series = False
self.fit_from_dataset(**params)
return self

def _setup_for_fit_from_dataset(
self,
Expand Down Expand Up @@ -1802,7 +1826,10 @@ def predict(
-------
TimeSeriesLike
One or several time series containing the forecasts of ``series``, or the forecast of the training series
if ``series`` is not specified and the model has been trained on a single series.
if ``series`` is not specified and the model has been trained on a single series. If the last ``fit()``
call was given a ``val_series`` that reaches beyond the training series, a warning is emitted in that
case: the training series does not include the validation part, so the forecast starts before the end
of ``val_series``.
"""
called_with_single_series = (
get_series_seq_type(series if series is not None else self.training_series)
Expand Down Expand Up @@ -1861,6 +1888,16 @@ def _setup_for_predict_from_dataset(
"`clean=True`."
),
)
if getattr(self, "_fit_used_val_series", False):
logger.warning(
"`predict()` was called without `series`, so the forecast is generated from the "
"training series that was passed to the last `fit()` call. That training series "
f"ends at {self.training_series.end_time()} and does not include the `val_series` "
"used for fitting, so the forecast starts at "
f"{self.training_series.end_time() + self.training_series.freq} and overlaps the "
"validation period. Pass `series=<series that includes the validation part>` to "
"`predict()` to start the forecast after `val_series` instead."
)
series = self.training_series

# guarantee that all inputs are either list of TimeSeries or None
Expand Down
53 changes: 53 additions & 0 deletions darts/tests/models/forecasting/test_torch_forecasting_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -3950,3 +3950,56 @@ def test_scale_batch_size_no_updates_post_fitting(self, probabilistic):
preds_after = model.predict(n=3, series=series, **pred_kwargs)
assert isinstance(preds, TimeSeries) and isinstance(preds_after, TimeSeries)
assert np.isclose(preds.values(), preds_after.values()).all()


@pytest.mark.parametrize(
"val_kind, expect_warning",
[
("none", False),
("extends_training", True),
("equals_training", False),
],
)
def test_predict_without_series_warns_when_fitted_with_val_series(
tmpdir_fn, caplog, val_kind, expect_warning
):
"""`predict()` without `series` must warn only if the last `fit()` got a `val_series` that
reaches beyond the training series.

The forecast must still start after `training_series` (behaviour unchanged).
"""
series = tg.sine_timeseries(length=100).astype(np.float32)
train = series[:70]
val_series = {
"none": None,
"extends_training": series[70:],
"equals_training": train,
}[val_kind]

model = RNNModel(
input_chunk_length=8,
n_epochs=1,
work_dir=tmpdir_fn,
random_state=42,
**tfm_kwargs,
)
model.fit(train, val_series=val_series)

# no `series` passed -> falls back to `training_series`
caplog.clear()
with caplog.at_level(logging.WARNING):
pred_fallback = model.predict(n=3)
assert ("`predict()` was called without `series`" in caplog.text) is expect_warning
if expect_warning:
# the warning points at the training series and at the resulting start
assert str(train.end_time()) in caplog.text
assert str(train.end_time() + train.freq) in caplog.text
# behaviour unchanged: the forecast still starts right after the training series
assert pred_fallback.start_time() == train.end_time() + train.freq

# explicit `series` -> never warns, forecast starts after that series
caplog.clear()
with caplog.at_level(logging.WARNING):
pred_explicit = model.predict(n=3, series=series)
assert "`predict()` was called without `series`" not in caplog.text
assert pred_explicit.start_time() == series.end_time() + series.freq