☰
基于MobileNetV2的迁移学习实战:猴子图片分类微调全流程
2026/10/13 14:01:54 网站建设 项目流程

1. 项目背景与整体思路拆解

1.1 为什么选这个数据集做微调练习

十种猴子图片分类这个数据集,在迁移学习圈子里算是经典练手材料了。它一共十个类别,每类大概一百多张图,训练集总量在一千三百张左右,验证集一百多张。数据量不大,但类别之间的视觉差异有时候挺微妙,比如某些猴子的面部特征、毛色、体型在不同角度下会混淆。拿它来练微调,好处是训练速度快,单卡几十分钟就能跑完一轮完整实验,特别适合反复调整参数、观察效果。

我当初选这个数据集,核心目的不是追求多高的准确率,而是想走通一条完整的迁移学习链路:加载预训练权重、替换分类头、冻结部分层、解冻微调、数据增强、学习率调度、模型保存与推理。这条链路走通了,换成其他数据集,比如花卉分类、食物分类、甚至工业缺陷检测,套路基本一致,只是数据加载和类别数改一改。

从零基础的角度看,这个项目能帮你建立几个关键认知:预训练模型到底在学什么、为什么微调比从头训练更稳、冻结与解冻的时机怎么把握、学习率为什么要设得很小。这些认知比单纯跑出一个高准确率重要得多。

1.2 整体方案选型与背后的考量

方案上我选的是MobileNetV2作为骨干网络。为什么不用ResNet50或者EfficientNet?原因很直接:这个数据集太小了,一千多张图,参数量太大的模型容易过拟合,而且训练慢。MobileNetV2参数量大概三百多万,深度可分离卷积的设计让它在小数据集上表现相当稳,推理速度也快,适合快速迭代实验。

迁移学习的策略分两阶段走。第一阶段,冻结骨干网络的所有层,只训练新加的分类头。这一步的目的是让随机初始化的分类层先收敛到一个合理范围,避免一开始就用大梯度去破坏预训练权重。第二阶段,解冻骨干网络的后半部分,用极小的学习率做微调。这里有个经验:解冻的层数不要太多,一般解冻最后二十到三十层就够了,前面的底层特征(边缘、纹理)是通用的,没必要动。

数据增强方面,我用了随机水平翻转、随机旋转、随机缩放和随机对比度调整。注意,猴子的图片水平翻转是合理的,因为猴子左右对称;但垂直翻转就不合适了,倒过来的猴子在现实里不存在,反而会引入噪声。这个细节很多人容易忽略。

学习率调度用的是余弦退火配合预热。预热阶段让学习率从接近零慢慢升到初始值,避免训练初期的不稳定;余弦退火则让学习率平滑下降,帮助模型在后期收敛到更优的局部极小值。

2. 核心细节解析与实操要点

2.1 数据管道的搭建与增强策略

数据加载这块,我用的是tf.keras.preprocessing.image.ImageDataGenerator配合flow_from_directory。虽然TensorFlow 2.x推荐用tf.data,但对于这种小数据集,ImageDataGenerator写起来更快,代码量少,调试直观。目录结构按类别分文件夹,每个文件夹里放对应类别的图片,这是最省事的组织方式。

增强参数我设了这些:rotation_range=20,width_shift_range=0.1,height_shift_range=0.1,zoom_range=0.15,horizontal_flip=True,brightness_range=[0.8, 1.2]。旋转角度控制在二十度以内,因为猴子面部朝向变化不会太夸张;缩放范围十五个百分点,模拟不同距离拍摄;亮度调整模拟不同光照条件。

注意:增强只对训练集做,验证集和测试集绝对不能做增强,否则评估结果会失真。验证集的预处理只做归一化和尺寸调整。

输入尺寸我统一缩放到224×224。MobileNetV2原生支持224×224,虽然它也能接受其他尺寸,但用原生尺寸能最大程度保留预训练权重的特征提取能力。归一化用的是preprocess_input,这个函数会把像素值从[0, 255]映射到[-1, 1],和MobileNetV2训练时的预处理保持一致。如果你自己写归一化,比如除以255,反而会和预训练权重不匹配,导致效果下降。

批量大小设的是32。这个数据集小,批量太大容易导致梯度更新次数太少,收敛慢;批量太小则训练不稳定。32是一个比较平衡的值,显存占用也不高,6GB显存的卡就能跑。

