《DEEP LEARNING with Python》第十三章 时间序列预测

第十三章 时间序列预测

Timeseries forecasting

运行代码

在 Colab 上运行

在 GitHub 上查看

本章内容

  • 时间序列机器学习概述
  • 理解循环神经网络(RNN)
  • 将循环神经网络应用于温度预测示例
  • An overview of machine learning for timeseries
  • Understanding recurrent neural networks (RNNs)
  • Applying RNNs to a temperature forecasting example

本章探讨时间序列,其中时间顺序至关重要。我们将重点关注最常见且最有价值的时间序列任务:预测。利用近期数据预测未来趋势是一项强大的能力,无论您是想预测能源需求、管理库存,还是仅仅预测天气。

不同类型的时间序列任务

Different kinds of timeseries tasks

时间序列可以是任何通过定期测量获得的数据,例如股票的每日价格、城市的每小时用电量或商店的每周销售额。时间序列无处不在,无论我们观察的是自然现象(例如地震活动、河流中鱼类种群的演变或某个地点的天气),还是人类活动模式(例如网站访问量、国家GDP或信用卡交易)。与您目前接触过的数据类型不同,处理时间序列需要理解系统的动态特性——其周期性变化、随时间推移的趋势、常规状态以及突发峰值。

迄今为止,最常见的时间序列相关任务是预测:预测序列中接下来会发生什么。例如,提前几个小时预测用电量以便预估需求,提前几个月预测收入以便制定预算,提前几天预测天气以便安排日程。本章重点讨论预测。但实际上,时间序列还可以用于许多其他用途,例如:

  • 异常检测——检测连续数据流中发生的任何异常情况。企业网络出现异常活动?可能是攻击者。生产线上的读数异常?需要人工检查。异常检测通常采用无监督学习,因为您通常不知道要查找哪种异常,因此无法使用特定的异常示例进行训练。
  • 分类——为时间序列分配一个或多个类别标签。例如,给定网站访问者的活动时间序列,判断该访问者是机器人还是真人。
  • 事件检测——识别连续数据流中特定预期事件的发生情况。一个特别有用的应用是“热词检测”,其中模型监控音频流并检测诸如“OK Google”或“Hey Alexa”之类的语音指令。
  • Anomaly detection—Detect anything unusual happening within a continuous data stream. Unusual activity on your corporate network? Might be an attacker. Unusual readings on a manufacturing line? Time for a human to go take a look. Anomaly detection is typically done via unsupervised learning, because you often don’t know what kind of anomaly you’relooking for, and thus you can’t train on specific anomaly examples.
  • Classification—Assign one or more categorical labels to a timeseries. For instance, given the timeseries of activity of a visitor on a website, classify whether the visitor is a bot or a human.
  • Event detection—Identify the occurrence of a specific, expected event within a continuous data stream. A particularly useful application is “hotword detection,” where a model monitors an audio stream and detects utterances like “OK, Google” or “Hey, Alexa.”

在本章中,你将学习循环神经网络(RNN)以及如何将其应用于时间序列预测。

温度预测示例

A temperature forecasting example

在本章中,我们所有的代码示例都将针对同一个问题:根据建筑物屋顶上的一组传感器记录的近期每小时大气压力和湿度等物理量的时间序列测量值,预测24小时后的温度。正如您将看到的,这是一个相当具有挑战性的问题!

我们将利用这个温度预测任务来突出时间序列数据与你迄今为止遇到的数据集类型之间的根本区别,表明密集连接网络和卷积网络并不适合处理它,并展示一种真正擅长解决此类问题的新型机器学习技术:循环神经网络(RNN)。

我们将使用德国耶拿马克斯·普朗克生物地球化学研究所气象站记录的天气时间序列数据集。[1]该数据集记录了14种不同的物理量(例如温度、气压、湿度、风向等),每10分钟记录一次,时间跨度为数年。原始数据可追溯至2003年,但我们下载的数据子集仅限于2009年至2016年。

我们先来下载并解压缩数据:

1
2
!wget https://s3.amazonaws.com/keras-datasets/jena_climate_2009_2016.csv.zip
!unzip jena_climate_2009_2016.csv.zip

我们来看一下数据。

1
2
3
4
5
6
7
8
9
10
11
12
import os

fname = os.path.join("jena_climate_2009_2016.csv")

with open(fname) as f:
data = f.read()

lines = data.split("\n")
header = lines[0].split(",")
lines = lines[1:]
print(header)
print(len(lines))

清单 13.1:检查耶拿天气数据集的数据

这将输出 420,551 行数据(每行是一个时间步长:记录一个日期和 14 个与天气相关的值),以及以下标题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
["Date Time",
"p (mbar)",
"T (degC)",
"Tpot (K)",
"Tdew (degC)",
"rh (%)",
"VPmax (mbar)",
"VPact (mbar)",
"VPdef (mbar)",
"sh (g/kg)",
"H2OC (mmol/mol)",
"rho (g/m**3)",
"wv (m/s)",
"max. wv (m/s)",
"wd (deg)"]

现在,将所有 420,551 行数据转换为 NumPy 数组:一个数组用于存储温度(单位为摄氏度),另一个数组用于存储其余数据——我们将使用这些特征来预测未来温度。请注意,我们舍弃了“日期时间”列。

1
2
3
4
5
6
7
8
9
10
11
12
import numpy as np

temperature = np.zeros((len(lines),))
raw_data = np.zeros((len(lines), len(header) - 1))

for i, line in enumerate(lines):
values = [float(x) for x in line.split(",")[1:]]
# We store column 1 in the temperature array.
temperature[i] = values[1]
# We store all columns (including the temperature) in the raw_data
# array.
raw_data[i, :] = values[:]

清单 13.2:解析数据

图 13.1 显示了温度(单位:摄氏度)随时间变化的曲线图。从图中可以清晰地看出温度的年度周期性变化——数据跨越了八年。

1
2
3
from matplotlib import pyplot as plt

plt.plot(range(len(temperature)), temperature)

清单 13.3:绘制温度时间序列图

img图 13.1:数据集整个时间范围内的温度(ºC)

图 13.2 显示了前 10 天温度数据的更窄范围图。由于数据每 10 分钟记录一次,因此每天有 24 × 6 = 144 个数据点。

1
plt.plot(range(1440), temperature[:1440])

清单 13.4:绘制温度时间序列的前 10 天

img图 13.2:数据集前 10 天的温度(ºC)

从这张图中可以看出每日周期性变化,尤其最近四天更为明显。另外需要注意的是,这十天的周期应该来自一个相当寒冷的冬季月份。

时间序列数据具有跨多个时间尺度的周期性,这是一个重要且非常常见的特征。无论是天气、商场停车场占用率、网站流量、超市销售额,还是健身追踪器记录的步数,你都会看到每日周期和年度周期(人为生成的数据通常也具有每周周期)。在探索数据时,务必留意这些模式。

如果使用我们的数据集,根据过去几个月的数据预测下个月的平均气温,由于数据具有可靠的年度周期性,这个问题很容易解决。但如果将数据尺度缩小到天,气温的变化就显得更加混乱。那么,这个时间序列在日尺度上是可以预测的吗?让我们一探究竟。

在所有实验中,我们将使用前 50% 的数据进行训练,接下来的 25% 用于验证,最后 25% 用于测试。处理时间序列数据时,使用比训练数据更新的验证和测试数据至关重要,因为我们试图根据过去预测未来,而不是反过来,因此验证/测试数据的划分应该反映这种时间顺序。有些问题如果反转时间轴,反而会变得简单得多!

1
2
3
4
5
6
7
8
9
>>> num_train_samples = int(0.5 * len(raw_data))
>>> num_val_samples = int(0.25 * len(raw_data))
>>> num_test_samples = len(raw_data) - num_train_samples - num_val_samples
>>> print("num_train_samples:", num_train_samples)
>>> print("num_val_samples:", num_val_samples)
>>> print("num_test_samples:", num_test_samples)
num_train_samples: 210225
num_val_samples: 105112
num_test_samples: 105114

清单 13.5:计算每次数据分割的样本数量

数据准备

Preparing the data

问题的具体表述如下:给定过去五天每小时采样一次的数据,我们能否预测 24 小时后的温度?

