简介:这份资源是面向深度学习入门与进阶开发者的TensorFlow版ResNet实现代码,覆盖18、50、101、152四种经典层数,以最简化的方式构建网络结构,便于在此基础上做各类变体改造与迁移学习实验。压缩包共5个文件,包含4个py脚本与1个mat文件,py脚本分别对应不同层数的网络定义与通用工具函数,mat文件为图像预处理所需的均值文件,整体仅619KB,轻量易读。作者同时提供了TensorFlow版预训练权重,受上传限制需按描述中的网盘地址另行获取,方便直接加载做微调或推理验证。目前已有5287人学习下载,适合想理解ResNet残差结构、动手复现经典骨干网络或搭建图像分类基线的读者参考,代码结构清晰,便于按层数快速切换与二次开发。
1. 从一次训练不收敛说起:这套 TensorFlow ResNet 代码到底解决什么问题
去年帮一个做工业质检的朋友看模型,他拿了个开源 ResNet 在 TensorFlow 上跑,loss 从第一个 epoch 就开始抖,训到半夜准确率还卡在 60% 上下。我让他把 backbone 换掉,用一份按 18/50/101/152 分档、结构对齐原论文的 TensorFlow 版 ResNet 实现,同样的数据集、同样的学习率,第二天早上验证集就上了 92%。问题不在数据,也不在调参玄学,而在他那份实现里 shortcut 分支和 BN 的位置跟标准结构对不上,深层网络梯度根本传不下去。
这份代码要解决的就是这件事:把 ResNet 的四种主流深度(18、50、101、152)在 TensorFlow 里做成可切换、可加载预训练权重、可直接接下游任务的 backbone。它适合两类人——一类是刚接触残差网络、想拿真实可跑的代码理解 bottleneck 和 basic block 区别的新手;另一类是要在检测、分割、分类任务里换 backbone、需要一份结构干净、方便改的工程代码的熟手。关键词里的 tensorflow resnet pre-train,落到这份资源上就是:结构定义 + 权重加载 + 微调入口,三件事都能直接上手。
2. 结构选型与目录拆解:四种深度是怎么共用一套代码的
2.1 为什么用 basic block 和 bottleneck 两套残差单元
ResNet 的深度差异不是简单堆层数,而是残差单元类型不同。18 和 34 用 basic block,两个 3x3 卷积;50、101、152 用 bottleneck,1x1 降维、3x3 卷积、1x1 升维。这份代码把两者抽成独立函数,靠一个配置表决定每个 stage 堆多少个 block。这样加深度只改配置,不动结构代码,二次开发时想插自定义 block 也有地方下手。
# 残差单元配置:每种深度对应每个 stage 的 block 数量 # 18/34 用 basic block,50/101/152 用 bottleneck RESNET_CONFIG = { 18: {'block': 'basic', 'layers': [2, 2, 2, 2]}, 34: {'block': 'basic', 'layers': [3, 4, 6, 3]}, 50: {'block': 'bottleneck', 'layers': [3, 4, 6, 3]}, 101: {'block': 'bottleneck', 'layers': [3, 4, 23, 3]}, 152: {'block': 'bottleneck', 'layers': [3, 8, 36, 3]}, }layers数组的四个数字对应 conv2_x 到 conv5_x 四个 stage 的重复次数。50 和 101 的差异只在第三个 stage(6 对 23),152 则在第二、三 stage 都加深。改深度时只动这张表,网络构建逻辑完全复用,这是这套代码最值得抄的一点。
2.2 目录结构与模块职责
一份能直接跑的工程,目录通常长这样,各文件职责清晰,改哪块找哪个文件:
resnet_tf/ ├── models/ │ ├── resnet.py # 主干网络定义,含 basic/bottleneck │ └── layers.py # 卷积、BN、shortcut 等基础层封装 ├── configs/ │ └── resnet_config.py # 深度配置表、超参默认值 ├── utils/ │ ├── checkpoint.py # 预训练权重加载与变量映射 │ └── preprocess.py # 输入归一化、数据增强 ├── train.py # 训练入口,支持从预训练微调 ├── eval.py # 验证集评估 └── export.py # 导出为 SavedModel 供部署models/resnet.py是核心,utils/checkpoint.py是预训练能不能用起来的关键。很多人卡在权重加载上,就是因为变量命名和 checkpoint 里的 key 对不上,这个文件专门处理映射。
2.3 输入输出接口约定
backbone 对外只暴露两个东西:输入张量和指定 stage 的特征图。分类任务接全局池化加全连接,检测分割任务则取中间 stage 输出。常见做法是把ResNet类写成返回一个字典,key 是 stage 名,value 是对应特征图,下游按需取用。
class ResNet(tf.keras.Model): def __init__(self, depth=50, include_top=True, num_classes=1000): super().__init__() cfg = RESNET_CONFIG[depth] self.stem = StemBlock() # 7x7 conv + maxpool self.stages = [ # 四个 stage make_stage(cfg['block'], cfg['layers'][i], filters=64 * 2**i) for i in range(4) ] self.include_top = include_top if include_top: self.pool = tf.keras.layers.GlobalAveragePooling2D() self.fc = tf.keras.layers.Dense(num_classes) def call(self, x, training=False): x = self.stem(x, training=training) feats = {} for i, stage in enumerate(self.stages): x = stage(x, training=training) feats[f'stage{i+2}'] = x # stage2~stage5 if self.include_top: x = self.pool(x) return self.fc(x) return featsinclude_top=False时返回特征字典,这是接下游任务的标准姿势。training参数一路透传给 BN 层,训练和推理行为不同,漏传会导致推理结果异常,这是新手最容易翻车的地方之一。
3. 从零跑通:环境、预训练权重加载与训练入口
3.1 环境准备与依赖版本
TensorFlow 版本对 ResNet 影响很大,2.x 系列里tf.keras的 BN 和变量管理行为有差异。建议锁 2.8 以上,配 CUDA 11.2 及以上。依赖不多,核心就三个:
pip install tensorflow-gpu==2.10.0 pip install numpy opencv-python # 数据管道用 tf.data,无需额外装提示:如果只有 CPU,把
tensorflow-gpu换成tensorflow,但 152 深度训练会非常慢,建议先用 18 验证流程。
3.2 预训练权重加载:变量名映射是核心
预训练权重能不能用上,全看变量名对不对得上。这份代码在utils/checkpoint.py里做了一层映射,把 checkpoint 里的 key 转成 Keras 层的变量路径。加载时按层名匹配,匹配不上的打印警告而不是直接报错,方便排查。
def load_pretrained(model, ckpt_path, skip_mismatch=True): """加载预训练权重,按变量名逐层匹配""" ckpt = tf.train.load_checkpoint(ckpt_path) ckpt_vars = {k: ckpt.get_tensor(k) for k in ckpt.get_variable_to_shape_map()} loaded, skipped = 0, [] for var in model.weights: name = var.name.split(':')[0] # 去掉 :0 后缀 key = map_name(name) # 映射到 checkpoint 命名 if key in ckpt_vars and ckpt_vars[key].shape == var.shape: var.assign(ckpt_vars[key]) loaded += 1 else: skipped.append(name) print(f'loaded {loaded} vars, skipped {len(skipped)}') if skipped and not skip_mismatch: raise ValueError(f'未匹配变量: {skipped[:5]}') return modelmap_name负责把conv2d_1/kernel这类 Keras 命名转成 checkpoint 里的conv1/weights。加载完打印 loaded 和 skipped 数量,skipped 太多说明映射规则没覆盖全,得回去补。skip_mismatch=True时只警告不中断,适合微调场景——分类头本来就不需要预训练权重。
3.3 训练入口与微调配置
train.py支持两种模式:从零训练和从预训练微调。微调时常见做法是冻结 stem 和前两个 stage,只训后面 stage 加分类头,学习率调小一个量级。
# 从预训练微调,冻结前两个 stage python train.py \ --depth 50 \ --pretrained ./ckpt/resnet50.ckpt \ --freeze_until stage3 \ --lr 0.001 \ --epochs 30 \ --batch_size 32 \ --data_dir ./data/train--freeze_until stage3表示 stage2 及之前的层trainable=False。--lr微调时别用从零训练的大学习率,0.001 起步比较稳。--depth直接决定用哪套配置,18 到 152 一条命令切换。训练日志里重点看两个:loss 是否平稳下降、验证集准确率有没有在几个 epoch 后超过冻结前的基线。如果 loss 不降,先检查预训练权重 loaded 数量是不是接近总变量数。
4. 核心模块拆解:shortcut、BN 与下采样的配合
4.1 shortcut 分支的两种处理
残差连接看着简单,但维度不匹配时怎么处理有讲究。这份代码里 shortcut 分两种情况:通道数和空间尺寸都不变时直接恒等映射;需要下采样时用 1x1 卷积配 stride=2,再接 BN。顺序错了或者 stride 放错位置,梯度路径就断了。
class Bottleneck(tf.keras.layers.Layer): def __init__(self, filters, stride=1, downsample=False): super().__init__() self.conv1 = ConvBN(filters, 1, 1) # 1x1 降维 self.conv2 = ConvBN(filters, 3, stride, pad=1) # 3x3 主卷积 self.conv3 = ConvBN(filters * 4, 1, 1) # 1x1 升维 self.downsample = downsample if downsample: # shortcut 用 1x1 卷积对齐通道和尺寸 self.shortcut = ConvBN(filters * 4, 1, stride) def call(self, x, training=False): identity = x out = self.conv1(x, training) out = self.conv2(out, training) out = self.conv3(out, training) if self.downsample: identity = self.shortcut(x, training) return tf.nn.relu(out + identity) # 相加后再激活注意tf.nn.relu(out + identity)的位置:残差相加之后才做激活,不是各自激活再相加。downsample分支的 stride 必须和主分支 conv2 的 stride 一致,否则相加时空间尺寸对不上,直接报错。bottleneck 的输出通道是中间通道的 4 倍,这个 4 倍关系在配置 filters 时不能搞错。
4.2 BN 层的位置与 training 透传
BN 在 ResNet 里是稳定训练的关键,位置固定:每个卷积之后、激活之前。这份代码把 conv+BN+relu 封成一个ConvBN层,避免手写时漏掉 BN 或者顺序写反。更重要的是training参数必须一路透传,训练时 BN 用 batch 统计量,推理时用滑动平均,漏传会让推理结果完全不对。
class ConvBN(tf.keras.layers.Layer): def __init__(self, filters, kernel_size, strides=1, pad=0): super().__init__() self.conv = tf.keras.layers.Conv2D( filters, kernel_size, strides, padding='same' if pad else 'valid', use_bias=False) # 有 BN 就不需要 bias self.bn = tf.keras.layers.BatchNormalization() def call(self, x, training=False): x = self.conv(x) x = self.bn(x, training=training) # training 必须透传 return tf.nn.relu(x)use_bias=False是因为 BN 自带偏置,再加卷积 bias 是冗余的,还会让权重加载时多出匹配不上的变量。training从模型call一路传到每个ConvBN,中间任何一层忘了传,推理时 BN 就会用当前 batch 的统计量,单张图推理结果会飘。
4.3 下采样 stage 的 stride 放置
ResNet 四个 stage 里,stage3、stage4、stage5 的第一个 block 要做下采样,空间尺寸减半、通道翻倍。下采样的 stride 放在 bottleneck 的 3x3 卷积上(basic block 放在第一个 3x3),不是放在 1x1 上。这个细节决定了特征图尺寸变化是否符合预期。
def make_stage(block_type, num_blocks, filters, stride=1): """构建一个 stage,第一个 block 负责下采样""" blocks = [] for i in range(num_blocks): # 只有第一个 block 用指定 stride,其余 stride=1 s = stride if i == 0 else 1 downsample = (s != 1) or (filters != current_filters) blocks.append(build_block(block_type, filters, s, downsample)) return tf.keras.Sequential(blocks)stride只在 stage 的第一个 block 生效,后面 block 都是 stride=1。downsample的判断条件是两个:stride 不为 1,或者输入输出通道不一致。漏掉通道判断,某些配置下 shortcut 维度对不上,训练直接崩。
5. 避坑与排查:权重加载、显存和 BN 的那些坑
5.1 预训练权重 loaded 数量远小于总变量数
现象:加载预训练后打印loaded 20 vars, skipped 200 vars,模型等于没加载。原因:变量名映射规则没覆盖全,Keras 的层命名和 checkpoint 命名体系不同,比如 Keras 用conv2d_1而 checkpoint 用conv1。解决:在map_name里补全映射规则,先打印几个 skipped 的变量名和 checkpoint 里的 key 对比,找出规律再批量映射。加载后 loaded 数应该接近总变量数减去分类头。
5.2 推理结果每次都不一样
现象:同一张图推理两次,输出不同。原因:training参数没透传,BN 在推理时用了当前 batch 统计量。解决:检查模型call和每个子层call是否都有training参数并正确传递,推理时显式传training=False。这是最隐蔽的坑,训练时看不出来,部署时才暴露。
5.3 152 深度训练显存爆掉
现象:batch_size 设 16 还是 OOM。原因:152 的参数量和中间激活值远大于 18,尤其 bottleneck 升维到 4 倍通道后特征图很大。解决:用梯度累积模拟大 batch,或者开混合精度tf.keras.mixed_precision.set_global_policy('mixed_float16'),显存能省近一半。再不行就冻结前两个 stage,减少反向传播的激活存储。
5.4 微调后准确率反而下降
现象:加载预训练微调,验证集准确率比从零训练还低。原因:学习率太大,把预训练学到的特征冲掉了。解决:微调学习率比从零训练小一个量级,先用小学习率 warmup 几个 epoch,再决定要不要解冻更多层。冻结策略上,先只训分类头,稳定后再逐段解冻,别一上来就全网络放开。
5.5 数据预处理和预训练不匹配
现象:权重加载正常,但模型表现差。原因:输入归一化方式和预训练时不一致,比如预训练用的是 ImageNet 均值方差,这边只做了除以 255。解决:在utils/preprocess.py里对齐预训练时的归一化参数,均值[0.485, 0.456, 0.406]、方差[0.229, 0.224, 0.225],别自己拍脑袋改。
6. 二次开发:换 backbone、加注意力与导出部署
拿到这份代码后,最常见的改造有三个方向。第一个是换 backbone 接下游任务,把include_top=False打开,取stage3或stage4的特征图接检测头或分割头。这里有个细节:检测任务通常要 stage3 和 stage4 两层特征做 FPN,取特征字典时按 key 拿就行,不用改主干代码。
第二个是往 block 里插注意力模块。以 SE 为例,在 bottleneck 的 conv3 之后、残差相加之前插入一个 squeeze-excitation 分支,通道数不变,只改Bottleneck.call里的几行。改完记得重新加载预训练权重时跳过新增的注意力层变量,否则 loaded 数量会掉。
class SEBlock(tf.keras.layers.Layer): def __init__(self, channels, reduction=16): super().__init__() self.gap = tf.keras.layers.GlobalAveragePooling2D() self.fc1 = tf.keras.layers.Dense(channels // reduction, activation='relu') self.fc2 = tf.keras.layers.Dense(channels, activation='sigmoid') def call(self, x): w = self.gap(x) # [B, C] w = self.fc2(self.fc1(w)) # [B, C] w = tf.reshape(w, [-1, 1, 1, x.shape[-1]]) return x * w # 通道加权第三个是导出部署。export.py把模型转成 SavedModel,推理时注意输入签名要和预处理对齐。导出前用model.save('resnet50_export'),加载时tf.keras.models.load_model直接可用。如果要用 TFLite,先转 SavedModel 再转,BN 层会被折叠进卷积,推理速度能提一截。
验证改造是否成功,我一般跑三步:先用一张图过一遍前向,看输出 shape 对不对;再加载预训练权重,看 loaded 数量有没有异常下降;最后跑一个 epoch 微调,看 loss 是否正常下降。这三步走完,基本能确认改造没引入结构性问题。
从那以后我每次改 backbone 结构,都强制先跑一遍权重加载的 loaded/skipped 统计,再动训练脚本。这个习惯帮我省了太多半夜排查的功夫。希望帮到你。
本文还有配套的精品资源,点击获取