简介:这份本科毕业设计资料围绕深度学习文本摘要自动生成展开,面向自然语言处理方向的学生与入门研究者,借助Transformer的编码器—解码器结构和自注意力机制,解决长文本关键信息提炼与短摘要生成问题。压缩包共34个文件、约360KB,以18个Python训练推理脚本和5个Shell运行脚本为主,并配有YAML/Docker配置、词表、说明文档与测试样例,覆盖数据预处理、模型构建、训练评估到Web演示的完整链路。已有3834人学习下载,说明其作为课程设计与毕业设计参考具有较高关注度。读者可据此实践分词、词汇表构建、序列化输入、损失函数与优化器配置,并用ROUGE、BLEU评估摘要质量。也可参考脚本组织方式在TensorFlow或PyTorch中复现实验,理解编码器捕捉原文关键信息、解码器生成摘要的流程,并借助测试与容器配置排查运行问题,适合作为毕业设计baseline和工程练习样本。
1. 文本摘要的错觉:模型不是在压缩文本,而是在赌下一个词
做文本摘要的深度学习模型,最反直觉的一点是:它从来没有“先读懂全文、再动笔缩写”。自然语言处理里的生成式摘要,本质上把“给一段原文、输出一段短文本”建模成了一个逐词的条件概率问题——每写一个字,都在给定原文和已写内容的前提下,重新赌一次下一个词最可能是什么。Transformer 之所以成为这个任务的主流骨架,不是因为它更懂语义,而是因为它让原文里任意两个位置能直接互相对看一眼,并且这种对齐可以并行、可以堆叠几十层。明白这一点,后面那些“重复”“跑题”“大段抄原文”的毛病才有解释的入口:它们大多不是模型笨,而是这个概率游戏在特定条件下的必然产物。
2. 抽取式与生成式:同一份数据在两种范式里被建模成了什么
摘要任务在文献里通常被切成两条路。一条叫抽取式,从原文里挑句子拼成摘要;一条叫生成式,让模型自己写新句子。两者用的可能是同一批语料,但被建模成了完全不同的数学对象,这直接决定了后面所有的工程细节和翻车方式。
2.1 抽取式:把摘要降级成句子级的二分类排序
抽取式的核心假设很朴素:原文里已经存在能表达主旨的句子,任务只是给每个句子打分。于是“写摘要”变成“给 n 个句子各自输出一个 0 到 1 之间的分数,取 top-k”。这个降级非常划算——句子是天然的处理单元,长度可控,标签也能自动构造:把参考摘要和原文每个句子算一次重叠度,超过阈值就标 1。
import re def sent_split(text): # 只按句末标点切,避免把小数点和英文缩写切碎 return [s.strip() for s in re.split(r'(?<=[。!?!?;;])', text) if s.strip()] def overlap_score(sent, ref_grams, n=2): # 用句子与参考摘要的 n-gram 命中率,近似“这句话该不该被抽出来” toks = list(sent) grams = [''.join(toks[i:i + n]) for i in range(len(toks) - n + 1)] if not grams: return 0.0 hit = sum(1 for g in grams if g in ref_grams) return hit / len(grams) # 精度口径,不是召回这段代码有两个地方值得盯住。一是overlap_score用的是精度而不是召回:短句子只要命中寥寥几个 bigram,分母就很小,分数被抬得很高,于是标注出来的正样本会系统性偏向短句。工程上的补救是给分数乘一个长度惩罚项,或者直接改成 F1 口径。二是阈值的选择直接决定正负样本比例,阈值调高会让正样本稀到不足 5%,模型学成一个“全判负”的懒惰解也能拿到很低的损失。n=2的 bigram 是最常用的一档,n=1太松,功能词全能命中;n=3太严,改写过的句子几乎匹配不上。
抽取式的上限也很清楚:它永远写不出原文没有的句子,所以摘要不可能比原文更凝练,只能做到“删减”。好处同样明显——每个输出字都能溯源到原文某一句,事实一致性天然有保障,这在需要交付可信结果的场景里非常值钱。
2.2 生成式:把摘要写成条件概率的连乘
生成式走的是另一条路,用编码器-解码器结构直接建模P(y|x),其中 x 是原文,y 是参考摘要。整个序列的概率被拆成逐词的连乘:
P(y|x) = Π_t P(y_t | y_<t, x)训练时用交叉熵,把每一步的负对数概率加起来求平均;解码时用贪心或者束搜索,一步步把词选出来。这个建模方式的表达能力强得多,能压缩、能改写、能合并两句信息,但代价是它没有“这句话来自原文哪里”的硬约束。
最常见的失败模式是专名和数字被悄悄换掉。原文里的某个产品型号在词表里不存在,被映射成<unk>,解码时模型只能根据上下文猜一个高频词填进去,出来的句子读着通顺、事实全错。这类错误在 token 级交叉熵上看不出来——被替换的那个词本身就概率不高,损失贡献很小,但它毁掉的是整条摘要的可信度。后面第 4 章讲的复制机制,就是为了堵这个洞。
2.3 ROUGE 的机制,以及它为什么和人眼判断对不上
评测环节是很多人第一次感到挫败的地方。ROUGE 系列指标本质上是 n-gram 或最长公共子序列的匹配度:ROUGE-N 统计模型输出和参考摘要共享了多少个 n-gram,通常按召回算;ROUGE-L 找两者的最长公共子序列,衡量句子级顺序的相似性。
| 指标 | 实际衡量的东西 | 容易被什么骗 |
|---|---|---|
| ROUGE-1 | 单词层面的覆盖度 | 堆砌原文高频词 |
| ROUGE-2 | 相邻词搭配的复现 | 直接抄原句,改写能力越强分越低 |
| ROUGE-L | 词序结构的一致性 | 长句套模板,结构像但信息空 |
看懂这张表就能理解一个常见困惑:为什么有些模型 ROUGE 涨了,人读着反而更差。因为 n-gram 匹配天然奖励“逐字复述”,惩罚“合理的同义改写”。摘要越抽象、改写幅度越大,ROUGE 掉得越厉害。反过来,一个把原文第一句原封不动抄出来的模型,ROUGE-2 往往不低。所以这个指标只能当相对比较的参照,不能当质量结论。
提示:报告结果时必须写清楚分词口径(按字还是按词)、是否去停用词、参考摘要取几条。这三项任何一项不同,分数就能差出好几个点。
3. Transformer 编码器:自注意力凭什么把一篇长文压成向量
把原文送进编码器,出来是一堆同长度的向量。这一步到底发生了什么,决定了模型能看见多远的依赖、能承受多长的输入。
3.1 QKV 三个矩阵到底在算什么
自注意力的机制可以压成一句话:每个位置都提出一个“我想找什么”的查询,和所有位置给出的“我有什么”的索引做匹配,再按匹配度把“我的内容”加权求和。Q 是查询,K 是索引,V 是内容。
import math import torch import torch.nn as nn class MultiHeadSelfAttention(nn.Module): def __init__(self, d_model=512, n_head=8): super().__init__() assert d_model % n_head == 0 self.d_k = d_model // n_head # 每个头的维度,512/8=64 self.n_head = n_head 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, x, mask=None): B, L, _ = x.shape # 一次线性变换后切多头:[B, L, d_model] -> [B, h, L, d_k] q = self.w_q(x).view(B, L, self.n_head, self.d_k).transpose(1, 2) k = self.w_k(x).view(B, L, self.n_head, self.d_k).transpose(1, 2) v = self.w_v(x).view(B, L, self.n_head, self.d_k).transpose(1, 2) # 缩放点积:除以 sqrt(d_k) 压住点积方差 score = torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(self.d_k) if mask is not None: # mask 中 True 表示“不许看”,填极大负数,softmax 后约等于 0 score = score.masked_fill(mask, -1e9) attn = torch.softmax(score, dim=-1) # [B, h, L, L] out = torch.matmul(attn, v) # [B, h, L, d_k] out = out.transpose(1, 2).contiguous().view(B, L, -1) return self.w_o(out), attn几个参数值得单独说清楚。d_k是每个头的维度,缩放因子sqrt(d_k)不是拍脑袋定的:q 和 k 的每个分量近似独立、均值 0 方差 1,点积的方差就随维度线性增长,维度一大点积的绝对值就大,softmax 会被推到接近 one-hot 的饱和区,梯度几乎归零。除以sqrt(d_k)相当于把方差拉回 1,让注意力分布保持柔软。
mask的语义是最容易写反的地方。填充掩码要把 pad 位置屏蔽掉,因果掩码要把当前位置之后的 token 屏蔽掉,文档级任务里还可能需要屏蔽掉不该跨段关注的句子。三种掩码形状不同,用-1e9而不是-inf是为了避免整行都是负无穷时 softmax 出 NaN。
返回的attn形状是[B, h, L, L],这个矩阵是后面很多调试手段的抓手:想看模型有没有把注意力塌缩到某一个位置、有没有关注到不该关注的 pad,直接可视化它最快。
3.2 多头与位置编码:为什么顺序必须显式注入
自注意力有一个容易被忽略的性质:它对输入顺序是置换等变的。把原文的句子打乱重排,输出只是跟着重排,内容不变。这对摘要来说是致命的——“因为……所以……”和“所以……因为……”在模型眼里一模一样。
所以位置信息必须显式加进去。常见做法是用不同频率的正弦余弦函数生成一个位置向量,直接加到词向量上。这个设计有个漂亮的性质:任意两个位置的编码之间存在一个只与相对距离有关的线性变换,模型因此能从绝对位置编码里学到相对位置的概念。另一类做法是可学习的位置嵌入,实现更简单,但超过训练时见过的最大长度就彻底失效。
多头的意义在于给模型多个独立的子空间。每个头用自己的 QKV 投影,在同一层里并行地做多次“对齐”。实际观察下来的经验是,浅层的头偏向关注相邻词和固定搭配,深层的头会出现明显的指代和长距离依赖模式——有的头专门盯着主语,有的头在找数字和专名。这种分工是训练自发形成的,不是设计出来的。
3.3 残差、层归一化,以及长文档的复杂度天花板
一个编码器层除了注意力,还有前馈网络、残差连接和层归一化。残差保证梯度能跨层直通,层归一化把每个 token 的激活拉回稳定分布。这两者的摆放顺序有讲究:放在子层之前的 Pre-LN 结构训练更稳,深层堆叠不容易发散,代价是表达能力的理论优势略弱;放在之后的 Post-LN 是原始设计,收敛后效果可能更好,但需要精心的学习率预热。
真正的硬约束在复杂度上。自注意力的分数矩阵是L×L,L翻倍,显存和计算翻四倍。L=512时单层单头的分数矩阵是 26 万个数,还能接受;L=4096就变成 1677 万,乘以头数和层数直接爆显存。所以长文档摘要一定要做取舍:
| 策略 | 做法 | 代价 |
|---|---|---|
| 硬截断 | 只取前 N 个 token | 尾部信息全丢,最常见也最伤 |
| 滑窗 | 分段编码,窗口间重叠 | 段间关系丢失,需二次融合 |
| 稀疏注意力 | 每个位置只关注固定窗口加若干全局位置 | 实现复杂,超参敏感 |
| 层次化编码 | 先编码句子,再在句子向量上做一层注意力 | 句子切分质量直接影响上限 |
本科毕设里最常见的是硬截断,因为它一行代码就能跑通。但第 5 章会讲到,这一行代码往往是“ROUGE 怎么都上不去”的根源。
4. 解码阶段的失控:重复、抄原文、提前停是怎么发生的
训练损失降下去只说明模型学会了在给定正确前缀时猜对下一个词。真正决定输出质量的,是推理阶段这一小段循环。
4.1 教师强制与暴露偏差
训练时解码器的输入是参考摘要的真实前缀,这叫教师强制。它让训练可以并行、收敛快,但制造了一个训练和推理之间的裂缝:推理时模型拿到的是自己上一步的输出。一旦某一步选错,错误就会沿着时间步累积,后面的上下文全部偏离训练分布。
这就是暴露偏差。缓解手段有几类:计划采样,按一定概率把模型自己的输出喂回去,让它在训练中就见到自己的错误;标签平滑,别让模型对单个词过度自信;以及束搜索,用多路径搜索降低单步走错的概率。
4.2 束搜索与长度惩罚
贪心解码每步只取概率最高的词,容易走进死胡同。束搜索同时保留 k 条候选路径,每步扩展后再剪枝。
def beam_search(model, src, beam=4, max_len=64, len_alpha=1.0, eos_id=2): # beams 里存 (累计对数概率, 已生成 token 列表) beams = [(0.0, [1])] # 1 = 起始符 finished = [] for _ in range(max_len): cand = [] for score, seq in beams: logp = model.decode_step(src, seq) # 词表上的 log 概率 topk_logp, topk_idx = logp.topk(beam) for lp, idx in zip(topk_logp.tolist(), topk_idx.tolist()): cand.append((score + lp, seq + [idx])) # 长度惩罚:否则短句因为负对数概率累加得少而霸榜 cand.sort(key=lambda x: x[0] / (len(x[1]) ** len_alpha), reverse=True) beams = [] for score, seq in cand: if seq[-1] == eos_id: finished.append((score, seq)) elif len(beams) < beam: beams.append((score, seq)) if not beams: break pool = finished if finished else beams return max(pool, key=lambda x: x[0] / (len(x[1]) ** len_alpha))[1]beam控制搜索宽度,len_alpha控制长度惩罚强度,max_len是硬上限。这三个参数里,len_alpha最容易被忽略又最影响观感:对数概率全是负数,序列越长累加值越小,不做归一化的话模型会系统性偏好极短的输出,摘要变成一句话。len_alpha=1.0是按长度线性归一,0.6到0.8之间通常更接近人想要的长度分布。
beam也不是越大越好。实践中 ROUGE 随 beam 先升后降,beam 开到 10 以上时模型倾向于产出“安全但空洞”的通用句——因为在高概率路径上,模板句永远比有信息量的句子得分高。
4.3 重复的三种成因与对策
生成式摘要最尴尬的输出是复读:“公司公司公司……”或者整句循环。成因至少有三层。
第一层是数据。如果训练语料里的参考摘要本身就有重复模式,模型会照学。第二层是解码的自强化:某个词一旦被选中,它作为上下文会让下一个位置再选它的概率变高,形成正反馈。第三层是注意力塌缩,解码器在多个时间步反复关注编码器的同一个位置,导致反复输出同一段内容。
对应的对策也是三档。最直接的是 n-gram 阻断,解码时禁止已经出现过的 bigram 再次出现;稍微温和的是重复惩罚,对已生成 token 的 logit 除以一个大于 1 的系数;最治本的是覆盖机制,把历史所有时间步的注意力权重累加成一个覆盖向量,作为下一步解码的额外输入,并对“重复关注同一位置”施加惩罚项加进损失。
4.4 复制机制:让模型学会从原文里“抄”
前面提到生成式模型会把专名换成高频词。指针生成网络给出的解法是引入一个软开关:
p_gen = sigmoid(W · [h_t, c_t, x_t]) P(w) = p_gen · P_vocab(w) + (1 - p_gen) · Σ_{i: w_i = w} a_t[i]h_t是解码器当前隐状态,c_t是上下文向量,x_t是当前输入。p_gen表示“这一步该自己生成还是该从原文抄”,最终词表分布是生成分布和注意力分布的加权和。
这个设计的巧妙之处在于,它把 OOV 问题转化成了一个概率分配问题:词表里没有的词,只要原文里出现过,就能通过注意力权重被“抄”出来,概率不为零。副作用是模型会变得偏爱抄写,抽象性下降,长句照搬原文的比例上升。所以复制机制通常要和覆盖损失一起用,一个负责“别丢信息”,一个负责“别重复”。
5. 避坑与排查:loss 一路下降但 ROUGE 不动的五个现场
以下五条是这类项目里出现频率最高的现场,按“现象、原因、解决”拆开写。
5.1 损失平稳下降,验证集 ROUGE 第二个 epoch 后开始跌
现象:训练集交叉熵从 6.x 降到 1.x,验证损失也在降,但验证 ROUGE 在第 2 到第 3 个 epoch 见顶后回落,生成结果越来越像模板。
原因:token 级交叉熵和序列级指标不同源。模型很快学会了高频模板句(“会议于昨日召开”“相关负责人表示”),这些句子在语料里反复出现,概率高、损失低,但信息量接近于零。验证损失降是因为它也在拟合这种分布,而 ROUGE 会因为摘要里缺少实词而掉。
解决:按 ROUGE 早停,别按 loss 早停;加 0.1 的标签平滑抑制过度自信;监控生成结果的 distinct-2(不同 bigram 占比),这个数掉下去就是模板化的信号。
5.2 输出整段复读,或者卡在同一个短语上出不来
现象:生成的摘要出现连续的相同词,或者同一句话循环到max_len才停。
原因:多数情况是解码阶段没有任何重复约束;少数情况是掩码写反了——把 pad 位置当成可见,位置编码全乱,模型只能在局部上下文里打转。
解决:先加 n-gram 阻断和重复惩罚,这两行改动成本最低;再打印attn矩阵看是否塌缩到单一列;最后检查 mask 的布尔语义,True到底是“可见”还是“屏蔽”,这一点不同实现约定相反,照抄代码必翻车。
5.3 所有摘要都在固定长度附近戛然而止
现象:不管原文多长,生成的摘要长度都集中在某个值附近,长文档的后半部分信息完全不出现在结果里。
原因:编码器的最大位置数有限,训练时又用了“永远取前 N 个 token”的截断策略,模型压根没见过“文档尾部有信息”的样本,自然学不会去那儿找。
解决:训练时改成随机采样窗口,让窗口覆盖文档的不同区段;推理时用滑窗分段生成再做融合;如果算力允许,换成层次化编码,先按句子编码再在句子级做注意力,把长度瓶颈从 token 级挪到句子级。
5.4 ROUGE 复现不出来,和参考实现差好几个点
现象:同一份数据、同一个模型结构,跑出来的分数比预期低 3 到 5 个点。
原因:评测口径的差异远大于模型差异。分词是按字还是按词、是否去停用词、参考摘要取第一条还是全部拼接、全半角和大小写有没有归一化,任何一项不同都能造成这个量级的偏差。
解决:把预处理脚本当成评测的一部分做版本管理,和模型代码一起提交;做对比实验时一次只改一个变量;报告结果时把口径写在表格标题里。
5.5 中文按字切和按词切,模型排名会翻转
现象:两个模型,按字算 ROUGE 是 A 高,按词算是 B 高。
原因:ROUGE-N 的 n-gram 粒度决定了匹配难度。按字切时二元组是“两个相邻汉字”,匹配门槛低,分数普遍虚高;按词切时二元组是“两个相邻词”,匹配门槛高,改写幅度大的模型吃亏更明显。
解决:同时报告 char-level 和 word-level 两组数字,并且训练和评测用同一个分词器。只报一组而在文中不写口径,等于没报。
注意:显存告急时第一反应往往是砍 batch size,但注意力层的显存占用随序列长度平方增长,把
max_len从 1024 降到 512 省下的显存通常比砍一半 batch 更多。
6. 取舍与验证:抽取、生成还是两段式,怎么判断它真的 work
三条路线摆在一起,取舍其实很清楚。
| 方案 | 事实一致性 | 语言流畅度 | ROUGE 上限 | 工程成本 |
|---|---|---|---|---|
| 纯抽取式 | 高,可溯源 | 中,句子拼接生硬 | 中 | 低,单卡可跑 |
| 纯生成式 | 低,易改数字和专名 | 高 | 高 | 中高,需要束搜索和重复控制 |
| 抽取加改写 | 较高,幻觉被限制在被选中的句子里 | 高 | 较高 | 高,两个模型要分别调 |
纯抽取式最容易交付,也最容易解释,但读起来像摘录而不是摘要。纯生成式观感最好,风险全在事实性上。两段式的思路是先用抽取模型把候选句压缩到一个很短的集合,再让生成模型在这个集合内部做改写,等于给幻觉加了一道围栏——模型只能改写被选中的句子,不能凭空生成原文没有的事实。
验证环节我建议不要只盯 ROUGE。一个成本很低的补充检查是实体一致性:把摘要里出现的数字、专名、时间抽出来,逐个回原文里查有没有出现过。
import re def entity_consistency(summary, source): # 抽出数字和连续英文/型号片段,检查是否都能在原文中找到 ents = re.findall(r'\d+(?:\.\d+)?%?|[A-Za-z]{2,}', summary) missing = [e for e in ents if e not in source] return 1 - len(missing) / max(len(ents), 1), missing这个函数返回一致率和缺失实体列表。一致率低于 0.95 的时候,基本可以确定模型在编数字,这时候 ROUGE 再高也不能用。参数上,正则里的[A-Za-z]{2,}可以按语种换成对应的专名模式,max(len(ents), 1)是为了防止摘要里一个实体都没有时除零。
再往上一步是抽样人工评估。抽 50 条,只看三件事:有没有事实错误、有没有信息遗漏、读起来是否通顺。这三项各打 1 到 5 分,比任何自动指标都更能说明问题,而且 50 条半小时就能看完。
从那以后,我每次跑完一轮训练,都强制先抽 50 条样本逐条读一遍,再去看 ROUGE 的数字——因为指标只会告诉你“像不像参考摘要”,不会告诉你“这句话是不是真的”。希望帮到你。
本文还有配套的精品资源,点击获取