TF2 RNN篇之循环神经网络

TF2 RNN篇之循环神经网络



现在我们来考虑如何处理序列信号,以文本序列为例,考虑一个句子:
“ I   h a t e   t h i s   b o r i n g   m o v i e ” “I \ hate \ this \ boring \ movie” I hate this boring movie
通过Embedding 层,可以将它转换为shape 为[𝑏, 𝑠, 𝑛]的张量,𝑏为句子数量,𝑠为句子长度,𝑛为词向量长度。上述句子可以表示为shape 为[1,5,10]的张量,其中5 代表句子单词长度,10 表示词向量长度。

接下来逐步探索能够处理序列信号的网络模型,为了便于表达,我们以情感分类任务为例,如图所示。情感分类任务通过分析给出的文本序列,提炼出文本数据表达的整体语义特征,从而预测输入文本的情感类型:正面评价或者负面评价。从分类角度来看,情感分类问题就是一个简单的二分类问题,与图片分类不一样的是,由于输入是文本序列,传统的卷积神经网络并不能取得很好的效果。那么什么类型的网络擅长处理序列数据呢?

在这里插入图片描述

全连接层可行吗?

首先我们想到的是,对于每个词向量,分别使用一个全连接层网络

𝒐 = 𝜎(𝑾𝑡𝒙𝑡 + 𝒃𝑡 )

提取语义特征,如图所示,各个单词的词向量通过𝑠个全连接层分类网络1 提取每个单词的特征,所有单词的特征最后合并,并通过分类网络2 输出序列的类别概率分布,对于长度为𝑠的句子来说,至少需要𝑠个全网络层。

在这里插入图片描述
这种方案的缺点有:

❑网络参数量是相当可观的,内存占用和计算代价较高,同时由于每个序列的长度𝑠并不相同,网络结构是动态变化
❑ 每个全连接层子网络𝑾𝑖和𝒃𝑖只能感受当前词向量的输入,并不能感知之前和之后的语境信息,导致句子整体语义的缺失,每个子网络只能根据自己的输入来提取高层特征,有如管中窥豹。
我们接下来逐一解决这2 大缺陷。

共享权值

在介绍卷积神经网络时,我们就比较过,卷积神经网络之所以在处理局部相关数据时优于全连接网络,是因为它充分利用了权值共享的思想,大大减少了网络的参数量,使得网络训练起来更加高效。那么,我们在处理序列信号的问题上,能否借鉴权值共享的思想呢?

上图中的方案,𝑠个全连接层的网络并没有实现权值同享。我们尝试将这𝑠个网络层参数共享,这样其实相当于使用一个全连接网络来提取所有单词的特征信息,如下图所示。
在这里插入图片描述
通过权值共享后,参数量大大减少,网络训练变得更加稳定高效。但是,这种网络结构并没有考虑序列之间的先后顺序,将词向量打乱次序仍然能获得相同的输出,无法获取有效的全局语义信息

全局语义

如何赋予网络提取整体语义特征的能力呢?或者说,如何让网络能够按序提取词向量的语义信息,并累积成整个句子的全局语义信息呢?我们想到了内存(Memory)机制。如果网络能够提供一个单独的内存变量,每次提取词向量的特征并刷新内存变量,直至最后一个输入完成,此时的内存变量即存储了所有序列的语义特征,并且由于输入序列之间的先后顺序,使得内存变量内容与序列顺序紧密关联

在这里插入图片描述
我们将上述Memory 机制实现为一个状态张量 ,如图 11.6 所示,除了原来的𝑾 参数共享外,这里额外增加了一个𝑾 参数,每个时间戳𝑡上状态张量 刷新机制为:
在这里插入图片描述
其中状态张量 h 0 h_0 h0为初始的内存状态,可以初始化为全0,经过𝑠个词向量的输入后得到网络最终的状态张量 h s , h s h_s,h_s hs,hs较好地代表了句子的全局语义信息,基于 𝑠通过某个全连接层分类器即可完成情感分类任务。

循环神经网络

通过一步步地探索,我们最终提出了一种“新型”的网络结构,如图所示,在每
个时间戳𝑡,网络层接受当前时间戳的输入𝒙𝑡和上一个时间戳的网络状态向量 h t − 1 h_{t-1} ht1,经过
h t h_t ht = 𝑓𝜃( h t − 1 h_{t−1} ht1, 𝒙𝑡 )

变换后得到当前时间戳的新状态向量 𝑡,并写入内存状态中,其中𝑓𝜃代表了网络的运算逻辑,𝜃为网络参数集。在每个时间戳上,网络层均有输出产生𝒐𝑡,𝒐𝑡 = 𝑔𝜙(h 𝑡 ),即将网络的状态向量变换后输出。在这里插入图片描述
上述网络结构在时间戳上折叠,如图所示,网络循环接受序列的每个特征向量𝒙t,并刷新内部状态向量 𝑡,同时形成输出𝒐t。对于这种网络结构,我们把它叫做循环网络结构(Recurrent Neural Network,简称RNN)。

