1. 为什么还要自己搭一个分类网络平台
很多人第一次接触深度学习分类任务,都是直接调一个预训练模型,改改最后的全连接层,跑一遍数据就完事了。这么做当然能出结果,但一旦你想换骨干网络、想对比不同结构在同一份数据上的表现、想搞清楚某个模型到底为什么快为什么慢,就会发现自己手里缺一个能横向拉通的东西。我当初也是被这个问题卡了很久,每次换网络都要重新写一遍数据加载、训练循环、评估逻辑,重复劳动不说,还特别容易在某个细节上写错,导致对比结果根本不可信。
所以后来我干脆花时间搭了一个小型的分类网络平台,把 VGG16、MobileNet、ResNet50 这三个经典结构统一到同一套训练和评估流程里。这样做的价值在于,你换网络只需要改一个字符串参数,数据管道、损失函数、优化器策略、评估指标全部复用,对比出来的结果才有意义。这篇文章就把我搭建这个平台的完整思路和踩过的坑讲清楚,适合已经跑通过一两个分类 Demo、想进一步提升工程效率的读者。如果你还完全没写过训练循环,建议先把最基础的 mnist 分类跑通再来看,不然有些地方会有点吃力。
需要先说明一点,我这里说的“平台”不是什么大型系统,就是一套组织良好的代码结构,核心目标是可替换、可对比、可复现。这三个词看着简单,真正做到位需要不少设计上的取舍,下面会一点点展开。
2. 三个骨干网络到底差在哪里
在动手写代码之前,得先搞清楚 VGG16、MobileNet、ResNet50 这三个网络的设计哲学,不然你只是机械地调用 API,遇到问题根本不知道怎么调。这三个网络恰好代表了三条不同的技术路线,理解了它们,你后面做模型选型会非常有底气。
2.1 VGG16:用堆叠换表达能力的暴力美学
VGG16 的核心思想非常朴素,就是用连续的 3x3 小卷积核堆叠来替代大卷积核。在它之前,很多人还在用 7x7、5x5 的卷积核,VGG 的作者发现,两个 3x3 卷积堆叠起来的感受野等价于一个 5x5,三个 3x3 堆叠等价于一个 7x7,但参数量更少、非线性更强。这个结论在当时是很有冲击力的,它直接奠定了后续很多网络的设计基础。
VGG16 的结构可以分成五段卷积块,每段后面接一个最大池化做下采样,最后接三个全连接层输出分类结果。它的参数量大约在 1.38 亿左右,其中绝大部分参数集中在第一个全连接层,这也是它最大的问题——又大又慢。我在实际测试里发现,同样一批图片,VGG16 的推理耗时差不多是 ResNet50 的两倍多,显存占用也高得离谱。所以现在除非是做特征提取或者教学演示,一般不会把 VGG16 当作首选骨干。
不过 VGG16 有一个隐藏优点:它的结构极其规整,特征层次非常清晰,做迁移学习的时候,前面几层提取的边缘、纹理特征泛化性很好。我在一些小样本任务上试过,冻结前面的卷积层只训练后面,效果反而比一些更复杂的网络稳定。
2.2 MobileNet:为效率而生的深度可分离卷积
MobileNet 要解决的核心问题是在算力受限的设备上也能跑得动。它的杀手锏是深度可分离卷积,把一个标准卷积拆成两步:先对每个通道单独做卷积(深度卷积),再用 1x1 卷积把通道信息融合起来(逐点卷积)。这个拆解能把计算量降到原来的大概八分之一到九分之一,参数量也大幅下降。
我拿实际数据说话:标准 3x3 卷积在输入输出通道都是 256 的时候,计算量大概是 256×256×3×3×H×W;换成深度可分离卷积后,深度卷积部分是 256×3×3×H×W,逐点卷积是 256×256×H×W,加起来差不多是原来的九分之一。这个差距在移动端是决定性的。
MobileNet 还有两个超参数可以调,一个是宽度乘子,用来整体缩放每层的通道数,另一个是分辨率乘子,用来缩放输入图片尺寸。这两个参数让你可以在精度和速度之间做精细的权衡。我在做对比实验的时候,经常用宽度乘子 0.5 的版本先快速验证想法,确认方向对了再上完整版本,能省下大量等待时间。
2.3 ResNet50:残差连接解决深层退化
ResNet 要解决的是一个反直觉的问题:网络越深,效果反而越差。这不是过拟合,因为在训练集上的误差也变大了,作者把这个现象叫做退化。理论上更深的网络至少不应该比浅的差,因为多出来的层可以学成恒等映射,但实际训练中很难学到。
残差连接的做法是让网络学习残差映射,也就是输出等于输入加上一个残差函数。如果最优映射接近恒等,网络只需要把残差学成接近零就行,这比直接学恒等映射容易得多。这个设计让训练上百层甚至上千层的网络成为可能。
ResNet50 里的 50 指的是带权重的层数,它用了瓶颈结构,也就是 1x1 降维、3x3 卷积、1x1 升维的组合,在保持表达能力的同时控制计算量。相比 VGG16,ResNet50 参数量只有约 2500 万,但精度更高、速度更快,这也是它成为工业界默认骨干的原因之一。
下面这张表是我在相同输入尺寸 224x224、相同批次大小下实测的粗略对比,供你选型时参考:
| 网络 | 参数量 | 单张推理耗时(相对值) | 显存占用 | 适合场景 |
|---|---|---|---|---|
| VGG16 | 约 1.38 亿 | 2.3 | 高 | 教学、特征提取 |
| MobileNet | 约 420 万 | 0.4 | 低 | 移动端、实时任务 |
| ResNet50 | 约 2500 万 | 1.0 | 中 | 通用分类、迁移学习 |
注意:上面的耗时是相对值,具体数字跟你的硬件、框架版本、是否开启混合精度都有关系,不要当成绝对值用。
3. 平台骨架怎么设计才不别扭
理解了三个网络之后,接下来是工程层面的设计。我见过不少人把代码写成一个大文件,里面全是 if-else 判断用哪个网络,改起来痛苦不堪。我踩过这个坑之后,总结出一套相对清爽的组织方式,核心思路是把变化的部分和不变的部分彻底分开。
3.1 用注册表模式管理骨干网络
最直接的做法是写一个字典,把网络名字映射到构建函数上。这样新增网络只需要加一行注册,不用动训练逻辑。我一开始觉得这有点过度设计,但当我加到第五个网络的时候,就庆幸自己这么做了。
BACKBONE_REGISTRY = {} def register_backbone(name): def wrapper(func): BACKBONE_REGISTRY[name] = func return func return wrapper @register_backbone("vgg16") def build_vgg16(num_classes, input_shape): # 构建逻辑 ... @register_backbone("mobilenet") def build_mobilenet(num_classes, input_shape): ... @register_backbone("resnet50") def build_resnet50(num_classes, input_shape): ...用装饰器注册的好处是,构建函数和注册动作写在一起,不容易漏掉。调用的时候只需要BACKBONE_REGISTRY[name](num_classes, input_shape),训练代码完全不用关心具体是哪个网络。
这里有个细节要注意:不同网络对输入尺寸的要求不一样。VGG16 和 ResNet50 通常用 224x224,MobileNet 可以接受更小的输入。如果你的数据管道写死了尺寸,换网络的时候就会报错。我的做法是把输入尺寸也作为构建参数传进去,让每个网络自己决定怎么处理。
3.2 数据管道要独立于模型
数据加载这块,我强烈建议用tf.data而不是自己写生成器。tf.data的预取和并行处理能力是手写生成器很难比的,尤其是在机械硬盘上读大量小文件的时候,差距非常明显。
一个典型的管道长这样:
def build_dataset(file_paths, labels, batch_size, input_shape, is_training): dataset = tf.data.Dataset.from_tensor_slices((file_paths, labels)) def parse_func(path, label): image = tf.io.read_file(path) image = tf.image.decode_jpeg(image, channels=3) image = tf.image.resize(image, input_shape[:2]) image = tf.cast(image, tf.float32) / 255.0 return image, label dataset = dataset.map(parse_func, num_parallel_calls=tf.data.AUTOTUNE) if is_training: dataset = dataset.shuffle(buffer_size=1000) dataset = dataset.map(augment, num_parallel_calls=tf.data.AUTOTUNE) dataset = dataset.batch(batch_size) dataset = dataset.prefetch(tf.data.AUTOTUNE) return dataset这里有几个我踩过的坑。第一,num_parallel_calls一定要设成AUTOTUNE,不然数据加载会成为瓶颈,GPU 利用率上不去。第二,数据增强要放在 batch 之前还是之后,取决于你用的是什么增强,像随机裁剪这种改变尺寸的操作必须在 batch 之前做。第三,prefetch几乎是必加的,它能让数据准备和模型计算重叠起来,实测能提升百分之二三十的吞吐。
3.3 训练循环的统一抽象
训练循环里变化最大的其实是优化器和学习率策略。我的做法是把这些也做成可配置的,用一个配置字典驱动整个训练过程。
def train_one_epoch(model, dataset, optimizer, loss_fn, metrics): for images, labels in dataset: with tf.GradientTape() as tape: predictions = model(images, training=True) loss = loss_fn(labels, predictions) gradients = tape.gradient(loss, model.trainable_variables) optimizer.apply_gradients(zip(gradients, model.trainable_variables)) metrics.update_state(labels, predictions) return metrics.result()这段代码看着简单,但有几个关键点。第一,training=True必须显式传,否则 BatchNorm 和 Dropout 的行为会不对,这个坑我见过太多人踩。第二,GradientTape默认只跟踪一次,如果你需要在同一个 tape 里多次计算梯度,要加persistent=True。第三,指标更新要在梯度更新之后,不然统计的是旧参数下的结果。
4. 迁移学习时冻结层到底怎么冻
搭好平台之后,真正让它发挥价值的是迁移学习。但冻结层这件事,看着简单,实际操作起来有不少门道。
4.1 冻结的粒度选择
最粗的粒度是冻结整个骨干网络,只训练最后的分类头。这种做法适合新数据集和原始数据集差异很大、且数据量很小的情况。我一般会在数据量少于一千张的时候用这个策略,训练非常快,几分钟就能看到初步结果。
中等粒度是冻结前面的卷积块,只训练后面的块和分类头。VGG16 和 ResNet50 都可以按块来冻,MobileNet 因为结构比较紧凑,通常按深度可分离卷积单元来冻。这个策略适合数据量中等、且新数据和原始数据有一定相似性的场景。
最细的粒度是只冻结 BatchNorm 层,其他层都参与训练。这个做法在数据量充足的时候效果最好,但要注意学习率要调小,不然预训练权重会被破坏得太快。
4.2 冻结之后 BatchNorm 的行为陷阱
这是我最想强调的一个坑。当你冻结一个卷积块的时候,如果这个块里有 BatchNorm 层,你必须把它的trainable设为 False,但更重要的是,在调用模型的时候要确保 BatchNorm 处于推理模式。在 TensorFlow 里,这取决于你传的training参数。
如果你用model(x, training=True),即使 BatchNorm 层的权重被冻结了,它仍然会用当前 batch 的统计量来归一化,这会破坏预训练时学到的统计信息。正确的做法是,在冻结阶段用training=False调用骨干网络,只让分类头用training=True。这个细节非常隐蔽,我当初调了很久才发现问题出在这里。
def call(self, inputs, training=None): features = self.backbone(inputs, training=False) # 骨干始终推理模式 outputs = self.classifier(features, training=training) return outputs4.3 解冻时机的判断
什么时候解冻,没有绝对标准,但有几个信号可以参考。如果训练损失下降很慢,验证损失已经连续多个 epoch 不降,说明当前冻结策略的表达能力到顶了,可以考虑解冻一部分。如果验证损失开始上升,说明过拟合了,这时候解冻反而可能更糟,应该先加正则化。
我自己的习惯是先用冻结策略跑一轮,记录下最佳验证精度,然后解冻最后两个块,用更小的学习率再跑一轮,对比两次的结果。多数情况下解冻会带来一两个点的提升,但如果数据量太小,解冻反而会掉点。
5. 实测中那些让人抓狂的问题
平台搭好不代表就一帆风顺了,实际跑起来会遇到各种奇怪的问题。这一节我把印象最深的几个问题整理出来,附上排查思路,希望能帮你少走弯路。
5.1 损失不下降的排查链路
损失不下降是最常见也最让人焦虑的问题。我的排查顺序是这样的:先看数据,随机抽几张图片和标签打印出来,确认图片没读错、标签没对错位。这一步能排除掉大部分低级错误,我就遇到过因为文件名排序和标签排序不一致导致标签全错的情况。
数据没问题之后,看模型输出。用一个极小的数据集,比如十张图片,让模型去过拟合。如果连十张图片都过拟合不了,那肯定是模型结构或者损失函数有问题。我遇到过因为分类头输出维度写错,导致所有预测都落在同一个类上的情况。
模型能过拟合小数据之后,再看学习率。学习率太大会导致损失震荡甚至发散,太小会导致下降极慢。我一般会先用一个中等学习率跑几百步,观察损失曲线,如果震荡就调小,如果几乎不动就调大。
5.2 显存溢出的几种典型情况
显存溢出在换网络的时候特别容易遇到,因为不同网络的显存占用差异很大。VGG16 在批次大小 32 的时候可能就爆了,而 MobileNet 在同样批次下还很宽裕。
除了批次大小,还有几个容易被忽略的显存杀手。一是输入尺寸,224 和 320 的显存占用差将近一倍。二是数据增强里的某些操作,比如大范围的随机旋转会引入额外的中间张量。三是梯度累积,如果你手动实现梯度累积,要注意中间变量的释放。
我的应对策略是写一个自动降批次的逻辑,捕获显存溢出异常后把批次减半重试,直到能跑通为止。这样换网络的时候不用手动调批次,省心很多。
5.3 评估指标和训练指标对不上的原因
有时候训练时看到的准确率很高,但用model.evaluate评估出来却低不少。这个问题通常出在 Dropout 和 BatchNorm 上。训练时它们是活跃的,评估时应该关闭,如果你评估的时候忘了传training=False,结果就会对不上。
另一个原因是数据管道在训练和评估时的行为不一致。比如训练时有 shuffle 和增强,评估时没有,如果你评估时用的还是训练管道,增强操作会引入随机性,导致每次评估结果都不一样。我的做法是训练和评估用两个独立的管道构建函数,参数上明确区分。
6. 三个网络在同一份数据上的真实表现
说了这么多设计层面的东西,最后还是得看实际效果。我用一份中等规模的数据集,大概两万张图片、十个类别,在相同训练策略下跑了这三个网络,记录了一些观察。
VGG16 收敛最慢,前几个 epoch 损失下降明显比另外两个慢,但一旦收敛,最终的训练精度并不差。它的主要问题是太吃资源,同样的批次大小,训练一个 epoch 的时间差不多是 ResNet50 的一点五倍。如果你的硬件有限,VGG16 真的不是好选择。
MobileNet 收敛最快,前两个 epoch 就能看到明显的精度提升,这跟它参数量小、优化起来容易有关系。但它的最终精度比 ResNet50 低了大概两个点,这个差距在类别更多、更细的任务上可能会更大。如果你的场景对延迟敏感,这两个点的差距是可以接受的。
ResNet50 的表现最均衡,收敛速度适中,最终精度最高,资源占用也在可接受范围内。如果只能选一个,我一般会默认用 ResNet50。它的残差结构让训练过程很稳定,不太会出现损失突然爆炸的情况,这对新手来说很友好。
有一点要提醒,上面的观察是在特定数据集和特定超参数下得到的,换一份数据结论可能完全不同。比如在类别非常少、图片非常简单的任务上,MobileNet 完全可能追平甚至超过 ResNet50。所以不要把这些结论当成铁律,自己动手对比一遍才是最有价值的。
7. 把这套平台用起来的几个建议
平台搭好只是开始,怎么用好它才是关键。我分享几个自己总结的实用建议。
第一,每次实验都要记录配置。我见过太多人跑了一堆实验,最后忘了哪个结果对应哪组参数。我的做法是把网络名、学习率、批次大小、冻结策略这些关键参数拼成一个字符串作为实验名,所有日志和模型文件都带上这个名字,回溯起来非常方便。
第二,先用小数据快速验证。不要一上来就用全量数据跑,先取十分之一的数据,把整个流程跑通,确认没有低级错误,再上全量。这个习惯帮我省下了大量等待时间。
第三,对比实验要控制变量。如果你想对比三个网络,除了网络本身,其他所有参数都要保持一致,包括数据划分、随机种子、学习率策略。不然你根本不知道性能差异是网络带来的还是参数带来的。
第四,善用回调函数。TensorFlow 的ModelCheckpoint和EarlyStopping能帮你自动保存最佳模型和提前终止训练,不用自己写逻辑。我一般会同时用这两个,再配一个TensorBoard回调,训练过程可视化之后,很多问题一眼就能看出来。
这套平台我用了挺长时间,中间也重构过几次,每次重构都是因为遇到了新的需求。它不是什么一劳永逸的东西,但有了这个骨架,每次加新网络或者新策略都只是小改动,不会再像以前那样推倒重来。如果你也在做类似的对比实验,希望这些经验能帮你少踩几个坑。