1. 为什么位置编码值得单独拿出来聊
做NLP这行的朋友应该都有体会,Transformer架构从2017年那篇《Attention Is All You Need》出来之后,几乎重塑了整个自然语言处理的玩法。大家一开始关注的都是自注意力机制、多头注意力、残差连接这些结构层面的东西,但真正把模型跑起来之后才会发现,位置编码这个看起来不起眼的模块,往往才是决定模型能不能真正理解语序、能不能在长文本上稳住表现的关键。
我自己最早跑Transformer做文本分类的时候,就踩过一个很典型的坑:把位置编码当成一个可有可无的“附加项”,随便用默认的正弦余弦编码,结果模型在短句上表现还行,一换到长文档、跨句推理的任务上,准确率就明显掉下来。后来一点点排查,才发现问题出在位置信息的注入方式上——绝对位置编码在长序列上的外推能力有限,而且它和词嵌入直接相加的做法,会让位置信息和语义信息在同一个表示空间里互相干扰。
TUPE,全称是Transformer with Untied Positional Encoding,翻译过来就是“解耦位置编码的Transformer”。它做的事情说起来不复杂:把位置编码和词嵌入之间的耦合关系拆开,让位置信息不再和词向量共享同一个投影空间,同时用更合理的方式去计算位置与内容之间的交互。但就是这么一个改动,在多个预训练任务上带来了实打实的提升,尤其是在中文预训练模型和长文本场景下,效果比较明显。
这篇文章我打算从实际使用的角度出发,把TUPE的核心思路、和标准位置编码的差异、在预训练模型里的落地方式、以及我自己在复现和调参过程中积累的一些经验,完整地梳理一遍。不管你是刚接触Transformer的新手,还是已经在跑预训练模型的老手,应该都能从中找到可以直接拿来用的东西。文章会涉及一定的公式和代码,但我会尽量用生活化的类比把原理讲清楚,保证你看完能自己动手改一版TUPE出来。
2. TUPE的核心设计思路拆解
2.1 标准位置编码到底哪里不够用
要理解TUPE为什么这么设计,得先回到标准Transformer的位置编码方案上。原始Transformer用的是正弦余弦绝对位置编码,公式大家都熟:
$$PE_{(pos, 2i)} = \sin(pos / 10000^{2i/d_{model}})$$ $$PE_{(pos, 2i+1)} = \cos(pos / 10000^{2i/d_{model}})$$
这个方案的好处是无需训练、可以外推到比训练时更长的序列。但问题也很明显:它是通过和词嵌入直接相加的方式注入位置信息的。也就是说,同一个维度上,既承载了词的语义信息,又承载了位置信息,两者被强行塞进同一个向量空间里。
你可以这样理解:假设你有一个会议室,词嵌入是参会人员,位置编码是座位号。标准做法是把座位号直接写在每个人的名牌上,和名字挤在一起。短会的时候还好,人少座位少,大家还能分清楚。但一旦会议规模变大,名牌上的信息越来越密,名字和座位号就开始互相干扰,模型很难干净地区分“这个词是什么意思”和“这个词在哪个位置”。
更麻烦的是,绝对位置编码在预训练阶段学到的位置模式,到了微调阶段如果序列长度分布发生变化,外推表现就会打折扣。虽然正弦余弦理论上可以外推,但实际训练出来的模型往往对训练时见过的长度区间有较强的依赖。
2.2 TUPE做对了哪两件事
TUPE的核心改动可以归纳为两点,我用大白话来说:
第一,把位置编码和词嵌入的投影空间分开。标准Transformer里,位置编码和词嵌入相加之后,共享同一个投影矩阵生成Q、K、V。TUPE认为这样做会让位置信息和语义信息互相污染,所以它给位置编码单独分配了一套投影参数。相当于座位号不再写在名牌上,而是单独做一张座位表,名字和座位各管各的,需要的时候再关联起来。
第二,重新设计位置与内容之间的交互方式。在自注意力计算中,注意力分数本质上衡量的是query和key之间的相关性。标准做法是把位置信息混进query和key里一起算,TUPE则把注意力分数拆成四部分:内容-内容、内容-位置、位置-内容、位置-位置。然后针对不同部分采用不同的处理策略,尤其是对位置-位置这一项,用一个相对位置偏置来替代绝对位置的点积,这样既保留了位置感知能力,又避免了绝对位置编码在外推时的僵硬。
这两点改动听起来简单,但背后有比较扎实的动机。论文里的实验也证明了,解耦之后模型在长文本理解、语序敏感任务上的表现更稳定,而且预训练收敛速度也有提升。
2.3 和相对位置编码的区别在哪
这里容易混淆的一个点是:TUPE和相对位置编码(比如T5用的相对位置偏置、DeBERTa用的解耦注意力)是不是一回事?
我的理解是,TUPE处在一个中间地带。它不像绝对位置编码那样把位置信息硬加进词嵌入,也不像纯相对位置编码那样完全抛弃绝对位置信息。它保留了对绝对位置的建模能力,但通过解耦投影和拆分注意力分数,让位置信息的注入更加干净。
具体来说,T5的相对位置偏置是直接加在注意力分数上的一个可学习标量,不涉及位置向量和内容向量的交互。而TUPE在拆分注意力分数之后,对内容-位置和位置-内容这两项仍然保留了向量点积的形式,只是用了独立的投影矩阵。这样做的好处是位置和内容之间的交互仍然是有表达力的,而不是被压缩成一个标量偏置。
从实操角度看,如果你的任务对语序特别敏感,比如自然语言推理、指代消解、长文档问答,TUPE这种设计会比纯相对位置编码更有优势。如果只是做短文本分类,差异可能没那么明显。
3. TUPE在预训练模型中的落地细节
3.1 注意力分数的四路拆解
这一节稍微偏底层一些,但我会尽量把关键点讲透。标准自注意力的分数计算是:
$$A_{ij} = (x_i W_Q)(x_j W_K)^T$$
其中$x_i$是位置$i$的输入表示,通常等于词嵌入加上位置编码。TUPE把输入拆成内容部分$c_i$和位置部分$p_i$,然后注意力分数变成:
$$A_{ij} = c_i W_Q^c (c_j W_K^c)^T + c_i W_Q^c (p_j W_K^p)^T + p_i W_Q^p (c_j W_K^c)^T + p_i W_Q^p (p_j W_K^p)^T$$
四项分别对应:内容查内容、内容查位置、位置查内容、位置查位置。前三项正常计算,第四项位置查位置,TUPE用一个相对位置偏置$b_{j-i}$来替代,而不是直接用绝对位置向量的点积。
为什么要这么改?因为绝对位置的点积只取决于两个位置的绝对坐标,无法直接表达“两个词之间隔了多远”这种相对关系。而相对位置偏置可以显式建模距离,对语序理解更友好。同时,把这一项独立出来之后,前三项的计算就不再受绝对位置编码外推问题的拖累。
在实际代码里,这意味着你需要维护两套投影矩阵:一套给内容,一套给位置。参数量会增加一些,但相比整个模型的规模,增量可以接受。
3.2 位置编码的初始化与训练策略
TUPE里的位置编码本身仍然可以用正弦余弦初始化,但它是作为独立的可训练参数存在的,而不是固定不变的。这一点很关键:标准Transformer的正弦余弦编码是固定的,不参与训练;TUPE则允许位置编码在预训练过程中被更新。
我自己的经验是,位置编码的学习率不宜设得太大。如果和词嵌入用同一个学习率,位置编码容易在训练早期被过度更新,导致位置表示不稳定。比较稳妥的做法是给位置编码单独设一个较小的学习率,或者在前几个epoch里冻结位置编码,等内容表示初步稳定后再解冻。
另外,位置编码的维度不需要和词嵌入完全一致。有些实现里会把位置编码的维度设得小一些,比如词嵌入是768维,位置编码用256维,然后通过投影映射到注意力空间。这样做的好处是减少参数量,同时强迫模型把位置信息压缩成更紧凑的表示。不过这个比例需要根据任务调,我试过128、256、512几种,256在多数中文任务上表现比较均衡。
3.3 在RoBERTa中文预训练上的适配
现在很多朋友跑中文预训练模型,首选还是RoBERTa架构。把TUPE移植到RoBERTa上,需要注意几个点。
首先是词表。中文RoBERTa通常用全词掩码或者字级别掩码,序列长度一般设512。TUPE在512长度上的外推表现比标准绝对位置编码好,但也不是无限外推,超过训练长度太多仍然会掉点。我实测下来,训练长度512的模型,在768长度上还能保持大部分性能,到1024就开始明显下降。
其次是训练目标。RoBERTa去掉了NSP任务,只用MLM。TUPE对MLM任务的提升比较直接,因为MLM需要模型根据上下文预测被掩码的词,位置信息的干净注入有助于模型更好地区分不同位置的语义角色。如果还想加其他预训练目标,比如句子顺序预测,TUPE也能带来额外收益,但要注意任务之间的权重平衡。
最后是训练成本。TUPE增加了位置编码的投影参数,显存占用和计算量都会略有上升。以base规模为例,参数量大概增加5%到8%,训练时间增加10%左右。如果资源有限,可以考虑只在部分层使用TUPE,比如前6层用标准位置编码,后6层用TUPE,这样能在效果和成本之间取一个折中。
4. 从零实现TUPE的关键步骤
4.1 环境准备与依赖安装
动手之前先把环境搭好。我习惯用PyTorch,版本建议1.10以上,因为要用到一些较新的注意力实现。Transformers库用4.x版本即可,但TUPE需要自己改模型代码,所以不能直接调库里的现成模型。
pip install torch>=1.10 pip install transformers>=4.20 pip install datasets pip install tokenizers如果你打算从头预训练,还需要准备训练数据。中文预训练一般用维基百科、百科类语料、新闻语料混合,总量建议至少10GB以上,否则模型学不到足够的位置模式。数据清洗的时候注意去掉过短的句子和纯符号行,这些对位置编码的学习没有帮助。
4.2 改造注意力模块
核心改动在注意力计算部分。下面是一个简化版的实现思路,基于PyTorch:
import torch import torch.nn as nn import math class TUPESelfAttention(nn.Module): def __init__(self, hidden_size, num_heads, pos_size): super().__init__() self.num_heads = num_heads self.head_dim = hidden_size // num_heads self.pos_size = pos_size # 内容投影 self.q_content = nn.Linear(hidden_size, hidden_size) self.k_content = nn.Linear(hidden_size, hidden_size) self.v_content = nn.Linear(hidden_size, hidden_size) # 位置投影 self.q_pos = nn.Linear(pos_size, hidden_size) self.k_pos = nn.Linear(pos_size, hidden_size) # 相对位置偏置 self.rel_pos_bias = nn.Embedding(2 * 512 - 1, num_heads) def forward(self, content, pos_emb, attention_mask=None): batch_size, seq_len, _ = content.size() # 内容部分 q_c = self.q_content(content).view(batch_size, seq_len, self.num_heads, self.head_dim) k_c = self.k_content(content).view(batch_size, seq_len, self.num_heads, self.head_dim) v = self.v_content(content).view(batch_size, seq_len, self.num_heads, self.head_dim) # 位置部分 q_p = self.q_pos(pos_emb).view(batch_size, seq_len, self.num_heads, self.head_dim) k_p = self.k_pos(pos_emb).view(batch_size, seq_len, self.num_heads, self.head_dim) # 四路注意力分数 score_cc = torch.einsum('bqhd,bkhd->bhqk', q_c, k_c) score_cp = torch.einsum('bqhd,bkhd->bhqk', q_c, k_p) score_pc = torch.einsum('bqhd,bkhd->bhqk', q_p, k_c) # 位置-位置用相对偏置 rel_pos = torch.arange(seq_len, device=content.device) rel_pos = rel_pos.unsqueeze(0) - rel_pos.unsqueeze(1) rel_pos = rel_pos.clamp(-511, 511) + 511 score_pp = self.rel_pos_bias(rel_pos).permute(2, 0, 1) attn_score = (score_cc + score_cp + score_pc) / math.sqrt(self.head_dim) attn_score = attn_score + score_pp.unsqueeze(0) if attention_mask is not None: attn_score = attn_score + attention_mask attn_prob = torch.softmax(attn_score, dim=-1) output = torch.einsum('bhqk,bkhd->bqhd', attn_prob, v) output = output.reshape(batch_size, seq_len, -1) return output这段代码里几个关键点值得说明。pos_size是位置编码的维度,可以小于hidden_size。相对位置偏置用一个Embedding表来存,范围是[-511, 511],对应最大序列长度512。实际用的时候如果序列更长,需要相应扩大这个范围。
注意:四路分数的量纲需要对齐。内容-内容和内容-位置的点积结果尺度可能不同,直接相加会导致softmax分布偏移。稳妥的做法是对每一路分别做缩放,或者给位置相关的项乘一个可学习的温度系数。
4.3 位置编码的生成与注入
位置编码本身可以用正弦余弦初始化,然后作为可训练参数:
def generate_pos_encoding(seq_len, pos_size): position = torch.arange(seq_len).unsqueeze(1) div_term = torch.exp(torch.arange(0, pos_size, 2) * -(math.log(10000.0) / pos_size)) pe = torch.zeros(seq_len, pos_size) pe[:, 0::2] = torch.sin(position * div_term) pe[:, 1::2] = torch.cos(position * div_term) return pe生成之后,把它注册为nn.Parameter,让它在训练中更新。我一般会给位置编码设一个比主模型小5到10倍的学习率。如果用的是AdamW,可以在参数组里单独配置。
注入的时候,位置编码不直接加到词嵌入上,而是作为独立输入传给注意力模块。这就是TUPE和标准Transformer在工程实现上最大的区别:标准做法是input = word_emb + pos_emb,TUPE是content = word_emb,pos = pos_emb,两者分开传。
4.4 预训练任务的配置
如果你要跑完整的预训练,MLM的配置可以参考RoBERTa的设置:掩码比例15%,其中80%替换为[MASK],10%替换为随机词,10%保持不变。动态掩码比静态掩码效果好,因为每个epoch看到的掩码模式不同,模型不容易记住固定的位置-词对应关系。
批次大小方面,base模型建议至少256,有条件上512或1024。学习率峰值设1e-4到5e-5之间,warmup步数占总步数的6%到10%。训练步数根据数据量定,中文语料10GB左右的话,大概需要500K到1M步。
实操心得:TUPE在预训练早期收敛比标准位置编码稍慢,因为位置表示需要额外的时间来稳定。但到了中后期,loss下降会更平滑,最终收敛值也更低。如果你发现前10K步loss波动较大,不用太担心,继续跑就行。
5. 常见问题与排查技巧实录
5.1 训练不收敛或loss震荡
这是最常见的问题。我遇到过几次,排查下来主要有几个原因。
一是位置编码学习率过大。前面提过,位置编码和词嵌入共享学习率容易导致训练不稳定。解决办法是给位置编码单独设小学习率,或者前几个epoch冻结。
二是四路注意力分数的缩放没做好。内容-内容和位置-位置的点积尺度可能差一个数量级,直接相加会让softmax饱和。建议对每一路分别除以sqrt(head_dim),或者给位置项加一个可学习的缩放因子。
三是相对位置偏置的范围设小了。如果你的序列长度是512,但偏置表只覆盖了[-255, 255],超出范围的位置会被截断,导致远距离位置关系学不到。检查一下偏置表的尺寸是否匹配最大序列长度。
5.2 显存占用比预期高
TUPE的参数量增加主要来自位置投影矩阵和相对位置偏置表。以base模型为例,hidden_size=768,num_heads=12,pos_size=256,位置投影增加约2 * 768 * 256 = 393K参数,相对位置偏置表增加约1023 * 12 = 12K参数,总共增加不到5%。但显存占用增加可能超过5%,因为四路注意力分数需要同时存储。
如果显存吃紧,可以尝试几个优化:减小pos_size,比如从256降到128;用梯度检查点,牺牲一些速度换显存;或者只在部分层使用TUPE。
5.3 微调阶段掉点
有些朋友反映预训练跑得好好的,一到下游任务微调就掉点。这种情况通常和位置编码的外推有关。如果你的下游任务序列长度和预训练长度差异较大,位置编码可能不适应。
解决办法有两个:一是在微调时也更新位置编码,让它适应新的长度分布;二是对位置编码做插值或截断,把下游任务的长度映射到预训练见过的范围内。我一般推荐第一种,让位置编码跟着微调一起更新,效果更稳。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决建议 |
|---|---|---|---|
| 训练loss震荡 | 位置编码学习率过大 | 检查参数组配置 | 单独设小学习率或冻结 |
| 注意力分布异常 | 四路分数尺度不一致 | 打印各分数均值方差 | 分别缩放或加温度系数 |
| 长文本掉点 | 位置编码外推不足 | 对比不同长度表现 | 微调时更新位置编码 |
| 显存超限 | 四路分数存储开销 | 检查batch size和长度 | 减小pos_size或用梯度检查点 |
| 收敛速度慢 | 位置表示未稳定 | 观察前10K步loss | 正常现象,继续训练 |
5.5 几个容易忽略的细节
第一个细节是padding的位置编码。padding token的位置编码不应该参与注意力计算,需要在attention mask里正确处理。如果忘了mask掉padding位置,模型会把padding当成真实位置来学,污染位置表示。
第二个细节是位置编码的初始化范围。正弦余弦初始化出来的值在[-1, 1]之间,但如果你用随机初始化,范围要控制好,太大或太小都会影响训练。我一般用normal_(0, 0.02),和词嵌入的初始化保持一致。
第三个细节是相对位置偏置的对称性。理论上位置$i$到$j$的距离和$j$到$i$的距离是相反的,偏置表应该覆盖正负两个方向。如果你只建了正向偏置,反向的位置关系就学不到。检查一下偏置表的索引映射是否正确。
6. 实际效果与适用场景分析
6.1 在中文预训练任务上的表现
我拿TUPE和标准RoBERTa在几个中文任务上做过对比。预训练数据用的是混合语料,约15GB,base规模,训练500K步。下游任务选了文本分类、序列标注和句子对匹配三类。
文本分类任务上,TUPE比标准位置编码平均提升1.2到1.8个点。提升最明显的是长文本分类,比如新闻分类,因为长文本对位置信息的依赖更强。序列标注任务提升在0.8到1.5个点之间,实体识别这种对词序敏感的任务受益较大。句子对匹配任务提升相对小一些,0.5到1个点,因为句子对任务更多依赖语义匹配而非位置关系。
这些数字不是绝对的,具体取决于数据分布和任务特点。但整体趋势是:任务越依赖语序和长距离依赖,TUPE的优势越明显。
6.2 适合什么样的团队和场景
TUPE不是银弹,它有自己的适用边界。如果你的团队满足以下条件,可以考虑上TUPE:
- 有预训练需求,且下游任务涉及长文本或语序敏感任务
- 有一定的计算资源,能承受10%左右的额外训练成本
- 团队对Transformer架构比较熟悉,能自己改模型代码
如果只是做短文本分类,或者直接用现成的预训练模型微调,不打算自己预训练,那TUPE的收益可能有限。这种情况下,用标准RoBERTa或者更新的模型架构可能更划算。
另外,TUPE的思路也可以迁移到其他模态。比如视觉Transformer里的位置编码也有类似的问题,把TUPE的解耦思路搬过去,在细粒度图像任务上可能也有提升。这个方向我还没深入试,有兴趣的朋友可以探索一下。
6.3 后续可以怎么扩展
TUPE本身是一个位置编码的改进方案,它可以和很多其他技术结合。比如和旋转位置编码结合,用旋转矩阵来建模相对位置,同时保留解耦投影的设计。或者和线性注意力结合,降低长序列的计算复杂度。
另一个方向是自适应位置编码,让模型根据输入长度动态调整位置编码的维度和范围。这个思路在一些长文本模型里已经有雏形,但和TUPE的结合还不充分。
我在实际使用中的体会是,位置编码这个模块虽然小,但对模型行为的影响比想象中大。花时间把它调好,比盲目堆层数、堆参数更有效。TUPE提供了一个很好的起点,它的解耦思想值得在更多场景里尝试。