把第 1 篇的脊柱「拟合一个函数」特化成本系列真正的主角:拟合「下一个词的概率分布」。但模型只能理解数字,文字要先变成数,从最朴素的词袋,到能捕捉语义的词嵌入,再到从零手写 BPE 分词算法,一路铺好语言模型开跑前的最后一段地基。
第 1 篇:机器学习到底在学什么铲平了机器学习的地基:模型是被数据选出的函数,损失衡量好坏,梯度下降找最优参数。这一篇要做两件事:一是把这套地基演化到语言模型。语言模型要拟合的不是任意函数,而是「下一个词的概率分布」,这是贯穿全系列的脊柱第一次被正式写下定义。二是回答一个更基础的问题:文字要怎么变成数,才能塞进第 1 篇讲的那套机器(向量、矩阵、损失函数)里?
答案分两层。粗粒度地看,一段文字可以先变成词袋(bag of words)这样的稀疏计数向量,勉强够用但丢掉了语义;细粒度地看,每个词还能单独变成一个词嵌入(word embedding),一个稠密向量,语义相近的词离得近。但在这两层之前,还有一个更前置的问题——词从哪来?现代语言模型的答案是字节对编码(byte-pair encoding,BPE),本篇会从零手写这个算法。
第1篇的地基是 :给定输入 ,模型输出一个值 。房价预测是这样,垃圾邮件分类也是这样, 是一个标量或一个类别。语言模型要解决的问题表面上也能装进这个模板:输入一段文本,输出下一个词。但「下一个词」不是一个标量,而是词表(vocabulary,记作 ,语言模型能生成的所有词/子词的集合)里的一个选择,而且每个候选词的把握程度还不一样,例如「我今天很」后面接「开心」的可能性显然比接「轮胎」高,但接「轮胎」也不是绝对不可能(也许在讲一次爆胎的糟心事)。
把这种「不确定但有偏好」的选择用数学语言写出来,就是一个条件概率分布:给定前面的词(上下文,记作 ),下一个词 的概率是
这就是第1篇结尾埋下的脊柱,现在第一次写成公式。更完整的写法:给定一个长度为 的词元序列 ,语言模型计算的是
这个分布必须满足两条约束:任何词元的概率都非负,;词表里所有词元的概率加起来必须是 1,。这和第1篇式 (1.8) 里 sigmoid 把线性输出压到 、读作「属于正类的概率」是同一种性质,只是 sigmoid 只需要保证一个数落在 里,这里要保证整个词表上的一份「概率预算」刚好分完、一分不多一分不少。这就需要一个能同时处理多个候选、且输出自动归一化的函数,会正式引入它。
举一个五个词的迷你词表 的例子:给定上文 ,一个训练好的语言模型可能给出
五个数加起来正好是 1,「cool」拿到了最高的概率。这份「品味」就是模型从训练数据里学到的东西。这种只用前文预测下一个词、不看后文的模型,叫自回归语言模型(autoregressive language model),也叫因果语言模型(causal language model),本系列后面几篇(以及市面上几乎所有能对话、能续写的语言模型)都是这一种。
式 (2.2) 里的 从哪来?训练前它并不存在,需要从数据里估。估这个分布的方法,就是贯穿第 3–5 篇的主线:第 3 篇用最朴素的计数,第 4、5 篇换成神经网络。但在讨论「怎么估」之前,还有一个更前置的问题: 和 都是文字,机器只能理解数字,词元本身要先变成数字,而且这些数字最好还带点语义。这是本篇剩下部分的任务。
假设有一堆文档,想知道每篇讲的主题是「电影」、「音乐」还是「科学」,这便是第 1 篇讲过的分类问题的多分类版本:数据集 ,, 是类别数(这里是 3)。但 是一段文字,不是第1篇里现成的数字向量,第一步就卡住了。
最朴素的解法是词袋(bag of words,BoW)。给定一批文档组成的语料(corpus),流程分为两步:
举例,词表若是 ["a", "and", ..., "fun", ..., "listen", ...](26 个词),文档「Watching movies is great fun.」变成向量 ,对应「fun」、「great」、「is」、「movies」、「watching」这几维是 1,其余是 0。这样一批文档就变成了一个矩阵,行是文档、列是词表里的词元,叫文档-词元矩阵(document-term matrix,DTM)。
拿到 DTM 之后,第 1 篇那套机制就能直接套用了,但分类目标从「电影/音乐/科学」三选一变成词表里 个词元选一个。此时,第 1 篇的 sigmoid(只能输出一个概率,适合二分类)不够用了,需要能同时给多个候选打分、且打出来的分自动归一化成一个合法分布的函数——softmax:
其中, 是网络输出层的 维logit(激活函数之前的原始输出), 是要计算概率的类别下标。分子把第 个 logit 指数化,分母把所有类别的指数化 logit 加起来做归一化,这保证了 个输出非负且相加为 1,恰好是式 (2.2) 要求的那两条约束。
举例来说,若三个 logit 是 ,先分别指数化:、、,三者相加 ,于是 、、,因此第一类拿到最高的概率。
配套的损失函数是交叉熵(cross-entropy),衡量预测分布与真实分布的差距。真实标签用独热编码(one-hot encoding)表示:正确类别对应的维度是 1,其余是 0。设正确类别是 ,交叉熵化简后只剩一项:
是模型分给正确类别的概率。这正是第1篇二元交叉熵(式 1.9)在类别数 时的推广: 时式 (2.3) 退化回第1篇那个二分类的特例,softmax 也退化回 sigmoid。 说到底就是套用同一个 softmax,只是 换成了词表大小 ,但它可能是一个上万甚至上百万的数字,这也是本系列后面几篇不断要优化计算效率的原因之一。
一个两层前馈网络,第一层把 维输入压到隐藏维度,ReLU 引入非线性,第二层输出 个 logit,用 softmax + 交叉熵训练,就能做词袋分类。代码结构和第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 更灵活,能自由控制前向传播的每一步):
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( 个连续词元组成的片段,如「cat chased」是一个二元组/bigram)能挽回一部分词序信息,但词表会跟着膨胀,参数、数据需求都涨上去,第3篇讲计数语言模型时会正面碰到这个代价。
词袋还有一个更根本的问题:同义词无能。「movie」和「film」在词袋眼里是两个毫不相干的维度,模型必须分别学一套参数,哪怕它们几乎同义。要解决这个问题,需要一种表示方法,能让语义相近的词,数值上也相近,这就是词嵌入要做的事。
把词袋向量拆开看,每个词其实是一个独热向量(one-hot vector):一个长度为 的向量,对应词元那一维是 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}$,现在正式派上用场。)高。

