想要入门一项新技术,最快的方法就是写一个”Hello World”程序。入门CNN,大家一般会写一个简单的图片分类项目。可是,RNN的入门项目就比较少见了。自然语言处理任务要求的数据量都比较大,不是那么好设计一个入门项目。
在这篇文章中,我将展示一个入门级的RNN项目——字母级语言模型。这个项目的逻辑比较简单,要求的数据量不大,几分钟就可以训练完,非常适合新手入门。
这个项目使用的框架是PyTorch。首先,我会抛弃PyTorch的高级组件,仅使用线性层、自动求导机制来从头实现一个简单的RNN。之后,我还会用PyTorch的高级组件搭一个更通用的RNN。相信通过阅读这篇教程,大家不仅能够理解RNN的底层原理,还能够学到PyTorch中RNN组件的用法,能够自己搭建出各种各样的NLP任务模型。
知识背景
详细的知识介绍可以参考我的上篇文章:循环神经网络基础。
RNN
RNN 适用于处理序列数据。令$x^{< i >}$是序列的第$i$个元素,那么$x^{< 1 >} x^{< 2 >}…x^{< T_x >}$就是一个长度为$T_x$的序列。NLP中最常见的元素是单词,对应的序列是句子。
RNN使用同一个神经网络处理序列中的每一个元素。同时,为了表示序列的先后关系,RNN还有表示记忆的隐变量$a$,它记录了前几个元素的信息。对第$t$个元素的运算如下:
其中,$W, b$都是线性运算的参数,$g$是激活函数。隐藏层的激活函数一般用tanh,输出层的激活函数根据实际情况选用。另外,$a$得有一个初始值$a^{< 1 >}$,一般令$a^{< 1 >}=\vec0$。
语言模型
语言模型是NLP中的一个基础任务。假设我们以单词为基本元素,句子为序列,那么一个语言模型能够输出某句话的出现概率。通过比较不同句子的出现概率,我们能够开发出很多应用。比如在英语里,同音的”apple and pear”比”apple and pair”的出现概率高(更可能是一个合理的句子)。当一个语音识别软件听到这句话时,可以分别写下这两句发音相近的句子,再根据语言模型断定这句话应该写成前者。
规范地说,对于序列$x^{< 1 >}…x^{< T_x >}$,语言模型的输出是$P(x^{< 1 >},…, x^{< T_x >})$。这个式子也可以写成$P(x^{< 1 >}) \times P(x^{< 2 >} |x^{< 1 >}) \times P(x^{< 3 >} |x^{< 1 >}, x^{< 2 >}) … \times P(x^{< T_x >} |x^{< 1 >}, x^{< 2 >}, …, x^{< T_x-1 >})$,即一句话的出现概率,等于第一个单词出现在句首的概率,乘上第二个单词在第一个单词之后的概率,乘上第三个单词再第一、二个单词之后的概率,这样一直乘下去。
单词级的语言模型需要的数据量比较大,在这个项目中,我们将搭建一个字母级语言模型。即我们以字母为基本元素,单词为序列。语言模型会输出每个单词的概率。比如我们输入”apple”和”appll”,语言模型会告诉我们单词”apple”的概率更高,这个单词更可能是一个正确的英文单词。
RNN 语言模型
为了计算语言模型的概率,我们可以用RNN分别输出$P(x^{< 1 >})$, $P(x^{< 2 >} |x^{< 1 >})$, …,最后把这些概率乘起来。
$P(x^{< t >} |x^{< 1 >}, x^{< 2 >}, …, x^{< t-1 >})$这个式子,说白了就是给定前$t-1$个字母,猜一猜第$t$个字母最可能是哪个。比如给定了前四个字母”appl”,第五个单词构成”apply”, “apple”的概率比较大,构成”appll”, “appla”的概率较小。
为了让神经网络学会这个概率,我们可以令RNN的输入为<sos> x_1, x_2, ..., x_T
,RNN的标签为x_1, x_2, ..., x_T, <eos>
(<sos>
和<eos>
是句子开始和结束的特殊字符,实际实现中可以都用空格' '
表示。<sos>
也可以粗暴地用全零向量表示),即输入和标签都是同一个单词,只是它们的位置差了一格。模型每次要输出一个softmax的多分类概率,预测给定前几个字母时下一个字母的概率。这样,这个模型就能学习到前面那个条件概率了。
代码讲解
项目地址:https://github.com/SingleZombie/DL-Demos/tree/master/dldemos/BasicRNN。
数据集获取
为了搭建字母级语言模型,我们只需要随便找一个有很多单词的数据集。这里我选择了斯坦福大学的大型电影数据集,它收录了IMDb上的电影评论,正面评论和负面评论各25000条。这个数据集本来是用于情感分类这一比较简单的NLP任务,拿来搭字母级语言模型肯定是没问题的。
这个数据集的文件结构大致如下:
1 | ├─test |
其中,imdb.vocab
记录了数据集中的所有单词,一行一个。test
和train
是测试集和训练集,它们的neg
和pos
子文件夹分别记录了负面评论和正面评论。每一条评论都是一句话,存在一个txt文件里。
训练字母级语言模型时,直接拿词汇表来训练也行,从评论中截取一个个单词也行。我已经写好了这些读取数据集的代码,在dldemos/BasicRNN/read_imdb.py
文件中。
在读取单词时,我们只需要26个字母和空格这一共27个字符。其他的字符全可以过滤掉。为了方便,我使用了正则表达式过滤出这27个字符:
1 | words = re.sub(u'([^\u0020\u0061-\u007a])', '', words) |
这样,一个读取词汇表文件的函数就长这样:
1 | def read_imdb_vocab(dir='data/aclImdb'): |
我写好了读取词汇表的函数read_imdb_vocab
和read_imdb_words
,它们都会返回一个单词的列表。我还写了一个读数据集整个句子的函数read_imdb
。它们的用法和输出如下:
1 | def main(): |
1 | the |
数据集读取
RNN的输入不是字母,而是表示字母的向量。最简单的字母表示方式是one-hot编码,每一个字母用一个某一维度为1,其他维度为0的向量表示。比如我有a, b, c三个字母,它们的one-hot编码分别为:
1 | a: [1, 0, 0] |
现在,我们只有单词数组。我们要把每个单词转换成这种one-hot编码的形式。
在转换之前,我准备了一些常量(dldemos/BasicRNN/constant.py
):
1 | EMBEDDING_LENGTH = 27 |
我们一共有27个字符,0号字符是空格,剩余字母按照字母表顺序排列。LETTER_MAP
和ENCODING_MAP
分别完成了字母到数字的正向和反向映射。LETTER_LIST
是所有字母的列表。
PyTorch提供了用于管理数据集读取的Dataset类。Dataset一般只会存储获取数据的信息,而非原始数据,比如存储图片路径。而每次读取时,Dataset才会去实际读取数据。在这个项目里,我们用Dataset存储原始的单词数组,实际读取时,每次返回一个one-hot编码的向量。
使用Dataset时,要继承这个类,实现__len__
和__getitem__
方法。前者表示获取数据集的长度,后者表示获取某项数据。我们的单词数据集WordDataset
应该这样写(dldemos/BasicRNN/main.py
):
1 | import torch |
构造数据集的参数是words, max_length, is_onehot
。words
是单词数组。max_length
表示单词的最大长度。在训练时,我们一般要传入一个batch的单词。可是,单词有长有短,我们不可能拿一个动态长度的数组去表示单词。为了统一地表达所有单词,我们可以记录单词的最大长度,把较短的单词填充空字符,直到最大长度。is_onehot
表示是不是one-hot编码,我设计的这个数据集既能输出用数字标签表示的单词(比如abc表示成[0, 1, 2]
),也能输出one-hoe编码表示的单词(比如abc表示成[[1, 0, 0], [0, 1, 0], [0, 0, 1]]
)。
1 | def __init__(self, words, max_length, is_onehot=True) |
在获取数据集时,我们要根据是不是one-hot编码,先准备好一个全是0的输出张量。如果存的是one-hot编码,张量的形状是[MAX_LENGTH, EMBEDDING_LENGTH]
,第一维是单词的最大长度,第二维是one-hot编码的长度。而如果是普通的标签数组,则张量的形状是[MAX_LENGTH]
。准备好张量后,遍历每一个位置,令one-hot编码的对应位为1,或者填入数字标签。
另外,我们用空格表示单词的结束。要在处理前给单词加一个' '
,保证哪怕最长的单词也会至少有一个空格。
1 | def __getitem__(self, index): |
注意!短单词的填充部分应该全是空字符。千万不要忘记给空字符的one-hot编码赋值。
1 | for i in range(self.max_length): |
有了数据集类,结合之前写好的数据集获取函数,可以搭建一个DataLoader。DataLoader是PyTorch提供的数据读取类,它可以方便地从Dataset的子类里读取一个batch的数据,或者以更高级的方式取数据(比如随机取数据)。
1 | def get_dataloader_and_max_length(limit_length=None, |
这个函数会先调用之前编写的数据读取API获取单词数组。之后,函数会计算最长的单词长度。这里,我用limit_length
过滤了过长的单词。据实验,这个数据集里最长的单词竟然有60多个字母,把短单词填充至60需要浪费大量的计算资源。因此,我设置了limit_length
这个参数,不去读取那些过长的单词。
计算完最大长度后,别忘了+1,保证每个单词后面都有一个表示单词结束的空格。
最后,用DataLoader(dataset, batch_size=256)
就可以得到一个DataLoader。batch_size
就是指定batch size的参数。我们这个神经网络很小,输入数据也很小,可以选一个很大的batch size加速训练。
模型定义
模型的初始化函数和训练函数定义如下(dldemos/BasicRNN/models.py
):
1 | import numpy as np |
我们来一点一点地看看这个模型是怎么搭起来的。
回忆一下RNN的公式:
我们可以把第一行公式里的两个$W$合并一下,$x, a$拼接一下。这样,只需要两个线性层就可以描述RNN了。
因此,在初始化函数中,我们定义两个线性层linear_a
,linear_y
。另外,hidden_units
表示隐藏层linear_a
的神经元数目。tanh
就是普通的tanh函数,它用作第一层的激活函数。
linear_a
就是公式的第一行,由于我们把输入x
和状态a
拼接起来了,这一层的输入通道数是hidden_units + EMBEDDING_LENGTH
,输出通道数是hidden_units
。第二层linear_y
表示公式的第二行。我们希望RNN能预测下一个字母的出现概率,因此这一层的输出通道数是EMBEDDING_LENGTH=27
,即字符个数。
1 | def __init__(self, hidden_units=32): |
在描述模型运行的forward
函数中,我们先准备好输出张量,再初始化好隐变量a
和第一轮的输入x
。根据公式,循环遍历序列的每一个字母,用a, x
计算hat_y
,并维护每一轮的a, x
。最后,所有hat_y
拼接成的output
就是返回结果。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21def forward(self, word: torch.Tensor):
# word shape: [batch, max_word_length, embedding_length]
batch, Tx = word.shape[0:2]
# word shape: [max_word_length, batch, embedding_length]
word = torch.transpose(word, 0, 1)
# output shape: [max_word_length, batch, embedding_length]
output = torch.empty_like(word)
a = torch.zeros(batch, self.hidden_units, device=word.device)
x = torch.zeros(batch, EMBEDDING_LENGTH, device=word.device)
for i in range(Tx):
next_a = self.tanh(self.linear_a(torch.cat((a, x), 1)))
hat_y = self.linear_y(next_a)
output[i] = hat_y
x = word[i]
a = next_a
# output shape: [batch, max_word_length, embedding_length]
return torch.transpose(output, 0, 1)
我们来看一看这个函数的细节。一开始,输入张量word
的形状是[batch数,最大单词长度,字符数=27]
。我们提前获取好形状信息。
1 | # word shape: [batch, max_word_length, embedding_length] |
我们循环遍历的其实是单词长度那一维。为了方便理解代码,我们可以把单词长度那一维转置成第一维。根据这个新的形状,我们准备好同形状的输出张量。输出张量output[i][j]
表示第j个batch的序列的第i个元素的27个字符预测结果。
1 | # word shape: [max_word_length, batch, embedding_length] |
按照前文知识准备的描述,第一轮的输入是空字符,期待的输出是句子里的第一个字母;第二轮的输入的第一个字母,期待的输出是第二个字母……。因此,我们要把输入x
初始化为空。理论上x
应该是一个空字符,其one-hot编码是[1, 0, 0, ...]
,但这里我们拿一个全0的向量表示句首也是可行的。除了初始化x
,还要初始化一个全零隐变量a
。
1 | a = torch.zeros(batch, self.hidden_units, device=word.device) |
之后,按照顺序遍历每一个元素,计算y_hat
并维护a, x
。最后输出结果前别忘了把转置过的维度复原回去。
1 | for i in range(Tx): |
从逻辑上讲,模型应该输出softmax的结果。但是,PyTorch的CrossEntropyLoss
已经包含了softmax的计算,我们不用在模型里加softmax。
训练
main函数中完整的训练代码如下(dldemos/BasicRNN/models.py
):
1 | def train_rnn1(): |
首先,调用之前编写的函数,准备好dataloader
和model
。同时,准备好优化器optimizer
和损失函数citerion
。优化器和损失函数按照常见配置选择即可。
1 | device = 'cuda:0' |
这个语言模型一下就能训练完,做5个epoch就差不多了。每一代训练中,
先调用模型求出hat_y
,再调用损失函数citerion
,最后反向传播并优化模型参数。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22for epoch in range(5):
loss_sum = 0
dataset_len = len(dataloader.dataset)
for y in dataloader:
y = y.to(device)
hat_y = model(y)
n, Tx, _ = hat_y.shape
hat_y = torch.reshape(hat_y, (n * Tx, -1))
y = torch.reshape(y, (n * Tx, -1))
label_y = torch.argmax(y, 1)
loss = citerion(hat_y, label_y)
optimizer.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 0.5)
optimizer.step()
loss_sum += loss
print(f'Epoch {epoch}. loss: {loss_sum / dataset_len}')
算损失函数前需要预处理一下数据,交叉熵损失函数默认hat_y
的维度是[batch数,类型数]
,label_y
是一个一维整形标签数组。而模型的输出形状是[batch数,最大单词长度,字符数]
,我们要把前两个维度融合在一起。另外,我们并没有提前准备好label_y
,需要调用argmax
把one-hot编码转换回标签。
1 | hat_y = model(y) |
之后就是调用PyTorch的自动求导功能。注意,为了防止RNN梯度过大,我们可以用clip_grad_norm_
截取梯度的最大值。
1 | optimizer.zero_grad() |
我还顺带输出了每一代的loss。当然这里我偷了个懒,这个loss并不能表示每一个样本的平均loss。不过,我们能通过这个loss得到模型的训练进度,这就够了。
1 | print(f'Epoch {epoch}. loss: {loss_sum / dataset_len}') |
测试
我们可以手动为字母级语言模型写几个测试用例,看看每一个单词的概率是否和期望的一样。我的测试单词列表是:
1 | test_words = [ |
我构筑了几组长度一样,但是最后几个字母不太一样的“单词”。通过观察这些词的概率,我们能够验证语言模型的正确性。理论上来说,英文里的正确单词的概率会更高。
我们的模型只能输出每一个单词的softmax前结果。我们还要为模型另写一个求语言模型概率的函数。
1 |
|
这个函数和forward
大致相同。只不过,这次我们的输出output
要表示每一个单词的概率。因此,它被初始化成一个全1的向量。
1 | # output shape: [batch] |
每轮算完最后一层的输出后,我们手动调用F.softmax
得到softmax的概率值。
1 | tmp = self.linear_y(next_a) |
接下来,我们要根据每一个batch当前位置的单词,去hat_y
里取出需要的概率。比如第2个batch当前的字母是b
,我们就要取出hat_y[2][2]
。
第i
轮所有batch的字母可以用word_label[i]
表示。根据这个信息,我们可以用probs = hat_y[torch.arange(batch), word_label[i]]
神奇地从hat_y
里取出每一个batch里word_label[i]
处的概率。把这个概率乘到output
上就算完成了一轮计算。
有了语言模型函数,我们可以测试一下开始那些单词的概率。
1 | def test_language_model(model, is_onehot=True, device='cuda:0'): |
1 | apple: 9.39846032110836e-08 |
通过观察每一组用例,我们能发现,apple, apply, bear, beer
这些正确的单词的概率确实会高一些。这个语言模型训练得不错。有趣的是,caq
这种英语里几乎不存在的字母组合的概率也偏低。当然,语言模型对难一点的单词的判断就不太准了。queen
和queue
的出现概率就比较低。
采样单词
语言模型有一个很好玩的应用:我们可以根据语言模型输出的概率分布,采样出下一个单词;输入这一个单词,再采样下一个单词。这样一直采样,直到采样出空格为止。使用这种采样算法,我们能够让模型自动生成单词,甚至是英文里不存在,却看上去很像那么回事的单词。
我们要为模型编写一个新的方法sample_word
,采样出一个最大长度为10的单词。这段代码的运行逻辑和之前的forward
也很相似。只不过,这一次我们没有输入张量,每一轮的x
要靠采样获得。np.random.choice(LETTER_LIST, p=np_prob)
可以根据概率分布np_prob
对列表LETTER_LIST
进行采样。根据每一轮采样出的单词letter
,我们重新生成一个x
,给one-hot编码的对应位置赋值1。
1 |
|
使用这个方法,我们可以写一个采样20次的脚本:
1 | def sample(model): |
我的一次输出是:
1 | movine oaceniefke xumedfasss tinkly cerawedaus meblilesen douteni ttingieftu sinsceered inelid tniblicl krouthyych mochonalos memp dendusmani sttywima dosmmek dring diummitt pormoxthin |
采样出来的单词几乎不会是英文里的正确单词。不过,这些单词的词缀很符合英文的造词规则,非常好玩。如果为采样函数加一些限制,比如只考虑概率前3的字母,那么算法应该能够采样出更正确的单词。
PyTorch里的RNN函数
刚刚我们手动编写了RNN的实现细节。实际上,PyTorch提供了更高级的函数,我们能够更加轻松地实现RNN。其他部分的代码逻辑都不怎么要改,我这里只展示一下要改动的关键部分。
新的模型的主要函数如下:
1 | class RNN2(torch.nn.Module): |
初始化时,我们用nn.Embedding
表示单词的向量。词嵌入(Embedding)是《深度学习专项-RNN》第二门课的内容,我会在下一篇笔记里介绍。这里我们把nn.Embedding
看成一种代替one-hot编码的更高级的向量就行。这些向量和线性层参数W
一样,是可以被梯度下降优化的。这样,不仅是RNN可以优化,每一个单词的表示方法也可以被优化。
注意,使用nn.Embedding
后,输入的张量不再是one-hot编码,而是数字标签。代码中的其他地方也要跟着修改。
nn.GRU
可以创建GRU。其第一个参数是输入的维度,第二个参数是隐变量a
的维度,第三个参数是层数,这里我们只构建1层RNN,batch_first
表示输入张量的格式是[batch, Tx, embedding_length]
还是[Tx, batch, embedding_length]
。
貌似RNN中常用的正则化是靠dropout实现的。我们要提前准备好dropout层。
1 | def __init__(self, hidden_units=64, embeding_dim=64, dropout_rate=0.2): |
准备好了计算层后,在forward里只要依次调用它们就行了。其底层原理和我们之前手写的是一样的。其中,self.rnn(emb, hidden)
这个调用完成了循环遍历的计算。
由于输入格式改了,令第一轮输入为空字符的操作也更繁琐了一点。我们要先定义一个空字符张量,再把它和输入的第一至倒数第二个元素拼接起来,作为网络的真正输入。
1 | def forward(self, word: torch.Tensor): |
PyTorch里的RNN用起来非常灵活。我们不仅能够给它一个序列,一次输出序列的所有结果,还可以只输入一个元素,得到一轮的结果。在采样单词时,我们不得不每次输入一个元素。有关采样的逻辑如下:
1 |
|
以上就是PyTorch高级RNN组件的使用方法。在使用PyTorch的RNN时,主要的改变就是输入从one-hot向量变成了标签,数据预处理会更加方便一些。另外,PyTorch的RNN会自动完成循环,可以给它输入任意长度的序列。
总结
在这篇文章中,我展示了一个字母级语言模型项目。这个项目涉及到的编程知识有:
- one-hot编码的处理
- RNN的底层实现
- 如何用RNN对语言模型任务建模
- 如何用RNN求出语言模型的概率
- 如何对语言模型采样
- PyTorch的RNN组件
这篇文章只展示了部分关键代码。想阅读整个项目完整的代码,可以访问该项目的GitHub链接。
如果大家正在学深度学习,强烈建议大家从头写一遍这个项目。编写代码能够学到很多细节,加深对RNN的理解。
在编写这个项目时,我总结了项目中几个比较有挑战性的部分。大家阅读代码或自己动手时可以格外注意这些部分。第一个比较难的部分是和batch有关的计算。RNN本身必须得顺序处理序列,效率较低,同时处理一个batch的数据是一个很重要的加速手段。我们的代码都得尽量符合向量化编程要求,一次处理一个batch。
另外,相比一般的数据,序列数据多了一个时间维度(或者说序列维度),在向量化计算中考虑这个维度是很耗费脑力的。我们可以在代码中加入对中间变量形状的注释。在使用PyTorch或者其他框架时,要注意是batch维度在前面,还是时间维度在前面。注意初始化RNN的batch_first
这个参数。还有,一个张量到底是one-hot编码,还是embedding,还是标签序列,这个也要想清楚来。
PyTorch里的
CrossEntropyLoss
自带了softmax操作,千万不能和softmax混用!我之前写了这个bug,调了很久才调出来,真是气死人了。