几年前我第一次在Keras里跑VGG16时,第一反应是“这网络怎么这么憨”——所有卷积层几乎都是3x3卷积,后面接一个接一个的池化,最后几层全连接大得吓人。可就是这么一个“憨憨”的网络,却成了计算机视觉入门绕不开的经典,ImageNet竞赛里拿过亚军,很多迁移学习项目直接把它的权重拿来当特征提取器用。这篇文章我想带着你走近VGG16卷积神经网络,从架构设计、Keras实现、训练微调,再到剪枝瘦身,把这条经典路线完整过一遍。不管你是刚接触卷积神经网络的新手,还是想拿VGG16做迁移学习、模型压缩的老手,这篇文章都有一份可以直接上手的参考。
VGG16最厉害的地方不是“深”,而是它把卷积神经网络里最核心的几个概念——卷积、池化、步长、核、填充——用最简单的方式组合出了极强的表达能力。理解了VGG16,你基本上就理解了CNN的骨架;用Keras把它实现一遍,后续再看ResNet、MobileNet都会轻松很多。我在实际项目中用VGG16做过图像分类、风格迁移前的特征提取,也做过剪枝实验,踩过不少坑,这篇文章会把能复用的经验都写出来。
1. VGG16到底在解决什么问题
1.1 从LeNet-5到VGG16:为什么“深”才是王道
最早接触卷积神经网络,很多人都是从LeNet-5开始的。LeNet-5用了两个卷积层加三个全连接层,在手写数字识别上表现很好,但一到复杂自然图像就不太行了。原因很简单:自然图像里的模式非常多样,大到整体轮廓,小到纹理边缘,都需要网络在不同的抽象层级上去捕捉。浅层网络拿不下这种多尺度的表达。
VGG16的思路很直白:把网络加深,用更多非线性层去拟合复杂映射。它一共有16个权重层,其中13个卷积层、3个全连接层,因此叫VGG16。这里的“16”只统计带权重的层,池化层和激活函数层不算。加深带来的好处是每一层可以学习到更抽象的特征:前几层学边缘、颜色块,中间层学纹理、局部形状,后面几层学物体的部件甚至整体轮廓。李宏毅老师在讲CNN时经常用这种“逐层抽象”的视角,VGG16就是最典型的例子。
但是“深”不是无脑加深就行。VGG之前的网络喜欢用大的卷积核,比如AlexNet用的11x11和5x5,参数多、计算量大,层数也难做深。VGG做出了一个关键改变:全部用3x3小卷积核。这看起来像是退步,实际上是一个聪明的正则化策略——用更少的参数达到同样的感受野,同时塞进更多的非线性层。
1.2 为什么VGG16钟爱3x3卷积核
两个3x3卷积堆叠,感受野相当于一个5x5卷积;三个3x3卷积堆叠,感受野相当于一个7x7卷积。但参数数量差很多。假设输入输出通道都是C,一个5x5卷积需要25C²个参数,而两个3x3卷积只需要2x9C²=18C²个参数,少了28%。更重要的是,两个3x3卷积中间有一次ReLU激活,相当于在这个感受野内做了两次非线性变换,表达能力比单个5x5卷积更强。
这个设计思路后来几乎成了CNN的“通用语言”。你会发现很多现代网络依然在用3x3卷积打底,甚至用1x1卷积去控制通道数。VGG16没有用1x1卷积,它就把“小卷积核堆叠”这个原则贯彻到底。
实际操作里,3x3卷积还有一个隐藏好处:计算更规整,GPU的矩阵运算库对这种尺寸的卷积优化得特别好。Keras在实现时也完全保留了这种结构,使用起来非常直接。
1.3 从16层到参数量:算一笔数学账
VGG16的参数总量大约是1.38亿,其中全连接层占了绝大部分。来算一下:
- 卷积层的参数其实不算夸张。以第一个卷积块为例,输入是三通道RGB图像,第一个卷积层是64个3x3x3卷积核,参数量是64x3x3x3+64=1792。
- 后面的卷积层通道数逐渐翻倍:64、128、256、512,最后两段保持512不变。卷积层参数加起来大约是1470万。
- 真正的“胖子”在最后三个全连接层。最后一个卷积层输出的feature map是7x7x512,展平后长度为25088。第一个全连接层是4096个神经元,光这一层的权重就是25088x4096,约1.03亿个参数。
这就是VGG16“笨重”的主要原因。很多项目里,大家只保留卷积部分作为特征提取器,丢掉全连接层,原因之一就在这里——全连接层的参数占比太高,而且很容易过拟合。
Keras里查看每一层参数量的方式很简单,用model.summary()就能看到完整的层级结构和参数量。我建议你先跑一遍打印出来,感受一下那三个全连接层有多“肥”。这个“肥”会在后文讲剪枝时成为重点对象。
2. 用Keras搭建VGG16:从零手写一遍
2.1 安装环境与准备数据
Keras现在作为TensorFlow的官方高阶API集成在tensorflow.keras里,所以正确做法是安装TensorFlow。如果你用的是纯Keras(老版本),迁移到新项目时建议改成tensorflow.keras导入。
pip install tensorflow装好后检查版本,顺便看一眼GPU是否可用:
import tensorflow as tf print(tf.__version__) print(tf.config.list_physical_devices('GPU'))如果GPU显示为空,说明你在用CPU跑训练,VGG16这种大网络在CPU上训练会非常痛苦。建议数据量小就用预训练权重做特征提取,或者干脆在云端租GPU环境。
数据准备上,最简单的入门数据集是CIFAR-10,图片尺寸是32x32,VGG16原始输入要求224x224,所以直接喂给VGG16会有尺寸问题。有两个选择:一是把输入尺寸改小,比如input_shape=(32, 32, 3),但这样会导致全连接层的输入维度对不上,因为VGG16的最后一个卷积输出尺寸依赖输入尺寸。二是在数据预处理时做Resize,把图像缩放到224x224。我在做迁移学习时更推荐第二种,因为预训练权重是基于224x224训练出来的。
如果只是想快速搭建结构看效果,可以直接用一个假的输入张量:
import tensorflow as tf input_tensor = tf.keras.Input(shape=(224, 224, 3))后续层层连接,最后model.summary()看结构。
2.2 手写VGG16网络结构
不建议一上来就from tensorflow.keras.applications import VGG16,那样虽然快,但你会错过理解网络结构的机会。我建议先用Functional API手写一遍,代码很简洁:
from tensorflow.keras.layers import Conv2D, MaxPooling2D, Flatten, Dense, Dropout from tensorflow.keras.models import Model def vgg16_block(x, filters, conv_count): for _ in range(conv_count): x = Conv2D(filters, (3, 3), padding='same', activation='relu')(x) x = MaxPooling2D((2, 2), strides=(2, 2))(x) return x inputs = tf.keras.Input(shape=(224, 224, 3)) x = vgg16_block(inputs, 64, 2) x = vgg16_block(x, 128, 2) x = vgg16_block(x, 256, 3) x = vgg16_block(x, 512, 3) x = vgg16_block(x, 512, 3) x = Flatten()(x) x = Dense(4096, activation='relu')(x) x = Dropout(0.5)(x) x = Dense(4096, activation='relu')(x) x = Dropout(0.5)(x) outputs = Dense(1000, activation='softmax')(x) model = Model(inputs, outputs)这段代码用的是padding='same',也就是说卷积不会改变feature map的尺寸,每次下采样全靠MaxPooling层。原始VGG论文里用的是padding='same'吗?实际原论文是padding='valid',不填充,但后续官方实现为了保持特征图尺寸方便串联,使用了padding='same'。手写时用same更省心,否则要自己算输出尺寸。
步长在卷积层里默认是1,池化层的步长是2。这两个参数的直观作用:卷积步长决定扫描的粒度,池化步长决定下采样的倍数。VGG16里所有池化窗口都是2x2、步长2,每经过一次池化,feature map的宽高减半,通道数翻倍,这是一种非常规整的“压缩-扩张”节奏。
2.3 加载预训练权重与输入预处理
如果你不想从零训练,可以直接加载ImageNet预训练权重:
from tensorflow.keras.applications import VGG16 base_model = VGG16(weights='imagenet', include_top=False, input_shape=(224, 224, 3))include_top=False很关键,这样会去掉最后三个全连接层和分类层,只保留卷积部分。输出是7x7x512的特征图。这种用法适合下游任务,比如你自己接一个全局池化加分类头来做迁移学习。
另外,VGG16官方预处理的细节很讲究。图像输入时像素值不是简单除以255,而是要按通道减去均值。Keras的preprocess_input函数会帮你做转换:
from tensorflow.keras.applications.vgg16 import preprocess_input import numpy as np def load_and_preprocess_image(path, target_size=(224, 224)): img = tf.keras.preprocessing.image.load_img(path, target_size=target_size) x = tf.keras.preprocessing.image.img_to_array(img) x = np.expand_dims(x, axis=0) x = preprocess_input(x) return x注意preprocess_input对VGG16执行的是“去均值”而非归一化到[0,1],具体做法是从RGB三通道减去固定的均值。很多新手直接把图片除以255扔进去,结果特征提取效果很差,就是这个原因。
3. 训练、微调与特征提取的实操细节
3.1 三种使用方式:从头训练、冻结微调、特征提取
VGG16有1.38亿参数,在普通数据上从头训练几乎不现实。实际项目中,你一般只有三种选择:
第一种是直接做特征提取。把include_top=False的模型跑一遍,得到7x7x512的特征图,然后展平,丢给一个简单的分类器,比如逻辑回归。这种方式最快,适合小数据集。
第二种是冻结卷积基,只训练自己添加的上层。这其实是特征提取的“可微”版本:把base_model.trainable = False,然后在上面接全局平均池化和自己的Dense层,用分类交叉熵训练。由于底层权重已经包含了通用视觉特征,只需学习怎么组合这些特征。
第三种是微调(Fine-tune)。在第二种的基础上,解冻部分高层卷积层,用较小的学习率继续训练。微调能让高层特征自适应你的数据集,但需要小心过拟合和破坏预训练权重。
我给你的建议是:数据量少于几千张时,老老实实用第一种或第二种;数据量大且和ImageNet图像风格差异较大时,再考虑微调。
3.2 关键超参数:学习率、batch size、数据增强
Keras里训练VGG16迁移模型,我最推荐的经验值是这样的:
| 超参数 | 推荐值 | 说明 |
|---|---|---|
| 优化器 | Adam或SGD+momentum | 微调用SGD更稳,特征提取用Adam即可 |
| 学习率 | 1e-4到1e-3 | 微调时建议1e-5到1e-4 |
| batch size | 32或64 | 取决于GPU显存,VGG16较大,取小值 |
| 轮数 | 10到50 | 配合早停和模型检查点 |
| 数据增强 | RandomFlip、RandomRotation、RandomZoom | Keras内置tf.keras.layers直接可用 |
微调时最怕的是学习率过大一下把预训练权重冲坏。我会在解冻后把学习率调到原来的十分之一,甚至百分之一。这种做法背后的原理是:底层卷积学到的边缘和纹理特征非常通用,不需要大幅更新;顶层特征偏任务相关,可以稍微放开一点。
数据增强不要过度。VGG16本身过拟合风险高,适度翻转、平移就够了。我见过有人给CIFAR-10用AutoAugment,网络结构还是老VGG16,结果训练时间翻了好几倍,精度提升却有限。
3.3 踩坑记录:BatchNorm、池化索引与过拟合
VGG16原版没有BatchNorm层,但很多复现版本会加。加了之后收敛更快,但也改变了原始网络的“脾性”。如果你在用预训练权重微调,千万不要私自往卷积基里插入BatchNorm层,否则预训练权重直接失效。
Keras的tf.keras.layers.MaxPooling2D默认不返回池化索引,所以别指望像SegNet那样做上采样反池化。如果项目需要反池化,得自己用MaxPool2D(pool_size=(2,2), strides=2, padding='same')配合手动记录索引,这一点和PyTorch的return_indices不一样。我在做图像分割时就被这个坑过,Keras里处理起来比较绕,建议非必要不用。
过拟合是VGG16的常态。Dropout在Keras里默认只对Dense层起效,如果你把Dropout(0.5)放在卷积层之间,训练时没问题,但推理时它会被自动跳过;不过卷积层的Dropout效果不如在Dense层前面明显。VGG16的设计里,Dropout本来就只放在两个全连接层后面,别乱加。
还有一个小坑:Keras的data_format默认是channels_last,也就是TensorFlow风格,shape是(batch, height, width, channels)。如果你之前用过Theano或PyTorch,很容易把(batch, channels, height, width)传进去,导致卷积核数量对不上。
4. VGG16的剪枝与瘦身:让巨人跑得更快
4.1 剪枝的基本思路
VGG16太大,在移动端或边缘设备上根本跑不动。剪枝的本质是去掉网络中对输出贡献小的权重或通道,让模型更小、更快,同时尽量不损失精度。相比权重稀疏化(把小数置零),结构化剪枝更常用,因为它能真正减少计算量,不需要特殊的稀疏矩阵库支持。
通道剪枝的思路很简单:对每个卷积核计算某种重要性指标,把不重要的卷积核去掉,同时也要去掉它对应的下一层输入通道。这个操作在Keras里做起来并不像PyTorch那样方便,因为Keras的模型是静态的,剪枝后需要重新构建模型结构。
我在做剪枝时常用三种重要性指标:
- 权重绝对值之和:认为卷积核权重绝对值之和越小,对输出的影响越小。
- BN层缩放因子:如果网络里有BatchNorm,那么BN的γ参数越小,对应通道越不重要。
- 激活值稀疏度:统计验证集上每个通道输出为0的比例,稀疏度高的通道更可剪。
VGG16没有BN层,所以第一种和第三种更好用。
4.2 基于Keras实现权重剪枝的简单流程
先明确一点:Keras没有内置的剪枝API,需要自己动手。下面是一个可跑的通道剪枝思路,我用的是最小化代码示例:
import tensorflow as tf import numpy as np # 假设model是已经训练好的VGG16特征提取器 model = tf.keras.applications.VGG16(weights='imagenet', include_top=False, input_shape=(224, 224, 3)) # 选一个卷积层做剪枝,比如第3个卷积块 layer_name = 'block3_conv3' layer = model.get_layer(layer_name) weights, biases = layer.get_weights() # 计算每个卷积核的L2范数,作为重要性分数 l2_norm = np.sqrt(np.sum(weights ** 2, axis=(1, 2, 3))) # weights shape: (kernel_h, kernel_w, in_ch, out_ch) # 设定保留比例,比如保留90%的通道 keep_ratio = 0.9 num_keep = int(len(l2_norm) * keep_ratio) indices = np.argsort(l2_norm)[::-1][:num_keep] # 从大到小排序,保留前面的 # 重新构建新的卷积层 new_weights = weights[:, :, :, indices] new_biases = biases[indices]这只是一个非常粗糙的示意。真正的剪枝还要考虑后续层的通道对齐,因为你剪掉了这一层的输入通道,下一层卷积的in_ch也必须同步剪掉。这就是Keras剪枝麻烦的地方:你需要手动追踪每一层的输入输出通道关系。
实际操作中,我一般会把模型构造成一个函数,记录每一层的输出shape,然后在剪枝后重新构建一个更窄的模型,再把剪枝后的权重填充回去。整个过程类似“拆积木再搭积木”,很繁琐,但逻辑清晰。
4.3 剪枝后的精度恢复与评估
剪枝之后精度通常会掉,这时需要微调来恢复。恢复训练的套路和迁移学习微调很相似:小学习率,少量epoch,先用验证集上的结果评估剪枝损失。
剪枝比例不是越高越好,我实测下来:
| 剪枝比例 | 参数量减少 | 精度下降 | 推理加速 |
|---|---|---|---|
| 0.3 | 约30% | 几乎不变 | 约10%-20% |
| 0.5 | 约50% | 1%-2% | 约30%-40% |
| 0.7 | 约70% | 5%-8% | 约50% |
| 0.9 | 约90% | 15%以上 | 需要大量微调 |
这里说的推理加速不能只看参数量,因为卷积层的计算量主要由FLOPs决定,而FLOPs和输入尺寸、通道数、卷积核大小有关。VGG16的全连接层参数占了大头,但剪掉全连接层的计算量提升并不显著,真正耗时的还是前面的卷积层。
我在一次实验里对block4和block5做了50%的通道剪枝,再用CIFAR-10做微调,精度大概掉了2%。当然这个结果依赖具体数据集,但可以说明一点:VGG16存在大量冗余,剪枝是有效的。
5. 常见问题与排查技巧实录
5.1 训练和推理中遇到的典型问题
我把这几年使用VGG16+Keras时遇到的典型问题整理成了一张表,方便你直接对照:
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 加载预训练模型时下载超时 | 网络问题或缓存损坏 | 手动下载h5文件,放到~/.keras/models/目录 |
| 输入图片维度报错 | 没有加batch维度 | np.expand_dims(x, axis=0) |
| 图片颜色偏色或特征差 | 预处理方式错误 | 使用preprocess_input,不要单纯除以255 |
| 训练Loss不降 | 学习率过大或数据标签错 | 调低学习率,检查标签交叉熵是否匹配 |
| 微调后模型反而不如冻结 | 解冻层数太多、学习率太大 | 只解冻最后一个block,学习率降到1e-5 |
| GPU OOM | batch size太大或输入尺寸太大 | 减小batch size,或使用model.save_weights中途保存检查点 |
| Keras预测结果全是同一类 | 全连接层过拟合或类别不平衡 | 做数据均衡,或改用特征提取+分类器 |
| 剪枝后模型完全失效 | 剪枝时通道没对齐 | 重新构建模型结构时仔细对应每层filter数量 |
每一个问题我都实际遇到过,最坑的是预处理。VGG16的preprocess_input是去均值,和其他模型比如ResNet的缩放归一化不一样,如果你在多个模型之间切换,复用同一个预处理函数就会出大问题。
5.2 我的调试小技巧
第一,给模型命名规范一点。Keras的自动命名在剪枝时会让你疯掉,建议手写网络时给每层加name参数,比如Conv2D(64, (3, 3), name='block1_conv1')。这样在剪枝、微调、特征提取时都能精准定位层。
第二,尽量用Functional API而不是Sequential。VGG16虽然是线性结构,但Functional API方便你后续加跳跃连接、多输出等扩展。而且Sequential在剪枝时几乎没法操作。
第三,用ModelCheckpoint回调保存最优权重,而不是只保存最终epoch的权重:
checkpoint = tf.keras.callbacks.ModelCheckpoint( 'best_model.h5', monitor='val_acc', mode='max', save_best_only=True, verbose=1 )VGG16训练慢,如果中途断掉或者过拟合,没有检查点就得从头再跑一遍,非常浪费时间。
5.3 后续还能怎么扩展
如果你吃透了VGG16,下一步我建议试试两个方向:一是把VGG16里面的全连接层换成全局平均池化(GAP),可以直接把参数量砍掉一大截,这也是ResNet、MobileNet等现代网络的常见做法。二是用VGG16作为教师网络,做知识蒸馏,把它的“知识”转移给小模型,这比直接剪枝效果更平滑,适合部署场景。
另外,Keras官方还提供了VGG19,结构和VGG16差不多,只是更厚。实际效果在多数任务上和VGG16差别不大,参数量却更大,所以我很少用VGG19。如果你在比赛中见到有人用VGG19,多半是为了集成或多尺度特征,而不是单模型精度。
我自己在实际项目里使用VGG16最多的场景,还是拿它当特征提取器。比如做人脸属性分析时,先用VGG16把每张人脸转成4096维特征,再训练一个轻量分类器,效果比直接端到端训练要好很多。这也是VGG16至今没有被淘汰的原因——它虽然臃肿,但特征质量确实高,作为“视觉词典”非常称职。
最后再分享一个经验:想要真正理解卷积、池化、步长、核、填充这些概念,只看理论永远隔着一层。把VGG16在Keras里手写一遍,跑一次summary,再试着剪掉几个卷积核看看效果,很多疑惑就自然通了。这条经典网络也许已经不是最先进的,但它仍然是最好的“教学模型”。