Source code for darts.models.forecasting.timesfm3_model

"""
TimesFM 3.0
-----------

TimesFM 3.0 can be used the same way as other foundation models (e.g. Chronos2).

For detailed examples and tutorials, see:

* `Foundation Model Examples
  <https://unit8co.github.io/darts/examples/25-FoundationModel-examples.html>`__
* `Fine-Tuning Examples
  <https://unit8co.github.io/darts/examples/27-Torch-and-Foundation-Model-Fine-Tuning-examples.html>`__
"""

import math
import os

import torch
import torch.nn.functional as F
from torch import nn

from darts.logging import raise_log
from darts.models.components.huggingface_connector import HuggingFaceConnector
from darts.models.components.timesfm3_submodels import (
    _cpm_iterative_revin_refine,
    _get_output_patch_via_roll,
    _get_running_stats,
    _ResidualBlock,
    _ResidualBlockConfig,
    _revin,
    _StackedMixingTransformer,
    _StackedTransformersConfig,
    _stitch_patches,
    _TransformerConfig,
    build_residual_block_config,
    build_stacked_transformers_config,
)
from darts.models.forecasting.foundation_model import FoundationModel
from darts.models.forecasting.pl_forecasting_module import (
    PLForecastingModule,
    io_processor,
)
from darts.utils.data.torch_datasets.utils import (
    InputChunkLength,
    ModuleStage,
    PLModuleInput,
    PLModuleOutput,
    TorchTrainingSample,
    _parse_input_chunk_length,
)
from darts.utils.likelihood_models.torch import QuantileRegression


