0%

你的第一个PyTorch RNN模型——字母级语言模型

想要入门一项新技术,最快的方法就是写一个”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任务,拿来搭字母级语言模型肯定是没问题的。

这个数据集的文件结构大致如下:

text
1
2
3
4
5
6
7
8
9
10
├─test
│ ├─neg
│ │ ├ 0_2.txt
│ │ ├ 1_3.txt
│ │ └ ...
│ └─pos
├─train
│ ├─neg
│ └─pos
└─imdb.vocab

其中,imdb.vocab记录了数据集中的所有单词,一行一个。testtrain是测试集和训练集,它们的negpos子文件夹分别记录了负面评论和正面评论。每一条评论都是一句话,存在一个txt文件里。

训练字母级语言模型时,直接拿词汇表来训练也行,从评论中截取一个个单词也行。我已经写好了这些读取数据集的代码,在dldemos/BasicRNN/read_imdb.py文件中。

在读取单词时,我们只需要26个字母和空格这一共27个字符。其他的字符全可以过滤掉。为了方便,我使用了正则表达式过滤出这27个字符:

1
words = re.sub(u'([^\u0020\u0061-\u007a])', '', words)

这样,一个读取词汇表文件的函数就长这样:

1
2
3
4
5
6
7
8
9
def read_imdb_vocab(dir='data/aclImdb'):
fn = os.path.join(dir, 'imdb.vocab')
with open(fn, 'rb') as f:
word = f.read().decode('utf-8').replace('\n', ' ')
words = re.sub(u'([^\u0020\u0061-\u007a])', '',
word.lower()).split(' ')
filtered_words = [w for w in words if len(w) > 0]

return filtered_words

我写好了读取词汇表的函数read_imdb_vocabread_imdb_words,它们都会返回一个单词的列表。我还写了一个读数据集整个句子的函数read_imdb。它们的用法和输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
def main():
vocab = read_imdb_vocab()
print(vocab[0])
print(vocab[1])

lines = read_imdb()
print('Length of the file:', len(lines))
print('lines[0]:', lines[0])
words = read_imdb_words(n_files=100)
print('Length of the words:', len(words))
for i in range(5):
print(words[i])
text
1
2
3
4
5
6
7
8
9
10
the
and
Length of the file: 12500
lines[0]: Bromwell High is a cartoon ...
Length of the words: 23425
bromwell
high
is
a
cartoon

数据集读取

RNN的输入不是字母,而是表示字母的向量。最简单的字母表示方式是one-hot编码,每一个字母用一个某一维度为1,其他维度为0的向量表示。比如我有a, b, c三个字母,它们的one-hot编码分别为:

1
2
3
a: [1, 0, 0]
b: [0, 1, 0]
c: [0, 0, 1]

现在,我们只有单词数组。我们要把每个单词转换成这种one-hot编码的形式。

在转换之前,我准备了一些常量(dldemos/BasicRNN/constant.py):

1
2
3
4
5
6
7
EMBEDDING_LENGTH = 27
LETTER_MAP = {' ': 0}
ENCODING_MAP = [' ']
for i in range(26):
LETTER_MAP[chr(ord('a') + i)] = i + 1
ENCODING_MAP.append(chr(ord('a') + i))
LETTER_LIST = list(LETTER_MAP.keys())

我们一共有27个字符,0号字符是空格,剩余字母按照字母表顺序排列。LETTER_MAPENCODING_MAP分别完成了字母到数字的正向和反向映射。LETTER_LIST是所有字母的列表。

PyTorch提供了用于管理数据集读取的Dataset类。Dataset一般只会存储获取数据的信息,而非原始数据,比如存储图片路径。而每次读取时,Dataset才会去实际读取数据。在这个项目里,我们用Dataset存储原始的单词数组,实际读取时,每次返回一个one-hot编码的向量。

使用Dataset时,要继承这个类,实现__len____getitem__方法。前者表示获取数据集的长度,后者表示获取某项数据。我们的单词数据集WordDataset应该这样写(dldemos/BasicRNN/main.py):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import torch
from torch.utils.data import DataLoader, Dataset

from dldemos.BasicRNN.constant import EMBEDDING_LENGTH, LETTER_MAP
from dldemos.BasicRNN.read_imdb import read_imdb_vocab, read_imdb_words

