简介:这份资源是面向深度学习入门与进阶开发者的TensorFlow版ResNet实现代码,覆盖18、50、101、152四种经典层数,以最简化的方式构建网络结构,便于在此基础上做各种网络变形与迁移学习实验。压缩包共5个文件,包含4个py脚本和1个mat文件,其中py脚本分别对应不同层数的ResNet网络定义与通用工具函数,mat文件为图像预处理所需的均值文件,整体仅619KB,轻量易读,适合逐行研读网络搭建细节。资源同时提供TensorFlow版预训练权重获取方式,受上传文件大小限制,需按描述中的网盘地址下载各层数预训练模型,可直接用于分类任务微调或特征提取。目前已有5287人学习下载,无论是想理解残差连接与瓶颈结构,还是需要一份可复用的预训练骨干网络代码,都能从中获得清晰的实现参考与排错思路。
1. 从一次训练翻车说起:为什么我建议你手头常备一份 TensorFlow 版 ResNet 实现
去年帮一个做工业质检的朋友调模型,他们用某个开源仓库里的 ResNet 跑缺陷分类,训练集上准确率冲到 99%,验证集死活卡在 72% 不动。我让他把 backbone 换成一个结构干净的 TensorFlow 版 ResNet-50,同样的数据、同样的增强策略,验证集直接拉到 89%。问题出在原仓库把 shortcut 分支的 stride 和主分支的 stride 写反了,导致下采样时特征图对不齐——这种坑,你光看论文是看不出来的,必须有一份能逐行对照的实现。
这份 TensorFlow 版 ResNet 代码覆盖了 18、50、101、152 四个经典深度,正好对应从轻量实验到大规模预训练的全场景。它适合两类人:一类是刚接触残差网络、想搞明白 bottleneck 和 basic block 到底差在哪的新手;另一类是要拿 ResNet 当 backbone 做迁移学习、需要一份可控可改的预训练权重加载逻辑的老手。下面我按「结构拆解 → 环境跑通 → 训练推理 → 改造扩展」的顺序,把这份代码讲透。
2. 四个深度版本的结构差异:basic block 与 bottleneck 到底怎么选
2.1 从 ResNet-18 到 ResNet-152 的层数账
很多人第一次看 ResNet 系列会懵:为什么 18 和 34 是一组,50、101、152 是另一组?核心区别在残差块类型。18 和 34 用的是 basic block,每个块里两个 3×3 卷积;50 及以上用的是 bottleneck block,结构是 1×1 降维 → 3×3 卷积 → 1×1 升维。bottleneck 的设计目的是在加深网络的同时控制参数量,否则 152 层用 basic block 参数量会爆炸。
这份代码里,四个深度共用同一套 backbone 构建逻辑,通过一个配置表区分。常见做法是定义一个ResNetConfig,把每个 stage 的 block 数量和 block 类型写进去:
# 四个深度的配置表,block 类型和每层重复次数 RESNET_CONFIGS = { 18: {"block": "basic", "layers": [2, 2, 2, 2]}, 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 的重复次数。18 和 34 的 basic block 里没有通道压缩,所以每个 stage 的 block 数少;50 以上因为 bottleneck 参数量小,可以堆更多层。改深度只需要改这个字典,不用动网络构建代码。
2.2 bottleneck 的通道压缩比与 shortcut 处理
bottleneck 里有个容易忽略的参数:压缩比。标准实现是第一个 1×1 卷积把通道压到 1/4,第三个 1×1 卷积再升回 4 倍。这个比例直接影响参数量和表达能力。代码里通常写成:
class BottleneckBlock(tf.keras.layers.Layer): def __init__(self, filters, stride=1, downsample=None): super().__init__() # 1x1 降维,压缩到 1/4 self.conv1 = Conv2D(filters // 4, 1, strides=1, padding='valid') self.bn1 = BatchNormalization() # 3x3 卷积,stride 控制下采样 self.conv2 = Conv2D(filters // 4, 3, strides=stride, padding='same') self.bn2 = BatchNormalization() # 1x1 升维,恢复到 filters self.conv3 = Conv2D(filters, 1, strides=1, padding='valid') self.bn3 = BatchNormalization() self.downsample = downsample # shortcut 分支,通道或尺寸不匹配时使用 def call(self, inputs, training=None): residual = inputs x = tf.nn.relu(self.bn1(self.conv1(inputs), training=training)) x = tf.nn.relu(self.bn2(self.conv2(x), training=training)) x = self.bn3(self.conv3(x), training=training) # shortcut 分支:通道数或空间尺寸变化时用 1x1 卷积对齐 if self.downsample is not None: residual = self.downsample(inputs, training=training) return tf.nn.relu(x + residual)这里的关键是downsample分支。当输入输出通道不一致,或者 stride 不为 1 导致特征图尺寸变化时,shortcut 必须用 1×1 卷积加 BN 做投影,否则加法操作会因为 shape 不匹配直接报错。我见过有人图省事用 padding 补零,结果训练时 loss 震荡得厉害——残差连接的意义就是让梯度有一条恒等通路,补零会破坏这个性质。
2.3 各版本参数量与显存占用对照
选哪个深度不是拍脑袋决定的,得看你的显存和任务复杂度。下面这张表是输入 224×224、batch size 32 时的粗略参考:
| 版本 | 参数量 | 单卡显存占用 | 适用场景 |
|---|---|---|---|
| ResNet-18 | ~11.7M | ~4GB | 小数据集、快速验证、边缘部署 |
| ResNet-50 | ~25.6M | ~7GB | 通用迁移学习、工业质检、分类基线 |
| ResNet-101 | ~44.5M | ~11GB | 细粒度分类、数据量充足时 |
| ResNet-152 | ~60.2M | ~15GB | 大规模预训练、竞赛刷点 |
显存不够时优先降 batch size 而不是换小模型,因为 BN 层在小 batch 下统计量不准,反而拖累收敛。如果非要跑 152 但只有单张 8GB 卡,常见做法是开混合精度加梯度累积,代码里加两行mixed_precision.set_global_policy('mixed_float16')就能省将近一半显存。
3. 环境搭建与预训练权重加载:从零跑通第一个 batch
3.1 依赖版本与目录结构
这份代码基于 TensorFlow 2.x,建议用 2.8 以上版本,因为tf.keras.layers.Conv2D的groups参数和混合精度 API 在早期版本里行为不一致。依赖清单不长:
# 建议用 conda 建独立环境,避免和系统里的 tf 冲突 conda create -n resnet_tf python=3.9 conda activate resnet_tf pip install tensorflow==2.12.0 pip install numpy opencv-python tqdm目录结构通常是这样的:models/放网络定义,utils/放数据加载和预处理,train.py是训练入口,predict.py做单图推理,weights/存预训练权重。跑之前先确认weights/目录下有对应的.h5或 checkpoint 文件,没有的话代码里一般会提供从零初始化的开关。
3.2 预训练权重加载的两种方式
预训练权重是这份代码的核心价值之一。加载方式分两种:一种是完整加载包括分类头,适合直接微调;另一种是只加载 backbone,换掉最后的全连接层做自定义分类。代码里通常这样处理:
def build_model(num_classes=1000, depth=50, pretrained=None, include_top=True): # 构建 backbone backbone = ResNetBackbone(depth=depth) # 如果指定了预训练权重,先加载 if pretrained is not None: backbone.load_weights(pretrained, by_name=True, skip_mismatch=True) # include_top=False 时只返回特征提取部分 if not include_top: return backbone # 接分类头 x = backbone.output x = GlobalAveragePooling2D()(x) outputs = Dense(num_classes, activation='softmax')(x) return tf.keras.Model(inputs=backbone.input, outputs=outputs)by_name=True表示按层名匹配加载,skip_mismatch=True让分类头不匹配时跳过而不是报错。这两个参数在迁移学习里几乎是必开的,否则你换个类别数就得手动改权重文件。注意加载权重前要确保模型已经 build 过,不然层名对不上,常见做法是先跑一次model.build(input_shape)或者喂一个 dummy batch。
3.3 数据管道与预处理对齐
预训练权重是在 ImageNet 上训的,预处理必须对齐:减均值、除标准差,顺序不能错。代码里一般用tf.data搭管道:
def preprocess(image, label, is_training=True): # 统一 resize 到 224x224 image = tf.image.resize(image, [224, 224]) # 训练时做随机翻转和裁剪增强 if is_training: image = tf.image.random_flip_left_right(image) image = tf.image.random_crop(image, [224, 224, 3]) # 归一化到 [0,1] 再减 ImageNet 均值 image = tf.cast(image, tf.float32) / 255.0 mean = tf.constant([0.485, 0.456, 0.406]) std = tf.constant([0.229, 0.224, 0.225]) image = (image - mean) / std return image, label均值标准差用的是 ImageNet 统计值,如果你用自己的数据集从零训,可以换成自己算的。但只要是加载预训练权重做微调,就必须用这套值,否则输入分布和权重期望的分布不一致,收敛会慢很多。这个坑我踩过,当时换了数据集忘了改预处理,训了 20 个 epoch 才发现 accuracy 一直上不去。
4. 训练、评估与推理:把模型真正跑起来
4.1 训练循环与学习率策略
训练入口一般用model.fit配合回调,或者自定义tf.GradientTape循环。这份代码两种都支持,自定义循环更灵活,方便加梯度裁剪和自定义日志:
optimizer = tf.keras.optimizers.SGD(learning_rate=0.01, momentum=0.9) # 余弦退火,前 5 个 epoch 线性 warmup lr_schedule = tf.keras.optimizers.schedules.CosineDecay( initial_learning_rate=0.01, decay_steps=total_steps, alpha=0.001) @tf.function def train_step(images, labels): with tf.GradientTape() as tape: predictions = model(images, training=True) loss = loss_fn(labels, predictions) gradients = tape.gradient(loss, model.trainable_variables) # 梯度裁剪,防止早期梯度爆炸 gradients, _ = tf.clip_by_global_norm(gradients, 5.0) optimizer.apply_gradients(zip(gradients, model.trainable_variables)) return loss学习率策略对 ResNet 微调影响很大。常见做法是 backbone 用小学习率(1e-4),新加的分类头用大学习率(1e-3),分层设置。如果嫌麻烦,统一用 0.01 加 warmup 也能跑,但收敛会慢一些。梯度裁剪那行别省,ResNet 在 batch size 较小时前几个 step 梯度范数经常冲到几十,裁一下稳很多。
4.2 评估指标与混淆矩阵
训练时只看 accuracy 容易被骗,尤其类别不平衡的时候。代码里评估部分一般会输出 top-1、top-5 和混淆矩阵:
def evaluate(model, dataset, num_classes): confusion = np.zeros((num_classes, num_classes), dtype=np.int32) for images, labels in dataset: preds = tf.argmax(model(images, training=False), axis=-1) for t, p in zip(labels.numpy(), preds.numpy()): confusion[t][p] += 1 # 按行归一化得到每类的召回率 recall = np.diag(confusion) / confusion.sum(axis=1) return recall, confusion混淆矩阵能直接告诉你模型在哪些类上翻车。我之前做一个 12 类缺陷分类,整体 accuracy 有 91%,但混淆矩阵显示第 7 类和第 9 类的召回率只有 60% 多,后来发现是这两类样本在预处理时被随机裁剪裁掉了关键区域。这种问题不看混淆矩阵根本发现不了。
4.3 单图推理与批量预测
推理脚本要处理两件事:加载模型和预处理输入。注意推理时的预处理不能带随机增强,只保留 resize 和归一化:
def predict(image_path, model, class_names): image = tf.io.read_file(image_path) image = tf.image.decode_jpeg(image, channels=3) image = tf.image.resize(image, [224, 224]) image = tf.cast(image, tf.float32) / 255.0 mean = tf.constant([0.485, 0.456, 0.406]) std = tf.constant([0.229, 0.224, 0.225]) image = (image - mean) / std # 加 batch 维度 image = tf.expand_dims(image, 0) logits = model(image, training=False) probs = tf.nn.softmax(logits) idx = tf.argmax(probs, axis=-1).numpy()[0] return class_names[idx], probs.numpy()[0][idx]批量预测时把expand_dims换成tf.stack或者直接用dataset.batch()。有个细节:如果图片长宽比差异大,直接 resize 会变形,常见做法是先按短边缩放再中心裁剪,代码里可以加个tf.image.resize_with_crop_or_pad处理。
5. 避坑与常见问题排查:那些让我熬夜的报错
5.1 加载权重后 loss 不降反升
现象:加载预训练权重后,第一个 epoch 的 loss 比随机初始化还高。原因通常是预处理没对齐,或者分类头的初始化方式不对。预训练权重期望的输入是减过 ImageNet 均值的,如果你直接喂 [0,255] 的原始像素,第一层卷积的输出分布完全错位。解决方法是检查预处理函数,确保减均值除标准差的顺序和数值都对。另外分类头如果用了默认的glorot_uniform初始化,在类别数差异大时也会导致初始 loss 偏高,换成zeros初始化 bias 会稳一些。
5.2 BN 层在小 batch 下报错或指标异常
现象:batch size 设为 4 或 8 时,训练 loss 剧烈震荡,验证集指标忽高忽低。原因是 BatchNormalization 在 batch 太小时统计量不准,移动平均更新方向偏差大。解决办法有两个:一是尽量把 batch size 拉到 16 以上;二是如果显存实在不够,把 BN 换成 GroupNormalization,或者冻结 BN 层的移动统计量只训练 gamma 和 beta。代码里可以在构建模型时加个开关,控制 BN 层是否可训练。
5.3 显存溢出但 batch size 已经很小
现象:batch size 降到 2 还是 OOM。原因可能是输入分辨率太高,或者中间特征图没有及时释放。224×224 的输入在 ResNet-152 上,第一个 stage 的输出通道是 256,特征图尺寸 56×56,单张图的激活值就占不少显存。解决办法:先确认输入尺寸是不是被意外放大,然后开混合精度训练,再把梯度累积加上模拟大 batch。如果还不行,考虑用tf.keras.mixed_precision配合LossScaleOptimizer,能再省 30% 左右。
5.4 多卡训练时指标不聚合
现象:用MirroredStrategy多卡训练,日志里每个卡各报各的 loss,最终指标比单卡还差。原因是自定义训练循环里没有做跨卡聚合。tf.distribute环境下,loss 和梯度都需要用strategy.reduce做 all-reduce。常见做法是把train_step用@tf.function包起来,然后在策略作用域内定义模型和优化器,Keras 的fit会自动处理聚合,自定义循环就得手动加。这个坑在多卡场景下几乎必踩,建议直接用model.fit省事。
5.5 推理结果和训练时对不上
现象:训练时验证集 accuracy 有 90%,推理脚本跑同一批图只有 60%。原因通常是推理时忘了切training=False,导致 BN 层还在用 batch 统计量而不是移动平均。另一个常见原因是推理时的 resize 方式和训练时不一致,比如训练用了中心裁剪,推理直接拉伸。解决办法:推理前显式调model(x, training=False),并把预处理逻辑抽成一个共用函数,训练和推理都调它,避免两处写不一致。
6. 二次开发与改造:把 ResNet 变成你自己的 backbone
拿到这份代码后,最常做的改造是换 backbone 的输出层和插入自定义模块。比如你要做多标签分类,把最后的Dense(num_classes, activation='softmax')换成Dense(num_classes, activation='sigmoid'),损失函数换成BinaryCrossentropy就行。如果要做目标检测,把include_top=False的 backbone 接到 FPN 或 PAN 上,注意取的是 conv2_x 到 conv5_x 四个 stage 的输出,通道数分别是 256、512、1024、2048(以 ResNet-50 为例)。
另一个高频改造是替换 basic block 里的激活函数。ReLU 换成 Swish 或 GELU 在小数据集上有时能涨点,但要注意 GELU 的计算量比 ReLU 大不少,推理延迟会上去。改的时候直接继承BasicBlock重写call方法,或者加个activation参数传进去。
验证改造是否有效,我一般用一个小数据集跑 10 个 epoch 做 A/B 对比,固定随机种子,只看验证集 loss 曲线。如果新模块在前 3 个 epoch 就明显低于基线,才值得继续训。最后说个血泪教训:每次改完 backbone,一定先用一个 batch 的数据跑一遍前向传播,确认输出 shape 和 dtype 都对,再开训练。我有次改完 shortcut 分支忘了对齐通道,训到第 5 个 epoch 才发现 loss 是 nan,白白浪费一晚上。从那以后我每次动网络结构,都强制走一遍model.summary()加 dummy batch 前向,确认无误才启动训练。希望这份拆解能帮你少走点弯路。
本文还有配套的精品资源,点击获取