TextRank+Seq2Seq+PyQt5:从零构建本地智能摘要与关键词提取系统 简介基于文本排序与序列到序列模型并结合图形界面库设计的一体化文章摘要、标题与关键词辅助生成系统面向自然语言处理学习者和开发者适用于论文、新闻等长文本的快速信息抽取与自动摘要。资源包包含全部Python工程源码与训练数据集共一千九百七十二个文件整体约三百二十五兆字节主要文件类型有Python脚本、界面配置文件、数据表以及动态链接库、时区数据等运行依赖目录结构清晰完整。目前已有二百五十二人学习下载。项目完整覆盖数据预处理、抽取摘要、模型搭建与编译、模型训练与保存、图形界面开发和应用封装六大模块并附有可直接执行的文件能够在个人电脑上运行也便于二次改进是学习自然语言处理文本生成与桌面应用开发的良好实践项目。1. 摘要工具轮不到自己做TextRankSeq2SeqPyQt5 这套组合的适用边界先给一个反直觉的判断在调用大模型 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_size5): 按滑动窗口构建词共现关系 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_k10, window_size5, damping0.85, max_iter50): # 只保留名词、动词、形容词过滤掉纯停用词 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(), keylambda x: x[1], reverseTrue)[:top_k] return [word for word, score in ranked] if __name__ __main__: sample 华为发布新款折叠屏手机搭载自研芯片续航提升百分之三十售价较上一代降低五百元。 print(textrank_keywords(sample, top_k5))说明一下几个关键参数。damping对应 PageRank 的阻尼系数标准取 0.85它在控制随机跳转概率——越大越看重全局图结构越小越局部。window_size5对中文新闻类文本是一个经验折中窗口过大华为和售价这种远距离词也会被建边削弱主题词的权重窗口过小像折叠屏手机这种三个词组成的复合词会被拆开。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第一列正文第二列标准摘要。不需要做成 JSONTSV 读取快、便于排查坏行配合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_idx0) self.gru nn.GRU(embed_size, hidden_size, batch_firstTrue) 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, biasFalse) 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), dim2) energy self.v(torch.tanh(self.attn(combined))).squeeze(2) # (batch, seq_len) energy energy.masked_fill(mask 0, -1e9) return torch.softmax(energy, dim1) class DecoderGRU(nn.Module): def __init__(self, vocab_size, embed_size, hidden_size, max_len30): super().__init__() self.embedding nn.Embedding(vocab_size, embed_size, padding_idx0) self.gru nn.GRU(embed_size hidden_size, hidden_size, batch_firstTrue) 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_ratio0.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), dim2) 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, dim1) # (batch, max_len, vocab_size) # 超参数字级词表 6000embedding 128hidden 256batch 64 # 损失函数使用 NLLLoss配合 log_softmax或 CrossEntropyLoss # 优化器 Adamlr 0.001梯度裁剪 max_norm5.0训练循环的关键点有四个teacher_forcing_ratio控制训练时解码器有多大比例使用真实词语作为下一步输入设成 0.8 起步、每个 epoch 衰减 0.1可以防止模型过早依赖真实序列而推理时崩掉mask必须把 padding 位置在 attention 里置为负无穷否则模型会学到关注空位推理时摘要末尾出现乱码梯度裁剪max_norm5.0是针对 GRU 在长序列上梯度爆炸的常规手段损失用CrossEntropyLoss但要把 padding 位置的预测结果 ignore 掉ignore_index0。这里给一个参数参考字级词表 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, modesummary): super().__init__() self.text text self.mode mode def run(self): try: # 真正耗时操作在这里执行 if self.mode keywords: keywords textrank_keywords(self.text, top_k8) 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 stylecolor:red输入太短正文至少 20 字/p) return self.worker GenerateWorker(text, modesummary) 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( fb关键词/b{data.get(keywords, [])}br fb摘要/b{data.get(summary, )}br fb候选标题/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 个 epochloss 停在 6.5 左右纹丝不动生成的结果全是标点符号。原因学习率太大导致 loss 震荡或 teacher forcing ratio 一直是 0.8模型过度依赖真实序列推理时第一步错后面全错还可能是梯度爆炸把参数推出合理区间。解决学习率从 0.001 降到 0.0005加 cosine 衰减teacher forcing ratio 每个 epoch 衰减 0.1让模型逐步独立预测梯度裁剪max_norm5.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. 验证摘要质量从困惑度到人工评价的落地做法模型训练完不能只看 lossloss 低不等于摘要可用。我一般分三层验证。第一层是代化指标用 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_width3的版本做对比——beam search 能明显提升通顺度但推理时间会翻三倍到底值不值得用上面的人工评价分数说话。在这套系统上栽过的最深跟头是数据清洗比模型结构更决定上限第一次训练用 3 万对未清洗的稿子模型学会了一句话模板据报道……换用 5000 对认真过滤的样本后效果反而翻了一倍。以后再做类似工具我会把一半时间留给数据而不是急着调注意力机制。希望帮到你。本文还有配套的精品资源点击获取