# 数据 · 一段莎士比亚怎么变成一串数字

> 逐行拆解 data/shakespeare_char/prepare.py——下载文本、建字符表、编码解码、划分训练/验证集、存成 train.bin 与 meta.pkl。模型只认数字，这一章就是把文字翻译成数字的全过程。

- 作者：David（道雾轩）
- 专栏：nanoGPT 源码解读（https://daiw.org/manual/nanogpt.md）
- 最后更新：2026-07-13
- 原文：https://daiw.org/manual/nanogpt/data-prepare-char
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

# 数据 · 一段莎士比亚怎么变成一串数字

模型不认字，只认数字。所以在训练开始前，必须先把「文本」翻译成「一串整数」。这件事就发生在 `data/shakespeare_char/prepare.py` 里——**全文不到 70 行**，却是整条流水线的起点。我们一段段读。

<Callout type="info">
  这里读的是**字符级(character-level)**的版本：一个字符就是一个 token。它最直观，适合入门。GPT-2 真正用的是 BPE 子词分词（`data/shakespeare/prepare.py` 和 `openwebtext/prepare.py`），原理更绕但思路一致。先把字符级这版吃透，子词版你一看就懂。
</Callout>

## 第一步：把文本下载下来

```python
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** 个字符。

到这里，内存里握着的就是一整本莎士比亚——但它还是「文字」，模型看不懂。

## 第二步：建一张「字符 ↔ 数字」的对照表

```python
# 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」里做预测。

<Callout type="info">
  **词表(vocabulary)** 就是「模型认识的所有 token 的清单」。字符级的词表小得可怜(65)，所以简单；GPT-2 的 BPE 词表有 50257 个，能覆盖英文里绝大多数常见词片，但代价是分词逻辑复杂得多。词表越大，模型输出层越宽、越吃显存——这个权衡我们在第二站还会碰到。
</Callout>

## 第三步：编码器与解码器

```python
# 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`(**s**tring **to** **i**nteger)：字符 → 编号。比如 `stoi['A']` 可能是 `13`。
- `itos`(**i**nteger **to** **s**tring)：编号 → 字符，反过来查。
- `encode('Hi')`：把字符串里每个字符查表换成编号，得到一个整数列表，如 `[20, 47]`。
- `decode([20, 47])`：反着来，把编号列表拼回字符串 `'Hi'`。

**这就是「分词器(tokenizer)」最朴素的样子**——字符级下，它就是俩字典。真正的 GPT-2 分词器(`tiktoken`)复杂得多，但对外的接口一模一样：`encode` 把文本变数字，`decode` 把数字变回文本。记住这对函数，采样那一章还要用它把模型吐出的数字翻译回人话。

## 第四步：切成训练集和验证集

```python
# 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 是并排打印、互相对照的。

## 第五步：编码 + 存成二进制

```python
# 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` 直接把它当成一个大数组来随机取样。

## 第六步：把「翻译表」也存下来

```python
# 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`。

它有两个用处，后面都会碰到：

1. **训练时**(`train.py`)会读 `meta.pkl` 里的 `vocab_size`，好知道模型输出层要开多宽——是「65 选 1」还是「50257 选 1」。
2. **采样时**(`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 只有五个零件](https://daiw.org/manual/nanogpt/model-overview)
