深度学习—— 4 多层感知机

4 多层感知机

最简单的深度网络称为多层感知机。多层感知机由多层神经元组成, 每一层与它的上一层相连,从中接收输入; 同时每一层也与它的下一层相连,影响当前层的神经元。

4.1 多层感知机

多层感知机在输出层和输入层之间增加一个或多个全连接隐藏层,并通过激活函数转换隐藏层的输出。

4.1.1 隐藏层

4.1.1.1 线性模型的局限性

线性模型的一个主要局限性是它们只能表示输入和输出之间的简单关系。 如果我们想要模拟更复杂的非线性关系,就需要更复杂的模型。

4.1.1.2 在网络中加入隐藏层

我们可以通过在网络中加入一个或多个隐藏层来克服线性模型的限制, 使其能处理更普遍的函数关系类型。 要做到这一点,最简单的方法是将许多全连接层堆叠在一起。我们可以把前\(L-1\)层看作表示,把最后一层看作线性预测器。 这种架构通常称为多层感知机(multilayer perceptron),通常缩写为MLP。

图4.1:多层感知机

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

然而,具有全连接层的多层感知机的参数开销可能会高得令人望而却步。 即使在不改变输入或输出大小的情况下, 需要在参数节约和模型有效性之间进行权衡。

4.1.1.3 从线性到非线性

即使在添加隐藏层后,按照之前的逻辑,我们的输出仍然是输入的线性组合。 也就是说,即使我们有无限多的隐藏层,多层感知机仍然等同于线性模型。

原因如下:

\[ \begin{gathered} \mathbf{H} & = \mathbf{X} \mathbf{W}^{(1)} + \mathbf{b}^{(1)}, \\ \mathbf{O} & = \mathbf{H}\mathbf{W}^{(2)} + \mathbf{b}^{(2)}. \end{gathered} \tag{4.1} \]

其中\(X\)表示输入,\(H\)表示隐藏层的输出,\(O\)表示输出层的输出,\(\mathbf{W}^{(1)}\)\(\mathbf{W}^{(2)}\)是权重参数,\(\mathbf{b}^{(1)}\)\(\mathbf{b}^{(2)}\)是偏置参数。

上面的隐藏单元由输入\(X\)的仿射函数给出, 而输出\(O\)(softmax操作前)只是隐藏单元\(H\)的仿射函数。 仿射函数的仿射函数本身就是仿射函数, 所以这和线性模型没有区别。

引入关键因素: 在仿射变换之后对每个隐藏单元应用非线性的激活函数\(\sigma\)

\[ \begin{gathered} \mathbf{H} & = \sigma(\mathbf{X} \mathbf{W}^{(1)} + \mathbf{b}^{(1)}), \\ \mathbf{O} & = \mathbf{H}\mathbf{W}^{(2)} + \mathbf{b}^{(2)}.\\ \end{gathered} \tag{4.2} \]

4.1.1.4 通用近似定理

多层感知机可以通过隐藏神经元,捕捉到输入之间复杂的相互作用, 这些神经元依赖于每个输入的值。

4.1.2 激活函数

激活函数通过计算加权和并加上偏置来确定神经元是否应该被激活, 它们将输入信号转换为输出的可微运算。常用的激活函数包括ReLU函数、sigmoid函数和tanh函数

其函数和导数如下图所示:

图4.2:激活函数

绘图代码见附录A

4.1.2.1 ReLU函数(修正线性单元)

ReLU函数仅保留正元素并丢弃所有负元素,给定元素\(x\),ReLU函数被定义为该元素与\(0\)的最大值:

\[ \begin{equation}\begin{gathered} \operatorname{ReLU}(x) = \max(x, 0). \end{gathered}\end{equation} \tag{4.3} \]

使用ReLU的原因是,它求导表现得特别好:要么让参数消失,要么让参数通过。 这使得优化表现得更好,并且ReLU减轻了困扰以往神经网络的梯度消失问题

**注意,ReLU函数有许多变体,包括参数化ReLU(pReLU) 函数。 该变体为ReLU添加了一个线性项,因此即使参数是负的,某些信息仍然可以通过:*

\[ \begin{equation}\begin{gathered} \operatorname{pReLU}(x) = \max(0, x) + \alpha \min(0, x). \end{gathered}\end{equation} \tag{4.4} \]

4.1.2.2 sigmoid函数(挤压函数)

sigmoid函数将范围 \((-inf, inf)\) 中的任意输入压缩到区间 \((0, 1)\) 中的某个值:

\[ \begin{equation}\begin{gathered} \operatorname{sigmoid}(x) = \frac{1}{1 + \exp(-x)}. \end{gathered}\end{equation} \tag{4.5} \]

当我们想要将输出视作二元分类问题的概率时, sigmoid仍然被广泛用作输出单元上的激活函数 (sigmoid可以视为softmax的特例)。然而,sigmoid在隐藏层中已经较少使用, 它在大部分时候被更简单、更容易训练的ReLU所取代

4.1.2.3 tanh函数(双曲正切函数)

tanh函数也能将其输入压缩转换到区间\((-1, 1)\)上:

\[ \begin{equation}\begin{gathered} \operatorname{tanh}(x) = \frac{1 - \exp(-2x)}{1 + \exp(-2x)}. \end{gathered}\end{equation} \tag{4.6} \]

4.2 多层感知机的从零开始实现

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

# 4.2.3 激活函数


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

# 4.2.4 模型


def net(X):
    # 因为忽略了空间结构,所以需要使用reshape将每个二维图像转换为一个长度为num_inputs的向量
    X = X.reshape((-1, num_inputs))
    H = relu(X@W1 + b1)  # 这里“@”代表矩阵乘法
    return (H@W2 + b2)


if __name__ == "__main__":
    # 4.2 多层感知机的从零开始实现

    # 4.2.1 读取数据
    batch_size = 256
    train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)

    # 4.2.2 初始化模型参数
    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]

    # 4.2.3 激活函数

    # 4.2.4 模型

    # 4.2.5 损失函数
    loss = nn.CrossEntropyLoss(reduction='none') # reduction='none'表示直接返回每个样本的损失,其他有sum、mean

    # 4.2.6 训练
    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)

    # 4.2.7 预测
    d2l.predict_ch3(net, test_iter)

4.3 多层感知机的简洁实现

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

