qinghuan
← 预训练与训练工程 / 全部笔记

单元 25 / 预训练与训练工程

P0 · 必会主线L2 · 进阶

预训练语料:清洗、去重、来源与污染检查

清洗文本、给文档生成指纹、聚合近重复内容,并检查训练资料是否混入评测内容,让每一步都能追查来源。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能生成稳定文档指纹,计算近重复候选,保留来源记录,并说明跨集合污染检查的盲区。

学习理由、准备要求与知识点

为什么学它

重复内容会改变模型学到的数据比例;评测内容混进训练数据还可能让分数虚高。先识别文档与来源,才能解释提升来自哪里。

先读这些内容

知识点清单

  • 原始字节与规范化文本的双重身份
  • SHA-256 指纹与碰撞复核
  • 词片段集合与 Jaccard 相似度
  • 阈值图的连通分量
  • 确定性代表文档选择
  • 组级划分与评测集隔离
  • 污染候选人工复核
  • 数据版本与删除清单

运行前先确认Python 3.14 标准库与 Java17;只处理内置小语料。源码基线 CPython v3.14.0;近重复算法为原创教学模型,不复现论文的大规模系统。

01先弄懂它是什么

同一批文本为什么这次训练和上次效果不同?要回答这个问题,必须知道每条语料从哪里来、后来被怎样处理过。每条记录至少要有稳定文档编号、来源、获取时间、原始内容摘要、清洗规则版本和可用范围。原文与清洗后的文本应使用不同身份,否则规则一变,同一个编号就可能悄悄对应另一份输入。记录了来源,也仍需另外确认具体的收集和使用条件。

去重前先说清楚“重复”指什么。字节完全相同才是精确重复;删掉多余空白后相同,只能说在这套规范化规则下相同。允许少量增删或替换的是近重复,要另外规定怎么比、相似到多少才算。本篇把每两个相邻词组成一个片段,用片段集合的交集大小除以并集大小,得到 Jaccard:完全相同为一,没有共同片段为零。这种比较不记录片段重复次数,也不能证明两篇文档意思相同。

检查评测污染,就是查训练材料里是否已经出现了评测题目、答案或非常接近的改写。简单指纹只能找出它能识别的匹配,没有命中也不能证明完全干净。划分普通训练集和验证集时,要把一组近重复文档放在同一边,避免相似副本分散到两边。使用独立的外部评测集时,先固定评测内容,再检查候选训练材料与它的重叠,并留下命中证据;不要反复根据测试成绩调整清洗阈值。

文中这几个词,先认一认

Shingle
连续几个词组成的小片段。本例取相邻两个词,并用元组保存,避免拼接字符串后分不清原来的边界。
连通分量
用相似关系连在一起的一组文档。可能甲像乙、乙像丙,但甲和丙本身并没有达到相似阈值。
污染候选
与已经固定的评测内容出现可解释重叠的训练样本,需要继续按任务和重叠类型检查;它只是待核查对象。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

先保留原文,再为具体文本类型选择保守清洗。新闻正文可以折叠空白,Python代码却不能随意删除缩进;数字、大小写、标点也可能是任务标签的一部分。实验故意只折叠空白,不做大小写或数字替换。指纹输入统一使用 UTF-8,并将清洗规则版本写入清单。散列值用来定位候选,删除前仍比较规范化文本,以处理碰撞以及规则实现错误。

近重复检测先构造片段集合,再计算交并比。短于片段宽度的文档不能都变成空集合,否则大量不相关短文本会被当作相同;本例给非空短文本保留完整词元组,空文档在进入流程时拒绝。阈值只是一项配置,应在独立标注的小样本上检查误删与漏检。对于中文,分词规则同样属于版本化输入,不能直接把按空格分词的英文实验当成中文处理方案。

这里的 Java 程序把文档两两比较:相似度达到阈值,就把两篇连起来,再用并查集把相连的文档归成一组。每组选输入下标最小的文档作代表,因此输入顺序不变时,重跑会选到同一个代表。这种直接比较适合小规模核验;文本很多时,通常先找可能相似的候选,再准确计算相似度。

MinHash 或局部敏感哈希可以减少候选查找的成本,但这类概率方法可能漏掉匹配,需要检查漏检情况。论文中的去重收益还取决于具体实验设置,不能当成任何领域都会得到的提升比例。

先跑通一个最小例子

保存为 experiment.py,用 Python3.14 运行。内置英文短文只验证处理规则和污染命中逻辑,不代表真实语料质量。

