动手学深度学习(李沐)PyTorch 第 3 章 线性神经网络

3.1 线性回归

线性回归是对n维输入的加权,外加偏差

线性回归可以看作是单层神经网络

回归问题中最常用的损失函数是平方误差函数。 平方误差可以定义为以下公式:

常数1/2不会带来本质的差别,但这样在形式上稍微简单一些 (因为当我们对损失函数求导后常数系数为1)

由于平方误差函数中的二次方项, 估计值y-hat(i)和观测值y(i)之间较大的差异将导致更大的损失。 为了度量模型在整个数据集上的质量 ,我们需计算在训练集个样本上的损失均值(也等价于求和)。

线性回归刚好是一个很简单的优化问题。 与我们将在本书中所讲到的其他大部分模型不同,线性回归的解可以用一个公式简单地表达出来, 这类解叫作解析解(analytical solution)。

首先,我们将偏置b合并到参数w中,合并方法是在包含所有参数的矩阵中附加一列。 我们的预测问题是最小化。 这在损失平面上只有一个临界点,这个临界点对应于整个区域的损失极小点。 将损失关于w的导数设为0,得到解析解:

基础优化算法

一般而言,损失函数很复杂,参数空间庞大,我们不知道它在何处能取得最小值。而通过巧妙地使用梯度来寻找函数最小值(或者尽可能小的值)的方法就是梯度法

梯度指示的方向是各点处的函数值减小最多的方向。因此,无法保证梯度所指的方向就是函数的最小值或者真正应该前进的方向。实际上,在复杂的函数中,梯度指示的方向基本上都不是函数值最小处。

梯度下降通过不断沿着反梯度方向更新参数求解

函数的极小值、最小值以及被称为鞍点(saddle point)的地方,梯度为0。极小值是局部最小值,也就是限定在某个范围内的最小值。虽然梯度法是要寻找梯度为0的地方,但是那个地方不一定就是最小值(也有可能是极小值或者鞍点)。此外,当函数很复杂且呈扁平状时,学习可能会进入一个(几乎)平坦的地区,陷入被称为"学习高原"的无法前进的停滞期。

虽然梯度的方向并不一定指向最小值,但沿着它的方向能够最大限度地减小函数的值。因此,在寻找函数的最小值(或者尽可能小的值)的位置的任务中,要以梯度的信息为线索,决定前进的方向。

此时梯度法就派上用场了。在梯度法中,函数的取值从当前位置沿着梯度方向前进一定距离,然后在新的地方重新求梯度,再沿着新梯度方向前进,如此反复,不断地沿梯度方向前进。像这样,通过不断地沿梯度方向前进,逐渐减小函数值的过程就是梯度法(主要是指梯度下降法)(gradient method)。梯度法是解决机器学习中最优化问题的常用方法,特别是在神经网络的学习中经常被使用

式(4.7)的 η表示更新量,在神经网络的学习中,称为学习率(learning rate)。学习率决定在一次学习中,应该学习多少,以及在多大程度上更新参数。式(4.7)是表示更新一次的式子,这个步骤会反复执行。也就是说,每一步都按式(4.7)更新变量的值,通过反复执行此步骤,逐渐减小函数值。

学习率需要事先确定为某个值,比如0.01或0.001。一般而言,这个值过大或过小,都无法抵达一个"好的位置"。在神经网络的学习中,一般会一边改变学习率的值,一边确认学习是否正确进行了

如果学习率太小:会导致多次计算梯度,而计算梯度是很贵的

如果学习率太大:会导致一直在震荡,并没有真正的在下降

像学习率这样的参数称为超参数 。这是一种和神经网络的参数(权重和偏置)性质不同的参数。相对于神经网络的权重参数是通过训练数据和学习算法自动获得的,学习率这样的超参数则是人工设定的。一般来说,超参数需要尝试多个值,以便找到一种可以使学习顺利

进行的设定。

1.小批量随机梯度下降

小批量随机梯度下降是深度学习默认的求解算法

3.2 线性回归的从零开始实现

在这一节中,我们将从零开始实现整个方法, 包括数据流水线、模型、损失函数和小批量随机梯度下降优化器。

在这一节中,我们将只使用张量和自动求导。 在之后的章节中,我们会充分利用深度学习框架的优势,介绍更简洁的实现方式。

python 复制代码
%matplotlib inline # IPython 魔法命令,它的作用是将生成的图表直接嵌入到 Notebook 的单元格输出中,而不是在弹出的窗口中显示。
import random
import torch
from d2l import torch as d2l

生成数据集

我们将根据带有噪声的线性模型构造一个人造数据集。 我们的任务是使用这个有限样本的数据集来恢复这个模型的参数。 我们将使用低维数据,这样可以很容易地将其可视化。 在下面的代码中,我们生成一个包含1000个样本的数据集, 每个样本包含从标准正态分布中采样的2个特征。 我们的合成数据集是一个矩阵

我们使用线性模型参数w=[2, -3.4]^T、b=4.2 和噪声项生成数据集及其标签:

可以视为模型预测和标签时的潜在观测误差 。 在这里我们认为标准假设成立,即

服从均值为0的正态分布。 为了简化问题,我们将标准差设为0.01。 下面的代码生成合成数据集。

python 复制代码
def synthetic_data(w, b, num_examples):  #@save
    """生成y=Xw+b+噪声"""
    X = torch.normal(0, 1, (num_examples, len(w))) # 生成服从正态分布的特征矩阵X
    y = torch.matmul(X, w) + b # 计算线性模型y=Xw+b
    y += torch.normal(0, 0.01, y.shape) # 添加噪声,噪声服从均值为0,标准差为0.01的正态分布
    return X, y.reshape((-1, 1))  # 返回特征矩阵X和标签向量y

true_w = torch.tensor([2, -3.4])
true_b = 4.2
features, labels = synthetic_data(true_w, true_b, 1000)

输入参数:

  • w:模型的权重,形状为向量,表示线性模型中每个特征的权重。
  • b:模型的偏置项(截距),这是一个标量。
  • num_examples:生成数据的样本数量。

X = torch.normal(0, 1, (num_examples, len(w))):生成一个形状为 (num_examples, len(w)) 的特征矩阵 X,其中每个元素都从均值为0、标准差为1的正态分布中采样。

y = torch.matmul(X, w) + b:根据线性模型 y = Xw + b 计算标签。torch.matmul 是矩阵乘法 ,w 是权重,b 是偏置。

torch.matmul -> Matrix product of two tensors.

X 的维度是1000×2,而 w 的维度是 2,可以看作是 12的行向量。在进行矩阵乘法时,torch.matmul(X, w) 会将 w 广播(broadcasting)成适合的维度进行相乘即21。

y += torch.normal(0, 0.01, y.shape):给生成的标签 y 添加服从均值为0,标准差为0.01的正态分布噪声,模拟现实中的数据不完美性。

y.reshape((-1, 1)):将一维张量 y 转换成二维张量 ,使其形状为 (num_examples, 1)。(因为-1是占位符,会自动计算)

