《动手学深度学习》多层感知机(MLP)实战指南:隐藏层、激活函数与从零到简洁实现

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

《动手学深度学习》多层感知机(MLP)实战指南:隐藏层、激活函数与从零到简洁实现

多层感知机(Multilayer Perceptron,MLP)是深度神经网络的起点:它在输入层与输出层之间引入一个或多个全连接隐藏层,并借助非线性激活函数摆脱线性模型的表达能力限制。本文以《动手学深度学习》(d2l-zh)多层感知机章节为主线,系统讲解隐藏层的动机与数学形式、ReLU/sigmoid/tanh 三大激活函数的性质与导数、以及它们如何在 从零实现 与 简洁实现 中落地,并给出 MXNet、PyTorch、TensorFlow、PaddlePaddle 四种框架下的可运行代码,帮助你真正掌握 MLP 的构建原理与训练流程。

一个单隐藏层的多层感知机(4 个输入、5 个隐藏单元、3 个输出)

为什么线性模型不够:从单调性到特征交互

在进入隐藏层之前,需要先理解为什么“单个仿射变换 + softmax”的模型结构有本质局限。仿射变换是带偏置项的线性变换,而线性是一个很强的假设。

线性意味着单调

线性模型隐含了单调性假设:任何特征的增大都会导致模型输出增大(对应权重为正)或减小(对应权重为负)。这在某些场景下合理,例如预测申请人是否会偿还贷款时,收入越高通常越可能还款;但收入与还款概率的关系并非线性——收入从 0 增加到 5 万带来的还款可能性提升,远大于从 100 万增加到 105 万。一种缓解办法是对数据做预处理,例如用收入的对数作为特征,让线性变得更合理。

也可以轻易构造违反单调性的例子:根据体温预测死亡率时,高于 37℃ 的人温度越高风险越大,低于 37℃ 的人温度越高风险反而越低。此时可以改用“与 37℃ 的距离”作为特征来化解。

像素的上下文让预处理失效

但对猫狗图像分类来说,增加位置 (13,17)(13, 17) 处像素的强度,是否总是增加(或降低)图像描绘狗的似然?依赖线性模型相当于假设“区分猫和狗只需要评估单个像素的强度”,而在倒置图像仍保留类别标签的世界里,这种方法注定失败。

这里的关键区别在于:任何像素的重要性都以复杂方式取决于其上下文(周围像素的值)。虽然数据可能存在一种能考虑特征间相关交互的表示,在其之上构建线性模型是合适的,但我们无法手工计算这种表示。深度神经网络的做法,是用观测数据联合学习隐藏层表示、以及作用于该表示的线性预测器——这正是引入隐藏层的动机。

在网络中加入隐藏层:MLP 的结构与层数

克服线性模型限制最直接的方法,是将多个全连接层堆叠在一起:每一层都输出到上一层,直到生成最终输出。可以把前 L−1L-1 层看作“表示”,把最后一层看作“线性预测器”。这种架构即多层感知机(multilayer perceptron),通常缩写为 MLP。

以上图为例:这个 MLP 有 4 个输入、3 个输出,隐藏层包含 5 个隐藏单元。由于输入层不涉及任何计算,产生输出只需实现隐藏层和输出层的计算,因此该网络的层数为 2。注意这两个层都是全连接的:每个输入都会影响隐藏层中的每个神经元,隐藏层中的每个神经元又影响输出层中的每个神经元。

同时要注意,全连接层的参数开销可能高得令人望而却步:即使不改变输入或输出大小,也存在参数节约与模型有效性之间的权衡。这一点对理解后续章节中卷积层(局部连接、权值共享)为何重要很有帮助。

从线性到非线性:为什么必须有激活函数

