1. Score Matching 到底是什么
如果你最近关注生成模型、扩散模型或者密度估计方向,一定绕不开 Score Matching 这个词。我最早接触它是在做独立成分分析的时候,当时被配分函数困扰得不行,后来发现这一招能直接把配分函数绕过去,瞬间觉得整个世界清爽了。
简单来说,Score Matching 是一种估计概率密度模型参数的方法。它不直接拟合概率密度本身,而是拟合密度函数的对数梯度,也就是所谓的“得分函数”。我们不去和那个归一化常数较劲,而是换个赛道,让模型在不同样本点上的得分尽量逼近真实数据分布在该点的得分。就这么一个思路转换,很多原来算不动的问题突然变得可以解了。
这篇文章我想把这个方法的原理、推导、代码实现和它在如今大火的方向(尤其是扩散模型)里的作用全部拆开来讲,适合有一定概率统计和机器学习基础、但被“得分”这个概念绕晕的读者。学完你不仅能手动实现一个简化版 Score Matching,还能理解为什么扩散模型的训练目标其实就是一个变种的得分匹配。
先说清楚一点,这里有个经常被搜索引擎带偏的坑:有一部分人搜“得分匹配”会跑到“倾向得分匹配(Propensity Score Matching)”去,那是因果推断领域的工具,用来处理观察性研究中样本自选择偏差的。和本文要讲的 Score Matching 是两码事,后面我会专门在常见误区里再展开一次。
2. 配分函数难题:为什么我们非要绕开它
2.1 最大似然估计的死穴
在统计建模里,最常见的参数估计思路是最大似然估计。假设我们有数据 x,模型给它的概率密度是 p(x;θ),那我们就最大化所有样本的 log p(x;θ) 之和。看起来很完美,但问题是,在实际建模时密度函数往往长这样:
p(x;θ) = (1/Z(θ)) * exp(-E(x;θ))
这里的 E 是能量函数,Z(θ) 是配分函数,等于对 exp(-E(x;θ)) 在整个定义域上做积分。麻烦就出在这个 Z(θ) 上。对数密度展开后,Z(θ) 会进入梯度项,而这个积分通常没有解析解。数据维度一高,数值积分也救不了你,算都算不动,更别说求梯度了。
我记得第一次跑受限玻尔兹曼机时就深刻体会过这种痛苦,为了估算配分函数,得跑蒙特卡洛采样,跑了半天还不一定收敛。所谓“似然是理论上最优,实操是计算上要命”,就是这个感受。
2.2 得分函数如何绕开 Z(θ)
得分函数定义为对数密度的梯度:
s(x) = ∇x log p(x;θ)
注意这里是对 x 求梯度,不是对 θ。现在我们把 p(x;θ) 的形式代进去:
log p(x;θ) = -E(x;θ) - log Z(θ)
对 x 求梯度后,log Z(θ) 这一项压根不依赖 x,梯度直接为零,配分函数就这么消失了。左边剩下的就是对能量函数求梯度,干净利落。
这就是 Score Matching 的精髓:我根本不碰密度值本身,只管密度在空间上的变化方向。配分函数不管多难算,反正它在得分函数眼里不存在。这个思路在物理里其实挺自然,能量函数的梯度就是力,我们直接用力来推断体系的性质,没必要先去把配分函数算出来。
2.3 Fisher 散度:衡量两个得分函数有多像
现在问题变成了:模型得让它在样本点上的得分函数尽量接近真实数据分布在样本点上的得分。怎么衡量两个向量场有多接近?最常用的是 Fisher 散度,它本质上就是两个向量之间差的平方,再按真实分布加权:
J(θ) = (1/2) ∫ p_data(x) ∥ s_θ(x) - s_data(x) ∥² dx
其中 s_data(x) = ∇x log p_data(x),s_θ(x) = ∇x log p_model(x;θ)。这个目标很简单直觉化:如果模型在每个位置上的“密度变化方向”和数据真实的变化方向一致,那这个散度就小,模型就学得好。
但是这个目标函数里还有个 s_data 没有解析表达式,实际数据分布我们只知道样本,导数怎么算?还没有那么简单。下一节讲怎么绕。
3. 核心原理拆解:从 Fisher 散度到可计算的目标
3.1 Hyvärinen 的核心技巧:分部积分消掉真实得分
2005 年 Aapo Hyvärinen 提出了一个漂亮的推导。把 Fisher 散度展开:
(1/2) ∫ p_data ∥s_θ∥² dx - ∫ p_data · s_data · s_θ dx + 常数
问题主要出在中间项,它含 s_data。Hyvärinen 的技巧是用分部积分处理这一项。因为在自然边界条件下概率密度在无穷远处趋于零,分部积分后,这一项可以变成一个不含 s_data 的表达式。
标准目标函数长这样:
J(θ) = E_p_data [ (1/2) ∥s_θ(x)∥² + tr(∇x s_θ(x)) ] + 常数
这里的 tr(∇x s_θ(x)) 是雅可比矩阵的迹,也叫得分函数的散度。括号里两项都只依赖模型得分函数本身和它的导数,跟真实数据密度的梯度完全无关。真实分布只通过样本期望进入梯度计算,直接可用样本均值估计。
中间我用纸笔推了几天,最有感的是这地方用了分部积分和边界项消失这个假设。实际数据分布并不一定满足无穷远处密度为零的条件,但实践中极少有人去较真这个,因为效果是真的好。
3.2 显式得分匹配和隐式得分匹配
按照目标函数有没有显式写出模型密度,可以把 Score Matching 分成两类:
- 显式得分匹配:模型给出了带参数的密度形式,能够直接写出得分函数和它的雅可比矩阵。比如高斯混合模型,能量函数和梯度都能显式表示。前面那套推导直接可用。
- 隐式得分匹配:模型不是一个显式概率分布,而是一个直接输出得分函数的神经网络。我们不知道它的归一化常数,甚至不知道它对应什么能量函数,但关联的就是它可以被当作某个分布的得分来训练。现代深度生成模型基本都是这条路。
我刚开始学的时候一直对“模型直接输出得分”有点转不过弯:既然得分是密度的梯度,那这个密度是什么?答案是:不必知道。只要训练好后能通过它做朗之万采样得到样本,密度本身并无必要显式写出来。这种“只学方向不学高度”的思路,后来就是扩散模型的底层引擎。
3.3 Denoising Score Matching:把问题再转换一次
原版 Score Matching 对神经网络来说其实不太友好,因为要算 tr(∇x s_θ(x)),相当于对网络输出做逐维微分。在网络参数量很大的时候,这个“对输入的二阶信息”的获取过程非常消耗算力。
于是 Vincent 在 2011 年提出了 Denoising Score Matching,思路更加聪明:如果我先把真实样本故意加上高斯噪声扰动一下,然后训练模型去预测这个噪声。直觉上,扰动后的数据分布得分函数,正是从噪声中恢复干净数据的逆过程。用噪声预测作为训练目标,几乎等价于在扰动分布上做得分匹配。
很多刚接触扩散模型的人可能会觉得扩散模型的训练目标是“预测噪声,完全是一种任务设计上的巧合”,其实它的数学本质就是一个去噪得分匹配。DDPM 里的损失函数跟 Denoising Score Matching 在数学上是等价的,只是多了一步重新参数化。
4. 从零实现改进版简化得分匹配
4.1 一维混合高斯模型:最容易看清原理的实验台
纸上谈兵到这里差不多了,我直接带大家跑一个实验,看看得分匹配到底怎么用。这里我选了一维高斯混合模型,因为它所有解析量都能算出来,方便校验代码算出的梯度对不对。
设定目标数据分布是:
p_data(x) = 0.3 * N(-2, 0.7) + 0.7 * N(2, 1.1)
模型的参数是两组高斯参数(均值 μ1 和 μ2,方差 σ1² 和 σ2²),以及两个混合权重,为了保证权重和为 1,我会用 softmax 来参数化权重。模型得分函数可以解析求出来:
s_θ(x) = ∇x log [ w1 * N(x;μ1,σ1²) + w2 * N(x;μ2,σ2²) ]
这里由于得分匹配目标函数里有模型得分对 x 的导数项(雅可比矩阵的迹),我对它做了自动微分,其实写起来反而简单。
代码如下:
import torch import torch.nn as nn import numpy as np import matplotlib.pyplot as plt torch.manual_seed(42) # 真实数据分布 def true_sample(n): comp = torch.rand(n) > 0.3 x = torch.where(comp, torch.randn(n) * 1.1 + 2.0, torch.randn(n) * 0.7 - 2.0) return x.view(-1, 1) class GMMScore(nn.Module): def __init__(self): super().__init__() self.logits = nn.Parameter(torch.tensor([0.2, 0.0])) self.mu = nn.Parameter(torch.tensor([[-1.0], [1.5]])) self.log_sigma = nn.Parameter(torch.tensor([0.0, 0.0])) def component_log_probs(self, x): # x: (N,1) x = x.unsqueeze(0) # (1,N,1) mu = self.mu.view(2,1,1) # (2,1,1) sigma = self.log_sigma.exp().view(2,1,1) log_p = -0.5 * ((x - mu) / sigma) ** 2 - self.log_sigma.view(2,1,1) - 0.5 * np.log(2 * np.pi) return log_p # (2,N,1) def log_prob(self, x): w = torch.softmax(self.logits, dim=0).view(2,1,1) log_comp = self.component_log_probs(x) # log-sum-exp max_log, _ = log_comp.max(dim=0, keepdim=True) log_sum = max_log + torch.log((w * (log_comp - max_log).exp()).sum(dim=0, keepdim=True)) return log_sum.squeeze(-1) # (N,1) def score(self, x): x.requires_grad_(True) lp = self.log_prob(x).sum() grad = torch.autograd.grad(lp, x, create_graph=True)[0] return grad def score_matching_loss(model, x): x = x.detach().requires_grad_(True) s = model.score(x) # (N,1) # 计算 tr(nabla s) div = torch.zeros_like(x) for i in range(x.shape[1]): gi = torch.autograd.grad(s[:, i].sum(), x, create_graph=True)[0][:, i] div[:, i] = gi loss = (s ** 2 / 2.0 + div).mean() return loss model = GMMScore() opt = torch.optim.Adam(model.parameters(), lr=0.02) data = true_sample(2000) for epoch in range(3000): x = data.clone() loss = score_matching_loss(model, x) opt.zero_grad() loss.backward() opt.step() if epoch % 500 == 0: print(f"epoch {epoch:4d} loss {loss.item():.4f}") # 结果打印 w = torch.softmax(model.logits, dim=0).detach().numpy() mu = model.mu.detach().numpy().flatten() sigma = model.log_sigma.exp().detach().numpy().flatten() print("学到的权重:", w, "真实权重: [0.3, 0.7]") print("学到的均值:", mu, "真实均值: [-2.0, 2.0]") print("学到的标准差:", sigma, "真实标准差: [0.7, 1.1]")跑出来的效果随着训练轮数增加,参数会逐步逼近真实值。我自己跑这段代码大概到 1500 轮左右,均值和标准差就比较接近真实值了,权重也能基本对准,说明靠得分匹配确实能恢复出真实分布的形状。
这个实验虽然简单,但它验证了整条逻辑链:我只需要给模型一批样本,不用算配分函数,不用做马尔可夫链蒙特卡洛采样,纯粹用一个可微分的源函数求梯度,最后就能拿到这个分布的真实参数。
4.2 多维度拓展时雅可比矩阵的迹怎么算
刚才的代码是一维场景,tr(∇x s) 其实就是一个数,直接对唯一维度求梯度就行。但深度学习里数据维度非常高,图像就是成千上万维的,如果逐维手动展开计算,内存和计算时间会非常夸张。
实际工程中有两种常用替代方案:
- Sliced Score Matching:把 score 函数沿随机投影方向做投影,再对投影方向求梯度近似原目标。这个技巧只需要一次反向传播,计算成本大幅下降,是现在扩散模型的常见训练方式。原论文是 Song 等人 2019 年提出,叫做 Sliced Score Matching。
- Denoising Score Matching 重参数化:直接把训练目标改成预测噪声的 L2 损失。这在 DDPM 里就被化简成一个极简的形式,效果又稳又好。
如果让我给建议:做图像生成,直接上 Denoising Score Matching;做表格类数据的密度估计,Sliced Score Matching 就够用;做小规模时序密度建模,原版目标也不是不能跑,注意维度别太高就行。
4.3 训练中的实操心得笔记
实操得分匹配时,有一些容易被忽略但很影响效果的细节。
第一,学习率不要设太大。得分匹配的损失尺度比普通回归损失小一个量级左右,因为它有良性的平方项和导数项混合,学习率一大容易训练震荡。Adam 一般用 1e-3 到 1e-4 之间。
第二,对数据进行标准化很重要。得分函数对数据的尺度极其敏感,如果特征尺度差异很大,雅可比矩阵的迹会出现数值病态。先把数据归一化到标准区间,或者做 z-score 归一化,训练会稳定很多。
第三,模拟训练的数据要干净。因为得分匹配涉及对输入的二阶导,如果数据里有异常值或随机跳变点,梯度变化会非常剧烈,这会让优化过程非常不稳定。这也是为什么大部分扩散模型要先对图像做把像素归一化到 [-1,1] 区间,跟这里是一个原因。
第四,千万要加 create_graph=True,否则雅可比矩阵的梯度没法继续回传。我一开始忘了加,报错信息还不太直观,花了一阵子才排查出来。
5. Score Matching 为什么成了扩散模型的发动机
5.1 从随机微分方程看生成过程
现在生成模型的大热门扩散模型,核心思路其实是:前向过程把数据一点一点加噪声破坏掉,直到变成纯高斯噪声;逆向过程去学习如何从噪声恢复出数据。
如果你把加噪声的过程看成是一个连续时间随机微分方程,那么它的逆动力学方程里,恰好有一个漂移项依赖数据分布的得分函数。换句话说,只要你能估计出加噪后每一步分布的得分函数,就能用一个反向随机微分方程把样本从噪声里完整地生成出来。
这也解释了为什么扩散模型训练的核心不依赖对抗生成,也不用变分下界,本质上就是一个巨大的得分函数学习网络。你训练它是为了做生成,但中途学到的其实就是数据分布的梯度场。
5.2 扩散模型训练目标推导
DDPM 的训练目标,推导到最后等价于优化:
L = E_{t, x0, ε} [ λ(t) ∥ ε_θ(x_t, t) - ε ∥² ]
x_t 是加了 t 步噪声后的含噪样本,ε 是当时加进去的噪声,ε_θ 是网络预测的噪声。这个形式和 Denoising Score Matching 在数学上是等价的,只是做了线性变换,把得分网络变成了噪声预测网络。
有人可能会问,为什么要预测噪声,不直接预测 X0?实验和经验都表明,预测噪声更像是回归任务里一个好的目标参数化方式,因为噪声在不同时间步下的尺度和分布都是已知的,目标比较稳定,训练中不容易出现各时间步损失尺度失衡的问题。这也是现在主流扩散代码库默认的设计。
5.3 不同时间步的损失权重为什么不能随便改
训练扩散模型的时候,每个时间步 t 的样本损失会乘上一个和时间相关的权重 λ(t),这个权重不是凭感觉定的,它直接对应去噪得分匹配中不同噪声强度下的 Fisher 散度加权。
在实践中,权重选择会影响生成品质。过大的权重集中在高噪声阶段,模型会花大量算力在还原低频轮廓上,细节可能变差;权重集中在低噪声阶段,模型又会过度关注细节织造,整体结构可能不稳定。所以工程上大家会用一些简单的调度策略,比如线性衰减或者固定的加权方式,而这些经验的根其实就是得分匹配理论中的噪声调度。
我在做条件生成模型时,试过自己定义权重调度,发现一个规律:保持 λ(t) 与噪声方差 σ(t) 成反比的调度效果比较稳健,这和“各时间步训练难度相近”的直觉一致。
6. 概念迷思大盘点:得分匹配、倾向得分、评分卡别再搞混了
6.1 和倾向得分匹配的界线在哪里
搜索“得分匹配”的时候经常会跳出来“倾向得分匹配”。这两者除了名字里都有“得分”和“匹配”四个字,技术路线完全不一样。
倾向得分匹配是经济学、医学、社会科学里做因果推断的常用方法。它解决的场景是:在一项观察性研究中,有的人接受了处理,有的人没有,而接受处理的概率往往和个体特征有关,这就导致处理组和对照组在特征分布上不平衡。倾向得分就是“在给定特征下接受处理的概率”,有了这个概率,就可以把处理组和对照组里倾向得分相近的个体配对,模拟随机对照试验的效果。
而 Score Matching 里的得分是概率密度对数据维度的梯度,是描述密度几何形状的一个量。它不涉及因果推断,也不涉及处理分配,纯粹是统计机器学习里的密度估计技术。一句话区分:一个在找概率密度的梯度,一个在算样本进入处理组的概率。
如果你搜到“pymatch”或者“python倾向匹配得分”相关代码,那是在处理倾向得分匹配的问题,跟本文的模型完全不同,千万别看混了。
6.2 和评分卡、评分模型也没关系
还有一类搜索流量来自“评分卡”“信用评分”等领域。那些内容说的是用逻辑回归构建的信用评分模型,给客户打分,这里的“得分”是指分数。和 Score Matching 没有任何关系。
我在很多公开问答平台上见过有人把这两个概念混在一起提问,提问者其实是想要一个评分卡模型的实现,但用的是“得分匹配”关键词,底下的回复答非所问。如果大家以后遇到类似模糊的搜索词,建议先用英文确认领域:score matching 是机器学习里的密度估计技术;propensity score matching 是因果推断技术;credit scoring 是风控建模技术。三个方向完全独立。
6.3 什么时候选择 Score Matching 而不是 MLE 或 GAN
如果你正在设计一个概率模型,先考虑一下有没有必要用 Score Matching。我的判断经验是这样:
- 模型能写出能量函数、归一化常数难算,但能量的梯度好算,用 Score Matching 就对了。能量模型、受限玻尔兹曼机等场景非常典型。
- 你只需要生成新样本,不需要显式算似然值,那 Score Matching 配合朗之万采样是简单可靠的方案。
- 你确实需要计算似然值用于模型比较,那得用归一化流或者自回归模型,Score Matching 只给你一个未归一化的能量函数,似然需要再额外做归一化估计,比较麻烦。
- 你的数据量很小且维度很高,Score Matching 可能不如基于核的方法稳定,因为二阶导估计在小样本下噪声会比较大。
6.4 判断自己是不是学岔路的五个自查问题
如果你学完还是不确定自己理解的是不是 Score Matching,可以拿下面这组问题自查:
- 得分函数是对谁求梯度?答案是数据 x,而不是参数 θ。
- 目标函数里的核心项是哪两项?第一项是模型得分平方的期望,第二项是模型得分的散度(雅可比矩阵的迹)。
- 这个方法和配分函数的关系是什么?配分函数在得分函数里不出现,所以不用计算。
- 扩散模型里的训练目标本质是什么?它就是在带噪声的数据分布上做了去噪得分匹配。
- Score Matching 和倾向得分匹配的区别是什么?一个是密度梯度估计,一个是条件概率建模。
这五个问题都答得上来,才算真正过了概念关。
7. 工程实践避坑指南
7.1 训练不收敛时先查哪些层
如果得分匹配训练的损失一路狂飙或者震荡,我一般会按照以下顺序排查:
- 确认数据预处理有没有把特征尺度压到合理范围。这个最常见的坑,因为得分函数对尺度太敏感。
- 确认模型输出有没有做合理的激活限制。有些模型配置里,输出得分函数前不加限制,导致梯度爆炸,要处理。
- 确认优化器和学习率的设置有没有问题。建议从 1e-4 起步,用 Adam 优化器。
- 确认损失函数里的雅可比迹有没有用 create_graph 留住计算图,不然反向传播会传不齐。
7.2 评估质量:光看损失没有意义
Score Matching 的训练损失下降只能说明向量场匹配得好了一些,但它并不能直接给出对数似然的绝对值,所以不同任务之间随意比较绝对值没有意义。
我通常建议把评估分成两部分:一是看生成样品的可视化或下游指标,比如图像生成用 FID、IS,表格数据看边际分布的拟合程度;二是看朗之万采样出来的样本与真实样本之间的统计量差异。如果采样样本分布跟真实样本分布的重叠度很高,就说明训练是成功的。
7.3 采样时的步长与噪声添加策略
用学习好的得分函数生成样本,通常要做朗之万采样,采样过程中步长的设置相当关键。步长太大,粒子会在高概率区域附近乱跳;步长太小,采样效率太低,可能一直困在低概率区。
我常用的策略是退火式采样,步长从大逐步变小。这个做法的逻辑也是得分匹配的正常范畴:大噪声阶段探索全局,小噪声阶段精修细节。和扩散模型的逆向过程异曲同工。
7.4 数据量不足时的增强方案
如果只有几百条数据,想用高维得分匹配,效果会非常惨。一个可行的小技巧是利用数据增强间接扩大样本量:对数据进行加微小噪声扰动,相当于人为扩大样本分布的支持范围,这样反而能让得分函数估计更平滑。这是我在小样本表格数据上验证过的做法,比直接硬训或者换模型都要省事。
8. 一些个人经验与扩展方向
这套思路我从前几年接触到今天,前前后后在不同的项目里用了不少次,中间踩过的坑也不少。回头来看,Score Matching 给了我一个非常核心的心智模型:很多计算的困难都来自归一化常数,而很多问题本质上只需要梯度和变化方向,方向对了,归一化常数天然就会绕开。
如果你继续深入,可以关注 Score-Based Generative Models、SDE 框架和基于得分匹配的扩散模型这些方向。也可以把它和最优传输、条件生成模型结合,用得分函数做约束,在很多实际任务里会有不可思议的效果。
最后再分享一个小经验:如果你要在实际项目里用得分匹配,先把数据可视化做出来。把学到的得分函数画成向量场,和真实分布叠在一起看,非常直观。这一步能帮你发现 80% 的建模问题,比盯着损失曲线盲目调参靠谱得多。