PyTorch实战:各种模型的简洁实现


线性回归的初始实现

%matplotlib inline
import matplotlib_inline
import torch
from IPython import display
from matplotlib import pyplot as plt
import numpy as np
import random

生成数据集

num_inputs = 2
num_examples = 1000
true_w = [2, -3.4] # 表示真实权重
true_b = 4.2 # 表示真实偏差
features = torch.randn(num_examples, num_inputs,
                       dtype=torch.float32) # torch.randn函数会从标准正态分布中随机采样一个张量
labels = true_w[0] * features[:, 0] + true_w[1] * features[:, 1] + true_b # 将权重第一项和features第一列相乘得到第一项,再将权重第二项与features第二列相乘,最后加上偏差
labels += torch.tensor(np.random.normal(0, 0.01, size=labels.size()),
                       dtype=torch.float32) # 模拟真实数据中的噪声,均值为0,标准差为0.01,大小和labels相同

# print(features)
# print(labels)
def use_svg_display():
    # 用矢量图显示,这对于需要高质量矢量图形的情况非常有用
    # display.set_matplotlib_formats('svg') # 通常情况下,该方法将输出渲染为PNG图像格式,但是这个函数自2021年5月起已被弃用
 matplotlib_inline.backend_inline.set_matplotlib_formats('svg')

def set_figsize(figsize=(3.5, 2.5)):
    use_svg_display()
    # 设置图的尺寸
    plt.rcParams['figure.figsize'] = figsize # plt.rcParams是Matplotlib库中的一个全局配置对象,用于设置和控制图形的各种参数。通过修改plt.rcParams中的参数,可以影响图形的样式、布局、字体、颜色等方面

 

# 设置图像的大小
# plt.rcParams['figure.figsize'] = (10, 10)

# 设置图像的分辨率
# plt.rcParams['figure.dpi'] = 300

# 设置保存的图像的分辨率
# plt.rcParams['savefig.dpi'] = 300

# 设置线条的颜色
# plt.rcParams['lines.color'] = 'r'

# 设置线条的样式
# plt.rcParams['lines.linestyle'] = '--'

# 设置线条的宽度
# plt.rcParams['lines.linewidth'] = 2.0

# 设置字体
# plt.rcParams['font.family'] = 'serif'

# 设置字体大小
# plt.rcParams['font.size'] = 12

# 设置字体颜色
# plt.rcParams['text.color'] = 'k'

# 设置背景颜色
# plt.rcParams['axes.facecolor'] = 'white'

# 设置坐标轴的颜色
# plt.rcParams['axes.edgecolor'] = 'black'

# 设置标题的大小
# plt.rcParams['axes.titlesize'] = 20

# 设置坐标轴标签的大小
# plt.rcParams['axes.labelsize'] = 15

# 设置坐标轴刻度的大小
# plt.rcParams['xtick.labelsize'] = 10
# plt.rcParams['ytick.labelsize'] = 10

# 设置图例的大小
# plt.rcParams['legend.fontsize'] = 'large'

# 设置图例的位置
# plt.rcParams['legend.loc'] = 'best'


# # 在../d2lzh_pytorch里面添加上面两个函数后就可以这样导入
# import sys
# sys.path.append("..")
# from d2lzh_pytorch import * 

set_figsize()
plt.scatter(features[:, 1].numpy(), labels.numpy(), 1); # 获取features的二维数组的第二列的所有元素,并将其转换为numpy数组,同理转换labels数组;1是散点的大小

读取数据

def data_iter(batch_size, features, labels): # [每个批次的大小 特征数据 标签数据] 可以按批次随机读取数据的迭代器
    num_examples = len(features) # 计算特征的长度
    indices = list(range(num_examples)) # 创建一个长度为特征长度的列表,range函数会生成一个从0开始,到num_examples - 1结束的整数序列。
    random.shuffle(indices)  # 样本的读取顺序是随机的
    for i in range(0, num_examples, batch_size): # 每次循环处理一个批次的数据,循环的步长为batch_size,因为每次循环处理的是一个新的批次
        j = torch.LongTensor(indices[i: min(i + batch_size, num_examples)]) # 最后一次可能不足一个batch,创建一个张量j,包含当前批次的索引
        yield  features.index_select(0, j), labels.index_select(0, j) # 使用index_select函数从features和labels中选择出当前批次的数据,并使用yield关键字返回。yield关键字表示这是一个生成器函数,每次调用next方法时,都会返回下一个批次的数据。
batch_size = 10

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

初始化模型参数

w = torch.tensor(np.random.normal(0, 0.01, (num_inputs, 1)), dtype=torch.float32) # 使用NumPy的random.normal函数生成一个形状为(num_inputs, 1)的正态分布随机数数组,该正态分布的均值为0,标准差为0.01。
b = torch.zeros(1, dtype=torch.float32) # 偏置初始化为0

w.requires_grad_(requires_grad=True)
b.requires_grad_(requires_grad=True) 

定义模型

def linreg(X, w, b):  # 本函数已保存在d2lzh_pytorch包中方便以后使用
    return torch.mm(X, w) + b

定义损失函数

def squared_loss(y_hat, y):  # 本函数已保存在d2lzh_pytorch包中方便以后使用
    # 注意这里返回的是向量, 另外, pytorch里的MSELoss并没有除以 2
    return (y_hat - y.view(y_hat.size())) ** 2 / 2

