
一次在整理模型评测方案的时候我需要一份多标签multi-label数据集。手头能用的公开多标签数据集要么太大、要么领域太偏短期想快速验证模型改造思路并不方便。后来我直接用了最经典的 MNIST自己动手做了一份多标签版本效果意外地好整个流程跑下来也就小半天。这篇文章就把我踩过的坑、用过的思路和完整可跑的代码整理出来。核心解决一件事MNIST 原本是单标签single-label任务怎么改造才能变成多标签任务。我会介绍三种常用的改造思路并给出可直接复现的 PyTorch 代码适合正在学习多标签分类、需要快速做算法验证的开发者参考。1. 多标签任务与 MNIST 改造的核心思路1.1 多标签和多分类到底差在哪很多人容易把多标签multi-label和多分类multi-class混在一起。多分类是“多个类别里选一个”比如 MNIST 原始任务就是判断 0 到 9 中的某一个数字。多标签是“每一个类别都独立判断 yes/no”一张图里可能同时具备多个属性。打个比方多分类就像让你从一盒巧克力里选一颗口味结果只能是“某一个”多标签则是让你对同一颗巧克力判断“是否含糖”“是否含坚果”“是否含酒心”等多个属性这些属性可以同时为真也可以同时为假。从模型结构上看多标签任务通常在最后一层输出多个独立分支每个分支对应一个类别预测最后用 sigmoid 把输出压到 0 到 1 之间。虽然很多资料会说多标签就是“多个二分类问题”但在工程实现上共享特征提取器、多分支输出结构的处理方式跟单纯的多个独立模型有明显区别。1.2 为什么选 MNIST 做多标签改造MNIST 在深度学习领域的地位类似编程界的 Hello World。它只有 28x28 的灰度图、10 个类别、6 万张训练图和 1 万张测试图模型训练速度快几乎不存在数据加载瓶颈。但它有个明显短板原始标签只有一个数字类别构成不了“多个标签同时存在”的条件。因此我需要人为构造一些属性让每个样本同时拥有多个独立标签。这就把问题从“识别数字”变成了“识别数字 其他属性”本质上是扩大了任务的信息量。综合下来用 MNIST 做多标签改造有几个实际好处数据量小迭代快合适做算法预研原始特征和标签非常干净改造出问题容易排查不需要额外下载大规模数据集离线就能完成方便可视化拼接后的图像可以直接保存查看调试门槛低。2. 三种改造方案的设计与选型解析2.1 图像拼接法双数字识别最直观的做法是取两张 MNIST 图片水平或垂直拼成一张新图标签就是两个数字类别。比如左边是“7”右边是“3”新标签就是 [7, 3]。模型要同时识别出这两个数字。这种方案的可扩展性很强。你可以拼接两张图也可以拼接三张、四张可以固定位置也可以随机位置。拼接后的图像尺寸会变大比如两张 28x28 图像水平拼接后变成 56x28但整体计算量仍然可控。我当时选择水平拼接主要考虑到卷积核的滑动方向。水平拼接后图像宽度变大但高度不变CNN 的卷积核能在一个方向上看到更多信息模型对左、右两侧数字的注意力分布更清晰。2.2 合成属性法数字类别 图像状态第二种思路不是拼接而是对同一张数字图施加不同变换然后把这些变换作为附加标签。常见的做法包括对图像做小角度旋转标签记录“是否旋转过”对图像添加噪声标签记录“是否加噪”对图像做水平翻转标签记录“是否翻转”对图像做平移标签记录“是否偏移”。假设原始数字是“5”我随机决定是否对它做旋转那么标签就是 [数字5, 是否旋转是/否]。这样标签数量从 1 个变成 2 个其中数字类别是多分类旋转与否是二分类。这种方案在真实场景中很常见。很多工业质检任务就是这样既要识别物体类别又要判断物体状态完好/缺陷/偏移。因此用这种方案做算法验证迁移到实际业务时更有参考价值。2.3 区域分割法笔画分布预测第三种思路稍微复杂一些。MNIST 图像是 28x28 的可以把它划分成 4x4 的网格每个网格大小是 7x7。统计每个网格区域内数字笔画的像素数量如果超过阈值就认为该区域“含有数字内容”否则认为“为空”。这样一张数字图就变成了 16 个区域的二值标签模型需要预测每个区域是否有笔画分布。这个方案的动机来自文档版面分析。比如一张扫描件需要判断哪些区域有文字、哪些区域是空白。把 MNIST 数字当作文字网格当作版面区域就能模拟这类任务。三种方案的标签构成差异很大我整理了一个对比表方案标签维度标签类型任务特点适合场景图像拼接法2 个数字类别多类别组合同时识别多个目标目标检测、场景文字识别合成属性法数字类别 状态标签多类别 二分类多属性判断工业质检、医学影像属性判断区域分割法16 个区域二值标签多二分类局部内容分布预测文档版面分析、图像分割简化版3. 数据构造的完整实操过程3.1 环境准备与 MNIST 加载我的环境是 Python 3.8 PyTorch 2.0依赖包主要是 torchvision、numpy、PIL。代码不复杂CPU 就可以跑不需要 GPU。先写好基础的加载代码import torch from torch.utils.data import Dataset, DataLoader from torchvision import datasets, transforms import numpy as np transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) mnist_data datasets.MNIST( root./data, trainTrue, downloadTrue, transformtransform )本质上我只需要利用 PyTorch 自带的 MNIST 数据读取能力后续改造都是在__getitem__方法里做文章。需要注意一点用ToTensor()后图像维度是 1x28x28灰度图只有一个通道拼接时要注意维度的处理。3.2 拼接法数据集的实现我定义了一个ConcatMNISTDataset类每次取样本时随机选两张图片进行水平拼接同时返回两个标签。class ConcatMNISTDataset(Dataset): def __init__(self, mnist_dataset, prob_randomTrue): self.mnist mnist_dataset self.prob_random prob_random def __len__(self): return len(self.mnist) def __getitem__(self, idx): img1, label1 self.mnist[idx] if self.prob_random: idx2 np.random.randint(len(self.mnist)) else: idx2 (idx 1) % len(self.mnist) img2, label2 self.mnist[idx2] # 水平拼接维度是 [1, H, W1] 和 [1, H, W2]在最后一个维度上拼接 img torch.cat([img1, img2], dim2) return img, torch.tensor([label1, label2], dtypetorch.long)这个实现需要注意几个细节随机索引时要避免选中同一张图否则两个标签会一样模型学到的会是“复制输出”不具备泛化意义如果希望构造可控数据集可以用prob_randomFalse的方式让第 i 张图固定跟第 i1 张图拼接方便调试torch.cat在 dim2 上是水平拼接因为图像的维度是 [C, H, W]dim2 对应的是宽度方向。3.3 合成属性法的实现合成属性法是我个人用得最多的一种代码也不复杂class AttrMNISTDataset(Dataset): def __init__(self, mnist_dataset, noise_prob0.5, rotate_prob0.5): self.mnist mnist_dataset self.noise_prob noise_prob self.rotate_prob rotate_prob def __len__(self): return len(self.mnist) def __getitem__(self, idx): img, label self.mnist[idx] # 标签0数字类别 # 标签1是否加噪声0/1 # 标签2是否旋转0/1 is_noise 0 is_rotate 0 if np.random.rand() self.noise_prob: noise torch.randn_like(img) * 0.2 img img noise img torch.clamp(img, 0.0, 1.0) is_noise 1 if np.random.rand() self.rotate_prob: img transforms.functional.rotate(img, 15) is_rotate 1 labels torch.tensor([label, is_noise, is_rotate], dtypetorch.float32) return img, labels这里有两个容易踩坑的细节。第一加噪声后一定要做clamp。如果不做图像像素值会超出 [0,1] 范围模型输入分布会漂移训练时 loss 下降异常缓慢。第二旋转操作要在ToTensor()之后进行。PIL 图像和 Tensor 的旋转接口参数不同torchvision.transforms.functional.rotate可以直接作用于 Tensor但要注意旋转后超出边界的部分会用 0 填充。如果不想影响数字的完整度旋转角度不要超过 15 度。labels我用了float32而不是long因为多标签任务混合了多分类数字类别和二分类是否加噪、是否旋转后面计算损失时统一按浮点处理更方便。3.4 区域分割法的实现区域分割法需要把 28x28 的图像切分成 16 个 7x7 的格子class GridMNISTDataset(Dataset): def __init__(self, mnist_dataset, threshold0.1): self.mnist mnist_dataset self.threshold threshold def __len__(self): return len(self.mnist) def __getitem__(self, idx): img, label self.mnist[idx] # img: [1, 28, 28] # 去掉通道维度 img_np img.squeeze(0).numpy() grid_labels [] for i in range(4): for j in range(4): region img_np[i*7:(i1)*7, j*7:(j1)*7] mean_pixel region.mean() grid_labels.append(1.0 if mean_pixel self.threshold else 0.0) # 注意这里也可以同时保留数字类别作为附加标签 return img, torch.tensor(grid_labels, dtypetorch.float32)这里的阈值threshold0.1不是随便定的。MNIST 图像像素值经过归一化后背景部分接近 0数字笔画区域通常在 0.5 以上。7x7 区域里如果数字笔画穿过了该区域均值会明显大于 0.1如果只是边缘蹭到一点均值会很低。实际调试时可以打印几个区域的均值分布来确认阈值。不过这个方案有个隐患如果数字本身很小比如“1”字形很窄那么它可能只穿过 2 到 3 个格子其余 13 个格子全是 0。这会导致标签极度稀疏训练时模型容易偏向全部预测 0。要解决这个问题可以在网格标签之外额外保留数字类别标签。这样就变成了 10 类分类 16 个二分类的联合任务整体更有训练价值。4. 模型设计与训练细节4.1 多标签分类的模型改造思路多标签任务的模型结构其实不难。共享一个特征提取主干然后在头部拆出多个分支。以拼接法为例输入是 1x28x56 的图像主干用两层卷积输出展平后接两个全连接分支每个分支输出 10 个 logits对应 0 到 9 的数字。import torch.nn as nn class MultiLabelCNN(nn.Module): def __init__(self, num_classes10): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 32, kernel_size3, padding1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size3, padding1), nn.ReLU(), nn.MaxPool2d(2) ) # 经过两次 2x2 池化28x56 - 7x14 # 展平维度 64 * 7 * 14 6272 self.head1 nn.Linear(6272, num_classes) self.head2 nn.Linear(6272, num_classes) def forward(self, x): x self.features(x) x x.view(x.size(0), -1) out1 self.head1(x) out2 self.head2(x) return out1, out2这里两个分支是独立的没有共享最后的全连接层。这样设计的原因是两个数字在图像中的空间位置虽然固定但特征分布不完全相同各自独立的分类头能让模型针对位置信息做更精细的调整。如果你用的是合成属性法模型结构类似区别在于第二个分支的输出维度不是 10而是 1二分类或者把多个状态标签放到同一个多输出分支里。两个分支的输出都以 logits 形式返回不要在模型内部提前做 sigmoid。损失函数计算时会把 logits 和标签一起处理这样数值稳定性更好。4.2 损失函数与评估指标的选择多标签任务最常用的损失函数是BCEWithLogitsLoss。它内部先对 logits 做 sigmoid再计算二分类交叉熵规避了直接计算 sigmoid 后可能出现的梯度消失问题。拼接法的标签是两个数字类别每个数字类别本身是互斥的。理论上可以对每个数字分支单独做CrossEntropyLoss然后加起来。但为了演示多标签训练方式我统一使用BCEWithLogitsLoss。具体做法是把标签转换成 one-hot 向量变成 20 维然后对两个分支的 logits 做 concat跟 20 维标签计算损失。def train_one_epoch(model, dataloader, optimizer): model.train() total_loss 0 for imgs, labels in dataloader: # labels: [B, 2]分别是两个数字的类别索引 labels_onehot torch.zeros(imgs.size(0), 20) for i in range(2): labels_onehot[range(imgs.size(0)), i * 10 labels[:, i]] 1.0 out1, out2 model(imgs) logits torch.cat([out1, out2], dim1) loss nn.functional.binary_cross_entropy_with_logits(logits, labels_onehot) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() return total_loss / len(dataloader)评估指标方面多标签任务有自己专门的指标跟多分类不完全一样精确匹配率Exact Match Ratio所有标签都预测正确才算对这个指标比较严格汉明损失Hamming Loss预测错误的标签比例允许部分错误F1 宏平均 / 微平均对标签维度的查准率和查全率做综合评估。拼接法里如果只关注最终“两个数字是否同时正确”用精确匹配率最直观。但在实际业务中允许一个标签对、一个标签错的情况这时候汉明损失更合适。我自己的习惯是训练时看汉明损失评估时看精确匹配率两者结合不容易被单一指标带偏。4.3 训练过程与超参数设置拼接法数据集的训练超参数我沿用 MNIST 任务常见配置dataset ConcatMNISTDataset(mnist_data) dataloader DataLoader(dataset, batch_size128, shuffleTrue, num_workers0) model MultiLabelCNN() optimizer torch.optim.Adam(model.parameters(), lr1e-3) epochs 10 for epoch in range(epochs): loss train_one_epoch(model, dataloader, optimizer) print(fEpoch {epoch1}, Loss: {loss:.4f})在 CPU 环境下10 个 epoch 大约需要 3 到 5 分钟。MNIST 本身数据量小模型参数量也不大这个速度是完全可以接受的。调试时有几点需要特别留意。第一初始 loss 是否合理。二分类问题的初始 loss 应该在 log(2) 附近约 0.693。如果初始 loss 远超这个值说明标签或模型输出维度可能有问题。第二关注每个分支是否都学起来了。如果两个分支 loss 下降速度明显不一致大概率是一个位置的信息量比另一个多比如左边数字大、右边数字小。这时候可以在拼接时做随机缩放保持难度均衡。第三batch size 不要太小。多标签任务的梯度在多个标签维度上叠加batch size 太小会导致标签维度上的梯度噪声增大模型收敛变慢。5. 常见问题排查与避坑实录5.1 样本不平衡问题MNIST 本身类别分布基本均匀但改造后可能引入不平衡。比如拼接法里如果随机索引取图两张图出现相同数字的概率是 1/10这个比例本身还好。但如果你手动挑选某些组合比如只拼接“0”和“1”模型就会对这两个数字过拟合。合成属性法更需要注意不平衡。比如旋转概率设为 0.1那么 90% 的样本标记为“未旋转”模型学到的就是“全都预测未旋转”准确率也有 90%但根本没有学到特征。解决办法有几个把变换概率尽量均衡比如旋转概率设为 0.5用WeightedRandomSampler对少数类别过采样跑通流程后再逐步调整类别比例观察指标变化。5.2 标签相关性问题拼接法的两个标签在位置上是强相关的——左边位置对应第一个数字右边位置对应第二个数字。模型很容易偷懒只学“左边是啥、右边是啥”的位置映射而忽略数字本身特征。这个问题在做测试时会暴露如果我把拼接顺序换一下比如把左右图互换但标签顺序没换模型准确率会大幅下降。说明模型已经过拟合到位置信息而不是学到数字内容本身。为了避免这个陷阱可以在数据集里做一个小调整以一定概率交换左右图同时交换标签顺序。if np.random.rand() 0.5: img torch.cat([img2, img1], dim2) labels torch.tensor([label2, label1], dtypetorch.long) else: img torch.cat([img1, img2], dim2) labels torch.tensor([label1, label2], dtypetorch.long)这样模型无法依赖固定位置来判断数字必须真正理解两张图各自的内容。虽然训练难度有所提升但泛化能力明显更好。5.3 评估指标的错误使用很多初学者做多标签任务仍然沿用多分类的Accuracy标准算出来的数值很高但参考价值有限。举个例子区域分割法里 16 个标签如果大部分格子都是 0模型即使全部预测 0也能获得一个很高的准确率。但这对实际任务毫无意义。这时应该用汉明损失和 F1 指标才能真正反映模型对非空区域的识别能力。我在项目里做过一次对比同样训练好的模型用准确率评估是 89%但用 F1 宏平均评估只有 62%说明模型对少数类区域比如数字笔画覆盖的格子识别并不好。正确的做法是先确认标签的分布情况再选择指标。如果标签非常稀疏优先看 F1如果标签分布均匀精确匹配率就够用。6. 个人经验总结与后续扩展建议6.1 实际效果和经验体会在跑完这三种方案之后我个人比较推荐的是合成属性法。原因很简单它的标签设计最接近真实业务场景。真实的多标签任务里标签往往有不同类型有的是类别有的是状态有的是布尔判断。合成属性法把多种标签类型混合在一起模型训练时面临的问题更全面。拼接法的优点是直观、易于可视化适合快速验证多输出结构。但它的标签本质上只是两个独立的单标签拼在一起没有体现“标签间存在关联”这一多标签任务的特殊之处。区域分割法在实现上最具挑战性可解释性也最强。因为它把图像的局部信息跟标签做了一一对应训练过程能直观看到模型对不同区域的注意力分布。缺点是标签稀疏问题严重需要额外增加标签或调整阈值。如果你只想要一个快速跑通的 demo我建议从拼接法入手代码量最少模型结构也最简单。6.2 可以继续扩展的方向这套改造思路不仅能用在 MNIST 上稍加改动就可以迁移到其他数据集。比如 CIFAR-10 是 32x32 的彩色图类别是 10 类。可以用同样的拼接法构造双物体识别任务或者用合成属性法给图片添加模糊、色彩抖动等状态标签训练多标签模型。再比如在文本领域可以构造“主题类别 情感极性 是否含特定实体”的多标签数据集。方法本身是通用的核心在于如何把单标签数据改造出额外的标签维度。另外一个值得尝试的方向是把多种改造方案结合起来。比如先拼接两张图再对拼接后的图施加旋转这样标签变成 4 个左数字、右数字、是否旋转、是否加噪。整个任务复杂度提升明显但模型结构只需要增加对应数量的输出分支即可验证成本很低。我自己在后续实验中就把合成属性法和拼接法结合过用来模拟一个机器人抓取场景里的“物体识别 状态判断”任务。效果不错而且整个数据准备工作量不大是性价比很高的一种实验路径。如果你正在为多标签分类模型寻找训练数据不妨直接拿 MNIST 动手改一改跑通流程之后再考虑换用更大、更复杂的真实数据集做最终验证。这套组合拳打下来应该能帮你节省不少时间。