简介:这份PDF文档系统梳理了2018年至2022年初GPT-1、GPT-2、GPT-3、GPT-NeoX-20B、Megatron-11B、MT-NLG与Gopher等大规模预训练语言模型所使用数据集的组成情况,面向自然语言处理研究人员、机器学习工程师与数据科学家,帮助读者理解各模型训练数据的来源、规模与令牌数量差异,并直面数据集透明度不足这一核心问题。资源包内含1个PDF文件,大小约2.36MB,结构上依次覆盖Wikipedia、Books、Journals、Reddit链接、Common Crawl等常见数据源分析,以及各模型数据集摘要、The Pile v1分组数据、Gopher的MassiveWeb分析等章节,并附有结论、延伸阅读与附录A的Top 50资源清单。作者Alan D. Thompson在文中引用大量参考文献与附录材料,对Books1、Books2及Wikipedia等数据集的统计口径提出质疑,为后续数据集构建与模型训练提供可复用的评估视角。目前已有112人学习,适合需要深入对比主流大模型数据构成、关注数据透明度标准的中高级读者参考。
1. 大规模预训练语言模型数据集:从 GPT-1 到 GPT-2 的语料账本
很多人第一次接触 GPT 系列,注意力全在模型结构上——多少层 Transformer、多少注意力头、参数量多大。但真正决定模型能力上限的,往往是那本没人细看的语料账本。GPT-1 用了约 7000 本书规模的 BooksCorpus,到了 GPT-2,语料直接膨胀到 40GB 的 WebText,模型参数从 1.17 亿涨到 15 亿。参数量涨了十几倍,语料涨了几十倍,这两条曲线不是巧合。
这篇要讲的是:当你手里有一批原始文本,怎么把它变成能喂给预训练语言模型的数据集。不是调包跑个 demo,而是从数据来源、清洗规则、去重策略、token 统计一路走到可复现的预处理流水线。适合两类人:一是准备自己跑小规模预训练或继续预训练(continue pretraining)的工程师,二是需要评估「这个数据集到底够不够、干不干净」的算法负责人。读完你应该能自己搭一条从原始文本到 tokenized shard 的完整链路,并且知道每一步的坑在哪。
2. 语料从哪来:GPT 系列的数据源选型与配比逻辑
2.1 为什么 WebText 的「质量过滤」比「规模」更值得抄
GPT-2 的 WebText 没有公开完整数据集,但论文里透露了构造思路:从 Reddit 上抓取获得至少 3 个 karma 的外链页面。这个做法的本质不是「Reddit 数据好」,而是用人类投票当了一个廉价的质量过滤器。karma 门槛筛掉的不是内容类型,而是大量低质、垃圾、机器生成的页面。
我一般会把这个思路抽象成三层过滤:
| 过滤层 | 目的 | 常见手段 |
|---|---|---|
| 来源层 | 控制初始质量分布 | 白名单域名、社区投票、编辑审核 |
| 文档层 | 去掉明显垃圾 | 长度阈值、符号比例、重复行检测 |
| 内容层 | 去掉有害/低信息 | 困惑度过滤、分类器打分、去重 |
来源层最省事但最不灵活,内容层最灵活但成本最高。实际项目里,来源层能砍掉 60% 以上的噪声,文档层再砍 20%,内容层只处理剩下的 20%。顺序反了,你会把算力浪费在垃圾上。
2.2 用 Python 搭一个最小可用的语料采集与初筛脚本
下面这段代码做三件事:从本地目录读取原始文本、按文档层规则初筛、输出统计报告。不依赖任何外部 API,可以直接跑。
import os import re import json from collections import Counter def basic_filter(text, min_len=200, max_symbol_ratio=0.3): """文档层初筛:长度 + 符号比例""" if len(text) < min_len: return False, "too_short" # 统计非字母数字字符比例 symbol_count = len(re.findall(r'[^\w\s]', text)) if symbol_count / max(len(text), 1) > max_symbol_ratio: return False, "too_many_symbols" # 重复行检测:如果某一行重复超过 5 次,判为低质 lines = [l.strip() for l in text.split('\n') if l.strip()] if lines: most_common = Counter(lines).most_common(1)[0][1] if most_common > 5: return False, "repetitive_lines" return True, "ok" def scan_corpus(root_dir): stats = Counter() kept_docs = [] for fname in os.listdir(root_dir): if not fname.endswith('.txt'): continue path = os.path.join(root_dir, fname) with open(path, 'r', encoding='utf-8', errors='ignore') as f: text = f.read() ok, reason = basic_filter(text) stats[reason] += 1 if ok: kept_docs.append({"file": fname, "chars": len(text)}) return stats, kept_docs if __name__ == "__main__": stats, kept = scan_corpus("./raw_texts") print("过滤统计:", dict(stats)) print("保留文档数:", len(kept)) total_chars = sum(d["chars"] for d in kept) print("总字符数:", total_chars) with open("filtered_index.json", "w", encoding="utf-8") as f: json.dump(kept, f, ensure_ascii=False, indent=2)逻辑说明:basic_filter里三个判断分别对应长度不足、符号噪声过多、模板化重复内容。min_len=200是经验值,中文语料可以降到 100,英文建议不低于 200。max_symbol_ratio=0.3对代码类语料要放宽到 0.5,否则会把正常代码全滤掉。repetitive_lines的阈值 5 是防止导航栏、页脚这类模板文本混入。
参数怎么改:如果你的语料是论坛帖子,长度阈值降到 50;如果是书籍,提到 500。符号比例对中文可以设 0.2,因为中文标点占比天然比英文低。这些数字没有标准答案,跑一遍看统计分布再定。
2.3 数据配比:别让某一类语料主导整个预训练
GPT-2 的 WebText 虽然来源单一,但内容分布很广。如果你自己混合多个来源,配比就是第二个关键决策。常见做法是:通用网页文本占 60%~70%,书籍/长文占 15%~20%,代码占 5%~10%,问答/对话占 5%。这个比例不是死的,取决于你的下游任务。
我一般会先跑一个来源分布统计,再决定要不要上采样或下采样。如果某个来源的 token 数超过总量 50%,要么砍它,要么补其他来源。单一来源主导会导致模型在那个领域的困惑度很低,但换一个领域就崩。
3. 清洗与去重:把 40GB 压到 25GB 的实操路径
3.1 去重的三个层级:文档级、段落级、句子级
去重是预训练数据预处理里最容易被低估的一步。Common Crawl 原始数据里,重复内容能占到 30% 以上。不去重,模型会在重复样本上过拟合,浪费算力。
三个层级的去重成本和收益完全不同:
- 文档级:用 MinHash 或 SimHash,速度快,能去掉完全重复和近似重复的整篇文档。成本低,收益高,必做。
- 段落级:对每个段落做哈希,去掉跨文档的重复段落。成本中等,能进一步压缩 10%~15%。
- 句子级:对句子做精确匹配或模糊匹配。成本高,收益递减,一般只在数据量特别大时才做。
我的建议是:文档级必做,段落级看数据来源,句子级除非你有明确证据表明重复严重,否则先跳过。
3.2 用 MinHash 做文档级去重的完整代码
下面用datasketch库实现 MinHash + LSH 去重。如果没有这个库,pip install datasketch即可。
from datasketch import MinHash, MinHashLSH import os def get_minhash(text, num_perm=128): """对文本生成 MinHash 签名""" m = MinHash(num_perm=num_perm) # 按空格切词,中文可以先分词再传入 for token in set(text.split()): m.update(token.encode('utf-8')) return m def dedup_documents(docs, threshold=0.8): """docs: list of (doc_id, text)""" lsh = MinHashLSH(threshold=threshold, num_perm=128) minhashes = {} for doc_id, text in docs: m = get_minhash(text) minhashes[doc_id] = m lsh.insert(doc_id, m) keep = [] removed = set() for doc_id, text in docs: if doc_id in removed: continue keep.append(doc_id) # 查询相似文档并标记删除 candidates = lsh.query(minhashes[doc_id]) for c in candidates: if c != doc_id: removed.add(c) return keep, removed if __name__ == "__main__": docs = [] for i, fname in enumerate(os.listdir("./raw_texts")): with open(f"./raw_texts/{fname}", encoding="utf-8", errors="ignore") as f: docs.append((f"doc_{i}", f.read())) keep, removed = dedup_documents(docs, threshold=0.8) print(f"保留 {len(keep)} 篇,去除 {len(removed)} 篇")逻辑说明:num_perm=128是 MinHash 的排列数,越大越精确但越慢。128 在大多数场景够用。threshold=0.8表示 Jaccard 相似度超过 0.8 就判为重复。这个阈值对新闻类语料可以设 0.7,对技术文档设 0.85。
参数怎么改:如果语料是短文本(如推文),num_perm可以降到 64,因为短文本的 token 集合小,高排列数收益不大。如果语料是长文档,num_perm提到 256 更稳。threshold越低去重越激进,可能误删不同但相似的文档,建议从 0.85 开始往下调,观察保留率。
3.3 清洗规则表:哪些字符该删,哪些该留
清洗不是把所有非字母数字都删掉。下面这张表是我在多个项目里总结的规则,按「必删 / 可删 / 保留」分类:
| 类型 | 处理 | 原因 |
|---|---|---|
| HTML 标签 | 必删 | 无语言信息,干扰 tokenizer |
| 连续空白 | 合并为单空格 | 减少无效 token |
| 控制字符 | 必删 | 编码噪声 |
| 数学符号 | 保留 | 代码和学术语料需要 |
| 中文标点 | 保留 | 语义边界 |
| 表情符号 | 视场景 | 对话语料保留,正式语料可删 |
| URL | 可替换为占位符 | 保留结构信息但不让模型记具体链接 |
提示:清洗规则一旦确定,要写成可配置的 YAML 或 JSON,不要硬编码在脚本里。不同来源的语料需要不同规则,硬编码会让你每次改规则都改代码。
4. Tokenization 与分片:从原始文本到训练可用的 shard
4.1 选 tokenizer:BPE、WordPiece 还是 SentencePiece
预训练语言模型常用的 tokenizer 就三类:BPE(GPT 系列)、WordPiece(BERT 系列)、SentencePiece(多语言场景)。如果你是从头训练,选哪个取决于语料语言和词表大小。
| tokenizer | 适合场景 | 词表大小经验值 |
|---|---|---|
| BPE | 英文为主,代码 | 32000~50257 |
| WordPiece | 英文,需要 subword 泛化 | 30000~50000 |
| SentencePiece | 中文、多语言、混合语料 | 32000~100000 |
GPT-2 用的 BPE 词表是 50257。如果你做中文预训练,SentencePiece 的 unigram 模式通常比 BPE 更稳,因为中文没有天然空格分词,BPE 的合并规则容易产生奇怪的分片。
4.2 用 Hugging Face tokenizers 训练自己的 BPE 并编码
下面代码用tokenizers库训练一个 BPE tokenizer,然后把语料编码成 token id 序列。
from tokenizers import Tokenizer, models, trainers, pre_tokenizers from tokenizers.processors import BertProcessing import os def train_bpe(corpus_files, vocab_size=32000, save_path="bpe_tokenizer.json"): tokenizer = Tokenizer(models.BPE(unk_token="[UNK]")) tokenizer.pre_tokenizer = pre_tokenizers.Whitespace() trainer = trainers.BpeTrainer( vocab_size=vocab_size, special_tokens=["[PAD]", "[UNK]", "[CLS]", "[SEP]", "[MASK]"], min_frequency=2 ) tokenizer.train(corpus_files, trainer) tokenizer.save(save_path) return tokenizer def encode_corpus(tokenizer, input_dir, output_path, max_len=1024): """把语料编码成定长序列,写入二进制文件""" import numpy as np all_ids = [] for fname in os.listdir(input_dir): if not fname.endswith(".txt"): continue with open(os.path.join(input_dir, fname), encoding="utf-8", errors="ignore") as f: text = f.read() encoded = tokenizer.encode(text) ids = encoded.ids # 按 max_len 切分 for i in range(0, len(ids), max_len): chunk = ids[i:i+max_len] if len(chunk) == max_len: all_ids.append(chunk) arr = np.array(all_ids, dtype=np.uint16) arr.tofile(output_path) print(f"写入 {len(all_ids)} 个序列,总 token 数 {arr.size}") return arr if __name__ == "__main__": files = [f"./raw_texts/{f}" for f in os.listdir("./raw_texts") if f.endswith(".txt")] tok = train_bpe(files, vocab_size=32000) encode_corpus(tok, "./raw_texts", "./tokenized.bin", max_len=1024)逻辑说明:BpeTrainer的min_frequency=2表示出现少于 2 次的 token 不进入词表,防止词表被罕见词撑大。max_len=1024是序列长度,GPT-2 用的是 1024,你可以根据显存调整到 512 或 2048。np.uint16能表示 0~65535,够 32000 词表用,比 int32 省一半空间。
参数怎么改:vocab_size对中文建议 50000 以上,因为汉字组合多。min_frequency对大规模语料可以设 5 或 10,进一步压缩词表。max_len要和模型的位置编码匹配,不能超过模型支持的最大长度。
4.3 分片存储:为什么不要把所有 token 塞进一个文件
上面代码把所有序列写进一个.bin文件,这在数据量小的时候没问题。但如果你有几十 GB token,单文件会导致:读取时无法并行、内存映射困难、训练中断后难以恢复。常见做法是分片成多个文件,每个文件 1GB~2GB,训练时按 shard 顺序读取。
分片命名建议用shard_00001.bin这种零填充格式,方便排序。每个 shard 配一个.idx文件记录序列偏移量,训练时先读 idx 再随机采样。这套做法在 Megatron-LM 和 GPT-NeoX 的数据加载里都能看到影子。
5. 避坑与排查:预训练数据预处理里最容易翻车的 5 个点
5.1 去重后训练集和验证集出现重叠
现象:验证集 loss 异常低,但下游任务表现差。原因:去重只在训练集内部做,没有跨训练集和验证集去重。解决:把验证集也纳入 MinHash 索引,或者在划分数据集之前先做全局去重,再切分。
5.2 Tokenizer 训练时内存爆掉
现象:tokenizer.train()跑到一半 OOM。原因:tokenizers库默认把整个语料加载进内存做频率统计。解决:用train_from_iterator传入生成器,分批读取文件,不要一次性传文件列表。
5.3 编码后的 token 数远少于预期
现象:原始文本 10GB,编码后只有 2GB token。原因:清洗阶段删太多,或者 tokenizer 的min_frequency设太高导致大量 token 变成[UNK]。解决:先跑一遍清洗前后的字符数对比,再检查[UNK]比例,超过 1% 就要降低min_frequency或换 tokenizer。
5.4 分片文件读取顺序错乱
现象:训练时 loss 震荡剧烈。原因:shard 文件名没有零填充,shard_1.bin排在shard_10.bin后面,导致数据顺序错乱。解决:统一用shard_{:05d}.bin格式命名,读取时按文件名排序。
5.5 多来源语料配比在编码后失真
现象:明明按 6:2:2 配比混合,训练时发现某来源占比超过 50%。原因:不同来源的文本长度差异大,按文档数配比不等于按 token 数配比。解决:配比要在 token 级别做,先统计每个来源的 token 总数,再按目标比例采样。
注意:这五个坑里,去重和配比是最容易被忽略的。很多人把精力花在 tokenizer 调参上,结果数据本身就有问题,调什么参数都救不回来。
6. 用 1B token 规模验证你的流水线:一个可复现的检查清单
如果你不想一上来就搞几十 GB,可以先跑一个 1B token 的小规模验证。1B token 大约对应 2GB~4GB 原始英文文本,或者 1.5GB~2GB 中文文本。这个规模足够暴露流水线里的绝大多数问题,成本又可控。
具体做法:从你的完整语料里随机采样 1B token 对应的原始文本,走一遍完整流程——初筛、去重、tokenizer 训练、编码、分片。然后检查下面这张表:
| 检查项 | 健康范围 | 异常时看什么 |
|---|---|---|
| 去重保留率 | 60%~85% | 低于 60% 说明来源重复严重 |
| UNK 比例 | < 0.5% | 高于 1% 要扩词表或降 min_frequency |
| 平均序列长度 | 接近 max_len 的 80% | 太低说明文档太短或切分太碎 |
| 各来源 token 占比 | 与目标配比偏差 < 5% | 偏差大说明配比没在 token 级做 |
| 分片数 | 每片 1GB~2GB | 太多或太少都影响加载效率 |
跑完这张表,你基本能判断流水线能不能上大规模。如果 1B token 跑通且各项指标正常,放大到 10B 或 40B 只是时间问题。
我自己的习惯是:每次改清洗规则或 tokenizer 参数,都重新跑一遍 1B token 验证,对比前后指标。这个习惯帮我省了很多次「上线后才发现数据有问题」的后悔药。预训练数据这活儿,玄学不多,血泪经验多——大部分翻车都是因为跳过了验证步骤。希望帮到你。
本文还有配套的精品资源,点击获取