在y.reshape((-1, 1)前,y.shape是torch.Size([1000]),也就是11000的行向量(tensor中的一维张量);而y.reshape((-1, 1)的shape是torch.Size([1000,1]),也就是10001的列向量(实际上在tensor中不算向量,实际上是二维的张量)

注意,features中的每一行都包含一个二维数据样本, labels中的每一行都包含一维标签值(一个标量)。

python 复制代码
print('features:', features[0],'\nlabel:', labels[0])

通过生成第二个特征features[:, 1]和labels的散点图, 可以直观观察到两者之间的线性关系。

python 复制代码
d2l.set_figsize()
d2l.plt.scatter(features[:, 1].detach().numpy(), labels.detach().numpy(), 1);

读取数据集

回想一下,训练模型时要对数据集进行遍历,每次抽取一小批量样本,并使用它们来更新我们的模型。 由于这个过程是训练机器学习算法的基础,所以有必要定义一个函数, 该函数能打乱数据集中的样本并以小批量方式获取数据

在下面的代码中,我们定义一个data_iter函数, 该函数接收批量大小、特征矩阵和标签向量作为输入,生成大小为batch_size的小批量。 每个小批量包含一组特征和标签。

python 复制代码
def data_iter(batch_size, features, labels):
    num_examples = len(features)
    indices = list(range(num_examples)) # 创建一个列表,内容是从 0 到 num_examples-1 的索引值。这些索引对应于数据集中每个样本的位置
    # 这些样本是随机读取的,没有特定的顺序
    random.shuffle(indices) # 随机打乱索引顺序。这样每次迭代时,样本的顺序都是随机的,没有特定顺序。
    for i in range(0, num_examples, batch_size):
    	# 每次从打乱后的索引列表中取出当前批量对应的索引,并将这些索引转换成pytorch张量
        batch_indices = torch.tensor(
            indices[i: min(i + batch_size, num_examples)])
        # 使用 yield 返回当前批量的特征和标签。yield 是 Python 的生成器函数,可以逐批返回数据而不是一次性返回所有数据,节省内存。
        yield features[batch_indices], labels[batch_indices]

yield就是 return 返回一个值,并且记住这个返回的位置,下次迭代就从这个位置后开始。

python中yield的用法详解

Python yield 使用浅析

通常,我们利用GPU并行运算 的优势,处理合理大小的"小批量"。 每个样本都可以并行地进行模型计算,且每个样本损失函数的梯度也可以被并行计算。 GPU可以在处理几百个样本时,所花费的时间不比处理一个样本时多太多

我们直观感受一下小批量运算:读取第一个小批量数据样本并打印。 每个批量的特征维度显示批量大小和输入特征数。 同样的,批量的标签形状与batch_size相等。

python 复制代码
batch_size = 10

for X, y in data_iter(batch_size, features, labels):
    print(X, '\n', y)
    break

每次 for X, y in data_iter(batch_size, features, labels) 调用时,它不会一次性返回所有数据,而是分批次生成并返回数据。

当我们运行迭代时,我们会连续地获得不同的小批量,直至遍历完整个数据集。 上面实现的迭代对教学来说很好,但它的执行效率很低,可能会在实际问题上陷入麻烦。 例如,它要求我们将所有数据加载到内存中,并执行大量的随机内存访问。 在深度学习框架中实现的内置迭代器效率要高得多, 它可以处理存储在文件中的数据和数据流提供的数据。

初始化模型参数

在我们开始用小批量随机梯度下降优化我们的模型参数之前, 我们需要先有一些参数。 在下面的代码中,我们通过从均值为0、标准差为0.01的正态分布中采样随机数来初始化权重, 并将偏置初始化为0

python 复制代码
w = torch.normal(0, 0.01, size=(2,1), requires_grad=True)
b = torch.zeros(1, requires_grad=True)

这行代码使用了 PyTorch 来创建一个可训练的标量张量,并启用了自动求导功能。具体解析如下:

  • torch.zeros(1):创建一个包含一个元素的张量,并将其初始化为零。torch.zeros(1) 返回一个形状为 (1,) 的张量,即一个包含单个元素的零张量
  • requires_grad=True:这个参数告诉 PyTorch 要对这个张量进行自动求导 。这意味着 PyTorch 会跟踪对该张量的操作,从而可以计算该张量的梯度。这个功能在优化过程中非常重要,因为它允许你对参数进行反向传播(即计算梯度并更新参数),从而训练模型。

在初始化参数之后,我们的任务是更新这些参数,直到这些参数足够拟合我们的数据。 每次更新都需要计算损失函数关于模型参数的梯度。 有了这个梯度,我们就可以向减小损失的方向更新每个参数 。 因为手动计算梯度很枯燥而且容易出错,所以没有人会手动计算梯度。 我们使用 2.5节中引入的自动微分来计算梯度

定义模型

接下来,我们必须定义模型,将模型的输入和参数同模型的输出关联起来。 回想一下,要计算线性模型的输出, 我们只需计算输入特征X和模型权重w的矩阵-向量乘法后加上偏置b。 注意,上面的Xw是一个向量,而b是一个标量。 回想一下 2.1.3节中描述的广播机制 : 当我们用一个向量加一个标量时,标量会被加到向量的每个分量上

python 复制代码
def linreg(X, w, b):  #@save
    """线性回归模型"""
    return torch.matmul(X, w) + b

定义损失函数

因为需要计算损失函数的梯度,所以我们应该先定义损失函数。 这里我们使用 3.1节中描述的平方损失函数 。 在实现中,我们需要将真实值y的形状转换为和预测值y_hat的形状相同

python 复制代码
def squared_loss(y_hat, y):  #@save
    """均方损失"""
    return (y_hat - y.reshape(y_hat.shape)) ** 2 / 2

虽然它们两个元素个数是一样的,但可能一个是向量,另一个是行向量或者列向量

注意这里损失函数中没有求均值,而是在下面的sgd中batch_size求均值了

定义优化算法

正如我们在 3.1节中讨论的,线性回归有解析解。 尽管线性回归有解析解,但本书中的其他模型却没有。 这里我们介绍小批量随机梯度下降

在每一步中,使用从数据集中随机抽取的一个小批量,然后根据参数计算损失的梯度。 接下来,朝着减少损失的方向更新我们的参数。 下面的函数实现小批量随机梯度下降更新。 该函数接受模型参数集合、学习速率和批量大小作为输入。每 一步更新的大小由学习速率lr决定。 因为我们计算的损失是一个批量样本的总和,所以我们用批量大小(batch_size) 来规范化步长,这样步长大小就不会取决于我们对批量大小的选择。

python 复制代码
def sgd(params, lr, batch_size):  #@save
    """小批量随机梯度下降"""
    with torch.no_grad():
        for param in params:
            param -= lr * param.grad / batch_size
            param.grad.zero_()

小批量随机梯度下降(SGD,Stochastic Gradient Descent)优化算法

接受三个参数:

  • params:模型的参数列表,通常是一个包含多个张量的迭代器,每个张量代表模型的一个参数。
  • lr:学习率(Learning Rate),即更新参数时所乘的步长大小。学习率控制着每次参数更新的幅度。
  • batch_size:小批量的大小,即每次使用多少个样本进行参数更新。通常与梯度计算相关联。

with torch.no_grad()::

使用 torch.no_grad() 上下文管理器,表示在这个代码块中不需要计算梯度 。这样可以避免在参数更新过程中误计算梯度,因为此时只需要更新参数,而不需要记录操作以供反向传播。

param -= lr * param.grad / batch_size:对每个参数执行参数更新:

  • param.grad:表示该参数的梯度。这个梯度通常是通过反向传播计算出来的,表示损失函数对该参数的偏导数
  • lr * param.grad / batch_size:首先将梯度乘以学习率,然后除以批量大小 。这相当于将梯度的平均值(针对当前批量)乘以学习率 ,得到一个步长,沿着梯度方向减小参数值,从而最小化损失函数。
  • param -=:用来更新参数,即用当前的参数值减去计算得到的步长值

param.grad.zero_():
将当前参数的梯度置零 。因为 PyTorch 会累积梯度,因此每次更新参数后,需要将梯度清零,以免下一次计算中混入之前的梯度值。

训练

现在我们已经准备好了模型训练所有需要的要素,可以实现主要的训练过程部分了。 理解这段代码至关重要,因为从事深度学习后, 相同的训练过程几乎一遍又一遍地出现。 在每次迭代中,我们读取一小批量训练样本,并通过我们的模型来获得一组预测。 计算完损失后,我们开始反向传播,存储每个参数的梯度。 最后,我们调用优化算法sgd来更新模型参数。

概括一下,我们将执行以下循环:

在每个迭代周期(epoch)中,我们使用data_iter函数遍历整个数据集, 并将训练数据集中所有样本都使用一次(假设样本数能够被批量大小整除)。 这里的迭代周期个数num_epochs和学习率lr都是超参数,分别设为3和0.03。 设置超参数很棘手,需要通过反复试验进行调整。 我们现在忽略这些细节,以后会在 11节中详细介绍。

python 复制代码
lr = 0.03
num_epochs = 3
net = linreg
loss = squared_loss

for epoch in range(num_epochs):
    for X, y in data_iter(batch_size, features, labels):
        l = loss(net(X, w, b), y)  # X和y的小批量损失
        # 因为l形状是(batch_size,1),而不是一个标量。l中的所有元素被加到一起,
        # 并以此计算关于[w,b]的梯度
        l.sum().backward()
        sgd([w, b], lr, batch_size)  # 使用参数的梯度更新参数
    with torch.no_grad():
        train_l = loss(net(features, w, b), labels)
        print(f'epoch {epoch + 1}, loss {float(train_l.mean()):f}')

{float(train_l.mean()):f}

这个部分是控制输出格式的。在 {} 内部的表达式 float(train_l.mean()) 会被计算,然后用 :f 来指定浮点数的格式

:f 表示将浮点数格式化为默认的六位小数

每次 for X, y in data_iter(batch_size, features, labels) 调用时,它不会一次性返回所有数据,而是分批次生成并返回数据。

在每个 epoch 中,for X, y in data_iter(batch_size, features, labels) 这个循环会持续调用 data_iter,直到所有样本都被处理完。

data_iter 不会一次性返回所有数据,而是每次生成一个批次,返回一次,直到样本全部处理完。

因为我们使用的是自己合成的数据集,所以我们知道真正的参数是什么。 因此,我们可以通过比较真实参数和通过训练学到的参数来评估训练的成功程度。 事实上,真实参数和通过训练学到的参数确实非常接近。

python 复制代码
print(f'w的估计误差: {true_w - w.reshape(true_w.shape)}')
print(f'b的估计误差: {true_b - b}')

注意,我们不应该想当然地认为我们能够完美地求解参数。 在机器学习中,我们通常不太关心恢复真正的参数,而更关心如何高度准确预测参数。 幸运的是,即使是在复杂的优化问题上,随机梯度下降通常也能找到非常好的解。 其中一个原因是,在深度网络中存在许多参数组合能够实现高度精确的预测。

3.3 线性回归的简洁实现

在过去的几年里,出于对深度学习强烈的兴趣, 许多公司、学者和业余爱好者开发了各种成熟的开源框架。 这些框架可以自动化基于梯度的学习算法中重复性的工作。 在 3.2节中,我们只运用了: (1)通过张量来进行数据存储和线性代数; (2)通过自动微分来计算梯度。 实际上,由于数据迭代器、损失函数、优化器和神经网络层很常用, 现代深度学习库也为我们实现了这些组件。

本节将介绍如何通过使用深度学习框架来简洁地实现 3.2节中的线性回归模型。

生成数据集

python 复制代码
import numpy as np
import torch
from torch.utils import data
from d2l import torch as d2l

true_w = torch.tensor([2, -3.4])
true_b = 4.2
features, labels = d2l.synthetic_data(true_w, true_b, 1000)

读取数据集

我们可以调用框架中现有的API来读取数据。 我们将features和labels作为API的参数传递,并通过数据迭代器指定batch_size。 此外,布尔值is_train表示是否希望数据迭代器对象在每个迭代周期内打乱数据。

python 复制代码
def load_array(data_arrays, batch_size, is_train=True):  #@save
    """构造一个PyTorch数据迭代器"""
    dataset = data.TensorDataset(*data_arrays)
    return data.DataLoader(dataset, batch_size, shuffle=is_train)

batch_size = 10
data_iter = load_array((features, labels), batch_size)

对于像数据特征和标签这样的输入,不需要在函数内部修改它们,因此使用元组 可以提高安全性,防止误操作导致的数据更改。并且因为元组是不可变的,它的内存开销和性能通常比列表更高效。

在函数 load_array 中,使用了参数拆包(*data_arrays),可以直接解包元组或列表。元组在这种解包操作中与列表同样适用,但由于元组通常用于表达固定结构,因此在这个场景下使用元组可能更加语义清晰。

load_array 函数的作用:

该函数的主要作用是将输入的数据数组转换为 PyTorch 的 DataLoader,使得在训练过程中可以更方便地进行小批量数据的迭代。

参数解析:

  • data_arrays: 是输入的特征和标签,一般由两个张量组成,分别是特征张量 features 和标签张量 labels。
  • *data_arrays 使用了可变参数的方式(拆包操作符 *),可以接受多个输入张量(比如特征和标签)。在函数内部,它们会被拆包并传递给 TensorDataset。
  • batch_size: 表示每次返回的数据批次大小。
  • is_train: 一个布尔值,表示是否在加载数据时随机打乱数据。默认值为 True,即在训练时数据会被随机打乱。

构造 TensorDataset:

torch.utils.data.TensorDataset(*data_arrays):将传入的多个张量(特征和标签)打包成一个 Dataset 对象 。TensorDataset 是 PyTorch 提供的用于包装数据的类,可以同时包含特征和标签

例如,假设 data_arrays 包含两个张量 features 和 labels,TensorDataset(features, labels) 将它们组合成一个可迭代的数据集,其中每个样本由一对 (feature, label) 组成。

返回 DataLoader:

torch.utils.data.DataLoader(dataset, batch_size, shuffle=is_train):DataLoader 是 PyTorch 提供的用于加载数据的类 ,它可以自动将 Dataset 以小批量形式加载出来,并支持多线程加载、随机打乱数据等功能

batch_size: 指定每次加载的样本数。

shuffle=is_train: 如果 is_train=True,则在每个 epoch 之前会随机打乱数据,这对训练过程很重要,可以提高模型的泛化能力。对测试集通常不打乱数据。

在 Python 中,解包(unpacking)指的是将一个可迭代对象(如元组或列表)展开成单独的元素并传递给函数或赋值操作。在你的 load_array 函数中,* data_arrays 是一种解包操作,它用于将元组或列表中的元素展开并分别传递给函数。在 Python 中, * 操作符可以用于将一个可迭代对象(如元组、列表)解包为单独的元素,并将这些元素作为单独的参数传递给函数。例如:

python 复制代码
def my_func(a, b):
    print(a, b)

args = (1, 2)
my_func(*args)  # 等同于 my_func(1, 2)

在 load_array 函数中:

  • data_arrays 是一个包含多个张量的元组 (features, labels)。
  • *data_arrays 解包操作会将 data_arrays 元组中的每个元素(即 features 和 labels)作为单独的参数传递给 TensorDataset。
  • TensorDataset 期望接收多个单独的张量,而不是一个包含多个张量的容器(如元组或列表)。

使用data_iter的方式与我们在 3.2节中使用data_iter函数的方式相同。为了验证是否正常工作,让我们读取并打印第一个小批量样本。 与 3.2节不同,这里我们使用iter构造Python迭代器,并使用next从迭代器中获取第一项。

python 复制代码
next(iter(data_iter))

定义模型

当我们在 3.2节中实现线性回归时, 我们明确定义了模型参数变量,并编写了计算的代码,这样通过基本的线性代数运算得到输出。 但是,如果模型变得更加复杂,且当我们几乎每天都需要实现模型时,自然会想简化这个过程。 这种情况类似于为自己的博客从零开始编写网页。 做一两次是有益的,但如果每个新博客就需要工程师花一个月的时间重新开始编写网页,那并不高效。

对于标准深度学习模型,我们可以使用框架的预定义好的层。这使我们只需关注使用哪些层来构造模型,而不必关注层的实现细节。 我们首先定义一个模型变量net,它是一个Sequential类的实例。 Sequential类将多个层串联在一起。 当给定输入数据时,Sequential实例将数据传入到第一层, 然后将第一层的输出作为第二层的输入,以此类推 。 在下面的例子中,我们的模型只包含一个层,因此实际上不需要Sequential。 但是由于以后几乎所有的模型都是多层的,在这里使用Sequential会让你熟悉"标准的流水线"。

回顾 图3.1.2中的单层网络架构, 这一单层被称为全连接层 (fully-connected layer), 因为它的每一个输入都通过矩阵-向量乘法得到它的每个输出

在PyTorch中,全连接层在Linear类中定义 。 值得注意的是,我们将两个参数传递到nn.Linear中。 第一个指定输入特征形状,即2,第二个指定输出特征形状,输出特征形状为单个标量,因此为1

python 复制代码
# nn是神经网络的缩写
from torch import nn

net = nn.Sequential(nn.Linear(2, 1))

2是features的列数,1是label的列数

初始化模型参数

在使用net之前,我们需要初始化模型参数。 如在线性回归模型中的权重和偏置。 深度学习框架通常有预定义的方法来初始化参数。 在这里,我们指定每个权重参数应该从均值为0、标准差为0.01的正态分布中随机采样, 偏置参数将初始化为零。

正如我们在构造nn.Linear时指定输入和输出尺寸一样, 现在我们能直接访问参数以设定它们的初始值 。 我们通过net[0]选择网络中的第一个图层, 然后使用weight.data和bias.data方法访问参数 。 我们还可以使用替换方法normal_和fill_来重写参数值

python 复制代码
net[0].weight.data.normal_(0, 0.01)
net[0].bias.data.fill_(0)

net[0]是指容器里面的第一层,也就是我们的linear

fill_同样是一个就地操作,直接修改偏置的值。

定义损失函数

计算均方误差 使用的是MSELoss 类,也称为平方L_2范数。 默认情况下,它返回所有样本损失的平均值

python 复制代码
loss = nn.MSELoss()

定义优化算法

小批量随机梯度下降算法 是一种优化神经网络的标准工具, PyTorch在optim模块中实现了该算法的许多变种 。 当我们实例化一个SGD实例时,我们要指定优化的参数 (可通过net.parameters()从我们的模型中获得)以及优化算法所需的超参数字典。 小批量随机梯度下降只需要设置lr值,这里设置为0.03。

python 复制代码
trainer = torch.optim.SGD(net.parameters(), lr=0.03)

训练

通过深度学习框架的高级API来实现我们的模型只需要相对较少的代码。 我们不必单独分配参数、不必定义我们的损失函数,也不必手动实现小批量随机梯度下降。 当我们需要更复杂的模型时,高级API的优势将大大增加。 当我们有了所有的基本组件,训练过程代码与我们从零开始实现时所做的非常相似。

回顾一下:在每个迭代周期里,我们将完整遍历一次数据集(train_data), 不停地从中获取一个小批量的输入和相应的标签。 对于每一个小批量,我们会进行以下步骤:

  • 通过调用net(X)生成预测并计算损失l(前向传播)。
  • 通过进行反向传播来计算梯度。
  • 通过调用优化器来更新模型参数。

为了更好的衡量训练效果,我们计算每个迭代周期后的损失,并打印它来监控训练过程。

python 复制代码
num_epochs = 3
for epoch in range(num_epochs):
    for X, y in data_iter:
        l = loss(net(X) ,y)
        trainer.zero_grad()
        l.backward()
        trainer.step()
    l = loss(net(features), labels)
    print(f'epoch {epoch + 1}, loss {l:f}')

没用with no_gard的原因是梯度清零的时机不一样,一个在backward前一个在backward后

为什么省略l.sum():因为loss函数中已经有sum()操作了

下面我们比较生成数据集的真实参数和通过有限数据训练获得的模型参数。 要访问参数,我们首先从net访问所需的层,然后读取该层的权重和偏置。 正如在从零开始实现中一样,我们估计得到的参数与生成数据的真实参数非常接近

python 复制代码
w = net[0].weight.data
print('w的估计误差:', true_w - w.reshape(true_w.shape))
b = net[0].bias.data
print('b的估计误差:', true_b - b)

3.4 softmax回归

通常,机器学习实践者用分类这个词来描述两个有微妙差别的问题: 1. 我们只对样本的"硬性"类别感兴趣,即属于哪个类别; 2. 我们希望得到"软性"类别,即得到属于每个类别的概率。 这两者的界限往往很模糊。其中的一个原因是:即使我们只关心硬类别,我们仍然使用软类别的模型。

softmax回归 虽然名字中带有回归,但实际上是一个分类问题

分类问题

我们从一个图像分类问题开始。 假设每次输入是一个2*2的灰度图像。 我们可以用一个标量表示每个像素值,每个图像对应四个特征x_1,x_2,x_3,x_4。 此外,假设每个图像属于类别"猫""鸡"和"狗"中的一个。

接下来,我们要选择如何表示标签。 我们有两个明显的选择:最直接的想法是选择y属于{1,2,3}, 其中整数分别代表{狗,猫,鸡}。 这是在计算机上存储此类信息的有效方法。 如果类别间有一些自然顺序, 比如说我们试图预测{婴儿,儿童,青少年,青年人,中年人,老年人}, 那么将这个问题转变为回归问题,并且保留这种格式是有意义的。

但是一般的分类问题并不与类别之间的自然顺序有关 。 幸运的是,统计学家很早以前就发明了一种表示分类数据的简单方法:独热编码 (one-hot encoding)。 独热编码是一个向量,它的分量和类别一样多。 类别对应的分量设置为1,其他所有分量设置为0。 在我们的例子中,标签

将是一个三维向量, 其中(1,0,0)对应于"猫"、(0,1,0)对应于"鸡"、(0,0,1)对应于"狗":

网络架构

为了估计所有可能类别的条件概率,我们需要一个有多个输出的模型,每个类别对应一个输出。 为了解决线性模型的分类问题,我们需要和输出一样多的仿射函数 (affine function)。 每个输出对应于它自己的仿射函数。 在我们的例子中,由于我们有4个特征和3个可能的输出类别, 我们将需要12个标量来表示权重(带下标的w), 3个标量来表示偏置(带下标的b)。 下面我们为每个输入计算三个未规范化的预测(logit):o1,o2,o3

我们可以用神经网络图 图3.4.1来描述这个计算过程。 与线性回归一样,softmax回归也是一个单层神经网络 。 由于计算每个输出o1,o2,o3取决于 所有输入x1,x2,x3,x4, 所以softmax回归的输出层也是全连接层

为了更简洁地表达模型,我们仍然使用线性代数符号。 通过向量形式表达为o=Wx+b, 这是一种更适合数学和编写代码的形式。 由此,我们已经将所有权重放到一个3*4矩阵中。 对于给定数据样本的特征x, 我们的输出是由权重与输入特征进行矩阵-向量乘法再加上偏置b得到的。

全连接层的参数开销

正如我们将在后续章节中看到的,在深度学习中,全连接层无处不在。 然而,顾名思义,全连接层是"完全"连接的,可能有很多可学习的参数。 具体来说,对于任何具有d个输入和q个输出的全连接层, 参数开销为O(dq),这个数字在实践中可能高得令人望而却步。 幸运的是,将d个输入转换为q个输出的成本可以减少到O((dq)/n), 其中超参数n可以由我们灵活指定,以在实际应用中平衡参数节约和模型有效性

softmax运算

现在我们将优化参数以最大化观测数据的概率。 为了得到预测结果,我们将设置一个阈值,如选择具有最大概率的标签。

我们希望模型的输出y-hat_j可以视为属于类j的概率, 然后选择具有最大输出值的类别argmax_j{y_j}作为我们的预测。 例如,如果y-hat_1,y-hat_2,y-hat_3分别为0.1、0.8和0.1, 那么我们预测的类别是2,在我们的例子中代表"鸡"。

然而我们能否将未规范化的预测o直接视作我们感兴趣的输出呢? 答案是否定的。 因为将线性层的输出直接视为概率时存在一些问题: 一方面,我们没有限制这些输出数字的总和为1。 另一方面,根据输入的不同,它们可以为负值。 这些违反了 2.6节中所说的概率基本公理。

要将输出视为概率,我们必须保证在任何数据上的输出都是非负的且总和为1 。 此外,我们需要一个训练的目标函数,来激励模型精准地估计概率。 例如, 在分类器输出0.5的所有样本中,我们希望这些样本是刚好有一半实际上属于预测的类别。 这个属性叫做校准(calibration)。

社会科学家邓肯·卢斯于1959年在选择模型(choice model)的理论基础上 发明的softmax函数正是这样做的: softmax函数能够将未规范化的预测变换为非负数并且总和为1,同时让模型保持 可导(非负:负的x -> e^x -> 正的输出;总和为1:分子相加等于分母)的性质。 为了完成这一目标,我们首先对每个未规范化的预测求幂,这样可以确保输出非负。 为了确保最终输出的概率值总和为1,我们再让每个求幂后的结果除以它们的总和。如下式:

这里,对于所有的j总有0 <= y-hat_j <= 1。 因此,y-hat可以视为一个正确的概率分布。 softmax运算不会改变未规范化的预测o之间的大小次序 ,只会确定分配给每个类别的概率。 因此,在预测过程中,我们仍然可以用下式来选择最有可能的类别。

尽管softmax是一个非线性函数 ,但softmax回归的输出仍然由输入特征的仿射变换决定。 因此,softmax回归是一个线性模型(linear model)。

小批量样本的矢量化

为了提高计算效率并且充分利用GPU,我们通常会对小批量样本的数据执行矢量计算。 假设我们读取了一个批量的样本X, 其中特征维度(输入数量)为d,批量大小为n。 此外,假设我们在输出中有q个类别。 那么小批量样本的特征为 X ∈ R n × d X \in R^{n \times d} X∈Rn×d, 权重为 W ∈ R d × q W \in R^{d \times q} W∈Rd×q, 偏置为 b ∈ R 1 × q b \in R^{1 \times q} b∈R1×q。 softmax回归的矢量计算表达式为:

相对于一次处理一个样本, 小批量样本的矢量化加快了X和W的矩阵-向量乘法。 由于X中的每一行代表一个数据样本, 那么softmax运算可以按行(rowwise)执行: 对于O的每一行,我们先对所有项进行幂运算,然后通过求和对它们进行标准化。 在 (3.4.5)中,XW+b 的求和会使用广播机制, 小批量的未规范化预测O和输出概率Y-hat 都是形状为n*q的矩阵。

损失函数

接下来,我们需要一个损失函数来度量预测的效果。 我们将使用最大似然估计,这与在线性回归 ( 3.1.3节) 中的方法相同。

交叉熵损失:

现在让我们考虑整个结果分布的情况,即观察到的不仅仅是一个结果。 对于标签y,我们可以使用与以前相同的表示形式。 唯一的区别是,我们现在用一个概率向量表示 ,如(0.1,0.2,0.7), 而不是仅包含二元项的向量(0,0,1)。 我们使用 (3.4.8)来定义损失l, 它是所有标签分布的预期损失值。 此损失称为交叉熵损失 (cross-entropy loss),它是分类问题最常用的损失之一。

使用softmax操作子得到每个类的预测置信度

使用交叉熵来衡量预测和标号的区别作为损失函数

3.5 图像分类数据集

MNIST数据集 (LeCun et al., 1998) 是图像分类中广泛使用的数据集之一,但作为基准数据集过于简单。 我们将使用类似但更复杂的Fashion-MNIST数据集 (Xiao et al., 2017)。

python 复制代码
%matplotlib inline
import torch
import torchvision
from torch.utils import data
from torchvision import transforms
from d2l import torch as d2l

d2l.use_svg_display()

读取数据集

我们可以通过框架中的内置函数将Fashion-MNIST数据集下载并读取到内存中。

python 复制代码
# 通过ToTensor实例将图像数据从PIL类型变换成32位浮点数格式,
# 并除以255使得所有像素的数值均在0~1之间
trans = transforms.ToTensor()
mnist_train = torchvision.datasets.FashionMNIST(
    root="../data", train=True, transform=trans, download=True)
mnist_test = torchvision.datasets.FashionMNIST(
    root="../data", train=False, transform=trans, download=True)

Fashion-MNIST由10个类别的图像组成, 每个类别由训练数据集(train dataset)中的6000张图像 和测试数据集(test dataset)中的1000张图像组成。 因此,训练集和测试集分别包含60000和10000张图像。 测试数据集不会用于训练,只用于评估模型性能。

python 复制代码
len(mnist_train), len(mnist_test)

每个输入图像的高度和宽度均为28像素。 数据集由灰度图像组成,其通道数为1。 为了简洁起见,本书将高度h像素、宽度w像素图像的形状记为h*w或(h,w)

python 复制代码
mnist_train[0][0].shape

由于是黑白图片,因此channel为1

Fashion-MNIST中包含的10个类别,分别为t-shirt(T恤)、trouser(裤子)、pullover(套衫)、dress(连衣裙)、coat(外套)、sandal(凉鞋)、shirt(衬衫)、sneaker(运动鞋)、bag(包)和ankle boot(短靴)。 以下函数用于在数字标签索引及其文本名称之间进行转换。

python 复制代码
def get_fashion_mnist_labels(labels):  #@save
    """返回Fashion-MNIST数据集的文本标签"""
    text_labels = ['t-shirt', 'trouser', 'pullover', 'dress', 'coat',
                   'sandal', 'shirt', 'sneaker', 'bag', 'ankle boot']
    return [text_labels[int(i)] for i in labels]

这个return语句是一个列表推导式,它执行以下步骤:

  • 遍历输入的 labels,其中 labels 是一个包含数字标签的列表或张量
  • 对于每个 i,先通过 int(i) 将其转换为整数索引(以确保能够正确索引 text_labels 列表,防止 i 是其他类型如张量)。
  • 使用 text_labels[int(i)] 来获取对应的文本标签。
  • 最终返回一个包含所有文本标签的新列表。

我们现在可以创建一个函数来可视化这些样本。

python 复制代码
def show_images(imgs, num_rows, num_cols, titles=None, scale=1.5):  #@save
    """绘制图像列表"""
    figsize = (num_cols * scale, num_rows * scale)
    _, axes = d2l.plt.subplots(num_rows, num_cols, figsize=figsize)
    axes = axes.flatten()
    for i, (ax, img) in enumerate(zip(axes, imgs)):
        if torch.is_tensor(img):
            # 图片张量
            ax.imshow(img.numpy())
        else:
            # PIL图片
            ax.imshow(img)
        ax.axes.get_xaxis().set_visible(False)
        ax.axes.get_yaxis().set_visible(False)
        if titles:
            ax.set_title(titles[i])
    return axes

以下是训练数据集中前几个样本的图像及其相应的标签。

python 复制代码
X, y = next(iter(data.DataLoader(mnist_train, batch_size=18)))
show_images(X.reshape(18, 28, 28), 2, 9, titles=get_fashion_mnist_labels(y));

读取小批量

为了使我们在读取训练集和测试集时更容易,我们使用内置的数据迭代器,而不是从零开始创建。 回顾一下,在每次迭代中,数据加载器每次都会读取一小批量数据,大小为batch_size。 通过内置数据迭代器,我们可以随机打乱了所有样本,从而无偏见地读取小批量。

python 复制代码
batch_size = 256

def get_dataloader_workers():  #@save
    """使用4个进程来读取数据"""
    return 4

train_iter = data.DataLoader(mnist_train, batch_size, shuffle=True,
                             num_workers=get_dataloader_workers())

我们看一下读取训练数据所需的时间。

python 复制代码
timer = d2l.Timer()
for X, y in train_iter:
    continue
f'{timer.stop():.2f} sec'

整合所有组件

现在我们定义load_data_fashion_mnist函数,用于获取和读取Fashion-MNIST数据集。 这个函数返回训练集和验证集的数据迭代器。 此外,这个函数还接受一个可选参数resize,用来将图像大小调整为另一种形状。

python 复制代码
def load_data_fashion_mnist(batch_size, resize=None):  #@save
    """下载Fashion-MNIST数据集,然后将其加载到内存中"""
    trans = [transforms.ToTensor()]
    if resize:
        trans.insert(0, transforms.Resize(resize))
    trans = transforms.Compose(trans)
    mnist_train = torchvision.datasets.FashionMNIST(
        root="../data", train=True, transform=trans, download=True)
    mnist_test = torchvision.datasets.FashionMNIST(
        root="../data", train=False, transform=trans, download=True)
    return (data.DataLoader(mnist_train, batch_size, shuffle=True,
                            num_workers=get_dataloader_workers()),
            data.DataLoader(mnist_test, batch_size, shuffle=False,
                            num_workers=get_dataloader_workers()))

这里首先创建了一个包含 transforms.ToTensor() 的列表 trans。

transforms.ToTensor():将输入的 PIL 图像或 NumPy 数组转换为 PyTorch 的张量格式,同时将像素值从 [0, 255] 范围缩放到 [0, 1] 范围。这是模型训练和推理过程中常见的预处理步骤,因为神经网络期望输入是浮点数张量。

这里检查是否传入了 resize 参数。

如果传入了 resize,表示需要调整图像大小。resize 应该是一个整数或包含两个元素的元组,指定图像的目标尺寸。

transforms.Resize(resize):这是一个图像预处理操作,它会将输入图像调整为指定的尺寸(resize 参数)。它被插入到 trans 列表的开头(insert(0, ),表示在所有其他操作之前首先调整图像大小

例如,如果传入 resize=(64, 64),那么图像会被调整为 64x64 像素。

transforms.Compose()将一系列预处理操作(trans 列表中的每个操作)组合成一个复合操作 。在这里,trans 列表可能包含两个操作:

如果没有 resize:只包含 ToTensor() 操作。

如果有 resize:包含 Resize(resize) 和 ToTensor(),图像会先被调整大小,然后转换为张量。
Compose 函数的作用是按顺序依次执行这些操作。最终结果是一个由 transforms.Compose() 定义的预处理管道,它会被应用于加载 Fashion-MNIST 数据集的每一张图像。

下面,我们通过指定resize参数来测试load_data_fashion_mnist函数的图像大小调整功能。

python 复制代码
train_iter, test_iter = load_data_fashion_mnist(32, resize=64)
for X, y in train_iter:
    print(X.shape, X.dtype, y.shape, y.dtype)
    break

我们现在已经准备好使用Fashion-MNIST数据集,便于下面的章节调用来评估各种分类算法。

3.6 softmax回归的从零开始实现

本节我们将使用刚刚在 3.5节中引入的Fashion-MNIST数据集, 并设置数据迭代器的批量大小为256

python 复制代码
import torch
from IPython import display
from d2l import torch as d2l

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

初始化模型参数

和之前线性回归的例子一样,这里的每个样本都将用固定长度的向量表示。 原始数据集中的每个样本都是28*28的图像。 本节将展平每个图像,把它们看作长度为784的向量 。 在后面的章节中,我们将讨论能够利用图像空间结构的特征, 但现在我们暂时只把每个像素位置看作一个特征

回想一下,在softmax回归中,我们的输出与类别一样多。 因为我们的数据集有10个类别,所以网络输出维度为10。 因此,权重将构成一个78410的矩阵, 偏置将构成一个110的行向量 。 与线性回归一样,我们将使用正态分布初始化我们的权重W,偏置初始化为0

python 复制代码
num_inputs = 784
num_outputs = 10

W = torch.normal(0, 0.01, size=(num_inputs, num_outputs), requires_grad=True)
b = torch.zeros(num_outputs, requires_grad=True)

定义softmax操作

在实现softmax回归模型之前,我们简要回顾一下sum运算符如何沿着张量中的特定维度工作。 如 2.3.6节和 2.3.6.1节所述, 给定一个矩阵X,我们可以对所有元素求和(默认情况下)。 也可以只求同一个轴上的元素,即同一列(轴0)或同一行(轴1)。 如果X是一个形状为(2, 3)的张量,我们对列进行求和, 则结果将是一个具有形状(3,)的向量。 当调用sum运算符时,我们可以指定保持在原始张量的轴数,而不折叠求和的维度。 这将产生一个具有形状(1, 3)的二维张量。

python 复制代码
X = torch.tensor([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]])
X.sum(0, keepdim=True), X.sum(1, keepdim=True)

回想一下,实现softmax由三个步骤组成:

  1. 对每个项求幂(使用exp);
  2. 每一行求和(小批量中每个样本是一行),得到每个样本的规范化常数;
  3. 将每一行除以其规范化常数,确保结果的和为1。

在查看代码之前,我们回顾一下这个表达式:

分母或规范化常数,有时也称为配分函数(其对数称为对数-配分函数)。 该名称来自统计物理学中一个模拟粒子群分布的方程。

python 复制代码
def softmax(X):
    X_exp = torch.exp(X)
    partition = X_exp.sum(1, keepdim=True)
    return X_exp / partition  # 这里应用了广播机制

torch.exp(X):对 X 中的每个元素取指数值

X_exp.sum(1, keepdim=True):对 X_exp 沿着第 1 维(即列的方向)求和,得到每一行的总和。这个和称为 partition function,它是 softmax 函数分母部分,用于归一化每个类别的指数值。

X_exp / partition:将 X_exp 中每一行的元素除以对应行的 partition ,这是 softmax 的归一化步骤。

在 X_exp / partition 这一行代码中,严格来说并不是矩阵除法 ,而是逐元素除法

逐元素除法 vs 矩阵除法

  • 逐元素除法:每个元素会与另一个张量中的相应元素进行除法操作。这里的 X_exp / partition 就是逐元素的除法运算。
  • 矩阵除法:如果是矩阵除法,那我们通常会涉及到矩阵乘法或求逆,但这并不是 softmax 函数中的情形。

在 softmax 函数中,分母 partition 是每一行的和,因此我们希望对每一行的每个元素都除以该行的总和 。这是因为我们希望 softmax 的结果是一个概率分布,即每行的值都归一化为 0 到 1 之间,并且每行的总和为 1

广播机制的作用

  • 张量的形状
    在广播之前:
    X_exp 的形状是 (batch_size, num_classes),即每个样本有 num_classes 个预测值。
    partition 是通过 X_exp.sum(1, keepdim=True ) 得到的,它的形状是 (batch_size, 1),即每一行有一个总和值。
  • 广播机制
    广播机制允许形状不同的张量参与运算,具体是将较小的张量扩展为与较大张量兼容的形状。广播不需要真的复制数据,而是逻辑上扩展数据。

在这个例子中:

partition 的形状是 (batch_size, 1),它的每一行只有一个数值。

当执行 X_exp / partition 时,PyTorch 的广播机制会将 partition 的每个元素沿着列方向扩展,变为形状 (batch_size, num_classes) ,即每个样本的所有类别都共享相同的分母。

具体过程:
逐元素除法:通过广播机制,partition 的每一行的那个单一值被扩展,使得 X_exp 的每一行都能与其对应的行总和值逐元素相除

每一行的 X_exp[i, :] 都会除以 partition[i, 0 ],这相当于对行进行归一化运算最终确保每行的所有元素加起来等于 1

正如上述代码,对于任何随机输入,我们将每个元素变成一个非负数。 此外,依据概率原理,每行总和为1。

python 复制代码
X = torch.normal(0, 1, (2, 5))
X_prob = softmax(X)
X_prob, X_prob.sum(1)

注意,虽然这在数学上看起来是正确的,但我们在代码实现中有点草率。 矩阵中的非常大或非常小的元素可能造成数值上溢或下溢,但我们没有采取措施来防止这点。

定义模型

定义softmax操作后,我们可以实现softmax回归模型。 下面的代码定义了输入如何通过网络映射到输出。 注意,将数据传递到模型之前,我们使用reshape函数将每张原始图像展平为向量

python 复制代码
def net(X):
    return softmax(torch.matmul(X.reshape((-1, W.shape[0])), W) + b)

X是[256,1,28,28], reshape后为[256,784]

定义损失函数

接下来,我们实现 3.4节中引入的交叉熵损失函数。 这可能是深度学习中最常见的损失函数,因为目前分类问题的数量远远超过回归问题的数量。

回顾一下,交叉熵采用真实标签的预测概率的负对数似然 。 这里我们不使用Python的for循环迭代预测(这往往是低效的), 而是通过一个运算符选择所有元素。 下面,我们创建一个数据样本y_hat,其中包含2个样本在3个类别的预测概率 , 以及它们对应的标签y 。 有了y,我们知道在第一个样本中,第一类是正确的预测; 而在第二个样本中,第三类是正确的预测。 然后使用y作为y_hat中概率的索引, 我们选择第一个样本中第一个类的概率和第二个样本中第三个类的概率。

python 复制代码
y = torch.tensor([0, 2])
y_hat = torch.tensor([[0.1, 0.3, 0.6], [0.3, 0.2, 0.5]])
y_hat[[0, 1], y]

y_hat[[0, 1], y] 使用了高级索引(fancy indexing),结合行索引和列索引来选择 y_hat 中的特定元素。实现的是利用索引来从 y_hat 张量中选取特定位置的元素。

[0, 1]:这是行索引,表示我们要选取 y_hat 中的第 0 行和第 1 行

y:这是列索引由张量 y 给出,它包含 [0, 2]

因此,y_hat[[0, 1], y] 的含义是:

  • 从 y_hat 的第 0 行选择索引为 0 的元素。
  • 从 y_hat 的第 1 行选择索引为 2 的元素。

现在我们只需一行代码就可以实现交叉熵损失函数。

python 复制代码
def cross_entropy(y_hat, y):
    return - torch.log(y_hat[range(len(y_hat)), y])

cross_entropy(y_hat, y)

range(len(y_hat)) -> [0, 1, ..., len(y_hat)-1]

提示,代码中的log实际上是ln

分类精度

给定预测概率分布y_hat,当我们必须输出硬预测(hard prediction)时, 我们通常选择预测概率最高的类。 许多应用都要求我们做出选择。如Gmail必须将电子邮件分类为"Primary(主要邮件)"、 "Social(社交邮件)""Updates(更新邮件)"或"Forums(论坛邮件)"。 Gmail做分类时可能在内部估计概率,但最终它必须在类中选择一个。

当预测与标签分类y一致时,即是正确的。 分类精度即正确预测数量与总预测数量之比。 虽然直接优化精度可能很困难(因为精度的计算不可导), 但精度通常是我们最关心的性能衡量标准,我们在训练分类器时几乎总会关注它。

为了计算精度,我们执行以下操作。 首先,如果y_hat是矩阵,那么假定第二个维度存储每个类的预测分数。 我们使用argmax获得每行中最大元素的索引来获得预测类别。 然后我们将预测类别与真实y元素进行比较。 由于等式运算符"=="对数据类型很敏感, 因此我们将y_hat的数据类型转换为与y的数据类型一致。 结果是一个包含0(错)和1(对)的张量。 最后,我们求和会得到正确预测的数量。

python 复制代码
def accuracy(y_hat, y):  #@save
    """计算预测正确的数量"""
    if len(y_hat.shape) > 1 and y_hat.shape[1] > 1:
        y_hat = y_hat.argmax(axis=1)
    cmp = y_hat.type(y.dtype) == y
    return float(cmp.type(y.dtype).sum())

if len(y_hat.shape) > 1 and y_hat.shape[1] > 1:

这段代码的目的是判断 y_hat 是否是二维张量y_hat.shape :这是 PyTorch 张量的形状属性,它返回张量的维度(即张量每一维的大小)作为一个元组 ),并且它是否包含多个类别的概率分布。如果 y_hat 是二维的,且列数(即 y_hat.shape[1])大于 1 ,表示 y_hat 可能是类别的概率分布,而不是类别索引。

例如,假设 y_hat 是一个形状为 (batch_size, num_classes) 的张量,表示每个样本的 num_classes 类别的预测概率分布。

y_hat = y_hat.argmax(axis=1):

argmax(axis=1):对 y_hat 的每一行(每个样本的预测)进行操作,找出概率最大的类别索引 。这样做的目的是将概率分布转换为类别的索引。

例如,如果 y_hat 是 [[0.1, 0.3, 0.6], [0.2, 0.7, 0.1]],argmax(axis=1) 会返回 [2, 1],因为每个样本的最大概率对应的类别索引分别是 2 和 1。

cmp = y_hat.type(y.dtype) == y:

这一行代码的作用是将 y_hat 和 y 进行比较,生成一个表示是否预测正确的布尔张量。

y_hat.type(y.dtype):确保 y_hat 和 y 的数据类型一致。y_hat 可能是整数型,而 y 的数据类型可能不同(例如 Long 类型)。通过 type() 强制类型转换,保证它们可以进行比较

== y:逐元素比较 y_hat 和 y ,返回一个布尔张量,表示每个样本的预测是否正确。

例如,假设 y_hat = [2, 1] 且 y = [2, 0],比较的结果为 cmp = [True, False]。

return float(cmp.type(y.dtype).sum()):

cmp.type(y.dtype):将布尔张量 cmp 转换为与 y 相同的数据类型(通常是整数)。在布尔张量中,True 转换为 1,False 转换为 0。

.sum():对 cmp 中的所有元素求和,得到预测正确的样本数量

例如,假设 cmp = [True, False],转换后变为 [1, 0],求和结果为 1,表示有 1 个样本预测正确。

float(...):将结果转换为浮点数,确保返回的值是一个浮点数,而不是整型。

我们将继续使用之前定义的变量y_hat和y分别作为预测的概率分布和标签。 可以看到,第一个样本的预测类别是2(该行的最大元素为0.6,索引为2),这与实际标签0不一致。 第二个样本的预测类别是2(该行的最大元素为0.5,索引为2),这与实际标签2一致。 因此,这两个样本的分类精度率为0.5。

python 复制代码
accuracy(y_hat, y) / len(y)

同样,对于任意数据迭代器data_iter可访问的数据集, 我们可以评估在任意模型net的精度。

python 复制代码
def evaluate_accuracy(net, data_iter):  #@save
    """计算在指定数据集上模型的精度"""
    if isinstance(net, torch.nn.Module):
        net.eval()  # 将模型设置为评估模式
    metric = Accumulator(2)  # 正确预测数、预测总数
    with torch.no_grad():
        for X, y in data_iter:
            metric.add(accuracy(net(X), y), y.numel())
    return metric[0] / metric[1]

if isinstance(net, torch.nn.Module):

net.eval() # 将模型设置为评估模式

isinstance(net, torch.nn.Module):检查传入的 net 是否是 torch.nn.Module 的实例如果是,它表示这是一个 PyTorch 模型

net.eval():将模型设置为评估模式。在评估模式下,模型的行为会有所不同,尤其是对 Dropout 和 BatchNorm 层,它们在训练和推理时的表现是不一样的。

  • Dropout:在训练时随机丢弃神经元,在评估模式下关闭。
  • BatchNorm:在评估模式下使用训练时计算的均值和方差。

初始化计数器

metric = Accumulator(2) # 正确预测数、预测总数

Accumulator(2):这是一个自定义的累加器,用来保存和累加多个值。这里 Accumulator 的作用是累加两个值:正确预测的数量和样本总数。2 表示累加器有两个部分,分别用于统计这两个值。

禁用梯度计算

with torch.no_grad():

torch.no_grad():禁用梯度计算。因为我们只是在评估模型的准确率,而不是进行训练,因此不需要计算梯度。这样可以提高计算效率,减少内存占用。

遍历数据集

for X, y in data_iter:

这里遍历 data_iter,每次从数据迭代器中获取一小批数据 (X, y):

X:小批量输入数据。

y:对应的真实标签。

更新计数器

metric.add(accuracy(net(X), y), y.numel())

net(X):将输入 X 喂给模型,得到预测结果。

accuracy(net(X), y):调用前面定义的 accuracy 函数,计算模型在当前批次数据上的准确预测数量。

y.numel():计算当前批次中样本的总数。numel() 返回张量中的元素数量,对于分类任务而言,这通常就是样本数。

metric.add():将准确预测的数量和样本总数累加到累加器 metric 中

返回总体准确率

return metric[0] / metric[1]
metric[0]:累加器的第一个元素,表示所有批次中正确预测的总数
metric[1]:累加器的第二个元素,表示所有批次中样本的总数。
最后返回的是准确率,即正确预测的总数除以样本总数

这里定义一个实用程序类Accumulator,用于在多个变量上累加数据 。 在上面的evaluate_accuracy函数中, 我们在Accumulator实例中创建了2个变量, 分别用于存储正确预测的数量和预测的总数量。 当我们遍历数据集时,两者都将随着时间的推移而累加

python 复制代码
class Accumulator:  #@save
    """在n个变量上累加"""
    def __init__(self, n):
    	# 创建了一个包含 n 个元素的列表 self.data,所有元素初始值为 0.0。这个列表存储要累加的变量
    	# 例如,如果 n=2,那么 self.data 将是 [0.0, 0.0]
    	# 在 Python 中,列表乘以一个整数 n(如这里的 2)会将列表的内容重复 n 次,从而生成一个包含 n 个相同元素的新列表
        self.data = [0.0] * n

    def add(self, *args):
    	# 这个方法接收任意数量的参数 *args,并将它们累加到 self.data 中对应的位置。
    	# zip(self.data, args):将 self.data 和传入的参数 args 配对在一起。
    	# a + float(b):对 self.data 中的每个元素 a,累加对应的 args 元素 b,并将 b 转换为浮点数。然后将累加后的结果保存回 self.data。
        self.data = [a + float(b) for a, b in zip(self.data, args)]

    def reset(self):
        self.data = [0.0] * len(self.data)

    def __getitem__(self, idx):
        return self.data[idx]

由于我们使用随机权重初始化net模型, 因此该模型的精度应接近于随机猜测。 例如在有10个类别情况下的精度为0.1。

python 复制代码
evaluate_accuracy(net, test_iter)

训练

在我们看过 3.2节中的线性回归实现, softmax回归的训练过程代码应该看起来非常眼熟。 在这里,我们重构训练过程的实现以使其可重复使用。 首先,我们定义一个函数来训练一个迭代周期。 请注意,updater是更新模型参数的常用函数,它接受批量大小作为参数。 它可以是d2l.sgd函数,也可以是框架的内置优化函数。

python 复制代码
def train_epoch_ch3(net, train_iter, loss, updater):  #@save
    """训练模型一个迭代周期(定义见第3章)"""
    # 将模型设置为训练模式
    if isinstance(net, torch.nn.Module):
        net.train()
    # 训练损失总和、训练准确度总和、样本数
    metric = Accumulator(3)
    # 逐批处理数据,X 是输入数据,y 是对应的标签
    for X, y in train_iter:
        # 计算梯度并更新参数
        y_hat = net(X)
        l = loss(y_hat, y)
        
        # 使用PyTorch内置的优化器和损失函数
        if isinstance(updater, torch.optim.Optimizer):
            updater.zero_grad() # 清除之前的梯度,避免梯度累加。
            l.mean().backward() # 将损失的平均值反向传播,计算出每个参数的梯度。
            updater.step() # 根据梯度更新模型的参数。
        # 使用定制的优化器和损失函数
        else:
            l.sum().backward() # 将损失的总和反向传播,计算梯度。
            updater(X.shape[0]) # 使用自定义的更新函数,其中 X.shape[0] 表示当前批次的样本数量。
        
        metric.add(float(l.sum()), accuracy(y_hat, y), y.numel())
    # 返回训练损失和训练精度
    return metric[0] / metric[2], metric[1] / metric[2]

在神经网络训练中,反向传播 是通过损失函数来计算每个参数的梯度,然后使用优化器来更新参数。在实际操作中,将损失的平均值反向传播将损失的总和反向传播有一些细微的区别,尤其是在处理不同的批次大小(batch size)时,这会影响梯度的计算和模型的训练效果。

  1. 损失的总和反向传播
    含义:这里 l.sum() 是当前批次损失的总和
    影响:这种方式会根据批次的大小(样本数量)直接累积损失,然后计算出相应的梯度。
    损失总和的梯度会随批次大小增加,即
    大批次的损失较大,反向传播计算的梯度也会较大

    对于较大的批次,参数更新的幅度会更大;对于较小的批次,更新幅度较小
    换句话说,梯度的规模会受到批次大小的直接影响,因此需要谨慎调节学习率。
  2. 损失的平均值反向传播
    含义:这里 l.mean() 是当前批次损失的平均值
    影响:这种方式首先将每个样本的损失求平均值,然后再进行反向传播。
    平均损失的梯度不会直接受到批次大小的影响 。无论批次大小是多少,梯度的规模都会保持一致。
    这样可以使得批次大小变化时,梯度的尺度保持稳定,因此模型对不同批次大小的适应性更好。
    在小批次和大批次之间训练时,学习率可以保持不变,不需要因为批次大小调整学习率。

一般来说,损失平均值反向传播更推荐用于标准化和稳定的训练流程。

在展示训练函数的实现之前,我们定义一个在动画中绘制数据的实用程序类Animator, 它能够简化本书其余部分的代码。

python 复制代码
class Animator:  #@save
    """在动画中绘制数据"""
    def __init__(self, xlabel=None, ylabel=None, legend=None, xlim=None,
                 ylim=None, xscale='linear', yscale='linear',
                 fmts=('-', 'm--', 'g-.', 'r:'), nrows=1, ncols=1,
                 figsize=(3.5, 2.5)):
        # 增量地绘制多条线
        if legend is None:
            legend = []
        d2l.use_svg_display()
        self.fig, self.axes = d2l.plt.subplots(nrows, ncols, figsize=figsize)
        if nrows * ncols == 1:
            self.axes = [self.axes, ]
        # 使用lambda函数捕获参数
        self.config_axes = lambda: d2l.set_axes(
            self.axes[0], xlabel, ylabel, xlim, ylim, xscale, yscale, legend)
        self.X, self.Y, self.fmts = None, None, fmts

    def add(self, x, y):
        # 向图表中添加多个数据点
        if not hasattr(y, "__len__"):
            y = [y]
        n = len(y)
        if not hasattr(x, "__len__"):
            x = [x] * n
        if not self.X:
            self.X = [[] for _ in range(n)]
        if not self.Y:
            self.Y = [[] for _ in range(n)]
        for i, (a, b) in enumerate(zip(x, y)):
            if a is not None and b is not None:
                self.X[i].append(a)
                self.Y[i].append(b)
        self.axes[0].cla()
        for x, y, fmt in zip(self.X, self.Y, self.fmts):
            self.axes[0].plot(x, y, fmt)
        self.config_axes()
        display.display(self.fig)
        display.clear_output(wait=True)

接下来我们实现一个训练函数, 它会在train_iter访问到的训练数据集上训练一个模型net。 该训练函数将会运行多个迭代周期(由num_epochs指定)。 在每个迭代周期结束时,利用test_iter访问到的测试数据集对模型进行评估。 我们将利用Animator类来可视化训练进度。

python 复制代码
def train_ch3(net, train_iter, test_iter, loss, num_epochs, updater):  #@save
    """训练模型(定义见第3章)"""
    animator = Animator(xlabel='epoch', xlim=[1, num_epochs], ylim=[0.3, 0.9],
                        legend=['train loss', 'train acc', 'test acc'])
    for epoch in range(num_epochs):
    	# 调用函数 train_epoch_ch3 执行一次训练周期,并返回训练损失和训练准确率。
    	# train_metrics 是一个元组,包含训练损失和训练准确率。
        train_metrics = train_epoch_ch3(net, train_iter, loss, updater)
        # evaluate_accuracy(net, test_iter):调用 evaluate_accuracy 函数,评估模型在测试集上的准确率,返回 test_acc。
        test_acc = evaluate_accuracy(net, test_iter)
        animator.add(epoch + 1, train_metrics + (test_acc,))
    
    # 在训练完成后,使用断言来检查训练结果是否合理
    train_loss, train_acc = train_metrics
    # 确保最终训练损失小于 0.5。如果不满足,抛出错误并显示当前的 train_loss。
    assert train_loss < 0.5, train_loss
    # 确保训练准确率在 (0.7, 1] 之间,且不超过 1。如果不满足,抛出错误并显示当前的 train_acc
    assert train_acc <= 1 and train_acc > 0.7, train_acc
    # 确保测试集上的准确率也在 (0.7, 1] 之间。如果不满足,抛出错误并显示当前的 test_acc
    assert test_acc <= 1 and test_acc > 0.7, test_acc

作为一个从零开始的实现,我们使用 3.2节中定义的 小批量随机梯度下降来优化模型的损失函数,设置学习率为0.1

python 复制代码
lr = 0.1

def updater(batch_size):
    return d2l.sgd([W, b], lr, batch_size)

现在,我们训练模型10个迭代周期。 请注意,迭代周期(num_epochs)和学习率(lr)都是可调节的超参数。 通过更改它们的值,我们可以提高模型的分类精度。

python 复制代码
num_epochs = 10
train_ch3(net, train_iter, test_iter, cross_entropy, num_epochs, updater)

预测

现在训练已经完成,我们的模型已经准备好对图像进行分类预测。 给定一系列图像,我们将比较它们的实际标签(文本输出的第一行)和模型预测(文本输出的第二行)。

python 复制代码
def predict_ch3(net, test_iter, n=6):  #@save
    """预测标签(定义见第3章)"""
    for X, y in test_iter:
        break
    trues = d2l.get_fashion_mnist_labels(y)
    preds = d2l.get_fashion_mnist_labels(net(X).argmax(axis=1))
    titles = [true +'\n' + pred for true, pred in zip(trues, preds)]
    d2l.show_images(
        X[0:n].reshape((n, 28, 28)), 1, n, titles=titles[0:n])

predict_ch3(net, test_iter)

3.7 softmax回归的简洁实现

在 3.3节中, 我们发现通过深度学习框架的高级API能够使实现

线性回归变得更加容易。 同样,通过深度学习框架的高级API也能更方便地实现softmax回归模型。 本节如在 3.6节中一样, 继续使用Fashion-MNIST数据集,并保持批量大小为256。

python 复制代码
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)