首先,我们需要将数据预处理成神经网络可以接收的格式。这很简单:数据本身就是数值型的,所以不需要进行任何向量化处理。但是,数据中的每个时间序列的尺度都不同(例如,大气压强以毫巴为单位,约为 1000;而 H₂O 以毫摩尔每摩尔为单位,约为 3)。我们将分别对每个时间序列进行归一化,使它们在相似的尺度上都取较小的值。我们将使用前 210,225 个时间步长的数据作为训练数据,因此我们只会基于这部分数据计算均值和标准差。

1
2
3
4
mean = raw_data[:num_train_samples].mean(axis=0)
raw_data -= mean
std = raw_data[:num_train_samples].std(axis=0)
raw_data /= std

清单 13.6:数据归一化

接下来,我们创建一个Dataset对象,该对象会生成过去五天的数据批次以及未来 24 小时的目标温度。由于数据集中的样本高度冗余(样本N和样本的N + 1大部分时间步长都相同),因此为每个样本显式分配内存会造成浪费。相反,我们将动态生成样本,内存中仅保留原始raw_data数组temperature,不包含其他任何内容。

我们可以轻松地用 Python 生成器来实现这个功能,但 Keras 中有一个内置的数据集工具可以做到这一点(timeseries_dataset_from_array()),所以我们可以直接使用它来节省一些工作。它通常可以用于任何类型的时间序列预测任务。

理解 timeseries_dataset_from_array()

Understanding timeseries_dataset_from_array()

为了理解它的作用timeseries_dataset_from_array(),我们来看一个简单的例子。其基本思路是,你提供一个时间序列数据数组(参数data),它timeseries_dataset_from_array会返回从原始时间序列中提取的窗口(我们称之为“序列”)。

假设你正在使用data = [0 1 2 3 4 5 6]和;sequence_length=3那么timeseries_dataset_from_array将生成以下样本: [0 1 2][1 2 3][2 3 4][3 4 5][4 5 6]

您还可以将target数组传递给该函数timeseries_dataset_from_array()。数组的第一个元素targets应该与要从该数组生成的第一个序列的目标值相匹配data。因此,如果您要进行时间序列预测,只需将数组作为targetsfor 函数相同的数组data,并偏移一定量即可。

例如,使用 x``data = [0 1 2 3 4 5 6 ...]y sequence_length=3,您可以通过传递 x 来创建一个数据集,以预测序列中的下一步 targets = [3 4 5 6 ...]。让我们来试一试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import numpy as np
import keras

# Generate an array of sorted integers from 0 to 9.
int_sequence = np.arange(10)
dummy_dataset = keras.utils.timeseries_dataset_from_array(
# The sequences we generate will be sampled from [0 1 2 3 4 5 6].
data=int_sequence[:-3],
# The target for the sequence that starts at data[N] will be data[N
# + 3].
targets=int_sequence[3:],
# The sequences will be 3 steps long.
sequence_length=3,
# The sequences will be batched in batches of size 2.
batch_size=2,
)

for inputs, targets in dummy_dataset:
for i in range(inputs.shape[0]):
print([int(x) for x in inputs[i]], int(targets[i]))

这段代码会输出以下结果:

1
2
3
4
5
[0, 1, 2] 3
[1, 2, 3] 4
[2, 3, 4] 5
[3, 4, 5] 6
[4, 5, 6] 7

我们将用它timeseries_dataset_from_array来实例化三个数据集:一个用于训练,一个用于验证,一个用于测试。

我们将使用以下参数值:

  • sampling_rate = 6 — 每小时抽取一个数据点进行观测:我们将只保留六个数据点中的一个。
  • sequence_length = 120 — 观测数据将追溯至五天前(120 小时)。
  • delay = sampling_rate * (sequence_length + 24 - 1) — 序列的目标温度将是序列结束后 24 小时的温度。
  • start_index = 0对于end_index = num_train_samples训练数据集,仅使用前 50% 的数据。
  • start_index = num_train_samples对于end_index = num_train_samples + num_val_samples验证数据集,仅使用接下来的 25% 的数据。
  • start_index = num_train_samples + num_val_samples— 对于测试数据集,使用剩余的样本。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
sampling_rate = 6
sequence_length = 120
delay = sampling_rate * (sequence_length + 24 - 1)
batch_size = 256

train_dataset = keras.utils.timeseries_dataset_from_array(
raw_data[:-delay],
targets=temperature[delay:],
sampling_rate=sampling_rate,
sequence_length=sequence_length,
shuffle=True,
batch_size=batch_size,
start_index=0,
end_index=num_train_samples,
)

val_dataset = keras.utils.timeseries_dataset_from_array(
raw_data[:-delay],
targets=temperature[delay:],
sampling_rate=sampling_rate,
sequence_length=sequence_length,
shuffle=True,
batch_size=batch_size,
start_index=num_train_samples,
end_index=num_train_samples + num_val_samples,
)

test_dataset = keras.utils.timeseries_dataset_from_array(
raw_data[:-delay],
targets=temperature[delay:],
sampling_rate=sampling_rate,
sequence_length=sequence_length,
shuffle=True,
batch_size=batch_size,
start_index=num_train_samples + num_val_samples,
)

清单 13.7:实例化用于训练、验证和测试的数据集

每个数据集生成一个元组(samples, targets),其中samples是包含 256 个样本的批次,每个样本包含 120 小时的连续输入数据,targets是对应的 256 个目标温度数组。请注意,样本是随机打乱的,因此同一批次中的两个连续序列(例如samples[0]samples[1])在时间上不一定接近。

个人注:

在处理时间序列时,初学者往往会陷入一个直觉误区:认为“训练过程的随机化”会破坏“时间维度的连续性”。

其实,我们需要区分两个完全不同的概念:序列内部的连续性(Temporal Order inside a sample)和样本之间的顺序(Order of samples in a batch)。

  1. 序列内部:必须严格有序

对于每一个单独的样本(Sample),比如你提到的那 120 小时 的数据,它的内部逻辑必须是严格按时间排序的:

  • \(t_1, t_2, \ldots, t_{120}\) \(\rightarrow\) 预测 \(t_{121}\)
  • 如果你把这 120 小时内部打乱,模型就无法学习到“趋势”和“周期”,卷积或循环神经网络也就失去了意义。
  1. 样本之间:为什么要随机打乱(Shuffle)?

当你把成千上万个“120 小时长”的片段喂给模型时,为什么要打乱这些片段的先后顺序(比如让 samples[0] 是 1 月份的数据,samples[1] 是 8 月份的数据)?

A. 破坏由于时间带来的“局部相关性”

时间序列数据通常具有很强的自相关性。如果按顺序训练:

  • 连续几个 Batch 全是“冬天”的数据,模型为了降低 Loss,会简单粗暴地学会“预测气温很低”。
  • 接下来几个 Batch 全是“夏天”,模型又会被强行拉向另一个极端。
  • 这种震荡会导致梯度下降过程非常不稳定。打乱顺序可以让每个 Batch 都成为整体数据的一个“微型缩影”,让梯度更新更平滑。

B. 避免过拟合到特定序列

如果模型总是以相同的顺序看到数据,它可能会产生一种“作弊”式记忆,记住“1 月之后必接 2 月”这种宏观顺序,而不是学习如何根据前 120 小时的特征来推断未来。

  1. 形象的类比:就像考卷上的题目
  • 序列内部:就像一道数学证明题。步骤 1、步骤 2、步骤 3 必须按顺序写,否则逻辑不通。
  • 样本打乱:就像整张考卷有 100 道证明题。你可以先做第 50 题,再做第 1 题,这并不会影响你完成某一道具体题目时的逻辑,反而能防止你因为疲劳或思维定式(比如以为后面的题一定比前面的难)而产生偏差。
  1. 例外情况:什么时候不能打乱?

只有在一种特殊情况下,样本顺序不能乱:有状态的 RNN (Stateful RNN)

在这种模式下,模型会把上一个样本(Batch n)结束时的隐藏状态(Hidden State)传给下一个样本(Batch n+1)。这就要求样本在时间轴上必须是无缝衔接的。但在《Deep Learning with Python》的大多数实例中,我们使用的是 Stateless(无状态) 模式,每个序列都是独立预测的,因此打乱是安全的且必要的。

1
2
3
4
5
6
>>> for samples, targets in train_dataset:
>>> print("samples shape:", samples.shape)
>>> print("targets shape:", targets.shape)
>>> break
samples shape: (256, 120, 14)
targets shape: (256,)

清单 13.8:检查数据集

一个符合常识、非机器学习的基线

