概述
前面一篇文章已经讲过 paddle.nn.Linear、激活函数和 paddle.nn.Sequential。这一篇继续向前走一步:用 paddle.nn 构建并训练第一个多层感知机,也就是常说的 MLP。
很多初学者第一次写 MLP 时,容易只记住几行代码:
python
model = nn.Sequential(
nn.Linear(2, 16),
nn.ReLU(),
nn.Linear(16, 2),
)
但只会写这几行还不够。一个真正可训练的 MLP 至少要串起这些环节:
text
准备数据
|
定义模型
|
前向计算 logits
|
计算损失 loss
|
反向传播 backward
|
优化器更新参数
|
清理梯度
|
评估准确率
本篇文章会围绕一个可运行的二分类任务展开,用随机生成的二维数据训练一个 MLP,让你看清楚模型从输入到输出、从 loss 到参数更新的完整流程。
读完这篇文章,你应该能独立写出一个基于 paddle.nn 的 MLP,并能解释每一层的 shape、每一步训练代码的作用和常见错误的排查方式。
背景:什么是多层感知机
多层感知机(Multilayer Perceptron,MLP)是一类最基础的前馈神经网络。它通常由多层全连接层和非线性激活函数组成。
一个简单 MLP 可以写成:
text
输入特征 x
|
Linear
|
ReLU
|
Linear
|
ReLU
|
Linear
|
输出 logits
如果用公式表示两层隐藏层结构,大致是:
h 1 = R e L U ( x W 1 + b 1 ) h_1 = ReLU(xW_1 + b_1) h1=ReLU(xW1+b1)
h 2 = R e L U ( h 1 W 2 + b 2 ) h_2 = ReLU(h_1W_2 + b_2) h2=ReLU(h1W2+b2)
l o g i t s = h 2 W 3 + b 3 logits = h_2W_3 + b_3 logits=h2W3+b3
这里的 logits 是分类分数,还不是概率。训练分类模型时,通常直接把 logits 交给交叉熵损失函数。
MLP 的适用场景包括:
- 表格特征分类。
- 表格特征回归。
- 简单向量特征建模。
- 作为更大模型中的分类头或回归头。
- 学习神经网络训练流程的入门模型。
MLP 是由 Linear + 激活函数 堆叠出的前馈网络,是理解深度学习训练流程的最好起点之一。
任务设计:用二维点做一个二分类问题
为了专注于 paddle.nn 和 MLP 本身,本文不引入外部数据集,而是构造一个可控的二分类任务。
输入是二维点:
text
x = [x1, x2]
标签规则:
text
如果 x1^2 + x2^2 > 1,则类别为 1
否则类别为 0
这个任务的分类边界是一个圆:
text
类别 0:圆内点
类别 1:圆外点
为什么不用简单的 x1 + x2 > 0?因为那是线性可分问题,一个线性层就可能拟合得很好。圆形边界更适合展示 MLP 的非线性表达能力。
数据流如下:
text
随机生成二维点 [N, 2]
|
根据半径生成标签 [N]
|
MLP 输出 logits [N, 2]
|
CrossEntropyLoss 计算分类损失
这个例子小、直观、可复制,适合用来理解第一个 MLP。
准备代码:导入依赖和设置随机种子
新建文件:
text
first_mlp_with_paddle_nn.py
写入基础依赖:
python
import paddle
import paddle.nn as nn
import paddle.nn.functional as F
paddle.seed(2026)
再准备两个调试函数:
python
def show_tensor(name, tensor):
print(f"\n{name}")
print("-" * 60)
print(tensor)
print("shape:", tensor.shape)
print("dtype:", tensor.dtype)
print("stop_gradient:", tensor.stop_gradient)
def show_model_parameters(model):
print("\nmodel parameters")
print("-" * 60)
total = 0
for name, param in model.named_parameters():
num = param.numel()
total += num
print(name, param.shape, "numel=", num)
print("total parameters:", total)
学习模型时建议养成一个习惯:每写一个新模型,先打印输入 shape、输出 shape 和参数量。
数据构造:生成训练集和验证集
先写一个数据生成函数:
python
def make_circle_data(num_samples):
x = paddle.rand([num_samples, 2], dtype="float32") * 4.0 - 2.0
radius_square = paddle.sum(x ** 2, axis=1)
labels = (radius_square > 1.0).astype("int64")
return x, labels
这段代码做了几件事:
paddle.rand([num_samples, 2])生成[0, 1)的随机数。* 4.0 - 2.0把范围变成[-2, 2)。paddle.sum(x ** 2, axis=1)计算每个点的 x 1 2 + x 2 2 x_1^2 + x_2^2 x12+x22。radius_square > 1.0得到布尔标签。.astype("int64")转成分类标签需要的整数类型。
创建训练集和验证集:
python
train_x, train_y = make_circle_data(1024)
val_x, val_y = make_circle_data(256)
show_tensor("train_x", train_x)
show_tensor("train_y", train_y)
你应该重点观察:
text
train_x shape: [1024, 2]
train_y shape: [1024]
train_x dtype: float32
train_y dtype: int64
这正是分类任务常见输入结构:
text
features: [batch_size, feature_dim]
labels: [batch_size]
模型定义:用 nn.Layer 构建 MLP
先用自定义 nn.Layer 写法构建模型:
python
class MLP(nn.Layer):
def __init__(self, input_dim, hidden_dim, num_classes):
super().__init__()
self.fc1 = nn.Linear(input_dim, hidden_dim)
self.fc2 = nn.Linear(hidden_dim, hidden_dim)
self.fc3 = nn.Linear(hidden_dim, num_classes)
def forward(self, x):
x = self.fc1(x)
x = F.relu(x)
x = self.fc2(x)
x = F.relu(x)
logits = self.fc3(x)
return logits
创建模型:
python
model = MLP(input_dim=2, hidden_dim=16, num_classes=2)
show_model_parameters(model)
模型结构是:
text
输入 [N, 2]
|
fc1: Linear(2, 16)
|
ReLU
|
fc2: Linear(16, 16)
|
ReLU
|
fc3: Linear(16, 2)
|
logits [N, 2]
这里的 num_classes=2 表示二分类输出两个类别分数。
为什么最后一层不加 ReLU?
因为最后一层输出的是分类 logits。logits 可以是任意实数,后续交叉熵损失会处理这些分数。如果最后强行加 ReLU,会限制输出范围,通常不是分类模型的常规做法。
前向验证:先确认模型输出 shape
训练前不要急着写优化器,先跑一次前向:
python
sample_x = train_x[:4]
logits = model(sample_x)
show_tensor("sample_x", sample_x)
show_tensor("logits", logits)
形状应该是:
text
sample_x: [4, 2]
logits: [4, 2]
这表示:
- 输入 4 个样本。
- 每个样本 2 个特征。
- 模型为每个样本输出 2 个类别分数。
如果这里 shape 不对,先不要进入训练循环。优先检查:
python
print(sample_x.shape)
print(model)
以及每个 Linear 的输入输出维度。
损失函数:为什么分类任务使用交叉熵
二分类任务可以有不同写法。本文使用两个类别 logits,因此损失函数使用:
python
loss = F.cross_entropy(logits, labels)
官方 cross_entropy 文档说明,该函数会把 softmax 操作和交叉熵计算合并,以提供更稳定的数值计算。它的输入 input 应该是未缩放的 logits,不应该先对 softmax 输出再计算。
示例:
python
sample_y = train_y[:4]
loss = F.cross_entropy(logits, sample_y)
show_tensor("loss", loss)
这里:
text
logits shape: [4, 2]
sample_y shape: [4]
loss shape: []
loss 是一个 0 维 Tensor,也就是标量 Tensor,可以直接调用:
python
loss.backward()
重要提醒:训练时通常不要写成:
python
prob = F.softmax(logits, axis=1)
loss = F.cross_entropy(prob, labels)
更推荐:
python
loss = F.cross_entropy(logits, labels)
推理展示概率时再使用 softmax。
优化器:用 Adam 更新模型参数
优化器负责根据梯度更新参数。本文使用 paddle.optimizer.Adam:
python
optimizer = paddle.optimizer.Adam(
learning_rate=0.01,
parameters=model.parameters(),
)
这里最关键的是:
python
parameters=model.parameters()
它告诉优化器:更新这个模型中所有可训练参数。
动态图训练中,官方优化器文档也强调需要提供 parameters。如果没有传入模型参数,优化器不知道应该更新谁。
训练中的固定步骤是:
text
logits = model(x)
loss = F.cross_entropy(logits, y)
loss.backward()
optimizer.step()
optimizer.clear_grad()
含义:
model(x):前向计算。loss:得到训练目标。loss.backward():计算梯度。optimizer.step():根据梯度更新参数。optimizer.clear_grad():清理梯度,避免下一轮累积。
指标函数:手写 accuracy
分类任务常用准确率:
python
def accuracy(logits, labels):
pred = paddle.argmax(logits, axis=1)
correct = pred == labels
acc = paddle.mean(correct.astype("float32"))
return acc
这段代码的 shape 关系:
text
logits: [N, 2]
labels: [N]
pred: [N]
correct: [N]
acc: []
paddle.argmax(logits, axis=1) 表示在类别维上取分数最高的类别。
如果 logits 的 shape 是 [batch_size, num_classes],分类预测通常就是:
python
pred = paddle.argmax(logits, axis=1)
不要写成 axis=0,那会跨 batch 聚合,含义通常不对。
训练循环:把 MLP 真正训练起来
完整训练代码如下:
python
import paddle
import paddle.nn as nn
import paddle.nn.functional as F
class MLP(nn.Layer):
def __init__(self, input_dim, hidden_dim, num_classes):
super().__init__()
self.fc1 = nn.Linear(input_dim, hidden_dim)
self.fc2 = nn.Linear(hidden_dim, hidden_dim)
self.fc3 = nn.Linear(hidden_dim, num_classes)
def forward(self, x):
x = self.fc1(x)
x = F.relu(x)
x = self.fc2(x)
x = F.relu(x)
logits = self.fc3(x)
return logits
def make_circle_data(num_samples):
x = paddle.rand([num_samples, 2], dtype="float32") * 4.0 - 2.0
radius_square = paddle.sum(x ** 2, axis=1)
labels = (radius_square > 1.0).astype("int64")
return x, labels
def accuracy(logits, labels):
pred = paddle.argmax(logits, axis=1)
correct = pred == labels
acc = paddle.mean(correct.astype("float32"))
return acc
paddle.seed(2026)
train_x, train_y = make_circle_data(1024)
val_x, val_y = make_circle_data(256)
model = MLP(input_dim=2, hidden_dim=16, num_classes=2)
optimizer = paddle.optimizer.Adam(
learning_rate=0.01,
parameters=model.parameters(),
)
for epoch in range(101):
model.train()
logits = model(train_x)
loss = F.cross_entropy(logits, train_y)
train_acc = accuracy(logits, train_y)
loss.backward()
optimizer.step()
optimizer.clear_grad()
if epoch % 20 == 0:
model.eval()
with paddle.no_grad():
val_logits = model(val_x)
val_loss = F.cross_entropy(val_logits, val_y)
val_acc = accuracy(val_logits, val_y)
print(
"epoch:", epoch,
"train_loss:", float(loss.numpy()),
"train_acc:", float(train_acc.numpy()),
"val_loss:", float(val_loss.numpy()),
"val_acc:", float(val_acc.numpy()),
)
这段代码就是一个最小但完整的 MLP 训练程序。
训练时你应该能看到:
train_loss整体下降。train_acc整体上升。val_acc逐步提高。
因为数据是随机生成的,小规模实验结果可能有波动,但整体趋势应该是可学习的。
训练过程拆解:每一行代码在做什么
训练循环中最核心的是这几行:
python
logits = model(train_x)
loss = F.cross_entropy(logits, train_y)
train_acc = accuracy(logits, train_y)
loss.backward()
optimizer.step()
optimizer.clear_grad()
逐行解释:
| 代码 | 作用 |
|---|---|
model(train_x) |
调用 forward,得到分类 logits |
F.cross_entropy(logits, train_y) |
计算分类损失 |
accuracy(logits, train_y) |
计算训练准确率 |
loss.backward() |
根据 loss 反向计算梯度 |
optimizer.step() |
根据梯度更新模型参数 |
optimizer.clear_grad() |
清理梯度,避免累积 |
这里特别要注意两点:
train_acc是指标,不参与参数更新。- 真正驱动参数更新的是
loss.backward()计算出的梯度。
可以把训练循环理解成:
text
先问模型现在错多少
|
再根据错误反推参数该怎么改
|
最后让优化器完成实际修改
评估逻辑:model.eval 与 no_grad
验证阶段代码:
python
model.eval()
with paddle.no_grad():
val_logits = model(val_x)
val_loss = F.cross_entropy(val_logits, val_y)
val_acc = accuracy(val_logits, val_y)
这里有两个关键点:
model.eval():切换到评估模式。with paddle.no_grad():不记录梯度,减少不必要的计算和内存占用。
当前 MLP 只有 Linear 和 ReLU,训练模式和评估模式差异不大。但后续模型如果包含 Dropout、BatchNorm,train() 和 eval() 就非常重要。
建议养成习惯:
text
训练阶段:
model.train()
验证/推理阶段:
model.eval()
with paddle.no_grad():
...
这样后续模型变复杂时,不容易犯模式切换错误。
推理预测:从 logits 得到类别和概率
训练完成后,可以拿几个样本做预测:
python
model.eval()
with paddle.no_grad():
test_x = paddle.to_tensor(
[[0.0, 0.0], [1.5, 1.5], [0.5, 0.5], [-1.2, 0.0]],
dtype="float32",
)
logits = model(test_x)
prob = F.softmax(logits, axis=1)
pred = paddle.argmax(prob, axis=1)
print("test_x:")
print(test_x)
print("prob:")
print(prob)
print("pred:")
print(pred)
解释:
[0.0, 0.0]在圆内,应该更倾向类别 0。[1.5, 1.5]在圆外,应该更倾向类别 1。[0.5, 0.5]在圆内,应该更倾向类别 0。[-1.2, 0.0]在圆外,应该更倾向类别 1。
推理阶段可以使用 softmax 把 logits 转成概率:
python
prob = F.softmax(logits, axis=1)
但训练阶段仍然建议把原始 logits 交给 F.cross_entropy。
用 Sequential 重写同一个 MLP
这个 MLP 结构是纯顺序结构,因此也可以用 nn.Sequential 写:
python
model = nn.Sequential(
nn.Linear(2, 16),
nn.ReLU(),
nn.Linear(16, 16),
nn.ReLU(),
nn.Linear(16, 2),
)
这和自定义 MLP 类的结构等价:
text
Linear(2, 16)
|
ReLU
|
Linear(16, 16)
|
ReLU
|
Linear(16, 2)
什么时候用 Sequential?
- 模型是简单单路顺序结构。
- 中间没有复杂分支。
- 不需要复用多个中间结果。
- 不需要在
forward中写额外逻辑。
什么时候用自定义 nn.Layer?
- 有多个输入或多个输出。
- 有条件分支。
- 有残差连接。
- 需要复用中间结果。
- 需要在
forward里写更复杂的控制流。
本文后续仍然推荐用自定义 MLP 类,因为它更接近真实项目结构,也方便后面继续扩展。
模型参数量:确认 MLP 规模
当前模型:
python
model = MLP(input_dim=2, hidden_dim=16, num_classes=2)
参数量计算:
text
fc1 weight: 2 * 16 = 32
fc1 bias: 16
fc2 weight: 16 * 16 = 256
fc2 bias: 16
fc3 weight: 16 * 2 = 32
fc3 bias: 2
total = 32 + 16 + 256 + 16 + 32 + 2 = 354
用代码验证:
python
total = 0
for name, param in model.named_parameters():
num = param.numel()
total += num
print(name, param.shape, num)
print("total:", total)
参数量不是越大越好。对于这个小任务,hidden_dim=16 已经足够。如果隐藏层太宽、训练数据太少,模型可能更容易过拟合。
学习率影响:太大和太小分别会怎样
优化器中的学习率:
python
learning_rate=0.01
它控制每次参数更新的步幅。
可以简单理解:
text
学习率太小:训练很慢,loss 下降不明显
学习率太大:训练不稳定,loss 震荡甚至变成 NaN
建议初学时尝试三组值:
python
0.001
0.01
0.1
观察训练日志:
- loss 是否下降。
- accuracy 是否上升。
- 是否出现大幅震荡。
- 是否出现
NaN。
不要只看最后一次结果,应该观察整个训练曲线趋势。
隐藏层宽度影响:hidden_dim 是什么
模型中的:
python
hidden_dim=16
表示隐藏层特征维度。它决定中间表示的容量。
例如:
python
MLP(input_dim=2, hidden_dim=8, num_classes=2)
MLP(input_dim=2, hidden_dim=32, num_classes=2)
MLP(input_dim=2, hidden_dim=128, num_classes=2)
影响包括:
hidden_dim越大,参数越多。- 模型表达能力可能更强。
- 计算开销和内存占用增加。
- 小数据集上过拟合风险增加。
对初学者建议:
text
先用小模型跑通流程
再逐步增大 hidden_dim
观察训练集和验证集指标变化
这比一开始堆很大的网络更稳。
保存模型参数:训练结果不要只停留在内存里
训练完成后,可以保存模型参数:
python
paddle.save(model.state_dict(), "mlp_circle.pdparams")
加载参数:
python
new_model = MLP(input_dim=2, hidden_dim=16, num_classes=2)
state_dict = paddle.load("mlp_circle.pdparams")
new_model.set_state_dict(state_dict)
new_model.eval()
验证加载后的模型:
python
with paddle.no_grad():
logits = new_model(val_x)
acc = accuracy(logits, val_y)
print("loaded model val_acc:", float(acc.numpy()))
这部分会在后续"模型保存与加载"中详细展开。这里先建立基本认知:
text
state_dict 保存的是模型参数
模型结构仍然需要用代码重新创建
如果模型结构和保存时不一致,加载参数时就可能出现 shape 不匹配。
完整脚本:可直接复制运行
下面给出完整脚本,方便一次性运行。
python
import paddle
import paddle.nn as nn
import paddle.nn.functional as F
class MLP(nn.Layer):
def __init__(self, input_dim, hidden_dim, num_classes):
super().__init__()
self.fc1 = nn.Linear(input_dim, hidden_dim)
self.fc2 = nn.Linear(hidden_dim, hidden_dim)
self.fc3 = nn.Linear(hidden_dim, num_classes)
def forward(self, x):
x = self.fc1(x)
x = F.relu(x)
x = self.fc2(x)
x = F.relu(x)
logits = self.fc3(x)
return logits
def make_circle_data(num_samples):
x = paddle.rand([num_samples, 2], dtype="float32") * 4.0 - 2.0
radius_square = paddle.sum(x ** 2, axis=1)
labels = (radius_square > 1.0).astype("int64")
return x, labels
def accuracy(logits, labels):
pred = paddle.argmax(logits, axis=1)
correct = pred == labels
acc = paddle.mean(correct.astype("float32"))
return acc
def print_parameters(model):
total = 0
for name, param in model.named_parameters():
num = param.numel()
total += num
print(name, param.shape, num)
print("total parameters:", total)
def main():
paddle.seed(2026)
train_x, train_y = make_circle_data(1024)
val_x, val_y = make_circle_data(256)
model = MLP(input_dim=2, hidden_dim=16, num_classes=2)
print_parameters(model)
optimizer = paddle.optimizer.Adam(
learning_rate=0.01,
parameters=model.parameters(),
)
for epoch in range(101):
model.train()
logits = model(train_x)
loss = F.cross_entropy(logits, train_y)
train_acc = accuracy(logits, train_y)
loss.backward()
optimizer.step()
optimizer.clear_grad()
if epoch % 20 == 0:
model.eval()
with paddle.no_grad():
val_logits = model(val_x)
val_loss = F.cross_entropy(val_logits, val_y)
val_acc = accuracy(val_logits, val_y)
print(
"epoch:", epoch,
"train_loss:", float(loss.numpy()),
"train_acc:", float(train_acc.numpy()),
"val_loss:", float(val_loss.numpy()),
"val_acc:", float(val_acc.numpy()),
)
model.eval()
with paddle.no_grad():
test_x = paddle.to_tensor(
[[0.0, 0.0], [1.5, 1.5], [0.5, 0.5], [-1.2, 0.0]],
dtype="float32",
)
logits = model(test_x)
prob = F.softmax(logits, axis=1)
pred = paddle.argmax(prob, axis=1)
print("test_x:")
print(test_x)
print("prob:")
print(prob)
print("pred:")
print(pred)
paddle.save(model.state_dict(), "mlp_circle.pdparams")
if __name__ == "__main__":
main()
运行:
bash
python first_mlp_with_paddle_nn.py
如果你使用 CPU 环境,这个小例子也能很快运行完。
常见错误
错误一:输入最后一维和 Linear 不匹配
错误示例:
python
model = MLP(input_dim=2, hidden_dim=16, num_classes=2)
x = paddle.randn([32, 3])
logits = model(x)
模型第一层是:
python
nn.Linear(2, 16)
它要求输入最后一维是 2,但 x 是 [32, 3]。
排查方式:
python
print(x.shape)
print(model.fc1.weight.shape)
错误二:分类标签 dtype 不正确
分类标签应是类别编号,通常使用 int64:
python
labels = labels.astype("int64")
不要写成浮点标签:
python
labels = labels.astype("float32")
排查方式:
python
print(labels.shape)
print(labels.dtype)
错误三:手动 softmax 后再 cross_entropy
错误倾向:
python
prob = F.softmax(logits, axis=1)
loss = F.cross_entropy(prob, labels)
更推荐:
python
loss = F.cross_entropy(logits, labels)
因为 cross_entropy 已经合并了 softmax 和交叉熵计算,数值上更稳定。
错误四:忘记 clear_grad
错误写法:
python
loss.backward()
optimizer.step()
正确写法:
python
loss.backward()
optimizer.step()
optimizer.clear_grad()
梯度默认会累积,不清理会影响下一轮训练。
错误五:评估时没有使用 no_grad
评估阶段不需要梯度:
python
model.eval()
with paddle.no_grad():
val_logits = model(val_x)
这能减少不必要的计算图记录。
错误六:loss 下降但 accuracy 不变
先检查:
argmax的axis是否写对。- 标签 shape 是否是
[N]。 - 标签取值是否在
[0, num_classes)。 num_classes是否和模型最后一层输出一致。
对于本文:
text
logits: [N, 2]
labels: [N]
argmax axis=1
错误七:训练集很好,验证集很差
这通常是过拟合或训练验证分布不一致。
可尝试:
- 减小
hidden_dim。 - 减少训练轮数。
- 增加训练数据。
- 加入正则化。
- 检查训练集和验证集生成规则是否一致。
本文数据是同一规则随机生成,一般不会出现严重分布不一致。
总结
这一篇用 paddle.nn 构建并训练了第一个多层感知机,核心知识点包括:
- MLP 通常由
Linear + 激活函数堆叠组成。 - 输入 shape 通常是
[batch_size, feature_dim]。 - 分类输出 logits shape 通常是
[batch_size, num_classes]。 - 分类标签 shape 通常是
[batch_size],dtype 通常是int64。 F.cross_entropy应直接接收 logits,不要先手动 softmax。loss.backward()负责计算梯度。optimizer.step()负责更新参数。optimizer.clear_grad()负责清理梯度。model.train()用于训练模式,model.eval()用于评估模式。paddle.no_grad()用于验证和推理,避免无意义的梯度记录。model.state_dict()可以保存模型参数。
如果只能记住一句话,那就是:
一个可训练的 MLP 不只是几层 Linear,而是数据、模型、损失、梯度、优化器和评估指标共同组成的闭环。