词嵌入深度实战:从分布假设、Word2Vec 负采样到 GloVe 与上下文化表示的工程全解

词嵌入(Word Embedding)是把离散符号映射为连续稠密向量的技术,它是一切现代 NLP 与 LLM 的底层地基:Transformer 的第一层就是一个巨大的嵌入查找表,检索式增强(RAG)的"语义向量"本质也是嵌入,甚至连多模态对齐都依赖共享嵌入空间。本文从分布假设的第一性原理出发,推导 Word2Vec 负采样与 GloVe 的数学,给出可复现的 PyTorch 实现,并落到 Transformer 嵌入层与工程化检索嵌入的真实生产细节。

一、为什么需要嵌入:从符号表到连续向量空间

语言模型最早用 one-hot 表示词:词表大小为 V,每个词是一个 V 维单位向量。它在工程上立刻暴露三个致命问题:

维度 one-hot 的问题 嵌入的解法
维度灾难 词表 10 万 → 每词 10 万维稀疏向量,矩阵无法存 压缩到 100~4096 维稠密向量
正交性 任意两 one-hot 内积为 0,"猫"与"狗"毫不相似 语义相近的词向量内积/余弦相近
泛化 未见组合无法推理 连续空间可插值、可类比

核心思想来自 分布假设(Distributional Hypothesis):"出现在相似上下文中的词,语义相似"(Harris, 1954;Firth, 1957)。嵌入的学习目标,就是让共现模式编码进向量几何。

二、经典静态嵌入的第一性原理

2.1 Word2Vec:CBOW 与 Skip-gram

Word2Vec(Mikolov et al., 2013)给出两个互为对偶的架构:

  • CBOW:用上下文窗口词预测中心词(适合高频词、训练快)。
  • Skip-gram:用中心词预测上下文词(对低频词、罕见组合更鲁棒,工业界默认)。

Skip-gram 的朴素目标是最大化平均对数似然:

$$

\frac{1}{T}\sum_{t=1}^{T}\sum_{-c\le j\le c, j\ne 0} \log p(w_{t+j}\mid w_t)

$$

其中 $c$ 为窗口半宽。朴素 softmax 概率需对全词表归一化,复杂度 $O(V)$,无法接受。

2.2 负采样:NCE 的简化

Word2Vec 用 Negative Sampling 把多分类变成二分类。对每个正样本 $(w_o, w_c)$,采样 $k$ 个负词 $w_{neg}\sim P_n(w)$(通常用 $P_n(w)^{3/4}$ 平滑 unigram 分布)。损失为:

$$

