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

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

我们将使用 这个数据集 <https://archive.ics.uci.edu/ml/datasets/ElectricityLoadDiagrams20112014>`__(在 ``darts.datasets` 中可直接获取),该数据集包含葡萄牙一家能源公司的370名客户的电力消耗测量数据,频率为15分钟。我们将尝试对未来两周进行预测。在这个频率下,这意味着我们尝试预测未来2,688个时间步。这是一个相当高的要求,我们将尝试找到一个 的模型来完成这个任务。

我们将使用开源的 Optuna库 进行超参数优化,以及Darts的 TCN模型 (参见 这里 以获取该模型的介绍文章)。该模型采用扩张卷积,在捕捉高频序列(15分钟)并覆盖长时间段(多周)时表现出色,同时保持较小的模型整体尺寸。

建议使用GPU来运行此笔记本,尽管所有概念都适用于模型在CPU或GPU上运行的情况。

首先,我们安装并导入所需的内容:

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

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

from darts.datasets import ElectricityDataset
from darts.models import TCNModel, LinearRegressionModel
from darts.dataprocessing.transformers import Scaler
from darts.metrics import smape
from darts.utils.likelihood_models 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天“预测评估”部分。在整个笔记本中,我们将评估一些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="{}".format(i, lw=1))
../_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("{} sMAPE: {:.2f} +- {:.2f}".format(name, np.mean(smapes), np.std(smapes)))

    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,
        num_loader_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模型,并得到了大约17%的sMAPE。虽然这个模型看起来是一个不错的开始(在一些序列上表现相当好),但它并不如简单的线性回归那样好。

我们当然可以做得更好,因为有许多参数我们固定了,但它们可能对性能有重大影响,例如:

  • 网络的架构(滤波器数量、膨胀大小、核大小等…)

  • 学习率

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

  • 回溯和前瞻窗口的长度

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

一个选项:使用 gridsearch()

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

  • 它需要指数级的时间在超参数的数量上:因此,对任何非平凡数量的超参数进行网格搜索很快就会变得难以处理。

  • 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

结论

在本笔记本中,我们已经看到 Optuna 可以无缝地用于优化 Darts 模型的超参数。事实上,在超参数优化方面,Darts 并没有什么特别之处:Optuna 和其他库可以像与其他框架一起使用一样使用。唯一需要注意的是 PyTorch Lightning 集成,这些集成可以通过 Darts 获得。

侧面结论:我们应该使用线性回归还是TCN来预测电力消耗?

两种方法各有优缺点。

线性回归的优点:

  • 简单性

  • 不需要缩放

  • 速度

  • 不需要GPU

  • 通常开箱即用,无需调整即可提供良好的性能

线性回归的缺点:

  • 虽然可以需要大量的内存(如这里作为全局模型使用时),但也有解决方法(例如,基于SGD的方法)。

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

TCN, 优点:

  • 可能更具可调性和强大功能

  • 通常由于SGD而具有较低的内存需求

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

  • 一旦模型训练完成,可以在许多时间序列上进行非常快速的批量推理 - 特别是如果使用GPU。

TCN, 缺点:

  • 更多的超参数,可能需要更长时间来调整,并带来更大的过拟合风险。这也意味着模型更难工业化和维护。

  • 通常需要GPU

[ ]: