Week 9
Week 9 - Tokenizer:从原始文字到训练 Batch
Key question怎样把“我喜欢AI,AI也喜欢猫。”稳定地变成模型可用的整数 IDs,同时不混淆 tokenizer training、encoding 与模型学习?
Learning objectives
- 比较 character、word、UTF-8 byte 与 subword 四种单位对 V、L、覆盖能力和计算成本的影响。
- 区分 code point、UTF-8 byte、BPE symbol、token 与 token ID,并说明 normalization 为什么属于 tokenizer 协议。
- 手算 byte-level BPE 的两轮 pair count、tie break、merge 与长度变化。
- 区分 tokenizer training 与 encoding,并把 tokenizer artifacts 与 model checkpoint 绑定。
- 沿 text → tokens → stream [L] → shifted examples [T] → padded batch [B,T] 完成一条可审计的数据路径。
- 分别构造 causal mask、padding mask 与 loss mask,解释它们各自阻止哪一种错误。
- 在 Week 10 前明确结束 w09-readable-v1,并切换到不兼容的 mini-gpt-v1。
145 min estimated reading time
本周重点不是实现四套 tokenizer,而是让数据可靠地进入已经学过的模型。主线沿用五词表与 course_data.py;字符和 BPE 是解释替代方案的独立实验。先学会一个可靠入口,再比较更复杂方案。
Scroll horizontally to view all columns.
| 学习单元 | 本次解决的问题 |
|---|---|
| 一:稳定的文字接口 | 固定分词规则、ID 顺序和未知词行为;重复编码不能重新随机编号。 |
| 二:真的构造数据 | 同一数据函数从文档构造 T+1 窗口;先分训练/验证,再各自切窗。 |
| 三:判断实验是否可信 | 检查文档重复、长片段重叠和有效目标数,分清流程演示与独立验证。 |
| 四:按需要比较方案 | 先可读字符 BPE 的两轮合并,再选读字节、特殊 token 和 padding;不要求这些成为五词模型的前置。 |
运行 python week09_data_protocol.py;它直接产生 Week 10 能接收的五词 inputs/targets。最终项目会明确新建字符词表,不会把另一套 ID 悄悄送进已有 checkpoint。
建议阅读、手算、改代码交替进行,每个单元可拆成几次完成。章节编号保留用于旧链接和回查;按页面从上到下的新顺序学习,不需要按旧编号来回跳转。
Week 9 核心目标:让文字与模型共享一份稳定协议
Tokenizer 不是语言模型。它规定 normalization、怎样切分、Vocabulary 中有哪些 token、每个 token 对应哪个 ID,以及 IDs 怎样 decode。它在模型前把文字变成整数,也在生成后把整数还原为文字;真正可学习的 contextual patterns 位于 embedding 与 Transformer 参数中。
Scroll horizontally to view all columns.
| 概念 | 本章含义 | 固定句中的例子 |
|---|---|---|
| Token | Tokenizer 发出的一个离散计算单位 | 可以是 我、喜欢、AI,也可能是某个 byte |
| Vocabulary | 允许 token 与 ID 的有限清单 | w09-readable-v1 有 V=11 个 entries |
| Token ID | 某个 entry 的整数地址 | 在该快照中 AI 的地址是 6 |
| Tokenizer | normalization、split、encode、decode 与 special-token policy 的整体协议 | 将固定句映射成下方 stream |
Scroll horizontally to view all columns.
| ID | token | role |
|---|---|---|
| 0 | <BOS> | 文档开始 |
| 1 | <EOS> | 文档结束 |
| 2 | <PAD> | 补齐 batch 长度 |
| 3 | <UNK> | 一种有损的未知项策略 |
| 4 | 我 | 内容 token |
| 5 | 喜欢 | 内容 token |
| 6 | AI | 内容 token |
| 7 | , | 内容 token |
| 8 | 也 | 内容 token |
| 9 | 猫 | 内容 token |
| 10 | 。 | 内容 token |
- 我喜欢AI,AI也喜欢猫。
- [<BOS>, 我, 喜欢, AI, ,, AI, 也, 喜欢, 猫, 。, <EOS>]
- IDs s=[0,4,5,6,7,6,8,5,9,10,1], shape [L]=[11]
- decode(skip_special_tokens=True) → 我喜欢AI,AI也喜欢猫。
Scroll horizontally to view all columns.
| 教学 artifact | 使用位置 | 基本单位 / ID space | 学习目的 | 何时结束 |
|---|---|---|---|---|
| character-demo | 第 4 节 | 9 个已知 code points,IDs 0..8 | 看清 encode、decode、dtype 与 [L] | 第 4 节结束 |
| toy-byte-bpe | 第 5–7 节 | UTF-8 bytes 0..255,再建立 256、257 | 手算 pair count、merge、冻结与还原 | 第 7 节结束 |
| w09-readable-v1 | 第 8–18 节 | 可读 pieces,V=11,IDs 0..10 | 走完 special stream、window、mask 与 batch | Week 9 结束 |
| mini-gpt-v1 | Week 10 起 | 五个 tokens,V=5,IDs 0..4 | 连接 canonical MiniGPT | 按 Week 10–12 协议 |
Knowledge check
为什么 tokenizer artifacts 必须与 model checkpoint 一起版本化?
1. Token 不等于 Word:同一句话可以有四种边界
对“我喜欢AI,AI也喜欢猫。”,同一 raw text 在不同规则下可以发出不同数量的单位。下面的 word-like row 依赖指定 segmenter;subword row 恰好看起来一样,只是这个教学词表训练后的结果,并非 subword 的定义。
Scroll horizontally to view all columns.
| scheme | emitted units | count L | immediate benefit | immediate cost |
|---|---|---|---|---|
| character / code point | [我, 喜, 欢, A, I, ,, A, I, 也, 喜, 欢, 猫, 。] | 13 | 这些 code points 无需 word dictionary | 喜欢 与 AI 都被拆开,sequence 较长 |
| word-like(指定 segmenter) | [我, 喜欢, AI, ,, AI, 也, 喜欢, 猫, 。] | 9 | 短而且容易阅读 | 中文边界、未见词和规范差异需要额外规则 |
| UTF-8 byte | E6 88 91 … 41 49 … E3 80 82 | 31 | 固定 256 个 base symbols 可覆盖任意有效 UTF-8 文本 | 最长;单个 byte fragment 不一定可独立阅读 |
| subword(进一步训练后的示意) | [我, 喜欢, AI, ,, AI, 也, 喜欢, 猫, 。] | 9 | 常见片段短,少见文本仍可拆成更小单位 | merge、normalization 与 ID 都属于该 tokenizer 版本 |
Knowledge check
13 个 Unicode code points 是否证明所有 tokenizer 都会产生 13 个 IDs?
2. 为什么不简单按 Word 切:覆盖与长度的两难
在固定句“我喜欢AI,AI也喜欢猫。”中,word-like tokenizer 可把 喜欢 与 AI 保持为整体;byte base 则一定能表达这些有效 UTF-8 bytes,但需要 31 个 positions。Subword 从覆盖能力较强的小单位出发,再把训练语料中的常见相邻片段合并。
Scroll horizontally to view all columns.
| choice | Vocabulary / coverage | sequence length | 主要代价 |
|---|---|---|---|
| whole word | 需要很多完整词;缺项时常依赖 <UNK> 或额外 fallback | 常见词通常较短 | V 大或 unknown information loss |
| character / byte | base V 较小;byte base 可覆盖有效 UTF-8 | 固定句为 13 / 31 | 更多 positions 与更弱的人类可读性 |
| subword | common pieces + smaller fallback pieces | 通常介于两端;本示意为 9 | 需要冻结训练出的 merges 与 routing rules |
Knowledge check
Subword 相比有限 whole-word Vocabulary 解决了什么问题?
3. Vocabulary Size 的 Trade-off:V 与 L 会同时改变系统
固定句的 13、9、31 positions 只显示方向,不证明“9 最优”。较大的 V 可以为常见片段分配单独 entry,较小的 V 则常要用更多 positions 表达同一文字。模型仍会在每个 position 输出 V 个 logits。
Scroll horizontally to view all columns.
| design direction | vocabulary-facing tables | same text length | context / Attention implication |
|---|---|---|---|
| smaller V | 较少 rows 与 output candidates | 往往更大 L | 固定 T 能覆盖的原文可能更少,位置对更多 |
| larger V | 较多 rows 与 output candidates | 常见片段往往更短 | 每一步的 LM-head computation / memory 增大 |
Scroll horizontally to view all columns.
| example | input embedding VC | bias-free untied LM head VC | total 2VC |
|---|---|---|---|
| 本章教学尺寸 V=11, C=4 | 11×4=44 | 11×4=44 | 88 parameters |
| 示意规模 V=50,000, C=768 | 38,400,000 | 38,400,000 | 76,800,000 parameters |
现在可以看到 V 的双重作用:它一方面决定 embedding / LM-head 的表有多宽,另一方面决定每个 position 要比较多少个 next-token candidates;而 tokenizer 通过改变 L,又会改变要处理多少个 positions。
Knowledge check
C 固定且 V 翻倍,哪两个常见 interfaces 会变大?
4. Character Tokenizer:先看清 Encode / Decode 闭环
对“我喜欢AI,AI也喜欢猫。”,这个简化 tokenizer 把每个 Unicode code point 当作 token。它适合检查闭环,却不等于 production 最佳方案,也不能自动处理 Vocabulary 外 code point、grapheme cluster 或 normalization 差异。
import torch
text = "我喜欢AI,AI也喜欢猫。"
character_tokens = ["我", "喜", "欢", "A", "I", ",", "也", "猫", "。"]
char_to_id = {token: index for index, token in enumerate(character_tokens)}
id_to_char = {index: token for token, index in char_to_id.items()}
def encode_characters(value: str) -> list[int]:
return [char_to_id[character] for character in value]
def decode_characters(ids: list[int]) -> str:
return "".join(id_to_char[index] for index in ids)
ids = encode_characters(text)
token_tensor = torch.tensor(ids, dtype=torch.long)
assert ids == [0, 1, 2, 3, 4, 5, 3, 4, 6, 1, 2, 7, 8]
assert tuple(token_tensor.shape) == (13,)
assert decode_characters(ids) == textKnowledge check
为什么明知本句会有 13 个 tokens,仍值得先实现 character tokenizer?
8. Tokenizer Training 与 Text Encoding 不同
Scroll horizontally to view all columns.
| phase | input | changes artifacts? | output |
|---|---|---|---|
| tokenizer training | training corpus documents | 是:选择 normalization、base vocab、merges、special IDs | versioned artifact A |
| text encoding | one text + frozen A | 否 | IDs in 0..V_A−1 |
| model training | batches of frozen-A IDs | 否:A 不变;model parameters 改变 | updated neural weights |
- train documents
- normalization / pre-tokenization policy
- BPE counts + ordered merges
- frozen tokenizer artifact A
- encode train / validation / inference text
- token streams
- model training
下面把 w09-readable-v1 写成一个最小、具体的 teaching artifact,而不是调用未配置的通用 library tokenizer。它使用 identity normalization、显式 ordered Vocabulary,以及固定的 longest-first content routes;无法匹配的一个 Unicode code point 映射为 <UNK>。这个 routing 是教学约定,不声称由前面两轮 toy BPE 直接产生。
- ① TOKENS 冻结 ordered Vocabulary 与 special IDs
- ② CONTENT_ROUTES 规定最长优先的内容切分
- ③ segment_content 只返回 token strings
- ④ encode_content 把 strings lookup 成 IDs,不添加边界
- ⑤ encode_document 恰好添加一次 BOS 与 EOS
- ⑥ decode 按同一 ID table 重建文字
第一次阅读下面代码时,只追踪固定句从 cursor=0 到 cursor=len(text) 的移动;第二次再检查 special-token ownership 与 error branches。这样可以先看懂数据流,再看防御性细节。
TOKENS = (
"<BOS>",
"<EOS>",
"<PAD>",
"<UNK>",
"我",
"喜欢",
"AI",
",",
"也",
"猫",
"。",
)
TOKEN_TO_ID = {token: token_id for token_id, token in enumerate(TOKENS)}
ID_TO_TOKEN = {token_id: token for token, token_id in TOKEN_TO_ID.items()}
BOS_ID = TOKEN_TO_ID["<BOS>"] # 0
EOS_ID = TOKEN_TO_ID["<EOS>"] # 1
PAD_ID = TOKEN_TO_ID["<PAD>"] # 2
UNK_ID = TOKEN_TO_ID["<UNK>"] # 3
# Deterministic longest-first routing for this small teaching artifact.
# Equal-length routes retain this declared order.
CONTENT_ROUTES = ("喜欢", "AI", "我", ",", "也", "猫", "。")
HIDDEN_ON_DECODE_IDS = {BOS_ID, EOS_ID, PAD_ID}
class ReadableTokenizerV1:
version = "w09-readable-v1"
normalization = "identity"
vocabulary = TOKENS
def segment_content(self, text: str) -> list[str]:
pieces: list[str] = []
cursor = 0
while cursor < len(text):
matched = next(
(
piece
for piece in CONTENT_ROUTES
if text.startswith(piece, cursor)
),
None,
)
if matched is None:
pieces.append("<UNK>")
cursor += 1
else:
pieces.append(matched)
cursor += len(matched)
return pieces
def encode_content(
self,
text: str,
*,
add_special_tokens: bool = False,
) -> list[int]:
if add_special_tokens:
raise ValueError("encode_content never adds BOS/EOS")
return [TOKEN_TO_ID[piece] for piece in self.segment_content(text)]
def encode_document(
self,
text: str,
*,
add_special_tokens: bool = True,
) -> list[int]:
if not add_special_tokens:
raise ValueError("use encode_content when boundaries are unwanted")
return [
BOS_ID,
*self.encode_content(text, add_special_tokens=False),
EOS_ID,
]
def decode(
self,
ids: list[int],
*,
skip_special_tokens: bool = True,
) -> str:
pieces: list[str] = []
for token_id in ids:
if token_id not in ID_TO_TOKEN:
raise ValueError(f"token ID out of range: {token_id}")
if skip_special_tokens and token_id in HIDDEN_ON_DECODE_IDS:
continue
pieces.append(ID_TO_TOKEN[token_id])
return "".join(pieces)
W09_READABLE_V1 = ReadableTokenizerV1()
def encode_content(text: str) -> list[int]:
return W09_READABLE_V1.encode_content(
text,
add_special_tokens=False,
)
def encode_document(text: str) -> list[int]:
return W09_READABLE_V1.encode_document(
text,
add_special_tokens=True,
)
RUNNING_TEXT = "我喜欢AI,AI也喜欢猫。"
content_ids = encode_content(RUNNING_TEXT)
document_ids = encode_document(RUNNING_TEXT)
assert content_ids == [4, 5, 6, 7, 6, 8, 5, 9, 10]
assert document_ids == [0, *content_ids, 1]
assert W09_READABLE_V1.decode(
document_ids,
skip_special_tokens=True,
) == RUNNING_TEXT
# These calls never mutate vocabulary, routes, or IDs.Scroll horizontally to view all columns.
| fixed input | method | output | artifact mutation |
|---|---|---|---|
| 我喜欢AI,AI也喜欢猫。 | segment_content | [我,喜欢,AI,,,AI,也,喜欢,猫,。] | none |
| 我喜欢AI,AI也喜欢猫。 | encode_content | [4,5,6,7,6,8,5,9,10] | none |
| 我喜欢AI,AI也喜欢猫。 | encode_document | [0,4,5,6,7,6,8,5,9,10,1] | none |
| [0,4,5,6,7,6,8,5,9,10,1] | decode(skip_special_tokens=True) | 我喜欢AI,AI也喜欢猫。 | none |
Knowledge check
固定 BPE rule (41,49)→256 后,再 encode 固定句会改变这条 rule 吗?
9. Tokenizer 与 Model 必须绑定:Shape 相同仍可能完全错位
w09-readable-v1 把“我喜欢AI,AI也喜欢猫。”中的 喜欢 编为 5、AI 编为 6。若另一个同样 V=11 的 tokenizer 把两者交换,输入 6 会读取原本为 AI 训练的 embedding row,却被新系统解释成 喜欢;输出 column 6 也会 decode 成错误 piece。
Scroll horizontally to view all columns.
| bundle item | why model loading needs it |
|---|---|
| ordered token list / Vocabulary | 定义每个 embedding row 与 LM-head column 的名称 |
| ordered BPE merges | 决定新文字如何组合成 Vocabulary entries |
| normalizer + pre-tokenizer | 决定 merge 前看到的 symbols 与 boundaries |
| special-token IDs / insertion policy | 定义 BOS、EOS、PAD、UNK 的控制地址与何时出现 |
| tokenizer version / content hash | 在加载前做精确 compatibility check |
| model config + weights | 声明 V、C、context 等 shape,并保存已学习参数 |
w09-readable-v1:
ID 5 -> 喜欢
ID 6 -> AI
incompatible-tokenizer:
ID 5 -> AI
ID 6 -> 喜欢
Both report V=11.
Model input ID 6 is numerically in range, but its learned row and displayed token disagree.Knowledge check
为什么两个 V=11 的 tokenizer 仍可能不能共享同一个 checkpoint?
12. 把 Corpus 变成 Token Tensor:保留顺序与文档边界
只有一份固定文档“我喜欢AI,AI也喜欢猫。”时,w09-readable-v1 直接得到 [0,4,5,6,7,6,8,5,9,10,1],shape [11]。它仍是一条 chronological stream,不是 batch,也没有 C feature axis。
import torch
from w09_readable_v1 import encode_document
documents = ["我喜欢AI,AI也喜欢猫。"]
# encode_document already inserts exactly one BOS and one EOS per document.
stream: list[int] = []
for document in documents:
stream.extend(encode_document(document))
stream_tensor = torch.tensor(stream, dtype=torch.long)
assert stream == [0, 4, 5, 6, 7, 6, 8, 5, 9, 10, 1]
assert tuple(stream_tensor.shape) == (11,)
assert stream_tensor.dtype == torch.longScroll horizontally to view all columns.
| multi-document policy | boundary example | 会训练哪种 transition | trade-off |
|---|---|---|---|
| 连续串接 boundary tokens | …内容,<EOS>,<BOS>,下一篇… | 内容末尾→EOS,也可能包含 EOS→BOS | 实现简单;需要接受控制 token 间的跨文档 transition |
| 每篇文档内单独切 windows | window 不跨 document | 只监督同一文档内与内容末尾→EOS | 语义边界清楚;短尾部可能浪费 positions |
| packing + boundary-aware masks | 一张矩形 batch 放多篇文档 | 用 attention/loss policy 阻断不需要的跨文档连接 | 利用率高,但实现与审计更复杂 |
本章单文档 stream 不会触发上述差异。扩展到多文档时,必须在数据规范中选定一种 policy;不能只插入 EOS 就声称所有跨文档信息流都已经被阻断。
Knowledge check
固定句刚变为 stream_tensor 时 shape 与 dtype 是什么?
13. Train / Validation Split:先隔离 Documents,再切 Windows
单句“我喜欢AI,AI也喜欢猫。”只用于 pipeline 手算,不能冒充有意义的 train/validation experiment。真实 corpus 应先分 documents,再冻结 tokenizer,最后分别建立 train_stream 与 val_stream;validation forward 不调用 backward 或 optimizer.step。
- raw documents
- document/group/temporal split
- fit tokenizer A on train documents only (when training from scratch)
- freeze A
- encode train documents → train_stream [L_train]
- encode validation documents → val_stream [L_val]
- form windows separately inside each split
这是 provenance guarantee,不是 value-level deduplication:两份独立 documents 可能都包含常见短语或 boilerplate,因此完全相同的 ID window values 可以自然地分别出现在 train 与 validation。若任务还要求去重,必须另外声明 document/group deduplication policy。
Scroll horizontally to view all columns.
| stage | train lane | validation lane |
|---|---|---|
| raw data | train documents | held-out validation documents |
| tokenizer | fit A here if from scratch, then freeze | only apply frozen A |
| model | forward + loss + backward + step | eval/no_grad forward + loss only |
| claim | optimization progress | estimate on held-out token sequences |
Validation 虽不参与 backward,却会通过选模型、调超参数影响你的决策。若反复针对同一 validation 改进,再把它当完全没见过的最终测验,会过度乐观。正式比较可另留 test split,最后才使用;本课三句玩具数据不足以支持这种质量结论。
Knowledge check
为什么应在 stream concatenation 之前按 documents 分割?
14. 从 Token Stream 取一个 Example:Target 必须右移一位
固定 stream s=[0,4,5,6,7,6,8,5,9,10,1] 来自“我喜欢AI,AI也喜欢猫。”。选择 start i=0 与 context length T=4;x 读四个连续 IDs,y 从下一个位置读同样四个 IDs。
Scroll horizontally to view all columns.
| stream index | ID | token |
|---|---|---|
| 0 | 0 | <BOS> |
| 1 | 4 | 我 |
| 2 | 5 | 喜欢 |
| 3 | 6 | AI |
| 4 | 7 | , |
| 5 | 6 | AI |
| 6 | 8 | 也 |
| 7 | 5 | 喜欢 |
| 8 | 9 | 猫 |
| 9 | 10 | 。 |
| 10 | 1 | <EOS> |
x = s[0:4] = [0,4,5,6] = [<BOS>, 我, 喜欢, AI] shape [T]=[4]
y = s[1:5] = [4,5,6,7] = [我, 喜欢, AI, ,] shape [T]=[4]Scroll horizontally to view all columns.
| position t | input token | target next token | causal model may use |
|---|---|---|---|
| 0 | <BOS> | 我 | [<BOS>] |
| 1 | 我 | 喜欢 | [<BOS>, 我] |
| 2 | 喜欢 | AI | [<BOS>, 我, 喜欢] |
| 3 | AI | , | [<BOS>, 我, 喜欢, AI] |
Knowledge check
在 i=0 的 example 中,input ID 5(喜欢)对应哪个 target?
15. 构造一个 Batch:随机选择 Rows,Rows 内保持连续
Python s[a:b] 包含位置 a,不包含 b,所以恰有 b−a 项。T=4 的一道训练序列需要连续五个源 ID:前四个给 input,后四个给 target,两段重叠三项。随机数的 high 也不包含上界;把最后合法起点先写出来,再决定 randint 的 high。
继续只用“我喜欢AI,AI也喜欢猫。”的 w09-readable-v1 stream。固定 B=2、T=4,并选择 legal starts [0,5];随机性只选择 row 从哪里开始,每个 row 内仍按原 stream 顺序前进。
starts = [0,5]
inputs = [
[0,4,5,6], # <BOS> 我 喜欢 AI
[6,8,5,9], # AI 也 喜欢 猫
] # [B,T] = [2,4]
targets = [
[4,5,6,7], # 我 喜欢 AI ,
[8,5,9,10], # 也 喜欢 猫 。
] # [B,T] = [2,4]Scroll horizontally to view all columns.
| batch row b | start | input sequence | target sequence |
|---|---|---|---|
| 0 | 0 | <BOS> 我 喜欢 AI | 我 喜欢 AI , |
| 1 | 5 | AI 也 喜欢 猫 | 也 喜欢 猫 。 |
import torch
def sample_batch(source: torch.Tensor, batch_size: int, context_length: int):
if source.ndim != 1:
raise ValueError("source must have shape [L]")
if len(source) <= context_length:
raise ValueError("source needs at least context_length + 1 IDs")
# torch.randint excludes high, so high=L-T yields starts 0..L-T-1.
starts = torch.randint(
low=0,
high=len(source) - context_length,
size=(batch_size,),
)
inputs = torch.stack(
[source[i : i + context_length] for i in starts.tolist()]
)
targets = torch.stack(
[source[i + 1 : i + context_length + 1] for i in starts.tolist()]
)
return inputs, targets
source = torch.tensor([0, 4, 5, 6, 7, 6, 8, 5, 9, 10, 1])
inputs, targets = sample_batch(source, batch_size=2, context_length=4)
assert inputs.shape == targets.shape == (2, 4)Knowledge check
为什么每个 row 要从 source 读取 T+1 个 IDs?
10. Special Tokens:由谁添加边界必须只有一个答案
在 Week 9 的可读快照中,0..3 是 reserved specials,内容 entries 从 4 开始。它们是 model-visible Vocabulary entries,不是输入中看见字符 <EOS> 就自动产生的魔法。是否显示、mask、停止生成或忽略 loss 都需要调用方与模型 API 明确约定。
Scroll horizontally to view all columns.
| ID | special token | role in w09-readable-v1 | typical handling |
|---|---|---|---|
| 0 | <BOS> | 文档起点 / 初始 context | encode_document 在最前添加一次 |
| 1 | <EOS> | 文档终点 | encode_document 在最后添加一次;generation 可把它当 stop candidate |
| 2 | <PAD> | 把不等长 rows 补成 rectangle | 通常在 attention / loss 中 mask |
| 3 | <UNK> | word-only fallback 的 unknown marker | 有损;不是 byte fallback 的必需品 |
Scroll horizontally to view all columns.
| API result for 我喜欢AI,AI也喜欢猫。 | tokens | IDs |
|---|---|---|
| encode_content(add_special_tokens=False) | [我,喜欢,AI,,,AI,也,喜欢,猫,。] | [4,5,6,7,6,8,5,9,10] |
| encode_document(add_special_tokens=True) | [<BOS>,我,喜欢,AI,,,AI,也,喜欢,猫,。,<EOS>] | [0,4,5,6,7,6,8,5,9,10,1] |
from w09_readable_v1 import (
BOS_ID,
EOS_ID,
W09_READABLE_V1,
encode_content,
encode_document,
)
text = "我喜欢AI,AI也喜欢猫。"
content_ids = encode_content(text)
document_ids = encode_document(text)
assert document_ids == [BOS_ID, *content_ids, EOS_ID]
assert W09_READABLE_V1.decode(
document_ids,
skip_special_tokens=True,
) == text
# Wrong: encode_document already owns both boundaries.
# duplicated = [BOS_ID, *encode_document(text), EOS_ID]Knowledge check
为什么 encode_document(text)+[EOS] 会破坏本节约定?
11. Padding 与 Attention Mask:矩形 Shape 不等于有效数据
Scroll horizontally to view all columns.
| 控制项 | 先问一句话 |
|---|---|
| causal mask | 这个位置能否偷看后面的真实答案? |
| padding mask | 这个位置是不是为了凑矩形而填进去的? |
| loss mask | 这一次预测有没有有效答案,是否要计分? |
三个控制可以同时存在,不能因名字里都有 mask 就交换用法。−100 只是本例“这题不计分”的记号,不属于词表,绝不能拿去查询 embedding。
保留主句“我喜欢AI,AI也喜欢猫。”作为 row 0,并用较短的“我喜欢AI。”作为 row 1 的 padding illustration。两者均由 w09-readable-v1 的 encode_document 产生;本表临时把 batch width 记作 T_pad=11,避免与后面 training-window T=4 混淆。
Scroll horizontally to view all columns.
| row | input_ids [T_pad=11] | attention_mask [T_pad=11] |
|---|---|---|
| 0: 我喜欢AI,AI也喜欢猫。 | [0,4,5,6,7,6,8,5,9,10,1] | [1,1,1,1,1,1,1,1,1,1,1] |
| 1: 我喜欢AI。 | [0,4,5,6,10,1,2,2,2,2,2] | [1,1,1,1,1,1,0,0,0,0,0] |
Scroll horizontally to view all columns.
| object | example value | job |
|---|---|---|
| PAD token ID | 2 | 占住矩形 tensor 的 unused slot |
| attention_mask | real=1, pad=0 | 禁止 query 把 PAD key 当作上下文;具体 API 还会处理 padded queries |
| causal mask | j≤t | 禁止任何 query 读取 future key,不判断 PAD |
| loss ignore index | -100 | 让 padded target 不计入 cross entropy |
接下来把规则落到一个较短的 T_mask=7 next-token batch。Row 0 从主句取连续八个 source IDs,形成七组 input→target;Row 1 的完整短文档只有六个 IDs,所以 input 补一个 PAD,同时把“EOS 后没有本文件内 next token”和 PAD 对应的 targets 都设为 -100。
Scroll horizontally to view all columns.
| row | input_ids [7] | attention_mask [7] | next-token targets [7] |
|---|---|---|---|
| 0: 主句 prefix | [0,4,5,6,7,6,8] | [1,1,1,1,1,1,1] | [4,5,6,7,6,8,5] |
| 1: 完整短文档 | [0,4,5,6,10,1,2] | [1,1,1,1,1,1,0] | [4,5,6,10,1,-100,-100] |
Scroll horizontally to view all columns.
| control | 它回答的问题 | row 1 的具体效果 |
|---|---|---|
| causal mask | query t 能否读取 future key j>t? | t=4(。)只能读取 key positions 0..4 |
| padding mask | query 能否把无效 PAD key 当作上下文? | 所有 query 都不能读取 position 6 的 PAD key |
| loss mask / ignore_index | 这个 position 的预测要不要计分并产生 gradient? | positions 5、6 的 targets=-100,不参与平均 loss |
import torch
import torch.nn.functional as F
input_ids = torch.tensor([
[0, 4, 5, 6, 7, 6, 8],
[0, 4, 5, 6, 10, 1, 2],
], dtype=torch.long)
attention_mask = torch.tensor([
[1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 0],
], dtype=torch.bool)
targets = torch.tensor([
[4, 5, 6, 7, 6, 8, 5],
[4, 5, 6, 10, 1, -100, -100],
], dtype=torch.long)
B, T = input_ids.shape
causal = torch.tril(torch.ones(T, T, dtype=torch.bool))
# allowed[b,t,j]: query t may read key j only when j is not
# in the future and row b says that key j is real rather than PAD.
allowed = causal.unsqueeze(0) & attention_mask[:, None, :]
assert tuple(allowed.shape) == (2, 7, 7)
assert allowed[1, 4].tolist() == [
True, True, True, True, True, False, False
]
valid_targets = targets.ne(-100)
assert int(valid_targets.sum()) == 12 # row 0: 7, row 1: 5
# Zero logits mean a uniform distribution over V=11 candidates.
# Cross Entropy averages only the 12 valid targets.
logits = torch.zeros(B, T, 11)
loss = F.cross_entropy(
logits.reshape(B * T, 11),
targets.reshape(B * T),
ignore_index=-100,
)
assert torch.isclose(loss, torch.log(torch.tensor(11.0)))Knowledge check
为什么 input_ids.shape=[2,11] 仍不足以说明这个 padded batch 正确?
6. 未见 Token:先决定信息如何保留
假设 w09-readable-v1 的 word-only variant 忘了把固定句中的 猫 放进 Vocabulary:它可以输出 <UNK> 的 ID 3,但所有未知片段都会坍缩到同一地址,无法 exact decode 猫。byte-capable fallback 则能保留 E7 8C AB,随后仍可重建原文。
Scroll horizontally to view all columns.
| policy | 猫 不可 direct lookup 时的 output | round-trip | cost / behavior |
|---|---|---|---|
| <UNK> | [3] | 有损:只能还原为 unknown marker | 短,但不同未知片段共享一个 ID |
| subword / byte fallback | 一个或多个已知 smaller-piece IDs | 若 normalization/byte policy lossless,可保留原始文字 | k 可能大于 1,sequence 变长 |
| explicit error | 不产生 IDs | 调用方必须处理失败 | 适合不允许自动 replacement 的严格输入 |
固定快照实际包含 猫,所以正常 encoding 是 ID 9。malformed byte sequence 是另一类问题:它需要 UTF-8 replacement/error policy,不能与“有效但未见的词”混为一谈。
Knowledge check
为什么 byte fallback 通常比把 猫 替换成 <UNK> 更少丢失信息?
7. BPE 的核心直觉:真的手算两轮 Merge
这是一个无 pre-token boundaries 的 toy byte-level BPE training:固定句的每对相邻 bytes 都可参与计数。只列频率至少为 2 的初始 pairs;(9C,E6) 跨越 喜 的最后 byte 与 欢 的第一 byte,并在两次 喜欢 中出现。
Scroll horizontally to view all columns.
| initial adjacent byte pair | count | why repeated |
|---|---|---|
| (41,49) | 2 | 两次 AI |
| (E5,96) | 2 | 两个 喜 的前两 bytes |
| (96,9C) | 2 | 两个 喜 的后两 bytes |
| (9C,E6) | 2 | 两次 喜→欢 的 code-point boundary |
| (E6,AC) | 2 | 两个 欢 的前两 bytes |
| (AC,A2) | 2 | 两个 欢 的后两 bytes |
Round 1
all six candidates tie at count 2
lexicographically smallest integer pair = (0x41, 0x49)
(41,49) -> new token 256, displayed [41 49]
... E6 AC A2 [41 49] EF BC 8C [41 49] E4 B9 9F ...
length: 31 - 2 = 29 symbolsRound 1 后必须重新计数。两个 [41 49] 的邻居分别不同,因此没有产生新的 repeated adjacent pair;其余五个 repeated pairs 仍各出现两次。
Scroll horizontally to view all columns.
| recount after round 1 | count |
|---|---|
| (96,9C) | 2 |
| (9C,E6) | 2 |
| (AC,A2) | 2 |
| (E5,96) | 2 |
| (E6,AC) | 2 |
Round 2
all five repeated pairs tie at count 2
lexicographically smallest integer pair = (0x96, 0x9C)
(96,9C) -> new token 257, displayed [96 9C]
喜 at each occurrence is now: E5 [96 9C]
length: 29 - 2 = 27 symbols
total after two rounds: 31 - 2 - 2 = 27下面的程序不是伪代码:它从固定句的 31 个 bytes 开始,按本节声明的 count 与 tie rule 训练两轮,再把这两条 frozen merges 应用于同一句文字。最后先展开每个 learned symbol 的 bytes,拼接完整 byte stream,再统一做 UTF-8 decode。
from collections import Counter
def count_pairs(symbols: list[int]) -> Counter:
return Counter(zip(symbols, symbols[1:]))
def choose_pair(pair_counts: Counter) -> tuple[int, int]:
highest_count = max(pair_counts.values())
tied_pairs = [
pair
for pair, count in pair_counts.items()
if count == highest_count
]
return min(tied_pairs) # lexicographic integer-pair tie break
def merge_non_overlapping(
symbols: list[int],
pair: tuple[int, int],
new_id: int,
) -> tuple[list[int], int]:
merged: list[int] = []
replacements = 0
cursor = 0
while cursor < len(symbols):
if (
cursor + 1 < len(symbols)
and (symbols[cursor], symbols[cursor + 1]) == pair
):
merged.append(new_id)
replacements += 1
cursor += 2
else:
merged.append(symbols[cursor])
cursor += 1
return merged, replacements
text = "我喜欢AI,AI也喜欢猫。"
training_symbols = list(text.encode("utf-8"))
history: list[tuple[tuple[int, int], int, int, int]] = []
assert len(training_symbols) == 31
for new_id in (256, 257):
pair_counts = count_pairs(training_symbols)
chosen_pair = choose_pair(pair_counts)
before_length = len(training_symbols)
training_symbols, replacements = merge_non_overlapping(
training_symbols,
chosen_pair,
new_id,
)
assert len(training_symbols) == before_length - replacements
history.append(
(chosen_pair, new_id, replacements, len(training_symbols))
)
assert history == [
((0x41, 0x49), 256, 2, 29),
((0x96, 0x9C), 257, 2, 27),
]
def encode_with_frozen_merges(
value: str,
merges: list[tuple[tuple[int, int], int, int, int]],
) -> list[int]:
symbols = list(value.encode("utf-8"))
for pair, new_id, _training_replacements, _training_length in merges:
symbols, _ = merge_non_overlapping(symbols, pair, new_id)
return symbols
encoded = encode_with_frozen_merges(text, history)
assert encoded == training_symbols
assert len(encoded) == 27
# A learned token stores a byte sequence, so decoding expands first.
token_bytes = {byte_id: bytes([byte_id]) for byte_id in range(256)}
for pair, new_id, _count, _length in history:
left, right = pair
token_bytes[new_id] = token_bytes[left] + token_bytes[right]
restored_bytes = b"".join(token_bytes[token_id] for token_id in encoded)
restored_text = restored_bytes.decode("utf-8", errors="strict")
assert restored_text == textScroll horizontally to view all columns.
| stage | selected pair | replacements | stream length | what changed |
|---|---|---|---|---|
| initial bytes | — | — | 31 | 只有 base byte IDs 0..255 |
| round 1 | (41,49)→256 | 2 | 29 | 两次 AI 各缩短一个 position |
| round 2 | (96,9C)→257 | 2 | 27 | 两次 喜 的后两个 bytes 各缩短一个 position |
| encoding | 按 256 再 257 的顺序应用 | 由输入决定 | 本句仍为 27 | 不重新训练、不改变 rules |
Knowledge check
为什么 Round 2 选择 (96,9C),而且此时 喜 仍不是一个 token?
5. Unicode 与 UTF-8:字符身份不等于序列化 Bytes
Scroll horizontally to view all columns.
| 单位或记号 | 人话说明 | 例子 |
|---|---|---|
| Unicode code point | 给一个字符身份编号 | “我”的编号写作 U+6211 |
| UTF-8 byte | 把字符存储/传输成每项 0–255 的字节 | “我”是三个字节 E6 88 91 |
| 0x / 十六进制 | 一种写整数的方法;A–F 表示 10–15 | 0x41=65,不是第 41 个 token |
| token ID | 分词器自己分配的词表地址 | 具体数值只在那份词表内有意义 |
第一遍会区分“字符数、字节数、token 数”就够了。U+6211、0xE6 等可交给 Python ord、encode、hex 验证;不要求心算进制转换。后面的集合、映射箭头也只是把输入范围和输出范围写短。
Unicode 给“我”分配 U+6211;UTF-8 将这个 code point 序列化为 E6 88 91。ASCII 的 A 与 I 各占一个 byte,而本句中的七个 Han code points 和两个全角标点各占三个 bytes,因此总计 21+4+6=31。
Scroll horizontally to view all columns.
| visible code point | Unicode | UTF-8 hex bytes | byte count |
|---|---|---|---|
| 我 | U+6211 | E6 88 91 | 3 |
| 喜 | U+559C | E5 96 9C | 3 |
| 欢 | U+6B22 | E6 AC A2 | 3 |
| A | U+0041 | 41 | 1 |
| I | U+0049 | 49 | 1 |
| , | U+FF0C | EF BC 8C | 3 |
| A | U+0041 | 41 | 1 |
| I | U+0049 | 49 | 1 |
| 也 | U+4E5F | E4 B9 9F | 3 |
| 喜 | U+559C | E5 96 9C | 3 |
| 欢 | U+6B22 | E6 AC A2 | 3 |
| 猫 | U+732B | E7 8C AB | 3 |
| 。 | U+3002 | E3 80 82 | 3 |
text = "我喜欢AI,AI也喜欢猫。"
utf8_bytes = text.encode("utf-8")
assert len(text) == 13
assert len(utf8_bytes) == 31
assert utf8_bytes.hex(" ").upper() == (
"E6 88 91 E5 96 9C E6 AC A2 41 49 EF BC 8C "
"41 49 E4 B9 9F E5 96 9C E6 AC A2 E7 8C AB E3 80 82"
)在变成 bytes 之前,还必须回答 normalization:哪些 Unicode 序列应被当作同一种输入?例如屏幕上都像 é 的文字,可以由一个 composed code point U+00E9 表示,也可以由 e(U+0065)加 combining acute accent(U+0301)表示;identity policy 会保留差异,NFC 会把后一种规范化成前一种。
Scroll horizontally to view all columns.
| visible text | code points before normalization | UTF-8 bytes | policy result |
|---|---|---|---|
| é(composed) | [U+00E9] | C3 A9 | identity 与 NFC 都保留 composed form |
| e + ◌́(decomposed) | [U+0065,U+0301] | 65 CC 81 | identity 保留两点;NFC 变为 U+00E9 |
| AI(full-width) | [U+FF21,U+FF29] | EF BC A1 EF BC A9 | NFKC 可变为 ASCII AI;NFC 不做这项兼容折叠 |
import unicodedata
composed = "é"
decomposed = "e\u0301"
assert composed != decomposed
assert [f"U+{ord(ch):04X}" for ch in composed] == ["U+00E9"]
assert [f"U+{ord(ch):04X}" for ch in decomposed] == [
"U+0065",
"U+0301",
]
assert composed.encode("utf-8").hex(" ").upper() == "C3 A9"
assert decomposed.encode("utf-8").hex(" ").upper() == "65 CC 81"
# NFC makes these two canonically equivalent spellings identical.
assert unicodedata.normalize("NFC", decomposed) == composed
# NFKC also performs compatibility folding, which is a stronger choice.
assert unicodedata.normalize("NFKC", "AI") == "AI"- token IDs
- expand learned symbols back to byte symbols
- join one byte sequence
- UTF-8 decoder with a defined error policy
- 我喜欢AI,AI也喜欢猫。
Knowledge check
为什么 A 与 我 不是各占一个 UTF-8 byte?
16. Tokenizer 不负责理解语言:它只定义离散接口
w09-readable-v1 把固定句中的 猫 映射为 9,只表示 ordered Vocabulary 的第 9 个地址。任何“猫常与喜欢共同出现”的 predictive pattern,都要由 token sequences、loss、backprop 与 neural parameters 学习;数字 9 和 BPE merge 本身不含这条知识。
- raw text 我喜欢AI,AI也喜欢猫。
- tokenizer → integer IDs [B,T]
- embedding lookup → learned floats [B,T,C]
- Transformer → contextual floats [B,T,C]
- LM head → logits [B,T,V]
- decode selected IDs → text
Scroll horizontally to view all columns.
| tokenizer responsibility | model / training responsibility |
|---|---|
| normalization and segmentation | learn useful continuous features from examples |
| ordered Vocabulary and ID lookup | combine left context with Attention / FFN |
| special-token and fallback policy | produce and update vocabulary logits via loss |
| decode IDs under fixed artifact | model probability, behavior, factuality and errors |
Knowledge check
系统可以在哪里学到 猫 与 喜欢 经常共同出现?
17. Week 9 最应该理解的 9 件事
- Token 是 tokenizer-defined compute unit,不一定是 word;一个 token 通常占一个 model position。
- Code point、UTF-8 byte 与 token 是不同单位;normalization 决定编码前是否折叠某些 Unicode 差异。
- Subword 在 Vocabulary size 与 sequence length 之间折中;token count 属于具体 tokenizer。
- Encode 把 text 映射到 IDs;decode 按 frozen tokenizer policy 返回 text。
- Tokenizer training 构建 artifacts;encoding 只应用它们,不改变它们。
- Tokenizer ID mapping、normalization 与 special-token policy 必须匹配 model checkpoint。
- Causal mask、padding mask 与 loss ignore mask 分别控制未来 keys、PAD keys 与需要计分的 targets。
- Token stream 通过右移一位提供 inputs 与 next-token targets。
- Batch 把连续、已右移的 windows stack 为 inputs,targets:[B,T]。
用 AI 自查第 5 与第 8 条:BPE training 学到 (41,49)→256 后,encoding 两次出现的 AI 只应用该 rule,不重新计数;在 i=0 的可读 stream example 中,AI 是 t=3 的 input,右移 target 是 ,。
Knowledge check
请用固定句中的 AI 解释第 5 与第 8 条。
18. Week 9 → Week 10:把同一批输入直接交给模型
主线统一使用 course_examples/course_data.py 中的 FIVE_WORD_TOKENIZER:我=0、喜欢=1、AI=2、学习=3、猫=4。按空格切分,无 BOS/EOS/PAD/UNK。原文仍是“我 喜欢 AI”“猫 喜欢 我”“我 学习 AI”;输入 [[0,1],[4,1],[0,3]],目标 [[1,2],[1,0],[3,2]]。
Scroll horizontally to view all columns.
| 材料 | 用途 | 是否直接给主线 MiniGPT |
|---|---|---|
| 五词 tokenizer + T=2 窗口 | Week 6–12 的逐步计算与机制演示 | 是。V=5,ID 含义一直不变。 |
| w09-readable-v1 / character-demo / toy-byte-bpe | 比较切词、未知字符、特殊 token 与字节合并 | 否。它们是独立实验,不重解释已有整数。 |
| 最终独立文档项目的字符 tokenizer | Week 12 明确开启一个新实验 | 使用同一模型类,但显式新建词表、配置和 checkpoint。 |
# 可独立运行:在 course_examples 目录中执行。
import torch
from course_data import FIVE_WORD_TOKENIZER, DEMO_DOCUMENTS, make_windows
x, y = make_windows(DEMO_DOCUMENTS, FIVE_WORD_TOKENIZER, block_size=2)
inputs = torch.tensor(x, dtype=torch.long)
targets = torch.tensor(y, dtype=torch.long)
print(inputs.tolist()) # [[0,1],[4,1],[0,3]]
print(targets.tolist()) # [[1,2],[1,0],[3,2]]
# Week 10 接入:logits, loss = model(inputs, targets)本章的独立切词示例没有消失:它们帮助你判断何时需要新方案,但不是主线代码的隐含前置。你不必实现工业 BPE,才能看懂 GPT 训练。下一周保留这些 inputs/targets,只把 Bigram 的一行查分换成 embedding、位置、多个 block 和输出层。
Knowledge check
从 Bigram 换成 MiniGPT 后,这六个 target ID 应该改变吗?