Modelos globales: Multi-series forecasting con Python y Skforecast

Si te gusta  Skforecast , ayúdanos dándonos una estrella en  GitHub! ⭐️

Modelos de forecasting globales: modelado de múltiples series temporales con machine learning

Joaquín Amat Rodrigo, Javier Escobar Ortiz
Octubre 2022 (última actualización Noviembre 2024)

Modelos de forecasting globales

En los modelos de series individuales (Local Forecasting Model), una única serie temporal se modela como una combinación lineal o no lineal de sus valores pasados y, opcionalmente, variables exógenas. Aunque este método proporciona una comprensión exhaustiva de cada serie, su escalabilidad puede verse comprometida cuando se dispone de un gran número de series.

Los modelos globales multiserie (Global Forecasting Model) consisten en un único modelo que tiene en cuenta varias series temporales de forma simultánea. Intentan captar los patrones básicos que rigen las series, mitigando así el ruido potencial que pueda introducir cada serie. Este enfoque es eficiente desde el punto de vista computacional, es fácil de mantener y puede conseguir mayor generalización, aunque potencialmente a costa de sacrificar cierta resolución individual. Se pueden distinguir dos estrategias de modelos de previsión global: Múltiples series temporales independientes y Múltiples series temporales dependientes.

Ventajas de los modelos multiseries

  • Es más fácil mantener y controlar un solo modelo que varios.

  • Dado que todas las series temporales se combinan durante el entrenamiento, cuando las series sean cortas (pocos datos) el modelo tendrá una mayor capacidad de aprendizaje al disponer de más observaciones.

  • Al combinar múltiples series temporales, el modelo puede aprender patrones más generalizables.

Desventajas de los modelos multiseries

  • Si las series no siguen la misma dinámica interna, el modelo puede aprender un patrón que no represente a ninguna de ellas.

  • Las series pueden enmascararse unas a otras, por lo que el modelo puede no predecirlas todas con el mismo rendimiento.

  • Es más exigente desde el punto de vista computacional (tiempo y recursos) entrenar y realizar backtesting de un modelo grande que de varios pequeños.

Múltiples series temporales independientes

En esta situación, cada serie temporal es independiente de las demás o, dicho de otro modo, los valores pasados de una serie no se utilizan como predictores de las otras series. ¿Por qué es útil entonces modelar todo junto? Aunque las series no dependen unas de otras, pueden seguir el mismo patrón intrínseco en cuanto a sus valores pasados y futuros. Por ejemplo, en una misma tienda, las ventas de los productos A y B pueden no estar relacionadas, pero siguen la misma dinámica, la de la tienda.

Para predecir los siguientes n steps, se sigue una estrategia recurisva, recursive multi-step forecasting.

Múltiples series temporales dependientes

Todas las series se modelan teniendo en cuenta que cada serie temporal depende no sólo de sus valores pasados, sino también de los valores pasados de las demás series. Se espera que el modelo no sólo aprenda la información de cada serie por separado, sino que también las relacione. Por ejemplo, las mediciones realizadas por todos los sensores (caudal, temperatura, presión...) instalados en una máquina industrial como un compresor. Series temporales multivariantes user guide.

Series con la misma longitud

En este primer ejemplo, se utilizan múltiples series temporales de la misma longitud. El objetivo es comparar los resultados de un modelo global con los de un modelo individual para cada serie al predecir los próximos 7 días de ventas para 50 productos diferentes en una tienda, utilizando los 5 años de historial disponible. Los datos se han obtenido del Store Item Demand Forecasting Challenge. Este conjunto de datos contiene 913,000 transacciones de ventas desde el 01/01/2013 hasta el 31/12/2017 para 50 productos (SKU) en 10 tiendas.

Librerías

In [ ]:
# Manipulación de datos
# ==============================================================================
import numpy as np
import pandas as pd

# Gráficos
# ==============================================================================
import matplotlib.pyplot as plt
from tqdm.notebook import tqdm

# Modelado y Forecasting
# ==============================================================================
import sklearn
import skforecast
from sklearn.ensemble import HistGradientBoostingRegressor
from sklearn.preprocessing import StandardScaler
from sklearn.feature_selection import RFECV
from skforecast.recursive import ForecasterRecursiveMultiSeries
from skforecast.recursive import ForecasterRecursive
from skforecast.model_selection import TimeSeriesFold, OneStepAheadFold
from skforecast.model_selection import backtesting_forecaster
from skforecast.model_selection import bayesian_search_forecaster
from skforecast.model_selection import backtesting_forecaster_multiseries
from skforecast.model_selection import bayesian_search_forecaster_multiseries
from skforecast.feature_selection import select_features_multiseries
from skforecast.preprocessing import RollingFeatures
from skforecast.exceptions import OneStepAheadValidationWarning
from skforecast.plot import set_dark_theme
from skforecast.preprocessing import series_long_to_dict
from skforecast.preprocessing import exog_long_to_dict

# Warnings
# ==============================================================================
import warnings
warnings.filterwarnings('once')

color = '\033[1m\033[38;5;208m'
print(f"{color}Versión skforecast: {skforecast.__version__}")
print(f"{color}Versión scikit-learn: {sklearn.__version__}")
print(f"{color}Versión pandas: {pd.__version__}")
print(f"{color}Versión numpy: {np.__version__}")
Versión skforecast: 0.14.0
Versión scikit-learn: 1.5.1
Versión pandas: 2.2.3
Versión numpy: 2.0.2

Datos

In [8]:
# Lectura de datos
# ======================================================================================
data = pd.read_csv('./train_stores_kaggle.csv')
display(data)
print(f"Shape: {data.shape}")
date store item sales
0 2013-01-01 1 1 13
1 2013-01-02 1 1 11
2 2013-01-03 1 1 14
3 2013-01-04 1 1 13
4 2013-01-05 1 1 10
... ... ... ... ...
912995 2017-12-27 10 50 63
912996 2017-12-28 10 50 59
912997 2017-12-29 10 50 74
912998 2017-12-30 10 50 62
912999 2017-12-31 10 50 82

913000 rows × 4 columns

Shape: (913000, 4)
In [9]:
# Preparación datos
# ======================================================================================
selected_store = 2 # Seleccionar una tienda
selected_items = data.item.unique()
data = data[(data['store'] == selected_store) & (data['item'].isin(selected_items))].copy()
data['date'] = pd.to_datetime(data['date'], format='%Y-%m-%d')
data = pd.pivot_table(
           data    = data,
           values  = 'sales',
           index   = 'date',
           columns = 'item'
       )