设 X∈Rn×d\mathbf{X} \in \mathbb{R}^{n \times d} 表示含 nn 个样本、每个样本 dd 个输入特征的小批量。对于具有 hh 个隐藏单元的单隐藏层 MLP,用 H∈Rn×h\mathbf{H} \in \mathbb{R}^{n \times h} 表示隐藏层输出,即隐藏表示(hidden representations),也称隐藏层变量或隐藏变量。由于隐藏层和输出层都是全连接的,有隐藏层权重 W(1)∈Rd×h\mathbf{W}^{(1)} \in \mathbb{R}^{d \times h}、偏置 b(1)∈R1×h\mathbf{b}^{(1)} \in \mathbb{R}^{1 \times h},以及输出层权重 W(2)∈Rh×q\mathbf{W}^{(2)} \in \mathbb{R}^{h \times q}、偏置 b(2)∈R1×q\mathbf{b}^{(2)} \in \mathbb{R}^{1 \times q}。输出 O∈Rn×q\mathbf{O} \in \mathbb{R}^{n \times q} 的形式化计算为:

H=XW(1)+b(1),O=HW(2)+b(2).\begin{aligned} \mathbf{H} & = \mathbf{X} \mathbf{W}^{(1)} + \mathbf{b}^{(1)}, \\ \mathbf{O} & = \mathbf{H}\mathbf{W}^{(2)} + \mathbf{b}^{(2)}. \end{aligned}

仿射的仿射仍是仿射

添加隐藏层后模型需要跟踪和更新额外参数,但在纯线性形式下我们没有任何收益:隐藏单元是输入的仿射函数,输出(softmax 操作前)只是隐藏单元的仿射函数,而仿射函数的仿射函数本身仍是仿射函数——之前的线性模型已经能表示任何仿射函数。可以形式化地证明,对任意权重值,只需合并隐藏层即可得到等价单层模型(参数 W=W(1)W(2)\mathbf{W} = \mathbf{W}^{(1)}\mathbf{W}^{(2)},b=b(1)W(2)+b(2)\mathbf{b} = \mathbf{b}^{(1)} \mathbf{W}^{(2)} + \mathbf{b}^{(2)}):

O=(XW(1)+b(1))W(2)+b(2)=XW(1)W(2)+b(1)W(2)+b(2)=XW+b.\mathbf{O} = (\mathbf{X} \mathbf{W}^{(1)} + \mathbf{b}^{(1)})\mathbf{W}^{(2)} + \mathbf{b}^{(2)} = \mathbf{X} \mathbf{W}^{(1)}\mathbf{W}^{(2)} + \mathbf{b}^{(1)} \mathbf{W}^{(2)} + \mathbf{b}^{(2)} = \mathbf{X} \mathbf{W} + \mathbf{b}.

激活函数打破线性坍缩

为了发挥多层架构的潜力,需要在仿射变换之后对每个隐藏单元应用非线性的激活函数(activation function)σ\sigma;激活函数的输出(如 σ(⋅)\sigma(\cdot))称为活性值(activations)。有了激活函数,多层感知机就不可能再退化(坍缩)成线性模型:

H=σ(XW(1)+b(1)),O=HW(2)+b(2).\begin{aligned} \mathbf{H} & = \sigma(\mathbf{X} \mathbf{W}^{(1)} + \mathbf{b}^{(1)}), \\ \mathbf{O} & = \mathbf{H}\mathbf{W}^{(2)} + \mathbf{b}^{(2)}. \end{aligned}

这里非线性函数 σ\sigma 以按行方式作用于输入(每次计算一个样本);本节用于隐藏层的激活函数通常不仅按行操作,而是按元素操作——计算完每层的线性部分后,每个活性值可独立计算,无需查看其他隐藏单元的值,这对大多数激活函数都成立。要构建更通用的 MLP,可以继续堆叠隐藏层,例如 H(1)=σ1(XW(1)+b(1))\mathbf{H}^{(1)} = \sigma_1(\mathbf{X} \mathbf{W}^{(1)} + \mathbf{b}^{(1)})、H(2)=σ2(H(1)W(2)+b(2))\mathbf{H}^{(2)} = \sigma_2(\mathbf{H}^{(1)} \mathbf{W}^{(2)} + \mathbf{b}^{(2)}),一层叠一层,从而产生更有表达能力的模型。

通用近似定理:能力与学习的区别