定义优化算法

def sgd(params, lr, batch_size):  # 本函数已保存在d2lzh_pytorch包中方便以后使用
    for param in params:
        param.data -= lr * param.grad / batch_size # 注意这里更改param时用的param.data

训练模型

lr = 0.03
num_epochs = 3
net = linreg
loss = squared_loss

for epoch in range(num_epochs):  # 训练模型一共需要num_epochs个迭代周期
    # 在每一个迭代周期中,会使用训练数据集中所有样本一次(假设样本数能够被批量大小整除)。X
    # 和y分别是小批量样本的特征和标签
    for X, y in data_iter(batch_size, features, labels):
        l = loss(net(X, w, b), y).sum()  # l是有关小批量X和y的损失
        l.backward()  # 小批量的损失对模型参数求梯度
        sgd([w, b], lr, batch_size)  # 使用小批量随机梯度下降迭代模型参数

        # 不要忘了梯度清零
        w.grad.data.zero_()
        b.grad.data.zero_()
    train_l = loss(net(features, w, b), labels)
    print('epoch %d, loss %f' % (epoch + 1, train_l.mean().item()))

注意:如果要重新训练,请执行随机权重初始化那段代码。

线性回归的简洁实现

随着深度学习框架的发展,开发深度学习应用变得越来越便利。实践中,我们通常可以用比上一节更简洁的代码来实现同样的模型。在本节中,我们将介绍如何使用PyTorch更方便地实现线性回归的训练。

生成数据集

num_inputs = 2
num_examples = 1000
true_w = [2, -3.4]
true_b = 4.2
features = torch.tensor(np.random.normal(0, 1, (num_examples, num_inputs)), dtype=torch.float)
labels = true_w[0] * features[:, 0] + true_w[1] * features[:, 1] + true_b
labels += torch.tensor(np.random.normal(0, 0.01, size=labels.size()), dtype=torch.float)

读取数据

PyTorch提供了data包来读取数据。由于data常用作变量名,我们将导入的data模块用Data代替。在每一次迭代中,我们将随机读取包含10个数据样本的小批量。

import torch.utils.data as Data

batch_size = 10
# 将训练数据的特征和标签组合
dataset = Data.TensorDataset(features, labels)
# 随机读取小批量
data_iter = Data.DataLoader(dataset, batch_size, shuffle=True)

定义模型

在上一节从零开始的实现中,我们需要定义模型参数,并使用它们一步步描述模型是怎样计算的。当模型结构变得更复杂时,这些步骤将变得更繁琐。其实,PyTorch提供了大量预定义的层,这使我们只需关注使用哪些层来构造模型。下面将介绍如何使用PyTorch更简洁地定义线性回归。

  1. 导入 torch.nn 模块。实际上,“nn”是neural networks(神经网络)的缩写。顾名思义,该模块定义了大量神经网络的层。之前我们已经用过了autograd,而nn就是利用autograd来定义模型。

  2. nn的核心数据结构是Module,它是一个抽象概念,既可以表示神经网络中的某个层(layer),也可以表示一个包含很多层的神经网络。

    在实际使用中,最常见的做法是继承nn.Module,撰写自己的网络/层。

    一个nn.Module实例应该包含一些层以及返回输出的前向传播(forward)方法。

class LinearNet(nn.Module):
    def __init__(self, n_feature): #初始化对象的属性和层
        super(LinearNet, self).__init__() # 因为我们要继承nn.Module的功能
        self.linear = nn.Linear(n_feature, 1) # 创建一个线性层对象,接收两个参数,输入特征为n_feature,输出特征为1
    # 输入特征数和输出特征数决定了权重矩阵和偏置的形状。权重矩阵的形状是(输出特征数,输入特征数),偏置的形状是(输出特征数,)
    # forward 定义前向传播
    def forward(self, x):
        y = self.linear(x)
        return y

net = LinearNet(num_inputs) # 传给 n_feature
print(net) # 使用print可以打印出网络的结构

输出:

LinearNet(
  (linear): Linear(in_features=2, out_features=1, bias=True)
)

事实上我们还可以用nn.Sequential来更加方便地搭建网络,Sequential是一个有序的容器,网络层将按照在传入Sequential的顺序依次被添加到计算图中。

# 写法一
net = nn.Sequential(
    nn.Linear(num_inputs, 1)
    # 此处还可以传入其他层
    )

# 写法二
net = nn.Sequential()
net.add_module('linear', nn.Linear(num_inputs, 1))
# net.add_module ......

# 写法三
from collections import OrderedDict
net = nn.Sequential(OrderedDict([
          ('linear', nn.Linear(num_inputs, 1))
          # ......
        ]))

print(net)
print(net[0])

可以通过net.parameters()来查看模型所有的可学习参数,此函数将返回一个生成器。

for param in net.parameters():
    print(param)

输出:

Parameter containing:
tensor([[-0.0277,  0.2771]], requires_grad=True)
Parameter containing:
tensor([0.3395], requires_grad=True)

作为一个单层神经网络,线性回归输出层中的神经元和输入层中各个输入完全连接。因此,线性回归的输出层又叫全连接层。

