卷积神经网络的引入5 —— 当空间结构被彻底打乱时,CNN 还能成立吗?
卷积神经网络的引入5 —— 当空间结构被彻底打乱时,CNN 还能成立吗?
上一章我们验证了一个重要结论:
即便图像遭遇局部遮挡(Random Erasing),CNN 依然保持明显优势。
但“局部破坏”还不足以动摇 CNN 的根基。
那么一个更尖锐的问题是:
如果彻底打乱空间结构,CNN 还能成立吗?
这就是对 CNN 空间归纳偏置 的终极测试。
一、归纳偏置:CNN 为什么“相信结构”?
CNN 的设计假设是:
- 邻近像素具有更强相关性;
- 局部结构具有可复用性;
- 空间平移不改变语义(平移不变性/等变性);
所以卷积核在一个局部窗口内滑动,就能学到边缘、纹理等特征,并在更大规模上复用。
当“空间连续性”成立时,CNN 强大无比;
当“空间连续性”不成立时,CNN 可能会失去意义。
二、实验设计:Pixel Shuffle(全局像素打乱)
我们设计一个极端扰动:
将图像 flatten 成向量,生成一个 固定随机排列,对所有样本一致打乱,再 reshape 回 32×32。
关键原则:
- 打乱是固定映射(训练/测试一致);
- 标签不变;
- 数据的边际统计分布不变;
这样做的本质是:
- 空间结构被摧毁
- 信息量不变
从信息论角度看,样本仍包含分类所需信息;
从结构角度看,空间连续性不再成立。
三、代码实现
class PixelShuffle:
def __init__(self):
self.perm = torch.randperm(32 * 32)
def __call__(self, img):
c, h, w = img.shape
flat = img.view(c, -1)
flat = flat[:, self.perm]
return flat.view(c, h, w)
在 transform 中加入:
train_transforms = [
transforms.ToTensor(),
PixelShuffle(),
transforms.Normalize((0.5,0.5,0.5),(0.5,0.5,0.5))
]
同一排列 perm 用于训练集与测试集。
四、理论预测
| 模型 | 预期表现 |
|---|---|
| MLP | 几乎不受影响 |
| CNN | 精度显著下降 |
理由非常直接:
- MLP 输入本来就是 flatten 之后的向量,顺序被打乱并不破坏输入机制;
- CNN 依赖局部卷积窗口,窗口内的邻近像素必须“有关联”。
当邻域内像素来自图像各处、毫无语义连续性时,卷积核将失去结构意义。
五、实验结果
import os
import time
import random
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torchvision import datasets, transforms
from torch.utils.data import DataLoader
# ======================
# 1) 全局配置 & 控制变量
# ======================
SEED = 42
def seed_everything(seed=SEED):
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
seed_everything(SEED)
# ✅ MacBook Pro(Apple Silicon)优先使用 MPS
if torch.backends.mps.is_available():
DEVICE = torch.device("mps")
elif torch.cuda.is_available():
DEVICE = torch.device("cuda")
else:
DEVICE = torch.device("cpu")
print("Using device:", DEVICE)
BATCH_SIZE = 256 # ✅ 建议增大 batch 以提升吞吐,不够再降到 128
EPOCHS = 20
LR = 1e-3
WD = 0.0
# ======================
# 2) Pixel Shuffle 变换(固定映射)
# ======================
class PixelShuffle:
"""
将 32x32 的图像打乱(固定 perm),且在训练/测试一致使用该 perm。
"""
def __init__(self, perm=None, seed=SEED):
if perm is not None:
self.perm = perm
else:
g = torch.Generator()
g.manual_seed(seed)
self.perm = torch.randperm(32 * 32, generator=g)
def __call__(self, img):
# img: Tensor [C, H, W], H=W=32
c, h, w = img.shape
flat = img.view(c, -1) # [C, 1024]
flat = flat[:, self.perm] # [C, 1024] 重排
return flat.view(c, h, w)
# 同一 perm 用于“打乱”实验(train/test 保持一致)
shuffle_perm = torch.randperm(32 * 32, generator=torch.Generator().manual_seed(SEED))
pixel_shuffle = PixelShuffle(perm=shuffle_perm)
# ======================
# 3) 数据集 & DataLoader(控制变量:除 PixelShuffle 以外保持一致)
# ======================
norm = transforms.Normalize((0.4914, 0.4822, 0.4465),
(0.2470, 0.2435, 0.2616))
def build_loaders(use_shuffle: bool):
aug_list = [transforms.ToTensor()]
if use_shuffle:
aug_list.append(pixel_shuffle)
aug_list.append(norm)
transform = transforms.Compose(aug_list)
train_set = datasets.CIFAR10(root="./data", train=True, download=True, transform=transform)
test_set = datasets.CIFAR10(root="./data", train=False, download=True, transform=transform)
# ✅ Mac 上 DataLoader:num_workers 先用 0 最稳;想压榨 CPU 再试 2
num_workers = 0
train_loader = DataLoader(
train_set,
batch_size=BATCH_SIZE,
shuffle=True,
num_workers=num_workers,
pin_memory=False, # ✅ mps 不需要 pin_memory
persistent_workers=(num_workers > 0),
)
test_loader = DataLoader(
test_set,
batch_size=BATCH_SIZE,
shuffle=False,
num_workers=num_workers,
pin_memory=False,
persistent_workers=(num_workers > 0),
)
return train_loader, test_loader
# ======================
# 4) 模型(尽量“同一等级”的简洁版本)
# ======================
class SimpleMLP(nn.Module):
def __init__(self):
super().__init__()
self.net = nn.Sequential(
nn.Flatten(), # [B, 3*32*32]
nn.Linear(3 * 32 * 32, 512),
nn.ReLU(inplace=True),
nn.Linear(512, 256),
nn.ReLU(inplace=True),
nn.Linear(256, 10)
)
def forward(self, x):
return self.net(x)
class SimpleCNN(nn.Module):
def __init__(self):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(3, 32, kernel_size=3, padding=1), # [B, 32, 32, 32]
nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=2), # [B, 32, 16, 16]
nn.Conv2d(32, 64, kernel_size=3, padding=1),
nn.ReLU(inplace=True),
nn.MaxPool2d(kernel_size=2), # [B, 64, 8, 8]
)
self.classifier = nn.Sequential(
nn.Flatten(),
nn.Linear(64 * 8 * 8, 256),
nn.ReLU(inplace=True),
nn.Linear(256, 10)
)
def forward(self, x):
x = self.features(x)
x = self.classifier(x)
return x
def count_params(model):
return sum(p.numel() for p in model.parameters() if p.requires_grad)
# ======================
# 5) 验证准确率(只用于 test)
# ======================
@torch.no_grad()
def accuracy(model, loader):
model.eval()
correct = 0
total = 0
for x, y in loader:
x, y = x.to(DEVICE), y.to(DEVICE)
logits = model(x)
pred = logits.argmax(dim=1)
correct += (pred == y).sum().item()
total += y.size(0)
return correct / total
# ======================
# 6) 训练:更快(不再每 epoch 扫一遍 train_loader 做 accuracy)
# ======================
def train_one_model(model, train_loader, test_loader, name="model"):
model = model.to(DEVICE)
optimizer = optim.Adam(model.parameters(), lr=LR, weight_decay=WD)
print(f"\n=== Train {name} ===")
print(f"Trainable params: {count_params(model)}")
print(f"Device: {DEVICE}")
history = {"epoch": [], "loss": [], "train_acc": [], "test_acc": [], "time": []}
for epoch in range(1, EPOCHS + 1):
t0 = time.perf_counter()
model.train()
total_loss = 0.0
correct = 0
total = 0
for x, y in train_loader:
x, y = x.to(DEVICE), y.to(DEVICE)
optimizer.zero_grad(set_to_none=True)
logits = model(x)
loss = F.cross_entropy(logits, y)
loss.backward()
optimizer.step()
total_loss += loss.item() * x.size(0)
# ✅ 训练时顺手统计 train_acc(几乎不增加时间)
pred = logits.argmax(dim=1)
correct += (pred == y).sum().item()
total += y.size(0)
avg_loss = total_loss / len(train_loader.dataset)
train_acc = correct / total
test_acc = accuracy(model, test_loader)
dt = time.perf_counter() - t0
history["epoch"].append(epoch)
history["loss"].append(avg_loss)
history["train_acc"].append(train_acc)
history["test_acc"].append(test_acc)
history["time"].append(dt)
print(f"Epoch {epoch:02d} | loss={avg_loss:.4f} | train_acc={train_acc:.4f} | test_acc={test_acc:.4f} | time={dt:.2f}s")
return model, history
# ======================
# 7) 画图:完整对比(test_acc / train_loss / epoch_time)
# ======================
def plot_histories(histories, title="CIFAR-10"):
import matplotlib.pyplot as plt
keys = list(histories.keys())
epochs = histories[keys[0]]["epoch"]
# (1) Test Accuracy
plt.figure(figsize=(10, 5))
for k, h in histories.items():
plt.plot(epochs, h["test_acc"], marker="o", label=k)
plt.title(f"{title} - Test Accuracy")
plt.xlabel("Epoch")
plt.ylabel("Accuracy")
plt.grid(True, alpha=0.3)
plt.legend()
plt.tight_layout()
plt.show()
# (2) Train Loss
plt.figure(figsize=(10, 5))
for k, h in histories.items():
plt.plot(epochs, h["loss"], marker="o", label=k)
plt.title(f"{title} - Train Loss")
plt.xlabel("Epoch")
plt.ylabel("Loss")
plt.grid(True, alpha=0.3)
plt.legend()
plt.tight_layout()
plt.show()
# (3) Epoch Time
plt.figure(figsize=(10, 5))
for k, h in histories.items():
plt.plot(epochs, h["time"], marker="o", label=k)
plt.title(f"{title} - Epoch Time (Seconds)")
plt.xlabel("Epoch")
plt.ylabel("Seconds")
plt.grid(True, alpha=0.3)
plt.legend()
plt.tight_layout()
plt.show()
# (4) Summary
print("\n=== Summary ===")
for k, h in histories.items():
best = max(h["test_acc"])
final = h["test_acc"][-1]
total_t = sum(h["time"])
print(f"{k:12s} | best_test={best:.4f} | final_test={final:.4f} | total_time={total_t:.1f}s")
# ======================
# 8) 实验入口(normal vs shuffle)
# ======================
if __name__ == "__main__":
# 实验 A:不打乱
train_loader_norm, test_loader_norm = build_loaders(use_shuffle=False)
# 实验 B:Pixel Shuffle 打乱(核心控制变量:除了 pixel_shuffle 以外全部相同)
train_loader_shuf, test_loader_shuf = build_loaders(use_shuffle=True)
histories = {}
# MLP normal / CNN normal
_, histories["MLP-normal"] = train_one_model(SimpleMLP(), train_loader_norm, test_loader_norm, name="MLP-normal")
_, histories["CNN-normal"] = train_one_model(SimpleCNN(), train_loader_norm, test_loader_norm, name="CNN-normal")
# MLP shuffle / CNN shuffle
_, histories["MLP-shuffle"] = train_one_model(SimpleMLP(), train_loader_shuf, test_loader_shuf, name="MLP-shuffle")
_, histories["CNN-shuffle"] = train_one_model(SimpleCNN(), train_loader_shuf, test_loader_shuf, name="CNN-shuffle")
plot_histories(histories, title="CIFAR-10 PixelShuffle Control-Var")

