又到了一年一度的毕设季,后台好几个读者问我同一个题目:“基于深度学习的新闻摘要生成算法实现”。这个题选得挺聪明,新闻摘要属于文本生成里最有代表性的任务之一,数据好找、效果直观、还能往上堆注意力机制和预训练模型,不管是本科还是硕士,都有足够的发挥空间。但真上手你会发现,单是“能用Encoder-Decoder跑通一个模型”和“把摘要做出可用效果”之间,隔着数据清洗、调参、解码策略、评估指标好几座大山。
这篇文章就把我做这个题目时踩过的坑和验证过可行的方案,完整梳理一遍。从模型选型、数据处理、训练细节,到推理解码和评估,尽量说清楚每一步“为什么这么干”,不光是给代码。适合正在做相关毕设的同学,也适合想认真入坑自然语言生成的新手。看完你至少能少走一个月的弯路。
1. 整体方案设计与核心思路拆解
1.1 为什么新闻摘要适合做深度学习毕设
选毕设题目有个朴素的标准:数据要够多、任务要够明确、效果要能被看懂。新闻摘要刚好三条全占。新闻语料在网上有大量公开来源,爬下来就是现成的“原文-标题”配对,不需要像很多 NLP 任务那样请人标注,天然就是弱监督数据。摘要这个东西,普通人一眼就能看出好不好,答辩演示的时候,老师哪怕不懂深度学习,也能直观感受到“这个模型生成的标题通不通顺、有没有抓住重点”。
另外这个题目的技术含量也可以循序渐进。最基础的版本用 Encoder-Decoder + 注意力就能跑;想拿高分可以加 Pointer-Generator 解决未登录词问题、加 Beam Search 改善生成质量、甚至换 BART/T5 这类生成预训练模型作为对比实验。一套下来,从原理到工程都有了,写到论文里的“实验对比”部分也会非常好看。
1.2 抽取式还是生成式:方案选型的关键决策
做摘要之前,首先要搞清楚一件事:你是要做抽取式(Extractive)还是生成式(Abstractive)。这两个方向的技术路线完全不同。
抽取式是从原文里挑出关键句子拼成摘要,技术上更像是“排序问题”,经典算法有 TextRank、基于图的排序等。它的优点是结果一定通顺、没有幻觉,但缺点也很明显——摘要是原文句子的拼接,不够凝练。比如一条 500 字新闻,好的标题可能是一句原文里根本没有的话,抽取式做不出来。
生成式则是“理解原文之后重新写一句话”,跟人类写标题的思路更接近。它需要模型具备一定的“改写”和“压缩”能力,这正是 Encoder-Decoder 框架擅长的。毕设题目既然点名了 Encoder-Decoder,那主线当然走生成式。不过实操里我会建议你做一个抽取式作为 Baseline(比如简单的 TextRank 或者抽取第一句拼标题),拿来和深度学习模型对比。别小看这个 Baseline,它能从两个角度帮你:第一,毕业论文里需要对照实验,Baseline 的存在让你的深度学习模型提升有了参照;第二,如果深度学习模型连 TextRank 都打不过,你也能早点发现问题,而不是拖到最后。
1.3 Encoder-Decoder 为什么是文本生成的地基
说 Encoder-Decoder 之前,先想想“生成摘要”这件事的本质。输入是一篇几百词甚至上千词的新闻正文,输出是一句几十词以内的标题,模型要做的核心事情是:先把长的东西“压缩理解”,再把这个理解“展开成一句话”。
这正好对应两个模块。Encoder 负责把整个输入序列“看一遍”,编码成一个包含语义信息的向量序列;Decoder 负责从这个向量序列出发,一个词一个词地把摘要生成出来。你可以把整个过程类比成人类做阅读理解:先读完整篇文章在脑子里形成“这篇文章在说什么”的把握,然后动笔写一句话梗概——写的时候每写一个词,都会回顾一下原文的重点,确保自己没跑偏。
这个流程在数学上的表达就是:
- Encoder 将输入序列 x = (x₁, x₂, ..., xₙ) 编码为隐状态序列 h = (h₁, h₂, ..., hₙ)
- Decoder 在第 t 步根据之前的输出词 y₁, y₂, ..., yₜ₋₁ 以及编码信息,预测下一个词 yₜ 的概率分布 p(yₜ | y₁, ..., yₜ₋₁, x)
整个模型训练的目标就是让每一步的条件概率乘积尽可能大,也就是最大化 P(y₁, y₂, ..., yₘ | x) 。这个过程听起来不复杂,但从这个基础框架出发,能延伸出注意力机制、复制机制、指针网络等大量改进方案,所以我一直觉得把 Encoder-Decoder 吃透,比直接调包用现成的摘要模型更有价值,写论文的时候也更有话可说。
2. 核心技术拆解:从RNN到注意力再到复制机制
2.1 编码器:用双向LSTM把新闻读进隐状态
在最基础的版本里,Encoder 用的是单层 LSTM,但我在实际项目里强烈建议你用双向 LSTM(BiLSTM)。原因很简单:新闻标题的写作经常会用到全文前后文的信息。举个例子,“球队在落后 20 分的情况下完成逆转”,如果你想在摘要里准确体现“逆转”这个动作,只看前半句是不够的,必须结合后文才知道发生了什么。双向 LSTM 的 forward 方向能捕捉上文信息,backward 方向能捕捉下文信息,把两者拼接起来,每个位置的隐状态就同时携带了上下文信息,这对摘要任务来说非常关键。
具体参数上,我当时的设置是:词向量维度 dₑₘ₆ = 256,LSTM 隐藏层维度 dₕᵢddₑₙ = 512。注意这里有个小坑——如果你用 PyTorch 的 nn.LSTM,需要注意 batch_first 参数。我习惯设置 batch_first=True,这样输入张量的形状是 (batch_size, seq_len, embedding_dim),数据的组织方式更符合直觉。
编码器输出的隐状态 hᵢ 会组成一个矩阵,维度是 (seq_len, hidden_dim * 2)(双向所以要乘 2)。这个矩阵后半部分的工作——也就是注意力机制——要靠它提供原始素材,所以这里尽量别省显存,hidden_dim 设成 512 是我试下来成本和效果比较平衡的点。
2.2 解码器与注意力机制:每次生成都回顾原文重点
如果只有一个编码器的最后一个隐状态传给解码器,模型就相当于逼着一个几百词的新闻“压缩成一个向量”,这对长文本来说信息损失太严重了,而且解码器越到后面,对原文的记忆越模糊。
解决方案就是注意力机制。它的核心思想是:解码器在生成每个词的时候,不是机械地依赖一个固定向量,而是动态地“回看”原文的每一个位置,找出当前步骤最应该关注的部分。
我使用的是经典的 Bahdanau Attention(也叫加性注意力)。它的计算过程可以拆成三步:
- 解码器当前隐状态 sₜ 和编码器的每个隐状态 hᵢ 做一个打分函数,得到一个分数 eₜᵢ = vᵀ tanh(W₁hᵢ + W₂sₜ)
- 对所有分数做 softmax,得到注意力权重 αₜᵢ
- 用权重对所有编码器隐状态做加权求和,得到上下文向量 cₜ = Σᵢ αₜᵢ hᵢ
然后把这个上下文向量和解码器隐状态拼接起来,用于预测下一个词。这个机制就类似于人写作时“重读原文相关段落”的行为,注意力权重越大,说明原文那个位置对当前生成词的贡献越大。我经常跟人说,注意力机制的美妙之处在于它不光带来了效果提升,还能可视化——训练完把注意力权重画出来,你能直接看到模型在生成“冠军”这个词时,确实在重点看原文里“夺冠”那句话,这玩意拿到答辩现场演示效果拉满。
2.3 指针生成网络:解决未登录词问题
新闻里人名、机构名、地名密集出现,比如“某跨国公司的 CEO 宣布了什么政策”,这些专有名词往往不在词表里,是未登录词(OOV)。标准的 Seq2Seq 模型遇到这种情况只有一个办法:生成一个<UNK>占位符。如果摘要里频繁出现<UNK>,整个输出就废了,老师一看就是“模型没训练好”。
解决方案是在基础架构上加入指针生成网络(Pointer-Generator Network)。它的核心思想是:在生成每个词时,模型先算一个“拷贝概率”p_gen,这个概率表示当前这个词是从词表里“生成”的,还是直接从原文里“复制”过来的。
具体来说,解码器每一步会计算 p_gen = σ(Wc · cₜ + Wy · yₜ + Ws · sₜ + b),其中 yₜ 是当前输入词。然后最终的词概率分布是:
P(w) = p_gen × P_vocab(w) + (1 - p_gen) × Σᵢ:ᵢ₌ᵥ αₜᵢ
这个式子右边第一项就是“从词表生成词”,第二项是把原文里出现的词按注意力权重“复制”过来。有了这个机制,新闻里的人名地名就能原封不动地出现在摘要里,效果提升非常明显。我在实验里对比过,指针网络把 ROUGE-1 的分数提高了大约 4 到 5 个点,而且是肉眼可见的改善——没有它,摘要里一遇到人名就出现<UNK>,有了它,摘要瞬间像样多了。
2.4 覆盖机制:防止摘要陷入循环重复
生成模型有个臭名昭著的问题:生成到后面,模型经常会重复刚才已经说过的词,比如“某某球队 获胜 获胜 获胜”。这是因为解码器在生成第 t 个词时,没有“记忆”自己之前已经覆盖过哪些原文信息,注意力可能反复落在同一个位置。
解决办法是 Coverage Mechanism(覆盖机制)。做法是维护一个覆盖向量 covᵢ,表示编码器第 i 个位置已经被注意力“看过”多少轮。每次计算注意力打分时,把这个覆盖向量加权进去,相当于对重复关注过的位置进行“惩罚”:
eₜᵢ = vᵀ tanh(W₁hᵢ + W₂sₜ + W₃covᵢ)
同时,在损失函数里加一个覆盖损失项,鼓励注意力权重不要重复分配。加了覆盖机制之后,模型生成重复词的频率显著下降,尤其是在新闻这种信息密集的文本上,效果非常值得做。实测下来,覆盖机制 + 指针网络,这两个改进是毕设拿高分的关键组合。
3. 数据处理与模型训练的实操细节
3.1 数据集选择与清洗流程
做摘要任务,数据质量基本决定模型效果的天花板。我在做的时候用的是某公开的中文新闻语料库,里面有几十万条新闻,每条包含“正文”和“标题”两个字段。但原始数据不能直接用,里面的脏数据比你想象的多得多。
我梳理了一套清洗流程,你直接照着做就行:
- 去掉正文长度小于 100 的条目,太短的新闻没有摘要价值,而且会给数据引入大量噪声
- 去掉正文和标题长度比小于 2 的条目,正常新闻的标题应该远比正文短,如果标题比正文还长,这个数据八成有问题
- 删除含 HTML 标签、广告关键词(如“点击查看”“了解更多”)的文本,用正则表达式一次性处理
- 全文统一转换成简体中文,这里我用到了 OpenCC 库,繁简转换比较可靠
- 把标题里的引号、书名号统一成半角格式,避免同一个词因为引号不同被切成两个 token
这套流程走完,数据从最初的 40 万条左右清洗到 32 万条左右,清洗率大约 20%。别嫌洗掉得多,摘要模型对数据的整齐度极其敏感,一条带广告尾巴的样本,就能让模型在验证集上多出两三个“点击查看”之类的垃圾输出。
3.2 分词、构建词典与数据截断
中文 NLP 跟英文有个关键区别:英文天然按空格分词,中文必须先分词。我试过 jieba 分词和基于 BERT 的切词,实际使用中 jieba 就够用了,毕竟我们用的是 LSTM 这类传统模型,不是预训练模型。
分词之后是构建词典。这里要给一个最低配置参考:词表大小设成 5 万,把出现次数低于 5 的词归入<UNK>。构建词典时一定要把<PAD>、<UNK>、<BOS>、<EOS>这四个特殊 token 预留出来。其中<PAD>用来把 batch 里长度不一的句子补齐到相同长度,<UNK>代表未登录词,<BOS>和<EOS>分别用于告诉解码器“开始生成”和“结束生成”。
关于截断长度,我做了一组简单的统计分析:新闻正文长度集中在 300 到 600 字,标题集中在 15 到 50 字。所以我设置了正文最大长度 512,标题最大长度 80。长度不够就 pad,超过就截断。这个设定在显存利用率和效果之间比较平衡,建议你跟着自己数据的分布去统计一下,而不是拍脑袋随便定。
3.3 模型参数与训练策略
模型结构搭建完成之后,关键就是怎么训练。我把自己的参数配置整理成了表格,照着跑基本不会有方向性的错误:
| 参数 | 设置值 | 说明 |
|---|---|---|
| 词向量维度 | 256 | 随机初始化,没有用预训练词向量 |
| LSTM 隐层维度 | 512 | 编码器是双向,所以实际维度 1024 |
| LSTM 层数 | 2 | 超过 2 层提升有限,训练成本成倍增加 |
| Batch Size | 64 | 视显存调整,12G 显存够用 |
| 优化器 | Adam | 学习率 0.001,注意配合梯度裁剪 |
| 梯度裁剪 | 5.0 | 防止 RNN 梯度爆炸,必须加 |
| 学习率衰减 | 每 2 个 epoch 乘以 0.8 | 后期收敛更稳定 |
| Teacher Forcing 比例 | 1.0 开始,逐步降低 | 前期强制用真实词,后期让模型自己生成 |
| Epoch 数 | 15 左右 | 根据验证集 ROUGE 早停 |
这里重点说两个只有实际跑过才会懂的坑。
第一个坑是Teacher Forcing。Decoder 训练时,一种做法是每一步都把模型上一步生成错误的词喂回模型,结果误差会像滚雪球一样越滚越大,模型根本训练不动。所以我用 Teacher Forcing:训练时以一定概率把真实的目标词喂给解码器作为下一步输入,而不是用模型上一步生成的词。但在训练后期,要逐步降低这个比例,让模型逐渐学会“自己生成自己吃”,否则训练出来的模型在测试时会因为推理和训练分布不一致而崩掉。
第二个坑是损失函数只计算 Decoder 部分的损失。新手很容易把 Encoder 的损失也算进去,但实际上 Encoder 没有直接的监督信号,它只是把输入编码成向量,损失全部来自解码器每个 step 的交叉熵。实现时记得把 padding 位置的损失 mask 掉,不然<PAD>位置会让损失函数出现偏差。我见过有人没做 padding mask,训练了十几个 epoch 后 loss 还是降不下去,查了半天发现是这个问题。
3.4 核心代码框架:一个简化但完整的实现
下面是训练循环的核心框架代码,基于 PyTorch,我做了删减保留关键部分。要注意这里只是让你看懂数据流,完整代码里还需要补上注意力打分、覆盖向量这些细节:
class Encoder(nn.Module): def __init__(self, vocab_size, embed_size, hidden_size, num_layers=2): 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) def forward(self, src, src_len): embedded = self.embedding(src) # [batch, seq_len, embed_size] packed = nn.utils.rnn.pack_padded_sequence( embedded, src_len, batch_first=True, enforce_sorted=False) outputs, (hidden, cell) = self.lstm(packed) outputs, _ = nn.utils.rnn.pad_packed_sequence(outputs, batch_first=True) # outputs: [batch, seq_len, hidden_size * 2] return outputs, hidden, cell class Decoder(nn.Module): def __init__(self, vocab_size, embed_size, hidden_size, attention, num_layers=2): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_size, padding_idx=0) self.attention = attention self.lstm = nn.LSTM(embed_size + hidden_size * 2, hidden_size, num_layers, batch_first=True) self.fc_out = nn.Linear(hidden_size * 3, vocab_size) def forward(self, trg, encoder_outputs, hidden, cell): embedded = self.embedding(trg) # [batch, trg_len, embed_size] # 将编码器输出展平到每个时间步 outputs = [] for t in range(trg.size(1)): step_input = embedded[:, t:t+1, :] # [batch, 1, embed_size] context, attn_weights = self.attention(step_input, encoder_outputs) lstm_input = torch.cat((step_input, context), dim=-1) output, (hidden, cell) = self.lstm(lstm_input, (hidden, cell)) output = torch.cat((output, context), dim=-1) pred = self.fc_out(output) # [batch, 1, vocab_size] outputs.append(pred) return torch.cat(outputs, dim=1)上面代码里我特意省略了注意力实现,因为注意力机制根据覆盖机制的有无会有几个不同的版本。但你要看清楚核心逻辑:Decoder 每个时间步都要把上一步的隐状态、当前输入词向量、注意力机制产生的上下文向量这三者拼接之后,再经过 LSTM 和全连接层,才能得到当前词的概率分布。这种“拼接输入”的模式是解码器的标准玩法。
训练循环里有个关键点:数据要装成 DataLoader,但要处理变长序列。我的做法是在 collate_fn 里对 batch 内的样本按长度排序,然后用 pack_padded_sequence 压缩 LSTM 计算。一句话总结就是:RNN 处理变长序列时,直接 pad 到相同长度会浪费大量算力,pack_padded_sequence 才是正解。很多人图省事直接 pad,结果训练速度慢一倍,显存还爆了。
4. 推理阶段、评估指标与调优实战
4.1 用Beam Search而不是贪心搜索
训练完成之后,生成摘要的推理阶段同样有很多门道。最朴素的解码方式是贪心搜索:每一步都选概率最大的词,然后把这个词作为下一步输入。这种办法简单,但问题在于:每一步最优不等于整句话最优。类似于下棋只走当前最好的一步,可能后面全崩了。
我用的是 Beam Search,思路是每一步保留 top-k 个候选序列(k 叫 beam size,一般取 4 或 5),最后从候选中选出概率乘积最大的一条。这就好比一次走棋,保留几种最好的走法同时往下推演。具体操作上需要注意 trg 端要按步数循环,维护一个“候选序列集合”,每轮扩展这个集合,然后截取 top-k。这里贴上核心的推理逻辑:
def beam_search(model, encoder_outputs, src_mask, beam_size=4, max_len=80): batch_size = encoder_outputs.size(0) # 初始化解码器状态和起始token(<BOS>) decoder_input = torch.tensor([[BOS_IDX]] * batch_size) hidden, cell = model.decoder.init_state(encoder_outputs) # 每个样本维护多个候选: (log_prob_sum, sequence) sequences = [[0.0, [BOS_IDX]]] completed = [] for step in range(max_len): all_candidates = [] for log_prob, seq in sequences: decoder_input = torch.tensor([seq[-1]]).unsqueeze(0) output, hidden, cell = model.decoder( decoder_input, encoder_outputs, hidden, cell) log_probs = torch.log_softmax(output[0, -1], dim=-1) topk_log_probs, topk_idx = torch.topk(log_probs, beam_size) for k in range(beam_size): candidate = (log_prob + topk_log_probs[k].item(), seq + [topk_idx[k].item()]) all_candidates.append(candidate) # 按得分排序,保留当前长度下最好的beam_size个 all_candidates.sort(key=lambda x: x[0], reverse=True) sequences = [] for _, seq in all_candidates: if seq[-1] == EOS_IDX: completed.append((log_prob, seq)) else: sequences.append((log_prob, seq)) if len(sequences) >= beam_size: break if len(sequences) == 0: break if completed: best = sorted(completed, key=lambda x: x[0], reverse=True)[0] else: best = sorted(sequences, key=lambda x: x[0], reverse=True)[0] return best[1]这里有一个容易出错的细节:Beam Search 的 log 概率用的是累加。因为每个词的概率都小于 1,连乘会让数值越来越小甚至下溢,所以统一取 log 之后做加法。另外,beam 里所有序列尽量保持在相近的长度,避免短的序列因为 token 少而得分偏高,所以在最终排序时我会对长度做归一化,具体做法是除以 sequence length 的某个幂次(比如 0.7),这个幂次是经验值。
4.2 ROUGE指标到底怎么算,如何看懂它的含义
做摘要毕设,评估指标绕不开 ROUGE。ROUGE 的全称是 Recall-Oriented Understudy for Gisting Evaluation,简单理解就是把生成的摘要和参考摘要拿来对比重叠词的数量。
它有几个变种:
- ROUGE-1:单个词的匹配率
- ROUGE-2:相邻两个词(bigram)的匹配率
- ROUGE-L:最长公共子序列的匹配率
不要只盯着一个指标看。我的经验是:ROUGE-1 高不一定说明摘要好,因为它只看单个词,模型只要多输出几个高频词就能刷上去;ROUGE-2 更严格,需要连续两个词都匹配;ROUGE-L 关注的是语序信息。三个指标放在一起看,才能反映模型的真实水平。实操里我直接用 py-rouge 或者 HuggingFace 的 evaluate 库,里面已经封装好了计算逻辑。
基线水平大致这样:在一个几十万规模的中文新闻数据集上,简单的抽取式 Baseline(取正文第一句做标题)ROUGE-1 能有 20 上下。我的指针生成网络 + 覆盖机制模型,ROUGE-1 到了 31 左右,ROUGE-2 在 17 左右,ROUGE-L 在 27 左右。别小看这个数字,新闻标题的写作自由度高,同一个新闻不同媒体可能起完全不同的标题,所以 ROUGE 分数天花板没有想象中那么高,ROUGE-1 能到 30 以上已经算个不错的模型了。
4.3 常见问题排查速查表
做这个项目时遇到的 90% 的问题,都可以归到下面几类。我直接整理成一张表,你碰到类似现象对着查就行:
| 问题现象 | 可能原因 | 排查与解法 |
|---|---|---|
| Loss 不下降 | 学习率过高或过低 | 尝试 0.001、0.0005 从小往大调,观察前 500 步趋势 |
| Loss 下降到 3 左右就停滞 | 没有做 padding mask | 检查交叉熵是否把 ` 位置的损失也算进去了 |
| 生成摘要通篇都是 `` | 未登录词问题 | 加入指针生成网络,或增大词表 |
| 生成内容重复严重 | 注意力崩溃 | 加入覆盖机制,第 2 个 epoch 之后开始生效 |
| 生成摘要比原文还长 | 没有设置 终止条件 | Beam Search 里强制遇到 or 达到 max_len 就停止 |
| 训练时显存溢出 | batch size 太大 | 从 64 降到 32,或开启梯度累积 |
| 验证 ROUGE 高但人工看起来差 | 训练数据太短 | 检查数据集里是不是有大量只能提取原句的短新闻 |
| 同一个 batch 内长度差异巨大,训练慢 | 没有用 pack_padded_sequence | 改用长度排序 + pack 机制 |
其中我想展开说两个最容易被忽视的坑。
第一个是验证集不干净导致 ROUGE 虚高。我遇到过一次,验证集里混了一些从网上抓的“脏数据”,标题就是正文第一句话的截断,导致模型只要机械地复制开头,ROUGE 就非常高。但拿到真实新闻上一测,效果惨不忍睹。这个问题必须警惕,验证集也得经过清洗。
第二个是训练和验证的预处理不一致。我一度在训练时做了繁转简和全角转半角,但验证时因为脚本写漏了全角转半角,导致所有分数掉了 2 到 3 个点。看起来小,实际影响巨大,这种低级的预处理不一致问题,我建议你在跑实验之前,写一个单测脚本验证 train/val 的预处理输出是否一致。
5. 实验设计、论文展示与答辩经验
5.1 对比实验怎么设计才好看
毕设能不能拿高分,一半在模型的创新度,另一半在实验设计的完整度。单跑一个模型是不行的,你需要一个递进式的实验序列。我当时设计的四组实验是:
- Baseline:抽取式方法(比如 TextRank 或直接取首句),用于证明“简单方法”的上限
- 基础 Seq2Seq + Attention:证明深度学习方法至少不输给抽取式
- Seq2Seq + Attention + Pointer-Generator:证明复制机制对新闻摘要至关重要
- 上面全部 + Coverage 机制:证明覆盖机制能解决重复问题
四组实验结果画成折线图,一组比一组高,而且每一组提升都有明确的原理解释。答辩的时候老师问“你这个模型对比抽取式有什么优势?”“为什么加 Pointer 有用?”你都能拿出数据逐条讲。这种“递进式”实验设计,比只放一个最终模型的单项结果有说服力得多。
另外我强烈建议做一组人工评估。随机抽 100 条新闻,让 3 个人打分(1 到 5 分),评估维度是流畅度、信息完整度、概括准确度。人工评估的好处是能捕捉到 ROUGE 漏掉的语义信息,比如生成标题是否和原文事实矛盾。这部分内容写进论文里,能体现你对问题的全面思考,而不是只会跑指标。
5.2 可视化与系统演示的加分项
很多同学把精力全放在模型上,结果演示环节只会开个 Jupyter Notebook 跑代码给老师看,那效果肯定大打折扣。
我建议做一个简单的 Web 演示页面,用 Gradio 或 Flask 包一个界面。用户输入新闻正文,点击提交,模型自动生成标题。再顺手把注意力热力图显示出来,让老师看到生成某个词时模型到底在看原文哪个位置。这一步不需要特别复杂的工程,但交互式的效果远胜于命令行输出。
另外一个细节:把生成失败的案例也准备几个。答辩现场如果全是成功案例,老师会觉得你挑了好的展示;准备 2 到 3 个反面案例,并主动解释失败原因(比如“文本太长了,长度超过训练分布”“出现了训练集里没有的地名”),反而能证明你对模型边界有清晰的认识。
5.3 从基础版本到进阶方向
如果你的时间充裕,可以在基础模型之上再探索一到两个进阶方向,这在论文里作为 Future Work 或者扩展实验都很有分量。
- 用生成预训练模型做对比:比如 BART 或 T5,在中文上可以用中文版的生成模型。现在很多开源模型都已经支持新闻摘要任务,它们的生成效果会显著超过从头训练 LSTM,但参数量大、训练慢。你不需要复现整个预训练过程,只需要做微调对比。
- 加入关键词信息:在很多新闻场景中,标题其实就是最能概括全文的几个关键词的组合。可以尝试用无监督方式抽取关键词,把它们作为辅助输入注入 Decoder。
- 多任务联合训练:比如同时让模型做“新闻分类”和“标题生成”,共享 Encoder,两个任务互相促进。这在论文里属于方法论创新,可以增加理论深度。
5.4 我自己踩过的那些坑(经验总结)
做这个题目的过程中,有几个事情如果我一开始就知道,能省出至少一周时间。整理成几条个人体会放在这里:
第一,不要在训练脚本上过度纠结代码优雅。我一开始把所有模块抽象成六个文件,结果每次改一个超参数都要跳来跳去。后来我直接把训练循环、模型定义、数据处理放在三个文件里,简单清晰,跑实验反而快很多。毕设的核心是验证想法,不是写工程框架。
第二,每次实验必须保存完整的配置记录。我用的是最笨的办法——每次跑实验把参数自动保存成一个 JSON 文件,命名里带上日期和关键参数。否则你跑十组实验之后,根本分不清哪个结果对应哪组参数。
第三,要尽早检查生成结果而不是只盯着 loss。Loss 下降不等于生成质量变好。我建议第一个 epoch 跑完,就手动生成几条摘要看一下,哪怕模型还很烂,至少能确认 pipeline 是通的。我曾经历过 loss 正常下降但生成结果全是重复词的情况,等发现问题的时候已经白跑了三四个 epoch。
最后再分享一个小技巧:如果你在训练时发现显卡显存不够用,又不想降 batch size 影响收敛速度,可以用梯度累积——每几个 batch 累加梯度再更新一次。我当时把 batch size 降到 32,用梯度累积 2 步,等效 batch size 还是 64,效果几乎没有下降。但要注意,用了梯度累积,学习率可能需要同步微调,我自己的经验是等效 batch size 翻倍时,学习率可以稍微加大 10% 到 20%,收敛速度会快一点。