ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Python实现CNN卷积神经网络:从零搭建图像分类模型

2026/9/24 15:21:15 拓冰建站 浏览量
Python实现CNN卷积神经网络:从零搭建图像分类模型 简介这是一份基于Python与TensorFlow的卷积神经网络完整实现代码PDF资料适合图像识别入门者或需要快速搭建CNN模型的开发者参考。内容从MNIST数据集加载开始依次讲解卷积层、池化层、全连接层、Dropout层、Softmax分类和交叉熵损失函数的代码写法并给出Adam优化器训练与准确率评估的完整流程。资源为单个PDF文件压缩包大小仅56KB方便随时查阅。目前已有3760人学习下载。PDF中还包含针对训练集规模和卷积核数量变化的对比实验如1000/10000/100000条训练数据、16/32个卷积核等不同组合下的测试准确率展示并总结出“小训练集增加卷积核收益有限、扩充数据提升更明显”的实践结论。读者可据此快速理解CNN各模块作用也可直接参考代码修改参数进行实验还能结合实验结果分析数据量与网络宽度对精度的影响。1. PythonCNN卷积神经网络代码实现从套框架到真正读懂卷积很多人入门深度学习时会发现一个尴尬的现象跑通 MNIST 教程只需要十分钟但换到自己的数据集上模型要么 loss 不降、要么显存爆掉、要么精度始终上不去。问题的根源往往是代码实现停留在“照抄”层面——卷积层、池化层、全连接层的形状到底怎么流转是黑匣子。其实Python 里用 CNN 卷积神经网络做图像分类并不复杂关键是把 PyTorch或 TensorFlow/Keras的工具函数拆开看懂数据管道怎么接、卷积层怎么定义、每一步的张量形状怎么算、损失和反向传播怎么闭环。这篇文章就沿着一条完整、可复现的最小实现路径走一遍适合刚学完 python 基础想动手写第一个深度学习模型的读者也适合已经跑通过 demo 但想排查训练问题的熟手。代码都是以 CPU 可运行为前提有一张 NVIDIA 显卡会更快但不影响理解和复现。2. 搭建 CNN 最小可运行骨架数据管道、卷积层与全连接层的 Python 代码2.1 数据管道从本地文件夹到 DataLoader 的 Python 代码图像分类的第一步不是建网络而是把散落在文件夹里的图片变成一个模型能批量消费的数据流。常见做法是用torchvision.datasets.ImageFolder它要求数据按类别分目录存放结构如下data/ train/ cat/ 001.jpg 002.jpg dog/ 001.jpg 002.jpg val/ cat/ dog/这样的目录结构是 ImageFolder 的约定也是后续换自己数据集时改动成本最低的方案。读取代码用 PyTorch 写就是from torchvision import datasets, transforms from torch.utils.data import DataLoader transform transforms.Compose([ transforms.Resize((64, 64)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) train_dataset datasets.ImageFolder(rootdata/train, transformtransform) train_loader DataLoader(train_dataset, batch_size32, shuffleTrue, num_workers2, pin_memoryTrue) print(train_dataset.classes) # [cat, dog] print(len(train_dataset)) # 样本总数这里要说明三个比较影响结果的参数。Resize((64, 64))是为了给所有输入图片一个统一尺寸实际使用时要根据任务调整通常 224×224、128×128、64×64 是常用档位尺寸越大信息越全但训练越慢。Normalize用的是 ImageNet 数据集的均值和标准差对大部分自然图像任务都适用它是逐通道做(x - mean) / std目的是把像素值拉到一个相对稳定的分布帮助梯度下降更平滑地收敛。batch_size是每次迭代送入的图片数CPU 训练建议 16 或 32显存不够就减半。shuffleTrue必须在训练阶段打开否则每个 epoch 内模型看到的样本顺序完全一样容易陷入对顺序的过拟合。DataLoader的作用是把Dataset包装成可迭代对象。每次迭代它会从数据集中抽出batch_size张图组成一个四维张量形状是(batch_size, channels, height, width)对应到这里就是(32, 3, 64, 64)。通道数在前是 PyTorch 的约定和 TensorFlow 的(height, width, channels)正好相反初学者在这上面翻车很常见。如果num_workers在 Windows 上运行时偶尔报错就设成 0问题基本就消失了。数据管道这一层是之后所有训练的基础。如果你手头的数据不是按文件夹分类的而是放在一个 CSV 文件里、记录着图片路径和标签那就要自定义一个 Dataset 类重写__len__和__getitem__两个方法。比如import pandas as pd from PIL import Image class CsvDataset(torch.utils.data.Dataset): def __init__(self, csv_path, transformNone): self.df pd.read_csv(csv_path) self.transform transform def __len__(self): return len(self.df) def __getitem__(self, idx): row self.df.iloc[idx] img Image.open(row[path]).convert(RGB) if self.transform: img self.transform(img) return img, row[label]在__getitem__里返回一个 (图像, 标签) 的元组标签可以是整数索引需要自己去实现字符串到索引的映射。这样就把数据读入和预处理逻辑完全封装起来了不管原始数据长什么样训练代码都不用改。2.2 CNN 网络类卷积、池化、全连接层怎么拼数据管道准备好以后开始定义网络。一个最基础、也能真正跑起来的 CNN 结构是两次“卷积 激活 池化”把特征图压平之后接两层全连接。用 PyTorch 的nn.Module实现import torch import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes2): super(SimpleCNN, self).__init__() self.features nn.Sequential( nn.Conv2d(3, 16, kernel_size3, padding1), # 64x64 - 64x64 nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 64x64 - 32x32 nn.Conv2d(16, 32, kernel_size3, padding1), # 32x32 - 32x32 nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 32x32 - 16x16 ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(32 * 16 * 16, 64), nn.ReLU(inplaceTrue), nn.Linear(64, num_classes), ) def forward(self, x): x self.features(x) x self.classifier(x) return x这个网络的核心设计逻辑是这样几个点。Conv2d(3, 16, kernel_size3, padding1)含义是输入 3 通道、输出 16 通道、卷积核 3×3padding1保证输出特征图的宽高和输入保持一致。卷积层负责提取局部特征浅层学到的通常是边缘、颜色块深层会把它们组合成更语义化的模式。MaxPool2d(2)是 2×2 最大池化把每个 2×2 区域取最大值宽高减半增强平移不变性并减少后续计算量。ReLU作为激活函数为网络提供非线性能力在 CNN 里它是目前性价比最高的选择梯度消失风险远低于 Sigmoid。整个网络的实际流转可以这样推演。输入一张 3×64×64 的彩色图经过第一个卷积得到 16×64×64池化后变 16×32×32再过一个卷积变成 32×32×32池化后变 32×16×16。此时torch.flatten把它拉成32*16*16 8192的一维向量全连接层把这 8192 个值映射到 64 维最后输出 2 维的类别得分。nn.Linear只做线性变换没有内置激活函数所以最后一层直接输出 logits未归一化得分配合后面要讲的CrossEntropyLoss使用。维度推算是每个初学者必须亲手做一遍的事。关键公式是H_out (H_in 2*padding - kernel_size) / stride 1。这里的 MaxPool 和 Conv 都可以套这个公式。如果算错维度PyTorch 会在第一次前向传播时报类似mat1 and mat2 shapes cannot be multiplied的错误。最稳妥的调试手段是用随机张量跑一次前向model SimpleCNN(num_classes2) dummy torch.randn(2, 3, 64, 64) output model(dummy) print(output.shape) # torch.Size([2, 2])只要这一步能跑通说明网络的张量流转逻辑没有错误可以安心进入训练环节了。3. 训练闭环损失函数、优化器与每天训练必看的三个指标3.1 损失函数与优化器选型交叉熵 Adam 的组合逻辑网络定义完接下来的问题是怎么让它学会。图像分类任务中最常用的是交叉熵损失PyTorch 里直接调用nn.CrossEntropyLoss()。它内部做了两件事先把网络输出过的 logits 做 softmax 转成概率分布再计算和真实标签之间的交叉熵。这就是为什么输出层不需要自己额外加 softmax 的原因加了反而会让梯度在反向传播时不稳定。优化器选择 Adam 是当前图像分类任务中比较省心的默认项。它基于一阶矩和二阶矩估计自适应调整每个参数的学习率对学习率的初始值不那么敏感通常设置 0.001 就能取得不错的效果。如果想更接近研究论文里的做法也可以在 Adam 收敛后切换到 SGD momentum但这属于调优技巧不是第一步必须做的。import torch.optim as optim device torch.device(cuda if torch.cuda.is_available() else cpu) model SimpleCNN(num_classes2).to(device) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr0.001)lr是最值得花时间去调的超参数0.001 只是起点。太大 loss 会震荡甚至飞掉太小模型学得很慢。后面第 5 章会介绍学习率调度在训练中途动态调整。3.2 训练循环与三个指标loss、accuracy、单 epoch 耗时训练循环是整套代码最核心的部分。每个 epoch 要做的事是遍历训练集、清零梯度、前向传播、计算损失、反向传播、更新参数。同时按固定间隔在验证集上计算准确率防止模型过拟合而不自知。代码如下def train_one_epoch(model, loader, criterion, optimizer, device): model.train() running_loss, correct, total 0.0, 0, 0 for images, labels in loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() * images.size(0) _, preds torch.max(outputs, 1) correct (preds labels).sum().item() total labels.size(0) epoch_loss running_loss / total epoch_acc correct / total return epoch_loss, epoch_acc这里有几个容易被忽略的细节。optimizer.zero_grad()必须在每个 batch 前调用因为 PyTorch 的梯度是累积的不清零的话上一轮的梯度会叠加到本轮上。model.train()会把模型设置为训练模式对 BatchNorm 和 Dropout 来说这很重要评估阶段要调回model.eval()这个坑在第 4 章专门展开。loss.item()是从只含一个元素的张量中取出 Python 数值方便做统计。训练时至少要看三个指标。第一是训练 loss它整体应该持续下降如果上下剧烈震荡说明学习率偏大如果不降反升先怀疑是代码问题而不是网络问题。第二是训练准确率它反映模型在见过的数据上的拟合程度。第三是验证集准确率这是在未见过的数据上的泛化表现这才是你真正关心的指标。再额外记录一下单 epoch 的耗时它决定了你调参的迭代速度。验证部分的写法和训练类似但少了反向传播和参数更新并且要用torch.no_grad()关闭梯度计算def evaluate(model, loader, criterion, device): model.eval() val_loss, correct, total 0.0, 0, 0 with torch.no_grad(): for images, labels in loader: images, labels images.to(device), labels.to(device) outputs model(images) loss criterion(outputs, labels) val_loss loss.item() * images.size(0) _, preds torch.max(outputs, 1) correct (preds labels).sum().item() total labels.size(0) return val_loss / total, correct / totaltorch.no_grad()不仅省内存还省计算时间因为 PyTorch 不再需要为每个张量构建计算图。3.3 断点续训与最优权重保存训练一个稍微大的 CNN 可能要跑几小时到几天中途断电、显存溢出、或者只是想换个学习率继续调如果没有保存权重的机制一切都要重来。我一般会在每个 epoch 结束后保存一份当前的最优模型并额外保存一份“最近一轮”的完整状态以备断点续训。best_acc 0.0 for epoch in range(1, 31): train_loss, train_acc train_one_epoch(...) val_loss, val_acc evaluate(...) print(fEpoch {epoch:02d} | Train Loss {train_loss:.4f} | fTrain Acc {train_acc:.4f} | Val Acc {val_acc:.4f}) if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_model.pth) torch.save({ epoch: epoch, model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), best_acc: best_acc, }, checkpoint.pth)保存state_dict而不是整个模型是推荐做法原因是 state_dict 只包含参数和缓冲区文件更小、更稳定和代码版本兼容性更好。断点续训时需要重建模型、优化器再加载 checkpoint 里的状态checkpoint torch.load(checkpoint.pth, map_locationdevice) model.load_state_dict(checkpoint[model_state_dict]) optimizer.load_state_dict(checkpoint[optimizer_state_dict]) start_epoch checkpoint[epoch]map_locationdevice这段代码解决的是训练和推理不在同一台设备上的问题比如在 GPU 上训练、在普通电脑上做 CPU 推理加载权重时加上它就不会报显存相关的错误。这是很多人容易漏掉的细节。4. 卷积网络代码最容易翻车的 5 个细节从形状报错到过拟合排查4.1 现象mat1 and mat2 shapes cannot be multiplied最常见的前向报错。原因是全连接层的输入维度写错了把三维特征图压平后的长度和nn.Linear的第一个参数对不上。比如你的特征图是32×16×16压平后是 8192但 Linear 写成了nn.Linear(4096, 64)。原因基本是只改了输入图片尺寸忘了重新推算卷积和池化之后的特征图大小。解决方法是先按公式算出 H_out 和 W_out再用dummy torch.randn(1, 3, 64, 64)跑一次前向把model(x).shape打印出来和理论值对照。如果你不想手算更高阶一点的做法是把卷积部分做成一个方法用池化层本身去计算最终尺寸但入门阶段还是建议手动算一遍这一步对理解网络结构非常关键。4.2 现象训练 loss 正常验证集准确率却忽高忽低甚至完全不行一个典型场景是训练 loss 持续下降但验证集精度始终在随机水平附近徘徊或者验证时预测结果明显异常。原因很可能是在验证阶段忘了调用model.eval()。BatchNorm层在训练模式中使用当前 batch 的均值和方差进行归一化而在评估模式中使用训练阶段累计的全局统计量。如果不切换验证时每个 batch 的 BN 统计量都在变化输出结果自然不稳定。同理Dropout层在训练时随机丢弃神经元评估时应该关闭model.eval()会自动处理这两件事。解决方式很简单在验证函数开头加一行model.eval()训练时再model.train()。另外还要确认验证集没有开启shuffle并且图片预处理要和训练时保持一致。4.3 现象训练 loss 一直在降但 loss 数值偏高、收敛很慢比如分类猫狗图片100 个 epoch 后训练准确率到了 99%验证集却只有 60%这是典型的过拟合代码写对了但策略有问题。原因有两个层面。第一是数据量太少模型把训练集里不属于通用规律的部分也背了下来。常见应对是引入数据增强随机水平翻转、随机旋转、颜色抖动等增加训练样本的多样性这话在老手嘴里通常叫“给数据加扰动”。第二是模型容量偏大而模型偏复杂更好过拟合解决方式是加 Dropout 层、加权重衰减weight_decay或直接换一个更小的网络。实际操作中我会首先在验证集上多花点时间去排查# 检查验证集数据是否和训练集分布一致 for images, labels in val_loader: plt.imshow(images[0].permute(1, 2, 0)) break如果验证集样本分布和训练集差异过大比如训练集全是白天拍的图、验证集全是夜景那模型在验证集上表现差就不是代码能解决的而是数据分布问题。这类问题在图像分类里很常见值得先排除再动模型。4.4 现象CUDA out of memory在 GPU 上训练时显存溢出是最常见的报错。原因基本是单张图的尺寸太大、batch_size 太大或网络层数太深三者叠加直接把显存打满。解决的优先级通常是这样先把batch_size减半这是最直接的手段比如从 32 降到 16显存占用减半。还不够就把输入分辨率从 224 降到 128 或 64。还可以暂时把pin_memoryTrue关掉。如果这些都不行就要改代码结构了比如用torch.cuda.amp自动混合精度训练把部分计算从 fp32 降到 fp16显存消耗能降低不少。CPU 训练不存在这个问题但是速度会慢一到两个数量级所以实践中很多人更倾向于调小输入尺寸和 batch而不是扩大模型。4.5 现象模型滚动式学习训练完用最佳权重做推理还是有偏差有人喜欢在训练完所有 epoch 后拿最后一个 epoch 的权重去推理这是很多实现里埋的隐患。其实最后一个 epoch 不一定是泛化能力最好的尤其是边训练边波动的情况下更稳妥的做法是始终保存验证集准确率最高的那套权重。前面第 3 章的写法已经实现了这一点推理阶段直接加载best_model.pth。还有一个相对好的习惯保存权重后再加一条最简单的 sanity check用同一张图分别加载权重和直接用代码运行对比预测结果是否一致。这个习惯能在模型交付前挡掉很多低质量权重。推理代码如下model SimpleCNN(num_classes2) model.load_state_dict(torch.load(best_model.pth, map_locationcpu)) model.eval() img Image.open(test.jpg).convert(RGB) img_tensor transform(img).unsqueeze(0) # (1, 3, 64, 64) with torch.no_grad(): logits model(img_tensor) pred torch.argmax(logits, dim1).item() print(train_dataset.classes[pred])unsqueeze(0)是为了给单张图片加上 batch 维度把三维张量变成四维。这个操作太容易忘了忘掉之后就会报期望 4 维输入、实际拿到 3 维输入的维度错误。argmax取 logits 中得分最高的索引索引映射回类别名。5. 让 CNN 真正好用起来的进阶写法迁移学习、特征图可视化与训练配置5.1 迁移学习用预训练权重把训练时间从小时级降到分钟级当你手头的数据集只有几百张图时从零训练一个 CNN 往往效果一般因为随机初始化的卷积核要学到有用的特征需要大量数据。更好的做法是使用在 ImageNet 上预训练好的模型把前面的卷积层当作特征提取器只重新训练最后几层。这在 PyTorch 里实现起来非常简洁import torchvision.models as models def create_pretrained_model(num_classes2): model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) for param in model.parameters(): param.requires_grad False # 冻结卷积层 model.fc nn.Linear(model.fc.in_features, num_classes) return modelrequires_grad False表示冻结这部分参数的梯度计算反向传播时不会更新它们大幅减少计算量。model.fc是 ResNet18 最后的全连接层in_features是它上一层输出的特征维度把原输出层替换成你自己的类别数即可。这样做的好处是特征提取部分直接用现成的只需要训练最后一层做分类。如果追求更好的效果还可以解冻最后几个 Block 做微调让高层特征更贴合你的数据分布。但微调时要使用更小的学习率比如 0.0001避免破坏已经学好的特征。开源社区里反正有大量训练好的权重用起来比从零开始省事得多。5.2 特征图可视化确认网络看到了什么CNN 常常被吐槽是黑匣子但实际上有一类低成本的方法可以直观观察中间层学到了什么那就是把卷积层的输出特征图直接画出来。做法是把网络里某一层的输出取出来转成灰度图或伪彩色图查看。def visualize_feature_maps(model, image_tensor, layer_namefeatures): model.eval() activations {} def hook_fn(name): def hook(module, input, output): activations[name] output.detach() return hook model.features.register_forward_hook(hook_fn(layer_name)) with torch.no_grad(): model(image_tensor.unsqueeze(0)) maps activations[layer_name][0] # (C, H, W) for i in range(min(maps.size(0), 16)): plt.subplot(4, 4, i 1) plt.imshow(maps[i], cmapgray) plt.show()用register_forward_hook在前向传播时把中间层输出“钩”出来不需要修改网络结构。第一次跑这个可视化你会看到浅层特征图里大多是边缘、纹理深层更像某种模式组合。如果深层特征图全是空白或噪声基本可以判断训练出了问题比如梯度消失或数据预处理有误。这个可视化本身就是一个很好的阶段性验证动作能直观确认模型到底在学什么。5.3 训练配置学习率调度与梯度裁剪手动调学习率很消耗时间常见做法是让学习率在训练过程中自动变化。PyTorch 的ReduceLROnPlateau是很好用的方案它监控验证集指标连续几个 epoch 没有提升就自动把学习率乘以一个系数scheduler torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemax, factor0.5, patience3 ) scheduler.step(val_acc)modemax表示监控的指标越高越好factor0.5表示降到原来的二分之一patience3表示连续 3 个 epoch 没有超过历史最高验证准确率就降学习率。这个调度器监控的是你传入的val_acc所以scheduler.step()必须在每个 epoch 的验证结束后调用而不是在训练阶段调用。梯度裁剪解决的是另一种问题loss 突然变成 NaN或者某个 batch 的梯度值大得离谱导致参数一步更新跨越太大。常见做法是裁剪到固定范数阈值比如 10 或 1.0torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm10.0)放在loss.backward()之后、optimizer.step()之前执行。这一行代码可以阻止梯度爆炸特别是在模型较深、batch 较小时能够有效提升训练稳定性。6. 一个能直接复现的 CNN 项目结构从组织代码到验证结果实际做项目时很少有人把全部代码写进一个文件。一方面不好维护另一方面换数据集或换模型时要大改。下面是一个我常用的、兼顾入门和工作的项目结构project/ main.py # 训练入口控制整个流程 model.py # 网络定义包括 SimpleCNN 和预训练模型 data.py # 数据集与 DataLoader 相关代码 train.py # 训练和验证函数 utils.py # 可视化、指标记录等工具 config.py # 超参数集中管理config.py把学习率、batch_size、epoch 数、图片尺寸集中到一个字典或 dataclass改参数时不用翻代码。做实验时最怕的就是参数散落在各处改一个漏一个。验证模型效果时不要只看一个准确率数字最好把分类报告和混淆矩阵一起打印出来from sklearn.metrics import classification_report, confusion_matrix y_true, y_pred [], [] with torch.no_grad(): for images, labels in val_loader: outputs model(images) _, preds torch.max(outputs, 1) y_true.extend(labels.numpy()) y_pred.extend(preds.numpy()) print(classification_report(y_true, y_pred)) print(confusion_matrix(y_true, y_pred))对于二分类来说准确率可能掩盖类别不平衡问题比如 95% 的样本都是猫模型全都预测猫也有 95% 准确率但这个模型毫无价值。分类报告里的 precision、recall、f1-score 才是需要盯的指标尤其是样本比较少的那一类。这是我跑项目时的血泪经验只盯着准确率调参是最容易做出自我感觉良好但上线就翻车模型的方式。另外我在交付模型前一定会在训练集上做一次小样本过拟合测试用 50 张图跑 20 个 epoch确认 loss 能降到接近 0。如果小样本都学不动那大概率是网络结构或数据管道有问题不值得盲目加大数据量去试。做完这些再上全量数据基本就能保证一次跑通。每次跑新项目我都沿用这套检查习惯希望帮到你。本文还有配套的精品资源点击获取