Lecture 4 · 大规模数据处理的工具 · Software Tools for Large-Scale Data

Training Data for AI · Lecture 04。个人学习讲义与配套实验,按主题整理并持续修订。

课程源码 ZIP ↓

个人整理的学习笔记,非官方讲义;课程来源与整理说明见课程介绍。配套示例未逐一运行验证。

本篇目录

对应 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

  1. 说明为什么”读数据常常比算数据还贵”,以及这怎样决定了存储格式的设计。
  2. 解释列式存储(Parquet)的核心机制:按列存放、类型、编码与压缩、行组与统计信息、谓词下推;知道它的局限。
  3. 说明 Iceberg 这类”表格式”在 Parquet 之上加了什么,以及规范化 vs 反规范化的表设计各自的取舍。
  4. 用 map → shuffle → reduce 描述一个分布式数据处理任务;解释 Spark 的阶段、shuffle、惰性执行和容错,以及数据倾斜、幂等性、浮点可复现性这些实际问题。
  5. 根据数据量和需求,判断”一台大机器 + Polars”还是”Spark 集群”;会粗算一次处理的成本。
  6. 解释完全去重(指纹、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 万行的网页表。

格式文件大小
JSONL40.88 MB
JSONL + zlib(整个文件压缩)10.39 MB
迷你 Parquet(按列 + 压缩)9.58 MB
查询JSONL 要读迷你 Parquet 要读
A:所有网页 quality 的平均值40.88 MB0.18 MB
B:quality > 0.9 且英文的网页的 URL40.88 MB0.48 MB

JSONL 要读完整个文件、解析每一个字段;列式文件只读需要的列。在这张表上,正文占了 JSONL 约 77% 的字节,而两个查询都用不到它。

3.2 Parquet 的内部(补充)

slides 列出了 Parquet 的优点:高度压缩的列式存储格式;列式带来更好的访问和压缩;强制类型;所有数据处理工具都支持它。它的内部结构(这部分是补充的背景,minicol.py 用约 200 行实现了一个简化版):

  1. 行组(row group):每若干行(真实的 Parquet 通常是几十 MB 到 1 GB 的数据)切成一组。
  2. 列块(column chunk):每个行组里,每一列存成一块。读一列 = 读每个行组里这一列的那一块。
  3. 编码:低基数的列用字典编码(“en” 只存一次,每行只存一个小整数下标),重复的值用游程编码,整数用差分 / 位压缩……
  4. 压缩:每个列块再用通用压缩算法(Snappy、Zstd、Gzip 等)压缩。
  5. 统计信息:每个列块记录最小值、最大值(还有空值个数等)。
  6. 页脚(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 / 150.47
按 quality 排序后再写13 / 150.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 位小数上不同(最大差别 10−610^{-6})。浮点数加法不满足结合律,(a+b)+c(a+b)+c 和 a+(b+c)a+(b+c) 在最后几位可能不同;map-reduce 加法的顺序和单进程不一样。换成定点整数累加(质量分 × 10000)之后,结果逐位一致。分布式管线要做到”同样的输入 → 逐位相同的输出”,就要注意这种细节——否则你没法用”新旧结果是否完全相同”来检查一次代码修改有没有出错。
  • 分区要用稳定的哈希。Python 内置的 hash() 对字符串每个进程用不同的随机种子(防止哈希碰撞攻击);用它来分区,两个工作进程会把同一个键分到不同的分区。

在这么小的数据上,多进程并不比单进程快(进程启动、序列化都有开销)——这本身也是 slides “1 TB 以下用一台机器”建议的一个缩影。

5.4 数据倾斜

网络数据处处是长尾:少数大网站、少数热门 URL、少数巨长的文档。代码 02 演示 2:按域名分区时,最大的域名有 9071 个网页(占 15.1%),而一半的域名少于 46 个网页。

做法shuffle 搬运的记录最大分区 / 平均分区
A:每行直接按域名 shuffle60,0002.58
B:map 端先合并(combiner)5,8681.15
C:热门域名”加盐”后再 shuffle60,0001.21

一个阶段要等所有分区都做完才算完——最大的分区就是”掉队者”(straggler),决定了整个阶段的耗时。两种对策:

  • map 端合并(combiner):求和、计数、取最小值这类可以分步计算的操作,先在每个分片里合并(每个分片每个域名只剩一条),再 shuffle。搬运的数据少了 10 倍,倾斜也随之消失。
  • 加盐(salting):不能在 map 端合并的操作,把热门的键拆成几个”键 + 随机后缀”,分散到不同的分区,之后再汇总一次把盐去掉。

5.5 容错与幂等性

代码 02 演示 3:16 个任务,每次以 30% 的概率失败(可能失败在做了一半的时候),失败就重试,一共执行了 28 次。

写法输出的记录数重复记录
不幂等:边处理边追加到共享输出79,03719,037
幂等:写到自己的位置,成功后整体提交60,0000

重试本身不是问题,问题在于失败前做了一半的事。不幂等的写法会悄悄产生重复——而重复数据进入训练集是有害的(第 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)用一个 mm 位的位数组和 kk 个哈希函数回答”这个指纹见过吗”:

  • 插入 xx:把 h1(x),…,hk(x)h_1(x), \dots, h_k(x) 这 kk 个位置都设成 1;
  • 查询 xx:如果 kk 个位置都是 1,回答”可能见过”;只要有一个是 0,回答”一定没见过”。

它不会漏报(见过的一定能认出来),但会误报(别的元素恰好把 xx 的 kk 个位置都设成了 1)。插入 nn 个元素后,误报率约为

(1−e−kn/m)k,\left(1 - e^{-kn/m}\right)^k ,

给定每个元素占多少位(m/nm/n),取 k≈(m/n)ln⁡2k \approx (m/n)\ln 2 时误报率最低。

代码 03 演示 1(30,000 个文档,27,629 个不同的指纹):

每个元素几位kk内存实测误报率理论误报率
4313.8 KB0.14710.1469
8627.6 KB0.02180.0216
161155.3 KB0.00060.0005

而 Python 集合存这些指纹约占 4.3 MB——Bloom filter 小了两个数量级以上,误报率和理论完全一致;每个元素多给一倍的位,误报率大约降一个数量级。误报的代价是”一个本来不重复的文档被当成重复丢掉”(用 8 位 / 元素做流式去重时,93 个其实第一次出现的文档被丢掉了)——在去重里通常可以接受。

6.3 近似重复:MinHash 与 LSH

改了几个词、加了一行页脚的转载,指纹就完全不同了。代码 03 演示 3:完全去重只能找到真实重复对的 49%。

近似程度怎么定义? 把一个文档看作它的词片段(shingle,连续 nn 个词)的集合,两个文档的相似度 = 两个集合的 Jaccard 相似度 ∣A∩B∣/∣A∪B∣|A \cap B| / |A \cup B|。改几个词只影响附近的几个片段。

MinHash(Broder 1997):用 KK 个随机哈希函数,对每个函数取片段集合上的最小哈希值,得到一个长度为 KK 的签名。关键性质:对一个随机的哈希函数,两个集合的最小值相同的概率恰好等于它们的 Jaccard 相似度(最小值落在交集里的概率)。所以两个签名”相同位置相等”的比例,就是 Jaccard 的无偏估计。签名的大小和文档长度无关。代码 03 里,128 个哈希函数估计 Jaccard 的平均绝对误差是 0.016。

LSH 分桶(locality-sensitive hashing):签名还是不能两两比较。把签名切成 bb 段、每段 rr 个数;任何一段完全相同的两个文档成为”候选对”,只比较候选对。两个 Jaccard 为 ss 的文档成为候选对的概率是

P(s)=1−(1−sr)b,P(s) = 1 - \left(1 - s^{r}\right)^{b},

一条 S 形曲线,拐点大约在 (1/b)1/r(1/b)^{1/r}。代码 03 演示 2(16 段 × 8)实测和理论吻合:

真实 Jaccard实测成为候选的比例理论
[0.5, 0.6)0.1390.126
[0.6, 0.7)0.4050.404
[0.7, 0.8)0.7790.815
[0.8, 1.0)1.0001.000

bb 和 rr 是一个可以事先算出来的设计选择:每段个数多,曲线右移、更严格;段数多,曲线左移、更宽松。

代码 03 演示 3(30,000 个文档,真实的重复对 5181 对):

方法召回精度比较的文档对
完全去重(指纹相同)0.4941.0000(只算指纹)
LSH 16 × 8,相似度 ≥ 0.60.6541.0003,476
LSH 32 × 4,相似度 ≥ 0.50.8811.0004,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

  1. 读数据常常比算数据还贵:好工具的第一目标是少读、顺序读、并行读。
  2. 数据是表格:有类型、很多列、上万亿行。数据工作的大部分时间是在给这张表加特征、按特征过滤和分组。
  3. 列式存储(Parquet):只读需要的列;同一列一起压缩;行组的最小值 / 最大值让查询跳过整组(数据要先排序或分区才有用);强制类型让错误早暴露。局限:不可修改、元数据老旧,仍在演进。
  4. 表格式(Iceberg):在很多 Parquet 文件之上加目录、元数据、快照,让提交原子化;规范化的表设计让”加一个新特征”只写一张小表。
  5. 一台机器 vs 集群:小于约 1 TB,一台带 NVMe 的大机器 + Polars 往往最省事;更大或更急时用 Spark。
  6. map → shuffle → reduce:倾斜用 map 端合并或加盐缓解;容错依赖确定性、幂等的任务;惰性执行让优化器把过滤前移(UDF 是黑盒);浮点加法顺序会影响逐位可复现性。
  7. 去重:完全重复用指纹(大规模时用 Bloom filter,误报率 (1−e−kn/m)k(1-e^{-kn/m})^k);近似重复用 MinHash 估计 Jaccard、用 LSH 的 S 形曲线 1−(1−sr)b1-(1-s^r)^b 只比较候选对。

10. 常见误区 · Common pitfalls

  1. “处理大数据就要上集群。” slides:大多数情况下一台大机器 + Polars 就够了,而且迭代快得多。
  2. “列式存储主要是为了省空间。” 主要是为了少读(列裁剪)和跳过(谓词下推);压缩是附带的好处(代码 01 里只比压缩的 JSONL 小一点)。
  3. “有了 min / max 统计就能自动跳过数据。” 数据没有按查询条件排序或分区时,几乎跳不过(代码 01 演示 3:0 / 15)。
  4. “重试就能保证正确。” 只有任务是幂等的才行;否则重试会产生重复(代码 02 演示 3)。
  5. “同样的代码跑两次,结果一定逐位相同。” 并行求和的顺序、Python 的 hash()、未设种子的随机数都会破坏这一点。
  6. “Bloom filter 会漏掉重复。” 不会;它只会误报(把新文档当成重复)。
  7. “MinHash 能找出所有近似重复。” 召回取决于 LSH 的段数和每段个数(S 形曲线的拐点),以及片段长度;要根据你想要的相似度阈值设计。
  8. “完全去重就够了。” 合成数据里完全去重只找到一半的重复对;真实网页里近似重复和重复的模板段落更多。

11. 练习 · Exercises

练习 1(Bloom filter 的大小) 你要对 100 亿个文档指纹做流式去重,希望误报率不超过 1%。(a) 每个元素至少要多少位?需要多少内存?最优的 kk 是多少?(b) 如果用一个 Python 集合存 20 字节的指纹(加上开销按每个 100 字节算),需要多少内存?

参考答案

(a) 最优 kk 时误报率约为 (1/2)k=2−(m/n)ln⁡2(1/2)^{k} = 2^{-(m/n)\ln 2}。令它等于 0.01:(m/n)ln⁡2⋅ln⁡2=ln⁡100(m/n) \ln 2 \cdot \ln 2 = \ln 100,m/n=ln⁡100/(ln⁡2)2≈4.605/0.4805≈9.6m/n = \ln 100 / (\ln 2)^2 \approx 4.605 / 0.4805 \approx 9.6 位。k≈9.6×0.693≈6.6k \approx 9.6 \times 0.693 \approx 6.6,取 7。内存:1010×9.6/8≈1.2×101010^{10} \times 9.6 / 8 \approx 1.2 \times 10^{10} 字节 ≈ 12 GB——一台大内存机器就放得下。(代码 03 里 8 位 / 元素、k = 6 实测 2.2%,和这个推算一致。)

(b) 1010×10010^{10} \times 100 字节 = 1 TB——放不进一台机器的内存,必须分布式。

练习 2(设计 LSH) 你用 128 个哈希函数的 MinHash,希望 Jaccard ≥ 0.8 的文档对至少有 95% 成为候选对,而 Jaccard ≤ 0.5 的文档对成为候选的概率不超过 5%。在 (b,r)∈{(32,4),(16,8),(8,16)}(b, r) \in \{(32,4), (16,8), (8,16)\} 里选一个,说明理由。

参考答案

用 P(s)=1−(1−sr)bP(s) = 1 - (1 - s^r)^b:

  • (32,4)(32, 4):P(0.8)=1−(1−0.4096)32≈1.000P(0.8) = 1 - (1 - 0.4096)^{32} \approx 1.000;P(0.5)=1−(1−0.0625)32≈0.873P(0.5) = 1 - (1 - 0.0625)^{32} \approx 0.873——太宽松,0.5 的文档对大多成了候选。
  • (16,8)(16, 8):P(0.8)=1−(1−0.1678)16≈0.947P(0.8) = 1 - (1 - 0.1678)^{16} \approx 0.947;P(0.5)=1−(1−0.0039)16≈0.061P(0.5) = 1 - (1 - 0.0039)^{16} \approx 0.061——非常接近要求(0.947 略低于 0.95,0.061 略高于 0.05)。
  • (8,16)(8, 16):P(0.8)=1−(1−0.0281)8≈0.204P(0.8) = 1 - (1 - 0.0281)^{8} \approx 0.204——太严格。

选 (16, 8),它的拐点 (1/16)1/8≈0.707(1/16)^{1/8} \approx 0.707 正好在 0.5 和 0.8 之间。如果一定要同时满足两个 5%,可以用更多的哈希函数(例如 20 段 × 8 = 160 个:P(0.8)≈0.974P(0.8) \approx 0.974,P(0.5)≈0.075P(0.5) \approx 0.075——又放宽了下端;可以继续调 rr)。这是一个”在两种错误之间取舍”的问题,和分类器的阈值一样。

练习 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

原课材料

本讲补充的经典工作

  • 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)。

和本目录其他课程的联系


13. 下一讲预告 · Next lecture

有了处理大规模数据的工具,下一个问题是:怎样系统地比较、设计训练数据集? 第 3 讲留下了一个问题——LAION 训练出的大 CLIP 不如 OpenAI 的,差距随规模增大,怎样改进?

第 5 讲:当代图文数据集。 DataComp 把基准翻了过来:训练代码和算力固定,只比数据。我们会看到它的设计(候选池、四个规模、38 个评测任务)和发现(CLIP 分数和图片内容过滤的组合、更宽松的阈值、小规模实验能指导大规模),以及 MetaCLIP 的元数据平衡、T-MARS 对”图里有字”的处理、交错图文数据(OBELICS)和多模态大模型的数据配比(MM1)。代码会搭一个迷你 DataComp,亲手比较各种过滤方法。

← 返回课程目录