《机器学习实战:基于Scikit-Learn、Keras和TensorFlow》第15章 使用RNN和CNN处理序列

  • 2019 年 12 月 23 日
  • 筆記

第10章 使用Keras搭建人工神经网络 第11章 训练深度神经网络 第12章 使用TensorFlow自定义模型并训练 第13章 使用TensorFlow加载和预处理数据 第14章 使用卷积神经网络实现深度计算机视觉 第15章 使用RNN和CNN处理序列 [第16章 使用RNN和注意力机制进行自然语言处理] [第17章 使用自编码器和GAN做表征学习和生成式学习] [第18章 强化学习] [第19章 规模化训练和部署TensorFlow模型]


击球手击出垒球,外场手会立即开始奔跑,并预测球的轨迹。外场手追踪球,不断调整移动步伐,最终在观众的掌声中抓到它。无论是在听完朋友的话还是早餐时预测咖啡的味道,你时刻在做的事就是在预测未来。在本章中,我们将讨论循环神经网络,一类可以预测未来的网络(当然,是到某一点为止)。它们可以分析时间序列数据,比如股票价格,并告诉你什么时候买入和卖出。在自动驾驶系统中,他们可以预测行车轨迹,避免发生事故。更一般地说,它们可在任意长度的序列上工作,而不是截止目前我们讨论的只能在固定长度的输入上工作的网络。举个例子,它们可以将语句,文件,以及语音范本作为输入,应用在在自动翻译,语音到文本的自然语言处理应用中。

在本章中,我们将学习循环神经网络的基本概念,如何使用时间反向传播训练网络,然后用来预测时间序列。然后,会讨论RNN面对的两大难点:

  • 不稳定梯度(换句话说,在第11章中讨论的梯度消失/爆炸),可以使用多种方法缓解,包括循环dropout和循环层归一化。
  • 有限的短期记忆,可以通过LSTM 和 GRU 单元延长。

RNN不是唯一能处理序列数据的神经网络:对于小序列,常规紧密网络也可以;对于长序列,比如音频或文本,卷积神经网络也可以。我们会讨论这两种方法,本章最后会实现一个WaveNet:这是一种CNN架构,可以处理上万个时间步的序列。在第16章,还会继续学习RNN,如何使用RNN来做自然语言处理,和基于注意力机制的新架构。

循环神经元和层

到目前为止,我们主要关注的是前馈神经网络,激活仅从输入层到输出层的一个方向流动(附录 E 中的几个网络除外)。 循环神经网络看起来非常像一个前馈神经网络,除了它也有连接指向后方。 让我们看一下最简单的 RNN,由一个神经元接收输入,产生一个输出,并将输出发送回自己,如图 15-1(左)所示。 在每个时间步t(也称为一个帧),这个循环神经元接收输入x(t)以及它自己的前一时间步长 y(t-1) 的输出。 因为第一个时间步骤没有上一次的输出,所以是0。可以用时间轴来表示这个微小的网络,如图 15-1(右)所示。 这被称为随时间展开网络。

图15-1 循环神经网络(左),随时间展开网络(右)

你可以轻松创建一个循环神经元层。 在每个时间步t,每个神经元都接收输入矢量x(t) 和前一个时间步 y(t-1) 的输出矢量,如图 15-2 所示。 注意,输入和输出都是矢量(当只有一个神经元时,输出是一个标量)。

图15-2 一层循环神经元(左),及其随时间展开(右)

每个循环神经元有两组权重:一组用于输入x(t),另一组用于前一时间步长 y(t-1) 的输出。 我们称这些权重向量为 wx 和 wy。如果考虑的是整个循环神经元层,可以将所有权重矢量放到两个权重矩阵中,Wx 和 Wy。整个循环神经元层的输出可以用公式 15-1 表示(b是偏差项,φ(·)是激活函数,例如 ReLU)。

公式15-1 单个实例的循环神经元层的输出

就像前馈神经网络一样,可以将所有输入和时间步t放到输入矩阵X(t)中,一次计算出整个小批次的输出:(见公式 15-2)。

公式15-2 小批次实例的循环层输出

在这个公式中:

  • Y(t) 是 m × nneurons 矩阵,包含在小批次中每个实例在时间步t的层输出(m是小批次中的实例数,nneurons 是神经元数)。
  • X(t) 是 m × ninputs 矩阵,包含所有实例的输入 (ninputs 是输入特征的数量)。
  • Wx 是 ninputs × nneurons 矩阵,包含当前时间步的输入的连接权重。
  • Wy 是 nneurons × nneurons 矩阵,包含上一个时间步的输出的连接权重。
  • b是大小为 nneurons 的矢量,包含每个神经元的偏置项。
  • 权重矩阵 Wx 和 Wy 通常纵向连接成一个权重矩阵W,形状为(ninputs + nneurons) × nneurons(见公式 15-2 的第二行)

注意,Y(t) 是 X(t) 和 Y(t-1) 的函数,Y(t-1)是 X(t-1)和 Y(t-2) 的函数,以此类推。这使得 Y(t) 是从时间t = 0开始的所有输入(即 X(0),X(1),…,X(t))的函数。 在第一个时间步,t = 0,没有以前的输出,所以它们通常被假定为全零。

记忆单元

