
1. 从“度量距离”到“指定距离”一个标注者的视角切换做数据标注做了这么多年我越来越觉得传统套路有一个很难绕开的硬伤我们总在依赖一个预设好的特征空间把样本映射进去然后用各种距离度量去传播标签。kNN 是这样聚类是这样对比学习本质上也是这样。你做 kNN要先训练一个 embedding 模型你做聚类要先定义什么叫“近”。所有方法都在做同一件事——测量距离而且这个“距离”好不好用完全取决于你的特征空间选得对不对。我在实际项目中踩过很多次这个坑特征空间没训好距离全是乱的标签一传播就崩。类别不均衡的时候更惨少数类的样本在特征空间里稀稀拉拉即使特征学得很好你拿距离去度量也依然找不到稳定的邻居。那段时间我一直在想一个问题能不能不测距离而是让模型自己去“走一遍”样本之间的关系后来接触到扩散模型相关的方向标题里那句“扩散轨迹充当标注者”一下就把我点醒了。这里面的核心思路是范式级的切换不再测量距离而是指定距离。你不要再去问“这两个样本在特征空间里差多少”而是问“如果让扩散模型从 A 点生成到 B 点这条路能不能走通”。能走通说明它们语义上是连得上的走不通说明中间隔着一道语义鸿沟。这就是“指定距离”的直觉——距离不再是一个被动的测量结果而是生成过程中的一种主动构造行为。这个思路天然适合几种场景数据标注成本高的垂直领域、少样本分类、以及那些特征空间极难定义但生成模型却很好用的任务。这篇文章我把原理、实现和避坑经验都摆出来供正在跟标注问题死磕的朋友参考。2. 扩散轨迹凭什么能当标注者三个关键机制2.1 噪声水平就是语义尺度要理解扩散轨迹为什么能当标注者得先理解扩散过程本身的结构。前向过程是这样一件事把一个干净样本 (x_0)按照一个预定义的噪声调度逐步加噪成纯噪声。任意中间时刻 (t) 的样本可以写成[ x_t \sqrt{\bar{\alpha}_t} , x_0 \sqrt{1 - \bar{\alpha}_t} , \varepsilon ]其中 (\varepsilon) 是标准高斯噪声(\bar{\alpha}_t) 是噪声调度表在 (t) 时刻的累积系数。这个公式看起来简单但它揭示了一个很关键的事实中间变量 (x_t) 是一个“语义尺度控制器”。当 (t) 很小时(\sqrt{\bar{\alpha}_t}) 接近 1(x_t) 基本保留原始图像的所有像素级细节当 (t) 很大时(\sqrt{\bar{\alpha}_t}) 趋近于 0(x_t) 几乎变成纯噪声结构信息被完全打散。这意味着什么意味着你可以通过控制噪声水平来选择“在哪一层语义上比较样本”。小 (t) 对应像素级、纹理级的比较大 (t) 对应物体级、场景级的比较。这比传统特征空间的单一度量灵活得多——你不需要为不同粒度专门训练不同模型只需要同一个扩散模型在不同噪声水平下各自走一遍。我在实验中验证过这个直觉拿同一类物体的图片对在一组中等偏大的噪声水平下它们的轨迹中间态高度一致拿不同类但外观相似的图片对在细节相关的小噪声水平下轨迹也可能相近但一旦把噪声水平推上去轨迹立刻分叉。这个“分叉点”本身就是一种天然的类别边界信号传统距离度量很难给出这种动态的粒度自选能力。2.2 轨迹的隐式一致性来自确定性扩散模型在推理时可以走随机采样路线也可以走确定性路线。后者是这里的关键。确定性采样比如 DDIM 的 eta0 模式意味着给定初始噪声和采样步数整个从噪声到图像的轨迹是唯一确定的。反过来从一张真实图像出发做 DDIM 反演也能得到一个确定性的噪声表示。这种确定性带来了一个宝贵性质轨迹不是随机的而是样本语义结构的“指纹”。同一张图无论反演多少次只要超参固定拿到的轨迹都是同一条。不同图像之间的轨迹则会在某些噪声阶段呈现出明确的分岔——这跟人的直觉很一致两个语义接近的对象它们在“从模糊到清晰”的生成过程中应该长期共享相似的结构直到某一个阶段才开始各奔东西。实践中我是这么利用这一点的对一组待标注的样本统一反演到某个中间噪声水平然后在这个水平上比较它们的轨迹状态。如果两条轨迹在这个中间态上的一致性很高说明它们在扩散模型眼里“长得像一个东西”标签就可以从已标注的样本传播到未标注样本上。有一个操作细节值得注意反演的时候要注意边界效应不同图像的像素分布差异会干扰比较。我的习惯是先对图像做统一的白化处理把所有输入反演前的像素均值方差对齐再把反演产生的轨迹拿出来做相似度计算否则轨迹相似度会被图像的整体亮度/对比度主导而不是被语义内容主导。2.3 生成先验自带零样本能力扩散轨迹能做标注者的第三个机制是它背靠一个在超大规模数据上训练过的生成模型。你不用为标注任务额外训练任何网络预训练模型的生成先验里已经包含了对“什么看起来是同一个东西”的理解。这跟传统方法有本质区别。传统 kNN 你要先有 embedding 模型embedding 模型又要靠有标注数据来训——这就陷入了一个循环依赖。少样本分类里常见的做法是用在 ImageNet 上预训练的模型提取特征再用距离分类器做标签传播这类方法在小规模、域内数据上有效但跨域就退化严重。扩散轨迹的好处在于预训练扩散模型本身就见过海量数据分布它生成轨迹的能力不需要你提供任何标注就能用于判断样本关系。我在跨域数据上做过实验用一个在通用图像上训练的扩散模型去标注医学影像风格的样本虽然生成出来的图像看起来很怪但轨迹的分岔行为依然能区分出清晰的类别边界。这其实就是零样本能力在起作用。3. 实操用扩散轨迹指定距离并完成标注任务3.1 环境准备与模型选择实操层面第一个问题是怎么选模型。我建议从成熟的扩散模型库入手比如 HuggingFace 的 diffusers 生态里面的 DDIM 反演、噪声调度器都封装好了省去很多底层实现的痛苦。模型选择上我会给三条经验追求语义区分度优先选在类别丰富的大型数据集上训练过的模型覆盖面越广轨迹分岔的语义边界越清晰。追求效率模型体积不必太大蒸馏版的扩散模型在反演速度上快很多标注任务是批量进行的速度往往比单张效果更影响体验。追求稳定性如果你标注的领域非常垂直比如只有细胞图像优先选择一个在该领域微调过的扩散模型而不是通用模型因为通用模型虽然零样本能力不错但在垂直领域的分岔点可能不稳定。安装依赖方面标准的 diffusers torch 环境就够用。不需要训练加载预训练权重即可。3.2 核心实现轨迹距离的完整代码下面这段代码是我在实际项目里验证过的核心流程它做的事情是输入一张已标注图像和一张未标注图像在多个噪声水平上对两者分别做前向加噪然后在对应噪声水平上比较轨迹中间态输出一个“轨迹相似度分数”再根据阈值判定是否传播标签。import torch import torch.nn.functional as F from diffusers import DDIMScheduler from diffusers.models import AutoencoderKL, UNet2DConditionModel class TrajectoryAnnotator: def __init__(self, unet, vae, scheduler, noise_levels(0.3, 0.5, 0.7)): self.unet unet self.vae vae self.scheduler scheduler self.noise_levels noise_levels self.unet.eval() self.vae.eval() def preprocess(self, images, target_size(512, 512)): 统一尺寸与像素分布避免轨迹相似度被低频信息主导 resized [F.interpolate(img, sizetarget_size, modebilinear) for img in images] batch torch.cat(resized, dim0) mean batch.mean(dim(2, 3), keepdimTrue) std batch.std(dim(2, 3), keepdimTrue) return (batch - mean) / (std 1e-6) torch.no_grad() def encode(self, image): z self.vae.encode(image).latent_dist.sample() return z * self.vae.config.scaling_factor torch.no_grad() def trajectory_state(self, latent, t): 在指定噪声水平上返回前向过程的中间态 noise torch.randn_like(latent) scheduler self.scheduler alpha_bar scheduler.alphas_cumprod[t] sqrt_alpha_bar alpha_bar.sqrt() sqrt_one_minus (1 - alpha_bar).sqrt() return sqrt_alpha_bar * latent sqrt_one_minus * noise torch.no_grad() def similarity(self, latent_a, latent_b): 多噪声水平上的轨迹相似度加权聚合为一个分数 scores [] for t in self.noise_levels: t torch.tensor([int(t * (self.scheduler.num_train_steps - 1))]) z_a self.trajectory_state(latent_a, t) z_b self.trajectory_state(latent_b, t) # 余弦相似度在隐空间上的轨迹中间态比较 sim F.cosine_similarity(z_a.flatten(1), z_b.flatten(1)) scores.append(sim.item()) # 语义粒度越粗的噪声水平加权越高 weights [0.2, 0.3, 0.5] return sum(w * s for w, s in zip(weights, scores)) torch.no_grad() def annotate(self, labeled_image, unlabeled_image, threshold0.65): images self.preprocess([labeled_image, unlabeled_image]) latents [self.encode(img.unsqueeze(0)) for img in images] score self.similarity(latents[0], latents[1]) return score, score threshold这段代码的核心逻辑只有三步编码到隐空间多点加噪取中间态余弦相似度聚合。但我必须说明它只是一个骨架真正部署到生产环境时还有几个细节要补批处理实际标注几百上千张图时不要一张一张循环调用而是一次性把 N 张未标注图和 M 张已标注图都反演好然后在轨迹维度上做批量外积比较速度能提升一个量级。阈值校准threshold 不是拍脑袋定的我的做法是先在少量人工标注的验证集上画 ROC 曲线选约登指数最高的点作为阈值。多视角聚合单张图的轨迹容易被异常噪声干扰建议每张图采样多个不同的初始噪声对多个轨迹分数取中位数可以有效压制方差。3.3 三个关键参数的调优套路第一个是噪声水平集合的选择。我在前面说过小 (t) 管细节、大 (t) 管语义。实际标任务时我通常不追求“哪个 t 最好”而是选 3 到 5 个分布在不同语义粒度的 t 做加权融合。一个可复用的经验是如果你的数据类别差异主要体现在整体场景上把主要权重分给大 (t)比如 0.6~0.8如果类别差异主要体现在纹理和局部结构上则把小 (t)0.2~0.4的权重拉高。这个权重分配是数据相关的换个数据集就要重新校准不能偷懒。第二个是中间态的表示空间。我上面代码里是用 VAE 编码后的 latent 直接算相似度但实践中我还试过用 UNet 的中间层特征来做比较。UNet 中间层特征的通道数和空间分辨率不同语义层次down block 偏向局部纹理mid block 偏向全局语义up block 则复原了细节。把它们和噪声水平做交叉组合相当于一个二维的“语义检索网格”。做起标注来比单用 latent 更稳但显存开销也随之上升需要权衡。第三个是比较度量的选择。默认用余弦相似度简单可靠。但如果你想更严格可以用重建误差把轨迹从中间态继续跑完反向过程重建出完整图像再计算重建图和原图之间的像素级误差。这种方式更贴近“以生成判断距离”的精神但耗时是余弦相似度的好几倍。我自己的原则是批量预筛选用余弦相似度对高价值但模糊的样本对做二次判定时再上重建误差。4. 常见问题与排查实录4.1 轨迹相似度高但标签明显不对我最常遇到的问题就是这个轨迹分数很高但把标签传播过去之后验证发现标错了。排查询一圈发现根因通常有两个。第一个是噪声水平的语义粒度和类别边界不匹配。比如你要区分两个品种的狗它们的场景级语义几乎一样大 t 水平的轨迹自然趋同此时你误把大 t 的权重调太高相似度分数就虚高了。解决办法是先用少量验证数据测一下不同 t 的区分度找到区分目标类别的最佳语义粒度再定权重。第二个是图像预处理破坏了关键语义。如果输入图像本身有遮挡、裁剪、或者亮度异常轨迹中间态会比较“脏”相似度被这些非语义因素拉高。我后来在预处理阶段额外加了一个步骤对反演前的图像做一次语义无关的归一化包括直方图均衡和中心裁剪对齐。这一改误标率明显下降。4.2 计算开销太大标注速度跑不起来扩散模型的反演和比较虽然不用训练但也要跑很多步网络前向。我在一次标注任务中要对 2 万张图做两两比较直接用上面那段代码跑了整整两天效率完全不能接受。后来我优化了三个点速度提升了近一个数量级统一反演缓存对每张图只反演一次把所有需要的中间态都存下来。比较阶段不再重复跑网络只在内存里读取预计算好的中间态做相似度计算。这是最有效的一刀。降采样比较相似度比较不需要在完整 latent 上做可以把 latent 空间尺寸缩到 16x16 甚至 8x8语义信息保留得还行但计算量减到原来的几十分之一。分治策略先用小 t 集合做一轮粗筛只有分数落在临界区的样本对才用全 t 集合做精细判定。大多数“明显不像”的样本对在第一轮就被过滤掉了根本不进入昂贵的完整轨迹比较。4.3 少数类样本的标注率低得离谱这是所有标注方法的通病少数类样本本身数量少随机抽到的相似样本对就少标签传播很容易把少数类彻底漏掉。扩散轨迹也不例外。我的解法是引入反向扩展不是只做“已标注 → 未标注”的单向传播而是把少数类样本作为扩散生成的条件生成一批语义相似但视觉有差异的扩充样本再把这些扩充样本加进轨迹比较池里。扩散模型的条件生成在这里反而成了天然的数据增强器。这样少数类在比较池里的密度上来了轨迹匹配的命中率也上来了。需要注意的是扩充样本只能用于辅助匹配不能直接当标注结果用否则会把生成模型的偏见带进标注数据里。4.4 跨域数据上轨迹分岔点不稳定跨域的时候模型的生成先验可能不太适配你的数据轨迹分岔的位置会发生漂移。我在一个工业质检项目里遇到过同一条产线的正常品和次品在通用模型的轨迹里几乎不分岔分类效果跟随机猜差不多。后来我改成在少量该产线的真实数据上对扩散模型做了轻量微调只调几个 adapter 层几百步训练量轨迹分岔线立刻清晰了。这说明扩散轨迹的零样本能力是有边界的遇到严重分布偏移时花一点微调成本是值得的。这个成本和从头训练一个分类模型相比还是低得多。5. 一些我个人的实操建议在这个方案上折腾了大半年有几个体会特别深。第一个体会是扩散轨迹当标注者最适合的恰恰是那些传统方法做不好的“模糊地带”。两个样本在特征空间里看起来差不多、但实际类别不同——这种场景下传统距离度量通常直接放弃而扩散轨迹因为能保留多粒度的语义尺度反而能通过某个中间噪声水平的轨迹分岔找到边界。所以如果你的数据恰好处于这种“看着像但其实是不同类”的状态这个方案值得一试。第二个体会是阈值校准和数据校验永远不能省。扩散轨迹是生成模型驱动的生成模型有一定的幻觉倾向轨迹相似度分数高不代表语义真的同构。我在流程里强制加入了一个小比例的抽样人工复核每次只抽 5% 的传播结果让人工看一遍用来校准阈值、发现新问题。这个习惯已经帮我拦下了好几次大规模标注事故。第三个体会是这个方案的可扩展方向除了标签传播轨迹分岔点本身的信息还可以用来做类别层级分析比如自动发现一个数据集中有哪些粗糙的语义族、哪些细粒度边界。我最近就在试着把每条轨迹的分岔位置提出来当作一个“语义指纹”写到数据集的元信息里后续做主动学习时可以直接排优先级。这个方向还没有完全跑通但初步实验显示有可行性。最后再分享一个小技巧在比较轨迹中间态之前掉换一下比较顺序不会影响结果但把整批样本按轨迹分岔程度聚类再逐个簇内传播标签效率和准确率都会好很多。这个其实和“先粗分组再做精细比较”的老经验一脉相承只是这里的“粗分组”用的是扩散轨迹而不是传统聚类特征。实际测下来同样的数据处理时间能缩短一半标注准确率还能提两三个百分点。有同类需求的朋友可以直接去试试。