初始化模型参数

如我们在 3.4节所述, softmax回归的输出层是一个全连接层。 因此,为了实现我们的模型, 我们只需在Sequential中添加一个带有10个输出的全连接层。 同样,在这里Sequential并不是必要的, 但它是实现深度模型的基础。 我们仍然以均值0和标准差0.01随机初始化权重。

python 复制代码
# PyTorch不会隐式地调整输入的形状。因此,
# 我们在线性层前定义了展平层(flatten),来调整网络输入的形状
# nn.Flatten():将多维输入展平为一维。例如,输入的图像数据(通常为2D或3D)会被展平为1D张量,以便输入到全连接层中。对于28x28的图像(如MNIST数据集),它将其展平为一个长度为784的向量。
# nn.Linear(784, 10):这是一个全连接层(线性层),输入大小为784(28x28的展平图像),输出大小为10(表示10个类别的分类任务,例如MNIST数据集中的0-9分类)。
# 这一步定义了一个两层的神经网络,其中第一层是展平操作,第二层是一个线性层,用于将输入的数据映射到10个类别上
net = nn.Sequential(nn.Flatten(), nn.Linear(784, 10))


# init_weights(m):这是一个自定义的函数,用于初始化网络中的参数。
# m:是网络中的某个层(Module)。在调用 net.apply(init_weights) 时,PyTorch 会遍历网络中的每个层,并将它传递给这个函数。
def init_weights(m):
    if type(m) == nn.Linear: # 检查当前传递进来的层 m 是否是一个线性层 nn.Linear。只有当层是线性层时,才会执行权重初始化。
    	# 用均值为0,标准差为0.01的正态分布对线性层的权重 m.weight 进行初始化。这意味着权重会随机初始化为接近零的小值。
        nn.init.normal_(m.weight, std=0.01)