data.columns.name = None
data.columns = [f"item_{col}" for col in data.columns]
data = data.asfreq('1D')
data = data.sort_index()
data.head(4)
Out[9]:
item_1 item_2 item_3 item_4 item_5 item_6 item_7 item_8 item_9 item_10 ... item_41 item_42 item_43 item_44 item_45 item_46 item_47 item_48 item_49 item_50
date
2013-01-01 12.0 41.0 19.0 21.0 4.0 34.0 39.0 49.0 28.0 51.0 ... 11.0 25.0 36.0 12.0 45.0 43.0 12.0 45.0 29.0 43.0
2013-01-02 16.0 33.0 32.0 14.0 6.0 40.0 47.0 42.0 21.0 56.0 ... 19.0 21.0 35.0 25.0 50.0 52.0 13.0 37.0 25.0 57.0
2013-01-03 16.0 46.0 26.0 12.0 12.0 41.0 43.0 46.0 29.0 46.0 ... 23.0 20.0 52.0 18.0 56.0 30.0 5.0 45.0 30.0 45.0
2013-01-04 20.0 50.0 34.0 17.0 16.0 41.0 44.0 55.0 32.0 56.0 ... 15.0 28.0 50.0 24.0 57.0 46.0 19.0 32.0 20.0 45.0

4 rows × 50 columns

El dataset se divide en 3 particiones: una para el entrenamiento, otra para la validación y otra para test.

In [10]:
# Separación datos train-validation-test
# ======================================================================================
end_train = '2016-05-31 23:59:00'
end_val = '2017-05-31 23:59:00'
data_train = data.loc[:end_train, :].copy()
data_val   = data.loc[end_train:end_val, :].copy()
data_test  = data.loc[end_val:, :].copy()
print(f"Fechas train      : {data_train.index.min()} --- {data_train.index.max()}  (n={len(data_train)})")
print(f"Fechas validación : {data_val.index.min()} --- {data_val.index.max()}  (n={len(data_val)})")
print(f"Fechas test       : {data_test.index.min()} --- {data_test.index.max()}  (n={len(data_test)})")
Fechas train      : 2013-01-01 00:00:00 --- 2016-05-31 00:00:00  (n=1247)
Fechas validación : 2016-06-01 00:00:00 --- 2017-05-31 00:00:00  (n=365)
Fechas test       : 2017-06-01 00:00:00 --- 2017-12-31 00:00:00  (n=214)

Se representan cuatro de las series para comprender sus tendencias y patrones. Se recomienda al lector que grafique más de ellas para comprenderlas en profundidad.

In [11]:
# Gráfico series temporales
# ======================================================================================
set_dark_theme()
fig, axs = plt.subplots(4, 1, figsize=(7, 5), sharex=True)
data.iloc[:, :4].plot(
    legend   = True,
    subplots = True, 
    title    = 'Ventas de la tienda 2',
    ax       = axs, 
)
for ax in axs:
    ax.axvline(pd.to_datetime(end_train) , color='white', linestyle='--', linewidth=1.5)
    ax.axvline(pd.to_datetime(end_val) , color='white', linestyle='--', linewidth=1.5)
fig.tight_layout();

Forecasting individual para cada item

Se entrena un modelo Gradient Boosting Machine (GBM) para cada producto, utilizando las ventas de los últimos 14 días, así como las ventas promedio, máxima y mínima de los últimos 7 días como predictores. El rendimiento del modelo para los siguientes 7 días se evalúa mediante backtesting utilizando el Error Absoluto Medio (MAE) como métrica de evaluación. Finalmente, se compara el rendimiento de estos modelos individuales con el de un modelo global entrenado con todas las series.

In [12]:
# Entrenar y realizar backtesting de un modelo para cada item
# ======================================================================================
items = []
mae_values = []
predictions = {}

for i, item in enumerate(tqdm(data.columns)):
    # Definir el forecaster
    window_features = RollingFeatures(stats=['mean', 'min', 'max'], window_sizes=7)
    forecaster = ForecasterRecursive(
                     regressor       = HistGradientBoostingRegressor(random_state=8523),
                     lags            = 14,
                     window_features = window_features
                 )
    # Backtesting forecaster
    cv = TimeSeriesFold(
            steps              = 7,
            initial_train_size = len(data_train) + len(data_val),
            refit              = False,
         )
    metric, preds = backtesting_forecaster(
                        forecaster    = forecaster,
                        y             = data[item],
                        cv            = cv,
                        metric        = 'mean_absolute_error',
                        verbose       = False,
                        show_progress = False
                    )
    items.append(item)
    mae_values.append(metric.at[0, 'mean_absolute_error'])
    predictions[item] = preds

# Resultados
uni_series_mae = pd.Series(
                     data  = mae_values,
                     index = items,
                     name  = 'uni_series_mae'
                 )
uni_series_mae.head()
Out[12]:
item_1    6.004406
item_2    9.994352
item_3    8.652751
item_4    5.528955
item_5    5.096925
Name: uni_series_mae, dtype: float64

Modelo global: Forecasting multiseries

Se entrena modelo global con las mismas características que los modelos individuales. Se evalúa el rendimiento del modelo global en el conjunto de test y se compara con el rendimiento de los modelos individuales.

In [13]:
# Entrenamiento y backtesting con un único modelo para todos los items
# ======================================================================================
items = list(data.columns)

# Definir el forecaster
window_features = RollingFeatures(stats=['mean', 'min', 'max'], window_sizes=7)
forecaster_ms = ForecasterRecursiveMultiSeries(
                    regressor          = HistGradientBoostingRegressor(random_state=8523),
                    lags               = 14,
                    encoding           = 'ordinal',
                    transformer_series = StandardScaler(),
                    window_features    = window_features,
                )
# Backtesting forecaster para todos los items
cv = TimeSeriesFold(
            steps              = 7,
            initial_train_size = len(data_train) + len(data_val),
            refit              = False,
         )
multi_series_mae, predictions_ms = backtesting_forecaster_multiseries(
                                       forecaster            = forecaster_ms,
                                       series                = data,
                                       levels                = items,
                                       cv                    = cv,
                                       metric                = 'mean_absolute_error',
                                       add_aggregated_metric = False,
                                       verbose               = False,
                                       show_progress         = True  
                                   )   
