• 问答
  • 技术
  • 实践
  • 资源
Pytorch 中 RNN 模型的及变长序列的处理
技术讨论

Pytorch中RNN模型的及变长序列的处理

作者 | 小新
来源 | https://lhyxx.top/
编辑 | 极市平台
本文仅作学术分享,版权归原作者所有,侵删。

对于RNN模型的原理说明,已经是非常熟悉了,网上也有很多详细的讲解文章。本文就不赘述RNN模型的数学原理了,而是从实际代码实现与使用的角度来阐述。毕竟,“原理我都懂了,但是就是不会用”,应该是很多同学的通病。理论和实际应用还是有一定的gap的。本文则通过总结最近对pytorch中RNN模型的使用,来从如何编码使用RNN模型的角度,来着力于提升实际动手操作的能力。

本文主要内容:

  • RNN模型(包括GRU以及LSTM)的使用说明
  • 处理变长序列的方法以及一些小技巧
  • pack_padded_sequence 与 pad_packed_sequence
  • 使用RNN进行文本处理的基本步骤

RNN模型(包括GRU以及LSTM)的使用说明

torch.nn.RNN

RNN原理

基本RNN计算公式如下:

$$
\mathrm{h}{\mathrm{t}}=\tanh \left(\mathrm{W}{\mathrm{ih}} \mathrm{x}{\mathrm{t}}+\mathrm{b}{\mathrm{ih}}+\mathrm{W}{\mathrm{hh}} \mathrm{h}{(\mathrm{t}-1)}+\mathrm{b}_{\mathrm{hh}}\right)
$$

其中 $\mathrm{h}{\mathrm{t}}$ 是时刻 $t$ 的隐状态 (hidden state) ,$\mathrm{x}{\mathrm{t}}$ 是 $t$ 时刻的输入,$\mathrm{h}_{(t-1)}$ 是 $t-1$ 时刻的状态。

因此,简单来说一句话:RNN就是根据当前时刻的输入和上一时 刻的状态求当前时刻的状态,就可以简化成一个函数: $\mathrm{h}{i}=f\left(\mathrm{x}{i}, \mathrm{~h}_{i-1}\right)$ 。

参数说明

  • 模型参数

    • input_size:输入向量维度
    • hidden_size:隐层状态维度
    • num_layers:RNN层数
    • nonlinearity:使用哪种非线性激活函数。[tanh, relu],Default:tanh
    • bias:bool, 如果为False,则不使用偏置项。Default: True
    • batch_first: bool, 如果为True,则输入形状为(batch, seq, feature)。默认为False,因此RNN的默认输入形状为(seq, batch, feature)
    • dropout:float, 指定dropout率,Default: 0
    • bidirectional:是否为双向。Default:False
  • 输入说明

    • input:Tensor,形状(seq_len, batch, input_size)。或者是使用torch.nn.utils.rnn.pack_padded_sequence()进行pack过的对象。
    • h_0: (num_layers * num_directions, batch, hidden_size)。如果RNN初始状态没有指定,则默认为全零张量。如果bidirectional为True,则num_directions=2,否则为1。
  • 输出说明

    • output:(seq_len, batch, num_directions * hidden_size)。输出最后一层每个step的隐层特征。如果输入是 torch.nn.utils.rnn.PackedSequence对象,则输出也是经过packed的对象,需要使用 torch.nn.utils.rnn.pack_sequence() 给变回Tensor。如果指定batch_first=True,则输出形状为(batch, seq_len, num_directions * hidden_size)。
    • h_n: (num_layers * num_directions, batch, hidden_size)。输出每一层最后一个step的隐层特征
  1. 对于RNN模型的输出output,可以使用output.view(seq_len, batch, num_directions, hidden_size)来分离方向维度,第0维是前向,第1维是反向。
    对于RNN模型的隐层状态h_n,可以使用h_n.view(num_layers, num_directions, batch, hidden_size)来分离层数维度和方向维度。
  2. 对于双向RNN来说,前向传播中,最后一个step是最后时刻的输出,即output[-1, :, :]。而对于反向传播中,第0个step是最后时刻的状态,即output[0, :, :]

RNN用例

rnn = nn.RNN(10, 20, 2)
input = torch.randn(5, 3, 10)
h0 = torch.randn(2, 3, 20)
output, hn = rnn(input, h0)

torch.nn.GRU

GRU原理

GRU计算公式如下:

$$
\begin{aligned}
r{t} &=\sigma\left(W{i r} x{t}+b{i r}+W{h r} h{(t-1)}+b{h r}\right) \
z
{t} &=\sigma\left(W{i z} x{t}+b{i z}+W{h z} h{(t-1)}+b{h z}\right) \
n{t} &=\tanh \left(W{i n} x{t}+b{i n}+r{t} *\left(W{h n} h{(t-1)}+b{h n}\right)\right) \
h{t} &=\left(1-z{t}\right) n{t}+z{t} h_{(t-1)}
\end{aligned}
$$

GRU中增加了两个门控装置,分别是reset 和 update 门,分别对应 $r{t},z{t}$ 。 $n{t}$ 则是经过门控之前的下一时刻的状态,然后将下一时刻的状态 $n{t}$ 与上一时刻的状态 $h{t-1}$ 通过reset门和update门进行加劝分配得到最终的下一时刻的状态 $h{t}$ 。

公式中 * 表示Hadamard积,$sigma$ 表示sigmoid函数。 根据公式,我们可以将三个门控分别看作三个函数:

$$
r{t}=f\left(x{t}, h{t-1}\right)
$$
$$
\begin{gathered}
z
{t}=f\left(x{t}, h{t-1}\right) \
n{t}=f\left(x{t}, h{t-1}, r{t}\right)
\end{gathered}
$$

至于这些函数应该如何实现和设计,就都是使用神经网络自己去根据数据学习拟合的了,在训练的过程中不断调整函数中的参数,从而最终学到合适的函数。这也是神经网络的强大之处,人们只需要指定变量之间的关系,至于他们到底有什么关系,就交给神经网络自己去根据数据拟合了,只要有足够大规模的训练数据即可。

参数说明

GRU模型与RNN在使用上可以说完全一致。基本参数可以参见RNN部分的参数说明。

  • 模型参数

    • input_size
    • hidden_size
    • num_layers
    • bias
    • batch_first
    • dropout
    • bidirectional
  • 输入参数

    • input : (seq_len, batch, input_size)
    • h_0 : (num_layers * num_directions, batch, hidden_size)
  • 输出参数

    • output : (seq_len, batch, num_directions * hidden_size)
    • h_n :(num_layers * num_directions, batch, hidden_size)

GRU用例

rnn = nn.RNN(10, 20, 2)
input = torch.randn(5, 3, 10)
h0 = torch.randn(2, 3, 20)
output, hn = rnn(input, h0)

torch.nn.LSTM

LSTM原理

LSTM计算公式如下:

$$
\begin{aligned}
i{t} &=\sigma\left(W{i i} x{t}+b{i i}+W{h i} h{t-1}+b{h i}\right) \
f
{t} &=\sigma\left(W{i f} x{t}+b{i f}+W{h f} h{t-1}+b{h f}\right) \
o{t} &=\sigma\left(W{i o} x{t}+b{i o}+W{h o} h{t-1}+b{h o}\right) \
g
{t} &=\tanh \left(W{i g} x{t}+b{i g}+W{h g} h{t-1}+b{h g}\right) \
c{t} &=f{t} \odot c{t-1}+i{t} \odot g{t} \
h
{t} &=o{t} \odot \tanh \left(c{t}\right)
\end{aligned}
$$

LSTM中增加了三个门控装置, 分别是 input, forget, output 门, 分别对应 $i{t}$,$f{t}$, $o_{t}$。公式中 $\odot$ 表示Hadamard积,$\sigma$ 表示sigmoid 函数。

根据公式,我们可以将三个门控分别看作三个函数:

$$
r{t}=f\left(x{t}, h_{t-1}\right)
$$

$$
\begin{gathered}
z{t}=f\left(x{t}, h{t-1}\right) \
n
{t}=f\left(x{t}, h{t-1}, r_{t}\right)
\end{gathered}
$$

也就是说, 三个门控装置都是根据输入 $x$ 和上一时刻的隐状态 $h{t-1}$ 决定的。 $g{t}$ 是不经过门控时的下一时刻的状态。得到三个门控信号以及不经门控时的下一时刻的状态 $g{t}$ 后,更新cell状态,也就是上一时刻的cell状态经过forget门来控制遗忘部分内容,下一时刻的状态经过输入门控制输入部分内容,共同得到下一时刻的cell状态。最后cell状态经过output门控制输出部分内容,从而输出 $h{t}$ 。

参数说明

LSTM模型的基本参数与RNN和GRU相同。稍微有些不同的地方在于模型的输入与输出。LSTM模型除了每个step的隐状态 $h{t}$ 之外,还有每个step的cell状态 $c{t}$。cell状态与输出的hidden状态上面公式已经解释了,也就是cell的状态并没有直接输出,而是通过一个输出门来控制输出哪些内容。

  • 模型参数

    • input_size
    • hidden_size
    • num_layers
    • bias
    • batch_first
    • dropout
    • bidirectional
  • 输入参数

    • input : (seq_len, batch, input_size)
    • h_0 : (num_layers * num_directions, batch, hidden_size),初始状态。若不提供则默认为全零。
    • c_0 : (num_layers * num_directions, batch, hidden_size),初始cell状态。若不提供则默认全零。
  • 输出参数

    • output : (seq_len, batch, num_directions * hidden_size),最后一层每个step的输出特征。
    • h_n :(num_layers * num_directions, batch, hidden_size),每一层的最后一个step的输出特征。
    • c_n :(num_layers * num_directions, batch, hidden_size),每一层的最后一个step的cell状态。

LSTM用例

rnn = nn.LSTM(10, 20, 2)
input = torch.randn(5, 3, 10)
h0 = torch.randn(2, 3, 20)
c0 = torch.randn(2, 3, 20)
output, (hn, cn) = rnn(input, (h0, c0))

处理变长序列

在处理文本数据时,通常一个batch中的句子长度都不一样。
例如如下几个句子

[it is a lovely day]
[i love chinese food]
[i love music]

对应词典
{pad:0, it:1, is:2, a:3, lovely:4, day:5, i:6, love:7, chinese:8, food:9, music:10}

而pytorch中Tensor一定是所有向量的维度都相同的。因此在处理变长序列时,需要进行以下步骤:

首先,将所有文本padding成固定长度。

[1, 2, 3, 4, 5]
[6, 7, 8, 9, 0]
[6, 7, 10, 0, 0]

然后将单词转化为one_hot的形式,变为(batch,seq_len,vocabsize)形状的Tensor。

到这里,所有句子都padding成了相同的长度,但是现在还不能直接送到RNN中,因为句子中有大量的0(PAD),这些PAD也送入RNN中的话,也会影响对句子的计算过程。为了排除这些PAD的影响,pytorch提供了两个函数torch.nn.utils.rnn.pack_padded_sequencetorch.nn.utils.rnn.pad_packed_sequence。下面介绍一下这两个函数的使用。

pack and pad

torch.nn.utils.rnn.pack_padded_sequence(input, lengths, batch_first=False, enforce_sorted=True)

  • 参数
    • input: Tensor, (seq_len, batch, *),*表示可以是任意维度,如果是one-hot表示,则是vocabsize维度,如果是其他embedding表示,则是对应embedding的维度。这里输入的是padding之后得到的Tensor,因此seq_len都是固定长度的。
    • lengths: Tensor, batch中每个句子的真实长度。
    • batch_first: 第一维度是否是batch
    • enforce_sorted