展开收起代码 · experiment.pypython · 50 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

experiment.py · python
import hashlib

def normalize(text):
    # 只折叠空白,不改大小写和数字语义
    value = " ".join(text.split())
    if not value:
        raise ValueError("empty document")
    return value

def fingerprint(text):
    return hashlib.sha256(normalize(text).encode("utf-8")).hexdigest()

def shingles(text, width=2):
    if width <= 0:
        raise ValueError("width")
    words = normalize(text).split()
    # 短文本保留其完整词元组,不能统一变成空集合
    if len(words) < width:
        return {tuple(words)}
    return {tuple(words[i:i+width]) for i in range(len(words)-width+1)}

def similarity(a, b):
    left, right = shingles(a), shingles(b)
    return len(left & right) / len(left | right)

docs = ["a b c", " a  b c ", "a b d", "x y z"]
assert fingerprint(docs[0]) == fingerprint(docs[1])
assert normalize("x 1") != normalize("x 2")
assert abs(similarity("a b c", "a b d") - 1/3) < 1e-12
assert similarity("yes", "no") == 0
assert similarity("yes", "yes") == 1
# 指纹只是候选索引,重复删除仍比较规范化文本
seen, kept = {}, []
for index, doc in enumerate(docs):
    value, key = normalize(doc), fingerprint(doc)
    if value not in seen.setdefault(key, []):
        kept.append(index)
        seen[key].append(value)
assert kept == [0, 2, 3]
test = ["a b c", "new benchmark"]
hits = [(i, j) for i in kept for j, q in enumerate(test)
        if similarity(docs[i], q) >= 0.9]
assert hits == [(0, 0)]
try:
    normalize("  ")
except ValueError:
    pass
else:
    raise AssertionError("empty text must fail")
print("8 corpus identity and contamination checks passed")

核对运行结果输出 8 corpus identity and contamination checks passed;重复记录保留编号0,冻结评测命中(0,0)。

正在加载流程图…

每次删除都能回到原始记录和命中规则;评测集只用于隔离检查,不用于反复调参。
查看流程图源码(Mermaid)
flowchart TD
 A["原文与来源清单"] --> B["版本化规范化"]
 B --> C["指纹定位与内容复核"]
 C --> D["片段候选与相似度"]
 D --> E["家族分组和稳定代表"]
 E --> F["冻结评测重叠检查"]
 F --> G["训练清单与删除证据"]

按顺序看,每一步做了什么

  1. 固定文档编号、规则版本和冻结评测内容。
  2. 拒绝空文档,分别保存原始摘要与规范化摘要。
  3. 复核精确重复,按片段相似度构造近重复家族。
  4. 按家族划分普通集合,筛查外部评测重叠并输出证据。

03对照实现代码

CPython hashlib 的 SHA-256 构造路径 (新标签页)↗

CPython v3.14.0 · __get_openssl_constructor;__get_builtin_constructor;file_digest

下例是使用真实 hashlib 接口的原创指纹模型,不是 SHA-256 内核实现,也不是论文去重工具源码。

核心机制代码 · python
import hashlib
def identity(raw):
    # 原始身份用于回溯,不让清洗规则覆盖历史
    original = hashlib.sha256(raw.encode("utf-8")).hexdigest()
    # 规范化规则必须与语料类型匹配并单独版本化
    normalized = " ".join(raw.split())
    # 指纹相等之后仍可比较规范化正文确认重复
    cleaned = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
    return {"raw": original, "clean": cleaned, "rule": "space-v1"}

命名构造器按运行环境连接到可用的摘要实现;Python接口统一,并不意味着压缩轮函数由Python解释器执行。

SHA-256接收字节,因此编码属于数据身份协议。相同文本若采用不同编码,其摘要也可能不同。

流式update允许分块处理大文档,分块边界不改变连续字节流的摘要;它没有文本规范化功能。

指纹模型把原始与清洗身份分开,便于定位差异;它既不检测语义重复,也不能单独证明来源许可或评测独立性。

代码先调用谁,再调用谁

  1. 文本规则生成规范化字符串
  2. UTF-8编码后调用sha256构造器
  3. 摘要实现消费字节并返回hexdigest

04动手写一个例子

相似图的确定性家族归并

输入非空整数片段集合与阈值,返回每篇文档所属连通分量的最小下标。集合元素已由分词阶段无碰撞编码。

