AIGC 基本功|FID / CLIP Score 到底测了什么-FID

人工智能炼丹君
2026-10-02 / 0 评论 / 2 阅读 / 正在检测是否收录...

FID / CLIP Score 到底测了什么

所属方向:评测 | 难度:进阶 | 前置知识:无(本篇自洽,只需要你见过「协方差矩阵」和「余弦相似度」这两个词)
关键词:FID、Inception Score、CLIP Score、2-Wasserstein 距离、样本量偏差、指标失效


01. 为什么需要它

先给一个我自己跑出来的数字,它比任何论述都更能说明问题。

我从同一个分布里抽两组样本:真实组和生成组来自同一个人工构造的 2048 维多元高斯:协方差特征值按 $1/k$ 衰减,迹归一化为 2048。总体的高斯距离为 0,但两组有限样本的估计值不必为 0。每组 10000 个样本时,实测 FID = 71.20;每组加到 50000 个样本,实测 FID = 14.21。两组的总体分布相同,但具体抽样不同。这里改变样本量并重复抽样取平均;不是对真实 Inception 图像特征的测量。

这不是实现写错了,而是 FID 的固有性质:它是一个自带正偏差的估计量。偏差来自哪里?Inception-v3 的 pool3 特征是 2048 维,一个 2048×2048 的协方差矩阵有 $2048 \times 2049 / 2 \approx 2.10 \times 10^{6}$ 个自由参数,全靠有限样本去填。有限样本让均值与协方差发生波动,经非线性距离公式后产生估计偏差。同分布基线的期望非负,但不能把自由参数数量直接当作最低样本量。我做过分解(附录 fid_bias_lab.py 的 [A2] 段):在 $d=512$、$n=40000$ 时,把均值换成真值只能消掉 1.4% 的偏差,把协方差换成真值能消掉 98.6%——在这组谱和尺度下,偏差主要来自协方差估计。

这些数字展示了样本量可以显著改变同分布基线,但不能把 71 或 57 分推广为所有真实模型的偏差。特征整体乘 $c$,距离就乘 $c^2$;谱结构、参考集是否固定、两个分布的差异也会影响偏差。因此跨论文比较前必须核对特征器、数据集和样本量等协议。Chong 与 Forsyth 的研究 还说明偏差依赖生成器,同样的样本量并不能自动消除排序偏差。

第二个坑在另一头。FID 只比较分布,不比较单张图。例如,若只是把同一组图像与 prompt 重新错配,图像集合不变,FID 就完全不变;但把所有橘猫换成黑猫可能改变图像分布,不能保证 FID 不变。因此文生图评测常配合 CLIP 相似度或其他条件一致性指标——它不需要真实图片做参考(reference-free),能逐样本给分。但 CLIP Score 有自己的洞:它先给每个图文对打分,常见的数据集均值会丢失分布信息,好样本和坏样本可以互相平均掉。在合成共享空间里,可以构造汇总分数近似相同、逐样本分布却不同的两组输出。第 6.3 节列出四组例子;标准差比最大约 17.55 倍对应 $p=0.9$,并非 $p=0.3$ 那一行。分数一样,产品体验完全不同。

所以这篇要讲清楚的是三件事:FID 的闭式解是怎么推出来的、它的偏差有多大且怎么补救、以及 FID 和 CLIP Score 各自测的是哪一半。

02. 最小可用理解

三句话:

  1. 机制:把真实图和生成图都过一遍 Inception-v3,取 pool3 层的 2048 维特征;对两组特征各拟合一个多元高斯 $\mathcal{N}(\mu_{\text{real}}, \Sigma_{\text{real}})$ 和 $\mathcal{N}(\mu_{\text{gen}}, \Sigma_{\text{gen}})$;然后算这两个高斯之间的 Fréchet 距离(也就是 2-Wasserstein 距离的平方)。全部闭式,几行代码。
  2. 成本:只需要一阶矩和二阶矩,不需要知道分布的形状,也不需要先训一个判别器。代价是它只看得见前两阶矩,有限样本协方差可以计算,但估计误差会进入 FID;大样本区间常用 $1/n$ 展开描述偏差,系数依赖具体分布。
  3. 效果与代价:FID 对改变前两阶矩的分布变化敏感,也可能漏掉矩匹配的模式变化;CLIPScore 提供逐图文对相似度,但不等于综合质量。本文合成例子说明两个目标可能冲突,不能据此断言真实 FID 与 CLIPScore 永远相反。

03. 数学推导

3.1 为什么不能逐图打分

生成模型的评测有一个结构性困难:无条件生成或自由文生图评测通常没有逐图配对的真值。你拿不到「这张生成图对应的真值图」,因此 MSE、PSNR、SSIM 等有参考指标不能直接用于这种非配对比较——它们要求两张图逐像素对齐。

一个自然的替代是「只给生成图打分」,这就是 Inception Score(IS)的思路:把生成图送进 Inception-v3,看分类分布 $p(y \mid x)$ 是不是既尖锐又有多样性。但它有一个致命缺陷:它根本不看真实图片。记忆训练集也可能得到高 IS,因为 IS 不检查是否抄袭,也不比较目标数据分布。高 IS 还要求预测类别清晰且类别边缘分布有多样性,并非真实图片自动「满分」。FID 同样不直接检验记忆训练集。

FID 的出发点就是要补上这一半:把真实分布也纳入比较,比较两个分布之间的距离,而不是给单张图打分。

3.2 为什么是高斯

图像在 Inception 特征空间(2048 维)里的分布形状未知,而且在这个维度上你没法可靠地估计它的形状。FID 选择用一阶矩和二阶矩近似描述,估计质量仍依赖样本量。

那么问题变成:在只知道均值和协方差的条件下,应该假设什么分布?答案是高斯——它是给定前两阶矩时熵最大的分布,也就是「在已知信息下最不作额外假设」的那个选择。这个选择的物理含义是:FID 只承诺比较前两阶矩,不承诺比较形状。后面 3.5 节会看到,这个妥协是有代价的。

3.3 Fréchet 距离的闭式解

设 $X \sim \mathcal{N}(\mu_1, \Sigma_1)$、$Y \sim \mathcal{N}(\mu_2, \Sigma_2)$。2-Wasserstein 距离的平方定义为所有耦合(joint distribution)中传输代价的最小值:

$$W_2^2 = \min_{\text{coupling}} \mathbb{E} \big[ \| X - Y \|^2 \big]$$

先看任意一个耦合的代价是多少。设交叉协方差 $C = \mathrm{Cov}(X, Y)$,把 $\|X - Y\|^2$ 展开成三项并分别取期望:第一项 $\mathbb{E}\|X\|^2 = \|\mu_1\|^2 + \mathrm{Tr}(\Sigma_1)$(因为 $\mathrm{Tr}(\Sigma_1)$ 就是 $X$ 各维方差之和);第二项同理;第三项交叉项 $\mathbb{E}[X^\top Y] = \mu_1^\top \mu_2 + \mathrm{Tr}(C)$。三项合并,$\|\mu_1\|^2 + \|\mu_2\|^2 - 2\mu_1^\top\mu_2$ 正好凑成 $\|\mu_1 - \mu_2\|^2$,于是

$$\mathbb{E} \big[ \| X - Y \|^2 \big] = \| \mu_1 - \mu_2 \|^2 + \mathrm{Tr}(\Sigma_1) + \mathrm{Tr}(\Sigma_2) - 2\,\mathrm{Tr}(C)$$

前三项由边缘分布决定,动不了。所以要让传输代价最小,等价于让 $\mathrm{Tr}(C)$ 最大。约束是联合协方差矩阵必须半正定:

$$\begin{pmatrix} \Sigma_1 & C \\ C^\top & \Sigma_2 \end{pmatrix} \succeq 0$$

这个半正定约束下的迹最大化有闭式解(协方差补全问题的标准结果)。$\Sigma_1$ 可逆时最优解为

$$C^\star = \Sigma_1^{1/2} \big( \Sigma_1^{1/2} \Sigma_2 \Sigma_1^{1/2} \big)^{1/2} \Sigma_1^{-1/2}$$

代回去,利用迹的循环性质把外面的 $\Sigma_1^{1/2}$ 和 $\Sigma_1^{-1/2}$ 抵消掉,得到

$$\mathrm{Tr}(C^\star) = \mathrm{Tr} \Big( \big( \Sigma_1^{1/2} \Sigma_2 \Sigma_1^{1/2} \big)^{1/2} \Big) = \mathrm{Tr} \Big( \big( \Sigma_1 \Sigma_2 \big)^{1/2} \Big)$$

最后一个等号值得停一下,因为它是实现环节最容易写错的地方。$\Sigma_1 \Sigma_2$ 两个对称矩阵的乘积一般不是对称矩阵,不能直接交给对称特征值求解器。当 $\Sigma_1$ 正定时,$\Sigma_1 \Sigma_2$ 与 $\Sigma_1^{1/2} \Sigma_2 \Sigma_1^{1/2}$ 相似:

$$\Sigma_1^{1/2} \big( \Sigma_1^{1/2} \Sigma_2 \Sigma_1^{1/2} \big) \Sigma_1^{-1/2} = \Sigma_1 \Sigma_2$$

而后者 $\Sigma_1^{1/2} \Sigma_2 \Sigma_1^{1/2}$ 是对称半正定的(对任意 $v$ 有 $v^\top \Sigma_1^{1/2} \Sigma_2 \Sigma_1^{1/2} v = (\Sigma_1^{1/2} v)^\top \Sigma_2 (\Sigma_1^{1/2} v) \ge 0$)。相似矩阵特征值相同,而主平方根与相似变换可交换,所以两者的平方根迹相等:

$$\mathrm{Tr} \big( (\Sigma_1 \Sigma_2)^{1/2} \big) = \sum_i \sqrt{\lambda_i}, \quad \lambda_i = \text{eig} \big( \Sigma_1^{1/2} \Sigma_2 \Sigma_1^{1/2} \big)$$

奇异协方差可由连续性取极限,实际计算仍使用半正定夹心矩阵而无需显式求逆。

把所有项拼起来,就是 FID 的完整定义:

$$\mathrm{FID} = \| \mu_{\text{real}} - \mu_{\text{gen}} \|^2 + \mathrm{Tr}(\Sigma_{\text{real}}) + \mathrm{Tr}(\Sigma_{\text{gen}}) - 2\,\mathrm{Tr} \Big( \big( \Sigma_{\text{real}} \Sigma_{\text{gen}} \big)^{1/2} \Big)$$

三项的物理含义分别是:均值项管「两组图的平均特征偏了多远」(对应内容/风格的整体偏移),两个迹项管「各自铺开得多宽」(对应多样性),交叉项管「两者铺开的形状有多重合」。注意如果两组分布只是整体缩放 $c$ 倍,那么 $\mu$ 变 $c$ 倍、$\Sigma$ 变 $c^2$ 倍,四项一起变 $c^2$ 倍——FID 不是尺度不变的,这一点后面会用到。

3.4 对称求解器必须使用对称半正定输入

上面那个「最后一个等号」在实现里就是一道坎。看看三种写法差多少(附录 fid_core.py 的 [2][3] 段,随机生成的对称正定 $\Sigma_1, \Sigma_2$):

$d$ 对称化路线(正确) eigh(Σ1Σ2)(错误) 通用特征值参考
8 11.5957639872 10.8132126304 11.5957639872
32 41.7985007127 40.2403835447 41.7985007127
128 173.3801162800 165.4519506579 173.3801162800

本表里错误写法偏小,且绝对误差随所选维度增加;这不是任意矩阵都成立的单调律。原因很具体:np.linalg.eigh 是专供对称矩阵的求解器,它只读矩阵的上三角(或下三角)并假设输入对称。按 NumPy 官方文档,默认 UPLO="L" 只读下三角并按其镜像解释上三角,并不是计算 $(A+A^\top)/2$。它与半正定夹心矩阵不是一回事。

