【Python学习打卡-Day43】从Kaggle到CAM:我的第一个CNN图像分类项目全流程复盘
本文介绍了一个完整的CNN图像分类项目实战,从Kaggle获取猫狗数据集,采用模块化思想构建项目结构。项目分为配置文件(config.py)、数据处理(dataset.py)、模型定义(model.py)、工具函数(utils.py)、训练脚本(train.py)和可视化(visualize.py)六个模块。重点讲解了自定义Dataset类的实现方法、简单CNN模型的设计,以及数据预处理流程。通过
📋 前言
各位伙伴们,大家好!如果说之前的学习是在“集齐龙珠”,那么今天,我们就要“召唤神龙”了!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”
- 下载数据:前往 Kaggle Dogs vs. Cats 页面,下载
train.zip和test1.zip。 - 解压数据:在项目根目录下创建
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模型确实学到了有效的特征!
心得与总结
这次复习项目让我收获巨大,远超任何单一的练习:
- 工程化思维的胜利:将代码拆分成多个文件,初期看似麻烦,但一旦项目变得复杂,其优势是压倒性的:代码更清晰、bug更容易定位、模块可以复用。这是一个从“学生”到“工程师”的思维转变。
- 数据是“万物之源”:这次我们不再依赖
torchvision的“保姆式”数据集,而是亲手为Kaggle的原始图片文件构建了数据加载管道。这让我深刻理解到,在真实世界中,数据预处理和加载往往占据了项目的大部分时间。 - 串联知识,形成闭环:这个项目像一根线,将我们之前学的所有珍珠——
Dataset、DataLoader、CNN模型构建、训练循环、模型保存/加载、Hook机制、Grad-CAM——全部串联了起来,形成了一条完整的、闪闪发光的“知识项链”。 - 从“能用”到“好用”:加入
tqdm进度条、在验证集上保存最佳模型、使用config.py管理参数,这些细节让我们的项目不再是一个玩具,而是一个具备了良好工程实践雏形的“产品”。
这次大作业,是真正的“学以致用”。它不仅巩固了我的技能,更建立了我独立完成一个小型AI项目的信心。
再次感谢 @浙大疏锦行 老师设计的这次综合性大作业,它让我真正体验到了从0到1构建一个项目的完整流程和巨大成就感!
更多推荐



所有评论(0)