第 15 章 · 经典网络结构

词嵌入与 word2vec

不管是 RNN 还是后面的 Transformer,处理文字前都得先把词变成向量。 可“词”本是离散符号,计算机只认数字——怎么变出来的向量还带语义? 这一章把词嵌入(embedding)从 one-hot 的缺陷讲起,一直落到 word2vec 具体怎么训, 以及仓库里 TokenEmbedding 查表长什么样;读完后,第 19 章的 embedding 层、 第 17 章注意力里的点积,就都有根了。

读完这一章,你会明白

  • one-hot 为什么又浪费又正交无语义;
  • 词嵌入表 vocab_size × dim 是什么、查表在数学上等价于什么;
  • 余弦相似度 / 点积如何衡量“意思有多近”;
  • 分布假设:“看上下文”为何能学出语义;
  • skip-gram / CBOW 的网络有几层、每层是什么(不是黑盒);
  • CBOW / skip-gram 各怎么做完形填空、损失函数长什么样;
  • 负采样 为什么能让 word2vec 在百万词表上训得动;
  • 走一遍从句子抽样本 → 前向 → 反传,embedding 向量怎么被拧;
  • 国王−男人+女人≈女王 意味着什么、又有什么局限;
  • 从 word2vec 到 大模型 embedding 层 的传承与差别。

1. 计算机只认数字:先看最笨的 one-hot

词是符号,得先编码成数字。最直接的办法是one-hot:词表里有 V 个词, 就用一个长度 V 的向量,只有这个词对应的位置是 1,其余全是 0

idone-hot(词表 V=5)
0[1, 0, 0, 0, 0]
1[0, 1, 0, 0, 0]
苹果2[0, 0, 1, 0, 0]

id 只是行号;one-hot 把“我是第几个词”展开成 V 维稀疏向量。

第 14 章 RNN 若直接吃 one-hot,通常还会乘一个矩阵 Wx 变成稠密向量—— 其实那个 Wx每一行,就是一个词的嵌入向量(下面第 3 节)。

2. one-hot 的两个毛病

one-hot(猫) · one-hot(狗) = 0, one-hot(猫) · one-hot(苹果) = 0 不同词永远正交,没有任何“远近”可言
我们真正想要的

(几十~几百维)、稠密(每维是小数)、且语义相近 → 向量相近。 这就是词嵌入 / 分布式表示(distributed representation)

3. 词嵌入表:查表到底是什么

词嵌入的核心数据结构是一张(矩阵)E,形状 词表大小 × 向量维度。 第 i 行就是 id 为 i 的那个词的向量。给定 token id,不做矩阵乘法,直接取第几行——这叫查表(lookup)。

token id例如 1 =「狗」
embedding 表 Evocab × dim, 每行一词
向量 hdim 维, 喂给 RNN / Transformer

id 是行号;embedding_table_[id] 直接取出该行。表里每一行都是可训练参数

若坚持用 one-hot 记号,查表等价于矩阵乘:x = ET · one-hot —— 只有一个 1 的稀疏向量乘大矩阵,结果只是挑出对应行。 实现时当然不会真做这个大乘法,直接索引一行即可。

src/deeplearning/transformer/token_embedding.cpp · Init + Encode(精简)
embedding_table_.assign(vocab_size_, vector<double>(model_dim_, 0));
// 随机初始化每行(类似 Xavier 缩放的均匀分布)              1

for (int token_id : token_ids)
  output.push_back(embedding_table_[token_id]);           2
  1. 一开始每行是随机小数,还没有语义;训练后才把相近词拧到相近方向。
  2. Encode:一串 id → 一串向量,供第 19 章 Transformer 吃。
用 RGB 理解“向量”

颜色用 (R,G,B) 三个数表示,相近颜色三个数也相近。词嵌入用 dim 维(如 300、768)表示“意思”, 训练目标就是让上下文相似的词,坐标也靠近

4. 语义相近 = 方向相近:点积与余弦

有了向量,用点积衡量相似度:方向越一致,点积越大。 但向量长度也会影响点积,所以常用余弦相似度——只看夹角、不看长短:

cos(θ) = (a · b) / (|a| · |b|) 取值 −1~1;越接近 1 越相似。注意力里的 Q·K 也是同一套几何(第 17 章)

手算二维例子(已归一化长度):

词对向量(示意)余弦 ≈直觉
猫 / 狗(0.9, 0.4) vs (0.85, 0.45)0.98方向很接近
猫 / 苹果(0.9, 0.4) vs (−0.2, 0.95)≈ 0.1几乎无关

