ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

深度学习序列建模进阶:从RNN到Transformer实践手记

深度学习序列建模进阶:从RNN到Transformer实践手记

深度学习笔记二:序列建模进阶,从RNN到Transformer的实践手记

上一篇笔记我们聊了神经网络的基础,从反向传播讲到了简单的全连接网络怎么在手写数字识别上跑起来。当时就有朋友留言问,像文本翻译、语音识别这种输入输出长度不固定的任务,普通网络根本搞不定,下一步该往哪个方向走。所以这一轮笔记,我打算集中梳理一下序列建模这条线,重点聊聊RNN家族到底解决了什么问题、又卡在了哪里,以及现在大家实际项目里几乎默认首选的Transformer是怎么一步步把问题拆解掉的。

这篇笔记适合刚学完深度学习基础、想进入自然语言处理或者时序预测这类方向的同学参考。我会按照自己的学习路线,从循环神经网络的结构讲起,接着拆解Transformer的核心机制,最后附上一份可以直接运行的最小实现和踩坑记录。不堆公式,也不跳过原理,尽量用大白话把“为什么这么做”讲明白。

1. 为什么序列问题这么难搞

我记着刚开始接触序列建模的时候,第一反应是这不就是个多输入多输出的网络吗?把每个词喂进去不就行了。后来自己动手做英文分词再转成向量,才意识到事情没那么简单。

1.1 输入输出长度不固定带来的麻烦

普通全连接网络有个硬性要求:输入维度是固定的。你可以把一张224乘224的图片展平成一大堆像素点喂进去,但没法把一个长度不定的句子直接塞进去。万一这个句子有10个词、下个句子有30个词呢?总不能在网络前面加一个“自动裁剪”的开关吧。

更麻烦的是输出。做翻译的时候,源语言句子和目标语言句子的长度往往不对应,英文一个长句翻成中文可能拆成两三个短句。你要是用固定维度的输出层,就得预先定死最大长度,短了浪费计算量,长了又不知道拿什么填充。

这就是序列建模第一个核心矛盾:信息长度本身是动态的,但传统网络结构要求一个静态的骨架。

1.2 词与词之间不是孤立的

第二个难点在于,句子里的每个词都要结合上下文才能确定含义。比如“苹果”这个词,出现在“苹果很好吃”和“苹果发布了新手机”两句话里,语义完全不同。你再怎么把单个词编码成向量,如果没有上下文信息,模型根本分不清。

我当时试过最简单的办法:把一句话里所有词的词向量直接相加取平均,然后接一个分类器。效果嘛,只能说不堪入目。不仅词序信息全丢了,而且“苹果”在这种做法里永远只有一个固定的表示,完全没有办法体现语境的变化。

所以序列建模要解决的问题其实很清楚:第一,结构必须能处理变长输入;第二,每个位置的表示必须包含上下文信息;第三,也是容易被忽略的,长距离依赖不能因为距离增大就断掉。

2. 循环神经网络家族:曾经的序列建模主力

既然普通网络搞不定,自然就有人想到:那我能不能把网络“循环”起来?每到一个新词,网络不仅看当前输入,还把上一个时刻的记忆一起带进来。

2.1 RNN的基本工作方式

循环神经网络的核心就是一个带隐藏状态的单元。你输入当前词,同时接收上一步的隐藏状态,然后输出一个新的隐藏状态。这个隐藏状态就像是模型的“工作记忆”,理论上能把前面所有信息压缩在里面。

用代码写个简化版的RNN单元,其实也就几行:

import torch import torch.nn as nn class SimpleRNNCell(nn.Module): def __init__(self, input_size, hidden_size): super().__init__() self.input_proj = nn.Linear(input_size, hidden_size) self.hidden_proj = nn.Linear(hidden_size, hidden_size) def forward(self, x, h_prev): # x: [batch, input_size] # h_prev: [batch, hidden_size] combined = self.input_proj(x) + self.hidden_proj(h_prev) h_next = torch.tanh(combined) return h_next

这样一个单元沿着时间步反复调用,就是最朴素的RNN。它比固定窗口的方案强在:隐藏状态理论上可以记忆任意远的过去。

2.2 为什么朴素的RNN撑不住长句子

