
1. 为什么80行代码值得单独写一篇ResNet18这个网络结构做深度学习的人几乎没有不知道的。2015年那篇Deep Residual Learning的论文出来之后残差连接基本上成了后续所有主流网络的标配。但有意思的是很多人第一次自己动手写ResNet的时候都会卡在同一个地方论文看懂了结构图也看明白了真到写代码的时候BasicBlock里的shortcut到底怎么处理、下采样在哪一步做、BN放在卷积前面还是后面这些细节一动手就露馅。我这篇东西就是冲着这个来的。用TensorFlow把ResNet18从头搭一遍控制在80行左右的核心代码不依赖任何预训练权重也不调用现成的模型库。适合两类人看一类是刚学完CNN基础、想找个经典网络练手的另一类是平时调库调多了想搞清楚tf.keras.applications.ResNet50里面到底在干什么的。读完你至少能拿到三样东西一份能直接跑的完整代码、对残差结构每个细节的清晰理解、以及自己动手改网络结构的能力。先说清楚80行指的是模型定义部分的核心代码不包括数据加载和训练循环。如果把训练也塞进去80行根本不够看而且会稀释掉重点。我的思路是模型定义单独成文件训练脚本另写这样结构清晰也方便你直接把这个模型拿去接自己的数据。2. ResNet18的结构拆解与设计思路2.1 残差到底解决了什么问题在ResNet之前网络层数一往上堆准确率反而下降。这不是过拟合因为训练误差也在涨。理论上更深的网络至少不应该比浅的差——你后面加的那些层哪怕全做恒等映射效果也不该退化。但实际训练中让一堆非线性层去拟合恒等映射这件事本身就很难。残差连接的做法很直接不让网络直接学目标映射H(x)而是学残差F(x) H(x) - x最后输出F(x) x。如果最优解就是恒等映射那网络只需要把F(x)的权重压到接近零就行这比从头学一个恒等映射容易得多。这个思路说白了就是给网络一条“偷懒”的路径让它不用每层都拼命学有用的东西。2.2 BasicBlock的设计细节ResNet18用的是BasicBlock不是Bottleneck。这两个的区别值得说清楚结构类型组成参数量适用网络BasicBlock3x3 Conv 3x3 Conv较少ResNet18/34Bottleneck1x1 3x3 1x1较多但通道压缩ResNet50/101/152BasicBlock里两个3x3卷积每个后面跟BN和ReLU。注意顺序是Conv → BN → ReLU这个顺序在TensorFlow里对应Conv2D然后BatchNormalization然后ReLU。有些实现会把BN放在Conv前面那是另一套逻辑标准ResNet论文用的是Conv-BN-ReLU。shortcut连接分两种情况如果输入输出通道数相同且空间尺寸不变直接相加如果通道数变了或者尺寸减半了就需要用1x1卷积做投影把输入映射到和输出一样的维度。这个判断逻辑是写代码时最容易出错的地方。2.3 整体网络结构ResNet18的完整结构是这样的第一层7x7卷积stride2输出64通道后面接3x3最大池化stride2然后四个stage每个stage由2个BasicBlock组成stage164通道特征图尺寸不变stage2128通道第一个block做下采样stage3256通道第一个block做下采样stage4512通道第一个block做下采样最后全局平均池化 全连接层这里有个细节stage2到stage4的第一个block第一个卷积的stride设为2实现空间尺寸减半同时shortcut也要用stride2的1x1卷积来匹配尺寸。这个下采样逻辑如果写错了训练的时候直接报维度不匹配的错。3. 80行核心代码逐段拆解3.1 环境准备与依赖代码基于TensorFlow 2.x用Keras的Functional API或者Subclassing API都可以。我选Subclassing因为结构更清晰也方便你理解每一层在干什么。import tensorflow as tf from tensorflow.keras import layers, Model # 确认版本2.x以上都行 print(tf.__version__)不需要额外装什么包TensorFlow自带Keras。如果你用的是GPU版本确认一下CUDA和cuDNN的版本匹配这个坑我后面会细说。3.2 BasicBlock的实现先写BasicBlock这是整个网络的基本单元class BasicBlock(layers.Layer): def __init__(self, filters, stride1): super(BasicBlock, self).__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() # shortcut通道或尺寸不匹配时用1x1卷积投影 self.downsample None if stride ! 1: self.downsample tf.keras.Sequential([ layers.Conv2D(filters, 1, stridesstride, use_biasFalse), layers.BatchNormalization() ]) def call(self, x, trainingFalse): identity x out self.conv1(x) out self.bn1(out, trainingtraining) out self.relu(out) out self.conv2(out) out self.bn2(out, trainingtraining) if self.downsample is not None: identity self.downsample(x, trainingtraining) out out identity return self.relu(out)几个关键点解释一下。use_biasFalse是因为后面跟了BNBN本身有beta参数卷积的bias就多余了去掉能省一点参数量。training参数要一路传下去因为BN在训练和推理时的行为不一样这个如果忘了传推理结果会不对。shortcut的判断条件我写的是stride ! 1因为在这个网络里通道数变化和尺寸变化是同时发生的。如果你要写更通用的版本应该判断stride ! 1 or input_channels ! filters。但ResNet18的结构比较规整用stride判断就够了。3.3 主网络的搭建有了BasicBlock主网络就是把它按规律堆起来class ResNet18(Model): def __init__(self, num_classes1000): super(ResNet18, self).__init__() self.stem tf.keras.Sequential([ layers.Conv2D(64, 7, strides2, paddingsame, use_biasFalse), layers.BatchNormalization(), layers.ReLU(), layers.MaxPooling2D(3, strides2, paddingsame) ]) self.stage1 self._make_stage(64, 2, stride1) self.stage2 self._make_stage(128, 2, stride2) self.stage3 self._make_stage(256, 2, stride2) self.stage4 self._make_stage(512, 2, stride2) self.avgpool layers.GlobalAveragePooling2D() self.fc layers.Dense(num_classes) def _make_stage(self, filters, blocks, stride): layers_list [BasicBlock(filters, stridestride)] for _ in range(1, blocks): layers_list.append(BasicBlock(filters, stride1)) return tf.keras.Sequential(layers_list) def call(self, x, trainingFalse): x self.stem(x, trainingtraining) x self.stage1(x, trainingtraining) x self.stage2(x, trainingtraining) x self.stage3(x, trainingtraining) x self.stage4(x, trainingtraining) x self.avgpool(x) return self.fc(x)_make_stage这个辅助函数把重复的逻辑抽出来了每个stage的第一个block负责下采样后面的block保持尺寸。这样写比手动展开每个block清晰得多也方便你改成ResNet34把blocks参数改成3,4,6,3就行。3.4 参数量验证写完代码最好验证一下参数量ResNet18官方参数量大约是11.7M不计全连接层。你可以这样检查model ResNet18(num_classes1000) model.build(input_shape(None, 224, 224, 3)) model.summary()如果参数量对不上大概率是某个地方的通道数或者shortcut写错了。我第一次写的时候把stage2的shortcut漏了参数量少了差不多一半训练的时候直接维度报错。4. 训练实操与踩坑记录4.1 数据管道怎么搭模型写完了得喂数据。我用tf.data搭管道这是TensorFlow里效率比较高的方式def build_dataset(image_dir, batch_size32, image_size224): dataset tf.keras.utils.image_dataset_from_directory( image_dir, image_size(image_size, image_size), batch_sizebatch_size, label_modecategorical ) # 归一化到[0,1] dataset dataset.map(lambda x, y: (x / 255.0, y)) dataset dataset.prefetch(tf.data.AUTOTUNE) return datasetprefetch这个操作很关键它让数据加载和GPU计算重叠起来不然GPU经常在等数据利用率上不去。AUTOTUNE让TensorFlow自己决定预取多少batch省得你手动调。4.2 学习率与优化器选择ResNet原文用的是SGD加动量初始学习率0.1每30个epoch降10倍。但那是ImageNet级别的训练如果你在小数据集上fine-tune或者从头训学习率要调小。我的经验是场景优化器初始学习率说明从头训小数据集SGDmomentum0.01数据少学习率大了容易震荡从头训大数据集SGDmomentum0.1配合warmup更稳微调Adam1e-4Adam对学习率不敏感省心我试过在小数据集上用0.1的学习率loss直接飞了降到0.01之后才正常收敛。这个坑很典型很多人照着论文参数抄结果数据规模差了两个数量级参数当然不适用。4.3 BN层的坑BN层有几个地方容易出问题。第一是training参数必须正确传递训练时trainingTrue推理时trainingFalse。如果你用model.fit()Keras会自动处理但如果你自己写训练循环忘了传这个参数BN会用推理模式跑训练结果就是loss降不下去。第二是batch size太小的时候BN效果不好。BN依赖batch内的统计量batch太小统计量不准。一般建议batch size至少16最好32以上。如果显存不够可以用tf.keras.layers.BatchNormalization(momentum0.9)调一下动量或者换GroupNorm但那就不是标准ResNet了。第三是分布式训练时BN的同步问题。如果你用多卡训练每张卡上的BN是独立的统计量只在本卡内计算。要跨卡同步需要用SyncBatchNormalization这个在tf.keras.layers.experimental里面。4.4 训练循环的写法我习惯自己写训练循环这样每一步在干什么都清楚tf.function def train_step(model, images, labels, optimizer, loss_fn): with tf.GradientTape() as tape: predictions model(images, trainingTrue) loss loss_fn(labels, predictions) gradients tape.gradient(loss, model.trainable_variables) optimizer.apply_gradients(zip(gradients, model.trainable_variables)) return losstf.function装饰器把Python函数编译成图速度比eager模式快不少。但注意第一次调用会慢因为要trace图后面就快了。如果你在train_step里面print东西每次trace只会print一次这个行为有时候会让人困惑。5. 常见问题速查与排查技巧5.1 维度不匹配报错这是最常见的错误通常出在shortcut上。报错信息一般是ValueError: Dimensions must be equal。排查步骤检查每个stage的第一个blockstride是否为2检查shortcut的卷积stride是否和主路径一致检查shortcut的输出通道数是否等于block的filters我遇到过一次stage3的shortcut写成了stride1结果特征图尺寸对不上加法操作直接报错。这种错误看代码不容易发现因为结构上看起来是对的只有跑起来才知道。5.2 Loss不下降如果loss一直不动或者震荡按这个顺序排查学习率是不是太大了先降到1e-3试试BN的training参数传对了吗数据归一化做了吗输入范围是不是在合理区间标签对不对有没有one-hot编码错误我踩过最坑的一次是数据增强写错了把图片翻转的维度搞混了结果图片全花了loss当然不降。这种问题只能靠可视化中间结果来排查。5.3 显存不够ResNet18本身不大224x224输入batch size 32大概需要4-6GB显存。如果不够几个办法减小batch size但注意BN对batch size敏感用混合精度训练tf.keras.mixed_precision.set_global_policy(mixed_float16)能省差不多一半显存用梯度累积小batch多步累积后再更新混合精度是我最推荐的改动小效果好。但注意最后全连接层的输出要转回float32不然数值会溢出。5.4 推理速度慢训练完了要部署推理速度也是问题。几个优化点用model.save()保存为SavedModel格式加载比H5快用TensorRT或者TFLite转换推理速度能提升2-3倍如果只是推理把BN层融合到卷积里能减少计算量BN融合是个经典优化推理时BN就是个线性变换可以合并到前面的卷积权重里。TensorFlow的tf.keras.utils里有现成的工具但自己写也不难就是几行矩阵运算的事。6. 代码扩展与后续玩法6.1 改成ResNet34前面说了_make_stage的blocks参数改一下就行self.stage1 self._make_stage(64, 3, stride1) self.stage2 self._make_stage(128, 4, stride2) self.stage3 self._make_stage(256, 6, stride2) self.stage4 self._make_stage(512, 3, stride2)参数量会从11.7M涨到21.8M训练时间大概翻倍。如果你的数据集够大ResNet34的效果一般比18好一些但差距不会特别大。6.2 加注意力机制残差结构加注意力是现在很常见的改法。最简单的SE模块在BasicBlock的残差分支上加一个通道注意力class SEBlock(layers.Layer): def __init__(self, channels, reduction16): super(SEBlock, self).__init__() self.gap layers.GlobalAveragePooling2D() self.fc1 layers.Dense(channels // reduction, activationrelu) self.fc2 layers.Dense(channels, activationsigmoid) def call(self, x): scale self.gap(x) scale self.fc1(scale) scale self.fc2(scale) return x * tf.reshape(scale, (-1, 1, 1, x.shape[-1]))把SEBlock插在BasicBlock的残差相加之前就变成了SE-ResNet。这个改动大概增加5%的参数量但在分类任务上通常能涨1-2个点。6.3 迁移学习怎么用自己写的模型没有预训练权重但你可以加载官方ResNet的权重来初始化。不过要注意官方权重是按特定结构训练的你的结构必须完全一致才能加载。如果改了结构只能加载部分层或者干脆从头训。从头训小数据集的话建议冻结前面的层只训后面的stage和全连接。等loss稳定了再解冻全部层做fine-tune。这个策略在小数据集上很有效能防止过拟合。6.4 部署到移动端如果要把模型部署到手机或者嵌入式设备TFLite是首选。转换命令很简单converter tf.lite.TFLiteConverter.from_keras_model(model) converter.optimizations [tf.lite.Optimize.DEFAULT] tflite_model converter.convert()量化之后模型大小能压到原来的四分之一推理速度也能提升。但注意量化会损失一点精度一般掉1个点以内可以接受。如果精度要求高可以用量化感知训练在训练时就模拟量化误差这样转换后精度损失更小。7. 我个人的一些实操体会写ResNet18这个事代码本身不难难的是把每个细节都搞对。我第一次写的时候shortcut的stride写错了训练的时候维度报错查了半天才发现是stage2的shortcut没加stride2。后来我养成了一个习惯每写完一个stage就build一下模型打印每层的输出shape确认无误再往下写。另一个体会是不要迷信论文里的超参数。论文用的学习率、batch size都是针对ImageNet调的你的数据集规模不一样这些参数必须重新调。我一般先用小学习率跑几个epoch看loss下降的趋势再决定要不要加大。最后说一个调试技巧如果模型不收敛先把网络简化比如去掉BN、去掉shortcut看能不能训起来。如果能再一步步加回来这样能快速定位问题出在哪。这个方法帮我省了很多时间比盲目调参有效得多。代码我放在这了80行左右你可以直接复制去跑。如果有问题大概率是TensorFlow版本差异导致的API变化检查一下layers.Conv2D的参数名和BatchNormalization的调用方式就行。