第 2 章 1980—1986:反向传播、多层感知机与 Hopfield 网络
学习目标
- 理解多层感知机(MLP)为什么能解决 XOR
- 亲手用链式法则实现反向传播(1986)
- 用 PyTorch 自动微分训练 MLP,掌握 DataLoader 与训练 / 验证 / 测试流程
- 实现 Hopfield 网络(1982),理解内容寻址记忆
2.1 时代背景:连接主义的复兴
第一次寒冬里,神经网络研究几乎停滞。但两股力量在暗处积蓄:
1982 年,物理学家 John Hopfield 提出 Hopfield 网络——一个能存储和回忆模式的循环网络,用物理学家的能量函数证明它一定收敛,让「神经网络能记忆」重新得到学术界的正视。
1986 年,David Rumelhart、Geoffrey Hinton 和 Ronald Williams 发表《Learning representations by back-propagating errors》,把反向传播(backpropagation)系统化。配合 1980 年福岛邦彦提出的 Neocognitron(卷积神经网络的雏形),连接主义(connectionism)正式复兴。
2.2 多层感知机:给感知机加一层
感知机是「输入 → 输出」的单层结构。多层感知机(MLP)在中间加一个或多个隐藏层,每个隐藏层先做线性变换,再经过非线性激活函数:
输入 x → 线性(w₁x+b₁) → 激活 σ → 线性(w₂h+b₂) → 输出关键是中间的非线性激活函数。没有它,两层线性变换叠加仍是线性变换,MLP 就和单层一样弱。有了 sigmoid / tanh / ReLU,MLP 才能表达 XOR 这样的非线性函数。
2.2.1 手工反向传播:XOR 的救赎
反向传播的本质是链式法则:把输出误差沿网络逐层往回传,算出每个权重对误差的「责任」,然后按梯度下降更新。
对 2-2-1 网络(sigmoid 激活 + MSE 损失),更新公式为:
输出层误差 δ₂ = (ŷ - y) · σ'(z₂)
隐藏层误差 δ₁ = (δ₂·w₂ᵀ) · σ'(z₁)完整代码见 examples/ch02_backprop.py,其中没有任何自动微分——每个梯度都是手写的:
def train_step(xb, yb, lr=0.5):
global w1, b1, w2, b2
h, out = forward(xb)
d_out = (out - yb) * out * (1 - out) # 输出层误差
d_h = (d_out @ w2.T) * h * (1 - h) # 隐藏层误差
w2 -= lr * h.T @ d_out
b2 -= lr * d_out.sum(0)
w1 -= lr * xb.T @ d_h
b1 -= lr * d_h.sum(0)运行结果:
训练前 MSE = 0.2518
训练后 MSE = 0.0180
XOR 预测: [0, 1, 1, 0] (期望 [0, 1, 1, 0])第 1 章判定「不可能」的 XOR,加了一层隐藏层后轻松学会。Minsky 的悲观结论被推翻,深度学习的第一块基石就此落下。
2.3 PyTorch 自动微分与 MLP 实战
手写反向传播适合理解原理,但真实项目不会手写。PyTorch 的 自动微分(autograd) 会记录张量的运算图,调用 .backward() 后自动计算所有梯度。
训练一个 MLP 需要完整的数据管线:
Dataset提供样本,DataLoader分批(batch)加载;- 划分训练集 / 验证集 / 测试集——验证集用来调超参,测试集只评估一次;
- 每个 batch:前向 → 算损失 →
loss.backward()→optimizer.step()→ 清梯度; - 每轮结束在验证集上评估,决定是否早停、是否调整学习率。
examples/ch02_mlp.py 在 Fashion-MNIST(10 类服装图片,28×28 灰度)上训练三层 MLP:
class MLP(nn.Module):
def __init__(self):
super().__init__()
self.net = nn.Sequential(
nn.Flatten(),
nn.Linear(28 * 28, 256),
nn.ReLU(),
nn.Linear(256, 128),
nn.ReLU(),
nn.Linear(128, 10),
)
def forward(self, x):
return self.net(x)运行结果:
epoch 1:loss = 0.5841,验证准确率 = 82.08%
epoch 2:loss = 0.3665,验证准确率 = 86.26%
epoch 3:loss = 0.2997,验证准确率 = 88.38%
测试准确率 = 87.57%注意 loss.item() 取的是 Python 标量,loss.backward() 要求 loss 是标量——这是新手最常见的报错来源之一。
2.4 Hopfield 网络:会记忆的循环网络
Hopfield 网络用 Hebbian 规则把二值图案存进权重矩阵:
W = (Σ pᵀp - n·I) / N回忆时,把带噪的图案作为初始状态,反复执行 s ← sign(W·s) 直到稳定。稳定点就是网络「记起」的模式。因为能量函数单调下降,网络保证收敛——这就是它的理论魅力。
examples/ch02_hopfield.py 存入 T、L、X 三个 8×8 图案,然后把每个图案翻转 20% 的像素作为探针:
图案 T:翻转 20% 后回忆,与 T/L/X 的汉明距离 [0, 26, 23],回忆结果 → T(正确)
图案 L:翻转 20% 后回忆,与 T/L/X 的汉明距离 [26, 0, 25],回忆结果 → L(正确)
图案 X:翻转 20% 后回忆,与 T/L/X 的汉明距离 [23, 25, 0],回忆结果 → X(正确)回忆结果与正确图案的汉明距离为 0,说明噪声被完全清除。这就是内容寻址记忆:不需要知道位置,给个大致的样子,就能把完整内容「勾」出来。Hopfield 网络的容量约为存储单元数的 14%(这里是 64 单元、3 图案,在容量内);存得太多会出现虚假吸引子。
2.5 三足鼎立:这条路上还有什么
1980—1986 年还出现了另外两个重要的思想:1980 年 Fukushima 的 Neocognitron(卷积 + 池化的原型,第 3 章的远祖),以及 1985 年 Ackley、Hinton、Sejnowski 的 玻尔兹曼机(用随机神经元和统计力学训练)。它们与反向传播一起,构成了连接主义复兴的三块拼图。前者的故事在第 3 章继续。
完整代码
本章用到的完整示例代码:
examples/ch02_backprop.py
"""第 2 章示例(1986):手工实现反向传播训练 XOR。
一个 2-2-1 网络,隐藏层用 sigmoid,输出层用 sigmoid。
不用任何自动微分库,亲手写出链式法则的两个误差项。
运行方式:
uv run python examples/ch02_backprop.py
"""
import torch
torch.manual_seed(1)
X = torch.tensor([[0.0, 0.0], [0.0, 1.0], [1.0, 0.0], [1.0, 1.0]])
y = torch.tensor([[0.0], [1.0], [1.0], [0.0]])
w1 = torch.randn(2, 2) * 0.5
b1 = torch.zeros(2)
w2 = torch.randn(2, 1) * 0.5
b2 = torch.zeros(1)
def sigmoid(z):
return 1 / (1 + torch.exp(-z))
def forward(x):
h = sigmoid(x @ w1 + b1)
return h, sigmoid(h @ w2 + b2)
def mse():
_, out = forward(X)
return ((out - y) ** 2).mean()
def train_step(xb, yb, lr=0.5):
global w1, b1, w2, b2
h, out = forward(xb)
# 输出层误差:对 sigmoid + MSE 的链式法则
d_out = (out - yb) * out * (1 - out)
# 隐藏层误差:沿 w2 传回,再乘 sigmoid 导数
d_h = (d_out @ w2.T) * h * (1 - h)
w2 -= lr * h.T @ d_out
b2 -= lr * d_out.sum(0)
w1 -= lr * xb.T @ d_h
b1 -= lr * d_h.sum(0)
print(f"训练前 MSE = {mse().item():.4f}")
for _ in range(4000):
train_step(X, y)
print(f"训练后 MSE = {mse().item():.4f}")
_, out = forward(X)
print("XOR 预测:", [round(float(v)) for v in out.squeeze()], "(期望 [0, 1, 1, 0])")examples/ch02_mlp.py
"""第 2 章示例(1986):用 PyTorch 自动微分训练多层感知机。
数据管线:Dataset/DataLoader 分批加载 Fashion-MNIST,
划分训练/验证/测试集,训练完成后报告测试准确率。
运行方式:
uv run python examples/ch02_mlp.py
"""
import torch
from torch import nn
from torch.utils.data import DataLoader, random_split
from torchvision import datasets, transforms
torch.manual_seed(0)
device = "cuda" if torch.cuda.is_available() else "cpu"
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.2860,), (0.3530,)),
])
full_train = datasets.FashionMNIST("data", train=True, download=True, transform=transform)
test = datasets.FashionMNIST("data", train=False, download=True, transform=transform)
train_set, val_set = random_split(
full_train, [55000, 5000], generator=torch.Generator().manual_seed(0)
)
train_loader = DataLoader(train_set, batch_size=256, shuffle=True)
val_loader = DataLoader(val_set, batch_size=1024)
test_loader = DataLoader(test, batch_size=1024)
class MLP(nn.Module):
def __init__(self):
super().__init__()
self.net = nn.Sequential(
nn.Flatten(),
nn.Linear(28 * 28, 256),
nn.ReLU(),
nn.Linear(256, 128),
nn.ReLU(),
nn.Linear(128, 10),
)
def forward(self, x):
return self.net(x)
model = MLP().to(device)
opt = torch.optim.SGD(model.parameters(), lr=0.1, momentum=0.9)
scheduler = torch.optim.lr_scheduler.StepLR(opt, step_size=2, gamma=0.5)
criterion = nn.CrossEntropyLoss()
def evaluate(loader):
model.eval()
correct = total = 0
with torch.no_grad():
for xb, yb in loader:
pred = model(xb.to(device)).argmax(1)
correct += (pred == yb.to(device)).sum().item()
total += len(yb)
return correct / total
for epoch in range(3):
model.train()
total_loss = 0
for xb, yb in train_loader:
xb, yb = xb.to(device), yb.to(device)
opt.zero_grad()
loss = criterion(model(xb), yb)
loss.backward()
opt.step()
total_loss += loss.item() * len(xb)
print(f"epoch {epoch + 1}:loss = {total_loss / len(train_set):.4f},"
f"验证准确率 = {evaluate(val_loader) * 100:.2f}%")
scheduler.step()
print(f"测试准确率 = {evaluate(test_loader) * 100:.2f}%")examples/ch02_hopfield.py
"""第 2 章示例(1982):Hopfield 网络——内容寻址记忆。
把若干二值图案"烧录"到权重矩阵中;给一个被噪声污染的图案,
网络通过迭代更新逐步"回忆起"最接近的已存图案。
运行方式:
uv run python examples/ch02_hopfield.py
"""
import torch
torch.manual_seed(0)
# 三个 8×8 图案:T、L、X(用 ±1 编码)
T = [1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0,
0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0,
0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0]
L = [1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0,
1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0,
1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1]
X = [1, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0,
0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0,
0, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 1]
patterns = [torch.tensor(p, dtype=torch.float32).view(1, 64) * 2 - 1 for p in (T, L, X)]
# Hebbian 学习:W = (Σ pᵀp - n·I)/64
P = torch.cat(patterns)
W = (P.T @ P - 3 * torch.eye(64)) / 64
def recall(probe, max_iters=50):
state = probe.clone()
for _ in range(max_iters):
new = torch.sign(state @ W)
if torch.equal(new, state):
break
state = new
return state
def hamming(a, b):
return int((a != b).sum().item())
for name, p in zip("TLX", patterns):
flip = torch.rand(1, 64) < 0.2 # 20% 像素翻转
probe = torch.where(flip, -p, p)
rec = recall(probe)
dists = [hamming(rec, q) for q in patterns]
best = "TLX"[dists.index(min(dists))]
print(f"图案 {name}:翻转 20% 后回忆,与 T/L/X 的汉明距离 {dists},"
f"回忆结果 → {best}({'正确' if best == name else '错误'})")动手实践
- 运行
examples/ch02_backprop.py,把隐藏层节点数从 2 改成 3 和 1,分别观察训练后 MSE,解释为什么 1 个隐藏节点学不会 XOR。 - 在
examples/ch02_mlp.py中把ReLU换成Sigmoid,训练 3 轮,对比 loss 与验证准确率,解释差异。 - 修改 Hopfield 的图案为字母 A、B、C(自行设计 8×8),验证网络能否正确回忆;然后存入 5 个图案,观察回忆正确率下降。
- 在 MLP 训练循环里故意不调用
optimizer.zero_grad(),运行后解释梯度累积现象。
常见错误
| 错误写法 | 现象 | 原因 |
|---|---|---|
loss = criterion(...) 后不 zero_grad() | loss 不降或乱跳 | 梯度在累积,应在每个 step 前清空 |
loss.backward() 前 loss 不是标量 | RuntimeError: grad can be implicitly created only for scalar outputs | 对向量 loss 要先 .sum() 或 .mean() |
tensor.item() 用在 requires_grad 张量上 | RuntimeError: Can't call numpy() on Tensor that requires grad | 先 detach() 或用 .item()(标量) |
| 手写反向传播时漏乘激活导数 | 不收敛 | 链式法则漏项,σ'(z) 必须出现 |
| Hopfield 用 0/1 编码图案 | 回忆效果差 | 用 ±1 编码更符合 Hebbian 更新与符号阈值 |
| 把测试集反复用来调参 | 测试准确率虚高 | 测试集只能评估一次,调参用验证集 |
章末练习
基础
- 为什么隐藏层必须配非线性激活?两层纯线性层等价于什么?
- 写出 2-2-1 网络中隐藏层误差
δ₁的推导(链式法则)。 - 训练 / 验证 / 测试三个集合各自的用途是什么?
提高
- 用反向传播推导 3 层网络(2-4-2-1)的更新公式,并实现它训练 XOR。
- 在 Hopfield 网络中,为什么
W要减去n·I?试想如果图案彼此高度相似,会发生什么。 - 给 MLP 增加 dropout(第 7 章详细讲),在 Fashion-MNIST 上对比验证集准确率。
挑战
- 实现一个 4 层 MLP(784-512-256-64-10),在 Fashion-MNIST 上训练,画出 train/val 准确率曲线,观察过拟合出现的 epoch。
- 用 Hebbian 规则把 10 个随机图案存入 Hopfield 网络,统计回忆成功率与图案数量的关系。
章末自测
- 反向传播的核心数学工具是?
- A. 矩阵求逆
- B. 链式法则
- C. 傅里叶变换
- D. 蒙特卡洛
- MLP 能解决 XOR 的关键是?
- A. 更多训练数据
- B. 隐藏层 + 非线性激活
- C. 更大的学习率
- D. 更快的 GPU
- PyTorch 中计算梯度的调用是?
- A.
loss.forward() - B.
loss.backward() - C.
optimizer.grad() - D.
loss.compute()
- A.
- 每个训练 step 之前必须?
- A.
optimizer.zero_grad() - B.
optimizer.save() - C.
model.eval() - D. 什么都不用做
- A.
- Hopfield 网络使用什么规则存储模式?
- A. 梯度下降
- B. Hebbian 规则
- C. 遗传算法
- D. 随机搜索
- 测试集的正确用法是?
- A. 反复调参
- B. 只评估一次最终模型
- C. 参与训练
- D. 验证集太小的时候代替
nn.Linear(784, 256)的参数个数是?- A. 784
- B. 256
- C. 784×256+256
- D. 784+256
- Fashion-MNIST 的输入形状是?
- A. (28, 28) 灰度
- B. (28, 28, 3) 彩色
- C. (32, 32)
- D. (10,)
- Hopfield 网络的回忆过程是?
- A. 一次前向
- B. 反复迭代
s ← sign(W·s)直到稳定 - C. 反向传播
- D. 随机采样
- 1986 年把反向传播系统化的论文作者不包括?
- A. Rumelhart
- B. Hinton
- C. Williams
- D. LeCun
