返回项目列表

CV 图像分类实战

📋 项目概览

难度⭐⭐⭐高级
时长2周
前置知识
Python 基础PyTorch 基础CNN 基础概念
关联路线图节点

🎯 学习目标

  • 掌握 CNN 完整训练流程
  • 理解数据增强技术
  • 学会模型评估和优化
  • 了解迁移学习方法

📦 项目结构

data/# 数据集目录必需
models/# 模型定义必需
utils/# 工具函数
train.py# 训练脚本必需
README.md# 项目说明必需

🚀 实现步骤

1

环境准备与 GPU 验证

配置 PyTorch 深度学习环境,确保 GPU 可用

任务清单
  • 1.1 创建 conda 环境:`conda create -n cv-project python=3.9 && conda activate cv-project`
  • 1.2 安装 PyTorch(GPU 版本):访问 https://pytorch.org/get-started/locally/ 选择对应的 CUDA 版本,运行 `pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118`
  • 1.3 验证安装:运行 `python -c "import torch; print(torch.__version__)"`
  • 1.4 运行 GPU 验证脚本(见下方代码)
  • 1.5 记录 GPU 型号和可用显存大小
GPU 验证代码
import torch

# 检查 GPU 是否可用
print(f"CUDA available: {torch.cuda.is_available()}")

# 获取 CUDA 版本
print(f"CUDA version: {torch.version.cuda}")

# 获取 GPU 数量
print(f"GPU count: {torch.cuda.device_count()}")

# 获取当前 GPU 型号
if torch.cuda.is_available():
    print(f"GPU: {torch.cuda.get_device_name(0)}")
    # 获取显存大小(GB)
    mem = torch.cuda.get_device_properties(0).total_mem / 1024**3
    print(f"Total memory: {mem:.1f} GB")
    # 检查 cuDNN 是否启用
    print(f"cuDNN enabled: {torch.backends.cudnn.enabled}")
    print(f"cuDNN version: {torch.backends.cudnn.version()}")

✓ 完成标准:运行 gpu_verification.py 能打印出 GPU 型号和显存大小,且 CUDA available 显示 True

2

数据加载与增强

使用 torchvision 加载 CIFAR-10 数据集,实现数据增强 pipeline

任务清单
  • 2.1 创建项目目录结构:`mkdir -p data models utils scripts`
  • 2.2 下载 CIFAR-10 数据集到 data 目录
  • 2.3 定义训练集数据增强 pipeline(随机翻转、裁剪、归一化)
  • 2.4 定义测试集预处理(仅归一化)
  • 2.5 使用 DataLoader 创建训练集和测试集加载器
  • 2.6 可视化一个 batch 的图片,确认增强效果
数据加载代码
import torch
from torchvision import datasets, transforms
from torch.utils.data import DataLoader

# 训练集数据增强
transform_train = transforms.Compose([
    transforms.RandomCrop(32, padding=4),      # 随机裁剪,周围补零
    transforms.RandomHorizontalFlip(),          # 随机水平翻转
    transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2),  # 颜色抖动
    transforms.ToTensor(),                      # 转换为张量
    transforms.Normalize(                      # ImageNet 统计值归一化
        mean=[0.4914, 0.4822, 0.4465],
        std=[0.2023, 0.1994, 0.2010]
    )
])

# 测试集预处理(不增强)
transform_test = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize(
        mean=[0.4914, 0.4822, 0.4465],
        std=[0.2023, 0.1994, 0.2010]
    )
])

# 加载数据集
train_dataset = datasets.CIFAR10(
    root='./data', train=True, download=True, transform=transform_train
)
test_dataset = datasets.CIFAR10(
    root='./data', train=False, download=True, transform=transform_test
)

# 创建 DataLoader
train_loader = DataLoader(train_dataset, batch_size=128, shuffle=True, num_workers=4, pin_memory=True)
test_loader = DataLoader(test_dataset, batch_size=128, shuffle=False, num_workers=4, pin_memory=True)

# CIFAR-10 类别名称
classes = ['airplane', 'automobile', 'bird', 'cat', 'deer', 'dog', 'frog', 'horse', 'ship', 'truck']

print(f"训练集样本数: {len(train_dataset)}")
print(f"测试集样本数: {len(test_dataset)}")
可视化代码
import matplotlib.pyplot as plt
import numpy as np

# 获取一个 batch
images, labels = next(iter(train_loader))

# 反归一化以便显示(使用 CIFAR-10 的统计值)
def imshow(img):
    img = img.numpy()
    # 反归一化: img * std + mean
    img = img * np.array([0.2023, 0.1994, 0.2010]).reshape(3, 1, 1) + np.array([0.4914, 0.4822, 0.4465]).reshape(3, 1, 1)
    img = np.clip(img, 0, 1)
    plt.imshow(np.transpose(img, (1, 2, 0)))
    plt.axis('off')

