Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

文本如何变成数字

“请打印报告。”这六个字符,人一眼就能读懂。神经网络面对的却是矩阵:它不能直接把“请”乘上一个权重,也不知道“打印”该算一个整体还是两个字。

进入语言模型之前,我们需要完成两次转换:先把文本拆成带编号的小片段,再为每个编号取出一个向量。第一次决定模型看见哪些单位,第二次让这些单位进入可学习的计算。

1. 一个词元,到底有多大

模型处理的文本单位通常叫 Token,本书也称它为词元。它可能是一个字、一个词的一部分、一段字节,也可能是标点。不要把 Token 直接等同于中文词语或英文单词。

对“请打印报告。”,至少可以想出三种拆法:

方法示例会遇到什么问题
按字拆请 / 打 / 印 / 报 / 告 / 。单位简单,但序列可能较长
按词拆请 / 打印 / 报告 / 。需要词边界规则,陌生词怎样处理?
学习常用片段请 / 打印 / 报 / 告。片段由数据与算法决定,不一定符合词义

最后一行只是示意。换一份语料、一个词表大小或一组切分规则,结果就可能变化。

中文文本尤其提醒我们:空格不是可靠的词边界。“研究生命起源”可以切成“研究 / 生命 / 起源”,一味匹配更长片段也可能得到错误理解。模型分词器的目标通常是提供稳定、可用的编码,不承担完整的语义分析。

还要分清“字”在代码中的含义。Python 的 list(text) 按 Unicode 码点拆字符串;一个看起来完整的符号,可能由多个码点组成。带组合重音的字母、某些表情组合,都不能简单用屏幕上看见的字符数推断编码长度。

2. 编码之前,先约定怎样处理文本

看起来相同的文本,内部写法未必相同。例如 é 可以用一个码点表示,也可以写成 e 加组合重音。若直接统计,程序可能把它们当成不同片段。

Unicode 的 NFC 规范化可以统一这类规范等价的写法。本章还统一换行符,但保留空格和大小写。这样做是为了减少无意差异,同时尽量保留原文信息。

另一种常见形式 NFKC 会进一步合并一些兼容写法。例如全角“A”可以变成“A”。这对某些检索任务方便,却可能抹去排版或符号区别。规范化不是越强越好:代码中的大小写、缩进,数学中的特殊字符,都可能有用。Unicode 规范化说明给出了这些形式的准确边界。

也别随手删除标点。句号可以帮助模型认识边界;换行可能表示新段落,也可能属于代码结构。训练时怎样处理,使用模型时就要采用一致规则。

3. 从常见相邻片段中学出 BPE

如果词表只收录完整词语,新人名和新术语容易变成“没见过”。如果每次只处理最小字符,序列又可能很长。子词切分试着折中:常见片段合成较大的单位,不常见的部分继续拆小。

我们用一个微型 BPE 演示这个过程。BPE 是 Byte Pair Encoding 的缩写;本章为了容易观察,从字符开始合并,实际系统也可以从字节表示开始。

训练语料只有六句本书自编的打印提示:

打印机准备好了。
打印机正在打印。
请打印报告。
请打印照片。
打印报告。
打印照片。

先拆成字符,统计每一句内部相邻片段的出现次数。“打”和“印”相邻出现 7 次,于是把它们合成“打印”。重新统计,再选最常见的一对。每次合并后,统计单位都变了,不能一直沿用第一轮的计数。

图 1:这些规则由六句训练文本一起决定。本例同频时按字符串排序;有些轮次合并的是其他句子中的片段,因此当前句子不变。

训练结束后,合并顺序也要保存。处理新文本时,从字符出发,按已经学到的顺序执行这些合并。不能在每个新句子上重新统计、重新训练,否则相同文本可能得到与模型训练时不一致的编号。

本例允许标点参与合并,最后甚至得到“打印报告。”这个片段。它很好地说明:BPE 学的是重复模式,片段边界不保证是词义边界。实际分词器常另有预切分、边界规则或字节回退机制;这里没有复现某个现成大模型的完整分词器。

4. 词表是编号表,嵌入是参数表

把可用片段列出来,每个分配一个整数编号,就得到词表。编号只是查找位置:ID 为 20 的词元,并不比 ID 为 10 的词元“大一倍”,相邻编号也不意味着语义相近。

本章还保留四个特殊编号:

记号本例用途
<BOS>序列开始
<EOS>序列结束
<PAD>为批次补齐长度时预留的位置,本例暂不使用
<UNK>无法用当前字符词表表示的内容

这些是本例约定,不是所有模型共同使用的字符串。给模型加载权重时,词表、特殊编号和分词规则也要一起匹配。

接下来准备一个可学习的矩阵:

E∈RV×d E\in\mathbb R^{V\times d}