注意:torch.nn仅支持输入一个batch的样本不支持单个样本输入,如果只有单个样本,可使用input.unsqueeze(0)来添加一维。

例如我们有一个单个样本x,它的形状是[特征数],例如[3]。如果我们想将它作为批量样本输入到torch.nn模型中,我们需要给它添加一个维度,使其形状变为[1, 特征数],例如[1, 3]。我们可以使用unsqueeze(0)来实现这一点。下面是一个例子:

import torch

# 假设我们有一个形状为[3]的单个样本
x = torch.tensor([1.0, 2.0, 3.0])
print(x.shape)  # 输出:torch.Size([3])

# 使用unsqueeze(0)给它添加一个维度
x = x.unsqueeze(0)
print(x.shape)  # 输出:torch.Size([1, 3])

unsqueeze函数中,0是一个参数,表示要在哪个维度上添加新的维度。具体来说,0表示在第一个维度(也就是最外层的维度)上添加新的维度。例如,如果我们有一个形状为[3\]的张量,使用unsqueeze(0)后,它的形状会变为[1, 3]

初始化模型参数

在使用net前,我们需要初始化模型参数,如线性回归模型中的权重和偏差。PyTorch在init模块中提供了多种参数初始化方法。

这里的initinitializer的缩写形式。我们通过init.normal_将权重参数每个元素初始化为随机采样于均值为0、标准差为0.01的正态分布。偏差会初始化为零。

from torch.nn import init

init.normal_(net[0].weight, mean=0, std=0.01)
init.constant_(net[0].bias, val=0)  # 也可以直接修改bias的data: net[0].bias.data.fill_(0)

注意:如果这里的net是用之前一开始的代码自定义的,那么上面代码会报错,net[0].weight应改为net.linear.weightbias亦然。因为net[0]这样根据下标访问子模块的写法只有当net是个ModuleList或者Sequential实例时才可以。

定义损失函数

PyTorch在nn模块中提供了各种损失函数,这些损失函数可看作是一种特殊的层,PyTorch也将这些损失函数实现为nn.Module的子类。我们现在使用它提供的均方误差损失作为模型的损失函数。

loss = nn.MSELoss()

定义优化算法

同样,我们也无须自己实现小批量随机梯度下降算法。torch.optim模块提供了很多常用的优化算法比如SGD、Adam和RMSProp等。下面我们创建一个用于优化net所有参数的优化器实例,并指定学习率为0.03的小批量随机梯度下降(SGD)为优化算法。

import torch.optim as optim

optimizer = optim.SGD(net.parameters(), lr=0.03)
print(optimizer)

输出:

SGD (
Parameter Group 0
    dampening: 0
    lr: 0.03
    momentum: 0
    nesterov: False
    weight_decay: 0
)

我们还可以为不同子网络设置不同的学习率,这在finetune时经常用到。例:

optimizer =optim.SGD([
                # 如果对某个参数不指定学习率,就使用最外层的默认学习率
                {'params': net.subnet1.parameters()}, # lr=0.03
                {'params': net.subnet2.parameters(), 'lr': 0.01}
            ], lr=0.03)

有时候我们不想让学习率固定成一个常数,那如何调整学习率呢?主要有两种做法。

  • 一种是修改optimizer.param_groups中对应的学习率:

    for epoch in range(100):
        if epoch == 50:
            for param_group in optimizer.param_groups:
                param_group['lr'] = 0.01
  • 另一种是更简单也是较为推荐的做法——新建优化器,由于optimizer十分轻量级,构建开销很小,故而可以构建新的optimizer。但是后者对于使用动量的优化器(如Adam),会丢失动量等状态信息,可能会造成损失函数的收敛出现震荡等情况。

    # 假设我们正在使用PyTorch
    import torch
    import torch.optim as optim
    
    # 创建一个简单的模型
    model = torch.nn.Linear(10, 1)
    
    # 创建一个优化器
    optimizer = optim.SGD(model.parameters(), lr=0.1)
    
    # 在训练过程中,我们可能想要在某个时刻改变学习率
    for epoch in range(100):
        if epoch == 50:
            optimizer = optim.SGD(model.parameters(), lr=0.01)
    
        # 这里省略了训练代码...

训练模型

num_epochs = 3
for epoch in range(1, num_epochs + 1):
    for X, y in data_iter:
        output = net(X)
        l = loss(output, y.view(-1, 1))
        optimizer.zero_grad() # 梯度清零,等价于net.zero_grad()
        l.backward()
        optimizer.step()
    print('epoch %d, loss: %f' % (epoch, l.item()))

输出:

epoch 1, loss: 0.000457
epoch 2, loss: 0.000081
epoch 3, loss: 0.000198

下面我们分别比较学到的模型参数和真实的模型参数。我们从net获得需要的层,并访问其权重(weight)和偏差(bias)。学到的参数和真实的参数很接近。

dense = net[0]
print(true_w, dense.weight)
print(true_b, dense.bias)

输出:

[2, -3.4] tensor([[ 1.9999, -3.4005]])
4.2 tensor([4.2011])

图像分类数据集

在介绍softmax回归的实现前我们先引入一个多类图像分类数据集。它将在后面的章节中被多次使用,以方便我们观察比较算法之间在模型精度和计算效率上的区别。图像分类数据集中最常用的是手写数字识别数据集MNIST[1]。

