AI 学习第 14 课:CNN 训 6 轮卡在 0.5352,数据没打乱

第 13 课把 Conv2d 的参数量和池化后的尺寸算清了,这课把卷积块拼成一个能训练的完整网络,跑满 6 个 epoch。第一版直接卡死:loss 从 2.6104 掉到 0.6989 就再也不动,测试准确率 0.5352 一步没挪。而训练集里类 1 恰好占 53.32%------模型压根没学,全部猜成类 1 也能拿到这个分数。

环境准备

本机 Python 3.13.14 + torch 2.15.0+cpu。PyTorch 2.15 于 2026-10-28 发布,官方 Release Blog 写明支持 Python 3.11 到 3.15,这次发布累计 3117 个 commit、583 位贡献者,改动集中在分布式与低精度推理,CNN 这层的 API 没有破坏性变化,第 11 课写的训练循环可以原样搬。

bash 复制代码
python -c "import torch, sys; print(sys.version.split()[0], torch.__version__)"

输出 3.13.14 2.15.0+cpu。CPU 版够用,这个网络只有 3442 个参数,512 条样本跑一轮实测 0.099 秒,6 轮不到一秒。

数据与网络

任务很小:8×8 灰度图,竖条是类 0,横条是类 1,条宽 2 像素,位置随机平移,噪声 0.9。位置随机是故意的------固定位置上的白块只要一层全连接就能分开,卷积层学不到"方向"这个真正的判别特征。

python 复制代码
import torch
import torch.nn as nn


def make_data(n, noise=0.9, seed=0):
    """8x8 单通道:竖条=类 0,横条=类 1,位置随机平移,噪声 0.9"""
    g = torch.Generator().manual_seed(seed)
    x = torch.rand(n, 1, 8, 8, generator=g) * noise
    y = torch.randint(0, 2, (n,), generator=g)
    pos = torch.randint(0, 7, (n,), generator=g)
    for i in range(n):
        p = pos[i].item()
        if y[i].item() == 0:
            x[i, 0, :, p:p + 2] = 1.0      # 竖条占两列
        else:
            x[i, p:p + 2, :] = 1.0         # 横条占两行
    return x, y


xtr, ytr = make_data(512, seed=1)
xte, yte = make_data(256, seed=2)
print("训练集类 1 占比:", round(ytr.float().mean().item(), 4))


class CNN(nn.Module):
    def __init__(self, bn=True):
        super().__init__()
        B = (lambda c: nn.BatchNorm2d(c)) if bn else (lambda c: nn.Identity())
        self.features = nn.Sequential(
            nn.Conv2d(1, 8, 3, padding=1), B(8), nn.ReLU(), nn.MaxPool2d(2),    # 8x8 -> 4x4
            nn.Conv2d(8, 16, 3, padding=1), B(16), nn.ReLU(), nn.MaxPool2d(2),  # 4x4 -> 2x2
        )
        self.classifier = nn.Sequential(
            nn.Flatten(),
            nn.Linear(16 * 2 * 2, 32), nn.ReLU(), nn.Dropout(0.3),
            nn.Linear(32, 2),
        )

    def forward(self, x):
        return self.classifier(self.features(x))


net = CNN()
print("参数量:", sum(p.numel() for p in net.parameters()))

输出两行:

text 复制代码
训练集类 1 占比: 0.5332
参数量: 3442

3442 这个数可以逐层手算对上,跟第 13 课的算法一样,卷积层数 weight 加 bias,BN 数 weight 加 bias:

层 计算 参数量
Conv2d(1,8,3,p1) 8×1×3×3 + 8 80
BatchNorm2d(8) 8×2 16
Conv2d(8,16,3,p1) 16×8×3×3 + 16 1168
BatchNorm2d(16) 16×2 32
Linear(64,32) 64×32 + 32 2080
Linear(32,2) 32×2 + 2 66

尺寸链路:8×8 经 conv padding=1 保持 8×8,pool 后 4×4,再 conv 保持,再 pool 到 2×2,展平得 16×2×2=64。BN 里的 running_mean、running_var 是缓冲区,不进 parameters(),所以上表不数它们。反过来,如果表格数字和打印不一致,八成是某个 padding 或通道数写错了。