A commonsense, non-machine-learning baseline

在你开始使用黑盒深度学习模型解决温度预测问题之前,让我们先尝试一种简单易懂的方法。这可以作为一次合理的检验,并建立一个基准线,你需要超越这个基准线才能证明更高级的机器学习模型的有效性。当你遇到一个尚无已知解决方案的新问题时,这种常识性的基准线就非常有用。一个经典的例子是不平衡分类任务,其中某些类别远比其他类别常见。如果你的数据集包含 90% 的 A 类样本和 10% 的 B 类样本,那么一种符合常理的分类方法是,当遇到新样本时,始终预测为“A”。这样的分类器总体准确率为 90%,因此任何基于机器学习的方法都应该超过 90% 的准确率才能证明其有效性。有时,这种基本的基准线可能会出乎意料地难以超越。

在这种情况下,可以合理地假设温度时间序列是连续的(明天的温度很可能与今天的温度接近),并且具有日周期性。因此,一种合理的做法是始终预测24小时后的温度等于当前温度。让我们使用平均绝对误差(MAE)指标来评估这种方法,其定义如下:

1
np.mean(np.abs(preds - targets))

这是评估循环。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def evaluate_naive_method(dataset):
total_abs_err = 0.0
samples_seen = 0
for samples, targets in dataset:
# The temperature feature is at column 1, so `samples[:, -1,
# 1]` is the last temperature measurement in the input
# sequence. Recall that we normalized our features to retrieve
# a temperature in Celsius degrees, we need to un-normalize it,
# by multiplying it by the standard deviation and adding back
# the mean.
preds = samples[:, -1, 1] * std[1] + mean[1]
total_abs_err += np.sum(np.abs(preds - targets))
samples_seen += samples.shape[0]
return total_abs_err / samples_seen

print(f"Validation MAE: {evaluate_naive_method(val_dataset):.2f}")
print(f"Test MAE: {evaluate_naive_method(test_dataset):.2f}")

清单 13.9:计算常识基线 MAE

这个基于常识的基准模型在验证集上的平均绝对误差 (MAE) 为 2.44 摄氏度,在测试集上的平均绝对误差为 2.62 摄氏度。因此,如果你总是假设 24 小时后的温度与现在的温度相同,那么平均误差将达到 2.5 摄氏度。这不算太糟糕,但你可能不会基于这种启发式方法来推出天气预报服务。现在,关键在于运用你的深度学习知识来做得更好。

我们来尝试一个基本的机器学习模型

Let’s try a basic machine learning model

就像在尝试机器学习方法之前建立一个符合常识的基准模型很有用一样,在研究复杂且计算成本高的模型(例如循环神经网络)之前,尝试简单、低成本的机器学习模型(例如小型、密集连接的网络)也很有用。这是确保你为解决问题引入的任何更复杂的模型都是合理且能带来实际收益的最佳方法。

清单 13.10 展示了一个全连接模型,该模型首先对数据进行展平,然后经过两层网络进行处理Dense请注意最后一层没有激活函数Dense,这在回归问题中很常见。我们使用均方误差 (MSE) 作为损失函数,而不是平均绝对误差 (MAE),因为与 MAE 不同,MSE 在零附近是平滑的,这对于梯度下降来说是一个有用的特性。我们将通过将其作为指标来监控 MAE compile()

个人注:这句话揭示了分类问题回归问题在神经网络输出层设计上的本质区别。

简单来说:激活函数就像是一个“过滤器”或“缩减器”,而回归问题需要的是“真实原值”。

  1. 为什么回归问题通常不加激活函数?

在回归任务中(比如预测房价、明天的气温、股票走势),你的目标输出是一个连续的数值。这个数值的范围理论上是无限制的:

  • 房价可能是 50 万,也可能是 500 万。
  • 气温可能是 \(-10\) 度,也可能是 \(40\) 度。

如果你在最后一层加了常见的激活函数,会发生什么?

  • Sigmoid:会将输出强制压缩在 \([0, 1]\) 之间。你永远无法预测出 2 块钱以外的价格。
  • ReLU:会将所有负数变成 0。你永远无法预测出零下摄氏度。
  • Tanh:会将输出压缩在 \([-1, 1]\) 之间。

不加激活函数(即“线性激活”),意味着输出层的神经元仅仅执行 \(y = wx + b\) 的线性变换,保持了数值的自由度,让模型能输出从负无穷到正无穷的任何值。

  1. 只有一种例外:限制输出范围

只有当你明确知道预测的目标值一定在某个范围内时,才会加激活函数:

  • 如果你预测的是概率(0 到 1),必须加 Sigmoid
  • 如果你预测的是非负数(比如降雨量),有时会加 ReLU
  • 但在大多数通用回归任务中,保持 Dense 层“裸奔”是最稳妥的选择。
  1. 与分类问题的对比

为了方便记忆,你可以看下表:

任务类型 输出层激活函数 输出含义
回归 (Regression) None (无) 具体数值(如 152.5)
二分类 Sigmoid 概率(0 或 1)
多分类 Softmax 属于各类的概率分布
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import keras
from keras import layers

inputs = keras.Input(shape=(sequence_length, raw_data.shape[-1]))
x = layers.Flatten()(inputs)
x = layers.Dense(16, activation="relu")(x)
outputs = layers.Dense(1)(x)
model = keras.Model(inputs, outputs)

callbacks = [
# We use a callback to save the best-performing model.
keras.callbacks.ModelCheckpoint("jena_dense.keras", save_best_only=True)
]
model.compile(optimizer="adam", loss="mse", metrics=["mae"])
history = model.fit(
train_dataset,
epochs=10,
validation_data=val_dataset,
callbacks=callbacks,
)

# Reloads the best model and evaluates it on the test data
model = keras.models.load_model("jena_dense.keras")
print(f"Test MAE: {model.evaluate(test_dataset)[1]:.2f}")

清单 13.10:训练和评估密集连接模型

让我们展示验证和训练的损失曲线(见图 13.3)。

1
2
3
4
5
6
7
8
9
10
11
import matplotlib.pyplot as plt

loss = history.history["mae"]
val_loss = history.history["val_mae"]
epochs = range(1, len(loss) + 1)
plt.figure()
plt.plot(epochs, loss, "r--", label="Training MAE")
plt.plot(epochs, val_loss, "b", label="Validation MAE")
plt.title("Training and validation MAE")
plt.legend()
plt.show()

清单 13.11:绘图结果

img图 13.3:使用简单、密集连接的网络在 Jena 温度预测任务上的训练和验证 MAE

部分验证损失接近于未进行任何学习的基线模型,但这种接近并不稳定。这恰恰说明了设立该基线模型的价值:事实证明,超越它并非易事。你的常识蕴含着许多机器学习模型无法获取的宝贵信息。

你可能会疑惑,如果存在一个简单且性能良好的模型能够从数据推导出目标值(即常识性的基线模型),为什么你正在训练的模型找不到它并对其进行改进呢?这是因为你寻找解决方案的模型空间——也就是你的假设空间——实际上是所有具有你定义的配置的双层网络的空间。常识性的启发式模型只是这个空间中数百万个模型中的一个。这就像大海捞针。即使一个好的解决方案理论上存在于你的假设空间中,也不意味着你就能通过梯度下降找到它。

这是机器学习的一个相当重要的局限性:除非学习算法被硬编码为寻找特定类型的简单模型,否则它有时可能无法找到简单问题的简单解决方案。因此,使用良好的特征工程和相关的架构先验至关重要:你需要精确地告诉模型它应该寻找什么。

我们来尝试一下一维卷积模型

Let’s try a 1D convolutional model

说到使用合适的架构先验:由于我们的输入序列具有每日周期性,或许卷积模型会适用?时间卷积神经网络可以像空间卷积神经网络一样,在不同日期之间复用相同的表征,就像空间卷积神经网络可以在图像的不同位置之间复用相同的表征一样。

你已经了解了图层Conv2DSeparableConv2D控件,它们通过在二维网格上滑动的小窗口接收输入。这些图层还有一维甚至三维版本: Conv1D、、SeparableConv1DConv3D。[2]该Conv1D层依赖于在输入序列上滑动的 1D 窗口,而该Conv3D层依赖于在输入体积上滑动的立方体窗口。