# Resultados
display(multi_series_mae.head(3))
print('')
display(predictions_ms.head(3))
levels mean_absolute_error
0 item_1 5.521236
1 item_2 9.245126
2 item_3 7.301948

item_1 item_2 item_3 item_4 item_5 item_6 item_7 item_8 item_9 item_10 ... item_41 item_42 item_43 item_44 item_45 item_46 item_47 item_48 item_49 item_50
2017-06-01 35.106861 90.367217 60.613740 34.796786 30.344390 94.850880 92.222349 125.069804 85.972618 121.856981 ... 36.761204 58.301875 79.983903 43.559158 127.045573 98.774219 37.078906 77.610719 49.004847 109.749311
2017-06-02 38.663113 101.583964 63.187115 35.903646 31.293313 100.855224 97.763251 134.804295 92.259393 121.838499 ... 40.470627 59.746983 85.748729 50.961104 137.746852 102.729668 42.124969 83.469452 53.310940 116.044636
2017-06-03 38.264879 103.619330 65.602129 40.237811 33.283988 107.100882 101.806627 144.945584 94.686035 133.521963 ... 41.592844 64.585156 90.099446 51.959348 141.979401 107.639900 41.524414 89.363027 46.842094 117.288006

3 rows × 50 columns

Comparación

In [14]:
# Diferencia de la métrica de backtesting para cada item
# ======================================================================================
multi_series_mae = multi_series_mae.set_index('levels')
multi_series_mae.columns = ['multi_series_mae']
results = pd.concat((uni_series_mae, multi_series_mae), axis = 1)
results['improvement'] = results.eval('uni_series_mae - multi_series_mae')
results['improvement_(%)'] = 100 * results.eval('(uni_series_mae - multi_series_mae) / uni_series_mae')
results = results.round(2)
results.style.bar(subset=['improvement_(%)'], align='mid', color=['#d65f5f', '#5fba7d'])
Out[14]:
  uni_series_mae multi_series_mae improvement improvement_(%)
item_1 6.000000 5.520000 0.480000 8.050000
item_2 9.990000 9.250000 0.750000 7.500000
item_3 8.650000 7.300000 1.350000 15.610000
item_4 5.530000 5.030000 0.500000 8.960000
item_5 5.100000 4.660000 0.440000 8.630000
item_6 10.830000 9.750000 1.080000 9.960000
item_7 10.580000 9.830000 0.750000 7.120000
item_8 11.810000 10.420000 1.400000 11.830000
item_9 9.420000 8.690000 0.730000 7.760000
item_10 11.640000 10.330000 1.300000 11.210000
item_11 11.520000 10.430000 1.090000 9.450000
item_12 11.960000 10.890000 1.080000 8.990000
item_13 12.130000 11.360000 0.760000 6.300000
item_14 10.350000 9.560000 0.790000 7.610000
item_15 12.460000 11.640000 0.820000 6.620000
item_16 6.000000 5.920000 0.090000 1.480000
item_17 7.460000 7.190000 0.270000 3.560000
item_18 12.690000 12.120000 0.570000 4.490000
item_19 7.720000 7.370000 0.360000 4.600000
item_20 8.250000 7.830000 0.420000 5.080000
item_21 8.580000 8.060000 0.520000 6.080000
item_22 11.790000 10.710000 1.080000 9.150000
item_23 7.490000 6.770000 0.720000 9.600000
item_24 10.470000 9.860000 0.610000 5.850000
item_25 12.950000 11.650000 1.310000 10.100000
item_26 9.230000 8.590000 0.640000 6.930000
item_27 5.480000 5.170000 0.310000 5.680000
item_28 12.590000 12.080000 0.510000 4.020000
item_29 10.980000 10.180000 0.810000 7.330000
item_30 8.430000 7.890000 0.530000 6.290000
item_31 10.530000 10.070000 0.460000 4.420000
item_32 9.710000 9.160000 0.540000 5.580000
item_33 9.740000 9.320000 0.420000 4.270000
item_34 6.340000 5.930000 0.410000 6.450000
item_35 11.200000 10.180000 1.020000 9.120000
item_36 12.000000 10.620000 1.380000 11.520000
item_37 6.510000 6.110000 0.400000 6.150000
item_38 11.620000 11.130000 0.500000 4.270000
item_39 8.340000 7.370000 0.970000 11.680000
item_40 7.100000 6.630000 0.470000 6.650000
item_41 5.670000 5.220000 0.450000 7.950000
item_42 7.440000 6.940000 0.500000 6.680000
item_43 8.620000 8.570000 0.050000 0.540000
item_44 6.980000 6.410000 0.570000 8.190000
item_45 12.720000 11.790000 0.930000 7.310000
item_46 10.350000 9.890000 0.460000 4.450000
item_47 5.500000 4.980000 0.520000 9.500000
item_48 9.270000 8.190000 1.090000 11.740000
item_49 6.300000 5.960000 0.340000 5.350000
item_50 11.860000 10.610000 1.250000 10.510000
In [15]:
# Mejora media de todos los items
# ======================================================================================
results[['improvement', 'improvement_(%)']].agg(['mean', 'min', 'max'])
Out[15]:
improvement improvement_(%)
mean 0.696 7.3634
min 0.050 0.5400
max 1.400 15.6100
In [16]:
# Número de series con mejora positiva y negativa
# ======================================================================================
pd.Series(np.where(results['improvement_(%)'] < 0, 'negative', 'positive')).value_counts()
Out[16]:
positive    50
Name: count, dtype: int64

El modelo global logra una mejora media del 7.4% en comparación con el uso de un modelo individual para cada serie. Para todas las series, el error de predicción evaluado mediante backtesting es menor cuando se utiliza el modelo global. Este caso de uso demuestra que un modelo multiserie puede tener ventajas sobre varios modelos individuales a la hora de predecir series temporales que siguen una dinámica similar. Además de las mejoras potenciales en la predicción, también es importante tener en cuenta el beneficio de tener un único modelo que mantener y la velocidad de entrenamiento y predicción.

⚠ Warning

Esta comparación se ha realizado sin optimizar los hiperparámetros del modelo. Consulte la sección Ajuste de hiperparámetros y selección de lags para comprobar que las conclusiones se mantienen cuando los modelos se ajustan con la mejor combinación de hiperparámetros y lags.