训练循环

循环还是第 11 课那五步:zero_grad、forward、loss、backward、step。差别只在取 batch 的方式------shuffle=False 时先用 argsort 按标签把数据排好序,模拟把 CSV 原样读进来的情况。很多公开数据集的样本就是按 label 分块排列的,一行 shuffle=False(或者干脆写死 torch.arange)就把这件事带进训练了。

python 复制代码
def evaluate(model, x, y, bs=256):
    model.eval()                      # 评估必须切到 eval:BN 用 running stats,Dropout 关闭
    correct = 0
    with torch.no_grad():
        for i in range(0, len(x), bs):
            correct += (model(x[i:i + bs]).argmax(1) == y[i:i + bs]).sum().item()
    return correct / len(x)


def train(bn=True, shuffle=False, epochs=6, seed=0):
    torch.manual_seed(seed)
    model = CNN(bn)
    opt = torch.optim.Adam(model.parameters(), lr=1e-2)
    lossf = nn.CrossEntropyLoss()
    order = torch.argsort(ytr, stable=True)                 # 按标签排序,模拟原始数据顺序
    xa, ya = (xtr, ytr) if shuffle else (xtr[order], ytr[order])
    for ep in range(epochs):
        model.train()
        perm = torch.randperm(len(xa)) if shuffle else torch.arange(len(xa))
        total = 0.0
        for i in range(0, len(xa), 32):
            idx = perm[i:i + 32]
            opt.zero_grad()
            loss = lossf(model(xa[idx]), ya[idx])
            loss.backward()
            opt.step()
            total += loss.item() * len(idx)
        if ep in (0, epochs - 1):
            print(f"epoch {ep + 1} loss={total / len(xa):.4f} "
                  f"test_acc={evaluate(model, xte, yte):.4f}")
    with torch.no_grad():
        pred = model(xte).argmax(1)
    print(f"预测为类 1 的比例: {(pred == 1).float().mean().item():.4f}")
    return model

跑两组做对照,只差一个 shuffle:

python 复制代码
print("--- 打乱 ---")
train(bn=True, shuffle=True)
print("--- 不打乱 ---")
train(bn=True, shuffle=False)

输出:

text 复制代码
--- 打乱 ---
epoch 1 loss=0.4865 test_acc=0.8164
epoch 6 loss=0.0107 test_acc=1.0000
预测为类 1 的比例: 0.5352
--- 不打乱 ---
epoch 1 loss=2.6104 test_acc=0.5352
epoch 6 loss=0.6989 test_acc=0.5352
预测为类 1 的比例: 1.0000

打乱后 6 轮满分,预测分布也和真实分布一致。不打乱,6 轮结束 loss 停在 0.6989------ln2 约等于 0.6931,这就是二分类模型对所有样本输出同一概率时的交叉熵。最后一行 1.0000 更有说服力:模型把所有测试样本都判成类 1,一个类 0 都没抓到,准确率自然等于类 1 的占比。

锅不在 BatchNorm

看到这个现象,第一反应是 BatchNorm:每个 batch 只有一类样本,BN 沿 (N,H,W) 算出来的均值方差是单类分布,归一化把类间差异抹平了,模型自然分不开。这个解释很顺,但顺的解释最该验。把 BN 换成 Identity 再跑一遍:

python 复制代码
print("--- 不打乱、去掉 BN ---")
train(bn=False, shuffle=False)

输出:

text 复制代码
--- 不打乱、去掉 BN ---
epoch 1 loss=2.0655 test_acc=0.5352
epoch 6 loss=0.6981 test_acc=0.5352
预测为类 1 的比例: 1.0000

照样卡在 0.53,照样全判类 1。BN 是无辜的,问题出在顺序本身:32 个同类样本组成一个 batch,每一步梯度都指向"把这个类全判成 1",相邻两步的方向正好相反,Adam 的一阶矩在两类之间来回对消,参数就停在初始点附近不动。loss 停在 ln2 附近而不是发散,正是因为它在两个相反方向之间被来回拉扯。

