负采样与对比学习——从 word2vec 到 CLIP

教科书版本的 word2vec 依赖一个 softmax 输出层,把模型的原始得分转成词表中每个词的概率——扩展性问题正是从这里来的。训练时,损失每次只用到每对样本的一个概率 P(target | center)。但 softmax 把它定义为整个词表总量中的一份:

P(target) = exp(score_target) / Σ_w exp(score_w)

分母要对全部 V 个词求和,所以为了拿到你真正想要的那一个数,你必须算出全部 V 个得分——不是因为你需要它们,而是因为归一化项需要它们。少算任何一个得分,归一化就崩了。

当词表 V = 10⁶、嵌入维度 d = 300 时,把中心词与每个词打分是一次 V × d 的矩阵乘法——3 亿次乘加——外加为分母做的一百万次指数运算,而这只是一个训练对的开销。

为了感受这个量级,把每个样本的计算量与 MNIST 比一比:

                          INPUT      HIDDEN     OUTPUT     OUTPUT-LAYER MATMUL
MNIST                     784        128        10         128 × 10  = 1,280 ops
word2vec  (V = 10⁶)       V → 1      300        V          300 × 10⁶ = 300,000,000 ops

MNIST 是一个 10 类分类问题;word2vec 则是百万类的——词表里每个词对应一个输出神经元。输出层里的一切开销——权重、矩阵乘法、softmax——都随这个类别数线性增长。

2013 年的后续论文提出负采样,作为层次 softmax 的替代方案。它将词表预测改为二分类:区分观测到的词与上下文词对,以及从噪声分布中抽取的词对。这改变了训练目标,并非仅仅近似 softmax 的分母。

一个正样本和 k 个负样本需要 k+1 次点积,成本为 O((k+1)d),而不是 O(Vd)。只有被选中的输出向量会收到梯度,重复抽到的样本会重复计入。本文将输出向量存为 (V, d) 矩阵 Ep 的行,即 word2vec 文章中 E' 布局的转置。

比较观测词对与随机词对,将负采样与对比学习联系起来。CLIP 和稠密检索模型使用相关思想,但其常见的 softmax 损失不同于 word2vec 中独立的 sigmoid 项。

从样本对到负样本

word2vec 的训练数据是滑动窗口扫过语料得到的 (center, context) 对:the cat sat on the mat 产生 (sat, cat)、(sat, on)、(sat, the) 等等——数十亿条真实的共现。

负采样保持这些对完全不变,只是不再单纯地最大化真实 (center, context) 匹配的似然,而是每步再加 k 个随机对作为配重——调整权重,让它们的似然同时被最小化。

两类对共享同一个中心词,但挑选陪伴词(搭档)的方式不同:

正样本词对来自上下文窗口。负样本独立于中心词抽取搭配词,通常按 count(w)^0.75 加权。抽到的词也可能是真实上下文词:“负”指的是抽样方式,并不表示该词对从未出现。因此可能产生假负样本。

向量查表和点积运算保持不变。变化的是对哪些词评分,以及这些分数如何进入损失函数。

原始点积 v_c · v'_w 可以是任意实数——正的、负的、大的、小的——但损失需要一个 0 到 1 之间的概率:「这一对有多可能是真的?」把我们带到那里的是 sigmoid 函数 σ。

有了得分,我们就可以用 sigmoid 把每个得分变成概率。σ 把任意实值得分压进 (0, 1):

点积 v_c · v'_wσ模型说
很大的正数~1「真实对」
~00.5不确定
很大的负数~0「随机对」

对每个真实对,我们希望 σ 爬向 1;对每个随机对,希望它落向 0——训练把点积往这些方向推。

在与 word2vec 那篇文章相同的五词玩具例子上,下面的小组件跑的正是这个打分步骤。挑一个 (center, target) 训练对,切换哪些词被采样为负样本,然后逐步走过这 k+1 个点积——score[w] = v_c · v'_w,逐项展开,再过一遍 sigmoid。只有目标和负样本会被打分;Ep 的其他行保持灰色,从不被读取。