Series de diferente longitud y distintas variables exógenas

En escenarios en los que se tienen que modelar múltiples series, es habitual que las series tengan longitudes distintas debido a diferencias en los momentos de inicio del registro de los datos. Para hacer frente a este escenario, la clase ForecasterAutoregMultiSeries permite modelizar simultáneamente series temporales de distintas longitudes y con distintas variables exógenas.

  • Cuando las series modeladas tienen longitudes diferentes, deben almacenarse en un diccionario Python. Las claves del diccionario son los nombres de las series y los valores son las propias series. Todas las series deben ser del tipo pandas.Series, tener un índice datetime y tener la misma frecuencia.

Series values Permitido
[NaN, NaN, NaN, NaN, 4, 5, 6, 7, 8, 9] ✔️
[0, 1, 2, 3, 4, 5, 6, 7, 8, NaN] ✔️
[0, 1, 2, 3, 4, NaN, 6, 7, 8, 9] ✔️
[NaN, NaN, 2, 3, 4, NaN, 6, 7, 8, 9] ✔️


  • Cuando se utilizan variables exógenas diferentes para cada serie o cuando las variables exógenas son las mismas pero tienen valores diferentes para cada serie, deben almacenarse en un diccionario. Las claves del diccionario son los nombres de las series y los valores son las propias variables exógenas. Todas las variables exógenas deben ser de tipo pandas.DataFrame o pandas.Series.

Datos

Los datos de este ejemplo están almacenados en "formato largo" en un único DataFrame. La columna series_id identifica la serie a la que pertenece cada observación. La columna timestamp contiene la fecha de la observación, y la columna value contiene el valor de la serie en esa fecha. Cada serie temporal tiene una longitud diferente.

Las variables exógenas se almacenan en un DataFrame separado, también en "formato largo". La columna series_id identifica la serie a la que pertenece cada observación. La columna timestamp contiene la fecha de la observación, y las columnas restantes contienen los valores de las variables exógenas en esa fecha.

In [17]:
# Lectura series con diferentes longitudes y variables exógenas
# ==============================================================================
series = pd.read_csv(
    'https://raw.githubusercontent.com/skforecast/skforecast-datasets/main/data/demo_multi_series.csv'
)
exog = pd.read_csv(
    'https://raw.githubusercontent.com/skforecast/skforecast-datasets/main/data/demo_multi_series_exog.csv'
)
series['timestamp'] = pd.to_datetime(series['timestamp'])
exog['timestamp'] = pd.to_datetime(exog['timestamp'])
display(series.head())
display(exog.head())
series_id timestamp value
0 id_1000 2016-01-01 1012.500694
1 id_1000 2016-01-02 1158.500099
2 id_1000 2016-01-03 983.000099
3 id_1000 2016-01-04 1675.750496
4 id_1000 2016-01-05 1586.250694
series_id timestamp sin_day_of_week cos_day_of_week air_temperature wind_speed
0 id_1000 2016-01-01 -0.433884 -0.900969 6.416639 4.040115
1 id_1000 2016-01-02 -0.974928 -0.222521 6.366474 4.530395
2 id_1000 2016-01-03 -0.781831 0.623490 6.555272 3.273064
3 id_1000 2016-01-04 0.000000 1.000000 6.704778 4.865404
4 id_1000 2016-01-05 0.781831 0.623490 2.392998 5.228913

Cuando las series tienen longitudes diferentes, los datos deben transformarse en un diccionario. Las claves del diccionario son los nombres de las series y los valores son las propias series. Para ello, se utiliza la función series_long_to_dict, que toma el DataFrame en "formato largo" y devuelve un diccionario de series.

Del mismo modo, cuando las variables exógenas son diferentes (valores o variables) para cada serie, los datos deben transformarse en un diccionario. Las claves del diccionario son los nombres de las series y los valores son las propias variables exógenas. Se utiliza la función exog_long_to_dict, que toma el DataFrame en "formato largo" y devuelve un diccionario de variables exógenas.

In [18]:
# Transform series and exog to dictionaries
# ==============================================================================
series_dict = series_long_to_dict(
                data      = series,
                series_id = 'series_id',
                index     = 'timestamp',
                values    = 'value',
                freq      = 'D'
              )

exog_dict = exog_long_to_dict(
                data      = exog,
                series_id = 'series_id',
                index     = 'timestamp',
                freq      = 'D'
            )
/home/ubuntu/anaconda3/envs/skforecast_14_py12/lib/python3.12/site-packages/skforecast/preprocessing/preprocessing.py:299: MissingValuesWarning: Series 'id_1003' is incomplete. NaNs have been introduced after setting the frequency. 
 You can suppress this warning using: warnings.simplefilter('ignore', category=MissingValuesWarning)
  warnings.warn(

Algunas variables exógenas se omiten en las series 1 y 3 para ilustrar que se pueden utilizar diferentes variables exógenas para cada serie.

In [19]:
exog_dict['id_1000'] = exog_dict['id_1000'].drop(columns=['air_temperature', 'wind_speed'])
exog_dict['id_1003'] = exog_dict['id_1003'].drop(columns=['cos_day_of_week'])
In [20]:
# Particiones de entrenamiento y test
# ==============================================================================
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()}
In [21]:
# Gráfico series
# ==============================================================================
set_dark_theme()
colors = plt.rcParams['axes.prop_cycle'].by_key()['color']
fig, axs = plt.subplots(5, 1, figsize=(8, 4), sharex=True)
for i, s in enumerate(series_dict.values()):
    axs[i].plot(s, label=s.name, color=colors[i])
    axs[i].legend(loc='upper right', fontsize=8)
    axs[i].tick_params(axis='both', labelsize=8)
    axs[i].axvline(pd.to_datetime(end_train), color='white', linestyle='--', linewidth=1)
In [22]:
# Descripción de cada serie
# ==============================================================================
for k in series_dict.keys():
    print(f"{k}:")
    try:
        print(
            f"\tTrain: len={len(series_dict_train[k])}, {series_dict_train[k].index[0]}"
            f" --- {series_dict_train[k].index[-1]} "
            f" (missing={series_dict_train[k].isnull().sum()})"
        )
    except:
        print(f"\tTrain: len=0")
    try:
        print(
            f"\tTest : len={len(series_dict_test[k])}, {series_dict_test[k].index[0]}"
            f" --- {series_dict_test[k].index[-1]} "
            f" (missing={series_dict_test[k].isnull().sum()})"
        )
    except:
        print(f"\tTest : len=0")