误差会原样传进 FID。同一对 128 维特征($n=4096$),正确路线算出 FID = 45.584468,错误路线算出 50.484178,差 +4.899711。这个量级足以让你以为模型退化了。

3.5 FID 只看前两阶矩,所以有结构性盲区

3.2 节的高斯假设现在来收账了。构造一个极端例子:

  • 真实分布 $P = \frac{1}{2}\mathcal{N}(+m, I) + \frac{1}{2}\mathcal{N}(-m, I)$——两个分离的模式;
  • 生成分布 $Q = \mathcal{N}(0, I + m m^\top)$——把两个模式糊成一团的单个高斯。

两者的均值都是 0,协方差都是 $I + m m^\top$。前两阶矩完全一样,所以 FID 的真值严格等于 0,不管两个模式离多远。

但人(或者一个简单的分类器)一眼就能看出区别。实测(附录 fid_bias_lab.py 的 [D] 段,$d=32$、$n=40000$;记 $a = \|m\|$,横轴是两个模式中心的间距 $2a$):

模式间距 $2a$ FID(总体真值) FID(经验估计) 贝叶斯最优 AUC 1-NN 双样本检验准确率 二次特征 ridge AUC
2 1.42e-14 0.0156 0.5365 0.4956 0.5022
4 2.84e-14 0.0162 0.6601 0.5056 0.5003
8 −2.84e-14 0.0163 0.8106 0.6204 0.4972
12 0.0 0.0165 0.8739 0.7010 0.4977
16 8.53e-14 0.0239 0.9033 0.7518 0.5042

FID 从头到尾是 0(那 0.015~0.024 是前面说的有限样本估计误差,不是信号),而贝叶斯最优判别器的 AUC 已经到了 0.9033,1-NN 双样本检验准确率到了 0.7518(0.5 表示完全无法区分)。两个模式明明越离越远,FID 一动不动。

更值得玩味的是最后两列:二次特征上的 ridge 分类器 AUC 也一直是 0.50。这不是巧合——这里的平方损失 ridge 在类平衡、矩匹配时缺少均值层面的监督信号。不能推广为所有二次判别器都只看前两阶矩:例如对 $x^2$ 设阈值也可以利用平方值分布的尾部差异。为了确认这一点我做了对照实验:固定模式间距,改成把 $Q$ 的协方差整体放大 $s$ 倍(破坏矩匹配),于是 FID 和二次 ridge AUC 一起抬头:

协方差放大倍数 $s$ FID 二次特征 ridge AUC
1.0 8.53e-14 0.5021
1.05 0.0585 0.5022
1.2 0.8745 0.5198
1.5 4.8490 0.5972
2.0 16.4710 0.7806

本实验中,矩匹配使总体 FID 为零,二次特征 ridge 的测试 AUC 接近随机水平 0.5;改变协方差后两者均发生变化。 1-NN 那种基于局部密度的判别器则不吃这一套——它看的是密度本身的形状,不是矩。

这张图要看什么:左图是 $P$(蓝)和 $Q$(橙)在二维上的真实散点,连同它们各自的 1σ/2σ 椭圆——注意两个椭圆几乎完全重合,这就是「前两阶矩完全一样」的几何含义:二阶统计量把两个分离的团和一个糊在一起的团画成了同一个椭圆。右图是同一个实验扫过模式间距的结果,蓝线(FID,左轴对数刻度)从头到尾贴在 $10^{-14}$ 量级纹丝不动,橙线(贝叶斯最优 AUC)和绿线(1-NN 准确率,右轴)一路爬到 0.90 / 0.75。两条线之间的那片空白,就是 FID 用高斯假设换来的盲区。

04. 代码实现

核心只有三件事:对称矩阵的平方根、$\mathrm{Tr}((\Sigma_1\Sigma_2)^{1/2})$ 的对称化路线、以及协方差怎么估。下面这段是 fid_core.py 的主干(完整版见附录)。

import numpy as np

def sqrtm_sym(C: np.ndarray, eps: float = 1e-6) -> np.ndarray:
    r"""对称半正定矩阵的平方根。

    对 C = V diag(w) V^T,有 C^{1/2} = V diag(sqrt(w)) V^T。
    eps 是相对谱尺度的负特征值容差,不是给正特征值设置下限。
    明显非半正定输入报错;容差内负值截到 0,保留真正的零特征值。
    """
    C = np.asarray(C, dtype=np.float64)
    if C.ndim != 2 or C.shape[0] != C.shape[1] or not np.isfinite(C).all():
        raise ValueError("C must be a finite square matrix")
    scale = max(np.linalg.norm(C, ord=np.inf), np.finfo(float).tiny)
    if not np.allclose(C, C.T, rtol=0.0, atol=eps * scale):
        raise ValueError("C must be symmetric")
    w, V = np.linalg.eigh((C + C.T) / 2)
    if w.min() < -eps * max(np.abs(w).max(), np.finfo(float).tiny):
        raise ValueError("C must be positive semidefinite")
    w = np.sqrt(np.clip(w, 0.0, None))
    return (V * w) @ V.T

def trace_sqrt_product(sigma1: np.ndarray, sigma2: np.ndarray,
                       eps: float = 1e-6) -> float:
    r"""计算 Tr((Σ1 Σ2)^{1/2}),走对称化路线。

    Σ1 正定时,Σ1 Σ2 与 Σ1^{1/2} Σ2 Σ1^{1/2} 相似:
        Σ1^{1/2} (Σ1^{1/2} Σ2 Σ1^{1/2}) Σ1^{-1/2} = Σ1 Σ2
    二者特征值相同,而后者是**对称半正定**的,可以安全用 eigh。
    主平方根与相似变换可交换,所以迹也相同:
        Tr((Σ1 Σ2)^{1/2}) = Σ_i sqrt(λ_i)
    """
    s1 = sqrtm_sym(sigma1, eps)
    M = s1 @ sigma2 @ s1
    M = 0.5 * (M + M.T)               # 强制对称,压掉浮点不对称
    w = np.linalg.eigvalsh(M)
    return float(np.sqrt(np.clip(w, 0.0, None)).sum())

这里先展示平方根与交叉项;完整均值、协方差与距离实现见附录。

四个符号和 3.3 节的推导一一对应:mu1/mu2 是 $\mu_1/\mu_2$,sigma1/sigma2 是 $\Sigma_1/\Sigma_2$,trace_sqrt_product 就是 $\mathrm{Tr}((\Sigma_1\Sigma_2)^{1/2})$,eps 是判定负特征值是否属于舍入误差的相对容差,不是把所有小特征值抬高的正则项。

跑 python fid_core.py 的自检输出(这些数字全部是实跑结果):

[1] 恒等性:FID(P, P) 必须为 0
    FID(P,P) = -4.263e-14

[2] 对称化路线 vs 错误写法 vs 一般特征值参考实现
         d          sym(正确)        naive(错误)      eigvals(参考)
         8    11.5957639872    10.8132126304    11.5957639872
        32    41.7985007127    40.2403835447    41.7985007127
       128   173.3801162800   165.4519506579   173.3801162800

[3] 这个差异会传进 FID:同一对特征,两种写法差多少
    FID(sym)   = 45.584468
    FID(naive) = 50.484178   (差值 +4.899711)

[4] 尺度不是不变的:特征整体乘 c,FID 变 c^2 倍
    c=0.5  FID=   11.396117  期望 c^2*base=   11.396117
    c=2.0  FID=  182.337871  期望 c^2*base=  182.337871
    c=4.0  FID=  729.351482  期望 c^2*base=  729.351482

[5] 有偏 vs 无偏协方差(n 越小差得越多)
          n      unbiased        biased           差值
        256     43.932626     43.765931    -0.166696
       1024     10.959338     10.948996    -0.010341
       8192      1.518005      1.517825    -0.000180

逐条对一下这几个数为什么要看:

  • [1] 按绝对值和尺度相关容差检查恒等性;0.0、极小正值和极小负值都可能正确。明显超出容差才需要排查。附录还验证奇异、小尺度协方差与非 PSD 输入。
  • [4] 验证 3.3 节末尾那个推论:$c=2$ 时 $4 \times 45.584468 = 182.337871$,完全吻合。这意味着任何改变特征尺度的预处理都会改变 FID,而且不是线性地改。
  • [5] 本例两组样本数相同,用 $1/n$ 会把双方协方差一起缩小,因此表中的有偏协方差版本距离略小。协方差无偏不代表 FID 无偏;不同样本量时不能直接套这个单调结论。$n=8192$ 时差 0.00018 可以忽略,$n=256$ 时差 0.167 就不能忽略了。工业实现和手写实现在这上面分叉,跨实现比较时要注意。

05. 工业级实现对照

生产里大家用的是 mseitzer/pytorch-fid(截至 2026-10 的实现为准),核心函数在 src/pytorch_fid/fid_score.py#calculate_frechet_distance。和上面的最小实现有五处差异,每一处都有原因:

1. 矩阵平方根用的是 scipy 而不是 eigh。 官方写的是

covmean, _ = linalg.sqrtm(sigma1.dot(sigma2), disp=False)
if not np.isfinite(covmean).all():
    offset = np.eye(sigma1.shape[0]) * eps
    covmean = linalg.sqrtm((sigma1 + offset).dot(sigma2 + offset))
if np.iscomplexobj(covmean):
    if not np.allclose(np.diagonal(covmean).imag, 0, atol=1e-3):
        raise ValueError("Imaginary component {}".format(np.max(np.abs(covmean.imag))))
    covmean = covmean.real

scipy.linalg.sqrtm 是通用矩阵的主平方根求解器,不假设输入对称,所以直接喂 $\Sigma_1\Sigma_2$ 是对的——这跟我的对称化路线在数学上等价,只是浮点路径不同。代价是结果可能带极小的虚部(数值误差),所以有了那段 .real 兜底:先看对角线虚部是不是都在 $10^{-3}$ 以内,超了就报错而不是默默取实部。我的最小实现用 eigvalsh 绕开了复数问题,代价是必须自己先把矩阵对称化。真正的坑是有人为了去掉 scipy 依赖把它换成 np.linalg.eigh——那就是 3.4 节那个 +4.9 的错误。

2. 数值容差与正则化不同。 本文只截掉容差内的负特征值并保留零值,以便奇异或小尺度协方差仍满足恒等性;不能无条件把所有特征值抬到 eps。pytorch-fid 在 sqrtm 失败时给协方差加 eps*I 重试,这是改变问题的正则化兜底,应记录是否触发。

3. 协方差用 np.cov(act, rowvar=False),默认无偏。 也就是 04 节 [5] 那张表的第一列。这一点在 $n$ 小的时候会造成跨实现的系统差异。

4. 特征提取有一整套约定。 src/pytorch_fid/inception.py 里的 InceptionV3 取的是第 3 个 block(最终平均池化之后)的 2048 维;resize_input=True 会先把输入双线性缩放到 299×299;use_fid_inception=True 用的是与 TensorFlow 版对齐的 FID 专用权重,而不是 torchvision 默认的 ImageNet 权重。命令行还有 --dims,可以选 64 / 192 / 768 / 2048——换了 dims 就是换了另一个指标,数字不可比,这是跨论文比较时最常被忽略的一项。

5. 保留数值诊断。 该实现返回原始结果,不自动截零。生产实现也可以先验证误差在容差内再截零,但不能不加检查地掩盖大负值;正好为 0 并不说明出错。

还有一个官方不管、但你必须自己管的事:预处理的一致性。Parmar 等人在 arXiv:2104.11222 里指出,resize 是否抗锯齿、图片是否被 JPEG 量化,都会显著改变 FID 的数值——大到足以改变两篇论文的排序。所以在比对任何两个 FID 之前,先确认两边的 Inception 权重、dims、resize 方式、量化流程、样本量、协方差是否有偏,这六项是不是一致。六项里任何一项对不上,两个 FID 就不是同一个东西。

