ARTICLE DETAIL

资讯详情

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

用Python实现LSH论文相似性比对:MinHash与Banding实战

用Python实现LSH论文相似性比对:MinHash与Banding实战 简介这套基于Python局部敏感哈希LSH算法的论文相似性比对工程面向毕业设计、学术查重与大规模文本去重场景。项目从中国论文网爬取80余篇真实论文构建语料库引入第三方库lshash配合自定义main.py实现文本哈希映射、相似候选检索与比对全流程配套test.txt可用于快速验证算法效果。资源共85个文件以78个txt论文语料为主体另有5个Python脚本、说明文档及停用词表压缩包整体仅340KB轻量且结构完整便于直接运行和二次改造。目前已有176人学习浏览。通过该资源既可理解局部敏感哈希中(r1,r2,p1,p2)敏感函数族的概率约束与参数意义也能掌握从文本预处理、哈希分桶到相似度输出的工程实现思路将语料替换为自己的论文集合即可完成查重实验是毕业设计与算法实践的高性价比参考。1. 用 Python 复现论文相似性比对LSH 到底解决了什么问题每年毕业季导师都会把学生论文集中到一块让我帮忙查重。常规做法是逐篇两两比对但本科论文一收就是几百篇纯暴力比对的时间和算力都吃不消。后来我改用局部敏感哈希Locality-Sensitive HashingLSH来做论文相似性比对任务从所有论文两两比较高相似度变成了先粗筛出很可能相似的候选对再精确比对速度快了两个数量级。这份资源对应的就是这样一套完整的 Python 实现从分词、特征构建到 LSH 索引查询都有可跑的代码。适合正在做相似性比对相关毕设的学生也适合需要处理中文文档近似去重或查重的从业者。LSH 的核心思路很简单让相似的文本在哈希后以高概率落在同一个桶里不相似的文本大概率落不到一起。这样我们只需要检查落在同桶的文本对就能找出潜在相似项而不必比较所有组合。下面我会把这套方案的原理、代码实现、参数调法和常见坑一次性讲清楚。2. 局部敏感哈希的原理与选型为什么是 MinHash Banding2.1 相似度度量的几种方案对比为什么 LSH 适合论文比对做文本相似度第一件事是定什么叫相似。常见度量有编辑距离、Jaccard 相似度、余弦相似度、SimHash 距离等。编辑距离适合短文本比如代码行或标题比对但对整篇论文这种上万字的文本计算代价太高也没有考虑同义词替换后语义不变的情况。余弦相似度需要先把文本向量化用 TF-IDF 或词向量表征计算上要维护一个大规模向量矩阵。Jaccard 相似度衡量两个集合的公共元素占比直接落在论文里哪些词是共有的这个直觉上而且能搭配 MinHash 做近似计算天生适合大规模文本。选 LSH 而不是直接硬算 Jaccard核心就三个字降复杂度。几百篇论文两两 Jaccard 是 O(n²) 的量级每一对还得做一次集合交集运算在纯 Python 里就是灾难。LSH 的 banding 过程把签名矩阵竖直切分成若干 band每一 band 再哈希到桶里只有至少某个 band 完全相同的两篇论文才会被拉进同一个候选集需要精确计算的候选对数量断崖式下降。论文比对不需要 100% 召回允许少量漏报换取数量级的速度提升这个 trade-off 恰好是 LSH 的主场。2.2 MinHash 签名与 Banding 分段原理和两个关键参数MinHash 做的事情是把每篇论文的词集合压缩成固定长度的签名signature签名之间仍然能近似估算 Jaccard 相似度。具体做法是用多个独立的哈希函数对集合里每个元素算出哈希值取每个哈希函数下的最小值作为签名的分量。注意这里取的是最小哈希值不是把元素都哈希一遍。两篇论文的相似度越高任何一个哈希函数下它们的最小值相等的概率就越大这个概率恰好等于 Jaccard 相似度。用 128 个哈希函数就能得到 128 维的签名向量后续比较签名而不是原始词集合。签名长度num_perm决定精度。值越大估计的 Jaccard 值越贴近真实值但内存和耗时也线性上升。Banding 是第二层处理。签名向量被切分成b个 band每个 band 有r行满足b × r num_perm。两篇论文被视为候选对的条件是至少有一个 band 的两段签名完全相同。这个条件听起来苛刻但它的概率曲线很有用。给定两篇论文的真实相似度为s某个 band 相同的概率是s^r任何一个 band 都不相同的概率是(1 - s^r)^b于是至少一个 band 相同的概率就是1 - (1 - s^r)^b一般称为碰撞概率曲线。参数的选取直接决定这条曲线的形状。以num_perm 128、b 16、r 8为例计算s 0.6时碰撞概率约 0.92s 0.4时约 0.22。也就是说相似度在 0.6 以上的文本大概率被选出0.4 以下的很难混进来。毕业设计里如果是查重阈值通常取 0.6 或 0.7对应的 band 数量可以先用代码粗略估算再定。阈值 s 0.6, b 16, r 8 时: P(至少一个band相同) 1 - (1 - 0.6^8)^16 ≈ 0.922.3 这个资源包里你会看到什么模块结构与依赖拿到资源后我建议先把代码结构过一遍。这套项目通常会分成几个模块数据读取与清洗、中文分词与特征构建、MinHash 签名生成、LSH 索引与查询、结果导出与评估。对应的核心文件大致是文件/目录职责preprocess.py读取论文文本去噪、分句、停用词过滤vectorize.py分词并用 MinHash 生成签名向量lsh_index.py构建 LSH 索引查询 top-k 相似论文evaluate.py用已知相似样本验证召回率与误报率requirements.txt依赖清单jieba、datasketch、pandas依赖安装上Python 3.8 以上都可跑核心库是datasketch它内部已经实现了 MinHash 和 MinHashLSH不用自己造轮子。jieba负责中文分词。关于 Python 环境如果你用的是 PyCharm 或 VSCode记得把解释器指到同一个虚拟环境不然会出现代码在编辑器里能跑、到命令行就 ModuleNotFoundError 的怪问题。我在第一次跑这个项目时犯过一个低级错误直接用pip install datasketch装完就跑结果代码里用了datasketch.lean_minhash那是新版本接口老版本没有。如果你发现项目代码报from datasketch import LeanMinHash失败多半是版本不匹配。建议先看一下requirements.txt里锁定的版本再决定装哪个版本。3. 把论文变成可比的向量分词、特征构建与 LSH 索引建立3.1 环境准备与依赖安装先准备环境。虚拟环境这一步别跳过datasketch和jieba都依赖一些底层编译库直接往系统 Python 里装容易把环境搞乱。# bash python3 -m venv venv source venv/bin/activate pip install jieba datasketch pandas如果你在 Windows 上用的是 Anacondaconda create -n lsh python3.9之后同样执行后两行即可。注意datasketch在 Windows 上的 wheel 包一般可以直接安装但如果遇到编译报错多半是 Python 版本太高降到 3.9 或 3.10 大概率解决。3.2 中文论文的分词与特征向量化LSH 处理的是词的集合所以第一步是把论文正文切成有实际意义的词丢掉标点、数字和停用词。下面这段代码是标准的处理流程# python import jieba import re STOPWORDS set() with open(stopwords.txt, r, encodingutf-8) as f: for line in f: STOPWORDS.add(line.strip()) def clean_text(text: str) - str: 去除特殊字符、合并空白保留中英文和数字 text re.sub(r\s, , text) text re.sub(r[^\u4e00-\u9fa5a-zA-Z0-9], , text) return text.lower() def tokenize(text: str) - list: jieba 分词 停用词过滤返回 token 列表 text clean_text(text) words jieba.lcut(text) words [w for w in words if w.strip() and w not in STOPWORDS and len(w) 1] return words逻辑说明clean_text先把所有不可见字符折叠成单个空格再把非中英文数字字符替换成空格这能避免 PDF 转文本时产生的乱码符号干扰后续词频统计。tokenize里len(w) 1这个条件是个细节单字词在论文里绝大多数是助词、虚词或噪声过滤掉既能减小集合规模也降低 MinHash 对无意义单字的敏感度。参数说明jieba.lcut默认使用精确模式适合整段论文的分词不需要关掉。STOPWORDS建议用通用的中文停用词表并额外加入你所在领域的常见虚词。注意这里把文本转成小写再做后续处理是为了避免英文缩写的不同大小写形式被视作不同 token。3.3 建立 LSH 索引并查询相似论文分完词之后进入 LSH 的核心环节。先为每篇论文生成 MinHash 签名再把签名插入 LSH 索引最后对查询文本做同样的签名处理并检索候选集。# python from datasketch import MinHash, MinHashLSH from typing import Dict, List NUM_PERM 128 def build_minhash(tokens: List[str], num_perm: int NUM_PERM) - MinHash: 用 token 集合生成 MinHash 签名 m MinHash(num_permnum_perm) for token in set(tokens): # 去重后更新集合的 Jaccard 不考虑词频 m.update(token.encode(utf-8)) return m def build_index(corpus: Dict[str, List[str]]) - MinHashLSH: 对全部论文构建 LSH 索引threshold 决定相似度阈值 lsh MinHashLSH(threshold0.6, num_permNUM_PERM) for paper_id, tokens in corpus.items(): signature build_minhash(tokens, NUM_PERM) lsh.insert(paper_id, signature) return lsh def query_similar(lsh: MinHashLSH, query_tokens: List[str], top_k: int 10) - List[str]: 查询与目标文本相似的前 top_k 篇论文 query_sig build_minhash(query_tokens, NUM_PERM) candidates lsh.query(query_sig) return candidates[:top_k] # LSH 返回无序结果按需排序逻辑说明build_minhash里set(tokens)是关键。MinHash 建模的是集合相似度即 Jaccard 系数它只看有哪些词不看词出现了几次。如果你不过滤重复 token那重复出现的核心词会被当成多个元素导致相似度被高频率词带偏。论文这种长文本里研究方法分析这类词的重复次数可能远高于领域特定词但它们在每篇论文里都高频出现会增加集合的基数让相似度计算结果失真。用set去重之后再更新签名才是标准的 MinHash 用法。参数说明MinHashLSH(threshold0.6, num_perm128)中的threshold是期望的相似度阈值datasketch会根据threshold和num_perm反向推算 band 数量和 row 数量。num_perm128是签名长度越大越精确内存与计算开销也越大。对论文规模几百到几千篇来说128 是一个比较舒服的折中值。查询端candidates[:top_k]只是简单截断更严谨的做法是按候选对重新计算精确 Jaccard 后再排序这一步后面会展开。3.4 参数说明签名长度、Band 数与阈值的对应关系datasketch会自动根据threshold和num_perm计算 band 数与行数但如果你想手工控制理解下面这张表会很有帮助。num_permthreshold自动计算出的 band 数 b每个 band 的行数 r碰撞概率s0.6 时1280.51012约 0.991280.6168约 0.921280.7245约 0.722560.6328约 0.93这张表能看到一个比较反直觉的点threshold设得越高碰撞概率反而越低。这个碰撞指的是相似度 0.6 的文本对至少一个 band 完全相同的概率阈值升高会让 band 判定的标准变严本来相似度 0.6 能碰撞的文本对在threshold0.7时大概只有七成能被召回。所以查重项目里如果要求高召回率threshold不要直接按查重标准 0.6 以上来设可以适当调低到 0.5把候选对拿到再做一次精确判定反而不容易漏。内存占用方面num_perm128时每篇论文在 LSH 内部维护一个 128 维的签名对 1000 篇论文签名总量也就几 MB 量级完全不构成压力。但如果把num_perm调到 512索引开销会直线上升而精度收益在论文场景下并不明显。4. 让相似度结果可信阈值判断、参数调优与扩展场景4.1 相似度 Score 的计算方式与返回结果解读LSH 返回的是候选论文 ID 列表是个粗筛结果它不直接给你一个相似度分数。真正拿得出手的相似度值需要在候选集上重新精确计算 Jaccard 或余弦相似度。我在项目里会在查询后追加一个精确计算步骤# python def jaccard_score(tokens_a: List[str], tokens_b: List[str]) - float: 精确计算两个 token 集合的 Jaccard 相似度 set_a set(tokens_a) set_b set(tokens_b) inter len(set_a set_b) union len(set_a | set_b) return inter / union if union ! 0 else 0.0 def rank_candidates(query_tokens: List[str], candidates: List[str], corpus: Dict[str, List[str]]) - List[tuple]: scored [] for pid in candidates: score jaccard_score(query_tokens, corpus[pid]) scored.append((pid, score)) scored.sort(keylambda x: x[1], reverseTrue) return scored[:10]逻辑说明jaccard_score用的是集合运算和|复杂度取决于两个 token 集合的大小对候选集里的每篇论文只算一次比全库两两比对便宜得多。这里重新计算的价值有两个一是 LSH 的碰撞概率是近似值边界文本对可能恰好在某个 band 上相同直接放行二是拿到排序后的分数你才能给出一份查重报告而不是一个无序候选列表。参数说明rank_candidates截断了 top-10实际可按需求调整。毕业论文查重场景下一般score 0.6的才值得标注为疑似重复0.4 到 0.6 之间可以列为参考相似再人工复核。注意 Jaccard 相似度对论文段落复制粘贴后增删若干词的情况比较敏感只要添加了足够多的新词分数会迅速降低。这是所有基于集合的相似度方法的天然特性不是 BUG。4.2 超参调整怎么从命中的论文多调整到命中的论文准调参的本质是在召回率和精确率之间找平衡点。如果你发现查询结果里混入了大量相似度不足 0.3 的论文这是误报说明当前 band 配置太宽松候选集噪声多。相反如果你发现真正相似的论文一篇都没返回那就是漏报说明碰撞条件太严。我一般用这样一套调整顺序先把num_perm固定在 128threshold设为 0.5跑一次全库检索记录候选对和对应分数看分数分布如果大部分候选对的 Jaccard 在 0.5 以下把threshold调到 0.6 甚至 0.7减少误报如果分数在 0.6 以上的候选对被漏掉把threshold降到 0.4通过后续准确排序捞回来。datasketch通过threshold参数间接控制b和r但如果你要极端控制 band 和 row可以改用MinHashLSH(num_perm128, params(b, r))直接传入元组绕过 threshold 的自动计算。用params的时候b和r必须精确满足b * r num_perm否则会报 ValueError。我实际调试中发现b越大召回越高但误报也越高r越大则反过来。毕业设计答辩时如果有老师问起这两个参数能说出这条 trade-off 基本就算讲清楚了。提示调参后一定要重新看一次全库的召回情况别只盯着查询的那一两篇论文。LSH 的参数是全局的一篇论文查得准不代表整个库准。4.3 扩展场景把 LSH 用到开题查重、文献综述整理、代码查重这个项目的适用场景不止于毕业论文查重。我后来做文献综述时会在开题阶段就用同一套 LSH 索引去筛相似的论文段落。做法很简单把每篇文献的摘要和结论部分切出来构建索引再把自己写的综述段落作为查询文本返回的相似文献按分数排序直接得到这段话和哪篇已有文献相关的线索省去大量人工翻文献的时间。代码查重也可以用但有两个注意点。代码 token 要比论文更细比如把变量名、注释、字符串字面量都去掉只保留语法关键词和结构 token。这种情况下 Jaccard 相似度比 TF-IDF 更合适因为代码的词集天然就是很小的集合TF-IDF 反而会稀释相似度。另一个点是缩进和换行必须归一化否则两个逻辑相同的函数因为换行不同会被判成不相似。这部分我在项目里没有直接实现但你可以在clean_text阶段加入一个类似移除空白字符的预处理逻辑改动成本很低。如果场景换成代码查重推荐把len(w) 1这个过滤条件去掉因为if、for、def都是单 token 且对代码结构非常关键。这里的分词也从jieba.lcut换成按空格切分即可因为大部分代码文件经过词法处理后本身就是以空格分隔的 token 序列。5. 避坑手册五个让 LSH 结果翻车的常见问题5.1 现象两篇明显相关的论文没被召回查重时我拿两篇知道是互相抄袭的论文做测试一篇是原文一篇改了标题和摘要中间段落大段复制结果 LSH 一个候选对都没返回。原因分析这两篇论文的修改幅度太大Jaccard 相似度实际只有 0.5 左右而我把threshold设成了 0.7碰撞概率太低等于直接过滤掉了。解决把threshold从 0.7 降到 0.5重跑索引。同时把num_perm从 64 提高到 128因为num_perm过低时估算误差大容易把边界情况的相似度算低。从那以后我每次做查重场景都会先跑一遍已知相似对确认召回率达标再上线而不是想当然地设 0.7 就觉得安全。5.2 现象换了电脑跑结果不一样了同一份代码同一份语料在公司电脑上跑的结果和在家里跑的结果对不上相似论文排序略有差异。原因分析datasketch内部用随机哈希函数生成签名没有固定随机种子时每次构建索引的签名向量都不同。理论上 LSH 的候选集大概率一致但边界上的论文可能因为随机扰动被分到不同的桶导致候选集出现细微差异。解决在代码入口处固定随机种子# python import random random.seed(42)同时对datasketch内部MinHash 的哈希函数由 Python 的random模块驱动固定种子之后结果就完全可复现了。我后来做实验时还会额外保存一份签名向量到磁盘作为复现依据。5.3 现象中英文混排论文的分词结果乱成一团工程类论文里英文缩写和专业术语很多比如 CNN 模型LSTM 网络。jieba.lcut对这些混合文本的处理会切成不理想的形式比如 cnn 和 模型 被分开这本身还好最麻烦的是clean_text里如果把英文和数字混在一起处理LSTM-100 会被保留为一个 token和另一篇论文里的 LSTM-200 就不是同一个词。原因分析我的clean_text正则[^\u4e00-\u9fa5a-zA-Z0-9]保留了连字符导致带编号的模型名变成了完整 token匹配时完全无法对齐。解决调整正则把非字母数字的字符全部替换成空格拆掉连字符# python text re.sub(r[^a-zA-Z0-9\u4e00-\u9fa5], , text)这样 LSTM-100 会被拆成 LSTM 和 100更符合词集的语义。你还要注意tokenize里保留数字 token 会在相似度计算中引入一些无意义的对齐如果语料里都是公式编号建议把纯数字 token 也过滤掉。5.4 现象LSH 查询很快但内存占用却很大语料从 500 篇扩到 5000 篇后查询速度依然毫秒级但服务器内存占用飙升到几个 GBPython 进程直接卡死。原因分析MinHashLSH默认会把原始签名对象保留在内存里以支持删除操作。5000 篇论文、num_perm256时签名数组本身占内存其实不大但MinHashLSH内部为每个 hash 表维护了大量 Python 对象引用加上每篇论文在索引里被复制了多次内存膨胀是预期的。解决改用MinHashLSH的storage_config参数用磁盘存储而不是纯内存或者直接用MinHashLSHForest。我在项目里采用的替代方案是MinHashLSH(..., storage_config{type: disk})速度会慢一点但 5000 篇论文的内存占用降到可接受范围。如果你的查询频率远高于构建频率更推荐把索引序列化到磁盘每次查询只加载索引不加载原始语料。5.5 现象返回候选对后精确 Jaccard 只有 0.3误报极高候选集里有大量 Jaccard 只有 0.3 的论文感觉 LSH 白干了甚至比全量比对还慢。原因分析threshold设得太低比如 0.3。b × r num_perm的配置在这种低阈值下会让 band 判定非常宽松很多只有一段话相同的论文也会被撞进同一个桶。解决提高threshold到 0.5 以上或者在查询端做二次过滤。比如只保留 Jaccard 排序后 top-20 的结果。这里有个容易忽略的点LSH 参数里threshold是输入期望阈值它和真实碰撞概率曲线并不是硬阈值中间有一段过渡带。所以你实际拿到的候选对里相似度在阈值以下的会占相当比例这也是为什么 LSH 永远只是粗筛必须配合精确计算使用。6. 进阶用已知抄袭样本验证召回率告别黑匣子式调参调参调得再熟练没有验证指标也说服不了导师或同事。我后来的做法是先构造一份已知相似对测试集然后量化召回率让 LSH 的调参从玄学变成可量化的迭代过程。构造测试集的方式是从语料里随机取 20 篇论文对每篇做 3 种不同程度的改写——只删改段落改述并增删句子大改结构和语序。这 60 篇改写的论文分别和原文组成已知相似对。然后跑一遍全库查询看每一篇原文能否在候选集里召回对应的改写论文。核心评估代码如下# python known_pairs [(paper_0001, paper_0001_mod1), (paper_0001, paper_0001_mod2), # ... 全部已知相似对 ] def evaluate_recall(lsh, corpus, known_pairs, threshold0.5): hit 0 for origin, mod in known_pairs: query_sig build_minhash(corpus[mod]) candidates set(lsh.query(query_sig)) if origin in candidates: hit 1 recall hit / len(known_pairs) print(f召回率: {recall:.2f} ({hit}/{len(known_pairs)})) return recall运行之后你会得到一组召回率数据。当召回率低于 0.8 时优先看漏掉的是哪种改写程度。如果全是大改结构的样本没召回这是正常的因为 Jaccard 本身可能就低于 0.4目标要适当放宽如果只删改段落的都召回不了那就要检查分词和停用词配置问题大概率出在前置处理上。在做这步验证时我会同步用difflib.SequenceMatcher做一套基线结果对同一批已知相似对做暴力比对获取一个理想召回率上限。LSH 的召回率以这个基线的 90% 为门槛低于门槛就继续调num_perm和threshold达标就固定参数跑全库。这套流程跑下来答辩老师问你怎么证明这个查重方案有效的时候你能直接掏出召回率数据和测试集构成说明而不是只靠一句LSH 本来就适合做这个。从那以后我每次搭 LSH 方案都强制自己先构造测试集再调参再也不会拿一篇文章测几次就说能用了。这次拆完这份论文相似性比对项目完整的测试集构造、MinHash 签名生成和评估脚本都在资源包里照着跑一遍就能复现同样的数据。希望帮到你。本文还有配套的精品资源点击获取
返回列表