More about forecasting in cienciadedatos.net


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 skforecast script 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-guided refine_plan() or create_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 skforecast code.

  • 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.

The ask() method is available in both workflows. It can explain a profile, plan, validation setup, backtest result, or answer general forecasting questions, but it will never execute the workflow or modify your parameters without explicit instruction.

Fast path: one call

Profiling, planning and execution happen internally.

data
Forecast
forecast()
or forecast_code()
predictions + code
Backtesting (validation)
create_cv()
Deterministic, Agentic mode
or pass a skforecast TimeSeriesFold object
backtest()
or backtest_code()
metrics + predictions + code
Step-by-step path: full control

Build a profile and a plan from your data, then branch into forecasting and backtesting.

data
profile()
plan()
refine_plan(), optional (Deterministic or Agentic mode)
Forecast
forecast()
or forecast_code()
predictions + code
Backtesting (validation)
create_cv()
Deterministic, Agentic mode
or pass a skforecast TimeSeriesFold object
backtest()
or backtest_code()
metrics + predictions + code
LLM reasoning: available at any moment, in any workflow
Call ask() before, during or after either path. It can take a profile, a plan, a forecast_result, a backtest_result, or nothing at all (pure Q&A).

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
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__}")
Version skforecast_ai: 0.1.0
Version skforecast: 0.23.0

✏️ 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-flash-preview"
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 Path

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 │
├────────────────┼────────────────────────┤
│ Missing target │ none                   │
└────────────────┴────────────────────────┘

                                      Recommendation                                      
┏━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Property               Value                                                          ┃
┡━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ Task type             │ single_series                                                  │
├───────────────────────┼────────────────────────────────────────────────────────────────┤
│ Forecaster            │ ForecasterRecursive                                            │
├───────────────────────┼────────────────────────────────────────────────────────────────┤
│ Forecaster candidates │ ForecasterRecursive, ForecasterDirect, ForecasterFoundation,   │
│                       │ ForecasterStats                                                │
├───────────────────────┼────────────────────────────────────────────────────────────────┤
│ Estimator             │ LGBMRegressor                                                  │
├───────────────────────┼────────────────────────────────────────────────────────────────┤
│ Estimator candidates  │ LGBMRegressor, XGBRegressor, Ridge                             │
└───────────────────────┴────────────────────────────────────────────────────────────────┘

╭───────────────────────────────────── Explanation ──────────────────────────────────────╮
                                                                                        
  A single-series ML forecaster (ForecasterRecursive) is recommended. Data: 17544       
  observations, 'h' frequency. Alternative forecasters: ['ForecasterDirect',            
  'ForecasterFoundation', 'ForecasterStats']. 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()
╭───────────────────────────────────── Explanation ──────────────────────────────────────╮
                                                                                        
                    Forecaster & Estimator Recommendation Explained                     
                                                                                        
  Why ForecasterRecursive?                                                              
                                                                                        
  For your 17,544-observation hourly bike-sharing series, ForecasterRecursive is the    
  natural starting point. It trains a single model that learns to predict demand one    
  step ahead, then feeds its own predictions forward to cover all 36 future hours. Key  
  reasons it fits here:                                                                 
                                                                                        
   • Sufficient data: With ~2 years of hourly observations, there's ample history to    
     train a robust lag-based model without overfitting.                                
   • Flexibility: You don't need step-specific models — demand patterns at hour +1 and  
     hour +36 can reasonably be captured by the same underlying relationships (recent   
     usage, time of day, weather).                                                      
   • Full feature support: Rolling window features, calendar features, and exogenous    
     variables all work natively, which is important given your data's clear daily and  
     weekly seasonality.                                                                
                                                                                        
  ForecasterDirect remains a listed alternative if you later find that short-horizon    
  and long-horizon predictions benefit from fundamentally different feature             
  relationships, but that adds training complexity without a guaranteed gain here.      
                                                                                        
  ------------------------------------------------------------------------------------  
                                                                                        
  Why LGBMRegressor?                                                                    
                                                                                        
  LightGBM (gradient boosting on decision trees) is the preferred estimator at this     
  scale for several practical reasons:                                                  
                                                                                        
   • Handles 17,544 rows efficiently: LightGBM is specifically designed for fast        
     training on medium-to-large tabular datasets, far outpacing Random Forest or deep  
     learning in wall-clock time here.                                                  
   • Native categorical support: Your weather variable is categorical. LightGBM         
     handles this internally without one-hot expansion, which reduces noise and speeds  
     up training — this is why categorical_features='auto' is set in the plan.          
   • NaN tolerance: LightGBM natively handles missing values in lag/window features     
     that arise at the start of the training window. This is why rows with NaN are      
     kept rather than dropped.                                                          
   • Strong out-of-the-box performance: Gradient boosting captures nonlinear            
     interactions (e.g., temperature matters more on weekends, or holidays suppress     
     demand regardless of weather) without manual feature crossing.                     
                                                                                        
  XGBoost and Ridge are listed as alternatives — Ridge would serve as a simpler linear  
  baseline, while XGBoost is broadly comparable to LightGBM.                            
                                                                                        
  ------------------------------------------------------------------------------------  
                                                                                        
  What the Exogenous Variables Add                                                      
                                                                                        
  Your three exogenous variables — holiday, weather, and temp — contribute information  
  that pure lag history cannot capture:                                                 
                                                                                        
                                                                                        
   Variable  Type           What it contributes                                         
   ──────────────────────────────────────────────────────────────────────────────────   
   holiday   Likely binary  Demand on public holidays may collapse or shift even when   
                            recent usage was high — the model can't infer this from     
                            yesterday's counts alone                                    
   weather   Categorical    Rainy or stormy conditions are associated with suppressed   
                            ridership regardless of the usual hourly pattern            
   temp      Numeric        Temperature has a well-known nonlinear relationship with    
                            cycling demand (too cold or too hot both reduce usage)      
                                                                                        
                                                                                        
  Together, they act as contextual signals that explain demand departures from what     
  the lag/seasonal pattern alone would predict. Importantly, for predict() to use       
  these, you must supply their known or forecast values across all 36 future steps —    
  they cannot be inferred from the series itself.                                       
                                                                                        
  ------------------------------------------------------------------------------------  
                                                                                        
  How the Features Work Together                                                        
                                                                                        
  The plan combines three complementary information sources:                            
                                                                                        
   1 Lags (including lags at 24, 168 hours): Capture the immediate recent trend, the    
     same hour yesterday, and the same hour last week — directly encoding daily and     
     weekly seasonality.                                                                
   2 Rolling window features (3h, 24h, 168h mean/std): Summarise short-term momentum,   
     daily average level, and weekly average level as smoothed context signals.         
   3 Calendar features (hour, day_of_week, weekend, month): Give the model explicit     
     awareness of when it is, complementing what the lags imply implicitly.             
   4 Exogenous variables: Add external context (weather, holidays, temperature) that    
     is unknowable from the series history.                                             
                                                                                        
  LightGBM's tree structure is particularly well-suited to learning interactions        
  across all these feature types — for example, automatically discovering that hour=8   
  AND day_of_week=weekday AND weather=clear predicts a morning commute surge.           
                                                                                        
╰────────────────────────────────────────────────────────────────────────────────────────╯

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                                                              ┃
┡━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ 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     │   - handle_categorical_exog: Categorical exogenous variables       │
│                   │ detected: ['weather']. These are handled automatically by          │
│                   │ skforecast (categorical_features='auto'). (optional)               │
└───────────────────┴────────────────────────────────────────────────────────────────────┘

