第 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。
| 词 | id | one-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 的两个毛病
- 维度爆炸、极其稀疏:真实词表动辄 3 万~50 万(子词 BPE 后见第 20 章),每个词一个 V 维向量,99.99% 都是 0,存储和计算都浪费。
- 完全不懂语义:任意两个不同词的 one-hot 向量都互相垂直—— 点积恒为 0。在 one-hot 眼里,“猫”和“狗”的相似度,与“猫”和“苹果”一样都是 0。
短(几十~几百维)、稠密(每维是小数)、且语义相近 → 向量相近。 这就是词嵌入 / 分布式表示(distributed representation)。
3. 词嵌入表:查表到底是什么
词嵌入的核心数据结构是一张表(矩阵)E,形状 词表大小 × 向量维度。 第 i 行就是 id 为 i 的那个词的向量。给定 token id,不做矩阵乘法,直接取第几行——这叫查表(lookup)。
id 是行号;embedding_table_[id] 直接取出该行。表里每一行都是可训练参数。
若坚持用 one-hot 记号,查表等价于矩阵乘:x = ET · one-hot —— 只有一个 1 的稀疏向量乘大矩阵,结果只是挑出对应行。 实现时当然不会真做这个大乘法,直接索引一行即可。
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
- 一开始每行是随机小数,还没有语义;训练后才把相近词拧到相近方向。
- Encode:一串 id → 一串向量,供第 19 章 Transformer 吃。
颜色用 (R,G,B) 三个数表示,相近颜色三个数也相近。词嵌入用 dim 维(如 300、768)表示“意思”, 训练目标就是让上下文相似的词,坐标也靠近。
4. 语义相近 = 方向相近:点积与余弦
有了向量,用点积衡量相似度:方向越一致,点积越大。 但向量长度也会影响点积,所以常用余弦相似度——只看夹角、不看长短:
手算二维例子(已归一化长度):
| 词对 | 向量(示意) | 余弦 ≈ | 直觉 |
|---|---|---|---|
| 猫 / 狗 | (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):
一个词的意思,由它经常出现的上下文决定。“猫”常跟“抓”“毛”“喵”一起出现; “狗”常跟“吠”“忠诚”“骨头”一起——它们的上下文分布很像,所以向量该靠近。 反过来,很少跟“猫”共现的词(如“GDP”“编译”),向量应离得远。
这正好能变成自监督任务(第 11 章):文本里被遮住的词就是标签, 周围词是输入,不需要人工标注。word2vec 和语言模型预测下一个 token用的是同一哲学, 只是 word2vec 只管学一张静态词表,语言模型还要学上下文动态表示。
6. word2vec 的网络长什么样?
很多人卡在“word2vec 到底训了个什么网络”。答案可能出乎你意料:它几乎是最浅的三层网络—— 没有 ReLU、没有堆很多层,核心就是两张 embedding 表 + 点积打分 + 分类损失。 先看清结构,后面训练步骤才好跟。
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(或两者平均)当最终词向量文件。训练过程中两张表都在变。
input_embeddings_.assign(vocab_size_, vector<double>(embed_dim, 0));
output_embeddings_.assign(vocab_size_, vector<double>(embed_dim, 0));
// 对两张表每行做均匀随机初始化 … 1
- 代码里叫
input_embeddings_/output_embeddings_,就是书里的 Win 与 Wout。训之前全是随机小数,没有语义。
6.2 skip-gram 结构图:中心词猜上下文
skip-gram 一次训练样本 = (中心词, 目标上下文词)。 网络数据流可以画成下面这样——请对照图从左往右读:
skip-gram 一次前向 · 样本 (中心 sat → 预测上下文 cat)
one-hot(V 维)
无 ReLU
希望 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
↓ 各自查 Win, 向量逐元素平均
CBOW = 多输入平均成一个 h,再当分类问题猜中心词;后半段和 skip-gram 相同。
7. 从语料造样本:滑窗扫一遍就有训练集
网络结构清楚了,接下来是数据从哪来。不需要人工标注:拿一篇语料,用小窗口在句子上滑动, 每个位置自动抠出训练对。
虚线框 = 窗口(左右各 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 得到:
| 步骤 | 中心词(输入) | 正样本(要预测) | 网络在干什么 |
|---|---|---|---|
| 样本 A | sat | cat | 给 sat 的向量,让 cat 那一维 softmax 概率 ↑ |
| 样本 B | sat | on | 给 sat 的向量,让 on 那一维 softmax 概率 ↑ |
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
}
}
- 滑窗扫句子:每个中心词,对窗口内每个上下文位置各造一条训练对。整篇语料重复此过程,就得到海量免费样本。
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
造样本 从语料里取一对 (中心, 目标) 本例:中心词 sat, 要预测的上下文词 cat
-
2
前向 · 查表 取出中心词嵌入 h = usat = [0.1, 0.3]
-
3
前向 · 打分 对每个候选词算 logits scorew = vw · h
-
4
前向 · 概率 softmax 变成概率分布 P(cat) ≈ 0.30, 其余更低 — 模型还不够自信
-
5
算损失 交叉熵衡量猜得有多差 loss = −log P(cat) ≈ 1.20
-
6
反传 梯度回传,更新向量 grad = probs − one-hot(cat) · 更新 usat, vcat …
-
7
重复 下一条样本,再跑 2→6 千万次后,常共现的 cat / sat 向量会越靠越近
③ 手算 cat 的分数:0.2×0.1 + 0.8×0.3 = 0.26(最高,但 softmax 后仍只有 30%)。⑥ 梯度会把 cat 的概率往 1 推。
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
- 正样本:中心词向量 u 与上下文词向量 v 点积,希望 sigmoid(score)→1。损失是 −log σ(u·v)。
- 对 u、v 同步更新,把共现对拉近——对应书上第 8 节第 ⑥ 步“反传更新”。
③ logits 怎么算——对每个词 w,scorew = vw · h(就是隐藏层和 Wout 第 w 行做点积):
| 候选词 w | vw · h 计算 | score |
|---|---|---|
| cat ✓ | 0.2×0.1 + 0.8×0.3 | 0.26 |
| sat | 0.4×0.1 + 0.1×0.3 | 0.07 |
| on | 0.1×0.1 + 0.5×0.3 | 0.16 |
| mat | 0.3×0.1 + 0.2×0.3 | 0.09 |
softmax 归一化后 P(cat)≈0.30。训练目标:让正确答案的概率 → 1,loss → 0。
外层再套两层循环:for 每个句子 → for 窗口每个位置 → for 每个上下文目标,每遇到一条样本就跑一遍上面的 ②~⑥。 现代实现用 mini-batch + 负采样(下一节),但单次更新的数学就是这张七步图。
9. 词表太大:负采样把输出层“瘦身”
第 8 节要对整个词表做 softmax——真实语料 V 可达 10 万,每一步都算 V 次点积 + 归一化,慢到没法训。 负采样(negative sampling) 换了个思路:不再预测“词表里哪一个”,只问“这一对词像不像真的共现”——变成几个二分类。
分别与下面各词向量做点积 · sigmoid 二分类
只更新 h 与这 K+1 个 v · 不再扫全词表 V
网络骨架不变:还是中心词向量 h;输出侧从“V 维 softmax”换成“1 正 + K 负”的 sigmoid 二分类。
- 正样本:真实的 (sat, cat),算 σ(usat·vcat),希望 → 1;
- 负样本:随机抽 K 个词(如 mat、GDP),算 σ(u·vneg),希望 → 0。
每个训练步只更新usat、vcat、以及 K 个 vneg 共 K+2 组向量,复杂度从 O(V) 降到 O(K)(K 常取 5~20)。 这就是 word2vec 能在普通电脑上吃下十亿词的原因。
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
}
- 随机抽 K 个“假上下文”词,希望 σ(u·vneg)→0,把噪声对推开。
SampleNegative按词频0.75 抽样,高频词更容易被抽到当负样本。
9.1 负采样版训练四步(接在第 8 节后面)
仍用样本 (sat → cat),但不算全词表 softmax:
- 查表:usat = Win[sat], vcat = Wout[cat]。
- 正样本 loss:score = u·vcat, loss+ = −log σ(score),把共现对拉近。
- 负样本 loss:随机抽到 mat, loss− = −log(1 − σ(u·vmat)),把噪声对推开。
- 反传:对 loss+ + loss− 求导,更新涉及到的 u、v——重复亿万次后,常共现的 cat/sat 向量夹角变小,随机 mat 被拧远。
训完后通常丢掉 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 随上下文变(动态) |
| 典型 dim | 100~300 | 768、4096… |
| 用法 | 下载复用,接 RNN/CNN | GPT / BERT 内置,第 20 章预训练 |
12. 一条线串起来:从 one-hot 到注意力
one-hot(离散、无语义) → embedding 表(稠密、可训练) → word2vec 式共现训练或LM 端到端训练长出语义 → 点积 / 余弦比远近(第 1 章) → Q·KT 注意力打分(第 17 章) → RAG 检索也用同一套向量相似度(第 22 章)。
RNN 每个时间步吃的 xt,就是这张表的一行;
mini LM 在 EncodeSequence 里先查 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
--show-pairs打印滑窗样本,对应第 7 节。cat最近邻里应出现dog,cos(cat, dog)明显高于cos(cat, apples)。- 也可用
--mode cbow、--corpus-file your.txt。详见仓库docs/word2vec-demo.md。
这里训出的是静态词向量(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 的什么问题。