MLP 可以通过隐藏神经元(它们依赖每个输入的值)捕捉输入之间复杂的相互作用,甚至能轻易设计隐藏节点执行任意计算,例如在一对输入上进行基本逻辑操作。对某些激活函数的选择,MLP 是通用近似器:即使只有一个隐藏层,给定足够多的神经元和正确的权重,也可以对任意函数建模——尽管实际学习到该函数非常困难。

可以这样类比:神经网络有点像 C 语言——它和任何现代编程语言一样能表达任何可计算的程序,但想出一个符合规范的程序才是最困难的部分。同样,单隐藏层网络能学习任何函数,并不意味着应该用单隐藏层网络解决所有问题:事实上,通过使用更深(而非更广)的网络,许多函数能被更紧凑地逼近,这也是后续章节反复讨论的主题。

激活函数全解析:ReLU、sigmoid 与 tanh

激活函数通过计算加权和并加上偏置来确定神经元是否应该被激活,是将输入信号转换为输出的可微运算,大多数激活函数都是非线性的。下面在四种框架(MXNet / PyTorch / TensorFlow / PaddlePaddle)下逐一介绍,并给出绘制函数曲线所需的导入代码(d2l.plot 等绘图工具的实现见 d2l/torch.py 等对应框架模块):

# MXNet
%matplotlib inline
from d2l import mxnet as d2l
from mxnet import autograd, np, npx
npx.set_np()
# PyTorch
%matplotlib inline
from d2l import torch as d2l
import torch
# TensorFlow
%matplotlib inline
from d2l import tensorflow as d2l
import tensorflow as tf
# PaddlePaddle
%matplotlib inline
from d2l import paddle as d2l
import warnings
warnings.filterwarnings("ignore")
import paddle

ReLU:实现简单、表现优异的默认选择

最受欢迎的激活函数是修正线性单元(Rectified linear unit,ReLU),它实现简单,在各种预测任务中表现良好,提供了一种非常简单的非线性变换。给定元素 xx,定义为该元素与 00 的最大值:

ReLU⁡(x)=max⁡(x,0).\operatorname{ReLU}(x) = \max(x, 0).

通俗地说,ReLU 通过将相应活性值设为 0,只保留正元素并丢弃所有负元素,其函数图像是分段线性的。四种框架下绘制 ReLU 曲线与导数的代码(输入范围 −8.0-8.0 到 8.08.0,步长 0.1):

# MXNet
x = np.arange(-8.0, 8.0, 0.1)
x.attach_grad()
with autograd.record():
    y = npx.relu(x)
d2l.plot(x, y, 'x', 'relu(x)', figsize=(5, 2.5))
# PyTorch
x = torch.arange(-8.0, 8.0, 0.1, requires_grad=True)
y = torch.relu(x)
d2l.plot(x.detach(), y.detach(), 'x', 'relu(x)', figsize=(5, 2.5))
# TensorFlow
x = tf.Variable(tf.range(-8.0, 8.0, 0.1), dtype=tf.float32)
y = tf.nn.relu(x)
d2l.plot(x.numpy(), y.numpy(), 'x', 'relu(x)', figsize=(5, 2.5))
# PaddlePaddle
x = paddle.arange(-8.0, 8.0, 0.1, dtype='float32')
x.stop_gradient = False
y = paddle.nn.functional.relu(x)
d2l.plot(x.detach().numpy(), y.detach().numpy(), 'x', 'relu(x)', figsize=(5, 2.5))

ReLU 的导数性质极佳:输入为负时导数为 0,输入为正时导数为 1。当输入精确等于 0 时函数不可导,此时默认使用左侧导数(导数为 0),实际中可以忽略这种情况,因为输入可能永远都不会是 0——正如书中所言:“如果微妙的边界条件很重要,我们很可能是在研究数学而非工程。”绘制导数时:

# MXNet
y.backward()
d2l.plot(x, x.grad, 'x', 'grad of relu', figsize=(5, 2.5))
# PyTorch
y.backward(torch.ones_like(x), retain_graph=True)
d2l.plot(x.detach(), x.grad, 'x', 'grad of relu', figsize=(5, 2.5))
# TensorFlow
with tf.GradientTape() as t:
    y = tf.nn.relu(x)
d2l.plot(x.numpy(), t.gradient(y, x).numpy(), 'x', 'grad of relu',
         figsize=(5, 2.5))
# PaddlePaddle
y.backward(paddle.ones_like(x), retain_graph=True)
d2l.plot(x.detach().numpy(), x.grad.numpy(), 'x', 'grad of relu', figsize=(5, 2.5))

使用 ReLU 的原因是它的导数要么让参数消失、要么让参数直接通过,这使得优化表现更好,并减轻了困扰以往神经网络的梯度消失问题(后续章节会详细介绍)。注意 ReLU 有许多变体,包括参数化 ReLU(Parameterized ReLU,pReLU),它为 ReLU 添加一个线性项,即使参数为负某些信息仍能通过:

pReLU⁡(x)=max⁡(0,x)+αmin⁡(0,x).\operatorname{pReLU}(x) = \max(0, x) + \alpha \min(0, x).

sigmoid:可微的阈值单元近似

sigmoid 函数将定义域 R\mathbb{R} 中的输入变换到区间 (0,1)(0, 1) 上的输出,因此常被称为挤压函数(squashing function)——它把 (−∞,∞)(-\infty, \infty) 范围的任意输入压缩到 (0,1)(0, 1):

sigmoid⁡(x)=11+exp⁡(−x).\operatorname{sigmoid}(x) = \frac{1}{1 + \exp(-x)}.

最早的神经网络试图建模“激发”或“不激发”的生物神经元,先驱可追溯到人工神经元的发明者麦卡洛克和皮茨,他们关注阈值单元:输入低于某阈值时取值 0,超过阈值时取值 1。当研究转向基于梯度的学习时,sigmoid 成为自然选择,因为它是阈值单元平滑、可微的近似。sigmoid 至今仍广泛用作二元分类问题输出单元上的激活函数(可视为 softmax 的特例);但在隐藏层中,它大部分时候已被更简单、更易训练的 ReLU 取代。在后续循环神经网络的章节中,将看到利用 sigmoid 单元控制时序信息流的架构。

绘制 sigmoid 曲线时注意:当输入接近 0 时,sigmoid 接近线性变换。

# MXNet
with autograd.record():
    y = npx.sigmoid(x)
d2l.plot(x, y, 'x', 'sigmoid(x)', figsize=(5, 2.5))
# PyTorch
y = torch.sigmoid(x)
d2l.plot(x.detach(), y.detach(), 'x', 'sigmoid(x)', figsize=(5, 2.5))
# TensorFlow
y = tf.nn.sigmoid(x)
d2l.plot(x.numpy(), y.numpy(), 'x', 'sigmoid(x)', figsize=(5, 2.5))
# PaddlePaddle
y = paddle.nn.functional.sigmoid(x)
d2l.plot(x.detach().numpy(), y.detach().numpy(), 'x', 'sigmoid(x)', figsize=(5, 2.5))

sigmoid 的导数有一个优美的自指形式:

ddxsigmoid⁡(x)=exp⁡(−x)(1+exp⁡(−x))2=sigmoid⁡(x)(1−sigmoid⁡(x)).\frac{d}{dx} \operatorname{sigmoid}(x) = \frac{\exp(-x)}{(1 + \exp(-x))^2} = \operatorname{sigmoid}(x)\left(1-\operatorname{sigmoid}(x)\right).

绘制导数时注意:当输入为 0 时导数达到最大值 0.25;输入在任一方向上越远离 0,导数越接近 0(这正是梯度消失的根源之一)。

# MXNet
y.backward()
d2l.plot(x, x.grad, 'x', 'grad of sigmoid', figsize=(5, 2.5))
# PyTorch
# 清除以前的梯度
x.grad.data.zero_()
y.backward(torch.ones_like(x), retain_graph=True)
d2l.plot(x.detach(), x.grad, 'x', 'grad of sigmoid', figsize=(5, 2.5))
# TensorFlow
with tf.GradientTape() as t:
    y = tf.nn.sigmoid(x)