╭─────────────────────────────────── 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()
╭───────────────────────────────────── Explanation ──────────────────────────────────────╮
                                                                                        
                              Forecasting Plan Walkthrough                              
                                                                                        
  Overview                                                                              
                                                                                        
  You have 17,544 hourly observations of a users series — roughly two years of data.    
  The plan uses a ForecasterRecursive with LightGBM to predict 36 steps ahead (36       
  hours), with an 80% prediction interval around each point forecast.                   
                                                                                        
  ------------------------------------------------------------------------------------  
                                                                                        
  Why These Lags?                                                                       
                                                                                        
  The lag set [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] was selected via     
  autocorrelation analysis (PACF). Each lag represents a past hour that carries         
  statistically meaningful partial autocorrelation with the target. There are three     
  natural clusters:                                                                     
                                                                                        
  Short-range lags (1–33 hours)                                                         
                                                                                        
   • Lags 1–3: Immediate recent behaviour — what happened in the last 1–3 hours is      
     almost always predictive.                                                          
   • Lags 5, 8, 10, 15: Shorter within-day dependencies, possibly capturing shift       
     patterns or gradual trends.                                                        
   • Lags 19–26: These bracket the 24-hour daily cycle. Users at 2pm today are likely   
     similar to users at 2pm yesterday. The cluster around lag 24 is the most           
     important daily seasonal anchor.                                                   
   • Lags 32–33: Just over a day back — capturing day-over-day momentum with a slight   
     offset (e.g., yesterday plus the previous partial day).                            
                                                                                        
  Weekly seasonal lags (~119–169 hours)                                                 
                                                                                        
   • Lags 119–169 cluster around 168 hours (7 days). User behaviour on Monday at 9am    
     is strongly predictive of Monday at 9am next week. The spread around 168 accounts  
     for slightly asymmetric weekly patterns (e.g., Friday afternoons behave            
     differently from Monday mornings at the same hour).                                
                                                                                        
  Bi-weekly lags (~313–337 hours)                                                       
                                                                                        
   • Lags 313–337 sit around 336 hours (two weeks). These capture fortnightly cycles —  
     common in usage data driven by pay cycles, bi-weekly meetings, or recurring        
     events. With 17,544 observations (~2 years), there is enough data for the model    
     to reliably estimate these long-range dependencies.                                
                                                                                        
  ▌ LightGBM handles a large, sparse lag set efficiently — it will assign near-zero     
  ▌ importance to any lags that turn out not to be useful, so including candidate       
  ▌ lags is low-risk.                                                                   
                                                                                        
  ------------------------------------------------------------------------------------  
                                                                                        
  Why These Window Features?                                                            
                                                                                        
  Rolling (window) features summarise the recent history of the series into a small     
  number of meaningful statistics. Three scales are used:                               
                                                                                        
                                                                                        
   Feature           What it captures                                                   
   ──────────────────────────────────────────────────────────────────────────────────   
   mean(window=3)    The very recent local level — has usage been rising or falling     
                     in the last 3 hours?                                               
   std(window=3)     Short-term volatility — is usage fluctuating or stable right       
                     now?                                                               
   mean(window=24)   The typical level over the last full day — a smooth daily          
                     baseline that absorbs noise from individual lags.                  
   mean(window=168)  The typical level over the last full week — the long-run weekly    
                     baseline, useful for detecting week-over-week shifts.              
                                                                                        
                                                                                        
  These four features complement the lags in an important way: the lags capture         
  specific past time points, while the rolling means capture smoothed levels across a   
  range. Together they give LightGBM both fine-grained and coarse-grained views of the  
  series history.                                                                       
                                                                                        
  ------------------------------------------------------------------------------------  
                                                                                        
  The Exogenous Variables                                                               
                                                                                        
  Three external variables are included:                                                
                                                                                        
  holiday: Whether the current hour falls on a holiday. User patterns on holidays    
     often resemble weekends but deviate from the day-of-week calendar feature.         
  weather: A categorical variable (e.g., sunny/rainy/snowy). Skforecast detects      
     this automatically via categorical_features='auto' and passes it to LightGBM as a  
     native categorical — no one-hot encoding needed.                                   
  temp: Temperature, a continuous variable that likely has a nonlinear relationship  
     with users (e.g., very cold or very hot temperatures suppressing outdoor           
     activity).                                                                         
                                                                                        
  ▌ These variables must be provided for all 36 future hours when calling predict().    
  ▌ The plan assumes you have forecasts or known values for these variables at          
  ▌ prediction time.                                                                    
                                                                                        
  ------------------------------------------------------------------------------------  
                                                                                        
  Calendar Features                                                                     
                                                                                        
  Four calendar features are added using raw ordinal encoding (no cyclical sin/cos      
  transformation): hour, day_of_week, weekend, and month. LightGBM's tree-based splits  
  handle ordinal integers well and can discover the natural boundaries (e.g., hour 8    
  vs. hour 9, weekday vs. weekend) directly from data — cyclical encoding is more       
  important for linear models.                                                          
                                                                                        
  ------------------------------------------------------------------------------------  
                                                                                        
  How the 80% Prediction Interval Is Produced                                           
                                                                                        
  The interval [0.1, 0.9] targets the 10th and 90th percentiles of the forecast         
  distribution — meaning approximately 80% of future observations should fall within    
  the band.                                                                             
                                                                                        
  The method is bootstrapping, which works as follows:                                  
                                                                                        
  Step 1 — Store in-sample residuals                                                    
                                                                                        
  During fit(), the model predicts each training observation and stores the errors      
  (residuals). These residuals represent the distribution of mistakes the model makes,  
  indexed by the magnitude of the prediction (using use_binned_residuals=True).         
  Binning by prediction level is important: if the model makes larger errors when       
  users is high (heteroscedasticity), the intervals will be wider at those levels and   
  narrower when usage is low.                                                           
                                                                                        
  Step 2 — Bootstrap multi-step paths                                                   
                                                                                        
  At prediction time, n_boot=500 simulated forecast trajectories are generated. For     
  each trajectory:                                                                      
                                                                                        
   1 A residual is drawn (at random, from the appropriate bin for the current           
     predicted level).                                                                  
   2 That residual is added to the point forecast to create a perturbed value.          
   3 The perturbed value is fed back as if it were an observation to produce the next   
     step.                                                                              
                                                                                        
  This propagates uncertainty across all 36 steps: errors compound over the horizon,    
  so the intervals naturally widen the further out you forecast.                        
                                                                                        
  Step 3 — Derive quantiles                                                             
                                                                                        
  After 500 trajectories, each future hour has a distribution of 500 simulated values.  
  The 10th percentile of that distribution becomes lower_bound and the 90th percentile  
  becomes upper_bound.                                                                  
                                                                                        
  What the output looks like                                                            
                                                                                        
  Each of the 36 forecast rows will contain three columns:                              
                                                                                        
  pred — the point forecast (LightGBM's direct prediction)                           
  lower_bound — the 10th percentile (80% interval lower edge)                        
  upper_bound — the 90th percentile (80% interval upper edge)                        
                                                                                        
  A note on calibration                                                                 
                                                                                        
  An 80% interval should contain the true value roughly 80% of the time. In practice    
  this depends on whether the stored residuals are representative of future errors.     
  Using use_binned_residuals=True improves calibration by matching residual size to     
  prediction magnitude, but it is worth checking empirically — the backtesting step in  
  the script will compute coverage so you can verify this directly.                     
                                                                                        
╰────────────────────────────────────────────────────────────────────────────────────────╯

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 as lags, estimator, estimator_kwargs, forecaster, steps, interval, or window_features. Only the fields you explicitly specify are updated; the rest of the configuration is deterministically re-derived from the original plan.

  • LLM mode (prompt provided): describe your domain knowledge in natural language. The LLM interprets this context and suggests appropriate lags and window_features. Its reasoning is appended to plan.explanation and the changed fields are recorded in plan.llm_refined_fields for 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                                                              ┃
┡━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ 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     │   - handle_categorical_exog: Categorical exogenous variables       │
│                   │ detected: ['weather']. These are handled automatically by          │
│                   │ skforecast (categorical_features='auto'). (optional)               │
└───────────────────┴────────────────────────────────────────────────────────────────────┘

╭─────────────────────────────────── 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                                                              ┃
┡━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ 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     │   - handle_categorical_exog: Categorical exogenous variables       │
│                   │ detected: ['weather']. These are handled automatically by          │
│                   │ skforecast (categorical_features='auto'). (optional)               │
└───────────────────┴────────────────────────────────────────────────────────────────────┘

╭─────────────────────────────────── 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 key dynamics for hourly bike       
  rentals:                                                                              
                                                                                        
   1 "The last few hours give a good sense of the current trend" → Short lags [1, 2,    
     3, 4, 5, 6] capture the immediate momentum and direction of demand (e.g., a        
     commute ramp-up in progress).                                                      
   2 "Clear daily rhythm with rush-hour peaks" → Lags around 24 h [23, 24, 25] and 48   
     h [47, 48, 49] anchor the model to the same hour on the previous one and two       
     days, directly encoding the intra-day cycle. The ±1 neighbours (23/25, 47/49) add  
     robustness for slight timing shifts in peaks.                                      
   3 "Usually similar to what happened at the same time last week" → Lags around 168 h  
     [167, 168, 169] capture the same hour from exactly one week ago, accounting for    
     the weekday/weekend rhythm the user highlighted. Again, ±1 neighbours provide      
     robustness.                                                                        
   4 Rolling window — 24 h mean + std: The 24-hour mean smooths over the full daily     
     cycle and gives the model a sense of the current day's overall demand level. The   
     24-hour std captures intra-day volatility (e.g., a rainy day suppresses peaks),    
     which is useful for a demand series driven by weather and events.                  
   5 Rolling window — 168 h mean + max: The weekly mean gives the baseline level for    
     the current week (distinguishing a typical weekday week from a holiday week),      
     while the weekly max captures whether any unusually high peak occurred recently —  
     a signal relevant for capacity and planning in bike-share systems. Together these  
     two window sizes (one short/reactive, one long/trend) follow the multi-scale best  
     practice without over-engineering the feature set.                                 
                                                                                        
  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()
╭───────────────────────────────────── Explanation ──────────────────────────────────────╮
                                                                                        
               Refined Plan vs. Original: What Changed and Why It Matters               
                                                                                        
  Summary of Changes                                                                    
                                                                                        
  The refinement moved from a generic default configuration to one tailored             
  specifically to the dynamics of hourly bike-rental demand. Here are the key           
  differences:                                                                          
                                                                                        
  ------------------------------------------------------------------------------------  
                                                                                        
  1. Lags: Generic → Seasonality-Aware                                                  
                                                                                        
                                                                                        
         Original (default)                    Refined                                  
   ──────────────────────────────────────────────────────────────────────────────────   
   Lags  Typically a simple contiguous block   [1, 2, 3, 4, 5, 6, 23, 24, 25, 47, 48,   
         (e.g., 1–24)                          49, 167, 168, 169]                       
                                                                                        
                                                                                        
  Why it matters: A contiguous block like 1–24 wastes capacity on lags 7–22 (mid-day    
  history) that carry little signal compared to the structural anchors of bike demand:  
  the same hour yesterday, two days ago, and last week.                                 
                                                                                        
  The refined set targets three distinct temporal patterns you described:               
                                                                                        
   • Lags 1–6: Capture the current trend/momentum — whether demand is ramping up        
     (morning commute) or tapering off.                                                 
   • Lags 23–25 and 47–49: Anchor the model to the same clock-hour from the previous    
     one and two days, directly encoding the daily commute rhythm. The ±1 neighbours    
     (e.g., 23 and 25 alongside 24) make the model robust to slight timing shifts in    
     peaks.                                                                             
   • Lags 167–169: Encode the same hour from exactly one week ago, capturing the        
     weekday/weekend contrast you highlighted. This is the single most important        
     structural feature for a weekly-seasonal series.                                   
                                                                                        
  The net effect is fewer lags but higher information density — the model sees the      
  right history rather than redundant intermediate history.                             
                                                                                        
  ------------------------------------------------------------------------------------  
                                                                                        
  2. Window Features: Simple → Multi-Scale                                              
                                                                                        
                                                                                        
            Original (default)              Refined                                     
   ──────────────────────────────────────────────────────────────────────────────       
   Windows  Possibly a single rolling mean  24-h mean + std, and 168-h mean + max       
                                                                                        
                                                                                        
  Why it matters: Two scales working together:                                          
                                                                                        
   • 24-h mean: Tells the model the overall demand level for the current day — is       
     today a busy day or a quiet one? This smooths over individual hour noise.          
   • 24-h std: Captures intra-day volatility. A high std suggests strong peak/trough    
     contrast (typical dry weekday); a low std suggests suppressed, flat demand (rainy  
     day, holiday). This interacts naturally with your weather and holiday exogenous    
     variables.                                                                         
   • 168-h mean: Gives the model the baseline demand level for the current week as a    
     whole, helping distinguish a normal weekday week from a holiday-affected week.     
   • 168-h max: Flags whether an unusually high peak occurred recently — relevant for   
     understanding capacity constraints and demand surges in bike-share contexts.       
                                                                                        
  ------------------------------------------------------------------------------------  
                                                                                        
  3. Everything Else Stayed the Same (and That's Intentional)                           
                                                                                        
   • LGBMRegressor: Correct choice for 17,544 observations. Handles the categorical     
     weather variable natively via categorical_features='auto'. Fast to train and       
     tune.                                                                              
   • Exogenous variables (holiday, weather, temp): All three are included. weather is   
     automatically encoded as a categorical feature.                                    
   • Calendar features (hour, day_of_week, weekend, month): Added as raw ordinal        
     features — these are critical for a series with strong time-of-day and             
     day-of-week patterns.                                                              
   • 80% prediction interval (0.1–0.9) via bootstrapping: Appropriate for operational   
     planning where you want to communicate a credible demand range, not just a point   
     estimate.                                                                          
   • MAE as the primary metric: Interpretable in the same units as users, robust to     
     the demand spikes that are common in bike-rental data.                             
                                                                                        
  ------------------------------------------------------------------------------------  
                                                                                        
  Important Caveat                                                                      
                                                                                        
  ▌ The lag and window feature choices are hypotheses grounded in domain reasoning,     
  ▌ not validated improvements. The actual accuracy gain over a simpler default         
  ▌ configuration will only be confirmed once backtesting results are in hand. If       
  ▌ MAE from backtesting does not improve over a simpler lag set, consider running a    
  ▌ hyperparameter search that includes lags in the search space.                       
                                                                                        
╰────────────────────────────────────────────────────────────────────────────────────────╯

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_size is 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 next steps time 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 via exog.

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.",
    forecast_result = results_eval
)
answer.show_explanation()
╭───────────────────────────────────── Explanation ──────────────────────────────────────╮
                                                                                        
                         Forecast Results: Hourly Bike Rentals                          
                                                                                        
  Overall Model Quality                                                                 
                                                                                        
  The model performs well above the naive baseline, which is the most important         
  finding. The key metrics from backtesting are:                                        
                                                                                        
                                                                                        
   Metric  Value        Interpretation                                                  
   ──────────────────────────────────────────────────────────────────────────────────   
   MAE     39.99 users  On average, predictions are off by ~40 users per hour           
   MASE    0.621        62% of the naive forecast error — meaningfully better than      
                        simply repeating the last observed value                        
   MSE     3,680        Reflects occasional larger errors (e.g., during unusual         
                        demand spikes)                                                  
   MAPE    48.2%        Should be interpreted cautiously — high percentage errors       
                        often arise during low-demand hours (e.g., 2 AM with 5 actual   
                        users vs. 8 predicted = 60% error)                              
                                                                                        
                                                                                        
  The MASE of 0.621 is the headline result: any value below 1.0 means the model beats   
  the naive "repeat yesterday's value" benchmark. A score of 0.62 represents a solid,   
  practically useful model for an hourly bike-share demand series.                      
                                                                                        
  The gap between MAE (~40) and the square root of MSE (~61) suggests the model makes   
  occasional larger errors, likely during rush-hour peaks or weather-driven demand      
  swings that are harder to predict precisely.                                          
                                                                                        
  ------------------------------------------------------------------------------------  
                                                                                        
  Prediction Interval Quality                                                           
                                                                                        
  An 80% prediction interval was generated using bootstrapping ([0.1, 0.9] quantiles).  
  This means roughly 8 out of 10 actual observations should fall between the lower and  
  upper bounds.                                                                         
                                                                                        
  Looking at the interval widths:                                                       
                                                                                        
   • Average interval width: ~66 users (105.9 − 39.3 mean bounds)                       
   • At peak hours (e.g., 12:00–16:00 on Dec 30): bounds span roughly 75 users (105 to  
     180), reflecting genuine uncertainty during high-demand periods                    
   • At low-demand hours (e.g., 23:00 on Dec 31): bounds narrow to ~33 users (8 to      
     41), which makes intuitive sense — late-night demand is more predictable and       
     lower in magnitude                                                                 
                                                                                        
  The lower bound never goes below ~0.5, which is appropriate for a count-like          
  variable. The bootstrapping method correctly captures asymmetric uncertainty — the    
  upper bound tends to extend further above the point forecast than the lower bound     
  does below it, reflecting that demand can spike upward more easily than it can go     
  negative.                                                                             
                                                                                        
  ------------------------------------------------------------------------------------  
                                                                                        
  Forecast Pattern (Dec 30–31)                                                          
                                                                                        
  The predictions tell a coherent story that aligns with known bike-share behaviour:    
                                                                                        
  December 30 (Sunday afternoon)                                                        
                                                                                        
  Forecasts start at ~146 users at noon and hold relatively stable through the       
     afternoon (133–137 range from 13:00–16:00), consistent with leisure weekend        
     riding patterns rather than sharp commuter peaks.                                  
                                                                                        
  December 31 (New Year's Eve)                                                          
                                                                                        
  Demand drops sharply in the evening: from a moderate daytime level down to ~62     
     users at 19:00, then rapidly declining to ~43 → 32 → 21 → 17 users at 23:00.       
  This pattern is plausible: New Year's Eve riders likely shift to other transport   
     modes for celebrations, and cold late-December conditions (captured via temp and   
     weather exogenous variables) may additionally suppress demand.                     
                                                                                        
  Summary statistics across all 36 steps:                                               
                                                                                        
  Mean forecast: 74 users/hour                                                       
  Range: 5 to 153 users/hour                                                         
  This spread is consistent with a series transitioning from an active afternoon to  
     a quiet New Year's Eve night.                                                      
                                                                                        
  ------------------------------------------------------------------------------------  
                                                                                        
  Feature Design and Its Effect                                                         
                                                                                        
  The lag and window structure directly shaped these results:                           
                                                                                        
   • Short lags [1–6] allowed the model to track the current demand trend in real       
     time, which matters most during transitions (e.g., the evening decline on Dec      
     31).                                                                               
   • 24h and 48h lags [23–25, 47–49] anchored predictions to the same hour on prior     
     days, capturing the daily rhythm without which MAPE would be far higher.           
   • 168h lags [167–169] encoded the weekday/weekend distinction — critical for a       
     series where Sunday ridership differs structurally from Monday.                    
   • Rolling mean/std (24h) provided the model with a sense of the current day's        
     overall demand level and volatility, which is especially valuable given the        
     weather and holiday exogenous variables.                                           
   • Rolling mean/max (168h) gave the model a weekly baseline and a signal for whether  
     any unusual peak occurred recently.                                                
                                                                                        
  ------------------------------------------------------------------------------------  
                                                                                        
  Caveats                                                                               
                                                                                        
   • MAPE (48%) appears high but is largely an artifact of low-demand hours where       
     small absolute errors become large percentages. MAE and MASE are more reliable     
     guides here.                                                                       
  The forecasts cover only Dec 30–31, which is a holiday period. Model performance   
     on typical weekdays may differ.                                                    
  The 80% interval calibration should be verified: if actual coverage deviates       
     significantly from 80%, the intervals may be over- or under-confident. This can    
     be checked using calculate_coverage on the backtesting predictions.                
                                                                                        
╰────────────────────────────────────────────────────────────────────────────────────────╯

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 │
├────────────────┼────────────────────────┤
│ Missing target │ none                   │
└────────────────┴────────────────────────┘

                                      Recommendation                                      
┏━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Property               Value                                                          ┃
┡━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ Task type             │ single_series                                                  │
├───────────────────────┼────────────────────────────────────────────────────────────────┤
│ Forecaster            │ ForecasterRecursive                                            │
├───────────────────────┼────────────────────────────────────────────────────────────────┤
│ Forecaster candidates │ ForecasterRecursive, ForecasterDirect, ForecasterFoundation,   │
│                       │ ForecasterStats                                                │
├───────────────────────┼────────────────────────────────────────────────────────────────┤
│ Estimator             │ LGBMRegressor                                                  │
├───────────────────────┼────────────────────────────────────────────────────────────────┤
│ Estimator candidates  │ LGBMRegressor, XGBRegressor, Ridge                             │
└───────────────────────┴────────────────────────────────────────────────────────────────┘

╭───────────────────────────────────── Explanation ──────────────────────────────────────╮
                                                                                        
  A single-series ML forecaster (ForecasterRecursive) is recommended. Data: 17544       
  observations, 'h' frequency. Alternative forecasters: ['ForecasterDirect',            
  'ForecasterFoundation', 'ForecasterStats']. 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                                                              ┃
┡━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ 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     │   - handle_categorical_exog: Categorical exogenous variables       │
│                   │ detected: ['weather']. These are handled automatically by          │
│                   │ skforecast (categorical_features='auto'). (optional)               │
└───────────────────┴────────────────────────────────────────────────────────────────────┘

╭─────────────────────────────────── 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 key dynamics for hourly bike       
  rentals:                                                                              
                                                                                        
   1 "The last few hours give a good sense of the current trend" → Short lags [1, 2,    
     3, 4, 5, 6] capture the immediate momentum and direction of demand (e.g., a        
     commute ramp-up in progress).                                                      
   2 "Clear daily rhythm with rush-hour peaks" → Lags around 24 h [23, 24, 25] and 48   
     h [47, 48, 49] anchor the model to the same hour on the previous one and two       
     days, directly encoding the intra-day cycle. The ±1 neighbours (23/25, 47/49) add  
     robustness for slight timing shifts in peaks.                                      
   3 "Usually similar to what happened at the same time last week" → Lags around 168 h  
     [167, 168, 169] capture the same hour from exactly one week ago, accounting for    
     the weekday/weekend rhythm the user highlighted. Again, ±1 neighbours provide      
     robustness.                                                                        
   4 Rolling window — 24 h mean + std: The 24-hour mean smooths over the full daily     
     cycle and gives the model a sense of the current day's overall demand level. The   
     24-hour std captures intra-day volatility (e.g., a rainy day suppresses peaks),    
     which is useful for a demand series driven by weather and events.                  
   5 Rolling window — 168 h mean + max: The weekly mean gives the baseline level for    
     the current week (distinguishing a typical weekday week from a holiday week),      
     while the weekly max captures whether any unusually high peak occurred recently —  
     a signal relevant for capacity and planning in bike-share systems. Together these  
     two window sizes (one short/reactive, one long/trend) follow the multi-scale best  
     practice without over-engineering the feature set.                                 
                                                                                        
  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 │
└─────────────────────┴─────────┴─────────────┴─────────────┘
Generated code
import pandas as pd                                                                       
from lightgbm import LGBMRegressor                                                        
from skforecast.preprocessing import RollingFeatures, CalendarFeatures                    
from skforecast.recursive import ForecasterRecursive                                      
                                                                                          
# Load data                                                                               
data = pd.read_csv('data.csv')                                                            
                                                                                          
# Load future exogenous variables covering the forecast horizon                           
exog_future = pd.read_csv('exog_future.csv')                                              
                                                                                          
data['date_time'] = pd.to_datetime(data['date_time'])                                     
data = data.set_index('date_time')                                                        
data = data.asfreq('h')                                                                   
data = data.sort_index()                                                                  
                                                                                          
exog_future['date_time'] = pd.to_datetime(exog_future['date_time'])                       
exog_future = exog_future.set_index('date_time')                                          
exog_future = exog_future.asfreq('h')                                                     
exog_future = exog_future.sort_index()                                                    
                                                                                          
exog_features = ['holiday', 'weather', 'temp']                                            
                                                                                          
window_features = RollingFeatures(                                                        
    stats        = ['mean', 'std', 'mean', 'max'],                                        
    window_sizes = [24, 24, 168, 168],                                                    
)                                                                                         
                                                                                          
calendar_features = CalendarFeatures(                                                     
    features = ['hour', 'day_of_week', 'weekend', 'month'],                               
    encoding = None,                                                                      
)                                                                                         
                                                                                          
# Create forecaster                                                                       
forecaster = ForecasterRecursive(                                                         
    estimator            = LGBMRegressor(random_state=123, verbose=-1),                   
    lags                 = [1, 2, 3, 4, 5, 6, 23, 24, 25, 47, 48, 49, 167, 168, 169],     
    window_features      = window_features,                                               
    calendar_features    = calendar_features,                                             
    categorical_features = 'auto',                                                        
    dropna_from_series   = False,                                                         
)                                                                                         
                                                                                          
# Fit                                                                                     
forecaster.fit(                                                                           
    y                         = data['users'],                                            
    exog                      = data[exog_features],                                      
    store_in_sample_residuals = True,                                                     
)                                                                                         
                                                                                          
# Predict intervals                                                                       
steps = 36                                                                                
predictions = forecaster.predict_interval(                                                
    steps    = steps,                                                                     
    exog     = exog_future[exog_features],                                                
    method   = 'bootstrapping',                                                           
    interval = [0.1, 0.9],                                                                
)                                                                                         
print(predictions)                                                                        

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()
Generated code
import pandas as pd                                                                       
from sklearn.metrics import mean_absolute_error, mean_squared_error,                      
mean_absolute_percentage_error                                                            
from skforecast.metrics import mean_absolute_scaled_error                                 
from lightgbm import LGBMRegressor                                                        
from skforecast.preprocessing import RollingFeatures, CalendarFeatures                    
from skforecast.recursive import ForecasterRecursive                                      
                                                                                          
# Load data                                                                               
data = pd.read_csv('data.csv')                                                            
                                                                                          
data['date_time'] = pd.to_datetime(data['date_time'])                                     
data = data.set_index('date_time')                                                        
data = data.asfreq('h')                                                                   
data = data.sort_index()                                                                  
                                                                                          
# Train/test split                                                                        
end_train = '2012-12-30 11:00:00'  # last training date, adjust to change the split point 
data_train = data.loc[:end_train]                                                         
data_test  = data.loc[data.index > end_train]                                             
exog_features = ['holiday', 'weather', 'temp']                                            
                                                                                          
print(                                                                                    
    f"Train dates : {data_train.index.min()} --- {data_train.index.max()}                 
(n={len(data_train)})"                                                                    
)                                                                                         
print(                                                                                    
    f"Test dates  : {data_test.index.min()} --- {data_test.index.max()}                   
(n={len(data_test)})"                                                                     
)                                                                                         
                                                                                          
window_features = RollingFeatures(                                                        
    stats        = ['mean', 'std', 'mean', 'max'],                                        
    window_sizes = [24, 24, 168, 168],                                                    
)                                                                                         
                                                                                          
calendar_features = CalendarFeatures(                                                     
    features = ['hour', 'day_of_week', 'weekend', 'month'],                               
    encoding = None,                                                                      
)                                                                                         
                                                                                          
# Create forecaster                                                                       
forecaster = ForecasterRecursive(                                                         
    estimator            = LGBMRegressor(random_state=123, verbose=-1),                   
    lags                 = [1, 2, 3, 4, 5, 6, 23, 24, 25, 47, 48, 49, 167, 168, 169],     
    window_features      = window_features,                                               
    calendar_features    = calendar_features,                                             
    categorical_features = 'auto',                                                        
    dropna_from_series   = False,                                                         
)                                                                                         
                                                                                          
# Fit                                                                                     
forecaster.fit(                                                                           
    y                         = data_train['users'],                                      
    exog                      = data_train[exog_features],                                
    store_in_sample_residuals = True,                                                     
)                                                                                         
                                                                                          
# Predict intervals                                                                       
steps = 36                                                                                
predictions = forecaster.predict_interval(                                                
    steps    = steps,                                                                     
    exog     = data_test[exog_features],                                                  
    method   = 'bootstrapping',                                                           
    interval = [0.1, 0.9],                                                                
)                                                                                         
print(predictions)                                                                        
                                                                                          
# Evaluate on test set                                                                    
actual = data_test['users'].iloc[:steps]                                                  
mae = mean_absolute_error(actual, predictions['pred'])                                    
mse = mean_squared_error(actual, predictions['pred'])                                     
mase = mean_absolute_scaled_error(                                                        
    y_true  = actual,                                                                     
    y_pred  = predictions['pred'],                                                        
    y_train = data_train['users'],                                                        
)                                                                                         
mape = mean_absolute_percentage_error(actual, predictions['pred'])                        
                                                                                          
print(f"MAE  : {mae:.4f}")                                                                
print(f"MSE  : {mse:.4f}")                                                                
print(f"MASE : {mase:.4f}")                                                               
print(f"MAPE : {mape:.4f}")                                                               
                                                                                          
# NOTE: This script uses a train/test split for demonstration purposes.                   
# For production forecasting, retrain with all available data                             
# and provide future exogenous values covering the forecast horizon.                      

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:

  1. Explicit instantiation (recommended): manually construct a TimeSeriesFold and pass it directly to backtest(). Use this when you already know your exact operational constraints.

  2. Deterministic create_cv(): allow the assistant to derive a sensible TimeSeriesFold from the profile and plan using rule-based defaults. You can override individual parameters explicitly.

  3. LLM create_cv() (with a prompt): describe your deployment use case in natural language. The LLM translates your description into a fully-configured TimeSeriesFold, 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

📖 API Reference    📝 User Guide

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

📖 API Reference    📝 User Guide

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

📖 API Reference    📝 User Guide

# LLM reasoning behind the TimeSeriesFold configuration
# ==============================================================================
print(textwrap.fill(cv_llm_explanation, width=88))
The user wants to forecast bike demand 36 hours ahead with a single, fixed training run.
- **initial_train_size = "2012-08-31 23:59"**: The model is trained once on all data up
to the end of August 2012, exactly as specified by the user. A date string is used to
pin the cutoff precisely. - **refit = False**: The user explicitly stated the model
should NOT be retrained as the window rolls forward. A single fit on the initial
training set is used for all subsequent test folds. - All other parameters are left at
their defaults (expanding window, no gap, no stride override, etc.), as the user did not
specify any additional constraints. 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]
╭───────────────────────────────────── 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?"
    ),
    backtest_result = results_backtest
)
answer.show_explanation()
╭───────────────────────────────────── Explanation ──────────────────────────────────────╮
                                                                                        
               Backtesting Results: Hourly Bike Rental Demand Forecasting               
                                                                                        
  Strategy Overview                                                                     
                                                                                        
  The backtesting simulated how the model would perform in production by walking        
  forward through the data from September 1 to December 31, 2012, producing             
  36-hour-ahead forecasts at each step (82 folds in total, fold 0 through fold 81).     
                                                                                        
  Key configuration choices:                                                            
                                                                                        
  refit=False: The model was trained once on all data up to August 31, 2012, and     
     then evaluated without retraining. This tests how well a single trained model      
     generalises over a ~4-month deployment window — a realistic "deploy once"          
     scenario.                                                                          
  fixed_train_size=True: The training window was fixed (not expanding), consistent   
     with refit=False having no effect on window growth when the model is never         
     retrained.                                                                         
  gap=0: Forecasts begin immediately after the last observed value — no ingestion    
     delay is assumed.                                                                  
  fold_stride=36: Each 36-step forecast block is non-overlapping, so every test      
     observation is evaluated exactly once.                                             
                                                                                        
  ------------------------------------------------------------------------------------  
                                                                                        
  Metric Interpretation                                                                 
                                                                                        
                                                                                        
   Metric  Value       Interpretation                                                   
   ─────────────────────────────────────────────────────────────────────────            
   MAE     50.3 users  On average, predictions are off by ~50 users per hour            
   MSE     6,911       Sensitive to large errors; RMSE ≈ 83 users                       
   MASE    0.820       Better than a naïve seasonal forecast (< 1.0) ✅                 
   MAPE    55.5%       High — caution warranted (see note below)                        
                                                                                        
                                                                                        
  What stands out                                                                       
                                                                                        
  MASE of 0.82 is the most meaningful number here. It tells you the model makes 18%     
  fewer errors than simply repeating the value from the same hour the previous day.     
  For an hourly demand series with complex weather and event effects, this is a solid   
  result.                                                                               
                                                                                        
  MAPE of 55.5% is misleadingly high. This inflated figure almost certainly comes from  
  the overnight/early-morning hours (1am–5am) where true demand is very low (near       
  zero). Small absolute errors become enormous percentage errors when the denominator   
  is tiny — for example, predicting 5 users when 3 actually showed up gives a 67%       
  error despite a 2-user absolute miss. This is a known pathology of MAPE on            
  intermittent or low-count data. Do not use MAPE as the primary decision metric here.  
                                                                                        
  MAE of ~50 users should be judged against the demand range. The predictions span      
  roughly 3 to 867 users (mean ~234), so a 50-user average error represents about 21%   
  of the mean demand — reasonable for a 36-hour horizon with real-world variability in  
  weather and holidays.                                                                 
                                                                                        
  The negative lower bound (min = −0.94) in a handful of predictions is a minor         
  artefact of the bootstrapping interval method. In production, clip lower bounds to 0  
  for bike-rental demand.                                                               
                                                                                        
  ------------------------------------------------------------------------------------  
                                                                                        
  Prediction Interval Quality                                                           
                                                                                        
  The 80% prediction intervals [0.1, 0.9] show sensible behaviour:                      
                                                                                        
   • Mean interval width: 280 − 185 ≈ 95 users on average                               
   • Intervals widen appropriately during high-demand periods (upper bound reaches      
     909) and narrow at low-demand times (overnight intervals are tight)                
  The bootstrapping method uses in-sample residuals, so interval quality is tied to  
     how representative those residuals are of future errors                            
                                                                                        
  To formally validate calibration, you would compute calculate_coverage on the         
  backtest predictions. A well-calibrated 80% interval should contain the true value    
  approximately 80% of the time.                                                        
                                                                                        
  ------------------------------------------------------------------------------------  
                                                                                        
  Feature Design Rationale                                                              
                                                                                        
  The lag and window structure directly encodes the three dynamics described for this   
  dataset:                                                                              
                                                                                        
   • Short lags [1–6 h] → current momentum (e.g., commute ramp-up)                      
   • Daily lags [23, 24, 25] and [47, 48, 49] → same hour yesterday and two days ago,   
     with ±1 buffer for timing shifts in peaks                                          
   • Weekly lags [167, 168, 169] → same hour last week, capturing the weekday/weekend   
     rhythm                                                                             
   • 24 h rolling mean + std → today's overall demand level and intra-day volatility    
   • 168 h rolling mean + max → this week's baseline and whether any unusual peak       
     occurred recently                                                                  
                                                                                        
  The three exogenous variables (holiday, weather, temp) add contextual signal that     
  the lag structure alone cannot capture. The categorical weather column is handled     
  automatically.                                                                        
                                                                                        
  ------------------------------------------------------------------------------------  
                                                                                        
  Deployment Readiness Assessment                                                       
                                                                                        
                                                                                        
   Factor                              Assessment                                       
   ──────────────────────────────────────────────────────────────────────────────────   
   MASE < 1.0                          ✅ Meaningfully beats the naïve baseline         
   36-hour horizon                     ✅ Covered with appropriate lag structure        
   Prediction intervals available      ✅ 80% intervals for uncertainty communication   
   refit=False holds up over 4 months  ✅ No evidence of severe degradation             
   Overnight MAPE inflation            ⚠️ Not a real problem, but avoid MAPE in         
                                       reporting                                        
   Negative lower bounds               ⚠️ Minor — clip to 0 in production               
   Calibration not yet verified        ⚠️ Run calculate_coverage before relying on      
                                       intervals                                        
                                                                                        
                                                                                        
  Recommendation                                                                        
                                                                                        
  The model is a reasonable candidate for deployment, with three caveats before going   
  live:                                                                                 
                                                                                        
   1 Verify interval calibration: Compute the actual coverage rate on the backtest      
     predictions. If it falls significantly below 80%, consider switching to            
     method='conformal_intervals' which provides formal coverage guarantees.            
   2 Plan a retraining cadence: The model was tested with refit=False over 4 months.    
     In production, schedule periodic retraining (e.g., monthly) and consider setting   
     up a RangeDriftDetector to flag when incoming data drifts outside the training     
     distribution.                                                                      
   3 Clip negative predictions: Apply max(pred, 0) and max(lower_bound, 0) on the       
     output before serving.                                                             
                                                                                        
