Day 20:构建第一个真实神经网络 — 图像分类实战

今日目标 :从零搭建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
# 否则模型很难收敛

六、今日作业

  1. 跑通CIFAR-10训练:从零训练CNN,目标准确率 > 85%
  2. 改进模型:尝试至少一个改进方法(加宽网络、换激活函数、调learning rate等),记录准确率变化
  3. 分析错误:运行错误案例分析,找出最容易混淆的类别对
  4. 打卡 :评论区发你的最佳准确率,格式:"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曲线图。

相关推荐
新知图书1 小时前
11.4 基于扣子编程的实现过程(AI 数据质检工作流)
人工智能·agent·ai agent·智能体
YHL2 小时前
🚀 端侧 AI DEEPSEEK-R1-WEBGPU 项目实战(二):封装进度条组件,看懂 React 事件与组件树
人工智能
皮皮狗工坊2 小时前
贡院计划第一弹:DeepSeek Harness考生翻墙抄了答案,还企图隐藏罪证
人工智能
Dawson Zhu2 小时前
Agent自我纠错死循环:从原理剖析到工程化防御体系构建
人工智能·语言模型·架构·aigc
ZJU_统一阿萨姆2 小时前
【算子开发】卷积算子基础实现与优化
人工智能·语言模型
Justin3go2 小时前
DeepSeek Harness 如何做到 99% 缓存命中率(原理详解)
人工智能·开源·agent·deepseek
luckystar513~2 小时前
自己动手写Agent Harness【hooks】:生命周期钩子实现
人工智能
fightcrap2 小时前
DeepSeek Harness:Cordis 插件树与 Agent 主链路
人工智能·后端·程序员
张人玉2 小时前
基于 Python 的桌面端智能识别与可视化系统——生活垃圾分类可视化大屏
python·分类·sqlite·echarts·生活
呆萌很2 小时前
Sigmoid 与 Tanh 激活函数(S 型饱和激活函数)
人工智能·深度学习·机器学习