# 这一行代码会遍历 net 中的所有层,并对每一层调用 init_weights 函数。
net.apply(init_weights);

重新审视Softmax的实现

在前面 3.6节的例子中, 我们计算了模型的输出,然后将此输出送入交叉熵损失。 从数学上讲,这是一件完全合理的事情。 然而,从计算角度来看,指数可能会造成数值稳定性问题。

回想一下,softmax函数, 其中y-hat_j是预测的概率分布。 o_j是未规范化的预测o的第j个元素。 如果o_k中的一些数值非常大, 那么exp(o_k)可能大于数据类型容许的最大数字,即上溢 (overflow)。 这将使分母或分子变为inf(无穷大), 最后得到的是0、inf或nan(不是数字)的y-hat_j。 在这些情况下,我们无法得到一个明确定义的交叉熵值。

解决这个问题的一个技巧是: 在继续softmax计算之前,先从所有o_k中减去max(o_k)。 这里可以看到每个o_k按常数进行的移动不会改变softmax的返回值

在减法和规范化步骤之后,可能有些o_j-max(o_k)具有较大的负值 。 由于精度受限,exp(o_j-max(o_k))将有接近零的值 ,即下溢 (underflow)。 这些值可能会四舍五入为零 ,使y-hat_j为零, 并且使得log(y-hat_j)的值为**-inf**。 反向传播几步后,我们可能会发现自己面对一屏幕可怕的nan结果。

