简介:面向机器学习与深度学习初学者的PDF教程,系统讲解用Python和TensorFlow从零实现卷积神经网络(CNN)的完整过程。内容覆盖MNIST数据读取、权重初始化、卷积层与池化层设计、ReLU激活、全连接层Softmax分类、Dropout防过拟合及交叉熵损失函数,并附有可直接运行的代码段与训练、准确率评估流程。代码中的每个模块均配有注释与参数说明,便于学习者逐行复现并理解网络结构。资源为单个PDF文档,体积仅56KB,内容精炼易读,适合随时查阅;文档在代码基础上还给出了增大训练集与增大卷积核数的多组对比实验,直观展示数据量与网络结构对模型性能的影响,帮助读者避开过拟合与欠拟合陷阱。该资源已有3760人学习,适合需要快速上手CNN实战、完成图像识别小项目或课程设计的学习者参考。
1. 别急着调参:Python 里跑 CNN 前,先弄清这张网络到底在算什么
很多人第一次在 Python 里写卷积神经网络,都以为难点在模型结构。真上手后你会发现,把卷积、池化、全连接堆起来的代码不到五十行,真正卡住你的是数据怎么进网络、张量形状怎么对齐、归一化参数怎么设。CNN 处理的是带空间结构的图像数据,核心是“用小的卷积核在整张图上滑动提取局部特征”,而代码落地的关键,是把滑动过程表示成矩阵运算。这篇笔记针对想用 Python 把 CNN 从原理跑到落地的人:先用手写实现拆开卷积网络的黑匣子,再给一套能改的 PyTorch 骨架,最后把参数设置和踩坑经验放在一起说。读完你不仅能把 MNIST 这类任务跑通,也知道失败的时候该往哪个方向查。
2. 从零手写卷积层:用 NumPy 拆解 CNN 的四个核心组件
2.1 卷积层与 im2col:把滑窗计算变成矩阵乘法
手写卷积层,最常见也是最容易理解的做法是 im2col:把输入图像上每一个卷积窗口对应的像素取出来,排成一行,把所有窗口排成矩阵,再跟卷积核做矩阵乘法。这个方法比多层 for 循环快得多,也是很多推理引擎真正在干的事。
import numpy as np def im2col(img, kernel_h, kernel_w, stride=1, pad=0): """ 将输入图像展开为卷积窗口矩阵 img: (C, H, W) 的输入 返回: (窗口数, C * kernel_h * kernel_w) """ C, H, W = img.shape out_h = (H + 2 * pad - kernel_h) // stride + 1 out_w = (W + 2 * pad - kernel_w) // stride + 1 img_padded = np.pad(img, ((0, 0), (pad, pad), (pad, pad)), mode='constant') cols = [] for h in range(0, out_h): for w in range(0, out_w): window = img_padded[:, h * stride:h * stride + kernel_h, w * stride:w * stride + kernel_w] cols.append(window.reshape(-1)) return np.array(cols).T def conv_forward(img, kernel, bias, stride=1, pad=0): """ kernel: (C_out, C_in, k_h, k_w) 返回: (C_out, out_h, out_w) """ C_out, C_in, k_h, k_w = kernel.shape cols = im2col(img, k_h, k_w, stride, pad) # (C_in*k_h*k_w, 窗口数) ker = kernel.reshape(C_out, -1) # (C_out, C_in*k_h*k_w) out = ker @ cols + bias.reshape(-1, 1) # 矩阵乘法完成全部窗口 return out.reshape(C_out, -1, -1) # 测试:一张 1x4x4 的输入,两个 3x3 卷积核 img = np.random.randn(1, 4, 4) kernel = np.random.randn(2, 1, 3, 3) bias = np.zeros(2) out = conv_forward(img, kernel, bias, stride=1, pad=0) print("输出形状:", out.shape)这里 im2col 返回的矩阵,行是输入通道乘以窗口面积的展开,列是所有滑窗位置。conv_forward 把卷积核也展开成二维矩阵,一次 @ 运算就把所有窗口的结果算完了。
这个写法里 stride 和 pad 的公式是关键:out_h = (H + 2 * pad - kernel_h) // stride + 1。写代码实现时经常在这两个参数上翻车,尤其是 pad 大于 1 时,np.pad 的元组参数顺序对应输入维度,对 (C, H, W) 的结构,中间两个元组分别管 H 和 W,写反了就会出现输出尺寸对不上。
注意:上面的卷积没有处理 batch 维度,单张图够用。跑批量训练时需要在外面套一层循环,或者把 (B, C, H, W) 的输入按 batch 展开;追求性能就直接用框架实现,手写版本更多是用来理解形状变化。
2.2 池化层与激活函数:下采样为什么稳,ReLU 为什么活
池化层做的事情是下采样:把特征图分成小块,每块取最大值或平均值,从而减少参数、增大感受野、带来一定的平移不变性。CNN 代码实现里最常见的池化是 2×2、步长为 2 的最大池化,输出尺寸直接减半。
def max_pool2x2(img): """ 对 (C, H, W) 输入做 2x2 最大池化,步长为 2 """ C, H, W = img.shape out_h, out_w = H // 2, W // 2 out = np.zeros((C, out_h, out_w)) for c in range(C): for i in range(out_h): for j in range(out_w): out[c, i, j] = img[c, i*2:i*2+2, j*2:j*2+2].max() return out这段代码逻辑很简单,但有两个细节值得注意。一是 H 或 W 为奇数时,边缘会被直接丢弃,这就是为什么很多经典网络在进入池化前会用 padding 把特征图凑成偶数。二是最大池化在反向传播时只把梯度回传给窗口内最大值的位置,其余位置梯度为 0,如果某个窗口内最大值长期集中在一个位置,其他位置的权重很难被更新。这也是为什么现代网络里池化层用得越来越少,更多用步长为 2 的卷积做下采样。
激活函数方面,CNN 代码实现里现在几乎都是 ReLU:max(0, x)。它的优势不是精度,而是梯度稳定。sigmoid 在输入绝对值较大时梯度趋近于 0,多层反向传播一乘,浅层几乎收不到梯度;ReLU 在正区间梯度恒为 1,让深层网络能真正训练起来。
def relu(x): return np.maximum(0, x) def relu_grad(x): # 反向传播要用:正数位置梯度为 1,其余为 0 return (x > 0).astype(np.float32)ReLU 也不是没有坑。输入全为负时输出恒为 0,梯度也恒为 0,一旦某层的权重初始化导致大量神经元输出为负,这些神经元就“死”了。常见做法是用 He 初始化(按 fan_in 计算方差)而不是默认的均匀分布随机初始化。
2.3 反向传播:手写一次梯度,才知道框架替你扛了什么
很多人以为反向传播很玄学,其实它就是链式法则。手写代码时最直观的思路是:前向保存每一层的中间结果,反向从损失出发,逐层把梯度传回去。这里给出全连接层加 softmax 交叉熵的最小示例,它和卷积层反向拼接后,就是一个能训练的网络。
def softmax_loss_grad(logits, labels): """ logits: (N, num_classes) 未过 softmax 的原始输出 labels: (N,) 类别索引 返回: 损失、logits 的梯度 """ N = logits.shape[0] exp_logits = np.exp(logits - logits.max(axis=1, keepdims=True)) probs = exp_logits / exp_logits.sum(axis=1, keepdims=True) loss = -np.log(probs[np.arange(N), labels] + 1e-8).mean() dlogits = probs.copy() dlogits[np.arange(N), labels] -= 1 dlogits /= N return loss, dlogits这里减掉 logits.max 是为了防止 exp 溢出,这是新手最容易翻车的地方。交叉熵损失对 logits 的梯度有个非常漂亮的结论:softmax 概率减去 one-hot 标签,再除以 N。写代码时只要记住这一点,全连接层的反向就只是转置乘梯度。
卷积层的反向要处理 im2col 的逆操作,把梯度按窗口位置累加回原图。这也是手写 CNN 代码实现里最绕的一段,需要对每个窗口位置维护一份索引映射。我的建议是,第一次手写不要追求高性能,直接用 im2col 缓存窗口坐标,反向时把梯度填回对应位置即可。框架存在的意义就是让你不必每次都写这些。
2.4 最小前向验证:用一张图走通全流程
把上面几段拼起来,做一个输入单张 1×8×8 图像的二分类前向。这里把卷积、ReLU、池化、全连接顺序串起来,确认每一层输出形状符合预期,这是所有 CNN 代码实现的第一步验证。
np.random.seed(0) img = np.random.randn(1, 8, 8) # 单通道 8x8 kernel = np.random.randn(4, 1, 3, 3) * 0.1 # 4 个输出通道 bias = np.zeros(4) h1 = relu(conv_forward(img, kernel, bias, stride=1, pad=1)) # (4, 8, 8) p1 = max_pool2x2(h1) # (4, 4, 4) flatten = p1.reshape(-1) # 64 w = np.random.randn(64, 2) * 0.01 logits = flatten @ w # (2,) print("各层形状:", h1.shape, p1.shape, flatten.shape, logits.shape)这一段代码每一次输出形状都可以用公式验算:卷积 pad=1 保持尺寸不变,池化减半,最后拉平接全连接。如果某一个尺寸和你预期不一致,问题通常出在 out_h / out_w 的计算公式上,而不是网络本身。
到这里,一个最小 CNN 前向就算落地了。反向传播把全连接和卷积的梯度回传补齐后,就能用梯度下降更新权重跑 MNIST。但手写版本调参和排错的时间成本偏高,实际项目里大家几乎都用框架。下一章给你一套能直接改的 PyTorch 骨架,把训练、验证、模型保存一次性跑通。
3. 用 PyTorch 搭 CNN:从环境准备到训练循环的三段式骨架
3.1 环境准备:python 安装、vscode 配置、依赖包清单
在动手写 PyTorch 代码之前,先把 Python 环境理顺。常见做法是用 conda 或 python venv 建一个独立环境,避免把系统 Python 搞乱。这里给出一套最小命令。
conda create -n cnn python=3.10 -y conda activate cnn pip install torch torchvision matplotlib numpy如果你用的是 vscode,装好 Python 插件后在命令面板里选解释器,指向刚才创建环境的 python 路径即可。这一步没有技术含量,但对新手来说是第一个坑:torch 安装后 import 报错,基本是环境没选对,而不是包真的没装。
验证安装是否正常,用一段很短的代码就够了:
import torch import torchvision print(torch.__version__) print(torch.cuda.is_available())CPU 环境也能跑 CNN,只是训练慢。注意 torchvision 和 torch 的版本要配套,pip 安装时最好一起装,不要分开装不同版本。我在实际项目里踩过的最深的一个坑是 torch 1.13 配了 torchvision 0.15 导致数据集加载报错,所以这里特别提醒版本配套问题。
3.2 Dataset 与 DataLoader:归一化参数不能乱拍
PyTorch 里数据加载的标准动作是:定义 Dataset 子类,重写len和getitem,再用 DataLoader 分 batch、打乱、并行加载。对于 MNIST 和 Fashion-MNIST,torchvision 直接提供现成数据集,但归一化参数值得多说两句。
import torch from torch.utils.data import DataLoader from torchvision import datasets, transforms transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_data = datasets.MNIST('./data', train=True, download=True, transform=transform) train_loader = DataLoader(train_data, batch_size=64, shuffle=True, num_workers=2) x, y = next(iter(train_loader)) print(x.shape, y.shape, x.dtype)Normalize 里的均值和标准差是数据集的统计值,不是随便拍的。MNIST 官方给的是 0.1307 和 0.3081;如果换了数据集,第一件事就是用代码算一遍均值方差,否则梯度下降会很不稳定。对于 Fashion-MNIST,均值约 0.2860,标准差约 0.3530,可直接用。
ToTensor 会把 PIL Image 转成 0~1 的 float32 tensor,并自动把 HWC 变成 CHW。这里也藏着 CNN 代码实现最常见的低级错误:如果数据是 numpy 数组,你得自己保证通道维在最前面,torch 不会帮你判断语义。
DataLoader 的三个参数要调好:batch_size 决定梯度更新频率,shuffle 训练集必须为 True,num_workers 在 Windows 上容易出问题,可以先设 0,跑通后再调大。
3.3 定义网络结构:三卷积两全连接的 LeNet 风格
这里给出一个能直接训练 MNIST 的 CNN,结构沿用 LeNet-5 的设计思路:两个卷积块加一个全连接头。注释里标明每层输出的形状变化,方便你改成自己的数据集时推算输入尺寸。
import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 16, kernel_size=3, stride=1, padding=1), # 1x28x28 -> 16x28x28 nn.ReLU(inplace=True), nn.MaxPool2d(2), # 16x14x14 nn.Conv2d(16, 32, kernel_size=3, stride=1, padding=1), # 32x14x14 nn.ReLU(inplace=True), nn.MaxPool2d(2), # 32x7x7 ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(32 * 7 * 7, 128), nn.ReLU(inplace=True), nn.Linear(128, num_classes) ) def forward(self, x): return self.classifier(self.features(x))这个网络的参数计算很容易验证:第一层卷积权重是 1×16×3×3,加 16 个 bias;线性层输入维度是 3277=1568,输出 128。用 summary 或逐层打印 shape 都能确认。
把网络定义写成 nn.Sequential 的嵌套,好处是结构一目了然,坏处是如果你想在中间插入残差连接、跳层,就得改成显式写法。在 ResNet 这类结构里,更常见的写法是单独定义init里的层,在 forward 里手工拼接。
3.4 训练循环:epoch、batch、学习率、损失曲线的观察方法
CNN 代码实现里,训练循环是最好抄也是最少被讲清楚的部分。一个规范的 PyTorch 训练循环包括:模型 train 模式、优化器清零梯度、前向、计算损失、反向、更新,外加每若干步打印一次训练信息。
import torch.optim as optim model = SimpleCNN() optimizer = optim.Adam(model.parameters(), lr=1e-3) criterion = nn.CrossEntropyLoss() def train_one_epoch(model, loader, optimizer, criterion): model.train() total_loss, correct, total = 0.0, 0, 0 for x, y in loader: optimizer.zero_grad() out = model(x) # 前向 loss = criterion(out, y) # 交叉熵损失 loss.backward() # 反向 optimizer.step() # 更新 total_loss += loss.item() * x.size(0) correct += (out.argmax(1) == y).sum().item() total += y.size(0) return total_loss / total, correct / total loss, acc = train_one_epoch(model, train_loader, optimizer, criterion) print(f"epoch 1 | loss {loss:.4f} | acc {acc:.4f}")这里值得解释的是 CrossEntropyLoss 自带 softmax,模型最后一层不要再用 softmax,否则会得到错误结果。另外 optimizer.zero_grad() 必须在 loss.backward() 之前调用,否则梯度会跨 batch 累加,典型表现是 loss 在下降但曲线噪声很大。
观察训练是否正常,一是看每个 epoch 的训练 loss 是否单调下降,二是第一轮结束后跑一次验证集,对比训练和验证准确率。如果训练 loss 下降但验证不涨,大概率过拟合;如果 loss 完全不动,先把学习率调大一个量级试一次,别的先别动。
4. CNN 必调的 6 个参数:卷积核、层数、通道数、步长、填充、学习率与 Batch Size
4.1 卷积核大小与层数:堆叠 3×3 为什么比单个 5×5 划算
一维卷积神经网络与二维 CNN 在很多原理上是相通的,卷积核大小决定了一个输出点能“看到”的输入范围,也就是感受野。两个 3×3 堆叠,感受野是 5×5,但参数量是 2×9=18,比单个 5×5 的 25 少,而且中间多了一次非线性激活,表达能力反而更强。这是 VGG 之后主流网络普遍用 3×3 的原因。
有种说法是卷积核越大越好,实际项目中并不成立。大于 7×7 的卷积核参数量爆炸,在小数据集上非常容易过拟合。如果你处理的是时序数据,可以考虑 kernel_size=5 或 7;对图像来说,kernel_size=3 是最稳的起点。第一次实验用 3×3,后面想提升精度,优先加深层数而不是加大卷积核。
4.2 通道数与下采样策略:步长 2、填充到底怎么选
通道数是一个黑匣子,大多数从业者都是靠经验选:第一层 16 或 32,每经过一次下采样翻倍,最后卷积层通常在 256~512 之间。这个策略的依据是,空间分辨率减半时,用更多通道补偿信息损失。
通道数直接决定显存占用。一个 32×32 输入,第一层 32 通道,特征图是 32×32×32,约 32K 个浮点数;到第三层 128 通道、8×8 空间,约 8K。真正占显存的大头是卷积层输出的特征图和反向传播保存的中间变量,所以调参时先看显存是否够,再决定要不要加通道。
填充的作用是控制特征图边缘信息和输出尺寸。公式是 out = (H + 2*pad - kernel) // stride + 1。想让卷积不改变尺寸,pad 通常取 (kernel - 1) // 2,这就是为什么 kernel 为奇数在工程上更顺手。步长每增加 1,输出约减半,计算量显著下降;步长为 2 的下采样比 MaxPool 保留更多信息,但参数量和计算量也更大。
# 两种下采样的对比写法 pool_down = nn.Sequential(nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding=1)) conv_down = nn.Sequential(nn.Conv2d(64, 128, 3, stride=2, padding=1))两种写法输出尺寸相同,但 conv_down 不会像 MaxPool 那样抹掉所有非最大值的信息。
4.3 学习率与 Batch Size:最明智的调参顺序
调参不是全参数一锅乱调,更稳妥的做法是先定结构,再定学习率,最后微调 batch size 和正则化。学习率是最重要的超参数,Adam 默认 1e-3 是合理的起点;如果 loss 震荡剧烈,降到 3e-4 试一次;如果 loss 几乎不动,先升到 3e-3,确认有下降后调回来。
Batch size 影响的是梯度噪声。小的 batch(16~32)噪声大、容易跳出局部极小,但收敛不稳;大的 batch(128~256)梯度稳定、训练快,但可能收敛到尖锐的局部最优点,泛化略差。要加大 batch size 时,学习率要同步调大,原因是梯度噪声变小了,可以走更大的步长。一个简单的法则是:batch size 翻倍,学习率也翻倍,这是不知道内部细节时最可靠的近似。
学习率调度器也不是非要不可。我的经验是,先固定学习率跑 5 个 epoch,如果 loss 还在稳步下降,就不加调度;如果后期震荡不收敛,再加一个 cosine 或 step 调度。
from torch.optim.lr_scheduler import CosineAnnealingLR scheduler = CosineAnnealingLR(optimizer, T_max=20, eta_min=1e-5) # 每个 epoch 结束后调用 scheduler.step() 即可CosineAnnealingLR 的 T_max 设为预计的 epoch 总数,eta_min 设为目标最低学习率。它的好处是前期下降慢、后期下降快,比每隔固定步数除以 10 的 step 调度在分类任务上更稳。
4.4 一张参数速查表
整理一张可直接照抄的表,按优先级排列。
| 参数 | 起点值 | 调整方向 | 常见症状 |
|---|---|---|---|
| 学习率 | 1e-3(Adam) | 震荡则降,不降则升 | loss 震荡 / loss 不变 |
| 卷积核 | 3×3 | 时序任务可试 5 或 7 | 精度卡住 |
| 层数 | 卷积块 2~3 个 | 小数据集别超过 5 个 | 过拟合 / 训练太慢 |
| 通道数 | 16/32 起步翻倍 | 显存够再翻 | 过拟合 / OOM |
| 步长 | stride=2 下采样 | 大图先保持 stride=2 | 输出尺寸对不上 |
| 填充 | pad=(kernel-1)//2 | 保持尺寸不变 | 边缘精度差 |
| Batch size | 32 或 64 | 与学习率同向调整 | 收敛不稳或太慢 |
这张表不是标准答案,但它能帮你把 CNN 的首轮实验控制在一天内。调参过程中记录每一次改动和验证结果,网络性能下降时难以定位是谁的问题,多数原因不是玄学,而是没有从基线逐项修改,同时动了三个参数。
提示:每改一个参数,先只跑 1 个 epoch 看趋势,确认有效再继续。这样浪费的时间最少,得到的结论最干净。
5. 健壮性避坑与排查:CNN 训练失败的五个典型现象
5.1 现象:损失不降反升,或者从第一个 epoch 就出现 NaN
原因常见有两类:学习率过大导致梯度更新跨过极小点,还有数据没有归一化导致特征值量级过大。NaN 更容易定位,一般出在损失函数和输入数据上。
解决:先把学习率降到 1e-4 或 3e-4 试跑 10 个 batch;检查输入数据是否在 0~1 或标准化后的合理范围。分类任务用 CrossEntropyLoss,最后一层不要接 softmax;回归任务用 MSELoss,输出层不要接 ReLU。这两个配错,loss 的数值会变得无法解释。
5.2 现象:训练准确率很高(如 99%),验证准确率只有 70%
这是典型的过拟合,在 CNN 里比全连接网络更隐蔽,因为卷积本身有参数共享,但数据量太小(每类几百张)照样过拟合。从 loss 曲线上看,训练 loss 持续下降,验证 loss 在第几个 epoch 后开始反弹,就说明模型开始死记训练集了。
解决:优先加数据增强(随机裁剪、水平翻转、颜色抖动),这是最有效的正则化手段;其次加 dropout,放在全连接层之前比较有效;最后再考虑减小模型规模。注意数据增强只在训练集做,验证集只做标准化,不做增强。很多人把增强加到了验证集上,导致指标忽高忽低,白白浪费时间。
5.3 现象:特征图全变成 NaN,或者某一层输出恒为同一个值
原因:梯度爆炸或初始化不当。ReLU 网络里最常见的初始化是 He 初始化,PyTorch 的 nn.Conv2d 默认初始化对深层网络来说方差偏大。特征图输出恒为同一个值,通常是一整层的权重初始化全为 0,或者 ReLU 把所有输入都压成了 0。
解决:在模型定义后显式初始化权重。
def init_weights(m): if isinstance(m, nn.Conv2d) or isinstance(m, nn.Linear): nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu') if m.bias is not None: nn.init.zeros_(m.bias) model.apply(init_weights)kaiming_normal_ 的 mode 建议用 fan_out,它对反向传播更友好;bias 全部置 0 不会影响训练,因为卷积层的偏置梯度本来就比权重小得多。
5.4 现象:GPU 利用率很低,训练速度没有随硬件升级变快
原因:数据加载是瓶颈,网络在等 CPU 准备数据。Windows 下 num_workers 设大了反而报错,很多新手直接设 0,导致数据加载串行,GPU 一直空转。另一个隐蔽的原因是每个 epoch 都重新做一次增强计算,CPU 成了整个训练链路里的短板。
解决:先把 DataLoader 的 num_workers 从 2 开始调,并加 pin_memory=True,看看 GPU 利用率是否有明显提升。如果数据增强太重,把增强后的结果缓存到磁盘,或离线预处理后存成张量文件,训练时直接读取。GPU 利用率低,问题往往不在网络,而在数据管道。
5.5 现象:同一个代码跑两次,结果完全不一样,甚至同样的随机种子也不一致
原因:PyTorch 的 CUDA 卷积算法本身有随机性,某些算子(如 atomicAdd)在 float 类型下不保证完全确定。还有一个隐蔽的问题:DataLoader 多进程下数据读取顺序不稳定。
解决:需要精确复现实验时,在代码开头固定所有随机源。
import random, numpy as np, torch random.seed(0) np.random.seed(0) torch.manual_seed(0) torch.cuda.manual_seed_all(0) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = Falsecudnn.deterministic 会降低训练速度,benchmark 关闭后卷积自动调优也停了。实际项目里,以精度对比为目的时开确定性,追求训练速度时关闭。这也是很多框架代码里会保留开关的原因,不是写给你看的,是给不同使用场景准备的。
注意:验证集评估时,记得调用 model.eval(),否则 BatchNorm 和 dropout 的行为会跟训练时不一致,得到的准确率会偏低的。这是最隐蔽的坑,排查优先级永远排第一。
6. 从“能跑”到“能交付”:混淆矩阵与模型导出收尾
6.1 混淆矩阵:看清错在哪一类
准确率只是全局指标,CNN 代码实现的验收阶段更需要知道“错在哪一类”。比如数字识别里把 4 当成 9,把 7 当成 1,这两类错误对业务的影响完全不同。用混淆矩阵能直观看到哪一对类别最容易被混淆。
from sklearn.metrics import confusion_matrix import matplotlib.pyplot as plt import seaborn as sns model.eval() all_pred, all_true = [], [] with torch.no_grad(): for x, y in val_loader: out = model(x) all_pred.extend(out.argmax(1).tolist()) all_true.extend(y.tolist()) cm = confusion_matrix(all_true, all_pred) sns.heatmap(cm, annot=True, fmt='d', cmap='Blues') plt.xlabel('predicted') plt.ylabel('true') plt.show()针对混淆集中的类别,常见做法是收集该类别的错误样本,单独建一个小校验集,逐张看模型哪里错了——是图像本身模糊,还是预处理方式和训练集不一致。这一步比盲目改网络结构有效得多。
6.2 部署时导出 ONNX:把模型交给生产环境
训练完之后,PyTorch 模型通常要转成 ONNX 才能在推理引擎或移动端跑。导出本身不算复杂,坑在动态尺寸。
model.eval() dummy = torch.randn(1, 1, 28, 28) torch.onnx.export(model, dummy, "model.onnx", input_names=["input"], output_names=["output"], opset_version=12)opset_version 决定导出的算子集,导出后建议用 onnxruntime 跑一遍推理,对比 PyTorch 的预测结果是否一致。如果导出报错,通常是你用了动态维度的层(如自适应池化),解决方法是把 dummy 输入换成代表性尺寸并固定,或显式声明 dynamic_axes 控制允许变化的维度。
我最早用 Python 做 CNN 时,花了整整两个晚上排查一个“验证准确率停滞在 90%”的问题,最后发现是测试集评估时忘了关 model.eval(),BatchNorm 和 dropout 的行为全乱了。从那以后我给自己定了个习惯:训练和验证代码永远分开写,model.train() 和 model.eval() 必须成对出现在函数开头。这个习惯后来帮我省了很多次定位问题的时间。希望帮到你。
本文还有配套的精品资源,点击获取