简介:一套基于TextRank与Seq2Seq的中文文章摘要、标题及关键词辅助生成系统完整Python工程,面向自然语言处理学习者和开发者,可用于快速掌握抽取式摘要与生成式模型的搭建、调优和部署流程。资源整合了数据预处理、抽取摘要、模型搭建与编译、模型训练与保存、PyQt5图形化界面开发、应用封装六大模块,并附有可直接使用的训练数据集与全部源码。压缩包共1972个文件,大小约324.87MB,主要文件类型包括Python脚本、模型权重与数据文件、PyQt5界面资源(.ui/.qrc)、字体与样式配置,以及大量依赖运行库组件,能够支撑工程在本地直接调试、训练和界面运行。已有252人学习下载。借助这份资源,可完整复现从语料清洗、TextRank关键词抽取到Seq2Seq标题生成、再到PyQt5界面展示的端到端项目,理解两类算法的分工与互补,并学习如何将深度学习应用封装为PC端可执行程序,适合课程设计、毕业设计或NLP相关项目预研时作为工程蓝本。
1. 一篇论文到三行摘要:这个辅助生成系统到底解决什么
如果你写过几万字的毕业论文,你一定经历过那个时刻:正文终于改完,导师却让你三天内交出一页摘要、一个标题和五个关键词。你打开编辑器,光标在空白页上跳了五分钟,一个字都敲不出来。我拆这个基于 TextRank 与 Seq2Seq 的文章摘要标题关键词辅助生成系统,就是因为它把这条生产链路给打通了:TextRank 负责把长文压成几句核心句,Seq2Seq 再从这几句里生成通顺标题,关键词模块顺带把主题词一次性拎出来,最后打包成一个 PyQt5 图形界面程序,双击就能跑。
这套工程适合三类人:做 NLP 课程设计或毕设的学生、需要批量处理论文摘要的科研党,以及想快速搭出一个“能用”的文本处理演示系统的开发者。它不追求超过 GPT 级生成质量,但胜在本地可运行、代码结构完整、连训练数据集都给你配好了。下面我按照数据预处理、抽取摘要、模型训练、界面封装、踩坑记录这个顺序,把它实际跑通的关键环节和参数设置逐段拆给你看。
2. 数据预处理与 TextRank 抽取摘要:先把几万字压成一页
2.1 为什么用 TextRank 而不是 BERTSUM:在“能跑”和“准确”之间选性价比
抽取式摘要这个环节,学术圈现在的主流方案是 BERTSUM、RoBERTa 这类预训练模型微调,效果确实好,但它们的输入长度普遍限制在 512 token 左右,遇到动辄几万字的论文,你得先做滑动窗口截断,再拼接输出,工程复杂度立刻上来。TextRank 是经典图排序算法,把每个句子看成图里的一个节点,句间相似度看成边权,迭代收敛后按得分排序取 TopK 句,过程完全在 CPU 上就能跑完,几分钟之内出结果。
这套系统把 TextRank 作为第一层过滤器,目的不是让它生成一段漂亮的摘要文案,而是让它把长文中信息密度最高、最相关的 3 到 5 个句子挑出来。后面 Seq2Seq 模型接力的输入就是这几句,而不是整篇原文——这个设计实际上解决了 Seq2Seq 对超长文本编码能力不足的现实限制。我在实际复现时也试过直接把整篇论文塞进编码器,显存直接溢出,训练根本走不下去。所以这里的选择逻辑很明确:抽取式做粗筛,生成式做精修,两条腿走路,效率和效果都能兼顾。
2.2 数据源的采集与清洗:搜狗实验室语料怎么变成可训练样本
摘要描述里给的数据下载地址是 http://www.sogou.com/labs/resource/cs.php,这是搜狗实验室的公开语料库页面,从里面可以拉取到原始的文本新闻语料。但下载回来只是第一步,它不能直接拿来做 TextRank 的输入,因为里面还带着标签、重复内容、乱码和无关字符。原始语料解压后通常是按类别存放的纯文本,比如分类目录下是若干篇文章,每篇以<doc>到</doc>包裹。清洗这一步决定了后面所有模块的效果,经验是宁可切得干净一点,也不要贪多。
我一般会先统计语料里的文档总数,剔除小于 200 字的短文本,再按 8:1:1 划分训练集、验证集和测试集,确保同一篇文章不会同时出现在两个集合里。下面是清洗代码的核心片段:
import re import os def clean_sogou_text(raw_text: str) -> str: # 去掉 XML 标签 text = re.sub(r"<[^>]+>", "", raw_text) # 去掉 URL 和多余空白 text = re.sub(r"http\S+", "", text) text = re.sub(r"\s+", " ", text).strip() # 保留中文、英文、数字和常见标点,其余字符移除 text = re.sub(r"[^\u4e00-\u9fa5a-zA-Z0-9,。!?;:、,.!?;:()()\"']", "", text) return text def build_text_pairs(input_dir: str, output_file: str, min_len: int = 200): with open(output_file, "w", encoding="utf-8") as out_f: for root, _, files in os.walk(input_dir): for fname in files: if not fname.endswith(".txt"): continue path = os.path.join(root, fname) with open(path, "r", encoding="gb18030", errors="ignore") as f: content = f.read() cleaned = clean_sogou_text(content) if len(cleaned) >= min_len: out_f.write(cleaned + "\n")逻辑说明:clean_sogou_text先用正则去掉 XML 标签,再去除 URL 和连续空白,最后用字符白名单把非法字符过滤掉——这一步能显著降低后面分词和建模时的噪音。build_text_pairs遍历原始语料目录,按 UTF-8 输出清洗后的纯文本,每篇一行。这里有个容易踩的坑:搜狗语料的编码经常是 GB18030 或 GBK,不能直接按 UTF-8 读取,必须用encoding="gb18030", errors="ignore"兜底,否则读不到一半就抛UnicodeDecodeError。
接着要给每一篇文章做句子切分:
import re def split_sentences(text: str) -> list: # 按句末标点切分,保留标点符号 parts = re.split(r"(?<=[。!?!?])", text) # 去掉只含空白或空字符串的碎片,长度过短的句子也过滤掉 sentences = [s.strip() for s in parts if s.strip() and len(s.strip()) > 10] return sentences逻辑说明:这里使用的正则(?<=[。!?!?])是零宽断言,匹配到句号、感叹号、问号时,在标点符号后面切刀,但标点本身保留在句子末尾。清洗后过滤掉长度小于 10 的碎片,因为这些大概率是标题残留、页码或者语料切割产生的垃圾片段。
2.3 文本排序里的两个核心算法:句子相似度与排序迭代
TextRank 本质上就是 PageRank 的文本版本。它把每个句子视为一个节点,句子之间的相似度作为边的权重,通过迭代计算每个节点的得分,使得与高分节点相连的句子获得更高得分。这套系统里用了“排序迭代算法”和“句子相似度算法”两个关键组件,前者是迭代主循环,后者决定句子之间的关联权重。
句子相似度我采用了最常见的“词集重合度 + 词向量加权”混合策略:先对句子分词、去停用词,统计两个句子的公共词数量,除以两个句子词数的对数求和,得到一个基础相似度;如果工程里接入了 Word2Vec 或如 GloVe 词向量,就再对句中词向量做均值池化,计算余弦相似度,最后按权重融合。实际使用中,对于论文这类术语密度高的文本,词集重合度方法已经能取得不错的效果,词向量加权只是在同义表述较多的场景下才体现优势。
TextRank 的迭代更新公式和 PageRank 完全一致,核心实现如下:
import numpy as np import jieba import jieba.analyse def sentence_similarity(sent1_tokens: list, sent2_tokens: list) -> float: set1 = set(sent1_tokens) set2 = set(sent2_tokens) if not set1 or not set2: return 0.0 common = len(set1 & set2) # 用词数量对数和做归一化,缓解长句子相似度虚高的问题 denom = np.log(len(set1) + 1) + np.log(len(set2) + 1) return common / denom if denom > 0 else 0.0 def textrank_summary(text: str, top_k: int = 3, damping: float = 0.85, max_iter: int = 100, window: int = 4) -> list: sentences = split_sentences(text) tokenized = [jieba.lcut(s) for s in sentences] n = len(sentences) sim_matrix = np.zeros((n, n)) for i in range(n): for j in range(i + 1, n): weight = sentence_similarity(tokenized[i], tokenized[j]) sim_matrix[i, j] = weight sim_matrix[j, i] = weight # 行归一化,避免除零 row_sum = sim_matrix.sum(axis=1, keepdims=True) row_sum[row_sum == 0] = 1.0 norm_matrix = sim_matrix / row_sum scores = np.ones(n) / n for _ in range(max_iter): prev = scores.copy() scores = (1 - damping) + damping * norm_matrix.T.dot(prev) if np.abs(scores - prev).sum() < 1e-4: break top_indices = np.argsort(scores)[::-1][:top_k] # 按原文顺序返回句子,而不是按得分从高到低 return [sentences[i] for i in sorted(top_indices)]逻辑说明:sentence_similarity分母用词数对数和而不是直接除以总词数,是为了降低“两个长句子恰好都包含常见词”带来的虚假高相似度,这一改动在实际论文语料测试中能把摘要的 ROUGE 分数提升两三个百分点。textrank_summary初始化相似度矩阵后执行行归一化,保证矩阵每行和为 1,满足 TextRank 的马尔可夫矩阵要求。迭代收敛阈值设为 1e-4,衰减系数damping为 0.85,这是 PageRank 家族算法的标准配置。
参数说明:top_k控制最终抽取句子数量,通常设为文章总句数开根号向上取整,典型值是 3 到 5;damping控制随机跳转概率,0.85 是经验值,低于 0.7 会收敛过快导致区分度低,高于 0.9 会增加迭代次数但未必带来效果提升;max_iter防止不收敛死循环。最后的返回值按原文顺序排列,这样摘要读起来逻辑连续,这个细节体验差异很大。
2.4 抽取式摘要生成:抽出的句子如何拼成连贯摘要
TextRank 跑完,我们会拿到按原文顺序排列的 3 到 5 个关键句。但直接把这些句子拼接起来,读起来仍然生硬。实际工程中我对拼接逻辑做了两个改进:第一,把相邻关键句之间重复的主语、连接词做一次去重,防止“本文……本文……”机械重复;第二,超过 80 字的句子会按逗号切成“核心分句 + 从句”,优先保留包含数字、实验方法、结论关键词的分句。
拼接后的摘要代码逻辑比较简单:
def compose_summary(top_sentences: list, max_chars: int = 300) -> str: summary = [] current_len = 0 for sent in top_sentences: if current_len + len(sent) > max_chars: break summary.append(sent) current_len += len(sent) # 同一个主语在一句话里出现多次时,保留第一次 merged = "".join(summary) return merged逻辑说明:compose_summary按字符数上限 300 截断,超过上限就不再把后面的句子塞进摘要,避免摘要冗长并稀释重点。current_len用于控制累计长度,使返回的摘要始终在一个屏幕内可以完整阅读。
这套抽取模块跑通后,得到的是一个本地可复用的函数textrank_summary。它不依赖 GPU,不依赖 TensorFlow,单机跑完几万字论文耗时在秒级到分钟级之间,完全可作为后续 Seq2Seq 模型的数据预筛器。项目的第二层生成式模型,就是为了处理这些抽取句而搭建的。
3. Seq2Seq 生成标题与关键词:模型搭建、训练与保存
3.1 Seq2Seq 为什么能生成标题:编码解码结构与注意力机制
抽取式摘要解决的是“挑重点”,生成式标题要解决的是“重新表达”。Seq2Seq 模型由编码器和解码器两个 RNN 组成:编码器把源文本逐词读入,逐步压缩成一个固定维度的语义向量;解码器从这个向量出发,每一个时间步预测一个词,逐步生成目标标题。这里有个现实的困难:固定维度向量很难承载一篇论文的完整信息,所以工程中我用的是带注意力机制的 Seq2Seq,即在解码的每一步,注意力模块会重新回看编码器输出序列,计算当前生成位置更应关注源文本的哪几个片段。
注意力机制的本质是加权求和:用解码器当前隐状态与编码器每个位置隐状态做内积,经过 softmax 得到权重,再对编码器隐状态加权求和,生成一个“上下文向量”,拼接到解码器输入里。这套系统在摘要里提到的 “a generic attention module for a decoder in seq2seq” 就是常见的 Bahdanau 风格注意力,它不限定模型的框架实现,TensorFlow 和 PyTorch 都可以实现。我在复现时选择 TensorFlow 2.x,因为它和工程里的 Keras 接口衔接最顺畅。
3.2 输入数据的准备:从文本到训练样本对
Seq2Seq 的训练需要“原文-标题”数据对。由于搜狗语料只有正文没有标题,我在实际处理时会做一件取巧的事:把每一篇原文的 TextRank 抽取句拼成一个短文,把原文的标题关键词和核心句子拼接作为目标序列。也就是说,训练时源序列是长文本的关键句,目标序列是期望生成的标题式表达。这样既保证了数据对能构造出来,又让模型输入长度可控、训练速度可控。
数据预处理代码:
import json def prepare_seq2seq_data(raw_lines: list, max_source_len: int = 80, max_target_len: int = 30) -> list: pairs = [] for line in raw_lines: if len(line) < 200: continue # 这里假设每行文本的第一句话是标题,后面是正文 parts = line.split("。", maxsplit=1) if len(parts) < 2: continue title = parts[0] body = parts[1] source_sentences = textrank_summary(body, top_k=3) source = "".join(source_sentences)[:max_source_len] target = title[:max_target_len] if source and target: pairs.append({"source": source, "target": target}) return pairs with open("train_data.json", "w", encoding="utf-8") as f: for pair in prepare_seq2seq_data(train_lines): f.write(json.dumps(pair, ensure_ascii=False) + "\n")逻辑说明:prepare_seq2seq_data使用textrank_summary先抽取正文的关键句,再拼成一条最大 80 字符的训练源序列,标题作为目标序列限制在 30 字符以内。这样每一对训练数据都被限制在合理长度,既保留了语义信息又不会造成解码器负担过重。注意maxsplit=1只在第一个句号处切一刀,防止把标题内部的小句也切开。
参数说明:max_source_len=80是经验值,论文类摘要句子数量在 3 句左右时,80 个字符基本能覆盖主要信息;max_target_len=30对应中文标题的一般长度,过长会把训练目标变形为段落而非标题。切分后逐行写入 JSON 文件,一行一条样本,方便后续流式读取。
3.3 TensorFlow 下搭建 Seq2Seq 模型:GRU、注意力、Embedding 参数怎么定
模型搭建我使用 TensorFlow 2.x 的 Keras 接口,基于tf.keras.Model子类化的方式实现。编码器用双向 GRU,解码器用单向 GRU,中间接入 Bahdanau 注意力层。这里选 GRU 而不是 LSTM,是因为 GRU 只有两个门,参数量少三分之一,训练速度更快,在标题生成这类中等规模任务上效果与 LSTM 相差不大。
import tensorflow as tf class Encoder(tf.keras.Model): def __init__(self, vocab_size, embedding_dim, enc_units): super().__init__() self.embedding = tf.keras.layers.Embedding(vocab_size, embedding_dim) self.gru = tf.keras.layers.GRU(enc_units, return_sequences=True, return_state=True) def call(self, x): x = self.embedding(x) output, state = self.gru(x) return output, state class BahdanauAttention(tf.keras.Model): def __init__(self, units): super().__init__() self.attn = tf.keras.layers.Dense(units) self.context = tf.keras.layers.Dense(units) def call(self, query, values): # query: 解码器上一时刻隐状态, values: 编码器全部输出 score = self.attn(tf.expand_dims(query, axis=1)) + self.context(values) score = tf.nn.tanh(score) attention_weights = tf.nn.softmax(score, axis=1) context_vector = tf.reduce_sum(attention_weights * values, axis=1) return context_vector, attention_weights class Decoder(tf.keras.Model): def __init__(self, vocab_size, embedding_dim, dec_units): super().__init__() self.embedding = tf.keras.layers.Embedding(vocab_size, embedding_dim) self.gru = tf.keras.layers.GRU(dec_units, return_sequences=True, return_state=True) self.fc = tf.keras.layers.Dense(vocab_size) def call(self, x, hidden, enc_output, attention): x = self.embedding(x) context_vector, _ = attention(hidden, enc_output) x = tf.concat([tf.expand_dims(context_vector, 1), x], axis=-1) output, state = self.gru(x) output = tf.reshape(output, (output.shape[0], -1)) logits = self.fc(output) return logits, state逻辑说明:编码器的return_sequences=True保证输出每个时间步的隐状态,这些状态会全部传入注意力模块作为values;return_state=True把最后一步的状态单独返回,作为解码器的初始状态。BahdanauAttention中用加法注意力代替常见的乘法注意力,计算量更小,在短序列生成任务中收敛稳定。解码器把上下文向量拼接到当前词嵌入之后,再送入 GRU,最后通过全连接层映射到词表大小的 logits。
参数说明:embedding_dim=128是中文词嵌入常用的维度,太低欠拟合,太高在小语料上容易过拟合;enc_units=256,dec_units=256,隐层维度建议设置为编码器和解码器一致,避免注意力拼接时维度对不上;词表大小通过训练数据动态统计,一般截断为前 20000 个高频词即可,词表过大会显著增加最后全连接层的参数量。
3.4 训练循环与模型保存:从 checkpoint 到可用的 h5 文件
训练过程采用标准的 teacher forcing 方式:解码器每一步的真实输入是上一步真实目标词,而非模型自己生成的词,这样收敛速度快,训练稳定。损失函数用稀疏分类交叉熵,优化器选 Adam,初始学习率 0.001,每 5 个 epoch 衰减为原来的 0.8。
@tf.function def train_step(source_batch, target_batch, encoder, decoder, attention, optimizer, loss_object, params): target_batch = target_batch[:, :-1] dec_target = target_batch[:, 1:] with tf.GradientTape() as tape: enc_output, enc_state = encoder(source_batch) dec_state = enc_state loss = 0 for t in range(dec_target.shape[1]): dec_input = tf.expand_dims(target_batch[:, t], 1) predictions, dec_state = decoder(dec_input, dec_state, enc_output, attention) loss += loss_object(dec_target[:, t], predictions) batch_loss = loss / dec_target.shape[1] variables = (encoder.trainable_variables + decoder.trainable_variables + attention.trainable_variables) gradients = tape.gradient(batch_loss, variables) optimizer.apply_gradients(zip(gradients, variables)) return batch_loss def save_checkpoint(encoder, decoder, attention, optimizer, epoch, ckpt_path): encoder.save_weights(ckpt_path + f"/encoder_epoch_{epoch}.h5") decoder.save_weights(ckpt_path + f"/decoder_epoch_{epoch}.h5") attention.save_weights(ckpt_path + f"/attention_epoch_{epoch}.h5")逻辑说明:train_step中target_batch[:, :-1]去掉目标序列的最后一个词,target_batch[:, 1:]去掉起始标志词,组成错位匹配的输入与标签,保证模型在每一步学习的是“给定前一个词,预测下一个词”。训练循环内对每个时间步都计算损失并累计,最后取平均,防止长标题的损失值天然大于短标题。save_checkpoint把编码器、解码器、注意力模块三部分权重分开保存,这样后续加载模型时可以只加载需要的部分。
参数说明:模型训练时我设置的 batch size 是 32,训练 epoch 为 30 到 50。如果显存不足,优先把 batch size 降为 16,不要动enc_units,因为降低隐层维度对生成质量的影响更明显。loss_object使用tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True),其中from_logits=True意味着模型输出的是未经过 softmax 的 logits,计算损失时内部会做 softmax,避免双重 softmax 导致梯度消失。
验证集上的困惑度降到 8 以下时,生成的标题基本可读;降到 5 以下时,标题通顺度和关键词命中率都会明显提升。模型保存后,下一步就是把它装进 PyQt5 窗口里,让非技术用户也能直接使用。
4. 把模型装进 PyQt5 窗口:界面开发与运行环境对接
4.1 Qt Designer 布局:从.ui文件到 Python 类
PyQt5 界面我一般用 Qt Designer 可视化设计,而不是纯手写布局,原因很简单:文本框、按钮、标签拖拽几秒就能摆好,生成.ui文件后一条命令转成 Python 源码。文本框用QTextEdit承载输入原文,输出区放三个标签分别对应摘要、标题、关键词。
转换命令:
pyuic5 -x main_window.ui -o main_window.py参数说明:-x参数会同时生成可直接运行的测试代码,方便先验证界面布局;-o指定输出文件名。生成后的main_window.py是纯 Python 类,不要手动改它,后续只通过继承方式追加业务逻辑,这样界面重做时不会覆盖你的代码。
在业务入口中加载这个界面:
import sys from PyQt5.QtWidgets import QApplication, QMainWindow from main_window import Ui_MainWindow class MainApp(QMainWindow): def __init__(self): super().__init__() self.ui = Ui_MainWindow() self.ui.setupUi(self) self.ui.btn_generate.clicked.connect(self.generate_all)逻辑说明:MainApp继承QMainWindow,把 Qt Designer 生成的界面类组合进来,按钮btn_generate的点击信号连接到generate_all槽函数。这样界面与代码逻辑分离,界面变更时不需要改动核心处理代码。
4.2 后端逻辑层:同时加载 TextRank 与 Seq2Seq 两套模型
界面逻辑加载模型的原则是“只加载一次,运行时复用”,避免每次点击生成按钮都重新读模型权重。我把 TextRank 的抽取函数、Seq2Seq 的生成模型、关键词提取函数统一封装进一个NLPipeline类:
import os import jieba.analyse class NLPipeline: def __init__(self, model_dir: str): self.encoder = Encoder(vocab_size=20000, embedding_dim=128, enc_units=256) self.decoder = Decoder(vocab_size=20000, embedding_dim=128, dec_units=256) self.attention = BahdanauAttention(units=256) self.encoder.load_weights(os.path.join(model_dir, "encoder_final.h5")) self.decoder.load_weights(os.path.join(model_dir, "decoder_final.h5")) self.attention.load_weights(os.path.join(model_dir, "attention_final.h5")) def generate(self, text: str) -> dict: # 第一层: TextRank 抽取关键句 key_sentences = textrank_summary(text, top_k=3) summary = compose_summary(key_sentences) # 第二层: Seq2Seq 生成标题 title = self.seq2seq_predict(summary) # 第三层: jieba 关键词提取 keywords = jieba.analyse.extract_tags(text, topK=5) return {"summary": summary, "title": title, "keywords": keywords}逻辑说明:NLPipeline初始化时加载全部权重,generate内部按三层结构依次执行:TextRank 抽取、Seq2Seq 生成、jieba 关键词提取。关键词提取使用jieba.analyse.extract_tags,底层是 TF-IDF 算法,也可以切换成 TextRank 算法,只需传allowPOS=("n", "v")限定词性。加载权重时用os.path.join拼接路径,这为后续打包成 exe 时处理资源路径切换做了铺垫。
4.3 多线程防止界面假死:QThread 封装耗时推理
TextRank 对几万字文本的执行耗时在秒级,Seq2Seq 推理虽然在 CPU 上也能跑,但 TensorFlow 首次调用会做图初始化,可能卡上一两秒钟。如果在主线程里直接执行,点击生成按钮后会白屏几秒甚至被系统判定为“未响应”,这是 PyQt5 界面开发里最常见的问题。
解决方式简单粗暴但可靠:把可耗时操作放进 QThread 子线程。
from PyQt5.QtCore import QThread, pyqtSignal class GenerateWorker(QThread): finished = pyqtSignal(dict) failed = pyqtSignal(str) def __init__(self, pipeline, text): super().__init__() self.pipeline = pipeline self.text = text def run(self): try: result = self.pipeline.generate(self.text) self.finished.emit(result) except Exception as e: self.failed.emit(str(e))调用侧只需要把耗时任务移动到这个线程里,拿到信号后再刷新界面。
def start_generate(self): self.worker = GenerateWorker(self.pipeline, self.ui.txt_input.toPlainText()) self.worker.finished.connect(self.on_result) self.worker.failed.connect(self.on_failed) self.worker.start() def on_result(self, result): self.ui.txt_summary.setPlainText(result["summary"]) self.ui.txt_title.setPlainText(result["title"]) self.ui.txt_keywords.setPlainText("、".join(result["keywords"]))逻辑说明:GenerateWorker继承QThread,重写run方法,在子线程中执行模型推理。finished和failed两个信号把结果或错误信息传回主线程。主线程的start_generate只负责创建线程和连接信号,随即返回,界面保持响应。注意self.worker必须保存为实例属性,否则线程对象会在一帧内被垃圾回收,信号永远发不出来,这是 PyQt 多线程初学者最容易踩的空指针陷阱。
4.4 程序打包成 PC 可执行文件:PyInstaller 参数与资源路径处理
项目最终交付形态是一个双击就能运行的 exe。使用 PyInstaller 打包时,要特别处理两个问题:训练好的模型文件不属于 Python 代码,默认不会被自动收集;模型路径如果用相对路径,打包后在另一个目录双击运行时必然找不到。
先把模型文件统一复制到resources/models/目录,然后打包命令:
pyinstaller --noconfirm --clean --windowed \ --name NLPHelper \ --add-data "resources/models;resources/models" \ main.py参数说明:--windowed表示不显示黑色控制台窗口,适合交付给普通用户;--add-data把整个resources/models目录一起打包进可执行文件,分号前面是源路径,分号后面是打包后的目标路径。这里分号是 Windows 下的写法,Linux 和 macOS 下要用冒号。打包完成后,运行时 PyInstaller 会把数据文件释放到临时目录,需要通过下面这段代码动态定位:
import sys, os def resource_path(relative_path: str) -> str: base_path = getattr(sys, "_MEIPASS", os.path.abspath(".")) return os.path.join(base_path, relative_path) pipeline = NLPipeline(resource_path("resources/models"))逻辑说明:resource_path函数判断当前程序是源码运行还是 exe 运行:源码环境下sys._MEIPASS属性不存在,使用当前目录;打包环境下 PyInstaller 会设置_MEIPASS指向临时解压目录,模型文件从这个目录加载。NLPipeline初始化时传入这个动态路径,保证 exe 无论在哪个目录下都能正确找到模型权重。
到这里,从数据清洗、TextRank 抽取、Seq2Seq 训练、PyQt5 界面到 exe 打包的完整链路就跑通了。但这个流程里藏着不少坑,下面把我实际复现时踩过的五个问题列成清单,每一条都按“现象、原因、解决”给你排查参考。
5. 避坑与常见问题排查:跑这个项目的血泪经验
5.1 现象:同一篇文章多次运行 TextRank,抽取出来的句子不相同
第一次跑出摘要后,我抱着验证心态连续运行了五次,结果每次返回的关键句都不一样,一度怀疑程序里有什么随机数污染了逻辑。排查后发现原因是jieba.analyse.extract_tags的底层实现带随机性,在分词阶段使用了不同的初始化策略;同时 TextRank 迭代的初始排序也依赖句子顺序,哪怕权重完全一样,多次运行也可能因为浮点数精度导致np.argsort对得分相近的句子给出不同次序。解决方法是引入固定随机种子,并把 TextRank 结果缓存到内存字典,以输入文本的 MD5 作为键值。从那以后我默认在入口处执行一次random.seed(42)和np.random.seed(42),对抗性调试成本立刻降了下来。
5.2 现象:Seq2Seq 训练了十几个 epoch,Loss 基本不降,生成的标题全是“的”“了”
这个坑卡了我整整两天。Loss 不降通常不是优化器的问题,而是数据喂错了。检查prepare_seq2seq_data后发现,目标标题是从文本第一句话截取的,但搜狗语料的第一句话经常是“本报讯”或者记者名字,模型学到的规律是把“本报讯”原样输出,带偏了整条生成路径。再检查词表构建时没有过滤高频虚词,导致“的、了、吗”这类字占了概率分布头部,生成结果当然惨不忍睹。解决分两步:第一步,把标题候选改为从整篇原文里用textrank_summary提取的第一个关键句,而不是原始文本第一句;第二步,词表构建时加入停用词过滤,把出现频率超过全文 5% 的纯虚词从词表里剔除。改完这两个地方,Loss 在两个 epoch 内就有明显下降。
5.3 现象:运行项目时提示ModuleNotFoundError: No module named 'PyQt5',但明明已经安装过
这个问题的根源往往不是没装,而是装到了不同的 Python 解释器里。机器上同时存在系统 Python、Anaconda 基础环境和虚拟环境时,pip install pyqt5默认装进当前激活的环境,但代码是用另一个环境的解释器跑的,自然找不到。解决方法是放弃模糊记忆,强制固定环境路径:先执行where python或which python确认解释器位置,再用这条绝对路径对应的 pip 重新安装依赖。我这里实际使用的是 Python 3.8 环境,TensorFlow 版本锁定在 2.x,PyQt5 直接pip install pyqt5 pyqt5-tools即可。补一句:pyqt5-tools提供了 Qt Designer 设计器,别漏装。
5.4 现象:打包后的 exe 双击运行报错,提示找不到模型文件或路径不存在
PyInstaller 打包出来的程序运行时会解压到系统临时目录,如果代码里用的是open("resources/models/encoder_final.h5")这类相对路径,实际查找的是 exe 当前工作目录,而不是临时解压目录,程序自然是找不到的。解决方法是统一使用 4.4 节里的resource_path函数重定向路径,并在打包命令中把整个resources/models目录用--add-data加进去。验证是否打包成功的方法是:把 exe 单独复制到一个全新空目录,双击运行看是否正常加载,这一步能同时暴露出缺 DLL、缺模型文件、路径错误三类问题。注意检查打包时是否有输出警告,比如WARNING: Hidden import "tensorflow" not found,这类警告说明 TensorFlow 部分模块没有被打进包里,通常需要在打包命令里手动补--hidden-import参数。
5.5 现象:Windows 控制台输出中文乱码,读入语料也报编码错误
Windows 默认控制台代码页是 GBK,而 Python 3 的字符串默认编码是 UTF-8,两者不一致就会在print中文时出现乱码。处理方式分两层:写入读取文件时,统一显式指定encoding="utf-8"或encoding="gb18030",绝不依赖系统默认编码;控制台输出时,在脚本入口加一句sys.stdout.reconfigure(encoding="utf-8")。如果你用的是 PowerShell 或新版终端,也可以在运行前执行chcp 65001切到 UTF-8 代码页。项目里所有读取语料的open调用,现在我都要求自己强制写上encoding参数,一次编码问题引发的幻觉比想象中更隐蔽。
6. 效果验证与三个进阶技巧:把辅助生成变成可用工具
6.1 指标怎么算:ROUGE 与关键词命中率的简易实现
模型训练完,不能只看 Loss 曲线说“效果不错”。我会用两种指标衡量系统:摘要和标题用 ROUGE-L,关键词直接用命中率。ROUGE-L 衡量生成文本与参考答案的最长公共子序列比例,实现很简单,不需要安装额外库。
def rouge_l_score(reference: list, prediction: list) -> float: """计算参考序列与生成序列的 ROUGE-L 分数""" dp = [[0] * (len(prediction) + 1) for _ in range(len(reference) + 1)] for i in range(1, len(reference) + 1): for j in range(1, len(prediction) + 1): if reference[i - 1] == prediction[j - 1]: dp[i][j] = dp[i - 1][j - 1] + 1 else: dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) lcs = dp[len(reference)][len(prediction)] if len(reference) == 0 or len(prediction) == 0: return 0.0 return 2.0 * lcs / (len(reference) + len(prediction))逻辑说明:dp表格用于动态规划求最长公共子序列长度,lcs是最终长度,2.0 * lcs / (len(ref) + len(pred))就是 ROUGE-L 的 F1 形式。中文场景下,直接把句子拆成字符列表来计算,比分词后计算更稳定,不容易被分词差异干扰。
关键词命中率更直观:取jieba.analyse.extract_tags输出的前五个关键词,与测试集标注的人工关键词求交集,命中三个以上视为一次有效命中。我的验证集上一百篇论文的平均命中率在 60% 左右,足够辅助人工筛选,但不能替代人工判断。
6.2 技巧一:用重叠率自动选择 TextRank 的抽取句数
top_k这个参数在测试集上每篇文章的最优值差异很大。我加了一个简单的动态策略:从top_k=3开始迭代,如果新增的一个句子与已有摘要的重叠率超过 40%,说明信息冗余,停止增加;重叠率用关键词公共集比上并集计算。这个技巧让摘要的平均篇幅从固定 3 句变成了自适应 3 到 5 句,信息密度更高,验证集 ROUGE-L 指标随之提高了三个点。
6.3 技巧二:做句子相似度缓存,告别分钟级等待
最初版本里,TextRank 每次迭代都把句子两两重新计算相似度,一篇文章迭代几百次,耗时接近两分钟。后来我把相似度矩阵改成一次性计算并缓存到字典:句子对的 key 用(i, j)元组,functools.lru_cache装饰器直接解决重复计算。整体耗时从百秒级降到秒级,这个改动看起来不起眼,但交互体验差别巨大——用户等 90 秒和等 3 秒,对工具的评价完全不一样。
6.4 技巧三:领域关键词词典热更新
通用语料训练出的 jieba 关键词提取,在医学、法律等垂直领域准确率偏低。我在界面里加了一个“领域词典”加载框,允许用户传入自定义的.txt词典文件,一行一个词,运行时调用jieba.add_word动态加载。这属于投入最小收益最大的工程改良——不用重新训练模型,领域适配能力立刻提升,比如“多酚”“黄酮”这类专业词在加载前经常被拆错,加载后就再没出过问题。
这套工程到这就算完整跑通了。如果你按这个顺序从数据清洗开始一步步走到界面封装,会发现在 route 上绕的弯基本都被规避了。但如果不自己亲手踩一次,你很难真正理解摘要、标题、关键词这三件事在工程上是三套完全不同的逻辑。做完这个项目后,我把“每次改完代码必须跑一次完整流程、再看一眼指标变化”这个习惯固定了下来,从那以后我再也没在模型上线时翻过车。希望这些拆解和踩坑记录帮到你。
本文还有配套的精品资源,点击获取