我最早接触Deep MIML Network,是在一个多标签图像项目上怎么都训不动的时候。每张图挂了好几个标签,可我试了全局平均池化加分类头、试了多层注意力,指标始终上不去。后来回去查资料才发现,这类任务的真正难点不在网络多深,而在问题建模——它天然适合用“多示例多标记(MIML)”来描述,而Deep MIML Network正是把深度学习特征和MIML框架缝合起来的一套完整方案。这篇文章就围绕它的原理和可复现代码展开,适合正在做多标签分类、弱监督定位、或者想给模型加一点“可解释证据”的读者。
1. 为什么多标记任务会“卡”在对象表示上:MIML的由来
1.1 三个让我意识到“问题建模比模型更关键”的场景
先说我踩过的那个坑。某项目的数据是从网页抓下来的商品图,每张图有颜色、款式、材质等多个标签,但没有任何区域标注。模型输入是整张图,输出是20个标签。最初我直接拿ResNet抽全局特征,最后一层换成sigmoid多标签头,BCE loss训练,看起来没什么问题。结果测试集mAP只有0.4左右,尤其是“纹理”“材质”这类标签,基本等于在猜。后来我把预测错的样本翻出来看,发现关键证据往往只占画面很小一块,比如一个包到底是“真皮”还是“人造革”,决定性细节在包角的小标签上,全局池化一压,这个信号就没了。
第二个场景是病理切片。一张切片里同时存在多种病变区域,整个切片的标签是“多种病变的并集”,但具体哪个区域对应哪个标签,标注成本极高。行业里最常用的做法,是把切片切成一堆小patch,一个patch就是一个示例。如果某个patch包含病变细胞,那么它所属的切片就带对应标签——但单个patch自己是没有标签的。
第三个场景是文本。一条评论可能同时包含“物流快”和“包装差”两类信息,整条评论是一个多标签对象,但细分到句子,每个句子通常只承载一个方面。以上三种问题有同一个数学结构:一个对象由多个部分组成,对象级标签已知,部分级标签未知。这种结构,就是MIML建模的出发点。
1.2 MIML与MIL、MLL的形式化区别
在做Deep MIML Network之前,必须先把几个容易混淆的缩写分清。很多人会把它和MIL混在一起,但它们放开的维度不一样。
| 问题范式 | 对象结构 | 标签形式 | 典型难度 |
|---|---|---|---|
| 标准监督学习 | 一个示例 = 一个对象 | 一个标签 | 无歧义 |
| 多标记学习(MLL) | 一个示例 = 一个对象 | 多个标签同时存在 | 标签之间有关联 |
| 多示例学习(MIL) | 一个包 = 多个示例,包级标签 | 一个标签 | 示例级标签缺失 |
| 多示例多标记学习(MIML) | 一个包 = 多个示例,包级标签 | 多个标签 | 示例标签缺失 + 标签关联 |
从形式化角度看,MIML训练集可以写成 {(X_i, Y_i)},其中 X_i = {x_{i1}, x_{i2}, ..., x_{in}} 是一个包,Y_i ⊆ L 是该包对应的多个标签。模型的任务,是学一个从包到标签集合的映射,并尽可能定位每个标签背后的证据。
这个形式化最大的价值,是把你从“必须把整张图压成一个向量”的思维里解放出来。包内的示例是独立存在的实体,最后要不要聚合成一个全局向量、怎么聚合,是可以设计的。而Deep MIML Network的思路,就是保留到示例级特征,再通过学习得到的权重决定每个标签应该“看”哪些示例。
1.3 深度化之前的老痛点
MIML不是新概念,早在二十年前就有成体系的传统方法。早期的代表性方案,走的都是“先构造包级表示,再做多标记分类”的路子。比如有些工作用核方法把包映射到高维空间,有些工作借鉴多标记传播的思路迭代优化示例级标签。这些方法在一个几百个样例的小数据集上能跑出不错的结果,但放到真实场景就暴露出三个硬伤。
第一个痛点是特征工程。传统方法很难直接吃原始像素或原始文本词向量,都需要人工设计特征。拿商品图举例,要手工提取颜色直方图、纹理谱、HOG这类特征;拿文本举例,要做分词、停用词过滤、TF-IDF加权。这套流程一旦换一个领域,全部推倒重来。
第二个痛点是示例对齐。早期很多工作假设一个包里的示例数量较少且可以对齐,有的方法需要先计算包间距离矩阵再做聚类。但真实场景里包内示例数量差异极大,一张切片可能切出几千个patch,一条评论可能只有四五个句子,这种非对齐数据对核方法很不友好。
第三个痛点是端到端训练缺失。传统pipeline里,特征提取、包表示构造、多标签分类是三个独立模块,每个模块的误差会逐级放大。而且没有可微的注意力机制,也就没法回答“模型是根据什么判断出这个标签”的问题。
等到深度网络成为标配,MIML进入深度时代就成了必然:用一个CNN或RNN处理示例级特征,用可微的注意力把包内示例聚合成包级表示,再用多标签头输出。整个过程端到端可训,而且中间产物直接可以作为可解释证据。Deep MIML Network,就是这一思路最具代表性的实践形态。
2. Deep MIML Network的架构拆解:示例是如何变成证据链的
2.1 从包到预测的四个阶段
我第一次看Deep MIML Network相关论文时,被那一堆模块名字绕得头晕。实际上如果抛开术语,它的核心流程只有四步。
第一步:把输入的包拆成一组示例,每个示例通过特征提取器转成向量。这个特征提取器在图像任务是CNN,在文本任务可以是RNN或者Transformer编码器。第二步:引入一个标签感知的注意力模块,针对每个标签,分别计算包内各示例的重要性权重。第三步:用这个权重对示例特征做加权求和,得到每个标签各自的包级表示。第四步:把包级表示送进分类层,得到该标签是否存在。
这里最关键的转变在于:包级表示不再是一个全局共享的向量,而是每个标签一份。多标签问题里,不同标签的证据往往落在不同示例上。比如商品同时有“皮革”和“红色”两个标签,皮革证据在包角标签,红色证据在整体外观,强行压缩成同一个向量再去做所有标签的预测,信息必然被稀释。
2.2 示例特征提取器怎么选
实际实现中,示例特征提取器的选择,直接决定了整个模型的基线和上限。
如果包的输入是图像,通常有两种做法。一种是端到端方案,原始图像先被切分成N个区域或patch,每个patch走同一个CNN分支,得到N个示例特征。这种做法的优点是梯度可以直接回传到CNN,缺点是要把整张图的所有patch都过一遍网络,计算量大。另一种方案是先用预训练CNN提取每个patch的特征存成npy文件,MIML模型只吃特征向量。这种方案训练速度快,特别适合我这种内存有限、还要反复调参的场景。
如果输入是文本,示例往往是句子或段落级别。早期工作喜欢用Bi-LSTM编码句子,现在更常见的是直接取Transformer最后一层的CLS向量或mean pooling。我在文本实验里两种都试过,结论是:示例特征质量对最终效果的影响,往往大于后接的注意力模块。所以如果发现模型效果上不去,先回去看看特征提取器有没有过拟合、要不要微调,而不是急着改注意力结构。
需要注意的是,Deep MIML Network本身对示例特征提取器没有强约束,它要求的是“示例特征维度一致、包内示例数量不定”。所以它其实是一个非常灵活的框架,CNN、RNN、Transformer都可以作为前置模块接到里面。
2.3 标签感知注意力——每个标签各有一把尺子
要说Deep MIML Network最核心的模块,一定是标签感知注意力。这部分的直觉可以这样理解:传统注意力给每个示例算一个权重,然后用一套权重聚合所有示例;但在MIML场景里,一个示例可能对标签A很重要,对标签B完全不相关,一套权重根本不够用。
举例来说,一张海滩照片同时有“夕阳”和“人群”两个标签。海面波光的示例对“夕阳”贡献大,沙滩上密集的人头示例对“人群”贡献大。如果用单一权重,最后聚合出来的是一个模棱两可的混合特征。标签感知注意力的做法,是把“包内示例权重”这一层复制成L份,L是标签数量,每个标签独立算出自己的示例权重分布。
从实现角度讲,它通常是一个线性层,输入示例特征,输出L维logits,每个维度对应一个标签。然后沿着示例维度做softmax,得到L个概率分布。把每个标签的权重分别和示例特征做加权平均,就得到L个包级表示。这一步计算量非常小,但效果提升很明显,后面第五节我会给实测对比。
2.4 可解释性:attention权重真的能当证据吗
大多数人在第一次看到这个结构时都会问:attention权重输出那么大一张图,它真的靠谱吗?我的看法是:它的定位不是“绝对真理”,而是一个“弱监督证据假设”。
在没有任何示例级标注的情况下,模型只能根据“哪些示例能让损失下降更快”来分配权重。训练完之后,权重高的示例通常确实对应人类直觉上的关键区域,这一点在很多测试集上都能观察到。但也要小心两个情况:一是attention会把特征最像“类别平均脸”的示例挑出来,而不是真正的判别性细节;二是当标签之间高度相关时,注意力权重可能趋同,导致可解释性下降。
所以在实际项目里,我一般把attention权重当作用来“筛选候选证据”的工具。预测某个标签为正时,把权重最高的前3个示例可视化出来,交给业务方做人工复核。这比纯黑盒模型好用得多,至少能定位问题所在,而不是只能对着指标猜。
3. 可运行的代码骨架:数据、模型与训练循环
3.1 BagDataset:把不等长示例切成固定批次
Deep MIML实现的第一个拦路虎,是包内示例数量不一致。PyTorch的DataLoader要求一个batch内所有张量形状一致,所以必须padding到一个统一长度,并且额外记录一个mask矩阵标明哪些位置是真实示例。
下面是我在项目里一直沿用的Dataset写法,输入是“每个包的示例特征列表”和“多标签向量”。
import torch import numpy as np from torch.utils.data import Dataset class BagDataset(Dataset): def __init__(self, bag_feats_list, bag_labels, max_examples=32): self.bag_feats = bag_feats_list self.bag_labels = bag_labels self.max_examples = max_examples def __len__(self): return len(self.bag_feats) def __getitem__(self, idx): feats = self.bag_feats[idx] # list of [D] 或 [n, D] label = self.bag_labels[idx] # [L] n = len(feats) # 超过max_examples时,随机采样一部分示例 if n > self.max_examples: keep = np.random.choice(n, self.max_examples, replace=False) feats = [feats[i] for i in keep] n = self.max_examples feat_tensor = torch.zeros(self.max_examples, feats[0].shape[0]) mask = torch.zeros(self.max_examples, dtype=torch.long) feat_tensor[:n] = torch.as_tensor(np.stack(feats), dtype=torch.float32) mask[:n] = 1 return feat_tensor, mask, torch.as_tensor(label, dtype=torch.float32)这里有两个细节值得说。一是“超过max_examples怎么做”,我习惯用随机采样而不是直接截断,因为直接截断会损失后部信息,随机采样至少让每个示例都有机会进入训练。二是mask的数据类型,我在后续代码里要拿它做masked_fill,用long类型就行,不用转成bool,减少一次转换。
如果你拿到的是原始图片而不是特征,可以在这个类里加一个预处理函数,把图片切成patch集合,再经过预训练CNN提取特征。为了加速调试,建议先把所有特征离线提取好存成缓存文件,否则每个epoch重新提一次特征,时间成本完全不可控。
3.2 核心网络:注意力聚合加多标签头
下面是Deep MIML Network最核心的网络结构,我会把注意力模块和分类头分开写,方便读者对照上面的原理。
import torch import torch.nn as nn import torch.nn.functional as F class DeepMIMLNetwork(nn.Module): def __init__(self, feat_dim, hidden_dim=256, num_labels=20, temperature=1.0): super().__init__() self.num_labels = num_labels self.temperature = temperature # 示例特征变换,把预训练特征映射到公共空间 self.example_fc = nn.Sequential( nn.Linear(feat_dim, hidden_dim), nn.ReLU(inplace=True), nn.Linear(hidden_dim, hidden_dim), ) # 标签感知注意力,输出每个标签在各示例上的logits self.attention_net = nn.Linear(hidden_dim, num_labels) # 分类头 self.classifier = nn.Linear(hidden_dim, num_labels) def forward(self, bag_feats, mask): # bag_feats: [B, N, D] # mask: [B, N], 1表示有效 embedded = self.example_fc(bag_feats) # [B, N, H] attn_logits = self.attention_net(embedded) # [B, N, L] # 温度缩放,控制注意力分布的锐利程度 attn_logits = attn_logits / self.temperature # 用mask把padding位置的注意力logits压成极小值 attn_logits = attn_logits.masked_fill( mask.unsqueeze(-1) == 0, -1e9 ) attn_weights = F.softmax(attn_logits, dim=1) # [B, N, L] # 每个标签各自加权聚合示例特征 # bag_rep: [B, L, H] bag_rep = torch.einsum('bnl,bnd->bld', attn_weights, embedded) logits = self.classifier(bag_rep) # [B, L, L] -> 取对角线 logits = torch.diagonal(logits, dim1=1, dim2=2) # [B, L] return logits, attn_weights这里有一个容易写错的地方:分类头输入的维度是 [B, L, H],直接过一个 nn.Linear(H, L) 会输出 [B, L, L] 的形状,也就是每个标签的包级表示都会算出L个预测。正确的做法是取对角线元素,让“第j个标签的表示”只用来预测“第j个标签”。这也是很多复现版本效果差的一个隐藏原因。
如果你觉得torch.diagonal不够直观,也可以把classifier改成逐标签的全连接层组合,或者把bag_rep reshape成 [B*L, H],过完线性层再reshape回 [B, L]。效果一样,选顺手的方式即可。
3.3 训练循环里必须处理的三个细节
有了模型,训练循环本可以直接套模板,但有几个MIML特有的细节不处理就会踩坑。
第一个细节是loss选择。多标签任务的标配是BCEWithLogitsLoss,它内部自带sigmoid,数值上更稳定。不要手动sigmoid之后再用BCELoss,我这里吃过数值不稳定的亏,早期版本训练到后期出现了NaN。
第二个细节是pos_weight的设置。真实多标签数据里,正样本往往只占个位数,负样本占绝大多数。如果直接BCE,模型会趋向于把所有标签都预测为负。我的做法是先统计每个标签的正样本比例,然后用pos_weight放大正样本的梯度贡献。
pos_weight = torch.zeros(num_labels) for label_vec in train_labels: pos_weight += label_vec neg_count = len(train_labels) - pos_weight pos_weight = neg_count / (pos_weight + 1e-6) criterion = nn.BCEWithLogitsLoss(pos_weight=pos_weight)第三个细节是显存控制。如果包内示例数N比较大,[B, N, L] 的注意力矩阵在L也大时会很占显存。一个可行的优化是分组计算,不要一次性把全部标签的注意力都展开。另一个更简单的方法是限制max_examples,我在大多数实验里设32已经够用,设到128在示例特征维度高时显存容易爆。
下面给一个完整的训练片段,方便直接跑通:
import torch.optim as optim model = DeepMIMLNetwork(feat_dim=512, num_labels=20) optimizer = optim.Adam(model.parameters(), lr=1e-3) criterion = nn.BCEWithLogitsLoss(pos_weight=pos_weight) for epoch in range(60): model.train() total_loss = 0.0 for bag_feats, mask, label in train_loader: optimizer.zero_grad() logits, attn_weights = model(bag_feats, mask) loss = criterion(logits, label) loss.backward() optimizer.step() total_loss += loss.item() print(f"epoch {epoch}, loss {total_loss / len(train_loader):.4f}")我建议第一次跑通实验时先固定一个简单数据集,目标不是刷指标,而是确认loss能稳定下降。只要loss降了、训练没有NaN,再开始调结构和超参,这样定位问题会快很多。
4. 训练中的坑与调参笔记
4.1 attention坍缩与温度缩放
Deep MIML模型一个很典型的失败模式,是注意力坍缩。所谓坍缩,是指某个标签的注意力权重几乎全部集中到同一个示例上,无论输入什么样本,模型都依赖这一个示例做决策。这种情况在训练初期尤其容易出现,因为网络还没有学到好的特征表示,softmax自然倾向于“赌一把”,把权重压到某个随机示例上。
我解决这个问题用了一个很实用的机制:温度缩放。原理很简单,把attention的logits除以一个temperature再softmax。温度大于1时,分布更平滑,每个示例都有机会拿到梯度;温度小于1时,分布更尖锐。我的策略是初期用1.5甚至2.0的高温,让注意力保持分散,训练到中期再降到1.0,最后可以试试0.8让决策更聚焦。
如果你发现注意力权重始终很平均,那可能是温度太高,模型学不到区分性证据;如果某些标签的注意力梯队完全不变、三两轮就定死,那就是温度太低或者学习率太大。这个模块在实现时注意加在softmax之前,也就是我上面代码里的attn_logits / self.temperature的位置。
另一个有效手段是给注意力权重加一点熵正则,惩罚过于尖锐的分布:
entropy = -torch.sum(attn_weights * torch.log(attn_weights + 1e-9), dim=1).mean() loss = loss - 0.01 * entropy这个trick可以让注意力更平均地覆盖所有相关示例,实际效果得看数据集规模酌情调整系数,我一般从0.01起步,效果不明显就不加。
4.2 示例长度不均与mask处理
包内示例数量不均,是MIML数据固有的麻烦。padding是必须的,但如果在forward过程中忘记mask,padding位置会被当成长度为0的实义示例参与加权,模型会学到一些莫名其妙的偏差。
按照我上面的代码,mask的位置是在attention logits上做masked_fill,把padding位置的logits置为负无穷,这样softmax之后这些位置的权重自然为0。需要注意,masked_fill要放在softmax之前,放在之后会导致权重求和小于1,间接改变聚合特征的尺度。
还有一个小问题是:如果某个包的全部示例都被mask掉(理论上不该发生,但bug会出现),softmax会返回NaN。稳妥做法是在Dataset层面保证每个包至少保留一个示例,训练时也可以加个断言检查。我用过一个笨办法,把mask每行求和并断言全部大于0,成本很低,但能救你一个下午的调试时间。
此外,如果示例数量差距太大,比如有的包只有2个示例、有的包有200个,建议对超过上限的包做随机采样时记录一下被采样的索引。这样在后面做attention可视化时才知道权重对应哪个原始示例,否则证据定位没法回溯,可解释性也就无从谈起。
4.3 标签稀疏下的loss调整
多标签数据一个几乎必然的问题,是标签长尾分布。我用过一个文本分类数据集,总共20个标签,排前3的标签占了80%的正样本,排后5的标签在整个训练集里只有几十个正样本。这种情况直接上BCE,模型很快会陷入“全预测为负”的局部最优。
pos_weight是一个立竿见影的手段,但它对极稀疏标签作用有限。当某些标签的正样本实在过少时,单纯加权会让模型对少样本标签过于敏感,反而误伤其他标签。我遇到这种情况会用focal loss替代BCE。focal loss的主要思路是让模型关注难分样本,降低易分负样本的loss贡献。
class FocalLoss(nn.Module): def __init__(self, gamma=2.0, pos_weight=None): super().__init__() self.gamma = gamma self.pos_weight = pos_weight def forward(self, logits, targets): bce = nn.functional.binary_cross_entropy_with_logits( logits, targets, pos_weight=self.pos_weight, reduction="none" ) pt = torch.exp(-bce) focal = (1 - pt) ** self.gamma * bce return focal.mean()我在实验里发现,当正负样本比例超过1:20时,focal loss比直接调pos_weight更稳;比例在1:5以内,普通pos_weight就够了。规律是先用一组小实验定位你数据的稀疏程度,再决定用哪个loss。
4.4 一组可以当作起点的超参
训练Deep MIML网络上手时,最怕的是漫无目的地调参。我把自己跑过的一组稳定超参贴出来,供参考。
| 超参 | 建议范围 | 我的起点 | 说明 |
|---|---|---|---|
| 学习率 | 1e-4 ~ 1e-3 | 1e-3 | 使用预训练特征时1e-3够用,特征提取器可微时建议1e-4 |
| 批大小 | 16 ~ 64 | 32 | 取决于包内示例数与显存 |
| max_examples | 16 ~ 64 | 32 | 超过示例数过多会加剧采样噪声 |
| attention温度 | 0.8 ~ 2.0 | 1.0 | 训练初期偏高,后期可调低 |
| 隐藏维度 | 128 ~ 512 | 256 | 与示例特征维度同量级即可 |
| optimizer | Adam / AdamW | Adam | weight decay设到1e-4 ~ 1e-5防过拟合 |
这些参数不需要每个都细调,优先动温度和max_examples,因为这个模型的瓶颈往往不在容量,而在证据聚合的方式。
5. 实测结果:在公开基准上能带来多少提升
5.1 评测指标与数据集
我在复现Deep MIML Network时,用了两类公开基准来验证。第一类是图像包数据,把多标签图像切成若干region作为示例,标签就是图像级多标签;第二类是文本包数据,把一篇文档拆成句子作为示例,标签是文档级主题多标签。
评估指标上,多标签分类最常用的三个是:mAP(所有标签的排序性能)、子集准确率(全部标签完全预测对的样本占比)、Hamming Loss(标签级误分率)。我在最终报告里以mAP为主指标,因为它不依赖阈值选择,能直接反映模型对相关标签和无关标签的排序能力。
跑实验时有个小经验:先固定一个统一的验证集切分,避免在不同运行间因为随机种子不同导致指标波动。多标签数据集里标签分布很不均匀,随机种子一换,结果可能跳动两三个点。
5.2 三组对照实验
为了让读者更快理解Deep MIML Network的价值,我做了三组对照实验。
第一组:全局池化 vs 标签感知注意力。同一个预训练特征、同一个分类头,只把聚合方式从global average pooling换成标签感知注意力,mAP从0.41提升到0.55。这个涨幅非常大,说明一个全局向量确实不足以支撑所有标签的预测。
第二组:串行注意力 vs 标签感知注意力。串行注意力先算一组包级权重,做一次加权聚合,再用聚合后的向量做多标签预测。结果mAP是0.49,明显低于标签感知注意力的0.55。这印证了我在2.3节说的:多个标签需要多套权重,共用一套权重会互相干扰。
第三组:固定预训练特征 vs 端到端微调。在图像任务里,我把预训练CNN的特征固定住,只训MIML头部,mAP为0.55;再解锁CNN末端两层做端到端微调,mAP提升到0.60。对质量要求高的项目,这一步值得付出额外训练时间。
5.3 从attention权重反推证据
最后我想展示一下attention权重在实际案例里的表现。某张测试图像预测出两个正标签,我把每个标签下权重最高的三个示例取出来看。第一个标签对应高权重示例,人工观察确实能看到明显的相关区域;第二个标签对应的高权重示例,第一眼看起来有点“偏”,但细看发现相关的特征隐藏在背景区域。这种定位能力在之前的全局特征模型里是完全不存在的。
当然,attention不是完美的解释器。也有个别样本的高权重示例让人摸不着头脑,我后来分析发现,这些样本在特征空间里本身就比较“孤僻”,属于分布外样本。我的处理方法是引入一个置信度阈值:当模型对某个标签的预测概率接近0.5的决策边界时,不看attention证据,直接标记为待人工复核。
这套“预测-注意力-复核”的流程,在实际项目中比单纯看指标更实用,因为它能帮你快速发现数据标注错误、标签噪声和特征提取器的盲区。
最后再分享一个我很后悔没有早点知道的技巧:在数据集规模允许时,先用少量样本把模型整体跑通,再把attention权重保存成可视化文件。这个可视化文件不只是用来写报告的素材,更是调试模型对齐问题的钥匙。我一度怀疑自己的注意力模块写错了,后来把权重最高示例和原图叠在一起看,才发现是数据预处理里region的坐标偏移了一个位置。此类问题,只靠loss曲线永远发现不了。