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

多层感知机(MLP),

现在使用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)

语句含义:

  1. import torch:导入 PyTorch 深度学习框架

  2. from torch import nn:从 PyTorch 导入神经网络模块

  3. from d2l import torch as d2l:导入 d2l 库中的 PyTorch 工具函数,并重命名为 d2l

  4. batch_size = 256:定义批量大小为 256,即每次训练 / 测试时处理 256 个样本

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

    • 调用 d2l 库的load_data_fashion_mnist函数加载 Fashion-MNIST 数据集
    • 该函数会返回两个迭代器:
      • train_iter:训练数据集的迭代器,用于模型训练
      • test_iter:测试数据集的迭代器,用于模型评估
    • 迭代器会按照指定的batch_size(256)来组织数据,每次迭代返回一个批次的图像和对应的标签

Fashion-MNIST 是一个常用的图像分类数据集,包含 10 个类别的衣物图片(如 T 恤、裤子、鞋子等),每个图片是 28×28 像素的灰度图。

初始化模型参数

Fashion-MNIST中的每个图像由 28×28=784个灰度像素值组成。 所有图像共分为10个类别。 忽略像素之间的空间结构, 我们可以将每个图像视为具有784个输入特征 和10个类的简单分类数据集。 首先,我们将实现一个具有单隐藏层的多层感知机, 它包含256个隐藏单元。 注意,我们可以将这两个变量都视为超参数。 通常,我们选择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]

参数维度定义

num_inputs, num_outputs, num_hiddens = 784, 10, 256
  • num_inputs = 784:输入特征数。因为 Fashion-MNIST 数据集的图像是 28×28 像素的灰度图,展平后为 784 个特征(28×28=784)。
  • num_outputs = 10:输出类别数。Fashion-MNIST 有 10 个衣物类别,因此输出层需要 10 个神经元。
  • num_hiddens = 256:隐藏层神经元数量。这里定义隐藏层有 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))
  • nn.Parameter:PyTorch 中用于标记可学习参数的类。被标记的参数会被自动纳入模型的参数列表,参与反向传播和优化。
  • torch.randn(shape):生成符合标准正态分布(均值 0,方差 1)的随机数,用于初始化权重。
  • * 0.01:将权重初始值缩小 100 倍。这是为了避免初始权重过大导致激活值(或梯度)爆炸,是一种常见的初始化技巧。
  • requires_grad=True:设置为True表示这些参数需要计算梯度,用于反向传播时更新。
  • 偏置b1b2初始化为 0,是一种简单有效的初始化方式。

参数列表整合

params = [W1, b1, W2, b2]
  • 将所有可学习参数(权重和偏置)整理到一个列表中,方便后续传入优化器(如 SGD)进行参数更新。

激活函数

为了确保我们对模型的细节了如指掌, 我们将实现ReLU激活函数, 而不是直接调用内置的relu函数。

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

  1. def relu(X):
    定义一个名为relu的函数,参数X是输入的张量(可以是单个数值、向量、矩阵或更高维的张量)。

  2. a = torch.zeros_like(X)
    使用torch.zeros_like(X)创建一个与输入X形状完全相同的张量a,且所有元素值均为 0。
    例如:如果X是形状为(3, 4)的张量,那么a也是(3, 4)的张量,每个元素都是 0。

  3. return torch.max(X, a)
    使用torch.max(X, a)返回Xa中对应位置元素的最大值。

    • X中的元素为正数时,max(X元素, 0)的结果是该正数本身;
    • X中的元素为负数或 0 时,max(X元素, 0)的结果是 0

模型

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

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

这段代码定义了一个多层感知机(MLP)的前向传播函数 net(X),实现了输入数据从输入层到输出层的计算过程。结合前面定义的网络参数(W1, b1, W2, b2)和 ReLU 激活函数,完整构建了一个两层神经网络的计算逻辑。

输入数据重塑

X = X.reshape((-1, num_inputs))
  • X 是输入的批量数据(如 Fashion-MNIST 的图像数据),原始形状通常为 (batch_size, 1, 28, 28)(批量大小 × 通道数 × 高度 × 宽度)。
  • reshape((-1, num_inputs)) 将输入数据展平为二维张量:
    • 第一个维度 -1 表示自动计算批量大小(保持与输入的批量数一致);
    • 第二个维度 num_inputs = 784(28×28),即每个图像展平为 784 个特征。
  • 结果形状为 (batch_size, 784),符合输入层对特征向量的要求。

隐藏层计算(含激活函数)

H = relu(X@W1 + b1)
  • X@W1:矩阵乘法,输入特征(X)与第一层权重(W1)相乘。
    • X 形状:(batch_size, 784)W1 形状:(784, 256),结果形状:(batch_size, 256)
  • + b1:加上第一层偏置(b1 形状:(256,)),通过广播机制与矩阵乘法结果相加,形状仍为 (batch_size, 256)
  • relu(...):对线性变换结果应用 ReLU 激活函数,引入非线性,输出隐藏层特征 H(形状:(batch_size, 256))。

输出层计算

return (H@W2 + b2)
  • H@W2:隐藏层特征(H)与第二层权重(W2)相乘。
    • H 形状:(batch_size, 256)W2 形状:(256, 10),结果形状:(batch_size, 10)
  • + b2:加上第二层偏置(b2 形状:(10,)),最终输出形状为 (batch_size, 10),对应 10 个类别的原始预测分数(logits)。

整体流程

输入图像 → 展平为 784 维特征 → 第一层线性变换(784→256)→ ReLU 激活 → 第二层线性变换(256→10)→ 输出 10 个类别的预测分数。

这个函数实现了神经网络的 “前向传播” 过程,即从输入到输出的计算链路,是模型进行预测和后续反向传播(计算梯度)的基础。

损失函数

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

作用是创建一个交叉熵损失函数(CrossEntropyLoss)的实例,其中参数 reduction='none' 是关键配置

  1. nn.CrossEntropyLoss:这是 PyTorch 内置的交叉熵损失函数,通常用于多分类任务。它实际上是 nn.LogSoftmax() 和 nn.NLLLoss()(负对数似然损失)的结合,会自动对输入进行 softmax 操作并计算损失。

  2. reduction='none':这是损失函数的归约方式参数,决定了输出损失的形式:

    • 当 reduction='none' 时,损失函数会返回每个样本的原始损失值,输出形状与输入样本的数量一致。
    • 其他常用取值:
      • reduction='mean'(默认):返回所有样本损失的平均值
      • reduction='sum':返回所有样本损失的总和

