码到成功
深入理解 Embedding 层的本质:不只是降维

Embedding 层经常出现在 NLP、推荐系统、搜索、广告和图学习里。
## 从 one-hot 的问题说起
假设有一句话:
```text
公主很漂亮
```
如果用 one-hot 表示,每个字都可以变成一个只有一个位置为 1、其他位置为 0 的向量。
比如词表里只有这几个字时,可以写成:
```text
公 [0 0 0 0 1]
主 [0 0 0 1 0]
很 [0 0 1 0 0]
漂 [0 1 0 0 0]
亮 [1 0 0 0 0]
```
这种表示非常清楚:每个字都有独立身份,互不混淆。
如果词表扩大一些,向量也会变长:
```text
公 [0 0 0 0 1 0 0 0 0 0]
主 [0 0 0 1 0 0 0 0 0 0]
很 [0 0 1 0 0 0 0 0 0 0]
漂 [0 1 0 0 0 0 0 0 0 0]
亮 [1 0 0 0 0 0 0 0 0 0]
```
one-hot 的优点是简单、明确、不会混淆。
但它也有一个很大的问题:每个词之间完全独立,无法表达语义关系。

比如:
```text
公主很漂亮
王妃很漂亮
```
从人类理解来看,“公主”和“王妃”有某种语义接近性;“漂亮”也和描述人物外貌有关。
但在 one-hot 里,“公主”“王妃”“汽车”“石头”之间的距离都差不多。它只能告诉模型“它们不是同一个词”,却很难告诉模型“谁和谁更像”。
这就是 one-hot 最大的短板:它表达身份很强,表达关系很弱。
## Embedding 的第一层理解:查表
Embedding 层最直接的实现方式就是查表。
假设有一个词表:
```text
公 -> 0
主 -> 1
很 -> 2
漂 -> 3
亮 -> 4
```
输入句子:
```text
公主很漂亮
```
可以先变成 ID 序列:
```text
[0, 1, 2, 3, 4]
```
Embedding 层内部维护一个矩阵:
```text
Embedding Matrix = vocab_size × embedding_dim
```
比如:
```text
vocab_size = 5
embedding_dim = 3
```
矩阵可能长这样:
```text
[
[ 0.12, -0.31, 0.44],
[ 0.28, 0.09, -0.15],
[-0.20, 0.51, 0.33],
[ 0.66, -0.07, 0.18],
[ 0.41, 0.25, -0.38]
]
```
输入 ID 为 `0`,就取第 0 行;输入 ID 为 `3`,就取第 3 行。
所以从工程角度看:
```text
Embedding = 根据 ID 从矩阵中取出对应向量
```
这就是查表。
## one-hot 乘矩阵和 Embedding 查表等价
如果一个词的 one-hot 是:
```text
[0, 0, 1, 0]
```
Embedding 矩阵是:
```text
E =
[
[0.1, 0.2, 0.3],
[0.4, 0.5, 0.6],
[0.7, 0.8, 0.9],
[1.0, 1.1, 1.2]
]
```
那么:
```text
[0, 0, 1, 0] × E = [0.7, 0.8, 0.9]
```
也就是取出了矩阵第 2 行。
所以 Embedding 查表和 one-hot 乘矩阵在数学上是等价的。不同点在于,真实模型不会真的构造巨大的 one-hot 向量,而是直接用 ID 查矩阵行,这样更高效。
用 NumPy 演示一下:
```python
import numpy as np
embedding_matrix = np.array([
[0.1, 0.2, 0.3],
[0.4, 0.5, 0.6],
[0.7, 0.8, 0.9],
[1.0, 1.1, 1.2],
])
one_hot = np.array([0, 0, 1, 0])
by_matmul = one_hot @ embedding_matrix
by_lookup = embedding_matrix[2]
print(by_matmul)
print(by_lookup)
```
两个结果完全一样。
## Embedding 的第二层理解:连续语义空间
Embedding 不是简单地把向量变短。
如果只是降维,那它最多算一个压缩工具。Embedding 真正有价值的地方,是它能在训练中学习对象之间的关系。
比如在 NLP 中,模型会从上下文里学习:
```text
公主、王妃、女王
```
这些词可能经常出现在相似语境里。
又比如:
```text
汽车、公交、火车
```
这些词可能经常和交通、出行相关。
经过训练后,这些词在向量空间中可能会更接近。

