ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

TensorFlow手写ResNet全系列:从残差结构到训练迁移实战

TensorFlow手写ResNet全系列:从残差结构到训练迁移实战 简介TensorFlow版的ResNet实现源码包覆盖18层、50层、101层与152层四种主流深度代码采用简化方式构建便于针对不同任务灵活调整网络结构整体定位清晰适合有一定TensorFlow基础、需要快速搭建图像分类或迁移学习模型的开发者与研究者。压缩包共5个文件以4个Python脚本为主体分别对应不同层数的网络定义另含1个MAT数据文件用于图像预处理所需的均值数据整个资源包仅619KB轻量易用结构一目了然。代码将残差块、瓶颈块等核心结构独立拆分只需要修改少量参数即可切换网络深度或改变通道配置能够直接用于图像分类、特征提取也可作为检测、分割模型的骨干网络随包还提供预训练权重下载入口方便读者在获得模型参数后直接做初始化或微调大幅降低从零训练的时间成本。目前已有5282人学习下载适合需要高效复现ResNet并进行扩展实验的开发者参考。 不少朋友在GitHub上翻到过各种TensorFlow版ResNet代码clone下来能跑通但一换数据集、一改深度就出问题训练Loss乱跳、验证集准确率上不去、推理时BatchNorm表现异常。这些问题的根子往往不在框架而在你对ResNet代码里的结构编排和细节处理没吃透。这篇文章我就拿TensorFlow实现ResNet-18/50/101/152这件事来聊把这套结构的核心设计、代码组织、训练与迁移的实操要点一次性讲明白适合刚入门图像分类、想自己手写骨干网络、或者准备拿ResNet做迁移学习的读者参考。1. ResNet到底解决了什么退化现象与残差设计1.1 为什么“越深越好”不成立在ResNet出现之前主流思路是加卷积层、加网络深度来提升表达能力。VGG从16层加到19层效果有提升但当网络深度继续加大时问题就来了——训练集上的误差反而变高。这不是过拟合能解释的因为训练误差本身就下不去。这个现象被称为退化问题。原因并不神秘深层网络在反向传播时梯度需要逐层回传层数一多梯度经过多次连乘后要么接近0梯度消失要么剧烈震荡不稳定。网络前面的层几乎拿不到有效的梯度信号训练自然停滞。ResNet的聪明之处在于引入了恒等映射。它可以形象理解为原来每个卷积层强制去拟合“从输入直接输出期望结果”的函数现在改为拟合“期望结果与输入的差异”。如果某个block已经学得很好后续block只需要输出接近0的残差这样网络加深时至少不会比浅层更差。用数学语言描述一个残差块可以写成[ y \mathcal{F}(x, {W_i}) x ]其中(\mathcal{F})就是卷积、BatchNorm、ReLU组成的映射函数(x)是输入末尾的(x)就是捷径连接。这条捷径把梯度直接传回前面的层使早期的层也能得到有效的训练信号。这个“残差学习”的设计是整份ResNet代码中最核心的“题眼”。1.2 从代码上看捷径连接的两种形态在TensorFlow层里捷径连接不是一句“add一下”那么简单它有两种形态对应不同的维度情况。第一种是输入输出shape完全一致直接相加out layers.add([conv_block_output, inputs])第二种是shape不一致需要先调整维度。常见做法是用一个1x1卷积将输入的通道数变成输出通道数同时通过stride降低分辨率shortcut layers.Conv2D( filtersout_filters, kernel_size1, stridesstride, paddingsame )(inputs) shortcut layers.BatchNormalization()(shortcut) out layers.add([conv_block_output, shortcut])这个1x1卷积只负责改变维度和步长不做复杂的非线性变换所以不会引入太多额外参数但在整个网络结构中它出现的频率很高——每一个Stage的第一个Block几乎都要用到。这里要强调一个新手常犯的错shape不一致时简单用padding或全局池化去强行对齐是不可靠的。padding只会补零不改变通道数全局池化会丢掉空间信息。标准做法就是上面这种1x1卷积的shortcut。2. BasicBlock与BottleneckBlock两份核心代码的差异2.1 BasicBlock结构简单但有下采样细节ResNet-18和ResNet-34就是由BasicBlock堆出来的。它的结构很直白两个3x3卷积每个卷积后面跟BatchNorm和ReLU最后残差相加。完整代码可以写成这样import tensorflow as tf from tensorflow.keras import layers, Model class BasicBlock(layers.Layer): def __init__(self, filters, stride1, use_shortcut_convFalse): super(BasicBlock, self).__init__() self.conv1 layers.Conv2D(filters, 3, stridesstride, paddingsame, use_biasFalse) self.bn1 layers.BatchNormalization(momentum0.9, epsilon1e-5) self.conv2 layers.Conv2D(filters, 3, strides1, paddingsame, use_biasFalse) self.bn2 layers.BatchNormalization(momentum0.9, epsilon1e-5) self.use_shortcut_conv use_shortcut_conv if use_shortcut_conv: self.shortcut_conv layers.Conv2D(filters, 1, stridesstride, use_biasFalse) self.shortcut_bn layers.BatchNormalization(momentum0.9, epsilon1e-5) def call(self, inputs, trainingFalse): shortcut inputs if self.use_shortcut_conv: shortcut self.shortcut_conv(inputs) shortcut self.shortcut_bn(shortcut, trainingtraining) x self.conv1(inputs) x self.bn1(x, trainingtraining) x tf.nn.relu(x) x self.conv2(x) x self.bn2(x, trainingtraining) x layers.add([x, shortcut]) x tf.nn.relu(x) return x注意两个细节第一每个Conv2D都设置了use_biasFalse。因为卷积后面紧跟BatchNormBN层本身就带偏置beta参数卷积层再带bias就冗余了还会造成参数浪费和训练不稳定。这是ResNet代码中的一个常见约定。第二use_shortcut_conv参数只在两种情况下为True一是stride不为1导致空间分辨率变化时二是输入通道数与输出通道数不一致时。如果你有印象ResNet的第一个Stage输入通道是64而每个Stage的第一个Block输出通道都会翻倍这时的shortcut就必须要经过1x1卷积。2.2 BottleneckBlock三卷积结构解决计算量问题ResNet-50/101/152不再用BasicBlock而是改用BottleneckBlock。原因很简单层数太深直接用3x3卷积堆计算量和参数量会爆炸。Bottleneck的思路是用1x1卷积先把通道数降下来在低维空间做3x3卷积最后再用1x1卷积升维。代码结构如下class BottleneckBlock(layers.Layer): expansion 4 def __init__(self, filters, stride1, use_shortcut_convFalse): super(BottleneckBlock, self).__init__() self.conv1 layers.Conv2D(filters, 1, strides1, use_biasFalse) self.bn1 layers.BatchNormalization(momentum0.9, epsilon1e-5) self.conv2 layers.Conv2D(filters, 3, stridesstride, paddingsame, use_biasFalse) self.bn2 layers.BatchNormalization(momentum0.9, epsilon1e-5) self.conv3 layers.Conv2D(filters * 4, 1, strides1, use_biasFalse) self.bn3 layers.BatchNormalization(momentum0.9, epsilon1e-5) self.use_shortcut_conv use_shortcut_conv if use_shortcut_conv: self.shortcut_conv layers.Conv2D(filters * 4, 1, stridesstride, use_biasFalse) self.shortcut_bn layers.BatchNormalization(momentum0.9, epsilon1e-5) def call(self, inputs, trainingFalse): shortcut inputs if self.use_shortcut_conv: shortcut self.shortcut_conv(inputs) shortcut self.shortcut_bn(shortcut, trainingtraining) x self.conv1(inputs) x self.bn1(x, trainingtraining) x tf.nn.relu(x) x self.conv2(x) x self.bn2(x, trainingtraining) x tf.nn.relu(x) x self.conv3(x) x self.bn3(x, trainingtraining) x layers.add([x, shortcut]) x tf.nn.relu(x) return x这里最关键的变量是expansion 4。Bottleneck内部中间层的通道数是filters但最终输出通道数是filters * 4。为什么这样设计因为要保证每个Stage的输出通道数与BasicBlock体系一致。以ResNet-50为例conv2_x阶段的输出是256维对应64x4conv3_x输出512维对应128x4以此类推。stride放在中间那个3x3卷积上这是一个值得注意的设计选择。早期版本也有人把下采样放在第一个1x1卷积上但后来研究发现放在中间效果更稳定因为1x1卷积先降维再下采样会丢失更多空间信息。实际代码中每个Stage的第一个BottleneckBlock的stride为2其余为1从而完成分辨率减半。2.3 两种Block的选型依据用表格对比一下会更直观项目BasicBlockBottleneckBlock适用网络ResNet-18/34ResNet-50/101/152卷积层数2个3x3卷积1x1 3x3 1x1输出通道等于内部filters等于内部filters的4倍参数效率浅层网络参数少计算直接深层网络大幅降低计算量残差相加维度直接相加1x1卷积调整shortcutResNet-18用BasicBlock完全没有问题因为层数浅总计算量可控但如果ResNet-152也用BasicBlock堆参数量和FLOPs都会大到一个不实用的程度。Bottleneck的哲学是“降维—卷积—升维”工程上和计算资源上都是更划算的选择。3. 从Block到完整网络四个Stage组装与18/50/101/152参数差异3.1 网络骨架与Stage构建逻辑ResNet整体结构可以拆成五段开头一个7x7卷积加最大池化、中间四个Stage、末尾一个全局平均池化和全连接分类层。上面讲的Block都是用在四个Stage里的。构建每个Stage时需要指定两个东西一个是这个Stage用多少个Block另一个是每个Block的初始通道数。用代码实现就是def build_stage(filters, blocks, stride2): block_list [] for i in range(blocks): if i 0: block_list.append(BottleneckBlock(filters, stridestride, use_shortcut_convTrue)) else: block_list.append(BottleneckBlock(filters, stride1, use_shortcut_convFalse)) return tf.keras.Sequential(block_list)注意这个if i 0的逻辑每个Stage的第一个Block承担下采样任务简称“下采样块”之后的Block保持分辨率不变。完整的ResNet-50模型部分如下def build_resnet(input_shape(224, 224, 3), num_classes1000, variantresnet50): config { resnet18: [2, 2, 2, 2], resnet34: [3, 4, 6, 3], resnet50: [3, 4, 6, 3], resnet101: [3, 4, 23, 3], resnet152: [3, 8, 36, 3], } block_counts config[variant] inputs layers.Input(shapeinput_shape) x layers.Conv2D(64, 7, strides2, paddingsame, use_biasFalse)(inputs) x layers.BatchNormalization(momentum0.9, epsilon1e-5)(x) x tf.nn.relu(x) x layers.MaxPool2D(pool_size3, strides2, paddingsame)(x) if variant in [resnet18, resnet34]: x build_stage_basic(block_counts[0], 64, stride1, xx) x build_stage_basic(block_counts[1], 128, stride2, xx) x build_stage_basic(block_counts[2], 256, stride2, xx) x build_stage_basic(block_counts[3], 512, stride2, xx) else: x build_stage_bottleneck(block_counts[0], 64, stride1, xx) x build_stage_bottleneck(block_counts[1], 128, stride2, xx) x build_stage_bottleneck(block_counts[2], 256, stride2, xx) x build_stage_bottleneck(block_counts[3], 512, stride2, xx) x layers.GlobalAveragePooling2D()(x) outputs layers.Dense(num_classes, activationsoftmax)(x) return Model(inputs, outputs)需要注意一个容易搞混的点BottleneckBlock内部的filters是64/128/256/512但最终输出通道要乘以4即256/512/1024/2048。BasicBlock则直接输出64/128/256/512。这也是为什么ResNet-50之后Stage输出的通道数通常写作256、512、1024、2048的原因。3.2 四种深度的blocks配置与参数量对比ResNet的深度其实就体现在不同Stage堆叠的Block数量上。把四种常见版本的配置放一起对比网络conv2_xconv3_xconv4_xconv5_x总层数使用Block类型ResNet-18222218BasicBlockResNet-50346350BottleneckBlockResNet-10134233101BottleneckBlockResNet-15238363152BottleneckBlock参数量大致如下ImageNet分类头、224x224输入ResNet-18约11.7MResNet-50约25.6MResNet-101约44.5MResNet-152约60.2M这个参数量的差异主要来自conv4_x的堆积。conv4_x阶段输入输出分辨率是14x14通道数是1024是“宽且深”的一个阶段Block加得越多参数量和计算量涨得越快。这也是ResNet-101和ResNet-152之间计算成本差距很大的主要原因。3.3 一个容易被忽视的维度问题输入尺寸与padding完整ResNet默认输入是224x224。7x7卷积stride2后得到112x112最大池化stride2后得到56x56正好进入conv2_x。之后的每个Stage开头stride2空间分辨率依次减半56→28→14→7。但如果你用的数据集不是224x224比如是32x32的CIFAR或者256x256的自定义数据也可以直接用但要留意两点第一paddingsame虽然能自动补零但如果输入太小比如32x32前面7x7卷积和3x3池化会消耗较多空间信息最后GAP层还是有值但模型表达能力受影响。CIFAR这种小图数据集更好的做法是去掉开头的最大池化层或者改成更小的kernel这也是很多CIFAR版ResNet代码的变体来源。第二如果你在代码里用了Model(inputs, outputs)构建完整模型输入shape改起来很方便但如果你是从keras.applications.ResNet50(weightsimagenet)加载预训练模型再改输入尺寸要注意预训练权重对分辨率并不是完全不变的虽然结构上能跑但效果会有变化。4. 训练过程中的隐性陷阱BatchNorm行为与学习率调节4.1 BatchNorm在训练和推理时的两种行为这段是整份代码调试经验里最值钱的部分真心建议多看两遍。BatchNorm在call()里接收training参数这个参数直接决定它的行为trainingTrue时BN会用当前batch的均值和方差做归一化同时更新全局的滑动均值和滑动方差。trainingFalse时BN不再更新任何统计量只使用训练阶段累积的全局统计量做归一化。对应到代码就是每一处bn(x, trainingtraining)的调用。如果你在推理阶段把training误设为True或者用Keras的model.predict()时把内部变量弄混模型输出几乎必然异常因为测试batch的统计量与训练分布不一致特别是在batch较小的情况下波动很大。还有个更隐蔽的坑如果你用model.fit()训练完了但想在自定义循环里做推理直接调用model(inputs, trainingFalse)是安全的但如果调用的是model(inputs)且没有显式传trainingKeras会用默认行为通常等于False这没问题。但如果你手动构建了Block层就必须自己在每一层传training。BatchNorm的momentum参数也值得单独说。默认值是0.99这在ImageNet这种大规模数据上是合理的因为每个batch的统计量估计都比较靠谱。但如果你的batch size很小比如8或160.99会导致滑动统计量更新太慢前期训练很不稳定。我一般在小batch训练时把momentum调到0.9或0.95能明显改善收敛过程。4.2 训练循环中的细节配置给出一个可直接抄作业的训练循环模板包含数据增强、学习率schedule和checkpoint保存model build_resnet(input_shape(224, 224, 3), num_classes10, variantresnet50) optimizer tf.keras.optimizers.SGD(learning_rate0.1, momentum0.9, weight_decay1e-4) loss_fn tf.keras.losses.SparseCategoricalCrossentropy() train_acc tf.keras.metrics.SparseCategoricalAccuracy() val_acc tf.keras.metrics.SparseCategoricalAccuracy() tf.function def train_step(x, y): with tf.GradientTape() as tape: logits model(x, trainingTrue) loss loss_fn(y, logits) grads tape.gradient(loss, model.trainable_variables) optimizer.apply_gradients(zip(grads, model.trainable_variables)) train_acc.update_state(y, logits) return loss学习率采用阶梯下降还是余弦退火都可以。对于ResNet在中小数据集上的表现我实测下来余弦退火比阶梯下降更省心不需要频繁调试衰减节点。阶梯下降常见的配置是[30, 60, 90]个epoch处乘0.1但换数据集后这套配置不一定适用。weight_decay在SGD优化器里是1e-4这个值在ImageNet上验证过。但要注意如果你用Adam或AdamWweight decay的实现方式不同不能直接搬参数。还有一点是不要把weight decay作用到所有变量上BN层的gamma和beta一般不加正则不然会破坏归一化的尺度。4.3 怎么判断训练是否正常ResNet的标准训练曲线有一个特征前几个epoch准确率会从随机水平快速爬升然后进入平台期慢慢涨。如果你发现Loss下降极慢先检查学习率如果Loss震荡剧烈先检查batch size和BN的momentum如果验证集准确率上不去但训练集一直涨那大概率是过拟合需要加强数据增强或者加大dropout。另外提供一个非常实用的排查方法训练第一个batch之后把真实标签和模型输出的logits打出来看一眼。如果softmax的结果是均匀分布说明模型还没起步正常如果全集中在某一个类上可能权重初始化或数据标签有问题。再用tf.debugging.check_numerics包一层loss能快速定位NaN的来源通常就是lr太大导致梯度爆炸。5. 预训练模型与迁移使用的实操经验5.1 加载官方预训练权重的两种方式如果只是想用ResNet而不是手写keras.applications是最省事的base_model tf.keras.applications.ResNet50( weightsimagenet, include_topFalse, input_shape(224, 224, 3), poolingavg ) x base_model.output x layers.Dense(256, activationrelu)(x) x layers.Dropout(0.5)(x) outputs layers.Dense(10, activationsoftmax)(x) model Model(base_model.input, outputs)include_topFalse表示去掉原来1000类的全连接分类头方便接自己的分类器。poolingavg直接用全局平均池化把特征图压成向量再往下接全连接层。如果你想结构上完全自定义又想用官方权重做法是把官方模型的层按名字复制到自己的模型里。但坦白说很麻烦大部分情况没必要。除非你改动了网络结构的某个卷积层通道数否则直接用applications是最稳的。5.2 迁移学习中BatchNorm的解冻细节用预训练模型做迁移学习时常见的策略是先冻结所有层只训练新加的分类头跑通后再解冻部分层做微调。但在TensorFlow里冻结ResNet有个坑仅仅设置layer.trainable False对卷积层是有效的对BN层的行为却需要格外小心。冻结状态下BN不再更新全局统计量而是使用加载预训练权重时自带的统计量。如果新数据集的分布和ImageNet相差很大冻结BN会导致特征分布不匹配模型效果受限。一般来说数据分布与ImageNet比较接近时冻结前几个Stage是安全的但如果数据是医学影像、卫星图这类与自然图像差异大的数据建议解冻BN层让统计量重新适应。我自己通常的做法是先冻结全部层训练10个epoch再把后面两个Stage和BN层一起解冻使用较小的学习率原学习率的0.1倍微调。5.3 从零训练还是用预训练作为结尾部分聊一点个人选择上的心得。对于大多数人来说只要你的数据集不是特别大比如小于10万张从零训练ResNet-50基本是吃力不讨好的事情。ImageNet预训练模型已经学到了非常通用的边缘、纹理、形状特征拿来迁移是性价比最高的选择。即使你的任务和ImageNet有差异迁移学习带来的起点优势通常也比从零训练强得多。只有两种情况我会选择从零训练一是数据集非常大且与ImageNet分布差异极大二是任务本身需要完全不同的输入形态比如单通道灰度大图、超分辨率等。另外补充一个实用技巧自定义数据集做迁移学习时输入尺寸不必一定用224可以尝试256或320更大的分辨率往往能带来几个点的提升代价是训练时间变长值得在算力允许时一试。还有一点在训练ResNet时把tf.keras.mixed_precision.set_global_policy(mixed_float16)打开能显著减少显存占用和训练时间特别是50层以上的模型。对精度的影响在大多数分类任务上可以忽略值得默认开启。本文还有配套的精品资源点击获取
返回列表