word2vec 的训练,本质上就是在海量句子里,把常一起出现的词的向量夹角拧小, 不常共现的拧开

5. 分布假设:凭什么“看上下文”能学语义?

词向量不是人手工标注“猫和狗相似度 0.9”,而是靠一条语言学直觉,叫分布假设(distributional hypothesis):

“You shall know a word by the company it keeps”

一个词的意思,由它经常出现的上下文决定。“猫”常跟“抓”“毛”“喵”一起出现; “狗”常跟“吠”“忠诚”“骨头”一起——它们的上下文分布很像,所以向量该靠近。 反过来,很少跟“猫”共现的词(如“GDP”“编译”),向量应离得远。

这正好能变成自监督任务(第 11 章):文本里被遮住的词就是标签, 周围词是输入,不需要人工标注。word2vec 和语言模型预测下一个 token用的是同一哲学, 只是 word2vec 只管学一张静态词表,语言模型还要学上下文动态表示。

6. word2vec 的网络长什么样?

很多人卡在“word2vec 到底训了个什么网络”。答案可能出乎你意料:它几乎是最浅的三层网络—— 没有 ReLU、没有堆很多层,核心就是两张 embedding 表 + 点积打分 + 分类损失。 先看清结构,后面训练步骤才好跟。

和 MNIST 对照:同一类“分类头”

MNIST 是 784 → 隐藏层 → 10 类 softmax
skip-gram 是 中心词 one-hot → dim 维词向量词表 V 类 softmax(或负采样版二分类)。
差别:输入、输出都是“词”,而且中间那层 dim 维向量正是我们最终要保存的词嵌入

6.1 两张表:Win 与 Wout

word2vec 的可训练参数主要是两个矩阵,形状都是 V × dim(V = 词表大小,dim = 词向量维度,常取 100~300):

矩阵形状第 i 行是什么什么时候用
Win 输入嵌入V × dim词 i 当输入时的向量 ui中心词(skip-gram)或上下文词(CBOW)查表
Wout 输出嵌入V × dim词 i 当预测目标时的向量 vi和 hidden 做点积,看“像不像这个词”

训完后通常丢掉 Wout,只留 Win(或两者平均)当最终词向量文件。训练过程中两张表都在变。

src/deeplearning/embedding/word2vec.cpp · InitEmbeddings(精简)
input_embeddings_.assign(vocab_size_, vector<double>(embed_dim, 0));
output_embeddings_.assign(vocab_size_, vector<double>(embed_dim, 0));
// 对两张表每行做均匀随机初始化 …                    1
  1. 代码里叫 input_embeddings_ / output_embeddings_,就是书里的 WinWout。训之前全是随机小数,没有语义。

6.2 skip-gram 结构图:中心词猜上下文

skip-gram 一次训练样本 = (中心词, 目标上下文词)。 网络数据流可以画成下面这样——请对照图从左往右读:

skip-gram 一次前向 · 样本 (中心 sat → 预测上下文 cat)

① 输入中心词 sat
one-hot(V 维)
Win 查表取出第 sat 行
② 隐藏层 hdim 维词向量
无 ReLU
③ 与 Wout 点积每个词 w: score = vw·h
④ softmaxV 个概率
希望 cat 最高

三层结构:输入 one-hot → 隐藏层词向量 h → 输出 logits → softmax。 和第 3 章小 MLP 同构,只是隐藏层没有激活函数,且 h 正是我们要保存的嵌入。

实现时不会真构造 V 维 one-hot,而是直接用中心词 id 去 Win 查一行——和第 3 节 embedding 查表完全一样。 输出侧也不是真的乘满 V×dim 矩阵,而是只算目标词那一行h 的点积(负采样时连全 V 都不算)。

6.3 CBOW 结构图:上下文猜中心词

CBOW 把窗口里多个上下文词的嵌入向量平均,得到一个 hidden,再去预测中心词:

CBOW · 窗口 the, cat, sat, on, the → 猜中心词 sat

the
cat
on
the

↓ 各自查 Win, 向量逐元素平均

隐藏层 hmean(uthe, ucat, uon, …)
与 Wout 点积 + softmax在词表 V 上分类
目标 sat中心词概率 ↑

CBOW = 多输入平均成一个 h,再当分类问题猜中心词;后半段和 skip-gram 相同。

7. 从语料造样本:滑窗扫一遍就有训练集

网络结构清楚了,接下来是数据从哪来。不需要人工标注:拿一篇语料,用小窗口在句子上滑动, 每个位置自动抠出训练对。

thecatsatonthemat

