面向哲思的编程与架构
笔记哲思阅读动态搜索RSS 订阅
切换到深色模式
搜索
RSS 订阅
切换到深色模式
© 2026 Vic Chen. All rights reserved.CC BY-NC-ND 4.0
← 笔记
从预测一个词开始(二):词怎么变成数

从预测一个词开始(二):词怎么变成数

2026年3月5日5,93217分钟

把第 1 篇的脊柱「拟合一个函数」特化成本系列真正的主角:拟合「下一个词的概率分布」。但模型只能理解数字,文字要先变成数,从最朴素的词袋,到能捕捉语义的词嵌入,再到从零手写 BPE 分词算法,一路铺好语言模型开跑前的最后一段地基。


目录
  • TL;DR
  • 1. 脊柱演化:从「拟合一个函数」到「拟合一个分布」
  • 2. 词袋:最朴素的「文字变数字」
  • 3. 词嵌入:让语义相近的词,数值上也相近
  • 4. 字节对编码:词从哪来
  • 5. 收拢:地基铲平,词也变成了数
目录
  • TL;DR
  • 1. 脊柱演化:从「拟合一个函数」到「拟合一个分布」
  • 2. 词袋:最朴素的「文字变数字」
  • 3. 词嵌入:让语义相近的词,数值上也相近
  • 4. 字节对编码:词从哪来
  • 5. 收拢:地基铲平,词也变成了数
目录
  1. TL;DR
  2. 1. 脊柱演化:从「拟合一个函数」到「拟合一个分布」
  3. 2. 词袋:最朴素的「文字变数字」
  4. 3. 词嵌入:让语义相近的词,数值上也相近
  5. 4. 字节对编码:词从哪来
  6. 5. 收拢:地基铲平,词也变成了数
机器学习语言模型
相关文章
  • 01
    从预测一个词开始(一):机器学习到底在学什么2026/03
  • 02
    概率视角下的机器学习(三):贝叶斯概念学习与朴素贝叶斯2026/01
  • 03
    概率视角下的机器学习(二):概率论工具箱2026/01
← 上一篇从预测一个词开始(一):机器学习到底在学什么
下一篇 →给网站加朗读功能(一):从 Web Speech API 到云端 TTS 的完整实现

评论

© 2026 Vic Chen · 面向哲思的编程与架构CC BY-NC-ND 4.0

TL;DR

第 1 篇:机器学习到底在学什么铲平了机器学习的地基:模型是被数据选出的函数,损失衡量好坏,梯度下降找最优参数。这一篇要做两件事:一是把这套地基演化到语言模型。语言模型要拟合的不是任意函数,而是「下一个词的概率分布」p(next∣context)p(\text{next}\mid\text{context})p(next∣context),这是贯穿全系列的脊柱第一次被正式写下定义。二是回答一个更基础的问题:文字要怎么变成数,才能塞进第 1 篇讲的那套机器(向量、矩阵、损失函数)里?

答案分两层。粗粒度地看,一段文字可以先变成词袋(bag of words)这样的稀疏计数向量,勉强够用但丢掉了语义;细粒度地看,每个词还能单独变成一个词嵌入(word embedding),一个稠密向量,语义相近的词离得近。但在这两层之前,还有一个更前置的问题——词从哪来?现代语言模型的答案是字节对编码(byte-pair encoding,BPE),本篇会从零手写这个算法。


1. 脊柱演化:从「拟合一个函数」到「拟合一个分布」

第1篇的地基是 y=f(x)y=f(x)y=f(x):给定输入 xxx,模型输出一个值 yyy。房价预测是这样,垃圾邮件分类也是这样,yyy 是一个标量或一个类别。语言模型要解决的问题表面上也能装进这个模板:输入一段文本,输出下一个词。但「下一个词」不是一个标量,而是词表(vocabulary,记作 V\mathcal{V}V,语言模型能生成的所有词/子词的集合)里的一个选择,而且每个候选词的把握程度还不一样,例如「我今天很」后面接「开心」的可能性显然比接「轮胎」高,但接「轮胎」也不是绝对不可能(也许在讲一次爆胎的糟心事)。