根据图片可以看出在打乱像素之后MLP的准确率影响很小,但是CNN收到的影响很大
六、现象解析:CNN 的强假设被摧毁
shuffle 后会发生什么?
- 原本局部区域的边缘不再连通;
- 原本的纹理不再连续;
- 同一个卷积核滑动到不同位置,看到的是“随机拼贴的碎片”。
这意味着:
CNN 的卷积窗口不再对应任何有意义的局部模式。
换句话说:
- 不是 CNN 学不会;
- 是它想学的“对象”在数据空间里不存在了。
七、核心结论:CNN 的优势来自“结构先验”
这章得出的结论非常明确:
CNN 的强大不是因为模型更深、参数更多,而是因为它 强烈相信空间连续性。
当空间结构存在时,CNN 的归纳偏置让它更高效、更稳健;
当空间结构被摧毁时,CNN 的归纳偏置反而变成累赘。
这就是归纳偏置的双刃剑:
- 结构正确 → 泛化强
- 结构不成立 → 学习困难
八、整个系列的“闭环”到这里完成了
我们通过五个卷,逐步验证了:
- 低维场景差距不大;
- 随数据复杂度提升,差距显著扩大;
- 局部破坏下,CNN 依然稳健;
- 但当空间结构被彻底打乱,CNN 的优势就会崩塌。
这一闭环明确指向:
CNN 成立的前提是:图像语义与空间结构紧密耦合。
九、下一章预告:把问题升级到 ViT
接下来最自然的问题是:
不使用卷积的 Vision Transformer(ViT)会怎样?
ViT 通过 patch embedding + 自注意力建模全局关系,并依赖位置编码。
当我们打乱 patch 顺序时:
- 加位置编码 vs 不加位置编码
- 比起 CNN,谁更依赖空间结构?
这将是卷6的核心探索方向。
如果你已经读到这里,那么你已经走完了从“直觉”到“实验”的关键路径:
CNN 的强,是因为它“相信结构”。

浙公网安备 33010602011771号