浅尝辄止

理论是灰色的,而生命之树常青。这里是@Dilettante258 的个人博客,用于记载和分享学习。

多层感知机

Dilettante258's avatar
| 0 views

最简单的深度网络称为多层感知机。多层感知机由多层神经元组成, 每一层与它的上一层相连,从中接收输入; 同时每一层也与它的下一层相连,影响当前层的神经元。 当我们训练容量较大的模型时,我们面临着过拟合的风险。

隐藏层

仿射变换, 它是一种带有偏置项的线性变换(y^=Xw+b\hat{\mathbf{y}}=Xw+b)。softmax回归的模型通过单个仿射变换将我们的输入直接映射到输出,然后进行softmax操作。如果我们的标签通过仿射变换后确实与我们的输入数据相关,那么这种方法确实足够了。 但是,仿射变换中的线性是一个很强的假设。

线性模型可能会出错

  • 线性模型的假设是,特征的增大会导致模型输出的增大(如果对应的权重为正),或者导致模型输出的减小(如果对应的权重为负)。
  • 有些情况下这个假设是合理的,比如预测贷款偿还概率,收入较高的申请人更有可能偿还贷款。
  • 但有些情况下这个假设是不合理的,比如根据体温预测死亡率,体温高于37摄氏度的人风险更大,低于37摄氏度的人风险更低。
  • 在某些问题中,简单的预处理可以使线性模型变得更合理,比如使用收入的对数作为特征。
  • 但对于一些复杂问题,线性模型的假设是不可行的,因为特征之间的相关交互作用很复杂,我们需要使用深度神经网络来学习隐藏层表示和应用于该表示的线性预测器。

在网络中加入隐藏层

我们可以通过在网络中加入一个或多个隐藏层来克服线性模型的限制, 使其能处理更普遍的函数关系类型。 要做到这一点,最简单的方法是将许多全连接层堆叠在一起。 每一层都输出到上面的层,直到生成最后的输出。多层感知机层与层之间是全连接的(全连接的意思就是:上一层的任何一个神经元与下一层的所有神经元都有连接)。 我们可以把前L1L−1层看作表示,把最后一层看作线性预测器。 这种架构通常称为多层感知机(multilayer perceptron),通常缩写为MLP

一个单隐藏层的多层感知机,具有5个隐藏单元

这个多层感知机有4个输入,3个输出,其隐藏层包含5个隐藏单元。 输入层不涉及任何计算,因此使用此网络产生输出只需要实现隐藏层和输出层的计算。 因此,这个多层感知机中的层数为2。 注意,这两个层都是全连接的。 每个输入都会影响隐藏层中的每个神经元, 而隐藏层中的每个神经元又会影响输出层中的每个神经元。

对于一个多层感知机,输入层接收输入数据并将其线性变换为隐藏层的输出。然后,在仿射变换之后对每个隐藏单元应用非线性的激活函数σσ(activation function)。,得到非线性变换后的结果。激活函数的输出(例如,σ()σ(⋅))被称为活性值(activations)。

本节应用于隐藏层的激活函数通常不仅按行操作,也按元素操作。 这意味着在计算每一层的线性部分之后,我们可以计算每个活性值, 而不需要查看其他隐藏单元所取的值。对于大多数激活函数都是这样。

通用近似定理

多层感知机可以通过隐藏神经元,捕捉到输入之间复杂的相互作用, 这些神经元依赖于每个输入的值。 我们可以很容易地设计隐藏节点来执行任意计算。 例如,在一对输入上进行基本逻辑操作,多层感知机是通用近似器。 即使是网络只有一个隐藏层,给定足够的神经元和正确的权重, 我们可以对任意函数建模,尽管实际中学习该函数是很困难的。 神经网络有点像C语言。 C语言和任何其他现代编程语言一样,能够表达任何可计算的程序。 但实际上,想出一个符合规范的程序才是最困难的部分。

而且,虽然一个单隐层网络能学习任何函数, 但并不意味着我们应该尝试使用单隐藏层网络来解决所有问题。 事实上,通过使用更深(而不是更广)的网络,我们可以更容易地逼近许多函数。 我们将在后面的章节中进行更细致的讨论。

激活函数