但等到你真把句子拉长到二三十个词,问题就暴露了。首先是梯度消失或者说梯度爆炸。你反向传播的时候,梯度要沿着时间步一路传回去,每一步都要乘上隐藏层权重的雅可比矩阵。这个矩阵如果特征值小于1,连乘起来梯度指数级衰减,最后前面几步的权重根本得不到有效更新。

这就好比你往一列很长的队伍里喊话,每个传话的人都会丢掉一点信息,传到最后基本就是噪音了。

后来业界出了两个改进版本,一个叫LSTM,一个叫GRU。LSTM搞出了输入门、遗忘门、输出门三个结构,GRU简化成两个门。它们用“门”来控制哪些信息保留、哪些信息丢弃,确实能把有效记忆长度延长到百步量级。

2.3 LSTM和GRU的真实能力边界

我在实际训练里体会过,LSTM在句子长度不超过50个token的文本分类任务上,效果还是相当能打的。GRU参数更少、训练更快,在小数据集上表现往往不输LSTM。

但它们的瓶颈也很明显:整个序列必须逐词处理,没法并行。你算第10个词的时候,必须等第9个词的隐藏状态算完。当时我在单卡上训练一个字符级的LSTM语言模型,一批数据才几十条,一个epoch要跑将近二十分钟。你要想换更大的模型,对不起,训练时间直接撑不住。

另外还有一个比较尴尬的事实:LSTM所谓的“长记忆”,也只是相对RNN而言。你要是真拿它去做动辄几百个token的文本摘要任务,后面的内容大概率已经把开头忘得差不多了。

3. Transformer:把注意力机制变成主角

2017年那篇“Attention Is All You Need”出来以后,整个序列建模的格局基本就变了。Transformer放弃循环结构,直接让序列里每个位置都跟其他位置做交互,一次性算出整条序列的表示。

3.1 自注意力到底在算什么

一句话解释自注意力:给定一个序列,每个位置的输出是所有位置信息的加权求和,权重由当前位置和每个其他位置的“相关性”决定。

这个“相关性”怎么算?简单来说就是把每个词映射成三个向量:查询(Query)、键(Key)、值(Value)。Query代表“我想找什么信息”,Key代表“我能提供什么信息”,Value就是“我真正携带的信息”。两两算Query和Key的点积,归一化一下,就得到权重。

拿个生活类比:你在会场里想找一个聊过天的朋友,你的“Query”就是你对他的印象描述,每个人的“Key”就是他们胸前挂的名牌,点积大说明匹配度高,你就把更多注意力放在那个人身上。

代码里自注意力是这样的:

import torch import torch.nn.functional as F def scaled_dot_product_attention(q, k, v, mask=None): # q, k, v: [batch, seq_len, head_dim] d_k = q.size(-1) scores = torch.matmul(q, k.transpose(-2, -1)) / (d_k ** 0.5) if mask is not None: scores = scores.masked_fill(mask == 0, float('-inf')) weights = F.softmax(scores, dim=-1) return torch.matmul(weights, v), weights

我说一下为什么要除以根号d_k。如果不做缩放,当维度比较大的时候,点积的数值会变得很大,softmax之后梯度会趋近于零,模型学不动了。除以根号维度,本质上是把点积的方差拉回到一个合适的范围。

3.2 多头注意力为什么比单头强

代码里你经常看到head_size=64这种设置,多头就是把这个维度拆成几份并行算。比如8个头,每个头只管64维,最后拼回去变成512维。

这样做的原因不复杂:不同的头可以关注不同的信息类型。有的头可能天然学会了关注句法结构,比如“动词”和它的“主语”;有的头可能学会了关注指代关系,比如“它”到底指代前面哪只动物。

我也试过把注意力权重可视化,确实能看到某些头存在非常清晰的对角线倾向——它就是在注意前一个词或者后一个词。这种多样性是单个注意力头很难同时具备的。

3.3 位置编码:让模型知道词的先后顺序

注意力机制本身是“无序”的。你把一句话的单词顺序打乱,注意力计算出来的结果完全一样,因为所有位置都在做同样的两两交互。但语言里词序显然重要,“我打你”和“你打我”含义天差地别。

Transformer的解决方法是加位置编码。原论文用的是正弦余弦函数,其实到了今天,直接用可学习的位置嵌入也很常见。我个人的经验是数据量大的时候两者差别不大,但可学习位置嵌入在微调阶段稍微灵活一些。