由于时间t的循环神经元的输出,是由所有先前时间步骤计算出来的的函数,你可以说它有一种记忆形式。神经网络的一部分,保留一些跨越时间步长的状态,称为存储单元(或简称为单元)。单个循环神经元或循环神经元层是非常基本的单元,只能学习短期规律(取决于具体任务,通常是10个时间步)。本章后面我们将介绍一些更为复杂和强大的单元,可以学习更长时间步的规律(也取决于具体任务,大概是100个时间步)。

一般情况下,时间步t的单元状态,记为 h(t)(h代表“隐藏”),是该时间步的某些输入和前一时间步状态的函数:h(t) = f(h(t–1), x(t))。 其在时间步t的输出,表示为 y(t),也和前一状态和当前输入的函数有关。 我们已经讨论过的基本单元,输出等于单元状态,但是在更复杂的单元中并不总是如此,如图 15-3 所示。

图15-3 单元的隐藏状态和输出可能不同

输入和输出序列

RNN 可以同时输入序列并输出序列(见图 15-4,左上角的网络)。这种序列到序列的网络可以有效预测时间序列(如股票价格):输入过去N天价格,则输出向未来移动一天的价格(即,从N - 1天前到明天)。

或者,你可以向网络输入一个序列,忽略除最后一项之外的所有输出(图15-4右上角的网络)。 换句话说,这是一个序列到矢量的网络。 例如,你可以向网络输入与电影评论相对应的单词序列,网络输出情感评分(例如,从-1 [讨厌]+1 [喜欢])。

相反,可以向网络一遍又一遍输入相同的矢量(见图15-4的左下角),输出一个序列。这是一个矢量到序列的网络。 例如,输入可以是图像(或是CNN的结果),输出是该图像的标题。

最后,可以有一个序列到矢量的网络,称为编码器,后面跟着一个称为解码器的矢量到序列的网络(见图15-4右下角)。 例如,这可以用于将句子从一种语言翻译成另一种语言。 给网络输入一种语言的一句话,编码器会把这个句子转换成单一的矢量表征,然后解码器将这个矢量解码成另一种语言的句子。 这种称为编码器 – 解码器的两步模型,比用单个序列到序列的 RNN实时地进行翻译要好得多,因为句子的最后一个单词可以影响翻译的第一句话,所以你需要等到听完整个句子才能翻译。第16章还会介绍如何实现编码器-解码器(会比图15-4中复杂)

图15-4 序列到序列(左上),序列到矢量(右上),矢量到序列(左下),延迟序列到序列(右下)

训练RNN

训练RNN诀窍是在时间上展开(就像我们刚刚做的那样),然后只要使用常规反向传播(见图 15-5)。 这个策略被称为时间上的反向传播(BPTT)。

图15-5 随时间反向传播

