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
94 changes: 93 additions & 1 deletion neuralforecast/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,33 @@ def _insample_times(
return out


def _count_periods(
cutoff,
reference,
freq: Union[int, str, pd.offsets.BaseOffset],
) -> int:
"""Count timesteps strictly after `cutoff` up to and including `reference`."""
if isinstance(freq, int):
return int(reference - cutoff)
try:
dates = pd.date_range(start=cutoff, end=reference, freq=freq)
except ValueError:
# freq is a Polars-style string (e.g. "1mo") that pandas cannot parse.
import polars as _polars

dates = _polars.datetime_range(start=cutoff, end=reference, interval=freq, eager=True)
if len(dates) > 0 and dates[0].replace(tzinfo=None) != pd.Timestamp(cutoff).to_pydatetime():
warnings.warn(
f"Cutoff '{cutoff}' does not fall on a period boundary for "
f"frequency '{freq}'. It has been snapped to '{dates[0]}', "
"which may result in an unexpected `test_size` or `val_size`. "
"Ensure the cutoff aligns with the data frequency.",
UserWarning,
stacklevel=3,
)
return len(dates) - 1


MODEL_FILENAME_DICT = {
"autoformer": Autoformer,
"autoautoformer": Autoformer,
Expand Down Expand Up @@ -1396,6 +1423,8 @@ def cross_validation(
step_size: int = 1,
val_size: Optional[int] = 0,
test_size: Optional[int] = None,
validation_cutoff: Optional[Any] = None,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Allowing Any here is a validation nightmare, especially with dates that can be timestamp, pandas offsets, strings....

test_cutoff: Optional[Any] = None,
use_init_models: bool = False,
verbose: bool = False,
refit: Union[bool, int] = False,
Expand All @@ -1420,7 +1449,11 @@ def cross_validation(
n_windows (int, None): Number of windows used for cross validation. If None, define `test_size`.
step_size (int): Step size between each window.
val_size (int, optional): Length of validation size. If passed, set `n_windows=None`. Defaults to 0.
test_size (int, optional): Length of test size. If passed, set `n_windows=None`.
test_size (int, optional): Length of test size. If passed, set `n_windows=None`.
validation_cutoff: Last date of the training set (start of validation).
Converted to `val_size` using the data frequency. Cannot be combined with `val_size`.
test_cutoff: Last date of the validation set (start of test).
Converted to `test_size` using the data frequency. Cannot be combined with `test_size`.
use_init_models (bool, optional): Use initial model passed when object was instantiated.
verbose (bool): Print processing steps.
refit (bool or int): Retrain model for each cross validation window.
Expand All @@ -1439,6 +1472,52 @@ def cross_validation(
fcsts_df (pandas or polars DataFrame): DataFrame with insample `models` columns for point predictions and probabilistic
predictions for all fitted `models`.
"""
# Convert date-based cutoffs to integer sizes
if validation_cutoff is not None or test_cutoff is not None:
if df is None:
raise ValueError(
"Must provide `df` when using `validation_cutoff` or `test_cutoff`."
)
if validation_cutoff is not None and val_size not in (0, None):
raise ValueError("Cannot use both `validation_cutoff` and `val_size`.")
if test_cutoff is not None and test_size is not None:
raise ValueError("Cannot use both `test_cutoff` and `test_size`.")
if test_cutoff is not None and n_windows not in (1, None):
raise ValueError("Cannot use both `test_cutoff` and `n_windows`.")

ends_by_id = ufp.group_by_agg(df, by=id_col, aggs={time_col: "max"})
unique_end_dates = ends_by_id[time_col]
max_date = unique_end_dates.max()

if unique_end_dates.min() != unique_end_dates.max():
warnings.warn(
"Series have different end dates. The cutoff is resolved "
"against the global max date and may not align to the "
"intended cutoff for shorter series. Consider using "
"`test_size` / `val_size` directly.",
UserWarning,
stacklevel=2,
)

if test_cutoff is not None:
test_size = _count_periods(test_cutoff, max_date, self.freq)
if test_size <= 0:
raise ValueError(
"`test_cutoff` must be strictly before the last date in `df`."
)
# test_size now drives window count; clear n_windows so the
# existing logic (elif n_windows is None) computes it.
n_windows = None

if validation_cutoff is not None:
ref = test_cutoff if test_cutoff is not None else max_date
val_size = _count_periods(validation_cutoff, ref, self.freq)
if val_size <= 0:
raise ValueError(
"`validation_cutoff` must be strictly before `test_cutoff` "
"(or the last date in `df` when `test_cutoff` is not set)."
)

if h is not None:
if h > self.h:
# if only cross_validation called without fit() called first, prediction_intervals
Expand Down Expand Up @@ -1487,6 +1566,19 @@ def cross_validation(
assert n_windows is not None
assert test_size is not None

if test_size < self.h:
raise ValueError(
f"`test_size` ({test_size}) is smaller than the model horizon "
f"h={self.h}. Increase `test_size` or move `test_cutoff` further "
"back, or reduce the model's horizon."
)
if val_size is not None and val_size > 0 and val_size < self.h:
raise ValueError(
f"`val_size` ({val_size}) is smaller than the model horizon "
f"h={self.h}. Increase `val_size` or move `validation_cutoff` "
"further back, or reduce the model's horizon."
)

# Recover initial model if use_init_models.
if use_init_models:
self._reset_models()
Expand Down
187 changes: 187 additions & 0 deletions tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
PatchTST,
PredictionIntervals,
TimesNet,
_count_periods,
_insample_times,
_type2scaler,
)
Expand Down Expand Up @@ -116,6 +117,34 @@ def test_cutoff_deltas(setup, step_size, freq, days):
assert cutoff_deltas.nunique() == 1
assert cutoff_deltas.unique()[0] == pd.Timedelta(f"{days}D")


@pytest.mark.parametrize(
"cutoff, reference, freq, expected",
[
# monthly end-of-month: 12 steps between 1959-12-31 and 1960-12-31
(pd.Timestamp("1959-12-31"), pd.Timestamp("1960-12-31"), "ME", 12),
# daily: 30 steps
(pd.Timestamp("2000-01-01"), pd.Timestamp("2000-01-31"), "D", 30),
# weekly (Mon): 4 steps
(pd.Timestamp("2000-01-03"), pd.Timestamp("2000-01-31"), "W-MON", 4),
# quarterly start: 4 steps
(pd.Timestamp("2023-01-01"), pd.Timestamp("2024-01-01"), "QS", 4),
# integer freq: simple subtraction
(10, 22, 2, 12),
],
)
def test_count_periods(cutoff, reference, freq, expected):
assert _count_periods(cutoff, reference, freq) == expected


def test_count_periods_misaligned_warns():
# weekly: cutoff one day off the Monday boundary → snaps forward, count drops by 1
aligned = _count_periods(pd.Timestamp("2000-01-03"), pd.Timestamp("2000-01-31"), "W-MON")
with pytest.warns(UserWarning, match="does not fall on a period boundary"):
misaligned = _count_periods(pd.Timestamp("2000-01-04"), pd.Timestamp("2000-01-31"), "W-MON")
assert misaligned == aligned - 1


@pytest.fixture
def setup_airplane_data_polars(setup_airplane_data):
"""Create polars version of airplane data with renamed columns."""
Expand Down Expand Up @@ -157,6 +186,164 @@ def test_neural_forecast_fit_cross_validation(setup_airplane_data):
pd.testing.assert_frame_equal(init_cv, after_cv)
pd.testing.assert_frame_equal(after_fcst, init_fcst)

# test cross_validation with date-based cutoffs
def test_cross_validation_date_cutoffs():
# AirPassengersPanel uses end-of-month timestamps; freq="M" matches.
# max_date = 1960-12-31, so test_cutoff="1959-12-31" → test_size=12.
nf = NeuralForecast(
models=[NHITS(h=12, input_size=24, max_steps=5)], freq="ME"
)

# test_cutoff only: equivalent to test_size=12, n_windows=None (computed as 1)
cv_cutoff = nf.cross_validation(
AirPassengersPanel,
test_cutoff=pd.Timestamp("1959-12-31"),
use_init_models=True,
)
cv_size = nf.cross_validation(
AirPassengersPanel,
test_size=12,
n_windows=None,
use_init_models=True,
)
assert set(cv_cutoff["cutoff"].unique()) == set(cv_size["cutoff"].unique())
assert cv_cutoff.shape == cv_size.shape

# validation_cutoff only: val period is the 12 months before max_date
cv_val_cutoff = nf.cross_validation(
AirPassengersPanel,
validation_cutoff=pd.Timestamp("1959-12-31"),
test_size=12,
n_windows=None,
use_init_models=True,
)
cv_val_size = nf.cross_validation(
AirPassengersPanel,
val_size=12,
test_size=12,
n_windows=None,
use_init_models=True,
)
assert set(cv_val_cutoff["cutoff"].unique()) == set(cv_val_size["cutoff"].unique())
assert cv_val_cutoff.shape == cv_val_size.shape

# both cutoffs together
cv_both = nf.cross_validation(
AirPassengersPanel,
test_cutoff=pd.Timestamp("1959-12-31"),
validation_cutoff=pd.Timestamp("1958-12-31"),
use_init_models=True,
)
cv_both_size = nf.cross_validation(
AirPassengersPanel,
test_size=12,
val_size=12,
n_windows=None,
use_init_models=True,
)
assert set(cv_both["cutoff"].unique()) == set(cv_both_size["cutoff"].unique())
assert cv_both.shape == cv_both_size.shape

# conflict: test_cutoff + test_size raises
with pytest.raises(ValueError, match="Cannot use both"):
nf.cross_validation(
AirPassengersPanel,
test_cutoff=pd.Timestamp("1959-12-31"),
test_size=12,
n_windows=None,
use_init_models=True,
)

# conflict: test_cutoff + explicit n_windows raises
with pytest.raises(ValueError, match="Cannot use both"):
nf.cross_validation(
AirPassengersPanel,
test_cutoff=pd.Timestamp("1959-12-31"),
n_windows=2,
use_init_models=True,
)

# conflict: validation_cutoff + val_size raises
with pytest.raises(ValueError, match="Cannot use both"):
nf.cross_validation(
AirPassengersPanel,
validation_cutoff=pd.Timestamp("1959-12-31"),
val_size=12,
test_size=12,
n_windows=None,
use_init_models=True,
)

# test_cutoff at or after max_date raises
max_date = AirPassengersPanel["ds"].max()
with pytest.raises(ValueError, match="strictly before"):
nf.cross_validation(
AirPassengersPanel,
test_cutoff=max_date,
use_init_models=True,
)

# validation_cutoff at or after test_cutoff raises
with pytest.raises(ValueError, match="strictly before"):
nf.cross_validation(
AirPassengersPanel,
test_cutoff=pd.Timestamp("1959-12-31"),
validation_cutoff=pd.Timestamp("1959-12-31"), # equal → not strictly before
use_init_models=True,
)

# validation_cutoff at or after max_date (no test_cutoff) raises
with pytest.raises(ValueError, match="strictly before"):
nf.cross_validation(
AirPassengersPanel,
validation_cutoff=max_date,
test_size=12,
n_windows=None,
use_init_models=True,
)

# test_size smaller than horizon raises (via test_cutoff)
with pytest.raises(ValueError, match="smaller than the model horizon"):
nf.cross_validation(
AirPassengersPanel,
test_cutoff=pd.Timestamp("1960-11-30"), # only 1 month before max_date
use_init_models=True,
)

# val_size smaller than horizon raises (via validation_cutoff)
with pytest.raises(ValueError, match="smaller than the model horizon"):
nf.cross_validation(
AirPassengersPanel,
test_cutoff=pd.Timestamp("1959-12-31"),
validation_cutoff=pd.Timestamp("1959-11-30"), # only 1 month before test_cutoff
use_init_models=True,
)

# heterogeneous end dates: warn that cutoff may not align for shorter series
df_uneven = AirPassengersPanel.copy()
# Truncate one series so its end date differs from the other
airline2_mask = df_uneven["unique_id"] == "Airline2"
df_uneven = df_uneven[~(airline2_mask & (df_uneven["ds"] > pd.Timestamp("1959-12-31")))]
with pytest.warns(UserWarning, match="different end dates"):
nf.cross_validation(
df_uneven,
test_cutoff=pd.Timestamp("1958-12-31"),
use_init_models=True,
)

# Polars DataFrame: test_cutoff should work identically to the pandas path
nf_pl = NeuralForecast(
models=[NHITS(h=12, input_size=24, max_steps=5)], freq="1mo"
)
AirPassengersPanel_pl = polars.from_pandas(AirPassengersPanel)
cv_cutoff_pl = nf_pl.cross_validation(
AirPassengersPanel_pl,
test_cutoff=pd.Timestamp("1959-12-31"),
use_init_models=True,
)
assert cv_cutoff_pl.shape == cv_cutoff.shape


# test cross_validation with refit
def test_neural_forecast_refit(setup_airplane_data):
AirPassengersPanel_train, _ = setup_airplane_data
Expand Down