2019-02-28
很多人第一次接触 Embedding 时,会把它理解成“把 one-hot 降维”。这个理解没错,但只说了一半。Embedding 更重要的能力是:把离散对象放进一个连续向量空间,让模型能够学习它们之间的关系。
Embedding 层经常出现在 NLP、推荐系统、搜索、广告和图学习里。
假设有一句话:
公主很漂亮
如果用 one-hot 表示,每个字都可以变成一个只有一个位置为 1、其他位置为 0 的向量。
比如词表里只有这几个字时,可以写成:
公 [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]
这种表示非常清楚:每个字都有独立身份,互不混淆。
如果词表扩大一些,向量也会变长:
公 [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 的优点是简单、明确、不会混淆。
但它也有一个很大的问题:每个词之间完全独立,无法表达语义关系。
比如:
公主很漂亮
王妃很漂亮
从人类理解来看,“公主”和“王妃”有某种语义接近性;“漂亮”也和描述人物外貌有关。
但在 one-hot 里,“公主”“王妃”“汽车”“石头”之间的距离都差不多。它只能告诉模型“它们不是同一个词”,却很难告诉模型“谁和谁更像”。
这就是 one-hot 最大的短板:它表达身份很强,表达关系很弱。
Embedding 层最直接的实现方式就是查表。
假设有一个词表:
公 -> 0
主 -> 1
很 -> 2
漂 -> 3
亮 -> 4
输入句子:
公主很漂亮
可以先变成 ID 序列:
[0, 1, 2, 3, 4]
Embedding 层内部维护一个矩阵:
Embedding Matrix = vocab_size × embedding_dim
比如:
vocab_size = 5
embedding_dim = 3
矩阵可能长这样:
[
[ 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 行。
所以从工程角度看:
Embedding = 根据 ID 从矩阵中取出对应向量
这就是查表。
如果一个词的 one-hot 是:
[0, 0, 1, 0]
Embedding 矩阵是:
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]
]
那么:
[0, 0, 1, 0] × E = [0.7, 0.8, 0.9]
也就是取出了矩阵第 2 行。
所以 Embedding 查表和 one-hot 乘矩阵在数学上是等价的。不同点在于,真实模型不会真的构造巨大的 one-hot 向量,而是直接用 ID 查矩阵行,这样更高效。
用 NumPy 演示一下:
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 真正有价值的地方,是它能在训练中学习对象之间的关系。
比如在 NLP 中,模型会从上下文里学习:
公主、王妃、女王
这些词可能经常出现在相似语境里。
又比如:
汽车、公交、火车
这些词可能经常和交通、出行相关。
经过训练后,这些词在向量空间中可能会更接近。
这就是 Embedding 的关键能力:
让离散词语拥有可以计算距离的连续表示
one-hot 里,任意两个不同词都是“完全不同”。
Embedding 里,不同词之间可以有远近关系:
距离近:语义、上下文或行为相似
距离远:关系较弱
常用方式是余弦相似度:
cos(a, b) = (a · b) / (||a|| × ||b||)
其中:
a · b 是点积||a|| 是向量长度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 向量通常是随机初始化的。模型训练过程中,反向传播会不断更新这些向量。
例如文本分类任务中:
词 ID -> Embedding -> 文本特征提取网络 -> 分类层 -> 损失函数
如果模型预测错了,损失函数会产生梯度。梯度一路反传,最后会更新本次出现过的词向量。
也就是说,Embedding 不是天生有语义,而是在任务中被训练出有用结构。
PyTorch 中使用 nn.Embedding。
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)
输出形状是:
torch.Size([4, 4])
意思是:输入 4 个 token ID,每个 ID 映射成 4 维向量。
如果输入是一个 batch:
token_ids = torch.tensor([
[0, 1, 2, 3],
[3, 2, 1, 0],
])
vectors = embedding(token_ids)
print(vectors.shape)
输出形状是:
torch.Size([2, 4, 4])
对应:
batch_size × sequence_length × embedding_dim
下面写一个很小的 PyTorch 文本分类模型:
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)
这个模型结构非常简单:
Embedding -> 平均池化 -> 线性分类
它不一定适合复杂任务,但很适合理解 Embedding 如何进入神经网络。
文本长度经常不一致。
比如:
[公, 主, 很, 漂, 亮]
[王, 妃, 漂, 亮]
为了组成 batch,通常会补齐:
[公, 主, 很, 漂, 亮]
[王, 妃, 漂, 亮, PAD]
如果约定 PAD 的 ID 是 0,可以这样写:
embedding = nn.Embedding(
num_embeddings=10000,
embedding_dim=128,
padding_idx=0
)
padding_idx=0 表示第 0 行向量用于补齐,不参与正常更新。
这样模型不会把补齐符号当成真正词语学习。
Keras 里也可以直接使用 Embedding 层:
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输入形状:
batch_size × sequence_length
输出形状:
batch_size × sequence_length × output_dim
原文标签里提到 CNN,这里也顺带说一下 NLP 中的 CNN。
文本经过 Embedding 后,会得到一个二维结构:
sequence_length × embedding_dim
可以把它理解成一张“文本特征图”:
每一行:一个词的向量
每一列:某个向量维度
然后可以用一维卷积提取局部 n-gram 特征。
PyTorch 示例:
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 输出是:
batch_size × sequence_length × embedding_dim
而 Conv1d 需要:
batch_size × channels × length
所以要把 embedding_dim 转成通道维。
Embedding 可以随机初始化,也可以使用预训练词向量。
随机初始化的好处是简单,模型会根据当前任务自己学习。
预训练向量的好处是已经从大量语料中学到了一些通用语义结构,适合数据较少或希望利用外部知识的场景。
PyTorch 中可以这样加载:
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 表示训练时继续更新这些向量。
如果希望固定预训练向量:
embedding = nn.Embedding.from_pretrained(
pretrained,
freeze=True,
padding_idx=0
)
Embedding 在 NLP 中很常见,但它不只属于 NLP。
只要是离散 ID,都可以考虑 Embedding:
推荐系统里,一个简单思路是:
用户 ID -> 用户向量
商品 ID -> 商品向量
用户向量 · 商品向量 -> 匹配分数
PyTorch 示例:
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 的本质不是“词向量”这么窄,而是:
把离散对象映射成可训练的连续向量
Embedding 不是天然有语义。
随机初始化时,向量只是随机数。只有经过合适任务训练,向量空间才会逐渐形成有意义的结构。
Embedding 也不是越大越好。
参数量可以估算为:
参数量 = vocab_size × embedding_dim
词表越大、维度越高,参数量越大,训练成本和过拟合风险也越高。
整数 ID 不能直接当连续特征用。
比如:
北京 -> 1
上海 -> 2
广州 -> 3
这里的 3 并不比 1 更“大”,ID 只是编号。直接把 ID 当数值喂给模型,容易引入错误的大小关系。
one-hot 能表示身份,但难表达关系;Embedding 则让模型可以学习关系。
Embedding 层的本质可以分成两层理解。
第一层,它是查表:
输入 ID,取出 Embedding 矩阵中对应的一行
第二层,它是表示学习:
通过训练,把离散对象放到连续向量空间里,让相似对象距离更近
one-hot 的问题不是不能用,而是它太独立。它能很好地区分“谁是谁”,但不擅长表达“谁和谁更像”。
Embedding 正是为了解决这个问题:它让词、用户、商品、类别这些离散对象拥有可学习、可计算、可比较的向量表示。
理解了这一点,再看 NLP、推荐系统或搜索里的 Embedding,就会清楚很多。