1. 为什么你需要重新认识 nn.Embedding
在开始接触自然语言处理或者推荐系统的时候,十有八九会遇到nn.Embedding。我看过不少入门教程,一上来就告诉你"Embedding就是查表",然后甩出一行代码。怎么说呢,这句话对了一半,但恰恰是没说的那一半,让不少人在实战里踩了坑。比如有人以为 Embedding 能直接学到语义,有人不清楚 padding_idx 究竟是干嘛的,还有人被"输入的是什么、输出的形状是什么"绕晕。这篇博文我会把nn.Embedding的来龙去脉、参数细节、底层原理和踩坑记录一次讲透,让新手能直接照着用,让老手也能查缺补漏。
nn.Embedding这个名字,字面意思是"嵌入",它的作用是把离散的、稀疏的整数 ID 映射成稠密的、可学习的实数向量。在 NLP 里,每个整数 ID 通常代表一个词,比如词表里 "猫" 是 5,"狗" 是 12,那么 Embedding 层就可以把 5 这个索引变成一个 128 维的向量,这个向量初始是随机的,后期随着模型训练不断调整,最终希望它能"内化"词的某种语法或语义信息。在推荐系统里,这个 ID 也可能是用户 ID 或者物品 ID,作用类似的——把稀疏的类别特征变成稠密向量再喂给后续网络。
这个函数之所以是"万能砖头",是因为几乎所有需要处理类别型离散特征的地方都能用上它。不要以为只有 Transformer 需要它,你哪怕做一个简单的文本分类、情感分析、序列标注,甚至一个 CTR 预估模型,都绕不开这层结构。适合谁来读这篇文?刚入门 PyTorch 的同学,正在复现论文但卡在 Embedding 层形状不匹配的同学,以及准备把预训练词向量迁移进来但不知道如何正确加载的同学。
提示:这篇文章不是 API 文档的复读机,而是结合实际代码、维度推导和常见报错,把
nn.Embedding这个模块在工程里最容易被忽略的细节挖出来。
2. nn.Embedding 是什么:从一个朴素需求说起
2.1 为什么不能直接给模型喂词
先想一个问题:神经网络本质是数学运算,只能吃数字,吃不了字符串。那怎么让模型理解"苹果"和"香蕉"的关系?最简单粗暴的办法是做一个词表,给每个词一个 ID。比如:
苹果 -> 0 香蕉 -> 1 水果 -> 2但这一步还不够。如果你直接把 ID 0、1、2 当作数字输入模型,模型会认为 2 大于 1,1 大于 0,甚至会觉得 0+1=1(对应"苹果"+"香蕉"="香蕉"),这完全胡扯。类别特征之间不存在天然的大小序关系,它们是平权的,ID 只是代号。
那用 one-hot 编码行不行?可以,但问题也很明显。假设词表有 50000 个词,每个词是一个 50000 维的向量,其中只有一位是 1,其余全是 0。这会造成两个麻烦:
- 维度爆炸:50000 维的输入,全连接层权重就是 50000×128,参数量一下子到了 640 万,训练起来很吃力。
- 语义鸿沟:任意两个 one-hot 向量之间,距离全部是根号 2,完全体现不出"苹果"和"香蕉"比"苹果"和"汽车"更接近。
nn.Embedding的出现就是为了解决这两件事。它把每一个 ID 对应到一个低维稠密向量,维度是你自己指定的,比如 128 维。这样 50000 个词只需要 50000×128 个参数,同时向量是从数据里学出来的,能在一定程度上表达词之间的相似性。
2.2 Embedding 的数学本质
nn.Embedding的本质就是一个可查询的权重矩阵。矩阵的行数等于词表大小num_embeddings,列数等于嵌入维度embedding_dim。你要从一个整数 IDi得到对应的向量,只需要取这个矩阵的第i行。
这里有个很直观的类比:把词表想象成一本新华字典,每个词有一个页码(ID),而每一页上写着这个词对应的"释义向量"。模型训练的目标,就是不断修改每一页上的"释义",让整个字典更好地服务下游任务。
再深挖一层,Embedding 层实际上和一个特殊的全连接层等价。如果你输入的是 one-hot 向量,那么经过一个没有偏置的nn.Linear(num_embeddings, embedding_dim),得到的输出就是该词的词向量。也就是说,Embedding 是在输入为 one-hot 前提下 Linear 层的高效实现。高效在哪里?避免了构造高维 one-hot 向量所需的大量内存和计算,直接用索引查表,时间和空间都省了一大截。
2.3 一个最简代码示例
import torch import torch.nn as nn # 定义一个词表大小为 10000、嵌入维度为 128 的 Embedding 层 embedding = nn.Embedding(num_embeddings=10000, embedding_dim=128) # 随机生成一批整数 ID,形状是 [batch_size, seq_len] input_ids = torch.randint(0, 10000, (4, 16)) # 前向传播 output = embedding(input_ids) print(output.shape) # torch.Size([4, 16, 128])这里输入是 4×16 的整数张量,输出是 4×16×128 的浮点张量。也就是说,Embedding 层对输入张量的每一个整数元素,都替换成对应的 128 维向量。这也是后面讲维度变化时最重要的一个认知:Embedding 不会改变输入维度,而是在最后一个维度上做替换。
3. nn.Embedding 的参数逐个拆解
3.1 核心参数:num_embeddings 和 embedding_dim
构造nn.Embedding时,有两个必填参数。
num_embeddings是词表大小,也就是有多少个不同的 ID 需要表示。注意,这里传的是"数量",不是"最大索引值"。如果词表一共有 10000 个词,索引范围是 0 到 9999,那么num_embeddings应该填 10000。如果索引从 1 开始,你依然需要给索引 0 留出位置,否则会出现越界错误。
embedding_dim是每个词向量的维度。这个值怎么选?太小了表达力不够,语义信息塞不进去;太大了参数量暴涨,训练变慢而且容易过拟合。常规经验是:几百到一千的范围内,视任务复杂度而定。中小规模词表(比如 5 万以内)配 128 或 256 维是常见配置;大规模预训练模型可能用到 768 甚至 1024 维。这不是硬性规定,但可以作为初始参考值。
3.2 容易误解的 padding_idx
padding_idx是我见过被误解最多的参数。它的作用是:指定某个索引对应的向量不参与梯度更新,并且在初始化时会被置为全零向量。
为什么会需要它?处理文本时,一个 batch 里句子的长度往往不同,我们通常会pad到相同长度。假设最长句子是 20 个词,短句只有 8 个词,剩下的 12 个位置会填充一个特殊的 padding token,通常是 ID 0。这些填充位置没有任何语义,如果让它们的向量也参与训练,不仅浪费计算,还可能引入噪声——模型会莫名其妙学到"填充位置有某种含义"。
用padding_idx=0之后,索引 0 处的向量永远是零向量,梯度计算到这一行时会被直接屏蔽,这对模型训练是有实际帮助的。不过这里有个细节值得注意:padding_idx只能指定一个值,不能传一个列表。如果你有多个 padding token,需要自己提前归并。
还有一个操作层面的坑:padding_idx的取值必须在[0, num_embeddings)范围内,否则初始化时会直接报错。
3.3 进阶参数:max_norm、norm_type、scale_grad_by_freq 和 sparse
max_norm和norm_type合起来用于限制词向量的范数。比如设max_norm=1.0,每次前向传播时,如果某个词向量的 L2 范数超过 1,就会被原地归一化到范数恰好为 1。这个操作在模型训练中偶尔会有奇效,尤其当你希望词向量分布不要过于激进时。
norm_type默认是 2,即 L2 范数。也可以设为 1 或无穷大(float('inf')),分别对应 L1 范数和最大绝对值范数。实际使用中,L2 是最常用的。
scale_grad_by_freq这个参数有点冷门。当设为True时,梯度会按照词频的倒数进行缩放。也就是说,词频越低(出现次数少)的词,梯度会被放大;词频越高,梯度会被缩小。这个设计初衷是避免高频词主导更新,但现代模型里用到它的场景确实不多,往往是用采样器或损失函数来做类似的事情。可以了解,但不建议盲目开启。
sparse参数设为True时,梯度会以稀疏张量的形式返回,这在 Embedding 层参数极多但每次只更新一小部分行的场景下能节省内存。典型的例子是超大词表的推荐系统模型。但注意,sparse=True时与某些优化器(比如不支持的 Adam 变体)或者与weight_norm这类操作可能不兼容。默认sparse=False,大多数情况下不需要动它。
3.4 权重参数与初始化细节
nn.Embedding内部有一个weight参数,形状是[num_embeddings, embedding_dim]。默认情况下,这个权重是从标准正态分布N(0, 1)里采样的。但我得提醒你,这个默认初始化在实际任务中并不是最优的,尤其是当你有预训练词向量要加载时,往往直接覆盖掉weight内容。
你自己手动初始化时,常见做法是使用均匀分布,范围可以取[-0.05, 0.05]或者与维度相关的值,例如(-1/sqrt(embedding_dim), 1/sqrt(embedding_dim))。下面是一个手动初始化的示例:
import torch.nn as nn embedding = nn.Embedding(10000, 128) # 均匀分布初始化 init_range = 0.1 nn.init.uniform_(embedding.weight, -init_range, init_range) # 或者使用 xavier 初始化,更贴近线性层风格 nn.init.xavier_uniform_(embedding.weight)有一个特别容易犯的错误:你手动对embedding.weight做操作时,如果padding_idx已经设定了,最好再检查一下那行向量是否还是全零。因为你可能在初始化的过程中无意中覆盖了它。稳妥的写法是,初始化所有行之后,再手动把embedding.weight.data[padding_idx] = 0。
4. 实操:从构建到前向传播的完整流程
4.1 构造一个合理的 Embedding 层实战模板
假设我们要做一个简单的文本情感分类任务,词表大小 20000,每个词用 256 维向量表示。我们不希望填充位置参与学习,所以把 padding token 的索引设为 0。
import torch import torch.nn as nn class TextModel(nn.Module): def __init__(self, vocab_size, embed_dim, num_classes): super().__init__() self.embedding = nn.Embedding( num_embeddings=vocab_size, embedding_dim=embed_dim, padding_idx=0 ) self.classifier = nn.Linear(embed_dim, num_classes) def forward(self, x): # x: [batch_size, seq_len] embedded = self.embedding(x) # [batch_size, seq_len, embed_dim] pooled = embedded.mean(dim=1) # [batch_size, embed_dim] logits = self.classifier(pooled) # [batch_size, num_classes] return logits model = TextModel(vocab_size=20000, embed_dim=256, num_classes=2) dummy_input = torch.randint(0, 20000, (8, 32)) logits = model(dummy_input) print(logits.shape) # torch.Size([8, 2])上面的代码体现了一个典型用法:先对序列做 Embedding,再把序列维度通过平均池化压缩成单个向量,最后接分类层。这是最简的文本分类框架,虽然朴素,但没有花哨操作,适合用来理解 Embedding 在模型中的位置。
注意:
mean(dim=1)会让 padding 位置的全零向量也参与平均,这会把有效信息拉低。更严谨的做法是使用带 mask 的池化,或者用nn.utils.rnn.pack_padded_sequence等方案处理。这一点在生产环境的模型里非常关键。
4.2 维度变化:多维输入的查表行为
很多人对 Embedding 输入的维度处理很好奇:输入可以是标量、一维张量、二维张量、三维张量,输出分别是什么?
规则其实很统一:nn.Embedding内部按元素作用,输入形状是[*, N],输出就是[*, N, embedding_dim]。也就是说,除了最后一维的每一个整数被替换成向量以外,前面的所有维度都原样保留。
看几个例子:
embedding = nn.Embedding(100, 8) # 单个索引 scalar_input = torch.tensor(5) scalar_out = embedding(scalar_input) print(scalar_out.shape) # torch.Size([8]) # 一维序列 vec_input = torch.tensor([5, 6, 7]) vec_out = embedding(vec_input) print(vec_out.shape) # torch.Size([3, 8]) # 二维批量输入 mat_input = torch.tensor([[5, 6], [7, 8]]) mat_out = embedding(mat_input) print(mat_out.shape) # torch.Size([2, 2, 8])在许多场景下,你会发现输入经过 Embedding 后多了一个维度,这往往会把下游层的维度算错。比如 Transformer 里,输入[batch, seq]过 Embedding 变[batch, seq, d_model],这时候要注意后续 Linear 层作用在最后一维上,别把维度搞混。
4.3 和 DataLoader 配合时的注意事项
实际项目里,输入 ID 通常来自DataLoader。你需要注意三点:
- 输入张量的 dtype 必须是整数类型,比如
torch.long。如果你不小心传了浮点数,nn.Embedding会报错说"expected dtype torch.long"。 - 索引必须落在
[0, num_embeddings - 1]范围内,越界会直接抛出IndexError。这个错误在训练跑了一半时出现特别让人抓狂,所以最好在建词表时就把 padding 和 unknown token 都分配好 ID。 - 如果 GPU 内存不足,可以检查一下是否有索引越界导致的异常张量,这通常意味着数据预处理有 bug。
# 错误的输入类型示例 bad_input = torch.tensor([5, 6, 7]).float() # embedding(bad_input) # RuntimeError # 正确的姿势 good_input = bad_input.long() out = embedding(good_input)4.4 冻结某些词向量的技巧
有时候你不希望某些词的向量参与训练。比如加载了预训练词向量,希望大部分词微调,但另一些词(可能是噪音词)保持不动。方法是直接设置weight.requires_grad = False,但这样会把所有词都冻结。如果只想冻结某几行,需要在训练循环里通过钩子(hook)或者手动更新梯度的方式实现,比如在反向传播后把指定行梯度清零。
optimizer.zero_grad() loss.backward() # 假设索引 100-200 的词向量不更新 with torch.no_grad(): embedding.weight.grad[100:200] = 0 optimizer.step()这个操作在你做领域适配或者处理特殊 token 时会比较有用。不过日常任务中,全量微调通常是效果最好的,冻结操作需要理由充分。
5. 常见问题与排查技巧实录
5.1 "IndexError: index out of range" 到底是什么原因
这是使用nn.Embedding时最常见的报错,几乎每个用 Embedding 的人都遇到过。报错信息大概长这样:
IndexError: index out of range in self出现这句话,说明你的输入张量中存在一个值v,它大于等于num_embeddings,或者小于 0。举个例子,你定义了nn.Embedding(1000, 64),但输入里出现了一个 ID 为 1000 的元素,于是越界。
排查思路很直接:
- 打印输入张量的
max()和min()。 - 检查词表构建逻辑。有没有可能某些新词没有进词表,但在处理时却赋予了一个超出范围的临时 ID?
- 检查是否忘记添加 unknown token。通常在构建词表时,会预留 ID 0 或 1 给
[PAD]和[UNK]。
ids = torch.tensor([1, 5, 9999, 3]) embedding = nn.Embedding(1000, 64) max_id = ids.max().item() print("最大 ID:", max_id) print("词表大小:", embedding.num_embeddings) # 如果最大 ID >= 词表大小,就必定越界5.2 训练时 loss 降不下去,会不会是 Embedding 的问题
有几个隐藏的坑会导致训练异常。
第一,padding_idx的位置。如果你的 padding token 不是 0,而是设成了 1,但语料里仍然出现索引 0,那么这个索引 0 就变成了一个正常训练的普通 token。更糟糕的是,如果padding_idx对应的向量不是全零,模型可能会把"空白位置"当作有意义的特征。
第二,词表过大但训练语料太少。50 万词表,只有几十万条样本,大部分词出现次数极少,它们的向量几乎学不好。这种情况下,可以考虑缩小词表、过滤低频词,或者使用预训练向量做初始化。
第三,Embedding 层的学习率。由于 Embedding 层往往有海量参数,如果和其他层用同一个学习率,有时容易震荡。可以给 Embedding 层单独设置一个较小的学习率,尤其在加载了预训练向量的情况下。
optimizer = torch.optim.Adam([ {'params': model.embedding.parameters(), 'lr': 1e-3}, {'params': model.classifier.parameters(), 'lr': 1e-3} ])5.3 如何检查某个词的向量
训练完模型,你想看看"苹果"这个词的向量长什么样,学习得到底怎么样。直接按住索引查询就行。
apple_id = vocab["苹果"] vector = model.embedding.weight.data[apple_id] print(vector)如果你的词表是动态构建的,在验证集上新出现的词会变成 unknown token,它们的向量基本学不到语义信息,这是正常的。尽量保证训练语料覆盖了任务中大部分词汇。
5.4 Embedding 输出不需要激活函数
还有一个常见误区:有人会对 Embedding 的输出加ReLU或tanh。直接说结论,nn.Embedding的输出不需要激活函数。它是一个查表模块,本质上是把离散特征映射为连续特征,不是线性变换层,加激活函数不会带来非线性表达力的提升,反而可能破坏已经学到的向量表示。
如果非要做非线性变换,也应该在整个模型的更深层去处理,而不是紧跟着 Embedding 就加。
5.5 加载预训练词向量时最常见的错误
假设你在用 gensim 训练好的 Word2Vec,想把权重迁移到nn.Embedding。大致流程如下:
import torch import torch.nn as nn vocab_size = 20000 embed_dim = 100 embedding = nn.Embedding(vocab_size, embed_dim) # 形状:(vocab_size, embed_dim),从外部加载 pretrained_vectors = torch.randn(vocab_size, embed_dim) # 覆盖权重 embedding.weight.data.copy_(pretrained_vectors)看到这里你可能觉得简单。但实际场景中的坑是:外部词表的索引和你的词表索引是否对齐。如果你的"苹果"在外部词表里是 50,在本地词表里是 100,直接复制权重会牛头不对马嘴。正确做法是在构建本地词表时,同时记录每个词在外部词表中的 ID,然后按对应关系逐行复制。
实践心得:我在处理预训练向量时,习惯先对本地词表做一次缺失词筛查,把外部没有的词统一映射到一个随机初始化的
[UNK]向量上。别把这些缺失词硬塞给某个现有词,否则污染是全局性的。
6. 一些容易被忽略的进阶用法
6.1 权重共享:Encoder 和 Decoder 共用同一个人
Transformer 里经常出现 embedding 层和输出层权重共享的操作。什么意思?输入侧要把 token ID 转成向量,输出侧要把向量映射回词表概率,两个过程如果用同一个权重矩阵,能显著减少参数量,并且实验中往往有正则化效果。
在 PyTorch 里实现权重共享并不难,关键是把两个模块的weight指向同一个Parameter对象。
import torch.nn as nn class SharedEmbeddingModel(nn.Module): def __init__(self, vocab_size, embed_dim): super().__init__() self.embedding = nn.Embedding(vocab_size, embed_dim) # 让输出层共享 embedding 的权重 self.output_proj = nn.Linear(embed_dim, vocab_size, bias=False) self.output_proj.weight = self.embedding.weight def forward(self, x): emb = self.embedding(x) logits = self.output_proj(emb) return logits注意,这样做的前提是embed_dim和输出层维度一致。Transformer 的原始论文里,输入和输出 embedding 共享权重,同时还会乘一个sqrt(d_model)的缩放因子,这算是实现细节上的一个加分项。
6.2 高维 Embedding 和压缩交互
在推荐系统里,常常需要对多个离散特征做 Embedding,然后把它们的向量做 concatenation 或者交集运算之后再喂给多层感知机。这个时候,nn.Embedding的 batch 维度处理就尤为重要。
一个常见做法是,把所有类别特征的 Embedding 结果拼接起来:
class WideDeepModel(nn.Module): def __init__(self, vocab_sizes, embed_dim, hidden_dim): super().__init__() self.embedding_list = nn.ModuleList([ nn.Embedding(v, embed_dim) for v in vocab_sizes ]) self.fc = nn.Linear(embed_dim * len(vocab_sizes), hidden_dim) def forward(self, x_list): # x_list 是一个列表,每个元素是 [batch] embeds = [embed(x) for embed, x in zip(self.embedding_list, x_list)] concat = torch.cat(embeds, dim=-1) # [batch, embed_dim * n] return self.fc(concat)这类用法在工业界的 CTR 预估模型中非常常见。核心点是:每个离散特征有自己的nn.Embedding层,互不干扰,最后接一个全连接层做特征交叉。相比于直接对稀疏高维的 one-hot 输入接全连接,这种方式的参数数量少得多,训练效率也更高。
6.3 自定义词向量初始化:从高斯分布到均匀分布
默认初始化为N(0,1)在实际任务中有时会让收敛变慢,因为初始向量范围太大,学习率稍微大一点就容易震荡。常见的替代方案:
- 均匀分布:
U(-0.05, 0.05),范围小,收敛稳定。 - 截断正态:例如
N(0, 0.01),适合替代默认初始化。 - 预训练表示:用 Word2Vec、GloVe 或 BERT token embedding 初始化。
import torch.nn.functional as F # 截断正态初始化示例 def truncated_normal_(tensor, mean=0.0, std=0.01): with torch.no_grad(): tensor.normal_(mean, std) # 简单粗暴的做法,实际更严谨的是做截断 tensor.clamp_(-3 * std, 3 * std) return tensor这里说一个经验:大部分从零训练的 NLP 任务,用U(-0.05, 0.05)比默认的N(0, 1)更稳。尤其在 batch size 比较小、学习率比较大的情况下,默认初始化会明显拖慢收敛速度。
7. 从 Embedding 延伸:认识 nn.EmbeddingBag 和损失函数的关系
7.1 为什么有时候 Embedding 不是好选择
当你对整个句子做分类,且不需要逐词的向量时,直接用nn.Embedding+ 池化可能会浪费算力。PyTorch 提供了一个更高效的工具:nn.EmbeddingBag。它能在查表的同时完成 mean、sum 或 max 池化,不需要保留整个序列维度,内存占用更少,速度更快。
import torch.nn as nn embedding_bag = nn.EmbeddingBag(10000, 128, mode='mean') input_ids = torch.tensor([2, 3, 5, 1, 4, 6]) offsets = torch.tensor([0, 3]) # 表示两个序列的分界 output = embedding_bag(input_ids, offsets) print(output.shape) # torch.Size([2, 128])这个模块在句子级别分类、或者物品序列聚合等场景下非常实用。它和你手动做embedding.mean(dim=1)的区别在于,EmbeddingBag在计算时可以做很多底层优化,并且更显式地支持变长序列聚合。
7.2 和 CrossEntropyLoss 配合的问题
当你用nn.Embedding做序列标注时,模型的输出层往往直接接nn.CrossEntropyLoss。这里有一个隐藏的注意点:CrossEntropyLoss期望模型输出的 logits 形状是[N, C],而序列标注任务的输出往往是[batch, seq_len, num_classes]。这时候需要手动把维度重新排布,把 batch 和 seq_len 合并,再算损失。
logits = logits.view(-1, num_classes) # [batch*seq_len, num_classes] labels = labels.view(-1) # [batch*seq_len] loss = criterion(logits, labels)如果不做这一步,CrossEntropyLoss通常也能跑,但计算方式可能是错的,至少在ignore_index处理上容易出问题。比如设置ignore_index=-100或padding_idx,必须先理解清楚目标张量和 logits 的维度对齐关系。
8. 实战经验复盘:从零训练一个小型词向量模型
8.1 准备一份小语料,搭建模型
我想用一个具体的小实验把上面的知识点串起来。假设我们有语料库,里面包含若干个句子。目标很简单:训练一个nn.Embedding,让同义词的向量尽可能接近。
做法是构造一个"跳字模型"(Skip-gram)的简化版本。给定中心词,预测上下文词。在这个任务里,Embedding 层的输出会被喂给一个映射层,然后计算预测概率。
首先,构造一个极小的词表:
sentences = [ "我 喜欢 吃 苹果", "我 喜欢 吃 香蕉", "我 喜欢 喝 牛奶", "猫 喜欢 吃 鱼", ]构建词表并分配 ID:
vocab = {} for sent in sentences: for w in sent.split(): if w not in vocab: vocab[w] = len(vocab) vocab_size = len(vocab) print(vocab_size) # 8 # 把句子转成 ID 序列 def encode(sent): return [vocab[w] for w in sent.split()]然后定义模型:
class SkipGram(nn.Module): def __init__(self, vocab_size, embed_dim): super().__init__() self.center_embed = nn.Embedding(vocab_size, embed_dim) self.context_embed = nn.Embedding(vocab_size, embed_dim) self.output = nn.Linear(embed_dim, vocab_size) def forward(self, center_ids, context_ids): center_vec = self.center_embed(center_ids) # [batch, embed_dim] context_vec = self.context_embed(context_ids) # [batch, embed_dim] score = (center_vec * context_vec).sum(dim=-1) # 向量点积相似度 return score训练时,目标函数是让中心词和真正上下文词的点积尽可能大,和随机负样本的点积尽可能小。你可以用负采样思想的简化版本来做:正样本 score 大,负样本 score 小,用 BCE loss 来约束。
我在这里不展开完整的负采样训练代码,因为核心是为了演示 Embedding 层的实际参与方式。很多论文实现里,Skip-gram 有多个变体,但大部分都围绕着一个中心:Embedding 层的参数是调整的目标,也是模型最终要得到的产物。
8.2 实验观察:训练后的向量是否有效
训练结束后,你可以把词向量抽取出来,用余弦相似度看看词之间的语义关系。
def cosine_similarity(a, b): return (a @ b) / (a.norm() * b.norm()) vec_apple = model.center_embed.weight.data[vocab["苹果"]] vec_banana = model.center_embed.weight.data[vocab["香蕉"]] vec_cat = model.center_embed.weight.data[vocab["猫"]] print("苹果-香蕉相似度:", cosine_similarity(vec_apple, vec_banana)) print("苹果-猫相似度:", cosine_similarity(vec_apple, vec_cat))如果词表太小、语料太少,你会发现相似度规律不明显。这很正常,Embedding 的质量严重依赖语料规模和任务设计。这个实验更大的价值在于让你直观理解:nn.Embedding的参数是可学习的,训练的本质就是调整这张查表矩阵的每一行。
9. 避坑清单:我见过的那些匪夷所思的报错
最后整理一份基于实测的避坑清单,每一条都是我看过或者亲历过的真实问题。
Embedding层接收浮点输入报 dtype 错。这不是 bug,API 设计如此。你需要在数据预处理阶段确保 ID 张量是torch.long。
Embedding层在 DataLoader 启动时突然越界。这往往发生在验证集出现训练集没见过的 token 上。解决方案是给词表加 unknown token,或者在 collate_fn 里统一做映射。
padding_idx设了但向量不是零。有可能是手动初始化覆盖了它。初始化顺序很重要:先手动初始化全表,再单独把 padding 行置零。
sparse=True时模型无法正常反推。部分优化器对稀疏梯度支持不友好。如果遇到优化器报错,干脆关掉sparse,除非你的 Embedding 层巨大且词表访问极度稀疏。
Embedding 层参数太多,加载模型时显存溢出。虚拟内存不足时,先检查num_embeddings是否设置得过大。有些新手会把词表大小设成一亿,但实际数据里根本没有那么多词。
共享权重时忘记把bias关掉。如果你复制output_proj.weight = embedding.weight,但output_proj仍有 bias,计算结果和共享权重的语义不一致。建议构造时设bias=False。
加载预训练向量时产生维度不匹配。如果你用torch.load出来的 checkpoint 维度是 300,但当前模型embedding_dim是 256,直接 copy 会报错。需要先对预训练向量做投影或者对齐,比如用 PCA 降维到 256。
某些词向量在训练后严重偏移,可以用max_norm限制范数。我踩过这种坑:训练到一半,某个高频 token 的向量范数越来越大,导致梯度爆炸。设max_norm=3之后明显稳定。
我个人在实际操作中的体会是,
nn.Embedding的坑往往不在 API 本身,而在使用场景的边界条件。词表构建、padding 策略、索引对齐,这些周边环节才是重灾区。把上面这些细节提前处理好,训练过程会顺畅很多。
10. 最后再分享一个小技巧
训练文本模型时,如果你觉得模型收敛速度太慢,可以尝试对 Embedding 层的输出做一个缩放,比如乘以sqrt(embedding_dim)。这个技巧在 Transformer 的原始实现里就有。原因在于,点积注意力对向量范数敏感,适当放大嵌入向量的尺度,可以让 softmax 过后的分布不那么平滑,注意力更集中。
embed_out = self.embedding(x) * math.sqrt(self.embed_dim)还有一个冷门的建议:在加载预训练向量时,不要把 unknown token 的向量设成全零。全零向量在多层网络中经过线性变换依然是零,梯度更新时这一行几乎不动,导致 unknown token 可能变成一个"死点"。更好的做法是给它一个较小的随机噪声作为初始值,让后续训练有能力微调。
最后是检查padding_idx和max_norm的关系。如果你同时设置了padding_idx=0和max_norm=2.0,要确保 padding 那一行不会被归一化逻辑改动。PyTorch 内部处理得比较好,但我建议不要依赖直觉,训练前的权重检查脚本值得写一个。打印一下embedding.weight.data[0].norm(),确认它是 0,这样后面排查问题会省力很多。