Source code for openstef_foundation_models.models.forecasting.chronos2_forecaster

# SPDX-FileCopyrightText: 2025 Contributors to the OpenSTEF project <openstef@lfenergy.org>
#
# SPDX-License-Identifier: MPL-2.0

"""Chronos-2 foundation-model forecaster.

:class:`Chronos2Forecaster` adapts the generic
:class:`~openstef_foundation_models.inference.backend.InferenceBackend` to the
OpenSTEF :class:`~openstef_models.models.forecasting.forecaster.Forecaster`
contract. It owns the Chronos-2 specific pre- and post-processing while the
backend stays model-agnostic:

- **Preprocessing** builds the ``context``, ``attention_mask``, ``group_ids``,
  ``future_covariates`` and ``future_covariates_mask`` tensors. Every non-target
  feature column is treated as a *known* covariate: its history feeds an extra
  context row and its horizon values feed ``future_covariates``. Chronos-2
  shares attention within a group, so the target series and its covariates share
  one ``group_id``. Chronos-2 normalises each series internally, so raw values
  are fed unscaled.
- **Postprocessing** picks each series' target row out of the batched output,
  slices the model's frozen horizon to the requested length and resamples the
  model-native quantile grid onto the requested quantiles.

The model is zero-shot: there is nothing to train, so :meth:`fit` is a no-op and
:attr:`is_fitted` is always ``True`` once a backend is attached.
"""

from typing import ClassVar, override

import numpy as np
import pandas as pd
from pydantic import Field

from openstef_core.datasets.validated_datasets import ForecastDataset, ForecastInputDataset
from openstef_core.mixins.predictor import BatchResult, HyperParams
from openstef_core.utils.numpy import interpolate_quantiles, zero_fill_with_mask
from openstef_foundation_models.inference.backend import InferenceBackend
from openstef_models.models.forecasting.forecaster import Forecaster


