电力负荷预测的超参数优化

在本 notebook 中,我们将演示如何使用深度学习预测模型进行超参数优化,以便准确预测电力负荷并提供置信区间。

我们将使用此数据集(在 darts.datasets 中即可获取),其中包含一家葡萄牙能源公司的 370 位客户的电力消耗测量数据,频率为 15 分钟。我们将尝试预测未来 2 周。在此频率下,这意味着我们将尝试预测未来 2,688 个时间步。这是一个相当苛刻的要求,我们将尝试找到一个模型来完成此任务。

我们将使用开源 Optuna 库进行超参数优化,并使用 Darts 的 TCN 模型(参阅此处获取此模型的入门文章)。此模型采用扩张卷积,在捕获如此高频率(15 分钟)长时间(数周)序列的同时保持模型整体尺寸较小方面效果极佳。

建议使用 GPU 运行此 notebook,尽管所有概念无论模型运行在 CPU 还是 GPU 上都适用。

首先,我们安装并导入所需的库

[ ]:
# necessary packages:
!pip install -U darts
!pip install -U optuna
[1]:
%matplotlib inline


import matplotlib.pyplot as plt
import numpy as np
import optuna
import torch
from optuna.integration import PyTorchLightningPruningCallback
from optuna.visualization import (
    plot_contour,
    plot_optimization_history,
    plot_param_importances,
)
from pytorch_lightning.callbacks import EarlyStopping
from sklearn.preprocessing import MaxAbsScaler
from tqdm.notebook import tqdm

from darts.dataprocessing.transformers import Scaler
from darts.datasets import ElectricityDataset
from darts.metrics import smape
from darts.models import LinearRegressionModel, TCNModel
from darts.utils.likelihood_models.torch import GaussianLikelihood

数据准备

以下单元格可能需要几分钟才能执行完成。它将从互联网下载约 250 MB 的数据。我们指定 multivariate=False,因此我们获得 370 个单变量 TimeSeries 的列表。我们也可以指定 multivariate=True 以获得一个包含 370 个分量的多变量 TimeSeries

[2]:
all_series = ElectricityDataset(multivariate=False).load()

我们保留每个序列的最后 80 天数据,并将它们全部转换为 float32 类型

[3]:
NR_DAYS = 80
DAY_DURATION = 24 * 4  # 15 minutes frequency

all_series_fp32 = [
    s[-(NR_DAYS * DAY_DURATION) :].astype(np.float32) for s in tqdm(all_series)
]

我们有 370 个单变量 TimeSeries,每个的时间频率为 15 分钟。接下来,我们将在所有这些序列上训练一个单一的全局模型。

首先,我们创建训练集。我们将最后 14 天的数据作为测试集,并将之前的 14 天作为验证集(用于超参数优化)。

请注意,下面的 valtest 集仅包含序列中 14 天的“预测评估”部分。在本 notebook 中,我们将评估一些 14 天预测在 val(或 test)上的准确性。然而,为了生成这些 14 天的预测,我们的模型将需要消耗一定长度的回溯窗口 in_len 的时间戳。因此,下面我们还将创建包含这些额外 in_len 点的验证集(由于 in_len 本身是一个超参数,我们动态创建这些更长的验证集);这主要用于早停。

[4]:
# Split in train/val/test
val_len = 14 * DAY_DURATION  # 14 days

train = [s[: -(2 * val_len)] for s in all_series_fp32]
val = [s[-(2 * val_len) : -val_len] for s in all_series_fp32]
test = [s[-val_len:] for s in all_series_fp32]

# Scale so that the largest value is 1.
# This way of scaling perserves the sMAPE
scaler = Scaler(scaler=MaxAbsScaler())
train = scaler.fit_transform(train)
val = scaler.transform(val)
test = scaler.transform(test)

让我们绘制一些序列

[5]:
for i in [10, 50, 100, 150, 250, 350]:
    plt.figure(figsize=(15, 5))
    train[i].plot(label=f"{i}")
