预训练数据流水线 本节摘要:模型是一面镜子,你喂什么它就照什么——喂垃圾,它就用完美的流畅度照出垃圾。大多数人以为训练 LLM 靠架构,其实靠数据。Llama 3 用了 15.6 万亿 token,GPT-3 用 3000 亿,DeepSeek-V2 用 8.1 万亿——三者架构大同小异,输出质量的天壤之别几乎全来自数据。本节带你搭一条流式数据流水线:清洗(去 HTML、语言检测、质量过滤)、MinHash 近似去重、序列打包、按批随机化,快到 8 卡集群永远不等下一批。你会理解 Chinchilla 缩放定律为什么说「大多数模型欠训练」,以及为什么 Llama 3 故意违背它。
本节摘要:模型是一面镜子,你喂什么它就照什么——喂垃圾,它就用完美的流畅度照出垃圾。大多数人以为训练 LLM 靠架构,其实靠数据。Llama 3 用了 15.6 万亿 token,GPT-3 用 3000 亿,DeepSeek-V2 用 8.1 万亿——三者架构大同小异,输出质量的天壤之别几乎全来自数据。本节带你搭一条流式数据流水线:清洗(去 HTML、语言检测、质量过滤)、MinHash 近似去重、序列打包、按批随机化,快到 8 卡集群永远不等下一批。你会理解 Chinchilla 缩放定律为什么说「大多数模型欠训练」,以及为什么 Llama 3 故意违背它。
阅读完本节,你应当能够:
你有了分词器。现在需要数据。
不是一个数据集,不是一个 CSV,而是数 TB 文本——已清洗、已去重、已按质量过滤、已分词成定长序列、按随机批次供给,快到 8 卡集群永远不等下一批。
多数人以为训练 LLM 是模型架构的事。其实不是。Llama 3 用 15.6 万亿 token,GPT-3 用 3000 亿,DeepSeek-V2 用 8.1 万亿——三者的架构大致相同:堆叠的 Transformer 块加注意力和前馈层。输出质量的差异压倒性地来自数据。DeepMind 的 Chinchilla 论文把这说得很精确:给定算力预算,模型参数与训练 token 有一个最优比率。Chinchilla 表明 2022 年大多数模型都严重欠训练——参数太多、数据太少。一个 70B 模型在 1.4 万亿 token 上训练(Chinchilla 最优)能打败一个 280B 模型在 3000 亿 token 上训练(Gopher)。
你的数据流水线,决定了模型学的是语言还是学的是噪声。
每个大语言模型都在多种来源的混合上训练。确切配比是各实验室严守的秘密,但类别我们清楚。
| 来源 | 体量 | 质量 | 使用者 |
|---|---|---|---|
| Common Crawl | 原始约 250 TB | 低(需重度过滤) | GPT-3、Llama、多数开源模型 |
| 维基百科 | 约 20 GB | 高 | 每个主流 LLM |
| GitHub 代码 | 1 TB+ | 中(大量重复、死代码) | StarCoder、CodeLlama、DeepSeek-Coder |
| 书籍(BookCorpus、Pile) | 约 100 GB | 高 | GPT-2、GPT-3、早期模型 |
| 学术论文(arXiv、S2ORC) | 约 100 GB | STEM 高 | Llama、Galactica |
| StackOverflow、Reddit | 约 100 GB | 中 | Llama、Falcon |
| 精选网页(C4、RefinedWeb) | 约 5 TB | 中高(已预过滤) | T5、Falcon |
Llama 3 公开了数据配比:约 50% 网页、25% 代码、13% 书籍与论文、8% 数学、4% 多语言网页。配比与总量同等重要——网页太多,模型变成 Reddit 鹦鹉;代码太少,不会编程;数学太少,推理不行。调好这个混合是训练 LLM 最难的环节之一,没有公式,只能靠实验和评估。
原始网页数据极脏。一份典型 Common Crawl 转储含:HTML 标签与 JavaScript、样板页眉页脚导航、重复页(精确与近似)、机器生成垃圾、个人身份信息(PII)、低质文本(关键词列表、SEO 垃圾)、编码成文本的非文本内容。清洗不是可选项,它是「生成连贯段落」与「输出混杂 HTML 与商品列表」之间的分水岭。
trafilatura、readability 等库可抽取正文、丢弃导航广告样板。精确去重很简单:给每篇文档算哈希,删重复。但真正麻烦的是近似重复。同一篇新闻的两份拷贝,周围广告略不同,内容 95% 相同,逐字节却不同。MinHash + 局部敏感哈希(LSH)能高效解决。
思路:(1) Shingling——把文档转成 n-gram 集合(如 5-gram);(2) MinHash——对每个 shingle 集合算 k 个哈希值,每个哈希取所有 shingle 的最小值,得到固定大小「签名」,近似两文档间的 Jaccard 相似度;(3) LSH——按 MinHash 签名的「带」分桶,同桶文档是近似重复候选,避免了 O(n²) 的两两比较;(4) 验证——对每对候选算精确 Jaccard,超阈值(通常 0.8)就删一份。Llama 团队报告通过去重移除了约 38% 的网页数据——Common Crawl 超过三分之一是重复或近似重复。
模型期望定长输入序列,文档却是变长的——有的 50 token,有的 5 万。朴素做法:每篇填到最大长度,在填充 token 上浪费巨量算力。更好做法:把多篇文档用 [EOS] 分隔,拼进同一条序列。一条 2048-token 序列可能含三篇短文档拼接。注意力掩码必须正确设置:文档 A 的 token 不该注意到同序列里文档 B 的 token,需要块对角注意力掩码。长文档在序列边界处截断或切块,切点尽量对齐段落或句子边界。
固定算力预算 C(以 FLOPs 计),最优模型大小 N 与数据大小 D 满足:N_opt ~ C^0.5、D_opt ~ C^0.5。即模型与数据要大致等比放大——参数多 10 倍的模型,需要约多 10 倍的训练 token 才能到同等损失。
| 模型 | 参数 | 训练 token | Chinchilla 最优? |
|---|---|---|---|
| GPT-3 | 175B | 300B | 否(欠训练 3~4 倍) |
| Chinchilla | 70B | 1.4T | 是(设计如此) |
| Llama 2 | 70B | 2T | 过训练(有意) |
| Llama 3 | 70B | 15T | 严重过训练 |
💡 关键概念:Llama 3 故意违背 Chinchilla——Meta 发现,用更多数据过训练,能产出更适合推理的模型。额外训练成本只付一次,但更小的模型永久更省服务成本。这被称为「推理最优」缩放,自 2024 年起成为业界标准。
去 HTML、规整空白、移除非文本内容。用公有领域文本(古腾堡计划)做小语料。
import re def clean_text(text): text = re.sub(r"<[^>]+>", "", text) # 去 HTML 标签 text = re.sub(r"http\S+", "", text) # 去 URL text = re.sub(r"[^\x20-\x7E\n]", "", text) # 只留可打印 ASCII text = re.sub(r"\n{3,}", "\n\n", text) # 压多余空行 text = re.sub(r" {2,}", " ", text) # 压多余空格 return text.strip() def quality_filter(text, min_words=50, max_ratio_caps=0.3, max_ratio_special=0.1): words = text.split() if len(words) < min_words: return False # 太短(存根页) if sum(w.isupper() for w in words)/len(words) > max_ratio_caps: return False # 全大写(SEO) special = sum(1 for c in text if not c.isalnum() and not c.isspace()) if special/max(len(text),1) > max_ratio_special: return False # 特殊字符多(机生噪声) return True
三个检查就能移除惊人数量的网页垃圾。
只用 hashlib,从零实现 MinHash。
import hashlib def get_shingles(text, k=5): words = text.lower().split() return {" ".join(words[i:i+k]) for i in range(len(words)-k+1)} if len(words)>=k else set() def minhash_signature(shingles, num_hashes=128): sig = [] for i in range(num_hashes): mn = min(int(hashlib.sha256(f"{i}:{s}".encode()).hexdigest(),16) for s in shingles) if shingles else 0 sig.append(mn) return sig def lsh_buckets(sig, bands=16): rpb = len(sig)//bands return [(b, hashlib.md5(str(sig[b*rpb:b*rpb+rpb]).encode()).hexdigest()) for b in range(bands)]
num_hashes=128、bands=16 控制精确率与召回率的权衡:哈希越多估计越准,带越多召回越高但误报越多。同桶的候选对再算精确 Jaccard,超 0.8 阈值删一份。
def tokenize_corpus(docs, tokenizer): ids = [] for d in docs: ids.extend(tokenizer.encode(d)); ids.append(tokenizer.eos_id) return ids def pack_sequences(token_ids, seq_len, pad_id=0): seqs, masks = [], [] for i in range(0, len(token_ids), seq_len): s = token_ids[i:i+seq_len]; m = [1]*len(s) if len(s) < seq_len: s += [pad_id]*(seq_len-len(s)); m += [0]*(seq_len-len(s)) seqs.append(s); masks.append(m) return seqs, masks
产出随机化批次的打包序列,供训练循环消费。
import random class PreTrainingDataLoader: def __init__(self, sequences, masks, batch_size, shuffle=True): self.sequences, self.masks, self.batch_size, self.shuffle = sequences, masks, batch_size, shuffle def __iter__(self): idx = list(range(len(self.sequences))) if self.shuffle: random.shuffle(idx) for s in range(0, len(idx), self.batch_size): b = idx[s:s+self.batch_size] yield [self.sequences[i] for i in b], [self.masks[i] for i in b]
算关键数字:总 token、唯一 token、压缩比、文档长度分布、序列利用率。压缩比告诉你分词器在本语料上多有效——英文通常压到约每 token 3~4 字符。序列利用率告诉你打包序列里多少是真数据而非填充——低于 90% 说明打包低效。
from datasets import load_dataset from transformers import AutoTokenizer import time ds = load_dataset("wikitext", "wikitext-2-raw-v1", split="train") tok = AutoTokenizer.from_pretrained("meta-llama/Meta-Llama-3-8B") t0 = time.time() tok_ds = ds.map(lambda x: tok(x["text"], truncation=True, max_length=2048), batched=True, num_proc=4) print(f"{sum(len(t) for t in tok_ds['input_ids']):,} tokens in {time.time()-t0:.2f}s")
HuggingFace 底层用 Rust 分词器、4 核并行。你的纯 Python 流水线慢 10~50 倍。这就是生产团队用编译分词器的原因——算法相同,实现语言是差距。
本节产出 outputs/prompt-data-quality-checker.md——一个用于校验与调试 LLM 训练流水线数据质量的提示。Python 代码是独立工具,接口稳定。
(Easy) 给清洗流水线加语言检测(用字符集分析的简单启发式),只留英文文档,统计移除了多少篇。
(Medium) 在 MinHash 近似去重旁再加 SHA-256 精确去重,对比两种方法在网页语料上各抓出多少重复。
(Hard) 构建困惑度质量过滤器:在维基文本上训一个小二元语言模型,给每篇文档打困惑度,移除最差的 20%,对比过滤与未过滤数据上的模型输出质量。
下一节,我们用上一章的分词器与本节的数据,从零训练一个 124M 参数的迷你 GPT——每一行 numpy 都看得见。