id_1000:
	Train: len=213, 2016-01-01 00:00:00 --- 2016-07-31 00:00:00  (missing=0)
	Test : len=153, 2016-08-01 00:00:00 --- 2016-12-31 00:00:00  (missing=0)
id_1001:
	Train: len=30, 2016-07-02 00:00:00 --- 2016-07-31 00:00:00  (missing=0)
	Test : len=153, 2016-08-01 00:00:00 --- 2016-12-31 00:00:00  (missing=0)
id_1002:
	Train: len=183, 2016-01-01 00:00:00 --- 2016-07-01 00:00:00  (missing=0)
	Test : len=0
id_1003:
	Train: len=213, 2016-01-01 00:00:00 --- 2016-07-31 00:00:00  (missing=73)
	Test : len=153, 2016-08-01 00:00:00 --- 2016-12-31 00:00:00  (missing=73)
id_1004:
	Train: len=91, 2016-05-02 00:00:00 --- 2016-07-31 00:00:00  (missing=0)
	Test : len=31, 2016-08-01 00:00:00 --- 2016-08-31 00:00:00  (missing=0)
In [23]:
# Variables exógenas de cada serie
# ==============================================================================
for k in series_dict.keys():
    print(f"{k}:")
    try:
        print(f"\t{exog_dict[k].columns.to_list()}")
    except:
        print(f"\tNo variables exógenas")
id_1000:
	['sin_day_of_week', 'cos_day_of_week']
id_1001:
	['sin_day_of_week', 'cos_day_of_week', 'air_temperature', 'wind_speed']
id_1002:
	['sin_day_of_week', 'cos_day_of_week', 'air_temperature', 'wind_speed']
id_1003:
	['sin_day_of_week', 'air_temperature', 'wind_speed']
id_1004:
	['sin_day_of_week', 'cos_day_of_week', 'air_temperature', 'wind_speed']

Modelo global: Forecaster multiseries

In [24]:
# Fit forecaster
# ==============================================================================
regressor = HistGradientBoostingRegressor(random_state=123, max_depth=5)
window_features = RollingFeatures(stats=['mean', 'min', 'max'], window_sizes=7)
forecaster = ForecasterRecursiveMultiSeries(
                regressor          = regressor,
                lags               = 14,
                window_features    = window_features,
                encoding           = "ordinal",
                dropna_from_series = False
            )

forecaster.fit(series=series_dict_train, exog=exog_dict_train, suppress_warnings=True)
forecaster
Out[24]:

ForecasterRecursiveMultiSeries

General Information
  • Regressor: HistGradientBoostingRegressor(max_depth=5, random_state=123)
  • Lags: [ 1 2 3 4 5 6 7 8 9 10 11 12 13 14]
  • Window features: ['roll_mean_7', 'roll_min_7', 'roll_max_7']
  • Window size: 14
  • Series encoding: ordinal
  • Exogenous included: True
  • Weight function included: False
  • Series weights: None
  • Differentiation order: None
  • Creation date: 2024-11-06 14:49:01
  • Last fit date: 2024-11-06 14:49:02
  • Skforecast version: 0.14.0
  • Python version: 3.12.5
  • Forecaster id: None
Exogenous Variables
    sin_day_of_week, cos_day_of_week, air_temperature, wind_speed
Data Transformations
  • Transformer for series: None
  • Transformer for exog: None
Training Information
  • Series names (levels): id_1000, id_1001, id_1002, id_1003, id_1004
  • Training range: 'id_1000': ['2016-01-01', '2016-07-31'], 'id_1001': ['2016-07-02', '2016-07-31'], 'id_1002': ['2016-01-01', '2016-07-01'], 'id_1003': ['2016-01-01', '2016-07-31'], 'id_1004': ['2016-05-02', '2016-07-31']
  • Training index type: DatetimeIndex
  • Training index frequency: D
Regressor Parameters
    {'categorical_features': 'warn', 'early_stopping': 'auto', 'interaction_cst': None, 'l2_regularization': 0.0, 'learning_rate': 0.1, 'loss': 'squared_error', 'max_bins': 255, 'max_depth': 5, 'max_features': 1.0, 'max_iter': 100, 'max_leaf_nodes': 31, 'min_samples_leaf': 20, 'monotonic_cst': None, 'n_iter_no_change': 10, 'quantile': None, 'random_state': 123, 'scoring': 'loss', 'tol': 1e-07, 'validation_fraction': 0.1, 'verbose': 0, 'warm_start': False}
Fit Kwargs
    {}

🛈 API Reference    🗎 User Guide

Sólo se pueden predecir juntas las series cuya última ventana de datos (last_window) termine en el mismo índice fecha-hora. Si levels = None, se excluyen de la predicción las series que no alcanzan el índice máximo. En este ejemplo, la serie 'id_1002 se excluye.

In [25]:
# Predict
# ==============================================================================
predicciones = forecaster.predict(steps=5, exog=exog_dict_test, suppress_warnings=True)
predicciones
Out[25]:
id_1000 id_1001 id_1003 id_1004
2016-08-01 1433.494674 3068.244797 2748.768695 7763.964965
2016-08-02 1465.937652 3468.972018 2022.956989 8734.459604
2016-08-03 1407.568704 3475.785941 1860.174602 9111.776904
2016-08-04 1355.034624 3356.315154 1823.007406 8815.493044
2016-08-05 1298.820257 3325.735999 1815.224166 8664.891475

Backtesting

Cuando las series tienen longitudes diferentes, el proceso de backtesting sólo devuelve predicciones para las fechas-horas que están presentes en las series.

In [26]:
# Backtesting
# ==============================================================================
cv = TimeSeriesFold(
        steps              = 24,
        initial_train_size = len(series_dict_train["id_1000"]),
        refit              = False,
     )

metrics_levels, backtest_predictions = backtesting_forecaster_multiseries(
    forecaster            = forecaster,
    series                = series_dict,
    exog                  = exog_dict,
    cv                    = cv,
    metric                = "mean_absolute_error",
    add_aggregated_metric = False,
    n_jobs                = "auto",
    verbose               = True,
    show_progress         = True,
    suppress_warnings     = True
)