╰────────────────────────────────────────────────────────────────────────────────────────╯

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()
Generated code
import pandas as pd                                                                       
from lightgbm import LGBMRegressor                                                        
from skforecast.preprocessing import RollingFeatures, CalendarFeatures                    
from skforecast.recursive import ForecasterRecursive                                      
from skforecast.model_selection import TimeSeriesFold, backtesting_forecaster             
                                                                                          
# Load data                                                                               
data = pd.read_csv('data.csv')                                                            
                                                                                          
data['date_time'] = pd.to_datetime(data['date_time'])                                     
data = data.set_index('date_time')                                                        
data = data.asfreq('h')                                                                   
data = data.sort_index()                                                                  
                                                                                          
window_features = RollingFeatures(                                                        
    stats        = ['mean', 'std', 'mean', 'max'],                                        
    window_sizes = [24, 24, 168, 168],                                                    
)                                                                                         
                                                                                          
calendar_features = CalendarFeatures(                                                     
    features = ['hour', 'day_of_week', 'weekend', 'month'],                               
    encoding = None,                                                                      
)                                                                                         
                                                                                          
# Create forecaster                                                                       
forecaster = ForecasterRecursive(                                                         
    estimator            = LGBMRegressor(random_state=123, verbose=-1),                   
    lags                 = [1, 2, 3, 4, 5, 6, 23, 24, 25, 47, 48, 49, 167, 168, 169],     
    window_features      = window_features,                                               
    calendar_features    = calendar_features,                                             
    categorical_features = 'auto',                                                        
    dropna_from_series   = False,                                                         
)                                                                                         
                                                                                          
