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.23.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 primarily because your dataset has 17,544 │ │ hourly observations and a single target series ("users"), which makes │ │ ForecasterRecursive with LGBMRegressor the natural starting point: one global model │ │ trained recursively across the full series, using a gradient boosting estimator │ │ well-suited to datasets of this size. │ │ │ │ Why ForecasterRecursive │ │ │ │ ForecasterRecursive trains a single model that reuses its own predictions as inputs │ │ when generating multi-step forecasts. For a 36-step ahead horizon on hourly data, │ │ this is efficient and flexible. The plan notes that ForecasterDirect and │ │ ForecasterFoundation are alternatives, but the recursive approach is the recommended │ │ default for this kind of single-series regression problem. │ │ │ │ Why LGBMRegressor │ │ │ │ The profile decision explicitly states that a gradient boosting model is preferred │ │ for a dataset of this size. LightGBM handles the scale well, trains quickly, and │ │ natively tolerates missing values, which is why the plan keeps NaN rows rather than │ │ dropping them. XGBRegressor and Ridge are listed as alternatives but were not │ │ selected as the primary recommendation. │ │ │ │ What the lags and window features capture │ │ │ │ The plan uses a rich set of 32 lags, including short-term lags (1, 2, 3), daily │ │ cycle lags (around 24), weekly cycle lags (around 168), and longer lags reaching │ │ back to 337 hours. This directly encodes recent demand, the same hour yesterday, and │ │ the same hour last week. The window features add rolling summaries: a short 3-hour │ │ mean and standard deviation, a 24-hour mean capturing the daily average, and a │ │ 168-hour mean capturing the weekly average. Together these give the model a layered │ │ picture of demand at multiple time scales. │ │ │ │ What the exogenous variables add │ │ │ │ Your three exogenous variables, "holiday", "weather", and "temp", provide │ │ information the lags and window features cannot reconstruct on their own. │ │ │ │ • "holiday" signals days when demand patterns are structurally different from │ │ normal workdays, something a lag of 24 or 168 hours may not reliably capture if │ │ holidays fall irregularly. │ │ • "weather" is categorical (detected automatically by skforecast via │ │ categorical_features='auto') and may associate with distinct demand regimes, such │ │ as rain suppressing ridership. │ │ • "temp" is a continuous variable that may be associated with comfort thresholds │ │ influencing whether people choose to bike. │ │ │ │ Together these three variables give the model context about conditions at the │ │ forecast horizon that the historical series alone cannot supply. │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯
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 hours ahead, │ │ 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 carefully chosen lags rather than a dense consecutive │ │ block. A few groupings stand out: │ │ │ │ • Short-term lags (1, 2, 3, 5, 8, 10, 15): capture immediate autocorrelation and │ │ intra-hour dynamics from the last several hours. │ │ • Daily cycle lags (17 through 26, 32, 33): bracket the 24-hour mark, letting the │ │ model learn from approximately the same hour yesterday plus a few hours on either │ │ side. │ │ • Weekly cycle lags (119, 121, 135, 136, 142, 143, 145, 160, 166, 167, 169): │ │ cluster around 120 hours (5 days) and 168 hours (exactly one week ago), capturing │ │ the strong day-of-week pattern typical of hourly user data. │ │ • Two-week lags (313, 335, 337): reach back roughly two weeks, picking up any │ │ fortnightly seasonality or calendar regularities. │ │ │ │ This sparse but deliberate selection avoids loading the model with hundreds of │ │ redundant consecutive lags while still covering the dominant seasonal frequencies │ │ present in an hourly series. │ │ │ │ Window Features │ │ │ │ Three RollingFeatures objects complement the lags by summarising history at │ │ different scales: │ │ │ │ • Mean and std over the last 3 hours: a very short smoothing window that captures │ │ the immediate local level and variability, useful for detecting sudden spikes or │ │ drops. │ │ • Mean over the last 24 hours: represents the recent daily average level, giving │ │ the model a smoothed sense of today's baseline without adding 24 separate lag │ │ columns. │ │ • Mean over the last 168 hours: the rolling weekly average, encoding the typical │ │ weekly rhythm as a single feature rather than a collection of week-ago lags. │ │ │ │ Together these four statistics (two short, one daily, one weekly) provide │ │ multi-scale context that the point-in-time lags alone cannot fully capture. │ │ │ │ Calendar and Exogenous Features │ │ │ │ Calendar features (hour, day_of_week, weekend, month) are added with raw ordinal │ │ encoding, giving LightGBM direct access to time-of-day and day-of-week signals. The │ │ three exogenous variables, holiday, temp, and weather (which is categorical and │ │ handled automatically via categorical_features='auto'), supply real-world context │ │ that is not derivable from the series history alone. │ │ │ │ How the 80% Prediction Interval Is Produced │ │ │ │ The interval method is bootstrapping, which works in these steps: │ │ │ │ 1 During fit, in-sample residuals (the differences between fitted and actual values │ │ on the training set) are stored. With use_binned_residuals=True, these residuals │ │ are grouped by the predicted value level, so that the interval width can vary │ │ depending on whether the forecast is high or low, rather than applying a single │ │ uniform spread. │ │ 2 At prediction time, the forecaster runs n_boot simulated forecast paths, each │ │ time drawing residuals from the appropriate bin and adding them to the recursive │ │ predictions step by step. │ │ 3 After all bootstrap paths are collected, the 10th and 90th percentiles of those │ │ paths at each horizon step form the lower and upper bounds, producing an interval │ │ with nominal 80% coverage. │ │ │ │ The result is a DataFrame with columns for the point forecast, lower_bound, and │ │ upper_bound across all 36 steps. │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯
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', │ │ │ 'max'], '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)', 'max(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 core dynamics for hourly bike │ │ rentals: │ │ │ │ 1 Short-term trend (last few hours): Lags 1–6 capture the immediate recent trend — │ │ the last few hours of demand, including the build-up and decay of rush-hour │ │ peaks. │ │ 2 Daily rhythm (24-hour cycle): Lags 23, 24, 25 capture the same hour yesterday and │ │ its immediate neighbours, directly encoding the strong daily seasonality. A │ │ rolling mean and std over a 24-hour window also summarise yesterday's average │ │ level and variability (e.g., how volatile demand was over the past day), which │ │ helps the model adapt to different day types. │ │ 3 Weekly rhythm (weekday vs. weekend): Lags 47, 48, 49 (±1 around 48 h = 2 days │ │ ago) and lags 167, 168, 169 (±1 around 168 h = exactly one week ago) let the │ │ model directly compare the same hour from the previous week and the day before │ │ yesterday. A rolling mean and max over the full 168-hour week summarise the │ │ weekly baseline level and the peak demand observed in the past week — critical │ │ for distinguishing weekday commuter spikes from weekend leisure patterns. │ │ │ │ This set avoids redundancy (no consecutive lags beyond the short-term window) while │ │ covering all three seasonality scales the user identified. The window features │ │ complement the lags by providing smooth, compact summaries at the daily and weekly │ │ scales without requiring dozens of additional lag columns. │ │ │ │ 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 extends the feature set beyond a simple set of recent lags by │ │ explicitly encoding three seasonality scales that are characteristic of hourly bike │ │ rental demand. Here is what changed and why each change is relevant. │ │ │ │ Original vs Refined Feature Set │ │ │ │ The profile decision identified a ForecasterRecursive with LGBMRegressor and noted │ │ the exogenous variables. The refinement step specified a concrete, structured set of │ │ lags and window features rather than leaving them at a generic default. │ │ │ │ What Changed │ │ │ │ Lags │ │ │ │ The refined plan uses lags [1, 2, 3, 4, 5, 6, 23, 24, 25, 47, 48, 49, 167, 168, 169] │ │ instead of a simple consecutive range. The key additions are: │ │ │ │ • Lags 1 through 6 capture the immediate short-term trend, including the build-up │ │ and decay of rush-hour peaks over the preceding few hours. │ │ • Lags 23, 24, 25 target the same hour yesterday and its immediate neighbours, │ │ directly encoding the strong 24-hour daily cycle. │ │ • Lags 47, 48, 49 reference two days ago at the same hour, giving the model a │ │ second daily anchor point. │ │ • Lags 167, 168, 169 reference exactly one week ago plus or minus one hour, │ │ encoding the weekly rhythm and the contrast between weekday commuter patterns and │ │ weekend leisure patterns. │ │ │ │ This avoids adding dozens of consecutive lag columns while still covering all three │ │ seasonality scales. │ │ │ │ Window Features │ │ │ │ Four rolling summary features are added across two window sizes: │ │ │ │ • Rolling mean and standard deviation over 24 hours summarise yesterday's average │ │ demand level and its variability, helping the model adapt to different day types. │ │ • Rolling mean and maximum over 168 hours (one full week) capture the weekly │ │ baseline level and the peak demand observed in the past week. │ │ │ │ These complement the point lags by providing smooth, compact signals at the daily │ │ and weekly scales. │ │ │ │ Calendar Features │ │ │ │ Hour of day, day of week, a weekend indicator, and month are added as raw ordinal │ │ features. For bike rentals, the hour of day alone can account for large swings │ │ between off-peak and peak periods, and the weekend flag directly encodes the │ │ commuter-versus-leisure split. │ │ │ │ Why It Matters for This Dataset │ │ │ │ Hourly bike rental demand is strongly structured by time. Without lags and window │ │ features that explicitly span 24-hour and 168-hour horizons, the model would need to │ │ infer those cycles indirectly from recent lags alone, which becomes unreliable for a │ │ 36-step forecast horizon. By anchoring features at those exact periodicity points, │ │ the model has direct access to the relevant historical context at each seasonality │ │ scale. │ │ │ │ One important caveat: as noted in the plan, the lag and window feature choices are │ │ hypotheses and not yet validated improvements. The accuracy gain over a simpler lag │ │ set should be confirmed through backtesting before relying on them in production. │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯
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 | 39.992629 | 3680.134351 | 0.621108 | 0.4819 |
| pred | lower_bound | upper_bound | |
|---|---|---|---|
| 2012-12-30 12:00:00 | 146.169439 | 105.096817 | 180.289260 |
| 2012-12-30 13:00:00 | 135.193183 | 90.746536 | 170.332678 |
| 2012-12-30 14:00:00 | 133.087911 | 82.962582 | 167.156476 |
| 2012-12-30 15:00:00 | 133.307877 | 91.917838 | 163.950205 |
| 2012-12-30 16:00:00 | 137.103069 | 90.414013 | 171.935524 |
# 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 covers 36 hourly steps from 2012-12-30 12:00 through 2012-12-31 23:00, │ │ predicting bike rental demand using a ForecasterRecursive model with LGBMRegressor. │ │ The model achieved a MASE of 0.621, which is below 1.0, meaning it outperforms the │ │ naive baseline forecast. Here is a full breakdown. │ │ │ │ Model Setup │ │ │ │ The forecaster was built with these key components: │ │ │ │ • Estimator: LGBMRegressor, chosen as a gradient boosting model well suited to the │ │ dataset size of 17,544 hourly observations │ │ • Lags: 15 selected lags covering short-term recency (hours 1 through 6), the daily │ │ cycle (hours 23, 24, 25), two-day history (hours 47, 48, 49), and the weekly │ │ cycle (hours 167, 168, 169) │ │ • Window features: rolling mean and standard deviation over 24 hours, plus rolling │ │ mean and maximum over 168 hours (one full week) │ │ • Exogenous variables: holiday, weather (categorical, handled automatically), and │ │ temp │ │ • Calendar features: hour, day of week, weekend indicator, and month │ │ │ │ Evaluation Metrics │ │ │ │ The backtesting results across the full series are: │ │ │ │ • MAE of 39.99 means the model's point forecasts are off by about 40 users per hour │ │ on average │ │ • MASE of 0.621 confirms the model beats the naive seasonal baseline, since any │ │ value below 1.0 represents an improvement over that reference │ │ • MAPE of 0.4819 (roughly 48%) is a percentage-based measure; because bike rental │ │ counts can approach low values at night or quiet periods, this figure may be │ │ inflated by near-zero denominators and should be interpreted with caution │ │ • MSE of 3,680 reflects the average squared error, which penalizes larger │ │ individual mistakes more heavily than MAE does │ │ │ │ Predictions │ │ │ │ Across all 36 predicted hours, the point forecasts range from a minimum of about 4.9 │ │ users to a maximum of about 152.8 users, with a mean prediction of about 74.3 users │ │ per hour. │ │ │ │ The 80% prediction intervals (lower bound at the 10th percentile, upper bound at the │ │ 90th percentile, computed via bootstrapping) show: │ │ │ │ • Lower bounds range from about 0.5 to 105.1, with a mean of about 39.3 │ │ • Upper bounds range from about 13.6 to 187.3, with a mean of about 105.9 │ │ │ │ The interval width varies across the horizon, which is expected with bootstrapping: │ │ uncertainty tends to grow as predictions feed back into later steps in a recursive │ │ forecaster. The visible rows at the end of the horizon (late evening on 2012-12-31) │ │ show notably lower predicted demand, consistent with nighttime hours, and │ │ correspondingly narrower intervals. │ │ │ │ Summary │ │ │ │ The model performs well relative to the naive baseline, with a MASE of 0.621 and a │ │ MAE of roughly 40 users per hour. The 80% intervals provide a useful uncertainty │ │ band around each point forecast. The MAPE should be treated as supplementary given │ │ the potential for low rental counts in off-peak hours to distort percentage-based │ │ measures. │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯
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.184796 | 14.907624 | 34.497458 |
| 2013-01-01 01:00:00 | 13.733646 | 3.659032 | 21.379483 |
| 2013-01-01 02:00:00 | 7.976549 | 2.642388 | 13.898093 |
| 2013-01-01 03:00:00 | 5.635198 | 1.515536 | 9.686748 |
| 2013-01-01 04:00:00 | 5.568613 | 1.659878 | 9.801700 |
# 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', │ │ │ 'max'], '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)', 'max(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 core dynamics for hourly bike │ │ rentals: │ │ │ │ 1 Short-term trend (last few hours): Lags 1–6 capture the immediate recent trend — │ │ the last few hours of demand, including the build-up and decay of rush-hour │ │ peaks. │ │ 2 Daily rhythm (24-hour cycle): Lags 23, 24, 25 capture the same hour yesterday and │ │ its immediate neighbours, directly encoding the strong daily seasonality. A │ │ rolling mean and std over a 24-hour window also summarise yesterday's average │ │ level and variability (e.g., how volatile demand was over the past day), which │ │ helps the model adapt to different day types. │ │ 3 Weekly rhythm (weekday vs. weekend): Lags 47, 48, 49 (±1 around 48 h = 2 days │ │ ago) and lags 167, 168, 169 (±1 around 168 h = exactly one week ago) let the │ │ model directly compare the same hour from the previous week and the day before │ │ yesterday. A rolling mean and max over the full 168-hour week summarise the │ │ weekly baseline level and the peak demand observed in the past week — critical │ │ for distinguishing weekday commuter spikes from weekend leisure patterns. │ │ │ │ This set avoids redundancy (no consecutive lags beyond the short-term window) while │ │ covering all three seasonality scales the user identified. The window features │ │ complement the lags by providing smooth, compact summaries at the daily and weekly │ │ scales without requiring dozens of additional lag columns. │ │ │ │ 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.1848 │ 14.9076 │ 34.4975 │ ├─────────────────────┼─────────┼─────────────┼─────────────┤ │ 2013-01-01 01:00:00 │ 13.7336 │ 3.6590 │ 21.3795 │ ├─────────────────────┼─────────┼─────────────┼─────────────┤ │ 2013-01-01 02:00:00 │ 7.9765 │ 2.6424 │ 13.8981 │ ├─────────────────────┼─────────┼─────────────┼─────────────┤ │ 2013-01-01 03:00:00 │ 5.6352 │ 1.5155 │ 9.6867 │ ├─────────────────────┼─────────┼─────────────┼─────────────┤ │ 2013-01-01 04:00:00 │ 5.5686 │ 1.6599 │ 9.8017 │ ├─────────────────────┼─────────┼─────────────┼─────────────┤ │ ... │ ... │ ... │ ... │ ├─────────────────────┼─────────┼─────────────┼─────────────┤ │ 2013-01-02 07:00:00 │ 55.5648 │ 20.9451 │ 92.3618 │ ├─────────────────────┼─────────┼─────────────┼─────────────┤ │ 2013-01-02 08:00:00 │ 69.1623 │ 43.4470 │ 182.4530 │ ├─────────────────────┼─────────┼─────────────┼─────────────┤ │ 2013-01-02 09:00:00 │ 75.4641 │ 43.4894 │ 184.2271 │ ├─────────────────────┼─────────┼─────────────┼─────────────┤ │ 2013-01-02 10:00:00 │ 65.8258 │ 37.8858 │ 163.2721 │ ├─────────────────────┼─────────┼─────────────┼─────────────┤ │ 2013-01-02 11:00:00 │ 64.8625 │ 39.0137 │ 182.4295 │ └─────────────────────┴─────────┴─────────────┴─────────────┘
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 the model 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 retrained as the evaluation window rolls forward, refit=False (train once). This means fixed_train_size has no effect (as documented: fixed_train_size is irrelevant without refit), so it is left at its default. The forecast horizon is 36 hours (steps=36), matching the dataset metadata. No deployment gap was mentioned, so gap=0 (default). With 17,544 hourly observations and training ending in August 2012 (roughly 8 months × ~730 h ≈ 5,840 observations), there is ample remaining data for well over 2 folds of 36-step evaluation, satisfying the minimum-folds constraint. 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: 50.3025, mean_squared_error: 6910.7727, │ │ mean_absolute_scaled_error: 0.8204, mean_absolute_percentage_error: 0.5551. │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯
| mean_absolute_error | mean_squared_error | mean_absolute_scaled_error | mean_absolute_percentage_error | |
|---|---|---|---|---|
| 0 | 50.302476 | 6910.772709 | 0.820406 | 0.555051 |
| fold | pred | lower_bound | upper_bound | |
|---|---|---|---|---|
| 2012-09-01 00:00:00 | 0 | 129.141506 | 100.578964 | 153.657475 |
| 2012-09-01 01:00:00 | 0 | 106.534982 | 73.902908 | 136.019721 |
| 2012-09-01 02:00:00 | 0 | 67.848374 | 38.599536 | 105.805277 |
| 2012-09-01 03:00:00 | 0 | 39.658499 | 19.872419 | 69.489409 |
| 2012-09-01 04:00:00 | 0 | 19.804622 | 5.966910 | 36.093576 |
# 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 LightGBM across 82 folds on │ │ hourly bike rental data, producing a mean absolute error of 50.30 users. The MASE of │ │ 0.82 confirms the model beats the naive baseline, which is an encouraging sign. │ │ Whether it is ready to deploy depends on the tolerance for roughly 50 users of │ │ average error in context. │ │ │ │ Backtesting Strategy │ │ │ │ The cross-validation used a fixed-window, no-refit design with the following │ │ configuration: │ │ │ │ • Initial training cutoff: 2012-08-31 23:59 (everything before that date used for │ │ training) │ │ • Forecast horizon: 36 steps (36 hours ahead per fold) │ │ • Refit: False, meaning one model was trained once and evaluated across all 82 │ │ folds without updating │ │ • Fixed training size: True, so the training window does not expand as the │ │ evaluation moves forward │ │ • Gap: 0, meaning forecasts begin immediately after the last training observation │ │ • Fold stride: 36, so each fold's test window starts exactly where the previous one │ │ ended, covering September through December 2012 without overlap │ │ │ │ This setup simulates deploying a single trained model and measuring how it holds up │ │ over four months of unseen hourly data. │ │ │ │ Evaluation Metrics │ │ │ │ • MAE: 50.30 users — on average, predictions deviate from actual rentals by about │ │ 50 users per hour. This is the most interpretable number for operational │ │ decisions. │ │ • MSE: 6910.77 — the squared error is sensitive to occasional large misses. The gap │ │ between MAE and the square root of MSE (roughly 83) suggests some hours have │ │ substantially larger errors than the average. │ │ • MASE: 0.82 — this is the most important metric here. A value below 1.0 means the │ │ model outperforms the naive seasonal baseline. At 0.82, it beats that baseline by │ │ a meaningful margin. │ │ • MAPE: 0.56 (55.6%) — this is high and should be interpreted cautiously. MAPE │ │ inflates when actual rental counts are near zero, which happens in overnight │ │ hours. It likely overstates the practical error during the hours that matter most │ │ operationally. │ │ │ │ Predictions and Intervals │ │ │ │ Across all 2,928 predicted hours, point forecasts ranged from roughly 3 to 867 │ │ users, with a mean of about 234. The 80% prediction intervals (lower bound mean │ │ ~185, upper bound mean ~280) provide a reasonable spread around those point │ │ forecasts. A small number of lower bound values dip slightly below zero, which is │ │ physically impossible for rental counts and is worth noting as a minor calibration │ │ imperfection. │ │ │ │ Is the Model Ready to Deploy? │ │ │ │ The evidence is moderately positive but not conclusive on its own. Points in favour: │ │ │ │ • MASE of 0.82 confirms it genuinely improves on the naive baseline across 82 │ │ non-overlapping evaluation folds │ │ • The fixed-window no-refit strategy is a conservative test, meaning real │ │ deployment with periodic retraining would likely perform at least as well │ │ • The model covers all three seasonality scales identified for this data (hourly, │ │ daily, weekly) through carefully chosen lags and rolling features │ │ │ │ Points to consider before deploying: │ │ │ │ • A MAE of 50 users may or may not be acceptable depending on the operational │ │ context (fleet sizing, rebalancing decisions, etc.). That threshold is a business │ │ judgement, not a statistical one. │ │ • The MASE of 0.82 is solid but leaves room for improvement. Hyperparameter tuning │ │ via bayesian_search_forecaster or feature selection via select_features could │ │ close that gap. │ │ • The model was trained once and never retrained across four months. Running a │ │ refit=True or periodic-refit backtest would reveal whether drift over time is a │ │ concern. │ │ • The 80% prediction intervals have not been formally calibrated. Checking coverage │ │ with calculate_coverage against a holdout set would confirm whether the intervals │ │ are trustworthy for operational uncertainty management. │ │ │ │ A reasonable next step before deployment would be to tune the model and verify │ │ interval coverage, but the current results justify continued development rather than │ │ rejection. │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯
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: 50.3025, mean_squared_error: 6910.7727, │ │ mean_absolute_scaled_error: 0.8204, mean_absolute_percentage_error: 0.5551. │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ 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… ┃ ┡━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━┩ │ 50.3025 │ 6910.7727 │ 0.8204 │ 0.5551 │ └─────────────────────┴────────────────────┴──────────────────────┴──────────────────────┘ Backtest Predictions (2928 rows) ┏━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━┓ ┃ Index ┃ fold ┃ pred ┃ lower_bound ┃ upper_bound ┃ ┡━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━┩ │ 2012-09-01 00:00:00 │ 0.0000 │ 129.1415 │ 100.5790 │ 153.6575 │ ├─────────────────────┼─────────┼──────────┼─────────────┼─────────────┤ │ 2012-09-01 01:00:00 │ 0.0000 │ 106.5350 │ 73.9029 │ 136.0197 │ ├─────────────────────┼─────────┼──────────┼─────────────┼─────────────┤ │ 2012-09-01 02:00:00 │ 0.0000 │ 67.8484 │ 38.5995 │ 105.8053 │ ├─────────────────────┼─────────┼──────────┼─────────────┼─────────────┤ │ 2012-09-01 03:00:00 │ 0.0000 │ 39.6585 │ 19.8724 │ 69.4894 │ ├─────────────────────┼─────────┼──────────┼─────────────┼─────────────┤ │ 2012-09-01 04:00:00 │ 0.0000 │ 19.8046 │ 5.9669 │ 36.0936 │ ├─────────────────────┼─────────┼──────────┼─────────────┼─────────────┤ │ ... │ ... │ ... │ ... │ ... │ ├─────────────────────┼─────────┼──────────┼─────────────┼─────────────┤ │ 2012-12-31 19:00:00 │ 81.0000 │ 67.3204 │ 31.5294 │ 108.5078 │ ├─────────────────────┼─────────┼──────────┼─────────────┼─────────────┤ │ 2012-12-31 20:00:00 │ 81.0000 │ 40.3452 │ 20.0169 │ 82.1894 │ ├─────────────────────┼─────────┼──────────┼─────────────┼─────────────┤ │ 2012-12-31 21:00:00 │ 81.0000 │ 29.3831 │ 13.5137 │ 61.1140 │ ├─────────────────────┼─────────┼──────────┼─────────────┼─────────────┤ │ 2012-12-31 22:00:00 │ 81.0000 │ 19.5810 │ 11.1901 │ 42.4820 │ ├─────────────────────┼─────────┼──────────┼─────────────┼─────────────┤ │ 2012-12-31 23:00:00 │ 81.0000 │ 16.1096 │ 8.8776 │ 31.6928 │ └─────────────────────┴─────────┴──────────┴─────────────┴─────────────┘ 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', │ │ │ 'max'], '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)', 'max(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 core dynamics for hourly bike │ │ rentals: │ │ │ │ 1 Short-term trend (last few hours): Lags 1–6 capture the immediate recent trend — │ │ the last few hours of demand, including the build-up and decay of rush-hour │ │ peaks. │ │ 2 Daily rhythm (24-hour cycle): Lags 23, 24, 25 capture the same hour yesterday and │ │ its immediate neighbours, directly encoding the strong daily seasonality. A │ │ rolling mean and std over a 24-hour window also summarise yesterday's average │ │ level and variability (e.g., how volatile demand was over the past day), which │ │ helps the model adapt to different day types. │ │ 3 Weekly rhythm (weekday vs. weekend): Lags 47, 48, 49 (±1 around 48 h = 2 days │ │ ago) and lags 167, 168, 169 (±1 around 168 h = exactly one week ago) let the │ │ model directly compare the same hour from the previous week and the day before │ │ yesterday. A rolling mean and max over the full 168-hour week summarise the │ │ weekly baseline level and the peak demand observed in the past week — critical │ │ for distinguishing weekday commuter spikes from weekend leisure patterns. │ │ │ │ This set avoids redundancy (no consecutive lags beyond the short-term window) while │ │ covering all three seasonality scales the user identified. The window features │ │ complement the lags by providing smooth, compact summaries at the daily and weekly │ │ scales without requiring dozens of additional lag columns. │ │ │ │ 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]
# Ranked leaderboard
# ==============================================================================
results_compare.results
| rank | name | forecaster | estimator | mean_absolute_error | mean_squared_error | mean_absolute_scaled_error | mean_absolute_percentage_error | error | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 1 | ForecasterRecursive | ForecasterRecursive | LGBMRegressor | 46.584343 | 5443.171132 | 0.754003 | 0.470311 | None |
| 1 | 2 | ForecasterDirect | ForecasterDirect | LGBMRegressor | 49.020298 | 5848.934447 | 0.793430 | 0.497920 | None |
| 2 | 3 | ForecasterFoundation | ForecasterFoundation | Chronos-2 | NaN | NaN | NaN | NaN | ImportError: chronos-forecasting >=2.0 is requ... |
# 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: 'ForecasterRecursive' │ │ (ForecasterRecursive / LGBMRegressor) = 46.5843, 5.0% ahead of 'ForecasterDirect' │ │ (49.0203). 1 configuration failed to run and is ranked last. │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯
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]
config.json: 0%| | 0.00/969 [00:00<?, ?B/s]
model.safetensors: 0%| | 0.00/112M [00:00<?, ?B/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 | refined_plan | ForecasterRecursive | LGBMRegressor | 50.302476 | 0.820406 |
| 2 | 3 | lgbm_direct | ForecasterDirect | LGBMRegressor | 50.380925 | 0.821734 |
| 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_ai/lib/python3.13/site-packages/skforecast_a │ │ i/_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) is the clear winner, and the margin over the │ │ second-place candidate is meaningful rather than negligible. With a MAE of 38.44 │ │ versus 50.30 for the refined_plan (ForecasterRecursive with LGBMRegressor), the gap │ │ is substantial in absolute terms. The MASE values reinforce this: Chronos-2 scores │ │ 0.597, while refined_plan scores 0.820. │ │ │ │ Key observations from the leaderboard │ │ │ │ • foundation_model (Chronos-2): MAE 38.44, MASE 0.597 │ │ • refined_plan (LGBMRegressor, recursive): MAE 50.30, MASE 0.821 │ │ • lgbm_direct (LGBMRegressor, direct): MAE 50.38, MASE 0.822 │ │ • lgbm_daily_lags (LGBMRegressor, recursive): MAE 54.95, MASE 0.896 │ │ • ridge_baseline (Ridge, recursive): MAE 93.15, MASE 1.519 │ │ │ │ On the gap between positions 1 and 2 │ │ │ │ The separation between Chronos-2 and the best ML candidate is notable. Both │ │ candidates were evaluated under identical cross-validation conditions: 82 folds, a │ │ fixed training window, a 36-step horizon, and no refit. That controlled setup makes │ │ the comparison fair and the gap credible, not an artifact of different evaluation │ │ conditions. │ │ │ │ Between positions 2, 3, and 4 │ │ │ │ The two LGBMRegressor candidates in second and third place (refined_plan and │ │ lgbm_direct) are practically equivalent. Their MAE values are 50.30 and 50.38, and │ │ their MASE values are 0.820 and 0.822. Those differences are negligible, and no │ │ meaningful practical distinction can be drawn between the recursive and direct │ │ strategies here. │ │ │ │ The lgbm_daily_lags variant (MAE 54.95, MASE 0.896) sits a bit further back from │ │ those two, but all three LGBM candidates beat the naive baseline because their MASE │ │ values are below 1.0. │ │ │ │ On the ridge baseline │ │ │ │ The Ridge model (MASE 1.519) fails to beat the naive baseline, since a MASE above │ │ 1.0 means the naive baseline would have produced smaller errors. It serves its │ │ intended role as a sanity-check lower bound, confirming that the more capable models │ │ are adding real value. │ │ │ │ Summary │ │ │ │ The foundation model leads by a meaningful margin. The two top LGBM variants are │ │ essentially tied with each other. All ML candidates except Ridge beat the naive │ │ baseline, and Ridge confirms the floor below which no useful model should fall. │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯
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.6% ahead of 'refined_plan' (50.3025). │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯ Comparison Results ┏━━━━━━━┳━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┓ ┃ Index ┃ rank ┃ name ┃ forecaster ┃ estimator ┃ mean_absol… ┃ mean_absolu… ┃ ┡━━━━━━━╇━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━━━━━┩ │ 0 │ 1 │ foundation_… │ ForecasterF… │ Chronos-2 │ 38.4362 │ 0.5975 │ ├───────┼──────┼──────────────┼──────────────┼──────────────┼─────────────┼──────────────┤ │ 1 │ 2 │ refined_plan │ ForecasterR… │ LGBMRegress… │ 50.3025 │ 0.8204 │ ├───────┼──────┼──────────────┼──────────────┼──────────────┼─────────────┼──────────────┤ │ 2 │ 3 │ lgbm_direct │ ForecasterD… │ LGBMRegress… │ 50.3809 │ 0.8217 │ ├───────┼──────┼──────────────┼──────────────┼──────────────┼─────────────┼──────────────┤ │ 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 ──────────────────────────────────╮ │ │ │ The direct strategy trains one independent model per forecast step, so it avoids the │ │ error propagation that builds up when a recursive model feeds its own predictions │ │ back as inputs. For hourly demand with strong daily and weekly seasonality, the │ │ direct approach becomes preferable when the predictive relationship changes │ │ meaningfully across the horizon, for example when the features that best explain │ │ demand at hour 1 ahead differ substantially from those that explain demand at hour │ │ 24 or hour 168 ahead. │ │ │ │ Specific situations that favour ForecasterDirect │ │ │ │ • Long horizons relative to the seasonal cycle. When you need to forecast 24 or 48 │ │ hours ahead, recursive predictions accumulate errors over many steps. A direct │ │ model at step 24 is trained directly against the 24-hour-ahead target, so it is │ │ not penalised by errors at steps 1 through 23. │ │ • Step-specific seasonal patterns. Peak morning demand and late-evening troughs may │ │ be driven by different lag relationships. A direct model at each hour can learn │ │ those hour-specific dynamics independently, whereas a single recursive model must │ │ compromise across all steps. │ │ • Horizon-dependent exogenous features. If you have exogenous variables such as │ │ weather forecasts or calendar flags whose predictive value varies by how far │ │ ahead you are forecasting, direct models can weight them differently at each │ │ step. │ │ • When recursive error propagation is measurable. The practical test is backtesting │ │ with backtesting_forecaster using TimeSeriesFold. If the recursive model's │ │ accuracy degrades notably at the far end of the horizon but the direct model │ │ holds up, that is the empirical signal to prefer direct. │ │ │ │ Situations where recursive is still competitive │ │ │ │ • Short horizons (a few steps). Error propagation is small when you only need a few │ │ steps ahead, so the simplicity and speed of a single model often wins. │ │ • Limited training data. Fitting N separate models (one per step) multiplies the │ │ effective model count. With scarce data, a single recursive model may generalise │ │ better. │ │ • When strong lag structure dominates. If the 24-lag and 168-lag values are │ │ overwhelmingly predictive at every horizon, a single recursive model captures │ │ that structure efficiently. │ │ │ │ Practical skforecast guidance │ │ │ │ • Use ForecasterDirect with the steps parameter set to your full horizon (for │ │ example 24 or 48) at initialisation. │ │ • Enable n_jobs=-1 to parallelise the N model fits, which offsets the training time │ │ cost. │ │ • Per-series lag dicts are not available in ForecasterDirect, but you can pass │ │ different lag arrays to the single lags parameter. │ │ • Run backtesting_forecaster with both ForecasterRecursive and ForecasterDirect │ │ under the same TimeSeriesFold configuration and compare MASE or RMSSE values. A │ │ MASE below 1 confirms the model beats the naive baseline; the leaderboard result │ │ tells you which strategy is relatively stronger. │ │ │ │ The bottom line is: for hourly demand at horizons of 24 hours or longer, the direct │ │ strategy is worth testing, and the empirical comparison via backtesting is the most │ │ reliable way to decide. │ │ │ ╰────────────────────────────────────────────────────────────────────────────────────────╯
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)
----- matplotlib 3.11.0 pandas 2.3.3 plotly 6.8.0 session_info v1.0.1 skforecast 0.23.0 skforecast_ai 0.2.0 ----- IPython 9.15.0 jupyter_client 8.9.1 jupyter_core 5.9.1 ----- Python 3.13.14 | packaged by conda-forge | (main, Jun 12 2026, 09:50:25) [GCC 14.3.0] Linux-7.0.0-1010-aws-x86_64-with-glibc2.43 ----- Session information updated at 2026-08-21 08:44
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
If you use skforecast 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.
