3182 字
16 分钟
Adam 优化器:让训练快三倍的秘密

用了几天普通的梯度下降(SGD)之后,我发现训练过程有点慢,而且准确率提升到一定程度就卡住了。

老板说:“试试 Adam 优化器。”

结果一试,准确率从 90.79% 直接跳到 96.24%,loss 也降得更快。

虽然 Adam 的实现比 SGD 复杂,但效果确实好得多。今天就来解释一下:什么是优化器,为什么我们需要 Adam。

什么是优化器#

优化器(Optimizer)决定了参数如何更新。

我们之前用的是最简单的梯度下降(SGD):

θ ← θ - lr · ∇L

每次更新都是:参数减去学习率乘以梯度。

这种方法简单直接,但有很多问题:

  1. 学习率难调:太大会震荡,太小会收敛慢
  2. 所有参数用同一个学习率:不同参数的梯度大小可能差很多
  3. 容易卡在局部最优或鞍点:梯度接近零时更新变得很慢

优化器的作用就是改进这个更新规则,让训练更快、更稳定。

NOTE

优化器是深度学习的核心组件之一。一个好的优化器能让训练速度快几倍,准确率提升几个百分点。常见的优化器有 SGD、Momentum、RMSprop、Adam、AdamW 等。

SGD 的问题#

让我们看看 SGD 在 MNIST 上的表现:

model = MinimalClassifier(input_dim=784, hidden_dim=128, output_dim=10,
lr=0.01, use_adam=False)
loss_list = train(model, X_train, y_train, epochs=5, batch_size=64)
test(model, X_test, y_test)

输出:

Epoch 1/5: 100%| 938/938 [00:01<00:00, 648.53it/s, loss=0.9948]
Epoch 2/5: 100%| 938/938 [00:01<00:00, 785.26it/s, loss=0.3770]
Epoch 3/5: 100%| 938/938 [00:01<00:00, 671.51it/s, loss=0.2485]
Epoch 4/5: 100%| 938/938 [00:01<00:00, 533.82it/s, loss=0.1981]
Epoch 5/5: 100%| 938/938 [00:01<00:00, 690.19it/s, loss=0.1713]
Test accuracy: 0.9079

最终结果:

  • 测试准确率:90.79%
  • 最终 loss:0.1713

还不错,但能不能更好?

Adam 优化器#

Adam(Adaptive Moment Estimation)是目前最流行的优化器之一。它结合了两种技术:

  1. Momentum:给梯度加上”惯性”,让更新更稳定
  2. RMSprop:给每个参数自适应的学习率

Adam 的核心思想#

Adam 维护两个额外的变量:

m(一阶矩估计,momentum):
m ← β₁ · m + (1 - β₁) · ∇L

这是梯度的指数移动平均,类似于”速度”。让更新方向更稳定。

v(二阶矩估计,variance):
v ← β₂ · v + (1 - β₂) · (∇L)²

这是梯度平方的指数移动平均,用来估计梯度的方差。梯度大的参数会得到更小的学习率,梯度小的参数会得到更大的学习率。

参数更新(带偏置校正):
m̂ = m / (1 - β₁ᵗ)
v̂ = v / (1 - β₂ᵗ)
θ ← θ - lr · m̂ / (√v̂ + ε)

TIP

偏置校正(bias correction)是因为在训练初期,m 和 v 都是从零开始的,会偏向零。除以 (1 - βᵗ) 可以修正这个偏差。

手写 Adam 优化器#

用 numpy 实现一个简洁的 Adam:

class TinyAdam:
"""
超简洁 Adam:只存 m/v,不做任何面向对象封装花活。
以 (param, grad) 列表作为输入,直接原地更新参数。
"""
def __init__(self, lr=0.001, beta1=0.9, beta2=0.999, eps=1e-8):
self.lr = lr # 学习率
self.beta1 = beta1 # momentum 的衰减系数
self.beta2 = beta2 # RMSprop 的衰减系数
self.eps = eps # 防止除零的小常数
self.t = 0 # 时间步
self.m = {} # 一阶矩(momentum)
self.v = {} # 二阶矩(variance)
def step(self, params_and_grads):
"""
参数:
params_and_grads: [(param, grad), ...] 列表
"""
self.t += 1
for param, grad in params_and_grads:
key = id(param) # 用参数的内存地址作为 key
# 获取或初始化 m 和 v
m = self.m.get(key, np.zeros_like(param))
v = self.v.get(key, np.zeros_like(param))
# 更新 m 和 v
m = self.beta1 * m + (1 - self.beta1) * grad
v = self.beta2 * v + (1 - self.beta2) * (grad ** 2)
# 偏置校正
m_hat = m / (1 - self.beta1 ** self.t)
v_hat = v / (1 - self.beta2 ** self.t)
# 参数更新(原地修改)
param -= self.lr * m_hat / (np.sqrt(v_hat) + self.eps)
# 保存更新后的 m 和 v
self.m[key] = m
self.v[key] = v