display(metrics_levels)
display(backtest_predictions)
Information of folds
--------------------
Number of observations used for initial training: 213
Number of observations used for backtesting: 153
    Number of folds: 7
    Number skipped folds: 0 
    Number of steps per fold: 24
    Number of steps to exclude between last observed data (last window) and predictions (gap): 0
    Last fold only includes 9 observations.

Fold: 0
    Training:   2016-01-01 00:00:00 -- 2016-07-31 00:00:00  (n=213)
    Validation: 2016-08-01 00:00:00 -- 2016-08-24 00:00:00  (n=24)
Fold: 1
    Training:   No training in this fold
    Validation: 2016-08-25 00:00:00 -- 2016-09-17 00:00:00  (n=24)
Fold: 2
    Training:   No training in this fold
    Validation: 2016-09-18 00:00:00 -- 2016-10-11 00:00:00  (n=24)
Fold: 3
    Training:   No training in this fold
    Validation: 2016-10-12 00:00:00 -- 2016-11-04 00:00:00  (n=24)
Fold: 4
    Training:   No training in this fold
    Validation: 2016-11-05 00:00:00 -- 2016-11-28 00:00:00  (n=24)
Fold: 5
    Training:   No training in this fold
    Validation: 2016-11-29 00:00:00 -- 2016-12-22 00:00:00  (n=24)
Fold: 6
    Training:   No training in this fold
    Validation: 2016-12-23 00:00:00 -- 2016-12-31 00:00:00  (n=9)

levels mean_absolute_error
0 id_1000 164.959423
1 id_1001 1055.559754
2 id_1002 NaN
3 id_1003 235.663130
4 id_1004 968.459237
id_1000 id_1001 id_1003 id_1004
2016-08-01 1433.494674 3068.244797 2748.768695 7763.964965
2016-08-02 1465.937652 3468.972018 2022.956989 8734.459604
2016-08-03 1407.568704 3475.785941 1860.174602 9111.776904
2016-08-04 1355.034624 3356.315154 1823.007406 8815.493044
2016-08-05 1298.820257 3325.735999 1815.224166 8664.891475
... ... ... ... ...
2016-12-27 1712.239821 1083.974921 2069.136527 NaN
2016-12-28 1622.091030 1015.958815 1984.614144 NaN
2016-12-29 1555.862159 1085.108004 1909.150202 NaN
2016-12-30 1513.901694 1114.592910 1965.060657 NaN
2016-12-31 1459.122750 1001.655092 1969.768680 NaN

153 rows × 4 columns

In [27]:
# Gráfico predicciones backtesting
# ==============================================================================
colors = plt.rcParams['axes.prop_cycle'].by_key()['color']
fig, axs = plt.subplots(5, 1, figsize=(8, 4), sharex=True)
for i, s in enumerate(series_dict.keys()):
    axs[i].plot(series_dict[s], label=series_dict[s].name, color=colors[i])
    axs[i].axvline(pd.to_datetime(end_train), color='white', linestyle='--', linewidth=1)
    try:
        axs[i].plot(backtest_predictions[s], label='prediction', color="white")
    except:
        pass
    axs[i].legend(loc='upper left', fontsize=8)
    axs[i].tick_params(axis='both', labelsize=8)

Al permitir la modelización de series temporales de diferentes longitudes y con diferentes variables exógenas, la clase ForecasterRecursiveMultiSeries proporciona una herramienta flexible y potente para utilizar toda la información disponible para entrenar los modelos.

Optimización de hiperparámetros (tuning) y selección de lags

En el primer ejemplo de este documento, la comparación entre forecaster se ha realizado sin optimizar los hiperparámetros de los regresores. Para una comparación justa, se utiliza una estrategia de grid search con el fin de seleccionar la mejor configuración para cada forecaster. Véase más información en hyperparameter tuning and lags selection.

🖉 Note