06. 代价与边界

6.1 偏差有多大:随 $1/n$ 衰减,随维度放大

把 01 节那个实验做全(附录 fid_bias_lab.py 的 [A] 段,$P$ 和 $Q$ 是同一个分布,所以真值是 0):

每组样本量 $n$ FID($d=512$) FID($d=2048$)
50 431.54 2131.57
1000 53.85 640.65
10000 5.39 71.20
50000 1.09 14.21

两个观察:

  • 双对数坐标下这些点近乎落在一条直线上,斜率接近 $-1$,也就是偏差按 $\sim 1/n$ 衰减。在本实验的大样本区间,样本量翻倍时平均偏差约减半。
  • 维度从 512 涨到 2048(4 倍),$n=10000$ 处的偏差从 5.40 涨到 71.14(13 倍)。把 $d$ 从 64 扫到 2048 做拟合([B] 段,固定 $n=10000$),实测指数约为 1.814:
$d$ 64 128 256 512 1024 2048
FID($P$,$P$) 0.1326 0.4517 1.5584 5.3967 19.5333 71.1371

这里约 $d^{1.8}$ 的拟合只适用于所选的 $1/k$ 协方差谱、迹归一化与扫描范围。真实 Inception 的谱和尺度不同,不能把这个指数当作通用样本量定律。

这张图要看什么:左图是 $P=Q$(真值 0)时 FID 随样本量的变化,双对数刻度,蓝线 $d=512$、橙线 $d=2048$,灰色虚线是斜率 $-1$ 的参考——两条实测线几乎与它平行,这就是「偏差按 $1/n$ 衰减」的直接证据;注意橙线在 $n=50000$ 时还有 14.21,远没有收敛到 0。右图固定 $n=10000$ 扫维度,同样是对数刻度,拟合斜率约 1.814,意味着维度翻一倍偏差涨约 3.5 倍;最右端采用了与 Inception pool3 相同的维度,但使用的是合成高斯特征,并非实际图像嵌入。

6.2 能不能把偏差外推掉

在偏差近似服从 $1/n$ 展开的样本区间,可以尝试外推;需检验拟合稳定性。Chong & Forsyth(arXiv:1911.07023)的做法是:用 $n = N, N/2, N/4, N/8$ 四个点算四个 FID,对 $1/n$ 做线性拟合 $\mathrm{FID}(n) \approx F_{\infty} + \beta / n$,截距 $F_{\infty}$ 就是外推到无穷样本量的估计。

实测([C] 段,$d=512$):

  • $P = Q$(真值 0):$n=40000$ 估 1.3473、$n=5000$ 估 10.8717,外推得 $F_{\infty} = -0.0121$。直接报 $n=40000$ 的数字(1.35)比外推差得多。
  • $P \neq Q$(真值 0.3635):$n=40000$ 直接估 1.7430,误差 +1.3795;外推得 0.4845,误差 +0.1210。

外推把误差压掉了约 11 倍。代价是要多算三次特征统计量(不过统计量可以复用——从大样本里抽子集就行,不用重新过一遍 Inception)。

报告时同时给参考/生成样本数、原始 FID 和完整协议;使用外推还要报告拟合点、重复采样与不确定性。负截距反映估计误差,不是负的总体距离;外推不保证每个有限样本实验都更准。

6.3 FID 和 CLIP Score 在给不同的东西打高分

CLIP Score 的定义(Hessel 等,arXiv:2104.08718)比 FID 简单得多:

$$\mathrm{CLIP\text{-}S} = w \cdot \max \big( \cos(f_{\text{img}}, f_{\text{txt}}), 0 \big), \quad w = 2.5$$

其中 $f_{\text{img}}$ 和 $f_{\text{txt}}$ 是 CLIP 的图像/文本编码,$w=2.5$ 只是把数值放大到好读的量级。原始 CLIPScore 为图像描述评价提出,reference-free 指不需要人工参考描述;它仍需要待评图像和文本。迁移到文生图时不需要配对真值图。这里按原论文 $w=2.5$,其他实现也会用 100 等缩放,必须注明模型和约定。

下面的人工共享空间反例中,两者最优点不同(附录 clip_alignment_lab.py 的 [A] 段:一个结构化的 CLIP 替身,共享表示空间 $d_s=64$、$K=24$ 个概念,真实数据的多样性固定为 $\sigma_{\text{real}}=0.5$,$n=20000$):

生成多样性 $\sigma_g$ 0.05 0.2 0.4 0.5 0.6 0.8 1.0
FID 10.97 5.24 0.63 0.029 0.65 5.65 15.61
CLIP Score 2.323 1.323 0.744 0.607 0.515 0.399 0.335

FID 在 $\sigma_g = 0.5$(真实数据的多样性)处取最小 0.029,呈 U 形;CLIP Score 单调递减,在 $\sigma_g = 0.05$(几乎退化成确定性输出)处取最大 2.323。该构造下,相似度奖励靠近概念中心,分布距离奖励匹配设定的方差;不能解释为所有 CLIP 模型都偏好确定性。 顺带一提,真实数据自己的 CLIP Score 是 0.6082,而 $\sigma_g=0.5$ 那个「FID 最优」的模型是 0.6073——跟真实数据几乎一样。也就是说在这个实验里,FID 最优点才对应「和真实数据一致」,CLIP Score 的最优点对应的是「模式收敛」。

而且 CLIP Score 的均值性质会掩盖分布。构造两个模型([B] 段):$M_1$ 以概率 $p$ 输出完美匹配的图、以 $1-p$ 输出纯噪声;$M_2$ 每张图都中等匹配(二分调 $\sigma$ 让它的 CLIP Score 与 $M_1$ 相同):

$p$ $M_1$ CLIP Score $M_1$ 逐样本标准差 $M_1$ 好图占比 $M_1$ FID $M_2$ $\sigma$ $M_2$ CLIP Score $M_2$ 逐样本标准差 $M_2$ FID
0.3 0.8330 0.4695 0.2980 9.920 0.353 0.8332 0.1078 1.323
0.5 1.3039 0.5078 0.4966 10.191 0.204 1.3016 0.0847 5.112
0.7 1.7896 0.4627 0.7012 10.677 0.122 1.7895 0.0536 8.079
0.9 2.2681 0.2992 0.9023 11.584 0.058 2.2688 0.0171 10.663

两组 CLIP Score 的最大差距只有 0.0023(按构造应该同分),但 $M_1$ 的逐样本相似度标准差是 $M_2$ 的 17.55 倍($p=0.9$ 时 0.2992 对 0.0171)。同一个分数,一个是「九成图完美、一成完全不沾边」,另一个是相似度更集中的输出($p=0.9$ 时均值也很高,不能称为勉强沾边)。汇总均值看不见这个区别,但逐样本 CLIPScore 的直方图或分位数可以显示它。

这张图要看什么:左图是同一个 $\sigma_g$ 扫描下两个指标的走向,蓝线(FID,左轴,越低越好)呈 U 形、在 $\sigma_g=0.5$ 处触底,橙线(CLIP Score,右轴,越高越好)单调下降、在最左端 $\sigma_g=0.05$ 处封顶——两条线的最优点不同,说明这组构造下两个目标存在冲突;0.5 并不是横轴右端,也不能据此断言现实模型的普遍走势;灰色竖虚线标出的是真实数据的多样性 $\sigma_{\text{real}}=0.5$。右图是 $p=0.5$ 那一行两个模型的逐样本相似度分布:橙色的 $M_1$ 是明显的双峰(一半堆在接近 1 的位置、一半堆在 0 附近),蓝色的 $M_2$ 是一根集中在 0.5 附近的单峰,图中直方图直接来自附录实际实验数据,橙/蓝虚线分别是原始余弦均值。CLIPScore 先对每个相似度截零,分数接近不保证原始余弦均值相同;汇总分数仍可能掩盖两种很不一样的样本分布。

6.4 什么时候不该用 FID

  • 样本量小的时候:估计偏差可能淹没模型差异,具体量级取决于数据与特征器。先做同分布基线和重复抽样,再判断是否增样本或尝试外推;没有通用的「10k 以下偏差大于 70」门槛。
  • 要评价单张图的时候:FID 根本没有「单张图」这个概念。需要逐样本打分就上 CLIP Score / ImageReward,但要区分逐样本评分与数据集均值,要配着直方图或者分位数一起看。
  • 要区分「质量」和「覆盖」的时候:FID 把两者压成一个数。一个只生成 10 张高质量图的模型和一个生成 10000 张中等质量图的模型,FID 可能很接近,但产品含义完全不同。这种情况应该用 Improved Precision & Recall(arXiv:1904.06991)拆成两个数。
  • 两个分布形状不同但矩相同的时候:3.5 节的实验——FID 严格为 0,而 1-NN 双样本检验有 0.75 的判别率。
  • 要跨论文比较的时候:除非核实了 05 节末尾那六项完全一致,否则请把数字当成「同一篇论文内部的相对量」,不要当成绝对值。

07. 经典论文脉络

  1. Inception Score(arXiv:1606.03498,Improved Techniques for Training GANs)——第一个被广泛采用的自动指标:用 Inception 的分类分布衡量「单图是否清晰可辨」+「整体是否有多样性」。贡献是让 GAN 评测摆脱了人工打分;根本缺陷是完全不看真实分布,所以无法检测记忆训练集,也无法检测 mode collapse 的另一种形式。
  2. FID(arXiv:1706.08500,TTUR 那篇)——把真实分布拉进比较,用 Inception pool3 特征上的 2-Wasserstein 距离平方评价分布差异。贡献是「两个分布之间的距离」这个范式,在论文实验中展示了对若干退化的敏感性,后来成为常用指标;这种表现不保证覆盖所有退化。
  3. Improved Precision and Recall Metric for Assessing Generative Models(2019)——指出单个标量无法同时表达「生成质量」和「分布覆盖」,拆成 P(生成样本落在真实流形内的比例)和 R(真实样本能被生成覆盖的比例)。贡献是提供了 FID 缺失的那个维度:FID 相同的一对模型,可以在 P/R 平面上处于完全不同、甚至此消彼长的位置。
  4. Effectively Unbiased FID(arXiv:1911.07023)——把 FID 当成一个统计估计量来审视,指出它是有偏的、偏差随 $1/n$ 衰减,并给出用多个样本量外推到 $F_{\infty}$ 的方法。6.2 节那组数字就是照它的做法复现的。它解释了有限样本偏差为何会影响模型排序。
  5. CLIPScore(arXiv:2104.08718)——把评测从「分布 vs 分布」拉回「图 vs 文」,提出不需要参考图的图文对齐指标。贡献是让文生图有了逐样本的自动化对齐分数;局限是相似度不能覆盖全部质量维度,汇总均值也会丢失样本分布;与 FID 的关系依赖实际模型。

补充一条横向的:Borji 的 Pros and Cons of GAN Evaluation Measures(arXiv:1802.03446)系统比较了十几种指标的失效模式,结论是没有任何单一指标能在所有场景下胜出——这也是本篇反复强调「两个指标一起看、连同它们的盲区一起看」的依据。

08. 常见误解

误解 1:「FID 越低,生成的图越好。」 FID 只比较两个分布的前两阶矩,不比较单张图。实测证据:把两个模式越拉越远,FID 一动不动(3.5 节,FID 恒为 0,1-NN 判别率 0.7518)。反过来的方向也成立——FID 很低但每张图都文不对题,是完全可能的。

误解 2:「FID = 0 说明两个分布一样。」 3.5 节整节都在反驳这一点。前两阶矩匹配就够了,形状随便怎么不同都行。高斯假设换来的就是这个。

