返回项目列表
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 特征图各一张,并写出简要分析