ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

文本相似度分析实战:从TF-IDF到余弦相似度,Python完整实现

文本相似度分析实战:从TF-IDF到余弦相似度,Python完整实现 简介这份PDF文档围绕Python中文文本相似度分析展开面向具备Python基础、希望入门NLP的开发者以jieba分词和gensim库为主线完整讲解从分词、词袋模型到TF-IDF建模、余弦相似度计算的实现流程。资源包含1个PDF文件压缩包大小仅63KB虽体量精简但内容聚焦已有3060人学习下载。文档以可复现的代码为例结合目标文档与测试文档的对比逐步演示Dictionary、doc2bow、TfidfModel及SparseMatrixSimilarity等关键API的用法并展示相似度排序结果。同时点出实际项目中去除停用词、词形还原等预处理方向以及TF-IDF中词频与逆文档频率的直观理解适合作为文本相似度分析入门或快速上手的参考资料。1. 文本相似度分析在做什么从“字符串一样”到“语义接近”这一步到底怎么跨文本相似度分析是 NLP 里被问得最多、也最容易让人误会的一个需求。你在做数据清洗时要判断两行地址是不是同一个做知识库检索时要把用户问题映射到最相近的标准问法做反作弊时要找出批量重复的评论——这些场景背后都是同一件事给定两个文本算出一个数告诉系统它俩有多像。这个数不是 0 就是 1 的“是否相同”而是 0 到 1 之间的连续值越接近 1 代表越相似。听起来很简单但真正动手你会发现两个玄学问题为什么把一模一样的两句话分词之后相似度反而不到 1为什么“苹果手机”和“iPhone”在字面上没有任何公共词却应该被判定为相似本文要讲的就是这一连串操作的落地路径——从选算法、搭管线、调参数到处理那些让结果集体翻车的边缘情况。这份笔记适合正在给文本数据做去重、清洗、检索或粗排的 Python 从业者不涉及大模型微调也不讲复杂架构目标是让你能在一小时内跑通一版可以交付的相似度分析流程。2. 相似度算法怎么选从集合交集到向量空间选错算法等于白做2.1 先搞懂相似度到底在算什么字符、词、还是语义文本相似度分析的第一步不是写代码而是先回答一个问题你要比较的对象是什么颗粒度。按颗粒度从小到大排常见做法有四层。字符级比较典型代表是编辑距离和最长公共子串适合处理“订单编号输错一位”“地址里多了一个空格”这类低层错误词级集合比较典型代表是 Jaccard 相似度适合短文本去重向量空间比较典型代表是 TF-IDF 加余弦相似度适合长度不等的文档和检索场景语义级比较典型代表是词向量平均、BERT 句向量适合同义改写、意图识别。如果目标只是“把明显重复的评论挑出来”用语义模型属于杀鸡用牛刀推理成本高且可解释性差如果目标是“用户问题匹配到知识库标准问”那字符级算法基本无效。选错颗粒度后续所有参数调优都是白费力气。先看一个最简单的量化例子。假设有两条文本 A 和 BA 是“北京今日天气如何”B 是“今天北京天气怎么样”。字符级编辑距离算出来相似度可能不到 0.3因为两个字符串逐个字符对比时大量错位但人类一眼就知道这是同一个意思。反过来两条都含“北京”“天气”的文本即使语序完全不同词级算法也能给出较高分数。这背后的取舍是字符级对噪声敏感但能抓住拼写错误词级对语序不敏感但能抓住词汇重合。常见做法是先用词级算法做粗筛再用字符级做精排两者配合而不是互相替代。2.2 四种主流算法的适用边界与参数直觉先给一份我平时选型用的对照表方便新手直接抄作业算法计算对象典型相似度值含义适合场景不适合场景编辑距离Levenshtein字符0.8 代表只差两次编辑订单号、手机号、短地址长文档、同义改写Jaccard词集合0.5 代表一半词相同短文本去重、标签重叠语序敏感、一词多义TF-IDF 余弦词向量带权重0.6 代表方向接近文档检索、文章查重短文本词频统计失真句向量BERT 等语义向量0.8 代表语义高度一致同义句匹配、FAQ 问答算力受限、需解释分数的场景注意 Jaccard 的一个直接反直觉结论两个完全相同的文本Jaccard 相似度就是 1但两个完全不同的长文本因为都包含“的”“了”“是”这类高频词Jaccard 反而可能拿到 0.2 以上。所以做文本相似度分析时分词后第一件事是过滤停用词和标点否则所有分数都会向“中等偏高”偏移让后续阈值设定基本靠猜。TF-IDF 天然缓解了这个问题因为 IDF 会给高频词更低的权重——但 TF-IDF 在小样本数据集上同样有坑后面的章节会专门讲。2.3 为什么说 TF-IDF 余弦是入门最优解如果你只想选一个算法先跑通流程我一般会推荐 TF-IDF 加权词向量配余弦相似度。理由有三个。一是计算可控scikit-learn 自带 TfidfVectorizer不需要自己维护词表、不需要训练词向量几百行文本秒出结果。二是结果可解释你可以打印出每个词条的 TF-IDF 权重直接告诉业务方“这个分数主要由哪几个词撑起来”这在项目验收时非常有用。三是它对长度不敏感一个 20 字的问题和一个 500 字的文档比较时向量都归一化到单位长度不会被文本长度带偏。需要注意 TF-IDF 的词项权重并不代表“重要性”它只代表“区分度”。一个词在文本库中越罕见IDF 越大但它对业务是否关键算法不知道。比如在一个法律文本库中“甲”和“乙”这种代词 TF 高但区分不了案情真正关键的案号、金额、日期都是数字和字母组合分词时往往被切碎。所以落地时要在分词环节做一层自定义词典注入把案号、型号、地名这类领域词提前保护起来否则后续相似度分数会被无关词稀释。这一步是文本相似度分析里第一个值得花时间的调优点具体操作放在下一章。3. 用 Python 跑通最小可用的相似度分析管线jieba 分词 TF-IDF 余弦相似度3.1 准备环境与最小依赖尽量避开过新的版本在开始写核心代码之前先把运行环境搭稳。文本相似度分析用到的基础库只有三个jieba 负责分词scikit-learn 负责 TF-IDF 向量化和余弦相似度计算pandas 负责读写结果。安装命令如下pip install jieba scikit-learn pandas这里有个血泪经验scikit-learn 的版本不要盲目追新如果你是 Python 3.8 或 3.9 环境装 1.1.x 或 1.2.x 就够用不必强行上 1.3 以上版本。新版本对 NumPy 的版本有连带要求一旦环境里已有其他工程锁定 NumPy 1.23升级 sklearn 时 pip 会尝试帮你“修复依赖”结果可能把整个环境的 NumPy 都给换了导致其他脚本大面积报错。我一般做法是先把四个库的版本装在一个新的虚拟环境里避免污染项目主环境。如果是第一次接触直接用 venv 隔离是最省心的python -m venv sim_env source sim_env/bin/activate # Windows 下用 sim_env\Scripts\activate pip install jieba scikit-learn pandas版本选定之后还要确认 jieba 能正常加载默认词典。有些精简版 Linux 环境缺少中文字体或编码配置jieba 加载词典时可能出现乱码但多数情况下不影响分词结果。真正致命的是 Python 文件头部忘记声明编码导致源码里的中文注释直接让解释器报 SyntaxError——UTF-8 编码必须写清楚这是新手最常见的开场白翻车。3.2 文本预处理这一步决定后续相似度分数可信度拿到原始文本后不要直接丢给 TfidfVectorizer。常见做法是走“清洗 → 分词 → 过滤停用词”三步。清洗环节要处理的是空值、URL、换行符、HTML 标签分词环节决定算法的基本颗粒度过滤环节决定哪些词进入向量。下面给一个可复用的预处理函数import re import jieba # 停用词表可以按业务扩充这里放一小批高频无意义词 STOP_WORDS set([ 的, 了, 在, 是, 有, 和, 就, 不, 都, 而, 及, 与, 着, 或, 一个, 没有, 我们, 你们, 这个, 那个 ]) def clean_text(text: str) - str: 清洗原始文本去掉网页标签、URL、换行和多余空白 if not isinstance(text, str): return text re.sub(r[^], , text) # 去 HTML 标签 text re.sub(rhttp\S|www\.\S, , text) # 去 URL text re.sub(r\s, , text) # 合并空白 return text.strip() def tokenize(text: str) - list: 先清洗再分词返回过滤停用词和标点后的词列表 text clean_text(text) # jieba.cut 返回生成器用 lcut 直接得到列表更省事 words jieba.lcut(text) # 过滤掉单字符、纯标点、空字符串和停用词 result [] for w in words: w w.strip() if not w: continue if w in STOP_WORDS: continue if len(w) 1 and not w.isdigit(): # 去掉单字但保留数字 continue result.append(w) return result这个函数的参数开设了两层过滤第一层是停用词表第二层是单字符过滤。需要留意的点是len(w) 1 and not w.isdigit()会把“好”“赞”这类有实际意义的单字也滤掉如果业务里有很多单字情感词建议把过滤条件放宽为仅丢弃非数字单字符。另一个容易忽略的细节是 jieba.lcut 对英文单词的切分默认不区分大小写如果你希望“Apple”和“apple”是同一个词就不需要额外处理但如果你要保留产品名的大小写区分需要在分词前做一次大小写归一化或反向保留。预处理函数的返回值直接决定向量空间的词表规模词表越小计算越快但信息损失也越大这个平衡点在下一节的参数说明里会具体展开。3.3 用 TfidfVectorizer 生成向量调用方式与关键参数预处理函数就绪后进入向量化和相似度计算环节。先给最省事的官方封装版本适合快速验证结果from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity import pandas as pd # 模拟一批待比较的文本 documents [ 北京今日天气晴朗适合外出游玩, 今天北京的天气很好适合出去玩, 上海明天有雨出门记得带伞, 北京天气晴朗游玩推荐去颐和园 ] # 传入自定义的分词函数 vectorizer TfidfVectorizer(tokenizertokenize, lowercaseTrue) tfidf_matrix vectorizer.fit_transform(documents) # 计算余弦相似度矩阵第 i 行第 j 列代表第 i 和第 j 条文本的相似度 sim_matrix cosine_similarity(tfidf_matrix, tfidf_matrix) # 转成 DataFrame 方便查看 df pd.DataFrame(sim_matrix) df.columns [fdoc{i} for i in range(len(documents))] df.index [fdoc{i} for i in range(len(documents))] print(df.round(3))这个代码块里最重要的一个参数是tokenizertokenize它让你完全掌控分词逻辑而不是让 TfidfVectorizer 使用默认的字符级预处理。默认 tokenizer 只会按空白切英文和数字对中文基本无效。其他需要关注的参数包括lowercaseTrue会把所有英文转小写适合大部分场景如果业务中“Python”和“python”要区分改成lowercaseFalse。min_df1表示词至少在 1 篇文档中出现才纳入词表数据量小的时候保持默认即可数据量大时可以设min_df2或min_df3能有效减少稀有词带来的噪声。max_df0.9表示在超过 90% 的文档中都出现的词会被过滤相当于内置了一个高频词过滤器。集群中像“公司”“产品”这类词如果出现在几乎每篇文档里它们对区分度毫无贡献用这个参数压掉效果很明显。norml2是默认值它会将每个向量归一化为单位长度让余弦相似度只关注方向不关注长度。3.4 手动实现 TF-IDF 与余弦拆开黑匣子看分数来源官方封装能让你跑通但很多熟手卡在“分数为什么是这个数”的追问上。这时建议手动写一遍 TF-IDF 和余弦计算算法逻辑并不复杂写完之后再回头看 TfidfVectorizer 的输出心里就踏实了。下面给一个教学版实现import math from collections import Counter def compute_tf(words: list) - dict: 词频每个词在文档中出现的次数 / 文档总词数 total len(words) count Counter(words) return {word: cnt / total for word, cnt in count.items()} def compute_idf(documents_words: list) - dict: 逆文档频率总文档数 / 包含该词的文档数再取对数加 1 sklearn 默认使用平滑版log((1 N) / (1 df)) 1 N len(documents_words) df {} for words in documents_words: for word in set(words): df[word] df.get(word, 0) 1 idf {} for word, freq in df.items(): idf[word] math.log((1 N) / (1 freq)) 1 return idf def cosine_sim(vec1: dict, vec2: dict) - float: 两个稀疏向量的余弦相似度 common_words set(vec1.keys()) set(vec2.keys()) dot sum(vec1[w] * vec2[w] for w in common_words) norm1 math.sqrt(sum(v * v for v in vec1.values())) norm2 math.sqrt(sum(v * v for v in vec2.values())) if norm1 0 or norm2 0: return 0.0 return dot / (norm1 * norm2) # 使用之前的 tokenize 函数做分词 docs_words [tokenize(doc) for doc in documents] idf compute_idf(docs_words) # 构造每个文档的 TF-IDF 向量 tfidf_vectors [] for words in docs_words: tf compute_tf(words) vec {word: tf[word] * idf[word] for word in tf} tfidf_vectors.append(vec) # 验证 doc0 和 doc1 的相似度 print(doc0 vs doc1:, round(cosine_sim(tfidf_vectors[0], tfidf_vectors[1]), 3)) print(doc0 vs doc2:, round(cosine_sim(tfidf_vectors[0], tfidf_vectors[2]), 3))手动版本与 sklearn 版本的分数会有微小差异原因在于平滑公式和是否开启子线性 TF 缩放。sklearn 的sublinear_tfTrue把 TF 替换为1 log(TF)这在长文档比较时能防止高频词主导相似度如果你发现结果被一两个反复出现的词绑架可以打开这个参数对比一下分数分布。手动实现的意义并不在于替代 sklearn而是在排查时能自己算一遍分数来源比如“doc0 和 doc1 取了交集词权重最高的几个是什么”。实际交付时用官方封装足够但面试和排障时手动版本是立得住的基本功。3.5 把两两比较封装成可复用函数阈值、输出、入库工程上你不会每次都手动画矩阵通常要封装成一个函数、接收一批文本、输出相似度对。下面给出一个可直接接业务的最小封装def similarity_pairs(texts: list, threshold: float 0.5) - list: 输入文本列表输出相似度超过阈值的文本对及其分数。 返回格式: [(idx_i, idx_j, score), ...] vectorizer TfidfVectorizer(tokenizertokenize, lowercaseTrue) tfidf_matrix vectorizer.fit_transform(texts) sim_matrix cosine_similarity(tfidf_matrix, tfidf_matrix) pairs [] n len(texts) # 只取上三角避免重复比较自己和自己 for i in range(n): for j in range(i 1, n): score sim_matrix[i][j] if score threshold: pairs.append((i, j, round(score, 3))) # 按分数降序排列方便先处理最明显的重复 pairs.sort(keylambda x: x[2], reverseTrue) return pairs # 示例调用 texts [ 北京今日天气晴朗适合外出游玩, 今天北京的天气很好适合出去玩, 上海明天有雨出门记得带伞, 北京天气晴朗游玩推荐去颐和园 ] for i, j, score in similarity_pairs(texts, threshold0.5): print(f第{i}条 与 第{j}条 相似度: {score})这个函数有一个容易踩的坑当texts里存在空字符串时fit_transform不会报错但会生成一个全零向量与任何文本的余弦相似度都是 0看起来没问题实际上所有空文本都被丢弃了如果你的后续任务要求保留空文本占位需要在外层先给每条文本打上原始索引避免内部下标对不上业务主键。另一个常见需求是输出 TopK 而不是阈值过滤做法是把pairs切片成前 K 个但要注意 TopK 会隐瞒“低于阈值但排名靠前”的噪声对所以正式交付时我习惯两者都看先用 TopK 数量感知工作量再用阈值固定决策边界。4. 文本相似度最容易翻车的 5 个坑现象、原因、排查路径4.1 空值文本导致全零向量相似度集体归零现象数据是从 Excel 或数据库读出来的部分单元格是 NaN传给 TfidfVectorizer 后程序不报错但输出的相似度矩阵里好几行全是 0排在后面的正常文本对也被拉低了均值。原因pandas 读取空单元格得到float(nan)进tokenize时被isinstance(text, str)拦截成空字符串分词结果为空列表向量自然全是 0。如果文本列表里有 None 而不是空字符串部分 sklearn 版本会直接抛 ValueError。排查路径打印预处理后的文本列表检查是否存在空字符串更彻底的做法是在进入向量化之前加一行断言。assert all(isinstance(t, str) for t in texts), 文本列表存在非字符串元素 texts [t if isinstance(t, str) else for t in texts]解决在tokenize内部对空结果直接返回一个占位 token 不是好方案那会制造一个“空文本与所有文本相似度为 0.2”的假象。正确做法是在业务层把空文本单独标记不参与相似度计算最后输出时再把空文本按“未比较”状态合并回去。宁可缺失相似度分数也不能给下游一个假的低分。4.2 分词不一致导致同一语义被切出不同词现象两条文本“Python 文本相似度分析”和“python 文本相似度分析”预想相似度应是 0.95 以上实际只有 0.6。打印词表发现第一条切出“python”第二条切出“text”。原因默认 TfidfVectorizer 的 tokenizer 是按我传入的tokenize函数走但 jieba 对英文和数字的切分有自己的规则。如果输入是“Python文本相似度”jieba 可能切成“python”和“文本”而“Python”首字母大写时被保留成了“Python”造成同一个词的两个变体进入词表。lowercaseTrue可以缓解但只对英文生效中文全角英文字符不会被归一化。排查路径打印vectorizer.get_feature_names_out()查看词表里是否同时存在 “python” 和 “Python” 或 “python” 和 “”。解决在clean_text里统一做一次 Unicode 规范化把全角字符转半角再统一小写。unicodedata.normalize是标准做法把全角英文字母映射到半角import unicodedata def normalize_text(text: str) - str: # NFKC 会把全角英文字母、数字转为半角 text unicodedata.normalize(NFKC, text) return text.lower()普通文本做 lower 没坏处但如果业务需要保留“iPhone”和“iphone”的差异比如商品名不要全局 lower改为在业务词表里维护别名映射把 “iPhone” 映射到规范词 “苹果手机”比一刀切更保险。4.3 小样本数据集的 IDF 失真导致相似度被罕见词绑架现象只有 50 条文本其中一条包含“量子纠缠”这个词其他文本都没出现过这个词。计算相似度时包含该词的两条文本相似度直接拉到 0.8而业务上它们只是偶然聊到了同一个概念内容差异很大。原因IDF 的作用是压低高频词、抬升罕见词但在小样本里一个词的 IDF 完全由“几乎没出现过”这一个信息决定它不代表这个词的语义强度。sklearn 的平滑公式log((1N)/(1df))1让只出现一次的词拿到接近理论最大值的权重于是它成了相似度主导项。排查路径打印词表中权重最高的 10 个词看它们的 IDF 是否都在 3.5 以上对应出现次数仅为 1 的极端情况。如果高频权重词全是业务上无关的罕见词基本可以断定是样本量太小导致的失真。解决三个方向同时做。第一扩大背景语料把额外语料作为 IDF 计算文档一起fit但只对当前业务文本做transform第二对 TF-IDF 权重做截断比如把单个词项权重压到 0.3 以内防止一个词独大第三在词表层面过滤给min_df2强制每个词至少在 2 篇文档中出现过直接剔除只出现一次的偶发词。第三种最粗暴但最有效缺点是如果业务里有很多真正的长尾专业词汇它们也会被误杀要结合业务判断。4.4 两两比较是 O(n^2)文本量过千后内存翻车现象文本量到 5000 条时cosine_similarity(tfidf_matrix, tfidf_matrix)生成的矩阵是 5000×5000float64 类型占 200MB勉强能跑到 20000 条时矩阵占 3.2GB进程直接 OOM。原因相似度矩阵是一个稠密矩阵即使底层向量稀疏相似度矩阵本身是每对文本都会算出一个分数。内存增长是 n^2 级别不受词表稀疏度影响。排查路径用sim_matrix.nbytes查看矩阵字节数确认是否在内存预算内。如果数据量预期会增长尽早把架构改成“只算 TopK 近似”的模式。解决两条路。第一分块计算每次只算 1000×1000 的子矩阵保留大于阈值的分数丢弃其余部分第二用NearestNeighbors配合metriccosine做近似最近邻检索只返回每行 TopK不构建完整矩阵。工程上我一般建议直接上第二种NearestNeighbors 底层是 KD 树或球树在稀疏高维数据上速度远优于暴力计算。但要注意近似检索会漏掉一些真实近邻漏检率与数据分布有关在做严格去重而不是检索的场景下分块暴力计算反而更可靠。4.5 停用词表覆盖不足导致所有分数都偏高现象两条完全不相干的新闻都被判定为 0.5 相似度人工抽检发现它们都包含大量“公司”“表示”“认为”“相关”等中性词这些词在词表中权重并不低。原因前期维护的停用词表只覆盖了“的、了、是”这类基础词没有覆盖领域内的高频无意义词。在财经新闻里“公司”出现率极高但区分度极低在技术文档里“实现”“方法”也是同理。IDF 只在文档频次差异大的词上有区分能力对普遍出现的中性词无能为力。排查路径打印vectorizer.get_feature_names_out()并按 IDF 从低到高排序看排在最前面的词是否大部分是业务噪声。把它们加入停用词表后重新计算观察相似度分数分布的变化。解决建立领域停用词表从结果倒推更高效。跑一次全量数据提取 IDF 最低的 200 个词人工勾掉有区分度的业务词剩下的全部进停用词表。这是一个迭代收敛的过程不要指望一版停用词表到位。另外max_df0.9的参数可以自动压制这类词但它的阈值是全局的对某些文档频次在 80% 但语义关键的词会误伤人工维护词表仍然是不可省的一步。5. 再进一步用矩阵运算替代双循环对比验证阈值是否靠谱相似度对生成之后最后要做的不是直接交付分数而是验证这套分数在业务上是否稳健。一个常见的低级错误是用文本本身的字符相似度去校验 TF-IDF 相似度结果发现“北京今日天气晴朗”和“今天北京天气晴朗”字符相似度只有 0.6而 TF-IDF 给到 0.9就怀疑算法有 bug。实际上这是两种不同颗粒度的度量不该直接对比。验证正确做法是构造一组已知正负样本比如 100 对人工标好的“应该相似”和“应该不相似”文本计算相似度后画出阈值与准确率的曲线找出让误杀率和漏过率都可接受的阈值点。进阶优化方向上第一个值得做的是把双循环替换成矩阵乘法。假如你要计算 A 列表 2000 条文本和 B 列表 2000 条文本的交叉相似度双循环要跑 400 万次余弦计算而cosine_similarity(A_vec, B_vec)一行代码就能拿到完整矩阵。第二个优化是保留 TF-IDF 向量的稀疏性不要toarray()转稠密矩阵稀疏矩阵参与矩阵乘法时内存和耗时都小一个量级。第三个优化是给相似度对加上业务置信度比如文本长度相差 3 倍以上时即便分数高也标记为可疑因为 TF-IDF 对长度不敏感但业务上 20 字的标题和 2000 字的正文不该被判定为重复。另一个值得养成的工作习惯是每次跑完都保留一份带参数的实验记录至少记下停用词表版本、min_df、max_df、sublinear_tf 是开还是关、阈值设了多少。文本相似度分析的黑匣子不在于算法多么难懂而在于每次调参后结果都变没有版本管理就找不到最优参数组合。我用一个极简的 CSV 记录每次实验的参数和抽检准确率累计二十多次迭代就能摸清一套相对稳定的配置这比靠感觉调参可靠得多。最后提醒一句相似度分数只是决策的输入不是决策本身。阈值定在 0.6 还是 0.8永远取决于你的业务能容忍漏报还是误报——宁缺毋滥的查重要压低阈值多召回宁可错杀不可放过的反作弊则要提高阈值严格过滤。这条经验是从多次带着“分数到底怎么定”的困惑中摸出来的希望帮到你。本文还有配套的精品资源点击获取
返回列表