if __name__ == "__main__":
    # 4.3 多层感知机的简洁实现

    # 4.3.1 定义模型
    net = nn.Sequential(nn.Flatten(),         # 将输入的多维张量转换为一维张量
                        nn.Linear(784, 256),  # 全连接层
                        nn.ReLU(),            # 激活函数
                        nn.Linear(256, 10))   # 全连接层

    # 4.3.2 初始化模型参数
    def init_weights(m):
        if type(m) == nn.Linear:
            nn.init.normal_(m.weight, std=0.01)

    net.apply(init_weights)

    # 4.3.3 读取数据并训练模型
    batch_size, lr, num_epochs = 256, 0.1, 10
    train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
    loss = nn.CrossEntropyLoss()
    optimizer = torch.optim.SGD(net.parameters(), lr=lr)
    d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, optimizer)

    # 4.3.4 预测
    d2l.predict_ch3(net, test_iter)

4.4 模型选择、欠拟合和过拟合

如何发现可以泛化的模式是机器学习的根本问题。

4.4.1 训练误差和泛化误差

训练误差是指, 模型在训练数据集上计算得到的误差。 泛化误差是指, 模型应用在同样从原始样本的分布中抽取的无限多数据样本时,模型误差的期望。(实际中只能通过测试数据集来近似估计泛化误差)

几个倾向于影响模型泛化的因素:

  1. 可调整参数的数量。当可调整参数的数量(有时称为自由度)很大时,模型往往更容易过拟合。
  2. 参数采用的值。当权重的取值范围较大时,模型可能更容易过拟合。
  3. 训练样本的数量。即使模型很简单,也很容易过拟合只包含一两个样本的数据集。而过拟合一个有数百万个样本的数据集则需要一个极其灵活的模型。

4.4.2 模型选择

训练集: 用于训练模型的数据集 验证集: 用于调整模型超参数的数据集 测试集: 用于评估模型泛化误差的数据集

数据集 用途 参与训练 用来调参 用来评估最终性能
训练集 拿来训练模型 ✅ 是 ❌ 否 ❌ 否
验证集 拿来选模型/调参数 ❌ 否 ✅ 是 ❌ 否
测试集 拿来最终评估 ❌ 否 ❌ 否 ✅ 是

(但现实是验证数据和测试数据之间的边界模糊得令人担忧。)

当训练数据稀缺时,我们甚至可能无法提供足够的数据来构成一个合适的验证集。这个问题的一个流行的解决方案是采用\(K\)折交叉验证。 这里,原始训练数据被分成\(K\)个不重叠的子集。 然后执行\(K\)次模型训练和验证,每次在\(K-1\)个子集上进行训练, 并在剩余的一个子集(在该轮中没有用于训练的子集)上进行验证。 最后,通过对\(K\)次实验的结果取平均来估计训练和验证误差。

4.4.3 欠拟合和过拟合

欠拟合 训练误差和验证误差都很严重, 但它们之间仅有一点差距。 如果模型不能降低训练误差,这可能意味着模型过于简单(即表达能力不足), 无法捕获试图学习的模式。 此外,由于我们的训练和验证误差之间的泛化误差很小, 我们有理由相信可以用一个更复杂的模型降低训练误差。

过拟合 当我们的训练误差明显低于验证误差时要小心, 这表明严重的过拟合。 注意,过拟合并不总是一件坏事。 特别是在深度学习领域,众所周知, 最好的预测模型在训练数据上的表现往往比在保留(验证)数据上好得多。 最终,我们通常更关心验证误差,而不是训练误差和验证误差之间的差距。

是否过拟合或欠拟合可能取决于模型复杂性和可用训练数据集的大小

4.4.3.1 模型复杂性

比如一个线性回归模型的拟合(特征是\(x\)的幂给出的, 模型的权重是\(w_i\)给出的,偏置是\(w_0\)给出):

\[ \begin{equation}\begin{gathered} \hat{y}= \sum_{i=0}^d x^i w_i \end{gathered}\end{equation} \tag{4.7} \]

高阶多项式函数比低阶多项式函数复杂得多。 高阶多项式的参数较多,模型函数的选择范围较广。 因此在固定训练数据集的情况下, 高阶多项式函数相对于低阶多项式的训练误差应该始终更低(最坏也是相等)。 事实上,当数据样本包含了\(x\)的不同值时, 函数阶数等于数据样本数量的多项式函数可以完美拟合训练集。

模型的选择和拟合情况如下图所示:

图4.3:模型复杂度对欠拟合和过拟合的影响
4.4.3.2 数据集大小

训练数据集中的样本越少,我们就越有可能(且更严重地)过拟合。 随着训练数据量的增加,泛化误差通常会减小。

4.4.4 多项式回归

通过多项式拟合来探索这些概念

import math
import numpy as np
import torch
from torch import nn
from d2l import torch as d2l


def evaluate_loss(net, data_iter, loss):  # @save
    """评估给定数据集上模型的损失"""
    metric = d2l.Accumulator(2)  # 损失的总和,样本数量
    for X, y in data_iter:
        out = net(X)
        y = y.reshape(out.shape)
        l = loss(out, y)
        metric.add(l.sum(), l.numel())
    return metric[0] / metric[1]


def train(train_features, test_features, train_labels, test_labels,
          num_epochs=400):
    loss = nn.MSELoss(reduction='none')
    input_shape = train_features.shape[-1]
    # 不设置偏置,因为我们已经在多项式中实现了它
    net = nn.Sequential(nn.Linear(input_shape, 1, bias=False))
    batch_size = min(10, train_labels.shape[0])
    train_iter = d2l.load_array((train_features, train_labels.reshape(-1, 1)),
                                batch_size)
    test_iter = d2l.load_array((test_features, test_labels.reshape(-1, 1)),
                               batch_size, is_train=False)
    trainer = torch.optim.SGD(net.parameters(), lr=0.01)
    animator = d2l.Animator(xlabel='epoch', ylabel='loss', yscale='log',
                            xlim=[1, num_epochs], ylim=[1e-3, 1e2],
                            legend=['train', 'test'])
    for epoch in range(num_epochs):
        d2l.train_epoch_ch3(net, train_iter, loss, trainer)
        if epoch == 0 or (epoch + 1) % 20 == 0:
            animator.add(epoch + 1, (evaluate_loss(net, train_iter, loss),
                                     evaluate_loss(net, test_iter, loss)))
    print('weight:', net[0].weight.data.numpy())


