☰
Transformer文本摘要生成:从编码器到解码器的工程实践与避坑指南
2026/10/20 2:26:46 网站建设 项目流程

简介:这份资源是面向本科毕业设计场景的深度学习文本摘要自动生成项目,聚焦自然语言处理与Transformer模型的工程落地,适合具备一定Python基础、希望系统实践NLP课程设计或毕业设计的学生。压缩包共34个文件,约360KB,以18个Python脚本为核心,覆盖模型定义、数据预处理、训练与评估流程,另含5个Shell脚本用于训练与测试调度,以及txt、yml、vocab、Dockerfile等配置与词表文件,整体结构清晰、便于按模块阅读。项目围绕编码器-解码器架构展开,涉及分词、词汇表构建、序列化输入、损失函数与优化器配置,并引入ROUGE、BLEU等指标衡量摘要质量,同时提供beam search、强化学习训练等进阶实现,可帮助读者理解从数据到模型再到评价的完整链路。目前已有3764人学习下载,适合作为NLP方向毕业设计的参考方案与代码模板。

1. 文本摘要自动生成:为什么模型写出的摘要总像“正确的废话”

你大概见过这样的场景:把一篇两千字的新闻丢给一个摘要模型,它返回一句“本文介绍了某事件的发生背景、主要经过和各方反应”。语法没错,信息也没错,但读完等于没读。这不是模型偷懒,而是文本摘要这个任务本身有一个反直觉的底层矛盾——摘要的“好”没有唯一解,但训练时模型只能对着一个参考答案学。

抽取式摘要从原文里挑句子,看起来安全,却容易选出冗长、重复、指代不明的片段;生成式摘要能压缩改写,却要面对“事实一致性”这个玄学问题:模型可能把“A 公司收购 B 公司”写成“B 公司收购 A 公司”,句子通顺,事实翻车。本科毕业设计选这个方向,真正要搞懂的不是调哪个库,而是 Transformer 到底怎么把一篇长文本压进一组向量、解码器又怎么把这组向量重新展开成通顺句子。这份资源围绕的正是这条从“理解”到“生成”的完整链路,把注意力机制、编码器-解码器结构、训练目标和推理策略串成一条能跑通的工程路径。搞懂它,你再看任何摘要模型,都能一眼看出它是在哪个环节妥协了。

2. Transformer 编码器:一篇长文本是怎么被压进向量里的

2.1 自注意力到底在算什么

文本摘要的第一个硬骨头是“长距离依赖”。传统 RNN 按顺序读词,读到句尾时句首的信息已经衰减得差不多了;CNN 靠卷积核扩大感受野,但层数不够时仍然抓不住跨段落的关联。Transformer 的自注意力机制换了个思路:每个词直接和序列里所有词算相关性,一步到位建立全局连接。

具体做法是把每个词向量投影成三个矩阵:Query(查询)、Key(键)、Value(值)。用公式说就是:

Attention(Q, K, V) = softmax(QK^T / sqrt(d_k)) V

Q 和 K 的点积衡量“这个词该对那个词放多少注意力”,除以 sqrt(d_k) 是防止维度太高时点积过大、softmax 梯度消失。softmax 归一化后得到权重,再对 V 加权求和,输出就是融合了全局信息的新的词表示。

我一般会这么理解:Query 是“我在找什么”,Key 是“我有什么”,Value 是“我实际能提供的内容”。三者都来自同一个输入序列时叫自注意力;如果 Query 来自解码器、Key 和 Value 来自编码器,那就是交叉注意力,负责在生成时“回头看原文”。

2.2 多头注意力:为什么要拆成好几组

单组注意力只能学到一种关联模式。有的词需要关注语法搭配,有的需要关注语义指代,一组权重很难同时兼顾。多头注意力把 d_model 维的向量切成 h 份,每份独立做一次注意力,最后拼接再线性变换。

import torch import torch.nn as nn class MultiHeadAttention(nn.Module): def __init__(self, d_model, num_heads): super().__init__() assert d_model % num_heads == 0 self.d_model = d_model self.num_heads = num_heads self.d_k = d_model // num_heads # 三个投影矩阵分别生成 Q、K、V 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_o = nn.Linear(d_model, d_model) def forward(self, query, key, value, mask=None): batch_size = query.size(0) # 投影后拆成多头: (batch, seq_len, num_heads, d_k) -> (batch, num_heads, seq_len, d_k) Q = self.W_q(query).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2) K = self.W_k(key).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2) V = self.W_v(value).view(batch_size, -1, self.num_heads, self.d_k).transpose(1, 2) # 缩放点积注意力 scores = torch.matmul(Q, K.transpose(-2, -1)) / (self.d_k ** 0.5) if mask is not None: scores = scores.masked_fill(mask == 0, -1e9) attn = torch.softmax(scores, dim=-1) context = torch.matmul(attn, V) # 拼接多头并做输出投影 context = context.transpose(1, 2).contiguous().view(batch_size, -1, self.d_model) return self.W_o(context)

这段代码里最关键的参数是num_heads。头数太少,模型只能学到单一维度的关联;头数太多,每个头的维度 d_k 太小,表达能力反而下降。常见的做法是 d_model=512、num_heads=8,每个头 64 维。mask 参数在编码器里用来屏蔽 padding 位置,在解码器里还要额外屏蔽未来位置,防止生成时“偷看”后面的词。

2.3 位置编码:没有它,注意力就是词袋

自注意力本身对词序不敏感——把“猫追狗”和“狗追猫”打乱输入,算出来的注意力权重完全一样。这显然不行。Transformer 用位置编码把顺序信息注入进去:

PE(pos, 2i) = sin(pos / 10000^(2i/d_model)) PE(pos, 2i+1) = cos(pos / 10000^(2i/d_model))

用正弦余弦而不是可学习的位置嵌入,好处是能外推到训练时没见过的序列长度。每个位置的位置编码和词嵌入直接相加,这样每个词的表示里既包含语义信息,也包含它在序列中的位置信息。编码器通常堆 6 层,每层包含一个多头自注意力子层和一个前馈网络子层,两个子层都带残差连接和层归一化。残差连接保证梯度能直接回传,层归一化稳定训练过程。堆叠多层的作用是逐层抽象:底层关注局部搭配,高层关注段落级语义。

3. 解码器与训练目标:摘要句子是怎么一个词一个词“长”出来的

3.1 掩码自注意力与交叉注意力

解码器的结构和编码器类似,但多了两个关键设计。第一是掩码自注意力:生成第 t 个词时,只能看到前 t-1 个词,不能看到后面的。实现上就是在注意力分数矩阵的上三角区域填负无穷,softmax 之后这些位置的权重变成 0。第二是交叉注意力:Query 来自解码器当前状态,Key 和 Value 来自编码器输出。这一步让解码器在生成每个词时都能“回看”原文的不同部分,决定当前该关注原文的哪句话。

class DecoderLayer(nn.Module): def __init__(self, d_model, num_heads, d_ff, dropout=0.1): super().__init__() self.self_attn = MultiHeadAttention(d_model, num_heads) self.cross_attn = MultiHeadAttention(d_model, num_heads) self.ffn = nn.Sequential( nn.Linear(d_model, d_ff), nn.ReLU(), nn.Linear(d_ff, d_model) ) self.norm1 = nn.LayerNorm(d_model) self.norm2 = nn.LayerNorm(d_model) self.norm3 = nn.LayerNorm(d_model) self.dropout = nn.Dropout(dropout) def forward(self, x, enc_output, src_mask, tgt_mask): # 掩码自注意力:只看已生成的词 attn_out = self.self_attn(x, x, x, tgt_mask) x = self.norm1(x + self.dropout(attn_out)) # 交叉注意力:Query 来自解码器,Key/Value 来自编码器 cross_out = self.cross_attn(x, enc_output, enc_output, src_mask) x = self.norm2(x + self.dropout(cross_out)) # 前馈网络 ffn_out = self.ffn(x) x = self.norm3(x + self.dropout(ffn_out)) return x

注意tgt_mask和src_mask的区别:前者是下三角矩阵,屏蔽未来位置;后者屏蔽原文的 padding。交叉注意力里传入的是src_mask,因为原文所有有效位置都应该被看到。这个结构决定了摘要生成的一个核心特性——每生成一个词,模型都会重新扫一遍原文,而不是像 RNN 那样只靠一个固定长度的上下文向量。

3.2 教师强制与损失函数

训练时用“教师强制”策略:把参考答案的前 t-1 个词喂给解码器,让它预测第 t 个词。损失函数是交叉熵,逐词计算预测分布和真实词之间的差异:

def train_step(model, src, tgt, optimizer, criterion): # tgt 形如 <sos> w1 w2 ... wn <eos> tgt_input = tgt[:, :-1] # 输入: <sos> w1 ... wn-1 tgt_output = tgt[:, 1:] # 目标: w1 w2 ... <eos> # 构造掩码 src_mask = (src != PAD_IDX).unsqueeze(1).unsqueeze(2) tgt_mask = torch.tril(torch.ones(tgt_input.size(1), tgt_input.size(1))).bool() logits = model(src, tgt_input, src_mask, tgt_mask) # 展平后计算交叉熵,忽略 padding 位置 loss = criterion(logits.reshape(-1, logits.size(-1)), tgt_output.reshape(-1)) optimizer.zero_grad() loss.backward() optimizer.step() return loss.item()

这里有个容易翻车的点:criterion必须设置ignore_index=PAD_IDX,否则 padding 位置也会参与损失计算,模型会学会“预测 padding”,浪费容量。另外教师强制会导致训练和推理不一致——推理时没有参考答案,模型只能吃自己上一步的输出,一旦前面生成错了,错误会累积。这个现象叫 exposure bias,是生成式摘要的经典问题,常见缓解手段是计划采样或强化学习微调,但本科阶段先把基础流程跑通更重要。

3.3 推理策略:贪心、束搜索与长度惩罚

推理时从<sos>开始,每步选一个词,直到生成<eos>或达到最大长度。最简单的贪心策略每步选概率最大的词,但容易生成重复、平淡的句子。束搜索保留 top-k 条候选路径,最后选整体概率最高的:

def beam_search(model, src, src_mask, beam_size=4, max_len=50): # 初始只有 <sos> beams = [([SOS_IDX], 0.0)] for _ in range(max_len): candidates = [] for seq, score in beams: if seq[-1] == EOS_IDX: candidates.append((seq, score)) continue tgt_tensor = torch.tensor(seq).unsqueeze(0) tgt_mask = torch.tril(torch.ones(len(seq), len(seq))).bool() logits = model(src, tgt_tensor, src_mask, tgt_mask) log_probs = torch.log_softmax(logits[:, -1, :], dim=-1) topk_probs, topk_ids = log_probs.topk(beam_size) for i in range(beam_size): candidates.append((seq + [topk_ids[0, i].item()], score + topk_probs[0, i].item())) # 按累计对数概率排序,保留 top-k beams = sorted(candidates, key=lambda x: x[1], reverse=True)[:beam_size] if all(s[-1] == EOS_IDX for s, _ in beams): break return beams[0][0]

束搜索的 beam_size 是个权衡参数:太小退化成贪心,太大显存和计算量成倍增长。实际项目里还会加长度惩罚,因为对数概率是负数,越长的序列累计值越小,模型倾向于生成短句。长度惩罚通常用score / length^alpha来归一化,alpha 取 0.6 到 1.0 之间。这些细节在毕业设计的实验对比里往往是拉开差距的地方。

4. 数据与评估:为什么你的摘要模型指标好看但读起来不行

4.1 数据集构造与预处理

文本摘要常用的公开数据集格式是“原文-摘要”成对出现。预处理阶段要做几件事:分词、截断、构造词表、添加特殊标记。原文通常很长,需要截断到模型能接受的最大长度(比如 512 个 token),但截断策略有讲究——直接截尾可能丢掉关键结论,按段落采样或取首尾拼接更合理。

def build_dataset(pairs, tokenizer, max_src_len=512, max_tgt_len=128): data = [] for src_text, tgt_text in pairs: src_ids = tokenizer.encode(src_text)[:max_src_len] tgt_ids = tokenizer.encode(tgt_text)[:max_tgt_len] # 目标序列首尾加特殊标记 tgt_ids = [SOS_IDX] + tgt_ids + [EOS_IDX] data.append((src_ids, tgt_ids)) return data

词表大小直接影响模型参数量。词表太小,很多词被切成子词,序列变长;词表太大,嵌入矩阵占显存。常见做法是用 BPE 或 WordPiece 做子词切分,词表控制在 3 万到 5 万之间。另外要注意,原文和摘要必须用同一个分词器,否则解码器输出的词和词表对不上。

4.2 ROUGE 指标的局限

ROUGE 是摘要任务最常用的自动评估指标,核心思想是计算生成摘要和参考答案之间的 n-gram 重叠。ROUGE-1 看单词重叠,ROUGE-2 看二元组重叠,ROUGE-L 看最长公共子序列。公式看起来客观,但问题很明显:它只认字面重叠,不认语义等价。模型把“经济增长迅速”写成“经济快速发展”,ROUGE 分数会掉,但人读起来意思一样。

更麻烦的是,ROUGE 高的摘要不一定事实一致。模型可能从原文不同位置拼凑出通顺但错误的句子,ROUGE 照样给高分。所以毕业设计里如果只报 ROUGE,答辩时很容易被问住。常见的补充做法是人工抽检几十条样本,按“流畅度、信息量、事实一致性”三个维度打分,哪怕样本少,也能说明你意识到了指标的局限。

4.3 事实一致性问题的根源

生成式摘要的事实错误大致分三类:实体替换(人名、机构名搞混)、数量错误(数字、日期写错)、关系颠倒(主客体互换)。根源在于模型学的是“给定原文,什么摘要概率最高”,而不是“给定原文,什么摘要事实正确”。训练数据里的参考答案本身就可能包含噪声,模型把噪声也学进去了。

缓解思路有几个方向:一是复制机制,让模型在生成实体时倾向于直接从原文复制而不是从词表采样;二是引入实体识别或关系抽取的辅助损失;三是在解码时对实体词做约束。这些在本科阶段不一定要全部实现,但在论文的“不足与改进”部分写清楚,能体现你对问题本质的理解。

5. 避坑与排查:训练摘要模型时最容易翻车的几个地方

5.1 损失降到很低但生成全是重复词

现象:训练集 loss 稳定下降,但推理时模型反复输出同一个词或同一句话。

原因:解码器掩码写错,导致训练时模型能“偷看”未来词,学到的条件分布和推理时不一致。或者束搜索没有加长度惩罚,模型陷入“重复短句累计概率高”的局部最优。

解决:检查tgt_mask是否是严格下三角矩阵,确保第 t 个位置只能看到前 t-1 个位置。推理时加长度惩罚和重复惩罚(对已生成的词降低其再次出现的概率)。

5.2 显存溢出,batch size 调到 1 还是 OOM

现象:训练时 CUDA out of memory,减小 batch 也没用。

原因:自注意力的计算复杂度是 O(n²),序列长度翻倍,显存需求翻四倍。原文截断长度设成 1024 时,注意力矩阵本身就占大量显存。

解决:把原文最大长度降到 512 或 256,或者用梯度累积模拟大 batch。如果还不行,考虑用稀疏注意力或分块注意力的变体,但本科阶段优先降长度。

5.3 ROUGE 分数异常高,接近 1.0

现象:评估时 ROUGE-1 超过 0.9,看起来好得不真实。

原因:评估脚本里把参考答案直接当成了生成结果,或者分词时没有去掉特殊标记,导致生成摘要和参考答案共享了大量<sos>、<eos>、<pad>标记。

解决:检查评估流程,确保生成摘要来自模型推理输出,分词时过滤掉所有特殊标记,只对有效词计算重叠。

5.4 训练 loss 震荡不收敛

现象:loss 时高时低,几个 epoch 后仍然没有下降趋势。

原因:学习率太大,或者没有用 warmup。Transformer 对学习率敏感,直接上 1e-3 容易震荡。

解决:用 Adam 优化器,学习率设 1e-4 到 5e-4,加 warmup 步数(比如前 4000 步线性升温)。另外检查层归一化的位置,Post-LN 比 Pre-LN 更难训练,新手建议用 Pre-LN。

5.5 生成的摘要和原文事实矛盾

现象:摘要读起来通顺,但把原文的“增长 3%”写成“下降 3%”,或者把主语和宾语搞反。

原因:模型过度依赖语言模型的先验,生成时没有充分关注原文对应位置。交叉注意力的权重可能集中在无关词上。

解决:可视化交叉注意力权重,看生成实体词时模型关注原文哪里。如果注意力分散,考虑在训练时加入实体一致性损失,或者在解码时对数字、实体词做强制复制约束。

6. 从能跑到好用:摘要系统的设计取舍与验证思路

把模型跑通只是起点,真正决定一个摘要系统好不好用的,是你在几个关键维度上做的取舍。第一个取舍是抽取式还是生成式。抽取式摘要直接从原文选句子,事实一致性好,但句子之间的衔接生硬,指代关系可能断裂;生成式摘要灵活流畅,但事实一致性风险高。常见的工程做法是混合式:先用抽取式选出关键句,再用生成式对这些句子做压缩改写。本科毕业设计如果只做生成式,至少在论文里讨论一下这个取舍。

第二个取舍是模型规模和推理速度。Transformer 的参数量随层数和 d_model 增长,6 层 512 维的模型在单卡上还能训,12 层 768 维就需要多卡或梯度累积。如果目标是部署到实际场景,推理延迟比 ROUGE 分数更重要。这时候可以考虑知识蒸馏,用大模型教小模型,或者用层数更少的轻量结构。我一般会这么理解:毕业设计的模型不需要最大,但需要你能说清楚为什么选这个规模。

第三个取舍是自动指标和人工评估的配比。ROUGE 适合快速迭代,但不能作为唯一标准。一个可操作的验证流程是:每次实验跑完 ROUGE 后,固定抽 50 条样本做人工打分,记录流畅度、信息量、事实一致性三个维度的均分。如果 ROUGE 涨了但人工分没涨,说明模型在拟合参考答案的表面模式,而不是真正学会了摘要。这个习惯一旦养成,后面做任何生成任务都不会被指标带偏。

还有一个容易被忽略的点是解码策略的验证。贪心、束搜索、采样各有适用场景。束搜索适合追求 ROUGE 的场景,采样适合需要多样性的场景。验证方法是固定同一个模型,分别用不同解码策略生成摘要,对比 ROUGE 和人工分。很多时候你会发现,束搜索的 ROUGE 最高,但人工读起来最死板;采样的 ROUGE 低一些,但摘要更自然。这个对比本身就能成为毕业设计里一个有价值的实验章节。

从那以后我每次做生成任务,都会在跑完自动指标后强制抽一批样本自己读一遍,读不通顺或者事实对不上的,一律回去查注意力权重和数据预处理。这个习惯帮我避开了很多“指标好看但没法用”的坑。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询