尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

VGG16卷积神经网络详解:从Keras实现到迁移学习与模型微调

VGG16卷积神经网络详解:从Keras实现到迁移学习与模型微调 很多人学卷积神经网络上来就被一堆概念砸晕卷积、池化、步长、填充、感受野……然后打开框架面对几十个参数完全不知道从哪下手。我自己的经验是与其在花哨的PPT图里绕圈子不如老老实实把VGG16的每一层结构掰开揉碎配合Keras代码跑一遍看懂每个数字是怎么算出来的、为什么要这么设计。之所以选VGG16作为切入点是因为它几乎是所有经典CNN里骨架最规整的一个。没有残差连接没有Inception分支就是纯纯的卷积池化全连接堆叠。正是这种单调的结构让你能把所有注意力放在理解卷积神经网络的核心机制上而不用被各种技巧干扰。等吃透了VGG16再去玩ResNet、DenseNet、EfficientNet你会发现大批新模型无非是在VGG的骨架上做变体手术。这篇文章我不会讲太多数学推导重点放在用Keras把VGG16跑起来逐层拆解它的参数计算然后聊聊实际应用中最常见的三个方向——特征提取、迁移学习和模型微调顺带分享一些我踩过的坑。1. 从搭建环境开始Keras的版本地狱与兼容性先说环境这一点网上教程写得最马虎但实际碰上问题的人最多。Keras在2024年以后的生态变了很多TensorFlow 2.x内置的是tf.keras而独立安装的keras包在3.0之后变成了多后端实现可以把TensorFlow、PyTorch或JAX当作后端来跑。正因为有这种分裂网上的老教程和新项目经常对不上。我的建议是除非你有特殊需求否则直接在TensorFlow的环境里用tf.keras这是最不容易出错的路径。# 推荐直接用conda创建干净环境Python版本别太新也别太老 conda create -n vgg16 python3.10 -y conda activate vgg16 # CPU版适合学习原理和跑小数据 pip install tensorflow-cpu2.15.0 # GPU版如果你的显卡是NVIDIA且CUDA配置好了 # pip install tensorflow2.15.0版本选择上有个细节值得注意TensorFlow 2.15是最后一个原生支持Python 3.11的版本后面2.16就要Python 3.9到3.12之间而且2.16开始默认编译的CUDA版本也换了。如果你跟着某些教程安装最新的TensorFlow再配旧显卡驱动很容易在import tensorflow时直接报错崩溃。所以学习阶段选2.15这个版本最稳。装完验证一下import tensorflow as tf from tensorflow.keras.applications import VGG16 # 构建一个默认的VGG16不要预训练权重只看结构 model VGG16(weightsNone, include_topTrue, input_shape(224, 224, 3)) # 打印结构总览 model.summary()如果你能顺利打印出结构说明环境基本没问题。这里有个新手常犯的错误input_shape必须和VGG16默认的224×224保持一致如果你想用其他尺寸输入后面会有连锁反应。这个我放到第三节细说。2. 一张图读懂VGG16的计算流程卷积、池化、步长、核、填充到底是什么关系与其拿着结构图硬背不如把VGG16简化成一张流水线来看。原始输入是一张224×224×3的RGB图像3个通道分别是红绿蓝。流水线大致是卷积层负责扫描特征池化层负责压缩数据全连接层负责做决定。2.1 卷积核用一个小窗口扫描整张图卷积核kernel本质上是一个小矩阵比如VGG16里用得最多的3×3卷积核。你可以把它想象成一个手电筒在图像上从左到右、从上到下地滑动每停在一个位置就把窗口内的像素值和卷积核的数值做加权求和得到一个输出值。这个值衡量的是这个位置和某种特征模式的匹配度。VGG16里每个卷积核是三维的因为输入图像有多个通道。以第一个卷积层为例输入是224×224×3这一层有64个卷积核每个卷积核的尺寸是3×3×3长、宽、通道数。64个卷积核就意味着输出64个通道每个通道是输入图像经过一个卷积核扫描后的结果。所以第一个卷积层的输出形状是224×224×64。这里有个概念必须分清卷积核的通道数必须等于输入的通道数卷积核的数量决定了输出的通道数。很多人搞混这一点导致后面查参数数量时怎么都对不上。2.2 步长和填充控制扫描密度和边界处理步长stride是卷积核每次滑动的距离。步长为1就是挨着扫步长为2相当于隔一个像素扫一次输出尺寸直接减半。VGG16里所有卷积层的步长都是1池化层的步长是2。填充padding处理的是图像边缘问题。步长为1时224×224的图像用3×3的卷积核扫描如果不做填充输出会变成222×222图像尺寸逐层缩水堆叠层数一多特征图就没了。VGG16所有卷积层用paddingsame也就是在原图周围补一圈0让输出尺寸和输入保持一致。这也是为什么VGG16的卷积层全部保持着尺寸不变、通道数变多的特点。用公式表示就是输出尺寸 (输入尺寸 - 核大小 2 * 填充) / 步长 1代入224、核大小3、填充1、步长1得到(224 - 3 2 1) / 1 224。完美保持尺寸。2.3 池化只保留最显著的特征信息池化层是VGG16的下采样机制。VGG16用的是2×2最大池化MaxPooling2D也就是在2×2的窗口里取最大值步长也是2。效果就是每个窗口输出一个数特征图的长宽各缩小一半。为什么取最大值而不取平均值我的理解是卷积层激活后数值大的位置通常对应明显特征比如边缘、角点MaxPooling留下的是最强烈的响应相当于保留了这个区域内最有代表性的特征。这个过程会丢失大量信息但换来的是参数量的骤降和一定程度的平移不变性——特征是模糊地存在于某个区域而不需要精确定位到像素级。2.4 激活函数ReLU非线性的来源如果没有非线性函数卷积层和全连接层再多也只是线性变换的叠加表达力等同于一层再多层也没有意义。VGG16在每轮卷积后接ReLURectified Linear Unitf(x) max(0, x)。负值全部归零正值保留。这个操作简单粗暴但功效非常好计算快、不容易梯度消失。注意VGG16的池化层本身不带激活函数激活是在池化之前的卷积输出上加的。顺序是Conv → ReLU → Conv → ReLU → Pool一直循环这个模式。2.5 全连接层把二维特征图拍扁做分类经过5组卷积池化后特征图从224×224×3压缩到了7×7×512。这个7×7×512的数据被展开Flatten成一维向量长度为250887×7×512。然后送入三层全连接网络第一层4096个神经元第二层4096个神经元最后一层是1000个神经元对应ImageNet的1000个类别。最后接Softmax把输出变成每个类别的概率。现在回过头看VGG16的总参数量约1.38亿个参数。其中绝大部分不是卷积层贡献的而是三个全连接层第一层全连接的权重数就有25088×4096≈1.027亿占了总参数的74%。这是VGG的一个显著缺陷后面做工程优化时首先要动刀的就是全连接层。下面这张表就是VGG16各阶段的尺寸变化和参数情况我把关键数字整理出来方便对照model.summary()验证阶段输入尺寸操作输出尺寸单层参数量Block1224×224×3Conv3-64 ×2224×224×64(3×3×31)×641792Pool1224×224×64MaxPool112×112×640Block2112×112×64Conv3-128 ×2112×112×128(3×3×641)×12873856Pool2112×112×128MaxPool56×56×1280Block356×56×128Conv3-256 ×356×56×256(3×3×1281)×256295168Pool356×56×256MaxPool28×28×2560Block428×28×256Conv3-512 ×328×28×512(3×3×2561)×5121180160Pool428×28×512MaxPool14×14×5120Block514×14×512Conv3-512 ×37×7×512(3×3×5121)×5122359808Pool57×7×512MaxPool7×7×5120FC17×7×512Dense409625088×40964096FC24096Dense40964096×40964096FC34096Dense10004096×100010003. 用Keras一步一步搭建VGG16并逐层解读直接用applications.VGG16一步到位固然方便但我更建议新手先手动搭建一遍。因为只有手写代码时你才会被迫思考每一层的输入输出形状、参数数量是怎么衔接的。3.1 手写VGG16模型from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Conv2D, MaxPooling2D, Flatten, Dense, Dropout def build_vgg16(input_shape(224, 224, 3), num_classes1000): model Sequential() # Block 1 model.add(Conv2D(64, (3, 3), activationrelu, paddingsame, input_shapeinput_shape, nameblock1_conv1)) model.add(Conv2D(64, (3, 3), activationrelu, paddingsame, nameblock1_conv2)) model.add(MaxPooling2D((2, 2), strides(2, 2), nameblock1_pool)) # Block 2 model.add(Conv2D(128, (3, 3), activationrelu, paddingsame, nameblock2_conv1)) model.add(Conv2D(128, (3, 3), activationrelu, paddingsame, nameblock2_conv2)) model.add(MaxPooling2D((2, 2), strides(2, 2), nameblock2_pool)) # Block 3 model.add(Conv2D(256, (3, 3), activationrelu, paddingsame, nameblock3_conv1)) model.add(Conv2D(256, (3, 3), activationrelu, paddingsame, nameblock3_conv2)) model.add(Conv2D(256, (3, 3), activationrelu, paddingsame, nameblock3_conv3)) model.add(MaxPooling2D((2, 2), strides(2, 2), nameblock3_pool)) # Block 4 model.add(Conv2D(512, (3, 3), activationrelu, paddingsame, nameblock4_conv1)) model.add(Conv2D(512, (3, 3), activationrelu, paddingsame, nameblock4_conv2)) model.add(Conv2D(512, (3, 3), activationrelu, paddingsame, nameblock4_conv3)) model.add(MaxPooling2D((2, 2), strides(2, 2), nameblock4_pool)) # Block 5 model.add(Conv2D(512, (3, 3), activationrelu, paddingsame, nameblock5_conv1)) model.add(Conv2D(512, (3, 3), activationrelu, paddingsame, nameblock5_conv2)) model.add(Conv2D(512, (3, 3), activationrelu, paddingsame, nameblock5_conv3)) model.add(MaxPooling2D((2, 2), strides(2, 2), nameblock5_pool)) # 全连接层 model.add(Flatten(nameflatten)) model.add(Dense(4096, activationrelu, namefc1)) model.add(Dropout(0.5, namedropout1)) model.add(Dense(4096, activationrelu, namefc2)) model.add(Dropout(0.5, namedropout2)) model.add(Dense(num_classes, activationsoftmax, namepredictions)) return model model build_vgg16() model.summary()注意两个细节第一手写版本我在两个全连接层后各加了一个Dropout(0.5)原始论文里也有这个操作。在全连接层参数量爆炸的情况下Dropout是抑制过拟合的重要手段。applications.VGG16默认是不带Dropout的这点很多人不知道。第二Flatten层的参数量是0但它有一个隐藏的计算成本将25088个数值展开成一维向量这个操作本身不占显存的权重但会占用激活值的内存在训练时这块开销不小。3.2 为什么VGG16全连接层可以动态适配任何输入尺寸这里有个经常被误解的点。如果你运行上面代码时把input_shape改成(128, 128, 3)模型依然能构建成功但后面全连接层的参数量会发生变化——因为卷积加池化后的特征图尺寸不再是7×7而是4×4128经过5次/2缩小Flatten后的长度就从25088变成了4×4×5128192对应全连接层的权重矩阵也变了。这带来一个实际问题如果你打算用applications.VGG16的预训练权重做迁移学习输入尺寸随意改会直接导致权重加载失败。预训练权重是在224×224输入上训练出来的全连接层的维度已经固定。所以要么保持224×224输入要么去掉全连接层include_topFalse只用卷积部分做特征提取。4. 预训练权重的加载与迁移学习实战VGG16真正的价值不在于从零训练而在于借力打力——用别人在ImageNet上训练好的权重迁移到你的小数据集上。这是绝大多数实际项目的做法因为普通场景根本不可能有几百万张标注图。4.1 加载预训练模型并冻住卷积层from tensorflow.keras.applications.vgg16 import VGG16, preprocess_input from tensorflow.keras.models import Model from tensorflow.keras.layers import Dense, GlobalAveragePooling2D, Input from tensorflow.keras.optimizers import Adam # 加载预训练权重去掉最顶上的全连接层 base_model VGG16(weightsimagenet, include_topFalse, input_shape(224, 224, 3)) # 冻结所有卷积层 for layer in base_model.layers: layer.trainable False # 在卷积层之后接自己的分类头 x base_model.output x GlobalAveragePooling2D()(x) # 替代Flatten x Dense(512, activationrelu)(x) predictions Dense(num_classes, activationsoftmax)(x) model Model(inputsbase_model.input, outputspredictions) model.compile(optimizerAdam(learning_rate1e-4), losscategorical_crossentropy, metrics[accuracy])这里我特别用GlobalAveragePooling2D替代了Flatten。原因很实际Flatten会把7×7×512的数据拉成25088维向量再接Dense层参数量动辄上亿而GlobalAveragePooling2D直接对每个通道求全局平均值7×7×512变成了512维参数量骤减。同时这个操作让模型对输入尺寸不再敏感——不管卷积特征图是7×7还是4×4全局平均池化后都是512维。很多新模型的末段都采用这个设计。4.2 数据预处理最容易忽略却最关键的一步用预训练模型最坑的一步就是数据预处理。VGG16在ImageNet上训练时图像的预处理方式是每个像素减掉RGB三个通道的均值。preprocess_input帮我们封装了这个步骤。from tensorflow.keras.preprocessing.image import ImageDataGenerator from tensorflow.keras.applications.vgg16 import preprocess_input datagen ImageDataGenerator( preprocessing_functionpreprocess_input, # 注意这里不是rescale rotation_range20, width_shift_range0.2, height_shift_range0.2, horizontal_flipTrue, validation_split0.2 ) train_generator datagen.flow_from_directory( data/train, target_size(224, 224), batch_size32, class_modecategorical, subsettraining ) val_generator datagen.flow_from_directory( data/val, target_size(224, 224), batch_size32, class_modecategorical, subsetvalidation )新手常犯的错误是既用rescale1./255归一化又用preprocess_input结果双重处理导致输入分布完全偏离预训练模型的期望。记住用VGG16预训练权重时输入必须走preprocess_input不要再手动除以255。这个函数的逻辑是先除以255把像素值映射到[0,1]然后每个通道减去对应均值、除以标准差。它是专门配合ImageNet训练设置的。4.3 微调解锁深层卷积层让模型更懂你的数据冻结所有卷积层训练一轮后模型已经能用通用特征做基础的分类了。但如果你想进一步提升准确率可以进入微调阶段解冻靠近输出端的几个卷积块用较小的学习率继续训练。# 解冻Block5 for layer in base_model.layers: if block5 in layer.name: layer.trainable True # 重新编译必须重新编译才能生效 model.compile(optimizerAdam(learning_rate1e-5), losscategorical_crossentropy, metrics[accuracy])微调阶段有两条铁律第一学习率必须比迁移学习阶段低一到两个数量级否则容易破坏预训练权重中已经学好的通用特征。我一般用1e-5起步。第二修改trainable后必须重新compile否则改动不生效——这是Keras的一个经典大坑框架不会报错只是静默地保持原样运行。从原理上讲VGG16前几层学习到的是通用边缘和纹理特征这些几乎在任何视觉任务里都有用越往后越偏向ImageNet特有的高层语义特征比如狗耳朵、猫眼睛的形状。微调就是让后半部分放弃部分ImageNet特有特征转而去学习你任务里的特征。只微调后半部分的原因也在这里前半部分通用性强没必要冒险扰动。5. VGG16的瓶颈与剪枝思路VGG16诞生于2014年到今天看它的缺点非常明显参数量1.38亿模型文件约528MB单张224×224图像跑一次前向推理的计算量约153亿次浮点运算。相比之下ResNet50参数量约2560万计算量约38亿次浮点运算。VGG16的性价比确实不高。想在实际项目里用VGG16通常要做减法。我在实践中试过几种思路按性价比排序5.1 去掉全连接层全连接层占了总参数量的88%左右但它们只服务于1000类分类对特征提取本身贡献不大。直接把include_topFalse然后用全局平均池化替代参数量能降到约1470万一下缩掉一个数量级。这是最简单、最安全的瘦身手段。5.2 结构化剪枝删减卷积通道这一招比删除全连接层更激进但也更有效。核心思路是评估每个卷积核的重要性把贡献小的通道连同其对应的卷积核一起剪掉。常见的重要性评估方法有L1范数裁剪计算每个卷积核权重矩阵的L1范数所有权重绝对值之和认为范数小的卷积核学到的特征不显著可以舍去。这是最朴素但效果不错的方法。基于BN层缩放因子的稀疏化裁剪如果网络里有BN层BN层的γ参数缩放因子大小可以作为通道重要性的代理指标。训练时在损失函数中加入对γ的L1正则约束让不重要的通道γ趋近于0训练完直接剪掉γ很小的通道。Taylor展开近似用损失函数对通道输出的梯度乘以输出值来近似该通道对损失的影响剪掉影响最小的通道。实际剪枝时不建议一次砍太多。我常用的节奏是每次剪掉10%-20%的通道然后做短时间微调恢复精度再评估再剪。一次性砍掉50%以上模型的表征能力容易崩盘且很难微调回来。Keras里做结构化剪枝没有官方工具但可以用tf.keras.Model的层操作自己实现。下面这段代码展示了如何利用TensorFlow Model Optimization Toolkit做权重剪枝这是非结构化剪枝更细粒度import tensorflow_model_optimization as tfmot pruning_schedule tfmot.sparsity.keras.PolynomialDecay( initial_sparsity0.30, final_sparsity0.85, begin_step0, end_step1000 ) pruned_model tfmot.sparsity.keras.prune_low_magnitude( model, pruning_schedulepruning_schedule ) pruned_model.compile(optimizeradam, losscategorical_crossentropy, metrics[accuracy]) # 训练时需要额外回调 from tensorflow_model_optimization.sparsity.keras import UpdatePruningStep pruned_model.fit(train_generator, epochs10, callbacks[UpdatePruningStep()])需要提醒的是这种非结构化剪枝产生的稀疏矩阵在普通CPU/GPU上并不一定带来肉眼可见的推理加速因为稠密矩阵运算库对稀疏数据的利用效率有限。如果你追求真正的推理加速还是要做结构化剪枝或者直接转用TensorRT的稀疏推理模式。5.3 知识蒸馏让学生模型学习VGG16的输出这个思路适合模型要在手机上跑的场景。用一个参数量小得多的学生网络比如只有几层卷积的小模型去模仿VGG16在大量数据上的软标签输出。软标签就是经过Softmax后的概率分布它比硬标签0或1包含更多信息——比如这张图有70%像猫、20%像狗这种模糊性可以告诉学生模型类别之间的相似性关系。蒸馏训练时通常把学生模型的损失设计为两部分一部分是学生输出和真实硬标签之间的交叉熵另一部分是学生输出和老师VGG16软标签之间的KL散度。温度参数T控制软标签的平滑程度T越大概率分布越平滑类别间的关系暴露得越充分。实践中T通常在3~8之间调。import tensorflow as tf def distillation_loss(y_true, y_pred, teacher_output, temperature4.0, alpha0.7): # 硬标签损失 hard_loss tf.keras.losses.categorical_crossentropy(y_true, y_pred) # 软标签损失两边都除以温度T soft_pred tf.nn.softmax(y_pred / temperature) soft_teacher tf.nn.softmax(teacher_output / temperature) soft_loss tf.keras.losses.KLDivergence()(soft_teacher, soft_pred) # 加权合并 return alpha * hard_loss (1 - alpha) * soft_loss * (temperature ** 2)蒸馏的好处是学生模型可以从VGG16学到暗知识——那些类别间的微妙关联而不只是生硬的标签。我曾在某个工业质检项目里把VGG16蒸馏成一个只有原来1/20参数量的模型准确率只掉了不到两个百分点推理速度提升了近十倍。对于生产环境来说这是VGG16发挥余热的有效方式。6. 用自定义数据集实战完整微调VGG16的流程记录前面讲了一堆理论这里我用一个实际的场景串一遍完整流程。假设我们要做一个简单的分类任务区分猫和狗的图片这是一个典型的二分类问题。6.1 数据准备与生成器配置import os from tensorflow.keras.preprocessing.image import ImageDataGenerator from tensorflow.keras.applications.vgg16 import preprocess_input data_dir ./data/cats_vs_dogs IMG_SIZE 224 BATCH_SIZE 32 train_datagen ImageDataGenerator( preprocessing_functionpreprocess_input, rotation_range30, width_shift_range0.2, height_shift_range0.2, shear_range0.2, zoom_range0.2, horizontal_flipTrue, fill_modenearest, validation_split0.2 ) train_gen train_datagen.flow_from_directory( os.path.join(data_dir, train), target_size(IMG_SIZE, IMG_SIZE), batch_sizeBATCH_SIZE, class_modebinary, subsettraining ) val_gen train_datagen.flow_from_directory( os.path.join(data_dir, train), target_size(IMG_SIZE, IMG_SIZE), batch_sizeBATCH_SIZE, class_modebinary, subsetvalidation ) # 不要忘了验证集和测试集不做数据增强只做预处理 test_datagen ImageDataGenerator(preprocessing_functionpreprocess_input) test_gen test_datagen.flow_from_directory( os.path.join(data_dir, test), target_size(IMG_SIZE, IMG_SIZE), batch_sizeBATCH_SIZE, class_modebinary, shuffleFalse )数据增强这一块我多说一句。rotation_range、width_shift_range这些参数的选择不是越多越好。增强太狠模型会把过度的扭曲当作真实分布反而学不到关键特征增强太弱小数据集容易过拟合。我的经验是先从轻中度增强开始——旋转20度、平移0.2、水平翻转这基本是计算机视觉任务的标准套餐等观察验证集走势后再决定要不要加强。6.2 迁移学习阶段base_model VGG16(weightsimagenet, include_topFalse, input_shape(IMG_SIZE, IMG_SIZE, 3)) for layer in base_model.layers: layer.trainable False x base_model.output x GlobalAveragePooling2D()(x) x Dense(256, activationrelu)(x) x Dropout(0.3)(x) # 二分类用sigmoid多分类用softmax out Dense(1, activationsigmoid)(x) model Model(inputsbase_model.input, outputsout) model.compile(optimizerAdam(learning_rate1e-4), lossbinary_crossentropy, metrics[accuracy]) # 早停回调防止过拟合 from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau, ModelCheckpoint callbacks [ EarlyStopping(monitorval_loss, patience5, restore_best_weightsTrue), ReduceLROnPlateau(monitorval_loss, factor0.5, patience2, min_lr1e-7), ModelCheckpoint(vgg16_finetuned.h5, monitorval_accuracy, save_best_onlyTrue) ] history model.fit( train_gen, steps_per_epochtrain_gen.samples // BATCH_SIZE, validation_dataval_gen, validation_stepsval_gen.samples // BATCH_SIZE, epochs20, callbackscallbacks )为什么前几轮只用1e-4的学习率跑冻结层因为新加的Dense层是随机初始化的输出分布完全紊乱如果一开始就用大学习率解冻整个网络反向传播会剧烈扰动预训练权重相当于把学好的特征全毁了。先用小学习率把新的分类头训到能正常工作——也就是让预训练特征和分类任务之间的接口稳定下来——再考虑解冻深层卷积层微调这个顺序是无数前人总结出来的血泪经验。6.3 微调阶段for layer in base_model.layers: if block5 in layer.name: layer.trainable True model.compile(optimizerAdam(learning_rate1e-5), lossbinary_crossentropy, metrics[accuracy]) history_finetune model.fit( train_gen, steps_per_epochtrain_gen.samples // BATCH_SIZE, validation_dataval_gen, validation_stepsval_gen.samples // BATCH_SIZE, epochs10, callbackscallbacks )微调阶段只解冻block5而不是全部解冻是经过考量的。block1和block2学习的是通用边缘和纹理特征在任何视觉任务里几乎通用扰动它们基本没有收益只有风险。block5最接近分类任务学习到的特征最偏ImageNet类别这部分才最需要适应当前任务。如果数据集和ImageNet差异很大比如医学影像、卫星图可以酌情解冻更多层但要搭配更低的学习率。我见过有人直接解冻全部层用1e-4学习率去跑结果验证集准确率像坐过山车完全训练不稳定。6.4 实测效果与性能数据我在一个约5000张猫狗图像的数据集上跑过上面的完整流程记录几组关键数据供参考。硬件环境是单张RTX 3070 8GB显存阶段训练时间验证集准确率冻结卷积层只训新分类头约8分钟/20轮97.1%解冻Block5微调约3分钟/10轮98.6%解冻Block4和Block5继续微调约5分钟/10轮98.9%迁移学习的威力一目了然只训练一个分类头就能到97%以上这放在从零训练一个随机初始化的CNN上几乎是不可能的。但从98.6%到98.9%的提升也说明当数据量有限时迁移学习的收益是边际递减的——最后那零点几个百分点的代价是更长的训练时间和更大的过拟合风险。6.5 排查问题的思路分享实际跑这个流程时最容易遇到两个坑第一个坑是图像预处理不对导致准确率极低。如果你忘记用preprocess_input或者重复用了rescale预训练权重的输入分布就被破坏了特征提取器输出的特征完全偏离预期准确率可能只有50%左右随机猜。排查方法很简单单独取一张图分别用正确和错误的预处理方式过一遍模型看中间层的特征图分布差异。正确预处理后Block1特征图的均值和方差应该在合理范围错误预处理的特征图往往数值异常。第二个坑是显存崩溃。默认224×224输入batch_size32在8GB显存上跑VGG16微调是非常吃紧的训练过程中随时可能OOM。解决办法有几种batch_size降到16甚至8关闭其他占用显存的程序如果真的想跑大batch可以先用include_topFalse把特征提前算好缓存到本地因为冻结卷积层时卷积部分的输出是固定的然后只把缓存的特征送入分类头训练。第二种方法是特征缓存策略代码类似# 第一阶段用base_model把所有图像的特征提取出来存成npy train_features base_model.predict(train_gen, verbose1) np.save(train_features.npy, train_features) # 然后只训练分类头这个技巧在数据集不大、卷积层保持冻结时特别好用训练速度和吃显存情况都会大幅改善。但注意一旦进入微调阶段解冻卷积层特征缓存这个方法就失效了因为卷积层的权重在变化每轮前向计算出的特征都不同。7. 可视化卷积层看看VGG16究竟学到了什么很多人学了VGG16的结构能说出每层的形状变化但对卷积神经网络到底提取了什么特征依然没有直觉。可视化是打通这一环的最好方式。这里分享两个实用方法。7.1 特征图可视化把一张输入图片分别通过VGG16的前几层把每个通道的输出特征图保存成图片。你会发现浅层卷积核提取的是边缘、线条、色斑中层卷积核开始响应纹理模式比如毛发、鳞片深层卷积核则对物体部件眼睛、轮子、鼻孔产生响应。import numpy as np import matplotlib.pyplot as plt from tensorflow.keras.models import Model # 取一层作为输出 layer_name block1_conv1 intermediate_model Model(inputsmodel.input, outputsmodel.get_layer(layer_name).output) # 读取并预处理一张图片 img tf.keras.preprocessing.image.load_img(sample_cat.jpg, target_size(224, 224)) img_array tf.keras.preprocessing.image.img_to_array(img) img_array np.expand_dims(img_array, axis0) img_array preprocess_input(img_array) # 获取这块的特征图 feature_maps intermediate_model.predict(img_array) # 画出前16个通道的特征图 fig, axes plt.subplots(4, 4, figsize(10, 10)) for i in range(16): ax axes[i // 4, i % 4] ax.imshow(feature_maps[0, :, :, i], cmapviridis) ax.axis(off) plt.tight_layout() plt.show()当你看多了不同层的特征图会明白一个关键道理卷积神经网络的特征是分层的抽象——底层特征具体但通用高层特征抽象但跟任务绑定。VGG16靠堆叠卷积层从像素级的边缘逐步过渡到语义级的猫耳朵每一步都建立在前一步的特征组合之上。这个认知对设计网络结构和调优方向极其重要。7.2 用Keras Grad-CAM定位模型关注的区域在实际业务中模型凭什么这样判断常常比模型判断得准不准更重要。Grad-CAM梯度加权类激活映射是一种经典的可视化技术通过计算类别输出对最后一个卷积层特征图的梯度得到每个通道对分类决策的重要性权重然后加权求和得到热力图叠加到原图上就能看到模型重点关注图像的哪个区域。from tensorflow.keras import backend as K import cv2 def grad_cam(model, img_array, layer_nameblock5_conv3, class_idxNone): grad_model Model( inputs[model.input], outputs[model.get_layer(layer_name).output, model.output] ) with tf.GradientTape() as tape: conv_output, predictions grad_model(img_array) if class_idx is None: class_idx np.argmax(predictions[0]) loss predictions[:, class_idx] grads tape.gradient(loss, conv_output) pooled_grads tf.reduce_mean(grads, axis(0, 1, 2)) conv_output conv_output[0] pooled_grads pooled_grads.numpy() for i in range(pooled_grads.shape[-1]): conv_output[:, :, i] * pooled_grads[i] heatmap np.mean(conv_output, axis-1) heatmap np.maximum(heatmap, 0) heatmap / np.max(heatmap) # 与原图叠加 img cv2.imread(sample_cat.jpg) heatmap cv2.resize(heatmap, (img.shape[1], img.shape[0])) heatmap np.uint8(255 * heatmap) heatmap cv2.applyColorMap(heatmap, cv2.COLORMAP_JET) superimposed cv2.addWeighted(img, 0.6, heatmap, 0.4, 0) return superimposed我在一次工业质检项目中用过这个技术。一个模型在分辨OK和NG工件时准确率很高但可视化后发现它关注的却是工件旁边的背景区域——因为训练集中某种背景颜色和NG样本高度相关模型学了一个偷懒的捷径。看到热力图的那一刻我才真正体会到可视化不仅是在看模型而是在审查模型。这类偏差单靠准确率指标完全发现不了也是Grad-CAM这类技术最值钱的地方。8. 升级替代有哪些比VGG16更值得用的卷机网络最后想说点实在的。VGG16是学习卷积神经网络原理的绝佳教材但在真实项目中除非你的场景极其特殊否则我不会建议从零使用VGG16做生产模型。它太老了、太重了。按我的经验在不同的典型场景下有更合适的选择中小型数据集分类任务首选ResNet50或EfficientNetB0。ResNet50通过残差连接解决了深层网络梯度消失问题且预训练权重同样丰富。EfficientNetB0在同等参数量下精度更高、推理更快。VGG16的优势只剩结构通俗易懂和预训练权重随处可得。移动端或边缘设备部署MobileNetV2/V3、EfficientNet-Lite几乎是标准答案。深度可分离卷积把标准卷积拆成逐通道卷积和逐点卷积两步参数量和计算量都大幅降低精度损失却相对可控。这类模型在树莓派上跑实时推理都绰绰有余VGG16想都不要想。需要高精度、有大算力场景如果追求极致精度EfficientNetV2、ConvNeXt这类现代模型明显优于VGG系列。但要清醒地认识到精度提升的绝大部分贡献来自训练技巧比如更优学率调度、更强的数据增强、更大的batch而非单纯的网络结构变化。所以哪怕你换了更好的模型训练策略的经验依然通用。但我也要说句公道话正因为VGG16结构规整、没有花哨操作它依然是学术界和工业界沟通的通用语言。很多论文的Baseline还是拿VGG16做对比很多老项目里跑着VGG16的代码很多现成的Fine-tuning工具和第三方权重都围绕VGG16构建。彻底丢下它有时候反而会遇到兼容性问题。我的做法是给团队新人讲原理时一定用VGG16上生产环境时则按场景选更合适的模型——两手都要各取所长。从我自己的实践来看理解VGG16、跑通VGG16微调流程、掌握可视化手段这套组合拳打下来基本上就能应付绝大多数图像分类需求的入门开发了。往后遇到更复杂的检测、分割任务你会发现它们骨子里依然是特征提取器任务头的套路只是特征提取器和任务头的具体形式变了而已。把地基打扎实了后面的路会顺畅很多。
返回列表