就像在正常的反向传播中一样,展开的网络(用虚线箭头表示)中先有一个正向传播(虚线)。然后使用损失函数 C(Y(0), Y(1), …Y(T)) 评估输出序列(其中T是最大时间步)。这个损失函数会忽略一些输出,见图15-5(例如,在序列到矢量的RNN中,除了最后一项,其它的都被忽略了)。损失函数的梯度通过展开的网络反向传播(实线箭头)。最后使用在 BPTT 期间计算的梯度来更新模型参数。注意,梯度在损失函数所使用的所有输出中反向流动,而不仅仅通过最终输出(例如,在图 15-5 中,损失函数使用网络的最后三个输出 Y(2),Y(3) 和 Y(4),所以梯度流经这三个输出,但不通过 Y(0) 和 Y(1)。而且,由于在每个时间步骤使用相同的参数Wb,所以反向传播将做正确的事情并对所有时间步求和。

幸好,tf.keras处理了这些麻烦。

预测时间序列

假设你在研究网站每小时的活跃用户数,或是所在城市的每日气温,或公司的财务状况,用多种指标做季度衡量。在这些任务中,数据都是一个序列,每步有一个或多个值。这被称为时间序列。在前两个任务中,每个时间步只有一个值,它们是单变量时间序列。在财务状况的任务中,每个时间步有多个值(利润、欠账,等等),所以是多变量时间序列。典型的任务是预测未来值,称为“预测”。另一个任务是填空:预测(或“后测”)过去的缺失值,这被称为“填充”。例如,图15-6展示了3个单变量时间序列,每个都有50个时间步,目标是预测下一个时间步的值(用X表示)。

图15-6 时间序列预测

简单起见,使用函数generate_time_series()生成的时间序列,如下:

def generate_time_series(batch_size, n_steps):      freq1, freq2, offsets1, offsets2 = np.random.rand(4, batch_size, 1)      time = np.linspace(0, 1, n_steps)      series = 0.5 * np.sin((time - offsets1) * (freq1 * 10 + 10))  #   wave 1      series += 0.2 * np.sin((time - offsets2) * (freq2 * 20 + 20)) # + wave 2      series += 0.1 * (np.random.rand(batch_size, n_steps) - 0.5)   # + noise      return series[..., np.newaxis].astype(np.float32)

这个函数可以根据要求创建出时间序列(通过batch_size参数),长度为n_steps,每个时间步只有1个值。函数返回NumPy数组,形状是[批次大小, 时间步数, 1],每个序列是两个正弦波之和(固定强度+随机频率和相位),加一点噪音。

笔记:当处理时间序列时(和其它类型的时间序列),输入特征通常用3D数组来表示,其形状是 [批次大小, 时间步数, 维度],对于单变量时间序列,其维度是1,多变量时间序列的维度是其维度数。

用这个函数来创建训练集、验证集和测试集:

n_steps = 50  series = generate_time_series(10000, n_steps + 1)  X_train, y_train = series[:7000, :n_steps], series[:7000, -1]  X_valid, y_valid = series[7000:9000, :n_steps], series[7000:9000, -1]  X_test, y_test = series[9000:, :n_steps], series[9000:, -1]

X_train包含7000个时间序列(即,形状是[7000, 50, 1]),X_valid有2000个,X_test有1000个。因为预测的是单一值,目标值是列矢量(y_train的形状是[7000, 1])。

基线模型

使用RNN之前,最好有基线指标,否则做出来的模型可能比基线模型还糟。例如,最简单的方法,是预测每个序列的最后一个值。这个方法被称为朴素预测,有时很难被超越。在这个例子中,它的均方误差为0.020:

>>> y_pred = X_valid[:, -1]  >>> np.mean(keras.losses.mean_squared_error(y_valid, y_pred))  0.020211367

另一个简单的方法是使用全连接网络。因为结果要是打平的特征列表,需要加一个Flatten层。使用简单线性回归模型,使预测值是时间序列中每个值的线性组合:

model = keras.models.Sequential([      keras.layers.Flatten(input_shape=[50, 1]),      keras.layers.Dense(1)  ])

使用MSE损失、Adam优化器编译模型,在训练集上训练20个周期,用验证集评估,最终得到的MSE值为0.004。比朴素预测强多了!

实现一个简单RNN

搭建一个简单RNN模型:

model = keras.models.Sequential([    keras.layers.SimpleRNN(1, input_shape=[None, 1])  ])

这是能实现的最简单的RNN。只有1个层,1个神经元,如图15-1。不用指定输入序列的长度(和之前的模型不同),因为循环神经网络可以处理任意的时间步(这就是为什么将第一个输入维度设为None)。默认时,SimpleRNN使用双曲正切激活函数。和之前看到的一样:初始状态h(init)设为0,和时间序列的第一个值x(0)一起传递给神经元。神经元计算这两个值的加权和,对结果使用双曲正切激活函数,得到第一个输出y(0)。在简单RNN中,这个输出也是新状态h(0)。这个新状态和下一个输入值x(1),按照这个流程,直到输出最后一个值,y49。所有这些都是同时对每个时间序列进行的。

笔记:默认时,Keras的循环层只返回最后一个输出。要让其返回每个时间步的输出,必须设置return_sequences=True

用这个模型编译、训练、评估(和之前一样,用Adam训练20个周期),你会发现它的MSE只有0.014。击败了朴素预测,但不如简单线性模型。对于每个神经元,线性简单模型中每个时间步骤每个输入就有一个参数(前面用过的简单线性模型一共有51个参数)。相反,对于简单RNN中每个循环神经元,每个输入每个隐藏状态只有一个参数(在简单RNN中,就是每层循环神经元的数量),加上一个偏置项。在这个简单RNN中,只有三个参数。

趋势和季节性 还有其它预测时间序列的模型,比如权重移动平均模型或自动回归集成移动平均(ARIMA)模型。某些模型需要先移出趋势和季节性。例如,如果要研究网站的活跃用户数,它每月会增长10%,就需要去掉这个趋势。训练好模型之后,在做预测时,你可以将趋势加回来做最终的预测。相似的,如果要预测防晒霜的每月销量,会观察到明显的季节性:每年夏天卖的多。需要将季节性从时间序列去除,比如计算每个时间步和前一年的差值(这个方法被称为差分)。然后,当训练好模型,做预测时,可以将季节性加回来,来得到最终结果。 使用RNN时,一般不需要做这些,但在有些任务中可以提高性能,因为模型不是非要学习这些趋势或季节性。

很显然,这个简单RNN过于简单了,性能不成。下面就来添加更多的循环层!

深度RNN

将多个神经元的层堆起来,见图15-7。就形成了深度RNN。

图15-7 深度RNN(左)和随时间展开的深度RNN(右)

用tf.keras实现深度RNN相当容易:将循环层堆起来就成。在这个例子中,我们使用三个SimpleRNN层(也可以添加其它类型的循环层,比如LSTM或GRU):

model = keras.models.Sequential([      keras.layers.SimpleRNN(20, return_sequences=True, input_shape=[None, 1]),      keras.layers.SimpleRNN(20, return_sequences=True),      keras.layers.SimpleRNN(1)  ])

警告:所有循环层一定要设置return_sequences=True(除了最后一层,因为最后一层只关心输出)。如果没有设置,输出的是2D数组(只有最终时间步的输出),而不是3D数组(包含所有时间步的输出),下一个循环层就接收不到3D格式的序列数据。

如果对这个模型做编译,训练和评估,其MSE值可以达到0.003。总算打败了线性模型!

最后一层不够理想:因为要预测单一值,每个时间步只能有一个输出值,最终层只能有一个神经元。但是一个神经元意味着隐藏态只有一个值。RNN大部分使用其他循环层的隐藏态的所有信息,最后一层的隐藏态不怎么用到。另外,因为SimpleRNN层默认使用tanh激活函数,预测值位于-1和1之间。想使用另一个激活函数该怎么办呢?出于这些原因,最好使用紧密层:运行更快,准确率差不多,可以选择任何激活函数。如果做了替换,要将第二个循环层的return_sequences=True删掉:

model = keras.models.Sequential([      keras.layers.SimpleRNN(20, return_sequences=True, input_shape=[None, 1]),      keras.layers.SimpleRNN(20),      keras.layers.Dense(1)  ])

如果训练这个模型,会发现它收敛更快,效果也不错。

提前预测几个时间步

目前为止我们只是预测下一个时间步的值,但也可以轻易地提前预测几步,只要改变目标就成(例如,要提前预测10步,只要将目标变为10步就成)。但如果想预测后面的10个值呢?

第一种方法是使用训练好的模型,预测出下一个值,然后将这个值添加到输入中(假设这个预测值真实发生了),使用这个模型再次预测下一个值,依次类推,见如下代码:

series = generate_time_series(1, n_steps + 10)  X_new, Y_new = series[:, :n_steps], series[:, n_steps:]  X = X_new  for step_ahead in range(10):      y_pred_one = model.predict(X[:, step_ahead:])[:, np.newaxis, :]      X = np.concatenate([X, y_pred_one], axis=1)    Y_pred = X[:, n_steps:]

想象的到,第一个预测值比后面的更准,因为错误可能会累积(见图15-8)。如果在验证集上评估这个方法,MSE值为0.029。MSE比之前高多了,但因为任务本身难,这个对比意义不大。将其余朴素预测(预测时间序列可以恒定10个步骤)或简单线性模型对比的意义更大。朴素方法效果很差(MSE值为0.223),线性简单模型的MSE值为0.0188:比RNN的预测效果好,并且还快。如果只想在复杂任务上提前预测几步的话,这个方法就够了。

图15-8 提前预测10步,每次1步

第二种方法是训练一个RNN,一次性预测出10个值。还可以使用序列到矢量模型,但输出的是10个值。但是,我们先需要修改矢量,时期含有10个值:

series = generate_time_series(10000, n_steps + 10)  X_train, Y_train = series[:7000, :n_steps], series[:7000, -10:, 0]  X_valid, Y_valid = series[7000:9000, :n_steps], series[7000:9000, -10:, 0]  X_test, Y_test = series[9000:, :n_steps], series[9000:, -10:, 0]

然后使输出层有10个神经元:

model = keras.models.Sequential([      keras.layers.SimpleRNN(20, return_sequences=True, input_shape=[None, 1]),      keras.layers.SimpleRNN(20),      keras.layers.Dense(10)  ])

训练好这个模型之后,就可以一次预测出后面的10个值了:

Y_pred = model.predict(X_new)

这个模型的效果不错:预测10个值的MSE值为0.008。比线性模型强多了。但还有继续改善的空间,除了在最后的时间步用训练模型预测接下来的10个值,还可以在每个时间步预测接下来的10个值。换句话说,可以将这个序列到矢量的RNN变成序列到序列的RNN。这种方法的优势,是损失会包含RNN的每个时间步的输出项,不仅是最后时间步的输出。这意味着模型中会流动着更多的误差梯度,梯度不必只通过时间流动;还可以从输出流动。这样可以稳定和加速训练。

更加清楚一点,在时间步0,模型输出一个包含时间步1到10的预测矢量,在时间步1,模型输出一个包含时间步2到11的预测矢量,以此类推。因此每个目标必须是一个序列,其长度和输入序列长度相同,每个时间步包含一个10维矢量。先准备目标序列:

Y = np.empty((10000, n_steps, 10)) # each target is a sequence of 10D vectors  for step_ahead in range(1, 10 + 1):      Y[:, :, step_ahead - 1] = series[:, step_ahead:step_ahead + n_steps, 0]  Y_train = Y[:7000]  Y_valid = Y[7000:9000]  Y_test = Y[9000:]

笔记:目标要包含出现在输入中的值(X_trainY_train有许多重复),听起来很奇怪。这不是作弊吗?其实不是:在每个时间步,模型只知道过去的时间步,不能向前看。这个模型被称为因果模型。

要将模型变成序列到序列的模型,必须给所有循环层(包括最后一个)设置return_sequences=True,还必须在每个时间步添加紧密输出层。出于这个目的,Keras提供了TimeDistributed层:它将任意层(比如,紧密层)包装起来,然后在输入序列的每个时间步上使用。通过变形输入,将每个时间步处理为独立实例(即,将输入从 [批次大小, 时间步数, 输入维度] 变形为 [批次大小 × 时间步数, 输入维度] ;在这个例子中,因为前一SimpleRNN有20个神经元,输入的维度数是20),这个层的效率很高。然后运行紧密层,最后将输出变形为序列(即,将输出从 [批次大小 × 时间步数, 输出维度] 变形为 [批次大小, 时间步数, 输出维度] ;在这个例子中,输出维度数是10,因为紧密层有10个神经元)。下面是更新后的模型:

model = keras.models.Sequential([      keras.layers.SimpleRNN(20, return_sequences=True, input_shape=[None, 1]),      keras.layers.SimpleRNN(20, return_sequences=True),      keras.layers.TimeDistributed(keras.layers.Dense(10))  ])

紧密层实际上是支持序列(和更高维度的输入)作为输入的:如同TimeDistributed(Dense(…))一样处理序列,意味着只应用在最后的输入维度上(所有时间步独立)。因此,因此可以将最后一层替换为Dense(10)。但为了能够清晰,我们还是使用TimeDistributed(Dense(10)),因为清楚的展示了紧密层独立应用在了每个时间上,并且模型会输出一个序列,不仅仅是一个单矢量。

训练时需要所有输出,但预测和评估时,只需最后时间步的输出。因此尽管训练时依赖所有输出的MSE,评估需要一个自定义指标,只计算最后一个时间步输出值的MSE:

def last_time_step_mse(Y_true, Y_pred):      return keras.metrics.mean_squared_error(Y_true[:, -1], Y_pred[:, -1])    optimizer = keras.optimizers.Adam(lr=0.01)  model.compile(loss="mse", optimizer=optimizer, metrics=[last_time_step_mse])

得到的MSE值为0.006,比前面的模型提高了25%。可以将这个方法和第一个结合起来:先用这个RNN预测接下来的10个值,然后将结果和输入序列连起来,再用模型预测接下来的10个值,以此类推。使用这个方法,可以预测任意长度的序列。对长期预测可能不那么准确,但用来生成音乐和文字是足够的,第16章有例子。

提示:当预测时间序列时,最好给预测加上误差条。要这么做,一个高效的方法是用MC Dropout,第11章介绍过:给每个记忆单元添加一个MC Dropout层丢失部分输入和隐藏状态。训练之后,要预测新的时间序列,可以多次使用模型计算每一步预测值的平均值和标准差。

简单RNN在预测时间序列或处理其它类型序列时表现很好,但在长序列上表现不佳。接下来就探究其原因和解决方法。

处理长序列

在训练长序列的 RNN 模型时,必须运行许多时间步,展开的RNN变成了一个很深的网络。正如任何深度神经网络一样,它面临不稳定梯度问题(第11章讨论过),使训练无法停止,或训练不稳定。另外,当RNN处理长序列时,RNN会逐渐忘掉序列的第一个输入。下面就来看看这两个问题,先是第一个问题。

应对不稳定梯度

很多之前讨论过的缓解不稳定梯度的技巧都可以应用在RNN中:好的参数初始化方式,更快的优化器,dropout,等等。但是非饱和激活函数(如 ReLU)的帮助不大;事实上,它会导致RNN更加不稳定。为什么呢?假设梯度下降更新了权重,可以令第一个时间步的输出提高。因为每个时间步使用的权重相同,第二个时间步的输出也会提高,这样就会导致输出爆炸 —— 不饱和激活函数不能阻止这个问题。要降低爆炸风险,可以使用更小的学习率,更简单的方法是使用一个饱和激活函数,比如双曲正切函数(这就解释了为什么tanh是默认选项)。同样的道理,梯度本身也可能爆炸。如果观察到训练不稳定,可以监督梯度的大小(例如,使用TensorBoard),看情况使用梯度裁剪。

另外,批归一化也没什么帮助。事实上,不能在时间步骤之间使用批归一化,只能在循环层之间使用。更加准确点,技术上可以将BN层添加到记忆单元上(后面会看到),这样就可以应用在每个时间步上了(既对输入使用,也对前一步的隐藏态使用)。但是,每个时间步用BN层相同,参数也相同,与输入和隐藏态的大小和偏移无关。在实践中,César Laurent等人在2015年的一篇论文展示,这么做的效果不好:作者发现BN层只对输入有用,而对隐藏态没用。换句话说,在循环层之间使用BN层时,效果只有一点(即在图15-7中垂直使用),在循环层之内使用,效果不大(即,水平使用)。在Keras中,可以在每个循环层之前添加BatchNormalization层,但不要期待太高。

另一种归一化的形式效果好些:层归一化。它是由Jimmy Lei Ba等人在2016年的一篇论文中提出的:它跟批归一化很像,但不是在批次维度上做归一化,而是在特征维度上归一化。这么做的一个优势是可以独立对每个实例,实时计算所需的统计量。这还意味着训练和测试中的行为是一致的(这点和BN相反),且不需要使用指数移动平均来估计训练集中所有实例的特征统计。和BN一样,层归一化会学习每个输入的比例和偏移参数。在RNN中,层归一化通常用在输入和隐藏态的线型组合之后。

使用tf.keras在一个简单记忆单元中实现层归一化。要这么做,需要定义一个自定义记忆单元。就像一个常规层一样,call()接收两个参数:当前时间步的inputs和上一时间步的隐藏statesstates是一个包含一个或多个张量的列表。在简单RNN单元中,states包含一个等于上一时间步输出的张量,但其它单元可能包含多个状态张量(比如LSTMCell有长期状态和短期状态)。单元还必须有一个state_size属性和一个output_size属性。在简单RNN中,这两个属性等于神经元的数量。下面的代码实现了一个自定义记忆单元,作用类似于SimpleRNNCell,但会在每个时间步做层归一化:

class LNSimpleRNNCell(keras.layers.Layer):      def __init__(self, units, activation="tanh", **kwargs):          super().__init__(**kwargs)          self.state_size = units          self.output_size = units          self.simple_rnn_cell = keras.layers.SimpleRNNCell(units,                                                            activation=None)          self.layer_norm = keras.layers.LayerNormalization()          self.activation = keras.activations.get(activation)      def call(self, inputs, states):          outputs, new_states = self.simple_rnn_cell(inputs, states)          norm_outputs = self.activation(self.layer_norm(outputs))          return norm_outputs, [norm_outputs]

代码不难。和其它自定义类一样,LNSimpleRNNCell继承自keras.layers.Layer。构造器接收unit的数量、激活函数、设置state_sizeoutput_size属性,创建一个没有激活函数的SimpleRNNCell(因为要在线性运算之后、激活函数之前运行层归一化)。然后构造器创建LayerNormalization层,最终拿到激活函数。call()方法先应用简单RNN单元,计算当前输入和上一隐藏态的线性组合,然后返回结果两次(事实上,在SimpleRNNCell中,输入等于隐藏状态:换句话说,new_states[0]等于outputs,因此可以放心地在剩下的call()中忽略new_states)。然后,call()应用层归一化,然后使用激活函数。最后,返回去输出两次(一次作为输出,一次作为新的隐藏态)。要使用这个自定义单元,需要做的是创建一个keras.layers.RNN层,传给其单元实例:

model = keras.models.Sequential([      keras.layers.RNN(LNSimpleRNNCell(20), return_sequences=True,                       input_shape=[None, 1]),      keras.layers.RNN(LNSimpleRNNCell(20), return_sequences=True),      keras.layers.TimeDistributed(keras.layers.Dense(10))  ])

相似地,可以创建一个自定义单元,在时间步之间应用dropout。但有一个更简单的方法:Keras提供的所有循环层(除了keras.layers.RNN)和单元都有一个dropout超参数和一个recurrent_dropout超参数:前者定义dropout率,应用到所有输入上(每个时间步),后者定义dropout率,应用到隐藏态上(也是每个时间步)。无需在RNN中创建自定义单元来应用dropout。

有了这些方法,就可以减轻不稳定梯度问题,高效训练RNN了。下面来看如何处理短期记忆问题。

处理短期记忆问题

由于数据在RNN中流动时会经历转换,每个时间步都损失了一定信息。一定时间后,第一个输入实际上会在 RNN 的状态中消失。就像一个搅局者。比如《寻找尼莫》中的多莉想翻译一个长句:当她读完这句话时,就把开头忘了。为了解决这个问题,涌现出了各种带有长期记忆的单元。首先了解一下最流行的一种:长短时记忆神经单元 LSTM。

LSTM 单元

长短时记忆单元在 1997 年由 Sepp Hochreiter 和 Jürgen Schmidhuber 首次提出,并在接下来的几年内经过 Alex GravesHaşim SakWojciech Zaremba 等人的改进,逐渐完善。如果把 LSTM 单元看作一个黑盒,可以将其当做基本单元一样来使用,但 LSTM 单元比基本单元性能更好:收敛更快,能够感知数据的长时依赖。在Keras中,可以将SimpleRNN层,替换为LSTM层:

model = keras.models.Sequential([      keras.layers.LSTM(20, return_sequences=True, input_shape=[None, 1]),      keras.layers.LSTM(20, return_sequences=True),      keras.layers.TimeDistributed(keras.layers.Dense(10))  ])

或者,可以使用通用的keras.layers.RNN layer,设置LSTMCell参数:

model = keras.models.Sequential([      keras.layers.RNN(keras.layers.LSTMCell(20), return_sequences=True,                       input_shape=[None, 1]),      keras.layers.RNN(keras.layers.LSTMCell(20), return_sequences=True),      keras.layers.TimeDistributed(keras.layers.Dense(10))  ])

但是,当在GPU运行时,LSTM层使用了优化的实现(见第19章),所以更应该使用LSTM层(RNN大多用来自定义层)。

LSTM 单元的工作机制是什么呢?图 15-9 展示了 LSTM 单元的结构。

图15-9 LSTM单元

如果不观察黑箱的内部,LSTM单元跟常规单元看起来差不多,除了LSTM单元的状态分成了两个矢量:h(t) 和 c(t)(c代表 cell)。可以认为 h(t) 是短期记忆状态,c(t) 是长期记忆状态。

现在打开黑箱。LSTM 单元的核心思想是它能从长期状态中学习该存储什么、丢掉什么、读取什么。当长期状态 c(t-1) 从左向右在网络中传播,它先经过遗忘门(forget gate),丢弃一些记忆,之后通过添加操作增加一些记忆(从输入门中选择一些记忆)。结果c(t) 不经任何转换直接输出。因此,在每个时间步,都有一些记忆被抛弃,也有新的记忆添加进来。另外,添加操作之后,长时状态复制后经过 tanh 激活函数,然后结果被输出门过滤。得到短时状态h(t)(它等于这一时间步的单元输出, y(t)。接下来讨论新的记忆如何产生,门是如何工作的。

首先,当前的输入矢量 x(t) 和前一时刻的短时状态 h(t-1) 作为输入,传给四个不同的全连接层,这四个全连接层有不同的目的:

  • 输出 g(t)的层是主要层。它的常规任务是分析当前的输入 x(t) 和前一时刻的短时状态 h(t-1)。基本单元中与这种结构一样,直接输出了 h(t) 和 y(t) 。相反的,LSTM 单元中的该层的输出不会直接出去,儿是将最重要的部分保存在长期状态中(其余部分丢掉)。
  • 其它三个全连接层被是门控制器(gate controller)。其采用 Logistic 作为激活函数,输出范围在 0 到 1 之间。可以看到,这三个层的输出提供给了逐元素乘法操作,当输入为 0 时门关闭,输出为 1 时门打开。具体讲:
    • 遗忘门(由 f(t) 控制)决定哪些长期记忆需要被删除;
    • 输入门(由 i(t) 控制) 决定哪部分 g(t) 应该被添加到长时状态中。
    • 输出门(由 o(t) 控制)决定长时状态的哪些部分要读取和输出为 h(t) 和y(t)。

总而言之,LSTM 单元能够学习识别重要输入(输入门的作用),存储进长时状态,并保存必要的时间(遗忘门功能),并在需要时提取出来。这解释了为什么LSTM 单元能够如此成功地获取时间序列、长文本、录音等数据中的长期模式。

公式 15-3 总结了如何计算单元的长时状态,短时状态,和单个实例的在每个时间步的输出(小批次的公式和这个公式很像)。

公式15-3 LSTM计算

在这个公式中,

  • Wxi,Wxf,Wxo,Wxg 是四个全连接层连接输入向量 x(t) 的权重。
  • Whi,Whf,Who,Whg 是四个全连接层连接上一时刻的短时状态 h(t-1) 的权重。
  • bi,bf,bo,bg是全连接层的四个偏置项。需要注意的是 TensorFlow 将bf初始化为全 1 向量,而非全 0。这样可以保证在训练状态开始时,忘掉所有东西。

窥孔连接

在基本 LSTM 单元中,门控制器只能观察当前输入 x(t) 和前一时刻的短时状态 h(t-1)。不妨让各个门控制器窥视一下长时状态,获取一些上下文信息。该想法由 Felix Gers 和 Jürgen Schmidhuber 在 2000 年提出。他们提出了一个 LSTM 的变体,带有叫做窥孔连接的额外连接:把前一时刻的长时状态 c(t-1) 输入给遗忘门和输入门,当前时刻的长时状态c(t)输入给输出门。这么做时常可以提高性能,但不一定每次都能有效,也没有清晰的规律显示哪种任务适合添加窥孔连接。

Keras中,LSTM层基于keras.layers.LSTMCell单元,后者目前还不支持窥孔。但是,试验性的tf.keras.experimental.PeepholeLSTMCell支持,所以可以创建一个keras.layers.RNN层,向构造器传入PeepholeLSTMCell

LSTM有多种其它变体,其中特别流行的是GRU单元。

GRU 单元

图15-10 GRU单元

门控循环单元(图 15-10)在 2014 年的 Kyunghyun Cho 的论文中提出,并且此文也引入了前文所述的编码器-解码器网络。

GRU单元是 LSTM 单元的简化版本,能实现同样的性能(这也说明了为什么它能越来越流行)。简化主要在一下几个方面:

  • 长时状态和短时状态合并为一个矢量 h(t)。
  • 用一个门控制器z(t)控制遗忘门和输入门。如果门控制器输出 1,则遗忘门打开(=1),输入门关闭(1 – 1 = 0)。如果输出0,则相反。换句话说,如果当有记忆要存储,那么就必须先在其存储位置删掉该处记忆。这构成了 LSTM 本身的常见变体。
  • GRU 单元取消了输出门,每个时间步输出全态矢量。但是,增加了一个控制门 r(t) 来控制前一状态的哪些部分呈现给主层g(t)。

公式 15-4 总结了如何计算单元对单个实例在每个时间步的状态。

公式15-4 GRU计算

Keras提供了keras.layers.GRU层(基于keras.layers.GRUCell记忆单元);使用时,只需将SimpleRNNLSTM替换为GRU

LSTM和GRU是RNN取得成功的主要原因之一。尽管它们相比于简单RNN可以处理更长的序列了,还是有一定程度的短时记忆,序列超过100时,比如音频、长时间序列或长序列,学习长时模式就很困难。应对的方法之一,是使用缩短输入序列,例如使用1D卷积层。

使用1D卷积层处理序列

在第14章中,我们使用2D卷积层,通过在图片上滑动几个小核(或过滤器),来产生多个2D特征映射(每个核产生一个)。相似的,1D军几层在序列上滑动几个核,每个核可以产生一个1D特征映射。每个核能学到一个非常短序列模式(不会超过核的大小)。如果你是用10个核,则输出会包括10个1维的序列(长度相同),或者可以将输出当做一个10维的序列。这意味着,可以搭建一个由循环层和1D卷积层(或1维池化层)混合组成的神经网络。如果1D卷积层的步长是1,填充为零,则输出序列的长度和输入序列相同。但如果使用"valid"填充,或大于1的步长,则输出序列会比输入序列短,所以一定要按照目标作出调整。例如,下面的模型和之前的一样,除了开头是一个步长为2的1D卷积层,用因子2对输入序列降采样。核大小比步长大,所以所有输入会用来计算层的输出,所以模型可以学到保存有用的信息、丢弃不重要信息。通过缩短序列,卷积层可以帮助GRU检测长模式。注意,必须裁剪目标中的前三个时间步(因为核大小是4,卷积层的第一个输出是基于输入时间步0到3),并用因子2对目标做降采样:

model = keras.models.Sequential([      keras.layers.Conv1D(filters=20, kernel_size=4, strides=2, padding="valid",                          input_shape=[None, 1]),      keras.layers.GRU(20, return_sequences=True),      keras.layers.GRU(20, return_sequences=True),      keras.layers.TimeDistributed(keras.layers.Dense(10))  ])    model.compile(loss="mse", optimizer="adam", metrics=[last_time_step_mse])  history = model.fit(X_train, Y_train[:, 3::2], epochs=20,                      validation_data=(X_valid, Y_valid[:, 3::2]))

如果训练并评估这个模型,你会发现它是目前最好的模型。卷积层确实发挥了作用。事实上,可以只使用1D卷积层,不用循环层!

WaveNet

在一篇2016年的论文中,Aaron van den Oord和其它DeepMind的研究者,提出了一个名为WaveNet的架构。他们将1D卷积层叠起来,每一层膨胀率(如何将每个神经元的输入分开)变为2倍:第一个卷积层一次只观察两个时间步,,接下来的一层观察四个时间步(感受野是4个时间步的长度),下一层观察八个时间步,以此类推(见图15-11)。用这种方式,底下的层学习短时模式,上面的层学习长时模式。得益于翻倍的膨胀率,这个网络可以非常高效地处理极长的序列。

图15-11 WaveNet架构

在WaveNet论文中,作者叠了10个卷积层,膨胀率为1, 2, 4, 8, …, 256, 512,然后又叠了一组10个相同的层(膨胀率还是1, 2, 4, 8, …, 256, 512),然后又是10个相同的层。作者解释到,一摞这样的10个卷积层,就像一个超高效的核大小为1024的卷积层(只是更快、更强、参数更少),所以同样的结构叠了三次。他们还给输入序列左填充了一些0,以满足每层的膨胀率,使序列长度不变。下面的代码实现了简化的WaveNet,来处理前面的序列:

model = keras.models.Sequential()  model.add(keras.layers.InputLayer(input_shape=[None, 1]))  for rate in (1, 2, 4, 8) * 2:      model.add(keras.layers.Conv1D(filters=20, kernel_size=2, padding="causal",                                    activation="relu", dilation_rate=rate))  model.add(keras.layers.Conv1D(filters=10, kernel_size=1))  model.compile(loss="mse", optimizer="adam", metrics=[last_time_step_mse])  history = model.fit(X_train, Y_train, epochs=20,                      validation_data=(X_valid, Y_valid))

Sequential模型开头是一个输入层(比只在第一个层上设定input_shape简单的多);然后是一个1D卷积层,使用"causal"填充:这可以保证卷积层在做预测时,不会窥视到未来值(等价于在输入序列的左边用零填充填充合适数量的0)。然后添加相似的成对的层,膨胀率为1、2、4、8,接着又是1、2、4、8。最后,添加输出层:一个有10个大小为1的过滤器的卷积层,没有激活函数。得益于填充层,每个卷积层输出的序列长度都和输入序列一样,所以训练时的目标可以是完整序列:无需裁剪或降采样。

最后两个模型的序列预测结果最好!在WaveNet论文中,作者在多种音频任务(WaveNet名字正是源于此)中,包括文本转语音任务(可以输出多种语言极为真实的语音),达到了顶尖的表现。他们还用这个模型生成音乐,每次生成一段音频。每段音频包含上万个时间步(LSTM和GRU无法处理如此长的序列),这是相当了不起的。

第16章,我们会继续探索RNN,会看到如何用RNN处理各种NLP任务。

练习

  1. 你能说出序列到序列RNN 的几个应用吗?序列到矢量的应用?矢量到序列的应用?
  2. RNN层的输入要有多少维?每一维表示什么?输出呢?
  3. 如果搭建深度序列到序列RNN,哪些RNN层要设置return_sequences=True?序列到矢量RNN又如何?
  4. 假如有一个每日单变量时间序列,想预测接下来的七天。要使用什么RNN架构?
  5. 训练RNN的困难是什么?如何应对?
  6. 画出LSTM单元的架构图?
  7. 为什么在RNN中使用1D卷积层?
  8. 哪种神经网络架构可以用来分类视频?
  9. 为SketchRNN数据集(TensorFlow Datasets中有),训练一个分类模型。
  10. 下载Bach chorales数据集,并解压。它含有382首巴赫作曲的赞美歌。每首的长度是100到640时间步,每个时间步包含4个整数,每个整数对应一个钢琴音符索引(除了0,表示没有音符)。训练一个可以预测下一个时间步(四个音符)的模型,循环、卷积、或混合架构。然后使用这个模型来生成类似巴赫的音乐,每个时间一个音符:可以给模型一首赞美歌的开头,然后让其预测接下来的时间步,然后将输出加到输入上,再让模型继续预测。或者查看Google的 Coconet 模型,它是Google来做巴赫曲子的。

参考答案见附录A。


第10章 使用Keras搭建人工神经网络 第11章 训练深度神经网络 第12章 使用TensorFlow自定义模型并训练 第13章 使用TensorFlow加载和预处理数据 第14章 使用卷积神经网络实现深度计算机视觉 第15章 使用RNN和CNN处理序列 [第16章 使用RNN和注意力机制进行自然语言处理] [第17章 使用自编码器和GAN做表征学习和生成式学习] [第18章 强化学习] [第19章 规模化训练和部署TensorFlow模型]