# Time series cross-validation configuration                                              
cv = TimeSeriesFold(                                                                      
    steps              = 36,                                                              
    initial_train_size = '2012-08-31 23:59:00',                                           
    refit              = False,                                                           
)                                                                                         
                                                                                          
# Run backtesting                                                                         
exog_features = ['holiday', 'weather', 'temp']                                            
                                                                                          
metrics, predictions = backtesting_forecaster(                                            
    forecaster        = forecaster,                                                       
    y                 = data['users'],                                                    
    exog              = data[exog_features],                                              
    cv                = cv,                                                               
    metric            = ['mean_absolute_error', 'mean_squared_error',                     
'mean_absolute_scaled_error', 'mean_absolute_percentage_error'],                          
    interval          = [0.1, 0.9],                                                       
    n_jobs            = 'auto',                                                           
    verbose           = False,                                                            
    show_progress     = True,                                                             
    suppress_warnings = True,                                                             
)                                                                                         
                                                                                          
print(metrics)                                                                            
print(predictions.head())                                                                 

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
╭───────────────────────────────────── 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 │
└────────────────────┴─────────────────────┘
                                     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 │
├────────────────┼────────────────────────┤
│ Missing target │ none                   │
└────────────────┴────────────────────────┘

                                      Recommendation                                      