但大部分模型在MNIST上的分类精度都超过了95%。为了更直观地观察算法之间的差异,我们将使用一个图像内容更加复杂的数据集Fashion-MNIST[2](这个数据集也比较小,只有几十M,没有GPU的电脑也能吃得消)。

本节我们将使用torchvision包,它是服务于PyTorch深度学习框架的,主要用来构建计算机视觉模型。torchvision主要由以下几部分构成:

  1. torchvision.datasets: 一些加载数据的函数及常用的数据集接口;
  2. torchvision.models: 包含常用的模型结构(含预训练模型),例如AlexNet、VGG、ResNet等;
  3. torchvision.transforms: 常用的图片变换,例如裁剪、旋转等;
  4. torchvision.utils: 其他的一些有用的方法。

获取数据集

首先导入本节需要的包或模块:

import torch
import torchvision
import torchvision.transforms as transforms
import matplotlib_inline
import matplotlib.pyplot as plt
import time

下面,我们通过torchvision的torchvision.datasets来下载这个数据集。第一次调用时会自动从网上获取数据。我们通过参数train来指定获取训练数据集或测试数据集(testing data set)。测试数据集也叫测试集(testing set),只用来评价模型的表现,并不用来训练模型。

注意:另外我们还指定了参数transform = transforms.ToTensor()使所有数据转换为Tensor,如果不进行转换则返回的是PIL图片。

transforms.ToTensor()将尺寸为 (H x W x C) 且数据位于[0, 255]的PIL图片或者数据类型为np.uint8的NumPy数组转换为尺寸为(C x H x W)且数据类型为torch.float32且位于[0.0, 1.0]的Tensor

注意: 由于像素值为0到255的整数,所以刚好是uint8所能表示的范围,包括transforms.ToTensor()在内的一些关于图片的函数就默认输入的是uint8型,若不是,可能不会报错但可能得不到想要的结果。所以,如果用像素值(0-255整数)表示图片数据,那么一律将其类型设置成uint8,避免不必要的bug。 本人就被这点坑过,详见我的这个博客2.2.4节

mnist_train = torchvision.datasets.FashionMNIST(root='~/Datasets/FashionMNIST', train=True, download=True, transform=transforms.ToTensor())
mnist_test = torchvision.datasets.FashionMNIST(root='~/Datasets/FashionMNIST', train=False, download=True, transform=transforms.ToTensor())

上面的mnist_trainmnist_test都是torch.utils.data.Dataset的子类,所以我们可以用len()来获取该数据集的大小,还可以用下标来获取具体的一个样本。

训练集中和测试集中的每个类别的图像数分别为6,000和1,000。因为有10个类别,所以训练集和测试集的样本数分别为60,000和10,000。

print(type(mnist_train))
print(len(mnist_train), len(mnist_test))

输出:

<class 'torchvision.datasets.mnist.FashionMNIST'>
60000 10000

我们可以通过下标来访问任意一个样本:

feature, label = mnist_train[0]
print(feature.shape, label)  # Channel x Height x Width

输出:

torch.Size([1, 28, 28]) tensor(9)

变量feature对应高和宽均为28像素的图像。由于我们使用了transforms.ToTensor(),所以每个像素的数值为[0.0, 1.0]的32位浮点数。

注意:feature的尺寸是 (C x H x W) 的,而不是 (H x W x C)。第一维是通道数,因为数据集中是灰度图像,所以通道数为1。后面两维分别是图像的高和宽。

Fashion-MNIST中一共包括了10个类别,分别为t-shirt(T恤)、trouser(裤子)、pullover(套衫)、dress(连衣裙)、coat(外套)、sandal(凉鞋)、shirt(衬衫)、sneaker(运动鞋)、bag(包)和ankle boot(短靴)。以下函数可以将数值标签转成相应的文本标签。

# 本函数已保存在d2lzh包中方便以后使用
def get_fashion_mnist_labels(labels):
    text_labels = ['t-shirt', 'trouser', 'pullover', 'dress', 'coat',
                   'sandal', 'shirt', 'sneaker', 'bag', 'ankle boot']
    return [text_labels[int(i)] for i in labels]

下面定义一个可以在一行里画出多张图像和对应标签的函数。

# 本函数已保存在d2lzh包中方便以后使用
def show_fashion_mnist(images, labels):
    d2l.use_svg_display() # 设置matplotlib的图像显示模式为SVG格式。
    # 这里的_表示我们忽略(不使用)的变量
    _, figs = plt.subplots(1, len(images), figsize=(12, 12)) # 创建一个新的图像窗口,并返回一个包含窗口和子图对象元组。在这里,我们只关注子图对象,所以使用_忽略窗口对象。
    for f, img, lbl in zip(figs, images, labels): # zip是一个内置函数,它用于将可迭代的对象作为参数,返回一个迭代器,它的每一个元素都是一个元组,元组中的每一个元素分别来自figs, images, labels
        f.imshow(img.view((28, 28)).numpy()) # 将图像转换为28×28的numpy数组,并显示在子图上
        f.set_title(lbl) # 设置子图的标题为对应的标签
        f.axes.get_xaxis().set_visible(False) # 隐藏子图的x轴和y轴
        f.axes.get_yaxis().set_visible(False) 
    plt.show()

