这是「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):
# He 初始化(适合 ReLU)
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: # 隐藏层用 ReLU
x = np.maximum(0, x)
else: # 输出层用 Softmax
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}") # (1, 10)
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))

# ReLU
axes[0].plot(x, np.maximum(0, x))
axes[0].set_title('ReLU: max(0, x)')
axes[0].grid(True, alpha=0.3)

# Sigmoid
axes[1].plot(x, 1 / (1 + np.exp(-x)))
axes[1].set_title('Sigmoid: 1/(1+e⁻ˣ)')
axes[1].grid(True, alpha=0.3)

# Tanh
axes[2].plot(x, np.tanh(x))
axes[2].set_title('Tanh: (eˣ-e⁻ˣ)/(eˣ+e⁻ˣ)')
axes[2].grid(True, alpha=0.3)

# GELU(Transformer 中常用)
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]
# 避免log(0)
loss = -np.log(correct_probs + 1e-8)
return loss.mean()

# MSE 损失(回归任务)
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 卷积(仅用于理解原理)"""
# 添加 padding
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))
# [[6. 8.]
# [3. 4.]]
  • 最大池化:取区域最大值,保留最显著特征
  • 平均池化:取区域平均值,更平滑

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)

# 如果输入输出维度不同,需要 1×1 卷积调整
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
# CUDA 11.8(NVIDIA GPU)
pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118

# CPU 版本
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) # 标准正态分布

# 从 NumPy 转换
import numpy as np
arr = np.array([1, 2, 3])
t = torch.from_numpy(arr)

# 从张量到 NumPy
arr_back = t.numpy()

# GPU 操作
if torch.cuda.is_available():
device = torch.device('cuda')
a_gpu = a.to(device)
print(f"设备: {a_gpu.device}") # cuda:0

# 张量形状操作
x = torch.randn(2, 3, 4)
print(x.shape) # torch.Size([2, 3, 4])
print(x.view(2, 12)) # reshape
print(x.reshape(-1, 4)) # 自动推断维度
print(x.permute(0, 2, 1).shape) # 交换维度 → [2, 4, 3]

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 # y = 3*2 + 1 = 7
loss = y ** 2 # loss = 49

# 反向传播(自动计算梯度)
loss.backward()

print(f"dy/dw = {w.grad}") # 2*y*x = 2*7*2 = 28
print(f"dy/dx = {x.grad}") # 2*y*w = 2*7*3 = 42
print(f"dy/db = {b.grad}") # 2*y*1 = 14

💡 关键概念:只要设置了 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(), # 转为张量 [0,1]
transforms.Normalize((0.1307,), (0.3081,)) # 标准化(MNIST 均值和标准差)
])

# 下载并加载数据
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)}") # 60000
print(f"测试集大小: {len(test_dataset)}") # 10000

# 查看一个样本
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) # 1通道→32通道, 3×3卷积
self.conv2 = nn.Conv2d(32, 64, 3, 1) # 32通道→64通道
self.dropout1 = nn.Dropout(0.25)
self.dropout2 = nn.Dropout(0.5)
# 全连接层
self.fc1 = nn.Linear(9216, 128) # 64*12*12 = 9216
self.fc2 = nn.Linear(128, 10)

def forward(self, x):
# x: [B, 1, 28, 28]
x = F.relu(self.conv1(x)) # [B, 32, 26, 26]
x = F.relu(self.conv2(x)) # [B, 64, 24, 24]
x = F.max_pool2d(x, 2) # [B, 64, 12, 12]
x = self.dropout1(x)
x = torch.flatten(x, 1) # [B, 9216]
x = F.relu(self.fc1(x)) # [B, 128]
x = self.dropout2(x)
x = self.fc2(x) # [B, 10]
return x

model = MNISTNet()
print(model)

# 计算参数量
total_params = sum(p.numel() for p in model.parameters())
print(f"参数量: {total_params:,}") # 约 1.2M

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() # 1. 清零梯度
output = model(data) # 2. 前向传播
loss = criterion(output, target) # 3. 计算损失
loss.backward() # 4. 反向传播
optimizer.step() # 5. 更新参数

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

# 训练 10 个 epoch
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), # 随机旋转 ±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]) # ImageNet 标准化
])

val_transform = transforms.Compose([
transforms.Resize((224, 224)),
transforms.ToTensor(),
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
])

# 假设数据目录结构如下:
# data/cats_and_dogs/
# ├── cat/
# │ ├── cat.1.jpg
# │ └── ...
# └── dog/
# ├── dog.1.jpg
# └── ...

# 使用 ImageFolder 自动按子文件夹分类
full_dataset = datasets.ImageFolder('data/cats_and_dogs', transform=train_transform)

# 划分训练集和验证集(80:20)
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])

# 验证集使用不同的 transform
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}") # ['cat', 'dog']
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(
# Block 1
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), # 224→112
nn.Dropout2d(0.25),

# Block 2
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), # 112→56
nn.Dropout2d(0.25),

# Block 3
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), # 56→28
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:,}") # 约 50M

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

# 加载预训练 ResNet50
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 # 2048
model_ft.fc = nn.Linear(num_features, 2) # 猫狗二分类

# 只训练最后一层
optimizer = torch.optim.Adam(model_ft.fc.parameters(), lr=0.001)

# 方式二:微调(解冻后面的层)
# for param in model_ft.layer4.parameters():
# param.requires_grad = True
# optimizer = torch.optim.Adam([
# {'params': model_ft.layer4.parameters(), 'lr': 1e-5},
# {'params': model_ft.fc.parameters(), 'lr': 1e-3}
# ])

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()

# 训练(代码同上,只是换了模型)
# ... 约 5-10 个 epoch 即可达到 95%+ 准确率

# 对比:
# 从零训练 CNN: ~75-85% (20 epoch)
# 迁移学习 ResNet50: ~95-98% (5 epoch)

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), # 50% 概率水平翻转
transforms.RandomRotation(degrees=15), # 随机旋转 ±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), # 10% 概率变灰度
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)),
])

# Cutout / Mixup / CutMix 等高级增强通常需要自定义实现

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)

# 在训练循环中使用:
# for inputs, labels in train_loader:
# inputs, labels_a, labels_b, lam = mixup_data(inputs, labels, alpha=0.2)
# outputs = model(inputs)
# loss = mixup_criterion(criterion, outputs, labels_a, labels_b, lam)

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)

# 方法1:StepLR — 每 step_size 个 epoch 乘以 gamma
scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=7, gamma=0.1)

# 方法2:CosineAnnealingLR — 余弦退火(推荐)
scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=50)

# 方法3:ReduceLROnPlateau — 损失不再下降时降低学习率
scheduler = optim.lr_scheduler.ReduceLROnPlateau(
optimizer, mode='min', patience=3, factor=0.5
)

# 方法4:OneCycleLR — 一个周期策略(超推荐)
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
# 1. Dropout — 随机丢弃神经元
nn.Dropout(0.5)

# 2. 权重衰减(L2 正则化)
optimizer = optim.Adam(model.parameters(), lr=0.001, weight_decay=1e-4)

# 3. 早停(Early Stopping)
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

# 4. 数据增强(见第 9 节)

# 5. Batch Normalization
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

# 使用示例
# model = 加载训练好的模型
# grad_cam = GradCAM(model, model.layer4[-1])
# cam = grad_cam.generate(input_tensor)

# 可视化
# heatmap = cv2.applyColorMap(np.uint8(255 * cam), cv2.COLORMAP_JET)
# heatmap = cv2.cvtColor(heatmap, cv2.COLOR_BGR2RGB)
# superimposed = 0.4 * heatmap + 0.6 * original_image

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 计算机视觉三部曲」的第二篇。如果觉得有帮助,欢迎关注后续更新。