因此,您可以构建一维卷积神经网络,它与二维卷积神经网络完全类似。它们非常适合任何符合平移不变性假设的序列数据(这意味着,如果您在序列上滑动一个窗口,窗口的内容应该遵循相同的属性,而与窗口的位置无关)。

让我们用温度预测问题来尝试一下。我们选择初始窗口长度为 24,这样我们每次就能查看 24 小时的数据(一个周期)。随着我们对序列进行下采样(通过MaxPooling1D分层),我们将相应地减小窗口大小(图 13.4):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
inputs = keras.Input(shape=(sequence_length, raw_data.shape[-1]))
x = layers.Conv1D(8, 24, activation="relu")(inputs)
x = layers.MaxPooling1D(2)(x)
x = layers.Conv1D(8, 12, activation="relu")(x)
x = layers.MaxPooling1D(2)(x)
x = layers.Conv1D(8, 6, activation="relu")(x)
x = layers.GlobalAveragePooling1D()(x)
outputs = layers.Dense(1)(x)
model = keras.Model(inputs, outputs)

callbacks = [
keras.callbacks.ModelCheckpoint("jena_conv.keras", save_best_only=True)
]
model.compile(optimizer="adam", loss="mse", metrics=["mae"])
history = model.fit(
train_dataset,
epochs=10,
validation_data=val_dataset,
callbacks=callbacks,
)

model = keras.models.load_model("jena_conv.keras")
print(f"Test MAE: {model.evaluate(test_dataset)[1]:.2f}")

img图 13.4:使用一维卷积神经网络在耶拿温度预测任务上的训练和验证平均绝对误差 (MAE)。

结果表明,该模型的性能甚至比密集连接模型还要差,验证集的平均绝对误差 (MAE) 仅为 2.9 度左右,远低于合理的基准值。问题出在哪里?原因有二:

  • 首先,天气数据并不完全符合平移不变性假设。虽然数据确实呈现出每日周期性变化,但早晨的数据与傍晚或深夜的数据具有不同的特性。天气数据仅在非常特定的时间尺度内才具有平移不变性。
  • 其次,数据的顺序至关重要。与五天前的数据相比,最近的数据对于预测第二天的温度更有价值。一维卷积神经网络无法利用这一特性。特别是,我们的最大池化层和全局平均池化层在很大程度上破坏了数据的顺序信息。

循环神经网络

Recurrent neural networks

全连接方法和卷积方法的效果都不理想,但这并不意味着机器学习不适用于这个问题。全连接方法首先将时间序列展平,从而消除了输入数据中的时间信息。卷积方法对数据的每个片段都采用相同的处理方式,甚至应用了池化操作,这破坏了顺序信息。我们应该将数据视为一个序列,其中因果关系和顺序至关重要。

有一类神经网络架构是专门为这种应用场景设计的:循环神经网络。其中,长短期记忆(LSTM)层尤其受欢迎。我们稍后会了解这些模型的工作原理——但首先让我们来试用一下LSTM层。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
inputs = keras.Input(shape=(sequence_length, raw_data.shape[-1]))
x = layers.LSTM(16)(inputs)
outputs = layers.Dense(1)(x)
model = keras.Model(inputs, outputs)

callbacks = [
keras.callbacks.ModelCheckpoint("jena_lstm.keras", save_best_only=True)
]
model.compile(optimizer="adam", loss="mse", metrics=["mae"])
history = model.fit(
train_dataset,
epochs=10,
validation_data=val_dataset,
callbacks=callbacks,
)

model = keras.models.load_model("jena_lstm.keras")
print("Test MAE: {model.evaluate(test_dataset)[1]:.2f}")

清单 13.12:一个简单的基于 LSTM 的模型

图 13.5 显示了结果。效果显著提升!我们实现了低至 2.39 度的验证平均绝对误差 (MAE) 和 2.55 度的测试平均绝对误差。基于 LSTM 的模型终于能够超越传统的基线模型(尽管目前优势并不明显),这充分展现了机器学习在该任务上的价值。

img图 13.5:基于 LSTM 模型在耶拿温度预测任务上的训练和验证 MAE。(请注意,由于第 1 个 epoch 的训练 MAE 较高 (7.75),会扭曲图表的比例,因此本图中省略了第 1 个 epoch 的数据。)

但是,为什么LSTM模型比全连接模型或卷积神经网络表现得明显更好?我们又该如何进一步改进模型呢?为了解答这些问题,让我们更深入地了解一下循环神经网络。

理解循环神经网络

Understanding recurrent neural networks

你目前为止所看到的神经网络,例如全连接网络和卷积神经网络(ConvNet),一个主要特点是它们没有记忆。每个输入都是独立处理的,输入之间不保留任何状态。对于这类网络,要处理一个序列或时间序列的数据点,你必须一次性将整个序列输入网络:将其转换为单个数据点。例如,我们在全连接网络示例中就是这样做的:我们将五天的数据展平为一个大的向量,并一次性处理完毕。这类网络被称为前馈网络(feedforward networks)。

相比之下,当你阅读当前句子时,你是逐字逐句地处理信息——或者更确切地说,是逐次扫视——同时还要记住前面的内容;这让你能够流畅地理解句子所传达的含义。生物智能则以增量的方式处理信息,同时维护一个基于过往信息构建并随着新信息的到来不断更新的内部模型。

循环神经网络(RNN)采用相同的原理,尽管形式极其简化:它通过遍历序列元素并维护一个包含当前已处理信息的状态来处理序列。实际上,RNN 是一种具有内部循环的神经网络(参见图 13.6)。

img图 13.6:循环网络:一个带有环路的网络

在处理两个不同的独立序列(例如同一批次中的两个样本)之间,循环神经网络 (RNN) 的状态会被重置,因此您仍然将一个序列视为一个单独的数据点:网络的一个输入。变化之处在于,该数据点不再一次性处理;相反,网络会在内部循环处理序列元素。

为了更清晰地理解循环状态的概念,我们来实现一个简单的循环神经网络(RNN)的前向传播过程。这个RNN的输入是一个向量序列,我们将其编码为一个大小为n的秩为2的张量(timesteps, input_features)。它循环遍历时间步,在每个时间步,它考虑其在t=1时的状态t和在t=2时输入的向量t(形状为n=1 (input_features,)),并将它们组合起来得到在t=2时的输出t。然后,我们将下一步的状态设置为前一步的输出。对于第一个时间步,前一步的输出尚未定义;因此,没有当前状态。所以我们将状态初始化为一个全零向量,称为网络的初始状态。

用伪代码表示,这就是循环神经网络(RNN)。

1
2
3
4
5
6
7
# The state at t
state_t = 0
# Iterates over sequence elements
for input_t in input_sequence:
output_t = f(input_t, state_t)
# The previous output becomes the state for the next iteration.
state_t = output_t

清单 13.13:RNN 伪代码

你甚至可以进一步完善这个函数f:输入和状态到输出的转换将由两个矩阵WU以及一个偏置向量参数化。这类似于前馈网络中密集连接层所执行的转换。

1
2
3
4
state_t = 0
for input_t in input_sequence:
output_t = activation(dot(W, input_t) + dot(U, state_t) + b)
state_t = output_t

清单 13.14:RNN 的更详细伪代码

为了使这些概念绝对明确,让我们用 NumPy 编写一个简单的 RNN 前向传播实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import numpy as np

# Number of timesteps in the input sequence
timesteps = 100
# Dimensionality of the input feature space
input_features = 32
# Dimensionality of the output feature space
output_features = 64
# Input data: random noise for the sake of the example
inputs = np.random.random((timesteps, input_features))
# Initial state: an all-zero vector
state_t = np.zeros((output_features,))
# Creates random weight matrices
W = np.random.random((output_features, input_features))
U = np.random.random((output_features, output_features))
b = np.random.random((output_features,))
successive_outputs = []
# input_t is a vector of shape (input_features,).
for input_t in inputs:
# Combines the input with the current state (the previous output)
# to obtain the current output. We use tanh to add nonlinearity (we
# could use any other activation function).
output_t = np.tanh(np.dot(W, input_t) + np.dot(U, state_t) + b)
# Stores this output in a list
successive_outputs.append(output_t)
# Updates the state of the network for the next timestep
state_t = output_t
# The final output is a rank-2 tensor of shape (timesteps,
# output_features).
final_output_sequence = np.concatenate(successive_outputs, axis=0)

清单 13.15:简单循环神经网络的 NumPy 实现