尽管我们要计算指数函数,但我们最终在计算交叉熵损失时会取它们的对数 。 通过将softmax和交叉熵结合在一起,可以避免反向传播过程中可能会困扰我们的数值稳定性问题。 如下面的等式所示,我们避免计算exp(o_j-max(o_k)), 而可以直接使用o_j-max(o_k) ,因为log(exp(~))被抵消了。

我们也希望保留传统的softmax函数,以备我们需要评估通过模型输出的概率。 但是,我们没有将softmax概率传递到损失函数中, 而是在交叉熵损失函数中传递未规范化的预测,并同时计算softmax及其对数, 这是一种类似"LogSumExp技巧"的聪明方式。

python 复制代码
# 交叉熵损失函数
loss = nn.CrossEntropyLoss(reduction='none')

优化算法

在这里,我们使用学习率为0.1的小批量随机梯度下降作为优化算法。 这与我们在线性回归例子中的相同,这说明了优化器的普适性。

python 复制代码
trainer = torch.optim.SGD(net.parameters(), lr=0.1)

训练

接下来我们调用 3.6节中 定义的训练函数来训练模型。

python 复制代码
num_epochs = 10
d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, trainer)

和以前一样,这个算法使结果收敛到一个相当高的精度,而且这次的代码比之前更精简了。

相关推荐
悟兰因w15 分钟前
神经网络(二):卷积神经网络
python·神经网络·计算机视觉·cnn
明志刘明1 小时前
基于MindSpore实现Transformer机器翻译(下)
人工智能·深度学习·自然语言处理·transformer·机器翻译
张小生1802 小时前
《深度学习》—— 神经网络中常用的激活函数
人工智能·深度学习·神经网络
俏皮舌大烟佬2 小时前
NLP基础
人工智能·深度学习·自然语言处理·nlp
醒了就刷牙4 小时前
55 循环神经网络RNN的实现_by《李沐:动手学深度学习v2》pytorch版
pytorch·rnn·深度学习
Bosenya124 小时前
【PyTorch】Tensor(张量)介绍
人工智能·pytorch·python
风虎云龙科研服务器5 小时前
【巅峰算力,静谧之作】4卡4090GPU深度学习“静音”服务器
人工智能·深度学习
qq_550337995 小时前
研1日记15
pytorch·深度学习
菜就多练_08286 小时前
《深度学习》卷积神经网络CNN 实现手写数字识别
人工智能·深度学习·cnn·手写数字识别