ℹ
本篇开始正式使用词元(token)这个词,指分词后不可再分的最小单位;「词元」与「词」(word)不总是一回事。本篇第 4 节会看到,一个词可能被切成好几个词元。上下文明确时,本系列仍会混用「词」泛指词元,不做区分。

把这种「不确定但有偏好」的选择用数学语言写出来,就是一个条件概率分布:给定前面的词(上下文,记作 s\boldsymbol{s}s),下一个词 ttt 的概率是

Pr⁡(t∣s)(2.1)\Pr(t \mid \boldsymbol{s}) \tag{2.1}Pr(t∣s)(2.1)

这就是第1篇结尾埋下的脊柱,现在第一次写成公式。更完整的写法:给定一个长度为 LLL 的词元序列 s=(t1,t2,…,tL)\boldsymbol{s}=(t_1, t_2, \ldots, t_L)s=(t1​,t2​,…,t,语言模型计算的是

Pr⁡(t=tL+1∣s=(t1,t2,…,tL))(2.2)\Pr\big(t = t_{L+1} \mid \boldsymbol{s} = (t_1, t_2, \ldots, t_L)\big) \tag{2.2}Pr(t=tL+1​∣s=(t

这个分布必须满足两条约束:任何词元的概率都非负,Pr⁡(t∣s)≥0\Pr(t\mid\boldsymbol{s}) \ge 0Pr(t∣s)≥0;词表里所有词元的概率加起来必须是 1,∑t∈VPr⁡(t∣s)=1\sum_{t\in\mathcal{V}} \Pr(t\mid\boldsymbol{s}) = 1∑t∈V​Pr(t∣s。这和第1篇式 (1.8) 里 sigmoid 把线性输出压到 、读作「属于正类的概率」是同一种性质,只是 sigmoid 只需要保证一个数落在 里,这里要保证整个词表上的一份「概率预算」刚好分完、一分不多一分不少。这就需要一个能同时处理多个候选、且输出自动归一化的函数,会正式引入它。

✓
第1篇的 y=f(x)y=f(x)y=f(x) 和这里的 Pr⁡(t∣s)\Pr(t\mid\boldsymbol{s})Pr(t∣s) 不是两套体系,是同一套体系的两种输出形状。训练的动作完全一样:用损失函数衡量预测分布与真实分布的差距,用梯度下降调整参数。只是这里的「预测」从一个数字变成了一整个词表上的分布。训练 = 拟合这个分布,采样 = 从这个分布里取样,这句话会在后面几篇反复出现。

举一个五个词的迷你词表 V={are,cool,language,models,useless}\mathcal{V}=\{\text{are}, \text{cool}, \text{language}, \text{models}, \text{useless}\}V={are,cool,language,models,useless} 的例子:给定上文 s=(language,models,are)\boldsymbol{s}=(\text{language}, \text{models}, \text{are})s=(language,models,are),一个训练好的语言模型可能给出

Pr⁡(are∣s)=0.01,Pr⁡(cool∣s)=0.77,Pr⁡(language∣s)=0.02,Pr⁡(models∣s)=0.15,Pr⁡(useless∣s)=0.05\begin{aligned} \Pr(\text{are}\mid\boldsymbol{s}) &= 0.01, & \Pr(\text{cool}\mid\boldsymbol{s}) &= 0.77, \\ \Pr(\text{language}\mid\boldsymbol{s}) &= 0.02, & \Pr(\text{models}\mid\boldsymbol{s}) &= 0.15, \\ \Pr(\text{useless}\mid\boldsymbol{s}) &= 0.05 \end{aligned}Pr(are

五个数加起来正好是 1,「cool」拿到了最高的概率。这份「品味」就是模型从训练数据里学到的东西。这种只用前文预测下一个词、不看后文的模型,叫自回归语言模型(autoregressive language model),也叫因果语言模型(causal language model),本系列后面几篇(以及市面上几乎所有能对话、能续写的语言模型)都是这一种。

ℹ
还有一类掩码语言模型(masked language model),代表是 BERT:训练时随机遮住句子中间的词,用前后双向的上下文去猜被遮住的词,适合做文本分类、命名实体识别这类「理解」任务,但不适合逐词生成文本。本系列聚焦自回归语言模型,掩码语言模型不展开。

式 (2.2) 里的 Pr⁡(⋅∣⋅)\Pr(\cdot\mid\cdot)Pr(⋅∣⋅) 从哪来?训练前它并不存在,需要从数据里估。估这个分布的方法,就是贯穿第 3–5 篇的主线:第 3 篇用最朴素的计数,第 4、5 篇换成神经网络。但在讨论「怎么估」之前,还有一个更前置的问题:s\boldsymbol{s}s 和 ttt 都是文字,机器只能理解数字,词元本身要先变成数字,而且这些数字最好还带点语义。这是本篇剩下部分的任务。


2. 词袋:最朴素的「文字变数字」

假设有一堆文档,想知道每篇讲的主题是「电影」、「音乐」还是「科学」,这便是第 1 篇讲过的分类问题的多分类版本:数据集 {(xi,yi)}i=1N\{(\boldsymbol{x}_i, y_i)\}_{i=1}^N{(xi​,yi​)}i=1N​,, 是类别数(这里是 3)。但 是一段文字,不是第1篇里现成的数字向量,第一步就卡住了。

最朴素的解法是词袋(bag of words,BoW)。给定一批文档组成的语料(corpus),流程分为两步:

  1. 建词表:列出语料里出现过的所有独立词元,就是词表 V\mathcal{V}V;
  2. 文档转向量:每篇文档变成一个长度为 ∣V∣|\mathcal{V}|∣V∣ 的向量,每一维对应词表里的一个词元,取值是该词元在这篇文档里是否出现(或出现次数)。

举例,词表若是 ["a", "and", ..., "fun", ..., "listen", ...](26 个词),文档「Watching movies is great fun.」变成向量 x=[0,0,…,1,…]T\boldsymbol{x}=[0,0,\ldots,1,\ldots]^Tx=[0,0,…,1,…]T,对应「fun」、「great」、「is」、「movies」、「watching」这几维是 1,其余是 0。这样一批文档就变成了一个矩阵,行是文档、列是词表里的词元,叫文档-词元矩阵(document-term matrix,DTM)。

ℹ
自然语言里词频服从齐夫定律(Zipf's law):一个词的出现频率与它的排名成反比(Chen 注:「排名」指按出现频率从高到低排序后的位次,最常见词排名第 1。齐夫定律说排名第 $k$ 的词频率约与 $1/k$ 成正比。),第二常见的词大约只有最常见词一半的频率。这意味着 DTM 天生稀疏(sparse),大部分格子是 0,只有少数高频词元占了大部分格子。

拿到 DTM 之后,第 1 篇那套机制就能直接套用了,但分类目标从「电影/音乐/科学」三选一变成词表里 ∣V∣|\mathcal{V}|∣V∣ 个词元选一个。此时,第 1 篇的 sigmoid(只能输出一个概率,适合二分类)不够用了,需要能同时给多个候选打分、且打出来的分自动归一化成一个合法分布的函数——softmax:

softmax(z,k)=defez(k)∑j=1Cez(j)\text{softmax}(\boldsymbol{z}, k) \overset{\text{def}}{=} \frac{e^{z^{(k)}}}{\sum_{j=1}^{C} e^{z^{(j)}}}softmax(z,k)=def∑j

其中,z\boldsymbol{z}z 是网络输出层的 CCC 维logit(激活函数之前的原始输出),kkk 是要计算概率的类别下标。分子把第 kkk 个 logit 指数化,分母把所有类别的指数化 logit 加起来做归一化,这保证了 CCC 个输出非负且相加为 1,恰好是式 (2.2) 要求的那两条约束。

举例来说,若三个 logit 是 z=[2.0,1.0,0.5]T\boldsymbol{z}=[2.0,1.0,0.5]^Tz=[2.0,1.0,0.5]T,先分别指数化:e2.0≈7.39e^{2.0}\approx7.39e2.0≈7.39、e1.0≈2.72e^{1.0}\approx2.72、,三者相加 ,于是 、、,因此第一类拿到最高的概率。

配套的损失函数是交叉熵(cross-entropy),衡量预测分布与真实分布的差距。真实标签用独热编码(one-hot encoding)表示:正确类别对应的维度是 1,其余是 0。设正确类别是 ccc,交叉熵化简后只剩一项:

loss(y^,y)=−log⁡(y^(c))(2.3)\text{loss}(\hat{\boldsymbol{y}}, \boldsymbol{y}) = -\log\big(\hat{y}^{(c)}\big) \tag{2.3}loss(y^​,y)=−log(y^​

y^(c)\hat{y}^{(c)}y^​(c) 是模型分给正确类别的概率。这正是第1篇二元交叉熵(式 1.9)在类别数 C>2C>2C>2 时的推广:C=2C=2C=2 时式 (2.3) 退化回第1篇那个二分类的特例,softmax 也退化回 sigmoid。 说到底就是套用同一个 softmax,只是 换成了词表大小 ,但它可能是一个上万甚至上百万的数字,这也是本系列后面几篇不断要优化计算效率的原因之一。

一个两层前馈网络,第一层把 ∣V∣|\mathcal{V}|∣V∣ 维输入压到隐藏维度,ReLU 引入非线性,第二层输出 CCC 个 logit,用 softmax + 交叉熵训练,就能做词袋分类。代码结构和第1篇的逻辑回归几乎一样:

片段 1. 词袋分类器的数据准备
import re, torch, torch.nn as nn
 
def tokenize(text):
    return re.findall(r"\w+", text.lower())  # 提取单词、转小写
 
def get_vocabulary(texts):
    tokens = {token for text in texts for token in tokenize(text)}
    return {word: idx for idx, word in enumerate(sorted(tokens))}







tokenize 用正则 \w+ 把文本切成单词并转小写;get_vocabulary 把语料里所有独立词元排序后编号,形成词表;doc_to_bow 把一篇文档转成词表长度的 0/1 向量。模型定义换成 nn.Module 的写法(比第1篇用的 nn.Sequential 更灵活,能自由控制前向传播的每一步):

片段 2. 两层前馈分类器
class SimpleClassifier(nn.Module):
    def __init__(self, input_dim, hidden_dim, output_dim):
        super().__init__()
        self.fc1 = nn.Linear(input_dim, hidden_dim)
        self.relu = nn.ReLU()
        self.fc2 = nn.Linear(hidden_dim, output_dim)
 
    def forward(self, x):
        x = self.fc1(x)   # (N, |V|) → (N, hidden_dim)
        x = self

forward 里没有手动加 softmax,这是因为 PyTorch 的 nn.CrossEntropyLoss 内部把 softmax 和交叉熵合并计算,数值上更稳定,训练循环与第1篇逻辑回归的四行完全一致(zero_grad → 前向算损失 → backward → step)。

词袋能运作,但硬伤也很明显:丢词序。「the cat chased the dog」和「the dog chased the cat」意思相反,词袋表示却完全相同:两句话用的词一样,只是顺序换了。n-gram(nnn 个连续词元组成的片段,如「cat chased」是一个二元组/bigram)能挽回一部分词序信息,但词表会跟着膨胀,参数、数据需求都涨上去,第3篇讲计数语言模型时会正面碰到这个代价。

词袋还有一个更根本的问题:同义词无能。「movie」和「film」在词袋眼里是两个毫不相干的维度,模型必须分别学一套参数,哪怕它们几乎同义。要解决这个问题,需要一种表示方法,能让语义相近的词,数值上也相近,这就是词嵌入要做的事。


3. 词嵌入:让语义相近的词,数值上也相近

把词袋向量拆开看,每个词其实是一个独热向量(one-hot vector):一个长度为 ∣V∣|\mathcal{V}|∣V∣ 的向量,对应词元那一维是 1,其余全是 0。独热向量有两个问题:一是信息量几乎为零,一个词只对应一个孤零零的 1,模型学不到任何「这个词像什么」的线索。二是对词表里没出现过的词(未登录词,out-of-vocabulary,OOV)完全没办法表示,只能置零,等于丢掉整句话的一部分语义。

词嵌入(word embedding)换了一种表示:不用稀疏的独热向量,而是给每个词学一个稠密向量(dense vector,大部分维度非零),维度通常是 100–1000(远小于词表大小),语义相近的词,嵌入向量余弦相似度(cosine similarity)(Chen 注:第 1 篇里点积除以两个向量范数的那个公式,$\cos\theta = \frac{\boldsymbol{x}\cdot\boldsymbol{y}}{\lVert\boldsymbol{x}\rVert\lVert\boldsymbol{y}\rVert}$,现在正式派上用场。)高。

图1. word2vec 在约 1000 亿词的新闻语料上训练出的国家-首都词嵌入
注:用主成分分析投影到二维,语义相近的词聚在一起,城市到首都的连线近乎平行。
(图片来源:Andriy Burkov, The Hundred-Page Language Models Book, §2.2)
图1. word2vec 在约 1000 亿词的新闻语料上训练出的国家-首都词嵌入
注:用主成分分析投影到二维,语义相近的词聚在一起,城市到首都的连线近乎平行。
(图片来源:Andriy Burkov, The Hundred-Page Language Models Book, §2.2)

学出这种嵌入的经典算法是 word2vec,本篇聚焦它的 skip-gram 变体。核心思想是:一个词的意思,可以由它常出现的上下文反推出来。给定一个词,训练模型去预测它周围的词(上下文词,context word),预测得好,说明模型抓住了这个词的用法,而用法相近的词自然会被推向相近的嵌入。

具体流程如下:给定一个跳字大小(skip-gram size,如 5,表示当前词前后各两个词落入上下文窗口),把语料切成一系列(中心词,上下文词)训练对。例如句子「professor alan turing's research advanced」,以「turing's」为中心词、跳字大小为 5,展开成 4 个训练对:

中心词(输入)上下文词(目标)位置
turing'sprofessor−2
turing'salan−1
turing'sresearch+1
turing'sadvanced+2

模型结构是两层:第一层是嵌入层(Chen 注:embedding layer,一个 $\lvert\mathcal{V}\rvert \times d$ 的矩阵,$d$ 是嵌入维度。),把中心词的独热向量压成一个 ddd 维稠密向量;第二层是输出层,把这个向量映回 ∣V∣\lvert\mathcal{V}\rvert∣V∣ 维 logit,通过 softmax 得到「每个词是上下文词」的概率。损失函数与第 2 节的多分类交叉熵一样,只是这里的「类别数」就是词表大小(可能上万),四个训练对的损失分别算、再取平均,一次反向传播同时把梯度喂给这四个目标。

✓
同一个输入(如「turing's」)在不同训练对里,模型的输出其实完全一样,差别只在损失怎么算,因为每次对比的目标词不同。这也是为什么同样的问题问 chat 语言模型,答案有时会不一样:模型本身是确定性的,真正引入随机性的是生成时的采样(sampling)步骤,第 5 篇会讲。

训练收敛后,输出层被丢弃,嵌入层本身就是要的东西:把一个词的独热向量喂进嵌入层,输出的 ddd 维向量就是这个词学到的嵌入。为什么这样能学出语义?因为「movie」和「film」这类近义词大量出现在相似的上下文里,训练时它们要预测的上下文词高度重叠,损失函数会持续把它们的嵌入向量推向彼此靠近,不需要人工标注「这两个词是同义词」,语义关系是从海量无标注文本的共现统计里自动浮现的。

词嵌入最出名的一个副产品,是嵌入空间支持类似向量算术的语义运算:king−man+woman≈queen\text{king} - \text{man} + \text{woman} \approx \text{queen}king−man+woman≈queen。这一发现在当年是个转折点(Chen 注:出自 Mikolov et al. 2013 年提出 word2vec 的论文《Efficient Estimation of Word Representations in Vector Space》,是这一发现最初的展示实验。):它说明神经网络能把词的语义编码进一个可以做加减法的向量空间,为后来大语言模型能「理解」并操作语言概念埋下了最初的信号。

ℹ
word2vec 不是唯一的词嵌入算法,GloVe 用全局词共现统计、FastText 引入子词信息,各有侧重,本系列不展开。此外,词嵌入并非从虚空中生造语义关系。1985 年普林斯顿的 WordNet 就已经尝试用人工编写的同义词集合和语义链接来组织词语,但这种手工构建的方式没法扩展到大词表,也没法捕捉训练语料里那些细微、连人都未必总结得出的用词规律。词嵌入是让机器从数据里自己学出这种关系,不再依赖人工编目。

4. 字节对编码:词从哪来

前两节假设词表已经现成,但问题是词表怎么来?最直接的想法是「按空格切词」,这正是第 2 节 tokenize 函数在做的事。但这个想法有硬伤:英语一个词的所有表面形式(surface form,如 do、does、doing、did)加起来能有几百万种;形态更复杂的语言更极端,芬兰语一个名词可以有 2000–3000 种变形。把每种变形都塞进词表,内存和计算都扛不住;更糟的是,推理时遇到训练时没见过的词形,照样是无解的 OOV 问题。

解法是把词切得更细,切成子词(subword),比如把「interesting」切成「interest」+「ing」。子词能让词表大小可控,同时几乎不会遇到真正的 OOV(生僻词可以拆成见过的子词片段的组合)。本节从零实现最常用的子词切分算法:字节对编码(byte-pair encoding,BPE)。

ℹ
BPE 最初是一种数据压缩算法,后来被移植到 NLP:把词当成字符序列,反复合并最高频的相邻符号对,直到词表达到目标大小。

基础算法分为三步:

  1. 初始化:把语料里每个词拆成单字符,初始词表就是语料里出现过的所有独立字符;
  2. 迭代合并:统计所有相邻符号对的出现次数,把出现最多的一对合并成一个新符号,加入词表;
  3. 重复,直到词表达到目标大小,或没有可合并的对为止。

直接在大语料上暴力实现效率很低,因为每次合并后重新扫描整个语料太浪费。更高效的做法是先把词按空格切开、统计每个词的出现次数,后续的符号对统计直接在「词 → 计数」这张表上做,不用重新扫描原始语料。

片段 3. 初始化词表
from collections import defaultdict
import re
 
def initialize_vocabulary(corpus):
    vocabulary = defaultdict(int)
    charset = set()
    for word in corpus:
        word_with_marker = '_' + word          # ① 加词首边界标记
        characters = list(word_with_marker)    # ② 拆成单字符
        charset.update(characters)             # ③ 收集字符集
        tokenized_word 

行 ① 给每个词加上下划线 _ 作为词首边界标记,这样「restart」里的「re」和「agree」里的「re」能被区分为「词首 re」(_re)和「词中 re」。重建句子时,遇到带 _ 前缀的词元就知道前面要加空格。行 ②–④ 把词拆成单字符、记录字符集、用空格连接成「分词版」字符串(如「hello」变成_ h e l l o)。vocabulary 最终是「分词版词 → 出现次数」的字典。

接下来统计相邻符号对的频次:

片段 4. 统计相邻符号对频次
def get_pair_counts(vocabulary):
    pair_counts = defaultdict(int)
    for tokenized_word, count in vocabulary.items():
        tokens = tokenized_word.split()           # ① 还原成符号列表
        for i in range(len(tokens) - 1):
            pair = (tokens[i], tokens[i + 1])     # ② 相邻符号对
            pair_counts[pair] += count            # ③ 按词频加权计数
    return

行 ① 把「分词版」字符串按空格拆回符号列表;行 ②–③ 遍历相邻符号对,按这个词在语料里的出现次数(而不是 1)累加。一个词出现 100 次,它内部的每个符号对也要算 100 次,这正是「在计数表上统计」比「扫描整个语料」高效的地方。

拿到最高频的符号对后,把它在整个词表里合并成一个新符号:

片段 5. 合并指定符号对
def merge_pair(vocabulary, pair):
    new_vocabulary = {}
    bigram = re.escape(' '.join(pair))                     # ① 转义特殊字符
    pattern = re.compile(r"(?<!\S)" + bigram + r"(?!\S)")  # ② 整词匹配
    for tokenized_word, count in vocabulary.items():
        new_tokenized_word = pattern.sub(""

行 ① 的 re.escape 把符号对中可能存在的正则特殊字符(如 .、*)转义成字面字符。行 ② 的 (?<!\S) 和 (?!\S) 是负向后顾和负向前瞻断言,确保匹配到的符号对前后都是空白或字符串边界,避免把「good」错误地匹配进「thisisgood」内部,只匹配独立存在的符号对。行 ③ 用 pattern.sub 把匹配到的符号对之间的空格去掉,完成合并(如把_ h e l l o里的(e, l)合并成_ h el l o)。

三个函数拼起来,就是完整的 BPE 训练循环:

片段 6. BPE 主循环
def byte_pair_encoding(corpus, vocab_size):
    vocabulary, charset = initialize_vocabulary(corpus)
    merges = []
    tokens = set(charset)
    while len(tokens) < vocab_size:               # ① 未达到目标词表大小
        pair_counts = get_pair_counts(vocabulary)
        if not pair_counts:                       # ② 无对可合并,提前结束
            break
        most_frequent_pair = max(pair_counts, key=pair_counts.get)  # ③ 取最高频对
        merges.append(most_frequent_pair)



merges 按顺序记录了每一次合并操作。这个顺序很重要,推理时要按同样的顺序重放。循环在词表达到 vocab_size(行 ①)或无对可合并(行 ②)时停止;每一轮取当前最高频的符号对(行 ③)、在整个词表里执行合并(行 ④)、把新符号计入词表(行 ⑤–⑥)。

ℹ
训练结束后拿到的四个输出:vocabulary(合并后的词频表)、merges(合并顺序)、charset(初始字符集)、tokens(最终符号集)。merges 和 charset 是推理时分词一个新词所需要的全部信息。

训练好之后怎么用:给一个新词,按 merges 记录的顺序,依次把能匹配上的符号对合并:

片段 7. 用训练好的 BPE 分词
def tokenize_word(word, merges, vocabulary, charset, unk_token="<UNK>"):
    word = '_' + word
    if word in vocabulary:
        return [word]
    tokens = [char if char in charset else unk_token for char in word]
 
    for left, right in merges:
        i = 0
        while i 




先加词首标记、检查整词是否已在词表里(常见词直接命中,不用拆);否则拆成单字符,不在字符集里的字符替换成<UNK>(未知符号占位);再按 merges 记录的顺序,逐条尝试把相邻符号对合并起来。用一个在新闻语料上训练、词表大小设为 5000 的分词器,处理句子「Let's proceed to the language modeling chapter.」,结果是:

分词结果示例
["_Let", "'", "s", "_proceed", "_to", "_the", "_language",
 "_model", "ing", "_chapter", "."]

「modeling」被拆成了「_model」+「ing」两个词元,说明它在训练语料里不算高频,词表大小设得又不大,没能给它单独留一个完整词元的位置。这里也能看出tokenize_word的效率问题:外层遍历所有 merges(可能几千条)、内层还要扫描当前词的所有符号对,双重循环。好在现代语言模型的词表往往超过 10 万,绝大多数常见词能在第一步「整词命中」里直接返回,不会真正触发这套慢速的子词合并路径。

✓
中文这类不用空格分词的语言,会跳过「先按空格切词」这一步,直接把整句拆成单字符,后续 BPE 流程完全一样,合并的对象从「字母对」变成「汉字对」。

5. 收拢:地基铲平,词也变成了数

图 2. 本篇内容结构
注:脊柱演化为 p(next|context),词袋提供最朴素的文字转数字方式,词嵌入解决语义相近性,BPE 解决词从哪来。

这一篇做了两件事。先对第 1 篇的脊柱进行演化:语言模型不是在拟合任意一个函数,而是在拟合「下一个词的概率分布」Pr⁡(t∣s)\Pr(t\mid\boldsymbol{s})Pr(t∣s)。这是本系列真正的主线,后面每一篇都在回答「怎么估这个分布」或「怎么重塑这个分布」。再回答了一个更前置的问题:文字要先变成数,才能进入这套机制。词袋是最朴素的答案,够用但丢词序、丢语义;词嵌入用稠密向量修复了语义相近性;BPE 则解决了「词表里的词从哪来」,用子词而不是完整单词,词表可控,几乎不会遇到真正无法表示的词。

但本篇还没有回答核心问题:Pr⁡(t∣s)\Pr(t\mid\boldsymbol{s})Pr(t∣s) 具体怎么估? 下一篇给出第一个、也是最朴素的答案——数数。下一篇会实现一个真正能预测下一个词的计数语言模型:不用任何神经网络,纯粹靠统计训练语料里词元序列出现的频次,就能算出条件概率;还会碰到一个绕不开的麻烦:训练语料再大,也总有没见过的词元组合,下一篇会讲平滑(smoothing)技术怎么给这些「没见过的」组合一个非零的、合理的概率。数数能把语言模型的雏形跑起来,但天花板很低:维度灾难(curse of dimensionality)会让计数模型在长上下文面前彻底失效,而这正是第 4 篇要请出神经网络来解决的问题。

L
​
)
1
​
,
t2​
,
…
,
tL​
)
)
(2.2)
)
=
1
(0,1)(0,1)(0,1)
(0,1)(0,1)(0,1)
第 2 节
∣
s
)
Pr(language∣s)
Pr(useless∣s)
​
=0.01,=0.02,=0.05​
Pr(cool∣s)Pr(models∣s)​
=0.77,=0.15,​
yi∈{1,…,C}y_i\in\{1,\ldots,C\}
yi​∈{1,…,C}
CCC
xi\boldsymbol{x}_ixi​
=
1
C
​
ez(j)
ez(k)
​
e1.0≈
2.72
e0.5≈1.65e^{0.5}\approx1.65e0.5≈1.65
≈11.76\approx11.76≈11.76
softmax(z,1)≈0.63\text{softmax}(\boldsymbol{z},1)\approx0.63softmax(z,1)≈0.63
softmax(z,2)≈0.23\text{softmax}(\boldsymbol{z},2)\approx0.23softmax(z,2)≈0.23
softmax(z,3)≈0.14\text{softmax}(\boldsymbol{z},3)\approx0.14softmax(z,3)≈0.14
(c)
)
(2.3)
Pr⁡(t∣s)\Pr(t\mid\boldsymbol{s})
Pr(t∣s)
CCC
∣V∣|\mathcal{V}|∣V∣
def doc_to_bow(doc, vocabulary):
tokens = set(tokenize(doc))
bow = [0] * len(vocabulary)
for token in tokens:
if token in vocabulary:
bow[vocabulary[token]] = 1
return bow
.relu(x)
x = self.fc2(x) # (N, hidden_dim) → (N, C)
return x # 返回 logit,不手动加 softmax
=
' '
.join(characters)
# ④ 空格连接
vocabulary[tokenized_word] += 1 # ⑤ 计数累加
return vocabulary, charset
pair_counts
.join(pair), tokenized_word)
# ③ 去空格合并
new_vocabulary[new_tokenized_word] = count
return new_vocabulary
vocabulary = merge_pair(vocabulary, most_frequent_pair) # ④ 执行合并
new_token = ''.join(most_frequent_pair) # ⑤ 合并后的新符号
tokens.add(new_token) # ⑥ 加入词表
return vocabulary, merges, charset, tokens
<
len
(tokens)
-
1
:
if tokens[i:i+2] == [left, right]:
tokens[i:i+2] = [left + right]
else:
i += 1
return tokens