ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

CNN猫狗分类实战:TensorFlow+Keras从数据到模型调参全解析

CNN猫狗分类实战:TensorFlow+Keras从数据到模型调参全解析

猫狗分类这个项目,我从 TensorFlow 和 Keras 入手做过不止一遍,每次带新人入门,也都拿它当第一块练手砖。它看着简单——不就是区分一张图里是猫还是狗吗,但真把数据预处理、卷积特征提取、训练调参这些环节走一遍,你对深度学习整个工作流的理解会扎实很多。甚至很多实际项目里的坑,比如类别不均衡、过拟合、训练时间失控,在这个小项目里全都能提前踩一遍。这篇文章就把我用 CNN 做猫狗分类的整套思路和实操过程完整拆开,从为什么选这个题、数据怎么准备,到网络结构怎么设计、训练参数怎么调,再到常见问题怎么排查,一次性说清楚。

1. 项目拆解与整体思路

1.1 猫狗分类为什么是 CNN 入门的“标准课”

图像分类是卷积神经网络最经典的应用场景,而猫狗分类又是图像分类里最亲民的一个。原因很简单:数据好找、任务直观、效果可感知。你用几个全连接层去做这件事,准确率大概率卡在 70% 上下,但换成 CNN,轻松能到 90% 以上。这种“同一种任务、不同结构、天差地别”的对比,对理解卷积层到底在干什么特别有帮助。

更深一层来看,猫狗分类虽然题目简单,但它几乎覆盖了图像分类任务的所有核心环节:图片读取与尺寸统一、数据增强、模型构建、损失函数选择、训练验证、模型保存与加载、单张图片预测。这些环节一个不少,而且每个环节都能在这个小项目里被验证。我见过很多新人直接上手做工业级项目,结果被复杂的数据管线劝退;反过来,也见过有人只跑完 MNIST 手写数字就以为自己会深度学习了,结果连自己的图片路径都处理不明白。猫狗分类正好处于中间位置——比 MNIST 复杂,但又不至于让人迷失在细节里。

从实际业务角度看,这个项目的思路可以平移。比如某开发者想做一个宠物店自助结算系统,或者小区宠物门禁识别,甚至只是帮朋友做一个“判断自家狗有没有偷吃猫粮”的小工具,底层逻辑和猫狗分类一模一样。所以别小看这个入门项目,它背后藏着的是图像分类通用流水线。

1.2 技术选型:为什么是 TensorFlow 加 Keras

选 TensorFlow 而不是其他框架,很大程度是因为它在工业落地、社区资料、部署生态上的积累比较厚。虽然 PyTorch 在研究领域非常流行,但 TensorFlow 的 SavedModel 格式、TensorFlow Lite 的移动端支持,对后面做实际产品化更顺手。Keras 作为 TensorFlow 的高层 API,又把建模门槛压得很低——几十行代码就能定义一个完整的 CNN,这对快速验证想法特别重要。

我实际用下来的感受是,Keras 的 Sequential 模型在 CNN 这种“层叠结构”上写起来非常直观,卷积层、池化层、全连接层一层一层叠上去,网络结构一目了然。而且 Keras 内置了 ImageDataGenerator 做数据增强,EarlyStopping 做早停,ModelCheckpoint 做模型保存,这些都是在实际项目中高频使用的工具,不用自己重复造轮子。

还有一个容易被忽略的选型理由:TensorFlow 2.x 的 Keras 已经深度整合,tf.keras 就是官方推荐的写法,不用担心 API 兼容性问题。早期 TensorFlow 1.x 那套 Session、placeholder 的写法折磨了很多人,现在用 tf.keras 就清爽多了。你要是完全没基础,直接从 2.x 的 Keras 入手是最省心路径。

2. 数据准备:比模型更重要的第一步

2.1 数据集结构与目录约定

做图像分类,第一步就是把数据整理成模型能直接用、代码能直接读的结构。我习惯按类别建文件夹,再用 Keras 的flow_from_directory自动读取。推荐的数据组织方式如下:

data/ train/ cats/ cat.001.jpg cat.002.jpg dogs/ dog.001.jpg dog.002.jpg validation/ cats/ cat.001.jpg dogs/ dog.001.jpg

这里有两个关键点。第一,训练集和验证集要提前分好,不能把验证图片混进训练集;第二,验证集的比例通常在 10% 到 20% 之间,数据量小的时候可以稍微多留一点。我一般用 20% 做验证,这样训练曲线看起来更稳,判断是否过拟合也更可靠。

