ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于Transformer的文本摘要生成系统:从原理到实践

基于Transformer的文本摘要生成系统:从原理到实践

简介:面向本科毕业设计场景的深度学习文本摘要自动生成项目,以Transformer模型为核心,覆盖数据预处理、编码器与解码器建模、训练和评估全流程。资源包共34个文件,压缩后大小约360KB,主要包含18个Python脚本用于模型结构、数据批次处理、集束搜索解码、训练与测试,5个Shell脚本用于训练、评估及持续集成测试,并附带若干配置文件、依赖列表、容器部署说明和项目文档,目录划分清晰,便于快速定位和复用。项目对文本摘要任务中的分词、词表构建、序列化输入、ROUGE与BLEU指标计算等关键环节均有对应实现,同时涉及深度学习框架的工程化实践,能帮助读者完整掌握基于注意力机制的自回归文本生成方法。目前已有3718人学习下载,适合正在准备毕业设计或入门自然语言处理文本生成方向的学生参考使用。

1. 项目速览:一个能跑通的 NLP 毕设,解决"长文本读不完"的问题

有段时间我帮一个做毕设的同学调这套文本摘要项目,他每天要精读十来篇论文,每篇两万字起步,光靠肉眼扫摘要根本分不清哪篇值得细看。等把基于深度学习的文本摘要自动生成系统跑通之后,两万字的正文喂进去,十几秒就能吐出一段两百字的核心摘要,虽然偶尔漏细节,但用来判断要不要精读已经足够。这是一个标准的自然语言处理本科毕业设计:输入长文本、输出短摘要,核心是 Transformer 的编码器-解码器架构。适合两类人——准备做 NLP 毕设的学生,以及想搞明白 seq2seq 加注意力机制怎么落地的开发者。下面按能跑通的标准逐层拆。

2. 项目定位与技术选型:做生成式摘要,为什么从零搭 Transformer

2.1 任务定义:抽取式与生成式的岔路口

文本摘要这个任务,动手写代码前第一步就要想清楚走哪条技术路线。抽取式摘要(Extractive Summarization)把原文里信息量最大的句子原封不动拼出来,实现简单、句子天然通顺,但它只能复制原文的句子,没法产生新的概括性表达;生成式摘要(Abstractive Summarization)更像人写摘要:先把全文"读懂",再重新组织语言,能写出原文里不存在的概括句,但训练难度明显更大,模型容易生成不通顺或者重复的内容。

这套毕设选的是生成式路线,模型用一个标准的 Transformer 编码器-解码器结构:编码器读完整篇正文,解码器从左到右逐词"吐出"摘要。选择生成式的理由很直接——抽取式在中文短文本数据上基本就是"找中心句",区分度太低,撑不起一篇需要讲清原理的毕业设计;而生成式能完整覆盖词表构建、注意力机制、序列生成、解码策略这些知识点,答辩时有东西可讲。

那为什么不用现成的 BERT 直接做?这是个高频问题。BERT 是编码器模型,擅长理解类任务,比如文本分类、语义匹配;摘要是生成任务,需要解码器逐词产出。常见做法是用 BERT 当编码器、再接一个随机初始化解码器,这是微调路线,对本科毕设来说模型体积大、显存压力高、可调的参数少。从零实现一个 Transformer 反而能把每个模块吃透,参数改起来心里有底。这套项目本质上就是"Transformer 源码级复现 + 摘要任务落地",这也是我推荐它作为毕设主线的核心理由。

2.2 技术栈清单:PyTorch 一套走完

整个工程的技术栈非常收敛,没有花哨的依赖,用一句话概括就是"Python 3.8 + PyTorch + jieba + 一个评估库"。

组件选型作用
深度学习框架PyTorch定义模型、训练、推理
中文分词jieba把中文句子切成词
数据处理numpy / 标准库构造词表、padding、batch
指标评估rouge_score计算 ROUGE-1/2/L
依赖管理pip + requirements.txt一键安装环境

