AIGC 基本功|分类器无关引导 CFG 的代价与调法-CFG

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

分类器无关引导 CFG 的代价与调法

所属方向:生成范式 | 难度:进阶 | 前置知识:DDPM 训练目标与采样流程($\varepsilon$ 预测器、$\bar\alpha_t$ 与 DDIM 那一步怎么走都在那篇推过,这里直接用结论)
关键词:CFG、guidance scale、噪声预测外推、倾斜分布、guidance interval、guidance rescale、autoguidance


01. 为什么需要它

我第一次认真算 CFG 的账,是被一句「CFG 不增加参数,所以是免费的」气到的。它不增加参数,但它让每一步的网络前向从一次变成两次——这个 2× 是定义级的,不是工程上的估计。后面会把账算到 MAC 和显存字节上,先看它到底换来了什么。

我搭了一个二维高斯混合的玩具:4 个高斯分量,2 个类别,两类故意重叠(类心只差 1.3,标准差 0.6~1.0),每个类里一个紧分量(方差 0.36)一个松分量(方差 1.00)。选它的理由是:高斯混合的 MMSE 去噪器 $E[x_0|x_t]$ 有闭式解,条件分支和无条件分支都是精确的。也就是说,这个玩具里没有任何训练误差,可把训练误差排除,但仍要控制有限步采样、有限样本与数值积分误差。

然后我故意换上一个弱去噪器——把整簇拟合成一个高斯的线性维纳滤波。它有误差,而且误差随 $t$ 变,这才有资格代表真实 UNet。结果是这样的:

$w$ 生成方差 / 真实条件方差 软纯度
1.0 0.5847 0.7379
2.0 0.4012 0.8787
3.0 0.2751 0.9312
5.0 0.1290 0.9553
7.5 0.0499 0.9648
15.0 0.0028 0.9696

$w$ 从 1 拉到 15,纯度只涨了 0.2317,多样性塌到真实值的 0.28%——画面上就是所有样本缩成一个点。这就是真实世界里「CFG 调过了就一片死板、颜色发焦」的原型。注意 $w=1$ 那一行的方差比是 0.5847 而不是 1:弱去噪器本身就在过平滑,CFG 不是来修它的,CFG 是在过平滑的基础上再往目标类上推。

把弱去噪器换回精确去噪器,故事变了,但没变好:

$w$ 软纯度 硬纯度 多样性比 典型度 (nats) 类心偏移
真实条件样本(参照) 0.7388 0.7834 1.0000 +0.0000 0.0000
0.0 0.5001 0.5008 1.2512 +0.0081 0.6651
1.0 0.7409 0.7852 0.9876 +0.0030 0.0075
2.0 0.8770 0.9545 0.8596 −0.1995 0.4546
3.0 0.9245 0.9969 0.8193 −0.4974 0.7721
5.0 0.9503 1.0000 0.8042 −1.2480 1.2615
7.5 0.9636 1.0000 0.7933 −2.3042 1.7809
10.0 0.9744 1.0000 0.7603 −3.3963 2.2545
15.0 0.9905 1.0000 0.6716 −5.5530 3.0681
25.0 0.9985 1.0000 0.6033 −9.7613 4.2532

先看第一行和 $w=1$ 那一行:它们几乎完全一样(软纯度 0.7388 对 0.7409,典型度 +0.0000 对 +0.0030,类心偏移 0.0000 对 0.0075)。在去噪器精确的前提下,$w=1$ 采出来的就是真实条件分布 $p(x|c)$——这是对的,因为无条件分支和条件分支都是闭式解,没有误差可修。

那么 $w>1$ 在买什么?看 $w=7.5$:软纯度从 0.7409 涨到 0.9636,但典型度掉到 −2.3042 nats,类心偏移 1.78。典型度是「生成样本的平均 $\log p_{\text{data}}$」减去「真实样本的平均 $\log p_{\text{data}}$」,负值意味着生成样本平均落在较低的数据密度区;高斯混合的支撑是整个空间,不能称为“支撑之外”。也就是说:CFG 不是在把分布修得更准,它是在换一个目标分布,而且换过去的那一边偏离高密度数据区域。$w=25$ 时典型度 −9.7613、类心偏移 4.25,画面上就是过饱和、结构崩坏。

这一篇要讲的就是这个交易:用两倍算力,买一个明确的偏离。讲清楚代价的构成(算力、误差放大、幅度膨胀),才能讲清楚三个可调旋钮(强度 $w$、引导区间、rescale)分别在动哪一根杠杆。

02. 最小可用理解

三句话:

  1. 机制:每一步跑两遍网络,一次带条件 $c$、一次带空条件,然后把无条件预测沿「条件减无条件」的方向外推 $w$ 倍:$e_{\text{guided}}=e_{\text{un}}+w(e_{\text{c}}-e_{\text{un}})$。
  2. 成本:常规双分支实现的等价样本级网络计算约翻倍;本文卷积账本在 batch 维翻倍、权重共享的口径下,MAC 与逐层张量字节和都翻倍——$1.898\times10^{11}\to3.796\times10^{11}$ MAC、$0.13\ \text{GB}\to0.25\ \text{GB}$,比值都是 2.0000。只要 $w>1$ 就是这个价,跟 $w$ 是 2 还是 30 无关。
  3. 效果:每个噪声时刻的组合 score 可形式上写成倾斜密度的 score,但通常不能保证最终输出服从 $p(x|c)^{w}p(x)^{1-w}$。$w$ 越大越像目标类、越不像真实数据;$w=1$ 回到普通条件采样,$w=0$ 回到无条件采样。

这张图要看什么:左轴两条实线是软纯度(蓝,$E[p(c|x)]$)和多样性比(绿,生成方差 / 真实条件方差),右轴两条虚线是典型度(红)和类心偏移(紫)。四条线在 $w\approx2\sim3$ 附近同时拐弯:纯度在那之前涨得最快(0.7409→0.9245),之后收益迅速变平(0.9245→0.9985 用了 22 个 $w$);而典型度和类心偏移是没有平台期的,一路线性往下掉。灰色水平点线是真实条件样本自己的软纯度 0.7388——$w>1$ 的曲线整体在它上方,这就是「买纯度」的字面意思。竖线两条:灰色实线是 $w=1$($0<w<1$ 时在两分支之间插值,$w<0$ 才沿反方向外推),红色点划线是 $w=7.5$(SD 系默认值)。所以「$w=7.5$ 是常用默认值」这句话的实质是:把后面那段边际收益极低、但代价线性增长的区间也一并买了。

03. 数学推导

3.1 符号与定义

DDPM 的前向过程写成 $x_t=\sqrt{\bar\alpha_t}x_0+\sqrt{1-\bar\alpha_t}\,\varepsilon$,网络学的是 $\varepsilon_\theta(x_t,t,c)\approx\varepsilon$。记

$$e_{\text{c}}=\varepsilon_\theta(x_t,t,c),\qquad e_{\text{un}}=\varepsilon_\theta(x_t,t,\varnothing)$$

其中 $\varnothing$ 是空条件(训练时按一定概率丢弃条件,让同一个权重同时充当两个分支)。CFG 的输出是

$$e_{\text{guided}}=e_{\text{un}}+w\,(e_{\text{c}}-e_{\text{un}})=w\,e_{\text{c}}+(1-w)\,e_{\text{un}}$$

这个改写值得停一下:它是两支预测的仿射组合,系数和为 1,但其中一个系数为负。$w=1$ 时退化成纯条件分支,$w=0$ 时退化成纯无条件分支,$w>1$ 时无条件分支的系数 $1-w<0$,也就是把「条件相对无条件的优势」往远处推。

常用共享权重实现需要用条件丢弃训练空条件分支;定义上也可以分别训练条件和无条件模型,不是必须共享权重。做法是训练时按固定概率把条件替换成空条件(文生图里常用 10%~20%,具体比例随模型配置变化,不能把 10% 当成全系列统一默认),损失函数完全不变,只是输入换了一个占位 embedding。所以 CFG 不需要额外参数、不需要第二个网络,代价全部发生在推理侧——这一点是理解全文的钥匙:它把成本从「训练时一次」挪到了「推理时每次」。也因此,CFG 的强度是采样时才决定的超参,同一个 checkpoint 可以在 $w=3$ 和 $w=15$ 之间随便切。

3.2 它和「倾斜分布」是什么关系

用 score 的语言会更清楚。$\varepsilon$ 预测与 score 只差一个缩放:$\varepsilon_\theta=-\sqrt{1-\bar\alpha_t}\,\nabla_{x_t}\log p_\theta(x_t|c)$。把这个关系代进 3.1 的式子,$\sqrt{1-\bar\alpha_t}$ 整体提出来抵消,得到

$$\tilde{s}(x_t|c)=\nabla\log p(x_t|\varnothing)+w\big(\nabla\log p(x_t|c)-\nabla\log p(x_t|\varnothing)\big)=(1-w)\nabla\log p(x_t)+w\,\nabla\log p(x_t|c)$$

而一个倾斜分布 $\tilde p(x|c)\propto p(x|c)^{w}p(x)^{1-w}$ 的 score 恰好就是

$$\nabla\log\tilde p(x|c)=w\,\nabla\log p(x|c)+(1-w)\,\nabla\log p(x)$$

两式逐字相同。所以教科书里那句「CFG 采样自 $p(x|c)^w p(x)^{1-w}$」,在 score 层面是恒等的。

问题出在下一步:这个恒等式只保证每一步的 score 是对的,不保证采样出来的分布是 $\tilde p$。要让整条链落在 $\tilde p$ 上,需要每一步的边际 $p(x_t|c)$ 也被同样地倾斜,而这一点并不由上式推出——真实链上的 $x_t$ 来自上一步的输出,分布已经不是 $\tilde p$ 的边际了。$w$ 越大,这个偏差越大。第 06 节会用交叉熵把它量化:在 $w=7.5$ 时,CFG 采出的分布相对倾斜目标的交叉熵是 3.6926,而倾斜目标自己对自己的交叉熵是 2.2258,差了 1.47 nats——不是小偏差,是两个不同的分布。

几何上看得更直接:

这张图要看什么:三张等高线在同一个坐标系(网格 $[-4.2,4.2]^2$)下并排——左是真实条件分布 $p(x|c)$,中是倾斜目标 $p(x|c)^{7.5}p(x)^{-6.5}$,右是 CFG 实际采出的分布。三个面板里的灰色细线是同一组无条件数据密度等值线,用它当标尺。黑色加号是 4 个高斯分量的类心,蓝色圆点是真实条件均值,红色菱形是生成分布均值,箭头从前者指到后者(长度 1.78,而数据每维标准差约 0.91——偏了将近两个标准差)。要看的是中图和右图的差别:倾斜目标仍然贴着数据密度的等高线走,只是把权重在已有支撑上重新分配;而 CFG 采出的分布已经更多质量被推向低密度区域。这就是「偏离高密度数据区域」的几何版本,也是「过曝」这两个字的字面意思。

3.3 误差放大:为什么最坏上界是 $2w-1$

设真实的条件/无条件预测为 $e^\star_{\text{c}}$ 与 $e^\star_{\text{un}}$,模型误差为 $\delta_{\text{c}}=e_{\text{c}}-e^\star_{\text{c}}$、$\delta_{\text{un}}=e_{\text{un}}-e^\star_{\text{un}}$。引导后预测相对「同样加权过的真值」$w e^\star_{\text{c}}+(1-w)e^\star_{\text{un}}$ 的误差是

$$\delta_{\text{guided}}=w\,\delta_{\text{c}}+(1-w)\,\delta_{\text{un}}$$

取范数并放缩:

$$\|\delta_{\text{guided}}\|\le w\|\delta_{\text{c}}\|+(w-1)\|\delta_{\text{un}}\|\le(2w-1)\max\big(\|\delta_{\text{c}}\|,\|\delta_{\text{un}}\|\big)$$

