这是「Python 计算机视觉三部曲」的第二篇。 上一篇我们用 OpenCV 学会了“人工设计特征”,这一篇开始进入“让模型自己学习特征”的核心地带。
这篇文章会带你完成一次完整升级:
- 从神经网络基础到 CNN 架构演进(知道为什么这样设计)
- 从 PyTorch 基础到两套可运行实战(知道如何真正训练)
- 从训练到评估与调参(知道如何把效果做上去)
阅读导航
- 想先理解原理主线:优先看
第 1~4 章
- 想快速进入工程实操:重点看
第 5~8 章
- 想提升模型表现:重点看
第 9~11 章
系列导航
- 第一篇:计算机视觉与图像处理入门 — 从像素到特征
- 第二篇:深度学习与计算机视觉 — 从 CNN 到实战(本文)
- 第三篇:目标检测实战 — 从 YOLO 到部署
1. 为什么需要深度学习?
1.1 传统方法的瓶颈
回顾上一篇的特征提取流程:
1
| 原始图像 → 人工设计特征(SIFT/HOG/ORB) → 分类器(SVM/随机森林) → 结果
|
这个流程有一个根本性的问题:特征是人选的。
- SIFT 对旋转和缩放不变,但应对遮挡和形变能力有限
- HOG 适合行人检测,但对纹理丰富的物体效果差
- 没有任何手工特征能通用到所有视觉任务
深度学习的革命在于:让网络自己学习特征。
1
| 原始图像 → 深度神经网络(自动学习特征 + 分类) → 结果
|
1.2 ImageNet 时刻
2012 年,Alex Krizhevsky 用 AlexNet 在 ImageNet 竞赛中将错误率从 26% 降到了 16%——这不是渐进式改良,而是范式转换。从此,深度学习统治了计算机视觉。
| 年份 |
冠军模型 |
Top-5 错误率 |
关键创新 |
| 2011 |
XOR(非深度) |
25.8% |
— |
| 2012 |
AlexNet |
16.4% |
ReLU、Dropout、GPU 训练 |
| 2014 |
VGGNet |
7.3% |
更深更规整 |
| 2014 |
GoogLeNet |
6.7% |
Inception 模块 |
| 2015 |
ResNet |
3.6% |
残差连接 |
| 2017 |
SENet |
2.3% |
通道注意力 |
1.3 深度学习 vs 传统方法的本质区别
| 维度 |
传统方法 |
深度学习 |
| 特征设计 |
人工 |
自动学习 |
| 数据需求 |
少 |
多 |
| 计算需求 |
低 |
高(需要 GPU) |
| 泛化能力 |
受限于特征设计 |
数据驱动,上限更高 |
| 可解释性 |
高 |
低(黑盒) |
2. 神经网络基础:从零理解
2.1 神经元:最小计算单元
一个神经元做的事极其简单:
1 2
| 输出 = 激活函数(权重 × 输入 + 偏置) 即: y = f(wx + b)
|
用 Python 实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| import numpy as np
def neuron(x, w, b, activation='relu'): """单个神经元的计算""" z = np.dot(w, x) + b if activation == 'relu': return np.maximum(0, z) elif activation == 'sigmoid': return 1 / (1 + np.exp(-z)) elif activation == 'tanh': return np.tanh(z) elif activation == 'none': return z
|
2.2 多层感知机(MLP)
把多个神经元堆叠成层,再把层堆叠成网络:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| import numpy as np
class SimpleMLP: """一个简单的多层感知机""" def __init__(self, layer_sizes): """ layer_sizes: [输入维度, 隐藏层1, 隐藏层2, ..., 输出维度] 例如: [784, 128, 64, 10] 表示 MNIST 分类器 """ self.weights = [] self.biases = [] for i in range(len(layer_sizes) - 1): w = np.random.randn(layer_sizes[i], layer_sizes[i+1]) * np.sqrt(2.0 / layer_sizes[i]) b = np.zeros(layer_sizes[i+1]) self.weights.append(w) self.biases.append(b) def forward(self, x): """前向传播""" for i in range(len(self.weights)): x = x @ self.weights[i] + self.biases[i] if i < len(self.weights) - 1: x = np.maximum(0, x) else: exp_x = np.exp(x - np.max(x)) x = exp_x / exp_x.sum() return x
mlp = SimpleMLP([784, 128, 64, 10]) fake_input = np.random.randn(1, 784) output = mlp.forward(fake_input) print(f"输出形状: {output.shape}") print(f"预测类别: {np.argmax(output)}")
|
2.3 激活函数:为什么需要非线性?
如果没有激活函数,无论多少层线性变换,结果仍然是线性的——多层等于一层。激活函数引入了非线性,让网络能拟合复杂的函数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| import matplotlib.pyplot as plt import numpy as np
x = np.linspace(-5, 5, 200)
fig, axes = plt.subplots(1, 4, figsize=(20, 4))
axes[0].plot(x, np.maximum(0, x)) axes[0].set_title('ReLU: max(0, x)') axes[0].grid(True, alpha=0.3)
axes[1].plot(x, 1 / (1 + np.exp(-x))) axes[1].set_title('Sigmoid: 1/(1+e⁻ˣ)') axes[1].grid(True, alpha=0.3)
axes[2].plot(x, np.tanh(x)) axes[2].set_title('Tanh: (eˣ-e⁻ˣ)/(eˣ+e⁻ˣ)') axes[2].grid(True, alpha=0.3)
from scipy.special import erf axes[3].plot(x, 0.5 * x * (1 + erf(x / np.sqrt(2)))) axes[3].set_title('GELU') axes[3].grid(True, alpha=0.3)
plt.tight_layout() plt.savefig('activation_functions.png', dpi=150, bbox_inches='tight') plt.show()
|
| 激活函数 |
优点 |
缺点 |
常用场景 |
| ReLU |
计算快,缓解梯度消失 |
神经元死亡(负值输出永远为 0) |
CNN 隐藏层(默认选择) |
| Leaky ReLU |
解决神经元死亡 |
需要调 α |
ReLU 效果不好时 |
| Sigmoid |
输出 (0,1),适合概率 |
梯度消失严重 |
二分类输出层 |
| Tanh |
输出 (-1,1),零中心 |
梯度消失 |
RNN |
| GELU |
平滑,性能好 |
计算稍慢 |
Transformer、ViT |
2.4 损失函数:衡量"错多少"
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| def cross_entropy_loss(predictions, targets): """ predictions: 模型输出概率 [N, C] targets: 真实标签 [N](整数) """ N = predictions.shape[0] correct_probs = predictions[np.arange(N), targets] loss = -np.log(correct_probs + 1e-8) return loss.mean()
def mse_loss(predictions, targets): return np.mean((predictions - targets) ** 2)
|
2.5 反向传播:怎么"学"?
反向传播就是用链式法则计算损失函数对每个参数的梯度,然后用梯度下降更新参数。
1 2
| 参数 ← 参数 - 学习率 × 梯度 w ← w - lr × ∂Loss/∂w
|
手动实现反向传播非常繁琐(需要计算每层的偏导数),这就是为什么我们用 PyTorch——它自动帮我们算。
3. 卷积神经网络(CNN)详解
3.1 为什么不用全连接网络处理图像?
MNIST 图片 28×28=784 个像素,全连接还能勉强处理。但一张 1080p 图片有 1920×1080×3 = 6,220,800 个值,如果第一层隐藏层有 1024 个神经元,仅这一层就有 63 亿个参数——内存爆炸、计算爆炸、过拟合爆炸。
CNN 的核心洞察:图像具有局部相关性和平移不变性。
- 局部相关性:相邻像素通常相关,不需要每个像素与所有像素相连
- 平移不变性:一个猫在左上角和右下角,特征是一样的
3.2 卷积层:核心操作
卷积层用一个小的卷积核(也叫滤波器/Filter/Kernel)在整张图上滑动,提取局部特征。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| import numpy as np
def conv2d_manual(input_img, kernel, stride=1, padding=0): """手动实现 2D 卷积(仅用于理解原理)""" if padding > 0: input_img = np.pad(input_img, padding, mode='constant') h_in, w_in = input_img.shape h_k, w_k = kernel.shape h_out = (h_in - h_k) // stride + 1 w_out = (w_in - w_k) // stride + 1 output = np.zeros((h_out, w_out)) for i in range(h_out): for j in range(w_out): region = input_img[i*stride:i*stride+h_k, j*stride:j*stride+w_k] output[i, j] = np.sum(region * kernel) return output
img = np.array([ [0, 0, 0, 0, 0], [0, 1, 1, 1, 0], [0, 1, 1, 1, 0], [0, 1, 1, 1, 0], [0, 0, 0, 0, 0] ], dtype=np.float32)
kernel = np.array([ [-1, -1, -1], [ 0, 0, 0], [ 1, 1, 1] ], dtype=np.float32)
result = conv2d_manual(img, kernel) print("卷积结果:\n", result)
|
3.3 卷积的三个关键超参数
| 参数 |
含义 |
影响 |
| Kernel Size |
卷积核大小 |
3×3 最常用,5×5/7×7 用在第一层 |
| Stride |
滑动步长 |
1=密集扫描,2=输出缩小一半 |
| Padding |
边缘填充 |
“same”=输出大小不变,“valid”=不填充 |
输出尺寸公式:
1
| 输出尺寸 = floor((输入尺寸 - 核大小 + 2×填充) / 步长) + 1
|
3.4 多通道卷积
彩色图片有 3 个通道,卷积核也需要 3 个通道。一个 3×3 卷积核作用于 RGB 图像时,实际大小是 3×3×3,输出是 1 个通道。要输出 64 个通道,就需要 64 个这样的卷积核。
1 2 3
| 输入: (H, W, 3) 64 个卷积核: 每个是 (3, 3, 3) 输出: (H', W', 64)
|
3.5 池化层:降维与不变性
池化层没有参数,只做固定操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| def max_pool2d(input_img, pool_size=2, stride=2): """手动实现最大池化""" h_in, w_in = input_img.shape h_out = (h_in - pool_size) // stride + 1 w_out = (w_in - pool_size) // stride + 1 output = np.zeros((h_out, w_out)) for i in range(h_out): for j in range(w_out): region = input_img[i*stride:i*stride+pool_size, j*stride:j*stride+pool_size] output[i, j] = np.max(region) return output
img = np.array([ [1, 3, 2, 4], [5, 6, 8, 7], [3, 2, 1, 0], [1, 2, 3, 4] ], dtype=np.float32)
print("最大池化结果:\n", max_pool2d(img))
|
- 最大池化:取区域最大值,保留最显著特征
- 平均池化:取区域平均值,更平滑
3.6 完整的 CNN 结构
一个典型的 CNN 由三类层交替堆叠而成:
1
| [卷积 → ReLU → 池化] × N → 展平 → [全连接 → ReLU] × M → 全连接(输出)
|
浅层学简单特征(边缘、纹理),深层学复杂特征(形状、语义):
1 2 3 4
| Layer 1: 边缘、角点 Layer 2: 纹理、简单图案 Layer 3: 物体部件(轮子、眼睛) Layer 4: 完整物体概念
|
4. 经典 CNN 架构演进
4.1 AlexNet(2012)— 开山之作
1 2 3 4 5 6
| Conv(11×11, 96, stride=4) → ReLU → MaxPool Conv(5×5, 256) → ReLU → MaxPool Conv(3×3, 384) → ReLU Conv(3×3, 384) → ReLU Conv(3×3, 256) → ReLU → MaxPool FC(4096) → FC(4096) → FC(1000)
|
关键创新:ReLU(替代 Sigmoid)、Dropout、GPU 训练、数据增强。
4.2 VGGNet(2014)— 简洁的力量
VGG 的核心思想:用连续的 3×3 卷积替代大核卷积。
两个 3×3 卷积的感受野 = 一个 5×5 卷积,但参数更少、非线性更强。
1 2 3 4 5 6
| [Conv3-64] ×2 → Pool [Conv3-128] ×2 → Pool [Conv3-256] ×3 → Pool [Conv3-512] ×3 → Pool [Conv3-512] ×3 → Pool FC(4096) → FC(4096) → FC(1000)
|
4.3 GoogLeNet / Inception(2014)— 多尺度并行
Inception 模块同时使用 1×1、3×3、5×5 卷积和最大池化,然后把结果拼接在一起:
1 2 3 4
| 输入 → ┬ 1×1 Conv ──────────────┐ ├ 1×1 Conv → 3×3 Conv ──┤ ├ 1×1 Conv → 5×5 Conv ──┤→ Concat → 输出 └ 3×3 MaxPool → 1×1 Conv┘
|
1×1 卷积用于降维,减少计算量。
4.4 ResNet(2015)— 革命性的残差连接
问题:网络越深,训练越难(梯度消失/退化)。
解决方案:残差连接(Skip Connection)。
1 2 3 4
| x → [Conv → ReLU → Conv] → +x → ReLU → 输出 └──── 残差块 F(x) ────┘ 即: 输出 = F(x) + x
|
核心思想:让网络学习残差 F(x) = H(x) - x,而非直接学习 H(x)。如果某层不需要变换,F(x) 只需学成 0,信息就能直接流过去。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| import torch import torch.nn as nn
class BasicBlock(nn.Module): """ResNet 基础残差块""" def __init__(self, in_channels, out_channels, stride=1): super().__init__() self.conv1 = nn.Conv2d(in_channels, out_channels, 3, stride, 1, bias=False) self.bn1 = nn.BatchNorm2d(out_channels) self.conv2 = nn.Conv2d(out_channels, out_channels, 3, 1, 1, bias=False) self.bn2 = nn.BatchNorm2d(out_channels) self.relu = nn.ReLU(inplace=True) self.shortcut = nn.Sequential() if stride != 1 or in_channels != out_channels: self.shortcut = nn.Sequential( nn.Conv2d(in_channels, out_channels, 1, stride, bias=False), nn.BatchNorm2d(out_channels) ) def forward(self, x): identity = self.shortcut(x) out = self.relu(self.bn1(self.conv1(x))) out = self.bn2(self.conv2(out)) out += identity out = self.relu(out) return out
|
ResNet 让训练上百层的网络成为可能,是深度学习史上最伟大的架构创新之一。
5. PyTorch 基础:张量与自动微分
5.1 安装
1 2 3 4 5 6 7 8
| pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118
pip install torch torchvision
python -c "import torch; print(torch.__version__); print(torch.cuda.is_available())"
|
5.2 张量(Tensor)
张量是 PyTorch 的核心数据结构,类似 NumPy 的 ndarray,但可以在 GPU 上计算且支持自动微分。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| import torch
a = torch.tensor([1.0, 2.0, 3.0]) b = torch.zeros(3, 4) c = torch.randn(3, 4)
import numpy as np arr = np.array([1, 2, 3]) t = torch.from_numpy(arr)
arr_back = t.numpy()
if torch.cuda.is_available(): device = torch.device('cuda') a_gpu = a.to(device) print(f"设备: {a_gpu.device}")
x = torch.randn(2, 3, 4) print(x.shape) print(x.view(2, 12)) print(x.reshape(-1, 4)) print(x.permute(0, 2, 1).shape)
|
5.3 自动微分(Autograd)
PyTorch 最强大的特性——自动计算梯度。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| import torch
x = torch.tensor([2.0], requires_grad=True) w = torch.tensor([3.0], requires_grad=True) b = torch.tensor([1.0], requires_grad=True)
y = w * x + b loss = y ** 2
loss.backward()
print(f"dy/dw = {w.grad}") print(f"dy/dx = {x.grad}") print(f"dy/db = {b.grad}")
|
💡 关键概念:只要设置了 requires_grad=True,PyTorch 会追踪所有计算,反向传播时自动求导。这就是"深度学习框架"存在的意义——你只写前向计算,框架帮你算梯度。
5.4 构建神经网络的标准方式
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| import torch import torch.nn as nn
class SimpleNet(nn.Module): def __init__(self): super().__init__() self.flatten = nn.Flatten() self.linear_relu_stack = nn.Sequential( nn.Linear(28*28, 512), nn.ReLU(), nn.Linear(512, 256), nn.ReLU(), nn.Linear(256, 10), ) def forward(self, x): x = self.flatten(x) logits = self.linear_relu_stack(x) return logits
model = SimpleNet() print(model)
total_params = sum(p.numel() for p in model.parameters()) print(f"总参数量: {total_params:,}")
|
6. 实战一:手写数字识别(MNIST)
6.1 数据准备
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| import torch from torch.utils.data import DataLoader from torchvision import datasets, transforms
transform = transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) ])
train_dataset = datasets.MNIST('./data', train=True, download=True, transform=transform) test_dataset = datasets.MNIST('./data', train=False, transform=transform)
train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True) test_loader = DataLoader(test_dataset, batch_size=1000, shuffle=False)
print(f"训练集大小: {len(train_dataset)}") print(f"测试集大小: {len(test_dataset)}")
import matplotlib.pyplot as plt img, label = train_dataset[0] plt.imshow(img.squeeze(), cmap='gray') plt.title(f"标签: {label}") plt.axis('off') plt.show()
|
6.2 构建 CNN 模型
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| import torch import torch.nn as nn import torch.nn.functional as F
class MNISTNet(nn.Module): """轻量级 CNN,专为 MNIST 设计""" def __init__(self): super().__init__() self.conv1 = nn.Conv2d(1, 32, 3, 1) self.conv2 = nn.Conv2d(32, 64, 3, 1) self.dropout1 = nn.Dropout(0.25) self.dropout2 = nn.Dropout(0.5) self.fc1 = nn.Linear(9216, 128) self.fc2 = nn.Linear(128, 10) def forward(self, x): x = F.relu(self.conv1(x)) x = F.relu(self.conv2(x)) x = F.max_pool2d(x, 2) x = self.dropout1(x) x = torch.flatten(x, 1) x = F.relu(self.fc1(x)) x = self.dropout2(x) x = self.fc2(x) return x
model = MNISTNet() print(model)
total_params = sum(p.numel() for p in model.parameters()) print(f"参数量: {total_params:,}")
|
6.3 训练
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63
| import torch import torch.nn as nn import torch.optim as optim from tqdm import tqdm
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = MNISTNet().to(device)
criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=0.001)
def train(model, device, train_loader, optimizer, criterion, epoch): model.train() train_loss = 0 correct = 0 total = 0 for batch_idx, (data, target) in enumerate(tqdm(train_loader, desc=f'Epoch {epoch}')): data, target = data.to(device), target.to(device) optimizer.zero_grad() output = model(data) loss = criterion(output, target) loss.backward() optimizer.step() train_loss += loss.item() _, predicted = output.max(1) total += target.size(0) correct += predicted.eq(target).sum().item() acc = 100. * correct / total avg_loss = train_loss / len(train_loader) print(f'训练损失: {avg_loss:.4f} | 训练准确率: {acc:.2f}%') return avg_loss, acc
def test(model, device, test_loader, criterion): model.eval() test_loss = 0 correct = 0 with torch.no_grad(): for data, target in test_loader: data, target = data.to(device), target.to(device) output = model(data) test_loss += criterion(output, target).item() pred = output.argmax(dim=1) correct += pred.eq(target).sum().item() test_loss /= len(test_loader) acc = 100. * correct / len(test_loader.dataset) print(f'测试损失: {test_loss:.4f} | 测试准确率: {acc:.2f}%\n') return test_loss, acc
num_epochs = 10 for epoch in range(1, num_epochs + 1): train(model, device, train_loader, optimizer, criterion, epoch) test(model, device, test_loader, criterion)
torch.save(model.state_dict(), 'mnist_cnn.pth') print("模型已保存!")
|
🎯 预期结果:5 个 epoch 后测试准确率就能达到 99%+。MNIST 相对简单,但这是理解整个深度学习流程的最佳起点。
6.4 可视化预测结果
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| import torch import matplotlib.pyplot as plt import numpy as np
model.eval() fig, axes = plt.subplots(2, 5, figsize=(15, 6))
with torch.no_grad(): data, target = next(iter(test_loader)) data, target = data.to(device), target.to(device) output = model(data) pred = output.argmax(dim=1) for i, ax in enumerate(axes.flat): img = data[i].cpu().squeeze() ax.imshow(img, cmap='gray') color = 'green' if pred[i] == target[i] else 'red' ax.set_title(f'预测: {pred[i].item()} / 真实: {target[i].item()}', color=color) ax.axis('off')
plt.tight_layout() plt.show()
|
7. 实战二:猫狗分类器
7.1 数据准备
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| import os from torchvision import datasets, transforms from torch.utils.data import DataLoader, random_split
train_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(), transforms.RandomRotation(10), transforms.ColorJitter( brightness=0.2, contrast=0.2, saturation=0.2, hue=0.1 ), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ])
val_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ])
full_dataset = datasets.ImageFolder('data/cats_and_dogs', transform=train_transform)
train_size = int(0.8 * len(full_dataset)) val_size = len(full_dataset) - train_size train_dataset, val_dataset = random_split(full_dataset, [train_size, val_size])
val_dataset.dataset = datasets.ImageFolder('data/cats_and_dogs', transform=val_transform)
train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=4)
print(f"类别: {full_dataset.classes}") print(f"训练集: {len(train_dataset)} 张") print(f"验证集: {len(val_dataset)} 张")
|
7.2 自定义 CNN
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
| import torch import torch.nn as nn
class CatDogCNN(nn.Module): def __init__(self): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.Conv2d(32, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2, 2), nn.Dropout2d(0.25), nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.Conv2d(64, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2, 2), nn.Dropout2d(0.25), nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.Conv2d(128, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(2, 2), nn.Dropout2d(0.25), ) self.classifier = nn.Sequential( nn.Linear(128 * 28 * 28, 512), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(512, 2), ) def forward(self, x): x = self.features(x) x = x.view(x.size(0), -1) x = self.classifier(x) return x
model = CatDogCNN() total_params = sum(p.numel() for p in model.parameters()) print(f"参数量: {total_params:,}")
|
7.3 训练猫狗分类器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69
| import torch import torch.nn as nn import torch.optim as optim from tqdm import tqdm import copy
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = CatDogCNN().to(device)
criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.parameters(), lr=0.001) scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, 'min', patience=3, factor=0.5)
best_acc = 0.0 best_model_wts = copy.deepcopy(model.state_dict())
num_epochs = 20
for epoch in range(num_epochs): model.train() running_loss = 0.0 running_corrects = 0 for inputs, labels in tqdm(train_loader, desc=f'Epoch {epoch+1}/{num_epochs}'): inputs, labels = inputs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() * inputs.size(0) running_corrects += (outputs.argmax(1) == labels).sum().item() epoch_loss = running_loss / len(train_dataset) epoch_acc = running_corrects / len(train_dataset) model.eval() val_loss = 0.0 val_corrects = 0 with torch.no_grad(): for inputs, labels in val_loader: inputs, labels = inputs.to(device), labels.to(device) outputs = model(inputs) loss = criterion(outputs, labels) val_loss += loss.item() * inputs.size(0) val_corrects += (outputs.argmax(1) == labels).sum().item() val_loss /= len(val_dataset) val_acc = val_corrects / len(val_dataset) scheduler.step(val_loss) print(f'训练 - Loss: {epoch_loss:.4f}, Acc: {epoch_acc:.4f} | ' f'验证 - Loss: {val_loss:.4f}, Acc: {val_acc:.4f}') if val_acc > best_acc: best_acc = val_acc best_model_wts = copy.deepcopy(model.state_dict()) torch.save(model.state_dict(), 'catdog_best.pth') print(f' → 新最佳模型! Acc: {best_acc:.4f}')
model.load_state_dict(best_model_wts) print(f'\n训练完成! 最佳验证准确率: {best_acc:.4f}')
|
🎯 预期结果:自定义 CNN 在猫狗分类上大约能达到 75-85% 的准确率。但要超过 90%,通常需要迁移学习。
8. 迁移学习:站在巨人的肩膀上
8.1 核心思想
不要从零开始训练!在 ImageNet 上预训练的模型已经学会了丰富的特征(边缘、纹理、形状),这些知识可以迁移到新任务上。
1 2
| 方式一:特征提取 — 冻结预训练模型,只训练最后的分类层 方式二:微调 — 解冻部分或全部层,用较小的学习率微调
|
8.2 使用 ResNet50 迁移学习
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| import torch import torch.nn as nn import torchvision.models as models
model_ft = models.resnet50(weights=models.ResNet50_Weights.DEFAULT)
for param in model_ft.parameters(): param.requires_grad = False
num_features = model_ft.fc.in_features model_ft.fc = nn.Linear(num_features, 2)
optimizer = torch.optim.Adam(model_ft.fc.parameters(), lr=0.001)
|
8.3 迁移学习训练
1 2 3 4 5 6 7 8 9 10 11
| device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model_ft = model_ft.to(device)
criterion = nn.CrossEntropyLoss()
|
8.4 迁移学习的三种策略
| 策略 |
数据量 |
数据与 ImageNet 相似度 |
操作 |
| 特征提取 |
少 |
高 |
冻结所有卷积层,只训练分类层 |
| 微调顶层 |
少 |
低 |
冻结底层,微调顶层+分类层 |
| 全量微调 |
多 |
低 |
小学习率微调全部层 |
💡 经验法则:数据少 → 多冻结;数据多 → 少冻结。学习率预训练层要比新层小 10-100 倍。
9. 数据增强:让数据翻倍的艺术
数据增强是在不改变语义的前提下,对训练图像进行随机变换,有效增加数据量、防止过拟合。
9.1 常用增强方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| from torchvision import transforms
basic_aug = transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(degrees=15), transforms.ColorJitter(brightness=0.2, contrast=0.2), ])
advanced_aug = transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomCrop(224), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomVerticalFlip(p=0.1), transforms.RandomRotation(degrees=15), transforms.RandomAffine(degrees=0, translate=(0.1, 0.1)), transforms.RandomAffine(degrees=0, scale=(0.9, 1.1)), transforms.ColorJitter(brightness=0.3, contrast=0.3, saturation=0.3, hue=0.1), transforms.RandomGrayscale(p=0.1), transforms.GaussianBlur(kernel_size=3), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), transforms.RandomErasing(p=0.5, scale=(0.02, 0.2)), ])
|
9.2 Mixup 实现
Mixup 是一种强大的正则化方法:将两张图片按比例混合,标签也按相同比例混合。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| import torch import numpy as np
def mixup_data(x, y, alpha=0.2): """Mixup 数据增强""" lam = np.random.beta(alpha, alpha) if alpha > 0 else 1.0 batch_size = x.size(0) index = torch.randperm(batch_size).to(x.device) mixed_x = lam * x + (1 - lam) * x[index] y_a, y_b = y, y[index] return mixed_x, y_a, y_b, lam
def mixup_criterion(criterion, pred, y_a, y_b, lam): """Mixup 损失函数""" return lam * criterion(pred, y_a) + (1 - lam) * criterion(pred, y_b)
|
9.3 增强效果对比
| 方法 |
猫狗分类准确率 |
ImageNet Top-1 |
| 无增强 |
78% |
— |
| 基础增强 |
85% |
— |
| 基础 + Mixup |
89% |
+1.5% |
| 全套增强 |
92% |
+2-3% |
10. 训练技巧与调参指南
10.1 学习率调度器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| import torch.optim as optim
optimizer = optim.Adam(model.parameters(), lr=0.001)
scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=7, gamma=0.1)
scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=50)
scheduler = optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode='min', patience=3, factor=0.5 )
scheduler = optim.lr_scheduler.OneCycleLR( optimizer, max_lr=0.01, epochs=20, steps_per_epoch=len(train_loader) )
|
10.2 优化器选择
| 优化器 |
优点 |
缺点 |
推荐场景 |
| SGD + Momentum |
泛化好 |
需要精调学习率 |
大规模训练、追求最佳精度 |
| Adam |
快速收敛、不需精调 |
可能过拟合 |
快速原型、迁移学习 |
| AdamW |
Adam + 正确的权重衰减 |
— |
Transformer、大模型 |
| SGD + Momentum + Cosine |
最佳精度 |
训练慢 |
比赛、论文 |
10.3 防过拟合的 5 招
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| nn.Dropout(0.5)
optimizer = optim.Adam(model.parameters(), lr=0.001, weight_decay=1e-4)
patience = 5 best_val_loss = float('inf') counter = 0
for epoch in range(100): if val_loss < best_val_loss: best_val_loss = val_loss counter = 0 torch.save(model.state_dict(), 'best.pth') else: counter += 1 if counter >= patience: print(f"早停于 Epoch {epoch+1}") break
nn.BatchNorm2d(64)
|
10.4 超参数速查表
| 超参数 |
推荐起始值 |
搜索范围 |
| 学习率 |
1e-3 (Adam) / 1e-1 (SGD) |
[1e-5, 1e-1] |
| Batch Size |
32 / 64 |
16~256(受 GPU 内存限制) |
| Dropout |
0.5 |
[0.2, 0.7] |
| 权重衰减 |
1e-4 |
[1e-6, 1e-2] |
| Epoch 数 |
50~100 |
配合早停 |
11. 模型评估与可视化
11.1 混淆矩阵
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| from sklearn.metrics import confusion_matrix, classification_report import seaborn as sns import matplotlib.pyplot as plt import numpy as np
model.eval() all_preds = [] all_labels = []
with torch.no_grad(): for inputs, labels in val_loader: inputs = inputs.to(device) outputs = model(inputs) preds = outputs.argmax(1).cpu().numpy() all_preds.extend(preds) all_labels.extend(labels.numpy())
cm = confusion_matrix(all_labels, all_preds) plt.figure(figsize=(8, 6)) sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', xticklabels=['Cat', 'Dog'], yticklabels=['Cat', 'Dog']) plt.xlabel('预测') plt.ylabel('真实') plt.title('混淆矩阵') plt.show()
print(classification_report(all_labels, all_preds, target_names=['Cat', 'Dog']))
|
11.2 Grad-CAM:看模型在看什么
Grad-CAM(Gradient-weighted Class Activation Mapping)能可视化模型做决策时关注图像的哪个区域。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57
| import torch import torch.nn.functional as F import numpy as np import cv2
class GradCAM: """Grad-CAM 可视化""" def __init__(self, model, target_layer): self.model = model self.target_layer = target_layer self.gradients = None self.activations = None target_layer.register_forward_hook(self._save_activation) target_layer.register_backward_hook(self._save_gradient) def _save_activation(self, module, input, output): self.activations = output def _save_gradient(self, module, grad_input, grad_output): self.gradients = grad_output[0] def generate(self, input_image, target_class=None): output = self.model(input_image) if target_class is None: target_class = output.argmax(1).item() self.model.zero_grad() output[0, target_class].backward() weights = self.gradients.mean(dim=[2, 3], keepdim=True) cam = (weights * self.activations).sum(dim=1, keepdim=True) cam = F.relu(cam) cam = cam.squeeze().cpu().numpy() cam = (cam - cam.min()) / (cam.max() - cam.min() + 1e-8) cam = cv2.resize(cam, (input_image.shape[2], input_image.shape[3])) return cam
|
11.3 特征图可视化
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| import matplotlib.pyplot as plt
def visualize_feature_maps(model, input_image, layer_name='conv1', num_features=16): """可视化卷积层的特征图""" activation = {} def get_activation(name): def hook(model, input, output): activation[name] = output.detach() return hook for name, layer in model.named_modules(): if name == layer_name: layer.register_forward_hook(get_activation(name)) model(input_image) features = activation[layer_name].squeeze().cpu() fig, axes = plt.subplots(4, 4, figsize=(12, 12)) for i, ax in enumerate(axes.flat): if i < min(num_features, features.shape[0]): ax.imshow(features[i], cmap='viridis') ax.set_title(f'Filter {i}') ax.axis('off') plt.suptitle(f'{layer_name} 特征图') plt.tight_layout() plt.show()
|
12. 总结与下一步
我们学到了什么?
| 模块 |
核心概念 |
关键工具 |
| 神经网络基础 |
神经元、激活函数、损失函数、反向传播 |
NumPy 手动实现 |
| CNN |
卷积、池化、特征层次化 |
PyTorch nn.Conv2d |
| 经典架构 |
AlexNet → VGG → Inception → ResNet |
torchvision.models |
| PyTorch |
张量、自动微分、nn.Module |
torch.autograd |
| 实战 |
MNIST、猫狗分类 |
torchvision.datasets |
| 迁移学习 |
特征提取、微调策略 |
预训练 ResNet50 |
| 数据增强 |
翻转、旋转、颜色抖动、Mixup |
torchvision.transforms |
| 训练技巧 |
学习率调度、正则化、早停 |
lr_scheduler |
| 评估可视化 |
混淆矩阵、Grad-CAM |
sklearn.metrics |
从分类到检测
目前为止,我们只做了一件事:图像分类 — 给模型一张图,它告诉你"这是什么"。
但现实世界中,一张图里通常有多个物体,我们不仅要知道它们是什么,还要知道它们在哪里。这就是目标检测的任务。
在下一篇文章中,我们将:
- 理解目标检测的核心概念:Anchor、IoU、NMS
- 深入 YOLO 系列算法的设计思想
- 用 YOLOv8 训练自定义目标检测模型
- 将模型部署到实际应用中
下篇预告:《目标检测实战:从 YOLO 到部署》
我们将深入探讨:
- 目标检测基础:Anchor、IoU、NMS、mAP
- YOLO 系列演进:v1 → v5 → v8 → v11
- 实战:用 YOLOv8 训练自定义检测器
- 模型导出与部署:ONNX、TensorRT、Web 应用
本文是「Python 计算机视觉三部曲」的第二篇。如果觉得有帮助,欢迎关注后续更新。