AI-For-Beginners 课程:从词袋到语义向量的词嵌入(Embeddings)实战指南——PyTorch 与 TensorFlow 双框架实现
AI-For-Beginners 课程:从词袋到语义向量的词嵌入(Embeddings)实战指南——PyTorch 与 TensorFlow 双框架实现
导读
本篇文章对应 AI-For-Beginners 课程第 5 章 NLP 部分的第 14 课(lessons/5-NLP/14-Embeddings/README.md),系统讲解词嵌入(Embeddings)的核心原理与实战用法。你将掌握:为什么 one-hot 词袋向量不适合文本分类、如何用 Embedding 层把词映射为低维稠密向量、如何用 Embedding Bag 模型解决变长序列问题、以及 Word2Vec / GloVe 等预训练语义嵌入如何在 PyTorch 与 TensorFlow 中落地。课程以 AG_NEWS 新闻数据集上的文本分类为贯穿案例,读完即可在两个主流深度学习框架中动手训练自己的嵌入分类器。
上图为本课程作者绘制的词嵌入分类器示意图,展示了输入序列中每个词被转换为低维嵌入向量,再经过聚合与线性分类层输出结果的全过程。
一、为什么需要 Embeddings:one-hot 表示的三大缺陷
在课程前几节(BoW / TF-IDF 分类器)中,我们一直在操作长度为 vocab_size 的高维词袋向量,并显式地把低维的位置表示转换成稀疏的 one-hot 表示。以这种形式为底层的文本分类存在三个明显的痛点:
- 内存不高效:词表有多大,向量就有多长。若词表规模达到数万,每个词都是一个长度为
vocab_size的稀疏向量,绝大部分位置都是 0,存储与计算都极度浪费。 - 词与词相互独立:one-hot 编码下任意两个词向量的内积恒为 0,向量本身不携带任何语义信息,模型无法感知"猫"与"狗"比"猫"与"汽车"更相近。
- 维度灾难:高维稀疏向量让下游分类器参数量急剧膨胀,训练难度与过拟合风险同步上升。
Embedding(嵌入) 的思想正是针对上述问题:用低维稠密向量表示每个词,并且让这些向量在某种程度上一反映词的语义。在课程语境里,可以先把 Embedding 理解为"降低词向量维度的手段",其真正意义上的语义化将在后文 Word2Vec 部分展开。
二、Embedding 层:本质是一张"查表式"的 Linear 层
课程对 Embedding 层的定位非常精炼:它接收一个词(word)作为输入,输出一个长度为指定 embedding_size 的向量。在某种意义上它非常像 Linear 层(PyTorch 术语)或 Dense 层(TensorFlow 术语),但关键区别在于:
Linear/Dense层必须接收 one-hot 编码后的向量;- Embedding 层直接接收词的编号(token index),从而完全绕开了构造超大 one-hot 向量的步骤。
用 PyTorch 实现一个嵌入分类器,核心网络结构仅两行(见 EmbeddingsPyTorch.ipynb):
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 Bag 模型:
- 首先把文本中每个词转换为对应的嵌入向量;
- 然后在所有嵌入向量上计算某个聚合函数,例如
sum、average或max; - 最后接一个线性分类器输出类别。
由此,分类网络从"词袋(bag-of-words)"平滑迁移到了"嵌入袋(embedding bag)",维度从 vocab_size 骤降到 embed_dim(本课示例常用 32 或 100)。
三、变长序列的两种处理策略:Padding 与 Offset
切换到词嵌入后立刻面临一个新问题:在词袋模型中,一个 batch 内所有 BoW 张量尺寸都是固定的 vocab_size,与文本实际长度无关;而嵌入输入是"每样本词数不定的 token 序列"。课程给出了两种解决方案,两种方案在仓库源码 torchnlp.py 中均有完整实现。
策略一:Padding(填充到 batch 内最大长度)
通过为 DataLoader 提供 collate_fn=padify,将 batch 内所有序列填充到本 batch 的最长长度,短序列尾部补 0(torchnlp.py):
def padify(b, voc=None, tokenizer=tokenizer):
# b 是长度为 batch_size 的元组列表,每项为 (label, text)
v = [encode(x[1], voc=voc, tokenizer=tokenizer) for x in b]
l = max(map(len, v)) # 计算本 batch 内序列最大长度
return (
torch.LongTensor([t[0]-1 for t in b]),
torch.stack([torch.nn.functional.pad(
torch.tensor(t), (0, l-len(t)), mode='constant', value=0) for t in v])
)
train_loader = torch.utils.data.DataLoader(
train_dataset, batch_size=16, collate_fn=padify, shuffle=True)
在 EmbeddingsPyTorch.ipynb 中,用 EmbedClassifier(vocab_size, 32, len(classes))、学习率 lr=1 训练 25k 条样本后,准确率可逐步爬到约 0.76;笔记特别提示,完整训练多个 epoch 并调优学习率后,该模型可以达到约 90% 的准确率。
策略二:Offset 偏移表示 + EmbeddingBag
Padding 并非最省内存的表示方式,另一种思路是把一个 batch 的所有 token 拼进一个大向量,再用一个 offset 向量记录每条序列的起始位置(torchnlp.py):
def offsetify(b, voc=None):
x = [torch.tensor(encode(t[1], voc=voc)) 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]), # labels
torch.cat(x), # text:拼合的大向量
o # offsets
)
上图为课程配图(以字符序列为例说明原理,本课处理的是词序列):所有序列内容拼接为一个长向量,offset 数组保存各序列的起始下标,从而避免任何填充。
PyTorch 为此提供了 EmbeddingBag 层:它同时接收内容向量与偏移向量,内部已内置 mean、sum 或 max 聚合,与 Embedding 相比少了一步手写聚合(见 EmbeddingsPyTorch.ipynb):
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)
由于网络此时接收 (text, off) 两个参数、DataLoader 也返回三个值(标签、文本、偏移),课程同步调整了训练循环 train_epoch_emb(torchnlp.py),使用 lr=4、epoch_size=25000 训练后准确率同样达到约 0.75。
四、TensorFlow / Keras 版 Embedding Bag 模型
在 EmbeddingsTF.ipynb 中,同一套思路用 Keras 的 Sequential 模型表达为四个层级:
TextVectorization层:输入字符串,输出 token 编号张量,通过max_tokens=vocab_size设定词表上限并忽略低频词;Embedding层:vocab_size个词各自映射为长度 100 的稠密向量,形状由(None, None)变为(None, None, 100);Lambda聚合层:对 token 轴执行tf.reduce_mean,得到整段文本的 100 维数值表示;Dense(4, activation='softmax')线性分类层,对应 AG_NEWS 的 4 个新闻类别。
vocab_size = 30000
batch_size = 128
vectorizer = keras.layers.experimental.preprocessing.TextVectorization(
max_tokens=vocab_size, input_shape=(1,))
model = keras.models.Sequential([
vectorizer,
keras.layers.Embedding(vocab_size, 100),
keras.layers.Lambda(lambda x: tf.reduce_mean(x, axis=1)),
keras.layers.Dense(4, activation='softmax')
])
model.summary()
模型摘要显示参数量约 300 万,几乎全部来自 Embedding 层(30000 × 100)。训练时用 vectorizer.adapt(ds_train.take(500).map(extract_text)) 基于数据子集构建词表,一轮训练后验证准确率可达约 0.86。笔记同时提醒两点工程细节:
- 变长序列在送入
TextVectorization后会自动填充 PAD token(编号 0),例如'Hello, world!'与'I am glad to meet you!'会被整理为(2, 6)的矩形张量; - 为减少填充浪费,可把数据集中序列按长度排序后再分 batch,使每个 batch 内序列长度相近。
五、语义嵌入:Word2Vec 的两种架构
前面训练的 Embedding 层确实学会了"查表",但其向量并不具备语义——两个近义词对应的向量在空间中的距离未必更近。要让"相似词或同义词的向量在欧氏距离等度量下彼此靠近",就需要在大规模文本上以特定方式预训练嵌入模型。课程重点介绍第一种主流方法 Word2Vec,它基于两种架构生成词的分布式表示:
- 连续词袋(Continuous bag-of-words, CBoW):给定 ngram ,模型从上下文 预测中间词 ;
- 连续跳词(Continuous skip-gram):与 CBoW 相反,模型用当前词去预测其周围上下文窗口中的词。
两种架构各有取舍:CBoW 训练更快,而 skip-gram 较慢,但对低频词的表示效果更好。
上图为词向量经典论文《Efficient Estimation of Word Representations in Vector Space》中的原图,直观对比了 CBoW 与 Skip-Gram 的预测方向差异。
在 EmbeddingsPyTorch.ipynb 与 EmbeddingsTF.ipynb 中,可以用 gensim 库加载在 Google News 语料上预训练的 Word2Vec(300 维,首次下载耗时较长):
import gensim.downloader as api
w2v = api.load('word2vec-google-news-300')
加载后即可验证嵌入的语义能力。查询 w2v.most_similar('neural'),返回的最近邻依次是 neuronal (0.780)、neurons (0.733)、neural_circuits (0.725)、neuron (0.717) 等神经科学词汇,相似度由余弦相似度给出。更经典的是向量算术:寻找与 king + woman - man 最近的词,gensim 返回 ('queen', 0.7118),印证了嵌入空间中的方向能编码语义关系。
两个 notebook 还分别演示了手写向量算术的完整过程。PyTorch 版本(GloVe 词表)使用:
qvec = vocab.vectors[vocab.stoi['king']] - vocab.vectors[vocab.stoi['man']] \
+ 1.3 * vocab.vectors[vocab.stoi['woman']]
d = torch.sum((vocab.vectors - qvec)**2, dim=1)
min_idx = torch.argmin(d)
vocab.itos[min_idx] # 输出 'queen'
TensorFlow 版本使用 numpy 完成同样的平方距离检索。注意两处代码都对 man / woman 向量加了修正系数(1.3 或 1.7),课程提示"删掉系数试试看会发生什么",是理解向量空间方向的绝佳动手实验。
六、Word2Vec 的局限与 FastText、GloVe
课程也明确指出 Word2Vec 的两点不足:
- 只看局部上下文:CBoW 与 skip-gram 都属于"预测式(predictive)"嵌入,只利用窗口内的局部上下文,不利用全局上下文;
- 忽略词形学(morphology):不考虑词根、词缀等词内部结构。
针对这两点,课程介绍了两个后继模型:
- FastText:在 Word2Vec 基础上,为每个词以及词内的字符 n-gram 都学习向量表示,训练时逐项求平均得到一个向量。虽然大幅增加了预训练计算量,但使嵌入能够编码子词(sub-word)信息,对低频词与词形变化更鲁棒。
- GloVe:走的是"计数式"路线——先构建词-上下文共现矩阵,再通过矩阵分解/神经网络方法把共现矩阵压缩为更具表达力、非线性的低维词向量。
gensim 库同时支持上述多种嵌入模型,读者只需替换加载代码即可切换实验。
七、在 PyTorch 中使用预训练嵌入:词表对齐的两种路径
Word2Vec / GloVe 预训练嵌入可以直接替换神经网络中的 Embedding 层,但有一个绕不开的问题:预训练所用词表与当前文本语料的词表大概率不一致。课程给出了两种解决思路,并分别在两个 notebook 中实现。
路径一:用预训练向量填充自己的词表
在 EmbeddingsPyTorch.ipynb 中,遍历自定义词表的每个词,能在预训练模型中找到的就填入其向量,找不到的就用标准正态分布随机初始化:
embed_size = len(w2v.get_vector('hello'))
net = EmbedClassifier(vocab_size, embed_size, len(classes))
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")
示例运行结果是:找到 41080 个词,54732 个词缺失——超过一半的词只能随机初始化,这正是笔记中"未见准确率大幅提升"(约 0.76)的原因之一:双方词表差异过大。同时嵌入层从 32 维升到 300 维导致参数量剧增、训练变慢,也更易过拟合,需要更多训练样本。
路径二:用预训练模型的词表加载数据集
更省事的做法是直接以预训练词表作为数据集词表。PyTorch 的 torchtext 对嵌入有内建支持,一行即可实例化 GloVe 词表:
vocab = torchtext.vocab.GloVe(name='6B', dim=50)
该词表对象提供三个基本操作:
vocab.stoi:词 → 索引的字典;vocab.itos:索引 → 词的逆映射;vocab.vectors:全部嵌入向量矩阵,取词s的向量即vocab.vectors[vocab.stoi[s]]。
随后只需在 offsetify 中把 vocab 传给 encode(encode(t[1], voc=vocab)),再用一句直接赋值即可完成权重注入:
net = EmbedClassifier(len(vocab), len(vocab.vectors[0]), len(classes))
net.embedding.weight.data = vocab.vectors
即便如此,仍有一部分语料词在 GloVe 词表中缺失而被忽略,最终准确率约为 0.757,与随机初始化基线相差不大。课程的结论是:想要实质提升,最终还得在自己的语料上训练自己的嵌入——这也正是 Assignment 的设计动机。
八、在 Keras 中使用预训练嵌入:两种词表策略的实战对照
EmbeddingsTF.ipynb 把词表对齐问题拆成两种可对比的实现。
方式一:使用 tokenizer 自己的词表
用 vectorizer.get_vocabulary() 取得分词器词表,构造 vocab_size × embed_size 的权重矩阵 W,逐词填充 Word2Vec 向量(缺失词留零或随机)。示例运行结果:找到 4551 词,缺失 784 词。然后以预训练权重创建冻结的嵌入层:
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 表示不再微调嵌入层,能加速训练但会略微牺牲准确率——示例中验证准确率约 0.82,略低于从头训练版本。
方式二:使用嵌入模型的词表
若改用 Word2Vec 词表驱动整个流程,首先用嵌入词表设置向量化层:
vocab = list(w2v.vocab.keys())
vectorizer = keras.layers.experimental.preprocessing.TextVectorization(input_shape=(1,))
vectorizer.set_vocabulary(vocab)
然后调用 gensim 的便捷方法 w2v.get_keras_embedding(train_embeddings=False) 自动生成对应的 Keras 嵌入层。训练 5 个 epoch 后验证准确率约 0.61——明显低于前两种方式,笔记给出的解释依然一致:数据集中大量词在预训练词表中不存在而被直接忽略。这再次印证:预训练嵌入的性能上限受限于词表覆盖度,必要时应回到"自训练嵌入"路线。
九、上下文嵌入:解决一词多义问题
传统预训练嵌入(Word2Vec 等)的核心局限是词义消歧(word sense disambiguation)问题:一个词的所有可能含义都被编码进同一个向量。课程用 play 一词举例:
- 我去剧院看了一场 play(戏剧);
- John 想和他的朋友们 play(玩耍)。
上面两种含义在预训练嵌入中是同一个向量,这会干扰下游模型。要突破这一限制,需要基于**语言模型(language model)**构建嵌入——语言模型在大规模语料上训练,知道词在不同上下文中如何搭配使用。课程明确说明:上下文嵌入的详细讨论超出本课范围,将在本课程后续"语言模型"单元(第 15 课 Language Modeling 等)中展开。
十、结论、练习与作业
本课的核心收获可以概括为一句话:在 TensorFlow 与 PyTorch 中构建并使用 Embedding 层,让词的语义更好地反映在向量表示中。从 one-hot 到低维稠密嵌入,再到语义化的预训练嵌入(Word2Vec / GloVe / FastText),最后过渡到上下文嵌入,构成了 NLP 表示学习的完整演进脉络。
动手练习建议直接运行两份配套 notebook:
- PyTorch 版嵌入实验(配套公共工具模块 torchnlp.py,内含
load_dataset、encode、padify、offsetify、train_epoch_emb等函数) - TensorFlow 版嵌入实验
课程还布置了开放性挑战:Word2Vec 已被用于生成歌词与诗歌等有趣应用,可参考相关博文与视频后,在自己收集的文本语料(例如来自 Kaggle 的歌词数据集)上复现这些技巧,并改写 notebook 记录自己的发现。正式作业见 lessons/5-NLP/14-Embeddings/assignment.md:使用本课两个 notebook 之一(PyTorch 或 TensorFlow 版本),换用自己的数据集重新运行,并记录与文档数据集不同的结论;课程示例数据集包括 Beatles 歌词语料。推荐的自学资料是 Word2Vec 原始论文《Efficient Estimation of Word Representations in Vector Space》。
本文基于课程英文原版文档 lessons/5-NLP/14-Embeddings/README.md(孟加拉语翻译见 translations/bn/lessons/5-NLP/14-Embeddings/README.md)及两份配套 notebook 编写,代码示例均取自仓库内真实源码。