激活函数(activation function)通过计算加权和并加上偏置来确定神经元是否应该被激活, 它们将输入信号转换为输出的可微运算。 大多数激活函数都是非线性的。 由于激活函数是深度学习的基础,下面简要介绍一些常见的激活函数。

%matplotlib inline
import torch
from d2l import torch as d2l

ReLU函数

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

ReLU(x)=max(x,0).ReLU(x)=max(x,0).

通俗地说,ReLU函数通过将相应的活性值设为0,仅保留正元素并丢弃所有负元素。 为了直观感受一下,我们可以画出函数的曲线图。 正如从图中所看到,激活函数是分段线性的。

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))

../_images/output_mlp_76f463_21_0.svg

当输入为负时,ReLU函数的导数为0,而当输入为正时,ReLU函数的导数为1。 注意,当输入值精确等于0时,ReLU函数不可导。 在此时,我们默认使用左侧的导数,即当输入为0时导数为0。 我们可以忽略这种情况,因为输入可能永远都不会是0。 下面我们绘制ReLU函数的导数。

y.backward(torch.ones_like(x), retain_graph=True)
d2l.plot(x.detach(), x.grad, 'x', 'grad of relu', figsize=(5, 2.5))

../_images/output_mlp_76f463_36_0.svg

使用ReLU的原因是,它求导表现得特别好:要么让参数消失,要么让参数通过。 这使得优化表现得更好,并且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函数

对于一个定义域在 R 中的输入, sigmoid函数将输入变换为区间(0, 1)上的输出。 因此,sigmoid通常称为挤压函数(squashing function): 它将范围(-inf, inf)中的任意输入压缩到区间(0, 1)中的某个值:

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

在最早的神经网络中,科学家们感兴趣的是对“激发”或“不激发”的生物神经元进行建模。 因此,这一领域的先驱可以一直追溯到人工神经元的发明者麦卡洛克和皮茨,他们专注于阈值单元。 阈值单元在其输入低于某个阈值时取值0,当输入超过阈值时取值1。

当人们逐渐关注到到基于梯度的学习时, sigmoid函数是一个自然的选择,因为它是一个平滑的、可微的阈值单元近似。 当我们想要将输出视作二元分类问题的概率时, sigmoid仍然被广泛用作输出单元上的激活函数 (sigmoid可以视为softmax的特例)。 然而,sigmoid在隐藏层中已经较少使用, 它在大部分时候被更简单、更容易训练的ReLU所取代。 在后面关于循环神经网络的章节中,我们将描述利用sigmoid单元来控制时序信息流的架构。

下面,我们绘制sigmoid函数。 注意,当输入接近0时,sigmoid函数接近线性变换。

y = torch.sigmoid(x)
d2l.plot(x.detach(), y.detach(), 'x', 'sigmoid(x)', figsize=(5, 2.5))

../_images/output_mlp_76f463_51_0.svg

sigmoid函数的导数为下面的公式:

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

sigmoid函数的导数图像如下所示。 注意,当输入为0时,sigmoid函数的导数达到最大值0.25; 而输入在任一方向上越远离0点时,导数越接近0。

# 清除以前的梯度
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))

../_images/output_mlp_76f463_66_0.svg

tanh函数

与sigmoid函数类似, tanh(双曲正切)函数也能将其输入压缩转换到区间(-1, 1)上。 tanh函数的公式如下:

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

下面我们绘制tanh函数。 注意,当输入在0附近时,tanh函数接近线性变换。 函数的形状类似于sigmoid函数, 不同的是tanh函数关于坐标系原点中心对称。

y = torch.tanh(x)
d2l.plot(x.detach(), y.detach(), 'x', 'tanh(x)', figsize=(5, 2.5))

../_images/output_mlp_76f463_81_0.svg

tanh函数的导数是:

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

tanh函数的导数图像如下所示。 当输入接近0时,tanh函数的导数接近最大值1。 与我们在sigmoid函数图像中看到的类似, 输入在任一方向上越远离0点,导数越接近0。

# 清除以前的梯度
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))

../_images/output_mlp_76f463_96_0.svg

从零开始实现

导入Fashion-MNIST图像分类数据集。