┏━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ Property               Value                                                          ┃
┡━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ Task type             │ single_series                                                  │
├───────────────────────┼────────────────────────────────────────────────────────────────┤
│ Forecaster            │ ForecasterRecursive                                            │
├───────────────────────┼────────────────────────────────────────────────────────────────┤
│ Forecaster candidates │ ForecasterRecursive, ForecasterDirect, ForecasterFoundation,   │
│                       │ ForecasterStats                                                │
├───────────────────────┼────────────────────────────────────────────────────────────────┤
│ Estimator             │ LGBMRegressor                                                  │
├───────────────────────┼────────────────────────────────────────────────────────────────┤
│ Estimator candidates  │ LGBMRegressor, XGBRegressor, Ridge                             │
└───────────────────────┴────────────────────────────────────────────────────────────────┘

╭───────────────────────────────────── Explanation ──────────────────────────────────────╮
                                                                                        
  A single-series ML forecaster (ForecasterRecursive) is recommended. Data: 17544       
  observations, 'h' frequency. Alternative forecasters: ['ForecasterDirect',            
  'ForecasterFoundation', 'ForecasterStats']. 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                                                              ┃
┡━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
│ 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     │   - handle_categorical_exog: Categorical exogenous variables       │
│                   │ detected: ['weather']. These are handled automatically by          │
│                   │ skforecast (categorical_features='auto'). (optional)               │
└───────────────────┴────────────────────────────────────────────────────────────────────┘