d2l.plot(x.numpy(), t.gradient(y, x).numpy(), 'x', 'grad of sigmoid',
         figsize=(5, 2.5))
# PaddlePaddle
# 清除以前的梯度
x.clear_gradient()
y.backward(paddle.ones_like(x), retain_graph=True)
d2l.plot(x.detach().numpy(), x.grad.numpy(), 'x', 'grad of sigmoid', figsize=(5, 2.5))

tanh:关于原点中心对称的挤压函数

与 sigmoid 类似,tanh(双曲正切)函数将其输入压缩到区间 (−1,1)(-1, 1) 上:

tanh⁡(x)=1−exp⁡(−2x)1+exp⁡(−2x).\operatorname{tanh}(x) = \frac{1 - \exp(-2x)}{1 + \exp(-2x)}.

tanh 的形状与 sigmoid 相似,但关于坐标系原点中心对称;当输入在 0 附近时接近线性变换。

# MXNet
with autograd.record():
    y = np.tanh(x)
d2l.plot(x, y, 'x', 'tanh(x)', figsize=(5, 2.5))
# PyTorch
y = torch.tanh(x)
d2l.plot(x.detach(), y.detach(), 'x', 'tanh(x)', figsize=(5, 2.5))
# TensorFlow
y = tf.nn.tanh(x)
d2l.plot(x.numpy(), y.numpy(), 'x', 'tanh(x)', figsize=(5, 2.5))
# PaddlePaddle
y = paddle.tanh(x)
d2l.plot(x.detach().numpy(), y.detach().numpy(), 'x', 'tanh(x)', figsize=(5, 2.5))

tanh 的导数为:

ddxtanh⁡(x)=1−tanh⁡2(x).\frac{d}{dx} \operatorname{tanh}(x) = 1 - \operatorname{tanh}^2(x).

当输入接近 0 时,tanh 导数接近最大值 1;与 sigmoid 类似,输入离 0 越远导数越接近 0。

# MXNet
y.backward()
d2l.plot(x, x.grad, 'x', 'grad of tanh', figsize=(5, 2.5))
# PyTorch
# 清除以前的梯度
x.grad.data.zero_()
y.backward(torch.ones_like(x), retain_graph=True)
d2l.plot(x.detach(), x.grad, 'x', 'grad of tanh', figsize=(5, 2.5))
# TensorFlow
with tf.GradientTape() as t:
    y = tf.nn.tanh(x)
d2l.plot(x.numpy(), t.gradient(y, x).numpy(), 'x', 'grad of tanh',
         figsize=(5, 2.5))
# PaddlePaddle
# 清除以前的梯度
x.clear_gradient()
y.backward(paddle.ones_like(x), retain_graph=True)
d2l.plot(x.detach().numpy(), x.grad.numpy(), 'x', 'grad of tanh', figsize=(5, 2.5))

实战落地:在 Fashion-MNIST 上实现 MLP

掌握了上述原理后,从零实现章节 给出了把理论变为可训练模型的完整路径。以下实现要点可直接对照仓库源码阅读。

数据与参数初始化

Fashion-MNIST 中每个图像由 28×28=78428 \times 28 = 784 个灰度像素组成,共 10 个类别;忽略像素空间结构后可视为 784 个输入特征 + 10 类的分类数据集。使用 batch_size = 256 加载数据(d2l.load_data_fashion_mnist 的实现见 d2l/torch.py):

batch_size = 256
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)

实现一个含单隐藏层(256 个隐藏单元)的 MLP。num_inputs、num_hiddens 都是超参数,通常选择 2 的若干次幂作为层宽度,因为内存在硬件中的分配和寻址方式使得这样计算更高效。对每一层记录一个权重矩阵和一个偏置向量,并为损失关于这些参数的梯度分配内存:

# PyTorch
num_inputs, num_outputs, num_hiddens = 784, 10, 256

