基于Encoder-Decoder的新闻摘要生成算法实战指南

发布时间:2026/10/20 3:03:56
基于Encoder-Decoder的新闻摘要生成算法实战指南
最近帮一位学弟梳理毕设思路发现很多人在选题阶段都会把新闻摘要生成放上候选名单但真正动手时第一步就卡住了——不是卡在模型跑不起来而是卡在不知道这个任务到底该从哪儿拆起。新闻摘要看起来就是个读长文、写短文的活儿可一旦落到代码实现问题就变成了数据怎么清洗、词表怎么建、模型怎么搭、摘要怎么生成、效果怎么评估。这一整套链路里任何一环出问题毕业论文答辩现场都会很难看。这篇文章就围绕我手上的一个实际项目——基于深度学习的新闻摘要生成算法来展开核心框架采用Encoder-Decoder模型。我会把从任务理解、数据管道、模型搭建、训练调参到推理解码、效果评估的完整流程串起来讲并把我踩过的坑和验证过可行的做法分享出来。无论你是第一次接触自然语言处理的大四学生还是想从图像方向转过来做NLP的开发者只要按这篇文章的路子走一遍应该能独立跑通一个像样的摘要生成项目。1. 先搞清楚摘要生成解决的是什么问题1.1 新闻摘要不止是压缩文本在正式写代码之前我强烈建议先把任务定义弄清楚。很多人一开始把新闻摘要当成删减版原文也就是在原文里挑出关键句子拼成一段话。这种思路叫做抽取式摘要在技术实现上更像排序问题给每个句子打一个重要性分然后取前几个句子。早期经典方法、TextRank、Lead-3标题正文首段都属于这类。但我们现在要做的是生成式摘要也叫抽象式摘要。模型不是去原文里选句子而是重新组织语言写出一段在原文中没有逐字出现的摘要。举个简单例子原文里有该公司宣布推出新款手机售价3999元将于下月开售生成式摘要可能写成某公司下月发售定价3999元的新机。词汇变了、语序变了但信息核心被保留下来。这个把语义压缩并重写的过程本质上是一个序列到序列的生成问题也正是Encoder-Decoder框架最适合发挥的场景。1.2 新闻领域摘要的特殊难点省新闻文本和其他文本相比有几个非常突出的难点做数据清洗和模型设计时都要心里有数信息密度高实体多。人名、地名、机构名、数字、百分比往往集中在开头几句话生成时一旦漏掉或者改写错摘要的信息准确度就崩了。原文长度长。新闻正文动辄几百到上千字而摘要通常要求控制在两三句以内。Encoder端要处理的序列长度远大于一般的对话任务对建模能力和显存都有压力。开篇重要但不是全部。新闻写作讲究倒金字塔结构重要信息往往在前面但仅仅靠截取前几句的Lead-3方式会漏掉文章后半部分的关键背景和结论这给了生成式方法发挥的空间也让评估变难。把任务定义清楚之后你才能确定技术方案边界是做纯生成式还是用抽取式先选出候选句再生成对毕设来说我的建议是直接做一端到端的生成式系统这样技术主线清晰实验对比好写答辩时也更容易讲出为什么这样设计。2. Encoder-Decoder框架拆解一遍编码一遍解码2.1 Encoder把变长文本编码成语义向量序列Encoder-Decoder框架的核心思想可以用一句话概括先用编码器把整个输入文本读一遍把语义浓缩成中间表示再用解码器基于这个中间表示一个词一个词地把输出摘要写出来。编码器最常见的选择是双向LSTM或双向GRU。双向的意思是从左往右读一遍再从右往左读一遍然后把两个方向的隐状态拼接起来。这样每个位置上的隐状态既能包含我左边的信息也能包含我右边的信息。对于新闻这种上下文跨度较大的文本双向结构比单向结构效果明显更好。如果把某个时刻的隐状态公式写出来大概是这样其中是遗忘门是输入门是候选状态。后面我们工程上直接用PyTorch的nn.LSTM即可不需要手动实现这些门控细节但心里要清楚模型在每一个时间步输出的隐状态本质上是对当前位置及前后文信息的一种编码结果。整条输入文本通过编码器后得到一组隐状态序列这就是后续解码器的上下文素材库。2.2 Decoder逐字逐句把摘要写出来解码器的工作方式和编码器不同它是自回归式的在每一个时间步它只生成一个词然后把这个词拼到上一轮已生成的序列后面再继续生成下一个词直到输出终止符或者达到最大长度。从概率角度看模型在计算的是这样一个条件概率也就是输入全文信息的前提下当前第个词出现的概率。它不仅依赖编码器给出的上下文信息还依赖此前已经生成过的词。这种自己预测自己的过程本质上就是一个条件语言模型。如果设计一个最简单朴素版本就是把编码器最后一步的隐状态当作初始状态传给解码器然后每一时间步把上一词嵌入拼接上当前状态经过线性变换和softmax得到词表上的分布。这个版本能跑但效果很差原因是最后一步隐状态无法完整承载整篇长文的信息越长的新闻开头的信息丢失越严重。这就是注意力机制的出场时机。2.3 注意力机制解码器学会翻原文笔记注意力机制解决的核心问题是让解码器在生成每个词的时候动态地决定该重点看原文的哪些位置。可以理解成考试做论述题时你手握一本原文笔记每写一句话都会先翻到相关段落看一眼而不是靠考前背下的全部内容硬写。实现上常见的是加性注意力也叫Bahdanau风格其中是编码器第个位置的隐状态是解码器当前隐状态是可学习的权重矩阵。本质上是计算解码器状态与编码器各个位置的匹配分数然后做softmax归一化得到权重。把所有编码器隐状态按权重加权求和得到当前时间步的上下文向量这样做的好处是解码器生成金额、日期、人名这类信息时注意力权重会集中在原文相关词附近摘要内容的准确率会明显提升。在我的实测里加了注意力之后生成摘要的可读性提高了一个档次重复率也显著下降。还要提一个工程上的细节解码端训练时采用的Teacher Forcing策略。训练时解码器每一步输入的不是自己上一步生成的词而是真实摘要里的上一个词。这样做的好处是训练更稳定、收敛更快。但要注意推理阶段没有真实摘要可参考只能用自己的输出作为下一步输入这种训练和推理不一致的问题叫曝光偏差。缓解方法包括计划采样也就是训练时按一定概率随机用模型自己的输出后面可以作为一个进阶优化点。3. 数据管道搭建从原始新闻到可训练样本3.1 数据从哪里来先看字段再看规模做摘要生成的第一步往往是找一个成对数据集左边是原文文本右边是人工撰写的摘要。对毕设来说数据集不需要太大我建议起步规模控制在100万对以内甚至20万到50万对就足够验证模型和技能了。我当时用的是一个开源新闻语料每个样本包含两个核心字段document新闻正文和summary至少两句话的人工摘要。在实际处理之前我先做了一轮数据体检统计了文本长度的分布。这一步很多人会跳过但跳过之后模型训练一定会出问题——因为如果正文普遍有2000字而你的模型序列长度设成120大部分样本在输入阶段就丢掉了一半以上信息。3.2 清洗规则要保守别把有效信息误删数据清洗看起来简单实则容易过度。以下是我验证过比较稳妥的清洗顺序统一全半角把中文和英文之间的空格规范化。去掉HTML标签、URL、多余空行和连续空白字符。对英文做大小写归并如果是中文任务不需要分词直接按字级别处理能显著减少词表规模也避免分词错误传播。过滤掉正文长度小于50、摘要长度小于10样本同时去掉正文中超过80%是标点或数字的垃圾样本。根据模型能力做长度截断我使用的阈值是正文最长取120个token摘要最长取60个token。注意这里的截断不是简单地从开头切而是要尽量保留新闻前部信息同时检查摘要内容是否大部分仍在正文截断范围内否则样本无效。3.3 词表构建和批处理填充接下来是词表构建。如果按字级别处理中文新闻通常只需要1万到2万字的词表按词级别则可能需要5万到10万。毕设场景我建议用字级或BPE级别既缩小词表又减少未登录词。构建词表时要设置最小词频阈值比如词频小于3的词直接丢弃所有未登录词统一映射到unk。此外要确保保留四个特殊符号pad填充、bos句子开始、eos句子结束和unk未知词。批次处理时由于新闻文本长度差异很大一个批次内的样本需要填充到相同长度。这里有一个实用的小技巧把数据按长度排序之后分桶也就是长度相近的样本放在同一个批次里再填充能极大减少填充率训练速度经常能提升30%以上。预处理的示例代码大致如下def build_vocab(texts, min_freq3): from collections import Counter counter Counter() for text in texts: counter.update(list(text)) vocab {pad: 0, bos: 1, eos: 2, unk: 3} idx 4 for ch, freq in counter.items(): if freq min_freq: vocab[ch] idx idx 1 return vocab def encode_text(text, vocab, max_len): tokens list(text)[:max_len] ids [vocab.get(t, vocab[unk]) for t in tokens] return ids做完这些数据管道才算完整。这时一定要做一次抽样检查随机打印几个样本的正文前30个字和摘要人工看一下是否语义相关、长度是否合理。如果摘要内容频繁出现在正文截断位置之外就要调整截断策略。4. 模型代码实现手写一个完整的最小可跑工程4.1 模型结构定义到了这一步项目才算进入真正的深度学习环节。我用一个基于LSTM和加性注意力的Encoder-Decoder模型作为主基线隐藏层维度256层数2Dropout设为0.3。这个规模在单张入门级显卡上就能训练显存占用也很温和。代码可以直接套用下面的结构import torch import torch.nn as nn import torch.nn.functional as F class Encoder(nn.Module): def __init__(self, vocab_size, embed_size, hidden_size, num_layers, dropout0.3): super().__init__() self.embedding nn.Embedding(vocab_size, embed_size, padding_idx0) self.lstm nn.LSTM(embed_size, hidden_size, num_layers, batch_firstTrue, bidirectionalTrue, dropoutdropout) # 双向LSTM的输出维度是2*hidden_size self.fc_h nn.Linear(hidden_size * 2, hidden_size) self.fc_c nn.Linear(hidden_size * 2, hidden_size) def forward(self, src): embedded self.embedding(src) outputs, (hidden, cell) self.lstm(embedded) # 将双向的隐状态拼接后投影回隐藏维度作为解码器初始状态 hidden torch.cat((hidden[-2], hidden[-1]), dim1) # [batch, 2*hidden] cell torch.cat((cell[-2], cell[-1]), dim1) hidden torch.tanh(self.fc_h(hidden)).unsqueeze(0) cell torch.tanh(self.fc_c(cell)).unsqueeze(0) return outputs, (hidden, cell) class AttentionDecoder(nn.Module): def __init__(self, vocab_size, embed_size, hidden_size, num_layers, dropout0.3): super().__init__() self.embedding nn.Embedding(vocab_size, embed_size, padding_idx0) self.lstm nn.LSTM(embed_size hidden_size, hidden_size, num_layers, batch_firstTrue, dropoutdropout) # 注意力参数 self.W_enc nn.Linear(hidden_size * 2, hidden_size) self.W_dec nn.Linear(hidden_size, hidden_size) self.v nn.Linear(hidden_size, 1, biasFalse) self.fc_out nn.Linear(hidden_size * 2, vocab_size) self.dropout nn.Dropout(dropout) def forward(self, trg, encoder_outputs, hidden, cell): # encoder_outputs: [batch, src_len, 2*hidden] # 先算编码器状态的投影避免每个时间步重复算 enc_proj self.W_enc(encoder_outputs) outputs [] for t in range(trg.size(1)): trg_in trg[:, t] # [batch] embedded self.dropout(self.embedding(trg_in)) lstm_input embedded # 如果是首个时间步直接把已有隐状态作为输入 if t 0: last_context context else: last_context torch.zeros_like(embedded) lstm_input torch.cat((embedded, last_context), dim-1) # [batch, embedhidden] lstm_input lstm_input.unsqueeze(1) out, (hidden, cell) self.lstm(lstm_input, (hidden, cell)) out out.squeeze(1) # [batch, hidden] dec_proj self.W_dec(out).unsqueeze(1) # [batch, 1, hidden] attn_scores self.v(torch.tanh(enc_proj dec_proj)).squeeze(-1) # [batch, src_len] attn_weights F.softmax(attn_scores, dim-1) # [batch, src_len] context torch.bmm(attn_weights.unsqueeze(1), encoder_outputs).squeeze(1) concat torch.cat((out, context), dim-1) logits self.fc_out(concat) outputs.append(logits) return torch.stack(outputs, dim1)需要解释一下fc_h和fc_c的作用因为在双向LSTM中最后一层两个方向的隐状态拼起来是2 * hidden_size但我们希望传给解码器的初始状态跟解码器自己的的维度保持一致所以要投影回hidden_size。这个不起眼的投影层若不写代码会直接报维度不匹配错误属于新手必踩的坑。4.2 训练循环和损失屏蔽主框架搭好之后训练代码反而比模型代码更容易出问题。核心难点有两个一个是Teacher Forcing逻辑另一个是损失函数里需要把填充位置屏蔽掉。填充位置的屏蔽很简单如果目标序列里的某个位置是pad的id那这个位置就完全不参与梯度计算。直接计算交叉熵再按padding mask取平均否则模型会花费大量梯度去学习预测填充符训练损失会偏低但实际效果很糟糕。用代码实现如下loss_fn nn.CrossEntropyLoss(ignore_index0) # pad的id设为0 def train_step(src, trg, model, optimizer, teacher_forcing_ratio0.5): model.train() optimizer.zero_grad() encoder_outputs, (hidden, cell) model.encoder(src) logits model.decoder(trg[:, :-1], encoder_outputs, hidden, cell) # 每个batch内的样本各自独立执行teacher forcing比较麻烦 # 这里用简化方案整个batch统一使用teacher forcing loss loss_fn(logits.reshape(-1, logits.size(-1)), trg[:, 1:].reshape(-1)) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() return loss.item()这里要注意上面代码里的teacher_forcing_ratio参数在简化版本里没有真正派上用场更精细的正确做法是在一个batch内按token或者按batch随机选择是否使用真实摘要作为解码输入。如果想做得更严谨可以用torch.where构造一个mask把真实输入和模型预测输出混合起来控制整体训练稳定性。5. 训练过程中的真实困境与排查思路5.1 损失不降、NAN和梯度爆炸先别急着改模型我在这个项目里遇到的第一类问题是训练损失一直不降甚至出现NAN。排查的顺序很重要不要一上来就换注意力机制或者改LSTM层数而是先做下面三个检查第一个检查是数据对齐模拟项目里我吃过一次亏train集和验证集在预处理时用了不同的截断长度导致验证集上大量样本的词表外比例过高指标惨不忍睹。后来统一了预处理函数才算解决。第二个检查是初始化LSTM的权重初始化对训练稳定性影响极大。我会把所有可训练参数做一次均匀分布初始化范围设为这样前几次前向传播的数值范围是可控的。第三个检查是梯度裁剪。新闻摘要这类生成任务非常容易出现梯度爆炸在batch size较大时尤其明显。直接加一行torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0)就能解决绝大多数NAN问题。如果以上都排查了损失还是纹丝不动可以试着打印一下模型参数的梯度范数看看是不是某些层的梯度本身就是零。这种情况多发生在编码器双向LSTM的投影层因为初始化不合理导致激活值饱和。解法是把torch.tanh换成nn.LayerNorm后再投影或者干脆用nn.Sequential加一层归一化。5.2 显存不够和训练太慢试试梯度累积和分桶毕设阶段很少能拿到高端显卡显存不够是常态。一个新闻样本的source长度是120、target长度是60如果batch size开到128很容易直接完全OOM。我尝试过两条有效路线梯度累积法把batch size从128降到32然后累积4个batch的梯度再优化一次。这个做法既降低了单次显存占用又保留了大规模batch的训练稳定性。动态填充和分桶按source长度排序后分成若干个桶每个桶单独设置batch。虽然代码实现要多写一点但实际训练吞吐量提升非常明显短样本不会被长样本拖累。还有一种容易被忽视的显存优化就是在训练中把用不到的中间变量及时释放。例如在train_step末尾手动del logits, loss并调用torch.cuda.empty_cache()。虽然这不是治本方案但对直接内存峰值有明显帮助。5.3 过拟合和检查点策略毕设答辩的高分保障当batch size、学习率、梯度裁剪都稳定之后新问题会转为过拟合训练损失持续下降但验证集上的ROUGE指标在几个epoch之后开始下降或停滞。此时建议把Dropout打开并增大到0.4同时把Embedding的dropout也打开。检查点策略方面千万不要把最后一个epoch的模型作为最终模型。我习惯在每个epoch结束时用验证集计算ROUGE-L然后只在验证指标提升时保存模型权重。这样即使后面过拟合了手上也保有一个泛化能力最好的模型。对毕设而言答辩时呈现验证集指标随epoch先升后降这条曲线本身就比我跑了50个epoch更有研究价值。6. 推理生成摘要解码策略决定摘要质量的好坏6.1 贪心解码的问题一个字都不能错但错一个就崩训练完成后真正的考验在推理阶段。最简单的解码方式是贪心解码也就是每个时间步取概率最大的词然后作为下一步输入。这样生成的摘要看起来很顺但实际效果往往很糟模型一旦在某一步预测错了后续整个句子都会跑偏而且生成的结果容易陷入重复。举个例子我的基线模型用贪心解码生成摘要时经常出现某公司表示某公司表示该公司表示这种恶性重复。这本质上不是因为模型没有学到语义而是因为贪心解码缺乏全局权衡一旦进入重复的循环就出不来了。6.2 束搜索Beam Search为什么是标配束搜索的改进思路是每一步不只保留概率最大的一个候选而是保留前K个候选继续往下生成。最终选择整体概率最高的一条完整路径。K的取值一般在3到8之间对于新闻摘要任务我建议K4。束搜索实现时有一个细节要特别注意每个候选要记录自己的长度和累计对数概率。由于生成句子越长累计概率理论上越低所以如果不做长度归一化模型会倾向于生成很短的句子。我采用的策略是除以候选长度的0.7次方score total_log_prob / len**0.7这是一个经验值可以在验证集上微调。伪代码如下只有40行左右def beam_search(model, src, vocab, beam_width4, max_len60): bos_id vocab[bos] eos_id vocab[eos] device src.device encoder_outputs, (hidden, cell) model.encoder(src) init_tok torch.tensor([bos_id]).unsqueeze(0).to(device) seqs [[[bos_id], 0.0, hidden, cell]] # [tokens, score, hidden, cell] completed [] for step in range(max_len): new_seqs [] for tokens, score, h, c in seqs: if tokens[-1] eos_id: completed.append((tokens, score)) continue decoder_input torch.tensor([tokens[-1]]).unsqueeze(0).to(device) # 这里省略了注意力计算和LSTM前向的具体实现逻辑同训练阶段 logits model.decoder.decode_step(decoder_input, encoder_outputs, h, c) probs F.log_softmax(logits, dim-1)[0] top_probs, top_idx probs.topk(beam_width) for i in range(beam_width): new_tokens tokens [top_idx[i].item()] new_score score top_probs[i].item() new_seqs.append([new_tokens, new_score, new_h, new_c]) # 排序并保留前beam_width个候选 new_seqs.sort(keylambda x: x[1] / len(x[0]) ** 0.7, reverseTrue) seqs new_seqs[:beam_width] completed.sort(keylambda x: x[1] / len(x[0]) ** 0.7, reverseTrue) best completed[0][0] # 去掉bos和eos并转成文本 return .join([vocab_inv[i] for i in best if i not in (bos_id, eos_id)])6.3 重复惩罚和覆盖惩罚把正文忠实度拉回来即使用了束搜索长摘要中也经常出现信息只在开头出现后半段开始飘或者重复的情况。这时候可以用两个后处理技巧n-gram重复阻断判断当前候选序列末尾几个词是否已经出现过如果出现过就不再扩展这条路。实现起来就是在new_tokens里检查最近3个词是否与前面某段重复。覆盖惩罚在注意力层上加一项惩罚让解码器尽量不去反复关注同一个原文位置。具体做法是累计历史注意力权重并在当前步的注意力分数里减去一定倍数的累计值。我在项目里把这两个技巧都做了最终ROUGE-L大约比单纯束搜索提高了3到4个点人工可读性提升幅度更大。另外还需要做一个简单的后处理把token序列里unk替换成原新闻中出现频率最高的未登录词或者干脆用原文中的对应实体替换这一步对新闻摘要的实体保留非常有效。7. 效果评估与改进方向ROUGE指标之下的冷思考7.1 ROUGE指标怎么算到底在看什么毕设答辩中评委会问的第一个问题大概率是你这个摘要效果怎么评估。最常用的自动指标是ROUGE它本质上是在比较生成摘要和参考摘要之间n-gram的重叠情况。ROUGE-1看单字重合率ROUGE-2看相邻二字重合率ROUGE-L看最长公共子序列覆盖度。以ROUGE-L为例它会计算生成摘要和参考摘要的最长公共子序列长度然后算出召回率、精确率和F1值。例如参考摘要为公司发布新手机生成摘要为该公司发布新机型最长公共子序列是公司发布新长度5生成摘要总长8参考总长7那么召回率是5/7精确率是5/8。从实际操作来看我最终模型在验证集上的指标大约是ROUGE-1为31、ROUGE-2为12、ROUGE-L为28这个数字在新闻摘要任务里属于中等水平。它不算耀眼但对于一个从零搭建的LSTM注意力基线来说已经足以支撑一份完整的毕业设计论文。7.2 指标高不等于摘要好忠实性才是新闻摘要的地基自动指标有一个隐蔽的问题两个句子的含义完全相同但用词完全不同ROUGE分数会非常低。反之一个语句通顺但核心信息张冠李戴的摘要反而可能因为n-gram重合获得高分。新闻摘要最致命的问题不是语言不流畅而是模型编造了原文不存在的事实。所以在项目评估时我做了很关键的一步人工抽样评估。我会随机抽100条新闻让几个人对生成的摘要从信息忠实度、流畅度、信息完整度三个维度打分。结果显示有些ROUGE分数中等的候选人工看起来反而更好因为它抓住了关键实体和因果关系。这个发现写进论文里往往比堆砌技术细节更有说服力。7.3 如果想挑战更高分数可以从哪几条线改这个项目做到这里已经具备了完整的毕设形态但如果时间和精力允许下面几个方向可以让项目更有层次感把LSTM编码器换成预训练语言模型底座比如BERT类模型作为编码器再接入Transformer解码器文本理解能力会整体上一个台阶代价是显存和训练时间大增。加入拷贝机制Pointer-Generator允许模型在生成时直接从原文中复制罕见词这对新闻里大量的人名、机构名、数字非常友好ROUGE-2提升通常最明显。结合抽取式方法做两阶段摘要先用一个抽取器选关键句再把关键句送入生成式模型压缩。这个思路符合学术界的抽取再生成范式答辩时也容易讲清楚每个模块存在的必要性。如果让我重新做一次这个项目我会在数据部分多分配一倍时间把人工评估标注做得更完整同时把基座模型逐步升级每一步都在验证集上记录指标变化。这样一个线性清晰的改进链路比单跑一个大模型出来的结果更适合作为毕设的核心故事线。