误解 3:「两篇论文的 FID 可以直接比大小。」 我自己踩过最狠的一个。至少六项要对齐:Inception 权重、特征维度 dims、resize 方式与是否抗锯齿、量化流程、样本量、协方差是否有偏。实测的敏感度:$d=2048$ 时样本量从 10k 到 50k,本文特定人工高斯模型的估计距离从 71.20 变 14.21(差 57);特征整体乘 $c$ 倍,FID 乘 $c^2$ 倍($c=2$ 时 45.58 → 182.34)。这些数字说明 FID 更像一个有单位的物理量,不是一个无量纲分数。

误解 4:「FID 与 CLIP Score 必然反向变化。」 两者测的内容不同,可能同好同坏,也可能冲突。本文合成实验给出冲突的一个例子,不是普遍定律。CFG 的变化也不能精确等同于给合成嵌入加某个固定高斯噪声。

误解 5:「平均 CLIP Score 高,说明每张图都对。」 数据集均值会掩盖尾部。实测两个 CLIP Score 差 0.0023 的模型,逐样本相似度标准差差 17.55 倍(0.2992 对 0.0171)。要看单张图的质量分布,得看直方图或者低分位数,不能只看均值。

误解 6:「FID 非负,所以所有负输出都直接夹为 0。」 应先确认输入和平方根实现,再检查负值是否在尺度相关容差内。小负数可以记录后截零,明显负数必须报错;恰好输出 0 本身既不能证明正确,也不能证明有错。

09. 动手验证

三个数值脚本在文末附录,依赖 numpy;配图另需 matplotlib,不使用真实 Inception/CLIP 权重:

python fid_core.py            # 约 1 秒:FID 最小实现 + 5 组自检
python fid_bias_lab.py ALL    # 约 1 分钟:样本量偏差 / 维度 / 外推 / 矩盲区
python clip_alignment_lab.py ALL   # 约 10 秒:FID 与 CLIP Score 的相反最优

预期结果(这些数字是实跑输出,可以直接对):

  1. fid_core.py 会按尺度相关容差断言恒等性,包括奇异和小尺度 PSD 输入。数值可以恰好为 0;不要要求固定尾数或符号。明显超出容差时再查矩阵平方根与输入。
  2. fid_bias_lab.py 的 [A] 段,$d=2048$ 那一列:n=10000 应约 71.20、n=50000 应约 14.21($P=Q$,真值 0)。[D] 段最后一行的贝叶斯 AUC 应约 0.9033、1-NN 准确率应约 0.7518,而总体 FID 的数值实现应在零附近的浮点容差内。
  3. clip_alignment_lab.py 的 [A] 段,最优 $\sigma_g$:FID 是 0.5、CLIP Score 是 0.05。[B] 段最后打印的「CLIP Score 最大差距」应约 0.0023、「标准差比值」应约 17.55 倍。

想自己造一个「FID 失效」的例子?改 fid_bias_lab.py 的 [D] 段里那个 a(模式间距的一半)就行:把 $a$ 从 1 扫到 8(中心间距从 2 到 16),总体 FID 保持 0;本例 1-NN 准确率从约 0.50 增至 0.75。

10. 延伸阅读

  • 音频质量评测:MOS、PESQ 与 FAD 各测什么(本系列已发布)——里面的 FAD(Fréchet Audio Distance)就是 FID 换了个特征提取器,高斯矩估计的风险同样存在,但偏差系数与特征器、尺度和样本相关性有关。
  • 视频生成评测:VBench 与人工验收(本系列规划中)——VBench 是多维度视频评测框架,不是简单把 FID 升维;FVD 才是采用视频特征的相关高斯距离指标。两者都不能直接套本文合成模型的 $d^{1.8}$ 指数。
  • DDPM 训练目标与采样流程、Classifier-Free Guidance(本系列已发布)——CFG 会影响条件一致性与多样性,但不是 6.3 节 $\sigma_g$ 的严格等价参数。实际趋势需要在具体模型上测量。

附录:完整代码

09 节用到的脚本全文如下(fid_bias_lab.py、fid_core.py、clip_alignment_lab.py、make_figures.py)。复制到本地存成同名文件,按各脚本开头的依赖说明准备环境后即可运行。

fid_bias_lab.py

"""
fid_bias_lab.py —— FID 的样本量偏差实验。

回答三个问题:
  [A] 两个分布**完全一样**时,FID 是多少?(答:不是 0,而且 n 越小越离谱)
  [B] 这个偏差随维度 d、样本量 n 怎么变?
  [C] 能不能把它外推掉?(Chong & Forsyth, arXiv:1911.07023 的做法)
  [D] FID 只看前两阶矩,那「前两阶矩完全一样、分布完全不同」能骗过去吗?

运行:
    python fid_bias_lab.py            # 全跑
    python fid_bias_lab.py A          # 只跑 A 段
"""

from __future__ import annotations

import json
import os
import sys

import numpy as np

from fid_core import fid_from_features, frechet_distance, covariance

HERE = os.path.dirname(os.path.abspath(__file__))
OUT_JSON = os.path.join(HERE, "_fid_bias_results.json")


# ──────────────────────────────────────────────────────────────
# 造一个明确指定谱与尺度的合成协方差:特征值按 1/k 衰减,迹归一到 d
# ──────────────────────────────────────────────────────────────
def spectrum_cov(d: int, trace: float | None = None, alpha: float = 1.0) -> np.ndarray:
    r"""对角协方差,特征值 λ_k ∝ k^{-alpha},归一化到 Tr(Σ)=trace。

    这是人为选择的谱衰减模型,并非对 Inception pool3 的实测拟合:
    少数几个方向撑着大部分方差,长尾方向方差很小。
    trace 默认取 d,也就是「每个维度平均方差为 1」。
    """
    k = np.arange(1, d + 1, dtype=np.float64)
    lam = k.astype(np.float64) ** (-alpha)
    if trace is None:
        trace = float(d)
    lam = lam * (trace / lam.sum())
    return np.diag(lam)


def sample_gaussian(mean: np.ndarray, cov_diag: np.ndarray, n: int,
                    rng: np.random.Generator) -> np.ndarray:
    """从对角协方差的高斯里采样(对角阵直接按列缩放,不用 Cholesky)。"""
    d = mean.shape[0]
    lam = np.diag(cov_diag) if cov_diag.ndim == 2 else cov_diag
    z = rng.standard_normal((n, d))
    return mean[None, :] + z * np.sqrt(lam)[None, :]


# ──────────────────────────────────────────────────────────────
# [A] P == Q 时的 FID
# ──────────────────────────────────────────────────────────────
def section_a(d_list=(512, 2048), reps=3, verbose=True):
    print("=" * 74)
    print("[A] 两个分布完全一样时,FID 不是 0")
    print("    真实分布 = 生成分布,理论上 FID = 0。实测:")
    print("=" * 74)

    n_list = [50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 50000]
    out = {}
    for d in d_list:
        rng = np.random.default_rng(20261001 + d)
        cov = spectrum_cov(d)
        mu = np.zeros(d)
        rows = []
        for n in n_list:
            vals = []
            for r in range(reps):
                x1 = sample_gaussian(mu, cov, n, rng)
                x2 = sample_gaussian(mu, cov, n, rng)
                vals.append(fid_from_features(x1, x2))
            m = float(np.mean(vals))
            s = float(np.std(vals))
            rows.append((n, m, s))
            if verbose:
                print(f"    d={d:>5}  n={n:>6}  FID = {m:>10.4f}  (std {s:.4f})")
        out[d] = rows
        if verbose:
            print()
    return {"n_list": n_list, "by_d": {str(k): v for k, v in out.items()}}


# ──────────────────────────────────────────────────────────────
# [A2] 偏差到底来自均值还是协方差
# ──────────────────────────────────────────────────────────────
def section_a2(d=512, n=40000, reps=3, verbose=True):
    print("=" * 74)
    print("[A2] 偏差来自哪里:均值还是协方差?(P == Q,真值 0)")
    print("=" * 74)
    rng = np.random.default_rng(556677)
    cov = spectrum_cov(d)
    mu = np.zeros(d)

    full, mean_known, cov_known = [], [], []
    for _ in range(reps):
        x1 = sample_gaussian(mu, cov, n, rng)
        x2 = sample_gaussian(mu, cov, n, rng)
        m1, s1 = x1.mean(0), covariance(x1)
        m2, s2 = x2.mean(0), covariance(x2)
        full.append(frechet_distance(m1, s1, m2, s2))
        # 假设均值已知(用真实 mu=0),只估协方差
        mean_known.append(frechet_distance(mu, s1, mu, s2))
        # 假设协方差已知(用真实 cov),只估均值
        cov_known.append(frechet_distance(m1, cov, m2, cov))

    f, mk, ck = float(np.mean(full)), float(np.mean(mean_known)), float(np.mean(cov_known))
    theory_mean = 2.0 * np.trace(cov) / n
    if verbose:
        print(f"    d={d}  n={n}")
        print(f"    两个都估(正常做法)      FID = {f:>10.4f}")
        print(f"    均值已知、只估协方差      FID = {mk:>10.4f}   "
              f"占全部偏差的 {mk / f * 100:5.1f}%")
        print(f"    协方差已知、只估均值      FID = {ck:>10.4f}   "
              f"占全部偏差的 {ck / f * 100:5.1f}%")
        print(f"    理论值 2*Tr(Sigma)/n = {theory_mean:.6f}(对照上一行)")
        print()
        print("    -> 偏差几乎全部来自**协方差估计**。均值那一项理论上就是")
        print("       2*Tr(Sigma)/n,小到可以忽略;麻烦的是 d x d 个协方差元素。")
    print()
    return {"d": d, "n": n, "full": f, "mean_known": mk, "cov_known": ck,
            "theory_mean": float(theory_mean)}


# ──────────────────────────────────────────────────────────────
# [B] 偏差随 d 与 n 的缩放
# ──────────────────────────────────────────────────────────────
def section_b(verbose=True):
    print("=" * 74)
    print("[B] 偏差随维度 d 怎么长(固定 n=10000)")
    print("=" * 74)
    n = 10000
    rows = []
    for d in (64, 128, 256, 512, 1024, 2048):
        rng = np.random.default_rng(777000 + d)
        cov = spectrum_cov(d)
        mu = np.zeros(d)
        vals = []
        for r in range(3):
            x1 = sample_gaussian(mu, cov, n, rng)
            x2 = sample_gaussian(mu, cov, n, rng)
            vals.append(fid_from_features(x1, x2))
        m = float(np.mean(vals))
        rows.append((d, m, d / n))
        if verbose:
            print(f"    d={d:>5}  n={n}  d/n={d / n:>7.4f}  FID = {m:>10.4f}")
    print()
    return {"n": n, "rows": rows}