W1 = nn.Parameter(torch.randn(
    num_inputs, num_hiddens, requires_grad=True) * 0.01)
b1 = nn.Parameter(torch.zeros(num_hiddens, requires_grad=True))
W2 = nn.Parameter(torch.randn(
    num_hiddens, num_outputs, requires_grad=True) * 0.01)
b2 = nn.Parameter(torch.zeros(num_outputs, requires_grad=True))

params = [W1, b1, W2, b2]

手动实现 ReLU 与模型前向

为了确保对模型细节了如指掌,从零实现中手动实现 ReLU 激活函数而非直接调用内置 relu:

# PyTorch
def relu(X):
    a = torch.zeros_like(X)
    return torch.max(X, a)

前向计算与公式完全对应:先 reshape 把每个二维图像展平成 num_inputs 长度的向量,再依次计算“线性部分 → ReLU → 线性部分”:

# PyTorch
def net(X):
    X = d2l.reshape(X, (-1, num_inputs))
    H = relu(X @ W1 + b1)  # 这里“@”代表矩阵乘法
    return (H @ W2 + b2)

损失直接使用高级 API 内置的 softmax + 交叉熵(loss = nn.CrossEntropyLoss(reduction='none')),训练流程与 softmax 回归完全相同,调用 d2l.train_ch3 将迭代周期设为 10、学习率设为 0.1 即可(train_ch3 的实现见 d2l/torch.py,内部通过 evaluate_accuracy 在测试集上评估精度)。

简洁实现:四行定义模型

在 简洁实现章节 中,与 softmax 回归的简洁实现相比,唯一的区别是添加了 2 个全连接层:第一层是隐藏层(256 个隐藏单元 + ReLU 激活),第二层是输出层。

# 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:
        nn.init.normal_(m.weight, std=0.01)

net.apply(init_weights);
# TensorFlow
net = tf.keras.models.Sequential([
    tf.keras.layers.Flatten(),
    tf.keras.layers.Dense(256, activation='relu'),
    tf.keras.layers.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

训练过程与 softmax 回归完全相同,这种模块化设计使得与模型架构有关的内容可以独立出来:

# PyTorch
batch_size, lr, num_epochs = 256, 0.1, 10
loss = nn.CrossEntropyLoss(reduction='none')
trainer = torch.optim.SGD(net.parameters(), lr=lr)
# TensorFlow
batch_size, lr, num_epochs = 256, 0.1, 10
loss = tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True)
trainer = tf.keras.optimizers.SGD(learning_rate=lr)
# PaddlePaddle
batch_size, lr, num_epochs = 256, 0.1, 10
loss = nn.CrossEntropyLoss(reduction='none')
trainer = paddle.optimizer.SGD(parameters=net.parameters(), learning_rate=lr)
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, trainer)

小结

  • 多层感知机在输出层和输入层之间增加一个或多个全连接隐藏层,并通过激活函数转换隐藏层的输出;
  • 没有激活函数时,多层架构会坍缩为等价的线性模型,因此非线性激活函数是发挥深度架构潜力的关键要素;
  • 常用的激活函数包括 ReLU 函数、sigmoid 函数和 tanh 函数,其中 ReLU 因其良好的导数行为与训练表现成为现代网络的默认选择。

练习

  1. 计算 pReLU 激活函数的导数。
  2. 证明一个仅使用 ReLU(或 pReLU)的多层感知机构造了一个连续的分段线性函数。
  3. 证明 tanh⁡(x)+1=2sigmoid⁡(2x)\operatorname{tanh}(x) + 1 = 2 \operatorname{sigmoid}(2x)。
  4. 假设我们有一个非线性单元,将它一次应用于一个小批量的数据。这会导致什么样的问题?

进一步探索:可继续阅读 数值稳定性和初始化(梯度消失与参数初始化)、dropout(隐藏层单元的暂退法),并参考 d2l 工具包 中 plot、train_ch3、load_data_fashion_mnist 等函数的实现,深入理解每行训练代码背后的细节。

登录后查看全文
d2l-zh