猫狗大战深度学习项目:从CNN到迁移学习的完整实战指南
2026/9/16 22:53:59 网站建设 项目流程

简介:面向优达学城机器学习课程的毕业项目“猫狗大战”,这份Jupyter Notebook工程系统复现了图像分类项目的完整流程:从数据导入与预处理、CNN模型设计,到训练监控、验证评估与参数调优,适合希望动手实践深度学习的入门及进阶学习者。压缩包内共9个文件,以.ipynb主程序为核心,辅以PDF毕业报告、PNG模型结构图、MD说明文档、CSV数据文件及HTML导出页等,整体仅1.91MB,文件精简但覆盖了项目交付所需的全部模块。已有90人学习浏览。资源不仅包含可运行的代码,还提供了详细的实验记录和训练日志,能够帮助读者理解卷积层与池化层的作用、学习率与批次设置、过拟合的判别与调整策略,是完成同类图像识别项目时极具参考价值的完整样例,同时也展示了规范的工程组织方式。

1. 猫狗大战为什么是第一份拿得出手的深度学习简历

先给结论:这个项目虽然看起来是“分猫分狗”的入门玩具,但它实际上是优达学城机器学习纳米学位里最能拉开差距的一道坎。原因很简单,它的评价标准不是“预测对了多少张”,而是“在未知测试集上能跑到多高的准确率”。2013年Kaggle刚上线这个比赛时,顶尖方案准确率在98%左右,而深度学习方法彻底改写游戏规则之后,榜单头部一度冲到99%以上。你拿到的这个pgj.zip里如果只有猫狗图片和几个没跑通的notebook,那你真正要补的不是代码,而是一整套从数据加载、数据增强到迁移学习的标准动作。

对从业五年的工程师来说,这个标题可能显得浅,但它背后涉及卷积神经网络的基础结构、过拟合的诊断、冻结与解冻的迁移学习策略,是一份可以放进简历、面试时能讲清楚前因后果的端到端项目。对刚入门的人而言,它又是第一个能让你完整走完“数据准备 → 模型训练 → 预测提交”全流程的实操样本。本文不假设你手头有那份pgj.zip的具体代码,而是按优达学城项目要求的常规解法,给你一套能在jupyter notebook里直接跑通的最小实现、调参思路和常见报错排查路径。

2. 在jupyter notebook里搭好猫狗大战的基线环境

2.1 用虚拟环境隔离项目依赖,而不是直接在base环境装包

优达学城的多数毕业项目对依赖版本有隐性要求,尤其是TensorFlow和Keras的搭配。猫狗大战虽然数据量不大,但如果你手头的notebook是别人留下的,大概率会碰到版本不一致导致的API报错,常见的有keras.preprocessing.image在TensorFlow 2.9之后被移除、ImageDataGeneratorflow_from_directory返回对象结构变化等。我一般会为这类项目单独建一个conda环境,锁定Python和深度学习框架的版本组合,避免污染日常工作环境。

创建环境的命令如下:

conda create -n dogscats python=3.8 conda activate dogscats pip install jupyter notebook pip install tensorflow==2.8.0 pip install numpy pandas matplotlib scikit-learn

说明一下版本选择的理由。Python 3.8是兼容性最好的版本,TensorFlow 2.8对应Keras 2.8,这个组合里keras.preprocessing.image.ImageDataGenerator还能直接用。如果你的机器有NVIDIA显卡,则可以额外执行pip install tensorflow-gpu==2.8.0,但要注意CUDA必须匹配到cuDNN 8.1和CUDA 11.2,配不上会在import阶段直接报could not load dynamic library的错。

2.2 数据目录结构的硬性约定

无论你手头的zip里图片怎么摆放,我都建议先把数据整理成标准的目录树,因为后面的flow_from_directorytf.data都依赖这个结构。常见的做法是这样组织:

dogscats/ ├── train/ │ ├── cats/ # 按文件名前缀分类 │ └── dogs/ ├── validation/ │ ├── cats/ │ └── dogs/ └── test/ └── unknown/ # 待预测图片,不做标签区分

优达学城原项目的数据划分比例一般是训练集2万张左右、验证集约2500张、测试集约1万张不带标签。如果你手上数据不足2万,可以用train_test_split手动切分。切分代码片段如下:

import os import shutil from sklearn.model_selection import train_test_split # 假设原始文件全部在 raw/ 目录下,命名形如 cat.0.jpg / dog.0.jpg all_files = [f for f in os.listdir('raw') if f.endswith('.jpg')] cats = [f for f in all_files if f.startswith('cat')] dogs = [f for f in all_files if f.startswith('dog')] train_files, val_files = train_test_split(cats + dogs, test_size=0.2, random_state=42)

这里要注意random_state必须固定,否则每次运行notebook切出来的验证集会变化,前面跑的实验结果就没法复现了。

2.3 在jupyter notebook里启动训练的会话写法

启动训练时不要直接在notebook里用python train.py这种写法,而是用魔法命令%run或者直接在单元格里执行函数调用。我常用的模式是:

%load_ext tensorboard

然后在训练代码里加入TensorBoard回调,训练完成后用%tensorboard --logdir ./logs查看曲线。这样做的价值不只是曲线可视化,而是能让你在notebook页面上直观看到loss和accuracy的走向,判断是否出现过拟合。需要说明的是,ImageDataGenerator在验证集上做增强没有任何意义,常见错误是把同一套增强同时应用在训练集和验证集上,这会让验证集不再代表真实分布。

3. 从零训练到迁移学习:猫狗分类模型的三个必选层次

3.1 自建小型CNN做基线的模型结构

如果你直接上手迁移学习,一旦效果不好很难判断是数据问题还是预训练权重适配问题。所以我一般会先写一个极简CNN把完整流程跑通,确认数据加载、训练循环、预测提交都没有bug后,再换预训练模型。这个思路在机器学习项目的实践中被反复验证:先用最简单的模型建立baseline,后面每一步改动才有对比基准。

以下是一个用于猫狗二分类的最小CNN结构:

from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Conv2D, MaxPooling2D, Flatten, Dense, Dropout model = Sequential([ Conv2D(32, (3, 3), activation='relu', input_shape=(150, 150, 3)), MaxPooling2D(2, 2), Conv2D(64, (3, 3), activation='relu'), MaxPooling2D(2, 2), Conv2D(128, (3, 3), activation='relu'), MaxPooling2D(2, 2), Flatten(), Dropout(0.5), Dense(512, activation='relu'), Dense(1, activation='sigmoid') ]) model.compile(optimizer='adam', loss='binary_crossentropy', metrics=['accuracy'])

每层的作用说明如下:卷积层的(3, 3)是感受野大小,activation='relu'引入非线性;MaxPooling2D降低特征图尺寸并增强平移不变性;Dropout(0.5)是刻意的过拟合抑制手段,但注意它只应该在训练时生效,Keras在验证和预测时会自动关闭dropout。输入尺寸选(150, 150)而不是更大的(224, 224),原因是在自建CNN阶段、且没有GPU加速时,150的尺寸能显著减少训练时间,一个epoch大约缩短到四分之一。

3.2 用ImageDataGenerator做数据增强的3个关键参数

猫狗大战的数据集规模对于纯CNN来说很容易过拟合,因为在25000张图中要学习的参数数量远大于数据能提供的约束信息。数据增强是最直接的缓解手段,而ImageDataGenerator的出场率最高。我常用的三组核心参数是rotation_rangewidth_shift_rangehorizontal_flip,其中horizontal_flip=True是必须的,因为猫狗照片的左右镜像不改变类别语义。

from tensorflow.keras.preprocessing.image import ImageDataGenerator train_datagen = ImageDataGenerator( rescale=1.0/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' ) val_datagen = ImageDataGenerator(rescale=1.0/255)

以下三点是根据我在实际项目里的经验总结出来的建议。rotation_range=40代表最多旋转40度,超过这个值猫狗照片的边缘会出现大量无意义填充,模型容易学到黑色边框这种伪特征。fill_mode='nearest'应对的是平移或旋转后留下的空白区域,用最邻近像素填充;若改为'constant',填充值为0,即黑色,反而会引入不存在的边缘信号。验证集的生成器只能做rescale,任何在验证集上做几何增强的行为都会污染评估结果,因为验证集的意义在于模拟真实预测时遇到的自然图片。

3.3 迁移学习:VGG16作为特征提取器的接入方式

基线模型跑通后,迁移学习是让准确率从90%提升到98%左右的最短路径。优达学城的参考方案里使用最多的预训练模型是VGG16,我已经验证过可以稳定工作。选择VGG16的原因有两点:结构简单,层的堆叠方式适合在notebook里复现和解读;它的预训练权重基于ImageNet,它学到的边缘、纹理、形状特征对猫狗分类有直接的迁移价值。

from tensorflow.keras.applications import VGG16 base_model = VGG16(weights='imagenet', include_top=False, input_shape=(150, 150, 3)) base_model.trainable = False model = Sequential([ base_model, Flatten(), Dense(256, activation='relu'), Dropout(0.5), Dense(1, activation='sigmoid') ]) model.compile(optimizer='adam', loss='binary_crossentropy', metrics=['accuracy'])

include_top=False表示去掉ImageNet分类器部分的三个全连接层,只保留卷积基。base_model.trainable = False冻结整个预训练网络的权重,训练阶段只更新后加的全连接层。在jupyter notebook里执行这段代码时,第一次运行会下载约500MB的权重文件到~/.keras/models,如果网速不好建议提前手动下载并放到对应目录,否则单元格会长时间停留在下载阶段,看起来像卡死了。

3.4 冻结与解冻的微调策略

当图3.3中的特征提取模型在验证集上准确率稳定在92%到95%区间时,下一步可以解冻部分顶层卷积层继续微调。解冻的正确做法是逐层指定trainable属性,而不是一键base_model.trainable = True,后者会让所有卷积层的权重同时更新,在大学习率下大概率冲掉预训练特征。

base_model.trainable = True for layer in base_model.layers[:10]: layer.trainable = False

上面这段代码的逻辑是:冻结VGG16的前10层,只允许最后5层的权重参与训练,然后必须用更低的学习率重新编译模型:

model.compile(optimizer=tf.keras.optimizers.RMSprop(learning_rate=1e-5), loss='binary_crossentropy', metrics=['accuracy'])

这里的1e-5不是随意取的数字。预训练模型的权重已经相对稳定,如果学习率维持之前的1e-3,微调时的梯度更新幅度会远超权重本身的数值范围,验证准确率甚至可能从92%瞬间跌到70%。换成RMSprop而不是adam,是因为在迁移学习中RMSprop在调整预训练权重时更平稳,adam的动量机制容易在少量epoch内过度修正。

4. 训练循环里的5个必调参数和3个排查指标

4.1 batch size、epochs与steps_per_epoch的关系

训练模型时最容易被忽略的关系是steps_per_epochbatch_sizetrain_samples三者之间的数量关系。flow_from_directory默认不自动补全最后一个batch,如果不显式指定steps_per_epoch,Keras会按ceil(样本数 / batch_size)来计算。但在用了data augmentation的情况下,数据是实时生成的、每一轮epoch都会重新生成新样本,所以通常设置steps_per_epoch = 训练集图片总数 // batch_size,不要依赖默认值,避免最后的残缺batch造成统计偏置。

history = model.fit( train_generator, steps_per_epoch=20000 // 32, epochs=10, validation_data=val_generator, validation_steps=2500 // 32, callbacks=[model_checkpoint, early_stop, reduce_lr] )

这里参数含义如下:batch_size=32是显存和梯度稳定性的折中,显存充裕可以提高到64,但过高的batch size会让梯度更平滑、收敛更快,却也更容易收敛到泛化差的平底区域;epochs不宜直接跑到15、20这种数字,而是配合早停回调让它自己决定何时停;validation_steps不设的话默认会跑完全部验证集,数据量大时很耗时间,设置为2500 // 32 = 78表示每个epoch用78个batch的验证数据,已经具有足够的统计意义。

4.2 三个回调的配置方法与触发逻辑

我常用的回调组合是ModelCheckpoint、EarlyStopping和ReduceLROnPlateau三个配合使用。三者各司其职:ModelCheckpoint保存最优权重;EarlyStopping在验证集指标连续不提升时中断训练;ReduceLROnPlateau在验证loss进入平台期时自动降低学习率来尝试跳出局部最小值。

from tensorflow.keras.callbacks import ModelCheckpoint, EarlyStopping, ReduceLROnPlateau checkpoint = ModelCheckpoint( 'best_model.h5', monitor='val_accuracy', mode='max', save_best_only=True, verbose=1 ) early_stop = EarlyStopping( monitor='val_loss', patience=5, restore_best_weights=True ) reduce_lr = ReduceLROnPlateau( monitor='val_loss', factor=0.2, patience=3, min_lr=1e-6 )

patience这个参数的取值很关键。在微调阶段,验证loss不下降持续3个epoch是正常的,尤其在用很低学习率的时候,loss曲线会呈阶梯状。如果把patience设得太小,例如1,那么训练会在第一次验证loss回升时立即停止,你根本看不到后续阶梯下降的那部分。restore_best_weights=True会在训练结束时把权重回滚到验证集最优的那个epoch,而不是保留最后一个epoch的状态,这一点能避免训练后期过拟合导致的最终模型退化。

4.3 训练过程应该盯住哪些指标而不是只看accuracy

在jupyter notebook里跑model.fit时,输出表格中的lossaccuracyval_lossval_accuracy四个指标需要在每个epoch结束时对比分析。只盯着accuracy是一个非常明显的误用,因为准确率在小幅度波动时你觉得模型还能接受,但val_loss已经在显著爬升,暗示过拟合开始了,等到准确率明显下降再停手就晚了。

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(acc, label='train_acc') plt.plot(val_acc, label='val_acc') plt.legend() plt.subplot(1, 2, 2) plt.plot(loss, label='train_loss') plt.plot(val_loss, label='val_loss') plt.legend() plt.show()

判定过拟合的基本规则是:训练accuracy持续上升、但val_accuracy在某个epoch之后停滞或下降,同时train_loss还在降、val_loss却开始回升,这两组曲线形成“开口”就是典型的过拟合信号。出现这个信号后,优先调整的不是模型结构,而是数据增强强度、dropout比例以及是否应该更早冻结更多层。如果连train_loss都无法下降,则属于欠拟合,说明模型容量不够或学习率过小,此时增加层数或调大学习率更合适。

4.4 解决Importerror等jupyter notebook运行环境问题

不少人在打开别人留下的notebook时,第一行import tensorflow就报错,常见的错误信息包括:ModuleNotFoundError: No module named 'tensorflow',或ImportError: DLL load failed while importing rpds。后者通常在Windows环境出现,原因是rpds这个Python包缺少对应的Visual C++运行库,常见于绿色版Python环境或精简版系统。通常的解决路径是先pip install --upgrade rpds-py,再在系统里安装Microsoft Visual C++ Redistributable(对应本机系统架构的x64版本)并重启终端。如果重启后在jupyter里仍然报错,确认一下当前notebook用的kernel是否对应你刚才安装包的conda环境,在notebook界面右上角的kernel名称经常和实际环境不一致,切换kernel后一般能解决问题。

5. 预测提交与kernel重启后的模型复用

5.1 用最优权重进行测试集预测

模型训练完成后,下一步是读取之前保存的模型权重并预测未知图片的类别。这个环节在优达学城毕业项目流程里通常是最后一步,常见的问题在于忘记重新加载best_model.h5,而是直接用训练结束时的内存模型对象predict,导致结果不是最优的那组权重跑出来的。

from tensorflow.keras.models import load_model model = load_model('best_model.h5') def predict_image(img_path): from tensorflow.keras.preprocessing import image img = image.load_img(img_path, target_size=(150, 150)) x = image.img_to_array(img) / 255.0 x = x.reshape((1, 150, 150, 3)) pred = model.predict(x)[0][0] return 'dog' if pred > 0.5 else 'cat'

load_model会自动加载完整模型结构,但一个很容易踩的坑是:如果之前训练时用了自定义层或Lambda层,则在load时必须显式传入custom_objects,否则会报Unknown layer错误。target_size必须和训练时的输入尺寸完全一致,上面例子是150,如果训练时改成了224,这里也得改成224。pred > 0.5是因为最后一层sigmoid的输出在0到1之间,0.5是二分类的天然决策边界。

5.2 批量预测写CSV提交文件

毕业项目通常要求将预测结果写入CSV文件,使用文件名作为索引。这里提供一个批量处理版本,同时注意处理图片读取失败的容错:

import os import pandas as pd test_dir = 'test/unknown' results = [] for fname in sorted(os.listdir(test_dir)): fpath = os.path.join(test_dir, fname) try: label = predict_image(fpath) results.append({'id': fname.split('.')[0], 'label': label}) except Exception as e: print(f'Failed: {fname}, error: {e}') results.append({'id': fname.split('.')[0], 'label': 'dog'}) submission = pd.DataFrame(results) submission.to_csv('submission.csv', index=False)

关于预测速度的说明:在CPU环境下,每张150x150的图片经过VGG16预测大约耗时30到50ms,1万张图片的测试集需要约10分钟。如果在notebook里执行这段代码时遇到kernel假死的现象,不要慌,把cell上面输出区的In [*]状态当作判断标准,如果jupyter notebook无法运行的状态持续太久,可以把这段代码包装成脚本用%run predict.py在外部执行,能规避notebook内核的一些资源管理问题。

5.3 notebook重启后环境丢失怎么办

jupyter notebook的使用过程中最常见的痛点在于:昨天还能跑的notebook,今天重启后第一行import就报错。多数原因在于kernel没有对应到期望的conda环境。确认方式是在notebook里运行:

import sys print(sys.executable)

输出的路径如果指向base环境的python,说明kernel选错了。解决方法是先在终端里执行conda activate dogscats,然后安装jupyter的kernel注册工具并注册环境:

pip install ipykernel python -m ipykernel install --user --name dogscats --display-name "Python (dogscats)"

之后再打开notebook,在Kernel菜单里的Change Kernel中选择Python (dogscats)。这一步做完,import环境问题和DLL加载类报错中的绝大多数情况都能自动消失。注意--name后面的值必须和conda环境名一致,否则kernel会启动一个不存在的环境。

6. 让准确率再涨1到2个百分点的三个微操作

模型主体已经固定为VGG16加全连接层的结构之后,还想继续提升验证集准确率,需要的是针对边界样本的微调手段。

6.1 把验证集上错误的样本打印出来看

训练结束后写四行代码,把验证集里预测错误的图片路径和真实标签打出来。多数情况下,你会发现错误集中在模糊照片、极端光照、或者猫狗姿势异常的样本上。这个步骤不需要改模型,它真正的价值是帮你决定是否要调整数据增强的参数。比如错误大多来自暗光图片,那就把亮度扰动加入增强;如果错误全是小尺寸猫狗,就该在预处理里加上裁剪策略。

import numpy as np val_generator.reset() predictions = model.predict(val_generator, steps=val_generator.samples // 32) y_true = val_generator.classes[:len(predictions)] misclassified = np.where((predictions.flatten() > 0.5).astype(int) != y_true)[0]

6.2 在测试时做TTA(Test-Time Augmentation)

对同一张图片做多次轻微变换后取预测平均,通常是提升准确率最稳妥的免费手段。常见实现是对每张测试图片做水平翻转、小幅旋转后,把三个预测结果取平均。

def predict_with_tta(img_path, model, n_aug=5): from tensorflow.keras.preprocessing.image import ImageDataGenerator datagen = ImageDataGenerator(rescale=1.0/255, horizontal_flip=True) img = image.load_img(img_path, target_size=(150, 150)) x = image.img_to_array(img) x = x.reshape((1, 150, 150, 3)) preds = [] for _ in range(n_aug): batch = datagen.flow(x, batch_size=1).next() preds.append(model.predict(batch)[0][0]) return 'dog' if np.mean(preds) > 0.5 else 'cat'

TTA的代价是预测时间成倍增长,1万张测试图需要原来的3到5倍时间。它适用于你对时间限制不敏感但准确率还有硬指标要求的情况。对优达学城这个项目来说,TTA通常能把准确率从97%左右提升到97.5%,虽然幅度小,但在达到优秀线边缘时可能正好是B和A的差别。

6.3 输出两次预测结果的置信度差异

最终提交CSV之前,把每个预测结果的sigmoid输出值也保存下来,绘制置信度分布图。正态分布且集中在0.05到0.95区间的模型,说明对大多数样本都有清晰判断。如果你看到大量样本的预测概率集中在0.45到0.55之间,说明模型对这批图片高度不确定,这时提升准确率就不应该靠继续调参了,而是需要回头检查预处理环节和标签噪声。这个验证操作我建议放在最后一次训练之后、提交CSV之前,做一遍就能判断当前模型是否还能从模型层面获益。

pred_probs = [] for fname in sorted(os.listdir(test_dir))[:200]: prob = model.predict(...)[0][0] pred_probs.append(prob) plt.hist(pred_probs, bins=20) plt.show()

这组操作做完,如果所有样本都集中在两端,说明模型已经接近当前结构的上限,是时候收工了。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询