ARTICLE DETAIL

资讯详情

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

用Python实现余弦相似度:从原理到TF-IDF文本比较实战

用Python实现余弦相似度:从原理到TF-IDF文本比较实战 简介一款演示用 Python 实现余弦相似度算法的轻量源码包面向希望快速理解文本向量化与相似度计算的初学者或开发者。资源不仅附带可运行的示例代码还讲解了余弦相似度的核心原理通过计算两个非零向量的夹角余弦值衡量相似程度结果分布于负一到一之间。实现部分覆盖分词、建立词汇表、使用词袋模型或 TF-IDF 将文本转为向量再借助 numpy 求出相似度串联起从原始文本到最终得分的完整流程。压缩包共四个文件以 Python 脚本为主体另有说明文档、开源许可和 Git 忽略配置整体仅约三 KB结构精简便于快速研读。整体代码风格简洁便于阅读和修改。目前已有4207人学习下载内容虽小巧但兼顾理论梗概与可执行实现适合用作算法入门练习或教学辅助。1. 余弦相似度解决的不只是“判断两段话像不像”做文本去重、搜索排序、问答候选匹配或内容推荐时很多人第一反应是上模型、上向量库。但多数业务里真正需要的只是一个能快速算出的“方向一致程度”分数余弦相似度算法就是这个分数最朴素的来源。它不看文本长短只看向量夹角短句和长文比较时不会被篇幅带偏。我用 Python 落地的路径通常分三层先谈向量化选型再写最小可跑通的实现最后调参数适配真实数据。整个过程不需要 GPU、不需要训练几十行代码就能跑通原型。接下来按这个顺序把原理、代码和坑一次讲完新手能照着复现熟手也能在参数和边界处理上找到可复用的判断依据。文中所有代码都按“能直接复制跑通”的标准写涉及中文的地方会单独给切词替代方案。2. 余弦相似度的几何直觉与文本向量化选型2.1 为什么文本比较里余弦比欧氏距离更稳两个文本各自表示成向量后欧氏距离度量的是绝对坐标差向量越长、维度越大距离就越容易被“篇幅”或“高频词”主导。举例来说一篇 200 字的新闻和一篇 2000 字的新闻讲同一件事词频分布相近欧氏距离会因为维度膨胀被拉得很大但余弦只看夹角分数依然接近 1。这正是信息检索领域长期用余弦作为排序分的根本原因文本的相似是“比例上的相似”不是“数量上的相等”。余弦公式里的除法本质是一次归一化。分子是两个向量的点积分母是两个向量模长的乘积作用等价于先把两个向量各自拉回单位长度再做点积。因此只要向量被 L2 归一化过余弦相似度就退化成一次纯点积运算。这个等价关系在后面讨论 sklearn 参数和性能优化时会被反复用到也是排查“分数为什么溢出”“为什么不在 [0, 1] 区间”的第一条线索。2.2 词袋、TF-IDF、词向量简单实现选哪条路向量化方案维度来源是否需训练语义能力简单实现推荐度词袋 CountVectorizer词表长度否无只统计频次教学与最小原型TF-IDF词表长度否削弱停用词、突出关键词默认选型Word2Vec / 词向量均值池化嵌入维度是有基础语义迁移原型阶段不推荐我一般直接选 TF-IDF 作为简单实现的主方案。词袋把“的、了、是”这类停用词和核心词同等对待相似度容易被高频词带偏两个都在讲接口文档的文本会因为“我们、以及”这类词虚高词向量又引入训练成本和词表外词OOV问题数据量不够时均值池化出来的向量区分度很差。TF-IDF 不需要训练只要语料规模合理就能把常见词的权重压下去让文本主干词浮出来。2.3 手算一个三维向量看懂分数从哪来假设两个文本切词后得到三个词 A、B、C词频向量分别是 v1 [2, 1, 0] 和 v2 [1, 2, 1]。先算点积2×1 1×2 0×1 4。再算模长‖v1‖ sqrt(41) ≈ 2.236‖v2‖ sqrt(141) ≈ 2.449。余弦值就是 4 / (2.236 × 2.449) ≈ 0.73夹角大约 43 度两个文本方向足够接近。用 Python 验证一遍import numpy as np v1 np.array([2, 1, 0]) v2 np.array([1, 2, 1]) cos np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2)) print(cos) # 0.7302967433402214这个手算示例顺带暴露了三个边界行为两个向量完全相同时余弦值为 1完全不共享任何词时点积为 0余弦值等于 0某个向量全零时模长为 0公式直接除零。实际编码必须处理最后一种情况我习惯在归一化前加一个“任一向量模长为 0 则返回 0”的分支这个约定在后面所有实现里保持一致。3. 用 Python 写最小可跑通的余弦相似度实现3.1 纯 Python 字典先绕开所有依赖先从零依赖的写法开始便于看清每一步在做什么。下面的代码用字典统计词频手写点积和模长所有计算逻辑都摊在明面上不借助 numpy 和 sklearnfrom collections import Counter import math def tokenize(text): # 英文按空白切词即可中文场景请替换为 jieba.cut return [w for w in text.lower().split() if w] def cosine_similarity_pure(text1, text2): # Counter 统计词频得到两个“词 - 频次”字典 vec1 Counter(tokenize(text1)) vec2 Counter(tokenize(text2)) # 取两个词表的并集作为公共维度缺失的词补 0 vocab set(vec1) | set(vec2) dot sum(vec1.get(w, 0) * vec2.get(w, 0) for w in vocab) # 模长 各分量平方和的平方根 norm1 math.sqrt(sum(v * v for v in vec1.values())) norm2 math.sqrt(sum(v * v for v in vec2.values())) # 任一文本没有有效词时向量为零相似度无意义约定返回 0 if norm1 0 or norm2 0: return 0.0 return dot / (norm1 * norm2) print(cosine_similarity_pure(python list append, python list extend))这段代码的逻辑分四步Counter 构造词频字典两个字典的键并集作为公共维度点积只遍历并集缺失词用 get 的默认值补 0模长用平方和开根。返回值落在 [0, 1]0 代表没有公共词1 代表词分布完全一致。这里的 get(w, 0) 写法比直接取下标安全得多避免了 KeyError也省掉一层 if 判断。3.2 用 sklearn 收敛到可上线的写法纯 Python 版本适合理解原理但遇到标点、大小写、停用词干扰时需要自己补一堆正则和过滤逻辑写到最后代码量不比业务逻辑少。常见做法是换 sklearn 的 TfidfVectorizer把切词、建词表、算 TF-IDF、L2 归一化全部包进一个对象里管理from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity texts [ python list append vs extend, python list extend vs append, linux shell sort file by size ] vectorizer TfidfVectorizer(stop_wordsenglish, max_features5000) tfidf_matrix vectorizer.fit_transform(texts) # 输入是稀疏矩阵输出是两两相似度矩阵 sim_matrix cosine_similarity(tfidf_matrix) print(sim_matrix[0, 1]) # 前两条共享全部关键词分数接近 1 print(sim_matrix[0, 2]) # 无公共词分数接近 0fit_transform 在一次调用里完成建词表和向量化两件事之后对任意新文本可以直接调 transform保证词表和新文本的维度对齐。cosine_similarity 接收稀疏矩阵即可不会因为转稠密数组爆内存。sklearn 的 TfidfVectorizer 默认自带转小写和 L2 归一化这两点恰好是余弦计算最需要的预处理所以开箱即用时的输出通常已经是单位向量的点积结果。3.3 输出解读与参数边界上面代码里两个参数值得单独说明。stop_wordsenglish 表示剔除英文停用词避免 and、the、vs 这类词抬高无关文本的相似度max_features5000 限制词表规模防止冷门词撑高维度、拖慢后续的矩阵乘法。sim_matrix 是一个 n×n 对称矩阵对角线恒为 1非对角线元素就是对应两条文本的余弦相似度。拿输出看前两条文本共享 append、list、extend 三个关键词第二条只是调换了语序分数会非常接近 1第三条文本与第一条没有任何公共词分数趋近 0。这恰好验证了余弦的一个特性它统计公共词分布不做语义推断也不感知词序。如果业务里需要区分“python list”和“list python”这种顺序差异单纯靠余弦加 TF-IDF 是做不到的需要到下一章引入 n-gram 或词向量才能部分解决。提示调试相似度结果时先打印 vectorizer.get_feature_names_out() 看词表。词表正确与否决定了后续所有参数调整是否有效词表错了阈值和权重怎么调都是白调。4. 让结果可用的参数调整与真实文本坑点4.1 文本清洗直接套 sklearn 会遇到的三个问题把上一章的代码放到真实数据上最常见的是相似度虚高或虚低。虚高的典型原因是标点和 HTML 标签没清理两个讲不同接口的文档会因为在同一套模板里都出现 div、class、nbsp 这类噪音 token 被拉近距离。虚低的典型原因是切词不一致C、Python3.10 这种带特殊字符的词TfidfVectorizer 默认的 token_pattern 会按字母数字边界把它们拆碎甚至整个丢弃。第三个问题是换行和全角空格造成同义词分裂同一个词被记成两个 token。处理真实数据前我会先做一次轻量清洗再交给向量化器import re def clean_text(text): # 去掉 HTML 标签避免 div/span 这类噪音词进入词表 text re.sub(r[^], , text) # 保留中英文、数字以及 和 .保证 C、python 3.10 这类词不被切断 text re.sub(r[^a-zA-Z0-9\u4e00-\u9fa5.], , text) return re.sub(r\s, , text).strip()清洗规则按数据源收紧爬虫来的文本保留“”和“.”是为了让技术名词不被拦腰截断纯数据库字段可以更激进直接去掉全部标点符号。注意清洗必须发生在 fit_transform 之前顺序反了词表里会混进大量噪音 token。相似度系统上线后大多数“分数诡异”的问题最终都能追到清洗规则不一致上两侧文本走的清洗链路不同比对结果就没有意义。4.2 TfidfVectorizer 的 3 个必调参数参数默认值调整方向典型场景max_df1.00.80.95去掉在 80% 以上文档里都出现的“伪停用词”min_df125去掉只出现一次的拼写错误与噪音词norml2保持 l2余弦依赖单位向量改成 None 会直接失真sublinear_tfFalseTrue长文档词频爆炸时用 1log(tf) 压缩尺度这里说一个容易被忽略的细节norm 参数一旦改成 NoneTfidfVectorizer 输出的就不再是单位向量cosine_similarity 算出来的值会失真分数不再保证落在 [0, 1]。sublinear_tf 设成 True 的逻辑是让词频做对数增长避免一篇长文里某个词出现 50 次就把其他词全部压过。这两个参数分别从“向量长度”和“词频尺度”两个方向影响余弦值调试时要先确认它们没被误改再去看其他参数。max_df 和 min_df 的配合值得多写一句。max_df 的典型误用是设得太低把一些真正有价值的中频词也滤掉了min_df 的误用是设得太高导致小样本数据集上词表空掉。我的经验是先让 min_df1 跑一遍看 get_feature_names_out 的输出规模再根据噪音占比逐步往上调而不是一开始就给一个激进值。4.3 中文切词与英文 n-gram 的差异TfidfVectorizer 默认的 token_pattern 按字母和数字切词对中文一整段文字基本失效一整句会被当作一个 token相似度恒等于 0 或 1。中文场景的标准做法是引入 jieba 分词再用 callable 传给 tokenizer 参数import jieba def zh_tokenizer(text): # jieba 对清洗后的文本切词去掉纯空白 token return [w for w in jieba.cut(clean_text(text)) if w.strip()] vectorizer TfidfVectorizer(tokenizerzh_tokenizer, max_df0.85, min_df2)英文场景则相反我建议保留单词切分的同时开启 n-gram把 ngram_range 设为 (1, 2)或者干脆换 analyzerchar_wb这样拼写变体和紧邻词序能被部分捕获。注意 ngram_range 对中文也能配合 jieba 使用但词表会成倍膨胀必须同时限制 max_features否则矩阵维度直接失控。梯队化的调试顺序是先看切词结果再看词表规模最后才谈相似度阈值。5. 用对称性与极端样本给相似度实现做体检实现写完最值得做的一组验证不是找“看起来像”的例子而是用三个断言检查计算本身的正确性。第一对称性sim(a, b) 必须等于 sim(b, a)sklearn 的实现天然满足但手写版本一旦在归一化时用错了向量破坏对称性的概率很高所以纯 Python 版一定要补这个用例。第二自相似度sim(a, a) 必须恒等于 1前提是清洗、切词和向量化在两次输入上行为一致。第三零向量与空文本空字符串、纯标点文本不能抛异常返回值统一约定为 0。写成一段可直接运行的脚本from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity v TfidfVectorizer(stop_wordsenglish) m v.fit_transform([python go, go python, ]) # 断言一同文本自相似度恒为 1 assert abs(cosine_similarity(m[0], m[0])[0, 0] - 1.0) 1e-6 # 断言二对称性成立 assert abs(cosine_similarity(m[0], m[1])[0, 0] - cosine_similarity(m[1], m[0])[0, 0]) 1e-12这里要特别说明零向量TfidfVectorizer 对空文本会输出一行全零向量cosine_similarity 在内部处理时会警告甚至返回 nan。线上实现里我永远在调用前先过滤掉空文本或者在封装函数里对全零行直接返回 0而不是等 sklearn 抛出警告后再排查。一个实用技巧TF-IDF 矩阵配合 cosine_similarity在文本量上来后会变成 O(n²) 的矩阵乘法耗时随样本数平方增长。如果只需要在两组文本之间做匹配不要构造完整的 n×n 矩阵直接用 transform 得到单行向量再两两比较。另一个更常用的优化是预先对矩阵做归一化归一化之后余弦相似度退化成一次矩阵点积推理速度会明显提升from sklearn.preprocessing import normalize # 先做 L2 归一化之后用点积等价代替余弦相似度 normed normalize(tfidf_matrix) vec_pair normed[0:1].dot(normed[1:2].T).toarray()[0, 0]生产环境里我会把这一整套封装成一个函数入参是两个待比较文本出参是 [0, 1] 的分数对外只暴露 max_df、min_df 和分词器三个配置项。这样同一套实现可以稳定复用到文本去重、搜索候选排序和推荐召回的不同任务里遇到新数据源时只需要调整清洗规则而不需要重写相似度计算核心。本文还有配套的精品资源点击获取
返回列表