这个实现非常简洁,核心就是三步:

  1. 更新 m 和 v
  2. 偏置校正
  3. 用校正后的 m 和 v 更新参数
IMPORTANT

id(param) 作为 key 是因为我们需要为每个参数维护独立的 m 和 v。id() 返回对象的内存地址,保证唯一性。

集成到模型里#

修改模型的 __init__step 方法:

class MinimalClassifier:
def __init__(self, input_dim, hidden_dim, output_dim, lr=0.01, use_adam=False):
self.lr = lr
self.use_adam = use_adam
self.adam = TinyAdam(lr=lr) if use_adam else None
self.W1 = np.random.randn(input_dim, hidden_dim) * 0.01
self.b1 = np.zeros((1, hidden_dim))
self.W2 = np.random.randn(hidden_dim, output_dim) * 0.01
self.b2 = np.zeros((1, output_dim))
# ... forward 和 backward 不变 ...
def step(self):
if self.use_adam:
self.adam.step([
(self.W2, self.dW2),
(self.b2, self.db2),
(self.W1, self.dW1),
(self.b1, self.db1),
])
else:
# 普通 SGD
self.W2 -= self.lr * self.dW2
self.b2 -= self.lr * self.db2
self.W1 -= self.lr * self.dW1
self.b1 -= self.lr * self.db1

现在可以通过 use_adam=True 来切换优化器。

对比实验:SGD vs Adam#

SGD(第一次运行)#

model = MinimalClassifier(input_dim=784, hidden_dim=128, output_dim=10,
lr=0.01, use_adam=False)
loss_list = train(model, X_train, y_train, epochs=5, batch_size=64)
test(model, X_test, y_test)

输出:

Epoch 1/5: 100%| 938/938 [00:01<00:00, 648.53it/s, loss=0.9948]
Epoch 2/5: 100%| 938/938 [00:01<00:00, 785.26it/s, loss=0.3770]
Epoch 3/5: 100%| 938/938 [00:01<00:00, 671.51it/s, loss=0.2485]
Epoch 4/5: 100%| 938/938 [00:01<00:00, 533.82it/s, loss=0.1981]
Epoch 5/5: 100%| 938/938 [00:01<00:00, 690.19it/s, loss=0.1713]
Test accuracy: 0.9079

Adam(第二次运行)#

model = MinimalClassifier(input_dim=784, hidden_dim=128, output_dim=10,
lr=0.01, use_adam=True)
loss_list = train(model, X_train, y_train, epochs=5, batch_size=64)
test(model, X_test, y_test)

输出:

Epoch 1/5: 100%| 938/938 [00:02<00:00, 390.04it/s, loss=0.0076]
Epoch 2/5: 100%| 938/938 [00:02<00:00, 461.98it/s, loss=0.0225]
Epoch 3/5: 100%| 938/938 [00:02<00:00, 438.99it/s, loss=0.0439]
Epoch 4/5: 100%| 938/938 [00:02<00:00, 419.07it/s, loss=0.0011]
Epoch 5/5: 100%| 938/938 [00:01<00:00, 474.81it/s, loss=0.0007]
Test accuracy: 0.9624

结果对比#

优化器最终 Loss测试准确率提升
SGD0.171390.79%-
Adam0.000796.24%+5.45%

差距惊人:

  • Loss 降低了 244 倍(0.1713 → 0.0007)
  • 准确率提升了 5.45 个百分点(90.79% → 96.24%)

NOTE

训练时间略有增加(每个 epoch 从 1 秒增加到 2 秒),因为 Adam 需要额外的计算(维护 m 和 v)。但第一个epoch就成功收敛到0.007(巧合),考虑到准确率的提升,这点时间完全值得。

为什么 Adam 这么好用#

Adam 好用的原因:

1. 自适应学习率#

不同参数的梯度大小可能差很多。Adam 给每个参数自适应的学习率:

  • 梯度大的参数 → 学习率变小(防止震荡)
  • 梯度小的参数 → 学习率变大(加速收敛)

2. Momentum 加速#