[docs] class Chronos2HyperParams(HyperParams): """Hyperparameters for :class:`Chronos2Forecaster`. Chronos-2 is a pretrained zero-shot model, so it exposes no trainable or tunable hyperparameters. The class exists to satisfy the forecaster contract and to host future inference-time knobs. """
[docs] class Chronos2Forecaster(Forecaster): """Zero-shot probabilistic forecaster backed by a Chronos-2 checkpoint. The forecaster composes an :class:`InferenceBackend` (built once and reused across an entire backtest) and translates between OpenSTEF datasets and the model's tensor interface. Prediction is batch-first: :meth:`predict_batch` runs the backend once over a stack of series and :meth:`predict` is a batch-of-one wrapper. """ HyperParams: ClassVar[type[Chronos2HyperParams]] = Chronos2HyperParams backend: InferenceBackend = Field( description="Execution backend wrapping the resolved Chronos-2 checkpoint.", ) hyperparams: Chronos2HyperParams = Field( default_factory=Chronos2HyperParams, description="Inference hyperparameters (none are tunable for Chronos-2).", ) supports_batching: bool = Field( default=True, description="Chronos-2 runs a whole batch of series in a single backend call.", ) @property @override def hparams(self) -> Chronos2HyperParams: return self.hyperparams @property @override def is_fitted(self) -> bool: return True
[docs] @override def fit(self, data: ForecastInputDataset, data_val: ForecastInputDataset | None = None) -> None: """Fit the forecaster. Chronos-2 is pretrained and zero-shot, so there is nothing to fit. The method exists only to satisfy the forecaster contract. Args: data: Unused training data. data_val: Unused validation data. """
[docs] @override def predict(self, data: ForecastInputDataset) -> ForecastDataset: """Forecast a single series. Args: data: Input dataset whose target history provides the model context. Returns: Probabilistic forecast for the requested quantiles and horizon. """ return self._forecast([data])[0]
[docs] @override def predict_batch(self, data: list[ForecastInputDataset]) -> BatchResult[ForecastDataset]: """Forecast a batch of series in a single backend call. Args: data: Input datasets to forecast. Each provides its own target history and forecast start. Returns: One forecast per input dataset, in the same order. """ results: BatchResult[ForecastDataset] = [] results.extend(self._forecast(data)) return results
def _forecast(self, batch: list[ForecastInputDataset]) -> list[ForecastDataset]: """Run the backend once over *batch* and post-process each forecast. Each series contributes a target row plus one row per covariate, all sharing a ``group_id``. After inference, each series' target row is sliced back out of the batched output. Args: batch: Input datasets to forecast. Returns: One forecast dataset per input, in the same order. """ inputs, target_indices = self._build_inputs(batch) outputs = self.backend.run(inputs) predictions = np.asarray(outputs[self.backend.metadata.output_name]) return [ self._build_forecast(data, predictions[target_index]) for data, target_index in zip(batch, target_indices, strict=True) ] def _build_inputs(self, batch: list[ForecastInputDataset]) -> tuple[dict[str, np.ndarray], list[int]]: """Assemble the batched model input tensors for a batch of series. Each series becomes a matrix of ``target + covariate`` rows over one ``context + horizon`` grid; the matrices are concatenated along the row (batch) axis and split once into the context and future blocks. Rows of a series share a ``group_id`` so Chronos-2 attends across the group. The target row's future is exactly what the model predicts, so it is blanked out. Args: batch: Input datasets to forecast. Returns: A tuple of the input mapping (``context``, ``attention_mask``, ``group_ids``, ``future_covariates``, ``future_covariates_mask``) and the output-row index of each series' target row. """ context_length = self.backend.metadata.context_length horizon_length = self.backend.metadata.horizon_length max_covariates = self.backend.metadata.max_covariates matrices: list[np.ndarray] = [] group_ids: list[int] = [] target_indices: list[int] = [] for group_id, data in enumerate(batch): target_indices.append(len(group_ids)) matrix = self._build_group_matrix(data, context_length, horizon_length, max_covariates) matrices.append(matrix) group_ids.extend([group_id] * matrix.shape[0]) values, mask = zero_fill_with_mask(np.concatenate(matrices)) # The target row's future is exactly what the model predicts: blank it out. values[target_indices, context_length:] = np.float32(0.0) mask[target_indices, context_length:] = np.float32(0.0) inputs = { "context": values[:, :context_length], "attention_mask": mask[:, :context_length], "group_ids": np.asarray(group_ids, dtype=np.int64), "future_covariates": values[:, context_length:], "future_covariates_mask": mask[:, context_length:], } return inputs, target_indices @staticmethod def _build_group_matrix( data: ForecastInputDataset, context_length: int, horizon_length: int, max_covariates: int | None, ) -> np.ndarray: """Build the ``target + covariate`` matrix for a single series group. The target and every covariate column are reindexed onto one grid that spans the context window and the forecast horizon. Row 0 is the target; each remaining row is a covariate. Missing timestamps stay ``NaN`` here - the caller turns them into zeros plus a mask. When ``max_covariates`` is set the checkpoint froze its covariate axis, so the row count must be exactly ``1 + max_covariates``: a series with fewer covariates is padded with all-``NaN`` rows (masked out downstream, so they do not affect the forecast) and one with more is rejected, turning what would be an opaque ONNX Runtime shape error into a clear message. Args: data: Input dataset providing the target and covariate columns. context_length: Number of context steps the model consumes. horizon_length: Frozen forecast horizon the model emits. max_covariates: Frozen covariate-row count, or ``None`` when the covariate axis is dynamic and no padding is needed. Returns: Matrix of shape ``(n_rows, context_length + horizon_length)``, target row first, where ``n_rows`` is ``1 + max_covariates`` when the axis is frozen. Raises: ValueError: If the series has more covariates than the frozen ``max_covariates`` axis can hold. """ columns = [data.target_column, *(name for name in data.feature_names if name != data.target_column)] forecast_start = pd.Timestamp(data.forecast_start) index = pd.date_range( start=forecast_start - context_length * data.sample_interval, periods=context_length + horizon_length, freq=data.sample_interval, ) matrix = data.data[columns].reindex(index).to_numpy(dtype=np.float32).T if max_covariates is None: return matrix n_covariates = matrix.shape[0] - 1 if n_covariates > max_covariates: msg = ( f"Series has {n_covariates} covariates but the checkpoint's covariate axis is frozen at " f"{max_covariates}; drop covariates or use a dynamic-shape checkpoint." ) raise ValueError(msg) if n_covariates < max_covariates: padding = np.full((max_covariates - n_covariates, matrix.shape[1]), np.nan, dtype=np.float32) matrix = np.concatenate([matrix, padding]) return matrix def _build_forecast(self, data: ForecastInputDataset, predictions: np.ndarray) -> ForecastDataset: """Post-process one series' raw quantile predictions into a dataset. Args: data: Input dataset the prediction was produced for. predictions: Raw model output of shape ``(n_native_quantiles, horizon)``. Returns: Forecast dataset sliced to the requested horizon and resampled onto the requested quantiles. """ native = predictions.T # (horizon, n_native_quantiles) forecast_index = data.create_forecast_range(self.max_horizon) steps = min(len(forecast_index), native.shape[0]) forecast_index = forecast_index[:steps] resampled = interpolate_quantiles( native[:steps], self.backend.metadata.native_quantiles, self.quantiles, ) return ForecastDataset.from_quantile_predictions( predictions=resampled, index=forecast_index, quantiles=self.quantiles, sample_interval=data.sample_interval, target_column=data.target_column, )
__all__ = [ "Chronos2Forecaster", "Chronos2HyperParams", ]