scoring one pair · k+1 dot products, not V
training pair
negatives — sampled
all 3 dot products done — loss is defined
E (V=5 × d=3)
row sat = v_c
cat
0.21
-0.43
0.15
mat
0.07
0.62
-0.31
on
-0.55
0.18
0.40
sat
0.33
-0.27
0.84
the
-0.12
0.49
-0.06
Ep (V=5 × d=3)
target + k negatives — the rest unread
cat
0.45
0.62
-0.20
mat
-0.31
0.15
0.48
on
0.18
-0.40
0.27
sat
-0.22
0.33
0.11
the
0.07
0.55
-0.39
scores → σ target + k negatives
Stage 2 — score · k+1 dot products, not V
cat
mat
on
sat
the
-0.19
0.26
0.39
·
skip
Stage 3 — sigmoid · independent σ, no softmax
cat
mat
on
sat
the
0.45
0.56
0.60
·
—
each score through its own σ — no shared denominator; target → 1, negatives → 0.
loss −Σ log σ
k+1 terms
1.95
wordrolescoreσ(score)direction
onpositive+0.3940.597→ pushed toward 1
catnegative-0.1870.453→ pushed toward 0
matnegative+0.2600.565→ pushed toward 0

每个 sigmoid 估计的是所选采样方案下的数据或噪声标签概率。它不是 P(上下文词 | 中心词),并且取决于噪声分布和负样本数量。各词对应的值无需相加为一。

负样本是怎么采的?

负样本有两种常见来源:像 word2vec 那样从噪声分布抽词,或者像 CLIP 和 DPR 那样复用同一批次中的其他样本。

将计数取小于一的幂,可以在保留频率排序的同时减弱高频词的主导作用。停用词占多少概率取决于语料,并不存在通用比例。

负采样论文报告,在其测试中,指数 0.75 的效果优于原始词频采样和均匀采样:

P(w)=count(w)0.75∑w′count(w′)0.75P(w) = \frac{\text{count}(w)^{0.75}}{\sum_{w'} \text{count}(w')^{0.75}}

P(w) 是噪声采样概率。分母归一化取幂后的计数,它来自语料频率,而不是模型当前的评分。

有了这个分布,采样就很直接:用上面的公式为词表中每个词算出 P(w),然后对每个正样本对 (center, context),从这个分布中抽 k 个随机词——就是在词表上做一次加权掷骰、重复 k 次,P(w) 更高的词被挑中得更频繁。

使用批内负样本时,当前批次中的其他样本提供对照候选项。

在包含 N 个标注词对 (Qi, Pi) 的批次中,将 Pi 作为 Qi 的正样本,其余候选作为负样本。这是在分配训练标签,并不能证明所有非对角线项都没有语义关联:

batch:    (Q1, P1)   (Q2, P2)   (Q3, P3)   (Q4, P4)

for Q1:   positive = P1,   negatives = {P2, P3, P4}
for Q2:   positive = P2,   negatives = {P1, P3, P4}
for Q3:   positive = P3,   negatives = {P1, P2, P4}
for Q4:   positive = P4,   negatives = {P1, P2, P3}

编码器已经计算了全部 N 个候选向量。复用它们无需额外的编码器前向传播,但 N×N 分数矩阵仍然需要计算和内存。256 个样本的批次可为每个查询提供 255 个候选负样本。

其他候选项也可能与查询相关,从而成为假负样本。批次组成、重复样本处理和难负样本选择都会影响训练;批内负样本并非天然更优。

损失

损失。 每对样本的损失是 k+1 个 log-sigmoid 项之和——正样本一项,每个负样本一项——取代完整 softmax 的 −log P(target | center):

loss = − log σ(v_c · v'_t)  −  Σ log σ(−v_c · v'_n)
       ─────────────────       ───────────────────────
       true (positive) pair     k sampled negatives

