Más sobre forecasting en cienciadedatos.net
- Forecasting series temporales con machine learning
- Modelos ARIMA y SARIMAX
- Forecasting series temporales con gradient boosting: XGBoost, LightGBM y CatBoost
- Forecasting series temporales con XGBoost
- Global Forecasting: Multi-series forecasting
- Forecasting de la demanda eléctrica con machine learning
- Modelos de forecasting globales: Análisis comparativo de modelos de una y múltiples series
- Forecasting con deep learning
- Forecasting de visitas a página web con machine learning
- Forecasting del precio de Bitcoin
- Forecasting probabilístico
- Forecasting de demanda intermitente
- Reducir el impacto del Covid en modelos de forecasting
- Modelar series temporales con tendencia utilizando modelos de árboles
Introducción¶
En casos de uso en los que se necesita predecir cientos o miles de series temporales ¿se debe desarrollar un modelo individual para cada serie o un único modelo capaz de predecir todas las series a la vez?
En los modelo de forecasting locales, se crea un modelo independiente para cada serie temporal. Aunque este método proporciona una comprensión exhaustiva de cada serie, su escalabilidad puede verse dificultada por la necesidad de crear y mantener cientos o miles de modelos.
El modelo de forecasting global consiste en crear un único modelo que tenga en cuenta todas las series temporales simultáneamente. Intenta captar los patrones comunes que rigen las series, mitigando así el ruido que pueda introducir cada serie. Este enfoque es eficiente desde el punto de vista computacional, fácil de mantener y puede producir generalizaciones más sólidas, aunque potencialmente a costa de sacrificar algunos aspectos individuales.
Para ayudar a comprender las ventajas de cada estrategia de forecasting, este documento examina y compara los resultados obtenidos al predecir el consumo energético de más de mil edificios utilizando el conjunto de datos ASHRAE - Great Energy Predictor III disponible en Kaggle.
El forecasting con modelos globales parte de la base de que las series que se comportan de forma similar pueden beneficiarse de ser modelizadas conjuntamente. Aunque el uso principal de cada edificio está disponible en el conjunto de datos, puede que no refleje grupos con patrones similares de consumo de energía, por lo que se crean grupos adicionales utilizando métodos de clustering. Se realizan un total de 5 experimentos:
Forecasting individual de cada edificio.
Forecasting de todos los edificios juntos con una única estrategia de modelo global.
Forecasting de grupos de edificios en función de su uso principal (un modelo global por uso principal).
Forecasting de grupos de edificios basada en la agrupación de características de series temporales (un modelo global por agrupación).
Forecasting de grupos de edificios basada en la agrupación por Dynamic Time Warping (DTW) (un modelo global por agrupación).
El consumo de energía de cada edificio se predice semanalmente (resolución diaria) durante 13 semanas siguiendo cada estrategia. La eficacia de cada enfoque se evalúa utilizando varias métricas de rendimiento, como el error absoluto medio (MAE), el error absoluto y el sesgo. El objetivo del estudio es identificar el enfoque más eficaz tanto para las predicciones globales como para un grupo específico de edificios.
💡 Tip
Este documento forma parte de una serie sobre modelos de forecasting globales:
- Modelos de forecasting globales: modelado de múltiples series temporales con machine learning
- Forecasting escalable: modelado de mil de series temporales con un único modelo global
- Modelos de forecasting globales: Análisis comparativo de modelos de una y múltiples series
- Modelos de forecasting globales: Guía paso a paso con Kaggle Sticker Sales
✏️ Note
Si prefieres una visión general rápida antes de sumergirte en los detalles, considera comenzar con la sección de Conclusiones. Este enfoque te permite adaptar tu lectura a tus intereses y limitaciones de tiempo, y resume nuestros hallazgos e ideas. Después de leer las conclusiones, es posible que encuentres ciertas secciones particularmente relevantes o interesantes. Siéntete libre de navegar directamente a esas partes del artículo para una comprensión más profunda.
Librerías¶
Librerías utilizadas en este documento.
# Manipulación de datos
# ==============================================================================
import numpy as np
import pandas as pd
from datetime import datetime
from skforecast.datasets import fetch_dataset
from tqdm.auto import tqdm
# Gráficos
# ==============================================================================
import matplotlib.pyplot as plt
import matplotlib.ticker as ticker
from skforecast.plot import set_dark_theme
# Forecasting
# ==============================================================================
import lightgbm
from lightgbm import LGBMRegressor
import skforecast
from skforecast.recursive import ForecasterRecursive, ForecasterRecursiveMultiSeries
from skforecast.model_selection import (
backtesting_forecaster,
TimeSeriesFold,
backtesting_forecaster_multiseries
)
from skforecast.preprocessing import (
CalendarFeatures,
reshape_series_long_to_dict,
reshape_exog_long_to_dict
)
# Feature engineering
# ==============================================================================
import tsfresh
from tsfresh import extract_features, select_features
from tsfresh.feature_extraction.settings import ComprehensiveFCParameters, from_columns
# Clustering
# ==============================================================================
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA
from sklearn.cluster import KMeans
from sktime.clustering.k_means import TimeSeriesKMeans
# Warnings
# ==============================================================================
import warnings
warnings.filterwarnings('once')
warnings.simplefilter('ignore', category=skforecast.exceptions.InputTypeWarning)
color = '\033[1m\033[38;5;208m'
print(f"{color}Versión skforecast: {skforecast.__version__}")
print(f"{color}Versión lightgbm: {lightgbm.__version__}")
Versión skforecast: 0.25.0 Versión lightgbm: 4.7.0
Datos¶
Los datos utilizados en este documento se han obtenido de la competición de Kaggle Addison Howard, Chris Balbach, Clayton Miller, Jeff Haberl, Krishnan Gowri, Sohier Dane. (2019). ASHRAE - Great Energy Predictor III. Kaggle.
Tres archivos se utilizan para crear el conjunto de datos de modelado:
weather_train.csv y weather_test.csv: Estos archivos contienen datos relacionados con el clima para cada edificio, incluida la temperatura del aire exterior, la temperatura de rocío, la humedad relativa y otros parámetros meteorológicos. Los datos meteorológicos son cruciales para comprender el impacto de las condiciones externas en el uso de energía de los edificios.
building_metadata.csv: este archivo proporciona metadatos para cada edificio en el conjunto de datos, como el tipo de edificio, el uso principal, el metraje cuadrado, el número de pisos y el año de construcción. Esta información ayuda a comprender las características de los edificios y su posible influencia en los patrones de consumo de energía.
train.csv: el conjunto de datos de entrenamiento contiene la variable objetivo, es decir, los datos de consumo de energía para cada edificio, junto con las fechas de las lecturas de consumo de energía. También incluye los identificadores de edificio y clima correspondientes para vincular la información en diferentes conjuntos de datos.
Los tres archivos se han preprocesado para eliminar los edificios con menos del 85% de valores diferentes de NaN o cero, para utilizar solo el medidor de electricidad y para agregar los datos a una frecuencia diaria.
# Lectura de datos
# ==============================================================================
data = fetch_dataset('ashrae_daily')
data.head(3)
╭────────────────────────────────── ashrae_daily ──────────────────────────────────╮ │ Description: │ │ Daily energy consumption data from the ASHRAE competition with building metadata │ │ and weather data. │ │ │ │ Source: │ │ Kaggle competition Addison Howard, Chris Balbach, Clayton Miller, Jeff Haberl, │ │ Krishnan Gowri, Sohier Dane. (2019). ASHRAE - Great Energy Predictor III. │ │ Kaggle. https://www.kaggle.com/c/ashrae-energy-prediction/overview │ │ │ │ URL: │ │ https://huggingface.co/datasets/skforecast/ashrae_daily/resolve/main/ashrae_dail │ │ y.parquet │ │ │ │ Shape: 444324 rows x 10 columns │ ╰──────────────────────────────────────────────────────────────────────────────────╯
| building_id | meter_reading | site_id | primary_use | square_feet | air_temperature | dew_temperature | sea_level_pressure | wind_direction | wind_speed | |
|---|---|---|---|---|---|---|---|---|---|---|
| timestamp | ||||||||||
| 2016-01-01 | id_105 | 1102.766933 | 1 | Education | 50623 | 3.8 | 2.4 | 1020.9 | 240.0 | 3.1 |
| 2016-01-01 | id_106 | 17.606200 | 1 | Education | 5374 | 3.8 | 2.4 | 1020.9 | 240.0 | 3.1 |
| 2016-01-01 | id_107 | 8233.625107 | 1 | Education | 97532 | 3.8 | 2.4 | 1020.9 | 240.0 | 3.1 |
# Asegurar que el indice de todas las series está completo (sin huecos)
# ==============================================================================
data = (
data
.groupby('building_id')
.apply(lambda group: group.asfreq('D', fill_value=np.nan), include_groups=False)
.reset_index()
.set_index('timestamp')
)
# Imputar valores faltantes de air_temperature y wind_speed usando fowrard y backward fill
# ==============================================================================
# La imputación debe hacerse por separado para cada edificio
data = data.sort_values(by=['building_id', 'timestamp'])
data['air_temperature'] = data.groupby('building_id')['air_temperature'].ffill().bfill()
data['wind_speed'] = data.groupby('building_id')['wind_speed'].ffill().bfill()
data = data.sort_index()
print(
f"Rango de fechas disponibles : {data.index.min()} --- {data.index.max()} "
f"(n_días={(data.index.max() - data.index.min()).days})"
)
Rango de fechas disponibles : 2016-01-01 00:00:00 --- 2016-12-31 00:00:00 (n_días=365)
Análisis exploratorio¶
Uso principal de los edificios¶
Uno de los atributos clave asociados con cada edificio es su uso designado. Esta característica puede desempeñar un papel crucial en la influencia del patrón de consumo de energía, ya que los usos distintos pueden impactar significativamente tanto en la cantidad como en el momento del consumo de energía.
# Número de edificios y tipo de edificios basado en el uso principal
# ==============================================================================
n_building = data['building_id'].nunique()
n_type_building = data['primary_use'].nunique()
print(f"Número de edificios: {n_building}")
print(f"Número de tipos de edificios: {n_type_building}")
display(data.drop_duplicates(subset=['building_id'])['primary_use'].value_counts())
Número de edificios: 1214 Número de tipos de edificios: 16
primary_use Education 463 Office 228 Entertainment/public assembly 157 Public services 150 Lodging/residential 112 Other 19 Healthcare 19 Parking 13 Warehouse/storage 12 Manufacturing/industrial 10 Services 9 Food sales and service 5 Technology/science 5 Retail 5 Utility 4 Religious worship 3 Name: count, dtype: int64
Para algunas categorías de uso principal, hay un número limitado de edificios dentro del conjunto de datos. Para simplificar el análisis, las categorías con menos de 100 edificios se agrupan en la categoría "Other".
# Tipos de edificios (primary use) con menos de 100 muestras se agrupan como "Other".
# ==============================================================================
infrequent_categories = (
data
.drop_duplicates(subset=['building_id'])['primary_use']
.value_counts()
.loc[lambda x: x < 100]
.index
.tolist()
)
print("Categorias poco frecuentes:")
print("===========================")
print('\n'.join(infrequent_categories))
data['primary_use'] = np.where(
data['primary_use'].isin(infrequent_categories),
'Other',
data['primary_use']
)
Categorias poco frecuentes: =========================== Other Healthcare Parking Warehouse/storage Manufacturing/industrial Services Food sales and service Technology/science Retail Utility Religious worship
A continuación, se crea un gráfico que muestra el consumo de energía para un edificio seleccionado al azar dentro de cada categoría respectiva, y un gráfico de todas las series temporales disponibles para cada categoría.
# Series temporales para 1 edificio seleccionado al azar por grupo
# ==============================================================================
set_dark_theme()
fig, axs = plt.subplots(nrows=3, ncols=2, figsize=(8, 5.5), sharex=True, sharey=False)
sample_ids = (
data
.groupby('primary_use')['building_id']
.apply(lambda x: x.sample(1, random_state=333))
.tolist()
)
axs = axs.flatten()
for i, building_id in enumerate(sample_ids):
data_sample = data[data['building_id'] == building_id]
building_type = data_sample['primary_use'].unique()[0]
data_sample.plot(
y = 'meter_reading',
ax = axs[i],
legend = False,
title = f"Edificio: {building_id}, tipo: {building_type}",
fontsize = 8
)
axs[i].set_xlabel("")
axs[i].set_ylabel("")
# Scientific notation for y axis
axs[i].ticklabel_format(axis='y', style='sci', scilimits=(0, 0))
axs[i].title.set_size(9)
fig.suptitle('Consumo de energía para 6 edificios aleatorios', fontsize=12)
fig.tight_layout()
plt.show()
⚠️ Warning
Si bien se dispone de 1214 edificios, para mantener el entrenamiento del modelo dentro de un rango de tiempo razonable, se puede utilizar un subconjunto de, por ejemplo, 600 edificios seleccionados al azar. Se anima al lector a adaptar el número de edificios si es necesario y comprobar si las conclusiones se mantienen.
# Muestra de 600 edificios
# ==============================================================================
rng = np.random.default_rng(12345)
buildings = data['building_id'].unique()
buildings_selected = rng.choice(
buildings,
size = 600,
replace = False
)
data = data.query("building_id in @buildings_selected")
Clustering por patrón de consumo energético¶
La idea detrás de modelar múltiples series de forma conjunta es poder capturar los patrones principales que rigen las series, reduciendo así el impacto del ruido que cada serie pueda tener. Esto significa que las series que se comportan de manera similar pueden beneficiarse de ser modelizadas juntas. Una forma de identificar posibles grupos de series es realizar un estudio de clustering antes de modelizar. Si como resultado del clustering se identifican grupos claros, es apropiado modelar cada uno de ellos por separado.
El clustering es una técnica de análisis no supervisado que agrupa un conjunto de observaciones en clústeres que contienen observaciones consideradas homogéneas, mientras que las observaciones en diferentes clústeres se consideran heterogéneas. Los algoritmos que agrupan series temporales se pueden dividir en dos grupos: aquellos que utilizan una transformación para crear variables antes de agrupar (clustering de series temporales basado en características) y aquellos que trabajan directamente en las series temporales (medidas de distancia elástica).
Clustering basado en características de series temporales: Se extraen varaibles que describen las características estructurales de cada serie temporal, y luego estas variables se introducen en algoritmos de clustering. Estas variables se obtienen aplicando operaciones estadísticas que capturan mejor las características subyacentes: tendencia, estacionalidad, periodicidad, correlación serial, asimetría, curtosis, caos, no linealidad y auto-similitud.
Medidas de distancia elástica: Este enfoque trabaja directamente en las series temporales, ajustando o "reajustando" las series en comparación con otras. La más conocida de esta familia de medidas es Dynamic Time Warping (DTW).
Para información más detallada sobre el clustering de series temporales, consulta A review and evaluation of elastic distance functions for time series clustering.
⚠️ Warning
Ambos enfoques de clustering utilizados a continuación (características de las series temporales y DTW) se calculan utilizando la serie observada completa de cada edificio, es decir, el año completo, incluyendo el periodo de agosto a diciembre que después se utiliza como ventana de test en el backtesting. Esta es una simplificación con fines de benchmarking: las asignaciones de clúster resultantes, utilizadas para decidir qué edificios se modelizan conjuntamente, están informadas por datos que no estarían disponibles en el momento de una predicción real. En un pipeline de producción, los clústeres deberían formarse utilizando únicamente los datos disponibles hasta el corte de entrenamiento, para evitar este tipo de sesgo de anticipación (lookahead bias).
Clustering basado en características de las series¶
Extracción de variables¶
tsfresh es una librería de Python para extraer variables de series temporales y datos secuenciales, que incluye medidas estadísticas, coeficientes de Fourier y otras variables en el dominio del tiempo y de la frecuencia. Proporciona un enfoque sistemático para automatizar el cálculo de variables y seleccionar las más informativas.
Para empezar, se utiliza la configuración predeterminada de tsfresh, que calcula todas las variables disponibles. La forma más sencilla de acceder a esta configuración es utilizar las funciones proporcionadas por la clase ComprehensiveFCParameters. Esta función crea un diccionario que asocia el nombre de cada característica a una lista de parámetros que se utilizarán cuando se llame a la función con ese nombre.
# Variables por defecto
# ==============================================================================
default_features = ComprehensiveFCParameters()
print("Nombre de las variables extraídas por tsfresh")
print("=============================================")
print('\n'.join(list(default_features)))
Nombre de las variables extraídas por tsfresh ============================================= variance_larger_than_standard_deviation has_duplicate_max has_duplicate_min has_duplicate sum_values abs_energy mean_abs_change mean_change mean_second_derivative_central median mean length standard_deviation variation_coefficient variance skewness kurtosis root_mean_square absolute_sum_of_changes longest_strike_below_mean longest_strike_above_mean count_above_mean count_below_mean last_location_of_maximum first_location_of_maximum last_location_of_minimum first_location_of_minimum percentage_of_reoccurring_values_to_all_values percentage_of_reoccurring_datapoints_to_all_datapoints sum_of_reoccurring_values sum_of_reoccurring_data_points ratio_value_number_to_time_series_length sample_entropy maximum absolute_maximum minimum benford_correlation time_reversal_asymmetry_statistic c3 cid_ce symmetry_looking large_standard_deviation quantile autocorrelation agg_autocorrelation partial_autocorrelation number_cwt_peaks number_peaks binned_entropy index_mass_quantile cwt_coefficients spkt_welch_density ar_coefficient change_quantiles fft_coefficient fft_aggregated value_count range_count approximate_entropy friedrich_coefficients max_langevin_fixed_point linear_trend agg_linear_trend augmented_dickey_fuller number_crossing_m energy_ratio_by_chunks ratio_beyond_r_sigma linear_trend_timewise count_above count_below lempel_ziv_complexity fourier_entropy permutation_entropy query_similarity_count mean_n_absolute_max
Muchas de las variables se calculan utilizando diferentes valores para sus argumentos.
# Configuración por defecto para "partial_autocorrelation"
# ==============================================================================
default_features['partial_autocorrelation']
[{'lag': 0},
{'lag': 1},
{'lag': 2},
{'lag': 3},
{'lag': 4},
{'lag': 5},
{'lag': 6},
{'lag': 7},
{'lag': 8},
{'lag': 9}]
Para acceder a la vista detallada de cada característica y los valores de los parámetros incluidos en la configuración predeterminada, utilice el siguiente código:
# Configuración por defecto para todas las variables
# ==============================================================================
# Descomentar para ver la configuración completa (salida muy larga)
# from pprint import pprint
# pprint(default_features)
Una vez que se ha definido la configuración de las variables, el siguiente paso es extraerlas de las series temporales. Para ello, se utiliza la función extract_features() de tsfresh. Esta función recibe como entrada la o las series temporales y la configuración de las variables a extraer. La salida es un dataframe con las variables extraídas.
# Extracción de variables
# ==============================================================================
ts_features = extract_features(
timeseries_container = data[['building_id', 'meter_reading']].reset_index(),
column_id = "building_id",
column_sort = "timestamp",
column_value = "meter_reading",
default_fc_parameters = default_features,
impute_function = tsfresh.utilities.dataframe_functions.impute,
n_jobs = 4
)
print("Dimensiones de ts_features:", ts_features.shape)
ts_features.head(3)
Dimensiones de ts_features: (600, 783)
| meter_reading__variance_larger_than_standard_deviation | meter_reading__has_duplicate_max | meter_reading__has_duplicate_min | meter_reading__has_duplicate | meter_reading__sum_values | meter_reading__abs_energy | meter_reading__mean_abs_change | meter_reading__mean_change | meter_reading__mean_second_derivative_central | meter_reading__median | ... | meter_reading__fourier_entropy__bins_5 | meter_reading__fourier_entropy__bins_10 | meter_reading__fourier_entropy__bins_100 | meter_reading__permutation_entropy__dimension_3__tau_1 | meter_reading__permutation_entropy__dimension_4__tau_1 | meter_reading__permutation_entropy__dimension_5__tau_1 | meter_reading__permutation_entropy__dimension_6__tau_1 | meter_reading__permutation_entropy__dimension_7__tau_1 | meter_reading__query_similarity_count__query_None__threshold_0.0 | meter_reading__mean_n_absolute_max__number_of_maxima_7 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| id_1001 | 1.0 | 0.0 | 0.0 | 1.0 | 2.543133e+05 | 4.114736e+08 | 212.298183 | -0.049314 | 0.026097 | 304.500150 | ... | 0.090729 | 0.090729 | 0.700821 | 1.752526 | 3.038890 | 4.327469 | 5.278212 | 5.735413 | 0.0 | 2871.142857 |
| id_1003 | 1.0 | 0.0 | 0.0 | 0.0 | 7.423270e+05 | 1.561927e+09 | 218.155580 | -0.904797 | 0.782968 | 1996.688070 | ... | 0.235155 | 0.446547 | 1.719214 | 1.636071 | 2.696952 | 3.780121 | 4.766919 | 5.454277 | 0.0 | 3171.445826 |
| id_1004 | 1.0 | 0.0 | 1.0 | 1.0 | 3.275762e+06 | 3.185436e+10 | 1435.418191 | 0.328759 | -2.879118 | 9054.500992 | ... | 0.136002 | 0.245901 | 0.771605 | 1.637745 | 2.735159 | 3.797395 | 4.713989 | 5.339062 | 0.0 | 13217.571568 |
3 rows × 783 columns
Como resultado del proceso de extracción, se han creado 783 variables para cada serie temporal (building_id en este caso). El dataframe devuelto tiene como índice la columna especificada en el argumento column_id de extract_features.
La extracción por defecto de tsfresh genera un gran número de variables. Sin embargo, solo unas pocas pueden ser de interés en cada caso de uso. Para seleccionar las más relevantes, tsfresh incluye un proceso de selección automatizado basado en test de hipótesis (FeatuRE Extraction based on Scalable Hypothesis tests).
⚠️ Warning
El proceso de selección utilizado por tsfresh se basa en la importancia de cada característica para predecir con exactitud la variable objetivo. Para realizar este proceso, se necesita una variable objetivo, por ejemplo, el tipo de edificio asociado a una serie temporal determinada. Sin embargo, hay casos en los que no se dispone fácilmente de una variable objetivo. En tales casos, pueden utilizarse estrategias alternativas:
- En lugar de calcular todas las variables estándar, calcular sólo las que puedan ser relevantes para la aplicación específica, basándose en los conocimientos de los expertos.
- Excluir variables basadas en criterios como baja varianza y alta correlación. Esto ayuda a refinar el conjunto de variables a considerar, centrándose en aquellas que proporcionan la información más relevante para el análisis.
- Utilizar técnicas como PCA, t-SNE o autocodificadores para reducir la dimensionalidad.
Como la selección de variables es un paso crítico que afecta a la información disponible para los siguientes pasos del análisis, es recomendable entender los parámetros que controlan el comportamiento de select_features().
⚠️ Warning
El orden del índice devuelto en el dataframe de variables no es el mismo que el orden de las columnas en el dataframe original. Por lo tanto, los datos pasados al argumento y en select_features deben estar ordenados para garantizar la correcta asociación entre las variables y la variable objetivo.
# Seleccionar caracteristicas relevantes
# ==============================================================================
target = (
data[['building_id', 'primary_use']]
.drop_duplicates()
.set_index('building_id')
.loc[ts_features.index, :]
['primary_use']
)
assert ts_features.index.equals(target.index)
ts_features_selected = select_features(
X = ts_features,
y = target,
fdr_level = 0.001 # Un filtrado muy estricto
)
ts_features_selected.index.name = 'building_id'
print(f"Número de variables antes de la selección: {ts_features.shape[1]}")
print(f"Número de variables tras la selección: {ts_features_selected.shape[1]}")
ts_features_selected.head()
Número de variables antes de la selección: 783 Número de variables tras la selección: 262
| meter_reading__cwt_coefficients__coeff_13__w_2__widths_(2, 5, 10, 20) | meter_reading__time_reversal_asymmetry_statistic__lag_3 | meter_reading__fft_coefficient__attr_"imag"__coeff_14 | meter_reading__cwt_coefficients__coeff_12__w_2__widths_(2, 5, 10, 20) | meter_reading__fft_coefficient__attr_"real"__coeff_52 | meter_reading__change_quantiles__f_agg_"mean"__isabs_False__qh_0.4__ql_0.2 | meter_reading__cwt_coefficients__coeff_9__w_2__widths_(2, 5, 10, 20) | meter_reading__cwt_coefficients__coeff_8__w_2__widths_(2, 5, 10, 20) | meter_reading__cwt_coefficients__coeff_5__w_2__widths_(2, 5, 10, 20) | meter_reading__fft_coefficient__attr_"abs"__coeff_52 | ... | meter_reading__number_peaks__n_3 | meter_reading__ar_coefficient__coeff_1__k_10 | meter_reading__lempel_ziv_complexity__bins_5 | meter_reading__augmented_dickey_fuller__attr_"usedlag"__autolag_"AIC" | meter_reading__approximate_entropy__m_2__r_0.3 | meter_reading__ar_coefficient__coeff_2__k_10 | meter_reading__sample_entropy | meter_reading__lempel_ziv_complexity__bins_10 | meter_reading__mean_second_derivative_central | meter_reading__fft_coefficient__attr_"imag"__coeff_66 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| building_id | |||||||||||||||||||||
| id_1001 | 6.826138 | 3.442917e+07 | -13196.753777 | 12.030563 | -7628.816978 | -0.129015 | -3.912888 | -9.874489 | 9.502322 | 15873.642987 | ... | 39.0 | 0.800818 | 0.215847 | 13.0 | 0.569996 | -0.042719 | 0.135011 | 0.284153 | 0.026097 | -12066.902088 |
| id_1003 | 532.475812 | -1.685504e+07 | -9842.688817 | 580.669414 | -30259.389852 | -57.335155 | -103.029346 | -85.134903 | 523.822698 | 30879.572926 | ... | 51.0 | 0.631371 | 0.251366 | 15.0 | 1.075901 | -0.133314 | 1.373685 | 0.336066 | 0.782968 | -480.328641 |
| id_1004 | 3842.017430 | -2.048682e+09 | -16987.081591 | 3477.250740 | -251461.502332 | -32.344863 | -4909.050477 | -2610.620993 | 4888.722487 | 267714.847953 | ... | 48.0 | 0.536810 | 0.262295 | 16.0 | 0.806466 | -0.222787 | 0.909909 | 0.338798 | -2.879118 | -15323.742941 |
| id_1010 | 35.982333 | -5.002756e+03 | -197.270808 | 40.074998 | -2086.324646 | 0.384581 | -25.906483 | -26.748608 | 29.887855 | 2393.603859 | ... | 47.0 | 0.615801 | 0.270492 | 14.0 | 1.177241 | -0.080681 | 1.809459 | 0.355191 | -0.006868 | -0.316418 |
| id_1014 | 428.452168 | -2.029309e+07 | -895.160202 | 465.314991 | -51416.366795 | 39.945500 | -286.781950 | -317.905714 | 323.499854 | 53759.415208 | ... | 45.0 | 0.671580 | 0.273224 | 15.0 | 0.778352 | -0.257865 | 0.826844 | 0.352459 | -0.509944 | 3568.761154 |
5 rows × 262 columns
Algunas de las variables creadas pueden tener valores ausentes para algunas de las series temporales. Dado que la mayoría de los algoritmos de clustering no permiten valores ausentes, se excluyen.
# Eliminar variables con valores ausentes
# ==============================================================================
ts_features_selected = ts_features_selected.dropna(axis=1, how='any')
print(
f"Número de variables tras la selección y la eliminación de valores perdidos: "
f"{ts_features_selected.shape[1]}"
)
Número de variables tras la selección y la eliminación de valores perdidos: 262
Una vez que la matriz de variables final esté lista, puede ser útil crear un nuevo diccionario para almacenar las variables seleccionadas y los parámetros utilizados para calcularlas. Esto se puede hacer fácilmente utilizando la función from_columns.
# Diccionario con las variables seleccionadas y su configuración
# ==============================================================================
selected_features_info = from_columns(ts_features_selected)
# Descomentar para inspeccionar el diccionario resultante
# pprint(selected_features_info['meter_reading'])
K-means clustering¶
El método de clustering K-means se utiliza para agrupar los edificios. Dado que se sabe que el clustering se ve negativamente afectado por la alta dimensionalidad, y dado que se han creado varias centenas de características para cada edificio, se utiliza un PCA para reducir la dimensionalidad de los datos antes de aplicar el k-means.
# Escalado de variables para que tengan media 0 y desviación estándar 1
# ==============================================================================
scaler = StandardScaler().set_output(transform="pandas")
ts_features_selected_scaled = scaler.fit_transform(ts_features_selected)
ts_features_selected_scaled.head(2)
| meter_reading__cwt_coefficients__coeff_13__w_2__widths_(2, 5, 10, 20) | meter_reading__time_reversal_asymmetry_statistic__lag_3 | meter_reading__fft_coefficient__attr_"imag"__coeff_14 | meter_reading__cwt_coefficients__coeff_12__w_2__widths_(2, 5, 10, 20) | meter_reading__fft_coefficient__attr_"real"__coeff_52 | meter_reading__change_quantiles__f_agg_"mean"__isabs_False__qh_0.4__ql_0.2 | meter_reading__cwt_coefficients__coeff_9__w_2__widths_(2, 5, 10, 20) | meter_reading__cwt_coefficients__coeff_8__w_2__widths_(2, 5, 10, 20) | meter_reading__cwt_coefficients__coeff_5__w_2__widths_(2, 5, 10, 20) | meter_reading__fft_coefficient__attr_"abs"__coeff_52 | ... | meter_reading__number_peaks__n_3 | meter_reading__ar_coefficient__coeff_1__k_10 | meter_reading__lempel_ziv_complexity__bins_5 | meter_reading__augmented_dickey_fuller__attr_"usedlag"__autolag_"AIC" | meter_reading__approximate_entropy__m_2__r_0.3 | meter_reading__ar_coefficient__coeff_2__k_10 | meter_reading__sample_entropy | meter_reading__lempel_ziv_complexity__bins_10 | meter_reading__mean_second_derivative_central | meter_reading__fft_coefficient__attr_"imag"__coeff_66 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| building_id | |||||||||||||||||||||
| id_1001 | -0.291819 | 0.036635 | -0.399262 | -0.310218 | 0.256123 | 0.114873 | 0.264589 | 0.261563 | -0.348869 | -0.234496 | ... | -1.322932 | 0.336083 | -0.436683 | 0.282832 | -0.912063 | 0.488825 | -2.087332 | -0.576604 | 0.244556 | -0.880899 |
| id_1003 | -0.058363 | 0.034091 | -0.256339 | -0.060110 | 0.118053 | -0.177152 | 0.225910 | 0.215803 | -0.207669 | -0.152704 | ... | 1.171205 | -0.316247 | 0.425191 | 0.720766 | 1.172814 | -0.073738 | 1.132361 | 0.588872 | 0.536417 | 0.016526 |
2 rows × 262 columns
# Gráfico de la reducción de la varianza en función del número de componentes PCA
# ==============================================================================
pca = PCA()
pca.fit(ts_features_selected_scaled)
n_components = np.argmax(np.cumsum(pca.explained_variance_ratio_) > 0.85) + 1
fig, ax = plt.subplots(nrows=1, ncols=1, figsize=(6, 2.5))
ax.plot(np.cumsum(pca.explained_variance_ratio_))
ax.set_title('Variance explained by PCA components')
ax.set_xlabel('Number of components')
ax.set_ylabel('Cumulative explained variance')
ax.axhline(y=0.85, color='red', linestyle='--')
ax.axvline(x=n_components, color='red', linestyle='--')
ax.text(
x=n_components+10,
y=0.4,
s=f"n_components = {n_components}",
color='red',
verticalalignment='center'
)
plt.show();
La dimensionalidad de las 262 variables originales se reduce utilizando los primeros 16 componentes principales (que explican el 85% de la varianza).
# PCA con tantos componentes como sea necesario para explicar el 85% de la varianza
# ==============================================================================
pca = PCA(n_components=0.85)
pca_projections = pca.fit_transform(ts_features_selected_scaled)
# Crear un data frame con las proyecciones. Cada columna es un componente principal
# y cada fila es el id del edificio.
pca_projections = pd.DataFrame(
pca_projections,
index = ts_features_selected_scaled.index,
columns = [f"PC{i}" for i in range(1, pca.n_components_ + 1)]
)
print(f"Number of components selected: {pca.n_components_}")
print(f"Explained variance: {pca.explained_variance_ratio_.sum():.3f}")
pca_projections.head(3)
Number of components selected: 16 Explained variance: 0.852
| PC1 | PC2 | PC3 | PC4 | PC5 | PC6 | PC7 | PC8 | PC9 | PC10 | PC11 | PC12 | PC13 | PC14 | PC15 | PC16 | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| building_id | ||||||||||||||||
| id_1001 | -3.953822 | 4.591653 | 2.225976 | 2.705873 | 2.598711 | 1.528322 | -2.501696 | 0.186481 | -3.489617 | -0.586468 | 0.899327 | 1.244604 | -2.319459 | 3.545177 | 3.871482 | -1.581641 |
| id_1003 | -2.111044 | -3.083024 | -1.824578 | 0.121061 | -1.716628 | 0.034439 | 0.221577 | -0.395868 | -0.155805 | 0.284908 | -0.612728 | -0.313265 | 0.357750 | -0.209675 | 0.709589 | 1.054758 |
| id_1004 | 9.973499 | -2.132875 | -0.612174 | 1.788844 | -2.797131 | -0.123844 | -3.009209 | 1.008297 | -2.330331 | -1.814039 | 1.339265 | -1.029985 | -1.903606 | -4.379159 | -0.572582 | 0.913577 |
Uno de los retos inherentes del clustering es determinar el número óptimo de clústeres, ya que no hay una verdad absoluta o un número predefinido de clústeres en el aprendizaje no supervisado. Se han propuesto varias heurísticas para guiar esta selección, y en este caso, se utiliza el método del codo.
El método del codo implica trazar la suma total de cuadrados dentro del clúster (WSS) frente al número de clústeres (k). El número óptimo de clústeres se identifica típicamente en el "codo" de la curva, donde la tasa de disminución de WSS comienza a disminuir notablemente. Esto sugiere que añadir más clústeres más allá de este punto proporciona poca mejora en el rendimiento del clustering.
# Número óptimo de clusters (método del codo)
# ==============================================================================
range_n_clusters = range(1, 20)
inertias = []
size_of_clusters = []
for n_clusters in range_n_clusters:
kmeans = KMeans(
n_clusters = n_clusters,
n_init = 20,
random_state = 963852
)
kmeans.fit(pca_projections)
inertias.append(kmeans.inertia_)
sizes = [int(i) for i in np.unique(kmeans.labels_, return_counts=True)[1]]
size_of_clusters.append(sizes)
inertias = pd.Series(inertias, index=range_n_clusters)
dicrease_in_inertia = inertias.pct_change() * -100
fig, axs = plt.subplots(1, 2, figsize=(8, 3.5))
inertias.plot(marker='o', ax=axs[0])
axs[0].xaxis.set_major_locator(ticker.MaxNLocator(integer=True))
axs[0].set_title("Varianza Intra-cluster vs número de clusters", fontsize=10)
axs[0].set_xlabel('Número de clusters')
axs[0].set_ylabel('Varianza Intra-cluster (Inercia)')
dicrease_in_inertia.plot(kind='bar', ax=axs[1])
axs[1].set_title("Reducción de la inercia en % vs número de clusters", fontsize=10)
axs[1].set_xlabel('Número de clusters')
axs[1].set_ylabel('Reducción de la inercia (%)')
fig.tight_layout()
plt.show();
El gráfico muestra que después de 9 clústeres, la disminución de la inercia se ralentiza, por lo que 9 clústeres pueden ser una buena elección.
Además de analizar la evolución de la inercia (intra-varianza), es importante comprobar el tamaño de los clústeres que se están creando. La presencia de clústeres pequeños puede indicar sobreajuste o muestras anómalas que no encajan bien en ninguno de los grupos.
# Distribución del tamaño de los clusters
# ==============================================================================
for n_clusters, sizes in zip(range_n_clusters, size_of_clusters):
print(f"Tamaño del cluster (n = {n_clusters}): {sizes}")
Tamaño del cluster (n = 1): [600] Tamaño del cluster (n = 2): [25, 575] Tamaño del cluster (n = 3): [574, 3, 23] Tamaño del cluster (n = 4): [304, 3, 269, 24] Tamaño del cluster (n = 5): [274, 241, 3, 62, 20] Tamaño del cluster (n = 6): [66, 3, 237, 18, 4, 272] Tamaño del cluster (n = 7): [66, 21, 2, 1, 1, 272, 237] Tamaño del cluster (n = 8): [274, 2, 238, 1, 15, 5, 1, 64] Tamaño del cluster (n = 9): [228, 2, 179, 3, 17, 110, 1, 59, 1] Tamaño del cluster (n = 10): [179, 6, 1, 226, 111, 1, 14, 1, 60, 1] Tamaño del cluster (n = 11): [111, 1, 58, 1, 1, 228, 6, 1, 179, 13, 1] Tamaño del cluster (n = 12): [104, 171, 2, 7, 177, 56, 1, 1, 7, 2, 71, 1] Tamaño del cluster (n = 13): [86, 1, 54, 3, 1, 176, 23, 183, 1, 5, 6, 1, 60] Tamaño del cluster (n = 14): [115, 3, 57, 1, 45, 1, 1, 1, 138, 1, 133, 12, 2, 90] Tamaño del cluster (n = 15): [75, 1, 6, 126, 22, 53, 1, 59, 1, 136, 3, 5, 1, 110, 1] Tamaño del cluster (n = 16): [113, 1, 77, 32, 2, 3, 1, 135, 38, 54, 126, 11, 1, 1, 3, 2] Tamaño del cluster (n = 17): [130, 3, 1, 87, 134, 1, 16, 1, 111, 2, 1, 47, 4, 56, 1, 2, 3] Tamaño del cluster (n = 18): [88, 1, 23, 56, 112, 1, 4, 1, 2, 75, 1, 1, 32, 1, 126, 5, 69, 2] Tamaño del cluster (n = 19): [104, 1, 22, 129, 1, 2, 113, 1, 3, 84, 3, 75, 1, 52, 1, 2, 2, 3, 1]
Cuando se crean 9 clústeres, el tamaño de los clústeres no está bien equilibrado. Antes de modelizar las series temporales, los clústeres más pequeños (menos de 20 observaciones) se combinan en un único clúster denominado "other".
# Entrenamiento de un modelo de clustering con 9 clusters y asignación de cada edificio a un cluster
# ==============================================================================
kmeans = KMeans(n_clusters=9, n_init=20, random_state=963852)
kmeans.fit(pca_projections)
clusters = kmeans.predict(pca_projections)
clusters = pd.DataFrame({
'building_id': pca_projections.index,
'cluster_base_on_features': clusters.astype(str)
})
# Combinar los clusters con menos de 20 edificios en un solo cluster
threshold = 20
cluster_size = clusters['cluster_base_on_features'].value_counts()
cluster_size = cluster_size[cluster_size < threshold].index.tolist()
clusters['cluster_base_on_features'] = np.where(
clusters['cluster_base_on_features'].isin(cluster_size),
'Other',
clusters['cluster_base_on_features']
)
clusters['cluster_base_on_features'].value_counts()
cluster_base_on_features 0 228 2 179 5 110 7 59 Other 24 Name: count, dtype: int64
# Añadir el cluster predicho al data frame con la información de los edificios
# ==============================================================================
data = pd.merge(
data.reset_index(), # Para evitar perder el índice
clusters,
on ='building_id',
how ='left',
validate = 'm:1'
).set_index('timestamp')
data.head(3)
| building_id | meter_reading | site_id | primary_use | square_feet | air_temperature | dew_temperature | sea_level_pressure | wind_direction | wind_speed | cluster_base_on_features | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| timestamp | |||||||||||
| 2016-01-01 | id_970 | 0.000000 | 9 | Other | 346056 | 7.8 | NaN | NaN | NaN | 3.1 | 5 |
| 2016-01-01 | id_1066 | 639.270004 | 12 | Education | 55800 | 1.9 | -1.2 | 1016.2 | 200.0 | 5.0 | 5 |
| 2016-01-01 | id_862 | 483.950409 | 8 | Other | 27640 | 25.0 | 20.0 | 1019.7 | 0.0 | 0.0 | 5 |
Una vez que se han asignado los edificios a un clúster, es útil examinar las variables de los edificios agrupados. Por ejemplo, la distribución del uso principal de los edificios.
# Porcentaje de cada tipo de edificio en cada cluster
# ==============================================================================
primary_usage_per_cluster = (
data
.groupby('cluster_base_on_features')['primary_use']
.value_counts(normalize=True)
.unstack()
.fillna(0)
)
primary_usage_per_cluster = (100 * primary_usage_per_cluster).round(1)
primary_usage_per_cluster
| primary_use | Education | Entertainment/public assembly | Lodging/residential | Office | Other | Public services |
|---|---|---|---|---|---|---|
| cluster_base_on_features | ||||||
| 0 | 23.7 | 14.9 | 21.5 | 10.5 | 13.6 | 15.8 |
| 2 | 54.2 | 7.3 | 0.6 | 27.9 | 6.1 | 3.9 |
| 5 | 30.0 | 22.7 | 0.9 | 16.4 | 13.6 | 16.4 |
| 7 | 55.9 | 13.6 | 1.7 | 15.3 | 6.8 | 6.8 |
| Other | 50.0 | 8.3 | 0.0 | 29.2 | 4.2 | 8.3 |
Los resultados sugieren (la tabla debe leerse horizontalmente) que el proceso de clustering basado en características extraídas de las series temporales genera grupos que difieren de los formados por el uso principal del edificio.
Clustering basado en la distancia elástica (DTW)¶
DTW es una técnica que mide la similitud entre dos secuencias temporales, que pueden variar en velocidad. En esencia, es una medida de distancia elástica que permite que las series temporales se estiren o compriman para alinearse entre sí de forma óptima. El clustering con DTW implica agrupar datos de series temporales en función de sus distancias DTW, asegurando que las series temporales dentro del mismo clúster tengan formas y patrones similares, incluso si están desfasadas en el tiempo o tienen longitudes diferentes.
La clase TimeSeriesKMeans de la librería Sktime permite la aplicación de clustering K-means con una variedad de métricas de distancia, incluyendo DTW, Euclidiana, ERP, EDR, LCSS, cuadrada, DDTW, WDTW y WDDTW. Muchas de estas métricas son distancias elásticas, lo que hace que el método sea muy adecuado para series temporales.
Sktime requiere que las series temporales estén estructuradas en un formato largo con un multiíndice. El nivel más externo del índice representa el identificador de la serie, mientras que el nivel más interno corresponde a la fecha.
# Convertir los datos a formato long con un multiíndice: (building_id, timestamp)
# ==============================================================================
data_long = (
data
.reset_index()
.loc[:, ['timestamp', 'building_id', 'meter_reading']]
.set_index(['building_id', 'timestamp'])
.sort_index(ascending=True)
)
data_long
| meter_reading | ||
|---|---|---|
| building_id | timestamp | |
| id_1001 | 2016-01-01 | 142.999700 |
| 2016-01-02 | 141.000801 | |
| 2016-01-03 | 137.000300 | |
| 2016-01-04 | 133.000100 | |
| 2016-01-05 | 127.000300 | |
| ... | ... | ... |
| id_999 | 2016-12-27 | 2627.250000 |
| 2016-12-28 | 2667.250000 | |
| 2016-12-29 | 2495.000000 | |
| 2016-12-30 | 2059.500000 | |
| 2016-12-31 | 1899.500000 |
219600 rows × 1 columns
⚠️ Warning
La siguiente celda tiene un tiempo de ejecución de aproximadamente 10 minutos.
# Fit clustering
# ==============================================================================
model = TimeSeriesKMeans(
n_clusters = 4,
metric = "dtw",
max_iter = 10,
random_state = 123456
)
model.fit(data_long)
TimeSeriesKMeans(max_iter=10, n_clusters=4, random_state=123456)Please rerun this cell to show the HTML repr or trust the notebook.
TimeSeriesKMeans(max_iter=10, n_clusters=4, random_state=123456)
# Predicción de clusters
# ==============================================================================
clusters = model.predict(data_long)
clusters = pd.DataFrame({
'building_id': data_long.index.get_level_values('building_id').drop_duplicates(),
'cluster_base_on_dtw': clusters.astype(str),
})
# Size of each cluster
clusters['cluster_base_on_dtw'].value_counts().sort_index().to_frame(name='Number of series')
| Number of series | |
|---|---|
| cluster_base_on_dtw | |
| 0 | 179 |
| 1 | 14 |
| 2 | 342 |
| 3 | 65 |
# Añadir el cluster predicho al dataframe con la información de los edificios
# ==============================================================================
data = pd.merge(
data.reset_index(), # Para evitar perder el índice
clusters,
on = 'building_id',
how = 'left',
validate = 'm:1'
).set_index('timestamp')
data.head(3)
| building_id | meter_reading | site_id | primary_use | square_feet | air_temperature | dew_temperature | sea_level_pressure | wind_direction | wind_speed | cluster_base_on_features | cluster_base_on_dtw | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| timestamp | ||||||||||||
| 2016-01-01 | id_970 | 0.000000 | 9 | Other | 346056 | 7.8 | NaN | NaN | NaN | 3.1 | 5 | 2 |
| 2016-01-01 | id_1066 | 639.270004 | 12 | Education | 55800 | 1.9 | -1.2 | 1016.2 | 200.0 | 5.0 | 5 | 2 |
| 2016-01-01 | id_862 | 483.950409 | 8 | Other | 27640 | 25.0 | 20.0 | 1019.7 | 0.0 | 0.0 | 5 | 2 |
✏️ Note
Sktime ofrece algoritmos de clustering adicionales, como TimeSeriesKMeansTslearn, TimeSeriesKMedoids y TimeSeriesKShapes, que valen la pena explorar. Otras excelentes bibliotecas para clustering de series temporales son: DTAIDistance, Tslearn and AEON.
# Guardar los datos para modelado para evitar repetir los pasos anteriores
# ==============================================================================
data.to_parquet('data_modelling.parquet')
Modelado y forecasting¶
Después de establecer los tres criterios de agrupación (uso del edificio, clustering basado en características de series temporales y clustering basado en Dynamic Time Warping), se entrenan modelos globales de una y de múltiples series. La evaluación que sigue se centra en determinar la capacidad de estos modelos para predecir los datos diarios durante los últimos cinco meses del año (agosto-diciembre de 2016), utilizando un esquema de backtesting a 7 días vista sin reentrenamiento (22 folds semanales). En esta evaluación se utilizan tres métricas distintas:
El promedio del error absoluto medio (MAE) para todos los edificios.
La suma de los errores absolutos, es decir, la desviación absoluta entre el valor predicho y el consumo real para todos los edificios.
El sesgo (bias) de las predicciones sumado para todos los edificios.
Además de los valores rezagados (lags) de la propia serie temporal, se incluyen el día de la semana (codificado en seno-coseno), la temperatura exterior y la velocidad del viento como variables exógenas.
⚠️ Warning
Las variables exógenas air_temperature y wind_speed utilizadas durante el backtesting corresponden a las observaciones históricas reales del periodo de test, no a predicciones realizadas con antelación. Esta es una simplificación habitual con fines de benchmarking, pero en un despliegue real los valores futuros de las variables meteorológicas no se conocen de antemano y deberían sustituirse por predicciones meteorológicas (por ejemplo, de un proveedor externo de previsión del tiempo) o por variables que estén realmente disponibles en el momento de la predicción.
✏️ Note
Para una explicación más detallada de la validación de modelos de series temporales, se recomienda consultar la Backtesting user guide. Para más información sobre las variables de calendario y la codificación cíclica, visita Calendar features and Cyclical features in time series.
Para entrenar los modelos, buscar los hiperparámetros óptimos y evaluar su rendimiento predictivo, los datos se dividen en tres conjuntos separados: entrenamiento, validación y test.
# Lectura de los datos para modelado
# ==============================================================================
data = pd.read_parquet('data_modelling.parquet')
Los datos se transforman de un dataframe en formato largo a un diccionario de series. Aunque esto no es estrictamente necesario, ya que skforecast admite múltiples formatos de entrada, es el formato recomendado para los modelos multi-serie, puesto que permite combinar fácilmente series de diferente longitud y con distintos subconjuntos de variables exógenas.
# Transformación de series y exog a diccionarios
# ==============================================================================
series_dict = reshape_series_long_to_dict(
data = data.reset_index(),
series_id = 'building_id',
index = 'timestamp',
values = 'meter_reading',
freq = 'D'
)
exog_dict = reshape_exog_long_to_dict(
data = data[['building_id', 'primary_use', 'air_temperature', 'wind_speed']].reset_index(),
series_id = 'building_id',
index = 'timestamp',
freq = 'D'
)
# División de los datos en entrenamiento y test
# ==============================================================================
# La división en train/test utilizada para el modelado la gestiona internamente TimeSeriesFold
# (ver `cv` más abajo), que recibe el series_dict/exog_dict completo, sin dividir.
# Las particiones se crean por si se necesitan para análisis adicionales.
end_train = '2016-07-31 23:59:00'
series_dict_train = {k: v.loc[: end_train,] for k, v in series_dict.items()}
exog_dict_train = {k: v.loc[: end_train,] for k, v in exog_dict.items()}
series_dict_test = {k: v.loc[end_train:,] for k, v in series_dict.items()}
exog_dict_test = {k: v.loc[end_train:,] for k, v in exog_dict.items()}
# Definición de los forecasters individual y global
# ==============================================================================
calendar_features = CalendarFeatures(features = ['day_of_week'], encoding='cyclical')
params_lgbm_single = {
'n_estimators': 500,
'learning_rate': 0.01,
'max_depth': 4,
'random_state': 8520,
'verbose': -1
}
forecaster_single = ForecasterRecursive(
estimator = LGBMRegressor(**params_lgbm_single),
lags = 31,
)
params_lgbm_global = {
'n_estimators': 500,
'learning_rate': 0.01,
'max_depth': 10,
'random_state': 8520,
'verbose': -1
}
forecaster_global = ForecasterRecursiveMultiSeries(
estimator = LGBMRegressor(**params_lgbm_global),
lags = 31,
calendar_features = calendar_features,
encoding = "ordinal_category"
)
# Definición del backtesting
# ==============================================================================
cv = TimeSeriesFold(
steps = 7,
initial_train_size = end_train,
refit = False
)
# Variables exógenas incluidas en el modelo
# ==============================================================================
exog_features = ['primary_use', 'air_temperature', 'wind_speed']
# Tabla de resultados para todos los modelos
# ==============================================================================
table_results = pd.DataFrame(columns=['modelo', 'mae', 'abs_error', 'bias', 'elapsed_time'])
table_results = table_results.set_index('modelo')
table_results = table_results.astype({'mae': float, 'abs_error': float, 'bias': float, 'elapsed_time': object})
Modelo individual para cada edificio¶
Un modelo de forecasting individual se entrena y evalúa para cada edificio.
# Entrenamiento y predicción de un modelo para cada edificio
# ==============================================================================
predictions_all_buildings = {}
metrics_all_buildings = {}
errors_all_buildings = {}
# Entrenamiento y predicción para cada edificio
start = datetime.now()
for building in tqdm(data['building_id'].unique(), desc='Modelling buildings'):
# Obtener los datos del edificio
data_building = data[data['building_id'] == building]
data_building = data_building.asfreq('D').sort_index()
# Backtesting
try:
metric, predictions = backtesting_forecaster(
forecaster = forecaster_single,
y = data_building['meter_reading'],
exog = data_building[exog_features],
cv = cv,
metric = 'mean_absolute_error',
show_progress = False
)
predictions_all_buildings[building] = predictions['pred']
metrics_all_buildings[building] = metric.at[0, 'mean_absolute_error']
errors_all_buildings[building] = (
predictions['pred'] - data_building.loc[predictions.index, 'meter_reading']
)
except Exception as e:
print(f"Error modelling building {building}: {e}")
end = datetime.now()
predictions_all_buildings = pd.DataFrame(predictions_all_buildings)
errors_all_buildings = pd.DataFrame(errors_all_buildings)
mean_metric_all_buildings = pd.Series(metrics_all_buildings).mean()
sum_abs_errors_all_buildings = errors_all_buildings.abs().sum(axis=1).sum()
sum_bias_all_buildings = errors_all_buildings.sum().sum()
table_results.loc['One model per building', ['mae', 'abs_error', 'bias', 'elapsed_time']] = [
mean_metric_all_buildings,
sum_abs_errors_all_buildings,
sum_bias_all_buildings,
end - start
]
print(
f"\nError absoluto medio promedio para todos los edificios: {mean_metric_all_buildings:.0f}\n"
f"Suma de errores absolutos para todos los edificios (x 10.000): {sum_abs_errors_all_buildings/10000:.0f}\n"
f"Sesgo (bias) (x 10.000): {sum_bias_all_buildings/10000:.0f}"
)
Modelling buildings: 0%| | 0/600 [00:00<?, ?it/s]
Error absoluto medio promedio para todos los edificios: 503 Suma de errores absolutos para todos los edificios (x 10.000): 4619 Sesgo (bias) (x 10.000): -31
# Gráfico con las predicciones y los valores reales para 2 edificios seleccionados al azar
# ==============================================================================
rng = np.random.default_rng(147)
selected_buildings = rng.choice(data['building_id'].unique(), size=2, replace=False)
fig, axs = plt.subplots(2, 1, figsize=(6, 4), sharex=True)
axs = axs.flatten()
for i, building in enumerate(selected_buildings):
series_dict_test[building].plot(ax=axs[i], label='test')
predictions_all_buildings[building].plot(ax=axs[i], label='One model per building')
axs[i].set_title(f"Building {building}", fontsize=10)
axs[i].set_xlabel("")
axs[i].legend()
fig.tight_layout()
plt.show();
Modelo global para todos los edificios¶
Se entrena un modelo global para todos los edificios y se evalúa utilizando la clase ForecasterRecursiveMultiSeries de skforecast. Este modelo permite al usuario pasar las series temporales en varios formatos, incluido un dataframe con las series temporales organizadas como columnas. Para más información sobre cómo utilizar series de diferente longitud, o diferentes variables exógenas por serie, consulta la documentación de Global Forecasting Models.
# Forecaster multi-series para modelar todos los edificios a la vez
# ==============================================================================
start = datetime.now()
metric, predictions = backtesting_forecaster_multiseries(
forecaster = forecaster_global,
series = series_dict,
exog = exog_dict,
cv = cv,
metric = 'mean_absolute_error',
add_aggregated_metric = False,
verbose = False,
show_progress = True
)
end = datetime.now()
# Combinar las predicciones con los valores reales
results = predictions.reset_index(names=['timestamp']).merge(
data[['building_id', 'meter_reading']].reset_index(names=['timestamp']),
left_on=['timestamp', 'level'],
right_on=['timestamp', 'building_id'],
how='inner'
)
results['error'] = results['pred'] - results['meter_reading']
mean_metric_all_buildings = metric['mean_absolute_error'].mean()
sum_abs_errors_all_buildings = results['error'].abs().sum()
sum_bias_all_buildings = results['error'].sum()
table_results.loc['Global model', ['mae', 'abs_error', 'bias', 'elapsed_time']] = [
mean_metric_all_buildings,
sum_abs_errors_all_buildings,
sum_bias_all_buildings,
end - start
]
print(
f"\nError absoluto medio promedio para todos los edificios: {mean_metric_all_buildings:.0f}\n"
f"Suma de errores absolutos para todos los edificios (x 10.000): {sum_abs_errors_all_buildings/10000:.0f}\n"
f"Sesgo (bias) (x 10.000): {sum_bias_all_buildings/10000:.0f}"
)
0%| | 0/22 [00:00<?, ?it/s]
Error absoluto medio promedio para todos los edificios: 456 Suma de errores absolutos para todos los edificios (x 10.000): 4182 Sesgo (bias) (x 10.000): 881
# Añadir las predicciones al gráfico ya existente (no mostrar el gráfico todavía)
# ==============================================================================
for i, building in enumerate(selected_buildings):
predictions.query("level==@building")['pred'].plot(ax=axs[i], label='Global model')
axs[i].legend()
Modelo global por uso principal¶
# Modelos forecaster multi-serie para edificios agrupados por uso principal
# ==============================================================================
predictions_all_buildings = []
metrics_all_buildings = []
start = datetime.now()
for primary_usage in data['primary_use'].unique():
print(
f"Entrenando y evaluando el modelo para el uso principal: {primary_usage} "
f"(n = {data[data['primary_use'] == primary_usage]['building_id'].nunique()})"
)
# Crear un subconjunto basado en el uso principal
building_id_subset = set(data.loc[data['primary_use'] == primary_usage, 'building_id'].unique())
series_dict_subset = {k: v for k, v in series_dict.items() if k in building_id_subset}
exog_dict_subset = {k: v for k, v in exog_dict.items() if k in building_id_subset}
metric, predictions = backtesting_forecaster_multiseries(
forecaster = forecaster_global,
series = series_dict_subset,
exog = exog_dict_subset,
cv = cv,
metric = 'mean_absolute_error',
add_aggregated_metric = False,
verbose = False,
show_progress = True,
suppress_warnings = True
)
predictions_all_buildings.append(predictions)
metrics_all_buildings.append(metric)
end = datetime.now()
predictions_all_buildings = pd.concat(predictions_all_buildings, axis=0)
metrics_all_buildings = pd.concat(metrics_all_buildings, axis=0)
results = predictions_all_buildings.reset_index(names=['timestamp']).merge(
data[['building_id', 'meter_reading']].reset_index(names=['timestamp']),
left_on=['timestamp', 'level'],
right_on=['timestamp', 'building_id'],
how='inner'
)
results['error'] = results['pred'] - results['meter_reading']
mean_metric_all_buildings = metrics_all_buildings['mean_absolute_error'].mean()
sum_abs_errors_all_buildings = results['error'].abs().sum()
sum_bias_all_buildings = results['error'].sum()
table_results.loc['Global model per primary usage', ['mae', 'abs_error', 'bias', 'elapsed_time']] = [
mean_metric_all_buildings,
sum_abs_errors_all_buildings,
sum_bias_all_buildings,
end - start
]
print(
f"\nError absoluto medio promedio para todos los edificios: {mean_metric_all_buildings:.0f}\n"
f"Suma de errores absolutos para todos los edificios (x 10.000): {sum_abs_errors_all_buildings/10000:.0f}\n"
f"Sesgo (bias) (x 10.000): {sum_bias_all_buildings/10000:.0f}"
)
Entrenando y evaluando el modelo para el uso principal: Other (n = 62)
0%| | 0/22 [00:00<?, ?it/s]
Entrenando y evaluando el modelo para el uso principal: Education (n = 229)
0%| | 0/22 [00:00<?, ?it/s]
Entrenando y evaluando el modelo para el uso principal: Entertainment/public assembly (n = 82)
0%| | 0/22 [00:00<?, ?it/s]
Entrenando y evaluando el modelo para el uso principal: Office (n = 108)
0%| | 0/22 [00:00<?, ?it/s]
Entrenando y evaluando el modelo para el uso principal: Public services (n = 67)
0%| | 0/22 [00:00<?, ?it/s]
Entrenando y evaluando el modelo para el uso principal: Lodging/residential (n = 52)
0%| | 0/22 [00:00<?, ?it/s]
Error absoluto medio promedio para todos los edificios: 457 Suma de errores absolutos para todos los edificios (x 10.000): 4193 Sesgo (bias) (x 10.000): 608
# Añadir las predicciones al gráfico existente (no mostrar el gráfico todavía)
# ==============================================================================
for i, building in enumerate(selected_buildings):
predictions_all_buildings.query("level==@building")['pred'].plot(ax=axs[i], label='Global model per primary usage')
axs[i].legend()
Modelo global por cluster basado en características de las series¶
Se entrena y evalua un modelo global para cada cluster basado en las variables de las series temporales.
# Forecaster multi-series para edificios agrupados por las características de las series
# ==============================================================================
predictions_all_buildings = []
metrics_all_buildings = []
start = datetime.now()
for cluster in data['cluster_base_on_features'].unique():
print(
f"Entrenando y evaluando el modelo para el cluster: {cluster} "
f"(n = {data[data['cluster_base_on_features'] == cluster]['building_id'].nunique()})"
)
# Crear un subconjunto basado en los clusters de características de las series temporales
building_id_subset = set(data.loc[data['cluster_base_on_features'] == cluster, 'building_id'].unique())
series_dict_subset = {k: v for k, v in series_dict.items() if k in building_id_subset}
exog_dict_subset = {k: v for k, v in exog_dict.items() if k in building_id_subset}
metric, predictions = backtesting_forecaster_multiseries(
forecaster = forecaster_global,
series = series_dict_subset,
exog = exog_dict_subset,
cv = cv,
metric = 'mean_absolute_error',
add_aggregated_metric = False,
verbose = False,
show_progress = True,
suppress_warnings = True
)
predictions_all_buildings.append(predictions)
metrics_all_buildings.append(metric)
end = datetime.now()
predictions_all_buildings = pd.concat(predictions_all_buildings, axis=0)
metrics_all_buildings = pd.concat(metrics_all_buildings, axis=0)
results = predictions_all_buildings.reset_index(names=['timestamp']).merge(
data[['building_id', 'meter_reading']].reset_index(names=['timestamp']),
left_on=['timestamp', 'level'],
right_on=['timestamp', 'building_id'],
how='inner'
)
results['error'] = results['pred'] - results['meter_reading']
mean_metric_all_buildings = metrics_all_buildings['mean_absolute_error'].mean()
sum_abs_errors_all_buildings = results['error'].abs().sum()
sum_bias_all_buildings = results['error'].sum()
table_results.loc['Global model per cluster (features)', ['mae', 'abs_error', 'bias', 'elapsed_time']] = [
mean_metric_all_buildings,
sum_abs_errors_all_buildings,
sum_bias_all_buildings,
end - start
]
print(
f"\nError absoluto medio promedio para todos los edificios: {mean_metric_all_buildings:.0f}\n"
f"Suma de errores absolutos para todos los edificios (x 10.000): {sum_abs_errors_all_buildings/10000:.0f}\n"
f"Sesgo (bias) (x 10.000): {sum_bias_all_buildings/10000:.0f}"
)
Entrenando y evaluando el modelo para el cluster: 5 (n = 110)
0%| | 0/22 [00:00<?, ?it/s]
Entrenando y evaluando el modelo para el cluster: 7 (n = 59)
0%| | 0/22 [00:00<?, ?it/s]
Entrenando y evaluando el modelo para el cluster: 2 (n = 179)
0%| | 0/22 [00:00<?, ?it/s]
Entrenando y evaluando el modelo para el cluster: 0 (n = 228)
0%| | 0/22 [00:00<?, ?it/s]
Entrenando y evaluando el modelo para el cluster: Other (n = 24)
0%| | 0/22 [00:00<?, ?it/s]
Error absoluto medio promedio para todos los edificios: 420 Suma de errores absolutos para todos los edificios (x 10.000): 3856 Sesgo (bias) (x 10.000): 557
# Añadir las predicciones al gráfico existente (no mostrar el gráfico todavía)
# ==============================================================================
for i, building in enumerate(selected_buildings):
predictions_all_buildings.query("level==@building")['pred'].plot(ax=axs[i], label='Global model per cluster (features)')
axs[i].legend()
Modelo global por cluster basado DTW¶
Se entrenan y evalúan un modelo global para cada clúster basado en la distancia elástica (DTW).
# Forecaster multi-series para edificios agrupados por DTW
# ==============================================================================
predictions_all_buildings = []
metrics_all_buildings = []
start = datetime.now()
for cluster in data['cluster_base_on_dtw'].unique():
print(
f"Entrenando y evaluando el modelo para el cluster: {cluster} "
f"(n = {data[data['cluster_base_on_dtw'] == cluster]['building_id'].nunique()})"
)
# Crear un subconjunto basado en los clusters DTW
building_id_subset = set(data.loc[data['cluster_base_on_dtw'] == cluster, 'building_id'].unique())
series_dict_subset = {k: v for k, v in series_dict.items() if k in building_id_subset}
exog_dict_subset = {k: v for k, v in exog_dict.items() if k in building_id_subset}
metric, predictions = backtesting_forecaster_multiseries(
forecaster = forecaster_global,
series = series_dict_subset,
exog = exog_dict_subset,
cv = cv,
metric = 'mean_absolute_error',
add_aggregated_metric = False,
verbose = False,
show_progress = True,
suppress_warnings = True
)
predictions_all_buildings.append(predictions)
metrics_all_buildings.append(metric)
end = datetime.now()
predictions_all_buildings = pd.concat(predictions_all_buildings, axis=0)
metrics_all_buildings = pd.concat(metrics_all_buildings, axis=0)
results = predictions_all_buildings.reset_index(names=['timestamp']).merge(
data[['building_id', 'meter_reading']].reset_index(names=['timestamp']),
left_on=['timestamp', 'level'],
right_on=['timestamp', 'building_id'],
how='inner'
)
results['error'] = results['pred'] - results['meter_reading']
mean_metric_all_buildings = metrics_all_buildings['mean_absolute_error'].mean()
sum_abs_errors_all_buildings = results['error'].abs().sum()
sum_bias_all_buildings = results['error'].sum()
table_results.loc['Global model per cluster (DTW)', ['mae', 'abs_error', 'bias', 'elapsed_time']] = [
mean_metric_all_buildings,
sum_abs_errors_all_buildings,
sum_bias_all_buildings,
end - start
]
print(
f"\nError absoluto medio promedio para todos los edificios: {mean_metric_all_buildings:.0f}\n"
f"Suma de errores absolutos para todos los edificios (x 10.000): {sum_abs_errors_all_buildings/10000:.0f}\n"
f"Sesgo (bias) (x 10.000): {sum_bias_all_buildings/10000:.0f}"
)
Entrenando y evaluando el modelo para el cluster: 2 (n = 342)
0%| | 0/22 [00:00<?, ?it/s]
Entrenando y evaluando el modelo para el cluster: 3 (n = 65)
0%| | 0/22 [00:00<?, ?it/s]
Entrenando y evaluando el modelo para el cluster: 0 (n = 179)
0%| | 0/22 [00:00<?, ?it/s]
Entrenando y evaluando el modelo para el cluster: 1 (n = 14)
0%| | 0/22 [00:00<?, ?it/s]
Error absoluto medio promedio para todos los edificios: 421 Suma de errores absolutos para todos los edificios (x 10.000): 3868 Sesgo (bias) (x 10.000): 601
# Añadir las predicciones al gráfico existente (no mostrar el gráfico todavía)
# ==============================================================================
for i, building in enumerate(selected_buildings):
predictions_all_buildings.query("level==@building")['pred'].plot(ax=axs[i], label='Global model per cluster (DTW)')
axs[i].legend()
Resultados¶
# Tabla de resultados
# ==============================================================================
def highlight_best(column):
# Bias is best when closest to zero, not when most negative;
# mae and abs_error are best when smallest.
best_idx = column.abs().idxmin() if column.name == 'bias' else column.idxmin()
return ['background-color: green' if idx == best_idx else '' for idx in column.index]
table_results['elapsed_time'] = table_results['elapsed_time'].astype(str).str[:7]
table_results.style.apply(highlight_best, subset=['mae', 'abs_error', 'bias'], axis=0).format(precision=0)
| mae | abs_error | bias | elapsed_time | |
|---|---|---|---|---|
| modelo | ||||
| One model per building | 503 | 46194541 | -312364 | 0:01:38 |
| Global model | 456 | 41816769 | 8808073 | 0:00:13 |
| Global model per primary usage | 457 | 41930577 | 6084150 | 0:00:17 |
| Global model per cluster (features) | 420 | 38555819 | 5572232 | 0:00:17 |
| Global model per cluster (DTW) | 421 | 38679398 | 6013944 | 0:00:17 |
# Gráfico con las predicciones y los valores reales para 2 edificios seleccionados al azar
# ==============================================================================
handles, labels = axs[0].get_legend_handles_labels()
fig.legend(handles, labels, loc='upper center', bbox_to_anchor=(0.5, 0.05), ncol=2)
for ax in axs:
ax.legend().remove()
fig
Conclusión¶
La decisión entre utilizar modelos individuales o globales puede influir mucho en el resultado. Este análisis compara estas dos estrategias, destacando el balance entre la capacidad predictiva y la eficiencia computacional.
Capacidad de predicción: Los modelos globales superan a los individuales en términos de error medio absoluto, lo que sugiere que captan mejor los patrones comunes entre las series, dando lugar a mejores predicciones.
Eficiencia computacional: Los modelos globales resultan más eficientes, requiriendo menos tiempo que entrenar un modelo individual para cada edificio. Esto pone de relieve la ventaja del modelo global en aplicaciones sensibles al tiempo, donde el entrenamiento y la predicción rápidos son importantes.
Mejor modelo: Para este caso de uso, utilizar varios modelos globales, uno por cada cluster, logra el mejor rendimiento global, con el menor error absoluto medio y error absoluto en comparación con usar un único modelo global.
Compromiso con el sesgo (bias): los modelos individuales por edificio tienen, con diferencia, el menor sesgo absoluto (-31, en unidades de 10.000), mientras que todas las variantes de modelo global muestran un sesgo positivo mayor (557-881, en las mismas unidades). Por tanto, el menor error absoluto medio y error absoluto logrados por los enfoques globales se consiguen a costa de un sesgo menos centrado.
Próximos pasos
Este análisis ha proporcionado ideas interesantes sobre la eficacia de los modelos globales frente a los individuales en el forecasting de series temporales. Los posibles próximos pasos podrían incluir:
Revisar los edificios con un error alto. Identifica si hay un grupo para el que el modelo no funciona bien.
Añadir más variables exógenas: consultar skforecast's user guide Calendar Features para obtener variables de calendario y luz solar que suelen afectar al consumo de energía.
Optimización de lags e hiperparámetros: Utilizar Grid, Random o Bayesian search para encontrar la mejor configuración del modelo.
Probar otros algoritmos de machine learning.
Información de sesión¶
import session_info
session_info.show(html=False)
----- lightgbm 4.7.0 matplotlib 3.10.9 numpy 2.4.6 pandas 2.3.3 session_info v1.0.1 skforecast 0.25.0 sklearn 1.7.2 sktime 1.1.0 tqdm 4.67.3 tsfresh 0.21.2 ----- IPython 9.15.0 jupyter_client 8.9.1 jupyter_core 5.9.1 ----- Python 3.13.14 | packaged by conda-forge | (main, Jun 12 2026, 09:44:26) [MSC v.1944 64 bit (AMD64)] Windows-11-10.0.26200-SP0 ----- Session information updated at 2026-09-08 21:38
Instrucciones para citar¶
¿Cómo citar este documento?
Si utilizas este documento o alguna parte de él, te agradecemos que lo cites. ¡Muchas gracias!
Modelos de forecasting globales: Análisis comparativo de modelos de una y múltiples series por Joaquín Amat Rodrigo y Javier Escobar Ortiz, disponible bajo una licencia Attribution-NonCommercial-ShareAlike 4.0 International (CC BY-NC-SA 4.0 DEED) en https://www.cienciadedatos.net/documentos/py53-modelos-forecasting-globales.html
¿Cómo citar skforecast?
Si utilizas skforecast, te agradeceríamos mucho que lo cites. ¡Muchas gracias!
Zenodo:
Amat Rodrigo, Joaquin, & Escobar Ortiz, Javier. (2026). skforecast (v0.25.0). Zenodo. https://doi.org/10.5281/zenodo.8382788
APA:
Amat Rodrigo, J., & Escobar Ortiz, J. (2026). skforecast (Version 0.25.0) [Computer software]. https://doi.org/10.5281/zenodo.8382788
BibTeX:
@software{skforecast, author = {Amat Rodrigo, Joaquin and Escobar Ortiz, Javier}, title = {skforecast}, version = {0.25.0}, month = {09}, year = {2026}, license = {BSD-3-Clause}, url = {https://skforecast.org/}, doi = {10.5281/zenodo.8382788} }
¿Te ha gustado el artículo? Tu ayuda es importante
Tu contribución me ayudará a seguir generando contenido divulgativo gratuito. ¡Muchísimas gracias! 😊
Este documento creado por Joaquín Amat Rodrigo y Javier Escobar Ortiz tiene licencia Attribution-NonCommercial-ShareAlike 4.0 International.
Se permite:
-
Compartir: copiar y redistribuir el material en cualquier medio o formato.
-
Adaptar: remezclar, transformar y crear a partir del material.
Bajo los siguientes términos:
-
Atribución: Debes otorgar el crédito adecuado, proporcionar un enlace a la licencia e indicar si se realizaron cambios. Puedes hacerlo de cualquier manera razonable, pero no de una forma que sugiera que el licenciante te respalda o respalda tu uso.
-
No-Comercial: No puedes utilizar el material para fines comerciales.
-
Compartir-Igual: Si remezclas, transformas o creas a partir del material, debes distribuir tus contribuciones bajo la misma licencia que el original.
