最近帮一位学弟梳理毕设思路,发现很多人在选题阶段都会把"新闻摘要生成"放上候选名单,但真正动手时,第一步就卡住了——不是卡在模型跑不起来,而是卡在"不知道这个任务到底该从哪儿拆起"。新闻摘要看起来就是个"读长文、写短文"的活儿,可一旦落到代码实现,问题就变成了:数据怎么清洗、词表怎么建、模型怎么搭、摘要怎么生成、效果怎么评估。这一整套链路里任何一环出问题,毕业论文答辩现场都会很难看。
这篇文章就围绕我手上的一个实际项目——基于深度学习的新闻摘要生成算法来展开,核心框架采用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_freq=3): 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,层数2,Dropout设为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, dropout=0.3): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_size, padding_idx=0) self.lstm = nn.LSTM(embed_size, hidden_size, num_layers, batch_first=True, bidirectional=True, dropout=dropout) # 双向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]), dim=1) # [batch, 2*hidden] cell = torch.cat((cell[-2], cell[-1]), dim=1) 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, dropout=0.3): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_size, padding_idx=0) self.lstm = nn.LSTM(embed_size + hidden_size, hidden_size, num_layers, batch_first=True, dropout=dropout) # 注意力参数 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, bias=False) 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, embed+hidden] 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, dim=1)需要解释一下fc_h和fc_c的作用:因为在双向LSTM中,最后一层两个方向的隐状态拼起来是2 * hidden_size,但我们希望传给解码器的初始状态跟解码器自己的的维度保持一致,所以要投影回hidden_size。这个不起眼的投影层若不写,代码会直接报维度不匹配错误,属于新手必踩的坑。
4.2 训练循环和损失屏蔽
主框架搭好之后,训练代码反而比模型代码更容易出问题。核心难点有两个:一个是Teacher Forcing逻辑,另一个是损失函数里需要把填充位置屏蔽掉。
填充位置的屏蔽很简单:如果目标序列里的某个位置是<pad>的id,那这个位置就完全不参与梯度计算。直接计算交叉熵再按padding mask取平均,否则模型会花费大量梯度去学习"预测填充符",训练损失会偏低但实际效果很糟糕。
用代码实现如下:
loss_fn = nn.CrossEntropyLoss(ignore_index=0) # pad的id设为0 def train_step(src, trg, model, optimizer, teacher_forcing_ratio=0.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_norm=5.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之间,对于新闻摘要任务,我建议K=4。
束搜索实现时有一个细节要特别注意:每个候选要记录自己的长度和累计对数概率。由于生成句子越长,累计概率理论上越低,所以如果不做长度归一化,模型会倾向于生成很短的句子。我采用的策略是除以候选长度的0.7次方(score = total_log_prob / len**0.7),这是一个经验值,可以在验证集上微调。
伪代码如下,只有40行左右:
def beam_search(model, src, vocab, beam_width=4, max_len=60): 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(key=lambda x: x[1] / len(x[0]) ** 0.7, reverse=True) seqs = new_seqs[:beam_width] completed.sort(key=lambda x: x[1] / len(x[0]) ** 0.7, reverse=True) 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提升通常最明显。
- 结合抽取式方法做两阶段摘要:先用一个抽取器选关键句,再把关键句送入生成式模型压缩。这个思路符合学术界的"抽取再生成"范式,答辩时也容易讲清楚每个模块存在的必要性。
如果让我重新做一次这个项目,我会在数据部分多分配一倍时间,把人工评估标注做得更完整,同时把基座模型逐步升级,每一步都在验证集上记录指标变化。这样一个线性清晰的改进链路,比单跑一个大模型出来的结果更适合作为毕设的核心故事线。