1. 从零开始:为什么选择Kaggle猫狗数据集作为你的第一个CV项目?
如果你刚踏入计算机视觉(CV)或者深度学习的大门,想找一个项目来练手,那么“猫狗识别”几乎是一个绕不开的经典入门题。而Kaggle上的“Dogs vs. Cats”数据集,更是这个经典题目中最具代表性的一个。你可能已经在各种教程、博客里无数次看到它的身影,但今天,我想从一个一线实践者的角度,跟你聊聊为什么这个“老掉牙”的项目至今仍有巨大的学习价值,以及如何通过一个“超详细”的流程,真正吃透它,而不是仅仅跑通代码。
这个项目的魅力在于它的“恰到好处”。问题本身足够直观——区分猫和狗,谁都能理解。数据量也足够友好——Kaggle提供的训练集包含25000张标注好的图片(12500只猫,12500只狗),测试集12500张。这个规模对于个人电脑的GPU(甚至强大的CPU)来说,是可以在可接受的时间内完成训练的,不会让你在数据准备和硬件等待上耗尽热情。同时,它又包含了真实世界图片的复杂性:不同的品种、姿态、光照、背景以及部分遮挡。这迫使你不能只写几行简单的逻辑,而必须引入卷积神经网络(CNN)这类能够自动学习层次化特征的模型。
网络上很多教程止步于“导入Keras,加载VGG16,冻结底层,训练顶层,得到90%+准确率”。这当然能跑出结果,但你会留下无数疑问:为什么用这个模型?图片怎么预处理?数据不够怎么办?准确率卡住了怎么提升?模型为什么这么大?这次,我们抛开那些“快餐式”的教程,我会带你像完成一个真正的工业级项目一样,走完全流程:从环境搭建、数据探索与分析(EDA)、预处理与增强、模型选择与构建、训练策略设计、到模型评估、优化及最终部署的完整思路。即使你零基础,跟着做下来,你获得的将不仅仅是一个能识别猫狗的程序,而是一套应对图像分类问题的可迁移的方法论。
2. 战场准备:构建可复现的深度学习开发环境
工欲善其事,必先利其器。一个稳定、隔离且便于管理的开发环境是高效实验的基础。很多人直接在自己的系统Python环境里
pip install tensorflow
,这极易导致包版本冲突,项目一多环境就崩溃。我们的第一步,就是建立一个专属于本项目的纯净环境。
我强烈推荐使用
conda
(通过Miniconda或Anaconda安装)进行环境管理。它不仅管理Python包,还能处理一些非Python的二进制依赖,对于深度学习框架非常友好。
首先,我们创建一个新的conda环境。打开终端(Windows用Anaconda Prompt,Linux/Mac用终端),执行以下命令:
conda create -n dogsvscats python=3.8 -y
这里指定Python 3.8,因为它与主流深度学习库的兼容性经过长期考验,非常稳定。环境名
dogsvscats
一目了然。
激活这个环境:
conda activate dogsvscats
接下来安装核心深度学习框架。TensorFlow和PyTorch是两大主流,考虑到Kaggle内核和许多在线教程对TensorFlow/Keras的支持更原生,我们选择它来保证最大的可复现性。使用
pip
在conda环境内安装:
pip install tensorflow==2.10.0
为什么是2.10.0?这是最后一个官方支持原生Windows GPU的版本(如果你用Windows且拥有NVIDIA GPU),同时也是一个长期稳定版本。如果你使用Linux或Mac,或者使用WSL2,可以考虑安装更新的版本,但2.10.0是一个安全的基准线。
然后安装本项目必备的数据处理与可视化工具包:
pip install numpy pandas matplotlib opencv-python pillow scikit-learn
-
numpy,pandas: 数值计算和数据处理基石。 -
matplotlib: 绘图,用于可视化图片、学习曲线等。 -
opencv-python(cv2): 强大的图像处理库,用于更灵活的图片读取和变换。 -
pillow(PIL): Python图像处理标准库,常与matplotlib配合使用。 -
scikit-learn: 用于数据划分、评估指标计算等。
最后,安装一个能方便进行数据增强的库。虽然TensorFlow/Keras内置了
ImageDataGenerator
,但这里我推荐
albumentations
,它速度更快、功能更丰富,并且支持与OpenCV的无缝集成。
pip install albumentations
至此,你的专属作战室已经搭建完毕。所有后续操作都请确保在
dogsvscats
这个conda环境下进行。你可以通过命令
conda list
来检查已安装的包。接下来,我们去获取弹药——数据。
3. 数据获取与初窥:从Kaggle下载到第一次“阅兵”
我们的数据来自Kaggle竞赛“Dogs vs. Cats”。访问竞赛页面(https://www.kaggle.com/c/dogs-vs-cats),如果你没有Kaggle账号,需要先注册。注册后,在比赛页面找到“Data”选项卡,下载
train.zip
和
test1.zip
。
train.zip
解压后是一个名为
train
的文件夹,里面包含了25000张以
cat.xxx.jpg
和
dog.xxx.jpg
命名的图片。
test1.zip
解压后是12500张以数字命名的未标注图片。
假设你在项目根目录下创建了一个
data
文件夹,结构如下:
dogsvscats_project/
├── data/
│ ├── train/ # 解压train.zip得到
│ │ ├── cat.0.jpg
│ │ ├── dog.0.jpg
│ │ └── ...
│ └── test1/ # 解压test1.zip得到
│ ├── 0.jpg
│ ├── 1.jpg
│ └── ...
├── src/ # 存放代码的文件夹
└── environment.yml # (可选)用于导出环境配置)
在写任何模型代码之前,我们必须先“认识”我们的数据。创建一个Jupyter Notebook或Python脚本,开始数据探索性分析(EDA)。
首先,加载必要的库并查看数据概貌:
import os
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import cv2
from PIL import Image
# 设置路径
train_dir = './data/train'
test_dir = './data/test1'
# 统计各类别数量
cat_files = [f for f in os.listdir(train_dir) if f.startswith('cat')]
dog_files = [f for f in os.listdir(train_dir) if f.startswith('dog')]
print(f"训练集图片总数: {len(os.listdir(train_dir))}")
print(f"猫的图片数量: {len(cat_files)}")
print(f"狗的图片数量: {len(dog_files)}")
print(f"测试集图片数量: {len(os.listdir(test_dir))}")
输出会确认数据是平衡的,各有12500张。平衡的数据集在初期简化了我们的工作,我们暂时不需要处理类别不平衡问题。
接下来,随机查看几张图片,了解其多样性:
def plot_sample_images(class_name, num_samples=4):
"""绘制指定类别的样本图片"""
files = [f for f in os.listdir(train_dir) if f.startswith(class_name)]
selected_files = np.random.choice(files, num_samples, replace=False)
plt.figure(figsize=(12, 8))
for i, filename in enumerate(selected_files):
img_path = os.path.join(train_dir, filename)
img = cv2.imread(img_path)
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # OpenCV默认BGR,转为RGB
plt.subplot(2, 2, i+1)
plt.imshow(img)
plt.title(f"{class_name}: {filename}")
plt.axis('off')
plt.tight_layout()
plt.show()
plot_sample_images('cat')
plot_sample_images('dog')
通过可视化,你会发现图片尺寸不一,有横屏有竖屏,背景复杂,猫狗姿态各异。有的狗图片里可能包含多只狗,甚至包含人。这些“噪声”正是真实数据的体现。
再深入一步,分析图片的尺寸分布和色彩通道统计,这对后续设计预处理管道至关重要:
heights, widths = [], []
for filename in os.listdir(train_dir)[:500]: # 抽样500张分析,以节省时间
img_path = os.path.join(train_dir, filename)
with Image.open(img_path) as img:
width, height = img.size
widths.append(width)
heights.append(height)
plt.figure(figsize=(12, 4))
plt.subplot(1, 2, 1)
plt.hist(widths, bins=50, alpha=0.7, label='Width')
plt.hist(heights, bins=50, alpha=0.7, label='Height')
plt.xlabel('Pixels')
plt.ylabel('Frequency')
plt.legend()
plt.title('Image Size Distribution (Sample)')
plt.subplot(1, 2, 2)
plt.scatter(widths, heights, alpha=0.5)
plt.xlabel('Width')
plt.ylabel('Height')
plt.title('Width vs Height Scatter')
plt.tight_layout()
plt.show()
print(f"宽度范围: {min(widths)} - {max(widths)}")
print(f"高度范围: {min(heights)} - {max(heights)}")
print(f"中位数尺寸: {np.median(widths)} x {np.median(heights)}")
你会发现图片尺寸差异巨大,从几十像素到几百像素不等。神经网络通常需要固定尺寸的输入,因此我们必须决定一个统一的输入尺寸,比如224x224或299x299。这个选择需要权衡:尺寸太小会丢失细节,影响模型对细小特征的捕捉;尺寸太大会急剧增加计算量和内存消耗,可能导致训练缓慢甚至OOM(内存溢出)。对于猫狗识别,224x224是一个经过大量实践验证的、在精度和效率之间取得良好平衡的尺寸,也是许多经典CNN架构(如VGG, ResNet)的默认输入尺寸。
4. 构建高效数据管道:预处理、增强与数据集划分
原始数据不能直接喂给模型。我们需要一个数据管道,负责在训练时动态地读取图片、进行预处理、应用数据增强,并生成模型所需的批量(batch)数据。TensorFlow 2.x推荐使用
tf.data
API来构建这个管道,它高效且灵活。
首先,我们需要创建一个包含图片路径和对应标签的DataFrame。标签我们采用整数编码:猫为0,狗为1。
import pandas as pd
import os
train_dir = './data/train'
image_paths = []
labels = []
for filename in os.listdir(train_dir):
img_path = os.path.join(train_dir, filename)
image_paths.append(img_path)
# 从文件名提取标签
label = 0 if filename.startswith('cat') else 1
labels.append(label)
df = pd.DataFrame({'file_path': image_paths, 'label': labels})
# 打乱数据顺序非常重要,防止模型学习到因文件顺序带来的虚假模式
df = df.sample(frac=1, random_state=42).reset_index(drop=True)
print(df.head())
接下来,我们将数据划分为训练集和验证集。验证集用于在训练过程中监控模型在未见过的数据上的表现,防止过拟合。通常使用80%-20%或90%-10%的比例。我们使用
scikit-learn
的
train_test_split
。
from sklearn.model_selection import train_test_split
train_df, val_df = train_test_split(df, test_size=0.2, random_state=42, stratify=df['label'])
print(f"训练集大小: {len(train_df)}")
print(f"验证集大小: {len(val_df)}")
注意
stratify=df['label']
参数,它确保训练集和验证集中猫狗的比例与原数据集一致,即都是50%对50%。
现在,核心部分来了:使用
tf.data
构建数据管道。我们将定义一个函数,它接收文件路径和标签,负责读取图片、解码、预处理和增强。
import tensorflow as tf
# 定义目标图像尺寸
IMG_SIZE = (224, 224)
BATCH_SIZE = 32 # 根据你的GPU内存调整,常见的有16, 32, 64
def decode_and_preprocess_image(file_path, label, is_training=True):
"""
读取、解码、预处理单张图片。
is_training: 训练时应用数据增强,验证/测试时只做基础预处理。
"""
# 1. 读取并解码图片
img = tf.io.read_file(file_path)
img = tf.image.decode_jpeg(img, channels=3)
# 2. 转换为float32并归一化到[0,1]范围(或[-1,1],取决于模型)
img = tf.image.convert_image_dtype(img, tf.float32)
# 3. 调整大小
img = tf.image.resize(img, IMG_SIZE)
# 4. 数据增强(仅在训练时)
if is_training:
# 随机水平翻转(猫狗识别中很有效的增强)
img = tf.image.random_flip_left_right(img)
# 随机调整亮度、对比度、饱和度(轻微程度)
img = tf.image.random_brightness(img, max_delta=0.1)
img = tf.image.random_contrast(img, lower=0.9, upper=1.1)
img = tf.image.random_saturation(img, lower=0.9, upper=1.1)
# 限制像素值范围,防止增强产生超出[0,1]的值
img = tf.clip_by_value(img, 0.0, 1.0)
# 5. 标准化:将[0,1]范围的像素值,转换为模型期望的格式。
# 许多在ImageNet上预训练的模型要求输入标准化。常见的有两种:
# a) 使用ImageNet的均值和标准差: mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]
# b) 直接缩放到[-1, 1]: img = (img * 2.0) - 1.0
# 这里我们选择方式b,因为它计算更简单,且与一些内置预处理兼容。
img = (img * 2.0) - 1.0
return img, label
# 创建训练数据集管道
train_dataset = tf.data.Dataset.from_tensor_slices((train_df['file_path'].values, train_df['label'].values))
train_dataset = train_dataset.map(lambda x, y: decode_and_preprocess_image(x, y, is_training=True),
num_parallel_calls=tf.data.AUTOTUNE)
train_dataset = train_dataset.shuffle(buffer_size=1024) # 打乱顺序
train_dataset = train_dataset.batch(BATCH_SIZE)
train_dataset = train_dataset.prefetch(buffer_size=tf.data.AUTOTUNE) # 预取数据,提升GPU利用率
# 创建验证数据集管道(不应用增强)
val_dataset = tf.data.Dataset.from_tensor_slices((val_df['file_path'].values, val_df['label'].values))
val_dataset = val_dataset.map(lambda x, y: decode_and_preprocess_image(x, y, is_training=False),
num_parallel_calls=tf.data.AUTOTUNE)
val_dataset = val_dataset.batch(BATCH_SIZE)
val_dataset = val_dataset.prefetch(buffer_size=tf.data.AUTOTUNE)
这个管道有几个关键点:
-
tf.data.AUTOTUNE: 让TensorFlow自动设置并行处理的线程数,优化性能。 -
shuffle: 在map之后、batch之前进行打乱,确保每个batch内的数据是随机的。 -
prefetch: 在GPU训练当前批次时,CPU异步准备下一个批次的数据,消除I/O瓶颈,这是提升训练速度的关键技巧。 - 数据增强 : 我们只对训练集应用。水平翻转对猫狗识别非常有效,因为猫狗通常没有左右朝向的固有区别。亮度、对比度、饱和度的微调可以模拟不同光照条件,提升模型鲁棒性。增强的强度不宜过大,否则会扭曲语义信息。
注意 :关于标准化,如果你打算使用在ImageNet上预训练的模型(我们接下来就会这么做),并且不修改其顶层,那么输入标准化必须与模型训练时使用的标准化方式一致。大多数
tf.keras.applications中的模型(如ResNet50, EfficientNet)都内置了预处理函数,会自动处理标准化。为了清晰演示原理,我们上面手动实现了[-1, 1]的缩放。在实际使用预训练模型时,更推荐使用tf.keras.applications.[model].preprocess_input函数。
5. 模型战略:迁移学习与经典架构选择
从头开始训练一个深度CNN在猫狗数据集上也能工作,但需要大量的数据、时间和计算资源,且容易过拟合。迁移学习(Transfer Learning)是解决这个问题的银弹。其核心思想是:利用在大规模数据集(如ImageNet,包含1000个类别)上预训练好的模型,将其学到的通用图像特征(如边缘、纹理、形状)迁移到我们的新任务上。
具体做法是:保留预训练模型的卷积基(特征提取器),移除其顶部的全连接分类层(原本用于ImageNet的1000类分类),然后接上我们自定义的、适合二分类的新头部进行微调(Fine-tuning)。
那么,该选择哪个预训练模型呢?常见的候选有VGG16、ResNet50、InceptionV3、MobileNetV2、EfficientNetB0等。选择时需要权衡 精度 、 模型大小 和 推理速度 。
- VGG16 :结构简单,特征提取能力强,但模型参数量巨大(约1.38亿),训练和推理慢,内存占用高。适合作为理解原理的教学模型,实际部署不推荐。
- ResNet50 :引入了残差连接,解决了深层网络梯度消失问题,在精度和复杂度间取得了很好的平衡,是工业界非常常用的基准模型。
- MobileNetV2/EfficientNetB0 :为移动和嵌入式设备设计,参数量少,计算效率高(使用深度可分离卷积),精度稍逊于ResNet50但远超VGG16,是追求效率时的首选。
对于猫狗识别这个相对简单的任务,MobileNetV2或EfficientNetB0通常就能达到非常高的精度(>98%),且训练和预测速度快,模型文件小。这里我们以 EfficientNetB0 为例,它通过复合缩放(Compound Scaling)在精度和效率上达到了新的SOTA。
我们将使用Keras Applications模块加载预训练的EfficientNetB0,并构建新的模型。
from tensorflow.keras import layers, models, applications
def build_model(input_shape=(224, 224, 3)):
"""
构建基于EfficientNetB0的迁移学习模型。
"""
# 1. 加载预训练的EfficientNetB0卷积基,不包括顶部分类层,并冻结其权重
base_model = applications.EfficientNetB0(include_top=False,
weights='imagenet',
input_shape=input_shape)
# 冻结卷积基,在初始训练阶段不更新其权重
base_model.trainable = False
# 2. 构建新的模型头部
inputs = layers.Input(shape=input_shape)
# 使用EfficientNetB0内置的预处理(将输入从[0,255]标准化到[-1,1])
# 注意:我们之前管道已经做了[-1,1]的缩放,所以这里可以不用。但为了通用性,我们注释掉手动缩放,使用内置的。
# x = applications.efficientnet.preprocess_input(inputs)
x = base_model(inputs, training=False) # training=False确保BatchNorm层在推理模式下运行
# 添加全局平均池化层,将4D特征图转换为2D向量,大大减少参数
x = layers.GlobalAveragePooling2D()(x)
# 添加一个全连接层作为过渡,增加非线性表达能力,并使用Dropout防止过拟合
x = layers.Dense(128, activation='relu')(x)
x = layers.Dropout(0.5)(x) # Dropout率0.5是一个常用起点
# 输出层:二分类,使用sigmoid激活函数
outputs = layers.Dense(1, activation='sigmoid')(x)
# 3. 组合成完整模型
model = models.Model(inputs, outputs)
return model, base_model
# 构建模型
model, base_model = build_model()
model.summary()
运行
model.summary()
,你会看到模型结构。重点是,
base_model
(EfficientNetB0)的所有层都被标记为
Non-trainable
,参数数量为400多万个,但这些参数在初始训练时不会被更新。我们新增的头部只有约1.6万个可训练参数。这意味着我们只需要训练很少的参数,就能快速得到一个不错的模型。
为什么先冻结预训练层?因为预训练层已经学到了非常好的通用特征,如果一开始就全部放开训练,在较小的数据集上,底层特征很容易被带偏(灾难性遗忘)。我们通常采用“分阶段微调”策略:
- 阶段一 :冻结卷积基,只训练新添加的头部。用较大的学习率快速让头部适应新任务。
- 阶段二 :解冻卷积基的部分顶层(靠近分类器的层),用较小的学习率一起微调。顶层的特征更偏向于特定任务,而底层的特征更通用(如边缘、颜色)。
6. 训练的艺术:配置损失、优化器与回调函数
模型构建好后,需要编译(Compile)它,指定如何衡量误差(损失函数)、如何减小误差(优化器)以及如何评估性能(评估指标)。
对于二分类问题,输出层使用
sigmoid
激活函数,输出一个0到1之间的概率值(表示是狗的概率)。因此:
-
损失函数(Loss)
:使用
binary_crossentropy(二元交叉熵),这是二分类问题的标准选择。 -
优化器(Optimizer)
:
Adam优化器是当前最流行的自适应学习率优化器,它结合了动量和自适应学习率的优点,通常不需要太多调参就能工作得很好。我们设置一个初始学习率。 -
评估指标(Metrics)
:除了监控损失,我们更关心分类准确率(Accuracy)。还可以加入
AUC(ROC曲线下面积)来评估模型在不同阈值下的整体性能。
# 编译模型
initial_learning_rate = 1e-3 # 初始学习率,对于Adam,1e-3或1e-4是常见起点
model.compile(optimizer=tf.keras.optimizers.Adam(learning_rate=initial_learning_rate),
loss='binary_crossentropy',
metrics=['accuracy', tf.keras.metrics.AUC(name='auc')])
训练过程不是简单地调用
model.fit
就完事了。我们需要精心设计训练策略,并用回调函数(Callbacks)来自动化执行这些策略。回调函数是在训练过程的特定时间点(如每个epoch结束后)被调用的对象,用于实现诸如保存模型、调整学习率、提前停止等功能。
以下是几个至关重要的回调函数:
- ModelCheckpoint :在验证集性能达到最佳时保存模型权重。这能防止因为后续过拟合而丢失最好的模型。
- EarlyStopping :当验证集指标在连续多个epoch内不再提升时,自动停止训练,避免无效计算和过拟合。
- ReduceLROnPlateau :当验证集损失停滞时,自动降低学习率。这有助于模型在收敛后期跳出局部最优或平坦区域。
- TensorBoard :将训练日志可视化,方便我们监控损失和准确率曲线。
from tensorflow.keras import callbacks
import os, datetime
# 创建用于保存模型和日志的目录
log_dir = "./logs/fit/" + datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
checkpoint_dir = "./model_checkpoints"
os.makedirs(checkpoint_dir, exist_ok=True)
# 定义回调函数列表
callbacks_list = [
# 保存最佳模型(基于验证集准确率)
callbacks.ModelCheckpoint(
filepath=os.path.join(checkpoint_dir, 'best_model.weights.h5'),
monitor='val_accuracy', # 监控的指标
save_best_only=True, # 只保存最好的
save_weights_only=True, # 只保存权重,不保存整个模型(更轻量)
mode='max', # 对于准确率,越大越好
verbose=1
),
# 提前停止
callbacks.EarlyStopping(
monitor='val_loss', # 监控验证集损失
patience=10, # 容忍多少个epoch没有改善
restore_best_weights=True, # 停止后恢复最佳权重
verbose=1
),
# 动态降低学习率
callbacks.ReduceLROnPlateau(
monitor='val_loss',
factor=0.5, # 学习率乘以0.5
patience=5, # 容忍5个epoch
min_lr=1e-7, # 学习率下限
verbose=1
),
# TensorBoard日志
callbacks.TensorBoard(
log_dir=log_dir,
histogram_freq=1, # 每1个epoch记录一次直方图(可选,会减慢训练)
update_freq='epoch' # 每个epoch后写入日志
)
]
现在,我们可以开始第一阶段训练(只训练头部):
# 第一阶段训练:冻结基座,只训练头部
epochs_stage1 = 10
history_stage1 = model.fit(
train_dataset,
epochs=epochs_stage1,
validation_data=val_dataset,
callbacks=callbacks_list,
verbose=1 # 显示进度条
)
训练过程中,观察控制台输出。你会看到每个epoch的训练损失/准确率和验证损失/准确率。理想情况下,训练和验证的准确率都应稳步上升,损失稳步下降,且两者差距不大。
第一阶段训练完成后,我们进入第二阶段:解冻部分卷积层进行微调。通常,我们解冻基座模型的最后若干层(比如最后30-50层),因为这些层学习的是更高级、更任务相关的特征。
# 第二阶段:解冻部分基座层进行微调
# 首先,确保我们加载了第一阶段得到的最佳权重
model.load_weights(os.path.join(checkpoint_dir, 'best_model.weights.h5'))
# 解冻base_model的部分顶层。EfficientNetB0有很多层,我们解冻最后50层左右。
base_model.trainable = True
# 设置一个标志,只解冻部分层
fine_tune_at = -50 # 解冻最后50层。你可以通过查看base_model.summary()来选择合适的层索引。
for layer in base_model.layers[:fine_tune_at]:
layer.trainable = False
# 重新编译模型。在解冻层后重新编译是必须的,否则更改不会生效。
# 使用更小的学习率,因为微调需要更温和的更新。
model.compile(optimizer=tf.keras.optimizers.Adam(learning_rate=initial_learning_rate / 10),
loss='binary_crossentropy',
metrics=['accuracy', tf.keras.metrics.AUC(name='auc')])
# 继续训练
epochs_stage2 = 10
# 注意:EarlyStopping和ReduceLROnPlateau会从上次的状态继续监控,这是合理的。
history_stage2 = model.fit(
train_dataset,
initial_epoch=history_stage1.epoch[-1] + 1, # 从上一阶段结束的epoch开始
epochs=epochs_stage1 + epochs_stage2,
validation_data=val_dataset,
callbacks=callbacks_list,
verbose=1
)
7. 模型评估与性能分析:不仅仅是看准确率
训练结束后,我们不能只看最后的验证准确率就宣告胜利。需要从多个维度深入评估模型,理解其行为。
首先,绘制训练历史曲线,这是诊断模型训练过程最直观的工具。
def plot_training_history(history_stage1, history_stage2):
"""绘制包含两个阶段的训练历史曲线"""
# 合并历史
acc = history_stage1.history['accuracy'] + history_stage2.history['accuracy']
val_acc = history_stage1.history['val_accuracy'] + history_stage2.history['val_accuracy']
loss = history_stage1.history['loss'] + history_stage2.history['loss']
val_loss = history_stage1.history['val_loss'] + history_stage2.history['val_loss']
epochs_range = range(len(acc))
stage1_epochs = len(history_stage1.history['accuracy'])
plt.figure(figsize=(12, 4))
plt.subplot(1, 2, 1)
plt.plot(epochs_range, acc, label='Training Accuracy')
plt.plot(epochs_range, val_acc, label='Validation Accuracy')
plt.axvline(x=stage1_epochs-1, color='gray', linestyle='--', label='Start Fine-tuning')
plt.xlabel('Epoch')
plt.ylabel('Accuracy')
plt.legend(loc='lower right')
plt.title('Training and Validation Accuracy')
plt.subplot(1, 2, 2)
plt.plot(epochs_range, loss, label='Training Loss')
plt.plot(epochs_range, val_loss, label='Validation Loss')
plt.axvline(x=stage1_epochs-1, color='gray', linestyle='--', label='Start Fine-tuning')
plt.xlabel('Epoch')
plt.ylabel('Loss')
plt.legend(loc='upper right')
plt.title('Training and Validation Loss')
plt.tight_layout()
plt.show()
plot_training_history(history_stage1, history_stage2)
观察曲线,你应该能看到:第一阶段,验证准确率快速上升后趋于平缓;在开始微调(虚线处)后,验证准确率可能有一个小幅跃升或继续缓慢提升,同时训练损失进一步下降。如果微调后验证损失急剧上升而训练损失下降,那可能是过拟合的信号,需要减少解冻的层数或使用更强的正则化(如增大Dropout率)。
接下来,在完整的验证集上进行最终评估,并生成分类报告和混淆矩阵。
from sklearn.metrics import classification_report, confusion_matrix, ConfusionMatrixDisplay
import numpy as np
# 加载最佳模型权重
model.load_weights(os.path.join(checkpoint_dir, 'best_model.weights.h5'))
# 获取验证集所有数据的预测结果和真实标签
# 注意:验证集管道(val_dataset)已经做了批处理和预取,我们需要迭代它
val_labels = []
val_preds = []
for images, labels in val_dataset:
preds = model.predict(images, verbose=0) # 预测,得到概率
val_preds.extend(preds.flatten()) # 展平成一维数组
val_labels.extend(labels.numpy())
val_preds = np.array(val_preds)
val_labels = np.array(val_labels)
# 将概率转换为类别(阈值0.5)
val_preds_class = (val_preds > 0.5).astype(int)
# 打印分类报告
print(classification_report(val_labels, val_preds_class, target_names=['Cat', 'Dog']))
# 输出精确率(Precision)、召回率(Recall)、F1-score等
# 绘制混淆矩阵
cm = confusion_matrix(val_labels, val_preds_class)
disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=['Cat', 'Dog'])
disp.plot(cmap=plt.cm.Blues)
plt.title('Confusion Matrix on Validation Set')
plt.show()
分类报告会告诉你模型在每个类别上的精确率(预测为猫/狗的样本中,有多少是真的猫/狗)和召回率(所有真正的猫/狗中,有多少被预测对了)。对于平衡数据集,准确率是主要指标,但精确率和召回率能帮你发现模型在特定类别上的弱点。混淆矩阵则直观展示了分类错误的具体情况:猫被误判为狗(假阳性)和狗被误判为猫(假阴性)各有多少。
此外,可视化一些被错误分类的样本非常有价值。这能帮你直观理解模型在哪里“犯了糊涂”。
# 找出验证集中预测错误的样本索引
error_indices = np.where(val_preds_class != val_labels)[0]
print(f"验证集错误分类样本数: {len(error_indices)} / {len(val_labels)}")
# 随机查看一些错误样本
num_samples_to_show = min(8, len(error_indices))
sample_indices = np.random.choice(error_indices, num_samples_to_show, replace=False)
plt.figure(figsize=(15, 10))
for i, idx in enumerate(sample_indices):
# 从原始DataFrame中获取文件路径
file_path = val_df.iloc[idx]['file_path']
true_label = val_df.iloc[idx]['label']
pred_prob = val_preds[idx]
img = cv2.imread(file_path)
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
plt.subplot(2, 4, i+1)
plt.imshow(img)
plt.title(f"True: {'Dog' if true_label==1 else 'Cat'}\nPred: {'Dog' if pred_prob>0.5 else 'Cat'} ({pred_prob:.3f})")
plt.axis('off')
plt.tight_layout()
plt.show()
观察这些错误案例,你可能会发现一些模式:例如,黑色猫咪在暗背景下难以辨认;猫咪的姿势像狗(如站立);图片极度模糊;或者图片中同时包含猫和狗。这些分析能指导你后续的改进方向,例如收集更多困难样本、调整数据增强策略(如针对暗光条件)等。
8. 模型优化与部署思考:从实验室到生产
得到一个验证集准确率98%的模型并不意味着项目结束。我们还需要考虑模型的效率、鲁棒性以及如何将其用起来。
1. 模型优化与剪枝 我们的模型基于EfficientNetB0,本身已经比较高效。但如果你对推理速度有极致要求,或者想部署到资源受限的设备(如手机、树莓派),可以考虑以下步骤:
- 量化(Quantization) :将模型权重从32位浮点数(float32)转换为8位整数(int8)。这能显著减小模型体积和加速推理,且精度损失通常很小。TensorFlow Lite提供了训练后量化工具。
- 剪枝(Pruning) :移除网络中不重要的权重(例如那些接近0的权重),产生稀疏模型,然后进行压缩。TensorFlow Model Optimization Toolkit支持此功能。
- 使用更小的模型 :直接换用MobileNetV2或专门为边缘设备设计的模型如EfficientNet-Lite。
2. 处理类别不平衡(虽然本例平衡) 如果你的数据是不平衡的(例如狗的照片远多于猫),那么准确率这个指标就会失真。你需要:
- 在计算损失时使用 类别权重(class_weight) ,让模型更关注少数类。
- 使用 F1-score 或 AUC 作为主要评估指标。
- 采用 过采样(如SMOTE) 或 欠采样 技术。
3. 部署为推理服务 将训练好的模型保存下来,并创建一个简单的API服务,以便接收新图片并返回预测结果。
# 保存完整的模型(包括架构和权重)
model.save('./dogsvscats_final_model.h5')
# 或者只保存权重(更小)
# model.save_weights('./dogvs cats_final_weights.h5')
# 加载模型进行单张图片预测
from tensorflow.keras.models import load_model
loaded_model = load_model('./dogsvscats_final_model.h5')
def predict_single_image(image_path, model, img_size=(224, 224)):
"""预测单张图片"""
img = tf.io.read_file(image_path)
img = tf.image.decode_jpeg(img, channels=3)
img = tf.image.resize(img, img_size)
img = tf.image.convert_image_dtype(img, tf.float32)
img = (img * 2.0) - 1.0 # 与训练时相同的标准化
img = tf.expand_dims(img, axis=0) # 增加批次维度 [1, 224, 224, 3]
prediction = model.predict(img, verbose=0)[0][0] # 获取概率值
class_name = 'Dog' if prediction > 0.5 else 'Cat'
confidence = prediction if prediction > 0.5 else 1 - prediction
return class_name, confidence
# 测试
test_img_path = './data/test1/123.jpg'
class_name, confidence = predict_single_image(test_img_path, loaded_model)
print(f"预测结果: {class_name}, 置信度: {confidence:.2%}")
你可以使用Flask、FastAPI等框架将这个预测函数包装成REST API,或者使用TensorFlow Serving来部署一个高性能的推理服务。
4. 持续改进的方向
- 数据层面 :如果准确率还不够满意,首先应该考虑收集更多数据,特别是那些模型容易出错的“困难样本”。Kaggle上这个数据集是2013年的,图片质量相对现在较低,你可以尝试从其他来源补充更清晰、更多样化的图片。
- 模型层面 :尝试不同的预训练模型架构(ResNet50, DenseNet, ConvNeXt等)和不同的输入尺寸(如299x299, 384x384)。可以使用自动化机器学习(AutoML)工具如Keras Tuner进行超参数搜索。
- 集成学习 :训练多个不同的模型(如EfficientNetB0, ResNet50, MobileNetV2),然后将它们的预测结果进行平均或投票,通常能提升1-2个百分点的性能。
走完以上所有步骤,你已经完成了一个从数据到部署的完整计算机视觉项目闭环。这个“超详细”的过程,其价值不在于让你记住每一个代码片段,而在于理解每一个决策背后的原因,并建立起解决一个真实机器学习问题的系统性思维。下次当你面对一个新的图像分类任务时,这套流程——数据EDA、构建管道、迁移学习、分阶段训练、综合评估、优化部署——将成为你可靠的行动指南。

312

被折叠的 条评论
为什么被折叠?



