diff --git a/README.md b/README.md index 838a100..3a8c31a 100644 --- a/README.md +++ b/README.md @@ -48,13 +48,21 @@ TimeCopilot can pull a public time series dataset directly from the web and fore uvx timecopilot forecast https://otexts.com/fpppy/data/AirPassengers.csv ``` -Want to try a different LL​M? +Want to try a different LLM? ```bash uvx timecopilot forecast https://otexts.com/fpppy/data/AirPassengers.csv \ --llm openai:gpt-4o ``` +Need faster inference for long time series? + +```bash +uvx timecopilot forecast https://otexts.com/fpppy/data/AirPassengers.csv \ + --llm openai:gpt-4o \ + --max_length 100 +``` + Have a specific question? ```bash @@ -98,9 +106,11 @@ df = pd.read_csv("https://timecopilot.s3.amazonaws.com/public/data/air_passenger # Initialize the forecasting agent # You can use any LLM by specifying the model parameter +# Optional: Set max_length to use only the last N observations for faster inference tc = TimeCopilot( llm="openai:gpt-4o", retries=3, + max_length=100, # Use only last 100 observations for faster inference ) # Generate forecast @@ -108,6 +118,7 @@ tc = TimeCopilot( # - freq: The frequency of your data (e.g., 'D' for daily, 'M' for monthly) # - h: The forecast horizon, which is the number of periods to predict # - seasonality: The seasonal period of your data, which can be inferred if not provided +# - max_length: Maximum number of observations to use from the end of each series result = tc.forecast(df=df) # The output contains: diff --git a/docs/max_length.md b/docs/max_length.md new file mode 100644 index 0000000..4f24964 --- /dev/null +++ b/docs/max_length.md @@ -0,0 +1,51 @@ +# Max Length Parameter + +The `max_length` parameter has been added to improve inference times by using only the last N values of each time series. + +## Usage + +### Python API + +```python +from timecopilot import TimeCopilot + +# Initialize with max_length +tc = TimeCopilot(llm="openai:gpt-4o", max_length=100) + +# Or set it per forecast +result = tc.forecast(df=df, max_length=50) +``` + +### CLI + +```bash +# Use max_length parameter +timecopilot forecast data.csv --max_length 100 + +# With other parameters +timecopilot forecast data.csv --llm openai:gpt-4o --max_length 50 +``` + +## How it works + +When `max_length` is set, each time series is truncated to use only the last N observations before training and inference. This can significantly improve performance for long time series while often maintaining good forecast accuracy. + +## Example + +```python +import pandas as pd +from timecopilot import TimeCopilot + +# Create long time series +df = pd.DataFrame({ + 'unique_id': ['series_1'] * 1000, + 'ds': pd.date_range('2020-01-01', periods=1000, freq='D'), + 'y': range(1000) +}) + +# Use only last 100 observations +tc = TimeCopilot(llm="openai:gpt-4o", max_length=100) +result = tc.forecast(df=df, h=10) +``` + +The forecaster will automatically use only the last 100 observations from each series, potentially improving speed while maintaining accuracy. \ No newline at end of file diff --git a/tests/test_agent_max_length.py b/tests/test_agent_max_length.py new file mode 100644 index 0000000..8b01499 --- /dev/null +++ b/tests/test_agent_max_length.py @@ -0,0 +1,62 @@ +import pytest +from utilsforecast.data import generate_series + +from timecopilot.agent import TimeCopilot + + +def test_agent_max_length_parameter(): + """Test that TimeCopilot agent accepts and uses max_length parameter.""" + # Create a TimeCopilot agent with max_length + agent = TimeCopilot(llm="test", max_length=50) + assert agent.max_length == 50 + + # Test default (None) + agent_default = TimeCopilot(llm="test") + assert agent_default.max_length is None + + +def test_agent_forecast_method_max_length(): + """Test that forecast method accepts max_length parameter.""" + # Create test data + df = generate_series(n_series=1, freq="D", min_length=100, max_length=100) + + # This is a unit test, so we just verify the parameter is passed through + # We can't test the full forecast without LLM access + agent = TimeCopilot(llm="test") + + # Test that the method accepts the parameter + try: + # This will fail during actual execution due to LLM, but we can verify + # the parameter is accepted + agent.forecast(df, max_length=30) + except Exception as e: + # We expect this to fail due to LLM issues, but the parameter should be accepted + assert "max_length" not in str(e) # Parameter error would mention max_length + + +def test_agent_max_length_override(): + """Test that forecast method can override instance max_length.""" + # Create agent with default max_length + agent = TimeCopilot(llm="test", max_length=100) + assert agent.max_length == 100 + + # Generate test data + df = generate_series(n_series=1, freq="D", min_length=50, max_length=50) + + # Test that calling forecast with max_length parameter overrides the instance setting + original_max_length = agent.max_length + + try: + # This will fail during execution, but we can verify the override works + agent.forecast(df, max_length=30) + except Exception: + # The forecast will fail due to LLM, but the max_length should be updated + assert agent.max_length == 30 # Should be overridden + + # Test that None value doesn't override if instance has a value + agent.max_length = original_max_length # Reset + try: + agent.forecast(df, max_length=None) + except Exception: + # max_length should remain at original value since None was passed + assert agent.max_length == original_max_length \ No newline at end of file diff --git a/tests/test_max_length.py b/tests/test_max_length.py new file mode 100644 index 0000000..312f6b3 --- /dev/null +++ b/tests/test_max_length.py @@ -0,0 +1,105 @@ +import pytest +from utilsforecast.data import generate_series + +from timecopilot.models.benchmarks.stats import ADIDA, AutoARIMA, SeasonalNaive +from timecopilot.models.foundational.timesfm import TimesFM + + +def test_max_length_parameter_exists(): + """Test that max_length parameter exists in model constructors.""" + # Test ADIDA + model = ADIDA(max_length=50) + assert model.max_length == 50 + + # Test AutoARIMA + model = AutoARIMA(max_length=100) + assert model.max_length == 100 + + # Test SeasonalNaive + model = SeasonalNaive(max_length=30) + assert model.max_length == 30 + + # Test TimesFM + model = TimesFM(max_length=20) + assert model.max_length == 20 + + +def test_max_length_none_by_default(): + """Test that max_length is None by default.""" + model = ADIDA() + assert model.max_length is None + + model = AutoARIMA() + assert model.max_length is None + + model = SeasonalNaive() + assert model.max_length is None + + +def test_truncate_series_functionality(): + """Test the _maybe_truncate_series method.""" + # Generate test data + df = generate_series(n_series=2, freq="D", min_length=50, max_length=50) + + # Test with max_length + model = SeasonalNaive(max_length=20) + truncated_df = model._maybe_truncate_series(df) + + # Check that each series has at most max_length rows + for uid in df['unique_id'].unique(): + series_data = truncated_df[truncated_df['unique_id'] == uid] + assert len(series_data) <= 20 + + # Test without max_length (should not truncate) + model_no_limit = SeasonalNaive(max_length=None) + not_truncated_df = model_no_limit._maybe_truncate_series(df) + assert len(not_truncated_df) == len(df) + + +def test_truncate_series_preserves_latest_data(): + """Test that truncation preserves the latest data points.""" + # Generate test data with known values + df = generate_series(n_series=1, freq="D", min_length=30, max_length=30) + + # Sort by date to ensure proper ordering + df = df.sort_values(['unique_id', 'ds']) + + # Test with max_length smaller than series length + model = SeasonalNaive(max_length=10) + truncated_df = model._maybe_truncate_series(df) + + # The truncated data should have the same latest date as the original + original_latest = df['ds'].max() + truncated_latest = truncated_df['ds'].max() + assert original_latest == truncated_latest + + # And should have 10 rows + assert len(truncated_df) == 10 + + +def test_truncate_series_multiple_series(): + """Test truncation works correctly with multiple series.""" + # Generate multiple series of different lengths + df1 = generate_series(n_series=1, freq="D", min_length=40, max_length=40) + df1['unique_id'] = 'series_1' + + df2 = generate_series(n_series=1, freq="D", min_length=25, max_length=25) + df2['unique_id'] = 'series_2' + + # Combine series + import pandas as pd + df = pd.concat([df1, df2], ignore_index=True) + + # Test truncation + model = SeasonalNaive(max_length=20) + truncated_df = model._maybe_truncate_series(df) + + # Check each series separately + series1_data = truncated_df[truncated_df['unique_id'] == 'series_1'] + series2_data = truncated_df[truncated_df['unique_id'] == 'series_2'] + + # Series 1 should be truncated to 20 rows + assert len(series1_data) == 20 + + # Series 2 should remain at 25 rows (less than max_length) + assert len(series2_data) == 25 \ No newline at end of file diff --git a/timecopilot/_cli.py b/timecopilot/_cli.py index eed4382..012b81b 100644 --- a/timecopilot/_cli.py +++ b/timecopilot/_cli.py @@ -25,17 +25,23 @@ def forecast( seasonality: int | None = None, query: str | None = None, retries: int = 3, + max_length: int | None = None, ): with self.console.status( "[bold blue]TimeCopilot is navigating through time...[/bold blue]" ): - forecasting_agent = TimeCopilotAgent(llm=llm, retries=retries) + forecasting_agent = TimeCopilotAgent( + llm=llm, + retries=retries, + max_length=max_length + ) result = forecasting_agent.forecast( df=path, freq=freq, h=h, seasonality=seasonality, query=query, + max_length=max_length, ) result.output.prettify(self.console) diff --git a/timecopilot/agent.py b/timecopilot/agent.py index 9f1fc0c..13cfd30 100644 --- a/timecopilot/agent.py +++ b/timecopilot/agent.py @@ -49,19 +49,19 @@ from .utils.experiment_handler import ExperimentDataset, ExperimentDatasetParser MODELS = { - "ADIDA": ADIDA(), - "AutoARIMA": AutoARIMA(), - "AutoCES": AutoCES(), - "AutoETS": AutoETS(), - "CrostonClassic": CrostonClassic(), - "DynamicOptimizedTheta": DynamicOptimizedTheta(), - "HistoricAverage": HistoricAverage(), - "IMAPA": IMAPA(), - "SeasonalNaive": SeasonalNaive(), - "Theta": Theta(), - "ZeroModel": ZeroModel(), - "TimesFM": TimesFM(), - "Prophet": Prophet(), + "ADIDA": lambda max_length=None: ADIDA(max_length=max_length), + "AutoARIMA": lambda max_length=None: AutoARIMA(max_length=max_length), + "AutoCES": AutoCES, + "AutoETS": AutoETS, + "CrostonClassic": CrostonClassic, + "DynamicOptimizedTheta": DynamicOptimizedTheta, + "HistoricAverage": HistoricAverage, + "IMAPA": IMAPA, + "SeasonalNaive": lambda max_length=None: SeasonalNaive(max_length=max_length), + "Theta": Theta, + "ZeroModel": ZeroModel, + "TimesFM": lambda max_length=None: TimesFM(max_length=max_length), + "Prophet": Prophet, } TSFEATURES: dict[str, Callable] = { @@ -259,13 +259,19 @@ class TimeCopilot: def __init__( self, llm: str, + max_length: int | None = None, **kwargs: Any, ): """ Args: llm: The LLM to use. + max_length: Maximum number of observations to use from the + end of each time series for training and inference. If None, all + observations are used. This can significantly improve inference times + for long time series by reducing the amount of data processed. **kwargs: Additional keyword arguments to pass to the agent. """ + self.max_length = max_length self.system_prompt = f""" You're a forecasting expert. You will be given a time series @@ -408,7 +414,11 @@ async def cross_validation_tool( f"Model {str_model} is not available. Available models are: " f"{', '.join(MODELS.keys())}" ) - callable_models.append(MODELS[str_model]) + model_factory = MODELS[str_model] + if callable(model_factory): + callable_models.append(model_factory(max_length=self.max_length)) + else: + callable_models.append(model_factory) forecaster = TimeCopilotForecaster(models=callable_models) fcst_cv = forecaster.cross_validation( df=ctx.deps.df, @@ -436,7 +446,11 @@ async def forecast_tool( ctx: RunContext[ExperimentDataset], model: str, ) -> str: - callable_model = MODELS[model] + model_factory = MODELS[model] + if callable(model_factory): + callable_model = model_factory(max_length=self.max_length) + else: + callable_model = model_factory forecaster = TimeCopilotForecaster(models=[callable_model]) fcst_df = forecaster.forecast( df=ctx.deps.df, @@ -475,6 +489,7 @@ def forecast( freq: str | None = None, seasonality: int | None = None, query: str | None = None, + max_length: int | None = None, ) -> AgentRunResult[ForecastAgentOutput]: """Generate forecast and analysis. @@ -498,6 +513,11 @@ def forecast( agent. You can embed `freq`, `h` or `seasonality` here in plain English, they take precedence over the keyword arguments. + max_length: Maximum number of observations to use from the + end of each time series for training and inference. If None, + uses the instance's default max_length, or all observations + if no default is set. This can significantly improve inference + times for long time series by reducing the amount of data processed. Returns: A result object whose `output` attribute is a fully @@ -506,6 +526,10 @@ def forecast( `result.output.prettify()` to print a nicely formatted report. """ + # Use the provided max_length or fall back to the instance's default + if max_length is not None: + self.max_length = max_length + query = f"User query: {query}" if query else None experiment_dataset_parser = ExperimentDatasetParser( model=self.forecasting_agent.model, diff --git a/timecopilot/models/benchmarks/stats.py b/timecopilot/models/benchmarks/stats.py index 1759465..8fd0855 100644 --- a/timecopilot/models/benchmarks/stats.py +++ b/timecopilot/models/benchmarks/stats.py @@ -80,13 +80,19 @@ def __init__( self, alias: str = "ADIDA", prediction_intervals: ConformalIntervals | None = None, + max_length: int | None = None, ): """ Args: alias (str): Custom name of the model. prediction_intervals (ConformalIntervals, optional): Information to compute conformal prediction intervals. + max_length (int, optional): Maximum number of observations to use from the + end of each time series for training and inference. If None, all + observations are used. This can significantly improve inference times + for long time series by reducing the amount of data processed. """ + super().__init__(max_length=max_length) self.alias = alias self.prediction_intervals = prediction_intervals @@ -145,6 +151,7 @@ def forecast( identifiers as the input DataFrame. """ freq = self._maybe_infer_freq(df, freq) + df = self._maybe_truncate_series(df) fcst_df = run_statsforecast_model( model=_ADIDA(alias=self.alias), df=df, @@ -198,6 +205,7 @@ def __init__( season_length: int | None = None, alias: str = "AutoARIMA", prediction_intervals: ConformalIntervals | None = None, + max_length: int | None = None, ): """ Args: @@ -240,7 +248,12 @@ def __init__( alias (str): Custom name of the model. prediction_intervals (ConformalIntervals, optional): Information to compute conformal prediction intervals. + max_length (int, optional): Maximum number of observations to use from the + end of each time series for training and inference. If None, all + observations are used. This can significantly improve inference times + for long time series by reducing the amount of data processed. """ + super().__init__(max_length=max_length) self.d = d self.D = D self.max_p = max_p @@ -330,6 +343,7 @@ def forecast( identifiers as the input DataFrame. """ freq = self._maybe_infer_freq(df, freq) + df = self._maybe_truncate_series(df) season_length = self._maybe_get_seasonality(freq) fcst_df = run_statsforecast_model( model=_AutoARIMA( @@ -944,6 +958,7 @@ def __init__( self, season_length: int | None = None, alias: str = "SeasonalNaive", + max_length: int | None = None, ): """ Args: @@ -951,7 +966,12 @@ def __init__( If None, it will be inferred automatically using [`get_seasonality`][timecopilot.models.utils.forecaster.get_seasonality]. alias (str): Custom name of the model. + max_length (int, optional): Maximum number of observations to use from the + end of each time series for training and inference. If None, all + observations are used. This can significantly improve inference times + for long time series by reducing the amount of data processed. """ + super().__init__(max_length=max_length) self.season_length = season_length self.alias = alias @@ -1010,6 +1030,7 @@ def forecast( identifiers as the input DataFrame. """ freq = self._maybe_infer_freq(df, freq) + df = self._maybe_truncate_series(df) season_length = self._maybe_get_seasonality(freq) fcst_df = run_statsforecast_model( model=_SeasonalNaive( diff --git a/timecopilot/models/foundational/timesfm.py b/timecopilot/models/foundational/timesfm.py index d0459a5..1384f89 100644 --- a/timecopilot/models/foundational/timesfm.py +++ b/timecopilot/models/foundational/timesfm.py @@ -22,6 +22,7 @@ def __init__( num_layers: int = 20, model_dims: int = 1280, alias: str = "TimesFM", + max_length: int | None = None, ): """ Args: @@ -45,6 +46,10 @@ def __init__( Should match the configuration of the pretrained checkpoint. alias (str, optional): Name to use for the model in output DataFrames and logs. Defaults to "TimesFM". + max_length (int, optional): Maximum number of observations to use from the + end of each time series for training and inference. If None, all + observations are used. This can significantly improve inference times + for long time series by reducing the amount of data processed. Notes: - Only PyTorch checkpoints are currently supported. JAX is not supported. @@ -56,6 +61,7 @@ def __init__( - For more information, see the [TimesFM documentation](https://github.com/google-research/timesfm). """ + super().__init__(max_length=max_length) if "pytorch" not in repo_id: raise ValueError( "TimesFM only supports pytorch models, " @@ -153,6 +159,7 @@ def forecast( identifiers as the input DataFrame. """ freq = self._maybe_infer_freq(df, freq) + df = self._maybe_truncate_series(df) qc = QuantileConverter(level=level, quantiles=quantiles) if qc.quantiles is not None and len(qc.quantiles) != len(DEFAULT_QUANTILES_TFM): raise ValueError( diff --git a/timecopilot/models/utils/forecaster.py b/timecopilot/models/utils/forecaster.py index e1c907a..3a28d26 100644 --- a/timecopilot/models/utils/forecaster.py +++ b/timecopilot/models/utils/forecaster.py @@ -60,6 +60,16 @@ def maybe_convert_col_to_datetime(df: pd.DataFrame, col_name: str) -> pd.DataFra class Forecaster: alias: str + def __init__(self, max_length: int | None = None): + """ + Args: + max_length (int, optional): Maximum number of observations to use from the + end of each time series for training and inference. If None, all + observations are used. This can significantly improve inference times + for long time series by reducing the amount of data processed. + """ + self.max_length = max_length + @staticmethod def _maybe_infer_freq( df: pd.DataFrame, @@ -76,6 +86,32 @@ def _maybe_get_seasonality(self, freq: str) -> int: else: return get_seasonality(freq) + def _maybe_truncate_series(self, df: pd.DataFrame) -> pd.DataFrame: + """ + Truncate each time series to the last max_length observations if max_length is set. + + Args: + df: DataFrame with columns 'unique_id', 'ds', 'y' + + Returns: + DataFrame with truncated series if max_length is set, otherwise original DataFrame + """ + if self.max_length is None: + return df + + # Sort by unique_id and ds to ensure proper ordering + df = df.sort_values(['unique_id', 'ds']) + + # Take the last max_length rows for each series + truncated_dfs = [] + for uid in df['unique_id'].unique(): + series_df = df[df['unique_id'] == uid] + if len(series_df) > self.max_length: + series_df = series_df.tail(self.max_length) + truncated_dfs.append(series_df) + + return pd.concat(truncated_dfs, ignore_index=True) + def forecast( self, df: pd.DataFrame, @@ -200,6 +236,7 @@ def cross_validation( """ freq = self._maybe_infer_freq(df, freq) df = maybe_convert_col_to_datetime(df, "ds") + df = self._maybe_truncate_series(df) # mlforecast cv code results = [] sort_idxs = maybe_compute_sort_indices(df, "unique_id", "ds")