揭秘 DeepSeekMath 的数据管道:基于 FastText 的复现与分析
1. 为什么数据收集很重要(且经常被忽视)
阅读大多数大型语言模型(LLM)论文时,重点通常放在模型架构、参数数量或微调技巧上。然而,DeepSeekMath 表明,在数学等领域(也适用于大多数其他领域)中,预训练数据的收集地点和方式可能决定模型的成败。事实上,DeepSeekMath-Base 7B 在其精心策划的数学语料库上训练后,仅通过关注高质量数据而非单纯扩展参数,便在竞赛级基准测试中超越了更大规模的模型。
直观地说,开放网络上的数学内容相对于“通用”网页来说是稀有的。此外,数学页面通常嵌入公式、LaTeX 片段或领域特定术语,这些是简单的基于关键词的过滤(“页面是否包含‘积分’这个词?”等)会遗漏或误分类的。因此,构建一个干净、丰富、以数学为重点的网络语料库需要:
- 真实数学页面的初始种子,以便分类器能够学习数学内容的特征。
- 迭代扩展:找到未包含在种子中的新数学相关页面,然后重新训练分类器以提高召回率。
- 领域级别推理:识别整个网站或子路径(例如,
mathoverflow.net/questions
)是数学密集型的。 - 去重和污染过滤:避免重复抓取相同内容或意外包含测试集问题(例如,GSM8K 问题)。
- 令牌预算管理:由于“数学”页面可能很冗长(大量符号、证明、代码片段),需要具体决定从每个页面获取多少令牌以达到总计 100 亿以上令牌的目标。
这种多阶段、多迭代的方法是 DeepSeekMath 获得优势的关键:通过第四轮数据收集,他们从 3550 万个网页中积累了 120 亿数学相关令牌——所有这些都经过严格过滤,以确保是真正的数学内容,并清除了基准测试问题。
2. DeepSeekMath 的数据收集管道(论文 2.1 节)
让我们来详细解读论文中主要的“数据收集和去污染”步骤,重点关注您希望在自己的代码中镜像或改编的亮点:
初始种子(OpenWebMath)
DeepSeekMath 从 OpenWebMath(一个精心策划的高质量数学网页文本集合,约 13.6 亿令牌)开始。
- 他们从 OpenWebMath 中随机抽取 50 万个数学页面作为正例。
- 对于负例,他们从(未过滤的)Common Crawl 数据转储中抽取 50 万个随机页面。
这些组合的 100 万个示例(50 万个数学 / 50 万个非数学)用于训练一个 FastText 分类器,其超参数为:dim=256, lr=0.1, wordNgrams=3, minCount=3, epoch=3
.
数学页面的迭代召回(4 轮)
在用种子数据训练分类器后,他们将其运行在经过 URL 去重的 Common Crawl 语料库(400 亿 HTML 页面)上。
任何得分高于特定阈值的页面都被暂时标记为“数学”。
- 他们根据分类器得分进行排序,并仅保留顶部部分(例如,在第一次遍历中,得分最高的页面总计达到 40 亿令牌)。
- 基于领域的扩展:他们按领域(例如,
mathoverflow.net
的所有页面)对 Common Crawl 页面进行分组,并检查在前一次迭代中已“收集”的比例。- 如果整个领域有超过 10% 的页面被标记为数学,则该领域被标记为数学相关。
- 然后,他们手动标注了确实包含数学内容的特定 URL 路径(例如,
/questions
)。 - 在这些路径下但之前未被选中的任何 Common Crawl URL 都会被添加到种子中。
这为重新训练分类器提供了更丰富的正例集。
他们重复此过程四次。到第四轮结束时,近 98% 的新数学页面都已被发现,因此他们停止了。
结果是 3550 万个数学页面,总计 120 亿令牌。
去重(URL 级别和 MD5 草图)
- 在分类之前,他们进行基于 URL 的去重,以折叠琐碎的重定向或镜像页面。
- 在获取 HTML 后,他们对每个页面文本的前 3,000 个字符进行 MD5 近似重复检查。
- 如果两个页面共享相同的 3,000 字符 MD5 哈希,则其中一个被丢弃。
HTML → 纯文本
一旦页面被抓取(WARC 段)
- 他们会删除所有标签(正则表达式
<[^>]+>
→ 空格)并折叠空白字符。 - 这会生成一个粗略的“纯文本”版本,供分类器使用。
基准污染过滤
- 他们会移除任何包含 GSM8K、MATH、CMATH 等数据集中出现的 10-gram 子字符串的页面。
- 对于较短的 n-gram(≥ 3),他们会进行精确匹配。
这确保了他们的预训练数据不会泄露测试问题。
令牌预算选择
- 每个候选页面都会生成一个估计的令牌计数(通过他们的分词器)。
- 他们根据分类器置信度对页面进行排序,并持续添加页面,直到达到 120 亿令牌(超出预算时停止)。
这种“按置信度贪婪选择”的方法确保在包含置信度较低的页面之前,先用最高质量的页面填满预算。
这如何创建“高质量数学语料库”
- 种子 → 分类器 → 召回 迭代地完善模型对“数学”的理解。
- 领域标记(例如,
mathoverflow.net
)捕获纯粹的页面分类器可能遗漏的整个站点。 - 去重 确保您不会在近乎相同的内容上浪费令牌。
- N-gram 过滤 移除测试集污染。
- 置信度驱动的令牌预算 优先选择真正的数学密集型页面。
所有这些共同构成了高质量的数学语料库:
- 大:120 亿令牌。
- 多语言:尽管英语占主导地位,但也保留了中文数学页面(例如,高考问题)。
- 干净:没有 GSM8K/MATH 问题泄露。
3. 您的 Python 代码:基于 FastText 的单次数学爬取器
以下是实现简化版 DeepSeekMath 数据收集管道的 Python 代码,该管道使用 FastText 进行分类。此代码将帮助您在数学数据集上训练 FastText 分类器,抓取网页,并根据分类器的预测进行过滤。
3.1. 在数学数据集上训练 FastText 分类器
# 1- Install required packages
%pip install datasets -q
%pip install cdx-toolkit -q
%pip install warcio fasttext tqdm tiktoken -q
import fasttext
import json
import itertools
import pandas as pd
import requests
import io
import re
import hashlib
import tqdm
from datasets import load_dataset
from warcio.archiveiterator import ArchiveIterator
import cdx_toolkit
import tiktoken
# 2- Load the dataset
# https://huggingface.co/datasets/kenhktsui/math-classifiers-data
ds = load_dataset("kenhktsui/math-classifiers-data") # you should probably spend some time to understand the dataset structure
# 3- Write out training & validation files in fastText format
with open("math.train", "w") as f:
for example in ds['train']:
label = example['label']
label_str = f"__label__{label}"
f.write(f"{label_str} {example['text']}\n")
with open("math.valid", "w") as f:
for example in ds['test']:
label = example['label']
label_str = f"__label__{label}"
f.write(f"{label_str} {example['text']}\n")
# 4- Train with hyperparameters matching DeepSeekMath (dim=256, lr=0.1, wordNgrams=3, minCount=3, epoch=3)
model = fasttext.train_supervised(
input="math.train",
lr=0.1,
dim=256,
wordNgrams=3,
epoch=3,
minCount=3,
verbose=2
)
# 5- Save the model to disk
model.save_model("model/math-classifier.bin")
# 6- Evaluate quickly on train/valid
math = fasttext.load_model("model/math-classifier.bin")
print("Train metrics:", math.test("math.train"))
print("Valid metrics:", math.test("math.valid"))
print("Label for 'What is the integral of x^2 ?':", math.predict("What is the integral of x^2 ?"))
print("Label for 'in the politics of the United States, what is the role of the president?':", math.predict("in the politics of the United States, what is the role of the president?"))
# --- 3.2: Build a small CDX index (1 000 HTML pages) in pure Python ---
import cdx_toolkit
import pandas as pd
cdx = cdx_toolkit.CDXFetcher(source="cc")
query = "commoncrawl.org/*"
print("Size estimate for query:", cdx.get_size_estimate(query))
rows = []
for obj in cdx.iter(query, limit=1000, filter=["status:200", "mime:text/html"]):
rows.append((obj["url"], obj["filename"], obj["offset"], obj["length"]))
df = pd.DataFrame(rows, columns=["url", "warc", "offset", "length"])
df.to_csv("cc-index.csv", sep=",", index=False, header=False)
# --- 3.3: Fetch each WARC segment, dedupe, strip HTML, classify with fastText (pure Python) ---
# 1) Load the tiny 1,000-row index
columns = ["url", "warc", "offset", "length"]
rows = pd.read_csv("cc-index.csv", sep=",", names=columns, dtype=str)
seen_url = set()
seen_sim = set()
scored = []
for _, row in tqdm.tqdm(rows.iterrows(), total=len(rows)):
url = row["url"]
warc_filename = row["warc"]
offset = int(row["offset"])
length = int(row["length"])
warc_url = f"https://data.commoncrawl.org/{warc_filename}"
byte_range = f"bytes={offset}-{offset + length - 1}"
try:
resp = requests.get(warc_url, headers={"Range": byte_range}, timeout=15)
resp.raise_for_status()
except Exception:
continue
for rec in ArchiveIterator(io.BytesIO(resp.content)):
if rec.rec_type != "response":
continue
try:
html = rec.content_stream().read().decode("utf-8", "ignore")
except Exception:
continue
# 1) URL-level dedupe
if url in seen_url:
continue
seen_url.add(url)
# 2) Near-dup via MD5 on first 3,000 chars
md5_prefix = hashlib.md5(html[:3000].encode("utf-8", "ignore")).hexdigest()
if md5_prefix in seen_sim:
continue
seen_sim.add(md5_prefix)
# 3) Strip tags → plain-ish text
text = re.sub(r"<[^>]+>", " ", html)
text = re.sub(r"\s+", " ", text).strip()
# 4) Classify with fastText
label, prob = math.predict(text)
scored.append({
"url": url,
"text": text,
"label": label[0],
"score": float(prob[0])
})
print(f"Fetched & scored {len(scored)} pages")
# --- 3.4: Count tokens and select pages until 1 M token budget is reached ---
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
TOKENS_BUDGET = 1_000_000
for doc in scored:
doc["ntok"] = len(enc.encode(doc["text"]))
scored.sort(key=lambda d: d["score"], reverse=True)
kept = []
total_tokens = 0
for doc in scored:
if total_tokens + doc["ntok"] > TOKENS_BUDGET:
break
kept.append(doc)
total_tokens += doc["ntok"]
print(f"Kept {len(kept)} docs → {total_tokens:,} tokens (~{total_tokens/1e6:.2f} M)")
# --- 3.5: Write selected pages to JSONL (pure Python) ---
import json
with open("pass1-math.jsonl", "w", encoding="utf-8") as out:
for doc in kept:
out.write(json.dumps({
"url": doc["url"],
"text": doc["text"],
"label": doc["label"],
"score": doc["score"]
}, ensure_ascii=False) + "\n")
print("Wrote pass1-math.jsonl 👍")
4. “全面”版本的建议
如果您想将令牌数量从 1 百万扩展到 120 亿(并真正与 DeepSeekMath 匹敌),以下是我会添加的具体步骤:
预取 URL 去重
- 维护一个
seen_urls
集合,并在请求 WARC 之前跳过任何已存在的 URL。
HTML 解析而非正则表达式
- 使用
BeautifulSoup
删除<script>
、<style>
、<nav>
、<footer>
标签,然后提取<article>
或按文本长度计算的最大<div>
。
基准 N-gram 过滤
- 构建一个包含 GSM8K/MATH/CMATH 中所有 10-gram 的 Aho-Corasick 自动机。丢弃与其中任何一个匹配的页面。
领域特征与手动标注
- 追踪
(domain_name → (#pages_seen, #pages_labelled_math))
。 - 每次遍历后,标记超过 10% 页面被分类为数学的领域。
- 手动标注这些领域的 URL 模式,并将其添加到下一轮的种子中。
迭代分类器再训练
- 每次遍历后,将您“保留”的页面分为高置信度正例、低置信度候选和负例。
- 标注一部分随机选取的低置信度页面,在扩展后的种子上重新训练
fastText
,然后重新运行。 - 重复此过程,直到收益减小。
令牌预算收集(两层)
- 首先估算
approx_tokens = len(text.split())
,以便在接近预算时跳过大页面。 - 然后仅对临界候选计算精确的
len(tiktoken.encode(text))
。
分片与输出
- 当您写入
pass1-math.jsonl
时,按hash(url) % N
将其拆分为 128 或 256 个分片。 - 同时构建一个 CSV 文件
index_of_shards.csv
,将每个 URL 映射到其分片和字节偏移量。
5. 最终思考与结论
构建一个包含 120 亿令牌的高质量数学语料库并非易事。您需要:
- 从一个好的种子开始:使用 OpenWebMath 或类似精心策划的数据集。
- 训练一个强大的分类器:如果速度是首要考虑因素,FastText 是一个不错的选择,它能确保正负样本的多样性。
- 迭代:召回、扩展领域、重新训练,然后再次召回——在每次通过时清除受污染的页面。
- 严格去重:在 URL 级别和通过近重复哈希进行去重。
- 预算令牌:目标是 120 亿令牌,而不是 10 万亿。优先选择最可靠的页面。
- 对最终输出进行分片:确保您的预训练管道能够有效扩展。
在我的演示脚本中,我实现了一个迷你 DeepSeekMath 管道:
- FastText → 小型 CDX 切片(1,000 页)→ MD5 去重 → 剥离标签 → 分类 → 贪婪式 100 万令牌选择 → JSONL。
这提供了其工作原理的概览。
然而,为了达到论文的性能,您需要实现完整的迭代、领域扩展和污染过滤步骤。
DeepSeekMath 证明了 7 亿参数模型解决竞赛级别数学问题(top-1 准确率 51.7%)所需的仅仅是高质量数据,而不是像 Minerva 那样的 540 亿参数。作为开源社区,我们可以通过为其他领域精心构建特定领域语料库来复制这一成功。
我希望本指南能帮助您将“数据收集”视为一个一流的研究方向——也许它是每个高性能大型语言模型背后默默无闻的英雄。
参考文献
Zhihong Shao 等。“DeepSeekMath: Pushing the Limits of Mathematical Reasoning in Open Language Models。” arXiv:2402.03300(2024 年 4 月 27 日)。
https://arxiv.org/abs/2402.03300Hugging Face 数据集:数学分类器数据
https://huggingface.co/datasets/kenhktsui/math-classifiers-data