海海艺思创

首页/ AI写作教程/ 文本语料近重复去重:用MinHash筛查重复稿并保留版本

AI写作教程

文本语料近重复去重:用MinHash筛查重复稿并保留版本

同一篇资料换了标题、标点或少量段落,文件哈希就可能不同。更稳妥的清理方式是先筛出文本重合候选,再由编辑判断是否属于重复副本、公共引用或有效修订。本文提供独立设计的本地学习原型与阈值对照流程;现有资料只确认官方博客收录了相关文章,未提供正文方…

文本语料近重复去重:用MinHash筛查重复稿并保留版本

先确定要找的是副本,还是值得保留的修订

本题解决网页资料与历史稿件之间的文字重合,不处理图片相似度、选题聚类或语义检索。大幅换词的同义改写不一定有足够的字面重合,不能把未命中解释为内容独立,也不能把命中解释为抄袭结论。

资料包中的 Hugging Face 官方博客索引列出了 Large-scale Near-deduplication Behind BigCode,条目日期为2023年5月16日。但索引不含正文,无法据此确认原文的分片单位、签名长度、候选索引参数或保留策略。下面的标准化、MinHash与分带匹配属于本文独立设计的学习原型,不是该文章的复现;代码语料经验迁移到中文文章时,需要另行验证。

  • 重复副本:正文几乎一致,只是格式或采集方式不同,可以提出合并检索入口的建议。
  • 有效修订:日期、数字、适用条件或结论发生变化,即使大部分文字一致,也应保留版本关系。
  • 公共引用:共同引用一段公开说明,不足以判定整篇重复。
  • 相同模板:页眉、免责声明、固定栏目说明相同,应区分模板与独立正文。

建立只读原文与可重建的匹配副本

  1. 选择一小批已授权、已脱敏的纯文本材料;网页先提取正文,PDF和扫描件先完成文字整理。本原型不承担网页解析或OCR。
  2. 给每条材料分配唯一的 doc_id,并登记 source_id、version、原始文件路径、采集时间及原文件哈希。相同来源的不同版本不能共用同一 doc_id。
  3. 原文件留在只读目录;把提取后的原文作为 raw 字段交给匹配程序。标准化结果另存为 normalized,不覆盖 raw。
  4. 单独建立 labels 表,由人工标注材料对是否属于待合并副本,并记录判定理由。不要先看算法分数再倒推标签。
  5. 记录Python版本、程序版本、标准化规则、分片长度、签名参数和输入清单。修改任一表示规则后,应重建全部签名与候选索引。

建议将交付物分成原文清单、标准化副本、候选对、阈值对照记录与人工审核表五份。文件哈希仍用于检查原件是否被改动,不替代近重复候选筛查。

用中文短例理解标准化与字符分片

学习原型先做NFKC标准化、统一英文大小写,再只保留字母和数字字符。这样会忽略空白、标点及部分符号,便于观察格式差异,但不是适用于所有语料的默认规则。例如小数点、负号、单位符号可能承载事实,最终判断必须回到原文。

假设输入为“资料,先备份!”,标准化副本为“资料先备份”。取长度为3的连续字符窗口,得到“资料先”“料先备”“先备份”三个分片。下面的原型将分片放入集合,因此同一分片重复出现多次,只计一次。

集合A与B的精确Jaccard分数定义为交集大小除以并集大小。这里的“精确”只表示对当前分片集合直接计算,不表示它能精确判断文档是否应该删除。没有有效分片的材料不进入签名比较,避免把空文本误当成重复。

实际中文文章可把字符窗口长度3、5、7作为待比较选项,而不是先认定某个长度最好。段落顺序、文本长度和模板比例也会影响结果。以下代码采用3,仅为便于短样本演示。

运行一个只输出候选的MinHash原型

下面仅使用Python标准库。每个分片在64个固定盐值下分别计算稳定哈希,每个位置取最小值形成签名;再分为16组,每组4个值。两个文档有任意一组完全相同,就进入候选集合。这是采用独立加盐哈希构造MinHash签名的简化实现,适合学习数据流,不是面向大型语料的性能方案。

分带阶段不设置硬性的相似度保证,也可能漏掉应复核的材料。候选命中后再算精确Jaccard,并提取原文中的连续相同片段。共享分片只是局部重合线索,连续相同片段也不一定覆盖全部重合位置。

import hashlib
import json
import unicodedata
from difflib import SequenceMatcher
from itertools import combinations

K, M, ROWS = 3, 64, 4

def normalize(text):
    text = unicodedata.normalize('NFKC', text).casefold()
    return ''.join(c for c in text if c.isalnum())

def shingles(text):
    return {text[i:i + K] for i in range(len(text) - K + 1)}