很简单:总而言之,RNN 就是一个for循环,它会重用上一次循环迭代中计算出的量,仅此而已。当然,符合这个定义的 RNN 有很多种——这个例子只是最简单的 RNN 形式之一。RNN 的特征在于其阶跃函数,例如本例中的以下函数(参见图 13.7):

1
output_t = tanh(matmul(input_t, W) + matmul(state_t, U) + b)

img图 13.7:一个简单的 RNN,随时间展开

在这个例子中,最终输出是一个形状为 的秩为 2 的张量(timesteps, output_features),其中每个时间步都是循环在时间 的输出。输出张量中的 t每个时间步都包含输入序列中从 到 的时间步的信息——也就是关于整个过去时间的信息。因此,在很多情况下,你不需要完整的输出序列;你只需要最后一个输出(循环结束时的输出),因为它已经包含了整个序列的信息。t``0``t``output_t

Keras 中的重复层

A recurrent layer in Keras

你刚才在 NumPy 中简单地实现的过程对应于一个实际的 Keras 层——SimpleRNN层。

有一个细微的区别:SimpleRNN与其他 Keras 层一样,它处理的是一批序列,而不是像 NumPy 示例中那样处理单个序列。这意味着它接受形状为 n 的输入,(batch_size, timesteps, input_features) 而不是 n (timesteps, input_features)。在指定shape初始 n 参数时Input(),请注意您可以将该timesteps参数设置为 n None,这将使您的网络能够处理任意长度的序列。

1
2
3
num_features = 14
inputs = keras.Input(shape=(None, num_features))
outputs = layers.SimpleRNN(16)(inputs)

清单 13.16:一个可以处理任意长度序列的 RNN 层

如果你的模型需要处理长度可变的序列,这将特别有用。但是,如果所有序列的长度都相同,我建议指定完整的输入形状,因为它能够model.summary() 显示输出长度信息,这总是很有用的,而且还可以解锁一些性能优化(参见本章后面的“关于 RNN 运行时性能”部分)。

Keras 中的所有循环层(SimpleRNN``renderLSTM``renderGRU``render)都可以以两种不同的模式运行:它们可以返回每个时间步的完整连续输出序列(形状为 n 的秩为 3 的张量(batch_size, timesteps, output_features)),也可以只返回每个输入序列的最后一个输出(形状为 n 的秩为 2 的张量 (batch_size, output_features))。这两种模式由构造函数参数控制。让我们来看一个使用 render并只返回最后一个时间步输出的return_sequences示例。SimpleRNN

1
2
3
4
5
6
7
>>> num_features = 14
>>> steps = 120
>>> inputs = keras.Input(shape=(steps, num_features))
>>> # Note that return_sequences=False is the default.
>>> outputs = layers.SimpleRNN(16, return_sequences=False)(inputs)
>>> print(outputs.shape)
(None, 16)

清单 13.17:仅返回其最后一个输出步骤的 RNN 层

以下示例返回完整的输出序列。

1
2
3
4
5
6
7
>>> num_features = 14
>>> steps = 120
>>> inputs = keras.Input(shape=(steps, num_features))
>>> # Sets return_sequences to True
>>> outputs = layers.SimpleRNN(16, return_sequences=True)(inputs)
>>> print(outputs.shape)
(None, 120, 16)

清单 13.18:返回其完整输出序列的 RNN 层

有时,为了增强网络的表示能力,可以将多个循环层依次堆叠起来。在这种设置下,必须确保所有中间层都能返回完整的输出序列。

1
2
3
4
inputs = keras.Input(shape=(steps, num_features))
x = layers.SimpleRNN(16, return_sequences=True)(inputs)
x = layers.SimpleRNN(16, return_sequences=True)(x)
outputs = layers.SimpleRNN(16)(x)

清单 13.19:堆叠 RNN 层

实际上,你很少会用到这SimpleRNN一层。它通常过于简单,没什么实际用处。特别是,它SimpleRNN存在一个主要问题:虽然理论上它应该能够保留t很多时间步之前输入的信息,但实际上,这种长期依赖关系是无法学习的。这是由于梯度消失问题造成的,这种现象类似于多层非循环网络(前馈网络)中观察到的现象:随着网络层数的增加,网络最终会变得无法训练。Hochreiter、Schmidhuber 和 Bengio 在 20 世纪 90 年代初研究了这种现象的理论原因。[3]

值得庆幸的是,SimpleRNNKeras 中并非只有这一个循环层。还有另外两个:Recurrent``LSTMRecurrent GRU,它们的设计初衷就是为了解决这些问题。

我们来看一下这LSTM一层。底层的长短期记忆(LSTM)算法是由Hochreiter和Schmidhuber于1997年开发的;[4]这是他们对梯度消失问题研究的最终成果。

这一层是你已经了解的那层的变体SimpleRNN;它增加了一种跨多个时间步传递信息的方法。想象一下,有一条传送带与你正在处理的序列平行运行。序列中的信息可以随时跳上传送带,被运送到后面的时间步,并在你需要的时候完整地跳下来。这本质上就是 LSTM 的工作原理:它将信息保存下来以备后用,从而防止旧信号在处理过程中逐渐消失。这应该会让你想起你在第 9 章学到的残差连接:它们的思想基本相同。

为了详细了解这个过程,让我们从SimpleRNN单元格开始(参见图 13.8)。因为你会有很多权重矩阵,所以 用字母(和)来索引单元格中的W和矩阵,以便输出U``o``Wo``Uo

img图 13.8:层的起始点LSTM:aSimpleRNN

让我们在这个图中增加一个跨时间步传递信息的额外数据流。我们称其在不同时间步的值分别为 和Ct,其中C代表进位(carry)。该信息将对单元产生以下影响:它将与输入连接和循环连接结合(通过稠密变换:先与权重矩阵进行点积运算,然后加上偏置项并应用激活函数),并且它会影响传递到下一个时间步的状态(通过激活函数和乘法运算)。从概念上讲,进位数据流是一种调节下一个输出和下一个状态的方法(参见图 13.9)。到目前为止都很简单。

img图 13.9:从 SimpleRNN 到 LSTM:添加进位轨迹

个人注: \(C_t\)的意义?

这里的 \(C_t\)(或文中提到的“进位” Carry)代表的是 LSTM(长短期记忆网络)中的“细胞状态”(Cell State)

在深度学习的语境下,你可以从以下三个层面来理解它的意义:

  1. 概念层面:长期记忆的“传送带”

如果说普通的 RNN 状态 \(h_t\) 像是一个人的“瞬时反应”,那么 \(C_t\) 就像是一本“长期备忘录”

  • 它贯穿于整个序列的处理过程,像一条传送带一样跨越不同的时间步。
  • “Carry(进位)” 这个词用得很精妙,它意味着这一步处理不完的信息、或者需要长期保留的信息,被“进位”到了下一步。
  1. 功能层面:信息调节阀

文中提到 \(C_t\) 会“影响下一个输出和下一个状态”,这正是 LSTM 核心的 “门控机制”

  • 遗忘与保留:模型会决定 \(C_t\) 中哪些旧信息该丢弃,哪些新信息该存入。
  • 调节(Modulation):它作为一个“背景背景”或“上下文”,去调制(乘以)当前的计算结果。
    • 举例:如果你在读一段话,前面出现了“他(男)”,这个性别信息就会存在 \(C_t\) 里。当你后面读到动词时,\(C_t\) 就会调节输出,让模型知道接下来的代词应该对应“Him”而不是“Her”。
  1. 数学与物理层面:解决梯度消失

这是 \(C_t\) 存在的最硬核原因。

  • 在普通 RNN 中,信息传递是连乘运算,传久了梯度就没了(梯度消失)。
  • 而在包含 \(C_t\) 的结构中,信息的流动主要是加法运算。这种“线性连接”形成了一条梯度高速公路,使得神经网络能够“记住”成百上千个时间步之前的信息。

总结

文中的 \(C_t\) 就是 Cell State。它通过稠密变换(权重点积)融合了当前输入,又通过乘法运算(门控)决定了给下一步留多少。

你可以把它理解为: 一个带有“开关控制”的全局变量,专门用来保存那些需要跨越长距离、不能被轻易覆盖掉的关键背景信息。

个人注:和那个 state t有什么不同?

这是一个非常经典的问题。在 LSTM(长短期记忆网络)中,同时存在两个状态:\(C_t\)(细胞状态/Cell State)\(h_t\)(隐藏状态/Hidden State)

