小时候查字典,最怕遇到“生僻字”:部首目录里没有,拼音也读不准,整本翻过去还是不认识。早期的词嵌入就像一套“死记硬背的词典”——每个词都是一个独立条目,查得到就有向量,查不到就只能干瞪眼。后来有人想,为什么要死记整字呢?看到“氵”就知道和水有关,看到“青”就能猜出大概读音,“清”“晴”“情”都能猜个七八分。子词嵌入(Subword Embedding)就是把这种“偏旁部首识字法”教给机器:把一个英文单词拆成连续的小片段,为每个小片段学习一个向量,再把所有小片段向量加起来,当作整词向量。这样,模型从未见过的 readable,也能拆成 rea、ead、abl、ble 等熟悉的部件拼出大致含义。FastText 就是把这套方法落地的代表算法。接下来,我们就从“老词典哪里不好用”开始,一步步看机器如何学会用偏旁部首认生词。
掌握子词嵌入,是解决现实 NLP 中未登录词问题的关键一步,也是理解 FastText、BPE 等主流方案的地基,能直接提升你对词向量技术的工程落地能力。
可视化演示
展示系统处理流水线、实时性能指标曲线及多方案对比分析,包含数据流转、吞吐延迟监控与方案评估。
本节配有交互动效(进入视口后自动加载,约 3KB)。
系统可视化 处理流程:输入→处理→模型→输出 性能监控:实时指标 P=L×T
整词嵌入困境
想象你接手一座老式图书馆,管理员只认书脊上的唯一编号,每本书都单独登记。遇到一本新书,只要编号表里没有,管理员就两手一摊:查不了。Word2Vec 这类传统词嵌入就像这位管理员:它把语料里出现过的每个单词当成一个独立符号,在嵌入矩阵里给每个词分配一行向量。词与词之间没有任何内部结构可以借用,一个词就是一整块“死记硬背”的知识。
这种整词嵌入有一个明显痛点:如果某个词在训练语料中一次都没出现,也就是未登录词(OOV),模型就没有它的向量。哪怕这个词只是常见词的一个合法派生形式,比如 reader 在语料里没出现,模型也只能把它当成“陌生人”。同时,即使某个低频词出现了,由于样本太少,它的向量也学不扎实,数据稀疏效应很明显。原本 read、reader、reading 在形态上密切相关,但在整词嵌入看来,它们是三个毫无关系的编号,关于“读”的形态信息被完全丢掉了。
如果用一个数学式子描述整词嵌入,那就是把单词 \(w\) 直接映射成一个独立的稠密向量:
这就像旧词典只收完整汉字,遇到“氵”加“青”的组合字就查不到。既然整字查表有缺陷,老师们开始换一种思路:与其死背整字,不如先认偏旁部首。
- 词项 \(w\)代表语料中一个具体单词,比如
read或reading。 - 映射符号 \(\mapsto\)表示“查嵌入表”这个动作,把离散的单词 ID 变成连续向量。
- 嵌入向量 \(\mathbf{e}_w\)词 \(w\) 专属的稠密向量,维度通常是几十到几百。
- 向量空间 \(\mathbb{R}^d\)表示最终得到的向量生活在 \(d\) 维实数空间,\(d\) 是嵌入维度。
子词拆解思路
小学语文老师教生字时,不会让孩子把每个字都当成一幅画硬记,而是教他们拆偏旁、认部首:看到“氵”知道和水有关,看到“木”知道和树有关。子词嵌入借鉴的正是这个方法:不把单词当整体,而是把单词拆成连续的字符片段,这些片段就是机器眼中的“偏旁部首”。
具体做法是:先在单词首尾加上边界符号,再用一个长度为 \(n\) 的滑动窗口扫过整个单词,得到一组字符 n-gram。以 reading、\(n=3\) 为例,加入边界后可以切出 <re、rea、ead、adi、din、ing、ng> 这些片段。其中 <re 和 ng> 这样的边界片段能保留词首和词尾的形态线索,让前缀、后缀也有自己的“偏旁”。
用集合语言来描述,单词 \(w\) 的所有 n-gram 子词构成集合:
当模型把 readable 拆成 rea、ead、abl、ble 等片段时,会发现这些片段在训练语料中早就见过。它就像小学生第一次看到“湖”字,虽然整字没见过,但“氵、古、月”都是老朋友,组合在一起就能猜个八九不离十。
- 边界前缀 \(\text{<w}\)标记单词开头,让
rea这样的词首片段知道自己是前缀。 - 内部 n-gram \(c_i\cdots c_{i+n-1}\)从第 \(i\) 个字符开始连续取 \(n\) 个字符,覆盖词干部分。
- 下标范围 \(1 \le i \le \ell-n+1\)保证窗口不越界,\(\ell\) 是单词长度。
- 边界后缀 \(\text{/w>}\)标记单词结尾,保留
ing这类后缀信息。
模型聚合机制
偏旁部首有了,下一步是怎么把它们变成“字义”。每个偏旁本身并没有完整意义,但组合起来却能让人产生整体感觉:rea 让人想到“读”,ing 暗示进行时,ble 带有“可被……”的语义色彩。子词嵌入的做法类似:为子词表里的每个 n-gram 学习一个低维向量,再把一个词包含的所有子词向量逐维相加,得到这个词的向量。
这个聚合操作非常直接,公式如下:
在 FastText 的原始设计中,还会把整词向量也加进去,相当于在“偏旁组合”之外再保留一点“整字印象”。有了这套聚合机制,一个从未见过的词只要部件都熟悉,就能得到一个有意义的向量,这是整词嵌入做不到的。
- 子词 \(g\)单词 \(w\) 被拆出的一个具体部件,比如
rea或ing。 - 子词集合 \(G(w)\)包含单词 \(w\) 所有 n-gram 的集合,也就是部件清单。
- 子词向量 \(\mathbf{z}_g\)每个子词对应一个可学习的 \(d\) 维向量,相当于“偏旁的含义”。
- 求和符号 \(\sum\)把所有子词向量逐位相加,得到最终词向量 \(\mathbf{v}_w\)。
FastText实现
偏旁部首识字法听起来很美,但真要大规模教给机器,还需要一套完整的“学校制度”。FastText 就是这套制度:它在 Word2Vec 的 skip-gram 框架上做了改造,让中心词不再直接查整词向量,而是先拆成 n-gram,再聚合出词向量,然后用这个聚合向量去预测上下文词。
训练时,模型会同时学习两类参数:一类是每个字符 n-gram 对应的子词向量,另一类是每个上下文词对应的输出向量。给定中心词 \(w\),模型预测某个上下文词 \(o\) 出现的概率可以写成 softmax 形式:
词表很大时,分母要遍历所有词,计算代价太高,所以 FastText 实际使用负采样来近似训练:随机抽几个“负样本”词,让模型学会区分真正上下文和随机噪声。这套“学校制度”使得子词向量和上下文向量在训练中同步更新,最终沉淀下来的子词向量就成了机器的“偏旁部首库”。
- 中心词 \(w\)当前要预测上下文的词,它的向量由所有子词向量聚合得到。
- 上下文词输出向量 \(\mathbf{u}_o\)每个上下文词也有一个独立的输出向量,代表它作为“邻居”的身份。
- 点积 \(\mathbf{u}_o^\top \mathbf{v}_w\)计算输出向量与中心词向量的匹配分数。
- 分母遍历 \(j=1\) 到 \(V\)对词表里所有词做归一化,把匹配分数变成概率。
对比整词方案
如果说整词嵌入是一本只收整字的旧字典,那么 FastText 就是一本新式偏旁字典:生僻字也能按偏旁查到。这种差异在形态丰富的语言里尤其明显。像土耳其语、芬兰语这类语言,一个动词可能有几十种词尾变化,整词嵌入几乎不可能覆盖所有词形,而子词嵌入只要掌握词干和常见后缀,就能处理大量未见变体。
在罕见词和 OOV 处理上,子词嵌入的优势也很直观:即使某个词从未出现,只要它由已见过的子词片段组成,就能生成向量,还能与已知词比较相似度。拼写相近、共享词根的一些词,向量天然会更接近,这对信息检索和文本分类都很有帮助。
不过,任何选择都有代价。子词嵌入需要额外存储一个子词表,参数量明显上升。用公式对比一下:
新词典虽然厚了一点,但查生词的能力强了很多。对于很多实际问题来说,多占一点存储换回更低的 OOV 率,是一笔很划算的买卖。
- 整词表大小 \(|V|\)语料中不同单词的个数,Word2Vec 只需要为它们各存一个向量。
- 子词表大小 \(|S|\)所有字符 n-gram 的个数,通常远大于整词表。
- 向量维度 \(d\)每个整词或子词向量的长度。
- 两者之差 \((|V|+|S|)d - |V|d = |S|d\)这就是子词方案多出来的存储成本。
演进与挑战
偏旁部首识字法也并非完美。固定长度的 n-gram 就像“一刀切”的部首切分,有些片段切出来没有明确含义,还浪费存储空间。另一条技术路线是 BPE(Byte Pair Encoding),它不再穷举所有固定 n-gram,而是根据语料中的共现频率,反复合并最常见的字符对,逐步生成“数据驱动的偏旁部首”。SentencePiece 则进一步把空格也当作普通字符处理,让模型可以在多语言场景下统一切分。
这些演进说明:子词并不是越多越好,长度 \(n\) 和子词表大小需要仔细权衡。\(n\) 太小,部件太碎,难以表达词根语义;\(n\) 太大,子词表爆炸,部件变得过于具体而失去泛化能力。一个单词能拆出的 n-gram 数量也有简单规律:
无论是 FastText 的固定 n-gram,还是 BPE、SentencePiece 的自适应切分,本质上都是同一个思路:与其把每个词当成死记硬背的整块,不如让模型学会用可复用的“偏旁部首”去理解语言。这也是现代预训练语言模型处理海量词汇的底层智慧之一。
- 单词长度 \(\ell\)单词包含的字符数,是拆分的基础。
- 窗口长度 \(n\)每个子词片段包含的字符数,通常取 3 到 6。
- 加 3 的原因除了内部 n-gram 外,还要加上词首边界、词尾边界以及两者共同覆盖中间部分的片段。
AI 应用场景
电商搜索:长尾词和错拼召回
阶段:查询理解与召回
解决的问题:用户在电商平台输入 iphnoe、airpods pro 耳机套 这类错拼或长尾词时,整词匹配往往搜不到结果。子词嵌入可以把查询拆成子词片段,与商品标题中的 iphone、airpods 匹配,有效提升召回率。
具体案例:某跨境电商平台的搜索日志显示,约 8% 的查询包含拼写变体或罕见词形。接入基于 FastText 的子词向量后,搜索引擎先用子词向量召回候选商品,再用精排模型打分,错拼查询的点击率提升明显。
多语言文本分类:形态丰富语料
阶段:文本表征
解决的问题:土耳其语、芬兰语等语言词形变化极多,整词词表巨大且 OOV 率高。子词嵌入得益于共享词干和后缀,可以让分类模型在训练样本有限时也能理解未见词形。
具体案例:某社交平台要对几十种语言的用户举报文本做分类。使用 FastText 子词嵌入对文本向量取平均后送入线性分类器,土耳其语的分类 F1 值相比整词嵌入提升了约 6 个百分点,同时训练时间远短于深度模型。
语音助手意图识别:抵御生词干扰
阶段:意图识别
解决的问题:语音助手经常听到用户说出品牌名、地名或自定义昵称,这些词不一定在训练语料中。子词嵌入能把生词映射到与已知词相近的向量空间,从而稳定地识别“播放某首歌”“导航到某地”等意图。
具体案例:某车载语音助手在日志中发现 spotify、soundcloud 等应用名频繁作为 OOV 出现。引入子词嵌入后,即使这些词在训练时从未见过,模型也能通过 spo、ti、fy 等碎片获得相似表示,意图分类准确率提升了 4.2%。
Python 演示
import mathimport random class MiniFastText: def __init__(self, dim=8, n=3, seed=42): self.dim = dim self.n = n self.rng = random.Random(seed) self.char_vec = {} self.subword_vec = {} def _char_vector(self, ch): if ch not in self.char_vec: self.char_vec[ch] = [self.rng.uniform(-0.5, 0.5) for _ in range(self.dim)] return self.char_vec[ch] def _subword_vector(self, gram): if gram not in self.subword_vec: chars = gram.replace('<', '').replace('>', '') mean = [0.0] * self.dim for ch in chars: cv = self._char_vector(ch) for i in range(self.dim): mean[i] += cv[i] total = max(len(chars), 1) self.subword_vec[gram] = [v / total for v in mean] return self.subword_vec[gram] def ngrams(self, word): wrapped = '<' + word + '>' n = self.n return [wrapped[i:i + n] for i in range(len(wrapped) - n + 1)] def embed(self, word): vec = [0.0] * self.dim for gram in self.ngrams(word): gv = self._subword_vector(gram) for i in range(self.dim): vec[i] += gv[i] norm = math.sqrt(sum(v * v for v in vec)) return [v / norm for v in vec] if norm > 0 else vec def cosine(self, a, b): return sum(x * y for x, y in zip(a, b)) def main(): m = MiniFastText() candidates = ["reading", "reader", "listen", "listener", "quick", "quicker"] cand_vec = {w: m.embed(w) for w in candidates} new_word = "readable" new_vec = m.embed(new_word) print("subword pieces:", m.ngrams(new_word)) scored = sorted( candidates, key=lambda w: m.cosine(new_vec, cand_vec[w]), reverse=True ) for w in scored: print(w, round(m.cosine(new_vec, cand_vec[w]), 3)) if __name__ == "__main__": main()
readable 生成向量,并与多个已知词比较余弦相似度,直观展示“偏旁部首”聚合的效果。wrapped = '<' + word + '>'给单词加上边界符号,让词首和词尾也能参与 n-gram 拆分。return [wrapped[i:i + n] for i in range(len(wrapped) - n + 1)]用滑动窗口生成全部字符 n-gram。self.subword_vec[gram] = [v / total for v in mean]为每个子词生成一个向量,这里用字符向量的均值模拟子词向量。for gram in self.ngrams(word):遍历生词的所有子词部件,这是聚合的关键入口。for i in range(self.dim): vec[i] += gv[i]把每个子词向量的每一维相加,实现词向量聚合。norm = math.sqrt(sum(v * v for v in vec))对最终向量做 L2 归一化,方便计算余弦相似度。scored = sorted(... key=lambda w: m.cosine(new_vec, cand_vec[w]), reverse=True)按余弦相似度排序,找出最接近的已知词。
常见误区
误区很多初学者以为子词嵌入只是把 Word2Vec 的上下文窗口从整词改成字符 n-gram,核心方法没有变化。
正解子词嵌入改变的是“词的表示方式”,而不是窗口。Word2Vec 把每个整词当作一个不可拆分的 token,直接查表;FastText 在训练时把目标词拆成多个 n-gram,分别查子词向量后求和,再参与上下文预测。窗口大小描述的是上下文范围,和词内部是否拆解是两个维度。
为何常见:因为两者都叫“词向量”,训练目标又都来自 Word2Vec 的 skip-gram,初学者容易只看到框架相似,忽略内部结构变化。
误区有人以为只要用了子词嵌入,任何生词都能得到完美向量,OOV 问题从此消失。
正解子词嵌入只是大幅缓解 OOV。如果生词中的字符从未在训练语料中出现,或者生词由完全陌生的拼写片段组成,模型依然无法得到可靠向量。即使子词都见过,多个罕见片段聚合出来的向量质量也可能很差。因此在实际工程中仍需配合字符回退策略或标准化处理。
为何常见:因为 FastText 对很多常规派生词表现得很好,容易让人误以为它是万能钥匙,忽略了“可复用部件”这个前提。
误区初学者倾向认为 n-gram 长度越大,包含的上下文信息越多,所以子词嵌入效果一定越好。
正解n 过大时,每个子词更像一个完整单词,通用性下降,且子词表大小爆炸式增长;n 过小时,子词碎片丢失词根语义,区分度不足。通常 3 到 6 的 n 值在存储和泛化之间比较平衡,具体需要通过验证集调整。
为何常见:人很容易类比“信息越多越好”的直觉,忽略了子词嵌入的目标是复用和泛化,而不是记住更长的字符串。
误区有人觉得既然 FastText 也能生成词向量,那么它和 Word2Vec 的向量在下游任务中可以直接互换,效果大同小异。
正解两者度量相似度的偏好不同。子词向量对拼写和词形更敏感,所以 quick 和 quickly 会靠近;整词向量更强调上下文中的语义共现关系。在拼写纠错、低频词任务上子词向量更合适,在纯语义相似度任务上整词向量可能更稳定。替换前需要评估任务类型。
为何常见:两类向量都在“词嵌入”这个大概念下,接口长相相似,让人误以为语义性质也相同。
误区不少学习者把 FastText 的字符 n-gram 和 BPE 都叫“子词”,于是认为它们是同一种方法的两种叫法。
正解FastText 按固定窗口穷举字符 n-gram,子词表由字符组合自动产生;BPE 是数据驱动地从语料中统计字符对共现频率,反复合并高频片段来构造子词表。两者都能缓解 OOV,但切分逻辑和子词表生成方式完全不同。
为何常见:由于预训练模型大量使用 BPE,而 FastText 也用 subword 概念,初学者容易把“子词”这个大概念和具体算法混淆。
面试题
Q1. 遇到词典中没有的“未登录词”,整词嵌入会怎么处理?
- A.没有向量,只能丢弃或随机初始化← 正确答案
- B.自动从相邻词继承一个向量
- C.用偏旁拆分自动生成向量
- D.用上下文词向量平均代替
答案:A。整词嵌入把每个词当作独立符号,未登录词没有对应嵌入行,只能丢弃或随机初始化。B、C、D 都是子词嵌入或其他策略才能做到的事,不符合整词嵌入的机制。
Q2. 假设使用 3-gram 子词,并加入 < 和 > 边界标记,以下哪个不是 cat 的子词?
- A.
<ca - B.
cat - C.
at> - D.
ca>← 正确答案
答案:D。<cat> 的 3-gram 依次是 <ca、cat、at>,共 3 个。ca> 不符合连续 3 字符切分,因此不是合法子词。边界符号只能出现在首尾,不会同时挤在中间。
Q3. 子词嵌入最想解决的核心问题是什么?
- A.降低训练语料的大小
- B.处理生僻或未登录词← 正确答案
- C.提高词向量的维度
- D.避免使用神经网络
答案:B。子词嵌入通过部件复用让模型能推断未见词的向量,核心目标是缓解 OOV。它不负责压缩语料,也不会自动提升维度,仍然依赖神经网络学习向量。
Q4. FastText 中,一个词的最终向量通常如何得到?
- A.所有字符 n-gram 向量直接求和← 正确答案
- B.所有上下文词向量取平均
- C.按位置编码加权求和
- D.直接查整词表得到
答案:A。FastText 把词拆成多个 n-gram,每个 n-gram 查子词向量后求和得到词向量。它不直接查整词表,也不使用位置编码。上下文词向量是训练中的输出参数,不是词表示本身。
Q5. 哪种语言场景下,子词嵌入的优势最明显?
- A.英文这种形态简单的语言
- B.土耳其语这种形态丰富的语言← 正确答案
- C.只有数字组成的语言
- D.不存在自然语言
答案:B。形态丰富的语言词形变化多,整词表巨大且 OOV 率高,子词嵌入可以通过词干和词缀的复用处理未见词形。英文也有帮助,但优势不如土耳其语这类语言突出。
Q6. 以下哪项不是 FastText 子词方案的不足?
- A.子词表会占用更多存储空间
- B.遇到新字符依然无法处理
- C.无法为从未见过的词生成向量← 正确答案
- D.训练和存储成本明显增加
答案:C。FastText 的强项恰恰是能为由已知子词组成的未见词生成向量。A、B、D 都是真实不足:子词表带来额外空间开销,陌生字符无向量可用,训练成本也更高。
Q7. 设单词长度为 6、n=3,加上边界符号后大约可以切出多少个 n-gram?
- A.4 个
- B.5 个
- C.6 个← 正确答案
- D.8 个
答案:C。根据公式 \(|G_n(w)| = \ell - n + 3\),\(6 - 3 + 3 = 6\)。例如 abcdef 加边界后得到 <ab、abc、bcd、cde、def、ef> 共 6 个片段。
Q8. 子词嵌入能够带来哪些好处?
- A.可复用形态部件表示词← 正确答案
- B.能处理未收录的派生词← 正确答案
- C.词向量维度自动变小
- D.拼写相关的词向量更接近← 正确答案
答案:ABD。子词嵌入把词拆成可复用部件,派生词即使没出现过也能由部件拼出向量,且共享部件的词向量更接近。它不会改变用户设定的向量维度,C 错误。
Q9. 关于 BPE 子词切分,以下说法正确的有哪些?
- A.根据语料频率合并字符对← 正确答案
- B.能缓解未登录词问题← 正确答案
- C.与 n-gram 一样穷举全部片段
- D.完全不需要子词词表
答案:AB。BPE 反复合并高频字符对来构造子词表,因此也能缓解 OOV。它不是穷举所有固定长度片段,而是数据驱动地挑选片段,且仍然需要维护子词词表,所以 C、D 错误。
Q10. 训练 FastText 时,哪些向量会被更新?
- A.字符 n-gram 子词向量← 正确答案
- B.上下文词的输出向量← 正确答案
- C.词表中的 one-hot 编码
- D.语料库的分词结果
答案:AB。FastText 要同时学习子词向量和上下文输出向量,训练时梯度会更新这两类参数。one-hot 编码是固定输入,不参与更新;分词结果是预处理产物,也不是模型参数。