模型不认识汉字,它从头到尾只在跟数字打交道

模型其实不认识字

上一篇我们读进了一份文字,也看清了它的字符表。但有件事必须先讲明白:模型从头到尾不认识汉字,也不认识字母。它内部全是数学运算——加法、乘法、矩阵——这些运算只能作用在数字上。

所以训练的第一道工序,是把文字翻译成数字。这道工序有个名字,叫编码(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%,只用来检查,不参与训练

为什么要留一份不给模型训练?因为我们要防一种叫"死记硬背"的情况。

模型训练久了,有可能不是真的学会了语言规律,而是把训练用的那些文字段落原样背了下来。一个只会背书的模型,在见过的内容上表现很好,一遇到没见过的就露馅。

验证集就是用来识破这种情况的。它里面的文字模型在训练时从没碰过。如果模型在训练集上表现好、在验证集上也好,说明它学到的是真规律;如果训练集上很好、验证集上很差,说明它在背书。这个差距,第七篇正式训练时我们会盯着看,它有个名字叫过拟合。

到这里,数据准备就全部完成了。我们把一份文字,变成了一条长长的整数序列,并分好了训练集和验证集。下一篇,这些数字就要第一次喂进模型,让训练循环转起来。

本篇要点

  • 模型只能做数字运算,文字必须先编码成数字才能进模型。
  • 给字符表里每个字符发一个号码,配上 stoiitos 两张对照表,就能写出 encodedecode 这对翻译函数。
  • 词表大小 vocab_size 是字符表的长度,模型每次预测就是在这么多候选里挑一个。
  • 被翻译的基本单位叫 token;我们用最简单的字符级(一字符一 token),真实模型用 BPE 子词分词,原理一致。
  • 整份语料编码后存成一个一维张量 data,这是训练要用的数据。
  • 把数据按 9:1 切成训练集和验证集,验证集用来识破模型"死记硬背"(过拟合)。

下一篇

数据备好了,下一篇我们搭第一个模型。它会非常笨——只看前一个字来猜下一个字——但这不要紧。第三篇的目标不是聪明,而是让"喂数据、算错误、拧旋钮"这个完整的训练循环第一次真正转起来。

参考资料

版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。

(采用 CC BY-NC-SA 4.0 许可协议进行授权)

本文标题:把文字变成数字:分词与编码

本文链接:https://www.sshipanoo.com/blog/ai/mini-gpt/02-把文字变成数字/

本文最后一次更新为 天前,文章中的某些内容可能已过时!