虽然它们都在时间步之间传递信息,但它们的分工性质完全不同。

  1. 核心定位对比:备忘录 vs. 当前输出
状态 符号 比喻 作用
细胞状态 \(C_t\) 长期备忘录 负责保存跨越长距离的、相对稳定的原始信息。
隐藏状态 \(h_t\) 即时工作记忆 负责根据当前的备忘录和当前输入,决定此刻要“表现”出什么。
  1. 数学特性的不同(为什么 \(C_t\) 能解决梯度消失)

这是两者在算法层面上最根本的区别:

  • \(h_t\) 是“剧烈变化”的:

    在普通的 RNN(以及 LSTM 的输出部分)中,\(h_t\) 每一层都要经过一个激活函数(如 \(tanh\)\(ReLU\))和复杂的矩阵点积运算。这就像“传声筒游戏”,每传一次,信息都会被扭曲或缩小一点点。传到 50 步之后,最初的信息基本就消失了。

  • \(C_t\) 是“线性流动”的:

    \(C_t\) 在时间轴上的流动主要是加法运算。它就像一条传送带,信息可以通过“遗忘门”丢弃一点,通过“输入门”增加一点,但主体信息是线性传递的。这种设计让梯度(误差信号)可以像走高速公路一样,无损地回传到很远之前。

  1. 一个直观的生活例子:读长篇小说

假设你正在读一本长篇侦探小说:

  • \(C_t\) (Cell State):是你脑子里的主线剧情备忘录
    • 你记得:主角是个警察,受害者是张三,案件发生在雨天。
    • 这些信息非常稳定,不会因为你读到某一个描写风景的词就变了。
  • \(h_t\) (Hidden State):是你读到当前这一行文字时的反应
    • 当你读到“凶手推开了门”时,\(h_t\) 会立刻让你感到紧张。
    • \(h_t\) 是由 \(C_t\)(你知道门后可能是凶手)和当前输入(推门这个动作)共同产生的。
    • 读完这一行,你的紧张感(\(h_t\))可能会消失,但主线剧情(\(C_t\))依然保留。
  1. 它们之间的互动关系

在 LSTM 的内部逻辑里:

  1. 先更新 \(C_t\)(决定忘掉什么旧的,记住什么新的)。
  2. 然后把 \(C_t\) 放进一个 \(tanh\) 函数里压减一下(变成 \(-1\)\(1\) 之间的数值)。
  3. 最后用一个“输出门”去过滤这个 \(C_t\),得到 \(h_t\)

结论:

\(h_t\) 其实是 \(C_t\) 的一个过滤后的副本\(C_t\) 包含了所有的潜力信息,而 \(h_t\) 只展示当前任务需要的那一部分。

在书中,Francois Chollet 把 \(C_t\) 称为“进位(Carry)”,就是因为它像数学加法里的进位一样,是直接“平移”到下一位的,不参与那种容易让信息丢失的复杂非线性变换。

现在来说说其中的微妙之处:进位数据流的下一个值是如何计算的。它涉及三种不同的转换。这三种转换都以 SimpleRNN单元格的形式呈现:

1
y = activation(dot(state_t, U) + dot(input_t, W) + b)

但是这三种变换都有各自的权重矩阵,分别用字母 、 和 进行索引if以下k是您目前所拥有的(可能看起来有点随意,但请耐心听我说完)。

1
2
3
4
output_t = activation(dot(state_t, Uo) + dot(input_t, Wo) + dot(C_t, Vo) + bo)
i_t = activation(dot(state_t, Ui) + dot(input_t, Wi) + bi)
f_t = activation(dot(state_t, Uf) + dot(input_t, Wf) + bf)
k_t = activation(dot(state_t, Uk) + dot(input_t, Wk) + bk)

清单 13.20:LSTM 架构的伪代码细节(1/2)

c_t您可以通过组合i_t、、f_t和来 获得新的进位状态(下一个) k_t

1
c_t+1 = i_t * k_t + c_t * f_t

清单 13.21:LSTM 架构的伪代码细节(2/2)

如图 13.10 所示添加即可。就这么简单。并不复杂——只是稍微有点繁琐。

img图 13.10:解剖结构LSTM

如果你想从哲学角度来探讨,你可以解释每个运算的用途。例如,你可以说乘法运算c_tf_t为了有意地忽略进位数据流中的无关信息。同时,i_t乘法运算k_t提供关于当前状态的信息,用新信息更新进位轨迹。但归根结底,这些解释意义不大,因为这些运算的 实际作用取决于参数化它们的权重,而权重是以端到端的方式学习的,每次训练都会重新开始,因此无法将某个运算的具体用途归功于它。RNN 单元的规范(如前所述)决定了你的假设空间——也就是你在训练过程中寻找良好模型配置的空间——但它并不决定单元的具体功能;这取决于单元的权重。同一个单元,不同的权重可以执行截然不同的操作。因此,构成 RNN 单元的运算组合最好被理解为一组对搜索的约束,而不是工程意义上的设计。

可以说,选择这些约束条件——也就是如何实现RNN单元——最好交给优化算法(例如遗传算法或强化学习过程)来处理,而不是交给人类工程师。未来,我们将采用这种方式构建模型。总之,你不需要了解LSTM单元的具体架构;作为人类,理解它不应该是你的工作。你只需要记住LSTM单元的作用:允许在稍后重新注入过去的信息,从而解决梯度消失问题。

充分发挥循环神经网络的潜力

Getting the most out of recurrent neural networks

到这时,你已经学会了

  • 什么是循环神经网络以及它们如何运作
  • 什么是 LSTM?为什么它在处理长序列数据时比简单的 RNN 表现更好?
  • 如何使用 Keras RNN 层处理序列数据
  • What RNNs are and how they work
  • What an LSTM is and why it works better on long sequences than a naive RNN
  • How to use Keras RNN layers to process sequence data

接下来,我们将回顾循环神经网络 (RNN) 的一些更高级的特性,这些特性可以帮助您最大限度地发挥深度学习序列模型的效用。在本节结束时,您将掌握使用 Keras 构建循环神经网络的大部分知识。

我们将涵盖以下内容:

  • 循环 dropout — 这是 dropout 的一种变体,用于防止循环层过拟合。
  • 堆叠循环层  ——这可以提高模型的表示能力(但会增加计算负荷)。
  • 双向循环层——这些层以不同的方式向循环网络呈现相同的信息,从而提高准确性并缓解遗忘问题。
  • Recurrent dropout—This is a variant of dropout, used to fight overfitting in recurrent layers.
  • Stacking recurrent layers—This increases the representational power of the model (at the cost of higher computational loads).
  • Bidirectional recurrent layers—These present the same information to a recurrent network in different ways, increasing accuracy and mitigating forgetting issues.

我们将利用这些技术来改进我们的温度预测循环神经网络。

利用循环丢弃来对抗过拟合

Using recurrent dropout to fight overfitting

让我们回到本章前面用到的基于 LSTM 的模型——这是我们第一个能够超越常识基线模型的模型。观察训练曲线和验证曲线,可以明显看出,尽管模型单元数量很少,但它很快就出现了过拟合:训练损失和验证损失在几个 epoch 后就开始显著偏离。你可能已经熟悉一种经典的应对过拟合的技术:dropout。dropout 会随机将层中的输入单元置零,以打破该层所接触的训练数据中偶然存在的相关性。但是,如何在循环神经网络中正确应用 dropout 并非易事。

人们早已知道,在循环层之前应用 dropout 会阻碍学习,而不是有助于正则化。2015 年,Yarin Gal 在他的贝叶斯深度学习博士论文中指出,[5]确定了循环神经网络中使用dropout的正确方法:在每个时间步应用相同的dropout掩码(相同的丢弃单元模式),而不是使用随时间步随机变化的dropout掩码。此外,为了规范由诸如和GRU之类的循环门控层形成的表征LSTM,应该对该层的内部循环激活应用一个时间恒定的dropout掩码(循环dropout掩码)。在每个时间步使用相同的dropout掩码可以使网络正确地随时间传播其学习误差;时间随机的dropout掩码会扰乱这种误差信号,并对学习过程造成损害。