Momentum 让更新方向更稳定,避免在谷底来回震荡。想象一个球滚下山坡,即使遇到小坑也会凭借惯性继续向前。

3. 对超参数不敏感#

Adam 的默认参数(lr=0.001, β₁=0.9, β₂=0.999)在大多数情况下都能工作得很好。不像 SGD,需要精心调整学习率。

4. 适合稀疏梯度#

对于稀疏梯度(很多元素是零),Adam 也能工作得很好。这在自然语言处理等任务中很常见。

Adam 的超参数#

Adam 有几个超参数:

lr(学习率)

  • 默认:0.001
  • 通常不需要调整,但可以尝试 0.0001 到 0.01 之间

β₁(momentum 衰减系数)

  • 默认:0.9
  • 控制 momentum 的”记忆长度”
  • 通常不需要改

β₂(RMSprop 衰减系数)

  • 默认:0.999
  • 控制梯度方差的”记忆长度”
  • 通常不需要改

ε(防止除零)

  • 默认:1e-8
  • 纯粹为了数值稳定性
  • 几乎不需要改
TIP

99% 的情况下,直接用默认参数就行。只有在训练不收敛或者过拟合严重时,才需要调整学习率。

可视化 Loss 曲线对比#

把 SGD 和 Adam 的 loss 曲线画在一起:

import matplotlib.pyplot as plt
# 假设已经有了两个 loss_list
plt.figure(figsize=(8, 5))
plt.plot(loss_list_sgd, label="SGD", alpha=0.7)
plt.plot(loss_list_adam, label="Adam", alpha=0.7)
plt.xlabel("Iteration")
plt.ylabel("Loss (CE)")
plt.yscale("log")
plt.title("SGD vs Adam on MNIST")
plt.legend()
plt.grid(True)
plt.tight_layout()
plt.show()

Adam 的曲线会明显更陡,下降得更快、更稳定。

Adam 的局限性#

虽然 Adam 很好用,但它也有局限性:

1. 可能过拟合

  • Adam 收敛快,但有时会过拟合训练集
  • 解决方法:加 L2 正则化或 Dropout

2. 泛化性能不一定最优

  • 有研究表明,SGD + Momentum 在某些任务上泛化性能更好
  • Adam 容易找到”尖锐”的最优点,泛化性差

3. 内存占用大

  • 需要为每个参数维护 m 和 v,内存占用是 SGD 的 3 倍

4. 需要调整学习率

  • 虽然比 SGD 好调,但仍然需要调整
  • 有些任务可能需要学习率衰减(learning rate decay)
WARNING

近年来出现了 AdamW(Adam + Weight Decay),在很多任务上表现更好。如果 Adam 效果不理想,可以试试 AdamW。

完整代码#

把 Adam 优化器和训练代码整合在一起:

