More about forecasting in cienciadedatos.net
- Time series forecasting with machine learning
- ARIMA and SARIMAX models
- Time series forecasting with gradient boosting
- Exponential smoothing models
- ARAR forecasting models
- Visualizing time series data
- Electricity demand forecasting
- Global forecasting models I
- Global forecasting models II
- Global forecasting models III
- Clustering time series
- Forecasting with deep learning models
- Probabilistic forecasting I
- Probabilistic forecasting II
- Probabilistic forecasting III
- Data drift detection in time series forecasting models
- Forecasting categorical time series
- Interpretability and explainability in forecasting models
What is skforecast-ai?¶
skforecast-ai is an AI forecasting assistant that pairs a deterministic engine, powered by skforecast, with an LLM reasoning layer. Simply provide a time series, and the assistant automatically profiles the data, selects a model using established best practices, and evaluates its performance. It returns both the final forecast and the runnable skforecast script that produced it.
It is organized around a single core object, the ForecastingAssistant, which consists of two complementary components:
Deterministic Engine (Rule-based and Reproducible): Profiles the data, selects a forecaster and estimator, derives lags and preprocessing steps, runs backtesting, and produces the final forecast. Crucially, it outputs the exact standalone
skforecastscript that generated the results. Given the same inputs and configuration, this workflow is guaranteed to be reproducible.Reasoning Layer (LLM-powered): Accessed primarily via the
ask()method, this layer interprets and explains the objects and results you pass to it: data profiles, modeling plans, validation choices, backtesting outputs, and forecasts. The LLM acts strictly as an interpreter; it does not rerun the workflow or silently change modeling recommendations behind the scenes. Agentic features, such as the LLM-guidedrefine_plan()orcreate_cv(), are separate, explicit steps where the LLM suggests adjustments that are then implemented transparently in deterministic code.
Why skforecast-ai?¶
- 🎯 Deterministic by design: built as a strict rule-based engine to guarantee absolute consistency, same input always means the same output.
- 🔍 Code you can inspect: the script you see is the code that ran. Inspect it, version it, or run it standalone with plain skforecast.
- ⚡ From data to forecast in one call: automatic data profiling, model and estimator selection, lag/feature engineering, and backtest evaluation.
- 💻 Python or terminal: drive the full pipeline from a few lines of Python or from the command line.
- 💬 LLM reasoning layer: explains the engine's decisions in plain language, helps you improve the configuration, and lets you ask for advice. This layer is entirely optional; the core forecasting pipeline can run fully offline.
- 🏗️ Built on skforecast: recursive & direct forecasters, multi-series, statistical, and foundation models (Chronos-2, TimesFM, Moirai, and more).
Quickstart (Python)¶
From raw data to a validated forecast, and the code behind it, in a few lines:
import pandas as pd
from skforecast_ai import ForecastingAssistant
from skforecast.datasets import load_demo_dataset
data = load_demo_dataset(verbose=False)
assistant = ForecastingAssistant()
result = assistant.forecast(data=data, target='y', steps=12)
print(result.predictions) # forecast for the next 12 steps
print(result.metrics) # evaluation metrics: MAE, MSE, MASE, MAPE
print(result.code) # the exact skforecast script that produced this result
That single forecast() call profiled the data, chose a forecaster and estimator, generated a skforecast script, and executed it. result.code is the script that ran.
Quickstart (CLI)¶
The same pipeline runs from the terminal. Point it at a CSV file or URL:
# End-to-end forecast (profile -> plan -> code -> forecast)
skforecast-ai forecast data.csv --target y --date-column datetime --steps 12
# Just inspect the data
skforecast-ai profile data.csv --target y --date-column datetime
# Generate a standalone, runnable script without executing it
skforecast-ai forecast-code data.csv --target y --date-column datetime --steps 12 --output forecast.py
Two ways to use skforecast-ai¶
skforecast-ai supports two distinct workflows using the same underlying forecasting engine:
The Fast Path: Use this when you want a forecast or backtest result in a single call. The assistant profiles the data, builds the modeling plan, executes the workflow, and returns the results alongside the reproducible
skforecastcode.The Step-by-Step Path: Use this when you want granular control to inspect or adjust intermediate decisions. You can manually create a profile, build a plan, optionally refine it with the LLM, define a validation strategy, evaluate the model, and then generate the forecast.
A useful mental model is that forecasting and validation are separate branches. Once you have a profile and a plan, you can use forecast() to produce future predictions directly, or backtest() to evaluate the model's performance on historical data. You can also use compare() to evaluate several candidate configurations under the same cross-validation strategy and obtain a ranked leaderboard, so the best configuration is chosen from measured performance rather than intuition.
The ask() method is available in both workflows. It can explain a profile, plan, validation setup, backtest result, comparison result, or answer general forecasting questions, but it will never execute the workflow or modify your parameters without explicit instruction.
The rest of this guide sets up the assistant and the dataset used throughout, then walks through the step-by-step path in detail. For the fast path -- the quickest way to go from raw data to a validated forecast with minimal setup -- see the Quickstart section above; it is ideal when you want rapid results and trust the assistant to make sensible, baseline modeling decisions on your behalf.
Assistant initialization¶
The first step is to instantiate a ForecastingAssistant, which will be responsible for executing the entire workflow (profiling, planning, backtesting, and forecasting), as well as explaining the outputs and suggesting improvements.
To activate the optional LLM support, users must pass a string in the format 'provider:model_name' (for example, 'openai:gpt-5.5', 'google:gemini-3-flash-preview', 'anthropic:claude-sonnet-5', or 'ollama:qwen3:8b'). For hosted providers, the corresponding API key must be available as an environment variable or passed explicitly when creating the assistant. In this tutorial, we set send_data_to_llm=False. This ensures strict data privacy: the LLM receives only metadata and summary statistics, never the raw time series values.
# Data processing
# ==============================================================================
import os
import textwrap
import pandas as pd
from skforecast.datasets import fetch_dataset
# Plots
# ==============================================================================
from skforecast.plot import set_dark_theme
import matplotlib.pyplot as plt
import plotly.graph_objects as go
import plotly.io as pio
import plotly.offline as poff
pio.templates.default = "seaborn"
poff.init_notebook_mode(connected=True)
plt.style.use('seaborn-v0_8-darkgrid')
# skforecast and skforecast-ai
# ==============================================================================
import skforecast
import skforecast_ai
import chronos # pip install chronos-forecasting
from skforecast_ai import ForecastingAssistant
from skforecast.model_selection import TimeSeriesFold
color = '\033[1m\033[38;5;208m'
print(f"{color}Version skforecast_ai: {skforecast_ai.__version__}")
print(f"{color}Version skforecast: {skforecast.__version__}")
print(f"{color}Version chronos-forecasting: {chronos.__version__}")
Version skforecast_ai: 0.2.0 Version skforecast: 0.24.0 Version chronos-forecasting: 2.3.1
✏️ Note
If you do not have access to an LLM assistant, you can still follow the full tutorial using only the deterministic methods. Profiling, planning, backtesting, and forecasting all run without an LLM. Only the ask() explanations and the LLM-guided variants of refine_plan() and create_cv() require a configured LLM; their deterministic counterparts (for example, refine_plan() with explicit overrides and prompt=None) work without one.
# LLM-enabled assistant
# ==============================================================================
LLM_MODEL = "google:gemini-3.5-flash"
api_key = os.getenv("GOOGLE_API_KEY")
assistant = ForecastingAssistant(
llm=LLM_MODEL, api_key=api_key, send_data_to_llm=False
)
# Using aws bedrock
# ==============================================================================
assistant = ForecastingAssistant(
llm='bedrock:eu.anthropic.claude-sonnet-4-6',
base_url="eu-west-1"
)
# Assistant without reasoning layer
# ==============================================================================
# assistant = ForecastingAssistant()
⚠️ Your data stays private
By default, enabling an LLM does not send your time-series data to the model provider.
The assistant passes only summary statistics, detected frequency,
seasonality flags and the forecaster configuration, never the raw observations.
To explicitly allow it, pass send_data_to_llm=True.
Data¶
The data in this document represent the hourly usage of the bike share system in the city of Washington, D.C. during the years 2011 and 2012. In addition to the number of users per hour, information about weather conditions and holidays is available.
# Downloading data
# ==============================================================================
data = fetch_dataset('bike_sharing', raw=True)
data = data[['date_time', 'users', 'holiday', 'weather', 'temp']]
data['date_time'] = pd.to_datetime(data['date_time'])
data.head()
╭───────────────────────────────── bike_sharing ──────────────────────────────────╮ │ Description: │ │ Hourly usage of the bike share system in the city of Washington D.C. during the │ │ years 2011 and 2012. In addition to the number of users per hour, information │ │ about weather conditions and holidays is available. │ │ │ │ Source: │ │ Fanaee-T,Hadi. (2013). Bike Sharing Dataset. UCI Machine Learning Repository. │ │ https://doi.org/10.24432/C5W894. │ │ │ │ URL: │ │ https://raw.githubusercontent.com/skforecast/skforecast- │ │ datasets/main/data/bike_sharing_dataset_clean.csv │ │ │ │ Shape: 17544 rows x 12 columns │ ╰─────────────────────────────────────────────────────────────────────────────────╯
| date_time | users | holiday | weather | temp | |
|---|---|---|---|---|---|
| 0 | 2011-01-01 00:00:00 | 16.0 | 0.0 | clear | 9.84 |
| 1 | 2011-01-01 01:00:00 | 40.0 | 0.0 | clear | 9.02 |
| 2 | 2011-01-01 02:00:00 | 32.0 | 0.0 | clear | 9.02 |
| 3 | 2011-01-01 03:00:00 | 13.0 | 0.0 | clear | 9.84 |
| 4 | 2011-01-01 04:00:00 | 1.0 | 0.0 | clear | 9.84 |
✏️ Note
skforecast-ai is ready to preprocess the data, but it is recommended that users apply their own preprocessing steps before using the assistant. This ensures the data is in the desired format and any necessary transformations have been applied before proceeding with the forecasting workflow.
# Interactive plot of time series
# ==============================================================================
fig = go.Figure()
fig.add_trace(
go.Scatter(x=data['date_time'], y=data['users'], mode='lines', name='Users')
)
fig.update_layout(
title = 'Number of users',
xaxis_title="Time",
yaxis_title="Users",
legend_title="Partition:",
width=800,
height=400,
margin=dict(l=20, r=20, t=35, b=20),
legend=dict(orientation="h", yanchor="top", y=1, xanchor="left", x=0.001)
)
fig.show()
For a deeper walkthrough of the exploratory analysis behind this dataset, see the skforecast example: Forecasting time series with skforecast, XGBoost, LightGBM and CatBoost.
Deep Dive: The Step-by-Step¶
While the fast path is great for getting a baseline, many data scientists need to control, inspect, and override intermediate decisions. The step-by-step path breaks the process into distinct, observable phases: Profiling, Planning, and Execution (Forecasting or Backtesting).
Profile the data¶
The profile() method is the first stage of the step-by-step workflow. It inspects the dataset and returns a ForecastingProfile object that contains:
Data metadata: detected frequency, index type, series lengths, missing values, and exogenous column roles.
Modeling recommendations: the selected forecaster family and estimator, along with alternative candidates and the reasoning behind each choice.
Lag structure: PACF-significant lags per series, used as a baseline for the planning stage.
Window feature suggestions: rolling statistics configurations appropriate for the detected seasonality.
This is a purely deterministic step: no LLM is involved. The profile object is a prerequisite for both plan() and ask() explain mode.
| Attribute | Description |
|---|---|
data_profile |
Full dataset metadata: frequency, index type, series lengths, missing values, exog columns |
forecaster |
Recommended skforecast forecaster class name |
forecaster_candidates |
Ordered list of compatible forecaster names |
estimator |
Recommended estimator class name (None for statistical models) |
estimator_candidates |
Ordered list of compatible estimator names |
series_pacf |
Per-series PACF-significant lags (used by plan() to set default lags) |
window_features |
Suggested window feature configurations |
calendar_features |
Recommended calendar feature names based on detected seasonality |
explanation |
Human-readable explanation of why this forecaster and estimator were selected |
# Profile the data
# ==============================================================================
profile = assistant.profile(
data = data,
target = 'users',
date_column = 'date_time'
)
# Inspect the profile
# ==============================================================================
profile
Dataset Profile ┏━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ Property ┃ Value ┃ ┡━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ Format │ single │ ├────────────────┼────────────────────────────────────────────────┤ │ Series │ 1 │ ├────────────────┼────────────────────────────────────────────────┤ │ Observations │ 17544 │ ├────────────────┼────────────────────────────────────────────────┤ │ Frequency │ h │ ├────────────────┼────────────────────────────────────────────────┤ │ Target │ users │ ├────────────────┼────────────────────────────────────────────────┤ │ Exog columns │ holiday, weather, temp (categorical: weather) │ ├────────────────┼────────────────────────────────────────────────┤ │ Missing values │ None │ └────────────────┴────────────────────────────────────────────────┘ Recommendation ┏━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ Property ┃ Value ┃ ┡━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ Task type │ single_series │ ├───────────────────────┼─────────────────────────────────────────────────────────────┤ │ Forecaster │ ForecasterRecursive │ ├───────────────────────┼─────────────────────────────────────────────────────────────┤ │ Forecaster candidates │ ForecasterRecursive, ForecasterDirect, ForecasterFoundation │ ├───────────────────────┼─────────────────────────────────────────────────────────────┤ │ Estimator │ LGBMRegressor │ ├───────────────────────┼─────────────────────────────────────────────────────────────┤ │ Estimator candidates │ LGBMRegressor, XGBRegressor, Ridge │ └───────────────────────┴─────────────────────────────────────────────────────────────┘ ╭───────────────────────────────── Profile Explanation ──────────────────────────────────╮ │ │ │ A single-series ML forecaster (ForecasterRecursive) is recommended. Data: 17544 │ │ observations, 'h' frequency. Alternative forecasters: ['ForecasterDirect', │ │ 'ForecasterFoundation']. Estimator: LGBMRegressor. A gradient boosting model is │ │ preferred for a dataset of this size (17544 observations). Alternative estimators: │ │ ['XGBRegressor', 'Ridge']. 3 exogenous variables (1 categorical) available as │ │ predictors. │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯
Once you have a profile, you can pass it to ask() to get an LLM-generated explanation of the modeling decisions. Note that the pre-computed profile is passed directly, so no profiling work is repeated.
# Ask the assistant to explain the profile
# ==============================================================================
answer = assistant.ask(
prompt = (
"Explain why this forecaster and estimator were recommended for my "
"hourly bike-sharing demand data, and what the exogenous variables add."
),
profile = profile,
steps = 36,
)
answer.show_explanation()
╭────────────────────────────────── Assistant Response ──────────────────────────────────╮ │ │ │ The forecaster and estimator were chosen based on your data size, frequency, and │ │ available features. With 17,544 hourly observations, a single-series ML approach │ │ using ForecasterRecursive with LGBMRegressor is the recommended starting point, and │ │ the three exogenous variables (holiday, weather, temp) directly enrich each │ │ prediction with real-world context. │ │ │ │ Why ForecasterRecursive │ │ │ │ ForecasterRecursive trains a single model and uses its own predictions as inputs │ │ when projecting multiple steps ahead. For a 36-step horizon on hourly data, this is │ │ efficient and flexible. It avoids the overhead of training one model per step, and │ │ the selected lags and window features are designed to capture the cycling patterns │ │ in your series. │ │ │ │ The alternative ForecasterDirect would train 36 separate models, one per forecast │ │ step, which may be useful if the relationship between past demand and future demand │ │ changes substantially across the horizon, but the recursive approach is the │ │ recommended default here. │ │ │ │ Why LGBMRegressor │ │ │ │ LightGBM (gradient boosting) is preferred for a dataset of this size. It handles: │ │ │ │ • A large number of observations efficiently │ │ • Mixed feature types, including the categorical weather variable, natively via │ │ skforecast's categorical_features='auto' setting │ │ • Non-linear interactions between lags, calendar signals, and exogenous drivers │ │ without manual feature engineering │ │ │ │ Alternative estimators listed were XGBRegressor and Ridge, but gradient boosting is │ │ the primary recommendation at this data scale. │ │ │ │ What the Lags and Window Features Capture │ │ │ │ The lag set includes short-range lags (1, 2, 3) for immediate autocorrelation, daily │ │ lags around 24 for the hour-of-day cycle, weekly lags around 168 for the day-of-week │ │ cycle, and longer lags (313, 335, 337) that may associate with patterns further back │ │ in time. The window features add rolling summaries: │ │ │ │ • Rolling mean and std over 3 hours, capturing very recent local trends │ │ • Rolling mean over 24 hours, capturing the daily average level │ │ • Rolling mean over 168 hours, capturing the weekly average level │ │ │ │ What the Exogenous Variables Add │ │ │ │ The three exogenous columns extend what the model can explain beyond pure │ │ autocorrelation: │ │ │ │ • holiday: flags days when commuter or leisure cycling patterns shift, which may │ │ not be predictable from lags alone │ │ • weather: a categorical variable (handled automatically) that may be associated │ │ with riders choosing to cycle or stay home │ │ • temp: a continuous variable that likely co-varies with demand, as temperature is │ │ commonly associated with outdoor activity levels │ │ │ │ Together, these variables give the model information about conditions at the │ │ forecast horizon that the target series itself cannot provide, making predictions │ │ more context-aware than a lags-only model would be. │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯
Build the plan¶
The plan() method converts the coarse modeling decisions in the ForecastingProfile into a fully-specified, executable configuration. It determines:
- Lags: derived from the PACF-significant lags detected in the profile. You can override these explicitly.
- Window features: rolling statistics configurations appropriate for the detected seasonality.
- Preprocessing steps: ordered list of transformations (e.g., differencing, scaling, NaN handling).
- Prediction interval method:
'bootstrapping','conformal', or'native'(selected based on the estimator). - Metrics: the primary and secondary evaluation metrics.
Like profile(), this is a deterministic step. The resulting ForecastPlan object is the complete blueprint that forecast() and backtest() execute.
| Attribute | Description |
|---|---|
forecaster |
Forecaster class name |
estimator |
Estimator class name |
forecaster_kwargs |
All constructor kwargs for the forecaster, including lags and window_features |
estimator_kwargs |
Constructor kwargs for the estimator |
steps |
Forecast horizon |
interval |
Prediction interval quantiles, e.g. [0.1, 0.9] |
interval_method |
Method used to produce the interval (bootstrapping, conformal, or native) |
use_exog |
Whether exogenous variables are included |
preprocessing_steps |
Ordered list of preprocessing actions with code snippets |
explanation |
Human-readable explanation of plan decisions |
# Build a plan from the profile
# ==============================================================================
plan = assistant.plan(
profile = profile,
steps = 36,
interval = [0.1, 0.9] # 80% prediction interval
)
# Inspect the plan
# ==============================================================================
plan
Forecast Plan ┏━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ Property ┃ Value ┃ ┡━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ Task type │ single_series │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Forecaster │ ForecasterRecursive │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Estimator │ LGBMRegressor │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Steps │ 36 │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Frequency │ h │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Lags │ [1, 2, 3, 5, 8, 10, 15, 17, 19, 20, 21, 22, 23, 24, 25, 26, 32, │ │ │ 33, 119, 121, 135, 136, 142, 143, 145, 160, 166, 167, 169, 313, │ │ │ 335, 337] │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Window features │ [{'stats': ['mean', 'std'], 'window_size': 3}, {'stats': ['mean'], │ │ │ 'window_size': 24}, {'stats': ['mean'], 'window_size': 168}] │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Calendar features │ ['hour', 'day_of_week', 'weekend', 'month'] (raw ordinal encoding) │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Use exog │ True │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Interval │ [0.1, 0.9] │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Interval method │ bootstrapping │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Primary metric │ mean_absolute_error │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Preprocessing │ 1 step │ └───────────────────┴────────────────────────────────────────────────────────────────────┘ Preprocessing Steps ┏━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ Step ┃ Reason ┃ ┡━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ handle_categorical_exog │ Categorical exogenous variables detected: ['weather']. These │ │ │ are handled automatically by skforecast │ │ │ (categorical_features='auto'). │ └─────────────────────────┴──────────────────────────────────────────────────────────────┘ ╭─────────────────────────────────── Plan Explanation ───────────────────────────────────╮ │ │ │ Plan: ForecasterRecursive + LGBMRegressor. Lags: [1, 2, 3, 5, 8, 10, 15, 17, 19, 20, │ │ 21, 22, 23, 24, 25, 26, 32, 33, 119, 121, 135, 136, 142, 143, 145, 160, 166, 167, │ │ 169, 313, 335, 337]. Window features: ['mean(window=3)', 'std(window=3)', │ │ 'mean(window=24)', 'mean(window=168)']. Calendar features: ['hour', 'day_of_week', │ │ 'weekend', 'month'] (raw ordinal encoding). Prediction intervals via bootstrapping. │ │ NaN rows kept (NaN-tolerant estimator). Exogenous variables included. MAE is │ │ interpretable, robust to outliers, and works at any scale. │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯
Pass both the profile and the plan to ask() for a detailed explanation of the chosen configuration.
# Ask the assistant to explain the plan
# ==============================================================================
answer = assistant.ask(
prompt = (
"Walk me through this plan. Why these lags and window features, "
"and how will the 80% prediction interval be produced?"
),
profile = profile,
plan = plan,
)
answer.show_explanation()
╭────────────────────────────────── Assistant Response ──────────────────────────────────╮ │ │ │ The plan builds a ForecasterRecursive with LGBMRegressor to forecast 36 steps ahead │ │ on hourly bike-sharing demand, using a rich set of lags, rolling window statistics, │ │ calendar features, and three exogenous variables. Here is a walkthrough of each │ │ design decision. │ │ │ │ Lag Selection │ │ │ │ The lag list contains 32 lags drawn from several distinct ranges: │ │ │ │ • Short lags (1, 2, 3, 5, 8, 10, 15): capture very recent momentum and short │ │ autocorrelation structure, meaning the last few hours of demand feed directly │ │ into the next prediction. │ │ • Daily lags (17 through 26, 32, 33): cluster around the 24-hour mark, capturing │ │ the same-hour pattern from yesterday. Lags like 23, 24, and 25 specifically │ │ bracket the exact daily repeat. │ │ • Multi-day lags (119, 121, 135, 136, 142, 143, 145, 160, 166, 167, 169): these are │ │ near the 5-day and 7-day marks (168 hours is one week). Lags 166, 167, and 169 │ │ bracket the weekly seasonal repeat, capturing the same hour from the same day │ │ last week. │ │ • Longer lags (313, 335, 337): these reach back roughly 13 days, picking up any │ │ bi-weekly or fortnightly patterns present in the series. │ │ │ │ The overall structure reflects that hourly ridership is likely governed by daily │ │ commuting rhythms and weekly cycles, so the lags are concentrated at those seasonal │ │ offsets rather than being a simple consecutive range. │ │ │ │ Window Features │ │ │ │ Four rolling statistics complement the lags: │ │ │ │ • Mean and standard deviation over 3 hours: captures very recent local level and │ │ volatility, useful for detecting sudden demand shifts. │ │ • Mean over 24 hours: summarises the typical demand over the past day, providing a │ │ smoothed daily-level signal without requiring 24 individual lag columns. │ │ • Mean over 168 hours: represents the average demand over the past week, giving the │ │ model a stable weekly baseline to anchor predictions. │ │ │ │ Together these summaries let the model distinguish whether current demand is above │ │ or below recent norms at three different timescales, with only four additional │ │ features. │ │ │ │ Calendar and Exogenous Features │ │ │ │ Calendar features (hour, day of week, weekend flag, month) are added with raw │ │ ordinal encoding, giving the model direct signals about time-of-day and day-of-week │ │ patterns. The three exogenous variables, namely holiday, weather (categorical, │ │ handled automatically via categorical_features='auto'), and temp, allow the model to │ │ adjust forecasts for conditions that affect ridership beyond historical patterns │ │ alone. │ │ │ │ How the 80% Prediction Interval Is Produced │ │ │ │ The interval method is bootstrapping, which works as follows: │ │ │ │ 1 During fit, in-sample residuals (the differences between fitted and actual values │ │ on the training set) are stored, and they are binned by prediction level using a │ │ KBinsDiscretizer. This means residuals are grouped according to the magnitude of │ │ the prediction they correspond to. │ │ 2 When predict_interval is called, the forecaster generates many bootstrap sample │ │ paths (controlled by the n_boot parameter). For each path, residuals are drawn │ │ from the bin matching the current predicted level, then added to the point │ │ forecast to simulate one possible future trajectory. │ │ 3 After all bootstrap paths are collected for each of the 36 steps, the 10th and │ │ 90th percentiles of those simulated values are taken as the lower and upper │ │ bounds. │ │ │ │ The result is an 80% interval, meaning that roughly 80% of actual future │ │ observations are expected to fall within those bounds if the residual distribution │ │ remains stable. The use of binned residuals (rather than a single global residual │ │ pool) improves calibration when forecast uncertainty varies with the predicted │ │ demand level, which is common in count-like series such as ridership. │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯
Refine the plan (optional)¶
The refine_plan() method lets you adjust the plan before execution. It operates in two distinct modes:
Deterministic mode (
prompt=None): pass explicit configuration overrides such aslags,estimator,estimator_kwargs,forecaster,steps,interval, orwindow_features. Only the fields you explicitly specify are updated; the rest of the configuration is deterministically re-derived from the original plan.LLM mode (
promptprovided): describe your domain knowledge in natural language. The LLM interprets this context and suggests appropriatelagsandwindow_features. Its reasoning is appended toplan.explanationand the changed fields are recorded inplan.llm_refined_fieldsfor full traceability.
⚠ Warning
A refined plan is a hypothesis, not a guaranteed improvement. The LLM may propose lags or window features that are not helpful for the series, or it may misread the domain context you provided. Always compare the refined plan against the original baseline using a proper backtest over multiple folds before adopting it.
Deterministic mode¶
# Refine the plan with explicit overrides (no LLM required)
# ==============================================================================
plan_det = assistant.refine_plan(
profile = profile,
plan = plan,
lags = [1, 2, 3, 24, 48, 168],
estimator_kwargs = {'n_estimators': 200, 'max_depth': 6}
)
plan_det
Forecast Plan ┏━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ Property ┃ Value ┃ ┡━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ Task type │ single_series │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Forecaster │ ForecasterRecursive │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Estimator │ LGBMRegressor │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Steps │ 36 │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Frequency │ h │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Lags │ [1, 2, 3, 24, 48, 168] │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Window features │ [{'stats': ['mean', 'std'], 'window_size': 3}, {'stats': ['mean'], │ │ │ 'window_size': 24}, {'stats': ['mean'], 'window_size': 168}] │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Calendar features │ ['hour', 'day_of_week', 'weekend', 'month'] (raw ordinal encoding) │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Use exog │ True │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Interval │ [0.1, 0.9] │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Interval method │ bootstrapping │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Primary metric │ mean_absolute_error │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Preprocessing │ 1 step │ └───────────────────┴────────────────────────────────────────────────────────────────────┘ Preprocessing Steps ┏━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ Step ┃ Reason ┃ ┡━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ handle_categorical_exog │ Categorical exogenous variables detected: ['weather']. These │ │ │ are handled automatically by skforecast │ │ │ (categorical_features='auto'). │ └─────────────────────────┴──────────────────────────────────────────────────────────────┘ ╭─────────────────────────────────── Plan Explanation ───────────────────────────────────╮ │ │ │ Plan: ForecasterRecursive + LGBMRegressor. Lags: [1, 2, 3, 24, 48, 168]. Window │ │ features: ['mean(window=3)', 'std(window=3)', 'mean(window=24)', │ │ 'mean(window=168)']. Calendar features: ['hour', 'day_of_week', 'weekend', 'month'] │ │ (raw ordinal encoding). Prediction intervals via bootstrapping. NaN rows kept │ │ (NaN-tolerant estimator). Exogenous variables included. MAE is interpretable, robust │ │ to outliers, and works at any scale. │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯
LLM mode¶
# Refine the plan using LLM-guided domain knowledge
# ==============================================================================
prompt = (
"I'm forecasting hourly bike rentals. Demand follows a clear daily rhythm with "
"rush-hour peaks, and it changes between weekdays and weekends. It's also usually "
"similar to what happened at the same time last week, and the last few hours give "
"a good sense of the current trend. Please pick lags and rolling features that fit this."
)
plan_refined = assistant.refine_plan(
profile = profile,
plan = plan,
prompt = prompt
)
# Refined plan proposed by the assistant
# ==============================================================================
plan_refined
Forecast Plan ┏━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ Property ┃ Value ┃ ┡━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ Task type │ single_series │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Forecaster │ ForecasterRecursive │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Estimator │ LGBMRegressor │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Steps │ 36 │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Frequency │ h │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Lags │ [1, 2, 3, 4, 5, 6, 23, 24, 25, 47, 48, 49, 167, 168, 169] │ │ │ (LLM-suggested) │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Window features │ [{'stats': ['mean', 'std'], 'window_size': 24}, {'stats': ['mean', │ │ │ 'std'], 'window_size': 168}] (LLM-suggested) │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Calendar features │ ['hour', 'day_of_week', 'weekend', 'month'] (raw ordinal encoding) │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Use exog │ True │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Interval │ [0.1, 0.9] │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Interval method │ bootstrapping │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Primary metric │ mean_absolute_error │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Preprocessing │ 1 step │ └───────────────────┴────────────────────────────────────────────────────────────────────┘ Preprocessing Steps ┏━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ Step ┃ Reason ┃ ┡━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ handle_categorical_exog │ Categorical exogenous variables detected: ['weather']. These │ │ │ are handled automatically by skforecast │ │ │ (categorical_features='auto'). │ └─────────────────────────┴──────────────────────────────────────────────────────────────┘ ╭─────────────────────────────────── Plan Explanation ───────────────────────────────────╮ │ │ │ Plan: ForecasterRecursive + LGBMRegressor. Lags: [1, 2, 3, 4, 5, 6, 23, 24, 25, 47, │ │ 48, 49, 167, 168, 169]. Window features: ['mean(window=24)', 'std(window=24)', │ │ 'mean(window=168)', 'std(window=168)']. Calendar features: ['hour', 'day_of_week', │ │ 'weekend', 'month'] (raw ordinal encoding). Prediction intervals via bootstrapping. │ │ NaN rows kept (NaN-tolerant estimator). Exogenous variables included. MAE is │ │ interpretable, robust to outliers, and works at any scale. │ │ │ │ LLM Refinement Reasoning: The user described three key dynamics for hourly bike │ │ rentals: │ │ │ │ 1 Recent trend (last few hours): Lags 1–6 capture the immediate short-term momentum │ │ and trend in rentals, which is especially useful for modelling rush-hour ramp-ups │ │ and cool-downs. │ │ 2 Daily rhythm with rush-hour peaks: Lags 23, 24, 25 anchor the forecast to the │ │ same hour yesterday (±1 hour tolerance). This directly encodes the strong 24-hour │ │ daily seasonality. A 24-hour rolling mean (window_size=24) smooths out the daily │ │ level signal, and std over 24 hours captures intra-day volatility (e.g. the │ │ spread between peak and off-peak hours). │ │ 3 Weekly pattern (weekday vs. weekend): Lags 47, 48, 49 capture the same hour two │ │ days ago (helpful for tracking weekday/weekend transitions), and lags 167, 168, │ │ 169 directly anchor to the same hour last week (±1 hour), capturing the strong │ │ 7-day seasonality. A 168-hour rolling mean summarises the weekly baseline level, │ │ while its std captures week-to-week variability and regime changes (e.g. a │ │ holiday week vs. a regular week). │ │ │ │ Together, this is a compact but well-targeted set: short lags for trend, seasonal │ │ lags at 24 h and 168 h for daily and weekly cycles, and rolling stats at the same │ │ two horizons for smoothed level and volatility — without inflating the feature space │ │ with redundant intermediate lags. │ │ │ │ Note: the LLM-suggested lags and window_features are hypotheses, not validated │ │ improvements. Confirm any expected accuracy gain before relying on them. │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯
Explain mode (refined plan)¶
# Ask the assistant what changed and why
# ==============================================================================
answer = assistant.ask(
prompt = (
"What changed in the refined plan compared to the original, "
"and why does it matter for this dataset?"
),
profile = profile,
plan = plan_refined,
)
answer.show_explanation()
╭────────────────────────────────── Assistant Response ──────────────────────────────────╮ │ │ │ The refined plan introduced a targeted set of lags and window features specifically │ │ chosen to reflect the known dynamics of hourly bike rental data, rather than using a │ │ generic default configuration. Here is what changed and why it is relevant. │ │ │ │ Lag Selection │ │ │ │ The original starting point would typically use a simple contiguous range of recent │ │ lags. The refined plan uses a compact, non-contiguous set: [1, 2, 3, 4, 5, 6, 23, │ │ 24, 25, 47, 48, 49, 167, 168, 169]. │ │ │ │ • Lags 1 through 6 capture short-term momentum, such as a rental surge building up │ │ over several consecutive hours during a morning rush. │ │ • Lags 23, 24, and 25 anchor each forecast to the same hour yesterday, plus or │ │ minus one hour, directly encoding the 24-hour daily cycle. │ │ • Lags 47, 48, and 49 capture the same hour two days ago, which may help the model │ │ navigate weekday-to-weekend transitions. │ │ • Lags 167, 168, and 169 anchor to the same hour last week, directly encoding the │ │ 7-day weekly seasonality. │ │ │ │ The plus-or-minus-one-hour tolerance around each seasonal anchor (24, 48, 168) │ │ matters because real rental patterns can shift slightly around their peak times. │ │ │ │ Window Features │ │ │ │ Two pairs of rolling statistics were added: mean and standard deviation over a │ │ 24-hour window, and mean and standard deviation over a 168-hour window. The means │ │ summarise the smoothed daily and weekly baseline level of rentals. The standard │ │ deviations capture intra-day volatility and week-to-week variability, for example │ │ distinguishing a holiday week from a regular commuting week. │ │ │ │ Why This Matters for This Dataset │ │ │ │ The dataset has 17,544 hourly observations with known exogenous context (holiday, │ │ weather, temperature). Hourly bike rentals exhibit a strong 24-hour daily cycle and │ │ a strong 7-day weekly cycle. A generic lag set might include many intermediate lags │ │ that add noise without adding signal. The refined set is compact by design, │ │ targeting only the three dynamics described: recent trend, daily rhythm, and weekly │ │ pattern. This keeps the feature space smaller while directly representing the │ │ seasonality structures most relevant to this series. │ │ │ │ One important caveat: the plan notes explicitly that the suggested lags and window │ │ features are hypotheses, not validated improvements. Any expected accuracy gain │ │ should be confirmed through backtesting before relying on it. │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯
Forecast¶
Once you have a profile and a plan, you can call forecast() or forecast_code(). Both accept the pre-computed profile and plan so no additional profiling is performed. The forecast() method executes the generated script and returns a ForecastResult; forecast_code() generates the script only, without running it.
The forecast branch operates in two modes:
Evaluation mode (
test_sizeis set): the dataset is split into train and test sets, the model is trained on the train portion, and predictions are compared against the held-out actuals to compute metrics.Prediction mode (
test_size=None, the default): the model is trained on the entire dataset and forecasts the nextstepstime points into the future. Because there is no ground truth, no metrics are returned. If the data has exogenous variables, their future values must be supplied viaexog.
Evaluation mode¶
# Forecast in evaluation mode, reusing the pre-computed profile and plan
# ==============================================================================
results_eval = assistant.forecast(
data = data,
target = 'users',
date_column = 'date_time',
steps = 36,
test_size = 36, # Last 36 hours as test set
profile = profile, # Reuse the pre-computed profile
plan = plan_refined # Reuse the refined plan
)
display(results_eval.metrics)
display(results_eval.predictions.head())
| series | MAE | MSE | MASE | MAPE | |
|---|---|---|---|---|---|
| 0 | users | 44.509038 | 4491.272806 | 0.69125 | 0.509689 |
| pred | lower_bound | upper_bound | |
|---|---|---|---|
| 2012-12-30 12:00:00 | 143.367904 | 109.261080 | 174.845666 |
| 2012-12-30 13:00:00 | 130.565596 | 86.144130 | 166.581911 |
| 2012-12-30 14:00:00 | 121.586466 | 85.988106 | 163.576222 |
| 2012-12-30 15:00:00 | 125.681532 | 81.114751 | 161.331299 |
| 2012-12-30 16:00:00 | 128.594036 | 81.989952 | 165.070426 |
# Plot predictions vs. actual values for the held-out test period
# ==============================================================================
set_dark_theme()
predictions = results_eval.predictions
fig, ax = plt.subplots(figsize=(7, 3.5))
data.set_index('date_time').loc[predictions.index, 'users'].plot(ax=ax, label='actual')
predictions['pred'].plot(ax=ax, label='prediction')
if {'lower_bound', 'upper_bound'}.issubset(predictions.columns):
ax.fill_between(
predictions.index, predictions['lower_bound'], predictions['upper_bound'],
alpha=0.3, label='80% prediction interval'
)
ax.set_title('Predictions vs. actual bike demand')
ax.set_ylabel('Users')
ax.legend()
plt.tight_layout()
plt.show()
# Ask the assistant to interpret the forecast results
# ==============================================================================
answer = assistant.ask(
prompt = "Explain the results of this forecast, including the metrics and predictions.",
result = results_eval
)
answer.show_explanation()
╭────────────────────────────────── Assistant Response ──────────────────────────────────╮ │ │ │ The forecast predicts hourly bike rental counts 36 steps ahead, covering the final │ │ hours of December 30 through December 31, 2012. The model uses a ForecasterRecursive │ │ with LGBMRegressor and beats the naive baseline on the primary metric. Here is a │ │ full breakdown. │ │ │ │ Model and Configuration │ │ │ │ The forecaster combines several feature types to capture the known dynamics of │ │ hourly rentals: │ │ │ │ • Lags 1 through 6 for short-term momentum │ │ • Lags 23, 24, and 25 to anchor to the same hour yesterday, capturing the daily │ │ 24-hour cycle │ │ • Lags 47, 48, and 49 for the same hour two days ago, helping with │ │ weekday-to-weekend transitions │ │ • Lags 167, 168, and 169 to anchor to the same hour last week, capturing the weekly │ │ 7-day cycle │ │ • Rolling mean and standard deviation over windows of 24 hours and 168 hours, │ │ providing smoothed level and volatility signals at both the daily and weekly │ │ scale │ │ • Calendar features (hour, day of week, weekend flag, month) as exogenous inputs │ │ • Three additional exogenous variables: holiday, weather (categorical, handled │ │ automatically), and temp │ │ │ │ Evaluation Metrics │ │ │ │ The backtesting results are: │ │ │ │ • MAE of 44.51, meaning predictions deviate from actual rental counts by about 44.5 │ │ users on average │ │ • MSE of 4491.27 │ │ • MASE of 0.691, which is below 1.0, meaning the model outperforms a naive baseline │ │ forecast │ │ • MAPE of approximately 51%, which should be interpreted cautiously since rental │ │ counts can be low (near zero during overnight hours), causing percentage errors │ │ to inflate │ │ │ │ The MASE is the most informative summary here. A value of 0.691 confirms the model │ │ is genuinely adding predictive value beyond simply repeating the last observed │ │ value. │ │ │ │ Predictions │ │ │ │ The 36-step forecast spans from 12:00 on December 30 to 23:00 on December 31. Key │ │ characteristics of the full set of 36 predictions: │ │ │ │ • Point forecasts range from a minimum of about 5.0 to a maximum of about 158.6 │ │ users, with a mean of about 70.4 users across the horizon │ │ • Lower bounds (10th percentile) range from approximately -0.3 to 109.3, with a │ │ mean of about 39.5 │ │ • Upper bounds (90th percentile) range from approximately 8.6 to 184.2, with a mean │ │ of about 106.1 │ │ │ │ The 80% prediction intervals (bootstrapping method, covering the 10th to 90th │ │ percentile) are noticeably wide relative to the point forecasts. For example, at │ │ 12:00 on December 30, the point forecast is about 143.4 users with an interval of │ │ roughly 109.3 to 174.8. By late on December 31, forecasts drop to the mid-to-low │ │ teens, reflecting typical overnight patterns, with tighter absolute interval widths │ │ at lower count levels. │ │ │ │ Summary │ │ │ │ The model performs well relative to the naive baseline (MASE of 0.691). The wide │ │ prediction intervals acknowledge genuine uncertainty in the recursive multi-step │ │ setup, where prediction error accumulates across steps. The low overnight rental │ │ counts are also a known driver of high MAPE values, so that metric alone should not │ │ be used to judge performance here. │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯
Prediction mode¶
In prediction mode, the model trains on the entire dataset and forecasts the next steps time points. Because the data includes exogenous variables (holiday, weather, temp), their future values must be supplied via the exog argument.
# Forecast the next 36 hours using the entire dataset (prediction mode)
# ==============================================================================
# Simulate future values of exogenous variables for the next 36 hours
exog = data[['holiday', 'weather', 'temp']].tail(36).copy()
exog.index = pd.date_range(
start=pd.to_datetime(data['date_time'].max()) + pd.Timedelta(hours=1),
periods=36,
freq='h'
)
results_pred = assistant.forecast(
data = data,
target = 'users',
date_column = 'date_time',
steps = 36,
test_size = None, # Use the entire dataset (prediction mode)
exog = exog, # Future values of exogenous variables
profile = profile,
plan = plan_refined
)
display(results_pred.predictions.head())
| pred | lower_bound | upper_bound | |
|---|---|---|---|
| 2013-01-01 00:00:00 | 24.309643 | 16.027705 | 35.219552 |
| 2013-01-01 01:00:00 | 13.915313 | 3.844538 | 23.919623 |
| 2013-01-01 02:00:00 | 8.877284 | 2.506829 | 15.059061 |
| 2013-01-01 03:00:00 | 6.143251 | 1.555860 | 12.018677 |
| 2013-01-01 04:00:00 | 6.188143 | 1.538990 | 11.670134 |
# Full results object
# ==============================================================================
results_pred
Dataset Profile ┏━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ Property ┃ Value ┃ ┡━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ Format │ single │ ├────────────────┼────────────────────────────────────────────────┤ │ Series │ 1 │ ├────────────────┼────────────────────────────────────────────────┤ │ Observations │ 17544 │ ├────────────────┼────────────────────────────────────────────────┤ │ Frequency │ h │ ├────────────────┼────────────────────────────────────────────────┤ │ Target │ users │ ├────────────────┼────────────────────────────────────────────────┤ │ Exog columns │ holiday, weather, temp (categorical: weather) │ ├────────────────┼────────────────────────────────────────────────┤ │ Missing values │ None │ └────────────────┴────────────────────────────────────────────────┘ Recommendation ┏━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ Property ┃ Value ┃ ┡━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ Task type │ single_series │ ├───────────────────────┼─────────────────────────────────────────────────────────────┤ │ Forecaster │ ForecasterRecursive │ ├───────────────────────┼─────────────────────────────────────────────────────────────┤ │ Forecaster candidates │ ForecasterRecursive, ForecasterDirect, ForecasterFoundation │ ├───────────────────────┼─────────────────────────────────────────────────────────────┤ │ Estimator │ LGBMRegressor │ ├───────────────────────┼─────────────────────────────────────────────────────────────┤ │ Estimator candidates │ LGBMRegressor, XGBRegressor, Ridge │ └───────────────────────┴─────────────────────────────────────────────────────────────┘ ╭───────────────────────────────── Profile Explanation ──────────────────────────────────╮ │ │ │ A single-series ML forecaster (ForecasterRecursive) is recommended. Data: 17544 │ │ observations, 'h' frequency. Alternative forecasters: ['ForecasterDirect', │ │ 'ForecasterFoundation']. Estimator: LGBMRegressor. A gradient boosting model is │ │ preferred for a dataset of this size (17544 observations). Alternative estimators: │ │ ['XGBRegressor', 'Ridge']. 3 exogenous variables (1 categorical) available as │ │ predictors. │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ Forecast Plan ┏━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ Property ┃ Value ┃ ┡━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ Task type │ single_series │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Forecaster │ ForecasterRecursive │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Estimator │ LGBMRegressor │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Steps │ 36 │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Frequency │ h │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Lags │ [1, 2, 3, 4, 5, 6, 23, 24, 25, 47, 48, 49, 167, 168, 169] │ │ │ (LLM-suggested) │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Window features │ [{'stats': ['mean', 'std'], 'window_size': 24}, {'stats': ['mean', │ │ │ 'std'], 'window_size': 168}] (LLM-suggested) │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Calendar features │ ['hour', 'day_of_week', 'weekend', 'month'] (raw ordinal encoding) │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Use exog │ True │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Interval │ [0.1, 0.9] │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Interval method │ bootstrapping │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Primary metric │ mean_absolute_error │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Preprocessing │ 1 step │ └───────────────────┴────────────────────────────────────────────────────────────────────┘ Preprocessing Steps ┏━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ Step ┃ Reason ┃ ┡━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ handle_categorical_exog │ Categorical exogenous variables detected: ['weather']. These │ │ │ are handled automatically by skforecast │ │ │ (categorical_features='auto'). │ └─────────────────────────┴──────────────────────────────────────────────────────────────┘ ╭─────────────────────────────────── Plan Explanation ───────────────────────────────────╮ │ │ │ Plan: ForecasterRecursive + LGBMRegressor. Lags: [1, 2, 3, 4, 5, 6, 23, 24, 25, 47, │ │ 48, 49, 167, 168, 169]. Window features: ['mean(window=24)', 'std(window=24)', │ │ 'mean(window=168)', 'std(window=168)']. Calendar features: ['hour', 'day_of_week', │ │ 'weekend', 'month'] (raw ordinal encoding). Prediction intervals via bootstrapping. │ │ NaN rows kept (NaN-tolerant estimator). Exogenous variables included. MAE is │ │ interpretable, robust to outliers, and works at any scale. │ │ │ │ LLM Refinement Reasoning: The user described three key dynamics for hourly bike │ │ rentals: │ │ │ │ 1 Recent trend (last few hours): Lags 1–6 capture the immediate short-term momentum │ │ and trend in rentals, which is especially useful for modelling rush-hour ramp-ups │ │ and cool-downs. │ │ 2 Daily rhythm with rush-hour peaks: Lags 23, 24, 25 anchor the forecast to the │ │ same hour yesterday (±1 hour tolerance). This directly encodes the strong 24-hour │ │ daily seasonality. A 24-hour rolling mean (window_size=24) smooths out the daily │ │ level signal, and std over 24 hours captures intra-day volatility (e.g. the │ │ spread between peak and off-peak hours). │ │ 3 Weekly pattern (weekday vs. weekend): Lags 47, 48, 49 capture the same hour two │ │ days ago (helpful for tracking weekday/weekend transitions), and lags 167, 168, │ │ 169 directly anchor to the same hour last week (±1 hour), capturing the strong │ │ 7-day seasonality. A 168-hour rolling mean summarises the weekly baseline level, │ │ while its std captures week-to-week variability and regime changes (e.g. a │ │ holiday week vs. a regular week). │ │ │ │ Together, this is a compact but well-targeted set: short lags for trend, seasonal │ │ lags at 24 h and 168 h for daily and weekly cycles, and rolling stats at the same │ │ two horizons for smoothed level and volatility — without inflating the feature space │ │ with redundant intermediate lags. │ │ │ │ Note: the LLM-suggested lags and window_features are hypotheses, not validated │ │ improvements. Confirm any expected accuracy gain before relying on them. │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ Predictions (36 rows) ┏━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━┓ ┃ Index ┃ pred ┃ lower_bound ┃ upper_bound ┃ ┡━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━┩ │ 2013-01-01 00:00:00 │ 24.3096 │ 16.0277 │ 35.2196 │ ├─────────────────────┼─────────┼─────────────┼─────────────┤ │ 2013-01-01 01:00:00 │ 13.9153 │ 3.8445 │ 23.9196 │ ├─────────────────────┼─────────┼─────────────┼─────────────┤ │ 2013-01-01 02:00:00 │ 8.8773 │ 2.5068 │ 15.0591 │ ├─────────────────────┼─────────┼─────────────┼─────────────┤ │ 2013-01-01 03:00:00 │ 6.1433 │ 1.5559 │ 12.0187 │ ├─────────────────────┼─────────┼─────────────┼─────────────┤ │ 2013-01-01 04:00:00 │ 6.1881 │ 1.5390 │ 11.6701 │ ├─────────────────────┼─────────┼─────────────┼─────────────┤ │ ... │ ... │ ... │ ... │ ├─────────────────────┼─────────┼─────────────┼─────────────┤ │ 2013-01-02 07:00:00 │ 32.6998 │ 20.6468 │ 73.6151 │ ├─────────────────────┼─────────┼─────────────┼─────────────┤ │ 2013-01-02 08:00:00 │ 54.4638 │ 40.3162 │ 128.4668 │ ├─────────────────────┼─────────┼─────────────┼─────────────┤ │ 2013-01-02 09:00:00 │ 63.5735 │ 40.4755 │ 167.7687 │ ├─────────────────────┼─────────┼─────────────┼─────────────┤ │ 2013-01-02 10:00:00 │ 75.5977 │ 34.4001 │ 164.2217 │ ├─────────────────────┼─────────┼─────────────┼─────────────┤ │ 2013-01-02 11:00:00 │ 81.6627 │ 34.0974 │ 178.6108 │ └─────────────────────┴─────────┴─────────────┴─────────────┘
Code-only mode¶
Use forecast_code() when you want to preview or export the reproducible script without executing it. This is useful for code review, auditing the generated pipeline, or running the script in a separate environment.
# Generate the reproducible script without executing it
# ==============================================================================
code_result = assistant.forecast_code(
data = data,
target = 'users',
date_column = 'date_time',
steps = 36,
test_size = 36,
profile = profile,
plan = plan_refined
)
code_result.show_code()
The ForecastResult object¶
Both forecast() modes return a ForecastResult, a lightweight container that bundles everything the assistant used and produced.
| Attribute | Type | Description |
|---|---|---|
predictions |
DataFrame | Forecasted values. When intervals are requested, the bound columns are included alongside the point predictions. |
metrics |
DataFrame or None | Evaluation metrics (MAE, MSE, MASE, MAPE), one row per series. None in prediction mode. |
code |
str | The exact standalone skforecast script that produced the forecast, ready to run on its own. |
profile |
ForecastingProfile |
The data profile behind the forecast. |
plan |
ForecastPlan |
The detailed configuration that was executed. |
Backtesting¶
The backtesting branch uses the same profile and plan as the forecast branch but evaluates the model's historical performance through time series cross-validation. The key decision is how to configure the TimeSeriesFold object, which controls exactly how the historical data is partitioned into successive training and test windows.
skforecast-ai provides three distinct ways to define this validation strategy:
Explicit instantiation (recommended): manually construct a
TimeSeriesFoldand pass it directly tobacktest(). Use this when you already know your exact operational constraints.Deterministic
create_cv(): allow the assistant to derive a sensibleTimeSeriesFoldfrom the profile and plan using rule-based defaults. You can override individual parameters explicitly.LLM
create_cv()(with a prompt): describe your deployment use case in natural language. The LLM translates your description into a fully-configuredTimeSeriesFold, accompanied by an explanation you can audit.
Define the backtesting strategy¶
Manual TimeSeriesFold¶
# Create your own TimeSeriesFold object
# ==============================================================================
end_train = '2012-08-31 23:59:00'
cv = TimeSeriesFold(
steps = 36,
initial_train_size = end_train,
refit = False,
verbose = False
)
cv
TimeSeriesFold
General Information
- Initial train size: 2012-08-31 23:59:00
- Initial train size as int: None
- Steps: 36
- Fold stride: 36
- Overlapping folds: False
- Window size: None
- Differentiation: None
- Refit: False
- Fixed train size: True
- Gap: 0
- Skip folds: None
- Allow incomplete fold: True
- Return all indexes: False
Deterministic create_cv()¶
# Let the assistant derive a TimeSeriesFold with rule-based defaults
# ==============================================================================
cv_det, cv_det_explanation = assistant.create_cv(
profile = profile,
plan = plan_refined,
initial_train_size = end_train,
refit = False,
)
print(cv_det_explanation)
cv_det
Initial training up to 2012-08-31 23:59:00, expanding window, no refit, 36-step horizon, 82 folds.
TimeSeriesFold
General Information
- Initial train size: 2012-08-31 23:59:00
- Initial train size as int: 14616
- Steps: 36
- Fold stride: 36
- Overlapping folds: False
- Window size: None
- Differentiation: None
- Refit: False
- Fixed train size: False
- Gap: 0
- Skip folds: None
- Allow incomplete fold: True
- Return all indexes: False
LLM create_cv() with a natural-language prompt¶
Rather than manually configuring TimeSeriesFold parameters, you can describe your backtesting strategy in natural language and let the assistant translate it into a rigorous cross-validation schema.
# Let the assistant create the TimeSeriesFold from a natural-language prompt
# ==============================================================================
prompt = (
"I forecast bike demand 36 hours ahead. "
"The model should be trained once on all data up to the end of August 2012, 23:59. "
"Do not refit the model as the window rolls forward."
)
cv_llm, cv_llm_explanation = assistant.create_cv(
profile = profile,
plan = plan_refined,
prompt = prompt
)
# TimeSeriesFold derived from the prompt
# ==============================================================================
cv_llm
TimeSeriesFold
General Information
- Initial train size: 2012-08-31 23:59
- Initial train size as int: 14616
- Steps: 36
- Fold stride: 36
- Overlapping folds: False
- Window size: None
- Differentiation: None
- Refit: False
- Fixed train size: False
- Gap: 0
- Skip folds: None
- Allow incomplete fold: True
- Return all indexes: False
# LLM reasoning behind the TimeSeriesFold configuration
# ==============================================================================
print(textwrap.fill(cv_llm_explanation, width=88))
The user wants to train once on all data up to the end of August 2012 (2012-08-31 23:59), so initial_train_size is set to that date string. Since the model should never be refitted as the evaluation window rolls forward, refit=False (train once). The forecast horizon is 36 hours (steps=36, already set in the dataset context). fixed_train_size has no effect when refit=False (the window doesn't move), so it is left at its default. No deployment gap was mentioned, so gap=0 (default). All other parameters are left at their defaults. Initial training up to 2012-08-31 23:59, expanding window, no refit, 36-step horizon, 82 folds.
Since the prompt correctly describes the intended training cutoff and horizon, the cv_llm object returned by create_cv() reproduces the same initial_train_size and steps as the one we built manually. Note, however, that create_cv() defaults to an expanding window (fixed_train_size=False) unless a fixed one is explicitly requested, so cv_llm and cv_det differ from the manually built cv (which uses a fixed window) in that respect.
✏️ Note
The assistant also returns a cv_llm_explanation string that details the choices it made. Always inspect it, and the resulting TimeSeriesFold, rather than assuming an LLM-derived configuration is equivalent to what you intended.
Run the backtest¶
# Run backtesting, reusing the pre-computed profile and plan
# ==============================================================================
results_backtest = assistant.backtest(
data = data,
target = 'users',
date_column = 'date_time',
cv = cv, # TimeSeriesFold object
profile = profile, # Reuse the pre-computed profile
plan = plan_refined # Reuse the refined plan
)
results_backtest.show_explanation()
display(results_backtest.metrics)
display(results_backtest.predictions.head())
0%| | 0/82 [00:00<?, ?it/s]
╭───────────────────────────────── Backtest Explanation ─────────────────────────────────╮ │ │ │ Initial training up to 2012-08-31 23:59:00, fixed window, no refit, 36-step horizon, │ │ 82 folds. Results — mean_absolute_error: 51.5109, mean_squared_error: 7214.6293, │ │ mean_absolute_scaled_error: 0.8401, mean_absolute_percentage_error: 0.5802. │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯
| mean_absolute_error | mean_squared_error | mean_absolute_scaled_error | mean_absolute_percentage_error | |
|---|---|---|---|---|
| 0 | 51.510902 | 7214.62932 | 0.840115 | 0.580167 |
| fold | pred | lower_bound | upper_bound | |
|---|---|---|---|---|
| 2012-09-01 00:00:00 | 0 | 130.211859 | 99.968511 | 156.251372 |
| 2012-09-01 01:00:00 | 0 | 109.513132 | 72.983995 | 140.265692 |
| 2012-09-01 02:00:00 | 0 | 71.058720 | 47.705659 | 102.381454 |
| 2012-09-01 03:00:00 | 0 | 45.074954 | 22.850053 | 69.603788 |
| 2012-09-01 04:00:00 | 0 | 21.052789 | 7.211254 | 37.476825 |
# Plot prediction intervals vs real value
# ==============================================================================
predictions = results_backtest.predictions
data_test = data.set_index('date_time').loc[predictions.index, :]
fig = go.Figure([
go.Scatter(name='Prediction', x=predictions.index, y=predictions['pred'], mode='lines'),
go.Scatter(
name='Real value', x=data_test.index, y=data_test['users'], mode='lines',
),
go.Scatter(
name='Upper Bound', x=predictions.index, y=predictions['upper_bound'], mode='lines',
marker=dict(color="#444"), line=dict(width=0), showlegend=False
),
go.Scatter(
name='Lower Bound', x=predictions.index, y=predictions['lower_bound'], marker=dict(color="#444"),
line=dict(width=0), mode='lines', fillcolor='rgba(68, 68, 68, 0.3)', fill='tonexty', showlegend=False
)
])
fig.update_layout(
title="Real value vs predicted in test data",
xaxis_title="Date time",
yaxis_title="Users",
width=800,
height=400,
margin=dict(l=20, r=20, t=35, b=20),
hovermode="x",
legend=dict(orientation="h", yanchor="top", y=1.1, xanchor="left", x=0.001),
# Initial zoom on the x axis between Oct 1 and Oct 10
xaxis=dict(range=['2012-10-01', '2012-10-10'])
)
fig.show()
# Ask the assistant to interpret the backtesting results
# ==============================================================================
answer = assistant.ask(
prompt = (
"Explain the results of this backtesting, including the strategy, metrics, "
"and predictions. Is the model good enough to deploy?"
),
result = results_backtest
)
answer.show_explanation()
╭────────────────────────────────── Assistant Response ──────────────────────────────────╮ │ │ │ The backtesting evaluated a ForecasterRecursive with LGBMRegressor across 82 folds │ │ covering September through December 2012, forecasting 36 hours ahead each time. The │ │ model achieved a Mean Absolute Error of 51.51 users and a MASE of 0.84, meaning it │ │ beats the naive baseline. Whether it is good enough to deploy depends on your │ │ tolerance for roughly 51-user average errors and the context of your use case. │ │ │ │ Backtesting Strategy │ │ │ │ The cross-validation used a fixed training window ending at 2012-08-31 23:59, with │ │ no refit across the 82 folds. Key parameters: │ │ │ │ • Steps: 36 (one forecast covers the next 36 hours) │ │ • Fold stride: 36 (non-overlapping test windows, each fold starts exactly where the │ │ previous one ended) │ │ • Fixed train size: True (the training window rolls forward, always the same │ │ length, discarding older data) │ │ • Refit: False (the model was trained once on the initial window and applied to all │ │ 82 folds without retraining) │ │ • Gap: 0 (no delay between training end and forecast start) │ │ • Differentiation: None │ │ │ │ This setup simulates a realistic deployment where the model is trained once and used │ │ to forecast rolling 36-hour windows without updates. │ │ │ │ Feature Design │ │ │ │ The model used a carefully targeted set of inputs: │ │ │ │ • Short lags (1 to 6): capture recent momentum and short-term trends │ │ • Daily seasonal lags (23, 24, 25): anchor the forecast to the same hour yesterday, │ │ plus or minus one hour │ │ • Two-day and weekly lags (47, 48, 49 and 167, 168, 169): capture the same hour two │ │ days ago and the same hour last week, encoding the 7-day cycle │ │ • Rolling window features: mean and standard deviation over 24-hour and 168-hour │ │ windows, capturing daily and weekly baseline level and volatility │ │ • Calendar features: hour, day of week, weekend flag, and month │ │ • Exogenous variables: holiday, weather (categorical, handled automatically), and │ │ temperature │ │ │ │ Evaluation Metrics │ │ │ │ • MAE: 51.51 users - On average, the point forecast is off by about 51 users per │ │ hour. Whether this is acceptable depends on the typical scale of hourly rentals. │ │ The prediction summary shows values ranging from roughly 4 to 858 users, so in │ │ high-demand periods the relative error may be small, while in low-demand periods │ │ it could be proportionally larger. │ │ • MSE: 7214.63 - The squared metric is notably higher relative to the MAE, │ │ suggesting some folds or hours have larger errors that inflate the squared loss. │ │ • MASE: 0.84 - This is the most important metric for deployment readiness. A MASE │ │ below 1.0 means the model outperforms the naive seasonal baseline. A value of │ │ 0.84 represents a meaningful improvement. │ │ • MAPE: 0.58 - The model's percentage errors average around 58%. This figure should │ │ be interpreted cautiously, as MAPE becomes unreliable when true values approach │ │ zero, which is likely during overnight low-demand hours. │ │ │ │ Prediction Intervals │ │ │ │ The 80% bootstrapped prediction intervals (lower bound at the 10th percentile, upper │ │ bound at the 90th) show a reasonable spread. Across all 2,928 predictions, the mean │ │ lower bound is about 186 users and the mean upper bound is about 280 users, │ │ bracketing the mean point forecast of about 231 users. The lower bound minimum dips │ │ just below zero (approximately -0.45), which is a known artifact of bootstrapping on │ │ a count series and worth noting if downstream systems expect non-negative values. │ │ │ │ Deployment Readiness Assessment │ │ │ │ The model clears the most important threshold: a MASE of 0.84 confirms it adds │ │ genuine predictive value beyond a naive approach. Points to weigh before deploying: │ │ │ │ • In favour: MASE below 1, consistent evaluation across 82 non-overlapping folds, │ │ rich feature set capturing daily and weekly patterns, exogenous variables │ │ included │ │ • Worth investigating: The MAPE of 0.58 suggests meaningful percentage errors, │ │ particularly in low-demand hours. The near-zero lower bound may need clipping for │ │ operational use. │ │ • Refit consideration: The backtest used refit=False. If data patterns shift over │ │ time, enabling periodic retraining via refit=True or refit=<int> in │ │ TimeSeriesFold could improve production performance. │ │ • Comparison baseline: It would be worth confirming the MASE improvement against a │ │ ForecasterEquivalentDate or ForecasterStats benchmark before committing to │ │ deployment. │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯
Code-only mode¶
Use backtest_code() to generate the reproducible backtesting script without executing it.
# Generate the reproducible backtest script without executing it
# ==============================================================================
code_backtest = assistant.backtest_code(
data = data,
target = 'users',
date_column = 'date_time',
cv = cv,
profile = profile,
plan = plan_refined
)
code_backtest.show_code()
The BacktestResult object¶
The backtest() method returns a BacktestResult, a lightweight container that bundles all the backtesting artifacts.
| Attribute | Type | Description |
|---|---|---|
predictions |
DataFrame | Full out-of-sample backtest predictions across all folds. |
metrics |
DataFrame | Backtesting metrics (MAE, MSE, MASE, MAPE), one row per series. |
cv_config |
dict | Resolved TimeSeriesFold parameters for full traceability of the validation strategy. |
code |
str | The exact standalone skforecast script that reproduces the backtesting workflow. |
explanation |
str | Human-readable summary of the backtesting configuration and results. |
profile |
ForecastingProfile |
The data profile behind the backtest. |
plan |
ForecastPlan |
The detailed configuration that was executed. |
# Full results object
# ==============================================================================
results_backtest
╭───────────────────────────────── Backtest Explanation ─────────────────────────────────╮ │ │ │ Initial training up to 2012-08-31 23:59:00, fixed window, no refit, 36-step horizon, │ │ 82 folds. Results — mean_absolute_error: 51.5109, mean_squared_error: 7214.6293, │ │ mean_absolute_scaled_error: 0.8401, mean_absolute_percentage_error: 0.5802. │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ Cross-Validation Configuration ┏━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━┓ ┃ Parameter ┃ Value ┃ ┡━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━┩ │ steps │ 36 │ ├────────────────────┼─────────────────────┤ │ initial_train_size │ 2012-08-31 23:59:00 │ ├────────────────────┼─────────────────────┤ │ refit │ False │ ├────────────────────┼─────────────────────┤ │ fixed_train_size │ True │ ├────────────────────┼─────────────────────┤ │ gap │ 0 │ ├────────────────────┼─────────────────────┤ │ fold_stride │ 36 │ ├────────────────────┼─────────────────────┤ │ differentiation │ None │ ├────────────────────┼─────────────────────┤ │ n_folds │ 82 │ └────────────────────┴─────────────────────┘ Backtest Metrics ┏━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━┓ ┃ mean_absolute_error ┃ mean_squared_error ┃ mean_absolute_scale… ┃ mean_absolute_perce… ┃ ┡━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━┩ │ 51.5109 │ 7214.6293 │ 0.8401 │ 0.5802 │ └─────────────────────┴────────────────────┴──────────────────────┴──────────────────────┘ Backtest Predictions (2928 rows) ┏━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━┓ ┃ Index ┃ fold ┃ pred ┃ lower_bound ┃ upper_bound ┃ ┡━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━┩ │ 2012-09-01 00:00:00 │ 0.0000 │ 130.2119 │ 99.9685 │ 156.2514 │ ├─────────────────────┼─────────┼──────────┼─────────────┼─────────────┤ │ 2012-09-01 01:00:00 │ 0.0000 │ 109.5131 │ 72.9840 │ 140.2657 │ ├─────────────────────┼─────────┼──────────┼─────────────┼─────────────┤ │ 2012-09-01 02:00:00 │ 0.0000 │ 71.0587 │ 47.7057 │ 102.3815 │ ├─────────────────────┼─────────┼──────────┼─────────────┼─────────────┤ │ 2012-09-01 03:00:00 │ 0.0000 │ 45.0750 │ 22.8501 │ 69.6038 │ ├─────────────────────┼─────────┼──────────┼─────────────┼─────────────┤ │ 2012-09-01 04:00:00 │ 0.0000 │ 21.0528 │ 7.2113 │ 37.4768 │ ├─────────────────────┼─────────┼──────────┼─────────────┼─────────────┤ │ ... │ ... │ ... │ ... │ ... │ ├─────────────────────┼─────────┼──────────┼─────────────┼─────────────┤ │ 2012-12-31 19:00:00 │ 81.0000 │ 70.1810 │ 32.3445 │ 121.4605 │ ├─────────────────────┼─────────┼──────────┼─────────────┼─────────────┤ │ 2012-12-31 20:00:00 │ 81.0000 │ 49.7231 │ 18.8142 │ 82.6254 │ ├─────────────────────┼─────────┼──────────┼─────────────┼─────────────┤ │ 2012-12-31 21:00:00 │ 81.0000 │ 33.5835 │ 11.7511 │ 54.1651 │ ├─────────────────────┼─────────┼──────────┼─────────────┼─────────────┤ │ 2012-12-31 22:00:00 │ 81.0000 │ 22.8176 │ 9.4612 │ 46.5829 │ ├─────────────────────┼─────────┼──────────┼─────────────┼─────────────┤ │ 2012-12-31 23:00:00 │ 81.0000 │ 15.6257 │ 7.3005 │ 33.3873 │ └─────────────────────┴─────────┴──────────┴─────────────┴─────────────┘ Dataset Profile ┏━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ Property ┃ Value ┃ ┡━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ Format │ single │ ├────────────────┼────────────────────────────────────────────────┤ │ Series │ 1 │ ├────────────────┼────────────────────────────────────────────────┤ │ Observations │ 17544 │ ├────────────────┼────────────────────────────────────────────────┤ │ Frequency │ h │ ├────────────────┼────────────────────────────────────────────────┤ │ Target │ users │ ├────────────────┼────────────────────────────────────────────────┤ │ Exog columns │ holiday, weather, temp (categorical: weather) │ ├────────────────┼────────────────────────────────────────────────┤ │ Missing values │ None │ └────────────────┴────────────────────────────────────────────────┘ Recommendation ┏━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ Property ┃ Value ┃ ┡━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ Task type │ single_series │ ├───────────────────────┼─────────────────────────────────────────────────────────────┤ │ Forecaster │ ForecasterRecursive │ ├───────────────────────┼─────────────────────────────────────────────────────────────┤ │ Forecaster candidates │ ForecasterRecursive, ForecasterDirect, ForecasterFoundation │ ├───────────────────────┼─────────────────────────────────────────────────────────────┤ │ Estimator │ LGBMRegressor │ ├───────────────────────┼─────────────────────────────────────────────────────────────┤ │ Estimator candidates │ LGBMRegressor, XGBRegressor, Ridge │ └───────────────────────┴─────────────────────────────────────────────────────────────┘ ╭───────────────────────────────── Profile Explanation ──────────────────────────────────╮ │ │ │ A single-series ML forecaster (ForecasterRecursive) is recommended. Data: 17544 │ │ observations, 'h' frequency. Alternative forecasters: ['ForecasterDirect', │ │ 'ForecasterFoundation']. Estimator: LGBMRegressor. A gradient boosting model is │ │ preferred for a dataset of this size (17544 observations). Alternative estimators: │ │ ['XGBRegressor', 'Ridge']. 3 exogenous variables (1 categorical) available as │ │ predictors. │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ Forecast Plan ┏━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ Property ┃ Value ┃ ┡━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ Task type │ single_series │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Forecaster │ ForecasterRecursive │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Estimator │ LGBMRegressor │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Steps │ 36 │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Frequency │ h │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Lags │ [1, 2, 3, 4, 5, 6, 23, 24, 25, 47, 48, 49, 167, 168, 169] │ │ │ (LLM-suggested) │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Window features │ [{'stats': ['mean', 'std'], 'window_size': 24}, {'stats': ['mean', │ │ │ 'std'], 'window_size': 168}] (LLM-suggested) │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Calendar features │ ['hour', 'day_of_week', 'weekend', 'month'] (raw ordinal encoding) │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Use exog │ True │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Interval │ [0.1, 0.9] │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Interval method │ bootstrapping │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Primary metric │ mean_absolute_error │ ├───────────────────┼────────────────────────────────────────────────────────────────────┤ │ Preprocessing │ 1 step │ └───────────────────┴────────────────────────────────────────────────────────────────────┘ Preprocessing Steps ┏━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ Step ┃ Reason ┃ ┡━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ handle_categorical_exog │ Categorical exogenous variables detected: ['weather']. These │ │ │ are handled automatically by skforecast │ │ │ (categorical_features='auto'). │ └─────────────────────────┴──────────────────────────────────────────────────────────────┘ ╭─────────────────────────────────── Plan Explanation ───────────────────────────────────╮ │ │ │ Plan: ForecasterRecursive + LGBMRegressor. Lags: [1, 2, 3, 4, 5, 6, 23, 24, 25, 47, │ │ 48, 49, 167, 168, 169]. Window features: ['mean(window=24)', 'std(window=24)', │ │ 'mean(window=168)', 'std(window=168)']. Calendar features: ['hour', 'day_of_week', │ │ 'weekend', 'month'] (raw ordinal encoding). Prediction intervals via bootstrapping. │ │ NaN rows kept (NaN-tolerant estimator). Exogenous variables included. MAE is │ │ interpretable, robust to outliers, and works at any scale. │ │ │ │ LLM Refinement Reasoning: The user described three key dynamics for hourly bike │ │ rentals: │ │ │ │ 1 Recent trend (last few hours): Lags 1–6 capture the immediate short-term momentum │ │ and trend in rentals, which is especially useful for modelling rush-hour ramp-ups │ │ and cool-downs. │ │ 2 Daily rhythm with rush-hour peaks: Lags 23, 24, 25 anchor the forecast to the │ │ same hour yesterday (±1 hour tolerance). This directly encodes the strong 24-hour │ │ daily seasonality. A 24-hour rolling mean (window_size=24) smooths out the daily │ │ level signal, and std over 24 hours captures intra-day volatility (e.g. the │ │ spread between peak and off-peak hours). │ │ 3 Weekly pattern (weekday vs. weekend): Lags 47, 48, 49 capture the same hour two │ │ days ago (helpful for tracking weekday/weekend transitions), and lags 167, 168, │ │ 169 directly anchor to the same hour last week (±1 hour), capturing the strong │ │ 7-day seasonality. A 168-hour rolling mean summarises the weekly baseline level, │ │ while its std captures week-to-week variability and regime changes (e.g. a │ │ holiday week vs. a regular week). │ │ │ │ Together, this is a compact but well-targeted set: short lags for trend, seasonal │ │ lags at 24 h and 168 h for daily and weekly cycles, and rolling stats at the same │ │ two horizons for smoothed level and volatility — without inflating the feature space │ │ with redundant intermediate lags. │ │ │ │ Note: the LLM-suggested lags and window_features are hypotheses, not validated │ │ improvements. Confirm any expected accuracy gain before relying on them. │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯
Comparing forecasters¶
Choosing a forecasting model should not rely on intuition alone. Two configurations that look equally reasonable can perform very differently once evaluated on real temporal data. The most reliable approach is to test every candidate under identical conditions and compare their metrics.
The compare() method does exactly that. It receives a list of candidate configurations, backtests each one using the same TimeSeriesFold strategy, and returns a leaderboard ranked by the selected metric.
In the step-by-step path, the key argument is profile. Passing the profile computed at the beginning of this tutorial skips profiling entirely and guarantees that every candidate is evaluated against the same data profile. Note that compare() does not accept a plan: each candidate derives its own plan from the shared profile, which is precisely what makes the candidates differ.
Candidates can be provided in two ways:
Automatic candidates (
candidates=None): the assistant builds the comparison set fromprofile.forecaster_candidates, using the forecaster types identified as suitable during profiling. This is useful when exploring a new dataset without a predefined shortlist.Explicit candidates (recommended): pass a list of
(name, config)tuples, wherenamelabels the row in the leaderboard andconfigholds the same override keys understood byplan():'forecaster','estimator','estimator_kwargs','lags'and'window_features'. This provides full control and makes the resulting table easier to interpret.
A failed candidate does not stop the comparison. Instead, a CandidateFailedWarning is issued, the row records the error and is placed last.
💡 Tip
All candidates use the same cross-validation strategy, ensuring a fair comparison. However, the results are only meaningful if the cv setup reflects the real use case where the model will be deployed. For example, if the production system retrains weekly, the backtest should also refit weekly. If the model is expected to forecast 24 hours ahead, the backtest should use a 24-hour horizon.
The evaluation window must also be representative. A period that is too short or dominated by unusual events (holidays, outages, or exceptional peaks) may favor a candidate that performs poorly over time. Define the validation setup carefully before comparing models so the final ranking is reliable.
Automatic candidates¶
# Compare the forecaster candidates suggested by the profile
# ==============================================================================
results_compare = assistant.compare(
data = data,
target = 'users',
date_column = 'date_time',
cv = cv, # Same TimeSeriesFold used in the backtest above
candidates = None, # Candidates suggested by the assistant
profile = profile # Reuse the pre-computed profile
)
Comparing forecasters: 0%| | 0/3 [00:00<?, ?it/s]
Loading weights: 0%| | 0/92 [00:00<?, ?it/s]
# Ranked leaderboard
# ==============================================================================
results_compare.results
| rank | name | forecaster | estimator | mean_absolute_error | mean_squared_error | mean_absolute_scaled_error | mean_absolute_percentage_error | |
|---|---|---|---|---|---|---|---|---|
| 0 | 1 | ForecasterFoundation | ForecasterFoundation | Chronos-2 | 38.436156 | 4231.326355 | 0.597467 | 0.611349 |
| 1 | 2 | ForecasterRecursive | ForecasterRecursive | LGBMRegressor | 46.584343 | 5443.171132 | 0.754003 | 0.470311 |
| 2 | 3 | ForecasterDirect | ForecasterDirect | LGBMRegressor | 49.020298 | 5848.934447 | 0.793430 | 0.497920 |
# Deterministic summary of the comparison
# ==============================================================================
results_compare.show_explanation()
╭──────────────────────────────── Comparison Explanation ────────────────────────────────╮ │ │ │ Compared 3 configurations, ranked ascending by mean_absolute_error. Shared │ │ cross-validation strategy: Initial training up to 2012-08-31 23:59:00, fixed window, │ │ no refit, 36-step horizon, 82 folds. Best: 'ForecasterFoundation' │ │ (ForecasterFoundation / Chronos-2) = 38.4362, 17.5% ahead of 'ForecasterRecursive' │ │ (46.5843). │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯
Explicit candidates¶
In practice, you will often already have a shortlist in mind: a fast baseline, a gradient boosting model, or a variant with a richer feature set. Passing explicit (name, config) tuples keeps the comparison focused and makes the resulting leaderboard easy to understand at a glance.
The config dictionary accepts the same overrides as plan(). Any omitted option falls back to the deterministic recommendation derived from the shared profile, so candidates can remain concise. For example, {'forecaster': 'ForecasterDirect'} changes only the forecaster while keeping the recommended estimator, lags, and features.
⚠️ Computational cost
Each candidate is backtested independently across all folds, so runtime increases with both the number and complexity of the configurations. Comparing four candidates will take roughly four times as long as running one backtest. Start with a small set of clearly different options, review the results, and refine from there. Testing many near-identical variants is costly and rarely useful.
# Compare an explicit shortlist of configurations
# ==============================================================================
candidates = [
(
"ridge_baseline",
{
"forecaster": "ForecasterRecursive",
"estimator" : "Ridge",
"lags" : 24,
}
),
(
"lgbm_daily_lags",
{
"forecaster": "ForecasterRecursive",
"estimator" : "LGBMRegressor",
"lags" : 24,
}
),
(
"refined_plan",
{
"forecaster" : plan_refined.forecaster,
"estimator" : plan_refined.estimator,
"lags" : plan_refined.forecaster_kwargs.get("lags"),
"window_features" : plan_refined.forecaster_kwargs.get("window_features"),
}
),
(
"lgbm_direct",
{
"forecaster": "ForecasterDirect",
"estimator" : "LGBMRegressor",
"lags" : 24,
}
),
(
"foundation_model",
{
"forecaster": "ForecasterFoundation"
}
),
]
results_compare = assistant.compare(
data = data,
target = 'users',
date_column = 'date_time',
cv = cv,
candidates = candidates, # Specific candidates to compare
metric = ['mean_absolute_error', 'mean_absolute_scaled_error'],
profile = profile
)
Comparing forecasters: 0%| | 0/5 [00:00<?, ?it/s]
Loading weights: 0%| | 0/92 [00:00<?, ?it/s]
The refined_plan candidate reuses the forecaster, estimator, lags and window features of the plan produced by refine_plan(). This is the recommended way to validate a refined plan: the leaderboard shows whether the extra domain knowledge actually improves the metrics compared to the deterministic baselines.
When several metrics are requested, all of them are shown as columns but only the first one drives the ranking.
# Ranked leaderboard, sorted by the first metric requested
# ==============================================================================
results_compare.results
| rank | name | forecaster | estimator | mean_absolute_error | mean_absolute_scaled_error | |
|---|---|---|---|---|---|---|
| 0 | 1 | foundation_model | ForecasterFoundation | Chronos-2 | 38.436156 | 0.597467 |
| 1 | 2 | lgbm_direct | ForecasterDirect | LGBMRegressor | 50.380925 | 0.821734 |
| 2 | 3 | refined_plan | ForecasterRecursive | LGBMRegressor | 51.510902 | 0.840115 |
| 3 | 4 | lgbm_daily_lags | ForecasterRecursive | LGBMRegressor | 54.953374 | 0.896312 |
| 4 | 5 | ridge_baseline | ForecasterRecursive | Ridge | 93.145620 | 1.519244 |
Inspect individual candidates¶
Because every candidate is a full BacktestResult, the details of any individual configuration remain available, including its metrics, its predictions and the standalone script that generated them.
# Inspect a specific candidate
# ==============================================================================
candidate = results_compare.candidates['foundation_model']
display(candidate.metrics)
display(candidate.predictions.head())
candidate.show_code()
| mean_absolute_error | mean_absolute_scaled_error | |
|---|---|---|
| 0 | 38.436156 | 0.597467 |
| level | fold | pred | |
|---|---|---|---|
| 2012-09-01 00:00:00 | users | 0 | 148.059479 |
| 2012-09-01 01:00:00 | users | 0 | 102.715004 |
| 2012-09-01 02:00:00 | users | 0 | 66.084412 |
| 2012-09-01 03:00:00 | users | 0 | 40.878601 |
| 2012-09-01 04:00:00 | users | 0 | 29.577911 |
Reuse the winning configuration¶
The most useful result of a comparison is often not the leaderboard, but best_candidate. It is a complete BacktestResult carrying both the winning profile and plan, so it can be fed back into the step-by-step workflow without manually rebuilding the configuration.
# Winning configuration
# ==============================================================================
print(f"Best candidate: {results_compare.best_name}")
results_compare.best_candidate.plan
Best candidate: foundation_model
Forecast Plan ┏━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━┓ ┃ Property ┃ Value ┃ ┡━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━┩ │ Task type │ foundation │ ├────────────────┼──────────────────────┤ │ Forecaster │ ForecasterFoundation │ ├────────────────┼──────────────────────┤ │ Estimator │ Chronos-2 │ ├────────────────┼──────────────────────┤ │ Steps │ 36 │ ├────────────────┼──────────────────────┤ │ Frequency │ h │ ├────────────────┼──────────────────────┤ │ Use exog │ True │ ├────────────────┼──────────────────────┤ │ Interval │ None │ ├────────────────┼──────────────────────┤ │ Primary metric │ mean_absolute_error │ ├────────────────┼──────────────────────┤ │ Preprocessing │ 1 step │ └────────────────┴──────────────────────┘ Preprocessing Steps ┏━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ ┃ Step ┃ Reason ┃ ┡━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │ handle_categorical_exog │ Categorical exogenous variables detected: ['weather']. │ │ │ Chronos-2 consumes categorical covariates natively, so no │ │ │ encoding is needed. │ └─────────────────────────┴──────────────────────────────────────────────────────────────┘ ╭─────────────────────────────────── Plan Explanation ───────────────────────────────────╮ │ │ │ Plan: ForecasterFoundation + Chronos-2. No lag or window features: the foundation │ │ model forecasts directly from the raw context window. Exogenous variables included. │ │ MAE is interpretable, robust to outliers, and works at any scale. │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯
# Produce the final forecast with the winning configuration
# ==============================================================================
results_pred_best = assistant.forecast(
data = data,
target = 'users',
date_column = 'date_time',
steps = 36,
interval = [0.1, 0.9],
test_size = None, # Prediction mode
exog = exog, # Future values of exogenous variables
profile = results_compare.profile, # Shared profile
plan = results_compare.best_candidate.plan # Winning plan
)
results_pred_best.show_code()
╭─────────────────────────────── IgnoredArgumentWarning ───────────────────────────────╮ │ A pre-built `plan` was provided, so the following argument(s) are ignored: │ │ ['interval']. To change these, refine the plan with `refine_plan()` before calling. │ │ │ │ Category : skforecast.exceptions.IgnoredArgumentWarning │ │ Location : │ │ /home/ubuntu/miniconda3/envs/skforecast_24_py13/lib/python3.13/site-packages/skforec │ │ ast_ai/_utils.py:402 │ │ Suppress : warnings.simplefilter('ignore', category=IgnoredArgumentWarning) │ ╰──────────────────────────────────────────────────────────────────────────────────────╯
Loading weights: 0%| | 0/92 [00:00<?, ?it/s]
Explain mode (comparison)¶
Like any other result, a ComparisonResult can be passed to ask() to explain why the ranking looks the way it does. However, the LLM cannot change the outcome: all metrics and rankings are computed deterministically before it sees the result.
# Ask the assistant to interpret the comparison
# ==============================================================================
answer = assistant.ask(
prompt = (
"Explain the comparison results. Is the margin between the top candidates "
"meaningful, or are they practically equivalent?"
),
result = results_compare
)
answer.show_explanation()
╭────────────────────────────────── Assistant Response ──────────────────────────────────╮ │ │ │ The foundation model (Chronos-2) clearly won this comparison, and the margin over │ │ the runner-up is meaningful rather than negligible. The best candidate posted a mean │ │ absolute error of 38.44 users, while the second-place candidate (lgbm_direct) posted │ │ 50.38 users, a gap that the deterministic summary describes as 23.7% ahead. That is │ │ a substantive difference in practical terms, not a borderline result. │ │ │ │ Leaderboard Summary │ │ │ │ • foundation_model (ForecasterFoundation / Chronos-2): MAE 38.44, MASE 0.597 │ │ • lgbm_direct (ForecasterDirect / LGBMRegressor): MAE 50.38, MASE 0.822 │ │ • refined_plan (ForecasterRecursive / LGBMRegressor): MAE 51.51, MASE 0.840 │ │ • lgbm_daily_lags (ForecasterRecursive / LGBMRegressor): MAE 54.95, MASE 0.896 │ │ • ridge_baseline (ForecasterRecursive / Ridge): MAE 93.15, MASE 1.519 │ │ │ │ Interpreting the Margins │ │ │ │ The gap between positions 2, 3, and 4 is comparatively narrow. The three │ │ LightGBM-based candidates span a MAE range from roughly 50 to 55 users, and their │ │ MASE values (0.822, 0.840, 0.896) are all fairly close together. Calling those three │ │ practically equivalent would be reasonable, since the differences among them are │ │ small relative to the larger gap separating them from Chronos-2. │ │ │ │ The Ridge baseline is in a different category altogether. Its MASE of 1.519 means it │ │ performs worse than a naive seasonal baseline, while every other candidate has a │ │ MASE below 1.0, meaning they all beat the naive baseline. │ │ │ │ Key Takeaway on MASE │ │ │ │ All four non-Ridge candidates have MASE below 1.0, confirming they each outperform │ │ the naive baseline. Chronos-2 at 0.597 has the most comfortable margin below 1.0, │ │ while lgbm_direct and refined_plan at 0.822 and 0.840 respectively are solid but │ │ closer to the naive threshold. │ │ │ │ Evaluation Robustness │ │ │ │ The comparison was run over 82 folds with a fixed 36-step horizon, which is a │ │ thorough evaluation. Results based on that many folds carry more weight than a │ │ single held-out window, so the ranking is unlikely to reverse due to a lucky or │ │ unlucky evaluation window. The gap between Chronos-2 and the LightGBM cluster is │ │ consistent enough across that many folds to be considered meaningful. │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯
The ComparisonResult object¶
The compare() method returns a ComparisonResult, which groups the shared setup, the ranked leaderboard, and the individual backtests in a single object.
| Attribute | Type | Description |
|---|---|---|
results |
DataFrame | Ranked leaderboard, one row per candidate, sorted best to worst. Columns: rank, name, forecaster, estimator, the metric columns, and error when at least one candidate failed. |
candidates |
dict | Mapping of candidate name to the full BacktestResult object. |
failures |
dict | Mapping of candidate name to a CandidateFailure describing why it failed. Empty when every candidate succeeds. |
ranking_metric |
str | Name of the metric used to sort results. |
cv_config |
dict | Resolved TimeSeriesFold parameters plus the resulting n_folds, applied identically to every candidate. |
profile |
ForecastingProfile |
The shared data profile behind every candidate. |
explanation |
str | Deterministic, human-readable summary of the comparison. |
best_name |
str | Name of the top-ranked candidate. |
best_candidate |
BacktestResult |
Top-ranked candidate as a complete BacktestResult. |
# Full results object
# ==============================================================================
results_compare
╭──────────────────────────────── Comparison Explanation ────────────────────────────────╮ │ │ │ Compared 5 configurations, ranked ascending by mean_absolute_error. Shared │ │ cross-validation strategy: Initial training up to 2012-08-31 23:59:00, fixed window, │ │ no refit, 36-step horizon, 82 folds. Best: 'foundation_model' (ForecasterFoundation │ │ / Chronos-2) = 38.4362, 23.7% ahead of 'lgbm_direct' (50.3809). │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ Comparison Results ┏━━━━━━━┳━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┓ ┃ Index ┃ rank ┃ name ┃ forecaster ┃ estimator ┃ mean_absol… ┃ mean_absolu… ┃ ┡━━━━━━━╇━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━━┩ │ 0 │ 1 │ foundation_… │ ForecasterF… │ Chronos-2 │ 38.4362 │ 0.5975 │ ├───────┼──────┼──────────────┼──────────────┼──────────────┼─────────────┼──────────────┤ │ 1 │ 2 │ lgbm_direct │ ForecasterD… │ LGBMRegress… │ 50.3809 │ 0.8217 │ ├───────┼──────┼──────────────┼──────────────┼──────────────┼─────────────┼──────────────┤ │ 2 │ 3 │ refined_plan │ ForecasterR… │ LGBMRegress… │ 51.5109 │ 0.8401 │ ├───────┼──────┼──────────────┼──────────────┼──────────────┼─────────────┼──────────────┤ │ 3 │ 4 │ lgbm_daily_… │ ForecasterR… │ LGBMRegress… │ 54.9534 │ 0.8963 │ ├───────┼──────┼──────────────┼──────────────┼──────────────┼─────────────┼──────────────┤ │ 4 │ 5 │ ridge_basel… │ ForecasterR… │ Ridge │ 93.1456 │ 1.5192 │ └───────┴──────┴──────────────┴──────────────┴──────────────┴─────────────┴──────────────┘
Reproducible code¶
Every forecast or backtest workflow exposes the standalone skforecast script used to produce its results. This script is strictly deterministic, ensuring identical outputs for a given set of inputs and configurations. You can access the code at any time via the show_code() method.
Free-form Q&A¶
The ask() method is not limited to interpreting workflow objects. Without any profile, plan, or result attached, it works as a general forecasting knowledge assistant, useful for clarifying methodology, choosing between approaches, or understanding metric trade-offs.
# Ask a general forecasting question (no data or result required)
# ==============================================================================
answer = assistant.ask(
prompt = (
"For hourly demand with strong daily and weekly seasonality, when should "
"I prefer a direct forecasting strategy over a recursive one?"
)
)
answer.show_explanation()
╭────────────────────────────────── Assistant Response ──────────────────────────────────╮ │ │ │ Direct forecasting is worth considering when your forecast horizon is long enough │ │ that recursive error propagation becomes a real concern, and when the relationship │ │ between past observations and future values changes meaningfully across steps. For │ │ hourly data with daily and weekly seasonality, those conditions arise in specific │ │ ways. │ │ │ │ When Direct Is the Better Choice │ │ │ │ • Long horizons relative to your seasonality cycles. For hourly data, a 24-step or │ │ 168-step horizon means your recursive forecaster must feed its own predictions │ │ back as inputs many times before reaching the end of the horizon. Each step │ │ compounds any bias or error introduced by the model. A ForecasterDirect trains │ │ one independent model per step, so step 168 never sees the predicted values from │ │ steps 1 through 167. │ │ • Step-specific patterns across the horizon. Hourly demand often behaves │ │ differently at step 1 (one hour ahead) versus step 24 (tomorrow at the same hour) │ │ or step 168 (same hour next week). If the features that explain demand at 2 AM │ │ differ from those that explain demand at 5 PM, a single recursive model must │ │ represent all of those relationships at once. ForecasterDirect lets each step │ │ learn its own predictive relationship independently. │ │ • When you want to eliminate recursive error propagation entirely. ForecasterDirect │ │ always uses only observed lag values at prediction time, never its own prior │ │ predictions. This is the clearest structural advantage over ForecasterRecursive. │ │ │ │ When Recursive Is Still Preferable │ │ │ │ • Short horizons. If you only need 1 to 6 steps ahead, error propagation is minimal │ │ and ForecasterRecursive trains faster with a single model. │ │ • Limited training data. Training N separate models (one per step) with │ │ ForecasterDirect can lead to overfitting when data is scarce, since each model │ │ sees the same number of rows but must generalise to a specific future offset. │ │ • Computational budget. ForecasterDirect stores N estimators in its estimators_ │ │ dictionary and can be parallelised with n_jobs, but it is still slower and more │ │ memory-intensive than training one model. │ │ │ │ Practical Skforecast Guidance │ │ │ │ • With ForecasterDirect, set steps at initialisation to match your full horizon │ │ (for example, steps=168 for one week ahead at hourly frequency). │ │ • Use n_jobs=-1 to parallelise training across steps. │ │ • Add calendar features such as hour-of-day and day-of-week as exogenous variables │ │ in either forecaster; these may be especially valuable for capturing seasonality │ │ patterns. │ │ • Use backtesting_forecaster with a TimeSeriesFold configured to your actual │ │ operational horizon to compare the two strategies on your specific data before │ │ committing to one. │ │ • Compare both against ForecasterEquivalentDate (same hour, same weekday, prior │ │ week) as a naive baseline to confirm either ML approach adds value. │ │ │ │ The core trade-off is: ForecasterRecursive is simpler and faster; ForecasterDirect │ │ avoids error propagation and allows step-specific modelling, which may be beneficial │ │ at horizons of 24 or more steps on hourly data with strong seasonality patterns. │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯
Summary¶
This tutorial covered the step-by-step path of skforecast-ai. Here is a recap of what each stage does and when to use it:
| Step | Method | When to use |
|---|---|---|
| 1. Profile | profile() |
Always: produces the ForecastingProfile required by all downstream methods. |
| 2. Plan | plan() |
Always: converts the profile into an executable configuration. |
| 3. Refine plan | refine_plan() |
Optional: use when you want to override specific decisions (deterministic) or inject domain knowledge (LLM). Always evaluate the result. |
| 4a. Forecast | forecast() |
When you want future predictions or a held-out evaluation in a single execution. |
| 4a. Code only | forecast_code() |
When you want to preview or export the script without running it. |
| 4b. CV strategy | create_cv() |
When you want the assistant to derive or translate a TimeSeriesFold for you. |
| 4b. Backtest | backtest() |
When you want to evaluate the model over multiple historical folds. |
| 4b. Code only | backtest_code() |
When you want to preview or export the backtesting script without running it. |
| 4c. Compare | compare() |
When you want to rank several configurations under an identical cross-validation strategy and reuse the winner. |
| Any time | ask() |
When you want an LLM explanation of any intermediate object or result, or a general forecasting Q&A. |
The key advantage of this path is that the profile and plan are built once and reused across both the forecast and backtest branches. This avoids redundant profiling and ensures that both branches use the same modeling configuration. The same profile can also be handed to compare(), so every candidate is ranked against the very same data profile.
For a faster alternative that runs the entire pipeline in a single call, revisit the Quickstart section at the top of this guide. For a comprehensive overview of backtesting mechanics, see the skforecast backtesting user guide.
Session information¶
import session_info
session_info.show(html=False)
----- chronos 2.3.1 matplotlib 3.10.9 pandas 2.3.3 plotly 6.9.0 session_info v1.0.1 skforecast 0.24.0 skforecast_ai 0.2.0 ----- IPython 9.16.1 jupyter_client 8.9.1 jupyter_core 5.9.1 ----- Python 3.13.15 | packaged by conda-forge | (main, Aug 10 2026, 13:05:01) [GCC 14.4.0] Linux-7.0.0-1010-aws-x86_64-with-glibc2.43 ----- Session information updated at 2026-08-24 08:56
Citation¶
How to cite this document
If you use this document or any part of it, please acknowledge the source, thank you!
Agentic forecasting with skforecast-AI by Joaquín Amat Rodrigo and Javier Escobar Ortiz available under Attribution-NonCommercial-ShareAlike 4.0 International (CC BY-NC-SA 4.0 DEED) at https://cienciadedatos.net/documentos/py80-agentic-forecasting-skforecast-ai.ipynb
How to cite skforecast-ai
If you use skforecast-ai for a publication, we would appreciate if you cite the published software.
Zenodo:
Amat Rodrigo, Joaquin, & Escobar Ortiz, Javier. (2024). skforecast-ai (v0.2.0). Zenodo. https://doi.org/10.5281/zenodo.21338159
APA:
Amat Rodrigo, J., & Escobar Ortiz, J. (2024). skforecast-ai (Version 0.2.0) [Computer software]. https://doi.org/10.5281/zenodo.21338159
BibTeX:
@software{skforecast-ai, author = {Amat Rodrigo, Joaquin and Escobar Ortiz, Javier}, title = {skforecast-ai}, version = {0.2.0}, month = {09}, year = {2026}, license = {BSD-3-Clause}, url = {https://ai.skforecast.org/}, doi = {10.5281/zenodo.21338159} }
Did you like the article? Your support is important
Your contribution will help me to continue generating free educational content. Many thanks! 😊
This work by Joaquín Amat Rodrigo, Javier Escobar Ortiz is licensed under a Attribution-NonCommercial-ShareAlike 4.0 International.
Allowed:
-
Share: copy and redistribute the material in any medium or format.
-
Adapt: remix, transform, and build upon the material.
Under the following terms:
-
Attribution: You must give appropriate credit, provide a link to the license, and indicate if changes were made. You may do so in any reasonable manner, but not in any way that suggests the licensor endorses you or your use.
-
NonCommercial: You may not use the material for commercial purposes.
-
ShareAlike: If you remix, transform, or build upon the material, you must distribute your contributions under the same license as the original.