╭─────────────────────────────────── 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 key dynamics for hourly bike       
  rentals:                                                                              
                                                                                        
   1 "The last few hours give a good sense of the current trend" → Short lags [1, 2,    
     3, 4, 5, 6] capture the immediate momentum and direction of demand (e.g., a        
     commute ramp-up in progress).                                                      
   2 "Clear daily rhythm with rush-hour peaks" → Lags around 24 h [23, 24, 25] and 48   
     h [47, 48, 49] anchor the model to the same hour on the previous one and two       
     days, directly encoding the intra-day cycle. The ±1 neighbours (23/25, 47/49) add  
     robustness for slight timing shifts in peaks.                                      
   3 "Usually similar to what happened at the same time last week" → Lags around 168 h  
     [167, 168, 169] capture the same hour from exactly one week ago, accounting for    
     the weekday/weekend rhythm the user highlighted. Again, ±1 neighbours provide      
     robustness.                                                                        
   4 Rolling window — 24 h mean + std: The 24-hour mean smooths over the full daily     
     cycle and gives the model a sense of the current day's overall demand level. The   
     24-hour std captures intra-day volatility (e.g., a rainy day suppresses peaks),    
     which is useful for a demand series driven by weather and events.                  
   5 Rolling window — 168 h mean + max: The weekly mean gives the baseline level for    
     the current week (distinguishing a typical weekday week from a holiday week),      
     while the weekly max captures whether any unusually high peak occurred recently —  
     a signal relevant for capacity and planning in bike-share systems. Together these  
     two window sizes (one short/reactive, one long/trend) follow the multi-scale best  
     practice without over-engineering the feature set.                                 
                                                                                        
  Note: the LLM-suggested lags and window_features are hypotheses, not validated        
  improvements. Confirm any expected accuracy gain before relying on them.              
                                                                                        