torch.nn.utils.rnn.pad_packed_sequence(sequence, batch_first=False, padding_value=0.0, total_length=None)

  • 参数
    • sequence:batch to pad
    • batch_first: if True, the output will be in B x T x * format.
    • padding_value:values for padded elements.
    • total_length: if not None, the output will be padded to have length total_length

在实际使用中,pack_padded_sequence函数将padding之后的Tensor作为输入,pack_padded_sequence输出一个PackedSequence对象,其中Tensor中padding的部分都去掉了,也就是只保留了序列的真实长度。

然后经过RNN模型或者双向RNN模型,得到输出。

之后再利用pad_packed_sequence函数将RNN的输出结果变回来。

一个使用示例如下:

import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.nn.utils.rnn import pack_padded_sequence, pad_packed_sequence
import numpy as np

# padding函数
x: [[w1, w2,...], [w1, w2, ...], ...]
def padding(x):
    maxlen = max([len(l) for l in x])
    for sen in x:
        if len(sen) < maxlen:
            sen.extend([0] * (maxlen-len(sen)))
    return x

# 句子单词映射到onehot表示
# V: 单词表, padded_tokens:padding之后的输入
def id2onehot(V, padded_tokens):
    onehot = np.eye((len(V)))
    embeddings = []
    for sen in padded_tokens:
        sen_embdding = []
        for i,tokenid in enumerate(sen):
            sen_embdding.append(onehot[tokenid].tolist())
        embeddings.append(sen_embdding)
    # print(embeddings)
    return torch.FloatTensor(embeddings)

V = {'PAD':0, 'a':1, 'b':2, 'c':3, 'd':4}
sentences = ['abcd', 'd', 'acb']
sen_lens = [len(x) for x in sentences]

tokens = []
for sen in sentences:
    token = []
    for c in sen:
        token.append(V[c])
    tokens.append(token)

padded_tokens = padding(tokens)
X = id2onehot(V, padded_tokens)
# print(X)

torch.random.manual_seed(10)
# 定义一个双向lstm网络层
lstm = nn.LSTM(5, 3, num_layers=1, batch_first=True, bidirectional=True)  

X = X.float()
# 压紧数据,去掉padding部分
packed = pack_padded_sequence(X, torch.tensor(sen_lens), batch_first=True, enforce_sorted=False)
print(packed)

# 通过lstm进行计算,得到的结果也是压紧的
output, hidden = lstm(packed)

# 解压,恢复成带padding的形式
encoder_outputs, lenghts = pad_packed_sequence(output, batch_first=True)  
print(encoder_outputs)

上面的例子中,输入的句子长度是没有经过排序的,输入到pack_padded_sequence函数的输入句子并不是按照长度排序的。网上很多教程都说必须要将输入句子按照长度进行排序,然后输入到pack_padded_sequence中,之后再把顺序变回来。

但是我看pytorch官方手册里,pack_padded_sequence函数实际上有一个参数enforce_sorted的,该参数默认是True。如果该参数为True,则输入的句子应该按照长度顺序排序。而如果是False的话,实际上输入句子不排序也可以。那么什么情况下需要将该参数设为True呢?手册上是这么写的:

For unsorted sequences, use enforce_sorted = False. If enforce_sorted is True, the sequences should be sorted by length in a decreasing order, i.e. input[:,0] should be the longest sequence, and input[:,B-1] the shortest one. enforce_sorted = True is only necessary for ONNX export.

即只有使用ONNX export时,必须将该参数设为True。也就是说,平常使用的时候,不是必须设为True的,我们将该参数设为False,就可以直接输入无序的句子了,不用再手动对其排序,之后再变回来了。这样省事多了。

  • 0
  • 0
  • 97
收藏
暂无评论