《动手学深度学习》多层感知机简洁实现指南:用高级 API 构建与训练 MLP
《动手学深度学习》多层感知机简洁实现指南:用高级 API 构建与训练 MLP
导读
本篇文章基于《动手学深度学习》(d2l-zh)中文仓库中「多层感知机的简洁实现」一节,系统讲解如何借助高层级神经网络 API(MXNet Gluon、PyTorch、TensorFlow 与 PaddlePaddle)在 Fashion-MNIST 分类任务上快速搭建、初始化并训练一个带 ReLU 激活隐藏层的多层感知机(MLP)。读完本文,你将掌握四套主流框架下 MLP 的等价简洁写法、核心超参数的含义、参数初始化与训练循环的底层原理,并能够直接在笔记本中复制运行完整示例。
本文对应的原始文档为 chapter_multilayer-perceptrons/mlp-concise_origin.md,并参考其简体中文版 chapter_multilayer-perceptrons/mlp-concise.md;所有底层函数实现均可在仓库的 d2l 目录下对应框架文件中找到。
为什么需要「简洁实现」
在动手实现(见 chapter_multilayer-perceptrons/mlp-scratch.md)中,我们需要手动定义模型参数、自行实现激活函数、前向传播、损失计算和参数更新,代码冗长且容易出错。而借助高级 API,框架已经封装好网络层、激活函数、损失函数与优化器,我们只需要声明网络结构和指定训练策略,即可得到与手写实现功能完全一致的模型。
更重要的是,这种模块化设计把「模型架构」与「训练流程」彻底解耦:数据加载、损失计算、优化器更新等正交问题可以复用上一节 softmax 回归简洁实现(见 chapter_linear-networks/softmax-regression-concise.md)的全部代码,唯一的变化只是在网络里多添加一层带激活函数的隐藏层。
仓库的 d2l/init.py 展示了四种后端的统一导入方式,本文的示例代码也以此为基础:
from d2l import mxnet as d2l # 使用 MXNet 作为后端
from d2l import torch as d2l # 使用 PyTorch 作为后端
from d2l import tensorflow as d2l # 使用 TensorFlow 作为后端
from d2l import paddle as d2l # 使用 PaddlePaddle 作为后端
环境依赖与框架导入
本节代码来自原文,先根据你选用的框架导入对应模块:
# MXNet 后端
from d2l import mxnet as d2l
from mxnet import gluon, init, npx
from mxnet.gluon import nn
npx.set_np()
# PyTorch 后端
from d2l import torch as d2l
import torch
from torch import nn
# TensorFlow 后端
from d2l import tensorflow as d2l
import tensorflow as tf
# PaddlePaddle 后端
from d2l import paddle as d2l
import warnings
warnings.filterwarnings("ignore")
import paddle
from paddle import nn
其中 d2l 包即仓库根目录下的 d2l 模块(PyTorch 版本为 d2l/torch.py、TensorFlow 版本为 d2l/tensorflow.py、MXNet 版本为 d2l/mxnet.py、Paddle 版本为 d2l/paddle.py),后续调用到的 d2l.load_data_fashion_mnist、d2l.train_ch3 等工具函数都定义在这些文件中。
模型定义:在 softmax 回归基础上加一层隐藏层
与 softmax 回归简洁实现相比,唯一的架构差异是多添加了一个全连接层:
- 第一层为隐藏层,包含 256 个隐藏单元,激活函数为 ReLU;
- 第二层为输出层,输出维度为 10,对应 Fashion-MNIST 的 10 个类别,该层不加激活函数。
MXNet(Gluon)
net = nn.Sequential()
net.add(nn.Dense(256, activation='relu'),
nn.Dense(10))
net.initialize(init.Normal(sigma=0.01))
nn.Dense(256, activation='relu'):声明一个输出维度为 256 的全连接层,并内置 ReLU 激活;nn.Dense(10):输出层,10 个类别;net.initialize(init.Normal(sigma=0.01)):用均值为 0、标准差为 0.01 的正态分布初始化所有层的权重。
PyTorch
net = nn.Sequential(nn.Flatten(),
nn.Linear(784, 256),
nn.ReLU(),
nn.Linear(256, 10))
def init_weights(m):
if type(m) == nn.Linear:
torch.nn.init.normal_(m.weight, std=0.01)
net.apply(init_weights)
nn.Flatten():把形状为 (batch_size, 1, 28, 28) 的输入展平成 (batch_size, 784),其中 784 = 28 × 28;nn.Linear(784, 256)+nn.ReLU():隐藏层,输入 784 维、输出 256 维,ReLU 作为单独的层显式添加;nn.Linear(256, 10):输出层;init_weights遍历网络的所有子模块,仅对nn.Linear层使用std=0.01的正态分布重新初始化权重,然后通过net.apply(init_weights)递归应用到整个网络。
注意:PyTorch 的 nn.Linear 默认会以 kaiming_uniform_ 方式初始化权重,这里显式指定 std=0.01 的正态初始化,是为了与原文手写实现(用 init.Normal(sigma=0.01))保持一致的起点,便于对比收敛行为。
TensorFlow(Keras)
net = tf.keras.models.Sequential([
tf.keras.layers.Flatten(),
tf.keras.layers.Dense(256, activation='relu'),
tf.keras.layers.Dense(10)])
Flatten():展平输入,与 PyTorch 的nn.Flatten()作用相同;Dense(256, activation='relu'):隐藏层,activation参数直接在层内指定;Dense(10):输出层。
PaddlePaddle
net = nn.Sequential(nn.Flatten(),
nn.Linear(784, 256),
nn.ReLU(),
nn.Linear(256, 10))
for layer in net:
if type(layer) == nn.Linear:
weight_attr = paddle.framework.ParamAttr(initializer=paddle.nn.initializer.Normal(mean=0.0, std=0.01))
layer.weight_attr = weight_attr
与 PyTorch 写法结构一致,初始化则通过为 nn.Linear 层重新指定 ParamAttr(均值为 0、标准差为 0.01 的正态分布初始化器)来实现。
为什么隐藏单元数选 256,输出为什么是 10
- 隐藏单元数(256):256 是 2 的幂,在常见框架中对齐友好;同时也为模型提供了足够的容量来拟合 Fashion-MNIST 这种 10 类、像素级输入的任务,配合 ReLU 可以学到非线性决策边界。原文练习中明确建议读者尝试不同的隐藏层数量并对比效果。
- 输出层维度(10):Fashion-MNIST 共有 10 个类别(t-shirt、trouser、pullover 等),因此输出层必须映射到 10 个 logits,后续交给交叉熵损失处理。
训练配置:损失函数、优化器与超参数
模型定义好后,接下来是训练三要素:损失函数、优化器与超参数。原文给出的配置如下:
batch_size, lr, num_epochs = 256, 0.1, 10
即小批量大小为 256、学习率为 0.1、训练 10 个轮次(epoch)。下面按框架列出损失与优化器:
| 框架 | 损失函数 | 优化器 |
|---|---|---|
| MXNet | gluon.loss.SoftmaxCrossEntropyLoss() |
gluon.Trainer(net.collect_params(), 'sgd', {'learning_rate': lr}) |
| PyTorch | nn.CrossEntropyLoss(reduction='none') |
torch.optim.SGD(net.parameters(), lr=lr) |
| TensorFlow | tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True) |
tf.keras.optimizers.SGD(learning_rate=lr) |
| Paddle | nn.CrossEntropyLoss(reduction='none') |
paddle.optimizer.SGD(parameters=net.parameters(), learning_rate=lr) |
要点说明:
- 交叉熵 + Softmax:四套框架的交叉熵损失在数值上都在内部融合了 softmax 运算(MXNet 的
SoftmaxCrossEntropyLoss、TensorFlow 的from_logits=True均表示传入的是未经过 softmax 的原始 logits,框架内部完成 softmax 与交叉熵的数值稳定合并计算,避免中间结果溢出)。 - 稀疏标签:Fashion-MNIST 的标签是 0~9 的整数索引而非 one-hot 向量,因此使用 TensorFlow 的
SparseCategoricalCrossentropy(稀疏类别交叉熵);PyTorch/Paddle 的CrossEntropyLoss原生接受整数标签,与之一致。 reduction='none':PyTorch 与 Paddle 版本显式设置为不自动求平均,逐样本返回损失,由train_epoch_ch3内部自行累加与平均,这保证了与手写实现中按「样本数」归一化损失的逻辑一致(下文会结合源码说明)。- SGD 优化器:四者都是标准的随机梯度下降,学习率统一为 0.1。
gluon.Trainer通过net.collect_params()收集 MXNet 网络的全部参数;torch.optim.SGD(net.parameters())、paddle.optimizer.SGD(parameters=net.parameters())同理传入可训练参数列表;TensorFlow 则因 Keras 优化器会自动跟踪net.trainable_variables,无需显式传参。
数据加载与完整训练:一行代码跑通
数据加载与训练复用 softmax 回归章节的封装,原文档中仅两行:
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, trainer)
数据加载的实现细节
d2l.load_data_fashion_mnist(batch_size, resize=None) 在各框架中的实现略有不同:
- PyTorch(d2l/torch.py):使用
torchvision.datasets.FashionMNIST下载数据(缓存于../data目录),经transforms.ToTensor()转成张量;若传入resize参数还会先插入transforms.Resize。返回两个DataLoader,测试集不 shuffle,均使用 4 个进程读取数据。 - TensorFlow(d2l/tensorflow.py):直接使用
tf.keras.datasets.fashion_mnist.load_data(),将像素除以 255 归一化到 [0, 1],并补上通道维度;resize时用tf.image.resize_with_pad做填充缩放。返回tf.data.Dataset,训练集按batch_size分批并shuffle。 - MXNet / Paddle 版本语义一致,都返回可直接迭代的 (X, y) 小批量数据。
训练函数 train_ch3 的底层逻辑
d2l.train_ch3 在四个框架文件中均有定义(MXNet 见 d2l/mxnet.py、PyTorch 见 d2l/torch.py、TensorFlow 见 d2l/tensorflow.py、Paddle 见 d2l/paddle.py),其内部流程完全一致:
- 创建一个
Animator动画器,x 轴为 epoch(范围 [1, num_epochs]),y 轴范围 [0.3, 0.9],同时绘制 train loss、train acc、test acc 三条曲线; - 每个 epoch 调用
train_epoch_ch3执行一次完整的小批量训练,随后在测试集上计算精度; - 每轮结束后把 (epoch, train_loss, train_acc, test_acc) 追加到动画中;
- 训练结束后做断言校验:
train_loss < 0.5、train_acc > 0.7、test_acc > 0.7,若未达标会抛出 AssertionError,帮助读者快速发现实现或超参数配置问题。
单轮训练 train_epoch_ch3 的内部细节
以 PyTorch 版本(d2l/torch.py)为例,每个 epoch 内:
net.train() # 切换为训练模式
metric = Accumulator(3) # 累计:损失总和、正确数、样本数
for X, y in train_iter:
y_hat = net(X) # 前向传播
l = loss(y_hat, y) # 计算损失(逐样本)
if isinstance(updater, torch.optim.Optimizer):
updater.zero_grad() # 清空旧梯度
l.mean().backward() # 反向传播(按批平均损失)
updater.step() # 参数更新
metric.add(float(l.sum()), accuracy(y_hat, y), y.numel())
return metric[0] / metric[2], metric[1] / metric[2]
值得注意的工程细节:
- 因为损失设置了
reduction='none',这里先l.mean().backward()求平均梯度,再在metric中累加l.sum()与样本数y.numel(),最终metric[0] / metric[2]得到的仍是逐样本平均损失,与框架默认的 mean 语义一致; - 训练结束立即调用
evaluate_accuracy(见 d2l/torch.py),它会把模型切换为net.eval()评估模式并在torch.no_grad()下计算测试精度,避免评估阶段产生不必要的梯度; - TensorFlow 版本(d2l/tensorflow.py)使用
tf.GradientTape()记录前向计算,并通过updater.apply_gradients(zip(grads, params))应用梯度;同时其 Keras 内置损失接受 (y, y_hat) 参数顺序,与本书自实现损失接受 (y_hat, y) 的顺序不同,源码中做了显式分支处理; - Paddle 版本(d2l/paddle.py)与 PyTorch 流程一致,使用
updater.clear_grad()清梯度、l.mean().backward()反传、updater.step()更新。
简洁实现 vs 手写实现:差异在哪里
将本文与手写实现 chapter_multilayer-perceptrons/mlp-scratch.md 对比,可以总结出简洁实现的三点优势:
- 模型定义从「参数 + 计算逻辑」变为「层声明」:手写实现需要自行用
nn.Parameter创建W1、b1、W2、b2并手写relu(X @ W1 + b1);简洁实现只需按顺序列出Linear/Dense层,框架自动完成参数分配与维度检查。 - 激活函数由框架内置:ReLU 可以通过
activation='relu'参数(MXNet/Keras)或nn.ReLU()层(PyTorch/Paddle)直接声明,无需自行实现。 - 训练循环完全复用:数据加载、损失、优化器、动画绘制都由
d2l.train_ch3统一接管,模型架构与训练细节彼此独立,这正是原文强调的「模块化设计使我们能够将与模型架构有关的内容独立出来」。
收敛预期与常见问题排查
在默认超参数(batch_size=256、lr=0.1、num_epochs=10)下,该网络在 Fashion-MNIST 上可稳定收敛,训练与测试精度均会超过 0.7、训练损失低于 0.5——这也是 train_ch3 内置断言的阈值(见上文源码)。如果训练未达到预期,可按以下顺序排查:
- 学习率过高或过低:lr=0.1 是原文档验证过的默认值;改成更小学习率时收敛变慢,可适当增加
num_epochs。 - 隐藏层过深或过宽:盲目堆叠隐藏层可能带来过拟合或训练不稳定,建议先固定 256 单元单隐藏层作为基线,再逐层增加并观察验证精度变化(对应原文练习 1)。
- 激活函数选择:ReLU 是默认且稳健的选择;可尝试 sigmoid/tanh 对比梯度饱和带来的收敛差异(对应原文练习 2)。
- 权重初始化:本文统一采用
std=0.01的正态分布;可对比默认初始化(如 PyTorch 的 kaiming 初始化)或 Xavier 初始化对收敛速度的影响(对应原文练习 3)。过大的初始化方差(如 std=1.0)在深层网络中极易导致梯度爆炸或消失。
小结
- 借助高级 API,多层感知机可以在十几行代码内完成定义、初始化、训练与评估,实现远比手写版本简洁;
- 与 softmax 回归的简洁实现相比,MLP 唯一的结构性差异是增加了一到多个带激活函数的隐藏层;
- 本文示例在 MXNet、PyTorch、TensorFlow、PaddlePaddle 四种后端下结构一一对应,模型架构(
Sequential+ 全连接 + ReLU)、训练流程(load_data_fashion_mnist+train_ch3)与超参数(256 隐藏单元、batch_size=256、lr=0.1、10 epochs)完全等价,可直接跨框架对照学习。
练习
- 尝试添加不同数量的隐藏层(可同步修改学习率),观察哪种设置效果最好?
- 尝试不同的激活函数(如 sigmoid、tanh),哪一个收敛效果最好?
- 尝试不同的权重初始化方案(正态分布不同 std、Xavier、Kaiming 等),哪种方法效果最好?
动手实践时,建议在 chapter_multilayer-perceptrons 目录中对照本文与 mlp-scratch.md、mlp.md 阅读;本章完整目录见 chapter_multilayer-perceptrons/index.md,其中还包含过拟合、权重衰减、暂退法、数值稳定性与初始化等与本文密切相关的后续小节。