数据 · 一段莎士比亚怎么变成一串数字
逐行拆解 data/shakespeare_char/prepare.py——下载文本、建字符表、编码解码、划分训练/验证集、存成 train.bin 与 meta.pkl。模型只认数字,这一章就是把文字翻译成数字的全过程。
数据 · 一段莎士比亚怎么变成一串数字
模型不认字,只认数字。所以在训练开始前,必须先把「文本」翻译成「一串整数」。这件事就发生在 data/shakespeare_char/prepare.py 里——全文不到 70 行,却是整条流水线的起点。我们一段段读。
这里读的是**字符级(character-level)**的版本:一个字符就是一个 token。它最直观,适合入门。GPT-2 真正用的是 BPE 子词分词(data/shakespeare/prepare.py 和 openwebtext/prepare.py),原理更绕但思路一致。先把字符级这版吃透,子词版你一看就懂。
第一步:把文本下载下来
import os
import pickle
import requests
import numpy as np
# download the tiny shakespeare dataset
input_file_path = os.path.join(os.path.dirname(__file__), 'input.txt')
if not os.path.exists(input_file_path):
data_url = 'https://raw.githubusercontent.com/karpathy/char-rnn/master/data/tinyshakespeare/input.txt'
with open(input_file_path, 'w') as f:
f.write(requests.get(data_url).text)
with open(input_file_path, 'r') as f:
data = f.read()
print(f"length of dataset in characters: {len(data):,}")input_file_path指向脚本同目录下的input.txt(os.path.dirname(__file__)就是「本脚本所在的文件夹」)。if not os.path.exists(...):文件不在才下载——跑第二次就不会重复下了。- 下载的是 Karpathy 那份经典的「tiny shakespeare」,莎士比亚剧作的一大段纯文本。
- 最后
data就是一个巨大的字符串,len(data)约 1,115,394 个字符。
到这里,内存里握着的就是一整本莎士比亚——但它还是「文字」,模型看不懂。
第二步:建一张「字符 ↔ 数字」的对照表
# get all the unique characters that occur in this text
chars = sorted(list(set(data)))
vocab_size = len(chars)
print("all the unique characters:", ''.join(chars))
print(f"vocab size: {vocab_size:,}")这三行是全章的灵魂,拆开看:
set(data):把整本书丢进集合,自动去重,得到「这本书里到底出现过哪些不同字符」。sorted(list(...)):排个序,让顺序固定(否则每次跑得到的编号都不一样,没法复现)。- 结果
chars就是一张「字母表」,对莎士比亚这份数据来说,一共 65 个字符:
!$&',-.3:;?ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz注意里面不只有字母,还有空格、换行、标点、几个数字。凡是文中出现过的,都算一个「词表成员」;没出现过的(比如 #、@),模型这辈子都不认识。 vocab_size = 65 这个数字后面很关键——它决定了模型最后要在「65 选 1」里做预测。
词表(vocabulary) 就是「模型认识的所有 token 的清单」。字符级的词表小得可怜(65),所以简单;GPT-2 的 BPE 词表有 50257 个,能覆盖英文里绝大多数常见词片,但代价是分词逻辑复杂得多。词表越大,模型输出层越宽、越吃显存——这个权衡我们在第二站还会碰到。
第三步:编码器与解码器
# create a mapping from characters to integers
stoi = { ch:i for i,ch in enumerate(chars) }
itos = { i:ch for i,ch in enumerate(chars) }
def encode(s):
return [stoi[c] for c in s] # encoder: take a string, output a list of integers
def decode(l):
return ''.join([itos[i] for i in l]) # decoder: take a list of integers, output a string有了那张 65 个字符的表,编号就是顺手的事:
stoi(string to integer):字符 → 编号。比如stoi['A']可能是13。itos(integer to string):编号 → 字符,反过来查。encode('Hi'):把字符串里每个字符查表换成编号,得到一个整数列表,如[20, 47]。decode([20, 47]):反着来,把编号列表拼回字符串'Hi'。
这就是「分词器(tokenizer)」最朴素的样子——字符级下,它就是俩字典。真正的 GPT-2 分词器(tiktoken)复杂得多,但对外的接口一模一样:encode 把文本变数字,decode 把数字变回文本。记住这对函数,采样那一章还要用它把模型吐出的数字翻译回人话。
第四步:切成训练集和验证集
# create the train and test splits
n = len(data)
train_data = data[:int(n*0.9)]
val_data = data[int(n*0.9):]把整本书按 90% / 10% 切两段:
train_data:前 90%,用来训练——模型反复看它、从中学规律。val_data:后 10%,用来验证——模型没「背」过这段,拿它测「学到的东西能不能推广到没见过的文本上」。
为什么非要留一手?因为我们怕模型死记硬背(过拟合)。如果训练集 loss 一直降、验证集 loss 却开始升,就说明它在背课文而不是学语言。第三站看训练循环时,你会看到这两个 loss 是并排打印、互相对照的。
第五步:编码 + 存成二进制
# encode both to integers
train_ids = encode(train_data)
val_ids = encode(val_data)
print(f"train has {len(train_ids):,} tokens")
print(f"val has {len(val_ids):,} tokens")
# export to bin files
train_ids = np.array(train_ids, dtype=np.uint16)
val_ids = np.array(val_ids, dtype=np.uint16)
train_ids.tofile(os.path.join(os.path.dirname(__file__), 'train.bin'))
val_ids.tofile(os.path.join(os.path.dirname(__file__), 'val.bin'))- 先把两段文本各自
encode成整数列表:训练集约 1,003,854 个 token,验证集约 111,540 个。 np.array(..., dtype=np.uint16):转成 NumPy 数组,类型定为uint16(无符号 16 位整数)。
这里有个值得停下来想想的小细节——为什么是 uint16?
uint16 能表示 0 到 65535。我们的编号最大才 64(字符级),GPT-2 的词表也才 50257,都塞得进 uint16。用它而不用更大的 int32,每个 token 只占 2 字节而非 4 字节,.bin 文件直接小一半。对动辄几十亿 token 的大数据集,这一半就是几十 GB 的差别。一个不起眼的类型选择,背后是实打实的存储与 IO 考量——这种「小处见真章」的地方,正是逐行读源码才看得到的。
.tofile(...):把数组原样写成二进制文件train.bin/val.bin。没有任何格式包装,就是一长串紧挨着的uint16。第三站的get_batch会用np.memmap直接把它当成一个大数组来随机取样。
第六步:把「翻译表」也存下来
# save the meta information as well, to help us encode/decode later
meta = {
'vocab_size': vocab_size,
'itos': itos,
'stoi': stoi,
}
with open(os.path.join(os.path.dirname(__file__), 'meta.pkl'), 'wb') as f:
pickle.dump(meta, f)train.bin 里只有光秃秃的数字,可数字要能翻译回字符,得靠那张对照表。所以把 vocab_size、stoi、itos 打包成 meta,用 pickle 存成 meta.pkl。
它有两个用处,后面都会碰到:
- 训练时(
train.py)会读meta.pkl里的vocab_size,好知道模型输出层要开多宽——是「65 选 1」还是「50257 选 1」。 - 采样时(
sample.py)会读itos,把模型吐出的一串数字翻译回你看得懂的字符。
小结:数据这一站,做完了什么
prepare.py 跑完,data/shakespeare_char/ 下多出三个文件:
| 文件 | 内容 | 谁来用 |
|---|---|---|
train.bin | 训练集,约 100 万个 uint16 编号 | train.py 的 get_batch |
val.bin | 验证集,约 11 万个编号 | 同上,用于算验证 loss |
meta.pkl | vocab_size + 字符↔编号对照表 | train.py(取词表大小)、sample.py(解码) |
一句话总结这一站:我们把一本莎士比亚,变成了硬盘上一条约一百万个整数的长龙,并附了一张「数字翻字符」的密码本。
从现在起,模型眼里的世界就只剩这条整数长龙了。它要学的本事只有一个:盯着这条龙里前面的一段,猜下一个数字是几。 而负责「猜」的那台机器,就是下一站的主角。👉 模型全景:一个 GPT 只有五个零件