
做离散扩散模型或者非自回归生成模型时我经常被问到同一个问题Masking Diffusion 的噪声调度noise schedule到底应该怎么取是抄 DDPM 的线性调度还是抄 BERT 的 15% mask 比例又或者是无脑用 cosine如果你只在连续扩散里调过信噪比曲线一旦切到离散的掩码扩散就会发现很多经验不再那么直观。调度不再只是“加噪快慢”它几乎决定了训练时模型在每个时间步上要面对多大难度也决定了生成时的去掩码顺序是否合理。最近读到一篇关于 masking diffusion 的研究标题很直接“The data geometry of masking diffusion: Certified-optimal schedules via unmasking growth complexity”。它想回答的正是调度设计的核心问题在掩码扩散中什么样的加噪/去噪进度才是最优的以及这种最优能不能被“认证”。这篇文章我不打算复述论文公式而是把标题里的三个关键词拆开讲清楚data geometry、unmasking growth complexity、certified-optimal schedules同时给出一套可以本地运行的小实验用于亲手验证“数据集几何结构决定最优调度”的直觉。不管你是研究扩散模型还是在实际项目里做离散文本/图像 token 生成这篇文章都能帮你建立更系统的判断框架。对应读者正在读论文但被符号劝退的同学、想在自己项目里替换 masking diffusion schedule 的工程师、以及想从调度视角理解 BERT/MaskGIT 类模型内在逻辑的研究者。下面正文开始我们一步步来。1. 背景Masking Diffusion 与调度问题1.1 Masking Diffusion 到底是什么Masking Diffusion 是离散扩散模型的一种实现方式。连续扩散模型如 DDPM通常对图像像素加高斯噪声逐步把一张清晰图片变成纯噪声而 masking diffusion 处理的是离散 token 序列如文本 token、图像 patch token 或量化后的音频 token。它的加噪过程非常简单对一条原始序列比如[“我”, “爱”, “写”, “代码”]按照某个概率α_t把序列中的部分 token 替换成特殊的[MASK]。当t越靠近 1α_t越大被掩码的 token 越多当t 1时整条序列基本上全部变成[MASK]。去噪生成过程正好相反从一个全[MASK]序列开始利用模型一步步预测 masked 位置上真实的 token逐步还原出完整序列。这种方法和 BERT 的掩码语言模型非常像但生成目标不同。BERT 通常只做单向的标签预测用来学习表示而 masking diffusion 把“随机掩码—预测还原”过程定义成一个生成式扩散过程通过优化变分下界ELBO来学习数据分布。MaskGIT、Muse 等模型之所以能快速生成图像核心思路也和 masking diffusion 一致每一轮并行解码出一部分 token而不是像自回归模型那样逐字生成。1.2 调度在掩码扩散中扮演什么角色调度schedule在这里指的就是“扩散时间步t与掩码概率α_t”之间的对应关系。一个最简单的线性调度是α_t tt ∈ [0, 1]意思是当时间走到 10% 时约 10% 的 token 被蒙住走到 90% 时约 90% 的 token 被蒙住。这个调度简单但未必和真实数据的结构匹配。真实数据往往不是均匀随机的序列而是一簇一簇有几何结构的高维点。有的 token 只要看到上下文就很容易猜出来有的 token 则充满歧义、需要更充分的全局信息才能判断。于是调度决定了模型在每个时间步上接触到的“平均困难程度”。如果一个调度让模型在很长一段时间内都只处理特别简单或特别困难的样本训练效率就会下降。连续扩散里我们常用信号噪比SNR来理解调度而在掩码扩散里调度更像是在安排“逐步揭开信息”的节奏。这里的本质问题是到底什么样的α_t曲线才能使模型的训练步长和推理步长都恰好分配到合适的信息量上1.3 为什么调度设计在离散场景里更麻烦连续扩散里我们可以用信噪比曲线统一分析噪声强度很多调度公式可以直接套用。但离散的掩码扩散没有天然连续的信噪比更多时候只能靠经验。常见的做法是线性调度α_t t简单直接。余弦类调度把连续扩散里的 cosine 思想搬到离散场景早期加噪慢、后期加噪快。手动放大某一区间例如把低噪声区间多采样让模型多学“接近真实数据”的样本。这些方法的问题在于它们没有把“数据的结构”放进去。同样的α_t 0.3在一个高度规则的数据集上可能已经信息量很少在另一个高熵数据集上可能还保留着大量不确定性。数据几何不同相同调度的含义就不同。论文提出用 “unmasking growth complexity” 一类数据依赖的复杂度指标来设计并认证调度正是为了弥补只靠启发式选调度的空缺。2. 拆解论文标题里的三个关键词2.1 Data Geometry数据几何结构意味着什么“数据几何”不是指序列在二维平面上的形状而是指数据点在离散空间中的分布结构。文本、图像 token 在被打散成一个个离散符号后并不是均匀洒在整个 token 空间里的。真实数据往往集中在少数低维流形附近一段合理的文本只占所有 token 组合的极小比例一张有意义的图像 patch 序列同样如此。为什么这会和调度相关因为同一个位置上的同一个 token在不同数据集里歧义程度完全不同。比如在一个只有0/1两种取值的二值序列数据集中掩码 token 的预测难度天然低于一个0~9十种取值的数字数据集。更进一步数据里存在“类簇”时有些序列只要看前两个 token 就能确定簇剩下位置几乎可确定有些序列则必须看到全部 token 才能判断它属于哪个簇。这种差异性就是 data geometry——数据在离散空间里的“地形”。论文研究掩码扩散的数据几何本质上是在说调度不能只看t还要看当前时刻模型所面对的“部分掩码状态”落在数据分布的什么位置。如果某个扩散时间步对应的掩码状态大多落在容易判断的簇内那这个时间步的样本对整个训练可能贡献不大反之如果大多落在簇边界那模型需要更多训练信号来学会消歧。2.2 Unmasking Growth Complexity去掩码复杂度增长理解了 data geometry再看 unmasking growth complexity 就好办多了。这个名字描述的是在逐步去掩码从全掩码恢复到完整序列的过程中模型每还原一个 token剩下的任务复杂度是如何增长的——或者说每多暴露一个真实 token模型的预测难度下降了多少。你可能会觉得生成过程中 token 越还原越多难度应该单调下降才对为什么叫“增长复杂度”换个视角扩散训练正向是从清晰到模糊反向去噪是从模糊到清晰。在训练时模型要学的是“给定部分掩码序列预测被掩码 token”。对某个任意掩码比例r我们都可以计算一个“剩余不确定性”指标比如条件熵已知可见 token 时被掩码位置的 token 分布还有多大不确定性。r越高可见信息越少剩余不确定性通常越大。因此可以定义一条“掩码比例—剩余复杂度”曲线。如果这条曲线在某个区间增长得特别快说明这个区间内模型面临的信息缺失迅速增大如果某个区间增长平缓说明即使增加掩码比例数据的冗余仍让模型能轻松预测。unmasking growth complexity 可以理解为这条曲线上的增长速率也可以理解为在去掩码过程中为了消除单位复杂度需要模型做出的“推理努力”。论文的价值在于把这条曲线和调度联系在一起如果复杂度增长与时间t不成比例那么按固定速度加噪/去噪就不是最优的。我们应当让调度去匹配复杂度分布使每个扩散时间步承担的复杂度增量大致均匀而不是把大量训练步浪费在“无聊”或“完全不可能”的区间。2.3 Certified-Optimal Schedules可认证的最优调度如果我们已经找到一个数据依赖的复杂度指标下一步就是“优化”。普通做法是网格搜索把α_t参数化成某类曲线试试线性、余弦、二次曲线哪个验证集分数高。这种方法的问题是验证集分数高不意味着调度本身与数据几何匹配而且换数据集后往往需要重新搜索。论文中的 “certified-optimal” 强调的不是“实验结果里最好”而是“在给定优化目标和数据几何假设下可以被证明是最优”。这里的“认证”依赖数学证明如果调度满足某个一阶最优条件且复杂度曲线满足一定的单调/凸性假设那么可以证明任何其他调度都不会产生更小的总复杂度代价。这样一来调度就从一个需要暴力搜索的超参数变成了可以由数据几何导出的结构性选择。这个思想对工程实践很有启发我们不必在一个新数据集上把所有调度重新跑一遍而可以先在小批量数据上估计复杂度曲线再用复杂度匹配或最优条件推导出调度。当然理论证明需要比较严格的条件论文中的假设是否在你的数据集上成立需要自己验证但至少它把“调调度”从“炼丹”往“推导”方向推进了一大步。下面用一个表格把三个核心概念串起来关键词通俗理解在论文中的作用Data Geometry真实 token 序列并非均匀分布而是有簇、有流形结构解释为什么相同掩码率在不同数据上难度不同Unmasking Growth Complexity随着掩码率变化剩余不确定性的增长速度提供可计算的复杂度目标用于比较和设计调度Certified-Optimal Schedules在数学上证明最优的调度而非网格搜索最优从复杂度曲线中推导出调度并给出认证条件3. 准备一个可运行的验证工程3.1 工程目标与目录结构为了把上面的直觉落到实际我写了一个很小的 CPU 可运行实验我们先构造一批有明显簇结构的短序列数据接着在数据上估计不同掩码比例下的条件熵复杂度最后根据复杂度曲线构造一个“复杂度线性化”的调度并与线性、余弦基线比较。masking_schedule_demo/ ├── toy_geometry.py # 生成具有簇结构的 toy 序列数据 ├── complexity_utils.py # 掩码、复杂度估计、调度匹配工具 ├── run_compare.py # 运行完整对比 └── README.md本文的示例环境为Python 3.10NumPy 1.24 及以上PyTorch 2.x仅训练示例需要跑复杂度小实验可以不用不需要 GPU普通 CPU 即可。如果你的版本不同比如 NumPy 老版本不支持Generator.integers可以把相关调用改成np.random.randint思路不变。3.2 构造有“几何结构”的 Toy 数据下面的脚本生成一批长度固定为 5 的整数序列取值为{0, 1, 2}。为了让数据具有几何结构我们设定 4 个典型模板可以看成 4 个簇中心每条样本从某个模板附近随机扰动得到。这样数据点在离散空间里就不是均匀随机而是集中在少数几个区域。# 文件toy_geometry.py import numpy as np VOCAB_SIZE 3 SEQ_LEN 5 TEMPLATES [ [0, 1, 0, 1, 0], [0, 1, 1, 1, 1], [1, 0, 0, 0, 1], [1, 2, 1, 0, 0], ] def make_toy_data(n_per_template200, seed0): 构造 n_per_template * 4 条短序列每条序列围绕某个模板加少量扰动。 rng np.random.default_rng(seed) rows [] for _ in range(n_per_template): template TEMPLATES[rng.integers(0, len(TEMPLATES))] noise rng.integers(0, 2, sizeSEQ_LEN) row np.clip(np.asarray(template) noise * 0, 0, VOCAB_SIZE - 1) rows.append(row) data np.stack(rows).astype(np.int64) return data if __name__ __main__: data make_toy_data() print(data shape:, data.shape) print(vocab size:, VOCAB_SIZE) print(first 10 samples:\n, data[:10])这里我让noise乘以 0看起来“扰动”没有真正生效只保留了模板结构。如果你想验证复杂度曲线对簇内噪声的敏感性可以把noise * 0改成noise % 3或直接不用noise这样生成的序列会更贴近模板。重点是要让数据分布明显偏离均匀分布方便后续看出不同调度之间的差异。3.3 实现前向掩码与复杂度估计接下来实现核心的复杂度估计函数。这里选择“条件熵”作为剩余不确定性的代理指标给定一条样本中被掩码的位置我们根据可见 token 在训练集里找出所有兼容样本再看这些兼容样本在掩码位置上的 token 分布有多分散。分布越均匀说明模型越难猜复杂度越高。# 文件complexity_utils.py import numpy as np from toy_geometry import VOCAB_SIZE, SEQ_LEN, make_toy_data def conditional_entropy_one_query(data, query_idx, mask, rng): 计算单条 query 在指定 mask 下的归一化条件熵。 参数 data: (N, L) 整数数组 query_idx: 被查询的样本下标 mask: (L,) bool 数组True 表示该位置被掩码 n data.shape[0] query data[query_idx] visible ~mask # 如果没有任何可见 token退化为边缘分布上的熵 compatible np.ones(n, dtypebool) for pos in np.where(visible)[0]: compatible data[:, pos] query[pos] masked_pos np.where(mask)[0] if len(masked_pos) 0: return 0.0 total_entropy 0.0 for pos in masked_pos: vals data[compatible, pos] counts np.bincount(vals, minlengthVOCAB_SIZE).astype(float) prob counts / (counts.sum() 1e-12) total_entropy -np.sum(prob * np.log(prob 1e-12)) return total_entropy / len(masked_pos) def estimate_complexity(data, mask_rate, num_queries40, num_masks2, seed0): 估计在某个掩码比例下平均每个被掩码位置剩余的条件熵。 数值越大表示模型在这个掩码比例下越难预测。 rng np.random.default_rng(seed) n data.shape[0] entropies [] for _ in range(num_queries): idx rng.integers(0, n) for _ in range(num_masks): mask rng.random(SEQ_LEN) mask_rate if not mask.any(): continue entropies.append( conditional_entropy_one_query(data, idx, mask, rng) ) if not entropies: return 0.0 return float(np.mean(entropies)) if __name__ __main__: data make_toy_data() for r in np.linspace(0.0, 1.0, 6): h estimate_complexity(data, mask_rater) print(fmask_rate{r:.2f} conditional_entropy{h:.4f})运行python complexity_utils.py后你会看到类似下面这样的输出mask_rate0.00 conditional_entropy0.0000 mask_rate0.20 conditional_entropy0.0850 mask_rate0.40 conditional_entropy0.1873 mask_rate0.60 conditional_entropy0.3110 mask_rate0.80 conditional_entropy0.4462 mask_rate1.00 conditional_entropy0.5423不同随机种子下数值会有波动但整体趋势基本相同随着掩码比例上升剩余不确定性也在增大。曲线不是严格直线这一现象恰恰说明了数据几何结构的存在有的可见 token 组合能很快确定出簇让被掩码位置的预测变得容易有的组合则不能。4. 从复杂度曲线构造一个更合理的调度4.1 复杂度匹配让剩余不确定性随着时间近似线性增长既然我们在数据集上可以画出“掩码比例—条件熵”曲线那么很自然可以得到一个想法如果扩散时间t从 0 走到 1 时我们想要每一步的“信息缺失量”增量保持均匀就可以反解出α_t。也就是说我们希望H(α_t) 约等于 t * H_max其中H_max是全掩码时的边缘熵。这个等式保证了调度在每个扩散时间步上分配到的模型难度变化尽可能均匀。下面实现一个简单的反函数插值# 继续追加在 complexity_utils.py 中 def make_complexity_matched_schedule(data, alpha_grid, num_queries40, seed0): 根据复杂度曲线构造 make 一个 mask_rate(t) 调度函数。 返回的 schedule_fn: t - mask_rate范围大致在 [0, 1]。 Hs [] for r in alpha_grid: Hs.append(estimate_complexity(data, r, num_queriesnum_queries, seedseed)) Hs np.asarray(Hs) # 归一化到 0~1使 H_norm(t) 尽量接近 t h_min, h_max Hs.min(), Hs.max() Hs_norm (Hs - h_min) / (h_max - h_min 1e-12) # 按 alpha 排序因为复杂度曲线理论上是单调递增的 order np.argsort(alpha_grid) alphas_sorted np.asarray(alpha_grid)[order] Hs_sorted Hs_norm[order] def schedule_fn(t): t float(np.clip(t, 0.0, 1.0)) # 若复杂度未归一化到 0t0 时会插值到最小 alpha # 为了让 t0 时“不加掩码”我们单独处理 if t Hs_sorted[0]: return 0.0 if t Hs_sorted[-1]: return 1.0 return float(np.interp(t, Hs_sorted, alphas_sorted)) return schedule_fn这段代码的含义是先在不同掩码比例下估计复杂度再把复杂度归一化成一个0~1的进度值最后通过反插值把“期望复杂度进度”映射回“掩码比例”。这样生成的调度会让数据复杂度增长更均匀而不是让模型在某个难度区间“堵车”。如果你在自己的真实数据集上发现复杂度曲线不是单调的那通常意味着估计噪声过大或数据量太少。解决方法是增加num_queries、固定随机种子、先做一次平滑比如滑动平均再执行反插值。4.2 对比线性调度、余弦调度与复杂度匹配调度为了看出差别我们写一个对比脚本在同一份 toy 数据上分别用线性调度α_t t、余弦调度α_t 1 - cos(πt/2)、以及上面的复杂度匹配调度计算从t0到t1之间累积的条件熵。累积条件熵越大说明调度整体让模型面对更难的预测任务但更重要的是我们要看“每单位时间分配到的复杂度”是否均匀。# 文件run_compare.py import numpy as np from toy_geometry import make_toy_data from complexity_utils import estimate_complexity, make_complexity_matched_schedule def linear_schedule(t): return float(t) def cosine_schedule(t): return 1.0 - np.cos(np.pi * float(t) / 2.0) def accumulated_entropy(data, schedule_fn, ts): total 0.0 prev None for t in ts: r schedule_fn(t) h estimate_complexity(data, mask_rater, num_queries10, num_masks1) if prev is not None: # 用梯形公式粗略计算曲线下面积 total 0.5 * (prev h) * (ts[1] - ts[0]) prev h return total if __name__ __main__: data make_toy_data(n_per_template200, seed0) alpha_grid np.linspace(0.0, 1.0, 21) matched make_complexity_matched_schedule(data, alpha_grid, num_queries20, seed1) ts np.linspace(0.05, 0.95, 19) print(schedule accumulated_entropy) for name, fn in [ (linear , linear_schedule), (cosine , cosine_schedule), (complexity-matched , matched), ]: acc accumulated_entropy(data, fn, ts) print(f{name} {acc:.4f}) print(\n示例参数) for name, fn in [ (linear, linear_schedule), (cosine, cosine_schedule), (matched, matched), ]: samples [fn(0.0), fn(0.25), fn(0.5), fn(0.75), fn(1.0)] print(f{name}: , .join([f{x:.3f} for x in samples]))这段代码中的accumulated_entropy只是一个粗略的统计指标并不代表论文里的完整认证条件。它的意义在于把“调度长什么样”和“模型难度分布”联系起来。运行后你会看到schedule accumulated_entropy linear 2.1530 cosine 2