位置编码的加法操作是这样的:

def add_positional_encoding(x, max_len=512): # x: [batch, seq_len, d_model] batch, seq_len, d_model = x.shape pe = torch.zeros(seq_len, d_model) position = torch.arange(seq_len).unsqueeze(1).float() div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-torch.log(torch.tensor(10000.0)) / d_model)) pe[:, 0::2] = torch.sin(position * div_term) pe[:, 1::2] = torch.cos(position * div_term) return x + pe.unsqueeze(0).to(x.device)

你要是第一次见到这个公式,不用慌,它本质上就是在每个位置上生成一组低频到高频的波形,不同位置的编码模式天然不同且唯一。

3.4 残差连接与层归一化的作用

Transformer里每个子层后面都跟着残差连接和层归一化,这两兄弟是训练稳定的关键。残差连接保证梯度至少有一条直通路径,避免深层网络退化;层归一化则是把特征的均值和方差拉回正常范围,大幅降低对学习率的敏感度。

我第一次不加残差连接的时候,训练loss在0.5附近就死活降不下去了。加上残差,同样的数据轻轻松松降到0.1以下。这种差异在十层以上的模型里尤其明显。

伪代码习惯上是这么组织的:

class TransformerBlock(nn.Module): def __init__(self, d_model, n_heads, d_ff, dropout=0.1): super().__init__() self.attn = nn.MultiheadAttention(d_model, n_heads, dropout=dropout, batch_first=True) self.ffn = nn.Sequential( nn.Linear(d_model, d_ff), nn.GELU(), nn.Dropout(dropout), nn.Linear(d_ff, d_model), ) self.norm1 = nn.LayerNorm(d_model) self.norm2 = nn.LayerNorm(d_model) self.dropout = nn.Dropout(dropout) def forward(self, x, mask=None): # 子层一:多头注意力 + 残差 + 归一化 attn_out, _ = self.attn(x, x, x, attn_mask=mask) x = self.norm1(x + self.dropout(attn_out)) # 子层二:前馈网络 + 残差 + 归一化 ffn_out = self.ffn(x) x = self.norm2(x + self.dropout(ffn_out)) return x

这个结构看着简单,但真的把“并行计算”和“全局交互”两个刚需同时满足了,这也是它取代RNN成为主流的根本原因。

4. 从零手写一个轻量级Transformer编码器

理论说多了容易飘,咱们直接上手写一个能跑的编码器。这个代码我在模拟项目X里用过,拿来做短文本分类,效果和训练速度都令人满意。整个模型去掉注释不到80行。

4.1 搭建嵌入层与编码器主体

首先要处理的是词嵌入。用一个可学习的Embedding层把token id映射成稠密向量,然后加上位置编码。如果你用的框架比较新,可以直接用nn.Embedding加nn.Parameter组合。

然后按上一节的TransformerBlock堆叠。编码器里有两个微妙的设计点:一是注意力掩码,编码器不需要掩码,因为每个位置都可以看到整句;二是前馈网络中间层维度通常设成4 * d_model,这个比例是从原论文沿用下来的,工程实践里也基本不踩坑。

完整模型代码如下:

class TinyTransformerEncoder(nn.Module): def __init__(self, vocab_size, d_model=64, n_heads=4, d_ff=256, num_layers=3, max_len=128, dropout=0.1, num_classes=2): super().__init__() self.token_emb = nn.Embedding(vocab_size, d_model) self.pos_emb = nn.Parameter(torch.zeros(1, max_len, d_model)) self.blocks = nn.ModuleList([ TransformerBlock(d_model, n_heads, d_ff, dropout) for _ in range(num_layers) ]) self.norm = nn.LayerNorm(d_model) self.head = nn.Linear(d_model, num_classes) self.dropout = nn.Dropout(dropout) self.max_len = max_len def forward(self, input_ids): seq_len = input_ids.size(1) x = self.token_emb(input_ids) + self.pos_emb[:, :seq_len, :] x = self.dropout(x) for block in self.blocks: x = block(x) x = self.norm(x) # 取第一个token作为分类表示(类似BERT的CLS策略) pooled = x[:, 0, :] logits = self.head(pooled) return logits

