1 文本分类模型和序列文本模型
1.1 普通文本分类
- 例如:情感分析(0:消极评论,1:积极评论):
- x::今天这个饭菜太难吃了,太咸了
- y:0
- x:昨晚我睡的非常舒服,非常安静
- y:1
1.2 序列文本模型
- 例如:分词 (B:词的开头,M:词的中间,E:词的结尾,S:字单独成词):
- x:今天这个饭菜太难吃了,太咸了
- y:BEBEBESBES S BME
- 例如:命名实体识别(hanlp -> BMESO):
- x:某某某 来到 北京某地 参观 自然语义 科技 公司
- y:PER O LOC O ORG ORG ORG
- y:S-PER O S-LOC O B-ORG M-ORG E-ORG
自然语言一般来说都是一个序,token1、token2、token3、token4 ...
2 静态词向量提取
自然语言中,我们需要将文本序列中的每个单词转换成一个词向量,也就是 tokens -- [batch, T] --> vec -- [batch, T, embedding_dim]
,使用 Embedding 的话,每个单词对应的词向量是固定的,但是实际上我们的文本/词语是有上下文语义信息的。
Word2Vec、Embedding – 都是静态词向量提取,它们只是单纯的词语映射列表,不能考虑上下文关系。例如:重(zhong)、重(chong) – 重量、重叠的 ’重‘ 字,通过 Word2Vec、Embedding 提取出来的向量是一样的,为了解决这个问题,就需要动态词向量提取,也就是需要融合上下文。需要得到每时每刻的信息,就需要序列建模。
3 序列特征概述
神经网络、RNN、CNN等本质上都是提取特征信息,只不过RNN考虑了上下文特征。
前馈网络的两种结构:BP神经网络、卷积神经网络,输入都是一个独立的没有上下文联系的单位,比如输入是一张图片,神经网络识别是狗还是猫。
但是对于一些有明显的上下文特征的序列化输入,比如预测视频中下一帧的播放内容,那么很明显这样的输出必须依赖以前的输入, 也就是说网络必须拥有一定的 ‘记忆能力’ 。为了赋予网络这样的记忆力,一种特殊结构的神经网络 —— 递归神经网络(Recurrent Neural Network)便应运而生了。
记忆:例如两个文本的 token3-重(zhong)
、token-重(chong)
,两个词的含义不一样,希望最终提取出来的词向量也不一样,因此需要讲上下文的特征合并(融入)到这个词向量这里,这个时候这个词向量就是动态的,表示上下文特征的这个信息,就是记忆。
只要涉及到序列,不管是生成序列还是输入的是序列,都需要用到 RNN这个体系(RNN、LSTM、GRU) 或者其他的序列提取结构。RNN的一些应用场景:语言模型与文本生成、机器翻译、语音识别、图像描述生成、文本相似度计算等。
例如对于视频,也是一帧一帧的图像使用 CNN 提取,然后再使用 RNN这个体系(RNN、LSTM、GRU) 或者其他的序列模型 来提取数据的序列性。基本的RNN是只依赖上文的信息。
4 全连接的理解
- 全连接底层理解1 - 从按照样本循环 和 从按照时刻循环 两个角度
- 注:这里的时刻 是 序列样本的前后关系
def t0():
# token id 转 词向量
embed_layer = nn.Embedding(num_embeddings=100, embedding_dim=5)
fc_layer = nn.Linear(5, 6, bias=False)
# 原始输入 --> 原始输入一般输入的是token id 列表,[N,T] 表示N个文本,每个文本有T个token
x = torch.randint(100, size=(4, 16))
print(x.shape)
x = embed_layer(x) # [N,T] --> [N,T,E]
print(x.shape)
x2 = fc_layer(x) # dot([N,T,E],[E,E2]) --> [N,T,E2]
print(x2.shape)
print(x2[0][:3])
N, T, E = x.shape
""" 按照样本循环 """
for i in range(N):
# x[i] -- 单独的一个样本,[T,E]
# fc_layer.weight.T -- 权重w, [E,E2]
xi = torch.matmul(x[i], fc_layer.weight.T) # [T:E2]
print(xi[:3])
break
""" 按照时刻循环 """
for j in range(T):
# x[:, j, :] -- 单独的一个时刻的N个样本,[N,E]
# fc_layer.weight.T -- 权重w, [E,E2]
xj = torch.matmul(x[:, j, :], fc_layer.weight.T) # [N,E2]
print(xj[0]) # 第0个样本的第j时刻的值
if j >= 2:
break
""" 结果是一样的,全连接的底层也就是 相当于每个时刻 都乘 权重w """
- 全连接底层理解2 - 从按照样本循环 和 从按照时刻循环 两个角度 验证结果跟全连接一样
def t1():
embed_layer = nn.Embedding(num_embeddings=100, embedding_dim=5)
w1 = nn.Parameter(torch.randn(5, 6))
x = torch.randint(100, size=(4, 16))
x = embed_layer(x) # [N,T] --> [N,T,E]
N, T, E = x.shape
outputs = []
""" 按照时刻循环 """
for j in range(T):
xj = torch.matmul(x[:, j, :], w1) # 所有样本的第j个时刻的特征值
if j < 2:
print(xj[0]) # 第0个样本的第j时刻的值
xj = torch.unsqueeze(xj, dim=1) # 增加维度, [N,E2] --> [N,1,E2]
outputs.append(xj)
""" 按照样本循环 """
outputs1 = []
for i in range(N):
xi = torch.matmul(x[i], w1)
xi = torch.unsqueeze(xi, dim=0) # 增加维度, [N,E2] --> [N,1,E2]
outputs1.append(xi)
outputs = torch.concat(outputs, dim=1)
print(outputs.shape)
outputs1 = torch.concat(outputs1, dim=0)
print(outputs1.shape)
outputs2 = torch.matmul(x, w1)
print(outputs2.shape)
print(torch.mean((outputs2 - outputs)))
print(torch.mean((outputs2 - outputs1)))
5 RNN
5.1 全连接安装时刻循环角度理解 RNN
# RNN 的底层逻辑理解 - 从全连接按照时刻循环来理解
def t2():
"""
RNN 的底层逻辑理解 - 从全连接按照时刻循环来理解
两个参数 w1(全连接) 和 w2(记忆信息)
RNN的每个时刻的输出 = 当前时刻的全连接输出信息(w1x) + 上一个状态的记忆信息
上一个时刻的记忆信息: 初始为0,后续每次是当前时刻的输出作为当前时刻的记忆信息
:return:
"""
embed_layer = nn.Embedding(num_embeddings=100, embedding_dim=5)
w1 = nn.Parameter(torch.randn(5, 6))
w2 = nn.Parameter(torch.randn(6, 6))
x = torch.randint(100, size=(4, 16)) # token id
x = embed_layer(x) # [N,T] --> [N,T,E]
N, T, E = x.shape
states = torch.zeros(size=(N, w1.shape[1])) # 记忆的状态信息
outputs = []
""" 按照时刻循环 """
for j in range(T):
xj = torch.matmul(x[:, j, :], w1) # 所有样本的第j个时刻的当前特征值 [N,6]
pre_states = torch.matmul(states, w2) # 上一个时刻的,每一个样本的记忆信息 [N,6]*[6,6]-->[N,6]
# 记忆可能会丢失,所以丢失的权重w2,需要学习
xj = xj + pre_states # 将当前时刻的特征和之前时刻的序列特征/记忆信息合并到一起
states = xj # 当前的输出作为当前时刻的记忆信息,供下一时刻使用
if j < 2:
print(xj[0]) # 第0个样本的第j时刻的值
xj = torch.unsqueeze(xj, dim=1)
outputs.append(xj)
outputs1 = torch.concat(outputs, dim=1)
print(outputs1.shape)
"""
上面写的逻辑中使用了两次全连接
但是实际上不需要两次全连接, 使用矩阵拼接就是一次全连接
"""
states = torch.zeros(size=(N, w1.shape[1])) # 记忆的状态信息
outputs = []
w3 = torch.concat([w1, w2], dim=0) # [5+6, 6]
for j in range(T):
xj = torch.concat([x[:, j, :], states], dim=1) # [N,5+6]
xj = torch.matmul(xj, w3) # 当前时刻的特征信息
states = xj # 当前的输入作为当前时刻的记忆信息
if j < 2:
print(xj[0]) # 第0个样本的第j个时刻的值
xj = torch.unsqueeze(xj, dim=1)
outputs.append(xj)
outputs2 = torch.concat(outputs, dim=1)
print(outputs2.shape)
print(torch.mean(outputs1 - outputs2))
5.2 RNN 结构
- 对于上图:
- 网络某一时刻的输入xtx_txt,和之前介绍的bp神经网络的输入一样,xtx_txt是一个n维向量, 不同的是递归网络的输入将是一整个序列,也就是x=[x1 ,…,xt-1 ,xt ,xt+1 ,…xT ],对于语言模型,每一个xt个x_t个xt将代表一个词向量,一整个序列就代表一句话。
- hth_tht代表时刻ttt的隐藏状态 (状态信息/记忆信息)
- oto_tot 代表时刻ttt的输出
- 输入层到隐藏层之间的权重由U表示,它将我们的原始输入进行抽象作为隐藏层的输入 。
- 隐藏层到隐藏层的权重V,它是网络的记忆控制者,负责调度记忆。
- 隐藏层到输出层的权重W,从隐藏层学习到的表示将通过它再一次抽象,并作为最终输出 ----- W 是对于整个网络的一个参数:因为是序列建模,此时提取出来的特征向量比普通的全连接提取出来的特征多包含整个序列的特征,然后对这个特征是需要进行其他的操作,这个操作的参数就是W。
- 注:所有时刻之间的参数 W、U、V 是共享的。
- hth_tht 和 W 的计算部分不属于 RNN 的结构了。
- 将序列按时间展开就可以得到RNN的结构
- xtx_txt是时间ttt处的输入
- StS_tSt是时间ttt处的“记忆”,ht=f(Uxt+Vht−1)h_t=f(Ux_t+Vh_{t-1})ht=f(Uxt+Vht−1),f可以是非线性转换函数,比如tanh等OtO_tOt是时间t处的输出,比如是预测下一个词的话,可能是sigmoid/softmax输出的属于每个候选词的概率,Ot=softmax(Wht)O_t=softmax(Wh_t)Ot=softmax(Wht)。
5.3 RNN正向传播阶段
在t=1的时刻,U,V,W都被随机初始化好,h0h_0h0通常初始化为0,然后进行如下计算:
s1=Ux1+Vh0h1=f(s1)o1=g(Wh1)
\begin{align}
& s_{1}=U x_{1}+V h_{0} \\
& h_{1}=f\left(s_{1}\right) \\
& o_{1}=g\left(W h_{1}\right)
\end{align}
s1=Ux1+Vh0h1=f(s1)o1=g(Wh1)
时间就向前推进,此时的状态h1作为时刻1的记忆状态将参与下一次的预测活动,也就是:
s2=Ux2+Vh1h2=f(s2)o2=g(Wh2)
\begin{align}
s_{2} & = U x_{2}+V h_{1} \\
h_{2} & = f\left(s_{2}\right) \\
o_{2} & = g\left(W h_{2}\right)
\end{align}
s2h2o2=Ux2+Vh1=f(s2)=g(Wh2)
以此类推,可得
st=Uxt+Vht−1ht=f(Uxt+Vht−1)ot=g(Wht)
\begin{align}
s_{t} & = U x_{t}+V h_{t-1} \\
h_{t} & = f\left(U x_{t}+V h_{t-1}\right) \\
o_{t} & = g\left(W h_{t}\right)
\end{align}
sthtot=Uxt+Vht−1=f(Uxt+Vht−1)=g(Wht)
其中 f 可以是 tanh,relu,sigmoid 等激活函数,g通常是softmax也可以是其他。
值得注意的是,我们说递归神经网络拥有记忆能力,而这种能力就是通过 V 将以往的输入状态进行总结,而作为下次输入的辅助。
可以这样理解隐藏状态:h=f(现有的输入+过去记忆总结)h=f(现有的输入+过去记忆总结)h=f(现有的输入+过去记忆总结)
5.4 RNN API 使用
# RNN API 基本使用
def t3():
"""
RNN 的API基本使用
:return:
"""
rnn = nn.RNN(
input_size=5, # 每个时刻输入的向量维度大小
hidden_size=6, # 输出维度大小(是指 记忆信息 ?)
num_layers=1, # 有多少层 RNN
nonlinearity='tanh', # 激活函数,只支持 tanh 和 relu
batch_first=True, # 输入数据的维度格式: True-[N,T,E] 或 Flase-[T,N,E]-默认
bidirectional=False # 是否是双向RNN
)
print(len(list(rnn.parameters())))
# 4 个参数,分别如下:
# weight_ih_l0---torch.Size([6, 5]) -- 输入x,输入层到隐藏层之间的权重 U
# weight_hh_l0---torch.Size([6, 6]) -- 状态/记忆信息h,隐藏层到隐藏层的权重 V
# bias_ih_l0---torch.Size([6])
# bias_hh_l0---torch.Size([6])
for name, param in list(rnn.named_parameters()):
print(f"{name}---{param.shape}")
x = torch.randn(4, 16, 5)
output, hn = rnn(x)
print(output.shape) # [N,T,hidden_size(2 if bidirectional else 1)]
print(hn.shape) # [hidden_size(2 if bidirectional else 1) * num_layers, N, hidden_size]
# rnn 返回的是一个 Tuple
# 第一个元素是 每个时刻的输出信息[N,T,hidden_size(2 if bidirectional else 1)], N、T的位置注意 batch_first 参数
# 第二个元素是 (每一层的)最后一个时刻的状态信息 hn - [hidden_size(2 if bidirectional else 1) * num_layers, N, hidden_size]
# 双向RNN; 双向RNN-相当于做了两次RNN,方向相反(正向一便,反向一遍),输出的维度就是 2*6=12 ,参数不共享,输出结果维度乘2
# 多层 RNN 理解
def t4():
"""
多层 RNN 的理解,就是连续多次 RNN
:return:
"""
rnn1 = nn.RNN(5, 6, num_layers=1, batch_first=True)
rnn2 = nn.RNN(6, 6, num_layers=1, batch_first=True)
# 等价于
rnn = nn.RNN(5, 6, num_layers=2, batch_first=True)
rnn.weight_ih_l0 = rnn1.weight_ih_l0
rnn.weight_hh_l0 = rnn1.weight_hh_l0
rnn.bias_ih_l0 = rnn1.bias_ih_l0
rnn.bias_hh_l0 = rnn1.bias_hh_l0
rnn.weight_ih_l1 = rnn2.weight_ih_l0
rnn.weight_hh_l1 = rnn2.weight_hh_l0
rnn.bias_ih_l1 = rnn2.bias_ih_l0
rnn.bias_hh_l1 = rnn2.bias_hh_l0
x = torch.randn(4, 16, 5)
z1 = rnn1(x) # [4,16,5] -> [4,16,6]
z2 = rnn2(z1[0]) # [4,16,6] -> [4,16,6]
z = rnn(x)
print(torch.mean(z[0] - z2[0])) # 结果一样
# 基于rnn的结构,使用matmul实现rnn
def t6():
"""
基于rnn的结构,使用matmul实现rnn
:return:
"""
rnn1 = nn.RNN(5, 6, num_layers=1, batch_first=True, bidirectional=False)
x = torch.randn(2, 4, 5)
n, t, e = x.shape
z1 = rnn1(x) # [2,4,5] -> [2,4,6]
# NOTE: 从rnn1中提取参数,然后基于rnn的结构,自己基于matmul进行矩阵运算
states = torch.zeros((1, n, 6)) # 初始时刻状态信息
outputs = []
# 针对每个时刻进行遍历
for j in range(t):
# 针对当前时刻的输入
z_t = torch.matmul(x[:, j, :], rnn1.weight_ih_l0.T) + rnn1.bias_ih_l0
# 针对上一个时刻的状态信息的转换
h_pt = torch.matmul(states[0], rnn1.weight_hh_l0.T) + rnn1.bias_hh_l0
# 将当前时刻的输入提取特征和上一个时刻的特征合并
zh = z_t + h_pt
# 做一个激活函数
zh = torch.tanh(zh)
# 当前时刻的最终输出以及状态信息的保存
outputs.append(torch.unsqueeze(zh, dim=1)) # 输出的特征信息
states[0] = zh # 当前时刻的状态信息保存
outputs = torch.concat(outputs, dim=1)
print(torch.mean(torch.abs(outputs - z1[0])))
print(torch.mean(torch.abs(states - z1[1])))
output_proj = nn.Linear(6, 3)
outputs = output_proj(outputs)
print(outputs.shape)
5.5 RNN反向传播阶段
bp神经网络用到的误差反向传播方法将输出层的误差总和,对各个权重的梯度∇U,∇V,∇W,求偏导数,然后利用梯度下降法更新各个权重。
对于每一时刻t 的RNN网络,网络的输出oto_tot都会产生一定误差ete_tet,误差的损失函数,可以是交叉熵也可以是平方误差等等。那么总的误差为E=∑tetE=∑_te_tE=∑tet,我们的目标就是要求取:
∇U=∂E∂U=∑t∂et∂U∇V=∂E∂V=∑t∂et∂V∇W=∂E∂W=∑t∂et∂W
\begin{align}
\nabla U & = \frac{\partial E}{\partial U} = \sum_{t} \frac{\partial e_{t}}{\partial U} \\
\nabla V & = \frac{\partial E}{\partial V} = \sum_{t} \frac{\partial e_{t}}{\partial V} \\
\nabla W & = \frac{\partial E}{\partial W} = \sum_{t} \frac{\partial e_{t}}{\partial W}
\end{align}
∇U∇V∇W=∂U∂E=t∑∂U∂et=∂V∂E=t∑∂V∂et=∂W∂E=t∑∂W∂et
对于输出ot=g(Wht)o_t=g(Wh_t)ot=g(Wht),对于任意损失函数,求取∇W∇W∇W将是简单的,我们可以直接求取每个时刻的 ∂et/∂W∂e_t / ∂W∂et/∂W,由于它不存在和之前的状态依赖,可以直接求导取得,然后简单地求和即可。对于∇W,∇U∇W,∇U∇W,∇U的计算不能直接求导,因此需要用链式求导法则。
举个对于时刻t+1t+1t+1产生的误差et+1e_{t+1}et+1,我们想计算它对于V1,V2,....,Vt,Vt+1V^1 ,V^2 ,....,V^t ,V^{t+1}V1,V2,....,Vt,Vt+1 的梯度,可以如下计算:
∇V=∂E∂V=∑t∂et∂V∂et+1∂Vt+1=∂et+1∂ht+1∂ht+1∂Vt+1∂et+1∂Vt=∂et+1∂ht+1∂ht+1∂ht∂ht∂Vt∂et+1∂Vt−1=∂et+1∂ht+1∂ht+1∂ht∂ht∂ht−1∂ht−1∂Vt−1注:实际上,V是同一个,只是用来表示一下V不同的作用位置 \begin{align} & \nabla V = \frac{\partial E}{\partial V} = \sum_{t} \frac{\partial e_{t}}{\partial V} \\\\ & \frac{\partial e_{t+1}}{\partial V^{t+1}} = \frac{\partial e_{t+1}}{\partial h_{t+1}} \frac{\partial h_{t+1}}{\partial V^{t+1}} \\ & \frac{\partial e_{t+1}}{\partial V^{t}} = \frac{\partial e_{t+1}}{\partial h_{t+1}} \frac{\partial h_{t+1}}{\partial h^{t}} \frac{\partial h_{t}}{\partial V^{t}} \\ & \frac{\partial e_{t+1}}{\partial V^{t-1}} = \frac{\partial e_{t+1}}{\partial h_{t+1}} \frac{\partial h_{t+1}}{\partial h_{t}} \frac{\partial h_{t}}{\partial h_{t-1}} \frac{\partial h_{t-1}}{\partial V^{t-1}} \\ \\ & 注:实际上,V是同一个,只是用来表示一下V不同的作用位置 \\ \end{align} ∇V=∂V∂E=t∑∂V∂et∂Vt+1∂et+1=∂ht+1∂et+1∂Vt+1∂ht+1∂Vt∂et+1=∂ht+1∂et+1∂ht∂ht+1∂Vt∂ht∂Vt−1∂et+1=∂ht+1∂et+1∂ht∂ht+1∂ht−1∂ht∂Vt−1∂ht−1注:实际上,V是同一个,只是用来表示一下V不同的作用位置
反复运用链式法则,我们可以求出每一个∇V1,∇V2,....,∇Vt,∇Vt+1∇V^1 ,∇V^2 ,....,∇V^t,∇V^{t+1}∇V1,∇V2,....,∇Vt,∇Vt+1,在 同时刻都是共享同样的参数,这样可以大大减少训练参数,和CNN的共享权重类似。对于共享参数的RNN,我们只需将上述的一系列式子抹去标签并求和,就可以得到 :
∂et∂V=∑1≤k≤t∂et∂ht∏k<i≤t∂hi∂hi−1∂+hk∂V\frac{\partial e_{t}}{\partial V}=\sum_{1 \leq k \leq t} \frac{\partial e_{t}}{\partial h_{t}} \prod_{k<i \leq t} \frac{\partial h_{i}}{\partial h_{i-1}} \frac{\partial^{+} h_{k}}{\partial V}∂V∂et=1≤k≤t∑∂ht∂etk<i≤t∏∂hi−1∂hi∂V∂+hk
其中 ∂+hk∂V\frac{\partial^{+} h_{k}}{\partial V}∂V∂+hk 表示不利用链式法则直接求导,也就是假如对于函数 f(h(x))f(h(x))f(h(x)),对其直接求导结果如下:∂f(h(x))∂x=f′(h(x))\frac{∂f(h(x))}{∂x}=f′(h(x))∂x∂f(h(x))=f′(h(x)),也就是求导函数可以写成x的 表达式,也就是将h(x)看成常数了。
在Yoshua Bengio 论文中( http://proceedings.mlr.press/v28/pascanu13.pdf )证明了∥∏k<i≤t∂hi∂hi−1∥≤ηt−k\left\|\prod_{k<i \leq t} \frac{\partial h_{i}}{\partial h_{i-1}}\right\| \leq \eta^{t-k}∏k<i≤t∂hi−1∂hi≤ηt−k , 从而说明了这是梯度求导的一部分环节是 一个指数模型,当η<1时,就会出现”梯度消失“问题,而当η>1时,”梯度爆炸“也就产生了。
为了克服梯度消失的问题,LSTM和GRU模型便后续被推出了,为什么 LSTM和GRU可以克服梯度消失问题呢?由于它们都有特殊的方式存储”记忆” ,那么以前梯度比较大的”记忆”不会像简单的RNN一样马上被抹除, 因此可以一定程度上克服梯度消失问题。(问题描述:在普通RNN中对于 长序列而言,很早之前时刻输入的信息,对于当前时刻是不会产生影响的 - – 长序列信息丢失的问题 --> 长时依赖问题)
另一个简单的技巧可以用来克服梯度爆炸的问题就是gradient clipping, 也就是当你计算的梯度超过 阈值c 的或者小于 阈值−c 时候,便把此时的梯度设置成 c或−c。
下图所示是RNN的误差平面,可以看到RNN的误差平面要么非常陡峭,要么非常平坦,如果不采取任何措施,当你的参数在某一次更新之后,刚好碰到陡峭的地方,此时梯度变得非常大,那么你的参数更新也会非常大,很容易导致震荡问题。而如果你采取了gradient clipping这个技巧,那么即使你不幸碰到陡峭的地方,梯度也不会爆炸,因为梯度被限制在某个阈值c。
5.6 双向 RNN
# 双向 RNN 理解
def t5():
"""
双向 RNN 的理解,就是做了两次RNN,只不过方向相反(正向一便,反向一遍),参数不共享, 输出结维度为 hidden_size乘2
:return:
"""
rnn = nn.RNN(
input_size=5, # 每个时刻输入的向量维度大小
hidden_size=6, # 输出维度大小(是指 记忆信息 ?)
num_layers=1, # 有多少层 RNN
nonlinearity='tanh', # 激活函数,只支持 tanh 和 relu
batch_first=True, # 输入数据的维度格式: True-[N,T,E] 或 Flase-[T,N,E]-默认
bidirectional=True # 是否是双向RNN
)
print(len(list(rnn.parameters())))
# 8 个参数,分别如下:
# weight_ih_l0---torch.Size([6, 5])
# weight_hh_l0---torch.Size([6, 6])
# bias_ih_l0---torch.Size([6])
# bias_hh_l0---torch.Size([6])
# weight_ih_l0_reverse---torch.Size([6, 5])
# weight_hh_l0_reverse---torch.Size([6, 6])
# bias_ih_l0_reverse---torch.Size([6])
# bias_hh_l0_reverse---torch.Size([6])
for name, param in list(rnn.named_parameters()):
print(f"{name}---{param.shape}")
x = torch.randn(4, 16, 5)
output, hn = rnn(x)
print(output.shape) # [N,T,hidden_size(2 if bidirectional else 1)]
print(hn.shape) # [hidden_size(2 if bidirectional else 1) * num_layers, N, hidden_size]
# rnn 返回的是一个 Tuple
# 第一个元素是 每个时刻的输出信息[N,T,hidden_size(2 if bidirectional else 1)], N、T的位置注意 batch_first 参数
# 第二个元素是 (每一层的)最后一个时刻的状态信息 hn - [hidden_size(2 if bidirectional else 1) * num_layers, N, hidden_size]
Bidirectional RNN(双向RNN)假设当前 t的输出不仅仅和之前的序列有关, 并且还与之后的序列有关,例如:预测一个语句中缺失的词语那么需要根据上下文进行预测;Bidirectional RNN是一个相对简单的RNNs,由两个 RNNs上下叠加在一起组成。输出由这两个RNNs的隐藏层的状态决定。
5.7 深度双向 RNN
def t8():
rnn = nn.RNN(5, hidden_size=6, num_layers=4, batch_first=True, bidirectional=True)
for name, param in rnn.named_parameters():
print(name, param.shape)
x = torch.rand(3, 4, 5)
ho, hs = rnn(x)
print(ho.shape) # 最后一层每个时刻的输出值[n,t,2*hidden_size]
print(hs.shape) # 每一层每个方向的RNN的最后一个时刻的状态信息# [(2 if bidirectional else 1) * num_layers, N, hidden_size]
Deep Bidirectional RNN(深度双向RNN)类似Bidirectional RNN,区别在于每个每一步的输入有多层网络,这样的话该网络便具有更加强大的表达能力和学习能力,但是复杂性也提高了,同时需要训练更多的数据。
torch 中实现的 多层双向RNN,是相当于独立的两个 相反方向 的多层RNN的叠加,但是每层会将正向反向的结果合并之后再进行下一层的正向反向的计算,每层的正向反向的输出维度跟设置的 hidden_size 大小一致,最后合并的结果的维度还是 hidden_size × 2。
5.8 循环神经网络RNN-BPTT
RNN的训练和CNN/ANN训练一样,同样适用BP算法误差反向传播算法。 区别在于:RNN中的参数U、V、W是共享的,并且在随机梯度下降算法中, 每一步的输出不仅仅依赖当前步的网络,并且还需要前若干步网络的状态, 那么这种BP改版的算法叫做 Backpropagation Through Time (BPTT)
; BPTT算法和BP算法一样,在多层(多个输入时刻)训练过程中(长时依赖 – 即当前的输出和前面很长的一段序列有关,一般超过10步 ),可能产生梯度消失和梯度爆炸的问题。
BPTT和BP算法思路一样,都是求偏导,区别在于需要考虑时间对step的影响。
6 中途打个广告(嘿嘿)
-
科研之路,论文为峰。英文不佳,阅读难通?别慌!我们提供精准论文翻译,助您跨越语言障碍,深入理解文献精要。最低可至 0.1 元/篇,超高性价比之选。如有需要,欢迎添加微信:thesisTrans,开启便捷科研之旅!
-
目前仅限 arXiv 网址上收录的论文哦!
-
效果展示:
7 RNN 数据填充
- 自然语言的文本数据的长度一般情况下都是不相同的,因此就需要进行数据填充 。
- 使用最简单的填充 - 0填充;
- 此时填充位置的值也参与到模型训练了,再进行向量合并( 直接求均值、拿最后时刻等 )都会产生一定的偏差;
- 使用 mask 的方式进行填充;
- 可以解决上面的这个问题,求均值的时候,先让矩阵 × mask,再求和求均值;
- 但是如果是 双向RNN 呢?
- 就会出现问题,此时 RNN 不好做,例如:
正向 - 1 3 5 0 0 0;反向 - 0 0 0 5 3 1 此时反向是错的,而应该是 5 3 1 0 0 0
,这个问题在 RNN里面,有一种特殊的写法 - PackedSequence - 填充的序列 ,参考下面代码:
- 就会出现问题,此时 RNN 不好做,例如:
- 使用最简单的填充 - 0填充;
import torch
import torch.nn as nn
from torch.nn.utils import rnn
class Network(nn.Module):
def __init__(self):
super(Network, self).__init__()
self.embedding_layer = nn.Embedding(num_embeddings=1000, embedding_dim=8)
self.rnn = nn.RNN(input_size=8, hidden_size=4, num_layers=1, batch_first=True, bidirectional=False)
self.proj = nn.Linear(4, 2)
def forward(self, x, mask=None):
"""
前向过程
:param x: [N,T] LongTensor token id列表
:param mask: [N,T] 实际值位置为1 填充值位置为0
:return: 置信度 [N,2]
"""
# 1. embedding的向量提取
z1 = self.embedding_layer(x) # [N,T] --> [N,T,embedding_dim]
# 2. rnn的特征提取
if mask is None:
output, _ = self.rnn(z1) # [N,T,embedding_dim] --> [N,T,hidden_size]
else:
lengths = torch.sum(mask, dim=1).to(torch.long) # 计算每个序列的长度
# pack_padded_sequence: 从z1中仅提取有效数据
z2: rnn.PackedSequence = rnn.pack_padded_sequence(
z1,
lengths,
batch_first=True,
enforce_sorted=False
) # [N,T,E], 对于T中有填充, 此时的z2保存了当前批次中所有的实际存在的 sumT=t1+t2+...+tt 个tensor数据(词向量)
z2, _ = self.rnn(z2)
output, _ = rnn.pad_packed_sequence(z2, batch_first=True) # 解包,得到与原始输入相同形状的输出张量
# 3. 将T个时刻的向量合并成一个向量
"""
期望:用一个向量来表示整个文本的特征信息,并不需要针对每个时刻的token进行特征向量描述
--> 如何提取一个向量,并且这个向量可以体现当前文本?
方式一: 直接提取最后一个时刻的输出特征向量: output = output[:,-1,:]
方式二: 直接将所有时刻的特征向量求均值:
output = torch.mean(output,dim=1) # [N,T,hidden_size] -> [N,hidden_size]
# 将output里面的值看成每个时刻的输出值:hi
output = alpha_1*h_1 + alpha_2*h_2 + alpha_3*h_3 + ... + alpha_t*h_t --> 实际上就是 Attention
实际上torch.mean操作中,alpha_i = 1/t, t == T
"""
output = torch.mean(output, dim=1) # [N,T,hidden_size] -> [N,hidden_size]
# 4. 决策输出
scores = self.proj(output)
return scores
def t0():
"""
- 自然语言的文本数据的长度一般情况下都是不相同的,因此就需要进行<font color="#92d050">数据填充 </font>。
- 使用最简单的填充 - 0填充;
- 此时填充位置的值也参与到模型训练了,再进行向量合并( 直接求均值、拿最后时刻等 )都会产生一定的偏差;
- 使用 mask 的方式进行填充;
- 可以解决上面的这个问题,求均值的时候,先让矩阵 × mask,再求和求均值;
- 但是如果是 双向RNN 呢?
- 就会出现问题,此时 RNN 不好做,例如:`正向 - 1 3 5 0 0 0;反向 - 0 0 0 5 3 1 此时反向是错的,而应该是 5 3 1 0 0 0`,
- 这个问题在 RNN里面,有一种特殊的写法 - PackedSequence - 填充的序列
:return:
"""
net = Network()
_x = torch.tensor([
[2, 3, 5, 4, 6, 2], # 样本2实际长度为6,填充后大小为6
[2, 3, 5, 65, 0, 0], # 样本3实际长度为4,填充后大小为6
[1, 3, 5, 0, 0, 0] # 样本1实际长度为3,填充后大小为6
])
_mask = torch.tensor([
[1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
[1.0, 1.0, 1.0, 1.0, 0.0, 0.0],
[1.0, 1.0, 1.0, 0.0, 0.0, 0.0]
])
_r = net(_x, _mask)
print(_r)
print(_r.shape)
if __name__ == '__main__':
t0()
8 总结
- 注:RNN 体系算法最大的问题:速度慢
- RNN 叫做循环神经网络,必须是一个时刻一个时刻遍历,如果这个 t 特别大的时候,它的循环次数很多,它就很慢,而且不能通过堆硬件(加资源)的方式来提升速度,每次循环还是只能执行一次全连接 - 但是 卷积和全连接 加资源有意义,大模型的本质上就是全连接,就可以通过堆资源的方式提升速度 - 因此很多情况下,要考虑是否能够使用RNN。
- RNN 在提取长序列的时候作用还是很大的。
图1:一对一 -- 图像、不考虑序列的文本 -- 分类模型
图2:一对多 -- 图4的特殊
图3:多对一 -- 图4的特殊
图4:多对多1 -- 并不一定是一一对应 -- 编码器-解码器结构 -- 自编码神经网络/生成模型/Seq2Seq
图5:多对多2 -- 一个token对应一个输出值 -- 几个输入几个输出 -- 中文分词、命名实体识别 -- 叫作序列模型