ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

用Transformer搭建中英互译系统:核心原理与工程实践

用Transformer搭建中英互译系统:核心原理与工程实践 1. 项目概述与设计思路1.1 为什么文本翻译选 Transformer 而不是 LSTM前阵子我在本机搭了一套中英文本翻译演示系统跑通之后最大的感触是如果放在几年前拿循环神经网络做同一个任务光是把代码调成能并行训练这一步就能磨掉我大半天的耐心。现在用 Transformer 作为序列到序列翻译模型的骨干整条链路从数据清洗到端到端推理逻辑清晰太多了。翻译本质是一个序列到序列问题输入是源语言的一句话输出是目标语言的一句话。早年的主力方案是编码器-解码器结构的 LSTM编码器把源句压成一个固定长度的隐状态向量解码器从这个向量出发逐步生成目标词。问题是句子稍微长一点固定向量根本装不下全部语义越靠前的信息越容易被后续内容冲淡。Transformer 绕开了“顺序压缩”的思路用自注意力机制让每个词直接对全句其他词做加权关联这就等于把“逐词理解”变成了“全局关系建模”。初学者最容易问的问题翻译任务这么依赖上下文为什么不用更适合处理序列的循环结构答案是并行效率。LSTM 必须按时间步串行计算第 t 步依赖第 t-1 步的隐状态GPU 等于被迫排队跑而 Transformer 的注意力计算在单层内可以完全并行训练速度上一个量级。模型表达力更强速度还更快自然成为主流选项。1.2 这套系统到底做了什么、适合谁参考这个项目最终完成的功能很实在给定一句中文系统输出一句英文给定一句英文系统也能反向输出中文。严格来说这是典型的开放词表翻译任务而不是那种只能翻几十个固定句式的玩具 Demo。整个系统包含数据预处理、词表构建、模型训练、推理解码四个模块我用 PyTorch 复现了 Transformer 的标准结构编码器六层解码器六层注意力头数八模型维度 512。训练时使用 WMT 新闻语料的一个子集清洗之后保留约一百万对中英平行句对在单卡 GPU 上跑了两轮多。这篇博文适合两类人一是刚接触 NLP、想做文本翻译但不知道从哪下手的初学者跟着把每个模块拆开之后能建立 Transformer 的完整直觉二是已经有工程经验、想快速验证 Attention 机制实际效果的朋友可以直接参考我贴出来的关键实现和踩坑记录。我始终觉得与其照着教程“抄代码”不如先把设计思路想明白。1.3 把“翻译任务”拆成四个关键环节如果只给一句话概括这套系统的工程链路我会写数据清洗与句子对齐 → 子词切分与词表构建 → Transformer 编码器-解码器训练 → 束搜索解码输出。这四个环节每一步都有坑每个坑都会影响最终翻译质量。有意思的是很多人在模型结构上花大功夫调参反而忽视前两步。实际上平行语料里中文和英文句子经常存在数量不对齐、标点错位、长句乱断的问题如果输入是“你好世界”这种中英文之间词序相差很大的句子分词方式又直接决定了模型能否学到词对齐关系。我把清洗规则写得很保守去掉长度超过 80 个 token 的长句过滤双语长度比例超过 3:1 的对齐对统一把全角标点转半角。这套规则不做特别聪明的处理但很可靠。2. 核心细节解析与实操要点2.1 输入表示词嵌入和位置编码缺一不可自注意力机制本身不具备天然的顺序感如果不加位置编码句子“猫追老鼠”和“老鼠追猫”对模型来说没有任何区别。这是 Transformer 和 RNN 最大的结构差异RNN 靠递推顺序隐式获得位置信息Transformer 必须显式给每个 token 注入位置信号。我采用的是原论文里的正弦位置编码位置编码向量直接和词嵌入向量相加然后一起送入第一层。公式如下PE(pos, 2i) sin(pos / 10000^(2i / d_model))PE(pos, 2i1) cos(pos / 10000^(2i / d_model))其中 pos 是 token 在句子里的位置索引i 是向量维度序号。选择正弦函数而不是学出来的位置向量主要是为了处理超长句子模型没见过的长度也能生成对应的位置编码不依赖训练数据的长度分布。这里有个很容易被忽略的细节位置编码的振幅和词嵌入初始化标准差要保持在同一个量级。如果词嵌入初始化成均值为 0、方差 1而位置编码系数是固定三角函数的振幅 1两者相加之后输入分布会被位置信号主导。我实际实验时把词嵌入层先乘以 sqrt(d_model) 做缩放再叠加位置编码训练稳定度明显好很多。2.2 多头注意力Q、K、V 的角色分工注意力机制可以类比成一个“信息检索”过程。每个 token 生成三个向量Query 表示“我关心什么信息”Key 表示“我能提供什么信息”Value 是“我拥有的信息内容本身”。当前 token 拿 Query 去和所有 token 的 Key 做点积得到一个相似度分数经过 softmax 归一化成权重再去加权所有 token 的 Value完成信息融合。多头就是把这个过程复制多份。每个头使用独立的投影矩阵把词向量投影到不同的子空间让模型分别关注不同的关系。比如一个头学会关注句法主语和谓语的关系另一个头关注代词和先行词的关系最后的输出把所有头的向量拼接起来再经过一次线性变换。八个头可以理解为八套“并行的关系透镜”。我在项目里比较特殊的处理是把注意力分数除以 sqrt(d_k)也就是 Query 向量维度的平方根。d_k 是 64512 除以 8 个头的数量所以除以 8。如果不缩放两组随机初始化向量做点积方差会随着维度增大变得很大softmax 之后概率分布接近独热梯度无法有效回传。这个缩放是整个自注意力能够稳定训练的基石之一新手调参时往往忽略这个“常数”。2.3 编码器和解码器掩码遮住的未来信息翻译模型解码时有一个关键约束生成第 t 个词时模型只能看到已经生成的 t-1 个词绝不能偷看后面的目标词。想像一下考试写作文你写到第五个字的时候当然不知道接下来要写的字是什么——模型也一样。因此解码器里的自注意力必须施加“未来掩码”把矩阵右上角的部分置为负无穷softmax 之后这些位置的注意力权重会趋近于零。编码器和解码器的另一个区别是解码器中间多了一个交叉注意力层cross-attention。这个层的 Query 来自解码器上一层Key 和 Value 来自编码器输出。也就是解码器在生成词的时候不仅参考自己已经生成的内容还持续参考源句的全局语义。这是机器翻译最核心的“对齐”机制每生成一个目标词都要决定源句哪些词贡献最大。我在实现里把三种 mask 放在一起做了统一封装。padding mask 把填充符位忽略look-ahead mask 防止解码器偷看未来目标词交叉注意力的 padding mask 去掉编码器填充位。这块代码最容易被初学者写乱尤其要小心 mask 广播的维度。我在调试时用过一个很笨但有效的方法把 mask 值可视化生成一个上三角是深色、下三角是浅色的矩阵图一眼就能看出未来掩码是否生效。2.4 残差连接、层归一化和前馈层Transformer 每一层都在做“先变换再绕过变换”的操作。子层输出先经过残差连接与输入相加再做层归一化。残差连接保证了深层网络的梯度不会因为层数加深而消失层归一化则让每一层的激活值保持稳定的分布。在翻译这种生成任务里训练和推理阶段最怕的就是“小波动被不断放大”某层输入均值和方差异常最终输出几乎一定会崩。LayerNorm 和 BatchNorm 的差别在于LayerNorm 是对单个样本的所有特征维度做归一化不依赖 batch 大小mini-batch 特别小的时候也非常稳定。我在训练时把 batch 压到 32 个句子LayerNorm 依然表现良好换成 BatchNorm 很可能已经无法收敛了。前馈层就是两层的全连接网络中间加 ReLU 激活维度从 512 上升到 2048再压缩回 512。这个层位置在注意力子层之后可以理解为“注意力负责融合全局信息前馈层负责逐 token 做非线性变换”。整个 block 的堆叠过程就是 6 层编码器 6 层解码器层与层之间互相传递表征最后一层输出再接进线性层和 softmax产生目标词的概率分布。2.5 分词与词表BPE 子词切分到底解决什么问题中英翻译里一个棘手问题是未登录词。日常语料中经常出现人名、地名、机构名如果按词为单位建模词表动辄几十万稀疏问题非常严重。我采用的方案是 BPEByte Pair Encoding先把语料切到字符级别再反复合并出现频率最高的相邻字符对最终得到一套规模适中、包含完整语义单元中又保留字符弹性的子词表。子词化的价值体现在翻译英文单词 understanding 时模型可以选择生成 unprefix、stand 和 ing 多个片段组合出完整含义。遇到词表外的生词时至少能降级用字符合成避免全部变成 UNK。我的词表规模控制在 32000 个子词中文部分按字为单位切分这种组合在翻译质量和小语料适配性之间取得了平衡。动手实践的时候我建议先用现成的分词工具做快速验证比如 SentencePiece 或 HuggingFace 的 tokenizer等整体流程跑通之后再尝试做自定义标签化。分词水平直接决定模型上限如果词表里中文常用字都缺后面的 Transformer 结构再精巧也是白搭。3. 实操过程与核心环节实现3.1 数据准备与清洗流程这个项目的训练语料来自公开的中英平行语料子集。这里要特别注意语料下载之后不要直接进模型要做三层清洗。第一层是格式清洗。把编码统一成 UTF-8繁体中文转成简体英文大小写归一剔除包含乱码、HTML 标签或异常 Unicode 字符的句子。第二层是对齐质量过滤。中英句对经常存在“一句中文对应两句英文”的情况我按照标点切分后做两两组合尽量保证句对语意完整。第三层是长度过滤。过短的句子少于 4 个 token信息量小过长的句子超过 80 个 token训练成本高且容易爆显存直接去掉。清洗完之后我给数据做了打乱和分批。批次大小按 token 数来算而不是按句子条数。一个 batch 大约包含 2500 个源端 token这样能避免批次内句子长短悬殊导致大量 pad浪费计算量。实际操作时我会先按长度大致排序再随机打乱缓冲区达到类似分桶的效果。3.2 编码器-解码器结构的要点代码下面是我整理的 Transformer 核心模块的结构示意去掉了无关的工程细节保留最容易写错的注意力部分。整个项目代码大约 600 行核心网络模块集中在 200 行以内。import torch import torch.nn as nn import math class PositionalEncoding(nn.Module): def __init__(self, d_model, max_len5000): super().__init__() pe torch.zeros(max_len, d_model) pos 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(pos * div_term) pe[:, 1::2] torch.cos(pos * div_term) self.register_buffer(pe, pe.unsqueeze(0)) def forward(self, x): return x self.pe[:, :x.size(1)] def attention(q, k, v, maskNone): d_k q.size(-1) scores torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(d_k) if mask is not None: scores scores.masked_fill(mask 0, float(-inf)) weights torch.softmax(scores, dim-1) return torch.matmul(weights, v) class MultiHeadAttention(nn.Module): def __init__(self, d_model, num_heads): super().__init__() self.d_model d_model self.num_heads num_heads self.d_k d_model // num_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_out nn.Linear(d_model, d_model) def forward(self, q, k, v, maskNone): batch_size q.size(0) 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) attn attention(q, k, v, mask) attn attn.transpose(1, 2).contiguous().view(batch_size, -1, self.d_model) return self.w_out(attn)这里我故意把 mask 的维度留作了待传参数。实际调用时编码器自注意力传 padding mask解码器自注意力传 padding mask 与 look-ahead mask 相加的结果交叉注意力传针对编码器输出的 padding mask。维度不匹配是高频报错点有一个简单的自查方法注意力分数矩阵的形状一般是 (batch, heads, tgt_len, src_len)mask 必须是和这个形状兼容的 4 维张量或者广播后的形状。3.3 训练配置与损失函数选择训练翻译模型我用的损失是交叉熵配合 label smoothing标签平滑。标准的交叉熵要求模型对正确词输出概率为 1这会导致模型过度自信、生成译文僵化。标签平滑把正确词的概率目标设置为 0.9剩余 0.1 平均分配给其他词给模型一点容忍度。平滑系数太小没效果太大会让模型丧失自信、译文反而变差我试下来 0.1 是比较稳的默认值。优化器选择 Adambeta 参数为 (0.9, 0.98)epsilon 设为 1e-9。这里最需要注意的是千万不要直接用一个固定的学习率。Transformer 论文用的是 warmup 策略前 4000 步学习率从很小线性升到峰值之后按步数的负平方根缓慢衰减。原因是训练早期参数变化剧烈学习率太大会把注意力权重推入饱和区之后很难修正。超参数配置我整理成了表格这也是我最终跑通实验的一组可用参数参数名数值说明d_model512嵌入维度注意力输出维度num_layers6编码器/解码器层数num_heads8多头注意力的头数d_ff2048前馈网络隐藏维度dropout0.1各子层之间的随机失活概率batch_tokens2500每个 batch 的源端 token 上限warmup_steps4000学习率预热步数label_smoothing0.1标签平滑系数max_length80训练时允许的最大 token 数训练时我每 500 步用一个 500 句的验证集测一次 BLEU。BLEU 值在 1 万步以前增长缓慢属于正常现象不需要焦虑。我自己跑到约 3 万步时验证集 BLEU 从 7 涨到 26 左右后面越涨越慢到 6 万步基本进入平台期。3.4 推理解码贪心搜索换成束搜索训练结束之后推理阶段的目标是给定源句逐步生成目标句。最简单的贪心搜索每一步只取概率最高的词作为当前输出。问题是一旦第一步选错后续全部被带偏无法回头修正。束搜索的思路是每一步保留概率最高的前 beam size 条路径最终选总体概率最高的那条。我用的是 beam size4再加一个长度惩罚length penalty。模型倾向于输出较短的句子因为短句每个词的概率乘起来数值更大长句反而吃亏。长度惩罚项的做法是给每条候选句子的得分除以一个关于长度的惩罚系数公式近似为 score / ((5 len) / 6) 的 alpha 次方alpha 取 0.6。这个操作不用动训练代码只在推理 decode 时加却能显著改善长句译文的完整性。推理过程中每条 beam 路径都在按自回归方式追加 token直到出现句尾符或长度达到上限。我会额外设置一个重复惩罚如果当前候选序列中某个词出现次数过多就给它降权。这个技巧在翻译新闻语料时非常管用能避免模型陷入“不断重复最后一个词”的死循环。4. 常见问题与排查技巧实录4.1 训练时显存耗尽翻译模型的显存消耗大头是注意力矩阵。假设 batch 内某句话长 80自注意力的分数矩阵是 80x80乘以 batch 大小和注意力头数之后内存占用非常可观。我遇到 OOM 之后的第一反应不是换显卡而是先检查 batch_tokens 是否设置过大以及序列是否真的被截断到 mask 约束的 80。老手都会用梯度累积来绕过单卡显存限制也就是每 2 到 4 个小 batch 更新一次参数等效地扩大 batch size。我把 batch_tokens 从 2500 降到 1000再用梯度累积步数 2 补回来显存占用大幅下降收敛速度几乎没有损失。如果你的显卡支持混合精度绝大多数情况下可以无脑开启在损失不增的前提下把显存占用再压掉三分之一。4.2 损失不降或模型不收敛损失完全不下降十有八九是注意力 mask 写错了。这里有个特别隐蔽的问题padding mask 的值为 0 的位置用负无穷填充但在某些实现里 mask 的值反过来1 表示有效0 表示无效一旦方向写反就相当于全部 token 都被屏蔽模型什么都学不到。排查的办法很简单把某一个 batch 里 mask 的值打印出来数一下有效占比是不是大致符合预期。如果损失下降但幅度极慢检查学习率是否太小或者位置编码是否没有正确生效。我遇到过一种情况写位置编码时忘记用 register_buffer导致它被当成可训练参数参与优化整体参数量增加了几十万但损失反而不降。把位置编码改为不可训练常量之后训练立刻恢复正常。4.3 训练 BLEU 高、验证 BLEU 低这种情况就是过拟合在百万级别的语料上尤其常见。我的处理顺序比较简单先加 dropout把每层 dropout 从 0.1 提到 0.2如果仍然过拟合就减小 d_model 到 256最后实在不行才降低层数。很多初学者的第一反应是加更多语料但实际上先减模型容量往往更有效因为语料清洗不干净时加数据只会把噪声也灌进去。4.4 译文出现大量重复词重复词问题多半出现在解码时。除了前面提过的重复惩罚还可以检查输出序列是否有“重复片段”。这个现象在中文翻译里特别明显因为中文无明显词空格单字“的”很容易被反复生成。除了调惩罚项另一个取巧做法是增加验证集里长句的比例让模型多学会处理平稳的长距离依赖。4.5 出现大量 UNK子词词表仍然覆盖不到的低频词会成为 UNK。真正有效的解决方法是把语料重新过一遍 BPE合并阈值调低一点比如从出现频率 2 降到出现频率 1这样可以保留更多低频字符合并。另外中英翻译中数字和日期非常容易成为 UNK我专门加了一个预处理规则把孤立数字替换为同一个保留 token解码之后再映射回原数字。这个小技巧立竿见影比扩容词表省事得多。下面把上述问题整理成速查表方便大家直接对照现象可能原因排查与处理OOMbatch token 数过大减 batch_tokens / 梯度累积 / 混合精度损失不降mask 方向错误 / 位置编码可训练打印 mask / 用 register_buffer 固定位置编码验证 BLEU 低过拟合提高 dropout / 减小模型容量输出重复解码约束不够加重复惩罚 / 提高长度惩罚 alpha大量 UNK词表覆盖不足调节 BPE 合并阈值 / 预替换数字符号5. 一些个人的实操心得项目做到最后我发现比起模型结构工程习惯对结果的影响被很多人低估了。常常有人问我“注意力头数改成 16 会不会更好层数加到 12 是不是就能超过论文”我不否认调参能涨点但在我这套中英翻译任务里真正把 BLEU 从 20 拉到 25 的改动反而是数据清洗得更干净、词表构建得更稳、标签平滑系数调得更准。对我个人来说还有一个印象很深的体会不要在一开始就追求在最大数据集上训练先用一个 5 万句的小样本集把完整流程跑通确认代码没有 bug 之后再切换到大语料正式训练。小样本十分钟就能出一个结果哪怕模型效果差也能快速验证 mask、位置编码、推理解码这些关键环节都正常。否则直接上百万句数据训练三个小时发现损失不降排查成本会高得让人想放弃。最后分享一个小技巧解码时把注意力权重单独存下来选几个典型句子画成热力图。你可以直观看到“I”这个词在翻译“我”的时候注意力主要落在了源句的哪个位置。看到热力图清晰地呈现出中英文的词对齐关系我那时候才真正觉得自己理解了 Transformer 翻译的精髓。这个项目跑通之后还可以往两个方向继续扩展一是替换成更大规模的预训练语言模型做初始 checkpoint用领域语料微调二是加入多模态信息比如图片描述、语音转录和文本翻译联合训练。每一步都是新的坑但也都很值得走。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表