这是永兴TensorFlow笔记的第15篇。前几篇把全连接网络、反向传播、优化器这些基础讲了不少,理论看着都明白,但一上手搭卷积神经网络做图像分类,还是踩了不少坑。这篇笔记不打算重复教科书里的推导,而是把一次完整的实践过程拆开给你看:数据怎么准备、网络结构怎么选、训练参数怎么调、训练完怎么判断模型表现,再把过程中遇到的几个典型问题原原本本记下来。
如果你也卡在“看了很多教程,但自己动手时不知道从哪开始”这个阶段,这篇笔记应该能帮你把CNN的完整流程跑通,而且每一步都告诉你为什么要这么做。
1. 项目目标与整体设计思路
1.1 为什么图像分类绕不开卷积神经网络:三个致命短板
先从一个很朴素的问题说起:图像分类任务为什么不用我们之前用过的全连接网络?我自己第一次尝试时,是把图片拉成一维向量直接丢进全连接层,结果惨不忍睹。后来仔细分析了一下,全连接网络处理图像有这三个绕不开的问题。
第一个是参数爆炸。一张32像素乘32像素、带RGB三个通道的小图,展平之后就是3072个数值。如果第一层全连接设128个神经元,这一层的参数量就是3072乘128,接近40万个。图片稍微大一点,比如128乘128,展平后就有接近5万个输入,模型参数直接奔着百万级去,小数据集根本喂不饱这样的模型。
第二个是空间结构被完全丢掉。图像里识别一只猫,靠的是耳朵形状、眼睛位置、毛发纹理这些局部特征,以及它们之间的相对位置关系。全连接网络把所有像素等同对待,它不知道“相邻像素”意味着什么,也不知道特征在空间上如何排列。换句话说,它把图片当成一个没有内部结构的长向量来学。
第三个是位置变化需要重复学习。一只猫在图片左上角和右下角,语义是完全一样的,但全连接网络会把这两个位置当成两种完全不同的模式,需要更多样本才能学到“这是一只猫”。而卷积神经网络天生具备平移不变的特性,同一个卷积核可以在整张图上滑动,发现猫的特征和它出现在哪里无关。
用人脑来类比会更直观:你看一张照片时,会先被边缘、轮廓、色彩过渡吸引,而不是逐像素扫描。CNN的卷积操作本质上就是用一个滑动窗口去扫描图片,在每个局部区域提取模式,然后把得到的特征图层层递进、逐步抽象。这就是它适合做图像任务的根本原因。
1.2 怎样设计一个“小而稳”的CNN骨架:从卷积块到池化
看了一圈开源项目里的网络结构,会发现大家常用的骨架都差不多,不是没有道理的。我这次实践选择了一个非常经典的组合:卷积层加BatchNorm加ReLU激活,穿插最大池化,最后用全局平均池化收尾,再接一个softmax输出层。
之所以选择这种结构而不是直接堆一堆卷积层,有几个原因。卷积层负责提取特征,但特征提取之后需要保持数值稳定,所以紧跟一个BatchNorm层把输出拉回合理分布范围,这能让梯度下降过程稳定很多。激活函数选择ReLU是因为计算简单、不容易饱和,比sigmoid在深层网络里好训练得多。最大池化用来降低特征图的空间尺寸,一方面减少计算量,另一方面把局部的显著特征保留下来——比如最大池化2乘2窗口后,图像尺寸减半,通道数不变,计算开销直接降到之前的四分之一。
那这篇笔记的具体任务是什么?用公开图像分类数据集,从零搭建一个CNN完成分类任务,目标是把完整流程跑通,并对结果做可视化诊断。选择小数据集而不是动辄几十万张的大数据集,原因很实际:在普通配置的电脑上也能几分钟内完成一轮训练,方便反复试错,把网络结构、超参数调整、结果分析这套流程吃透。等你理解了“小网络怎么工作”,再迁移到更大的模型和数据集上就有了底气。
2. 数据准备环节:别让输入在第一步就带偏
2.1 数据加载与归一化:把像素变成网络能消化的输入
很多初学实践的人会在数据加载这一步偷懒,直接把原始数据丢给网络。这里先提醒一句:数据准备的规范程度,直接决定训练能不能收敛。TensorFlow给了很好用的数据加载接口,但加载出来之后有几件事必须自己做。
代码上我是这样处理的:
import tensorflow as tf from tensorflow.keras import layers, models # 这里以公开的小型图像分类数据集为例 (train_images, train_labels), (test_images, test_labels) = ( tf.keras.datasets.cifar10.load_data() ) # 归一化:把像素值从 [0, 255] 映射到 [0.0, 1.0] train_images = train_images.astype("float32") / 255.0 test_images = test_images.astype("float32") / 255.0 # 标签从 (N, 1) 调整为 (N,),方便后续计算 sparse 损失 train_labels = train_labels.reshape(-1) test_labels = test_labels.reshape(-1)归一化这一步不是可有可无的。原始像素值是0到255的整数,数值范围很大。没有归一化时,损失函数在不同方向上的变化速度差异很大,梯度下降会走得很扭曲,学习率稍大一点就容易震荡。把数据压到0到1之间后,梯度更新方向更稳定,优化器能在一个相对规整的损失面上搜索。
有一点值得注意:归一化的统计量只能用训练集计算,然后把同一套映射应用到测试集。虽然这里简单除以255不涉及统计量,但如果你用均值和标准差做标准化,务必先用训练集算好均值方差,再套用到测试集上。如果测试集用了自己的统计量,等于泄露了一部分数据分布信息,评估结果就不真实了。
2.2 数据增强只加在训练集:别让你的验证集“作弊”
只有一个数据集的训练样本太有限,模型很容易把样本背下来而不是学会抽象规律,也就是过拟合。这时数据增强就有用了。数据增强通过对图片做随机变换,制造出和原始样本同分布但没有完全重复的新样本,相当于白捡了一部分训练数据。
我这次只用了几个轻量级的增强操作:水平翻转、小幅旋转和缩放平移。实现方式也很简单,直接加在模型输入之前:
data_augmentation = tf.keras.Sequential([ layers.Input(shape=(32, 32, 3)), layers.RandomFlip("horizontal"), layers.RandomRotation(0.1), layers.RandomZoom(0.1), ])但这里有一个原则性细节:数据增强只能用在训练集,测试集和验证集必须保持原始图片不动。原因很简单,验证集和测试集的作用是评估模型在真实数据上的表现,如果在验证时也做了随机增强,每次评估的输入都不确定,得到的指标既有模型能力的影响,又有随机变换的影响,没法如实反映模型水平。
我当时踩过一个坑:图省事把增强层接到整个输入管道上,导致每轮epoch跑验证集时图片都不一样,val_loss上下跳得很厉害,早停回调都失灵了。后来把增强拆出来只给训练分支用,验证曲线一下子就平滑了。
2.3 Batch与数据管道:小批量训练为什么更稳
数据准备好之后,还要用tf.data把它组织成批量输入。直接一次性把所有数据喂给模型,容易出现两个问题:内存压力大,且梯度更新方向被大批量数据平均后,反而容易陷入局部最优。我习惯用小批量训练,每批32到64个样本。
批量组织的代码很固定:
batch_size = 64 train_dataset = tf.data.Dataset.from_tensor_slices((train_images, train_labels)) train_dataset = train_dataset.shuffle(buffer_size=1024).batch(batch_size).prefetch(tf.data.AUTOTUNE) test_dataset = tf.data.Dataset.from_tensor_slices((test_images, test_labels)) test_dataset = test_dataset.batch(batch_size).prefetch(tf.data.AUTOTUNE)这里的shuffle是打乱样本顺序,避免同一类别的图片集中在同一个batch里,导致梯度被某一类样本主导。prefetch(tf.data.AUTOTUNE)让数据加载在GPU或CPU计算的同时提前进行,减少训练过程中等待数据的空档。跑长训练时,这段管道优化的价值非常明显——同样一个模型,加上prefetch之后每轮epoch能快小半分钟。
为什么小批量训练更稳?可以这么理解:全量梯度相当于看完全部样本再做决定,方向最准但计算量最大;单样本梯度更新最快但噪声很大,路径会很抖。小批量取了一个折中,既保证梯度方向大致正确,又带有一定随机性,有助于跳出局部极小点。实践里如果把batch size从64提升到128,一般不需要调学习率;但如果提升到256以上,梯度更平滑了,学习率也可以考虑相应放大。
3. 卷积神经网络的搭建:每一层都在做什么
3.1 第一层卷积:让网络先学会看边缘和色块
现在来到工程的核心步骤:把网络结构写出来。我用函数式API而不是Sequential,原因后面会提到——做特征可视化时方便拿到中间层输出。
我先说第一层卷积的设计思路。输入图片是32乘32的RGB图,所以Input(shape=(32, 32, 3))。第一个卷积层我习惯用32个3乘3的卷积核,padding设置为same。3乘3是现在的主流选择,因为两个3乘3卷积堆叠起来等价于一个5乘5卷积的感受野,但参数更少、非线性也更多,训练起来更稳。padding用same是为了让卷积后特征图尺寸不变,因为后续池化层需要规则地减半空间尺寸,如果尺寸碎了,后面网络设计会很别扭。
第一层输出的特征图是32通道、空间尺寸仍为32乘32。通道数从3变成32,意味着网络把这32乘32的图像分解成32种不同的“局部模式响应”,有的是水平边缘、有的是垂直边缘、有的是颜色过渡。训练初期这些模式很粗糙,但已经比原始像素值高一个层次。
3.2 通道数逐步加深:从局部纹理到全局语义
卷积层不是堆得越多越好,但CNN的通道数设计有一个非常常见的经验规律:每经过一次空间降采样,通道数翻倍。我这次就是这样做的:通道数32、64、128,空间尺寸32、16、8。
增加通道数是在给网络更多的“记忆维度”。低层卷积负责提取局部纹理,这些模式数量有限,32个通道够用。但到中层时,模型需要把边缘组合成纹理、把纹理组合成局部部件,需要更多的特征表示空间,所以通道数增加到64。到了网络的深层,需要判断的已经是“这个区域像不像眼睛”“那片区域的轮廓像不像耳朵”这样更抽象的模式,数量更多,通道数也就成长到128。
中间我穿插了两个最大池化层,每次把空间尺寸减半。池化带来的好处是计算量骤降,同时把局部最显著的特征保留下来,并且让后续卷积层看到更大的区域——感受野变大了。图片从32乘32经过两次2乘2最大池化后变成8乘8,如果继续加池化就是4乘4,到这一步其实已经足够支撑一个分类判断了。
3.3 用全局平均池化收尾:省参又防过拟合
收尾部分的设计可能是这版结构里最值得说的一点。在得到8乘8乘128的特征图之后,很多旧式网络会把特征图展平成向量,再接一到两个全连接层。但展平后的维度是8乘8乘128,等于8192维,再接全连接层参数又上来了。我这次选择了GlobalAveragePooling2D,它对每个通道直接求平均值,把8乘8乘128压缩成128维向量,参数量为零,而且保留了每个通道最重要的整体响应。
这样做的好处很实际:第一,参数数量大幅减小,过拟合风险降低;第二,模型对特征位置的敏感度进一步降低——特征图从8乘8变成1乘1的过程中,只保留“这个特征是否存在”的平均响应,不再关心具体出现在哪个位置。这也和前面说的图像分类需要平移不变性相一致。
随后接一个Dropout(0.3)再进入输出层。Dropout在训练时随机丢弃30%的神经元连接,迫使网络不能依赖某几个特定神经元,是一种常用的正则化手段。放在全局平均池化之后而不是之前,是因为这里的128维向量已经比较浓缩,丢掉一部分后网络仍然能学出可靠的分类边界。
完整模型搭建代码如下:
inputs = layers.Input(shape=(32, 32, 3)) x = data_augmentation(inputs) x = layers.Conv2D(32, 3, padding="same")(x) x = layers.BatchNormalization()(x) x = layers.ReLU()(x) x = layers.MaxPooling2D(2)(x) x = layers.Conv2D(64, 3, padding="same")(x) x = layers.BatchNormalization()(x) x = layers.ReLU()(x) x = layers.MaxPooling2D(2)(x) x = layers.Conv2D(128, 3, padding="same")(x) x = layers.BatchNormalization()(x) x = layers.ReLU()(x) x = layers.GlobalAveragePooling2D()(x) x = layers.Dropout(0.3)(x) outputs = layers.Dense(10, activation="softmax")(x) model = models.Model(inputs, outputs) model.summary()你可以在本机跑一下model.summary(),会看到总参数量并不大。主要参数都集中在最后一层Dense(10),128乘10加上偏置,只有1290个。前面所有卷积层的参数合起来也不到十万。同样的数据量,如果换成纯全连接网络,第一层的参数量就超过这个数了。
在这里加一个直观的对比表格:
| 网络方案 | 输入特征维度 | 第一层参数规模 | 相对风险 |
|---|---|---|---|
| 全连接单隐层网络 | 3072 | 约40万 | 极易过拟合 |
| 本文CNN(卷积+池化+全局池化) | 32x32x3 | 卷积层仅数百到数千 | 参数量小,泛化更稳 |
从表格能看出来,CNN之所以是图像任务的默认选择,不是因为它“听起来更高级”,而是它从结构上就把参数规模和先验知识控制住了。
4. 训练配置与调参实录
4.1 优化器选型与学习率设置:从1e-3开始调
网络结构搭好之后,训练配置直接决定你能不能收敛。最常见的优化器选型有SGD和Adam两种。SGD方向稳定但收敛慢,对学习率要求高;Adam自带自适应学习率,对初始参数的敏感度低,是我跑实验时的默认选择。
学习率初始值我习惯从1e-3开始。这个值对大部分中小型网络都算一个安全的起点。如果训练日志里loss下降得很慢,可以试着调到3e-3或5e-3;如果loss出现剧烈震荡,立刻降回1e-4看看。不要追求一步到位,而是观察前几个epoch的表现再微调。
损失函数这里选择的是SparseCategoricalCrossentropy。它的输入是整数标签,例如数字3就直接传3,不需要变成one-hot向量。如果标签已经是one-hot形式,那就要换成CategoricalCrossentropy。两者的数学本质一样,只是标签编码方式不同。
配置代码如下:
model.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=1e-3), loss=tf.keras.losses.SparseCategoricalCrossentropy(), metrics=["accuracy"], )4.2 早停与学习率衰减:两个必须加的回调
训练最怕的不是误差大,而是不知道该什么时候停。如果手动控制epoch数,设少了模型欠拟合,设多了过拟合。所以我这次加了两个回调:EarlyStopping和ReduceLROnPlateau。
EarlyStopping监控验证集损失,连续多个epoch没有改善就提前终止训练。这里有一个特别容易忽略的细节:restore_best_weights参数一定要设为True。否则训练结束后模型保存的是最后一个epoch的权重,而最好的权重可能在几个epoch之前就已经出现,结果等于白早停了。
ReduceLROnPlateau的作用是当验证指标进入平台期后自动把学习率减半,让模型在更小的步长下继续精细搜索。我设置的是连续2个epoch验证损失不下降就把学习率乘0.5,最低降到1e-6。
early_stop = tf.keras.callbacks.EarlyStopping( monitor="val_loss", patience=5, restore_best_weights=True, ) reduce_lr = tf.keras.callbacks.ReduceLROnPlateau( monitor="val_loss", factor=0.5, patience=2, min_lr=1e-6, ) history = model.fit( train_dataset, validation_data=test_dataset, epochs=30, callbacks=[early_stop, reduce_lr], )4.3 训练日志怎么看:loss和accuracy的读法
训练跑起来之后,日志很容易让人迷惑。我记几个训练中期典型的日志片段,做一个解读。
假设出现这样的情况:
Epoch 8/30 Accuracy: 0.7214 - Loss: 0.8320 Validation Accuracy: 0.6136 - Validation Loss: 1.0942训练准确率明显高于验证准确率,而且训练loss比验证loss低不少,说明模型正在过拟合——它对训练样本记得很牢,但没有很好地把规律泛化到新样本上。这时候不是盲目继续训练就能解决的,需要看几个方向:增强是否太弱、Dropout比例要不要提高、模型容量是不是过大。
另一个情况:
Epoch 12/30 Accuracy: 0.7830 - Loss: 0.6150 Validation Accuracy: 0.6277 - Validation Loss: 1.3912训练loss还在降,但验证loss已经比上一个epoch明显回升了,这是过拟合的典型信号。如果没有早停回调,后续只会越走越远;有了早停,训练会在验证loss不再创新低的几个epoch后自动停下来,恢复最优权重。
以我这次的训练过程来说,大概在十几轮之后模型准确率稳定在70%上下。这个数字跟动辄90%以上的成熟模型没法比,但对于一个从零开始、结构极简、没有预训练权重的网络来说,放在小数据集上已经能说明整条流程是通的。你要做的不是追求一个高不可攀的准确率,而是确认每一步都在朝正确的方向走。
5. 结果诊断与可视化:让模型的可解释性说话
5.1 训练曲线诊断:过拟合写在图里
训练结束之后,我最先做的一件事就是画出训练和验证的loss曲线。很多人直接把history.history丢到变量里就不看了,其实曲线里能读出很多信息。
训练loss和验证loss都在下降且差距不大,说明模型状态良好。如果训练loss持续下降而验证loss先下降后反弹,就是过拟合的曲线形态。如果两边loss都很高甚至不降,更可能是学习率设置不当或网络结构出了问题。
画图的代码不复杂,用matplotlib就能完成:
import matplotlib.pyplot as plt history_dict = history.history epochs = range(1, len(history_dict["loss"]) + 1) plt.plot(epochs, history_dict["loss"], label="train_loss") plt.plot(epochs, history_dict["val_loss"], label="val_loss") plt.xlabel("Epochs") plt.ylabel("Loss") plt.legend() plt.title("Training and Validation Loss") plt.show()我这里手动分析了曲线:训练loss在前5个epoch快速下降,从2.3降到1.0左右,说明网络结构没问题,梯度能正常回传。往后训练loss继续下降,但验证loss在十几轮后开始徘徊并回升,最终早停触发在验证loss最低的位置。这个结果正好印证了为什么不能只靠训练集指标来判断模型好坏。
5.2 混淆矩阵:看清模型到底错在哪里
准确率是一个很笼统的指标,它只会告诉你“模型整体对了百分之多少”,但不会告诉你它错在哪些地方。想看清错误分布,最直接的工具是混淆矩阵。
TensorFlow自带tf.math.confusion_matrix,调用起来很简单:
predictions = tf.argmax(model.predict(test_dataset), axis=1) confusion_matrix = tf.math.confusion_matrix( test_labels, predictions, num_classes=10 ) print(confusion_matrix.numpy())混淆矩阵的行代表真实类别,列代表预测类别。对角线上的数字越高越好,非对角线上的大数字就是模型经常搞混的类别对。
以我这次的输出为例,最明显的错误集中在一组颜色和轮廓都比较接近的类别上,比如浅色背景下的某类物体和另一类形状相近的物体。这类错误的本质是低层特征相似——边缘走向、颜色分布都很接近,网络没有足够的深层线索去区分它们。改进方向有几个:增加对应类别的训练样本、调整数据增强策略让网络见过更多形态差异、或者加深网络来提取更抽象的特征。
5.3 可视化卷积特征:让每一层告诉你它学到了什么
这一步是我觉得整个实践最有意思的部分:把中间层的特征图打印出来,看网络到底在学什么。前面使用函数式API的原因就在这里——可以非常方便地截取某一层的输出。
# 选取一张测试图片,增加 batch 维度 sample_image = test_images[0][tf.newaxis, ...] # 截取所有层的输出 activation_model = models.Model( inputs=model.input, outputs=[layer.output for layer in model.layers], ) activations = activation_model.predict(sample_image) # 以第一个卷积层为例,打印前8个通道的特征图 first_conv_activations = activations[1] # 索引按层顺序来 for i in range(8): plt.subplot(2, 4, i + 1) plt.imshow(first_conv_activations[0, :, :, i], cmap="viridis") plt.axis("off") plt.show()浅层特征图里能看到大量边缘、纹理和颜色分界的响应,和输入图片的结构有一定的空间对应关系;到中间层,特征图开始变得稀疏,很多区域响应趋近于零,只有关键部位保留较强的激活;到最后的全局平均池化层,特征响应进一步浓缩成128维的向量,很难再用人眼直接解读,但它已经包含了对分类最有判别力的信息。这种从具体到抽象的变化过程,正是卷积神经网络处理图像的直观表达。
6. 常见问题与避坑实录
6.1 损失变成NaN:梯度失控的排查顺序
跑训练时偶尔会遇到loss突然变成NaN,所有指标瞬间失效。这个问题我在网络搭得比较深那几次遇到过几次。出现NaN时,我先不急着改网络,按顺序排查几个原因。
第一,学习率是否过大。学习率太大时,梯度更新一步跨得太远,参数变得极大或极小,数值溢出。解决办法是把学习率降一个数量级,比如从1e-3降到1e-4。
第二,输入数据中是否有异常值或NaN。检查一下训练数据里是不是有缺失值、无穷值混进去了。因为归一化后正常数据都在0到1之间,一旦出现异常值,经过多层卷积放大后很容易变成NaN。
第三,损失函数和标签是否匹配。如果用了CategoricalCrossentropy但标签是整数而非one-hot,或者标签里有超出类别数量的数值,都会导致计算错误。模型summary加上数据shape打印,基本能锁定问题。
6.2 显存溢出:小数据集也扛不住的几个原因
有次我以为数据集不大,训练时不会爆显存,结果还是报了OOM。后来复盘发现,问题不在单个batch_size,而在几个叠加因素上:数据增强层让每次训练读取的图片都经历了多次变换,增加临时显存占用;prefetch又额外缓存了一批数据;再加上3通道图片虽然尺寸小,但通道数在深层翻到128后,特征图显存占用依然不小。
解决办法很顺手:把batch_size从64降到32,prefetch的缓冲区调小或保留AUTOTUNE让系统自行分配,再不行就把中间层的通道数从128减到96。除非你想压榨单机极限性能,否则一般都是这几个参数组合的事。
6.3 Shape不匹配:summary是排错第一工具
搭建CNN时最常见的报错就是维度对不上。比如全连接层接在卷积层输出上,很多时候模型里的shape和你心里想的不一样。遇到这种情况第一件事不是翻文档,而是在报错前先执行model.summary()。它会按顺序打印每个输出张量的shape,一眼就能看出来是哪一层出了问题。
我把这个习惯当成固定流程:每搭完一个网络,先跑summary,确认各层维度符合预期,再开始训练。如果shape不对,重点检查输入图片尺寸、池化层的数量、padding选的是same还是valid。same保持尺寸不变,valid会让尺寸逐步缩小,两者会直接影响最终展平后的维度。
6.4 结果不稳定:先固定随机种子再谈调参
神经网络训练天然带随机性:初始化权重是随机的、数据打乱是随机的、数据增强是随机的。同一个模型多次训练,结果差一两个百分点很正常。但如果差得很多,每次训练曲线形态都不一样,那你需要先设置随机种子,把随机性固定下来,再谈调参。
代码很简单,放在最开头执行:
import tensorflow as tf tf.random.set_seed(42)设置了种子后,同一套代码多次运行结果会一致,这样你每次改一个变量,就能确认真的是这个变量带来了变化,而不是随机波动。
我把这次实践中踩过的坑整理成一张速查表,方便以后排查:
| 问题现象 | 可能原因 | 解决参考 |
|---|---|---|
| loss为NaN | 学习率过大 / 数据有异常值 | 降低学习率,检查输入数据 |
| 验证集loss回升 | 过拟合 | 增强数据增强,提高Dropout,提前早停 |
| 显存溢出 | batch过大 / 增强层显存开销高 | 减小batch,精简管道 |
| shape报错 | 池化/padding计算偏差 | 先跑model.summary()定位 |
| 多次训练结果差异大 | 随机性未固定 | 设置tf.random.set_seed |
最后再分享一点我自己的体会。这次实践做完,最大的收获不是模型准确率提升了多少,而是明白了CNN不是一个黑盒子——每一层在做什么、数据怎么流转、参数怎么调,都可以通过实验和可视化手段去验证。现在拿到一个新数据集,我已经习惯先搭一个极小的网络跑通流程,确认数据管道和损失函数没毛病,再逐步增加深度和宽度。这套流程看起来慢,但每一步都很踏实,适合大多数刚入门CNN实践的学习者。如果你也在自己动手搭网络,不妨也试着把每一层的输出可视化出来看看,那会比单纯跑一堆指标有意思得多。