简介:一个基于Python实现的文本相似度计算小项目,通过简洁代码演示余弦相似度算法在中文文本比较上的应用,适合自然语言处理入门者、算法学习者以及需要快速实现文本匹配功能的前后端开发者使用。项目共4个文件:1个可直接运行的Python主程序、1个Markdown格式的Readme说明、1个License许可证以及1个Git忽略文件,压缩包整体约3KB,结构紧凑。目前已有4207人学习下载。主程序演示了文本相似度计算的完整链路:先通过nltk对输入文本进行分词,再使用sklearn的CountVectorizer构造词袋向量、TfidfVectorizer构造TF-IDF向量,最后借助numpy计算两个向量的点积与范数,从而得到余弦相似度。代码同时给出两种向量化方式的比对结果,注释简洁,便于理解分词、词表构建、向量空间表示等关键概念。通过阅读源码与说明,读者可以快速掌握余弦相似度的原理与实现方式,并将该脚本直接复用于文档去重、信息检索、推荐系统、智能问答等常见场景。
1. 余弦相似度算法在文本相似度匹配中的第一个坑:向量长度
做文本去重时,我碰到过一个很直观的现象:两段摘要明显是同一主题,按字符重合度看却不到一半,用 Jaccard 也低得离谱。后来换成余弦相似度算法,情况立刻变了——它计算的是两个词频向量的夹角余弦,只关心方向,不关心向量模长。也就是说,一篇 50 字的短句和一篇 5000 字的长文,只要词频方向一致,相似度可以是 1。这个特性正是它在文本相似度场景里比编辑距离、Jaccard 更常用的原因。适合刚接触 Python 文本处理的人作为入门案例,也适合在搜索引擎、推荐系统的召回阶段做初筛。
2. 文本向量化:分词、词表与词袋模型的距离
2.1 分词决定了余弦相似度算法的上限
余弦相似度算法本身不直接吃字符串,它吃数值向量。所以第一步是把文本拆成可以计数的单元。中文可以用 jieba,英文可以直接用 nltk.word_tokenize。这里有一个常见的误解:分词不是越细越好。拆成单字会丢掉词组信息,“北京”和“首都”在单字向量里完全不相关;拆成整句又得不到稳定维度。我一般在简单实现里先把标点、换行过滤掉,再用 jieba 的精确模式切词:
import jieba import re def tokenize(text): # 去掉非中英文和数字的字符,避免标点干扰词频统计 text = re.sub(r'[^\w\u4e00-\u9fa5]+', ' ', text) return [w for w in jieba.cut(text) if w.strip()]这段代码核心是两件事:正则清理字符,jieba 按词典切分。re.sub里的\w对英文和数字有效,\u4e00-\u9fa5覆盖常用汉字,过滤掉标点和特殊符号,否则“。”会被当成一个词进入词表,白白增加维度。分词之后的结果是一个词列表,后续构建词表时需要对所有文档同时统计,不能只对单条文本统计,否则两个文本的向量维度对不上。
2.2 CountVectorizer 与 TfidfVectorizer 的取舍
向量化的常见做法有两种:词袋模型(BoW)和 TF-IDF。BoW 统计词频,实现简单,但“的”“了”这类高频停用词会占据很大的权重;TF-IDF 用词频乘逆文档频率,降低跨文档都出现的词的权重,让“余弦”“相似度”这类有区分度的词主导结果。下面这段代码把两条文本同时向量化:
from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer texts = [ "Python 余弦相似度算法计算文本相似度", "使用Python的余弦相似度分析文本" ] bow_vec = CountVectorizer(tokenizer=tokenize) bow_matrix = bow_vec.fit_transform(texts) tfidf_vec = TfidfVectorizer(tokenizer=tokenize) tfidf_matrix = tfidf_vec.fit_transform(texts) print(bow_vec.get_feature_names_out())这里的fit_transform分两步:fit建立词表,transform把每条文本映射成向量。bow_matrix是一个稀疏矩阵,行对应文本,列对应词表中的词。直接打印会看到(0, 2) 1这样的坐标形式,这是 scipy 稀疏矩阵的存储方式,不是 bug。后面的余弦相似度计算需要先.toarray()转成密集数组,或者用能处理稀疏矩阵的sklearn.metrics.pairwise.cosine_similarity。实际项目中我通常优先选 TF-IDF,因为它对高频无意义词的抑制是自动的,不需要手工维护太细的停用词表。
提示:
CountVectorizer默认的token_pattern只匹配字母数字,中文会被整个丢掉,所以必须显式传入tokenizer参数。
| 维度 | CountVectorizer | TfidfVectorizer |
|---|---|---|
| 权重含义 | 词频 | 词频 × 逆文档频率 |
| 跨文档常见词 | 权重偏高 | 自动压低 |
| 适用场景 | 关键词出现次数本身有意义 | 文本相似度、检索排序 |
| 输出矩阵 | 稀疏矩阵 | 稀疏矩阵 |
如果拿两个独立训练的向量去算相似度,维度不同直接报错。工程里常见做法是拿全量语料先fit一次词表,再把新来的文本只做transform。这个“先 fit 后 transform”的顺序经常被忽略。新手容易对每条文本单独调用fit_transform,结果是每条文本的向量维度完全不同,np.dot会抛出 shape mismatch。所以要保存一个tfidf_vec对象,新文本进来时调用tfidf_vec.transform([new_text]),词表保持训练时的样子。
3. Python 实现余弦相似度核心算法:从 numpy 到源码解析
3.1 用 numpy 手写余弦相似度计算
给定两个向量 A 和 B,余弦相似度公式是(A·B) / (|A| × |B|)。使用 numpy 可以这样写:
import numpy as np def cosine_similarity(vec_a, vec_b): dot_product = np.dot(vec_a, vec_b) norm_a = np.linalg.norm(vec_a) norm_b = np.linalg.norm(vec_b) return dot_product / (norm_a * norm_b)np.dot计算逐个元素乘积再求和,np.linalg.norm计算向量欧几里得范数,也就是向量的长度。这个函数的隐含假设是norm_a和norm_b都不为 0。如果某个文本全被过滤成空字符串,对应的向量全零,np.linalg.norm返回 0,分母会变成 0,得到nan。在实际的simpe_text_cosine_similarity.py这类简单实现中,通常会在函数入口判断一下:
if norm_a == 0 or norm_b == 0: return 0.0返回 0 表示其中一个文本没有有效特征,和任何文本都不相似。这在代码逻辑上说得通,但如果你面对的是一批短文本,空向量比例可能很高,需要留意 0 值的数量,否则最终相似度矩阵会有一大片 0,看不出真实差异。
3.2 稀疏矩阵与密集数组的转换陷阱
CountVectorizer返回的bow_matrix是scipy.sparse.csr_matrix,直接传入np.dot时,numpy 可能会把它当成对象数组处理,结果不是期望的数值。常见做法是.toarray()转成二维数组,再取第一行。下面是完整示例:
from sklearn.metrics.pairwise import cosine_similarity vec1 = tfidf_matrix.toarray()[0] vec2 = tfidf_matrix.toarray()[1] print(cosine_similarity([vec1], [vec2])[0][0])这里我用了 sklearn 内置的余弦相似度函数,它内部对稀疏矩阵做了优化,不会真的转成 dense。cosine_similarity接收二维数组,每行是一个文本向量,返回值是二维相似度矩阵。很多人在这段代码上踩坑:直接传vec1和vec2两个一维数组,返回值变成标量或者形状不一致。必须用[vec1]包一层,让它变成1 × n的矩阵。
这两种写法效果一样,但性能差别明显。几万条文本做两两相似度时,.toarray()会把稀疏矩阵膨胀成巨大的 dense 矩阵,内存直接翻车。所以生产代码里我会直接用cosine_similarity(tfidf_matrix),它内部用稀疏矩阵乘法实现,只计算非零位置的乘积,速度能快一个数量级。
在py3-cosine-similiarity-master这类小项目中,通常把分词、向量化、相似度计算拆成三个函数。Readme.md里会写调用流程,.gitignore排除虚拟环境。一个可复用的做法是把cosine_similarity封装成脚本入口,接收两个文本路径或者命令行参数:
python simpe_text_cosine_similarity.py "文本A" "文本B"脚本内部依次调用tokenize、TfidfVectorizer、cosine_similarity,最后输出0.87这样的浮点数。这个实现的关键不是函数多复杂,而是数据格式在每一步都要对齐:分词返回列表,向量化返回稀疏矩阵,相似度函数接收二维矩阵。只要这一步不统一,后续所有封装都会报错。
4. 停用词、词干提取与相似度阈值的工程调优
4.1 预处理对余弦相似度算法结果的影响
在基础实现跑通后,相似度数字往往不符合直觉。比如“我用Python写爬虫”和“我爬虫用Python写”词频完全一样,余弦相似度是 1,这正确。但“Python写爬虫”和“用Python做数据分析”可能只有 0.2,因为共享词太少。这时候第一反应不是调算法,而是检查预处理。停用词表能显著改变结果:
stop_words = {'的', '了', '和', '与', '在', '是', '对', '中', '就', '都'} def tokenize_with_stopwords(text): text = re.sub(r'[^\w\u4e00-\u9fa5]+', ' ', text) return [w for w in jieba.cut(text) if w.strip() and w not in stop_words]加上停用词之后,无意义的“的”“了”不再进入词表,向量维度缩小,和主题相关的词权重相对变大。但要小心,停用词不一定都是虚词,在特定领域“计算”“使用”这种高频词可能也不该参与相似度。工程上我用的是先看词频 Top 30,再人工决定哪些词加入停用表。盲目套用网上停用词表会把领域关键词删掉。
词干提取对英文有意义,对中文一般不用。中文不存在running到run的形态变化,但英文文本可以加PorterStemmer。如果语料是中英混合,我通常会单独处理:中文走 jieba,英文走word_tokenize加SnowballStemmer,最后把两个词列表拼起来。代码里要小心nltk.download('punkt')这个网络初始化步骤,在离线环境会直接报错,需要提前把 punkt 包放进 nltk_data。
4.2 相似度阈值怎么定才不算瞎调
阈值是文本相似度项目里最容易被问到的参数。0.8 以上才算相似?不一定。取决于向量化方式和文本长度。文本越长,非零维度越多,余弦值普遍偏小。我做过一个短标题去重任务,TF-IDF 向量的相似度分布集中在 0.1~0.4,0.5 以上明显是同一商品的变体。另一个长文档分类任务,同类文档相似度也只有 0.4。所以正确的做法是先跑一批样本,把相似度分布画出来,再根据任务选择阈值。下面这个表格是常见的经验范围:
| 场景 | 向量化方式 | 经验阈值 |
|---|---|---|
| 短文本去重 | TF-IDF | 0.8 以上 |
| 新闻标题聚类 | BoW | 0.6 以上 |
| 长文档相似 | TF-IDF | 0.4 以上 |
| 推荐召回 | TF-IDF | 0.3 以上可入候选 |
这个表不是标准答案,它只是说明不同场景下同一套余弦相似度算法输出的分布完全不同。调阈值时至少要看一个批次的正负样本分布,而不是拍脑袋定 0.8。如果分布图显示两个类别都堆在 0.3 附近,就说明特征工程有问题,改阈值没有意义。
4.3 相似度计算失败时优先排查的三个点
遇到nan先查零向量,遇到维度不一致查 fit 和 transform 是否分开。遇到相似度全部为 0 则查分词是否成功,特别是中文文本没有传tokenizer参数时,CountVectorizer默认正则匹配不出中文,词表为空,结果自然全零。这三个点覆盖了我在这个简单实现里遇到的绝大多数问题。排查时在分词函数后加一行print(tokens),比反复计算相似度直观得多。
5. 余弦相似度算法批量比较文本的向量化进阶写法
5.1 一次调用算出所有文本两两相似度
当文本数量从 2 条变成 2000 条,仍然用双重循环调用cosine_similarity会非常慢,而且代码难看。正确做法是直接把整个tfidf_matrix传给cosine_similarity,一次得到 N×N 矩阵:
from sklearn.metrics.pairwise import cosine_similarity import numpy as np # tfidf_matrix 的形状是 (n_samples, n_features) sim_matrix = cosine_similarity(tfidf_matrix) print(sim_matrix.shape) # 把对角线置 0,方便找最大相似度 np.fill_diagonal(sim_matrix, 0) for i, row in enumerate(sim_matrix): j = np.argmax(row) print(f"sample {i} 最相似的是 sample {j},相似度 {row[j]:.3f}")这里cosine_similarity会自动处理稀疏矩阵,内部用矩阵乘法计算,避免了 Python 层循环。np.fill_diagonal把每个文本和自身的相似度 1 清零,否则argmax永远指向自己。输出时用 f-string 格式化相似度保留三位小数,方便快速检查阈值。
这个写法的计算量级是 O(n²),2000 条文本生成 400 万个数值,内存大约 32MB,尚可接受。如果文本量上到几万,sim_matrix就会膨胀到几十 GB,这时候要用 minhash 或 SimHash 做候选召回,再用余弦相似度精排。这是另一个话题,但你要清楚余弦相似度算法的边界:它能处理中等规模两两比较,不适合海量全连接。
5.2 用 Top-K 相似结果验证阈值是否合理
批量算完相似度后,我通常会看每个样本的最相似 Top3 分布。如果绝大多数样本的最相似相似度都低于 0.2,说明语料本身区分度差,或者预处理过于激进。反之,如果所有样本最相似度都接近 1,说明文本几乎重复,任务可能不需要相似度模型。随着文本数据积累,这个验证步骤应该固化成脚本的一部分,每次换语料都跑一遍。
本文还有配套的精品资源,点击获取