深入理解 Embedding 层的本质:不只是降维




2019-02-28

blog_main_img

很多人第一次接触 Embedding 时,会把它理解成“把 one-hot 降维”。这个理解没错,但只说了一半。Embedding 更重要的能力是:把离散对象放进一个连续向量空间,让模型能够学习它们之间的关系。

Embedding 层经常出现在 NLP、推荐系统、搜索、广告和图学习里。

从 one-hot 的问题说起

假设有一句话:

公主很漂亮

如果用 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 里,“公主”“王妃”“汽车”“石头”之间的距离都差不多。它只能告诉模型“它们不是同一个词”,却很难告诉模型“谁和谁更像”。

这就是 one-hot 最大的短板:它表达身份很强,表达关系很弱。

Embedding 的第一层理解:查表

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 乘矩阵和 Embedding 查表等价

如果一个词的 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 不是简单地把向量变短。

如果只是降维,那它最多算一个压缩工具。Embedding 真正有价值的地方,是它能在训练中学习对象之间的关系。

比如在 NLP 中,模型会从上下文里学习:

公主、王妃、女王

这些词可能经常出现在相似语境里。

又比如:

汽车、公交、火车

这些词可能经常和交通、出行相关。

经过训练后,这些词在向量空间中可能会更接近。

Embedding 语义空间

这就是 Embedding 的关键能力:

让离散词语拥有可以计算距离的连续表示

one-hot 里,任意两个不同词都是“完全不同”。

Embedding 里,不同词之间可以有远近关系:

距离近:语义、上下文或行为相似
距离远:关系较弱

语义相似可以怎么计算

常用方式是余弦相似度:

cos(a, b) = (a · b) / (||a|| × ||b||)

其中:

  • a · b 是点积
  • ||a|| 是向量长度
  • 值越接近 1,方向越接近
  • 值越接近 0,关系越弱
  • 值越接近 -1,方向越相反

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 向量通常是随机初始化的。模型训练过程中,反向传播会不断更新这些向量。

Embedding 训练流程

例如文本分类任务中:

词 ID -> Embedding -> 文本特征提取网络 -> 分类层 -> 损失函数

如果模型预测错了,损失函数会产生梯度。梯度一路反传,最后会更新本次出现过的词向量。

也就是说,Embedding 不是天生有语义,而是在任务中被训练出有用结构。

PyTorch 里的 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 如何进入神经网络。

padding_idx 的作用

文本长度经常不一致。

比如:

[公, 主, 很, 漂, 亮]
[王, 妃, 漂, 亮]

为了组成 batch,通常会补齐:

[公, 主, 很, 漂, 亮]
[王, 妃, 漂, 亮, PAD]

如果约定 PAD 的 ID 是 0,可以这样写:

embedding = nn.Embedding(
    num_embeddings=10000,
    embedding_dim=128,
    padding_idx=0
)

padding_idx=0 表示第 0 行向量用于补齐,不参与正常更新。

这样模型不会把补齐符号当成真正词语学习。

Keras 里的 Embedding

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

Embedding 和 CNN 的关系

原文标签里提到 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

Embedding 在 NLP 中很常见,但它不只属于 NLP。

只要是离散 ID,都可以考虑 Embedding:

  • 用户 ID
  • 商品 ID
  • 店铺 ID
  • 城市 ID
  • 类目 ID
  • 广告 ID
  • 图节点 ID

推荐系统里,一个简单思路是:

用户 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,就会清楚很多。