From 2ef50f6f15e1929c679fff2908ad4e1e731aac35 Mon Sep 17 00:00:00 2001 From: Ayoub Chikri Date: Wed, 16 Sep 2026 20:06:35 +0200 Subject: [PATCH 1/2] Keep missing CSV observations on the shared time grid --- tests/test_csv_time_grid.py | 55 +++++++++++++++++++++ timesfm-forecasting/scripts/forecast_csv.py | 4 +- 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 tests/test_csv_time_grid.py diff --git a/tests/test_csv_time_grid.py b/tests/test_csv_time_grid.py new file mode 100644 index 00000000..8532a089 --- /dev/null +++ b/tests/test_csv_time_grid.py @@ -0,0 +1,55 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Missing observations must retain their original time positions.""" + +import importlib.util +from pathlib import Path +from unittest import mock + +import numpy as np +import pandas as pd + +SCRIPT = Path(__file__).resolve().parents[1] / "timesfm-forecasting/scripts/forecast_csv.py" +spec = importlib.util.spec_from_file_location("forecast_csv", SCRIPT) +forecast_csv = importlib.util.module_from_spec(spec) +spec.loader.exec_module(forecast_csv) + + + +def test_missing_observations_keep_shared_time_grid(tmp_path): + frame = pd.DataFrame({ + "date": pd.date_range("2026-01-01", periods=5), + "sales": [1.0, np.nan, 3.0, 4.0, np.nan], + "demand": [np.nan, 2.0, 3.0, np.nan, 5.0], + "empty": [np.nan] * 5, + }) + original = frame.copy(deep=True) + model = mock.Mock() + model.forecast.return_value = (np.zeros((3, 2)), np.zeros((3, 2, 10))) + + results = forecast_csv.forecast_series(model, frame, ["sales", "demand", "empty"], 2) + + inputs = model.forecast.call_args.kwargs["inputs"] + for column, values in zip(["sales", "demand", "empty"], inputs): + assert values.dtype == np.float32 + assert values.shape == (len(frame),) + np.testing.assert_array_equal(values, frame[column].to_numpy(dtype=np.float32)) + pd.testing.assert_frame_equal(frame, original) + + output_path = tmp_path / "forecast.csv" + forecast_csv.write_csv_output(results, str(output_path), frame, "date", 2) + exported = pd.read_csv(output_path, parse_dates=["date"]) + for _, group in exported.groupby("series"): + assert group["date"].tolist() == list(pd.date_range("2026-01-06", periods=2)) diff --git a/timesfm-forecasting/scripts/forecast_csv.py b/timesfm-forecasting/scripts/forecast_csv.py index 47392156..0e0a6808 100644 --- a/timesfm-forecasting/scripts/forecast_csv.py +++ b/timesfm-forecasting/scripts/forecast_csv.py @@ -121,7 +121,9 @@ def forecast_series( """Forecast all series and return results dict.""" inputs = [] for col in value_cols: - values = df[col].dropna().values.astype(np.float32) + # Retain missing observations so every column keeps the CSV time grid. + # TimesFM handles NaNs during preprocessing; dropping them shifts time. + values = df[col].to_numpy(dtype=np.float32) inputs.append(values) print(f"Forecasting {len(inputs)} series with horizon={horizon}...") From 26339d23c9ac36423eafd6049f2c43450884bb6f Mon Sep 17 00:00:00 2001 From: Ayoub Chikri Date: Fri, 18 Sep 2026 14:19:32 +0200 Subject: [PATCH 2/2] Reject CSV columns without observations before forecasting --- tests/test_csv_time_grid.py | 38 ++++++++++++++++++--- timesfm-forecasting/scripts/forecast_csv.py | 4 ++- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/tests/test_csv_time_grid.py b/tests/test_csv_time_grid.py index 8532a089..3e5023c0 100644 --- a/tests/test_csv_time_grid.py +++ b/tests/test_csv_time_grid.py @@ -20,6 +20,10 @@ import numpy as np import pandas as pd +import pytest + +from timesfm import ForecastConfig +from timesfm.timesfm_2p5.timesfm_2p5_base import TimesFM_2p5 SCRIPT = Path(__file__).resolve().parents[1] / "timesfm-forecasting/scripts/forecast_csv.py" spec = importlib.util.spec_from_file_location("forecast_csv", SCRIPT) @@ -27,25 +31,39 @@ spec.loader.exec_module(forecast_csv) +def preprocessing_model(): + """Run TimesFM 2.5 forecast preprocessing without loading model weights.""" + model = TimesFM_2p5() + model.forecast_config = ForecastConfig(max_context=5) + model.global_batch_size = 2 + model.compiled_decode = mock.Mock( + return_value=(np.zeros((2, 2)), np.zeros((2, 2, 10))) + ) + model.forecast = mock.Mock(wraps=model.forecast) + return model + def test_missing_observations_keep_shared_time_grid(tmp_path): frame = pd.DataFrame({ "date": pd.date_range("2026-01-01", periods=5), "sales": [1.0, np.nan, 3.0, 4.0, np.nan], "demand": [np.nan, 2.0, 3.0, np.nan, 5.0], - "empty": [np.nan] * 5, }) original = frame.copy(deep=True) - model = mock.Mock() - model.forecast.return_value = (np.zeros((3, 2)), np.zeros((3, 2, 10))) + model = preprocessing_model() - results = forecast_csv.forecast_series(model, frame, ["sales", "demand", "empty"], 2) + results = forecast_csv.forecast_series(model, frame, ["sales", "demand"], 2) inputs = model.forecast.call_args.kwargs["inputs"] - for column, values in zip(["sales", "demand", "empty"], inputs): + for column, values in zip(["sales", "demand"], inputs): assert values.dtype == np.float32 assert values.shape == (len(frame),) np.testing.assert_array_equal(values, frame[column].to_numpy(dtype=np.float32)) + _, preprocessed, masks = model.compiled_decode.call_args.args + np.testing.assert_array_equal(preprocessed[0], [1.0, 2.0, 3.0, 4.0, 4.0]) + np.testing.assert_array_equal(preprocessed[1], [0.0, 2.0, 3.0, 4.0, 5.0]) + np.testing.assert_array_equal(masks[0], [False] * 5) + np.testing.assert_array_equal(masks[1], [True, False, False, False, False]) pd.testing.assert_frame_equal(frame, original) output_path = tmp_path / "forecast.csv" @@ -53,3 +71,13 @@ def test_missing_observations_keep_shared_time_grid(tmp_path): exported = pd.read_csv(output_path, parse_dates=["date"]) for _, group in exported.groupby("series"): assert group["date"].tolist() == list(pd.date_range("2026-01-06", periods=2)) + + +def test_all_missing_column_is_rejected_before_forecast(): + frame = pd.DataFrame({"sales": [1.0, np.nan], "empty": [np.nan, np.nan]}) + model = preprocessing_model() + + with pytest.raises(ValueError, match="Column 'empty' has no observed values"): + forecast_csv.forecast_series(model, frame, ["sales", "empty"], 2) + + model.forecast.assert_not_called() diff --git a/timesfm-forecasting/scripts/forecast_csv.py b/timesfm-forecasting/scripts/forecast_csv.py index 0e0a6808..6ff4c36d 100644 --- a/timesfm-forecasting/scripts/forecast_csv.py +++ b/timesfm-forecasting/scripts/forecast_csv.py @@ -122,8 +122,10 @@ def forecast_series( inputs = [] for col in value_cols: # Retain missing observations so every column keeps the CSV time grid. - # TimesFM handles NaNs during preprocessing; dropping them shifts time. + # TimesFM interpolates gaps; dropping them shifts time. values = df[col].to_numpy(dtype=np.float32) + if values.size == 0 or np.isnan(values).all(): + raise ValueError(f"Column {col!r} has no observed values to forecast.") inputs.append(values) print(f"Forecasting {len(inputs)} series with horizon={horizon}...")