资讯动态

TensorFlow迁移学习实战:VGG16图像分类微调全流程

发布时间:2026/10/17 5:20:54 来源:尧图企业网站定制
先聊个真实的场景。我之前接到一个图像分类的任务数据只有五千多张图、十个类别设备也只是一块普通的消费级显卡。如果从零开始训一个残差网络大概率是过拟合加训练时间漫长调参调到怀疑人生。但同样是这个任务换成熟练的预训练模型来做迁移学习两天内就出了可用版本。这篇文章我就拿经典的VGG16来说说在TensorFlow里怎么加载预训练权重、怎么把它接到自己的分类任务上以及那些文档里不会写、但训练时几乎必踩的坑。1. VGG16不是最前沿的模型为什么我仍推荐用它做迁移学习1.1 迁移学习到底迁移的是什么很多初学者有个误解以为迁移学习就是把别人训练好的模型拿过来直接预测。真这么想就完全跑偏了。预训练模型在ImageNet这种千万级数据集上完成训练后它的卷积层已经学会了一套通用的视觉特征提取能力。底层卷积核学会的是边缘、颜色、纹理这些最基础的视觉元素中层学会的是形状、局部结构高层则能识别出眼睛、轮子这类零部件级别的特征。这套特征提取能力和你最终要做的任务是什么关系不大因为不管是识别猫狗、判断工业零件缺陷还是做农作物病害分类底层视觉特征都是通用的。所以迁移学习的基本逻辑是把预训练模型当作一个已经被海量数据“调教”好的特征提取器只更换最后负责分类的那部分然后用自己的数据去训练新的分类器。对于数据量有限的项目来说这相当于站在巨人的肩膀上。1.2 VGG16的结构里哪些部分能留哪些必须换VGG16由两部分组成13个卷积层构成的特征提取部分以及3个全连接层构成的分类部分。用框架自带的VGG16接口时include_topFalse参数的作用就是把后面的全连接分类部分整个切掉只留下卷积层。为什么要切掉而不直接用完整的网络来微调原因在于原始的3个全连接层是中看不中用的。第一个全连接层有超过1亿个参数参数量比前面所有卷积层加起来还多训练时极易过拟合而且ImageNet的1000类输出对你的任务来说根本没有意义。我们要做的是“换头”而不是“改装全身”。1.3 什么样的情况适合用VGG16一个被我反复验证的判断标准任务数据量在一万张以内时从头训练几乎不会有好结果而预训练模型往往能快速收敛到实用的精度。如果数据量很大比如几十万张那预训练模型可能反而限制了模型的能力上限此时再从零训练或者用更现代的大模型会更合适。VGG16的实际效果放在今天的基准测试里确实不算最强但它的结构规整、特征可视化容易、在中小数据集上表现稳定作为讲清楚迁移学习原理的模板再合适不过。把本文这套流程跑通了换成别的预训练模型只是改一行加载代码的事。2. 数据准备喂给VGG16的图片有哪些硬性规矩2.1 输入尺寸与归一化别只写个rescale1./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_directoryfrom tensorflow.keras.preprocessing.image import ImageDataGenerator from tensorflow.keras.applications.vgg16 import preprocess_input train_datagen ImageDataGenerator( preprocessing_functionpreprocess_input, rotation_range20, width_shift_range0.2, height_shift_range0.2, horizontal_flipTrue, zoom_range0.2 ) val_datagen ImageDataGenerator( preprocessing_functionpreprocess_input ) train_generator train_datagen.flow_from_directory( dataset/train, target_size(224, 224), batch_size32, class_modecategorical ) val_generator val_datagen.flow_from_directory( dataset/val, target_size(224, 224), batch_size32, class_modecategorical )注意验证集上的生成器除了preprocess_input以外不要做任何增强操作因为数据增强会造成验证集评估结果失真。评估时我们需要看到一个稳定的、可复现的准确率。你可能会问target_size(224, 224)是不是会把图片拉伸变形是的如果原图不是正方形确实会被强行缩放到正方形但这是绝大多数预训练模型的输入约定除非你用crop之类的策略先裁剪中心区域再缩放否则只能接受这个操作。实际跑起来之后你会发现对于VGG16这类模型输入尺寸带来的几何形变问题远没有很多人想象中那么致命因为预训练权重见过足够多的同类图像鲁棒性其实还不错。2.3 数据增强的分寸怎么把握数据增强的目的是让模型看到更多样的样本从而降低过拟合风险。但增强幅度过猛也会适得其反图像语义被破坏比如把“猫”增强成看不出猫的形状模型反而学不到有效特征。我的经验是对于中小数据量旋转20度以内、水平翻转、小幅缩放和位移这几项就足够了。不要用极端的旋转角度或严重的色彩扰动除非你的业务场景本身就有这样的变化。比如工业质检场景可能不需要随机翻转而自然图像分类任务通常可以放心用。3. 搭建“换头”模型在冻结卷积基上接自己的分类器3.1 加载VGG16时的几个关键参数from tensorflow.keras.applications import VGG16 base_model VGG16( weightsimagenet, include_topFalse, input_shape(224, 224, 3) )三个关键点的含义分别是weightsimagenet表示加载在ImageNet上训练好的权重这样我们就拿到了别人训练好的特征提取器include_topFalse表示去掉网络最后的全连接分类层input_shape必须和你的预处理尺寸匹配因为输入层会根据这个形状创建对应的占位符。还有一点容易忽略如果你之后想把模型用于灰度图输入不要直接改input_shape(224, 224, 1)原因是在加载预训练权重时第一层卷积的权重维度是按照三通道设计的强行改成单通道会导致权重加载失败。遇到灰度图数据要么把单通道复制三份变成伪RGB要么就用普通Conv2D重写输入层后再想办法迁移权重。3.2 GlobalAveragePooling2D为什么优于Flatten从VGG16最后的卷积层输出到分类层之间需要一步操作把特征图转换成向量。最常见的两个选择是Flatten和GlobalAveragePooling2D。直接Flatten有个问题VGG16最后一层卷积输出的特征图是7×7×512Flatten后得到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, activationrelu), Dropout(0.5), Dense(10, activationsoftmax) ]) base_model.trainable Falsebase_model.trainable False这一步的作用是冻结卷积基的全部参数。冻结后反向传播过程不再更新这些权重训练时只会更新新添加的Dense层参数好处是训练速度快、显存占用低更重要的是避免了在小数据集上把预训练权重拖坏的灾难。编译阶段有几个细节值得注意model.compile( optimizeradam, losscategorical_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( filepathbest_model.h5, monitorval_loss, save_best_onlyTrue, modemin ), EarlyStopping( monitorval_loss, patience5, restore_best_weightsTrue ), ReduceLROnPlateau( monitorval_loss, factor0.2, patience3, min_lr1e-6 ) ] history model.fit( train_generator, steps_per_epochtrain_generator.samples // train_generator.batch_size, validation_dataval_generator, validation_stepsval_generator.samples // val_generator.batch_size, epochs15, callbackscallbacks )ModelCheckpoint会在验证loss下降时自动保存当前最优的权重文件避免训练中途中断或过拟合恶化后找不到好结果。EarlyStopping的restore_best_weightsTrue尤其重要它在触发早停时会自动把模型权重回滚到验证集最优的那个状态而不是留下过拟合的最终状态。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( optimizeradam, losscategorical_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 归一化方式错误导致准确率上不去这是最隐蔽的坑之一因为程序不会报错但结果就是不对劲。如果你用rescale1./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, axis0) pred model.predict(image)最后一个容易忽略的细节是输入数据的dtype。如果你从磁盘读进来的图片是uint8类型的而模型期望的是float32推理时可能会得到错误的结果。astype(float32)这一步不能省因为预训练权重的数值范围是浮点精度的uint8整型数据和它相乘会产生截断误差虽然看起来只是精度问题但有时会导致预测概率分布完全乱掉。一些实际操作后的体会如果让我说出一条最能节省时间的经验那就是预训练模型的迁移学习不是“加载即胜利”而是“对症下药”的过程。数据的预处理、输入尺寸、分类头设计、解冻策略、学习率节奏每一步都直接决定你最终是得到一个可用的模型还是一个失败的项目。VGG16虽然不是参数效率最高的模型但它结构够经典用它把整个流程跑通一次后续再切任何网络都是顺水推舟的事。建议第一次实践的朋友先在小数据集上整个流程走一遍重点关注我提到的那些隐蔽问题等流程完全跑通再放大数据量做正式训练。另外一个小技巧训练完成后观察一下history对象里保存的训练曲线如果训练loss和验证loss的差距越来越大说明过拟合开始了这时候Dropout的比例、数据增强的强度、解冻的层数都要向收紧的方向调。反复玩味这一整套流程迁移学习就真的被你吃透了。

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

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

免费获取报价 →
↑