现在,我们看一下训练数据集中前10个样本的图像内容和文本标签。

X, y = [], []
for i in range(10):
    X.append(mnist_train[i][0])
    y.append(mnist_train[i][1])
show_fashion_mnist(X, get_fashion_mnist_labels(y))

img

读取小批量

我们将在训练数据集上训练模型,并将训练好的模型在测试数据集上评价模型的表现。前面说过,mnist_traintorch.utils.data.Dataset的子类,所以我们可以将其传入torch.utils.data.DataLoader来创建一个读取小批量数据样本的DataLoader实例。

在实践中,数据读取经常是训练的性能瓶颈,特别当模型较简单或者计算硬件性能较高时。PyTorch的DataLoader中一个很方便的功能是允许使用多进程来加速数据读取。这里我们通过参数num_workers来设置4个进程读取数据。

batch_size = 256
if sys.platform.startswith('win'):
    num_workers = 0  # 0表示不用额外的进程来加速读取数据
else:
    num_workers = 4
train_iter = torch.utils.data.DataLoader(mnist_train, batch_size=batch_size, shuffle=True, num_workers=num_workers)
test_iter = torch.utils.data.DataLoader(mnist_test, batch_size=batch_size, shuffle=False, num_workers=num_workers)

我们将获取并读取Fashion-MNIST数据集的逻辑封装在d2lzh_pytorch.load_data_fashion_mnist函数中供后面章节调用。该函数将返回train_itertest_iter两个变量。

最后我们查看读取一遍训练数据需要的时间。

start = time.time()
for X, y in train_iter:
    continue
print('%.2f sec' % (time.time() - start))

输出:

1.57 sec

softmax回归的初始实现

获取和读取数据

我们将使用Fashion-MNIST数据集,并设置批量大小为256。

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

初始化模型参数

跟线性回归中的例子一样,我们将使用向量表示每个样本。已知每个样本输入是高和宽均为28像素的图像。模型的输入向量的长度是 28×28=78428×28=78428×28=784:该向量的每个元素对应图像中每个像素。

由于图像有10个类别,单层神经网络输出层的输出个数为10,因此softmax回归的权重和偏差参数分别为784×10784×10784×10和1×101×101×10的矩阵。

num_inputs = 784
num_outputs = 10

W = torch.tensor(np.random.normal(0, 0.01, (num_inputs, num_outputs)), dtype=torch.float)
b = torch.zeros(num_outputs, dtype=torch.float)

同之前一样,我们需要模型参数梯度。

W.requires_grad_(requires_grad=True)
b.requires_grad_(requires_grad=True) 

实现softmax运算

在介绍如何定义softmax回归之前,我们先描述一下对如何对多维Tensor按维度操作。在下面的例子中,给定一个Tensor矩阵X。我们可以只对其中同一列(dim=0)或同一行(dim=1)的元素求和,并在结果中保留行和列这两个维度(keepdim=True)。

X = torch.tensor([[1, 2, 3], [4, 5, 6]])
print(X.sum(dim=0, keepdim=True))
print(X.sum(dim=1, keepdim=True))

输出:

tensor([[5, 7, 9]])
tensor([[ 6],
        [15]])

下面我们就可以定义前面小节里介绍的softmax运算了。

在下面的函数中,矩阵X行数是样本数,列数是输出个数

为了表达样本预测各个输出的概率,softmax运算会先通过exp函数对每个元素做指数运算,再对exp矩阵同行元素求和,最后令矩阵每行各元素与该行元素之和相除。这样一来,最终得到的矩阵每行元素和为1且非负。因此,该矩阵每行都是合法的概率分布。

softmax运算的输出矩阵中的任意一行元素代表了一个样本在各个输出类别上的预测概率。

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

可以看到,对于随机输入,我们将每个元素变成了非负数,且每一行和为1。

X = torch.rand((2, 5))
X_prob = softmax(X)
print(X_prob, X_prob.sum(dim=1))

输出:

tensor([[0.2206, 0.1520, 0.1446, 0.2690, 0.2138],
        [0.1540, 0.2290, 0.1387, 0.2019, 0.2765]]) tensor([1., 1.])

定义模型

有了softmax运算,我们可以定义上节描述的softmax回归模型了。这里通过view函数将每张原始图像改成长度为num_inputs的向量。

def net(X):
    return softmax(torch.mm(X.view((-1, num_inputs)), W) + b)

定义损失函数

上一节中,我们介绍了softmax回归使用的交叉熵损失函数。为了得到标签的预测概率,我们可以使用gather函数。

在下面的例子中,变量y_hat是2个样本在3个类别的预测概率,变量y是这2个样本的标签类别。通过使用gather函数,我们得到了2个样本的标签的预测概率。与softmax回归数学表述中标签类别离散值从1开始逐一递增不同,在代码中,标签类别的离散值是从0开始逐一递增的。

y_hat = torch.tensor([[0.1, 0.3, 0.6], [0.3, 0.2, 0.5]])
y = torch.LongTensor([0, 2])
y_hat.gather(1, y.view(-1, 1)) # 提取操作,1表示按列提取,即对于y_hat的每一行,根据y中对应行的值作为列索引,提取y_hat中的元素。例如,对于第一行,y中对应的值是0,所以提取y_hat第一行第0列的值0.1;对于第二行,y中对应的值是2,所以提取y_hat第二行第2列的值0.5。

