从算法到人工智能 · 第 24 课:深度学习——CNN、RNN 与过拟合
上一课我们用全连接神经网络(MLP)识别了手写数字,准确率 95% 以上。已经不错了,但还没到"深度学习"真正的杀手锏。
这一课讲两个改变世界的网络结构:
- CNN(卷积神经网络):专治图像,让计算机"看懂"图。
- RNN(循环神经网络):专治序列,让计算机"读懂"一句话。
它们解决的问题,正是当年全连接网络的两个硬伤——参数爆炸和无法处理顺序。
一、全连接网络的两个硬伤
上一课的 MLP,每个神经元和上一层的所有神经元相连。假设输入是一张 28×28=784 像素的图:
- 第一层 128 个神经元,就要 784×128 ≈ 10 万个参数。
- 如果是一张 1000×1000 的图(100 万像素)呢?第一层就要 1 亿+ 参数。
参数太多 → 容易过拟合、算不动、还浪费(图像里大部分信息是局部的,不需要全连接)。
更麻烦的是:图像"平移一点",像素就全变了,全连接网络会当成完全不同的输入。但它明明还是同一张图。
CNN 的答案:用"卷积"这种局部、共享的操作,让参数数量暴降,还能天然识别"平移不变"的特征。
二、卷积:用一个小窗口"扫"整张图
直觉:边缘检测
看这张 6×6 的图,中间有一条竖着的亮条(1 是亮,0 是暗):
0 0 1 1 0 0
0 0 1 1 0 0
0 0 1 1 0 0
0 0 1 1 0 0
0 0 1 1 0 0
0 0 1 1 0 0我们用一个 3×3 的卷积核(kernel)去"扫"它:
1 0 -1
1 0 -1
1 0 -1这个核的意思是:左边的像素减右边的像素。如果左右一样,结果就是 0;如果左边亮右边暗,结果是正;反过来是负。
扫出来的结果(卷积输出):
-3 -3 3 3
-3 -3 3 3
-3 -3 3 3
-3 -3 3 3看到没有? 左边一列 -3,右边一列 +3。卷积核自动定位了"竖边缘"的位置——亮条的左边界(负)和右边界(正)。
这就是卷积的威力:一个小窗口,扫出图像的局部特征。 不同的核检测不同的特征(横边、竖边、斜边、角点……)。
核心概念
| 概念 | 含义 |
|---|---|
| 卷积核(kernel/filter) | 一个小的权重矩阵,滑动扫过图像 |
| 步长(stride) | 每次滑几格 |
| 特征图(feature map) | 卷积输出的结果 |
| 参数共享 | 同一个核扫全图,参数只和核一样大 |
参数共享是关键:一个 3×3 的核只有 9 个参数,却能处理整张 1000×1000 的图。这就是 CNN 参数少、还能学到"平移不变特征"的原因。
三、从零手写卷积
不调库,用双重循环实现卷积,你会彻底看懂它在做什么:
import numpy as np
# 6x6 图,中间一条竖亮条
img = np.array([
[0,0,1,1,0,0],
[0,0,1,1,0,0],
[0,0,1,1,0,0],
[0,0,1,1,0,0],
[0,0,1,1,0,0],
[0,0,1,1,0,0],
], dtype=float)
# 3x3 卷积核:左边减右边 → 检测竖直边缘
kernel = np.array([[1,0,-1],[1,0,-1],[1,0,-1]], dtype=float)
H, W = img.shape
k = kernel.shape[0]
out = np.zeros((H-k+1, W-k+1))
for i in range(H-k+1):
for j in range(W-k+1):
# 把卷积核盖在 (i,j),对应位置相乘再求和
out[i, j] = np.sum(img[i:i+k, j:j+k] * kernel)
print("原图:\n", img.astype(int))
print("卷积输出:\n", out.astype(int))
# 左列是 -3,右列是 +3 —— 竖边缘被"点亮"了体会:卷积就是"局部加权求和 + 滑动"。这一行 np.sum(img[i:i+k, j:j+k] * kernel),是 CNN 的心脏。
四、池化:把特征图"压缩"
卷积后特征图还是很大,我们想把它缩小,同时保留最重要的信息。
最大池化(Max Pooling):把图分成 2×2 的小块,每块只取最大值。
fmap = np.array([[1,2,3,4],
[5,6,7,8],
[9,10,11,12],
[13,14,15,16]], dtype=float)
pool = np.zeros((2,2))
for i in range(2):
for j in range(2):
pool[i,j] = np.max(fmap[2*i:2*i+2, 2*j:2*j+2])
print(pool.astype(int))
# [[ 6 8]
# [14 16]]池化的好处:尺寸减半、计算变快,还保留"最显著"的特征。取最大值意味着"不管这个特征在块里的哪个位置,只要出现就保留"——天然抗微小位移。
五、PyTorch 实战:CNN 识别手写数字
手写看完原理,现在用工业级框架 PyTorch 搭一个真正的 CNN,识别 MNIST 手写数字。
import torch
import torch.nn as nn
import torch.nn.functional as F
from torchvision import datasets, transforms
# 1. 下载 MNIST(首次运行会自动下载到本地)
train = datasets.MNIST("./data", train=True, download=True,
transform=transforms.ToTensor())
test = datasets.MNIST("./data", train=False, download=True,
transform=transforms.ToTensor())
# 2. 定义 CNN:卷积 → 池化 → 卷积 → 池化 → 全连接
class CNN(nn.Module):
def __init__(self):
super().__init__()
self.conv1 = nn.Conv2d(1, 8, 3, padding=1) # 8 个 3x3 卷积核
self.conv2 = nn.Conv2d(8, 16, 3, padding=1) # 16 个 3x3 卷积核
self.fc = nn.Linear(16*7*7, 10) # 展平后全连接分类
def forward(self, x):
x = F.relu(F.max_pool2d(self.conv1(x), 2)) # 卷积 + 池化
x = F.relu(F.max_pool2d(self.conv2(x), 2))
x = x.view(x.size(0), -1) # 展平成一维
return self.fc(x)
# 3. 训练
model = CNN()
opt = torch.optim.Adam(model.parameters(), lr=0.003)
loader = torch.utils.data.DataLoader(train, batch_size=64, shuffle=True)
model.train()
for x, y in loader:
opt.zero_grad()
loss = F.cross_entropy(model(x), y) # 交叉熵损失
loss.backward() # 反向传播
opt.step() # 更新参数
# 4. 测试
model.eval()
correct = 0
tloader = torch.utils.data.DataLoader(test, batch_size=256)
with torch.no_grad():
for x, y in tloader:
correct += (model(x).argmax(1) == y).sum().item()
print(f"CNN 识别准确率:{correct/len(test):.1%}") # 通常 98% 以上注意:这段代码用 PyTorch(pip install torch torchvision),首次运行会联网下载 MNIST(约 10MB)。
关键对比:上一课的 MLP 是"全连接 + 直接把 784 像素塞进去";这一课的 CNN 是"先卷积提取特征、再池化压缩、最后才全连接分类"。CNN 让网络先"看局部",再"看全局",所以又准又省参数。
六、RNN:让网络"记住"顺序
CNN 擅长图像,但遇到"一句话"就抓瞎了——因为句子的意思依赖顺序:
- "我不喜欢这部电影" 和 "我喜欢这部电影",词几乎一样,意思完全相反。
- 全连接/CNN 把输入看成"一袋子词",丢了顺序。
RNN(循环神经网络) 的答案:让网络有一个"记忆",逐个处理输入,每一步都带上之前的信息。
核心是一个循环:h_t = tanh(x_t·W_xh + h_{t-1}·W_hh + b)
x_t:当前输入(第 t 个词)h_{t-1}:上一步的记忆h_t:更新后的记忆
关键:h_{t-1} 参与计算 h_t——记忆被一步步传递下去。这就是"循环"。
七、从零手写 RNN 前向传播
import numpy as np
np.random.seed(0)
W_xh = np.random.randn(3, 4) * 0.5 # 输入 → 隐藏
W_hh = np.random.randn(4, 4) * 0.5 # 隐藏 → 隐藏(记忆的传递)
b_h = np.zeros((1, 4))
# 输入序列:3 个词,每个是 3 维的 one-hot 向量
seq = np.array([[1,0,0],[0,1,0],[0,0,1]], dtype=float)
h = np.zeros((1, 4)) # 初始记忆 = 0
for x in seq:
x = x.reshape(1, -1)
# 核心公式:当前输入 + 上一步记忆
h = np.tanh(x @ W_xh + h @ W_hh + b_h)
print(h.round(3)) # 最终记忆(已经把整个序列"读"进去了)体会:循环里 h 每轮都更新,最终 h 里浓缩了整条序列的信息。这就是 RNN"读懂顺序"的机制。
八、LSTM 与 GRU:让记忆传得更远
RNN 的病根:记忆会"遗忘",梯度会消失
朴素 RNN 有个致命问题。回顾它的核心公式:
h_t = tanh(x_t·W_xh + h_{t-1}·W_hh + b)记忆每往前走一步,都要乘一次 W_hh,再套一层 tanh。
- 如果
|W_hh| < 1,连乘很多步后,早期信息被"稀释"到接近 0 → 记忆传不远。 - 反向传播时,梯度也要连乘同样的因子,同样会消失(或爆炸)→ 梯度消失/爆炸。
结果:朴素 RNN 只能记住"最近几步"。一句话说长了,开头的信息就丢了。
例:「我在法国长大……(中间隔 50 个词)……所以我能说一口流利的____。」朴素 RNN 很难把开头的"法国"和结尾的"____"关联起来——因为"法国"的信息在传递中早就衰减没了。
LSTM 的解法:加一条"传送带",用"门"控制记忆
LSTM(Long Short-Term Memory,长短期记忆,1997) 的核心,是引入一条独立的细胞状态(cell state)C_t——它像传送带一样,把长期记忆一路原样传下去,再用三个"门"决定记忆的增、删、读。
三个"门"都是 0~1 之间的数(0 = 全关,1 = 全开):
- 遗忘门
f_t:旧的记忆C_{t-1}里,哪些该忘掉。 - 输入门
i_t:当前的新信息,哪些该写进记忆。 - 输出门
o_t:记忆里的哪部分,输出给h_t(去影响下一步 / 预测)。
import numpy as np
def sigmoid(x):
return 1 / (1 + np.exp(-x))
np.random.seed(0)
d = 4 # 隐藏维度
# 输入 x(3维) 和上一隐藏 h(4维) 拼成 7 维 -> 输出 4*d = 16 维
W = np.random.randn(7, 4 * d) * 0.5
b = np.zeros(4 * d)
x = np.array([1.0, 0.0, 0.0]) # 当前词
h = np.zeros(d) # 上一隐藏
C = np.zeros(d) # 细胞状态(长期记忆)
z = np.concatenate([x, h]) @ W + b # 7 -> 16
f = sigmoid(z[0:d]) # 遗忘门
i = sigmoid(z[d:2*d]) # 输入门
o = sigmoid(z[2*d:3*d]) # 输出门
g = np.tanh(z[3*d:4*d]) # 候选记忆
C = f * C + i * g # 更新长期记忆:忘掉一些,写入一些
h = o * np.tanh(C) # 输出:把记忆的哪部分"说"出来
print("遗忘门 f:", f.round(2))
print("更新后的细胞状态 C:", C.round(2))
print("输出 h:", h.round(2))体会:关键在 C = f * C + i * g 这一行。因为 f 可以等于 1(全保留),C 就能几乎无损地一路传下去——这就是 LSTM 能记住"几十步前"信息的秘密。它用"门"这一招,把 RNN 的"每步强制遗忘"改成了"选择性遗忘"。
GRU:LSTM 的简化版
GRU(Gated Recurrent Unit,门控循环单元,2014) 把 LSTM 的三个门简化成两个(更新门、重置门),并且去掉了独立的细胞状态 C,直接用隐藏状态 h 承担记忆。参数更少、训练更快,效果常与 LSTM 相当。
| 朴素 RNN | LSTM | GRU | |
|---|---|---|---|
| 门的数量 | 0 | 3(遗忘/输入/输出) | 2(更新/重置) |
| 长期记忆载体 | 隐藏状态 h | 独立细胞状态 C | 隐藏状态 h |
| 记忆传多远 | 短(梯度消失) | 远 | 远 |
| 参数/速度 | 最少/最快 | 最多/最慢 | 居中 |
| 适用 | 教学演示 | 经典、稳定 | 工程常用 |
记住:LSTM/GRU 用"门"解决了 RNN 记忆传不远的病,让模型能真正"读懂长句子"。但即便是 LSTM,仍是顺序地一步步读,无法并行——这正是第 25 课 Transformer 要解决的下一道难题。
九、CNN vs RNN 对比总结
| CNN | RNN | |
|---|---|---|
| 擅长 | 图像(空间结构) | 序列(时间/顺序结构) |
| 核心 | 卷积(局部+共享) | 循环(记忆传递) |
| 典型任务 | 图像分类、人脸识别 | 文本、语音、时序预测 |
| 参数效率 | 极高(核共享) | 较高(权重共享) |
十、GAN:生成对抗网络——让机器"创造"
前面学的 CNN、RNN 都是判别式模型:给输入,输出一个"判断"(这是猫、这句话是正向情感)。但还有一大类模型是生成式的——目标是"造出"新数据:画一张不存在的人脸、把模糊照片变清晰。
最有名的生成模型就是 GAN(Generative Adversarial Network,生成对抗网络),2014 年由 Ian Goodfellow 提出。
核心思想:两个网络互相"较劲"
GAN 由两个网络组成,像一对"造假者"和"鉴定师":
| 角色 | 名字 | 任务 |
|---|---|---|
| 生成器 Generator (G) | 造假者 | 从随机噪声"造"假数据,目标是骗过鉴别器 |
| 鉴别器 Discriminator (D) | 鉴定师 | 判断输入是"真数据"还是"假货" |
它们对抗着训练:
- 鉴别器先学"分辨真假"——给它真图片和假图片,学会打标签。
- 生成器学"造假"——不断调整,让自己造的假图越来越逼真,直到鉴别器分不出来。
一句话:D 想变强,G 也想变强,两者互相逼着进步。最终生成器造出来的东西,连鉴定师都分不出真假。
# GAN 训练核心(概念示意,非完整代码)
for epoch in range(epochs):
# 1) 训练鉴别器:真图打 1,假图打 0
real = real_images[batch] # 真数据
noise = torch.randn(batch, latent_dim) # 随机噪声
fake = generator(noise) # 生成器造假
d_loss = loss(discriminator(real), 1) + loss(discriminator(fake), 0)
# 2) 训练生成器:希望鉴别器把假图也判成 1(真)
noise = torch.randn(batch, latent_dim)
g_loss = loss(discriminator(generator(noise)), 1)一个直觉:学生和老师
把生成器想象成"伪造名画的学生",鉴别器是"鉴定师"。
- 一开始学生画得很烂,鉴定师一眼看穿。
- 学生被看穿后改进画技,鉴定师也得提升眼力。
- 如此循环,学生越画越像,鉴定师越练越精。
最终,学生(生成器)出师——能画出以假乱真的画。
用在哪儿
| 应用 | 说明 |
|---|---|
| 图像生成 | 生成逼真的人脸、风景(如 StyleGAN) |
| 图像修复/超分辨率 | 把低清图变高清、补全残缺 |
| 风格迁移 | 把照片变成油画/漫画风 |
| 数据增强 | 造出更多训练样本,缓解过拟合 |
GAN 的痛点(面试常问)
GAN 虽然强,但训练极不稳定——两个网络对抗,很容易"一方碾压另一方",导致训练崩溃(模式崩塌:生成器只会造少数几种图,失去多样性)。这也是为什么后来出现 WGAN、CycleGAN 等改进版。
记住:GAN = 生成器 + 鉴别器,两个网络对抗训练;生成器造假、鉴别器辨真,最终生成器学会"创造"。
十一、过拟合:深度网络最该防的坑
上一课结尾预告过"过拟合、正则化",这里兑现。
什么是过拟合(overfitting)
过拟合 = 模型把训练集"背"下来了,换新数据就露馅。
典型症状:训练集准确率 99%,测试集只有 85%——差距越大,过拟合越严重。反过来,如果训练集本身准确率就上不去,那是欠拟合(underfitting):模型太简单,连训练集都学不会。
| 现象 | 训练集 | 测试集 | 原因 |
|---|---|---|---|
| 欠拟合 | 低 | 低 | 模型太简单 / 没学够 |
| 过拟合 | 高 | 明显更低 | 模型太复杂 / 背题了 |
为什么深度网络特别容易过拟合:CNN/Transformer 动辄上亿参数,记忆能力极强,训练数据不够多时,它干脆把答案背下来,而不是学到"规律"。
四个主流解法
① L2 正则化(权重衰减):在损失里加一项"惩罚大权重",逼模型用更小的权重、更平滑的边界。
损失 = 原始损失 + λ · Σ(权重²)λ 越大,惩罚越狠,模型越"保守"。直觉:大权重意味着"某个特征被极端放大",容易过拟合;L2 逼它克制。
② Dropout(随机失活):训练时,每轮随机关掉一部分神经元(比如 50%),逼网络"别只依赖某几个神经元",学会冗余、抗干扰。测试时再全部打开。
# PyTorch 一行:训练时随机丢弃 50% 神经元
x = torch.nn.functional.dropout(x, p=0.5, training=True)直觉:像考试时随机撕掉半本笔记,逼你不靠"死记"、靠"理解"。
③ 早停(Early Stopping):训练时盯着验证集 loss,一旦它开始回升(过拟合开始),立刻停。别等训练集 loss 降到 0 才停——那时早就背题了。
④ 数据增强(Data Augmentation):数据不够,就"造"数据——图片随机翻转、旋转、裁剪、加噪。模型见过更多变体,就不容易死记某一张图。这是图像领域防过拟合最有效的手段。
一个"作弊式"的总结
- 欠拟合 → 加大模型、多训练
- 过拟合 → 加数据(最治本)、加正则(L2/Dropout/早停)
记住 Dropout 和早停这两个名字,面试问"怎么防过拟合",先答"加数据 + 数据增强",再答"Dropout、L2、早停"。
十二、数据划分与交叉验证:正确评估模型
防过拟合之外,还要会正确地评估模型。评估最忌讳一件事:拿"考过的题"打分——那样只测出模型背题多好,不是真实水平。
三件套:训练集 / 验证集 / 测试集
把数据分成三份,各司其职:
| 数据集 | 作用 | 说明 |
|---|---|---|
| 训练集(train) | 喂给模型学参数 | 占比最大,约 60%~80% |
| 验证集(validation) | 调超参数、选模型 | 训练时观察 loss、做早停 |
| 测试集(test) | 最终打分 | 模型"从没见过",只用一次 |
铁律:测试集绝不能用来训练,也不能反复拿来调参——否则它就变成了"泄露答案"的验证集,分数会虚高,失去意义。
交叉验证(Cross Validation):数据少时更稳
数据太少时,简单切一次三份,结果可能"看运气"——某次切分恰好容易,分数就虚高。k 折交叉验证(k-fold CV)解决这个问题:
把数据平均分成 k 份,轮流拿 1 份当验证集、其余 k-1 份当训练集,训练 k 次,取 k 次得分的平均。
# k 折交叉验证(概念示意)
from sklearn.model_selection import cross_val_score
scores = cross_val_score(model, X, y, cv=5) # 5 折,训练 5 次
print(scores.mean()) # 平均分,更可信k 常取 5 或 10:折数越多越稳,但越慢。
一句话记住:训练集学、验证集调、测试集考;数据少就用交叉验证,平均多次才可靠。
十三、复杂度与要点
| 概念 | 说明 |
|---|---|
| 卷积 | O(核大小 × 输出尺寸),参数只和核大小有关 |
| 池化 | O(特征图尺寸),纯压缩、无参数 |
| RNN | 每步 O(hidden²),序列多长算多久(顺序依赖,无法并行) |
| 反向传播 | 仍是链式法则,框架自动算梯度 |
十四、动手时间 🎯
实验 1:手写卷积找边缘
把第三节代码跑一遍,然后换个卷积核再跑:
# 检测"横向边缘"的核:上边减下边
kernel = np.array([[1,1,1],[0,0,0],[-1,-1,-1]], dtype=float)看输出有什么变化?再试试把亮条换成横的,观察哪个核能点亮它。
实验 2:PyTorch CNN 跑 MNIST
装好 torch 和 torchvision 后,把第五节的完整代码跑通,看准确率。它应该比上一课 MLP 的 95% 以上略高,而且参数更少。
实验 3(挑战):RNN 做序列预测
把第七节的 RNN 加上反向传播,训练它记住一个简单序列。这是进阶内容,能跑通说明你真的懂了"循环"。
十五、小结
- CNN = 卷积 + 池化:卷积用小窗口扫图提取局部特征(参数共享),池化压缩特征图,专治图像。
- RNN = 带记忆的网络:
h_t = f(x_t, h_{t-1}),记忆一步步传递,专治序列。 - 它们的共同点:仍然是"模型 + 损失 + 梯度下降",只是把网络结构设计得更贴合数据的本质(图像的空间、句子的顺序)。