这就是 Embedding 的关键能力:
```text
让离散词语拥有可以计算距离的连续表示
```
one-hot 里,任意两个不同词都是“完全不同”。
Embedding 里,不同词之间可以有远近关系:
```text
距离近:语义、上下文或行为相似
距离远:关系较弱
```
## 语义相似可以怎么计算
常用方式是余弦相似度:
```text
cos(a, b) = (a · b) / (||a|| × ||b||)
```
其中:
- `a · b` 是点积
- `||a||` 是向量长度
- 值越接近 1,方向越接近
- 值越接近 0,关系越弱
- 值越接近 -1,方向越相反
Python 示例:
```python
import numpy as np
def cosine_similarity(a, b):
a = np.asarray(a, dtype=float)
b = np.asarray(b, dtype=float)
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
princess = [0.8, 0.2, 0.1]
queen = [0.75, 0.25, 0.12]
car = [0.1, 0.9, 0.4]
print(cosine_similarity(princess, queen))
print(cosine_similarity(princess, car))
```
如果向量学得合理,“公主”和“女王”的相似度应该比“公主”和“汽车”更高。
## Embedding 是怎么学出来的
Embedding 矩阵不是人工写死的,它是模型参数。
训练开始时,Embedding 向量通常是随机初始化的。模型训练过程中,反向传播会不断更新这些向量。