Yarin Gal 使用 Keras 进行了研究,并帮助将这种机制直接集成到 Keras 的循环层中。Keras 中的每个循环层都有两个与 dropout 相关的参数:dropout``dropout_replacement,一个浮点数,用于指定该层输入单元的 dropout 率;以及dropout_replacement recurrent_dropout,用于指定循环单元的 dropout 率。让我们将循环 dropout 添加到LSTM 第一个 LSTM 示例的循环层中,看看这样做会对过拟合产生什么影响。

由于使用了 dropout,我们无需过多依赖网络规模进行正则化,因此我们将使用LSTM单元数量翻倍的层,这应该会提升模型的表达能力(如果没有 dropout,这个网络会立即过拟合——不妨试试)。由于使用 dropout 进行正则化的网络需要更长的时间才能完全收敛,我们将训练模型的轮数增加五倍。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
inputs = keras.Input(shape=(sequence_length, raw_data.shape[-1]))
x = layers.LSTM(32, recurrent_dropout=0.25)(inputs)
# To regularize the Dense layer, we also add a Dropout layer after the
# LSTM.
x = layers.Dropout(0.5)(x)
outputs = layers.Dense(1)(x)
model = keras.Model(inputs, outputs)

callbacks = [
keras.callbacks.ModelCheckpoint(
"jena_lstm_dropout.keras", save_best_only=True
)
]
model.compile(optimizer="adam", loss="mse", metrics=["mae"])
history = model.fit(
train_dataset,
epochs=50,
validation_data=val_dataset,
callbacks=callbacks,
)

清单 13.22:训练和评估 dropout 正则化的 LSTM。

图 13.11 显示了结果。成功!在前 20 个 epoch 中,我们不再出现过拟合。验证集的平均绝对误差 (MAE) 低至 2.27 度(比未进行任何学习的基线模型提高了 7%),测试集的 MAE 为 2.45 度(比基线模型提高了 6.5%)。相当不错。

img图 13.11:使用 dropout 正则化 LSTM 在 Jena 温度预测任务上的训练损失和验证损失。

关于 RNN 运行时性能

On RNN runtime performance

参数极少的循环神经网络模型(例如本章中介绍的模型)在多核 CPU 上的运行速度通常比在 GPU 上快得多,因为它们只涉及少量矩阵乘法,而且由于循环的存在,乘法链难以并行化for。但规模较大的循环神经网络则可以从 GPU 运行时中获益匪浅。

在 GPU 上使用 KerasLSTMGRU层时,如果使用默认关键字参数,则该层将使用cuDNN 内核。cuDNN 内核是 NVIDIA 提供的底层算法的高度优化实现(我们在上一章中已经提到过)。与往常一样,cuDNN 内核有利有弊:它们速度很快,但灵活性不足——如果您尝试执行默认内核不支持的操作,速度将会急剧下降,这或多或少地迫使您只能使用 NVIDIA 提供的实现。例如,LSTM 和 GRU cuDNN 内核不支持循环 dropout,因此将其添加到层中会强制运行时回退到常规的 TensorFlow 实现,而后者在 GPU 上的速度通常要慢两到五倍(即使它们的计算成本相同)。

当无法使用 cuDNN 时,为了加速 RNN 层,您可以尝试展开循环 。展开for循环是指移除循环本身,并将其内容内联N次。对于forRNN 的循环,展开循环可以帮助 TensorFlow 优化底层计算图。然而,它也会显著增加 RNN 的内存消耗——因此,它仅适用于相对较小的序列(大约 100 步或更少)。此外,只有在模型预先知道数据中的时间步数时(即,如果您向初始参数传递一个shape不包含任何条目的参数),才能执行此操作。其工作原理如下:None``Input()

1
2
3
4
# sequence_length cannot be None.
inputs = keras.Input(shape=(sequence_length, num_features))
# Passes unroll=True to enable unrolling
x = layers.LSTM(32, recurrent_dropout=0.2, unroll=True)(inputs)

堆叠循环层

Stacking recurrent layers

因为你的模型不再过拟合,但似乎遇到了性能瓶颈,所以你应该考虑提升网络的容量和表达能力。回顾一下通用机器学习工作流程的描述:通常来说,提升模型的容量直到过拟合成为主要障碍是明智之举(假设你已经采取了一些缓解过拟合的基本措施,例如使用 dropout)。只要过拟合程度不是太严重,你的模型容量可能就还不够。

通常,增加网络容量是通过增加层中的单元数量或添加更多层来实现的。循环层堆叠是构建更强大循环网络的经典方法:例如,不久前谷歌翻译算法就是由七个大型LSTM 层堆叠而成的——这非常强大。

在 Keras 中,要将循环层堆叠在一起,所有中间层都应该返回其完整的输出序列(一个秩为 3 的张量),而不是最后一个时间步的输出。正如你已经了解到的,这可以通过指定来实现return_sequences=True

在下面的例子中,我们将尝试两个经过 dropout 正则化的循环层堆叠。为了有所改变,我们将使用GRU层而不是LSTM门控循环单元(Gated Recurrent Unit ) (GRU) 与 LSTM 非常相似——您可以将其视为 LSTM 架构的一个略微简化、精简的版本。它由 Cho 等人于 2014 年提出,当时循环网络刚刚开始在当时规模还很小的研究领域重新引起人们的兴趣。[6]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
inputs = keras.Input(shape=(sequence_length, raw_data.shape[-1]))
x = layers.GRU(32, recurrent_dropout=0.5, return_sequences=True)(inputs)
x = layers.GRU(32, recurrent_dropout=0.5)(x)
x = layers.Dropout(0.5)(x)
outputs = layers.Dense(1)(x)
model = keras.Model(inputs, outputs)

callbacks = [
keras.callbacks.ModelCheckpoint(
"jena_stacked_gru_dropout.keras", save_best_only=True
)
]
model.compile(optimizer="adam", loss="mse", metrics=["mae"])
history = model.fit(
train_dataset,
epochs=50,
validation_data=val_dataset,
callbacks=callbacks,
)
model = keras.models.load_model("jena_stacked_gru_dropout.keras")
print(f"Test MAE: {model.evaluate(test_dataset)[1]:.2f}")

清单 13.23:训练和评估 dropout 正则化的堆叠 GRU 模型

图 13.12 显示了测试结果。我们获得了 2.39 度的测试 MAE(比基准值提高了 8.8%)。可以看出,新增的层确实略微改善了结果,但改善幅度并不显著。此时,增加网络容量的收益可能已经递减。

img图 13.12:使用堆叠式 GRU 网络在 Jena 温度预测任务上的训练损失和验证损失

使用双向循环神经网络

Using bidirectional RNNs

本节介绍的最后一种技术是双向循环神经网络( bidirectional RNNs)双向RNN是一种常见的RNN变体,在某些任务上可以比常规RNN提供更优的性能。它常用于自然语言处理——你可以称它为深度学习在自然语言处理领域的瑞士军刀。

循环神经网络(RNN)对顺序的依赖性非常显著:它们按顺序处理输入序列的时间步,打乱或反转时间步会彻底改变RNN从序列中提取的表示。这正是它们在顺序至关重要的问题(例如温度预测问题)上表现出色的原因。双向RNN利用了RNN的顺序敏感性:它由两个常规RNN组成,例如你已经熟悉的R1层GRU和R2LSTM层,每个层分别沿一个方向(按时间顺序和按反时间顺序)处理输入序列,然后合并它们的表示。通过双向处理序列,双向RNN可以捕捉到单向RNN可能忽略的模式。

值得注意的是,本节中的 RNN 层按时间顺序(先处理较早的时间步)处理序列这一做法可能只是人为决定。至少,到目前为止,我们还没有质疑过这个决定。如果 RNN 按反时间顺序(例如,先处理较新的时间步)处理输入序列,它们是否也能表现良好呢?让我们实际尝试一下,看看结果如何。你只需要编写一个数据生成器的变体,其中输入序列沿时间维度反转(将最后一行替换为yield samples[:, ::-1, :], targets)。

当您训练与本节第一个实验中相同的基于 LSTM 的模型时,您会发现这种反向顺序的 LSTM 模型甚至远逊于常识基线模型。这表明,在这种情况下,时间顺序处理对于该方法的成功至关重要。这完全合乎情理:底层LSTM通常更擅长记忆近期而非远期数据,而且,自然地,对于该问题而言,较新的天气数据点比较旧的数据点更具预测性(这正是常识基线模型表现相当出色的原因)。因此,按时间顺序处理的 LSTM 层必然优于反向顺序处理的 LSTM 层。