选 PyTorch 而不是 TensorFlow,是我给这个项目定的调子。PyTorch 的动态图让调试非常舒服,print 一个张量就能看到 shape 和数值,对刚接触 seq2seq 的同学友好得多;将来想升级成微调预训练模型,生态也是现成的。我在帮同学排错时感触最深的就是这一点——动态图出问题可以直接定位到某一层,不用像静态图那样把整个计算图重新梳理一遍。

requirements.txt 大概是这样,按需装就行,不用追求最新版本:

torch>=1.10.0 jieba>=0.42.1 rouge-score>=0.1.2 numpy>=1.21.0 fastapi>=0.85.0 # 二次开发做接口时用 uvicorn>=0.18.0

2.3 数据与预处理:喂进模型前要过四道工序

训练数据是中文短文本摘要对,每行一条"正文\t摘要"。原始数据不能直接进模型,我按四道工序处理,每一步都独立成脚本,方便单独跑和复查。

第一步是清洗。把全角空格转半角、去掉 HTML 残留标签和乱码字符、把连续空白压成一个空格。这一步的代码很机械但必须做,数据里混一条带标签的样本,训练结果就多一分奇怪:

import re def clean_text(text): text = re.sub(r'<[^>]+>', '', text) # 去 HTML 标签 text = text.replace('\u3000', ' ').replace('\xa0', ' ') text = re.sub(r'\s+', ' ', text).strip() # 压缩连续空白 return text

第二步去重。按正文的哈希值去重,避免训练集里大量重复样本让模型"背题"。这个坑很隐蔽——有些公开数据集里同一篇新闻会被不同的摘要重复收录,不去重的话验证集指标虚高得离谱。第三步分词,用 jieba.cut 对正文和摘要都切一遍,词与词之间用空格连接,保存成新的文本文件。第四步过滤长度,正文控制在 10~510 个词,摘要控制在 3~100 个词,过长过短都丢掉,保证 batch 里的长度分布相对均匀。

预处理完的每一行长这样:

在 野外 动物园 里 , 长颈鹿 的 脖子 为什么 那么 长 ? 研究 显示 这 是 长期 自然选择 的 结果 。 长颈鹿 脖子 长 是 自然选择 的 结果

清洗和分词阶段最常见的问题是分词粒度和专有名词:jieba 默认词典会把一些人名、地名和专业术语切开,导致摘要里出现大量未知词。我一般会额外准备一个自定义词典,把数据里反复出现的专有名词加进去,分词效果立竿见影。预处理脚本里我还会在最后统计一次词表大小,如果小于 1 万,说明数据量或过滤条件有问题,趁早回头查,别等训完才发现。

3. 核心模块拆解:一条摘要从进入到返回的完整链路

3.1 词表构建:给每个词发一张身份证

模型不认识汉字,只认识数字。词表构建脚本要做的事,就是把训练集里所有词收集起来、按词频排序、给每个词分配一个固定的整数 id。下面这段是实际工程里的写法:

import jieba from collections import Counter def build_vocab(corpus_path, min_freq=2, max_size=50000): counter = Counter() with open(corpus_path, 'r', encoding='utf-8') as f: for line in f: parts = line.strip().split('\t') if len(parts) != 2: continue for text in parts: for word in jieba.cut(text): counter[word] += 1 # 0~3 留给特殊标记:padding、未知词、句子开头、句子结尾 vocab = {'<pad>': 0, '<unk>': 1, '<bos>': 2, '<eos>': 3} for word, freq in counter.most_common(max_size - 4): if freq >= min_freq: vocab[word] = len(vocab) return vocab

min_freq 是低频词过滤阈值,出现次数少于 2 的词直接丢掉、统一映射成 ,防止词表膨胀到几十万导致训练变慢。max_size 是词表上限,5 万的规模对本科毕设足够,再大边际收益很低。四个特殊标记是 seq2seq 的老规矩: 用于把 batch 里长短不一的句子补齐到相同长度; 告诉解码器"从这里开始生成"; 告诉解码器"句子到这里结束"; 兜底未登录词。还有一个纪律必须强调:词表只能基于训练集统计,验证集和测试集不能参与,否则指标会虚高。

3.2 编码器:多头自注意力是怎么读完全文的