在这里插入图片描述
更特别地,如果使用张量𝑾 x h _{xh} xh 、𝑾 h h _hh hh 和偏置𝒃来参数化𝑓𝜃网络,并按照
h𝑡 = 𝜎(𝑾 x h x t _{xh}x_t xhxt + 𝑾 h h h t − 1 _{hh}h_{t-1} hhht1 + 𝒃)
方式更新内存状态,我们把这种网络叫做基本的循环神经网络,如无特别说明,一般说的循环神经网络即指这种实现。在循环神经网络中,激活函数更多地采用tanh 函数,并且可以选择不使用偏执𝒃来进一步减少参数量。状态向量 𝑡可以直接用作输出,即𝒐𝑡 = 𝑡,也可以对 h𝑡做一个简单的线性变换 𝒐𝑡 = W h o W_{ho} Who h𝑡后得到每个时间戳上的网络输出𝒐𝑡。

梯度传播

通过循环神经网络的更新表达式可以看出输出对张量𝑾 x h _{xh} xh 、𝑾 h h _{hh} hh 和偏置𝒃均是可导的,可以利用自动梯度算法来求解网络的梯度。此处我们仅简单地推导一下RNN 的梯度传播公式,并观察其特点。

在这里插入图片描述
在这里插入图片描述

RNN层使用方法

在TensorFlow 中,可以通过layers.SimpleRNNCell 来完成𝜎(𝑾 x h _{xh} xh 𝒙𝑡 + 𝑾 x𝑡−1 + 𝒃)计算。需要注意的是,在TensorFlow 中,RNN 表示通用意义上的循环神经网络,对于我们目前介绍的基础循环神经网络,它一般叫做SimpleRNN。SimpleRNN 与SimpleRNNCell 的区别在于,带Cell 的层仅仅是完成了一个时间戳的前向运算,不带Cell 的层一般是基于Cell 层实现的,它在内部已经完成了多个时间戳的循环运算,因此使用起来更为方便快捷。

SimpleRNNCell

以某输入特征长度𝑛 = 4,Cell 状态向量特征长度ℎ = 3为例,首先我们新建一个SimpleRNNCell,不需要指定序列长度𝑠,代码如下:

In[3]
cell = layers.SimpleRNNCell(3) # 创建RNN Cell,内存向量长度为3
cell.build(input_shape=(None,4)) # 打印特征长度n=4
cell.trainable_variables # 打印wxh,whh,b向量
Out[3]:
[<tf.Variable 'kernel:0' shape=(4, 3) dtype=float32, numpy=>,
<tf.Variable 'recurrent_kernel:0' shape=(3, 3) dtype=float32, numpy=>,
<tf.Variable 'bias:0' shape=(3,) dtype=float32, numpy=array([0., 0., 0.],
dtype=float32)>]

可以看到,SimpleRNNCell 内部维护了3 个张量,kernel 变量即𝑾 x h _{xh} xh 张量,recurrent_kernel变量即𝑾 h h _{hh} hh 张量,bias 变量即偏置𝒃向量。但是RNN 的Memory 向量 h并不由SimpleRNNCell 维护,需要用户自行初始化向量 𝟎并记录每个时间戳上的 𝒕。通过调用Cell 实例即可完成前向运算:
在这里插入图片描述
对于 SimpleRNNCell 来说,𝒐𝑡 = 𝑡,并没有经过额外的线性层转换,是同一个对象;[ h𝑡 ]通过一个List 包裹起来,这么设置是为了与LSTM、GRU 等RNN 变种格式统一。在循环神经网络的初始化阶段,状态向量 h𝟎一般初始化为全0 向量,例如:

In[4]
# 初始化状态向量,用列表包裹,统一格式
h0 = [tf.zeros([4,64])]
x = tf.random.normal([4,80,100]) # 生成输入张量,4个80单词的句子
xt = x[:,0,:] # 所有句子的第一个单词
# 构造输入特征n=100,序列长度s=80,状态长度为64的Cell
cell = layers.SimpleRNNCell(64)
out,h1 = cell(xt,h0) # 前向计算
print(out.shaoe,h1[0].shape)
Out[4]: (4, 64) (4, 64)

可以看到经过一个时间戳的计算后,输出和状态张量的shape 都为[𝑏, ℎ],打印出这两者的
id 如下:

In [5]:print(id(out), id(h1[0]))
Out[5]:2154936585256 2154936585256

两者id 一致,即状态向量直接作为输出向量。对于长度为𝑠的训练来说,需要循环通过Cell 类𝑠次才算完成一次网络层的前向运算。例如:

h = h0 # h 保存每个时间戳上的状态向量列表
# 在序列长度的维度解开输入,得到xt:[b,n]
for xt in tf.unstack(x, axis=1):
	out, h = cell(xt, h) # 前向计算,out 和h 均被覆盖
# 最终输出可以聚合每个时间戳上的输出,也可以只取最后时间戳的输出
out = out

最后一个时间戳的输出变量out 将作为网络的最终输出。实际上,也可以将每个时间戳上
的输出保存,然后求和或者均值,将其作为网络的最终输出

多层SimpleRNNCell 网络

