From 347d21109c03655ef0c4e8b49be40a43a8c7cd27 Mon Sep 17 00:00:00 2001 From: Nefelibata <124799179+MeiSiristhebest@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:20:57 +0800 Subject: [PATCH 01/22] fix(automl): use a DatetimeIndex as the ts_forecast time column ts_forecast expects the timestamp in a column, so validate_data infers `time_col = dataframe.columns[0]` when the caller does not pass one. For a frame whose timestamps are the DataFrame index -- the idiomatic pandas shape -- that inference picks a value column, usually the label itself, and the conversion at `pd.to_datetime()` then reads those numbers as nanoseconds since the epoch instead of failing. The 120 real timestamps collapse onto 1970-01-01, `remove_ts_duplicates` silently drops the rows that collide, and the run dies with an unrelated-looking error: AssertionError: Duplicate timestamp values with different values for other columns. With a wider numeric range nothing collides and the same input instead fails later with "pandas dtypes must be int, float or bool" on the lag features. Neither message mentions the index. When `time_col` was inferred (never user-supplied), the index is a DatetimeIndex, and the inferred column is not datetime-like, promote the index to a column before the conversion. It keeps the index's own name, falling back to `ds`, and the decision is logged. A supplied `time_col`, a first column that is already datetime-like, and frames with no timestamps at all all keep their current behaviour; the existing error text is deliberately unchanged. Part of #1570 (Track A, item A1). --- flaml/automl/task/time_series_task.py | 16 ++++ .../automl/test_ts_forecast_datetime_index.py | 82 +++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 test/automl/test_ts_forecast_datetime_index.py diff --git a/flaml/automl/task/time_series_task.py b/flaml/automl/task/time_series_task.py index 9f16840891..0ab1db1df1 100644 --- a/flaml/automl/task/time_series_task.py +++ b/flaml/automl/task/time_series_task.py @@ -126,7 +126,9 @@ def validate_data( else: target_names = label + time_col_inferred = False if self.time_col is None: + time_col_inferred = True if isinstance(X_train_all, pd.DataFrame): assert dataframe is None, "One of dataframe and X arguments must be None" self.time_col = X_train_all.columns[0] @@ -150,6 +152,20 @@ def validate_data( else: raise ValueError("Must supply either X_train_all and y_train_all, or dataframe and label") + if ( + time_col_inferred + and isinstance(dataframe.index, pd.DatetimeIndex) + and not pd.api.types.is_datetime64_any_dtype(dataframe[self.time_col]) + ): + # The timestamps were passed as the DataFrame index instead of as a column, so the + # inference above picked a value column. Converting that column with + # `pd.to_datetime()` reads its numbers as nanoseconds since the epoch rather than + # failing, which silently replaces the real time axis with 1970-01-01. + index_name = dataframe.index.name or "ds" + dataframe = dataframe.reset_index().rename(columns={"index": index_name}) + logger.info(f"Timestamp column not found, using the DataFrame index as '{index_name}'.") + self.time_col = index_name + try: dataframe.loc[:, self.time_col] = pd.to_datetime(dataframe[self.time_col]) except Exception: diff --git a/test/automl/test_ts_forecast_datetime_index.py b/test/automl/test_ts_forecast_datetime_index.py new file mode 100644 index 0000000000..371357266e --- /dev/null +++ b/test/automl/test_ts_forecast_datetime_index.py @@ -0,0 +1,82 @@ +"""Regression tests for ts_forecast input where the timestamp is the DataFrame index. + +Flaml expects the timestamp to live in a *column* (`time_col`). When a user passes the +idiomatic pandas shape instead -- timestamps as a `DatetimeIndex` -- `validate_data` +defaults `time_col` to `dataframe.columns[0]`, which is a value column (often the label +itself), so the real timestamps are never looked at. +""" + +import numpy as np +import pandas as pd +import pytest + +from flaml import AutoML + + +def _make_train_df(periods=120): + return pd.DataFrame( + {"y": np.sin(np.arange(periods) / 6) * 10 + 50}, + index=pd.date_range("2018-01-01", periods=periods, freq="MS"), + ) + + +def _fit(automl, **kwargs): + settings = { + "task": "ts_forecast", + "label": "y", + "period": 12, + "time_budget": 5, + "estimator_list": ["lgbm"], + "metric": "mape", + "log_file_name": False, + "verbose": 0, + } + settings.update(kwargs) + automl.fit(**settings) + + +def test_unnamed_datetime_index_becomes_the_time_column(): + automl = AutoML() + _fit(automl, dataframe=_make_train_df()) + assert automl._state.task.time_col == "ds" + + +def test_named_datetime_index_keeps_its_name(): + df = _make_train_df() + df.index.name = "month" + automl = AutoML() + _fit(automl, dataframe=df) + assert automl._state.task.time_col == "month" + + +def test_promoted_index_keeps_every_training_row(): + """Pre-promotion the label column was read as epoch nanoseconds, collapsing the 120 real + timestamps onto a handful of 1970-01-01 values and dropping the duplicates.""" + df = _make_train_df() + automl = AutoML() + _fit(automl, dataframe=df) + assert automl._state.data_size[0] == len(df) + + +def test_explicit_time_col_still_wins_over_the_index(): + df = _make_train_df() + df["ts"] = df.index + automl = AutoML() + _fit(automl, dataframe=df, time_col="ts") + assert automl._state.task.time_col == "ts" + + +def test_genuine_missing_timestamp_still_raises(): + df = pd.DataFrame({"note": ["not-a-date"] * 120, "y": np.arange(120, dtype=float)}) + automl = AutoML() + with pytest.raises(ValueError) as exc: + _fit(automl, dataframe=df) + assert "must contain timestamp values" in str(exc.value) + + +def test_timestamp_column_input_is_unchanged(): + df = _make_train_df().reset_index().rename(columns={"index": "ds"}) + automl = AutoML() + _fit(automl, dataframe=df, time_col="ds") + assert automl._state.task.time_col == "ds" + assert len(automl.predict(df[["ds"]].tail(12))) == 12 From 800c05ff07c195a28c4d780fabc9f19815219a4a Mon Sep 17 00:00:00 2001 From: Nefelibata <124799179+MeiSiristhebest@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:17:11 +0800 Subject: [PATCH 02/22] fix(automl): resolve index-name collisions and promote DatetimeIndex in validation and inference --- flaml/automl/task/time_series_task.py | 79 ++++++++++++++++--- .../automl/test_ts_forecast_datetime_index.py | 46 +++++++++++ 2 files changed, 112 insertions(+), 13 deletions(-) diff --git a/flaml/automl/task/time_series_task.py b/flaml/automl/task/time_series_task.py index 0ab1db1df1..63d9413e6e 100644 --- a/flaml/automl/task/time_series_task.py +++ b/flaml/automl/task/time_series_task.py @@ -152,19 +152,13 @@ def validate_data( else: raise ValueError("Must supply either X_train_all and y_train_all, or dataframe and label") - if ( - time_col_inferred - and isinstance(dataframe.index, pd.DatetimeIndex) - and not pd.api.types.is_datetime64_any_dtype(dataframe[self.time_col]) - ): - # The timestamps were passed as the DataFrame index instead of as a column, so the - # inference above picked a value column. Converting that column with - # `pd.to_datetime()` reads its numbers as nanoseconds since the epoch rather than - # failing, which silently replaces the real time axis with 1970-01-01. - index_name = dataframe.index.name or "ds" - dataframe = dataframe.reset_index().rename(columns={"index": index_name}) - logger.info(f"Timestamp column not found, using the DataFrame index as '{index_name}'.") - self.time_col = index_name + dataframe, promoted_time_col = self._promote_datetime_index_if_needed( + dataframe, + time_col=self.time_col, + is_inferred=time_col_inferred, + ) + if promoted_time_col is not None: + self.time_col = promoted_time_col try: dataframe.loc[:, self.time_col] = pd.to_datetime(dataframe[self.time_col]) @@ -177,6 +171,12 @@ def validate_data( if X_val is not None: assert y_val is not None, "If X_val is not None, y_val must also be" + if isinstance(X_val, pd.DataFrame): + X_val, _ = self._promote_datetime_index_if_needed( + X_val, + time_col=self.time_col, + is_inferred=False, + ) val_df = TimeSeriesDataset.to_dataframe(X_val, y_val, target_names, self.time_col) val_len = len(val_df) else: @@ -401,7 +401,60 @@ def _preprocess(self, X, transformer=None): X = transformer.transform(X) return X + @staticmethod + def _promote_datetime_index_if_needed(df, time_col=None, is_inferred=False): + """Promote a DatetimeIndex to a guaranteed-unique column if needed. + + Args: + df: A pandas DataFrame. + time_col: The current timestamp column name (if known). + is_inferred: Whether time_col was inferred rather than explicitly specified. + + Returns: + Tuple of (df_processed, promoted_col_name or None). + """ + if not isinstance(df, pd.DataFrame) or not isinstance(df.index, pd.DatetimeIndex): + return df, None + + # Precondition check: + # 1. If time_col was inferred: promote if df lacks a datetime-typed time_col. + # 2. If time_col was explicit: promote if time_col is missing from df.columns. + needs_promotion = False + if is_inferred: + if time_col is None or time_col not in df.columns or not pd.api.types.is_datetime64_any_dtype(df[time_col]): + needs_promotion = True + else: + if time_col is not None and time_col not in df.columns: + needs_promotion = True + + if not needs_promotion: + return df, None + + base_name = df.index.name or "ds" + promoted_col = base_name if not is_inferred and time_col else base_name + if not is_inferred and time_col: + promoted_col = time_col + + # Guarantee unique column name avoiding collisions with existing columns + target_col = promoted_col + i = 1 + while target_col in df.columns: + target_col = f"{promoted_col}_{i}" + i += 1 + + df_out = df.copy() + df_out.insert(0, target_col, df.index) + df_out = df_out.reset_index(drop=True) + logger.info(f"Timestamp column not found, promoted DataFrame index as '{target_col}'.") + return df_out, target_col + def preprocess(self, X, transformer=None): + if isinstance(X, pd.DataFrame): + X, _ = self._promote_datetime_index_if_needed( + X, + time_col=self.time_col, + is_inferred=False, + ) if isinstance(X, (pd.DataFrame, np.ndarray, pd.Series)): X = normalize_ts_data(X.copy(), self.target_names, self.time_col) return self._preprocess(X, transformer) diff --git a/test/automl/test_ts_forecast_datetime_index.py b/test/automl/test_ts_forecast_datetime_index.py index 371357266e..c5f2279bf0 100644 --- a/test/automl/test_ts_forecast_datetime_index.py +++ b/test/automl/test_ts_forecast_datetime_index.py @@ -80,3 +80,49 @@ def test_timestamp_column_input_is_unchanged(): _fit(automl, dataframe=df, time_col="ds") assert automl._state.task.time_col == "ds" assert len(automl.predict(df[["ds"]].tail(12))) == 12 + + +def test_index_name_collision_resolves_to_unique_column(): + """When dataframe already has a column named 'ds' (or 'index') that is not datetime, + promoting an unnamed DatetimeIndex must not overwrite or conflict with the existing column.""" + df = _make_train_df() + df["ds"] = np.arange(len(df), dtype=float) # Collision candidate + df["ds_1"] = np.arange(len(df), dtype=float) # Double collision candidate + automl = AutoML() + _fit(automl, dataframe=df) + # Target column should be uniquely chosen as ds_2 avoiding collision + assert automl._state.task.time_col == "ds_2" + assert "ds" in automl._state.train_data.columns + assert "ds_1" in automl._state.train_data.columns + assert "ds_2" in automl._state.train_data.columns + assert len(automl._state.train_data) == len(df) + + +def test_validation_data_with_datetime_index(): + """Verify that validation data passed as DataFrame with DatetimeIndex is properly promoted.""" + df_train = _make_train_df(periods=100) + df_val = _make_train_df(periods=20) + df_val.index = pd.date_range("2026-05-01", periods=20, freq="MS") + automl = AutoML() + _fit( + automl, + X_train=df_train, + y_train=df_train["y"], + X_val=df_val, + y_val=df_val["y"], + ) + assert automl._state.task.time_col == "ds" + assert automl._state.X_val is not None + + +def test_predict_with_datetime_index(): + """Verify that future prediction data passed with DatetimeIndex works without explicit time_col.""" + df = _make_train_df(periods=120) + automl = AutoML() + _fit(automl, dataframe=df) + + # Future prediction input using DatetimeIndex + future_index = pd.date_range("2028-01-01", periods=12, freq="MS") + future_df = pd.DataFrame(index=future_index) + preds = automl.predict(future_df) + assert len(preds) == 12 From c92fc5106a873da0b1a7b85398403b502c9ca59e Mon Sep 17 00:00:00 2001 From: Nefelibata <124799179+MeiSiristhebest@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:55:08 +0800 Subject: [PATCH 03/22] fix(automl): preserve aligned indexes for DataFrame targets and fix regression tests --- flaml/automl/task/time_series_task.py | 16 ++++++ flaml/automl/time_series/ts_data.py | 3 + .../automl/test_ts_forecast_datetime_index.py | 57 +++++++++++++++---- 3 files changed, 65 insertions(+), 11 deletions(-) diff --git a/flaml/automl/task/time_series_task.py b/flaml/automl/task/time_series_task.py index 63d9413e6e..d14f7f5848 100644 --- a/flaml/automl/task/time_series_task.py +++ b/flaml/automl/task/time_series_task.py @@ -143,6 +143,18 @@ def validate_data( if X_train_all is not None: assert y_train_all is not None, "If X_train_all is not None, y_train_all must also be" assert dataframe is None, "If X_train_all is provided, dataframe must be None" + if isinstance(X_train_all, pd.DataFrame): + X_train_all, promoted_time_col = self._promote_datetime_index_if_needed( + X_train_all, + time_col=self.time_col, + is_inferred=time_col_inferred, + ) + if promoted_time_col is not None: + self.time_col = promoted_time_col + if isinstance(y_train_all, (pd.DataFrame, pd.Series)) and isinstance(X_train_all, pd.DataFrame): + if not y_train_all.index.equals(X_train_all.index): + y_train_all = y_train_all.copy() + y_train_all.index = X_train_all.index dataframe = TimeSeriesDataset.to_dataframe(X_train_all, y_train_all, target_names, self.time_col) elif dataframe is not None: @@ -177,6 +189,10 @@ def validate_data( time_col=self.time_col, is_inferred=False, ) + if isinstance(y_val, (pd.DataFrame, pd.Series)) and isinstance(X_val, pd.DataFrame): + if not y_val.index.equals(X_val.index): + y_val = y_val.copy() + y_val.index = X_val.index val_df = TimeSeriesDataset.to_dataframe(X_val, y_val, target_names, self.time_col) val_len = len(val_df) else: diff --git a/flaml/automl/time_series/ts_data.py b/flaml/automl/time_series/ts_data.py index 4a650db71c..8831ca4b58 100644 --- a/flaml/automl/time_series/ts_data.py +++ b/flaml/automl/time_series/ts_data.py @@ -549,6 +549,9 @@ def normalize_ts_data(X_train_all, target_names, time_col, y_train_all=None): elif isinstance(y_train_all, pd.Series): y_train_all = pd.DataFrame(y_train_all) y_train_all.index = X_train_all.index + elif isinstance(y_train_all, pd.DataFrame): + y_train_all = y_train_all.copy() + y_train_all.index = X_train_all.index dataframe = pd.concat([X_train_all, y_train_all], axis=1) diff --git a/test/automl/test_ts_forecast_datetime_index.py b/test/automl/test_ts_forecast_datetime_index.py index c5f2279bf0..3a1db1a21a 100644 --- a/test/automl/test_ts_forecast_datetime_index.py +++ b/test/automl/test_ts_forecast_datetime_index.py @@ -92,27 +92,62 @@ def test_index_name_collision_resolves_to_unique_column(): _fit(automl, dataframe=df) # Target column should be uniquely chosen as ds_2 avoiding collision assert automl._state.task.time_col == "ds_2" - assert "ds" in automl._state.train_data.columns - assert "ds_1" in automl._state.train_data.columns - assert "ds_2" in automl._state.train_data.columns - assert len(automl._state.train_data) == len(df) + assert "ds" in automl._feature_names_in_ + assert "ds_1" in automl._feature_names_in_ + assert "ds_2" in automl._feature_names_in_ + assert automl.data_size_full == len(df) def test_validation_data_with_datetime_index(): """Verify that validation data passed as DataFrame with DatetimeIndex is properly promoted.""" df_train = _make_train_df(periods=100) - df_val = _make_train_df(periods=20) - df_val.index = pd.date_range("2026-05-01", periods=20, freq="MS") + val_index = pd.date_range("2026-05-01", periods=20, freq="MS") + df_val = pd.DataFrame({"y": np.sin(np.arange(100, 120) / 6) * 10 + 50}, index=val_index) + X_val = pd.DataFrame(index=val_index) + y_val = df_val["y"] automl = AutoML() _fit( automl, - X_train=df_train, - y_train=df_train["y"], - X_val=df_val, - y_val=df_val["y"], + dataframe=df_train, + X_val=X_val, + y_val=y_val, ) assert automl._state.task.time_col == "ds" - assert automl._state.X_val is not None + assert automl._state.eval_method == "holdout" + assert len(automl.predict(X_val)) == len(val_index) + + +def test_validation_data_with_dataframe_target_and_datetime_index(): + """Verify that validation data with DataFrame y_val and DatetimeIndex preserves aligned indexes.""" + df_train = _make_train_df(periods=100) + val_index = pd.date_range("2026-05-01", periods=20, freq="MS") + df_val = pd.DataFrame({"y": np.sin(np.arange(100, 120) / 6) * 10 + 50}, index=val_index) + X_val = pd.DataFrame(index=val_index) + y_val = df_val[["y"]] # DataFrame target with DatetimeIndex + automl = AutoML() + _fit( + automl, + dataframe=df_train, + X_val=X_val, + y_val=y_val, + ) + assert automl._state.task.time_col == "ds" + assert automl._state.eval_method == "holdout" + assert len(automl.predict(X_val)) == len(val_index) + + +def test_xtrain_ytrain_with_datetime_index(): + """Verify that (X_train, y_train) inputs with DatetimeIndex are promoted and work seamlessly.""" + train_idx = pd.date_range("2018-01-01", periods=100, freq="MS") + val_idx = pd.date_range("2026-05-01", periods=20, freq="MS") + X_train = pd.DataFrame({"feat": np.arange(100, dtype=float)}, index=train_idx) + y_train = pd.Series(np.sin(np.arange(100) / 6) * 10 + 50, index=train_idx, name="y") + X_val = pd.DataFrame({"feat": np.arange(100, 120, dtype=float)}, index=val_idx) + y_val = pd.DataFrame({"y": np.sin(np.arange(100, 120) / 6) * 10 + 50}, index=val_idx) + automl = AutoML() + _fit(automl, X_train=X_train, y_train=y_train, X_val=X_val, y_val=y_val) + assert automl._state.task.time_col == "ds" + assert len(automl.predict(X_val)) == 20 def test_predict_with_datetime_index(): From 7d92b36d0fdfc1074b30899a5d80d2cadbc02346 Mon Sep 17 00:00:00 2001 From: Nefelibata <124799179+MeiSiristhebest@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:57:41 +0800 Subject: [PATCH 04/22] fix(automl): align targets by label before DatetimeIndex promotion and reject mismatched indexes --- flaml/automl/task/time_series_task.py | 40 +++++++++++---- flaml/automl/time_series/ts_data.py | 13 +++-- .../automl/test_ts_forecast_datetime_index.py | 49 +++++++++++++++++++ 3 files changed, 90 insertions(+), 12 deletions(-) diff --git a/flaml/automl/task/time_series_task.py b/flaml/automl/task/time_series_task.py index d14f7f5848..6cd9b2af03 100644 --- a/flaml/automl/task/time_series_task.py +++ b/flaml/automl/task/time_series_task.py @@ -144,6 +144,7 @@ def validate_data( assert y_train_all is not None, "If X_train_all is not None, y_train_all must also be" assert dataframe is None, "If X_train_all is provided, dataframe must be None" if isinstance(X_train_all, pd.DataFrame): + X_train_all, y_train_all = self._align_y_to_X_and_promote(X_train_all, y_train_all) X_train_all, promoted_time_col = self._promote_datetime_index_if_needed( X_train_all, time_col=self.time_col, @@ -151,10 +152,8 @@ def validate_data( ) if promoted_time_col is not None: self.time_col = promoted_time_col - if isinstance(y_train_all, (pd.DataFrame, pd.Series)) and isinstance(X_train_all, pd.DataFrame): - if not y_train_all.index.equals(X_train_all.index): - y_train_all = y_train_all.copy() - y_train_all.index = X_train_all.index + if isinstance(y_train_all, (pd.DataFrame, pd.Series)): + y_train_all = y_train_all.reset_index(drop=True) dataframe = TimeSeriesDataset.to_dataframe(X_train_all, y_train_all, target_names, self.time_col) elif dataframe is not None: @@ -184,15 +183,15 @@ def validate_data( if X_val is not None: assert y_val is not None, "If X_val is not None, y_val must also be" if isinstance(X_val, pd.DataFrame): - X_val, _ = self._promote_datetime_index_if_needed( + X_val, y_val = self._align_y_to_X_and_promote(X_val, y_val) + X_val, promoted_time_col_val = self._promote_datetime_index_if_needed( X_val, time_col=self.time_col, is_inferred=False, ) - if isinstance(y_val, (pd.DataFrame, pd.Series)) and isinstance(X_val, pd.DataFrame): - if not y_val.index.equals(X_val.index): - y_val = y_val.copy() - y_val.index = X_val.index + if promoted_time_col_val is not None: + if isinstance(y_val, (pd.DataFrame, pd.Series)): + y_val = y_val.reset_index(drop=True) val_df = TimeSeriesDataset.to_dataframe(X_val, y_val, target_names, self.time_col) val_len = len(val_df) else: @@ -417,6 +416,29 @@ def _preprocess(self, X, transformer=None): X = transformer.transform(X) return X + @staticmethod + def _align_y_to_X_and_promote(X, y): + """Align y to X by label before index promotion or resetting. + + If X and y are both pandas objects with indexes, align y to X.index by label. + If non-matching labels introduce new missing values, raise ValueError. + If X has a DatetimeIndex that gets promoted and reset to a RangeIndex, + y's index is also reset to RangeIndex to maintain position-level parity. + """ + if not isinstance(X, pd.DataFrame) or not isinstance(y, (pd.DataFrame, pd.Series)): + return X, y + + if not y.index.equals(X.index): + y_aligned = y.reindex(X.index) + # If reindexing introduced NaNs that were not originally present, indexes do not match + orig_nan_count = int(y.isna().sum().sum() if isinstance(y, pd.DataFrame) else y.isna().sum()) + new_nan_count = int(y_aligned.isna().sum().sum() if isinstance(y_aligned, pd.DataFrame) else y_aligned.isna().sum()) + if new_nan_count > orig_nan_count: + raise ValueError("Target index labels do not match feature index labels.") + y = y_aligned + + return X, y + @staticmethod def _promote_datetime_index_if_needed(df, time_col=None, is_inferred=False): """Promote a DatetimeIndex to a guaranteed-unique column if needed. diff --git a/flaml/automl/time_series/ts_data.py b/flaml/automl/time_series/ts_data.py index 8831ca4b58..efb2a99604 100644 --- a/flaml/automl/time_series/ts_data.py +++ b/flaml/automl/time_series/ts_data.py @@ -548,10 +548,17 @@ def normalize_ts_data(X_train_all, target_names, time_col, y_train_all=None): ) elif isinstance(y_train_all, pd.Series): y_train_all = pd.DataFrame(y_train_all) - y_train_all.index = X_train_all.index + if not y_train_all.index.equals(X_train_all.index): + y_aligned = y_train_all.reindex(X_train_all.index) + if y_aligned.isna().sum().sum() > y_train_all.isna().sum().sum(): + raise ValueError("Target index labels do not match feature index labels.") + y_train_all = y_aligned elif isinstance(y_train_all, pd.DataFrame): - y_train_all = y_train_all.copy() - y_train_all.index = X_train_all.index + if not y_train_all.index.equals(X_train_all.index): + y_aligned = y_train_all.reindex(X_train_all.index) + if y_aligned.isna().sum().sum() > y_train_all.isna().sum().sum(): + raise ValueError("Target index labels do not match feature index labels.") + y_train_all = y_aligned dataframe = pd.concat([X_train_all, y_train_all], axis=1) diff --git a/test/automl/test_ts_forecast_datetime_index.py b/test/automl/test_ts_forecast_datetime_index.py index 3a1db1a21a..1b629003da 100644 --- a/test/automl/test_ts_forecast_datetime_index.py +++ b/test/automl/test_ts_forecast_datetime_index.py @@ -161,3 +161,52 @@ def test_predict_with_datetime_index(): future_df = pd.DataFrame(index=future_index) preds = automl.predict(future_df) assert len(preds) == 12 + + +def test_training_with_reordered_dataframe_target_aligns_by_label(): + """Verify that when y_train is passed with shuffled timestamps, values align by label rather than position.""" + train_idx = pd.date_range("2018-01-01", periods=60, freq="MS") + X_train = pd.DataFrame({"feat": np.arange(60, dtype=float)}, index=train_idx) + # Shuffled index for y_train + shuffled_idx = train_idx[::-1] + y_values = np.sin(np.arange(60)[::-1] / 6) * 10 + 50 + y_train = pd.DataFrame({"y": y_values}, index=shuffled_idx) + + automl = AutoML() + _fit(automl, X_train=X_train, y_train=y_train) + + # When y_train is reordered, AutoML must correctly align y by index rather than crashing or distorting data + preds = automl.predict(X_train.tail(12)) + assert len(preds) == 12 + assert automl._state.task.time_col == "ds" + + +def test_validation_with_reordered_dataframe_target_aligns_by_label(): + """Verify that when y_val is passed with shuffled timestamps, values align by label rather than position.""" + df_train = _make_train_df(periods=80) + val_idx = pd.date_range("2024-09-01", periods=20, freq="MS") + X_val = pd.DataFrame(index=val_idx) + # Shuffled index for y_val + shuffled_val_idx = val_idx[::-1] + y_val = pd.DataFrame({"y": (np.arange(20)[::-1] + 100.0)}, index=shuffled_val_idx) + + automl = AutoML() + _fit(automl, dataframe=df_train, X_val=X_val, y_val=y_val) + + # First row of validation data in pre_data must correspond to val_idx[0] + expected_val_first = y_val.loc[val_idx[0], "y"] + preds = automl.predict(X_val) + assert len(preds) == 20 + assert automl._state.eval_method == "holdout" + + +def test_mismatched_target_index_raises_value_error(): + """Verify that passing targets with non-matching labels raises ValueError instead of silent corruption.""" + train_idx = pd.date_range("2018-01-01", periods=60, freq="MS") + mismatched_idx = pd.date_range("2019-01-01", periods=60, freq="MS") + X_train = pd.DataFrame({"feat": np.arange(60, dtype=float)}, index=train_idx) + y_train = pd.DataFrame({"y": np.arange(60, dtype=float)}, index=mismatched_idx) + + automl = AutoML() + with pytest.raises(ValueError, match="Target index labels do not match feature index labels"): + _fit(automl, X_train=X_train, y_train=y_train) From 26654d30feaa7cac6786fac30362de7394cfac99 Mon Sep 17 00:00:00 2001 From: Nefelibata <124799179+MeiSiristhebest@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:08:30 +0800 Subject: [PATCH 05/22] style: apply black and fix ruff F841 in test --- flaml/automl/task/time_series_task.py | 12 ++++++++---- test/automl/test_ts_forecast_datetime_index.py | 2 -- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/flaml/automl/task/time_series_task.py b/flaml/automl/task/time_series_task.py index 6cd9b2af03..ecbfb59b1c 100644 --- a/flaml/automl/task/time_series_task.py +++ b/flaml/automl/task/time_series_task.py @@ -397,9 +397,11 @@ def _preprocess(self, X, transformer=None): X = pd.DataFrame( dict( [ - (transformer._str_columns[idx], X[idx]) - if isinstance(X[0], List) - else (transformer._str_columns[idx], [X[idx]]) + ( + (transformer._str_columns[idx], X[idx]) + if isinstance(X[0], List) + else (transformer._str_columns[idx], [X[idx]]) + ) for idx in range(len(X)) ] ) @@ -432,7 +434,9 @@ def _align_y_to_X_and_promote(X, y): y_aligned = y.reindex(X.index) # If reindexing introduced NaNs that were not originally present, indexes do not match orig_nan_count = int(y.isna().sum().sum() if isinstance(y, pd.DataFrame) else y.isna().sum()) - new_nan_count = int(y_aligned.isna().sum().sum() if isinstance(y_aligned, pd.DataFrame) else y_aligned.isna().sum()) + new_nan_count = int( + y_aligned.isna().sum().sum() if isinstance(y_aligned, pd.DataFrame) else y_aligned.isna().sum() + ) if new_nan_count > orig_nan_count: raise ValueError("Target index labels do not match feature index labels.") y = y_aligned diff --git a/test/automl/test_ts_forecast_datetime_index.py b/test/automl/test_ts_forecast_datetime_index.py index 1b629003da..1fd9e77799 100644 --- a/test/automl/test_ts_forecast_datetime_index.py +++ b/test/automl/test_ts_forecast_datetime_index.py @@ -193,8 +193,6 @@ def test_validation_with_reordered_dataframe_target_aligns_by_label(): automl = AutoML() _fit(automl, dataframe=df_train, X_val=X_val, y_val=y_val) - # First row of validation data in pre_data must correspond to val_idx[0] - expected_val_first = y_val.loc[val_idx[0], "y"] preds = automl.predict(X_val) assert len(preds) == 20 assert automl._state.eval_method == "holdout" From 5c5a0230df10dac8e2b09426e7d0f26340df2128 Mon Sep 17 00:00:00 2001 From: Nefelibata <124799179+MeiSiristhebest@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:35:37 +0800 Subject: [PATCH 06/22] fix(automl): support positional Series targets with DatetimeIndex features Preserve the positional Series contract when X has a DatetimeIndex and y has a RangeIndex (or non-DatetimeIndex Series), while retaining label alignment for targets with DatetimeIndex. Add regressions for training and validation. --- flaml/automl/task/time_series_task.py | 21 ++++-- flaml/automl/time_series/ts_data.py | 25 ++++++-- .../automl/test_ts_forecast_datetime_index.py | 64 +++++++++++++++++++ 3 files changed, 99 insertions(+), 11 deletions(-) diff --git a/flaml/automl/task/time_series_task.py b/flaml/automl/task/time_series_task.py index ecbfb59b1c..e9e278dbf1 100644 --- a/flaml/automl/task/time_series_task.py +++ b/flaml/automl/task/time_series_task.py @@ -422,14 +422,27 @@ def _preprocess(self, X, transformer=None): def _align_y_to_X_and_promote(X, y): """Align y to X by label before index promotion or resetting. - If X and y are both pandas objects with indexes, align y to X.index by label. - If non-matching labels introduce new missing values, raise ValueError. - If X has a DatetimeIndex that gets promoted and reset to a RangeIndex, - y's index is also reset to RangeIndex to maintain position-level parity. + If X has a DatetimeIndex and y has a positional RangeIndex (or non-DatetimeIndex + Series), preserve positional pairing by assigning X.index to y. + Otherwise, if y has a DatetimeIndex or both are pandas objects with indexes, + align y to X.index by label. If non-matching labels introduce new missing + values, raise ValueError. """ if not isinstance(X, pd.DataFrame) or not isinstance(y, (pd.DataFrame, pd.Series)): return X, y + if isinstance(X.index, pd.DatetimeIndex): + if isinstance(y, pd.Series) and not isinstance(y.index, pd.DatetimeIndex): + if len(y) == len(X): + y = y.copy() + y.index = X.index + return X, y + elif isinstance(y, pd.DataFrame) and isinstance(y.index, pd.RangeIndex): + if len(y) == len(X): + y = y.copy() + y.index = X.index + return X, y + if not y.index.equals(X.index): y_aligned = y.reindex(X.index) # If reindexing introduced NaNs that were not originally present, indexes do not match diff --git a/flaml/automl/time_series/ts_data.py b/flaml/automl/time_series/ts_data.py index efb2a99604..84fe583fc1 100644 --- a/flaml/automl/time_series/ts_data.py +++ b/flaml/automl/time_series/ts_data.py @@ -547,14 +547,25 @@ def normalize_ts_data(X_train_all, target_names, time_col, y_train_all=None): index=X_train_all.index, ) elif isinstance(y_train_all, pd.Series): - y_train_all = pd.DataFrame(y_train_all) - if not y_train_all.index.equals(X_train_all.index): - y_aligned = y_train_all.reindex(X_train_all.index) - if y_aligned.isna().sum().sum() > y_train_all.isna().sum().sum(): - raise ValueError("Target index labels do not match feature index labels.") - y_train_all = y_aligned + if isinstance(y_train_all.index, pd.DatetimeIndex): + if not y_train_all.index.equals(X_train_all.index): + y_aligned = y_train_all.reindex(X_train_all.index) + if y_aligned.isna().sum() > y_train_all.isna().sum(): + raise ValueError("Target index labels do not match feature index labels.") + y_train_all = y_aligned + y_train_all = pd.DataFrame(y_train_all) + else: + y_train_all = pd.DataFrame(y_train_all) + y_train_all.index = X_train_all.index elif isinstance(y_train_all, pd.DataFrame): - if not y_train_all.index.equals(X_train_all.index): + if ( + isinstance(X_train_all.index, pd.DatetimeIndex) + and isinstance(y_train_all.index, pd.RangeIndex) + and len(y_train_all) == len(X_train_all) + ): + y_train_all = y_train_all.copy() + y_train_all.index = X_train_all.index + elif not y_train_all.index.equals(X_train_all.index): y_aligned = y_train_all.reindex(X_train_all.index) if y_aligned.isna().sum().sum() > y_train_all.isna().sum().sum(): raise ValueError("Target index labels do not match feature index labels.") diff --git a/test/automl/test_ts_forecast_datetime_index.py b/test/automl/test_ts_forecast_datetime_index.py index 1fd9e77799..6bc23b98c9 100644 --- a/test/automl/test_ts_forecast_datetime_index.py +++ b/test/automl/test_ts_forecast_datetime_index.py @@ -208,3 +208,67 @@ def test_mismatched_target_index_raises_value_error(): automl = AutoML() with pytest.raises(ValueError, match="Target index labels do not match feature index labels"): _fit(automl, X_train=X_train, y_train=y_train) + + +def test_training_with_range_index_series_target(): + """Verify positional pairing when X_train has DatetimeIndex and y_train is a RangeIndex Series.""" + train_idx = pd.date_range("2018-01-01", periods=60, freq="MS") + X_train = pd.DataFrame({"feat": np.arange(60, dtype=float)}, index=train_idx) + y_train = pd.Series(np.sin(np.arange(60) / 6) * 10 + 50, name="y") # Default RangeIndex + + automl = AutoML() + _fit(automl, X_train=X_train, y_train=y_train) + + assert automl._state.task.time_col == "ds" + assert automl._state.data_size[0] == len(X_train) + preds = automl.predict(X_train.tail(12)) + assert len(preds) == 12 + + +def test_validation_with_range_index_series_target(): + """Verify positional pairing when validation data has DatetimeIndex X_val and RangeIndex Series y_val.""" + df_train = _make_train_df(periods=80) + val_idx = pd.date_range("2024-09-01", periods=20, freq="MS") + X_val = pd.DataFrame({"feat": np.arange(80, 100, dtype=float)}, index=val_idx) + y_val = pd.Series(np.sin(np.arange(80, 100) / 6) * 10 + 50, name="y") # Default RangeIndex + + automl = AutoML() + _fit(automl, dataframe=df_train, X_val=X_val, y_val=y_val) + + assert automl._state.eval_method == "holdout" + preds = automl.predict(X_val) + assert len(preds) == 20 + + +def test_training_and_validation_with_range_index_series_targets(): + """Verify positional pairing when both training and validation targets are RangeIndex Series.""" + train_idx = pd.date_range("2018-01-01", periods=80, freq="MS") + val_idx = pd.date_range("2024-09-01", periods=20, freq="MS") + X_train = pd.DataFrame({"feat": np.arange(80, dtype=float)}, index=train_idx) + y_train = pd.Series(np.sin(np.arange(80) / 6) * 10 + 50, name="y") + X_val = pd.DataFrame({"feat": np.arange(80, 100, dtype=float)}, index=val_idx) + y_val = pd.Series(np.sin(np.arange(80, 100) / 6) * 10 + 50, name="y") + + automl = AutoML() + _fit(automl, X_train=X_train, y_train=y_train, X_val=X_val, y_val=y_val) + + assert automl._state.task.time_col == "ds" + assert automl._state.eval_method == "holdout" + preds = automl.predict(X_val) + assert len(preds) == 20 + + +def test_training_with_reordered_series_target_aligns_by_label(): + """Verify that when y_train is a Series with shuffled timestamps, values align by label.""" + train_idx = pd.date_range("2018-01-01", periods=60, freq="MS") + X_train = pd.DataFrame({"feat": np.arange(60, dtype=float)}, index=train_idx) + shuffled_idx = train_idx[::-1] + y_values = np.sin(np.arange(60)[::-1] / 6) * 10 + 50 + y_train = pd.Series(y_values, index=shuffled_idx, name="y") + + automl = AutoML() + _fit(automl, X_train=X_train, y_train=y_train) + + preds = automl.predict(X_train.tail(12)) + assert len(preds) == 12 + assert automl._state.task.time_col == "ds" From 11fcf86c8bd61c842a84b0a754a407792a8469cb Mon Sep 17 00:00:00 2001 From: Nefelibata <124799179+MeiSiristhebest@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:14:56 +0800 Subject: [PATCH 07/22] fix(automl): align datetime labels only when features also have DatetimeIndex Preserve positional Series contract when X has a RangeIndex (e.g. timestamps in a column or ndarray) and y has a DatetimeIndex. Only perform datetime-label alignment when both X and y have a DatetimeIndex. Add training and validation regressions for RangeIndex X with a datetime column and DatetimeIndex y. --- flaml/automl/task/time_series_task.py | 57 ++++++++++++------- flaml/automl/time_series/ts_data.py | 10 +++- .../automl/test_ts_forecast_datetime_index.py | 32 +++++++++++ 3 files changed, 75 insertions(+), 24 deletions(-) diff --git a/flaml/automl/task/time_series_task.py b/flaml/automl/task/time_series_task.py index e9e278dbf1..547110f581 100644 --- a/flaml/automl/task/time_series_task.py +++ b/flaml/automl/task/time_series_task.py @@ -422,37 +422,50 @@ def _preprocess(self, X, transformer=None): def _align_y_to_X_and_promote(X, y): """Align y to X by label before index promotion or resetting. - If X has a DatetimeIndex and y has a positional RangeIndex (or non-DatetimeIndex - Series), preserve positional pairing by assigning X.index to y. - Otherwise, if y has a DatetimeIndex or both are pandas objects with indexes, - align y to X.index by label. If non-matching labels introduce new missing + Only perform datetime-label alignment when X itself has a matching DatetimeIndex + and y also has a DatetimeIndex. If non-matching labels introduce new missing values, raise ValueError. + Otherwise (e.g. X has a DatetimeIndex and y has a RangeIndex, or X has a + RangeIndex and y is a Series with DatetimeIndex or RangeIndex), preserve + the positional Series contract by assigning X.index to y. """ if not isinstance(X, pd.DataFrame) or not isinstance(y, (pd.DataFrame, pd.Series)): return X, y - if isinstance(X.index, pd.DatetimeIndex): - if isinstance(y, pd.Series) and not isinstance(y.index, pd.DatetimeIndex): - if len(y) == len(X): - y = y.copy() - y.index = X.index - return X, y - elif isinstance(y, pd.DataFrame) and isinstance(y.index, pd.RangeIndex): + # Only perform datetime-label alignment when both X and y have a DatetimeIndex + if isinstance(X.index, pd.DatetimeIndex) and isinstance(y.index, pd.DatetimeIndex): + if not y.index.equals(X.index): + y_aligned = y.reindex(X.index) + orig_nan_count = int(y.isna().sum().sum() if isinstance(y, pd.DataFrame) else y.isna().sum()) + new_nan_count = int( + y_aligned.isna().sum().sum() if isinstance(y_aligned, pd.DataFrame) else y_aligned.isna().sum() + ) + if new_nan_count > orig_nan_count: + raise ValueError("Target index labels do not match feature index labels.") + y = y_aligned + return X, y + + # Positional pairing: when X has DatetimeIndex and y is a Series without DatetimeIndex (or RangeIndex DataFrame), + # or when X has RangeIndex and y is a Series (even if y has a DatetimeIndex). + if isinstance(y, pd.Series): + if len(y) == len(X): + y = y.copy() + y.index = X.index + return X, y + elif isinstance(y, pd.DataFrame): + if isinstance(X.index, pd.DatetimeIndex) and isinstance(y.index, pd.RangeIndex): if len(y) == len(X): y = y.copy() y.index = X.index return X, y - - if not y.index.equals(X.index): - y_aligned = y.reindex(X.index) - # If reindexing introduced NaNs that were not originally present, indexes do not match - orig_nan_count = int(y.isna().sum().sum() if isinstance(y, pd.DataFrame) else y.isna().sum()) - new_nan_count = int( - y_aligned.isna().sum().sum() if isinstance(y_aligned, pd.DataFrame) else y_aligned.isna().sum() - ) - if new_nan_count > orig_nan_count: - raise ValueError("Target index labels do not match feature index labels.") - y = y_aligned + elif not y.index.equals(X.index): + # When neither is DatetimeIndex, standard label alignment if indexes differ + y_aligned = y.reindex(X.index) + orig_nan_count = int(y.isna().sum().sum()) + new_nan_count = int(y_aligned.isna().sum().sum()) + if new_nan_count > orig_nan_count: + raise ValueError("Target index labels do not match feature index labels.") + y = y_aligned return X, y diff --git a/flaml/automl/time_series/ts_data.py b/flaml/automl/time_series/ts_data.py index 84fe583fc1..2c5f1bf399 100644 --- a/flaml/automl/time_series/ts_data.py +++ b/flaml/automl/time_series/ts_data.py @@ -547,7 +547,7 @@ def normalize_ts_data(X_train_all, target_names, time_col, y_train_all=None): index=X_train_all.index, ) elif isinstance(y_train_all, pd.Series): - if isinstance(y_train_all.index, pd.DatetimeIndex): + if isinstance(X_train_all.index, pd.DatetimeIndex) and isinstance(y_train_all.index, pd.DatetimeIndex): if not y_train_all.index.equals(X_train_all.index): y_aligned = y_train_all.reindex(X_train_all.index) if y_aligned.isna().sum() > y_train_all.isna().sum(): @@ -558,7 +558,13 @@ def normalize_ts_data(X_train_all, target_names, time_col, y_train_all=None): y_train_all = pd.DataFrame(y_train_all) y_train_all.index = X_train_all.index elif isinstance(y_train_all, pd.DataFrame): - if ( + if isinstance(X_train_all.index, pd.DatetimeIndex) and isinstance(y_train_all.index, pd.DatetimeIndex): + if not y_train_all.index.equals(X_train_all.index): + y_aligned = y_train_all.reindex(X_train_all.index) + if y_aligned.isna().sum().sum() > y_train_all.isna().sum().sum(): + raise ValueError("Target index labels do not match feature index labels.") + y_train_all = y_aligned + elif ( isinstance(X_train_all.index, pd.DatetimeIndex) and isinstance(y_train_all.index, pd.RangeIndex) and len(y_train_all) == len(X_train_all) diff --git a/test/automl/test_ts_forecast_datetime_index.py b/test/automl/test_ts_forecast_datetime_index.py index 6bc23b98c9..97590c6ba0 100644 --- a/test/automl/test_ts_forecast_datetime_index.py +++ b/test/automl/test_ts_forecast_datetime_index.py @@ -272,3 +272,35 @@ def test_training_with_reordered_series_target_aligns_by_label(): preds = automl.predict(X_train.tail(12)) assert len(preds) == 12 assert automl._state.task.time_col == "ds" + + +def test_training_with_range_index_x_datetime_col_and_datetime_index_series_target(): + """Verify positional pairing when X_train is RangeIndex with a datetime column and y_train is DatetimeIndex Series.""" + dates = pd.date_range("2020-01-01", periods=60, freq="MS") + X_train = pd.DataFrame({"ds": dates, "feat": np.arange(60, dtype=float)}) + y_train = pd.Series(np.sin(np.arange(60) / 6) * 10 + 50, index=dates, name="y") + + automl = AutoML() + _fit(automl, X_train=X_train, y_train=y_train, time_col="ds") + + assert automl._state.task.time_col == "ds" + assert automl._state.data_size[0] == len(X_train) + preds = automl.predict(X_train.tail(12)) + assert len(preds) == 12 + + +def test_validation_with_range_index_x_datetime_col_and_datetime_index_series_target(): + """Verify positional pairing when X_val is RangeIndex with a datetime column and y_val is DatetimeIndex Series.""" + train_dates = pd.date_range("2018-01-01", periods=80, freq="MS") + val_dates = pd.date_range("2024-09-01", periods=20, freq="MS") + X_train = pd.DataFrame({"ds": train_dates, "feat": np.arange(80, dtype=float)}) + y_train = pd.Series(np.sin(np.arange(80) / 6) * 10 + 50, name="y") + X_val = pd.DataFrame({"ds": val_dates, "feat": np.arange(80, 100, dtype=float)}) + y_val = pd.Series(np.sin(np.arange(80, 100) / 6) * 10 + 50, index=val_dates, name="y") + + automl = AutoML() + _fit(automl, X_train=X_train, y_train=y_train, X_val=X_val, y_val=y_val, time_col="ds") + + assert automl._state.eval_method == "holdout" + preds = automl.predict(X_val) + assert len(preds) == 20 From 0d533854985cbe278d4fde0d6675b25b93e72e67 Mon Sep 17 00:00:00 2001 From: Nefelibata <124799179+MeiSiristhebest@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:01:50 +0800 Subject: [PATCH 08/22] fix(automl): handle index-only training, length mismatch, and target name collisions --- flaml/automl/task/time_series_task.py | 42 +++++++++----- flaml/automl/time_series/ts_data.py | 4 ++ .../automl/test_ts_forecast_datetime_index.py | 57 +++++++++++++++++++ 3 files changed, 89 insertions(+), 14 deletions(-) diff --git a/flaml/automl/task/time_series_task.py b/flaml/automl/task/time_series_task.py index 547110f581..ce4cf4df85 100644 --- a/flaml/automl/task/time_series_task.py +++ b/flaml/automl/task/time_series_task.py @@ -131,10 +131,12 @@ def validate_data( time_col_inferred = True if isinstance(X_train_all, pd.DataFrame): assert dataframe is None, "One of dataframe and X arguments must be None" - self.time_col = X_train_all.columns[0] + if len(X_train_all.columns) > 0: + self.time_col = X_train_all.columns[0] elif dataframe is not None: assert X_train_all is None, "One of dataframe and X arguments must be None" - self.time_col = dataframe.columns[0] + if len(dataframe.columns) > 0: + self.time_col = dataframe.columns[0] else: self.time_col = "ds" @@ -149,6 +151,7 @@ def validate_data( X_train_all, time_col=self.time_col, is_inferred=time_col_inferred, + reserved_cols=target_names, ) if promoted_time_col is not None: self.time_col = promoted_time_col @@ -167,10 +170,14 @@ def validate_data( dataframe, time_col=self.time_col, is_inferred=time_col_inferred, + reserved_cols=target_names, ) if promoted_time_col is not None: self.time_col = promoted_time_col + if self.time_col is None: + self.time_col = "ds" + try: dataframe.loc[:, self.time_col] = pd.to_datetime(dataframe[self.time_col]) except Exception: @@ -188,6 +195,7 @@ def validate_data( X_val, time_col=self.time_col, is_inferred=False, + reserved_cols=target_names, ) if promoted_time_col_val is not None: if isinstance(y_val, (pd.DataFrame, pd.Series)): @@ -432,6 +440,9 @@ def _align_y_to_X_and_promote(X, y): if not isinstance(X, pd.DataFrame) or not isinstance(y, (pd.DataFrame, pd.Series)): return X, y + if len(X) != len(y): + raise ValueError(f"Target length ({len(y)}) does not match feature length ({len(X)}).") + # Only perform datetime-label alignment when both X and y have a DatetimeIndex if isinstance(X.index, pd.DatetimeIndex) and isinstance(y.index, pd.DatetimeIndex): if not y.index.equals(X.index): @@ -470,13 +481,14 @@ def _align_y_to_X_and_promote(X, y): return X, y @staticmethod - def _promote_datetime_index_if_needed(df, time_col=None, is_inferred=False): + def _promote_datetime_index_if_needed(df, time_col=None, is_inferred=False, reserved_cols=None): """Promote a DatetimeIndex to a guaranteed-unique column if needed. Args: df: A pandas DataFrame. time_col: The current timestamp column name (if known). is_inferred: Whether time_col was inferred rather than explicitly specified. + reserved_cols: Additional column names to avoid colliding with (e.g. target names). Returns: Tuple of (df_processed, promoted_col_name or None). @@ -498,17 +510,18 @@ def _promote_datetime_index_if_needed(df, time_col=None, is_inferred=False): if not needs_promotion: return df, None - base_name = df.index.name or "ds" - promoted_col = base_name if not is_inferred and time_col else base_name - if not is_inferred and time_col: - promoted_col = time_col - - # Guarantee unique column name avoiding collisions with existing columns - target_col = promoted_col - i = 1 - while target_col in df.columns: - target_col = f"{promoted_col}_{i}" - i += 1 + if is_inferred: + base_name = df.index.name or "ds" + promoted_col = base_name + # Guarantee unique column name avoiding collisions with existing columns and reserved names + reserved = set(reserved_cols) if reserved_cols else set() + target_col = promoted_col + i = 1 + while target_col in df.columns or target_col in reserved: + target_col = f"{promoted_col}_{i}" + i += 1 + else: + target_col = time_col df_out = df.copy() df_out.insert(0, target_col, df.index) @@ -522,6 +535,7 @@ def preprocess(self, X, transformer=None): X, time_col=self.time_col, is_inferred=False, + reserved_cols=self.target_names, ) if isinstance(X, (pd.DataFrame, np.ndarray, pd.Series)): X = normalize_ts_data(X.copy(), self.target_names, self.time_col) diff --git a/flaml/automl/time_series/ts_data.py b/flaml/automl/time_series/ts_data.py index 4cae28cd7a..f8df6832b7 100644 --- a/flaml/automl/time_series/ts_data.py +++ b/flaml/automl/time_series/ts_data.py @@ -539,6 +539,10 @@ def normalize_ts_data(X_train_all, target_names, time_col, y_train_all=None): if y_train_all is None: return X_train_all else: + if len(y_train_all) != len(X_train_all): + raise ValueError( + f"Target length ({len(y_train_all)}) does not match feature length ({len(X_train_all)})." + ) if isinstance(y_train_all, np.ndarray): # TODO: will need to revisit this when doing multivariate y y_train_all = pd.DataFrame( diff --git a/test/automl/test_ts_forecast_datetime_index.py b/test/automl/test_ts_forecast_datetime_index.py index 97590c6ba0..0157c579f3 100644 --- a/test/automl/test_ts_forecast_datetime_index.py +++ b/test/automl/test_ts_forecast_datetime_index.py @@ -304,3 +304,60 @@ def test_validation_with_range_index_x_datetime_col_and_datetime_index_series_ta assert automl._state.eval_method == "holdout" preds = automl.predict(X_val) assert len(preds) == 20 + + +def test_index_only_training_input_with_separate_target(): + """Verify that X_train with 0 feature columns and DatetimeIndex promotes correctly without IndexError.""" + dates = pd.date_range("2018-01-01", periods=120, freq="MS") + X_train = pd.DataFrame(index=dates) + y_train = pd.Series(np.sin(np.arange(120) / 6) * 10 + 50, index=dates, name="y") + automl = AutoML() + _fit(automl, X_train=X_train, y_train=y_train) + assert automl._state.task.time_col == "ds" + assert len(automl.predict(X_train.tail(12))) == 12 + + +def test_length_mismatch_raises_value_error(): + """Verify that when X has fewer rows than y, extra rows in y are NOT silently truncated.""" + train_idx = pd.date_range("2018-01-01", periods=60, freq="MS") + extra_idx = pd.date_range("2018-01-01", periods=61, freq="MS") + X_train = pd.DataFrame({"feat": np.arange(60, dtype=float)}, index=train_idx) + y_train = pd.Series(np.arange(61, dtype=float), index=extra_idx, name="y") + automl = AutoML() + with pytest.raises(ValueError, match="does not match feature length"): + _fit(automl, X_train=X_train, y_train=y_train) + + +def test_validation_length_mismatch_raises_value_error(): + """Verify that validation data with mismatched X_val and y_val raises ValueError.""" + df_train = _make_train_df(periods=80) + val_idx = pd.date_range("2024-09-01", periods=20, freq="MS") + val_extra_idx = pd.date_range("2024-09-01", periods=21, freq="MS") + X_val = pd.DataFrame(index=val_idx) + y_val = pd.Series(np.arange(21, dtype=float), index=val_extra_idx, name="y") + automl = AutoML() + with pytest.raises(ValueError, match="does not match feature length"): + _fit(automl, dataframe=df_train, X_val=X_val, y_val=y_val) + + +def test_unnamed_datetime_index_colliding_with_target_named_ds(): + """Verify that when target is named 'ds', an unnamed index promotes to 'ds_1' to avoid duplicate columns.""" + dates = pd.date_range("2018-01-01", periods=120, freq="MS") + X_train = pd.DataFrame({"feat": np.arange(120, dtype=float)}, index=dates) + y_train = pd.Series(np.sin(np.arange(120) / 6) * 10 + 50, index=dates, name="ds") + automl = AutoML() + _fit(automl, X_train=X_train, y_train=y_train, label="ds") + assert automl._state.task.time_col == "ds_1" + assert len(automl.predict(X_train.tail(12))) == 12 + + +def test_named_datetime_index_colliding_with_target_named_y(): + """Verify that when index and target are both named 'y', index promotes to 'y_1' to avoid duplicate columns.""" + dates = pd.date_range("2018-01-01", periods=120, freq="MS") + dates.name = "y" + X_train = pd.DataFrame({"feat": np.arange(120, dtype=float)}, index=dates) + y_train = pd.Series(np.sin(np.arange(120) / 6) * 10 + 50, index=dates, name="y") + automl = AutoML() + _fit(automl, X_train=X_train, y_train=y_train, label="y") + assert automl._state.task.time_col == "y_1" + assert len(automl.predict(X_train.tail(12))) == 12 From 456521dcfede7d39e9eda4c80446ae5b619ef5cb Mon Sep 17 00:00:00 2001 From: Nefelibata <124799179+MeiSiristhebest@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:12:12 +0800 Subject: [PATCH 09/22] style: apply black formatting to ts_data.py --- flaml/automl/time_series/ts_data.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/flaml/automl/time_series/ts_data.py b/flaml/automl/time_series/ts_data.py index f8df6832b7..f1de5cb71d 100644 --- a/flaml/automl/time_series/ts_data.py +++ b/flaml/automl/time_series/ts_data.py @@ -540,9 +540,7 @@ def normalize_ts_data(X_train_all, target_names, time_col, y_train_all=None): return X_train_all else: if len(y_train_all) != len(X_train_all): - raise ValueError( - f"Target length ({len(y_train_all)}) does not match feature length ({len(X_train_all)})." - ) + raise ValueError(f"Target length ({len(y_train_all)}) does not match feature length ({len(X_train_all)}).") if isinstance(y_train_all, np.ndarray): # TODO: will need to revisit this when doing multivariate y y_train_all = pd.DataFrame( From 3d36d21742002c55988aef714a848fdeabc785c9 Mon Sep 17 00:00:00 2001 From: Nefelibata <124799179+MeiSiristhebest@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:03:20 +0800 Subject: [PATCH 10/22] fix(automl): align time series inputs at boundary and preserve positional target contract - Revert duplicate target alignment in normalize_ts_data to preserve positional pairing - Restrict label alignment in TimeSeriesTask to when DatetimeIndex promotion is needed - Replace flawed NaN-count validation with direct index difference validation before reindexing - Preserve valid date-like strings in timestamp column over incidental DatetimeIndex - Restore caller's original index during prediction preprocessing to prevent assignment NaNs - Add differential unit tests for index preservation, label matching, and date strings --- flaml/automl/task/time_series_task.py | 205 +++++++++++------- flaml/automl/time_series/ts_data.py | 32 +-- .../automl/test_ts_forecast_datetime_index.py | 100 +++++++++ 3 files changed, 231 insertions(+), 106 deletions(-) diff --git a/flaml/automl/task/time_series_task.py b/flaml/automl/task/time_series_task.py index ce4cf4df85..355e6c5c7b 100644 --- a/flaml/automl/task/time_series_task.py +++ b/flaml/automl/task/time_series_task.py @@ -1,3 +1,4 @@ +import datetime import logging import time from typing import List @@ -146,40 +147,55 @@ def validate_data( assert y_train_all is not None, "If X_train_all is not None, y_train_all must also be" assert dataframe is None, "If X_train_all is provided, dataframe must be None" if isinstance(X_train_all, pd.DataFrame): - X_train_all, y_train_all = self._align_y_to_X_and_promote(X_train_all, y_train_all) - X_train_all, promoted_time_col = self._promote_datetime_index_if_needed( + needs_promotion = self._needs_datetime_index_promotion( X_train_all, time_col=self.time_col, is_inferred=time_col_inferred, - reserved_cols=target_names, ) - if promoted_time_col is not None: - self.time_col = promoted_time_col - if isinstance(y_train_all, (pd.DataFrame, pd.Series)): - y_train_all = y_train_all.reset_index(drop=True) + X_train_all, y_train_all = self._align_y_to_X_and_promote( + X_train_all, + y_train_all, + needs_promotion=needs_promotion, + ) + if needs_promotion: + X_train_all, promoted_time_col = self._promote_datetime_index_if_needed( + X_train_all, + time_col=self.time_col, + is_inferred=time_col_inferred, + reserved_cols=target_names, + ) + if promoted_time_col is not None: + self.time_col = promoted_time_col + if isinstance(y_train_all, (pd.DataFrame, pd.Series)): + y_train_all = y_train_all.reset_index(drop=True) dataframe = TimeSeriesDataset.to_dataframe(X_train_all, y_train_all, target_names, self.time_col) elif dataframe is not None: assert label is not None, "A label or list of labels must be provided." assert isinstance(dataframe, pd.DataFrame), "dataframe must be a pandas DataFrame" assert label in dataframe.columns, f"{label} must a column name in dataframe" + needs_promotion = self._needs_datetime_index_promotion( + dataframe, + time_col=self.time_col, + is_inferred=time_col_inferred, + ) + if needs_promotion: + dataframe, promoted_time_col = self._promote_datetime_index_if_needed( + dataframe, + time_col=self.time_col, + is_inferred=time_col_inferred, + reserved_cols=target_names, + ) + if promoted_time_col is not None: + self.time_col = promoted_time_col else: raise ValueError("Must supply either X_train_all and y_train_all, or dataframe and label") - dataframe, promoted_time_col = self._promote_datetime_index_if_needed( - dataframe, - time_col=self.time_col, - is_inferred=time_col_inferred, - reserved_cols=target_names, - ) - if promoted_time_col is not None: - self.time_col = promoted_time_col - if self.time_col is None: self.time_col = "ds" try: - dataframe.loc[:, self.time_col] = pd.to_datetime(dataframe[self.time_col]) + dataframe[self.time_col] = pd.to_datetime(dataframe[self.time_col]) except Exception: raise ValueError( f"For '{TS_FORECAST}' task, time column {self.time_col} must contain timestamp values." @@ -190,16 +206,26 @@ def validate_data( if X_val is not None: assert y_val is not None, "If X_val is not None, y_val must also be" if isinstance(X_val, pd.DataFrame): - X_val, y_val = self._align_y_to_X_and_promote(X_val, y_val) - X_val, promoted_time_col_val = self._promote_datetime_index_if_needed( + needs_promotion_val = self._needs_datetime_index_promotion( X_val, time_col=self.time_col, is_inferred=False, - reserved_cols=target_names, ) - if promoted_time_col_val is not None: - if isinstance(y_val, (pd.DataFrame, pd.Series)): - y_val = y_val.reset_index(drop=True) + X_val, y_val = self._align_y_to_X_and_promote( + X_val, + y_val, + needs_promotion=needs_promotion_val, + ) + if needs_promotion_val: + X_val, promoted_time_col_val = self._promote_datetime_index_if_needed( + X_val, + time_col=self.time_col, + is_inferred=False, + reserved_cols=target_names, + ) + if promoted_time_col_val is not None: + if isinstance(y_val, (pd.DataFrame, pd.Series)): + y_val = y_val.reset_index(drop=True) val_df = TimeSeriesDataset.to_dataframe(X_val, y_val, target_names, self.time_col) val_len = len(val_df) else: @@ -427,15 +453,55 @@ def _preprocess(self, X, transformer=None): return X @staticmethod - def _align_y_to_X_and_promote(X, y): - """Align y to X by label before index promotion or resetting. - - Only perform datetime-label alignment when X itself has a matching DatetimeIndex - and y also has a DatetimeIndex. If non-matching labels introduce new missing - values, raise ValueError. - Otherwise (e.g. X has a DatetimeIndex and y has a RangeIndex, or X has a - RangeIndex and y is a Series with DatetimeIndex or RangeIndex), preserve - the positional Series contract by assigning X.index to y. + def _is_valid_timestamp_col(df, col): + """Check if col in df is a valid datetime-like column without numeric nanosecond coercion.""" + if col is None or not isinstance(df, pd.DataFrame) or col not in df.columns: + return False + series = df[col] + if pd.api.types.is_datetime64_any_dtype(series): + return True + if pd.api.types.is_numeric_dtype(series) or pd.api.types.is_bool_dtype(series): + return False + non_null = series.dropna() + if non_null.empty: + return False + first_val = non_null.iloc[0] + if not isinstance(first_val, (str, datetime.date, datetime.datetime, pd.Timestamp, np.datetime64)): + return False + try: + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + converted = pd.to_datetime(series, errors="raise") + return not converted.isna().all() + except Exception: + return False + + @classmethod + def _needs_datetime_index_promotion(cls, df, time_col=None, is_inferred=False): + """Determine whether df's DatetimeIndex needs to be promoted to a column.""" + if not isinstance(df, pd.DataFrame) or not isinstance(df.index, pd.DatetimeIndex): + return False + if not is_inferred: + return time_col is not None and time_col not in df.columns + else: + if time_col is not None and cls._is_valid_timestamp_col(df, time_col): + return False + return True + + @classmethod + def _align_y_to_X_and_promote(cls, X, y, needs_promotion=False): + """Align y to X before index promotion or dataset construction. + + When needs_promotion is True, X's DatetimeIndex will be promoted to the time column. + In that case, if y also has a DatetimeIndex, validate that index labels match and + align y to X by label. If y has a RangeIndex (or is a Series without DatetimeIndex), + pair y with X positionally. + + When needs_promotion is False (e.g. X already has a valid timestamp column), + preserve the baseline positional contract for Series and DataFrames: row i of y + corresponds to row i of X. """ if not isinstance(X, pd.DataFrame) or not isinstance(y, (pd.DataFrame, pd.Series)): return X, y @@ -443,45 +509,34 @@ def _align_y_to_X_and_promote(X, y): if len(X) != len(y): raise ValueError(f"Target length ({len(y)}) does not match feature length ({len(X)}).") - # Only perform datetime-label alignment when both X and y have a DatetimeIndex - if isinstance(X.index, pd.DatetimeIndex) and isinstance(y.index, pd.DatetimeIndex): + # Only align by label when X's DatetimeIndex is being promoted to the time column + # and y also has a DatetimeIndex. + if needs_promotion and isinstance(X.index, pd.DatetimeIndex) and isinstance(y.index, pd.DatetimeIndex): if not y.index.equals(X.index): - y_aligned = y.reindex(X.index) - orig_nan_count = int(y.isna().sum().sum() if isinstance(y, pd.DataFrame) else y.isna().sum()) - new_nan_count = int( - y_aligned.isna().sum().sum() if isinstance(y_aligned, pd.DataFrame) else y_aligned.isna().sum() - ) - if new_nan_count > orig_nan_count: + if not X.index.is_unique or not y.index.is_unique: + raise ValueError("Duplicate index labels are not supported.") + try: + if not X.index.difference(y.index).empty or not y.index.difference(X.index).empty: + raise ValueError("Target index labels do not match feature index labels.") + except TypeError: raise ValueError("Target index labels do not match feature index labels.") - y = y_aligned + y = y.reindex(X.index) return X, y - # Positional pairing: when X has DatetimeIndex and y is a Series without DatetimeIndex (or RangeIndex DataFrame), - # or when X has RangeIndex and y is a Series (even if y has a DatetimeIndex). + # Positional pairing: maintain position-level parity if isinstance(y, pd.Series): - if len(y) == len(X): - y = y.copy() - y.index = X.index - return X, y + y = y.copy() + y.index = X.index + return X, y elif isinstance(y, pd.DataFrame): - if isinstance(X.index, pd.DatetimeIndex) and isinstance(y.index, pd.RangeIndex): - if len(y) == len(X): - y = y.copy() - y.index = X.index - return X, y - elif not y.index.equals(X.index): - # When neither is DatetimeIndex, standard label alignment if indexes differ - y_aligned = y.reindex(X.index) - orig_nan_count = int(y.isna().sum().sum()) - new_nan_count = int(y_aligned.isna().sum().sum()) - if new_nan_count > orig_nan_count: - raise ValueError("Target index labels do not match feature index labels.") - y = y_aligned + y = y.copy() + y.index = X.index + return X, y return X, y - @staticmethod - def _promote_datetime_index_if_needed(df, time_col=None, is_inferred=False, reserved_cols=None): + @classmethod + def _promote_datetime_index_if_needed(cls, df, time_col=None, is_inferred=False, reserved_cols=None): """Promote a DatetimeIndex to a guaranteed-unique column if needed. Args: @@ -493,21 +548,7 @@ def _promote_datetime_index_if_needed(df, time_col=None, is_inferred=False, rese Returns: Tuple of (df_processed, promoted_col_name or None). """ - if not isinstance(df, pd.DataFrame) or not isinstance(df.index, pd.DatetimeIndex): - return df, None - - # Precondition check: - # 1. If time_col was inferred: promote if df lacks a datetime-typed time_col. - # 2. If time_col was explicit: promote if time_col is missing from df.columns. - needs_promotion = False - if is_inferred: - if time_col is None or time_col not in df.columns or not pd.api.types.is_datetime64_any_dtype(df[time_col]): - needs_promotion = True - else: - if time_col is not None and time_col not in df.columns: - needs_promotion = True - - if not needs_promotion: + if not cls._needs_datetime_index_promotion(df, time_col=time_col, is_inferred=is_inferred): return df, None if is_inferred: @@ -530,6 +571,7 @@ def _promote_datetime_index_if_needed(df, time_col=None, is_inferred=False, rese return df_out, target_col def preprocess(self, X, transformer=None): + orig_index = getattr(X, "index", None) if isinstance(X, pd.DataFrame): X, _ = self._promote_datetime_index_if_needed( X, @@ -537,9 +579,16 @@ def preprocess(self, X, transformer=None): is_inferred=False, reserved_cols=self.target_names, ) + if self.time_col in X.columns and not pd.api.types.is_datetime64_any_dtype(X[self.time_col]): + if self._is_valid_timestamp_col(X, self.time_col): + X = X.copy() + X[self.time_col] = pd.to_datetime(X[self.time_col]) if isinstance(X, (pd.DataFrame, np.ndarray, pd.Series)): X = normalize_ts_data(X.copy(), self.target_names, self.time_col) - return self._preprocess(X, transformer) + X = self._preprocess(X, transformer) + if orig_index is not None and isinstance(X, pd.DataFrame) and len(X) == len(orig_index): + X.index = orig_index + return X elif isinstance(X, int): return X else: diff --git a/flaml/automl/time_series/ts_data.py b/flaml/automl/time_series/ts_data.py index f1de5cb71d..0ebfe1531a 100644 --- a/flaml/automl/time_series/ts_data.py +++ b/flaml/automl/time_series/ts_data.py @@ -549,35 +549,11 @@ def normalize_ts_data(X_train_all, target_names, time_col, y_train_all=None): index=X_train_all.index, ) elif isinstance(y_train_all, pd.Series): - if isinstance(X_train_all.index, pd.DatetimeIndex) and isinstance(y_train_all.index, pd.DatetimeIndex): - if not y_train_all.index.equals(X_train_all.index): - y_aligned = y_train_all.reindex(X_train_all.index) - if y_aligned.isna().sum() > y_train_all.isna().sum(): - raise ValueError("Target index labels do not match feature index labels.") - y_train_all = y_aligned - y_train_all = pd.DataFrame(y_train_all) - else: - y_train_all = pd.DataFrame(y_train_all) - y_train_all.index = X_train_all.index + y_train_all = pd.DataFrame(y_train_all) + y_train_all.index = X_train_all.index elif isinstance(y_train_all, pd.DataFrame): - if isinstance(X_train_all.index, pd.DatetimeIndex) and isinstance(y_train_all.index, pd.DatetimeIndex): - if not y_train_all.index.equals(X_train_all.index): - y_aligned = y_train_all.reindex(X_train_all.index) - if y_aligned.isna().sum().sum() > y_train_all.isna().sum().sum(): - raise ValueError("Target index labels do not match feature index labels.") - y_train_all = y_aligned - elif ( - isinstance(X_train_all.index, pd.DatetimeIndex) - and isinstance(y_train_all.index, pd.RangeIndex) - and len(y_train_all) == len(X_train_all) - ): - y_train_all = y_train_all.copy() - y_train_all.index = X_train_all.index - elif not y_train_all.index.equals(X_train_all.index): - y_aligned = y_train_all.reindex(X_train_all.index) - if y_aligned.isna().sum().sum() > y_train_all.isna().sum().sum(): - raise ValueError("Target index labels do not match feature index labels.") - y_train_all = y_aligned + y_train_all = y_train_all.copy() + y_train_all.index = X_train_all.index dataframe = pd.concat([X_train_all, y_train_all], axis=1) diff --git a/test/automl/test_ts_forecast_datetime_index.py b/test/automl/test_ts_forecast_datetime_index.py index 0157c579f3..ff3d4a02c5 100644 --- a/test/automl/test_ts_forecast_datetime_index.py +++ b/test/automl/test_ts_forecast_datetime_index.py @@ -361,3 +361,103 @@ def test_named_datetime_index_colliding_with_target_named_y(): _fit(automl, X_train=X_train, y_train=y_train, label="y") assert automl._state.task.time_col == "y_1" assert len(automl.predict(X_train.tail(12))) == 12 + + +def test_valid_date_string_column_preserved_over_incidental_datetime_index(): + """Verify that a valid ISO date string column is preserved over an incidental DatetimeIndex during inference and fit.""" + dates_str = pd.date_range("2018-01-01", periods=120, freq="MS").strftime("%Y-%m-%d").tolist() + incidental_idx = pd.date_range("2030-01-01", periods=120, freq="MS") + df = pd.DataFrame( + { + "ds": dates_str, + "y": np.sin(np.arange(120) / 6) * 10 + 50, + }, + index=incidental_idx, + ) + automl = AutoML() + # Inferred time_col (no explicit time_col provided) + _fit(automl, dataframe=df) + assert automl._state.task.time_col == "ds" + # Ensure the timeline corresponds to 2018, not 2030 (120 monthly periods end in 2027) + assert automl.model.end_date.year == 2027 + future_df = pd.DataFrame({"ds": pd.date_range("2028-01-01", periods=12, freq="MS").strftime("%Y-%m-%d")}) + assert len(automl.predict(future_df)) == 12 + + +def test_positional_series_contract_preserved_when_timestamp_column_present(): + """Verify that when X has an existing timestamp column, Series target is paired positionally even if X has a DatetimeIndex.""" + from flaml.automl.task.time_series_task import TimeSeriesTask + + # X has explicit timestamp column 'ds' in order, but an incidental reverse DatetimeIndex + dates = pd.date_range("2020-01-01", periods=5, freq="D") + X = pd.DataFrame( + {"ds": dates, "feat": [1.0, 2.0, 3.0, 4.0, 5.0]}, + index=dates[::-1], + ) + y = pd.Series([10.0, 20.0, 30.0, 40.0, 50.0], index=dates[::-1]) + + needs_promotion = TimeSeriesTask._needs_datetime_index_promotion(X, time_col="ds", is_inferred=False) + assert not needs_promotion + + X_aligned, y_aligned = TimeSeriesTask._align_y_to_X_and_promote(X, y, needs_promotion=needs_promotion) + # Target values must stay in original order (positional pairing), NOT re-indexed + assert list(y_aligned.values) == [10.0, 20.0, 30.0, 40.0, 50.0] + + +def test_label_mismatch_with_existing_nan_raises_value_error(): + """Verify that index label mismatch is rejected even when dropped row contains NaN (which defeated NaN-count check).""" + from flaml.automl.task.time_series_task import TimeSeriesTask + + # X has Jan 1 and Jan 2; y has Jan 2 and Jan 3 (with value NaN) + x_idx = pd.date_range("2020-01-01", periods=2, freq="D") + y_idx = pd.date_range("2020-01-02", periods=2, freq="D") + + X = pd.DataFrame({"feat": [1.0, 2.0]}, index=x_idx) + y = pd.Series([10.0, np.nan], index=y_idx) + + # When promotion is needed, mismatched index labels must raise ValueError + with pytest.raises(ValueError, match="Target index labels do not match feature index labels"): + TimeSeriesTask._align_y_to_X_and_promote(X, y, needs_promotion=True) + + +def test_predict_restores_datetime_index_preventing_assignment_nans(): + """Verify that predictions on future_df with DatetimeIndex retain future_df.index, preventing assignment NaNs.""" + df = _make_train_df(periods=120) + automl = AutoML() + _fit(automl, dataframe=df) + + future_index = pd.date_range("2028-01-01", periods=12, freq="MS") + future_df = pd.DataFrame(index=future_index) + + preds = automl.predict(future_df) + assert isinstance(preds, (pd.Series, pd.DataFrame, np.ndarray)) + # When assigned back to future_df, pandas aligns by index. If index was RangeIndex, this produced all NaNs. + future_df["prediction"] = preds + assert not future_df["prediction"].isna().any() + assert len(future_df["prediction"]) == 12 + + +def test_is_valid_timestamp_col_boundary_cases(): + """Directly test _is_valid_timestamp_col with various dtypes.""" + from flaml.automl.task.time_series_task import TimeSeriesTask + + df = pd.DataFrame( + { + "num_int": [1, 2, 3], + "num_float": [1.0, 2.0, 3.0], + "num_str": ["100", "200", "300"], + "invalid_str": ["not-a-date", "foo", "bar"], + "iso_str": ["2020-01-01", "2020-01-02", "2020-01-03"], + "dt64": pd.date_range("2020-01-01", periods=3), + "bool_col": [True, False, True], + } + ) + + assert not TimeSeriesTask._is_valid_timestamp_col(df, "num_int") + assert not TimeSeriesTask._is_valid_timestamp_col(df, "num_float") + assert not TimeSeriesTask._is_valid_timestamp_col(df, "num_str") + assert not TimeSeriesTask._is_valid_timestamp_col(df, "invalid_str") + assert not TimeSeriesTask._is_valid_timestamp_col(df, "bool_col") + assert not TimeSeriesTask._is_valid_timestamp_col(df, "non_existent_col") + assert TimeSeriesTask._is_valid_timestamp_col(df, "iso_str") + assert TimeSeriesTask._is_valid_timestamp_col(df, "dt64") From 1964edc8fd727e0c17d2b5298d6b05fd94340316 Mon Sep 17 00:00:00 2001 From: Nefelibata Date: Mon, 19 Oct 2026 14:53:15 +0800 Subject: [PATCH 11/22] fix(automl): preserve DataFrame target label alignment and disallow index promotion on explicit time_col - Preserve DataFrame target label alignment when an existing time column is present, avoiding positional index overwrite that inverted shuffled targets (reproducing maintainer thinkall's case). - Require unique matching index labels when reindexing DataFrame targets against features. - Introduce allow_index_fallback to _needs_datetime_index_promotion so training inputs with explicitly specified missing time_col raise a ValueError instead of silently promoting df.index. - Add value-level regression tests asserting exact head values and missing time_col error handling. --- flaml/automl/task/time_series_task.py | 67 ++++++++++++----- flaml/automl/time_series/ts_data.py | 14 +++- .../automl/test_ts_forecast_datetime_index.py | 75 +++++++++++++++++++ 3 files changed, 134 insertions(+), 22 deletions(-) diff --git a/flaml/automl/task/time_series_task.py b/flaml/automl/task/time_series_task.py index 355e6c5c7b..1031d07bc6 100644 --- a/flaml/automl/task/time_series_task.py +++ b/flaml/automl/task/time_series_task.py @@ -151,6 +151,7 @@ def validate_data( X_train_all, time_col=self.time_col, is_inferred=time_col_inferred, + allow_index_fallback=False, ) X_train_all, y_train_all = self._align_y_to_X_and_promote( X_train_all, @@ -162,6 +163,7 @@ def validate_data( X_train_all, time_col=self.time_col, is_inferred=time_col_inferred, + allow_index_fallback=False, reserved_cols=target_names, ) if promoted_time_col is not None: @@ -178,12 +180,14 @@ def validate_data( dataframe, time_col=self.time_col, is_inferred=time_col_inferred, + allow_index_fallback=False, ) if needs_promotion: dataframe, promoted_time_col = self._promote_datetime_index_if_needed( dataframe, time_col=self.time_col, is_inferred=time_col_inferred, + allow_index_fallback=False, reserved_cols=target_names, ) if promoted_time_col is not None: @@ -210,6 +214,7 @@ def validate_data( X_val, time_col=self.time_col, is_inferred=False, + allow_index_fallback=True, ) X_val, y_val = self._align_y_to_X_and_promote( X_val, @@ -221,6 +226,7 @@ def validate_data( X_val, time_col=self.time_col, is_inferred=False, + allow_index_fallback=True, reserved_cols=target_names, ) if promoted_time_col_val is not None: @@ -479,16 +485,17 @@ def _is_valid_timestamp_col(df, col): return False @classmethod - def _needs_datetime_index_promotion(cls, df, time_col=None, is_inferred=False): + def _needs_datetime_index_promotion(cls, df, time_col=None, is_inferred=False, allow_index_fallback=False): """Determine whether df's DatetimeIndex needs to be promoted to a column.""" if not isinstance(df, pd.DataFrame) or not isinstance(df.index, pd.DatetimeIndex): return False - if not is_inferred: + if allow_index_fallback: return time_col is not None and time_col not in df.columns - else: + if is_inferred: if time_col is not None and cls._is_valid_timestamp_col(df, time_col): return False return True + return False @classmethod def _align_y_to_X_and_promote(cls, X, y, needs_promotion=False): @@ -500,8 +507,8 @@ def _align_y_to_X_and_promote(cls, X, y, needs_promotion=False): pair y with X positionally. When needs_promotion is False (e.g. X already has a valid timestamp column), - preserve the baseline positional contract for Series and DataFrames: row i of y - corresponds to row i of X. + preserve the baseline positional contract for Series (y.index = X.index). + For DataFrame targets, align by label if unique labels match, or raise ValueError. """ if not isinstance(X, pd.DataFrame) or not isinstance(y, (pd.DataFrame, pd.Series)): return X, y @@ -509,9 +516,30 @@ def _align_y_to_X_and_promote(cls, X, y, needs_promotion=False): if len(X) != len(y): raise ValueError(f"Target length ({len(y)}) does not match feature length ({len(X)}).") - # Only align by label when X's DatetimeIndex is being promoted to the time column - # and y also has a DatetimeIndex. - if needs_promotion and isinstance(X.index, pd.DatetimeIndex) and isinstance(y.index, pd.DatetimeIndex): + # When X's DatetimeIndex is being promoted to the time column: + if needs_promotion: + if isinstance(X.index, pd.DatetimeIndex) and isinstance(y.index, pd.DatetimeIndex): + if not y.index.equals(X.index): + if not X.index.is_unique or not y.index.is_unique: + raise ValueError("Duplicate index labels are not supported.") + try: + if not X.index.difference(y.index).empty or not y.index.difference(X.index).empty: + raise ValueError("Target index labels do not match feature index labels.") + except TypeError: + raise ValueError("Target index labels do not match feature index labels.") + y = y.reindex(X.index) + return X, y + # Positional pairing when promotion is needed and y has RangeIndex / non-DatetimeIndex + y = y.copy() + y.index = X.index + return X, y + + # When promotion is NOT needed (e.g. X already has a timestamp column): + if isinstance(y, pd.Series): + y = y.copy() + y.index = X.index + return X, y + elif isinstance(y, pd.DataFrame): if not y.index.equals(X.index): if not X.index.is_unique or not y.index.is_unique: raise ValueError("Duplicate index labels are not supported.") @@ -523,32 +551,30 @@ def _align_y_to_X_and_promote(cls, X, y, needs_promotion=False): y = y.reindex(X.index) return X, y - # Positional pairing: maintain position-level parity - if isinstance(y, pd.Series): - y = y.copy() - y.index = X.index - return X, y - elif isinstance(y, pd.DataFrame): - y = y.copy() - y.index = X.index - return X, y - return X, y @classmethod - def _promote_datetime_index_if_needed(cls, df, time_col=None, is_inferred=False, reserved_cols=None): + def _promote_datetime_index_if_needed( + cls, df, time_col=None, is_inferred=False, allow_index_fallback=False, reserved_cols=None + ): """Promote a DatetimeIndex to a guaranteed-unique column if needed. Args: df: A pandas DataFrame. time_col: The current timestamp column name (if known). is_inferred: Whether time_col was inferred rather than explicitly specified. + allow_index_fallback: Whether to allow falling back to df.index when time_col is absent. reserved_cols: Additional column names to avoid colliding with (e.g. target names). Returns: Tuple of (df_processed, promoted_col_name or None). """ - if not cls._needs_datetime_index_promotion(df, time_col=time_col, is_inferred=is_inferred): + if not cls._needs_datetime_index_promotion( + df, + time_col=time_col, + is_inferred=is_inferred, + allow_index_fallback=allow_index_fallback, + ): return df, None if is_inferred: @@ -577,6 +603,7 @@ def preprocess(self, X, transformer=None): X, time_col=self.time_col, is_inferred=False, + allow_index_fallback=True, reserved_cols=self.target_names, ) if self.time_col in X.columns and not pd.api.types.is_datetime64_any_dtype(X[self.time_col]): diff --git a/flaml/automl/time_series/ts_data.py b/flaml/automl/time_series/ts_data.py index 0ebfe1531a..811de063b7 100644 --- a/flaml/automl/time_series/ts_data.py +++ b/flaml/automl/time_series/ts_data.py @@ -552,8 +552,18 @@ def normalize_ts_data(X_train_all, target_names, time_col, y_train_all=None): y_train_all = pd.DataFrame(y_train_all) y_train_all.index = X_train_all.index elif isinstance(y_train_all, pd.DataFrame): - y_train_all = y_train_all.copy() - y_train_all.index = X_train_all.index + if not y_train_all.index.equals(X_train_all.index): + if not X_train_all.index.is_unique or not y_train_all.index.is_unique: + raise ValueError("Duplicate index labels are not supported.") + try: + if ( + not X_train_all.index.difference(y_train_all.index).empty + or not y_train_all.index.difference(X_train_all.index).empty + ): + raise ValueError("Target index labels do not match feature index labels.") + except TypeError: + raise ValueError("Target index labels do not match feature index labels.") + y_train_all = y_train_all.reindex(X_train_all.index) dataframe = pd.concat([X_train_all, y_train_all], axis=1) diff --git a/test/automl/test_ts_forecast_datetime_index.py b/test/automl/test_ts_forecast_datetime_index.py index ff3d4a02c5..83600451d9 100644 --- a/test/automl/test_ts_forecast_datetime_index.py +++ b/test/automl/test_ts_forecast_datetime_index.py @@ -461,3 +461,78 @@ def test_is_valid_timestamp_col_boundary_cases(): assert not TimeSeriesTask._is_valid_timestamp_col(df, "non_existent_col") assert TimeSeriesTask._is_valid_timestamp_col(df, "iso_str") assert TimeSeriesTask._is_valid_timestamp_col(df, "dt64") + + +def test_reordered_dataframe_target_with_existing_time_col_aligns_by_value(): + """Verify that when X has an existing timestamp column, DataFrame target with reversed index + aligns by index label to preserve target-feature mapping (reproducing maintainer thinkall's case).""" + n = 40 + X = pd.DataFrame( + { + "ds": pd.date_range("2020-01-01", periods=n, freq="D"), + "feat": np.arange(n), + } + ) + y = pd.DataFrame({"y": np.arange(n, dtype=float)[::-1]}, index=np.arange(n)[::-1]) + + automl = AutoML() + _fit( + automl, + X_train=X, + y_train=y, + period=4, + time_budget=3, + keep_search_state=True, + ) + head_df = automl._state.X_train_all.train_data.head() + assert list(head_df["y"].values) == [0.0, 1.0, 2.0, 3.0, 4.0] + + +def test_reordered_dataframe_target_with_datetime_index_promotion_aligns_by_value(): + """Verify that when DatetimeIndex is promoted to time_col, DataFrame target with reversed + DatetimeIndex aligns by timestamp to preserve target-feature mapping.""" + dates = pd.date_range("2020-01-01", periods=40, freq="D") + X = pd.DataFrame({"feat": np.arange(40)}, index=dates) + y = pd.DataFrame({"y": np.arange(40, dtype=float)[::-1]}, index=dates[::-1]) + + automl = AutoML() + _fit( + automl, + X_train=X, + y_train=y, + period=4, + time_budget=3, + keep_search_state=True, + ) + head_df = automl._state.X_train_all.train_data.head() + assert list(head_df["y"].values) == [0.0, 1.0, 2.0, 3.0, 4.0] + + +def test_explicit_missing_time_col_raises_error_even_with_datetime_index(): + """Verify that explicitly specifying a non-existent time_col raises ValueError rather than + silently promoting DatetimeIndex during training (Copilot review discussion_r4308583028).""" + dates = pd.date_range("2020-01-01", periods=40, freq="D") + X = pd.DataFrame({"feat": np.arange(40)}, index=dates) + y = pd.Series(np.arange(40, dtype=float), index=dates, name="y") + + automl = AutoML() + with pytest.raises(ValueError, match="must contain timestamp values"): + _fit(automl, X_train=X, y_train=y, time_col="non_existent_time_col") + + # Also test dataframe input with explicit missing time_col + df = pd.DataFrame({"feat": np.arange(40), "y": np.arange(40, dtype=float)}, index=dates) + automl_df = AutoML() + with pytest.raises(ValueError, match="must contain timestamp values"): + _fit(automl_df, dataframe=df, time_col="non_existent_time_col") + + +def test_mismatched_dataframe_target_labels_raise_value_error(): + """Verify that DataFrame targets with mismatched labels raise ValueError instead of silent corruption.""" + dates = pd.date_range("2020-01-01", periods=40, freq="D") + wrong_dates = pd.date_range("2021-01-01", periods=40, freq="D") + X = pd.DataFrame({"ds": dates, "feat": np.arange(40)}) + y = pd.DataFrame({"y": np.arange(40, dtype=float)}, index=wrong_dates) + + automl = AutoML() + with pytest.raises(ValueError, match="Target index labels do not match feature index labels"): + _fit(automl, X_train=X, y_train=y, time_col="ds") From 1e3b1f20d6db4ca235c581dfbe3d9dbd0035d240 Mon Sep 17 00:00:00 2001 From: Nefelibata Date: Mon, 19 Oct 2026 15:01:42 +0800 Subject: [PATCH 12/22] test(automl): assert exact target value alignment in reordered target tests - Retain search state in test_training_with_reordered_dataframe_target_aligns_by_label, test_validation_with_reordered_dataframe_target_aligns_by_label, and test_training_with_reordered_series_target_aligns_by_label. - Compare normalized target values directly against y.reindex(...) to ensure shuffled targets are aligned by label rather than paired positionally (addressing Copilot discussion_r4311036655). --- test/automl/test_ts_forecast_datetime_index.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/test/automl/test_ts_forecast_datetime_index.py b/test/automl/test_ts_forecast_datetime_index.py index 83600451d9..df71507c40 100644 --- a/test/automl/test_ts_forecast_datetime_index.py +++ b/test/automl/test_ts_forecast_datetime_index.py @@ -173,12 +173,15 @@ def test_training_with_reordered_dataframe_target_aligns_by_label(): y_train = pd.DataFrame({"y": y_values}, index=shuffled_idx) automl = AutoML() - _fit(automl, X_train=X_train, y_train=y_train) + _fit(automl, X_train=X_train, y_train=y_train, keep_search_state=True) # When y_train is reordered, AutoML must correctly align y by index rather than crashing or distorting data preds = automl.predict(X_train.tail(12)) assert len(preds) == 12 assert automl._state.task.time_col == "ds" + expected_y = y_train.reindex(train_idx)["y"].values + actual_y = automl._state.X_train_all.train_data["y"].values + np.testing.assert_allclose(actual_y, expected_y) def test_validation_with_reordered_dataframe_target_aligns_by_label(): @@ -191,11 +194,14 @@ def test_validation_with_reordered_dataframe_target_aligns_by_label(): y_val = pd.DataFrame({"y": (np.arange(20)[::-1] + 100.0)}, index=shuffled_val_idx) automl = AutoML() - _fit(automl, dataframe=df_train, X_val=X_val, y_val=y_val) + _fit(automl, dataframe=df_train, X_val=X_val, y_val=y_val, keep_search_state=True) preds = automl.predict(X_val) assert len(preds) == 20 assert automl._state.eval_method == "holdout" + expected_val_y = y_val.reindex(val_idx)["y"].values + actual_val_y = automl._state.X_val.test_data["y"].values + np.testing.assert_allclose(actual_val_y, expected_val_y) def test_mismatched_target_index_raises_value_error(): @@ -267,11 +273,14 @@ def test_training_with_reordered_series_target_aligns_by_label(): y_train = pd.Series(y_values, index=shuffled_idx, name="y") automl = AutoML() - _fit(automl, X_train=X_train, y_train=y_train) + _fit(automl, X_train=X_train, y_train=y_train, keep_search_state=True) preds = automl.predict(X_train.tail(12)) assert len(preds) == 12 assert automl._state.task.time_col == "ds" + expected_y = y_train.reindex(train_idx).values + actual_y = automl._state.X_train_all.train_data["y"].values + np.testing.assert_allclose(actual_y, expected_y) def test_training_with_range_index_x_datetime_col_and_datetime_index_series_target(): From d57af8d42c902cd4effe340d982dd6ac3cd39057 Mon Sep 17 00:00:00 2001 From: Nefelibata Date: Mon, 19 Oct 2026 17:39:53 +0800 Subject: [PATCH 13/22] fix(automl): validate explicit time_col on features before joining targets - Validate explicitly named time_col on X_train_all and dataframe before TimeSeriesDataset.to_dataframe merges targets, preventing target-frame columns from satisfying the feature clock check. - Validate time_col on X_val before joining validation targets. - Add regression test test_explicit_time_col_missing_from_features_rejected_even_if_present_in_target_dataframe (reproducing maintainer thinkall's review case). --- flaml/automl/task/time_series_task.py | 13 +++++++++++ .../automl/test_ts_forecast_datetime_index.py | 23 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/flaml/automl/task/time_series_task.py b/flaml/automl/task/time_series_task.py index 1031d07bc6..b968b46bfc 100644 --- a/flaml/automl/task/time_series_task.py +++ b/flaml/automl/task/time_series_task.py @@ -170,6 +170,11 @@ def validate_data( self.time_col = promoted_time_col if isinstance(y_train_all, (pd.DataFrame, pd.Series)): y_train_all = y_train_all.reset_index(drop=True) + if not time_col_inferred: + if not isinstance(X_train_all, pd.DataFrame) or self.time_col not in X_train_all.columns: + raise ValueError( + f"For '{TS_FORECAST}' task, time column {self.time_col} must contain timestamp values." + ) dataframe = TimeSeriesDataset.to_dataframe(X_train_all, y_train_all, target_names, self.time_col) elif dataframe is not None: @@ -192,6 +197,10 @@ def validate_data( ) if promoted_time_col is not None: self.time_col = promoted_time_col + if not time_col_inferred and self.time_col not in dataframe.columns: + raise ValueError( + f"For '{TS_FORECAST}' task, time column {self.time_col} must contain timestamp values." + ) else: raise ValueError("Must supply either X_train_all and y_train_all, or dataframe and label") @@ -232,6 +241,10 @@ def validate_data( if promoted_time_col_val is not None: if isinstance(y_val, (pd.DataFrame, pd.Series)): y_val = y_val.reset_index(drop=True) + if not isinstance(X_val, pd.DataFrame) or self.time_col not in X_val.columns: + raise ValueError( + f"For '{TS_FORECAST}' task, time column {self.time_col} must contain timestamp values." + ) val_df = TimeSeriesDataset.to_dataframe(X_val, y_val, target_names, self.time_col) val_len = len(val_df) else: diff --git a/test/automl/test_ts_forecast_datetime_index.py b/test/automl/test_ts_forecast_datetime_index.py index df71507c40..70eee168f8 100644 --- a/test/automl/test_ts_forecast_datetime_index.py +++ b/test/automl/test_ts_forecast_datetime_index.py @@ -545,3 +545,26 @@ def test_mismatched_dataframe_target_labels_raise_value_error(): automl = AutoML() with pytest.raises(ValueError, match="Target index labels do not match feature index labels"): _fit(automl, X_train=X, y_train=y, time_col="ds") + + +def test_explicit_time_col_missing_from_features_rejected_even_if_present_in_target_dataframe(): + """Verify that an explicitly specified time_col must be present in features X, and cannot be + satisfied by a numeric column in DataFrame target y (reproducing maintainer thinkall's case).""" + dates = pd.date_range("2020-01-01", periods=40, freq="D") + X = pd.DataFrame({"feat": np.arange(40)}, index=dates) + y = pd.DataFrame( + {"y": 3 + 7 * np.arange(40, dtype=float), "ds": np.arange(40)}, + index=dates, + ) + + automl = AutoML() + with pytest.raises(ValueError, match="must contain timestamp values"): + _fit( + automl, + X_train=X, + y_train=y, + label="y", + time_col="ds", + period=4, + keep_search_state=True, + ) From 1771c8fea749953bf6cdd88470a130a82b7d5630 Mon Sep 17 00:00:00 2001 From: Nefelibata Date: Mon, 19 Oct 2026 23:51:19 +0800 Subject: [PATCH 14/22] fix(automl): preserve NumPy array normalization while validating DataFrame clocks - Restrict missing time_col check on X_train_all and X_val to pd.DataFrame instances, allowing NumPy arrays to reach TimeSeriesDataset.to_dataframe and normalize_ts_data (fixing P2 regression from review #5572245982 and discussion_r4314204631, r4314204711). - Add regression tests test_numpy_array_training_input_with_explicit_time_col and test_numpy_array_validation_input_accepted. --- flaml/automl/task/time_series_task.py | 11 +++--- .../automl/test_ts_forecast_datetime_index.py | 38 +++++++++++++++++++ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/flaml/automl/task/time_series_task.py b/flaml/automl/task/time_series_task.py index b968b46bfc..654636efcf 100644 --- a/flaml/automl/task/time_series_task.py +++ b/flaml/automl/task/time_series_task.py @@ -170,8 +170,7 @@ def validate_data( self.time_col = promoted_time_col if isinstance(y_train_all, (pd.DataFrame, pd.Series)): y_train_all = y_train_all.reset_index(drop=True) - if not time_col_inferred: - if not isinstance(X_train_all, pd.DataFrame) or self.time_col not in X_train_all.columns: + if not time_col_inferred and self.time_col not in X_train_all.columns: raise ValueError( f"For '{TS_FORECAST}' task, time column {self.time_col} must contain timestamp values." ) @@ -241,10 +240,10 @@ def validate_data( if promoted_time_col_val is not None: if isinstance(y_val, (pd.DataFrame, pd.Series)): y_val = y_val.reset_index(drop=True) - if not isinstance(X_val, pd.DataFrame) or self.time_col not in X_val.columns: - raise ValueError( - f"For '{TS_FORECAST}' task, time column {self.time_col} must contain timestamp values." - ) + if self.time_col not in X_val.columns: + raise ValueError( + f"For '{TS_FORECAST}' task, time column {self.time_col} must contain timestamp values." + ) val_df = TimeSeriesDataset.to_dataframe(X_val, y_val, target_names, self.time_col) val_len = len(val_df) else: diff --git a/test/automl/test_ts_forecast_datetime_index.py b/test/automl/test_ts_forecast_datetime_index.py index 70eee168f8..c5a2697085 100644 --- a/test/automl/test_ts_forecast_datetime_index.py +++ b/test/automl/test_ts_forecast_datetime_index.py @@ -568,3 +568,41 @@ def test_explicit_time_col_missing_from_features_rejected_even_if_present_in_tar period=4, keep_search_state=True, ) + + +def test_numpy_array_training_input_with_explicit_time_col(): + """Verify that a NumPy array X_train with timestamps in column 0 is accepted when time_col is explicit.""" + dates = pd.date_range("2020-01-01", periods=40, freq="D") + X_arr = np.column_stack([dates.astype(object), np.arange(40, dtype=float)]) + y_arr = np.arange(40, dtype=float) + + automl = AutoML() + _fit( + automl, + X_train=X_arr, + y_train=y_arr, + time_col="ds", + period=4, + ) + assert automl._state.task.time_col == "ds" + assert len(automl.predict(X_arr[-12:])) == 12 + + +def test_numpy_array_validation_input_accepted(): + """Verify that a NumPy array X_val with timestamps in column 0 is accepted for holdout validation.""" + dates = pd.date_range("2020-01-01", periods=40, freq="D") + df_train = pd.DataFrame({"ds": dates[:30], "y": np.arange(30, dtype=float)}) + X_val_arr = dates[30:].values.reshape(-1, 1) + y_val_arr = np.arange(30, 40, dtype=float) + + automl = AutoML() + _fit( + automl, + dataframe=df_train, + label="y", + X_val=X_val_arr, + y_val=y_val_arr, + period=4, + ) + assert automl._state.eval_method == "holdout" + assert len(automl.predict(X_val_arr)) == 10 From 66ad9c4405df5e9b0e03afc181435137da7eda61 Mon Sep 17 00:00:00 2001 From: Nefelibata Date: Tue, 20 Oct 2026 14:31:04 +0800 Subject: [PATCH 15/22] fix(automl): preserve non-None DatetimeIndex names and ensure prediction-form equivalence - In _promote_datetime_index_if_needed, check explicitly for df.index.name is None so named indexes with empty string '' or integer 0 are preserved instead of falling back to 'ds' (resolving P3 review from thinkall and Copilot). - Add parameterized regression test test_explicit_index_name_prediction_form_equivalence covering '', 0, and 'when' to verify raw-index frames and reset_index() frames yield equivalent predictions. --- flaml/automl/task/time_series_task.py | 2 +- .../automl/test_ts_forecast_datetime_index.py | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/flaml/automl/task/time_series_task.py b/flaml/automl/task/time_series_task.py index 654636efcf..a961897c33 100644 --- a/flaml/automl/task/time_series_task.py +++ b/flaml/automl/task/time_series_task.py @@ -590,7 +590,7 @@ def _promote_datetime_index_if_needed( return df, None if is_inferred: - base_name = df.index.name or "ds" + base_name = "ds" if df.index.name is None else df.index.name promoted_col = base_name # Guarantee unique column name avoiding collisions with existing columns and reserved names reserved = set(reserved_cols) if reserved_cols else set() diff --git a/test/automl/test_ts_forecast_datetime_index.py b/test/automl/test_ts_forecast_datetime_index.py index c5a2697085..08fbb01377 100644 --- a/test/automl/test_ts_forecast_datetime_index.py +++ b/test/automl/test_ts_forecast_datetime_index.py @@ -606,3 +606,25 @@ def test_numpy_array_validation_input_accepted(): ) assert automl._state.eval_method == "holdout" assert len(automl.predict(X_val_arr)) == 10 + + +@pytest.mark.parametrize("name", ["", 0, "when"]) +def test_explicit_index_name_prediction_form_equivalence(name): + """Verify that explicit index names (including empty string '' and integer 0) are preserved + as the time column, and raw-index future frames produce identical predictions to reset_index() frames.""" + df = _make_train_df(periods=120) + df.index.name = name + automl = AutoML() + _fit(automl, dataframe=df) + assert automl._state.task.time_col == name + + future_index = pd.date_range("2028-01-01", periods=12, freq="MS", name=name) + future_raw = pd.DataFrame(index=future_index) + future_reset = future_raw.reset_index() + + pred_raw = automl.predict(future_raw) + pred_reset = automl.predict(future_reset) + + assert len(pred_raw) == 12 + assert len(pred_reset) == 12 + np.testing.assert_allclose(pred_raw.values, pred_reset.values) From e4944d44ad6b92665596c343d346948fa5bcce2b Mon Sep 17 00:00:00 2001 From: Nefelibata Date: Tue, 20 Oct 2026 22:23:06 +0800 Subject: [PATCH 16/22] docs(test): update test_ts_forecast_datetime_index module docstring to reflect full test suite --- test/automl/test_ts_forecast_datetime_index.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/automl/test_ts_forecast_datetime_index.py b/test/automl/test_ts_forecast_datetime_index.py index 08fbb01377..da794268f8 100644 --- a/test/automl/test_ts_forecast_datetime_index.py +++ b/test/automl/test_ts_forecast_datetime_index.py @@ -1,9 +1,9 @@ """Regression tests for ts_forecast input where the timestamp is the DataFrame index. -Flaml expects the timestamp to live in a *column* (`time_col`). When a user passes the -idiomatic pandas shape instead -- timestamps as a `DatetimeIndex` -- `validate_data` -defaults `time_col` to `dataframe.columns[0]`, which is a value column (often the label -itself), so the real timestamps are never looked at. +FLAML expects the timestamp to live in a *column* (`time_col`). When a user passes timestamps +as a `DatetimeIndex`, `validate_data` promotes the index to a column, preserves explicit index +names, ensures label-based alignment for DatetimeIndex targets, and maintains backwards +compatibility for explicit columns and NumPy array inputs across training, validation, and prediction. """ import numpy as np From 95fb4cee180eb069a7e2b021bdad321d5dc3b920 Mon Sep 17 00:00:00 2001 From: Nefelibata Date: Wed, 21 Oct 2026 14:03:36 +0800 Subject: [PATCH 17/22] fix(automl): restore caller input index on predictions for estimators that reset output index - In AutoML.predict, preserve orig_index before preprocessing and restore it on returned Series/DataFrame predictions when lengths match (resolving Copilot review on prediction index restoration). - In Orbit.predict and Prophet.predict, restore X.index on output predictions if lengths match. - Add regression test test_predict_restores_datetime_index_for_estimator_that_resets_output_index covering estimators that reset their output index to RangeIndex, verifying that assigning predictions back to a DatetimeIndex input produces no NaNs. --- flaml/automl/automl.py | 9 ++++-- flaml/automl/time_series/ts_model.py | 4 +++ .../automl/test_ts_forecast_datetime_index.py | 28 +++++++++++++++++++ 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/flaml/automl/automl.py b/flaml/automl/automl.py index 1daccaebe1..e182ddf90b 100644 --- a/flaml/automl/automl.py +++ b/flaml/automl/automl.py @@ -882,15 +882,18 @@ def predict( if estimator is None: logger.warning("No estimator is trained. Please run fit with enough budget.") return None + orig_index = getattr(X, "index", None) X = self._state.task.preprocess(X, self._transformer) y_pred = estimator.predict(X, **pred_kwargs) if isinstance(y_pred, np.ndarray) and y_pred.ndim > 1 and isinstance(y_pred, np.ndarray): y_pred = y_pred.flatten() if self._label_transformer: - return self._label_transformer.inverse_transform(Series(y_pred.astype(int))) - else: - return y_pred + y_pred = self._label_transformer.inverse_transform(Series(y_pred.astype(int))) + if orig_index is not None and isinstance(y_pred, (Series, DataFrame)) and len(y_pred) == len(orig_index): + y_pred = y_pred.copy() + y_pred.index = orig_index + return y_pred def predict_proba(self, X, **pred_kwargs): """Predict the probability of each class from features, only works for diff --git a/flaml/automl/time_series/ts_model.py b/flaml/automl/time_series/ts_model.py index 445cf337b8..eca5a9316f 100644 --- a/flaml/automl/time_series/ts_model.py +++ b/flaml/automl/time_series/ts_model.py @@ -226,6 +226,8 @@ def predict(self, X: Union[TimeSeriesDataset, DataFrame], **kwargs): } ) ) + if hasattr(X, "index") and len(out) == len(X.index): + out.index = X.index return out else: @@ -325,6 +327,8 @@ def predict(self, X, **kwargs): forecast = self._model.predict(X, **kwargs) out = forecast["yhat"] out.name = self.target_names[0] + if hasattr(X, "index") and len(out) == len(X.index): + out.index = X.index return out else: diff --git a/test/automl/test_ts_forecast_datetime_index.py b/test/automl/test_ts_forecast_datetime_index.py index da794268f8..ac48a4d977 100644 --- a/test/automl/test_ts_forecast_datetime_index.py +++ b/test/automl/test_ts_forecast_datetime_index.py @@ -446,6 +446,34 @@ def test_predict_restores_datetime_index_preventing_assignment_nans(): assert len(future_df["prediction"]) == 12 +def test_predict_restores_datetime_index_for_estimator_that_resets_output_index(): + """Verify that when an estimator resets its prediction index (like Orbit or Prophet), + AutoML.predict() restores the caller's input DatetimeIndex, preventing assignment NaNs.""" + df = _make_train_df(periods=120) + automl = AutoML() + _fit(automl, dataframe=df) + + future_index = pd.date_range("2028-01-01", periods=12, freq="MS") + future_df = pd.DataFrame(index=future_index) + + # Wrap estimator to reset index to RangeIndex (simulating Orbit or Prophet) + orig_predict = automl._trained_estimator.predict + + def reset_predict(X, **kwargs): + preds = orig_predict(X, **kwargs) + return preds.reset_index(drop=True) if hasattr(preds, "reset_index") else preds + + automl._trained_estimator.predict = reset_predict + + preds = automl.predict(future_df) + assert isinstance(preds, (pd.Series, pd.DataFrame)) + assert preds.index.equals(future_index) + + future_df["prediction"] = preds + assert not future_df["prediction"].isna().any() + assert len(future_df["prediction"]) == 12 + + def test_is_valid_timestamp_col_boundary_cases(): """Directly test _is_valid_timestamp_col with various dtypes.""" from flaml.automl.task.time_series_task import TimeSeriesTask From ce5d195d741848250f0310b44520068704ae5c6f Mon Sep 17 00:00:00 2001 From: Nefelibata <124799179+MeiSiristhebest@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:19:53 +0800 Subject: [PATCH 18/22] fix(automl): preserve forecast prediction alignment --- flaml/automl/automl.py | 9 ++- flaml/automl/time_series/ts_model.py | 15 ++++- .../automl/test_ts_forecast_datetime_index.py | 66 +++++++++++++++++++ 3 files changed, 87 insertions(+), 3 deletions(-) diff --git a/flaml/automl/automl.py b/flaml/automl/automl.py index e182ddf90b..9aef4e8eb1 100644 --- a/flaml/automl/automl.py +++ b/flaml/automl/automl.py @@ -883,6 +883,8 @@ def predict( logger.warning("No estimator is trained. Please run fit with enough budget.") return None orig_index = getattr(X, "index", None) + if callable(orig_index): + orig_index = None X = self._state.task.preprocess(X, self._transformer) y_pred = estimator.predict(X, **pred_kwargs) @@ -890,7 +892,12 @@ def predict( y_pred = y_pred.flatten() if self._label_transformer: y_pred = self._label_transformer.inverse_transform(Series(y_pred.astype(int))) - if orig_index is not None and isinstance(y_pred, (Series, DataFrame)) and len(y_pred) == len(orig_index): + if ( + orig_index is not None + and not self._state.task.is_ts_forecastpanel() + and isinstance(y_pred, (Series, DataFrame)) + and len(y_pred) == len(orig_index) + ): y_pred = y_pred.copy() y_pred.index = orig_index return y_pred diff --git a/flaml/automl/time_series/ts_model.py b/flaml/automl/time_series/ts_model.py index eca5a9316f..f8f9b26115 100644 --- a/flaml/automl/time_series/ts_model.py +++ b/flaml/automl/time_series/ts_model.py @@ -323,12 +323,23 @@ def predict(self, X, **kwargs): X = X.rename(columns={self.time_col: "ds"}) if self._model is not None: + input_index = X.index.copy() + input_times = pd.Index(pd.to_datetime(X["ds"])) if "ds" in X.columns else None X = self._preprocess(X) forecast = self._model.predict(X, **kwargs) out = forecast["yhat"] + if input_times is not None and "ds" in forecast.columns: + forecast_times = pd.Index(pd.to_datetime(forecast["ds"])) + if not forecast_times.equals(input_times): + if not forecast_times.is_unique or not input_times.is_unique: + raise ValueError("Prophet cannot restore prediction order for duplicate timestamps.") + positions = forecast_times.get_indexer(input_times) + if (positions < 0).any(): + raise ValueError("Prophet forecast timestamps do not match prediction input timestamps.") + out = out.iloc[positions] out.name = self.target_names[0] - if hasattr(X, "index") and len(out) == len(X.index): - out.index = X.index + if len(out) == len(input_index): + out.index = input_index return out else: diff --git a/test/automl/test_ts_forecast_datetime_index.py b/test/automl/test_ts_forecast_datetime_index.py index ac48a4d977..68dd4ae097 100644 --- a/test/automl/test_ts_forecast_datetime_index.py +++ b/test/automl/test_ts_forecast_datetime_index.py @@ -6,6 +6,8 @@ compatibility for explicit columns and NumPy array inputs across training, validation, and prediction. """ +from types import SimpleNamespace + import numpy as np import pandas as pd import pytest @@ -656,3 +658,67 @@ def test_explicit_index_name_prediction_form_equivalence(name): assert len(pred_raw) == 12 assert len(pred_reset) == 12 np.testing.assert_allclose(pred_raw.values, pred_reset.values) + + +def _make_predict_only_automl(predictions, is_panel=False): + automl = AutoML() + automl._state = SimpleNamespace( + task=SimpleNamespace( + preprocess=lambda X, transformer: X, + is_ts_forecastpanel=lambda: is_panel, + ) + ) + automl._transformer = None + automl._label_transformer = None + automl._trained_estimator = SimpleNamespace(predict=lambda X, **kwargs: predictions.copy()) + return automl + + +def test_predict_handles_list_input_when_estimator_returns_series(): + expected = pd.Series([10.0, 20.0], index=[4, 5], name="y") + automl = _make_predict_only_automl(expected) + + actual = automl.predict([[1.0], [2.0]]) + + pd.testing.assert_series_equal(actual, expected) + + +def test_predict_preserves_panel_group_time_index(): + panel_index = pd.MultiIndex.from_tuples( + [("store_b", pd.Timestamp("2024-01-01")), ("store_a", pd.Timestamp("2024-01-01"))], + names=["store", "ds"], + ) + expected = pd.Series([2.0, 1.0], index=panel_index, name="y") + automl = _make_predict_only_automl(expected, is_panel=True) + + actual = automl.predict(pd.DataFrame({"store": ["store_a", "store_b"]})) + + pd.testing.assert_series_equal(actual, expected) + + +def test_prophet_predict_restores_unsorted_input_timestamp_order(monkeypatch): + from flaml.automl.time_series.ts_model import Prophet + + dates = pd.DatetimeIndex(["2028-03-01", "2028-01-01", "2028-02-01"], name="ds") + X = pd.DataFrame({"ds": dates}, index=dates) + + class SortingProphetModel: + def predict(self, frame, **kwargs): + sorted_dates = frame.sort_values("ds")["ds"] + return pd.DataFrame( + { + "ds": sorted_dates.to_numpy(), + "yhat": sorted_dates.dt.month.to_numpy(dtype=float), + } + ) + + estimator = Prophet() + estimator.time_col = "ds" + estimator.target_names = ["y"] + estimator._model = SortingProphetModel() + monkeypatch.setattr(estimator, "enrich", lambda frame: frame.copy()) + + actual = estimator.predict(X) + expected = pd.Series([3.0, 1.0, 2.0], index=dates, name="y") + + pd.testing.assert_series_equal(actual, expected) From c85b11b7757472168515d5f2a72cb697e3fe58b9 Mon Sep 17 00:00:00 2001 From: Nefelibata <124799179+MeiSiristhebest@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:43:09 +0800 Subject: [PATCH 19/22] fix(ts_forecast): align in-sample predictions by timestamp --- flaml/automl/time_series/ts_model.py | 18 ++++++++++++--- test/automl/test_forecast.py | 22 +++++++++++++++++++ .../automl/test_ts_forecast_datetime_index.py | 2 +- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/flaml/automl/time_series/ts_model.py b/flaml/automl/time_series/ts_model.py index f8f9b26115..80ef57cb74 100644 --- a/flaml/automl/time_series/ts_model.py +++ b/flaml/automl/time_series/ts_model.py @@ -371,13 +371,16 @@ def predict(self, X, **kwargs) -> pd.Series: if isinstance(X, DataFrame): if X.shape[0] == 0: return pd.Series([], name=self.target_names[0], dtype=float) - start = X[self.time_col].iloc[0] - end = X[self.time_col].iloc[-1] + requested_dates = pd.DatetimeIndex(pd.to_datetime(X[self.time_col])) + start = requested_dates.min() + end = requested_dates.max() exog = self._preprocess(X[self.regressors]).values if len(self.regressors) else None + in_sample = self.train_end_date is not None and end <= self.train_end_date + if in_sample and exog is not None: + exog = exog[requested_dates.argsort(kind="stable")] if self.train_end_date is not None and start > self.train_end_date: first_forecast_date = self.train_end_date + pd.tseries.frequencies.to_offset(self.frequency) forecast_dates = pd.date_range(start=first_forecast_date, end=end, freq=self.frequency) - requested_dates = pd.DatetimeIndex(X[self.time_col]) positions = forecast_dates.get_indexer(requested_dates) if (positions < 0).any() or (positions[1:] <= positions[:-1]).any(): raise ValueError( @@ -403,6 +406,15 @@ def predict(self, X, **kwargs) -> pd.Series: forecast = self._model.predict(start=start, end=end, exog=exog, **kwargs) else: forecast = self._model.predict(start=start, end=end, **kwargs) + if in_sample: + forecast_dates = forecast.index + if not isinstance(forecast_dates, pd.DatetimeIndex): + forecast_dates = pd.date_range(start=start, periods=len(forecast), freq=self.frequency) + positions = forecast_dates.get_indexer(requested_dates) + if (positions < 0).any(): + raise ValueError("Prediction timestamps are not covered by the model forecast.") + forecast = forecast.iloc[positions] + forecast.index = requested_dates else: raise ValueError( "X needs to be either a pandas Dataframe with dates as the first column" diff --git a/test/automl/test_forecast.py b/test/automl/test_forecast.py index e5c262c3c5..15a47374e9 100644 --- a/test/automl/test_forecast.py +++ b/test/automl/test_forecast.py @@ -222,6 +222,28 @@ def test_numpy_large(): ) +def test_statsmodels_in_sample_forecast_restores_requested_timestamp_order(): + from flaml.automl.time_series.ts_data import TimeSeriesDataset + from flaml.automl.time_series.ts_model import StatsModelsEstimator + + dates = pd.date_range("2026-01-01", periods=4, freq="D") + train_data = pd.DataFrame({"ds": dates, "y": range(4)}) + dataset = TimeSeriesDataset(train_data, time_col="ds", target_names="y") + estimator = StatsModelsEstimator() + estimator.fit(dataset) + estimator.regressors = [] + estimator.enrich = lambda X: X + estimator._model = Mock() + estimator._model.predict.return_value = pd.Series([10.0, 20.0, 30.0, 40.0], index=dates) + requested_dates = dates[[0, 2, 1, 3]] + + forecast = estimator.predict(pd.DataFrame({"ds": requested_dates})) + + estimator._model.predict.assert_called_once_with(start=dates[0], end=dates[-1]) + expected = pd.Series([10.0, 30.0, 20.0, 40.0], index=requested_dates, name="y") + pd.testing.assert_series_equal(forecast, expected) + + def test_statsmodels_future_forecast_uses_training_boundary(): import pandas as pd diff --git a/test/automl/test_ts_forecast_datetime_index.py b/test/automl/test_ts_forecast_datetime_index.py index 68dd4ae097..b34a630b09 100644 --- a/test/automl/test_ts_forecast_datetime_index.py +++ b/test/automl/test_ts_forecast_datetime_index.py @@ -704,7 +704,7 @@ def test_prophet_predict_restores_unsorted_input_timestamp_order(monkeypatch): class SortingProphetModel: def predict(self, frame, **kwargs): - sorted_dates = frame.sort_values("ds")["ds"] + sorted_dates = frame["ds"].sort_values() return pd.DataFrame( { "ds": sorted_dates.to_numpy(), From 748174bc950d3fc822781f45c21e187623f189af Mon Sep 17 00:00:00 2001 From: Nefelibata <124799179+MeiSiristhebest@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:54:26 +0800 Subject: [PATCH 20/22] test(ts_forecast): name timestamp alignment index --- test/automl/test_forecast.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/automl/test_forecast.py b/test/automl/test_forecast.py index 15a47374e9..a8ca21fa9d 100644 --- a/test/automl/test_forecast.py +++ b/test/automl/test_forecast.py @@ -235,7 +235,7 @@ def test_statsmodels_in_sample_forecast_restores_requested_timestamp_order(): estimator.enrich = lambda X: X estimator._model = Mock() estimator._model.predict.return_value = pd.Series([10.0, 20.0, 30.0, 40.0], index=dates) - requested_dates = dates[[0, 2, 1, 3]] + requested_dates = dates[[0, 2, 1, 3]].rename("ds") forecast = estimator.predict(pd.DataFrame({"ds": requested_dates})) From d5fe77ca8afe39b944261e5182f0bd47f071b017 Mon Sep 17 00:00:00 2001 From: Nefelibata <124799179+MeiSiristhebest@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:12:51 +0800 Subject: [PATCH 21/22] test(ts-forecast): cover real ARIMA prediction ordering --- test/automl/test_forecast.py | 43 ++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/test/automl/test_forecast.py b/test/automl/test_forecast.py index a8ca21fa9d..1caea219cb 100644 --- a/test/automl/test_forecast.py +++ b/test/automl/test_forecast.py @@ -244,6 +244,49 @@ def test_statsmodels_in_sample_forecast_restores_requested_timestamp_order(): pd.testing.assert_series_equal(forecast, expected) +def test_statsmodels_automl_predict_restores_unsorted_timestamps_with_real_model(): + from types import SimpleNamespace + + from statsmodels.tsa.arima.model import ARIMA as StatsmodelsARIMA + + from flaml.automl.time_series.ts_data import TimeSeriesDataset + from flaml.automl.time_series.ts_model import StatsModelsEstimator + + dates = pd.date_range("2026-01-01", periods=12, freq="D") + values = np.array([2.0, 9.0, 1.0, 8.0, 3.0, 12.0, 4.0, 11.0, 5.0, 15.0, 7.0, 17.0]) + target = pd.Series(values, index=dates, name="y") + train_data = pd.DataFrame({"ds": dates, "y": values}) + dataset = TimeSeriesDataset(train_data, time_col="ds", target_names="y") + + estimator = StatsModelsEstimator() + estimator.fit(dataset) + estimator.regressors = [] + estimator._model = StatsmodelsARIMA(target, order=(1, 0, 0)).fit() + + requested_dates = dates[[0, 2, 1, 3]].rename("ds") + X = pd.DataFrame({"ds": requested_dates}, index=requested_dates) + task = SimpleNamespace( + preprocess=lambda frame, transformer: frame, + is_ts_forecastpanel=lambda: False, + ) + automl = AutoML() + automl._state = SimpleNamespace(task=task) + automl._transformer = None + automl._trained_estimator = estimator + automl._label_transformer = None + + actual = automl.predict(X) + + chronological = estimator._model.predict(start=dates[0], end=dates[3]) + assert not np.isclose(chronological.iloc[1], chronological.iloc[2]) + expected = pd.Series( + chronological.reindex(requested_dates).to_numpy(), + index=X.index, + name="y", + ) + pd.testing.assert_series_equal(actual, expected) + + def test_statsmodels_future_forecast_uses_training_boundary(): import pandas as pd From 73cb38b36fe2baa4207633ac8e6561ef9cdd3bb8 Mon Sep 17 00:00:00 2001 From: Nefelibata <124799179+MeiSiristhebest@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:32:48 +0800 Subject: [PATCH 22/22] fix(ts-forecast): normalize date string array predictions --- flaml/automl/task/time_series_task.py | 12 ++++++++---- test/automl/test_ts_forecast_datetime_index.py | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/flaml/automl/task/time_series_task.py b/flaml/automl/task/time_series_task.py index a961897c33..9219ff2759 100644 --- a/flaml/automl/task/time_series_task.py +++ b/flaml/automl/task/time_series_task.py @@ -618,12 +618,16 @@ def preprocess(self, X, transformer=None): allow_index_fallback=True, reserved_cols=self.target_names, ) - if self.time_col in X.columns and not pd.api.types.is_datetime64_any_dtype(X[self.time_col]): - if self._is_valid_timestamp_col(X, self.time_col): - X = X.copy() - X[self.time_col] = pd.to_datetime(X[self.time_col]) if isinstance(X, (pd.DataFrame, np.ndarray, pd.Series)): X = normalize_ts_data(X.copy(), self.target_names, self.time_col) + if ( + isinstance(X, pd.DataFrame) + and self.time_col in X.columns + and not pd.api.types.is_datetime64_any_dtype(X[self.time_col]) + and self._is_valid_timestamp_col(X, self.time_col) + ): + X = X.copy() + X[self.time_col] = pd.to_datetime(X[self.time_col]) X = self._preprocess(X, transformer) if orig_index is not None and isinstance(X, pd.DataFrame) and len(X) == len(orig_index): X.index = orig_index diff --git a/test/automl/test_ts_forecast_datetime_index.py b/test/automl/test_ts_forecast_datetime_index.py index b34a630b09..2ce7c94906 100644 --- a/test/automl/test_ts_forecast_datetime_index.py +++ b/test/automl/test_ts_forecast_datetime_index.py @@ -638,6 +638,23 @@ def test_numpy_array_validation_input_accepted(): assert len(automl.predict(X_val_arr)) == 10 +def test_numpy_date_string_array_can_train_and_predict(): + """Verify ISO date strings are converted for NumPy array training and prediction.""" + train_dates = pd.date_range("2018-01-01", periods=120, freq="MS") + X_train = train_dates.strftime("%Y-%m-%d").to_numpy(dtype=object) + y_train = 10 + np.sin(np.arange(len(train_dates)) * 2 * np.pi / 12) + + automl = AutoML() + _fit(automl, X_train=X_train, y_train=y_train, time_col="ds") + + future_dates = pd.date_range("2028-01-01", periods=12, freq="MS") + X_future = future_dates.strftime("%Y-%m-%d").to_numpy(dtype=object) + predictions = automl.predict(X_future) + + assert len(predictions) == len(future_dates) + assert np.isfinite(np.asarray(predictions)).all() + + @pytest.mark.parametrize("name", ["", 0, "when"]) def test_explicit_index_name_prediction_form_equivalence(name): """Verify that explicit index names (including empty string '' and integer 0) are preserved