注意这里用的是 $|1-w|=w-1$,不是 $1-w$。系数 $1-w$ 是负的这件事,正是代价的来源:三角不等式给出最坏上界;负系数既可能使误差叠加,也可能使相关误差抵消。$w=7.5$ 时上界是 14 倍,$w=15$ 时是 29 倍。实测远小于这个界(第 06 节给数字),因为两支误差高度相关,但本 toy 的这些探测点上呈单调放大,但不是对任意两支误差都成立的定理。

3.4 幅度膨胀:过曝的机制

即使两支都精确,引导后的预测幅度也会膨胀。看 $x_0$ 的重建:$x_0=(x_t-\sqrt{1-\bar\alpha_t}\,e_{\text{guided}})/\sqrt{\bar\alpha_t}$,它是 $e_{\text{guided}}$ 的仿射函数。$e_{\text{guided}}$ 的幅度一大,重建的 $x_0$ 就被推到远离数据中心的位置——这就是类心偏移的来源,也是画面上「过曝」的直接机制。实测两支预测的逐元素相关系数 $\rho=0.9928$(高度相关),所以膨胀不是来自两支的水平差异,而是来自差值方向 $e_{\text{c}}-e_{\text{un}}$ 被乘了 $w$ 之后叠加在一个本来就很大的共同分量上。第 06 节会给出 $\text{std}(e_{\text{guided}})/\text{std}(e_{\text{c}})$ 随 $w$ 的曲线。

04. 代码实现

核心是「精确去噪器 + DDIM」。高斯混合下 $E[x_0|x_t]$ 有闭式解:分量内部是高斯的,所以

$$E[x_0|x_t,k]=\mu_k+\frac{\bar\alpha_t^{1/2}\,\sigma_k^2}{\bar\alpha_t\sigma_k^2+(1-\bar\alpha_t)}\big(x_t-\bar\alpha_t^{1/2}\mu_k\big)$$

再按分量后验 $r_k=p(k|x_t)$ 加权。代码就是这一行公式:

def x0_hat(X, a_bar, logw):
    s = np.sqrt(a_bar)
    v = a_bar * VAR + (1.0 - a_bar)
    gain = VAR * s / v
    r = posterior(X, a_bar, logw)
    per = MU[None, :, :] + gain[None, :, None] * (X[:, None, :] - s * MU[None, :, :])
    return (r[:, :, None] * per).sum(axis=1)

条件分支和无条件分支共用这个函数,只换先验权重:无条件用全混合的分量先验 $\pi_k$,条件用「只保留目标类、类内重新归一化」的先验。这是 CFG 训练方式的最小抽象——不是两个网络,是同一个网络喂两个条件。

这里有个实现上的坑值得单独说:类内重新归一化之后,类外分量的先验变成 0,$\log 0$ 会直接炸。所以所有先验都要先过一遍 safe_log,把非正的权重映到 $-\infty$ 而不是让 numpy 抛 RuntimeWarning。这个坑在写倾斜分布那段还会再咬一次——$\log p_{\text{tilde}}$ 的归一化常数要在网格上做数值积分,网格范围取窄了(比如只取 $\pm3.6$)会把分布的尾巴切掉,归一化常数偏小,后面所有交叉熵都跟着错。本文用的是 $\pm9$ 的 420 点网格。

$\varepsilon$ 预测和 DDIM 的一步:

def eps_hat(X, t, logw):
    a = abar(t)
    return (X - np.sqrt(a) * x0_hat(X, a, logw)) / np.sqrt(1.0 - a)

# DDIM (eta=0) 的一步,含 CFG 与引导区间
e_un = fn(X, t, LOGW_UNCOND)
e_c = fn(X, t, LOGW_COND)
w_eff = w if (lo <= t / T <= hi) else 1.0
e_g = e_un + w_eff * (e_c - e_un)
x0 = (X - np.sqrt(1.0 - a) * e_g) / np.sqrt(a)
X = np.sqrt(a_prev) * x0 + np.sqrt(max(1.0 - a_prev, 0.0)) * e_g

五行里三个细节值得说:

  1. w_eff 那一行的 t / T 是归一化噪声档位,区间外直接退回 $w=1$(不是 $w=0$)。这就是 guidance interval 的全部实现。
  2. e_g 可以写成 w_eff * e_c + (1 - w_eff) * e_un,两者数值等价但后者在 $w$ 很大时更容易看出负系数——建议保留后者以免误读。
  3. DDIM 的更新里 $x_0$ 和 $e_g$ 用的是同一个 $e_g$,所以缩放 $e_g$ 会同时改变 $x_0$ 和噪声项,不是单向的。这一点在第 06 节 rescale 那一段会咬人。

先验证去噪器本身是对的。做法是数值积分对拍:固定 20 万真实样本,对每个探测点 $x_t$ 按 $q(x_t|x_0)$ 加权求样本平均,跟闭式解比。

[Q1] 精确去噪器 E[x0|xt] 的数值积分对拍
  t     alpha_bar    闭式解 E[x0]        数值积分 E[x0]       最大绝对差
    1  9.999000e-01  [-0.80383,-0.00497]  [-0.80352,-0.00542]   3.00e-02
   50  9.710157e-01  [+0.33377,-0.32706]  [+0.33434,-0.32566]   1.13e-02
  200  6.590385e-01  [-1.63345,-1.01052]  [-1.62640,-1.00988]   1.32e-02
  500  7.858724e-02  [-0.03520,+0.23795]  [-0.03229,+0.24075]   1.03e-02
  900  2.752059e-04  [+0.01798,-0.00712]  [+0.02195,-0.00467]   4.12e-03
 1000  4.035830e-05  [+0.00501,-0.00579]  [+0.00899,-0.00330]   4.03e-03

差值在 $10^{-2}$ 量级且随 $t$ 增大而减小:$t$ 大时 $q(x_t|x_0)$ 的权重平、有效样本多,$t$ 小时权重集中、积分噪声大。这是纯蒙特卡洛噪声的水平,闭式解可以放心用。

再验证采样器步数够不够($w=7.5$):

  steps    软纯度    多样性比    典型度      类心偏移
    50     0.9640    0.7490     -2.2718      1.7794
   100     0.9637    0.7782     -2.2930      1.7803
   200     0.9636    0.7933     -2.3042      1.7809
   400     0.9635    0.8024     -2.3112      1.7813
  1000     0.9635    0.8055     -2.3136      1.7815

软纯度和类心偏移在 50 步就收敛了,但多样性比和典型度还在爬(0.7490→0.8055,−2.2718→−2.3136)。200 步的典型度距 1000 步约 0.4%,多样性比仍相差约 1.5%,后面全部实验统一用 200 步。这个坑值得记:只看 FID/纯度会以为早就收敛了,多样性类的指标还在漂。

05. 工业级实现对照

参考实现:

以 2026-09 的实现为准,去噪循环里的核心是这几行:

# expand the latents if we are doing classifier free guidance
latent_model_input = torch.cat([latents] * 2) if self.do_classifier_free_guidance else latents
if hasattr(self.scheduler, "scale_model_input"):
    latent_model_input = self.scheduler.scale_model_input(latent_model_input, t)

noise_pred = self.unet(latent_model_input, t, encoder_hidden_states=prompt_embeds, ...)[0]

if self.do_classifier_free_guidance:
    noise_pred_uncond, noise_pred_text = noise_pred.chunk(2)
    noise_pred = noise_pred_uncond + self.guidance_scale * (noise_pred_text - noise_pred_uncond)

if self.do_classifier_free_guidance and self.guidance_rescale > 0.0:
    noise_pred = rescale_noise_cfg(noise_pred, noise_pred_text, guidance_rescale=self.guidance_rescale)

latents = self.scheduler.step(noise_pred, t, latents, **extra_step_kwargs, return_dict=False)[0]

和我的最小实现比,有四处差异,每一处都有理由:

第一,合批不等于只启动一个 kernel。 torch.cat 让一次 UNet 调用同时处理两分支,但网络仍包含很多算子、kernel 和临时张量。理论算术量约翻倍;实际延迟与吞吐取决于 batch、显存、并行和硬件,不能直接断言延迟只涨 30%~60% 或吞吐必然减半。

第二,do_classifier_free_guidance 是一个属性而不是参数:

@property
def do_classifier_free_guidance(self):
    return self._guidance_scale > 1 and self.unet.config.time_cond_proj_dim is None

两个条件都值得注意。guidance_scale > 1 意味着 $w=1$ 时无条件分支根本不会被构造,连 negative_prompt_embeds 都不会编码——「免费的 $w=1$」在代码层面是真的。而 time_cond_proj_dim is None 是更关键的一句:当 UNet 配置里存在 time_cond_proj_dim(把引导强度作为时间条件的投影维度注入)时,diffusers 自动关掉双分支 CFG。这是带 guidance embedding 的 UNet 关闭双分支 CFG 的条件。LCM 与 LCM-LoRA 配置并不完全相同,不能仅凭模型名字判断;应核对 checkpoint 和 pipeline 是否使用引导嵌入或双分支。

第三,rescale_noise_cfg 把缩放做成了插值而不是替换:

std_text = noise_pred_text.std(dim=list(range(1, noise_pred_text.ndim)), keepdim=True)
std_cfg = noise_cfg.std(dim=list(range(1, noise_cfg.ndim)), keepdim=True)
noise_pred_rescaled = noise_cfg * (std_text / std_cfg)
noise_cfg = guidance_rescale * noise_pred_rescaled + (1 - guidance_rescale) * noise_cfg

原文(Lin 等人 2305.08891 第 3.4 节)的做法是把引导后预测的标准差拉回条件分支的标准差。这里额外乘了一个 $\phi\in[0,1]$ 做插值,注释写得很直白:完全 rescale 会得到「plain looking」的图——拉回标准差的同时也把引导的锐度拉掉了。0.7 是论文/示例中的一种建议值,不是 SDXL pipeline 的通用默认;当前 diffusers 接口 guidance_rescale 默认是 0.0。另外注意 std 是逐样本、跨所有通道与空间位置求的,不是全局统计量。

第四,负向 prompt 走的是同一个条件通道。 negative_prompt_embeds 与 prompt_embeds 拼在同一个 batch 里,chunk(2) 之后无条件分支就是负向 prompt 的预测。所以「负向 prompt」不是 CFG 之外的一个独立功能,它就是无条件分支的语义化替换:把「什么都不给」换成「明确不要什么」。负向 prompt 的效果还取决于编码器、截断长度与模型训练;公式本身不能证明“写太长就失效”。

06. 代价与边界

6.1 算力账:2× 是定义级的

把 SD-v1-5 的 UNet 在 512×512(潜空间 64×64)、fp16、batch=1 下的卷积逐层列出来算一遍:

[L1] stable-diffusion-v1-5 UNet,512x512 图(潜空间 64x64),fp16,batch=1
  配置                         单步 MAC(下限)       层间张量字节(下限)
  CFG off                 1.898e+11            0.13 GB
  CFG on (w>1)            3.796e+11            0.25 GB
  倍率:MAC 2.0000 | 激活 2.0000
  上表卷积的参数量合计 517.0 M(SD1.5 UNet 全量约 859 M)

口径说明:这里只数了「一层卷积 = 一个输入张量 + 一个输出张量」,注意力投影、GroupNorm、SiLU 的中间结果、残差分支的暂存都没有计入,所以 0.13 GB 是逐层输入输出张量字节和的局部账本,不是峰值显存,也不是峰值显存的严格下限(不同层可复用内存)。但本文所有结论只用它的比值,而比值 2.0000 是定义级的(batch 维从 1 变 2,权重一份不变),不依赖口径。

算术量翻倍兑现成多少墙钟?实测一个 64×64×320→320 的 3×3 卷积(SD 第一个 ResBlock 的尺寸),CPU + numpy BLAS 取 5 次最小值:

batch=1 : 0.0860 s
batch=2 : 0.1841 s
时间比  : 2.1405