举例来说,如果有 5 个样本,使用 reduction='none' 会得到一个包含 5 个元素的张量,每个元素对应一个样本的损失;而默认的 'mean' 则会返回这 5 个损失的平均值(单个标量)

训练

多层感知机的训练过程与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)
d2l.predict_ch3(net, test_iter)

手动实现一个简单的多层感知机是很容易的。然而如果有大量的层,从零开始实现多层感知机会变得很麻烦(例如,要命名和记录模型的参数)

多层感知机的简洁实现

通过高级API更简洁地实现多层感知机

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

模型

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

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);
  1. 创建神经网络模型(net = nn.Sequential(...)
    使用nn.Sequential构建了一个简单的全连接神经网络,包含以下层:

    • nn.Flatten():将输入的二维张量(如 28×28 的图像)展平为一维向量(784 个元素),便于全连接层处理。
    • nn.Linear(784, 256):第一个全连接层,输入维度 784,输出维度 256(将输入映射到 256 维特征空间)。
    • nn.ReLU():激活函数,引入非线性变换,增强模型表达能力。
    • nn.Linear(256, 10):第二个全连接层,输入维度 256,输出维度 10(通常对应 10 个类别的分类任务,如 MNIST 手写数字识别)。
  2. 定义权重初始化函数(init_weights(m)
    这是一个自定义函数,用于初始化网络中的权重参数:

    • if type(m) == nn.Linear:仅对全连接层(nn.Linear)进行初始化(忽略其他层如FlattenReLU)。
    • nn.init.normal_(m.weight, std=0.01):使用均值为 0、标准差为 0.01 的正态分布初始化全连接层的权重(m.weight)。
  3. 应用初始化(net.apply(init_weights);

    • net.apply(init_weights):将自定义的init_weights函数递归应用到网络的所有子模块(即所有层),完成权重初始化。
    • 分号(;)用于抑制 PyTorch 在 Jupyter 等环境中的输出显示。

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

batch_size, lr, num_epochs = 256, 0.1, 10
loss = nn.CrossEntropyLoss(reduction='none')
trainer = torch.optim.SGD(net.parameters(), lr=lr)

train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, trainer)

  • 我们可以使用高级API更简洁地实现多层感知机。

  • 对于相同的分类问题,多层感知机的实现与softmax回归的实现相同,只是多层感知机的实现里增加了带有激活函数的隐藏层。

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

只有当模型真正发现了一种泛化模式时,才会作出有效的预测

我们的目标是发现某些模式, 这些模式捕捉到了我们训练集潜在总体的规律。 如果成功做到了这点,即使是对以前从未遇到过的个体, 模型也可以成功地评估风险。 如何发现可以泛化的模式是机器学习的根本问题.

28*28的灰色图像,如果每个像素可以取256个灰度值中的其中一个,那么有256^{784}个可能的图像

  • 图像总像素数 = 28 × 28 = 784 个像素
  • 每个像素有 256 种选择(0 到 255 的灰度值)
  • 所有可能的图像数量 = 256^784(256 的 784 次方)
 

这个数字极其庞大,远远超过了宇宙中已知粒子的数量(约 10^80),可以表示为:
256^784 = (2^8)^784 = 2^(8×784) = 2^6272 ≈ 10^1889

 

这意味着理论上存在的 28×28 灰度图像数量是一个约有 1890 位数字的巨大数值,绝大多数这样的图像都是完全随机、没有任何实际意义的噪点图案。

训练误差和泛化误差

训练误差(training error)是指, 模型在训练数据集上计算得到的误差。 

泛化误差(generalization error)是指, 模型应用在同样从原始样本的分布中抽取的无限多数据样本时,模型误差的期望

统计学习理论

当我们训练模型时,我们试图找到一个能够尽可能拟合训练数据的函数。 但是如果它执行地“太好了”,而不能对看不见的数据做到很好泛化,就会导致过拟合。 这种情况正是我们想要避免或控制的。 深度学习中有许多启发式的技术旨在防止过拟合。

模型复杂性

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

  1. 可调整参数的数量。当可调整参数的数量(有时称为自由度)很大时,模型往往更容易过拟合。

  2. 参数采用的值。当权重的取值范围较大时,模型可能更容易过拟合。

  3. 训练样本的数量。即使模型很简单,也很容易过拟合只包含一两个样本的数据集。而过拟合一个有数百万个样本的数据集则需要一个极其灵活的模型。

模型选择

在机器学习中,我们通常在评估几个候选模型后选择最终的模型。 这个过程叫做模型选择。 有时,需要进行比较的模型在本质上是完全不同的(比如,决策树与线性模型)。 又有时,我们需要比较不同的超参数设置下的同一类模型。

例如,训练多层感知机模型时,我们可能希望比较具有 不同数量的隐藏层、不同数量的隐藏单元以及不同的激活函数组合的模型。 为了确定候选模型中的最佳模型,我们通常会使用验证集。

验证集

原则上,在确定所有的超参数之前,不希望用到测试集。 如果在模型选择过程中使用测试数据,可能会有过拟合测试数据的风险,那就麻烦大了。 如果过拟合了训练数据,还可以在测试数据上的评估来判断过拟合。

因此,我们决不能依靠测试数据进行模型选择。 然而,我们也不能仅仅依靠训练数据来选择模型,因为我们无法估计训练数据的泛化误差。

在实际应用中,情况变得更加复杂。 虽然理想情况下我们只会使用测试数据一次, 以评估最好的模型或比较一些模型效果,但现实是测试数据很少在使用一次后被丢弃。 我们很少能有充足的数据来对每一轮实验采用全新测试集。

解决此问题的常见做法是将我们的数据分成三份, 除了训练和测试数据集之外,还增加一个验证数据集(validation dataset), 也叫验证集(validation set)。 但现实是验证数据和测试数据之间的边界模糊得令人担忧。 除非另有明确说明,实际上是在使用应该被正确地称为训练数据和验证数据的数据集, 并没有真正的测试数据集。 因此,书中每次实验报告的准确度都是验证集准确度,而不是测试集准确度。

K折交叉验证

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

K 折交叉验证(K-Fold Cross Validation)是机器学习中一种常用的模型评估方法,用于更稳健地估计模型的泛化能力,尤其适用于数据量有限的场景。

核心思想

将原始数据集随机分割为K 个大小相似的子数据集(称为 “折”,Fold),其中K-1 个子集作为训练集1 个子集作为验证集。重复此过程 K 次,每次使用不同的子集作为验证集,最终取 K 次评估结果的平均值作为模型的整体性能指标。

步骤详解
  1. 数据分割
    将数据集随机分成 K 个互斥的子集(Fold 1, Fold 2, ..., Fold K),各子集大小尽量相等。

  2. 模型训练与验证(重复 K 次)

    • 第 1 次:用 Fold 2~K 训练模型,用 Fold 1 验证,记录性能指标(如准确率、损失等)。
    • 第 2 次:用 Fold 1,3~K 训练模型,用 Fold 2 验证,记录指标。
    • ...
    • 第 K 次:用 Fold 1~K-1 训练模型,用 Fold K 验证,记录指标。
  3. 结果聚合
    计算 K 次验证结果的平均值(如平均准确率、平均损失),作为模型的最终评估结果。

为什么使用 K 折交叉验证?
  • 减少随机性:避免因单次划分训练 / 验证集的随机性导致评估结果波动。
  • 充分利用数据:每个样本既作为训练数据又作为验证数据,提高数据利用率。
  • 更稳健的评估:尤其适合小数据集,能更可靠地反映模型的泛化能力。
常见 K 值选择
  • 最常用K=5K=10(学术研究中 10 折交叉验证较为普遍)。
  • 当数据集较小时,可增大 K(如 K=10)以充分利用数据;数据集较大时,可减小 K(如 K=5)以降低计算成本。
特殊情况:留一法(Leave-One-Out)

当 K 等于样本总数 N 时,称为 “留一法”:每次留 1 个样本作为验证集,其余 N-1 个作为训练集,重复 N 次。

 
  • 优点:评估结果稳定,数据利用率最高。
  • 缺点:计算成本极高(需训练 N 个模型),仅适用于极小数据集。
示例场景

假设用 10 折交叉验证评估一个图像分类模型:

 
  • 将 1000 张图像分成 10 个折(每折 100 张)。
  • 每次用 900 张训练,100 张验证,得到 10 个准确率。
  • 最终模型准确率为这 10 个准确率的平均值。

欠拟合还是过拟合

当比较训练和验证误差时,要注意两种常见的情况。 首先,要注意这样的情况:训练误差和验证误差都很严重, 但它们之间仅有一点差距。 如果模型不能降低训练误差,这可能意味着模型过于简单(即表达能力不足), 无法捕获试图学习的模式。 此外,由于我们的训练和验证误差之间的泛化误差很小, 我们有理由相信可以用一个更复杂的模型降低训练误差。 这种现象被称为欠拟合(underfitting)

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

是否过拟合或欠拟合可能取决于模型复杂性可用训练数据集的大小, 这两个点将在下面进行讨论

训练误差(Training Error) 和验证误差(Validation Error) 是评估模型性能的两个核心指标,它们从不同角度反映了模型的学习效果和泛化能力。

1. 训练误差(Training Error)

  • 定义:模型在训练数据集上的误差(如损失值、错误率等)。
    简单说,就是模型对 “已见过” 的数据的预测效果。
  • 计算方式:用训练数据输入模型,计算预测结果与真实标签的差异(如交叉熵损失、均方误差等)。
  • 意义:反映模型对训练数据的拟合程度
    训练误差低,说明模型 “记住” 了训练数据的规律;但过低可能意味着过拟合(过度学习训练数据中的细节甚至噪声)。

2. 验证误差(Validation Error)

  • 定义:模型在验证数据集上的误差。
    验证集是从原始数据中划分出的、不参与训练的子集,用于评估模型对 “未见过” 数据的预测能力。
  • 计算方式:与训练误差类似,但数据来源是验证集(模型训练过程中未接触过)。
  • 意义:主要用于判断模型的泛化能力指导训练过程(如调整超参数、早停等)。
    验证误差低且接近训练误差,说明模型泛化能力好;若验证误差远高于训练误差,可能存在过拟合。

3. 两者的关系与模型状态判断

    4. 关键区别

    • 数据来源:训练误差来自训练集(参与模型参数更新),验证误差来自验证集(不参与参数更新)。
    • 用途:训练误差主要反映模型对训练数据的拟合程度;验证误差主要用于评估泛化能力和调整模型(如超参数优化)。

    总结

    训练误差和验证误差是模型训练过程中的 “双指标”:

     
    • 训练误差低是基础,但需结合验证误差判断模型是否真正 “学会” 了规律(而非死记硬背)。
    • 最终目标是让验证误差尽可能低且稳定,确保模型在实际应用(测试集)中表现良好。
    什么是泛化能力

    泛化能力(Generalization Ability) 是衡量模型核心价值的关键指标,它描述了模型从 “已知训练数据” 中学习到的规律,能否有效应用于 “未见过的新数据” 的能力。

    简单来说:泛化能力强的模型,不仅能 “看懂” 训练时用过的样本,更能 “举一反三” 地处理真实场景中从未接触过的数据;反之,泛化能力弱的模型,只能 “死记硬背” 训练数据,遇到新数据就会失效。

    模型复杂性

    为了说明一些关于过拟合和模型复杂性的经典直觉, 我们给出一个多项式的例子。 给定由单个特征x和对应实数标签y组成的训练数据, 我们试图找到下面的d阶多项式来估计标签y。

    这只是一个线性回归问题,我们的特征是^{}x^{}的幂给出的, 模型的权重是w_{i}给出的,偏置是w_{0}给出的 (因为对于所有的x^{}都有x^{0}=1)。 由于这只是一个线性回归问题,我们可以使用平方误差作为我们的损失函数

    数据集大小

    另一个重要因素是数据集的大小。 训练数据集中的样本越少,我们就越有可能(且更严重地)过拟合。 随着训练数据量的增加,泛化误差通常会减小。 此外,一般来说,更多的数据不会有什么坏处。 对于固定的任务和数据分布,模型复杂性和数据集大小之间通常存在关系。 给出更多的数据,我们可能会尝试拟合一个更复杂的模型。 能够拟合更复杂的模型可能是有益的。 如果没有足够的数据,简单的模型可能更有用。 对于许多任务,深度学习只有在有数千个训练样本时才优于线性模型。 从一定程度上来说,深度学习目前的生机要归功于 廉价存储、互联设备以及数字化经济带来的海量数据集。

    多项式回归

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

    • 什么是多项式回归?

    多项式回归(Polynomial Regression) 是一种通过构建多项式函数来拟合数据集中非线性关系的回归方法。它本质上是线性回归的扩展—— 虽然模型形式是多项式(非线性),但核心参数(多项式系数)仍通过线性方式求解,因此也被归为 “广义线性回归” 的范畴

    引入库

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

    生成数据集

    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)
    参数初始化
    max_degree = 20  # 多项式的最大阶数(最高到x^19)
    n_train, n_test = 100, 100  # 训练集和测试集的样本量,各100个
    • 设定了多项式的最高阶数为 20(后续会生成 0 到 19 次项的特征)
    • 划分了训练集(100 样本)和测试集(100 样本)
    定义真实模型的参数
    true_w = np.zeros(max_degree)  # 初始化参数为全0(长度为20)
    true_w[0:4] = np.array([5, 1.2, -3.4, 5.6])  # 仅前4个参数非0

    生成输入特征
    # 生成服从正态分布的原始特征(共200个样本,1个特征)
    features = np.random.normal(size=(n_train + n_test, 1))
    np.random.shuffle(features)  # 打乱顺序
    
    # 生成多项式特征:将原始特征x转换为[x^0, x^1, x^2, ..., x^19]
    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 = np.dot(poly_features, true_w)
    # 加入高斯噪声(均值0,标准差0.1)
    labels += np.random.normal(scale=0.1, size=labels.shape)

    • 先用真实模型(3 阶多项式)计算理想标签(无噪声)
    • 再加入随机噪声(模拟真实数据中的观测误差),最终得到带噪声的标签
    代码的核心目的

    构造一个 “低阶真实模型但生成高阶特征” 的场景,用于后续演示:

    • 当用高阶多项式(如 20 阶)拟合时,会 “过度学习” 噪声,导致过拟合
    • 当用合适阶数(如 3 阶)拟合时,能更好地泛化到测试集

    后续通常会将 poly_features 和 labels 按 n_train 拆分为训练集和测试集,用于多项式回归的实验。

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

    这段代码的作用是将 NumPy 数组转换为 PyTorch 张量(tensor),并展示了部分转换后的数据,以便后续使用 PyTorch 进行模型训练。

    对模型进行训练和测试

    首先实现一个函数来评估模型在给定数据集上的损失。

    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]

    函数说明:

    初始化评估指标:

    • net:需要评估的模型(如多项式回归模型)
    • data_iter:数据迭代器,包含待评估的数据集(如测试集),每次迭代返回一批样本 (X, y)(特征和标签)
    • loss:损失函数(如均方误差损失 MSELoss
    metric = d2l.Accumulator(2)  # 损失的总和, 样本数量
    • d2l.Accumulator(2) 是一个累加器(来自 d2l 库),用于存储两个值:
      • 第一个位置:所有样本的损失总和
      • 第二个位置:总样本数量

    遍历数据集计算损失:

    for X, y in data_iter:
        out = net(X)  # 模型预测:输入特征X,输出预测值out
        y = y.reshape(out.shape)  # 调整标签y的形状,使其与预测值out一致
        l = loss(out, y)  # 计算当前批次的损失
        metric.add(l.sum(), l.numel())  # 累加损失总和和样本数量
    • 对数据迭代器中的每一批数据:
      • 用模型 net 对特征 X 进行预测,得到 out
      • 调整标签 y 的形状以匹配 out(避免因形状不匹配导致的计算错误)
      • 用损失函数 loss 计算预测值 out 与真实标签 y 之间的损失 l
      • 将当前批次的损失总和(l.sum())和样本数量(l.numel(),即损失张量中元素的总数)累加到 metric 中

    返回平均损失:

    return metric[0] / metric[1]

    示例场景:

    如果用该函数评估一个多项式回归模型在测试集上的表现:

    • net 是训练好的多项式回归模型
    • data_iter 是测试集的迭代器
    • loss 是均方误差损失(nn.MSELoss()
    • 函数返回值即为测试集上的平均均方误差(MSE),值越小说明模型预测越准确。
    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())

    参数说明:

    def train(train_features, test_features, train_labels, test_labels, num_epochs=400):
    • train_features/test_features:训练集 / 测试集的多项式特征(已转换为 tensor)
    • train_labels/test_labels:训练集 / 测试集的标签(已转换为 tensor)
    • num_epochs:训练的轮次,默认 400 轮

    初始化损失函数和模型:

    loss = nn.MSELoss(reduction='none')  # 均方误差损失,保留每个样本的损失值
    input_shape = train_features.shape[-1]  # 特征维度(即多项式阶数,如20)
    # 定义线性模型:无偏置(因为多项式特征已包含常数项x^0)
    net = nn.Sequential(nn.Linear(input_shape, 1, bias=False))
    • 使用均方误差(MSE)作为损失函数,reduction='none' 表示不自动求平均,保留每个样本的损失
    • 模型是一个简单的线性层(nn.Linear),输入维度为特征数(如 20),输出维度为 1(预测值)
    • 不设偏置(bias=False),因为多项式特征中已包含 x0=1(常数项),相当于自带偏置项

    构建数据迭代器

    batch_size = min(10, train_labels.shape[0])  # 批次大小,最多10个样本
    # 构建训练集迭代器(打乱数据)
    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)
    • 将训练集和测试集转换为可批量迭代的数据格式(来自 d2l 库的load_array工具)
    • 标签形状调整为 (n,1),与模型输出形状匹配

    定义优化器:

    trainer = torch.optim.SGD(net.parameters(), lr=0.01)  # 随机梯度下降优化器,学习率0.01
    • 使用 SGD(随机梯度下降)优化模型参数,学习率设为 0.01

    初始化可视化工具:

    animator = d2l.Animator(xlabel='epoch', ylabel='loss', yscale='log',
                            xlim=[1, num_epochs], ylim=[1e-3, 1e2],
                            legend=['train', 'test'])
    • 初始化动画绘制工具(来自 d2l 库),用于实时绘制训练集和测试集的损失曲线
    • 采用对数刻度(yscale='log')更清晰地展示损失变化

    模型训练循环:

    for epoch in range(num_epochs):
        # 训练一轮模型(内部包含前向传播、损失计算、反向传播、参数更新)
        d2l.train_epoch_ch3(net, train_iter, loss, trainer)
        # 每20轮或第一轮时,记录并绘制损失
        if epoch == 0 or (epoch + 1) % 20 == 0:
            animator.add(epoch + 1, (evaluate_loss(net, train_iter, loss),
                                     evaluate_loss(net, test_iter, loss)))
    • 循环训练num_epochs轮,每轮调用d2l.train_epoch_ch3完成一次完整训练(前向计算→损失→反向传播→参数更新)
    • 定期(每 20 轮)计算训练集和测试集的平均损失(调用之前定义的evaluate_loss函数),并通过animator绘制到图表中

    输出训练后参数:

    print('weight:', net[0].weight.data.numpy())  # 打印训练得到的权重参数
    • 训练结束后,输出模型学到的权重参数(即多项式各项的系数),可与真实参数true_w对比,观察拟合效果

    核心作用:

    该函数实现了多项式回归模型的完整训练流程,通过监控训练集和测试集的损失变化,可直观观察模型是否过拟合(如训练损失低但测试损失高)。同时输出最终学到的参数,便于分析模型对真实规律的拟合程度。

    三阶多项式函数拟合(正常)

    # 从多项式特征中选择前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:])

    线性函数拟合(欠拟合)

    让我们再看看线性函数拟合,减少该模型的训练损失相对困难。 在最后一个迭代周期完成后,训练损失仍然很高。 当用来拟合非线性模式(如这里的三阶多项式函数)时,线性模型容易欠拟合。

    # 从多项式特征中选择前2个维度,即1和x
    train(poly_features[:n_train, :2], poly_features[n_train:, :2],
          labels[:n_train], labels[n_train:])

    高阶多项式函数拟合(过拟合)

    现在,尝试使用一个阶数过高的多项式来训练模型。 在这种情况下,没有足够的数据用于学到高阶系数应该具有接近于零的值。 因此,这个过于复杂的模型会轻易受到训练数据中噪声的影响。 虽然训练损失可以有效地降低,但测试损失仍然很高。 结果表明,复杂模型对数据造成了过拟合

    # 从多项式特征中选取所有维度
    train(poly_features[:n_train, :], poly_features[n_train:, :],
          labels[:n_train], labels[n_train:], num_epochs=1500)

    • 欠拟合是指模型无法继续减少训练误差。过拟合是指训练误差远小于验证误差。

    • 由于不能基于训练误差来估计泛化误差,因此简单地最小化训练误差并不一定意味着泛化误差的减小。机器学习模型需要注意防止过拟合,即防止泛化误差过大。

    • 验证集可以用于模型选择,但不能过于随意地使用它。

    • 我们应该选择一个复杂度适当的模型,避免使用数量不足的训练样本。

    权重衰减

    在深度学习中,权重衰减(Weight Decay) 是一种核心的正则化技术,目的是通过限制模型权重参数的数值大小,缓解过拟合问题,提升模型在未见过的测试数据上的泛化能力。它的本质是在模型的损失函数中加入一个 “权重惩罚项”,迫使优化器在更新权重时不仅要最小化预测误差,还要尽量让权重保持较小的值。

    它通常也被称为L_{2}正则化。 这项技术通过函数与零的距离来衡量函数的复杂度, 因为在所有函数
    f中,函数f=0(所有输入都得到值0) 在某种意义上是最简单的。但是我们应该如何精确地测量一个函数和零之间的距离呢? 没有一个正确的答案。 事实上,函数分析和巴拿赫空间理论的研究,都在致力于回答这个问题。

    权重衰减是深度学习中最基础、最常用的正则化手段之一,核心是通过 “惩罚大权重” 限制模型复杂度:

    • 本质:在损失函数中加入权重惩罚项,平衡 “预测精度” 和 “参数规模”。
    • 常用形式:L2 正则化(抑制大权重,不稀疏化)、L1 正则化(稀疏化权重,特征选择)。
    • 核心目标:缓解过拟合,提升模型泛化能力。

    权重衰退通过L2正则项使得模型参数不会过大,从而控制模型复杂度

    正则项权重是控制模型复杂度的超参数

    什么是正则化技术?

    在机器学习和深度学习中,正则化(Regularization) 是一类用于缓解过拟合、提升模型泛化能力的技术。其核心思想是通过限制模型的复杂度(如参数规模、函数形式),让模型在训练数据上 “不过度拟合细节”,从而在未见过的测试数据上表现更稳定。

    常见的正则化技术可分为传统机器学习正则化深度学习专用正则化,以下是主要方法的详细介绍:

    什么是MSE?

    高维线性回归

    通过一个简单的例子来演示权重衰减。

    %matplotlib inline
    import torch
    from torch import nn
    from d2l import torch as d2l

    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)

    代码含义:

    变量定义:

    n_train, n_test, num_inputs, batch_size = 20, 100, 200, 5

    n_train = 20:训练数据集的样本数量为 20(数据量较少,故意制造过拟合场景)
    n_test = 100:测试数据集的样本数量为 100
    num_inputs = 200:每个样本的特征数量为 200(特征维度高,进一步增加过拟合风险)
    batch_size = 5:每次迭代训练时,模型处理的样本批次大小为 5

    定义真实参数(用于生成标签)

    true_w, true_b = torch.ones((num_inputs, 1)) * 0.01, 0.05
    
    • true_w:真实的权重参数,形状为(200, 1)(200 个特征 ×1 个输出),每个元素值都是 0.01
    • true_b:真实的偏置参数,值为 0.05 (这两个参数是 “隐藏的真实值”,模型训练的目标就是尽可能逼近这两个参数)

    生成训练数据

    train_data = d2l.synthetic_data(true_w, true_b, n_train)
    
    • d2l.synthetic_data:是深度学习框架d2l(如《动手学深度学习》配套的d2l库)中的一个工具函数,用于生成线性回归的人工数据集
    • 生成逻辑:根据线性模型 \(y = X \cdot w + b + \epsilon\) 生成数据,其中:
      • X 是随机生成的特征矩阵(形状为(n_train, num_inputs)
      • \(\epsilon\) 是随机噪声(模拟真实数据中的误差)
      • 最终返回(X, y)元组,即训练特征和对应的标签

    构建训练数据迭代器

    train_iter = d2l.load_array(train_data, batch_size)
    
    • d2l.load_array:将生成的(X, y)数据转换成可迭代的批次数据
    • 作用:训练时,模型不会一次性处理全部数据,而是按batch_size=5分批读取,每次返回一个批次的特征和标签,适合梯度下降优化

    生成测试数据

    test_data = d2l.synthetic_data(true_w, true_b, n_test)
    

    • 与训练数据生成逻辑相同,但样本数量为 100,用于评估模型的泛化能力

    构建测试数据迭代器

    test_iter = d2l.load_array(test_data, batch_size, is_train=False)
    

    • train_iter类似,但is_train=False表示这是测试数据,不需要打乱顺序(训练数据默认会打乱)

    整体目的

    这段代码通过人工生成数据,模拟了一个特征维度高(200 维)但训练样本少(20 个) 的场景,非常适合用于演示过拟合现象以及正则化(如权重衰减)的作用。后续可以基于这些数据训练线性回归模型,观察模型在训练集和测试集上的表现差异。

    从零开始实现

    从头开始实现权重衰减,只需将L2的平方惩罚添加到原始目标函数中

    初始化模型参数

    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]

    可见线性回归那部分,权重和偏置的初始化定义

    代码含义:

    1. 权重参数 w 的初始化

      w = torch.normal(0, 1, size=(num_inputs, 1), requires_grad=True)
      

       
      • torch.normal(0, 1, ...):生成一个服从均值为 0、标准差为 1 的正态分布(标准正态分布) 的随机张量。
      • size=(num_inputs, 1):指定权重 w 的形状为 (特征数, 1),在之前的代码中 num_inputs=200,因此 w 是一个 200 行、1 列的二维张量(每个特征对应一个权重)。
      • requires_grad=True:设置该张量需要被 PyTorch 的自动求导系统追踪梯度,以便后续计算损失对 w 的导数(用于更新权重)。
    2. 偏置参数 b 的初始化

      python

      运行

      b = torch.zeros(1, requires_grad=True)
      

       
      • torch.zeros(1):生成一个包含单个元素 0 的张量(形状为 (1,) 的一维张量),作为初始偏置。
      • requires_grad=True:同样设置偏置 b 需要被追踪梯度,以便后续更新。

    定义L2范数惩罚

    def l2_penalty(w):
        return torch.sum(w.pow(2)) / 2

    代码含义:

    return torch.sum(w.pow(2)) / 2

    w.pow(2):对权重张量 w 中的每个元素进行平方操作(即计算w_{i}^{2} )。
    torch.sum(...):对所有元素的平方结果求和(即计算\sum w_{i}^{2} )。
    / 2:将总和除以 2,这是 L2 正则化的常见形式(主要是为了后续求导时简化计算,不影响正则化的本质效果)。

    定义训练代码实现

    下面的代码将模型拟合训练数据集,并在测试数据集上进行评估。 从 3节以来,线性网络和平方损失没有变化, 所以我们通过d2l.linregd2l.squared_loss导入它们。 唯一的变化是损失现在包括了惩罚项。

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

    代码含义:

    • net:通过匿名函数定义了线性回归模型,实际调用 d2l.linreg(X, w, b) 计算预测值(即 y_hat= Xw+b)。
    • loss:指定损失函数为 d2l.squared_loss(均方误差损失,即 MSE)。
    • d2l.Animator:是d2l库中的可视化工具,用于实时绘制训练 / 测试损失曲线:横轴为训练轮次(epochs),纵轴为损失值,使用对数刻度(yscale='log')更清晰展示损失变化。图例分别标记 “train”(训练损失)和 “test”(测试损失)

    • 每 5 轮训练后,调用 d2l.evaluate_loss 分别计算模型在训练集和测试集上的损失,并用 animator 绘制到图表中,便于观察过拟合 / 泛化情况。

    忽略正则化直接训练

    我们现在用lambd = 0禁用权重衰减后运行这个代码。 注意,这里训练误差有了减少,但测试误差没有减少, 这意味着出现了严重的过拟合。

    train(lambd=0)

    使用权重衰减

    下面,我们使用权重衰减来运行代码。 注意,在这里训练误差增大,但测试误差减小。 这正是我们期望从正则化中得到的效果。

    简洁实现

    由于权重衰减在神经网络优化中很常用, 深度学习框架为了便于我们使用权重衰减, 将权重衰减集成到优化算法中,以便与任何损失函数结合使用。 此外,这种集成还有计算上的好处, 允许在不增加任何额外的计算开销的情况下向算法中添加权重衰减。 由于更新的权重衰减部分仅依赖于每个参数的:当前值, 因此优化器必须至少接触每个参数一次。

    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
        # 偏置参数没有衰减
        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())

    代码含义:

    定义模型:

    net = nn.Sequential(nn.Linear(num_inputs, 1))
    1. 创建了一个简单的线性回归模型,包含一个线性层,输入特征数为num_inputs,输出为 1
    2. nn.Sequential() 是 PyTorch 中的一个容器类,用于按顺序包装多个神经网络层(或模块),形成一个序列化的网络结构。它的核心作用是: 按传入的顺序依次执行各个层的计算;简化网络定义的代码结构;自动处理层与层之间的连接(前一层的输出作为后一层的输入)
    3. 例如:
      net = nn.Sequential(
          nn.Linear(20, 64),    # 第一层:输入20维,输出64维
          nn.ReLU(),            # 激活函数层
          nn.Linear(64, 10)     # 第二层:输入64维,输出10维
      )

    初始化参数:

      

    for param in net.parameters():
        param.data.normal_()

    将模型参数(权重和偏置)初始化为正态分布

    定义损失函数:

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

    使用均方误差损失函数,reduction='none'表示不自动求平均,保留每个样本的损失值

    设置训练超参数:

    num_epochs, lr = 100, 0.003

    训练 100 个 epochs,学习率为 0.003
    定义优化器与权重衰减

    trainer = torch.optim.SGD([
        {"params":net[0].weight,'weight_decay': wd},
        {"params":net[0].bias}], lr=lr)

    使用随机梯度下降 (SGD) 优化器,对权重 (weight) 应用权重衰减(L2 正则化),衰减系数为参数wd,而偏置 (bias) 不应用衰减

    设置可视化工具:

    animator = d2l.Animator(xlabel='epochs', ylabel='loss', yscale='log',
                           xlim
    =[5, num_epochs], legend=['train', 'test'])

    使用 d2l 库的 Animator 工具可视化训练过程,以对数尺度显示损失

    训练循环:

    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()       # 更新参数
        # 每5个epoch可视化一次损失
        if (epoch + 1) % 5 == 0:
            animator.add(epoch + 1,
                         (d2l.evaluate_loss(net, train_iter, loss),
                          d2l.evaluate_loss(net, test_iter, loss)))

    正则化是处理过拟合的常用方法:在训练集的损失函数中加入惩罚项,以降低学习到的模型的复杂度。

    保持模型简单的一个特别的选择是使用
    惩罚的权重衰减。这会导致学习算法更新步骤中的权重衰减。

    权重衰减功能在深度学习框架的优化器中提供。

    在同一训练代码实现中,不同的参数集可以有不同的更新行为。

    暂退法

    通过惩罚权重的L_{2}范数来正则化统计模型的经典方法。 在概率角度看,我们可以通过以下论证来证明这一技术的合理性: 我们已经假设了一个先验,即权重的值取自均值为0的高斯分布。 更直观的是,我们希望模型深度挖掘特征,即将其权重分散到许多特征中, 而不是过于依赖少数潜在的虚假关联

    重新审视过拟合

    当面对更多的特征而样本不足时,线性模型往往会过拟合。 相反,当给出更多样本而不是特征,通常线性模型不会过拟合。 不幸的是,线性模型泛化的可靠性是有代价的。 简单地说,线性模型没有考虑到特征之间的交互作用。 对于每个特征,线性模型必须指定正的或负的权重,而忽略其他特征。

    扰动的稳健性

    简单性的另一个角度是平滑性,即函数不应该对其输入的微小变化敏感。 例如,当我们对图像进行分类时,我们预计向像素添加一些随机噪声应该是基本无影响的。 1995年,克里斯托弗·毕晓普证明了 具有输入噪声的训练等价于Tikhonov正则化 (Bishop, 1995)。 这项工作用数学证实了“要求函数光滑”和“要求函数对输入的随机噪声具有适应性”之间的联系。

    然后在2014年,斯里瓦斯塔瓦等人 (Srivastava et al., 2014) 就如何将毕晓普的想法应用于网络的内部层提出了一个想法: 在训练过程中,他们建议在计算后续层之前向网络的每一层注入噪声。 因为当训练一个有多层的深层网络时,注入噪声只会在输入-输出映射上增强平滑性。

    这个想法被称为暂退法(dropout)。

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

    神经元输出 h公式:

    实践中的暂退法

    带有1个隐藏层和5个隐藏单元的多层感知机。 当我们将暂退法应用到隐藏层,以的概率将隐藏单元置为零时, 结果可以看作一个只包含原始神经元子集的网络。 删除了h_{2}h_{5}, 因此输出的计算不再依赖于或,并且它们各自的梯度在执行反向传播时也会消失。 这样,输出层的计算不能过度依赖于h_{1}....h_{5}的任何一个元素。

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

    从零开始实现

    import torch
    from torch import nn
    from d2l import torch as d2l
    
    
    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()
        return mask * X / (1.0 - dropout)

    代码含义:

    • X:输入的张量(神经元的输出)
    • dropout:丢弃概率(0 到 1 之间的数值)
    • 确保dropout参数在合理范围内(0 到 1 之间),否则会抛出错误
    • dropout=1时,所有神经元都被丢弃,返回一个与X形状相同的零张量
    • dropout=0时,不丢弃任何神经元,直接返回输入X
    • torch.rand(X.shape)生成与X形状相同的张量,元素值在 [0,1) 区间均匀分布
    • > dropout将生成布尔张量,元素为True的概率是1-dropout(保留的概率)
    • .float()将布尔张量转换为浮点张量(True变为 1.0,False变为 0.0)
    • mask * X:将被丢弃的神经元输出置为 0
    • / (1.0 - dropout):对保留的神经元输出进行缩放,确保输出的期望值不变
    X= torch.arange(16, dtype = torch.float32).reshape((2, 8))
    print(X)
    print(dropout_layer(X, 0.))
    print(dropout_layer(X, 0.5))
    print(dropout_layer(X, 1.))

    tensor([[ 0.,  1.,  2.,  3.,  4.,  5.,  6.,  7.],
            [ 8.,  9., 10., 11., 12., 13., 14., 15.]])
    tensor([[ 0.,  1.,  2.,  3.,  4.,  5.,  6.,  7.],
            [ 8.,  9., 10., 11., 12., 13., 14., 15.]])
    tensor([[ 0.,  2.,  0.,  6.,  0.,  0.,  0., 14.],
            [16., 18.,  0., 22.,  0., 26., 28., 30.]])
    tensor([[0., 0., 0., 0., 0., 0., 0., 0.],
            [0., 0., 0., 0., 0., 0., 0., 0.]])

    定义模型参数

    引入的Fashion-MNIST数据集。 我们定义具有两个隐藏层的多层感知机,每个隐藏层包含256个单元

    num_inputs, num_outputs, num_hiddens1, num_hiddens2 = 784, 10, 256, 256

    定义模型

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

    dropout1, dropout2 = 0.2, 0.5
    
    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
    
    
    net = Net(num_inputs, num_outputs, num_hiddens1, num_hiddens2)

    代码含义:

    • 定义了两个 dropout 概率,分别用于第一个隐藏层和第二个隐藏层
    • 继承自 PyTorch 的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()  # ReLU激活函数
    • 接收参数包括输入特征数、输出类别数、两个隐藏层的神经元数
    • is_training参数用于控制是否启用 dropout(训练时启用,测试时禁用)
    • 定义了三个全连接层和一个 ReLU 激活函数

    前向传播方法

    def forward(self, X):
        # 第一层:全连接 + ReLU激活
        H1 = self.relu(self.lin1(X.reshape((-1, self.num_inputs))))
        # 训练模式下,在第一个隐藏层后应用dropout1
        if self.training == True:
            H1 = dropout_layer(H1, dropout1)
            
        # 第二层:全连接 + ReLU激活
        H2 = self.relu(self.lin2(H1))
        # 训练模式下,在第二个隐藏层后应用dropout2
        if self.training == True:
            H2 = dropout_layer(H2, dropout2)
            
        # 输出层:全连接(无激活函数,通常配合softmax或直接计算损失)
        out = self.lin3(H2)
        return out

    前向传播过程中:

    • 先将输入 X 调整为合适的形状
    • 经过第一层全连接和 ReLU 激活后,在训练模式下应用 dropout1
    • 经过第二层全连接和 ReLU 激活后,在训练模式下应用 dropout2
    • 最后通过第三层全连接得到输出

    核心特点:

    • 这是一个三层全连接神经网络(输入层→隐藏层 1→隐藏层 2→输出层)
    • 只在训练模式下启用 dropout,测试 / 推理时不使用 dropout
    • 两个隐藏层使用不同的 dropout 概率(0.2 和 0.5),通常深层可以使用更高的 dropout 概率
    • 通过 dropout 随机丢弃部分神经元,减少过拟合风险,提高模型泛化能力

    简洁实现

    对于深度学习框架的高级API,我们只需在每个全连接层之后添加一个Dropout层, 将暂退概率作为唯一的参数传递给它的构造函数。 在训练时,Dropout层将根据指定的暂退概率随机丢弃上一层的输出(相当于下一层的输入)。 在测试时,Dropout层仅传递数据。

    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)

    • 暂退法在前向传播过程中,计算每一内部层的同时丢弃一些神经元。

    • 暂退法可以避免过拟合,它通常与控制权重向量的维数和大小结合使用的。

    • 暂退法将活性值替换为具有期望值的随机变量。

    • 暂退法仅在训练期间使用。

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

    前向传播

    前向传播(forward propagation或forward pass) 指的是:按顺序(从输入层到输出层)计算和存储神经网络中每层的结果。

    如上图,从左向右进行计算是一种正方向上的传播,简称为正向传播(forward propagation)。

    关键特点:

    1. 确定性:给定输入和网络参数(权重、偏置),前向传播的结果是唯一的。
    2. 逐层传递:信息从输入层依次流向隐藏层,最终到达输出层,无反向反馈。
    3. 计算复杂度:与网络层数、每层神经元数量以及输入数据维度相关,深层网络的前向传播可能需要大量计算资源。

    前向传播的结果会与真实标签比较,计算损失函数(Loss Function),再通过反向传播调整网络参数以最小化损失,这一过程构成了神经网络的训练循环

    前向传播计算图

    下图是与上述简单网络相对应的计算图, 其中正方形表示变量,圆圈表示操作符。 左下角表示输入,右上角表示输出。 注意显示数据流的箭头方向主要是向右和向上的。

    计算图是将计算过程用图形表示出来。

    反向传播

    反向传播(backward propagation或backpropagation)指的是计算神经网络参数梯度的方法。

     简言之,该方法根据微积分中的链式规则,按相反的顺序从输出层到输入层遍历网络。 该算法存储了计算某些参数梯度时所需的任何中间变量(偏导数)。 假设我们有函数Y=f(X)Z=g(Y), 其中输入和输出X,Y,Z是任意形状的张量。 利用链式法则,我们可以计算Z关于X的导数。

    在这里,我们使用prod运算符在执行必要的操作(如换位和交换输入位置)后将其参数相乘。 对于向量,这很简单,它只是矩阵-矩阵乘法。 对于高维张量,我们使用适当的对应项。 运算符prod指代了所有的这些符号。

    单隐藏层简单网络的参数是 W^{(1)}W^{(2)}。 反向传播的目的是计算梯度\frac{\partial J}{\partial W^{(1)}}\frac{\partial J}{\partial W^{(2)}}。 为此,我们应用链式法则,依次计算每个中间变量和参数的梯度。 计算的顺序与前向传播中执行的顺序相反,因为我们需要从计算图的结果开始,并朝着参数的方向努力。第一步是计算目标函数J=L+s相对于损失项L和正则项s的梯度。

    接下来,我们根据链式法则计算目标函数关于输出层变量o的梯度:

    接下来,我们计算正则化项相对于两个参数的梯度:

    训练神经网络

    在训练神经网络时,前向传播和反向传播相互依赖。 对于前向传播,我们沿着依赖的方向遍历计算图并计算其路径上的所有变量。 然后将这些用于反向传播,其中计算顺序与计算图的相反。

    以上述简单网络为例:一方面,s=\frac{\lambda }{2}\left ( \left \|W ^{(1)} \right \|_{2}^{F} + \left \|W ^{(2)} \right \|_{2}^{F} \right )在前向传播期间计算正则项 取决于模型参数W^{(1)}W^{(2)}的当前值。 它们是由优化算法根据最近迭代的反向传播给出的。 另一方面,反向传播期间参数\frac{\partial J}{\partial W^{2}} = prod(\frac{\partial J}{\partial o},\frac{\partial o}{\partial W^{2}})+ prod(\frac{\partial J}{\partial s},\frac{\partial s}{\partial W^{2}})=\frac{\partial J}{\partial o}h^{T}+\lambda W^{(2)}的梯度计算, 取决于由前向传播给出的隐藏变量ℎ的当前值。

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

    • 前向传播在神经网络定义的计算图中按顺序计算和存储中间变量,它的顺序是从输入层到输出层。

    • 反向传播按相反的顺序(从输出层到输入层)计算和存储神经网络的中间变量和参数的梯度。

    • 在训练深度学习模型时,前向传播和反向传播是相互依赖的。

    • 训练比预测需要更多的内存

    Logo

    魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。

    更多推荐