简介:基于文本排序与序列到序列模型并结合图形界面库设计的一体化文章摘要、标题与关键词辅助生成系统,面向自然语言处理学习者和开发者,适用于论文、新闻等长文本的快速信息抽取与自动摘要。资源包包含全部Python工程源码与训练数据集,共一千九百七十二个文件,整体约三百二十五兆字节,主要文件类型有Python脚本、界面配置文件、数据表以及动态链接库、时区数据等运行依赖,目录结构清晰完整。目前已有二百五十二人学习下载。项目完整覆盖数据预处理、抽取摘要、模型搭建与编译、模型训练与保存、图形界面开发和应用封装六大模块,并附有可直接执行的文件,能够在个人电脑上运行,也便于二次改进,是学习自然语言处理文本生成与桌面应用开发的良好实践项目。
1. 摘要工具轮不到自己做?TextRank+Seq2Seq+PyQt5 这套组合的适用边界
先给一个反直觉的判断:在调用大模型 API 做摘要已经极其便宜的今天,自己从头搭一套 TextRank + Seq2Seq + PyQt5 的本地摘要系统,依然不是重复造轮子。它解决的是内容安全可控、离线可用、训练数据私有这三个真问题——你不想把内部文档交给外部接口,又希望每天批量处理几百篇稿子并自动生成标题关键词。这套系统的定位很明确:抽取式打底(TextRank 负责关键词和候选标题),生成式补全(Seq2Seq 负责把抽取结果扩展成自然语言摘要),PyQt5 负责让运营同学不碰命令行就能点鼠标用。适合团队里已经有 Python 基础、有一批未标注的中文文本、愿意花一周到两周做数据清洗和训练调参的工程师。本文按工程落地顺序拆开讲:三个组件各解决哪一段、代码怎么组织、训练数据从哪来、以及最容易翻车的五个位置。
2. TextRank 做关键词和标题:无监督抽取的落地实现
2.1 TextRank 为什么适合做第一道工序
TextRank 的核心思想继承自 PageRank:把一篇文档里的词当成图的节点,如果两个词在一个窗口大小内共现,就给这两个节点连一条边。迭代若干轮之后,节点的得分会收敛——被越多高权重节点指向的词,得分越高。这个办法不需要标注数据,不需要训练,对垂直领域文本也能稳定输出结果,非常适合放在整个系统的第一步:先把文章里的核心实体、主题词捞出来,再喂给下游的 Seq2Seq 做生成。
比起直接用 Seq2Seq 从头生成关键词,抽取式结果有两个不可替代的优势。第一是可控性:抽取出来的词一定来自原文,不会出现模型自创一个原文里完全不存在的人名或机构名;第二是可解释性:编辑拿到关键词列表,扫一眼就知道模型"看到"了什么。反过来,纯抽取式的问题也很明显——关键词之间没有主语、谓语、宾语的连接,拼不成一句通顺的摘要,这才需要 Seq2Seq 接手做句子级别的生成。先抽后生成,本质上是把"开放生成"约束到"有依据的生成",OOV(未登录词)和幻觉问题会被压住不少。
选 TextRank 还有一个工程上的实际理由:它对 Python 环境的依赖极轻。核心逻辑用标准库加 jieba 就能跑,不需要引入大型深度学习框架,哪怕读者的机器上没有 GPU,这一部分也能毫秒级完成。后面接 Seq2Seq 时,TextRank 的结果还可以作为额外的特征拼进编码器输入,让生成模型知道该往哪个方向写。
2.2 关键词抽取代码:jieba 分词 + TextRank 打分
下面给出一个最小实现。整个逻辑自己管理图结构和迭代打分,不依赖 networkx,方便读者控制细节。
import jieba import jieba.posseg as pseg from collections import defaultdict import math STOP_WORDS = {"的", "了", "在", "是", "我", "有", "和", "就", "不", "人", "都", "一", "一个", "上", "也", "很", "到", "说", "要", "去", "你", "会", "着", "没有", "看", "好", "自己"} def build_window(words, window_size=5): """按滑动窗口构建词共现关系 window_size 表示窗口内最大词数,太大会引入无关噪声, 太小则丢失长距离搭配,中文场景 5~7 比较稳 """ windows = [] if len(words) <= window_size: windows.append(words) else: for i in range(len(words) - window_size + 1): windows.append(words[i:i + window_size]) return windows def textrank_keywords(text, top_k=10, window_size=5, damping=0.85, max_iter=50): # 只保留名词、动词、形容词,过滤掉纯停用词 words = [] for word, flag in pseg.cut(text): if word.strip() and word not in STOP_WORDS: if flag.startswith("n") or flag.startswith("v") or flag.startswith("a"): words.append(word) if not words: return [] windows = build_window(words, window_size) # 邻接矩阵用 dict 存,避免稀疏矩阵占内存 graph = defaultdict(lambda: defaultdict(float)) for window in windows: for i, w1 in enumerate(window): for j, w2 in enumerate(window): if i != j: graph[w1][w2] += 1.0 # 初始化得分并迭代 scores = {word: 1.0 for word in graph.keys()} for _ in range(max_iter): new_scores = {} for word, neighbors in graph.items(): s = 1.0 - damping total_weight = sum(neighbors.values()) if total_weight == 0: new_scores[word] = s continue for neighbor, weight in neighbors.items(): neighbor_total = sum(graph[neighbor].values()) if neighbor_total == 0: continue s += damping * weight / total_weight * scores[neighbor] * (weight / neighbor_total) new_scores[word] = s # 判断收敛,避免不必要的迭代 diff = sum(abs(new_scores[w] - scores[w]) for w in scores) scores = new_scores if diff < 1e-6: break ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:top_k] return [word for word, score in ranked] if __name__ == "__main__": sample = "华为发布新款折叠屏手机,搭载自研芯片,续航提升百分之三十,售价较上一代降低五百元。" print(textrank_keywords(sample, top_k=5))说明一下几个关键参数。damping对应 PageRank 的阻尼系数,标准取 0.85,它在控制随机跳转概率——越大,越看重全局图结构,越小,越局部。window_size=5对中文新闻类文本是一个经验折中:窗口过大,"华为"和"售价"这种远距离词也会被建边,削弱主题词的权重;窗口过小,像"折叠屏手机"这种三个词组成的复合词会被拆开。pseg.cut返回带词性的结果,过滤掉形容词以外的干扰项是为了不让"大幅""显著"这类修饰词占据关键词名额。
实际跑一遍会发现,不同领域文本对停用词表的敏感度很高。上面的STOP_WORDS只有十几个词,金融类文本里"万元""同比"大概率会挤进关键词,需要按业务语料扩充。这属于数据工程的范畴,可以在第 5 章统一排查。
2.3 从关键词到标题:模板拼接与去重
关键词抽出来之后,标题不能直接把关键词用空格拼一起,那样编辑没法用。常见的做法是设计几种标题模板,结合关键词的位置信息和词性做填充。我一般会按下面的规则处理:优先找全文第一个句子里的主语(一般是最早出现的 top 关键词),再找一个谓语动词和一个宾语名词,套进"[主语]:从[关键词A]看[关键词B]"这类结构。如果关键词之间出现重复词根,比如"折叠屏"和"折叠屏手机",用最长匹配去重。
这段逻辑虽然简单,却是决定编辑是否愿意用这个系统的关键。很多人把精力花在模型上,忽略了模板带来的格式约束。实际上,TextRank 输出的 top_k 关键词如果直接当标题,会出现"华为 手机 续航 价格"这种不像人话的结果,而模板法至少保证标题主干符合新闻语感。当然,纯模板也有天花板——遇到口语化严重的文本,模板拼出来的标题会显得僵硬,这时候需要手动把模板产生的候选标题一起交给 Seq2Seq 做改写,而不是直接输出。这部分衔接在下一章展开。
3. Seq2Seq 做摘要生成:从数据集准备到模型训练
3.1 生成式摘要和抽取式摘要的本质区别
TextRank 输出的是原文里真实存在的词和句子片段,这属于抽取式;Seq2Seq 要做的是生成式——编码器把原文语义压成一个向量序列,解码器逐个词预测,最终产出一句原文里不一定存在、但语义上忠实于原文的摘要。这个区别决定了训练数据的形式:生成式摘要需要"原文-摘要"配对数据,而不是原文和关键词列表。
为什么摘要任务要用 Seq2Seq 而不是直接上 Transformer?在数据量有限(几千到几万对)的情况下,Seq2Seq 结构简单、收敛稳定、显存占用低,加一个 attention 机制就足够处理中等长度文本。读者如果只有 CPU 或一块普通 GPU,洗数据一周、训练十几个小时就能得到一个能用的模型,这是工程性价比的选择。标题里既然写了 Seq2Seq,我们就按这个路线把流程打通;等数据超过十万对,再考虑换预训练模型也不迟。
3.2 训练数据集:从哪里拿、怎么清洗、格式长什么样
中文摘要数据集公开可用的主要有三类:LCSTS(哈工大发布的中文短文本摘要数据,约 240 万对,但原始文件较大,需要按协议申请)、THUCNews 的子集(新闻标题和正文天然成对,可以自己抽取)、以及各家的比赛脱敏数据。如果读者做的是垂直领域(法律、医疗、财经),公开数据集覆盖度不够,常见做法是拿历史稿件 URL 里的title字段和content字段直接构造配对。这个方案成本最低,但要注意 URL 里的标题有时是运营后改的,和正文内容相关性不稳定,清洗时最好过滤掉标题和正文重复度过低或过高的样本。
清洗这一步直接决定模型上限。我常用的清洗规则列成一张表:
| 清洗项 | 规则 | 理由 |
|---|---|---|
| 长度过滤 | 正文 50~500 字、摘要 5~50 字 | 太短无信息量,太长超出模型处理能力 |
| 重复度过滤 | 摘要与正文的字面重复率低于 90% | 纯抽取样本会让模型学会"抄原文" |
| 符号过滤 | 去掉连续感叹号、火星文、全角空格 | 避免模型学会输出噪声 |
| 标题复用过滤 | 丢弃标题和正文完全一样的样本 | 消除毫无难度的样本膨胀训练集 |
训练集格式最朴素的就是两列 TSV:第一列正文,第二列标准摘要。不需要做成 JSON,TSV 读取快、便于排查坏行,配合pandas或原生csv模块都很顺手。分词粒度上,中文场景按字切分往往比按词切分更稳——按词切分依赖 jieba 质量,且词表容易膨胀到 5 万以上;按字切分词表只需 4000~6000 字,OOV 少,模型也能隐式学到组词规律。后面代码按字级处理。
3.3 训练一个可用的 Seq2Seq:模型结构、损失函数与超参数
下面这段代码给一个带 attention 的最小训练框架。模型层面我选择单层 GRU 编码器和单层 GRU 解码器,刻意不用 LSTM,因为 GRU 参数少、收敛更快;attention 用加性注意力(Bahdanau),它对短文本摘要的效果比点积注意力更稳定。
import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import Dataset, DataLoader class EncoderGRU(nn.Module): def __init__(self, vocab_size, embed_size, hidden_size): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_size, padding_idx=0) self.gru = nn.GRU(embed_size, hidden_size, batch_first=True) def forward(self, x): embedded = self.embedding(x) # (batch, seq_len, embed_size) outputs, hidden = self.gru(embedded) return outputs, hidden # outputs 供 attention 使用 class Attention(nn.Module): def __init__(self, hidden_size): super().__init__() self.attn = nn.Linear(hidden_size * 2, hidden_size) self.v = nn.Linear(hidden_size, 1, bias=False) def forward(self, decoder_hidden, encoder_outputs, mask): # decoder_hidden: (batch, hidden_size) # encoder_outputs: (batch, seq_len, hidden_size) seq_len = encoder_outputs.size(1) decoder_hidden_expanded = decoder_hidden.unsqueeze(1).expand(-1, seq_len, -1) combined = torch.cat((decoder_hidden_expanded, encoder_outputs), dim=2) energy = self.v(torch.tanh(self.attn(combined))).squeeze(2) # (batch, seq_len) energy = energy.masked_fill(mask == 0, -1e9) return torch.softmax(energy, dim=1) class DecoderGRU(nn.Module): def __init__(self, vocab_size, embed_size, hidden_size, max_len=30): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_size, padding_idx=0) self.gru = nn.GRU(embed_size + hidden_size, hidden_size, batch_first=True) self.attention = Attention(hidden_size) self.fc = nn.Linear(hidden_size, vocab_size) self.max_len = max_len def forward(self, decoder_input, decoder_hidden, encoder_outputs, mask, teacher_forcing_ratio=0.8): outputs = [] for t in range(self.max_len): # 上一步输出作为当前输入(推理时用预测词,训练时按比例用真实词) embedded = self.embedding(decoder_input) # (batch, 1, embed_size) attn_weights = self.attention(decoder_hidden.squeeze(0), encoder_outputs, mask) attn_applied = torch.bmm(attn_weights.unsqueeze(1), encoder_outputs) # (batch, 1, hidden_size) gru_input = torch.cat((embedded, attn_applied), dim=2) output, decoder_hidden = self.gru(gru_input, decoder_hidden) pred = self.fc(output.squeeze(1)) # (batch, vocab_size) outputs.append(pred) use_teacher = torch.rand(1).item() < teacher_forcing_ratio if use_teacher and self.training: # 这里简化:真实下一词从外部传入,完整版在训练循环中维护 target top1 = decoder_input # 占位,训练循环会维护真实 target else: top1 = pred.argmax(1).unsqueeze(1) decoder_input = top1 return torch.stack(outputs, dim=1) # (batch, max_len, vocab_size) # 超参数:字级词表 6000,embedding 128,hidden 256,batch 64 # 损失函数使用 NLLLoss(配合 log_softmax)或 CrossEntropyLoss # 优化器 Adam,lr 0.001,梯度裁剪 max_norm=5.0训练循环的关键点有四个:teacher_forcing_ratio控制训练时解码器有多大比例使用真实词语作为下一步输入,设成 0.8 起步、每个 epoch 衰减 0.1,可以防止模型过早依赖真实序列而推理时崩掉;mask必须把 padding 位置在 attention 里置为负无穷,否则模型会学到"关注空位",推理时摘要末尾出现乱码;梯度裁剪max_norm=5.0是针对 GRU 在长序列上梯度爆炸的常规手段;损失用CrossEntropyLoss,但要把 padding 位置的预测结果 ignore 掉,ignore_index=0。
这里给一个参数参考:字级词表 6000、embedding 128、hidden 256、batch 64、单层 GRU。用一块 8GB 显存的 GPU,训练 5000~10000 对样本,大约 30~50 分钟能收敛到可看的效果。如果只有 CPU,同样数据量可能要 3~5 小时,属于能接受的范围。注意,上面代码为了展示结构,在 teacher forcing 分支里做了简化,真正跑训练时需要把目标序列target按时间步切分,作为decoder_input传入,工程化版本建议把循环全部向量化并用 attention mask 一次性算完,避免串行 for 带来的性能损耗。
4. 把模型装进界面:PyQt5 封装与工程目录设计
4.1 PyQt5 界面设计的取舍:输入区、结果区、状态栏
界面的目标用户是编辑或运营,他们不关心 Python,只关心"粘贴文章、点按钮、出结果"。所以 PyQt5 界面做三个区域就够:上方是QPlainTextEdit作为输入区,存文本,禁用富文本以降低粘贴格式干扰;下方是QTextBrowser作为结果区,因为QTextBrowser支持 HTML,可以把关键词标黄、摘要加粗、候选标题分行展示,搜索结果里"pyqt5 显示 html"指的就是这个能力;底部一条QStatusBar显示推理耗时和当前状态。
布局上用QVBoxLayout纵向排布,窗口初始大小设为 900x700。输入区占比较高,因为文章普遍在 500 字以上,太矮了不方便编辑。两个按钮放在输入区和结果区之间,一个叫"生成摘要",一个叫"仅抽取关键词"。为什么拆两个按钮?因为 TextRank 是秒级返回,Seq2Seq 需要加载模型和推理,拆开让用户可以快速试关键词、需要摘要时才等模型。
4.2 加载模型与推理的线程处理:QThread 而不是卡死主线程
新手最容易犯的错是直接在按钮点击槽函数里加载模型和跑推理。PyQt5 是主线程跑事件循环,如果主线程被model.generate()占住,界面会冻结,拖动窗口都没反应,Mac 上还会弹"应用无响应"。解决方法是把模型加载和推理塞进QThread,通过信号把结果传回主线程。
from PyQt5.QtCore import QThread, pyqtSignal from PyQt5.QtWidgets import QApplication, QMainWindow, QPushButton, QPlainTextEdit, QTextBrowser, QVBoxLayout, QWidget def create_model(): """加载训练好的 checkpoint,返回模型和词表""" # 工程代码里这里写入 checkpoint 路径和加载逻辑 return None, None class GenerateWorker(QThread): finished = pyqtSignal(dict) failed = pyqtSignal(str) def __init__(self, text, mode="summary"): super().__init__() self.text = text self.mode = mode def run(self): try: # 真正耗时操作在这里执行 if self.mode == "keywords": keywords = textrank_keywords(self.text, top_k=8) result = {"keywords": keywords} else: # 加载或复用全局模型,推测输出 result = {"summary": "这里是模型的摘要输出", "title": "候选标题1\n候选标题2"} self.finished.emit(result) except Exception as e: self.failed.emit(str(e)) class MainWindow(QMainWindow): def __init__(self): super().__init__() self.input_edit = QPlainTextEdit(self) self.output_browser = QTextBrowser(self) self.gen_btn = QPushButton("生成摘要", self) self.key_btn = QPushButton("仅抽取关键词", self) self.worker = None self._init_ui() def _init_ui(self): container = QWidget() layout = QVBoxLayout(container) layout.addWidget(self.input_edit) layout.addWidget(self.gen_btn) layout.addWidget(self.key_btn) layout.addWidget(self.output_browser) self.setCentralWidget(container) self.gen_btn.clicked.connect(self.start_summary) self.key_btn.clicked.connect(self.start_keywords) def start_summary(self): text = self.input_edit.toPlainText().strip() if len(text) < 20: self.output_browser.setHtml("<p style='color:red'>输入太短,正文至少 20 字</p>") return self.worker = GenerateWorker(text, mode="summary") self.worker.finished.connect(self.show_result) self.worker.failed.connect(self.show_error) self.worker.start() def show_result(self, data): self.output_browser.setHtml( f"<b>关键词:</b>{data.get('keywords', [])}<br>" f"<b>摘要:</b>{data.get('summary', '')}<br>" f"<b>候选标题:</b>{data.get('title', '')}" )关键点在于GenerateWorker的run方法里不做任何 UI 操作,模型加载建议放到全局只做一次,不要每次点击都重新加载——一个 GRU 模型加载需要几百毫秒到几秒,用户点一次按钮等十秒会直接关掉应用。主窗口持有self.worker引用,是为了防止 Python 垃圾回收把 worker 回收掉导致信号不触发,这是 PyQt5 里非常隐蔽的坑。
4.3 工程目录组织:模型文件、词表、配置文件分开放
Pytorch 训练的 checkpoint 动辄几十 MB,词表文件几 KB,配置参数零散。我建议按下面这个目录结构组织整个工程:
article_summary_system/ ├── main.py # PyQt5 入口 ├── textrank_module.py # TextRank 关键词抽取 ├── seq2seq_module.py # 模型定义 + 推理封装 ├── data/ │ ├── train.tsv # 训练语料 │ ├── vocab.json # 字级词表 │ └── stopwords.txt # 领域停用词 ├── models/ │ └── checkpoint.pt # 训练好的 Seq2Seq 权重 └── config.py # 所有超参数集中管理config.py集中管理超参数是很多人忽略的工程习惯。训练时参数散在训练脚本里,部署时要找半天;集中后,训练脚本和推理脚本都从config.py读同一个hidden_size、vocab_size、max_len,才不容易出现"训练用 256 隐层、部署加载时用了 128"这种模型尺寸不匹配的错误。另外,models/目录和代码目录分开,是因为后续换模型只需要替换 checkpoint,不用动代码,方便做 A/B 测试。
5. 从训练到部署:遇到过的 5 个坑和排查记录
5.1 OOV 词太多导致摘要崩坏:词表裁剪与未登录词处理
现象:训练完模型,发现摘要里频繁出现[UNK],人名和机构名全部变成未知符号,摘要读不通。
原因:字级词表只留了 6000 字,一些低频字完全没进词表;更常见的是按词训练时,词表只有 3 万,专有名词被切碎后成为 OOV。字级情况好很多,但遇到生僻字照样中招。
解决:按字训练,词表留 8000 字左右,基本覆盖常用汉字;推理时对未知字直接替换成[UNK]并记录位置,做后处理时从原文把该位置的词回填。TextRank 结果里本来就有原文词,回填是最稳定的后悔药。
5.2 训练 loss 一直不降:学习率、Teacher Forcing 和梯度裁剪
现象:训练 20 个 epoch,loss 停在 6.5 左右纹丝不动,生成的结果全是标点符号。
原因:学习率太大导致 loss 震荡,或 teacher forcing ratio 一直是 0.8,模型过度依赖真实序列,推理时第一步错后面全错;还可能是梯度爆炸把参数推出合理区间。
解决:学习率从 0.001 降到 0.0005,加 cosine 衰减;teacher forcing ratio 每个 epoch 衰减 0.1,让模型逐步独立预测;梯度裁剪max_norm=5.0挂上。三个一起改,基本两三个 epoch 就能看到 loss 明显下降。
5.3 PyQt5 界面加载模型后卡死:模型加载位置和信号槽
现象:点击"生成摘要",窗口立刻变白,标题栏显示"未响应",几秒后恢复但用户已经疯了。
原因:模型加载和推理都放在主线程的槽函数里,阻塞了事件循环;或者 worker 线程被垃圾回收,信号发不出来,看起来像永远在跑。
解决:模型加载挪到程序启动时后台完成,推理放进QThread的run;主窗口持有 worker 引用,确保对象存活。另外,Worker 里不要直接调用self.setHtml,而是通过信号传数据,这是 PyQt5 跨线程操作 UI 的唯一安全路径。
5.4 GPU 显存溢出:batch size 和序列长度的关系
现象:训练时 batch 设 128,每跑到第二个 batch 就报CUDA out of memory。
原因:摘要任务的序列长度方差极大,一个 batch 里混着 300 字的正文和 500 字的正文,显存按最长的那个分配,导致浪费。
解决:按长度分桶(bucket)采样,同一个 batch 只放长度相近的样本;batch size 从 128 降到 64,观察显存占用;再不够就把正文截断到 300 字,摘要截断到 30 字。对短文本摘要任务,长度超过 300 字的正文保留后半部分信息增益很小。
5.5 jieba 分词结果影响 TextRank 质量:用户词典和停用词表
现象:同一篇文章,TextRank 抽出的关键词里混入大量"进行""实现""相关"这类高频动词,真正的领域词反而排不上去。
原因:领域术语被 jieba 切碎,比如"变分自编码器"被切成"变分""自""编码器";停用词表只有通用词,没有领域泛化词。
解决:给 jieba 加载用户词典,把业务词全量加进去,jieba.add_word("变分自编码器");扩展停用词表到几百个领域词,金融领域加"同比""环比""百分点",法律领域加"依法""规定""本院"。这一步是 TextRank 效果提升最直接的途径,比调阻尼系数明显得多。
6. 验证摘要质量:从困惑度到人工评价的落地做法
模型训练完不能只看 loss,loss 低不等于摘要可用。我一般分三层验证。第一层是代化指标,用 ROUGE-1、ROUGE-2 和 ROUGE-L 对比生成摘要和标准摘要的重合度,算完后和抽取式的 TextRank 拼接结果做对比——如果 Seq2Seq 的 ROUGE 连 TextRank 都不如,说明模型没学到东西,回去查数据清洗和训练参数。第二层是边界 case 测试,准备十篇不同领域的文章,比如财经、体育、娱乐各三篇,一篇口语化采访稿,逐一生成摘要,检查是否出现主语错误、张冠李戴、尾句截断。第三层是人工评价,让编辑打三个分:忠实度(摘要事实对不对)、信息量(有没有抄模板废话)、通顺度(语法是否流畅),每篇取平均分。三层的权重不一样,工程期以 ROUGE 为主,上线前以人工为主。
这里给一个务实的小技巧:把 TextRank 的关键词列表拼进 Seq2Seq 的输入,作为额外的提示前缀。具体做法是在正文开头加上<K> 关键词1 关键词2 </K>,训练时也用同样格式构造样本。这样生成的摘要会更贴近关键词主题,标题候选也会带出更多核心词。但要注意,如果输入太长会挤压正文的位置,建议关键词控制在五个以内,超过五个反而干扰注意力分布。
另外一个容易忽略的验证维度是推理耗时。GRU 序列生成是串行的,摘要长度 30 个字就要循环 30 次,在 CPU 上单条推理平均 1~2 秒。对 PyQt5 界面这个速度够用,但如果以后要接批量处理接口,就得考虑 beam search 换 greedy 解码,或者把 batch 推理打开,一次处理多篇提升吞吐。我的建议是先把 greedy 跑通,再实现一个beam_width=3的版本做对比——beam search 能明显提升通顺度,但推理时间会翻三倍,到底值不值,得用上面的人工评价分数说话。
在这套系统上栽过的最深跟头是数据清洗比模型结构更决定上限:第一次训练用 3 万对未清洗的稿子,模型学会了一句话模板"据报道……",换用 5000 对认真过滤的样本后,效果反而翻了一倍。以后再做类似工具,我会把一半时间留给数据,而不是急着调注意力机制。希望帮到你。
本文还有配套的精品资源,点击获取