概述
前面已经有了数据读取、预处理和 MLP 模型。第 12 篇把重点放在训练本身:分类模型如何通过 loss、backward 和 optimizer 一步步学会预测。
一个完整训练循环包含:
text
取 batch
|
前向计算 logits
|
计算 loss
|
反向传播
|
优化器更新参数
|
清理梯度
|
记录指标
这些步骤缺一不可。只写模型结构,参数不会自动变好;只计算 loss,不调用 backward() 和 step(),参数也不会更新。
读完本文,你应该能从零写出分类模型训练循环,并理解损失函数、优化器、梯度清理和训练日志各自的作用。
分类任务的数据形状
分类模型最常见的输入输出形状是:
text
features: [batch_size, feature_dim]
labels: [batch_size]
logits: [batch_size, num_classes]
例如二分类:
text
batch_x: [32, 2]
batch_y: [32]
logits: [32, 2]
标签不是 one-hot,而是类别 ID:
text
[0, 1, 1, 0, ...]
并且 dtype 通常是 int64。
损失函数:cross_entropy 为什么常用于分类
分类模型最后输出 logits:
python
logits = model(batch_x)
logits 是未归一化分数,不是概率。训练时通常直接使用:
python
loss = paddle.nn.functional.cross_entropy(logits, batch_y)
交叉熵会衡量模型给真实类别的分数是否足够高。直觉上:
- 真实类别分数越高,loss 越小。
- 真实类别分数越低,loss 越大。
- loss 越大,梯度会推动参数向更正确方向更新。
训练时不要先手动 softmax 再交给 cross_entropy,因为很多交叉熵实现已经融合了 softmax 和 log 计算,数值更稳定。
优化器:参数更新的执行者
自动微分负责算梯度,优化器负责根据梯度更新参数。
常见写法:
python
optimizer = paddle.optimizer.Adam(
learning_rate=0.01,
parameters=model.parameters(),
)
关键参数:
learning_rate:每次参数更新的步幅。parameters:要更新的模型参数。
训练步骤:
python
loss.backward()
optimizer.step()
optimizer.clear_grad()
含义:
| 代码 | 作用 |
|---|---|
loss.backward() |
根据 loss 计算参数梯度 |
optimizer.step() |
根据梯度更新参数 |
optimizer.clear_grad() |
清理梯度,避免累积 |
完整训练脚本
下面给出一个可运行的分类训练脚本。
python
import paddle
import paddle.nn as nn
import paddle.nn.functional as F
class CircleDataset(paddle.io.Dataset):
def __init__(self, num_samples, seed):
super().__init__()
paddle.seed(seed)
self.x = paddle.rand([num_samples, 2], dtype="float32") * 4.0 - 2.0
radius_square = paddle.sum(self.x ** 2, axis=1)
self.y = (radius_square > 1.0).astype("int64")
def __len__(self):
return self.y.shape[0]
def __getitem__(self, index):
return self.x[index], self.y[index]
class Classifier(nn.Layer):
def __init__(self):
super().__init__()
self.net = nn.Sequential(
nn.Linear(2, 32),
nn.ReLU(),
nn.Linear(32, 16),
nn.ReLU(),
nn.Linear(16, 2),
)
def forward(self, x):
return self.net(x)
def accuracy(logits, labels):
pred = paddle.argmax(logits, axis=1)
return paddle.mean((pred == labels).astype("float32"))
def train_one_epoch(model, loader, optimizer):
model.train()
total_loss = 0.0
total_acc = 0.0
count = 0
for batch_x, batch_y in loader:
logits = model(batch_x)
loss = F.cross_entropy(logits, batch_y)
acc = accuracy(logits, batch_y)
loss.backward()
optimizer.step()
optimizer.clear_grad()
total_loss += float(loss.numpy())
total_acc += float(acc.numpy())
count += 1
return total_loss / count, total_acc / count
def evaluate(model, loader):
model.eval()
total_loss = 0.0
total_acc = 0.0
count = 0
with paddle.no_grad():
for batch_x, batch_y in loader:
logits = model(batch_x)
loss = F.cross_entropy(logits, batch_y)
acc = accuracy(logits, batch_y)
total_loss += float(loss.numpy())
total_acc += float(acc.numpy())
count += 1
return total_loss / count, total_acc / count
def main():
paddle.seed(2026)
train_loader = paddle.io.DataLoader(
CircleDataset(2048, seed=2026),
batch_size=64,
shuffle=True,
)
val_loader = paddle.io.DataLoader(
CircleDataset(512, seed=2027),
batch_size=128,
shuffle=False,
)
model = Classifier()
optimizer = paddle.optimizer.Adam(
learning_rate=0.01,
parameters=model.parameters(),
)
for epoch in range(20):
train_loss, train_acc = train_one_epoch(model, train_loader, optimizer)
val_loss, val_acc = evaluate(model, val_loader)
print(
"epoch:", epoch,
"train_loss:", train_loss,
"train_acc:", train_acc,
"val_loss:", val_loss,
"val_acc:", val_acc,
)
if __name__ == "__main__":
main()
训练循环拆解
核心代码:
python
for batch_x, batch_y in loader:
logits = model(batch_x)
loss = F.cross_entropy(logits, batch_y)
loss.backward()
optimizer.step()
optimizer.clear_grad()
每一轮 batch 都会重新构建动态图并计算梯度。optimizer.clear_grad() 很重要,因为 Paddle 的梯度默认会累积。
如果忘记清梯度,下一批样本的梯度会叠加上一批的梯度,训练行为会偏离预期。
train_one_epoch:把训练一轮封装起来
把训练一轮封装成函数的好处:
- 主流程更清楚。
- 训练和验证逻辑分开。
- 后续添加日志、学习率调度、混合精度更方便。
- 单独调试训练逻辑更容易。
函数返回平均 loss 和平均 accuracy:
python
return total_loss / count, total_acc / count
注意:这是 batch 平均。如果最后一个 batch 大小不同,严格样本加权平均会更精确。入门阶段 batch 平均足够直观。
训练日志应该看什么
每个 epoch 打印:
text
train_loss
train_acc
val_loss
val_acc
常见判断:
| 现象 | 可能原因 |
|---|---|
| train_loss 下降,val_acc 上升 | 正常学习 |
| train_loss 不下降 | 学习率、模型、数据或 loss 有问题 |
| train_acc 高,val_acc 低 | 可能过拟合 |
| loss 变 NaN | 学习率过大或数值不稳定 |
| acc 一直接近随机 | 标签、axis、模型输出可能有问题 |
训练日志不是装饰,它是判断模型是否正常学习的第一证据。
优化器选择:SGD 与 Adam
SGD:
python
optimizer = paddle.optimizer.SGD(
learning_rate=0.1,
parameters=model.parameters(),
)
Adam:
python
optimizer = paddle.optimizer.Adam(
learning_rate=0.01,
parameters=model.parameters(),
)
入门建议:
- 小实验优先用 Adam,收敛通常更快。
- 学习优化器原理时尝试 SGD。
- 不同优化器的合适学习率可能差很多。
不要机械地把 Adam 和 SGD 使用同一个学习率。
学习率:最先调的超参数
学习率太小:
text
loss 下降很慢
训练很多轮仍没明显变化
学习率太大:
text
loss 剧烈震荡
甚至出现 NaN
建议尝试:
text
Adam: 0.001、0.01、0.03
SGD: 0.01、0.05、0.1
观察训练曲线,而不是只看最后一个 epoch。
常见错误
错误一:logits 和 labels shape 不匹配
分类常见形状:
text
logits: [N, C]
labels: [N]
如果 labels 是 [N, 1],先检查损失函数要求,必要时 reshape。
错误二:labels dtype 不是 int64
分类标签通常:
python
labels = labels.astype("int64")
错误三:argmax axis 写错
对于 [batch_size, num_classes]:
python
pred = paddle.argmax(logits, axis=1)
错误四:训练阶段用了 no_grad
训练前向和 loss 不能放在 paddle.no_grad() 中,否则无法反向传播。
错误五:验证阶段更新了参数
验证函数中不要调用:
python
loss.backward()
optimizer.step()
调试方法:从单 batch 开始
如果训练不正常,先取一个 batch:
python
batch_x, batch_y = next(iter(train_loader))
logits = model(batch_x)
loss = F.cross_entropy(logits, batch_y)
print(batch_x.shape, batch_x.dtype)
print(batch_y.shape, batch_y.dtype)
print(logits.shape, logits.dtype)
print(loss.shape, loss.dtype)
确认单 batch 正常,再跑完整 epoch。
总结:分类训练是一个明确闭环
分类模型训练的核心闭环是:
text
batch -> logits -> loss -> backward -> step -> clear_grad
每个环节都要正确:
- 数据 shape 和 dtype 正确。
- 模型输出 logits。
- 交叉熵接收 logits 和类别标签。
backward()计算梯度。- 优化器更新参数。
- 清理梯度避免累积。
- 用训练和验证日志判断学习情况。
如果只能记住一句话,那就是:
训练循环不是模板代码,而是模型从错误中更新参数的完整机制。