../_images/examples_17-hyperparameter-optimization_10_0.png
../_images/examples_17-hyperparameter-optimization_10_1.png
../_images/examples_17-hyperparameter-optimization_10_2.png
../_images/examples_17-hyperparameter-optimization_10_3.png
../_images/examples_17-hyperparameter-optimization_10_4.png
../_images/examples_17-hyperparameter-optimization_10_5.png

构建简单的线性模型

我们首先不进行任何超参数优化,尝试一个简单的线性回归模型。它将作为基线。在此模型中,我们使用 1 周的回溯窗口。

注意:要比线性回归表现好得多通常并非易事!我们建议在转向更复杂的模型之前,始终先考虑至少一个这样合理的简单基线。

LinearRegressionModel 封装了 sklearn.linear_model.LinearRegression,这可能需要大量的处理和内存。运行此单元格需要几分钟,除非您的系统至少有 20GB RAM,否则我们建议跳过它。

[6]:
lr_model = LinearRegressionModel(lags=7 * DAY_DURATION)
lr_model.fit(train);

让我们看看这个模型的表现如何

[7]:
def eval_model(preds, name, train_set=train, val_set=val):
    smapes = smape(preds, val_set)
    print(f"{name} sMAPE: {np.mean(smapes):.2f} +- {np.std(smapes):.2f}")

    for i in [10, 50, 100, 150, 250, 350]:
        plt.figure(figsize=(15, 5))
        train_set[i][-7 * DAY_DURATION :].plot()
        val_set[i].plot(label="actual")
        preds[i].plot(label="forecast")


lr_preds = lr_model.predict(series=train, n=val_len)
eval_model(lr_preds, "linear regression")
linear regression sMAPE: 16.01 +- 20.59
../_images/examples_17-hyperparameter-optimization_14_1.png
../_images/examples_17-hyperparameter-optimization_14_2.png
../_images/examples_17-hyperparameter-optimization_14_3.png
../_images/examples_17-hyperparameter-optimization_14_4.png
../_images/examples_17-hyperparameter-optimization_14_5.png
../_images/examples_17-hyperparameter-optimization_14_6.png

这个模型开箱即用,表现相当不错!现在让我们看看是否可以使用深度学习做得更好。

构建简单的 TCN 模型

现在我们构建一个使用一些简单超参数选择的 TCN 模型,但不进行任何超参数优化。

[8]:
""" We write a function to build and fit a TCN Model, which we will re-use later.
"""


def build_fit_tcn_model(
    in_len,
    out_len,
    kernel_size,
    num_filters,
    weight_norm,
    dilation_base,
    dropout,
    lr,
    include_dayofweek,
    likelihood=None,
    callbacks=None,
):
    # reproducibility
    torch.manual_seed(42)

    # some fixed parameters that will be the same for all models
    BATCH_SIZE = 1024
    MAX_N_EPOCHS = 30
    NR_EPOCHS_VAL_PERIOD = 1
    MAX_SAMPLES_PER_TS = 1000

    # throughout training we'll monitor the validation loss for early stopping
    early_stopper = EarlyStopping("val_loss", min_delta=0.001, patience=3, verbose=True)
    if callbacks is None:
        callbacks = [early_stopper]
    else:
        callbacks = [early_stopper] + callbacks

    # detect if a GPU is available
    if torch.cuda.is_available():
        pl_trainer_kwargs = {
            "accelerator": "gpu",
            "gpus": -1,
            "auto_select_gpus": True,
            "callbacks": callbacks,
        }
        num_workers = 4
    else:
        pl_trainer_kwargs = {"callbacks": callbacks}
        num_workers = 0

    # optionally also add the day of the week (cyclically encoded) as a past covariate
    encoders = {"cyclic": {"past": ["dayofweek"]}} if include_dayofweek else None

    # build the TCN model
    model = TCNModel(
        input_chunk_length=in_len,
        output_chunk_length=out_len,
        batch_size=BATCH_SIZE,
        n_epochs=MAX_N_EPOCHS,
        nr_epochs_val_period=NR_EPOCHS_VAL_PERIOD,
        kernel_size=kernel_size,
        num_filters=num_filters,
        weight_norm=weight_norm,
        dilation_base=dilation_base,
        dropout=dropout,
        optimizer_kwargs={"lr": lr},
        add_encoders=encoders,
        likelihood=likelihood,
        pl_trainer_kwargs=pl_trainer_kwargs,
        model_name="tcn_model",
        force_reset=True,
        save_checkpoints=True,
    )

    # when validating during training, we can use a slightly longer validation
    # set which also contains the first input_chunk_length time steps
    model_val_set = scaler.transform([
        s[-((2 * val_len) + in_len) : -val_len] for s in all_series_fp32
    ])

    # train the model
    model.fit(
        series=train,
        val_series=model_val_set,
        max_samples_per_ts=MAX_SAMPLES_PER_TS,
        dataloader_kwargs={"num_workers": num_workers},
    )

    # reload best model over course of training
    model = TCNModel.load_from_checkpoint("tcn_model")

    return model