VV 是词表大小,dd 是每个词元向量的维数。词元编号为 ii,就取矩阵第 ii 行:

ei=E[i,:] \boldsymbol e_i=E[i,:]

这一步叫嵌入查表,也常直接称为 Embedding。它等价于用一个只有第 ii 位为 1 的独热行向量乘上 EE,但实际实现通常直接取行,不必真的创建很长的独热向量。

图 2:数值来自示例脚本同一种固定随机初始化,没有语义含义。图中只展示被选中的行;完整表有 25 行。

长度为 TT 的序列,查表结果是 T×dT\times d;一个批次有 BB 条等长序列时,结果是 B×T×dB\times T\times d。这就是后面神经网络接收到的数值输入。

这些向量通常和模型其他参数一起训练。训练可以让有用的关系出现在向量中,但不能把随机初始向量直接解释成词义。查表时同一个词元得到同一行;进入后续网络后,它在不同上下文中的表示还会变化。例如“苹果很甜”和“苹果发布产品”中的相同词元,后面的状态未必相同。

5. 陌生字符与字节,怎样选择

我们的字符表只从训练文本建立,没见过的“🙂”会变成 <UNK>。这样可以继续计算,却已经丢失原来的字符:解码只能恢复这个占位记号。

还有一种选择:先把文本编码成 UTF-8 字节。单个字节只有 256 种可能值,因此完整的字节基础表能表示任意合法 UTF-8 文本,不必为每个新字符增加一个基础编号。对于“茶🙂”,字节是:

[232, 140, 182, 240, 159, 153, 130]

两个码点用了七个字节。字节方案减少了字符覆盖问题,却可能增加序列长度;在字节基础上学习合并,可以把常见字节片段再组合起来。

单个字节片段未必能单独解码为完整字符,通常需要先拼接字节再解码。若模型自由生成了不合法的字节序列,还需要约定报错、替换或其他处理方式。“原文本可以无损编码”与“模型任意输出都构成合法文本”,是两件事。

词表大小和序列长度因此相互牵动:更多合并通常让训练语料的词元数减少,但嵌入表与输出分类层会随词表变大。一个“更短”的切分也未必带来更好的学习结果,还要看数据覆盖、训练和任务表现。

6. 把这些步骤连起来运行

脚本只使用 Python 标准库,可直接下载完整脚本:

python3 src/examples/tokenization.py
"""第 17 章:字符起步的微型 BPE、字节与嵌入查表;只用标准库。"""

from collections import Counter
from random import Random
import unicodedata


TEXTS = [
    "打印机准备好了。", "打印机正在打印。",
    "请打印报告。", "请打印照片。",
    "打印报告。", "打印照片。",
]
SPECIAL = ["<PAD>", "<BOS>", "<EOS>", "<UNK>"]


def normalize(text):
    # 保留大小写与空格,只统一换行和规范等价的 Unicode 写法。
    return unicodedata.normalize("NFC", text.replace("\r\n", "\n").replace("\r", "\n"))


def merge(tokens, pair):
    result, index = [], 0
    while index < len(tokens):
        if index+1 < len(tokens) and tuple(tokens[index:index+2]) == pair:
            result.append("".join(pair))
            index += 2
        else:
            result.append(tokens[index])
            index += 1
    return result


class TinyBPE:
    def __init__(self, texts, max_merges=6):
        sequences = [list(normalize(text)) for text in texts]
        self.base = sorted({token for tokens in sequences for token in tokens})
        self.merges, self.trace = [], []
        for _ in range(max_merges):
            counts = Counter(pair for tokens in sequences
                             for pair in zip(tokens, tokens[1:]))
            if not counts:
                break
            # 同频时按字符串排序,避免运行顺序改变结果。
            pair = min(counts, key=lambda p: (-counts[p], p))
            if counts[pair] < 2:
                break
            self.merges.append(pair)
            sequences = [merge(tokens, pair) for tokens in sequences]
            self.trace.append((pair, counts[pair], sum(map(len, sequences))))
        self.vocab = list(dict.fromkeys(SPECIAL+self.base+
                                       ["".join(p) for p in self.merges]))
        self.ids = {token: index for index, token in enumerate(self.vocab)}

    def tokenize(self, text):
        tokens = [c if c in self.base else "<UNK>" for c in normalize(text)]
        for pair in self.merges:
            tokens = merge(tokens, pair)
        return tokens

    def encode(self, text):
        tokens = ["<BOS>"]+self.tokenize(text)+["<EOS>"]
        return [self.ids[token] for token in tokens]

    def decode(self, ids):
        ignored = {"<PAD>", "<BOS>", "<EOS>"}
        return "".join(self.vocab[i] for i in ids if self.vocab[i] not in ignored)