Optimizar los hiperparámetros de múltiples modelos puede ser computacionalmente costoso. Para acelerar el proceso, la evaluación de cada configuración candidata se realiza utilizando *one-step-ahead* en lugar de *backtesting*. Para más detalles sobre las ventajas y limitaciones de este enfoque, consulte [One-step-ahead validation](https://skforecast.org/latest/user_guides/hyperparameter-tuning-and-lags-selection#one-step-ahead-validation.html).
In [ ]:
# Búsqueda de hiperparámetros y backtesting de un modelo para cada item
# ======================================================================================
warnings.simplefilter('ignore', category=OneStepAheadValidationWarning)
items = []
mae_values  = []

def search_space(trial):
    search_space  = {
        'lags'          : trial.suggest_categorical('lags', [7, 14]),
        'max_iter'      : trial.suggest_int('max_iter', 100, 500),
        'max_depth'     : trial.suggest_int('max_depth', 5, 10),
        'learning_rate' : trial.suggest_float('learning_rate', 0.01, 0.1)
    } 

    return search_space

for item in tqdm(data.columns):

    window_features = RollingFeatures(stats=['mean', 'min', 'max'], window_sizes=7)
    forecaster = ForecasterRecursive(
                    regressor       = HistGradientBoostingRegressor(random_state=123),
                    lags            = 14,
                    window_features = window_features
                )
    
    cv_search = OneStepAheadFold(initial_train_size = len(data_train))
    
    warnings.simplefilter('ignore', category=OneStepAheadValidationWarning)
    results_bayesian = bayesian_search_forecaster(
                        forecaster    = forecaster,
                        y             = data.loc[:end_val, item],
                        cv            = cv_search,
                        search_space  = search_space,
                        n_trials      = 20,
                        metric        = 'mean_absolute_error',
                        return_best   = True,
                        verbose       = False,
                        show_progress = False 
                    )
    
    cv_backtesting = TimeSeriesFold(
                        steps              = 7,
                        initial_train_size = len(data_train) + len(data_val),
                        refit              = False,
                        )
    metric, preds = backtesting_forecaster(
                        forecaster    = forecaster,
                        y             = data[item],
                        cv            = cv_backtesting,
                        metric        = 'mean_absolute_error',
                        verbose       = False,
                        show_progress = False
                    )

    items.append(item)
    mae_values.append(metric.at[0, 'mean_absolute_error'])

uni_series_mae = pd.Series(
                    data  = mae_values,
                    index = items,
                    name  = 'uni_series_mae'
                )
In [29]:
# Busqueda de hiperparámetros y backtesting de un modelo global para todos los items
# ======================================================================================
def search_space(trial):
    search_space  = {
        'lags'          : trial.suggest_categorical('lags', [7, 14]),
        'max_iter'      : trial.suggest_int('max_iter', 100, 500),
        'max_depth'     : trial.suggest_int('max_depth', 5, 10),
        'learning_rate' : trial.suggest_float('learning_rate', 0.01, 0.1)
    } 

    return search_space

forecaster_ms = ForecasterRecursiveMultiSeries(
                    regressor          = HistGradientBoostingRegressor(random_state=123),
                    lags               = 14,
                    transformer_series = StandardScaler(),
                    encoding           = 'ordinal'
                )

results_bayesian_ms = bayesian_search_forecaster_multiseries(
                        forecaster    = forecaster_ms,
                        series        = data.loc[:end_val, :],
                        levels        = None, # Si es None se seleccionan todos los niveles
                        cv            = cv_search,
                        search_space  = search_space,
                        n_trials      = 20,
                        metric        = 'mean_absolute_error',
                        return_best   = True,
                        verbose       = False,
                        show_progress = False 
                    )      

multi_series_mae, predictions_ms = backtesting_forecaster_multiseries(
                                       forecaster            = forecaster_ms,
                                       series                = data,
                                       levels                = None, # Si es None se seleccionan todos los niveles
                                       cv                    = cv_backtesting,
                                       metric                = 'mean_absolute_error',
                                       add_aggregated_metric = False,
                                       verbose               = False
                                   )
/home/ubuntu/anaconda3/envs/skforecast_14_py12/lib/python3.12/site-packages/skforecast/model_selection/_search.py:1827: OneStepAheadValidationWarning: One-step-ahead predictions are used for faster model comparison, but they may not fully represent multi-step prediction performance. It is recommended to backtest the final model for a more accurate multi-step performance estimate. 
 You can suppress this warning using: warnings.simplefilter('ignore', category=OneStepAheadValidationWarning)
  warnings.warn(
`Forecaster` refitted using the best-found lags and parameters, and the whole data set: 
  Lags: [ 1  2  3  4  5  6  7  8  9 10 11 12 13 14] 
  Parameters: {'max_iter': 352, 'max_depth': 9, 'learning_rate': 0.034448731526687745}
  Backtesting metric: 8.015025437227383
  Levels: ['item_1', 'item_2', 'item_3', 'item_4', 'item_5', 'item_6', 'item_7', 'item_8', 'item_9', 'item_10', 'item_11', 'item_12', 'item_13', 'item_14', 'item_15', 'item_16', 'item_17', 'item_18', 'item_19', 'item_20', 'item_21', 'item_22', 'item_23', 'item_24', 'item_25', 'item_26', 'item_27', 'item_28', 'item_29', 'item_30', 'item_31', 'item_32', 'item_33', 'item_34', 'item_35', 'item_36', 'item_37', 'item_38', 'item_39', 'item_40', 'item_41', 'item_42', 'item_43', 'item_44', 'item_45', 'item_46', 'item_47', 'item_48', 'item_49', 'item_50']

In [30]:
# Diferencia de la métrica de backtesting para cada item
# ======================================================================================
multi_series_mae = multi_series_mae.set_index('levels')
multi_series_mae.columns = ['multi_series_mae']
results = pd.concat((uni_series_mae, multi_series_mae), axis = 1)
results['improvement'] = results.eval('uni_series_mae - multi_series_mae')
results['improvement_(%)'] = 100 * results.eval('(uni_series_mae - multi_series_mae) / uni_series_mae')
results = results.round(2)

# Mejora media de todos los items
# ======================================================================================
results[['improvement', 'improvement_(%)']].agg(['mean', 'min', 'max'])
Out[30]:
improvement improvement_(%)
mean 0.5018 5.3168
min -0.0400 -0.7900
max 1.5100 13.1900
In [31]:
# Número de series con mejora positiva y negativa
# ======================================================================================
pd.Series(np.where(results['improvement_(%)'] < 0, 'negative', 'positive')).value_counts()
Out[31]:
positive    48
negative     2
Name: count, dtype: int64

Tras identificar la combinación de lags e hiperparámetros que logran el mejor rendimiento predictivo para cada forecaster, un número superior de modelos univariantes han logrado una mayor capacidad predictiva. Aun así, el modelo multiserie proporciona mejores resultados para la mayoría de los items.

Selección de predictores

La selección de predictores es el proceso de seleccionar un subconjunto de predictores relevantes (variables) para su uso en la construcción del modelo. Las técnicas de selección de predictores se utilizan por varias razones: para simplificar los modelos y hacerlos más fáciles de interpretar, para reducir el tiempo de entrenamiento, para evitar los problemas de dimensionalidad, para mejorar la generalización reduciendo el sobreajuste (formalmente, la reducción de la varianza), entre otros.

Skforecast es compatible con los métodos de selección implementados en scikit-learn. Existen varios métodos de selección de características, pero los más comunes son:

  • Recursive feature elimination (RFE)

  • Sequential Feature Selection (SFS)

  • Feature selection based on threshold (SelectFromModel)

💡 Tip

La selección de predictores es una herramienta poderosa para mejorar el rendimiento de los modelos de machine learning. Sin embargo, es computacionalmente costosa y puede llevar tiempo. Dado que el objetivo es encontrar el mejor subconjunto de variables, no el mejor modelo, no es necesario utilizar todo el conjunto de datos o un modelo muy complejo. En su lugar, se recomienda utilizar un pequeño subconjunto de datos y un modelo simple. Una vez que se haya identificado el mejor subconjunto de variables, el modelo puede entrenarse utilizando todo el conjunto de datos y una configuración más compleja.

Pesos en forecasting multiseries

Los pesos se utilizan para controlar la influencia que tiene cada observación en el entrenamiento del modelo. ForecasterAutoregMultiseries acepta dos tipos de pesos:

  • series_weights controla la importancia relativa de cada serie. Si una serie tiene el doble de peso que las demás, las observaciones de esa serie influyen el doble en el entrenamiento. Cuanto mayor sea el peso de una serie en relación con las demás, más se centrará el modelo en intentar aprender esa serie.

  • weight_func controla la importancia relativa de cada observación en función del índice. Por ejemplo, una función que asigna un peso menor a ciertas fechas.

Si se indican los dos tipos de pesos, estos se multiplican para crear los pesos finales como se muestra en la figura. El sample_weight resultante no puede contener valores negativos.

Más información sobre weights in multi-series forecasting y weighted time series forecasting con skforecast.

En este ejemplo, item_1 tiene una mayor importancia relativa entre series (pesa 3 veces más que el resto de series), y las observaciones entre '2013-12-01' y '2014-01-31' se consideran no representativas y se les aplica un peso de 0.

In [32]:
# Pesos en forecasting multiseries
# ======================================================================================
# Pesos de cada serie
series_weights = {'item_1': 3.0} # Las series que no aparezcan en el dict tienen un peso de 1

# Pesos de cada observación (por índice)
def custom_weights(index):
    """
    Devuelve 0 si el índice está entre '2013-12-01' y '2014-01-31', 1 en caso contrario.
    """
    weights = np.where(
                  (index >= '2013-12-01') & (index <= '2014-01-31'),
                   0,
                   1
              )
    
    return weights

forecaster = ForecasterRecursiveMultiSeries(
                 regressor          = HistGradientBoostingRegressor(random_state=123),
                 lags               = 14,
                 transformer_series = StandardScaler(),
                 transformer_exog   = None,
                 weight_func        = custom_weights, 
                 series_weights     = series_weights
             )

forecaster.fit(series=data)
forecaster.predict(steps=7).head(3)
/home/ubuntu/anaconda3/envs/skforecast_14_py12/lib/python3.12/site-packages/skforecast/recursive/_forecaster_recursive_multiseries.py:1383: IgnoredArgumentWarning: {'item_29', 'item_49', 'item_11', 'item_22', 'item_23', 'item_27', 'item_47', 'item_41', 'item_20', 'item_43', 'item_9', 'item_35', 'item_7', 'item_8', 'item_13', 'item_40', 'item_25', 'item_50', 'item_16', 'item_36', 'item_14', 'item_21', 'item_10', 'item_46', 'item_19', 'item_38', 'item_4', 'item_18', 'item_3', 'item_26', 'item_17', 'item_37', 'item_15', 'item_2', 'item_34', 'item_42', 'item_24', 'item_31', 'item_44', 'item_32', 'item_45', 'item_12', 'item_6', 'item_48', 'item_39', 'item_5', 'item_28', 'item_33', 'item_30'} not present in `series_weights`. A weight of 1 is given to all their samples. 
 You can suppress this warning using: warnings.simplefilter('ignore', category=IgnoredArgumentWarning)
  warnings.warn(
Out[32]:
item_1 item_10 item_11 item_12 item_13 item_14 item_15 item_16 item_17 item_18 ... item_46 item_47 item_48 item_49 item_5 item_50 item_6 item_7 item_8 item_9
2018-01-01 20.583876 61.580282 62.323468 62.581761 79.205140 56.450206 85.846570 22.718565 32.502652 79.377183 ... 53.421299 22.260726 47.144927 28.682841 22.372861 63.199237 53.271716 57.232493 68.655821 41.978625
2018-01-02 22.501538 71.421524 75.349079 75.283254 89.691063 59.121550 90.118244 24.848422 36.265055 87.467801 ... 62.983381 26.943935 51.961949 34.340793 19.502197 63.945234 57.812538 61.734525 82.100161 49.326013
2018-01-03 20.907183 73.185299 76.323151 75.547258 83.861397 63.508950 95.531382 25.331899 33.468072 90.110389 ... 62.890533 25.834892 52.942216 32.730445 18.498621 71.040223 61.123112 63.709698 84.395482 50.310361

3 rows × 50 columns

🖉 Nota

Se puede pasar un diccionario a `weight_func` para aplicar diferentes funciones a cada serie. Si una serie no se presenta en el diccionario, tendrá peso

Conclusiones

Este caso de uso muestra como un modelo multiserie puede presentar ventajas sobre varios modelos individuales cuando se predicen series temporales con una dinámica similar. Más allá de las posibles mejoras en la predicción, también es importante tener en cuenta la ventaja de tener un solo modelo que mantener.

Información de sesión

In [33]:
import session_info
session_info.show(html=False)
-----
matplotlib          3.9.2
numpy               2.0.2
pandas              2.2.3
session_info        1.0.0
skforecast          0.14.0
sklearn             1.5.1
tqdm                4.66.5
-----
IPython             8.27.0
jupyter_client      8.6.3
jupyter_core        5.7.2
notebook            6.4.12
-----
Python 3.12.5 | packaged by Anaconda, Inc. | (main, Sep 12 2024, 18:27:27) [GCC 11.2.0]
Linux-5.15.0-1071-aws-x86_64-with-glibc2.31
-----
Session information updated at 2024-11-06 15:28

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: modelado de múltiples series temporales con machine learning por Joaquín Amat Rodrigo and Javier Escobar Ortiz, disponible con licencia Attribution-NonCommercial-ShareAlike 4.0 International (CC BY-NC-SA 4.0 DEED) en https://www.cienciadedatos.net/documentos/py44-multi-series-forecasting-skforecast-español.html

¿Cómo citar skforecast?

Si utilizas skforecast en tu investigación o publicación, te lo agradeceríamos mucho que lo cites. ¡Muchas gracias!

Zenodo:

Amat Rodrigo, Joaquin, & Escobar Ortiz, Javier. (2024). skforecast (v0.14.0). Zenodo. https://doi.org/10.5281/zenodo.8382788

APA:

Amat Rodrigo, J., & Escobar Ortiz, J. (2024). skforecast (Version 0.14.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.14.0}, month = {11}, year = {2024}, license = {BSD-3-Clause}, url = {https://skforecast.org/}, doi = {10.5281/zenodo.8382788} }


¿Te ha gustado el artículo? Tu ayuda es importante

Mantener un sitio web tiene unos costes elevados, tu contribución me ayudará a seguir generando contenido divulgativo gratuito. ¡Muchísimas gracias! 😊


Creative Commons Licence
Este material 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.

  • NoComercial: No puedes utilizar el material para fines comerciales.

  • CompartirIgual: Si remezclas, transformas o creas a partir del material, debes distribuir tus contribuciones bajo la misma licencia que el original.