CS336这门课一路写下来,前面几个实验都在模型内部打转——注意力、层归一化、分布式训练,到Assignment 4突然要你把注意力放到训练集的源头:数据本身。我刚开始也觉得,去重不就是把重复行删掉吗?直到自己动手编数据流水线才发现,这一步直接决定模型会不会把训练集背下来,也关系到下游评测结果到底可不可信。这篇笔记不打算复述作业原文,只围绕精确哈希去重、MinHash近似去重、LSH参数调优和评估实验这四块,讲讲我是怎么理解、怎么实现、以及在反复跑实验过程中踩过的坑。如果你正在写CS336的Assignment 4,或者想在真实文本数据集上做一套可复现的清洗流程,这篇应该能用得上。
1. 这期作业和课程主线的关系:前三个作业练模型,第四个作业管数据质量
CS336的课名很长,核心就一句话:从零构建语言模型。前几期作业的轨迹是:用numpy自己写Transformer内部模块、训练一个小规模语言模型、再把训练扩展到多卡/分布式环境。一整套下来,你对"模型怎么算、怎么训"已经有了底。但真实的大语言模型训练工程里,决定最终效果的还有另一半——喂进去的数据长什么样。Assignment 4就是补上这一半:在最终训练之前,如何科学地去重。
1.1 为什么把Dedup作为课程的收官作业
数据去重其实是一项典型的"看起来简单、做起来全是细节"的任务。它可以做得极浅:读入每一行文本,算个哈希值,丢进集合里看看有没有出现过,完事。但放到现代语言模型训练场景里,它要进一步回答:完全相同的文本怎么去;相同网页加了时间戳、导航栏、广告尾巴之后变成的"近似重复文本"怎么去;以及训练集和验证集之间潜在的交叉重叠怎么防。CS336把它放进作业里,是因为它和前面作业关心的核心概念——过拟合、泛化、记忆——是强相关的。
我自己做的时候一个直观的感受是:前几次作业拼的是"在约束下把模块实现出来",而这次拼的是"先想清楚度量标准,再下手实现"。因为去重算法的效果不是单靠跑通代码就能看出来的,你必须设计一套评估方法,量化精确去重和近似去重各自抓到了多少重复文档,误伤了多少非重复文档。
1.2 重复样本如何偷偷拉低模型质量
语言模型训练的损失函数是预测下一个token,如果一个文本片段在训练集里出现两次,那它贡献的梯度就相当于别的样本的两倍。表面上这只是在改变采样分布,实际效果却很微妙:模型会把更多容量用在记住那些高频重复的片段上,而不是把钱花在泛化模式上。我在自己的小规模实验里观察过类似现象:训练集里某类重复文本占比提高后,模型在生成阶段明显更倾向于复述训练集中的片段,而不是做出基于语义的归纳。
更隐蔽的问题是评估集污染。如果测试文本或者下游任务的提示文本,它的近似版本出现在预训练语料里,那么模型在实际评估前就已经"见过答案",指标会虚高。这在学术研究和真实产品迭代里都是很危险的坑。因此,训练数据流水线里至少要有两道去重关:一道是针对训练语料内部,让语料本身不臃肿;另一道是把训练语料与评测集做交叉比对,把与评测集高相似的训练样本提前剔除。
1.3 这次作业真正想考核的底层能力
作业虽然给了脚手架代码,但核心算法逻辑需要自己补全。与其说它在考你会不会背MinHash论文,不如说它在考察四件事:理解不同粒度的哈希去重之间的差别;能把n-gram集合相似度问题转化为可计算的签名问题;能解释并实现LSH的band切分原理;最后,能设计实验,用Precision/Recall去证明你的实现不是拍脑袋写出来的。
所以这篇笔记的章节顺序,我也按这条理解链路往下走。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 精确去重:从逐行哈希到一套通用的文本规范化流程
精确去重是整个作业里最"诚实"的模块。它不搞近似,只负责回答一个简单问题:这条文档以前是不是出现过一模一样的?但你以为这就是一句 if text in seen: skip 吗?真这么想会漏掉很多看起来不同、实际完全一样的文本。
2.1 去重粒度决定了流水线的召回上限
常见的做法有三档:整篇文档级、段落级、句子级。CS336给出的数据通常是JSONL格式,每一行是一篇完整文档。最省事的做法是文档级去重——把整行的文本内容作为哈希输入。
但文档级去重有一个很容易被忽略的问题:如果文本内部包含不同版本的换行符、行尾空格、或者编码不一致,哪怕视觉上完全一样,哈希值也会不同。比如一个网页抓下来的正文里,某一段末尾有个不可见字符,另一份副本没有,这两个文本就变成"精确不重复"了。
所以做精确哈希之前,我强烈建议先做一次文本规范化。规范化这一步不是为算法服务,是为"召回率"服务。你规范得越多,能匹配上的重复文本就越多;但规范得过度,也可能把代码里大小写敏感的信息错误合并。文本去重的通用经验是:
- 统一换行符,
\r\n和\n都压成\n - 去掉首尾空白
- 对Unicode做NFKC规范化,避免全角半角差异
- 是否转小写要按数据场景定,英文Web文本一般可以转,代码数据不要转
- 是否压缩连续的空白字符(多个空格、多个空行),取决于你想让格式差异大到什么程度被忽略
我在实际实现时,把这些清洗操作包装成一个 normalize_text 函数,它返回的是规范化后的字符串。后续所有哈希都以这个规范化结果为输入。
2.2 基于集合的两阶段哈希和增量式哈希
拿到规范化文本之后,最简单的精确去重可以抽象成两步:
- 计算每一篇文档的哈希指纹,例如
hashlib.md5(text.encode("utf-8")).hexdigest()。 - 把所有指纹放进一个全局集合,如果当前指纹已存在,就判定为重复,否则保留这篇文档并加入集合。
这在数据量几万到几十万行时完全够用。但有一个代价:你要么先把所有指纹算完,再一次性做集合去重;要么按流式方式,边读边判断。前者的好处是逻辑简单,后者的好处是内存峰值低,而且更适合流水线式处理——读一行、判断一行、决定保留或丢弃。
我当时实现的是增量式模块,因为它能无缝衔接到后面MinHash的部分。MinHash同样需要边读文档、边计算签名、边判断当前文档是否与历史文档重复。如果整个流程都是增量式的,内存里只需要存历史文档的指纹集合,而不需要存全量原始文档,这对后一步的大规模近似去重非常重要。
哈希函数的选择也值得说一句。标准库里 hashlib.md5 顺手能用,性能也还好。但如果你要把同样的流程拉到千万篇文档规模,建议换 xxhash 库里的 xxh3_64 或 xxh64。md5大约128位,xxh64只有64位,碰撞概率从理论上说高一些,但实践里处理上千万级别文本仍然可以接受,换来的是几倍的速度提升。如果担心碰撞,可以用 xxh3_128,既有速度又有足够的位宽。
注意:不要用Python内置的
hash()做跨进程或持久化的哈希指纹。解释器启动时会随机化字符串哈希种子,同一份文本在不同进程里可能得到完全不同的哈希值,这在作业的分布式执行和结果复现阶段是个隐形炸弹。
2.3 精确去重的产出指标
把精确去重模块跑完后,至少应该记录三个数字:
- 输入总文档数
- 被判定为精确重复的文档数
- 保留下来的文档数
这里还有一个容易忽略的顺序问题。增量式去重默认保留先出现、删除后出现的文档。如果数据集是按某一属性聚类分布的,比如同一个站点的页面连续排列在一起,那这个顺序是自然的,第一批被保留的文档往往就是最早抓取的原版。但如果数据已经被随机打乱过,两个完全相同的副本可能横跨整个文件,后出现的一个被去重,统计出来的重复文档数依然准确。这个顺序因素不会影响去重率多少,不过会影响你"哪一份是原版"的判定,在做数据血缘分析时需要知道这一点。
3. MinHash近似去重:把模糊的"相似"变成可计算的集合问题
如果数据里的重复全部是精确复制,上一章的方案就够了。问题是真实爬虫语料里大量存在的是近似重复:一段回答被复制到论坛,前面加了一句"这个问题我已经解决";同一篇文章改变标题、删掉一张图片的描述、自动附加了站点版权脚注之后被重复发布。这类文本的字符完全不一样,但语义和内容主体高度重叠,继续用精确哈希会全部漏掉。
3.1 从文档到shingle集合
处理近似重复,通用思路是把文档切碎成一块块小片段,再比较这些碎片集合的重叠程度。最常见的是字符级n-gram,也就是k-shingles:把文档看成连续k个字符的滑动窗口。比如 "hello world" 如果用k=5切,会得到 "hello", "ello ", "llo w", "lo wo", "o wor", " worl", "world" 这样的子串集合。
k的取值会影响算法的灵敏度。k太小,比如k=3,几乎任何两段普通英文文本都会共享很多三字符组合,导致误报增多;k太大,比如k=10,一个字符的插入或删除就会让大量shingle对不上,近似重复会被漏掉。作业场景里字符级k=5是最常用的起点,我自己测试下来,用k=5做Web文本是个不错的默认值。对代码类数据可能要调大,对中文语料则会有完全不同的考虑,但课程数据集通常以英文Web文本为主,作业阶段可以先把k=5跑通再谈调参。
假如一个文档包含L个字符,它会产生大约 L - k + 1 个字符级shingle。一篇2000字符的文章会产生约1996个shingle塞进一个Python集合。这个数量级还好,但如果所有文档的shingle集合全部常驻内存,几万篇文档就会带来很大的内存压力,所以后期扩展版本必须想办法流式处理,或者只保留文档签名而不是全部shingle。
3.2 Jaccard相似度为什么适合做文本重复判定
两个shingle集合的重复程度,用Jaccard相似度来定义最直接:
[
J(A,B) = \frac{|A \cap B|}{|A \cup B|}
]
Jaccard的取值在0到1之间。完全相同的文档集合,Jaccard是1;完全不同的文档,Jaccard接近0;如果一段文本复制了80%内容,它的shingle集合重叠率通常也接近0.8。用集合相似度来定义文本近似度,比直接拿文本编辑距离要稳定,因为它天然忽略了shingle之间的顺序,对复制、粘贴、剪切等操作有一定鲁棒性。
问题在于,Jaccard的计算需要知道两个集合的交集和并集大小。如果语料里有N篇文档,每篇平均有S个shingle,直接计算所有文档对的开销是O(N²·S),在几万篇文档规模下就已经不太现实了。MinHash的作用,就是把每篇文档的高维shingle集合压缩成一个固定长度的低维签名,同时保证两个签名的相似度能近似反映原始集合的Jaccard相似度。
3.3 MinHash签名为什么能够近似Jaccard
MinHash的直觉非常漂亮。假设我们对所有可能的shingle进行一个随机排列,然后看两个集合在这个排列下各自的最小元素。如果两个集合有重叠,那么随机排列之后,全局最小元素来自重叠区域和非重叠区域的概率之比,恰好由重叠程度决定。严格推导出来的结论是:两个集合在随机排列下拥有同一个最小元素的概率,正好等于它们的Jaccard相似度。
所以理论上可以这样做:准备m个独立的随机排列,针对每一篇文档,分别找到每个排列下的最小shingle,得到m个值,这就是它的m维MinHash签名。之后比较两篇文档时,不再直接算shingle交集,而是比较两个m维签名中有多少维度相同。签名相同的比例,就是Jaccard相似度的无偏估计。
实际实现时,没有人会真的对几十万甚至几十亿个shingle做随机排列。真正常用的是用一个随机的哈希函数来模拟排列效果。每个shingle先被转成一个整数x,然后定义:
[
h(x) = (a \cdot x + b) \bmod p
]
其中p是一个足够大的质数,a和b是从随机种子生成的两个参数。对于一个文档的所有shingle计算出h(x),取最小值,就相当于在这个"伪随机排列"下取到了最小元素。多组不同的(a,b)就对应多组排列,得到多个最小值,拼在一起就是MinHash签名。
3.4 计算性能与Python实现的取舍
这个环节是最容易把作业代码写慢的地方。最容易出现的错误写法是:对每篇文档的每个shingle,套一层Python for循环,再对每组(a,b)再套一层for循环。如果一篇文档有2000个shingle,m=128,那单篇文档就是256万次哈希,虽然也能跑,但扩展到数万篇文档时速度会非常感人。
我当时做了两个优化。第一个是用专门的哈希函数先把每个shingle一次性转成一个整数,这样每个shingle只做一次字符串哈希,后面所有MinHash运算都基于整数完成。第二个是把(a,b)系数提前生成好,用numpy的矩阵运算批量计算,而不是逐组调Python函数。对单篇文档的所有shingle和所有哈希函数做批量矩阵乘法,虽然也要注意溢出,但整体性能提升是成数量级的。
另外,m的取值需要先定下来。m越大,对Jaccard的估计越准,但同时计算量和后续LSH索引占用都变大。CS336作业里给的数据量一般是几十万行级别,m取64或128是常见选择。如果对精度要求不高,m=64足以画出漂亮的趋势;如果要写进实验报告,建议用m=128把曲线做得更平滑。
有一点要特别提醒:MinHash比较的是集合元素的重复情况,它和文本长度没有直接关系。一篇超长文档和一篇短文档如果拥有完全相同的shingle集合,它们的Jaccard可能并不高,因为长文档的并集更大。实际做重复文档检测时,通常不会用Jaccard绝对值作为唯一的最终判定,你还会结合文档长度、重叠shingle的绝对数量做一层过滤。
3.5 签名生成阶段的完整流程
我现在回顾自己最终跑通的MinHash实现,核心流程大概是:
- 对文档做规范化,得到干净的文本。
- 按k=5切出所有字符shingle。
- 把每个shingle用xxh3_64转成一个无符号64位整数。
- 准备m组随机系数(a,b),这些系数由同一个固定种子生成,确保运行可复现。
- 对每个shingle整数应用m个哈希函数,对每个函数取最小值,得到m维签名。
- 返回这个签名作为文档的"指纹"。
这样每一篇文档最后只剩下一个m维向量,可能是128个uint64整数,原始shingle集合可以被释放掉。后续无论是做精确的比较,还是做LSH索引,都只需要在这个低维指纹上进行。
4. LSH:把高维相似度检索变成可执行的工程方案
有了MinHash签名之后,理论上你已经可以把每篇文档表示成一个m维向量。如果要寻找所有相似文档对,最暴力的做法是对所有N篇文档两两计算签名相似度,复杂度O(N²·m),N一旦上万就让人绝望。LSH就是来解决这个问题的:用很小的代价把"可能相似的文档对"快速筛出来,然后只对候选对做精确比较。
4.1 Band切分:把一条完整签名切成多个可哈希的片段
LSH的思想是:如果两个文档足够相似,那么它们的m维签名中大部分维度应该是一致的。与其比较所有维度,不如把m维签名切分成b个连续的band,每个band包含r个维度,满足 b × r = m。
然后对每个band计算一个bucket编号。常见做法是把该band内的r个值序列化成一个字节串,再哈希成一个短的key。每个文档会被插入到b个不同的band桶里。两个文档只要在任意一个band上的r个值完全相同,就会被放进同一个桶里,从而成为候选相似对。
这种设计是有意为之。它不要求两个文档在所有m维上都一致,只要某一小段连续维度一致,就值得进一步比较。因为真正的重复文本通常有很多维度的值都一样,某一个band完全匹配的概率很高;而完全无关的文档,在所有band上同时匹配的概率则被压得很低。
4.2 Threshold公式与S曲线的直觉
为什么作业总要你调band数和row数?因为这两个参数共同决定了近似去重检测的灵敏度。经典的近似公式是:
[
threshold \approx \left(\frac{1}{b}\right)^{\frac{1}{r}}
]
它表示两个文档的Jaccard相似度超过这个值时,被LSH判定为候选对的概率会快速爬升到接近1;低于这个值时,被判定为候选对的概率接近于0。S曲线的形状就是从这个跳变特性来的。
下面这张表整理了m=128时几组(b,r)组合对应的近似阈值,能帮你快速理解参数选择的方向:
| band数 b | band内行数 r | 乘积 b×r | 近似阈值 (1/b)^(1/r) | 检测倾向 |
|---|---|---|---|---|
| 16 | 8 | 128 | 约0.76 | 只抓高度相似文档,漏报较多 |
| 32 | 4 | 128 | 约0.42 | 中等灵敏度,抓大量近似重复 |
| 64 | 2 | 128 | 约0.13 | 非常激进,误报上升 |
| 8 | 16 | 128 | 约0.87 | 非常保守,仅抓几乎复制粘贴的文本 |
band越多,相当于把指纹切得更碎,两个文档只要有一小片区域重合就可能被选出来,所以灵敏度高,但误报也随之上升。band少则反之,要求两个文档在很长一段连续维度上都保持一致才肯把它们列为候选,抓得更严但容易漏掉局部相似的长尾重复。
我的经验是:如果目标是清理大规模Web语料,一般倾向threshold设在0.6到0.8之间,也就是用中等偏保守的参数,比如b=20、r=6或者b=16、r=8。过于激进的参数虽然能把重复率冲得很高,但代价是会误删大量只是共享了固定模板、正文内容完全不同的文档。
4.3 候选对的进一步验证与最终去重
LSH本身不会告诉你两篇文档相似度是多少,它只负责给出一个候选集合。作业里通常要求对这些候选对做进一步验证。验证方式有两种:
- 快速的方案:比较两篇文档的m维MinHash签名,计算相同维度比例,作为Jaccard的估计值;如果大于用户给定的阈值,就判定为重复。
- 精确的方案:取两个候选文档的原始shingle集合,实际计算Jaccard,得到准确值。这个开销更大,但因为LSH筛出的候选对通常很少,实际可以接受。
两种方式我都试过。签名相似度的优势是快,但会有估计误差;精确Jaccard的优势是准。如果你要写实验报告做性能评估,建议在LSH候选的基础上做精确Jaccard验证,这样才能画出更可信的P/R曲线。
我最终的实现顺序是这样:先跑LSH得出所有候选对,再对每个候选对计算真实Jaccard,大于阈值才标记为重复。这样可以避免LSH自身误报对最终统计造成干扰。
4.4 LSH索引的内存与扩展思考
如果把每篇文档的b个band key全部放进一个Python字典,几十万篇文档的内存开销会相当可观。我之前估算过,一篇文档128维签名本身很小,但是b个band每个都会生成一个key并插入字典,意味着存储量大约是 N×b 个条目。几万篇文档没感觉,几十万篇时Python dict的key对象开销会让你看到内存占据稳步上涨。
作业规模下可以直接用dict,但我建议在接口设计上预留一层抽象。如果要扩展到大语料,更合理的路线是:把每个band的key作为排序字段,用外部排序把相同key的文档聚在一起,再线性扫描每个key对应的文档列表。这种"分band落盘再merge"的做法在工业级数据管线里很常见,也能让同一套代码平滑迁移到Spark或MapReduce框架。
5. 实验评估:Precision/Recall曲线才是作业报告的立足点
算法写完了,参数也调了,但怎么向别人证明你的approximate dedup实现真的有效?如果只是说"我们找到了X个重复文档",这是不够的。因为MinHash+LSH内部有一堆近似判断,你必须用精确方法构造一个评价基准,才能说明近似方法到底有多少准确率和召回率。
5.1 如何构造评估用的真值集合
最直接的做法是抽样一个小型语料,例如1000篇或2000篇文档,用暴力两层嵌套循环对每一对文档计算真实的Jaccard相似度。凡是Jaccard大于某个人为阈值(比如0.8)的文档对,就记入一个"真值重复对集合"。
然后再用你实现的MinHash+LSH在这同一批小语料上跑一遍,得到LSH输出的候选重复对。注意这里的候选对是指LSH从bucket里筛出来的pair,而不是最终验证后的pair。把所有候选对与真值集合做对比:
- 如果某个候选对确实在真值集合里,就是True Positive。
- 如果某个候选对不在真值集合里,就是False Positive。
- 如果某个真值对没有被LSH候选集覆盖到,就是False Negative。
在评估时,我建议直接用LSH的候选集去算,而不是用最终过滤后的结果。因为如果你在候选阶段之后又加了真实Jaccard过滤,P/R已经被第二次修正了,你看到的会是"过滤后"的漂亮结果,看不到LSH本身参数的影响。作业里如果要求分析不同band/row参数的效果,应该把评估点放在LSH候选输出这一层,才更有分析价值。
5.2 不同参数组合下的P/R走向
我实际跑下来,大致会得到这样一个规律:当band数增加、r减少时,LSH的候选集会膨胀,Recall显著上升,Precision相应下降。比如用b=64、r=2时,LSH几乎会把所有相似度高于0.2的文档对都捞上来,但同时也引入了大量Jaccard只有0.2的无关对,Precision很难看。反过来用b=16、r=8时,Recall会比较保守,但捞出来的候选对基本都是一眼就能看出重复的,Precision会很高。
调参的本质就是在Precision和Recall之间找平衡点。实际操作时不是单看某一个指标,而是综合看F1或者看最终实际保留的文档数是否符合预期。作业报告里如果能画出一张不同band/row组合的P/R对比表,再附上一段解释,基本就能把这个部分写得很扎实。
5.3 从P/R反推业务阈值:你想要多高的查全率
对LLM训练数据清洗来说,我的个人倾向是"宁可多删不可漏删"。因为漏掉一个重复样本,代价是模型可能记住某个不该被放大的片段;多删一个相似样本,通常只是损失一点训练数据量,影响远小于前者。所以去重阈值往往会设得比较宽松,追求更高的召回。
但宽松阈值也会带来副作用:如果为了追求高召回把阈值降到0.3,很多共享了公共页脚、版权声明、统一导航模板的文档会被误判为重复而被删除。这种情况下语料整体多样性反而下降。更合理的做法是保留两套指标:一套是高阈值(0.8-0.9)用于抓完全复制型重复,另一套是低阈值(0.5-0.6)用于分析数据分布,而不是直接把低阈值结果应用到最终清洗。
5.4 实验后的数据报告要记录哪些信息
现在回到作业本身。跑完整个流水线后,我建议至少把下面几项数据记录清楚:
- 去重之前的文档总数与总token数
- 精确哈希去重移除的文档数与占比
- 在不同阈值或不同band/row组合下,近似去重移除的文档数与占比
- 抽样评估时用到的真值数量、候选对数量、Precision和Recall
- 保留下来的最终文档数与token数
这些数字不仅是作业验收的基础,也是将来复现实验、横向比较不同清洗策略时最重要的数据资产。
6. 我踩过的坑与最终的实现建议
这一期作业的代码量不算大,但很多细节问题会在你跑完整流程时集中爆发。我把几个印象比较深的坑列出来,给大家省点时间。
6.1 Python内置hash()的进程不一致问题
这是我在做MinHash时遇到的第一个大坑。写单个Python脚本时一切正常,一旦改造成多进程并行处理文档,就会得到完全不一致的签名。问题出在Python解释器默认会为字符串哈希添加随机种子。每次启动新进程时,同一个shingle字符串会被映射成完全不同的整数,签名失去了跨进程可比性。
解决方案是不要用内置hash()做任何"需要跨脚本/跨进程复现"的哈希,统一用hashlib或xxhash这类确定性算法。养成这个习惯之后,后续分布式执行才能保证结果可复现。
6.2 内存峰值不在shingle集合本身,而在dict与惰性缓存
你会发现真正吃掉内存的往往不是某个文档的上千个shingle,而是你把所有文档的中间结果都往dict里塞。比如把每一篇文档的shingle集合缓存在内存里,只为了之后计算精确Jaccard,又会叠加一层吞吐压力。
一个可行的做法是:第一遍扫完文档,算完MinHash签名后就释放原始shingle集合,只保留小体积的签名。等到LSH筛出候选对之后,再按文档ID回去读原始文本计算精确Jaccard。这样内存里只保留一份签名索引和一个按需读取的文本路径。虽然多了一次磁盘I/O,但整体扩展性好很多。如果数据集本身就是内存级大小并且只有几万行,直接全缓存也能跑通,但一旦数据量上来,这个设计差异会很明显。
6.3 阈值与band/row不是一回事,报实验时要写明
作业里经常出现"运行你的MinHash实现,用threshold为0.8去重"这类描述。有两种实现方式都叫threshold=0.8,一种是LSH的band/row参数已经把目标阈值设定在0.8附近,另一种是LSH采用宽松参数先产生候选,再在候选集上用Jaccard大于0.8作为过滤条件。这两者在结果上会略有差别。
建议在报告里写清楚:到底是用什么参数做LSH候选,候选产生后是否又做了真实Jaccard过滤,设置的threshold作用在哪个环节。我踩过的一个坑是代码里LSH参数写得很宽松,把阈值过滤放在最后,结果最终报告上显示阈值为0.8,实际LSH却把Jaccard只有0.15的无关文档都捞出来参与比较,虽然最后真实Jaccard过滤把它们滤掉了,但候选集过大导致运行时间膨胀。
6.4 文档顺序会影响去重统计口径
我前面已经提过一次顺序的影响,这里再展开说。增量式去重中,重复文档对里只有后出现的那一份会被标记为重复。如果你的文档集合不是按某种时间顺序排列的,那么"被保留的版本"可能并不是原始的版本,而只是文件里更靠前的某一份。
在做数据血缘或审计时要意识到这个特性。我见过有人把这一现象当成bug排查了很久,最后发现代码没坏,只是语义和预期不一致。如果希望保留的是最新版本,那应该先按时间戳排序,再跑去重;如果希望保留的是第一个看到的版本,则保持原顺序即可。
6.5 训练集与测试集的交叉去重是容易被忽视的增值项
作业要求里主要强调语料内部去重。但真正动手搭数据管道时,我强烈建议额外做一步训练集和评估集的交叉比对。操作不难:把测试集文档也切shingle、算签名,然后与训练集的LSH索引做一次查询,凡是Jaccard超过阈值的训练样本,直接从训练集中移除。
这一步对模型评估可信度的提升非常大。如果你的预训练语料里混入了评测集的相似文本,下游任务指标会虚高到让你产生错觉。CS336课程实验虽然不强制要求这一步,但理解了之后,对"数据隔离"这个概念会有更深切的认识。
6.6 不要执着于一次把参数调到最优
MinHash和LSH里参数很多,k、m、b、r、最终Jaccard阈值、数据规范化方式,每改动一个都可能让结果产生变化。我一开始想在第一次运行时就找到"完美参数",结果花了很多时间调参,却忽略了最重要的评价流程。后来我改为先固定一组比较稳的参数跑通端到端流程,再专门花时间扫描参数空间。这个顺序对作业效率非常重要——先有可运行的baseline,再做实验对比,永远比边调参边写代码要快。
7. 从数据集到训练质量的最后一步
这期作业做完之后,我对语言模型训练管线的理解有了明显的变化。以前总觉得模型架构、优化器、分布式策略才是决定模型上限的关键,但数据去重让我意识到,如果训练数据里充满重复,模型的泛化能力会被悄悄削弱。它不像loss曲线那样直观,却会在生成质量和评测可信度上体现出来。
如果你在写CS336的Assignment 4,我的建议是不要只满足于把测试样例跑过。试着把精确去重和MinHash近似去重各自的贡献拆开,看看哪些重复是精确哈希就能抓到的,哪些必须靠近似方法才能发现。这个对比分析的过程,比单纯完成作业更有价值。后续如果你自己训练一个小模型,也值得把这一套数据清洗流程应用上去,然后观察训练曲线的变化——那会是对这一期作业最好的验证。