[ ]:
model = build_fit_tcn_model(
    in_len=7 * DAY_DURATION,
    out_len=6 * DAY_DURATION,
    kernel_size=5,
    num_filters=5,
    weight_norm=False,
    dilation_base=2,
    dropout=0.2,
    lr=1e-3,
    include_dayofweek=True,
)
[10]:
preds = model.predict(series=train, n=val_len)
eval_model(preds, "First TCN model")
GPU available: True, used: True
TPU available: False, using: 0 TPU cores
IPU available: False, using: 0 IPUs
LOCAL_RANK: 0 - CUDA_VISIBLE_DEVICES: [0]
First TCN model sMAPE: 16.94 +- 20.03
../_images/examples_17-hyperparameter-optimization_18_3.png
../_images/examples_17-hyperparameter-optimization_18_4.png
../_images/examples_17-hyperparameter-optimization_18_5.png
../_images/examples_17-hyperparameter-optimization_18_6.png
../_images/examples_17-hyperparameter-optimization_18_7.png
../_images/examples_17-hyperparameter-optimization_18_8.png

上面,我们构建了第一个 TCN 模型,没有进行任何超参数搜索,sMAPE 约为 17%。尽管此模型看起来是一个好的开始(在某些序列上表现相当不错),但它不如简单的线性回归。

我们肯定可以做得更好,因为我们已经固定了许多参数,但它们可能对性能产生巨大影响,例如:

  • 网络的架构(滤波器数量、扩张大小、核大小等...)

  • 学习率

  • 是否使用权重归一化和/或 Dropout 率

  • 回溯和前瞻窗口的长度

  • 是否添加日历协变量,例如星期几

一种选择:使用 gridsearch()

尝试优化这些超参数的一种方法是尝试所有组合(假设我们已经将参数离散化)。Darts 提供了一个 gridsearch() 方法来执行此操作。优点是它非常易于使用。然而,它也有严重的缺点:

  • 它所需时间随超参数数量呈指数增长:因此,对任何非平凡数量的超参数进行网格搜索很快就变得无法处理。

  • 网格搜索是天真的:它不尝试关注超参数空间中比其他区域更有希望的区域。它仅限于预定义网格中的点。

  • 最后,出于简单性考虑,Darts 的 gridsearch() 方法(至少在撰写本文时)仅限于处理一个时间序列。

由于所有这些原因,对于任何严肃的超参数搜索,我们需要比网格搜索更好的技术。幸运的是,有一些很棒的工具可以帮助我们。

使用 Optuna

Optuna 是一个非常优秀的开源超参数优化库。它基于贝叶斯优化等思想,平衡了探索(超参数空间)与利用(即更多地探索那些看起来更有前景的空间区域)。它还可以使用剪枝功能,以便提前停止没有前途的实验。

让它工作非常简单:Optuna 将负责为我们建议(采样)超参数,而我们需要做的基本上就是计算一组超参数的目标值。在我们的案例中,这包括使用这些超参数构建模型、训练模型,并报告获得的验证准确率。我们还设置了一个 PyTorch Lightning 剪枝回调函数,以便提前停止没有前途的实验。所有这些都在下面的 objective() 函数中完成。

