做视觉分类的同行应该都有这种体会找一份能直接扔进模型训练的多标签数据集往往比搭模型本身更费劲。公开的多标签数据要么图片大、标注杂要么下载链接和预处理脚本已经不知道被改了多少手。我自己在项目里绕了几圈之后反而觉得用MNIST手工合成多标签multi-label数据集是性价比最高的方案——样本干净、类别可控、标签体系完全由自己定义还能顺手验证模型在“标签共存”这件事上到底学没学会。这篇文章我会把几种主流的“单标签改造成多标签”思路讲透并给出可直接运行的Python代码。整套生成器在CPU上几分钟就能跑完不需要GPU也不需要你有一堆标注好的真实数据。如果你是刚接触多标签分类、或者在做算法验证前需要一批可控的测试数据这篇内容可以直接拿来用。1. 为什么非要用MNIST来折腾多标签1.1 真实多标签数据的获取成本高多标签分类在现实里的应用很广自动驾驶要同时判断“车、人、红绿灯”医疗影像要同时给出多个病灶标签文本任务里一篇文章也经常命中多个主题。但真实场景中的多标签数据并不好拿。首先是标注成本。单标签任务只需要标注者回答“这张图里是什么”多标签任务却要回答“这张图里有哪些东西”要去挨个确认每个候选标签是否出现标注工作量直接翻好几倍。其次是数据质量问题。公开数据集里类别分布、标签噪声、图像尺寸都不可控经常要花大量时间做清洗。最后是敏感性问题很多公司内部的业务数据不能外传你很难拿到贴合自己场景的现成样本。所以在项目早期或论文实验阶段用合成方式构造一个可控的多标签数据集就成了一种非常实用的替代方案。你可以精确控制正标签数量、类别相关性、图像难度从而检验模型在不同条件下的表现。1.2 MNIST作为合成基底的三个天然优势MNIST是经典的手写数字数据集训练集6万张、测试集1万张每张图是28×28的灰度图标签是0到9。拿它当多标签数据的“原料”有三个很具体的好处获取成本几乎为零不管PyTorch还是TensorFlow一行代码就能下载并加载不需要写复杂的爬虫或数据清洗脚本。类别体系简单干净只有10个数字白色前景、黑色背景生成的合成图一眼就能看出“有什么”人工检查非常方便。尺寸规整28×28的单通道图很小拼成网格后仍然是可接受的输入尺寸。对于快速实验来说十几个epoch跑完也不过几分钟。更重要的是MNIST是单标签数据我们可以完全掌控它变成多标签的过程。这一点很关键——只有你清楚标签是怎么生成的才能在模型效果不好时判断问题是出在数据还是出在算法。1.3 多标签数据集的三个基本要求不是随便把几个标签拼到一起就能叫“多标签数据集”。我在造数据时通常会自查三点标签之间允许共存同一个样本里应当出现至少两个同时为1的标签位否则本质上可能只是单标签换了个编码方式。人类肉眼能够验证图像里有没有对应标签要能被我们直接检查否则模型预测错了你也没法判断是模型问题还是标注问题。正负样本比例不能过分悬殊如果某个标签在所有样本中几乎不会出现模型学到的一般是“永远预测0”指标再高也没有意义。后面介绍的三条生成思路都会围绕这三点来展开。2. 三种改造思路的取舍拼接、属性拆分、像素叠加2.1 拼接法把标签从1个变成多个拼接法是我最推荐初学者先试的一种思路非常直观从MNIST里随机抽几张图把它们拼成一行或一个网格合成一张大图。标签不再是一个数字而是“这张大图里出现了哪些数字”的多热multi-hot向量。举个例子抽一张数字1和一张数字5左右拼接那么新样本的标签就是0: 否1: 是2: 否3: 否4: 否5: 是6: 否7: 否8: 否9: 否写成多热向量就是[0, 1, 0, 0, 0, 1, 0, 0, 0, 0]。拼接法的最大优点是可视化直观你可以直接看到多个数字在空间上并排很像真实场景里的多物体识别。另一个隐藏好处是它天然保留每个数字的位置信息方便以后扩展成目标检测任务。需要注意的坑也有。拼接会让图像尺寸变大你把4张28×28的图拼成2×2网格后是56×56如果不做处理直接扔进原始MNIST的模型结构会报形状不匹配错误。此外固定拼接位置会让模型学会“只在左上角找数字”这是一种位置泄漏后面第4章我会详细说。2.2 属性法不修改图像只重新定义标签第二种思路比较“取巧”但非常实用图像本身完全不变只把原来的单个类别标签映射成多个语义属性。比如数字8可以同时属于“偶数”、“大数大于等于5”、“形状带闭合圈”这几个属性。那它的标签就是一个多热向量每个位置代表一个属性是否存在。我常用的属性映射是数字偶数大数带竖线特征含闭合/近似闭合质数010010100100210001300001410000501001611010701100811010901010属性法的优点是生成速度极快几乎零成本。缺点也很明显这些属性是我根据先验知识人为定义的它们之间存在不同程度的相关性。比如“大数”和“含闭合圈”在数字6、8、9上同时出现模型很可能把这两个属性当成一个任务去学。这一点在4.3节我会展开讲怎么避免。2.3 叠加法模拟遮挡并增加难度第三种思路是把两张MNIST图像做像素级叠加生成一张两个数字重叠的新图。常见的做法是把两张图逐像素相加再除以2或做截断保证像素值仍在[0,1]区间。举个例子数字3和数字7叠加后重叠区域的笔画会变浓轮廓互相交叉识别起来会比不重叠时难很多。标签同样是两个类的多热向量。叠加法的价值在于更接近真实世界中的遮挡问题——一个物体被另一个物体挡住一部分时模型需要根据不完整的信息做判断。缺点是生成出来的图有时候连人都看不清尤其当两张图的笔画在空间上大量重合时模型训练难度会明显上升。我自己的习惯是把叠加法当作“压力测试”来用一般不作为默认训练集。想验证模型在有噪声、有遮挡的极端情况下的鲁棒性时它会是一个好工具。2.4 三种方案怎么选一张表讲清楚方案生成复杂度视觉难度标签独立性最适合的场景拼接法低低到中完全可控多物体识别、目标检测前的分类任务属性法极低低依赖属性定义属性分类、弱监督、快速验证叠加法低到中高完全可控遮挡鲁棒性、压力测试如果你只是想快速得到一个能跑通多标签流程的数据集我建议先用拼接法。它的视觉语义最清晰标签和图像内容的对应关系最容易检查遇到问题了也方便排查。3. 从零写一个生成器拼接法为主附完整代码3.1 环境准备PyTorch、torchvision与NumPy生成数据集不需要GPUCPU完全够用。我用的是PyTorch生态主要因为加载MNIST方便。你需要先确保环境里有这些库pip install torch torchvision numpy matplotlib版本方面我测试过PyTorch 2.x和torchvision 0.15以上都能正常运行。如果你的网络环境下载MNIST困难可以先把数据下载好放到本地torchvision会自动识别./data目录下的文件。3.2 原始MNIST的加载与预处理第一步是把原始MNIST加载进来并做最小程度的预处理——归一化到[0,1]顺便把标签转成NumPy数组方便后面处理。import numpy as np import torch import matplotlib.pyplot as plt from torchvision import datasets, transforms def load_mnist(root./data): transform transforms.Compose([transforms.ToTensor()]) train_set datasets.MNIST(rootroot, trainTrue, downloadTrue, transformtransform) test_set datasets.MNIST(rootroot, trainFalse, downloadTrue, transformtransform) train_images train_set.data.float() / 255.0 train_labels train_set.targets.numpy() test_images test_set.data.float() / 255.0 test_labels test_set.targets.numpy() # 添加通道维度变成 (N, 1, 28, 28) train_images train_images.unsqueeze(1) test_images test_images.unsqueeze(1) return train_images, train_labels, test_images, test_labels train_images, train_labels, test_images, test_labels load_mnist() print(train_images.shape, train_labels.shape)这里有一个容易被忽略的细节ToTensor()已经把像素归一化到了[0,1]但train_set.data拿到的原始值是0到255的整数Tensor。所以我在这里显式地除以255之后再unsqueeze(1)加上通道维度。这样后续拼图时像素值始终在可控范围。3.3 核心生成器2×2网格拼接与多热编码接下来是重头戏。我选择用2×2网格作为默认拼接方式因为相比左右拼接它让模型需要同时关注图像的不同区域任务难度更接近真实多物体场景。生成器做的事情可以拆成两步随机从数据集中抽4张图的索引。把这4张图拼成2×2网格并生成对应的多热标签。def build_grid2x2_dataset(images, labels, num_samples, seed42): rng np.random.default_rng(seed) n images.shape[0] sample_images [] sample_labels [] for _ in range(num_samples): idxs rng.integers(0, n, size4) top torch.cat([images[idxs[0]], images[idxs[1]]], dim2) # 横向拼接 bottom torch.cat([images[idxs[2]], images[idxs[3]]], dim2) # 横向拼接 grid torch.cat([top, bottom], dim1) # 纵向拼接 hot np.zeros(10, dtypenp.float32) for idx in idxs: hot[labels[idx]] 1.0 sample_images.append(grid) sample_labels.append(hot) return torch.stack(sample_images), np.stack(sample_labels) sample_images, sample_labels build_grid2x2_dataset( train_images, train_labels, num_samples10000, seed0 ) print(sample_images.shape, sample_labels.shape)这里多热编码的生成逻辑很直接遍历4个索引如果某个数字出现了就把对应位置置1。即使同一样本里出现两个相同的数字比如抽到两张数字7该位置也只是1因为我们关心的是“这张图里有没有7”而不是“7出现了几次”。这符合多标签分类对“存在性”的定义。3.4 控制标签组合分布的分层采样上面这段代码里有个隐患——rng.integers(0, n, size4)是纯随机采样。MNIST原始类别分布并不均衡数字0的样本多、数字5的样本少纯随机抽到的4张图里更容易出现高频数字。这样生成的组合标签分布会偏移低頻数字几乎不怎么参与。更可控的做法是先选择“这张图里有哪几个类别”再从对应类别池里抽具体图像。这样你能精确控制每个类别的出现概率。def build_balanced_grid2x2(images, labels, num_samples, min_digits1, max_digits4, seed42): rng np.random.default_rng(seed) class_indices [np.where(labels c)[0] for c in range(10)] sample_images [] sample_labels [] for _ in range(num_samples): k rng.integers(min_digits, max_digits 1) chosen_classes rng.choice(10, sizek, replaceFalse) hot np.zeros(10, dtypenp.float32) picked_idx [] for c in chosen_classes: hot[c] 1.0 pool class_indices[c] picked_idx.append(int(rng.choice(pool))) # 不足4张的部分用随机重复补充之后在排列时打乱顺序 while len(picked_idx) 4: picked_idx.append(int(rng.choice(class_indices[rng.integers(0, 10)]))) rng.shuffle(picked_idx) top torch.cat([images[picked_idx[0]], images[picked_idx[1]]], dim2) bottom torch.cat([images[picked_idx[2]], images[picked_idx[3]]], dim2) grid torch.cat([top, bottom], dim1) sample_images.append(grid) sample_labels.append(hot) return torch.stack(sample_images), np.stack(sample_labels)通过min_digits和max_digits参数你可以控制每张合成图里包含几个不同数字。这一步很重要因为如果max_digits1那多标签问题直接退化成单标签你后面验证多标签算法就没有意义了。3.5 可视化和落盘生成完数据后一定要先肉眼检查一遍别急着训练。用matplotlib把样本和标签画出来def visualize_multilabel(images, labels, row2, col5): fig, axes plt.subplots(row, col, figsize(12, 5)) for i in range(row * col): ax axes[i // col][i % col] ax.imshow(images[i].squeeze(), cmapgray) present [str(j) for j in range(10) if labels[i][j] 1] ax.set_title(.join(present), fontsize12) ax.axis(off) plt.tight_layout() plt.show() visualize_multilabel(sample_images, sample_labels)看到图像里确实有对应数字标签也和视觉吻合再考虑保存。我一般用np.savez_compressed保存为压缩的.npz文件读取简单、不依赖额外工具np.savez_compressed( mnist_multilabel_grid2x2.npz, imagessample_images.numpy(), labelssample_labels, )如果你的数据量到了几十万张以上或者需要切片式随机读取建议改用HDF5格式。那个场景下npz加载整份数据到内存的效率就不够看了。4. resize、采样、标签设计生成过程中最容易翻车的四个细节4.1 拼接尺寸与数字识别要不要resize回28×28很多第一次做拼接数据的人会习惯性把拼接后的图直接resize回28×28因为“MNIST本来就是这个尺寸”。这个操作会让2×2网格里的每个数字缩成14×14左右笔画细节大面积丢失模型识别难度陡然上升。我的建议是尽量保持拼接后的真实尺寸。2×2网格是56×56模型输入也设置成56×56只需要把网络第一层的卷积核保持在3×3左右就能保留足够的空间细节。如果非要沿用28×28输入更合理的做法是做左右拼接加边距填充而不是2×2网格压缩。还有一个和拼接尺寸相关的坑是位置泄漏。如果你生成图像时永远把第一个数字放在左上、第二个放在右上模型就会学会“只看左上角有没有这个数字”而不是全局判断。这在训练集指标上会很好看一换到新位置分布就崩。我在生成器里用rng.shuffle(picked_idx)打乱四张图的排列位置正是为了让模型学到“图中哪里出现这个数字都算出现”的位置无关特征。4.2 随机采样会让哪些标签组合出现频率失控纯随机采样会带来长尾分布。数字0、1这类高频数字在合成样本里几乎无处不在而数字5、8这类相对低频的数字参与组合的次数很少。一旦某个标签在训练集里出现频率过低模型对它基本就是摆烂预测0最终各项指标看起来不差但本质上那个类完全没学会。解决办法就是我在3.4节里写的分层采样不直接按图像索引随机抽而是先决定“这4张图包含哪些类别”再从类别池里抽图。这样做还有一个额外的好处——标签组合的分布可以由你控制。如果你是想验证模型在长尾标签组合下的表现那就可以刻意构造一个多数类组合占比高、少数类组合占比低的数据集。反过来如果想要一个公平评估多标签模型能力的基准数据就尽量均衡采样。4.3 属性标签里容易藏一个单标签陷阱属性法最大的陷阱是设计属性时一不小心引入了一组互斥标签。比如你在属性向量里同时放了“是偶数”和“是奇数”那任何样本都只有一个属性为1这本质上仍然是单标签分类只是把原本的10类分数字变成了2类属性判断完全失去了多标签的意义。即使不完全是互斥关系属性之间高度相关也会掩盖问题。比如我在2.2节里的属性“大数”和“含闭合圈”它们在数字6、8、9上几乎同时出现模型很容易只学其中一个输出另一个输出直接复制前一个结论。你从指标上看两个标签都预测得不错但真实的泛化能力很差。所以我建议设计属性时要查看标签共现矩阵人工确认至少存在一批“两个属性同时为1”的样本并且最好有一些“一个属性为1、另一个为0”的反例。这样任务才真正需要模型做多标签判断。4.4 训练集和测试集必须沿用同一套生成规则这是我踩过最深的一个坑。生成训练集时用的是seed0、2×2模式、分层采样生成测试集时随手换了个seed或者把max_digits参数从4调成了2。结果训练集和测试集的数据分布完全不一致模型在测试集上的指标莫名其妙掉了一截花了好长时间才排查出来是数据生成规则变了。正确做法是把训练集和测试集交给同一个生成函数只更换随机种子来保证样本不重复。如果你想在测试时增加数据难度比如把测试集改成固定顺序拼接也要在记录里明确写清楚做到心里有数。另外验证集要保证没有和训练集相同的合成样本。由于每张图都是随机组合出来的合成样本重复的概率很低但如果数据量小、随机种子固定还是有可能出现。稳妥的做法是在生成时维护一组已使用的索引四元组遇到重复就重新采样。5. 拿自建数据集跑一个多标签基线模型5.1 针对多标签的模型输出与损失函数数据生成好之后最直接的验证方式是跑一个简单的多标签分类模型。这里不需要很复杂的网络一个小型CNN就够了。和单标签分类的关键区别在输出层和损失函数单标签用softmax CrossEntropyLoss。多标签用sigmoid BCEWithLogitsLoss。原因是softmax会让10个类别的预测概率彼此压制迫使模型只能选一个而多标签里我们要的是每个标签独立判断“有没有”所以每个输出节点走单独的sigmoid。import torch.nn as nn class SimpleMultiLabelCNN(nn.Module): def __init__(self, num_classes10): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 16, 3, padding1), nn.BatchNorm2d(16), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(16, 32, 3, padding1), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding1), nn.BatchNorm2d(64), nn.ReLU(), nn.AdaptiveAvgPool2d(1), ) self.classifier nn.Linear(64, num_classes) def forward(self, x): x self.features(x) return self.classifier(x.flatten(1))训练循环也不需要什么特殊魔法import torch.optim as optim from torch.utils.data import DataLoader, TensorDataset device torch.device(cuda if torch.cuda.is_available() else cpu) model SimpleMultiLabelCNN().to(device) criterion nn.BCEWithLogitsLoss() optimizer optim.Adam(model.parameters(), lr1e-3) train_dataset TensorDataset(sample_images, torch.from_numpy(sample_labels)) train_loader DataLoader(train_dataset, batch_size256, shuffleTrue) def train_one_epoch(model, loader, optimizer, criterion): model.train() total_loss 0.0 for imgs, labels in loader: imgs, labels imgs.to(device), labels.to(device) logits model(imgs) loss criterion(logits, labels) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() * imgs.size(0) return total_loss / len(loader.dataset) for epoch in range(10): loss train_one_epoch(model, train_loader, optimizer, criterion) print(fepoch {epoch 1}, loss: {loss:.4f})用你自己的合成数据跑通这个流程多标签分类的整个数据到训练闭环就完成了。5.2 三个关键评估指标Hamming Loss、子集准确率和宏F1多标签分类不能用普通的accuracy因为“预测对一个标签、预测错另一个标签”这种部分正确的情况在单标签里不存在在多标签里却很常见。我通常一起看三个指标Hamming Loss所有标签位上预测错误的比例越低越好。它对“部分错误”很敏感。from sklearn.metrics import hamming_loss, accuracy_score, f1_score def evaluate(model, images, labels, threshold0.5): model.eval() with torch.no_grad(): logits model(images.to(device)) preds (torch.sigmoid(logits) threshold).float().cpu().numpy() h_loss hamming_loss(labels, preds) subset_acc accuracy_score(labels, preds) macro_f1 f1_score(labels, preds, averagemacro, zero_division0) return h_loss, subset_acc, macro_f1子集准确率Subset Accuracy整条预测标签向量和真实标签向量完全一致才算对。这是最严格的指标。宏F1Macro F1每个标签单独算F1再取平均避免高频标签主导整体分数。只看Hamming Loss容易出现“模型全部预测0也得分不错”的错觉这时候结合子集准确率和宏F1一起看才能判断模型是真的在识别标签还是在抄底分布。5.3 我在验证时看到的几个直观现象拿这套合成数据跑了几轮之后有几个现象让我印象比较深。第一个现象打乱拼接顺序会让训练变慢但测试更稳。固定拼接位置时训练集的子集准确率很容易冲高换个布局就下降随机打乱位置后前几个epoch损失下降会慢一些但模型学到的是更本质的数字特征。第二个现象叠加数据上模型容易“漏报”。两个数字重叠后模型经常只预测出一个类另一个类的置信度在0.5阈值附近摇摆。这说明多标签模型在模糊区域天然倾向于做保守预测针对这类数据需要调低阈值或使用类别加权损失。第三个现象属性法的指标好到失真。因为标签是从类别直接导出的模型其实可以靠类别的隐藏特征做预测指标虚高不等于模型学到了你自己的属性定义。要用属性法验证算法必须在标签里人为注入噪声或者选择更独立的属性集合。5.4 接续扩展从网格拼接到目标检测和实例分割把2×2网格拼接这套生成器再往前推一步就能得到更有价值的数据形态。比如你在生成时记录每个数字在原图中的位置坐标那么每张合成图不仅有多标签还有对应的边界框信息。这其实就是一个“多标签检测框”的数据集足够用来预训练或者快速验证检测模型。想更进一步的话生成网格时保留下每个数字独立的像素掩码再组合成完整图像就把数据扩展成了实例分割风格。从纯MNIST多标签到带框检测再到实例分割整个链路其实都是用同一个小部件不断升级出来的。把第一步的生成器写干净后面都是水到渠成的事。