CPU 上 gemm 是算术受限的,所以比值贴近 2。GPU 上可能因合批提高利用率而使墙钟比小于 2;单位时间出图数由实测总延迟和 batch 决定,也不必精确减半。 换成端到端:20 步 DDIM 是 20 次前向变 40 次,50 步是 50 次变 100 次。

这张图要看什么:三张子图分别回答「算力翻倍」「误差放大」「幅度膨胀」。左图是对数刻度下的 MAC、激活字节、50 步前向次数,CFG on/off 两组柱子的高度差在任何一项上都是同样的 2×——强调它是乘性的,跟模型大小、步数、分辨率都无关。中图是实测误差放大倍数(蓝)对最坏上界 $2w-1$(红虚线),两条线差一个数量级,但形状都是单调的。右图是引导后预测的标准差比 $\text{std}(e_{\text{guided}})/\text{std}(e_{\text{c}})$ 随 $w$ 的曲线,$w=1$ 时是 1.00,$w=7.5$ 时 1.77,$w=25$ 时 4.21——这条曲线就是「过曝」的量化版本。

6.2 误差放大:实测远小于上界,但方向一致

探测点固定为「真实数据前向扩散到 $t$」,与 $w$ 无关,这样排除轨迹漂移的干扰:

$t$ $w{=}1$ $w{=}2$ $w{=}3$ $w{=}5$ $w{=}7.5$ $w{=}15$ 最坏上界(同序)
1000 1.000 1.298 1.839 3.037 4.580 9.265 1 / 3 / 5 / 9 / 14 / 29
700 1.000 1.301 1.842 3.042 4.589 9.283 同上
400 1.000 1.389 1.952 3.210 4.834 9.764 同上
200 1.000 1.540 2.184 3.553 5.302 10.596 同上
50 1.000 1.598 2.267 3.663 5.439 10.806 同上
10 1.000 1.599 2.269 3.664 5.438 10.801 同上

$w=15$ 时最坏上界是 29 倍,实测 9.3~10.8 倍。差距来自 3.3 节那个放缩的两处放水:一是最坏范数界允许经系数符号作用后同向叠加;二是它用两支误差的较大范数统一界定。两支预测相关系数 0.9928 不是两支误差相关系数,不能由它证明误差抵消;还要注意表格按条件分支误差归一,而 $(2w-1)$ 界按两支较大误差归一;只有分母一致时才能逐项比较。但随 $w$ 单调放大这件事是所有 $t$ 上一致的,而且低噪声端($t$ 小)放大得更狠——因为两支预测在 $t$ 小时都趋近于真实噪声,误差结构更接近。

6.3 幅度膨胀与 rescale 的边界

$t=300$ 处,探测点来自真实条件样本:

$w$ 0.0 1.0 2.0 3.0 5.0 7.5 10.0 15.0 25.0
$\text{std}$ 比 0.9110 1.0000 1.1009 1.2109 1.4493 1.7690 2.1026 2.7914 4.2052

$w=0$(纯无条件)的比是 0.9110——比 1 还小,因为无条件分支要覆盖全部 4 个分量,它的预测更「平均」。从 $w=1$ 往上单调涨到 4.21。

下面只做二维 toy 的 batch 标准差线性 rescale,在 $w=15$ 下扫系数 $\phi$。真实图像实现按每个样本的 C/H/W 统计;二维向量只有两个坐标,不能把此处整批统计量当成原论文的逐样本实现:

$\phi$ 多样性比 典型度 软纯度
0.0 0.6716 −5.5530 0.9905
0.3 0.8274 −6.1435 0.9910
0.5 0.9513 −6.5778 0.9913
0.7 1.0944 −7.0504 0.9914
1.0 1.3521 −7.8447 0.9916

结论是:能拉回多样性,但典型度更差(−5.5530 → −7.8447)。机制在 04 节那个细节里已经埋好了——DDIM 的更新中 $x_0$ 是 $e_{\text{guided}}$ 的仿射函数,缩放 $e_{\text{guided}}$ 的幅度等于把 $x_0$ 往「没去噪干净」的方向拽:多样性回来了,是因为你把样本往噪声里推回去了,不是因为它更对了。所以 rescale 修的是观感(过曝),不是分布。这个结论限定在本设定(精确去噪器 + DDIM $\eta=0$);这不能解释或否定真实图像的逐样本 rescale 效果,后者需要在实际模型上比较。

6.4 引导区间:帕累托前沿在哪

把 $w=7.5$ 只开在归一化噪声档位 $t/T$ 的某个区间内(区间外退回 $w=1$):

区间($t/T$) 软纯度 多样性比 典型度 类心偏移 说明
$[0.0,1.0]$ 0.9636 0.7933 −2.3042 1.7809 全程开
$[0.5,1.0]$ 0.9126 0.9571 −0.8896 0.9365 只在高噪声段(链首)
$[0.2,0.8]$ 0.9594 0.8167 −2.0693 1.6609 只在中段
$[0.0,0.5]$ 0.9519 0.7672 −1.1345 1.2251 只在低噪声段(链尾)
$[0.0,0.2]$ 0.9042 0.8172 −0.0831 0.4222 只在极低噪声段
$[0.0,0.0]$ 0.7409 0.9876 +0.0030 0.0075 全程不开($w=1$ 基线)

再细扫两族区间,用「纯度增益 / 典型度代价」当性价比:

细扫 A:只开链尾(区间 = [0, hi])
  区间        纯度增益    典型度代价    性价比    类心偏移   多样性比
  [0.0,0.1]     +0.0829      0.0099       8.345     0.1649    0.8899
  [0.0,0.2]     +0.1632      0.0861       1.896     0.4222    0.8172
  [0.0,0.3]     +0.1921      0.3080       0.624     0.6888    0.7890
  [0.0,0.4]     +0.2040      0.6816       0.299     0.9630    0.7737
  [0.0,0.5]     +0.2110      1.1375       0.185     1.2251    0.7672
  [0.0,0.8]     +0.2211      2.1335       0.104     1.7045    0.7862
  [0.0,1.0]     +0.2226      2.3072       0.097     1.7809    0.7933

细扫 B:只开链首(区间 = [lo, 1.0])
  区间        纯度增益    典型度代价    性价比    类心偏移   多样性比
  [0.0,1.0]     +0.2226      2.3072       0.097     1.7809    0.7933
  [0.3,1.0]     +0.2154      2.0350       0.106     1.6224    0.8669
  [0.5,1.0]     +0.1716      0.8926       0.192     0.9365    0.9571
  [0.7,1.0]     +0.0674      0.1837       0.367     0.2866    0.9813
  [0.9,1.0]     +0.0101      0.0209       0.481     0.0438    0.9868

读法:全程开的纯度增益是 +0.2226,典型度代价是 2.3072。只开链尾 10%(区间 $[0,0.1]$)就能拿到 +0.0829,代价只有 0.0099,性价比 8.345——是全程的 86 倍。 反过来看 B 族:只开链首 10%($[0.9,1.0]$)的纯度增益只有 +0.0101,也就是说链首那 10% 的引导几乎买不到任何东西,却占了不小的代价份额(单独开它的性价比 0.481,看着还行,是因为它代价绝对值小;但从全程里减掉它,增益只掉 0.0006)。

这张图要看什么:横轴是典型度代价、纵轴是纯度增益,双对数坐标,虚线是等性价比线。A 族(绿,只开链尾)整条曲线压在 B 族(蓝,只开链首)的左上方——同样的代价,A 族拿到的增益更多。两族共同端点是右上角那个「全程开」的点,性价比 0.097,是全图最差。左下角 $[0,0.1]$ 那个点孤零零地挂在性价比 8.345 的位置上。读这张图的结论不是「照抄区间」,而是「常量 $w$ 全程开的那个点,恰好落在帕累托前沿的最差端」。

需要诚实地区分一下:Kynkäänniemi 等人(2404.07724)在 ImageNet-512 上的结论是「引导在链首有害、在链尾基本没必要、只有中段有用」,而我的解析实验里性价比最高的是链尾。两边不能直接对齐——我是二维高斯混合 + 精确去噪器,纯度增益在链上的分布跟 ImageNet + 训练出来的 UNet 不一样。两边真正一致的结论是:常量 $w$ 全程开不是最优,区间应当作为超参暴露出来。 用的时候请以自己模型的实测为准,别照抄任何一边的具体区间。

6.5 什么时候不该用

  • 已经被蒸馏掉的模型:UNet 有 time_cond_proj_dim(引导强度烘进网络)时,再开双分支 CFG 是纯浪费——diffusers 已经帮你自动关了,手写推理代码时得自己关。
  • $w$ 已经很大还在往大调:从 $w=10$ 到 $w=25$,软纯度只从 0.9744 涨到 0.9985,典型度从 −3.3963 掉到 −9.7613。这一段是纯亏。
  • 多样性是硬指标的场景(数据增广、多样性评测、素材批量生成):CFG 的多样性比在 $w=7.5$ 时已经掉到 0.7933,且没有平台期。
  • 模型本身很弱时:弱去噪器那一栏 $w=15$ 把方差压到真实值的 0.28%。弱模型 + 大 $w$ = 确定性塌缩。这种情况该修模型,不是调 $w$。

07. 经典论文脉络

  • Classifier-Free Diffusion Guidance(arXiv:2207.12598)——Ho & Salimans。提出用「随机丢弃条件训练出来的同一个网络」替代外置分类器,把 classifier guidance 的对抗梯度换成两支预测的外推。留下的问题是:它默认 $w$ 是全程常量,且没有量化代价。
  • Common Diffusion Noise Schedules and Sample Steps are Flawed(arXiv:2305.08891)——Lin 等人。两个独立贡献:训练端的 zero-terminal-SNR 调度(让最后一步真的能走到纯噪声)与推理端的 guidance rescale(3.4 节)。Rescale 直接对着「$w$ 大了会过曝」这个现象下刀,是 rescale 方法的来源;6.3 节数值由本文二维 batch 统计 toy 实测,并非论文原表。
  • Applying Guidance in a Limited Interval Improves Sample and Distribution Quality in Diffusion Models(arXiv:2404.07724)——Kynkäänniemi 等人(NeurIPS 2024)。指出引导在链首有害、链尾基本没必要、只有中段有用,把引导限制在噪声水平的某个区间内,ImageNet-512 的 FID 从 1.81 降到 1.40,并建议在所有用引导的扩散模型里把区间作为超参暴露出来。
  • Guiding a Diffusion Model with a Bad Version of Itself(arXiv:2406.02507)——Karras 等人。用「训练不足的自己」当引导的负支,替代空条件分支。动机正好是本文 3.3 节那个负系数:既然误差会被放大,那就让负支的误差方向更有用——欠训练模型保留的是低频结构,引导方向因此更「语义」而不是更「纹理」。
  • Latent Consistency Models(arXiv:2310.04378)——Luo 等人。把「带引导的反向过程」看成一个增广的概率流 ODE,直接蒸馏它的解。这一步之后 $w$ 变成网络的一个输入,每步只需一次前向——这是「干掉 CFG 那 2×」最彻底的一条路,也是知识树里 step_distillation 那篇要展开的内容。

五篇的演进关系是一条很清楚的线:提出机制 → 修观感 → 修区间 → 修负支 → 把机制整个吸收进权重。 前四篇都在「怎么把 CFG 用得更好」,最后一篇是「怎么不再需要它」。

08. 常见误解

误解一:「CFG 就是在采样 $p(x|c)^w p(x)^{1-w}$。」 score 层面恒等,分布层面不成立。参考统计量是交叉熵 $-E_{X\sim Q}[\log P(X)]$。不同列目标不同,不能横向比较“越小越像”;即使固定 $P$,较低交叉熵也可能来自模式坍缩,需与均值、方差或分布距离联读:

| $w$ | 样本来源 | $-\mathbb{E}[\log p(x\|c)]$ | $-\mathbb{E}[\log p_{\text{tilde}}]$ | $-\mathbb{E}[\log p(x)]$ | 方差比 | 类心偏移 |
|---|---|---|---|---|---|---|
| — | 真实条件样本 | 2.5757 | — | 2.8562 | 1.0000 | 0.0000 |
| 1.0 | CFG 生成 | 2.5670 | 2.5670 | 2.8532 | 0.9876 | 0.0075 |
| 1.0 | 倾斜目标 | 2.5891 | 2.5891 | 2.8706 | 1.0090 | 0.0096 |
| 3.0 | CFG 生成 | 2.7405 | 2.4039 | 3.3536 | 0.8193 | 0.7721 |
| 3.0 | 倾斜目标 | 2.4824 | 2.3227 | 3.0070 | 0.8944 | 0.3546 |
| 7.5 | CFG 生成 | 4.5048 | 3.6926 | 5.1605 | 0.7933 | 1.7809 |
| 7.5 | 倾斜目标 | 2.6350 | 2.2258 | 3.2287 | 0.9077 | 0.6028 |
| 15.0 | CFG 生成 | 7.7257 | 6.1882 | 8.4092 | 0.6716 | 3.0681 |
| 15.0 | 倾斜目标 | 2.9792 | 2.2782 | 3.6030 | 1.0059 | 0.8828 |

$w=1$ 时两行几乎重合(差 0.02 左右),这个说法是对的。$w=3$ 已经开始分叉(类心偏移 0.7721 对 0.3546)。到 $w=7.5$,CFG 生成样本相对倾斜目标的交叉熵是 3.6926,而倾斜目标对自己的交叉熵是 2.2258,差 1.47 nats;类心偏移 1.78 对 0.60,差 3 倍。固定目标下两个期望显著不同,可以否定两分布相等;相近则不能证明分布相同。$w=1$ 在精确模型下成立,其他 $w$ 不能仅凭“很小”保证等价。归一化代码已保留 log-sum-exp 的偏移量,$w=1$ 时 logp_tilt_norm == logp_cond 可直接检验。

误解二:「$w$ 越大越准。」 纯度上去的同时典型度一路往下,而且没有平台期:$w=15$ 时典型度 −5.5530,$w=25$ 时 −9.7613。它变「准」的那一维是「属于目标类的程度」,代价是「属于真实数据的程度」。

误解三:「CFG 不增加参数,所以是免费的。」 MAC 从 $1.898\times10^{11}$ 到 $3.796\times10^{11}$,比值 2.0000;50 步 DDIM 的前向次数从 50 到 100。这是推理成本里最容易被漏掉的一项。

误解四:「把 $w$ 调小一点就省算力。」 不省。只要 $w>1$,do_classifier_free_guidance 就是 True,两个分支都要跑。$w=1.01$ 和 $w=30$ 的算力完全一样。

误解五:「rescale 能修 CFG 的过曝。」 在精确去噪器下它把多样性拉回来了(0.6716→1.3521)但典型度更差(−5.5530→−7.8447)。它修的是幅度观感,不是分布。把 rescale 当成「可以放心加大 $w$ 的许可证」是错的。

误解六:「$w<1$ 就是减弱引导。」 只有 $0<w<1$ 时两个系数都为正,预测是在两支之间插值,输出一般不是条件/无条件分布的概率混合而不是「弱一点的条件采样」。实测 $w=0$ 时多样性比是 1.2512(比真实条件还宽)、类心偏移 0.6651(往相反方向偏)。这是一个不同的分布,不是同一个分布的弱化版。

09. 动手验证

三个小实验,脚本在附录里,全部只依赖 numpy 与 matplotlib。

实验一:确认你的去噪器有没有误差。 跑 cfg_gmm.py 的 Q1 段,看闭式解与数值积分的差。差在 $10^{-2}$ 量级且随 $t$ 增大而减小,说明是蒙特卡洛噪声;如果差随 $w$ 变,说明你把 CFG 写进了去噪器本身。

实验二:扫你自己的 $w$。 改 cfg_gmm.py 里的 SPECS(换成你关心的类心距离与方差结构),跑 Q2。本文那张表的形状是:软纯度在 $w\approx3$ 前快速上升、之后变平;典型度与类心偏移全程线性恶化;多样性比是唯一一个在中间段有一点非单调的指标(0.8042 → 0.7933 → 0.7603,这三点实际单调下降)。如果你扫出来的曲线在这三项上形状一致,说明机制对上了。

实验三:量你自己的引导区间。 跑 cfg_gmm.py 的 Q5 段(细扫 A 与细扫 B),画出「纯度增益 vs 典型度代价」的双对数图。看两件事:你的曲线是不是也在全程开那个点性价比最低;以及 A 族(链尾)与 B 族(链首)哪一条压在左上。这张图应当成为你调 $w$ 之前先看的图,因为它告诉你性价比最高的区间在哪,而不是告诉你 $w$ 该取几。

实验四:算你自己模型的账。 把 cfg_lab.py 里的 CONVS 换成你的 UNet 配置(stage、分辨率、输入输出通道、重复次数),跑一遍看 MAC 与激活的比值是不是 2.0000。该账本比值由样本维决定;分两次调用不改变理论 MAC。实测峰值显存或延迟不等于 2 并不能说明实现错误。

10. 延伸阅读

读这篇之前建议先看:

读完之后:

  • 少步蒸馏:从 50 步到 4 步——沿着 07 节最后一篇继续,看 $w$ 是怎么被烘进网络、从而把 2× 变回 1× 的。(写作中)

回到知识树:本文是「生成范式」方向的一级节点,往上接 DDPM,往下接少步蒸馏。

附录:完整代码

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

cfg_gmm.py

# -*- coding: utf-8 -*-
"""cfg_gmm.py —— 在一个「去噪器可解析求解」的高斯混合模型上把 CFG 做实。

为什么非要用 GMM:
    CFG 需要一支条件去噪器和一支无条件去噪器。用真网络的话两支都有训练误差,
    观察到的任何形变都分不清是 CFG 造成的还是训歪了。而 GMM 的 MMSE 去噪器
    E[x0|xt] 有闭式解,两支都是**精确**的,于是生成分布的形变只能来自 CFG 本身。
    另外 p_t(x_t|c) 也有解析式,可以直接验证「CFG 是不是在从倾斜分布
    p(x|c)^w p(x)^{1-w} 里采样」这个命题。

本脚本回答六个问题(全部是实跑数字,不是推测):
    Q1  精确去噪器对不对?(数值积分对拍)
    Q2  w 扫描:条件纯度 / 多样性 / 典型度 怎么变?
    Q3  CFG 采出来的分布,是不是那个倾斜分布?
    Q4  w 把去噪器的**误差**放大了多少倍?(最坏上界 2w-1)
    Q5  只在某个噪声区间开引导,比全程开好吗?
    Q6  引导后的预测幅度膨胀多少?rescale 能不能压回去?

运行:  /usr/local/bin/python3 cfg_gmm.py
依赖:  numpy(无 torch,全部闭式解 + 向量化)
"""

import numpy as np

# ═════════════════════════════ 1. 数据:两组、四个高斯分量 ═════════════════════════════

# (均值, 各向同性方差, 无条件权重, 类别标签)
# 设计意图:两类故意重叠(类心相距约 1.3,分量标准差 0.6~1.0),
# 这样 w=1 时条件采样仍会「漏」到对面,给 CFG 留出可观测的改善空间。
# 两类各有一个「紧」分量和一个「松」分量 —— 方差不齐是后面「模式漂进尾巴」的关键。
SPECS = [
    ((-0.90, -0.05), 0.36, 0.25, 0),
    ((-0.10, -0.85), 1.00, 0.25, 0),
    ((0.90, 0.05), 0.36, 0.25, 1),
    ((0.10, 0.85), 1.00, 0.25, 1),
]

MU = np.array([s[0] for s in SPECS], dtype=float)      # [K, 2]
VAR = np.array([s[1] for s in SPECS], dtype=float)     # [K]
PI = np.array([s[2] for s in SPECS], dtype=float)      # [K]
CLS = np.array([s[3] for s in SPECS], dtype=int)       # [K]
K = len(SPECS)
DIM = 2
TARGET_CLS = 1          # 本文统一用「条件 = 类别 1」做演示

# ── 调度:DDPM 线性 beta,T=1000(与 ddpm 那篇同一套口径)──
T = 1000
BETAS = np.linspace(1e-4, 0.02, T)
ALPHAS = 1.0 - BETAS
ABAR = np.cumprod(ALPHAS)                     # alpha_bar,下标 0 对应 t=1


def abar(t):
    """alpha_bar_t,t 从 1 开始(与论文记号一致)。"""
    return ABAR[t - 1]


def safe_log(w):
    """对数,0 分量记为 -inf 而不报警告。"""
    w = np.asarray(w, dtype=float)
    out = np.full_like(w, -np.inf)
    m = w > 0
    out[m] = np.log(w[m])
    return out


# ═════════════════════════════ 2. 精确去噪器 ═════════════════════════════

def logpdf_comps(X, a_bar):
    """每个分量对 x_t 的对数密度:x_t|k ~ N(sqrt(a) mu_k, a var_k + (1-a))。"""
    s = np.sqrt(a_bar)
    m = s * MU                                                   # [K, 2]
    v = a_bar * VAR + (1.0 - a_bar)                              # [K]
    d2 = ((X[:, None, :] - m[None, :, :]) ** 2).sum(-1)           # [N, K]
    return -0.5 * (d2 / v[None, :] + DIM * np.log(2 * np.pi * v)[None, :])


def posterior(X, a_bar, logw):
    """分量后验权重 r_k = p(k | x_t)。logw 是分量的对数先验。"""
    lp = logpdf_comps(X, a_bar) + logw[None, :]
    lp -= lp.max(axis=1, keepdims=True)
    r = np.exp(lp)
    return r / r.sum(axis=1, keepdims=True)


def x0_hat(X, a_bar, logw):
    """MMSE 估计 E[x0 | x_t](精确闭式解)。

    分量内部是高斯的,所以 E[x0|x_t,k] 有闭式解:
        mu_k + S_k sqrt(a) (a S_k + (1-a) I)^{-1} (x_t - sqrt(a) mu_k)
    再按后验权重 r_k 加权。S_k = var_k * I,所以增益是个标量。
    """
    s = np.sqrt(a_bar)
    v = a_bar * VAR + (1.0 - a_bar)
    gain = VAR * s / v                                           # [K]
    r = posterior(X, a_bar, logw)                                # [N, K]
    per = MU[None, :, :] + gain[None, :, None] * (X[:, None, :] - s * MU[None, :, :])
    return (r[:, :, None] * per).sum(axis=1)                     # [N, 2]


def eps_hat(X, t, logw):
    """epsilon 预测:eps = (x_t - sqrt(a) x0_hat) / sqrt(1-a)。"""
    a = abar(t)
    return (X - np.sqrt(a) * x0_hat(X, a, logw)) / np.sqrt(1.0 - a)


def cond_logw(cls):
    """条件分支的分量对数先验:只保留该类内部分量,类内权重重新归一化。"""
    w = np.where(CLS == cls, PI, 0.0)
    w = w / w.sum()
    return safe_log(w)


LOGW_UNCOND = safe_log(PI)                     # 无条件:全混合
LOGW_COND = cond_logw(TARGET_CLS)              # 条件:类别 TARGET_CLS


# ── 弱去噪器:把整簇拟合成「一个高斯」之后的线性维纳滤波 ──
# 给 Q4 用的「不完美模型」:它是该高斯下的最优线性去噪器,但真实数据是混合体,
# 所以误差非零、且随 t 变化 —— 正好用来看 w 的放大倍数。
def single_gauss(logw):
    """按分量权重算出混合体的均值与平均方差。"""
    w = np.exp(logw)
    w = w / w.sum()
    mu = (w[:, None] * MU).sum(0)
    var = (w * (VAR + (MU ** 2).sum(1))).sum() - (mu ** 2).sum()
    return mu, var / DIM