[16]:
def objective(trial):
    callback = [PyTorchLightningPruningCallback(trial, monitor="val_loss")]

    # set input_chunk_length, between 5 and 14 days
    days_in = trial.suggest_int("days_in", 5, 14)
    in_len = days_in * DAY_DURATION

    # set out_len, between 1 and 13 days (it has to be strictly shorter than in_len).
    days_out = trial.suggest_int("days_out", 1, days_in - 1)
    out_len = days_out * DAY_DURATION

    # Other hyperparameters
    kernel_size = trial.suggest_int("kernel_size", 5, 25)
    num_filters = trial.suggest_int("num_filters", 5, 25)
    weight_norm = trial.suggest_categorical("weight_norm", [False, True])
    dilation_base = trial.suggest_int("dilation_base", 2, 4)
    dropout = trial.suggest_float("dropout", 0.0, 0.4)
    lr = trial.suggest_float("lr", 5e-5, 1e-3, log=True)
    include_dayofweek = trial.suggest_categorical("dayofweek", [False, True])

    # build and train the TCN model with these hyper-parameters:
    model = build_fit_tcn_model(
        in_len=in_len,
        out_len=out_len,
        kernel_size=kernel_size,
        num_filters=num_filters,
        weight_norm=weight_norm,
        dilation_base=dilation_base,
        dropout=dropout,
        lr=lr,
        include_dayofweek=include_dayofweek,
        callbacks=callback,
    )

    # Evaluate how good it is on the validation set
    preds = model.predict(series=train, n=val_len)
    smapes = smape(val, preds, n_jobs=-1, verbose=True)
    smape_val = np.mean(smapes)

    return smape_val if smape_val != np.nan else float("inf")

现在我们已经指定了目标,我们需要做的就是创建一个 Optuna 研究,并运行优化。我们可以要求 Optuna 运行指定的时间(如我们在此处所做),或者运行一定数量的试验。让我们运行优化几个小时

[ ]:
def print_callback(study, trial):
    print(f"Current value: {trial.value}, Current params: {trial.params}")
    print(f"Best value: {study.best_value}, Best params: {study.best_trial.params}")


study = optuna.create_study(direction="minimize")

study.optimize(objective, timeout=7200, callbacks=[print_callback])

# We could also have used a command as follows to limit the number of trials instead:
# study.optimize(objective, n_trials=100, callbacks=[print_callback])

# Finally, print the best value and best hyperparameters:
print(f"Best value: {study.best_value}, Best params: {study.best_trial.params}")

注意:如果我们想进一步优化,我们仍然可以多次调用 study.optimize() 从上次中断的地方继续。

使用 Optuna 您还可以做更多事情。有关更多信息,请参阅其文档。例如,可以通过可视化目标值历史(跨试验)、目标值作为某些超参数的函数,或某些超参数的总体重要性来获取有关优化过程的有用见解。

[16]:
plot_optimization_history(study)
[18]:
plot_contour(study, params=["lr", "num_filters"])
[19]:
plot_param_importances(study)

选择最佳模型

在 GPU 上运行超参数优化几个小时后,我们得到了:

Best value: 14.720555851487694, Best params: {'days_in': 14, 'days_out': 6, 'kernel_size': 19, 'num_filters': 19, 'weight_norm': True, 'dilation_base': 4, 'dropout': 0.07718156729165897, 'lr': 0.0008841998396117885, 'dayofweek': False}

现在我们可以使用这些超参数再次训练“最佳”模型。这次,我们将直接尝试拟合一个概率模型(使用高斯似然)。请注意,这实际上改变了损失函数,因此我们希望我们的超参数在这方面不太敏感。

[ ]:
best_model = build_fit_tcn_model(
    in_len=14 * DAY_DURATION,
    out_len=6 * DAY_DURATION,
    kernel_size=19,
    num_filters=19,
    weight_norm=True,
    dilation_base=4,
    dropout=0.0772,
    lr=0.0008842,
    likelihood=GaussianLikelihood(),
    include_dayofweek=False,
)

现在让我们看看随机预测的准确性,使用 100 个样本:

[51]:
best_preds = best_model.predict(
    series=train, n=val_len, num_samples=100, mc_dropout=True
)
eval_model(best_preds, "best model, probabilistic")
LOCAL_RANK: 0 - CUDA_VISIBLE_DEVICES: [0]
best model, probabilistic sMAPE: 15.21 +- 19.76
../_images/examples_17-hyperparameter-optimization_31_3.png
../_images/examples_17-hyperparameter-optimization_31_4.png
../_images/examples_17-hyperparameter-optimization_31_5.png
../_images/examples_17-hyperparameter-optimization_31_6.png
../_images/examples_17-hyperparameter-optimization_31_7.png
../_images/examples_17-hyperparameter-optimization_31_8.png

准确性看起来确实很好,而且这个模型没有遇到我们最初的线性回归和早期 TCN 模型遇到的一些相同问题(例如,看看它在序列 150 上曾经出现的失败模式)。

现在让我们也看看它在测试集上的表现如何:

[50]:
train_val_set = scaler.transform([s[:-val_len] for s in all_series_fp32])

best_preds_test = best_model.predict(
    series=train_val_set, n=val_len, num_samples=100, mc_dropout=True
)

eval_model(
    best_preds_test,
    "best model, probabilistic, on test set",
    train_set=train_val_set,
    val_set=test,
)
LOCAL_RANK: 0 - CUDA_VISIBLE_DEVICES: [0]
best model, probabilistic, on test set sMAPE: 19.21 +- 21.43
../_images/examples_17-hyperparameter-optimization_33_3.png
../_images/examples_17-hyperparameter-optimization_33_4.png
../_images/examples_17-hyperparameter-optimization_33_5.png
../_images/examples_17-hyperparameter-optimization_33_6.png
../_images/examples_17-hyperparameter-optimization_33_7.png
../_images/examples_17-hyperparameter-optimization_33_8.png

在测试集上的表现不如验证集,但仔细检查发现这似乎是由于圣诞节期间,一些客户(不足为奇地)改变了他们的消费习惯。除了圣诞节期间,预测的质量似乎与验证集期间大致相当,这很好地表明我们可能没有将超参数优化过度拟合到验证集上。

为了进一步改进此模型,考虑使用捕获公共假期的指标变量(我们在此处尚未这样做)可能是一个好主意。

作为最后一个实验,让我们看看我们的线性回归模型在测试集上的表现如何:

[43]:
lr_model = LinearRegressionModel(lags=7 * DAY_DURATION)
lr_preds_test = lr_model.predict(series=train_val_set, n=val_len)

eval_model(
    lr_preds_test,
    "linear regression, on test set",
    train_set=train_val_set,
    val_set=test,
)
linear regression, on test set sMAPE: 20.07 +- 22.67
../_images/examples_17-hyperparameter-optimization_35_1.png
../_images/examples_17-hyperparameter-optimization_35_2.png
../_images/examples_17-hyperparameter-optimization_35_3.png
../_images/examples_17-hyperparameter-optimization_35_4.png
../_images/examples_17-hyperparameter-optimization_35_5.png
../_images/examples_17-hyperparameter-optimization_35_6.png

结论

在本 notebook 中,我们看到 Optuna 可以无缝地用于优化 Darts 模型的超参数。实际上,对于超参数优化而言,Darts 并没有什么特别之处:Optuna 和其他库可以像用于其他框架一样使用。唯一需要注意的是 PyTorch Lightning 集成,它通过 Darts 提供。

附带结论:我们应该选择线性回归还是 TCN 来预测电力消耗?

这两种方法都有优缺点。

线性回归的优点

  • 简单

  • 不需要缩放

  • 速度快

  • 不需要 GPU

  • 通常开箱即用表现良好,无需调优

线性回归的缺点

  • 可能需要大量内存(在此处用作全局模型时),尽管有解决方法(例如,基于 SGD 的)。

  • 在我们的设置中,训练 LinearRegression 模型的随机版本是不切实际的,因为这将导致过高的计算复杂度。

TCN 的优点

  • 潜力更大,更易于调优

  • 由于 SGD,通常内存需求较低

  • 提供非常丰富的支持,以不同方式捕获随机性,而无需显著增加计算量

  • 模型训练完成后,对多个时间序列进行批量推断非常快 - 特别是使用 GPU 时

TCN 的缺点

  • 超参数更多,调优时间更长,过拟合风险更高。这也意味着模型更难工业化和维护

  • 通常需要 GPU

[ ]: