
简介这份本科毕业设计资料围绕深度学习文本摘要自动生成展开面向自然语言处理方向的学生与入门研究者借助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, n2): # 用句子与参考摘要的 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%模型学成一个“全判负”的懒惰解也能拿到很低的损失。n2的 bigram 是最常用的一档n1太松功能词全能命中n3太严改写过的句子几乎匹配不上。抽取式的上限也很清楚它永远写不出原文没有的句子所以摘要不可能比原文更凝练只能做到“删减”。好处同样明显——每个输出字都能溯源到原文某一句事实一致性天然有保障这在需要交付可信结果的场景里非常值钱。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_model512, n_head8): super().__init__() assert d_model % n_head 0 self.d_k d_model // n_head # 每个头的维度512/864 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, maskNone): 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×LL翻倍显存和计算翻四倍。L512时单层单头的分数矩阵是 26 万个数还能接受L4096就变成 1677 万乘以头数和层数直接爆显存。所以长文档摘要一定要做取舍策略做法代价硬截断只取前 N 个 token尾部信息全丢最常见也最伤滑窗分段编码窗口间重叠段间关系丢失需二次融合稀疏注意力每个位置只关注固定窗口加若干全局位置实现复杂超参敏感层次化编码先编码句子再在句子向量上做一层注意力句子切分质量直接影响上限本科毕设里最常见的是硬截断因为它一行代码就能跑通。但第 5 章会讲到这一行代码往往是“ROUGE 怎么都上不去”的根源。4. 解码阶段的失控重复、抄原文、提前停是怎么发生的训练损失降下去只说明模型学会了在给定正确前缀时猜对下一个词。真正决定输出质量的是推理阶段这一小段循环。4.1 教师强制与暴露偏差训练时解码器的输入是参考摘要的真实前缀这叫教师强制。它让训练可以并行、收敛快但制造了一个训练和推理之间的裂缝推理时模型拿到的是自己上一步的输出。一旦某一步选错错误就会沿着时间步累积后面的上下文全部偏离训练分布。这就是暴露偏差。缓解手段有几类计划采样按一定概率把模型自己的输出喂回去让它在训练中就见到自己的错误标签平滑别让模型对单个词过度自信以及束搜索用多路径搜索降低单步走错的概率。4.2 束搜索与长度惩罚贪心解码每步只取概率最高的词容易走进死胡同。束搜索同时保留 k 条候选路径每步扩展后再剪枝。def beam_search(model, src, beam4, max_len64, len_alpha1.0, eos_id2): # 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(keylambda x: x[0] / (len(x[1]) ** len_alpha), reverseTrue) 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, keylambda x: x[0] / (len(x[1]) ** len_alpha))[1]beam控制搜索宽度len_alpha控制长度惩罚强度max_len是硬上限。这三个参数里len_alpha最容易被忽略又最影响观感对数概率全是负数序列越长累加值越小不做归一化的话模型会系统性偏好极短的输出摘要变成一句话。len_alpha1.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 的数字——因为指标只会告诉你“像不像参考摘要”不会告诉你“这句话是不是真的”。希望帮到你。本文还有配套的精品资源点击获取