去年帮一个朋友看他复现的 CNN 模型网络能跑、loss 也在降就是准确率死活卡在 62% 上不去。我让他把输入尺寸打印出来——他搭的是 AlexNet代码里写的 227实际喂进去的图是 224。就这一个数字白跑了三天。这事儿之后我一直想写点东西聊聊这个 2012 年的老网络为什么到现在还值得一行一行地抠。AlexNet 是卷积神经网络真正意义上出圈的第一个模型8 个带参层、6000 万参数在当年的图像分类竞赛里把第二名甩开了十个百分点。放到今天看它的设计粗糙得有点可爱——卷积核大小拍脑袋定、LRN 后来被证明基本没用、双卡切分纯粹是因为单卡显存装不下。但正因为粗糙它把 CNN 的所有基本构件都摆在了明面上卷积、池化、激活、归一化、Dropout、全连接。你想搞清楚这些零件各自干什么活AlexNet 是最好的解剖标本。这篇东西写给两类人一类是刚学完卷积原理、想找个东西练手的学生另一类是做了几年工程、一直调框架 API、但没真正拆开 CNN 看过里面长什么样的开发者。我会把每一层的张量形状推一遍、代码敲一遍、踩过的坑列出来最后聊聊怎么把它当骨架改造成自己的模型。1. 今天还值得手撸一遍 AlexNet 的三个理由1.1 它是一台能完全拆开的发动机现在的模型动辄几十亿参数两行代码就能调用但大多数人说不清楚张量在里面到底怎么流动。AlexNet 只有 8 个带参层——5 个卷积层加 3 个全连接层参数量和层数都控制在一个下午能手工算完的范围内。你可以拿张纸把 227x227x3 这个输入一路推到 1000 维输出中间每一步的形状变化都写下来。这个过程做一遍比看十篇讲解文章都管用。我自己的体会是凡是能凭记忆把 AlexNet 的形状流转推一遍的人后面去看 VGG、ResNet 基本不会卡壳因为那些网络只是在 AlexNet 的基础上做加法加深、加残差、换卷积核尺寸。底层那套卷积改通道数、池化改空间尺寸的逻辑一脉相承。反过来如果 AlexNet 的 13→6 这一步你都说不清楚是怎么来的那 ResNet 里那些 stride 和 downsample 分支你只会背不会用。1.2 它暴露了所有工程取舍的原始动机论文里有一堆今天看来很奇怪的设定用 11x11 的大卷积核、第一层 stride 设成 4、搞了个 Local Response Normalization、还把网络劈成两半放在两张显卡上训练。这些不是设计美学是当年硬件和数据的现实逼出来的。拿双卡切分举例。当时的显卡单卡显存只有 3GB装不下整个模型和中间的特征图作者就把每个卷积层的通道数一分为二第一层放 48 个卷积核在卡 0、48 个在卡 1只在特定层做跨卡通信。今天你用单卡跑这份设计就成了纯粹的负担代码里还得手动合并。理解这个来龙去脉你以后遇到为什么这个开源实现和论文写得不一样的时候就不会一脸懵。同样道理11x11 的大卷积核在当年是有意义的——输入分辨率高、算力紧张用大步长的大核能快速把空间尺寸压下来减少后续计算量。今天回头看这个大核的参数效率很低34944 个参数只换来一层特征VGG 用三个 3x3 堆叠达到同样感受野只需要 333*C² 的参数这才是更优解。但没有 AlexNet 先趟过这条路就不会有后面的反思。1.3 小数据集上的迁移价值依然在这一点很多人忽略。AlexNet 在百万级图像上训出来的特征提取能力对于中小规模的图像任务仍然够用。我在一个几千张图的工业缺陷分类任务上试过冻结前面几个卷积层、只训最后两层全连接准确率比从零训一个小 CNN 高了将近 12 个百分点训练时间从几小时压到二十几分钟。原因是浅层卷积学到的是边缘、纹理、颜色斑块这类通用特征跟具体任务关系不大换个领域照样能用。越往深层走特征越抽象、越贴近原始任务的语义迁移价值越低。这个规律在大模型时代依然成立只是把层换成了模块。所以这篇不是考古。是把一个老模型当成一件还能用的工具重新盘一遍它的构造、用法和脾气。2. AlexNet 八个带参层形状推演与每层在干什么2.1 输入到底是 227 还是 224先说这个最容易把人坑住的地方。论文里写的是输入 224x224x3但如果你按论文给的卷积核和 stride 去推会发现推不通。Conv1 是 11x11 卷积核、stride 4、无 padding那 224 输入算出来的输出是 floor((224-11)/4)1 54而论文表格里写的是 55。差了一个。真正的原因是第一层实际上做了 padding2。有了 padding输入等效边长变成 2242*2 228floor((228-11)/4)1 55对上了。但更常见的实现是直接把输入设成 227这样 padding0 也能得到 55(227-11)/41 55。两种做法数学上等价但社区里流传的 227 版本更多因为当年那个经典实现就是这么干的。这个细节看着是吹毛求疵但它决定了你后面所有层的形状。我第一次复现的时候没注意输入用的 224跑到池化层之后张量尺寸和权重矩阵对不上报了个很隐晦的矩阵乘法错误查了半天才定位到第一层。所以我现在的习惯是搭完网络先不写训练代码先跑一遍形状验证。2.2 卷积层逐层拆解先记住一个通用公式后面所有计算都用它输出边长 floor((输入边长 2 × padding − 卷积核边长) / stride) 1按 227 输入、第一层 padding0 的版本逐层推下来是这样层类型配置输入形状输出形状参数量Conv1卷积96 个 11x11, stride 4227x227x355x55x9634944Pool1最大池化3x3, stride 255x55x9627x27x960Conv2卷积256 个 5x5, pad 227x27x9627x27x256614656Pool2最大池化3x3, stride 227x27x25613x13x2560Conv3卷积384 个 3x3, pad 113x13x25613x13x384885120Conv4卷积384 个 3x3, pad 113x13x38413x13x3841327488Conv5卷积256 个 3x3, pad 113x13x38413x13x256884992Pool3最大池化3x3, stride 213x13x2566x6x2560FC6全连接40966×6×2569216409637752832FC7全连接40964096409616781312FC8全连接1000409610004097000从这张表能看出一个很反直觉的事整个网络 6000 万参数里超过 90% 挤在三个全连接层里五个卷积层加起来才 300 多万。FC6 单独一层就 3775 万参数比所有卷积层总和的十倍还多。这也是为什么后来 VGG、ResNet 都在往全卷积方向走——全连接层是参数黑洞还特别容易过拟合。Conv3 到 Conv5 是连续三个 3x3 卷积中间没有池化。这段设计的意图是在空间分辨率已经压得比较低的特征图上用多个小卷积做非线性变换把语义信息往上提一层同时不损失空间信息。Conv3 的输入通道 256、输出 384 是扩张Conv4 保持 384 不变Conv5 又压回 256。这个扩—平—收的节奏是在参数量和特征表达力之间做平衡。Pool1 和 Pool2 的池化核是 3x3、stride 2这意味着相邻的池化窗口有重叠3 2论文里管这叫 overlapping pooling说能稍微降低错误率。Pool3 也是 3x3 但输入只有 13所以窗口不重叠了。这个细节在复现时容易漏如果全用 stride 2 的 2x2 池化形状会算不对。2.3 那些附加件的真实作用ReLU 是 AlexNet 最关键的贡献之一。在这之前主流用 tanh 或 sigmoid深层网络里梯度衰减很严重。ReLU 在正半轴导数恒为 1梯度不会越传越小训练速度能快好几倍。论文里给了个具体数字在同样错误率下ReLU 比 tanh 快 6 倍。这个结论在今天的实践里依然成立任何新模型的第一版几乎都还是 ReLU 打底。Dropout 只在 FC6 和 FC7 后面用比率 0.5。全连接层参数太多不加 Dropout 几乎必然过拟合。注意 Dropout 只在训练时生效推理时要关掉。用框架的话一行model.train()/model.eval()就搞定了自己手写就容易错——尤其是训练时缩放和推理时缩放这两种实现方式做反了会导致训练和推理的输出分布不一致。LRN 这块我得说实话后来几乎所有的复现都证明它没什么用。它做的是局部响应归一化模仿生物神经元的侧抑制公式是在通道维度上对相邻几个通道做归一化。我用同一个数据集分别开和不开 LRN 跑过最终准确率差异在小数点后一位训练时间反而多了 5%。现在的主流实现基本都把它省了或者留个开关默认关掉。学的时候要知道它是什么、为什么当年要试用的时候可以直接扔掉。3. 用 PyTorch 把 AlexNet 搭起来几处必须做的取舍3.1 逐层定义与形状验证代码直接上代码用nn.Sequential组合这样前向传播的过程最清晰也方便后面按索引冻结层import torch import torch.nn as nn class AlexNet(nn.Module): def __init__(self, num_classes1000): super().__init__() self.features nn.Sequential( nn.Conv2d(3, 96, kernel_size11, stride4, padding0), # 0 nn.ReLU(inplaceTrue), # 1 nn.MaxPool2d(kernel_size3, stride2), # 2 nn.Conv2d(96, 256, kernel_size5, padding2), # 3 nn.ReLU(inplaceTrue), # 4 nn.MaxPool2d(kernel_size3, stride2), # 5 nn.Conv2d(256, 384, kernel_size3, padding1), # 6 nn.ReLU(inplaceTrue), # 7 nn.Conv2d(384, 384, kernel_size3, padding1), # 8 nn.ReLU(inplaceTrue), # 9 nn.Conv2d(384, 256, kernel_size3, padding1), # 10 nn.ReLU(inplaceTrue), # 11 nn.MaxPool2d(kernel_size3, stride2), # 12 ) self.classifier nn.Sequential( nn.Dropout(0.5), nn.Linear(256 * 6 * 6, 4096), nn.ReLU(inplaceTrue), nn.Dropout(0.5), nn.Linear(4096, 4096), nn.ReLU(inplaceTrue), nn.Linear(4096, num_classes), ) def forward(self, x): x self.features(x) x torch.flatten(x, 1) return self.classifier(x)搭完之后第一件事不是训练是验证形状。这个习惯是我第一次踩坑之后逼出来的model AlexNet(num_classes10) dummy torch.randn(2, 3, 227, 227) print(model(dummy).shape) # torch.Size([2, 10]) with torch.no_grad(): x dummy for i, layer in enumerate(model.features): x layer(x) print(f{i:2d} {layer.__class__.__name__:12s} - {tuple(x.shape)})跑出来应该是 227 → 55 → 27 → 27 → 13 → 13 → 13 → 6最后展平是 6×6×256 9216。如果你的输出在某一层和这个序列对不上八成是输入尺寸搞错了或者 padding 少填了一个。3.2 inplaceTrue 该不该开代码里 ReLU 都带了inplaceTrue这个参数的意思是直接在原张量上改不新建内存。AlexNet 参数量大、中间特征图也占地方显存吃紧时开它能省下一截。但有两个前提你得清楚。一是不能对后续还要用的张量做 inplace 操作。nn.ReLU(inplaceTrue)(x)执行完之后 x 本身已经变了如果你后面还要拿原始 x 干别的事那就错了。二是某些网络结构里 inplace 会破坏反向传播需要的中间值报那种梯度计算所需的变量被 inplace 操作修改的错误第一次遇到会非常懵因为错误信息指向的是很远的地方。我的建议卷积后面紧跟的 ReLU 放心开因为那个特征图用完就扔但如果网络里有残差连接、或者 ReLU 输出还要送进别的分支就别开。这是个典型的看结构决定的参数没有统一答案。3.3 权重初始化论文的做法不一定适合你PyTorch 默认的初始化是 Kaiming 均匀分布对 AlexNet 这种深度基本够用。但如果你想严格贴近论文可以手工初始化一遍def init_weights(m): if isinstance(m, (nn.Conv2d, nn.Linear)): nn.init.normal_(m.weight, mean0.0, std0.01) nn.init.constant_(m.bias, 0.0) model.apply(init_weights)论文里用的是均值 0、标准差 0.01 的正态分布偏置初始化成 1为了让 ReLU 在训练早期有梯度可传。不过实测下来标准差 0.01 对于 ReLU 网络来说偏小容易让浅层输出太小训练前期几乎学不动。我一般会改成 Kaiming 正态分布nn.init.kaiming_normal_(m.weight, modefan_out, nonlinearityrelu)收敛速度明显更稳尤其是层数加深或者换了输入尺寸的时候。这是典型的论文做到了、但你不一定该照抄的例子——论文当年的初始化是配合它自己的数据和训练技巧的环境变了照搬反而是坑。4. 学习率、增强与显存训练配置里的三笔实账4.1 学习率与衰减策略的换算逻辑论文用的是 SGD初始学习率 0.01动量 0.9权重衰减 0.0005batch size 128学习率在验证集错误率不再下降时手动除以 10一共降了 3 次。这套配置在百万级训练集上是合理的。但你拿自己的小数据集去跑batch 128 很可能一个 epoch 只有几步学习率 0.01 会直接炸掉。经验法则是按 batch size 线性缩放如果用 batch 32学习率就设成 0.01 × 32/128 0.0025。另一个问题是手动降学习率在今天不现实你不可能一直盯着。用余弦退火或者阶梯衰减都行optimizer torch.optim.SGD(model.parameters(), lr0.0025, momentum0.9, weight_decay5e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_maxepochs)动量 0.9 的作用值得多说一句。SGD 每一步只看当前 batch 的梯度方向噪声很大在损失曲面的峡谷地形里容易左右震荡来回横跳却不下坡。动量相当于给梯度加了个低通滤波让更新方向更平滑。0.9 的含义是最近 10 步梯度按指数衰减加权平均这是个很稳的默认值除非你在做很激进的实验一般不用动。权重衰减 0.0005 对应的是 L2 正则作用是抑制参数长得太大。这个值配合 Adam 用的时候要重新调因为 Adam 本身有自适应缩放同样的 weight_decay 效果会强很多常见的做法是分类讨论或者直接用 AdamW。4.2 数据增强的原始配方与今天的替代方案论文里用了两种增强其中一种很巧妙。第一种是随机裁剪加水平翻转。训练时从 256x256 的图里随机裁 224x224测试时裁 5 块四角加中心分别预测再平均。这个多裁剪测试今天还在用原理是 TTA测试时增强。第二种是 PCA 颜色抖动。做法是对整个训练集所有像素的 RGB 值做 PCA得到 3 个主成分和对应的特征值每次训练时给每个像素加一个随机扰动扰动量等于alpha × 特征值 × 主成分alpha 服从均值 0、方差 0.1 的正态分布。这个操作的精妙之处在于它改变的是亮度和颜色但保留了图像的结构信息让模型对光照变化更鲁棒而且不像直接调亮度那样容易把暗部细节压没。今天大多数人用 ColorJitter 或 HSV 空间随机抖动代替效果差不多而且简单得多from torchvision import transforms train_tf transforms.Compose([ transforms.RandomResizedCrop(227, scale(0.8, 1.0)), transforms.RandomHorizontalFlip(), transforms.ColorJitter(brightness0.4, contrast0.4, saturation0.4), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ])最后那个 Normalize 的均值方差是通用图像数据集的统计值几乎成了行业默认。如果你训练自己的数据集最好重新算一遍实际的均值和方差。我试过一个偏暗的工业图像数据集用默认值和不用的差异有 2 个百分点不算小。提醒一句RandomResizedCrop的 scale 下限不要设太低。0.8 已经能带来足够的尺度变化设到 0.3 会把目标裁得只剩一小块在小数据集上反而是噪声。4.3 batch size 和显存的真实账假设你在 8G 显存的单卡上跑原始 AlexNet1000 类batch 能开多大粗略算一下。模型参数 6000 万float32 下约占 240MB。训练时每个参数还要存梯度SGD 没有额外优化器状态梯度再占 240MB加上一些中间缓冲模型本身约 500MB。剩下的全给激活值。激活值的大头在 Conv1 和 Conv2。Conv1 输出 55×55×96单张图约 1.16MB反向传播要保存Conv2 是 27×27×256 约 0.75MB。粗算下来单张图的激活值在 5 到 8MB 量级8G 显存扣掉模型和系统占用batch 128 大致够用但很紧可能还得开混合精度。更稳妥的做法是 batch 64 起步跑通了再往上加。而且现在很多人把 FC6 和 FC7 从 4096 缩到 1024 甚至 512参数量能砍掉七成多显存压力骤降对小数据集反而是好事——参数少了过拟合的倾向也轻了。5. 排查链路实录三个真实遇到过的报错5.1 形状不匹配从矩阵乘法错误反查第一层最早的一次报错长这样RuntimeError: mat1 and mat2 shapes cannot be multiplied (128x6400 and 9216x4096)看到 6400 和 9216 对不上就有方向了。9216 6×6×256 是期望的展平维度6400 5×5×256 说明最后输出的特征图是 5×5 而不是 6×6。往回推池化输出 6 是因为输入是 1313 变成 5 说明空间尺寸在某一层多减了。我一开始以为是输入尺寸用了 224 导致的但仔细一算224 走完整条链也还是能得到 6所以不是输入的问题。最后逐层打印 shape 才定位到是我自己在第二个池化层手滑加了padding113 变 5。这个错误的教训不在于那个 padding而在于池化层默认不 padding除非你明确知道自己在干什么。很多人是从卷积层复制粘贴过来改的padding 参数忘了清掉。顺带说个通用的排查手法把每层的输入输出形状做断言比肉眼检查靠谱得多EXPECTED [227, 55, 27, 27, 13, 13, 13, 6] def forward_with_check(self, x): for i, layer in enumerate(self.features): x layer(x) assert x.shape[-1] EXPECTED[i 1], \ flayer {i}({layer.__class__.__name__}) out{x.shape[-1]}, expect{EXPECTED[i1]} return x5.2 loss 不降一个被遗忘的归一化第二次踩坑是关于数据归一化的。模型能跑loss 也动但降到 2.3 左右就不动了准确率永远停在 10%——10 分类任务等于瞎猜。我先怀疑学习率从 0.01 一路试到 0.0001没用。再怀疑初始化换了几种没用。最后打印了一个 batch 的输入统计量images, labels next(iter(train_loader)) print(images.min(), images.max(), images.mean(), images.std()) # 输出: 0.0 255.0 118.3 62.1问题一眼就清楚了ToTensor 之后没做 Normalize输入值还在 0–255 范围。这么大的输入经过第一层卷积输出会爆到很大ReLU 之后大量神经元进入饱和区梯度几乎传不回来——看起来网络还在跑实际上大部分参数都冻住了。加上 Normalize 之后同样的配置loss 当天就降到了 0.8。这件事让我养成了一个习惯每次开训前先打印一个 batch 的统计数据固化成检查函数def sanity_check(loader): images, labels next(iter(loader)) assert images.dtype torch.float32, f输入类型不对: {images.dtype} assert images.min() -3.0 and images.max() 3.0, 输入未归一化 print(fbatch{tuple(images.shape)} fmean{images.mean():.3f} std{images.std():.3f} flabel分布{torch.bincount(labels).tolist()})这个函数拦住过我三四次错误包括一次通道顺序搞反BGR 当成 RGB 喂进去那次 loss 也降得很慢但不像未归一化那么明显全靠这个检查发现的。5.3 验证集准确率剧烈震荡第三次是验证准确率在 40% 到 60% 之间乱跳同一个 epoch 跑两次结果都不一样。排查方向按顺序走先关掉 Dropout确认 eval 模式生效还是震荡再固定随机种子还是震荡再检查学习率调度器没问题。最后把num_workers从 8 改成 0震荡消失了。原因定位到数据集里有几张损坏的图片解码时会抛异常在多 worker 模式下这个异常被静默吞掉了导致某个 batch 里少了几条数据样本分布跟着变化。日志里什么都没打所以才那么难查。修法很土但有效先把num_workers0跑一遍确认数据本身没问题再开多 worker同时写个脚本把所有图片预先解码一遍做清洗。这个坑的普适教训是任何偶发的问题先缩小随机源再定位。多进程是个巨大的随机源它能掩盖掉很多真实存在的问题。我把这几类问题的排查优先级整理成一张表出问题时按顺序看比漫无目的地试快得多现象第一怀疑对象验证方法直接报形状错输入尺寸 / padding逐层打印 shapeloss 完全不动数据未归一化 / lr 太小打印 batch 统计量loss 卡在某个值lr 过大 / 标签错位取 10 张图做单批过拟合验证集乱跳数据问题 / batch 太小num_workers0 复跑训练集好验证集差过拟合加 Dropout / 增强loss 变 NaNlr 太大 / 出现异常输入开梯度裁剪 检查输入6. 把 AlexNet 改成自己的三个动手方向6.1 换输入尺寸和分类头最常见的改造是把 1000 类换成自己的类别数顺带适配自己的图像分辨率。改分类头时全连接层的输入维度要跟着最后的特征图尺寸重算这一步手算容易错写个小函数自动推def get_flatten_dim(model, in_size): model.eval() with torch.no_grad(): x model.features(torch.zeros(1, 3, in_size, in_size)) return x.numel() dim get_flatten_dim(model, 128) # 128x128 输入 model.classifier[1] nn.Linear(dim, 4096)这里有个坑直接替换classifier[1]之后新层的参数是随机初始化的之前的预训练权重就丢了。正确做法是先记录旧维度只有在维度确实变了的情况下才替换没变就保留。如果你的输入分辨率比 227 小很多比如 64x64走完五次下采样后特征图会变成 1×1 甚至不够池化这时候得砍掉后面的池化层或者减小前两层的 stride。一个实用的判断标准是每层池化前空间尺寸至少要有池化核大小否则窗口都开不出来。6.2 冻结浅层做迁移前面提过浅层卷积学的是通用边缘纹理特征。冻结策略上我的经验是分领域看如果数据集和目标领域差异大医学图像、工业缺陷、遥感只冻结前两层就够了后面该训还得训因为中层的纹理模式已经开始领域特化了如果领域接近自然图像、日常物品可以冻到 Conv4。# 冻结前 8 个模块到 Conv3 结束 for p in model.features[:8].parameters(): p.requires_grad False # 优化器里只传需要梯度的参数省显存也快一点 optimizer torch.optim.SGD( filter(lambda p: p.requires_grad, model.parameters()), lr0.01, momentum0.9)要注意的是冻结只影响参数更新不影响前向计算所以显存占用基本不变。而且 AlexNet 本身没有 BatchNorm不用担心冻结了参数但统计量还在更新这个坑——换成带 BN 的骨架就得小心了通常要把冻结部分的 BN 整体切到 eval 模式。6.3 小数据集上的过拟合压制AlexNet 在几千张图上几乎必然过拟合。除了 Dropout我常用两招。第一招是把 FC6、FC7 从 4096 砍到 512 或 1024。参数量从 5800 万降到 300 万左右训练快十倍准确率往往还更高。这个反直觉的现象原因很简单小数据集撑不起那么大的假设空间参数越多模型越倾向于记住样本而不是学规律。第二招是标签平滑在交叉熵里把 one-hot 目标改成软标签抑制模型过度自信criterion nn.CrossEntropyLoss(label_smoothing0.1)这俩一起用我在一个 3000 张图的任务上把验证准确率从 71% 提到了 79%训练曲线也平滑多了。另外如果数据量真的很小可以考虑先在整个数据集上做一次 K 折用交叉验证的均值来判断改动是否真的有效——单次划分的波动有时候比你的改动幅度还大。最后分享一个我最近才养成的小习惯每次改完超参或结构先跑 200 步看 loss 曲线的前 50 步。如果前 50 步的形状不对比如先降后猛涨、或者一直在 2.3 附近持平后面跑多久都没用。这个检查花不到两分钟能省下大量无效训练时间。