📋 前言

各位伙伴们,大家好!如果说之前的学习是在“集齐龙珠”,那么今天,我们就要“召唤神龙”了!Day 43 是一个复习日,但它的任务却是一个激动人心的完整项目:在 Kaggle 上找到数据集,用 CNN 训练,用 Grad-CAM 可视化,并且,用模块化的思想来组织我们的代码。

这标志着我们从编写零散的“代码片段”,正式迈向了构建结构清晰、可复用的“AI工程项目”。这不仅仅是一次复习,更是一次思维的全面升级。准备好了吗?让我们开始构建自己的第一个图像分类项目吧!


一、项目规划:从“一锅炖”到“四菜一汤”

在接触复杂项目时,最忌讳的就是把所有代码都塞进一个文件里。这就像把所有食材都扔进一个锅里乱炖,最终只会得到一锅难以维护的“浆糊”。

我们要学习专业工程师的思维方式——模块化。我们将项目拆分成逻辑清晰的几个部分,各司其职:

cats_vs_dogs_project/
│
├── data/                    # 存放下载的数据集
│   ├── train/
│   │   ├── cat.0.jpg
│   │   ├── dog.0.jpg
│   │   └── ...
│   └── test1/
│       ├── 1.jpg
│       └── ...
│
├── config.py                # 配置文件:存放所有超参数和路径
├── dataset.py               # 数据模块:负责数据的加载、预处理和封装
├── model.py                 # 模型模块:定义CNN网络结构
├── utils.py                 # 工具模块:存放Grad-CAM、模型保存等辅助函数
├── train.py                 # 训练脚本:项目的主入口,负责启动和协调训练流程
└── visualize.py             # 可视化脚本:加载训练好的模型,进行Grad-CAM分析

这种“四菜一汤”(四个功能模块 + 一个主训练脚本)的结构,让我们的项目瞬间变得清晰、专业,并且极易扩展和维护!


二、项目实战:一步步构建你的CNN分类器

2.1 数据准备:Kaggle “Cats and Dogs”

  1. 下载数据:前往 Kaggle Dogs vs. Cats 页面,下载 train.ziptest1.zip
  2. 解压数据:在项目根目录下创建 data/ 文件夹,并将 train.zip 解压到其中,形成 data/train/ 目录。

2.2 编写代码:填充我们的模块

1. config.py:项目的“控制中心”
# config.py

import torch

# --- 训练配置 ---
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
LEARNING_RATE = 1e-4
BATCH_SIZE = 32
NUM_EPOCHS = 10
VALID_SPLIT = 0.2  # 验证集划分比例

# --- 数据与模型路径 ---
DATA_PATH = "./data/train/"
MODEL_SAVE_PATH = "cats_vs_dogs_cnn.pth"

# --- 图像参数 ---
IMAGE_SIZE = 128
2. dataset.py:数据的“大管家”

这是最关键的一步,因为 Kaggle 数据集不是 torchvision 自带的,我们需要自定义 Dataset 类来加载它。

# dataset.py

import os
import torch
from torch.utils.data import Dataset, DataLoader
from PIL import Image
from sklearn.model_selection import train_test_split
import torchvision.transforms as transforms

import config # 导入我们的配置

class CatsVsDogsDataset(Dataset):
    def __init__(self, file_list, transform=None):
        self.file_list = file_list
        self.transform = transform

    def __len__(self):
        return len(self.file_list)

    def __getitem__(self, idx):
        # 获取图像路径
        img_path = self.file_list[idx]
        
        # 读取图像
        image = Image.open(img_path).convert("RGB")
        
        # 从文件名中提取标签: 'cat'=0, 'dog'=1
        label_str = os.path.basename(img_path).split('.')[0]
        label = 0 if label_str == 'cat' else 1
        
        # 应用图像变换
        if self.transform:
            image = self.transform(image)
            
        return image, torch.tensor(label, dtype=torch.long)

def get_loaders():
    # 定义图像变换
    transform = transforms.Compose([
        transforms.Resize((config.IMAGE_SIZE, config.IMAGE_SIZE)),
        transforms.ToTensor(),
        transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
    ])

    # 获取所有训练图像文件
    all_files = [os.path.join(config.DATA_PATH, f) for f in os.listdir(config.DATA_PATH)]
    
    # 划分训练集和验证集
    train_files, val_files = train_test_split(
        all_files, test_size=config.VALID_SPLIT, random_state=42
    )

    # 创建Dataset实例
    train_dataset = CatsVsDogsDataset(train_files, transform=transform)
    val_dataset = CatsVsDogsDataset(val_files, transform=transform)

    # 创建DataLoader
    train_loader = DataLoader(train_dataset, batch_size=config.BATCH_SIZE, shuffle=True, num_workers=4)
    val_loader = DataLoader(val_dataset, batch_size=config.BATCH_SIZE, shuffle=False, num_workers=4)
    
    return train_loader, val_loader