╰────────────────────────────────────────────────────────────────────────────────────────╯
Generated code
import pandas as pd                                                                       
from lightgbm import LGBMRegressor                                                        
from skforecast.preprocessing import RollingFeatures, CalendarFeatures                    
from skforecast.recursive import ForecasterRecursive                                      
from skforecast.model_selection import TimeSeriesFold, backtesting_forecaster             
                                                                                          
# Load data                                                                               
data = pd.read_csv('data.csv')                                                            
                                                                                          
data['date_time'] = pd.to_datetime(data['date_time'])                                     
data = data.set_index('date_time')                                                        
data = data.asfreq('h')                                                                   
data = data.sort_index()                                                                  
                                                                                          
window_features = RollingFeatures(                                                        
    stats        = ['mean', 'std', 'mean', 'max'],                                        
    window_sizes = [24, 24, 168, 168],                                                    
)                                                                                         
                                                                                          
calendar_features = CalendarFeatures(                                                     
    features = ['hour', 'day_of_week', 'weekend', 'month'],                               
    encoding = None,                                                                      
)                                                                                         
                                                                                          
# Create forecaster                                                                       
forecaster = ForecasterRecursive(                                                         
    estimator            = LGBMRegressor(random_state=123, verbose=-1),                   
    lags                 = [1, 2, 3, 4, 5, 6, 23, 24, 25, 47, 48, 49, 167, 168, 169],     
    window_features      = window_features,                                               
    calendar_features    = calendar_features,                                             
    categorical_features = 'auto',                                                        
    dropna_from_series   = False,                                                         
)                                                                                         
                                                                                          