2.2 模型构建与层冻结的细节

加载MobileNetV2的时候,include_top=False,weights='imagenet',input_shape=(224, 224, 3)。include_top=False表示不要原来的全连接分类层,因为ImageNet是一千类,我们只要十类。weights='imagenet'表示加载在ImageNet上预训练好的权重,这是迁移学习的核心。

然后在骨干网络后面接一个GlobalAveragePooling2D层,再接一个Dropout(0.3),最后接Dense(10, activation='softmax')。GlobalAveragePooling2D比Flatten更适合小数据集,因为它直接对每个特征图求平均,参数量为零,能有效降低过拟合风险。Dropout设0.3,不算高,因为数据集小,正则化太强反而欠拟合。

冻结策略上,第一阶段把骨干网络所有层的trainable设为False。这里有个坑:设置完之后一定要重新编译模型,否则trainable的更改不会生效。我见过不少人改了trainable直接开始训练,结果发现骨干网络还在更新,就是因为忘了重新编译。

第二阶段解冻时,我选择解冻最后三十层。具体操作是遍历骨干网络的层,从倒数第三十层开始,把trainable设为True。注意BatchNormalization层要特别处理:即使解冻了,也建议把BN层保持冻结状态,因为小批量数据下BN的统计量不稳定,解冻后容易导致性能崩塌。这个细节在官方文档里不会强调,但实际做微调时非常关键。

2.3 学习率与优化器的选择逻辑

第一阶段用的优化器是Adam,学习率设1e-3。Adam自适应学习率,适合分类头这种随机初始化的层快速收敛。第二阶段换成SGD,学习率设1e-5,动量0.9。为什么换SGD?因为微调阶段需要更精细的梯度控制,SGD配合小学习率能让模型在预训练权重附近做微小调整,而Adam的自适应特性在微调时反而可能让更新步长过大,破坏已经学好的特征。

学习率调度我用的是ReduceLROnPlateau,监控验证集准确率,耐心值设3,因子0.5。也就是说,验证准确率连续三轮不提升,学习率减半。这个策略比固定学习率更灵活,能根据训练动态自动调整。

实操心得:微调阶段的学习率千万不要设大。我试过用1e-4做微调,结果验证准确率直接掉到随机水平,因为大学习率把预训练权重冲垮了。后来改成1e-5,甚至5e-6,才稳定提升。这个教训很深刻:预训练权重是宝贝,微调时要像对待古董一样小心。

3. 实操过程与核心环节实现

3.1 环境准备与依赖安装

环境我用的是Python 3.8,TensorFlow 2.6,CUDA 11.2,cuDNN 8.1。这些版本组合经过实测比较稳定。安装命令很简单:

pip install tensorflow==2.6.0 pip install numpy pandas matplotlib scikit-learn

如果你没有GPU,用CPU也能跑,只是训练时间会从几十分钟变成几个小时。建议至少用Google Colab的免费GPU,或者本地有一块6GB以上显存的N卡。

数据下载后,目录结构整理成这样:

data/ train/ n0/ n1/ ... n9/ val/ n0/ n1/ ... n9/

每个类别文件夹里放对应的图片。类别名称用n0到n9代替,避免中文路径带来的编码问题。

3.2 第一阶段训练:只训练分类头

代码骨架如下:

import tensorflow as tf from tensorflow.keras.applications import MobileNetV2 from tensorflow.keras import layers, models, optimizers base_model = MobileNetV2( input_shape=(224, 224, 3), include_top=False, weights='imagenet' ) base_model.trainable = False model = models.Sequential([ base_model, layers.GlobalAveragePooling2D(), layers.Dropout(0.3), layers.Dense(10, activation='softmax') ]) model.compile( optimizer=optimizers.Adam(learning_rate=1e-3), loss='categorical_crossentropy', metrics=['accuracy'] ) history = model.fit( train_generator, epochs=15, validation_data=val_generator, callbacks=[ tf.keras.callbacks.ModelCheckpoint( 'best_stage1.h5', monitor='val_accuracy', save_best_only=True ), tf.keras.callbacks.EarlyStopping( monitor='val_accuracy', patience=5, restore_best_weights=True ) ] )

第一阶段我跑了十五轮,实际大概在第十轮左右验证准确率就到顶了。训练集准确率能到百分之九十五以上,验证集大概在百分之八十五到九十之间。这个差距说明分类头已经拟合得不错,但骨干网络的特征还没有针对这个数据集调整。