import torch
from torch import nn
from d2l import torch as d2l

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

初始化模型参数

Fashion-MNIST数据集包含10个类别的图像,每个图像由784个灰度像素值组成。我们将这些图像视为具有784个输入特征和10个类别的分类数据集。

我们将实现一个具有单隐藏层的多层感知机,其中隐藏层包含256个隐藏单元。这两个变量可以被视为超参数。

通常,我们选择2的若干次幂作为层的宽度。 因为内存在硬件中的分配和寻址方式,这么做往往可以在计算上更高效。

注意,对于每一层我们都要记录一个权重矩阵和一个偏置向量。同时,要为损失关于这些参数的梯度分配内存。

num_inputs, num_outputs, num_hiddens = 784, 10, 256
# 有784个输入节点,10个输出节点,隐藏层有256个节点
W1 = nn.Parameter(torch.randn(
    num_inputs, num_hiddens, requires_grad=True) * 0.01)
# 权重初始化为从均值为0、标准差为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函数。

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

模型

因为我们忽略了空间结构, 所以我们使用reshape将每个二维图像转换为一个长度为num_inputs的向量。 只需几行代码就可以实现我们的模型。

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

损失函数

由于我们已经从零实现过softmax函数, 因此在这里我们直接使用高级API中的内置函数来计算softmax和交叉熵损失。

loss = nn.CrossEntropyLoss(reduction='none')

训练

幸运的是,多层感知机的训练过程与softmax回归的训练过程完全相同。 可以直接调用d2l包的train_ch3函数, 将迭代周期数设置为10,并将学习率设置为0.1.

num_epochs, lr = 10, 0.1
updater = torch.optim.SGD(params, lr=lr) # 小批量随机梯度下降算法
d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, updater)

../_images/output_mlp-scratch_106d07_81_0.svg

为了对学习到的模型进行评估,我们将在一些测试数据上应用这个模型。

d2l.predict_ch3(net, test_iter)

../_images/output_mlp-scratch_106d07_96_0.svg

多层感知机的简洁实现

import torch
from torch import nn
from d2l import torch as d2l

模型

与上一节相比, 唯一的区别是我们添加了2个全连接层(之前我们只添加了1个全连接层)。 第一层是隐藏层,它包含256个隐藏单元,并使用了ReLU激活函数。 第二层是输出层。

net = nn.Sequential(nn.Flatten(), # 一个层,用于将输入的二维图像数据(28x28)展平为一维向量(784)
                    nn.Linear(784, 256), # 线性层,将输入的784维向量映射为256维向量
                    nn.ReLU(), # 激活函数层,将线性层的输出进行非线性变换,增加模型的表达能力
                    nn.Linear(256, 10)) # 线性层,将256维向量映射为10维向量,用于表示10个类别的概率分布

def init_weights(m): # 权重初始化函数,用于初始化模型中的线性层的权重
    if type(m) == nn.Linear:
        nn.init.normal_(m.weight, std=0.01) # 权重初始化函数,用于初始化模型中的线性层的权重

net.apply(init_weights); # 将权重初始化函数应用到模型的所有线性层上。

训练过程的实现与我们实现softmax回归时完全相同, 这种模块化设计使我们能够将与模型架构有关的内容独立出来。

batch_size, lr, num_epochs = 256, 0.1, 10
# batch_size表示每个批次中的样本数量。
# lr表示学习率,用于控制模型参数更新的步长。
# num_epochs表示训练的轮数。
loss = nn.CrossEntropyLoss(reduction='none') # 交叉熵损失函数计算样本的损失
# 参数reduction='none'表示不对损失进行求和或平均,而是返回每个样本的损失值。
# 每个训练周期结束后对每个样本的损失进行个别处理,然后使用这些值进行可视化或其他分析。
trainer = torch.optim.SGD(net.parameters(), lr=lr) # 优化器,用于更新模型的参数。
# 使用随机梯度下降(torch.optim.SGD)作为优化器,将模型的参数传递给优化器进行更新,学习率为lr。
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
# train_iter和test_iter是数据迭代器,用于加载训练集和测试集的数据。
d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, trainer)
# 调用函数进行模型训练。

../_images/output_mlp-concise_f87756_36_0.svg