# ──────────────────────────────────────────────────────────────
# [C] 外推:FID(n) ≈ F_inf + beta / n
# ──────────────────────────────────────────────────────────────
def section_c(verbose=True):
    print("=" * 74)
    print("[C] 能不能把偏差外推掉?(arXiv:1911.07023 的做法)")
    print("    用 n = N, N/2, N/4, N/8 四个点,对 1/n 做线性拟合,截距即 F_inf")
    print("=" * 74)

    d = 512
    rng = np.random.default_rng(31337)
    cov = spectrum_cov(d)

    # C1: P == Q,真值 0
    N = 40000
    sizes = [N, N // 2, N // 4, N // 8]
    est = []
    for n in sizes:
        v = []
        for r in range(3):
            x1 = sample_gaussian(np.zeros(d), cov, n, rng)
            x2 = sample_gaussian(np.zeros(d), cov, n, rng)
            v.append(fid_from_features(x1, x2))
        est.append(float(np.mean(v)))
    inv_n = np.array([1.0 / n for n in sizes])
    beta, a0 = np.polyfit(inv_n, np.array(est), 1)
    if verbose:
        for n, e in zip(sizes, est):
            print(f"    P==Q  n={n:>6}  FID = {e:>9.4f}")
        print(f"    外推 F_inf = {a0:>9.4f}   (真值 0.0000, 斜率 beta={beta:.2f})")
    c1 = {"sizes": sizes, "est": est, "extrap": float(a0), "slope": float(beta)}

    # C2: P != Q,真值可以直接从矩算出来
    print()
    shift = np.zeros(d)
    shift[0] = 0.5                       # 只在第 0 维上挪一点
    mu_q = shift
    cov_q = cov * 1.03                   # 协方差整体放大 3%
    true_fid = frechet_distance(np.zeros(d), cov, mu_q, cov_q)
    est2 = []
    for n in sizes:
        v = []
        for r in range(3):
            x1 = sample_gaussian(np.zeros(d), cov, n, rng)
            x2 = sample_gaussian(mu_q, cov_q, n, rng)
            v.append(fid_from_features(x1, x2))
        est2.append(float(np.mean(v)))
    beta2, a02 = np.polyfit(inv_n, np.array(est2), 1)
    if verbose:
        for n, e in zip(sizes, est2):
            print(f"    P!=Q  n={n:>6}  FID = {e:>9.4f}")
        print(f"    真值 FID  = {true_fid:>9.4f}")
        print(f"    外推 F_inf = {a02:>9.4f}   (斜率 beta={beta2:.2f})")
        print(f"    直接用 n={N} 的估计误差 = {est2[0] - true_fid:+.4f}")
        print(f"    外推后的误差           = {a02 - true_fid:+.4f}")
    print()
    return {"c1": c1, "c2": {"sizes": sizes, "est": est2, "true": float(true_fid),
                             "extrap": float(a02), "slope": float(beta2)}}


# ──────────────────────────────────────────────────────────────
# [D] 前两阶矩一样、分布完全不同
# ──────────────────────────────────────────────────────────────
def _auc(scores_pos: np.ndarray, scores_neg: np.ndarray) -> float:
    """Mann-Whitney U 形式的 AUC:P(score_pos > score_neg)。"""
    a = np.sort(scores_pos)
    b = np.sort(scores_neg)
    # 对每个 b,统计有多少 a 严格大于它
    cnt = a.size - np.searchsorted(a, b, side="right")
    return float(cnt.sum() / (a.size * b.size))


def _quad_features(X: np.ndarray) -> np.ndarray:
    """二次特征展开:[x_i, x_i x_j (i<=j)]。"""
    n, d = X.shape
    iu = np.triu_indices(d)
    quad = X[:, iu[0]] * X[:, iu[1]]
    return np.concatenate([X, quad], axis=1)


def _gauss_logpdf(X: np.ndarray, mean: np.ndarray, cov: np.ndarray) -> np.ndarray:
    """对角/一般协方差下的高斯 log 密度。"""
    d = X.shape[1]
    Xc = X - mean[None, :]
    if cov.ndim == 2 and cov.shape[0] == cov.shape[1]:
        lam, V = np.linalg.eigh(cov)
        lam = np.clip(lam, 1e-12, None)
        proj = Xc @ V
        quad = (proj ** 2 / lam[None, :]).sum(axis=1)
        logdet = np.log(lam).sum()
    else:
        lam = np.asarray(cov).ravel()
        quad = (Xc ** 2 / lam[None, :]).sum(axis=1)
        logdet = np.log(lam).sum()
    return -0.5 * (quad + logdet + d * np.log(2 * np.pi))


def _nn_two_sample(Xa: np.ndarray, Xb: np.ndarray, m: int = 2500) -> float:
    """1-近邻两样本检验的准确率。

    把两组样本混在一起,对每个点找它的最近邻,看这个邻居是不是同组的。
    P == Q 时这个比例趋近 0.5(纯随机),分布有差别时会明显大于 0.5。
    """
    A, B = Xa[:m], Xb[:m]
    P = np.concatenate([A, B], axis=0)
    D = ((P[:, None, :] - P[None, :, :]) ** 2).sum(-1)
    np.fill_diagonal(D, np.inf)
    idx = np.argmin(D, axis=1)
    lab = np.concatenate([np.zeros(m), np.ones(m)])
    return float((lab[idx] == lab).mean())


def _ridge_auc(Xp: np.ndarray, Xq: np.ndarray, feat, ntr: int, lam: float,
               rng: np.random.Generator) -> float:
    """在给定特征映射上训一个 ridge 二分类器,返回测试集 AUC。"""
    Fp, Fq = feat(Xp), feat(Xq)
    n = Xp.shape[0]
    Xtr = np.concatenate([Fp[:ntr], Fq[:ntr]], axis=0)
    ytr = np.concatenate([np.ones(ntr), -np.ones(ntr)])
    Xte = np.concatenate([Fp[ntr:], Fq[ntr:]], axis=0)
    yte = np.concatenate([np.ones(n - ntr), -np.ones(n - ntr)])
    sd = Xtr.std(axis=0)
    sd[sd < 1e-12] = 1.0
    Xtr, Xte = Xtr / sd, Xte / sd
    Phi = Xtr.T @ Xtr
    w = np.linalg.solve(Phi + lam * np.eye(Phi.shape[0]), Xtr.T @ ytr)
    sc = Xte @ w
    return _auc(sc[yte > 0], sc[yte < 0])


def section_d(verbose=True):
    print("=" * 74)
    print("[D] 前两阶矩完全一样、分布完全不同 —— FID 看得见吗")
    print("    真实 P = 0.5*N(+m, I) + 0.5*N(-m, I)   (两个分离的模式)")
    print("    生成 Q = N(0, I + m m^T)                (一个把两个模式糊在一起的团)")
    print("    两者均值都是 0、协方差都是 I + m m^T  =>  FID 真值严格等于 0")
    print("=" * 74)

    d = 32
    n = 40000
    rng = np.random.default_rng(24680)
    u = rng.standard_normal(d)
    u /= np.linalg.norm(u)

    if verbose:
        print(f"    {'模式间距 2|m|':>12} {'FID(总体)':>12} {'FID(经验)':>10} "
              f"{'最优AUC':>9} {'1NN':>7} {'二次AUC':>8} {'线性AUC':>8}")
    rows = []
    for a in (1, 2, 3, 4, 6, 8):
        m = a * u
        sign = rng.integers(0, 2, size=n) * 2 - 1
        Xp = rng.standard_normal((n, d)) + sign[:, None] * m[None, :]
        cov_q = np.eye(d) + np.outer(m, m)
        Xq = rng.multivariate_normal(np.zeros(d), cov_q, size=n)

        # 总体 FID:直接用矩算,理论值 0
        cov_p = np.eye(d) + np.outer(m, m)
        fid_pop = frechet_distance(np.zeros(d), cov_p, np.zeros(d), cov_q)
        # 经验 FID
        fid_emp = frechet_distance(Xp.mean(0), covariance(Xp),
                                   Xq.mean(0), covariance(Xq))
        # 最优判别(log 密度比)
        inv_q = np.linalg.inv(cov_q)

        def logp_mix(X):
            return np.logaddexp(-0.5 * ((X - m) ** 2).sum(1),
                                -0.5 * ((X + m) ** 2).sum(1))

        def logq(X):
            return -0.5 * (X @ inv_q * X).sum(1)

        auc_bayes = _auc(logp_mix(Xp) - logq(Xp), logp_mix(Xq) - logq(Xq))
        # 1-NN 两样本检验
        acc_nn = _nn_two_sample(Xp, Xq)
        # 二次特征 / 线性特征的 ridge 分类器
        auc_quad = _ridge_auc(Xp, Xq, _quad_features, 8000, 1.0, rng)
        auc_lin = _ridge_auc(Xp, Xq, lambda X: X, 8000, 1.0, rng)

        rows.append((2 * a, float(fid_pop), float(fid_emp), auc_bayes,
                     acc_nn, auc_quad, auc_lin))
        if verbose:
            print(f"    {2 * a:>12} {fid_pop:>12.2e} {fid_emp:>10.4f} "
                  f"{auc_bayes:>9.4f} {acc_nn:>7.4f} {auc_quad:>8.4f} {auc_lin:>8.4f}")

    # 对照组:把 Q 的协方差整体放大 s 倍(破坏矩匹配),FID 与二次判别一起醒过来
    print()
    print("    对照组(2|m|=8):把 Q 的协方差整体放大 s 倍,破坏矩匹配")
    print(f"    {'s':>6} {'FID':>10} {'二次特征 ridge AUC':>20}")
    m = 8 * u
    sign = rng.integers(0, 2, size=n) * 2 - 1
    Xp = rng.standard_normal((n, d)) + sign[:, None] * m[None, :]
    cov_p = np.eye(d) + np.outer(m, m)
    ctrl = []
    for s in (1.0, 1.05, 1.2, 1.5, 2.0):
        cov_q_s = cov_p * s
        Xq_s = rng.multivariate_normal(np.zeros(d), cov_q_s, size=n)
        fid_s = frechet_distance(np.zeros(d), cov_p, np.zeros(d), cov_q_s)
        auc_s = _ridge_auc(Xp, Xq_s, _quad_features, 8000, 1.0, rng)
        ctrl.append((s, float(fid_s), auc_s))
        print(f"    {s:>6} {fid_s:>10.4f} {auc_s:>20.4f}")
    print("      -> 本例矩匹配时总体 FID 为 0,平方损失 ridge 的 AUC 近 0.5。")
    print("         这不代表任意二次分类器都无法区分两组分布。")
    print()
    return {"d": d, "n": n, "rows": rows, "control": ctrl}


# ──────────────────────────────────────────────────────────────
def main():
    which = sys.argv[1].upper() if len(sys.argv) > 1 else "ALL"
    res = {}
    if which in ("ALL", "A"):
        res["A"] = section_a()
    if which in ("ALL", "A2"):
        res["A2"] = section_a2()
    if which in ("ALL", "B"):
        res["B"] = section_b()
    if which in ("ALL", "C"):
        res["C"] = section_c()
    if which in ("ALL", "D"):
        res["D"] = section_d()
    if which == "ALL":
        with open(OUT_JSON, "w", encoding="utf-8") as f:
            json.dump(res, f, ensure_ascii=False, indent=1)
        print(f"结果已写入 {OUT_JSON}")


if __name__ == "__main__":
    main()

fid_core.py

"""
fid_core.py —— FID(Fréchet Inception Distance)的最小可用实现。

本机没有 torch,也没有 scipy(scipy.linalg.sqrtm 是官方实现的核心依赖),
所以这里的矩阵平方根全部用 numpy 的对称特征分解手算。
好处是每一步都看得见,也正好能把「手写实现最容易踩的那个坑」暴露出来。

运行:
    python fid_core.py
"""

from __future__ import annotations

import numpy as np


# ──────────────────────────────────────────────────────────────
# 1. 对称 PSD 矩阵的平方根
# ──────────────────────────────────────────────────────────────
def sqrtm_sym(C: np.ndarray, eps: float = 1e-6) -> np.ndarray:
    r"""对称半正定矩阵的平方根。

    对 C = V diag(w) V^T,有 C^{1/2} = V diag(sqrt(w)) V^T。
    eps 是相对谱尺度的负特征值容差,不是给正特征值设置下限。
    明显非半正定输入报错;容差内负值截到 0,保留真正的零特征值。
    """
    C = np.asarray(C, dtype=np.float64)
    if C.ndim != 2 or C.shape[0] != C.shape[1] or not np.isfinite(C).all():
        raise ValueError("C must be a finite square matrix")
    scale = max(np.linalg.norm(C, ord=np.inf), np.finfo(float).tiny)
    if not np.allclose(C, C.T, rtol=0.0, atol=eps * scale):
        raise ValueError("C must be symmetric")
    w, V = np.linalg.eigh((C + C.T) / 2)
    if w.min() < -eps * max(np.abs(w).max(), np.finfo(float).tiny):
        raise ValueError("C must be positive semidefinite")
    w = np.sqrt(np.clip(w, 0.0, None))
    return (V * w) @ V.T


def sqrtm_naive(A: np.ndarray, eps: float = 1e-6) -> np.ndarray:
    """「把 A 直接当对称矩阵开方」。

    np.linalg.eigh 只读矩阵的上/下三角并**假设输入对称**,
    默认 UPLO="L",以 A 的下三角及其镜像构造对称矩阵,
    并不等于 (A + A^T)/2。
    很多手写 FID 就是这么写的,而 A = Σ1 Σ2 恰恰不是对称矩阵。
    """
    w, V = np.linalg.eigh(A)
    w = np.sqrt(np.clip(w, eps, None))
    return (V * w) @ V.T


# ──────────────────────────────────────────────────────────────
# 2. Tr((Σ1 Σ2)^{1/2})
# ──────────────────────────────────────────────────────────────
def trace_sqrt_product(sigma1: np.ndarray, sigma2: np.ndarray,
                       eps: float = 1e-6) -> float:
    r"""计算 Tr((Σ1 Σ2)^{1/2}),走对称化路线。

    Σ1 正定时,Σ1 Σ2 与 Σ1^{1/2} Σ2 Σ1^{1/2} 相似:
        Σ1^{1/2} (Σ1^{1/2} Σ2 Σ1^{1/2}) Σ1^{-1/2} = Σ1 Σ2
    二者特征值相同,而后者是**对称半正定**的,可以安全用 eigh。
    主平方根与相似变换可交换,所以迹也相同:
        Tr((Σ1 Σ2)^{1/2}) = Σ_i sqrt(λ_i)
    """
    s1 = sqrtm_sym(sigma1, eps)
    M = s1 @ sigma2 @ s1
    M = 0.5 * (M + M.T)               # 强制对称,压掉浮点不对称
    w = np.linalg.eigvalsh(M)
    return float(np.sqrt(np.clip(w, 0.0, None)).sum())


def trace_sqrt_product_naive(sigma1: np.ndarray, sigma2: np.ndarray,
                             eps: float = 1e-6) -> float:
    """对照用的错误写法:直接对 Σ1 Σ2 调 eigh。"""
    return float(np.trace(sqrtm_naive(sigma1 @ sigma2, eps)))


def trace_sqrt_product_ref(sigma1: np.ndarray, sigma2: np.ndarray) -> float:
    """参考实现:用一般矩阵的特征值求解器 eigvals(不假设对称)。"""
    ev = np.linalg.eigvals(sigma1 @ sigma2)
    return float(np.sqrt(np.clip(ev.real, 0.0, None)).sum())


# ──────────────────────────────────────────────────────────────
# 3. FID 本体
# ──────────────────────────────────────────────────────────────
def frechet_distance(mu1: np.ndarray, sigma1: np.ndarray,
                     mu2: np.ndarray, sigma2: np.ndarray,
                     eps: float = 1e-6, mode: str = "sym") -> float:
    r"""两个多元高斯之间的 Fréchet 距离(= 2-Wasserstein 距离的平方)。

        FID = ||μ1 - μ2||^2 + Tr(Σ1) + Tr(Σ2) - 2 Tr((Σ1 Σ2)^{1/2})

    mode="sym"   用对称化路线(正确)
    mode="naive" 用 eigh(Σ1 Σ2)(错误,保留它只为对照)
    """
    diff = mu1 - mu2
    if mode == "sym":
        tr = trace_sqrt_product(sigma1, sigma2, eps)
    elif mode == "naive":
        tr = trace_sqrt_product_naive(sigma1, sigma2, eps)
    else:
        raise ValueError(f"unknown mode: {mode}")
    # 注意:这里**不做** max(val, 0)。浮点误差确实会让 FID(P,P) 变成 -1e-9 量级,
    # 但把负数夹成 0 会把真正的实现 bug(比如开方写错)一起藏掉——
    # 我自己第一版就是因为夹了 0,测试全绿而结果是错的。
    return float(diff @ diff + np.trace(sigma1) + np.trace(sigma2) - 2.0 * tr)


def covariance(X: np.ndarray, unbiased: bool = True) -> np.ndarray:
    r"""样本协方差。unbiased=True 用 1/(n-1)(np.cov 默认),
    False 用 1/n(高斯最大似然估计的常见约定)。"""
    X = np.asarray(X, dtype=np.float64)
    if X.ndim != 2 or not np.isfinite(X).all():
        raise ValueError("X must be a finite [n, d] array")
    n = X.shape[0]
    if n < (2 if unbiased else 1):
        raise ValueError("not enough samples for covariance")
    Xc = X - X.mean(axis=0, keepdims=True)
    denom = (n - 1) if unbiased else n
    return (Xc.T @ Xc) / denom


def fid_from_features(X1: np.ndarray, X2: np.ndarray,
                      unbiased: bool = True, eps: float = 1e-6,
                      mode: str = "sym") -> float:
    """直接从两组特征算 FID。X1: 真实 [n1, d],X2: 生成 [n2, d]。"""
    mu1, mu2 = X1.mean(axis=0), X2.mean(axis=0)
    sig1 = covariance(X1, unbiased=unbiased)
    sig2 = covariance(X2, unbiased=unbiased)
    return frechet_distance(mu1, sig1, mu2, sig2, eps=eps, mode=mode)


# ──────────────────────────────────────────────────────────────
# 4. 自检
# ──────────────────────────────────────────────────────────────
def _rand_psd(d: int, rng: np.random.Generator, k: int | None = None) -> np.ndarray:
    """随机对称正定矩阵:A A^T/k + 0.5 I;加单位阵后满秩。"""
    k = k or d
    A = rng.standard_normal((d, k))
    return A @ A.T / k + 0.5 * np.eye(d)


def self_test() -> None:
    rng = np.random.default_rng(20261001)

    print("=" * 68)
    print("[1] 恒等性:FID(P, P) 必须为 0")
    d = 32
    mu = rng.standard_normal(d)
    sig = _rand_psd(d, rng)
    identity = frechet_distance(mu, sig, mu, sig)
    assert abs(identity) < 1e-10 * np.trace(sig)
    print(f"    FID(P,P) = {identity:.3e} (按容差判断,不要求固定符号或尾数)")
    for scale in (1.0, 1e-12):
        singular = np.diag([scale, 0.0, 2 * scale])
        z = np.zeros(3)
        got = frechet_distance(z, singular, z, singular)
        assert abs(got) < 1e-10 * np.trace(singular)
    try:
        sqrtm_sym(np.diag([1.0, -0.1]))
    except ValueError:
        pass
    else:
        raise AssertionError("non-PSD input was accepted")
    print("    奇异/小尺度 PSD 恒等性、非 PSD 拒绝:通过")

    print()
    print("[2] 对称化路线 vs 错误写法 vs 一般特征值参考实现")
    print(f"    {'d':>6} {'sym(正确)':>16} {'naive(错误)':>16} {'eigvals(参考)':>16}")
    for d in (8, 32, 128):
        s1 = _rand_psd(d, rng)
        s2 = _rand_psd(d, rng)
        a = trace_sqrt_product(s1, s2)
        b = trace_sqrt_product_naive(s1, s2)
        c = trace_sqrt_product_ref(s1, s2)
        assert np.isclose(a, c, rtol=1e-9)
        print(f"    {d:>6} {a:>16.10f} {b:>16.10f} {c:>16.10f}")

    print()
    print("[3] 这个差异会传进 FID:同一对特征,两种写法差多少")
    d = 128
    n = 4096
    mu_a = rng.standard_normal(d) * 0.3
    sa = _rand_psd(d, rng)
    sb = sa + 0.05 * np.eye(d)
    xa = rng.multivariate_normal(mu_a, sa, size=n)
    xb = rng.multivariate_normal(-mu_a, sb, size=n)
    fa = fid_from_features(xa, xb, mode="sym")
    fb = fid_from_features(xa, xb, mode="naive")
    print(f"    FID(sym)   = {fa:.6f}")
    print(f"    FID(naive) = {fb:.6f}   (差值 {fb - fa:+.6f})")

    print()
    print("[4] 尺度不是不变的:特征整体乘 c,FID 变 c^2 倍")
    base = fid_from_features(xa, xb, mode="sym")
    for c in (0.5, 2.0, 4.0):
        got = fid_from_features(xa * c, xb * c, mode="sym")
        assert np.isclose(got, c * c * base, rtol=1e-9)
        print(f"    c={c:<4} FID={got:>12.6f}  期望 c^2*base={c * c * base:>12.6f}")

    print()
    print("[5] 有偏 vs 无偏协方差(n 越小差得越多)")
    d = 128
    truth_a = _rand_psd(d, rng)
    truth_b = truth_a + 0.08 * np.eye(d)
    print(f"    {'n':>7} {'unbiased':>13} {'biased':>13} {'差值':>12}")
    for n in (256, 1024, 8192):
        pa = rng.multivariate_normal(np.zeros(d), truth_a, size=n)
        pb = rng.multivariate_normal(np.zeros(d), truth_b, size=n)
        fu = fid_from_features(pa, pb, unbiased=True)
        fb2 = fid_from_features(pa, pb, unbiased=False)
        print(f"    {n:>7} {fu:>13.6f} {fb2:>13.6f} {fb2 - fu:>+12.6f}")

    print()
    print("=" * 68)


if __name__ == "__main__":
    self_test()

clip_alignment_lab.py

"""
clip_alignment_lab.py —— CLIP Score 与 FID 到底在给谁打高分。

本机没有 torch,跑不了真的 CLIP。这里搭的是一个**结构替身**:
    - 两个编码器把图像和文本投到同一个共享空间(真 CLIP 就是这么干的)
    - 打分用余弦相似度,并且照抄 CLIPScore 论文的定义
      CLIP-S = w * max(cos(image, text), 0),w = 2.5(arXiv:2104.08718)
    - 相似度用归一化向量,FID 用未归一化的原始特征(实际评测也是这么用的)

替身复现不了真 CLIP 的具体数值,但复现了它的**结构**。
下面两个结论都只依赖结构,不依赖具体权重:
    [A] CLIP Score 随生成多样性单调下降,FID 是 U 形 —— 两者的最优解不在一个地方
    [B] CLIP Score 只用一个均值,好坏样本可以互相平均掉

运行:
    python clip_alignment_lab.py
"""

from __future__ import annotations

import json
import os
import sys

import numpy as np

from fid_core import fid_from_features

HERE = os.path.dirname(os.path.abspath(__file__))
OUT_JSON = os.path.join(HERE, "_clip_results.json")

W = 2.5                 # CLIPScore 论文的缩放系数


# ──────────────────────────────────────────────────────────────
# 共享空间与两个(替身)编码器
# ──────────────────────────────────────────────────────────────
def build_concepts(K: int, ds: int, rng: np.random.Generator) -> np.ndarray:
    """K 个语义概念的类心,单位范数。ds >> K 时它们近似两两正交。"""
    C = rng.standard_normal((K, ds))
    C /= np.linalg.norm(C, axis=1, keepdims=True)
    return C


def encode_image(C: np.ndarray, idx: np.ndarray, sigma: float,
                 rng: np.random.Generator) -> np.ndarray:
    """「图像编码器」:类心 + 各向同性噪声。sigma 就是生成多样性。"""
    Z = rng.standard_normal((idx.shape[0], C.shape[1]))
    return C[idx] + sigma * Z


def encode_text(C: np.ndarray, idx: np.ndarray) -> np.ndarray:
    """「文本编码器」:prompt 直接就是类心本身。"""
    return C[idx]


def clip_score(images: np.ndarray, texts: np.ndarray, w: float = W) -> dict:
    """CLIP-S = w * max(cos(image, text), 0),逐样本取均值。"""
    a = images / np.linalg.norm(images, axis=1, keepdims=True)
    b = texts / np.linalg.norm(texts, axis=1, keepdims=True)
    cos = (a * b).sum(axis=1)
    return {
        "score": float(w * np.maximum(cos, 0.0).mean()),
        "cos_mean": float(cos.mean()),
        "cos_std": float(cos.std()),
        "cos_frac_gt_half": float((cos > 0.5).mean()),
        "cos": cos,
    }


# ──────────────────────────────────────────────────────────────
# [A] 多样性扫描:FID 与 CLIP Score 的最优解不在一起
# ──────────────────────────────────────────────────────────────
def section_a(K=24, ds=64, n=20000, verbose=True):
    print("=" * 76)
    print("[A] 生成多样性 sigma_g 扫描:FID 与 CLIP Score 分别给谁打高分")
    print(f"    真实数据 sigma_real = 0.5,K={K} 个概念,共享空间维度 ds={ds}")
    print("=" * 76)

    rng = np.random.default_rng(90210)
    C = build_concepts(K, ds, rng)

    idx = rng.integers(0, K, size=n)
    real_img = encode_image(C, idx, 0.5, rng)
    real_txt = encode_text(C, idx)
    ref = clip_score(real_img, real_txt)

    if verbose:
        print(f"    真实数据自己的 CLIP Score = {ref['score']:.4f} "
              f"(cos 均值 {ref['cos_mean']:.4f})")
        print()
        print(f"    {'sigma_g':>8} {'FID':>10} {'CLIPScore':>11} "
              f"{'cos均值':>9} {'cos标准差':>10}")

    rows = []
    for sg in (0.05, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.8, 1.0):
        idx2 = rng.integers(0, K, size=n)
        gen_img = encode_image(C, idx2, sg, rng)
        gen_txt = encode_text(C, idx2)
        fid = fid_from_features(real_img, gen_img)
        cs = clip_score(gen_img, gen_txt)
        rows.append((sg, float(fid), cs["score"], cs["cos_mean"], cs["cos_std"]))
        if verbose:
            mark = "  <- 真实值" if abs(sg - 0.5) < 1e-9 else ""
            print(f"    {sg:>8} {fid:>10.4f} {cs['score']:>11.4f} "
                  f"{cs['cos_mean']:>9.4f} {cs['cos_std']:>10.4f}{mark}")

    fids = [r[1] for r in rows]
    scores = [r[2] for r in rows]
    best_fid = rows[int(np.argmin(fids))][0]
    best_cs = rows[int(np.argmax(scores))][0]
    if verbose:
        print()
        print(f"    FID 最小时        sigma_g = {best_fid}")
        print(f"    CLIP Score 最大时 sigma_g = {best_cs}")
        print("    -> 在本合成实验中,FID 偏好的方差与 CLIPScore 不同;")
        print("       不能据此把真实模型中的多样性与文本对齐视为必然冲突。")
    print()
    return {"K": K, "ds": ds, "n": n, "rows": rows,
            "real_score": ref["score"], "best_fid_sigma": best_fid,
            "best_clip_sigma": best_cs}


# ──────────────────────────────────────────────────────────────
# [B] 同一个均值,完全不同的现实
# ──────────────────────────────────────────────────────────────
def _score_for_sigma(C, idx, sigma) -> float:
    """用固定种子探一次,避免二分过程本身消耗主随机流。"""
    probe = np.random.default_rng(4242)
    img = encode_image(C, idx, sigma, probe)
    return clip_score(img, encode_text(C, idx))["score"]


def section_b(K=24, ds=64, n=20000, verbose=True):
    print("=" * 76)
    print("[B] 汇总 CLIP Score 是均值:好样本和坏样本可以互相平均掉")
    print("    模型 M1:p 的概率输出完美匹配,1-p 的概率输出纯噪声")
    print("    模型 M2:各样本相似度较集中(调 sigma 让截断后的 CLIPScore 与 M1 相同)")
    print("    两者的 CLIP Score 一样,现实完全不一样。")
    print("=" * 76)

    rng = np.random.default_rng(1357)
    C = build_concepts(K, ds, rng)
    idx = rng.integers(0, K, size=n)
    real_img = encode_image(C, idx, 0.5, rng)

    if verbose:
        print(f"    {'p':>6} {'M1 CLIP':>9} {'M1 cos标准差':>13} {'M1 好图占比':>12} "
              f"{'M1 FID':>10} | {'M2 sigma':>9} {'M2 CLIP':>9} {'M2 cos标准差':>13} "
              f"{'M2 好图占比':>12} {'M2 FID':>10}")

    rows = []
    for p in (0.3, 0.5, 0.7, 0.9):
        # M1: 混合
        good = rng.random(n) < p
        img1 = np.where(good[:, None], C[idx], 0.0)          # 完美命中类心
        noise = rng.standard_normal((n, ds))
        noise /= np.linalg.norm(noise, axis=1, keepdims=True)
        img1 = img1 + np.where(good[:, None], 0.0, noise)    # 否则是随机方向
        cs1 = clip_score(img1, encode_text(C, idx))
        fid1 = fid_from_features(real_img, img1)

        # M2: 二分法找 sigma,使 CLIP Score 与 M1 对齐
        # 注意要对齐的是 score(含 max(cos, 0) 截断),不是裸的 cos 均值
        target = cs1["score"]
        lo, hi = 1e-3, 50.0
        for _ in range(60):
            mid = 0.5 * (lo + hi)
            if _score_for_sigma(C, idx, mid) > target:
                lo = mid
            else:
                hi = mid
        sg2 = 0.5 * (lo + hi)
        img2 = encode_image(C, idx, sg2, rng)
        cs2 = clip_score(img2, encode_text(C, idx))
        fid2 = fid_from_features(real_img, img2)

        rows.append({"p": p, "m1_clip": cs1["score"], "m1_std": cs1["cos_std"],
                     "m1_good": cs1["cos_frac_gt_half"], "m1_fid": float(fid1),
                     "m2_sigma": float(sg2), "m2_clip": cs2["score"],
                     "m2_std": cs2["cos_std"], "m2_good": cs2["cos_frac_gt_half"],
                     "m2_fid": float(fid2)})
        bins = np.linspace(-1.0, 1.0, 51)
        rows[-1].update(hist_bins=bins.tolist(),
                        hist1=np.histogram(np.clip(cs1["cos"], -1, 1), bins)[0].tolist(),
                        hist2=np.histogram(np.clip(cs2["cos"], -1, 1), bins)[0].tolist(),
                        m1_cos_mean=cs1["cos_mean"], m2_cos_mean=cs2["cos_mean"])
        if verbose:
            print(f"    {p:>6} {cs1['score']:>9.4f} {cs1['cos_std']:>13.4f} "
                  f"{cs1['cos_frac_gt_half']:>12.4f} {fid1:>10.3f} | "
                  f"{sg2:>9.3f} {cs2['score']:>9.4f} {cs2['cos_std']:>13.4f} "
                  f"{cs2['cos_frac_gt_half']:>12.4f} {fid2:>10.3f}")

    if verbose:
        print()
        d_clip = max(abs(r["m1_clip"] - r["m2_clip"]) for r in rows)
        d_std = max(r["m1_std"] / max(r["m2_std"], 1e-9) for r in rows)
        print(f"    CLIP Score 最大差距 = {d_clip:.4f}  (按构造两者应当同分)")
        print(f"    逐样本相似度标准差的比值最大 = {d_std:.2f} 倍")
        print("    -> 同一个 CLIP Score 背后,可以是「p 的图完美、其余完全不沾边」,")
        print("       也可以是「各样本有相近的相似度」。汇总均值看不见这个区别,逐样本分数分布可以。")
    print()
    return {"K": K, "ds": ds, "n": n, "rows": rows}


def main():
    which = sys.argv[1].upper() if len(sys.argv) > 1 else "ALL"
    res = {}
    if which in ("ALL", "A"):
        res["A"] = section_a()
    if which in ("ALL", "B"):
        res["B"] = section_b()
    if which == "ALL":
        with open(OUT_JSON, "w", encoding="utf-8") as f:
            json.dump(res, f, ensure_ascii=False, indent=1)
        print(f"结果已写入 {OUT_JSON}")


if __name__ == "__main__":
    main()

make_figures.py

"""
make_figures.py —— 画本文的配图。

数据来源都是已经跑完的实验(_fid_bias_results.json / _clip_results.json),
不在这里重新算,避免图上的数字和正文漂移。

运行:
    python make_figures.py
"""

from __future__ import annotations

import json
import os

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np

HERE = os.path.dirname(os.path.abspath(__file__))
FIGDIR = os.path.join(HERE, "..", "figures")
os.makedirs(FIGDIR, exist_ok=True)

# 配色(正文里写「这张图要看什么」时按这六个名字来描述)
C_MAIN = "#1f4e79"     # 深蓝:主曲线
C_ALT = "#c1440e"      # 橙红:对照曲线
C_GREEN = "#2e7d32"    # 绿:第三组
C_PURPLE = "#7b1fa2"   # 紫:标注线
C_GRAY = "#8a8a8a"     # 灰:参考线
C_LIGHT = "#bcd7ee"    # 浅蓝:填充

plt.rcParams["font.sans-serif"] = ["PingFang SC", "Arial Unicode MS", "DejaVu Sans"]
plt.rcParams["axes.unicode_minus"] = False
plt.rcParams["figure.dpi"] = 130
plt.rcParams["savefig.dpi"] = 130


def _load(name):
    p = os.path.join(HERE, name)
    if not os.path.exists(p):
        return None
    with open(p, encoding="utf-8") as f:
        return json.load(f)


# ──────────────────────────────────────────────────────────────
# 图 1:FID 的样本量偏差
# ──────────────────────────────────────────────────────────────
def fig_bias(res):
    rows512 = res["A"]["by_d"]["512"]
    rows2048 = res["A"]["by_d"]["2048"]
    rows_b = res["B"]["rows"]

    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12.4, 4.7))

    # ── 左:偏差 vs n ──
    n5 = [r[0] for r in rows512]
    f5 = [r[1] for r in rows512]
    n2 = [r[0] for r in rows2048]
    f2 = [r[1] for r in rows2048]

    ax1.loglog(n5, f5, "o-", color=C_MAIN, lw=2, ms=5, label=r"$d=512$")
    ax1.loglog(n2, f2, "s-", color=C_ALT, lw=2, ms=5, label=r"$d=2048$")
    # 参考斜率 1/n
    ref_n = np.array([n2[3], n2[-1]], dtype=float)
    ref_y = f2[-1] * (ref_n / n2[-1]) ** (-1.0)
    ax1.loglog(ref_n, ref_y, "--", color=C_GRAY, lw=1.6,
               label=r"$\mathrm{slope}=-1$")
    ax1.axvline(2048, color=C_PURPLE, ls=":", lw=1.6)
    ax1.annotate(r"$n=d=2048$", xy=(2048, 1.0), xytext=(2600, 1.6),
                 color=C_PURPLE, fontsize=10)
    ax1.annotate(r"$n=50000$ 时仍有 $14.21$", xy=(n2[-1], f2[-1]),
                 xytext=(9000, 30), color=C_ALT, fontsize=10.5,
                 arrowprops=dict(arrowstyle="->", color=C_ALT, lw=1.2))
    ax1.set_xlabel("样本量 $n$(两组各 $n$ 张)", fontsize=11)
    ax1.set_ylabel(r"$\mathrm{FID}$(真值 $0$)", fontsize=11)
    ax1.set_title("偏差随样本量衰减:$1/n$", fontsize=12, pad=8)
    ax1.legend(loc="upper right", fontsize=10, framealpha=0.95)
    ax1.grid(True, which="both", alpha=0.25)

    # ── 右:偏差 vs d ──
    dd = [r[0] for r in rows_b]
    bb = [r[1] for r in rows_b]
    ax2.loglog(dd, bb, "o-", color=C_MAIN, lw=2, ms=6)
    lo, hi = np.log(dd[0]), np.log(dd[-1])
    slope = (np.log(bb[-1]) - np.log(bb[0])) / (hi - lo)
    ax2.annotate(r"$\mathrm{slope}\approx %.2f$" % slope,
                 xy=(dd[2], bb[2]), xytext=(90, 20), fontsize=11.5,
                 color=C_PURPLE,
                 arrowprops=dict(arrowstyle="->", color=C_PURPLE, lw=1.2))
    ax2.scatter([2048], [bb[-1]], s=90, facecolors="none",
                edgecolors=C_ALT, lw=2, zorder=5)
    ax2.annotate(r"$d=2048$ 时 $71.14$", xy=(2048, bb[-1]),
                 xytext=(600, 40), color=C_ALT, fontsize=10.5,
                 arrowprops=dict(arrowstyle="->", color=C_ALT, lw=1.2))
    ax2.set_xlabel("特征维度 $d$", fontsize=11)
    ax2.set_ylabel(r"$\mathrm{FID}$(真值 $0$)", fontsize=11)
    ax2.set_title(r"固定 $n=10000$,偏差随维度暴涨", fontsize=12, pad=8)
    ax2.grid(True, which="both", alpha=0.25)

    fig.suptitle("图 1:人工高斯同分布,有限样本估计仍有偏差", fontsize=13.5, y=1.0)
    fig.tight_layout()
    out = os.path.join(FIGDIR, "fig_fid_bias.png")
    fig.savefig(out, bbox_inches="tight", facecolor="white")
    plt.close(fig)
    print("wrote", out, f"slope={slope:.3f}")