注意:ModelCheckpoint保存的是验证准确率最高的模型,不是最后一轮的模型。这个细节很重要,因为后期可能过拟合,最后一轮的模型未必是最好的。

3.3 第二阶段训练:解冻部分层做微调

解冻代码:

base_model.trainable = True for layer in base_model.layers[:-30]: layer.trainable = False for layer in base_model.layers: if isinstance(layer, layers.BatchNormalization): layer.trainable = False model.compile( optimizer=optimizers.SGD(learning_rate=1e-5, momentum=0.9), loss='categorical_crossentropy', metrics=['accuracy'] ) history_fine = model.fit( train_generator, epochs=30, validation_data=val_generator, callbacks=[ tf.keras.callbacks.ModelCheckpoint( 'best_stage2.h5', monitor='val_accuracy', save_best_only=True ), tf.keras.callbacks.ReduceLROnPlateau( monitor='val_accuracy', factor=0.5, patience=3, min_lr=1e-7 ), tf.keras.callbacks.EarlyStopping( monitor='val_accuracy', patience=8, restore_best_weights=True ) ] )

第二阶段我设了三十轮,实际大概在第二十轮左右收敛。验证准确率从第一阶段的百分之八十八左右提升到百分之九十三到九十五。提升幅度不算特别大,但这是正常的,因为MobileNetV2在ImageNet上学到的特征本身就很强,微调只是做局部适配。

微调过程中我观察到一个现象:前五轮验证准确率几乎没变化,甚至略有下降,然后从第六轮开始慢慢爬升。这是因为解冻后模型需要几轮来适应新的梯度分布。如果你看到前几轮没提升就放弃了,那就亏大了。耐心等一等,通常十轮之内会看到效果。

3.4 模型评估与推理验证

训练完成后,加载保存的最佳模型,在验证集上跑一遍混淆矩阵:

from sklearn.metrics import confusion_matrix, classification_report import numpy as np model = tf.keras.models.load_model('best_stage2.h5') val_generator.reset() preds = model.predict(val_generator, verbose=1) pred_classes = np.argmax(preds, axis=1) true_classes = val_generator.classes print(classification_report(true_classes, pred_classes)) print(confusion_matrix(true_classes, pred_classes))

从混淆矩阵里能看到哪些类别容易混淆。我跑下来发现,有两类猴子因为毛色接近,互相误判的比例稍高。针对这种情况,可以专门对这两类做更多数据增强,或者收集更多这两类的图片。

推理单张图片的代码:

from tensorflow.keras.preprocessing import image img = image.load_img('test.jpg', target_size=(224, 224)) img_array = image.img_to_array(img) img_array = np.expand_dims(img_array, axis=0) img_array = tf.keras.applications.mobilenet_v2.preprocess_input(img_array) pred = model.predict(img_array) pred_class = np.argmax(pred) print(f'预测类别: {pred_class}, 置信度: {pred[0][pred_class]:.4f}')

实操心得:推理时的预处理必须和训练时完全一致。我见过有人训练时用了preprocess_input,推理时忘了用,结果预测全错。这种低级错误排查起来很费时间,建议把预处理封装成一个函数,训练和推理都调用同一个函数。

4. 常见问题与排查技巧实录

4.1 训练不收敛或准确率上不去

这是最常见的问题。排查顺序我一般是这样:

第一,检查数据标签是否正确。用train_generator.class_indices打印类别映射,确认文件夹名称和标签对应关系。有时候文件夹命名混乱,导致标签错位,模型学出来的东西完全是乱的。

第二,检查预处理是否一致。训练用了preprocess_input,验证和推理也必须用。如果训练用了归一化到[0,1],验证用了[-1,1],模型看到的分布不一致,准确率肯定上不去。

第三,检查学习率是否过大。第一阶段1e-3通常没问题,但如果你的批量很小,比如8或者16,1e-3可能偏大,试试5e-4。第二阶段1e-5是安全值,如果还是不稳定,降到1e-6。

第四,检查冻结是否生效。打印len(model.trainable_variables),第一阶段应该只有分类头的几个变量,如果数量很多,说明冻结没生效,检查是否重新编译了模型。

4.2 过拟合的识别与应对

