对应 Maps to:Stanford CS525 Winter 2026 Training Data for AI,1/14 客座讲座 Software tools for large-scale data(Tony Wang,16 页 slides,标题 Tools for Processing Data: Speedrunning my PhD in 45 minutes)。 依据说明:Tony Wang 2025 年之前在 Stanford 读博,2024 年起在 Anthropic 预训练团队负责数据处理;slides 声明观点只代表他个人。slides 很短(数据是表格;Parquet、Iceberg 的优缺点;数据仓库与表的设计;Polars / DuckDB / Pandas vs Spark;一张”该用什么”的决策图),最后一页是现场演示(没有公开)。本讲在 slides 的骨架上补充了标准的背景知识:列式存储的内部结构、MapReduce / Spark 的执行模型(Dean & Ghemawat 2004;Zaharia et al. 2012)、去重算法(Bloom 1970;Broder 1997;Lee et al. 2022)。这些补充会在正文中标明。 配套代码 Code:
code/lecture04/(Python 标准库 + numpy;3 个脚本 + 2 个模块;每个都在 10 秒以内)。代码里手写了一个”迷你 Parquet”、一个 map-reduce、一个 Bloom filter 和 MinHash + LSH,用来看清楚这些工具背后的原理——真实工作中请用 Parquet / Polars / Spark 等成熟的工具。 预计阅读时间:2.5 小时(含运行代码)
0. 本讲导航 · How to read this lecture
0.1 学习目标 Learning goals
- 说明为什么”读数据常常比算数据还贵”,以及这怎样决定了存储格式的设计。
- 解释列式存储(Parquet)的核心机制:按列存放、类型、编码与压缩、行组与统计信息、谓词下推;知道它的局限。
- 说明 Iceberg 这类”表格式”在 Parquet 之上加了什么,以及规范化 vs 反规范化的表设计各自的取舍。
- 用 map → shuffle → reduce 描述一个分布式数据处理任务;解释 Spark 的阶段、shuffle、惰性执行和容错,以及数据倾斜、幂等性、浮点可复现性这些实际问题。
- 根据数据量和需求,判断”一台大机器 + Polars”还是”Spark 集群”;会粗算一次处理的成本。
- 解释完全去重(指纹、Bloom filter)和近似去重(MinHash + LSH)的原理,会用 S 形曲线设计 LSH 的参数。
0.2 先修与本讲范围 Prerequisites & scope
| 默认你已经会 Assumed | 本讲不展开 Not covered |
|---|---|
| Python 基础;知道什么是文件、进程、哈希函数 | 具体工具的安装和 API 细节(Spark、Polars 的官方文档写得很好) |
| 第 3 讲:Common Crawl、LAION | 预训练数据管线的具体过滤规则(第 6–7 讲) |
| GPU 上的数据处理、分布式文件系统的实现 |
0.3 本讲地图 Map
§1 为什么需要专门的工具:数据量的账本;读比算贵;一条数据管线的样子
§2 数据是表格
§3 存储格式:按行 vs 按列;Parquet 的内部;它的局限 ← 代码 01 演示 1–4
§4 表格式与表的设计:Iceberg;规范化 vs 反规范化 ← 代码 01 演示 5
§5 计算:一台机器(Polars / DuckDB)vs 集群(Spark); ← 代码 02
map-reduce;倾斜;容错;惰性执行;决策图与成本
§6 去重:指纹与 Bloom filter;MinHash 与 LSH;子串去重 ← 代码 03
§7 可复现的数据管线
§8 延伸与前沿
§9–13 小结、常见误区、练习、延伸阅读、下一讲预告
0.4 关于数字
字节数、记录数、精度召回等数字来自代码的实际输出,是确定的;耗时和吞吐量取决于你的机器,每次运行也会略有不同(代码 01、02 里标注了”耗时”的列)。代码的数据是一张合成的”网页抓取表”(webtable.py):6 万行,列包括主键、URL、域名、语言、月份、词数、质量分和正文;域名是长尾分布;约 8% 的网页是另一个网页的完全复制、约 7% 是近似复制,真相都记录在案。
1. 为什么需要专门的工具
先算一笔账(数字来自第 3 讲的 slides):
| 数据 | 量级 |
|---|---|
| Common Crawl 一次更新(约每月) | 约 30 亿个网页,约 100 TB 压缩数据 |
| Common Crawl 累计 | 约 2500 亿个网页,约 10 PB |
| LAION-5B 的候选池 | 约 500 亿个图文对 |
| 一个前沿语言模型的预训练数据 | 十几万亿到几十万亿个 token(第 1 讲 §1.4) |
一块好的 NVMe 固态硬盘顺序读的速度是每秒几 GB;100 TB 用一块盘读一遍要好几个小时,还没算解压和处理。网络和对象存储(S3)的吞吐更低、延迟更高。所以 slides 第一页就强调了一个经验:读数据常常比算数据还贵。一个好的数据工具,首先是让你少读、顺序地读、并行地读。
一条典型的预训练数据管线大致是这样(第 6 讲会详细讲每一步):
原始抓取(WARC / HTML)→ 抽取正文 → 语言识别 → 启发式过滤 → 去重(完全 / 近似)
→ 模型打分(质量、毒性……)→ 选择与混合 → 分词 → 切分成训练用的分片
每一步都是”对一张很大的表做一次变换”。这一讲讲的就是:这张表怎样存(§3–4),这些变换怎样算(§5),其中最常见也最难的一步——去重——怎样做(§6)。
2. 数据是表格
Tony Wang 的第一张 slides:数据是什么?
- 表格(tabular);
- 有类型(typed);
- 可以有各种类型的列;
- 可以有很多列、上万亿行。
这个”世界观”看起来平淡,但它决定了后面的一切。一个网页、一张图文对、一段代码、一条对话,都可以看作一行:主键、来源、正文(或图片的路径 / 字节)、以及一堆特征(语言、长度、质量分、毒性分、去重簇编号……)。数据工作的大部分时间,是在给这张表加特征、按特征过滤和分组。
3. 存储格式
3.1 按行存 vs 按列存
按行存(row-oriented):一行的所有字段放在一起,一行接一行。最常见的是 JSONL(每行一个 JSON 对象)和 CSV。优点:人能直接读、任何语言都能处理、可以流式地一行一行处理、追加写入很方便。Common Crawl 的派生数据、很多开源数据集都用它。
按列存(column-oriented):同一列的所有值放在一起。Parquet 是最常用的列式格式。
为什么按列存?因为分析和过滤几乎从不需要所有的列。“算所有网页质量分的平均值”只需要一列;“找出质量 > 0.9 的英文网页的 URL”需要三列——而正文(通常占绝大部分字节)一列都不需要。
代码 01 演示 1:同一张 6 万行的网页表。
| 格式 | 文件大小 |
|---|---|
| JSONL | 40.88 MB |
| JSONL + zlib(整个文件压缩) | 10.39 MB |
| 迷你 Parquet(按列 + 压缩) | 9.58 MB |
| 查询 | JSONL 要读 | 迷你 Parquet 要读 |
|---|---|---|
| A:所有网页 quality 的平均值 | 40.88 MB | 0.18 MB |
| B:quality > 0.9 且英文的网页的 URL | 40.88 MB | 0.48 MB |
JSONL 要读完整个文件、解析每一个字段;列式文件只读需要的列。在这张表上,正文占了 JSONL 约 77% 的字节,而两个查询都用不到它。
3.2 Parquet 的内部(补充)
slides 列出了 Parquet 的优点:高度压缩的列式存储格式;列式带来更好的访问和压缩;强制类型;所有数据处理工具都支持它。它的内部结构(这部分是补充的背景,minicol.py 用约 200 行实现了一个简化版):
- 行组(row group):每若干行(真实的 Parquet 通常是几十 MB 到 1 GB 的数据)切成一组。
- 列块(column chunk):每个行组里,每一列存成一块。读一列 = 读每个行组里这一列的那一块。
- 编码:低基数的列用字典编码(“en” 只存一次,每行只存一个小整数下标),重复的值用游程编码,整数用差分 / 位压缩……
- 压缩:每个列块再用通用压缩算法(Snappy、Zstd、Gzip 等)压缩。
- 统计信息:每个列块记录最小值、最大值(还有空值个数等)。
- 页脚(footer):文件末尾的元数据,记录 schema、每个列块的位置、编码和统计信息。读文件时先读页脚,再只 seek 到需要的列块。
为什么按列存压缩得好? 同一列的值类型相同、分布相似。代码 01 演示 2 逐列看压缩率:连续的整数主键 5.3 倍,只有 12 个值的月份 10 倍,随机的浮点质量分只有 2.7 倍,正文 3.6 倍。字典编码让语言列在压缩前从字符串变成一堆小整数——但 zlib 本身也很会利用重复,所以压缩之后字典编码的优势不大(0.023 MB vs 0.028 MB);字典编码真正的好处在读的时候:比较、分组、过滤可以直接在小整数上做。
要诚实地说:在这张表上,按列存 + 压缩(9.58 MB)只比”整个 JSONL 文件压缩”(10.39 MB)小一点——正文占了绝大部分字节,而正文怎么存都差不多。按列存最大的优势不是文件更小,而是只读需要的列、跳过不需要的行。
3.3 行组统计与谓词下推
代码 01 演示 3:查询 “quality > 0.9”,看能跳过多少行组(每 4096 行一组,共 15 组)。
| 写入顺序 | 跳过的行组 | 读的 MB |
|---|---|---|
| 原始顺序(按抓取顺序) | 0 / 15 | 0.47 |
| 按 quality 排序后再写 | 13 / 15 | 0.05 |
原始顺序下,每个行组里都有高质量和低质量的网页,max(quality) 总是大于 0.9,一组也跳不过。按 quality 排序后再写,高质量的网页集中在最后两组,读的数据少了一个数量级。统计信息只有在数据”有序”或”成块”时才有用——真实的管线常按日期、域名或某个常用的过滤列排序或分区再写。这是一个代价很小、收益很大的设计决定。
这种”根据统计信息跳过不可能满足条件的数据”叫谓词下推(predicate pushdown):过滤条件被”推”到了读文件这一层,不需要的数据根本不读。
3.4 类型
代码 01 演示 4:quality 列里混进了一个字符串 “N/A”。JSONL 照收不误,直到下游有人算平均值时才报错——那时离写入可能已经过了几周,也不知道是哪一步引入的。有类型的格式在写入时就拒绝它,错误在离源头最近的地方暴露。大规模数据管线里,这类”安静的错误”(一个字段偶尔是空字符串、偶尔是数字、偶尔是 null)是最常见也最难查的问题之一。
3.5 Parquet 的局限
slides 也列出了 Parquet 的缺点:
- 不可修改(immutable):想改一个值、加一列,只能写一个新文件。
- 行组大小和其他参数绑在一起;元数据格式比较老旧;一些”不太重要的小细节”。
- 这仍然是一个活跃的研究领域——近几年有不少新的列式格式在尝试改进(例如 Lance、Vortex、Nimble 等),针对机器学习的随机访问、宽表、大的二进制对象(图片、向量)等场景。
图片和多模态数据常用另一类格式:把成千上万个样本打包成一个个 tar 分片(例如 WebDataset),训练时按分片顺序读,避免大量的小文件随机访问。DataComp、LAION 的图片数据就是这样分发的。Common Crawl 的原始数据用的是 WARC(完整的 HTTP 响应)和 WET(抽取出的纯文本)格式。
4. 表格式与表的设计
4.1 Iceberg:在 Parquet 之上加一层
一张”表”在实际中往往是成百上千个 Parquet 文件。谁来记录”这张表由哪些文件组成”?如果两个任务同时在写,读的人会不会读到一半?Iceberg 这样的”表格式”(table format)回答这些问题。slides 列出了它的优缺点:
- 优点:目录(catalog)、元数据、统计信息;性能好得多(查询时先看元数据就能排除大量文件);不会出现不一致的状态——一次提交要么全部可见、要么全部不可见(通过原子地替换一个指向”当前快照”的元数据指针)。
- 缺点:不能”回填”(backfill)数据;需要一个 S3 之外的目录服务来管理所有的表(例如 AWS Glue);还有一些仍在研究中的细节。
- 建议:只有在做分布式处理、而且要管理很多数据集时才用它。
(补充:类似的表格式还有 Delta Lake、Apache Hudi。它们共同的思想是:数据文件本身不可修改,“修改”是写新文件、再原子地更新元数据;旧的快照还在,所以可以”时间旅行”回到过去的版本——这对数据管线的可复现性非常有用,§7。)
4.2 规范化 vs 反规范化
slides 讲了数据仓库 / 特征库(feature store)里的一个经典设计问题。一张主表:主键、URL、正文;很多特征:特征 A、特征 B……怎样存?
- 反规范化(denormalized):所有列放在一张宽表里。
- 规范化(normalized):主表 + 每个特征一张小表(主键 + 特征值),查询时按主键 join。
slides 的对比:
| 规范化 | 反规范化 | |
|---|---|---|
| 给一个特征加新值 | 碰的列少 | 所有的列都要变 |
| 不同特征在不同时间更新 | 可以 | 非常复杂 |
| 查询多个特征 | 可能需要 join | 不需要 join |
| 生产环境 | 经验上更好(特征多、更新频繁时) |
代码 01 演示 5:给 6 万个网页加一个新特征(毒性分数)。反规范化要重写整张表 9.61 MB(包括大段正文——Parquet 文件不可修改);规范化只写一张新的特征表 0.121 MB。代价是查询”英文网页的平均毒性”时要按主键做一次 join(哈希 join:先把一边建成字典,再扫描另一边)。
在预训练数据管线里,质量分、语言识别、去重标记、毒性分数……每个新的分类器都是一个新特征,而且常常由不同的人、在不同的时间算出来——这正是规范化设计的典型场景。
4.3 今天的典型架构
slides 画出了”今天的世界”:存储是 S3 上的 Parquet 文件,组织成 Iceberg 表,由 AWS Glue 之类的目录服务管理;计算端,小数据用 Polars(笔记本、一台 EC2 机器),大数据用 Spark(Kubernetes 集群、AWS EMR)。
5. 计算:一台机器,还是一个集群
5.1 一台机器:Polars / DuckDB / Pandas
slides:分析或变换小于约 1 TB 的 Parquet 文件(本地或云上),用 Polars、DuckDB(或者 Pandas)就够了。他特别推荐 Polars(pip install polars,polars.read_parquet('s3://...parquet'))。它们的共同点(补充):用 C++ / Rust 写的列式引擎,多线程,向量化执行,支持惰性查询(先写完整的查询计划再执行,可以做和 Spark 类似的优化)。
5.2 一个集群:Spark
slides:当你需要处理超过约 1 TB 的数据、而且有一批机器时,用 Spark:
- 这些机器按数据并行的阶段(stage)推进。每个阶段里,每台机器在数据的一部分上执行同样的处理逻辑。
- 有些阶段里,机器需要去别的机器上取中间数据——通常是上一阶段算出的(这一步叫 shuffle)。
- 接口大多像 SQL。
- 惰性执行(lazy execution)。
一个典型的 Spark 程序(slides 的例子,加上注释):
df = spark.read.table("tony.common_crawl_data") # df 是一个 DataFrame [primary_key, url, text](还没有读任何数据)
df = df.filter('text ilike "% cat %"') # 只是记下"要过滤"(还没有执行)
df.writeTo("tony.common_crawl_data_with_cat").createOrReplace() # 到这里才真正执行,写出一张新的 Iceberg 表
用户自定义函数(UDF):用 @udf 装饰一个 Python 函数,就能把它用在列上(例如算名字的长度)。
slides 说 Spark 好在:
- 简单的执行模型,有好的观测工具;
- 容易优化,性能可预测;
- 能扩展到几万个 CPU;
- API 灵活,几乎能表达任何计算;
- 容错和弹性伸缩:可以按需扩缩集群;能从很多种失败中恢复——“分布式计算里有很多失败,因为几乎一切都要过网络,而几乎一切过网络的东西都需要重试”。
竞争者?他说还没真正看到:Ray、Daft 很有前景,但还有很多问题。
5.3 map → shuffle → reduce(补充)
Spark 的执行模型继承自 Google 的 MapReduce(Dean & Ghemawat 2004)。用”按域名统计网页数、平均质量,并去掉完全重复的网页”这个典型任务来看:
阶段 1: map(每个分片独立) 给每个网页算内容指纹,输出 (指纹, 网页信息),分片内先去一次重
shuffle(按指纹分区) 同一个指纹的所有记录送到同一个分区:hash(指纹) mod 分区数
reduce(每个分区独立) 同一个指纹只保留 doc_id 最小的那个
阶段 2: shuffle(按域名分区)
reduce 按域名汇总:网页数、质量分之和、词数之和
代码 02 演示 1 用 16 个分片、8 个分区实现了这个任务,并和单进程的结果比较:去掉的重复网页数完全一样(6 万行 → 55,257 行);用 4 个进程并行,结果也一样。两个值得注意的细节:
- 平均质量有 4 个域名在第 6 位小数上不同(最大差别 )。浮点数加法不满足结合律, 和 在最后几位可能不同;map-reduce 加法的顺序和单进程不一样。换成定点整数累加(质量分 × 10000)之后,结果逐位一致。分布式管线要做到”同样的输入 → 逐位相同的输出”,就要注意这种细节——否则你没法用”新旧结果是否完全相同”来检查一次代码修改有没有出错。
- 分区要用稳定的哈希。Python 内置的
hash()对字符串每个进程用不同的随机种子(防止哈希碰撞攻击);用它来分区,两个工作进程会把同一个键分到不同的分区。
在这么小的数据上,多进程并不比单进程快(进程启动、序列化都有开销)——这本身也是 slides “1 TB 以下用一台机器”建议的一个缩影。
5.4 数据倾斜
网络数据处处是长尾:少数大网站、少数热门 URL、少数巨长的文档。代码 02 演示 2:按域名分区时,最大的域名有 9071 个网页(占 15.1%),而一半的域名少于 46 个网页。
| 做法 | shuffle 搬运的记录 | 最大分区 / 平均分区 |
|---|---|---|
| A:每行直接按域名 shuffle | 60,000 | 2.58 |
| B:map 端先合并(combiner) | 5,868 | 1.15 |
| C:热门域名”加盐”后再 shuffle | 60,000 | 1.21 |
一个阶段要等所有分区都做完才算完——最大的分区就是”掉队者”(straggler),决定了整个阶段的耗时。两种对策:
- map 端合并(combiner):求和、计数、取最小值这类可以分步计算的操作,先在每个分片里合并(每个分片每个域名只剩一条),再 shuffle。搬运的数据少了 10 倍,倾斜也随之消失。
- 加盐(salting):不能在 map 端合并的操作,把热门的键拆成几个”键 + 随机后缀”,分散到不同的分区,之后再汇总一次把盐去掉。
5.5 容错与幂等性
代码 02 演示 3:16 个任务,每次以 30% 的概率失败(可能失败在做了一半的时候),失败就重试,一共执行了 28 次。
| 写法 | 输出的记录数 | 重复记录 |
|---|---|---|
| 不幂等:边处理边追加到共享输出 | 79,037 | 19,037 |
| 幂等:写到自己的位置,成功后整体提交 | 60,000 | 0 |
重试本身不是问题,问题在于失败前做了一半的事。不幂等的写法会悄悄产生重复——而重复数据进入训练集是有害的(第 6 讲)。幂等(idempotent)的写法:每个任务写自己的输出位置(例如 part-00007),成功后原子地提交,重试时覆盖——不管失败多少次,结果都一样。Spark 的容错正是建立在”任务是确定性的、可以重算”之上;Iceberg 的”不会出现不一致的状态”也是同一个思想。
5.6 惰性执行与谓词下推
代码 02 演示 4:用户写的顺序是”先给每一行算内容指纹(贵),再过滤出 quality > 0.8 的英文网页”;如果先过滤再算指纹,指纹计算从 60,000 次减到 9,268 次,结果完全一样。
Spark、Polars 的惰性执行正是为此:filter、withColumn 只是在”记下要做什么”,等到真正要写出结果时,优化器看到完整的计划,自动把过滤往前挪、只读需要的列(配合列式存储)。代价是:用户自定义函数(UDF)对优化器是黑盒——它不知道这个函数贵不贵、有没有副作用,没法帮你重排。能用内置的表达式写的过滤,尽量不要写成 UDF。
5.7 该用什么?一张决策图,和一笔账
slides 的最后一张图(“数据处理 101”):
- 如果(数据超过 1 TB,或者需要处理得非常快)或者(你愿意搭建、或者已经有好的分布式基础设施)→ Spark 基础设施(EMR / Databricks)、Iceberg / S3 Tables、AWS Athena、Ray;
- 其他所有情况(“你很可能属于这一类”)→ 开一台带 NVMe 固态硬盘的大 EC2 机器,用 s5cmd 把 Parquet 文件拉到本地盘,用 Polars 处理,从笔记本连到这台机器上的 Jupyter。
代码 02 演示 5 示范了怎样粗算一笔账。在我的机器上,一个纯 Python 写的简单过滤器(长度、模板词比例、内容指纹)每个核心每秒能处理约 90 MB 文本(你的机器会不同)。一个月的 Common Crawl 约 100 TB 压缩数据,假设解压后约 300 TB:
| 处理内容(相对代价是假设的) | 核时 | 64 核机器 | 1000 核集群 |
|---|---|---|---|
| 只跑这个过滤器(× 1) | 约 900 | 约 14 小时 | 约 1 小时 |
| 加上 HTML 解析和正文抽取(假设 × 10) | 约 9,000 | 约 140 小时 | 约 9 小时 |
| 再加上语言识别和模型打分(假设 × 50) | 约 45,000 | 约 700 小时 | 约 45 小时 |
推算方法比具体数字重要:吞吐量(每核每秒多少 MB)× 数据量 → 核时 → 需要多少机器、多少时间。全量处理 Common Crawl 必须用集群;但做实验、调过滤器时,先在一个小样本上(几 GB 到几百 GB)用一台机器做,迭代速度快得多——这也是 DataComp、DCLM 在小规模上做实验的原因(第 5、6 讲)。
6. 去重 · Deduplication
6.1 为什么、在哪一层去重(补充)
网页数据里到处是重复:镜像站、转载、模板页面、同一篇新闻在几十个网站上出现。重复的危害(第 6 讲详细讨论):浪费训练算力;让模型记住并复述重复出现的文本(隐私、版权,第 18–19 讲);让测试题混进训练集(第 14 讲)。
去重可以在不同的粒度上做:URL(同一个地址抓了多次)、完全相同的文档、近似相同的文档、段落、子串(重复出现的长片段)。难点在规模:几十亿个文档不可能两两比较。
6.2 完全重复:指纹与 Bloom filter
内容指纹:把正文规范化(例如转小写、压缩空白)后算一个哈希(例如 SHA-1);指纹相同就是重复。分组(group by 指纹)就能去重——这正是代码 02 里 map-reduce 做的事。
指纹集合太大、放不进内存怎么办?Bloom filter(Bloom 1970)用一个 位的位数组和 个哈希函数回答”这个指纹见过吗”:
- 插入 :把 这 个位置都设成 1;
- 查询 :如果 个位置都是 1,回答”可能见过”;只要有一个是 0,回答”一定没见过”。
它不会漏报(见过的一定能认出来),但会误报(别的元素恰好把 的 个位置都设成了 1)。插入 个元素后,误报率约为
给定每个元素占多少位(),取 时误报率最低。
代码 03 演示 1(30,000 个文档,27,629 个不同的指纹):
| 每个元素几位 | 内存 | 实测误报率 | 理论误报率 | |
|---|---|---|---|---|
| 4 | 3 | 13.8 KB | 0.1471 | 0.1469 |
| 8 | 6 | 27.6 KB | 0.0218 | 0.0216 |
| 16 | 11 | 55.3 KB | 0.0006 | 0.0005 |
而 Python 集合存这些指纹约占 4.3 MB——Bloom filter 小了两个数量级以上,误报率和理论完全一致;每个元素多给一倍的位,误报率大约降一个数量级。误报的代价是”一个本来不重复的文档被当成重复丢掉”(用 8 位 / 元素做流式去重时,93 个其实第一次出现的文档被丢掉了)——在去重里通常可以接受。
6.3 近似重复:MinHash 与 LSH
改了几个词、加了一行页脚的转载,指纹就完全不同了。代码 03 演示 3:完全去重只能找到真实重复对的 49%。
近似程度怎么定义? 把一个文档看作它的词片段(shingle,连续 个词)的集合,两个文档的相似度 = 两个集合的 Jaccard 相似度 。改几个词只影响附近的几个片段。
MinHash(Broder 1997):用 个随机哈希函数,对每个函数取片段集合上的最小哈希值,得到一个长度为 的签名。关键性质:对一个随机的哈希函数,两个集合的最小值相同的概率恰好等于它们的 Jaccard 相似度(最小值落在交集里的概率)。所以两个签名”相同位置相等”的比例,就是 Jaccard 的无偏估计。签名的大小和文档长度无关。代码 03 里,128 个哈希函数估计 Jaccard 的平均绝对误差是 0.016。
LSH 分桶(locality-sensitive hashing):签名还是不能两两比较。把签名切成 段、每段 个数;任何一段完全相同的两个文档成为”候选对”,只比较候选对。两个 Jaccard 为 的文档成为候选对的概率是
一条 S 形曲线,拐点大约在 。代码 03 演示 2(16 段 × 8)实测和理论吻合:
| 真实 Jaccard | 实测成为候选的比例 | 理论 |
|---|---|---|
| [0.5, 0.6) | 0.139 | 0.126 |
| [0.6, 0.7) | 0.405 | 0.404 |
| [0.7, 0.8) | 0.779 | 0.815 |
| [0.8, 1.0) | 1.000 | 1.000 |
和 是一个可以事先算出来的设计选择:每段个数多,曲线右移、更严格;段数多,曲线左移、更宽松。
代码 03 演示 3(30,000 个文档,真实的重复对 5181 对):
| 方法 | 召回 | 精度 | 比较的文档对 |
|---|---|---|---|
| 完全去重(指纹相同) | 0.494 | 1.000 | 0(只算指纹) |
| LSH 16 × 8,相似度 ≥ 0.6 | 0.654 | 1.000 | 3,476 |
| LSH 32 × 4,相似度 ≥ 0.5 | 0.881 | 1.000 | 4,918 |
两两比较需要约 4.5 亿对;LSH 只比较了几千对。16 × 8 的召回不高,因为这里的近似复制对 Jaccard 中位数只有 0.6 左右(每改一个词,包含它的 5 个片段都变了),正好在拐点(约 0.7)下方;换成更宽松的 32 × 4,召回升到 0.881,候选对多了约 40%。(这里精度一直是 1.000,因为合成网页之间几乎没有共同的 5 词片段;真实网页里,同一个网站的导航、免责声明会让精度下降,常常要先去掉模板再去重。)
6.4 子串去重(补充)
Lee et al.(2022,Deduplicating Training Data Makes Language Models Better)用后缀数组在整个语料里找出重复的长子串(例如出现在成千上万个网页上的同一段 50 个 token 以上的文字),把它们删掉。这和文档级的 MinHash 是互补的:一篇大体原创的网页里也可能夹着一段到处都是的模板文字。第 6 讲会讨论这些方法对模型的影响。
7. 可复现的数据管线(补充)
这一讲的几个演示指向同一个工程原则:数据管线应该是可复现的——同样的输入、同样的代码,得到逐位相同的输出。否则你没法用”新旧结果比较”来检查修改,也没法在出问题时回答”这份训练数据到底是怎么来的”。几个要点:
- 确定性:固定随机种子;分区用稳定的哈希(不用 Python 的
hash());注意浮点加法的顺序(代码 02 演示 1)。 - 幂等:每个任务写自己的输出,成功后原子地提交(代码 02 演示 3)。
- 版本:数据文件不可修改,“修改”是写新文件、生成新快照(Iceberg 的快照、“时间旅行”)。训练时记录用的是哪个快照。
- 血缘(lineage):每个特征是哪个版本的哪个分类器算的、在哪个快照上算的。规范化的特征表让这件事更容易(§4.2)。
- 先小后大:先在一个小样本上把管线调通、做消融,再放大(第 5 讲 DataComp 的”小规模实验能指导大规模”)。
8. 延伸与前沿 · Beyond the original lecture
本节是我补充的。
- 面向语言模型数据的开源工具:Hugging Face 的 datatrove(用来构建 FineWeb)、AI2 的 Dolma 工具包、NVIDIA 的 NeMo Curator、阿里的 Data-Juicer 等,把抽取、过滤、去重、打分这些步骤封装成可以在单机或集群上运行的管线。DataComp 也公开了它的整套工具(第 5 讲)。
- GPU 上的数据处理:近似去重、模型打分(质量分类器、嵌入)越来越多地在 GPU 上做;当过滤器本身是一个神经网络时,“数据处理”和”模型推理”的界限变得模糊。
- 新的存储格式:§3.5 提到的面向机器学习的列式格式,以及把图片、向量和元数据放在同一张表里的”多模态湖仓”。
9. 小结 · Summary
- 读数据常常比算数据还贵:好工具的第一目标是少读、顺序读、并行读。
- 数据是表格:有类型、很多列、上万亿行。数据工作的大部分时间是在给这张表加特征、按特征过滤和分组。
- 列式存储(Parquet):只读需要的列;同一列一起压缩;行组的最小值 / 最大值让查询跳过整组(数据要先排序或分区才有用);强制类型让错误早暴露。局限:不可修改、元数据老旧,仍在演进。
- 表格式(Iceberg):在很多 Parquet 文件之上加目录、元数据、快照,让提交原子化;规范化的表设计让”加一个新特征”只写一张小表。
- 一台机器 vs 集群:小于约 1 TB,一台带 NVMe 的大机器 + Polars 往往最省事;更大或更急时用 Spark。
- map → shuffle → reduce:倾斜用 map 端合并或加盐缓解;容错依赖确定性、幂等的任务;惰性执行让优化器把过滤前移(UDF 是黑盒);浮点加法顺序会影响逐位可复现性。
- 去重:完全重复用指纹(大规模时用 Bloom filter,误报率 );近似重复用 MinHash 估计 Jaccard、用 LSH 的 S 形曲线 只比较候选对。
10. 常见误区 · Common pitfalls
- “处理大数据就要上集群。” slides:大多数情况下一台大机器 + Polars 就够了,而且迭代快得多。
- “列式存储主要是为了省空间。” 主要是为了少读(列裁剪)和跳过(谓词下推);压缩是附带的好处(代码 01 里只比压缩的 JSONL 小一点)。
- “有了 min / max 统计就能自动跳过数据。” 数据没有按查询条件排序或分区时,几乎跳不过(代码 01 演示 3:0 / 15)。
- “重试就能保证正确。” 只有任务是幂等的才行;否则重试会产生重复(代码 02 演示 3)。
- “同样的代码跑两次,结果一定逐位相同。” 并行求和的顺序、Python 的
hash()、未设种子的随机数都会破坏这一点。 - “Bloom filter 会漏掉重复。” 不会;它只会误报(把新文档当成重复)。
- “MinHash 能找出所有近似重复。” 召回取决于 LSH 的段数和每段个数(S 形曲线的拐点),以及片段长度;要根据你想要的相似度阈值设计。
- “完全去重就够了。” 合成数据里完全去重只找到一半的重复对;真实网页里近似重复和重复的模板段落更多。
11. 练习 · Exercises
练习 1(Bloom filter 的大小) 你要对 100 亿个文档指纹做流式去重,希望误报率不超过 1%。(a) 每个元素至少要多少位?需要多少内存?最优的 是多少?(b) 如果用一个 Python 集合存 20 字节的指纹(加上开销按每个 100 字节算),需要多少内存?
参考答案
(a) 最优 时误报率约为 。令它等于 0.01:, 位。,取 7。内存: 字节 ≈ 12 GB——一台大内存机器就放得下。(代码 03 里 8 位 / 元素、k = 6 实测 2.2%,和这个推算一致。)
(b) 字节 = 1 TB——放不进一台机器的内存,必须分布式。
练习 2(设计 LSH) 你用 128 个哈希函数的 MinHash,希望 Jaccard ≥ 0.8 的文档对至少有 95% 成为候选对,而 Jaccard ≤ 0.5 的文档对成为候选的概率不超过 5%。在 里选一个,说明理由。
参考答案
用 :
- :;——太宽松,0.5 的文档对大多成了候选。
- :;——非常接近要求(0.947 略低于 0.95,0.061 略高于 0.05)。
- :——太严格。
选 (16, 8),它的拐点 正好在 0.5 和 0.8 之间。如果一定要同时满足两个 5%,可以用更多的哈希函数(例如 20 段 × 8 = 160 个:,——又放宽了下端;可以继续调 )。这是一个”在两种错误之间取舍”的问题,和分类器的阈值一样。
练习 3(谓词下推的收益) 一张 1 亿行的网页表按”抓取月份”排序后写成 Parquet,每个行组 100 万行。(a) 查询”3 月份的网页”大约要读多少个行组?(b) 查询”质量分 > 0.9”呢(质量分和月份无关)?(c) 怎样让两种查询都能跳过大部分数据?
参考答案
(a) 共 100 个行组;假设 12 个月的网页数差不多,按月份排序后每个月约 833 万行,落在约 9 个连续的行组里,其余 91 个都能跳过。
(b) 质量分和月份无关,每个行组里都有高质量的网页,max(quality) 都大于 0.9——一个都跳不过(代码 01 演示 3 的”原始顺序”)。
(c) 几种办法:(1) 按两列分区(例如先按月份分目录,每个目录里按质量分排序);(2) 用 Z-order / Hilbert 曲线这类”多维排序”,让两个维度都有一定的局部性;(3) 如果某个过滤是固定的(例如”质量分 > 0.9”是训练集的定义),干脆把它物化成一张单独的表。真实的表格式(Iceberg)还支持分区、排序和更丰富的统计信息来帮助跳过。
练习 4(倾斜的代价) 一个阶段有 1000 个分区、1000 台机器。如果数据均匀分布,每个分区要处理 1 分钟。现在有一个键占了全部数据的 5%。(a) 不处理倾斜时这个阶段要多久?(b) 把这个热门键加盐成 50 个子键之后呢?
参考答案
(a) 那个分区要处理全部数据的 5% + 其余数据平均分到它的那一份(约 0.095%),大约是平均分区的 51 倍,约 51 分钟;整个阶段要等它,约 51 分钟(其他 999 台机器大部分时间在空等)。
(b) 热门键拆成 50 份,每份约 0.1%,和普通分区差不多;最大的分区大约是平均的 2 倍,阶段约 2 分钟(再加上之后一个很小的汇总阶段)。这就是代码 02 演示 2 里 2.58 → 1.21 的大规模版本。
练习 5(可复现性) 你重跑了一遍去年的数据管线,得到的训练集和去年的相差 0.3% 的文档。列出至少四个可能的原因,以及怎样避免。
参考答案
例如:(1) 输入变了:网页被删除、链接失效(第 3 讲 LAION 的”链接腐烂”)、或者输入表被覆盖了——用不可修改的快照,记录输入的版本。(2) 某个过滤器(分类器)的版本变了——在特征表里记录模型版本(血缘)。(3) 随机性:抽样没有固定种子,或者用了 Python 的 hash() 做分区 / 抽样——用稳定哈希、固定种子。(4) 浮点加法顺序:分片数、并行度变了,阈值附近的文档因为分数在最后一位不同而进出过滤——对边界敏感的计算用定点数或确定性的归约顺序。(5) 不幂等的写入在失败重试时产生了重复(代码 02 演示 3)。(6) 依赖库的版本变了(例如分词器、HTML 解析器的行为)——固定依赖版本。
12. 延伸阅读 · Further reading
原课材料
- Tony Wang 的 slides(课程网站)。重点是最后一张决策图。
- Polars 文档;Apache Spark 文档;Apache Iceberg 文档;Apache Parquet 格式说明。
本讲补充的经典工作
- Dean & Ghemawat, MapReduce: Simplified Data Processing on Large Clusters(OSDI 2004)。
- Zaharia et al., Resilient Distributed Datasets: A Fault-Tolerant Abstraction for In-Memory Cluster Computing(NSDI 2012)——Spark 的核心思想。
- Bloom, Space/Time Trade-offs in Hash Coding with Allowable Errors(CACM 1970)。
- Broder, On the Resemblance and Containment of Documents(1997)——MinHash。
- Leskovec, Rajaraman, Ullman, Mining of Massive Datasets 第 3 章(免费在线)——Jaccard、MinHash、LSH 讲得最清楚的教材。
- Lee et al., Deduplicating Training Data Makes Language Models Better(ACL 2022)。
和本目录其他课程的联系
- CS336 第 14 讲(数据处理):语言模型数据管线的实现细节,包括去重。
- CS324 第 2 讲(数据):代码里也实现了 MinHash / LSH 去重,并观察了重复数据对小模型的影响。
13. 下一讲预告 · Next lecture
有了处理大规模数据的工具,下一个问题是:怎样系统地比较、设计训练数据集? 第 3 讲留下了一个问题——LAION 训练出的大 CLIP 不如 OpenAI 的,差距随规模增大,怎样改进?
第 5 讲:当代图文数据集。 DataComp 把基准翻了过来:训练代码和算力固定,只比数据。我们会看到它的设计(候选池、四个规模、38 个评测任务)和发现(CLIP 分数和图片内容过滤的组合、更宽松的阈值、小规模实验能指导大规模),以及 MetaCLIP 的元数据平衡、T-MARS 对”图里有字”的处理、交错图文数据(OBELICS)和多模态大模型的数据配比(MM1)。代码会搭一个迷你 DataComp,亲手比较各种过滤方法。