电力负荷预测的超参数优化¶
在本笔记本中,我们演示了如何使用深度学习预测模型进行超参数优化,以便准确预测电力负荷并提供置信区间。
我们将使用 这个数据集 <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天作为验证集(将用于超参数优化)。
请注意,下面的 val 和 test 数据集仅包含序列的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))
构建一个简单的线性模型¶
我们首先不进行任何超参数优化,尝试一个简单的线性回归模型。它将作为基线。在这个模型中,我们使用一个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
这个模型已经开箱即用表现得相当不错!现在让我们看看是否可以使用深度学习做得更好。
构建一个简单的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
在上文中,我们构建了一个没有任何超参数搜索的第一个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
准确性看起来非常好,这个模型没有遇到我们最初的线性回归和早期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
在测试集上的表现并不理想,但仔细检查后发现,这似乎是由于圣诞节期间,一些客户(不出所料地)改变了他们的消费习惯。除了圣诞节期间,预测的质量似乎与我们在验证集期间的表现大致相当,这表明我们可能没有过度拟合验证集的超参数优化。
为了进一步改进这个模型,考虑使用捕捉公共假日的指示变量(我们在这里没有这样做)可能是一个好主意。
作为最后一个实验,让我们看看我们的线性回归模型在测试集上的表现如何:
[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
结论¶
在本笔记本中,我们已经看到 Optuna 可以无缝地用于优化 Darts 模型的超参数。事实上,在超参数优化方面,Darts 并没有什么特别之处:Optuna 和其他库可以像与其他框架一起使用一样使用。唯一需要注意的是 PyTorch Lightning 集成,这些集成可以通过 Darts 获得。
侧面结论:我们应该使用线性回归还是TCN来预测电力消耗?¶
两种方法各有优缺点。
线性回归的优点:
简单性
不需要缩放
速度
不需要GPU
通常开箱即用,无需调整即可提供良好的性能
线性回归的缺点:
虽然可以需要大量的内存(如这里作为全局模型使用时),但也有解决方法(例如,基于SGD的方法)。
在我们的设置中,训练
LinearRegression模型的随机版本是不切实际的,因为这会导致过大的计算复杂度。
TCN, 优点:
可能更具可调性和强大功能
通常由于SGD而具有较低的内存需求
非常丰富的支持以不同方式捕捉随机性,而无需显著增加计算量
一旦模型训练完成,可以在许多时间序列上进行非常快速的批量推理 - 特别是如果使用GPU。
TCN, 缺点:
更多的超参数,可能需要更长时间来调整,并带来更大的过拟合风险。这也意味着模型更难工业化和维护。
通常需要GPU
[ ]: