PyTorch生成式人工智能(25)——基于Transformer实现机器翻译
PyTorch生成式人工智能(25)——基于Transformer实现机器翻译
- 0. 前言
- 1. 子词分词 (Subword Tokenization)
- 1.1 英语和法语短语分词
- 1.2 构建训练数据
- 2. 词嵌入和位置编码
- 2.1 词嵌入
- 2.2 位置编码
- 3. 训练 Transformer 进行英法翻译
- 3.1 损失函数和优化器
- 3.2 训练循环
- 4. 使用训练完成的模型进行英法翻译
- 小结
- 系列链接
0. 前言
我们已经学习了如何从零开始构建了一个 Transformer 模型,本节将以英语到法语的翻译为例,通过介绍如何训练 Transformer
模型将英语句子转换为法语,深入理解 Transformer
的架构及自注意力机制的运作方式。
假设我们有英语到法语的翻译数据,目标是使用这个数据集训练编码器-解码器 Transformer
。首先,使用子词分词方法,将英语和法语短语拆分成词元 (token
)。然后,构建英语和法语的词汇表,词汇表包含每种语言中所有独特词元。词汇表能够将英语和法语短语表示为索引序列。接下来,使用词嵌入 (word embedding
) 将这些索引(本质上是独热编码向量)转换为紧凑的向量表示。将位置编码 (positional encoding
) 添加到词嵌入中,形成输入嵌入 (input embeddings
)。位置编码使 Transformer
能够知道序列中词元的顺序。
最后,使用英语到法语翻译数据集来训练编码器-解码器 Transformer
,从而实现英语到法语的翻译。训练完成后,使用训练好的 Transformer
翻译常见的英语短语为法语。具体来说,使用编码器捕捉英语短语的含义,然后利用训练好的 Transformer
解码器生成法语翻译,从开始词元 “BOS
” 开始,通过自回归的方式生成法语翻译。在每个时间步中,解码器根据先前生成的词元和编码器的输出生成最可能的下一个词元,直到预测的词元是 “EOS
”,表示句子的结束。
1. 子词分词 (Subword Tokenization)
有三种常见的分词方法:字符分词、单词分词和子词分词。在本节中,我们将使用子词分词,它在其他两种分词方法之间取得了平衡。子词分词将常见的词语保留为整体,并将不常见或更复杂的词拆分为子组件。
在本节中,将学习如何将英语和法语短语分词为子词,创建字典将这些词元映射到索引。训练数据将被转换为索引序列,并按批组织用于训练。
1.1 英语和法语短语分词
(1) 首先下载英语到法语翻译的文件,将 en2fr.csv
文件放置在 ./files/
文件夹中,并使用 pip
命令安装 transformers
库:
$ pip install transformers
(2) 加载数据,并打印出一个英语短语及其对应的法语翻译:
import pandas as pd# 加载 csv 文件
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"])
输出结果如下所示,训练数据中有 47,173 对英语到法语的翻译句子:
(3) 接下来,对数据集中的英语和法语短语进行分词。使用 Hugging Face
的预训练 XLM
模型作为分词器:
# 导入预训练分词器
from transformers import XLMTokenizertokenizer = 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?"))
输出结果如下所示:
需要注意的是,XLM
模型使用 </w>
作为分词分隔符,除非两个词元属于同一个词。子词分词通常将每个词元分割为完整的单词或标点符号,但有时一个单词会被拆分成多个音节。例如,单词 “French
” 被分割为 “fr
” 和 “ench
”。但模型不会在 “fr
” 和 “ench
” 之间插入 </w>
,因为这两个音节共同构成了单词 “French
”。
(4) 深度学习模型不能直接处理原始文本;因此,我们需要将文本转换为数字表示,再输入到模型中。创建字典,将所有英语词元映射为整数:
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
为每个词元分配一个唯一的整数值。反向映射字典 en_idx_dict
将整数(索引)映射回对应的词元,反向映射对于将整数序列转换回词元序列至关重要,使我们能够重建原始的英语短语。
(5) 使用字典 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]
(6) 可以使用字典 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)
输出结果如下所示:
['i</w>', 'don</w>', "'t</w>", 'speak</w>', 'fr', 'ench</w>', '.</w>']
i don't speak french.
需要注意的是,恢复后的英语短语中所有字母都是小写字母,因为预训练的分词器会自动将大写字母转换为小写字母,从而减少独特词元的数量。但某些模型并不会将字母转换为小写字母,如 GPT2
和 ChatGPT
,因此它们所用的词汇表更大。
(7) 将相同的步骤应用于法语短语,将词元映射到索引,或反之将索引映射为词元:
# 对所有法语句子进行分词
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()}
(8) 将法语短语 “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]
(9) 可以使用字典 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) 将以上四个字典保存在 ./files/
文件夹中,以便之后可以直接加载:
import picklewith open("files/dict.p","wb") as fb:pickle.dump((en_word_dict,en_idx_dict,fr_word_dict,fr_idx_dict),fb)
1.2 构建训练数据
为了计算效率和加速收敛,在训练过程中将训练数据分成多个批次。对于其他数据格式(如图像),创建批次非常简单:只需将特定数量的输入分组以形成批次,因为它们的大小相同。然而,在自然语言处理中,由于句子的长度各异,批处理可能会更复杂。为了标准化批次内的长度,我们会对较短的序列进行填充。这种统一性至关重要,因为输入到 Transformer
模型的数字表示必须具有相同的长度。例如,一个批次中的英语短语长度可能不同(法语短语也可能出现同样的情况)。为了解决这个问题,我们在批次中较短短语的数值表示末尾填充 0
值,确保所有输入到 Transformer
模型的序列长度相同。
需要注意的是,在每个句子的开始和结束位置加入 BOS
和 EOS
词元,以及填充批次内较短的序列,是机器翻译中的一个显著特征。这源于输入包含完整的句子或短语。相比之下,训练文本生成模型并不需要这些过程。
(1) 将所有英语短语转换为数值表示,然后对法语短语执行相同的过程:
out_en_ids=[[en_word_dict.get(w,1) for w in s] for s in en_tokens]
out_fr_ids=[[fr_word_dict.get(w,1) 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]
(2) 将数值表示分成多个数据批次:
import numpy as npbatch_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)))
需要注意的是,在将训练数据集中的样本分成批数据之前,我们已按英语短语的长度对训练数据集中的数据进行了排序。这种方法确保了每个批次内的数据具有可比长度,从而减少了填充的需求。因此,这种方法不仅减少了训练数据的总体大小,还加速了训练过程。
(3) 定义函数 seq_padding()
,将批次中的序列填充为相同的长度:
def seq_padding(X, padding=0):L = [len(x) for x in X]# 找出批次中最长序列的长度ML = max(L)# 如果批次短于最长序列,则在序列末尾填充 0padded_seq = np.array([np.concatenate([x, [padding] * (ML - len(x))])if len(x) < ML else x for x in X])return padded_seq
(4) 在模块 util.py
中创建 Batch()
类:
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()
Batch()
类处理一批英语和法语短语,并将它们转换为适合训练的格式。为了使解释更具体,我们以英语句子 “How are you?
” 及其法语翻译 “Comment êtes-vous?
” 为例。Batch()
类接收两个输入:src
,表示 “How are you?
” 中词元的索引序列,以及 trg
,表示 “Comment êtes-vous?
” 中词元的索引序列。Batch()
类生成张量 src_mask
,用于隐藏句子末尾的填充部分。例如,句子 “How are you?
” 被分解为六个词元:['BOS', 'how', 'are', 'you', '?', 'EOS']
。如果这个序列是一个批次的一部分,且批次的最大长度为八个词元,那么会在末尾添加两个零。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']
的索引构成,旨在强制模型根据先前的词元来预测下一个词元。
(5) Batch()
类还为解码器的输入生成了一个掩码 trg_mask
,掩码的目的是隐藏输入中的后续词元,确保模型仅依赖于之前的词元进行预测。掩码通过模块 util.py
中定义的 make_std_mask()
函数生成:
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) == 0return outputdef 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()
函数则为目标序列构建一个标准掩码,这个标准掩码有双重作用:既隐藏填充的零,又隐藏目标序列中的未来词元。
(6) 从模块 util.py
中导入 Batch()
类,并创建训练数据的批次:
from util import Batchbatches=[]
for b in batch_indexs: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)batches.append(Batch(batch_en,batch_fr))
BatchLoader()
类创建了用于训练的数据批次,批次列表中的每个批次包含 128
对数据,其中每对数据包含一个英文短语及其对应的法语翻译的数值表示。
2. 词嵌入和位置编码
经过分词处理后,英文和法语短语被表示为索引序列。在本节中,我们将使用词嵌入将这些索引(本质上是独热向量)转换为更紧凑的向量表示,用以捕捉短语中各个词语的语义信息及其相互关系。词嵌入还提高了训练效率,与庞大的独热向量相比,词嵌入使用连续的、低维的向量来减少模型的复杂性和维度。
注意力机制同时处理短语中的所有词元,而不是按顺序处理。这提高了效率,但并未使其能够识别词语的顺序。因此,我们将通过使用不同频率的正弦和余弦函数将位置编码添加到输入嵌入中。
2.1 词嵌入
(1) 英文和法语短语的数值表示涉及大量的索引。为了确定每种语言所需的唯一索引数量,可以计算 en_word_dict
和 fr_word_dict
字典中的独特词元的数量,这样可以得到每种语言词汇表中的独特词元的总数:
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
。
(2) 在模块 util.py
中定义 Embeddings()
类:
class Embeddings(nn.Module):def __init__(self, d_model, vocab):super().__init__()self.lut = nn.Embedding(vocab, d_model)self.d_model = d_modeldef forward(self, x):out = self.lut(x) * math.sqrt(self.d_model)return out
自定义的 Embeddings()
类使用了 PyTorch
的 nn.Embedding()
类。它还将输出乘以 d_model
的平方根(即 256
),这一步骤的目的是抵消后续计算注意力分数时除以 d_model
平方根的影响。
2.2 位置编码
(1) 为了准确表示输入和输出中元素的顺序,在模块 util.py
中定义 PositionalEncoding()
类:
class PositionalEncoding(nn.Module):# 初始化该类,允许最多 5000 个位置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
PositionalEncoding()
类通过正弦函数生成偶数索引的位置向量,通过余弦函数生成奇数索引的位置向量。需要注意的是,在 PositionalEncoding()
类中,使用了 requires_grad_(False)
参数,因为这些值不需要训练,在所有输入中保持不变,并且在训练过程中不会发生变化。
例如,英文短语中的六个词元 ['BOS', 'how', 'are', 'you', '?', 'EOS']
的索引,首先会通过词嵌入层进行处理,将这些索引转换为维度为 (1, 6, 256)
的张量:其中 1 表示批次中只有 1
个序列;6
表示序列中有 6
个词元;256
表示每个词元由一个 256
维的向量表示。在完成词嵌入处理后,PositionalEncoding()
类用于计算与词元 ['BOS', 'how', 'are', 'you', '?', 'EOS']
对应的索引的位置信息编码,这一步是为了为模型提供每个词元在序列中的位置信息。更进一步,我们可以打印这六个词元的确切位置编码值:
from util import PositionalEncoding
import torch
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
# 实例化 PositionalEncoding() 类并将模型维度设置为 256
pe = PositionalEncoding(256, 0.1)
# 创建一个词嵌入并用零填充
x = torch.zeros(1, 6, 256).to(DEVICE)
# 通过将位置编码添加到词嵌入来计算输入嵌入
y = pe.forward(x)
print(f"the shape of positional encoding is {y.shape}")
# 打印输入嵌入,由于词嵌入被设置为零,因此它与位置编码相同
print(y)
由于 PositionalEncoding()
类的输出是词嵌入和位置编码的总和,我们创建了一个全为零的词嵌入并将其输入到 pe
中,这样输出就是位置编码值。输出结果如下所示:
结果张量表示英文短语 “How are you?
” 的位置信息编码,位置编码的维度也是 (1, 6, 256)
,与 “How are you?
” 的词嵌入的大小相同。接下来,将词嵌入和位置编码合并成输入张量。
位置编码的一个重要特征是,无论输入序列是什么,它们的值都是相同的。这意味着,无论输入序列的具体内容如何,第一个词元的位置信息编码始终是相同的 256
维向量 [0.0000e+00, 1.1111e+00, ..., 1.1111e+00]
,第二个词元的位置信息编码始终是 [9.3497e-01, 6.0034e-01, ..., 1.1111e+00]
,依此类推。它们的值在训练过程中也不会发生变化。
3. 训练 Transformer 进行英法翻译
我们构建的英法翻译模型可以视为一个多类别分类器,其核心目标是在翻译英语句子时,预测法语词汇中的下一个词元。在本节中,我们将详细介绍选择合适的损失函数和优化器的过程,使用英法翻译的批数据集作为训练数据集来训练 Transformer
模型。
3.1 损失函数和优化器
(1) 首先,从模块 util.py
中导入 create_model()
函数,并构建一个 Transformer
,以便训练用于进行英法翻译:
from util import create_modelmodel = create_model(src_vocab, tgt_vocab, N=6,d_model=256, d_ff=1024, h=8, dropout=0.1)
在训练过程中使用标签平滑 (Label Smoothing
)。标签平滑通常用于训练深度神经网络,以提高模型的泛化能力。它用于解决置信度过高问题(即预测的概率大于真实的概率)和分类任务中的过拟合问题。具体来说,它通过调整目标标签修改模型的学习方式,旨在减少模型对训练数据的过高置信度,从而提高模型在未见数据上的表现。
在经典分类任务中,目标标签通常采用独热编码格式表示,这种表示意味着对每个训练样本标签的正确性具有绝对确定性。以绝对确信性进行训练可能会导致两个主要问题。第一个问题是过拟合:模型在预测时过于自信,导致它过于贴近训练数据,这会影响模型在新数据上的表现。第二个问题是置信度过高:以这种方式训练的模型通常输出过高的概率。例如,它们可能对正确类别输出 99%
的概率,而实际上置信度应该更低。
标签平滑通过调整目标标签,防止其过度自信。对于一个三类别问题,目标标签从 [1, 0, 0]
变成类似 [0.9, 0.05, 0.05]
的形式。这种方法通过惩罚过度自信的输出,鼓励模型不要对其预测过于自信。平滑后的标签是原标签和其他标签(通常是均匀分布)之间的混合。
(2) 在模块 util.py
中定义 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_idxself.confidence = 1.0 - smoothingself.smoothing = smoothingself.size = sizeself.true_dist = Nonedef 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] = 0mask = 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
参数 smoothing
用于控制向实际标签中注入的噪声量。如果 smoothing=0.1
,标签 [1, 0, 0]
会平滑成为 [0.9, 0.05, 0.05]
;如果 smoothing=0.05
,标签会平滑为 [0.95, 0.025, 0.025]
。然后,该类通过将预测结果与平滑后的标签进行比较计算损失。
(3) 使用 Adam
优化器,并使用在模块 util.py
中定义的 NoamOpt()
类在训练过程中动态调整学习率:
class NoamOpt:def __init__(self, model_size, factor, warmup, optimizer):self.optimizer = optimizerself._step = 0# 定义预热步骤self.warmup = warmupself.factor = factorself.model_size = model_sizeself._rate = 0# step() 方法用于应用优化器来调整模型参数def step(self):self._step += 1rate = self.rate()for p in self.optimizer.param_groups:p['lr'] = rateself._rate = rateself.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
NoamOpt()
类实现了一种动态学习率策略。首先,在训练的初始步骤中,学习率线性增加。之后,学习率会根据训练步数的平方根的倒数进行衰减。
(4) 创建优化器:
from util import NoamOptoptimizer = NoamOpt(256, 1, 2000, torch.optim.Adam(model.parameters(), lr=0, betas=(0.9, 0.98), eps=1e-9))
(5) 为了定义训练的损失函数,首先在 util.py
模块中创建 SimpleLossCompute()
类:
class SimpleLossCompute:def __init__(self, generator, criterion, opt=None):self.generator = generatorself.criterion = criterionself.opt = optdef __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()
类由三个关键元素构成:generator
(生成器,作为预测模型)、criterion
(损失函数,用于计算损失)和 opt
(优化器)。SimpleLossCompute()
类通过使用 generator
进行预测来处理一批训练数据 (x, y)
。接着,通过将这些预测结果与实际标签 y
进行比较来评估损失(实际标签 y
会由 LabelSmoothing()
类进行平滑处理)。最后,计算相对于模型参数的梯度,并利用优化器来更新这些参数。
(6) 定义损失函数:
from util import LabelSmoothing, SimpleLossComputecriterion = LabelSmoothing(tgt_vocab, padding_idx=0, smoothing=0.1)
loss_func = SimpleLossCompute(model.generator, criterion, optimizer)
接下来,使用准备好的数据来训练 Transformer
模型。
3.2 训练循环
(1) 模型训练 100
个 epoch
,计算每批次的损失和词元数量,在每个 epoch
结束后,我们通过将总损失除以总词元数量来计算该 epoch
的平均损失:
for epoch in range(100):model.train()tloss=0tokens=0for batch in batches:# 使用 Transformer 进行预测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.ntokensprint(f"Epoch {epoch}, average loss: {tloss/tokens}")
# 保存训练好的模型权重
torch.save(model.state_dict(),"files/en2fr.pth")
4. 使用训练完成的模型进行英法翻译
(1) 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()]# 当下一个词元为 “EOS” 时停止翻译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
要将英文短语翻译成法语,首先使用分词器将英文句子转换为词元。然后,我们在短语的开始和结尾分别添加 “BOS
” 和 “EOS
”。使用字典 en_word_dict
将词元转换为索引,将索引序列传递给训练好的模型中的编码器。编码器生成一个抽象的向量表示,并将其传递给解码器。
基于编码器生成的英文句子的抽象向量表示,训练好的模型中的解码器以自回归的方式进行翻译。从起始词元 “BOS
” 开始,在每个时间步,解码器基于之前生成的词元生成最可能的下一个词元,直到预测的词元为 “EOS
”,表示句子的结束。这与文本生成方法略有不同,在文本生成中,下一个词元是根据其预测的概率随机选择的。而在这里,选择下一个词元的方法是确定性的,即选择概率最高的词元,因为我们主要关注准确性。但如果希望翻译更具创意性,可以使用 top-K
采样和温度参数进行随机预测。
最后,我们将词元分隔符替换为空格,并去掉标点符号前的空格。输出的是格式整洁的法语翻译。
(2) 使用 translate()
函数翻译英文短语 “Today is a beautiful day!
”:
from util import subsequent_maskwith 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,weights_only=False)
model.load_state_dict(trained_weights)
model.eval()
eng = "Today is a beautiful day!"
translated_fr = translate(eng)
输出结果如下:
aujourd'hui est une belle journee!
(3) 尝试翻译一个更长的句子,观察训练好的模型是否能够成功翻译:
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.
使用翻译软件将输出结果翻译回英文,结果为:“a little boy in jeans climbs a small tree while another child watches
” ,虽然与原始英文句子不完全相同,但意思是一样的。
(4) 接下来,将测试训练过的模型是否会为以下两个英文句子生成相同的翻译:“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
的编码器成功地捕捉到了这两句话的语义本质。然后,将这些语句表示为相似的抽象连续向量,并将其传递给解码器。解码器根据这些向量生成翻译,并产生相同的结果。
小结
- 不同于循环神经网络 (Recurrent Neural Network, RNN)按顺序处理数据,
Transformer
以并行的方式处理输入数据(例如句子)。这种并行处理提高了效率,但并未使其能够识别输入的顺序。为了解决这个问题,Transformer
向输入的词嵌入中添加了位置编码。这些位置编码是分配给输入序列中每个位置的独特向量,其维度与输入词嵌入对齐 - 标签平滑通常用于训练深度神经网络,以提高模型的泛化能力。它用于解决置信度过高(预测的概率大于实际的概率)和分类中的过拟合问题。具体来说,它通过调整目标标签来改变模型的学习方式,旨在减少模型对训练数据的置信度,从而提高模型在未见数据上的表现
- 根据编码器输出的语义信息,训练好的
Transformer
解码器以自回归的方式进行翻译,从“BOS
” (句子开始)词元开始。在每一个时间步中,解码器根据先前生成的词元生成最可能的下一个词元,直到预测的词元是 “EOS
” (句子结束)
系列链接
PyTorch生成式人工智能实战:从零打造创意引擎
PyTorch生成式人工智能(1)——神经网络与模型训练过程详解
PyTorch生成式人工智能(2)——PyTorch基础
PyTorch生成式人工智能(3)——使用PyTorch构建神经网络
PyTorch生成式人工智能(4)——卷积神经网络详解
PyTorch生成式人工智能(5)——分类任务详解
PyTorch生成式人工智能(6)——生成模型(Generative Model)详解
PyTorch生成式人工智能(7)——生成对抗网络实践详解
PyTorch生成式人工智能(8)——深度卷积生成对抗网络
PyTorch生成式人工智能(9)——Pix2Pix详解与实现
PyTorch生成式人工智能(10)——CyclelGAN详解与实现
PyTorch生成式人工智能(11)——神经风格迁移
PyTorch生成式人工智能(12)——StyleGAN详解与实现
PyTorch生成式人工智能(13)——WGAN详解与实现
PyTorch生成式人工智能(14)——条件生成对抗网络(conditional GAN,cGAN)
PyTorch生成式人工智能(15)——自注意力生成对抗网络(Self-Attention GAN, SAGAN)
PyTorch生成式人工智能(16)——自编码器(AutoEncoder)详解
PyTorch生成式人工智能(17)——变分自编码器详解与实现
PyTorch生成式人工智能(18)——循环神经网络详解与实现
PyTorch生成式人工智能(19)——自回归模型详解与实现
PyTorch生成式人工智能(20)——像素卷积神经网络(PixelCNN)
PyTorch生成式人工智能(21)——归一化流模型(Normalizing Flow Model)
PyTorch生成式人工智能(22)——GLOW详解与实现
PyTorch生成式人工智能(23)——能量模型(Energy-Based Model)
PyTorch生成式人工智能(24)——使用PyTorch构建Transformer模型