做NLP这些年,Transformer算是绕不开的一个坎。从2017年Google那篇Attention Is All You Need出来以后,整个序列建模的路子就变了。以前我们处理文本、语音、时间序列,首选是LSTM、GRU,偶尔用TextCNN做局部特征提取,再不行上Attention机制给RNN打辅助。但Transformer直接把Attention当成了全部,不要循环、不要卷积,模型结构一下子简洁了,训练速度还快了一个量级。这篇内容不是给你念论文,而是结合我自己的使用和踩坑经验,从原理到代码,从NLP到CV再到时间序列,把Transformer怎么用、为什么这么用、遇到问题怎么排查,一次性说清楚。适合刚从RNN切换过来、或者对Transformer只停留在会调用Bert的层面、想深入理解并亲手实现的读者。
1. 先搞明白:Transformer到底解决了什么问题
1.1 从RNN到Transformer,序列建模的一次范式转移
RNN的本质是按时间步展开,t时刻的输出依赖t-1时刻的隐状态。这在理论上是优雅的——用一个循环结构压缩整个历史信息。但实际用起来两个问题马上浮现:一个是没法并行,因为t时刻必须等t-1算完,GPU的并行能力被白白浪费;另一个是长距离依赖很难捕捉。LSTM和GRU虽然用门控机制改善了梯度传播,但序列长度一旦超过几百,信息在逐步传递中还是会衰减,这是物理规律,谁也拦不住。
CNN也不是不行。TextCNN在短文本分类上效果不错,卷积核可以并行提取n-gram特征,但它的感受野是有限的。要看长依赖就得加深网络或者用膨胀卷积,设计起来费劲,而且卷积核的权重是训练完之后固定的,不管输入什么内容,它都用同一套滤波器去扫,灵活性不够。
Transformer的思路直接把问题换个角度:把序列建模变成一个"全连接"的注意力计算问题。每个token直接和序列中其他所有token计算相关度,一步到位,没有中间商。这样一来,任意两个位置之间的信息传递路径长度都是1,长距离依赖天然被解决;同时所有位置的注意力可以并行计算,训练效率大幅提升。
1.2 自注意力机制的核心思想
自注意力最直观的理解是"每个词都去问一遍序列里的其他词:我跟你有关系吗?有多大的关系?"
具体实现分三步:
- 每个token通过线性变换生成Query、Key、Value三个向量。
- 用Query和所有Key做点积,得到注意力分数。
- 对分数做softmax归一化,再和Value加权求和。
用生活化的类比来说:Q就像是你心里想找的东西(比如苹果),K是每个货架上贴的标签,V是货架上的实际商品。你拿着Q去比对每个K,找到匹配度最高的货架,然后从那个货架上取出V带走。
这个机制和CNN最本质的区别在于:注意力权重是动态计算的,模型可以根据输入内容自适应地决定关注什么。CNN的卷积核是训练好后固定不变的,属于"静态权重";而Transformer每个位置的注意力权重都随输入变化,属于"动态权重"。动态的好处是表达能力强,换个语境、换个输入,模型的注意力分配就会跟着变。
1.3 为什么最后是Transformer,而不是CNN或者RNN
从三个维度对比:并行性、长程依赖、可扩展性。
并行性上,RNN必须串行,Transformer可以一次性输入整个序列,GPU并行能力被充分利用。长程依赖上,RNN的信息传递路径长度等于序列长度,Transformer任意两点的路径长度都是1,信息不会在传递中丢失。可扩展性上,Transformer架构在增加数据和参数时表现出很好的规模效应,后来的Bert、GPT系列证明了这套架构在海量数据和超大模型下依然可以持续受益。这一点RNN很难做到——当年训练大规模LSTM语言模型的时间和成本,放到现在看确实不太划算。
当然,Transformer不是没有缺点。它的时间复杂度是O(n^2),序列一长就扛不住。所以后来有了各种优化,比如稀疏注意力、FlashAttention、线性注意力等等,但这是"优化"而不是"推翻",核心架构依然是Transformer。理解这个背景,你再看后面那些变体就会轻松许多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Transformer架构核心细节拆解
2.1 输入表示:Embedding与位置编码
Transformer的输入分两步:token embedding加位置编码。
Embedding很简单,就是把离散的token映射成稠密向量。位置编码是必须的,因为自注意力本身对位置不敏感——你把一句话的语序打乱,注意力分数完全不变。所以必须把位置信息注入进去。
原论文用的是正弦余弦函数:
code复制PE(pos, 2i) = sin(pos / 10000^(2i/d_model))
PE(pos, 2i+1) = cos(pos / 10000^(2i/d_model))
为什么要用公式而不是直接学一个位置向量?一个原因是正弦函数的性质可以让模型更容易学到相对位置关系——PE(pos+k)可以表示为PE(pos)的线性组合,模型有机会学到"位置偏移"这个信息。另一个原因是外推性,公式形式可以处理训练时没见过的更长的序列。
实际使用中,学出来的绝对位置编码(比如Bert的position embedding)在很多任务上效果和公式差不多,但外推能力偏弱。现在不少新模型采用RoPE(旋转位置编码)或者ALiBi,都是为了更好地处理相对位置关系。所以翻译、长文档处理这类任务,位置编码的选择比想象中重要。
2.2 多头注意力机制详解
多头注意力就是把原始的Q、K、V投影到h个子空间,每个子空间独立计算注意力,最后拼接起来再过一层线性变换。
为什么要多头?我的理解是:单头注意力只能捕获一种"关系模式"。比如有的头专门关注语法依赖,有的头关注共现关系,有的头关注指代消解。多个头各司其职,相当于多个专家从不同角度观察同一个序列,最后把意见汇总。这是一个集成学习的思想,让模型表达能力更强,而且实现上很便宜——把d_model拆成h份,每个头的维度是d_model/h,总计算量基本和单头持平。
一般取h=8或12,d_model取512或768。有一点要注意,d_model必须能被n_heads整除,否则向量拆分的时候维度对不上,代码直接报错。我之前第一次手写多头注意力时就栽在这上面,查了半天才反应过来。
2.3 前馈网络、残差连接与层归一化
多头注意力之后是两层全连接的前馈网络,中间加ReLU激活:
code复制FFN(x) = max(0, xW1 + b1)W2 + b2
第一层把维度从d_model升到4倍,第二层再降回来。这相当于在注意力完成"信息交流"之后,每个token在自身维度上做一次非线性变换。注意力的职责是让token之间交换信息,FFN的职责是让每个token在自己的特征空间里做加工,两者互补,缺一不可。
残差连接和层归一化是训练稳定性的关键。Transformer每一层子层都有"Add & Norm":把子层的输入和输出相加,再做LayerNorm。残差让梯度可以直达底层,避免深层网络退化;LayerNorm把每层输入拉回均值0、方差1的分布,让训练更稳定。
注意:LayerNorm是在特征维度上做归一化,和BatchNorm在batch维度上归一化是不一样的。Transformer用LayerNorm,因为序列长度可能变化,在batch维度统计均值方差会不稳定。这个区别在实现时很容易搞混。
2.4 完整的Transformer编码器结构
标准Transformer编码器就是上面的组件堆叠N次(论文里N=6):
- 输入经过Embedding和位置编码。
- 多头自注意力 + 残差 + LayerNorm。
- FFN + 残差 + LayerNorm。
- 重复N次。
解码器比编码器多了一个Cross-Attention层,用来关注编码器的输出,并且有一个带mask的Self-Attention,防止在训练时看到未来的信息。这就是为什么做生成式任务必须用解码器或者完整的编解码结构。
如果只是做分类、特征提取、序列编码,用编码器就够了;做翻译、生成、对话,用解码器或完整结构。很多教材把这块讲得很复杂,其实核心就是"谁和谁做注意力"的区别——编码器内部所有token互相看,解码器里每个token只能看前面的token,Cross-Attention里解码器的token去看编码器的输出。
3. 手撕Transformer:从零实现一个可用的模型
3.1 环境准备与数据说明
这一节我们直接用PyTorch从零实现一个Transformer编码器,在中文情感分类任务上跑通。环境建议:Python 3.8+,PyTorch 1.13以上,其余用标准库就行,不需要额外装复杂的包。
我用的是一个很小的中文短评情感分类数据集,大约3000条正负样本。数据量不大,纯CPU也能跑起来,但如果你想体验GPU加速,一张显存6G以上的卡就够了。这个规模能让你把训练流程完整走一遍,又不至于等待太久。
3.2 核心模块代码实现
我这里给出一个简明的TransformerEncoder实现,核心模块都写了注释。可以直接照抄到你的工程里用。
python复制import torch
import torch.nn as nn
import math
class MultiHeadAttention(nn.Module):
def __init__(self, d_model, n_heads, dropout=0.1):
super().__init__()
assert d_model % n_heads == 0
self.d_model = d_model
self.n_heads = n_heads
self.d_k = d_model // n_heads
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.dropout = nn.Dropout(dropout)
def forward(self, x, mask=None):
batch_size, seq_len, _ = x.size()
Q = self.w_q(x).view(batch_size, seq_len, self.n_heads, self.d_k).transpose(1, 2)
K = self.w_k(x).view(batch_size, seq_len, self.n_heads, self.d_k).transpose(1, 2)
V = self.w_v(x).view(batch_size, seq_len, self.n_heads, self.d_k).transpose(1, 2)
scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(self.d_k)
if mask is not None:
scores = scores.masked_fill(mask == 0, -1e9)
attn = torch.softmax(scores, dim=-1)
attn = self.dropout(attn)
context = torch.matmul(attn, V)
context = context.transpose(1, 2).contiguous().view(batch_size, seq_len, self.d_model)
return self.w_o(context)
这里有一个细节必须注意:scores要除以sqrt(d_k),防止维度增大时点积数值过大,导致softmax进入饱和区、梯度消失。这个设计是原论文特别强调的,自己写代码时容易漏。我见过不少新手在这里栽跟头,训练时loss完全不动,查了半天才发现是缩放因子漏了。
接着是前馈网络和编码器层:
python复制class FeedForward(nn.Module):
def __init__(self, d_model, d_ff, dropout=0.1):
super().__init__()
self.linear1 = nn.Linear(d_model, d_ff)
self.relu = nn.ReLU()
self.linear2 = nn.Linear(d_ff, d_model)
self.dropout = nn.Dropout(dropout)
def forward(self, x):
return self.linear2(self.dropout(self.relu(self.linear1(x))))
class EncoderLayer(nn.Module):
def __init__(self, d_model, n_heads, d_ff, dropout=0.1):
super().__init__()
self.self_attn = MultiHeadAttention(d_model, n_heads, dropout)
self.ffn = FeedForward(d_model, d_ff, dropout)
self.norm1 = nn.LayerNorm(d_model)
self.norm2 = nn.LayerNorm(d_model)
self.dropout = nn.Dropout(dropout)
def forward(self, x, mask=None):
attn_output = self.self_attn(x, mask)
x = self.norm1(x + self.dropout(attn_output))
ffn_output = self.ffn(x)
x = self.norm2(x + self.dropout(ffn_output))
return x
class TransformerEncoder(nn.Module):
def __init__(self, vocab_size, d_model, n_heads, d_ff, n_layers,
max_len=512, dropout=0.1, num_classes=2):
super().__init__()
self.embedding = nn.Embedding(vocab_size, d_model)
self.pos_embedding = nn.Embedding(max_len, d_model)
self.layers = nn.ModuleList([
EncoderLayer(d_model, n_heads, d_ff, dropout) for _ in range(n_layers)
])
self.pooler = nn.AdaptiveAvgPool1d(1)
self.classifier = nn.Linear(d_model, num_classes)
self.dropout = nn.Dropout(dropout)
def forward(self, x, mask=None):
seq_len = x.size(1)
positions = torch.arange(seq_len, device=x.device).unsqueeze(0).expand(x.size(0), -1)
x = self.embedding(x) + self.pos_embedding(positions)
x = self.dropout(x)
for layer in self.layers:
x = layer(x, mask)
x = x.transpose(1, 2)
x = self.pooler(x).squeeze(-1)
return self.classifier(x)
这里我用了可学习的位置embedding,对于固定长度的分类任务完全够用。注意池化方式,我把序列维度的特征做了平均池化,得到的是整个句子的向量,再接分类器。如果你做的是序列标注,就不要池化,而是把每个位置的输出都接上分类头。
3.3 训练与推理流程
模型搭好后,训练流程跟普通PyTorch项目一样:定义loss函数、优化器、batch迭代。我用CrossEntropyLoss和AdamW优化器,学习率设5e-4,加上一个warmup,效果比固定学习率稳很多。
python复制import torch.optim as optim
from torch.utils.data import DataLoader, TensorDataset
dataset = TensorDataset(train_inputs, train_labels)
loader = DataLoader(dataset, batch_size=32, shuffle=True)
model = TransformerEncoder(vocab_size=5000, d_model=256, n_heads=8,
d_ff=512, n_layers=3, num_classes=2)
optimizer = optim.AdamW(model.parameters(), lr=5e-4)
scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=20)
criterion = nn.CrossEntropyLoss()
for epoch in range(20):
model.train()
total_loss = 0
for inputs, labels in loader:
optimizer.zero_grad()
outputs = model(inputs)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
total_loss += loss.item()
scheduler.step()
print(f"Epoch {epoch+1}, Loss: {total_loss/len(loader):.4f}")
跑了20个epoch,在验证集上大概能到88%的准确率。这个结果不算高,但对于一个3000条小数据集的from-scratch模型来说已经可以接受了。分类任务里Transformer的优势更多体现在大规模预训练模型上,自己从零训练时,效果和TextCNN差距不会太大,但它的架构普适性更强,后续换到别的任务不用推倒重来。
3.4 关键参数的选择与调整
说几个参数调整的实测经验:
| 参数 | 建议值 | 备注 |
|---|---|---|
| d_model | 128~256起步 | 小任务没必要太大 |
| n_heads | 8 | 必须能整除d_model |
| d_ff | 4~6倍d_model | 默认4倍 |
| n_layers | 2~4 | 小数据集别太深 |
| dropout | 0.2~0.3 | 数据少时加大防过拟合 |
经验之谈:若你的验证loss在训练中后期反而上升,而训练loss还在降,先检查dropout和层数,别急着换模型结构。多数情况下是过拟合,调小模型或加大dropout就能解决。
4. Transformer在图像领域的应用:ViT与Swin Transformer
4.1 Vision Transformer:把图像当成句子
ViT的想法很直接:把一张图切成固定大小的patch,每个patch拉平成向量,当成一个"token",然后塞进标准的Transformer编码器里。比如224x224的图,切成16x16的patch,就有196个token,和NLP里处理一条长度196的句子没什么区别。再加一个特殊的class token,类似Bert里的CLS token,用来汇总图像全局信息,最后接分类头。
这个思路在2020年刚出来时大家半信半疑——CNN在视觉任务上统治了这么多年,Transformer没有归纳偏置(比如局部性、平移不变性)居然也能行?结果ViT在ImageNet上大规模预训练后,效果可以超越同级别的ResNet。但它的代价是需要海量数据,在小数据集上直接训练ViT效果往往不如CNN,这就是为什么后来出现了各种针对小数据的改进方案。
4.2 Swin Transformer:层次化设计解决了什么问题
Swin Transformer解决了ViT的两个痛点:一是ViT的patch大小固定,无法像CNN那样形成多尺度特征;二是全局自注意力计算量太大,图像分辨率一高就吃不消。
Swin的核心设计是Shifted Window。它把特征图划分成窗口,只在窗口内做自注意力,大幅降低计算量;同时在相邻层之间把窗口移位,让信息能在窗口间流动。再配合patch merging做下采样,形成类似CNN的特征金字塔结构。多尺度的好处是既能识别大目标,也能捕捉小细节,所以Swin在检测、分割等密集预测任务上表现比ViT更优。
这里有个关键点:窗口注意力把计算量从O(n^2)降到了O(window_size^2),但代价是每个token只能看到窗口内的信息,全局视野变弱了。Shifted Window的巧妙之处就在于通过层与层之间的窗口错位,让不同窗口的信息在下一层能够交互,相当于用两次局部注意力模拟了一次全局注意力,计算省了,视野也没完全丢。
4.3 图像分类实战配置
如果你想在图像分类上用Swin,建议直接用成熟的预训练模型,而不是从头训练。我用timm库加载过一个Swin-Tiny在CIFAR-10上做微调,配置非常简单:
bash复制pip install timm
然后加载模型:
python复制import timm
import torch
model = timm.create_model('swin_tiny_patch4_window7_224', pretrained=True, num_classes=10)
训练时把学习率调低一点,预训练模型微调学习率一般设1e-4左右,数据集小可以用更低的1e-5。输入尺寸按模型默认224x224就行。实测CIFAR-10上微调20个epoch左右,准确率能做到95%上下。
注意:加载预训练模型时,如果num_classes变了,最后一层分类头会被随机初始化,前几个epoch loss会比较高,这是正常的,别慌。等前面层适应新分类头后,loss自然就降下来了。
5. Transformer在时间序列预测中的应用:股票预测实战
5.1 时间卷积网络(TCN)与Transformer结合
热搜词里出现了"pytorch的tcn时间卷积网络+transformer实战股票预测",这个组合在实际项目中确实很常见。时间序列预测有两个难点:一是局部模式,比如短期的趋势、波动;二是长期依赖,比如周期性规律、宏观事件的影响。
TCN(Temporal Convolutional Network)擅长提取局部模式,因果卷积保证不泄露未来信息,膨胀卷积扩大感受野。Transformer擅长建模全局依赖,自注意力机制可以捕捉不同时间点之间的长程关联。把两者串接起来——先用TCN处理原始序列得到局部特征,再输入Transformer建模全局关系——在股票、电力负荷、流量预测等任务上表现都还不错。这个结构本质上就是把CNN的归纳偏置和Transformer的动态建模能力结合起来,互补明显。
5.2 股票预测的数据处理与特征工程
做股票预测,数据处理是最容易翻车的环节。第一个坑是数据泄漏。把整个序列直接切分为训练集和测试集,会引入未来信息,导致回测结果异常漂亮,实盘却稀烂。正确做法是按时间顺序切分,训练集只包含过去的数据,测试集只包含未来的数据,推荐做walk-forward验证。我见过太多人在这里栽跟头,回测曲线无比完美,一上线就亏损。
第二个坑是特征归一化。股票价格范围波动大,一般做z-score标准化或者min-max归一化。注意归一化的参数只能在训练集上计算,再应用到验证集和测试集,否则又算泄漏。这一点和图像分类里的标准化不一样,图像数据集整体分布相对稳定,时间序列的统计特性会随时间漂移,必须格外小心。
我常用的特征包括:过去N天的收盘价、成交量、涨跌幅、技术指标(如RSI、MACD)、以及一些简单日期特征(星期几、是否月初)。特征不用太多,核心是把价格序列变成一个窗口输入模型。窗口长度一般取30~60,太短学不到趋势,太长噪声太多。
5.3 模型训练与评估
以预测次日涨跌方向为例,可以把任务建模成二分类。输入是过去60个交易日的特征序列,输出是涨或跌的概率。架构上我搭了TCN+Transformer的串行结构,TCN做局部特征提取,Transformer捕捉全局依赖,最后过一个全连接层做分类。
训练时注意几个细节:
- 损失函数用CrossEntropyLoss,类别不平衡时给少数类加权重。
- 使用早停,以验证集F1作为监控指标。
- 学习率用CosineAnnealing或ReduceLROnPlateau,避免后期震荡。
在A股某只个股上回测,整体准确率大约在55%左右。单看数字不高,但对股市预测来说已经算是非常难得了。这个项目的价值不在于"预测得有多准",而在于完整展示了Transformer在序列预测上怎么落地、怎么评估、怎么避免数据泄漏。如果有人告诉你某个模型在股票预测上准确率80%以上,先别急着兴奋,大概率是数据处理出问题了。
6. 常见问题与排查技巧实录
6.1 训练不收敛怎么办
新手最容易遇到的问题是loss不下降。排查顺序:
- 先在小批量数据上过拟合,看loss能不能降到很低。如果不能,八成是代码有bug,比如mask写错、维度对不上。
- 检查学习率。Transformer对学习率比较敏感,推荐先用3e-4左右,配合warmup。
- 检查数据预处理。数据归一化、序列填充方式都会影响收敛。
我自己的排查习惯是:先固定一个batch的数据,让模型反复过拟合,如果这个最简单的场景都过拟合不了,那一定是代码逻辑的问题,和数据量、模型大小无关。
6.2 显存爆炸怎么解决
显存不够通常是因为序列长度太长或batch_size太大。三个方向:
- 减小batch_size,同时调低学习率,训练会慢但能跑。
- 使用梯度累积,模拟更大的batch,显存占用却能控制住。
- 换用FlashAttention这类高效注意力实现,从根源上降低显存和计算开销。PyTorch官方已经在一些版本里集成了FlashAttention,可以关注相关API。
6.3 推理速度慢的优化技巧
Transformer推理慢的瓶颈在自注意力的O(n^2)计算。优化手段包括:
- 用KV Cache缓存历史Key和Value,生成任务能省去重复计算。
- 用半精度推理(FP16),在N卡上速度翻倍,显存减半。
- 对长序列做截断或分组,而不是一次性跑整个序列。
这些优化对生产环境部署特别重要。我在一个文本分类服务里做了FP16 + batch动态padding,吞吐量提升了接近3倍,显存占用降了一半,效果几乎无损。
6.4 热搜词里提到的"Transformer技术纵深"资源整理
搜索热词中频繁出现"Transformer技术纵深pdf"、"手撕Transformer"、"Transformer源码"。我自己的建议是:
- 入门:先读原论文《Attention Is All You Need》,重点看3.2节的多头注意力公式。
- 进阶:花一个下午把上面3.2节的代码自己敲一遍,比看十篇博客都管用。
- 深入:去读Hugging Face的transformers库源码,不需要全读,先挑BertModel的forward函数看懂就够了。
- 查漏:遇到具体概念时再看"技术纵深"类PDF或专栏,当作字典查,不要从头到尾啃。
我见过太多人下载了一堆PDF源码,但真正动手敲代码的没几个。代码这东西,看一遍不如写一遍,写一遍不如调一遍,调一遍不如报错一遍。
我在实际项目里反复用Transformer,从NLP到CV再到时序预测,最大的感受是:这个架构值得投入时间搞懂,但千万不要一开始就陷进论文细节。先把自注意力、位置编码、残差连接这三样核心吃透,把代码跑通一个任务,后面再慢慢扩展。你真正理解了Transformer之后,再去接触Bert、GPT、ViT、Swin这些变体会发现,它们都是在这几个核心组件上做文章,换的是输入形式和训练目标,骨架还是那一套。
最后再分享一个小经验:如果你在某个任务上纠结"用不用Transformer",先问自己两个问题——数据量够不够大?需不需要建模长距离依赖?答案都是是,就放心用;数据量很小、任务又简单,不妨先用简单的模型做基线,再决定要不要上Transformer。工具没有好坏,只有合不合适。