# 显示 16 张图片
fig, axes = plt.subplots(4, 4, figsize=(10, 10))
for i, ax in enumerate(axes.flat):
    imshow(images[i])
    ax.set_title(classes[labels[i]])
plt.tight_layout()
plt.savefig('data_sample.png', dpi=150)
plt.show()

✓ 完成标准:能成功加载 CIFAR-10 数据集,可视化图片正常显示,且每张图片的增强效果不同(因为随机增强)

3

CNN 模型构建

构建一个包含残差连接的 CNN 模型,理解网络设计原则

任务清单
  • 3.1 在 models/ 目录下创建 `__init__.py` 和 `resnet.py`
  • 3.2 实现 BasicBlock 残差块(两个 3x3 卷积层 + skip connection)
  • 3.3 实现 ResNet 网络结构(3 个残差层 + 全局平均池化 + 全连接层)
  • 3.4 在 main.py 中实例化模型并打印结构
  • 3.5 验证模型前向传播,输出 shape 正确
  • 3.6 统计模型参数量和可训练参数数量
ResNet 模型代码
import torch
import torch.nn as nn
import torch.nn.functional as F

class BasicBlock(nn.Module):
    """基础残差块"""
    expansion = 1
    
    def __init__(self, in_channels, out_channels, stride=1):
        super(BasicBlock, self).__init__()
        # 第一个卷积层
        self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, 
                               stride=stride, padding=1, bias=False)
        self.bn1 = nn.BatchNorm2d(out_channels)
        # 第二个卷积层
        self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, 
                               stride=1, padding=1, bias=False)
        self.bn2 = nn.BatchNorm2d(out_channels)
        
        # Skip connection:如果形状不同,用 1x1 卷积调整
        self.shortcut = nn.Sequential()
        if stride != 1 or in_channels != out_channels * self.expansion:
            self.shortcut = nn.Sequential(
                nn.Conv2d(in_channels, out_channels * self.expansion, 
                         kernel_size=1, stride=stride, bias=False),
                nn.BatchNorm2d(out_channels * self.expansion)
            )
    
    def forward(self, x):
        out = F.relu(self.bn1(self.conv1(x)))
        out = self.bn2(self.conv2(out))
        out += self.shortcut(x)  # 残差连接
        out = F.relu(out)
        return out


class ResNet(nn.Module):
    """ResNet 网络"""
    
    def __init__(self, block, num_blocks, num_classes=10):
        super(ResNet, self).__init__()
        self.in_channels = 64
        
        # 初始卷积层
        self.conv1 = nn.Conv2d(3, 64, kernel_size=3, stride=1, padding=1, bias=False)
        self.bn1 = nn.BatchNorm2d(64)
        
        # 三个残差层
        self.layer1 = self._make_layer(block, 64, num_blocks[0], stride=1)
        self.layer2 = self._make_layer(block, 128, num_blocks[1], stride=2)
        self.layer3 = self._make_layer(block, 256, num_blocks[2], stride=2)
        self.layer4 = self._make_layer(block, 512, num_blocks[3], stride=2)
        
        # 全局平均池化 + 全连接层
        self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
        self.fc = nn.Linear(512 * block.expansion, num_classes)
    
    def _make_layer(self, block, out_channels, num_blocks, stride):
        strides = [stride] + [1] * (num_blocks - 1)
        layers = []
        for s in strides:
            layers.append(block(self.in_channels, out_channels, s))
            self.in_channels = out_channels * block.expansion
        return nn.Sequential(*layers)
    
    def forward(self, x):
        out = F.relu(self.bn1(self.conv1(x)))
        out = self.layer1(out)
        out = self.layer2(out)
        out = self.layer3(out)
        out = self.layer4(out)
        out = self.avgpool(out)
        out = torch.flatten(out, 1)
        out = self.fc(out)
        return out


def resnet18():
    return ResNet(BasicBlock, [2, 2, 2, 2])

def resnet34():
    return ResNet(BasicBlock, [3, 4, 6, 3])
模型使用代码
import torch

# 创建模型
model = resnet18()
model = model.cuda()  # 移到 GPU

# 打印模型结构
print(model)

# 统计参数量
total_params = sum(p.numel() for p in model.parameters())
trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"总参数量: {total_params:,}")
print(f"可训练参数: {trainable_params:,}")

# 测试前向传播
x = torch.randn(1, 3, 32, 32).cuda()
output = model(x)
print(f"输入 shape: {x.shape}")
print(f"输出 shape: {output.shape}")  # 应该是 [1, 10]

✓ 完成标准:模型能正常前向传播,输出 shape 为 [batch, 10],参数量约 1100 万

4

训练循环与优化

实现完整的训练循环,掌握学习率调度和梯度裁剪

任务清单
  • 4.1 在 scripts/ 目录下创建 train.py
  • 4.2 定义训练函数:前向传播、计算 loss、反向传播、更新参数
  • 4.3 定义验证函数:在测试集上评估模型准确率
  • 4.4 配置优化器(AdamW)和学习率调度器(CosineAnnealing)
  • 4.5 实现梯度裁剪防止梯度爆炸
  • 4.6 添加 TensorBoard 或 wandb 记录训练曲线
  • 4.7 保存最佳模型权重
  • 4.8 开始训练 50 个 epoch,观察 loss 和 accuracy 曲线
训练脚本
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
import matplotlib.pyplot as plt

def train_epoch(model, train_loader, criterion, optimizer, device):
    model.train()
    running_loss = 0.0
    correct = 0
    total = 0
    
    for images, labels in train_loader:
        images, labels = images.to(device), labels.to(device)
        
        # 前向传播
        outputs = model(images)
        loss = criterion(outputs, labels)
        
        # 反向传播
        optimizer.zero_grad()
        loss.backward()
        
        # 梯度裁剪:防止梯度爆炸
        torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
        
        optimizer.step()
        
        running_loss += loss.item()
        _, predicted = outputs.max(1)
        total += labels.size(0)
        correct += predicted.eq(labels).sum().item()
    
    return running_loss / len(train_loader), 100. * correct / total


def validate(model, val_loader, criterion, device):
    model.eval()
    running_loss = 0.0
    correct = 0
    total = 0
    
    with torch.no_grad():
        for images, labels in val_loader:
            images, labels = images.to(device), labels.to(device)
            outputs = model(images)
            loss = criterion(outputs, labels)
            
            running_loss += loss.item()
            _, predicted = outputs.max(1)
            total += labels.size(0)
            correct += predicted.eq(labels).sum().item()
    
    return running_loss / len(val_loader), 100. * correct / total


def plot_training_history(train_losses, val_losses, train_accs, val_accs):
    """绘制训练曲线"""
    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
    
    # Loss 曲线
    ax1.plot(train_losses, label='Train Loss')
    ax1.plot(val_losses, label='Val Loss')
    ax1.set_xlabel('Epoch')
    ax1.set_ylabel('Loss')
    ax1.set_title('Training and Validation Loss')
    ax1.legend()
    ax1.grid(True)
    
    # Accuracy 曲线
    ax2.plot(train_accs, label='Train Acc')
    ax2.plot(val_accs, label='Val Acc')
    ax2.set_xlabel('Epoch')
    ax2.set_ylabel('Accuracy (%)')
    ax2.set_title('Training and Validation Accuracy')
    ax2.legend()
    ax2.grid(True)
    
    plt.tight_layout()
    plt.savefig('training_curve.png', dpi=150)
    plt.show()


def main():
    device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    
    # 模型、优化器、损失函数
    model = resnet18().to(device)
    criterion = nn.CrossEntropyLoss()
    optimizer = optim.AdamW(model.parameters(), lr=0.001, weight_decay=0.01)
    
    # 学习率调度:余弦退火
    scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=50)
    
    # 训练
    best_acc = 0.0
    train_losses, val_losses = [], []
    train_accs, val_accs = [], []
    
    for epoch in range(50):
        train_loss, train_acc = train_epoch(model, train_loader, criterion, optimizer, device)
        val_loss, val_acc = validate(model, test_loader, criterion, device)
        
        # 更新学习率
        scheduler.step()
        
        # 记录曲线
        train_losses.append(train_loss)
        val_losses.append(val_loss)
        train_accs.append(train_acc)
        val_accs.append(val_acc)
        
        # 打印信息
        print(f"Epoch [{epoch+1}/50] "
              f"Train Loss: {train_loss:.4f}, Train Acc: {train_acc:.2f}% | "
              f"Val Loss: {val_loss:.4f}, Val Acc: {val_acc:.2f}% | "
              f"LR: {scheduler.get_last_lr()[0]:.6f}")
        
        # 保存最佳模型
        if val_acc > best_acc:
            best_acc = val_acc
            torch.save(model.state_dict(), 'best_model.pth')
            print(f"  -> Saved best model with acc: {best_acc:.2f}%")
    
    # 绘制训练曲线
    plot_training_history(train_losses, val_losses, train_accs, val_accs)
    print(f"Best validation accuracy: {best_acc:.2f}%")


if __name__ == '__main__':
    main()

✓ 完成标准:训练 50 个 epoch 后,验证集准确率 > 85%,能绘制出 loss 和 accuracy 曲线图

5

模型评估与可视化

全面评估模型性能,分析错误样本,可视化学习成果