输出:

tensor([[0.1000],
        [0.5000]])

下面实现了交叉熵损失函数。

def cross_entropy(y_hat, y):
    return - torch.log(y_hat.gather(1, y.view(-1, 1)))

计算分类准确率

给定一个类别的预测概率分布y_hat,我们把预测概率最大的类别作为输出类别。如果它与真实类别y一致,说明这次预测是正确的。分类准确率即正确预测数量与总预测数量之比。

为了演示准确率的计算,下面定义准确率accuracy函数。其中y_hat.argmax(dim=1)返回矩阵y_hat每行中最大元素的索引,且返回结果与变量y形状相同。相等条件判断式(y_hat.argmax(dim=1) == y)是一个类型为ByteTensorTensor,我们用float()将其转换为值为0(相等为假)或1(相等为真)的浮点型Tensor

def accuracy(y_hat, y):
    return (y_hat.argmax(dim=1) == y).float().mean().item()

让我们继续使用在演示gather函数时定义的变量y_haty,并将它们分别作为预测概率分布和标签。可以看到,第一个样本预测类别为2(该行最大元素0.6在本行的索引为2),与真实标签0不一致;第二个样本预测类别为2(该行最大元素0.5在本行的索引为2),与真实标签2一致。因此,这两个样本上的分类准确率为0.5。

print(accuracy(y_hat, y))

输出:

0.5

类似地,我们可以评价模型net在数据集data_iter上的准确率。

# 本函数已保存在d2lzh_pytorch包中方便以后使用。该函数将被逐步改进:它的完整实现将在“图像增广”一节中描述
def evaluate_accuracy(data_iter, net):
    acc_sum, n = 0.0, 0
    for X, y in data_iter:
        acc_sum += (net(X).argmax(dim=1) == y).float().sum().item()
        n += y.shape[0]
    return acc_sum / n

因为我们随机初始化了模型net,所以这个随机模型的准确率应该接近于类别个数10的倒数即0.1。

print(evaluate_accuracy(test_iter, net))

输出:

0.0681

训练模型

训练softmax回归的实现跟3.2(线性回归的从零开始实现)一节介绍的线性回归中的实现非常相似。我们同样使用小批量随机梯度下降来优化模型的损失函数。在训练模型时,迭代周期数num_epochs和学习率lr都是可以调的超参数。改变它们的值可能会得到分类更准确的模型。

num_epochs, lr = 5, 0.1

# 本函数已保存在d2lzh包中方便以后使用
def train_ch3(net, train_iter, test_iter, loss, num_epochs, batch_size,
              params=None, lr=None, optimizer=None):
    for epoch in range(num_epochs):
        train_l_sum, train_acc_sum, n = 0.0, 0.0, 0
        for X, y in train_iter:
            y_hat = net(X)
            l = loss(y_hat, y).sum()

            # 梯度清零
            if optimizer is not None:
                optimizer.zero_grad()
            elif params is not None and params[0].grad is not None:
                for param in params:
                    param.grad.data.zero_()

            l.backward()
            if optimizer is None:
                d2l.sgd(params, lr, batch_size)
            else:
                optimizer.step()  # “softmax回归的简洁实现”一节将用到


            train_l_sum += l.item()
            train_acc_sum += (y_hat.argmax(dim=1) == y).sum().item()
            n += y.shape[0]
        test_acc = evaluate_accuracy(test_iter, net)
        print('epoch %d, loss %.4f, train acc %.3f, test acc %.3f'
              % (epoch + 1, train_l_sum / n, train_acc_sum / n, test_acc))

train_ch3(net, train_iter, test_iter, cross_entropy, num_epochs, batch_size, [W, b], lr)

输出:

epoch 1, loss 0.7878, train acc 0.749, test acc 0.794
epoch 2, loss 0.5702, train acc 0.814, test acc 0.813
epoch 3, loss 0.5252, train acc 0.827, test acc 0.819
epoch 4, loss 0.5010, train acc 0.833, test acc 0.824
epoch 5, loss 0.4858, train acc 0.836, test acc 0.815

预测

训练完成后,现在就可以演示如何对图像进行分类了。给定一系列图像(第三行图像输出),我们比较一下它们的真实标签(第一行文本输出)和模型预测结果(第二行文本输出)。

X, y = iter(test_iter).next()

true_labels = d2l.get_fashion_mnist_labels(y.numpy())
pred_labels = d2l.get_fashion_mnist_labels(net(X).argmax(dim=1).numpy())
titles = [true + '\n' + pred for true, pred in zip(true_labels, pred_labels)]

d2l.show_fashion_mnist(X[0:9], titles[0:9])

img

softmax回归的简洁实现

首先导入所需的包或模块:

import torch
from torch import nn
from torch.nn import init
import numpy as np
import sys
sys.path.append("..") 
import d2lzh_pytorch as d2l

获取和读取数据

我们仍然使用Fashion-MNIST数据集和上一节中设置的批量大小。

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

定义和初始化模型

softmax回归的输出层是一个全连接层,所以我们用一个线性模块就可以了。因为前面我们数据返回的每个batch样本x的形状为(batch_size, 1, 28, 28), 所以我们要先用view()x的形状转换成(batch_size, 784)才送入全连接层。

