使用 PyTorch 学习生成式人工智能——训练一个Transformer模型进行英法翻译

34 阅读23分钟

本章内容涵盖

  • 将英语和法语短语分词为子词
  • 理解词嵌入和位置编码
  • 从零训练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()

① 创建源序列掩码,屏蔽填充部分
② 创建解码器输入(去掉目标序列最后一个词元)
③ 创建目标输出(去掉目标序列第一个词元,实现预测位移)
④ 创建目标序列掩码

image.png

图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”标记,表示句子结束。