import numpy as np
import tqdm
import matplotlib.pyplot as plt
import os
import gzip
# ============================================================
# Basic ops (same style as before)
# ============================================================
def relu(x):
return np.maximum(0, x)
def relu_grad(x):
return (x > 0).astype(float)
def flatten(x):
batch_size = x.shape[0]
return x.reshape(batch_size, -1)
def softmax(x):
x_max = np.max(x, axis=1, keepdims=True)
exp_x = np.exp(x - x_max)
return exp_x / np.sum(exp_x, axis=1, keepdims=True)
def cross_entropy(y_pred, y_true):
eps = 1e-15
y_pred = np.clip(y_pred, eps, 1 - eps)
ce = -np.sum(y_true * np.log(y_pred), axis=1)
return np.mean(ce)
def softmax_cross_entropy_grad(y_pred, y_true):
return (y_pred - y_true) / y_true.shape[0]
# ============================================================
# Minimal MLP Classifier
# ============================================================
class MinimalClassifier:
def __init__(self, input_dim, hidden_dim, output_dim, lr=0.01, use_adam=False):
self.lr = lr
self.use_adam = use_adam
self.adam = TinyAdam(lr=lr) if use_adam else None
self.W1 = np.random.randn(input_dim, hidden_dim) * 0.01
self.b1 = np.zeros((1, hidden_dim))
self.W2 = np.random.randn(hidden_dim, output_dim) * 0.01
self.b2 = np.zeros((1, output_dim))
def forward(self, X):
self.X = flatten(X)
self.z1 = self.X @ self.W1 + self.b1
self.a1 = relu(self.z1)
self.z2 = self.a1 @ self.W2 + self.b2
self.y_pred = softmax(self.z2)
return self.y_pred
def backward(self, y_true):
dL_dz2 = softmax_cross_entropy_grad(self.y_pred, y_true)
self.dW2 = self.a1.T @ dL_dz2
self.db2 = np.sum(dL_dz2, axis=0, keepdims=True)
dL_da1 = dL_dz2 @ self.W2.T
dL_dz1 = dL_da1 * relu_grad(self.z1)
self.dW1 = self.X.T @ dL_dz1
self.db1 = np.sum(dL_dz1, axis=0, keepdims=True)
def step(self):
if self.use_adam:
self.adam.step([
(self.W2, self.dW2),
(self.b2, self.db2),
(self.W1, self.dW1),
(self.b1, self.db1),
])
else:
self.W2 -= self.lr * self.dW2
self.b2 -= self.lr * self.db2
self.W1 -= self.lr * self.dW1
self.b1 -= self.lr * self.db1
# ============= 推理 =============
def predict(self, X):
y_pred = self.forward(X)
return np.argmax(y_pred, axis=1)
# ============= 保存 =============
def save(self, path="mnist_model.npz"):
np.savez(
path,
W1=self.W1, b1=self.b1,
W2=self.W2, b2=self.b2
)
print(f"Model saved to {path}")
# ============= 加载 =============
def load(self, path="mnist_model.npz"):
data = np.load(path)
self.W1 = data["W1"]
self.b1 = data["b1"]
self.W2 = data["W2"]
self.b2 = data["b2"]
print(f"Model loaded from {path}")
# ============================================================
# Load MNIST from Kaggle idx files (local)
# ============================================================
def _open_maybe_gz(path):
if os.path.exists(path):
return open(path, "rb")
if os.path.exists(path + ".gz"):
return gzip.open(path + ".gz", "rb")
raise FileNotFoundError(f"Cannot find {path} or {path+'.gz'}")
def load_mnist_from_local(data_dir):
train_images_path = os.path.join(data_dir, "train-images-idx3-ubyte")
train_labels_path = os.path.join(data_dir, "train-labels-idx1-ubyte")
test_images_path = os.path.join(data_dir, "t10k-images-idx3-ubyte")
test_labels_path = os.path.join(data_dir, "t10k-labels-idx1-ubyte")
# images: 16-byte header, then uint8 pixels
with _open_maybe_gz(train_images_path) as f:
data = np.frombuffer(f.read(), dtype=np.uint8, offset=16)
X_train = data.reshape(-1, 28, 28, 1) / 255.0
with _open_maybe_gz(test_images_path) as f:
data = np.frombuffer(f.read(), dtype=np.uint8, offset=16)
X_test = data.reshape(-1, 28, 28, 1) / 255.0
# labels: 8-byte header, then uint8 labels
with _open_maybe_gz(train_labels_path) as f:
labels_train = np.frombuffer(f.read(), dtype=np.uint8, offset=8)
with _open_maybe_gz(test_labels_path) as f:
labels_test = np.frombuffer(f.read(), dtype=np.uint8, offset=8)
y_train = np.zeros((labels_train.size, 10))
y_train[np.arange(labels_train.size), labels_train] = 1
y_test = np.zeros((labels_test.size, 10))
y_test[np.arange(labels_test.size), labels_test] = 1
return X_train, y_train, X_test, y_test
# ============================================================
# Train
# ============================================================
class TinyAdam:
"""
超简洁 Adam:只存 m/v,不做任何面向对象封装花活。
以 (param, grad) 列表作为输入,直接原地更新参数。
"""
def __init__(self, lr=0.001, beta1=0.9, beta2=0.999, eps=1e-8):
self.lr = lr
self.beta1 = beta1
self.beta2 = beta2
self.eps = eps
self.t = 0
self.m = {}
self.v = {}
def step(self, params_and_grads):
self.t += 1
for param, grad in params_and_grads:
key = id(param)
m = self.m.get(key, np.zeros_like(param))
v = self.v.get(key, np.zeros_like(param))
m = self.beta1 * m + (1 - self.beta1) * grad
v = self.beta2 * v + (1 - self.beta2) * (grad ** 2)
# 偏置校正
m_hat = m / (1 - self.beta1 ** self.t)
v_hat = v / (1 - self.beta2 ** self.t)
param -= self.lr * m_hat / (np.sqrt(v_hat) + self.eps)
self.m[key] = m
self.v[key] = v
def train(model, X_train, y_train, epochs, batch_size):
loss_list = []
for epoch in range(epochs):
pbar = tqdm.tqdm(range(0, len(X_train), batch_size),
desc=f"Epoch {epoch+1}/{epochs}")
for i in pbar:
X_batch = X_train[i:i+batch_size]
y_batch = y_train[i:i+batch_size]
y_pred = model.forward(X_batch)
loss = cross_entropy(y_pred, y_batch)
loss_list.append(loss)
model.backward(y_batch)
model.step()
pbar.set_postfix({"loss": f"{loss:.4f}"})
return loss_list
def test(model, X_test, y_test):
# quick test accuracy
y_pred_test = model.forward(X_test)
pred_classes = np.argmax(y_pred_test, axis=1)
true_classes = np.argmax(y_test, axis=1)
acc = (pred_classes == true_classes).mean()
print(f"\nTest accuracy: {acc:.4f}")
def plot_loss(loss_list):
plt.figure(figsize=(6, 4))
plt.plot(loss_list)
plt.xlabel("Iteration")
plt.ylabel("Loss (CE)")
plt.yscale("log")
plt.title("MNIST Training Loss")
plt.grid(True)
plt.tight_layout()
plt.show()
def plot_random_samples(model, X_test, y_test):
indices = random.sample(range(len(X_test)), 10)
plt.figure(figsize=(15, 3))
for i, idx in enumerate(indices):
img = X_test[idx:idx+1]
pred_class = model.predict(img)[0]
true_class = np.argmax(y_test[idx])
plt.subplot(2, 5, i+1)
plt.imshow(img.squeeze(), cmap="gray")
color = "green" if pred_class == true_class else "red"
plt.title(f"Pred: {pred_class}\nTrue: {true_class}", color=color)
plt.axis("off")
plt.tight_layout()
plt.show()
if __name__ == "__main__":
import time
np.random.seed(0)
# TODO: change this to where you unzipped the dataset files
# I obtain the dataset from https://www.kaggle.com/datasets/hojjatk/mnist-dataset?resource=download
# e.g. "/Users/lishuyu/Downloads/mnist"
data_dir = "./mnist"
X_train, y_train, X_test, y_test = load_mnist_from_local(data_dir)
model = MinimalClassifier(input_dim=784, hidden_dim=128, output_dim=10, lr=0.01, use_adam=True)
loss_list = train(model, X_train, y_train, epochs = 5, batch_size = 64)
test(model, X_test, y_test)
plot_loss(loss_list)
# ------------------- Save model -------------------
model.save("mnist_model.npz")
# ------------------- Load model -------------------
model.load("mnist_model.npz")
test(model, X_test, y_test)
# ------------------- Test model -------------------
import random
# 加载模型
model.load("mnist_model.npz")
plot_random_samples(model, X_test, y_test)