虚线框 = 窗口(左右各 1 词), 加粗 = 中心词

skip-gram:(sat→cat), (sat→on) | CBOW:(the, cat, on, the → sat)

窗口右移一格,中心词换成 on,又得到新样本。整本语料扫完,可得到数亿条免费训练对。

模型输入预测目标直觉
CBOW上下文多个词中心词“我 __ 了一杯咖啡” → 猜“喝”
skip-gram中心词上下文每个词给“喝” → 猜周围可能出现“咖啡/水”

句子 “the cat sat on the mat”,窗口 = 左右各 1 词,中心词 sat 时 skip-gram 得到:

步骤中心词(输入)正样本(要预测)网络在干什么
样本 Asatcat给 sat 的向量,让 cat 那一维 softmax 概率 ↑
样本 Bsaton给 sat 的向量,让 on 那一维 softmax 概率 ↑
src/deeplearning/embedding/word2vec.cpp · BuildSkipGramPairs(精简)
for (int center_pos = 0; center_pos < seq_len; center_pos++) {
  for (int context_pos = left .. right) {
    if (context_pos == center_pos) continue;
    centers.push_back(token_ids[center_pos]);
    contexts.push_back(token_ids[context_pos]);     1
  }
}
  1. 滑窗扫句子:每个中心词,对窗口内每个上下文位置各造一条训练对。整篇语料重复此过程,就得到海量免费样本。

8. 完整训练七步走:以 skip-gram 为例

下面用极小词表把一次训练从头到尾走通。词表 V=4:cat(0), sat(1), on(2), mat(3),向量维度 dim=2。 当前样本:中心词 sat, 目标上下文词 cat。参数刚随机初始化:

Win 行 (输入向量 u)Wout 行 (输出向量 v)
cat[0.5, 0.1][0.2, 0.8]
sat[0.1, 0.3][0.4, 0.1]
on[0.6, −0.2][0.1, 0.5]
mat[−0.1, 0.4][0.3, 0.2]
  1. 1
    造样本 从语料里取一对 (中心, 目标) 本例:中心词 sat, 要预测的上下文词 cat
  2. 2
    前向 · 查表 取出中心词嵌入 h = usat = [0.1, 0.3]
  3. 3
    前向 · 打分 对每个候选词算 logits scorew = vw · h
  4. 4
    前向 · 概率 softmax 变成概率分布 P(cat) ≈ 0.30, 其余更低 — 模型还不够自信
  5. 5
    算损失 交叉熵衡量猜得有多差 loss = −log P(cat) ≈ 1.20
  6. 6
    反传 梯度回传,更新向量 grad = probs − one-hot(cat) · 更新 usat, vcat
  7. 7
    重复 下一条样本,再跑 2→6 千万次后,常共现的 cat / sat 向量会越靠越近

③ 手算 cat 的分数:0.2×0.1 + 0.8×0.3 = 0.26(最高,但 softmax 后仍只有 30%)。⑥ 梯度会把 cat 的概率往 1 推。

src/deeplearning/embedding/word2vec.cpp · TrainSkipGramPair + UpdatePositivePair(精简)
auto& input_vec = input_embeddings_[center_id];   // u
auto& output_vec = output_embeddings_[context_id]; // v
const double score = Dot(input_vec, output_vec);
loss = -log(sigmoid(score));                         1

const double grad_scale = 1.0 - sigmoid(score);
input_vec[dim]  += grad_scale * lr * output_vec[dim];
output_vec[dim] += grad_scale * lr * input_vec[dim]; 2
  1. 正样本:中心词向量 u 与上下文词向量 v 点积,希望 sigmoid(score)→1。损失是 −log σ(u·v)。
  2. 对 u、v 同步更新,把共现对拉近——对应书上第 8 节第 ⑥ 步“反传更新”。

③ logits 怎么算——对每个词 w,scorew = vw · h(就是隐藏层和 Wout 第 w 行做点积):

候选词 wvw · h 计算score
cat ✓0.2×0.1 + 0.8×0.30.26
sat0.4×0.1 + 0.1×0.30.07
on0.1×0.1 + 0.5×0.30.16
mat0.3×0.1 + 0.2×0.30.09

softmax 归一化后 P(cat)≈0.30。训练目标:让正确答案的概率 → 1,loss → 0。

P(目标词 = w | 输入) = softmax(vwT · h) 第 19 章 LM Head同一公式:向量点积出分数,再 softmax 成分类概率
整篇语料怎么训?

