ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用MNIST手工合成多标签数据集:三种改造思路与完整代码

用MNIST手工合成多标签数据集:三种改造思路与完整代码

做视觉分类的同行应该都有这种体会:找一份能直接扔进模型训练的多标签数据集,往往比搭模型本身更费劲。公开的多标签数据要么图片大、标注杂,要么下载链接和预处理脚本已经不知道被改了多少手。我自己在项目里绕了几圈之后,反而觉得用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. 标签之间允许共存:同一个样本里应当出现至少两个同时为1的标签位,否则本质上可能只是单标签换了个编码方式。
  2. 人类肉眼能够验证:图像里有没有对应标签,要能被我们直接检查,否则模型预测错了你也没法判断是模型问题还是标注问题。
  3. 正负样本比例不能过分悬殊:如果某个标签在所有样本中几乎不会出现,模型学到的一般是“永远预测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)”、“形状带闭合圈”这几个属性。那它的标签就是一个多热向量,每个位置代表一个属性是否存在。

我常用的属性映射是:

数字偶数大数带竖线特征含闭合/近似闭合质数
010010
100100
210001
300001
410000
501001
611010
701100
811010
901010

属性法的优点是生成速度极快,几乎零成本。缺点也很明显:这些属性是我根据先验知识人为定义的,它们之间存在不同程度的相关性。比如“大数”和“含闭合圈”在数字6、8、9上同时出现,模型很可能把这两个属性当成一个任务去学。这一点在4.3节我会展开讲怎么避免。

2.3 叠加法:模拟遮挡并增加难度

第三种思路是把两张MNIST图像做像素级叠加,生成一张两个数字重叠的新图。常见的做法是把两张图逐像素相加,再除以2或做截断,保证像素值仍在[0,1]区间。

举个例子,数字3和数字7叠加后,重叠区域的笔画会变浓,轮廓互相交叉,识别起来会比不重叠时难很多。标签同样是两个类的多热向量。

叠加法的价值在于更接近真实世界中的遮挡问题——一个物体被另一个物体挡住一部分时,模型需要根据不完整的信息做判断。缺点是生成出来的图有时候连人都看不清,尤其当两张图的笔画在空间上大量重合时,模型训练难度会明显上升。

我自己的习惯是把叠加法当作“压力测试”来用,一般不作为默认训练集。想验证模型在有噪声、有遮挡的极端情况下的鲁棒性时,它会是一个好工具。

2.4 三种方案怎么选:一张表讲清楚

方案生成复杂度视觉难度标签独立性最适合的场景
拼接法低低到中完全可控多物体识别、目标检测前的分类任务
属性法极低低依赖属性定义属性分类、弱监督、快速验证
叠加法低到中高完全可控遮挡鲁棒性、压力测试

如果你只是想快速得到一个能跑通多标签流程的数据集,我建议先用拼接法。它的视觉语义最清晰,标签和图像内容的对应关系最容易检查,遇到问题了也方便排查。

3. 从零写一个生成器:拼接法为主,附完整代码

3.1 环境准备:PyTorch、torchvision与NumPy

生成数据集不需要GPU,CPU完全够用。我用的是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(root=root, train=True, download=True, transform=transform) test_set = datasets.MNIST(root=root, train=False, download=True, transform=transform) 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网格作为默认拼接方式,因为相比左右拼接,它让模型需要同时关注图像的不同区域,任务难度更接近真实多物体场景。

生成器做的事情可以拆成两步:

  1. 随机从数据集中抽4张图的索引。
  2. 把这4张图拼成2×2网格,并生成对应的多热标签。
def build_grid2x2_dataset(images, labels, num_samples, seed=42): rng = np.random.default_rng(seed) n = images.shape[0] sample_images = [] sample_labels = [] for _ in range(num_samples): idxs = rng.integers(0, n, size=4) top = torch.cat([images[idxs[0]], images[idxs[1]]], dim=2) # 横向拼接 bottom = torch.cat([images[idxs[2]], images[idxs[3]]], dim=2) # 横向拼接 grid = torch.cat([top, bottom], dim=1) # 纵向拼接 hot = np.zeros(10, dtype=np.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_samples=10000, seed=0 ) print(sample_images.shape, sample_labels.shape)