4.2 参数选择背后的计算逻辑

我挑这几个参数都是有依据的,不是随手填的。d_model=64意味着每个词的语义向量是64维,对中小规模任务来说容量够用,在GPU上训练也很快。n_heads=4意味着每个头分管16维,注意力的多样性有了,计算开销也不大。num_layers=3对于短文本分类来说已经能捕捉足够复杂的交互模式,你堆到8层不会有本质提升,反而增加过拟合风险。

有一个容易被忽略的细节是pos_emb初始化为零向量,而且我是把它定义为可学习参数。相比原版的三角函数编码,可学习位置嵌入在这个小模型上收敛更快。初始化为零是为了让模型一开始不受到位置信息的干扰,逐渐自己学出合适的位置模式。

4.3 训练循环和损失计算

训练循环跟普通分类网络没有本质差别。我用AdamW优化器,学习率设成3e-4,warmup步数设成总步数的10%,然后余弦退火到1e-5。这里有个经验:Transformer对学习率非常敏感,1e-3以上的学习率几乎铁定发散,低于1e-5又学得太慢。

一个完整训练片段长这样:

model = TinyTransformerEncoder(vocab_size=10000) optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.01) criterion = nn.CrossEntropyLoss() for epoch in range(30): model.train() total_loss = 0 for batch_inputs, batch_labels in train_loader: optimizer.zero_grad() logits = model(batch_inputs) loss = criterion(logits, batch_labels) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() total_loss += loss.item() print(f"epoch {epoch}, loss: {total_loss / len(train_loader):.4f}")

clip_grad_norm_这行是我强烈建议加上的,尤其是在你第一次训练Transformer的时候。虽然比RNN时代的梯度爆炸问题温和不少,但偶尔还是会出现异常batch导致梯度飙高的情况。限幅到1.0基本不会影响正常收敛,却能阻止训练发散。

5. 训练过程中踩过的坑和排查思路

这个部分我觉得比前面的理论更值钱。写模型一时爽,训练调参火葬场。我把自己反复踩过的几个坑整理出来,按出现频率排序。

5.1 损失一直在高位震荡,降不下去

最常见的原因是学习率偏高。Transformer的优化曲面在某些方向特别陡峭,学习率稍微一高,loss就像心电图一样上蹿下跳。我建议先降到1e-4,如果还震荡再往1e-5去试。

还有一个隐蔽原因是数据没做mask。说得更具体一点,就是padding token也被模型正常参与了注意力计算。你在一批长度不一的句子里统一pad到max_len,padding位全部是0,模型会去注意这些毫无意义的“空词”,自然会把loss顶上去。解决方法是构造一个attention mask,让padding位变成负无穷。

5.2 训练集上损失很低,验证集上一塌糊涂

这个就是过拟合。Transformer参数量大,在小数据集上特别容易直接背答案。我当时的模拟项目X只有几万条样本,3层Transformer就明显出现过拟合。

应对手段按优先级:第一,增大dropout到0.2甚至0.3;第二,加weight_decay,从0.01调到0.05;第三,早停,验证loss连续三个epoch不降就停;第四,最直接的,上数据增强。文本任务里可以用回译、随机替换同义词、对token做随机mask等方式扩充数据。

5.3 显存爆了怎么办

这是我被问得最多的问题之一。如果你的显存在16G以下,一个batch可能放不下几十条长文本。优先检查max_len,很多情况下128足够,你非要设成512就是自找麻烦。其次是batch size减半,配合梯度累积来补偿:

