生成式聊天机器人 -- 基于Transformer实现的SeqToSeq模型 -- 上
- 引言
- 数据预处理
- 下载并处理数据
- 数据加载
- Transformer模型
- 嵌入层&位置编码层
- 多头注意力机制
- EncoderLayer
- DecoderLayer
- Point-wise Feed Forward Network
- Transformer
引言
在此之前,我们已经了解了如何基于RNN加Attention架构去实现一个聊天机器人,本文将基于Transformer架构替换RNN重新实现一个生成式聊天机器人。
生成式聊天机器人 – 基于Pytorch + Global Attention + 双向 GRU 实现的SeqToSeq模型 – 上
生成式聊天机器人 – 基于Pytorch + Global Attention + 双向 GRU 实现的SeqToSeq模型 – 下
中文测试集效果(基于小型数据集训练出来的效果,仅能作为学习使用):
User:以第三人称叙述的方式写一个关于必须做出重要职业决定的主人公的短篇故事。
ChatBot:从前,有一位名叫杰克的人,他的父母住在一个小镇上。他的父母亲是一位勤劳的工作,并且他的职业生涯,并且在公司工作的职业生涯中取得了成功。User:讨论大萧条的原因
ChatBot:大萧条的经济因素是由于经济衰退,由于经济衰退化,经济衰退化,导致经济衰退。这些原因包括经济衰退化User:描述电脑主板的功能
ChatBot:计算机的主板电路板是电脑,通常由两个组成,每个组成,每个组件都包含一个组成,每个组件都包含一个组成,每个组件都包含一个组件。这些组件包括:User:提出数据隐私问题的道德解决方案
ChatBot:数据隐私问题的一个潜在解决方案是实施数据安全和隐私。这些方法可能包括:User:向非技术人员解释冒泡排序算法的概念。
ChatBot:这是一个简单的算法,它将其分成两个球员的球员名字列表,其中每个球员的球员排列表,并且每个球员都会根据其他元素的球员。这意味着它的球员,并且可以在其中找到一个球员的球员。User:给定以下输入,构建一个有创意的故事。一把神奇的弓箭
ChatBot:从前,有一个名叫莉莉莉莉的年轻女孩,她住在一个小村庄里。她住在一个小村庄里,一个名叫莉莉莉莉莉莉莉莉莉莉莉莉莉莉的年轻女孩,在一个小村庄里。她的小村庄里,她住在一个小村庄里,在森林里,在森林里游泳,在森林里游泳。她的一个小村庄里,在森林里,在森林里游泳,在森林里游泳,在森林里游泳,在森林里游泳。她的一个小村庄里,在森林里游泳,在森林里游泳,在森林里游泳,在森林里游泳,在森林里游泳。她的一个小村庄里游泳,在森林里游泳,在森林里游泳,在森林里游泳,在森林里游泳,在森林里游泳,在森林里游泳。User:提出一个关于自动驾驶汽车的想法。
ChatBot:自动驾驶汽车的一个潜在想法是使用自动驾驶汽车的自动驾驶汽车。自动驾驶汽车可以通过自动驾驶汽车User:写下您最近观看的电影的评论。寄生虫 (2019)
ChatBot:我最近看了电影《了!我最近看电影《了!我最近看了!我最近看了!我最近看了!我最近看了!我最近看了!我最近看了!我最近的电影《了!我最近的电影《了!我最近的电影《了!我最近的电影《了!我最近的电影《了!我最近的电影《了!我最近的电影星期的电影《了!我最近的电影星期的电影《了!我最近的电影星期的电影《了!我最近的电影星期的电影《了!我最近的电影星期的电影》是什么让我最近的电影《了!我最近的电影《了!我最近的电影《了!我最近的电影星期的电影《了!我最近的电影星期的电影》。我最近的电影《了!我最近的电影《了!我最User:创建 Twitter 帖子来宣传您的新产品。艾丽莎的全天然果酱
ChatBot:我们的新鲜花一切都是享用我们的美味佳选择!
数据预处理
下载并处理数据
本文使用的是github上找到的一个小型的开源中文数据集,下载链接如下:
- 数据集 + 中文分词
需要下载dataset文件夹到项目根目录下:
data_zh.json 是一个只有3万条对话的小型数据集,也是本文训练所采用的数据集,仅能作为学习使用 ,而dataset_train.json来自一个开源的中文对话语料库,大约1.4G的对话。
不需要基于大语料库进行训练的同学,可以不下载dataset_train.json文件。
char_base.json 是一个含有常见中文字符的字典文件,我们会利用该文件完成字典的初始化任务。
1. 初始化字典文件 1.初始化字典文件 1.初始化字典文件
import json# 常见的中文字符和英文标点符号
common_characters = ",。!?;:“”‘’()[]&#;``【】``;《》1234567890"
common_english_punctuation = ".,!?;:\"'()[]{}<>qwertyuiopasdfghjklzxcvbnm"# 打开JSON文件以读取汉字映射数据
with open('../dataset/dataset/char_base.json', 'r', encoding='utf-8') as json_file:char_data = json.load(json_file)# 创建汉字映射字典
word_map = {}
# 遍历char_data列表并将汉字及其对应的索引添加到字典中
for item in char_data:char = item.get("char", "")if char not in word_map:word_map[char] = len(word_map) + 1# 遍历常见字符并将它们添加到字典中
for char in common_characters:if char not in word_map:word_map[char] = len(word_map) + 1# 遍历常见英文标点符号并将它们添加到字典中
for char in common_english_punctuation:if char not in word_map:word_map[char] = len(word_map) + 1# 添加特殊标记
word_map['<unk>'] = len(word_map) + 1
word_map['<start>'] = len(word_map) + 1
word_map['<end>'] = len(word_map) + 1
word_map['<pad>'] = 0if __name__ == '__main__':# 保存汉字映射字典到文件 --- word_map保存每个字符对应的IDwith open('../dataset/dataset/WORDMAP_corpus.json', 'w', encoding='utf-8') as map_file:json.dump(word_map, map_file, ensure_ascii=False)
执行上述代码后,处理效果如下:
2. 初始化句对 2. 初始化句对 2.初始化句对
import jsonmax_len = 256# 打开WORDMAP_corpus_.json文件以读取汉字映射数据
with open('../dataset/dataset/WORDMAP_corpus.json', 'r', encoding='utf-8') as word_map_file:word_map = json.load(word_map_file)def encode_question(words, word_map):enc_c = [word_map.get(word, word_map['<unk>']) for word in words] + [word_map['<pad>']] * (max_len - len(words))return enc_cdef encode_reply(words, word_map):enc_c = [word_map['<start>']] + [word_map.get(word, word_map['<unk>']) for word in words] + [word_map['<end>']] + [word_map['<pad>']] * (max_len - len(words))return enc_cpairs_encoded = []def generate_from_small_dataset():# 只有3万条对话的小型数据集,数据量连大模型的微调都不够。全参训练就图看个乐,能回就成功了,误当真。input_file = "../dataset/dataset/data_zh.json"with open(input_file, "r", encoding="utf-8") as file: # 指定编码格式为utf-8data_zh = json.load(file)new_data = []for idx, item in enumerate(data_zh):query_history = []instruction = item["instruction"]output = item["output"]instruction = instruction.replace('\n\n', '\n')output = output[0].replace('\n\n', '\n')output = output[:max_len - 1].ljust(max_len - 1)qus = encode_question(instruction[:max_len-1], word_map)ans = encode_reply(output, word_map)pairs_encoded.append([qus, ans])generate_from_small_dataset()
if __name__ == '__main__':print('开始写pairs_encoded')with open('../pairs_encoded.json', 'w') as p:json.dump(pairs_encoded, p)
执行上述代码后,处理效果如下:
数据加载
下面我们使用DataSet类来封装对数据的读取和加载逻辑:
# 定义数据集类
class Dataset(Dataset):def __init__(self):# 加载已编码的问答对self.pairs = json.load(open('pairs_encoded.json'))self.dataset_size = len(self.pairs)def __getitem__(self, i):# 获取一组问答对question = torch.LongTensor(self.pairs[i][0])reply = torch.LongTensor(self.pairs[i][1])return question, replydef __len__(self):# 返回数据集大小return self.dataset_size
使用pytorch提供的DataLoader类来封装DataSet,完成数据的批量加载:
train_loader = data.DataLoader(Dataset(),batch_size=512,shuffle=True,pin_memory=True)
Transformer模型
关于Transformer和SeqToSeq模型理论部分大家可以阅读下文学习,本文就不再进行理论知识的讲解了,重点看代码逻辑的实现:
- 演进历史: Seq2Seq 到 Transformer
嵌入层&位置编码层
首先我们来看Transformer中的嵌入层与位置编码层的代码实现:
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")class Embeddings(nn.Module):"""实现词的嵌入并添加位置编码。"""def __init__(self, vocab_size, d_model, max_len):super(Embeddings, self).__init__()# 嵌入向量的维度self.d_model = d_model# 定义一个 Dropout 层,丢弃率为 0.1,用于防止过拟合self.dropout = nn.Dropout(0.1)# 定义一个嵌入层,将词的索引映射到 d_model 维的稠密向量空间# vocab_size 是词汇表的大小,d_model 是嵌入向量的维度self.embed = nn.Embedding(vocab_size, d_model)# 调用 create_positinal_encoding 方法创建位置编码self.pe = self.create_positinal_encoding(max_len, self.d_model)def create_positinal_encoding(self, max_len, d_model):pe = torch.zeros(max_len, d_model).to(device)# 遍历每个词的位置for pos in range(max_len):# 遍历每个位置的每个偶数维度for i in range(0, d_model, 2):# 根据正弦公式计算偶数维度的位置编码值pe[pos, i] = math.sin(pos / (10000 ** ((2 * i) / d_model)))# 根据余弦公式计算奇数维度的位置编码值pe[pos, i + 1] = math.cos(pos / (10000 ** ((2 * (i + 1)) / d_model)))# 在第 0 维添加一个维度,将 pe 的形状变为 (1, max_len, d_model),以便后续扩展批次大小pe = pe.unsqueeze(0)return pedef forward(self, encoded_words):# 通过嵌入层将编码后的词转换为嵌入向量# 乘以 math.sqrt(self.d_model) 是为了缩放嵌入向量,防止梯度消失或爆炸embedding = self.embed(encoded_words) * math.sqrt(self.d_model)# 将位置编码添加到嵌入向量上# self.pe[:, :embedding.size(1)] 截取位置编码的前 embedding.size(1) 个位置# 这里位置编码会自动扩展为与 encoded_words 相同的批次大小 -- 广播embedding += self.pe[:, :embedding.size(1)]# 对添加了位置编码的嵌入向量应用 Dropout 操作embedding = self.dropout(embedding)return embedding
下面通过一个实例来演示该层的运行流程:
关于Transformer采用的正弦位置编码理论部分这里就不过多讲解了,大家可自行查询相关资料进行学习。
多头注意力机制
本节我们来重点看一下Transformer中的多头注意力机制模块代码是如何实现的,以及通过图解的方式完整的看一遍它的处理流程。
import torch.nn.functional as Fclass MultiHeadAttention(nn.Module):"""多头注意力机制。多头注意力机制允许模型在不同的表示子空间中并行地关注输入序列的不同部分,从而捕捉到更丰富的语义信息。"""def __init__(self, heads, d_model):"""初始化多头注意力机制。:param heads: 注意力头的数量。:param d_model: 模型的维度,即输入和输出向量的维度。"""super(MultiHeadAttention, self).__init__()# 确保 d_model 能被 heads 整除,因为要将 d_model 均分到每个头中assert d_model % heads == 0# 每个头的维度self.d_k = d_model // heads# 注意力头的数量self.heads = heads# 定义 Dropout 层,丢弃率为 0.1,用于防止过拟合self.dropout = nn.Dropout(0.1)# 定义WQ,WK,WV矩阵,用于将输入的q,k,v向量投影到对应的query空间,key空间和value空间中self.query = nn.Linear(d_model, d_model)self.key = nn.Linear(d_model, d_model)self.value = nn.Linear(d_model, d_model)# 经过多头注意力计算后,每个头的输出是相互独立的。# 将这些输出拼接起来只是简单地将它们组合在一起,并没有充分融合各个头所提取的信息。# 通过一个线性变换层(全连接层),可以对拼接后的向量进行线性组合和变换,使得不同头的信息能够相互作用和融合,从而得到一个更加综合和丰富的表示。self.concat = nn.Linear(d_model, d_model)def forward(self, query, key, value, mask):"""前向传播方法,定义了多头注意力机制的具体计算流程。:param query: 查询向量,形状通常为 (batch_size, seq_len, d_model):param key: 键向量,形状通常为 (batch_size, seq_len, d_model):param value: 值向量,形状通常为 (batch_size, seq_len, d_model):param mask: 掩码矩阵,用于在注意力计算中屏蔽某些位置的信息,形状通常为 (batch_size, 1, seq_len, seq_len):return: 经过多头注意力机制计算后的交互向量,形状为 (batch_size, seq_len, d_model)"""# 通过线性变换将输入的 query、key 和 value 向量分别投影到对应的query,key,value空间中去query = self.query(query)key = self.key(key)value = self.value(value)# 将 query、key 和 value 向量的维度进行重塑和调整,以适应多头注意力的计算# 先将最后一个维度 d_model 拆分为 (heads, d_k),表示每个头分别关注输入维度的不同部分# 然后调整维度顺序为 (batch_size, heads, seq_len, d_k)query = query.view(query.shape[0], -1, self.heads, self.d_k).permute(0, 2, 1, 3)key = key.view(key.shape[0], -1, self.heads, self.d_k).permute(0, 2, 1, 3)value = value.view(value.shape[0], -1, self.heads, self.d_k).permute(0, 2, 1, 3)# 计算注意力分数# 通过矩阵乘法计算 query 和 key 的转置的乘积# 除以 math.sqrt(query.size(-1)) 是为了缩放注意力分数,防止梯度消失或爆炸scores = torch.matmul(query, key.permute(0, 1, 3, 2)) / math.sqrt(query.size(-1))# 应用掩码# 将掩码矩阵中值为 0 的位置对应的注意力分数设置为负无穷大# 这样在后续的 softmax 计算中,这些位置的权重将趋近于 0scores = scores.masked_fill(mask == 0, -1e9)# 计算注意力权重# 对注意力分数应用 softmax 函数,将其转换为概率分布weights = F.softmax(scores, dim=-1)# 应用 Dropout 进行正则化weights = self.dropout(weights)# 计算上下文向量# 通过矩阵乘法将注意力权重和 value 向量相乘,得到每个头的上下文向量context = torch.matmul(weights, value)# 将多头注意力的输出进行拼接# 先调整维度顺序为 (batch_size, seq_len, heads, d_k)# 然后将最后两个维度拼接成一个维度,即 (batch_size, seq_len, d_model)context = context.permute(0, 2, 1, 3).contiguous().view(context.shape[0], -1, self.heads * self.d_k)# 通过一个线性变换层(全连接层),可以对拼接后的向量进行线性组合和变换,使得不同头的信息能够相互作用和融合,从而得到一个更加综合和丰富的表示interacted = self.concat(context)return interacted
下面通过一个自注意力机制的运行实例来演示该层的运算流程:
# 参数信息
heads = 2
d_model = 8
batch_size = 1
seq_len = 6
上图流程中只演示了因果掩码的应用,使用padding掩码的过程也是一样的,将padding mask矩阵与还未进行SoftMax归一化的Attention Score矩阵相加即可,后面进行SoftMax运算时,会将Attention Score中值为负无穷的位置处的权重值设置为0。
EncoderLayer
本节我们来看一下Transformer中Encoder块是如何实现的:
class EncoderLayer(nn.Module):"""编码器层。编码器层是 Transformer 模型编码器部分的核心组件,主要由多头自注意力机制和前馈神经网络构成,并且在每个子层后都使用了残差连接和层归一化操作,有助于缓解梯度消失问题,提升模型的训练稳定性和性能。"""def __init__(self, d_model, heads):"""初始化编码器层。:param d_model: 模型的维度,即输入和输出向量的维度大小。:param heads: 多头注意力机制中的头数,决定了模型可以并行关注不同特征子空间的能力。"""super(EncoderLayer, self).__init__()# 定义层归一化层,对输入张量的最后一个维度进行归一化操作,# 使输入数据的均值为 0,方差为 1,有助于加速模型收敛和提高稳定性self.layernorm = nn.LayerNorm(d_model)# 实例化多头自注意力机制模块,用于对输入序列进行自注意力计算,# 可以捕捉序列中不同位置之间的依赖关系self.self_multihead = MultiHeadAttention(heads, d_model)# 实例化前馈神经网络模块,用于对多头自注意力机制的输出进行非线性变换,# 增加模型的表达能力self.feed_forward = FeedForward(d_model)# 定义 Dropout 层,丢弃率为 0.1,用于在训练过程中随机将部分输入元素置为 0,# 防止模型过拟合self.dropout = nn.Dropout(0.1)def forward(self, embeddings, mask):"""前向传播方法,定义了编码器层的具体计算流程。:param embeddings: 输入的嵌入向量,形状通常为 (batch_size, seq_len, d_model),其中 batch_size 是批次大小,seq_len 是序列长度,d_model 是模型维度。:param mask: 掩码矩阵,用于在多头注意力计算中屏蔽某些位置的信息,形状通常为 (batch_size, 1, seq_len, seq_len)。:return: 编码后的向量,形状与输入的 embeddings 相同,为 (batch_size, seq_len, d_model)。"""# 第一步:多头自注意力机制# 将输入的 embeddings 同时作为 query、key 和 value 输入到多头自注意力模块中,# 计算得到交互后的表示# 对多头自注意力模块的输出应用 Dropout 操作,防止过拟合interacted = self.dropout(self.self_multihead(embeddings, embeddings, embeddings, mask))# 第二步:第一个残差连接和层归一化# 将多头自注意力机制的输出与输入的 embeddings 相加,形成残差连接,# 有助于缓解梯度消失问题,使模型更容易训练# 对残差连接的结果应用层归一化操作,稳定训练过程interacted = self.layernorm(interacted + embeddings)# 第三步:前馈神经网络# 将经过多头自注意力机制和层归一化处理后的结果输入到前馈神经网络中,# 进行非线性变换# 对前馈神经网络的输出应用 Dropout 操作,防止过拟合feed_forward_out = self.dropout(self.feed_forward(interacted))# 第四步:第二个残差连接和层归一化# 将前馈神经网络的输出与经过多头自注意力机制和层归一化处理后的结果相加,# 形成残差连接# 对残差连接的结果应用层归一化操作,得到最终的编码结果encoded = self.layernorm(feed_forward_out + interacted)return encoded
再理解了多头注意力机制的代码实现后,EncoderLayer的代码就比较好理解了,但是这里需要注意一点,EncoderLayer类的前向传播方法传入的mask为padding_mask,用于将传入序列中为pad的词,在计算注意力分数时给排除掉,避免将注意力过多分配给这些无意义的词。
DecoderLayer
本节我们来看一下Transformer中Decoder块是如何实现的:
class DecoderLayer(nn.Module):"""解码器层。解码器层是 Transformer 模型解码器部分的核心组件,主要包含两个多头注意力机制(自注意力和编码器 - 解码器注意力)以及一个前馈神经网络,并且在每个子层后都使用了残差连接和层归一化操作,有助于提升模型性能和训练稳定性。"""def __init__(self, d_model, heads):"""初始化解码器层。:param d_model: 模型的维度,即输入和输出向量的维度大小。:param heads: 多头注意力机制中的头数,决定了模型可以并行关注不同特征子空间的能力。"""super(DecoderLayer, self).__init__()# 定义层归一化层,对输入张量的最后一个维度进行归一化操作,# 使输入数据的均值为 0,方差为 1,有助于加速模型收敛和提高稳定性self.layernorm = nn.LayerNorm(d_model)# 实例化第一个多头自注意力机制模块,用于对目标序列进行自注意力计算,# 可以捕捉目标序列中不同位置之间的依赖关系self.self_multihead = MultiHeadAttention(heads, d_model)# 实例化第二个多头注意力机制模块,用于进行编码器 - 解码器注意力计算,# 可以让解码器关注编码器的输出信息self.src_multihead = MultiHeadAttention(heads, d_model)# 实例化前馈神经网络模块,用于对多头注意力机制的输出进行非线性变换,# 增加模型的表达能力self.feed_forward = FeedForward(d_model)# 定义 Dropout 层,丢弃率为 0.1,用于在训练过程中随机将部分输入元素置为 0,# 防止模型过拟合self.dropout = nn.Dropout(0.1)def forward(self, embeddings, encoded, src_mask, target_mask):"""前向传播方法,定义了解码器层的具体计算流程。:param embeddings: 目标序列的嵌入向量,形状通常为 (batch_size, target_seq_len, d_model),其中 batch_size 是批次大小,target_seq_len 是目标序列长度,d_model 是模型维度。:param encoded: 编码器的输出,形状通常为 (batch_size, src_seq_len, d_model),其中 src_seq_len 是源序列长度。:param src_mask: 源序列的掩码矩阵,用于在编码器 - 解码器注意力计算中屏蔽某些位置的信息,形状通常为 (batch_size, 1, src_seq_len, src_seq_len)。:param target_mask: 目标序列的掩码矩阵,用于在自注意力计算中屏蔽某些位置的信息,通常是一个上三角矩阵,用于防止解码器看到未来的信息,形状为 (batch_size, 1, target_seq_len, target_seq_len)。:return: 解码后的向量,形状与输入的 embeddings 相同,为 (batch_size, target_seq_len, d_model)。"""# 第一步:目标序列的自注意力机制# 将输入的目标序列嵌入向量同时作为 query、key 和 value 输入到自注意力模块中,# 使用目标序列掩码 target_mask 进行计算,得到交互后的表示# 对自注意力模块的输出应用 Dropout 操作,防止过拟合query = self.dropout(self.self_multihead(embeddings, embeddings, embeddings, target_mask))# 第一个残差连接和层归一化# 将自注意力机制的输出与输入的目标序列嵌入向量相加,形成残差连接,# 有助于缓解梯度消失问题,使模型更容易训练# 对残差连接的结果应用层归一化操作,稳定训练过程query = self.layernorm(query + embeddings)# 第二步:编码器 - 解码器注意力机制# 将经过自注意力和层归一化处理后的结果作为 query,# 编码器的输出 encoded 同时作为 key 和 value,# 使用源序列掩码 src_mask 输入到多头注意力模块中,进行编码器 - 解码器注意力计算,# 让解码器关注编码器的输出信息# 对多头注意力模块的输出应用 Dropout 操作,防止过拟合interacted = self.dropout(self.src_multihead(query, encoded, encoded, src_mask))# 第二个残差连接和层归一化# 将编码器 - 解码器注意力机制的输出与经过自注意力和层归一化处理后的结果相加,# 形成残差连接# 对残差连接的结果应用层归一化操作interacted = self.layernorm(interacted + query)# 第三步:前馈神经网络# 将经过编码器 - 解码器注意力机制和层归一化处理后的结果输入到前馈神经网络中,# 进行非线性变换# 对前馈神经网络的输出应用 Dropout 操作,防止过拟合feed_forward_out = self.dropout(self.feed_forward(interacted))# 第三个残差连接和层归一化# 将前馈神经网络的输出与经过编码器 - 解码器注意力机制和层归一化处理后的结果相加,# 形成残差连接# 对残差连接的结果应用层归一化操作,得到最终的解码结果decoded = self.layernorm(feed_forward_out + interacted)return decoded
Decoder 在其所流经的第一个多头注意力机制模块中传入的掩码为因果掩码,用于确保在生成某个位置的输出时,解码器只能关注到该位置及其之前的输入信息,而不能看到未来的信息。
Decoder 所流经的第二个多头注意力机制模块的q来源于解码器自身第一个注意力层的输出,k和v来自编码器的输出,由于q,k,v来源不同,该层也被成为交叉自注意力层。同时该层计算时,会传入原序列对应的padding掩码矩阵,用于屏蔽源序列中的填充位置,防止模型在自注意力计算时关注到这些无意义的位置。
Point-wise Feed Forward Network
在 Transformer 架构里,FeedForward 层也被称作逐点前馈网络(Point-wise Feed Forward Network),主要是因为以下两点原因:
- 独立处理每个位置:逐点前馈网络对输入序列中的每个位置(时间步)进行独立的计算,各个位置之间不存在交互。也就是说,在该网络的计算过程中,对于输入序列里的每一个元素,都是按照相同的方式进行处理的,彼此之间没有依赖关系。例如,在一个句子的嵌入表示中,每个词的嵌入向量都会独立地经过前馈网络的计算,不会受到其他词的影响。
- 共享参数:逐点前馈网络在整个序列上共享相同的参数。具体而言,FeedForward 层中的两个线性层 self.fc1 和 self.fc2 的权重参数在处理输入序列的所有位置时都是一样的。这种参数共享的方式大大减少了模型的参数数量,提升了计算效率。
class FeedForward(nn.Module):"""前馈神经网络。"""def __init__(self, d_model, middle_dim=2048):super(FeedForward, self).__init__()self.fc1 = nn.Linear(d_model, middle_dim)self.fc2 = nn.Linear(middle_dim, d_model)self.dropout = nn.Dropout(0.1)def forward(self, x):out = F.relu(self.fc1(x))out = self.fc2(self.dropout(out))return out
下面通过一个实例来演示该过程:
# 初始化参数值
batch_size = 1
embedd_size = 8
seq_len = 6
middle_dim = 16
自注意力机制负责将同一个序列中所有词向量按照注意力权重进行信息融合,经过自注意力机制的处理后,Transformer再将数据通过前馈层独立的完成对单个词向量中信息的融合。
Transformer
我们上面已经讲解完了Transformer中涉及到的所有组件,下面看看如何把这些组件组装成一个完整的transformer模型。
import torch.nn.functional as Fclass Transformer(nn.Module):"""Transformer模型。Transformer是一种基于注意力机制的深度学习模型,广泛应用于自然语言处理任务,由编码器(Encoder)和解码器(Decoder)组成,能够处理序列到序列的任务。"""def __init__(self, d_model, heads, num_layers, word_map, max_len=260):"""初始化Transformer模型。:param d_model: 模型的维度,即嵌入向量和隐藏层的维度大小。:param heads: 多头注意力机制中的头数。:param num_layers: 编码器和解码器的层数。:param word_map: 词汇表,用于将单词映射为索引。:param max_len: 输入序列的最大长度,默认为260。"""super(Transformer, self).__init__()# 保存模型的维度self.d_model = d_model# 计算词汇表的大小self.vocab_size = len(word_map)# 初始化嵌入层,用于将输入的单词索引转换为嵌入向量self.embed = Embeddings(self.vocab_size, d_model, max_len=max_len)# 初始化编码器层列表,包含num_layers个EncoderLayer实例self.encoder = nn.ModuleList([EncoderLayer(d_model, heads) for _ in range(num_layers)])# 初始化解码器层列表,包含num_layers个DecoderLayer实例self.decoder = nn.ModuleList([DecoderLayer(d_model, heads) for _ in range(num_layers)])# 初始化线性层,用于将解码器的输出映射到词汇表大小的维度,以便进行单词预测self.logit = nn.Linear(d_model, self.vocab_size)def encode(self, src_words, src_mask):"""编码器前向传播,对源序列进行编码。:param src_words: 源序列的单词索引,形状通常为 (batch_size, src_seq_len):param src_mask: 源序列的掩码,用于屏蔽填充位置,形状通常为 (batch_size, 1, src_seq_len, src_seq_len):return: 编码后的源序列嵌入向量,形状为 (batch_size, src_seq_len, d_model)"""# 将源序列的单词索引通过嵌入层转换为嵌入向量src_embeddings = self.embed(src_words)# 依次通过编码器的每一层进行处理for layer in self.encoder:src_embeddings = layer(src_embeddings, src_mask)return src_embeddingsdef decode(self, target_words, target_mask, src_embeddings, src_mask):"""解码器前向传播,根据编码后的源序列和目标序列进行解码。:param target_words: 目标序列的单词索引,形状通常为 (batch_size, tgt_seq_len):param target_mask: 目标序列的掩码,包括填充掩码和因果掩码,形状通常为 (batch_size, 1, tgt_seq_len, tgt_seq_len):param src_embeddings: 编码后的源序列嵌入向量,形状为 (batch_size, src_seq_len, d_model):param src_mask: 源序列的掩码,用于屏蔽填充位置,形状通常为 (batch_size, 1, src_seq_len, src_seq_len):return: 解码后的目标序列嵌入向量,形状为 (batch_size, tgt_seq_len, d_model)"""# 将目标序列的单词索引通过嵌入层转换为嵌入向量tgt_embeddings = self.embed(target_words)# 依次通过解码器的每一层进行处理for layer in self.decoder:tgt_embeddings = layer(tgt_embeddings, src_embeddings, src_mask, target_mask)return tgt_embeddingsdef forward(self, src_words, src_mask, target_words, target_mask):"""Transformer模型的前向传播。:param src_words: 源序列的单词索引,形状通常为 (batch_size, src_seq_len):param src_mask: 源序列的掩码,用于屏蔽填充位置,形状通常为 (batch_size, 1, src_seq_len, src_seq_len):param target_words: 目标序列的单词索引,形状通常为 (batch_size, tgt_seq_len):param target_mask: 目标序列的掩码,包括填充掩码和因果掩码,形状通常为 (batch_size, 1, tgt_seq_len, tgt_seq_len):return: 经过Transformer模型处理后的输出,形状为 (batch_size, tgt_seq_len, vocab_size),表示每个位置预测每个单词的概率对数"""# 对源序列进行编码encoded = self.encode(src_words, src_mask)# 根据编码后的源序列和目标序列进行解码decoded = self.decode(target_words, target_mask, encoded, src_mask)# 将解码后的结果通过线性层映射到词汇表大小的维度# 并使用log_softmax函数将其转换为概率对数out = F.log_softmax(self.logit(decoded), dim=2)return out
Transformer 类实现了一个完整的 Transformer 模型,包含嵌入层、编码器、解码器和输出层。通过 encode 方法对源序列进行编码,decode 方法根据编码后的源序列和目标序列进行解码,最后在 forward 方法中组合编码和解码过程,并输出预测结果的概率对数。