1. 为什么80行代码值得单独写一篇ResNet18这个网络结构在图像分类领域算是老熟人了。2015年那篇Deep Residual Learning的论文出来之后残差连接几乎成了深层网络的标配设计。但有意思的是很多人第一次接触ResNet看的都是PyTorch版本的实现torchvision里直接models.resnet18(pretrainedTrue)一行就调出来了方便是方便可真正让你手写一遍的时候未必能说清楚里面的shortcut到底加在哪、下采样是怎么处理的、BasicBlock和Bottleneck的区别在哪。我这次用TensorFlow从头搭一个ResNet18核心代码控制在80行左右。为什么强调80行因为网上不少TF版本的ResNet实现动辄两三百行里面塞了大量重复的层定义和冗余的变量管理读起来反而抓不住重点。80行的目标是把结构讲清楚每一行都有存在的理由不写废话。这篇文章适合两类人一类是刚学完CNN基础、想找一个结构清晰的中等深度网络练手的另一类是用惯了高级API、想回头看看底层怎么搭的。我会把每个设计决策背后的原因讲透包括为什么用Conv2D的strides而不是单独的池化来做下采样、BN层放在激活函数前面还是后面、shortcut遇到通道数变化时怎么处理。这些细节在调包的时候完全感知不到但自己写的时候一个都绕不过去。先说一下整体思路。ResNet18由1个初始卷积层、4个stage每个stage由2个BasicBlock组成、1个全局平均池化层和1个全连接分类层构成。总共18层带权重的层卷积层和全连接层这也是18这个数字的来源。初始层是7x7卷积加最大池化后面4个stage的通道数分别是64、128、256、512每个stage的第一个Block负责下采样空间尺寸减半、通道数翻倍第二个Block保持维度不变。注意ResNet18用的是BasicBlock两个3x3卷积不是Bottleneck1x1降维3x31x1升维。Bottleneck是给ResNet50及以上用的参数量考虑不同。下面我会从环境准备开始一步步把代码搭起来中间穿插我实际调试时踩过的坑和验证方法。2. 环境准备与数据管线的几个关键决策2.1 TensorFlow版本选择和依赖安装TensorFlow 2.x之后Keras被深度整合进来搭网络基本就用tf.keras这套API。我用的版本是TF 2.10以上这个版本对tf.keras.layers的支持比较稳定Conv2D、BatchNormalization、GlobalAveragePooling2D这些层的行为都符合预期。安装就一条命令pip install tensorflow2.10如果你有GPU建议装tensorflow-gpu或者直接用官方带CUDA支持的版本。ResNet18虽然不算大但在CPU上训一个epoch也要等挺久有GPU的话体验会好很多。这里有个小细节TF 2.x默认开启eager execution调试的时候很方便但如果你要导出计算图做部署可能需要用tf.function包一下。不过对于本文这种以理解结构为目的的实现eager模式完全够用。2.2 输入尺寸和预处理的选择ResNet原始论文用的是224x224的输入配合7x7 stride 2的初始卷积和3x3 stride 2的最大池化经过这两步之后特征图从224降到56然后每个stage再降一半最后到7x7。这个尺寸设计是有讲究的7x7的特征图经过全局平均池化正好变成一个512维的向量接全连接层很自然。但实际用的时候你不一定非要用224。如果数据集图片比较小比如CIFAR-10的32x32直接套224的架构会导致特征图在早期就缩得太小。这时候有两个选择一是把初始层的7x7卷积改成3x3、去掉最大池化二是把输入resize到224。我一般倾向于后者因为保持网络结构和预训练权重兼容迁移学习的时候省事。预处理方面标准的做法是每个通道减均值除标准差。如果用ImageNet预训练权重均值和标准差要用ImageNet的统计值。自己从头训的话算一下自己数据集的均值和方差就行。# 假设输入是 [batch, 224, 224, 3] # 简单的归一化到 [0,1] 或者标准化 def preprocess(x): x tf.cast(x, tf.float32) / 255.0 mean tf.constant([0.485, 0.456, 0.406]) std tf.constant([0.229, 0.224, 0.225]) x (x - mean) / std return x提示如果你打算加载ImageNet预训练权重预处理必须和训练权重时一致否则精度会掉得莫名其妙。这个坑我踩过当时用[0,1]归一化加载预训练权重top-1掉了快10个点。2.3 数据增强要不要加从头训ResNet18的话数据增强几乎是必须的不然小数据集上很容易过拟合。常用的增强包括随机裁剪、随机翻转、颜色抖动。TF里可以用tf.keras.layers.RandomFlip、RandomCrop这些层直接搭在模型前面也可以用tf.data管道做。我个人的习惯是把增强放在tf.data管道里这样模型定义保持干净部署的时候不用把增强层剥掉。具体做法是在dataset.map里加随机操作注意要在batch之前做而且要用tf.image的函数保证在计算图里能跑。def augment(image, label): image tf.image.random_flip_left_right(image) image tf.image.random_brightness(image, 0.2) image tf.image.random_contrast(image, 0.8, 1.2) # 随机裁剪可以用 resize 配合 crop return image, label数据管道的性能也很关键。用dataset.cache()把数据缓存到内存或磁盘用dataset.prefetch(tf.data.AUTOTUNE)让CPU预处理和GPU计算重叠起来这两个操作能让训练速度提升不少。尤其是小数据集cache之后每个epoch几乎不花时间在读数据上。3. BasicBlock的拆解残差连接到底怎么加3.1 残差块的核心公式和两种实现路径残差块的核心就一个公式y F(x) x。其中F(x)是残差函数也就是两层卷积加BN加ReLU的那一坨x是恒等映射。这个加法要求F(x)和x的shape完全一致包括空间尺寸和通道数。当输入输出维度一致时直接相加就行。当维度不一致时比如stage的第一个Block通道数翻倍、空间减半就需要对x做变换让它和F(x)对齐。这个变换通常是一个1x1卷积配合stride 2再加BN。这就是所谓的shortcut projection。在代码里我一般这样组织BasicBlockclass BasicBlock(tf.keras.layers.Layer): def __init__(self, filters, stride1): super().__init__() self.conv1 tf.keras.layers.Conv2D( filters, 3, stridesstride, paddingsame, use_biasFalse) self.bn1 tf.keras.layers.BatchNormalization() self.conv2 tf.keras.layers.Conv2D( filters, 3, strides1, paddingsame, use_biasFalse) self.bn2 tf.keras.layers.BatchNormalization() self.downsample None if stride ! 1: self.downsample tf.keras.Sequential([ tf.keras.layers.Conv2D(filters, 1, stridesstride, use_biasFalse), tf.keras.layers.BatchNormalization() ]) def call(self, x, trainingFalse): identity x out self.conv1(x) out self.bn1(out, trainingtraining) out tf.nn.relu(out) out self.conv2(out) out self.bn2(out, trainingtraining) if self.downsample is not None: identity self.downsample(x, trainingtraining) out out identity out tf.nn.relu(out) return out这段代码有几个点值得展开说。3.2 为什么用strides而不是池化做下采样原始ResNet论文里下采样是通过stride 2的卷积实现的而不是先池化再卷积。这个选择的原因是池化会丢失信息而stride 2的卷积在学习下采样的同时保留了可学习的参数。具体到BasicBlock里第一个3x3卷积的stride设为2同时shortcut路径上的1x1卷积也用stride 2这样两条路径的空间尺寸就对齐了。有人可能会问能不能只在主路径上用stride 2shortcut路径用平均池化可以但效果通常不如1x1卷积。因为1x1卷积可以学习如何变换通道而池化只是机械地取平均或最大值。在ImageNet上用1x1卷积的版本精度略高一些。3.3 BN层的位置和training参数的处理BN层放在卷积之后、ReLU之前这是ResNet的标准做法。顺序是Conv - BN - ReLU。这个顺序在原始论文里就是这么定的后来的研究也基本沿用了这个设计。在TF里用BatchNormalization层的时候有个容易忽略的点training参数。训练时BN用当前batch的均值和方差推理时用移动平均。如果你在call方法里不传trainingTF会默认用trainingFalse这样训练时BN就不更新移动平均了推理时用的还是初始值精度会崩。所以我在call方法里显式传了training参数并且在模型调用的时候也要传。用model.fit的时候Keras会自动处理但如果你是自定义训练循环就得自己传。# 自定义训练循环里要这样写 with tf.GradientTape() as tape: logits model(x, trainingTrue) loss loss_fn(y, logits)注意BN的training参数和Dropout的training参数是两回事但都需要在训练和推理时切换。自定义循环里忘了传trainingTrueBN层就不会更新统计量这个坑很隐蔽因为loss看起来还在降但验证集精度上不去。3.4 恒等映射的加法为什么放在ReLU之前残差块的完整顺序是Conv-BN-ReLU-Conv-BN然后加上identity最后再ReLU。也就是说加法是在第二个BN之后、最终ReLU之前做的。这个顺序的原因和梯度流动有关。如果先ReLU再加identity那么identity路径上的梯度会被ReLU截断残差连接的效果就打折扣了。放在ReLU之前identity路径上的梯度可以无阻碍地回传这才是残差连接能训练深层网络的关键。原始论文里其实讨论过几种不同的排列最后选的是BN after addition的变体也就是加法之后不再接BN。我上面代码里用的是最常见的BN before addition版本两者在ResNet18上差别不大但后者实现更简洁。4. 从BasicBlock到完整ResNet18的组装4.1 初始层7x7卷积加最大池化ResNet18的入口是一个7x7、stride 2、64通道的卷积后面接BN和ReLU再接一个3x3、stride 2的最大池化。这两步下来224x224的输入变成56x56通道数变成64。inputs tf.keras.Input(shape(224, 224, 3)) x tf.keras.layers.Conv2D(64, 7, strides2, paddingsame, use_biasFalse)(inputs) x tf.keras.layers.BatchNormalization()(x) x tf.keras.layers.ReLU()(x) x tf.keras.layers.MaxPooling2D(3, strides2, paddingsame)(x)7x7的卷积核看起来很大但它的感受野覆盖了输入的大部分区域能捕捉到比较全局的特征。后面的网络再用3x3的小卷积核堆叠这样参数量可控感受野也能逐步扩大。4.2 四个stage的通道数变化规律四个stage的通道数依次是64、128、256、512每个stage的第一个Block做下采样stride 2第二个Block保持维度。空间尺寸的变化是56 - 28 - 14 - 7 - 4最后一个stage输出是7x7但经过全局平均池化就变成1x1了。# stage 1: 64通道不下采样因为初始层已经降到56了 x BasicBlock(64, stride1)(x) x BasicBlock(64, stride1)(x) # stage 2: 128通道下采样 x BasicBlock(128, stride2)(x) x BasicBlock(128, stride1)(x) # stage 3: 256通道 x BasicBlock(256, stride2)(x) x BasicBlock(256, stride1)(x) # stage 4: 512通道 x BasicBlock(512, stride2)(x) x BasicBlock(512, stride1)(x)这里有个容易搞混的地方stage 1的第一个Block不需要下采样因为初始层的最大池化已经把尺寸降到56了。如果这里再加stride 2尺寸会变成28后面就全乱了。所以stage 1的两个Block都是stride 1。4.3 全局平均池化和分类头四个stage之后特征图是7x7x512。全局平均池化把它变成512维的向量然后接一个全连接层输出类别数。x tf.keras.layers.GlobalAveragePooling2D()(x) outputs tf.keras.layers.Dense(num_classes, activationsoftmax)(x) model tf.keras.Model(inputs, outputs)全局平均池化替代了原始ResNet里的全连接层堆叠这个改动是ResNet论文里就有的。它的好处是参数量大幅减少7x7x512的全连接到512需要约1280万参数而全局平均池化是0参数同时有一定的正则化效果。分类头这里如果做迁移学习通常会先把Dense层之前的特征冻结只训最后的分类层。TF里可以用layer.trainable False来冻结。4.4 完整代码和参数量核对把上面的部分拼起来完整代码大概80行左右。搭好之后可以用model.summary()看一下参数量。ResNet18的参数量大约是1170万不计分类头加上分类头假设1000类大概1168万左右。model build_resnet18(num_classes1000) model.summary()如果参数量对不上大概率是某个Block的通道数或者stride设错了。我建议每搭完一个stage就打印一下输出shape确认空间尺寸和通道数符合预期。这个习惯能帮你快速定位问题比训完发现精度不对再回头查要高效得多。Stage输出尺寸通道数Block数是否下采样初始层56x5664-是7x7 conv maxpoolStage 156x56642否Stage 228x281282是Stage 314x142562是Stage 47x75122是5. 训练调试中那些文档不会告诉你的事5.1 学习率预热和衰减策略ResNet18从头训的时候学习率设置很关键。原始论文用的是0.1的初始学习率配合step decay每30个epoch降10倍。但这个设置是针对ImageNet的而且用了分布式训练的大batch。如果你在小数据集上训batch size只有32或64学习率要相应调小一般0.01到0.001之间比较稳妥。我习惯用warmup加余弦衰减。前5个epoch把学习率从0线性升到目标值然后按余弦曲线降到0。这样训练初期不会因为学习率太大导致loss爆炸后期又能精细收敛。initial_learning_rate 0.01 lr_schedule tf.keras.optimizers.schedules.CosineDecay( initial_learning_rate, decay_stepstotal_steps, alpha0.0) optimizer tf.keras.optimizers.SGD(learning_ratelr_schedule, momentum0.9)提示用SGD的时候momentum设0.9是标配weight decay设1e-4。如果用Adam学习率可以设1e-3但最终精度通常不如调好的SGD。这个结论在多个数据集上都被验证过。5.2 BN层和weight decay的交互有个细节很多人不知道BN层的参数gamma和beta通常不参与weight decay。因为BN的缩放和平移是为了归一化加正则化反而会干扰它的功能。在TF里如果你用model.fit的weight_decay参数它会作用于所有可训练变量包括BN参数。要排除BN参数得自己写正则化或者用优化器的exclude_from_weight_decay。我一般是在定义层的时候不加正则化然后在优化器层面统一处理。TF的AdamW优化器支持weight_decay参数但SGD的话需要自己实现。一个简单的做法是收集所有非BN的卷积核变量手动加L2正则到loss里。5.3 验证集精度震荡的排查思路训练过程中如果验证集精度上下震荡先别急着调参按这个顺序排查第一检查BN的training参数有没有正确传递。如果推理时BN还在用batch统计量精度会非常不稳定。第二看学习率是不是太大了。把学习率降10倍再跑几个epoch如果震荡消失那就是学习率的问题。第三检查数据增强是不是太激进。随机裁剪如果裁得太狠图片主体都被裁掉了模型学不到有效特征。可以先把增强关掉看精度是否稳定再逐步加回来。第四确认验证集和训练集的预处理一致。我见过有人训练时做了标准化验证时忘了做结果验证精度比训练低一大截。5.4 迁移学习时冻结哪些层用预训练权重做迁移学习的时候冻结策略取决于你的数据集大小和目标域相似度。如果数据集小且和ImageNet相似冻结所有卷积层只训分类头就行。如果数据集中等大小可以冻结前两个stage训后面两个stage和分类头。TF里冻结层的操作for layer in model.layers[:10]: layer.trainable False注意冻结之后要重新编译模型否则trainable的改动不会生效。这个坑我踩过当时改了trainable直接继续训发现参数根本没变查了半天才发现是没重新compile。6. 从80行代码延伸出去的几个方向代码跑通之后可以拿它当基线做不少实验。比如把BasicBlock换成Bottleneck就变成了ResNet50的结构参数量从1100万涨到2500万精度通常能提升1到2个点。或者把ReLU换成Swish、GELU这些激活函数看看在小数据集上有没有提升。还可以试试把BN换成GroupNorm或者LayerNorm这在batch size很小的时候比如2或4会有明显优势因为BN在小batch下方差估计不准。不过ResNet18本身不算深BN的问题没那么突出换归一化层的收益可能有限。另一个方向是修改下采样方式。把stride 2的卷积换成space-to-depth加卷积或者用blur pooling这些改动在超分辨率领域比较常见分类任务上不一定有提升但作为实验挺有意思。最后如果你想把模型部署到移动端可以用TFLite转换。ResNet18转成TFLite之后大概45MB左右float32量化到int8能压到12MB推理速度也能提升不少。转换的时候注意把BN层折叠进卷积这样能减少计算量。converter tf.lite.TFLiteConverter.from_keras_model(model) converter.optimizations [tf.lite.Optimize.DEFAULT] tflite_model converter.convert()我在实际项目里用ResNet18做过细粒度分类当时数据集只有几万张图从头训容易过拟合最后是用ImageNet预训练权重微调的。冻结了前三个stage只训最后一个stage和分类头学习率设1e-4训了20个epoch就到了不错的精度。如果全量微调学习率要更小而且容易把预训练学到的通用特征破坏掉。这个经验不一定通用但至少说明ResNet18这个体量的网络在中小数据集上微调是可行的不一定非要上更大的模型。