# ──────────────────────────────────────────────────────────────
# 图 2:前两阶矩一样、分布完全不同
# ──────────────────────────────────────────────────────────────
def fig_moment_blind(res):
    rows = res["D"]["rows"]
    sep = [r[0] for r in rows]
    fpop = [abs(r[1]) for r in rows]
    femp = [r[2] for r in rows]
    aucb = [r[3] for r in rows]
    nn = [r[4] for r in rows]

    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12.4, 4.7))

    # ── 左:二维示意 ──
    rng = np.random.default_rng(20261001)
    a = 4.2          # 二维示意里把模式拉开一点,让「两个团」一眼可见
    n = 1400
    sgn = rng.integers(0, 2, size=n) * 2 - 1
    P = rng.standard_normal((n, 2)) + np.stack([sgn * a, np.zeros(n)], axis=1)
    covq = np.eye(2) + np.array([[a * a, 0.0], [0.0, 0.0]])
    Q = rng.multivariate_normal(np.zeros(2), covq, size=n)
    ax1.scatter(P[:, 0], P[:, 1], s=9, alpha=0.5, color=C_MAIN,
                label="真实分布 $P$(两个模式)")
    ax1.scatter(Q[:, 0], Q[:, 1], s=9, alpha=0.5, color=C_ALT,
                label="生成分布 $Q$(糊成一团)")
    # 画 Q 的 1 个标准差椭圆
    w, V = np.linalg.eigh(covq)
    ang = np.degrees(np.arctan2(V[1, -1], V[0, -1]))
    from matplotlib.patches import Ellipse
    for k, col in ((1, C_ALT), (2, C_ALT)):
        e = Ellipse((0, 0), 2 * k * np.sqrt(w[0]), 2 * k * np.sqrt(w[1]),
                    angle=ang, fill=False, ls="--", lw=1.4,
                    edgecolor=col, alpha=0.75)
        ax1.add_patch(e)
    ax1.set_xlim(-9, 9)
    ax1.set_ylim(-4.2, 4.2)
    ax1.set_aspect("equal", adjustable="box")
    ax1.set_xlabel(r"$x_1$", fontsize=11)
    ax1.set_ylabel(r"$x_2$", fontsize=11)
    ax1.set_title(r"$\mathrm{FID}=0$,但一眼就能看出不是一回事", fontsize=12, pad=8)
    ax1.legend(loc="upper left", fontsize=10, framealpha=0.95)
    ax1.grid(True, alpha=0.25)

    # ── 右:FID 与可区分度 ──
    ax2.plot(sep, femp, "o-", color=C_MAIN, lw=2.2, ms=6,
             label=r"$\mathrm{FID}$(左边刻度)")
    ax2.set_yscale("log")
    ax2.set_ylim(1e-3, 1e1)
    ax2.axhline(0.5, color=C_GRAY, ls=":", lw=1.2)
    ax2.set_xlabel("模式间距 $2|m|$", fontsize=11)
    ax2.set_ylabel(r"$\mathrm{FID}$(对数刻度,真值严格为 $0$)", fontsize=11,
                   color=C_MAIN)
    ax2.tick_params(axis="y", labelcolor=C_MAIN)

    ax3 = ax2.twinx()
    ax3.plot(sep, aucb, "s--", color=C_ALT, lw=2.2, ms=6,
             label=r"$\mathrm{AUC}$(最优判别)")
    ax3.plot(sep, nn, "^--", color=C_GREEN, lw=2.2, ms=6,
             label=r"$1$-$\mathrm{NN}$ 两样本准确率")
    ax3.axhline(0.5, color=C_GRAY, ls="-", lw=1.0)
    ax3.set_ylim(0.45, 1.0)
    ax3.set_ylabel(r"$\mathrm{AUC}$ / $1$-$\mathrm{NN}$(右边刻度)", fontsize=11)
    ax3.text(sep[-1], 0.47, r"$0.5=$ 随机猜测", color=C_GRAY, fontsize=9.5,
             ha="right")

    h1, l1 = ax2.get_legend_handles_labels()
    h2, l2 = ax3.get_legend_handles_labels()
    ax2.legend(h1 + h2, l1 + l2, loc="center left", fontsize=9.5,
               framealpha=0.95)
    ax2.set_title("模式越分开,FID 越是一动不动,判别器越看得清",
                  fontsize=12, pad=8)

    fig.suptitle("图 2:FID 只匹配前两阶矩,形状对不对它不管", fontsize=13.5, y=1.0)
    fig.tight_layout()
    out = os.path.join(FIGDIR, "fig_moment_blind.png")
    fig.savefig(out, bbox_inches="tight", facecolor="white")
    plt.close(fig)
    print("wrote", out)