我自己常用的样本规模是猫狗各 1500 张左右,总共 3000 张训练图——这个量级在 CPU 上也能跑,在 GPU 上则非常快。你如果手头的数据更多,效果会更好,但别一上来就追求几万张,反而被数据整理耗掉精力。每张图的分辨率,我统一缩放到 150x150,这个尺寸对猫狗分类这种任务足够,而且训练速度快,迭代调试效率高。直接上 224x224 也不是不行,但训练时间会明显变长,初学者前期调试模型结构会非常痛苦。

2.2 数据增强与 ImageDataGenerator 实战

在数据量不够大的时候,数据增强是防止过拟合最有效的手段之一。Keras 的ImageDataGenerator可以方便地对训练图片做旋转、平移、缩放、翻转等变换。我实际用下来的配置是这样:

from tensorflow.keras.preprocessing.image import ImageDataGenerator # 训练数据增强配置 train_datagen = ImageDataGenerator( rescale=1./255, rotation_range=40, width_shift_range=0.2, height_shift_range=0.2, shear_range=0.2, zoom_range=0.2, horizontal_flip=True, fill_mode='nearest' ) # 验证数据只做归一化,不做增强 validation_datagen = ImageDataGenerator(rescale=1./255)

第一行rescale=1./255是把图片像素值从 0~255 缩放到 0~1,这对神经网络训练极其重要。我见过有人漏掉这一步,训练出来的损失值怎么调都降不下去,最后发现是数据范围差了几个量级。

rotation_range=40表示随机旋转 40 度以内,width_shift_range和height_shift_range是水平和垂直方向的随机平移,zoom_range是随机缩放,horizontal_flip是水平翻转。对猫狗分类来说,水平翻转非常合理——猫往左看往右看都是猫,不会改变类别。但垂直翻转就要谨慎,因为实际照片很少倒着拍,翻转后反而可能让模型学到不真实特征。fill_mode='nearest'则是在变换后填充图片边缘空白区域时使用最近邻方式,避免出现大片黑边干扰训练。

数据增强的另一个作用是让小数据集也能训练出较稳定的模型。之前就有个跑过 MNIST 的人来问我,为什么自己用同样的网络在猫狗数据集上效果差很多,其中一个核心原因就是没做增强。MNIST 是 28x28 的灰度图,而且数字相对规范;自然图像则千变万化,不增强就非常容易过拟合训练集。

3. CNN 模型设计细节

3.1 卷积层的设计思路与参数计算

CNN 的核心特点是局部感知和参数共享。我先用一个实际模型结构来说明,然后再解释每一层的设计意图:

from tensorflow.keras import layers, models model = models.Sequential([ # 第一个卷积块 layers.Conv2D(32, (3, 3), activation='relu', input_shape=(150, 150, 3)), layers.MaxPooling2D((2, 2)), # 第二个卷积块 layers.Conv2D(64, (3, 3), activation='relu'), layers.MaxPooling2D((2, 2)), # 第三个卷积块 layers.Conv2D(128, (3, 3), activation='relu'), layers.MaxPooling2D((2, 2)), # 展平后接全连接层 layers.Flatten(), layers.Dropout(0.5), layers.Dense(512, activation='relu'), layers.Dense(1, activation='sigmoid') ])

这里最值得解释的是input_shape=(150, 150, 3)。150x150 是图片尺寸,3 是 RGB 三个通道。第一层卷积用了 32 个 3x3 的卷积核,输出特征图的高度和宽度会从 150x150 变为 148x148,因为默认 padding 是valid,不填充边缘。随后 MaxPooling2D 用 2x2 窗口做下采样,特征图变成 74x74。经过三个“卷积+池化”块之后,到 Flatten 前的特征图尺寸大约是 18x18x128,这个数字虽然不用死记,但在排查维度错误时能帮上大忙。

为什么要用 3x3 卷积核而不是 5x5 或者更大?3x3 是目前实践中性价比很高的选择:它在参数量上比 5x5 小很多,又可以通过堆叠两层来获得接近 5x5 的感受野。两个 3x3 卷积的叠加,感受野等效于一个 5x5,但参数量更少,非线性表达能力还更强。这在很多经典网络结构里都是反复验证过的设计。

从第一个卷积块到第三个卷积块,通道数从 32 增加到 64 再增加到 128,这是 CNN 的常见趋势——越到深层,特征越抽象,通道数越多;同时空间尺寸不断减小,信息被逐步浓缩。

3.2 从卷积到全连接:展平、Dropout 与输出层

卷积层和池化层提取的是空间特征,但要输出一个“是猫还是狗”的概率,还需要把这些特征展开成向量,再接全连接层。Flatten()就是把三维特征图(18x18x128)拉平成一维向量,这一步没有可学习参数,只是改变数据形状。

接着我加了一个Dropout(0.5)。这个操作在训练时会随机让一半的神经元不工作,强迫网络不依赖某一个节点,从而提高泛化能力。Dropout 放在全连接层之前、展平之后,是很多经典结构都会采用的组合。我试过不加 Dropout,训练集准确率很快到 99%,验证集却卡在 85% 左右——典型的过拟合;加上以后,验证集能提升到 90% 以上。

Dense(512, activation='relu')是第一个全连接层,512 个神经元。这里 512 是经验值,不是越大越好,因为全连接层参数量很大,容易过拟合。Dense(1, activation='sigmoid')是输出层,只有一个神经元,输出范围 0~1,代表“是狗的概率”。sigmoid 函数天然适合二分类问题,输出大于 0.5 判为狗,小于 0.5 判为猫。

可能你会问:为什么不直接用两个神经元加 softmax?两种写法在数学上是等价的,但对二分类来说,单个 sigmoid 神经元配合二元交叉熵损失,是更简洁也更稳定的做法。我在实际项目中一律用这种写法。

4. 编译、训练与调参

4.1 优化器与损失函数的选择

模型定义好之后,接下来是编译:

model.compile( optimizer='adam', loss='binary_crossentropy', metrics=['accuracy'] )

优化器我直接选 Adam。它的好处是自带自适应学习率,基本不用手动调参,对初学者极其友好。你可能会好奇它和传统 SGD 的差别——简单说,Adam 会根据每个参数的历史梯度来调整更新步长,让训练更平稳、收敛更快。传统 SGD 需要手动精心调节学习率和动量,对新手就是个头痛问题。

损失函数选binary_crossentropy,因为这是二分类任务。它的作用是衡量预测概率和真实标签之间的差异,值越低表示预测越接近真实。从工程经验来说,如果看到训练损失持续下降,说明模型在学习;如果损失不降反升,通常是数据、模型结构或学习率出了问题。

metrics=['accuracy']让训练过程中同步输出准确率,方便监控。这里注意一点:accuracy 在类别比较均衡的数据集上才有参考意义。如果你猫狗数量严重不平衡,比如 90% 是猫,那你全部预测猫也能有 90% 准确率,指标就失去意义。所以做这个项目时,我会顺手确认一下训练集和验证集的类别分布是不是大致相当。

4.2 训练参数与回调函数设置

数据准备好以后,用flow_from_directory加载数据:

train_generator = train_datagen.flow_from_directory( 'data/train', target_size=(150, 150), batch_size=32, class_mode='binary' ) validation_generator = validation_datagen.flow_from_directory( 'data/validation', target_size=(150, 150), batch_size=32, class_mode='binary' )

然后训练:

from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint callbacks = [ EarlyStopping(monitor='val_loss', patience=5, restore_best_weights=True), ModelCheckpoint('cats_dogs_model.h5', monitor='val_loss', save_best_only=True) ] history = model.fit( train_generator, steps_per_epoch=train_generator.samples // 32, epochs=50, validation_data=validation_generator, validation_steps=validation_generator.samples // 32, callbacks=callbacks )

batch_size=32是一个常用起步值,意思是每轮迭代用 32 张图片计算一次梯度。batch size 小,训练波动大但内存占用小;batch size 大,训练稳定但需要更多内存,而且太大容易收敛到平坦的极小值,泛化能力可能下降。我做过几次对比,对这个小数据集来说,32 和 64 差别不大,但 16 会看到损失曲线抖动更明显。

steps_per_epoch表示每个 epoch 要跑多少步。它是训练样本总数除以 batch size 得到的。比如你训练集有 2400 张图,batch size 是 32,那一轮就是 75 步。这里我用//整除,如果样本数不能被 batch size 整除,最后不足一批的部分会被直接丢弃。数据量少时为了避免浪费样本,可以把steps_per_epoch设置为样本数除以 batch size 后向上取整,但更稳妥的做法是保持数据集完整、调整 batch size,确保整除关系清晰。

两个回调函数是训练过程中的“保险丝”。EarlyStopping监控验证集损失,连续 5 轮不改善就自动停止训练,并恢复到验证集损失最低时的权重,避免无效训练和过拟合。ModelCheckpoint会在验证集损失更优时保存模型。我强烈建议新人从一开始就加上这两个回调,否则很容易跑着跑着发现模型已经过拟合了,又得重新训练。

训练过程中,你可能会看到类似这样的输出:

Epoch 1/50 75/75 [==============================] - 45s 600ms/step - loss: 0.6932 - accuracy: 0.5231 - val_loss: 0.6929 - val_accuracy: 0.5063 Epoch 5/50 75/75 [==============================] - 42s 560ms/step - loss: 0.6321 - accuracy: 0.6625 - val_loss: 0.6112 - val_accuracy: 0.7031 Epoch 10/50 75/75 [==============================] - 40s 533ms/step - loss: 0.4243 - accuracy: 0.8121 - val_loss: 0.3620 - val_accuracy: 0.8438

如果前几个 epoch 的准确率在 50% 上下波动,不用慌,这是模型刚开始学不到有效特征的正常表现。真正要警惕的是训练准确率一路飙升、验证准确率却上不去,那就意味着过拟合开始出现了。

5. 评估与预测

5.1 损失曲线与验证集表现分析

训练结束后,第一步不是急着预测单张图片,而是先看训练曲线。我会把 history 里面的 loss 和 val_loss、accuracy 和 val_accuracy 分别画出来对比。等到训练完成之后,从 history 对象中提取记录:

import matplotlib.pyplot as plt acc = history.history['accuracy'] val_acc = history.history['val_accuracy'] loss = history.history['loss'] val_loss = history.history['val_loss'] plt.figure(figsize=(12, 4)) plt.subplot(1, 2, 1) plt.plot(range(len(loss)), loss, label='Training Loss') plt.plot(range(len(val_loss)), val_loss, label='Validation Loss') plt.legend() plt.title('Loss Curves') plt.subplot(1, 2, 2) plt.plot(range(len(acc)), acc, label='Training Accuracy') plt.plot(range(len(val_acc)), val_acc, label='Validation Accuracy') plt.legend() plt.title('Accuracy Curves') plt.show()

看曲线的标准姿势是:训练损失和验证损失都持续下降,并且差距不大,说明模型健康。如果训练损失降得很低、验证损失先降后升,就是过拟合。如果两者都不降,训练过程可能根本没收敛。这个判断习惯,你在任何图像分类项目里都用得上。

除了曲线,我还会在验证集上手动检查预测结果,用混淆矩阵看模型到底把猫错认成狗,还是把狗错认成猫。对二分类来说,混淆矩阵就四个数:真正例、真负例、假正例、假负例。通用代码:

import numpy as np from sklearn.metrics import confusion_matrix validation_generator.reset() predictions = model.predict(validation_generator, steps=validation_generator.samples // 32) predicted_classes = (predictions > 0.5).astype(int) true_classes = validation_generator.classes cm = confusion_matrix(true_classes[:len(predicted_classes)], predicted_classes) print(cm)

注意这里有个细节:validation_generator.classes是数据集的真实标签,但它的顺序和predict的输出顺序需要一一对应,所以我在预测前调用reset()确保从头开始读。有人在这里踩过坑,顺序对不上,结果混淆矩阵完全没意义。

5.2 单张图片预测与结果解读

模型训练好之后,最终还是要解决“给一张图判断是猫还是狗”的真实问题。我把单张预测写成一个可以反复调用的函数:

from tensorflow.keras.preprocessing import image def predict_image(img_path, model_path='cats_dogs_model.h5'): model = models.load_model(model_path) img = image.load_img(img_path, target_size=(150, 150)) img_array = image.img_to_array(img) img_array = np.expand_dims(img_array, axis=0) img_array = img_array / 255.0 pred = model.predict(img_array)[0][0] if pred >= 0.5: label = 'dog' confidence = pred else: label = 'cat' confidence = 1 - pred print(f'{label} with confidence {confidence:.4f}')

np.expand_dims(img_array, axis=0)这一步很关键,因为模型输入要求形状是 (batch_size, 150, 150, 3),单张图只有 (150, 150, 3),缺少 batch 维度,必须手动加一维。很多新人第一次跑预测时报维度错误,基本都卡在这。

还有一个容易忽略的点:单张预测时,输入的图片也要除以 255。我在训练时用ImageDataGenerator(rescale=1./255)做了归一化,但单张预测时没有任何增强器帮你做这件事,必须自己在代码里写。如果漏掉这步,模型输入分布和训练时不一致,预测结果会莫名其妙。

输出的置信度也要正确理解。model.predict返回的值是模型判断为“狗”的概率。我习惯把它转成更直观的格式:如果预测为狗,置信度就是这个值;如果预测为猫,置信度是 1 减去这个值。这样无论哪类,输出的都是一个“有多确定”的概率,而不是只看大于 0.5 还是小于 0.5。

6. 常见问题与排查技巧实录

6.1 训练准确率不升 / 损失不下降

这个问题的排查优先级非常固定。我遇到过几次,每次都能归结到下面几个原因。

第一,检查数据归一化。有没有把像素值缩放到 0~1?如果直接输入 0~255 的原始像素值,梯度计算很容易出问题,损失经常卡住不动。第二,检查标签和输出层是否匹配。二分类用sigmoid输出 1 个节点,配合binary_crossentropy;如果你错用categorical_crossentropy和softmax,训练日志会非常难看。第三,检查学习率。Adam 默认学习率是 0.001,通常不用改;但如果损失一直震荡不收敛,可以尝试调低到 0.0001 看看。

还有一个我经常提醒新人的坑:训练数据量太少,比如每类只有几十张,网络前期根本学不到稳定的特征。这时候不是模型的问题,是数据的问题,优先做数据增强或者补充数据。

6.2 过拟合:训练准确率很高但验证准确率低

这是猫狗分类里最经典的现象。训练准确率 95% 以上,验证准确率只有 80% 左右,说明模型把训练集的细节背下来了,而不是学到“什么是猫”“什么是狗”的泛化特征。我的应对顺序是这样的:

先加数据增强,让训练集每次喂给模型的图都不完全一样;再加 Dropout,强制网络不依赖单一特征;然后把全连接层神经元数量从 512 减到 256 或 128,减少参数量;最后考虑用小一点的模型,比如把第三个卷积块的通道数从 128 降到 64,牺牲一点训练精度换取泛化能力。

这些手段可以叠加使用,但不用一步到位,每次只改一个变量,再看验证集准确率有没有变化,这样能定位到真正有效的措施。

6.3 GPU 显存不足与训练时间失控

不是每个人都有独立显卡,这个项目用 CPU 跑也不是不行,只是时间会长。我实际用 CPU 跑 150x150 图像,每个 epoch 大约 40~60 秒,训练 20~30 个 epoch 大约需要 20 分钟到半小时,完全在可接受范围。

如果显存不足,优先降低 batch size,比如从 32 降到 16 或 8;也可以把输入尺寸从 150x150 降到 128x128,这一招对显存占用影响非常明显。

6.4 TensorFlow 版本与路径分隔符的经验

TensorFlow 2.x 和 Keras 的 API 在 2.6 之后有过一些调整,比如flow_from_directory的旧写法可能在部分新版本中被标记为废弃。我建议你在训练前先用print(tf.__version__)确认版本,再在遇到异常时优先搜索与当前版本匹配的写法。

Windows 用户在代码里写文件路径的时候,\会被当成转义字符,例如'data\train'会把\t解析成 Tab 符号。要么统一用正斜杠'data/train',要么在路径字符串前面加r写成r'data\train'。这虽然是个基础问题,但确实浪费过不少人半小时。

我整理了一份速查表,方便你在遇到问题时快速对照定位:

现象最可能原因优先排查方向
损失卡在 0.69 左右不降模型未学到有效特征检查数据归一化、标签与输出层匹配
训练准确率高,验证准确率低过拟合增加数据增强、加 Dropout、减小模型
预测时报维度错误缺少 batch 维度用np.expand_dims增加一维
训练速度极慢CPU 训练且模型偏大减小输入尺寸、降低 batch size
加载图片时报路径错误Windows 转义字符问题用正斜杠或原始字符串

7. 写在最后的几条实操心得

整个项目跑完,有几点体会特别深。第一,入门 CNN 最大的障碍不是数学公式,而是对数据流的理解——从文件夹到 ImageDataGenerator,再到模型输入,每一步的数据形状是什么,必须心里有数。建议你多打印train_generator.next()的输出,看看一个 batch 的数据长什么样。

第二,模型不是越复杂越好。我在同一份数据上试过把卷积层堆到五六层,验证集准确率并没有明显提升,反而训练时间倍增。对小数据集来说,三层卷积、两层全连接已经是很合理的配置。

第三,调参要有纪律。每次只改一个参数,记录下改动前后验证集的变化。我见过不少人在数据增强、学习率、网络结构几个变量之间来回改,结果模型表现变好了都不知道是哪一步起的作用,变差了也不知道该回滚到哪个版本。

第四,这个项目的后续扩展空间很大。换用迁移学习,比如用预训练模型做特征提取,通常能把准确率做到 99% 以上;把 Keras 模型导出成 TensorFlow Lite 格式,就能部署到手机端跑推理。如果你已经把这个基础流程走通,下一步很值得试试这些方向。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进