外层再套两层循环:for 每个句子 → for 窗口每个位置 → for 每个上下文目标,每遇到一条样本就跑一遍上面的 ②~⑥。 现代实现用 mini-batch + 负采样(下一节),但单次更新的数学就是这张七步图。

9. 词表太大:负采样把输出层“瘦身”

第 8 节要对整个词表做 softmax——真实语料 V 可达 10 万,每一步都算 V 次点积 + 归一化,慢到没法训。 负采样(negative sampling) 换了个思路:不再预测“词表里哪一个”,只问“这一对词像不像真的共现”——变成几个二分类。

h = usat中心词向量

分别与下面各词向量做点积 · sigmoid 二分类

vcat正样本 · 希望 → 1
vmat负样本 · 希望 → 0
v再随机抽 K 个

只更新 h 与这 K+1 个 v · 不再扫全词表 V

网络骨架不变:还是中心词向量 h;输出侧从“V 维 softmax”换成“1 正 + K 负”的 sigmoid 二分类。

每个训练步只更新usat、vcat、以及 K 个 vneg 共 K+2 组向量,复杂度从 O(V) 降到 O(K)(K 常取 5~20)。 这就是 word2vec 能在普通电脑上吃下十亿词的原因。

src/deeplearning/embedding/word2vec.cpp · 负采样(精简)
for (int neg_idx = 0; neg_idx < negative_num; neg_idx++) {
  int negative_id = SampleNegative(center_id, context_id);
  loss += -log(1 - sigmoid(u · v_neg));
  UpdateNegativePair(u, v_neg, lr);                 1
}
  1. 随机抽 K 个“假上下文”词,希望 σ(u·vneg)→0,把噪声对推开SampleNegative 按词频0.75 抽样,高频词更容易被抽到当负样本。

9.1 负采样版训练四步(接在第 8 节后面)

仍用样本 (sat → cat),但不算全词表 softmax:

  1. 查表:usat = Win[sat], vcat = Wout[cat]。
  2. 正样本 loss:score = u·vcat, loss+ = −log σ(score),把共现对拉近
  3. 负样本 loss:随机抽到 mat, loss = −log(1 − σ(u·vmat)),把噪声对推开
  4. 反传:对 loss+ + loss 求导,更新涉及到的 u、v——重复亿万次后,常共现的 cat/sat 向量夹角变小,随机 mat 被拧远。
语料滑窗(sat, cat)
查 u, vcat, vneg只查 K+2 行
sigmoid 二分类正样本↑ 负样本↓
更新向量语义从共现中涌现

训完后通常丢掉 Wout,只导出 Win 当最终词向量(.vec 文件)。 大模型则把 embedding 留在网络里端到端更新(第 19 章)。

和语言模型的关系

第 19 章 LM Head 也要对词表 softmax,词表一大同样头疼——大模型用子词 BPE控词表、 用采样 softmax 等技巧减负。思想一脉相承:别每一步都对 10 万个词算全概率。

10. 向量算术:神奇从哪来,别神化

训练好的词向量有个著名现象:语义关系 ≈ 向量方向上的平移

向量(国王) − 向量(男人) + 向量(女人) ≈ 向量(女王) “去掉男性、加上女性”的语义,变成空间里的一个固定方向

类似还有 巴黎 − 法国 + 日本 ≈ 东京。直觉是:某些隐含维度(性别、首都关系)被网络编码成近似线性的方向, 加减相当于沿这些方向走。这是统计共现压出来的副产品,不是人工写进规则里的。

局限也要知道

向量算术并非对所有词对都准;多义词(“银行”)在静态 embedding 里往往只有一个向量,无法区分“河岸”和“金融机构”。 这也是后来上下文相关表示(ELMo、BERT、GPT 各层 hidden state)要解决的问题——第 16 章注意力让每个位置动态融合上下文。

11. 不止 word2vec:GloVe 与静态 vs 动态

GloVe(2014) 走另一条路:先统计全语料的词共现矩阵(哪些词一起出现多少次), 再分解矩阵,使两个词的向量点积 ≈ 共现次数的对数。和 word2vec 不同起点,但得到的也是静态词向量,常一起出现在“预训练 embedding”下载站里。

word2vec / GloVe大模型 embedding 层
何时学语义单独在海量语料上预训练与 Transformer 一起,被“预测下一个 token”损失顺带优化
一词一向量?是(静态)表上仍是一行,但上层 hidden 随上下文变(动态)
典型 dim100~300768、4096…
用法下载复用,接 RNN/CNNGPT / BERT 内置,第 20 章预训练

12. 一条线串起来:从 one-hot 到注意力

整条 NLP 链路的起点