\mathcal{L} = -\log\sigma(\mathbf{v}'_{w_o}\!^\top \mathbf{v}_{w_c})

  • \sum_{i=1}^{k}\mathbb{E}_{w_i\sim P_n}\big[\log\sigma(-\mathbf{v}'_{w_i}\!^\top \mathbf{v}_{w_c})\big]

$$

其中 $\mathbf{v}_w$ 是输入(center)向量,$\mathbf{v}'_w$ 是输出(context)向量,$\sigma$ 为 sigmoid。直觉:拉高真实上下文词的得分,压低噪声词的得分。$k$ 通常取 5~15(小语料取更大)。

2.3 层级 Softmax(备选)

用 Huffman 树把词表组织成二叉树,每个词是叶子,概率分解为路径上二分类乘积,复杂度降到 $O(\log V)$,对低频词更友好。但它依赖词频构建树、实现更复杂,负采样因其简洁成为事实标准。

2.4 GloVe:共现矩阵上的加权最小二乘

GloVe(Pennington et al., 2014)不走局部滑窗似然,而是直接对全局 共现计数矩阵 $X_{ij}$(词 $i$ 在词 $j$ 上下文出现的次数)建模。核心洞察:词向量点积应等于共现的对数:

$$

\mathbf{w}_i^\top \tilde{\mathbf{w}}_j + b_i + \tilde{b}_j \approx \log X_{ij}

$$

损失为加权最小二乘:

$$

\mathcal{J} = \sum_{i,j=1}^{V} f(X_{ij})\,

\big(\mathbf{w}_i^\top \tilde{\mathbf{w}}_j + b_i + \tilde{b}_j - \log X_{ij}\big)^2

$$

权重 $f(x)=\min(1,(x/x_{\max})^\alpha)$($\alpha\approx0.75$)抑制高频噪声、保护稀有共现。GloVe 兼具全局统计信息与矩阵分解的可解释性。

2.5 fastText:子词 n-gram 与 OOV

fastText(Bojanowski et al., 2017)把词表示为其 字符 n-gram(3~6 元)向量之和,因此能为零样本 OOV 词(拼写变体、新词、形态衍生)合成向量,对小语种与形态丰富语言尤其有效。代价是词表需存 n-gram 而非整词,内存更高。

三、从零实现:PyTorch 中的 Skip-gram + 负采样

下面是可训练的最小可复现实现(教学规模,工业用 gensim/torch.nn.Embedding 的噪声对比损失)。


import torch
import torch.nn as nn
import torch.nn.functional as F
from collections import Counter
import random

class SkipGramNeg(nn.Module):
    def __init__(self, vocab_size, embed_dim, unigram_counts, neg_samples=5):
        super().__init__()
        self.in_emb = nn.Embedding(vocab_size, embed_dim)   # 中心词
        self.out_emb = nn.Embedding(vocab_size, embed_dim)  # 上下文词
        self.neg_samples = neg_samples
        # 负采样分布 P_n(w)^(3/4)
        self.noise_dist = torch.tensor(
            [c**0.75 for c in unigram_counts], dtype=torch.float
        )

    def forward(self, center, context, neg_mask=None):
        # center/context: [B]
        v_c = self.in_emb(center)              # [B, d]
        v_o = self.out_emb(context)            # [B, d]
        pos = torch.bmm(v_c.unsqueeze(1), v_o.unsqueeze(2)).squeeze()  # [B]
        pos_loss = F.logsigmoid(pos).mean()

        # 采样负词: [B, neg]
        neg = torch.multinomial(self.noise_dist, center.size(0)*self.neg_samples, replacement=True)
        neg = neg.view(center.size(0), self.neg_samples)
        v_neg = self.out_emb(neg)              # [B, neg, d]
        neg_score = torch.bmm(v_c.unsqueeze(1), v_neg.transpose(1,2)).squeeze(1)  # [B, neg]
        neg_loss = F.logsigmoid(-neg_score).mean()
        return -(pos_loss + neg_loss)

# 训练循环(伪代码骨架)
# model = SkipGramNeg(V, d, counts)
# opt = torch.optim.SGD(model.parameters(), lr=0.025)
# for center, ctx in batches:
#     loss = model(center, ctx)
#     loss.backward(); opt.step()

要点:logsigmoid 直接对应负采样损失的形式;学习率随训练进度线性退火(lr * (1 - progress))能显著稳定收敛;负采样数 $k$ 越大越平滑但越慢。

四、GloVe 训练循环实现

GloVe 需要先把语料扫一遍建共现矩阵,再最小化加权平方误差。


import torch, torch.nn as nn

class GloVe(nn.Module):
    def __init__(self, vocab_size, embed_dim, xmax=100.0, alpha=0.75):
        super().__init__()
        self.w = nn.Parameter(torch.randn(vocab_size, embed_dim) * 0.01)
        self.w_tilde = nn.Parameter(torch.randn(vocab_size, embed_dim) * 0.01)
        self.b = nn.Parameter(torch.zeros(vocab_size))
        self.b_tilde = nn.Parameter(torch.zeros(vocab_size))
        self.xmax, self.alpha = xmax, alpha

    def weight(self, x):
        return torch.clamp((x / self.xmax) ** self.alpha, max=1.0)

    def forward(self, i, j, xij):
        # i,j: [B] 词索引; xij: [B] 共现计数
        logx = torch.log(xij + 1e-10)
        inner = (self.w[i] * self.w_tilde[j]).sum(1) + self.b[i] + self.b_tilde[j] - logx
        return (self.weight(xij) * inner.pow(2)).mean()

# 仅对非零共现条目 (i,j,xij) 构造批次,稀疏但高效

建表阶段注意:窗口需对称(左右各 c),共现计数可加距离衰减权重 $1/d$(近邻权重更高)。

五、Transformer 时代:嵌入层如何工作

在 LLM 中,嵌入表就是 nn.Embedding(vocab, d_model),它把 token id 查表成 d_model 维向量。三个工程关键点直接决定训练稳定性与效果:

1. 与位置编码相加。 词嵌入本身不含顺序信息,必须叠加上位置信号(RoPE 则旋转进 q/k,见《相对位置编码深度实战》)。


class TokenEmbedding(nn.Module):
    def __init__(self, vocab, d_model):
        super().__init__()
        self.emb = nn.Embedding(vocab, d_model)

    def forward(self, x, pos_emb=None):
        h = self.emb(x) * (self.d_model ** 0.5)  # 尺度缩放
        if pos_emb is not None:
            h = h + pos_emb
        return h

2. 尺度 $\sqrt{d_{\text{model}}$。 原始 Transformer 把嵌入乘 $\sqrt{d_{\text{model}}$ 再与位置编码相加,抵消后续 LayerNorm 前的大方差(否则高维随机向量范数过大,淹没位置信号)。这是 Pre-LN 时代仍常保留的约定。

3. 初始化与权重共享。 嵌入表建议用截断正态($\sigma=0.02$)或 Xavier;权重共享(tied embedding) 让输入嵌入表与 LM 输出投影头共用同一矩阵(lm_head.weight = emb.weight),可省去 vocab × d 的额外参数、提升小模型表现,前提是嵌入维度等于隐藏维度。

六、现代视角:从静态到上下文化与检索式嵌入

上下文化嵌入。 静态嵌入(Word2Vec/GloVe)一个词只有一个向量,无法区分"苹果手机"与"苹果水果"。ELMo、BERT 一脉用双向语言模型产出 上下文相关 向量——同一词在不同句子中表示不同。这正是 LLM 隐藏状态可被直接当作句/词级嵌入使用的由来。

检索式(稠密)嵌入。 RAG 的语义向量通常由 bi-encoder 产出:查询与文档分别编码为向量,用余弦相似度在向量库检索。工程上:

  • 归一化:推理前对向量做 L2 归一化,使内积等价余弦,避免长度主导相似度。
  • 温度:对比训练的损失 $\log\frac{e^{\cos(q,d^+)/\tau}}{\sum e^{\cos(q,d)/\tau}}$,温度 $\tau$ 控制分布锐度。
  • Matryoshka 表示学习:让同一向量前缀子向量即可用,便于按检索精度/存储成本动态截断维度。
  • 量化:生产向量库普遍用 int8(标量量化)或二进制(乘积量化 PQ)压缩,召回率损失 <1% 时存储降 4~32 倍。

七、生产陷阱清单

陷阱 现象 正确做法
低频词欠拟合 长尾词向量随机游走 提高负采样 $k$,或用 fastText 子词兜底
未归一化检索向量 高范数文档总是"更相似" 推理前 L2 归一化,再算余弦
嵌入维度≠隐藏维度却强行 tied 形状报错 仅在 d_emb==d_hidden 时共享权重
训练集与推理 tokenizer 不一致 OOV 全变 固定 tokenizer 与词表版本,持久化
共现矩阵未加距离衰减 远近上下文同等权重 加 $1/d$ 衰减或仅用近窗
温度 $\tau$ 过大 难负例被淹没、区分度差 对比训练 $\tau$ 取 0.02~0.1 区间调参
忘记 $\sqrt{d}$ 缩放 位置信号被淹没、收敛慢 嵌入输出乘 $\sqrt{d_{\text{model}}$
向量库未量化 内存爆、延迟高 int8/PQ 压缩,先测召回率损失

结语

词嵌入从分布假设出发,经 Word2Vec 负采样、GloVe 全局统计、fastText 子词,演化成今天 Transformer 的嵌入表与检索式稠密向量。理解它的第一性原理——共现如何编码进几何、损失如何把"预测邻居"变成"向量邻近"——能让你在调试 LLM 训练不稳、RAG 召回偏差、向量库成本失控时,直击根因而非盲目调参。它与本系列已发的归一化、位置编码、交叉注意力、残差、学习率与梯度累积共同构成"现代 LLM 内部机制与训练工程"的完整拼图。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿
网站二维码

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部
/* 跳过导航链接 (无障碍) */ position: absolute; top: -100px; left: 15px; z-index: 99999; padding: 8px 16px; background: #007bff; color: #fff; font-size: 14px; border-radius: 0 0 4px 4px; text-decoration: none; transition: top 0.2s; } top: 0; outline: 3px solid #0056b3; }