拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从GPT-1到GPT-2:大规模预训练语言模型数据集构建与预处理实战

从GPT-1到GPT-2:大规模预训练语言模型数据集构建与预处理实战

简介:这份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 验证,对比前后指标。这个习惯帮我省了很多次「上线后才发现数据有问题」的后悔药。预训练数据这活儿,玄学不多,血泪经验多——大部分翻车都是因为跳过了验证步骤。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表