Transformer 编码器里最核心的组件是多头自注意力。它回答的问题是:模型理解当前这个词时,需要重点看句子里的哪些其他词。实现上就是把输入分别通过三组线性变换映射成 Query、Key、Value,做缩放点积注意力,再把多个注意头的结果拼起来。

import torch import torch.nn as nn import math class MultiHeadAttention(nn.Module): def __init__(self, d_model=512, n_heads=8, dropout=0.1): super().__init__() assert d_model % n_heads == 0 self.d_k = d_model // n_heads self.n_heads = n_heads 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.out = nn.Linear(d_model, d_model) self.dropout = nn.Dropout(dropout) def forward(self, q, k, v, mask=None): batch, seq_len = q.size(0), q.size(1) # 线性变换后拆成多个头:(batch, heads, seq_len, d_k) Q = self.w_q(q).view(batch, -1, self.n_heads, self.d_k).transpose(1, 2) K = self.w_k(k).view(batch, -1, self.n_heads, self.d_k).transpose(1, 2) V = self.w_v(v).view(batch, -1, self.n_heads, self.d_k).transpose(1, 2) # 缩放点积注意力,除以 sqrt(d_k) 防止点积数值过大 scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(self.d_k) if mask is not None: scores = scores.masked_fill(mask == 0, -1e9) attn = torch.softmax(scores, dim=-1) attn = self.dropout(attn) context = torch.matmul(attn, V) # 把多头拼回去还原成 (batch, seq_len, d_model) context = context.transpose(1, 2).contiguous().view(batch, seq_len, -1) return self.out(context)

几个参数拆开说。d_model=512 是模型的隐藏维度,每个词最终被表示成一个 512 维的向量;n_heads=8 表示把 512 维切成 8 份、每份 64 维,各自独立做注意力,相当于让模型从 8 个不同的子空间去观察词与词的关系,最后拼回 512 维。mask 参数在编码器自注意力里一般传 None,但在解码器里必须传,这是下一节的重点。被 mask 遮住的位置填的是 -1e9,经过 softmax 之后这些位置的权重会趋近于 0,等价于"模型看不到这里"。

编码器整体是把 6 层"多头注意力 + 前馈网络"堆叠起来,每层外面再接残差连接和 LayerNorm。残差连接解决深层网络的梯度传递问题,LayerNorm 稳定训练。标准写法很长但很机械,我建议先单独把 MultiHeadAttention 跑通,验证输入输出维度对了,再堆层数。

3.3 解码器:生成时为什么不能偷看未来

解码器和编码器结构相似,但有两处关键差异。第一,它的自注意力带因果掩码:生成第 i 个词时,只能看 1 到 i-1 位置已经生成的内容,不能偷看 i 位置之后,否则训练等于开卷考试,推理时直接翻车。第二,它中间插了一层交叉注意力,Query 来自解码器自身,Key 和 Value 来自编码器输出,这样解码器生成每个词时都能回头"查阅"原文。

因果掩码构造起来很简单,就是一个下三角矩阵:

def subsequent_mask(size): """下三角掩码:第 i 行只能看到 0~i 列,即当前位置及之前的词""" mask = torch.tril(torch.ones(size, size)).bool() return mask

这个 mask 会和 3.2 节的 mask 参数对上:编码器自注意力里 mask 是 None;解码器自注意力里 mask 是下三角;交叉注意力里 mask 是 src 的 padding 位置,防止解码器去注意原文里补零的部分。很多初写 Transformer 的同学在这里翻车,三个 mask 各写各的,训练 loss 看着正常,推理结果全乱。

3.4 训练与推理:Teacher Forcing 和 Beam Search

训练时为了让模型快速收敛,用了 Teacher Forcing:每一步的输入都用真实摘要的上一个词,而不是模型自己生成的词。落到代码上,就是把目标序列右移一位当输入、左移一位当标签:

for epoch in range(epochs): for src, tgt in train_loader: src = src.to(device) # (batch, src_len) tgt_in = tgt[:, :-1] # 去掉最后一个词,作为解码器输入 tgt_out = tgt[:, 1:] # 去掉第一个词,作为预测目标 logits = model(src, tgt_in) # (batch, tgt_len-1, vocab) loss = criterion( logits.reshape(-1, vocab_size), tgt_out.reshape(-1) ) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step()

