揭秘 DeepSeekMath 的数据管道:基于 FastText 的复现与分析

社区文章 发布于 2025 年 6 月 1 日

1. 为什么数据收集很重要(且经常被忽视)

阅读大多数大型语言模型(LLM)论文时,重点通常放在模型架构、参数数量或微调技巧上。然而,DeepSeekMath 表明,在数学等领域(也适用于大多数其他领域)中,预训练数据的收集地点和方式可能决定模型的成败。事实上,DeepSeekMath-Base 7B 在其精心策划的数学语料库上训练后,仅通过关注高质量数据而非单纯扩展参数,便在竞赛级基准测试中超越了更大规模的模型。

直观地说,开放网络上的数学内容相对于“通用”网页来说是稀有的。此外,数学页面通常嵌入公式、LaTeX 片段或领域特定术语,这些是简单的基于关键词的过滤(“页面是否包含‘积分’这个词?”等)会遗漏或误分类的。因此,构建一个干净、丰富、以数学为重点的网络语料库需要:

  1. 真实数学页面的初始种子,以便分类器能够学习数学内容的特征。
  2. 迭代扩展:找到未包含在种子中的新数学相关页面,然后重新训练分类器以提高召回率。
  3. 领域级别推理:识别整个网站或子路径(例如,mathoverflow.net/questions)是数学密集型的。
  4. 去重和污染过滤:避免重复抓取相同内容或意外包含测试集问题(例如,GSM8K 问题)。
  5. 令牌预算管理:由于“数学”页面可能很冗长(大量符号、证明、代码片段),需要具体决定从每个页面获取多少令牌以达到总计 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 亿参数。作为开源社区,我们可以通过为其他领域精心构建特定领域语料库来复制这一成功。

我希望本指南能帮助您将“数据收集”视为一个一流的研究方向——也许它是每个高性能大型语言模型背后默默无闻的英雄。

参考文献

  1. Zhihong Shao 等。“DeepSeekMath: Pushing the Limits of Mathematical Reasoning in Open Language Models。” arXiv:2402.03300(2024 年 4 月 27 日)。
    https://arxiv.org/abs/2402.03300

  2. Hugging Face 数据集:数学分类器数据
    https://huggingface.co/datasets/kenhktsui/math-classifiers-data

社区

注册登录 发表评论