输入 1
[{1,2},{2,3},{3,4}], threshold=1/3
输出 1
[0,0,0];两端相似度为0,仍通过中间文档相连

先独立定义相似关系,遍历每一对集合计算交集和并集。不能把输入顺序变化造成的保留差异误当成清洗收益。

每条合格边调用并查集合并,按树的秩连接并维护独立的最小下标字段。这样既能保持接近常数的归并开销,也能稳定选择家族代表。

最终压缩所有根,得到可用于组级划分的家族标签。该算法求的是连通分量,不是要求任意两篇都相似的团。

为什么这样写是对的处理任意边前缀后,并查集恰好表示这些边的连通分量;每个根的minimum字段等于该分量最小下标。

时间 期望 O(n²s + n²α(n))额外空间 O(n),不含输入集合

n为文档数,s为每个集合最大大小;哈希集合查询按期望常数计算。遍历文档对检查片段,路径压缩归并接近常数;最坏哈希行为不作常数保证。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 48 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.util.*;
public class Main {
    static int root(int[] p, int x) {
        // 路径压缩保持家族代表不变
        return p[x] == x ? x : (p[x] = root(p, p[x]));
    }
    static int[] group(List<Set<Integer>> docs, double threshold) {
        if (!Double.isFinite(threshold) || threshold <= 0 || threshold > 1)
            throw new IllegalArgumentException("threshold");
        int n = docs.size(); int[] p = new int[n], rank = new int[n], minimum = new int[n];
        for (int i=0;i<n;i++) {
            if(docs.get(i)==null || docs.get(i).isEmpty())
                throw new IllegalArgumentException("empty");
            p[i]=i; minimum[i]=i;
        }
        for(int i=0;i<n;i++) for(int j=i+1;j<n;j++) {
            int common=0;
            for(int token:docs.get(i)) if(docs.get(j).contains(token)) common++;
            long union=(long)docs.get(i).size()+docs.get(j).size()-common;
            // 除法转浮点前用long形成并集大小,避免整数相加溢出
            if((double)common/union >= threshold) {
                int a=root(p,i), b=root(p,j);
                if(a!=b){
                    if(rank[a]<rank[b]){int tmp=a;a=b;b=tmp;}
                    p[b]=a; minimum[a]=Math.min(minimum[a],minimum[b]);
                    if(rank[a]==rank[b])rank[a]++;
                }
            }
        }
        // 最小代表与树根分离,避免牺牲按秩归并的复杂度
        int[] labels=new int[n];
        for(int i=0;i<n;i++) labels[i]=minimum[root(p,i)];
        return labels;
    }
    static void check(boolean ok){if(!ok) throw new AssertionError();}
    public static void main(String[] args){
        var chain=List.of(Set.of(1,2),Set.of(2,3),Set.of(3,4));
        check(Arrays.equals(group(chain,1.0/3),new int[]{0,0,0}));
        check(Arrays.equals(group(chain,0.5),new int[]{0,1,2}));
        check(Arrays.equals(group(List.of(Set.of(1),Set.of(1)),1),new int[]{0,0}));
        check(group(List.of(),1).length==0);
        try {group(chain,Double.NaN); throw new AssertionError();}
        catch(IllegalArgumentException expected){}
        try {group(List.of(Set.of()),1); throw new AssertionError();}
        catch(IllegalArgumentException expected){}
        System.out.println("6 duplicate family checks passed");
    }
}

这些特殊情况也要试一试

  • 空集合拒绝,空文档列表返回空结果。
  • 阈值必须有限且位于零到一之间,零不作为有效筛选阈值。
  • 传递链可能合并相似度很低的两端,需要抽样检查过大分量。
进阶选读 · 候选检索与污染证据索引

场景两两比较无法处理上亿文档,需要有限资源下保留可解释的命中。

做法使用MinHash或倒排索引生成候选,准确复算相似度,再按稳定文档ID合并;记录候选策略、召回样本和保留原因,对巨型分量单独复核。

复杂度候选阶段依赖桶分布;极端模板文本仍可能形成平方数量候选,不能宣称普遍线性。

好处与代价更强归并降低重复但可能删除少数群体文本;需要按来源和长度分层统计误删。

05项目里怎么用

数据版本应由原始清单、清洗程序版本、参数和最终保留ID共同确定。只保存一个压缩文本包无法回答某篇内容为何进入或离开训练集。先做小规模可复核的规则,再扩展处理量;每个阶段统计文档数、字节数、Token数以及各来源占比,防止去重后分布悄悄偏移。