tgt_in 和 tgt_out 的错位是 seq2seq 训练的标准套路。假设真实摘要是 [ , 长, 颈, 鹿, ],模型看到 [ , 长, 颈, 鹿] 去预测 [长, 颈, 鹿, ],每一步都在学"根据已经生成的词预测下一个词"。clip_grad_norm_ 把梯度模长限制在 1.0,这行我建议必加——Transformer 训练初期梯度很容易爆炸,不加 loss 动不动就跳到 NaN。

推理阶段不能再 Teacher Forcing,因为根本没有真实摘要可用。最常见的改进是 Beam Search,不只是每步选概率最大的词,而是每步保留 top-k 条候选路径,最后挑综合分数最高的:

def beam_search(model, src, beam_size=4, max_len=50): model.eval() with torch.no_grad(): enc_out = model.encode(src) beams = [(torch.tensor([[2]]), 0.0)] # (token序列, 累计对数概率) for _ in range(max_len): candidates = [] for seq, score in beams: logits = model.decode_step(enc_out, seq) log_probs = torch.log_softmax(logits[:, -1, :], dim=-1) topv, topi = log_probs.topk(beam_size) for i in range(beam_size): new_seq = torch.cat([seq, topi[:, i].unsqueeze(0)], dim=1) candidates.append((new_seq, score + topv[:, i].item())) candidates.sort(key=lambda x: x[1], reverse=True) beams = candidates[:beam_size] return beams[0][0]

beam_size 是保留的候选路径数,4 是常见默认值。累计分数用 log 概率相加,避免连乘下溢。beam_size 调大理论上效果更好,但会出现"短句占便宜"的问题,实际工程里要加长度惩罚,这个我在第 5 章避坑里展开讲。

4. 运行与复现:从零开始跑通一次训练

4.1 目录结构与启动顺序

拿到这套源码后,我习惯先不看模型细节,而是把目录结构摸一遍,知道每个文件干什么再动手。这个项目目录长这样:

text_summarizer/ ├── data/ │ ├── train.txt # 训练集,每行 正文\t摘要 │ ├── valid.txt # 验证集 │ └── test.txt # 测试集 ├── config.py # 全部超参数集中在这里 ├── vocab.py # 构建词表 ├── dataset.py # Dataset 与 DataLoader ├── model.py # Transformer 编码器 + 解码器 ├── train.py # 训练入口 ├── predict.py # 加载模型生成摘要 ├── evaluate.py # 计算 ROUGE 指标 └── checkpoints/ # 模型权重保存目录

启动顺序严格按依赖关系走:先跑 vocab.py 生成词表文件,再跑 train.py 训练,最后用 predict.py 或 evaluate.py 验证。config.py 单独拎出来是刻意设计——所有超参数集中管理,改参数不用翻代码,这也是我给这个项目定下的一个约定。dataset.py 里负责把文本转成 id 序列,超长样本直接丢弃而不是截断:

from torch.utils.data import Dataset class SummaryDataset(Dataset): def __init__(self, path, vocab, max_src=510, max_tgt=100): self.pairs = [] with open(path, encoding='utf-8') as f: for line in f: parts = line.strip().split('\t') if len(parts) != 2: continue src = [vocab.get(w, 1) for w in parts[0].split()] tgt = [vocab.get(w, 1) for w in parts[1].split()] if len(src) <= max_src and len(tgt) <= max_tgt: self.pairs.append((src, tgt))

vocab.get(w, 1) 表示查不到词时返回 的 id(1),这是未登录词的兜底。超长样本直接丢弃而不是截断,是因为静默截断会让模型学到错误的对齐关系。

4.2 超参数与训练启动

模型侧的参数在第 3 章解释过,这里给一份可以直接照抄的配置表:

参数值说明
d_model512词向量和隐藏层维度
n_heads8注意力头数
n_layers6编码器和解码器层数
dropout0.1防止过拟合
batch_size64显存不够就降到 32
max_src_len510正文最大词数
max_tgt_len100摘要最大词数
learning_rate1e-4Adam 初始学习率
warmup_steps4000学习率先升后降
epochs30验证 loss 不降则提前停止

