ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Transformer 逐层拆解:从整体架构到 PyTorch 源码实现

Transformer 逐层拆解:从整体架构到 PyTorch 源码实现 目录一、静态词向量的困境二、先看全景Transformer 整体架构三、Attention 的直觉把注意力放在有价值的信息上四、Self-Attention句内的自己理解自己五、Self-Attention 是怎么算的5.1 第一步给每个词造三个角色——Q、K、V5.2 Q、K、V 各自扮演什么角色5.3 第二步内积打分——到底有多匹配5.4 第三步缩放与 softmax——从分数到权重5.5 第四步按得分分配特征5.6 矩阵化整个序列一次算完六、Multi-Head一次注意不够就多来几组6.1 类比 CNN 的 filter6.2 拼接与降维6.3 模块结构与完整源码6.4 不同 head 关注不同关系七、堆叠多层与前馈网络八、位置编码让模型知道词的顺序九、Add Norm残差连接与层归一化十、Decoder带 MASK 的生成器十一、输出层与损失函数十二、组装训练与推理全流程十三、延伸从 Transformer 到 BERT13.1 BERT 训练的词向量有什么不同13.2 BERT 就是 Transformer 的 Encoder13.3 训练方法一遮住 15% 的词猜一猜MLM13.4 训练方法二预测两个句子是否应该连在一起NSP13.5 如何使用 BERT任务和它一起练13.6 阅读理解预测答案的起止位置一、静态词向量的困境一切从输入说起词要先变成向量模型才有东西可算。而传统 word2vec 给出的词向量有个硬伤叫一词一向量——预训练好之后每个词的向量就永久固定了不管它出现在什么句子里。word2vec 把每个词映射成空间中的一个固定点。可是同一个词在不同语境里的意思并不相同预训练好的向量却再也不会变了。麻烦就出在固定二字同一个词在不同语境中往往含义不同——吃苹果里的苹果是水果苹果发布会里的苹果是公司但它们对应的向量始终是同一个。不同语境中相同的词该如何表达理想情况下一个词的表示应该随上下文动态变化。Transformer 给出的答案是不再把词向量当作最终表示而是当作初始表示交给 Self-Attention 根据上下文对整个序列做加权融合——同一个词出现在不同句子里融合后得到的表示并不相同。这个思路一路延伸就有了后面 BERT 的动态词向量。二、先看全景Transformer 整体架构看细节之前先把整栋楼的外观看清楚。Transformer 由左右两半组成左边是 Encoder编码器右边是 Decoder解码器各自都堆叠了 N 层。Transformer 整体架构。左侧 Encoder 堆叠 N 层每层包含 Self-Attention 和 Feed Forward 两个子层右侧 Decoder 同样堆叠 N 层多了一个 Encoder-Decoder Attention 子层最底部是 Positional Encoding最顶部是 Linear Softmax 输出层。这张图信息量很大不过不用慌可以把它拆成四个问题这也是本文后面各节的路线图1.输入如何编码——词向量之外还要加上位置编码。2.Attention 的目的是什么——让每个词按照相关性对整个序列加权。3.各个模块怎样组合——Multi-Head 加残差连接与归一化再加上前馈网络堆叠多层。4.输出结果是什么——Decoder 逐位生成最后经过 Linear 和 Softmax 得到词表上的概率分布。粗读架构图还能发现一个规律Encoder 和 Decoder 的每一层长得都很像都是注意力 前馈网络的重复单元。理解了一个单元就理解了整座塔。三、Attention 的直觉把注意力放在有价值的信息上Attention 这个词很形象对于输入的数据你的关注点是什么人看一张图、读一句话时注意力不会平均铺开而是聚焦在少数关键位置。机器翻译里最能体现这一点。把 She is eating a green apple 翻译成中文Decoder 在生成绿这个字的时候最需要参考的源语言词是 green——这就是注意力该放的地方。Encoder 读入英文句子并压缩成上下文向量Context vectorDecoder 逐词生成中文。没有注意力机制时所有信息都要挤在一个固定长度的向量里长句子很容易记不住。注意力机制要做的就是让计算机学会该看哪里生成每个输出时自动给输入中最相关的部分分配更大的权重。注意力的应用也不限于文本图像任务里同样直观给鸟类图片生成描述时注意力热力图清楚地显示模型正在盯着鸟的身体部位看。注意力让有价值的信息获得了更大的话语权。四、Self-Attention句内的自己理解自己上一节的翻译例子是两种序列之间的注意力源序列关注目标序列。Self-Attention 则更进一步——自己序列内部的词互相之间算注意力所以叫自注意力。经典例句The animal didnt cross the street becauseitwas too tired. 这里的 it 指的是什么语义上它指 animal。但如果把句尾换成 toonarrowit 就变成指 street 了——同一个词 it 的理解完全取决于上下文。注意力可视化工具中选中 it右侧高亮行后可以看到它对句中各词的注意力权重连线在 too tired 的语境下it 的注意力大量落在 animal 上。模型靠 Self-Attention 学会了指代消解。对机器来说如果每个词只是孤立编码it 就是 itanimal 就是 animal两者毫无关系。Self-Attention 让 it 的最终表示里吸收了 animal 的信息——每个词的新表示都是整个序列的加权组合。这就是自己序列内部自己理解自己。五、Self-Attention 是怎么算的这是全文最核心的一节。整个过程分四步造 Q、K、V → 内积打分 → 缩放并 softmax → 加权求和。一步一步来。5.1 第一步给每个词造三个角色——Q、K、V输入的每个词先经过编码Embedding变成向量 x。想得到当前词语和上下文的关系可以当作是一种加权为了完成加权给每个词构建三个向量——查询向量 q、键向量 k、值向量 v。它们分别由三个可训练的矩阵从 x 变换而来。补一句x 从哪来Embedding 层本质上是一张可训练的查表词 ID 进、向量出class Embeddings(nn.Module): 词嵌入层词 ID 查表得到 d_model 维向量 def __init__(self, d_model, vocab): super().__init__() self.lut nn.Embedding(vocab, d_model) self.d_model d_model def forward(self, x): # 查表得到向量后乘 sqrt(d_model) 放大 # 让嵌入与后面相加的位置编码处于相近的数值尺度 return self.lut(x) * math.sqrt(self.d_model)以 Thinking Machines 两个词为例。输入嵌入 x1、x2 分别与三个矩阵 W^Q、W^K、W^V 相乘得到 Queries、Keys、Values 三组向量。落到代码上这三个需要训练的矩阵就是三个线性层import torch import torch.nn as nn import torch.nn.functional as F import math, copy class QKVProjection(nn.Module): def __init__(self, d_model, d_k, d_v): super().__init__() self.wq nn.Linear(d_model, d_k, biasFalse) # W^Q生成查询 self.wk nn.Linear(d_model, d_k, biasFalse) # W^K生成键 self.wv nn.Linear(d_model, d_v, biasFalse) # W^V生成值 def forward(self, x): # x: [batch, seq_len, d_model] q self.wq(x) # [batch, seq_len, d_k] k self.wk(x) # [batch, seq_len, d_k] v self.wv(x) # [batch, seq_len, d_v] return q, k, v注意一个细节Q、K、V 全部来自同一份输入 x只是乘了三个不同的参数矩阵——这正是 Self 的体现也是自己序列内部自己算在代码上的对应。5.2 Q、K、V 各自扮演什么角色三个向量不是随便起的名字各自有明确的分工同一份输入 X分别乘以 W^Q、W^K、W^V 三个矩阵得到 Q、K、V。三个矩阵都是要训练的参数。向量名字含义通俗理解Qquery要去查询的我想找什么样的信息Kkey等着被查的我身上带什么标签可供检索Vvalue实际的特征信息匹配上之后我真正提供的内容用检索类比Q 是你输入搜索框的查询词K 是每篇文档的关键词标签V 是文档正文本身。你的查询词和每篇文档的标签匹配一遍算相关度相关度高的文档正文V就在最终结果里占更大比重。5.3 第二步内积打分——到底有多匹配有了 Q 和 K怎么衡量当前词跟某个上下文词的关联程度答案简洁到只有一步做内积。q 与 k 的内积表示有多匹配——两个向量方向越一致内积越大匹配分越高。以 Thinking 这个词为例它的查询向量 q1 分别和自己的键 k1、下一个词的键 k2 做内积得到两个分值q1·k1 112q1·k2 96。输入两个向量得到一个分值这就是匹配程度。矩阵形式下所有词两两之间的得分一次算完——Q 乘以 K 的转置# scores: [batch, seq_len, seq_len] # scores[i][j] 表示第 i 个词的 Q 与第 j 个词的 K 的匹配分 scores torch.matmul(q, k.transpose(-2, -1))5.4 第三步缩放与 softmax——从分数到权重拿到原始分数还不能直接用要先做两件事。第一件除以 √d_k 缩放。论文里这个操作叫 Scaled Dot-Product Attention。原始的内积分值有个坏毛病随着向量维度 d_k 的增大内积的数值会越来越大把分数喂进 softmax 之前如果不加控制输出会趋向 one-hot梯度近乎消失训练不动。所以标准做法是把分数除以 √d_k不让分值随着向量维度的增大而增加。注意力的完整公式 softmax(QK^T / √d_k) V。先算 Q 与 K 转置的乘积除以 √d_k 缩放softmax 归一化成权重最后对 V 加权求和得到输出 Z。第二件softmax 归一化。回忆一下 softmax它把任意一组实数变成和为 1 的概率分布。softmax 回忆。cat/car/frog 的原始分数 3.2、5.1、-1.7 经过 exp 变成 24.5、164.0、0.18再归一化得到 0.13、0.87、0.00——分数大的占比被进一步放大且总和恒为 1。两步合起来的完整注意力代码def attention(query, key, value, maskNone, dropoutNone): Scaled Dot-Product Attention 的标准实现 d_k query.size(-1) # 1. 内积打分 缩放 scores torch.matmul(query, key.transpose(-2, -1)) / math.sqrt(d_k) # 2. mask把不该看到的位置置为负无穷Decoder 里再细讲 if mask is not None: scores scores.masked_fill(mask 0, -1e9) # 3. softmax 归一化成注意力权重 p_attn F.softmax(scores, dim-1) if dropout is not None: p_attn dropout(p_attn) # 4. 对 V 加权求和 return torch.matmul(p_attn, value), p_attn5.5 第四步按得分分配特征softmax 之后每个分数变成了权重。注意力机制的本质在这一步显形Query 和每个 Key 匹配打分然后基于得分分配对应的 Value 特征——得分高的词它携带的特征信息就在结果里占的份额大。一个 Query 进来和 Source 里的 Key1 到 Key4 一一匹配打分再按分数加权 Value1 到 Value4最终得到 Attention Value。这说明了注意力的通用形态得分决定分配比例。对 Self-Attention 来说序列里每个词都做一遍这件事每个词的 Q 会跟整个序列中每一个K 计算得分然后基于得分再分配特征。5.6 矩阵化整个序列一次算完把单个词的计算推广到整个序列所有词的 Q 堆成矩阵 Q、K 堆成矩阵 K、V 堆成矩阵 V于是每个词的注意力可以同时算出来。完整流程数值示例。Thinking 的 q1 与 k1、k2 打分得 112、96除以 8√d_k得 14、12softmax 得 0.88、0.12用这两个权重对 v1、v2 加权求和得到 z1 0.88·v1 0.12·v2。这一步走完有三个值得回味的地方每个词看的不只是它前面的序列而是整个输入序列同一时间计算出所有词的表示结果——矩阵乘法天然并行每个 z 是一组带上下文的新特征这就是随语境变化的词表示。六、Multi-Head一次注意不够就多来几组6.1 类比 CNN 的 filter一组 q、k、v 经过一次注意力得到了一组当前词的特征表达。但一组表达够吗想想卷积神经网络——CNN 里一层有多个 filter每个 filter 提取一种特征特征图通道越多学到的模式越丰富。多头机制给每个注意力头单独配一组 W 矩阵Attention Head #0 用 W0^Q、W0^K、W0^VAttention Head #1 用 W1^Q、W1^K、W1^V各组互不干扰。CNN 中的特征图。不同卷积核从同一张图里提取不同特征。同样的思路搬到注意力上能不能用多组注意力同时提取多种特征这就是 multi-headed 机制的出发点。6.2 拼接与降维多组注意力多头各自算完之后得到多个特征表达。处理方式很直接将所有头的结果拼接在一起再通过一层全连接降维把拼接后的大向量压缩回原来的 d_model 维度。8 个注意力头各自的输出 z0 到 z7 先拼接成一个大向量再乘以 W^O 矩阵降维得到最终的 z。6.3 模块结构与完整源码把线性投影 → 缩放点积注意力 → 拼接 → 线性降维整条流水线画出来就是 Multi-Head Attention 模块左侧是多头的矩阵视角——每组 W 矩阵独立投影出 Q_i、K_i、V_i各自算出 z_i右侧是模块视角——多个 Scaled Dot-Product Attention 并联输出 Concat 后经 Linear 得到最终结果h 为头数。Multi-Head Attention 模块。输入 V、K、Q 各经过一个 Linear 层进入 h 个并行的缩放点积注意力输出拼接Concat后再过一层 Linear。对应的完整实现以 h8、d_model512 为例def clones(module, N): 深拷贝 N 份相同的子模块 return nn.ModuleList([copy.deepcopy(module) for _ in range(N)]) class MultiHeadedAttention(nn.Module): def __init__(self, h, d_model, dropout0.1): super().__init__() assert d_model % h 0 self.d_k d_model // h # 每个头的维度 512 / 8 64 self.h h self.linears clones(nn.Linear(d_model, d_model), 4) # 前3个投影QKV第4个做输出降维 self.attn None self.dropout nn.Dropout(pdropout) def forward(self, query, key, value, maskNone): if mask is not None: mask mask.unsqueeze(1) # 所有头共用同一个 mask nbatches query.size(0) # 1. 线性投影并拆头[batch, seq, d_model] - [batch, h, seq, d_k] query, key, value [ l(x).view(nbatches, -1, self.h, self.d_k).transpose(1, 2) for l, x in zip(self.linears, (query, key, value)) ] # 2. 每个头并行做缩放点积注意力 x, self.attn attention(query, key, value, maskmask, dropoutself.dropout) # 3. 拼接所有头[batch, h, seq, d_k] - [batch, seq, h*d_k] x x.transpose(1, 2).contiguous().view(nbatches, -1, self.h * self.d_k) # 4. 最后一层 Linear 降维回 d_model return self.linears[-1](x)几处实现细节值得停下来体会总计算量不涨d_model 被均分给 h 个头512 ÷ 8 64每个头在低维空间里独立做注意力拼接后再降回 512参数量和单头全维注意力基本相当view transpose 是拆头的核心把一个大矩阵折叠成 h 份小矩阵多头由此在张量维度上实现完全并行第 4 个 Linear 就是图 15 里那个 W^O。6.4 不同 head 关注不同关系多头不是摆设。可视化训练好的注意力会发现不同的注意力头关注的模式确实不同得到的特征向量表达也不相同——有的头盯着句法关系有的头盯着指代关系有的头看相邻词。同一个句子的两种注意力连线绿色与红色两组。不同的头捕捉到了不同的词与词之间的关系模式它们拼接在一起构成了比单一注意力更丰富的表示。七、堆叠多层与前馈网络一层注意力能学到的关系终究有限一层怎能够Transformer 的做法是堆叠多层把注意力 前馈网络作为一个标准单元重复 N 次而且每层的计算方法都相同——只是各层参数独立。Encoder #1 的完整工作流。输入嵌入 x1、x2 先经 Self-Attention 融合上下文得到 z1、z2再各自通过 Feed Forward Neural Network 得到 r1、r2交给 Encoder #2 继续加工。上一层输出就是下一层输入。其中 Feed Forward前馈网络FFN是每个词独立通过的两层全连接先升维再降维中间用 ReLU 激活。它和注意力的分工是注意力负责跨词融合信息FFN 负责对每个词自身做非线性变换。class PositionwiseFeedForward(nn.Module): 前馈网络两层全连接先升维 d_ff通常4倍再降回 d_model def __init__(self, d_model, d_ff, dropout0.1): super().__init__() self.w_1 nn.Linear(d_model, d_ff) # 升维 512 - 2048 self.w_2 nn.Linear(d_ff, d_model) # 降维 2048 - 512 self.dropout nn.Dropout(dropout) def forward(self, x): return self.w_2(self.dropout(F.relu(self.w_1(x))))把注意力和 FFN 组装成一个完整的 Encoder 层class EncoderLayer(nn.Module): 一层 Encoder Self-Attention 前馈网络各自带残差与归一化 def __init__(self, size, self_attn, feed_forward, dropout): super().__init__() self.self_attn self_attn self.feed_forward feed_forward self.sublayer clones(SublayerConnection(size, dropout), 2) self.size size def forward(self, x, mask): # 子层1QKVx 的自注意力 x self.sublayer[0](x, lambda x: self.self_attn(x, x, x, mask)) # 子层2前馈网络 return self.sublayer[1](x, self.feed_forward)这里的SublayerConnection是残差与归一化的封装八、位置编码让模型知道词的顺序还有一个隐患要解决。在 Self-Attention 中每个词都会考虑整个序列的加权权重只由内容相似度决定词出现的位置并不会对结果产生影响——把句子里的词随便打乱注意力结果一模一样相当于放哪都无所谓。但这跟实际语言是矛盾的我打他和他打我意思天差地别。我们希望模型能对位置有额外的认识。解决方案是位置编码给每个位置生成一个位置向量直接加到词向量上。Embedding with time signal。每个输入词的嵌入绿色加上对应的位置编码黄色得到真正送入注意力的表示黄绿相加。这样每个词既携带语义也携带我排在第几位的信息。论文用的是三角函数编码位置 pos 的第 i 维由 sin/cos 生成不同维度对应不同频率class PositionalEncoding(nn.Module): def __init__(self, d_model, dropout0.1, max_len5000): super().__init__() self.dropout nn.Dropout(pdropout) pe torch.zeros(max_len, d_model) # [max_len, d_model] position torch.arange(0, max_len).unsqueeze(1).float() div_term torch.exp(torch.arange(0, d_model, 2).float() * -(math.log(10000.0) / d_model)) pe[:, 0::2] torch.sin(position * div_term) # 偶数维用 sin pe[:, 1::2] torch.cos(position * div_term) # 奇数维用 cos pe pe.unsqueeze(0) self.register_buffer(pe, pe) # 不参与训练 def forward(self, x): # 词向量 位置编码直接逐元素相加 x x self.pe[:, : x.size(1)].requires_grad_(False) return self.dropout(x)选 sin/cos 有两个精妙之处不同位置的编码值有规律可区分任意相对位置 posk 的编码都能表示成 pos 位置编码的线性变换模型容易学到相对距离。而且它没有可训练参数训练中没见过的序列长度也能直接外推。九、Add Norm残差连接与层归一化每个子层注意力或 FFN的输出并不是直接传给下一层而是经过Add 与 Normalize两步处理。归一化Normalize把每个样本的特征维度归一到均值 0、方差 1稳定训练。Transformer 用的是 LayerNorm 而不是 BatchNorm——BatchNorm 沿 batch 方向统计句子长短不一、batch 内相互干扰效果不好LayerNorm 在每个样本自己的特征维度上统计跟 batch 里其他句子无关。BatchNorm 在 batch 方向上做归一化蓝色横向LayerNorm 在每个样本的特征维度上做归一化红色纵向。序列任务里 LayerNorm 不受句长和 batch 组成影响更稳定。连接Add基本的残差连接方式——子层输出加上输入本身残差连接。输入 x 经过两层权重层和 ReLU 得到 F(x)最后输出 F(x) x。捷径让梯度可以原路返回深层网络也能稳定训练。合在一起每个子层都有这样的封装class LayerNorm(nn.Module): 层归一化在每个样本的特征维度上归一到均值0、方差1 def __init__(self, features, eps1e-6): super().__init__() self.a_2 nn.Parameter(torch.ones(features)) # 缩放因子可学习 self.b_2 nn.Parameter(torch.zeros(features)) # 平移因子可学习 self.eps eps def forward(self, x): mean x.mean(-1, keepdimTrue) std x.std(-1, keepdimTrue) return self.a_2 * (x - mean) / (std self.eps) self.b_2 class SublayerConnection(nn.Module): 残差连接 归一化x Dropout(Sublayer(Norm(x))) def __init__(self, size, dropout): super().__init__() self.norm LayerNorm(size) self.dropout nn.Dropout(dropout) def forward(self, x, sublayer): return x self.dropout(sublayer(self.norm(x)))对应架构图里每个子层旁的 Add Normalize 方块归一化后的输入送进子层子层输出经 dropout 后与输入相加——残差保证信息畅通归一化保证数值稳定这正是 Transformer 能堆到 N 层而不崩的关键。十、Decoder带 MASK 的生成器Encoder 端是理解输入Decoder 端是生成输出两者有两处关键不同。第一处Attention 计算方式不同。Decoder 里有两层注意力翻译 je suis étudiant 到 I am a student。Decoding time step 5 时Encoder 输出的 K_encode、V_encode 被送入 Decoder 的 Encoder-Decoder AttentionDecoder 的输入是已生成的词previous outputs。Linear Softmax 在最顶端输出下一个词。Masked Self-AttentionDecoder 看自己已生成的部分但加入了MASK 机制——生成第 5 个词时只能看到前 4 个词不许偷看后面的答案Encoder-Decoder AttentionQ 来自 Decoder 当前状态而K 和 V 来自 Encoder 的输出。相当于 Decoder 拿自己的状态当查询词去 Encoder 编码好的源句子里检索最相关的信息。MASK 的实现只需两行——构造一个上三角屏蔽矩阵def subsequent_mask(size): 生成下三角 mask位置 i 只能看见 i 的位置 attn_shape (1, size, size) mask torch.triu(torch.ones(attn_shape), diagonal1) # 上三角为1 return mask 0 # 上三角为False结合 5.4 节 attention 函数里的masked_fill(mask 0, -1e9)未来位置加 -1e9 后softmax 权重趋近 0相当于看不见。完整的 Decoder 层class DecoderLayer(nn.Module): 一层 Decoder Masked自注意力 Encoder-Decoder注意力 前馈网络 def __init__(self, size, self_attn, src_attn, feed_forward, dropout): super().__init__() self.size size self.self_attn self_attn # Masked Self-Attention self.src_attn src_attn # Encoder-Decoder Attention self.feed_forward feed_forward self.sublayer clones(SublayerConnection(size, dropout), 3) def forward(self, x, memory, src_mask, tgt_mask): m memory # memoryEncoder 的最终输出 x self.sublayer[0](x, lambda x: self.self_attn(x, x, x, tgt_mask)) # Q 来自 DecoderK/V 来自 Encoder 输出 x self.sublayer[1](x, lambda x: self.src_attn(x, m, m, src_mask)) return self.sublayer[2](x, self.feed_forward)顺带把 mask 说全。Transformer 里实际有两类 MASK作用不同、经常配合使用序列 mask下三角矩阵挡住未来只在 Decoder 的自注意力里使用padding mask一个 batch 里的句子长短不一短句末尾要用占位符补齐才能拼成张量这些位置不携带任何语义必须屏蔽否则注意力权重会白白分给无意义的占位符。两类 mask 都是可见为 1、屏蔽为 0的矩阵叠加时按位相与两者都可见才可见。Encoder 侧只需要 padding maskDecoder 侧是两者的叠加——代码里反复出现的src_mask、tgt_mask参数携带的正是这些信息的组合。十一、输出层与损失函数Decoder 堆叠完最后一层输出的是每个位置的高维隐藏向量还不是词。要经过两层变换才能得到最终预测结果先过 Linear 投影到词表大小如 vocab_size 30000 维再过 Softmax 变成概率分布。Decoder 输出向量经 Linear 层映射成词表大小的分数再经 Softmax 得到每个词的概率取概率最大的作为当前步的预测词。class Generator(nn.Module): 输出头Linear Softmax映射到词表 def __init__(self, d_model, vocab): super().__init__() self.proj nn.Linear(d_model, vocab) def forward(self, x): return F.log_softmax(self.proj(x), dim-1)训练时的损失函数用 cross-entropy 即可拿每个位置预测出的词概率分布和真实下一个词做交叉熵整句的误差平均后反向传播。# Generator 输出的是 log 概率直接与真实词 ID 配对算损失 criterion nn.NLLLoss() loss criterion(out.reshape(-1, vocab_size), trg[:, 1:].reshape(-1)) # 标签目标序列左移一位对齐工程上常在交叉熵之外再加标签平滑label smoothing避免模型对正确词过度自信这里从简不展开。十二、组装训练与推理全流程把前面所有模块拼回全景图整体梳理。左侧 Encoder输入嵌入 位置编码后经过 N 层Multi-Head Attention Feed Forward各带 Add Norm右侧 Decoder输入右移一位经过 N 层Masked Multi-Head Attention Multi-Head Attention Feed Forward最后 Linear Softmax 输出概率。Encoder 与 Decoder 的堆叠结构用代码表达非常紧凑class Encoder(nn.Module): N 层 Encoder 堆叠 def __init__(self, layer, N): super().__init__() self.layers clones(layer, N) self.norm LayerNorm(layer.size) def forward(self, x, mask): for layer in self.layers: x layer(x, mask) return self.norm(x)Decoder 的堆叠完全对称只是子层调用时多了 memory 与 tgt_maskclass Decoder(nn.Module): N 层 Decoder 堆叠 def __init__(self, layer, N): super().__init__() self.layers clones(layer, N) self.norm LayerNorm(layer.size) def forward(self, x, memory, src_mask, tgt_mask): for layer in self.layers: x layer(x, memory, src_mask, tgt_mask) return self.norm(x)最后把所有模块装进一个顶层类——前面推理片段里用到的model.encode、model.decode、model.generator都定义在这里class EncoderDecoder(nn.Module): 完整 TransformerEmbedding Encoder Decoder 输出头 def __init__(self, encoder, decoder, src_embed, tgt_embed, generator): super().__init__() self.encoder encoder self.decoder decoder self.src_embed src_embed # 源语言Embedding 位置编码 self.tgt_embed tgt_embed # 目标语言Embedding 位置编码 self.generator generator # Linear Softmax 输出头 def encode(self, src, src_mask): return self.encoder(self.src_embed(src), src_mask) def decode(self, memory, src_mask, tgt, tgt_mask): return self.decoder(self.tgt_embed(tgt), memory, src_mask, tgt_mask) def forward(self, src, tgt, src_mask, tgt_mask): return self.generator( self.decode(self.encode(src, src_mask), src_mask, tgt, tgt_mask))并行加速训练体现在训练阶段的喂法上采用 teacher forcing——把完整的目标序列右移一位当输入原序列当标签一次性喂进 Decoder用 MASK 保证每个位置只能看到过去于是整句的所有位置在一步内并行算完损失。# 训练一次前向整句并行 out model(src, trg[:, :-1], src_mask, trg_mask) # 输入目标序列右移一位 loss criterion(out.reshape(-1, vocab_size), trg[:, 1:].reshape(-1)) # 标签目标序列左移对齐 loss.backward()而推理没有标签只能自回归从起始符开始每步把已生成的词喂进 Decoder预测下一个词再把新词接回去循环直到生成结束符# 推理自回归一个词一个词生成 memory model.encode(src, src_mask) # Encoder 只算一次 ys torch.full((1, 1), start_symbol) for i in range(max_len - 1): out model.decode(memory, src_mask, ys, subsequent_mask(ys.size(1))) # 每步重算 Decoder prob model.generator(out[:, -1]) # 取最后位置的分布 next_word torch.argmax(prob, dim-1) ys torch.cat([ys, next_word.unsqueeze(0).type_as(src.data)], dim1) if next_word end_symbol: break注意训练与推理的不对称Encoder 的 memory 在推理时只算一次每步只需重跑 Decoder——这也是工程上重要的加速点。训练充分后注意力可视化显示模型确实学到了语义关联效果展示。上句 too tired 时it 的注意力集中在 animal左下句换成 too wide 后it 的注意力转移到了 street右。模型不仅建立了指代关联还能随语境正确切换。十三、延伸从 Transformer 到 BERTTransformer 本体讲完了。它有两个著名后代一个只用 Encoder一个只用 Decoder这里顺着课件路线看前者——BERT。13.1 BERT 训练的词向量有什么不同先回到第一节的静态向量问题在 word2vec 中相同词对应的向量训练好后就固定了但在不同的场景中干哈呢的意思会相同吗显然不会。BERT 的目标就是产出随上下文变化的动态词向量。13.2 BERT 就是 Transformer 的 EncoderBERT 的全称是 Bidirectional Encoder Representations from Transformers——从命名就能看出它是什么说白了就是 Transformer 的 Encoder 部分。Transformer 原论文里Encoder 与 Decoder 是并立的两个模块。BERT 取走左侧 Encoder 全家桶嵌入 位置编码 N 层Self-Attention Feed Forward。取 Encoder 有个天然优势Encoder 的 Self-Attention 是双向的——每个词同时看左边和右边这正是 Bidirectional 的含义。而且它的训练并不需要标签有语料就能训练属于自监督学习。13.3 训练方法一遮住 15% 的词猜一猜MLM第一种训练方法句子中有 15% 的词汇被随机 mask 掉交给模型去预测被 mask 的家伙到底是什么。输入今天 [MASK] 玩 DOTA被 mask 位置的输出向量接一个多分类器在语料库词语数那么大的类别上做分类。预测对了说明模型真的利用了前后文。一个工程细节词语的可能性太多了所以中文一般按字来预测把分类类别数控制在可承受范围。如果 BERT 训练的向量好那分类自然 OK——分类器的准确率反过来逼着向量表达把上下文信息学进去。13.4 训练方法二预测两个句子是否应该连在一起NSP第二种方法训练句子级的理解预测两个句子是否应该连在一起。输入 [CLS] 赶紧 上号 [SEP] 准备 打 排位模型输出 yes而 [CLS] 赶紧 上号 [SEP] 老师 马上 点名 两句毫无关联输出 No。[SEP] 是两个句子之前的连接符[CLS] 是表示要做分类的向量——它的输出专门用来做句子级分类。13.5 如何使用 BERT任务和它一起练拿到预训练好的 BERT下游怎么用是不是需要先训练好向量的表达然后再训练需要的模型呢不需要这么麻烦——把所需的任务直接融入 BERT 中即可它俩一起训练。分类任务。句级分类取 [CLS] 位置的输出接一个分类器词级任务如序列标注则每个词的输出各接一个分类器。微调时整个模型端到端一起更新。13.6 阅读理解预测答案的起止位置更复杂一点的任务是阅读理解题输入是文章和问题输出是理解的答案位置——答案就是文章中的一段 span。文章 D {d1, d2, ..., dN} 和问题 Q {q1, q2, ..., qN} 一起送入 QA 模型输出答案的起始位置 s 和终止位置 e结果 A {q_s, ..., q_e} 就是文章中从第 s 个词到第 e 个词的片段。网络设计——需要分别计算答案的起始和终止位置。[CLS] 后接问题 q1、q2[SEP] 后接文章 d1、d2、d3。额外训练两个辅助向量分别与文章各位置的输出做 dot product经 Softmax 得到每个位置是答案起点的概率此例中 d2 以 0.5 最高。终止位置同理用另一个辅助向量。至此可以看到一条完整的演进链Transformer 提供了并行、双向、注意力驱动的强大骨架BERT 证明只用 Encoder 加自监督预训练就能产出动态词向量并在下游任务上通用同样的骨架换成 Decoder 再把训练目标改成预测下一个词就通向了 GPT 系列——大模型时代的大门由此打开。
返回列表