不要只看全局重复率。论坛模板、代码许可证头、数学公式和真正复制的正文应分别分析;同一片段频繁出现可能是格式规律,也可能是数据抓取错误。对污染候选保留位置与重叠片段,人工复核时区分题干、答案、通用常识和无关模板,报告命中与未覆盖策略。

外部评测内容固定后,每次修改训练清单,都用同一套规则重新检查重叠。比较不同训练结果时,也要使用同一套评测定义,并写清检查覆盖不到的翻译、语义改写或未知来源。删掉了哪些文档、为什么删除,都应留在记录里。每组保留哪篇代表文档也要有固定规则,例如按来源优先级和稳定文档编号排序,避免只因文件被读入的先后不同就换了代表。

容易踩的坑

  • 用Python进程随机化hash作为跨运行数据指纹。
  • 删除代码缩进或所有数字,制造并不存在的重复。
  • 先随机切分近重复副本,再认为不同ID保证独立。
  • 把零个指纹命中写成完全没有评测污染。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
规范化精确去重快速且命中证据明确漏掉局部复制与轻微改写流水线第一层
片段近重复能发现局部文本重用阈值与分词影响误删带人工抽样复核的扩展层
语义候选覆盖改写方向相近主题容易误判且证据较弱额外污染筛查而非自动证明

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01为什么保存两个摘要?

展开参考答案与推导

原始摘要定位原始字节,清洗摘要定位规则下的训练输入,二者服务于不同的回溯问题。

02近重复是否具有传递性?

展开参考答案与推导

阈值相似度通常没有传递性;图的连通关系具有传递性,所以分量内不保证两两相似。

03为什么短文本不能统一表示为空集合?

展开参考答案与推导

空表示丢失了内容身份,会把不同短文的并集和交集都变成零,制造定义歧义与错误合并。

换个情况,还会处理吗

01去重后小语种比例显著下降怎么办?

展开参考答案与推导

按语言与来源抽样检查规则是否偏向模板或分词错误,分析误删后调整预处理并保留版本,不直接以整体重复率作质量结论。

02测试题含常见定义并命中训练段落,能立刻认定作弊吗?

展开参考答案与推导

不能;保留重叠位置,区分一般知识与题目答案泄漏,依据预先定义的污染标准报告。

03清洗运行顺序改变导致代表不同怎么办?

展开参考答案与推导

使用稳定文档ID和固定优先规则排序,将排序规则加入版本清单。

写代码试一试

01计算两组片段的相似度。

输入 / 条件
{1,2,3} 与 {2,3,4}
输出 / 预期
0.5

提示 并集不要把公共元素计算两遍。

展开参考答案与推导

交集大小2,并集大小4,比例为二分之一。

02构造传递归并反例。

输入 / 条件
A={1,2},B={2,3},C={3,4},阈值1/3
输出 / 预期
一个家族,但A与C相似度0

提示 分别计算三条候选边。

展开参考答案与推导

AB和BC各有一条公共片段、三个并集元素,形成链;AC没有公共元素。

03精确去重如何抵御指纹碰撞?

输入 / 条件
两个不同正文被分入同一个摘要桶
输出 / 预期
保留两个不同正文

提示 摘要桶需要保存可复核内容。

展开参考答案与推导

桶内比较规范化正文,只删除正文相同的记录,或保存指向原文的可读取位置。

读完之后,独立完成

课后作业:自己做一遍

本篇共 12 道作业,先完成 9 道基础与综合任务,再按前置知识挑战进阶题。其中包含 2 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能生成稳定文档指纹,计算近重复候选,保留来源记录,并说明跨集合污染检查的盲区。

本篇重点加练

近重复阈值、传递分组与评测隔离彼此影响,需要检验规则改变对数据组成的后果。

  • 词片段集合与 Jaccard 相似度
  • 阈值图的连通分量
  • 组级划分与评测集隔离

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01原理问答

    同一段文字存在换行、空格、编码与标点差异。定义原始身份和规范化身份,列出两者各能回答和不能回答的数据追溯问题。

    提交内容
    身份契约与文本样例
    怎样算完成
    • 原始数据可追溯
    • 规范化规则有版本

    练到的知识点原始字节与规范化文本的双重身份 · SHA-256 指纹与碰撞复核

  2. 作业 02代码推演

    构造四篇短文及其词片段集合,手算两两 Jaccard 值,在两个自选阈值下画重复图并制定代表文档选择规则。

    提交内容
    集合、相似度表与分组图
    怎样算完成
    • 阈值包含规则明确
    • 代表选择确定且可重放

    练到的知识点词片段集合与 Jaccard 相似度 · 阈值图的连通分量 · 确定性代表文档选择

  3. 作业 03原理问答

    比较相同词片段集合但重复次数不同的两篇文本,说明集合式 Jaccard 忽略什么;自拟需要计数信息与无需计数信息的两种去重目标。

    提交内容
    目标对照与文本案例
    怎样算完成
    • 集合和多重集合区分
    • 指标选择对应具体目标

    练到的知识点词片段集合与 Jaccard 相似度 · 阈值图的连通分量

