一、这个项目在做什么
一句话概括:教会计算机看图识物。
具体来说,我们拿到了一个叫 CIFAR-100 的图片数据集------6 万张 32×32 像素的小图片,涵盖苹果、汽车、老虎、向日葵等 100 种类别。我们的任务是训练神经网络,让它看到一张图后能判断"这是什么"。
听起来简单,但 32×32 像素非常小(大约指甲盖大小的缩略图),而且 100 个类别里有很多很相近的东西(比如不同品种的花、不同类型的车),所以分类难度其实不低。
为了搞清楚"什么样的网络 + 什么样的训练方式效果最好",我们设计了一组系统的对比实验:两种网络结构(MLP 和 CNN),两种优化器(SGD 和 Adam),三个学习率(0.1、0.01、0.001),交叉组合一共 12 组实验,一键运行,自动对比。
项目见:
https://github.com/david-gao1/883AI/tree/main/研究生作业/深度学习
二、两种网络:MLP 和 CNN
MLP------最朴素的做法
MLP(多层感知机)是最基础的神经网络。它处理图片的方式非常粗暴:把一张 32×32×3 的彩色图片直接"拍平"成一个长度为 3072 的数字串,然后送进两层全连接网络,最后输出 100 个分数,分数最高的那个类别就是答案。
MLP 的问题在于,它把图片拍平的一瞬间,所有空间位置信息就丢了------左上角的像素和右下角的像素在向量里变成了地位平等的数字,网络不知道它们谁和谁是邻居。一只猫的眼睛和耳朵明明挨着,拍平后网络完全感知不到这个关系。
但 MLP 并不是没有价值。它是一个重要的基线------先用最朴素的方式跑一遍,再用更高级的方式跑一遍,两相对比才能清楚地看出改进有多大。
CNN------像人眼一样看图
CNN(卷积神经网络)的设计哲学直接模仿了人眼的工作方式。你看一张猫的照片,大脑不会一个像素一个像素地扫描,而是先看局部------这里有尖尖的轮廓(耳朵)、这里有圆形高光(眼睛)、这里有柔和纹理(毛发),然后把这些局部特征组合起来得出"这是一只猫"的结论。
CNN 做的事情一模一样:
-
用一个 3×3 的小窗口(卷积核)在图片上滑动,每滑一步就计算出窗口覆盖区域的一个"特征值"。不同的卷积核能检测不同的模式------有的专门找水平边缘,有的专门找垂直边缘,有的对颜色变化敏感。这些卷积核不是人工设计的,而是网络在训练过程中自己学出来的。
-
卷积扫描完一遍后,用池化操作把特征图缩小------每 2×2 的区域取最大值,尺寸减半。这样做既减少了计算量,又让网络的"视野"变大了:同样的 3×3 卷积核,在缩小后的特征图上实际覆盖的原图区域更大。
-
重复几轮"卷积 + 池化",特征从低级(边缘、色块)逐渐变成高级(纹理、部件、物体轮廓)。一张 32×32 的图片经过三轮处理后变成了 4×4,空间尺寸缩了 8 倍,但通道数从 3 涨到了 256------图变小了,每个位置蕴含的"语义信息"却更丰富了。
-
最后把这些高级特征拍平,送进全连接层做最终分类。
本项目的 CNN 采用了经典的 VGG 风格设计:三个阶段,每个阶段包含两层 3×3 卷积(通道数依次为 64、128、256),中间穿插批归一化(稳定训练)、ReLU 激活(引入非线性)、最大池化(缩小尺寸)和 Dropout(防过拟合)。麻雀虽小,五脏俱全------理解了这个结构,再去看 ResNet、EfficientNet 这些更复杂的网络,就只是在这些基础组件上做加法。
三、训练一个神经网络意味着什么
神经网络刚创建时,所有参数(卷积核里的数字、全连接层的权重)都是随机的,它对图片的分类完全靠蒙。训练的过程就是不断地"看题 → 答题 → 对答案 → 改参数",循环往复直到它学会为止。
每一轮(epoch)的训练流程是这样的:
第一步:前向传播。 把一批 128 张图片送入网络,网络层层计算,最后输出每张图对 100 个类别的得分。
第二步:算损失。 用交叉熵损失函数衡量"预测和真实答案差多远"。如果网络给正确类别打了最高分,损失就小;如果正确类别的分数被其他类别压过去了,损失就大。
第三步:反向传播。 从损失出发,沿着网络的计算链路从后往前追溯,算出每个参数对这次错误"贡献"了多少------这就是梯度。梯度告诉你:如果你把这个参数往某个方向调一点点,损失会变小还是变大。
第四步:更新参数。 优化器根据梯度调整参数:往损失变小的方向走一步。步子多大由学习率决定。
把训练集里的 45000 张图全部过一遍,就算完成了一个 epoch。本项目默认训练 10 个 epoch。
训练过程中还有一个重要的细节:每个 epoch 结束后,我们会在验证集(5000 张图)上评估模型的表现,记录验证准确率最高的那一轮模型参数。为什么不直接用最后一轮的参数?因为最后一轮不一定最好------可能第 7 轮时验证准确率最高,之后模型反而开始"死记硬背"训练数据了(过拟合)。保留验证集上最好的那一轮,就是一种简单有效的防过拟合策略。
四、优化器和学习率------训练效果的决定性因素
SGD 和 Adam 的区别
优化器决定了"怎么根据梯度调参数"。可以用一个下山的比喻来理解:你站在山上,想尽快走到最低点(损失最小的地方)。
SGD(随机梯度下降 + 动量) 的做法是:每一步沿着当前最陡的下坡方向走一步,步长就是学习率。加上动量后,它还会"记住"之前的行进方向------就像一个滚下山的球有惯性,不容易被小坑小洼拦住。SGD 的所有参数共用一个学习率,所以对学习率的选择很敏感:太大会震荡飞出去,太小会爬得很慢。但如果调好了,SGD 训出来的模型泛化性能往往不错。
Adam(自适应矩估计) 则更聪明:它给每个参数单独维护一份"档案",记录这个参数最近的梯度大小和波动程度。梯度一直很稳定的参数,它就大胆地迈大步;梯度忽大忽小的参数,它就谨慎地迈小步。这种"自适应学习率"的特性让 Adam 几乎不需要精心调参,开箱即用,收敛通常也更快。代价是每个参数多了两倍的存储开销。
学习率的影响
学习率控制的是每步走多远。本项目试了三个值:
- 0.1(太大):相当于下山时跳着走,很可能一脚踩过头跳到对面山坡上。实验中 Adam + 0.1 的组合直接完全发散,准确率降到了 1%------等于在 100 个类别里瞎猜。
- 0.001(太小):相当于一毫米一毫米地挪,10 个 epoch 还没走到好的位置。SGD + 0.001 的收敛速度明显慢于 SGD + 0.01。
- 0.01(适中):正常步伐走路,稳定下山。实验中 SGD + 0.01 是 CNN 的最佳组合,Adam 的最佳学习率则更小一些(0.001),这恰好印证了 Adam 的自适应步长本身就偏大,需要更小的初始学习率来配合。
实验结果充分说明了一个道理:学习率是最敏感的超参数。一个不合适的学习率可以直接让训练崩掉,而优化器的选择反倒是次要的。
五、防止过拟合的三道防线
神经网络有很强的"记忆力",如果不加约束,它很容易把训练数据的细枝末节都背下来(包括噪声和异常),到了新数据上就抓瞎------这就是过拟合。本项目用了三种手段来对抗它:
第一道:数据增强。 训练时,每次读取一张图片都随机做两件事:四周补 4 像素后随机裁剪回 32×32(相当于随机平移),以及 50% 概率水平翻转。这让模型每次"看到"同一张图的不同变体,等价于人为增加了数据量,减轻了死记硬背的可能。
第二道:Dropout。 训练时随机关掉 30% 的神经元(CNN 里是关掉整个通道),迫使网络不能依赖任何一个特定的神经元来做决策。每一步训练的其实是一个"残缺版"的网络,最终效果相当于集成了指数级多个小网络,泛化能力天然更强。测试时所有神经元都打开,PyTorch 会自动处理这个切换。
第三道:权重衰减。 两个优化器都加了 weight_decay=1e-4,每次更新参数时都把权重往零的方向拉一点点。这防止参数长得过大,让网络保持"简洁"。
此外,批归一化(BatchNorm)本身也有轻微的正则化效果------因为每个 batch 的统计量有随机性,等于给训练加了一点噪声。
六、实验结果:CNN 对 MLP 的碾压
12 组实验全部跑完后,结果非常清晰:
CNN 全面优于 MLP。 在最佳配置下(SGD + lr=0.01),CNN 测试准确率 38.69%,MLP 只有 20.45%------将近一倍的差距。在所有对应配置下,CNN 都比 MLP 好,没有例外。这个结果并不意外:CNN 保留了图片的空间结构,能看到"哪些像素是邻居",而 MLP 一拍平就把这些信息全丢了。
SGD 比 Adam 更稳。 在较宽的学习率范围内(0.001~0.01),SGD 都能有效训练,而 Adam 只在 lr=0.001 时可用,lr 稍大就崩掉。SGD 是图像分类里的经典选择,配合动量和合理的学习率就能出好结果。
10 个 epoch 远远不够。 最好的模型(CNN + SGD + lr=0.01)到第 10 轮时验证准确率还在持续上升,loss 还在持续下降,完全没有收敛。如果训练 50~100 个 epoch,配合学习率衰减策略,准确率预计还能大幅提升。
学习率 0.1 对 Adam 是灾难。 Adam 自适应步长本身就偏大,再给一个大学习率,参数更新就完全失控了。CNN + Adam + lr=0.1 和 MLP + Adam + lr=0.1 的测试准确率都掉到了 1%,和瞎猜一样。
从训练曲线上也能清楚地看到这些规律:好的配置下 loss 稳定下降、accuracy 持续上升,差的配置下 loss 高位震荡、accuracy 趴在底部不动。
七、工程层面:怎么把实验跑起来
好的实验不只是算法好,还需要工程设计能让实验"跑得顺、管得住、扩展得了"。
文件组织
整个项目分成几个清晰的模块:
src/models.py只管"网络长什么样"------定义 MLP 和 CNN 两个类,以及一个根据名字创建模型的工厂函数。将来要加新模型(比如 ResNet),只在这个文件里加一个类和一行注册代码就行。src/trainer.py管"怎么训练"------数据加载、优化器创建、训练循环、画图、保存结果,全在这里。train.py是总指挥------它自己不实现任何逻辑,只负责解析命令行参数、生成实验组合、依次调用上述函数。summarize_results.py是报告员------训练完成后读取所有实验的 JSON 结果,生成一份 Markdown 汇总报告。
这种"调度层 / 逻辑层 / 模型层"分开的设计,让任何一处改动都只影响一个文件,不会牵连其他部分。
一键跑完 12 组实验
所有超参数都通过命令行控制。默认配置就是 2 模型 × 2 优化器 × 3 学习率 = 12 组,一行命令跑完:
bash
uv run train.py --epochs 10
如果只想快速验证代码能不能跑通,可以用 5% 的数据跑 1 轮:
bash
uv run train.py --epochs 1 --subset-ratio 0.05
每组实验的结果独立存放在各自的目录下(模型参数、训练曲线、预测可视化、指标 JSON),最后汇总成一张 CSV 排名表。
可复现性
固定随机种子(42)保证了同样的代码 + 同样的参数永远得到同样的结果。设备自动选择(Mac GPU → NVIDIA GPU → CPU)让代码在任何机器上都能跑。依赖用 pyproject.toml + uv.lock 管理,uv sync 一行命令安装好所有库。
八、核心概念速查
| 概念 | 一句话解释 |
|---|---|
| 卷积 | 用小窗口在图片上滑动,提取局部特征 |
| 池化 | 每个小区域取最大值,缩小图片保留关键信息 |
| ReLU | 负数变 0,给网络引入非线性------没有它再多层也等于一层 |
| BatchNorm | 校准每层输出的分布,让训练更稳定更快 |
| Dropout | 训练时随机关掉一部分神经元,防止网络死记硬背 |
| 全连接层 | 每个输入都和每个输出相连,用于最终分类 |
| 交叉熵损失 | 衡量"你给正确类别打了多高的分",分数越高损失越小 |
| 前向传播 | 数据从输入到输出依次经过各层的计算 |
| 反向传播 | 从损失出发,逆序算出每个参数的梯度 |
| 梯度 | 告诉你"参数往哪个方向调能让损失变小" |
| 学习率 | 每次调参走多大一步 |
| epoch | 完整遍历训练集一次 |
| batch | 一次送入网络的一小批图片(128 张) |
| 过拟合 | 训练集表现好、新数据表现差------"背答案"了 |
| 数据增强 | 对训练图片做随机变换,增加多样性 |
九、总结与展望
这个项目虽然只用了最基础的 MLP 和 CNN,但完整覆盖了深度学习图像分类的核心链路:数据加载与预处理 → 模型设计 → 损失函数选择 → 优化器配置 → 训练循环 → 结果评估与可视化。12 组对比实验清晰地展示了三个关键结论:CNN 比 MLP 强在哪里(空间特征提取)、学习率为什么是最敏感的超参数(太大发散太小龟速)、SGD 和 Adam 各自的脾气(SGD 稳但需调参,Adam 方便但对大学习率敏感)。
从工程角度看,项目采用了模块化设计、命令行参数控制、笛卡尔积实验遍历、独立目录隔离结果、自动生成报告等实践,使得整个实验流程可复现、可扩展、易维护。
如果继续改进,有几个明确的方向:增加训练轮数配合学习率衰减(余弦退火或阶梯衰减)、引入更深的网络结构(ResNet 的残差连接可以让训练几百层成为可能)、加强数据增强策略(Mixup、CutMix)、使用学习率预热(Warmup)缓解初始阶段的不稳定。但即使是当前这个版本,它已经把深度学习图像分类的核心知识和工程实践都串起来了------理解了这些,再往更深更广的方向走,都会顺畅很多。