修法只有一行,把 torch.arange(len(xa)) 换成 torch.randperm(len(xa))。真实训练里这行对应 DataLoader 的 shuffle=True。按标签排序本身不是错,忘了在训练前打乱才是------这一点比记住 BN 的公式重要得多。

踩坑记录:三个真实报错

这三条都是拼网络时撞出来的,报错原文贴在这里,本机复现过。

python 复制代码
# 1) 特征图被压成 1x1,又在训练模式下喂 batch=1
bn = nn.BatchNorm2d(16)
bn.train()
try:
    bn(torch.randn(1, 16, 1, 1))
except Exception as e:
    print(type(e).__name__, ":", e)

# 2) permute 之后直接 view
f = net.features(xte[:4])                      # (4, 16, 2, 2)
try:
    f.permute(0, 2, 3, 1).view(4, -1)
except Exception as e:
    print(type(e).__name__, ":", e)
print("改成 reshape:", tuple(f.permute(0, 2, 3, 1).reshape(4, -1).shape))

# 3) 全连接层的输入维度手算错
try:
    nn.Sequential(nn.Flatten(), nn.Linear(128, 2))(f)
except Exception as e:
    print(type(e).__name__, ":", e)

输出:

text 复制代码
ValueError : Expected more than 1 value per channel when training, got input size torch.Size([1, 16, 1, 1])
RuntimeError : view size is not compatible with input tensor's size and stride (at least one dimension spans across two contiguous subspaces). Use .reshape(...) instead.
改成 reshape: (4, 64)
RuntimeError : addmm: mat1 and mat2 shapes cannot be multiplied (4x64 and 128x2)

第一条最容易被误判成代码写错。BatchNorm2d 在训练模式下沿 (N,H,W) 统计,特征图一旦被全局池化压成 1×1,每个通道只剩一个数,方差没意义,直接抛 ValueError。切到 eval() 走 running stats 就不会报------这也是线上推理 batch=1 必须先 model.eval() 的硬理由,不是"建议"。本机实测:同一层 batch=2 正常,eval() 下 batch=1 也正常。

第二条是 permute 只改 stride 不改内存布局,view 要求连续内存,改成 reshape 就过。

第三条纯手算错:展平后是 64 维,Linear 写了 128。注意报错里 4x64 是 batch × 特征,不是通道 × 尺寸,看错这个方向会白改半天。

小结

一个完整 CNN 就六个零件:卷积块、池化、展平、全连接、损失、优化器,拼起来不难,难的是喂进去的顺序和模式。训练脚本跑起来先别盯着 loss,打一行 (pred == 1).float().mean() 看模型是不是在输出同一类,比猜快得多;评估前后各打一次 model.training,能挡住一半的"指标不对"。

下一课第 15 课换掉卷积:用 RNN 处理长度不固定的序列,顺便踩 pack_padded_sequence 的坑。

相关推荐
yuyuxun143 分钟前
协同过滤的校园二手物品智能推荐系统的设计与实现 毕业设计-附源码40082
javascript·vue.js·spring boot·python·数据分析·django·课程设计
萝卜小白1 小时前
深度学习基础面经
人工智能·深度学习
如夏倾城1 小时前
百度文心(容易被忽略的好用AI)使用方式
人工智能·python·百度·语言模型·dubbo
用户019027581611 小时前
quotes 实时价和 klines 最新收盘价为什么对不上?
python
少司府2 小时前
嵌入式入门篇(二):初识STM32F103ZET6
linux·python·stm32·单片机·嵌入式硬件·mcu·嵌入式软件
打工仔折腾 AI3 小时前
Changedetection.io 网页变动监控:Docker 部署、通知配置与公网暴露实践
运维·人工智能·后端·python·docker·容器·性能优化
火云代理3 小时前
Python 使用代理 IP 前,先弄清 Requests 的配置来源与验证方法
网络·python·网络协议·tcp/ip·ip
2601_967979125 小时前
Python第七十四讲 异常处理完全精通 try-except零基础保姆级教程
python·异常处理·编程教程·错误处理·try-except
wuyk5555 小时前
Spider-第07讲-JSON接口与正则提取
爬虫·python·json