本章内容涵盖
- 将英语和法语短语分词为子词
- 理解词嵌入和位置编码
- 从零训练Transformer模型,实现英法翻译
- 使用训练好的Transformer将英文短语翻译成法文
在上一章中,我们基于论文《Attention Is All You Need》从零构建了一个可用于任意两种语言翻译的Transformer模型。具体实现了自注意力机制,利用查询(query)、键(key)、值(value)向量计算缩放点积注意力(Scaled Dot Product Attention, SDPA)。
为了更深入理解自注意力和Transformer结构,本章以英译法为案例进行讲解。通过训练模型将英文句子转换为法文,你将深入了解Transformer的架构及注意力机制的工作原理。
假设你已经收集了超过4.7万个英法翻译对。你的目标是利用这套数据集训练上一章中的编码器-解码器Transformer模型。本章将引导你完成项目的各个阶段。你将首先使用子词分词方法,将英法短语拆分成词元。接着构建英语和法语词汇表,包含各自语言中所有唯一词元。词汇表可帮助你将短语表示为索引序列。随后,利用词嵌入将这些索引(本质上是独热向量)转换成紧凑的向量表示。我们还将为词嵌入添加位置编码,形成输入嵌入,位置编码使Transformer能理解序列中词元的顺序。
最后,你将用英法翻译对数据集训练第9章中的编码器-解码器Transformer模型。训练完成后,你将学会使用训练好的Transformer翻译常用英文短语为法文。具体来说,先用编码器捕捉英文短语的语义,再用解码器以自回归方式生成法语翻译,解码器从开始标记“BOS”起步,每步根据之前生成的词元和编码器输出,预测最可能的下一个词元,直到预测出结束标记“EOS”,表示句子结束。训练好的模型能够准确翻译常用英语短语,效果媲美使用Google翻译。
10.1 子词分词
如第8章所述,分词方法主要有三种:字符级分词、词级分词和子词分词。本章我们采用子词分词,它在两者之间取得平衡,能将常用词完整保留于词表中,而将较少用或复杂词拆分为子部分。
本节你将学习如何将英语和法语短语切分为子词,并创建字典将词元映射为索引。然后训练数据将被转换为索引序列,并分批用于训练。
10.1.1 英语和法语短语分词
访问 mng.bz/WVAw 下载我从多来源收集的英法翻译数据压缩包。解压后,将en2fr.csv放入电脑的/files/文件夹。
我们加载数据,并打印一对英法短语:
import pandas as pd
df = pd.read_csv("files/en2fr.csv") # ①
num_examples = len(df) # ②
print(f"there are {num_examples} examples in the training data")
print(df.iloc[30856]["en"]) # ③
print(df.iloc[30856]["fr"]) # ④
① 加载CSV文件
② 统计短语对数量
③ 打印示例英语短语
④ 打印对应法语翻译
输出示例:
there are 47173 examples in the training data
How are you?
Comment êtes-vous?
训练数据包含47,173对英法短语。示例中英语短语“How are you?”及其法语对应“Comment êtes-vous?”。
在Jupyter Notebook新单元格运行:
!pip install transformers
安装transformers库。
接下来,我们使用Hugging Face的预训练XLM模型作为分词器,因其擅长多语言处理,包括英语和法语。
代码清单10.1 预训练分词器示例:
from transformers import XLMTokenizer # ①
tokenizer = XLMTokenizer.from_pretrained("xlm-clm-enfr-1024")
tokenized_en = tokenizer.tokenize("I don't speak French.") # ②
print(tokenized_en)
tokenized_fr = tokenizer.tokenize("Je ne parle pas français.") # ③
print(tokenized_fr)
print(tokenizer.tokenize("How are you?"))
print(tokenizer.tokenize("Comment êtes-vous?"))
① 导入预训练分词器
② 英语句子分词
③ 法语句子分词
输出示例:
['i</w>', 'don</w>', "'t</w>", 'speak</w>', 'fr', 'ench</w>', '.</w>']
['je</w>', 'ne</w>', 'parle</w>', 'pas</w>', 'franc', 'ais</w>', '.</w>']
['how</w>', 'are</w>', 'you</w>', '?</w>']
['comment</w>', 'et', 'es-vous</w>', '?</w>']
XLM模型使用</w>
作为词元分隔符,词元通常是完整单词或标点符号,有时会拆分成音节(如“French”拆为“fr”和“ench”,且不在两部分间插入</w>
,因其构成完整单词)。
深度学习模型无法直接处理文本,需将文本转换为数值表示。接下来为所有英语词元建立映射索引的字典。
代码清单10.2 英语词元映射索引:
from collections import Counter
en = df["en"].tolist() # ①
en_tokens = [["BOS"] + tokenizer.tokenize(x) + ["EOS"] for x in en] # ②
PAD = 0
UNK = 1
word_count = Counter()
for sentence in en_tokens:
for word in sentence:
word_count[word] += 1
frequency = word_count.most_common(50000) # ③
total_en_words = len(frequency) + 2
en_word_dict = {w[0]: idx + 2 for idx, w in enumerate(frequency)} # ④
en_word_dict["PAD"] = PAD
en_word_dict["UNK"] = UNK
en_idx_dict = {v: k for k, v in en_word_dict.items()} # ⑤
① 获取所有英语句子
② 分词并在句首尾添加“BOS”和“EOS”
③ 统计词频
④ 创建词元到索引映射字典
⑤ 创建索引到词元的反向映射字典
利用en_word_dict
,我们可将英语句子“I don’t speak French.”转为索引序列:
enidx = [en_word_dict.get(i, UNK) for i in tokenized_en]
print(enidx)
输出:
[15, 100, 38, 377, 476, 574, 5]
我们还可利用en_idx_dict
将索引转换回词元:
entokens = [en_idx_dict.get(i, "UNK") for i in enidx] # ①
print(entokens)
en_phrase = "".join(entokens) # ②
en_phrase = en_phrase.replace("</w>", " ") # ③
for x in '''?:;.,'("-!&)%''':
en_phrase = en_phrase.replace(f" {x}", f"{x}") # ④
print(en_phrase)
① 索引转词元
② 合并为字符串
③ 将</w>
替换为空格
④ 去除标点前多余空格
输出:
['i</w>', 'don</w>', "'t</w>", 'speak</w>', 'fr', 'ench</w>', '.</w>']
i don't speak french.
请注意,恢复的英语句子全为小写,因预训练分词器会自动将大写字母转为小写以减少词元数量。后续章节你将看到部分模型(如GPT2和ChatGPT)不会这样做,因此词汇表更大。
练习10.1
在代码清单10.1中,将“How are you?”分词为['how</w>', 'are</w>', 'you</w>', '?</w>']
。请参照本节步骤:(i)用en_word_dict
将词元转索引;(ii)用en_idx_dict
将索引转回词元;(iii)恢复英语句子,连接词元字符串,将</w>
换成空格,并去除标点前空格。
同样步骤适用于法语词元和索引的映射。
代码清单10.3 法语词元映射索引:
fr = df["fr"].tolist()
fr_tokens = [["BOS"] + tokenizer.tokenize(x) + ["EOS"] for x in fr] # ①
word_count = Counter()
for sentence in fr_tokens:
for word in sentence:
word_count[word] += 1
frequency = word_count.most_common(50000) # ②
total_fr_words = len(frequency) + 2
fr_word_dict = {w[0]: idx + 2 for idx, w in enumerate(frequency)} # ③
fr_word_dict["PAD"] = PAD
fr_word_dict["UNK"] = UNK
fr_idx_dict = {v: k for k, v in fr_word_dict.items()} # ④
① 法语句子分词
② 统计词频
③ 生成法语词元到索引映射
④ 生成索引到法语词元反向映射
将法语句子“Je ne parle pas français.”转为索引序列:
fridx = [fr_word_dict.get(i, UNK) for i in tokenized_fr]
print(fridx)
输出:
[28, 40, 231, 32, 726, 370, 4]
利用fr_idx_dict
可将索引转回法语词元,拼接成原始法语句:
frtokens = [fr_idx_dict.get(i, "UNK") for i in fridx]
print(frtokens)
fr_phrase = "".join(frtokens)
fr_phrase = fr_phrase.replace("</w>", " ")
for x in '''?:;.,'("-!&)%''':
fr_phrase = fr_phrase.replace(f" {x}", f"{x}")
print(fr_phrase)
输出:
['je</w>', 'ne</w>', 'parle</w>', 'pas</w>', 'franc', 'ais</w>', '.</w>']
je ne parle pas francais.
请注意,恢复的法语句子不完全与原句匹配,因分词时会将大写转小写,并去除法语重音符号。
练习10.2
在代码清单10.1中,“Comment êtes-vous?”被分为['comment</w>', 'et', 'es-vous</w>', '?</w>']
。请按照本节步骤:(i)用fr_word_dict
将词元转索引;(ii)用fr_idx_dict
将索引转词元;(iii)将词元合并成句子,替换</w>
为空间,去除标点前空格。
将上述四个字典保存到电脑的/files/文件夹,方便后续加载使用,无需每次重新映射:
import pickle
with open("files/dict.p", "wb") as fb:
pickle.dump((en_word_dict, en_idx_dict,
fr_word_dict, fr_idx_dict), fb)
四个字典已保存为单个pickle文件dict.p,你也可以从本书GitHub仓库下载该文件。
10.1.2 序列填充与批次创建
为了计算效率和加速收敛,训练时我们会将数据分成批次,就像前几章所做的那样。
对于图片等其他数据格式,创建批次较为简单:只需将固定数量的输入合并成一个批次,因为它们尺寸相同。但在自然语言处理中,句子长度不一,批次处理会更复杂。为统一批次内序列长度,我们对较短序列进行填充。保持输入数值表示长度一致对于Transformer至关重要。例如,一个批次中的英语短语长度可能不同(法语短语也一样),我们通过在短序列末尾添加零,使所有输入序列长度相同。
注意:在机器翻译中,在句首尾加入BOS和EOS标记,以及对批次内短序列进行填充,是其特点。这是因为输入是完整句子或短语。相比之下,后续两章讲到的文本生成模型训练中,输入序列长度是固定的,不涉及此类操作。
我们首先将所有英语短语转换为数值表示,法语短语同理:
out_en_ids = [[en_word_dict.get(w, UNK) for w in s] for s in en_tokens]
out_fr_ids = [[fr_word_dict.get(w, UNK) for w in s] for s in fr_tokens]
sorted_ids = sorted(range(len(out_en_ids)), key=lambda x: len(out_en_ids[x]))
out_en_ids = [out_en_ids[x] for x in sorted_ids]
out_fr_ids = [out_fr_ids[x] for x in sorted_ids]
接着,将数值序列分批:
import numpy as np
batch_size = 128
idx_list = np.arange(0, len(en_tokens), batch_size)
np.random.shuffle(idx_list)
batch_indexs = []
for idx in idx_list:
batch_indexs.append(np.arange(idx, min(len(en_tokens), idx + batch_size)))
注意,在分批前,我们按英语短语长度对数据进行了排序,保证同一批次内序列长度相近,从而减少填充需求,节省训练数据体积,加快训练速度。
为了将批次内序列填充到相同长度,我们定义如下函数:
def seq_padding(X, padding=0):
L = [len(x) for x in X]
ML = max(L) # ①
padded_seq = np.array([np.concatenate([x, [padding] * (ML - len(x))])
if len(x) < ML else x for x in X]) # ②
return padded_seq
① 找出批次内最长序列长度
② 对长度不足的序列末尾添加0填充
该函数先确定批次内最长序列长度,然后为较短序列末尾补零,使批内所有序列长度一致。
为了节省空间,我们在上一章下载的本地模块ch09util.py
中创建了一个Batch()类(见图10.1)。
代码清单10.4 本地模块中Batch()类实现:
import torch
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
class Batch:
def __init__(self, src, trg=None, pad=0):
src = torch.from_numpy(src).to(DEVICE).long()
self.src = src
self.src_mask = (src != pad).unsqueeze(-2) # ①
if trg is not None:
trg = torch.from_numpy(trg).to(DEVICE).long()
self.trg = trg[:, :-1] # ②
self.trg_y = trg[:, 1:] # ③
self.trg_mask = make_std_mask(self.trg, pad) # ④
self.ntokens = (self.trg_y != pad).data.sum()
① 创建源序列掩码,屏蔽填充部分
② 创建解码器输入(去掉目标序列最后一个词元)
③ 创建目标输出(去掉目标序列第一个词元,实现预测位移)
④ 创建目标序列掩码
图10.1 Batch()类的作用
Batch()类接收两个输入:src和trg,分别是源语言和目标语言的索引序列。它为训练数据添加了若干属性:src_mask(源序列掩码,用于隐藏填充部分)、修改后的trg(解码器输入)、trg_y(解码器输出)、trg_mask(目标序列掩码,用于隐藏填充和未来词元)。
Batch()类将一批英文和法文短语转换为适合训练的格式。举例来说,英文短语“How are you?”及对应法语“Comment êtes-vous?”,Batch()类的src输入是“How are you?”对应的词元索引序列,trg输入是“Comment êtes-vous?”对应的词元索引序列。该类生成一个张量src_mask,用来隐藏句尾填充部分。例如,“How are you?”拆成六个词元:['BOS', 'how', 'are', 'you', '?', 'EOS']。如果批次最大长度为8,则在末尾补两个0,src_mask指示模型忽略这两个填充词元。
Batch()类还准备Transformer解码器的输入和输出。法语短语“Comment êtes-vous?”拆成六个词元:['BOS', 'comment', 'et', 'es-vous', '?', 'EOS']。解码器输入trg是前五个词元的索引,输出trg_y是将输入向右移一位后的序列,即输入为['BOS', 'comment', 'et', 'es-vous', '?'],输出为['comment', 'et', 'es-vous', '?', 'EOS']。此设计与第8章一致,旨在强制模型基于已有词元预测下一个词元。
Batch()类还为解码器输入生成掩码trg_mask,目的是屏蔽后续词元,保证模型仅依赖先前词元进行预测。该掩码由本地模块ch09util中定义的make_std_mask()函数生成:
import numpy as np
def subsequent_mask(size):
attn_shape = (1, size, size)
subsequent_mask = np.triu(np.ones(attn_shape), k=1).astype('uint8')
output = torch.from_numpy(subsequent_mask) == 0
return output
def make_std_mask(tgt, pad):
tgt_mask = (tgt != pad).unsqueeze(-2)
output = tgt_mask & subsequent_mask(tgt.size(-1)).type_as(tgt_mask.data)
return output
subsequent_mask()函数生成一个序列掩码,确保模型只关注实际序列,忽略末尾的填充零。make_std_mask()函数生成目标序列的标准掩码,既遮挡填充部分也遮挡未来词元。
接下来,从本地模块导入Batch()类,并用它创建训练批次:
from utils.ch09util import Batch
class BatchLoader():
def __init__(self):
self.idx = 0
def __iter__(self):
return self
def __next__(self):
self.idx += 1
if self.idx <= len(batch_indexs):
b = batch_indexs[self.idx - 1]
batch_en = [out_en_ids[x] for x in b]
batch_fr = [out_fr_ids[x] for x in b]
batch_en = seq_padding(batch_en)
batch_fr = seq_padding(batch_fr)
return Batch(batch_en, batch_fr)
raise StopIteration
BatchLoader()类用于生成训练数据批次。列表中的每个批次包含128对短语,每对包含一条英文短语及其对应法语翻译的数值表示。
10.2 词嵌入与位置编码
在上一节的分词后,英语和法语短语已表示为索引序列。本节中,你将使用词嵌入将这些索引(本质是独热向量)转换为紧凑的向量表示,从而捕捉词元的语义信息及其相互关系。词嵌入还能提升训练效率:相比庞大的独热向量,词嵌入采用连续的低维向量,降低了模型复杂度和维度。
注意力机制同时处理短语中的所有词元,而非顺序处理,这提升了效率,但本身无法识别词元顺序。因此,我们用不同频率的正弦和余弦函数为输入嵌入添加位置编码。
10.2.1 词嵌入
英语和法语短语的数值表示包含大量索引。通过统计en_word_dict和fr_word_dict中字典元素数目,得出两种语言词汇中唯一词元的数量(后续将作为Transformer输入):
src_vocab = len(en_word_dict)
tgt_vocab = len(fr_word_dict)
print(f"there are {src_vocab} distinct English tokens")
print(f"there are {tgt_vocab} distinct French tokens")
输出:
there are 11055 distinct English tokens
there are 11239 distinct French tokens
数据集中英语有11,055个唯一词元,法语有11,239个。若采用独热编码,训练参数过多。为此,我们采用词嵌入,将数值表示压缩成长度为d_model = 256
的连续向量。
实现通过本地模块ch09util
中定义的Embeddings()
类:
import math
import torch.nn as nn
class Embeddings(nn.Module):
def __init__(self, d_model, vocab):
super().__init__()
self.lut = nn.Embedding(vocab, d_model)
self.d_model = d_model
def forward(self, x):
out = self.lut(x) * math.sqrt(self.d_model)
return out
Embeddings()
类调用了PyTorch的nn.Embedding()
,并将输出乘以d_model
的平方根(256),以抵消后续计算注意力分数时除以该值的影响。该类有效降低了英语和法语数值表示的维度。第8章中详细介绍了PyTorch的nn.Embedding()
。
10.2.2 位置编码
为准确表示输入和输出序列中元素的顺序,引入本地模块中的PositionalEncoding()
类:
class PositionalEncoding(nn.Module):
def __init__(self, d_model, dropout, max_len=5000): # ①
super().__init__()
self.dropout = nn.Dropout(p=dropout)
pe = torch.zeros(max_len, d_model, device=DEVICE)
position = torch.arange(0., max_len, device=DEVICE).unsqueeze(1)
div_term = torch.exp(torch.arange(0., d_model, 2, device=DEVICE) * -(math.log(10000.0) / d_model))
pe_pos = torch.mul(position, div_term)
pe[:, 0::2] = torch.sin(pe_pos) # ②
pe[:, 1::2] = torch.cos(pe_pos) # ③
pe = pe.unsqueeze(0)
self.register_buffer('pe', pe)
def forward(self, x):
x = x + self.pe[:, :x.size(1)].requires_grad_(False) # ④
out = self.dropout(x)
return out
① 初始化类,支持最多5,000个位置
② 对偶数索引位置应用正弦函数
③ 对奇数索引位置应用余弦函数
④ 将位置编码加到词嵌入上
PositionalEncoding()
类使用正弦函数对偶数位置编码,余弦函数对奇数位置编码。requires_grad_(False)
表明位置编码不参与训练,保持常数。
举例,英语短语“How are you?”的6个词元['BOS', 'how', 'are', 'you', '?', 'EOS']
,经过词嵌入层后,形状为(1, 6, 256):批次大小1,序列长度6,词嵌入维度256。然后通过PositionalEncoding()
类计算对应位置的编码,提供位置信息。
以下代码展示了位置编码的具体数值:
from utils.ch09util import PositionalEncoding
import torch
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
pe = PositionalEncoding(256, 0.1) # ①
x = torch.zeros(1, 8, 256).to(DEVICE) # ②
y = pe.forward(x) # ③
print(f"the shape of positional encoding is {y.shape}")
print(y) # ④
① 实例化位置编码,模型维度256,dropout率0.1
② 创建全零词嵌入张量
③ 计算输入嵌入(词嵌入+位置编码)
④ 输出张量形状及具体值
输出:
the shape of positional encoding is torch.Size([1, 8, 256])
tensor([[[ 0.0000e+00, 1.1111e+00, 0.0000e+00, ..., 0.0000e+00,
0.0000e+00, 1.1111e+00],
[ 9.3497e-01, 6.0034e-01, 8.9107e-01, ..., 1.1111e+00,
1.1940e-04, 1.1111e+00],
...
[ 7.2999e-01, 8.3767e-01, 2.5419e-01, ..., 1.1111e+00,
8.3581e-04, 1.1111e+00]]], device='cuda:0')
该张量即为“How are you?”的位置信息编码,形状(1, 6, 256),与词嵌入形状匹配。位置编码的一个重要特性是对任意输入序列,位置1的编码始终相同,位置2亦然,且其值在训练中保持不变。
10.3 训练用于英法翻译的 Transformer
我们构建的英法翻译模型可以看作是一个多类别分类器,核心任务是在翻译英语句子时预测法语词汇表中的下一个词元。这与第2章讨论的图像分类项目有些类似,但模型复杂度显著更高,因此需要谨慎选择损失函数、优化器及训练参数。
本节详细介绍选择合适的损失函数和优化器的过程。我们将使用英法翻译批次数据训练Transformer。训练完成后,你将学会用训练好的模型将常见英语短语翻译成法语。
10.3.1 损失函数与优化器
首先,从本地模块ch09util.py
导入create_model()
函数,构建Transformer模型,用于训练英法翻译:
from utils.ch09util import create_model
model = create_model(src_vocab, tgt_vocab, N=6,
d_model=256, d_ff=1024, h=8, dropout=0.1)
“Attention Is All You Need”论文中使用了多种超参数组合。这里我们选择256维的模型大小和8个头,因为这个组合在我们的任务中效果不错。感兴趣的读者可以用验证集调参,选择最优模型。
训练时采用论文中的标签平滑(label smoothing)技术。标签平滑在深度神经网络训练中常用来改善模型泛化能力,解决过度自信和分类过拟合问题。它通过调整目标标签来降低模型对训练数据的置信度,从而提升在未见数据上的表现。
通常分类任务中,目标标签为独热编码,意味着对每个样本的标签绝对确定。绝对确定的训练可能导致两个问题:一是过拟合,模型对训练数据过于自信,影响新数据表现;二是校准差,模型输出的概率往往过高,不符合实际置信度。
标签平滑让目标标签变得“不那么自信”,例如三分类任务中,目标标签由[1, 0, 0]变为[0.9, 0.05, 0.05]。这种方式惩罚过度自信的预测。平滑标签是原始标签与其他标签分布(通常为均匀分布)的混合。
我们在本地模块ch09util
定义了如下LabelSmoothing()
类:
class LabelSmoothing(nn.Module):
def __init__(self, size, padding_idx, smoothing=0.1):
super().__init__()
self.criterion = nn.KLDivLoss(reduction='sum')
self.padding_idx = padding_idx
self.confidence = 1.0 - smoothing
self.smoothing = smoothing
self.size = size
self.true_dist = None
def forward(self, x, target):
assert x.size(1) == self.size
true_dist = x.data.clone() # ①
true_dist.fill_(self.smoothing / (self.size - 2))
true_dist.scatter_(1,
target.data.unsqueeze(1), self.confidence) # ②
true_dist[:, self.padding_idx] = 0
mask = torch.nonzero(target.data == self.padding_idx)
if mask.dim() > 0:
true_dist.index_fill_(0, mask.squeeze(), 0.0)
self.true_dist = true_dist
output = self.criterion(x, true_dist.clone().detach()) # ③
return output
① 克隆模型预测值
② 对训练标签加噪声,生成平滑标签
③ 用平滑标签计算损失
LabelSmoothing()
先获取模型预测,再对训练标签加噪,平滑程度由smoothing
控制,比如smoothing=0.1
时,[1,0,0]变为[0.9,0.05,0.05]。之后计算预测和标签间的损失。
优化器使用Adam,但训练过程中采用动态学习率,而非固定值。定义本地模块中的NoamOpt()
类实现:
class NoamOpt:
def __init__(self, model_size, factor, warmup, optimizer):
self.optimizer = optimizer
self._step = 0
self.warmup = warmup # ①
self.factor = factor
self.model_size = model_size
self._rate = 0
def step(self): # ②
self._step += 1
rate = self.rate()
for p in self.optimizer.param_groups:
p['lr'] = rate
self._rate = rate
self.optimizer.step()
def rate(self, step=None): # ③
if step is None:
step = self._step
output = self.factor * (self.model_size ** (-0.5) *
min(step ** (-0.5), step * self.warmup ** (-1.5)))
return output
① 设定预热步数
② 通过step()
调整模型参数
③ 根据步数计算当前学习率
NoamOpt()
实现了预热学习率策略,初期线性增大学习率,预热结束后,学习率按训练步数的逆平方根衰减。
创建训练优化器:
from utils.ch09util import NoamOpt
optimizer = NoamOpt(256, 1, 2000, torch.optim.Adam(
model.parameters(), lr=0, betas=(0.9, 0.98), eps=1e-9))
定义训练损失计算的SimpleLossCompute()
类:
class SimpleLossCompute:
def __init__(self, generator, criterion, opt=None):
self.generator = generator
self.criterion = criterion
self.opt = opt
def __call__(self, x, y, norm):
x = self.generator(x) # ①
loss = self.criterion(x.contiguous().view(-1, x.size(-1)),
y.contiguous().view(-1)) / norm # ②
loss.backward() # ③
if self.opt is not None:
self.opt.step() # ④
self.opt.optimizer.zero_grad()
return loss.data.item() * norm.float()
① 模型预测
② 计算带标签平滑的损失
③ 计算梯度
④ 优化模型参数
SimpleLossCompute()
接收生成器(预测模型)、损失函数和优化器。它对一批训练数据计算预测,比较标签(带平滑),求梯度并更新参数。
定义损失函数:
from utils.ch09util import (LabelSmoothing, SimpleLossCompute)
criterion = LabelSmoothing(tgt_vocab, padding_idx=0, smoothing=0.1)
loss_func = SimpleLossCompute(model.generator, criterion, optimizer)
接下来,使用本章准备的数据训练Transformer。
10.3.2 训练循环
我们也可以将训练数据划分为训练集和验证集,直到模型在验证集上的性能不再提升时停止训练,类似于第2章的方法。但为了节省空间,我们将模型训练100个epoch。训练过程中,我们会统计每个批次的损失值和词元数量。每个epoch结束后,通过总损失除以总词元数,计算该epoch的平均损失。
代码示例如下:
for epoch in range(100):
model.train()
tloss = 0
tokens = 0
for batch in BatchLoader():
out = model(batch.src, batch.trg,
batch.src_mask, batch.trg_mask) # ①
loss = loss_func(out, batch.trg_y, batch.ntokens) # ②
tloss += loss
tokens += batch.ntokens # ③
print(f"Epoch {epoch}, average loss: {tloss / tokens}")
torch.save(model.state_dict(), "files/en2fr.pth") # ④
① 使用Transformer模型进行预测
② 计算损失并调整模型参数
③ 统计该批次的词元数
④ 训练完成后保存模型权重
如果使用支持CUDA的GPU,训练大约需要几个小时;如果使用CPU,可能需要整整一天。训练完成后,模型权重会保存在你的电脑中的en2fr.pth
文件中。你也可以从我的网站下载预训练好的权重文件(gattonweb.uky.edu/faculty/liu…)。
10.4 使用训练好的模型进行英法翻译
现在你已经训练好了Transformer模型,就可以用它来翻译任意英文句子成法语。我们定义了一个translate()函数,如下所示:
def translate(eng):
tokenized_en = tokenizer.tokenize(eng)
tokenized_en = ["BOS"] + tokenized_en + ["EOS"]
enidx = [en_word_dict.get(i, UNK) for i in tokenized_en]
src = torch.tensor(enidx).long().to(DEVICE).unsqueeze(0)
src_mask = (src != 0).unsqueeze(-2)
memory = model.encode(src, src_mask) # ①
start_symbol = fr_word_dict["BOS"]
ys = torch.ones(1, 1).fill_(start_symbol).type_as(src.data)
translation = []
for i in range(100):
out = model.decode(memory, src_mask, ys,
subsequent_mask(ys.size(1)).type_as(src.data)) # ②
prob = model.generator(out[:, -1])
_, next_word = torch.max(prob, dim=1)
next_word = next_word.data[0]
ys = torch.cat([ys, torch.ones(1, 1).type_as(
src.data).fill_(next_word)], dim=1)
sym = fr_idx_dict[ys[0, -1].item()]
if sym != 'EOS':
translation.append(sym)
else:
break # ③
trans = "".join(translation)
trans = trans.replace("</w>", " ")
for x in '''?:;.,'("-!&)%''':
trans = trans.replace(f" {x}", f"{x}") # ④
print(trans)
return trans
① 使用编码器将英文句子转换为向量表示
② 使用解码器预测下一个词元
③ 当预测到“EOS”时停止翻译
④ 将预测的词元拼接成法语句子,并替换分词符号为一个空格,去除标点前的空格
翻译一个英文句子时,首先用tokenizer将英文句子拆分成词元,然后在句首和句尾分别加上“BOS”和“EOS”。利用之前构建的字典en_word_dict将词元映射为索引,并将索引序列输入到训练好的模型的编码器,得到该句子的抽象向量表示。接着,训练好的解码器基于编码器生成的抽象表示,以自回归方式开始翻译,从“BOS”开始,每一步根据先前生成的词元预测下一个词元,直到生成“EOS”表示句子结束。注意,这里选词方式是确定性的,选择概率最高的词元,以保证翻译准确性。如果需要,你也可以像第8章那样使用随机采样方法,结合top-K采样和温度参数,使翻译更有创意。
最后,将分词符号</w>
替换为空格,并去除标点前的多余空格,得到格式整洁的法语句子。
试用translate()函数翻译英文句子“Today is a beautiful day!”:
from utils.ch09util import subsequent_mask
with open("files/dict.p", "rb") as fb:
en_word_dict, en_idx_dict, fr_word_dict, fr_idx_dict = pickle.load(fb)
trained_weights = torch.load("files/en2fr.pth", map_location=DEVICE)
model.load_state_dict(trained_weights)
model.eval()
eng = "Today is a beautiful day!"
translated_fr = translate(eng)
输出是:
aujourd'hui est une belle journee!
你可以用Google翻译验证该句确实是“今天是美好的一天!”的法语表达。
再试一个稍长的句子,看训练好的模型能否成功翻译:
eng = "A little boy in jeans climbs a small tree while another child looks on."
translated_fr = translate(eng)
输出是:
un petit garcon en jeans grimpe un petit arbre tandis qu'un autre enfant regarde.
用Google翻译回译为英文是“a little boy in jeans climbs a small tree while another child watches”,意思与原句相同,虽然表达不完全一致。
接下来测试模型是否对“I don’t speak French.”和“I do not speak French.”给出相同的法语翻译。先试“I don’t speak French.”:
eng = "I don't speak French."
translated_fr = translate(eng)
输出:
je ne parle pas francais.
再试“I do not speak French.”:
eng = "I do not speak French."
translated_fr = translate(eng)
输出:
je ne parle pas francais.
结果显示,这两个句子的法语翻译完全相同,说明Transformer的编码器成功捕捉了两句的语义核心,将它们转换成相似的抽象向量,再由解码器生成了相同的译文。
练习10.3
使用translate()函数翻译以下两句英文,比较与Google翻译的结果是否一致:(i)I love skiing in the winter! (ii)How are you?
本章中,你通过使用47000多对英法句子,训练了一个编码器-解码器结构的Transformer模型。训练完成后,模型可以较好地正确翻译常见的英文短语!
接下来的章节中,你将学习只包含解码器的Transformer,掌握如何从零构建,并用其生成比第8章使用长短期记忆网络生成的文本更连贯的文本。
总结
-
Transformer与循环神经网络不同,它们并非顺序处理输入数据(如句子),而是并行处理。这种并行处理提升了效率,但本身无法识别输入的顺序。为了解决这个问题,Transformer会将位置编码(positional encoding)加到输入嵌入中。位置编码是为输入序列中每个位置分配的唯一向量,其维度与输入嵌入相匹配。
-
标签平滑(Label smoothing)常用于深度神经网络的训练中,以提升模型的泛化能力。它主要用于解决模型过度自信(预测概率大于真实概率)和分类过拟合的问题。具体来说,标签平滑通过调整目标标签的方式,降低模型对训练数据的过度信心,从而帮助模型在未见过的数据上表现更好。
-
基于编码器捕捉到的英文短语含义,训练好的Transformer的解码器以自回归方式开始翻译,从句首标记“BOS”开始。在每个时间步,解码器根据之前生成的词元生成最可能的下一个词元,直到预测出“EOS”标记,表示句子结束。