简介:这是一份面向本科毕业设计与自然语言处理入门者的深度学习文本摘要项目实践包,围绕Transformer编码器—解码器架构,解决长文本关键信息提取与精炼摘要生成问题,适合具备一定Python基础、希望系统走通数据预处理、模型训练与效果评估流程的学习者。压缩包共34个文件、约360KB,以18个Python源码为主体,辅以5个Shell脚本、3个文本说明、2个YAML配置及词表、Markdown文档等,覆盖数据制作、训练、评估、服务部署与测试等环节。已有3781人学习下载。读者可据此理解分词与词汇表构建、序列化输入、损失函数和优化器配置,掌握ROUGE、BLEU等摘要评价指标的使用,并通过训练评估脚本、Docker及持续集成配置,获得从实验复现到工程化调试的完整参考,也便于课程设计复现与二次开发。
1. 文本摘要项目定位:一段长文压成三句话,这套毕设把全链路跑通了
三百行的需求文档,要在十秒内压成三句话给决策的人看,这是深度学习在自然语言处理里最接地气的一类任务:文本摘要。这套本科毕业设计做的就是完整的一条链路:输入一段中文长文本,输出 60~120 字、覆盖原文主干信息的短摘要,模型主干是 Transformer 的编码器-解码器结构,外面套着数据清洗、训练脚本、beam search 解码、ROUGE 评测和一个能点开的网页 Demo。它不适合直接拿去当生产级服务,但特别适合三种人:一是要交毕设、需要一份结构完整、讲得出所以然的工程;二是刚学完注意力机制、想看看它落地长什么样;三是手里有一批中文文稿、想先跑个基线再决定要不要投入。往下读,先看选型和目录,再看数据管线、模型与训练,最后是踩坑记录和二次开发。
2. 技术栈与工程结构:为什么选 Transformer,工程怎么分目录
2.1 选型拆解:抽取式省钱,生成式才叫“写摘要”
文本摘要这条路有两条岔:抽取式和生成式。抽取式的代表做法是 TextRank、Lead-3,把原文句子打分排序,挑几句拼起来。好处是稳定、不会胡编、几乎不用训练;坏处是它改不了一个字,原文啰嗦它就跟着啰嗦,摘要读起来像拼接。生成式走 seq2seq,模型自己组织语言,能压缩、能改写、能合并同义信息,代价是容易生成原文没有的事实,也就是常说的“幻觉”。
这套毕设走的是生成式路线,主干用 Transformer。为什么不选更“传统”的 LSTM + Attention?我自己的判断是三点:第一,长文本场景下自注意力的路径长度是 1,任意两个位置直连,LSTM 得靠隐状态一段段传,512 token 往上就开始遗忘开头;第二,Transformer 训练时可以整句并行,单卡跑一个 epoch 的时间能接受,LSTM 必须按时间步串行,同样算力下慢一截;第三,答辩时 Transformer 的结构讲得清、图画得出来,编码器几层、多头怎么切、位置编码为什么用正弦,这些都是一页 PPT 能说明白的东西。
但本科算力有限,不能照抄论文配置。常见做法是把 d_model 从 512 保住、层数从 6+6 降到 4+4,前馈层从 2048 降到 1024,词表控制在 3 万以内。词表是最容易被忽略的一项:3 万词表乘 512 维就是 1500 万参数,光 embedding 就吃掉不少显存,词表开到 5 万以上,单卡 12G 很容易第一批就 OOM。
2.2 目录结构:一份能答辩也能真跑的骨架
工程按“配置、数据、源码、权重”四块切,好处是换数据集不用动代码,换模型不用动脚本。目录大致长这样:
summarizer/ ├── configs/ │ └── base.yaml # 超参集中管理:d_model / lr / batch / beam ├── data/ │ ├── raw/ # 原始语料,一行一条 json:{"article": ..., "summary": ...} │ ├── processed/ # 清洗切分后的 train.jsonl / val.jsonl / test.jsonl │ └── vocab.txt # 词表,char 级或 BPE 都行 ├── src/ │ ├── preprocess.py # 清洗、分句、截断、切分数据集 │ ├── dataset.py # Dataset + 动态 padding 的 collate_fn │ ├── model.py # PositionalEncoding / TransformerSummarizer │ ├── train.py # 训练循环、梯度裁剪、checkpoint、日志 │ ├── decode.py # greedy 与 beam search 两套解码 │ ├── evaluate.py # ROUGE-1/2/L 打分 │ └── serve.py # Flask 推理接口 ├── ckpt/ # best.pt / last.pt └── requirements.txtconfigs/base.yaml这一层别省。毕设期间大概率要跑十几组对比实验,学习率、beam 宽度、层数都要调,参数写死在 train.py 里,改一次跑一次,最后自己都记不清哪份权重对应哪组配置。把超参抽出来,日志里顺手把 config 一起落盘,出问题的时候能对得上账。
src/decode.py单独拆出来也是同样的道理:训练和推理用的不是同一段代码路径,解码策略要反复试(贪心、beam、加不加长度惩罚),混在 train.py 里会越改越乱。
2.3 环境与三步跑通
依赖不多,一台带单卡 CUDA 的机器足够。我一般这样起环境:
python -m venv .venv && source .venv/bin/activate pip install torch --upgrade pip install -r requirements.txt # 主要就 torch / numpy / flask / pyyaml / tqdm # 1) 预处理:raw 目录下的 json 全部清洗、截断、切分 python -m src.preprocess --raw data/raw --out data/processed --max-src 512 --max-tgt 128 # 2) 训练:从配置文件读超参,权重默认写 ckpt/ python -m src.train --config configs/base.yaml --epochs 20 --batch-size 16 # 3) 起服务:加载 best.pt,暴露 HTTP 接口 python -m src.serve --ckpt ckpt/best.pt --port 8000--max-src 512是按 token 算的,不是按字数。中文一个字通常对应 1~2 个 token,512 token 差不多能装 350~450 个汉字,这个长度覆盖大多数新闻和通知正文,再长就得分段处理或者做滑窗。--max-tgt 128控制参考摘要的长度,短摘要任务基本够用,设到 256 会让解码变慢,而且模型容易学着把原文抄一遍。
提示:requirements.txt 里别写死 CUDA 版本号。换台机器装依赖的时候,torch 版本和驱动对不上是最常见的翻车点,装 CPU 版能跑通但训练速度会让人怀疑人生。
3. 数据管线拆解:中文长文本怎么变成模型能吃的样本
3.1 样本构造:一对 (正文, 摘要) 里的三个坑
原始语料一般是 json 行,字段就是正文和参考摘要。看着简单,实际动手会碰到三个问题。
第一个是长度不匹配。正文动辄两三千字,参考摘要可能就一句话。粗暴地从头上截 512 token,遇到“结论在最后”的文稿,模型根本看不到要总结的东西。我的做法是头部取 384、尾部取 128 拼接,中间用省略标记断开,能覆盖“背景 + 结论”这种常见结构。
第二个是烂样本。参考摘要长度小于 8 个字的、正文和摘要完全相同的、摘要其实是标题的,这些样本留着只会教模型抄。我一般会过一遍规则:摘要长度 8~128 字之间、和正文的重合度(最长公共子串长度占比)不超过 0.7,超了就丢。
第三个是数据泄漏。很多公开语料是同一篇稿子被多家转载,内容几乎一样。随机切分的话,训练集里出现过的原文很可能以近乎相同的版本落进测试集,ROUGE 会涨得特别好看,一上真实数据立刻打回原形。切分必须按来源分组,同一来源的文章只能进一个集合。
3.2 清洗与分句:中文文本的脏活全在这一层
网页扒下来的语料里全是 HTML 残片、全角空格、零宽字符。这一步不做干净,后面模型学出来的句子里会莫名其妙带符号。清洗和分句我写成两个纯函数,方便单测:
import re BLANK_PAT = re.compile(r"[ \t\u3000]+") SENT_END = re.compile(r"(?<=[。!?!?;;])") # 在句末标点后切,保留标点 def clean_text(text: str) -> str: """去标签、去零宽字符、压空白、合并重复标点""" text = re.sub(r"<[^>]+>", "", text) # HTML 标签 text = text.replace("\u200b", "").replace("\xa0", " ") text = BLANK_PAT.sub(" ", text) # 连续空白压成一个 text = re.sub(r"([,。!?;:])\1+", r"\1", text) # “!!!”→“!” return text.strip() def split_sentences(text: str, min_len: int = 6): """按中文句末标点切句,丢掉过短的碎片(如单独一个‘好。’)""" parts = [p.strip() for p in SENT_END.split(text)] return [p for p in parts if len(p) >= min_len]SENT_END用了后行断言(?<=...),意思是“切在标点之后但不吃掉标点”,这样切出来的句子自带句号,后面拼回摘要时不用再补。min_len=6这个阈值是经验值,中文里六个字以下基本是“好的。”“以上。”这种没有信息量的碎片,留着会污染训练目标。
分句在这套工程里有两个用处:一是做长度截断时按句边界切,不会把一句话拦腰截断;二是做抽取式兜底的时候,句子是打分的最小单位。这两块后面都会用到。
3.3 Dataset 与动态 padding:显存就是这么省出来的
固定长度 padding 是最浪费显存的做法。一篇文章 512 token,一个摘要 20 token,如果按 batch 内最大长度补齐,一个 16 条的 batch 里大半是 pad,模型还得学着忽略它们。改成动态 padding,每个 batch 只补到该 batch 的最长样本:
import json import torch from torch import nn from torch.utils.data import Dataset class SummaryDataset(Dataset): def __init__(self, path, tokenizer, max_src=512, max_tgt=128): self.rows = [json.loads(line) for line in open(path, encoding="utf-8")] self.tk, self.max_src, self.max_tgt = tokenizer, max_src, max_tgt def __len__(self): return len(self.rows) def __getitem__(self, i): r = self.rows[i] # 摘要侧手动加 BOS/EOS,解码时 BOS 作为起始符,EOS 让模型学会收尾 src = self.tk.encode(r["article"])[: self.max_src] tgt = [self.tk.bos_id] + self.tk.encode(r["summary"])[: self.max_tgt - 2] + [self.tk.eos_id] return torch.tensor(src), torch.tensor(tgt) def make_collate(pad_id: int): def collate(batch): srcs, tgts = zip(*batch) # pad_sequence 会按 batch 内最长的那条补齐,而不是全局最长 srcs = nn.utils.rnn.pad_sequence(srcs, batch_first=True, padding_value=pad_id) tgts = nn.utils.rnn.pad_sequence(tgts, batch_first=True, padding_value=pad_id) return srcs, tgts return collate几个参数值得说清楚:bos_id和eos_id是解码的边界信号,训练时目标序列尾部必须有eos_id,否则模型永远不知道什么时候停,生成结果会一直拖到max_len才断。pad_id一般固定为 0,它同时会作为CrossEntropyLoss的ignore_index和所有 attention 的key_padding_mask来源,这三处必须用同一个值,错一个就会出现“loss 正常但生成全是 pad”的玄学问题。
pad_sequence只能处理一维变长序列,所以src和tgt都得先转成 tensor 再进 collate。如果你的分词器输出的是 numpy 数组,记得在这里统一转,不然 DataLoader 的多进程会报一个很难读的序列化错误。
4. 模型与训练核心:Transformer 摘要器的前向、损失与解码
4.1 编码器-解码器骨架:位置编码和三种 mask
结构上就是标准的 Transformer,但有几个细节必须自己写对,否则训练曲线会很怪。位置编码用正弦版本,注册成 buffer 让它跟着模型走设备;编码和解码拆成两个方法,方便 beam search 复用编码结果,不用每步重算一遍。
import math import torch import torch.nn as nn class PositionalEncoding(nn.Module): def __init__(self, d_model, dropout=0.1, max_len=1024): super().__init__() pe = torch.zeros(max_len, d_model) pos = torch.arange(0, max_len).unsqueeze(1).float() div = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)) pe[:, 0::2] = torch.sin(pos * div) pe[:, 1::2] = torch.cos(pos * div) self.register_buffer("pe", pe.unsqueeze(0)) # [1, max_len, d_model] self.dropout = nn.Dropout(dropout) def forward(self, x): return self.dropout(x + self.pe[:, : x.size(1)]) class TransformerSummarizer(nn.Module): def __init__(self, src_vocab, tgt_vocab, d_model=512, nhead=8, num_layers=4, dim_ff=1024, dropout=0.1, pad_id=0): super().__init__() self.pad_id = pad_id self.src_emb = nn.Embedding(src_vocab, d_model, padding_idx=pad_id) self.tgt_emb = nn.Embedding(tgt_vocab, d_model, padding_idx=pad_id) self.pos = PositionalEncoding(d_model, dropout) self.transformer = nn.Transformer( d_model=d_model, nhead=nhead, num_encoder_layers=num_layers, num_decoder_layers=num_layers, dim_feedforward=dim_ff, dropout=dropout, batch_first=True) self.fc_out = nn.Linear(d_model, tgt_vocab) def encode(self, src): x = self.pos(self.src_emb(src)) return self.transformer.encoder(x, src_key_padding_mask=src.eq(self.pad_id)) def decode(self, memory, tgt_in, src_pad_mask): # 因果 mask:第 i 个位置只能看到前 i 个 token,防止训练时偷看答案 tgt_mask = nn.Transformer.generate_square_subsequent_mask( tgt_in.size(1), device=tgt_in.device) y = self.pos(self.tgt_emb(tgt_in)) h = self.transformer.decoder( y, memory, tgt_mask=tgt_mask, tgt_key_padding_mask=tgt_in.eq(self.pad_id), memory_key_padding_mask=src_pad_mask) return self.fc_out(h) def forward(self, src, tgt_in): return self.decode(self.encode(src), tgt_in, src.eq(self.pad_id))三种 mask 的分工要说透:src_key_padding_mask让编码器忽略 pad,不进注意力;tgt_mask是下三角,保证解码器自回归;tgt_key_padding_mask让解码器忽略目标侧的 pad。三者少一个都会出问题——少了tgt_mask,模型训练 loss 会迅速掉到接近 0,推理时却生成不出来,因为训练时它一直在看答案,这是最典型的“线上崩”案例。
batch_first=True这个参数别漏。PyTorch 的 Transformer 默认是(seq, batch, feature),跟 DataLoader 出来的形状正好相反,漏了会得到一个形状对不上但报错信息很含糊的错误。
4.2 训练循环:label smoothing、梯度裁剪和 warmup
训练循环本身不复杂,关键在三个稳定器:标签平滑、梯度裁剪、学习率 warmup。中文摘要任务的词表有几万个,模型很容易在第 0 步就对高频词过度自信,标签平滑0.1能把这种过度自信压下去;梯度裁剪给到1.0,防止个别长样本炸梯度;warmup 让学习率从 0 线性爬升再按步数平方根衰减,前几百步不至于把随机初始化的权重直接推飞。
import torch from torch import nn class NoamLR: """Transformer 原文的学习率调度:先线性 warmup,再按步数平方根衰减""" def __init__(self, optimizer, d_model, warmup=4000): self.opt, self.d_model, self.warmup, self.step_num = optimizer, d_model, warmup, 0 def step(self): self.step_num += 1 lr = self.d_model ** -0.5 * min(self.step_num ** -0.5, self.step_num * self.warmup ** -1.5) for g in self.opt.param_groups: g["lr"] = lr def train_one_epoch(model, loader, opt, sched, device="cuda", clip=1.0): model.train() criterion = nn.CrossEntropyLoss(ignore_index=model.pad_id, label_smoothing=0.1) total, n = 0.0, 0 for src, tgt in loader: src, tgt = src.to(device), tgt.to(device) tgt_in, tgt_out = tgt[:, :-1], tgt[:, 1:] # 右移一位:输入 BOS+a,预测 a+EOS logits = model(src, tgt_in) # [B, L, V] loss = criterion(logits.reshape(-1, logits.size(-1)), tgt_out.reshape(-1)) opt.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), clip) opt.step() sched.step() total += loss.item(); n += 1 return total / max(n, 1)tgt[:, :-1]和tgt[:, 1:]这个错位是 seq2seq 的核心。输入侧永远比输出侧少一位,第 t 步喂进去的是 BOS 到第 t-1 个词,要预测的是第 t 个词。写反了不会报错,loss 照样下降,但生成出来的东西完全不连贯。
ignore_index=model.pad_id必须和 Dataset 里的padding_value一致。它让 pad 位置的 loss 不计入平均,否则 batch 内 pad 越多,loss 被稀释得越厉害,你会看到一个“越训越低但其实什么都没学会”的假象。
注意:每个 epoch 结束记得存两个权重,
last.pt用于断点续训,best.pt按验证集 loss 挑。只看训练 loss 选模型是常见翻车点,训练 loss 还在降而验证 loss 已经开始往上走的时候,选出来的多半是过拟合的那个。
4.3 解码策略:先贪心跑通,再上 beam search 加长度惩罚
贪心解码实现简单,每步取概率最大的词,缺点是容易陷入重复循环,“记者记者记者”这种输出就是它干出来的。beam search 同时保留 k 条候选路径,每步扩展再剪枝,效果好一截。配上长度惩罚,避免模型偏爱短句(短句的累积 log 概率天然更高)。
import torch @torch.no_grad() def beam_search(model, src, bos_id, eos_id, beam=4, max_len=128, alpha=0.7, device="cuda"): """src: [1, S] 单条样本;alpha 越大越鼓励长摘要""" model.eval() src = src.to(device) src_mask = src.eq(model.pad_id) memory = model.encode(src) # 只编码一次,后面所有候选共用 alive, finished = [([bos_id], 0.0)], [] rule = lambda s: s[1] / ((5 + len(s[0])) / 6) ** alpha # GNMT 长度惩罚 for _ in range(max_len): cand = [] for seq, score in alive: tgt = torch.tensor([seq], device=device) logp = torch.log_softmax(model.decode(memory, tgt, src_mask)[0, -1], dim=-1) topv, topi = logp.topk(beam) cand += [(seq + [i], score + v) for v, i in zip(topv.tolist(), topi.tolist())] cand.sort(key=rule, reverse=True) alive = [] for seq, score in cand[:beam]: (finished if seq[-1] == eos_id else alive).append((seq, score)) if not alive: break best = max(finished or alive, key=rule) return best[0][1:] # 去掉开头的 BOSbeam=4是效果和速度的平衡点,调到 8 以上收益很小,耗时翻倍。alpha=0.7控制长度偏好,中文摘要我一般从 0.6 试到 1.0,太大会生成又长又啰嗦的句子,太小会变成一句话打发。max_len=128要跟训练时的max_tgt对齐,推理比训练长太多,模型没见过那么长的位置,输出会开始胡言乱语。
还有一个隐藏坑:memory只算了一次,但decode每步都要重新跑一遍解码器。步数一多就是 O(L²) 的重复计算,做 demo 够用,真要提速得加 KV cache,这块在二次开发里再说。
5. 常见问题排查:训练不收敛、显存爆、摘要复读机
这一章是我自己踩过、也在别人代码里见过的五类问题,按“现象 → 原因 → 解决”写,碰到了直接对号入座。
5.1 现象:loss 卡在 6~7 不降,生成全是高频字
原因基本逃不出三个:学习率没做 warmup,前几百步直接把权重推飞;pad_id在三处(Embedding 的padding_idx、padding_value、ignore_index)不一致,模型把 pad 当普通词学了;词表里unk占比过高,清洗阶段没处理生僻字符。
解决顺序是:先打印一个 batch 的src和tgt原文,肉眼确认里面没有满屏的 0;再看学习率曲线,前 4000 步应该从接近 0 爬到峰值;最后统计unk比例,超过 3% 就得换分词粒度,字级切分通常比词级稳。如果这三步都正常还卡着,把层数降到 2+2 跑一遍,能降说明是容量和数据的匹配问题,不是代码写错了。
5.2 现象:第一个 epoch 就跑 OOM,batch 降到 2 也不行
不一定是 batch 的问题。可能是按字数而不是按 token 截断,中文一个字在某些分词器下对应多个 token,512 字实际是 900 多 token;也可能是注意力矩阵没做 mask,[B, H, L, L]在 L=1000 时单层就要几个 G;还有一种是自己写的评估循环忘了torch.no_grad(),验证阶段悄悄建了计算图。
解决办法:先print(src.shape)确认真实长度;再开混合精度(torch.cuda.amp),显存大概能省 30%;批次装不下就用梯度累积,batch=4累积 4 次等价于batch=16,只是 BN 类的统计会有细微差别,这个模型里没有 BN,影响可以忽略。
5.3 现象:生成的摘要反复输出同一句,越到后面越离谱
典型的 exposure bias 加贪心解码。训练时每一步喂的都是正确的前缀,推理时喂的是自己上一步的输出,一旦错一个词就滚雪球;贪心解码又会在“记者”“记者们”这种高频词上反复打转。
解决分两层:解码层加上 beam search 和长度惩罚,再补一个no_repeat_ngram_size的硬约束,禁止连续三元组重复;训练层把标签平滑开到 0.1,并在验证集上按 ROUGE 而不是按 loss 选模型。我自己的血泪经验是,光调解码参数最多改善一半,剩下得靠训练侧,让模型见过更多不同写法的参考摘要。
5.4 现象:ROUGE 特别高,换一批真实文稿就原形毕露
十有八九是数据泄漏或者评测口径不对。中文按空格切词,切完整个句子是一个 token,ROUGE-1 会虚高到 0.9 以上;训练集和测试集里存在同源转载的近似重复样本,模型其实是在做背诵。
排查方法:先把评测改成按字切分(list(text)),这个改动会让分数掉一大截,但那才是真实水平;再做一次去重,用正文的 simhash 或者前 100 字的哈希分桶,同桶的只保留一条;最后人工抽 50 条看生成结果和参考摘要是不是“意思对但字面不同”,如果是,说明 ROUGE 低估了模型,得补一个人工评分维度。
5.5 现象:训练时看着还行,推理接口返回的结果完全对不上
八成是model.eval()忘了调,dropout 还在起作用,每次请求结果都不一样;或者加载 checkpoint 时只 load 了state_dict但没对齐模型结构,strict=False吞掉了不匹配的键,实际加载进去的是半随机权重。
我的做法是:加载完权重后立刻用训练集里的一条样本跑一遍推理,如果生成结果和训练日志里那条对不上,就说明权重没加载对。load_state_dict一定用strict=True,报错信息虽然长,但它会明确告诉你哪一层形状不匹配,比默默跑出一个烂结果强太多。
6. 二次开发:换底座、加兜底、把服务接出去
这套代码跑通之后,真正花时间的是改。三个方向按投入产出排一下:
| 改造方向 | 具体做法 | 收益与代价 |
|---|---|---|
| 换编码器底座 | 把src_emb+ 编码器换成中文预训练编码器,解码器保持随机初始化 | 收敛快、摘要更通顺;显存涨 2~3 倍,需要调小 batch |
| 加抽取式前置过滤 | 先用 TextRank 给句子打分,取 Top-N 句拼成短一点的输入再送模型 | 长文效果明显提升;多一层依赖,端到端时延增加 |
| 解码加硬约束 | beam search 里禁重复 n-gram、限制最短长度 | 复读机现象基本消失;参数要跟着数据集调 |
| 服务化 | Flask 外面套一层任务队列,长文本异步返回 | 并发上来不阻塞;工程复杂度上升 |
换底座这一项,最省事的做法是保留解码器结构,只替换编码侧,然后整个模型用一个小学习率(比如 1e-5)微调两三个 epoch。这时候原来的 warmup 步数要调小,不然还没热身完就训完了。
抽取式兜底值得单独说。生成长摘要最容易在超长文本上翻车,因为它要在 512 token 里装下三千字的信息。加一层抽取式过滤,先用句子打分挑出 15~20 句,把输入压到 400 token 以内,再送进模型,摘要的事实准确率会明显好于直接截断:
def textrank_filter(sentences, top_n=18, decay=0.85, iters=30): """极简 TextRank:句子两两算字面重合度,迭代传播分数,返回原文顺序的 Top-N 句""" n = len(sentences) sim = [[0.0] * n for _ in range(n)] for i in range(n): for j in range(i + 1, n): a, b = set(sentences[i]), set(sentences[j]) s = len(a & b) / (len(a) + len(b) - len(a & b) + 1e-6) sim[i][j] = sim[j][i] = s score = [1.0] * n for _ in range(iters): new = [(1 - decay) + decay * sum(sim[i][j] * score[j] for j in range(n) if j != i) for i in range(n)] score = new keep = sorted(range(n), key=lambda i: score[i], reverse=True)[:top_n] return [sentences[i] for i in sorted(keep)] # 还原原文顺序,别让摘要跳来跳去验证方法也得跟着改。ROUGE 只能告诉你字面重合,改完底座之后很可能 ROUGE 掉了但摘要更好读。我一般会固定一份 50 条的人工测试集,覆盖新闻、通知、会议纪要三类,每次改动跑一遍,不看总分,只看三条:有没有事实错误、有没有复读、开头两句能不能独立读懂。这三条过了,再看 ROUGE 做横向对比。
从那以后我每次交模型之前,都会先把这份 50 条测试集跑一遍,ROUGE 涨没涨是次要的,能一眼看出有没有退化成复读机才是关键。希望帮到你。
本文还有配套的精品资源,点击获取