
简介这份资源面向具备一定Python基础、希望系统掌握中文文本关键词抽取的开发者与学习者围绕TF-IDF、TextRank、Word2Vec词聚类三种主流方法展开实践。相比网上零散示例它重点补齐了Word2Vec词聚类抽取流程不够清晰的短板并以专利文本为样例同样适用于新闻、论文等中文语料。压缩包共20个文件约879KB包含14个csv结果与样例数据、4个py实现脚本、2个txt停用词与词性标注参考另附词向量目录便于对照运行与结果验证。目前已有443人学习下载。读者可据此获得三套可直接运行的抽取脚本、配套语料与停用词表、各方法输出关键词结果以及从分词、词向量训练到聚类抽取的完整实现思路适合作为课程设计、项目实践或算法对比实验的参考模板。1. 中文关键词抽取的三条路线为什么 TF-IDF、TextRank、Word2Vec 聚类总被放在一起比做中文文本处理的人绕不开一个需求给一篇没有标签的文章自动挑出它到底在讲什么。搜索、推荐、舆情、工单分类、论文归档第一步几乎都是关键词抽取。标题里把 TF-IDF、TextRank、Word2Vec 词聚类放在一起不是凑数而是三种完全不同的思路TF-IDF 靠统计词频和逆文档频率打分TextRank 靠词与词共现的图结构做迭代排序Word2Vec 词聚类则先把词映射成向量再在向量空间里把语义相近的词聚成簇用簇来代表主题。前两种输出的是「词」第三种输出的是「词团」粒度不一样适用场景也不一样。这篇笔记就按这三条路线把中文分词、停用词、参数、代码和踩坑一次讲透让你拿到一篇中文文本就能跑出可用的关键词而不是停在调包跑个 demo。2. 中文关键词抽取的前置工程分词、停用词与词性过滤2.1 为什么中文不能直接套英文的 TF-IDF英文用空格切词中文没有天然分隔符所以任何关键词抽取的第一步都是分词。很多人直接拿 jieba 默认模式切完就丢进 TF-IDF结果抽出来一堆「的」「了」「我们」「可以」这不是算法问题是前置工程没做。中文关键词抽取的质量七成取决于分词和停用词表三成才是打分算法。常见做法是jieba 精确模式分词加载一份中文停用词表再按词性只保留名词、动名词、专有名词。词性过滤这一步很多人省掉但它是把「的」「而且」这类高频虚词挡在门外的关键。import jieba import jieba.posseg as pseg # 加载停用词表一行一个词 def load_stopwords(path): with open(path, encodingutf-8) as f: return set(line.strip() for line in f if line.strip()) stopwords load_stopwords(stopwords.txt) # 只保留名词(n)、动名词(vn)、专有名词(nz)、英文(eng) ALLOW_POS {n, vn, nz, eng} def tokenize(text): words [] for word, flag in pseg.cut(text): w word.strip() if len(w) 2: # 单字基本不是关键词 continue if w in stopwords: continue if flag not in ALLOW_POS: continue words.append(w) return words这段代码的逻辑是先切词并拿到词性再依次过滤长度、停用词、词性。len(w) 2是因为中文单字极少单独成关键词除非是「税」「房」这类特殊领域词一般直接砍掉。ALLOW_POS是白名单比黑名单稳因为虚词词性太多列不全。停用词表建议用哈工大或百度的通用表打底再叠加你自己领域的词比如做电商就把「包邮」「正品」加进去否则它们会霸榜。2.2 停用词表和自定义词典的维护姿势停用词表不是一次性的它要跟着业务迭代。我一般会准备两份一份通用停用词一份领域停用词加载时合并。自定义词典用jieba.load_userdict把领域专有名词加进去否则「知识图谱」会被切成「知识」「图谱」两个词TF-IDF 打分就散了。这里有个血泪经验自定义词典的权重字段别乱填填太高会把整句话粘成一个词填 0 又没效果一般给词频量级的数字就行。jieba.load_userdict(userdict.txt) # 每行: 词 词频 词性userdict.txt格式是「词 词频 词性」词频决定这个词被切出来的优先级词性会覆盖 jieba 的默认判断。做垂直领域时这一步能把关键词召回率拉高一大截。3. TF-IDF 抽取关键词从单文档到语料库的打分实现3.1 TF-IDF 的打分逻辑与中文适配TF-IDF 的核心就一句话一个词在当前文档里出现越多TF 高在整个语料库里出现越少IDF 高它就越可能是关键词。公式是 TF 乘 IDFIDF 通常取log(总文档数 / 包含该词的文档数)。中文适配的坑在于TF 要不要归一化、IDF 用哪个底、要不要开方。sklearn 的TfidfVectorizer默认做了 L2 归一化和平滑直接拿来用没问题但如果你自己手写记得加平滑项否则某个词在所有文档都出现时 IDF 会变成 0 甚至除零。from sklearn.feature_extraction.text import TfidfVectorizer # 假设 corpus 是分词后用空格拼接的文档列表 corpus [ .join(tokenize(doc)) for doc in documents] vectorizer TfidfVectorizer( tokenizerlambda x: x.split(), # 已经分好词直接按空格切 lowercaseFalse, max_df0.8, # 出现在 80% 以上文档的词丢掉 min_df2, # 至少出现在 2 篇文档里才算 use_idfTrue, smooth_idfTrue, # 加平滑避免除零 sublinear_tfTrue # TF 用 1log(tf)抑制高频词 ) tfidf_matrix vectorizer.fit_transform(corpus) feature_names vectorizer.get_feature_names_out() # 取单篇文档 topK 关键词 def topk_keywords(doc_index, k10): row tfidf_matrix[doc_index].toarray()[0] ranked row.argsort()[::-1][:k] return [(feature_names[i], round(row[i], 4)) for i in ranked if row[i] 0]参数说明max_df0.8过滤掉太普遍的词min_df2过滤掉太生僻的词这两个是控制关键词质量最直接的两个旋钮。sublinear_tfTrue很关键它把 TF 从线性改成对数防止一个词重复 50 次就把分数拉爆。smooth_idfTrue是防除零的后悔药。取 topK 时记得过滤分数为 0 的词否则会返回一堆空位。3.2 单文档场景下 TF-IDF 的退化与补救TF-IDF 有个硬伤它依赖语料库算 IDF。如果你只有一篇文章没有语料库IDF 就退化成常数TF-IDF 等于纯词频抽出来的全是高频词。补救办法有两个一是拿一批同领域文档当背景语料只对目标文档打分二是退而求其次用 TF 加词性过滤至少把虚词挡掉。我一般会维护一个领域背景语料库几百篇就够IDF 就稳了。如果实在没有就在TfidfVectorizer里把use_idfFalse明确知道自己用的是词频别自欺欺人。4. TextRank 抽取关键词共现窗口、阻尼系数与迭代收敛4.1 TextRank 的图模型与中文共现窗口设置TextRank 的思路来自 PageRank把每个词当成图里的节点词与词如果在同一个窗口内共现就连一条边边权是共现次数。然后迭代计算每个节点的重要性重要性会沿着边传播。中文里窗口大小是核心参数一般设 2 到 5。窗口太小词之间连不上图会碎成孤岛窗口太大所有词都连在一起区分度就没了。经验值是 5也就是一个词和它前后各 5 个词建立共现关系。阻尼系数 d 一般取 0.85表示一个节点有 15% 的概率随机跳转防止陷入局部循环。import networkx as nx from collections import defaultdict def build_word_graph(words, window5): graph nx.Graph() for i, w in enumerate(words): if w not in graph: graph.add_node(w) for j in range(i 1, min(i window 1, len(words))): v words[j] if v w: continue if graph.has_edge(w, v): graph[w][v][weight] 1 else: graph.add_edge(w, v, weight1) return graph def textrank_keywords(words, topk10, window5, d0.85): graph build_word_graph(words, window) scores nx.pagerank(graph, alphad, weightweight) ranked sorted(scores.items(), keylambda x: x[1], reverseTrue) return ranked[:topk]build_word_graph里用min(i window 1, len(words))控制窗口边界避免越界。nx.pagerank的alpha就是阻尼系数weightweight让共现次数多的边权重更高。返回的分数是归一化后的不同文档之间不能直接比大小只能在同一篇文档内排序。4.2 TextRank 与 TF-IDF 的结果差异及选择同一篇文档TF-IDF 偏向「稀有且高频」的词TextRank 偏向「和很多重要词共现」的词。举个例子一篇讲机器学习的文章「模型」这个词可能 TF-IDF 分数不高因为它太常见但它和「训练」「参数」「损失」都共现TextRank 就会把它顶上来。所以选哪个取决于你要什么要突出文档独特性用 TF-IDF要抓文档核心主题词用 TextRank。实际项目里我经常两个都跑取交集或加权融合效果比单用一个稳。融合时注意先各自归一化否则量纲不一致加权就是玄学。5. Word2Vec 词聚类把关键词抽取变成向量空间里的簇发现5.1 训练词向量与中文语料预处理Word2Vec 不直接抽关键词它先把词变成向量再聚类用簇里的代表词当关键词。这条路适合「主题发现」而不是「单词抽取」因为一个簇往往对应一个语义主题。训练词向量需要一定规模的语料几千篇文档起步太少向量学不准。中文预处理还是分词加停用词但这里不建议做词性过滤因为 Word2Vec 靠上下文学语义过滤太狠会丢上下文。常见做法是用gensim的Word2Vecsg1用 Skip-gram对低频词更友好window5vector_size100到 200 之间min_count5过滤掉出现太少的词。from gensim.models import Word2Vec # sentences 是分词后的列表的列表 sentences [tokenize(doc) for doc in documents] model Word2Vec( sentencessentences, vector_size128, window5, min_count5, sg1, # Skip-gram workers4, epochs10 ) # 取词向量 words [w for w in model.wv.index_to_key] vectors [model.wv[w] for w in words]vector_size128是维度的常见起点太小表达不够太大容易过拟合且慢。min_count5很关键出现少于 5 次的词直接不进词表否则向量学不准还占内存。sg1比 CBOW 慢但小语料下效果通常更好。5.2 KMeans 聚类与簇关键词提取拿到词向量后用 KMeans 聚成 K 个簇K 一般取 5 到 20看文档主题多少。聚类前建议先对向量做归一化否则模长大的词会主导距离。聚完后每个簇取离质心最近的几个词作为代表这些词就是这一簇的关键词。from sklearn.cluster import KMeans from sklearn.preprocessing import normalize import numpy as np X normalize(np.array(vectors)) # L2 归一化 kmeans KMeans(n_clusters10, random_state42, n_init10) labels kmeans.fit_predict(X) # 每个簇取离质心最近的 5 个词 def cluster_keywords(k5): result {} for c in range(kmeans.n_clusters): idx np.where(labels c)[0] if len(idx) 0: continue center kmeans.cluster_centers_[c] dist np.linalg.norm(X[idx] - center, axis1) nearest idx[dist.argsort()[:k]] result[c] [words[i] for i in nearest] return resultn_init10是跑 10 次初始化取最优避免 KMeans 陷入局部最优这个参数别省。normalize用 L2 归一化让所有词向量落在单位球面上距离才有可比性。取簇代表词时用「离质心最近」而不是「簇内频率最高」是因为质心代表整个簇的语义中心频率高的词可能是通用词。6. 三条路线的避坑与排查分词、参数与结果异常6.1 抽出来的全是通用词现象TF-IDF 和 TextRank 结果里全是「问题」「方法」「研究」这类词。原因停用词表没覆盖领域通用词或者max_df设太高没过滤掉。解决把领域通用词加进停用词表同时把max_df从 0.8 降到 0.5 到 0.6让出现在一半以上文档的词直接出局。6.2 TextRank 跑出来图是空的现象nx.pagerank报错或者返回空。原因分词后有效词太少窗口内没有共现图没有边。解决检查分词结果确认停用词没把词砍光把窗口从 5 调到 7 试试如果文档本身很短TextRank 不适合换 TF-IDF。6.3 Word2Vec 聚类结果全是近义词现象一个簇里全是「高兴」「开心」「愉快」这种近义词。原因词向量学的是语义相似近义词天然聚在一起但这不是你要的主题簇。解决聚类前先做一次同义词合并或者把 K 调大让近义词自己占一簇主题词另成一簇。也可以对簇代表词再做一次去重同簇里语义太近的只留一个。6.4 不同文档的关键词分数没法比现象想按关键词分数给文档排序发现分数不可比。原因TF-IDF 的 IDF 依赖语料库TextRank 的分数是文档内归一化的。解决要跨文档比较统一用 TF-IDF 且固定语料库TextRank 只用于单文档内排序别跨文档比。6.5 自定义词典加了但没生效现象load_userdict之后专有名词还是被切碎。原因词典文件编码不是 UTF-8或者词频给太低被 jieba 默认词频压过。解决确认文件是 UTF-8词频给一个较大的数比如 10000词性填对。加载顺序也要注意load_userdict要在第一次cut之前调用。7. 把三种方法融合成一个可复用的关键词抽取函数单用一种方法总有短板实际项目里我习惯把三者融合。做法是TF-IDF 和 TextRank 各自归一化后加权求和得到词级别的分数Word2Vec 聚类得到主题簇把簇代表词作为补充。最终输出分两部分topK 单词关键词和 topN 主题簇。这样既有细粒度词又有粗粒度主题。def hybrid_keywords(text, corpus, topk10): words tokenize(text) # TF-IDF 部分用背景语料算 IDF vec TfidfVectorizer(tokenizerlambda x: x.split(), smooth_idfTrue) vec.fit([ .join(tokenize(d)) for d in corpus]) tfidf_scores dict(zip(vec.get_feature_names_out(), vec.transform([ .join(words)]).toarray()[0])) # TextRank 部分 tr_scores dict(textrank_keywords(words, topklen(words))) # 归一化后加权 def norm(d): if not d: return {} mx max(d.values()) or 1 return {k: v / mx for k, v in d.items()} tfidf_n, tr_n norm(tfidf_scores), norm(tr_scores) merged {} for w in set(tfidf_n) | set(tr_n): merged[w] 0.5 * tfidf_n.get(w, 0) 0.5 * tr_n.get(w, 0) return sorted(merged.items(), keylambda x: x[1], reverseTrue)[:topk]融合权重 0.5 比 0.5 是起点实际调的时候看哪个方法在你的数据上更准就往哪边偏。归一化那步不能省否则 TF-IDF 的绝对分数会把 TextRank 压没。这个函数可以直接嵌到你的 pipeline 里输入一篇文本和背景语料输出排序好的关键词。验证方法很简单人工标 20 篇文档的关键词跑一遍看命中率再调权重和参数。我自己的习惯是每次换领域都重新标 20 篇别信一次调好就一劳永逸中文文本的领域差异比想象中大。希望帮到你。本文还有配套的精品资源点击获取