Kaggle猫狗识别项目全流程:从数据探索到模型部署的深度学习实践

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)

这个管道有几个关键点:

  1. tf.data.AUTOTUNE : 让TensorFlow自动设置并行处理的线程数,优化性能。
  2. shuffle : 在 map 之后、 batch 之前进行打乱,确保每个batch内的数据是随机的。
  3. prefetch : 在GPU训练当前批次时,CPU异步准备下一个批次的数据,消除I/O瓶颈,这是提升训练速度的关键技巧。
  4. 数据增强 : 我们只对训练集应用。水平翻转对猫狗识别非常有效,因为猫狗通常没有左右朝向的固有区别。亮度、对比度、饱和度的微调可以模拟不同光照条件,提升模型鲁棒性。增强的强度不宜过大,否则会扭曲语义信息。

注意 :关于标准化,如果你打算使用在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万个可训练参数。这意味着我们只需要训练很少的参数,就能快速得到一个不错的模型。

为什么先冻结预训练层?因为预训练层已经学到了非常好的通用特征,如果一开始就全部放开训练,在较小的数据集上,底层特征很容易被带偏(灾难性遗忘)。我们通常采用“分阶段微调”策略:

  1. 阶段一 :冻结卷积基,只训练新添加的头部。用较大的学习率快速让头部适应新任务。
  2. 阶段二 :解冻卷积基的部分顶层(靠近分类器的层),用较小的学习率一起微调。顶层的特征更偏向于特定任务,而底层的特征更通用(如边缘、颜色)。

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结束后)被调用的对象,用于实现诸如保存模型、调整学习率、提前停止等功能。

以下是几个至关重要的回调函数:

  1. ModelCheckpoint :在验证集性能达到最佳时保存模型权重。这能防止因为后续过拟合而丢失最好的模型。
  2. EarlyStopping :当验证集指标在连续多个epoch内不再提升时,自动停止训练,避免无效计算和过拟合。
  3. ReduceLROnPlateau :当验证集损失停滞时,自动降低学习率。这有助于模型在收敛后期跳出局部最优或平坦区域。
  4. 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、构建管道、迁移学习、分阶段训练、综合评估、优化部署——将成为你可靠的行动指南。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值