简介:基于深度学习的文本摘要自动生成项目,依托Transformer模型解决长文本的关键信息提取与摘要生成问题,是面向本科毕业设计的完整实践方案。压缩包共34个文件,以18个Python脚本为核心,涵盖数据预处理、模型构建、训练评估、beam search解码等完整流程;另有Shell脚本、配置文件和词表等,便于一键运行与复现。项目还包含测试用例和CI脚本,支持Docker部署,降低环境配置门槛,并提供web演示接口,可直观查看摘要生成效果。已有3569人学习浏览。通过研读源码和实际操作,可掌握分词建表、序列化输入、自注意力机制、编码器-解码器结构及ROUGE/BLEU评价指标的使用,还能熟悉PyTorch或TensorFlow框架下的训练调试细节,并理解批处理、优化器配置等工程实现,为毕业设计答辩和后续NLP深入研究打下扎实基础。
1. 文本摘要没你想的那么“自动”:一个本科毕设的真实复杂度
做个基于深度学习的文本摘要自动生成系统,听起来就是“喂一堆文章,模型自己输出几句话”。等你真把需求拆开,就会发现这行字背后至少压着四件事:选哪种摘要方案、把数据洗干净、让模型在有限算力下跑起来、再用一套能说服答辩老师的评估方式证明它有效。自然语言处理这个方向里,文本摘要属于“入门容易、做深极难”的题目,本科毕设做到及格线不难,想做到有结论、有对比、有分析,就需要把每一步的边界都想清楚。
这篇文章适合两类人:一类是正在开题、想选文本摘要但还没确定技术路线的学生;另一类是已经跑通了最小demo、卡在“loss降了但输出没法看”的实践者。我会按自己做项目的习惯,从选型讲到数据管线,再给一个基于PyTorch的最小生成式摘要实现,最后用排障记录和评估技巧收尾。这里没有玄学,只有每一步的取舍和参数依据。
2. 先分清要做哪种摘要:抽取式与生成式的选型,以及它的核心原理
2.1 抽取式为什么是毕设的安全起点
文本摘要自动生成在落地时被分成两条路线:抽取式和生成式。抽取式不做“创作”,它从原文里挑出重要的句子拼成摘要。生成式则是让模型“重写”一段话,词不一定要原封不动。本科毕设选哪条,直接决定你后面所有工作量。
抽取式的优势是逻辑简单、可控性强。你只需要一个句子重要性打分模型,常见做法是把句子表示成向量,再用一个二分类或回归头打分,最后按分数挑Top-K句。这类方案可以用TextRank这种无监督方法先做基线,再换用BertSentenceExtractor之类的有监督模型。它的可解释性也好——答辩时你能指出“摘要里第三句来自原文第二段”,老师一听就懂。
但抽取式有个天花板:它永远无法产生原文里没有的词。如果一句话在原文里是分散的,抽取式只能整句拿到,摘要的连贯性和信息密度都不如生成式。毕设里如果你的指导老师要求“体现深度学习的价值”,抽取式很容易被质疑“这不就是排序模型吗”。所以我的建议是,如果时间充足,最终目标还是要落在生成式上;如果只剩三周,抽取式足够让你顺利毕业。
2.2 生成式必备的编码-解码与注意力机制
生成式文本摘要的基本框架是Sequence-to-Sequence。编码器把整篇源文档压缩成一个语义向量序列,解码器逐个生成目标摘要的词。这个框架里,注意力机制是转折点——没有注意力的Seq2Seq,面对长文档时会把信息压丢,生成结果几乎不可读。
注意力机制可以通俗理解为,解码器在生成第t个词时,会回头去看编码器的每个隐藏状态,并分配一个权重。权重大的位置对应原文里对当前这个词最有帮助的片段。对文本摘要来说,注意力不仅提升质量,还能让你可视化“模型在看哪里”,这个图放进毕设论文里很加分。
到了2017年之后,Transformer结构逐渐取代了LSTM。Transformer用self-attention直接建模任意两个位置的关系,解决长距离依赖比LSTM更高效。做毕设时,我不建议直接从头实现Transformer再预训练,那是几个月的工作量。更合理的路径是:用PyTorch实现一个基于LSTM的经典Bahdanau Attention模型作为基准,再加载一个预训练的BART或T5轻量版做对比。这样论文里既有传统的“我亲手实现的模型”,又有“我微调了预训练模型”,深度和广度都有了。
2.3 评估指标不是BLEU就够了:ROUGE的计算逻辑
很多人第一次听到“文本摘要评估”会觉得用BLEU就行,毕竟机器翻译也用它。但BLEU更看重n-gram精确率,也就是生成结果里有多少词和参考摘要重合;而摘要场景里,召回率同样重要——原文里的关键信息如果没被摘出来,就算措辞再漂亮也是漏报。所以领域里最常用的是ROUGE指标,ROUGE-L基于最长公共子序列,ROUGE-N基于n-gram重叠,各有侧重。
ROUGE-L的计算并不复杂:对模型生成的摘要和参考摘要,求它们的最长公共子序列长度,再算F1值。这个指标能捕捉句子的词序信息,又不像ROUGE-2那样对词汇变化太敏感。毕设里你至少需要报告ROUGE-1、ROUGE-2和ROUGE-L,三个值一起看才有意义——如果你只提ROUGE-L,答辩老师可能会问“为什么不用ROUGE-2”,你要能说出“ROUGE-2对同义词替换更敏感”这类理由。
实现ROUGE不建议自己造轮子,直接用评估库即可。常用的是rouge-score这个Python包,它支持中文和英文,而且计算方式和论文对齐。安装后写一个小脚本,把每篇测试样本的生成摘要和参考摘要都算一遍,最后输出宏平均。注意:ROUGE对空白和标点敏感,算之前要做统一的分词或分字操作,否则数字会异常偏低。
3. 让模型吃上干净数据:CNN/Daily Mail 数据集的预处理管线
3.1 原始数据长什么样,要处理成什么
做生成式摘要,最常用的英文数据集是CNN/Daily Mail,每篇样本包含article(正文)和highlights(摘要)。这个数据集原本用于阅读理解,后来被借用到摘要任务,所以文本里有大量实体替换的符号@entity1,使用时一般要还原或去掉。中文场景可以用LCSTS,它是微博摘要数据集,质量尚可但噪声偏多。本科毕设建议先用英文数据集跑通,再换中文验证泛化性。
拿到原始数据后,第一件事是统一格式。常见做法是写一个预处理函数,把article压缩成连续文本,去掉过长的段落,把multiple blanks替换成单个空格。这里有个参数值得注意:源文档最大长度。新闻原文通常上千词,但受限于显存和你用的模型结构,必须截断。我用LSTM时通常设max_src_len=512,注意512词不是字符;如果发现很多样本被截掉关键信息,再提升到768,同时观察显存。
处理后的数据要存成什么格式?常见做法是每一行一个JSON对象,字段包括id、source、target。这样后续可以用datasets库直接加载,也方便断点重跑。我一般会额外存一份统计信息,比如长度分布,用来决定截断阈值。
import json import re def clean_text(text, max_len=512): # 统一空格、去掉换行里的多余空白 text = re.sub(r"\s+", " ", text.strip()) # 简单截断到最大长度 return text[:max_len] def build_jsonl(raw_path, out_path, max_src_len=512, max_tgt_len=128): with open(raw_path, encoding="utf-8") as fin, open(out_path, "w", encoding="utf-8") as fout: for line in fin: item = json.loads(line) src = clean_text(item["article"], max_src_len) tgt = clean_text(item["highlights"], max_tgt_len) if len(src.strip()) < 50 or len(tgt.strip()) < 5: continue # 太短的数据没有训练价值 fout.write(json.dumps({"id": item["id"], "source": src, "target": tgt}, ensure_ascii=False) + "\n")这段代码的关键是continue条件:丢弃源文太短或摘要太短的样本。源文不足50词,摘要模型学的只是短句拼接,没意义;摘要不足5词,loss里几乎全是位置信息,模型容易过拟合到“输出很短”。截断参数要根据你的数据分布调,如果数据本来就长,max_src_len可以提到768,但不要拍脑袋设成1024——LSTM处理1024词的复杂度会翻倍,训练时间可能直接劝退。
3.2 构建词表、截断与填充:max_len怎么定
数据清洗完,下一个关键动作是构建词表。这里有个常见误区:直接用全量词表。实际应该按词频截断,设定vocab_size=30000或50000,把出现次数低于阈值的词替换成<unk>。对文本摘要来说,生僻的人名和地点没有太多语义贡献,保留它们反而让embedding矩阵过大。这个阈值就是min_freq=3,你可以统计后决定,一般3到5比较合理。
构建词表时还要预定义几个特殊token:<pad>、<bos>、<eos>、<unk>。解码器生成时以<bos>开始,直到生成<eos>或达到最大长度。后面训练循环里会用这些token做掩码。
from collections import Counter def build_vocab(tokenized_lines, vocab_size=30000, min_freq=3): counter = Counter() for line in tokenized_lines: counter.update(line) # 过滤掉低频词 vocab = {"<pad>": 0, "<bos>": 1, "<eos>": 2, "<unk>": 3} idx = 4 for word, freq in counter.most_common(vocab_size - 4): if freq < min_freq: continue vocab[word] = idx idx += 1 return vocabvocab_size和min_freq是一对需要联动的参数。vocab_size设得大,词表能覆盖更多词,但embedding层参数量变大;min_freq设得高,词表更干净,但更多的词会被退化成<unk>。一个比较保守的做法是:先看数据里的词汇总量,如果总量不到5万,就设vocab_size=30000;如果总量很大,再降min_freq而不是拔高vocab_size。原因是词频超低的词放在词表里,模型大概率学不到它们的有效embedding,还容易造成训练后期显存不必要的占用。
3.3 用PyTorch DataLoader组织训练批次
预处理做完,数据要变成张量才能进模型。这个环节最容易被忽略的坑是:批次不齐导致填充过多。假设一批里有两条样本,一条200词,一条400词,如果直接填充到400,短的样本一半位置都是<pad>,注意力机制会花大量权重去关注这些无效位置。应对办法是按长度排序,在同一个batch内限制长度差距,或者使用padding策略配合pack_padded_sequence。
PyTorch里最简单的处理是自定义collate_fn。它会接收一个batch的样本列表,负责把文本转成张量,做padding。我这里给出一个直接能用的版本:
import torch from torch.utils.data import Dataset, DataLoader class SummarizationDataset(Dataset): def __init__(self, jsonl_path, vocab, max_src_len=512, max_tgt_len=128): self.samples = [] self.vocab = vocab self.max_src_len = max_src_len self.max_tgt_len = max_tgt_len with open(jsonl_path, encoding="utf-8") as f: for line in f: item = json.loads(line) src_ids = [vocab.get(w, vocab["<unk>"]) for w in item["source"].split()[:max_src_len]] tgt_ids = [vocab["<bos>"]] + [vocab.get(w, vocab["<unk>"]) for w in item["target"].split()[:max_tgt_len]] tgt_ids = tgt_ids[:-1] # 返回给loss时每个位置预测下一个词 self.samples.append((src_ids, tgt_ids)) def __len__(self): return len(self.samples) def __getitem__(self, idx): return self.samples[idx] def collate_fn(batch, pad_idx=0): src_ids = [item[0] for item in batch] tgt_ids = [item[1] for item in batch] src_len = torch.tensor([len(x) for x in src_ids]) tgt_len = torch.tensor([len(x) for x in tgt_ids]) src_padded = torch.nn.utils.rnn.pad_sequence([torch.tensor(x) for x in src_ids], batch_first=True, padding_value=pad_idx) tgt_padded = torch.nn.utils.rnn.pad_sequence([torch.tensor(x) for x in tgt_ids], batch_first=True, padding_value=pad_idx) return src_padded, tgt_padded, src_len, tgt_len这里的关键参数是max_src_len和max_tgt_len,它俩直接决定你对“长文档摘要”的覆盖程度。max_tgt_len设成128,意味着生成摘要最多128个token——新闻摘要一般够用;如果你做的是论文摘要,可能要192甚至256。另外注意tgt_ids = tgt_ids[:-1]这行:训练时解码器输入是“已生成的部分”,输出要错开一位,让第i个token去预测第i+1个。这是很多新手容易漏掉的地方。
DataLoader的使用没有神奇之处,但shuffle=True和drop_last=True两个参数值得写进毕设里。drop_last=True防止最后一个batch尺寸太小导致批归一化抖动;batch_size则要结合显存调整,LSTM模型通常能开32或64,Transformer则建议从16试起。
4. 用PyTorch跑通一个生成式摘要的最小实现
4.1 模型骨架:从LSTM到Transformer,毕设选哪个
先做一个诚实的判断:如果你的毕设周期是3到4个月,且没有GPU服务器,建议主力模型用LSTM加注意力。它的参数量小,CPU也能训练(但很慢),最重要的是网络结构直观,前向传播、反向传播、梯度消失这些概念都能在答辩时讲清楚。Transformer虽然效果好,但是如果你从头训练,效果未必赶得上微调一个预训练模型,而且调参难度更高。
我推荐的做法是双轨并行:自己实现一个LSTM Seq2Seq模型作为主结果,再加载一个较小的预训练模型做对比。预训练模型候选很多,但考虑到本科毕设的硬件条件,facebook/bart-base或t5-small是比较合适的,参数量在1亿级别,微调一轮需要的时间可控。如果你机器只有8G显存,t5-small更稳妥。
自己写的LSTM模型长这样:编码器用双向LSTM,把正反向隐藏状态拼接起来作为每个位置的表征;解码器用单向LSTM,但需要用注意力机制加权编码器所有时刻的输出。注意力打分函数有加性(Bahdanau)和点积(Luong)两种,毕设里实现加性注意力即可,因为它更容易在代码里看清每一步在做什么。
import torch import torch.nn as nn import torch.nn.functional as F class BahdanauAttention(nn.Module): def __init__(self, hidden_size): super().__init__() self.W_h = nn.Linear(hidden_size, hidden_size, bias=False) self.W_s = nn.Linear(hidden_size, hidden_size, bias=False) self.v = nn.Linear(hidden_size, 1, bias=False) def forward(self, decoder_hidden, encoder_outputs, src_mask): # decoder_hidden: [batch, hidden_size] # encoder_outputs: [batch, src_len, hidden_size] score = self.v(torch.tanh(self.W_h(encoder_outputs) + self.W_s(decoder_hidden).unsqueeze(1))) score = score.squeeze(-1) # [batch, src_len] # 把padding位置设为负无穷,避免注意力分配到无效位置 score = score.masked_fill(src_mask == 0, -1e9) attn_weights = F.softmax(score, dim=-1) context = torch.bmm(attn_weights.unsqueeze(1), encoder_outputs).squeeze(1) return context, attn_weights这段代码里最容易被忽略的就是src_mask。如果不做mask,模型会把注意力分给<pad>,训练时看似没问题,但推理时一旦遇到长句,摘要里会出现很多莫名词汇。masked_fill的-1e9不是随便写的,它要足够小,让softmax在指数运算后落到接近0,但也不能太小到引发精度溢出。-1e9是个安全选择。
4.2 训练循环的写法:teacher forcing、掩码与loss
训练生成式摘要时,一个绕不开的技术是teacher forcing:解码时每一步输入实际使用参考摘要的前一个token,而不是用模型自己生成的那个token。这样做收敛更快,但也有代价——训练时模型没见过自己的错误输出,推理时一旦走一步错,后面会连环错。解决办法是计划采样(scheduled sampling),比如训练到第10个epoch后以20%概率喂自己的输出。毕设里可以不做这个,但论文里最好提一句“我用了teacher forcing,并讨论了它带来的暴露偏差”。
loss计算用的是交叉熵,但只计算非<pad>位置。这里的坑在于nn.CrossEntropyLoss默认会忽略ignore_index=-100,你可以直接传ignore_index=0。不过更稳妥的做法是自己做mask,避免未来修改padding值导致loss计算错误。
def compute_loss(logits, tgt_padded, tgt_len, pad_idx=0): # logits: [batch, tgt_len, vocab_size] logits = logits[:, :-1, :].contiguous() targets = tgt_padded[:, 1:].contiguous() loss_fct = nn.CrossEntropyLoss(ignore_index=pad_idx) loss = loss_fct(logits.view(-1, logits.size(-1)), targets.view(-1)) return loss # 训练循环中的核心片段 model.train() optimizer.zero_grad() src_padded, tgt_padded, src_len, tgt_len = batch encoder_outputs, encoder_hidden = model.encode(src_padded, src_len) decoder_outputs, attn_weights = model.decode(encoder_outputs, encoder_hidden, tgt_padded[:, :-1], tgt_len, src_mask) loss = compute_loss(decoder_outputs, tgt_padded, tgt_len) loss.backward() # 梯度裁剪是个关键参数,LSTM训练必须加 torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) optimizer.step()为什么是logits[:, :-1, :]和targets[:, 1:]?因为解码器在时间步t的输入是参考摘要的第t个token,输出应该预测第t+1个。如果你按我上一章Dataset里的做法,tgt_ids已经截成了“不包含最后一个token”,那么这里的错位可以省略,但要统一口径。我习惯保留模型输出的完整序列再做错位,这样改代码时更灵活。
clip_grad_norm_的max_norm=5.0是LSTM训练的常规设置。梯度裁剪不能保证一定不梯度爆炸,但它能把梯度的L2范数限制在5以内,避免一次坏batch把参数冲到NAN。如果你发现loss突然变成nan,先看是不是没加这个。
4.3 推理时要做的改进:beam search与长度惩罚
训练完模型,推理阶段不能用teacher forcing,必须让模型自己一个词一个词地生成。最简单的贪心解码是一步选概率最大的词,但这样做容易陷入局部循环,比如反复生成“的”字。更常用的beam search保留每步概率最高的Top-B个序列,B通常在3到5之间。
beam search不是只有宽度参数的。还有两个细节:一是长度惩罚,防止解码器偏向短句;二是覆盖惩罚,防止重复引用同一个上下文。长度惩罚的作用因子alpha一般取0.6到1.0,贝塔可以参考长度归一化。你可以先走最简单的版本,效果不够再加惩罚项。
def beam_search_decode(model, src_padded, vocab, beam_size=4, max_len=128, alpha=0.6): # 只处理batch=1的情况,便于演示 encoder_outputs, encoder_hidden = model.encode(src_padded, [src_padded.size(1)]) init_tokens = torch.tensor([[vocab["<bos>"]]]) beams = [(0.0, init_tokens, encoder_hidden, None)] completed = [] for _ in range(max_len): new_beams = [] for score, tokens, dec_hidden, attn_prev in beams: logits, dec_hidden, attn = model.decode_step(encoder_outputs, dec_hidden, tokens[:, -1]) log_probs = F.log_softmax(logits[:, -1, :], dim=-1) top_k = torch.topk(log_probs, beam_size) for i in range(beam_size): token = top_k.indices[0, i].unsqueeze(0) new_token_seq = torch.cat([tokens, token], dim=-1) new_score = score + top_k.values[0, i].item() if token.item() == vocab["<eos>"]: completed.append((new_score, new_token_seq)) else: new_beams.append((new_score, new_token_seq, dec_hidden, attn)) if not new_beams: break # 按累计log概率排序,取前beam_size个 new_beams.sort(key=lambda x: x[0], reverse=True) beams = new_beams[:beam_size] if completed: completed.sort(key=lambda x: x[0], reverse=True) best = completed[0][1] else: beams.sort(key=lambda x: x[0], reverse=True) best = beams[0][1] # 删除<bos>和<eos>,把id映射回词 result = [vocab.idx2token[t] for t in best[0].tolist() if t not in [vocab["<bos>"], vocab["<eos>"]]] return " ".join(result)这段代码是教学级演示,实际工程里要加长度惩罚:每个beam的score还要除以(len(sequence) ** alpha),避免短句得分占便宜。另外,beam search的维度是序列级,不是词级,所以每步都要对每个beam做top-k,然后全排序。其中model.decode_step是我自己写的接口,实际模型里要保留解码器的单步状态。很多新手卡在这里的是dec_hidden的更新,LSTM的hidden state和cell state要一起传递,返回时也要用元组。
5. 毕设避坑:训练不收敛、显存爆炸、摘要全是重复词的排障记录
5.1 现象:loss下降但ROUGE不动
这是最典型的“假收敛”。训练过程中交叉熵一直在降,但用验证集算ROUGE,数值始终在30以下徘徊。原因通常是评估阶段和解码阶段不一致:训练时老师强迫(teacher forcing)用的是参考词,模型只要模仿得差不多,loss就能降;但推理时模型自由生成,一步错步步错。
另一个常见原因是词表里<unk>太多。如果测试集里很多词没进词表,模型就会生成大段“unkunkunk”。解决方法是回滚到数据那里,把min_freq从3降到1,或者把vocab_size从30000提到50000。别以为这样是作弊——文本摘要允许未登录词原样复制,很多学术模型也用copy机制,但毕设里最经济的做法是扩大词表之后,ROUGE一般能涨两三个点。
5.2 现象:显存不够或训练太慢
如果显存只有4G,用512的源长度和128的目标长度训练LSTM,batch_size能开16就不错了。遇到OOM,第一反应不应该是换服务器,而是检查是不是padding太凶。假设一个batch里最长样本是512,其余平均150,那70%的显存都在处理<pad>。这就是我前面强调长度排序的原因。你可以写一个batch_sampler,按源长度排序后每个batch内长度差尽量小。这个优化能让有效batch扩大一倍。
慢的问题往往不是模型大,而是数据加载太慢。文本转token的split()每次都要跑一遍,即便做过一次。建议预处理阶段直接把token id存成numpy数组或parquet文件,训练时直接加载。懒加载的Dataset表达没问题,但它每次访问都重新算,就会拖慢训练。另一个优化是torch.backends.cudnn.benchmark=True,对固定输入尺寸的卷积有加速,对LSTM不一定,你可以在不同环境试试。
5.3 现象:生成的摘要反复出现同一个词
这是解码器的老毛病,尤其出现在训练不足或注意力没有覆盖惩罚时。模型在某个时间步输出了“人工智能”,之后每步都倾向于继续输出“人工智能”,因为它的隐状态已经被拉到那个语义区域。解决套路有三个。第一,推理时限制重复n-gram,同一词出现3次以上就不再进候选;第二,在loss里加一个重复惩罚项,比如生成位置与之前某位置的注意力分数过重叠时,给额外损失;第三,降低学习率,可能是模型在训练时就没学好“终止”信号,<eos>的概率一直上不去。
我实践中最有效的还是最后一条:训练时把<eos>当成普通词看待,很多代码会忽略<eos>的loss权重,导致模型永远学不会停。确认方法很简单,看训练集里target最后一词是<eos>的占比,以及模型在验证集beam search里是否经常提前停。我的经验是:对摘要任务,让<eos>出现在概率最高的位置需要成倍的训练样本,所以不要轻易删掉参考摘要里太短的样本,那些是学习<eos>的好素材。
5.4 现象:数据里有脏标签导致模型学奇怪规则
CNN/Daily Mail虽然是经典数据集,但它的摘要是有噪音的。部分样本的highlights只是一个词或一句话,实际是新闻网站的编辑标签,不是完整摘要。模型会学到“输出第一个句子的前半部分”这种偷懒策略。排查方法很简单:统计target长度分布,如果发现大量样本长度在10到20之间,那说明数据切分或清洗有问题。
解决方法是过滤。建议把target长度下限设到20或者30,源文长度下限设到100。如果过滤后数据量太少,比如不到1万条,那就别过滤,改成在训练时随机屏蔽过短样本。我偏向于宁可用1万条干净数据,也不用5万条噪音数据做本科毕设——一个能解释清楚的干净数据集,比一个玄学提升的混合数据集更适合写进论文。答辩时老师问“为什么数据量这么小”,你可以回答“我做了严格清洗,保证标签质量”,这比含糊其辞有说服力得多。
6. 让毕设从“跑通”到“有结论”:网格搜参、人工评测与消融实验的收尾技巧
6.1 用两组对照实验说明“注意力到底起了多大作用”
毕设答辩时,最扎心的问题往往是“你这个注意力机制是必需的么?去掉会怎样?”与其被问倒,不如自己先做消融实验。以LSTM模型为例,你可以训练三个版本:无注意力、加注意力、加注意力且换用更大的词表。注意这里每个版本都用同样的数据和超参数,只改一个变量,否则结论站不住脚。
对照结果的呈现建议用表格:模型版本、ROUGE-1、ROUGE-2、ROUGE-L、参数量。你会发现无注意力版本ROUGE-L可能只有28多,加注意力能到33以上。这个差距就是你论文里的核心贡献。再进一步,把注意力分布可视化并挑一两个典型样本放进论文附录,比一堆曲线直观得多。注意注意力可视化不是美观需求,而是解释性需求——你能指出“生成‘转折’这个词时,模型权重集中在原文第二段”,这个证词在答辩时几乎无懈可击。
6.2 人工评测标准与错误类型归类
ROUGE是硬指标,但它无法完全替代人工评测。本科毕设建议对随机抽取的50条测试样本做人工给分。给分维度可以是信息完整性、流畅度和忠实度,每个维度1到5分。信息完整性看是否覆盖核心信息,流畅度看语句是否通顺,忠实度看是否有原文没有的幻觉内容。这三项正好对应自动评估最弱的地方——幻觉。
人工评测最常见的问题是你自己评自己。所以最好找一个同班同学,你们互相评对方的模型输出,标准要提前写清楚。我当年吃过这个亏,自己评的摘要总觉得“语义还行”,别人一看就指出“这句话在原文里是两种解释,你写成了一种”。人工评测结果不需要做统计假设检验,只要把均值和每个维度的打分分布列出来,就能看出模型短板在哪里。比如信息完整性稳定4分,但流畅度只有2.5,那多半是解码器的生成长度失控或词表覆盖不够。这又能反哺到超参数调整,形成闭环。
最后说一个我自己一直坚守的习惯:每次训练完只保留最好的checkpoint,同时记录下来那一刻的所有超参数。文本摘要这个方向特别容易“之前跑的好好的,改个学习率就翻车”,如果没有实验记录,等到写论文时会焦虑到怀疑人生。开一个CSV文件,把日期、模型名、batch_size、学习率、词表大小、ROUGE-L数值一行一行记下来,两天后你就知道怎么选了。这不是额外负担,是让你后悔有药可吃。希望这篇实操笔记能帮你在毕设路上少走几段弯路,把“基于深度学习的文本摘要自动生成”做成一个自己讲得清楚、别人也信服的项目。
本文还有配套的精品资源,点击获取