def signature(parts):
    if not parts:
        return None
    return tuple(min(int.from_bytes(hashlib.blake2b(
        p.encode('utf-8'), digest_size=8,
        key=s.to_bytes(4, 'big')).digest(), 'big')
        for p in parts) for s in range(M))

def jaccard(a, b):
    return len(a.intersection(b)) / len(a.union(b)) if a and b else 0.0

def screen(docs):
    ids = [d['doc_id'] for d in docs]
    if len(ids) != len(set(ids)):
        raise ValueError('duplicate doc_id')
    prepared, buckets, pairs = {}, {}, set()
    for d in docs:
        n = normalize(d['raw'])
        parts = shingles(n)
        sig = signature(parts)
        prepared[d['doc_id']] = dict(d, normalized=n, parts=parts, sig=sig)
        if sig is None:
            continue
        for start in range(0, M, ROWS):
            key = (start, sig[start:start + ROWS])
            bucket = buckets.setdefault(key, [])
            for other in bucket:
                pairs.add(tuple(sorted((other, d['doc_id']))))
            bucket.append(d['doc_id'])
    results = []
    for left, right in sorted(pairs):
        a, b = prepared[left], prepared[right]
        blocks = SequenceMatcher(None, a['raw'], b['raw'], autojunk=False)
        excerpts = [a['raw'][x.a:x.a + x.size]
                    for x in blocks.get_matching_blocks() if x.size >= K]
        results.append({
            'left': left, 'right': right,
            'left_source': a['source_id'], 'right_source': b['source_id'],
            'left_version': a['version'], 'right_version': b['version'],
            'minhash_estimate': sum(x == y for x, y in zip(a['sig'], b['sig'])) / M,
            'jaccard': jaccard(a['parts'], b['parts']),
            'shared_shingles': sorted(a['parts'].intersection(b['parts']))[:20],
            'raw_excerpts': excerpts[:5],
            'suggestion': 'review_both_keep_originals',
            'review_status': 'pending'
        })
    return prepared, pairs, results

代码中的原文摘录来自raw,而不是标准化副本;标点差异较大时,摘录可能较碎。输出限制为前20个共享分片和前5个连续片段,仅用于预览,不是完整差异报告。正式审核应同时打开双方完整原文。若将摘录显示在网页中,必须按普通文本转义,不把资料内容当HTML执行。

把函数与下一节样本放在同一Python文件中运行即可。本轮未执行代码,也没有性能或识别效果记录。接入自己的资料时,用带有相同字段的记录替换样本;保存候选与标准化副本应写入独立输出目录,不能使用原文路径作为输出路径。

用正负样本分开比较候选召回与审核阈值

以下都是人为编写的示例材料,不是业务记录。A与B是格式不同的副本;C改动关键数字,按版本保留规则标为非合并对象;D仅共享模板;E是不同主题。标签1表示应进入副本合并复核,标签0表示不应按副本合并。标签针对保留策略,不是对文字重合程度的描述。

texts = {
    'A': '资料整理说明:先备份原始稿件,再登记来源与版本。每周归档一次,保留修改记录。',
    'B': '资料整理说明  先备份原始稿件,再登记来源与版本!每周归档一次;保留修改记录。',
    'C': '资料整理说明:先备份原始稿件,再登记来源与版本。每天归档一次,保留修改记录。',
    'D': '资料整理说明:本文供内部参考。摄影素材需要登记镜头焦距与拍摄位置。',
    'E': '盆栽养护记录:观察叶片状态,按照土壤湿度调整浇水频率。'
}
docs = [dict(doc_id=k, source_id='demo-' + k, version='v1', raw=v)
        for k, v in texts.items()]
labels = {('A', 'B'): 1, ('A', 'C'): 0, ('A', 'D'): 0, ('A', 'E'): 0}
prepared, candidates, results = screen(docs)
print(json.dumps(results, ensure_ascii=False, indent=2))

for threshold in (0.5, 0.7, 0.9):
    tp = fp = fn = candidate_positive = 0
    for pair, label in labels.items():
        a, b = (prepared[k] for k in pair)
        score = jaccard(a['parts'], b['parts'])
        hit = pair in candidates
        predicted = hit and score >= threshold
        candidate_positive += int(hit and label == 1)
        tp += int(predicted and label == 1)
        fp += int(predicted and label == 0)
        fn += int(not predicted and label == 1)
    positives = sum(labels.values())
    print(json.dumps({
        'threshold': threshold, 'tp': tp, 'fp': fp, 'fn': fn,
        'precision': tp / (tp + fp) if tp + fp else None,
        'recall': tp / (tp + fn) if tp + fn else None,
        'candidate_recall': candidate_positive / positives if positives else None
    }))