class _TimesFM3Module(PLForecastingModule):
    def __init__(
        self,
        # architecture parameters, extracted from the HuggingFace `config.json`
        input_patch_len: int = 32,
        output_patch_len: int = 64,
        quantiles: list[float] | None = None,
        residual_block_config: dict | _ResidualBlockConfig | None = None,
        transformer_config: dict | _StackedTransformersConfig | None = None,
        use_variate_attention: bool = True,
        value_clip: float = 1e20,
        use_stitching: bool = True,
        use_linear_detrending: bool = True,
        linear_detrending_threshold: float = 0.5,
        use_iterative_cpm_revin: bool = True,
        use_frozen_running_stats: bool = False,
        input_transform: str = "identity",
        **kwargs,
    ):
        """PyTorch module implementing the TimesFM 3.0 model, ported from
        `google-research/timesfm <https://github.com/google-research/timesfm/>`_ and
        adapted for Darts :class:`PLForecastingModule` interface.

        Multivariate inputs are supported natively: all target components form a single
        multivariate context processed with variate attention. Past covariates are passed as
        past-only channels, and future covariates as past-and-future channels of the model.

        The total number of channels (targets + past covariates + future covariates) must not
        exceed the ``max_variates`` of the checkpoint configuration (32 for the released
        checkpoint).

        Parameters
        ----------
        input_patch_len
            The length of each input patch.
        output_patch_len
            The length of the output forecast for each patch.
        quantiles
            A list of quantiles to predict.
        residual_block_config
            Configuration for the pre-transformer residual block.
        transformer_config
            Configuration for the stacked transformers.
        use_variate_attention
            Whether to use variate attention.
        value_clip
            Absolute value to clip input values to.
        use_stitching
            Whether to use stitching for predictions.
        use_linear_detrending
            Whether to apply linear detrending on context.
        linear_detrending_threshold
            Ratio threshold for applying linear detrending.
        use_iterative_cpm_revin
            Whether to use iterative RevIN refinement.
        use_frozen_running_stats
            Whether running stats freeze at context boundary.
        """
        enable_finetuning = kwargs.pop("enable_finetuning", False)
        super().__init__(**kwargs)

        if quantiles is None:
            quantiles = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]
        if residual_block_config is None:
            residual_block_config = _ResidualBlockConfig(
                hidden_dims=1280,
                output_dims=1280,
                use_bias=False,
                activation="relu",
            )
        else:
            residual_block_config = build_residual_block_config(residual_block_config)

        if transformer_config is None:
            transformer_config = _StackedTransformersConfig(
                num_layers=20,
                transformer=_TransformerConfig(
                    model_dims=1280,
                    hidden_dims=1280,
                    num_heads=16,
                    attention_norm="rms",
                    feedforward_norm="rms",
                    qk_norm="rms",
                    use_rope_seq=True,
                    use_rope_var=False,
                    use_bias=False,
                    ff_activation="relu",
                    deterministic=True,
                ),
            )
        else:
            transformer_config = build_stacked_transformers_config(transformer_config)

        if output_patch_len % input_patch_len != 0:
            raise_log(
                ValueError(
                    f"Output patch len {output_patch_len} must be a multiple of"
                    f" input patch len {input_patch_len}."
                ),
            )
        if (
            residual_block_config.output_dims
            != transformer_config.transformer.model_dims
        ):
            raise_log(
                ValueError(
                    "ResidualBlock output_dims must match Transformer model_dims."
                ),
            )
        if use_stitching and output_patch_len <= input_patch_len:
            raise_log(
                ValueError("use_stitching requires output_patch_len > input_patch_len"),
            )
        if input_transform != "identity":
            raise_log(
                ValueError(
                    f"Only the 'identity' input transformation is supported. "
                    f"Got {input_transform}."
                ),
            )

        self.input_patch_len = input_patch_len
        self.output_patch_len = output_patch_len
        self.model_quantiles = quantiles
        self.num_quantiles = len(quantiles)
        self.rolls = output_patch_len // input_patch_len
        self.max_variates = transformer_config.transformer.max_variates
        self.value_clip = value_clip
        self.use_stitching = use_stitching
        self.use_linear_detrending = use_linear_detrending
        self.linear_detrending_threshold = linear_detrending_threshold
        self.use_iterative_cpm_revin = use_iterative_cpm_revin
        self.use_frozen_running_stats = use_frozen_running_stats
        if use_stitching:
            self._stitching_extract_len = min(2 * input_patch_len, output_patch_len)

        # define model submodules; attribute names match the HuggingFace checkpoint
        # weights so they can be loaded with `load_state_dict()` without remapping
        self.pre_transformer_resblock = _ResidualBlock(config=residual_block_config)
        self.pre_transformer_resblock.set_input_dims(
            2 * (input_patch_len + output_patch_len)
        )
        self.transformer_stack = _StackedMixingTransformer(
            config=transformer_config,
            use_variate_attention=use_variate_attention,
        )
        self.output_head = nn.Linear(
            transformer_config.transformer.model_dims,
            output_patch_len * self.num_quantiles,
            bias=True,
        )

        self.future_len = (self.output_chunk_length or 0) + self.output_chunk_shift

        # gather indices of user-specified quantiles (used at prediction time);
        # by default (`likelihood=None`), the model is deterministic (median, index 4)
        user_quantiles: list[float] = (
            self.likelihood.quantiles
            if isinstance(self.likelihood, QuantileRegression)
            else [0.5]
        )
        self.user_quantile_indices = [
            self.model_quantiles.index(q) for q in user_quantiles
        ]

        # during fine-tuning, train on ALL pre-trained quantiles to preserve the
        # full distribution; prediction uses only user-specified quantiles
        if enable_finetuning:
            self._finetuning_likelihood = QuantileRegression(quantiles)
            self._finetuning_quantile_indices = list(range(self.num_quantiles))
        else:
            self._finetuning_likelihood = None
            self._finetuning_quantile_indices = None

    def _preprocess(
        self,
        values: torch.Tensor,
        masks: torch.Tensor,
        patch_is_target: torch.Tensor,
        freeze_after: int | None = None,
        patch_cpm_mask: torch.Tensor | None = None,
    ) -> tuple[
        torch.Tensor,
        torch.Tensor,
        torch.Tensor,
        tuple[torch.Tensor, torch.Tensor],
        torch.Tensor,
    ]:
        """Applies preprocessing: RevIN, masking, future covariates, ResBlock.

        Parameters
        ----------
        values
            Patched values of shape ``(batch, variates, n_patches, patch_len)``.
        masks
            Boolean mask of the same shape, where True = masked.
        patch_is_target
            Boolean mask of shape ``(batch, variates, n_patches)``.
        freeze_after
            Optional patch index after which running stats freeze.
        patch_cpm_mask
            Boolean mask of shape ``(batch, n_patches)`` or ``None``. If given, target
            variates at True positions are additionally masked (used for horizon CPM
            masking).

        Returns
        -------
        tuple
            ``(resblock_input, resblock_output, patch_mask, (running_mean, running_std),
            running_n)``.
        """
        running_n, running_mean, running_std = _get_running_stats(values, masks)
        if freeze_after is not None:
            _, _, n, _ = values.shape
            if 0 <= freeze_after < n - 1:
                running_mean[:, :, freeze_after + 1 :] = running_mean[
                    :, :, freeze_after : freeze_after + 1
                ]
                running_std[:, :, freeze_after + 1 :] = running_std[
                    :, :, freeze_after : freeze_after + 1
                ]

        # Apply CPM mask: mask target variates at CPM positions.
        if patch_cpm_mask is not None:
            cpm_bvnp = patch_cpm_mask[:, None, :, None]  # (b, 1, n, 1)
            cpm_target_only = cpm_bvnp & patch_is_target.unsqueeze(-1)
            masks = masks | cpm_target_only

        values_bvnp = _revin(values, running_mean, running_std, reverse=False)
        values_bvnp = torch.where(masks, 0.0, values_bvnp)

        # Roll values to get future covariate patches
        values_fcov, wrap_mask = _get_output_patch_via_roll(values, self.rolls)
        values_fcov = _revin(values_fcov, running_mean, running_std, reverse=False)

        # Roll the (CPM-modified) masks for future covariate masking.
        masks_fcov_raw, _ = _get_output_patch_via_roll(masks, self.rolls)
        masks_fcov = masks_fcov_raw | patch_is_target.unsqueeze(-1) | wrap_mask
        values_fcov = torch.where(masks_fcov, 0.0, values_fcov)

        values_cat = torch.cat([values_bvnp, values_fcov], dim=-1)
        masks_cat = torch.cat([masks, masks_fcov], dim=-1)

        resblock_input = torch.cat([values_cat, masks_cat.float()], dim=-1)
        resblock_output = self.pre_transformer_resblock(resblock_input)

        # Patch mask: a patch is fully masked if ALL points are masked
        patch_mask_bvn = masks_cat.all(dim=3)

        return (
            resblock_input,
            resblock_output,
            patch_mask_bvn,
            (running_mean, running_std),
            running_n,
        )

    def _forward_core(
        self,
        inputs: dict[str, torch.Tensor],
        freeze_after: int | None = None,
        patch_cpm_mask: torch.Tensor | None = None,
    ) -> torch.Tensor:
        """Full-sequence forward pass (equivalent to the original ``forward()``).

        Parameters
        ----------
        inputs
            Dictionary with keys ``"values"``: (batch, variates, n_patches, patch_len),
            ``"masks"``: same shape booleans, and ``"patch_is_target"``:
            (batch, variates, n_patches) booleans.
        freeze_after
            Optional patch index after which running stats freeze.
        patch_cpm_mask
            Boolean mask of shape ``(batch, n_patches)`` or ``None``. Horizon CPM mask.

        Returns
        -------
            Logits of shape (batch, variates, n_patches, output_patch_len,
            num_quantiles).
        """
        values = inputs["values"]
        values = torch.nan_to_num(values, nan=0.0)
        values = torch.clamp(values, -self.value_clip, self.value_clip)
        masks = inputs["masks"].bool()
        patch_is_target = inputs["patch_is_target"]

        _, _, _, p = values.shape
        if p != self.input_patch_len:
            raise_log(
                ValueError(
                    f"Input patch_len {p} != model input_patch_len {self.input_patch_len}"
                ),
            )

        # Preprocessing & ResBlock
        (
            _,
            transformer_input,
            transformer_patch_mask,
            revin_stats,
            running_n,
        ) = self._preprocess(
            values,
            masks,
            patch_is_target,
            freeze_after=freeze_after,
            patch_cpm_mask=patch_cpm_mask,
        )

        # Transformer
        # Only mask *leading* fully-masked patches (left-padding). This keeps horizon
        # patches (which are fully masked but come after valid context) visible to
        # attention.
        effective_patch_mask = torch.cumprod(transformer_patch_mask.int(), dim=2).bool()
        transformer_output = self.transformer_stack(
            transformer_input,
            effective_patch_mask,
        )

        # Output head
        raw_logits = self.output_head(transformer_output)
        revin_mean, revin_std = revin_stats

        if self.use_iterative_cpm_revin and patch_cpm_mask is not None:
            refined_mu, refined_sigma = _cpm_iterative_revin_refine(
                raw_logits,
                revin_n=running_n,
                revin_mu=revin_mean,
                revin_sigma=revin_std,
                patch_cpm_mask=patch_cpm_mask,
                median_q_idx=self.num_quantiles // 2,
                rolls=self.rolls,
                patch_len=self.input_patch_len,
                num_quantiles=self.num_quantiles,
                value_clip=self.value_clip,
            )
            cpm_bvn = patch_cpm_mask.unsqueeze(1)  # (b, 1, n)
            revin_mean = torch.where(cpm_bvn, refined_mu, revin_mean)
            revin_std = torch.where(cpm_bvn, refined_sigma, revin_std)

        revin_logits = _revin(raw_logits, revin_mean, revin_std, reverse=True)
        clipped_logits = torch.clamp(revin_logits, -self.value_clip, self.value_clip)

        # Reshape: (b, v, n, o*q) -> (b, v, n, o, q)
        b, v, n_patches = clipped_logits.shape[:3]
        return clipped_logits.view(
            b, v, n_patches, self.output_patch_len, self.num_quantiles
        )

    def _decode(
        self,
        target: torch.Tensor,
        horizon: int = 0,
        past_only_covariates: torch.Tensor | None = None,
        past_future_covariates: torch.Tensor | None = None,
        target_mask: torch.Tensor | None = None,
        past_only_mask: torch.Tensor | None = None,
        past_future_mask: torch.Tensor | None = None,
        mask: torch.Tensor | None = None,
    ) -> torch.Tensor:
        """Non-autoregressive single-pass decoding for TimesFM 3.0.

        Parameters
        ----------
        target
            Shape ``(batch, n_targets, context_len)``.
        horizon
            Forecast horizon. Inferred if ``past_future_covariates`` given.
        past_only_covariates
            Shape ``(batch, n_past_only, context_len)`` or ``None``.
        past_future_covariates
            Shape ``(batch, n_past_future, context_len + horizon)`` or ``None``.
        target_mask
            Shape ``(batch, n_targets, context_len)`` booleans or ``None``.
        past_only_mask
            Shape ``(batch, n_past_only, context_len)`` booleans or ``None``.
        past_future_mask
            Shape ``(batch, n_past_future, context_len + horizon)`` booleans or ``None``.
        mask
            Shape ``(batch, context_len)`` global boolean mask or ``None``.

        Returns
        -------
            Logits of shape ``(batch, num_variates, horizon, num_quantiles)``.
        """
        dtype = target.dtype
        device = target.device
        batch_size, num_target, context = target.shape

        if past_future_covariates is not None:
            horizon = past_future_covariates.shape[-1] - context
        if horizon <= 0:
            raise_log(ValueError("Decode function requires horizon > 0."))

        # 1. Pad context to multiple of input_patch_len
        ctx_padding = (
            self.input_patch_len - (context % self.input_patch_len)
        ) % self.input_patch_len
        if ctx_padding > 0:
            target = F.pad(target, (ctx_padding, 0))
            if mask is not None:
                mask = F.pad(mask, (ctx_padding, 0), value=True)
            if past_only_covariates is not None:
                past_only_covariates = F.pad(past_only_covariates, (ctx_padding, 0))
            if past_future_covariates is not None:
                past_future_covariates = F.pad(past_future_covariates, (ctx_padding, 0))
            if target_mask is not None:
                target_mask = F.pad(target_mask, (ctx_padding, 0), value=True)
            if past_only_mask is not None:
                past_only_mask = F.pad(past_only_mask, (ctx_padding, 0), value=True)
            if past_future_mask is not None:
                past_future_mask = F.pad(past_future_mask, (ctx_padding, 0), value=True)
            context = context + ctx_padding

        if mask is None:
            mask = torch.zeros(batch_size, context, dtype=torch.bool, device=device)
            if ctx_padding > 0:
                mask[:, :ctx_padding] = True

        # 2. Pad horizon
        if self.use_stitching:
            extract_len = self._stitching_extract_len
            overlap = extract_len - self.input_patch_len
            num_forecast_patches = max(
                math.ceil((horizon - overlap) / self.input_patch_len), 1
            )
            num_horizon_patches = num_forecast_patches + self.rolls - 1
            padded_horizon = num_horizon_patches * self.input_patch_len
            hor_padding = padded_horizon - horizon
        else:
            hor_padding = (-horizon) % self.output_patch_len
            padded_horizon = horizon + hor_padding
            num_horizon_patches = padded_horizon // self.input_patch_len
        num_context_patches = context // self.input_patch_len

        # 3. Build context & horizon inputs
        if target_mask is None:
            target_mask = torch.zeros_like(target, dtype=torch.bool)
        target_mask = target_mask | mask.unsqueeze(1)

        all_ctx_vals = [target]
        all_ctx_masks = [target_mask]
        num_past_only = 0
        if past_only_covariates is not None:
            num_past_only = past_only_covariates.shape[1]
            if past_only_mask is None:
                past_only_mask = torch.zeros_like(
                    past_only_covariates, dtype=torch.bool
                )
            all_ctx_vals.append(past_only_covariates)
            all_ctx_masks.append(past_only_mask | mask.unsqueeze(1))
        if past_future_covariates is not None:
            if past_future_mask is None:
                past_future_mask = torch.zeros_like(
                    past_future_covariates, dtype=torch.bool
                )
            all_ctx_vals.append(past_future_covariates[..., :context])
            all_ctx_masks.append(past_future_mask[..., :context] | mask.unsqueeze(1))

        ctx_vals = torch.cat(all_ctx_vals, dim=1)
        ctx_masks = torch.cat(all_ctx_masks, dim=1)

        if self.use_linear_detrending:
            t_ctx = torch.arange(-(context - 1), 1, dtype=dtype, device=device)
            t_ctx_bvc = t_ctx[None, None, :]
            t_ctx_bvc_normalized = t_ctx_bvc / context

            valid = ~ctx_masks
            n_v = valid.float().sum(dim=-1, keepdim=True)
            sum_t = torch.where(valid, t_ctx_bvc_normalized, 0.0).sum(
                dim=-1, keepdim=True
            )
            sum_t2 = torch.where(valid, t_ctx_bvc_normalized**2, 0.0).sum(
                dim=-1, keepdim=True
            )
            sum_y = torch.where(valid, ctx_vals, 0.0).sum(dim=-1, keepdim=True)
            sum_ty = torch.where(valid, t_ctx_bvc_normalized * ctx_vals, 0.0).sum(
                dim=-1, keepdim=True
            )

            det = n_v * sum_t2 - sum_t**2
            safe_det = torch.where(det == 0.0, 1.0, det)
            m_trend = torch.where(
                det == 0.0, 0.0, (n_v * sum_ty - sum_t * sum_y) / safe_det
            )
            c_trend = torch.where(
                det == 0.0,
                torch.where(n_v > 0, sum_y / torch.clamp_min(n_v, 1.0), 0.0),
                (sum_y - m_trend * sum_t) / torch.clamp_min(n_v, 1.0),
            )

            ctx_vals_detrended = ctx_vals - (m_trend * t_ctx_bvc_normalized + c_trend)

            mean_y = sum_y / torch.clamp_min(n_v, 1.0)
            sum_y2 = torch.where(valid, ctx_vals**2, 0.0).sum(dim=-1, keepdim=True)
            var_orig = torch.clamp_min(
                sum_y2 / torch.clamp_min(n_v, 1.0) - mean_y**2, 0.0
            )
            std_orig = torch.sqrt(var_orig)

            sum_yd = torch.where(valid, ctx_vals_detrended, 0.0).sum(
                dim=-1, keepdim=True
            )
            mean_yd = sum_yd / torch.clamp_min(n_v, 1.0)
            sum_yd2 = torch.where(valid, ctx_vals_detrended**2, 0.0).sum(
                dim=-1, keepdim=True
            )
            var_det = torch.clamp_min(
                sum_yd2 / torch.clamp_min(n_v, 1.0) - mean_yd**2, 0.0
            )
            std_det = torch.sqrt(var_det)

            apply_detrend = std_det < self.linear_detrending_threshold * std_orig
            ctx_vals = torch.where(apply_detrend, ctx_vals_detrended, ctx_vals)
        else:
            num_variates = ctx_vals.shape[1]
            m_trend = torch.zeros(
                (batch_size, num_variates, 1), dtype=dtype, device=device
            )
            c_trend = torch.zeros(
                (batch_size, num_variates, 1), dtype=dtype, device=device
            )
            apply_detrend = torch.zeros(
                (batch_size, num_variates, 1), dtype=torch.bool, device=device
            )

        ctx_vals = torch.where(ctx_masks, 0.0, ctx_vals)

        all_hor_vals = [
            torch.zeros(batch_size, num_target, padded_horizon, device=device),
            torch.zeros(batch_size, num_past_only, padded_horizon, device=device),
        ]
        all_hor_masks = [
            torch.ones(
                batch_size,
                num_target,
                padded_horizon,
                dtype=torch.bool,
                device=device,
            ),
            torch.ones(
                batch_size,
                num_past_only,
                padded_horizon,
                dtype=torch.bool,
                device=device,
            ),
        ]

        if past_future_covariates is not None:
            if past_future_mask is None:
                past_future_mask = torch.zeros_like(
                    past_future_covariates, dtype=torch.bool
                )
            pf_future_vals = past_future_covariates[..., context : context + horizon]
            pf_future_masks = past_future_mask[..., context : context + horizon]
            if self.use_linear_detrending:
                m_pf = m_trend[:, num_target + num_past_only :, :]
                c_pf = c_trend[:, num_target + num_past_only :, :]
                apply_detrend_pf = apply_detrend[:, num_target + num_past_only :, :]
                t_hor_pf = torch.arange(1, horizon + 1, dtype=dtype, device=device)[
                    None, None, :
                ]
                t_hor_pf_normalized = t_hor_pf / context
                pf_trend_hor = m_pf * t_hor_pf_normalized + c_pf
                pf_future_vals = torch.where(
                    apply_detrend_pf, pf_future_vals - pf_trend_hor, pf_future_vals
                )
            pf_future_vals = torch.where(pf_future_masks, 0.0, pf_future_vals)
            if hor_padding > 0:
                pf_future_vals = F.pad(pf_future_vals, (0, hor_padding))
                pf_future_masks = F.pad(pf_future_masks, (0, hor_padding), value=True)
            all_hor_vals.append(pf_future_vals)
            all_hor_masks.append(pf_future_masks)

        hor_vals = torch.cat(all_hor_vals, dim=1)
        hor_masks = torch.cat(all_hor_masks, dim=1)

        all_vals = torch.cat([ctx_vals, hor_vals], dim=-1)
        all_masks = torch.cat([ctx_masks, hor_masks], dim=-1)

        num_variates = all_vals.shape[1]
        patch_is_target = torch.zeros(
            (batch_size, num_variates, num_context_patches + num_horizon_patches),
            dtype=torch.bool,
            device=device,
        )
        patch_is_target[:, : num_target + num_past_only, :] = True

        # Reshape values & masks to patched shape (b, v, n, p)
        values_bvnp = all_vals.reshape(
            batch_size, num_variates, -1, self.input_patch_len
        )
        masks_bvnp = all_masks.reshape(
            batch_size, num_variates, -1, self.input_patch_len
        )

        inputs = {
            "values": values_bvnp,
            "masks": masks_bvnp,
            "patch_is_target": patch_is_target,
        }

        # Build horizon CPM mask: context=False, horizon=True.
        num_total_patches = num_context_patches + num_horizon_patches
        horizon_cpm_mask = torch.zeros(
            batch_size, num_total_patches, dtype=torch.bool, device=device
        )
        horizon_cpm_mask[:, num_context_patches:] = True

        freeze_after = (
            num_context_patches - 1 if self.use_frozen_running_stats else None
        )
        logits = self._forward_core(
            inputs,
            freeze_after=freeze_after,
            patch_cpm_mask=horizon_cpm_mask,
        )  # (b, v, n, output_patch_len, num_quantiles)

        if self.use_stitching:
            extract_len = self._stitching_extract_len
            overlap = extract_len - self.input_patch_len
            num_forecast_patches = max(
                math.ceil((horizon - overlap) / self.input_patch_len), 1
            )
            forecast_indices = torch.arange(num_forecast_patches, device=device) + (
                num_context_patches - 1
            )
            patch_preds = logits[:, :, forecast_indices, :extract_len, :]
            horizon_logits = _stitch_patches(
                patch_preds,
                self.input_patch_len,
            )[:, :, :horizon, :]
        else:
            num_forecast_chunks = padded_horizon // self.output_patch_len
            forecast_indices = torch.arange(
                num_forecast_chunks, device=device
            ) * self.rolls + (num_context_patches - 1)
            forecast_logits = logits[:, :, forecast_indices, :, :]
            horizon_logits = forecast_logits.reshape(
                batch_size, num_variates, -1, self.num_quantiles
            )[:, :, :horizon, :]

        if self.use_linear_detrending:
            t_forecast = torch.arange(1, horizon + 1, dtype=dtype, device=device)
            t_forecast_normalized = t_forecast / context
            trend_forecast = (
                m_trend[:, :, 0, None] * t_forecast_normalized[None, None, :]
                + c_trend[:, :, 0, None]
            )
            trend_forecast = torch.where(
                apply_detrend[:, :, 0, None], trend_forecast, 0.0
            )
            horizon_logits = horizon_logits + trend_forecast[:, :, :, None]

        return horizon_logits

    @io_processor
    def forward(self, x_in: PLModuleInput) -> PLModuleOutput:
        # B: batch size
        # L: input chunk length
        # T: output chunk length
        # S: output chunk shift
        # H = T + S: forecast horizon delegated to the model per chunk
        # C: target components
        # P: past covariates (past-only channels)
        # W: future covariates (past-and-future channels)
        # V = C + P + W: total variates (must be <= max_variates of the config)
        # Q = 9: pre-trained quantiles returned by the model
        # N: likelihood quantiles (user-specified, 1 if deterministic)

        # targets: (B, L, C) -> (B, C, L); missing values are handled with masks
        target = x_in.past_target
        batch_size, _, n_targets = target.shape
        target = target.transpose(1, 2)
        target_mask = torch.isnan(target)
        target = torch.nan_to_num(target, nan=0.0)

        # past covariates: (B, L, P) -> (B, P, L)
        past_only_covariates = x_in.past_covariates
        past_only_mask = None
        if past_only_covariates is not None:
            past_only_covariates = past_only_covariates.transpose(1, 2)
            past_only_mask = torch.isnan(past_only_covariates)
            past_only_covariates = torch.nan_to_num(past_only_covariates, nan=0.0)

        # future covariates: (B, L + H, W): the historic part comes from `x_past`, the
        # future part from `x_future`. The values in the gap created by
        # `output_chunk_shift` are unknown and are masked out.
        past_future_covariates = None
        past_future_mask = None
        historic_future_covariates = x_in.historic_future_covariates
        future_covariates = x_in.future_covariates
        if historic_future_covariates is not None and future_covariates is not None:
            if self.output_chunk_shift == 0:
                past_future_covariates = x_in.concatenate_future_along_time()
            else:
                n_future_covs = future_covariates.shape[-1]
                gap = torch.full(
                    (batch_size, self.output_chunk_shift, n_future_covs),
                    float("nan"),
                    device=target.device,
                    dtype=target.dtype,
                )
                past_future_covariates = torch.cat(
                    [historic_future_covariates, gap, future_covariates], dim=1
                )
            past_future_covariates = past_future_covariates.transpose(1, 2)
            past_future_mask = torch.isnan(past_future_covariates)
            past_future_covariates = torch.nan_to_num(past_future_covariates, nan=0.0)

        # single-pass decode over the whole horizon H
        # -> (B, C + P + W, H, Q)
        horizon_logits = self._decode(
            target=target,
            horizon=self.future_len,
            past_only_covariates=past_only_covariates,
            past_future_covariates=past_future_covariates,
            target_mask=target_mask,
            past_only_mask=past_only_mask,
            past_future_mask=past_future_mask,
        )

        # slice the output shift: (B, V, S + T, Q) -> (B, V, T, Q)
        horizon_logits = horizon_logits[:, :, self.output_chunk_shift :, :]
        # select the target variates: (B, C, T, Q)
        horizon_logits = horizon_logits[:, :n_targets, :, :]
        # (B, C, T, Q) -> (B, T, C, Q)
        horizon_logits = horizon_logits.permute(0, 2, 1, 3)

        # during training (fine-tuning), output all pre-trained quantiles for loss;
        # during prediction, output only user-specified quantiles
        if x_in.stage is ModuleStage.TRAIN:
            quantile_preds = horizon_logits[..., self._finetuning_quantile_indices]
        else:
            quantile_preds = horizon_logits[..., self.user_quantile_indices]

        return PLModuleOutput(prediction=quantile_preds)

    def _compute_loss(self, output: PLModuleOutput, target, criterion, sample_weight):
        if self.training:
            # compute loss on pre-trained quantiles
            return self._finetuning_likelihood.compute_loss(
                output.prediction, target, sample_weight
            )
        else:
            return super()._compute_loss(output, target, criterion, sample_weight)