学出这种嵌入的经典算法是 word2vec,本篇聚焦它的 skip-gram 变体。核心思想是:一个词的意思,可以由它常出现的上下文反推出来。给定一个词,训练模型去预测它周围的词(上下文词,context word),预测得好,说明模型抓住了这个词的用法,而用法相近的词自然会被推向相近的嵌入。
具体流程如下:给定一个跳字大小(skip-gram size,如 5,表示当前词前后各两个词落入上下文窗口),把语料切成一系列(中心词,上下文词)训练对。例如句子「professor alan turing's research advanced」,以「turing's」为中心词、跳字大小为 5,展开成 4 个训练对:
| 中心词(输入) | 上下文词(目标) | 位置 |
|---|---|---|
| turing's | professor | −2 |
| turing's | alan | −1 |
| turing's | research | +1 |
| turing's | advanced | +2 |
模型结构是两层:第一层是嵌入层(Chen 注:embedding layer,一个 $\lvert\mathcal{V}\rvert \times d$ 的矩阵,$d$ 是嵌入维度。),把中心词的独热向量压成一个 维稠密向量;第二层是输出层,把这个向量映回 维 logit,通过 softmax 得到「每个词是上下文词」的概率。损失函数与第 2 节的多分类交叉熵一样,只是这里的「类别数」就是词表大小(可能上万),四个训练对的损失分别算、再取平均,一次反向传播同时把梯度喂给这四个目标。
训练收敛后,输出层被丢弃,嵌入层本身就是要的东西:把一个词的独热向量喂进嵌入层,输出的 维向量就是这个词学到的嵌入。为什么这样能学出语义?因为「movie」和「film」这类近义词大量出现在相似的上下文里,训练时它们要预测的上下文词高度重叠,损失函数会持续把它们的嵌入向量推向彼此靠近,不需要人工标注「这两个词是同义词」,语义关系是从海量无标注文本的共现统计里自动浮现的。
词嵌入最出名的一个副产品,是嵌入空间支持类似向量算术的语义运算:。这一发现在当年是个转折点(Chen 注:出自 Mikolov et al. 2013 年提出 word2vec 的论文《Efficient Estimation of Word Representations in Vector Space》,是这一发现最初的展示实验。):它说明神经网络能把词的语义编码进一个可以做加减法的向量空间,为后来大语言模型能「理解」并操作语言概念埋下了最初的信号。
前两节假设词表已经现成,但问题是词表怎么来?最直接的想法是「按空格切词」,这正是第 2 节 tokenize 函数在做的事。但这个想法有硬伤:英语一个词的所有表面形式(surface form,如 do、does、doing、did)加起来能有几百万种;形态更复杂的语言更极端,芬兰语一个名词可以有 2000–3000 种变形。把每种变形都塞进词表,内存和计算都扛不住;更糟的是,推理时遇到训练时没见过的词形,照样是无解的 OOV 问题。
解法是把词切得更细,切成子词(subword),比如把「interesting」切成「interest」+「ing」。子词能让词表大小可控,同时几乎不会遇到真正的 OOV(生僻词可以拆成见过的子词片段的组合)。本节从零实现最常用的子词切分算法:字节对编码(byte-pair encoding,BPE)。
基础算法分为三步:
直接在大语料上暴力实现效率很低,因为每次合并后重新扫描整个语料太浪费。更高效的做法是先把词按空格切开、统计每个词的出现次数,后续的符号对统计直接在「词 → 计数」这张表上做,不用重新扫描原始语料。
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 最终是「分词版词 → 出现次数」的字典。
接下来统计相邻符号对的频次:
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 次,这正是「在计数表上统计」比「扫描整个语料」高效的地方。
拿到最高频的符号对后,把它在整个词表里合并成一个新符号:
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 训练循环:
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 记录的顺序,依次把能匹配上的符号对合并:
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 万,绝大多数常见词能在第一步「整词命中」里直接返回,不会真正触发这套慢速的子词合并路径。
这一篇做了两件事。先对第 1 篇的脊柱进行演化:语言模型不是在拟合任意一个函数,而是在拟合「下一个词的概率分布」。这是本系列真正的主线,后面每一篇都在回答「怎么估这个分布」或「怎么重塑这个分布」。再回答了一个更前置的问题:文字要先变成数,才能进入这套机制。词袋是最朴素的答案,够用但丢词序、丢语义;词嵌入用稠密向量修复了语义相近性;BPE 则解决了「词表里的词从哪来」,用子词而不是完整单词,词表可控,几乎不会遇到真正无法表示的词。
但本篇还没有回答核心问题: 具体怎么估? 下一篇给出第一个、也是最朴素的答案——数数。下一篇会实现一个真正能预测下一个词的计数语言模型:不用任何神经网络,纯粹靠统计训练语料里词元序列出现的频次,就能算出条件概率;还会碰到一个绕不开的麻烦:训练语料再大,也总有没见过的词元组合,下一篇会讲平滑(smoothing)技术怎么给这些「没见过的」组合一个非零的、合理的概率。数数能把语言模型的雏形跑起来,但天花板很低:维度灾难(curse of dimensionality)会让计数模型在长上下文面前彻底失效,而这正是第 4 篇要请出神经网络来解决的问题。