num_inputs = 784
num_outputs = 10

class LinearNet(nn.Module):
    def __init__(self, num_inputs, num_outputs):
        super(LinearNet, self).__init__()
        self.linear = nn.Linear(num_inputs, num_outputs)
    def forward(self, x): # x shape: (batch, 1, 28, 28)
        y = self.linear(x.view(x.shape[0], -1))
        return y

net = LinearNet(num_inputs, num_outputs)

我们将对x的形状转换的这个功能自定义一个FlattenLayer并记录在d2lzh_pytorch中方便后面使用。

# 本函数已保存在d2lzh_pytorch包中方便以后使用
class FlattenLayer(nn.Module):
    def __init__(self):
        super(FlattenLayer, self).__init__()
    def forward(self, x): # x shape: (batch, *, *, ...)
        return x.view(x.shape[0], -1)

这样我们就可以更方便地定义我们的模型:

from collections import OrderedDict

net = nn.Sequential(
    # FlattenLayer(),
    # nn.Linear(num_inputs, num_outputs)
    OrderedDict([
        ('flatten', FlattenLayer()),
        ('linear', nn.Linear(num_inputs, num_outputs))
    ])
)

然后,我们使用均值为0、标准差为0.01的正态分布随机初始化模型的权重参数。

init.normal_(net.linear.weight, mean=0, std=0.01)
init.constant_(net.linear.bias, val=0) 

softmax和交叉熵损失函数

如果做了上一节的练习,那么你可能意识到了分开定义softmax运算和交叉熵损失函数可能会造成数值不稳定。因此,PyTorch提供了一个包括softmax运算和交叉熵损失计算的函数。它的数值稳定性更好。

loss = nn.CrossEntropyLoss()

定义优化算法

我们使用学习率为0.1的小批量随机梯度下降作为优化算法。

optimizer = torch.optim.SGD(net.parameters(), lr=0.1)

训练模型

接下来,我们使用上一节中定义的训练函数来训练模型。

num_epochs = 5
d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, batch_size, None, None, optimizer)

输出:

epoch 1, loss 0.0031, train acc 0.745, test acc 0.790
epoch 2, loss 0.0022, train acc 0.812, test acc 0.807
epoch 3, loss 0.0021, train acc 0.825, test acc 0.806
epoch 4, loss 0.0020, train acc 0.832, test acc 0.810
epoch 5, loss 0.0019, train acc 0.838, test acc 0.823

多层感知机

隐藏层

多层感知机在单层神经网络的基础上引入了一到多个隐藏层(hidden layer)。隐藏层位于输入层和输出层之间。下图展示了一个多层感知机的神经网络图,它含有一个隐藏层,该层中有5个隐藏单元。

image-20231211174053052

上图所示的多层感知机中,输入和输出个数分别为4和3,中间的隐藏层中包含了5个隐藏单元(hidden unit)。由于输入层不涉及计算,上图中的多层感知机的层数为2。

由上图可见,隐藏层中的神经元和输入层中各个输入完全连接,输出层中的神经元和隐藏层中的各个神经元也完全连接。因此,多层感知机中的隐藏层和输出层都是全连接层。

具体来说,给定一个小批量样本 $\boldsymbol{X} \in \mathbb{R}^{n \times d}$ ,其批量大小为 $n$ ,输入个数为 $d$ 。假设多层感知机只有一个隐藏层,其中隐藏单元个数为 $h$ 。记隐藏层的输出 (也称为隐藏层变量或隐藏变量)为 $\boldsymbol{H}$ ,有 $\boldsymbol{H} \in \mathbb{R}^{n \times h}$ 。因为隐藏层和输出层均是全连接层,可以设隐藏层的权重参数和偏差参数分别为 $\boldsymbol{W}_h \in \mathbb{R}^{d \times h}$ 和 $\boldsymbol{b}_h \in \mathbb{R}^{1 \times h}$ ,输出层的权重和偏差参数分别为 $\boldsymbol{W}_o \in \mathbb{R}^{h \times q}$ 和 $\boldsymbol{b}_o \in \mathbb{R}^{1 \times q}$ 。

我们先来看一种含单隐藏层的多层感知机的设计。其输出 $O \in \mathbb{R}^{n \times q}$ 的计算为
$$
\begin{aligned}
\boldsymbol{H} & =\boldsymbol{X} \boldsymbol{W}_h+\boldsymbol{b}_h, \
\boldsymbol{O} & =\boldsymbol{H} \boldsymbol{W}_o+\boldsymbol{b}_o,
\end{aligned}
$$
也就是将隐藏层的输出直接作为输出层的输入。如果将以上两个式子联立起来,可以得到
$$
\boldsymbol{O}=\left(\boldsymbol{X} \boldsymbol{W}_h+\boldsymbol{b}_h\right) \boldsymbol{W}_o+\boldsymbol{b}_o=\boldsymbol{X} \boldsymbol{W}_h \boldsymbol{W}_o+\boldsymbol{b}_h \boldsymbol{W}_o+\boldsymbol{b}_o .
$$