one-hot(离散、无语义) → embedding 表(稠密、可训练) → word2vec 式共现训练LM 端到端训练长出语义 → 点积 / 余弦比远近(第 1 章) → Q·KT 注意力打分(第 17 章) → RAG 检索也用同一套向量相似度(第 22 章)。

RNN 每个时间步吃的 xt,就是这张表的一行; mini LMEncodeSequence 里先查 embedding、再加位置编码,再进 Transformer。 你已在仓库里见过这行代码——现在你知道这些数最初是怎么被“拧”出有语义的了。

13. 仓库实战:自己训一遍 word2vec

本章概念在仓库里有一套可运行实现:src/deeplearning/embedding/ 下的 WordTokenizer + Word2Vec, 演示程序 src/demo/word2vec。默认用内置小动物语料,跑 skip-gram + 负采样,最后打印最近邻词余弦相似度

运行示例
cd src
./build.sh
./bin/word2vec --show-pairs
./bin/word2vec --query-word cat --compare-word dog --epochs 40
  1. --show-pairs 打印滑窗样本,对应第 7 节。cat 最近邻里应出现 dog,cos(cat, dog) 明显高于 cos(cat, apples)
  2. 也可用 --mode cbow--corpus-file your.txt。详见仓库 docs/word2vec-demo.md
和大模型 embedding 的分工

这里训出的是静态词向量(word2vec 专用)。第 19 章TokenEmbedding 是字符级 LM 的输入表,结构类似,但语义由“预测下一个字”端到端学出来。 两者对照看,更容易分清“预训练词向量”和“LM 内置 embedding”。

小结

  • one-hot 高维稀疏,任意异词正交,无语义;embedding 用 vocab×dim 表一行一词。
  • 查表 = 用 id 取 embedding_table_[id];等价于 one-hot 乘嵌入矩阵,实现时直接索引。
  • 分布假设:上下文相似的词,意思相近 → 可做成自监督完形填空。
  • skip-gram 网络:one-hot → Win → dim 维词向量 h → 与 Wout 各行点积 → softmax;CBOW 先平均上下文再猜中心。
  • 训练七步:滑窗造样本 → 查表 → logits → softmax → 交叉熵 → 反传 → 重复;负采样把输出从 V 维缩成 K+1 个二分类。
  • CBOW 用上下文猜中心词;skip-gram 用中心词猜上下文;损失是词表分类或负采样二分类。
  • 训练反复拉近共现词向量、推开随机负词,语义从统计中涌现;可迁移为 GloVe/word2vec 文件。
  • 向量算术是线性方向的副产品;多义词与动态语义需上下文模型补足。
  • 仓库 Word2Vec 实现 skip-gram/CBOW + 负采样,可用 ./bin/word2vec 复现第 8–9 节训练流程。

动手与思考

问题 1:词嵌入查表和 one-hot 乘矩阵是什么关系?

数学上等价:one-hot 只有一个 1,乘嵌入矩阵 E 等于取出 E 的对应行。实现时用 id 直接索引 embedding_table_[id],不做稀疏大乘法。

问题 2:CBOW 和 skip-gram 的输入输出分别是什么?

CBOW:输入上下文词的 embedding(常平均),预测中心词。skip-gram:输入中心词,预测窗口内各上下文词。两者都靠共现统计自监督训练。

问题 3:为什么要负采样?

全词表 softmax 每步 O(V) 太慢。负采样改成少量正/负词的二分类,每步只更新几个词的向量,使 word2vec 能在超大语料上实用化。

问题 4:静态 word2vec 向量和大模型 embedding 有何不同?

word2vec/GloVe 一词一向量,训完固定;大模型 embedding 表虽也是一行一词,但与 Transformer 一起被 LM 损失更新,且上层 hidden state 随上下文变化,能处理多义词。

问题 5:国王−男人+女人≈女王说明什么、不能说明什么?

说明某些语义关系被编码成近似线性方向,可做向量运算;不能保证对所有词对成立,也不能解决一词多义——后者需要上下文相关的表示(注意力、深层网络)。

问题 6:skip-gram 的“隐藏层”是什么、有几层非线性?

隐藏层就是中心词的 dim 维嵌入向量 h,由 Win 查表得到。没有 ReLU 等非线性,整网本质是线性投影 + softmax(或负采样 sigmoid),所以极浅、极快,专训词向量。

词有了可训练的语义坐标,序列模型才能“读懂”每个 token。第 14 章 RNN 用 embedding 当每步输入; 下一部分请出注意力——先看它到底要解决 RNN 的什么问题。