和卷积神经网络一样,循环神经网络虽然在时间轴上面展开了多次,但只能算一个网络层。通过在深度方向堆叠多个Cell 类来实现深层卷积神经网络一样的效果,大大的提升网络的表达能力。但是和卷积神经网络动辄几十、上百的深度层数来比,循环神经网络很容易出现梯度弥散和梯度爆炸到现象,深层的循环神经网络训练起来非常困难,目前常见的循环神经网络模型层数一般控制在十层以内

x = tf.rando.normal([4,80,100])
xt = x[:,0,:] # 取第一个时间戳的输入x0
# 构建2个Cell,先cell0,后cell1,内存状态向量长度都为64
cell0 = layers.SimpleRNNCell(64)
cell1 = layers.SimpleRNNCell(64)
ho = [tf.zeros([4,64])] # cell0 的初始状态向量
h1 = [tf.zeros([4,64])] # cell1的 初始状态向量

在时间轴上面循环计算多次来实现整个网络的前向运算,每个时间戳上的输入xt 首先通过第一层,得到输出out0,再通过第二层,得到输出out1,代码如下:

for xt in tf.unstack(x,axis=1):
	# xt作为输入,输出out0
	out0,h0 = cell0(xt,h0)
	# 上一个cell的输出out0作为本cell的输入
	out1,h1 = cell1(out0,h1)

上述方式先完成一个时间戳上的输入在所有层上的传播,再循环计算完所有时间戳上的输入

实际上,也可以先完成输入在第一层上所有时间戳的计算,并保存第一层在所有时间
戳上的输出列表,再计算第二层、第三层等的传播。代码如下:

# 保存上一层的所有时间戳上面的输出
middle_sequences
# 计算第一层的所有时间戳上面的输出,并保存
for xt in tf.unstack(x,axis=1):
	out0,h0 = cell0(xt,h0)
	middle_sequences.append(out0)
# 计算第二层的所有时间戳上的输出
# 如果不上末层,需要保存所有时间戳上面的输出
for xt in middle_sequences:
	out1,h1 = cell1(xt,h1)

使用这种方式的话,我们需要一个额外的List 来保存上一层所有时间戳上面的状态信息:middle_sequences.append(out0)。这两种方式效果相同,可以根据个人喜好选择编程风格。需要注意的是,**循环神经网络的每一层、每一个时间戳上面均有状态输出,那么对于后续任务来说,我们应该收集哪些状态输出最有效呢?**一般来说,最末层Cell 的状态有可能保存了高层的全局语义特征,因此一般使用最末层的输出作为后续任务网络的输入。更特别地,每层最后一个时间戳上的状态输出包含了整个序列的全局信息,如果只希望选用一个状态变量来完成后续任务,比如情感分类问题,一般选用最末层、最末时间戳的状态输出最为合适。

SimpleRNN层

通过 SimpleRNNCell 层的使用,我们可以非常深入地理解循环神经网络前向运算的每个细节,但是在实际使用中,为了简便,不希望手动参与循环神经网络内部的计算过程,比如每一层的 状态向量的初始化,以及每一层在时间轴上展开的运算。通过SimpleRNN层高层接口可以非常方便地帮助我们实现此目的。
比如我们要完成单层循环神经网络的前向运算,可以方便地实现如下:

In[6]
layer = layers.SimpleRNN(64) # 创建状态向量长度为64的SimpleRNN层
x = tf.random.normal([4,80,100])
out = layer(x) # 和普通卷积网络一样,一行代码就可以输出结果了
out.shape
Out[6]
Out[6]: TensorShape([4, 64])

可以看到,通过SimpleRNN 可以仅需一行代码即可完成整个前向运算过程,它默认返回最后一个时间戳上的输出

如果希望返回所有时间戳上的输出列表,可以设置return_sequences=True 参数,代码如下:

In [7]:
# 创建RNN 层时,设置返回所有时间戳上的输出
layer = layers.SimpleRNN(64,return_sequences=True)
out = layer(x) # 前向计算
out # 输出,自动进行了concat 操作
Out[7]:
<tf.Tensor: id=12654, shape=(4, 80, 64), dtype=float32, numpy=
array([[[ 0.31804922, 0.7904409 , 0.13204293, ..., 0.02601025,
-0.7833339 , 0.65577114],>

可以看到,返回的输出张量shape 为[4,80,64],中间维度的80 即为时间戳维度。同样的,
对于多层循环神经网络,我们可以通过堆叠多个SimpleRNN 实现,如两层的网络,用法和
普通的网络类似。例如:

net =keras.Sequential([ # 构建2层RNN网络
# 除去最末层外,都需要返回所有时间戳的输出用作下一层的输入
layers.SimpleRNN(64,return_sequence=True),
layers.SimpleRNN(64),
])
out = net(x) # 前向计算

每层都需要上一层在每个时间戳上面的状态输出,因此除了最末层以外,所有的RNN 层都需要返回每个时间戳上面的状态输出,通过设置return_sequences=True 来实现。可以看到,使用SimpleRNN 层,与卷积神经网络的用法类似,非常简洁和高效。


参考书籍: TensorFlow 深度学习 — 龙龙老师

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值