先聊个真实的场景。我之前接到一个图像分类的任务,数据只有五千多张图、十个类别,设备也只是一块普通的消费级显卡。如果从零开始训一个残差网络,大概率是过拟合加训练时间漫长,调参调到怀疑人生。但同样是这个任务,换成熟练的预训练模型来做迁移学习,两天内就出了可用版本。这篇文章我就拿经典的VGG16来说说,在TensorFlow里怎么加载预训练权重、怎么把它接到自己的分类任务上,以及那些文档里不会写、但训练时几乎必踩的坑。
1. VGG16不是最前沿的模型,为什么我仍推荐用它做迁移学习
1.1 迁移学习到底迁移的是什么
很多初学者有个误解,以为迁移学习就是把别人训练好的模型拿过来直接预测。真这么想就完全跑偏了。
预训练模型在ImageNet这种千万级数据集上完成训练后,它的卷积层已经学会了一套通用的视觉特征提取能力。底层卷积核学会的是边缘、颜色、纹理这些最基础的视觉元素,中层学会的是形状、局部结构,高层则能识别出眼睛、轮子这类零部件级别的特征。这套特征提取能力和你最终要做的任务是什么关系不大,因为不管是识别猫狗、判断工业零件缺陷,还是做农作物病害分类,底层视觉特征都是通用的。
所以迁移学习的基本逻辑是:把预训练模型当作一个已经被海量数据“调教”好的特征提取器,只更换最后负责分类的那部分,然后用自己的数据去训练新的分类器。对于数据量有限的项目来说,这相当于站在巨人的肩膀上。
1.2 VGG16的结构里,哪些部分能留,哪些必须换
VGG16由两部分组成:13个卷积层构成的特征提取部分,以及3个全连接层构成的分类部分。用框架自带的VGG16接口时,include_top=False参数的作用就是把后面的全连接分类部分整个切掉,只留下卷积层。
为什么要切掉而不直接用完整的网络来微调?原因在于原始的3个全连接层是中看不中用的。第一个全连接层有超过1亿个参数,参数量比前面所有卷积层加起来还多,训练时极易过拟合,而且ImageNet的1000类输出对你的任务来说根本没有意义。我们要做的是“换头”,而不是“改装全身”。
1.3 什么样的情况适合用VGG16
一个被我反复验证的判断标准:任务数据量在一万张以内时,从头训练几乎不会有好结果,而预训练模型往往能快速收敛到实用的精度。如果数据量很大,比如几十万张,那预训练模型可能反而限制了模型的能力上限,此时再从零训练或者用更现代的大模型会更合适。
VGG16的实际效果放在今天的基准测试里确实不算最强,但它的结构规整、特征可视化容易、在中小数据集上表现稳定,作为讲清楚迁移学习原理的模板再合适不过。把本文这套流程跑通了,换成别的预训练模型只是改一行加载代码的事。
2. 数据准备:喂给VGG16的图片有哪些硬性规矩
2.1 输入尺寸与归一化:别只写个rescale=1./255就完事
用VGG16必须先理解一个前提:这个网络在ImageNet上训练时输入图片是224×224的RGB三通道图。你在使用预训练权重时,输入尺寸必须保持一致,否则网络内部的卷积层和池化层计算出的特征图尺寸会乱掉,而且预训练权重自适应的也是这个尺寸下的特征分布。
一个非常常见的错误是,很多人处理数据时直接用Rescale(1./255)把像素归一化到0到1之间。这个做法对于从头训练的模型没问题,但对于VGG16这类ImageNet预训练模型来说并不合适,原因在于预训练权重的统计特性是在ImageNet数据集的特定预处理方式下形成的。
标准的做法是使用框架提供的preprocess_input函数。这个函数会把RGB通道的像素值从0到255的取值范围转换成ImageNet数据集的统计特征范围——具体来说是先减去各通道的均值,再除以标准差。这么做之后,输入数据的分布才能和预训练权重当初学习时的数据分布对齐。
2.2 目录结构和数据集加载配置
我习惯在训练之前把数据整理成这样的目录结构:
dataset/ train/ class_a/xxx.jpg class_b/xxx.jpg val/ class_a/yyy.jpg class_b/yyy.jpg对应到代码里,最省事的加载方式就是ImageDataGenerator搭配flow_from_directory:
from tensorflow.keras.preprocessing.image import ImageDataGenerator from tensorflow.keras.applications.vgg16 import preprocess_input train_datagen = ImageDataGenerator( preprocessing_function=preprocess_input, rotation_range=20, width_shift_range=0.2, height_shift_range=0.2, horizontal_flip=True, zoom_range=0.2 ) val_datagen = ImageDataGenerator( preprocessing_function=preprocess_input ) train_generator = train_datagen.flow_from_directory( 'dataset/train', target_size=(224, 224), batch_size=32, class_mode='categorical' ) val_generator = val_datagen.flow_from_directory( 'dataset/val', target_size=(224, 224), batch_size=32, class_mode='categorical' )注意验证集上的生成器除了preprocess_input以外不要做任何增强操作,因为数据增强会造成验证集评估结果失真。评估时我们需要看到一个稳定的、可复现的准确率。
你可能会问,target_size=(224, 224)是不是会把图片拉伸变形?是的,如果原图不是正方形,确实会被强行缩放到正方形,但这是绝大多数预训练模型的输入约定,除非你用crop之类的策略先裁剪中心区域再缩放,否则只能接受这个操作。实际跑起来之后你会发现,对于VGG16这类模型,输入尺寸带来的几何形变问题远没有很多人想象中那么致命,因为预训练权重见过足够多的同类图像,鲁棒性其实还不错。
2.3 数据增强的分寸怎么把握
数据增强的目的是让模型看到更多样的样本,从而降低过拟合风险。但增强幅度过猛也会适得其反,图像语义被破坏,比如把“猫”增强成看不出猫的形状,模型反而学不到有效特征。
我的经验是,对于中小数据量,旋转20度以内、水平翻转、小幅缩放和位移这几项就足够了。不要用极端的旋转角度或严重的色彩扰动,除非你的业务场景本身就有这样的变化。比如工业质检场景可能不需要随机翻转,而自然图像分类任务通常可以放心用。
3. 搭建“换头”模型:在冻结卷积基上接自己的分类器
3.1 加载VGG16时的几个关键参数
from tensorflow.keras.applications import VGG16 base_model = VGG16( weights='imagenet', include_top=False, input_shape=(224, 224, 3) )三个关键点的含义分别是:weights='imagenet'表示加载在ImageNet上训练好的权重,这样我们就拿到了别人训练好的特征提取器;include_top=False表示去掉网络最后的全连接分类层;input_shape必须和你的预处理尺寸匹配,因为输入层会根据这个形状创建对应的占位符。
还有一点容易忽略,如果你之后想把模型用于灰度图输入,不要直接改input_shape=(224, 224, 1),原因是在加载预训练权重时,第一层卷积的权重维度是按照三通道设计的,强行改成单通道会导致权重加载失败。遇到灰度图数据,要么把单通道复制三份变成伪RGB,要么就用普通Conv2D重写输入层后再想办法迁移权重。
3.2 GlobalAveragePooling2D为什么优于Flatten
从VGG16最后的卷积层输出到分类层之间,需要一步操作把特征图转换成向量。最常见的两个选择是Flatten和GlobalAveragePooling2D。
直接Flatten有个问题:VGG16最后一层卷积输出的特征图是7×7×512,Flatten后得到25088个值,直接接全连接层会产生非常大的参数量,在这个位置极易过拟合。而且全连接层没有位置不变性的概念,把7×7网格展平后再接全连接层,相当于人为破坏了特征图的空间结构。
GlobalAveragePooling2D的做法是把每个通道的特征图整个做平均,7×7×512直接变成512维的向量,参数量从两万五降到512。有人担心平均池化会损失位置信息,但在迁移学习这个场景里,前一层的卷积基已经把关键特征提取得足够充分了,全局平均化反而是一种天然的正则化手段,能够显著降低过拟合风险。我在实际项目中几乎只用这种方式,效果很稳。
3.3 模型组装、冻结与编译
from tensorflow.keras.models import Sequential from tensorflow.keras.layers import GlobalAveragePooling2D, Dense, Dropout model = Sequential([ base_model, GlobalAveragePooling2D(), Dense(256, activation='relu'), Dropout(0.5), Dense(10, activation='softmax') ]) base_model.trainable = Falsebase_model.trainable = False这一步的作用是冻结卷积基的全部参数。冻结后,反向传播过程不再更新这些权重,训练时只会更新新添加的Dense层参数,好处是训练速度快、显存占用低,更重要的是避免了在小数据集上把预训练权重拖坏的灾难。
编译阶段有几个细节值得注意:
model.compile( optimizer='adam', loss='categorical_crossentropy', metrics=['accuracy'] )优化器在这里用的是Adam。第二阶段微调时我会换成SGD,这是后话,先按Adam跑。损失函数按分类任务选择categorical_crossentropy,如果类别多且每个样本只属于一个类别,那它和sparse_categorical_crossentropy的区别仅仅在于标签的编码形式,一个是one-hot,一个是整数索引。
新添加的Dense层数量,第一层我常常设置在256到512之间。这个数值不是随便定的,它承载的是“池化得到的512维特征”向“具体类别得分”过渡的映射能力。层数深一点或者宽一点虽然能提升拟合能力,但小数据下更容易过拟合,所以通常用一层全连接加一个Dropout就足够了。
4. 训练过程为什么分两个阶段,参数又该怎么调
4.1 第一阶段:冻结特征提取器,只训练分类头
第一阶段的训练目标是让新添加的分类器快速学会“读懂”卷积基给出的特征。
这个时候如果你直接用等比例的学习率从头训练所有层,会出现一个严重问题:预训练权重会在前几个更新步骤里被破坏得面目全非。梯度是随机初始化分类层的贡献和卷积层本身的贡献叠加在一起的,小数据集上的回传梯度方向根本不稳定,带着几个数量级的噪声去更新海量预训练参数,结果必然是不可控的漂移。
实际操作中,我把第一阶段的epoch设置成10到15轮,学习率选择1e-3到3e-3。对分类头这种浅层模型来说,这个学习率足够让它快速收敛。训练过程中实时观察loss曲线,正确率上升后loss应该稳步下降,如果loss不降反升,先检查是不是数据预处理出了问题,再去考虑学习率。
4.2 回调配置很重要,能帮你少跑很多冤枉路
尽可能把能配置的回调都配置上,尤其是模型保存、学习率自动调整和早停这三板斧。
from tensorflow.keras.callbacks import ModelCheckpoint, EarlyStopping, ReduceLROnPlateau callbacks = [ ModelCheckpoint( filepath='best_model.h5', monitor='val_loss', save_best_only=True, mode='min' ), EarlyStopping( monitor='val_loss', patience=5, restore_best_weights=True ), ReduceLROnPlateau( monitor='val_loss', factor=0.2, patience=3, min_lr=1e-6 ) ] history = model.fit( train_generator, steps_per_epoch=train_generator.samples // train_generator.batch_size, validation_data=val_generator, validation_steps=val_generator.samples // val_generator.batch_size, epochs=15, callbacks=callbacks )ModelCheckpoint会在验证loss下降时自动保存当前最优的权重文件,避免训练中途中断或过拟合恶化后找不到好结果。EarlyStopping的restore_best_weights=True尤其重要,它在触发早停时会自动把模型权重回滚到验证集最优的那个状态,而不是留下过拟合的最终状态。ReduceLROnPlateau在验证指标原地踏步时自动降低学习率,给训练过程一个“换挡再冲”的机会。
这个trick很实用:模型保存的监控指标用val_loss而不是val_accuracy。因为准确率的数值是离散的,一轮里提高一个点可能只是碰巧猜对了几个样本,但loss的细微变化能更连续地反映模型的收敛状态。
4.3 第二阶段:解冻小部分卷积层做微调
第一阶段的训练结束后,分类头已经基本收敛,这时候可以开始微调。微调的本质是让卷积层的特征也向你任务的分布方向做小幅调整。但这里要克制,不是解冻越多越好,而是解冻越少越好。
我通常的做法是先查看网络结构,确认最后几层卷积层的名字,然后只解冻最后两三个卷积块,其他层保持冻结。这里的选择逻辑是:底层的卷积特征更通用,基本不需要调整;高层的卷积特征更接近具体任务,和你的数据分布有偏差的地方主要集中在这里。数据量越少,解冻的层数就应该越少,我认为一万张以下解冻最后一个块就足够了。
微调阶段的学习率必须大幅下调,这是整个流程里最容易被忽略的一点。
for layer in base_model.layers: if layer.name == 'block5_conv3': layer.trainable = True model.compile( optimizer='adam', loss='categorical_crossentropy', metrics=['accuracy'] )这里有一个很多人会做错的选择:微调时继续用Adam。我在实际中更倾向于把优化器调整为SGD配很小的学习率,比如0.0001到0.00001。原因在于Adam的自适应学习率机制会让每个参数的更新步长差异很大,在已经收敛得很好的预训练权重上做精细调整时,Adam的更新幅度容易过大。SGD配合极小的学习率则能精确控制每次更新的幅度,让模型在你的数据集上平滑演进。当然这不是绝对真理,如果你习惯用Adam,也可以把学习率压到1e-5级别,效果未必差,但波动会更明显。
微调阶段的epoch也不用太多,5到10轮就够。如果验证集指标从微调开始就在下降,说明解冻的层数太多或学习率太大,赶紧停掉重新调节超参数。
4.4 显存不够或者时间紧的应对思路
VGG16在训练时的显存占用比想象中更大,主要原因是VGG16参数量巨大(1.38亿),虽然里面大部分是最后的全连接层,但卷积基本身就占了相当大的计算量。如果你只有一块8GB显存的显卡,batch size就别设32了,调到8或者16,显存占用能显著下降。更进一步的办法是开启混合精度训练,在TensorFlow里设置浮点运算策略,能让显存占用下降大约一半,同时训练速度也有提升。
from tensorflow.keras import mixed_precision mixed_precision.set_global_policy('mixed_float16')这里的原理是让大部分张量用半精度浮点数(FP16)参与计算,只在关键累加位置使用单精度兜底。绝大多数消费级显卡都支持这个模式。此外,把steps_per_epoch改小也可以缩短每个epoch的时间,因为VGG16训练一轮本身比现代轻量模型慢不少,可以先小步快跑地验证方案可行性,后面再加大训练量。
如果你连卷积基的前向计算量都嫌大,那可以考虑用MobileNetV2或者EfficientNet这类轻量模型替代VGG16。我有一次在嵌入式设备上做图像分类任务,VGG16部署后单帧推理时间接近1秒,换成MobileNetV3后降到50毫秒以内,精度只掉了两三个点,这种取舍要按需求来定。
5. 实测中的各种报错和“假收敛”,我都踩过一遍
5.1 输入尺寸报错:数据加载时最容易出的问题
典型报错信息大概是这样的:输入图片尺寸不匹配,网络层期望的输入形状和实际输入形状不一致。
这个问题的根源在于网络里连接部分的特征图尺寸是串行计算出来的:224×224经过5次下采样后变成7×7,任何一个环节的输入尺寸不对,后面的计算就全错。所以如果你打算用原来的VGG16结构做迁移学习,数据加载时就必须把图片resize到224×224。有些同学把target_size写成256或者512,一训练就报错。最简单的排查方式就是打印一下数据迭代器输出的张量形状,确认是(batch, 224, 224, 3)。
5.2 归一化方式错误导致准确率上不去
这是最隐蔽的坑之一,因为程序不会报错,但结果就是不对劲。如果你用rescale=1./255代替preprocess_input,训练十几轮后会发现准确率始终在低位徘徊,比如二分类只到六成七成,不像是正常的收敛过程。
原因是输入数据的分布和预训练权重所适配的分布不一致。ImageNet预训练时期的输入是经过均值、标准差归一化处理的,你输入一种完全不同尺度分布的数据,相当于用一种“方言”在跟预训练网络交流,它底层统计语义完全错位。可以把预训练权重想象成一套安装在某操作系统上的软件,你现在用的输入格式相当于换了另一套不兼容的运行时环境,软件虽然能启动但没法正常工作。解决方案很直接:数据生成器的预处理函数那里用上preprocess_input,重新训练一轮看看,效果会立竿见影。
5.3 解冻Batch Normalization层引发的连锁反应
VGG16原版结构里其实没有Batch Normalization层,但如果你换成带BN的变体版本,或者换了ResNet、Inception这类模型,解冻BN层会带来一个很典型的灾难。
BN层的含义是在训练过程中维护一组统计量(均值、方差),并且根据当前batch的数据不断更新这些统计量。你把整个卷积基都解冻后,BN层会初始化自己的统计量并重新学习。可问题是,你通常没有足够大的batch size和足够长的训练时间让这批统计量收敛回来,于是模型训练时的loss看似很低,验证集上却一塌糊涂。
我在一次用ResNet做迁移学习时遇到过这个现象,训练loss降到0.1以下,验证准确率却只有50%。查了好久才发现是解冻所有层导致的。如果你的预训练模型包含BN层,微调阶段一定要保持BN层处于冻结状态,或者在解冻时确保BN层不要重新计算统计量,只让其他卷积核更新。
5.4 微调时loss剧烈波动的背后原因
这个问题我在前面提过,这里详细说说现象和判断方法。微调阶段如果解冻层数过多、学习率过大,loss曲线会呈现出剧烈震荡,准确率也忽高忽低。造成这个现象的根本原因是:你同时更新大量参数,而这些参数原本已经在一个很稳定的状态,现在被推向新的方向,更新步长稍大就会跑过头。
解决办法也简单:把学习率再往下调一个数量级,从1e-5降到1e-6;把解冻层数再削减,比如从5个块减到1个块;batch size适当增大,让梯度的估计更稳定。另一种快速诊断手段是,微调开始时先只训练一个epoch并观察loss变化,如果第一个epoch内loss就发生了异常跳变,那基本可以确定是更新步长的问题,而不是数据问题。
6. 模型评估、保存与复用的实用建议
6.1 验证集评估:准确率高不代表能用
训练结束后,很多人的第一反应是看准确率。准确率当然要看,但只盯着它远远不够。一个在验证集上准确率达到95%的模型,可能在真实场景中表现很差,原因是真实场景的数据分布和验证集分布存在差异。
我每次训练完都会做两件事:画混淆矩阵,输出分类报告。混淆矩阵能告诉你模型是在哪些类别之间互相混淆,如果两个类误分率特别高,说明这些类别本身的视觉特征就比较接近,要么增加这几类的数据量,要么考虑给它们单独调整后处理逻辑。分类报告里重点关注每个类别的精确率和召回率,而不是整体的准确率。
6.2 模型保存与新数据推理的注意事项
模型保存时我通常会选择SavedModel格式而不是传统的.h5,因为SavedModel格式自带了一份完整的图结构,方便后续部署到服务端或移动端框架。导出之前先确认模型的输入层名称,推理时预处理步骤必须和训练时完全一致,这一点极其容易踩坑。我见过太多项目,模型在验证集上表现不错,上线时却忘了加preprocess_input,结果预测全部出错。最好的做法是把预处理逻辑一起打包进模型,在模型开头加入预处理层,这样调用方就不需要关心内部细节了。
推理代码的长这样:
from tensorflow.keras.models import load_model from tensorflow.keras.applications.vgg16 import preprocess_input model = load_model('saved_model_path') image = load_image('test.jpg') # 你的图片读取逻辑 image = image.resize((224, 224)) image = image.astype('float32') image = preprocess_input(image) image = tf.expand_dims(image, axis=0) pred = model.predict(image)最后一个容易忽略的细节是输入数据的dtype。如果你从磁盘读进来的图片是uint8类型的,而模型期望的是float32,推理时可能会得到错误的结果。astype('float32')这一步不能省,因为预训练权重的数值范围是浮点精度的,uint8整型数据和它相乘会产生截断误差,虽然看起来只是精度问题,但有时会导致预测概率分布完全乱掉。
一些实际操作后的体会
如果让我说出一条最能节省时间的经验,那就是:预训练模型的迁移学习不是“加载即胜利”,而是“对症下药”的过程。数据的预处理、输入尺寸、分类头设计、解冻策略、学习率节奏,每一步都直接决定你最终是得到一个可用的模型还是一个失败的项目。VGG16虽然不是参数效率最高的模型,但它结构够经典,用它把整个流程跑通一次,后续再切任何网络都是顺水推舟的事。建议第一次实践的朋友,先在小数据集上整个流程走一遍,重点关注我提到的那些隐蔽问题,等流程完全跑通,再放大数据量做正式训练。另外一个小技巧:训练完成后观察一下history对象里保存的训练曲线,如果训练loss和验证loss的差距越来越大,说明过拟合开始了,这时候Dropout的比例、数据增强的强度、解冻的层数都要向收紧的方向调。反复玩味这一整套流程,迁移学习就真的被你吃透了。