# ──────────────────────────────────────────────────────────────
# 图 3:FID 与 CLIP Score 的最优解不在一起
# ──────────────────────────────────────────────────────────────
def fig_clip_vs_fid(cres):
    rows = cres["A"]["rows"]
    sg = [r[0] for r in rows]
    fid = [r[1] for r in rows]
    cs = [r[2] for r in rows]

    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12.4, 4.7))

    # ── 左:sigma 扫描 ──
    ax1.plot(sg, fid, "o-", color=C_MAIN, lw=2.4, ms=6)
    ax1.set_xlabel(r"生成多样性 $\sigma_g$", fontsize=11)
    ax1.set_ylabel("FID(越低越好)", fontsize=11, color=C_MAIN)
    ax1.tick_params(axis="y", labelcolor=C_MAIN)
    ax1.set_ylim(-1.0, 19.5)
    imin = int(np.argmin(fid))
    ax1.scatter([sg[imin]], [fid[imin]], s=170, facecolors="none",
                edgecolors=C_MAIN, lw=2.2, zorder=5)
    ax1.annotate(r"FID 最小,$\sigma_g=%.2f$" % sg[imin],
                 xy=(sg[imin], fid[imin]), xytext=(0.58, 9.6),
                 color=C_MAIN, fontsize=10.5,
                 arrowprops=dict(arrowstyle="->", color=C_MAIN, lw=1.2))

    ax3 = ax1.twinx()
    ax3.plot(sg, cs, "s--", color=C_ALT, lw=2.4, ms=6)
    ax3.set_ylabel("CLIP Score(越高越好)", fontsize=11, color=C_ALT)
    ax3.tick_params(axis="y", labelcolor=C_ALT)
    ax3.set_ylim(0.15, 2.85)
    imax = int(np.argmax(cs))
    ax3.scatter([sg[imax]], [cs[imax]], s=170, facecolors="none",
                edgecolors=C_ALT, lw=2.2, zorder=5)
    ax3.annotate(r"CLIP Score 最大,$\sigma_g=%.2f$" % sg[imax],
                 xy=(sg[imax], cs[imax]), xytext=(0.21, 1.85),
                 color=C_ALT, fontsize=10.5,
                 arrowprops=dict(arrowstyle="->", color=C_ALT, lw=1.2))

    ax1.axvline(0.5, color=C_GRAY, ls=":", lw=1.4)
    ax1.text(0.31, 2.4, r"$\sigma_{\mathrm{real}}=0.5$", color=C_GRAY,
             fontsize=10)
    ax1.set_title("合成共享空间:两个目标的最优点不同", fontsize=12, pad=8)
    ax1.grid(True, alpha=0.22)

    # ── 右:同一个 CLIP Score 的两种现实 ──
    brows = cres["B"]["rows"]
    target = [r for r in brows if abs(r["p"] - 0.5) < 1e-9][0]
    # 直接读取实验的真实 cos 直方图;不重新捏造近似分布。
    bins = np.asarray(target["hist_bins"])
    ax2.stairs(target["hist1"], bins, fill=True, alpha=0.72, color=C_ALT,
               label=r"$M_1$:半数精确对齐,半数随机方向")
    ax2.stairs(target["hist2"], bins, fill=True, alpha=0.72, color=C_MAIN,
               label=r"$M_2$:相似度较集中")
    for key, color in [("m1_cos_mean", C_ALT), ("m2_cos_mean", C_MAIN)]:
        ax2.axvline(target[key], color=color, ls="--", lw=1.3)
    ax2.text(0.03, 0.74, "虚线为各自原始 cos 均值\n分数含截断,等分不等于 cos 均值相同",
             transform=ax2.transAxes, color="#444444", fontsize=8.5)
    ax2.set_xlabel(r"单张样本的相似度 $\cos(f_{\mathrm{img}}, f_{\mathrm{txt}})$", fontsize=11)
    ax2.set_ylabel(r"$\mathrm{count}$", fontsize=11)
    ax2.set_title(r"$M_1$ 标准差 $%.2f$,$M_2$ 标准差 $%.2f$,近似同分"
                  % (target["m1_std"], target["m2_std"]), fontsize=12, pad=8)
    ax2.legend(loc="upper center", fontsize=9.5, framealpha=0.95)
    ax2.grid(True, alpha=0.22)

    fig.suptitle("图 3:合成替身实验,不是真实 CLIP 或 Inception 测评",
                 fontsize=13.5, y=1.0)
    fig.tight_layout()
    out = os.path.join(FIGDIR, "fig_clip_vs_fid.png")
    fig.savefig(out, bbox_inches="tight", facecolor="white")
    plt.close(fig)
    print("wrote", out)


# ──────────────────────────────────────────────────────────────
def main():
    res = _load("_fid_bias_results.json")
    cres = _load("_clip_results.json")
    made = []
    if res:
        fig_bias(res)
        fig_moment_blind(res)
        made += ["fig_fid_bias.png", "fig_moment_blind.png"]
    if cres:
        fig_clip_vs_fid(cres)
        made.append("fig_clip_vs_fid.png")
    print("figures:", made)


if __name__ == "__main__":
    main()
0

评论 (0)

取消
粤ICP备2021042327号