例如文本分类任务中:
```text
词 ID -> Embedding -> 文本特征提取网络 -> 分类层 -> 损失函数
```
如果模型预测错了,损失函数会产生梯度。梯度一路反传,最后会更新本次出现过的词向量。
也就是说,Embedding 不是天生有语义,而是在任务中被训练出有用结构。
## PyTorch 里的 Embedding
PyTorch 中使用 `nn.Embedding`。
```python
import torch
import torch.nn as nn
embedding = nn.Embedding(
num_embeddings=10,
embedding_dim=4
)
token_ids = torch.tensor([0, 1, 2, 3])
vectors = embedding(token_ids)
print(vectors.shape)
print(vectors)
```
输出形状是:
```text
torch.Size([4, 4])
```
意思是:输入 4 个 token ID,每个 ID 映射成 4 维向量。
如果输入是一个 batch:
```python
token_ids = torch.tensor([
[0, 1, 2, 3],
[3, 2, 1, 0],
])
vectors = embedding(token_ids)
print(vectors.shape)
```
输出形状是:
```text
torch.Size([2, 4, 4])
```
对应:
```text
batch_size × sequence_length × embedding_dim
```
## 一个简单文本分类模型
下面写一个很小的 PyTorch 文本分类模型:
```python
import torch
import torch.nn as nn
class TextClassifier(nn.Module):
def __init__(self, vocab_size, embedding_dim, num_classes):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=0)
self.classifier = nn.Linear(embedding_dim, num_classes)
def forward(self, token_ids):
x = self.embedding(token_ids)
mask = (token_ids != 0).unsqueeze(-1)
x = x * mask
length = mask.sum(dim=1).clamp(min=1)
x = x.sum(dim=1) / length
logits = self.classifier(x)
return logits
model = TextClassifier(
vocab_size=10000,
embedding_dim=128,
num_classes=2
)
batch_token_ids = torch.randint(1, 10000, (32, 20))
logits = model(batch_token_ids)
print(logits.shape)
```
这个模型结构非常简单:
```text
Embedding -> 平均池化 -> 线性分类
```
它不一定适合复杂任务,但很适合理解 Embedding 如何进入神经网络。
## padding_idx 的作用
文本长度经常不一致。
比如:
```text
[公, 主, 很, 漂, 亮]
[王, 妃, 漂, 亮]
```
为了组成 batch,通常会补齐:
```text
[公, 主, 很, 漂, 亮]
[王, 妃, 漂, 亮, PAD]
```
如果约定 `PAD` 的 ID 是 0,可以这样写:
```python
embedding = nn.Embedding(
num_embeddings=10000,
embedding_dim=128,
padding_idx=0
)
```
`padding_idx=0` 表示第 0 行向量用于补齐,不参与正常更新。
这样模型不会把补齐符号当成真正词语学习。
## Keras 里的 Embedding
Keras 里也可以直接使用 Embedding 层:
```python
import tensorflow as tf
from tensorflow.keras import layers, models
model = models.Sequential([
layers.Embedding(
input_dim=10000,
output_dim=128,
mask_zero=True
),
layers.GlobalAveragePooling1D(),
layers.Dense(2, activation="softmax")
])
model.summary()
```
参数含义:
- `input_dim`:词表大小
- `output_dim`:向量维度
- `mask_zero`:是否把 0 当作 padding
输入形状:
```text
batch_size × sequence_length
```
输出形状:
```text
batch_size × sequence_length × output_dim
```
## Embedding 和 CNN 的关系
原文标签里提到 CNN,这里也顺带说一下 NLP 中的 CNN。
文本经过 Embedding 后,会得到一个二维结构:
```text
sequence_length × embedding_dim
```
可以把它理解成一张“文本特征图”:
```text
每一行:一个词的向量
每一列:某个向量维度
```
然后可以用一维卷积提取局部 n-gram 特征。
PyTorch 示例:
```python
import torch
import torch.nn as nn
class TextCNN(nn.Module):
def __init__(self, vocab_size, embedding_dim, num_classes):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=0)
self.conv = nn.Conv1d(
in_channels=embedding_dim,
out_channels=128,
kernel_size=3,
padding=1
)
self.relu = nn.ReLU()
self.pool = nn.AdaptiveMaxPool1d(1)
self.fc = nn.Linear(128, num_classes)
def forward(self, token_ids):
x = self.embedding(token_ids)
x = x.transpose(1, 2)
x = self.conv(x)
x = self.relu(x)
x = self.pool(x).squeeze(-1)
logits = self.fc(x)
return logits
model = TextCNN(
vocab_size=10000,
embedding_dim=128,
num_classes=2
)
token_ids = torch.randint(1, 10000, (32, 20))
logits = model(token_ids)
print(logits.shape)
```
注意这里的 `transpose(1, 2)`。
Embedding 输出是:
```text
batch_size × sequence_length × embedding_dim
```
而 `Conv1d` 需要:
```text
batch_size × channels × length
```
所以要把 `embedding_dim` 转成通道维。
## 预训练词向量
Embedding 可以随机初始化,也可以使用预训练词向量。
随机初始化的好处是简单,模型会根据当前任务自己学习。
预训练向量的好处是已经从大量语料中学到了一些通用语义结构,适合数据较少或希望利用外部知识的场景。
PyTorch 中可以这样加载:
```python
import torch
import torch.nn as nn
pretrained = torch.randn(10000, 128)
embedding = nn.Embedding.from_pretrained(
pretrained,
freeze=False,
padding_idx=0
)
```
`freeze=False` 表示训练时继续更新这些向量。
如果希望固定预训练向量:
```python
embedding = nn.Embedding.from_pretrained(
pretrained,
freeze=True,
padding_idx=0
)
```
## Embedding 不只用于 NLP
Embedding 在 NLP 中很常见,但它不只属于 NLP。
只要是离散 ID,都可以考虑 Embedding:
- 用户 ID
- 商品 ID
- 店铺 ID
- 城市 ID
- 类目 ID
- 广告 ID
- 图节点 ID
推荐系统里,一个简单思路是:
```text
用户 ID -> 用户向量
商品 ID -> 商品向量
用户向量 · 商品向量 -> 匹配分数
```
PyTorch 示例:
```python
import torch
import torch.nn as nn
class DotProductRecommender(nn.Module):
def __init__(self, num_users, num_items, embedding_dim):
super().__init__()
self.user_embedding = nn.Embedding(num_users, embedding_dim)
self.item_embedding = nn.Embedding(num_items, embedding_dim)
def forward(self, user_ids, item_ids):
user_vec = self.user_embedding(user_ids)
item_vec = self.item_embedding(item_ids)
score = (user_vec * item_vec).sum(dim=1)
return score
model = DotProductRecommender(
num_users=100000,
num_items=50000,
embedding_dim=64
)
user_ids = torch.tensor([1, 2, 3])
item_ids = torch.tensor([10, 20, 30])
scores = model(user_ids, item_ids)
print(scores)
```
这说明 Embedding 的本质不是“词向量”这么窄,而是:
```text
把离散对象映射成可训练的连续向量
```
## 常见误区
Embedding 不是天然有语义。
随机初始化时,向量只是随机数。只有经过合适任务训练,向量空间才会逐渐形成有意义的结构。
Embedding 也不是越大越好。
参数量可以估算为:
```text
参数量 = vocab_size × embedding_dim
```
词表越大、维度越高,参数量越大,训练成本和过拟合风险也越高。
整数 ID 不能直接当连续特征用。
比如:
```text
北京 -> 1
上海 -> 2
广州 -> 3
```
这里的 3 并不比 1 更“大”,ID 只是编号。直接把 ID 当数值喂给模型,容易引入错误的大小关系。
one-hot 能表示身份,但难表达关系;Embedding 则让模型可以学习关系。
Embedding 层的本质可以分成两层理解。
第一层,它是查表:
```text
输入 ID,取出 Embedding 矩阵中对应的一行
```
第二层,它是表示学习:
```text
通过训练,把离散对象放到连续向量空间里,让相似对象距离更近
```
one-hot 的问题不是不能用,而是它太独立。它能很好地区分“谁是谁”,但不擅长表达“谁和谁更像”。
Embedding 正是为了解决这个问题:它让词、用户、商品、类别这些离散对象拥有可学习、可计算、可比较的向量表示。
理解了这一点,再看 NLP、推荐系统或搜索里的 Embedding,就会清楚很多。