前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >《机器学习实战:基于Scikit-Learn、Keras和TensorFlow》第15章 使用RNN和CNN处理序列

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

作者头像
SeanCheney
发布2019-12-23 19:09:44
1.5K0
发布2019-12-23 19:09:44
举报
文章被收录于专栏:SeanCheney的专栏

第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()生成的时间序列,如下:

代码语言:javascript
复制
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,多变量时间序列的维度是其维度数。

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

代码语言:javascript
复制
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:

代码语言:javascript
复制
>>> y_pred = X_valid[:, -1]
>>> np.mean(keras.losses.mean_squared_error(y_valid, y_pred))
0.020211367

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

代码语言:javascript
复制
model = keras.models.Sequential([
    keras.layers.Flatten(input_shape=[50, 1]),
    keras.layers.Dense(1)
])

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

实现一个简单RNN

搭建一个简单RNN模型:

代码语言:javascript
复制
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):

代码语言:javascript
复制
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删掉:

代码语言:javascript
复制
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个值呢?

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

代码语言:javascript
复制
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个值:

代码语言:javascript
复制
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个神经元:

代码语言:javascript
复制
model = keras.models.Sequential([
    keras.layers.SimpleRNN(20, return_sequences=True, input_shape=[None, 1]),
    keras.layers.SimpleRNN(20),
    keras.layers.Dense(10)
])

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

代码语言:javascript
复制
Y_pred = model.predict(X_new)

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

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

代码语言:javascript
复制
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个神经元)。下面是更新后的模型:

代码语言:javascript
复制
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:

代码语言:javascript
复制
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,但会在每个时间步做层归一化:

代码语言:javascript
复制
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层,传给其单元实例:

代码语言:javascript
复制
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层:

代码语言:javascript
复制
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参数:

代码语言:javascript
复制
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 时门打开。具体讲:
代码语言:txt
复制
- 遗忘门(由 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对目标做降采样:

代码语言:javascript
复制
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,来处理前面的序列:

代码语言:javascript
复制
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模型


本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 循环神经元和层
    • 记忆单元
    • 输入和输出序列
    • 训练RNN
    • 预测时间序列
      • 基线模型
        • 实现一个简单RNN
          • 深度RNN
            • 提前预测几个时间步
            • 处理长序列
              • 应对不稳定梯度
                • 处理短期记忆问题
                • LSTM 单元
                  • 窥孔连接
                    • GRU 单元
                      • 使用1D卷积层处理序列
                        • WaveNet
                        • 练习
                        相关产品与服务
                        NLP 服务
                        NLP 服务(Natural Language Process,NLP)深度整合了腾讯内部的 NLP 技术,提供多项智能文本处理和文本生成能力,包括词法分析、相似词召回、词相似度、句子相似度、文本润色、句子纠错、文本补全、句子生成等。满足各行业的文本智能需求。
                        领券
                        问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档