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_indexnum_embeddings 的关系,确保万无一失。

嗯,关于词嵌入,今天就聊这么多。下一章咱们会讲位置编码,那是Transformer里另一个容易被忽视但极其重要的组件。到时候见。