Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/model-hub.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ TimeCopilot provides a unified API for time series forecasting, integrating foun

Here you'll find all the time series forecasting models available in TimeCopilot, organized by family. Click on any model name to jump to its detailed API documentation.

!!! note "Foundation model implementations"
Foundation model implementations live in the [`foundationforecast`](https://pypi.org/project/foundationforecast/) package on PyPI. TimeCopilot re-exports them at `timecopilot.models.foundation.*` for backward compatibility. Model docstrings are maintained in the [foundationforecast](https://github.com/TimeCopilot/foundationforecast) repository.

!!! note "Family example notebooks"
Walkthrough notebooks for families of foundation models [here](examples/index.md#foundation-models).

Expand Down
2 changes: 2 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ plugins:
python:
paths: [timecopilot]
options:
preload_modules:
- foundationforecast
relative_crossrefs: true
members_order: source
separate_signature: true
Expand Down
12 changes: 1 addition & 11 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ dependencies = [
"catboost>=1.2.10",
"datasets>=4.1.1",
"fire",
"foundationforecast>=0.1.1",
"fsspec>=2025.9.0",
"gluonts[torch]",
"huggingface-hub>=0.36.2,<2.0",
"hydra-core>=1.3.2",
"lightgbm>=4.6.0",
Expand All @@ -92,17 +92,7 @@ dependencies = [
"pytorch-lightning==2.4.0",
"scipy<=1.15.3",
"statsforecast>=2.0.2",
"tabpfn-time-series==1.0.3 ; python_full_version < '3.13'",
"tensorboard>=2.20.0",
"tfc-t0>=0.2.3 ; python_full_version >= '3.11' and python_full_version < '3.14'",
"timecopilot-chronos-forecasting>=0.2.2",
"timecopilot-granite-tsfm>=0.2.1 ; python_full_version >= '3.11' and python_full_version < '3.14'",
"timecopilot-timesfm>=0.3.0",
"timecopilot-tirex2>=0.1.0 ; python_full_version >= '3.11'",
"timecopilot-tirex>=0.1.1 ; python_full_version >= '3.11'",
"timecopilot-toto-2>=0.1.1",
"timecopilot-toto>=0.1.7",
"timecopilot-uni2ts>=0.1.3 ; python_full_version < '3.14'",
"torchmetrics>=1.8.2",
"transformers>=4.41,<6 ; python_full_version < '3.13'",
"transformers>=4.48,<6 ; python_full_version >= '3.13'",
Expand Down
6 changes: 3 additions & 3 deletions tests/models/foundation/test_chronos.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ def test_chronos_default_dtype_is_float32():
def test_chronos_model_uses_configured_dtype(mocker):
"""Ensure Chronos loads models with the configured dtype."""
mock_pipeline = mocker.patch(
"timecopilot.models.foundation.chronos.BaseChronosPipeline.from_pretrained"
"foundationforecast.models.chronos.BaseChronosPipeline.from_pretrained"
)
mocker.patch("torch.cuda.is_available", return_value=False)

Expand Down Expand Up @@ -43,12 +43,12 @@ def test_chronos_forecast_uses_configured_dtype(mocker):

# Patch dataset creation to capture dtype argument
mock_from_df = mocker.patch(
"timecopilot.models.foundation.chronos.TimeSeriesDataset.from_df"
"foundationforecast.models.chronos.TimeSeriesDataset.from_df"
)

# Avoid real model loading and CUDA branching
mocker.patch(
"timecopilot.models.foundation.chronos.BaseChronosPipeline.from_pretrained"
"foundationforecast.models.chronos.BaseChronosPipeline.from_pretrained"
)
mocker.patch("torch.cuda.is_available", return_value=False)

Expand Down
42 changes: 42 additions & 0 deletions tests/models/foundation/test_shims.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import sys

import pytest

from timecopilot.models.foundation.chronos import Chronos
from timecopilot.models.foundation.moirai import Moirai
from timecopilot.models.foundation.timegpt import TimeGPT
from timecopilot.models.foundation.timesfm import TimesFM
from timecopilot.models.foundation.toto import Toto
from timecopilot.models.utils.forecaster import Forecaster

SHIM_MODELS = [Chronos, Moirai, TimesFM, Toto, TimeGPT]

if sys.version_info >= (3, 11):
from timecopilot.models.foundation.tirex import TiRex

SHIM_MODELS.append(TiRex)

if sys.version_info >= (3, 11) and sys.version_info < (3, 14):
from timecopilot.models.foundation.flowstate import FlowState
from timecopilot.models.foundation.patchtst_fm import PatchTSTFM
from timecopilot.models.foundation.t0 import T0

SHIM_MODELS.extend([FlowState, PatchTSTFM, T0])

if sys.version_info < (3, 13):
from timecopilot.models.foundation.sundial import Sundial
from timecopilot.models.foundation.tabpfn import TabPFN

SHIM_MODELS.extend([Sundial, TabPFN])


@pytest.mark.parametrize("model_cls", SHIM_MODELS)
def test_foundation_shim_is_forecaster(model_cls):
assert issubclass(model_cls, Forecaster)


@pytest.mark.parametrize("model_cls", [Chronos, Moirai, TimesFM, Toto])
def test_foundation_shim_has_core_methods(model_cls):
assert hasattr(model_cls, "forecast")
assert hasattr(model_cls, "cross_validation")
assert hasattr(model_cls, "detect_anomalies")
15 changes: 7 additions & 8 deletions tests/models/foundation/test_timesfm.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,20 @@
import os

import pytest

from timecopilot.models.foundation.timesfm import _TimesFMV1, _TimesFMV2_p5
from foundationforecast.models.timesfm import _TimesFMV1, _TimesFMV2_p5

MODEL_PARAMS = [
(
_TimesFMV1,
[
"timecopilot.models.foundation.timesfm.timesfm_v1.TimesFmCheckpoint",
"timecopilot.models.foundation.timesfm.timesfm_v1.TimesFm",
"foundationforecast.models.timesfm.timesfm_v1.TimesFmCheckpoint",
"foundationforecast.models.timesfm.timesfm_v1.TimesFm",
],
),
(
_TimesFMV2_p5,
[
"timecopilot.models.foundation.timesfm.TimesFM_2p5_200M_torch",
"foundationforecast.models.timesfm.TimesFM_2p5_200M_torch",
],
),
]
Expand All @@ -24,7 +23,7 @@
@pytest.mark.parametrize("model_class, mock_paths", MODEL_PARAMS)
def test_load_model_from_local_path(mocker, model_class, mock_paths):
"""Tests loading from a local path."""
module_path = "timecopilot.models.foundation.timesfm"
module_path = "foundationforecast.models.timesfm"
mock_os_exists = mocker.patch(f"{module_path}.os.path.exists", return_value=True)
mock_loader = [mocker.patch(i) for i in mock_paths]

Expand Down Expand Up @@ -55,7 +54,7 @@ def test_load_model_from_local_path(mocker, model_class, mock_paths):
@pytest.mark.parametrize("model_class, mock_paths", MODEL_PARAMS)
def test_load_model_from_hf_repo(mocker, model_class, mock_paths):
"""Tests loading from a Hugging Face repo."""
module_path = "timecopilot.models.foundation.timesfm"
module_path = "foundationforecast.models.timesfm"
mock_os_exists = mocker.patch(f"{module_path}.os.path.exists", return_value=False)
mock_repo_exists = mocker.patch(f"{module_path}.repo_exists", return_value=True)
mock_loader = [mocker.patch(i) for i in mock_paths]
Expand Down Expand Up @@ -87,7 +86,7 @@ def test_load_model_from_hf_repo(mocker, model_class, mock_paths):
def test_model_raises_OSError_on_failed_load(mocker, model_class, _):
"""Tests that an OSError is raised on a failed load attempt."""

module_path = "timecopilot.models.foundation.timesfm"
module_path = "foundationforecast.models.timesfm"
mock_os_exists = mocker.patch(f"{module_path}.os.path.exists", return_value=False)
mock_repo_exists = mocker.patch(f"{module_path}.repo_exists", return_value=False)

Expand Down
3 changes: 1 addition & 2 deletions tests/models/foundation/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import torch

from timecopilot.models.foundation.utils import TimeSeriesDataset
from foundationforecast.core.utils import TimeSeriesDataset


def test_timeseries_dataset_class_default_dtype_is_bfloat16():
Expand Down
2 changes: 1 addition & 1 deletion tests/test_forecaster.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ def test_clean_cache_runs_after_each_model(monkeypatch, models):
monkeypatch.setattr(
TimeCopilotForecaster,
"_clean_model_cache",
staticmethod(lambda: calls.append("cleaned")),
lambda self: calls.append("cleaned"),
)

df = generate_series(n_series=1, freq="D", min_length=10)
Expand Down
116 changes: 10 additions & 106 deletions timecopilot/forecaster.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from typing import TYPE_CHECKING, TypeVar

import pandas as pd
from foundationforecast.core.multi_model import MultiModelForecasterMixin

from .models.utils.forecaster import Forecaster

Expand All @@ -29,7 +30,7 @@
)


class TimeCopilotForecaster(Forecaster):
class TimeCopilotForecaster(MultiModelForecasterMixin, Forecaster):
"""
Unified forecaster for multiple time series models.

Expand Down Expand Up @@ -72,41 +73,6 @@ def __init__(
self.fallback_model = fallback_model
self.clean_cache = clean_cache

def _validate_unique_aliases(self, models: list[Forecaster]) -> None:
"""
Validate that all models have unique aliases.

Args:
models (list[Forecaster]): List of model instances to validate.

Raises:
ValueError: If duplicate aliases are found.
"""
aliases = [model.alias for model in models]
duplicates = set([alias for alias in aliases if aliases.count(alias) > 1])

if duplicates:
raise ValueError(
f"Duplicate model aliases found: {sorted(duplicates)}. "
f"Each model must have a unique alias to avoid column name conflicts. "
f"Please provide different aliases when instantiating models of the "
f"same class."
)

@staticmethod
def _clean_model_cache() -> None:
"""Release temporary Python and CUDA memory between model calls."""
import gc

gc.collect()
try:
import torch

if torch.cuda.is_available():
torch.cuda.empty_cache()
except ImportError:
pass

@staticmethod
def _is_distributed_df(df: AnyDataFrame) -> bool:
"""
Expand All @@ -120,64 +86,6 @@ def _is_distributed_df(df: AnyDataFrame) -> bool:
"""
return not isinstance(df, pd.DataFrame)

def _call_models(
self,
attr: str,
merge_on: list[str],
df: pd.DataFrame,
h: int,
freq: str | None,
level: list[int | float] | None,
quantiles: list[float] | None,
**kwargs,
) -> pd.DataFrame:
# infer just once to avoid multiple calls to _maybe_infer_freq
freq = self._maybe_infer_freq(df, freq)
res_df: pd.DataFrame | None = None
for model in self.models:
known_kwargs = {
"df": df,
"h": h,
"freq": freq,
"level": level,
}
if attr != "detect_anomalies":
known_kwargs["quantiles"] = quantiles
fn = getattr(model, attr)
try:
res_df_model = fn(**known_kwargs, **kwargs)
except (ValueError, RuntimeError) as e:
if self.fallback_model is None:
raise e
fn = getattr(self.fallback_model, attr)
try:
res_df_model = fn(**known_kwargs, **kwargs)
res_df_model = res_df_model.rename(
columns={
col: (
col.replace(self.fallback_model.alias, model.alias)
if col.startswith(self.fallback_model.alias)
else col
)
for col in res_df_model.columns
}
)
except (ValueError, RuntimeError) as e:
raise e
if res_df is None:
res_df = res_df_model
else:
if "y" in res_df_model:
# drop y to avoid duplicate columns
# y was added by the previous condition
# to cross validation
# (the initial model)
res_df_model = res_df_model.drop(columns=["y"])
res_df = res_df.merge(res_df_model, on=merge_on, how="left")
if self.clean_cache:
self._clean_model_cache()
return res_df

def _forecast_pandas(
self,
df: pd.DataFrame,
Expand All @@ -192,9 +100,8 @@ def _forecast_pandas(
This method is called directly for pandas DataFrames or by the
distributed wrapper for each partition.
"""
return self._call_models(
"forecast",
merge_on=["unique_id", "ds"],
return MultiModelForecasterMixin.forecast(
self,
df=df,
h=h,
freq=freq,
Expand Down Expand Up @@ -369,9 +276,8 @@ def _cross_validation_pandas(
This method is called directly for pandas DataFrames or by the
distributed wrapper for each partition.
"""
return self._call_models(
"cross_validation",
merge_on=["unique_id", "ds", "cutoff"],
return MultiModelForecasterMixin.cross_validation(
self,
df=df,
h=h,
freq=freq,
Expand Down Expand Up @@ -564,15 +470,13 @@ def _detect_anomalies_pandas(
This method is called directly for pandas DataFrames or by the
distributed wrapper for each partition.
"""
return self._call_models(
"detect_anomalies",
merge_on=["unique_id", "ds", "cutoff"],
return MultiModelForecasterMixin.detect_anomalies(
self,
df=df,
h=h, # type: ignore
h=h,
freq=freq,
n_windows=n_windows,
level=level, # type: ignore
quantiles=None,
level=level,
)

def _detect_anomalies_distributed(
Expand Down
30 changes: 30 additions & 0 deletions timecopilot/models/foundation/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,35 @@
import sys

from .chronos import Chronos, ChronosFinetuningConfig
from .moirai import Moirai
from .timegpt import TimeGPT, TimeGPTFinetuningConfig
from .timesfm import TimesFM
from .toto import Toto

__all__ = [
"Chronos",
"ChronosFinetuningConfig",
"Moirai",
"TimeGPT",
"TimeGPTFinetuningConfig",
"TimesFM",
"Toto",
]

if sys.version_info >= (3, 11):
from .tirex import TiRex as TiRex

__all__.append("TiRex")

if sys.version_info >= (3, 11) and sys.version_info < (3, 14):
from .flowstate import FlowState as FlowState
from .patchtst_fm import PatchTSTFM as PatchTSTFM
from .t0 import T0 as T0

__all__.extend(["FlowState", "PatchTSTFM", "T0"])

if sys.version_info < (3, 13):
from .sundial import Sundial as Sundial
from .tabpfn import TabPFN as TabPFN

__all__.extend(["Sundial", "TabPFN"])
Loading
Loading