然而,对于许多其他问题,包括自然语言处理,情况并非如此:直觉上,一个词在理解句子中的重要性并不强烈依赖于它在句子中的位置。对于文本数据,反向处理与按时间顺序处理的效果一样好——你可以倒着读文本,完全没问题(试试看!)。虽然词序在理解语言中确实很重要,但你使用哪种顺序并非关键。

重要的是,用反向序列训练的循环神经网络(RNN)会学习到与用原始序列训练的RNN不同的表征,这就像现实世界中时间倒流时,你的心智模型也会有所不同——如果你的一生中第一天就去世,最后一天才出生。在机器学习中,不同有用 表征总是值得利用的,而且差异越大越好:它们提供了一个新的视角来审视你的数据,捕捉到其他方法遗漏的数据特征,从而有助于提升任务性能。这就是集成学习(ensembling)背后的直觉,我们将在第18章探讨这个概念。

双向循环神经网络利用这一思想来提升按时间顺序排列的循环神经网络的性能。它以两种方式处理输入序列(见图 13.13),从而获得更丰富的表示,并捕捉到仅按时间顺序排列的循环神经网络可能遗漏的模式。

img图 13.13:双向 RNN 层的工作原理

要在 Keras 中实例化双向 RNN,可以使用 Bidirectionalrecurrentlayer 层,它的第一个参数是一个循环层实例。recurrentlayer``Bidirectional 会创建该循环层的第二个独立实例,并使用一个实例按时间顺序处理输入序列,另一个实例按相反顺序处理输入序列。您可以在我们的温度预测任务上尝试一下。

1
2
3
4
5
6
7
8
9
10
11
inputs = keras.Input(shape=(sequence_length, raw_data.shape[-1]))
x = layers.Bidirectional(layers.LSTM(16))(inputs)
outputs = layers.Dense(1)(x)
model = keras.Model(inputs, outputs)

model.compile(optimizer="adam", loss="mse", metrics=["mae"])
history = model.fit(
train_dataset,
epochs=10,
validation_data=val_dataset,
)

清单 13.24:训练和评估双向 LSTM。

你会发现它的性能不如普通LSTM层。原因很容易理解:所有的预测能力都必须来自网络的按时间顺序排列的部分,因为众所周知,按时间顺序排列的部分在这项任务上的表现非常糟糕(同样,因为在这种情况下,近期过去比遥远过去重要得多)。与此同时,按时间顺序排列的部分的存在使网络的容量翻倍,并导致它更早地出现过拟合现象。

然而,双向循环神经网络非常适合处理文本数据——或者任何其他顺序重要但具体 使用哪种顺序无关紧要的数据。事实上,在2016年的一段时间里,双向长短期记忆网络(LSTM)被认为是许多自然语言处理任务中最先进的方法(在Transformer架构兴起之前,您将在第15章中学习到)

更进一步

Going even further

还有很多其他方法可以尝试提高温度预测问题的性能:

  • 调整堆叠式架构中每个循环层的单元数量以及dropout量。目前的设置很大程度上是任意的,因此可能并非最优解。
  • 调整优化器的学习率Adam,或者尝试使用不同的优化器。
  • Dense尝试在循环层之上使用多层堆叠作为回归器,而不是使用Dense单层。
  • 改进模型的输入:尝试使用更长或更短的序列或不同的采样率,或者开始进行特征工程。

一如既往,深度学习与其说是一门科学,不如说是一门艺术。我们可以提供一些指导原则,提示哪些方法可能有效,哪些无效,但最终,每个数据集都是独一无二的;你必须通过实证评估不同的策略。目前还没有任何理论能够预先告诉你如何才能以最优方式解决问题。你必须不断迭代。

根据我们的经验,使用这个数据集,在不进行任何学习的基线基础上提升约 10% 可能已经是你能做到的最好结果了。这不算太好,但这样的结果也合情合理:如果你能获取来自不同地点的大量数据,那么近期天气的可预测性很高;但如果你只有来自单个地点的测量数据,那么可预测性就很差。你所在地点的天气变化取决于周边地区的当前天气模式。

市场与机器学习

Markets and machine learning

一些读者肯定会想运用我们在此介绍的技术,尝试预测股票市场(或货币汇率等)的未来价格。然而,市场的统计特征与天气模式等自然现象截然不同。就市场而言,过去的表现并不能很好地预测未来的收益——就像开车时总是回头看后视镜一样,毫无益处。另一方面,机器学习则适用于那些过去数据能够很好地预测未来的数据集,例如天气、电力消耗或商店客流量。

请始终记住,所有交易的本质都是信息套利(information arbitrage):利用其他市场参与者所缺乏的数据或洞察来获取优势( gaining an advantage by using data or insights that other market participants are missing. )。试图利用众所周知的机器学习技术和公开数据来战胜市场实际上是一条死路,因为你与其他参与者相比没有任何信息优势。你很可能白白浪费时间和资源,一无所获。

概括

  • 正如你在第六章中学到的,在着手解决一个新问题时,最好先为你选择的衡量标准建立一些符合常理的基准线。如果没有基准线可以超越,你就无法判断自己是否取得了真正的进步。
  • 先尝试简单的型号,再考虑昂贵的型号,这样才能证明额外花费是值得的。有时候,简单的型号反而是最佳选择。
  • 当数据顺序至关重要时——特别是时间序列数据—— 循环神经网络 (RNN)非常适用,并且能够轻松超越那些先对时间数据进行展平的模型。Keras 中提供的两个基本 RNN 层分别是 std::LSTMset 层和 GRUstd::set 层。
  • 要在循环神经网络中使用 dropout,您应该使用时间常数 dropout 掩码和循环 dropout 掩码。这些掩码已内置于 Keras 循环层中,因此您只需使用recurrent_dropout 循环层的参数即可。
  • 堆叠式循环神经网络(RNN)比单层RNN具有更强的表征能力。但它们的计算成本也更高,因此并非总是物有所值。尽管它们在复杂问题(例如机器翻译)上表现出明显的优势,但对于规模较小、较为简单的问题,它们可能并不总是适用。

脚注

  1. 亚当·埃里克森和奥拉夫·科勒,https://www.bgc-jena.mpg.de/wetter。
  2. 目前还没有这个SeparableConv3D层,并非出于任何理论原因,而是因为我们还没有实现它。
  3. 例如,参见 Yoshua Bengio、Patrice Simard 和 Paolo Frasconi 的论文“使用梯度下降法学习长期依赖关系很困难”,发表于 IEEE 神经网络学报第 5 卷第 2 期(1994 年)。
  4. Sepp Hochreiter 和 Jürgen Schmidhuber,“长短期记忆”,神经计算9,第 1 期。 8(1997)。
  5. 参见 Yarin Gal,“深度学习中的不确定性(博士论文)”,2016 年 10 月 13 日,https://www.cs.ox.ac.uk/people/yarin.gal/website/blog_2248.html。
  6. 参见 Cho 等人,“论神经机器翻译的性质:编码器-解码器方法”,2014 年,https://arxiv.org/abs/1409.1259。

书籍各章的机翻md文件:
《DEEP LEARNING with Python》第一章 什么是深度学习?
《DEEP LEARNING with Python》第二章 神经网络的数学基础
《DEEP LEARNING with Python》第三章 TensorFlow、PyTorch、JAX 和 Keras 简介
《DEEP LEARNING with Python》第四章 分类与回归
《DEEP LEARNING with Python》第五章 机器学习基础
《DEEP LEARNING with Python》第六章 机器学习的通用工作流程
《DEEP LEARNING with Python》第七章 深入了解 Keras
《DEEP LEARNING with Python》第八章 图像分类
《DEEP LEARNING with Python》第九章 卷积神经网络架构模式
《DEEP LEARNING with Python》第十章 解读卷积神经网络的学习成果
《DEEP LEARNING with Python》第十一章 图像分割
《DEEP LEARNING with Python》第十二章 目标检测
《DEEP LEARNING with Python》第十三章 时间序列预测
《DEEP LEARNING with Python》第十四章 文本分类
《DEEP LEARNING with Python》第十五章 语言模型和Transformer
《DEEP LEARNING with Python》第十六章 文本生成
《DEEP LEARNING with Python》第十七章 图像生成
《DEEP LEARNING with Python》第十八章 现实世界的最佳实践
《DEEP LEARNING with Python》第十九章 人工智能的未来
《DEEP LEARNING with Python》第二十章 结论