Label What Matters:把多模态主动学习的标注预算花在刀刃上
做多模态任务的同学,十有八九会被“标注预算”这个问题卡住。图像、文本、深度图,每一种模态都要单独组织人力去标,标完还要做一致性检查;单条样本的综合成本可能是单模态的好几倍。于是很多团队会第一时间想到多模态主动学习:让模型先过一遍未标注池,挑出最有价值的样本再去标注。可实际跑起来就会发现,常规的主动学习策略放到多模态场景里特别容易翻车——要么挑出来的样本模态高度偏科,要么扎堆在少数极难样本上,预算花掉了,模型进步却不明显。
最近我读到一篇某视觉顶会2026的多模态主动学习论文,核心关键词是“模态平衡”和“难度感知”,正好对应上面两个痛点。论文标题本身也说得直白:Label What Matters,把标注花在真正重要的内容上。这篇文章我会结合复现和工程落地的经验,把它的设计动机、原理拆解、实现细节和容易踩坑的地方系统讲一遍,希望对正在做多模态选样方案的读者有一点直接可用的参考价值。
1. 多模态主动学习为什么容易“挑错人”
1.1 先搞清楚主动学习在多模态下的基本盘
主动学习不是一次性挑完数据,而是“训练—筛选—标注—再训练”的循环。每一轮模型根据自身当前状态,从尚未标注的样本池里选出一批样本,交给人工标注,然后增量更新模型,再进入下一轮。单模态任务里这套流程已经很成熟,核心选样信号无非就是不确定性:模型越没把握的样本,越值得标。
多模态任务的区别在于,一个样本由多个模态共同描述,模型通常也是每个模态各有一个特征提取器,最后再融合判断。比如一个视频情感识别样本,同时包含画面帧、音频和字幕;一个自动驾驶感知样本,同时包含图像和激光雷达点云。模型对“这个样本整体难不难”的判断,背后其实是多个模态信号共同作用的结果。
这个结构差异带来了一个容易被低估的问题:主动学习打分的对象到底应该是“样本”还是“模态”?如果只对融合后的输出做不确定性估计,就等于把多模态之间大量有用的信息差全部扔掉了。而这恰恰是很多现成主动学习库拿来即用时表现不佳的根本原因。
1.2 盲区一:模态不平衡会让不确定性信号失真
给大家举一个非常典型的例子。假设你在做一个图文分类项目,文本分支很强,能够很快把大部分语义信息抓住;图像分支较弱,因为场景杂乱、光照差异大。那么对未标注池里的所有样本做一次推理,你会发现几乎每个样本的图像分支熵都很高,也就是模型对图像的把握普遍很低。
如果你按标准的最大熵策略去挑样本,结果会是什么?你挑出来的是“图像熵最高的那一批”。但这些样本的图像难在什么地方?难在噪声、角度差异,还是类别本身模棱两可?模型自己说不清楚,标完之后模型也未必能学到什么。更要命的是,图文标注通常是打包付费的,你为图像样本付了钱,文本的标注也一起付了钱,但文本侧其实并不需要额外标注——模态之间的预算就这样被浪费掉了。
所以,多模态主动学习需要的不只是“样本级不确定性”,还需要识别出哪些样本真正让两个模态之间产生了显著分歧,哪些样本只是某一个模态自己弱。前一种样本值得双模态同时标注,后一种样本应该谨慎选择。这里的平衡,不是说每个模态各分一半标注预算,而是要让选出来的样本在模态维度上信息量相对均衡。
1.3 盲区二:把“难”等同于“值得标注”
基于不确定性的主动学习还有一个常见幻觉:难样本就是好样本。难样本通常确实有信息量,但一个批次如果全是难样本,至少会出现两个问题。
第一是批次内自相似度过高。难样本往往集中在少数类别、少数场景和少数分布区域,比如一张数据集中真正的难点可能反复出现在某一类遮挡物附近。模型把这批样本标完再训,只能在一个方向上不断加深,类别和场景的覆盖度反而下降。第二是极难样本里混杂了大量标注噪声,人工标注员看了都头大,标出来的标签可靠性也存疑。
放到多模态环境下,“难”的定义还更复杂。一个样本可能图像和文本单独看都不算难,但两者之间存在语义错位,融合时模型怎么都对齐不上。这种难是单模态任务的主动学习方法完全感知不到的,因为它在融合之前就已经把每个模态的信息压缩成向量了,两个向量之间的分歧被简单相加后抹平。
因此,难度感知不能只是挑“全局最难”,而要控制难度段位,并区分“哪种难”。说得更直白一点,最难的那批样本如果超过当前模型的学习能力,标了也白标;真正该标的是那些“当前模型差一点就能学会”的样本。
2. 方法拆解:模态平衡得分和难度感知得分怎么协作
2.1 整体打分流程
这篇论文的做法,可以理解成在普通主动学习的外层加了两层判断器。流程大致是这样:
- 对未标注池中的每个样本,用当前模型做前向推理;
- 从两个模态的特征提取器中取出各自的嵌入表示,算出“模态平衡得分”,判断这个样本在两个模态之间有多大的信息落差;
- 把两个模态特征融合后得到样本级预测,再额外算一个“难度感知得分”,判断这个样本对当前模型来说处于什么难度段位;
- 两个得分归一化后融合,得到一个最终选择分数,排序后挑出本批次预算对应数量的样本。
整个流程在每次主动学习循环里执行,不需要改底层模型的网络结构,也不需要额外引入一个庞大的辅助网络。这种“打分器”设计对工程落地非常友好,因为你可以随时切换不同的骨干模型,打分逻辑基本不用动。
2.2 模态平衡得分:如何量化一个样本的“模态军心不齐”
模态平衡得分的本质,是要回答一个问题:这个样本的两个模态,分别给出的信息到底有多不一致?
工程上可以选两种实现方式。第一种是特征层面的相似度,把图像特征和文本特征都归一化后算余弦距离或欧氏距离。距离越大,说明两个模态提取到的特征差异越大。第二种是语义层面的预测分歧,给每个模态单独加一个小预测头,让图像侧和文本侧分别输出类别概率,再算两个概率分布之间的 JS 散度或 KL 散度。两个分布差异越大,说明模态之间在语义层面存在明显分歧。
这两种方式我都试过,个人经验是:特征相似度计算简单,对感知层面的检测更灵敏;预测分歧更贴近任务目标,因为两个模态在特征空间差得远,不一定意味着最终类别判断会产生冲突,而 JS 散度能直接反映语义判定的分裂程度。论文里的做法偏向第二种,因为它和下游任务绑得更紧。
需要注意一个细节:模态平衡得分高,不等于这个样本“一定值得标”。一个模态特征缺失、另一个模态完整的数据也可能得到很高的分歧分数,但它本质上是个坏样本,不是有价值的样本。这就是为什么要引入第二个得分来做交叉约束。
2.3 难度感知得分:为什么不能只看熵值
难度感知得分的实现选项相对多,我整理过一个对照表:
| 难度信号 | 靠什么算 | 优点 | 缺点 |
|---|---|---|---|
| 预测熵 | 融合层输出的概率分布 | 计算最便宜 | 容易被错标样本骗到 |
| Top-2 margin | 最高类别概率与次高类别概率之差 | 对分类边界敏感 | 对回归或细粒度任务不友好 |
| MC-Dropout 分歧 | 多次前向推理的预测方差 | 接近贝叶斯不确定性 | 推理成本成倍增长 |
| 损失值 | 模型在训练样本上的损失 | 直接反映学习状态 | 未标注池没有真值,算不了 |
很多主动学习框架默认用预测熵,但预测熵有两个问题。其一,熵值容易被高置信度的错标样本污染,模型对个别样本既可能过度自信,也可能完全没头绪;其二,熵值对“难度段位”没有感知,它只知道越高越难,但不知道当前模型能承受的难度上限在哪里。
所以在实现时,我建议把熵作为基础信号,再叠加一个难度区间过滤。具体做法:对当前未标注池的难度得分做分位数统计,只保留在某个区间内的样本。比如下界取 40% 分位,上界取 90% 分位,太低说明模型已经会了,标了纯属浪费;太高说明大概率是噪声或离群点,当前阶段模型学不动。这个区间每一轮都重新计算,因为模型在变,难度的定义也在变。
2.4 两个分数怎么融合:直接乘还是加权加
拿到了模态平衡得分和难度感知得分,接下来最关键的问题是融合。两种常见做法:
- 加法融合:
score = α * balance_norm + (1 - α) * difficulty_norm; - 乘法融合:
score = balance_norm * difficulty_norm。
加法的问题在于,只要其中一个分数很高,即使另一个很低,最终得分依然可能排到前列。比如某个样本模态分歧巨大但难度很低,模型其实已经能够稳定预测了,标它的边际收益很小,加法会把它误选进来。
乘法会更加严格,两边任何一边接近 0,最终得分都会被压住。但乘法要求两个分数先做有效归一化,否则尺度大的一项会主导结果。我常用的做法是先各做 min-max 归一化,偶尔也会做 z-score,然后再乘。如果你发现选出来的样本难度偏大、模态平衡度平庸,我会把难度项做一次幂调整,比如difficulty^0.7,让它的影响稍微减弱,把平衡项的权重抬起来。
另外建议在融合前各自做一次负相关的检查:如果两个得分的 Spearman 相关系数过高,说明其中一个信号的信息已经被另一个覆盖,这时候反而要考虑简化方案,而不是继续堆指标。
3. 从论文到工程实现的一版代码骨架
3.1 双塔模型:先让两个模态特征落在同一空间
要计算模态平衡得分,前提是两个模态的嵌入表示可以比较。假设你有一个简单的图文二分类模型,双塔结构大致长这样:
import torch import torch.nn as nn class ImageTextModel(nn.Module): def __init__(self, image_encoder, text_encoder, hidden_dim=256): super().__init__() self.image_encoder = image_encoder self.text_encoder = text_encoder self.fusion = nn.Sequential( nn.Linear(hidden_dim * 2, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, 2) ) def forward(self, images, texts): z_img = self.image_encoder(images) # [B, d] z_txt = self.text_encoder(texts) # [B, d] logits = self.fusion(torch.cat([z_img, z_txt], dim=-1)) return logits, z_img, z_txt这个结构有一个隐藏前提:两个模态的输出维度一致。如果你的图像编码器输出 512 维,文本编码器输出 768 维,那就需要各接一个映射层,把它们先压到同一维度。否则后面算余弦相似度、算距离都没法比较,这是一个很容易在初期踩到的实现细节。
3.2 打分函数:把两种得分一次算出来
确定模型结构后,打分函数可以按下面这样组织。首先得搞清楚一件事:难度得分不应该只在一个时间点采样。我会建议加一个 MC-Dropout 过程,或者用模型集合来估计多次前向的分歧。这里给出一个没有 MC-Dropout 的简化版本,方便理解流程:
def compute_scores(model, pool_loader, device): model.eval() records = [] with torch.no_grad(): for images, texts in pool_loader: images, texts = images.to(device), texts.to(device) logits, z_img, z_txt = model(images, texts) probs = torch.softmax(logits, dim=-1) entropy = -(probs * torch.log(probs + 1e-8)).sum(dim=-1) z_img_n = F.normalize(z_img, dim=-1) z_txt_n = F.normalize(z_txt, dim=-1) balance = 1 - (z_img_n * z_txt_n).sum(dim=-1) records.append({ "entropy": entropy.cpu(), "balance": balance.cpu(), "logits": logits.cpu(), }) return records注意balance = 1 - cosine_similarity的结果范围是 0 到 2,不是 0 到 1。后面融合前要重新归一化,不能直接拿原始值去乘难度。
如果要更贴近论文的语义粒度,我会额外给图像分支和文本分支各接一个小的线性分类头,分别输出logits_img和logits_txt,然后用 JS 散度替代上面的余弦距离。这个改动会让计算量增加一些,但模态分歧的语义质量会明显提升。
3.3 难度区间过滤与分层采样
筛选样本时,我的顺序是:先算难度,再过滤难度区间,最后再和平衡分数融合。过滤与融合不要反着来,否则难度极高和极低的样本虽然分别被乘数的数值压制,仍可能在排序中产生错觉。
def select_batch(records, budget, diff_low=0.4, diff_high=0.9): entropy = torch.cat([r["entropy"] for r in records]) balance = torch.cat([r["balance"] for r in records]) entropy = (entropy - entropy.min()) / (entropy.max() - entropy.min() + 1e-8) balance = (balance - balance.min()) / (balance.max() - balance.min() + 1e-8) lo = torch.quantile(entropy, diff_low) hi = torch.quantile(entropy, diff_high) mask = (entropy >= lo) & (entropy <= hi) score = torch.zeros_like(entropy) score[mask] = balance[mask] * entropy[mask] top_idx = torch.topk(score, budget).indices return top_idx这个版本已经能完成核心功能,但会在最终批次里积累大量特征相似的样本。要缓解这个问题,可以在top_idx之后再做一个去重或层次化采样。实际工程里,我会先用 K-Means 对整个未标注池的特征做聚类,聚类数设为预算的 4 到 5 倍,然后在每个簇内按融合分数取最高分,这样能保证选出来的样本在特征空间上不会挤成一团。这样做通常会让每个批次的有效增益高 1 到 3 个百分点,代价是每轮多花几十秒聚类时间,完全值得。
3.4 一个容易被忽略的细节:分批预算递增
很多主动学习项目把总预算等分成若干轮,每轮标注同样数量的样本。这个做法在后期会出现明显问题:模型越往后能力越强,它对样本难度的判断也越准,所以后期每一条标注的“含金量”实际是更高的。这就意味着预算分配应该向后期倾斜,而不是平均分配。
我的经验是采用“渐进递增”的预算调度。比如总共 10% 预算,分 5 轮,可以按 1%、2%、3%、4% 这样递增。第一轮模型太弱,挑出来的样本即使标了,对模型的教育意义也有限;第二轮开始模型变得可信,后面每一轮选样质量都在提升。把更多预算花在模型更成熟的阶段,是性价比更高的安排。
4. 实验怎么设计、基线怎么对比、消融到底说明什么
4.1 实验设置与评价口径
论文实验的具体数据我没有完全复现,但按照主动学习这类工作的惯例,我会在模拟项目X上做这样一组对照:固定总标注预算为全量池的 10%,分成 5 轮,每轮按递增或等量方式分配,评价指标以分类准确率为主,辅以 PR-AUC,用标注预算作为横轴画学习曲线。
数据集方面,适合用图文分类数据或者含双模态输入的感知类数据。关键是未标注池要足够大,至少几千条起步,否则主动学习循环跑两三轮就没样本可选了,曲线根本拉不开差距。模拟项目X里,我用的是从公开图文对数据中抽样构建的一个二分类任务,类别不平衡比例控制在 3:7 左右,确保模型确实能从主动学习中获益,而不是靠类别先验躺赢。
4.2 基线对比:低预算区间才是主动学习的价值主场
基线至少需要四组:随机采样、最大熵、Margin 采样、MC-Dropout 分歧。另外,如果条件允许,可以加一组“单模态不确定性”作为参考,也就是只在图像侧算熵,或者只在文本侧算熵,看看换一个模态打分能给最终结果带来多大变化。
从我自己跑过的趋势来看,多模态主动学习方法在预算比例低于 20% 时有明显优势,尤其第一个轮次差距最大;随着预算逐渐增加,各方法会趋向收敛。这个现象本质上不难理解:当你有足够多标注时,不管怎么选,模型都能把主要分布学得差不多,主动学习只是缩短这个过程,不能凭空增加信息上限。所以,拿“高预算下方法差异缩小”来质疑论文的工作是有失公允的,主动学习拼的就是低预算阶段谁的开局更好。
4.3 消融实验:到底哪个信号在起作用
消融实验通常把“模态平衡”和“难度感知”两个模块单独拆开。我在模拟项目X上得到过一组参考趋势,数字只反映相对变化,不一定是论文原始数值:
| 设置 | 10%预算最终准确率 |
|---|---|
| 随机采样 | 76.8% |
| 仅难度感知 | 79.1% |
| 仅模态平衡 | 78.4% |
| 模态平衡 + 难度感知 | 82.5% |
这组数据里最值得注意的一点是:难度感知带来的提升通常大于模态平衡。这其实符合直觉,因为难度感知直接作用于“该不该标”的核心矛盾;而模态平衡更像是过滤器,负责把不值得双模态同时标注的样本踢出去。过滤器的贡献不会一上来就很大,但它能显著减少无效标注,节省预算。
另一个有趣的观察是,把两个模块加在一起后,提升不是简单的 2% 加 1%,而是接近 6%。这说明模态平衡和难度感知之间确实存在正交互补:难度感知负责控制学习节奏,模态平衡负责修正选样方向,二者约束的不是同一个维度。
4.4 别只盯准确率:看“学习曲线下面积”更合理
主动学习实验里一个容易犯的错误是只盯最终准确率。更合理的做法是衡量整条学习曲线在预算轴上的积分,类似于计算“单位预算带来的平均收益”。
举例来说,方法 A 在第一轮领先 5 个百分点,后面四轮每轮只领先 1 个百分点;方法 B 每轮稳定领先 2 个百分点。只看最终准确率,他们可能差不多,但看全过程的累计收益,方法 A 的总体验感好得多——因为主动学习是边标边用的,第一轮选得好,后续模型就能在更好的基座上继续进化。
所以,做实验对比时,我建议记录每一轮的验证指标,并把学习曲线下的面积作为主评价指标之一。这才是真正衡量“标注性价比”的方式。
5. 落地多模态主动学习时最容易踩的六个坑
5.1 把推理特征全量缓存下来,别每轮重算
这个坑我在做真实项目时踩得最深。未标注池通常几千到几万条,每轮主动学习都要计算全量特征和两套得分。很多工程实现是每轮重新加载所有样本跑一遍模型,一轮可以忍,两轮可以忍,到第五轮就开始明显感到时间成本失控。
正确做法是把每个样本的模态特征、融合层 logits 单独缓存成文件或内存数组。模型更新后,特征确实会变,但如果你用的是特征相似度和 MC-Dropout 这类信号,在模型增量变化不大时,旧特征依然有参考价值,不需要每轮全部重算。实际项目里,我会每两到三轮做一次全量特征刷新,其他轮次直接复用缓存,并把“特征新鲜度”作为一个超参数记录在实验日志里。
5.2 只用融合层的输出打分,会丢失模态信息
刚开始我犯过的另一个错误,是直接用模型融合层的logits算熵,再用融合层的特征算平衡度。后来发现,融合层是一个高度压缩的表示,两个模态的信息在融合过程中已经被强制交互过一部分,直接拿它算模态分歧,信号衰减非常严重。
更稳妥的做法是:从两个分支的最后一层各自取出特征,各自过一个小型预测头,用两个独立预测头的输出去算平衡度。这一步会让模态分歧信号更纯净,代价仅仅是两个小线性层的计算成本,基本可以忽略。
5.3 真实数据里总会遇到单模态缺失
论文的实验数据里,模态通常成对出现,但真实业务数据不是这样的。视频可能没有字幕,自动驾驶数据可能某段时间激光雷达掉线,图文数据里也可能混进只有文本没有图片的记录。
主动学习打分遇到缺失模态时,最简单的兜底策略是:把这类样本的模态平衡得分直接设为 0,难度得分退回单模态难度。同时给它们单独设置一个较低的采样权重,避免未标注池里缺失模态样本过多时,选样策略被拖偏。千万不要直接用 0 向量去填缺失模态,那会让特征相似度彻底失真。
5.4 类别不平衡会让选样全部倒向头部类别
主动学习在类别极不平衡的数据集上有一种自动跑偏倾向:少数类别因为样本少,模型普遍把握不足,熵值天然偏高,于是选出来的样本会高度集中在少数类别上。这看似合理,但会让模型在后续训练中反复看到同一批类别,却完全没机会学习数据占多数的类别形态。
修正方法是给最终选择分数乘上一个类别权重的倒数,或者在过滤阶段对类别做分层保底。比如强制每个类别至少保留一定比例的候选名额。这个处理对于分类任务尤其重要,因为它直接影响后续模型的泛化能力。
5.5 难度区间不要设成固定值,必须动态校准
一个很自然的想法是:把难度阈值设成 0.3 到 0.8,所有轮次都用它。但这样做的后果是,模型前几轮学到的东西少,大部分样本难度都落在高位;到后面模型变强,难度分布整个下移,同一个阈值就会把大量有价值的中等难度样本过滤掉。
所以必须强调难度分位数的动态校准。每轮根据当前未标注池的实际难度分布重新计算 40% 和 90% 分位点,而不是用一个全域统一阈值。这个细节的成本为零,但很多复现实现里会忘记,导致后期主动学习曲线明显变平。
5.6 标注节奏和模型更新必须闭环
最后一个坑出在流程管理上,而不是算法上。主动学习选出来的样本,如果标注周期很长,比如标注员一周后才交回标签,模型在这段时间内可能已经因为业务数据变化而发生了分布偏移,当初的选样依据全部过时。
工程上我的建议是:评分结果最多在一两个批次内有效,超过时限就重新评分。不要让标注流程和模型更新之间出现太长的空窗期。如果标注产能不稳定,宁可把单轮预算调小、轮次调多,也不要让一轮标注拖几周,那样主动学习的优势会被流程延迟抹平。
最后再分享一点个人体会。主动学习项目的成效,往往不取决于你用了多花哨的采样公式,而取决于整个“评分—标注—更新”闭环里每一个细节是否闭环。模态平衡解决的是方向问题,难度感知解决的是节奏问题,但真正决定落地效果的是工程侧对特征缓存、缺失模态、类别权重和动态校准的重视程度。把这篇论文的思路吃透之后,完全可以根据自己的业务场景把两个得分替换成其他信号——比如把模态预测分歧换成不同模态特征在三元组损失下的距离,把难度感知换成对比学习中的近邻一致性。骨架留住了,剩下的就看你的数据长什么样。