综合应用

  1. 作业 04编程实践

    实现 CPU 去重清单生成器,保留输入摘要、规则版本、代表映射与删除原因;将同一输入随机重排三次检查输出身份一致性。

    提交内容
    程序及三次清单
    怎样算完成
    • 摘要命中支持复核
    • 删除与保留可解释

    练到的知识点原始字节与规范化文本的双重身份 · SHA-256 指纹与碰撞复核 · 确定性代表文档选择 · 数据版本与删除清单

  2. 作业 05排错实验

    构造由公共模板串联的重复候选链,检查直接按连通分量切分对保留集组成的影响;人工复核误报并保留理由。

    提交内容
    候选链与审计报告
    怎样算完成
    • 传递分组影响可量化
    • 候选命中不等同污染定论

    练到的知识点阈值图的连通分量 · 组级划分与评测集隔离 · 污染候选人工复核

  3. 作业 06面试场景

    评测题与训练文档共享常见定义但解题信息不同。设计污染复核流程,区分逐字泄漏、模板相似和背景知识重叠。

    提交内容
    复核规则与判例表
    怎样算完成
    • 判断依据可追踪
    • 争议样本有处理规则

    练到的知识点词片段集合与 Jaccard 相似度 · 组级划分与评测集隔离 · 污染候选人工复核

  4. 作业 07编程实践

    实现 CPU 重复图阈值扫描器,对六篇自建文本报告各阈值下的分组及跨训练/评测连接;保留合并发生的边。

    提交内容
    扫描程序与分组变化表
    怎样算完成
    • 阈值规则固定
    • 泄漏路径可定位

    练到的知识点阈值图的连通分量 · 组级划分与评测集隔离

  5. 作业 08设计评审

    训练语料与评测材料共享来源但没有逐字重复。设计来源隔离与内容去重两道审计,说明各自能发现的依赖关系。

    提交内容
    来源图与双层审计规范
    怎样算完成
    • 检测范围分别声明
    • 未命中不直接当无泄漏证明

    练到的知识点组级划分与评测集隔离

  6. 作业 09排错实验

    向语料加入同一公共页脚,再移除页脚,观察候选相似度和连通分组的变化;对 8 篇以内自造文本保存规范化前后记录。

    提交内容
    受控噪声实验
    怎样算完成
    • 仅改变公共片段
    • 误合并原因可复核

    练到的知识点词片段集合与 Jaccard 相似度 · 阈值图的连通分量

进阶挑战 · 掌握前置后选做

  1. 作业 10设计评审

    设计数据删除请求后的实验影响分析:哪些训练快照、索引和指标需要重新生成?用三个版本的迷你数据集演示追踪。

    提交内容
    版本依赖图与演示清单
    怎样算完成
    • 删除传播范围明确
    • 历史结果身份不被改写

    练到的知识点组级划分与评测集隔离 · 污染候选人工复核 · 数据版本与删除清单

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 11思维拓展 · 跨域迁移

    将语料去重迁移到医学报告或法律模板,统一格式本身具有意义时,哪些相似性应保留?构造任务相关的“该合并/不该合并”判例。

    提交内容
    领域判例与评价协议
    怎样算完成
    • 相似与信息等价分开
    • 去重目标先于阈值选择

    练到的知识点词片段集合与 Jaccard 相似度 · 组级划分与评测集隔离

  2. 作业 12思维拓展 · 反例构造

    “每对跨集合文本相似度都低于阈值,就不存在来源泄漏”是否成立?构造内容变换或依赖链案例,并说明额外身份信息的作用。

    提交内容
    反例及可审计来源链
    怎样算完成
    • 反例满足给定相似度条件
    • 新增证据与结论相关

    练到的知识点阈值图的连通分量 · 组级划分与评测集隔离

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