模型自始至终只与数字打交道,它并不直接识别汉字

模型并不直接识别文字

上一篇我们读入了一份文字,也查看了它的字符表。在继续之前,需要先明确一件事:模型自始至终都不识别汉字,也不识别字母。它的内部全部是数学运算——加法、乘法、矩阵运算——而这些运算只能作用于数字。

因此,训练的第一道工序,是把文字转换成数字。这道工序称为编码(encoding),它所依赖的那张对照关系,称为分词器分词器分词器负责把文字切分成一个个 token,并在 token 与整数编号之间相互转换,是文字进入模型之前的第一道翻译工序。 (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词表大小vocabulary sizevocabulary sizeThe vocabulary size is the number of distinct tokens a model knows, equal to the length of its token list. It is fixed before training begins. Every time the model predicts the next token it is, in effect, choosing one candidate out of this many, so this number directly sets the width of the model output layer. A character-level Chinese corpus usually has a few thousand. 。它等于字符表的长度,也就是模型所"认识"的不同符号的总数。由于汉字本身数量较多,中文语料的 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)分词,但它并不是唯一的分词方式。按照"转换单位"的粗细,常见的分词方式有三种,对照如下:

分词方式转换单位词表大小(中文)特点代表
字符级(character-level)单个字符数千实现最简单,但同样文字切分出的序列较长本系列的迷你 GPT
子词(subword)经常一同出现的字符片段数万序列长度与词表大小之间的折中,主流选择BPE、OpenAI 的 tiktoken
单词级(word-level)完整的词数十万且难以覆盖序列最短,但词表过大、生僻词易遗漏早期的 NLP 模型

本系列的迷你 GPT 全程采用字符级分词,因为它最为简单、也最便于理解。

无论转换单位是一个字符、一个片段还是一个完整的词,这种被转换的基本单位都统称为 token。上一篇所说的"模型预测下一个字",更准确的表述是"模型预测下一个 token"。在我们采用的字符级方案中,一个 token 恰好对应一个字符,因此此前按"字"来理解并无偏差。

小贴士:真实大模型为何采用子词分词
真正的大模型大多采用子词分词,最常见的算法是 BPE(Byte Pair Encoding,字节对编码)。它的转换单位不是单个字符,而是经常一同出现的字符片段——可能是一个完整的词、一个词根,或者半个词,例如英文单词 "training" 可能被切分为 "train" 与 "ing"。 这样选择主要有两点原因。其一是效率:以片段为单位,同样一段文字切分出的 token 数量更少,模型需要处理的序列更短,计算更快。其二是词表大小的权衡:纯字符级方案的中文词表只有数千,纯单词级方案的词表会膨胀到数十万且仍难以覆盖全部词汇,子词分词恰好处于两者之间。这些算法我们不需要自己实现,理解到"字符级与子词只是转换单位的粗细不同,产物都称为 token、原理一致"即可。

把整份语料转换成一个张量

转换接口已经具备,下一步是把整份语料一次性编码完毕,并保存成 PyTorch 能够直接使用的形式——张量(tensor)。张量是 PyTorch 中所有运算的基本数据类型,在动手之前,有必要把它与几个相邻的概念区分清楚。

数学中描述"一组数",依据其组织方式有不同的名称。单独的一个数称为标量;若干个数排成一行称为向量;数排成行与列构成的二维表格称为矩阵。张量则是这一系列概念的统称与推广——它把标量、向量、矩阵纳入同一个框架,并允许继续向更高的维度扩展。下表把它们对照起来:

名称维度直观理解例子定位一个数所需的下标个数
标量(scalar)0 维单独的一个数3.140 个
向量(vector)1 维排成一行的一组数[2, 7, 1, 5]1 个
矩阵(matrix)2 维由行与列构成的数表[[2, 7], [1, 5]]2 个
张量(tensor)任意维上述概念的统称,并可推广到 3 维及以上见下文等于维度数

理解这张表的关键是"维度"这一栏。一个张量的维度,指的是要唯一定位其中的某一个数,需要几个下标。标量本身就是一个数,无需下标,是 0 维;向量取出一个数需要指明"第几个",用一个下标,是 1 维;矩阵需要同时指明"第几行、第几列",用两个下标,是 2 维;以此类推。维度更高的张量没有专门的中文名称,一律称为张量。

在编程的语境下,标量、向量、矩阵都只是张量的特例——PyTorch 把它们统一用 tensor 来表示,区别仅在于维度。本篇接下来构造的 data,是一条很长的一维张量(即一个向量);到第三篇,输入数据会变成二维张量;到第五篇之后,模型内部流动的是 (B, T, C) 形式的三维张量。掌握了"张量就是可以有任意多个维度的数组"这一点,后续不论遇到几维的数据都不会感到陌生。

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%,仅用于检验,不参与参数调整

为什么要专门留出一部分数据、不让模型用于训练。这是为了防范一种情况——模型并非真正掌握了语言规律,而是把训练所用的那些文字段落原样记诵了下来。一个仅靠记诵的模型,在它见过的内容上表现良好,一旦遇到未曾见过的内容便会暴露问题。

验证集正是用来识别这种情况的。验证集中的文字,模型在训练过程中从未接触。如果模型在训练集上表现良好、在验证集上同样表现良好,说明它学到的是可迁移的真实规律;如果在训练集上表现良好、在验证集上明显变差,则说明它更多是在记诵。这两者之间的差距,在第七篇正式训练时需要持续关注,它有一个专门的名称——过拟合overfittingoverfittingOverfitting is when a model stops learning the general patterns of language and instead memorizes the exact training text. Such a model scores well on data it has already seen and poorly on anything new. The held-out validation set exists precisely to detect this: a widening gap between strong training performance and weak validation performance is the signature of overfitting.

至此,数据准备工作全部完成。我们把一份文字,转换成了一条长整数序列,并划分好了训练集与验证集。下一篇起,这些数字将首次进入模型,使训练循环运转起来。

本篇要点

  • 模型只能进行数字运算,文字必须先编码成数字才能进入模型。
  • 为字符表中每个字符分配编号,并建立 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-把文字变成数字/

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