卷积神经网络的引入5 —— 当空间结构被彻底打乱时,CNN 还能成立吗?

卷积神经网络的引入5 —— 当空间结构被彻底打乱时,CNN 还能成立吗?

上一章我们验证了一个重要结论:

即便图像遭遇局部遮挡(Random Erasing),CNN 依然保持明显优势。

但“局部破坏”还不足以动摇 CNN 的根基。

那么一个更尖锐的问题是:

如果彻底打乱空间结构,CNN 还能成立吗?

这就是对 CNN 空间归纳偏置 的终极测试。


一、归纳偏置:CNN 为什么“相信结构”?

CNN 的设计假设是:

  • 邻近像素具有更强相关性;
  • 局部结构具有可复用性;
  • 空间平移不改变语义(平移不变性/等变性);

所以卷积核在一个局部窗口内滑动,就能学到边缘、纹理等特征,并在更大规模上复用。

当“空间连续性”成立时,CNN 强大无比;
当“空间连续性”不成立时,CNN 可能会失去意义。


二、实验设计:Pixel Shuffle(全局像素打乱)

我们设计一个极端扰动:

将图像 flatten 成向量,生成一个 固定随机排列,对所有样本一致打乱,再 reshape 回 32×32。

关键原则:

  1. 打乱是固定映射(训练/测试一致);
  2. 标签不变;
  3. 数据的边际统计分布不变;

这样做的本质是:

  • 空间结构被摧毁
  • 信息量不变

从信息论角度看,样本仍包含分类所需信息;
从结构角度看,空间连续性不再成立。


三、代码实现

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")

image

根据图片可以看出在打乱像素之后MLP的准确率影响很小,但是CNN收到的影响很大

六、现象解析:CNN 的强假设被摧毁

shuffle 后会发生什么?

  • 原本局部区域的边缘不再连通;
  • 原本的纹理不再连续;
  • 同一个卷积核滑动到不同位置,看到的是“随机拼贴的碎片”。

这意味着:

CNN 的卷积窗口不再对应任何有意义的局部模式。

换句话说:

  • 不是 CNN 学不会;
  • 是它想学的“对象”在数据空间里不存在了。

七、核心结论:CNN 的优势来自“结构先验”

这章得出的结论非常明确:

CNN 的强大不是因为模型更深、参数更多,而是因为它 强烈相信空间连续性

当空间结构存在时,CNN 的归纳偏置让它更高效、更稳健;
当空间结构被摧毁时,CNN 的归纳偏置反而变成累赘。

这就是归纳偏置的双刃剑:

  • 结构正确 → 泛化强
  • 结构不成立 → 学习困难

八、整个系列的“闭环”到这里完成了

我们通过五个卷,逐步验证了:

  1. 低维场景差距不大;
  2. 随数据复杂度提升,差距显著扩大;
  3. 局部破坏下,CNN 依然稳健;
  4. 但当空间结构被彻底打乱,CNN 的优势就会崩塌。

这一闭环明确指向:

CNN 成立的前提是:图像语义与空间结构紧密耦合


九、下一章预告:把问题升级到 ViT

接下来最自然的问题是:

不使用卷积的 Vision Transformer(ViT)会怎样?

ViT 通过 patch embedding + 自注意力建模全局关系,并依赖位置编码。

当我们打乱 patch 顺序时:

  • 加位置编码 vs 不加位置编码
  • 比起 CNN,谁更依赖空间结构?

这将是卷6的核心探索方向。


如果你已经读到这里,那么你已经走完了从“直觉”到“实验”的关键路径:

CNN 的强,是因为它“相信结构”。

posted @ 2026-03-01 18:12  方子敬  阅读(17)  评论(0)    收藏  举报