零、写在前面
噪声对比估计(Noise Contrastive Estimation, 简称 NCE) 是由 Gutmann 和 Hyvärinen 在 2010 年提出的一种强大的参数估计算法。它主要用于解决**非归一化概率模型(Unnormalized Models)**中配分函数(Partition Function)难以计算的问题。
现如今,NCE 的思想深深影响了自然语言处理(如 Word2Vec)和计算机视觉(如 SimCLR、MoCo 等对比学习方法中的 InfoNCE)。
一、为什么要用 NCE?
在统计机器学习和深度学习中,我们经常需要拟合一个概率分布。对于离散数据(比如语言模型预测下一个单词),我们通常使用 Softmax 函数:
Pθ(x)=exp(uθ(x))Z(θ) P_\theta(x) = \frac{\exp(u_\theta(x))}{Z(\theta)} Pθ(x)=Z(θ)exp(uθ(x))
其中:
- uθ(x)u_\theta(x)uθ(x) 是模型(如神经网络)输出的未归一化的分数(Logits)。
- Z(θ)=∑x∈Vexp(uθ(x))Z(\theta) = \sum_{x \in V} \exp(u_\theta(x))Z(θ)=∑x∈Vexp(uθ(x)) 是配分函数(归一化常数)。
痛点在于:
如果词表大小 VVV 非常大(比如几十万个词),或者 xxx 是连续的高维变量(积分代替求和),计算 Z(θ)Z(\theta)Z(θ) 的成本是极高的。如果我们用传统的最大似然估计(MLE)来优化,每次梯度下降都需要计算 Z(θ)Z(\theta)Z(θ) 及其梯度,这在工程上几乎不可行。
NCE 的目标:避开计算 Z(θ)Z(\theta)Z(θ),依然能够准确估计模型参数 θ\thetaθ。
二、核心思想
NCE的核心思想:将"无监督的生成问题"转化为"有监督的二分类问题"。
既然直接拟合数据分布太难,那我们就引入一个已知的、容易采样的噪声分布 pn(x)p_n(x)pn(x) 。
然后,我们训练一个二分类器,让它去区分:
- 正样本(Data): 来自真实数据分布 pd(x)p_d(x)pd(x) 的样本,标签记为 D=1D=1D=1。
- 负样本(Noise): 来自噪声分布 pn(x)p_n(x)pn(x) 的样本,标签记为 D=0D=0D=0。
只要我们的分类器足够强大,能够完美区分真实数据和噪声,那么它实际上也就"学会"了真实数据的分布 pd(x)p_d(x)pd(x)。
三、数学推导
1. 设定先验概率
假设我们有一批真实数据,同时我们根据噪声分布 pn(x)p_n(x)pn(x) 生成噪声数据。
设定真实数据与噪声数据的比例为 1:k1 : k1:k(即每 1 个真实样本,对应 kkk 个噪声样本)。
那么,对于任意一个进入分类器的样本,它的先验概率为:
- 来自真实数据的概率:P(D=1)=11+kP(D=1) = \frac{1}{1+k}P(D=1)=1+k1
- 来自噪声数据的概率:P(D=0)=k1+kP(D=0) = \frac{k}{1+k}P(D=0)=1+kk
2. 根据贝叶斯定理计算后验概率
给定一个样本 xxx,它属于真实数据(D=1D=1D=1)的真实后验概率是多少?根据贝叶斯定理:
P(D=1∣x)=P(x∣D=1)P(D=1)P(x∣D=1)P(D=1)+P(x∣D=0)P(D=0) P(D=1 | x) = \frac{P(x | D=1) P(D=1)}{P(x | D=1) P(D=1) + P(x | D=0) P(D=0)} P(D=1∣x)=P(x∣D=1)P(D=1)+P(x∣D=0)P(D=0)P(x∣D=1)P(D=1)
我们知道条件概率就是分布本身:
- P(x∣D=1)=pd(x)P(x | D=1) = p_d(x)P(x∣D=1)=pd(x) (真实数据分布)
- P(x∣D=0)=pn(x)P(x | D=0) = p_n(x)P(x∣D=0)=pn(x) (噪声分布)
代入先验概率,得到:
P(D=1∣x)=pd(x)⋅11+kpd(x)⋅11+k+pn(x)⋅k1+k=pd(x)pd(x)+k⋅pn(x) P(D=1 | x) = \frac{p_d(x) \cdot \frac{1}{1+k}}{p_d(x) \cdot \frac{1}{1+k} + p_n(x) \cdot \frac{k}{1+k}} = \frac{p_d(x)}{p_d(x) + k \cdot p_n(x)} P(D=1∣x)=pd(x)⋅1+k1+pn(x)⋅1+kkpd(x)⋅1+k1=pd(x)+k⋅pn(x)pd(x)
同理,它是噪声的后验概率为:
P(D=0∣x)=k⋅pn(x)pd(x)+k⋅pn(x) P(D=0 | x) = \frac{k \cdot p_n(x)}{p_d(x) + k \cdot p_n(x)} P(D=0∣x)=pd(x)+k⋅pn(x)k⋅pn(x)
3. 用 pθ(x)p_\theta(x)pθ(x)替代pd(x)p_d(x)pd(x)
在现实中,我们不知道真实的 pd(x)p_d(x)pd(x),我们的目的正是用模型 pθ(x)p_\theta(x)pθ(x) 去逼近 pd(x)p_d(x)pd(x)。因此,我们将上述公式中的 pd(x)p_d(x)pd(x) 替换为我们的模型 pθ(x)p_\theta(x)pθ(x),得到分类模型预测的概率:
Pθ(D=1∣x)=pθ(x)pθ(x)+k⋅pn(x) P_\theta(D=1 | x) = \frac{p_\theta(x)}{p_\theta(x) + k \cdot p_n(x)} Pθ(D=1∣x)=pθ(x)+k⋅pn(x)pθ(x)
Pθ(D=0∣x)=k⋅pn(x)pθ(x)+k⋅pn(x) P_\theta(D=0 | x) = \frac{k \cdot p_n(x)}{p_\theta(x) + k \cdot p_n(x)} Pθ(D=0∣x)=pθ(x)+k⋅pn(x)k⋅pn(x)
但是pθ(x)p_\theta(x)pθ(x)的计算不还是要算那个很大的配分函数吗?
NCE 作者提出一个极其大胆的假设:将归一化常数 Z(θ)Z(\theta)Z(θ) 看作是一个普通的可学习参数 ccc ,甚至在神经网络能力足够强时,直接强制假设 Z=1Z=1Z=1(即网络输出自动趋于归一化)。
因此,在 NCE 中,我们直接让 pθ(x)=exp(uθ(x))p_\theta(x) = \exp(u_\theta(x))pθ(x)=exp(uθ(x)),彻底抛弃了对配分函数的全局计算。
4. 构建目标函数(交叉熵/对数似然)
既然这是一个二分类问题,我们就用标准的二元交叉熵(或者说最大化对数似然)来作为目标函数 J(θ)J(\theta)J(θ)。
对于来自真实分布 pdp_dpd 的样本 xxx,我们希望 Pθ(D=1∣x)P_\theta(D=1|x)Pθ(D=1∣x) 越大越好;
对于来自噪声分布 pnp_npn 的样本 yyy,我们希望 Pθ(D=0∣y)P_\theta(D=0|y)Pθ(D=0∣y) 越大越好。
目标函数(期望形式 )为:
J(θ)=Ex∼pdlnPθ(D=1∣x)+k⋅Ey∼pnlnPθ(D=0∣y) J(\theta) = \mathbb{E}{x \sim p_d} \\ln P_\\theta(D=1 \| x) + k \cdot \mathbb{E}{y \sim p_n} \\ln P_\\theta(D=0 \| y) J(θ)=Ex∼pdlnPθ(D=1∣x)+k⋅Ey∼pnlnPθ(D=0∣y)
把步骤3中的式子代入,得到 NCE 的最终目标函数公式 :
J(θ)=Ex∼pdlnpθ(x)pθ(x)+k⋅pn(x)+k⋅Ey∼pnlnk⋅pn(y)pθ(y)+k⋅pn(y) J(\theta) = \mathbb{E}{x \sim p_d} \left \\ln \\frac{p_\\theta(x)}{p_\\theta(x) + k \\cdot p_n(x)} \\right + k \cdot \mathbb{E}{y \sim p_n} \left \\ln \\frac{k \\cdot p_n(y)}{p_\\theta(y) + k \\cdot p_n(y)} \\right J(θ)=Ex∼pdlnpθ(x)+k⋅pn(x)pθ(x)+k⋅Ey∼pnlnpθ(y)+k⋅pn(y)k⋅pn(y)
在实际训练中,期望 E\mathbb{E}E 会被替换为经验平均(即在一个 Batch 内的平均值)。
这个其实还是可以理解的,基于大数定律,我们用样本均值替代期望。
四、为什么 NCE 有效?
我们对目标函数跑梯度下降,真能使得pθ(x)p_\theta(x)pθ(x) 收敛到真实分布 pd(x)p_d(x)pd(x) 吗?
我们求导来证明:
第一项的导数:
\\begin{align} \\ln \\frac{p_\\theta}{p_\\theta + k p_n} \&= \\ln p_\\theta - \\ln(p_\\theta + k p_n) \\ \\nabla_\\theta \\ln \\frac{p_\\theta}{p_\\theta + k p_n}\&=\\nabla_\\theta \\left( \\ln p_\\theta - \\ln(p_\\theta + k p_n) \\right)\\ \&= \\nabla_\\theta \\ln p_\\theta - \\frac{1}{p_\\theta + k p_n} \\nabla\\theta p_\\theta\\ \&= \\nabla_\\theta \\ln p_\\theta - \\frac{p_\\theta}{p_\\theta + k p_n} \\nabla\\theta \\ln p_\\theta\\ \&= \\frac{kp_n}{p_\\theta + k p_n} \\nabla\\theta \\ln p_\\theta\\ \\end{align}
第二项的导数:
lnkpnpθ+kpn=ln(kpn)−ln(pθ+kpn)∇θlnkpnpθ+kpn=−pθpθ+kpn∇θlnpθ \begin{align} \ln \frac{k p_n}{p_\theta + k p_n} &= \ln(k p_n) - \ln(p_\theta + k p_n)\\ \nabla_\theta \ln \frac{k p_n}{p_\theta + k p_n} &= -\frac{p_\theta}{p_\theta + k p_n} \nabla_\theta \ln p_\theta \end{align} lnpθ+kpnkpn∇θlnpθ+kpnkpn=ln(kpn)−ln(pθ+kpn)=−pθ+kpnpθ∇θlnpθ
我们把期望写成积分形式:
∇θJ(θ)=Ex∼pdk⋅pn(x)pθ(x)+k⋅pn(x)∇θlnpθ(x)−k⋅Ey∼pnpθ(y)pθ(y)+k⋅pn(y)∇θlnpθ(y)=∫pd(x)k⋅pn(x)pθ(x)+k⋅pn(x)∇θlnpθ(x)dx−∫pn(x)⋅k⋅pθ(x)pθ(x)+k⋅pn(x)∇θlnpθ(x)dx=∫pd(x)−pθ(x)k⋅pn(x)pθ(x)+k⋅pn(x)∇θlnpθ(x)dx \begin{align} \nabla_\theta J(\theta) &= \mathbb{E}{x \sim p_d} \left \\frac{k \\cdot p_n(x)}{p_\\theta(x) + k \\cdot p_n(x)} \\nabla_\\theta \\ln p_\\theta(x) \\right - k \cdot \mathbb{E}{y \sim p_n} \left \\frac{p_\\theta(y)}{p_\\theta(y) + k \\cdot p_n(y)} \\nabla_\\theta \\ln p_\\theta(y) \\right \\ &= \int p_d(x) \left \\frac{k \\cdot p_n(x)}{p_\\theta(x) + k \\cdot p_n(x)} \\nabla_\\theta \\ln p_\\theta(x) \\right dx - \int p_n(x) \cdot k \cdot \left \\frac{p_\\theta(x)}{p_\\theta(x) + k \\cdot p_n(x)} \\nabla_\\theta \\ln p_\\theta(x) \\right dx \\ &= \int \left \\color{blue}{p_d(x)} - \\color{blue}{p_\\theta(x)} \\right \color{red}{\left \\frac{k \\cdot p_n(x)}{p_\\theta(x) + k \\cdot p_n(x)} \\nabla_\\theta \\ln p_\\theta(x) \\right} dx \end{align} ∇θJ(θ)=Ex∼pdpθ(x)+k⋅pn(x)k⋅pn(x)∇θlnpθ(x)−k⋅Ey∼pnpθ(y)+k⋅pn(y)pθ(y)∇θlnpθ(y)=∫pd(x)pθ(x)+k⋅pn(x)k⋅pn(x)∇θlnpθ(x)dx−∫pn(x)⋅k⋅pθ(x)+k⋅pn(x)pθ(x)∇θlnpθ(x)dx=∫pd(x)−pθ(x)pθ(x)+k⋅pn(x)k⋅pn(x)∇θlnpθ(x)dx
我们发现最大化 NCE 的目标函数,其全局最优解正是使得模型分布完全等于真实数据分布。
五、实验验证
简单写一个浅一点的神经网络,然后以一个双峰高斯分布为真实数据,选一个简单的、很宽的单峰高斯分布为噪声数据。以NCE作为loss,看看训练出来的结果如何。
5.1 如何把公式写成代码?
Pθ(D=1∣x)=exp(uθ(x))exp(uθ(x)+k⋅pn(x))=11+exp(log(k⋅pn(x))−uθ(x))=Sigmoid(uθ(x)−log(k⋅pn(x))) \begin{align} P_\theta(D=1|x) &= \frac{exp(u_\theta(x))}{exp(u_\theta(x) + k\cdot p_n(x))} \\ &= \frac{1}{1 + exp(log(k\cdot p_n(x)) - u_\theta(x))} \\ &= Sigmoid(u_\theta(x) - log(k \cdot p_n(x))) \end{align} Pθ(D=1∣x)=exp(uθ(x)+k⋅pn(x))exp(uθ(x))=1+exp(log(k⋅pn(x))−uθ(x))1=Sigmoid(uθ(x)−log(k⋅pn(x)))
5.2 代码验证
python
import torch
import torch.nn as nn
import torch.optim as optim
import matplotlib.pyplot as plt
import numpy as np
python
def sample_real_data(batch_size):
msk = torch.rand(batch_size) > 0.5
sample1 = torch.randn(batch_size) * 0.5 - 2.0
sample2 = torch.randn(batch_size) * 0.5 + 2.0
return torch.where(msk, sample1, sample2).unsqueeze(1)
python
noise_dist = torch.distributions.Normal(0.0, 4.0)
python
def sample_noise_data(batch_size):
return noise_dist.sample((batch_size, 1))
python
class EnergyModel(nn.Module):
def __init__(self):
super().__init__()
self.net = nn.Sequential(
nn.Linear(1, 32),
nn.ReLU(),
nn.Linear(32, 32),
nn.ReLU(),
nn.Linear(32, 1)
)
def forward(self, x):
return self.net(x)
python
model = EnergyModel()
optimizer = optim.Adam(model.parameters(), lr=1e-2)
criterion = nn.BCEWithLogitsLoss() # binary classificaion cross entropy
batch_size = 512
k = 5
epochs = 300
for epoch in range(epochs):
# 1:k sample
x_real = sample_real_data(batch_size)
x_noise = sample_noise_data(batch_size * k)
x_all = torch.cat([x_real, x_noise], dim=0)
u_theta = model(x_all)
log_p_n = noise_dist.log_prob(x_all)
logits = u_theta - np.log(k) - log_p_n
labels_real = torch.ones(batch_size, 1)
labels_noise = torch.zeros(batch_size * k, 1)
labels = torch.cat([labels_real, labels_noise], dim=0)
loss = criterion(logits, labels)
optimizer.zero_grad()
loss.backward()
optimizer.step()
if (epoch + 1) % 50 == 0:
print(f"Epoch {epoch + 1}/{epochs}, Loss: {loss.item():.4f}")
Epoch 50/300, Loss: 0.4031
Epoch 100/300, Loss: 0.3607
Epoch 150/300, Loss: 0.3159
Epoch 200/300, Loss: 0.3107
Epoch 250/300, Loss: 0.3240
Epoch 300/300, Loss: 0.3147
python
print("Training finished! Plotting results...")
model.eval()
Training finished! Plotting results...
EnergyModel(
(net): Sequential(
(0): Linear(in_features=1, out_features=32, bias=True)
(1): ReLU()
(2): Linear(in_features=32, out_features=32, bias=True)
(3): ReLU()
(4): Linear(in_features=32, out_features=1, bias=True)
)
)
python
x_test = torch.linspace(-8, 8, 400).unsqueeze(1)
python
with torch.no_grad():
u_theta_test = model(x_test)
p_theta_unnormalized = torch.exp(u_theta_test).numpy()
python
x_test_np = x_test.numpy()
python
plt.figure(figsize=(10, 6))
real_samples = sample_real_data(10000).numpy()
plt.hist(real_samples, bins=60, density=True, alpha=0.3, color='blue', label='True Data Histogram (Bimodal)')
noise_pdf = torch.exp(noise_dist.log_prob(x_test)).numpy()
plt.plot(x_test_np, noise_pdf, 'g--', label='Noise Distribution p_n(x)')
scale_factor = np.max(p_theta_unnormalized) / 0.4 # 0.4 约是直方图最高点
plt.plot(x_test_np, p_theta_unnormalized / scale_factor, 'r-', linewidth=2, label='Learned unnormalized p_theta(x)')
plt.legend()
plt.title("NCE: Learning Data Distribution via Binary Classification")
plt.show()

效果还不错。