从联立后的式子可以看出,虽然神经网络引入了隐藏层,却依然等价于一个单层神经网络: 其中输出层权重参数为 $\boldsymbol{W}_h \boldsymbol{W}_o$ ,偏差参数为 $\boldsymbol{b}_h \boldsymbol{W}_o+\boldsymbol{b}_o$ 。不难发现,即便再添加更多的隐藏层,以上设计依然只能与仅含输出层的单层神经网络等价

激活函数

上述问题的根源在于全连接层只是对数据做仿射变换(affine transformation),而多个仿射变换的叠加仍然是一个仿射变换。解决问题的一个方法是引入非线性变换,例如对隐藏变量使用按元素运算的非线性函数进行变换,然后再作为下一个全连接层的输入。这个非线性函数被称为激活函数(activation function)。下面我们介绍几个常用的激活函数。

ReLU函数

ReLU (rectified linear unit) 函数提供了一个很简单的非线性变换。给定元素 $x$ ,该函数定义为
$$
\operatorname{ReLU}(x)=\max (x, 0) .
$$
可以看出,ReLU函数只保留正数元素,并将负数元素清零。为了直观地观察这一非线性变换,我们先定义一个绘图函数xyplot

%matplotlib inline
import torch
import numpy as np
import matplotlib.pylab as plt
import sys
sys.path.append("..") 
import d2lzh_pytorch as d2l

def xyplot(x_vals, y_vals, name):
    d2l.set_figsize(figsize=(5, 2.5))
    d2l.plt.plot(x_vals.detach().numpy(), y_vals.detach().numpy())
    d2l.plt.xlabel('x')
    d2l.plt.ylabel(name + '(x)')

我们接下来通过Tensor提供的relu函数来绘制ReLU函数。可以看到,该激活函数是一个两段线性函数。

x = torch.arange(-8.0, 8.0, 0.1, requires_grad=True)
y = x.relu()
xyplot(x, y, 'relu')Copy to clipboardErrorCopied

img

显然,当输入为负数时,ReLU函数的导数为0;当输入为正数时,ReLU函数的导数为1。尽管输入为0时ReLU函数不可导,但是我们可以取此处的导数为0。下面绘制ReLU函数的导数。

y.sum().backward()
xyplot(x, x.grad, 'grad of relu')Copy to clipboardErrorCopied

img

sigmoid函数

sigmoid函数可以将元素的值变换到0和1之间:
$$
\operatorname{sigmoid}(x)=\frac{1}{1+\exp (-x)} .
$$
sigmoid函数在早期的神经网络中较为普遍,但它目前逐渐被更简单的ReLU函数取代。在后面“循环神经网络”一章中我们会介绍如何利用它值域在0到1之间这一特性来控制信息在神经网络中的流动。下面绘制了sigmoid函数。当输入接近0时,sigmoid函数接近线性变换。

y = x.sigmoid()
xyplot(x, y, 'sigmoid')Copy to clipboardErrorCopied

img

依据链式法则,sigmoid函数的导数
$$
\operatorname{sigmoid}^{\prime}(x)=\operatorname{sigmoid}(x)(1-\operatorname{sigmoid}(x)) .
$$
下面绘制了sigmoid函数的导数。当输入为0时,sigmoid函数的导数达到最大值0.25;当输入越偏离0时,sigmoid函数的导数越接近0。

x.grad.zero_()
y.sum().backward()
xyplot(x, x.grad, 'grad of sigmoid')Copy to clipboardErrorCopied

img

tanh函数

tanh(双曲正切)函数可以将元素的值变换到-1和1之间:
$$
\tanh (x)=\frac{1-\exp (-2 x)}{1+\exp (-2 x)}
$$
我们接着绘制tanh函数。当输入接近0时,tanh函数接近线性变换。虽然该函数的形状和sigmoid函数的形状很像,但tanh函数在坐标系的原点上对称。

y = x.tanh()
xyplot(x, y, 'tanh')Copy to clipboardErrorCopied

img

依据链式法则,tanh函数的导数
$$
\tanh ^{\prime}(x)=1-\tanh ^2(x) .
$$
下面绘制了tanh函数的导数。当输入为0时,tanh函数的导数达到最大值1;当输入越偏离0时,tanh函数的导数越接近0。

x.grad.zero_()
y.sum().backward()
xyplot(x, x.grad, 'grad of tanh')Copy to clipboardErrorCopied

img

多层感知机

多层感知机就是含有至少一个隐藏层的由全连接层组成的神经网络,且每个隐藏层的输出通过激活函数进行变换。多层感知机的层数和各隐藏层中隐藏单元个数都是超参数。以单隐藏层为例并沿用本节之前定义的符号,多层感知机按以下方式计算输出:
$$
\begin{aligned}
\boldsymbol{H} & =\phi\left(\boldsymbol{X} \boldsymbol{W}_h+\boldsymbol{b}_h\right), \
\boldsymbol{O} & =\boldsymbol{H} \boldsymbol{W}_o+\boldsymbol{b}_o,
\end{aligned}
$$

其中 $\phi$ 表示激活函数。在分类问题中,我们可以对输出 $\boldsymbol{O}$ 做softmax运算,并使用softmax回归中的交叉摘损失函数。在回归问题中,我们将输出层的输出个数设为 1 ,并将输出 $\boldsymbol{O}$ 直接提供给线性回归中使用的平方损失函数。


文章作者: QT-7274
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 QT-7274 !
评论
  目录