训练命令就一行:

python train.py --config config.py --gpu 0

warmup_steps 值得单独说。Transformer 对学习率非常敏感,冷启动直接用 1e-4,前几百步的梯度更新幅度过大,loss 会剧烈震荡。常见做法是前 4000 步让学习率从很小的值线性涨到 1e-4,之后再按步数衰减。这个策略和 Adam 配合几乎是 Transformer 训练的默认搭配。训练日志里重点盯三个数:step、loss、perplexity。loss 稳定下降就正常;如果 loss 在 5.0 附近长期横盘,优先怀疑数据预处理,比如正文和摘要切反了、分词后句子变成空串。

4.3 推理与 ROUGE 评估

训练完事,checkpoints 目录下会保存两份权重:最后一个 epoch 的,以及验证集 loss 最低的。推理时加载 loss 最低的那份,而不是最后一个 epoch,这是我血泪换来的经验,第 5 章展开。predict.py 做的事就是读模型、读词表、把输入文本分词转 id、过 beam search、把 id 映射回词并去掉 和 :

python predict.py --ckpt checkpoints/best.pt --text "野生动物园里,长颈鹿的脖子为什么那么长……"

评估脚本用 rouge_score 库计算 ROUGE-1、ROUGE-2、ROUGE-L。ROUGE 的核心思想是看生成摘要和参考摘要之间重叠了多少 n-gram:ROUGE-1 看一元组重叠,ROUGE-2 看二元组,ROUGE-L 按最长公共子序列算。中文评估时按词切分后再算,按字算会把大量合法但不同的表达判成错误,指标虚低:

from rouge_score import rouge_scorer scorer = rouge_scorer.RougeScorer(['rouge1', 'rouge2', 'rougeL'], use_stemmer=False) def evaluate(predictions, references): scores = {'rouge1': [], 'rouge2': [], 'rougeL': []} for pred, ref in zip(predictions, references): result = scorer.score(ref, pred) for key in scores: scores[key].append(result[key].fmeasure) for key in scores: print(f'{key}: {sum(scores[key]) / len(scores[key]):.4f}')

注意:ROUGE 分数高不等于摘要质量好,它是字面重叠度量,模型完全可以用"复述原句"的方式刷分。评估时每条样本都要人工抽看一遍,ROUGE 只当筛选器用。

ROUGE fmeasure 是精确率和召回率的调和平均,对摘要任务来说召回率偏重要——摘要应该尽量覆盖参考摘要里的关键信息,漏掉关键点比多写几个无关词更致命。

5. 避坑指南:训练文本摘要最容易翻车的五个地方

5.1 现象一:Loss 先降后涨,最后变 NaN

现象:前几百步 loss 正常下降,某个 step 突然跳成 NaN,之后再也回不来。

原因:Transformer 的 self-attention 里 QK^T 的数值会随序列长度显著增长,除以 sqrt(d_k) 只是缓解;更常见的是没有 warmup,冷启动大学习率把梯度一步打飞,数值直接溢出。

解决:三个手段叠加。第一,梯度裁剪:torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0),必须放在 loss.backward() 之后、optimizer.step() 之前;第二,实现 warmup 学习率调度;第三,把初始学习率从 1e-4 降到 3e-5 跑一两百步,确认稳定再调回去。排查时先确认 NaN 出现在第几步,再把 batch 缩到 1 复现,能更快定位到具体层。

5.2 现象二:生成结果全是 UNK 或是复读机

现象:摘要里大量 ,或者同一个词重复五六遍。

原因:UNK 多说明词表太小,或者测试集里专有名词太多,jieba 把术语切成识别不了的碎片;复读机则是训练不充分、解码策略没加长度惩罚的典型表现,beam search 倾向于选重复的路径,因为重复词让路径概率累积得很高。

解决:把 max_size 从 5 万提到 10 万,min_freq 从 2 降到 1;给 jieba 加载自定义词典;beam search 里加长度惩罚,把累计分数除以 len 的 alpha 次方,alpha 常用 0.6~1.0。数据量大时可以考虑用 BPE 分词替代字/词级切分,从根源上缓解未登录词。