小结#

这就是 Adam 优化器的完整实现和对比实验。

核心要点:

  1. Adam 结合了 Momentum 和 RMSprop:既有惯性,又有自适应学习率
  2. 效果显著:准确率从 90.79% 提升到 96.24%
  3. 实现简洁:核心代码只有 30 行左右
  4. 易于使用:默认参数在大多数情况下都能工作

SGD vs Adam 对比:

特性SGDAdam
实现复杂度简单中等
收敛速度
超参数调整困难容易
内存占用
泛化性能中等

对于大多数任务,Adam 是首选。除非你有充足的时间调参,或者发现 Adam 过拟合严重,否则直接用 Adam 就行。

[!quote] “Premature optimization is the root of all evil.”
— Donald Knuth

不要一开始就纠结优化器的选择。先用 Adam 跑通,看看效果。只有在遇到具体问题时,才需要考虑换其他优化器。

for future#

我们观察到模型还是有震荡,这是因为目前模型过小导致无法拟合所有的数据。下一步,增加模型大小/增加CNN层。

上一篇:ML5-给神经网络加上保存和加载功能 下一篇:ML7-用 Numba 加速的纯 numpy CNN 达到 98% 准确率


Works Cited

Goodfellow, Ian, et al. Deep Learning. MIT Press, 2016.

Kingma, Diederik P., and Jimmy Ba. “Adam: A Method for Stochastic Optimization.” Proceedings of the International Conference on Learning Representations, 2015.

Loshchilov, Ilya, and Frank Hutter. “Decoupled Weight Decay Regularization.” Proceedings of the International Conference on Learning Representations, 2019.

Adam 优化器:让训练快三倍的秘密
https://blog.lishuyu.top/posts/ml6-adam优化器让训练快三倍的秘密/
作者
猫猫魔女
发布于
2025-11-20
许可协议
CC BY-NC-SA 4.0