# Time series cross-validation configuration                                              
cv = TimeSeriesFold(                                                                      
    steps              = 36,                                                              
    initial_train_size = '2012-08-31 23:59:00',                                           
    refit              = False,                                                           
)                                                                                         
                                                                                          
# Run backtesting                                                                         
exog_features = ['holiday', 'weather', 'temp']                                            
                                                                                          
metrics, predictions = backtesting_forecaster(                                            
    forecaster        = forecaster,                                                       
    y                 = data['users'],                                                    
    exog              = data[exog_features],                                              
    cv                = cv,                                                               
    metric            = ['mean_absolute_error', 'mean_squared_error',                     
'mean_absolute_scaled_error', 'mean_absolute_percentage_error'],                          
    interval          = [0.1, 0.9],                                                       
    n_jobs            = 'auto',                                                           
    verbose           = False,                                                            
    show_progress     = True,                                                             
    suppress_warnings = True,                                                             
)                                                                                         
                                                                                          
print(metrics)                                                                            
print(predictions.head())                                                                 

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()
╭───────────────────────────────────── Explanation ──────────────────────────────────────╮
                                                                                        
           Direct vs Recursive Forecasting for Hourly Demand with Seasonality           
                                                                                        
  When Direct Beats Recursive                                                           
                                                                                        
  For hourly data with strong daily (24-step) and weekly (168-step) cycles, error       
  propagation is the core issue with recursive forecasting. Here's when that matters    
  enough to prefer direct:                                                              
                                                                                        
  ------------------------------------------------------------------------------------  
                                                                                        
  Key Decision Factors                                                                  
                                                                                        
  1. Forecast Horizon Length                                                            
                                                                                        
                                                                                        
   Horizon               Recommendation                                                 
   ────────────────────────────────────────────────────────────────────────────────     
   Short (1–6 hours)     ForecasterRecursive — error propagation is minimal             
   Medium (6–24 hours)   Either; benchmark both                                         
   Long (24–168+ hours)  ForecasterDirect — recursive errors compound significantly     
                                                                                        
                                                                                        
  The 24-step and 168-step seasonality lags are critical inputs. In recursive mode,     
  steps 2 through 24 rely on predicted (not observed) values as lag features —          
  degrading the quality of those seasonal signals progressively.                        
                                                                                        
  ------------------------------------------------------------------------------------  
                                                                                        
  2. Horizon-Dependent Patterns                                                         
                                                                                        
  Direct trains one model per step, so each step can learn its own relationship. This   
  matters when:                                                                         
                                                                                        
  Peak demand at hour 18 has different predictors than off-peak at hour 3            
  Weekend vs weekday transitions affect specific hours differently                   
  The marginal importance of lags shifts across the horizon (e.g., lag-168 matters   
     more for step-168 than step-1)                                                     
                                                                                        
  Recursive uses a single model that must generalise across all steps simultaneously —  
  it cannot specialise per step.                                                        
                                                                                        
  ------------------------------------------------------------------------------------  
                                                                                        
  3. Series Properties                                                                  
                                                                                        
                                                                                        
   Property                             Favours                                         
   ──────────────────────────────────────────────────────────────────────────────────   
   High autocorrelation, smooth series  Recursive (predictions feed back cleanly)       
   Noisy, irregular demand spikes       Direct (no error amplification)                 
   Strong, stable seasonality           Recursive handles it well with seasonal lags    
   Weak or shifting seasonality         Direct (each step learns its own seasonal       
                                        structure)                                      
                                                                                        
                                                                                        
  ------------------------------------------------------------------------------------  
                                                                                        
  Practical Considerations for Hourly Data                                              
                                                                                        
  Lag Configuration                                                                     
                                                                                        
  With ForecasterDirect(steps=168), you typically use:                                  
                                                                                        
  Short lags: [1, 2, 3, 24]                                                          
  Seasonal lags: [168] (same hour last week)                                         
                                                                                        
  Because direct never feeds predictions back, lag-1 at step-24 is still an observed    
  value, not a 23-step-ahead prediction. This is the primary advantage.                 
                                                                                        
  Computational Cost                                                                    
                                                                                        
  ForecasterDirect trains N = steps separate models. For steps=168, that is 168 models  
  — significantly more expensive than the single model in ForecasterRecursive. You can  
  mitigate this with n_jobs=-1 to parallelise fitting.                                  
                                                                                        
  Fixed Horizon Requirement                                                             
                                                                                        
  ForecasterDirect requires steps to be set at initialisation, not at predict time. If  
  your operational need varies (sometimes 24h, sometimes 48h), you would need separate  
  forecasters or to retrain.                                                            
                                                                                        
  ------------------------------------------------------------------------------------  
                                                                                        
  Recommended Approach                                                                  
                                                                                        
  Always benchmark both using backtesting_forecaster with a realistic                 
  TimeSeriesFold. Do not choose based on theory alone — the winning strategy is       
  ▌ data-dependent.                                                                     
                                                                                        
  A typical benchmark setup would:                                                      
                                                                                        
   1 Use the same estimator (e.g., LGBMRegressor) for both                              
   2 Use the same lag set and exogenous features                                        
   3 Evaluate with mean_absolute_error and mean_absolute_percentage_error over a        
     held-out period covering at least several weeks                                    
   4 Check performance per step (direct often wins at steps > 24, recursive may win at  
     steps 1–6)                                                                         
                                                                                        
  ------------------------------------------------------------------------------------  
                                                                                        
  Summary                                                                               
                                                                                        
  Prefer ForecasterDirect when:                                                         
                                                                                        
  Your horizon is ≥ 24 hours (one full daily cycle)                                  
  Demand patterns differ meaningfully by hour-of-day or day-of-week across the       
     horizon                                                                            
  The series has significant noise or irregular spikes                               
  You can afford the computational cost of 24–168 models                             
                                                                                        
  Stick with ForecasterRecursive when:                                                  
                                                                                        
  Your horizon is short (< 12 hours)                                                 
  Computational speed or memory is a constraint                                      
  You need a flexible horizon at predict time                                        
  The series is smooth and highly autocorrelated                                     
                                                                                        
╰────────────────────────────────────────────────────────────────────────────────────────╯

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.
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.

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.1.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-20 19:22

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! 😊

Become a GitHub Sponsor Become a GitHub Sponsor

Creative Commons Licence

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.