5.3 现象三:显存溢出 OOM

现象:batch_size=64 一跑就报 CUDA out of memory,调成 32 也撑不过几个 epoch。

原因:Transformer 的显存占用和序列长度的平方成正比,数据里混进一条 2000 词的长文本,直接打爆整批显存。很多毕设数据集里都藏着几条超长噪声样本,预处理过滤长度时阈值放太宽就会漏进来。

解决:预处理严格截断,max_src_len=510、max_tgt_len=100;DataLoader 按长度分桶,让同一个 batch 内句子长度接近,减少 padding 浪费;实在不行就梯度累积,每 4 个 batch 更新一次参数,等效放大 batch_size。我在 dataset.py 里还加了一个断言,超过长度上限的样本直接 drop,绝不静默截断。

5.4 现象四:Beam Search 效果反而不如 Greedy

现象:beam_size 从 1 调到 4,ROUGE 不升反降。

原因:beam search 按概率连乘打分,天然偏好短序列,beam 越大这个偏差越明显;另外很多实现里忘了把已经生成 的候选单独归入 final 列表,导致结束符被反复生成。

解决:加长度惩罚;已经生成 的路径直接进入 final 列表、不再参与扩展;对比 beam_size=1/4/8 三档,结合人工抽样挑最好的档位。别迷信 beam_size 越大越好,很多项目里 4 就是甜点,8 反而变差。

5.5 现象五:ROUGE 涨了,摘要却读不通

现象:ROUGE-1 从 0.3 涨到 0.45,但生成文本语法不通、逻辑混乱。

原因:ROUGE 只按字面重叠打分,模型发现了"抄原文片段"的捷径,生成的句子看起来像摘要,实际事实和语法都崩了。这个现象在中文短文本摘要数据上尤其明显,参考摘要本身就短,重叠率容易被刷高。

解决:训练时加 label smoothing,让模型不过分自信、减少复读;评估时每个 epoch 抽 20 条人工打分,关注通顺度和信息完整性两个维度;如果持续读不通,回头检查数据里有没有"正文和摘要根本不对应"的脏样本。ROUGE 只能当筛选器,不能当裁判,这是摘要评测的铁律。

6. 二次开发:三个能直接落地的改造方向

6.1 把模型封装成 HTTP 接口

答辩演示时,命令行跑 predict.py 不够直观。我一般把模型加载放到服务进程里只做一次,用 FastAPI 暴露一个 POST 接口:

from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() model, vocab = load_model('checkpoints/best.pt') # 进程启动时只加载一次 class Item(BaseModel): text: str @app.post('/summarize') def summarize(item: Item): summary = model.generate(item.text, beam_size=4) return {'summary': summary}

模型初始化必须在请求处理之外,否则每个请求都重新加载权重,CPU 推理会慢到怀疑人生。GPU 上要加排队和超时控制,并发一起来显存会被占满。

6.2 训练中加 ROUGE 早停

只看验证 loss 不够,loss 最低的模型不一定是摘要最好的模型。我习惯每个 epoch 结束后在验证集随机抽 200 条算 ROUGE-L,连续 3 个 epoch 不涨就早停,ROUGE 最高的权重单独存一份。改动很小,就在 train.py 的验证循环里多调一次 evaluate,很多项目靠这个把最终指标提了 2~3 个点。

6.3 从零训练切换到预训练模型微调

数据量够大或者想快速出效果时,可以直接用 Transformers 库做迁移学习:加载一个中文生成模型,把摘要当文本到文本任务微调,学习率降到 2e-5 左右、epoch 减到 3~5。这条路适合先要结果再抠细节的同学,但从零实现 Transformer 的代码要保留,答辩讲原理比盲目堆模型更能加分。

给这套东西做二次开发时我踩过最大的坑是"改参数不重启服务":FastAPI 不会热加载改过的 config.py,我因为没重启就反复调 beam_size,白白浪费一个下午。从那以后我每次改完参数都强制走一遍"改 config → 重启进程 → 看日志确认加载的是新值"的流程,这个习惯后来救过我很多次。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进