从规则推导,A与B的标准化副本一致且非空,因此分片集合和签名一致,属于应有的基本检查;这不是运行报告。C的部分文字与A重合,但保留频率发生变化,即使分数高,也不能据此合并原件。

  1. 先固定窗口与签名配置,运行0.5、0.7、0.9三档审核阈值,记录候选数、TP、FP、FN、精确率、召回率和运行时间。这些数值是实验输入,不是推荐生产阈值。
  2. 对小型人工标注集枚举全部材料对,直接计算分片Jaccard,找出“分数达到审核阈值但未被分带检索返回”的材料对。上面的screen只比较候选;排查遗漏时可用combinations(prepared, 2)生成全对照。
  3. 候选召回率检查检索阶段有没有找到人工正例;最终召回率还包含审核阈值造成的遗漏。不能只在已命中的候选中统计召回率。
  4. 窗口改为5或7时,重新生成分片、签名和候选,再重复实验。签名长度必须能被每组行数整除,且不同配置不能混用同一索引。
  5. 加入真实业务中的短稿、长稿、局部引用、模板、高重合修订和同义改写;按来源或版本家族划分调参集与独立验收集,避免同一家族同时进入两边。

本例只有一对正例与少量负例,只能检查流程,不足以决定全库阈值。分数没有预测为正时,精确率输出为空值表示分母为零,不应当成零分或满分。真实运行后应保留逐对得分与错误原因,而不只保留汇总指标。

候选异常时,按表示、检索、保留规则排查

常见故障可能原因修正动作
空文本或短文本没有候选正文提取失败,或标准化后长度小于窗口登记为待人工检查,不生成空签名;核对解析结果,另设短文本精确匹配流程
已知副本未命中窗口过长、改写较多或分带阶段漏检先直接计算集合Jaccard区分表示问题与检索问题,再比较窗口与分带配置;仅降低审核阈值不能找回未召回候选
不同稿件大量进入队列共享页眉、模板和免责声明占比过高在匹配副本中单独标记或排除模板,保留原文;记录模板规则并重新建立索引
数字不同却得到高分关键变化占全文比例小,或标准化去掉了小数点和负号增加原文数字、单位、日期与否定词差异核对;必要时采用保留符号的另一套标准化规则
短文被长文完整引用却分数不高并集包含长文大量独有分片另做段落级或短文包含比例检查,结果标为引用或局部重合,不等同整篇副本
同义改写几乎没有共享分片任务超出字面重合筛查范围记录方法边界,转人工对照;不要宣称已排除语义重复
处理时间或内存快速增长示例逐分片重复哈希,常见模板还会产生很大的候选桶限制原型批量,检查桶大小;扩大规模前核对成熟实现、资源预算与抽检召回,不能直接把全库塞入学习代码

发布前验收清单

交给编辑的应是候选复核清单,而不是待删除文件列表。多人结论不一致时,可参考多人独立评分与仲裁结果保留方法,分别记录原始判断与最终仲裁,不覆盖第一位审核者的意见。

  • 原件保护:输入原文件数量、路径与哈希未改变,程序没有删除、移动或覆盖动作。
  • 可追溯性:候选双方均能定位doc_id、source_id、version、原始文件和采集时间;标准化副本能够从原文重新生成。
  • 配置完整:运行记录包含窗口、签名长度、分带方式、标准化规则、程序版本及输入清单。
  • 证据可读:候选附共享分片、原文重合片段和完整原文入口,明确预览不是全文差异。
  • 版本核对:逐对检查日期、数字、单位、适用条件、否定与结论变化;有效修订保留双方并建立前后版本关系。
  • 来源核对:公共引用、转载授权、作者与原始出处分别登记。文字相同不意味着来源信息可以丢弃。
  • 保留建议:只使用“保留双方”“建议合并检索入口”“公共引用”“有效修订”“待仲裁”等状态;建议与实际操作分离。
  • 人工签署:审核表包含双方ID、结论、理由、保留版本、关联来源、审核人、审核时间和仲裁状态,不能只写一个相似度分数。
  • 独立验收:在未参与调参的材料对上记录误报、漏报及候选阶段遗漏,确认团队能够承担复核量后再扩大范围。
  • 发布边界:报告不声称中文效果已获验证,不把索引当成官方实现依据。若要复现BigCode方法,应先补齐原文与所引用实现的版本资料,再与本地原型逐项对照。

首次交付以一批可追溯候选、逐对阈值记录和已签署的保留建议为终点。即使某对材料被确认是副本,也先合并资料库中的检索展示或标记关系,原始文件仍保留在归档中。

下一步

把这篇的方法练一遍

提示词和步骤可以带到创作里直接试做一版。

去创作 看同栏目更多