def weak_eps_hat(X, t, logw):
    """单高斯近似的线性去噪器(有误差)。"""
    a = abar(t)
    mu, var = single_gauss(logw)
    s = np.sqrt(a)
    v = a * var + (1.0 - a)
    gain = var * s / v
    x0 = mu[None, :] + gain * (X - s * mu[None, :])
    return (X - s * x0) / np.sqrt(1.0 - a)


# ═════════════════════════════ 3. 采样器(DDIM, eta=0) ═════════════════════════════

def ddim_timesteps(steps):
    """均匀步长的 DDIM 时间步序列,从 T 递减到 1。"""
    stride = max(T // steps, 1)
    return list(range(T, 0, -stride))


def ddim_sample(x_T, steps=200, w=1.0, interval=(0.0, 1.0),
                weak=False, rescale=0.0):
    """确定性 DDIM 采样(eta=0),带 CFG。

    x_T      : [N, 2] 纯噪声起点
    w        : 引导强度(w=1 即纯条件,w=0 即纯无条件)
    interval : 引导生效的 t/T 区间 (lo, hi)。区间外退化为 w=1。
    weak     : True 则用单高斯弱去噪器(Q4/Q6 用)
    rescale  : 二维玩具的 batch 标准差线性 rescale 系数 phi;不等于图像逐样本 rescale
    """
    fn = weak_eps_hat if weak else eps_hat
    ts = ddim_timesteps(steps)
    X = x_T.copy()
    lo, hi = interval
    for i, t in enumerate(ts):
        a = abar(t)
        a_prev = abar(ts[i + 1]) if i + 1 < len(ts) else 1.0

        e_un = fn(X, t, LOGW_UNCOND)
        e_c = fn(X, t, LOGW_COND)

        frac = t / T
        w_eff = w if (lo <= frac <= hi) else 1.0
        e_g = e_un + w_eff * (e_c - e_un)          # = w_eff*e_c + (1-w_eff)*e_un

        if rescale > 0.0:
            # 二维 toy 用整批统计量;图像实现应按样本跨 C/H/W 统计。
            # 沿用线性插值,而不是把标准差比取 phi 次幂。
            s_g, s_c = e_g.std(), e_c.std()
            if s_g > 1e-12:
                e_g = (1.0 - rescale) * e_g + rescale * e_g * (s_c / s_g)

        x0 = (X - np.sqrt(1.0 - a) * e_g) / np.sqrt(a)
        X = np.sqrt(a_prev) * x0 + np.sqrt(max(1.0 - a_prev, 0.0)) * e_g
    return X


# ═════════════════════════════ 4. 度量 ═════════════════════════════

def logsumexp_rows(lp):
    m = lp.max(axis=1, keepdims=True)
    return m[:, 0] + np.log(np.exp(lp - m).sum(axis=1))


def logp_data(X):
    """真实数据分布(无条件 GMM)的对数密度。"""
    return logsumexp_rows(logpdf_comps(X, 1.0) + safe_log(PI)[None, :])


def logp_cond(X):
    """真实条件分布 p(x|c=TARGET_CLS) 的对数密度。"""
    return logsumexp_rows(logpdf_comps(X, 1.0) + LOGW_COND[None, :])


def class_posterior(X):
    """p(类别=TARGET_CLS | x) —— 用真实 GMM 算,作为「软纯度」。"""
    lp = logpdf_comps(X, 1.0) + safe_log(PI)[None, :]
    m = lp.max(axis=1, keepdims=True)
    r = np.exp(lp - m)
    r /= r.sum(axis=1, keepdims=True)
    return (r * (CLS[None, :] == TARGET_CLS)).sum(axis=1)


def spread(X):
    """分布的「宽度」:每维方差的平均。"""
    return float(np.mean(X.var(axis=0)))


def summarize(X, Xref, tag=""):
    """一组样本的核心指标。Xref 是真实条件分布 p(x|c) 的样本,作为基准。"""
    d2 = ((X[:, None, :] - MU[None, :, :]) ** 2).sum(-1) / VAR[None, :]
    return dict(
        tag=tag,
        purity=float(class_posterior(X).mean()),
        hard=float((CLS[np.argmin(d2, axis=1)] == TARGET_CLS).mean()),
        div=float(spread(X) / spread(Xref)),
        typicality=float(logp_data(X).mean() - logp_data(Xref).mean()),
        bias=float(np.linalg.norm(X.mean(0) - Xref.mean(0))),
    )


def sample_data(rng, n, cls=None):
    """从真实 GMM 采 n 个样本;cls 非空则只采该类的样本。"""
    w = PI.copy()
    if cls is not None:
        w = np.where(CLS == cls, PI, 0.0)
    w = w / w.sum()
    k = rng.choice(K, size=n, p=w)
    return MU[k] + np.sqrt(VAR[k])[:, None] * rng.standard_normal((n, DIM))


# ═══════════════════════ 5. 倾斜分布 p(x|c)^w p(x)^{1-w} ═══════════════════════

def tilted_logpdf(X, w):
    """未归一化的倾斜对数密度:w*log p(x|c) + (1-w)*log p(x)。"""
    return w * logp_cond(X) + (1.0 - w) * logp_data(X)


def _tilt_grid(w, lo=-9.0, hi=9.0, n=420):
    """在方格上算归一化后的倾斜密度,返回 (xs, P[ny, nx]),P 求和为 1。"""
    xs = np.linspace(lo, hi, n)
    GX, GY = np.meshgrid(xs, xs)
    P = np.column_stack([GX.ravel(), GY.ravel()])
    lp = tilted_logpdf(P, w)
    lp -= lp.max()
    d = np.exp(lp).reshape(n, n)
    return xs, d / d.sum()


_TILT_CACHE = {}


def logp_tilt_norm(X, w, lo=-9.0, hi=9.0, n=420):
    """归一化后的 log p_tilde(x)(归一化常数由方格数值积分得到)。"""
    key = (float(w), float(lo), float(hi), int(n))
    if key not in _TILT_CACHE:
        xs = np.linspace(lo, hi, n)
        GX, GY = np.meshgrid(xs, xs)
        lp = tilted_logpdf(np.column_stack([GX.ravel(), GY.ravel()]), w)
        offset = lp.max()
        d = np.exp(lp - offset).reshape(n, n)
        Z = d.sum() * (xs[1] - xs[0]) ** 2
        _TILT_CACHE[key] = offset + np.log(Z)
    return tilted_logpdf(X, w) - _TILT_CACHE[key]


def sample_tilted(rng, w, n):
    """从倾斜分布 p(x|c)^w p(x)^{1-w} 采样(方格离散近似 + 格内抖动)。"""
    xs, d = _tilt_grid(w)
    flat = d.ravel()
    idx = rng.choice(len(flat), size=n, p=flat / flat.sum())
    iy, ix = np.unravel_index(idx, d.shape)
    step = xs[1] - xs[0]
    return np.column_stack([xs[ix] + (rng.random(n) - 0.5) * step,
                            xs[iy] + (rng.random(n) - 0.5) * step])


# ═════════════════════════════ 6. 主流程 ═════════════════════════════

def base_rng():
    """主实验与画图共用的随机流起点。

    两边必须 draw 相同次数、相同顺序,否则图上的数字和正文表格会对不上。
    """
    rng = np.random.default_rng(20260928)
    Xref = sample_data(rng, 40000, cls=TARGET_CLS)
    return rng, Xref


def main():
    rng, Xref = base_rng()
    N = 20000
    STEPS = 200

    print("=" * 78)
    print("CFG 实跑账本  ——  数据:2D 高斯混合,4 分量 / 2 类,条件 = 类别 %d" % TARGET_CLS)
    print("=" * 78)

    # ── Q1:精确去噪器对拍(数值积分) ──
    print("\n[Q1] 精确去噪器 E[x0|xt] 的数值积分对拍")
    print("     做法:固定 20 万真实样本,对每个探测点 xt 按 q(xt|x0) 加权求样本平均。")
    print("  t     alpha_bar    闭式解 E[x0]        数值积分 E[x0]       最大绝对差")
    mc_rng = np.random.default_rng(7)
    X0 = sample_data(mc_rng, 200000)
    for t in (1, 50, 200, 500, 900, 1000):
        a = abar(t)
        Xt = np.sqrt(a) * X0 + np.sqrt(1 - a) * mc_rng.standard_normal(X0.shape)
        idx = mc_rng.integers(0, len(X0), size=60)
        xt_probe = Xt[idx]
        mc = np.empty_like(xt_probe)
        s = np.sqrt(a)
        for j, xp in enumerate(xt_probe):                  # 逐点算,避免大临时矩阵
            # 权重就是 q(xt|x0) ∝ exp(-||xt - sqrt(a) x0||^2 / (2(1-a)))
            d2 = ((xp[None, :] - s * X0) ** 2).sum(-1)
            ww = np.exp(-0.5 * (d2 - d2.min()) / (1 - a))  # 减最小值防下溢
            ww /= ww.sum()
            mc[j] = (X0 * ww[:, None]).sum(0)
        cf = x0_hat(xt_probe, a, LOGW_UNCOND)   # 探测数据来自全混合,故用无条件先验
        print("  %4d  %.6e   [%+.5f,%+.5f]   [%+.5f,%+.5f]   %.2e"
              % (t, a, cf[0, 0], cf[0, 1], mc[0, 0], mc[0, 1],
                 np.abs(mc - cf).max()))
    print("  → 差值在 1e-2 量级;t 大时权重平、有效样本多,t 小时权重集中、积分噪声大。")

    # 基准
    print("\n基准:真实条件分布 p(x|c=%d)" % TARGET_CLS)
    print("  每维方差 %.4f | 均值 [%+.4f,%+.4f] | 平均 log p_data %.4f | 软纯度 %.4f"
          % (spread(Xref), Xref.mean(0)[0], Xref.mean(0)[1],
             logp_data(Xref).mean(), class_posterior(Xref).mean()))

    x_T0 = rng.standard_normal((N, DIM))

    # ── 步长收敛检查 ──
    print("\n[Q0] DDIM 步数收敛检查(w=7.5,看 200 步够不够;1000 步 = 全量 T)")
    print("   steps    软纯度    多样性比    典型度      类心偏移")
    for st in (50, 100, 200, 400, 1000):
        X = ddim_sample(x_T0, steps=st, w=7.5)
        m = summarize(X, Xref)
        print("   %4d     %.4f    %.4f     %+.4f      %.4f"
              % (st, m["purity"], m["div"], m["typicality"], m["bias"]))

    # ── Q2:w 扫描 ──
    print("\n[Q2] 引导强度 w 扫描(精确去噪器,DDIM %d 步,N=%d)" % (STEPS, N))
    print("   w      软纯度    硬纯度    多样性比   典型度(nats)   类心偏移")
    r0 = summarize(Xref, Xref, tag="真实条件")
    print("  真实条件样本(参照)  %.4f   %.4f    %.4f     %+.4f       %.4f"
          % (r0["purity"], r0["hard"], r0["div"], r0["typicality"], r0["bias"]))
    sweep = {}
    for w in (0.0, 1.0, 2.0, 3.0, 5.0, 7.5, 10.0, 15.0, 25.0):
        X = ddim_sample(x_T0, steps=STEPS, w=w)
        m = summarize(X, Xref)
        sweep[w] = m
        print("  %5.1f    %.4f    %.4f    %.4f     %+.4f       %.4f"
              % (w, m["purity"], m["hard"], m["div"], m["typicality"], m["bias"]))
    print("  软纯度 = E[p(c|x)](真实后验);硬纯度 = 马氏最近分量属于目标类的比例;")
    print("  多样性比 = 生成样本每维方差 / 真实条件方差;")
    print("  典型度 = 生成样本与真实样本的平均 log p_data 之差(负 = 平均落在较低密度区,不代表支撑之外)。")

    # ── Q3:CFG 采的是不是倾斜分布 ──
    print("\n[Q3] CFG 采出的分布 vs 倾斜目标 p(x|c)^w p(x)^{1-w}")
    print("     统计量:固定目标的交叉熵差可否定同分布;更小不保证更像目标")
    print("   w   样本来源      -E[log p(x|c)]  -E[log p_tilde]  -E[log p(x)]  方差比  类心偏移")
    tilt_rows = {}
    # 参照行:真实条件分布自己的交叉熵(作为「完美拟合」的刻度)
    print("  ----  真实条件样本     %10.4f     %10s    %10.4f  %6.4f  %6.4f"
          % (-float(logp_cond(Xref).mean()), "(w=1 时同)",
             -float(logp_data(Xref).mean()),
             spread(Xref) / spread(Xref),
             float(np.linalg.norm(Xref.mean(0) - Xref.mean(0)))))
    for w in (1.0, 3.0, 7.5, 15.0):
        Xg = ddim_sample(x_T0, steps=STEPS, w=w)
        Xt = sample_tilted(rng, w, 20000)
        for name, X in (("CFG 生成", Xg), ("倾斜目标", Xt)):
            row = dict(
                ce_cond=-float(logp_cond(X).mean()),
                ce_tilt=-float(logp_tilt_norm(X, w).mean()),
                ce_data=-float(logp_data(X).mean()),
                div=float(spread(X) / spread(Xref)),
                bias=float(np.linalg.norm(X.mean(0) - Xref.mean(0))),
            )
            tilt_rows[(w, name)] = row
            print("  %4.1f  %-11s   %10.4f     %10.4f    %10.4f  %6.4f  %6.4f"
                  % (w, name, row["ce_cond"], row["ce_tilt"], row["ce_data"],
                     row["div"], row["bias"]))
        print("        ↑ 结合均值和方差判断;仅交叉熵接近不能证明同分布")

    # ── Q4:误差放大(固定探测分布) ──
    print("\n[Q4] 弱去噪器(单高斯近似)下,w 把误差放大了多少倍")
    print("     探测点固定为「真实数据前向扩散到 t」,与 w 无关,排除轨迹漂移的干扰。")
    ws4 = (1.0, 2.0, 3.0, 5.0, 7.5, 15.0)
    print("   t      " + "  ".join("w=%-4g" % w for w in ws4)
          + "   最坏上界 2w-1(同序)")
    probe = sample_data(rng, 20000)
    amp_by_t = {}
    for t in (1000, 700, 400, 200, 50, 10):
        a = abar(t)
        Xt = np.sqrt(a) * probe + np.sqrt(1 - a) * rng.standard_normal(probe.shape)
        ec = weak_eps_hat(Xt, t, LOGW_COND) - eps_hat(Xt, t, LOGW_COND)
        eu = weak_eps_hat(Xt, t, LOGW_UNCOND) - eps_hat(Xt, t, LOGW_UNCOND)
        base = np.linalg.norm(ec, axis=1).mean()
        row = []
        for w in ws4:
            amp = np.linalg.norm(w * ec + (1.0 - w) * eu, axis=1).mean()
            row.append(amp / max(base, 1e-12))
        amp_by_t[t] = row
        print("  %4d   " % t + "  ".join("%6.3f" % v for v in row)
              + "   " + " ".join("%.0f" % (2 * w - 1) for w in ws4))
    print("  → 实测远小于最坏上界:两支误差高度相关、互相抵消;但随 w 单调放大是一致的。")

    print("\n  弱去噪器下端到端效果(w 越大塌得越狠):")
    print("   w      生成方差/真条件    软纯度")
    for w in ws4:
        X = ddim_sample(x_T0, steps=STEPS, w=w, weak=True)
        m = summarize(X, Xref)
        print("  %5.1f        %.4f          %.4f" % (w, m["div"], m["purity"]))

    # ── Q5:引导区间 ──
    print("\n[Q5] 只在某个噪声区间开引导(w=7.5,精确去噪器,DDIM %d 步)" % STEPS)
    print("   区间(t/T)      软纯度    多样性比   典型度     类心偏移   说明")
    intervals = [
        ((0.0, 1.0), "全程开"),
        ((0.5, 1.0), "只在高噪声段(链的前半)"),
        ((0.2, 0.8), "只在中段"),
        ((0.0, 0.5), "只在低噪声段(链的后半)"),
        ((0.0, 0.2), "只在极低噪声段"),
        ((0.0, 0.0), "全程不开(w=1 基线)"),
    ]
    interval_rows = {}
    base = None
    for (lo, hi), name in intervals:
        if (lo, hi) == (0.0, 0.0):
            X = ddim_sample(x_T0, steps=STEPS, w=1.0)
        else:
            X = ddim_sample(x_T0, steps=STEPS, w=7.5, interval=(lo, hi))
        m = summarize(X, Xref)
        interval_rows[(lo, hi)] = dict(name=name, **m)
        if (lo, hi) == (0.0, 0.0):
            base = m
        print("   [%.1f,%.1f]      %.4f    %.4f    %+.4f     %.4f     %s"
              % (lo, hi, m["purity"], m["div"], m["typicality"], m["bias"], name))

    # 细扫:把「区间末端从哪切」和「区间起点从哪切」分别扫一遍,看帕累托前沿
    print("\n   细扫 A:只看链的后半段(区间 = [0, hi]),hi 从 0.1 扫到 1.0")
    print("   区间        纯度增益    典型度代价    性价比    类心偏移   多样性比")
    fine_a = {}
    for hi in (0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.8, 1.0):
        X = ddim_sample(x_T0, steps=STEPS, w=7.5, interval=(0.0, hi))
        m = summarize(X, Xref)
        g = m["purity"] - base["purity"]
        c = -(m["typicality"] - base["typicality"])
        fine_a[hi] = dict(gain=g, cost=c, ratio=g / c, **m)
        print("   [0.0,%.1f]     %+.4f      %.4f      %6.3f     %.4f    %.4f"
              % (hi, g, c, g / c, m["bias"], m["div"]))
    print("\n   细扫 B:只看链的前半段(区间 = [lo, 1.0]),lo 从 0.0 扫到 0.9")
    print("   区间        纯度增益    典型度代价    性价比    类心偏移   多样性比")
    fine_b = {}
    for lo in (0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9):
        X = ddim_sample(x_T0, steps=STEPS, w=7.5, interval=(lo, 1.0))
        m = summarize(X, Xref)
        g = m["purity"] - base["purity"]
        c = -(m["typicality"] - base["typicality"])
        fine_b[lo] = dict(gain=g, cost=c, ratio=g / c, **m)
        print("   [%.1f,1.0]     %+.4f      %.4f      %6.3f     %.4f    %.4f"
              % (lo, g, c, g / c, m["bias"], m["div"]))

    # ── Q6:幅度膨胀 + rescale ──
    print("\n[Q6] 引导后预测的幅度膨胀(t=300,探测点来自真实条件样本)")
    Xp_base = sample_data(rng, 8000, cls=TARGET_CLS)
    a300 = abar(300)
    Xp = np.sqrt(a300) * Xp_base + np.sqrt(1 - a300) * rng.standard_normal(Xp_base.shape)
    e_c = eps_hat(Xp, 300, LOGW_COND)
    e_u = eps_hat(Xp, 300, LOGW_UNCOND)
    rho = float(np.corrcoef(e_c.ravel(), e_u.ravel())[0, 1])
    print("     两分支预测的相关系数 rho = %.4f(高度相关 -> 膨胀来自「差值方向」)" % rho)
    print("   w      std(eps_guided)/std(eps_cond)")
    infl = {}
    for w in (0.0, 1.0, 2.0, 3.0, 5.0, 7.5, 10.0, 15.0, 25.0):
        e_g = e_u + w * (e_c - e_u)
        infl[w] = float(e_g.std() / e_c.std())
        print("  %5.1f        %.4f" % (w, infl[w]))

    print("\n     guidance rescale 的效果(w=15):")
    print("   phi    多样性比    典型度      软纯度")
    for phi in (0.0, 0.3, 0.5, 0.7, 1.0):
        X = ddim_sample(x_T0, steps=STEPS, w=15.0, rescale=phi)
        m = summarize(X, Xref)
        print("   %.1f      %.4f     %+.4f     %.4f"
              % (phi, m["div"], m["typicality"], m["purity"]))
    print("     → 本设定(精确去噪器 + DDIM eta=0)下 rescale 把多样性拉回来了,")
    print("       但典型度更差:它缩放的是 eps 的幅度,而 x0 是 eps 的仿射函数,")
    print("       缩放幅度等于把 x0 往「没去噪干净」的方向拽。")

    print("\n" + "=" * 78)
    print("全部数字由本脚本实跑产生。")
    print("=" * 78)


if __name__ == "__main__":
    main()

cfg_lab.py

# -*- coding: utf-8 -*-
"""cfg_lab.py —— CFG 的代价账本:算术量、激活显存、实测耗时。

这一篇的立论是「CFG 让每步算两遍,是推理成本里最容易被忽视的 2×」。
这句话里「2×」是**定义级精确**的(batch 维翻倍、权重不变),
但读者真正想知道的是:这个 2× 在真机上兑现成多少墙钟时间。

本脚本做三件事:
    L1  按 stable-diffusion-v1-5 的 UNet 配置手算一份特征图账本(fp16),
        给出「单步激活字节」和「单步 MAC」这两个绝对量级。
    L2  用 numpy 真跑一个 64x64x320 的 3x3 卷积,实测 batch=1 与 batch=2 的时间比。
        (CPU + BLAS,只用于说明算术量翻倍在时间上兑现的程度;GPU 上数字会不同。)
    L3  把「每步两遍」换算成端到端:50 步采样一共多了多少次前向。

运行:  /usr/local/bin/python3 cfg_lab.py
依赖:  numpy
"""

import time

import numpy as np

# ───────────────────────── L1. UNet 配置与账本 ─────────────────────────

# stable-diffusion-v1-5 的 UNet(以 2026-09 时 diffusers 的配置为准):
#   block_out_channels = [320, 640, 1280, 1280],潜空间 64x64(对应 512x512 图)
# 下面只列卷积:ResBlock 内部是 GroupNorm-SiLU-Conv3x3-GroupNorm-SiLU-Conv3x3。
# 注意力模块的 QKV / 输出投影、时间嵌入 MLP 都不在表里 —— 它们参数不少,
# 但激活量远小于特征图,且跨注意力在 SD 里只作用于 32/16/8 三个尺度。
CONVS = [
    # (阶段名, 空间尺寸, in_ch, out_ch, 重复次数)
    ("conv_in",            64,    4,  320, 1),
    ("down0.resnet",       64,  320,  320, 4),   # 2 个 ResBlock x 2 个 conv
    ("down0.downsample",   64,  320,  320, 1),
    ("down1.resnet",       32,  320,  640, 2),
    ("down1.resnet",       32,  640,  640, 2),
    ("down1.downsample",   32,  640,  640, 1),
    ("down2.resnet",       16,  640, 1280, 2),
    ("down2.resnet",       16, 1280, 1280, 2),
    ("down2.downsample",   16, 1280, 1280, 1),
    ("down3.resnet",        8, 1280, 1280, 4),
    ("mid.resnet",          8, 1280, 1280, 4),
    ("up0.resnet",          8, 2560, 1280, 3),
    ("up0.resnet",          8, 1280, 1280, 3),
    ("up1.resnet",         16, 2560, 1280, 3),
    ("up1.resnet",         16, 1280, 1280, 3),
    ("up2.resnet",         32, 1920,  640, 3),
    ("up2.resnet",         32,  640,  640, 3),
    ("up3.resnet",         64,  960,  320, 3),
    ("up3.resnet",         64,  320,  320, 3),
    ("conv_out",           64,  320,    4, 1),
]


def ledger(dtype_bytes=2, batch=1, with_cfg=False):
    """算一份特征图账本。

    dtype_bytes : fp16 = 2 字节
    batch       : 一次前向同时处理的样本数
    with_cfg    : True 则 batch 翻倍(无条件分支拼在 batch 维里)

    返回 (总 MAC, 层间张量字节总和, 参数量)
    """
    b = batch * (2 if with_cfg else 1)
    mac = 0
    act = 0
    params = 0
    for _, hw, cin, cout, rep in CONVS:
        n = hw * hw
        mac += b * n * cin * 9 * cout * rep
        # 一个卷积要留着输入、要写出输出,两块都算层间张量
        act += b * n * (cin + cout) * dtype_bytes * rep
        params += cin * 9 * cout * rep
    return mac, act, params


# ───────────────────────── L2. 实测:一个 3x3 卷积,batch 1 vs 2 ─────────────────────────

def conv3x3(X, W):
    """X: [B, C, H, W],W: [Co, C, 3, 3] —— im2col + 一次大矩阵乘。"""
    B, C, H, Wd = X.shape
    Co = W.shape[0]
    Xp = np.pad(X, ((0, 0), (0, 0), (1, 1), (1, 1)))
    win = np.lib.stride_tricks.sliding_window_view(Xp, (3, 3), axis=(2, 3))
    cols = win.reshape(B, C * 9, H * Wd).transpose(0, 2, 1).reshape(B * H * Wd, C * 9)
    out = cols @ W.reshape(Co, C * 9).T
    return out.reshape(B, H, Wd, Co).transpose(0, 3, 1, 2)


def time_conv(hw=64, cin=320, cout=320, batch=1, repeat=5):
    """对一个具体尺寸的 3x3 卷积计时,取 repeat 次的最小值。"""
    rng = np.random.default_rng(11)
    X = rng.standard_normal((batch, cin, hw, hw), dtype=np.float32)
    W = (rng.standard_normal((cout, cin, 3, 3), dtype=np.float32) / np.sqrt(cin * 9))
    conv3x3(X, W)                                   # 预热
    best = float("inf")
    for _ in range(repeat):
        t0 = time.perf_counter()
        conv3x3(X, W)
        best = min(best, time.perf_counter() - t0)
    return best


def main():
    print("=" * 78)
    print("CFG 的代价账本")
    print("=" * 78)

    print("\n[L1] stable-diffusion-v1-5 UNet,512x512 图(潜空间 64x64),fp16,batch=1")
    print("     (只算上表列出的卷积;注意力矩阵与 autograd 临时缓冲不计)")
    print("  ──────────────────────────────────────────────────────────────")
    print("  %-14s %18s %18s" % ("配置", "单步 MAC(下限)", "层间张量(下限)"))
    for tag, cfg in (("CFG off", False), ("CFG on (w>1)", True)):
        mac, act, par = ledger(with_cfg=cfg)
        print("  %-14s %18.3e %18s" % (tag, mac, "%.3f GB" % (act / 1024 ** 3)))
    mac0, act0, par0 = ledger(with_cfg=False)
    mac1, act1, _ = ledger(with_cfg=True)
    print("  ──────────────────────────────────────────────────────────────")
    print("  倍率:MAC %.4f | 激活 %.4f   ← 这两个 2 是定义级精确的" % (mac1 / mac0, act1 / act0))
    print("  「下限」口径说明:上表只列了卷积,每个卷积只算 1 份输入 + 1 份输出。")
    print("  注意力投影、GroupNorm/SiLU 的中间张量、残差分支都没计进去,")
    print("  真实峰值比这两个数大。本文只用它给量级,结论只依赖「翻倍」这个比值。")
    print("  上表卷积的参数量合计 %.1f M(SD1.5 UNet 全量约 859 M,"
          "差额是注意力投影与时间嵌入 MLP)" % (par0 / 1e6))

    print("\n[L2] 实测:一个 64x64x320 -> 320 的 3x3 卷积(SD 第一个 ResBlock 的尺寸)")
    print("     CPU + numpy BLAS,取 5 次最小值。用来看算术量翻倍兑现成多少墙钟。")
    t1 = time_conv(batch=1)
    t2 = time_conv(batch=2)
    print("  batch=1 : %.4f s" % t1)
    print("  batch=2 : %.4f s" % t2)
    print("  时间比  : %.4f" % (t2 / t1))
    print("  → CPU 上 BLAS 的 gemm 是算术受限的,所以比值贴近 2;")
    print("    GPU 上 batch=1 时 SM 常常没填满,真实墙钟比会明显小于 2,")
    print("    理论样本算术量约翻倍;吞吐与峰值显存应在实际硬件测量。")

    print("\n[L3] 换算成端到端")
    for steps in (20, 30, 50):
        print("  %2d 步 DDIM:CFG off 共 %2d 次前向 | CFG on 共 %2d 次前向"
              % (steps, steps, 2 * steps))
    print("  蒸馏类模型(LCM / SDXL-Turbo 那一支)把 w 变成网络的一个输入,")
    print("  只跑一遍前向 —— 这就是为什么「干掉 CFG」是提速的第一优先级。")

    print("\n" + "=" * 78)


if __name__ == "__main__":
    main()

make_figures.py

# -*- coding: utf-8 -*-
"""画本文的四张图。数据源全部来自 cfg_gmm.py / cfg_lab.py 的真实输出,不另造数。

    cfg_geometry.png   引导把样本推到了哪:真条件 / 倾斜目标 / CFG 实际生成
    w_sweep.png        纯度涨了多少、代价涨了多少(双轴)
    interval.png       引导只在某个噪声区间开,性价比差多少
    cost_ledger.png    那个 2x 的账本,以及 w 对误差的放大

运行:  /usr/local/bin/python3 make_figures.py
依赖:  numpy, matplotlib(字体 PingFang SC)

注意:matplotlib 的 mathtext 标签一律用 raw 字符串,且反斜杠后面只能跟字母
——源码会被 sync-code 原样搬进文章附录,反斜杠后面跟非字母会被体检器判成转义污染。
"""

import os

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

import cfg_gmm as G
import cfg_lab as L

plt.rcParams["font.sans-serif"] = ["PingFang SC", "Arial Unicode MS", "Heiti TC", "sans-serif"]
plt.rcParams["axes.unicode_minus"] = False
plt.rcParams["figure.facecolor"] = "white"
plt.rcParams["axes.facecolor"] = "white"
plt.rcParams["savefig.facecolor"] = "white"
plt.rcParams["font.size"] = 11

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

C_MAIN, C_ALT, C_GREEN, C_GRAY, C_PURPLE = ("#2563eb", "#dc2626", "#059669",
                                            "#6b7280", "#7c3aed")
STEPS = 200
N = 20000


def smooth(H, sigma=1.6):
    """可分离高斯平滑(不依赖 scipy)。"""
    r = int(3 * sigma)
    xs = np.arange(-r, r + 1)
    k = np.exp(-0.5 * (xs / sigma) ** 2)
    k /= k.sum()
    H = np.apply_along_axis(lambda m: np.convolve(m, k, mode="same"), 0, H)
    H = np.apply_along_axis(lambda m: np.convolve(m, k, mode="same"), 1, H)
    return H


def kde_on_grid(X, xs, sigma=2.0):
    """样本 -> 与 grid 同坐标的平滑密度(积分归一)。"""
    n = len(xs)
    lo, hi = xs[0], xs[-1]
    idx = np.clip(((X[:, 0] - lo) / (hi - lo) * n).astype(int), 0, n - 1)
    idy = np.clip(((X[:, 1] - lo) / (hi - lo) * n).astype(int), 0, n - 1)
    H = np.zeros((n, n))
    np.add.at(H, (idy, idx), 1.0)
    H = smooth(H, sigma)
    return H / H.sum()


def fig_geometry():
    """三张等高线:真实条件 / 倾斜目标 / CFG 实际生成。"""
    rng, Xref = G.base_rng()          # 与 cfg_gmm.main 同一条随机流
    xs = np.linspace(-4.2, 4.2, 200)
    GX, GY = np.meshgrid(xs, xs)
    P = np.column_stack([GX.ravel(), GY.ravel()])

    def dens(fn):
        d = np.exp(fn(P))
        d = d.reshape(len(xs), len(xs))
        return d / d.sum()

    D_cond = dens(lambda X: G.logp_cond(X))
    D_un = dens(lambda X: G.logp_data(X))

    w = 7.5
    lp_t = G.tilted_logpdf(P, w)
    lp_t = lp_t - lp_t.max()
    D_tilt = np.exp(lp_t).reshape(len(xs), len(xs))
    D_tilt /= D_tilt.sum()

    Xg = G.ddim_sample(rng.standard_normal((N, 2)), steps=STEPS, w=w)
    D_cfg = kde_on_grid(Xg, xs, sigma=2.2)

    mean_ref = Xref.mean(0)
    mean_cfg = Xg.mean(0)
    lvl_un = np.geomspace(D_un.max() * 1e-4, D_un.max(), 6)

    fig, axes = plt.subplots(1, 3, figsize=(15.2, 5.0))
    panels = [
        ("真实条件分布 $p(x|c)$", D_cond, mean_ref, C_MAIN),
        ("倾斜目标 $p(x|c)^{w}p(x)^{1-w}$", D_tilt, None, C_PURPLE),
        ("CFG 实际采出的分布", D_cfg, mean_cfg, C_ALT),
    ]
    for ax, (title, D, mean, col) in zip(axes, panels):
        ax.contour(xs, xs, D_un, levels=lvl_un, colors=[C_GRAY],
                   linewidths=0.7, alpha=0.55)
        lvl = np.geomspace(D.max() * 2e-3, D.max(), 8)
        ax.contourf(xs, xs, D, levels=lvl, cmap="Blues", alpha=0.85)
        ax.contour(xs, xs, D, levels=lvl, colors=[col], linewidths=1.1)
        ax.plot(G.MU[:, 0], G.MU[:, 1], "k+", ms=11, mew=1.8)
        ax.plot(mean_ref[0], mean_ref[1], "o", color=C_MAIN, ms=9,
                markeredgecolor="white", mew=1.6)
        if mean is not None and not np.allclose(mean, mean_ref):
            ax.plot(mean[0], mean[1], "D", color=C_ALT, ms=8,
                    markeredgecolor="white", mew=1.6)
            ax.annotate("", xy=mean, xytext=mean_ref,
                        arrowprops=dict(arrowstyle="->", color=C_ALT, lw=2.0))
        ax.set_title(title, fontsize=12)
        ax.set_xlim(xs[0], xs[-1])
        ax.set_ylim(xs[0], xs[-1])
        ax.set_aspect("equal")
        ax.grid(alpha=0.15)
        ax.set_xlabel("$x_1$")
        ax.set_ylabel("$x_2$")

    axes[0].text(0.02, 0.03, "灰色细线 = 无条件数据密度\n蓝色圆点 = 真实条件均值",
                 transform=axes[0].transAxes, fontsize=8.5, color=C_GRAY,
                 va="bottom")
    d_bias = float(np.linalg.norm(mean_cfg - mean_ref))
    axes[2].text(0.02, 0.03,
                 "红色菱形 = 生成分布均值\n离真实条件均值 %.2f(数据每维 std 约 0.91)"
                 % d_bias,
                 transform=axes[2].transAxes, fontsize=8.5, color=C_ALT, va="bottom")
    fig.suptitle(r"引导强度 $w=7.5$:样本被推到了哪(同一坐标系,网格 $[-4.2,4.2]^2$)",
                 fontsize=13)
    fig.tight_layout(rect=[0, 0, 1, 0.94])
    fig.savefig(os.path.join(FIGDIR, "cfg_geometry.png"), dpi=130)
    plt.close(fig)
    print("  cfg_geometry.png   类心偏移 %.4f" % d_bias)


def fig_w_sweep():
    """纯度涨了多少、代价涨了多少。"""
    rng, Xref = G.base_rng()          # 与 cfg_gmm.main 同一条随机流
    ws = np.array([0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 5.0, 7.5, 10.0, 15.0, 20.0, 25.0])
    x_T0 = rng.standard_normal((N, 2))
    rows = [G.summarize(G.ddim_sample(x_T0, steps=STEPS, w=float(w)), Xref)
            for w in ws]
    pur = np.array([r["purity"] for r in rows])
    div = np.array([r["div"] for r in rows])
    typ = np.array([r["typicality"] for r in rows])
    bias = np.array([r["bias"] for r in rows])

    fig, ax = plt.subplots(figsize=(9.6, 5.6))
    ax.plot(ws, pur, "o-", color=C_MAIN, lw=2.2, ms=5, label="软纯度 $E[p(c|x)]$")
    ax.plot(ws, div, "s-", color=C_GREEN, lw=2.2, ms=5, label="多样性比(生成方差/真条件)")
    ax.axhline(0.7388, color=C_GRAY, ls=":", lw=1.2)
    ax.text(0.6, 0.752, "真实条件样本 0.7388", color=C_GRAY, fontsize=9)
    ax.set_xlabel(r"引导强度 $w$")
    ax.set_ylabel("纯度 / 多样性比", color="black")
    ax.set_ylim(0.35, 1.35)
    ax.grid(alpha=0.25)
    ax.legend(loc="center left", fontsize=9.5)

    ax2 = ax.twinx()
    ax2.plot(ws, typ, "^--", color=C_ALT, lw=2.2, ms=5,
             label=r"典型度 $\Delta\log p_{\mathrm{data}}$(nats)")
    ax2.plot(ws, bias, "v--", color=C_PURPLE, lw=2.0, ms=5, label="类心偏移")
    ax2.set_ylabel("典型度 / 类心偏移(越负/越大越糟)")
    ax2.legend(loc="center right", fontsize=9.5)

    ax.axvline(1.0, color=C_GRAY, lw=1.0, alpha=0.6)
    ax.axvline(7.5, color=C_ALT, lw=1.2, ls="-.", alpha=0.8)
    ax.text(7.9, 1.30, "SD 系列默认 $w=7.5$", color=C_ALT, fontsize=9.5)
    ax.text(1.1, 1.28, "$w=1$ 就是纯条件模型", color=C_GRAY, fontsize=9)
    fig.suptitle("纯度每涨一点,样本就离真实数据远一点(DDIM %d 步,N=%d)"
                 % (STEPS, N), fontsize=12.5)
    fig.tight_layout(rect=[0, 0, 1, 0.95])
    fig.savefig(os.path.join(FIGDIR, "w_sweep.png"), dpi=130)
    plt.close(fig)
    print("  w_sweep.png       w=7.5: 纯度 %.4f / 多样性 %.4f / 典型度 %.4f / 偏移 %.4f"
          % (rows[8]["purity"], rows[8]["div"], rows[8]["typicality"], rows[8]["bias"]))


def fig_interval():
    """帕累托前沿:花多少「离数据流形的距离」,买多少「条件纯度」。"""
    rng, Xref = G.base_rng()          # 与 cfg_gmm.main 同一条随机流
    x_T0 = rng.standard_normal((N, 2))
    base = G.summarize(G.ddim_sample(x_T0, steps=STEPS, w=1.0), Xref)

    def run(lo, hi):
        m = G.summarize(G.ddim_sample(x_T0, steps=STEPS, w=7.5, interval=(lo, hi)),
                        Xref)
        gain = m["purity"] - base["purity"]
        cost = -(m["typicality"] - base["typicality"])
        return gain, cost, m

    fig, ax = plt.subplots(figsize=(9.8, 6.6))

    # 等性价比参考线(双对数坐标下是斜率 1 的直线);标签放在可见范围内
    xr = np.logspace(-2.4, 0.6, 60)
    for k in (0.1, 0.5, 1.0, 5.0):
        ax.plot(xr, k * xr, "--", color=C_GRAY, lw=0.8, alpha=0.65)
        x_lab = 0.30 / k
        if 6e-3 <= x_lab <= 3.0:
            ax.text(x_lab, k * x_lab * 1.22, "性价比 %.1f" % k, fontsize=8,
                    color=C_GRAY, ha="center", va="bottom", clip_on=True)

    # A 族:区间 = [0, hi],即只在链的后半(低噪声)开
    his = (0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.8, 1.0)
    pts_a = [run(0.0, hi) for hi in his]
    ax.plot([p[1] for p in pts_a], [p[0] for p in pts_a], "-o", color=C_GREEN,
            lw=2.2, ms=8, markeredgecolor="white", mew=1.4,
            label=r"只在低噪声段开:区间 $[0,h]$")
    for hi, (g, c, m) in zip(his, pts_a):
        if hi in (0.1, 0.2, 0.5, 1.0):
            ax.annotate("$h$=%.1f\n性价比 %.2f" % (hi, g / c), (c, g),
                        textcoords="offset points", xytext=(-64, 6),
                        fontsize=9, color=C_GREEN)
        print("  区间 [0.0,%.1f] 纯度增益 %+.4f 典型度代价 %.4f 性价比 %.3f"
              % (hi, g, c, g / c))

    # B 族:区间 = [lo, 1],即只在链的前半(高噪声)开
    los = (0.9, 0.8, 0.7, 0.6, 0.5, 0.4, 0.3, 0.2, 0.1, 0.0)
    pts_b = [run(lo, 1.0) for lo in los]
    ax.plot([p[1] for p in pts_b], [p[0] for p in pts_b], "-s", color=C_MAIN,
            lw=2.2, ms=7, markeredgecolor="white", mew=1.4,
            label=r"只在高噪声段开:区间 $[l,1]$")
    for lo, (g, c, m) in zip(los, pts_b):
        if lo in (0.9, 0.5, 0.0):
            ax.annotate("$l$=%.1f\n性价比 %.2f" % (lo, g / c), (c, g),
                        textcoords="offset points", xytext=(10, -14),
                        fontsize=9, color=C_MAIN)
        print("  区间 [%.1f,1.0] 纯度增益 %+.4f 典型度代价 %.4f 性价比 %.3f"
              % (lo, g, c, g / c))

    ax.set_xscale("log")
    ax.set_yscale("log")
    ax.set_xlabel(r"代价:典型度损失 $-\Delta\log p_{\mathrm{data}}$(nats)")
    ax.set_ylabel(r"收益:软纯度增益")
    ax.set_xlim(6e-3, 4.0)
    ax.set_ylim(6e-3, 0.4)
    ax.grid(alpha=0.25, which="both")
    ax.legend(loc="lower right", fontsize=10)
    fig.suptitle(r"引导区间的帕累托前沿($w=7.5$,DDIM %d 步)" % STEPS, fontsize=12.5)
    fig.tight_layout(rect=[0, 0, 1, 0.955])
    fig.savefig(os.path.join(FIGDIR, "interval.png"), dpi=130)
    plt.close(fig)
    print("  interval.png")


def fig_cost_ledger():
    """2x 账本 + w 对误差的放大。"""
    rng, _ = G.base_rng()             # 与 cfg_gmm.main 同一条随机流
    mac0, act0, _ = L.ledger(with_cfg=False)
    mac1, act1, _ = L.ledger(with_cfg=True)

    fig, axes = plt.subplots(1, 3, figsize=(15.0, 4.6))

    # (a) 账本
    ax = axes[0]
    labels = ["单步 MAC", "层间张量\n(fp16)", "50 步的前向\n次数"]
    v0 = [mac0 / 1e11, act0 / 1024 ** 3, 50.0]
    v1 = [mac1 / 1e11, act1 / 1024 ** 3, 100.0]
    x = np.arange(3)
    ax.bar(x - 0.19, v0, 0.36, color=C_MAIN, label="CFG off")
    ax.bar(x + 0.19, v1, 0.36, color=C_ALT, label=r"CFG on($w>1$)")
    for i, (a, b) in enumerate(zip(v0, v1)):
        ax.text(i - 0.19, a, "%.2f" % a, ha="center", va="bottom", fontsize=8.5)
        ax.text(i + 0.19, b, "%.2f" % b, ha="center", va="bottom", fontsize=8.5)
    ax.set_xticks(x)
    ax.set_xticklabels(labels, fontsize=9.5)
    ax.set_yscale("log")
    ax.set_ylabel("(MAC 单位 1e11,显存单位 GB)")
    ax.set_title("那个 2 倍:定义级精确", fontsize=12)
    ax.legend(fontsize=9)
    ax.grid(alpha=0.2, axis="y")

    # (b) 误差放大
    ax = axes[1]
    ws = np.array([1.0, 2.0, 3.0, 5.0, 7.5, 10.0, 15.0, 20.0])
    probe = G.sample_data(rng, 20000)
    for t, col in ((1000, C_MAIN), (200, C_GREEN), (10, C_ALT)):
        a = G.abar(t)
        Xt = np.sqrt(a) * probe + np.sqrt(1 - a) * rng.standard_normal(probe.shape)
        ec = G.weak_eps_hat(Xt, t, G.LOGW_COND) - G.eps_hat(Xt, t, G.LOGW_COND)
        eu = G.weak_eps_hat(Xt, t, G.LOGW_UNCOND) - G.eps_hat(Xt, t, G.LOGW_UNCOND)
        base = np.linalg.norm(ec, axis=1).mean()
        rat = [np.linalg.norm(w * ec + (1 - w) * eu, axis=1).mean() / base
               for w in ws]
        ax.plot(ws, rat, "o-", color=col, lw=2.0, ms=5, label="实测 $t=%d$" % t)
    ax.plot(ws, 2 * ws - 1, "k--", lw=1.6, label=r"最坏上界 $2w-1$")
    ax.set_xlabel(r"引导强度 $w$")
    ax.set_ylabel("去噪误差被放大的倍数")
    ax.set_title("两个分支的误差也一起被放大", fontsize=12)
    ax.legend(fontsize=9)
    ax.grid(alpha=0.25)

    # (c) 幅度膨胀
    ax = axes[2]
    Xb = G.sample_data(rng, 8000, cls=G.TARGET_CLS)
    a = G.abar(300)
    Xp = np.sqrt(a) * Xb + np.sqrt(1 - a) * rng.standard_normal(Xb.shape)
    e_c = G.eps_hat(Xp, 300, G.LOGW_COND)
    e_u = G.eps_hat(Xp, 300, G.LOGW_UNCOND)
    wgrid = np.linspace(0, 25, 120)
    infl = np.array([(e_u + w * (e_c - e_u)).std() / e_c.std() for w in wgrid])
    ax.plot(wgrid, infl, "-", color=C_PURPLE, lw=2.4)
    for w in (1.0, 7.5, 15.0):
        v = float((e_u + w * (e_c - e_u)).std() / e_c.std())
        ax.plot([w], [v], "o", color=C_ALT, ms=7)
        ax.annotate("$w$=%.1f:%.2f 倍" % (w, v), (w, v),
                    textcoords="offset points", xytext=(8, -12), fontsize=9)
    ax.axhline(1.0, color=C_GRAY, ls=":", lw=1.2)
    ax.set_xlabel(r"引导强度 $w$")
    ax.set_ylabel(r"$\mathrm{std}(\varepsilon_{\mathrm{guided}})/\mathrm{std}(\varepsilon_{\mathrm{cond}})$")
    ax.set_title("预测幅度膨胀(过曝的机制)", fontsize=12)
    ax.grid(alpha=0.25)

    fig.tight_layout()
    fig.savefig(os.path.join(FIGDIR, "cost_ledger.png"), dpi=130)
    plt.close(fig)
    print("  cost_ledger.png    MAC %.3e -> %.3e" % (mac0, mac1))


def main():
    print("画图(数据源:cfg_gmm.py / cfg_lab.py 的真实输出)")
    fig_geometry()
    fig_w_sweep()
    fig_interval()
    fig_cost_ledger()
    print("输出目录:%s" % FIGDIR)


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

评论 (0)

取消
粤ICP备2021042327号