class WordDataset(Dataset):
def __init__(self, words, max_length, is_onehot=True)
super().__init__()
n_words = len(words)
self.words = words
self.n_words = n_words
self.max_length = max_length
self.is_onehot = is_onehot

def __len__(self):
return self.n_words

def __getitem__(self, index):
"""return the (one-hot) encoding vector of a word"""
word = self.words[index] + ' '
word_length = len(word)
if self.is_onehot:
tensor = torch.zeros(self.max_length, EMBEDDING_LENGTH)
for i in range(self.max_length):
if i < word_length:
tensor[i][LETTER_MAP[word[i]]] = 1
else:
tensor[i][0] = 1
else:
tensor = torch.zeros(self.max_length, dtype=torch.long)
for i in range(word_length):
tensor[i] = LETTER_MAP[word[i]]

return tensor

构造数据集的参数是words, max_length, is_onehotwords是单词数组。max_length表示单词的最大长度。在训练时,我们一般要传入一个batch的单词。可是,单词有长有短,我们不可能拿一个动态长度的数组去表示单词。为了统一地表达所有单词,我们可以记录单词的最大长度,把较短的单词填充空字符,直到最大长度。is_onehot表示是不是one-hot编码,我设计的这个数据集既能输出用数字标签表示的单词(比如abc表示成[0, 1, 2]),也能输出one-hoe编码表示的单词(比如abc表示成[[1, 0, 0], [0, 1, 0], [0, 0, 1]])。

1
2
3
4
5
6
7
def __init__(self, words, max_length, is_onehot=True)
super().__init__()
n_words = len(words)
self.words = words
self.n_words = n_words
self.max_length = max_length
self.is_onehot = is_onehot

在获取数据集时,我们要根据是不是one-hot编码,先准备好一个全是0的输出张量。如果存的是one-hot编码,张量的形状是[MAX_LENGTH, EMBEDDING_LENGTH],第一维是单词的最大长度,第二维是one-hot编码的长度。而如果是普通的标签数组,则张量的形状是[MAX_LENGTH]。准备好张量后,遍历每一个位置,令one-hot编码的对应位为1,或者填入数字标签。

另外,我们用空格表示单词的结束。要在处理前给单词加一个' ',保证哪怕最长的单词也会至少有一个空格。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def __getitem__(self, index):
"""return the (one-hot) encoding vector of a word"""
word = self.words[index] + ' '
word_length = len(word)
if self.is_onehot:
tensor = torch.zeros(self.max_length, EMBEDDING_LENGTH)
for i in range(self.max_length):
if i < word_length:
tensor[i][LETTER_MAP[word[i]]] = 1
else:
tensor[i][0] = 1
else:
tensor = torch.zeros(self.max_length, dtype=torch.long)
for i in range(word_length):
tensor[i] = LETTER_MAP[word[i]]

return tensor

注意!短单词的填充部分应该全是空字符。千万不要忘记给空字符的one-hot编码赋值。

1
2
3
4
5
for i in range(self.max_length):
if i < word_length:
tensor[i][LETTER_MAP[word[i]]] = 1
else:
tensor[i][0] = 1

有了数据集类,结合之前写好的数据集获取函数,可以搭建一个DataLoader。DataLoader是PyTorch提供的数据读取类,它可以方便地从Dataset的子类里读取一个batch的数据,或者以更高级的方式取数据(比如随机取数据)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def get_dataloader_and_max_length(limit_length=None,
is_onehot=True,
is_vocab=True):
if is_vocab:
words = read_imdb_vocab()
else:
words = read_imdb_words(n_files=200)

max_length = 0
for word in words:
max_length = max(max_length, len(word))

if limit_length is not None and max_length > limit_length:
words = [w for w in words if len(w) <= limit_length]
max_length = limit_length

# for <EOS> (space)
max_length += 1

dataset = WordDataset(words, max_length, is_onehot)
return DataLoader(dataset, batch_size=256), max_length

这个函数会先调用之前编写的数据读取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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F

from dldemos.BasicRNN.constant import EMBEDDING_LENGTH, LETTER_LIST, LETTER_MAP

class RNN1(nn.Module):
def __init__(self, hidden_units=32):
super().__init__()
self.hidden_units = hidden_units
self.linear_a = nn.Linear(hidden_units + EMBEDDING_LENGTH,
hidden_units)
self.linear_y = nn.Linear(hidden_units, EMBEDDING_LENGTH)
self.tanh = nn.Tanh()