这里多热编码的生成逻辑很直接:遍历4个索引,如果某个数字出现了,就把对应位置置1。即使同一样本里出现两个相同的数字(比如抽到两张数字7),该位置也只是1,因为我们关心的是“这张图里有没有7”,而不是“7出现了几次”。这符合多标签分类对“存在性”的定义。

3.4 控制标签组合分布的分层采样

上面这段代码里有个隐患——rng.integers(0, n, size=4)是纯随机采样。MNIST原始类别分布并不均衡,数字0的样本多、数字5的样本少,纯随机抽到的4张图里更容易出现高频数字。这样生成的组合标签分布会偏移,低頻数字几乎不怎么参与。

更可控的做法是先选择“这张图里有哪几个类别”,再从对应类别池里抽具体图像。这样你能精确控制每个类别的出现概率。

def build_balanced_grid2x2(images, labels, num_samples, min_digits=1, max_digits=4, seed=42): 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, size=k, replace=False) hot = np.zeros(10, dtype=np.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]]], dim=2) bottom = torch.cat([images[picked_idx[2]], images[picked_idx[3]]], dim=2) grid = torch.cat([top, bottom], dim=1) sample_images.append(grid) sample_labels.append(hot) return torch.stack(sample_images), np.stack(sample_labels)

通过min_digits和max_digits参数,你可以控制每张合成图里包含几个不同数字。这一步很重要,因为如果max_digits=1,那多标签问题直接退化成单标签,你后面验证多标签算法就没有意义了。

3.5 可视化和落盘

生成完数据后,一定要先肉眼检查一遍,别急着训练。用matplotlib把样本和标签画出来:

def visualize_multilabel(images, labels, row=2, col=5): 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(), cmap="gray") present = [str(j) for j in range(10) if labels[i][j] == 1] ax.set_title("+".join(present), fontsize=12) 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", images=sample_images.numpy(), labels=sample_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 训练集和测试集必须沿用同一套生成规则

这是我踩过最深的一个坑。生成训练集时用的是seed=0、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_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 16, 3, padding=1), nn.BatchNorm2d(16), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(16, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding=1), 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(), lr=1e-3) train_dataset = TensorDataset(sample_images, torch.from_numpy(sample_labels)) train_loader = DataLoader(train_dataset, batch_size=256, shuffle=True) 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(f"epoch {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, threshold=0.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, average="macro", zero_division=0) return h_loss, subset_acc, macro_f1

子集准确率(Subset Accuracy):整条预测标签向量和真实标签向量完全一致才算对。这是最严格的指标。

宏F1(Macro F1):每个标签单独算F1再取平均,避免高频标签主导整体分数。

只看Hamming Loss容易出现“模型全部预测0也得分不错”的错觉,这时候结合子集准确率和宏F1一起看,才能判断模型是真的在识别标签,还是在抄底分布。

5.3 我在验证时看到的几个直观现象

拿这套合成数据跑了几轮之后,有几个现象让我印象比较深。

第一个现象:打乱拼接顺序会让训练变慢,但测试更稳。固定拼接位置时,训练集的子集准确率很容易冲高,换个布局就下降;随机打乱位置后,前几个epoch损失下降会慢一些,但模型学到的是更本质的数字特征。

第二个现象:叠加数据上模型容易“漏报”。两个数字重叠后,模型经常只预测出一个类,另一个类的置信度在0.5阈值附近摇摆。这说明多标签模型在模糊区域天然倾向于做保守预测,针对这类数据需要调低阈值或使用类别加权损失。

第三个现象:属性法的指标好到失真。因为标签是从类别直接导出的,模型其实可以靠类别的隐藏特征做预测,指标虚高不等于模型学到了你自己的属性定义。要用属性法验证算法,必须在标签里人为注入噪声,或者选择更独立的属性集合。

5.4 接续扩展:从网格拼接到目标检测和实例分割

把2×2网格拼接这套生成器再往前推一步,就能得到更有价值的数据形态。比如你在生成时记录每个数字在原图中的位置坐标,那么每张合成图不仅有多标签,还有对应的边界框信息。这其实就是一个“多标签+检测框”的数据集,足够用来预训练或者快速验证检测模型。

想更进一步的话,生成网格时保留下每个数字独立的像素掩码,再组合成完整图像,就把数据扩展成了实例分割风格。从纯MNIST多标签,到带框检测,再到实例分割,整个链路其实都是用同一个小部件不断升级出来的。把第一步的生成器写干净,后面都是水到渠成的事。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进