Forecasting with Deep Learning¶
Deep learning models have become increasingly popular for time series forecasting, especially when traditional statistical approaches struggle to capture non-linear relationships or complex temporal patterns. By leveraging neural network architectures, deep learning methods can automatically learn features and dependencies directly from raw data, offering significant advantages for large datasets, multivariate time series, and problems where classic models fall short.
Introduction to Recurrent Neural Networks (RNN), LSTM, and GRU¶
Recurrent Neural Networks (RNN) are a family of models specifically designed to work with sequential data, such as time series. Unlike traditional feedforward neural networks, which treat each input independently, RNNs introduce an internal memory that allows them to capture dependencies between elements of a sequence. This enables the model to leverage information from previous steps to improve future predictions.
The fundamental building block of an RNN is the recurrent cell, which receives two inputs at each time step: the current data point and the previous hidden state (the "memory" of the network). At every step, the hidden state is updated, storing relevant information about the sequence up to that point. This architecture allows RNNs to “remember” trends and patterns over time.
However, simple RNNs face difficulties when learning long-term dependencies due to issues like the vanishing or exploding gradient problem. To address these limitations, more advanced architectures such as Long Short-Term Memory (LSTM) and Gated Recurrent Unit (GRU) were developed. These variants are better at capturing complex and long-range patterns in time series data.
Basic RNN diagram. Source: James, G., Witten, D., Hastie, T., & Tibshirani, R. (2021). An Introduction to Statistical Learning (2nd ed.). Springer.
Types of Recurrent Layers in skforecast
With skforecast, you can use three main types of recurrent cells:
Simple RNN: Suitable for problems with short-term dependencies or when a simple model is sufficient. Less effective for capturing long-range patterns.
LSTM (Long Short-Term Memory): Adds gating mechanisms that allow the network to learn and retain information over longer periods. LSTMs are a popular choice for many complex forecasting problems.
GRU (Gated Recurrent Unit): Offers a simpler structure than LSTM, using fewer parameters while achieving comparable performance in many scenarios. Useful when computational efficiency is important.
✏️ Note
Guidelines for choosing a recurrent layer:
- Use LSTM if your time series contains long-term patterns or complex dependencies.
- Try GRU as a lighter alternative to LSTM.
- Use Simple RNN only for straightforward tasks or as a baseline.
LSTM Architecture and Gates¶
Long Short-Term Memory (LSTM) networks are a widely used type of recurrent neural network designed to effectively capture long-range dependencies in sequential data. Unlike simple RNNs, LSTMs use a more sophisticated architecture based on a system of memory cells and gates that control the flow of information over time.
The core component of an LSTM is the memory cell, which maintains information across time steps. Three gates regulate how information is added, retained, or discarded at each step:
Forget Gate: Decides which information from the previous cell state should be removed. It uses the current input and previous hidden state, applying a sigmoid activation to produce a value between 0 and 1 (where 0 means “completely forget” and 1 means “completely keep”).
Input Gate: Controls how much new information is added to the cell state, again using the current input and previous hidden state with a sigmoid activation.
Output Gate: Determines how much of the cell state is exposed as output and passed to the next hidden state.
This gating mechanism enables LSTMs to selectively remember or forget information, making them highly effective for modeling sequences with long-term patterns.
Diagram of the inputs and outputs of an LSTM. Source: databasecamp.de https://databasecamp.de/wp-content/uploads/lstm-architecture-1024x709.png.
Gated Recurrent Unit (GRU) cells are a simplified alternative to LSTMs, using only two gates (reset and update) but often achieving similar performance. GRUs require fewer parameters and can be computationally more efficient, which may be an advantage for some tasks or larger datasets.
💡 Tip
To learn more about forecasting with deep learning models visit our examples:
Libraries and data¶
⚠ Warning
skforecast supports multiple Keras backends: TensorFlow, JAX, and PyTorch (torch).
You can select the backend using the KERAS_BACKEND environment variable, or by editing your local configuration file at ~/.keras/keras.json.
import os
os.environ["KERAS_BACKEND"] = "tensorflow" # Options: "tensorflow", "jax", or "torch"
import keras
The backend must be set before importing Keras in your Python session. Once Keras is imported, the backend cannot be changed without restarting your Python process.
Alternatively, you can set the backend in your configuration file at ~/.keras/keras.json (valid values are "tensorflow", "jax" and "torch"):
{
"backend": "tensorflow"
}
# Libraries
# ==============================================================================
import os
os.environ["KERAS_BACKEND"] = "torch" # 'tensorflow', 'jax' or 'torch'
import keras
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.preprocessing import MinMaxScaler
import skforecast
from skforecast.plot import set_dark_theme
from skforecast.datasets import fetch_dataset
from skforecast.preprocessing import CalendarFeatures
from skforecast.deep_learning import create_and_compile_model
from skforecast.deep_learning import ForecasterRnn
from skforecast.model_selection import TimeSeriesFold
from skforecast.model_selection import backtesting_forecaster_multiseries
from skforecast.metrics import calculate_coverage
from skforecast.plot import plot_prediction_intervals
from keras.optimizers import Adam
from keras.losses import MeanSquaredError
from keras.callbacks import EarlyStopping, ReduceLROnPlateau
import warnings
warnings.filterwarnings('ignore', category=DeprecationWarning)
# cuDNN warning raised at every prediction when running on GPU (harmless)
warnings.filterwarnings(
'ignore', message='RNN module weights are not part of single contiguous chunk'
)
# Raised when ForecasterRnn stores a copy of a model not trained yet (harmless)
warnings.filterwarnings('ignore', message='Skipping variable loading for optimizer')
backend = keras.backend.backend()
print(f"skforecast version: {skforecast.__version__}")
print(f"keras version: {keras.__version__}")
print(f"Using backend: {backend}")
if backend == "tensorflow":
import tensorflow as tf
print(f"tensorflow version: {tf.__version__}")
elif backend == "torch":
import torch
print(f"torch version: {torch.__version__}")
elif backend == "jax":
import jax
print(f"jax version: {jax.__version__}")
else:
print("Backend not recognized. Use 'tensorflow', 'jax' or 'torch'.")
skforecast version: 0.26.0 keras version: 3.14.0 Using backend: torch torch version: 2.14.0
# Data download
# ==============================================================================
data = fetch_dataset(name="air_quality_valencia_no_missing")
data.head()
╭──────────────────────── air_quality_valencia_no_missing ─────────────────────────╮ │ Description: │ │ Hourly measures of several air chemical pollutant at Valencia city (Avd. │ │ Francia) from 2019-01-01 to 2023-12-31. Including the following variables: pm2.5 │ │ (µg/m³), CO (mg/m³), NO (µg/m³), NO2 (µg/m³), PM10 (µg/m³), NOx (µg/m³), O3 │ │ (µg/m³), Veloc. (m/s), Direc. (degrees), SO2 (µg/m³). Missing values have been │ │ imputed using linear interpolation. │ │ │ │ Source: │ │ Red de Vigilancia y Control de la Contaminación Atmosférica, 46250047-València - │ │ Av. França, https://mediambient.gva.es/es/web/calidad-ambiental/datos- │ │ historicos. │ │ │ │ URL: │ │ https://raw.githubusercontent.com/skforecast/skforecast- │ │ datasets/main/data/air_quality_valencia_no_missing.csv │ │ │ │ Shape: 43824 rows x 10 columns │ ╰──────────────────────────────────────────────────────────────────────────────────╯
| so2 | co | no | no2 | pm10 | nox | o3 | veloc. | direc. | pm2.5 | |
|---|---|---|---|---|---|---|---|---|---|---|
| datetime | ||||||||||
| 2019-01-01 00:00:00 | 8.0 | 0.2 | 3.0 | 36.0 | 22.0 | 40.0 | 16.0 | 0.5 | 262.0 | 19.0 |
| 2019-01-01 01:00:00 | 8.0 | 0.1 | 2.0 | 40.0 | 32.0 | 44.0 | 6.0 | 0.6 | 248.0 | 26.0 |
| 2019-01-01 02:00:00 | 8.0 | 0.1 | 11.0 | 42.0 | 36.0 | 58.0 | 3.0 | 0.3 | 224.0 | 31.0 |
| 2019-01-01 03:00:00 | 10.0 | 0.1 | 15.0 | 41.0 | 35.0 | 63.0 | 3.0 | 0.2 | 220.0 | 30.0 |
| 2019-01-01 04:00:00 | 11.0 | 0.1 | 16.0 | 39.0 | 36.0 | 63.0 | 3.0 | 0.4 | 221.0 | 30.0 |
# Checking the frequency of the time series
# ==============================================================================
print(f"Index : {data.index.dtype}")
print(f"Frequency : {data.index.freqstr}")
Index : datetime64[ns] Frequency : h
The data is split into three consecutive partitions: training, validation and test. The validation partition is used to monitor the training of the network (early stopping), and the test partition is kept aside for the final evaluation. Time series must be split in chronological order, never at random: a random split places future observations in the training set and produces overly optimistic metrics.
# Split train-validation-test
# ==============================================================================
data = data.loc["2019-01-01 00:00:00":"2021-12-31 23:59:59", :].copy()
end_train = "2021-03-31 23:59:00"
end_validation = "2021-09-30 23:59:00"
data_train = data.loc[:end_train, :].copy()
data_val = data.loc[end_train:end_validation, :].copy()
data_test = data.loc[end_validation:, :].copy()
print(
f"Dates train : {data_train.index.min()} --- "
f"{data_train.index.max()} (n={len(data_train)})"
)
print(
f"Dates validation : {data_val.index.min()} --- "
f"{data_val.index.max()} (n={len(data_val)})"
)
print(
f"Dates test : {data_test.index.min()} --- "
f"{data_test.index.max()} (n={len(data_test)})"
)
Dates train : 2019-01-01 00:00:00 --- 2021-03-31 23:00:00 (n=19704) Dates validation : 2021-04-01 00:00:00 --- 2021-09-30 23:00:00 (n=4392) Dates test : 2021-10-01 00:00:00 --- 2021-12-31 23:00:00 (n=2208)
# Plot series
# ==============================================================================
set_dark_theme()
colors = plt.rcParams['axes.prop_cycle'].by_key()['color'] * 2
fig, axes = plt.subplots(len(data.columns), 1, figsize=(8, 8), sharex=True)
for i, col in enumerate(data.columns):
axes[i].plot(data[col], label=col, color=colors[i])
axes[i].legend(loc='upper right', fontsize=8)
axes[i].tick_params(axis='both', labelsize=8)
for split_date in [end_train, end_validation]: # End of train and validation
axes[i].axvline(
pd.to_datetime(split_date), color='white', linestyle='--', linewidth=1
)
fig.suptitle("Air Quality Valencia", fontsize=16)
plt.tight_layout()
Building RNN-based models easily with create_and_compile_model¶
skforecast provides the utility function create_and_compile_model to simplify the creation of recurrent neural network architectures (RNN, LSTM, or GRU) for time series forecasting. This function is designed to make it easy for both beginners and advanced users to build and compile Keras models with just a few lines of code.
Basic usage
For most forecasting scenarios, you can simply specify the time series data, the number of lagged observations, the number of steps to predict, and the type of recurrent layer you wish to use (LSTM, GRU, or SimpleRNN). By default, the function sets reasonable parameters for each layer, but all architectural details can be adjusted to fit specific requirements.
# Basic usage of `create_and_compile_model`
# ==============================================================================
model = create_and_compile_model(
series = data, # All 10 series are used as predictors
levels = ["o3"], # Target series to predict
lags = 32, # Number of lags to use as predictors
steps = 24, # Number of steps to predict
recurrent_layer = "LSTM", # Recurrent layer ('LSTM', 'GRU', or 'RNN')
recurrent_units = 100, # Number of units in the recurrent layer
dense_units = 64 # Number of units in the dense layer
)
model.summary()
keras version: 3.14.0 Using backend: torch torch version: 2.14.0
Model: "functional"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓ ┃ Layer (type) ┃ Output Shape ┃ Param # ┃ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩ │ series_input (InputLayer) │ (None, 32, 10) │ 0 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ lstm_1 (LSTM) │ (None, 100) │ 44,400 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ dense_1 (Dense) │ (None, 64) │ 6,464 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ output_dense_td_layer (Dense) │ (None, 24) │ 1,560 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ reshape (Reshape) │ (None, 24, 1) │ 0 │ └─────────────────────────────────┴────────────────────────┴───────────────┘
Total params: 52,424 (204.78 KB)
Trainable params: 52,424 (204.78 KB)
Non-trainable params: 0 (0.00 B)
Advanced customization
All arguments controlling layer types, units, activations, and other options can be customized. You may also pass your own Keras model if you need full flexibility beyond what the helper function provides.
The arguments recurrent_layers_kwargs and dense_layers_kwargs allow you to specify the parameters for the recurrent and dense layers, respectively.
When using a dictionary, the same kwargs are applied to every layer of that type. For example, if you specify
recurrent_layers_kwargs = {'activation': 'tanh'}, all recurrent layers will use thetanhactivation function.You can also pass a list of dictionaries to specify different parameters for each layer. For instance,
recurrent_layers_kwargs = [{'activation': 'tanh'}, {'activation': 'relu'}]will specify that the first recurrent layer uses thetanhactivation function and the second usesrelu.
# Advanced usage of `create_and_compile_model`
# ==============================================================================
model = create_and_compile_model(
series = data,
levels = ["o3"],
lags = 32,
steps = 24,
exog = None, # No exogenous variables
recurrent_layer = "LSTM",
recurrent_units = [128, 64],
recurrent_layers_kwargs = [{'activation': 'tanh'}, {'activation': 'relu'}],
dense_units = [128, 64],
dense_layers_kwargs = {'activation': 'relu'},
output_dense_layer_kwargs = {'activation': 'linear'},
compile_kwargs = {
'optimizer': Adam(learning_rate=0.001),
'loss': MeanSquaredError()
},
model_name = None
)
model.summary()
keras version: 3.14.0 Using backend: torch torch version: 2.14.0
Model: "functional_1"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓ ┃ Layer (type) ┃ Output Shape ┃ Param # ┃ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩ │ series_input (InputLayer) │ (None, 32, 10) │ 0 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ lstm_1 (LSTM) │ (None, 32, 128) │ 71,168 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ lstm_2 (LSTM) │ (None, 64) │ 49,408 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ dense_1 (Dense) │ (None, 128) │ 8,320 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ dense_2 (Dense) │ (None, 64) │ 8,256 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ output_dense_td_layer (Dense) │ (None, 24) │ 1,560 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ reshape (Reshape) │ (None, 24, 1) │ 0 │ └─────────────────────────────────┴────────────────────────┴───────────────┘
Total params: 138,712 (541.84 KB)
Trainable params: 138,712 (541.84 KB)
Non-trainable params: 0 (0.00 B)
To gain a deeper understanding of this function, refer to a later section of this guide: Understanding create_and_compile_model in depth.
If you need to define a completely custom architecture, you can create your own Keras model and use it directly in skforecast workflows.
# Plotting the model architecture (requires `pydot` and `graphviz`)
# ==============================================================================
# from keras.utils import plot_model
# plot_model(
# model,
# show_shapes=True,
# show_layer_names=True,
# to_file='model-architecture.png'
# )
Types of problems in time series forecasting¶
Deep learning models for time series can handle a wide variety of forecasting scenarios, depending on how you structure your input data and define your prediction targets. These models are flexible enough to:
Predict a single value or multiple future values (single-step vs multi-step forecasting).
Work with a single time series or multiple series (both as predictors and as targets).
Incorporate exogenous variables (external features or known future information) alongside your main time series data.
By adjusting your data inputs (number of series, steps ahead to predict, and exogenous variables) deep learning architectures can be adapted to solve almost any classical or advanced forecasting problem.
1. Single-Series, Single-Step Forecasting (1:1)¶
In this scenario, the goal is to predict the next value in a single time series, using only its own past observations as predictors. This is known as a univariate autoregressive forecasting problem.
For example: Given a sequence of values , predict .
This setup is common for classic time series tasks and serves as a good starting point for experimenting with deep learning models.
# Create model
# ==============================================================================
lags = 24
keras.utils.set_random_seed(123) # Reproducible initialization and training
model = create_and_compile_model(
series = data[["o3"]], # Only the 'o3' series is used as predictor
levels = ["o3"], # Target series to predict
lags = lags, # Number of lags to use as predictors
steps = 1, # Single-step forecasting
recurrent_layer = "GRU",
recurrent_units = 64,
recurrent_layers_kwargs = {"activation": "tanh"},
dense_units = 32,
compile_kwargs = {'optimizer': Adam(), 'loss': MeanSquaredError()},
model_name = "Single-Series-Single-Step"
)
model.summary()
keras version: 3.14.0 Using backend: torch torch version: 2.14.0
Model: "Single-Series-Single-Step"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓ ┃ Layer (type) ┃ Output Shape ┃ Param # ┃ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩ │ series_input (InputLayer) │ (None, 24, 1) │ 0 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ gru_1 (GRU) │ (None, 64) │ 12,864 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ dense_1 (Dense) │ (None, 32) │ 2,080 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ output_dense_td_layer (Dense) │ (None, 1) │ 33 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ reshape (Reshape) │ (None, 1, 1) │ 0 │ └─────────────────────────────────┴────────────────────────┴───────────────┘
Total params: 14,977 (58.50 KB)
Trainable params: 14,977 (58.50 KB)
Non-trainable params: 0 (0.00 B)
The forecaster is created with the model, and validation data is provided so that the performance of the model is evaluated at the end of each epoch. A MinMaxScaler scales the series to the [0, 1] range, since neural networks train better when all inputs have a similar, small magnitude. ForecasterRnn applies the scaling during both training and prediction, and brings the predictions back to the original scale.
The fit_kwargs dictionary contains the arguments passed to the Keras fit method: number of epochs, batch size and callbacks, such as EarlyStopping, which stops the training when the validation loss no longer improves. The keys series_val (and exog_val when the model uses exogenous variables) are not Keras arguments: the forecaster extracts them to build the validation set and passes the remaining keys to Keras.
Training a neural network is a stochastic process (random weight initialization, shuffling of the batches). To make the results reproducible, the random seed is fixed with keras.utils.set_random_seed before creating each model. Small differences may still appear across hardware or library versions.
# Forecaster Definition
# ==============================================================================
forecaster = ForecasterRnn(
estimator=model,
levels=["o3"],
lags=lags, # Must be same lags as used in create_and_compile_model
transformer_series=MinMaxScaler(),
fit_kwargs={
"epochs": 50, # Number of epochs to train the model.
"batch_size": 512, # Batch size to train the model.
"verbose": 2, # One log line per epoch.
"callbacks": [
EarlyStopping(monitor="val_loss", patience=3, restore_best_weights=True)
], # Callback to stop training when it is no longer learning.
"series_val": data_val[["o3"]], # Validation data for model training.
},
)
# Fit forecaster
# ==============================================================================
forecaster.fit(data_train[['o3']])
forecaster
Using 'torch' backend with device: mps
Epoch 1/50
39/39 - 2s - 51ms/step - loss: 0.0532 - val_loss: 0.0160
Epoch 2/50
39/39 - 1s - 33ms/step - loss: 0.0123 - val_loss: 0.0099
Epoch 3/50
39/39 - 1s - 24ms/step - loss: 0.0091 - val_loss: 0.0078
Epoch 4/50
39/39 - 1s - 24ms/step - loss: 0.0074 - val_loss: 0.0063
Epoch 5/50
39/39 - 1s - 24ms/step - loss: 0.0063 - val_loss: 0.0058
Epoch 6/50
39/39 - 1s - 24ms/step - loss: 0.0058 - val_loss: 0.0059
Epoch 7/50
39/39 - 1s - 24ms/step - loss: 0.0055 - val_loss: 0.0057
Epoch 8/50
39/39 - 1s - 25ms/step - loss: 0.0054 - val_loss: 0.0057
Epoch 9/50
39/39 - 1s - 26ms/step - loss: 0.0054 - val_loss: 0.0054
Epoch 10/50
39/39 - 1s - 24ms/step - loss: 0.0053 - val_loss: 0.0055
Epoch 11/50
39/39 - 1s - 24ms/step - loss: 0.0052 - val_loss: 0.0056
Epoch 12/50
39/39 - 1s - 24ms/step - loss: 0.0052 - val_loss: 0.0053
Epoch 13/50
39/39 - 1s - 25ms/step - loss: 0.0051 - val_loss: 0.0053
Epoch 14/50
39/39 - 1s - 25ms/step - loss: 0.0051 - val_loss: 0.0053
Epoch 15/50
39/39 - 1s - 25ms/step - loss: 0.0051 - val_loss: 0.0053
Epoch 16/50
39/39 - 1s - 24ms/step - loss: 0.0051 - val_loss: 0.0052
Epoch 17/50
39/39 - 1s - 25ms/step - loss: 0.0050 - val_loss: 0.0051
Epoch 18/50
39/39 - 1s - 24ms/step - loss: 0.0050 - val_loss: 0.0052
Epoch 19/50
39/39 - 1s - 24ms/step - loss: 0.0050 - val_loss: 0.0053
Epoch 20/50
39/39 - 1s - 23ms/step - loss: 0.0052 - val_loss: 0.0051
ForecasterRnn
General Information
- Estimator: Functional
- Layers names: ['series_input', 'gru_1', 'dense_1', 'output_dense_td_layer', 'reshape']
- Lags: [ 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24]
- Window size: 24
- Maximum steps to predict: [1]
- Exogenous included: False
- Creation date: 2026-10-10 15:01:34
- Last fit date: 2026-10-10 15:02:02
- Keras backend: torch
- Skforecast version: 0.26.0
- Python version: 3.14.3
- Forecaster id: None
Exogenous Variables
-
None
Data Transformations
- Transformer for series: MinMaxScaler()
- Transformer for exog: MinMaxScaler()
Training Information
- Series names: o3
- Target series (levels): ['o3']
- Training range: [Timestamp('2019-01-01 00:00:00'), Timestamp('2021-03-31 23:00:00')]
- Training index type: DatetimeIndex
- Training index frequency: h
Estimator Parameters
-
{'name': 'Single-Series-Single-Step', 'trainable': True, 'layers': [{'module': 'keras.layers', 'class_name': 'InputLayer', 'config': {'batch_shape': (None, 24, 1), 'dtype': 'float32', 'sparse': False, 'ragged': False, 'name': 'series_input', 'optional': False}, 'registered_name': None, 'name': 'series_input', 'inbound_nodes': []}, {'module': 'keras.layers', 'class_name': 'GRU', 'config': {'name': 'gru_1', 'trainable': True, 'dtype': {'module': 'keras', 'class_name': 'DTypePolicy', 'config': {'name': 'float32'}, 'registered_name': None}, 'return_sequences': False, 'return_state': False, 'go_backwards': False, 'stateful': False, 'unroll': False, 'zero_output_for_mask': False, 'units': 64, 'activation': 'tanh', 'recurrent_activation': 'sigmoid', 'use_bias': True, 'kernel_initializer': {'module': 'keras.initializers', 'class_name': 'GlorotUniform', 'config': {'seed': None}, 'registered_name': None}, 'recurrent_initializer': {'module': 'keras.initializers', 'class_name': 'Orthogonal', 'config': {'seed': None, 'gain': 1.0}, 'registered_name': None}, 'bias_initializer': {'module': 'keras.initializers', 'class_name': 'Zeros', 'config': {}, 'registered_name': None}, 'kernel_regularizer': None, 'recurrent_regularizer': None, 'bias_regularizer': None, 'activity_regularizer': None, 'kernel_constraint': None, 'recurrent_constraint': None, 'bias_constraint': None, 'dropout': 0.0, 'recurrent_dropout': 0.0, 'reset_after': True, 'seed': None}, 'registered_name': None, 'build_config': {'input_shape': [None, 24, 1]}, 'name': 'gru_1', 'inbound_nodes': [{'args': ({'class_name': '__keras_tensor__', 'config': {'shape': (None, 24, 1), 'dtype': 'float32', 'keras_history': ['series_input', 0, 0]}},), 'kwargs': {'training': False, 'mask': None}}]}, {'module': 'keras.layers', 'class_name': 'Dense', 'config': {'name': 'dense_1', 'trainable': True, 'dtype': {'module': 'keras', 'class_name': 'DTypePolicy', 'config': {'name': 'float32'}, 'registered_name': None}, 'units': 32, 'activation': 'relu', 'use_bias': True, 'kernel_initializer': {'module': 'keras.initializers', 'class_name': 'GlorotUniform', 'config': {'seed': None}, 'registered_name': None}, 'bias_initializer': {'module': 'keras.initializers', 'class_name': 'Zeros', 'config': {}, 'registered_name': None}, 'kernel_regularizer': None, 'bias_regularizer': None, 'kernel_constraint': None, 'bias_constraint': None, 'quantization_config': None}, 'registered_name': None, 'build_config': {'input_shape': [None, 64]}, 'name': 'dense_1', 'inbound_nodes': [{'args': ({'class_name': '__keras_tensor__', 'config': {'shape': (None, 64), 'dtype': 'float32', 'keras_history': ['gru_1', 0, 0]}},), 'kwargs': {}}]}, {'module': 'keras.layers', 'class_name': 'Dense', 'config': {'name': 'output_dense_td_layer', 'trainable': True, 'dtype': {'module': 'keras', 'class_name': 'DTypePolicy', 'config': {'name': 'float32'}, 'registered_name': None}, 'units': 1, 'activation': 'linear', 'use_bias': True, 'kernel_initializer': {'module': 'keras.initializers', 'class_name': 'GlorotUniform', 'config': {'seed': None}, 'registered_name': None}, 'bias_initializer': {'module': 'keras.initializers', 'class_name': 'Zeros', 'config': {}, 'registered_name': None}, 'kernel_regularizer': None, 'bias_regularizer': None, 'kernel_constraint': None, 'bias_constraint': None, 'quantization_config': None}, 'registered_name': None, 'build_config': {'input_shape': [None, 32]}, 'name': 'output_dense_td_layer', 'inbound_nodes': [{'args': ({'class_name': '__keras_tensor__', 'config': {'shape': (None, 32), 'dtype': 'float32', 'keras_history': ['dense_1', 0, 0]}},), 'kwargs': {}}]}, {'module': 'keras.layers', 'class_name': 'Reshape', 'config': {'name': 'reshape', 'trainable': True, 'dtype': {'module': 'keras', 'class_name': 'DTypePolicy', 'config': {'name': 'float32'}, 'registered_name': None}, 'target_shape': (1, 1)}, 'registered_name': None, 'name': 'reshape', 'inbound_nodes': [{'args': ({'class_name': '__keras_tensor__', 'config': {'shape': (None, 1), 'dtype': 'float32', 'keras_history': ['output_dense_td_layer', 0, 0]}},), 'kwargs': {}}]}], 'input_layers': ['series_input', 0, 0], 'output_layers': ['reshape', 0, 0]}
Compile Parameters
-
{'optimizer': {'module': 'keras.src.backend.torch.optimizers.torch_adam', 'class_name': 'Adam', 'config': {'name': 'adam', 'learning_rate': 0.0010000000474974513, 'weight_decay': None, 'clipnorm': None, 'global_clipnorm': None, 'clipvalue': None, 'use_ema': False, 'ema_momentum': 0.99, 'ema_overwrite_frequency': None, 'loss_scale_factor': None, 'gradient_accumulation_steps': None, 'beta_1': 0.9, 'beta_2': 0.999, 'epsilon': 1e-07, 'amsgrad': False}, 'registered_name': 'Adam'}, 'loss': {'module': 'keras.losses', 'class_name': 'MeanSquaredError', 'config': {'name': 'mean_squared_error', 'reduction': 'sum_over_batch_size'}, 'registered_name': None}, 'loss_weights': None, 'metrics': None, 'weighted_metrics': None, 'run_eagerly': False, 'steps_per_execution': 1, 'jit_compile': False}
Fit Kwargs
-
{'epochs': 50, 'batch_size': 512, 'verbose': 2, 'callbacks': [
✏️ Note
The skforecast library is fully compatible with GPUs. See the Running on GPU section below in this document for more information.
In deep learning models, it’s important to control overfitting, when a model performs well on training data but poorly on new, unseen data. One common approach is to use a Keras callback, such as EarlyStopping, which halts training if the validation loss stops improving.
Another useful practice is to plot the training and validation loss after each epoch. This helps you visualize how the model is learning and spot signs of overfitting.
Graphical explanation of overfitting. Source: https://datahacker.rs/018-pytorch-popular-techniques-to-prevent-the-overfitting-in-a-neural-networks/.
# Training and overfitting tracking
# ==============================================================================
fig, ax = plt.subplots(figsize=(8, 3))
_ = forecaster.plot_history(ax=ax)
In the plot above, the training loss (blue) decreases rapidly during the first two epochs, indicating the model is quickly capturing the main patterns in the data. The validation loss (red) also decreases during the first epochs and then stabilizes, closely following the training loss. This suggests:
The model is not overfitting, as the validation loss stays close to the training loss after the first epochs.
Both losses decrease and stabilize together, indicating good generalization and effective learning.
No divergence is observed, which would appear as the validation loss increasing while training loss keeps decreasing.
Note that, in the first epoch, the validation loss is lower than the training loss. This is not an error: the training loss is averaged over all the batches of the epoch, while the weights are still improving, whereas the validation loss is computed at the end of the epoch with the updated weights.
Once the forecaster is trained, predictions can be obtained. If steps=None (default), the forecaster predicts all the steps the model was built for, forecaster.max_step.
# Predictions
# ==============================================================================
print(f"Max step: {forecaster.max_step}")
predictions = forecaster.predict(steps=None) # Same as steps=forecaster.max_step
predictions
Max step: 1
| level | pred | |
|---|---|---|
| 2021-04-01 | o3 | 44.585243 |
In time series forecasting, the process of backtesting consists of evaluating the performance of a predictive model by applying it retrospectively to historical data. Therefore, it is a special type of cross-validation applied to the previous period(s). To learn more about backtesting, visit the backtesting user guide.
A ForecasterRnn is backtested with backtesting_forecaster_multiseries, and the validation strategy is defined with a TimeSeriesFold. In this example, each fold predicts forecaster.max_step steps, and refit=False means that the network is trained only once, at the beginning, and then used to predict all the folds of the test set.
✏️ Note
In the previous step, the validation partition was used to monitor the training and to identify the number of epochs with the lowest validation loss (early stopping). For backtesting, the validation partition is added to the training set, so early stopping is no longer possible and the network is trained for the number of epochs identified before.
Calling fit on a forecaster that is already trained does not reinitialize the neural network: the training resumes from the weights already learned. To train from scratch, a new ForecasterRnn is created. Since the forecaster stores a copy of the model it receives, the object model still has its initial (untrained) weights and can be reused.
# Backtesting with test data
# ==============================================================================
cv = TimeSeriesFold(
steps = forecaster.max_step,
initial_train_size = len(data.loc[:end_validation]), # Train + validation
refit = False
)
# Number of epochs identified with early stopping (lowest validation loss)
best_epochs = int(np.argmin(forecaster.history_["val_loss"])) + 1
print(f"Number of epochs: {best_epochs}")
# New forecaster to train the network from scratch with train + validation data
keras.utils.set_random_seed(123)
forecaster_bt = ForecasterRnn(
estimator = model,
levels = ["o3"],
lags = lags,
transformer_series = MinMaxScaler(),
fit_kwargs = {"epochs": best_epochs, "batch_size": 512, "verbose": 0},
)
metrics, predictions = backtesting_forecaster_multiseries(
forecaster = forecaster_bt,
series = data[['o3']],
cv = cv,
levels = forecaster_bt.levels,
metric = "mean_absolute_error",
suppress_warnings = True,
verbose = False
)
Number of epochs: 17
Using 'torch' backend with device: mps
# Backtesting metrics
# ==============================================================================
metrics
| levels | mean_absolute_error | |
|---|---|---|
| 0 | o3 | 7.23852 |
# Backtesting predictions
# ==============================================================================
predictions.head(4)
| level | fold | pred | |
|---|---|---|---|
| 2021-10-01 00:00:00 | o3 | 0 | 58.345688 |
| 2021-10-01 01:00:00 | o3 | 1 | 63.150108 |
| 2021-10-01 02:00:00 | o3 | 2 | 67.116898 |
| 2021-10-01 03:00:00 | o3 | 3 | 67.518097 |
# Plotting predictions vs real values in the test set
# ==============================================================================
fig, ax = plt.subplots(figsize=(8, 3))
data_test["o3"].plot(ax=ax, label="test")
predictions.loc[predictions["level"] == "o3", "pred"].plot(ax=ax, label="predictions")
ax.set_title("O3")
ax.legend();
2. Single-Series, Multi-Step Forecasting (1:1, Multiple Steps)¶
In this scenario, the objective is to predict multiple future values of a single time series using only its own past observations as predictors. This is known as multi-step univariate forecasting.
For example: Given a sequence of values , predict , where is the prediction horizon (number of steps ahead).
This setup is common when you want to forecast several periods into the future (e.g., the next 24 hours of ozone concentration).
Model Architecture
You can use a similar network architecture as in the single-step case, but predicting multiple steps ahead usually benefits from increasing the capacity of the model (e.g., more units in LSTM/GRU layers or additional dense layers). This allows the model to better capture the complexity of forecasting several points at once.
# Create model
# ==============================================================================
lags = 24
keras.utils.set_random_seed(123) # Reproducible initialization and training
model = create_and_compile_model(
series = data[["o3"]], # Only the 'o3' series is used as predictor
levels = ["o3"], # Target series to predict
lags = lags, # Number of lags to use as predictors
steps = 24, # Multi-step forecasting
recurrent_layer = "GRU",
recurrent_units = 128,
recurrent_layers_kwargs = {"activation": "tanh"},
dense_units = 64,
compile_kwargs = {'optimizer': 'adam', 'loss': 'mse'},
model_name = "Single-Series-Multi-Step"
)
model.summary()
keras version: 3.14.0 Using backend: torch torch version: 2.14.0
Model: "Single-Series-Multi-Step"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓ ┃ Layer (type) ┃ Output Shape ┃ Param # ┃ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩ │ series_input (InputLayer) │ (None, 24, 1) │ 0 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ gru_1 (GRU) │ (None, 128) │ 50,304 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ dense_1 (Dense) │ (None, 64) │ 8,256 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ output_dense_td_layer (Dense) │ (None, 24) │ 1,560 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ reshape (Reshape) │ (None, 24, 1) │ 0 │ └─────────────────────────────────┴────────────────────────┴───────────────┘
Total params: 60,120 (234.84 KB)
Trainable params: 60,120 (234.84 KB)
Non-trainable params: 0 (0.00 B)
✏️ Note
The fit_kwargs parameter lets you customize any aspect of the model training process, passing arguments directly to the underlying Keras Model.fit() method. For example, you can specify the number of training epochs, batch size, and any callbacks you want to use.
In the code example, the model is trained for a maximum of 50 epochs with a batch size of 512. The EarlyStopping callback monitors the validation loss and automatically stops training if it does not improve for 3 consecutive epochs (patience=3). This helps prevent overfitting and saves computation time.
You can also add other callbacks, such as ModelCheckpoint to save the model at each epoch, or TensorBoard for real-time visualization of training and validation metrics.
# Forecaster Creation
# ==============================================================================
forecaster = ForecasterRnn(
estimator=model,
levels=["o3"],
lags=lags,
transformer_series=MinMaxScaler(),
fit_kwargs={
"epochs": 50,
"batch_size": 512,
"verbose": 2,
"callbacks": [
EarlyStopping(monitor="val_loss", patience=3, restore_best_weights=True)
], # Callback to stop training when it is no longer learning.
"series_val": data_val[["o3"]], # Validation data for model training.
},
)
# Fit forecaster
# ==============================================================================
forecaster.fit(data_train[['o3']])
Using 'torch' backend with device: mps Epoch 1/50
39/39 - 2s - 49ms/step - loss: 0.0824 - val_loss: 0.0312
Epoch 2/50
39/39 - 1s - 37ms/step - loss: 0.0290 - val_loss: 0.0275
Epoch 3/50
39/39 - 1s - 34ms/step - loss: 0.0266 - val_loss: 0.0247
Epoch 4/50
39/39 - 1s - 30ms/step - loss: 0.0252 - val_loss: 0.0236
Epoch 5/50
39/39 - 1s - 31ms/step - loss: 0.0242 - val_loss: 0.0223
Epoch 6/50
39/39 - 1s - 30ms/step - loss: 0.0228 - val_loss: 0.0195
Epoch 7/50
39/39 - 1s - 30ms/step - loss: 0.0210 - val_loss: 0.0187
Epoch 8/50
39/39 - 1s - 29ms/step - loss: 0.0204 - val_loss: 0.0181
Epoch 9/50
39/39 - 1s - 29ms/step - loss: 0.0201 - val_loss: 0.0179
Epoch 10/50
39/39 - 1s - 28ms/step - loss: 0.0197 - val_loss: 0.0178
Epoch 11/50
39/39 - 1s - 28ms/step - loss: 0.0193 - val_loss: 0.0173
Epoch 12/50
39/39 - 1s - 28ms/step - loss: 0.0190 - val_loss: 0.0168
Epoch 13/50
39/39 - 1s - 29ms/step - loss: 0.0187 - val_loss: 0.0166
Epoch 14/50
39/39 - 1s - 30ms/step - loss: 0.0186 - val_loss: 0.0165
Epoch 15/50
39/39 - 1s - 28ms/step - loss: 0.0183 - val_loss: 0.0166
Epoch 16/50
39/39 - 1s - 26ms/step - loss: 0.0181 - val_loss: 0.0166
Epoch 17/50
39/39 - 1s - 31ms/step - loss: 0.0180 - val_loss: 0.0163
Epoch 18/50
39/39 - 1s - 26ms/step - loss: 0.0179 - val_loss: 0.0166
Epoch 19/50
39/39 - 1s - 29ms/step - loss: 0.0178 - val_loss: 0.0163
Epoch 20/50
39/39 - 1s - 26ms/step - loss: 0.0177 - val_loss: 0.0160
Epoch 21/50
39/39 - 1s - 29ms/step - loss: 0.0177 - val_loss: 0.0162
Epoch 22/50
39/39 - 1s - 31ms/step - loss: 0.0177 - val_loss: 0.0165
Epoch 23/50
39/39 - 1s - 31ms/step - loss: 0.0175 - val_loss: 0.0162
# Training and overfitting tracking
# ==============================================================================
fig, ax = plt.subplots(figsize=(8, 3))
_ = forecaster.plot_history(ax=ax)
In this case, the prediction quality is expected to be lower than in the previous example, as shown by the higher loss values across epochs. This is easily explained: the model now has to predict 24 values at each step instead of just 1. The loss is the average error over the 24 predicted steps, and distant steps are harder to predict than the next hour.
# Predictions
# ==============================================================================
predictions = forecaster.predict()
predictions.head(4)
| level | pred | |
|---|---|---|
| 2021-04-01 00:00:00 | o3 | 45.661156 |
| 2021-04-01 01:00:00 | o3 | 41.645275 |
| 2021-04-01 02:00:00 | o3 | 39.358021 |
| 2021-04-01 03:00:00 | o3 | 34.298386 |
# Specific step predictions
# ==============================================================================
predictions = forecaster.predict(steps=[1, 3])
predictions
| level | pred | |
|---|---|---|
| 2021-04-01 00:00:00 | o3 | 45.661156 |
| 2021-04-01 02:00:00 | o3 | 39.358021 |
As in the previous example, a new forecaster is trained from scratch with the training and validation data, using the number of epochs identified with early stopping.
# Backtesting with test data
# ==============================================================================
cv = TimeSeriesFold(
steps = forecaster.max_step,
initial_train_size = len(data.loc[:end_validation]), # Train + validation
refit = False
)
# Number of epochs identified with early stopping (lowest validation loss)
best_epochs = int(np.argmin(forecaster.history_["val_loss"])) + 1
print(f"Number of epochs: {best_epochs}")
# New forecaster to train the network from scratch with train + validation data
keras.utils.set_random_seed(123)
forecaster_bt = ForecasterRnn(
estimator = model,
levels = ["o3"],
lags = lags,
transformer_series = MinMaxScaler(),
fit_kwargs = {"epochs": best_epochs, "batch_size": 512, "verbose": 0},
)
metrics, predictions = backtesting_forecaster_multiseries(
forecaster = forecaster_bt,
series = data[['o3']],
cv = cv,
levels = forecaster_bt.levels,
metric = "mean_absolute_error",
suppress_warnings = True,
verbose = False
)
Number of epochs: 20
Using 'torch' backend with device: mps
# Backtesting metrics
# ==============================================================================
metric_single_series = metrics.loc[
metrics["levels"] == "o3", "mean_absolute_error"
].iat[0]
metrics
| levels | mean_absolute_error | |
|---|---|---|
| 0 | o3 | 11.384324 |
# Backtesting predictions
# ==============================================================================
predictions
| level | fold | pred | |
|---|---|---|---|
| 2021-10-01 00:00:00 | o3 | 0 | 54.269314 |
| 2021-10-01 01:00:00 | o3 | 0 | 52.838135 |
| 2021-10-01 02:00:00 | o3 | 0 | 51.053108 |
| 2021-10-01 03:00:00 | o3 | 0 | 48.612125 |
| 2021-10-01 04:00:00 | o3 | 0 | 45.402122 |
| ... | ... | ... | ... |
| 2021-12-31 19:00:00 | o3 | 91 | 21.024916 |
| 2021-12-31 20:00:00 | o3 | 91 | 19.763149 |
| 2021-12-31 21:00:00 | o3 | 91 | 20.050594 |
| 2021-12-31 22:00:00 | o3 | 91 | 23.961246 |
| 2021-12-31 23:00:00 | o3 | 91 | 25.437262 |
2208 rows × 3 columns
# Plotting predictions vs real values in the test set
# ==============================================================================
fig, ax = plt.subplots(figsize=(8, 3))
data_test["o3"].plot(ax=ax, label="test")
predictions.loc[predictions["level"] == "o3", "pred"].plot(ax=ax, label="predictions")
ax.set_title("O3")
ax.legend();
3. Multi-Series, Single-Output Forecasting (N:1, Multiple Steps)¶
In this scenario, the goal is to predict future values of a single target time series by leveraging the past values of multiple related series as predictors. This is known as multivariate forecasting, where the model uses the historical data from several variables to improve the prediction of one specific series.
For example, suppose you want to forecast ozone concentration (o3) for the next 24 hours. In addition to past o3 values, you may include other series (such as temperature, wind speed, or other pollutant concentrations) as predictors. The model will then use the combined information from all available series to make a more accurate forecast.
Model setup
To handle this type of problem, the neural network architecture becomes a bit more complex. An additional recurrent layer is used to process the information from multiple input series, and another dense (fully connected) layer further processes the output from the recurrent layer. With skforecast, building such a model is straightforward: simply pass a list of integers to the recurrent_units and dense_units arguments to add multiple recurrent and dense layers as needed.
# Create model
# ==============================================================================
lags = 24
keras.utils.set_random_seed(123) # Reproducible initialization and training
model = create_and_compile_model(
series = data, # DataFrame with all series (predictors)
levels = ["o3"], # Target series to predict
lags = lags, # Number of lags to use as predictors
steps = 24, # Multi-step forecasting
recurrent_layer = "GRU",
recurrent_units = [128, 64],
recurrent_layers_kwargs = {"activation": "tanh"},
dense_units = [64, 32],
compile_kwargs = {'optimizer': 'adam', 'loss': 'mse'},
model_name = "MultiVariate-Multi-Step"
)
model.summary()
keras version: 3.14.0 Using backend: torch torch version: 2.14.0
Model: "MultiVariate-Multi-Step"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓ ┃ Layer (type) ┃ Output Shape ┃ Param # ┃ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩ │ series_input (InputLayer) │ (None, 24, 10) │ 0 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ gru_1 (GRU) │ (None, 24, 128) │ 53,760 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ gru_2 (GRU) │ (None, 64) │ 37,248 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ dense_1 (Dense) │ (None, 64) │ 4,160 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ dense_2 (Dense) │ (None, 32) │ 2,080 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ output_dense_td_layer (Dense) │ (None, 24) │ 792 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ reshape (Reshape) │ (None, 24, 1) │ 0 │ └─────────────────────────────────┴────────────────────────┴───────────────┘
Total params: 98,040 (382.97 KB)
Trainable params: 98,040 (382.97 KB)
Non-trainable params: 0 (0.00 B)
# Forecaster Creation
# ==============================================================================
forecaster = ForecasterRnn(
estimator=model,
levels=["o3"],
lags=lags,
transformer_series=MinMaxScaler(),
fit_kwargs={
"epochs": 50,
"batch_size": 512,
"verbose": 2,
"callbacks": [
EarlyStopping(monitor="val_loss", patience=3, restore_best_weights=True)
], # Callback to stop training when it is no longer learning.
"series_val": data_val, # Validation data for model training.
},
)
# Fit forecaster
# ==============================================================================
forecaster.fit(data_train)
Using 'torch' backend with device: mps Epoch 1/50
39/39 - 3s - 68ms/step - loss: 0.0892 - val_loss: 0.0462
Epoch 2/50
39/39 - 2s - 53ms/step - loss: 0.0304 - val_loss: 0.0265
Epoch 3/50
39/39 - 2s - 51ms/step - loss: 0.0260 - val_loss: 0.0245
Epoch 4/50
39/39 - 2s - 53ms/step - loss: 0.0244 - val_loss: 0.0216
Epoch 5/50
39/39 - 2s - 51ms/step - loss: 0.0216 - val_loss: 0.0185
Epoch 6/50
39/39 - 2s - 54ms/step - loss: 0.0192 - val_loss: 0.0168
Epoch 7/50
39/39 - 2s - 57ms/step - loss: 0.0181 - val_loss: 0.0166
Epoch 8/50
39/39 - 2s - 51ms/step - loss: 0.0175 - val_loss: 0.0158
Epoch 9/50
39/39 - 2s - 55ms/step - loss: 0.0171 - val_loss: 0.0156
Epoch 10/50
39/39 - 2s - 52ms/step - loss: 0.0168 - val_loss: 0.0158
Epoch 11/50
39/39 - 2s - 53ms/step - loss: 0.0164 - val_loss: 0.0154
Epoch 12/50
39/39 - 2s - 52ms/step - loss: 0.0162 - val_loss: 0.0152
Epoch 13/50
39/39 - 2s - 51ms/step - loss: 0.0160 - val_loss: 0.0153
Epoch 14/50
39/39 - 2s - 52ms/step - loss: 0.0159 - val_loss: 0.0151
Epoch 15/50
39/39 - 2s - 54ms/step - loss: 0.0157 - val_loss: 0.0152
Epoch 16/50
39/39 - 2s - 51ms/step - loss: 0.0156 - val_loss: 0.0153
Epoch 17/50
39/39 - 2s - 51ms/step - loss: 0.0156 - val_loss: 0.0151
Epoch 18/50
39/39 - 2s - 51ms/step - loss: 0.0154 - val_loss: 0.0149
Epoch 19/50
39/39 - 2s - 49ms/step - loss: 0.0153 - val_loss: 0.0149
Epoch 20/50
39/39 - 2s - 51ms/step - loss: 0.0152 - val_loss: 0.0151
Epoch 21/50
39/39 - 2s - 50ms/step - loss: 0.0151 - val_loss: 0.0149
Epoch 22/50
39/39 - 2s - 51ms/step - loss: 0.0151 - val_loss: 0.0149
Epoch 23/50
39/39 - 2s - 53ms/step - loss: 0.0149 - val_loss: 0.0150
Epoch 24/50
39/39 - 2s - 50ms/step - loss: 0.0149 - val_loss: 0.0148
Epoch 25/50
39/39 - 2s - 50ms/step - loss: 0.0148 - val_loss: 0.0155
Epoch 26/50
39/39 - 2s - 54ms/step - loss: 0.0146 - val_loss: 0.0150
Epoch 27/50
39/39 - 2s - 52ms/step - loss: 0.0145 - val_loss: 0.0150
# Training and overfitting tracking
# ==============================================================================
fig, ax = plt.subplots(figsize=(8, 3))
_ = forecaster.plot_history(ax=ax)
# Predictions
# ==============================================================================
predictions = forecaster.predict()
predictions.head(4)
| level | pred | |
|---|---|---|
| 2021-04-01 00:00:00 | o3 | 47.674732 |
| 2021-04-01 01:00:00 | o3 | 47.423969 |
| 2021-04-01 02:00:00 | o3 | 40.216942 |
| 2021-04-01 03:00:00 | o3 | 31.490055 |
As in the previous example, a new forecaster is trained from scratch with the training and validation data, using the number of epochs identified with early stopping.
# Backtesting with test data
# ==============================================================================
cv = TimeSeriesFold(
steps = forecaster.max_step,
initial_train_size = len(data.loc[:end_validation]), # Train + validation
refit = False
)
# Number of epochs identified with early stopping (lowest validation loss)
best_epochs = int(np.argmin(forecaster.history_["val_loss"])) + 1
print(f"Number of epochs: {best_epochs}")
# New forecaster to train the network from scratch with train + validation data
keras.utils.set_random_seed(123)
forecaster_bt = ForecasterRnn(
estimator = model,
levels = ["o3"],
lags = lags,
transformer_series = MinMaxScaler(),
fit_kwargs = {"epochs": best_epochs, "batch_size": 512, "verbose": 0},
)
metrics, predictions = backtesting_forecaster_multiseries(
forecaster = forecaster_bt,
series = data,
cv = cv,
levels = forecaster_bt.levels,
metric = "mean_absolute_error",
suppress_warnings = True,
verbose = False
)
Number of epochs: 24
Using 'torch' backend with device: mps
# Backtesting metrics
# ==============================================================================
metric_multivariate = metrics.loc[
metrics["levels"] == "o3", "mean_absolute_error"
].iat[0]
metrics
| levels | mean_absolute_error | |
|---|---|---|
| 0 | o3 | 11.355638 |
# Backtesting predictions
# ==============================================================================
predictions
| level | fold | pred | |
|---|---|---|---|
| 2021-10-01 00:00:00 | o3 | 0 | 52.785770 |
| 2021-10-01 01:00:00 | o3 | 0 | 51.302601 |
| 2021-10-01 02:00:00 | o3 | 0 | 46.665573 |
| 2021-10-01 03:00:00 | o3 | 0 | 36.451439 |
| 2021-10-01 04:00:00 | o3 | 0 | 34.589058 |
| ... | ... | ... | ... |
| 2021-12-31 19:00:00 | o3 | 91 | 18.893341 |
| 2021-12-31 20:00:00 | o3 | 91 | 16.776199 |
| 2021-12-31 21:00:00 | o3 | 91 | 14.773914 |
| 2021-12-31 22:00:00 | o3 | 91 | 19.856077 |
| 2021-12-31 23:00:00 | o3 | 91 | 17.896549 |
2208 rows × 3 columns
# Plotting predictions vs real values in the test set
# ==============================================================================
fig, ax = plt.subplots(figsize=(8, 3))
data_test["o3"].plot(ax=ax, label="test")
predictions.loc[predictions["level"] == "o3", "pred"].plot(ax=ax, label="predictions")
ax.set_title("O3")
ax.legend()
plt.show()
When several series are used as predictors, the model is expected to produce more accurate forecasts for the target series. However, this is not always the case. Here, the MAE is almost the same as the one obtained when only the o3 series was used as input, a difference too small to be considered a real improvement. If the additional series are not strongly related to the target, the model cannot learn meaningful relationships from them, and the extra information may not improve the forecast or may even introduce noise.
4. Multi-Series, Multi-Output Forecasting (N:M, Multiple Steps)¶
In this scenario, the goal is to predict multiple future values for several time series at once, using the historical data from all available series as input. This is known as multivariate-multioutput forecasting.
With this approach, a single model learns to predict several target series simultaneously, capturing relationships and dependencies not only within each series, but also across different series.
Real-world applications include:
Forecasting the sales of multiple products in an online store, leveraging past sales, pricing history, promotions, and other product-related variables.
Studying the flue gas emissions of a gas turbine, where you want to predict the concentration of multiple pollutants (e.g., NOX, CO) based on past emissions data and other related variables.
Modeling environmental variables (e.g., pollution, temperature, humidity) together, where the evolution of one variable may influence or be influenced by others.
# Create model
# ==============================================================================
levels = ['o3', 'pm2.5', 'pm10'] # Multiple target series to predict
lags = 24
keras.utils.set_random_seed(123) # Reproducible initialization and training
model = create_and_compile_model(
series = data, # DataFrame with all series (predictors)
levels = levels,
lags = lags,
steps = 24,
recurrent_layer = "LSTM",
recurrent_units = [128, 64],
recurrent_layers_kwargs = {"activation": "tanh"},
dense_units = [64, 32],
compile_kwargs = {'optimizer': Adam(), 'loss': MeanSquaredError()},
model_name = "MultiVariate-MultiOutput-Multi-Step"
)
model.summary()
keras version: 3.14.0 Using backend: torch torch version: 2.14.0
Model: "MultiVariate-MultiOutput-Multi-Step"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓ ┃ Layer (type) ┃ Output Shape ┃ Param # ┃ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩ │ series_input (InputLayer) │ (None, 24, 10) │ 0 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ lstm_1 (LSTM) │ (None, 24, 128) │ 71,168 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ lstm_2 (LSTM) │ (None, 64) │ 49,408 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ dense_1 (Dense) │ (None, 64) │ 4,160 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ dense_2 (Dense) │ (None, 32) │ 2,080 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ output_dense_td_layer (Dense) │ (None, 72) │ 2,376 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ reshape (Reshape) │ (None, 24, 3) │ 0 │ └─────────────────────────────────┴────────────────────────┴───────────────┘
Total params: 129,192 (504.66 KB)
Trainable params: 129,192 (504.66 KB)
Non-trainable params: 0 (0.00 B)
# Forecaster Creation
# ==============================================================================
forecaster = ForecasterRnn(
estimator=model,
levels=levels,
lags=lags,
transformer_series=MinMaxScaler(),
fit_kwargs={
"epochs": 50,
"batch_size": 512,
"verbose": 2,
"callbacks": [
EarlyStopping(monitor="val_loss", patience=3, restore_best_weights=True)
], # Callback to stop training when it is no longer learning.
"series_val": data_val, # Validation data for model training.
},
)
# Fit forecaster
# ==============================================================================
forecaster.fit(data_train)
Using 'torch' backend with device: mps Epoch 1/50
39/39 - 3s - 86ms/step - loss: 0.0343 - val_loss: 0.0163
Epoch 2/50
39/39 - 3s - 73ms/step - loss: 0.0135 - val_loss: 0.0108
Epoch 3/50
39/39 - 3s - 80ms/step - loss: 0.0116 - val_loss: 0.0095
Epoch 4/50
39/39 - 3s - 70ms/step - loss: 0.0109 - val_loss: 0.0090
Epoch 5/50
39/39 - 2s - 62ms/step - loss: 0.0102 - val_loss: 0.0083
Epoch 6/50
39/39 - 2s - 62ms/step - loss: 0.0091 - val_loss: 0.0071
Epoch 7/50
39/39 - 2s - 61ms/step - loss: 0.0083 - val_loss: 0.0065
Epoch 8/50
39/39 - 2s - 61ms/step - loss: 0.0080 - val_loss: 0.0063
Epoch 9/50
39/39 - 2s - 61ms/step - loss: 0.0078 - val_loss: 0.0061
Epoch 10/50
39/39 - 2s - 64ms/step - loss: 0.0077 - val_loss: 0.0065
Epoch 11/50
39/39 - 3s - 73ms/step - loss: 0.0075 - val_loss: 0.0061
Epoch 12/50
39/39 - 2s - 63ms/step - loss: 0.0074 - val_loss: 0.0060
Epoch 13/50
39/39 - 2s - 61ms/step - loss: 0.0073 - val_loss: 0.0058
Epoch 14/50
39/39 - 2s - 61ms/step - loss: 0.0072 - val_loss: 0.0058
Epoch 15/50
39/39 - 3s - 65ms/step - loss: 0.0070 - val_loss: 0.0058
Epoch 16/50
39/39 - 3s - 68ms/step - loss: 0.0069 - val_loss: 0.0059
Epoch 17/50
39/39 - 3s - 70ms/step - loss: 0.0068 - val_loss: 0.0058
Epoch 18/50
39/39 - 3s - 82ms/step - loss: 0.0067 - val_loss: 0.0057
Epoch 19/50
39/39 - 3s - 81ms/step - loss: 0.0066 - val_loss: 0.0058
Epoch 20/50
39/39 - 3s - 70ms/step - loss: 0.0065 - val_loss: 0.0058
Epoch 21/50
39/39 - 3s - 70ms/step - loss: 0.0065 - val_loss: 0.0057
Epoch 22/50
39/39 - 3s - 75ms/step - loss: 0.0064 - val_loss: 0.0056
Epoch 23/50
39/39 - 3s - 70ms/step - loss: 0.0062 - val_loss: 0.0058
Epoch 24/50
39/39 - 3s - 70ms/step - loss: 0.0062 - val_loss: 0.0058
Epoch 25/50
39/39 - 3s - 65ms/step - loss: 0.0061 - val_loss: 0.0058
# Training and overfitting tracking
# ==============================================================================
fig, ax = plt.subplots(figsize=(8, 3))
_ = forecaster.plot_history(ax=ax)
Predictions can be made for specific steps and levels as long as they are within the prediction horizon defined by the model. For example, you can predict ozone concentration (levels = "o3") for steps 1 and 5, that is, one and five hours ahead (steps = [1, 5]).
# Specific steps and levels predictions
# ==============================================================================
forecaster.predict(steps=[1, 5], levels="o3")
| level | pred | |
|---|---|---|
| 2021-04-01 00:00:00 | o3 | 53.947323 |
| 2021-04-01 04:00:00 | o3 | 33.060471 |
# Predictions for all steps and levels
# ==============================================================================
predictions = forecaster.predict()
predictions
| level | pred | |
|---|---|---|
| 2021-04-01 00:00:00 | o3 | 53.947323 |
| 2021-04-01 00:00:00 | pm2.5 | 14.239842 |
| 2021-04-01 00:00:00 | pm10 | 16.086864 |
| 2021-04-01 01:00:00 | o3 | 49.760284 |
| 2021-04-01 01:00:00 | pm2.5 | 14.384841 |
| ... | ... | ... |
| 2021-04-01 22:00:00 | pm2.5 | 12.679586 |
| 2021-04-01 22:00:00 | pm10 | 14.931500 |
| 2021-04-01 23:00:00 | o3 | 56.544186 |
| 2021-04-01 23:00:00 | pm2.5 | 13.461070 |
| 2021-04-01 23:00:00 | pm10 | 18.583685 |
72 rows × 2 columns
As in the previous example, a new forecaster is trained from scratch with the training and validation data, using the number of epochs identified with early stopping.
# Backtesting with test data
# ==============================================================================
cv = TimeSeriesFold(
steps = forecaster.max_step,
initial_train_size = len(data.loc[:end_validation]), # Train + validation
refit = False
)
# Number of epochs identified with early stopping (lowest validation loss)
best_epochs = int(np.argmin(forecaster.history_["val_loss"])) + 1
print(f"Number of epochs: {best_epochs}")
# New forecaster to train the network from scratch with train + validation data
keras.utils.set_random_seed(123)
forecaster_bt = ForecasterRnn(
estimator = model,
levels = levels,
lags = lags,
transformer_series = MinMaxScaler(),
fit_kwargs = {"epochs": best_epochs, "batch_size": 512, "verbose": 0},
)
metrics, predictions = backtesting_forecaster_multiseries(
forecaster = forecaster_bt,
series = data,
cv = cv,
levels = forecaster_bt.levels,
metric = "mean_absolute_error",
suppress_warnings = True,
verbose = False
)
Number of epochs: 22
Using 'torch' backend with device: mps
# Backtesting metrics
# ==============================================================================
metric_multivariate_multioutput = metrics.loc[
metrics["levels"] == "o3", "mean_absolute_error"
].iat[0]
metrics
| levels | mean_absolute_error | |
|---|---|---|
| 0 | o3 | 11.965154 |
| 1 | pm2.5 | 4.179921 |
| 2 | pm10 | 11.006198 |
| 3 | average | 9.050424 |
| 4 | weighted_average | 9.050424 |
| 5 | pooling | 9.050424 |
# Plot all the predicted variables as rows in the plot
# ==============================================================================
fig, ax = plt.subplots(len(levels), 1, figsize=(8, 2 * len(levels)), sharex=True)
for i, level in enumerate(levels):
data_test[level].plot(ax=ax[i], label="test")
predictions.loc[predictions["level"] == level, "pred"].plot(
ax=ax[i], label="predictions"
)
ax[i].set_title(level)
ax[i].legend()
plt.tight_layout()
plt.show()
Comparing Forecasting Strategies¶
As we have seen, various deep learning architectures and modeling strategies can be employed for time series forecasting. In summary, the forecasting approaches can be categorized into:
Single series, multi-step forecasting: Predict future values of a single series using only its past values.
Multivariate, single-output, multi-step forecasting: Use several series as predictors to forecast a target series over multiple future time steps.
Multivariate, multi-output, multi-step forecasting: Use multiple predictor series to forecast several targets over multiple steps.
Below is a summary table comparing the Mean Absolute Error (MAE) for each approach, calculated on the same target series, "o3":
# Metric comparison
# ==============================================================================
results = {
"Single-Series, Multi-Step": metric_single_series,
"Multi-Series, Single-Output": metric_multivariate,
"Multi-Series, Multi-Output": metric_multivariate_multioutput
}
table_results = pd.DataFrame.from_dict(results, orient='index', columns=['O3 MAE'])
table_results = (
table_results.style.highlight_min(axis=0, color='green').format(precision=4)
)
table_results
| O3 MAE | |
|---|---|
| Single-Series, Multi-Step | 11.3843 |
| Multi-Series, Single-Output | 11.3556 |
| Multi-Series, Multi-Output | 11.9652 |
In this example, the single-series and simple multivariate approaches produce similar errors, while adding more targets as outputs (multi-output) increases the prediction error. However, there is no universal rule: the best strategy depends on your data, domain, and prediction goals.
It's important to experiment with different architectures and compare their metrics to select the most appropriate model for your specific use case.
⚠ Warning
This comparison must be interpreted with caution. Each model is trained only once, with a single random seed, so small differences in the MAE are not significant. In addition, the architectures are not identical (the first two models use GRU layers and the multi-output model uses LSTM layers). A rigorous comparison requires the same architecture and several training runs per model.
Exogenous variables in deep learning models¶
Exogenous variables are external predictors (such as weather, holidays, or special events) that can influence the target series but are not part of its own historical values. When building deep learning models for time series forecasting, including these variables can help capture important patterns and improve accuracy, as long as their future values are available at prediction time.
In this section, we’ll demonstrate how to use exogenous variables in deep learning models with a new dataset: bike_sharing, which contains hourly bike usage in Washington D.C., together with weather and holiday information.
To learn more about exogenous variables in skforecast visit the exogenous variables user guide.
# Data download
# ==============================================================================
data_exog = fetch_dataset(name='bike_sharing', raw=False)
data_exog = data_exog[['users', 'temp', 'hum', 'windspeed', 'holiday']]
data_exog = data_exog.loc['2011-04-01 00:00:00':'2012-10-20 23:00:00', :].copy()
data_exog.head(3)
╭───────────────────────────────── 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 11 columns │ ╰─────────────────────────────────────────────────────────────────────────────────╯
| users | temp | hum | windspeed | holiday | |
|---|---|---|---|---|---|
| date_time | |||||
| 2011-04-01 00:00:00 | 6.0 | 10.66 | 100.0 | 11.0014 | 0.0 |
| 2011-04-01 01:00:00 | 4.0 | 10.66 | 100.0 | 11.0014 | 0.0 |
| 2011-04-01 02:00:00 | 7.0 | 10.66 | 93.0 | 12.9980 | 0.0 |
# Calendar features with cyclical encoding
# ==============================================================================
exog_transformer = CalendarFeatures(
features = ['month', 'week', 'day_of_week', 'hour'],
encoding = 'cyclical',
keep_original_columns = True,
)
data_exog = exog_transformer.fit_transform(data_exog)
exog_features = data_exog.columns.difference(['users']).tolist()
print(f"Exogenous features: {exog_features}")
data_exog.head(3)
Exogenous features: ['day_of_week_cos', 'day_of_week_sin', 'holiday', 'hour_cos', 'hour_sin', 'hum', 'month_cos', 'month_sin', 'temp', 'week_cos', 'week_sin', 'windspeed']
| users | temp | hum | windspeed | holiday | month_sin | month_cos | week_sin | week_cos | day_of_week_sin | day_of_week_cos | hour_sin | hour_cos | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| date_time | |||||||||||||
| 2011-04-01 00:00:00 | 6.0 | 10.66 | 100.0 | 11.0014 | 0.0 | 0.866025 | -0.5 | 0.999561 | 0.029633 | -0.433884 | -0.900969 | 0.000000 | 1.000000 |
| 2011-04-01 01:00:00 | 4.0 | 10.66 | 100.0 | 11.0014 | 0.0 | 0.866025 | -0.5 | 0.999561 | 0.029633 | -0.433884 | -0.900969 | 0.258819 | 0.965926 |
| 2011-04-01 02:00:00 | 7.0 | 10.66 | 93.0 | 12.9980 | 0.0 | 0.866025 | -0.5 | 0.999561 | 0.029633 | -0.433884 | -0.900969 | 0.500000 | 0.866025 |
# Split train-validation-test
# ==============================================================================
end_train = '2012-06-30 23:59:00'
end_validation = '2012-10-01 23:59:00'
data_exog_train = data_exog.loc[: end_train, :]
data_exog_val = data_exog.loc[end_train:end_validation, :]
data_exog_test = data_exog.loc[end_validation:, :]
print(
f"Dates train : {data_exog_train.index.min()} --- "
f"{data_exog_train.index.max()} (n={len(data_exog_train)})"
)
print(
f"Dates validation : {data_exog_val.index.min()} --- "
f"{data_exog_val.index.max()} (n={len(data_exog_val)})"
)
print(
f"Dates test : {data_exog_test.index.min()} --- "
f"{data_exog_test.index.max()} (n={len(data_exog_test)})"
)
Dates train : 2011-04-01 00:00:00 --- 2012-06-30 23:00:00 (n=10968) Dates validation : 2012-07-01 00:00:00 --- 2012-10-01 23:00:00 (n=2232) Dates test : 2012-10-02 00:00:00 --- 2012-10-20 23:00:00 (n=456)
The architecture of your deep learning model must be able to accept extra inputs alongside the main time series data. The create_and_compile_model function makes this straightforward: simply pass the exogenous variables as a DataFrame to the exog argument.
# `create_and_compile_model` with exogenous variables
# ==============================================================================
series = ['users']
levels = ['users']
lags = 72
keras.utils.set_random_seed(123) # Reproducible initialization and training
model = create_and_compile_model(
series = data_exog[series], # Single-series
levels = levels, # One target series to predict
lags = lags,
steps = 36,
exog = data_exog[exog_features], # Exogenous variables
recurrent_layer = "LSTM",
recurrent_units = [128, 64],
recurrent_layers_kwargs = {"activation": "tanh"},
dense_units = [64, 32],
compile_kwargs = {'optimizer': Adam(learning_rate=0.01), 'loss': 'mse'},
model_name = "Single-Series-Multi-Step-Exog"
)
model.summary()
keras version: 3.14.0 Using backend: torch torch version: 2.14.0
Model: "Single-Series-Multi-Step-Exog"
┏━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┓ ┃ Layer (type) ┃ Output Shape ┃ Param # ┃ Connected to ┃ ┡━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━┩ │ series_input │ (None, 72, 1) │ 0 │ - │ │ (InputLayer) │ │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ lstm_1 (LSTM) │ (None, 72, 128) │ 66,560 │ series_input[0][… │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ lstm_2 (LSTM) │ (None, 64) │ 49,408 │ lstm_1[0][0] │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ repeat_vector │ (None, 36, 64) │ 0 │ lstm_2[0][0] │ │ (RepeatVector) │ │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ exog_input │ (None, 36, 12) │ 0 │ - │ │ (InputLayer) │ │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ concat_exog │ (None, 36, 76) │ 0 │ repeat_vector[0]… │ │ (Concatenate) │ │ │ exog_input[0][0] │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ dense_td_1 │ (None, 36, 64) │ 4,928 │ concat_exog[0][0] │ │ (TimeDistributed) │ │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ dense_td_2 │ (None, 36, 32) │ 2,080 │ dense_td_1[0][0] │ │ (TimeDistributed) │ │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ output_dense_td_la… │ (None, 36, 1) │ 33 │ dense_td_2[0][0] │ │ (TimeDistributed) │ │ │ │ └─────────────────────┴───────────────────┴────────────┴───────────────────┘
Total params: 123,009 (480.50 KB)
Trainable params: 123,009 (480.50 KB)
Non-trainable params: 0 (0.00 B)
# Plotting the model architecture (requires `pydot` and `graphviz`)
# ==============================================================================
# from keras.utils import plot_model
# plot_model(
# model,
# show_shapes=True,
# show_layer_names=True,
# to_file='model-architecture-exog.png'
# )
# Forecaster Creation
# ==============================================================================
forecaster = ForecasterRnn(
estimator=model,
levels=levels,
lags=lags,
transformer_series=MinMaxScaler(),
transformer_exog=MinMaxScaler(),
fit_kwargs={
"epochs": 50,
"batch_size": 1024,
"verbose": 2,
"callbacks": [
EarlyStopping(monitor="val_loss", patience=3, restore_best_weights=True),
ReduceLROnPlateau(
monitor="val_loss", factor=0.5, patience=2, min_lr=1e-5, verbose=1
)
], # Callbacks to stop training early and to reduce the learning rate.
"series_val": data_exog_val[series], # Validation series
"exog_val": data_exog_val[exog_features], # Validation exogenous variables
},
)
# Fit forecaster with exogenous variables
# ==============================================================================
forecaster.fit(
series = data_exog_train[series],
exog = data_exog_train[exog_features]
)
Using 'torch' backend with device: mps
Epoch 1/50
11/11 - 8s - 758ms/step - loss: 0.0450 - val_loss: 0.0453 - learning_rate: 0.0100
Epoch 2/50
11/11 - 8s - 732ms/step - loss: 0.0168 - val_loss: 0.0451 - learning_rate: 0.0100
Epoch 3/50
11/11 - 8s - 713ms/step - loss: 0.0144 - val_loss: 0.0379 - learning_rate: 0.0100
Epoch 4/50
11/11 - 8s - 727ms/step - loss: 0.0127 - val_loss: 0.0362 - learning_rate: 0.0100
Epoch 5/50
11/11 - 8s - 717ms/step - loss: 0.0111 - val_loss: 0.0420 - learning_rate: 0.0100
Epoch 6/50
11/11 - 8s - 744ms/step - loss: 0.0114 - val_loss: 0.0316 - learning_rate: 0.0100
Epoch 7/50
11/11 - 9s - 802ms/step - loss: 0.0096 - val_loss: 0.0322 - learning_rate: 0.0100
Epoch 8/50
11/11 - 11s - 970ms/step - loss: 0.0085 - val_loss: 0.0278 - learning_rate: 0.0100
Epoch 9/50
11/11 - 11s - 1s/step - loss: 0.0075 - val_loss: 0.0229 - learning_rate: 0.0100
Epoch 10/50
11/11 - 13s - 1s/step - loss: 0.0108 - val_loss: 0.0353 - learning_rate: 0.0100
Epoch 11/50
Epoch 11: ReduceLROnPlateau reducing learning rate to 0.004999999888241291.
11/11 - 9s - 777ms/step - loss: 0.0082 - val_loss: 0.0244 - learning_rate: 0.0100
Epoch 12/50
11/11 - 9s - 789ms/step - loss: 0.0072 - val_loss: 0.0273 - learning_rate: 0.0050
# Training and overfitting tracking
# ==============================================================================
fig, ax = plt.subplots(figsize=(8, 3))
_ = forecaster.plot_history(ax=ax)
The training history shows that both losses decrease during the first epochs, and EarlyStopping restores the weights of the epoch with the lowest validation loss. However, the validation loss remains clearly above the training loss and fluctuates from one epoch to the next. The fluctuations are partly explained by the high initial learning rate (0.01), which ReduceLROnPlateau reduces during the training, and the gap may also reflect differences between the training and validation periods. If the gap widened while the training loss kept decreasing, it would be a clear sign of overfitting. In that case, you could try adding regularization such as dropout, simplifying the model by reducing its size, or revisiting the choice of exogenous features.
When using exogenous variables, the predict method requires the future values of these variables. They must be provided through the exog argument.
⚠ Warning
The future values of the exogenous variables must be known for the whole forecast horizon at prediction time. Calendar features meet this condition, but weather variables (temp, hum, windspeed) do not: in this example, their observed values are used as if they were known in advance, so the estimated errors are optimistic. In a real application, the backtesting should use the weather forecasts that were available at each point in time.
# Prediction with exogenous variables
# ==============================================================================
predictions = forecaster.predict(exog=data_exog_val[exog_features])
predictions.head(4)
| level | pred | |
|---|---|---|
| 2012-07-01 00:00:00 | users | 100.801903 |
| 2012-07-01 01:00:00 | users | 69.158737 |
| 2012-07-01 02:00:00 | users | 34.313553 |
| 2012-07-01 03:00:00 | users | 15.798633 |
As in the previous example, a new forecaster is trained from scratch with the training and validation data, using the number of epochs identified with early stopping.
# Backtesting with test data and exogenous variables
# ==============================================================================
cv = TimeSeriesFold(
steps = forecaster.max_step,
initial_train_size = len(data_exog.loc[:end_validation]), # Train + validation
refit = False
)
# Number of epochs identified with early stopping (lowest validation loss)
best_epochs = int(np.argmin(forecaster.history_["val_loss"])) + 1
print(f"Number of epochs: {best_epochs}")
# New forecaster to train the network from scratch with train + validation data
keras.utils.set_random_seed(123)
forecaster_bt = ForecasterRnn(
estimator = model,
levels = levels,
lags = lags,
transformer_series = MinMaxScaler(),
transformer_exog = MinMaxScaler(),
fit_kwargs = {"epochs": best_epochs, "batch_size": 1024, "verbose": 0},
)
metrics, predictions = backtesting_forecaster_multiseries(
forecaster = forecaster_bt,
series = data_exog[series],
exog = data_exog[exog_features],
cv = cv,
levels = forecaster_bt.levels,
metric = "mean_absolute_error",
suppress_warnings = True,
verbose = False
)
Number of epochs: 9
Using 'torch' backend with device: mps
# Backtesting metrics
# ==============================================================================
metrics
| levels | mean_absolute_error | |
|---|---|---|
| 0 | users | 81.483831 |
# Plotting predictions vs real values in the test set
# ==============================================================================
fig, ax = plt.subplots(figsize=(8, 3))
data_exog_test["users"].plot(ax=ax, label="test")
predictions.loc[predictions["level"] == "users", "pred"].plot(
ax=ax, label="predictions"
)
ax.set_title("users")
ax.legend();
Probabilistic forecasting with deep learning models¶
Conformal prediction is a framework for constructing prediction intervals that contain the true value with a specified probability (coverage probability). It works by combining the predictions of a point-forecasting model with its past residuals (differences between previous predictions and actual values). These residuals estimate the uncertainty of the forecast and determine the width of the interval that is added to the point forecast. The coverage is only ensured if the residuals are representative of the errors the model makes on new data, which requires computing them with data not used to train the model (calibration set).
In skforecast, intervals are obtained with the method predict_interval. To learn more about conformal predictions in skforecast, visit the Probabilistic Forecasting: Conformal Prediction user guide.
# Store in-sample residuals
# ==============================================================================
forecaster.set_in_sample_residuals(
series=data_exog_train[series], exog=data_exog_train[exog_features]
)
# Prediction intervals
# ==============================================================================
predictions = forecaster.predict_interval(
steps = None,
exog = data_exog_val[exog_features],
interval = [0.1, 0.9], # 80% prediction interval
method = 'conformal',
use_in_sample_residuals = True
)
predictions.head(4)
| level | pred | lower_bound | upper_bound | |
|---|---|---|---|---|
| 2012-07-01 00:00:00 | users | 100.801900 | 32.630502 | 168.973297 |
| 2012-07-01 01:00:00 | users | 69.158740 | 23.266781 | 115.050699 |
| 2012-07-01 02:00:00 | users | 34.313553 | -11.578406 | 80.205512 |
| 2012-07-01 03:00:00 | users | 15.798633 | -7.081111 | 38.678377 |
# Plot intervals
# ==============================================================================
plot_prediction_intervals(
predictions = predictions,
y_true = data_exog_val,
target_variable = "users",
title = "Predicted intervals",
kwargs_fill_between = {'color': 'gray', 'alpha': 0.4, 'zorder': 1}
)
⚠ Warning
In the previous example, the intervals are estimated with in-sample residuals (use_in_sample_residuals=True). Since the model has already seen the training data, these residuals tend to be smaller than the errors made on new data, and the resulting intervals are usually too narrow. Whenever possible, use out-of-sample residuals computed on a calibration set, as shown below.
Intervals with out-of-sample residuals¶
Out-of-sample residuals are obtained by predicting data that was not used to fit the network. Here, the validation partition is used as calibration set: the forecaster, trained with the training partition, predicts the validation partition with backtesting_forecaster_multiseries. Setting initial_train_size=None makes the backtesting use the forecaster as it is, without training it again. Although the validation partition was used for early stopping, the network was not fitted on it, so its residuals are a much better estimate of the errors on new data than the in-sample residuals.
The residuals are stored in the forecaster with set_out_sample_residuals. Both y_true and y_pred are given in the original scale of the series; the forecaster applies the transformation internally.
# Predictions on the calibration set (validation partition)
# ==============================================================================
cv_cal = TimeSeriesFold(
steps = forecaster.max_step,
initial_train_size = None, # Use the already trained forecaster
refit = False
)
_, predictions_cal = backtesting_forecaster_multiseries(
forecaster = forecaster,
series = data_exog_val[series],
exog = data_exog_val[exog_features],
cv = cv_cal,
levels = levels,
metric = "mean_absolute_error",
suppress_warnings = True,
verbose = False
)
# Store out-of-sample residuals
# ==============================================================================
forecaster.set_out_sample_residuals(
y_true = {"users": data_exog_val.loc[predictions_cal.index, "users"]},
y_pred = {"users": predictions_cal["pred"]}
)
To compare both types of residuals, the test partition is backtested with an 80% prediction interval, first with in-sample residuals and then with out-of-sample residuals. The series passed to the backtesting starts lags observations before the test partition, so that the first fold has its last window available.
# Interval backtesting on the test set: in-sample vs out-of-sample residuals
# ==============================================================================
data_exog_bt = data_exog.iloc[-(len(data_exog_test) + lags):] # Last window + test
cv_test = TimeSeriesFold(
steps = forecaster.max_step,
initial_train_size = None,
refit = False
)
results_intervals = {}
predictions_intervals = {}
for use_in_sample_residuals in [True, False]:
_, predictions_interval = backtesting_forecaster_multiseries(
forecaster = forecaster,
series = data_exog_bt[series],
exog = data_exog_bt[exog_features],
cv = cv_test,
levels = levels,
metric = "mean_absolute_error",
interval = [0.1, 0.9], # 80% prediction interval
interval_method = "conformal",
use_in_sample_residuals = use_in_sample_residuals,
suppress_warnings = True,
verbose = False
)
residuals = "in-sample" if use_in_sample_residuals else "out-of-sample"
predictions_intervals[residuals] = predictions_interval
results_intervals[residuals] = {
"coverage": calculate_coverage(
y_true = data_exog_test.loc[predictions_interval.index, "users"],
lower_bound = predictions_interval["lower_bound"],
upper_bound = predictions_interval["upper_bound"]
),
"mean_interval_width": (
predictions_interval["upper_bound"] - predictions_interval["lower_bound"]
).mean()
}
pd.DataFrame(results_intervals).T
| coverage | mean_interval_width | |
|---|---|---|
| in-sample | 0.682018 | 189.668943 |
| out-of-sample | 0.750000 | 276.799172 |
# Plot intervals with out-of-sample residuals (first two weeks of the test set)
# ==============================================================================
plot_prediction_intervals(
predictions = predictions_intervals["out-of-sample"].iloc[:14 * 24],
y_true = data_exog_test,
target_variable = "users",
title = "Predicted intervals (out-of-sample residuals)",
kwargs_fill_between = {'color': 'gray', 'alpha': 0.4, 'zorder': 1}
)
With in-sample residuals, the coverage of the 80% interval is clearly below the nominal value. Out-of-sample residuals produce wider intervals and bring the coverage closer to it. The coverage is still below 80%, which suggests that the errors in the test period (October) are larger than those observed in the calibration period (July to September), for example because of the change of season. Conformal intervals assume that the calibration residuals are representative of the errors in the forecasting period.
The intervals are built by adding and subtracting a margin to the point prediction, so the lower bound can be negative even though the number of users cannot. For this type of series, the bounds can be clipped at zero.
Using Custom Loss Functions in Deep Learning Models¶
By default, Keras models in skforecast can be trained using common loss functions such as MeanSquaredError or MeanAbsoluteError. However, in many forecasting tasks it is useful to design a custom loss function that reflects the specific goals of your problem. For example, you may want to penalize underestimation more than overestimation, handle imbalanced data, or directly optimize for a business-specific metric.
Keras and skforecast make this process straightforward:
You can define a loss as a Python function that takes
y_trueandy_predtensors and returns a scalar loss value.To save and load a model that uses a custom loss, the loss must be registered with
@keras.saving.register_keras_serializable().
This flexibility enables you to customize the training process according to your forecasting domain, ensuring that the model optimizes the metrics that are most relevant to your use case.
The following example defines weighted_mae, a mean absolute error in which each error is weighted by the magnitude of the true value, so that errors during demand peaks are penalized more than errors during periods of low demand. Keep in mind that the loss is computed on the scaled series (values in the [0, 1] range), so observations close to the minimum of the series receive almost no weight.
# Create custom loss function with keras
# ==============================================================================
from keras import ops
@keras.saving.register_keras_serializable(package="custom", name="weighted_mae")
def weighted_mae(y_true, y_pred):
"""
Weighted MAE to use with TensorFlow, PyTorch or JAX.
"""
y_true = ops.cast(y_true, dtype="float32")
y_pred = ops.cast(y_pred, dtype="float32")
error = ops.abs(y_true - y_pred)
weights = ops.abs(y_true)
return ops.mean(error * weights)
# `create_and_compile_model` with custom loss function
# ==============================================================================
series = ['users']
levels = ['users']
lags = 72
keras.utils.set_random_seed(123) # Reproducible initialization and training
model = create_and_compile_model(
series = data_exog[series],
levels = levels,
lags = lags,
steps = 36,
exog = data_exog[exog_features],
recurrent_layer = "LSTM",
recurrent_units = [128, 64],
recurrent_layers_kwargs = {"activation": "tanh"},
dense_units = [64, 32],
compile_kwargs = {
'optimizer': Adam(learning_rate=0.01),
'loss': weighted_mae # Custom loss function
},
model_name = "Single-Series-Multi-Step-Exog-CustomLoss"
)
model.summary()
keras version: 3.14.0 Using backend: torch torch version: 2.14.0
Model: "Single-Series-Multi-Step-Exog-CustomLoss"
┏━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┓ ┃ Layer (type) ┃ Output Shape ┃ Param # ┃ Connected to ┃ ┡━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━┩ │ series_input │ (None, 72, 1) │ 0 │ - │ │ (InputLayer) │ │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ lstm_1 (LSTM) │ (None, 72, 128) │ 66,560 │ series_input[0][… │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ lstm_2 (LSTM) │ (None, 64) │ 49,408 │ lstm_1[0][0] │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ repeat_vector │ (None, 36, 64) │ 0 │ lstm_2[0][0] │ │ (RepeatVector) │ │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ exog_input │ (None, 36, 12) │ 0 │ - │ │ (InputLayer) │ │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ concat_exog │ (None, 36, 76) │ 0 │ repeat_vector[0]… │ │ (Concatenate) │ │ │ exog_input[0][0] │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ dense_td_1 │ (None, 36, 64) │ 4,928 │ concat_exog[0][0] │ │ (TimeDistributed) │ │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ dense_td_2 │ (None, 36, 32) │ 2,080 │ dense_td_1[0][0] │ │ (TimeDistributed) │ │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ output_dense_td_la… │ (None, 36, 1) │ 33 │ dense_td_2[0][0] │ │ (TimeDistributed) │ │ │ │ └─────────────────────┴───────────────────┴────────────┴───────────────────┘
Total params: 123,009 (480.50 KB)
Trainable params: 123,009 (480.50 KB)
Non-trainable params: 0 (0.00 B)
# Forecaster Creation
# ==============================================================================
forecaster = ForecasterRnn(
estimator=model,
levels=levels,
lags=lags,
transformer_series=MinMaxScaler(),
transformer_exog=MinMaxScaler(),
fit_kwargs={
"epochs": 50,
"batch_size": 1024,
"verbose": 2,
"callbacks": [
EarlyStopping(monitor="val_loss", patience=3, restore_best_weights=True),
ReduceLROnPlateau(
monitor="val_loss", factor=0.5, patience=2, min_lr=1e-5, verbose=1
)
], # Callbacks to stop training early and to reduce the learning rate.
"series_val": data_exog_val[series], # Validation series
"exog_val": data_exog_val[exog_features], # Validation exogenous variables
},
)
# Fit forecaster with exogenous variables
# ==============================================================================
forecaster.fit(
series = data_exog_train[series],
exog = data_exog_train[exog_features]
)
Using 'torch' backend with device: mps
Epoch 1/50
11/11 - 11s - 1s/step - loss: 0.0351 - val_loss: 0.0627 - learning_rate: 0.0100
Epoch 2/50
11/11 - 10s - 888ms/step - loss: 0.0237 - val_loss: 0.0616 - learning_rate: 0.0100
Epoch 3/50
11/11 - 10s - 912ms/step - loss: 0.0217 - val_loss: 0.0485 - learning_rate: 0.0100
Epoch 4/50
11/11 - 10s - 864ms/step - loss: 0.0207 - val_loss: 0.0578 - learning_rate: 0.0100
Epoch 5/50
Epoch 5: ReduceLROnPlateau reducing learning rate to 0.004999999888241291.
11/11 - 9s - 861ms/step - loss: 0.0184 - val_loss: 0.0535 - learning_rate: 0.0100
Epoch 6/50
11/11 - 8s - 693ms/step - loss: 0.0173 - val_loss: 0.0528 - learning_rate: 0.0050
# Training and overfitting tracking
# ==============================================================================
fig, ax = plt.subplots(figsize=(8, 3))
_ = forecaster.plot_history(ax=ax)
# Prediction with exogenous variables
# ==============================================================================
predictions = forecaster.predict(exog=data_exog_val[exog_features])
predictions.head(4)
| level | pred | |
|---|---|---|
| 2012-07-01 00:00:00 | users | 73.639023 |
| 2012-07-01 01:00:00 | users | 55.834949 |
| 2012-07-01 02:00:00 | users | 45.473366 |
| 2012-07-01 03:00:00 | users | 61.383270 |
The loss values of this model are not comparable with those of the previous one, since each model minimizes a different function. To compare both models, evaluate them with a common metric using backtesting, as shown in the previous sections.
Understanding create_and_compile_model in depth¶
The create_and_compile_model function is designed to streamline the process of building and compiling RNN-based Keras models for time series forecasting, with or without exogenous variables. This function allows both rapid prototyping (with sensible defaults) and fine-grained customization for advanced users.
How the function works
At its core, create_and_compile_model builds a neural network that consists of three main building blocks:
Recurrent Layers (LSTM, GRU, or SimpleRNN): These layers capture temporal dependencies in the data. You can control the type, number, and configuration of recurrent layers using
recurrent_layer,recurrent_units, andrecurrent_layers_kwargs.Dense (Fully Connected) Layers: After temporal features are extracted, dense layers help model nonlinear relationships between learned features and the forecasting target(s). The structure is controlled by
dense_unitsanddense_layers_kwargs.Output Layer: The final dense layer matches the number of forecasting targets (
levels) and steps (steps). Its configuration can be adjusted withoutput_dense_layer_kwargs.
If you include exogenous variables (exog), the function automatically adjusts the input structure so that the model receives both the main time series and additional features.
Parameters
series: Main time series data (as a DataFrame), each column is treated as an input feature.lags: Number of past time steps to use as predictors. Defines the input sequence length. The same value must be used later in theForecasterRnnlagsargument.steps: Number of future time steps to predict.levels: List of variables to predict (target variables). Can be one or many columns fromseries. IfNone, defaults to the names of input series.exog: Exogenous variables (optional), given as a DataFrame. Must be aligned withseries.recurrent_layer: Type of recurrent layer, choose between'LSTM','GRU', or'RNN'. Keras API: LSTM, GRU, SimpleRNN.recurrent_units: Number of units per recurrent layer. Accepts a single int (for one layer) or a list/tuple for multiple stacked layers.recurrent_layers_kwargs: Dictionary (same for all layers) or lists of dictionaries (one per layer) with keyword arguments for the respective recurrent layers (e.g., activation functions, dropout, etc.).dense_units: Number of units per dense layer. Accepts a single int (for one layer) or a list/tuple for multiple stacked layers.dense_layers_kwargs: Dictionary (same for all layers) or lists of dictionaries (one per layer) with keyword arguments for the respective dense layers (e.g.,activation,kernel_initializerorkernel_regularizer). Dense layers do not acceptdropout.output_dense_layer_kwargs: Dictionary with keyword arguments for the output dense layer (e.g.,activation). Defaults to{'activation': 'linear'}.compile_kwargs: Dictionary of parameters for Keras’scompile()method, e.g. optimizer, loss function. Defaults to{'optimizer': Adam(), 'loss': MeanSquaredError()}.model_name: Name of the model.
Visit the full API documentation for create_and_compile_model for more details.
Example: Model summary and layer-by-layer explanation (no exog)¶
# Model summary `create_and_compile_model`
# ==============================================================================
model = create_and_compile_model(
series = data,
levels = ["o3"],
lags = 32,
steps = 24,
recurrent_layer = "GRU",
recurrent_units = 100,
dense_units = 64
)
model.summary()
keras version: 3.14.0 Using backend: torch torch version: 2.14.0
Model: "functional_2"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓ ┃ Layer (type) ┃ Output Shape ┃ Param # ┃ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩ │ series_input (InputLayer) │ (None, 32, 10) │ 0 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ gru_1 (GRU) │ (None, 100) │ 33,600 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ dense_1 (Dense) │ (None, 64) │ 6,464 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ output_dense_td_layer (Dense) │ (None, 24) │ 1,560 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ reshape (Reshape) │ (None, 24, 1) │ 0 │ └─────────────────────────────────┴────────────────────────┴───────────────┘
Total params: 41,624 (162.59 KB)
Trainable params: 41,624 (162.59 KB)
Non-trainable params: 0 (0.00 B)
| Layer Name | Type | Output Shape | Param # | Description |
|---|---|---|---|---|
| series_input | InputLayer |
(None, 32, 10) |
0 | Input layer of the model. It receives input sequences of length 32 (lags) with 10 features (predictor series) per step. |
| gru_1 | GRU |
(None, 100) |
33,600 | GRU (Gated Recurrent Unit) layer with 100 units and 'tanh' activation. Learns patterns and dependencies over time in the input data. |
| dense_1 | Dense |
(None, 64) |
6,464 | Fully connected (dense) layer with 64 units and ReLU activation. Processes the features extracted by the GRU layer. |
| output_dense_td_layer | Dense |
(None, 24) |
1,560 | Dense output layer with 24 units (one for each of the 24 future time steps to predict), linear activation. |
| reshape | Reshape |
(None, 24, 1) |
0 | Reshapes the output to match the format (steps, output variables). Here, steps=24 and levels=["o3"], so the final output is (None, 24, 1). |
Total params: 41,624 Trainable params: 41,624 Non-trainable params: 0
Example: Model summary and layer-by-layer explanation (exog)¶
# Create calendar exogenous variables
# ==============================================================================
data['hour'] = data.index.hour
data['day_of_week'] = data.index.dayofweek
data = pd.get_dummies(
data, columns=['hour', 'day_of_week'], drop_first=True, dtype=float
)
data.head(3)
| so2 | co | no | no2 | pm10 | nox | o3 | veloc. | direc. | pm2.5 | ... | hour_20 | hour_21 | hour_22 | hour_23 | day_of_week_1 | day_of_week_2 | day_of_week_3 | day_of_week_4 | day_of_week_5 | day_of_week_6 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| datetime | |||||||||||||||||||||
| 2019-01-01 00:00:00 | 8.0 | 0.2 | 3.0 | 36.0 | 22.0 | 40.0 | 16.0 | 0.5 | 262.0 | 19.0 | ... | 0.0 | 0.0 | 0.0 | 0.0 | 1.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| 2019-01-01 01:00:00 | 8.0 | 0.1 | 2.0 | 40.0 | 32.0 | 44.0 | 6.0 | 0.6 | 248.0 | 26.0 | ... | 0.0 | 0.0 | 0.0 | 0.0 | 1.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| 2019-01-01 02:00:00 | 8.0 | 0.1 | 11.0 | 42.0 | 36.0 | 58.0 | 3.0 | 0.3 | 224.0 | 31.0 | ... | 0.0 | 0.0 | 0.0 | 0.0 | 1.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
3 rows × 39 columns
# Model summary `create_and_compile_model` with exogenous variables
# ==============================================================================
series = ['so2', 'co', 'no', 'no2', 'pm10', 'nox', 'o3', 'veloc.', 'direc.', 'pm2.5']
exog_features = data.columns.difference(series).tolist() # day_of_week_* and hour_*
levels = ['o3', 'pm2.5', 'pm10'] # Multiple target series to predict
print("Target series:", levels)
print("Series as predictors:", series)
print("Exogenous variables:", exog_features)
print("")
model = create_and_compile_model(
series = data[series],
levels = levels,
lags = 32,
steps = 24,
exog = data[exog_features],
recurrent_layer = "LSTM",
recurrent_units = [128, 64],
recurrent_layers_kwargs = [{'activation': 'tanh'}, {'activation': 'relu'}],
dense_units = [128, 64],
dense_layers_kwargs = {'activation': 'relu'},
output_dense_layer_kwargs = {'activation': 'linear'},
compile_kwargs = {'optimizer': Adam(), 'loss': MeanSquaredError()},
model_name = None
)
model.summary()
Target series: ['o3', 'pm2.5', 'pm10'] Series as predictors: ['so2', 'co', 'no', 'no2', 'pm10', 'nox', 'o3', 'veloc.', 'direc.', 'pm2.5'] Exogenous variables: ['day_of_week_1', 'day_of_week_2', 'day_of_week_3', 'day_of_week_4', 'day_of_week_5', 'day_of_week_6', 'hour_1', 'hour_10', 'hour_11', 'hour_12', 'hour_13', 'hour_14', 'hour_15', 'hour_16', 'hour_17', 'hour_18', 'hour_19', 'hour_2', 'hour_20', 'hour_21', 'hour_22', 'hour_23', 'hour_3', 'hour_4', 'hour_5', 'hour_6', 'hour_7', 'hour_8', 'hour_9'] keras version: 3.14.0 Using backend: torch torch version: 2.14.0
Model: "functional_3"
┏━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┓ ┃ Layer (type) ┃ Output Shape ┃ Param # ┃ Connected to ┃ ┡━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━┩ │ series_input │ (None, 32, 10) │ 0 │ - │ │ (InputLayer) │ │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ lstm_1 (LSTM) │ (None, 32, 128) │ 71,168 │ series_input[0][… │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ lstm_2 (LSTM) │ (None, 64) │ 49,408 │ lstm_1[0][0] │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ repeat_vector │ (None, 24, 64) │ 0 │ lstm_2[0][0] │ │ (RepeatVector) │ │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ exog_input │ (None, 24, 29) │ 0 │ - │ │ (InputLayer) │ │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ concat_exog │ (None, 24, 93) │ 0 │ repeat_vector[0]… │ │ (Concatenate) │ │ │ exog_input[0][0] │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ dense_td_1 │ (None, 24, 128) │ 12,032 │ concat_exog[0][0] │ │ (TimeDistributed) │ │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ dense_td_2 │ (None, 24, 64) │ 8,256 │ dense_td_1[0][0] │ │ (TimeDistributed) │ │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ output_dense_td_la… │ (None, 24, 3) │ 195 │ dense_td_2[0][0] │ │ (TimeDistributed) │ │ │ │ └─────────────────────┴───────────────────┴────────────┴───────────────────┘
Total params: 141,059 (551.01 KB)
Trainable params: 141,059 (551.01 KB)
Non-trainable params: 0 (0.00 B)
| Layer Name | Type | Output Shape | Param # | Description |
|---|---|---|---|---|
| series_input | InputLayer |
(None, 32, 10) |
0 | Input layer for the main time series. Receives sequences of 32 time steps (lags) for 10 features (predictor series). |
| lstm_1 | LSTM |
(None, 32, 128) |
71,168 | First LSTM layer with 128 units, 'tanh' activation. Learns temporal patterns and dependencies from the input sequences. |
| lstm_2 | LSTM |
(None, 64) |
49,408 | Second LSTM layer with 64 units, 'relu' activation. Further summarizes the temporal information. |
| repeat_vector | RepeatVector |
(None, 24, 64) |
0 | Repeats the output of the previous LSTM layer 24 times, one for each future time step to predict. |
| exog_input | InputLayer |
(None, 24, 29) |
0 | Input layer for the 29 exogenous variables (23 hour and 6 day-of-week dummy variables) for each of the 24 future time steps. |
| concat_exog | Concatenate |
(None, 24, 93) |
0 | Concatenates the repeated LSTM output and the exogenous variables for each prediction time step, joining all features together. |
| dense_td_1 | TimeDistributed (Dense) |
(None, 24, 128) |
12,032 | Dense layer (128 units, ReLU) applied independently to each of the 24 time steps, learning complex relationships from all features. |
| dense_td_2 | TimeDistributed (Dense) |
(None, 24, 64) |
8,256 | Second dense layer (64 units, ReLU), also applied to each time step, further processes the combined features. |
| output_dense_td_layer | TimeDistributed (Dense) |
(None, 24, 3) |
195 | Final output layer, predicts 3 target variables (levels) for each of the 24 future steps ('linear' activation). |
Total params: 141,059 Trainable params: 141,059 Non-trainable params: 0
Running on GPU¶
skforecast is fully compatible with GPU acceleration. If your computer has a compatible GPU and the right software installed, skforecast will automatically use the GPU to speed up training.
Tips for GPU Training
Batch size matters: Large batch sizes (for example, 64, 128, 256, or even more) allow the GPU to process more data in one go, making training much faster compared to a CPU. Small batch sizes (for example, 8 or 16) don’t use all the power of the GPU, so training may be only a little faster (or sometimes not faster at all) than using just the CPU.
Performance boost: On a suitable GPU, training can be many times faster than on CPU. For example, with a large batch size, an NVIDIA T4 GPU can reduce training time from over a minute (CPU) to just a few seconds (GPU).
How to use the GPU with skforecast
- Install the GPU version of PyTorch (with CUDA support). Visit the PyTorch installation page and follow the instructions for your system. Make sure to select the version that matches your GPU and CUDA version. For example, to install PyTorch with CUDA 12.6, you can run:
pip install torch --index-url https://download.pytorch.org/whl/cu126
- Check if your GPU is available in Python:
# Check if GPU is available
# ==============================================================================
import torch
print("Torch version :", torch.__version__)
if torch.cuda.is_available():
print("Cuda available :", torch.cuda.is_available())
print("Device name :", torch.cuda.get_device_name(0))
else:
# macOS
print(f"MPS available : {torch.backends.mps.is_available()}")
Torch version : 2.14.0 MPS available : True
- Run your code as usual. If a GPU is detected, skforecast will use it automatically.
How to extract training and test matrices¶
While forecasting models are mainly used to predict future values, it's just as important to understand how the model is learning from the training data. Analyzing input and output matrices used during training, predictions on the training data or exploring the prediction matrices is crucial for assessing model performance and understanding areas for optimization. This process can reveal whether the model is overfitting, underfitting, or struggling with specific patterns in the data.
⚠ Warning
If any data transformations are applied, it will affect the output matrices. Consequently, the predictions generated in this transformed scale may require additional steps to revert back to the original data scale.
# Split train-test
# ==============================================================================
end_train = "2021-09-30 23:59:00"
data_train = data.loc[:end_train, :].copy()
data_test = data.loc[end_train:, :].copy()
print(
f"Dates train : {data_train.index.min()} --- "
f"{data_train.index.max()} (n={len(data_train)})"
)
print(
f"Dates test : {data_test.index.min()} --- "
f"{data_test.index.max()} (n={len(data_test)})"
)
data_train.head(3)
Dates train : 2019-01-01 00:00:00 --- 2021-09-30 23:00:00 (n=24096) Dates test : 2021-10-01 00:00:00 --- 2021-12-31 23:00:00 (n=2208)
| so2 | co | no | no2 | pm10 | nox | o3 | veloc. | direc. | pm2.5 | ... | hour_20 | hour_21 | hour_22 | hour_23 | day_of_week_1 | day_of_week_2 | day_of_week_3 | day_of_week_4 | day_of_week_5 | day_of_week_6 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| datetime | |||||||||||||||||||||
| 2019-01-01 00:00:00 | 8.0 | 0.2 | 3.0 | 36.0 | 22.0 | 40.0 | 16.0 | 0.5 | 262.0 | 19.0 | ... | 0.0 | 0.0 | 0.0 | 0.0 | 1.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| 2019-01-01 01:00:00 | 8.0 | 0.1 | 2.0 | 40.0 | 32.0 | 44.0 | 6.0 | 0.6 | 248.0 | 26.0 | ... | 0.0 | 0.0 | 0.0 | 0.0 | 1.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| 2019-01-01 02:00:00 | 8.0 | 0.1 | 11.0 | 42.0 | 36.0 | 58.0 | 3.0 | 0.3 | 224.0 | 31.0 | ... | 0.0 | 0.0 | 0.0 | 0.0 | 1.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
3 rows × 39 columns
# Model summary `create_and_compile_model` with exogenous variables
# ==============================================================================
lags = 5
series = ['so2', 'co', 'no', 'no2', 'pm10', 'nox', 'o3', 'veloc.', 'direc.', 'pm2.5']
# Exogenous variables: day_of_week_* and hour_* dummies
exog_features = data_train.columns.difference(series).tolist()
levels = ['o3', 'pm2.5', 'pm10'] # Multiple target series to predict
print("Target series:", levels)
print("Series as predictors:", series)
print("Exogenous variables:", exog_features)
print("")
model = create_and_compile_model(
series = data_train[series],
levels = levels,
lags = lags,
steps = 4,
exog = data_train[exog_features],
recurrent_layer = "GRU",
recurrent_units = 64,
dense_units = 32
)
model.summary()
Target series: ['o3', 'pm2.5', 'pm10'] Series as predictors: ['so2', 'co', 'no', 'no2', 'pm10', 'nox', 'o3', 'veloc.', 'direc.', 'pm2.5'] Exogenous variables: ['day_of_week_1', 'day_of_week_2', 'day_of_week_3', 'day_of_week_4', 'day_of_week_5', 'day_of_week_6', 'hour_1', 'hour_10', 'hour_11', 'hour_12', 'hour_13', 'hour_14', 'hour_15', 'hour_16', 'hour_17', 'hour_18', 'hour_19', 'hour_2', 'hour_20', 'hour_21', 'hour_22', 'hour_23', 'hour_3', 'hour_4', 'hour_5', 'hour_6', 'hour_7', 'hour_8', 'hour_9'] keras version: 3.14.0 Using backend: torch torch version: 2.14.0
Model: "functional_4"
┏━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┓ ┃ Layer (type) ┃ Output Shape ┃ Param # ┃ Connected to ┃ ┡━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━┩ │ series_input │ (None, 5, 10) │ 0 │ - │ │ (InputLayer) │ │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ gru_1 (GRU) │ (None, 64) │ 14,592 │ series_input[0][… │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ repeat_vector │ (None, 4, 64) │ 0 │ gru_1[0][0] │ │ (RepeatVector) │ │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ exog_input │ (None, 4, 29) │ 0 │ - │ │ (InputLayer) │ │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ concat_exog │ (None, 4, 93) │ 0 │ repeat_vector[0]… │ │ (Concatenate) │ │ │ exog_input[0][0] │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ dense_td_1 │ (None, 4, 32) │ 3,008 │ concat_exog[0][0] │ │ (TimeDistributed) │ │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ output_dense_td_la… │ (None, 4, 3) │ 99 │ dense_td_1[0][0] │ │ (TimeDistributed) │ │ │ │ └─────────────────────┴───────────────────┴────────────┴───────────────────┘
Total params: 17,699 (69.14 KB)
Trainable params: 17,699 (69.14 KB)
Non-trainable params: 0 (0.00 B)
# Forecaster creation
# ==============================================================================
forecaster = ForecasterRnn(
estimator = model,
levels = levels,
lags = lags,
transformer_series = MinMaxScaler(),
transformer_exog = MinMaxScaler(),
fit_kwargs = {"verbose": 0},
)
forecaster.fit(series=data_train[series], exog=data_train[exog_features])
forecaster
Using 'torch' backend with device: mps
ForecasterRnn
General Information
- Estimator: Functional
- Layers names: ['series_input', 'gru_1', 'repeat_vector', 'exog_input', 'concat_exog', 'dense_td_1', 'output_dense_td_layer']
- Lags: [1 2 3 4 5]
- Window size: 5
- Maximum steps to predict: [1 2 3 4]
- Exogenous included: True
- Creation date: 2026-10-10 15:13:49
- Last fit date: 2026-10-10 15:14:02
- Keras backend: torch
- Skforecast version: 0.26.0
- Python version: 3.14.3
- Forecaster id: None
Exogenous Variables
-
day_of_week_1, day_of_week_2, day_of_week_3, day_of_week_4, day_of_week_5, day_of_week_6, hour_1, hour_10, hour_11, hour_12, hour_13, hour_14, hour_15, hour_16, hour_17, hour_18, hour_19, hour_2, hour_20, hour_21, hour_22, hour_23, hour_3, hour_4, hour_5, hour_6, hour_7, hour_8, hour_9
Data Transformations
- Transformer for series: MinMaxScaler()
- Transformer for exog: MinMaxScaler()
Training Information
- Series names: so2, co, no, no2, pm10, nox, o3, veloc., direc., pm2.5
- Target series (levels): ['o3', 'pm2.5', 'pm10']
- Training range: [Timestamp('2019-01-01 00:00:00'), Timestamp('2021-09-30 23:00:00')]
- Training index type: DatetimeIndex
- Training index frequency: h
Estimator Parameters
-
{'name': 'functional_4', 'trainable': True, 'layers': [{'module': 'keras.layers', 'class_name': 'InputLayer', 'config': {'batch_shape': (None, 5, 10), 'dtype': 'float32', 'sparse': False, 'ragged': False, 'name': 'series_input', 'optional': False}, 'registered_name': None, 'name': 'series_input', 'inbound_nodes': []}, {'module': 'keras.layers', 'class_name': 'GRU', 'config': {'name': 'gru_1', 'trainable': True, 'dtype': {'module': 'keras', 'class_name': 'DTypePolicy', 'config': {'name': 'float32'}, 'registered_name': None}, 'return_sequences': False, 'return_state': False, 'go_backwards': False, 'stateful': False, 'unroll': False, 'zero_output_for_mask': False, 'units': 64, 'activation': 'tanh', 'recurrent_activation': 'sigmoid', 'use_bias': True, 'kernel_initializer': {'module': 'keras.initializers', 'class_name': 'GlorotUniform', 'config': {'seed': None}, 'registered_name': None}, 'recurrent_initializer': {'module': 'keras.initializers', 'class_name': 'Orthogonal', 'config': {'seed': None, 'gain': 1.0}, 'registered_name': None}, 'bias_initializer': {'module': 'keras.initializers', 'class_name': 'Zeros', 'config': {}, 'registered_name': None}, 'kernel_regularizer': None, 'recurrent_regularizer': None, 'bias_regularizer': None, 'activity_regularizer': None, 'kernel_constraint': None, 'recurrent_constraint': None, 'bias_constraint': None, 'dropout': 0.0, 'recurrent_dropout': 0.0, 'reset_after': True, 'seed': None}, 'registered_name': None, 'build_config': {'input_shape': [None, 5, 10]}, 'name': 'gru_1', 'inbound_nodes': [{'args': ({'class_name': '__keras_tensor__', 'config': {'shape': (None, 5, 10), 'dtype': 'float32', 'keras_history': ['series_input', 0, 0]}},), 'kwargs': {'training': False, 'mask': None}}]}, {'module': 'keras.layers', 'class_name': 'RepeatVector', 'config': {'name': 'repeat_vector', 'trainable': True, 'dtype': {'module': 'keras', 'class_name': 'DTypePolicy', 'config': {'name': 'float32'}, 'registered_name': None}, 'n': 4}, 'registered_name': None, 'build_config': {'input_shape': [None, 64]}, 'name': 'repeat_vector', 'inbound_nodes': [{'args': ({'class_name': '__keras_tensor__', 'config': {'shape': (None, 64), 'dtype': 'float32', 'keras_history': ['gru_1', 0, 0]}},), 'kwargs': {}}]}, {'module': 'keras.layers', 'class_name': 'InputLayer', 'config': {'batch_shape': (None, 4, 29), 'dtype': 'float32', 'sparse': False, 'ragged': False, 'name': 'exog_input', 'optional': False}, 'registered_name': None, 'name': 'exog_input', 'inbound_nodes': []}, {'module': 'keras.layers', 'class_name': 'Concatenate', 'config': {'name': 'concat_exog', 'trainable': True, 'dtype': {'module': 'keras', 'class_name': 'DTypePolicy', 'config': {'name': 'float32'}, 'registered_name': None}, 'axis': -1}, 'registered_name': None, 'build_config': {'input_shape': [[None, 4, 64], [None, 4, 29]]}, 'name': 'concat_exog', 'inbound_nodes': [{'args': ([{'class_name': '__keras_tensor__', 'config': {'shape': (None, 4, 64), 'dtype': 'float32', 'keras_history': ['repeat_vector', 0, 0]}}, {'class_name': '__keras_tensor__', 'config': {'shape': (None, 4, 29), 'dtype': 'float32', 'keras_history': ['exog_input', 0, 0]}}],), 'kwargs': {}}]}, {'module': 'keras.layers', 'class_name': 'TimeDistributed', 'config': {'name': 'dense_td_1', 'trainable': True, 'dtype': {'module': 'keras', 'class_name': 'DTypePolicy', 'config': {'name': 'float32'}, 'registered_name': None}, 'layer': {'module': 'keras.layers', 'class_name': 'Dense', 'config': {'name': 'dense_9', 'trainable': True, 'dtype': {'module': 'keras', 'class_name': 'DTypePolicy', 'config': {'name': 'float32'}, 'registered_name': None}, 'units': 32, 'activation': 'relu', 'use_bias': True, 'kernel_initializer': {'module': 'keras.initializers', 'class_name': 'GlorotUniform', 'config': {'seed': None}, 'registered_name': None}, 'bias_initializer': {'module': 'keras.initializers', 'class_name': 'Zeros', 'config': {}, 'registered_name': None}, 'kernel_regularizer': None, 'bias_regularizer': None, 'kernel_constraint': None, 'bias_constraint': None, 'quantization_config': None}, 'registered_name': None, 'build_config': {'input_shape': [None, 93]}}}, 'registered_name': None, 'build_config': {'input_shape': [None, 4, 93]}, 'name': 'dense_td_1', 'inbound_nodes': [{'args': ({'class_name': '__keras_tensor__', 'config': {'shape': (None, 4, 93), 'dtype': 'float32', 'keras_history': ['concat_exog', 0, 0]}},), 'kwargs': {'mask': None}}]}, {'module': 'keras.layers', 'class_name': 'TimeDistributed', 'config': {'name': 'output_dense_td_layer', 'trainable': True, 'dtype': {'module': 'keras', 'class_name': 'DTypePolicy', 'config': {'name': 'float32'}, 'registered_name': None}, 'layer': {'module': 'keras.layers', 'class_name': 'Dense', 'config': {'name': 'dense_10', 'trainable': True, 'dtype': {'module': 'keras', 'class_name': 'DTypePolicy', 'config': {'name': 'float32'}, 'registered_name': None}, 'units': 3, 'activation': 'linear', 'use_bias': True, 'kernel_initializer': {'module': 'keras.initializers', 'class_name': 'GlorotUniform', 'config': {'seed': None}, 'registered_name': None}, 'bias_initializer': {'module': 'keras.initializers', 'class_name': 'Zeros', 'config': {}, 'registered_name': None}, 'kernel_regularizer': None, 'bias_regularizer': None, 'kernel_constraint': None, 'bias_constraint': None, 'quantization_config': None}, 'registered_name': None, 'build_config': {'input_shape': [None, 32]}}}, 'registered_name': None, 'build_config': {'input_shape': [None, 4, 32]}, 'name': 'output_dense_td_layer', 'inbound_nodes': [{'args': ({'class_name': '__keras_tensor__', 'config': {'shape': (None, 4, 32), 'dtype': 'float32', 'keras_history': ['dense_td_1', 0, 0]}},), 'kwargs': {'mask': None}}]}], 'input_layers': [['series_input', 0, 0], ['exog_input', 0, 0]], 'output_layers': ['output_dense_td_layer', 0, 0]}
Compile Parameters
-
{'optimizer': {'module': 'keras.src.backend.torch.optimizers.torch_adam', 'class_name': 'Adam', 'config': {'name': 'adam', 'learning_rate': 0.0010000000474974513, 'weight_decay': None, 'clipnorm': None, 'global_clipnorm': None, 'clipvalue': None, 'use_ema': False, 'ema_momentum': 0.99, 'ema_overwrite_frequency': None, 'loss_scale_factor': None, 'gradient_accumulation_steps': None, 'beta_1': 0.9, 'beta_2': 0.999, 'epsilon': 1e-07, 'amsgrad': False}, 'registered_name': 'Adam'}, 'loss': {'module': 'keras.losses', 'class_name': 'MeanSquaredError', 'config': {'name': 'mean_squared_error', 'reduction': 'sum_over_batch_size'}, 'registered_name': None}, 'loss_weights': None, 'metrics': None, 'weighted_metrics': None, 'run_eagerly': False, 'steps_per_execution': 1, 'jit_compile': False}
Fit Kwargs
-
{'verbose': 0}
The ForecasterRnn provides the method create_train_X_y, which allows you to directly extract the matrices used for training. This can be very helpful for debugging, feature engineering, or advanced analysis. This method returns four elements:
X_train: Training values (predictors) for each step. The resulting array has 3 dimensions: (
n_observations,n_lags,n_series)exog_train: Value of exogenous variables (if any) aligned with
X_train. (n_observations,n_steps,n_exog)y_train: Values (target) of the time series related to each row of
X_train. The resulting array has 3 dimensions: (n_observations,n_steps,n_levels)dimension_names: A dictionary with the labels (names) for each axis in the arrays, helping you interpret which dimension corresponds to lags, features, steps, etc.
# Extract training info
# ==============================================================================
X_train, exog_train, y_train, dimension_names = forecaster.create_train_X_y(
series=data_train[series], exog=data_train[exog_features]
)
# Check the shape of the training data
# ==============================================================================
print(f"X_train shape : {X_train.shape} --- (train index, lags, predictors)")
print(
f"exog_train shape : {exog_train.shape} --- "
f"(train index, steps, exogenous variables)"
)
print(f"y_train shape : {y_train.shape} --- (train index, steps, levels)")
print(f"dimension_names keys : {list(dimension_names.keys())}")
X_train shape : (24088, 5, 10) --- (train index, lags, predictors) exog_train shape : (24088, 4, 29) --- (train index, steps, exogenous variables) y_train shape : (24088, 4, 3) --- (train index, steps, levels) dimension_names keys : ['X_train', 'y_train', 'exog_train']
# X_train dimension names
# ==============================================================================
dimension_names['X_train'] # (train index, lags, predictors)
{0: DatetimeIndex(['2019-01-01 05:00:00', '2019-01-01 06:00:00',
'2019-01-01 07:00:00', '2019-01-01 08:00:00',
'2019-01-01 09:00:00', '2019-01-01 10:00:00',
'2019-01-01 11:00:00', '2019-01-01 12:00:00',
'2019-01-01 13:00:00', '2019-01-01 14:00:00',
...
'2021-09-30 11:00:00', '2021-09-30 12:00:00',
'2021-09-30 13:00:00', '2021-09-30 14:00:00',
'2021-09-30 15:00:00', '2021-09-30 16:00:00',
'2021-09-30 17:00:00', '2021-09-30 18:00:00',
'2021-09-30 19:00:00', '2021-09-30 20:00:00'],
dtype='datetime64[ns]', name='datetime', length=24088, freq='h'),
1: ['lag_5', 'lag_4', 'lag_3', 'lag_2', 'lag_1'],
2: ['so2',
'co',
'no',
'no2',
'pm10',
'nox',
'o3',
'veloc.',
'direc.',
'pm2.5']}
# exog_train dimension names
# ==============================================================================
dimension_names['exog_train'] # (train index, steps, exogenous variables)
{0: DatetimeIndex(['2019-01-01 05:00:00', '2019-01-01 06:00:00',
'2019-01-01 07:00:00', '2019-01-01 08:00:00',
'2019-01-01 09:00:00', '2019-01-01 10:00:00',
'2019-01-01 11:00:00', '2019-01-01 12:00:00',
'2019-01-01 13:00:00', '2019-01-01 14:00:00',
...
'2021-09-30 11:00:00', '2021-09-30 12:00:00',
'2021-09-30 13:00:00', '2021-09-30 14:00:00',
'2021-09-30 15:00:00', '2021-09-30 16:00:00',
'2021-09-30 17:00:00', '2021-09-30 18:00:00',
'2021-09-30 19:00:00', '2021-09-30 20:00:00'],
dtype='datetime64[ns]', name='datetime', length=24088, freq='h'),
1: ['step_1', 'step_2', 'step_3', 'step_4'],
2: ['day_of_week_1',
'day_of_week_2',
'day_of_week_3',
'day_of_week_4',
'day_of_week_5',
'day_of_week_6',
'hour_1',
'hour_10',
'hour_11',
'hour_12',
'hour_13',
'hour_14',
'hour_15',
'hour_16',
'hour_17',
'hour_18',
'hour_19',
'hour_2',
'hour_20',
'hour_21',
'hour_22',
'hour_23',
'hour_3',
'hour_4',
'hour_5',
'hour_6',
'hour_7',
'hour_8',
'hour_9']}
# y_train dimension names
# ==============================================================================
dimension_names['y_train'] # (train index, steps, levels)
{0: DatetimeIndex(['2019-01-01 05:00:00', '2019-01-01 06:00:00',
'2019-01-01 07:00:00', '2019-01-01 08:00:00',
'2019-01-01 09:00:00', '2019-01-01 10:00:00',
'2019-01-01 11:00:00', '2019-01-01 12:00:00',
'2019-01-01 13:00:00', '2019-01-01 14:00:00',
...
'2021-09-30 11:00:00', '2021-09-30 12:00:00',
'2021-09-30 13:00:00', '2021-09-30 14:00:00',
'2021-09-30 15:00:00', '2021-09-30 16:00:00',
'2021-09-30 17:00:00', '2021-09-30 18:00:00',
'2021-09-30 19:00:00', '2021-09-30 20:00:00'],
dtype='datetime64[ns]', name='datetime', length=24088, freq='h'),
1: ['step_1', 'step_2', 'step_3', 'step_4'],
2: ['o3', 'pm2.5', 'pm10']}
We can obtain the training predictions using the predict method of the estimator stored inside the forecaster object. By examining the predictions on the training data, analysts can get a better understanding of how the model is performing and make adjustments as necessary.
# Training predictions using the internal estimator
# ==============================================================================
forecaster.estimator.predict(
x=X_train if exog_train is None else [X_train, exog_train], verbose=0
)[:3]
array([[[-0.00338835, 0.2571897 , 0.11807781],
[-0.02572712, 0.24238151, 0.10049161],
[-0.03643352, 0.26339644, 0.10169086],
[ 0.03926271, 0.24475446, 0.13603175]],
[[-0.01903319, 0.21007502, 0.09058783],
[-0.02943336, 0.23089173, 0.09210306],
[ 0.03560244, 0.22066787, 0.12366364],
[ 0.1243579 , 0.19673392, 0.10568984]],
[[ 0.00463324, 0.162771 , 0.07199315],
[ 0.0512383 , 0.16534695, 0.09965479],
[ 0.14150171, 0.14230084, 0.0861972 ],
[ 0.21637197, 0.14606792, 0.06289192]]], dtype=float32)
Skforecast provides the create_predict_X method to generate the matrices that the forecaster is using to make predictions. This method can be used to gain insight into the specific data manipulations that occur during the prediction process.
# Create input matrix for predict method
# ==============================================================================
X_predict, exog_predict = forecaster.create_predict_X(
steps=None, exog=data_test[exog_features]
)
X_predict # (None, lags, predictors)
╭───────────────────────────── DataTransformationWarning ──────────────────────────────╮ │ The output matrix is in the transformed scale due to the inclusion of │ │ transformations in the Forecaster. As a result, any predictions generated using this │ │ matrix will also be in the transformed scale. Please refer to the documentation for │ │ more details: │ │ https://skforecast.org/latest/user_guides/training-and-prediction-matrices.html │ │ │ │ Category : skforecast.exceptions.DataTransformationWarning │ │ Location : │ │ /Users/javier.escobar/code/github/skforecast/skforecast/deep_learning/_forecaster_rn │ │ n.py:1516 │ │ Suppress : warnings.simplefilter('ignore', category=DataTransformationWarning) │ ╰──────────────────────────────────────────────────────────────────────────────────────╯
| so2 | co | no | no2 | pm10 | nox | o3 | veloc. | direc. | pm2.5 | |
|---|---|---|---|---|---|---|---|---|---|---|
| lag_5 | 0.000000 | 0.000000 | 0.003086 | 0.054054 | 0.037383 | 0.015517 | 0.551471 | 0.062500 | 0.169444 | 0.074074 |
| lag_4 | 0.055556 | 0.000000 | 0.003086 | 0.054054 | 0.040498 | 0.015517 | 0.536765 | 0.046875 | 0.130556 | 0.083333 |
| lag_3 | 0.055556 | 0.006494 | 0.003086 | 0.060811 | 0.040498 | 0.018966 | 0.441176 | 0.046875 | 0.102778 | 0.074074 |
| lag_2 | 0.111111 | 0.012987 | 0.003086 | 0.074324 | 0.031153 | 0.022414 | 0.419118 | 0.015625 | 0.105556 | 0.064815 |
| lag_1 | 0.111111 | 0.019481 | 0.003086 | 0.074324 | 0.040498 | 0.022414 | 0.389706 | 0.015625 | 0.047222 | 0.083333 |
# Input matrix for predict method exogenous variables
# ==============================================================================
exog_predict # (None, steps, exogenous variables)
| day_of_week_1 | day_of_week_2 | day_of_week_3 | day_of_week_4 | day_of_week_5 | day_of_week_6 | hour_1 | hour_10 | hour_11 | hour_12 | ... | hour_21 | hour_22 | hour_23 | hour_3 | hour_4 | hour_5 | hour_6 | hour_7 | hour_8 | hour_9 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| step_1 | 0.0 | 0.0 | 0.0 | 1.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | ... | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| step_2 | 0.0 | 0.0 | 0.0 | 1.0 | 0.0 | 0.0 | 1.0 | 0.0 | 0.0 | 0.0 | ... | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| step_3 | 0.0 | 0.0 | 0.0 | 1.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | ... | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
| step_4 | 0.0 | 0.0 | 0.0 | 1.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | ... | 0.0 | 0.0 | 0.0 | 1.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 |
4 rows × 29 columns