def 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)

我们来一点一点地看看这个模型是怎么搭起来的。

回忆一下RNN的公式:

我们可以把第一行公式里的两个$W$合并一下,$x, a$拼接一下。这样,只需要两个线性层就可以描述RNN了。

因此,在初始化函数中,我们定义两个线性层linear_alinear_y。另外,hidden_units表示隐藏层linear_a的神经元数目。tanh就是普通的tanh函数,它用作第一层的激活函数。

linear_a就是公式的第一行,由于我们把输入x和状态a拼接起来了,这一层的输入通道数是hidden_units + EMBEDDING_LENGTH,输出通道数是hidden_units。第二层linear_y表示公式的第二行。我们希望RNN能预测下一个字母的出现概率,因此这一层的输出通道数是EMBEDDING_LENGTH=27,即字符个数。

1
2
3
4
5
6
7
8
def __init__(self, hidden_units=32):
super().__init__()
self.hidden_units = hidden_units
self.linear_a = nn.Linear(hidden_units + EMBEDDING_LENGTH,
hidden_units)
self.linear_y = nn.Linear(hidden_units, EMBEDDING_LENGTH)
self.tanh = nn.Tanh()

在描述模型运行的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
21
def 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
2
# word shape: [batch, max_word_length, embedding_length]
batch, Tx = word.shape[0:2]

我们循环遍历的其实是单词长度那一维。为了方便理解代码,我们可以把单词长度那一维转置成第一维。根据这个新的形状,我们准备好同形状的输出张量。输出张量output[i][j]表示第j个batch的序列的第i个元素的27个字符预测结果。

1
2
3
4
5
# 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)

按照前文知识准备的描述,第一轮的输入是空字符,期待的输出是句子里的第一个字母;第二轮的输入的第一个字母,期待的输出是第二个字母……。因此,我们要把输入x初始化为空。理论上x应该是一个空字符,其one-hot编码是[1, 0, 0, ...],但这里我们拿一个全0的向量表示句首也是可行的。除了初始化x,还要初始化一个全零隐变量a

1
2
a = torch.zeros(batch, self.hidden_units, device=word.device)
x = torch.zeros(batch, EMBEDDING_LENGTH, device=word.device)

之后,按照顺序遍历每一个元素,计算y_hat并维护a, x。最后输出结果前别忘了把转置过的维度复原回去。

1
2
3
4
5
6
7
8
9
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)

从逻辑上讲,模型应该输出softmax的结果。但是,PyTorch的CrossEntropyLoss已经包含了softmax的计算,我们不用在模型里加softmax。

训练

main函数中完整的训练代码如下(dldemos/BasicRNN/models.py):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
def train_rnn1():
device = 'cuda:0'
dataloader, max_length = get_dataloader_and_max_length(19)

model = RNN1().to(device)

optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
citerion = torch.nn.CrossEntropyLoss()
for 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}')

torch.save(model.state_dict(), 'dldemos/BasicRNN/rnn1.pth')
return model

首先,调用之前编写的函数,准备好dataloadermodel。同时,准备好优化器optimizer和损失函数citerion。优化器和损失函数按照常见配置选择即可。

1
2
3
4
5
6
7
device = 'cuda:0'
dataloader, max_length = get_dataloader_and_max_length(19)

model = RNN1().to(device)

optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
citerion = torch.nn.CrossEntropyLoss()

这个语言模型一下就能训练完,做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
22
for 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
2
3
4
5
6
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)

之后就是调用PyTorch的自动求导功能。注意,为了防止RNN梯度过大,我们可以用clip_grad_norm_截取梯度的最大值。

1
2
3
4
optimizer.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 0.5)
optimizer.step()

我还顺带输出了每一代的loss。当然这里我偷了个懒,这个loss并不能表示每一个样本的平均loss。不过,我们能通过这个loss得到模型的训练进度,这就够了。

1
print(f'Epoch {epoch}. loss: {loss_sum / dataset_len}')

测试

我们可以手动为字母级语言模型写几个测试用例,看看每一个单词的概率是否和期望的一样。我的测试单词列表是:

1
2
3
4
5
test_words = [
'apple', 'appll', 'appla', 'apply', 'bear', 'beer', 'berr', 'beee', 'car',
'cae', 'cat', 'cac', 'caq', 'query', 'queee', 'queue', 'queen', 'quest',
'quess', 'quees'
]

