文章目录
- 概要
- 背景
- 数据集与预处理
- 方法概览
- 算法原理
- 实验设置
- 最终结果
- 总结与展望
- 参考
概要
此实验核心代码在https://github.com/xuyihang894-bot/Curriculum-design—Fashion-MNIST-improvement-experiment给出,请结合原开源项目重新部署
Fashion-MNIST是GitHub上的经典开源项目,以下是项目链接 https://github.com/zalandoresearch/fashion-mnist
这个实验研究一个很现实的问题:当一个图像分类模型在某种风格的图片上训练好之后,把它放到颜色、背景完全不同的图片上,它还认得出来吗?本文对比了传统机器学习的随机森林、神经网络 CNN,以及一个针对性的改进策略——颜色随机化数据增强的结果来回答–模型能否泛化到未见过的颜色?
背景
深度神经网络在"独立同分布"假设下表现非常好——也就是训练和测试数据来自同一个分布。但现实中这个假设几乎从不成立:换个相机、换个光照、换个背景,分布就变了,这就是"分布外泛化"问题,英文叫 OOD generalization。 我把这个问题做成了一个最小可控的实验:训练集是黑底白色前景,就是普通的二值化服饰图;测试集是随机彩色背景加随机彩色前景,而且前景和背景颜色不一样。
训练集:黑背景 + 白前景 (二值化) 
测试集:随机彩色背景 + 随机彩色前景,二者颜色不同 
"最小可控"是关键——只改颜色这一个变量,其它(形状、类别、分辨率)全部不变,这样实验结论才干净,能明确把性能下降归因于颜色偏移。 最终回答的核心问题就是:模型能不能泛化到它从没见过的颜色?
数据集与预处理
原始数据: Fashion-MNIST,6 万张训练、1 万张测试,28×28 灰度,10 个服饰类别。
训练分布:把灰度图按阈值 127 二值化,变成纯黑(0)纯白(255),再复制成 3 通道,方便和 CNN 的彩色输入对齐,复制成 3 通道是为了让训练集和测试集张量形状一致(都是 3×28×28),同一个 CNN 不用改结构就能处理两种数据。
二值化(黑底白前景)代码:
def binarize(images, threshold=127):
return (images > threshold).astype(np.uint8) * 255 # 输出纯 {0, 255}
关键点:原图边缘有灰度过渡,二值化后前景 / 背景边界干净,后面合成彩色图时不会出现脏边或半透明的颜色混叠。
OOD 测试集:先同样二值化拿到"前景 mask",然后给每一张图随机抽一个前景色、一个背景色,用 mask 把它们合成上去,并且约束两个颜色的 L1 距离至少 0.3,保证可分辨。
OOD 测试集合成代码:
def make_ood_test(images, seed=42, min_distance=80):
rng = np.random.default_rng(seed)
binary = binarize(images) # (N,28,28) ∈ {0,255}
mask = (binary > 0).astype(np.float32)[:, None, :, :] # 前景 mask (N,1,28,28)
bg, fg = _sample_color_pairs(images.shape[0], min_distance, rng)
bg = bg.astype(np.float32)[:, :, None, None] / 255.0 # (N,3,1,1)
fg = fg.astype(np.float32)[:, :, None, None] / 255.0
out = mask * fg + (1.0 – mask) * bg # 前景填 fg,背景填 bg
return out.astype(np.float32)
关键点:逐样本随机抽一组前景色、背景色,用前景 mask 把它们「刷」上去。固定 seed=42 保证每次评测面对同一批彩色图,结果可复现。
保证前景背景颜色可分代码:
def _sample_color_pairs(n, min_distance=80, rng=None):
bg = rng.integers(0, 256, size=(n, 3))
fg = rng.integers(0, 256, size=(n, 3))
for _ in range(8): # 最多重采样 8 次
too_close = np.abs(bg – fg).sum(1) < min_distance
if not too_close.any():
break
fg[too_close] = rng.integers(0, 256, size=(int(too_close.sum()), 3))
return bg.astype(np.uint8), fg.astype(np.uint8)
关键点:约束前景与背景的 L1 颜色距离 ≥ 80/765,否则会出现前后景几乎同色、人眼都分不出的退化样本,污染评测。
评价指标:Accuracy(Train / Test-ID / Test-OOD)、混淆矩阵(10 × 10)、训练曲线(loss + 三组 accuracy) 、t-SNE 特征分布、还有逐类 F1 和错误样本,数值和可视化双重验证。
需要注意的是:训练集本身保持黑白不加色(这是"域偏移"的源头);只有测试集上色。这正是 OOD 的定义——训练时没见过的分布。
方法概览
传统机器学习选随机森林,200 棵树,输入是 784 维的灰度向量;它是经典的非线性分类器,训练快,作为传统方法的代表,树模型对像素特征鲁棒、训练几秒就完成、且天然非线性,是传统方法里很有代表性的强基线。
神经网络选择自己搭建的小型 CNN,两个卷积块加全局平均池化,输入是 3 通道彩色;它有空间归纳偏置,能端到端学特征,自建 SmallCNN 而不直接用 ResNet是因为28×28 小图不需要很深的网络,自建小网络可控、训练快、便于讲清楚每一层在做什么。
由于CNN 是可微的、能直接在训练 loop 里加增强,最适合验证改进策略。改进策略是CNN 加颜色随机化增强,训练的时候在线把黑白图随机重新上色,相当于把测试分布提前"剧透"给模型。
三个模型的训练/评测代码全部共享同一套数据和评测函数,保证严谨性。
算法原理
简单讲一下两个算法的原理
随机森林这个公式,简单来说就是很多棵决策树投票,少数服从多数。每棵树在数据的 bootstrap 采样子集上训练,每个节点只从随机的根号 d 个特征里挑最优分裂,这种"双重随机"让森林不容易过拟合。我把彩色图按标准亮度公式转成灰度,再拉平成 784 维喂进去。
随机森林代码:
def train_rf(x_train_rgb, y_train, *, n_estimators=200, seed=0):
x_tr = to_grayscale_flat(x_train_rgb) # RGB → 灰度 → 拉平成 784 维
clf = RandomForestClassifier(n_estimators=n_estimators, n_jobs=–1,
random_state=seed)
clf.fit(x_tr, y_train)
return clf
灰度转换代码:
gray = 0.299*img[:, 0] + 0.587*img[:, 1] + 0.114*img[:, 2] # ITU-R 标准亮度权重
关键点:让随机森林只看亮度、看不到颜色,用来观察–即使抹掉颜色信息,OOD 偏移是否仍然伤害它——答案是会,因为部分样本的亮度被反转了(亮前景变暗前景)。
cnn:输入 3×28×28,经过两个卷积块,每块是两个卷积加 BatchNorm 加 ReLU 再下采样,通道数从 32 到 64 到 128,最后全局平均池化成 128 维,接全连接和 Dropout,输出 10 类。BN 加速收敛、GAP 大幅减少参数、抗过拟合,适合小数据小模型。训练用 AdamW 优化器加余弦退火学习率,跑 8 个 epoch。
神经网络代码:
class SmallCNN(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(3,32,3,padding=1), nn.BatchNorm2d(32), nn.ReLU(True),
nn.Conv2d(32,32,3,padding=1), nn.BatchNorm2d(32), nn.ReLU(True),
nn.MaxPool2d(2), # 14×14
nn.Conv2d(32,64,3,padding=1), nn.BatchNorm2d(64), nn.ReLU(True),
nn.Conv2d(64,64,3,padding=1), nn.BatchNorm2d(64), nn.ReLU(True),
nn.MaxPool2d(2), # 7×7
nn.Conv2d(64,128,3,padding=1),nn.BatchNorm2d(128), nn.ReLU(True),
nn.AdaptiveAvgPool2d(1), # GAP -> 128
)
self.fc1 = nn.Linear(128, 128)
self.fc2 = nn.Linear(128, num_classes)
self.dropout = nn.Dropout(0.3)
def embed(self, x): # 返回倒数第二层 128 维特征,供 t-SNE
z = self.features(x).flatten(1)
return F.relu(self.fc1(z))
def forward(self, x):
return self.fc2(self.dropout(self.embed(x)))
关键点:embed() 单独留出口,是为了 t-SNE 能取到特征表示而不是最终的 10 维 logits——特征层最能反映模型学到了什么。BatchNorm 加速收敛、GAP 大幅减参数防过拟合,适合小图小模型。
改进策略 改进的思路特别朴素:既然测试集是随机颜色,那我训练的时候也让它见遍各种颜色。 
上面这个公式就是核心:对每张黑白图,取前景 mask,给前景配一个随机色、背景配另一个随机色,合成出来。注意这是在每一个 mini-batch 在线生成的,不是事先存好的,所以模型每次看到的配色都不一样,相当于无限多的颜色组合。 以下是关键代码,简单来讲就是:算 mask、各采一个随机色、约束两色距离、合成返回。它不需要改网络结构、不需要新增任何数据,纯粹是训练时的一个数据变换。这就是它优雅的地方。
颜色随机化数据增强代码:
def random_color_recolor(batch_bw, min_distance=0.30, p=1.0):
B, C, H, W = batch_bw.shape
device = batch_bw.device
mask = (batch_bw.mean(dim=1, keepdim=True) > 0.5).float() # 前景 mask
bg = torch.rand(B, 3, device=device) # 每张图随机背景色
fg = torch.rand(B, 3, device=device) # 每张图随机前景色
for _ in range(6): # 保证 |bg-fg|_1 >= min_distance
too_close = (bg – fg).abs().sum(1) < min_distance
if not too_close.any():
break
fg[too_close] = torch.rand(int(too_close.sum()), 3, device=device)
bg, fg = bg.view(B,3,1,1), fg.view(B,3,1,1)
recolored = mask * fg + (1.0 – mask) * bg
if p < 1.0: # 可选:保留一部分黑白图混合训练
keep = (torch.rand(B, device=device) < p).view(B,1,1,1).float()
return keep * recolored + (1.0 – keep) * batch_bw
return recolored
颜色约束:|bg – fg|₁ ≥ 0.30,不满足则对那些样本重采样。这里颜色约束的原因与前文提到的相同,约束颜色距离太近会产生前后景几乎同色的样本,相当于给模型喂噪声标签,干扰训练;0.30 是经验上的平衡点
这个策略把"颜色"变成了一个完全随机、与标签无关的变量,模型为了降 loss 只能去学颜色无关的形状特征——这正是 OOD 泛化需要的。
关键点: 1.在线生成——每个 mini-batch 都换新配色,等价于无限多的颜色组合,多样性远超预先扩充数据集,且不占额外存储。 2.与 data.py 的 OOD 构造同分布,相当于把测试分布"剧透"给训练,逼模型学颜色无关的形状特征。 3.纯张量整批运算,GPU/CPU 上几乎零额外开销;不改网络结构、不新增数据。
实验设置
实验设置如表格所示
baseline 与增强版几乎共享同一套训练代码。
def train_cnn(..., epochs=8, lr=1e-3, batch_size=256, use_color_aug=False):
model = SmallCNN(10).to(device)
optim = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=1e-4)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optim, T_max=epochs)
for ep in range(1, epochs + 1):
model.train()
for x, y in train_loader:
x, y = x.to(device), y.to(device)
if use_color_aug: # 改进开关
x = random_color_recolor(x, min_distance=0.30, p=1.0)
loss = F.cross_entropy(model(x), y)
optim.zero_grad(); loss.backward(); optim.step()
scheduler.step()
# 每轮都评测 ID 和 OOD,记录到 history(供画曲线)
_, id_acc, _, _ = evaluate(model, id_loader, device)
_, ood_acc, _, _ = evaluate(model, ood_loader, device)
关键点:两个模型共用代码、共用 seed、共用测试集,保证对比公平——性能差异只来自有没有颜色增强。
强调3点:一是用三组准确率——训练、同分布测试、分布外测试,分别反映拟合能力、常规泛化、和鲁棒性;二是四类可视化交叉验证;三是全程固定随机种子,结果可以完全复现。 
最终结果
三组准确率(Train / ID / OOD)都来自同一个评估函数,代码如下:
@torch.no_grad()
def evaluate(model, loader, device):
model.eval()
correct = total = 0
preds_all, labels_all = [], []
for x, y in loader:
x, y = x.to(device), y.to(device)
preds = model(x).argmax(dim=1)
correct += (preds == y).sum().item()
total += y.size(0)
preds_all.append(preds.cpu().numpy())
labels_all.append(y.cpu().numpy())
return loss/total, correct/total, np.concatenate(preds_all), np.concatenate(labels_all)
随机森林对称地走灰度路径:
def predict_rf(clf, x_rgb):
return clf.predict(to_grayscale_flat(x_rgb)) # 同样 RGB → 灰度 → 预测
先看随机森林:训练准确率几乎 100%,同分布测试 84%,但分布外掉到 28%。再看 CNN baseline:训练 92%、同分布 87%,看起来比随机森林还好,但分布外只有 16%——居然比随机森林还差! 这是第一个反直觉的发现:CNN 在常规任务上更强,却在 OOD 上更脆弱。原因是它的 BatchNorm 假设三个通道分布一致,测试时颜色一打乱,这个统计假设就崩了。
再看加了颜色增强的 CNN:分布外准确率冲到 84%,从 16% 提升了 68 个百分点,是原来的 5.2 倍。 而且更有意思的是——它的 OOD 准确率 84% 甚至反超了同分布的 82%,说明模型学到了和颜色无关的形状表征。
为什么 CNN baseline 比随机森林还差?核心是 BatchNorm:它在训练时统计的是"三通道相等"的黑白图的均值方差,测试时彩色图三通道差异巨大,归一化直接失效,特征全乱。这是一个很好的教学点——归纳偏置用错地方反而有害。
为什么增强后 OOD > ID?因为增强训练见过的颜色组合极其丰富,模型对"任意配色"都鲁棒;而 ID 测试是黑白图,反而是增强分布里相对"边角"的一种情况,加上增强带来的轻微正则,最终 OOD 略高于 ID。这也揭示一个权衡:增强后 ID 从 87% 略降到 82%,用一点同分布精度换来巨大的 OOD 提升,非常划算。
随机森林 OOD 28% 的失败原因和 CNN 不同:它看的是灰度,当某些样本"亮前景+暗背景"被换成"暗前景+亮背景"时,相当于亮度反相,等于没见过的输入。 
def save_training_curves(history, title, filename):
axes[1].plot(epochs, history["train_acc"], label="train")
axes[1].plot(epochs, history["val_acc_id"], label="val(ID)")
axes[1].plot(epochs, history["val_acc_ood"], label="val(OOD)") # 关键对比线
训练曲线:每个 epoch 记录 train_loss / train_acc / val_acc_id / val_acc_ood,用 matplotlib 画双子图。
上面的图片是训练曲线。左边是增强版 CNN:可以看到训练、同分布、分布外三条准确率曲线一起往上走,OOD 稳定爬到 84%。右边是 baseline:训练和同分布一路涨到 90% 左右,但分布外那条绿线从头到尾贴在 16% 不动——典型的"越训练越过拟合到黑色背景"。 
# 1) 从倒数第二层抽特征
@torch.no_grad()
def extract_features(model, x, device, batch_size=512):
feats = []
for i in range(0, len(x), batch_size):
z = model.embed(torch.from_numpy(x[i:i+batch_size]).to(device)) # embed()
feats.append(z.cpu().numpy())
return np.concatenate(feats, 0)
# 2) t-SNE 降到 2 维,按真实标签着色
def save_tsne(features, labels, title, filename, n_samples=2000, seed=0):
tsne = TSNE(n_components=2, perplexity=30, init="pca",
random_state=seed, max_iter=500)
emb = tsne.fit_transform(features.astype(np.float32))
for c in range(10):
m = labels == c
ax.scatter(emb[m,0], emb[m,1], s=8, alpha=0.6, label=CLASS_NAMES[c])
t-SNE:用 model.embed() 抽 3000 个样本的 128 维特征,sklearn.manifold.TSNE 降到 2 维,按真实标签上色。 两张 t-SNE 更直观,它把模型倒数第二层的 128 维特征压到二维看聚类。左边 baseline 在 OOD 上,各个类别的点糊成一团、分不开;右边增强版,同类别的点清晰地聚成簇。这就从特征层面证明了:增强让模型学到了真正可分的、颜色无关的表征。
总结与展望
这次实验有三个核心发现: 第一,CNN 在常规任务上强于随机森林,但在分布外反而更脆弱,根源是 BatchNorm 的分布假设被打破; 第二,一个简单的颜色随机化增强,就把 OOD 准确率从 16% 提到 84%,而且不用改任何网络结构; 第三,t-SNE 从特征层面证明了模型确实学到了颜色无关的表征,不是靠运气。 未来可以继续探索更激进的分布偏移,比如纹理、渐变背景;可以用 IRM、Mixup 这些更高级的域泛化方法;还可以做风格与内容的解耦表征、或者用对比学习获取不变特征。
参考
Xiao et al. 2017 — Fashion-MNIST Arjovsky et al. 2019 — IRM Zhang et al. 2018 — Mixup Geirhos et al. 2019 — Texture bias of CNN


