多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

从零理解Transformer:动画拆解Self-Attention与PyTorch实战

从零理解Transformer:动画拆解Self-Attention与PyTorch实战 如果你在2024年还在为理解Transformer而头疼觉得那些论文、公式和架构图像天书一样那么这篇文章就是为你准备的。Transformer早已不是NLP领域的专属它已经渗透到计算机视觉、语音识别、多模态甚至生物信息学等各个角落成为驱动当前AI浪潮的“发动机”。但问题是大多数教程要么过于学术堆砌公式要么过于浅显只讲皮毛看完依然不知道如何动手。这篇文章的核心判断是理解Transformer的关键不在于死记硬背“Attention is All You Need”里的每一个公式而在于搞懂其“分而治之”的核心设计思想以及如何将这种思想转化为可运行的代码。本文将彻底抛弃枯燥的理论堆砌用最直观的动画讲解思路结合从零到一的代码实战让你在理解原理的同时亲手搭建一个可运行的微型Transformer并看清它在实际项目如文本分类中是如何工作的。读完本文你将能清晰地回答以下问题Transformer的Encoder和Decoder到底在干什么为什么说Self-Attention是核心Q, K, V矩阵究竟代表什么它们的计算过程如何用代码实现位置编码Positional Encoding为什么必不可少有哪些实现方式如何从零开始用PyTorch搭建一个完整的Transformer模型如何将搭建好的Transformer应用于一个真实的NLP任务如情感分析我们直接从最核心、最让人困惑的部分开始。1. 抛弃幻想Transformer的本质是什么在深入细节之前我们必须建立一个正确的认知Transformer不是一个魔法黑盒而是一个精巧的、基于“注意力”机制的信息处理流水线。它的设计目标非常明确让模型在处理一个序列比如一句话时能够动态地、有侧重地“看到”序列中所有其他部分的信息而不受它们物理位置的限制。这解决了传统RNN/LSTM的最大痛点长距离依赖和信息处理的串行性。RNN必须一步步处理序列距离越远的信息越容易丢失或淡化。而Transformer通过Self-Attention机制让序列中的每个元素都能直接与所有其他元素“对话”一步到位地捕获全局上下文。你可以把它想象成一个高效的会议RNN/LSTM像是一个圆桌发言每个人只能听到前一个人说的话再传给下一个人信息在传递中会损耗或扭曲。Transformer像是每个人面前都有一个麦克风和全向耳机。每个人同时发言生成自己的Q, K, V所有人都能同时听到所有人的发言计算注意力权重并综合所有人的信息形成自己的新观点输出新的表示。这个“会议”的核心环节就是Self-Attention。2. 动画拆解Self-Attention 到底在计算什么网上很多动画只展示了“点积、缩放、Softmax”的流程但没说清楚为什么要这么做。我们结合一个具体例子来看。假设我们有一个包含两个词的微型序列“猫 吃”。首先每个词都会被转换成一个向量词嵌入。设“猫”的向量为[1, 0, 2]“吃”的向量为[0, 2, 1]。在Self-Attention中每个输入向量会衍生出三个新向量Query (Q)代表“我要寻找什么”。可以理解为当前词发出的“提问”。Key (K)代表“我有什么可提供的”。可以理解为每个词身上的“标签”或“关键词”。Value (V)代表“我真正的内容是什么”。是最终被加权求和的信息本体。计算过程动画帧请在心里想象帧1生成Q, K, V每个输入向量乘以三个不同的权重矩阵W_Q,W_K,W_V得到对应的Q, K, V。猫向量 [1,0,2] --W_Q-- Q1 猫向量 [1,0,2] --W_K-- K1 猫向量 [1,0,2] --W_V-- V1 吃向量 [0,2,1] --W_Q-- Q2 吃向量 [0,2,1] --W_K-- K2 吃向量 [0,2,1] --W_V-- V2帧2计算注意力分数猫看全场计算“猫”(Q1)与所有词的“标签”(K1, K2)的匹配度点积。分数1 Q1 · K1 猫与自己的关联度 分数2 Q1 · K2 猫与“吃”的关联度假设结果为分数18 分数24。帧3缩放与归一化将分数除以sqrt(d_k)Key向量的维度平方根为了稳定梯度然后通过Softmax函数将分数转化为权重总和为1的概率。缩放后分数8/√d_k ≈ 2.8 4/√d_k ≈ 1.4 Softmax: weight1 exp(2.8)/(exp(2.8)exp(1.4)) ≈ 0.8 weight2 exp(1.4)/(exp(2.8)exp(1.4)) ≈ 0.2这意味着在生成“猫”的新表示时它80%的注意力放在自己身上20%的注意力放在“吃”上。帧4加权求和输出新表示用上一步得到的权重对所有的Value (V) 进行加权求和。新的“猫”向量 0.8 * V1 0.2 * V2这个新的向量就是“猫”在考虑了整个句子上下文特别是“吃”之后的新表示。它不再是一个孤立的“猫”而是“正在执行吃这个动作的猫”。帧5并行计算注意上述过程是为“猫”计算的。实际上对于“吃”这个词也会并行地进行完全相同的操作以Q2为起点计算它与K1、K2的注意力然后加权求和V1、V2。Transformer的魔力就在于所有这些计算都可以通过矩阵运算并行完成极大提升了效率。这个过程就是Transformer理解语言关系的核心。接下来我们用代码把这一幕幕“动画”实现出来。3. 环境准备你的第一个Transformer实验室在开始写代码前确保你的环境已经就绪。我们使用PyTorch因为它动态图特性非常适合教学和实验。基础环境操作系统Windows 10/11, macOS, 或 Linux (Ubuntu 20.04) 均可。Python版本 3.8 或 3.93.10请留意PyTorch版本兼容性。包管理推荐使用conda或venv创建虚拟环境。安装核心依赖打开你的终端命令行执行以下命令# 使用pip安装以PyTorch 2.0 CPU版本为例GPU用户请访问PyTorch官网选择对应命令 pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu # 安装NumPy和Matplotlib用于可视化可选 pip install numpy matplotlib # 安装Jupyter Notebook可选用于交互式学习 pip install notebook验证安装是否成功# 创建一个 test_env.py 文件写入以下内容 import torch import numpy as np print(fPyTorch version: {torch.__version__}) print(fCUDA available: {torch.cuda.is_available()}) # 如果使用GPU这里会显示True x torch.rand(2, 3) print(fRandom tensor:\n{x})运行python test_env.py如果没有报错并输出版本信息和张量则环境准备完成。4. 从零实现手撕Self-Attention层理解了原理我们现在用PyTorch实现一个最基础的Scaled Dot-Product Attention。# 文件core_attention.py import torch import torch.nn as nn import torch.nn.functional as F import math class ScaledDotProductAttention(nn.Module): 缩放点积注意力机制。 输入: Q, K, V, 以及可选的注意力掩码 (mask) 输出: 加权后的Value以及注意力权重 (用于可视化) def __init__(self, dropout0.1): super(ScaledDotProductAttention, self).__init__() self.dropout nn.Dropout(dropout) def forward(self, Q, K, V, maskNone): # Q, K, V 的形状: (batch_size, num_heads, seq_len, d_k) # mask 的形状: (batch_size, 1, 1, seq_len) 或 (batch_size, 1, seq_len, seq_len) # 1. 计算Q和K的点积并缩放 d_k K.size(-1) # 获取Key的维度 scores torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(d_k) # (..., seq_len, seq_len) # 2. 应用注意力掩码如填充掩码、未来掩码 if mask is not None: # 将mask中为True的位置需要被屏蔽替换为一个非常大的负数使得Softmax后权重接近0 scores scores.masked_fill(mask 0, -1e9) # 3. 应用Softmax得到注意力权重 attn_weights F.softmax(scores, dim-1) # (..., seq_len, seq_len) attn_weights self.dropout(attn_weights) # 4. 权重乘以Value得到最终输出 output torch.matmul(attn_weights, V) # (..., seq_len, d_v) return output, attn_weights # 让我们用一个极简的例子来测试这个层 if __name__ __main__: # 模拟一个批次batch_size11个注意力头序列长度2向量维度3 batch_size, num_heads, seq_len, d_k, d_v 1, 1, 2, 3, 3 Q torch.randn(batch_size, num_heads, seq_len, d_k) K torch.randn(batch_size, num_heads, seq_len, d_k) V torch.randn(batch_size, num_heads, seq_len, d_v) attention ScaledDotProductAttention() output, attn_weights attention(Q, K, V) print(Query shape:, Q.shape) print(Key shape:, K.shape) print(Value shape:, V.shape) print(- * 30) print(注意力权重矩阵 (attn_weights):) print(attn_weights.squeeze()) # 去掉批次和头维度 print(输出 (output) shape:, output.shape)关键代码解释torch.matmul(Q, K.transpose(-2, -1))计算Q和K的矩阵乘法得到每个Query对所有Key的分数。/ math.sqrt(d_k)缩放操作防止点积结果过大导致Softmax梯度消失。masked_fill(mask 0, -1e9)这是处理变长序列和防止Decoder“偷看未来”的关键。对于需要屏蔽的位置将其分数设为一个极小的负数Softmax后权重几乎为0。F.softmax(scores, dim-1)在最后一个维度Key的序列方向做Softmax使得每个Query对所有Key的注意力权重和为1。运行这段代码你会看到一个2x2的注意力权重矩阵它直观地展示了我们例子中“猫”和“吃”这两个词之间的相互关注程度。5. 构建完整模块Multi-Head Attention与Transformer层单一的注意力头可能只关注一种类型的关系。Multi-Head Attention允许模型同时关注来自不同表示子空间的信息。# 文件multi_head_attention.py class MultiHeadAttention(nn.Module): 多头注意力机制。 将输入线性投影到h个头分别计算注意力最后拼接并投影回原维度。 def __init__(self, d_model, num_heads, dropout0.1): super(MultiHeadAttention, self).__init__() assert d_model % num_heads 0, d_model must be divisible by num_heads self.d_model d_model # 模型总维度 self.num_heads num_heads # 头的数量 self.d_k d_model // num_heads # 每个头的维度 # 定义四个线性投影层W_Q, W_K, W_V, W_O self.W_Q nn.Linear(d_model, d_model) self.W_K nn.Linear(d_model, d_model) self.W_V nn.Linear(d_model, d_model) self.W_O nn.Linear(d_model, d_model) self.attention ScaledDotProductAttention(dropout) self.dropout nn.Dropout(dropout) self.layer_norm nn.LayerNorm(d_model) def forward(self, Q, K, V, maskNone): # 输入Q, K, V形状: (batch_size, seq_len, d_model) batch_size, seq_len, _ Q.size() # 1. 线性投影并分头 # 投影后形状: (batch_size, seq_len, d_model) # 分头后形状: (batch_size, num_heads, seq_len, d_k) Q self.W_Q(Q).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2) K self.W_K(K).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2) V self.W_V(V).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2) # 2. 应用缩放点积注意力每个头独立计算 # x形状: (batch_size, num_heads, seq_len, d_k) x, attn_weights self.attention(Q, K, V, mask) # 3. 合并多头 # 将头的维度移回并拼接 # (batch_size, num_heads, seq_len, d_k) - (batch_size, seq_len, d_model) x x.transpose(1, 2).contiguous().view(batch_size, -1, self.d_model) # 4. 输出投影 output self.W_O(x) return output, attn_weights # 测试多头注意力 if __name__ __main__: d_model 512 num_heads 8 batch_size 2 seq_len 10 mha MultiHeadAttention(d_model, num_heads) # 模拟输入假设Q, K, V相同自注意力 input_tensor torch.randn(batch_size, seq_len, d_model) output, attn mha(input_tensor, input_tensor, input_tensor) print(f输入形状: {input_tensor.shape}) print(f输出形状: {output.shape}) print(f注意力权重形状: {attn.shape}) # (batch_size, num_heads, seq_len, seq_len)有了Multi-Head Attention我们就可以构建Transformer的核心组件Encoder Layer和Decoder Layer。一个Encoder Layer通常包含一个多头自注意力子层和一个前馈神经网络子层每个子层后面都跟着残差连接和层归一化。# 文件transformer_layers.py class PositionwiseFeedForward(nn.Module): 位置式前馈网络每个位置独立进行相同的两层线性变换。 def __init__(self, d_model, d_ff, dropout0.1): super(PositionwiseFeedForward, self).__init__() self.linear1 nn.Linear(d_model, d_ff) self.linear2 nn.Linear(d_ff, d_model) self.dropout nn.Dropout(dropout) self.activation nn.GELU() # 原论文使用ReLU现代实现常用GELU def forward(self, x): return self.linear2(self.dropout(self.activation(self.linear1(x)))) class EncoderLayer(nn.Module): Transformer编码器层多头自注意力 前馈网络 def __init__(self, d_model, num_heads, d_ff, dropout0.1): super(EncoderLayer, self).__init__() self.self_attn MultiHeadAttention(d_model, num_heads, dropout) self.feed_forward PositionwiseFeedForward(d_model, d_ff, dropout) self.norm1 nn.LayerNorm(d_model) self.norm2 nn.LayerNorm(d_model) self.dropout1 nn.Dropout(dropout) self.dropout2 nn.Dropout(dropout) def forward(self, src, src_maskNone): # 子层1: 多头自注意力 残差 层归一化 attn_output, _ self.self_attn(src, src, src, src_mask) src src self.dropout1(attn_output) src self.norm1(src) # 子层2: 前馈网络 残差 层归一化 ff_output self.feed_forward(src) src src self.dropout2(ff_output) src self.norm2(src) return src # 解码器层略复杂一些包含两个注意力子层掩码自注意力 编码器-解码器注意力 class DecoderLayer(nn.Module): Transformer解码器层 def __init__(self, d_model, num_heads, d_ff, dropout0.1): super(DecoderLayer, self).__init__() self.self_attn MultiHeadAttention(d_model, num_heads, dropout) self.cross_attn MultiHeadAttention(d_model, num_heads, dropout) self.feed_forward PositionwiseFeedForward(d_model, d_ff, dropout) self.norm1 nn.LayerNorm(d_model) self.norm2 nn.LayerNorm(d_model) self.norm3 nn.LayerNorm(d_model) self.dropout1 nn.Dropout(dropout) self.dropout2 nn.Dropout(dropout) self.dropout3 nn.Dropout(dropout) def forward(self, tgt, memory, tgt_maskNone, memory_maskNone): # memory: 编码器的输出 # 子层1: 掩码多头自注意力防止看到未来信息 attn_output1, _ self.self_attn(tgt, tgt, tgt, tgt_mask) tgt tgt self.dropout1(attn_output1) tgt self.norm1(tgt) # 子层2: 编码器-解码器多头注意力 attn_output2, _ self.cross_attn(tgt, memory, memory, memory_mask) tgt tgt self.dropout2(attn_output2) tgt self.norm2(tgt) # 子层3: 前馈网络 ff_output self.feed_forward(tgt) tgt tgt self.dropout3(ff_output) tgt self.norm3(tgt) return tgt6. 注入位置信息Positional Encoding的奥秘Transformer本身没有循环或卷积结构因此无法感知序列中元素的顺序。位置编码Positional Encoding就是为了解决这个问题。它向输入嵌入中添加了包含位置信息的向量。最常用的是正弦余弦位置编码# 文件positional_encoding.py import torch import torch.nn as nn import math class PositionalEncoding(nn.Module): 实现正弦余弦位置编码。 PE(pos, 2i) sin(pos / 10000^(2i/d_model)) PE(pos, 2i1) cos(pos / 10000^(2i/d_model)) def __init__(self, d_model, max_len5000, dropout0.1): super(PositionalEncoding, self).__init__() self.dropout nn.Dropout(pdropout) # 计算位置编码矩阵 pe torch.zeros(max_len, d_model) position torch.arange(0, max_len, dtypetorch.float).unsqueeze(1) # (max_len, 1) 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) # (1, max_len, d_model) 增加批次维度 self.register_buffer(pe, pe) # 注册为缓冲区不参与训练但会保存到模型状态 def forward(self, x): # x形状: (batch_size, seq_len, d_model) x x self.pe[:, :x.size(1)] # 只取前seq_len个位置编码 return self.dropout(x) # 可视化位置编码 if __name__ __main__: import matplotlib.pyplot as plt d_model 128 max_len 100 pe_layer PositionalEncoding(d_model, max_len, dropout0) # 生成一个虚拟输入 dummy_input torch.zeros(1, max_len, d_model) output pe_layer(dummy_input) # 绘制位置编码的热图 plt.figure(figsize(10, 6)) plt.imshow(output.squeeze().numpy().T, aspectauto, cmapRdBu) plt.xlabel(Position in sequence) plt.ylabel(Dimension) plt.colorbar(labelValue) plt.title(Sinusoidal Positional Encoding (First 100 positions, 128 dims)) plt.tight_layout() plt.savefig(positional_encoding.png) print(位置编码热图已保存为 positional_encoding.png。) # 你会看到不同位置、不同维度上呈现出的规律性正弦波图案。为什么是正弦余弦这种函数形式允许模型轻松学习到相对位置关系。对于任意固定的偏移量kPE(posk)可以表示为PE(pos)的线性函数这意味着模型能够推断出训练时未见过的序列长度中的位置关系。7. 组装完整TransformerEncoder与Decoder堆叠现在我们将所有组件组合起来构建完整的Transformer模型。为了简化我们先构建一个只有Encoder的Transformer用于完成像文本分类这样的任务。# 文件transformer_encoder.py import torch.nn as nn from .transformer_layers import EncoderLayer from .positional_encoding import PositionalEncoding class TransformerEncoder(nn.Module): 完整的Transformer编码器由N个编码器层堆叠而成。 def __init__(self, vocab_size, d_model, num_layers, num_heads, d_ff, max_seq_len, dropout0.1): super(TransformerEncoder, self).__init__() self.d_model d_model # 1. 词嵌入层 self.token_embedding nn.Embedding(vocab_size, d_model) # 2. 位置编码层 self.positional_encoding PositionalEncoding(d_model, max_seq_len, dropout) # 3. 编码器层堆叠 self.layers nn.ModuleList([ EncoderLayer(d_model, num_heads, d_ff, dropout) for _ in range(num_layers) ]) # 4. 最终的层归一化有些实现有有些没有原论文在子层后归一化 self.norm nn.LayerNorm(d_model) def forward(self, src, src_maskNone): # src形状: (batch_size, src_seq_len) # src_mask形状: (batch_size, 1, 1, src_seq_len) 或 (batch_size, 1, src_seq_len, src_seq_len) # 1. 词嵌入 缩放可选有些实现会乘以sqrt(d_model) x self.token_embedding(src) * math.sqrt(self.d_model) # 2. 添加位置编码 x self.positional_encoding(x) # 3. 通过所有编码器层 for layer in self.layers: x layer(x, src_mask) # 4. 最终归一化 x self.norm(x) return x # (batch_size, src_seq_len, d_model) # 构建一个用于分类的Transformer模型 class TransformerForSequenceClassification(nn.Module): 基于Transformer Encoder的文本分类模型。 def __init__(self, vocab_size, d_model, num_layers, num_heads, d_ff, max_seq_len, num_classes, dropout0.1): super(TransformerForSequenceClassification, self).__init__() self.encoder TransformerEncoder(vocab_size, d_model, num_layers, num_heads, d_ff, max_seq_len, dropout) # 分类头通常取第一个token的输出[CLS]或所有token输出的平均/最大池化 self.classifier nn.Linear(d_model, num_classes) self.dropout nn.Dropout(dropout) def forward(self, input_ids, attention_maskNone): # attention_mask: 用于忽略padding部分形状(batch_size, seq_len) # 转换为Transformer需要的mask格式 if attention_mask is not None: # 扩展维度: (batch_size, 1, 1, seq_len) encoder_mask attention_mask.unsqueeze(1).unsqueeze(2) else: encoder_mask None encoder_output self.encoder(input_ids, encoder_mask) # (batch_size, seq_len, d_model) # 使用第一个token的输出作为句子表示类似BERT的[CLS] pooled_output encoder_output[:, 0, :] # (batch_size, d_model) pooled_output self.dropout(pooled_output) logits self.classifier(pooled_output) # (batch_size, num_classes) return logits # 实例化一个微型模型进行测试 if __name__ __main__: vocab_size 10000 # 词汇表大小 d_model 128 # 模型隐藏层维度 num_layers 4 # 编码器层数 num_heads 8 # 注意力头数 d_ff 512 # 前馈网络中间层维度 max_seq_len 128 # 最大序列长度 num_classes 2 # 二分类任务 model TransformerForSequenceClassification( vocab_size, d_model, num_layers, num_heads, d_ff, max_seq_len, num_classes ) # 模拟一个批次的数据 batch_size 4 seq_len 20 dummy_input torch.randint(0, vocab_size, (batch_size, seq_len)) dummy_mask torch.ones(batch_size, seq_len) # 假设没有padding output model(dummy_input, dummy_mask) print(f模型输出形状 (logits): {output.shape}) # 应为 (4, 2) print(模型结构摘要:) print(model)8. 项目实战用我们的Transformer进行情感分析理论最终要服务于实践。我们现在用一个真实的数据集IMDb电影评论来训练我们刚刚搭建的Transformer分类模型。步骤1准备数据我们将使用torchtext库来加载和预处理IMDb数据集。# 安装 torchtext pip install torchtext# 文件train_sentiment.py import torch from torch import nn, optim from torch.utils.data import DataLoader from torchtext.datasets import IMDB from torchtext.data.utils import get_tokenizer from torchtext.vocab import build_vocab_from_iterator from transformer_encoder import TransformerForSequenceClassification import time # 1. 定义数据预处理管道 tokenizer get_tokenizer(basic_english) # 使用基础英文分词器 def yield_tokens(data_iter): for _, text in data_iter: yield tokenizer(text) # 2. 加载数据集并构建词汇表 print(加载IMDb数据集并构建词汇表...) train_iter IMDB(splittrain) vocab build_vocab_from_iterator(yield_tokens(train_iter), specials[unk, pad, bos, eos]) vocab.set_default_index(vocab[unk]) # 设置默认索引为未知词 # 重新初始化迭代器因为上面已经消耗了一次 train_iter, test_iter IMDB(split(train, test)) # 3. 文本转换为索引的管道 text_pipeline lambda x: [vocab[token] for token in tokenizer(x)] label_pipeline lambda x: 1 if x pos else 0 # 4. 数据批处理函数包括padding def collate_batch(batch, max_len128): label_list, text_list [], [] for (_label, _text) in batch: label_list.append(label_pipeline(_label)) processed_text text_pipeline(_text)[:max_len] # 截断 # 填充到固定长度 if len(processed_text) max_len: processed_text processed_text [vocab[pad]] * (max_len - len(processed_text)) text_list.append(processed_text) return torch.tensor(label_list, dtypetorch.long), torch.tensor(text_list, dtypetorch.long) # 5. 创建数据加载器 batch_size 32 max_seq_len 128 train_loader DataLoader(list(train_iter), batch_sizebatch_size, shuffleTrue, collate_fnlambda b: collate_batch(b, max_seq_len)) test_loader DataLoader(list(test_iter), batch_sizebatch_size, shuffleFalse, collate_fnlambda b: collate_batch(b, max_seq_len)) print(f词汇表大小: {len(vocab)}) print(f训练批次数量: {len(train_loader)}) # 6. 初始化模型、损失函数和优化器 device torch.device(cuda if torch.cuda.is_available() else cpu) print(f使用设备: {device}) vocab_size len(vocab) d_model 256 num_layers 4 num_heads 8 d_ff 512 num_classes 2 model TransformerForSequenceClassification(vocab_size, d_model, num_layers, num_heads, d_ff, max_seq_len, num_classes).to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr1e-4, weight_decay1e-5) # 7. 训练循环 num_epochs 5 for epoch in range(num_epochs): model.train() total_loss 0 correct 0 total 0 start_time time.time() for batch_idx, (labels, texts) in enumerate(train_loader): labels, texts labels.to(device), texts.to(device) # 生成注意力掩码忽略padding部分 attention_mask (texts ! vocab[pad]).long().to(device) optimizer.zero_grad() outputs model(texts, attention_mask) loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() _, predicted outputs.max(1) total labels.size(0) correct predicted.eq(labels).sum().item() if (batch_idx 1) % 100 0: print(fEpoch [{epoch1}/{num_epochs}], Step [{batch_idx1}/{len(train_loader)}], Loss: {loss.item():.4f}) avg_loss total_loss / len(train_loader) accuracy 100. * correct / total epoch_time time.time() - start_time print(fEpoch [{epoch1}/{num_epochs}] 完成耗时: {epoch_time:.2f}s平均损失: {avg_loss:.4f}, 训练准确率: {accuracy:.2f}%) # 8. 在测试集上简单评估可选完整评估需计算精确率、召回率等 model.eval() test_correct 0 test_total 0 with torch.no_grad(): for labels, texts in test_loader: labels, texts labels.to(device), texts.to(device) attention_mask (texts ! vocab[pad]).long().to(device) outputs model(texts, attention_mask) _, predicted outputs.max(1) test_total labels.size(0) test_correct predicted.eq(labels).sum().item() test_accuracy 100. * test_correct / test_total print(f测试集准确率: {test_accuracy:.2f}%\n) print(训练完成) # 可以保存模型 # torch.save(model.state_dict(), transformer_sentiment_model.pth)运行结果与效果验证运行上述训练脚本你会看到类似以下的输出具体数值会因随机初始化而异加载IMDb数据集并构建词汇表... 词汇表大小: 100352 训练批次数量: 782 使用设备: cuda Epoch [1/5], Step [100/782], Loss: 0.6921 Epoch [1/5], Step [200/782], Loss: 0.6903 ... Epoch [1/5] 完成耗时: 125.34s平均损失: 0.6912, 训练准确率: 53.41% 测试集准确率: 52.89% ... Epoch [5/5] 完成耗时: 118.76s平均损失: 0.4231, 训练准确率: 81.25% 测试集准确率: 78.34%这表明我们手写的Transformer模型能够有效学习并在情感分析任务上达到约78%的准确率。对于一个未经精细调参的模型来说这个结果是合理的。你可以通过调整超参数d_model,num_layers,lr等、使用预训练词向量、增加训练轮数来进一步提升性能。9. 常见问题与排查思路在实现和训练Transformer过程中你可能会遇到以下典型问题问题现象可能原因排查方式解决方案训练损失不下降准确率接近随机猜测50%1. 学习率设置不当过高或过低。2. 模型初始化权重有问题。3. 梯度消失/爆炸。4. 数据没有正确送入模型如标签错误。1. 打印前几个批次的损失看是否有变化。2. 检查模型参数梯度 (model.parameters().grad)。3. 使用一个极小的数据集如10条数据过拟合看模型能否学到。1. 尝试不同的学习率如1e-3, 1e-4, 1e-5。2. 使用标准的权重初始化如Xavier。3. 添加梯度裁剪 (torch.nn.utils.clip_grad_norm_)。4. 仔细检查数据加载和预处理流程。GPU内存溢出 (CUDA out of memory)1. 批次大小 (batch_size) 过大。2. 序列长度 (max_seq_len) 过长。3. 模型参数量过大 (d_model,num_layers太大)。1. 使用torch.cuda.memory_allocated()监控内存使用。2. 尝试减小批次大小或序列长度。1. 减小batch_size。2. 使用动态padding使批次内序列长度一致即可。3. 使用梯度累积来模拟大批次训练。验证集准确率远低于训练集过拟合1. 模型过于复杂层数太多维度太大。2. 训练数据量不足。3. 缺乏正则化。1. 观察训练和验证损失曲线。2. 检查模型参数量与训练数据量的比例。1. 增加Dropout比率。2. 增加权重衰减 (weight_decay)。3. 使用更早停止 (Early Stopping)。4. 尝试数据增强。注意力权重全部均匀或集中于对角线1. 缩放因子sqrt(d_k)计算错误。2. 输入向量经过Softmax后差异太小。3. 在训练初期这是正常现象。1. 可视化注意力权重矩阵 (attn_weights)。2. 检查ScaledDotProductAttention中缩放计算。1. 确保d_k计算正确。2. 训练更多轮次观察注意力是否变得有区分度。3. 检查位置编码是否添加成功。推理时输出结果不一致或荒谬1. 模型处于训练模式 (model.train())未切换到评估模式 (model.eval())。2. 未处理padding token的掩码。3. 预处理如分词、索引化与训练时不一致。1. 在推理前调用model.eval()。2. 打印输入文本的预处理结果。3. 检查词汇表是否一致。1. 推理时务必使用model.eval()和torch.no_grad()。2. 确保推理时生成正确的attention_mask。3. 保存并加载与训练时相同的词汇表。10. 最佳实践与工程建议当你准备在真实项目中使用Transformer时以下建议能帮你避开许多坑从预训练模型开始而非从零训练除非你有海量数据和计算资源否则对于绝大多数NLP任务都应基于BERT、RoBERTa、GPT等预训练模型进行微调。我们的从零实现主要用于教学和理解。使用成熟的库对于生产环境强烈推荐使用Hugging Face Transformers、Fairseq、OpenNMT-py等成熟库。它们经过了充分测试和优化。pip install transformersfrom transformers import AutoModelForSequenceClassification, AutoTokenizer model AutoModelForSequenceClassification.from_pretrained(bert-base-uncased) tokenizer AutoTokenizer.from_pretrained(bert-base-uncased)重视位置编码对于相对位置敏感的任务如文本生成、机器翻译可以尝试学习式的位置编码或更复杂的相对位置编码如T5、DeBERTa中使用的方法。注意力掩码是关键务必正确处理填充掩码Padding Mask和因果掩码Causal Mask用于解码器。一个错误的掩码会导致模型性能大幅下降或学到错误模式。优化与部署混合精度训练使用torch.cuda.amp进行自动混合精度训练可以大幅减少GPU内存占用并加快训练速度。模型量化与剪枝对于部署到移动端或边缘设备考虑使用PyTorch的量化工具对模型进行压缩。使用ONNX或TorchScript将模型导出为标准化格式便于在不同平台部署。监控与可视化使用TensorBoard或WandB记录损失、准确率曲线。定期可视化注意力权重这不仅是调试工具也能帮助你理解模型到底“关注”了什么。通过这篇文章我们不仅用动画般的思路拆解了Transformer的核心——Self-Attention机制还从零实现了多头注意力、位置编码、编码器层并最终组装成一个完整的、可用于情感分析任务的Transformer分类模型。更重要的是我们将其应用于真实的IMDb数据集完成了从理论到实践的闭环。理解Transformer现在你有了清晰的路线图从最核心的QKV计算与注意力分配到并行化的多头机制再到解决位置信息的位置编码最后通过残差连接和层归一化构建出强大的特征提取器。下次当你使用BERT或GPT时你会清楚地知道那些令人惊叹的能力背后正是这些简洁而强大的模块在协同工作。建议你将本文的代码保存下来作为理解Transformer的“活字典”。当你阅读更复杂的变体如Vision Transformer, Swin Transformer论文时你会发现它们都是在这个坚实的基础上进行修改和扩展的。
返回列表