课程8. 语言建模(LM)
课程计划
- 语言建模的任务。
- 构建用于语言建模任务的循环神经网络。
- 使用循环神经网络生成文本。
- 语言模型的附加功能。
语言建模的任务
我们可以创建一个神经网络,它可以生成与训练集中的文本类似的文本。
为了做到这一点,我们采用了语言建模的任务。
在这个任务中,我们尝试根据上下文(即我们已经知道的标记)来预测一个新的标记(符号、单词等)。
语言建模问题最简单的例子是前向语言建模问题,我们尝试根据已知的先前标记来预测下一个标记。
其实,生活中大家都遇到过这样的问题:
更正式地说,前向语言模型预测在给定的标记序列之后遇到特定标记(或标记组)的概率:
因此,直接语言模型(LM)的结构应该如下:
如果这里的 LM 是一个可训练的神经网络,那么如何训练这样的神经网络就变得很清楚了:
- 任务 — 分类问题分为 n n n 类,其中 n n n 是字典的大小;
- 数据 - 一组文本;
- 我们从头到尾浏览文本,在每个时间点,使用当前前缀(A cat is),我们教 LM 预测下一个单词。正确的词是文本中接下来的词(sitting)。我们学习给定概率分布和正确答案(单词 Sitting 的独热向量)之间的交叉熵。
任务的正式陈述
已给出:
- 标记词典(有限集) V = : ∣ V ∣ = N \mathbb{V}={}: |\mathbb{V}|=N V=:∣V∣=N.
- 最终的标记序列(文本)
w 0 , w 1 , . . . , w t − 1 : ∀ i = 1 , . . . , t − 1 ⇒ w i ∈ V w_0, w_1,..., w_{t-1}:\forall i=1,...,t-1 \Rightarrow w_i \in \mathbb{V} w0,w1,...,wt−1:∀i=1,...,t−1⇒wi∈V
需要:
建立一个模型,根据已知序列 w 0 , w 1 , . . . , w t − 1 w_0, w_1, ..., w_{t-1} w0,w1,...,wt−1 估计使用字典中每个标记的概率。那些。我们需要通过构建一个向量 P = ( P 1 , . . . , P N ) P=(P_1,...,P_N) P=(P1,...,PN) 来估计概率分布:
∀ w t i ∈ V ⇒ P i = P Θ ( w t i ∣ w t − 1 , . . . , w 0 ) \forall w_t^i \in \mathbb{V} \Rightarrow P_i=P_{\Theta}(w_t^i|w_{t-1},...,w_0) ∀wti∈V⇒Pi=PΘ(wti∣wt−1,...,w0)
文本概率与困惑度
现在我们来谈谈如何评估语言建模的好坏。
由于语言模型允许我们根据前一个标记来估计下一个标记的概率,因此我们可以计算特定文本的概率 - 标记序列 w 0 , . . . , w t w_0,...,w_t w0,...,wt:
P ( w 0 , w 1 , . . . , w t ) = ∏ i = 0 t P ( w i ∣ w i − 1 , . . . , w 0 ) P(w_0, w_1,...,w_t) = ∏_{i=0}^{t}{P(w_i|w_{i-1},...,w_0)} P(w0,w1,...,wt)=i=0∏tP(wi∣wi−1,...,w0)
在实际应用中,计算概率的乘积而不是概率的对数会更加方便。因为概率是一个很小的数字。而如果我们计算乘积,我们会得到一个非常小的数字,这个数字会计算不准确(由于舍入误差)。因此,最好计算该产品的对数:
l o g [ P ( w 0 , w 1 , . . . , w t ) ] = l o g [ ∏ i = 0 t P ( w i ∣ w i − 1 , . . . , w 0 ) ] = ∑ i = 0 t l o g [ P ( w i ∣ w i − 1 , . . . , w 0 ) ] log[P(w_0, w_1,...,w_t)] = log[∏_{i=0}^{t}{P(w_i|w_{i-1},...,w_0)}] = \sum_{i=0}^t{log[P(w_i|w_{i-1},...,w_0)]} log[P(w0,w1,...,wt)]=log[i=0∏tP(wi∣wi−1,...,w0)]=i=0∑tlog[P(wi∣wi−1,...,w0)]
在最后一个等式中,我们使用了乘积对数的性质(它分解为对数的和)。
然后我们可以使用文本概率值来评估我们模型的质量。为此,我们使用了所谓的困惑度,其计算公式如下:
P P ( w 0 , . . . , w t ) = e − 1 t ∗ l o g [ P ( w 0 , w 1 , . . . , w t ) ] PP(w_0,...,w_t) = e^{-\frac{1}{t}*log[P(w_0, w_1,...,w_t)]} PP(w0,...,wt)=e−t1∗log[P(w0,w1,...,wt)]
这里我们利用指数将对数转换回概率,并用系数 1 t \frac{1}{t} t1进行归一化(这样文本大小就不会影响最终结果)。
因此,我们可以在训练集上建立和训练一些语言模型。然后将其应用于测试样本中的新文本,并根据我们训练的语言模型估计该新文本中每个后续标记的概率。我们的模型预测的文本越接近真实文本,模型的质量就越好。
此外,困惑度越低越好。自己想想为什么会发生这种情况。
循环网络在语言建模问题中的应用
上一课讨论的循环神经网络非常适合语言建模任务。接下来,我们将看一个构建循环神经网络来解决直接语言建模问题的具体例子。
构建用于语言建模任务的循环神经网络
回想一下,循环神经网络可以看作是密集层对输入
x
t
x_t
xt 和前一个 rnn
状态
h
t
h_t
ht 的顺序应用:
然后,该架构可以应用于语言建模任务,如下所示:
- 让我们取一个大型文本语料库并将其分成标记序列 x 0 , x 1 , . . . , x k x_0, x_1,...,x_k x0,x1,...,xk。
- 我们将获得的序列输入到 RNN 输入中。我们计算以下标记 y 0 ^ , . . . , y k ^ \hat{y_0},...,\hat{y_k} y0^,...,yk^ 的概率分布。
- 计算损失函数——预测值与下一个 token y 0 , . . . , y k y_0,...,y_k y0,...,yk 的真实值之间的交叉熵。这里每个 y i y_i yi 都是序列 x i x_i xi 中下一个单词的 One-Hot 向量。
- 使用梯度下降进行优化。
我们可以通过该演示文稿,看下RNN是如何预测选择下一个词汇的。
使用循环神经网络生成文本
如果我们有一个训练有素的语言模型,可以估计文本中下一个标记的概率,那么使用这个模型就可以生成新的文本。
这里的算法如下:
- 我们将句子的开头(A cat is)作为 LLM 输入,
- 我们从 LLM 获得字典中下一个单词的概率分布,
- 我们根据概率分布选择文本中的下一个单词(A cat is sitting),
- 我们将一个新的句子开头作为 LLM 输入(A cat is sitting),
- …
现在我们尝试用一个具体的例子来分析这个算法。
考虑一个由姓名列表组成的数据集。每个序列都是一个单独的名称。我们将把单个符号(字母)视为标记。也就是说,我们将教 RNN 生成名称 =)
import os # 导入用于与计算机上的数据进行交互以及访问运行notebook环境的库的部分功能
from random import sample # random - 导入负责生成随机数的库的部分功能
import numpy as np # 导入用于处理向量和矩阵的库
import torch # 这里包含所有神经网络相关功能
import torch.nn as nn # 这里包含所有神经网络模块、层
import torch.nn.functional as F # 导入对我们有用的函数
import matplotlib.pyplot as plt # 用于绘制图表
from IPython.display import clear_output # 用于交互式绘制图表
# clear_output允许逐帧绘制收敛图
embed = nn.Embedding(10, 4) # 10 - 嵌入数量,4 - 维度
让我们加载并预处理数据:
! wget https://raw.githubusercontent.com/MSUcourses/Data-Analysis-with-Python/main/Deep%20Learning/Files/names.txt -O names.txt # 下载 names.txt
start_token = " " # 技术标记(这里是空格),每个序列都从它开始
with open("names.txt") as f: # 打开文件
names = f.read()[:-1].split('\n') # names - 读取文件内容(去掉最后一个字符)并按换行符分割
names = [start_token + line for line in names] # 给每个序列元素开头添加空格
names[:10] # 输出数据集中的前 10 个名字
输出:
len(names)
输出:7944
我们可以看到,数据集中有 7944 个示例。请注意,每个序列都以空格开头(这是一个技术标记,可以让网络理解名称以空格开头;为简单起见,在此任务中,我们假设空格仅出现在每个名称的开头,数据中没有其他空格)。
让我们考虑序列长度的分布:
MAX_LENGTH = max(map(len, names)) # map 函数将特定函数应用于列表或任何可迭代对象
# 在这种情况下,是计算 names 集合中每个元素的长度
# 并从结果中找出最大值
print("max_length =", MAX_LENGTH)
plt.title('Sequence length distribution')
plt.hist(list(map(len, names)), bins=25); # hist - 绘制直方图
# 确切地说是针对列表计算出现的次数
输出:
max_length = 16
文本预处理
解决任何问题的第一步都是预处理。
首先,让我们建立一个包含所有唯一标记的“字典”并对它们进行编号。然后我们可以将输入数据编码为其符号索引的序列。例如,名称“Aboba”将表示为“[0, 1, 15, 1, 0]”。
首先,您可以构建字典中所有唯一标记(字母)的集合。为此,我们将使用“set”类型:
set('aaaaaabbbbcc') # 输出字符串中所有唯一字符元素
输出:{‘a’, ‘b’, ‘c’}
tokens = set() # 用于存储数据集中所有唯一的标记(字符)的集合
for name in names: # 遍历所有的名字
tokens.update(set(name)) # 对每个名字序列取集合(去除重复字符),然后更新 tokens 集合
# 即将新名字中所有唯一的新标记添加到 tokens 集合中
tokens = list(tokens) # 将集合转换为列表
num_tokens = len(tokens)
print('num_tokens = ', num_tokens)
输出:num_tokens = 55
我们看到该数据集中有 55 个唯一标记。如果我们仔细观察数据集,我们会发现名称包含拉丁字母的小写和大写字母(26+26=52 个标记)、一个技术符号(空格)和一些特殊字符(撇号和连字符)。总计 55 个代币。
现在让我们创建一个标记字典,其中键将是标记,值将是相应标记的唯一索引:
# <字典符号 -> 其 id(标记列表中的索引)>
# 序列号
token_to_id = {
token: idx for idx, token in enumerate(tokens) # 对所有 token 进行编号,每个 token 都有自己的
}
token_to_id
输出:
{‘C’: 0,
‘z’: 1,
‘d’: 2,
‘T’: 3,
‘V’: 4,
‘v’: 5,
‘N’: 6,
‘X’: 7,
‘k’: 8,
‘e’: 9,
‘A’: 10,
‘p’: 11,
‘I’: 12,
‘W’: 13,
‘K’: 14,
‘r’: 15,
‘a’: 16,
‘c’: 17,
‘D’: 18,
“'”: 19,
‘Q’: 20,
‘-’: 21,
‘b’: 22,
‘g’: 23,
‘s’: 24,
‘x’: 25,
‘t’: 26,
‘L’: 27,
‘w’: 28,
’ ': 29,
‘R’: 30,
‘P’: 31,
‘q’: 32,
‘h’: 33,
‘B’: 34,
‘i’: 35,
‘j’: 36,
‘F’: 37,
‘l’: 38,
‘m’: 39,
‘M’: 40,
‘y’: 41,
‘Y’: 42,
‘U’: 43,
‘n’: 44,
‘o’: 45,
‘Z’: 46,
‘u’: 47,
‘G’: 48,
‘J’: 49,
‘E’: 50,
‘H’: 51,
‘S’: 52,
‘f’: 53,
‘O’: 54}
现在每个标记都有一个唯一的索引。
最后,我们实现一个函数,将名称列表转换为矩阵表示,稍后将用作批次。
因为文本的长度可以不同,并且神经网络处理的是固定长度的数据,所以如果未指定此参数,我们将把它们填充到最大长度“max_len”或样本中最长名称的长度:
def to_matrix(names, max_len=None, pad=token_to_id[' ']):
"""将名字列表转换为循环神经网络(RNN)可处理的矩阵"""
# 计算最大长度,若未指定 max_len,则使用 names 中最长名字的长度
max_len = max_len or max(map(len, names))
# 创建一个形状为 [名字数量, 最大长度] 的零矩阵,并将所有元素初始化为填充标记的索引
# pad 是技术标记(在本案例中是空格),用于将不同长度的序列填充到相同长度
names_ix = np.zeros([len(names), max_len], dtype='int32') + pad
# 遍历所有名字
for i in range(len(names)):
# 对于每个名字,将其中的每个标记(字符)转换为对应的索引
line_ix = [token_to_id[c] for c in names[i]]
# 将转换后的索引序列写入矩阵的对应行
names_ix[i, :len(line_ix)] = line_ix
return names_ix
让我们看一个例子。
我们将从数据集中输出每千个名称(这将是批次),然后对于每个名称,我们将输出其向量表示,其中我们将指示每个标记在字典中的索引:
print('\n'.join(names[::1000]))
print(to_matrix(names[::1000], max_len=MAX_LENGTH))
输出:
让我们突出显示所有与空格对应的位置。为此,我们使用一种特殊类型的图形 pcolormesh,它允许我们用颜色来可视化矩阵。黄色代表空格,紫色代表非空格字符:
plt.pcolormesh(to_matrix(names[::1000], max_len=MAX_LENGTH)==token_to_id[' '])
输出:
现在让我们编写一个“CharRNNLoop”类,它允许我们根据前面的标记预测下一个标记:
class CharRNNLoop(nn.Module):
def __init__(self, num_tokens=num_tokens, emb_size=16, rnn_num_units=64):
super(self.__class__, self).__init__()
self.emb = nn.Embedding(num_tokens, emb_size)
self.rnn = nn.RNN(emb_size, rnn_num_units, batch_first=True)
self.hid_to_logits = nn.Linear(rnn_num_units, num_tokens)
def forward(self, x):
h_seq, _ = self.rnn(self.emb(x))
next_logits = self.hid_to_logits(h_seq)
return next_logits
model = CharRNNLoop()
opt = torch.optim.Adam(model.parameters())
criterion = nn.NLLLoss()
最后,让我们检查一下一切是否正常。在这种情况下,我们将从原始数据集中选择一个大小为 32 的子样本作为一个批次:
batch_ix = to_matrix(sample(names, 32), max_len=MAX_LENGTH)
batch_ix = torch.LongTensor(batch_ix)
logits = model(batch_ix)
logits.shape
输出:torch.Size([32, 16, 55])
因为预测下一个 token 的任务是一个分类任务,所以损失函数就是我们熟悉的交叉熵。
对于“真实值”,我们将使用 batch_ix[:, 1:]
- 一个向前移动一步的标记索引矩阵。
batch_ix = _Abigail
logits = Abigail_
batch_ix[1:] logits[:-1]
# 对除了最后一个预测值之外的所有 logits 计算对数 softmax,因为在序列的末尾我们不需要进行任何预测
predictions_logp = F.log_softmax(logits[:, :-1], dim=-1)
# 我们的正确答案(从第 2 步开始的所有内容,不包括第一步,因为在开始时我们处于初始状态,没有进行任何预测)
actual_next_tokens = batch_ix[:, 1:]
# 根据预测值和真实值计算损失函数
loss = criterion(
# 将预测值展平为一个批次(即从维度 (batch, time, prob) 转换为 (batch*time, prob))
predictions_logp.contiguous().view(-1, num_tokens),
# 将真实值展平
actual_next_tokens.contiguous().view(-1)
)
# 进行反向传播,计算梯度
loss.backward()
模型训练
下一步是训练我们的模型进行分类任务。
训练过程本身与之前的几乎没有什么不同。唯一的区别是,这次我们对可变长度的字符串进行采样,然后在单个批次中将它们转换为单一长度:
model = CharRNNLoop()
opt = torch.optim.Adam(model.parameters())
criterion = nn.NLLLoss() # 使用 Adam 优化器
# 测量代码执行时间的魔法命令
%time
# 最大长度设置为 16
MAX_LENGTH = 16
# 用于存储每次迭代损失值的列表
history = []
for i in range(1000): # 总共进行 1000 次迭代
# 从所有名字中随机选择 32 个元素,并将其转换为适合模型输入的矩阵形式,最大长度为 MAX_LENGTH
batch_ix = to_matrix(sample(names, 32), max_len=MAX_LENGTH)
# 将矩阵转换为 PyTorch 的 LongTensor 类型
batch_ix = torch.LongTensor(batch_ix)
# 将批次数据输入模型,得到 logits(未经过 softmax 处理的预测值)
logits = model(batch_ix)
# 计算损失
# 对除了最后一个预测值之外的 logits 计算对数 softmax,因为序列末尾无需预测
predictions_logp = F.log_softmax(logits[:, :-1], dim=-1)
# 真实的下一个标记(即从第二个位置开始的标记,因为第一个位置是起始状态无需预测)
actual_next_tokens = batch_ix[:, 1:]
# 根据预测值和真实值计算损失函数
loss = criterion(
# 将预测值展平为一个批次(从维度 (batch, time, prob) 转换为 (batch*time, prob))
predictions_logp.contiguous().view(-1, num_tokens),
# 将真实值展平
actual_next_tokens.contiguous().view(-1)
)
# 使用反向传播进行训练
loss.backward() # 计算梯度
opt.step() # 更新模型参数(执行一步优化)
opt.zero_grad() # 清空梯度,为下一次迭代做准备
# 绘制图形的代码
# 将本次迭代的损失值添加到 history 列表中
history.append(loss.item())
if (i + 1) % 100 == 0: # 每 100 步更新一次图形
clear_output(True) # 清除之前的输出
plt.plot(history, label='loss') # 绘制损失值随迭代次数的变化曲线
plt.legend() # 显示图例
plt.show() # 显示图形
# 断言检查:检查模型是否收敛,即最后 10 步的平均损失值是否小于前 10 步的平均损失值
# 如果不是,则说明模型没有收敛
assert np.mean(history[:10]) > np.mean(history[-10:]), "RNN didn't converge."
输出:
名称生成
在训练语言模型之后(训练好的神经网络就是语言模型),我们开始进行数据生成。为此,我们将使用以下函数:
def generate_sample(model, seed_phrase=' ', max_length=MAX_LENGTH, temperature=1.0):
'''
该函数根据至少长度为 SEQ_LENGTH 的短语生成文本。
:param seed_phrase: 前缀字符。RNN 将继续生成该短语之后的内容
:param max_length: 最大输出长度,包括种子短语
:param temperature: 采样系数。温度越高,输出越随机;温度越低,输出越倾向于最可能的结果
'''
# 将种子短语转换为标记索引的序列
x_sequence = [token_to_id[token] for token in seed_phrase]
# 将序列转换为 PyTorch 张量
x_sequence = torch.tensor([x_sequence], dtype=torch.int64)
# 开始生成文本
# 循环直到达到最大长度(减去种子短语的长度)
for _ in range(max_length - len(seed_phrase)):
# 将当前序列输入模型,得到 logits(未经过 softmax 处理的预测值)
logits = model(x_sequence)
# 计算下一个标记的概率分布,通过对 logits 应用 softmax 函数
# temperature 控制采样的随机性,是一个超参数
p_next = F.softmax(logits / temperature, dim=-1).data.numpy()[0][-1]
# 从所有标记中,根据概率分布 p_next 随机采样下一个标记的索引
next_ix = np.random.choice(num_tokens, p=p_next)
# 将采样得到的索引转换为 PyTorch 张量
next_ix = torch.tensor([[next_ix]], dtype=torch.int64)
# 将采样得到的下一个标记的索引添加到当前序列中
x_sequence = torch.cat([x_sequence, next_ix], dim=1)
# 将生成的索引序列转换为对应的标记,并拼接成字符串返回
return ''.join([tokens[ix] for ix in x_sequence.data.numpy()[0]])
让我们看一些随机的例子:
for _ in range(10):
print(generate_sample(model, temperature=1.))
输出:
for _ in range(10):
print(generate_sample(model, temperature=.001))
输出:
for _ in range(10):
print(generate_sample(model, temperature=1000))
输出:
我们还可以指定一些子字符串来初始化模型的初始状态:
for _ in range(50):
print(generate_sample(model, seed_phrase=' Ale', temperature=1.))
输出:
语言模型的附加功能
温度(temperature)
在上面的例子中,我们使用了“generate_sample”函数来生成名称。此函数使用一个我们称之为温度的神奇参数。温度可以被认为是负责某些随机性的超参数。它允许在从对数形成概率时增加熵(即数据的传播)。这些值越高,最终概率的值就越接近零。例如,如果我们得到 logit 值 [1, 2, 3]
,并且温度值为 1000,那么我们将 logit(除以温度)转换为格式
[
1
e
−
3
,
2
e
−
3
,
3
e
−
3
]
[1e^{-3}, 2e^{-3}, 3e^{-3}]
[1e−3,2e−3,3e−3]。这些值之间的差异并不是很大。接下来,我们应用 Softmax 函数(指数),我们得到大致相同的概率值。相反,如果我们取较小的温度值,即使温度值非常相似,最终的概率也会有很大差异。
让我们看看最终结果如何取决于温度:
for _ in range(50):
print(generate_sample(model, seed_phrase=' Serg', temperature=.1))
输出:
for _ in range(50):
print(generate_sample(model, seed_phrase=' Serg', temperature=10))
输出:
我们发现,在低温下,生成的名称通常与真实名称相似。但同时,会生成大致相同的 token,因为它们的概率大致相同。而较高的温度值会增加概率之间的差异,从而导致“垃圾”的产生。因此,这个超参数的选择对最终结果有很大的影响。
你可以用不同的温度值练习,并思考在这个问题中哪个值是最优的,以及为什么会出现这种情况。
异常
我们在解决语言建模问题时面临的另一个问题是,经典循环神经网络对于源数据中的异常值非常不稳定。这与“忘记”旧信息的概念有关。
所以,我们可以尝试预测以某些“垃圾”结尾的输入序列的名称:
for _ in range(50):
print(generate_sample(model, seed_phrase=' SergAAA', temperature=1.))
输出:
我们看到我们的模型也开始产生一些垃圾。这是因为 RNN“忘记”了前面的字符,而关注最后的字符。我们在文本的末尾放置一些 A 符号,网络会“忘记”之前出现的符号,试图预测接下来会出现什么符号,认为接下来也会有 A 符号,然后尝试生成一些随机符号。
可以使用更强大的神经网络架构来解决此问题,例如我们在上一课中讨论过的 LSTM:
class CharLSTMLoop(nn.Module):
def __init__(self, num_tokens=num_tokens, emb_size=16, rnn_num_units=64):
super(self.__class__, self).__init__()
self.emb = nn.Embedding(num_tokens, emb_size)
self.rnn = nn.LSTM(emb_size, rnn_num_units, batch_first=True, num_layers=3)
self.hid_to_logits = nn.Linear(rnn_num_units, num_tokens)
def forward(self, x):
h_seq, _ = self.rnn(self.emb(x))
next_logits = self.hid_to_logits(h_seq)
return next_logits
model_lstm = CharLSTMLoop()
opt_lstm = torch.optim.Adam(model_lstm.parameters())
criterion_lstm = nn.NLLLoss()
# 设定序列的最大长度为 16
MAX_LENGTH = 16
# 用于存储每次迭代损失值的列表
history = []
# 进行 1000 次迭代训练
for i in range(1000):
# 从所有名字中随机抽取 32 个名字,将它们转换为矩阵形式,矩阵的最大长度为 MAX_LENGTH
batch_ix = to_matrix(sample(names, 32), max_len=MAX_LENGTH)
# 将矩阵转换为 PyTorch 的 LongTensor 类型,以便可以作为模型的输入
batch_ix = torch.LongTensor(batch_ix)
# 将批次数据输入到 LSTM 模型中,得到模型输出的 logits(未经过 softmax 处理的预测值)
logits_lstm = model_lstm(batch_ix)
# 计算损失
# 对 logits 去掉最后一个元素后进行对数 softmax 操作,得到预测的对数概率
predictions_logp_lstm = F.log_softmax(logits_lstm[:, :-1], dim=-1)
# 实际的下一个标记序列,从输入序列的第二个元素开始,作为真实标签
actual_next_tokens = batch_ix[:, 1:]
# 使用损失函数计算预测值和真实值之间的损失
loss_lstm = criterion_lstm(
# 将预测的对数概率展平为一维向量,方便计算损失
predictions_logp_lstm.contiguous().view(-1, num_tokens),
# 将真实的下一个标记序列展平为一维向量
actual_next_tokens.contiguous().view(-1)
)
# 基于反向传播算法进行模型训练
# 计算损失相对于模型参数的梯度
loss_lstm.backward()
# 根据计算得到的梯度,使用优化器更新模型的参数
opt_lstm.step()
# 将优化器中的梯度清零,为下一次迭代做准备
opt_lstm.zero_grad()
# 绘制损失变化曲线的代码
# 将当前迭代的损失值添加到 history 列表中
history.append(loss_lstm.item())
# 每迭代 100 次,更新一次损失变化曲线的显示
if (i + 1) % 100 == 0:
# 清除之前的输出,以便更新图形
clear_output(True)
# 绘制损失随迭代次数变化的曲线
plt.plot(history, label='loss')
# 显示图例
plt.legend()
# 显示图形
plt.show()
# 进行断言检查,确保模型收敛
# 比较前 10 次迭代的平均损失和最后 10 次迭代的平均损失
# 如果最后 10 次的平均损失不小于前 10 次的平均损失,说明模型可能没有收敛,抛出异常
assert np.mean(history[:10]) > np.mean(history[-10:]), "RNN didn't converge."
输出:
for _ in range(50):
print(generate_sample(model_lstm, seed_phrase=' SergAAA', temperature=1.))
输出:
我们看到这个模型不再像以前那样产生“垃圾”。她在结尾处看到了 A 符号并意识到那是一些垃圾。并且他认为在这种情况下完成名字的生成是必要的。您可以使用温度参数和 LSTM 层的尺寸进行练习,看看模型如何工作。
BPE
另一种可用于提高网络质量的有趣方法是 n-gram 编码。最流行的方法是二元编码(字节对编码,BPE)。
它基于这样的思想:对序列中的单个标记进行编码,而对 n 个连续标记的整个序列组(n-gram)进行编码。
让我们考虑一个二元编码的例子。
假设我们有一个标记(符号)序列:
aaabdaaabac
我们看到它经常重复两个连续的“a”标记。然后让我们用一个新标记替换标记对 aa
,我们称之为 Z
:
ZabdZabac
Z=aa
类似地,您可以通过用新的“Y”标记替换“ab”对来重复此过程:
ZYdZYac
Y=ab
Z=aa
最后,我们可以对新标记重复此过程,用新的“X”标记替换“ZY”:
XdXac
X=ZY
Y=ab
Z=aa
现在我们看到序列的大小已经减小了。因此,网络将运行得更快。我们可以针对新的标记序列“XdXac”训练并应用该模型(相应地,针对将添加标记“X”的新词典)。自然,在生成过程中我们的新令牌“X”也将被生成。因此,生成之后,我们必须执行逆变换,根据我们的规则替换标记 X:
X=ZY
Y=ab
Z=aa
实践表明,这种方法在现代模型中也非常有效。
语言建模的一般任务
到目前为止,我们只讨论了前向语言建模的任务,尝试根据前一个标记来预测下一个标记。
但没有什么可以阻止我们根据后续标记预测前一个标记。这就导致了逆向语言建模的问题。它还用于许多任务。
您还可以结合这两种方法并尝试生成序列中缺失的标记。这给我们带来了掩蔽语言建模的问题。在这种情况下,我们用特殊技术标记(掩码)替换一个或多个标记:
妈妈<MASK>
洗了镜框。
首先,我们尝试根据前面的标记(妈妈)来预测标记<MASK>
,然后根据后续的标记(洗了镜框)来预测标记。然后结合得到的概率分布并最终决定应该插入哪个标记来代替掩码。这种方法已用于更现代的模型中。比如我们下一课会讲到的BERT。