if __name__ == '__main__':
    # 1. 生成数据集
    max_degree = 20  # 多项式的最大阶数
    n_train, n_test = 100, 100  # 训练和测试数据集大小
    true_w = np.zeros(max_degree)  # 分配大量的空间
    true_w[0:4] = np.array([5, 1.2, -3.4, 5.6])

    features = np.random.normal(size=(n_train + n_test, 1)) # 噪声项
    np.random.shuffle(features) # 打乱顺序
    poly_features = np.power(features, np.arange(max_degree).reshape(1, -1))
    for i in range(max_degree):
        poly_features[:, i] /= math.gamma(i + 1)  # gamma(n)=(n-1)!
    # labels的维度:(n_train+n_test,)
    labels = np.dot(poly_features, true_w)
    labels += np.random.normal(scale=0.1, size=labels.shape)

    # NumPy ndarray转换为tensor
    true_w, features, poly_features, labels = [torch.tensor(
        x, dtype=torch.float32) for x in [true_w, features, poly_features, labels]]

    # 2. 定义、训练和测试模型
    # 在外部函数中定义

    # 3.1  三阶多项式函数拟合(正常)
    # 从多项式特征中选择前4个维度,即1,x,x^2/2!,x^3/3!
    train(poly_features[:n_train, :4], poly_features[n_train:, :4],
          labels[:n_train], labels[n_train:])

    # 3.2 线性函数拟合(欠拟合)
    # 从多项式特征中选择前2个维度,即1和x
    train(poly_features[:n_train, :2], poly_features[n_train:, :2],
          labels[:n_train], labels[n_train:])
    # 减少该模型的训练损失相对困难,在最后一个迭代周期完成后,训练损失仍然很高。

    # 3.3 高阶多项式函数拟合(过拟合)
    # 从多项式特征中选取所有维度,即1,x,x^2/2!,...,x^19/19!
    train(poly_features[:n_train, :], poly_features[n_train:, :],
          labels[:n_train], labels[n_train:], num_epochs=1500)
    # 训练误差迅速降低,但测试损失仍然很高

数据集的生成:

\[ \begin{equation}\begin{gathered} y = 5 + 1.2x - 3.4\frac{x^2}{2!} + 5.6 \frac{x^3}{3!} + \epsilon \text{ where } \epsilon \sim \mathcal{N}(0, 0.1^2) \end{gathered}\end{equation} \tag{4.8} \]

在优化的过程中,我们通常希望避免非常大的梯度值或损失值。 这就是我们将特征从 \(x^i\) 调整为 \(\frac{x^i}{i!}\) 的原因, 这样可以避免很大的 \(i\) 带来的特别大的指数值。

4.4.4.1 使用线性函数拟合(欠拟合)

减少该模型的训练损失相对困难。 在最后一个迭代周期完成后,训练损失仍然很高。

图4.4:使用线性函数拟合
4.4.4.2 使用三阶多项式函数拟合(合适)

该模型能有效降低训练损失和测试损失。 学习到的模型参数也接近真实值。

图4.5:使用三阶多项式函数拟合
4.4.4.3 使用高阶多项式函数拟合(过拟合)

在这种情况下,没有足够的数据用于学到高阶系数应该具有接近于零的值。 因此,这个过于复杂的模型会轻易受到训练数据中噪声的影响。 虽然训练损失可以有效地降低,但测试损失仍然很高。

图4.6:使用高阶多项式函数拟合

4.5 权重衰减

虽然可以通过去收集更多的训练数据来缓解过拟合,但是……并不太可能 假设我们已经拥有尽可能多的高质量数据,我们便可以将重点放在正则化技术上。正则化是处理过拟合的常用方法:在训练集的损失函数中加入惩罚项,以降低学习到的模型的复杂度权重衰减(weight decay)是最广泛使用的正则化的技术之一, 它通常也被称为\(L_2\)正则化。

原来的损失函数:

\[ \begin{equation}\begin{gathered} L(\mathbf{w}, b) = \frac{1}{n}\sum_{i=1}^n \frac{1}{2}\left(\mathbf{w}^\top \mathbf{x}^{(i)} + b - y^{(i)}\right)^2 \end{gathered}\end{equation} \tag{4.9} \]

现在的损失函数(通过正则化常数\(\lambda\)来描述这种权衡, 这是一个非负超参数):

\[ \begin{equation}\begin{gathered} L(\mathbf{w}, b) + \frac{\lambda}{2} \|\mathbf{w}\|^2 \end{gathered}\end{equation} \tag{4.10} \]

4.5.1 高维线性回归

使用的数据集由下面公式生成:

\[ \begin{equation}\begin{gathered} y = 0.05 + \sum_{i = 1}^d 0.01 x_i + \epsilon \text{ where } \epsilon \sim \mathcal{N}(0, 0.01^2) \end{gathered}\end{equation} \tag{4.11} \]

为了使过拟合的效果更加明显,我们可以将问题的维数增加到 \(d=200\), 并使用一个只包含20个样本的小训练集。

4.5.2 从零开始实现

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

# 随机初始化模型参数
def init_params():
    w = torch.normal(0, 1, size=(num_inputs, 1), requires_grad=True)
    b = torch.zeros(1, requires_grad=True)
    return [w, b]

# L2范数惩罚
def l2_penalty(w):
    return torch.sum(w.pow(2)) / 2

# 定义训练
def train(lambd):
    w, b = init_params()
    net, loss = lambda X: d2l.linreg(X, w, b), d2l.squared_loss
    num_epochs, lr = 100, 0.003
    animator = d2l.Animator(xlabel='epochs', ylabel='loss', yscale='log',
                            xlim=[5, num_epochs], legend=['train', 'test'])
    for epoch in range(num_epochs):
        for X, y in train_iter:
            # 增加了L2范数惩罚项,
            # 广播机制使l2_penalty(w)成为一个长度为batch_size的向量
            l = loss(net(X), y) + lambd * l2_penalty(w)
            l.sum().backward()
            d2l.sgd([w, b], lr, batch_size)
        if (epoch + 1) % 5 == 0:
            animator.add(epoch + 1, (d2l.evaluate_loss(net, train_iter, loss),
                                     d2l.evaluate_loss(net, test_iter, loss)))
    print('w的L2范数是:', torch.norm(w).item())


if __name__ == "__main__":
    n_train, n_test, num_inputs, batch_size = 20, 100, 200, 5
    true_w, true_b = torch.ones((num_inputs, 1)) * 0.01, 0.05
    train_data = d2l.synthetic_data(true_w, true_b, n_train)
    train_iter = d2l.load_array(train_data, batch_size)
    test_data = d2l.synthetic_data(true_w, true_b, n_test)
    test_iter = d2l.load_array(test_data, batch_size, is_train=False)

    # 无正则化
    train(lambd=0)

    # 使用权重衰减
    train(lambd=3)
4.5.2.1 不使用正则化

这里训练误差有了减少,但测试误差没有减少, 这意味着出现了严重的过拟合

图4.7:不使用正则化
4.5.2.2 使用权重衰减

在这里训练误差增大,但测试误差减小。 这正是我们期望从正则化中得到的效果。

图4.8:使用权重衰减

4.5.3 简洁实现

深度学习框架为了便于我们使用权重衰减, 将权重衰减集成到优化算法中,以便与任何损失函数结合使用。

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