其中 v_c 是中心词的输入嵌入,v'_t 是真实目标的输出嵌入,v'_n 是被采负样本词的输出嵌入,σ 是 sigmoid 函数。第一项把真实对的点积往上推(趋向 σ(·) = 1);第二项把每个负样本的点积往下推(趋向 σ(·) = 0)。

负样本项用的是 σ(−v_c · v'_n)——点积取负——这行得通是因为恒等式 σ(−x) = 1 − σ(x)。所以 −log σ(−v_c · v'_n) 不过就是 −log(1 − σ(v_c · v'_n)):交叉熵中标准的「错误类别」那一半,用在「这不是真实对」的方向上。损失中的每一项都是作用在一个 (center, w) 对上的二元交叉熵(BCE)——正样本标签为 1,每个负样本标签为 0。总损失就是 k+1 个 BCE 相加。

模型给观测标签的概率越低,损失越大。损失对 logit 的导数是 σ(z) − y,范围在 −1 到 1 之间。因此,较大损失并不意味着 logit 梯度无界。

每个被打分的词贡献一项——正样本是 −log σ,每个负样本是 −log(1 − σ):

词角色σ项值
on正样本0.5973−log(0.5973)0.5153
cat负样本0.4534−log(1 − 0.4534)0.6041
mat负样本0.5647−log(1 − 0.5647)0.8318

合计:L ≈ 1.95。贡献最大的是 mat——它的 σ(0.56)离负样本该在的位置(0)最远。

给定分数后,我们将采样标签建模为条件独立的伯努利观测。将其似然相乘,再取负对数,就得到这一求和式。这些项仍通过中心词向量共享参数。

P(all right)  =  P(positive right) × P(neg₁ right) × … × P(neg_k right)

−log 把乘积变成求和:

−log P(all right)  =  −log P(positive)  +  −log P(neg₁)  +  …  +  −log P(neg_k)

Softmax 建模上下文词的类别,负采样建模数据或噪声标签。两者优化不同的似然,回答不同的问题。无论哪一种,都不会自动在未见数据上得到校准良好的概率。

梯度

对于不带正则化的普通 SGD,只有中心词输入行和采样得到的输出行会更新。若一个词被重复抽取,需要累加它的贡献。所有梯度都应使用更新前的同一组参数计算。

要最小化 L,我们需要它对前向传播中涉及的每个参数的梯度:v_c(中心词在 E 中的行)、v'_t(目标在 Ep 中的行)以及每个 v'_n(每个负样本一行)。只需一条微积分事实——

d/dz [ −log σ(z) ] = σ(z) − 1

——链式法则就给出全部三个:

∂L / ∂v'_t = (σ_t − 1) · v_c                       ← 目标的输出行
∂L / ∂v'_n = σ_n · v_c                             ← 每个负样本的输出行
∂L / ∂v_c  = (σ_t − 1) · v'_t  +  Σ_n σ_n · v'_n   ← 中心词的输入行

其中 σ_t = σ(v_c · v'_t)、σ_n = σ(v_c · v'_n)——正是上面 ### 损失 一节里的那些数。

注意其中的对称性:每个输出行的梯度(∂L/∂v'_t、∂L/∂v'_n)都是一个标量乘以 v_c,而中心词的梯度是它所打分过的那些输出行的加权和。这是点积对其两个参数对称的直接后果——把任何 f(v_c · v'_w) 对 v'_w 求导,总会得到与 v_c 成比例的东西,反之亦然。

正样本输出向量的更新会加上 v_c 的某个倍数,负样本则减去。当 v_c 固定时,这会增大或减小点积。“拉近”和“推远”描述的是分数变化,并不保证欧氏距离相应减小或增大。

使用评分组件中未四舍五入的 sigmoid 值,中心向量梯度为:

∂L/∂v_c = (σ_on − 1) · v'_on + σ_cat · v'_cat + σ_mat · v'_mat
        ≈ [−0.0435, 0.5269, 0.0717]

正样本更新共享系数 η(1 − σ_t),但步长分别是 η(1 − σ_t)‖v_c‖ 和 η(1 − σ_t)‖v'_t‖,只有向量范数相等时才相同。中心向量还会收到全部负样本的贡献。

更新

学习率为 η 的梯度下降:

v'_t ← v'_t + η · (1 − σ_t) · v_c                    ← 朝 v_c 迈一步
v'_n ← v'_n − η · σ_n · v_c                          ← 远离 v_c 迈一步
v_c  ← v_c  + η · (1 − σ_t) · v'_t  −  η · Σ_n σ_n · v'_n
                                                      ← 朝 v'_t,远离每个 v'_n

Sigmoid 误差越大,更新系数越大,但向量范数也会影响实际步长。不同词对可能产生冲突梯度,过大的学习率也可能越过合适位置。因此损失不必在每一步都下降。

把上面的梯度代入,取 η = 0.1,中心词被轻轻推动:

v_c     = [0.33, −0.27, 0.84]
v_c_new = v_c − 0.1 · ∂L/∂v_c ≈ [0.3344, −0.3227, 0.8328]

一小步,但方向正是损失所要求的。v'_on、v'_cat 和 v'_mat 会在同一时刻按上面的公式各自更新;这里我们只盯着 v_c,好让推演简短。

验证这一步

先只更新 v_c,保持所有输出向量不变,以单独观察中心向量的贡献:

词之前之后方向
v_c · v'_on0.390.41上升——正样本更对齐 ✓
v_c · v'_cat−0.19−0.22下降——负样本被推开 ✓
v_c · v'_mat0.260.25下降——负样本被推开 ✓

在这个仅更新中心向量的示例中,损失从 1.9512 降至 1.9229。若同时基于更新前的值更新输出向量,损失为 1.8626。这些结果只对应当前示例和学习率,并不保证每次训练更新都如此。

为什么负采样能学到有用的向量

负采样不是完整 softmax 梯度的无偏估计。两种目标都奖励观测到的关联,但对竞争词赋予不同权重。区分共现与噪声能够捕捉词之间的统计关系,因此可以学到有用表示,而无需复现 softmax 的解。

one SGD step on a (center, context) pair · simplified to 2D
v_sat (center)v_cat (true)v_bananav_zebrav_rocket
σ(v_sat · v_w)
cat0.576→ 1
banana0.582→ 0
zebra0.511→ 0
rocket0.516→ 0
loss = 2.866
steps taken: 0

这个二维示例反复训练一个正词对和三个固定负样本。可以观察输入与输出向量变化时的 sigmoid 分数和损失。它用于展示训练目标,并不是这五个词的已训练语义地图。

下一个组件会在多个词对上训练,并显示输入向量的变化:

step-by-step skip-gram training · 21-word vocab, 2-D input embeddings, k=5 negatives
thekingsatinpalacequeenruledworkedplayedgardenamanchairwomanboyrangirlonmatcatdog
step
0
epoch ≈ 0 · 532 pairs/epoch
current pair
—
display groups (not training labels)
royalty
adult
youth
animal
verb
place
function

此浏览器演示在刻意构造的合成语料上训练。每一步处理一个词对,并从平滑词频分布中有放回地抽取五个负样本,也可能抽到正样本词。图中只显示输入向量,输出向量单独训练。颜色只是给读者看的分组标签,不参与训练。模型限制为二维以便显示,并不保证产生清晰的语义簇。

下面的 NumPy 步骤会在更新任何矩阵前复制所需向量。np.add.at 能正确累加重复输出索引的贡献,包括同时被抽为正、负样本的词。E 和 Ep 是两个独立的 (V, d) 浮点数组。

import numpy as np

def sgns_step(E, Ep, c, t, negatives, lr=0.1):
    rows = np.r_[t, np.asarray(negatives, dtype=int)]
    labels = np.zeros(len(rows))
    labels[0] = 1
    center = E[c].copy()
    outputs = Ep[rows].copy()
    logits = outputs @ center
    z = np.exp(-np.abs(logits))
    probabilities = np.where(logits >= 0, 1 / (1 + z), z / (1 + z))
    errors = probabilities - labels
    grad_center = errors @ outputs
    grad_outputs = errors[:, None] * center
    loss = np.sum(np.logaddexp(0, logits) - labels * logits)
    np.add.at(Ep, rows, -lr * grad_outputs)
    E[c] -= lr * grad_center
    return float(loss)  # loss before the update

# rng is a NumPy Generator; neg_dist sums to 1 over V words.
for c, t in pairs:
    negatives = rng.choice(len(E), size=k, replace=True, p=neg_dist)
    loss = sgns_step(E, Ep, c, t, negatives, lr)

对于不受约束的分数,以及从分布 q 独立抽取的 k 个负样本,总体最优值为 s*(c,w) = log[P_data(w|c) / (k q(w))]。当 q(w) = P_data(w) 时,它变为 PMI(c,w) − log(k)。采用 q(w) ∝ count(w)^0.75 会改变修正项。有限维向量只能近似这张理想分数矩阵。

这正是 Levy 与 Goldberg 的平移 PMI 分析的基础。它解释了 SGNS 分数捕捉的内容,但不意味着 SGNS、SVD 和完整 softmax 是可互换的目标。

什么时候用哪个

当任务需要对固定候选集合给出归一化分布,而且计算成本可接受时,可以使用完整 softmax。当表示质量和训练成本支持这一选择时,可以采用采样目标。没有哪种目标能保证在所有任务上准确率更高。

BERT 的掩码词元预测和自回归语言建模通常使用词表 softmax。检索系统常将相关项与采样或批内候选项进行比较。候选集合的选择,与使用 sigmoid 还是 softmax 损失,是两个不同问题。

DPR通过候选段落上的 softmax 学习查询与段落的评分。偏好奖励模型则学习回答的相对分数,常使用 −log σ(r_preferred − r_rejected);这是排序目标,不是 word2vec 的噪声采样目标。CLIP 是另一种基于 softmax 的对比学习示例。

CLIP:对比图像与描述

CLIP使用了 4 亿个图文对进行训练。它将批内不匹配项作为负样本,但损失是对称的 softmax 交叉熵,并非 word2vec 负采样使用的独立二分类损失。

原始 CLIP 实验将文本 Transformer 与 ResNet 或视觉 Transformer 配对。投影层将两种输出映射到共享向量空间,维度取决于模型版本。

对每个含 N 对样本的训练批 (I_1, T_1), ..., (I_N, T_N):

先对两组向量进行 L2 归一化,再计算 S[i,j] = exp(t) × dot(image[i], text[j]),其中 t 是可学习的对数尺度。此时点积就是余弦相似度。以索引 i 为目标,分别对各行、各列计算交叉熵,再将两个方向的平均损失取平均。对角线被标为正样本,但非对角线仍可能包含假负样本。参见 CLIP 实现。

原始训练批次包含 32,768 个图文对,每张图像对应 32,767 个候选负描述。编码器输出可以复用,但成对评分和跨设备同步向量仍然有成本。

你得到的是:一个语义相关的图像与文本落得近、不相关的落得远的嵌入空间。这就是 CLIP 能做零样本图像分类的原因——为类别名称计算文本嵌入(「一张狗的照片」「一张猫的照片」等等),然后按图像最接近哪个类别嵌入来分类。对比损失雕出的几何早已把含义编码进了两种模态;不需要任何带标签的分类器。

原始点积不等于余弦:SGNS 可以同时改变向量范数和夹角。CLIP 则归一化向量,并学习分数尺度。这些不同的几何性质并不意味着负样本存在通用目标夹角,也不能单独解释模型需要多大的批次。

共同思想是通过比较来学习,但具体方法很重要:word2vec SGNS 独立分类采样词对,CLIP 则通过 softmax 让候选项竞争。应结合分数需要表达的含义,一并选择采样方式与损失函数。