
NLP基础三Seq2Seq模型Encoder-Decoder和Attention做NLP绕不开Seq2Seq搞懂Seq2Seq又绕不开Encoder-Decoder和Attention。这三个词经常被放在一起提但很多人初学时容易被概念绕晕Encoder和Decoder到底谁是谁Attention加在哪个环节为什么有了Encoder-Decoder还不够非要再加个Attention这篇文章就专门把这三个概念串起来讲清楚结合机器翻译这个最典型的场景尽量用大白话拆解模型的结构、计算过程和训练技巧让你读完能真正理解这套架构的来龙去脉而不是只记住几个名词。这套东西是NLP入门的核心分水岭。无论是经典的神经机器翻译、文本摘要、对话生成还是后来横扫一切的Transformer本质上都脱胎于Seq2Seq这个框架。搞清楚它你后面看BERT、GPT这些模型都会轻松很多。我尽量照顾到不同基础的读者如果你是新手可以跟着一步步理解核心直觉如果你已经有基础可以重点看后面训练细节和踩坑部分这些是平时文档里不太会写的内容。1. 什么场景逼出了Seq2Seq模型1.1 传统模型处理变长序列的尴尬先回到最原始的问题在Seq2Seq出来之前我们用神经网络做序列任务时遇到的最大麻烦是什么答案是输入和输出的长度都不固定而且两者之间还不是简单的对应关系。拿机器翻译举例。输入是一句中文我今天早上吃了一碗牛肉面输出是一句英文I had a bowl of beef noodles this morning。两个句子的长度不一样词语顺序也完全对不上。中文的今天早上在英文里放在句尾中文的我对应英文的I但吃了对应的had却跑到了句子中间。这种输入输出之间复杂的、非线性的对齐关系传统模型根本招架不住。你可能会说RNN不是能处理序列吗对RNN确实能处理变长输入但它有个硬伤它天然设计成输入和输出一样长。最典型的RNN结构是一对一的每一个时间步吃一个输入、吐一个输出。可翻译任务里输入3个词输出可能5个词这个长度错位就处理不了。你不可能要求RNN的每个时间步都严格对应一个输入和一个输出。另一种思路是用CNN。但CNN处理序列任务很别扭它适合捕捉局部特征但很难建模长距离的依赖关系。而且CNN通常也需要把输入padding成固定长度。一句话20个词一句话200个词都塞进固定维度的向量里短的浪费空间长的又被截断丢信息。1.2 核心想法先读懂再表达Seq2Seq的思路其实非常朴素就八个字先读懂再表达。它把整个任务拆成两个阶段第一阶段用一个网络把输入序列完整读一遍提炼成理解的结果第二阶段用另一个网络从这个理解结果出发逐个词地生成输出序列。这个思路特别像我们人类做翻译时的流程。你拿到一句德语不会一个词一个词机械地对应成中文而是先把整句话读一遍理解它的意思然后组织成一句通顺的中文说出来。Seq2Seq就是把这个理解和表达的过程拆成了两个独立的神经网络模块让它们各司其职、协同配合。这套架构的厉害之处在于它彻底打破了输入和输出必须等长的限制。因为Encoder和Decoder是两个独立的网络Encoder读多长的输入都行Decoder想生成多长的输出也行两者之间只通过一个中间产物传递信息。这个中间产物就是上下文向量也就是我们常说的语义向量C。2. Encoder-Decoder架构拆解2.1 整体结构与角色分工整个Encoder-Decoder架构可以理解成一条流水线。Encoder是流水线的第一段负责把输入序列消化成一个包含语义信息的向量Decoder是流水线的第二段负责把这个向量展开成输出序列。先看Encoder。我们通常用RNN实践中基本都是LSTM或GRU来按顺序读取输入序列的每一个词。这里的按顺序读取不是简单地把词塞进去就完了RNN内部会维护一个隐状态每读入一个词隐状态就更新一次把当前词的信息和之前所有词的信息融合在一起。当最后一个词读完RNN的最终隐状态就包含了整个句子的信息。这个最终状态或者说由最终状态加工而来的向量就是上下文向量C。再看Decoder。Decoder同样是一个RNN但它的职责完全不同。它不是在读取而是在生成。Decoder接收上下文向量C作为初始状态然后一个词一个词地预测输出序列。每个时间步它根据上一个时刻生成的状态和已经生成的词预测下一个最可能的词是什么。这个过程会一直持续直到模型输出一个特殊的结束符表示我说完了。这里有个容易混淆的点Decoder每个时间步输出的到底是什么是词的概率分布一个向量长度等于词表大小每个维度表示对应词在这个位置出现的概率。训练时我们拿这个分布和真实目标词计算损失推理时我们取概率最高的那个词或者按其他策略选词作为这一时间步的输出。2.2 RNN单元选型LSTM和GRU的取舍理论上Encoder和Decoder可以用基础RNN但实践中没人这么做因为基础RNN有严重的梯度消失问题处理不了长句子。主流的做法是LSTM或者GRU。LSTM是长短期记忆网络通过输入门、遗忘门、输出门三个门控机制来控制信息的写入、保留和读取。它的好处是能记住很长时间以前的信息坏处是参数多、计算量大。LSTM的隐状态其实包含两个部分一个是长期记忆细胞状态C一个是当前时间步的隐状态h。很多人初学时容易把这里的状态C和前面的上下文向量C搞混注意区分它们是完全不同的东西。GRU是LSTM的简化版只有两个门更新门和重置门。它把LSTM的细胞状态和隐状态合并成了一个。参数更少训练更快在某些任务上效果和LSTM差不多甚至更好。实际项目中我个人的选择标准很简单数据量不大、对延迟敏感的任务优先用GRU训练快、省显存效果通常不会比LSTM差太多任务比较复杂、需要精细建模长期依赖的任务LSTM更稳妥。从近些年的趋势看GRU在工业界的应用比LSTM更广因为效率优势太明显了。不过说实话在Transformer普及之后这两者都已经沦为经典组件了但理解它们依然是理解Transformer的前置基础。2.3 上下文向量C的三种传递方式Encoder把整句话压缩成一个向量C之后怎么把它传给Decoder这个问题看起来简单但实际上有三种不同的做法效果差别还挺大。第一种做法只在Decoder的初始时刻传入。也就是Decoder第一个时间步的隐状态直接用C来初始化之后的每个时间步不再直接接触C只能通过上一步的隐状态间接获取信息。这种做法最简单缺点是C的信息在生成过程中被稀释了——生成长句子时越到后面的词来自C的直接信息越少。第二种做法在Decoder的每个时间步都传入C。也就是每一步生成时都把C拼接到Decoder的输入里让每一步都能直接看到编码信息。这种做法比第一种好相当于每一步都在提醒模型别忘了原始输入的内容。第三种做法把C同时用于初始化隐状态和每个时间步的输入拼接。这是前两种的组合信息通路最多。但它不一定是最好的——有研究指出C的信息通路太多反而可能导致过拟合尤其在小数据集上。我的建议是先把第二种作为默认方案它效果好且容易理解。如果你用的是现成框架实现可以看看框架默认用哪种再根据验证集效果决定要不要调整。3. 为什么要引入Attention机制3.1 固定长度向量的信息瓶颈基础的Encoder-Decoder有一个致命问题不管输入句子有多长Encoder都必须把全部信息塞进一个固定长度的向量C里。这句话意味着什么我举个例子。假设输入句子是I love you三个词Encoder要把它压缩成一个向量。再假设输入句子是某篇论文的摘要500个词Encoder还是要把它压缩成一个固定长度的向量。信息量差了上百倍容量却完全一样这必然导致信息丢失。句子越长后面的信息就越容易被前面挤掉C能承载的信息就越有限。这就是著名的信息瓶颈问题。在神经机器翻译早期一个直接表现是句子长度超过一定范围后翻译质量急剧下降。你给模型输入20个词的句子效果尚可输入50个词就开始胡言乱语了。后来大家分析发现问题主要出在这个固定长度的上下文向量上——它成为了整个系统的性能天花板。打个比方这就像你让一个人听完一场两小时的讲座后只准用一句话复述全部内容。这句话哪怕再怎么精炼也不可能保留所有细节。翻译也好、摘要也好生成过程中的每一步其实都需要参考原文的不同部分但固定向量C把所有信息揉成一团Decoder每一步拿到的都是同一份浓缩信息自然难以做好。3.2 Attention的核心思想动态聚焦Attention机制解决信息瓶颈的思路一句话概括就是不再试图把整个句子压缩成一个向量而是让Decoder在生成每个词的时候动态地回头看Encoder每一步的输出从中筛选出最相关的信息。它模拟的是人类翻译的另一个直觉我翻译这句话的时候并不是把整句原文背在脑子里不分轻重地使用而是每翻译一个片段就回看原文中对应的那个片段。翻译牛肉面时我会重点关注原文里的牛肉面这几个字而不是死死盯着句首的我今天。Attention做的就是这件事每一步生成时模型自动学会我应该重点关注原文的哪些位置。这就把信息瓶颈问题化解了。Decoder不再依赖单一向量C而是每一步都有一份按需取用的完整记忆。Encoder每一步输出的隐状态都被保留下来构成一个记忆矩阵Decoder每一步都从这个矩阵中检索自己需要的信息用检索结果辅助生成当前词。3.3 注意力权重是怎么算出来的Attention机制的实现细节我拆成三个步骤来讲。第一步计算相关性得分。Decoder当前的隐状态记为s和Encoder每一步的隐状态记为h之间要算一个相似度分数。方法有好几种可以直接点积s和h的向量内积可以用一个权重矩阵W把s和h映射到一个向量空间再点积这叫general式也可以把s和h拼接后过一层全连接网络这叫concat式。实践中最常用的是general式和点积式效果稳定且计算快。第二步把得分变成权重。Encoder有n个词就会得到n个得分。把这些得分放进softmax函数转成n个加起来等于1的概率权重。这一步的意义是归一化让模型可以专注在少数几个位置上而不是每个位置的权重都一样。第三步加权求和得到上下文向量。用上一步得到的n个权重对Encoder的n个隐状态做加权求和得到一个新的向量。这个向量含有Encoder各个位置的信息但权重集中在和当前生成词最相关的位置上。这个名字有时也叫context vector和前面固定C容易混淆注意区分。第三步得到的这个上下文向量会和Decoder当前的隐状态拼接在一起经过一层线性变换和softmax算出当前时间步的词的概率分布。上面这一步就是完整流程。为了让你理解得更直观我写一个简单例子。假设Encoder读了5个词Decoder现在要生成第3个词。模型算出的注意力权重是[0.1, 0.05, 0.6, 0.15, 0.1]那么第3个词获得了60%的注意力。这个权重分布告诉我们生成当前词时模型认为原文第3个词的信息最重要。具体计算过程就是Encoder的5个隐状态分别乘以对应权重再加起来得到一个加权融合的向量。3.4 两种attention模式局部与全局Attention还有一种分类方式全局Attention和局部Attention。全局Attention的意思是Decoder每一步生成时需要考虑原文中所有词的隐状态。上面讲的流程就是全局Attention。它的优点是信息全缺点是计算量随句子长度线性增长——句子越长每一步要计算的权重越多。局部Attention则做了一个折中每一步生成时只关注原文中一个小的窗口比如以某个对齐位置为中心前后各5个词。这个窗口之外的位置直接忽略权重设为0。这样做的好处是计算量大大减少缺点是需要额外预测对齐位置增加了一点复杂度。在今天的Transformer时代全局Attention是主流局部Attention更多出现在一些追求效率的特定场景里。理解这两者的区别对你后续学习Transformer里的稀疏Attention会有帮助。4. 训练和推理两个阶段的完全分离4.1 训练阶段和Teacher ForcingSeq2Seq模型的训练和推理行为上不太一样。推理的时候Decoder是一步错、步步错的——每个时间步把生成的词作为下一个时间步的输入。如果第1步选错了词后续所有生成都会受到影响。训练的时候如果也这么做会有一个大问题初期模型还没训练好生成的词基本都是错的把错误输出喂回Decoder模型会越学越乱收敛极慢。为了解决这个问题训练时采用一种叫Teacher Forcing的技术。简单来说就是训练时不使用模型自己预测的词作为下一步输入而是把真实的词喂进去。你告诉模型正确答案是什么让它每一步都基于真实的历史信息来学习预测下一步。这就像学开车副驾坐着一个教练每一步都告诉你正确操作让你专心学当前这一步怎么做而不是自己在错误操作中摸索。Teacher Forcing让训练过程稳定了很多但它也有副作用训练和推理行为不一致。训练时Decoder吃的是真实词推理时Decoder吃的是自己生成的词分布不同这叫做曝光偏差。结果一旦推理时第一步选错了词模型可能陷入一个训练时从未见过的糟糕状态。缓解曝光偏差的常见方案是Scheduled Sampling训练过程中以一定概率随机用模型生成的词取代真实词作为下一步输入并且随着训练推进这个概率逐渐增大。这个技巧在实践中有用但要注意调参概率变化太猛容易导致训练不稳定。4.2 推理阶段的解码策略贪心搜索和Beam Search推理时给定完整的输入序列模型要生成一个完整的目标序列。最简单的做法是贪心搜索每一步都取当前概率最高的词然后继续生成下一步。贪心搜索速度快但问题是短视——它只看当前这一步的最优不考虑全局。有点像下棋时只走眼前最好的一步不管后面几步会不会陷入被动。更好的做法是Beam Search束搜索。它的思路是每一步不只保留一个候选而是保留得分最高的前K个候选K叫束宽beam size。算法从解码第一步起维护一个包含K个部分序列的列表每步对列表里的每个部分序列扩展所有可能的下一个词计算累积得分然后只保留总得分最高的K个序列继续往下走。直到结束或者达到最大生成长度。Beam Search的几个实操要点束宽K的取值通常介于3到10。K太小比如1就退化成贪心搜索K太大会导致解码速度明显变慢。实际项目中常用K5效果和耗时平衡得比较好。候选序列的累积得分通常用对数概率相加但这样会偏向短句——概率是小于1的数乘得越多越小句子越短得分反而越高。所以最终选择时会做长度归一化用累积得分除以序列长度或者用更复杂的长度惩罚公式。当束宽较大时容易产生重复的候选序列。可以加一个重复惩罚某个词一旦在已生成序列中出现多次就降低它的得分避免模型不断输出同一个词。不同任务有不同偏好机器翻译喜欢用Beam Search因为目标句子有明确的标准答案倾向文本摘要也常用但对开放域对话生成Beam Search反而容易让回复变得平庸、无趣很多对话系统会故意用随机采样。我自己在实际使用中的体会是Beam Search对机器翻译和文本摘要的提升非常明显通常是改完就能在评测指标上看到收益。但使用的时候一定要做长度归一化否则你会看到模型倾向于输出话短整个句子的信息量不足。5. 常见问题和调优实战5.1 典型问题排查速查表我先列一个常见问题列表都是实际运行Seq2Seq模型时容易踩的坑。记住这个表格很多问题都能对症下药。现象可能原因处理思路训练损失不下降学习率设置过大/过小尝试在1e-3到1e-4之间调整或者做学习率预热损失震荡剧烈梯度爆炸使用梯度裁剪限制最大梯度范数在5左右长句翻译效果差模型容量不足、训练语料缺乏长句增加隐层维度、加深层数补充长句训练数据生成重复词束搜索宽度过大/训练不充分降低束宽增加重复惩罚检查训练收敛情况数据集小但模型大过拟合增加dropout通常0.3起引入预训练词向量推理时出现未知词词表外的词使用子词切分BPE或SentencePiece或对OOV做copy机制5.2 优化模型效果的几个实战经验优化效果这件事我把它拆成四个方向每个方向都有直接可操作的手段。数据层面清洗和预处理通常比改模型结构更能提升效果。对于中文任务分词策略的选择会影响最终的词表大小和数据分布。实践中建议用子词级别的切分方案比如BPE或SentencePiece好处是能处理词表外词不至于一碰到生词就崩溃。序列长度也可以做截断或过滤把过长和过短的句子都过滤掉可以减少很多噪声。模型结构层面除了前面说的LSTM/GRU选型还可以考虑几件事。一是双向Encoder把正向读取和反向读取的隐状态拼接起来作为每个词的隐状态让模型同时看到过去和未来的信息。这个技巧在机器翻译中几乎必用而且效果提升非常明显。二是增加层数把Encoder和Decoder从一层RNN增加到2到4层模型容量更大但要注意过拟合和训练速度的问题。三是注意力机制的变体比如加多头Attention——这一步你已经一只脚踏进Transformer的大门了。训练策略层面Optimizer优先选择Adam初始学习率设定为1e-3配合学习率衰减或者预热策略。在机器翻译这类任务上预热前若干步学习率从小到大线性增长确实能帮助训练更稳定尤其是在深层模型上。梯度裁剪是另一个必选项设置梯度最大范数为5.0左右能几乎杜绝训练过程中的NaN和损失爆炸。Batch size在显存允许的情况下尽量大一些能给训练带来稳定性。评估层面不要只看损失函数值要看具体任务指标。机器翻译用BLEU文本摘要用ROUGE。但指标只是一方面建议定期手动检查几组生成结果因为指标好的模型不一定是真的理解语义有可能是撞对了。我在实践中多次遇到过BLEU分数很高但翻译明显词不达意的情况人工检查真的不可省。5.3 对未来学习路径的影响理解Seq2Seq和Attention不只是为了学会这一个模型更重要的是为后续学习Transformer打地基。Transformer的Encoder和Decoder还是同样的角色划分只是把RNN换成了Self-AttentionTransformer里的多头注意力本质上就是把单头Attention复制多份、各学各的投影、最后拼接结果。你如果已经理解了注意力分数、加权求和这套流程再看Transformer会觉得顺理成章。我之前带过好几个同事都是从RNNAttention起步的。当时有个小技巧我觉得特别有用把注意力权重矩阵可视化出来看看模型在翻译每个词时关注了原文的什么位置。这个可视化既能验证模型学得对不对也能帮你发现一些有趣的对齐模式——比如翻译苹果时模型通常会把注意力集中在原文的苹果上而不是飘到别处。这种眼见为实对理解Attention非常有效。6. 动手实现的最小Demo说了这么多如果不上手写一个最小的可运行版本总感觉少点什么。这里给一个非常轻量的PyTorch实现思路它去掉了很多工程细节但保留了Seq2SeqAttention的核心骨架方便你把本文讲的每个模块对应到代码上。这个Demo可以用一个玩具任务来测把一串数字逆序输出或者做一个极小的英法翻译子集。用逆序任务最方便数据可以自己生成不需要预处理。Encoder核心代码可以简化为几行把输入序列过嵌入层逐个时间步喂给LSTM把所有隐状态存下来。这里注意我们要保留的是每个时间步的隐状态而不是只有最后一个状态因为Attention需要用到所有位置的隐状态。这对应了前文说的记忆矩阵。Decoder方面稍微复杂一点需要实现Attention计算。大致流程是输入上一步的隐状态和上一步的词嵌入先和Encoder的所有隐状态算注意力权重加权求和得到上下文向量再把上下文向量和当前词嵌入拼接起来喂给LSTM得到新的隐状态最后通过一个线性层映射到词表大小。这个线性层加上softmax两步合在一起就是所谓的输出投影层。训练循环里需要注意损失函数使用的目标词序列需要整体右移一位因为Decoder第一个时间步的输入是起始符输出目标是第一个真实词第二个时间步输入第一个真实词输出目标是第二个真实词依此类推。这个错位关系是很多新手刚开始写代码时容易搞混的地方。我建议你不要直接复制别人的完整代码而是先自己按本文讲的流程把框架搭起来遇到细节看参考实现。一步一行地写踩过几个坑之后你对这个模型的理解会完全不一样。这种理解是只看书或只看代码得不到的。刚才提到的训练时Decoder输入是真实词的错位关系这里多说一句有些框架会内部帮你处理这个错位有些则需要你在准备数据时自己做好。手动实现时最容易出bug的地方也在这里——输入和输出序列对齐错了一位整个训练过程就全乱套了。只要打印出来就能直接看出来排错时会省很多时间。个人建议你把束宽、双向Encoder这几个扩展点都加到最小Demo里然后拿同一个数据集对比效果。你会更直观地感受到每个模块对最终结果的影响权重。这不是一个可选的课后练习而是理解Seq2Seq最有效率的方法——数据在跑指标在变你脑子里对这个模型的认知也会越来越具体。我这几年做NLP项目最大的感受就是模型架构本身并不神秘反而全在细节里。同样的效果有人调得快有人调得慢差的往往是对数据flow的熟悉程度和对细节的敏感度。希望这篇梳理能让你的基本功更扎实后面再去碰Transformer、BERT这些衍生架构时每个机制在你眼里都是熟悉的老朋友。