《动手学深度学习》多层感知机简洁实现指南:用高级 API 构建与训练 MLP

原创2026-09-30 19:45:341,432 阅读
文章标签:人工智能深度学习机器学习教程

《动手学深度学习》多层感知机简洁实现指南:用高级 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),其内部流程完全一致:

  1. 创建一个 Animator 动画器,x 轴为 epoch(范围 [1, num_epochs]),y 轴范围 [0.3, 0.9],同时绘制 train loss、train acc、test acc 三条曲线;
  2. 每个 epoch 调用 train_epoch_ch3 执行一次完整的小批量训练,随后在测试集上计算精度;
  3. 每轮结束后把 (epoch, train_loss, train_acc, test_acc) 追加到动画中;
  4. 训练结束后做断言校验: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 对比,可以总结出简洁实现的三点优势:

  1. 模型定义从「参数 + 计算逻辑」变为「层声明」:手写实现需要自行用 nn.Parameter 创建 W1、b1、W2、b2 并手写 relu(X @ W1 + b1);简洁实现只需按顺序列出 Linear/Dense 层,框架自动完成参数分配与维度检查。
  2. 激活函数由框架内置:ReLU 可以通过 activation='relu' 参数(MXNet/Keras)或 nn.ReLU() 层(PyTorch/Paddle)直接声明,无需自行实现。
  3. 训练循环完全复用:数据加载、损失、优化器、动画绘制都由 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)完全等价,可直接跨框架对照学习。

练习

  1. 尝试添加不同数量的隐藏层(可同步修改学习率),观察哪种设置效果最好?
  2. 尝试不同的激活函数(如 sigmoid、tanh),哪一个收敛效果最好?
  3. 尝试不同的权重初始化方案(正态分布不同 std、Xavier、Kaiming 等),哪种方法效果最好?

动手实践时,建议在 chapter_multilayer-perceptrons 目录中对照本文与 mlp-scratch.md、mlp.md 阅读;本章完整目录见 chapter_multilayer-perceptrons/index.md,其中还包含过拟合、权重衰减、暂退法、数值稳定性与初始化等与本文密切相关的后续小节。

登录后查看全文
d2l-zh