TensorFlow从零手写ResNet18:80行代码详解残差连接与BasicBlock实现

📅 发布时间:2026/10/20 13:38:56
TensorFlow从零手写ResNet18:80行代码详解残差连接与BasicBlock实现
1. 为什么80行代码值得认真拆解ResNet18这个网络结构搞深度学习的人几乎没有不知道的。2015年那篇残差学习的论文出来之后基本上把深层网络训练难的问题给解决了。但很多人第一次接触ResNet要么是直接调tf.keras.applications.ResNet50要么是看论文里的公式推导真正自己从零手写一遍ResNet18的人反而不多。我当初也是这么过来的。最开始用预训练模型做迁移学习几行代码就能跑起来感觉ResNet不过如此。后来有一次需要改网络结构把残差块里的某些层替换掉才发现如果对底层实现不熟悉连改都不知道从哪下手。从那以后我就养成了一个习惯任何一个经典网络都要自己手写一遍。这篇文章要聊的就是用TensorFlow从零实现ResNet18整个核心代码控制在80行左右。你可能会问80行够吗够的。ResNet18本身结构并不复杂它的精髓在于残差连接的设计思想而不是堆砌多少代码。这80行里包含了BasicBlock的定义、残差连接的实现、网络主体的搭建、以及完整的编译和训练流程。适合谁看如果你已经了解卷积神经网络的基本概念知道卷积层、批归一化、激活函数大概是干什么的但还没有自己动手搭过一个完整的ResNet那这篇内容就是写给你的。如果你已经用过ResNet做项目但想搞清楚Keras的Functional API和Model子类化两种写法有什么区别这里也会涉及。我选择TensorFlow而不是PyTorch来写原因很简单Keras的Functional API在搭建这种有跳连结构的网络时表达非常直观。你用layers.add()把两个分支加起来的那一刻残差连接的概念就变得非常具象了。PyTorch当然也能写但TensorFlow的Keras接口对新手更友好一些尤其是调试阶段model.summary()一出来整个网络结构一目了然。下面我会从设计思路开始把每一层的选择理由讲清楚然后给出完整的代码实现最后分享一些我在实际训练中踩过的坑和排查技巧。代码你可以直接复制去跑但更重要的是理解每一行为什么这么写。2. ResNet18的整体设计思路拆解2.1 残差学习的核心逻辑为什么需要跳连要理解ResNet18得先理解残差连接到底解决了什么问题。在ResNet之前大家普遍认为网络越深表达能力越强效果应该越好。但实际训练中发现当网络深度增加到一定程度准确率反而下降了。这不是过拟合因为训练集上的误差也在变大。这个问题被称为退化问题。退化问题的根源在于深层网络在反向传播时梯度经过多层传递后容易消失或者爆炸导致前面的层很难得到有效的更新。残差连接的做法很巧妙它不让网络直接学习目标映射H(x)而是学习残差映射F(x) H(x) - x。然后通过跳连把输入x直接加到输出上最终输出变成F(x) x。这样做的好处是如果某一层什么都没学到F(x)趋近于0输出就等于输入x相当于恒等映射。这意味着即使多加了几层没有用的网络最差情况也不会比原来更差。而在实际训练中网络总能学到一些有用的残差特征所以效果只会更好。用生活化的类比来说这就像你搬家的时候不是把所有东西重新打包一遍而是在原有打包好的箱子上只调整需要改动的部分。大部分箱子直接搬过去就行省时省力。2.2 BasicBlock的结构设计两层卷积加一条捷径ResNet18使用的是BasicBlock这是ResNet系列里最基础的残差块。一个BasicBlock包含两个3x3卷积层每个卷积后面跟一个批归一化层和一个ReLU激活函数。输入通过跳连直接加到第二个卷积的输出上然后再经过ReLU激活。具体结构是这样的输入x先经过第一个3x3卷积步长可能是1也可能是2取决于这个块是不是下采样块。然后经过BN和ReLU。接着经过第二个3x3卷积和BN。这时候得到F(x)再把原始的x加过来。但这里有个细节如果输入和输出的通道数不一样或者空间尺寸因为步长变化了就不能直接相加。这时候需要在跳连上做一个1x1卷积来调整维度保证能加在一起。ResNet18的整体架构是这样的首先是一个7x7的大卷积步长2输出64个通道后面跟BN和ReLU再跟一个3x3的最大池化步长2。然后依次经过四个阶段每个阶段包含两个BasicBlock。第一个阶段通道数是64第二个阶段128第三个256第四个512。每个阶段的第一个BasicBlock可能会做下采样把空间尺寸减半、通道数翻倍。最后是一个全局平均池化层和一个全连接层输出分类结果。2.3 为什么选择TensorFlow的Functional APITensorFlow提供了三种搭建模型的方式Sequential、Functional API和Model子类化。Sequential适合线性堆叠的网络但ResNet有跳连不是线性的所以Sequential直接排除。Model子类化最灵活你可以写任意复杂的逻辑但调试起来麻烦一些因为网络结构是在call方法里动态定义的summary()有时候看不清楚。Functional API是折中方案。它用层作为函数接收张量返回张量你可以像搭积木一样把层拼起来。跳连操作就是简单的layers.add([x, shortcut])非常直观。而且Functional API构建的模型天然支持summary()、plot_model()这些工具调试方便。我个人的习惯是如果网络结构是静态的、可以用计算图表示的优先用Functional API。只有需要动态控制流比如根据输入长度决定循环次数的时候才用Model子类化。ResNet18显然是静态结构所以Functional API是最优选择。3. 核心细节解析与实操要点3.1 卷积层参数选择的门道写ResNet18的时候卷积层的参数设置有几个关键点需要注意。第一个是卷积核大小。BasicBlock里用的是3x3卷积这是经过大量实验验证的黄金尺寸。两个3x3卷积堆叠的感受野等于一个5x5卷积但参数量更少非线性表达能力更强。第一个7x7卷积是ResNet论文里的设计用来快速降低空间分辨率同时提取底层特征。第二个是步长的设置。7x7卷积步长为2后面的最大池化步长也为2这样输入图像经过这两层之后空间尺寸直接变成原来的四分之一。以224x224的输入为例经过7x7卷积变成112x112再经过池化变成56x56。然后四个阶段分别输出56x56、28x28、14x14、7x7的特征图。第三个是padding策略。3x3卷积用paddingsame这样当步长为1时输入输出空间尺寸不变。当步长为2时TensorFlow的same padding会自动计算需要的填充量保证输出尺寸是输入的一半。这个细节很重要如果你用paddingvalid尺寸计算会变得很麻烦容易出错。第四个是偏置项。因为卷积层后面紧跟着批归一化层BN层会减去均值所以卷积的偏置项其实没有作用反而浪费参数。所以我在代码里统一设置use_biasFalse。这是一个小细节但能体现你对网络结构的理解深度。3.2 批归一化的位置与作用批归一化在ResNet里是标配每个卷积层后面、激活函数前面都会加一个BN层。BN的作用是对每个mini-batch的数据做归一化让均值接近0、方差接近1然后通过两个可学习参数gamma和beta来恢复表达能力。为什么BN对ResNet这么重要因为残差连接会把输入直接加到输出上如果两个分支的数值尺度差异很大相加之后会导致数值不稳定。BN把每个分支的输出都规范化到相似的尺度相加的时候就不会出现某个分支主导的情况。在代码实现上BN层有一个axis参数需要注意。对于TensorFlow的channels_last格式默认格式特征图的形状是(batch, height, width, channels)所以axis3或者axis-1。如果你用的是channels_first格式那就要改成axis1。这个参数设错了BN就完全失效了而且不会报错只是训练效果变差非常隐蔽。3.3 跳连的两种处理方式跳连是ResNet的灵魂但实现的时候要分两种情况处理。第一种情况是输入输出维度一致直接相加就行。第二种情况是维度不一致需要用一个1x1卷积加BN来调整输入的通道数和空间尺寸然后再相加。判断维度是否一致的标准有两个通道数是否相同空间尺寸是否相同。在ResNet18里每个阶段的第一个BasicBlock会做下采样步长为2同时通道数翻倍。这时候跳连就需要用1x1卷积步长也设为2把输入映射到相同的维度。我在代码里用一个use_projection标志来控制是否启用投影捷径。当步长不为1或者输入通道数不等于输出通道数时就需要投影。这个逻辑封装在BasicBlock的call方法里写一次就够了。注意投影捷径里的1x1卷积后面也要跟BN但不需要ReLU激活。因为跳连的目的是传递原始信息加非线性激活反而会破坏信息的完整性。3.4 全局平均池化的优势ResNet18的最后部分传统做法是先展平特征图然后接一个全连接层。但ResNet论文里用的是全局平均池化直接把每个通道的特征图取平均值得到一个长度为通道数的向量然后再接全连接层。全局平均池化的好处有两个。第一大幅减少参数量。以512个通道的7x7特征图为例如果展平后接全连接层参数量是51277类别数非常大。用全局平均池化之后参数量只有512类别数。第二全局平均池化有正则化效果因为每个通道只输出一个值相当于强制网络学习每个通道的全局特征而不是依赖某些局部位置的信息。在实际写代码的时候layers.GlobalAveragePooling2D()一行就够了它会自动处理空间维度。然后接一个layers.Dense(num_classes, activationsoftmax)输出分类概率。4. 完整代码实现与逐段解析4.1 BasicBlock的代码实现先来看BasicBlock的实现。我把它写成一个自定义的Keras层继承自tf.keras.layers.Layer。这样做的好处是结构清晰可以像普通层一样调用。import tensorflow as tf from tensorflow.keras import layers, Model class BasicBlock(layers.Layer): def __init__(self, filters, stride1): super().__init__() self.conv1 layers.Conv2D(filters, 3, stridesstride, paddingsame, use_biasFalse) self.bn1 layers.BatchNormalization() self.conv2 layers.Conv2D(filters, 3, strides1, paddingsame, use_biasFalse) self.bn2 layers.BatchNormalization() self.relu layers.ReLU() self.use_projection stride ! 1 if self.use_projection: self.shortcut_conv layers.Conv2D(filters, 1, stridesstride, use_biasFalse) self.shortcut_bn layers.BatchNormalization() def call(self, inputs, trainingNone): x self.conv1(inputs) x self.bn1(x, trainingtraining) x self.relu(x) x self.conv2(x) x self.bn2(x, trainingtraining) if self.use_projection: shortcut self.shortcut_conv(inputs) shortcut self.shortcut_bn(shortcut, trainingtraining) else: shortcut inputs x layers.add([x, shortcut]) return self.relu(x)这段代码有几个细节值得展开说。第一training参数贯穿整个call方法传递给BN层。这是因为BN在训练和推理时的行为不同训练时用当前batch的均值和方差推理时用移动平均的均值和方差。如果不传这个参数BN层可能无法正确切换模式。第二use_projection的判断条件是stride ! 1。在ResNet18里通道数变化和步长变化是同步的所以只需要判断步长就够了。但如果你要改网络结构比如通道数变了但步长没变那判断条件就要改成stride ! 1 or inputs.shape[-1] ! filters。第三跳连的加法用layers.add([x, shortcut])这是Functional API的写法。如果你用Model子类化也可以直接写x shortcut效果一样。但用layers.add的好处是它会自动处理广播和类型转换更稳妥。4.2 网络主体的搭建有了BasicBlock搭建ResNet18就很简单了。我用Functional API来写整个网络定义大概30行。def build_resnet18(num_classes1000): inputs layers.Input(shape(224, 224, 3)) x layers.Conv2D(64, 7, strides2, paddingsame, use_biasFalse)(inputs) x layers.BatchNormalization()(x) x layers.ReLU()(x) x layers.MaxPooling2D(3, strides2, paddingsame)(x) for filters, stride, blocks in [(64, 1, 2), (128, 2, 2), (256, 2, 2), (512, 2, 2)]: for i in range(blocks): x BasicBlock(filters, stride if i 0 else 1)(x) x layers.GlobalAveragePooling2D()(x) outputs layers.Dense(num_classes, activationsoftmax)(x) return Model(inputs, outputs)这段代码里我用了一个循环来构建四个阶段。每个阶段的配置是(通道数, 第一个块的步长, 块的数量)。ResNet18每个阶段都是2个块所以blocks固定为2。第一个块的步长决定了是否下采样后面的块步长都是1。这里有一个容易踩坑的地方BasicBlock的实例化。每次循环都创建了一个新的BasicBlock对象这是正确的。如果你不小心把同一个BasicBlock对象重复使用Keras会报错因为一个层对象只能被调用一次除非你显式地允许重用。所以一定要在循环内部创建新实例。另外layers.Input的shape参数不包含batch维度。如果你要处理不同尺寸的输入可以把shape设为(None, None, 3)这样网络就能接受任意尺寸的图像。但要注意全局平均池化层会自动适应空间尺寸所以全连接层的输入维度不会受影响。4.3 模型编译与训练配置模型搭好之后编译和训练的代码就很标准了。我一般用Adam优化器学习率设0.001损失函数用交叉熵评估指标用准确率。model build_resnet18(num_classes10) model.compile( optimizertf.keras.optimizers.Adam(learning_rate0.001), losssparse_categorical_crossentropy, metrics[accuracy] ) model.summary()model.summary()会打印出每一层的名称、输出形状和参数量。ResNet18的参数量大约在1100万左右如果你看到这个数字说明网络结构是对的。如果参数量差很多那就要检查是不是某个阶段的通道数设错了或者BasicBlock的数量不对。训练的时候如果你用的是小数据集比如CIFAR-10输入尺寸可以改成32x32。但要注意原来的7x7卷积步长2加最大池化步长2会把32x32直接降到8x8后面的下采样阶段可能空间尺寸太小。这时候可以把第一个7x7卷积改成3x3去掉最大池化或者调整步长。这是一个常见的适配问题很多人直接拿ResNet18跑CIFAR-10发现效果不好就是因为输入尺寸和网络结构不匹配。4.4 参数量计算与验证ResNet18的参数量可以手动算一下验证代码实现是否正确。第一个7x7卷积77364 9408。每个BasicBlock里两个3x3卷积的参数量分别是336464和3364*64加起来是73728。四个阶段的通道数分别是64、128、256、512每个阶段2个块参数量逐阶段翻倍。具体算下来卷积层参数量大约是1100万全连接层参数量是512*类别数。如果类别数是1000全连接层就是51.2万。总参数量约1150万和Keras打印出来的数字对得上。如果你发现参数量对不上最常见的原因是BN层的参数没算进去。每个BN层有2个可学习参数gamma和beta还有2个不可学习的移动均值和方差。虽然移动均值和方差不算在可训练参数里但它们会占用内存。model.summary()里会分别显示可训练参数和不可训练参数你可以对照检查。5. 常见问题与排查技巧实录5.1 训练不收敛的排查思路ResNet18训练不收敛最常见的原因有几个。第一个是学习率太大。Adam的默认学习率是0.001对ResNet18来说通常没问题但如果你改了batch size或者数据集可能需要调整。我一般会先用0.001跑几个epoch看loss是不是在下降。如果loss震荡很厉害就降到0.0001试试。第二个原因是BN层没有正确设置training参数。如果你在自定义层里忘了把training传给BNBN在训练时可能用的是推理模式均值和方差不会更新导致训练效果很差。这个问题的隐蔽性很强因为代码不会报错只是效果不好。排查方法是打印BN层的移动均值和方差看它们有没有在训练过程中变化。第三个原因是数据预处理不对。ResNet论文里对输入图像做了标准化减去ImageNet的均值除以标准差。如果你直接输入0-255的原始像素值网络很难训练。正确的做法是把像素值缩放到0-1之间或者做标准化。我一般用tf.keras.applications.resnet.preprocess_input它会自动做正确的预处理。5.2 显存不足的优化策略ResNet18虽然不算大但在显存有限的显卡上训练还是可能遇到OOMOut of Memory的问题。最直接的解决方法是减小batch size。batch size从32降到16或者8显存占用会明显下降。但batch size太小会影响BN的效果因为BN依赖batch内的统计量。如果batch size小于8可以考虑用Group Normalization替代BN。另一个方法是使用混合精度训练。TensorFlow支持mixed_float16策略把大部分计算用float16来做显存占用可以减少将近一半速度也能提升。用法很简单在构建模型之前加几行代码就行。policy tf.keras.mixed_precision.Policy(mixed_float16) tf.keras.mixed_precision.set_global_policy(policy)但要注意混合精度训练时最后的输出层要强制用float32否则softmax可能会数值不稳定。可以在Dense层里加dtypefloat32参数。还有一个方法是梯度累积。如果你只能用很小的batch size但又想模拟大batch的效果可以累积几个小batch的梯度再更新一次参数。TensorFlow里可以用tf.GradientTape手动实现但代码会复杂一些。Keras的fit方法不直接支持梯度累积需要自己写训练循环。5.3 迁移学习中的层冻结技巧用ResNet18做迁移学习的时候通常会把前面的卷积层冻结只训练最后的全连接层。冻结的方法是把层的trainable属性设为False。base_model build_resnet18(num_classes1000) for layer in base_model.layers[:-3]: layer.trainable False但这里有一个坑如果你在冻结之前已经编译了模型冻结操作不会生效。必须先设置trainable再编译模型。这是因为Keras在编译时会记录哪些参数需要更新之后再改trainable不会影响已经编译好的优化器。另外BN层在冻结时有个特殊处理。即使你把BN层的trainable设为False它在训练时仍然会更新移动均值和方差。如果你希望BN层完全冻结需要显式地设置layer.trainable False并且确保trainingFalse。在Keras里当整个模型被冻结时BN层会自动切换到推理模式但在微调部分层的时候这个行为可能不符合预期。我一般的做法是先冻结所有卷积层只训练全连接层几个epoch让分类头先适应新数据。然后解冻最后几个卷积块用更小的学习率做微调。这样比一上来就全部解冻效果更好也更稳定。5.4 常见问题速查表问题现象可能原因排查方法解决方案loss不下降学习率过大打印每步loss降低学习率到0.0001loss震荡batch size太小检查batch size增大batch size或改用GroupNorm准确率上不去数据预处理不对检查输入像素范围做标准化处理显存OOMbatch size过大查看显存占用减小batch size或混合精度BN层不更新training参数未传递打印BN移动均值在call方法中传递training参数量对不上通道数配置错误对照论文结构检查每个阶段的filters训练速度慢数据管道瓶颈查看GPU利用率用tf.data优化输入管道6. 实操心得与扩展建议6.1 我踩过的三个坑第一个坑是BN层的axis参数。有一次我写了一个自定义的卷积块BN层的axis设成了1但我的数据格式是channels_last结果BN完全失效训练了50个epoch准确率还在10%左右。排查了半天才发现是axis的问题。从那以后我养成了一个习惯每次写BN层都显式地写axis-1并且打印一下输入形状确认。第二个坑是跳连的加法顺序。layers.add([x, shortcut])和layers.add([shortcut, x])在数学上是等价的但在TensorFlow里如果两个张量的形状不完全一样广播规则可能会导致意外的结果。我建议始终保持一致的顺序把主分支放在前面跳连分支放在后面这样出问题的时候容易定位。第三个坑是模型保存和加载。用Functional API构建的模型保存成H5格式没问题。但如果你用了自定义层比如我写的BasicBlock加载的时候需要提供custom_objects参数否则Keras不认识这个层。更稳妥的做法是保存成SavedModel格式它会自动包含自定义层的定义。model.save(resnet18_model) loaded_model tf.keras.models.load_model(resnet18_model)6.2 从ResNet18扩展到其他变体理解了ResNet18的实现扩展到ResNet34就很简单了。ResNet34的BasicBlock结构和ResNet18一样只是每个阶段的块数量从2变成了[3, 4, 6, 3]。你只需要改一下循环里的blocks配置就行。如果要扩展到ResNet50就需要换Bottleneck结构了。Bottleneck用三个卷积1x1降维、3x3卷积、1x1升维。这样做的目的是在保持表达能力的同时减少参数量。ResNet50的参数量比ResNet34多不了太多但深度增加了不少。再进一步你可以把ResNet18改成SE-ResNet在每个BasicBlock里加一个Squeeze-and-Excitation模块。SE模块的作用是自适应地调整每个通道的权重让网络关注更重要的特征。实现起来也不复杂加一个全局平均池化、两个全连接层和一个Sigmoid激活就行。6.3 80行代码的边界与取舍最后说一下这80行代码的边界。它实现的是ResNet18的核心结构可以直接用于训练和推理。但它没有包含数据加载、数据增强、学习率调度、模型检查点保存这些工程化的内容。这些内容如果都加进来代码量会翻好几倍。我的建议是先把核心结构跑通确认网络能正常前向传播和反向传播然后再逐步添加工程化的组件。不要一上来就追求大而全的代码那样反而容易迷失在细节里。理解每一行代码的作用比复制粘贴一百行代码更有价值。如果你想让这80行代码更实用我建议优先加两个东西一是数据增强用tf.keras.layers.RandomFlip和RandomRotation做在线增强能显著提升小数据集上的效果二是学习率衰减用tf.keras.callbacks.ReduceLROnPlateau在验证集loss不下降时自动降低学习率能帮你省去手动调参的麻烦。这两个东西加进去代码大概会多出20行但训练效果和稳定性会有明显提升。至于更复杂的分布式训练、混合精度、梯度累积等你真正需要的时候再加也不迟。