我构筑了几组长度一样,但是最后几个字母不太一样的“单词”。通过观察这些词的概率,我们能够验证语言模型的正确性。理论上来说,英文里的正确单词的概率会更高。

我们的模型只能输出每一个单词的softmax前结果。我们还要为模型另写一个求语言模型概率的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@torch.no_grad()
def language_model(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_label shape: [max_word_length, batch]
word = torch.transpose(word, 0, 1)
word_label = torch.argmax(word, 2)

# output shape: [batch]
output = torch.ones(batch, device=word.device)

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)))
tmp = self.linear_y(next_a)
hat_y = F.softmax(tmp, 1)
probs = hat_y[torch.arange(batch), word_label[i]]
output *= probs
x = word[i]
a = next_a

return output

这个函数和forward大致相同。只不过,这次我们的输出output要表示每一个单词的概率。因此,它被初始化成一个全1的向量。

1
2
# output shape: [batch]
output = torch.ones(batch, device=word.device)

每轮算完最后一层的输出后,我们手动调用F.softmax得到softmax的概率值。

1
2
tmp = self.linear_y(next_a)
hat_y = F.softmax(tmp, 1)

接下来,我们要根据每一个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
2
3
4
5
6
7
8
9
10
def test_language_model(model, is_onehot=True, device='cuda:0'):
_, max_length = get_dataloader_and_max_length(19)
if is_onehot:
test_word = words_to_onehot(test_words, max_length)
else:
test_word = words_to_label_array(test_words, max_length)
test_word = test_word.to(device)
probs = model.language_model(test_word)
for word, prob in zip(test_words, probs):
print(f'{word}: {prob}')
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
apple: 9.39846032110836e-08
appll: 6.516307937687316e-09
appla: 3.6599331565412285e-08
apply: 1.2422759709806996e-07
bear: 1.6009346381906653e-06
beer: 1.6936465954131563e-06
berr: 9.99331746243115e-07
beee: 1.5601625591443735e-07
car: 1.8536804418545216e-05
cae: 1.8946409454656532e-06
cat: 1.875695670605637e-05
cac: 6.04180786467623e-06
caq: 3.6483314147517376e-08
query: 1.6811516161396867e-06
queee: 5.9459132728534314e-08
queue: 9.488831942405795e-09
queen: 5.990783051856852e-07
quest: 2.737341446845676e-06
quess: 4.7091912165342364e-06
quees: 1.3468336419464322e-06

通过观察每一组用例,我们能发现,apple, apply, bear, beer这些正确的单词的概率确实会高一些。这个语言模型训练得不错。有趣的是,caq这种英语里几乎不存在的字母组合的概率也偏低。当然,语言模型对难一点的单词的判断就不太准了。queenqueue的出现概率就比较低。

采样单词

语言模型有一个很好玩的应用:我们可以根据语言模型输出的概率分布,采样出下一个单词;输入这一个单词,再采样下一个单词。这样一直采样,直到采样出空格为止。使用这种采样算法,我们能够让模型自动生成单词,甚至是英文里不存在,却看上去很像那么回事的单词。

我们要为模型编写一个新的方法sample_word,采样出一个最大长度为10的单词。这段代码的运行逻辑和之前的forward也很相似。只不过,这一次我们没有输入张量,每一轮的x要靠采样获得。np.random.choice(LETTER_LIST, p=np_prob)可以根据概率分布np_prob对列表LETTER_LIST进行采样。根据每一轮采样出的单词letter,我们重新生成一个x,给one-hot编码的对应位置赋值1。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@torch.no_grad()
def sample_word(self, device='cuda:0'):
batch = 1
output = ''

a = torch.zeros(batch, self.hidden_units, device=device)
x = torch.zeros(batch, EMBEDDING_LENGTH, device=device)
for i in range(10):
next_a = self.tanh(self.linear_a(torch.cat((a, x), 1)))
tmp = self.linear_y(next_a)
hat_y = F.softmax(tmp, 1)

np_prob = hat_y[0].detach().cpu().numpy()
letter = np.random.choice(LETTER_LIST, p=np_prob)
output += letter

if letter == ' ':
break

x = torch.zeros(batch, EMBEDDING_LENGTH, device=device)
x[0][LETTER_MAP[letter]] = 1
a = next_a

