AI for Beginners 词嵌入实战指南:从 One-Hot 词袋到 EmbeddingBag 与 Word2Vec 语义向量
AI for Beginners 词嵌入实战指南:从 One-Hot 词袋到 EmbeddingBag 与 Word2Vec 语义向量
在 Microsoft AI for Beginners 课程(12 周、24 课)的 NLP 单元中,第 14 课《词嵌入(Embeddings)》解决了文本表示的核心难题:如何用低维稠密向量代替高维稀疏的 One-Hot / 词袋表示,让计算机不仅能"记住"词,还能理解词与词之间的语义相似性。本文以 translations/he/lessons/5-NLP/14-Embeddings/README.md 为主干,结合仓库中的 PyTorch 与 TensorFlow 双份实战 Notebook 以及配套工具模块,完整讲解 Embedding 层的原理、变长序列处理、EmbeddingBag 架构、Word2Vec 语义嵌入与预训练嵌入的迁移用法,读完你可以独立在文本分类任务上复现并扩展这些方案。
为什么需要 Embedding:One-Hot 与词袋表示的三重缺陷
在用 BoW(词袋)或 TF/IDF 训练文本分类器时,我们处理的是长度为 vocab_size 的高维向量:先对文本进行分词,再把每个词的低维位置索引显式展开成稀疏的 One-Hot 表示。该课程文档明确指出这种表示存在三个问题:
- 内存不高效:每个词用一个长度为词表大小的向量表示,词表动辄数万(在 Notebook 中加载 News AG 数据集后词表大小达 95812),存储和计算开销巨大;
- 词与词完全独立:One-Hot 向量之间没有重叠维度,任意两个词的向量都是正交的;
- 无法表达语义相似性:
cat与kitten、king与queen这类语义相关的词,在 One-Hot 空间中距离与任意无关词完全相同。
Embedding(嵌入) 的核心思想,就是用低维稠密向量来表示词,让向量本身以某种方式反映词的语义含义。在课程语境下,先可以把 Embedding 直观理解为"降低词向量维度的手段"——真正的语义学习(如 Word2Vec)将在后文展开。
嵌入层与 Embedding Bag 模型
嵌入层:像 Linear 层,却不需要 One-Hot
课程文档对嵌入层给出了精确定义:嵌入层接收一个词作为输入,输出一个指定长度 embedding_size 的向量。从结构上看,它和 Linear 层非常相似,区别在于:Linear 层要求输入 One-Hot 编码向量,而嵌入层直接接收词的编号(词表索引),因此完全避免构造庞大的 One-Hot 向量。
更本质地说,嵌入层可以理解为一张查找表:Embedding(vocab_size, embedding_size) 本质上维护一个 vocab_size × embedding_size 的权重矩阵,输入词编号 i 时直接取矩阵第 i 行作为输出向量。
Embedding Bag:从词袋到"嵌入袋"
把嵌入层放在分类网络的第一层,模型就从词袋(bag-of-words)升级为 嵌入袋(embedding bag) 模型,其流程为:
- 将文本中的每个词逐一转换为对应的嵌入向量;
- 对所有嵌入向量计算某个聚合函数,如
sum、average(平均)或max(逐元素取最大); - 将聚合结果送入上层的线性分类器。
以上图所示架构为例:分类网络依次为"嵌入层 → 聚合层 → 线性分类器",五个词的序列经过嵌入与聚合后,得到整个句子的定长数值表示,再交给分类器输出类别。
实战一:用 PyTorch 构建嵌入分类器
本节内容全部来自 EmbeddingsPyTorch.ipynb,配套的公共工具函数位于 torchnlp.py。
加载 News AG 数据集与词表
Notebook 继续使用前一课的 News AG 新闻分类数据集,通过 torchnlp.py 中的 load_dataset() 完成加载与词表构建:
import torch
import torchtext
import numpy as np
from torchnlp import *
train_dataset, test_dataset, classes, vocab = load_dataset()
vocab_size = len(vocab)
print("Vocab size = ", vocab_size)
运行输出显示 Vocab size = 95812。在 load_dataset() 的实现中可以看到,它基于 basic_english 分词器统计词频并用 torchtext.vocab.vocab 按 min_freq 阈值构建词表,classes 为新闻的四类标签:World、Sports、Business、Sci/Tech。
定义嵌入分类器
class EmbedClassifier(torch.nn.Module):
def __init__(self, vocab_size, embed_dim, num_class):
super().__init__()
self.embedding = torch.nn.Embedding(vocab_size, embed_dim)
self.fc = torch.nn.Linear(embed_dim, num_class)
def forward(self, x):
x = self.embedding(x)
x = torch.mean(x, dim=1)
return self.fc(x)
该网络由三部分组成:Embedding(vocab_size, embed_dim) 负责把每个词的编号映射为 embed_dim 维稠密向量;torch.mean(x, dim=1) 对序列维度取平均(即 Embedding Bag 的 average 聚合);Linear(embed_dim, num_class) 输出四类新闻的分数。
处理变长序列:padify 填充
词袋模型下,一个 mini-batch 中所有 BoW 张量的大小都是固定的 vocab_size,与文本实际长度无关。改用嵌入后,每条样本的词数是可变的,无法直接堆叠成矩形张量。Notebook 给出的解决方案是给 DataLoader 提供 collate_fn 填充函数:
def padify(b):
# b 是长度为 batch_size 的元组列表
# - 元组第一个元素 = 标签
# - 第二个元素 = 特征(文本序列)
v = [encode(x[1]) for x in b] # 向量化所有序列
l = max(map(len, v)) # 计算本 mini-batch 中最长序列长度
return (
torch.LongTensor([t[0]-1 for t in b]), # 标签(类别从 1 起,减 1 对齐索引)
torch.stack([torch.nn.functional.pad(torch.tensor(t), (0, l-len(t)),
mode='constant', value=0) for t in v]) # 末尾补 0 到统一长度
)
train_loader = torch.utils.data.DataLoader(train_dataset, batch_size=16,
collate_fn=padify, shuffle=True)
padify 的逻辑是:先把每个样本编码为词编号序列,找出当前 mini-batch 内的最大长度 l,然后通过 torch.nn.functional.pad 用常数 0(即填充记号)把每个序列在末尾补齐到长度 l。对应实现同样封装在 torchnlp.py 的 padify() 中。
训练嵌入分类器
数据加载器就绪后,直接复用前一课定义的训练函数即可:
net = EmbedClassifier(vocab_size, 32, len(classes)).to(device)
train_epoch(net, train_loader, lr=1, epoch_size=25000)
Notebook 记录了训练过程:从 3200 条样本的 64.2% 准确率稳步上升到 22400 条的 75.7%,最终验证准确率约 76.2%。需要说明的是,这里只训练了 25000 条记录(不足一个完整 epoch),课程注释明确提示:可以继续训练多个 epoch 并调节学习率,达到约 90% 的准确率是可行的。
EmbeddingBag 层与 Offset 变长序列表示
对变长序列做填充并非最优方案——它引入了大量无意义的 0 填充。另一种更高效的表示是 offset(偏移)向量:把所有序列拼接进一个大的张量,同时用一个 offset 向量记录每个序列的起始位置。
PyTorch 为此提供了 torch.nn.EmbeddingBag 层,它与 Embedding 类似,但接受内容向量与偏移向量两个输入,并且内置聚合层,支持 mean、sum、max 三种模式。改造后的网络如下:
class EmbedClassifier(torch.nn.Module):
def __init__(self, vocab_size, embed_dim, num_class):
super().__init__()
self.embedding = torch.nn.EmbeddingBag(vocab_size, embed_dim)
self.fc = torch.nn.Linear(embed_dim, num_class)
def forward(self, text, off):
x = self.embedding(text, off)
return self.fc(x)
配套的 offsetify 数据转换函数负责构造 offset 向量:
def offsetify(b):
x = [torch.tensor(encode(t[1])) for t in b] # 编码所有序列
o = [0] + [len(t) for t in x] # 计算每个序列的长度
o = torch.tensor(o[:-1]).cumsum(dim=0) # 前缀和累积得到偏移
return (
torch.LongTensor([t[0]-1 for t in b]), # 标签
torch.cat(x), # 拼接后的文本
o # 偏移向量
)
train_loader = torch.utils.data.DataLoader(train_dataset, batch_size=16,
collate_fn=offsetify, shuffle=True)
偏移表示的原理是:假设三条序列长度分别为 3、5、2,拼接后的总长度为 10,offset 向量为 <a href="https://link.gitcode.com/i/3ab7b27b52362b5fd8ec143d9efb1d04" target="_blank">0, 3, 8],表示第 i 条序列从拼接张量的第 offset[i] 个位置开始。由于网络现在同时接收数据向量与偏移向量两个不同大小的参数,训练函数也必须相应调整,Notebook 中定义了专用的 train_epoch_emb,其完整实现同样可在 [torchnlp.py 的 train_epoch_emb() 中找到。使用 EmbeddingBag 后,相同训练规模下验证准确率约 75.5%,与填充方案基本持平,但免去了填充带来的冗余计算。
实战二:用 TensorFlow/Keras 构建嵌入分类器
本节内容来自 EmbeddingsTF.ipynb,使用 tensorflow_datasets 加载 ag_news_subset 数据集:
import tensorflow as tf
from tensorflow import keras
import tensorflow_datasets as tfds
import numpy as np
ds_train, ds_test = tfds.load('ag_news_subset').values()
四层网络结构
Keras 版本用 Sequential 模型串起四层,每层职责清晰:
vocab_size = 30000
batch_size = 128
vectorizer = keras.layers.experimental.preprocessing.TextVectorization(
max_tokens=vocab_size, input_shape=(1,))
model = keras.models.Sequential([
vectorizer, # 1. 文本 → 词编号张量
keras.layers.Embedding(vocab_size, 100), # 2. 词编号 → 100 维稠密向量
keras.layers.Lambda(lambda x: tf.reduce_mean(x, axis=1)), # 3. 平均聚合
keras.layers.Dense(4, activation='softmax') # 4. 线性分类器
])
model.summary()
四层的作用与文档中的"嵌入层 + 聚合层 + 线性分类器"架构一一对应:
TextVectorization层:输入字符串,输出词编号张量。指定vocab_size = 30000作为合理词表规模,忽略低频词;Embedding层:把n个词编号各映射为一个 100 维稠密向量,n × 1的输入因此变为n × 100;Lambda聚合层:沿第一个轴取平均,把所有词的向量平均为整个序列的数值表示(100 维);- 最终
Dense(4, activation='softmax')输出四类新闻的概率分布。
model.summary() 打印的参数统计印证了嵌入层的内存特性:embedding 层参数 300 万(30000 × 100),而 TextVectorization 与 Lambda 层参数为 0,模型总参数 3,000,404。
变长序列与 PAD 填充
TextVectorization 对单个输入会返回不同长度的词编号序列,例如 vectorizer('Hello, world!') 返回长度为 2 的张量,而 vectorizer('I am glad to meet you!') 返回长度为 6 的张量。但将多个序列批量送入时,向量化层必须产出矩形张量,于是用 PAD 记号(本例中为 0)填充未使用的位置:
vectorizer(['Hello, world!', 'I am glad to meet you!'])
# 输出 shape=(2, 6) 的整数张量:
# [[ 1, 45, 0, 0, 0, 0],
# [ 112, 1271, 1, 3, 1747, 158]]
可见短序列 "Hello, world!" 被填充了 4 个 0 记号。Notebook 还给出一个工程技巧:为尽量减少填充量,可按序列长度(词数)对数据集排序,保证每个 mini-batch 内序列长度相近。对于嵌入向量的直观感受,可以直接调用 model.layers1 查看嵌入层输出的稠密浮点向量。
训练
vectorizer.adapt(ds_train.take(500).map(extract_text)) # 用数据子集训练向量化层
model.compile(loss='sparse_categorical_crossentropy', metrics=['acc'])
model.fit(ds_train.map(tupelize).batch(batch_size),
validation_data=ds_test.map(tupelize).batch(batch_size))
训练一个 epoch 后得到 acc: 0.8155、val_acc: 0.8642。注意这里只用 500 条样本构建向量化层的词表以加速流程,因此部分文本中的词可能不在词表内而被忽略,会导致准确率略有下降;但在实际工程中,数据的子集往往已能给出相当好的词表估计。
语义嵌入:Word2Vec 与 CBoW / Skip-Gram
前文训练的嵌入层只是把词映射为向量,这个映射不一定承载语义。理想情况是:相似的词或同义词,其向量在某种向量距离(如欧氏距离)下彼此靠近。为此,需要在大型文本语料上以特定方式预训练嵌入模型,最经典的方法之一就是 Word2Vec。
两种主要架构
Word2Vec 基于两种用于产生词分布式表示的主要架构:
- 连续词袋(Continuous bag-of-words,CBoW):训练模型从上下文预测目标词。给定 n-gram ,模型的目标是用 预测 ;
- 连续跳字(Continuous skip-gram):与 CBoW 相反,模型用目标词的上下文窗口词来预测当前词。
两者各有取舍:CBoW 更快,skip-gram 较慢,但能更好地表示低频词。
用 gensim 实操 Word2Vec
课程 Notebook 用 gensim 库加载在 Google News 语料上预训练的 Word2Vec 模型(首次下载向量需要一些时间),并演示三类典型用法。
1. 查找语义最近的词:
import gensim.downloader as api
w2v = api.load('word2vec-google-news-300')
for w, p in w2v.most_similar('neural'):
print(f"{w} -> {p}")
输出结果生动展示了嵌入的语义聚类能力,与 "neural" 最接近的词是 neuronal(相似度 0.780)、neurons(0.733)、neural_circuits(0.725)、neuron(0.717)、cortical(0.694)等——全部属于神经科学领域词汇。
2. 提取词向量:
w2v.word_vec('play')[:20] # 只显示前 20 个分量
该模型每个词是 300 维浮点向量。
3. 向量运算揭示语义关系:语义嵌入最迷人的特性是可以在向量上做普通算术运算,让"向量加减反映词义变化"。经典的 king − man + woman ≈ queen 只需一行:
w2v.most_similar(positive=['king', 'woman'], negative=['man'])[0]
# 输出 ('queen', 0.7118192911148071)
Notebook 还演示了不依赖 gensim 魔法的底层实现:先计算 qvec = w2v['king'] - 1.7*w2v['man'] + 1.7*w2v['woman'],再计算该向量与词表中所有向量的平方距离 d = np.sum((w2v.vectors - qvec)**2, axis=1),用 argmin 找到最近向量对应的词 w2v.index_to_key[min_idx]。注意这里给 man 和 woman 加了小系数才得到 "queen",移除系数会得到不同的结果。
Word2Vec 的局限与 GloVe、FastText
Notebook 也客观指出了 Word2Vec 的两大局限:
- CBoW 与 skip-gram 都是预测式嵌入,只考虑局部上下文,无法利用全局上下文信息;
- Word2Vec 不考虑词形态学(morphology),即词义可能取决于词根等内部结构。
针对这些局限,两种替代方案被引入:
- FastText 在 Word2Vec 之上为每个词及其内部的字符 n-gram 学习向量表示,训练时将这些表示平均为一个向量。虽然显著增加了预训练计算量,但使词嵌入能够编码子词(sub-word)信息;
- GloVe 采用不同思路:先构建词-上下文共现矩阵,再用神经网络方法把共现矩阵分解为更具表达力的非线性词向量。
由于 gensim 支持多种词嵌入模型,可以直接把 api.load(...) 换成 FastText 或 GloVe 来对比效果。
使用预训练嵌入:词表不匹配问题的两种解法
预训练 Word2Vec(以及 GloVe 等同类模型)可以直接替代神经网络中的嵌入层,但有一个关键障碍:预训练模型使用的词表与我们的语料词表大概率不一致。课程给出了两种解决方向,并在 PyTorch 与 TensorFlow 两套 Notebook 中分别实现。
解法 A:保留自建词表,填充嵌入权重矩阵
沿用我们自己构建的词表(vocab_size 个词),嵌入层的权重矩阵形状为 vocab_size × embed_size。逐词遍历词表,在预训练模型中找到的词直接复制其向量,找不到的用随机值初始化。PyTorch 版如下:
embed_size = len(w2v.get_vector('hello'))
print(f'Embedding size: {embed_size}')
net = EmbedClassifier(vocab_size, embed_size, len(classes))
print('Populating matrix, this will take some time...', end='')
found, not_found = 0, 0
for i, w in enumerate(vocab.get_itos()):
try:
net.embedding.weight[i].data = torch.tensor(w2v.get_vector(w))
found += 1
except:
net.embedding.weight[i].data = torch.normal(0.0, 1.0, (embed_size,))
not_found += 1
print(f"Done, found {found} words, {not_found} words missing")
Notebook 的运行结果显示:找到 41080 个词,缺失 54732 个词(缺失量相当大,这正是预训练模型与新闻语料词表差异巨大的体现,也是最终准确率提升有限的原因)。Keras 版本与此对称,用 np.zeros((vocab_size, embed_size)) 初始化矩阵 W 并逐词填充(缺失词可留 0 或随机向量),随后直接以预训练权重构造不可训练的嵌入层:
emb = keras.layers.Embedding(vocab_size, embed_size, weights=[W], trainable=False)
model = keras.models.Sequential([
vectorizer, emb,
keras.layers.Lambda(lambda x: tf.reduce_mean(x, axis=1)),
keras.layers.Dense(4, activation='softmax')
])
trainable=False 表示不再重新训练嵌入层,虽然准确率可能略低,但显著加速了训练。
解法 B:改用预训练模型的词表
另一种更省事的思路是:直接用预训练模型的词表来加载数据集(词表可以在加载时指定)。在 PyTorch 中,torchtext 框架内置了嵌入支持,可实例化 GloVe 词表:
vocab = torchtext.vocab.GloVe(name='6B', dim=50)
GloVe 词表提供三个核心操作:
vocab.stoi:词 → 词典索引的映射;vocab.itos:索引 → 词的逆映射;vocab.vectors:所有嵌入向量构成的数组,词s的向量为vocab.vectors[vocab.stoi[s]]。
基于 GloVe 向量可以直接复现向量运算:qvec = vocab.vectors[vocab.stoi['king']] - vocab.vectors[vocab.stoi['man']] + 1.3*vocab.vectors[vocab.stoi['woman']],再对 vocab.vectors 逐行求平方距离并 argmin,得到 "queen"。
数据加载侧,只需把偏移函数改为用 GloVe 词表编码:
def offsetify(b):
x = [torch.tensor(encode(t[1], voc=vocab)) for t in b] # 把 GloVe 词表传给 encode
o = [0] + [len(t) for t in x]
o = torch.tensor(o[:-1]).cumsum(dim=0)
return (
torch.LongTensor([t[0]-1 for t in b]),
torch.cat(x),
o
)
加载权重变得极其简单——因为 vocab.vectors 本身就是完整的嵌入矩阵,直接整体拷贝进嵌入层:
net = EmbedClassifier(len(vocab), len(vocab.vectors[0]), len(classes))
net.embedding.weight.data = vocab.vectors
net = net.to(device)
Keras 版本对应的做法是:从 gensim 取出词表 vocab = list(w2v.vocab.keys()),用 vectorizer.set_vocabulary(vocab) 重建向量化层,再调用 gensim 内置的便捷函数 w2v.get_keras_embedding(train_embeddings=False) 自动生成对应嵌入层,拼接后训练 5 个 epoch(验证准确率约 61%)。
为什么准确率提升有限,以及根本解法
两份 Notebook 都指出:无论 Word2Vec 还是 GloVe,准确率提升都不显著,核心原因正是数据集中的大量词在预训练词表中缺失,这些词实际被忽略。因此课程给出两条根除路径:
- 在自己的语料上重新训练 Word2Vec 模型;
- 用自己的数据集训练自己的嵌入。
前者对应课程挑战环节,后者则是后续课程(语言模型)的主题。
上下文嵌入:解决一词多义问题
预训练嵌入(如 Word2Vec)的一个关键局限是词义消歧(word sense disambiguation)问题:虽然预训练嵌入能捕捉部分上下文含义,但一个词的所有可能含义都被编码进同一个向量,这会给下游模型带来困扰,因为许多词(如 "play")在不同语境下含义迥异:
- I went to a play at the theatre.(我去剧院看了一场戏剧。)
- John wants to play with his friends.(约翰想和朋友一起玩。)
上述两种含义在预训练嵌入中都由同一个 "play" 向量表示。要克服这一局限,需要构建基于语言模型的嵌入——语言模型在大型语料上训练,知道词如何在不同的上下文中组合。课程文档明确说明:上下文嵌入的讨论超出本课范围,将在后续讨论语言模型时(对应课程第 18 课 Transformers 等单元)再回到这一主题。
总结
本课完成了从稀疏高维词袋表示到低维稠密嵌入的完整进阶:
- 理解了嵌入层的本质(词编号 →
embedding_size维稠密向量)与 Embedding Bag 架构(嵌入 + sum/mean/max 聚合); - 掌握了 PyTorch(
Embedding/EmbeddingBag+ offset 表示)与 TensorFlow(TextVectorization+Embedding+Lambda)两套变长序列处理与分类实战方案; - 学习了 Word2Vec 的 CBoW 与 skip-gram 两种架构及其取舍,能通过 gensim 实操语义最近词查找与
king − man + woman ≈ queen向量运算; - 理解了预训练嵌入(Word2Vec、GloVe、FastText)的迁移用法与词表不匹配问题的两种解法,以及上下文嵌入(基于语言模型)的演进动机。
实践挑战与作业
课程的挑战环节鼓励把 Word2Vec 应用到有趣的场景——例如用预训练嵌入生成诗歌和歌词。课程提供了两篇外部参考材料(一篇作者用 Word2Vec 生成诗歌的博文,以及 Dan Shiffman 的讲解视频),并建议读者在自己的文本语料上尝试这些技术,语料可来自 Kaggle 等公开数据集。
配套作业要求见 assignment.md:使用本课的两份 Notebook(PyTorch 版或 TensorFlow 版任选),换成你自己的数据集重新运行(例如 Beatles 歌词数据集,注意遵循数据集的使用与署名要求),改写 Notebook 以突出你自己的发现,尝试不同类型的语料并记录结论。
进一步学习路径
- 通读本课原始笔记(英文版):lessons/5-NLP/14-Embeddings/README.md,以及本文依据的希伯来语翻译版:translations/he/lessons/5-NLP/14-Embeddings/README.md;
- 完整运行两份实战 Notebook:EmbeddingsPyTorch.ipynb 与 EmbeddingsTF.ipynb;
- 阅读封装好的公共工具函数 torchnlp.py,理解
load_dataset、encode、padify、offsetify、train_epoch_emb的完整实现; - 复习 Word2Vec 的原始论文《Efficient Estimation of Word Representations in Vector Space》(即课程"复习与自学"环节指定的文献),理解 CBoW 与 skip-gram 的训练目标与数学形式;
- 继续课程后续单元,尤其是 Transformers 单元,那里将讨论真正具备上下文感知能力的嵌入——上下文嵌入。