# 测试代码
if __name__ == '__main__':
    train_loader, val_loader = get_loaders()
    print(f"训练集批次数: {len(train_loader)}")
    print(f"验证集批次数: {len(val_loader)}")
    
    # 查看一个批次的数据
    images, labels = next(iter(train_loader))
    print(f"图像批次形状: {images.shape}") # [32, 3, 128, 128]
    print(f"标签批次形状: {labels.shape}") # [32]
3. model.py:CNN“建筑师”

我们定义一个简单的 CNN,它足以完成这个二分类任务。

# model.py

import torch.nn as nn
import torch.nn.functional as F

class SimpleCNN(nn.Module):
    def __init__(self, num_classes=2):
        super(SimpleCNN, self).__init__()
        self.conv1 = nn.Conv2d(3, 16, kernel_size=3, padding=1)
        self.conv2 = nn.Conv2d(16, 32, kernel_size=3, padding=1)
        self.conv3 = nn.Conv2d(32, 64, kernel_size=3, padding=1)
        self.pool = nn.MaxPool2d(2, 2)
        
        # 计算展平后的尺寸: 128 -> 64 -> 32 -> 16
        # 128*128经过3次pooling后变成16*16
        self.fc1 = nn.Linear(64 * 16 * 16, 512)
        self.fc2 = nn.Linear(512, num_classes)
        self.dropout = nn.Dropout(0.5)

    def forward(self, x):
        x = self.pool(F.relu(self.conv1(x)))
        x = self.pool(F.relu(self.conv2(x)))
        x = self.pool(F.relu(self.conv3(x)))
        
        x = x.view(-1, 64 * 16 * 16) # 展平
        
        x = F.relu(self.fc1(x))
        x = self.dropout(x)
        x = self.fc2(x)
        return x
4. utils.py:可复用的“工具箱”

这里我们直接放入上一节课已经写好的 GradCAM 类。

# utils.py

import torch
import torch.nn.functional as F

class GradCAM:
    # ... (此处粘贴 Day 42 中已经写好的完整 GradCAM 类代码)
    def __init__(self, model, target_layer):
        self.model = model
        self.target_layer = target_layer
        self.gradients = None
        self.activations = None
        self.target_layer.register_forward_hook(self.save_activations)
        self.target_layer.register_full_backward_hook(self.save_gradients)

    def save_activations(self, module, input, output):
        self.activations = output.detach()

    def save_gradients(self, module, grad_input, grad_output):
        self.gradients = grad_output[0].detach()

    def generate_cam(self, input_tensor, target_class=None):
        model_output = self.model(input_tensor)
        if target_class is None:
            target_class = torch.argmax(model_output, dim=1).item()
        self.model.zero_grad()
        one_hot = torch.zeros_like(model_output)
        one_hot[0, target_class] = 1
        model_output.backward(gradient=one_hot, retain_graph=True)
        weights = torch.mean(self.gradients, dim=(2, 3), keepdim=True)
        cam = torch.sum(weights * self.activations, dim=1, keepdim=True)
        cam = F.relu(cam)
        cam = F.interpolate(cam, size=(input_tensor.shape[2], input_tensor.shape[3]), mode='bilinear', align_corners=False)
        cam = cam - cam.min()
        cam = cam / (cam.max() + 1e-8)
        return cam.cpu().squeeze().numpy(), target_class
5. train.py:项目的“总指挥”

这个脚本将所有模块串联起来,执行训练和验证。

# train.py

import torch
import torch.nn as nn
import torch.optim as optim
from tqdm import tqdm

import config
from model import SimpleCNN
from dataset import get_loaders

def train_one_epoch(loader, model, optimizer, criterion, device):
    model.train()
    loop = tqdm(loader, leave=True)
    total_loss = 0
    for batch_idx, (data, targets) in enumerate(loop):
        data, targets = data.to(device), targets.to(device)
        
        # forward
        scores = model(data)
        loss = criterion(scores, targets)
        
        # backward
        optimizer.zero_grad()
        loss.backward()
        
        # optimizer step
        optimizer.step()
        
        total_loss += loss.item()
        loop.set_postfix(loss=loss.item())
        
    print(f"Epoch Loss: {total_loss/len(loader):.4f}")

def check_accuracy(loader, model, device):
    model.eval()
    num_correct = 0
    num_samples = 0
    
    with torch.no_grad():
        for x, y in loader:
            x, y = x.to(device), y.to(device)
            scores = model(x)
            _, predictions = scores.max(1)
            num_correct += (predictions == y).sum()
            num_samples += predictions.size(0)
            
    accuracy = float(num_correct) / float(num_samples)
    print(f"Validation Accuracy: {accuracy*100:.2f}%")
    return accuracy


