《动手学深度学习》多层感知机(MLP)实战指南:隐藏层、激活函数与从零到简洁实现
《动手学深度学习》多层感知机(MLP)实战指南:隐藏层、激活函数与从零到简洁实现
多层感知机(Multilayer Perceptron,MLP)是深度神经网络的起点:它在输入层与输出层之间引入一个或多个全连接隐藏层,并借助非线性激活函数摆脱线性模型的表达能力限制。本文以《动手学深度学习》(d2l-zh)多层感知机章节为主线,系统讲解隐藏层的动机与数学形式、ReLU/sigmoid/tanh 三大激活函数的性质与导数、以及它们如何在 从零实现 与 简洁实现 中落地,并给出 MXNet、PyTorch、TensorFlow、PaddlePaddle 四种框架下的可运行代码,帮助你真正掌握 MLP 的构建原理与训练流程。
为什么线性模型不够:从单调性到特征交互
在进入隐藏层之前,需要先理解为什么“单个仿射变换 + softmax”的模型结构有本质局限。仿射变换是带偏置项的线性变换,而线性是一个很强的假设。
线性意味着单调
线性模型隐含了单调性假设:任何特征的增大都会导致模型输出增大(对应权重为正)或减小(对应权重为负)。这在某些场景下合理,例如预测申请人是否会偿还贷款时,收入越高通常越可能还款;但收入与还款概率的关系并非线性——收入从 0 增加到 5 万带来的还款可能性提升,远大于从 100 万增加到 105 万。一种缓解办法是对数据做预处理,例如用收入的对数作为特征,让线性变得更合理。
也可以轻易构造违反单调性的例子:根据体温预测死亡率时,高于 37℃ 的人温度越高风险越大,低于 37℃ 的人温度越高风险反而越低。此时可以改用“与 37℃ 的距离”作为特征来化解。
像素的上下文让预处理失效
但对猫狗图像分类来说,增加位置 处像素的强度,是否总是增加(或降低)图像描绘狗的似然?依赖线性模型相当于假设“区分猫和狗只需要评估单个像素的强度”,而在倒置图像仍保留类别标签的世界里,这种方法注定失败。
这里的关键区别在于:任何像素的重要性都以复杂方式取决于其上下文(周围像素的值)。虽然数据可能存在一种能考虑特征间相关交互的表示,在其之上构建线性模型是合适的,但我们无法手工计算这种表示。深度神经网络的做法,是用观测数据联合学习隐藏层表示、以及作用于该表示的线性预测器——这正是引入隐藏层的动机。
在网络中加入隐藏层:MLP 的结构与层数
克服线性模型限制最直接的方法,是将多个全连接层堆叠在一起:每一层都输出到上一层,直到生成最终输出。可以把前 层看作“表示”,把最后一层看作“线性预测器”。这种架构即多层感知机(multilayer perceptron),通常缩写为 MLP。
以上图为例:这个 MLP 有 4 个输入、3 个输出,隐藏层包含 5 个隐藏单元。由于输入层不涉及任何计算,产生输出只需实现隐藏层和输出层的计算,因此该网络的层数为 2。注意这两个层都是全连接的:每个输入都会影响隐藏层中的每个神经元,隐藏层中的每个神经元又影响输出层中的每个神经元。
同时要注意,全连接层的参数开销可能高得令人望而却步:即使不改变输入或输出大小,也存在参数节约与模型有效性之间的权衡。这一点对理解后续章节中卷积层(局部连接、权值共享)为何重要很有帮助。
从线性到非线性:为什么必须有激活函数
设 表示含 个样本、每个样本 个输入特征的小批量。对于具有 个隐藏单元的单隐藏层 MLP,用 表示隐藏层输出,即隐藏表示(hidden representations),也称隐藏层变量或隐藏变量。由于隐藏层和输出层都是全连接的,有隐藏层权重 、偏置 ,以及输出层权重 、偏置 。输出 的形式化计算为:
仿射的仿射仍是仿射
添加隐藏层后模型需要跟踪和更新额外参数,但在纯线性形式下我们没有任何收益:隐藏单元是输入的仿射函数,输出(softmax 操作前)只是隐藏单元的仿射函数,而仿射函数的仿射函数本身仍是仿射函数——之前的线性模型已经能表示任何仿射函数。可以形式化地证明,对任意权重值,只需合并隐藏层即可得到等价单层模型(参数 ,):
激活函数打破线性坍缩
为了发挥多层架构的潜力,需要在仿射变换之后对每个隐藏单元应用非线性的激活函数(activation function);激活函数的输出(如 )称为活性值(activations)。有了激活函数,多层感知机就不可能再退化(坍缩)成线性模型:
这里非线性函数 以按行方式作用于输入(每次计算一个样本);本节用于隐藏层的激活函数通常不仅按行操作,而是按元素操作——计算完每层的线性部分后,每个活性值可独立计算,无需查看其他隐藏单元的值,这对大多数激活函数都成立。要构建更通用的 MLP,可以继续堆叠隐藏层,例如 、,一层叠一层,从而产生更有表达能力的模型。
通用近似定理:能力与学习的区别
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),它实现简单,在各种预测任务中表现良好,提供了一种非常简单的非线性变换。给定元素 ,定义为该元素与 的最大值:
通俗地说,ReLU 通过将相应活性值设为 0,只保留正元素并丢弃所有负元素,其函数图像是分段线性的。四种框架下绘制 ReLU 曲线与导数的代码(输入范围 到 ,步长 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 添加一个线性项,即使参数为负某些信息仍能通过:
sigmoid:可微的阈值单元近似
sigmoid 函数将定义域 中的输入变换到区间 上的输出,因此常被称为挤压函数(squashing function)——它把 范围的任意输入压缩到 :
最早的神经网络试图建模“激发”或“不激发”的生物神经元,先驱可追溯到人工神经元的发明者麦卡洛克和皮茨,他们关注阈值单元:输入低于某阈值时取值 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 的导数有一个优美的自指形式:
绘制导数时注意:当输入为 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(双曲正切)函数将其输入压缩到区间 上:
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 的导数为:
当输入接近 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 中每个图像由 个灰度像素组成,共 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 因其良好的导数行为与训练表现成为现代网络的默认选择。
练习
- 计算 pReLU 激活函数的导数。
- 证明一个仅使用 ReLU(或 pReLU)的多层感知机构造了一个连续的分段线性函数。
- 证明 。
- 假设我们有一个非线性单元,将它一次应用于一个小批量的数据。这会导致什么样的问题?
进一步探索:可继续阅读 数值稳定性和初始化(梯度消失与参数初始化)、dropout(隐藏层单元的暂退法),并参考 d2l 工具包 中 plot、train_ch3、load_data_fashion_mnist 等函数的实现,深入理解每行训练代码背后的细节。