任务清单
  • 5.1 加载最佳模型权重进行评估
  • 5.2 在测试集上计算分类报告(precision、recall、F1)
  • 5.3 绘制混淆矩阵热力图
  • 5.4 找出混淆最严重的类别对并分析原因
  • 5.5 实现 Grad-CAM 可视化,检查模型关注区域
  • 5.6 使用 t-SNE 可视化特征空间
  • 5.7 整理项目报告,包含所有可视化结果
评估代码
import torch
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.metrics import classification_report, confusion_matrix
from sklearn.manifold import TSNE

# 加载最佳模型
model = resnet18()
model.load_state_dict(torch.load('best_model.pth'))
model = model.cuda()
model.eval()

# 收集预测结果
all_preds = []
all_labels = []
all_features = []

with torch.no_grad():
    for images, labels in test_loader:
        images, labels = images.cuda(), labels.cuda()
        outputs = model(images)
        _, predicted = outputs.max(1)
        
        # 获取特征(全局平均池化前的特征图)
        features = model.avgpool(model.layer4(model.layer3(model.layer2(model.layer1(model.bn1(model.conv1(images))))))))
        
        all_preds.extend(predicted.cpu().numpy())
        all_labels.extend(labels.cpu().numpy())
        all_features.append(features.cpu().numpy())

all_preds = np.array(all_preds)
all_labels = np.array(all_labels)
all_features = np.concatenate(all_features, axis=0)

# 分类报告
print("=" * 60)
print("分类报告")
print("=" * 60)
print(classification_report(all_labels, all_preds, target_names=classes))

# 混淆矩阵
cm = confusion_matrix(all_labels, all_preds)
plt.figure(figsize=(12, 10))
sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', 
            xticklabels=classes, yticklabels=classes)
plt.xlabel('Predicted')
plt.ylabel('True')
plt.title('Confusion Matrix')
plt.tight_layout()
plt.savefig('confusion_matrix.png', dpi=150)
plt.show()

# 找出最容易混淆的类别对
print("
" + "=" * 60)
print("最容易混淆的类别对")
print("=" * 60)
for i in range(len(classes)):
    for j in range(len(classes)):
        if i != j and cm[i, j] > 50:
            print(f"{classes[i]} -> {classes[j]}: {cm[i, j]} 次")
Grad-CAM 代码
# Grad-CAM 可视化
# 需要安装:pip install grad-cam

from pytorch_grad_cam import GradCAM, GradCAMpp
from pytorch_grad_cam.utils.image import show_cam_on_image
import cv2
import numpy as np
import matplotlib.pyplot as plt

def visualize_gradcam(model, image, label, target_layer):
    """可视化 Grad-CAM"""
    model.eval()
    
    # 创建 GradCAM
    cam = GradCAM(model=model, target_layers=[target_layer])
    
    # 获取 CAM
    image_tensor = image.unsqueeze(0).cuda()
    pred = model(image_tensor).argmax().item()
    
    # 生成 mask
    grayscale_cam = cam(input_tensor=image_tensor, targets=None)
    mask = grayscale_cam[0, :]
    
    # 反归一化图片
    img = image.squeeze().cpu().permute(1, 2, 0).numpy()
    img = img * np.array([0.2023, 0.1994, 0.2010]) + np.array([0.4914, 0.4822, 0.4465])
    img = np.clip(img, 0, 1)
    
    # 叠加
    visualization = show_cam_on_image(img, mask, use_rgb=True)
    
    plt.figure(figsize=(10, 5))
    plt.subplot(1, 2, 1)
    plt.imshow(img)
    plt.title(f'Original: {classes[label]}')
    plt.axis('off')
    
    plt.subplot(1, 2, 2)
    plt.imshow(visualization)
    plt.title(f'Grad-CAM: Pred={classes[pred]}')
    plt.axis('off')
    plt.tight_layout()
    plt.savefig('gradcam_example.png')
    plt.show()

# 使用示例
images, labels = next(iter(test_loader))
visualize_gradcam(model, images[0], labels[0], model.layer4[-1])
t-SNE 可视化
# t-SNE 特征可视化
print("正在进行 t-SNE 降维...")
tsne = TSNE(n_components=2, random_state=42, perplexity=30)
features_2d = tsne.fit_transform(all_features[:2000])  # 使用子集加速

plt.figure(figsize=(12, 10))
scatter = plt.scatter(features_2d[:, 0], features_2d[:, 1], 
                      c=all_labels[:2000], cmap='tab10', 
                      alpha=0.6, s=5)
plt.colorbar(scatter, ticks=range(10))
plt.title('t-SNE Visualization of Feature Space')
plt.xlabel('t-SNE Dimension 1')
plt.ylabel('t-SNE Dimension 2')
plt.savefig('tsne_visualization.png', dpi=150)
plt.show()

✓ 完成标准:能生成分类报告、混淆矩阵图、Grad-CAM 可视化图、t-SNE 特征图各一张,并写出简要分析

📚 参考资源