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 API
TensorFlow提供了三种搭建模型的方式: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卷积用padding='same',这样当步长为1时,输入输出空间尺寸不变。当步长为2时,TensorFlow的same padding会自动计算需要的填充量,保证输出尺寸是输入的一半。这个细节很重要,如果你用padding='valid',尺寸计算会变得很麻烦,容易出错。
第四个是偏置项。因为卷积层后面紧跟着批归一化层,BN层会减去均值,所以卷积的偏置项其实没有作用,反而浪费参数。所以我在代码里统一设置use_bias=False。这是一个小细节,但能体现你对网络结构的理解深度。
3.2 批归一化的位置与作用
批归一化在ResNet里是标配,每个卷积层后面、激活函数前面都会加一个BN层。BN的作用是对每个mini-batch的数据做归一化,让均值接近0、方差接近1,然后通过两个可学习参数gamma和beta来恢复表达能力。
为什么BN对ResNet这么重要?因为残差连接会把输入直接加到输出上,如果两个分支的数值尺度差异很大,相加之后会导致数值不稳定。BN把每个分支的输出都规范化到相似的尺度,相加的时候就不会出现某个分支主导的情况。
在代码实现上,BN层有一个axis参数需要注意。对于TensorFlow的channels_last格式(默认格式),特征图的形状是(batch, height, width, channels),所以axis=3或者axis=-1。如果你用的是channels_first格式,那就要改成axis=1。这个参数设错了,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, activation='softmax')输出分类概率。
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, stride=1): super().__init__() self.conv1 = layers.Conv2D(filters, 3, strides=stride, padding='same', use_bias=False) self.bn1 = layers.BatchNormalization() self.conv2 = layers.Conv2D(filters, 3, strides=1, padding='same', use_bias=False) self.bn2 = layers.BatchNormalization() self.relu = layers.ReLU() self.use_projection = stride != 1 if self.use_projection: self.shortcut_conv = layers.Conv2D(filters, 1, strides=stride, use_bias=False) self.shortcut_bn = layers.BatchNormalization() def call(self, inputs, training=None): x = self.conv1(inputs) x = self.bn1(x, training=training) x = self.relu(x) x = self.conv2(x) x = self.bn2(x, training=training) if self.use_projection: shortcut = self.shortcut_conv(inputs) shortcut = self.shortcut_bn(shortcut, training=training) 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_classes=1000): inputs = layers.Input(shape=(224, 224, 3)) x = layers.Conv2D(64, 7, strides=2, padding='same', use_bias=False)(inputs) x = layers.BatchNormalization()(x) x = layers.ReLU()(x) x = layers.MaxPooling2D(3, strides=2, padding='same')(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, activation='softmax')(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_classes=10) model.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=0.001), loss='sparse_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传给BN,BN在训练时可能用的是推理模式,均值和方差不会更新,导致训练效果很差。这个问题的隐蔽性很强,因为代码不会报错,只是效果不好。排查方法是打印BN层的移动均值和方差,看它们有没有在训练过程中变化。
第三个原因是数据预处理不对。ResNet论文里对输入图像做了标准化,减去ImageNet的均值,除以标准差。如果你直接输入0-255的原始像素值,网络很难训练。正确的做法是把像素值缩放到0-1之间,或者做标准化。我一般用tf.keras.applications.resnet.preprocess_input,它会自动做正确的预处理。
5.2 显存不足的优化策略
ResNet18虽然不算大,但在显存有限的显卡上训练,还是可能遇到OOM(Out 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层里加dtype='float32'参数。
还有一个方法是梯度累积。如果你只能用很小的batch size,但又想模拟大batch的效果,可以累积几个小batch的梯度再更新一次参数。TensorFlow里可以用tf.GradientTape手动实现,但代码会复杂一些。Keras的fit方法不直接支持梯度累积,需要自己写训练循环。
5.3 迁移学习中的层冻结技巧
用ResNet18做迁移学习的时候,通常会把前面的卷积层冻结,只训练最后的全连接层。冻结的方法是把层的trainable属性设为False。
base_model = build_resnet18(num_classes=1000) for layer in base_model.layers[:-3]: layer.trainable = False但这里有一个坑:如果你在冻结之前已经编译了模型,冻结操作不会生效。必须先设置trainable,再编译模型。这是因为Keras在编译时会记录哪些参数需要更新,之后再改trainable不会影响已经编译好的优化器。
另外,BN层在冻结时有个特殊处理。即使你把BN层的trainable设为False,它在训练时仍然会更新移动均值和方差。如果你希望BN层完全冻结,需要显式地设置layer.trainable = False并且确保training=False。在Keras里,当整个模型被冻结时,BN层会自动切换到推理模式,但在微调部分层的时候,这个行为可能不符合预期。
我一般的做法是:先冻结所有卷积层,只训练全连接层几个epoch,让分类头先适应新数据。然后解冻最后几个卷积块,用更小的学习率做微调。这样比一上来就全部解冻效果更好,也更稳定。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| loss不下降 | 学习率过大 | 打印每步loss | 降低学习率到0.0001 |
| loss震荡 | batch size太小 | 检查batch size | 增大batch size或改用GroupNorm |
| 准确率上不去 | 数据预处理不对 | 检查输入像素范围 | 做标准化处理 |
| 显存OOM | batch 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行,但训练效果和稳定性会有明显提升。至于更复杂的分布式训练、混合精度、梯度累积,等你真正需要的时候再加也不迟。