语言模型( language model) 用于计算一个文字序列的概率, 评估该序列作为一段文本出现在通用或者特定场景中的可能性。每个人的语言能力蕴涵了一个语言模型,当我们说出或写下一段话的时候,已经在不自觉地应用语言模型来帮助我们决定这段话中的每个词,使之通顺合理。语言模型在自然语言处理中也有诸多应用, 例如, 当我们使用拼音中文输入法输入" ziranyuyan", 输出的候选文字中"自然语言"比"孜然鱼雁"更靠前,这是因为中文输入法所使用的语言模型判断出前者的概率更高。类似地,在机器翻译、拼写检查、语音识别等应用中,语言模型也被用来在多项候选文字中选择更合理、更有可能出现的文字。
本节将首先概述语言模型,然后介绍不同的方法来实现语言模型, 包括最简单的n元语法模型、更复杂但效果更好的循环神经网络、在循环神经网络的基础上引入的注意力机制,以及纯粹基于注意力机制的 Transformer模型。
1. 概述
要想得到一个语言模型,最简单的想法是从一个大型语料库中直接统计不同文字序列出现的频率。然而由于文字序列的排列组合空间极大,不可能找到一个包含所有合理的文字序列的语料库,因此这个想法是不可行的。既然序列的概率无法通过经验频率来估计, 那么是否可以通过概率乘法公式将其转换为一系列条件概率的乘积,转而估算这些条件概率呢?
其中, 表示输入文字序列中的第i个词。那么这个序列"自然语言"的概率可以分解为
P(自然语言)=P(自)×P(然|自)×P(语|自然)×P(言|自然语)
这种分解方式的一个潜在好处在于,一旦能够成功估算所有可能的条件概率, 就可以用它们来生成文本。具体而言,首先根据第一个词的概率分布采样出第一个词,再根据给定第一个词时第二个词的条件概率分布采样出第二个词,然后根据给定前两个词时第三个词的条件概率分布采样出第三个词,以此类推。这种逐个词依次输出,每一步根据已输出的词决定下一个词的过程称为自回归 ( auto- regressive) 过程。
那么如何估算这些条件概率呢? 最直接的想法是最大似然估计:
P(言|自然语) = count(自然语言)/ count(自然语)
但这显然也是不可行的,同样因为我们无法找到一个足够大的包含所有合理文字序列的语料库来估算频率。因此,人们发展出了n元语法模型、循环神经网络、 Transformer模型等一系列方法来计算这些条件概率。
(1) n元语法(n- gram)模型: 每个词的概率仅以前n-1个词为条件。
(2) 循环神经网络( recurrent neural network, RNN): 每个词的概率以一个包含前置序列全部信息的稠密向量为条件。
(3) Transformer模型: 每个词的概率通过对前置所有词使用注意力机制得到。
本节将对这些方法的细节进行详细介绍。除了这些方法,还存在一些更加复杂的方法,如基于句法结构的生成式文法等,后面会介绍其中一些方法,这里不再展开。
所有这些方法所共同面临的一个问题是,如何处理在模型训练时没有见过的词,即所谓未登录( out- of- vocabulary, OOV) 词。一个常用方法是引入一个特殊词"[UNK]": 在训练时,创建一个固定的词表(如所有高频词),将训练语料库中所有未在词表中出现的词都替换为"[UNK]", 并将"[UNK]"作为一个正常词估算概率; 在测试时, 使用"[UNK]"的概率来代替任何未登录词的概率。除这个方法之外,还可以在字符或者子词级建立语言模型。因为任何词都可以拆解为若干字符或子词的组合,而字符或子词的个数较少,所以这样的语言模型能够涵盖所有字符或子词,从而涵盖所有可能的文字序列。
下面最后讨论如何评估一个语言模型的质量。一种方式是在下游任务(如机器翻译、语音识别等) 中检验语言模型的性能,但这往往比较费时费力, 并且不同下游任务的评估结果有可能大相径庭。因此,评估语言模型的通用方法是使用困惑度( perplexity), 即评估模型是否给训练语料库之外的真实测试语言语料库赋予较大的概率。对于测试语料库 (m个序列),使用待评估模型计算每个词的平均负对数概率:
其中, M为测试语料库中的总词数。该评价指标相当于编码每个词平均所需的比特数,其二次幂2'就被称为测试数据的困惑度。困惑度越小则测试语料库的概率越大,因此可认为被评估模型的质量越高。困惑度的最小值是1,这仅当所有测试语料的概率均为1的极端情况下才能取得。需要特别注意的是,词表不同的两个语言模型,其困惑度是不可比较的,显然,词表较小的语言模型平均会给每个词更高的概率,因而更有可能具有较低的困惑度,极端情况下,如果词表只包含"[UNK]"这一个词, 那么模型的困惑度可以达到完美的1。但词表过小的语言模型会将过多的词当作"[UNK]",缺乏区分度,因而不是一个好的语言模型。因此,要比较不同的语言模型方法时, 需要使用统一的词表。
2. n元语法模型
在基础篇我们曾经谈论过n元语法模型,这里我们更深入讲解一番。下面是上文提到原处。
在上面,我们使用概率乘法公式分解文字序列的概率, 但无法对分解得到的条件概率进行估算。为了估算这些条件概率,可以引入马尔可夫假设,即假设每个词只依赖它前面的n-1个词。
上述方法被称为n元语法模型。所谓n元语法是指文本中的连续n个词。最简单的情况为一元语法 ( unigram) 模型:
一元语法模型假设每个词出现的概率独立于其他词,这类似于 4.2.1节中朴素贝叶斯模型所做的假设。二元语法( bigram)模型则假设每个词只与上一个词有关,而和其他词无关:
类似地,可以定义三元语法模型、四元语法模型等。由于n元语法模型对于条件概率的限制条件是只包含n-1个词的序列,因此当n较小时,可能的条件序列也相对较少,可以从语料库中通过统计频率来估算。
n元语法模型的一个缺点在于无法建模所谓的长距离依赖(即距离大于n的两个词之间的依赖关系)。长距离依赖在自然语言中很常见,例如英语中动词所采用的单复数形式取决于主语,但动词与其主语之间可能间隔任意多个词(如对于一个很长的定语从句)。n元语法模型的另一个缺点是需要存储大量的条件概率,当n较大时模型会非常巨大。尽管有这些缺点, n元语法模型仍有很不错的性能,在神经网络语言模型出现之前是最为成功的语言模型。
接下来讨论如何从语料库中估算n元语法模型的条件概率。最简单的方式是最大似然估计:
但这样做的一个常见问题是数据稀疏性问题: 尽管限制了序列的长度,但是不同的n元语法仍然是非常多的,因此一些合理的n元语法可能不会在训练语料库中出现,从而导致相应的条件概率估算为0。这样一来,模型会将包含这些n元语法的文本的概率计算为0,这是不合理的。
常见的处理数据稀疏性问题的方法有以下几类:平滑法(拉普拉斯平滑)、回退法(找不到对应的n元语法时使用n-1元语法,仍找不到时使用n-2元语法, 以此类推)、插值法(将n元语法模型、n-1元语法模型、n-2元语法模型等一系列模型加权平均)。其中最为成功的方法是改良型 Kneser- Ney 平滑。
一个著名的n元语法数据集和可视化界面是 Google Ngram Viewer, 包含几百年来多种语言的公开文献中n元语法的出现频率统计, 可以查询其官网了解详细信息。
3. 循环神经网络(RNN)
基于神经网络的语言模型可以避免n元语法模型的各种缺点。神经网络语言模型中最基础的模型之一是循环神经网络( recurrent neural network, RNN), 它使用隐状态 ( hidden state)来保存历史信息,并使用循环结构逐一处理输入序列中的每一个元素。接下来首先介绍最基本的循环神经网络语言模型,然后介绍循环神经网络的两个著名变体------长短期记忆( long short- term memory, LSTM)和门控循环单元( gated recurrent unit, GRU)(门控循环单元可以看作长短期记忆的一种简化变体), 最后介绍多层双向循环神经网络。
3.1 循环神经网络
循环神经网络的基本思想是在计算文字序列中每个词的条件概率时,计算一个稠密向量来表示条件(也就是这个词的前置序列)所包含的信息,然后用该向量来计算条件概率分布。循环神经网络的架构如下图所示。
用于语言模型的循环神经网络
在这个模型结构中,每一步网络的输入包含两部分,一部分是历史输入,是前置序列的总结, 称为隐状态,另一部分是新的输入,此处为词所对应的词嵌入, 模型的输出经过 softmax函数处理得到下一步的词的分布,例如"自然语言"的下一个词可能是"处(理)""生(成)"等。计算公式如下。
隐状态:
h⁽ᵗ⁾=σ(Wₓx⁽ᵗ⁾+Wₕh⁽ᵗ⁻¹⁾+bₕ)
输出:
其中,Wx为输入的参数,、为隐藏层的参数,、为输出的参数,σ()在此处表示激活函数。初始隐状态在没有其他信息的情况下一般设为全0。
循环神经网络的显著好处在于,解除了n元语法模型中对条件序列长度的限制,即不再使用马尔可夫假设,因而每一时刻的输出(即条件概率分布)都基于整个前置序列作为条件。并且,由于不需要像n元语法模型那样存储大量的条件概率,循环神经网络往往比典型的n元语法模型小, 即具有更少的模型参数。
下面讨论循环神经网络的训练。在每一步t, 训练损失函数为下一个词的预测分布与真实分布(即下一个词的独热编码) 的交叉熵( cross entropy):
其中, θ为模型参数,H(P,Q)=-∑ₓP(x)logQ(x)为两个概率分布P、Q的交叉熵, V为词表。注意, 真实分布只有在词上概率为1, 在其余词上的概率为0, 因此可以去除求和符号而仅保留非零项, 即预测分布在词上的概率。
总损失函数即为每一步交叉熵的平均值:
其中, T为总词数。该损失函数可以使用随机梯度下降进行优化。
下面用代码实现循环神经网络。首先读取数据,使用《小王子》这本书作为训练语料库。
python
import os
import sys
# 导入前面实现的小王子数据集
sys.path.append('./code')
from utils import TheLittlePrinceDataset
dataset = TheLittlePrinceDataset()
# 统计每句话的长度
sent_lens = []
max_len = -1
for sentence in dataset.tokens:
sent_len = len(sentence)
sent_lens.append(sent_len)
if sent_len > max_len:
max_len = sent_len
longest = sentence
print(max_len)
# 简单看一下语料中序列长度的分布
import matplotlib.pyplot as plt
plt.hist(sent_lens, bins=20)
plt.show()
然后建立词表,截断过长的序列, 将序列填充( padding) 为相同长度。实践中容易遇到极长的句子,这样的句子数量稀少但可能包含大量字符,如果不截断的话会极大拖慢模型的运行效率、占用大量显存甚至导致运行出错。因此在预处理阶段,可以对训练数据中过长的句子进行截断,即把预先确定的最大长度之外的词删去。注意, 一般不对测试数据进行截断,以便更完整地评估模型,并确保与其他工作的评估结果可比。为了使模型能够并行处理一个批次中不同长度的输入序列,可以使用填充将不同长度的输入对齐,常见做法是在长度不足的序列末尾持续添加特殊字符(如"[PAD]")直到序列达到设定长度。填充可以在预处理阶段进行,这样会把所有输入序列填充为相同长度,也可以在训练过程中进行, 根据每个批次的序列长度动态设置填充长度。预处理阶段进行填充的好处是只需处理一次数据,并且处理完成的数据可以保存下来多次使用。训练过程中进行填充的好处是插入的特殊字符更少,减少了计算量。
python
import numpy as np
dataset.build_vocab()
sent_tokens = dataset.convert_tokens_to_ids()
# 截断和填充
max_len=40
for i, tokens in enumerate(sent_tokens):
tokens = tokens[:max_len]
tokens += [dataset.token2id['<pad>']] * (max_len - len(tokens))
sent_tokens[i] = tokens
sent_tokens = np.array(sent_tokens)
print(len(dataset.tokens), max([len(x) for x in dataset.tokens]))
print(sent_tokens.shape)
print(sent_tokens[0])
python
1105 115
(1104, 40)
[ 4 17 20 742 743 744 742 743 744 2 62 19 9 1 1 2 1 10
745 746 4 17 20 21 1 2 30 335 194 33 299 3 0 0 0 0
0 0 0 0]
python
import torch
from torch import nn
import torch.nn.functional as F
# 定义一个正态分布的函数用于初始化参数
def normal(shape):
return torch.randn(size=shape) * 0.01
class RNN(nn.Module):
def __init__(self, input_size, hidden_size):
super(RNN, self).__init__()
self.input_size = input_size
self.hidden_size = hidden_size
# 将输入与隐状态分别经过线性变化后相加
self.W_xh = nn.Parameter(normal((input_size, hidden_size)))
self.W_hh = nn.Parameter(normal((hidden_size, hidden_size)))
self.b_h = nn.Parameter(torch.zeros(hidden_size))
def init_rnn_state(self, batch_size, hidden_size):
return (torch.zeros((batch_size, hidden_size), dtype=torch.float),)
def forward(self, inputs, states):
seq_len, batch_size, _ = inputs.shape
hidden_state, = states
hiddens = []
for step in range(seq_len):
# 输入hidden_state与inputs经过线性变换后相加,
# 输出的hidden_state也是下一时刻输入的hidden_state
xh = torch.mm(inputs[step], self.W_xh)
hh = torch.mm(hidden_state, self.W_hh)
hidden_state = xh + hh + self.b_h
hidden_state = torch.tanh(hidden_state)
hiddens.append(hidden_state)
# 返回所有时刻的hidden_state: seq_len * batch_size * hidden_size
# 以及最后时刻的hidden_state(可能用于后续输入):
# batch_size * hidden_size
return torch.stack(hiddens, dim=0), (hidden_state,)
# 在循环神经网络的基础上添加语言模型的输入输出、损失计算等
class RNNLM(nn.Module):
def __init__(self, model, vocab_size, hidden_size):
super(RNNLM, self).__init__()
self.vocab_size = vocab_size
self.hidden_size = hidden_size
self.embedding = nn.Embedding(vocab_size, hidden_size)
self.model = model
self.W_hq = nn.Parameter(normal((hidden_size, vocab_size)))
self.b_q = nn.Parameter(torch.zeros(vocab_size))
def forward(self, input_ids):
batch_size, seq_len = input_ids.shape
# input_ids形状为batch_size * seq_len,翻转为seq_len * batch_size,
# 将seq_len放在第一维方便计算
input_ids = torch.permute(input_ids, (1, 0))
# seq_len * batch_size * embed_size
embed = self.embedding(input_ids)
# batch_size * hidden_size
states = self.model.init_rnn_state(batch_size, self.hidden_size)
hiddens, _ = self.model(embed, states)
hiddens = torch.flatten(hiddens[:-1], start_dim=0, end_dim=1)
output_states = torch.mm(hiddens, self.W_hq) + self.b_q
labels = torch.flatten(input_ids[1:], start_dim=0, end_dim=1)
loss_fct = nn.CrossEntropyLoss(ignore_index=0)
loss = loss_fct(output_states, labels)
return loss
接下来讨论循环神经网络训练过程中经常出现的一个问题。假设第t步的损失为,隐状态为。由于总损失函数是各时刻损失函数的平均值, 因此训练过程中涉及各时刻的损失函数对历史时刻的求导, 这里以对求导为例, 根据链式法则展开梯度:
可以看到此梯度中包含连乘项,即每一时刻隐状态对上一时刻隐状态梯度的乘积。
在某些条件下,连乘项中的大部分因子小于1, 这会导致梯度随t的增加呈指数级衰减,这就是循环神经网络训练中著名的梯度消失问题。由此带来的后果是,距离远的梯度信号比距离近的梯度信号小得非常多,所以模型的参数实际上只能根据近距离的损失函数进行优化,从而破坏了长距离依赖。
反过来,如果大部分连乘项大于1, 又会导致梯度随t的增加呈指数级增长, 这就是循环神经网络训练中的梯度爆炸问题。梯度爆炸会导致参数更新幅度过大,从而可能使训练过程不稳定或是使参数落入损失函数很大的区域, 甚至导致 inf或NaN。
事实上, 梯度消失或梯度爆炸是神经网络训练中普遍存在的问题。对于循环神经网络,当输入序列较长时, 更容易发生梯度消失或梯度爆炸的问题。
对于梯度爆炸,一个非常直接的解决方案是梯度裁剪( gradient clipping), 即当梯度的模( norm) 超过某个阈值的时候,手动将其缩小到合理的范围内再使用梯度下降。那么, 梯度裁剪的思想是否也可以用于解决梯度消失问题,即当梯度的模低于某个阈值时手动将其增加到合理的范围呢?答案是否定的。这是因为当梯度的模较低时, 我们并不能确定是发生了梯度消失还是到达了损失函数的局部最优点,如果是后者, 那么增加梯度是不合理的, 这会导致梯度下降无法收敛。
下面展示使用梯度裁剪的循环神经网络语言模型的训练代码。
python
# 梯度裁剪
def grad_clipping(model, theta=1):
params = [p for p in model.parameters() if p.requires_grad]
norm = torch.sqrt(sum(torch.sum((p.grad ** 2)) for p in params))
if norm > theta:
for param in params:
param.grad[:] *= theta / norm
# 训练
from torch.utils.data import DataLoader
from torch.optim import SGD, Adam
import numpy as np
from tqdm import tqdm, trange
import matplotlib.pyplot as plt
def train_rnn_lm(data_loader, rnn, vocab_size, hidden_size=128,
epochs=200, learning_rate=1e-3):
# 准备模型、优化器等
rnn_lm = RNNLM(rnn, vocab_size, hidden_size)
optimizer = Adam(rnn_lm.parameters(), lr=learning_rate)
rnn_lm.zero_grad()
rnn_lm.train()
epoch_loss = []
with trange(epochs, desc='epoch', ncols=60) as pbar:
for epoch in pbar:
for step, batch in enumerate(data_loader):
loss = rnn_lm(batch)
pbar.set_description(f'epoch-{epoch}, ' + \
f'loss={loss.item():.4f}')
loss.backward()
grad_clipping(rnn_lm)
optimizer.step()
rnn_lm.zero_grad()
epoch_loss.append(loss.item())
epoch_loss = np.array(epoch_loss)
# 打印损失曲线
plt.plot(range(len(epoch_loss)), epoch_loss)
plt.xlabel('training epoch')
plt.ylabel('loss')
plt.show()
sent_tokens = np.array(sent_tokens)
print(sent_tokens.shape)
vocab_size = len(dataset.token2id)
data_loader = DataLoader(torch.tensor(sent_tokens, dtype=torch.long),\
batch_size=16, shuffle=True)
rnn = RNN(128, 128)
train_rnn_lm(data_loader, rnn, vocab_size, hidden_size=128,\
epochs=200, learning_rate=1e-3)
python
(1104, 40)
epoch-199, loss=0.3491: 100%|█| 200/200 [04:42<00:00, 1.41s