第 2 章:神经网络如何学习 —— 梯度下降算法¶
场景: 第 1 章我们搭好了一个神经网络,但它的权重是随机的,预测结果毫无意义。现在的问题是:如何让网络自动找到最优的权重?答案就是 梯度下降 ——一种让机器"从错误中学习"的数学方法。
2.1 学习 = 最小化错误¶
损失函数:量化"有多错"¶
在教网络学习之前,我们需要一个衡量标准—— 损失函数 (Loss Function)。它回答一个问题: 当前网络的预测和正确答案差多远?
import numpy as np
# 假设网络预测某张图片是各数字的概率
prediction = np.array([0.05, 0.02, 0.03, 0.10, 0.05, 0.60, 0.05, 0.03, 0.02, 0.05])
# 正确答案是数字 5(索引从0开始)
true_label = 5
# 交叉熵损失:最常用的分类损失函数
loss = -np.log(prediction[true_label])
print(f"预测数字5的概率: {prediction[true_label]:.2f}")
print(f"损失值: {loss:.4f}")
渲染效果:
- 如果预测概率是 0.99 ,损失 ≈ 0.01 (几乎完美)
- 如果预测概率是 0.01 ,损失 ≈ 4.6 (错得离谱)
损失函数的直觉
损失函数就像考试分数——分数越低越好。网络的目标就是找到一组权重,让损失函数的值尽可能小。
2.2 梯度下降的核心直觉:盲人下山¶
核心比喻:盲人下山
想象你是一个盲人,站在一座山上,目标是走到山谷的最低点。你看不见整个地形,但你可以:
- 用脚感受脚下的坡度( 计算梯度 )
- 朝最陡的下坡方向迈一小步( 更新位置 )
- 重复以上过程,直到感觉脚下是平的( 到达最低点 )
这就是梯度下降! 梯度 = 最陡的上坡方向, 负梯度 = 最陡的下坡方向。
一维情况:从抛物线开始¶
先看最简单的例子——找到 \(f(x) = x^2\) 的最小值:
import numpy as np
import matplotlib.pyplot as plt
def f(x):
return x ** 2
def gradient(x):
"""$f(x) = x^2$ 的导数是 $f'(x) = 2x$"""
return 2 * x
# 梯度下降
x = 3.0 # 起始位置(随便选)
learning_rate = 0.1 # 学习率(步长)
history = [x] # 记录路径
for step in range(20):
grad = gradient(x) # 计算当前位置的梯度
x = x - learning_rate * grad # 沿负梯度方向移动
history.append(x)
if step % 5 == 0:
print(f"步骤 {step:2d}: x = {x:.4f}, f(x) = {f(x):.6f}, 梯度 = {grad:.4f}")
# 可视化
x_vals = np.linspace(-4, 4, 100)
plt.figure(figsize=(10, 4))
plt.plot(x_vals, f(x_vals), 'b-', label=r'$f(x) = x^2$')
plt.scatter(history, [f(h) for h in history], c='red', s=50, zorder=5)
plt.plot(history, [f(h) for h in history], 'r--', alpha=0.5)
plt.xlabel('x')
plt.ylabel('f(x)')
plt.title('梯度下降:从 x=3 走向 x=0')
plt.legend()
plt.grid(True, alpha=0.3)
plt.show()
渲染效果:
步骤 0: x = 2.4000, f(x) = 5.760000, 梯度 = 6.0000
步骤 5: x = 0.7864, f(x) = 0.618475, 梯度 = 1.5729
步骤 10: x = 0.2577, f(x) = 0.066414, 梯度 = 0.5154
步骤 15: x = 0.0845, f(x) = 0.007133, 梯度 = 0.1689
红色点从 \(x=3\) 开始,一步步滑向最低点 \(x=0\)。梯度(斜率)越来越小,步长也越来越小。
2.3 学习率:步长的艺术¶
学习率 \(\eta\)(learning rate)控制每次更新的步长。这是神经网络训练中 最重要的超参数 。
def gradient_descent_demo(start_x, learning_rate, steps=10):
x = start_x
history = [x]
for _ in range(steps):
grad = 2 * x
x = x - learning_rate * grad
history.append(x)
return history
# 三种学习率对比
rates = [0.01, 0.1, 1.01]
histories = {}
for lr in rates:
histories[lr] = gradient_descent_demo(3.0, lr, steps=15)
# 打印对比
for lr, hist in histories.items():
final_x = hist[-1]
print(f"学习率 {lr:.2f}: 起始 x=3.0 → 最终 x={final_x:.4f}, f(x)={final_x**2:.6f}")
渲染效果:
学习率 0.01: 起始 x=3.0 → 最终 x=2.2166, f(x)=4.913163 ← 太慢!
学习率 0.10: 起始 x=3.0 → 最终 x=0.1059, f(x)=0.011217 ← 刚好
学习率 1.01: 起始 x=3.0 → 最终 x=3.0000, f(x)=9.000000 ← 发散!
学习率三定律
| 学习率 | 效果 | 比喻 |
|---|---|---|
| 太小(如 0.001) | 收敛极慢,可能永远到不了 | 蚂蚁下山,走一辈子 |
| 适中(如 0.01~0.1) | 稳定收敛 | 正常步伐下山 |
| 太大(如 1.0+) | 震荡甚至发散 | 巨人一步跨过山谷,跳到对面山上 |
2.4 多维梯度下降:真正的神经网络¶
神经网络有成千上万个参数(权重和偏置),每个参数都需要更新。多维梯度下降的核心思想不变—— 对每个参数求偏导数,沿负梯度方向更新 。
二维可视化¶
def f_2d(x, y):
"""一个碗状的二维函数"""
return x**2 + y**2
def gradient_2d(x, y):
"""偏导数: $\frac{\partial f}{\partial x} = 2x$, $\frac{\partial f}{\partial y} = 2y$"""
return np.array([2*x, 2*y])
# 二维梯度下降
point = np.array([3.0, 4.0]) # 起始点
lr = 0.1
path_2d = [point.copy()]
for _ in range(30):
grad = gradient_2d(point[0], point[1])
point = point - lr * grad
path_2d.append(point.copy())
path_2d = np.array(path_2d)
print(f"起始点: (3.0, 4.0), f = {f_2d(3, 4)}")
print(f"最终点: ({path_2d[-1][0]:.4f}, {path_2d[-1][1]:.4f}), f = {f_2d(*path_2d[-1]):.6f}")
渲染效果:
无论从哪个方向出发,梯度下降都会把你带到碗底 \((0, 0)\)。
2.5 用梯度下降训练第 1 章的网络¶
现在我们把梯度下降应用到第 1 章的手写数字识别网络上:
import numpy as np
class TrainableNN:
"""可训练的简单神经网络"""
def __init__(self, input_size=784, hidden_size=128, output_size=10):
self.W1 = np.random.randn(input_size, hidden_size) * 0.01
self.b1 = np.zeros((1, hidden_size))
self.W2 = np.random.randn(hidden_size, output_size) * 0.01
self.b2 = np.zeros((1, output_size))
def sigmoid(self, z):
return 1 / (1 + np.exp(-np.clip(z, -500, 500)))
def softmax(self, z):
exp_z = np.exp(z - np.max(z, axis=1, keepdims=True))
return exp_z / np.sum(exp_z, axis=1, keepdims=True)
def forward(self, X):
self.z1 = np.dot(X, self.W1) + self.b1
self.a1 = self.sigmoid(self.z1)
self.z2 = np.dot(self.a1, self.W2) + self.b2
self.a2 = self.softmax(self.z2)
return self.a2
def compute_loss(self, predictions, y_true):
"""交叉熵损失"""
n_samples = predictions.shape[0]
correct_probs = predictions[np.arange(n_samples), y_true]
return -np.mean(np.log(correct_probs + 1e-8))
def compute_gradients(self, X, y_true):
"""
计算损失函数对每个参数的梯度
这是反向传播的核心——第3章会详细讲解
"""
n_samples = X.shape[0]
# 输出层梯度
dz2 = self.a2.copy()
dz2[np.arange(n_samples), y_true] -= 1
dz2 /= n_samples
dW2 = np.dot(self.a1.T, dz2)
db2 = np.sum(dz2, axis=0, keepdims=True)
# 隐藏层梯度
da1 = np.dot(dz2, self.W2.T)
dz1 = da1 * self.a1 * (1 - self.a1)
dW1 = np.dot(X.T, dz1)
db1 = np.sum(dz1, axis=0, keepdims=True)
return {"W1": dW1, "b1": db1, "W2": dW2, "b2": db2}
def train_step(self, X, y_true, learning_rate=0.1):
"""执行一步梯度下降"""
# 前向传播
predictions = self.forward(X)
loss = self.compute_loss(predictions, y_true)
# 计算梯度
grads = self.compute_gradients(X, y_true)
# 更新所有参数(梯度下降的核心步骤)
self.W1 -= learning_rate * grads["W1"]
self.b1 -= learning_rate * grads["b1"]
self.W2 -= learning_rate * grads["W2"]
self.b2 -= learning_rate * grads["b2"]
return loss
# 模拟训练
np.random.seed(42)
nn = TrainableNN()
# 生成 100 个假样本用于演示
X_fake = np.random.randn(100, 784)
y_fake = np.random.randint(0, 10, 100)
print("开始训练...")
print(f"{'轮次':<6} {'损失':<12}")
print("-" * 20)
for epoch in range(10):
loss = nn.train_step(X_fake, y_fake, learning_rate=0.1)
print(f"{epoch+1:<6} {loss:<12.6f}")
print(f"\n最终损失: {loss:.6f}")
渲染效果:
为什么损失下降很慢?
因为我们用的是随机假数据——输入和标签之间没有任何规律。真正的训练中,当数据有规律可循时,损失会显著下降。这里的 \(\ln(10) \approx 2.3026\) 是随机猜测的基线损失。
2.6 PyTorch 实现:自动求导的梯度下降¶
上面的 NumPy 实现中,我们手动推导并计算了每个参数的梯度。PyTorch 的 自动求导(Autograd) 引擎可以自动完成这一切:
import torch
import torch.nn as nn
import torch.optim as optim
class TrainableNN(nn.Module):
"""与 2.5 节 NumPy 版本相同的可训练网络"""
def __init__(self):
super(TrainableNN, self).__init__()
self.fc1 = nn.Linear(784, 128)
self.sigmoid = nn.Sigmoid()
self.fc2 = nn.Linear(128, 10)
def forward(self, x):
x = self.fc1(x)
x = self.sigmoid(x)
x = self.fc2(x)
return x
torch.manual_seed(42)
model = TrainableNN()
criterion = nn.CrossEntropyLoss()
optimizer = optim.SGD(model.parameters(), lr=0.1)
X_fake = torch.randn(100, 784)
y_fake = torch.randint(0, 10, (100,))
print("开始训练(PyTorch 自动求导)...")
print(f"{'轮次':<6} {'损失':<12}")
print("-" * 20)
for epoch in range(10):
# 1. 前向传播
output = model(X_fake)
loss = criterion(output, y_fake)
# 2. 反向传播(自动计算所有梯度!)
optimizer.zero_grad()
loss.backward()
# 3. 参数更新
optimizer.step()
print(f"{epoch+1:<6} {loss.item():<12.6f}")
print(f"\n最终损失: {loss.item():.6f}")
运行结果:
开始训练(PyTorch 自动求导)...
轮次 损失
--------------------
1 2.302585
2 2.302575
3 2.302565
...
10 2.302525
最终损失: 2.302525
PyTorch 训练循环五步法
对比 NumPy 手动实现,PyTorch 的训练循环极其简洁:
| 步骤 | NumPy(手动) | PyTorch(自动) |
|---|---|---|
| 前向传播 | predictions = nn.forward(X) |
output = model(X) |
| 计算损失 | loss = nn.compute_loss(pred, y) |
loss = criterion(output, y) |
| 梯度清零 | 不需要(每次覆盖) | optimizer.zero_grad() |
| 反向传播 | grads = nn.compute_gradients(X, y) |
loss.backward() |
| 参数更新 | self.W1 -= lr * grads["W1"] |
optimizer.step() |
核心区别:loss.backward() 一行代码替代了第 3-4 章中所有手动梯度推导!
2.7 梯度下降的三种变体¶
| 类型 | 每次更新用的样本数 | 优点 | 缺点 |
|---|---|---|---|
| 批量梯度下降(BGD) | 全部 | 稳定,收敛确定 | 太慢,内存不够 |
| 随机梯度下降(SGD) | 1 个 | 快,能跳出局部最优 | 震荡大,不稳定 |
| 小批量梯度下降(Mini-batch) | 32~256 个 | 平衡速度和稳定性 | 需要调 batch size |
现代深度学习几乎都使用 小批量梯度下降 。
要点总结¶
- 损失函数量化了"预测有多错",学习的目标是最小化损失
- 梯度 = 函数增长最快的方向,负梯度 = 下降最快的方向
- 梯度下降 = 不断沿负梯度方向更新参数,直到到达最低点
- 学习率控制步长:太小收敛慢,太大会发散
- 多维梯度下降对每个参数分别求偏导数,同时更新
- 小批量梯度下降是现代深度学习的标准做法
课后练习¶
-
手动梯度下降 :对 \(f(x) = (x-3)^2 + 2\),从 \(x=10\) 开始,用学习率 0.1 执行 20 步梯度下降,手算前 3 步。
-
学习率实验 :修改上面代码中的
learning_rate,分别尝试 0.001、0.01、0.1、1.0、10.0,观察损失的变化曲线。 -
思考题 :如果损失函数的"地形"有多个山谷(局部最小值),梯度下降能找到全局最小值吗?为什么?
下一章预告: 第 2 章中我们直接用了 compute_gradients 函数,但没有解释梯度是怎么算出来的。第 3 章将揭示神经网络中最重要的算法—— 反向传播 (Backpropagation),它利用链式法则高效计算所有参数的梯度。