资讯动态

ResNet + TensorFlow 2 手写实战:残差结构、训练调优与 TFLite 部署

发布时间:2026/10/26 5:23:15 来源:尧图企业网站定制
1. 为什么 ResNet 值得在 TensorFlow 2 里亲手写一遍tf.keras.applications.ResNet50一行代码就能拉起来这个便利性对做业务落地的人是好消息对想真正吃透网络结构的人是坏消息。我见过太多人调参调到怀疑自己最后卡住的地方根本不是学习率而是他没意识到自己手里的这个 ResNet 是 v1.5 的瓶颈结构、shortcut 用了 1x1 投影、BN 的 momentum 语义和另一套框架正好相反。这些细节在 Keras 的高层封装里全被藏起来了出问题时你连怀疑的对象都找不到。这篇东西记录的是我自己反复手写、反复训练、反复踩坑之后沉淀下来的一套ResNet TensorFlow 2的实战路径。它不面向跑通一行调用就算完的场景而是给那些需要自己改结构、自己接数据管道、自己控制训练策略的人看的图像分类、特征提取、把 ResNet 当骨干塞进检测或分割网络、以及在端侧做量化部署。全文会从残差结构的设计逻辑讲起一路讲到模型怎么导出成 TFLite中间该算的账我会算给你看该避的坑我会直接标出来。基础要求不高会写tf.keras的Layer、知道卷积和 BN 大概在干什么就够了如果你已经能独立训练模型但对为什么不收敛始终说不清原因这篇可能更对你胃口。我先说清楚这次实战的定位我们不是去复刻某一篇特定版本的论文而是搭一个结构正确、训练可控、方便改造的 ResNet 骨架。结构正确指的是残差连接的位置、下采样的时机、BN 和激活的顺序都对得上训练可控指的是学习率、正则、精度策略都在我手里方便改造指的是换成 BasicBlock 还是 Bottleneck 只是一行参数的事。这三件事做到之后剩下的调参才有意义。2. 残差结构的设计逻辑与几个关键取舍2.1 恒等映射到底救了什么先说结论残差连接解决的不是梯度消失至少不主要是。梯度消失是它的附带效果真正被解决的是深层网络至少不该比浅层网络更差这个退化问题。这个区分很重要因为它决定了你在调试时的方向——如果你的 50 层模型训不过 18 层别急着怪梯度先想想是不是恒等映射这条路被堵住了。用生活化的类比假设你要把一个 3x3 的卷积核改成更深的堆叠理想情况下多出来的层只要学成什么都不做就行也就是学一个恒等映射。但让一堆卷积加非线性去精确表达恒等映射在数值上非常别扭——ReLU 把负半轴全部压成零信息只能单向流动网络很难原样传下去。残差块的做法是直接把输入x加到输出上F(x) x。这时候让F(x)收敛到 0 比让它收敛到恒等映射容易太多因为权重衰减、零初始化这些机制天然就在把卷积核往 0 上推。所以残差块的隐含语义是我只学增量。这个思路在工程上带来一个很实际的好处你可以放心地把网络加深而不需要重新设计整个优化策略。我在做一个细粒度分类任务时从 34 层换到 50 层的收益是实打实的 2 个多点而这个替换在代码上只是改了一个配置列表。2.2 BasicBlock 和 Bottleneck 的选型账ResNet 有两个积木两层 3x3 的 BasicBlock用于 18/34 层和1x1 - 3x3 - 1x1的 Bottleneck用于 50/101/152 层。Keras 的高层 API 里你只需要填[3,4,6,3]这样的数字但自己写的时候必须想清楚为什么这么分。关键在于计算量。假设输入输出通道都是 256Bottleneck 的中间通道压到 64直接堆两个 3x3256→256256 × 256 × 9 × 2 ≈ 118 万次乘加按每个输出像素算Bottleneck256×64×1 64×64×9 64×256×1 16384 36864 16384 69632差了将近17 倍。这就是瓶颈结构存在的全部理由——它把 3x3 卷积放在低维空间里做两头用 1x1 升降维代价是表达能力略微受限、并且多了一层非线性。所以 50 层以上的网络参数总量反而没有爆炸ResNet-50 大约 2560 万参数ResNet-34 大约 2180 万层数多了近一半参数只多了不到 20%。对比项BasicBlockBottleneck结构3x3 → 3x31x1 → 3x3 → 1x1通道扩张倍数14适用深度18 / 3450 / 101 / 1523x3 卷积计算量高低在压缩通道上算小数据集表现更稳容易过拟合需更强正则迁移学习友好度一般高预训练权重多为瓶颈版选型上我的建议很直接数据量小、图小、类别少优先 BasicBlock。瓶颈结构在 32×32 这种分辨率上收益不明显反而因为通道被压到 64 导致信息瓶颈过早出现。我在一个只有 5 万张训练图的任务上对比过ResNet-34 的验证精度比 ResNet-50 高 0.8 个点训练时间还少了四成。反过来如果你要用公开的 1000 类预训练权重做迁移那就只能用瓶颈版因为主流的公开权重基本都是瓶颈结构。2.3 下采样时 shortcut 怎么办三种方案的真实差异这是手写 ResNet 最容易写错的地方。当主分支通过stride2把特征图尺寸减半、通道数翻倍时shortcut 这条路传过来的x形状对不上了不能直接相加。原始论文给了三个方案方案做法参数量效果A尺寸不匹配时用零填充补通道stride 用切片实现0可用但信息有损B只在尺寸变化时用 1x1 卷积 BN 投影少量论文默认性价比最高C所有 shortcut 都用 1x1 卷积 BN较多略好但收益与代价不成正比我实际用的是B。理由很简单恒等映射这条路越干净越好能不加参数就不加只在不得不改维度的时候加一次。方案 A 的零填充会让新增的通道全部是 0虽然能跑但那部分残差支路前期等于废的收敛会慢一点。方案 C 我在一个小任务上试过验证精度差异在 0.2 个点以内属于噪声范围但参数和显存是实打实多出来的。还有一个更隐蔽的细节stride 放在哪一层。原始 v1 把stride2放在瓶颈块的第一个 1x1 卷积上后来大家发现放在中间的 3x3 上效果更好一般叫 v1.5因为 1x1 卷积做下采样会丢掉 3/4 的空间信息而 3x3 卷积本身有感受野下采样时还能利用邻域。Keras 官方那套applications用的就是 v1.5。你自己写的时候如果照抄论文的图很容易写成 v1然后发现精度比预期低一截还找不到原因。提示stride一定要和 shortcut 的投影保持同步。如果主分支 stride2 而投影支路 stride1张量形状对不上Keras 会在运行时报 shape mismatch这种错误在model.summary()里看不出来只有真正跑一次 forward 才暴露。3. TensorFlow 2 下的模型实现细节3.1 层的组织方式与权重初始化TF 2 里写自定义网络有两条路函数式 API 拼Model或者继承layers.Layer写积木再组装。我选后者原因是积木可以复用、可以单独测、可以塞进别的骨干网络里。写积木时有个原则所有子层必须在__init__里创建不能在call里创建否则每次前向都会新建变量model.summary()会越来越长权重也存不下来。初始化用 He 初始化he_normal或者VarianceScaling(scale2.0, modefan_out)。道理是 ReLU 会把一半的激活打成 0方差要相应放大一倍才能让信号在深层保持量级。这一点在 50 层网络上体现得非常明显用默认的 glorot 初始化前几个 epoch 的 loss 曲线会明显比 He 初始化抖。还有一个我很推荐的小技巧把每个残差块最后一个 BN 层的 gamma 初始化为 0。这样训练开始时整个残差分支输出恒为 0块的输出等于输入等于网络一开始就是个纯恒等映射堆叠梯度信号走 shortcut 畅通无阻。等训练几轮之后 gamma 慢慢长起来残差分支才开始起作用。实测下来这个改动能让前 5 个 epoch 的收敛速度提升明显尤其是在深网络上。import tensorflow as tf from tensorflow.keras import layers def _conv_bn(x, filters, kernel_size, stride, trainingNone, zero_gammaFalse, nameNone): x layers.Conv2D( filters, kernel_size, stridesstride, paddingsame, use_biasFalse, kernel_initializerhe_normal, nameNone if name is None else name _conv )(x) x layers.BatchNormalization( momentum0.9, epsilon1e-5, gamma_initializerzeros if zero_gamma else ones, nameNone if name is None else name _bn )(x, trainingtraining) return x注意这里use_biasFalse。卷积后紧跟 BN 的时候bias 是纯冗余参数——BN 的 beta 会把它完全吸收掉留着只会多占显存并且让权重衰减多算一项。3.2 BN 层的三个易错点BN 看着简单坑是最多的。第一个坑是momentum 的语义和 PyTorch 相反。TF 的momentum0.9意思是旧统计量保留 90%moving 0.9 * moving 0.1 * batchPyTorch 的momentum0.1是同一个意思。如果你从 PyTorch 代码翻译过来直接把 0.1 抄过去等价于让滑动统计量几乎完全跟随当前 batch推理时的均值和方差会剧烈跳动验证精度会莫名其妙地上下摆。我的经验值是 TF 里填0.9数据集特别大、batch 特别大的时候可以调到0.99。第二个坑是training标志的传递。BN 在训练时用当前 batch 统计量、更新滑动平均在推理时用滑动统计量。自定义Layer的call方法如果不显式把training往下传子层就会用默认值可能出现训练的时候 BN 走推理模式这种灾难。标准写法是def call(self, x, trainingNone)然后把training一路传给每个 BN。第三个坑是小 batch 下的 BN。BN 的统计量在 batch size 小于 8 的时候噪声非常大我见过 batch4 训练时 loss 一直震荡不降的案例把 batch 提到 32 立刻就正常了。如果显存实在不够可以用 GroupNormalization 替代或者冻结 BN 用预训练统计量。另外任何情况下都不建议在 BN 前面加 Dropout两者的统计会互相打架。3.3 完整可跑的网络代码下面这套代码是我实际在用的版本Bottleneck 实现支持任意深度配置training正确透传最后一层 BN 零初始化。class Bottleneck(layers.Layer): expansion 4 def __init__(self, filters, stride1, **kwargs): super().__init__(**kwargs) self.conv1 layers.Conv2D(filters, 1, use_biasFalse, kernel_initializerhe_normal) self.bn1 layers.BatchNormalization(momentum0.9, epsilon1e-5) self.conv2 layers.Conv2D(filters, 3, stridesstride, paddingsame, use_biasFalse, kernel_initializerhe_normal) self.bn2 layers.BatchNormalization(momentum0.9, epsilon1e-5) self.conv3 layers.Conv2D(filters * 4, 1, use_biasFalse, kernel_initializerhe_normal) self.bn3 layers.BatchNormalization(momentum0.9, epsilon1e-5, gamma_initializerzeros) self.proj None if stride ! 1: self.proj layers.Conv2D(filters * 4, 1, stridesstride, use_biasFalse, kernel_initializerhe_normal) self.proj_bn layers.BatchNormalization(momentum0.9, epsilon1e-5) def call(self, x, trainingNone): shortcut x y self.conv1(x) y self.bn1(y, trainingtraining) y tf.nn.relu(y) y self.conv2(y) y self.bn2(y, trainingtraining) y tf.nn.relu(y) y self.conv3(y) y self.bn3(y, trainingtraining) if self.proj is not None: shortcut self.proj_bn(self.proj(x), trainingtraining) return tf.nn.relu(y shortcut)组装整个网络时stem 部分要按输入分辨率改这是迁移小图任务时最常忽略的一步。标准的7x7 stride2 maxpoolstem 会把 224 的图压到 56但如果你输入是 32x32经过这个 stem 只剩 8x8后面四个 stage 做完只剩 1x1特征全被压没了。正确做法是小图用3x3 stride1的 stem 并且去掉 maxpool让第一个 stage 在 32x32 上跑。def build_resnet(block, blocks_per_stage, num_classes, input_shape, stemstandard, dropout0.0): inputs layers.Input(shapeinput_shape) if stem standard: x _conv_bn(inputs, 64, 7, 2) x layers.ReLU()(x) x layers.MaxPooling2D(3, strides2, paddingsame)(x) widths [64, 128, 256, 512] else: # 小图模式 x _conv_bn(inputs, 64, 3, 1) x layers.ReLU()(x) widths [64, 128, 256, 512] for i, (w, n) in enumerate(zip(widths, blocks_per_stage)): for j in range(n): stride 2 if (i 0 and j 0) else 1 x block(w, stridestride, namefstage{i}_block{j})(x) x layers.GlobalAveragePooling2D()(x) if dropout 0: x layers.Dropout(dropout)(x) outputs layers.Dense(num_classes, kernel_initializerhe_normal)(x) return tf.keras.Model(inputs, outputs) # ResNet-50 小图版 model build_resnet(Bottleneck, [3, 4, 6, 3], num_classes10, input_shape(32, 32, 3), stemsmall, dropout0.2)用GlobalAveragePooling2D而不是 Flatten 大 Dense是为了把分类头的参数量压到最少。原来 512×42048 维接 1000 类全连接是 200 万参数占了整个网络近十分之一换成 GAP 之后只剩 200 万变 2 万同时自带一点正则效果。这也是为什么它比 Flatten 版本在验证集上通常高半个点。4. 数据管道与训练策略的落地配置4.1 tf.data 的正确搭法模型写对了但数据管道拖后腿是最常见的训练慢得离谱的原因。tf.data的几个操作顺序非常讲究顺序错了性能差好几倍。我固定的写法是shuffle → map(增强) → batch → map(归一化) → prefetch。shuffle要在 batch 之前buffer_size至少等于一个 epoch 的样本数小了会出现明显的块状打乱——同一批里全是同类样本BN 的统计量会偏。map的num_parallel_callstf.data.AUTOTUNE必须加不然数据增强是单线程跑的GPU 有一半时间在等数据。归一化放在 batch 之后做因为对整批张量做除法的效率远高于逐张做。如果你在做一个反复迭代的调参周期cache()绝对是性价比最高的一个操作——第一次 epoch 读盘解码后面全部从内存或者本地缓存拿。但要注意大数据集上cache()到内存会 OOM得缓存到文件。AUTOTUNE tf.data.AUTOTUNE def make_ds(paths, labels, training, batch_size128, num_classes10): ds tf.data.Dataset.from_tensor_slices((paths, labels)) if training: ds ds.shuffle(min(len(paths), 20000), reshuffle_each_iterationTrue) ds ds.map(lambda p, l: load_and_augment(p, l, training), num_parallel_callsAUTOTUNE, deterministicnot training) ds ds.batch(batch_size, drop_remaindertraining) ds ds.map(lambda x, y: (tf.cast(x, tf.float32) / 255.0, tf.one_hot(y, num_classes)), num_parallel_callsAUTOTUNE) return ds.prefetch(AUTOTUNE)drop_remainderTrue在训练集上是必须的。最后一个残缺 batch 会让 BN 的统计量突跳一下同时如果你的损失函数用了标签平滑几个样本的梯度方向噪声会很大。验证集上则要设成 False否则最后几个样本永远不会被评估。4.2 数据增强的取舍增强这件事的原则是增强方式必须和你的任务语义一致。图像分类里水平翻转几乎永远是对的除非是文字识别、方向敏感的任务随机裁剪在 224 分辨率上是标配但在 32x32 上裁 4 个像素就等于丢掉 25% 的信息得慎重。我一般先用padding4再RandomCrop(32)这样裁出来的还是完整内容只是位置平移。颜色类的增强亮度、对比度、饱和度收益比想象中大尤其是在光照条件不一致的真实数据上。MixUp 和 CutMix 我用的频率低一些它们能显著抑制过拟合但会让收敛变慢、需要更多 epoch而且在小数据集上容易让模型学得糊。我的经验是数据量在万级以下、预训练权重可用的情况下少用 MixUp多用几何增强 轻量颜色增强数据量在十万级以上、从头训练MixUp 加上能带来两个点左右的提升。注意增强只在训练集开验证集和测试集必须是纯 resize 归一化。我见过有人把training标志写死成 True验证集也在随机裁剪结果验证精度曲线剧烈震荡排查了大半天。还有一个容易被忽略的点是归一化和预处理的匹配。如果你加载公开的预训练权重它的训练数据用的是特定预处理BGR 通道序、减 ImageNet 均值而 Keras 的preprocess_input默认是 caffe 模式即 RGB→BGR 再减均值。你把[0,1]归一化的图直接喂进去精度会掉十几个点而且现象很迷惑——loss 能降但就是降不到应有的水平。所以要么用配套的预处理函数要么自己在load_and_augment里把通道顺序和均值减到位。4.3 优化器、学习率调度与正则从头训练 ResNet 我用 SGD momentum 0.9 nesterov不用 Adam。原因不是玄学SGD 在卷积网络上泛化性能稳定更好Adam 前期收敛快但最终精度常常差零点几个点而且对权重衰减的处理不如 SGD 干净。学习率按线性缩放规则走基准是 batch 256 配 0.1batch 128 就是 0.05batch 1024 大概 0.4再大就要封顶了不然前几个 epoch 直接发散。调度用 warmup 余弦退火。warmup 的意义是前几百步 BN 的统计量还没稳定直接用大学习率会把权重推到一个糟糕的区域之后再也拉不回来。我一般 warmup 5 个 epoch或者总步数的 5%从 0.01 倍基准学习率线性升到基准值然后余弦退到 0。class WarmupCosine(tf.keras.optimizers.schedules.LearningRateSchedule): def __init__(self, base_lr, total_steps, warmup_steps, min_lr1e-6): super().__init__() self.base_lr, self.total_steps base_lr, total_steps self.warmup_steps, self.min_lr warmup_steps, min_lr def __call__(self, step): step tf.cast(step, tf.float32) warm self.base_lr * step / tf.maximum(1.0, self.warmup_steps) progress (step - self.warmup_steps) / tf.maximum( 1.0, self.total_steps - self.warmup_steps) progress tf.clip_by_value(progress, 0.0, 1.0) cosine self.min_lr 0.5 * (self.base_lr - self.min_lr) * ( 1.0 tf.cos(math.pi * progress)) return tf.where(step self.warmup_steps, warm, cosine)warmup 步数这样算steps_per_epoch ceil(N / batch_size)总步数 steps_per_epoch × epochswarmup_steps steps_per_epoch × 5。比如 5 万张图、batch 128steps_per_epoch是 39140 个 epoch 总步数 15640warmup 就是 1955 步。正则方面三个杠杆标签平滑 0.1、权重衰减、Dropout。标签平滑几乎无脑开它在分类任务上稳定的收益是零点几到一点几个点代价只是让 loss 数值不再能直接解读为概率。权重衰减我用解耦形式AdamW 那种思路现代 Keras 的优化器大多支持weight_decay参数老版本得自己在train_step里对 kernel 手动施加。系数从1e-4起调数据量越大可以越小。Dropout 只加在分类头前面卷积层内部不加——因为 BN 已经承担了正则作用两层正则叠加会让训练变得很慢。4.4 混合精度与 XLA模型上到 50 层、输入 224 的时候混合精度基本是必开的。mixed_float16策略下卷积和矩阵乘走 16 位权重更新和 BN 仍然保持 32 位显存占用能降三四成吞吐提升 1.5 到 2 倍取决于显卡的 Tensor Core 支持。tf.keras.mixed_precision.set_global_policy(mixed_float16)有两个必须注意的点。第一最后一层 Dense 要显式指定dtypefloat32不然 softmax 和交叉熵在 fp16 下容易溢出loss 会变成 NaN。第二BN 层在混合精度下必须跑 fp32新版 Keras 会自动处理但如果你用的是老版本或者自己写了call要手动指定dtypefloat32。另外损失缩放loss scaling新版优化器已经内置不需要像早期那样手动包LossScaleOptimizer。XLA 编译model.compile(..., jit_compileTrue)能再带来 10% 到 20% 的提速但它对动态 shape 非常敏感。如果你的数据管道里最后一个 batch 不是固定大小或者你做了可变尺寸的增强XLA 会反复重编译反而更慢。所以用 XLA 的前提是drop_remainderTrue加固定输入尺寸。5. 训练过程监控、调试与常见故障排查5.1 训练曲线该怎么读我不看单个 epoch 的 loss 数值我看形状。正常的曲线是训练 loss 在前几个 epoch 快速下降然后进入一个斜率稳定的长坡验证 loss 跟着降到某个点开始变平然后缓慢回升这时候该早停或者加强正则训练精度和验证精度的差距在 3 到 8 个点之间属于健康范围。异常的形状有几种典型。训练 loss 完全不降、在原地抖八成是学习率太大或者数据标签错位先降到原来的 1/10 试一次。训练 loss 降但验证 loss 从第一个 epoch 就开始涨那是过拟合不是学习率问题加增强、加权重衰减、减小模型规模。验证 loss 呈锯齿状剧烈震荡第一个怀疑对象是 BN 的滑动统计量没跟上检查 momentum 是不是填成了 0.1PyTorch 语义或者验证时training标志传错了。训练精度很高但验证精度卡在随机水平这几乎一定是数据管道的问题——训练集和验证集的预处理不一致、标签顺序和路径没对上、或者验证集本身被打乱了对应关系。我习惯用 TensorBoard 把学习率曲线和 loss 曲线放在一起看很多时候能直接看出问题比如某个时间点 loss 突然跳高回头一看正好是学习率 warmup 结束、余弦开始的那一步说明基准学习率设得太激进。5.2 常见问题速查表现象最可能原因处理方式loss 变 NaN混合精度下输出层用了 fp16学习率过大输出层与 BN 强制 fp32降 lr 10 倍并加 warmup精度卡在随机水平标签与输入错位预处理与预训练权重不匹配打印一批样本肉眼核对统一预处理训练集精度远高于验证集过拟合增强太弱加 RandAugment、MixUp、weight decay、Dropout验证精度抖动大BN momentum 设置不当验证 batch 太小TF 下 momentum 设 0.9验证 batch 至少 64显存爆batch 太大未开混合精度降 batch 或开 mixed_float16用梯度累积绕过训练极慢数据管道没并行没开 prefetchnum_parallel_callsAUTOTUNEprefetch冻结骨干后精度不涨BN 仍在更新统计量冻结层设trainableFalse后重建模型或用推理模式恢复训练后精度崩优化器状态没保存保存整个 checkpoint 而不是只save_weights5.3 我实际踩过的几个坑第一个坑是冻结骨干做微调时 BN 还在跑。把layer.trainable False只影响梯度不影响 BN 在训练模式下更新滑动均值和方差。如果你冻结了整个 ResNet 只训练分类头而输入分布和预训练数据差得远BN 的统计量会被你的数据慢慢带偏骨干提取的特征就废了。正确做法是把骨干里的 BN 显式设成推理模式或者干脆用一个不带 BN 的冻结头。这个坑的典型表现是冻结之后精度不如不冻结而且越训越差。第二个坑是从 checkpoint 恢复训练后精度断崖下跌。model.save_weights只存权重不存优化器的 momentum 和二阶矩。恢复之后学习率照样是余弦调度中段的值但没有历史动量前几十步的更新会非常粗暴。要么用tf.train.Checkpoint把优化器一起存要么恢复后重新走一小段 warmup。第三个坑是验证集准确率算错了。用model.evaluate配sparse_categorical_accuracy时如果你的标签是 one-hot 的这个指标会静默地算出一个看似合理但完全错误的数字。我有一次就是因为这个把一个实际 82% 的模型报成了 65%白折腾了两天。后来我养成习惯每次训练完都抽 20 张图肉眼比对预测结果。第四个坑跟标签平滑和精度指标的关系有关。开了 label smoothing 之后理论上 loss 的下界不再是 0 而是接近 0.4 左右10 分类、平滑系数 0.1如果你按loss 没降到 0 就是没训好来判断会误以为模型欠拟合。判断标准应该是精度曲线不是 loss 绝对值。6. 迁移学习与模型导出6.1 预训练权重微调的分层策略拿公开预训练权重做迁移我固定用三段式。第一段冻结整个骨干只训分类头学习率用 1e-3跑 5 到 10 个 epoch。这一步的目的不是冲精度而是让新的分类头先落到一个合理的区域别用随机初始化的梯度去污染骨干。第二段解冻最后一到两个 stage学习率降到 1e-4跑 20 个 epoch 左右。这一步在学任务特定的高层特征收益最大。第三段全网络解冻学习率降到 1e-5 到 3e-5再跑 10 个 epoch 收尾同时把数据增强强度调低一点。这个分层策略的逻辑是越浅的层特征越通用边缘、纹理越深的层越任务相关。全网络从一开始就用同一个学习率浅层会被任务特定的梯度带着跑偏等于把预训练的价值浪费掉。我在一个和预训练数据分布差异较大的任务上对比过分层微调比直接全解冻高 3 个多点。还有一个细节输入分辨率要和预训练时接近。用 224 训练的权重去接 32x32 的输入浅层的感受野完全对不上收益会打折扣。如果任务必须用小图要么把图放大到 224代价是计算量要么接受迁移效果打折、更多地依赖从头训练。6.2 导出成 SavedModel 和 TFLite部署环节最容易翻车的是预处理没有跟着模型一起走。导出的模型如果只接受已经归一化的张量而端侧调用方传的是原始像素精度一定会崩而且这种问题在测试环境里往往发现不了。我的做法是把归一化除以 255、通道顺序调整直接包进模型图里导出的模型接受 uint8 输入输出 logits调用方零心智负担。class ServingModel(tf.Module): def __init__(self, backbone): self.backbone backbone tf.function(input_signature[tf.TensorSpec([None, 32, 32, 3], tf.uint8)]) def __call__(self, x): x tf.cast(x, tf.float32) / 255.0 return {logits: self.backbone(x, trainingFalse)} serving ServingModel(model) tf.saved_model.save(serving, export/resnet_small, signatures{serving_default: serving.__call__.get_concrete_function()})转 TFLite 的时候float16量化最简单精度损失通常在 0.2 个点以内模型体积减半。int8全整数量化体积能减到 1/4但需要representative_dataset提供几百张有代表性的图来校准激活范围而且对 BN 的折叠比较敏感——如果 BN 的 epsilon 设得太小比如 1e-8折叠后可能出现数值问题。我的经验是 epsilon 用 1e-5别用更小的值。converter tf.lite.TFLiteConverter.from_saved_model(export/resnet_small) converter.optimizations [tf.lite.Optimize.DEFAULT] converter.target_spec.supported_types [tf.float16] open(resnet_small_fp16.tflite, wb).write(converter.convert())导出之后一定要做一致性校验同一批测试图分别用 Keras 模型和 TFLite 解释器跑一遍逐样本比对 top-1 预测是否一致不一致的比例超过 1% 就要查。我遇到过因为模型里有动态 shape 操作比如tf.image.resize导致 TFLite 转换后行为不一致的情况肉眼看不出来只有对着跑才发现。最后分享一个我一直在用的小习惯每次训练完把模型结构配置、超参、数据版本、最终的验证精度写进一个同目录的meta.json。等你三个月后回头找那个 87.2% 的模型是怎么训出来的会非常感谢当时的自己。手写 ResNet 这件事第一次会花你两天第三次之后就是半小时的事中间省下的时间全在调试上——而调试这件事唯一可靠的办法就是把每次实验的条件记清楚。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