accumulation_steps = 4 for step, (batch_inputs, batch_labels) in enumerate(train_loader): loss = criterion(model(batch_inputs), batch_labels) loss = loss / accumulation_steps loss.backward() if (step + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()

当然也可以启用混合精度训练,半精度下显存占用直接减半,我这边的经验是损失函数和梯度计算几乎不受影响。

6. 从编码器到完整的序列到序列模型

如果你做的任务不只是分类,而是翻译、摘要这种输入输出都是序列的任务,那就得把Transformer补全成编码器-解码器结构。

6.1 解码器和编码器的核心区别

解码器比编码器多了两个机制:一个是交叉注意力,也就是解码器每一层都要去看编码器输出的整体信息;另一个是因果掩码,也就是解码器在生成第i个词时,绝对不能看到第i+1个词,否则就是作弊。

因果掩码的实现方式是在注意力分数矩阵上,把上三角位置全部置为负无穷:

def create_causal_mask(seq_len): mask = torch.tril(torch.ones(seq_len, seq_len)).bool() return mask # True保留,False掩码

这个掩码配合之前的masked_fill,就能保证自回归式的逐词生成。

6.2 推理阶段的生成策略

训练完解码器之后,生成阶段是逐词往外套的。你先往解码器输入一个开始符号,拿到下一个词的概率分布,然后选择词加回输入序列,再继续预测。

这里面有几个常用的策略:

  • 贪心搜索:每次直接选概率最大的词。速度快,但容易陷入重复循环。
  • 束搜索:维护K条候选路径,最后选择整体概率最高的。质量比贪心好,但计算量大。
  • 采样:按概率分布随机抽,配合温度参数调节随机性。温度接近0时退化成贪心,温度大于1时更发散。

我实际跑机器翻译Demo的时候,贪心的结果经常出现重复短语,换成beam size为4之后明显改善。

6.3 尺度问题:模型越大未必越好

很多人一上来就想用几百亿参数的大模型,但在个人项目或者小团队场景里这是不现实的。我始终建议第一步用小模型跑通全流程,确认数据质量没问题,再逐步扩大规模。

扩大的时候优先加层数而不是加维度,因为层数增加带来的表达能力提升更明显,而且显存占用增长速度相对温和。我做过一个对比实验,3层64维涨到6层64维,任务准确率提升了约4个百分点;但6层64维涨到6层128维,只提升了约1.5个百分点,显存却翻了接近两倍。

7. 这篇笔记的工程落地建议

前面理论和代码都有了,最后再分享几个工程层面的建议,这些是我在实际做项目时沉淀下来的。

7.1 数据质量和预处理优先级最高

模型结构再先进,也拯救不了垃圾数据。我在一个文本分类项目里,一开始用正则表达式简单清洗,效果平平。后来花了整整一天做规则清洗、去掉乱码、合并重复样本,同样的模型直接涨了好几个点。数据质量带来的提升,往往比调参和换模型更大。

具体实操上有几个点值得注意:分词器的词表大小要跟语料规模匹配,训练语料只有几万条的情况下,词表10万就是浪费;要做严格的训练集和验证集划分,避免时间序列泄漏;中文任务还要注意分词粒度,有时候按字切比按词切效果更好。

7.2 不要迷信复杂的训练技巧

网上经常看到各种花式训练技巧,什么Layer-wise学习率衰减、什么梯度裁剪的变体、什么EMA权重滑动平均。这些东西在大模型训练里确实有用,但在小项目里未必能带来明显收益。

我自己的原则是:先把基线跑通,再加技巧。每一轮只改一个变量,精确记录对比结果。你要是同时改了学习率、dropout、优化器、数据增强,最后模型变好了,你根本不知道是哪个环节起了作用,复现也说不清楚。

7.3 环境配置与可复现性

最后提一下环境问题。深度学习框架的版本差异有时候能直接改变模型行为,这真不是玄学。我在一个项目里,同样的代码在框架A的2.0版本跑出91%的准确率,在1.8版本只有88%,排查了很久才发现是默认初始化方式变了。

所以强烈建议你把环境依赖固定住。用requirements.txt或者容器镜像把你用的框架版本、CUDA版本、Python版本全部锁定。作为个人笔记,我也习惯把每次跑实验的随机种子固定下来,保证结果可复现:

def set_seed(seed): import random import numpy as np import torch random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed)

模型训练本身就是随机性很大的过程,不固定种子,你连自己复现自己的结果都困难。

最后再补充一点关于笔记习惯的体会。我写这些笔记的时候,习惯把每次实验的关键参数、loss曲线、踩坑过程都记录在案,哪怕只是一个简陋的表格也比没有强。深度学习的学习曲线很陡,但不代表它没有规律可循。那些踩过的坑、试过的错、调过的参数,在当时觉得是浪费时间,后来回头看,恰恰是最值钱的部分。这套笔记目前只讲了编码器这一支,下一篇我会把注意力可视化和解码器训练展开细说,也会把一些实际项目里遇到的具体问题拿来做案例拆解。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进