def main():
    tokenizer = TinyBPE(TEXTS)
    for pair, count, total in tokenizer.trace:
        print(f"合并 {pair[0]} + {pair[1]},出现 {count} 次,训练文本剩 {total} 个词元")
    text = "请打印报告。"
    ids = tokenizer.encode(text)
    print(f"字符切分:{list(text)}")
    print(f"BPE 切分:{tokenizer.tokenize(text)}")
    print(f"含起止符的 ID:{ids},词表大小={len(tokenizer.vocab)}")
    print(f"解码结果:{tokenizer.decode(ids)}")
    print(f"未见字符:{tokenizer.decode(tokenizer.encode('打印🙂'))}")
    raw = "茶🙂".encode("utf-8")
    print(f"'茶🙂' 的 UTF-8 字节:{list(raw)},恢复={raw.decode('utf-8')}")
    same = normalize("e\u0301") == normalize("é")
    print(f"NFC 合并等价写法:{same}")
    print(f"NFC 保留全角:{normalize('A')};NFKC 转为:"
          f"{unicodedata.normalize('NFKC', 'A')}")
    rng = Random(7)
    table = [[rng.uniform(-1, 1) for _ in range(3)] for _ in tokenizer.vocab]
    vectors = [table[index] for index in ids]
    print(f"嵌入查表形状:{len(vectors)}×{len(vectors[0])}(随机值,未经训练)")
    assert tokenizer.decode(ids) == normalize(text)


if __name__ == "__main__":
    main()

输出为:

合并 打 + 印,出现 7 次,训练文本剩 31 个词元
合并 告 + 。,出现 2 次,训练文本剩 29 个词元
合并 打印 + 报,出现 2 次,训练文本剩 27 个词元
合并 打印 + 机,出现 2 次,训练文本剩 25 个词元
合并 打印 + 照,出现 2 次,训练文本剩 23 个词元
合并 打印报 + 告。,出现 2 次,训练文本剩 21 个词元
字符切分:['请', '打', '印', '报', '告', '。']
BPE 切分:['请', '打印报告。']
含起止符的 ID:[1, 18, 24, 2],词表大小=25
解码结果:请打印报告。
未见字符:打印<UNK>
'茶🙂' 的 UTF-8 字节:[232, 140, 182, 240, 159, 153, 130],恢复=茶🙂
NFC 合并等价写法:True
NFC 保留全角:A;NFKC 转为:A
嵌入查表形状:4×3(随机值,未经训练)

训练文本原有 38 个字符,六次合并后剩 21 个词元。这个数字不含起止符。我们保留原有基础字符和所有合并项,再加四个特殊记号,词表共有 25 项。

这份代码刻意没有加入复杂工程优化,但几个关键关系已经完整了:训练文本决定规则和编号,新文本复用规则,编号选择嵌入行,解码负责把片段接回文本。它还没有训练语言模型,因此也没有理由声称这些向量“理解”了打印任务。

7. 自测与小实验

  1. “一个 Token 就是一个中文词”为什么不成立?
  2. 词元 ID 相邻,能否推断它们意思接近?
  3. 本例“请打印报告。”为什么查出四个向量,而不是两个?
  4. 若 V=1000,d=32V=1000,d=32,嵌入矩阵有多少个可学习数值?长度为 20 的序列查表后是什么形状?
  5. 为什么词表和合并顺序需要随模型保存?
  6. 本例对“打印🙂”编码再解码,为什么恢复不了原文?
  7. 将 max_merges 分别设成 0、1、6,记录词表大小和训练文本词元数。哪一个变化意味着模型一定更好?
  8. 把“A”规范化为“A”,是否总是适合你的任务?
参考答案与提示
  1. 切分单位由编码方案决定,可能是字符、子词、字节或标点,也可能跨越词义边界。
  2. 不能。编号用于查表;关系要看学习后的表示和具体计算。
  3. 两个文本片段之外,还加入了 BOS 与 EOS。
  4. 有 32000 个数值;查表结果是 20×3220\times32。
  5. 权重依赖编号含义,分词又依赖合并规则与顺序。只保存权重不足以恢复同一计算过程。
  6. 表情被替换成 UNK,原字符信息已丢失。
  7. 本语料对应词表 19、20、25 项,文本 38、31、21 个词元。两种数量都不是性能保证,需要训练与评估。
  8. 不总是。它可能有利于统一输入,也可能删除任务需要保留的区别。

下一章,我们把这些单位放进一个真正的预测任务:给定前文,下一个词元可能是什么?

继续阅读:Sennrich 等人的子词 BPE 论文将这种合并思路用于神经机器翻译;SentencePiece介绍了可直接从原始句子训练的分词系统。本章代码展示核心思路,不是对这些系统全部行为的复刻。