模型不认识汉字,它从头到尾只在跟数字打交道
模型其实不认识字
上一篇我们读进了一份文字,也看清了它的字符表。但有件事必须先讲明白:模型从头到尾不认识汉字,也不认识字母。它内部全是数学运算——加法、乘法、矩阵——这些运算只能作用在数字上。
所以训练的第一道工序,是把文字翻译成数字。这道工序有个名字,叫编码(encoding),它依赖的那张对照表,叫分词器(tokenizer)。这一篇就把这道工序做出来,并顺便把上一篇欠下的那个术语——token——还清。
给每个字符发一个号码
最直接的翻译办法是:把字符表里的每个字符,按顺序发一个号码。第 0 个字符是 0,第 1 个是 1,依此类推。
上一篇我们已经用 sorted(set(text)) 拿到了字符表 chars。现在给它配两张对照表:一张从字符查号码,一张从号码查字符。
with open("input.txt", "r", encoding="utf-8") as f:
text = f.read()
chars = sorted(set(text))
vocab_size = len(chars)
# stoi: string to int,字符 -> 号码
# itos: int to string,号码 -> 字符
stoi = {ch: i for i, ch in enumerate(chars)}
itos = {i: ch for i, ch in enumerate(chars)}
print(f"词表大小 vocab_size = {vocab_size}")
print(f"'你' 的号码是 {stoi['你']}") # 假设语料里有"你"这个字
print(f"号码 100 对应的字符是 '{itos[100]}'")
这里出现了一个关键数字:vocab_size,词表大小。它就是字符表的长度,也就是模型"认识"多少种不同的符号。中文语料因为汉字多,vocab_size 通常有几千。记住这个数字,后面搭模型时它会反复出现——模型每次预测下一个字,本质上就是在这 vocab_size 个候选里挑。
编码与解码:两个函数
有了两张对照表,就能写出一对互逆的函数。encode 把一串文字翻译成一串号码,decode 把号码翻译回文字。
def encode(s: str) -> list[int]:
"""文字 -> 号码列表"""
return [stoi[ch] for ch in s]
def decode(nums: list[int]) -> str:
"""号码列表 -> 文字"""
return "".join(itos[i] for i in nums)
# 验证一下:编码再解码,应该回到原文
sample = "今天天气真好"
encoded = encode(sample)
print(f"编码结果:{encoded}")
print(f"解码还原:{decode(encoded)}")
跑一下,你会看到 encode("今天天气真好") 变成了一串整数,再 decode 回来,又是原来那句话。这对函数就是模型和文字之间的翻译官:进模型之前用 encode,模型吐出号码之后用 decode 看结果。
还清那个术语:token
现在可以讲 token 了。
我们刚才做的,是把每一个字符当成一个翻译单位——一个汉字一个号码,一个标点一个号码。这种做法叫字符级(character-level)分词。我们这个迷你 GPT 全程用它,因为它最简单、最好理解。
但真正的大模型不这么做。它们用的是子词(subword)分词,最常见的算法叫 BPE(Byte Pair Encoding)。它的翻译单位不是单个字符,而是常一起出现的字符片段——可能是一个完整的词、一个词根,或半个词。比如英文的 "training" 可能被切成 "train" 和 "ing" 两个单位。
这种被翻译的基本单位,不管是一个字符还是一个片段,统称 token。上一篇说"模型预测下一个字",更准确的说法是"模型预测下一个 token"。在我们的字符级方案里,一个 token 恰好就是一个字符,所以你之前按"字"理解完全没错。
为什么真实模型要绕到子词这一步?两个原因。一是效率:用片段当单位,同样一句话拆出来的 token 数更少,模型要处理的序列更短、算得更快。二是词表大小的平衡:纯字符级的中文词表是几千,纯单词级的词表会爆炸到几十万还覆盖不全,子词正好卡在中间,几万个 token 就能拼出几乎所有文本。OpenAI 的 tiktoken 用的就是 BPE。
这些我们不用自己实现,知道"我们用的是最简单的字符级,真实模型用更聪明的 BPE,但它们都叫分词、产物都叫 token"就够了。原理完全一致。
把整份语料变成一个大张量
翻译官有了,下一步是把整份语料一次性编码好,存成 PyTorch 能直接用的形式——张量(tensor)。张量你可以先简单理解成"一串数字组成的数组",是 PyTorch 里所有运算的基本数据类型。
import torch
# 把整份文字编码成一个一维长张量
data = torch.tensor(encode(text), dtype=torch.long)
print(f"张量形状:{data.shape}") # 一维,长度等于总字符数
print(f"前 30 个号码:{data[:30]}")
dtype=torch.long 指定这些号码是整数。现在 data 就是整份语料的数字版,一条很长的整数序列。模型训练时,要用的就是它。
切出训练集和验证集
最后还有一步,看似多余,其实很重要:把 data 切成两份。前 90% 叫训练集,后 10% 叫验证集。
n = int(0.9 * len(data))
train_data = data[:n] # 前 90%,用来训练(拧旋钮)
val_data = data[n:] # 后 10%,只用来检查,不参与训练
为什么要留一份不给模型训练?因为我们要防一种叫"死记硬背"的情况。
模型训练久了,有可能不是真的学会了语言规律,而是把训练用的那些文字段落原样背了下来。一个只会背书的模型,在见过的内容上表现很好,一遇到没见过的就露馅。
验证集就是用来识破这种情况的。它里面的文字模型在训练时从没碰过。如果模型在训练集上表现好、在验证集上也好,说明它学到的是真规律;如果训练集上很好、验证集上很差,说明它在背书。这个差距,第七篇正式训练时我们会盯着看,它有个名字叫过拟合。
到这里,数据准备就全部完成了。我们把一份文字,变成了一条长长的整数序列,并分好了训练集和验证集。下一篇,这些数字就要第一次喂进模型,让训练循环转起来。
本篇要点
- 模型只能做数字运算,文字必须先编码成数字才能进模型。
- 给字符表里每个字符发一个号码,配上
stoi和itos两张对照表,就能写出encode和decode这对翻译函数。 - 词表大小
vocab_size是字符表的长度,模型每次预测就是在这么多候选里挑一个。 - 被翻译的基本单位叫 token;我们用最简单的字符级(一字符一 token),真实模型用 BPE 子词分词,原理一致。
- 整份语料编码后存成一个一维张量
data,这是训练要用的数据。 - 把数据按 9:1 切成训练集和验证集,验证集用来识破模型"死记硬背"(过拟合)。
下一篇
数据备好了,下一篇我们搭第一个模型。它会非常笨——只看前一个字来猜下一个字——但这不要紧。第三篇的目标不是聪明,而是让"喂数据、算错误、拧旋钮"这个完整的训练循环第一次真正转起来。
参考资料
版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。
(采用 CC BY-NC-SA 4.0 许可协议进行授权)
本文标题:把文字变成数字:分词与编码
本文链接:https://www.sshipanoo.com/blog/ai/mini-gpt/02-把文字变成数字/
本文最后一次更新为 天前,文章中的某些内容可能已过时!