AI for Beginners 词嵌入实战指南:从 One-Hot 词袋到 EmbeddingBag 与 Word2Vec 语义向量

原创2026-10-08 21:40:131,961 阅读
文章标签:教程人工智能机器学习深度学习计算机视觉NLP

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 层映射为稠密向量的过程

为什么需要 Embedding:One-Hot 与词袋表示的三重缺陷

在用 BoW(词袋)或 TF/IDF 训练文本分类器时,我们处理的是长度为 vocab_size 的高维向量:先对文本进行分词,再把每个词的低维位置索引显式展开成稀疏的 One-Hot 表示。该课程文档明确指出这种表示存在三个问题:

  1. 内存不高效:每个词用一个长度为词表大小的向量表示,词表动辄数万(在 Notebook 中加载 News AG 数据集后词表大小达 95812),存储和计算开销巨大;
  2. 词与词完全独立:One-Hot 向量之间没有重叠维度,任意两个词的向量都是正交的;
  3. 无法表达语义相似性: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) 模型,其流程为:

  1. 将文本中的每个词逐一转换为对应的嵌入向量;
  2. 对所有嵌入向量计算某个聚合函数,如 sum、average(平均)或 max(逐元素取最大);
  3. 将聚合结果送入上层的线性分类器。

以上图所示架构为例:分类网络依次为"嵌入层 → 聚合层 → 线性分类器",五个词的序列经过嵌入与聚合后,得到整个句子的定长数值表示,再交给分类器输出类别。

实战一:用 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 (W−2,W−1,W0,W1,W2)(W_{-2}, W_{-1}, W_0, W_1, W_2),模型的目标是用 (W−2,W−1,W1,W2)(W_{-2}, W_{-1}, W_1, W_2) 预测 W0W_0;
  • 连续跳字(Continuous skip-gram):与 CBoW 相反,模型用目标词的上下文窗口词来预测当前词。

两者各有取舍:CBoW 更快,skip-gram 较慢,但能更好地表示低频词。

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 的两大局限:

  1. CBoW 与 skip-gram 都是预测式嵌入,只考虑局部上下文,无法利用全局上下文信息;
  2. 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,准确率提升都不显著,核心原因正是数据集中的大量词在预训练词表中缺失,这些词实际被忽略。因此课程给出两条根除路径:

  1. 在自己的语料上重新训练 Word2Vec 模型;
  2. 用自己的数据集训练自己的嵌入。

前者对应课程挑战环节,后者则是后续课程(语言模型)的主题。

上下文嵌入:解决一词多义问题

预训练嵌入(如 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 以突出你自己的发现,尝试不同类型的语料并记录结论。

进一步学习路径

登录后查看全文
AI-For-Beginners