4、词嵌入(Word Embedding):从One-hot到Embedding、PyTorch nn.Embedding实现、可训练参数的含义
好,咱们今天聊聊词嵌入。说实话,这个词在NLP领域里太基础了,基础到很多人觉得它不值一提。但我个人觉得,恰恰是这种基础组件,决定了你上层模型的天花板。
我记得刚入行那会儿,有个老前辈跟我说过一句话:「你给模型喂什么,它就学什么。」当时我不太理解,直到自己动手做了一次文本分类,才发现——喂One-hot向量,模型学得那叫一个痛苦。后来换成Embedding,效果直接翻倍。嗯,今天咱们就把这件事彻底讲透。
4.1 从One-hot到Embedding:为什么非换不可?
先说说One-hot编码。说白了,就是把每个词表示成一个超长的稀疏向量。比如词表有10000个词,那每个词就是一个10000维的向量,其中只有1个位置是1,其余全是0。
举个例子:
词表 = ["我", "爱", "你", "学习", "AI"]
"我" → [1, 0, 0, 0, 0]
"爱" → [0, 1, 0, 0, 0]
"AI" → [0, 0, 0, 0, 1]
看着挺规整对吧?但问题来了——
- 维度灾难:词表越大,向量越长。100万词就是100万维,存都存不下。
- 语义鸿沟:任意两个词之间的余弦相似度都是0。也就是说,「猫」和「狗」在模型眼里毫无关系。这合理吗?显然不合理。
- 无法表达相似性:你没法说「国王」比「皇帝」更接近「君主」。One-hot眼里,它们都是孤岛。
我在项目中遇到过最典型的场景:用One-hot做情感分析,模型死活学不会「好」和「棒」是近义词。后来换成Embedding,同样的数据,准确率从72%直接跳到89%。
核心结论:One-hot是离散的、稀疏的、无语义的。Embedding是连续的、稠密的、有语义的。后者才是深度学习真正需要的输入形式。
4.2 Embedding的本质:一个可训练的查找表
你想想看,Embedding到底在做什么?其实特别简单——它就是一个可训练的查找表。
假设词表大小是V,每个词想映射成一个d维的稠密向量。那我们就初始化一个形状为 [V, d] 的矩阵。每个词对应矩阵中的一行。训练过程中,这一行会不断被梯度更新,最终学到这个词的「语义表示」。
用PyTorch实现,就是一行代码:
import torch.nn as nn
# 词表大小10000,每个词映射成128维向量
embedding_layer = nn.Embedding(num_embeddings=10000, embedding_dim=128)
调用的时候,你只需要传入词的索引:
# 假设输入是 batch_size=2, 每个句子长度=5
input_ids = torch.tensor([[1, 2, 3, 4, 5], [6, 7, 8, 9, 10]])
output = embedding_layer(input_ids) # 形状: [2, 5, 128]
你看,就是这么简单。输入是整数索引,输出是稠密向量。中间那个矩阵,就是我们要训练的参数。
小提示:Embedding层的输入必须是整数,不能是浮点数。如果你传了浮点数进去,PyTorch会直接报错。我曾经在数据预处理时忘了转类型,debug了半小时才发现。
4.3 nn.Embedding 的核心参数
咱们来拆解一下 nn.Embedding 的几个关键参数。我建议你记住它们,因为几乎每个NLP项目都会用到。
| 参数名 | 含义 | 常见取值 |
|---|---|---|
num_embeddings |
词表大小,即有多少个不同的词 | 根据实际词表定,通常5000~50000 |
embedding_dim |
每个词映射成的向量维度 | 128、256、512(小模型用128,大模型用512+) |
padding_idx |
指定哪个索引是填充符,该索引对应的向量不参与梯度更新 | 通常设为0 |
max_norm |
对向量进行L2范数裁剪,防止某些词向量过大 | 1.0 或 None |
sparse |
是否使用稀疏梯度,可以节省内存 | True 或 False |
这里重点说一下 padding_idx。为什么需要它?因为句子长度不一样,我们通常会把短句子补0到统一长度。这些补上的0本身没有语义,不应该参与训练。设置 padding_idx=0 后,索引0对应的向量会一直保持为全0,梯度也不会更新它。
注意:padding_idx 一旦设置,对应的向量就永久冻结了。如果你后续想微调这个向量,需要重新创建一个不带 padding_idx 的Embedding层。我曾经在迁移学习时踩过这个坑,模型怎么调都学不好,最后发现是padding向量被锁死了。
4.4 可训练参数的含义:到底在学什么?
好,现在到了最关键的问题:Embedding层的参数到底在学什么?
说白了,就是在学一个语义空间。每个词对应空间中的一个点。训练过程中,模型会不断调整这些点的位置,让语义相近的词靠得更近,语义相远的词离得更远。
举个例子,假设我们训练一个简单的语言模型:
# 一个极简的Embedding + 线性层
class SimpleLM(nn.Module):
def __init__(self, vocab_size, embed_dim):
super().__init__()
self.embed = nn.Embedding(vocab_size, embed_dim)
self.linear = nn.Linear(embed_dim, vocab_size)
def forward(self, x):
x = self.embed(x) # [batch, seq_len, embed_dim]
x = self.linear(x) # [batch, seq_len, vocab_size]
return x
训练过程中,Embedding矩阵的每一行都在被梯度更新。如果两个词经常出现在相似的上下文中(比如「猫」和「狗」都出现在「宠物」附近),它们的向量就会越来越接近。
我习惯把Embedding层看作模型的「第一层特征提取器」。它把离散的符号变成了连续的数值,让后续的Transformer能吃下去。没有这一步,后面的自注意力机制根本没法工作。
一句话总结:Embedding层的参数,就是每个词在d维空间中的坐标。训练的过程,就是不断调整这些坐标,让语义关系在几何上得到体现。
4.5 实战建议与避坑指南
最后,分享几个我在实际项目中积累的经验:
- Embedding维度怎么选? 我个人的经验是:词表大小在1万以下,128维够用;1万到5万,256维;5万以上,512维起步。当然,如果你资源充足,直接上512也没问题。
- 要不要预训练? 如果你的数据量很大(比如百万级语料),随机初始化Embedding然后从头训练完全可行。但如果数据量小,我建议用预训练的词向量(如GloVe、FastText)来初始化,能省不少事。
- 小心OOV(未登录词) 词表外的词怎么办?我一般会加一个
<UNK>标记,所有未登录词都映射到这个索引上。这样至少不会报错。 - 我曾经犯过的错:有一次我把
num_embeddings设得比实际词表小,结果训练时频繁报索引越界。后来我养成了一个习惯——每次构建词表后,先打印一下max_index和num_embeddings的关系,确保万无一失。
嗯,关于词嵌入,今天就聊这么多。下一章咱们会讲位置编码,那是Transformer里另一个容易被忽视但极其重要的组件。到时候见。