猫狗分类(Dogs vs Cats)这个项目,几乎每个深度学习入门的人都会撞上。数据量适中、类别清晰、不需要标框也不需要搞分割,拿它当第一个完整的图像分类实战再合适不过。但说实话,如果你只是图省事,直接调一个现成的预训练模型把流程跑通,那收获其实很有限。真正值得做的是用TensorFlow从零搭一个经典的AlexNet网络,亲手处理数据、定义网络、训练、评估、调参,把卷积神经网络的几块核心组件彻底吃透。这篇文章就来完整地走一遍这个过程,从网络结构设计到训练调参、再到最后的预测和误差分析,代码全给你放出来,照着敲就能跑。
这篇文章适合几种人:刚看完卷积神经网络基础理论、想用一个真实项目验证理解的人;跑过一些现成示例但没自己搭过完整网络的人;以及想了解经典AlexNet在现代TensorFlow版本里怎么落地的同学。我会把每个设计决策背后的“为什么”也讲清楚,而不是直接甩一份代码让你复制。
1. 项目背景与整体设计思路
1.1 为什么选AlexNet作为练手目标
AlexNet在深度学习历史上是一个绕不开的节点。2012年它在大型图像识别竞赛里拿下冠军,把错误率一下子拉低了一大截,直接引发了后面那波深度学习浪潮。现在看它,结构不算复杂:5层卷积加3层全连接,但它第一次把ReLU激活函数、Dropout、局部响应归一化(LRN)、重叠最大池化这些技术在同一个网络里完整组合起来。所以复现AlexNet,等于把卷积神经网络最核心的知识点全部过了一遍。
相比后来那些动辄几十层上百层的网络,AlexNet还有一个优势:结构“看得过来”。它每一层的输出尺寸、通道数、参数量,算起来都很直观,非常适合做手工计算练习。我用它做完猫狗分类后,再去理解那些更深更复杂的网络,明显感觉底盘稳了很多。
这里也提醒一句:不要纠结于“这网络是不是过时了”。练手项目的核心价值是理解原理,AlexNet结构和现代网络相比虽然老,但里面的每一层你都解释得清楚,比什么都重要。
1.2 猫狗大战数据集的特点与难点
“猫狗大战”原始数据集在这个圈子流传很广,网上随便一搜就能找到下载地址。整套数据包含25000张训练图片和12500张测试图片,猫狗各半。测试集是没有标注的,需要自己训练好模型去预测,然后按格式提交到一个评分系统里比对结果。但如果只是练习,完全可以把训练集再切出一块当验证集,用来评估模型效果。
这类数据集有个很典型的共同特点:图片尺寸不统一,有横的、有竖的、有大有小,有的猫狗还只占了画面的一部分。这跟我在项目里经常调侃的“证件照式数据集”完全不是一个难度。处理这种数据,最锻炼人的地方就是预处理那一步,你必须在喂给网络之前,把所有图片统一缩放、归一化,还得想清楚怎么在不破坏语义的情况下去增强数据。
另外,这个数据集对新手来说还有一个隐性难点:25000张图,如果耐心不够,加载和训练过程可能会很磨人。我自己第一次跑的时候,光训练就花了不少时间,后来才学会合理控制输入尺寸和批量大小。这些具体思路后面展开讲。
1.3 技术方案选型:从理论到代码的决策过程
技术方案其实有三个决策点:框架版本、网络实现方式、训练流程组织方式。
框架版本我用的是TensorFlow 2.x。这可能是现在最省心的选择,因为2.x把Keras深度集成进来,写起来比1.x那种先建图再跑会话的方式舒服太多。你要是在网上搜AlexNet的代码,会看到大量1.x时代的写法,里面全是各种占位符、会话运行之类的代码,在2.x里基本跑不起来了。这一点我在后面“常见坑点”那节还会专门说。
网络实现方式我用的是Keras的Sequential顺序模型加函数式API混搭。因为AlexNet主体就是一个串行结构,用顺序模型完全可以描述。至于局部响应归一化,TensorFlow 2.x没有现成的层,要么用Lambda包一层计算逻辑,要么用BatchNormalization替代。我在代码里会给出我更推荐的方案。
训练流程上,我采用数据从目录直接读取的方式,配合数据增强层和回调函数,这样代码简洁,也不容易因为手动管理数据而踩坑。整个方案的取舍原则很简单:尽量贴近经典结构,同时保证代码在今天的TensorFlow环境里能顺利运行。
2. 环境搭建与数据准备
2.1 运行环境版本建议
先说环境,因为环境不一致导致的各种玄学报错真的太常见了。我自己建议的版本配置如下:
| 组件 | 推荐版本 | 备注 |
|---|---|---|
| 操作系统 | Windows 10/11 或主流Linux发行版 | 没有硬性要求,个人感觉Linux下训练更稳 |
| Python | 3.8 - 3.10 | 太新的版本有时会和TensorFlow的依赖有兼容问题 |
| TensorFlow | 2.10 - 2.13 | 2.6版本以下的代码风格差异较大,不太建议 |
| CUDA / cuDNN | 跟随TensorFlow版本对应的推荐版 | 装错版本会直接导致GPU无法识别 |
| 显卡 | 不限,显存4G以上最好 | 低显存也能跑,需要小批量和小尺寸 |
如果机器没有NVIDIA显卡,也完全可以用CPU训练,就是慢一些。AlexNet比现代大模型小很多,CPU训练也能收获完整流程,只是建议把数据增强后的图片尺寸调小一点,并把训练轮数控制好。
2.2 数据集下载与目录组织
数据集的原始文件名是猫狗混合的,格式大概像“cat.0.jpg”、“dog.0.jpg”这种。第一步是把它们整理成TensorFlow可以直接从文件夹读取的目录结构。推荐用下面的方式组织:
cat_dog_data/ ├── train/ │ ├── cat/ │ ├── dog/ ├── val/ │ ├── cat/ │ ├── dog/这样组织后,image_dataset_from_directory这类加载接口可以直接把子文件夹名当成类别标签,自动解析出每个图片的标签,省去手写标签映射的麻烦。
手动整理的过程用操作系统自带的文件移动命令,或者写一段简单的Python脚本都行。需要注意:移动文件之前,最好先把猫和狗对应的原文件名做一次抽查,确认没有猫图混进狗文件夹。我遇到过下载的解压包本身就有几个错位文件的情况,如果不去检查,这些脏数据会导致模型训练过程无端出现诡异的准确率波动。
另外提醒一下验证集的切分比例。我在实际项目中常用8:2,也就是留出20%左右的图片作为验证集。这个比例在25000张的规模下,足够让验证曲线稳定,也不至于让训练数据明显缩水。切分时最好先对文件名排序,再按比例抽样,避免因为文件在磁盘上的顺序存在规律,导致验证集分布不均衡。
2.3 图片预处理与数据增强策略
图片预处理至少包含三件事:统一尺寸、归一化、数据增强。
统一尺寸方面,AlexNet原始输入是227x227像素。虽然很多资料里也写成224x224,但原始论文实际用的就是227,因为第一层卷积核11x11、步长4,算下来正好是227。为了忠实复现经典结构,我就用227x227。显卡比较一般的话,降到160x160也能跑,但精度会受一定影响。
归一化是深度学习的常规操作。把像素值从[0,255]缩放到[0,1]区间,可以让梯度更新更平稳。在Keras里,用Rescaling(1.0 / 255)这个预处理层就能轻松完成,不需要自己手动对每张图做运算。
数据增强这块,我一开始觉得猫狗数据集已经有25000张图,应该够用,结果训练时还是很快就出现过拟合迹象。后来老老实实加上随机水平翻转、随机旋转、随机缩放,情况才明显改善。增强就是要人为制造“新的”训练样本,让模型见过更多姿态的猫和狗,提高泛化能力。代码里我习惯把增强层放在网络的最前面,这样训练时每次迭代都会动态地生成略有差异的图片。
3. AlexNet网络结构逐层拆解
3.1 卷积层组的具体设计与参数含义
AlexNet的5层卷积,可以分为三个卷积组,每一组后面都跟一个最大池化层。下面把这几个卷积层的参数用表格梳理清楚:
| 层名 | 卷积核大小 | 卷积核数量 | 步长 | 填充方式 | 输出尺寸 |
|---|---|---|---|---|---|
| Conv1 | 11 x 11 | 96 | 4 | valid | 55 x 55 x 96 |
| Conv2 | 5 x 5 | 256 | 1 | same | 27 x 27 x 256 |
| Conv3 | 3 x 3 | 384 | 1 | same | 13 x 13 x 384 |
| Conv4 | 3 x 3 | 384 | 1 | same | 13 x 13 x 384 |
| Conv5 | 3 x 3 | 256 | 1 | same | 13 x 13 x 256 |
为什么第一层要用11x11这么大的卷积核?大卷积核的感受野大,可以更快地看到图像里的整体结构,适合捕捉猫狗的轮廓和耳朵、尾巴这类大尺度特征。后面几层再用5x5和3x3的小卷积核,在更深的层提取更细微的边缘和纹理特征。这种“从大到小”的卷积核组合思路,在后面的很多网络里都能看到影子。
每个卷积层后面,原始结构还包含一个局部响应归一化层。LRN的核心思想是响应大的神经元会被加强,而周围竞争激烈的响应被抑制,有点类似“让神经元之间互相压制、突出显著特征”的效果。现在很多实践表明LRN带来的精度提升很有限,甚至可以被忽略。但为了贴近AlexNet原版,我在代码里提供了两种处理方式,一种是用自定义层实现LRN,另一种是用BatchNormalization替代。个人建议新手直接使用BatchNormalization,训练更稳,也更容易收敛。
3.2 全连接层与输出层设计
卷积部分结束后,特征图被展平成向量,进入全连接层。AlexNet原版的三个全连接层分别是4096、4096、1000,其中1000对应ImageNet的1000个类别。在猫狗二分类任务里,最后一层的神经元数量改成2,并使用Softmax激活函数输出两个类别的概率分布。这表示输入图片属于猫的概率和属于狗的概率,两者之和始终为1。
全连接层的参数量非常惊人。整个AlexNet有数千万个参数,其中绝大多数就在最后的全连接层里。这也是为什么需要在全连接层中间插入两个Dropout层。Dropout的原理是训练时随机让一部分神经元失活,让网络不能过度依赖某一个特定神经元,从而提升泛化能力。猫狗数据集上如果不加Dropout,过拟合基本是必然的。
代码里常用的Dropout比例是0.5。这个比例在AlexNet原论文里就是0.5,实际操作中也验证了在猫狗任务上比较合适。你可能会在别的项目里看到0.3或0.4,那不是万能的,应该根据自己的数据量和过拟合程度调整。
3.3 完整模型定义代码
下面这段代码是我在项目里实际使用的AlexNet简化实现版本。为了代码可读性,我使用了Keras的顺序模型,并在卷积层之间加入了BatchNormalization来替代原版LRN。如果你想要更贴近论文的版本,可以把BatchNormalization换成后面给的LRN自定义层。
import tensorflow as tf from tensorflow.keras import layers, models def build_alexnet(input_shape=(227, 227, 3), num_classes=2): model = models.Sequential([ # 数据增强层:放在网络最前面,训练时动态变化 layers.RandomFlip("horizontal"), layers.RandomRotation(0.05), layers.RandomZoom(0.1), layers.Rescaling(1.0 / 255), # 第一卷积组 layers.Conv2D(96, (11, 11), strides=4, padding="valid", activation="relu"), layers.BatchNormalization(), layers.MaxPooling2D((3, 3), strides=2), # 第二卷积组 layers.Conv2D(256, (5, 5), padding="same", activation="relu"), layers.BatchNormalization(), layers.MaxPooling2D((3, 3), strides=2), # 第三卷积组 layers.Conv2D(384, (3, 3), padding="same", activation="relu"), layers.Conv2D(384, (3, 3), padding="same", activation="relu"), layers.Conv2D(256, (3, 3), padding="same", activation="relu"), layers.MaxPooling2D((3, 3), strides=2), # 全连接部分 layers.Flatten(), layers.Dropout(0.5), layers.Dense(4096, activation="relu"), layers.Dropout(0.5), layers.Dense(4096, activation="relu"), layers.Dense(num_classes, activation="softmax") ]) return model如果坚持要按照原版加LRN,可以这样实现:
from tensorflow.keras import layers import tensorflow as tf class LRNLayer(layers.Layer): def __init__(self, alpha=0.0001, beta=0.75, k=1.0, n=5, **kwargs): super().__init__(**kwargs) self.alpha = alpha self.beta = beta self.k = k self.n = n def call(self, inputs): depth_radius = self.n // 2 return tf.nn.local_response_normalization( inputs, depth_radius=depth_radius, bias=self.k, alpha=self.alpha, beta=self.beta )然后把这个层插到每个卷积组中、池化层之前即可。
第一层卷积没有在padding里设成same,输出尺寸会比输入小很多,这是AlexNet的设计。后面卷积层使用same保持尺寸,直到池化层再压缩。大家在自己修改网络时,特别要注意各层输出尺寸的匹配问题,别到全连接层才发现特征图展平后和第一个Dense神经元数量对不上。
4. 模型训练与超参数调优实录
4.1 超参数的设置逻辑与推荐值
训练阶段的超参数,每一项都不是拍脑袋定的,背后都有调整逻辑。下面的配置是我在猫狗数据集上多次试验后觉得最稳妥的一档:
| 超参数 | 推荐值 | 说明 |
|---|---|---|
| 批量大小 | 32 | 显存不够就用16,更大不等于更好 |
| 训练轮数 | 15 | 配合早停,实际在10轮左右基本收敛 |
| 优化器 | Adam | 初始学习率设为0.0001到0.001之间 |
| 学习率 | 0.001 | 太高会使损失爆炸,太低收敛太慢 |
| 损失函数 | 稀疏交叉熵 | 因为标签是整数而不是独热编码 |
| 评估指标 | 准确率 | 二分类任务最直观的指标 |
这里重点展开一下批量大小。批量大小决定了每次参数更新前模型“看”多少张图。批量太小,比如4或8,梯度方向容易不稳定,训练起来比较震荡;批量太大,比如256,单次更新需要的计算量高,显存占用也大,而且不一定收敛得更好。32是经典图像分类任务里一个很均衡的取值,经验上看在精度和训练速度之间取得了不错的平衡。
学习率方面,Adam优化器自带自适应机制,对学习率没有那么敏感,但0.001仍然是一个很良好的一般选择。我在实际训练中遇到过一次loss变成NaN的情况,排查下来是学习率设置到了0.01,优化器在刚开始时梯度爆炸了。这个经验可以省去你好几个小时的排查时间。另外,建议配合ReduceLROnPlateau回调,在验证集指标停滞时自动降低学习率,往往能在训练后期再榨出一点精度。
4.2 数据加载与训练脚本
数据加载直接使用TensorFlow自带的目录读取工具,可以省掉大量手写数据管道的代码。切分训练集和验证集的操作在加载时直接完成,不需要物理移动文件,非常方便,程序如下:
train_ds = tf.keras.utils.image_dataset_from_directory( "cat_dog_data/train", validation_split=0.2, subset="training", seed=42, image_size=(227, 227), batch_size=32, ) val_ds = tf.keras.utils.image_dataset_from_directory( "cat_dog_data/train", validation_split=0.2, subset="validation", seed=42, image_size=(227, 227), batch_size=32, )建议顺手动用cache()和prefetch()优化性能,这样训练时不用每次迭代都去磁盘重新读图:
train_ds = train_ds.cache().prefetch(buffer_size=tf.data.AUTOTUNE) val_ds = val_ds.cache().prefetch(buffer_size=tf.data.AUTOTUNE)接着定义模型、编译和回调函数,开始训练:
model = build_alexnet() model.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=0.001), loss="sparse_categorical_crossentropy", metrics=["accuracy"], ) callbacks = [ tf.keras.callbacks.EarlyStopping(patience=3, restore_best_weights=True), tf.keras.callbacks.ReduceLROnPlateau(factor=0.2, patience=2), tf.keras.callbacks.ModelCheckpoint("best_alexnet.h5", save_best_only=True), ] history = model.fit( train_ds, validation_data=val_ds, epochs=15, callbacks=callbacks, )EarlyStopping的restore_best_weights=True非常关键。它会在验证损失不再下降时提前终止训练,并把模型权重回滚到验证集表现最好的那一刻,避免后面多训练的轮次反而把模型带偏。
4.3 训练过程结果解读
我在实际训练中的记录显示,前3个epoch训练准确率会快速爬升,从50%左右一路上升到80%附近,这属于正常现象。模型在学习率较高的情况下快速拟合了训练集的主要分布。验证集准确率会稍微滞后一些,也是正常的,因为它每轮看到的都是没训练过的图。
从第5轮开始,训练曲线和验证曲线之间的差距会慢慢拉开。如果差距越拉越大,训练准确率逼近95%而验证准确率停在88%左右,这就是过拟合的信号。这时候不是急着加数据增强,而是应该先看一下是不是Dropout比例太小、正则化不够,或者是不是验证集本身分布和训练集有偏差。
整体跑到第10轮左右,验证准确率能稳定在90%上下。对从零训练、没有使用预训练权重的AlexNet来说,这个成绩已经算是不错的基准线。如果后续还想冲更高,可以尝试迁移学习、增大训练数据、多尺度训练等更复杂的方案,但那些就是另一个阶段的事了。
5. 模型评估与预测可视化
5.1 评估模型的最佳姿势
训练结束后,不要急着拿测试集去预测,更不要拿验证集去调参之后再用它评估。评估模型的标准姿势是:验证集用于训练过程中的模型选择和早停,测试集用于最终一次性评估。但在猫狗大战这个任务里,官方测试集没有标签,所以实际操作中要么从训练集再切出一部分完全独立的测试集,要么就用验证集做一个初步评估。
我习惯把验证集指标作为主要参考,然后随机抽取一批验证集图片做人工目检。深度学习模型在指标上可能看不出问题,但一看图片就露馅。比如模型可能严重依赖于背景颜色,或者专门识别毛色,而不是真正学会猫狗的关键特征。人工检查几十张预测图,是快速发现这类问题的有效手段。
5.2 单张图片预测完整代码
模型训练好之后,实际使用时通常是拿来对单张图片做预测。下面的代码演示了如何加载一张图片,做与训练时一致的预处理,然后输出猫和狗的概率:
import numpy as np from tensorflow.keras.preprocessing import image def predict_single_image(model, img_path): img = image.load_img(img_path, target_size=(227, 227)) img_array = image.img_to_array(img) img_array = np.expand_dims(img_array, axis=0) # 增加批次维度 # 注意:训练时模型里已经有Rescaling预处理层 probs = model.predict(img_array, verbose=0)[0] labels = ["cat", "dog"] pred_idx = np.argmax(probs) return labels[pred_idx], probs[pred_idx]这里有个新手常见的错误:训练阶段如果把数据增强写进了模型里,预测时增强层仍然在模型结构中,它也会生效。虽然增强层默认是训练模式才做随机变换,但为了保险,预测时可以单独定义一个不含增强层的推理模型,或者使用model.predict时指定不更新状态。TensorFlow的预测逻辑一般会自动处理,但自己心里要清楚这件事,否则看到每次预测同一张图结果不同,还以为是模型没保存好。
5.3 混淆矩阵与类别误差分析
准确率是整个数据集的宏观指标,但二分类问题里只看准确率远远不够。如果我模型把猫全猜成狗,狗全猜成猫,准确率照样可能50%。所以,我建议在验证集上输出混淆矩阵,看看每个类别内部的表现。
from sklearn.metrics import confusion_matrix, classification_report val_ds_no_shuffle = val_ds.unbatch().batch(32) y_true = [] y_pred = [] for batch_x, batch_y in val_ds_no_shuffle: pred_probs = model.predict_on_batch(batch_x) pred_labels = np.argmax(pred_probs, axis=1) y_true.extend(batch_y.numpy()) y_pred.extend(pred_labels) cm = confusion_matrix(y_true, y_pred) print(cm) print(classification_report(y_true, y_pred, target_names=["cat", "dog"]))根据我的实操经验,猫和狗的预测错误在分类初期经常是“猫被认成狗”略少于“狗被认成猫”的。原因是数据里狗的姿态和品种非常多,有些小型犬的耳朵和脸型确实接近猫;反过来,有些猫是短毛猫,身体纹理偏模糊,也容易被误判。如果看到这种结果,不要焦虑,这反映的是数据本身的难度而非代码Bug。想进一步提升,可以考虑对出错最多的图片做针对性数据增强,比如增加旋转角度、加入随机裁剪,让网络见过更多局部特写。
6. 常见坑点与实战避坑指南
6.1 TensorFlow 1.x代码在2.x环境下的兼容问题
这个坑可以说是所有从老教程起步的人都会遇到。网上能搜到大量AlexNet的实现,但很多都是TensorFlow 1.x时代的代码,里面充斥着占位符、会话运行、变量初始化这一类写法。在2.x环境下直接运行会报各种奇怪错误。比如tf.placeholder已经被彻底删除,tf.Session也不存在了。
解决办法有两个方向:一是看懂老代码的意图,然后用Keras重写;二是保持网络结构不变,只把计算图部分换成2.x的写法。我的建议就直接用Keras的Sequential,因为AlexNet本身结构就是顺序的,没必要绕回复杂的图构建模式。如果你在学习时发现某个教程用的还是tf.contrib里的函数,直接放弃那个教程,寻找更新版本,tf.contrib整个模块都已经被移除了。
6.2 目录结构与标签错位问题
使用image_dataset_from_directory加载数据时,类别标签是由子目录名决定的。问题恰恰容易出在这里:有人把train/cat里放的是狗图,或者把两个类别的名字写成了“Cat”和“dog”,大小写不一致,导致标签顺序和预设不符。虽然TensorFlow能自动把字母序靠前的目录名映射为0,但如果你默认“猫是0、狗是1”,而实际映射变成了“dog”为0,就会导致混淆矩阵的解读完全反过来。
对策很简单:数据集加载完成后,先用一个批次的数据打印出类别名称和标签的对应关系。这一条在调试初期就做,能省下后面所有结果分析阶段的困惑。
6.3 显存不足与训练崩溃处理
显存不足的报错通常表现得很粗暴,训练进行到某个批次直接崩溃,提示CUDA或显存相关错误。遇到这种情况,优先把批量大小从32降到16或8,这是最直接的手段。如果仍然溢出,再把输入图片尺寸从227x227降到160x160,这会成倍降低显存消耗,因为卷积层的中间特征图尺寸跟着变小了。
还有一个提升显存利用效率的方案,是使用混合精度训练。在TensorFlow中开启起来很简单:
from tensorflow.keras import mixed_precision mixed_precision.set_global_policy("mixed_float16")这个方案可以让支持的操作使用半精度浮点计算,显存占用明显减少,而在现代显卡上精度损失通常可忽略。如果是第一轮跑通项目,我不建议上来就开混合精度,先把基础流程跑通,再考虑优化。
6.4 过拟合与loss为NaN的诊断方法
过拟合的判断标准是训练集指标远好于验证集指标。遇到过拟合,先在模型层面看Dropout是否存在、比例是否合理;再看数据层面是否做了增强;最后再考虑是否应该用更小的网络。AlexNet本身有数百万参数,在不做任何正则化的情况下,猫狗数据集很容易被“背下来”,所以Dropout和增强都不是可选项,而是必须项。
loss变成NaN则是另一种常见故障。它通常是学习率太大导致梯度爆炸。解决方式是先把学习率降到0.0001重新训练,如果能稳定收敛,再用0.0005逐步加回来。此外,如果使用了自定义损失函数,也要检查里面有没有除零操作或对数值取对数的非法输入。NaN一旦出现,前期训练的所有权重基本都废了,与其费力寻找哪一步开始坏,不如直接重开并降低学习率来得高效。
6.5 图片加载问题与通道数不统一
猫狗数据集的图片几乎全是JPG,但偶尔也会混进一两张PNG格式的图。PNG可能带有透明通道,加载后变成4通道,而模型的输入是三通道,API会直接报错。处理方法是在预处理时统一转换成RGB三通道。自己写数据管道时,还要注意灰度图可能会以单通道载入,这同样会引发问题。稳妥做法是所有图片先统一转换成RGB,再做尺寸调整和归一化。
你在复现这个项目时,如果每一步都稳扎稳打地把数据和模型搞明白,收获会远超“把准确率刷到多少”这件事本身。我个人最大的体会是:手动从零搭建一个经典网络,比直接套用预训练模型更能帮你建立对卷积、池化、Dropout这些核心概念的直观理解。后续你想继续扩展,可以做迁移学习、目标检测,甚至去实验不同结构和超参数组合,猫狗数据集是一个很理想的试验场。把这份代码留好,它就是你的第一块深度学习试验田。