过拟合的典型表现是训练准确率持续上升,验证准确率停滞甚至下降。我跑这个数据集时,第一阶段后期就出现了轻微过拟合,训练准确率百分之九十八,验证准确率百分之八十七,差距十一个百分点。

应对手段有几个:增加Dropout比例,从0.3提到0.5;增加数据增强强度,比如把旋转范围从20度提到30度;加L2正则化,在Dense层加kernel_regularizer=l2(1e-4);减少解冻层数,从三十层减到二十层。

我试过把Dropout提到0.5,验证准确率提升了大概一个百分点,但训练准确率下降明显,说明正则化起作用了。不过Dropout太高也会导致欠拟合,0.5算是上限了,再高就不建议了。

4.3 显存不足与批量调整

显存不足报错通常是ResourceExhaustedError。解决办法最直接的是减小批量。批量从32降到16,显存占用大概减半。如果还不行,降到8。批量太小会影响BN层的统计量,但MobileNetV2的BN层在微调时是冻结的,所以批量小一点问题不大。

另一个办法是减小输入尺寸,从224降到192或者160。但这样会损失一些细节,准确率可能下降一两个百分点。如果显存实在紧张,可以试试混合精度训练,用tf.keras.mixed_precision.set_global_policy('mixed_float16'),显存占用能减少大概百分之三十,训练速度也能提升。

4.4 常见问题速查表

问题现象可能原因排查方法解决方案
训练准确率不上升学习率过小或标签错误打印标签映射,检查学习率调整学习率,修正标签
验证准确率远低于训练过拟合对比训练和验证曲线增加Dropout,增强数据
微调后准确率暴跌学习率过大检查第二阶段学习率降到1e-5或更低
显存不足批量或模型过大查看报错信息减小批量或输入尺寸
推理结果全错预处理不一致对比训练和推理预处理统一预处理函数
BN层导致不稳定BN层被解冻检查BN层trainable状态冻结所有BN层

避坑技巧:微调时如果验证准确率突然暴跌,不要慌,先回滚到上一个检查点,把学习率减半再试。我遇到过好几次这种情况,都是学习率偏大导致的。另外,保存检查点的频率要高一些,比如每轮都保存,这样出问题能快速回滚。

5. 扩展思路与进阶方向

5.1 换骨干网络的对比实验

MobileNetV2跑通之后,可以试试换成EfficientNetB0或者ResNet50V2,对比一下准确率和训练时间。EfficientNetB0参数量更小,但结构更复杂,在小数据集上表现有时候比MobileNetV2好。ResNet50V2参数量大,容易过拟合,需要更强的正则化。

我做过一组对比:MobileNetV2验证准确率百分之九十四,EfficientNetB0百分之九十五,ResNet50V2百分之九十二。EfficientNetB0略好,但训练时间多了大概百分之四十。如果追求极致准确率,可以上EfficientNet;如果追求速度和部署便利,MobileNetV2更合适。

5.2 部署到移动端或边缘设备

MobileNetV2本身就是为移动端设计的,转成TFLite格式后模型大小只有几兆,推理速度在手机上能到几十毫秒。转换命令:

converter = tf.lite.TFLiteConverter.from_keras_model(model) tflite_model = converter.convert() with open('model.tflite', 'wb') as f: f.write(tflite_model)

转换后可以用Netron查看模型结构,确认输入输出节点。部署到安卓或者树莓派上,就能做一个实时的猴子识别应用。这个扩展方向挺有意思,能把训练好的模型真正用起来。

5.3 用tf.data替换ImageDataGenerator

ImageDataGenerator虽然方便,但性能不如tf.data。大数据集下tf.data的并行加载和预取能显著提升训练速度。替换思路是用tf.keras.preprocessing.image_dataset_from_directory加载数据,然后用.map()做增强,.prefetch()做预取。代码量稍多,但训练速度能提升百分之二十到三十。

这个优化在小数据集上感知不明显,但如果你以后要处理几万张图片的数据集,tf.data的优势就体现出来了。建议在这个项目跑通之后,花点时间把数据管道改成tf.data版本,算是为以后打基础。

我个人在实际操作中的体会是,迁移学习最核心的不是模型结构多复杂,而是把数据管道、预处理、学习率、冻结策略这几个环节的细节做扎实。这个猴子分类项目虽然简单,但把每个环节都走一遍,踩一遍坑,比看十篇教程都管用。后面再遇到新的分类任务,无非是换个数据集、调调参数,底层逻辑是一样的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询