[docs] class TimesFM3Model(FoundationModel): # Structural limits of the original implementation. These are constants of the # upstream `TimesFM3Forecaster` and are not part of the HuggingFace `config.json` # (which only carries the architecture parameters read by `_create_model()`). _MAX_CONTEXT_LENGTH = 15360 def __init__( self, input_chunk_length: InputChunkLength, output_chunk_length: int, output_chunk_shift: int = 0, accept_license: bool = False, likelihood: QuantileRegression | None = None, hub_model_name: str = "google/timesfm-3.0-pytorch", hub_model_revision: str | None = None, local_dir: str | os.PathLike | None = None, **kwargs, ): """ TimesFM 3.0 foundation model for zero-shot time series forecasting. This is an implementation of Google's TimesFM 3.0 model [1]_, [2]_, ported from `google-research/timesfm <https://github.com/google-research/timesfm>`_ with adaptations to use the Darts API. TimesFM 3.0 is a pre-trained foundation model designed for zero-shot forecasting across both short and long horizons. Unlike previous versions, it natively supports multivariate time series (with variate attention), past covariates, and future covariates. Using this model will automatically download and cache the pre-trained model from HuggingFace Hub (`google/timesfm-3.0-pytorch <https://huggingface.co/google/timesfm-3.0-pytorch>`_). Alternatively, you can specify a local directory containing the model config and weights using the ``local_dir`` parameter. By default, this model is deterministic and outputs only the median (0.5 quantile). To enable probabilistic forecasts, pass a :class:`~darts.utils.likelihood_models.torch.QuantileRegression` instance to the ``likelihood`` parameter. The quantiles used must be a subset of those used during TimesFM 3.0 pre-training, see below for details. For probabilistic forecasts, it is recommended to call :func:`predict()` with ``predict_likelihood_parameters=True`` or ``num_samples >> 1`` to get meaningful results. .. tip:: You can perform full or partial fine-tuning of the model by setting the ``enable_finetuning`` parameter. Read more in the parameter description below and in the `Fine-Tuning Examples <https://unit8co.github.io/darts/examples/27-Torch-and-Foundation-Model-Fine-Tuning-examples.html>`__. .. note:: The TimesFM 3.0 pre-trained weights are distributed under the `TimesFM Non-Commercial License v1.0 <https://huggingface.co/google/timesfm-3.0-pytorch/blob/main/LICENSE>`_ and are restricted to non-commercial, non-production use. You must explicitly acknowledge this license by passing ``accept_license=True`` when constructing the model. The model code is licensed under the Apache-2.0 License. .. note:: The total number of target components and covariates must not exceed 32 (the maximum number of variates supported by the checkpoint). .. note:: Zero-shot forecasts match the original implementation when the forecast horizon ``n`` is smaller than or equal to ``output_chunk_length``. For longer horizons, Darts auto-regressively applies the model on ``output_chunk_length``-sized chunks, which may produce slightly different results than the original single-pass decoding. Set ``output_chunk_length >= n`` to obtain a single-pass forecast. Parameters ---------- input_chunk_length Number of time steps in the past to take as a model input (per chunk). Applies to the target series, and past and/or future covariates (if the model supports it). Can be either an ``int`` for a fixed input window, or a ``(min_length, max_length)`` tuple to enable variable-length inputs for inference and fine-tuning. For TimesFM 3.0, ``max_length`` must be less than or equal to 15,360. output_chunk_length Number of time steps predicted at once (per chunk) by the internal model. Also, the number of future values from future covariates to use as a model input (if the model supports future covariates). It is not the same as forecast horizon `n` used in `predict()`, which is the desired number of prediction points generated using either a one-shot- or autoregressive forecast. Setting `n <= output_chunk_length` prevents auto-regression. This is useful when the covariates don't extend far enough into the future, or to prohibit the model from using future values of past and / or future covariates for prediction (depending on the model's covariate support). output_chunk_shift Optionally, the number of steps to shift the start of the output chunk into the future (relative to the input chunk end). This will create a gap between the input and output. If the model supports `future_covariates`, the future values are extracted from the shifted output chunk. Predictions will start `output_chunk_shift` steps after the end of the target `series`. If `output_chunk_shift` is set, the model cannot generate autoregressive predictions (`n > output_chunk_length`). The covariate values inside the gap are unknown to the model and are masked out. likelihood The likelihood model to be used for probabilistic forecasts. Must be ``None`` or an instance of :class:`~darts.utils.likelihood_models.torch.QuantileRegression`. If using ``QuantileRegression``, the quantiles must be a subset of those used during TimesFM 3.0 pre-training: [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]. Default: ``None``, which will make the model deterministic (median quantile only). When fine-tuning is enabled, the training loss is always computed on all pre-trained quantiles to preserve the full distribution, regardless of the ``likelihood`` setting. The ``likelihood`` parameter only affects prediction output. accept_license Must be set to ``True`` to confirm acceptance of the TimesFM Non-Commercial License v1.0 for the pre-trained weights. Default: ``False``. hub_model_name The model ID on HuggingFace Hub. Default: ``"google/timesfm-3.0-pytorch"``. hub_model_revision The model version to use. This can be a branch name, tag name, or commit hash. Default is ``None``, which will use the default branch from ``hub_model_name``. local_dir Optional local directory to load the pre-downloaded model. If specified and the directory is empty, the model will be downloaded from HuggingFace Hub and saved to this directory. Default is ``None``, which will use a cache directory managed by ``huggingface_hub`` instead. Note that this is different from the ``work_dir`` parameter used for saving model checkpoints during fine-tuning. **kwargs Optional arguments to initialize the pytorch_lightning.Module, pytorch_lightning.Trainer, and Darts' :class:`TorchForecastingModel`. loss_fn PyTorch loss function used for fine-tuning a deterministic model. Ignored for probabilistic models when ``likelihood`` is specified. Default: ``nn.MSELoss()``. torch_metrics A torch metric or a ``MetricCollection`` used for evaluation. A full list of available metrics can be found at https://torchmetrics.readthedocs.io/en/latest/. Default: ``None``. optimizer_cls The PyTorch optimizer class to be used. Default: ``torch.optim.Adam``. optimizer_kwargs Optionally, some keyword arguments for the PyTorch optimizer (e.g., ``{'lr': 1e-3}`` for specifying a learning rate). Otherwise, the default values of the selected ``optimizer_cls`` will be used. Default: ``None``. lr_scheduler_cls Optionally, the PyTorch learning rate scheduler class to be used. Specifying ``None`` corresponds to using a constant learning rate. Default: ``None``. lr_scheduler_kwargs Optionally, some keyword arguments for the PyTorch learning rate scheduler. Default: ``None``. batch_size Number of time series (input and output sequences) used in each training pass. Default: ``32``. n_epochs Number of epochs over which to train the model. Default: ``100``. model_name Name of the model. Used for creating checkpoints and saving tensorboard data. If not specified, defaults to the following string ``"YYYY-mm-dd_HH_MM_SS_torch_model_run_PID"``, where the initial part of the name is formatted with the local date and time, while PID is the process ID (preventing models spawned at the same time by different processes to share the same model_name). E.g., ``"2021-06-14_09_53_32_torch_model_run_44607"``. work_dir Path of the working directory, where to save checkpoints and Tensorboard summaries. Default: current working directory. log_tensorboard If set, use Tensorboard to log the different parameters. The logs will be located in ``"{work_dir}/darts_logs/{model_name}/logs/"``. Default: ``False``. nr_epochs_val_period Number of epochs to wait before evaluating the validation loss (if a validation ``TimeSeries`` is passed to the :func:`fit()` method). Default: ``1``. force_reset If set to ``True``, any previously-existing model with the same name will be reset (all checkpoints will be discarded). Default: ``False``. save_checkpoints Whether to automatically save the untrained model and checkpoints from training. To load the model from checkpoint, call :func:`MyModelClass.load_from_checkpoint()`, where :class:`MyModelClass` is the :class:`TorchForecastingModel` class that was used (such as :class:`TFTModel`, :class:`NBEATSModel`, etc.). If set to ``False``, the model can still be manually saved using :func:`save()` and loaded using :func:`load()`. Default: ``False``. add_encoders A large number of past and future covariates can be automatically generated with `add_encoders`. This can be done by adding multiple pre-defined index encoders and/or custom user-made functions that will be used as index encoders. Additionally, a transformer such as Darts' :class:`Scaler` can be added to transform the generated covariates. This happens all under one hood and only needs to be specified at model creation. Read :meth:`SequentialEncoder <darts.dataprocessing.encoders.SequentialEncoder>` to find out more about ``add_encoders``. Default: ``None``. An example showing some of ``add_encoders`` features: .. highlight:: python .. code-block:: python def encode_year(idx): return (idx.year - 1950) / 50 add_encoders={ 'cyclic': {'future': ['month']}, 'datetime_attribute': {'future': ['hour', 'dayofweek']}, 'position': {'past': ['relative'], 'future': ['relative']}, 'custom': {'past': [encode_year]}, 'transformer': Scaler(), 'tz': 'CET' } .. random_state Controls the randomness of the weights initialization and reproducible forecasting. pl_trainer_kwargs By default :class:`TorchForecastingModel` creates a PyTorch Lightning Trainer with several useful presets that performs the training, validation and prediction processes. These presets include automatic checkpointing, tensorboard logging, setting the torch device and more. With ``pl_trainer_kwargs`` you can add additional kwargs to instantiate the PyTorch Lightning trainer object. Check the `PL Trainer documentation <https://pytorch-lightning.readthedocs.io/en/stable/common/trainer.html>`__ for more information about the supported kwargs. Default: ``None``. Running on GPU(s) is also possible using ``pl_trainer_kwargs`` by specifying keys ``"accelerator", "devices", and "auto_select_gpus"``. Some examples for setting the devices inside the ``pl_trainer_kwargs`` dict: - ``{"accelerator": "cpu"}`` for CPU, - ``{"accelerator": "gpu", "devices": [i]}`` to use only GPU ``i`` (``i`` must be an integer), - ``{"accelerator": "gpu", "devices": -1, "auto_select_gpus": True}`` to use all available GPUs. For more info, see here: https://pytorch-lightning.readthedocs.io/en/stable/common/trainer.html#trainer-flags , and https://pytorch-lightning.readthedocs.io/en/stable/common/trainer.html#accelerators-gpu-basic With parameter ``"callbacks"`` you can add custom or PyTorch-Lightning built-in callbacks to Darts' :class:`TorchForecastingModel`. Below is an example for adding EarlyStopping to the training process. The model will stop training early if the validation loss `val_loss` does not improve beyond specifications. .. highlight:: python .. code-block:: python from pytorch_lightning.callbacks.early_stopping import EarlyStopping # stop training when validation loss does not decrease more than 0.05 (`min_delta`) over # a period of 5 epochs (`patience`) my_stopper = EarlyStopping( monitor="val_loss", patience=5, min_delta=0.05, mode='min', ) pl_trainer_kwargs={"callbacks": [my_stopper]} .. Note that you can also use a custom PyTorch Lightning Trainer for training and prediction with optional parameter ``trainer`` in :func:`fit()` and :func:`predict()`. show_warnings whether to show warnings raised from PyTorch Lightning. Useful to detect potential issues of your forecasting use case. Default: ``False``. enable_finetuning Enables model fine-tuning. Only effective if not ``None``. If a bool, specifies whether to perform full fine-tuning / training (all parameters are updated) or keep all parameters frozen. If a dict, specifies which parameters to fine-tune. Must only contain one key-value record. Can be used to: - Unfreeze specific parameters, while keeping everything else frozen: ``{"unfreeze": ["param.name.patterns.*"]}`` - Freeze specific parameters, while keeping everything else unfrozen: ``{"freeze": ["param.name.patterns.*"]}`` Default: ``None``. References ---------- .. [1] A. Das, W. Kong, R. Sen, Y. Zhou. "A decoder-only foundation model for time-series forecasting", 2025. arXiv https://arxiv.org/abs/2310.10688. .. [2] "TimesFM 3.0", 2026. Google Research. https://huggingface.co/google/timesfm-3.0-pytorch Examples -------- Point forecasting: >>> from darts.models import TimesFM3Model >>> from darts.datasets import AirPassengersDataset >>> series = AirPassengersDataset().load().astype("float32") >>> # you must explicitly set `accept_license=True` to use the model >>> model = TimesFM3Model( ... input_chunk_length=12, ... output_chunk_length=6, ... accept_license=False, ... ) >>> model.fit(series) >>> pred = model.predict(n=6) >>> pred #Passengers Month 1961-01-01 436.861908 1961-02-01 440.754639 1961-03-01 449.636871 1961-04-01 458.311768 1961-05-01 459.939941 1961-06-01 466.235779 Probabilistic forecasting: >>> from darts.utils.likelihood_models import QuantileRegression >>> # you must explicitly set `accept_license=True` to use the model >>> model = TimesFM3Model( ... input_chunk_length=12, ... output_chunk_length=6, ... likelihood=QuantileRegression(quantiles=[0.1, 0.5, 0.9]), ... accept_license=False, ... ) >>> model.fit(series) >>> pred = model.predict(n=6, predict_likelihood_parameters=True) >>> pred #Passengers_q0.100 #Passengers_q0.500 #Passengers_q0.900 Month 1961-01-01 380.856689 436.861908 517.565186 1961-02-01 365.766022 440.754639 550.635132 1961-03-01 361.614166 449.636871 581.230164 1961-04-01 356.575531 458.311768 607.764648 1961-05-01 347.237854 459.939941 618.507874 1961-06-01 345.421265 466.235779 631.610840 """ if not accept_license: raise_log( ValueError( "TimesFM 3.0 pre-trained weights are distributed under the TimesFM " "Non-Commercial License v1.0 (non-commercial, non-production use). " "Set `accept_license=True` to confirm you have reviewed and accept " "the terms: " "https://huggingface.co/google/timesfm-3.0-pytorch/blob/main/LICENSE" ), ) # validate `input_chunk_length` against the model's maximum context length _, max_icl = _parse_input_chunk_length(input_chunk_length) if max_icl > self._MAX_CONTEXT_LENGTH: raise_log( ValueError( f"`input_chunk_length` {max_icl} cannot be greater than model's " f"maximum context length {self._MAX_CONTEXT_LENGTH}" ), ) # load the model configuration for validation, as done by `Chronos2Model` hf_connector = HuggingFaceConnector( model_name=hub_model_name, model_revision=hub_model_revision, local_dir=local_dir, ) config = hf_connector.load_config() self._model_quantiles = tuple(config["quantiles"]) self._max_variates = config["transformer_config"]["transformer"]["max_variates"] # by default (`likelihood=None`), model is deterministic; otherwise, only # QuantileRegression likelihood is supported and quantiles must be a subset of # the quantiles used during pre-training (read from the model configuration) if likelihood is not None: if not isinstance(likelihood, QuantileRegression): raise_log( ValueError( f"Only QuantileRegression likelihood is supported for TimesFM 3.0 " f"in Darts. Got {type(likelihood)}." ), ) user_quantiles: list[float] = likelihood.quantiles if not set(user_quantiles).issubset(self._model_quantiles): raise_log( ValueError( f"The quantiles for QuantileRegression likelihood {user_quantiles} " f"must be a subset of TimesFM 3.0 quantiles " f"{self._model_quantiles}." ), ) super().__init__(**kwargs) def _create_model(self, train_sample: TorchTrainingSample) -> PLForecastingModule: n_variates = sum( el.shape[1] for el in [ train_sample.past_target, train_sample.past_covariates, train_sample.future_covariates, ] if el is not None ) if n_variates > self._max_variates: raise_log( ValueError( f"The total number of target and covariate components {n_variates} " f"exceeds the maximum number of variates {self._max_variates} " f"supported by the TimesFM 3.0 checkpoint." ), ) # the architecture parameters are extracted from the HuggingFace `config.json` pl_module_params = self.pl_module_params or {} return self.hf_connector.load_model( module_class=_TimesFM3Module, pl_module_params=pl_module_params, )