def main():
    print("开始训练...")
    train_loader, val_loader = get_loaders()
    
    model = SimpleCNN().to(config.DEVICE)
    criterion = nn.CrossEntropyLoss()
    optimizer = optim.Adam(model.parameters(), lr=config.LEARNING_RATE)
    
    best_accuracy = 0.0

    for epoch in range(config.NUM_EPOCHS):
        print(f"--- Epoch {epoch+1}/{config.NUM_EPOCHS} ---")
        train_one_epoch(train_loader, model, optimizer, criterion, config.DEVICE)
        accuracy = check_accuracy(val_loader, model, config.DEVICE)
        
        if accuracy > best_accuracy:
            best_accuracy = accuracy
            torch.save(model.state_dict(), config.MODEL_SAVE_PATH)
            print(f"模型已保存,最佳准确率: {best_accuracy*100:.2f}%")

if __name__ == '__main__':
    main()
6. visualize.py:见证奇迹的“透视镜”

训练完成后,我们用这个脚本来加载模型,并对一张新的图片进行 Grad-CAM 可视化。

# visualize.py

import torch
import torchvision.transforms as transforms
from PIL import Image
import matplotlib.pyplot as plt
import numpy as np

import config
from model import SimpleCNN
from utils import GradCAM

def visualize(image_path, model, target_layer):
    # 图像预处理
    transform = transforms.Compose([
        transforms.Resize((config.IMAGE_SIZE, config.IMAGE_SIZE)),
        transforms.ToTensor(),
        transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
    ])
    
    original_img = Image.open(image_path).convert("RGB")
    input_tensor = transform(original_img).unsqueeze(0).to(config.DEVICE)
    
    # Grad-CAM
    grad_cam = GradCAM(model, target_layer)
    heatmap, pred_class_idx = grad_cam.generate_cam(input_tensor)
    
    # 反归一化用于显示
    img_to_show = np.array(original_img.resize((config.IMAGE_SIZE, config.IMAGE_SIZE))) / 255.0
    
    # 绘制
    classes = ('猫', '狗')
    fig, axs = plt.subplots(1, 3, figsize=(15, 5))
    plt.rcParams['font.sans-serif'] = ['SimHei'] # Matplotlib中显示中文
    
    fig.suptitle(f"模型预测: {classes[pred_class_idx]}", fontsize=16)
    axs[0].imshow(img_to_show)
    axs[0].set_title("原始图像")
    axs[0].axis('off')

    axs[1].imshow(heatmap, cmap='jet')
    axs[1].set_title("Grad-CAM 热力图")
    axs[1].axis('off')

    heatmap_colored = plt.cm.jet(heatmap)[:, :, :3]
    superimposed_img = heatmap_colored * 0.4 + img_to_show * 0.6
    axs[2].imshow(superimposed_img)
    axs[2].set_title("热力图叠加")
    axs[2].axis('off')

    plt.tight_layout()
    plt.show()

if __name__ == '__main__':
    # 加载模型
    model = SimpleCNN().to(config.DEVICE)
    model.load_state_dict(torch.load(config.MODEL_SAVE_PATH, map_location=config.DEVICE))
    model.eval()
    
    # 选择一张测试图片
    # 注意:你需要自己提供一张猫或狗的图片路径
    test_image_path = "./data/test1/1.jpg" # 示例路径
    
    # 可视化
    visualize(test_image_path, model, model.conv3) # 选择最后一个卷积层

三、成果与心得

在运行 train.py 完成训练后,运行 visualize.py,你将看到,结果清晰地告诉我们,模型在判断这张图片为“猫”时,它的“注意力”主要集中在了猫的头部、耳朵和眼睛这些关键区域。这完全符合我们的直觉,证明了我们的CNN模型确实学到了有效的特征!

心得与总结

这次复习项目让我收获巨大,远超任何单一的练习:

  1. 工程化思维的胜利:将代码拆分成多个文件,初期看似麻烦,但一旦项目变得复杂,其优势是压倒性的:代码更清晰、bug更容易定位、模块可以复用。这是一个从“学生”到“工程师”的思维转变。
  2. 数据是“万物之源”:这次我们不再依赖 torchvision 的“保姆式”数据集,而是亲手为Kaggle的原始图片文件构建了数据加载管道。这让我深刻理解到,在真实世界中,数据预处理和加载往往占据了项目的大部分时间。
  3. 串联知识,形成闭环:这个项目像一根线,将我们之前学的所有珍珠——DatasetDataLoader、CNN模型构建、训练循环、模型保存/加载、Hook机制、Grad-CAM——全部串联了起来,形成了一条完整的、闪闪发光的“知识项链”。
  4. 从“能用”到“好用”:加入 tqdm 进度条、在验证集上保存最佳模型、使用 config.py 管理参数,这些细节让我们的项目不再是一个玩具,而是一个具备了良好工程实践雏形的“产品”。

这次大作业,是真正的“学以致用”。它不仅巩固了我的技能,更建立了我独立完成一个小型AI项目的信心。


再次感谢 @浙大疏锦行 老师设计的这次综合性大作业,它让我真正体验到了从0到1构建一个项目的完整流程和巨大成就感!

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