今日目标 :从零搭建CNN,训练CIFAR-10分类器,完成从数据准备到模型导出的完整Pipeline
预计阅读 :10分钟 | 动手操作:50分钟
一、今天你要完成一个真正的项目
CIFAR-10 图像分类 --- 深度学习入门的"Hello World"
输入: 32×32×3 的彩色图片
输出: 10个类别之一(飞机、汽车、鸟、猫、鹿、狗、青蛙、马、船、卡车)
你将亲手完成:
数据准备 → 模型设计 → 训练调参 → 评估分析 → 模型导出
二、完整项目代码
2.1 数据准备:增强和加载
python
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
import numpy as np
from sklearn.metrics import classification_report, confusion_matrix
import matplotlib.pyplot as plt
torch.manual_seed(42)
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f"设备: {device}")
# ===== CIFAR-10的统计量 =====
CIFAR10_MEAN = (0.4914, 0.4822, 0.4465)
CIFAR10_STD = (0.2470, 0.2435, 0.2616)
# ===== 训练数据增强 =====
train_transform = transforms.Compose([
transforms.RandomCrop(32, padding=4), # 先填充到36再随机裁32
transforms.RandomHorizontalFlip(p=0.5), # 50%概率水平翻转
transforms.ColorJitter(brightness=0.1, # 亮度抖动
contrast=0.1, # 对比度抖动
saturation=0.1), # 饱和度抖动
transforms.ToTensor(),
transforms.Normalize(CIFAR10_MEAN, CIFAR10_STD),
])
# ===== 验证数据只做归一化 =====
val_transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize(CIFAR10_MEAN, CIFAR10_STD),
])
# 加载数据集
train_dataset = datasets.CIFAR10('./data', train=True, download=True, transform=train_transform)
val_dataset = datasets.CIFAR10('./data', train=False, download=True, transform=val_transform)
train_loader = DataLoader(train_dataset, batch_size=128, shuffle=True, num_workers=0, pin_memory=True)
val_loader = DataLoader(val_dataset, batch_size=128, shuffle=False, num_workers=0, pin_memory=True)
# 类别名
CLASSES = ['飞机', '汽车', '鸟', '猫', '鹿', '狗', '青蛙', '马', '船', '卡车']
print(f"训练集: {len(train_dataset)} 张")
print(f"验证集: {len(val_dataset)} 张")
2.2 模型设计:从零搭建CNN
python
class CIFAR10CNN(nn.Module):
"""
一个经典的CNN架构,适合CIFAR-10
架构设计思路:
- 3个卷积块,每个块 = Conv → BN → ReLU → Conv → BN → ReLU → Pool
- 通道数逐步增加:3 → 32 → 64 → 128
- 空间尺寸逐步减小:32 → 16 → 8 → 4
- 最后全连接:128×4×4 → 256 → 10
"""
def __init__(self, num_classes=10):
super().__init__()
# Block 1: 32×32 → 16×16
self.conv1 = nn.Sequential(
nn.Conv2d(3, 32, 3, padding=1, bias=False),
nn.BatchNorm2d(32),
nn.ReLU(inplace=True),
nn.Conv2d(32, 32, 3, padding=1, bias=False),
nn.BatchNorm2d(32),
nn.ReLU(inplace=True),
nn.MaxPool2d(2), # 32 → 16
nn.Dropout2d(0.1),
)
# Block 2: 16×16 → 8×8
self.conv2 = nn.Sequential(
nn.Conv2d(32, 64, 3, padding=1, bias=False),
nn.BatchNorm2d(64),
nn.ReLU(inplace=True),
nn.Conv2d(64, 64, 3, padding=1, bias=False),
nn.BatchNorm2d(64),
nn.ReLU(inplace=True),
nn.MaxPool2d(2), # 16 → 8
nn.Dropout2d(0.1),
)
# Block 3: 8×8 → 4×4
self.conv3 = nn.Sequential(
nn.Conv2d(64, 128, 3, padding=1, bias=False),
nn.BatchNorm2d(128),
nn.ReLU(inplace=True),
nn.Conv2d(128, 128, 3, padding=1, bias=False),
nn.BatchNorm2d(128),
nn.ReLU(inplace=True),
nn.MaxPool2d(2), # 8 → 4
nn.Dropout2d(0.2),
)
# 分类头
self.classifier = nn.Sequential(
nn.AdaptiveAvgPool2d(1), # 全局平均池化 → (B, 128, 1, 1)
nn.Flatten(),
nn.Linear(128, 256),
nn.ReLU(inplace=True),
nn.Dropout(0.3),
nn.Linear(256, num_classes),
)
def forward(self, x):
x = self.conv1(x)
x = self.conv2(x)
x = self.conv3(x)
x = self.classifier(x)
return x
# 创建模型
model = CIFAR10CNN(num_classes=10).to(device)
# 统计参数量
total = sum(p.numel() for p in model.parameters())
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"模型参数量: {total:,} (可训练: {trainable:,})")
# 测试前向传播
dummy = torch.randn(4, 3, 32, 32).to(device)
output = model(dummy)
print(f"输入: {dummy.shape} → 输出: {output.shape}")
2.3 训练配置:优化器和学习率调度
python
# 损失函数
criterion = nn.CrossEntropyLoss()
# 优化器:AdamW + weight_decay
optimizer = optim.AdamW(model.parameters(), lr=0.001, weight_decay=1e-4)
# 学习率调度:余弦退火
scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=50, eta_min=1e-5)
# 早停(从Day 13复用的)
class EarlyStopping:
def __init__(self, patience=10):
self.patience = patience
self.best_loss = float('inf')
self.counter = 0
self.early_stop = False
def __call__(self, val_loss):
if val_loss < self.best_loss:
self.best_loss = val_loss
self.counter = 0
else:
self.counter += 1
if self.counter >= self.patience:
self.early_stop = True
return self.early_stop
early_stopping = EarlyStopping(patience=15)
2.4 训练循环
python
def train_epoch(model, loader, criterion, optimizer, device):
model.train()
running_loss, correct, total = 0.0, 0, 0
for images, labels in loader:
images, labels = images.to(device), labels.to(device)
optimizer.zero_grad()
outputs = model(images)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
running_loss += loss.item() * images.size(0)
_, predicted = outputs.max(1)
correct += predicted.eq(labels).sum().item()
total += labels.size(0)
return running_loss / total, correct / total
@torch.no_grad()
def validate(model, loader, criterion, device):
model.eval()
running_loss, correct, total = 0.0, 0, 0
all_preds, all_labels = [], []
for images, labels in loader:
images, labels = images.to(device), labels.to(device)
outputs = model(images)
loss = criterion(outputs, labels)
running_loss += loss.item() * images.size(0)
_, predicted = outputs.max(1)
correct += predicted.eq(labels).sum().item()
total += labels.size(0)
all_preds.extend(predicted.cpu().numpy())
all_labels.extend(labels.cpu().numpy())
return running_loss / total, correct / total, np.array(all_preds), np.array(all_labels)
# 开始训练
epochs = 50
best_acc = 0.0
history = {'train_loss': [], 'train_acc': [], 'val_loss': [], 'val_acc': []}
print(f"\n{'='*60}")
print(f"开始训练 CIFAR-10 CNN ({epochs} epochs)")
print(f"{'='*60}")
for epoch in range(epochs):
train_loss, train_acc = train_epoch(model, train_loader, criterion, optimizer, device)
val_loss, val_acc, _, _ = validate(model, val_loader, criterion, device)
scheduler.step()
current_lr = optimizer.param_groups[0]['lr']
# 保存最佳模型
if val_acc > best_acc:
best_acc = val_acc
torch.save({
'epoch': epoch,
'model_state_dict': model.state_dict(),
'optimizer_state_dict': optimizer.state_dict(),
'val_acc': val_acc,
}, 'cifar10_best.pth')
# 记录
history['train_loss'].append(train_loss)
history['train_acc'].append(train_acc)
history['val_loss'].append(val_loss)
history['val_acc'].append(val_acc)
if epoch % 10 == 0 or epoch == epochs - 1:
print(f"Epoch {epoch+1:3d}/{epochs} | "
f"LR: {current_lr:.6f} | "
f"Train Loss: {train_loss:.4f} Acc: {train_acc:.2%} | "
f"Val Loss: {val_loss:.4f} Acc: {val_acc:.2%}")
# 早停检查
if early_stopping(val_loss):
print(f"早停触发!在第 {epoch+1} 个epoch停止")
break
print(f"\n训练完成!最佳验证准确率: {best_acc:.2%}")
2.5 模型评估
python
# 加载最佳模型
checkpoint = torch.load('cifar10_best.pth')
model.load_state_dict(checkpoint['model_state_dict'])
model.eval()
# 完整评估
val_loss, val_acc, all_preds, all_labels = validate(model, val_loader, criterion, device)
print(f"\n{'='*60}")
print(f"最终评估结果")
print(f"{'='*60}")
print(f"验证集准确率: {val_acc:.2%}")
# 分类报告
print(f"\n分类报告:")
print(classification_report(all_labels, all_preds, target_names=CLASSES))
# 混淆矩阵
cm = confusion_matrix(all_labels, all_preds)
print(f"\n混淆矩阵:")
print(f"{'':>6s}", end='')
for c in CLASSES:
print(f"{c:>6s}", end='')
print()
for i, row in enumerate(cm):
print(f"{CLASSES[i]:<6s}", end='')
for val in row:
print(f"{val:6d}", end='')
print()
# 各类别准确率
print(f"\n各类别准确率:")
for i, cls in enumerate(CLASSES):
cls_acc = cm[i, i] / cm[i].sum()
bar = '█' * int(cls_acc * 30)
print(f" {cls:<4s}: {bar} {cls_acc:.2%}")
2.6 单张图片推理
python
def predict_single_image(model, image_tensor, device):
"""
对单张图片推理
image_tensor: (3, 32, 32) 或 (1, 3, 32, 32)
"""
model.eval()
if image_tensor.dim() == 3:
image_tensor = image_tensor.unsqueeze(0)
image_tensor = image_tensor.to(device)
with torch.no_grad():
output = model(image_tensor)
probs = F.softmax(output, dim=1)
top5_prob, top5_idx = probs.topk(5)
print("Top-5 预测:")
for i in range(5):
cls = CLASSES[top5_idx[0, i].item()]
prob = top5_prob[0, i].item()
print(f" {i+1}. {cls:<4s}: {prob:.2%}")
return CLASSES[top5_idx[0, 0].item()], top5_prob[0, 0].item()
# 测试一张图片
sample_img, sample_label = val_dataset[0]
pred_cls, pred_prob = predict_single_image(model, sample_img, device)
true_cls = CLASSES[sample_label]
print(f"\n真实: {true_cls} | 预测: {pred_cls} ({pred_prob:.2%})")
三、深入分析
3.1 卷积核可视化
python
def visualize_kernels(model, layer_name='conv1.0'):
"""可视化第一层卷积核"""
conv_layer = model
for name in layer_name.split('.'):
conv_layer = getattr(conv_layer, name) if name.isdigit() else getattr(conv_layer, name)
kernels = conv_layer.weight.data.cpu().numpy()
kernels = (kernels - kernels.min()) / (kernels.max() - kernels.min() + 1e-8)
n_kernels = kernels.shape[0]
print(f"{layer_name}: 共 {n_kernels} 个卷积核, 形状 {kernels.shape[1:]}")
# 打印前4个卷积核的统计信息
for i in range(min(4, n_kernels)):
print(f" 核 {i}: 均值={kernels[i].mean():.4f}, "
f"标准差={kernels[i].std():.4f}, "
f"稀疏度={(np.abs(kernels[i])<0.1).mean():.1%}")
visualize_kernels(model, 'conv1.0')
3.2 错误案例分析
python
def analyze_errors(model, loader, device, max_errors=10):
"""找出模型预测错误的样本"""
model.eval()
errors = []
with torch.no_grad():
for images, labels in loader:
images, labels = images.to(device), labels.to(device)
outputs = model(images)
_, predicted = outputs.max(1)
mask = predicted != labels
for i in range(len(mask)):
if mask[i] and len(errors) < max_errors:
errors.append({
'true': CLASSES[labels[i].item()],
'pred': CLASSES[predicted[i].item()],
'confidence': F.softmax(outputs[i], dim=0)[predicted[i]].item(),
})
if len(errors) >= max_errors:
break
print(f"\n错误案例分析 (前{len(errors)}个):")
for i, e in enumerate(errors):
print(f" {i+1}. 真实: {e['true']:<4s} → 预测: {e['pred']:<4s} (置信度: {e['confidence']:.2%})")
# 统计最常见的混淆对
from collections import Counter
confusion_pairs = Counter()
for e in errors:
confusion_pairs[(e['true'], e['pred'])] += 1
print(f"\n最常见的混淆:")
for (true, pred), count in confusion_pairs.most_common(5):
print(f" {true} → {pred}: {count}次")
analyze_errors(model, val_loader, device)
3.3 模型复杂度分析
python
def analyze_model_complexity(model, input_size=(3, 32, 32)):
"""分析模型的参数量和计算量分布"""
print(f"\n{'='*60}")
print(f"模型复杂度分析")
print(f"{'='*60}")
print(f"{'层名':<25s} {'参数量':>10s} {'占比':>8s}")
print('-' * 45)
total = sum(p.numel() for p in model.parameters())
for name, param in model.named_parameters():
if param.requires_grad:
num = param.numel()
pct = num / total * 100
print(f"{name:<25s} {num:>10,d} {pct:>7.1f}%")
print('-' * 45)
print(f"{'总计':<25s} {total:>10,d}")
analyze_model_complexity(model)
四、动手实践:挑战更好的准确率
4.1 改进方向
python
"""
当前基线准确率: ~85-88%
改进方向(按难度排序):
┌─────────────────────────┬──────────┬──────────────────────────┐
│ 改进方法 │ 难度 │ 预期提升 │
├─────────────────────────┼──────────┼──────────────────────────┤
│ 1. 加更多epoch训练 │ ⭐ │ +1-2% │
│ 2. 更强的数据增强 │ ⭐ │ +1-3% │
│ 3. 加宽加深网络 │ ⭐⭐ │ +2-4% │
│ 4. 用残差连接 │ ⭐⭐⭐ │ +3-5% │
│ 5. 学习率warmup │ ⭐⭐ │ +0.5-1% │
│ 6. Label Smoothing │ ⭐ │ +0.5-1% │
│ 7. CutMix/MixUp增强 │ ⭐⭐ │ +2-4% │
│ 8. 用ResNet18预训练 │ ⭐⭐ │ +5-10% │
└─────────────────────────┴──────────┴──────────────────────────┘
"""
4.2 快速升级:使用ResNet18
python
def train_resnet18():
"""用ResNet18+Achieve ~95%+准确率"""
from torchvision.models import resnet18, ResNet18_Weights
model = resnet18(weights=ResNet18_Weights.DEFAULT)
model.fc = nn.Linear(512, 10) # 替换最后一层为10分类
model = model.to(device)
# 更强的数据增强
train_transform_v2 = transforms.Compose([
transforms.RandomCrop(32, padding=4),
transforms.RandomHorizontalFlip(),
transforms.AutoAugment(transforms.AutoAugmentPolicy.CIFAR10), # 自动增强
transforms.ToTensor(),
transforms.Normalize(CIFAR10_MEAN, CIFAR10_STD),
])
train_dataset_v2 = datasets.CIFAR10('./data', train=True, download=True, transform=train_transform_v2)
train_loader_v2 = DataLoader(train_dataset_v2, batch_size=128, shuffle=True, num_workers=0)
# 冻结前几层(迁移学习)
for param in model.parameters():
param.requires_grad = False
for param in model.layer4.parameters(): # 只训练最后一层卷积块
param.requires_grad = True
for param in model.fc.parameters(): # 训练分类头
param.requires_grad = True
optimizer = optim.AdamW(filter(lambda p: p.requires_grad, model.parameters()),
lr=0.001, weight_decay=1e-4)
scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=20)
criterion = nn.CrossEntropyLoss()
print("\nResNet18迁移学习训练中...")
for epoch in range(20):
train_loss, train_acc = train_epoch(model, train_loader_v2, criterion, optimizer, device)
val_loss, val_acc, _, _ = validate(model, val_loader, criterion, device)
scheduler.step()
print(f"Epoch {epoch+1:2d}: train_acc={train_acc:.2%}, val_acc={val_acc:.2%}")
print(f"\nResNet18最终准确率: {val_acc:.2%}")
return model
# 可选:训练ResNet18
# resnet_model = train_resnet18()
五、常见坑点
坑1:CIFAR-10图片太小,卷积太多会丢失信息
python
# ❌ 32×32的图,经过5次池化 → 1×1 → 空间信息全没了
# ✅ 控制池化次数,32×32 → 最多到4×4
坑2:验证集用了和训练集一样的增强
python
# ❌ 验证集也用了RandomHorizontalFlip
# val_transform = transforms.Compose([transforms.RandomHorizontalFlip(), ...])
# ✅ 验证集只做必要的预处理
# val_transform = transforms.Compose([transforms.ToTensor(), transforms.Normalize(...)])
坑3:batch_size太大导致收敛慢
python
# CIFAR-10图片小,batch_size可以大一些
# 经验值:128-256适合大多数GPU
# 但batch_size太大会导致泛化能力下降
坑4:忘记Normalize
python
# ❌ 直接ToTensor() → 像素值在[0,1]
# 模型期望的是归一化到N(0,1)的数据
# ✅ 必须用数据集的均值和标准差做Normalize
# 否则模型很难收敛
六、今日作业
- 跑通CIFAR-10训练:从零训练CNN,目标准确率 > 85%
- 改进模型:尝试至少一个改进方法(加宽网络、换激活函数、调learning rate等),记录准确率变化
- 分析错误:运行错误案例分析,找出最容易混淆的类别对
- 打卡 :评论区发你的最佳准确率,格式:"Day 20/100 打卡:CIFAR-10分类准确率XX%!"
今日小结
今天你完成了第一个完整的深度学习项目:
✅ 数据准备:CIFAR-10 + 数据增强
✅ 模型设计:3个卷积块 + 分类头
✅ 训练配置:AdamW + 余弦退火 + 早停
✅ 完整训练:50 epochs自动保存最佳模型
✅ 模型评估:分类报告、混淆矩阵、各类别准确率
✅ 单张图片推理:Top-5预测
✅ 深入分析:卷积核可视化、错误案例分析
✅ 改进方向:6种方法 + ResNet18迁移学习
✅ 4个经典坑点
明日预告
Day 21:卷积神经网络CNN --- 深入理解卷积
卷积的数学原理、感受野、空洞卷积、深度可分离卷积、分组卷积
🔥 关注我,每天解锁一个端侧AI技能!
微信公众号:xxx | 小红书:xxx | CSDN:xxx
评论区打卡,一起坚持100天!
附:小红书图文版
封面标题建议:手把手教你训练第一个CNN | CIFAR-10分类 🖼️
P1 --- 封面
标题:图像分类实战
副标题:CIFAR-10 | CNN | 完整Pipeline
关键词:图像分类 / CNN / PyTorch
P2 --- 项目全貌
CIFAR-10:10类彩色图片,32×32
数据准备 → 模型设计 → 训练 → 评估 → 推理
一个完整的深度学习项目
P3 --- 模型架构
3个卷积块:Conv→BN→ReLU→Pool
通道数:3→32→64→128
分类头:全局池化 → 256 → 10
参数量:~1.5M
P4 --- 训练技巧
数据增强:随机裁剪+翻转+颜色抖动
AdamW + weight_decay
余弦退火学习率
早停防止过拟合
P5 --- 评估分析
分类报告 + 混淆矩阵
错误案例分析
卷积核可视化
Easy baselines: 85%+
P6 --- 今日作业
训练CIFAR-10 CNN > 85%准确率
尝试改进方法
评论区打卡 Day 20/100
标签:#图像分类 #CNN #CIFAR10 #PyTorch #深度学习
CSDN发布提示:CSDN版本建议在模型架构部分放一张网络结构图,在评估部分放混淆矩阵热力图,在训练部分放loss/acc曲线图。