return output

使用这个方法,我们可以写一个采样20次的脚本:

1
2
3
4
5
6
def sample(model):
words = []
for _ in range(20):
word = model.sample_word()
words.append(word)
print(*words)

我的一次输出是:

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。其他部分的代码逻辑都不怎么要改,我这里只展示一下要改动的关键部分。

写这份代码时我参考了 https://github.com/floydhub/word-language-model

新的模型的主要函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class RNN2(torch.nn.Module):
def __init__(self, hidden_units=64, embeding_dim=64, dropout_rate=0.2):
super().__init__()
self.drop = nn.Dropout(dropout_rate)
self.encoder = nn.Embedding(EMBEDDING_LENGTH, embeding_dim)
self.rnn = nn.GRU(embeding_dim, hidden_units, 1, batch_first=True)
self.decoder = torch.nn.Linear(hidden_units, EMBEDDING_LENGTH)
self.hidden_units = hidden_units

self.init_weights()

def init_weights(self):
initrange = 0.1
nn.init.uniform_(self.encoder.weight, -initrange, initrange)
nn.init.zeros_(self.decoder.bias)
nn.init.uniform_(self.decoder.weight, -initrange, initrange)

def forward(self, word: torch.Tensor):
# word shape: [batch, max_word_length]
batch, Tx = word.shape[0:2]
first_letter = word.new_zeros(batch, 1)
x = torch.cat((first_letter, word[:, 0:-1]), 1)
hidden = torch.zeros(1, batch, self.hidden_units, device=word.device)
emb = self.drop(self.encoder(x))
output, hidden = self.rnn(emb, hidden)
y = self.decoder(output.reshape(batch * Tx, -1))

return y.reshape(batch, Tx, -1)

初始化时,我们用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
2
3
4
5
6
7
8
9
def __init__(self, hidden_units=64, embeding_dim=64, dropout_rate=0.2):
super().__init__()
self.drop = nn.Dropout(dropout_rate)
self.encoder = nn.Embedding(EMBEDDING_LENGTH, embeding_dim)
self.rnn = nn.GRU(embeding_dim, hidden_units, 1, batch_first=True)
self.decoder = torch.nn.Linear(hidden_units, EMBEDDING_LENGTH)
self.hidden_units = hidden_units

self.init_weights()

准备好了计算层后,在forward里只要依次调用它们就行了。其底层原理和我们之前手写的是一样的。其中,self.rnn(emb, hidden)这个调用完成了循环遍历的计算。

由于输入格式改了,令第一轮输入为空字符的操作也更繁琐了一点。我们要先定义一个空字符张量,再把它和输入的第一至倒数第二个元素拼接起来,作为网络的真正输入。

1
2
3
4
5
6
7
8
9
10
11
def forward(self, word: torch.Tensor):
# word shape: [batch, max_word_length]
batch, Tx = word.shape[0:2]
first_letter = word.new_zeros(batch, 1)
x = torch.cat((first_letter, word[:, 0:-1]), 1)
hidden = torch.zeros(1, batch, self.hidden_units, device=word.device)
emb = self.drop(self.encoder(x))
output, hidden = self.rnn(emb, hidden)
y = self.decoder(output.reshape(batch * Tx, -1))

return y.reshape(batch, Tx, -1)

PyTorch里的RNN用起来非常灵活。我们不仅能够给它一个序列,一次输出序列的所有结果,还可以只输入一个元素,得到一轮的结果。在采样单词时,我们不得不每次输入一个元素。有关采样的逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@torch.no_grad()
def sample_word(self, device='cuda:0'):
batch = 1
output = ''

hidden = torch.zeros(1, batch, self.hidden_units, device=device)
x = torch.zeros(batch, 1, device=device, dtype=torch.long)
for _ in range(10):
emb = self.drop(self.encoder(x))
rnn_output, hidden = self.rnn(emb, hidden)
hat_y = self.decoder(rnn_output)
hat_y = F.softmax(hat_y, 2)

np_prob = hat_y[0, 0].detach().cpu().numpy()
letter = np.random.choice(LETTER_LIST, p=np_prob)
output += letter

if letter == ' ':
break

x = torch.zeros(batch, 1, device=device, dtype=torch.long)
x[0] = LETTER_MAP[letter]

return output

以上就是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,调了很久才调出来,真是气死人了。