def train_concise(wd):
    net = nn.Sequential(nn.Linear(num_inputs, 1))
    for param in net.parameters():
        param.data.normal_()
    loss = nn.MSELoss(reduction='none')
    num_epochs, lr = 100, 0.003
    # 默认情况下,PyTorch同时衰减权重和偏移。 这里我们只为权重设置了weight_decay,所以偏置参数b不会衰减。
    trainer = torch.optim.SGD([
        {"params": net[0].weight, 'weight_decay': wd},
        {"params": net[0].bias}], lr=lr)
    animator = d2l.Animator(xlabel='epochs', ylabel='loss', yscale='log',
                            xlim=[5, num_epochs], legend=['train', 'test'])
    for epoch in range(num_epochs):
        for X, y in train_iter:
            trainer.zero_grad()
            l = loss(net(X), y)
            l.mean().backward()
            trainer.step()
        if (epoch + 1) % 5 == 0:
            animator.add(epoch + 1,
                         (d2l.evaluate_loss(net, train_iter, loss),
                          d2l.evaluate_loss(net, test_iter, loss)))
    print('w的L2范数:', net[0].weight.norm().item())


if __name__ == "__main__":
    n_train, n_test, num_inputs, batch_size = 20, 100, 200, 5
    true_w, true_b = torch.ones((num_inputs, 1)) * 0.01, 0.05
    train_data = d2l.synthetic_data(true_w, true_b, n_train)
    train_iter = d2l.load_array(train_data, batch_size)
    test_data = d2l.synthetic_data(true_w, true_b, n_test)
    test_iter = d2l.load_array(test_data, batch_size, is_train=False)

    # 无正则化
    train_concise(0)

    # 使用权重衰减
    train_concise(3)
4.5.3.1 不使用正则化
图4.9:不使用正则化
4.5.3.2 使用权重衰减
图4.10:使用权重衰减

4.6 暂退法

暂退法在前向传播过程中,计算每一内部层的同时注入噪声,这已经成为训练神经网络的常用技术。 这种方法之所以被称为暂退法,因为我们从表面上看是在训练过程中丢弃(drop out)一些神经元。 在整个训练过程的每一次迭代中,标准暂退法包括在计算下一层之前将当前层中的一些节点置零。

那么关键的挑战就是如何注入这种噪声。 一种想法是以一种无偏向(unbiased)的方式注入噪声。 这样在固定住其他层时,每一层的期望值等于没有噪音时的值。 在标准暂退法正则化中,通过按保留(未丢弃)的节点的分数进行规范化来消除每一层的偏差:

\[ \begin{split}\begin{aligned} h' = \begin{cases} 0 & \text{ 概率为 } p \\ \frac{h}{1-p} & \text{ 其他情况} \end{cases} \end{aligned}\end{split} \tag{4.12} \]

期望值保持不变,即 \(E[h'] = h\)

4.6.1 实践中的暂退法

当我们将暂退法应用到隐藏层,以\(p\)的概率将隐藏单元置为零时, 结果可以看作一个只包含原始神经元子集的网络。 如图4.11, 删除了\(h_2\)\(h_5\), 因此输出的计算不再依赖于\(h_2\)\(h_5\),并且它们各自的梯度在执行反向传播时也会消失。 这样,输出层的计算不能过度依赖于\(h_1,...,h_5\)的任何一个元素。

图4.11:dropout前后的多层感知机

通常,我们在测试时不用暂退法。 给定一个训练好的模型和一个新的样本,我们不会丢弃任何节点,因此不需要标准化。 然而也有一些例外:一些研究人员在测试时使用暂退法, 用于估计神经网络预测的“不确定性”: 如果通过许多不同的暂退法遮盖后得到的预测结果都是一致的,那么我们可以说网络发挥更稳定。

4.6.2 从零开始实现

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

# 定义一个dropout函数
def dropout_layer(X, dropout):
    assert 0 <= dropout <= 1
    # 在本情况中,所有元素都被丢弃
    if dropout == 1:
        return torch.zeros_like(X)
    # 在本情况中,所有元素都被保留
    if dropout == 0:
        return X
    mask = (torch.rand(X.shape) > dropout).float() # 生成一个mask
    return mask * X / (1.0 - dropout) # 按位mask

# 测试dropout函数
class Net(nn.Module):
    def __init__(self, num_inputs, num_outputs, num_hiddens1, num_hiddens2,
                 is_training = True):
        super(Net, self).__init__()
        self.num_inputs = num_inputs
        self.training = is_training
        self.lin1 = nn.Linear(num_inputs, num_hiddens1)
        self.lin2 = nn.Linear(num_hiddens1, num_hiddens2)
        self.lin3 = nn.Linear(num_hiddens2, num_outputs)
        self.relu = nn.ReLU()

    def forward(self, X):
        H1 = self.relu(self.lin1(X.reshape((-1, self.num_inputs))))
        # 只有在训练模型时才使用dropout
        if self.training == True:
            # 在第一个全连接层之后添加一个dropout层
            H1 = dropout_layer(H1, dropout1)
        H2 = self.relu(self.lin2(H1))
        if self.training == True:
            # 在第二个全连接层之后添加一个dropout层
            H2 = dropout_layer(H2, dropout2)
        out = self.lin3(H2)
        return out

if __name__ == "__main__":
    num_inputs, num_outputs, num_hiddens1, num_hiddens2 = 784, 10, 256, 256

    # 定义模型
    # 我们可以将暂退法应用于每个隐藏层的输出(在激活函数之后), 并且可以为每一层分别设置暂退概率: 常见的技巧是在靠近输入层的地方设置较低的暂退概率。 
    # 下面的模型将第一个和第二个隐藏层的暂退概率分别设置为0.2和0.5, 并且暂退法只在训练期间有效。
    dropout1, dropout2 = 0.2, 0.5
    net = Net(num_inputs, num_outputs, num_hiddens1, num_hiddens2)

    # 训练和测试模型
    num_epochs, lr, batch_size = 10, 0.5, 256
    loss = nn.CrossEntropyLoss(reduction='none')
    train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
    trainer = torch.optim.SGD(net.parameters(), lr=lr)
    d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, trainer)

4.6.3 简洁实现

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

net = nn.Sequential(nn.Flatten(),
                    nn.Linear(784, 256),
                    nn.ReLU(),
                    # 在第一个全连接层之后添加一个dropout层
                    nn.Dropout(dropout1),
                    nn.Linear(256, 256),
                    nn.ReLU(),
                    # 在第二个全连接层之后添加一个dropout层
                    nn.Dropout(dropout2),
                    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)

trainer = torch.optim.SGD(net.parameters(), lr=lr)
d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, trainer)

4.7 前向传播、反向传播和计算图

4.7.1 前向传播

按顺序(从输入层到输出层)计算存储神经网络中每层的结果。

\[ \begin{equation}\begin{gathered} \mathbf{z}= \mathbf{W}^{(1)} \mathbf{x}\\ \mathbf{h}= \phi(\mathbf{z})\\ \mathbf{o}= \mathbf{W}^{(2)} \mathbf{h}\\ \end{gathered}\end{equation} \tag{4.13} \]

损失函数\(l\),样本标签\(y\),则损失项,正则项,正则化损失:

\[ \begin{gathered} L = l(\mathbf{o}, y)\\ s = \frac{\lambda}{2} \left(\|\mathbf{W}^{(1)}\|_F^2 + \|\mathbf{W}^{(2)}\|_F^2\right)\\ J = L + s \end{gathered} \tag{4.14} \]

目标函数即为\(J\)

4.7.2 前向传播计算图

正方形表示变量,圆圈表示操作符,左下角表示输入,右上角表示输出。

图4.12:前向传播计算图

4.7.3 反向传播

反向传播指的是计算神经网络参数梯度的方法。 简言之,该方法根据微积分中的链式规则,按相反的顺序从输出层到输入层遍历网络。 该算法存储了计算某些参数梯度时所需的任何中间变量(偏导数)

在此例子中,即通过链式法则计算\(\partial J/\partial \mathbf{W}^{(1)}\)\(\partial J/\partial \mathbf{W}^{(2)}\)

\[ \begin{gathered} \frac{\partial J}{\partial \mathbf{W}^{(2)}}= \text{prod}\left(\frac{\partial J}{\partial \mathbf{o}}, \frac{\partial \mathbf{o}}{\partial \mathbf{W}^{(2)}}\right) + \text{prod}\left(\frac{\partial J}{\partial s}, \frac{\partial s}{\partial \mathbf{W}^{(2)}}\right)\\ \frac{\partial J}{\partial \mathbf{W}^{(1)}} = \text{prod}\left(\frac{\partial J}{\partial \mathbf{z}}, \frac{\partial \mathbf{z}}{\partial \mathbf{W}^{(1)}}\right) + \text{prod}\left(\frac{\partial J}{\partial s}, \frac{\partial s}{\partial \mathbf{W}^{(1)}}\right) \end{gathered} \tag{4.15} \]

4.7.4 内存需求

因此,在训练神经网络时,在初始化模型参数后, 我们交替使用前向传播和反向传播,利用反向传播给出的梯度来更新模型参数。 注意,反向传播重复利用前向传播中存储的中间值,以避免重复计算。 带来的影响之一是我们需要保留中间值,直到反向传播完成。 这也是训练比单纯的预测需要更多的内存(显存)的原因之一。 此外,这些中间值的大小与网络层的数量和批量的大小大致成正比。 因此,使用更大的批量来训练更深层次的网络更容易导致内存不足(out of memory)错误。

4.8 数值稳定性和模型初始化

初始化方案的选择在神经网络学习中起着举足轻重的作用, 它对保持数值稳定性至关重要。糟糕选择可能会导致我们在训练时遇到梯度爆炸或梯度消失。

  1. 需要用启发式的初始化方法来确保初始梯度既不太大也不太小。
  2. ReLU激活函数缓解了梯度消失问题,这样可以加速收敛。
  3. 随机初始化是保证在进行优化前打破对称性的关键。

4.8.1 梯度爆炸、梯度消失、打破对称性

梯度消失(gradient vanishing)参数更新过小,在每次更新时几乎不会移动,导致模型无法学习。根据链式求导法则,梯度的计算是由不同因子的连乘结果,只要其中某个因子的数值小于1那么随着网络的加深,后续的梯度一定是逐渐降低的(假设其他因子设置合理)。如果因子的数值够低,后续梯度甚至会出现消失现象,导致网络难以训练和收敛,这就是梯度消失的现象。例如: 当sigmoid函数的输入很大或是很小时,它的梯度都会消失。 梯度爆炸(gradient exploding)参数更新过大,破坏了模型的稳定收敛。同梯度消失的原理一样,梯度爆炸也是因为因子的数值大于1,在经过网络的不断加深,后续梯度出现爆炸的现象。 打破对称性:神经网络设计中的另一个问题是其参数化所固有的对称性。 如果我们将隐藏层的所有参数初始化为同一个常量,会发生什么? 在这种情况下,在前向传播期间,隐藏单元采用相同的输入和参数, 产生相同的激活,该激活被送到输出单元。 在反向传播期间,根据参数对输出单元进行微分, 得到一个梯度,其元素都取相同的值。 因此,在基于梯度的迭代之后, 隐藏单元的所有元素仍然采用相同的值。 这样的迭代永远不会打破对称性,我们可能永远也无法实现网络的表达能力。 隐藏层的行为就好像只有一个单元。 请注意,虽然小批量随机梯度下降不会打破这种对称性,但暂退法正则化可以。

4.8.2 初始化参数

  1. Pytorch的默认初始化
  2. Xavier初始化: 从均值为零,方差 \(\sigma^2 = \frac{2}{n_\mathrm{in} + n_\mathrm{out}}\) 的高斯分布中采样权重。

4.9 环境与分布偏移

分布偏移(Distribution Shift)是指模型在训练和测试数据集之间的数据分布不匹配的情况。这种不匹配可能导致模型在测试集上的表现下降,因为模型在训练时学习到的特征在测试时可能不再适用。

4.9.1 分布偏移的类型

  1. 协变量(特征)偏移:虽然输入的分布改变,但标签没有改变。例如猫狗识别,训练数据主要来自家养,而测试数据主要来自野外拍摄,此时输入数据的分布发生了变化,但标签(猫或狗)没有变化。
  2. 标签偏移:虽然输入的分布保持不变,但标签的分布改变。假设你训练了一个模型来预测某城市的天气,训练数据中晴天和雨天的比例是9:1,但测试数据中这个比例变成了1:1。输入的天气特征分布保持不变,但标签的分布发生了变化。
  3. 概念偏移:输入和输出之间的映射关系发生了变化,即特征和标签之间的关系变了。例如,有一个垃圾邮件分类器,训练数据中垃圾邮件的特征是某些关键词(如“免费”、“优惠”等),但随着时间推移,垃圾邮件发送者改变了策略,使用了新的关键词(如“促销”、“折扣”等)。

4.9.2 分布偏移纠正

可以采用加权经验风险最小化等方法。

注:真实风险是从真实分布中抽取的所有数据的总体损失的预期。然而,这个数据总体通常是无法获得的。经验风险是训练数据的平均损失,用于近似真实风险。在实践中,我们进行经验风险最小化。

4.10 实战Kaggle比赛:房价预测

import hashlib
import os
import tarfile
import zipfile
import requests
import numpy as np
import pandas as pd
import torch
from torch import nn
from d2l import torch as d2l


def download(name, cache_dir=os.path.join('..', 'data')):
    """下载一个DATA_HUB中的文件,返回本地文件名"""
    assert name in DATA_HUB, f"{name} 不存在于 {DATA_HUB}"
    url, sha1_hash = DATA_HUB[name]
    os.makedirs(cache_dir, exist_ok=True)
    fname = os.path.join(cache_dir, url.split('/')[-1])
    if os.path.exists(fname):
        sha1 = hashlib.sha1()  # 初始化一个sha1对象
        with open(fname, 'rb') as f:
            while True:
                data = f.read(1048576)  # 逐块读取文件内容,每次读取 1 MB
                if not data:
                    break
                sha1.update(data)  # sha1可以在历史哈希值基础上用当前文件内容更新哈希值
        if sha1.hexdigest() == sha1_hash:
            return fname  # 命中缓存
    print(f'正在从{url}下载{fname}...')
    r = requests.get(url, stream=True, verify=True)
    with open(fname, 'wb') as f:
        f.write(r.content)
    return fname


def download_extract(name, folder=None):
    """下载并解压zip/tar文件"""
    fname = download(name)
    base_dir = os.path.dirname(fname)
    data_dir, ext = os.path.splitext(fname)
    if ext == '.zip':
        fp = zipfile.ZipFile(fname, 'r')
    elif ext in ('.tar', '.gz'):
        fp = tarfile.open(fname, 'r')
    else:
        assert False, '只有zip/tar文件可以被解压缩'
    fp.extractall(base_dir)
    return os.path.join(base_dir, folder) if folder else data_dir


def download_all():
    """下载DATA_HUB中的所有文件"""
    for name in DATA_HUB:
        download(name)


def get_net():
    net = nn.Sequential(nn.Linear(in_features, 1))
    return net

# 采用相对误差而不是绝对误差来衡量误差


def log_rmse(net, features, labels):
    # 将预测值中小于1的部分设置为1,以避免在取对数时出现负无穷大的情况
    clipped_preds = torch.clamp(net(features), 1, float('inf'))
    rmse = torch.sqrt(loss(torch.log(clipped_preds), torch.log(labels)))
    return rmse.item()


def train(net, train_features, train_labels, test_features, test_labels,
          num_epochs, learning_rate, weight_decay, batch_size):
    train_ls, test_ls = [], []
    train_iter = d2l.load_array((train_features, train_labels), batch_size)
    # 这里使用的是Adam优化算法
    optimizer = torch.optim.Adam(net.parameters(),
                                 lr=learning_rate,
                                 weight_decay=weight_decay)
    for epoch in range(num_epochs):
        for X, y in train_iter:
            optimizer.zero_grad()
            l = loss(net(X), y)
            l.backward()
            optimizer.step()
        train_ls.append(log_rmse(net, train_features, train_labels))
        if test_labels is not None:
            test_ls.append(log_rmse(net, test_features, test_labels))
    return train_ls, test_ls

# 用K折交叉验证来评估模型


def get_k_fold_data(k, i, X, y):
    assert k > 1
    fold_size = X.shape[0] // k
    X_train, y_train = None, None
    for j in range(k):
        idx = slice(j * fold_size, (j + 1) * fold_size)
        X_part, y_part = X[idx, :], y[idx]
        if j == i:
            X_valid, y_valid = X_part, y_part
        elif X_train is None:
            X_train, y_train = X_part, y_part
        else:
            X_train = torch.cat([X_train, X_part], 0)
            y_train = torch.cat([y_train, y_part], 0)
    return X_train, y_train, X_valid, y_valid


def k_fold(k, X_train, y_train, num_epochs, learning_rate, weight_decay, batch_size):
    train_l_sum, valid_l_sum = 0, 0
    for i in range(k):
        data = get_k_fold_data(k, i, X_train, y_train)
        net = get_net()
        train_ls, valid_ls = train(net, *data, num_epochs, learning_rate,
                                   weight_decay, batch_size)
        train_l_sum += train_ls[-1]
        valid_l_sum += valid_ls[-1]
        if i == 0:
            d2l.plot(list(range(1, num_epochs + 1)), [train_ls, valid_ls],
                     xlabel='epoch', ylabel='rmse', xlim=[1, num_epochs],
                     legend=['train', 'valid'], yscale='log')
        print(f'折{i + 1},训练log rmse{float(train_ls[-1]):f}, '
              f'验证log rmse{float(valid_ls[-1]):f}')
    return train_l_sum / k, valid_l_sum / k


def train_and_pred(train_features, test_features, train_labels, test_data,
                   num_epochs, lr, weight_decay, batch_size):
    net = get_net()
    train_ls, _ = train(net, train_features, train_labels, None, None,
                        num_epochs, lr, weight_decay, batch_size)
    d2l.plot(np.arange(1, num_epochs + 1), [train_ls], xlabel='epoch',
             ylabel='log rmse', xlim=[1, num_epochs], yscale='log')
    print(f'训练log rmse:{float(train_ls[-1]):f}')
    # 将网络应用于测试集。
    preds = net(test_features).detach().numpy()
    # 将其重新格式化以导出到Kaggle
    test_data['SalePrice'] = pd.Series(preds.reshape(1, -1)[0])
    submission = pd.concat([test_data['Id'], test_data['SalePrice']], axis=1)
    submission.to_csv('submission.csv', index=False)


if __name__ == "__main__":
    DATA_HUB = dict()
    DATA_URL = 'http://d2l-data.s3-accelerate.amazonaws.com/'

    DATA_HUB['kaggle_house_train'] = (
        DATA_URL + 'kaggle_house_pred_train.csv', '585e9cc93e70b39160e7921475f9bcd7d31219ce')
    DATA_HUB['kaggle_house_test'] = (
        DATA_URL + 'kaggle_house_pred_test.csv', 'fa19780a7b011d9b009e8bff8e99922a8ee2eb90')

    #  划分训练集以创建验证集
    train_data = pd.read_csv(download('kaggle_house_train'))
    test_data = pd.read_csv(download('kaggle_house_test'))
    # 删除ID列以及标签列
    all_features = pd.concat((train_data.iloc[:, 1:-1], test_data.iloc[:, 1:]))

    # 数据预处理
    # 若无法获得测试数据,则可根据训练数据计算均值和标准差
    numeric_features = all_features.dtypes[all_features.dtypes !=
                                           'object'].index
    all_features[numeric_features] = all_features[numeric_features].apply(
        lambda x: (x - x.mean()) / (x.std()))
    # 标准化数据有两个原因: 方便优化;不知道哪些特征是相关的,避免让惩罚分配给一个特征的系数比分配给其他任何特征的系数更大。
    # 在标准化数据之后,所有均值消失,因此我们可以将缺失值设置为0
    all_features[numeric_features] = all_features[numeric_features].fillna(0)
    # 将离散数值转换为独热编码(get_dummies函数将分类变量转换为虚拟变量)
    # “Dummy_na=True”将“na”(缺失值)视为有效的特征值,并为其创建指示符特征
    all_features = pd.get_dummies(all_features, dummy_na=True)
    # 通过values属性得到NumPy格式的数据,并转换成张量
    n_train = train_data.shape[0]
    train_features = torch.tensor(
        all_features[:n_train].values, dtype=torch.float32)
    test_features = torch.tensor(
        all_features[n_train:].values, dtype=torch.float32)
    train_labels = torch.tensor(
        train_data.SalePrice.values.reshape(-1, 1), dtype=torch.float32)

    # 训练模型
    loss = nn.MSELoss()
    in_features = train_features.shape[1]
    k, num_epochs, lr, weight_decay, batch_size = 5, 100, 5, 0, 64
    # 将训练集划分为K份,然后使用第K份作为验证集,其余作为训练集
    train_l, valid_l = k_fold(
        k, train_features, train_labels, num_epochs, lr, weight_decay, batch_size)
    print(f'{k}-折验证: 平均训练log rmse: {float(train_l):f}, 'f'平均验证log rmse: {float(valid_l):f}')

    # 使用所有数据对其进行训练,然后预测并在Kaggle提交结果
    train_and_pred(train_features, test_features, train_labels,
                   test_data, num_epochs, lr, weight_decay, batch_size)

附录

附录A:激活函数绘图代码

import matplotlib as mpl
import matplotlib.pyplot as plt
import numpy as np

# 设置中文字体
mpl.rcParams['font.sans-serif'] = ['SimHei']
mpl.rcParams['axes.unicode_minus'] = False

# 定义激活函数


def relu(x):
    return np.maximum(0, x)


def sigmoid(x):
    return 1 / (1 + np.exp(-x))


def tanh(x):
    return np.tanh(x)


def prelu(x, alpha=0.2):
    return np.maximum(0, x) + alpha * np.minimum(0, x)

# 定义导数


def relu_derivative(x):
    return np.where(x > 0, 1, 0)


def sigmoid_derivative(x):
    s = sigmoid(x)
    return s * (1 - s)


def tanh_derivative(x):
    return 1 - np.tanh(x) ** 2


def prelu_derivative(x, alpha=0.2):
    return np.where(x > 0, 1, alpha)


# 创建数据点
x = np.linspace(-5, 5, 1000)

# 创建图形和子图 (2行4列,共8个子图)
fig, axes = plt.subplots(2, 4, figsize=(22, 10))

# 设置图表标题
fig.suptitle('激活函数及其导数对比', fontsize=20)

# 第一行:激活函数
# ReLU 激活函数
axes[0, 0].plot(x, relu(x), 'r-', linewidth=2)
axes[0, 0].set_title('ReLU 激活函数', fontsize=16)
axes[0, 0].set_xlabel('x', fontsize=14)
axes[0, 0].set_ylabel('ReLU(x)', fontsize=14)
axes[0, 0].grid(True, linestyle='--', alpha=0.7)
axes[0, 0].axhline(y=0, color='k', linestyle='-', alpha=0.3)
axes[0, 0].axvline(x=0, color='k', linestyle='-', alpha=0.3)

# Sigmoid 激活函数
axes[0, 1].plot(x, sigmoid(x), 'g-', linewidth=2)
axes[0, 1].set_title('Sigmoid 激活函数', fontsize=16)
axes[0, 1].set_xlabel('x', fontsize=14)
axes[0, 1].set_ylabel('Sigmoid(x)', fontsize=14)
axes[0, 1].grid(True, linestyle='--', alpha=0.7)
axes[0, 1].axhline(y=0, color='k', linestyle='-', alpha=0.3)
axes[0, 1].axvline(x=0, color='k', linestyle='-', alpha=0.3)

# Tanh 激活函数
axes[0, 2].plot(x, tanh(x), 'b-', linewidth=2)
axes[0, 2].set_title('Tanh 激活函数', fontsize=16)
axes[0, 2].set_xlabel('x', fontsize=14)
axes[0, 2].set_ylabel('Tanh(x)', fontsize=14)
axes[0, 2].grid(True, linestyle='--', alpha=0.7)
axes[0, 2].axhline(y=0, color='k', linestyle='-', alpha=0.3)
axes[0, 2].axvline(x=0, color='k', linestyle='-', alpha=0.3)

# pReLU 激活函数
axes[0, 3].plot(x, prelu(x), 'm-', linewidth=2)
axes[0, 3].set_title('pReLU 激活函数 (α=0.2)', fontsize=16)
axes[0, 3].set_xlabel('x', fontsize=14)
axes[0, 3].set_ylabel('pReLU(x)', fontsize=14)
axes[0, 3].grid(True, linestyle='--', alpha=0.7)
axes[0, 3].axhline(y=0, color='k', linestyle='-', alpha=0.3)
axes[0, 3].axvline(x=0, color='k', linestyle='-', alpha=0.3)

# 第二行:导数
# ReLU 导数
axes[1, 0].plot(x, relu_derivative(x), 'r-', linewidth=2)
axes[1, 0].set_title('ReLU 导数', fontsize=16)
axes[1, 0].set_xlabel('x', fontsize=14)
axes[1, 0].set_ylabel('ReLU\'(x)', fontsize=14)
axes[1, 0].grid(True, linestyle='--', alpha=0.7)
axes[1, 0].axhline(y=0, color='k', linestyle='-', alpha=0.3)
axes[1, 0].axvline(x=0, color='k', linestyle='-', alpha=0.3)

# Sigmoid 导数
axes[1, 1].plot(x, sigmoid_derivative(x), 'g-', linewidth=2)
axes[1, 1].set_title('Sigmoid 导数', fontsize=16)
axes[1, 1].set_xlabel('x', fontsize=14)
axes[1, 1].set_ylabel('Sigmoid\'(x)', fontsize=14)
axes[1, 1].grid(True, linestyle='--', alpha=0.7)
axes[1, 1].axhline(y=0, color='k', linestyle='-', alpha=0.3)
axes[1, 1].axvline(x=0, color='k', linestyle='-', alpha=0.3)

# Tanh 导数
axes[1, 2].plot(x, tanh_derivative(x), 'b-', linewidth=2)
axes[1, 2].set_title('Tanh 导数', fontsize=16)
axes[1, 2].set_xlabel('x', fontsize=14)
axes[1, 2].set_ylabel('Tanh\'(x)', fontsize=14)
axes[1, 2].grid(True, linestyle='--', alpha=0.7)
axes[1, 2].axhline(y=0, color='k', linestyle='-', alpha=0.3)
axes[1, 2].axvline(x=0, color='k', linestyle='-', alpha=0.3)

# pReLU 导数
axes[1, 3].plot(x, prelu_derivative(x), 'm-', linewidth=2)
axes[1, 3].set_title('pReLU 导数 (α=0.2)', fontsize=16)
axes[1, 3].set_xlabel('x', fontsize=14)
axes[1, 3].set_ylabel('pReLU\'(x)', fontsize=14)
axes[1, 3].grid(True, linestyle='--', alpha=0.7)
axes[1, 3].axhline(y=0, color='k', linestyle='-', alpha=0.3)
axes[1, 3].axvline(x=0, color='k', linestyle='-', alpha=0.3)

# 调整布局
plt.tight_layout()

# 保存图像
save_path = './source/_posts/DL/activation_functions.svg'
plt.savefig(save_path, format='svg', dpi=300, bbox_inches='tight')

print(f"图像已保存至: {save_path}")

# 显示图像
plt.show()

附录B:神经网络反向传播通俗解释

神经网络使用反向传播算法进行训练,核心是链式法则(链式求导),训练的目标是最小化损失函数。我们要解决的就是怎么调整模型的参数\(\omega\)才能让损失函数最小。

这就类似于\(y=f(x)\)的函数,我们想要找到一个最优的\(x\)使得\(y\)最小。这里的\(x\)就是神经网络的参数\(\omega\),而\(y\)就是损失函数\(L(\omega)\)。自然而然的引出了导数的概念,也就是梯度\(\frac{\partial L}{\partial \omega}\),它告诉我们在当前参数位置,损失函数的变化率。

由此引出以下几个问题:

1. 导数和梯度的关系是什么?

情况一:一元函数(只有一个输入)\(f(w) = w^2\)

  • 它的导数是:\(\frac{df}{dw} = 2w\)
  • 它的梯度也是:\(\nabla f(w) = (2w)\)

在这种情况下,梯度就等于导数本身,只是写成了一个一维向量。

情况二:多元函数(多个输入)\(f(w_1, w_2) = w_1^2 + w_2^2\)

没法用「导数」描述它的全部方向的变化。要用梯度:\(\nabla f = \left( \frac{\partial f}{\partial w_1}, \frac{\partial f}{\partial w_2} \right) = (2w_1, 2w_2)\)

情况 使用的概念 是否等价
一维函数 \(f(w)\) 导数 & 梯度 ✅ 等价(梯度是 1 维向量)
多维函数 \(f(w_1, w_2, \dots)\) 梯度(向量) ❌ 不等价,导数只看一个方向,梯度是全部方向

2. 为什么不直接让梯度为0就可以了?

问题 回答
为什么不直接让梯度为 0? 因为求不出来(无解析解),而且梯度为 0 不一定是最小值
那我们怎么做? 使用梯度下降,沿着负梯度方向一点点走,逐步逼近最小值
好处? 不需要显式解方程,适合高维、大规模、非线性优化问题

3. 有了梯度之后怎么更新参数?为什么这么更新?

有了梯度之后,我们用梯度的反方向来更新参数,因为梯度指出了损失函数增加最快的方向,所以反方向是下降最快的方向

参数更新的公式(梯度下降法)。设:

  • \(\mathbf{w}\):模型参数(可以是权重、偏置等)
  • \(\mathcal{L}(\mathbf{w})\):损失函数
  • \(\nabla \mathcal{L}(\mathbf{w})\):当前参数处的梯度
  • \(\eta\):学习率(learning rate,控制步子大小)

那么参数更新公式为:

\[ \mathbf{w}_{\text{new}} = \mathbf{w}_{\text{old}} - \eta \cdot \nabla \mathcal{L}(\mathbf{w}_{\text{old}}) \tag{B.1} \]

为什么是这个公式?

1️⃣ 梯度告诉我们什么?

梯度 \(\nabla \mathcal{L}(\mathbf{w})\) 是一个向量,表示在每一个维度上:

  • 增加参数 → 损失函数会上升多少

所以梯度的方向,就是“损失函数上升最快的方向”

2️⃣ 那我们想做什么?

我们想让损失函数 变小,而不是变大!

因此,我们要朝损失下降最快的方向移动,那就是梯度的负方向\(-\nabla \mathcal{L}(\mathbf{w})\)

3️⃣ 为什么还要乘一个 \(\eta\) 学习率?

梯度只是一个方向,我们还需要控制“走多远”:

  • 步子太大 → 可能错过最小值,甚至震荡或发散
  • 步子太小 → 学得太慢,收敛极慢

所以我们加一个系数 \(\eta\),就是 学习率,来控制更新的幅度:\(\text{更新量} = -\eta \cdot \nabla \mathcal{L}(\mathbf{w})\)

步骤 说明
1. 计算梯度 得到损失函数对参数的导数(每个方向的变化率)
2. 取反方向 因为梯度是上升方向,下降要走反方向
3. 乘以学习率 控制更新的幅度,避免步子太大或太小
4. 更新参数 \(\mathbf{w}_{\text{new}} = \mathbf{w}_{\text{old}} - \eta \cdot \nabla \mathcal{L}\)

附录C:为什么关注激活函数的导数?

举例:ReLU 的导数

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

  • 它的导数为:

    \[ \text{ReLU}'(x) = \begin{cases} 1, & x > 0 \\ 0, & x \leq 0 \end{cases} \tag{C.1} \]

这意味着只有当输入为正时,ReLU 才会让梯度流动——否则梯度为 0,神经元不再学习。

了解梯度消失或爆炸的问题

某些激活函数的导数可能导致梯度消失(如 Sigmoid)或爆炸(如 Softplus 的大输入)。分析导数可以帮助理解和选择更合适的激活函数,避免这些问题。

例子:Sigmoid 的导数

  • 最大值是 0.25,输入过大或过小时导数接近 0,导致梯度消失。

设计新激活函数

很多改进型激活函数(如 Leaky ReLU、ELU、Swish、Mish)都是基于对导数行为的深入理解后提出的。例如:

  • Leaky ReLU:解决 ReLU 的“神经元死亡”问题(即一旦输出为 0,就无法恢复),通过在负区间给一个小斜率(导数非零):

    \[ \text{Leaky ReLU}(x) = \begin{cases} x, & x > 0 \\ 0.01x, & x \leq 0 \end{cases} \tag{C.2} \]