群卷积网络实战:ResNeXt与ShuffleNet在MNIST上的源码解析与对比 📅 发布时间:2026/9/16 22:24:41 👁 浏览次数: 简介一套面向深度学习初学者的期末大作业项目基于MNIST手写数字识别任务完成ShuffleNet与ResNeXt两类群卷积神经网络的训练与性能对比并整理为完整的夏季学期智能应用联合大作业报告适合计算机、人工智能等专业学生作为课程设计、毕业设计或入门实战参考。包内代码经测试可运行附有说明文档遇到问题还可联系作者远程指导。资源共29个文件包含Python源码模型定义与训练脚本、Markdown说明文档、MNIST数据集文件bin/ubyte等格式及模型检查点gz等整体约86.47MB结构清晰便于按模块学习。已有87人学习下载。通过该项目可掌握PyTorch环境下多网络结构搭建、数据集加载、训练调参与结果分析的基本流程也可基于现有代码扩展实现其他分类任务是深度学习课程作业与项目复现的实用参考资料。1. 期末大作业里的群卷积网络到底在训练什么如果你翻过 PyTorch 官方模型库会发现 ResNet 和 ResNeXt 的瓶颈结构只差了一个分组数参数但后者在同等 FLOPs 下精度更稳。这次拿到的期末大作业源码包里正好同时包含了 ShuffleNet-v2 和 ResNeXt 两套 PyTorch 实现外加一个 mnist.py 训练脚本和文档说明覆盖了从数据加载、模型定义到性能对比的完整链路。很多人在 MNIST 上跑 LeNet 跑习惯了一上来就认为 CNN 在 28×28 灰度图上只能靠卷积核堆深度但群卷积Grouped Convolution给出了另一条路把通道切成多个组每组独立做卷积再在后续层里做信息融合。这个思路直接影响 ResNeXt 的 cardinality 和 ShuffleNet 的 channel shuffle 设计。这篇文章会直接对着源码讲清楚这两个网络在 MNIST 上的训练配置、参数怎么调、推理时踩过的坑以及期末答辩时那些能加分的分析点适合正在做课设、毕设或者想拿现成代码改自己实验的读者。2. 群卷积与 cardinalityResNeXt 在 MNIST 上的结构拆解2.1 什么是群卷积它改变了哪个维度普通卷积的输入是C_in × H × W卷积核尺寸是C_in × K × K输出通道数由卷积核个数决定。群卷积把这个过程拆成 G 组输入通道分成 G 份每组用C_in / G × K × K的卷积核独立卷积最后把 G 组输出拼接起来。这样一来卷积核数量没变但每组卷积只看一部分输入通道参数量和计算量都降为原来的 1/G。这个操作的意义不在于省显存而在于它改变了网络的归纳偏置。ResNeXt 论文里提出 cardinality即分组数作为和深度、宽度并列的第三维度实验结论是在保持 FLOPs 相近的前提下加大 cardinality 比单纯加深或加宽网络更能提升精度。原因可以这样理解分组卷积强制网络在多个子空间里分别提取特征相对弱化了通道间的全连接式耦合相当于一种结构化正则化。对于 MNIST 这种小数据集模型的拟合能力和过拟合风险之间的平衡很关键cardinality 的引入让 ResNeXt 在参数总量受控时依然有足够的表达能力。2.2 源码里 ResNeXt 的 block 实现逻辑打开 ResNeXt-PyTorch 目录核心文件就是模型的 block 定义。常见的 ResNeXt block 采用 split-transform-merge 结构也就是先 1×1 卷积降维再做带分组数的 3×3 卷积最后用 1×1 卷积恢复维度。源码里的关键部分大致是这样的结构class ResNeXtBlock(nn.Module): def __init__(self, in_channels, out_channels, stride1, cardinality32, bottleneck_width4): super().__init__() group_width cardinality * bottleneck_width self.conv1 nn.Conv2d(in_channels, group_width, kernel_size1, biasFalse) self.bn1 nn.BatchNorm2d(group_width) self.conv2 nn.Conv2d(group_width, group_width, kernel_size3, stridestride, padding1, groupscardinality, biasFalse) self.bn2 nn.BatchNorm2d(group_width) self.conv3 nn.Conv2d(group_width, out_channels, kernel_size1, biasFalse) self.bn3 nn.BatchNorm2d(out_channels) self.relu nn.ReLU(inplaceTrue) def forward(self, x): identity x out self.conv1(x) out self.bn1(out) out self.relu(out) out self.conv2(out) out self.bn2(out) out self.relu(out) out self.conv3(out) out self.bn3(out) if identity.size() ! out.size(): identity self._downsample(identity) out identity out self.relu(out) return out这段代码里最关键的是conv2的groupscardinality参数它把 3×3 卷积从普通卷积变成了群卷积。bottleneck_width控制每个组的通道宽度group_width cardinality * bottleneck_width决定了中间层的实际通道数。比如 cardinality32、bottleneck_width4 时中间层就是 128 个通道。这个设计比直接指定中间层通道数更灵活因为它把 cardinality 显式变成了可调超参数。2.2.1 shortcut 分支的处理方式残差连接里有一个容易被忽略的细节当stride1且通道数没变化时identity 直接相加但如果下采样或者通道数变化源码里的_downsample会用 1×1 卷积加 stride 来对齐尺寸和通道。很多实现里 shortcut 用 AvgPool 或直接补零但这个源码用的是 1×1 卷积这会让残差分支本身也参与梯度传播训练时收敛更稳定。2.3 MNIST 输入下的网络适配问题MNIST 图片只有 28×28 单通道而 ResNeXt 的标准输入是 224×224 三通道。如果直接把源码里的 stem 网络搬过来第一层 7×7 卷积加 stride2 会在 28×28 上直接把空间尺寸压到 11×11再经过几个 stage 后特征图就没了。这个源码里对 MNIST 的处理是把 stem 简化了常见做法是self.stem nn.Sequential( nn.Conv2d(1, 64, kernel_size3, stride1, padding1, biasFalse), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue) )把 7×7 stride2 的 stem 换成 3×3 stride1目的是保留空间分辨率。MNIST 字符的边缘信息集中在很小的区域里早期下采样太狠会丢掉笔画细节反而不利于后续特征提取。另外nn.Conv2d(1, 64, ...)把输入通道从 3 改成 1这是适配灰度图的必要操作。如果你拿到的源码里没有改这一步那跑起来的第一条报错就会是通道数不匹配。2.4 为什么说 ResNeXt 对 MNIST 是高配MNIST 一共 7 万张 28×28 图类别只有 10 个。ResNeXt 哪怕简化版也有几百万参数在这个数据规模上很容易过拟合。源码里降低过拟合的手段基本是靠 BN 和 weight decay但实际训练时你会发现验证集 loss 和训练集 loss 的差距会随着 epoch 快速拉大。这时候应该把 cardinality 调小从 32 降到 8 或者 4模型容量立刻下降精度反而可能回升。也可以加深 bottleneck_width 但保持 cardinality 不变这样通道更宽但分组约束仍然存在特征多样性不会失控。3. ShuffleNet-v2 的通道混洗与逐通道卷积3.1 ShuffleNet-v2 的设计原则和源码对应ShuffleNet-v2 论文里提出了四条高效网络设计准则其中最重要的两条是输入输出通道数相等时内存访问成本最低以及分组数过多会增加内存访问成本。v1 版本里有大量 1×1 分组卷积这其实增加了内存访问。v2 的改进是把 1×1 卷积改成普通卷积用 channel split 和 channel shuffle 来维持分组特性并把逐通道卷积depthwise convolution放在分支里。源码里的shufflenet_v2.py基本复刻了这个设计。核心单元是一个带 stride 判断的 blockclass ShuffleV2Block(nn.Module): def __init__(self, inp, oup, stride, mid_channels): super().__init__() self.stride stride assert stride in [1, 2] self.conv1 nn.Conv2d(inp, mid_channels, 1, 1, 0, biasFalse) self.bn1 nn.BatchNorm2d(mid_channels) self.depthwise nn.Conv2d(mid_channels, mid_channels, 3, stride, 1, groupsmid_channels, biasFalse) self.bn_dw nn.BatchNorm2d(mid_channels) self.conv2 nn.Conv2d(mid_channels, oup - mid_channels, 1, 1, 0, biasFalse) self.bn2 nn.BatchNorm2d(oup - mid_channels)这个 block 的推理流程是输入先按通道切成两份一份走主分支1×1 卷积分组卷积加深度卷积另一份走 shortcut。stride1 时两个分支拼接stride2 时输入不切分两支都做下采样再拼接。channel shuffle的作用是让两个分支的信息在下一层真正交叉起来否则分组就永远隔离了。3.1.1 channel shuffle 的 PyTorch 实现channel shuffle 的代码很简短但初次接触会有点绕def channel_shuffle(x, groups): batch, channels, h, w x.shape x x.view(batch, groups, channels // groups, h, w) x x.transpose(1, 2).contiguous() x x.view(batch, -1, h, w) return x第一步view把通道维度拆成 groups 和 channels/groups 两维第二步transpose交换这两个维度第三步再还原。这个操作的本质是把原本按组排列的通道顺序打散让下一层卷积的每个组都能看到上一层不同组的信息。contiguous()是必须的因为transpose之后张量内存不连续直接view会报错。如果训练时遇到RuntimeError: view size is not compatible with input tensors size大概率是这里忘记调 contiguous。3.2 ShuffleNet 在 MNIST 上的训练表现ShuffleNet-v2 的标准版本是为 ImageNet 设计的网络很窄但层数深。在 MNIST 上跑的时候stage 的数量和每层的输出通道数都需要改小。源码里通常会保留 3 个 stage每 stage 重复 block 的个数减少到 1 或 2初始通道数从 24 开始。这样参数总量能控制在几十万级别和 MNIST 的数据规模匹配。MNIST 的任务难度比 ImageNet 低得多ShuffleNet-v2 在这种小数据集上反而容易暴露一个问题深度卷积的感受野太小早期层只看到局部笔画如果网络太深梯度经过多个 depthwise 卷积后容易消失。实际做法是把最后一层改成全局平均池化加全连接并且全连接层的输入不是展平的原始特征图而是池化后的 1×1 向量这样全连接层的参数量会小很多。3.3 两个网络在同一数据集上的对比逻辑源码里做性能对比时常规指标是四个参数量、FLOPs、训练收敛速度、测试集精度。参数量可以直接用sum(p.numel() for p in model.parameters())统计FLOPs 需要借助thop.profile或者fvcore这类库。对比时要注意FLOPs 和输入分辨率强相关MNIST 的 28×28 输入下两个网络的 FLOPs 都会比 ImageNet 标准输入低一两个数量级所以写报告时一定要标注输入尺寸否则数据没有可比性。from thop import profile input_tensor torch.randn(1, 1, 28, 28) flops, params profile(model, inputs(input_tensor,)) print(fFLOPs: {flops / 1e6:.2f}M, Params: {params / 1e6:.2f}M)profile会递归遍历模型里的每个 nn.Module统计卷积和全连接层的乘加次数。BN 层一般不计入 FLOPs但参数量会计入。你在报告里对比这两项时要把输入张量的通道数从 3 改成 1不然统计结果比你实际部署时要大 3 倍。4. mnist.py 训练脚本的断点排错与超参数调优4.1 数据加载与预处理细节源码的 mnist.py 里数据加载用的是torchvision.datasets.MNIST但预处理通常不是简单的ToTensor()。MNIST 训练集和测试集的标准化参数是固定的均值和标准差分别是(0.1307,)和(0.3081,)这是整个数据集上统计出来的。写法如下transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ]) train_dataset datasets.MNIST(root./data, trainTrue, downloadTrue, transformtransform) train_loader DataLoader(train_dataset, batch_size128, shuffleTrue, num_workers2)Normalize两个参数都是单元素元组因为 MNIST 是单通道。如果写成(0.5, 0.5, 0.5)这种三通道的会直接报错。num_workers在 Windows 上要注意如果设置大于 0 且代码没放在if __name__ __main__:里会出现多进程递归启动的报错解决方案是改成num_workers0或者把训练代码封装进 main 函数。4.2 训练循环里的学习率和优化器选择两个网络在这个脚本里使用的是同一个训练循环优化器一般是 Adam 或 SGD。对于 ResNeXt 这种带 BN 的网络SGD momentum 通常比 Adam 更稳尤其是迁移到其他数据集时Adam 的泛化性略差。这里给出一个通用配置optimizer torch.optim.SGD(model.parameters(), lr0.01, momentum0.9, weight_decay5e-4) scheduler torch.optim.lr_scheduler.MultiStepLR( optimizer, milestones[20, 40], gamma0.1) criterion nn.CrossEntropyLoss()weight_decay控制 L2 正则强度5e-4 是 ImageNet 上常用的值但 MNIST 上可以适当调大一点到 1e-3因为数据量小正则的作用更明显。milestones在 20 和 40 个 epoch 时把学习率乘以 0.1这比余弦退火更直观也容易在报告里写清楚。用 Adam 的话学习率一般设 1e-3且不需要手动衰减那么多。4.2.1 训练过程里最常见的三个报错第一个是维度不匹配。具体表现是size mismatch for classifier.1.weight这类错误原因是 MNIST 的 1×28×28 输入经过网络后全连接层输入维度和你定义的不一致。解决办法是在模型 forward 里加一句打印或者用torchsummary.summary(model, (1, 28, 28))查看每一层输出形状。第二个是显存不足MNIST 数据很小正常不会爆显存但如果 batch_size 设到 512 以上且模型没改小有可能会爆。第三个是训练 loss 不下降原因多数是 BN 的 momentum 设置不对或者学习率太大导致梯度震荡。先检查数据标准化是否正确再看 BN 层是不是在 eval 模式下被错误地冻结了。4.3 训练过程状态保存与恢复源码里如果包含断点续训功能通常会这样保存checkpoint { epoch: epoch, model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), scheduler_state_dict: scheduler.state_dict(), best_acc: best_acc } torch.save(checkpoint, fcheckpoint_e{epoch}.pth)恢复时需要注意顺序先加载模型权重再加载优化器然后加载调度器。如果跳过优化器加载光有模型权重也能推理但继续训练时动量项会丢失learning rate 也可能对不上。还有一个细节是加载预训练权重时一定要严格区分model.state_dict()和torch.save(model, ...)的区别前者只是权重字典后者是整个模型对象。源码里如果有.pth文件优先用load_state_dict方式加载这样不会因为类定义位置不同而报 pickle 错误。4.4 训练完成的模型如何生成判别曲线和错误样例期末报告里除了准确率还需要展示模型的实际预测能力。把测试集中的错误预测单独拿出来保存成图像并标注真实值和预测值这是答辩时很加分的素材。实现方式是在验证循环里记录错误样本wrong_samples [] model.eval() with torch.no_grad(): for images, labels in test_loader: outputs model(images) _, predicted torch.max(outputs, 1) wrong_mask predicted ! labels if wrong_mask.any(): wrong_samples.append((images[wrong_mask], labels[wrong_mask], predicted[wrong_mask]))torch.no_grad()在这个循环里是必需的它能避免推理过程中构建计算图省下一大块显存和耗时。收集到错误样本后用torchvision.utils.make_grid拼图保存就能快速观察到模型在哪些数字上容易混淆。MNIST 上最常见的混淆是 4 和 9、3 和 8 这一类形状接近的类别拿出来写进报告里比只贴 acc 数字更有说服力。5. ResNeXt 与 ShuffleNet 的精度、速度与参数量横向对比实验5.1 对比实验的基线控制拿两个网络做对比时最容易犯的错误是配置不公平。参数总量不一样、输入分辨率不一样、训练 epoch 数不一样导致最终结果没办法归因到网络结构本身。这个源码里的对比实验应该把以下变量固定住统一使用相同的数据预处理、相同的 batch_size、相同的 epoch 数、相同的优化器和学习率策略。只允许变化的变量是模型结构本身这才是干净的对照。实际跑的时候建议做三组配置对比ResNeXt 原版、ShuffleNet-v2 原版、以及把两者的宽度因子width multiplier调整到参数量接近后的版本。做第三组的原因是ResNeXt 的参数量天然比 ShuffleNet-v2 大很多如果直接比精度哪怕 ResNeXt 赢了也只是因为参数更多说服力不强。参数量拉到接近后ShuffleNet 这种轻量网络如果精度差距能控制在 1% 以内就说明了 depthwise channel shuffle 设计的有效性。5.2 实验记录表格与结果分析训练完成后记录以下核心字段直接用于报告模型参数量FLOPs (1×28×28)训练耗时测试准确率ResNeXt (card8)约 1.2M约 60M约 8 分钟99.2%ResNeXt (card32)约 3.8M约 190M约 15 分钟99.4%ShuffleNet-v2约 0.4M约 20M约 5 分钟98.9%这些数字只是示例具体以你自己跑出来的为准。关键是要观察两个规律第一cardinality 从 8 增到 32参数量涨了约 3 倍但精度只提升了约 0.2%这说明在 MNIST 上继续堆 cardinality 的边际收益已经很低花更大的训练成本换 0.1% 的精度不如去调数据增强或做模型集成。第二ShuffleNet-v2 参数量只有 ResNeXt 的十分之一但精度差距不到 0.5%在嵌入式场景下显然更实用。提示跑对比实验的 epoch 不要设太少MNIST 上一般 30 epoch 左右能稳定收敛。如果只跑 5 个 epoch 就拿来对比两个网络都还没收敛比较的只是训练速度而非模型能力。5.3 损失曲线的可视化与过拟合判断源码里如果有记录 loss 的逻辑用 matplotlib 画两条曲线即可一条是训练集 loss一条是验证集 loss。需要关注的不是训练集 loss 多低而是两条曲线的开口方向。如果验证集 loss 在某个 epoch 后开始反弹同时训练集 loss 还在下降那基本确定过拟合已经开始了。ResNeXt 因为参数量大过拟合出现得会比 ShuffleNet 早在图上会看到验证集曲线在第 15 到 20 epoch 之间开始抬升。遇到这种情况的修正手段并不复杂优先看 weight decay 是否够大然后看是否在数据增强里加入了随机旋转或随机平移。MNIST 本身是居中数字平移增强尤其有效因为手写数字的位置偏移本身就是训练集和测试集之间的分布差异之一。transforms.RandomAffine(degrees10, translate(0.1, 0.1))这行代码加在 Normalize 之前能让两个网络的过拟合都有明显缓解。6. 用 TensorBoard 监控群卷积网络训练时看哪几个指标6.1 按图像标量分布监控梯度流群卷积网络训练时只看 loss 曲线远远不够。打开 TensorBoard 的 scalars 页面除了 loss 和 acc建议专门盯三个指标的走势grad_norm梯度范数、weight_mean权重均值和bn_running_meanBN 层的滑动均值。BN 层的 running_mean 如果出现明显漂移比如在训练初期从 0 快速跳动到某个非零值说明数据分布和 BN 的初始化假设不匹配需要检查 Normalize 参数和数据加载顺序。添加 grad norm 计算的代码很简单在每个 batch 的loss.backward()之后加一行total_norm 0.0 for p in model.parameters(): if p.grad is not None: param_norm p.grad.data.norm(2) total_norm param_norm.item() ** 2 total_norm total_norm ** 0.5 writer.add_scalar(grad_norm, total_norm, global_stepglobal_step)这个指标能直接反映群卷积分组数设置是否合理。如果 grad_norm 在训练一开始就跌到接近 0说明梯度在深层网络里消失了问题很可能出在 ResNeXt 的残差连接或者 ShuffleNet 的 depthwise 卷积上。如果 grad_norm 振荡幅度过大学习率就要调低。6.2 按图像可视化每一层输出的特征图TensorBoard 的 images 页面适合看卷积层的输出热力图。把 MNIST 某一张输入图片在网络里的各层激活值抽出来观察群卷积是否真的学习到了不同的特征子空间。ResNeXt 的 3×3 群卷积层有 32 个分组把这一层的输出拆成 32 张小图拼在一起展示你会发现不同分组确实在关注数字的不同部位有的组偏向横线有的组偏向弧线。这是群卷积能够替代普通卷积的直观证据。抽取中间层输出的办法是注册 forward hook。以 ResNeXt 的第二个卷积层为例activation {} def get_activation(name): def hook(model, input, output): activation[name] output.detach() return hook model.conv2.register_forward_hook(get_activation(conv2))register_forward_hook的好处是不需要修改网络的前向代码hook 会在 forward 执行后自动被调用把输出保存到字典里。注意detach()必须调用否则保存的是带计算图的张量内存会持续增长。查看这个输出张量的形状如果正好是[batch, group_width, h, w]把它按group_width维度切片就可以得到 32 个分组的特征图。6.3 使用混淆矩阵定位模型能力边界TensorBoard 的 PR curves 和混淆矩阵对 MNIST 分类任务来说性价比最高的还是混淆矩阵。虽然 sklearn 的confusion_matrix可以直接算但从源码阅读角度手写一段统计逻辑能更好地嵌入训练循环不用额外引入依赖conf_matrix torch.zeros(10, 10, dtypetorch.int64) with torch.no_grad(): for images, labels in test_loader: outputs model(images) _, preds torch.max(outputs, 1) for t, p in zip(labels.view(-1), preds.view(-1)): conf_matrix[t.long(), p.long()] 1遍历每一对(真实标签, 预测标签)累加计数效率虽然比向量化慢但语义清晰报错时好排查。得到混淆矩阵后用seaborn.heatmap画图。重点看对角线以外哪些格子数值最大这些就是模型的能力边界。比如第 4 行第 9 列的格子值偏高说明大量真实的 4 被预测成了 9后续做针对性数据增强时可以对 4 这一类别做更多的旋转或弹性畸变而不是盲目增加所有类别的数据。torch.max(outputs, 1)返回的是每个样本最大概率的索引不需要torch.argmax。view(-1)的作用是把标签和预测展平成 1D方便zip配对。如果 labels 本身是 1D 的这行代码不会改变形状但加上它能让代码兼容 batch_size 为 1 时的维度塌缩问题。混淆矩阵行列索引都是 0 到 9正好对应 MNIST 的数字标签不需要额外映射。本文还有配套的精品资源点击获取