• 第3章 深度学习基础


    深度学习简介

    预备知识

    深度学习基础

    线性回归

    线性回归输出是一个连续值,因此适用于回归问题。回归问题在实际中很常见,如预测房屋价格、气温、销售额等连续值的问题。与回归问题不同,分类问题中模型的最终输出是一个离散值。我们所说的图像分类、垃圾邮件识别、疾病检测等输出为离散值的问题都属于分类问题的范畴。softmax回归则适用于分类问题。

    由于线性回归和softmax回归都是单层神经网络,它们涉及的概念和技术同样适用于大多数的深度学习模型。我们首先以线性回归为例,介绍大多数深度学习模型的基本要素和表示方法。

    线性回归的基本要素

    我们以一个简单的房屋价格预测作为例子来解释线性回归的基本要素。这个应用的目标是预测一栋房子的售出价格(元)。我们知道这个价格取决于很多因素,如房屋状况、地段、市场行情等。为了简单起见,这里我们假设价格只取决于房屋状况的两个因素,即面积(平方米)和房龄(年)。接下来我们希望探索价格与这两个因素的具体关系。

    模型定义

    设房屋的面积为 (x_1),房龄为 (x_2),售出价格为 (y)。我们需要建立基于输入 (x_1)(x_2) 来计算输出 (y) 的表达式,也就是模型(model)。顾名思义,线性回归假设输出与各个输入之间是线性关系:

    [hat{y} = x_1 w_1 + x_2 w_2 + b ]

    其中 (w_1)(w_2) 是权重(weight),(b) 是偏差(bias),且均为标量。它们是线性回归模型的参数(parameter)。模型输出 (hat{y}) 是线性回归对真实价格 (y) 的预测或估计。我们通常允许它们之间有一定误差。

    模型训练

    接下来我们需要通过数据来寻找特定的模型参数值,使模型在数据上的误差尽可能小。这个过程叫作模型训练(model training)。下面我们介绍模型训练所涉及的3个要素。

    (1) 训练数据

    我们通常收集一系列的真实数据,例如多栋房屋的真实售出价格和它们对应的面积和房龄。我们希望在这个数据上面寻找模型参数来使模型的预测价格与真实价格的误差最小。在机器学习术语里,该数据集被称为训练数据集(training data set)或训练集(training set),一栋房屋被称为一个样本(sample),其真实售出价格叫作标签(label),用来预测标签的两个因素叫作特征(feature)。特征用来表征样本的特点。

    假设我们采集的样本数为 (n),索引为 (i) 的样本的特征为 (x_1^{(i)})(x_2^{(i)}),标签为 (y^{(i)})。对于索引为 (i) 的房屋,线性回归模型的房屋价格预测表达式为

    [hat{y}^{(i)} = x_1^{(i)} w_1 + x_2^{(i)} w_2 + b ]

    (2) 损失函数

    在模型训练中,我们需要衡量价格预测值与真实值之间的误差。通常我们会选取一个非负数作为误差,且数值越小表示误差越小。一个常用的选择是平方函数。它在评估索引为 (i) 的样本误差的表达式为

    [ell^{(i)}(w_1, w_2, b) = frac{1}{2} left(hat{y}^{(i)} - y^{(i)} ight)^2 ]

    其中常数 (frac 1 2) 使对平方项求导后的常数系数为1,这样在形式上稍微简单一些。显然,误差越小表示预测价格与真实价格越相近,且当二者相等时误差为0。给定训练数据集,这个误差只与模型参数相关,因此我们将它记为以模型参数为参数的函数。在机器学习里,将衡量误差的函数称为损失函数(loss function)。这里使用的平方误差函数也称为平方损失(square loss)。

    通常,我们用训练数据集中所有样本误差的平均来衡量模型预测的质量,即

    [ell(w_1, w_2, b) =frac{1}{n} sum_{i=1}^n ell^{(i)}(w_1, w_2, b) =frac{1}{n} sum_{i=1}^n frac{1}{2}left(x_1^{(i)} w_1 + x_2^{(i)} w_2 + b - y^{(i)} ight)^2 ]

    在模型训练中,我们希望找出一组模型参数,记为 (w_1^*, w_2^*, b^*),来使训练样本平均损失最小:

    [w_1^*, w_2^*, b^* = underset{w_1, w_2, b}{argmin} ell(w_1, w_2, b) ]

    (3) 优化算法

    当模型和损失函数形式较为简单时,上面的误差最小化问题的解可以直接用公式表达出来。这类解叫作解析解(analytical solution)。本节使用的线性回归和平方误差刚好属于这个范畴。然而,大多数深度学习模型并没有解析解,只能通过优化算法有限次迭代模型参数来尽可能降低损失函数的值。这类解叫作数值解(numerical solution)。

    在求数值解的优化算法中,小批量随机梯度下降(mini-batch stochastic gradient descent)在深度学习中被广泛使用。它的算法很简单:先选取一组模型参数的初始值,如随机选取;接下来对参数进行多次迭代,使每次迭代都可能降低损失函数的值。在每次迭代中,先随机均匀采样一个由固定数目训练数据样本所组成的小批量(mini-batch)(mathcal{B}),然后求小批量中数据样本的平均损失有关模型参数的导数(梯度),最后用此结果与预先设定的一个正数的乘积作为模型参数在本次迭代的减小量。

    在训练本节讨论的线性回归模型的过程中,模型的每个参数将作如下迭代:

    [egin{aligned} w_1 &leftarrow w_1 - frac{eta}{|mathcal{B}|} sum_{i in mathcal{B}} frac{ partial ell^{(i)}(w_1, w_2, b) }{partial w_1} = w_1 - frac{eta}{|mathcal{B}|} sum_{i in mathcal{B}}x_1^{(i)} left(x_1^{(i)} w_1 + x_2^{(i)} w_2 + b - y^{(i)} ight),\ w_2 &leftarrow w_2 - frac{eta}{|mathcal{B}|} sum_{i in mathcal{B}} frac{ partial ell^{(i)}(w_1, w_2, b) }{partial w_2} = w_2 - frac{eta}{|mathcal{B}|} sum_{i in mathcal{B}}x_2^{(i)} left(x_1^{(i)} w_1 + x_2^{(i)} w_2 + b - y^{(i)} ight),\ b &leftarrow b - frac{eta}{|mathcal{B}|} sum_{i in mathcal{B}} frac{ partial ell^{(i)}(w_1, w_2, b) }{partial b} = b - frac{eta}{|mathcal{B}|} sum_{i in mathcal{B}}left(x_1^{(i)} w_1 + x_2^{(i)} w_2 + b - y^{(i)} ight). end{aligned} ]

    在上式中,(|mathcal{B}|) 代表每个小批量中的样本个数(批量大小,batch size),(eta) 称作学习率(learning rate)并取正数。需要强调的是,这里的批量大小和学习率的值是人为设定的,并不是通过模型训练学出的,因此叫作超参数(hyperparameter)。我们通常所说的“调参”指的正是调节超参数,例如通过反复试错来找到超参数合适的值。在少数情况下,超参数也可以通过模型训练学出。本书对此类情况不做讨论。

    模型预测

    模型训练完成后,我们将模型参数 (w_1, w_2, b) 在优化算法停止时的值分别记作 (hat{w}_1, hat{w}_2, hat{b})。注意,这里我们得到的并不一定是最小化损失函数的最优解 (w_1^*, w_2^*, b^*),而是对最优解的一个近似。然后,我们就可以使用学出的线性回归模型 (x_1 hat{w}_1 + x_2 hat{w}_2 + hat{b}) 来估算训练数据集以外任意一栋面积(平方米)为(x_1)、房龄(年)为(x_2)的房屋的价格了。这里的估算也叫作模型预测、模型推断或模型测试。

    线性回归的表示方法

    我们已经阐述了线性回归的模型表达式、训练和预测。下面我们解释线性回归与神经网络的联系,以及线性回归的矢量计算表达式。

    神经网络图

    在深度学习中,我们可以使用神经网络图直观地表现模型结构。为了更清晰地展示线性回归作为神经网络的结构,图3.1使用神经网络图表示本节中介绍的线性回归模型。神经网络图隐去了模型参数权重和偏差。

    图3.1 线性回归是一个单层神经网络

    在图3.1所示的神经网络中,输入分别为 (x_1)(x_2),因此输入层的输入个数为2。输入个数也叫特征数或特征向量维度。图3.1中网络的输出为 (o),输出层的输出个数为1。需要注意的是,我们直接将图3.1中神经网络的输出 (o) 作为线性回归的输出,即 (hat{y} = o)。由于输入层并不涉及计算,按照惯例,图3.1所示的神经网络的层数为1。所以,线性回归是一个单层神经网络。输出层中负责计算 (o) 的单元又叫神经元。在线性回归中,(o) 的计算依赖于 (x_1)(x_2)。也就是说,输出层中的神经元和输入层中各个输入完全连接。因此,这里的输出层又叫全连接层(fully-connected layer)或稠密层(dense layer)。

    矢量计算表达式

    在模型训练或预测时,我们常常会同时处理多个数据样本并用到矢量计算。在介绍线性回归的矢量计算表达式之前,让我们先考虑对两个向量相加的两种方法。a

    下面先定义两个1000维的向量。

    import torch
    from time import time
    
    a = torch.ones(1000)
    b = torch.ones(1000)
    

    向量相加的一种方法是,将这两个向量按元素逐一做标量加法。

    start = time()
    c = torch.zeros(1000)
    for i in range(1000):
        c[i] = a[i] + b[i]
        
    print(time() - start)
    

    输出:

    0.02039504051208496
    

    向量相加的另一种方法是,将这两个向量直接做矢量加法。

    start = time()
    d = a + b
    
    print(time() - start)
    

    输出:

    0.0008330345153808594
    

    结果很明显,后者比前者更省时。因此,我们应该尽可能采用矢量计算,以提升计算效率。

    让我们再次回到本节的房价预测问题。如果我们对训练数据集里的3个房屋样本(索引分别为1、2和3)逐一预测价格,将得到

    [egin{aligned} hat{y}^{(1)} &= x_1^{(1)} w_1 + x_2^{(1)} w_2 + b,\ hat{y}^{(2)} &= x_1^{(2)} w_1 + x_2^{(2)} w_2 + b,\ hat{y}^{(3)} &= x_1^{(3)} w_1 + x_2^{(3)} w_2 + b. end{aligned} ]

    现在,我们将上面3个等式转化成矢量计算。设

    [oldsymbol{hat{y}} = egin{bmatrix} hat{y}^{(1)} \ hat{y}^{(2)} \ hat{y}^{(3)} end{bmatrix},quad oldsymbol{X} = egin{bmatrix} x_1^{(1)} & x_2^{(1)} \ x_1^{(2)} & x_2^{(2)} \ x_1^{(3)} & x_2^{(3)} end{bmatrix},quad oldsymbol{w} = egin{bmatrix} w_1 \ w_2 end{bmatrix} ]

    对3个房屋样本预测价格的矢量计算表达式为(oldsymbol{hat{y}} = oldsymbol{X} oldsymbol{w} + b,) 其中的加法运算使用了广播机制(参见2.2节)。例如:

    a = torch.ones(3)
    b = 10
    print(a + b)
    

    输出:

    tensor([11., 11., 11.])
    

    广义上讲,当数据样本数为 (n),特征数为 (d) 时,线性回归的矢量计算表达式为

    [oldsymbol{hat{y}} = oldsymbol{X} oldsymbol{w} + b ]

    其中模型输出 (oldsymbol{hat{y}} in mathbb{R}^{n imes 1}) 批量数据样本特征 (oldsymbol{X} in mathbb{R}^{n imes d}),权重 (oldsymbol{w} in mathbb{R}^{d imes 1}), 偏差 (b in mathbb{R})。相应地,批量数据样本标签 (oldsymbol{y} in mathbb{R}^{n imes 1})。设模型参数 (oldsymbol{ heta} = [w_1, w_2, b]^ op),我们可以重写损失函数为

    [ell(oldsymbol{ heta})=frac{1}{2n}(oldsymbol{hat{y}}-oldsymbol{y})^ op(oldsymbol{hat{y}}-oldsymbol{y}) ]

    小批量随机梯度下降的迭代步骤将相应地改写为

    [oldsymbol{ heta} leftarrow oldsymbol{ heta} - frac{eta}{|mathcal{B}|} sum_{i in mathcal{B}} abla_{oldsymbol{ heta}} ell^{(i)}(oldsymbol{ heta}), ]

    其中梯度是损失有关3个为标量的模型参数的偏导数组成的向量:

    [ abla_{oldsymbol{ heta}} ell^{(i)}(oldsymbol{ heta})= egin{bmatrix} frac{ partial ell^{(i)}(w_1, w_2, b) }{partial w_1} \ frac{ partial ell^{(i)}(w_1, w_2, b) }{partial w_2} \ frac{ partial ell^{(i)}(w_1, w_2, b) }{partial b} end{bmatrix} = egin{bmatrix} x_1^{(i)} (x_1^{(i)} w_1 + x_2^{(i)} w_2 + b - y^{(i)}) \ x_2^{(i)} (x_1^{(i)} w_1 + x_2^{(i)} w_2 + b - y^{(i)}) \ x_1^{(i)} w_1 + x_2^{(i)} w_2 + b - y^{(i)} end{bmatrix}= egin{bmatrix} x_1^{(i)} \ x_2^{(i)} \ 1 end{bmatrix} (hat{y}^{(i)} - y^{(i)}) ]

    小结

    • 和大多数深度学习模型一样,对于线性回归这样一种单层神经网络,它的基本要素包括模型、训练数据、损失函数和优化算法。
    • 既可以用神经网络图表示线性回归,又可以用矢量计算表示该模型。
    • 应该尽可能采用矢量计算,以提升计算效率。

    注:本节除了代码之外与原书基本相同,原书传送门

    线性回归的从零开始实现

    在了解了线性回归的背景知识之后,现在我们可以动手实现它了。尽管强大的深度学习框架可以减少大量重复性工作,但若过于依赖它提供的便利,会导致我们很难深入理解深度学习是如何工作的。因此,本节将介绍如何只利用Tensorautograd来实现一个线性回归的训练。

    首先,导入本节中实验所需的包或模块,其中的matplotlib包可用于作图,且设置成嵌入显示。

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

    生成数据集

    我们构造一个简单的人工训练数据集,它可以使我们能够直观比较学到的参数和真实的模型参数的区别。设训练数据集样本数为1000,输入个数(特征数)为2。给定随机生成的批量样本特征 (oldsymbol{X} in mathbb{R}^{1000 imes 2}),我们使用线性回归模型真实权重 (oldsymbol{w} = [2, -3.4]^ op) 和偏差 (b = 4.2),以及一个随机噪声项 (epsilon) 来生成标签

    [oldsymbol{y} = oldsymbol{X}oldsymbol{w} + b + epsilon ]

    其中噪声项 (epsilon) 服从均值为0、标准差为0.01的正态分布。噪声代表了数据集中无意义的干扰。下面,让我们生成数据集。

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

    注意,features的每一行是一个长度为2的向量,而labels的每一行是一个长度为1的向量(标量)。

    print(features[0], labels[0])
    

    输出:

    tensor([0.8557, 0.4793]) tensor(4.2887)
    

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

    def use_svg_display():
        # 用矢量图显示
        display.set_matplotlib_formats('svg')
    
    def set_figsize(figsize=(3.5, 2.5)):
        use_svg_display()
        # 设置图的尺寸
        plt.rcParams['figure.figsize'] = figsize
    
    # # 在../d2lzh_pytorch里面添加上面两个函数后就可以这样导入
    # import sys
    # sys.path.append("..")
    # from d2lzh_pytorch import * 
    
    set_figsize()
    plt.scatter(features[:, 1].numpy(), labels.numpy(), 1);
    

    我们将上面的plt作图函数以及use_svg_display函数和set_figsize函数定义在d2lzh_pytorch包里。以后在作图时,我们将直接调用d2lzh_pytorch.plt。由于pltd2lzh_pytorch包中是一个全局变量,我们在作图前只需要调用d2lzh_pytorch.set_figsize()即可打印矢量图并设置图的尺寸。

    原书中提到的d2lzh里面使用了mxnet,改成pytorch实现后本项目统一将原书的d2lzh改为d2lzh_pytorch

    读取数据

    在训练模型的时候,我们需要遍历数据集并不断读取小批量数据样本。这里我们定义一个函数:它每次返回batch_size(批量大小)个随机样本的特征和标签。

    # 本函数已保存在d2lzh包中方便以后使用
    def data_iter(batch_size, features, labels):
        num_examples = len(features)
        indices = list(range(num_examples))
        random.shuffle(indices)  # 样本的读取顺序是随机的
        for i in range(0, num_examples, batch_size):
            j = torch.LongTensor(indices[i: min(i + batch_size, num_examples)]) # 最后一次可能不足一个batch
            yield  features.index_select(0, j), labels.index_select(0, j)
    

    让我们读取第一个小批量数据样本并打印。每个批量的特征形状为(10, 2),分别对应批量大小和输入个数;标签形状为批量大小。

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

    输出:

    tensor([[-1.4239, -1.3788],
            [ 0.0275,  1.3550],
            [ 0.7616, -1.1384],
            [ 0.2967, -0.1162],
            [ 0.0822,  2.0826],
            [-0.6343, -0.7222],
            [ 0.4282,  0.0235],
            [ 1.4056,  0.3506],
            [-0.6496, -0.5202],
            [-0.3969, -0.9951]]) 
     tensor([ 6.0394, -0.3365,  9.5882,  5.1810, -2.7355,  5.3873,  4.9827,  5.7962,
             4.6727,  6.7921])
    

    初始化模型参数

    我们将权重初始化成均值为0、标准差为0.01的正态随机数,偏差则初始化成0。

    w = torch.tensor(np.random.normal(0, 0.01, (num_inputs, 1)), dtype=torch.float32)
    b = torch.zeros(1, dtype=torch.float32)
    

    之后的模型训练中,需要对这些参数求梯度来迭代参数的值,因此我们要让它们的requires_grad=True

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

    定义模型

    下面是线性回归的矢量计算表达式的实现。我们使用mm函数做矩阵乘法。

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

    定义损失函数

    我们使用上一节描述的平方损失来定义线性回归的损失函数。在实现中,我们需要把真实值y变形成预测值y_hat的形状。以下函数返回的结果也将和y_hat的形状相同。

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

    定义优化算法

    以下的sgd函数实现了上一节中介绍的小批量随机梯度下降算法。它通过不断迭代模型参数来优化损失函数。这里自动求梯度模块计算得来的梯度是一个批量样本的梯度和。我们将它除以批量大小来得到平均值。

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

    训练模型

    在训练中,我们将多次迭代模型参数。在每次迭代中,我们根据当前读取的小批量数据样本(特征X和标签y),通过调用反向函数backward计算小批量随机梯度,并调用优化算法sgd迭代模型参数。由于我们之前设批量大小batch_size为10,每个小批量的损失l的形状为(10, 1)。回忆一下自动求梯度一节。由于变量l并不是一个标量,所以我们可以调用.sum()将其求和得到一个标量,再运行l.backward()得到该变量有关模型参数的梯度。注意在每次更新完参数后不要忘了将参数的梯度清零。

    在一个迭代周期(epoch)中,我们将完整遍历一遍data_iter函数,并对训练数据集中所有样本都使用一次(假设样本数能够被批量大小整除)。这里的迭代周期个数num_epochs和学习率lr都是超参数,分别设3和0.03。在实践中,大多超参数都需要通过反复试错来不断调节。虽然迭代周期数设得越大模型可能越有效,但是训练时间可能过长。而有关学习率对模型的影响,我们会在后面“优化算法”一章中详细介绍。

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

    输出:

    epoch 1, loss 0.028127
    epoch 2, loss 0.000095
    epoch 3, loss 0.000050
    

    训练完成后,我们可以比较学到的参数和用来生成训练集的真实参数。它们应该很接近。

    print(true_w, '
    ', w)
    print(true_b, '
    ', b)
    

    输出:

    [2, -3.4] 
     tensor([[ 1.9998],
            [-3.3998]], requires_grad=True)
    4.2 
     tensor([4.2001], requires_grad=True)
    

    小结

    • 可以看出,仅使用Tensorautograd模块就可以很容易地实现一个模型。接下来,本书会在此基础上描述更多深度学习模型,并介绍怎样使用更简洁的代码(见下一节)来实现它们。

    注:本节除了代码之外与原书基本相同,原书传送门

    线性回归的简洁实现

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

    生成数据集

    我们生成与上一节中相同的数据集。其中features是训练数据特征,labels是标签。

    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)
    

    这里data_iter的使用跟上一节中的一样。让我们读取并打印第一个小批量数据样本。

    for X, y in data_iter:
        print(X, y)
        break
    

    输出:

    tensor([[-2.7723, -0.6627],
            [-1.1058,  0.7688],
            [ 0.4901, -1.2260],
            [-0.7227, -0.2664],
            [-0.3390,  0.1162],
            [ 1.6705, -2.7930],
            [ 0.2576, -0.2928],
            [ 2.0475, -2.7440],
            [ 1.0685,  1.1920],
            [ 1.0996,  0.5106]]) 
     tensor([ 0.9066, -0.6247,  9.3383,  3.6537,  3.1283, 17.0213,  5.6953, 17.6279,
             2.2809,  4.6661])
    

    定义模型

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

    首先,导入torch.nn模块。实际上,“nn”是neural networks(神经网络)的缩写。顾名思义,该模块定义了大量神经网络的层。之前我们已经用过了autograd,而nn就是利用autograd来定义模型。nn的核心数据结构是Module,它是一个抽象概念,既可以表示神经网络中的某个层(layer),也可以表示一个包含很多层的神经网络。在实际使用中,最常见的做法是继承nn.Module,撰写自己的网络/层。一个nn.Module实例应该包含一些层以及返回输出的前向传播(forward)方法。下面先来看看如何用nn.Module实现一个线性回归模型。

    class LinearNet(nn.Module):
        def __init__(self, n_feature):
            super(LinearNet, self).__init__()
            self.linear = nn.Linear(n_feature, 1)
        # forward 定义前向传播
        def forward(self, x):
            y = self.linear(x)
            return y
        
    net = LinearNet(num_inputs)
    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])
    

    输出:

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

    可以通过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)
    

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

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

    初始化模型参数

    在使用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是用3.3.3节一开始的代码自定义的,那么上面代码会报错,net[0].weight应改为net.linear.weightbias亦然。因为net[0]这样根据下标访问子模块的写法只有当net是个ModuleList或者Sequential实例时才可以,详见4.1节。

    定义损失函数

    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中对应的学习率,另一种是更简单也是较为推荐的做法——新建优化器,由于optimizer十分轻量级,构建开销很小,故而可以构建新的optimizer。但是后者对于使用动量的优化器(如Adam),会丢失动量等状态信息,可能会造成损失函数的收敛出现震荡等情况。

    # 调整学习率
    for param_group in optimizer.param_groups:
        param_group['lr'] *= 0.1 # 学习率为之前的0.1倍
    

    训练模型

    在使用Gluon训练模型时,我们通过调用optim实例的step函数来迭代模型参数。按照小批量随机梯度下降的定义,我们在step函数中指明批量大小,从而对批量中样本梯度求平均。

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

    小结

    • 使用PyTorch可以更简洁地实现模型。
    • torch.utils.data模块提供了有关数据处理的工具,torch.nn模块定义了大量神经网络的层,torch.nn.init模块定义了各种初始化方法,torch.optim模块提供了很多常用的优化算法。

    注:本节除了代码之外与原书基本相同,原书传送门

    softmax回归

    前几节介绍的线性回归模型适用于输出为连续值的情景。在另一类情景中,模型输出可以是一个像图像类别这样的离散值。对于这样的离散值预测问题,我们可以使用诸如softmax回归在内的分类模型。和线性回归不同,softmax回归的输出单元从一个变成了多个,且引入了softmax运算使输出更适合离散值的预测和训练。本节以softmax回归模型为例,介绍神经网络中的分类模型。

    分类问题

    让我们考虑一个简单的图像分类问题,其输入图像的高和宽均为2像素,且色彩为灰度。这样每个像素值都可以用一个标量表示。我们将图像中的4像素分别记为(x_1, x_2, x_3, x_4)。假设训练数据集中图像的真实标签为狗、猫或鸡(假设可以用4像素表示出这3种动物),这些标签分别对应离散值(y_1, y_2, y_3)

    我们通常使用离散的数值来表示类别,例如(y_1=1, y_2=2, y_3=3)。如此,一张图像的标签为1、2和3这3个数值中的一个。虽然我们仍然可以使用回归模型来进行建模,并将预测值就近定点化到1、2和3这3个离散值之一,但这种连续值到离散值的转化通常会影响到分类质量。因此我们一般使用更加适合离散值输出的模型来解决分类问题。

    softmax回归模型

    softmax回归跟线性回归一样将输入特征与权重做线性叠加。与线性回归的一个主要不同在于,softmax回归的输出值个数等于标签里的类别数。因为一共有4种特征和3种输出动物类别,所以权重包含12个标量(带下标的(w))、偏差包含3个标量(带下标的(b)),且对每个输入计算(o_1, o_2, o_3)这3个输出:

    [egin{aligned} o_1 &= x_1 w_{11} + x_2 w_{21} + x_3 w_{31} + x_4 w_{41} + b_1,\ o_2 &= x_1 w_{12} + x_2 w_{22} + x_3 w_{32} + x_4 w_{42} + b_2,\ o_3 &= x_1 w_{13} + x_2 w_{23} + x_3 w_{33} + x_4 w_{43} + b_3. end{aligned} ]

    图3.2用神经网络图描绘了上面的计算。softmax回归同线性回归一样,也是一个单层神经网络。由于每个输出(o_1, o_2, o_3)的计算都要依赖于所有的输入(x_1, x_2, x_3, x_4),softmax回归的输出层也是一个全连接层。

    图3.2 softmax回归是一个单层神经网络

    既然分类问题需要得到离散的预测输出,一个简单的办法是将输出值(o_i)当作预测类别是(i)的置信度,并将值最大的输出所对应的类作为预测输出,即输出 (underset{i}{argmax} o_i)。例如,如果(o_1,o_2,o_3)分别为(0.1,10,0.1),由于(o_2)最大,那么预测类别为2,其代表猫。

    然而,直接使用输出层的输出有两个问题。一方面,由于输出层的输出值的范围不确定,我们难以直观上判断这些值的意义。例如,刚才举的例子中的输出值10表示“很置信”图像类别为猫,因为该输出值是其他两类的输出值的100倍。但如果(o_1=o_3=10^3),那么输出值10却又表示图像类别为猫的概率很低。另一方面,由于真实标签是离散值,这些离散值与不确定范围的输出值之间的误差难以衡量。

    softmax运算符(softmax operator)解决了以上两个问题。它通过下式将输出值变换成值为正且和为1的概率分布:

    [hat{y}_1, hat{y}_2, hat{y}_3 = ext{softmax}(o_1, o_2, o_3) ]

    其中

    [hat{y}_1 = frac{ exp(o_1)}{sum_{i=1}^3 exp(o_i)},quad hat{y}_2 = frac{ exp(o_2)}{sum_{i=1}^3 exp(o_i)},quad hat{y}_3 = frac{ exp(o_3)}{sum_{i=1}^3 exp(o_i)}. ]

    容易看出(hat{y}_1 + hat{y}_2 + hat{y}_3 = 1)(0 leq hat{y}_1, hat{y}_2, hat{y}_3 leq 1),因此(hat{y}_1, hat{y}_2, hat{y}_3)是一个合法的概率分布。这时候,如果(hat{y}_2=0.8),不管(hat{y}_1)(hat{y}_3)的值是多少,我们都知道图像类别为猫的概率是80%。此外,我们注意到

    [underset{i}{argmax} o_i = underset{i}{argmax} hat{y}_i ]

    因此softmax运算不改变预测类别输出。

    单样本分类的矢量计算表达式

    为了提高计算效率,我们可以将单样本分类通过矢量计算来表达。在上面的图像分类问题中,假设softmax回归的权重和偏差参数分别为

    [oldsymbol{W} = egin{bmatrix} w_{11} & w_{12} & w_{13} \ w_{21} & w_{22} & w_{23} \ w_{31} & w_{32} & w_{33} \ w_{41} & w_{42} & w_{43} end{bmatrix},quad oldsymbol{b} = egin{bmatrix} b_1 & b_2 & b_3 end{bmatrix}, ]

    设高和宽分别为2个像素的图像样本(i)的特征为

    [oldsymbol{x}^{(i)} = egin{bmatrix}x_1^{(i)} & x_2^{(i)} & x_3^{(i)} & x_4^{(i)}end{bmatrix}, ]

    输出层的输出为

    [oldsymbol{o}^{(i)} = egin{bmatrix}o_1^{(i)} & o_2^{(i)} & o_3^{(i)}end{bmatrix}, ]

    预测为狗、猫或鸡的概率分布为

    [oldsymbol{hat{y}}^{(i)} = egin{bmatrix}hat{y}_1^{(i)} & hat{y}_2^{(i)} & hat{y}_3^{(i)}end{bmatrix}. ]

    softmax回归对样本(i)分类的矢量计算表达式为

    [egin{aligned} oldsymbol{o}^{(i)} &= oldsymbol{x}^{(i)} oldsymbol{W} + oldsymbol{b},\ oldsymbol{hat{y}}^{(i)} &= ext{softmax}(oldsymbol{o}^{(i)}). end{aligned} ]

    小批量样本分类的矢量计算表达式

    为了进一步提升计算效率,我们通常对小批量数据做矢量计算。广义上讲,给定一个小批量样本,其批量大小为(n),输入个数(特征数)为(d),输出个数(类别数)为(q)。设批量特征为(oldsymbol{X} in mathbb{R}^{n imes d})。假设softmax回归的权重和偏差参数分别为(oldsymbol{W} in mathbb{R}^{d imes q})(oldsymbol{b} in mathbb{R}^{1 imes q})。softmax回归的矢量计算表达式为

    [egin{aligned} oldsymbol{O} &= oldsymbol{X} oldsymbol{W} + oldsymbol{b},\ oldsymbol{hat{Y}} &= ext{softmax}(oldsymbol{O}), end{aligned} ]

    其中的加法运算使用了广播机制,(oldsymbol{O}, oldsymbol{hat{Y}} in mathbb{R}^{n imes q})且这两个矩阵的第(i)行分别为样本(i)的输出(oldsymbol{o}^{(i)})和概率分布(oldsymbol{hat{y}}^{(i)})

    交叉熵损失函数

    前面提到,使用softmax运算后可以更方便地与离散标签计算误差。我们已经知道,softmax运算将输出变换成一个合法的类别预测分布。实际上,真实标签也可以用类别分布表达:对于样本(i),我们构造向量(oldsymbol{y}^{(i)}in mathbb{R}^{q}) ,使其第(y^{(i)})(样本(i)类别的离散数值)个元素为1,其余为0。这样我们的训练目标可以设为使预测概率分布(oldsymbol{hat y}^{(i)})尽可能接近真实的标签概率分布(oldsymbol{y}^{(i)})

    我们可以像线性回归那样使用平方损失函数(|oldsymbol{hat y}^{(i)}-oldsymbol{y}^{(i)}|^2/2)。然而,想要预测分类结果正确,我们其实并不需要预测概率完全等于标签概率。例如,在图像分类的例子里,如果(y^{(i)}=3),那么我们只需要(hat{y}^{(i)}_3)比其他两个预测值(hat{y}^{(i)}_1)(hat{y}^{(i)}_2)大就行了。即使(hat{y}^{(i)}_3)值为0.6,不管其他两个预测值为多少,类别预测均正确。而平方损失则过于严格,例如(hat y^{(i)}_1=hat y^{(i)}_2=0.2)(hat y^{(i)}_1=0, hat y^{(i)}_2=0.4)的损失要小很多,虽然两者都有同样正确的分类预测结果。

    改善上述问题的一个方法是使用更适合衡量两个概率分布差异的测量函数。其中,交叉熵(cross entropy)是一个常用的衡量方法:

    [Hleft(oldsymbol y^{(i)}, oldsymbol {hat y}^{(i)} ight ) = -sum_{j=1}^q y_j^{(i)} log hat y_j^{(i)}, ]

    其中带下标的(y_j^{(i)})是向量(oldsymbol y^{(i)})中非0即1的元素,需要注意将它与样本(i)类别的离散数值,即不带下标的(y^{(i)})区分。在上式中,我们知道向量(oldsymbol y^{(i)})中只有第(y^{(i)})个元素(y^{(i)}_{y^{(i)}})为1,其余全为0,于是(H(oldsymbol y^{(i)}, oldsymbol {hat y}^{(i)}) = -log hat y_{y^{(i)}}^{(i)})。也就是说,交叉熵只关心对正确类别的预测概率,因为只要其值足够大,就可以确保分类结果正确。当然,遇到一个样本有多个标签时,例如图像里含有不止一个物体时,我们并不能做这一步简化。但即便对于这种情况,交叉熵同样只关心对图像中出现的物体类别的预测概率。

    假设训练数据集的样本数为(n),交叉熵损失函数定义为

    [ell(oldsymbol{Theta}) = frac{1}{n} sum_{i=1}^n Hleft(oldsymbol y^{(i)}, oldsymbol {hat y}^{(i)} ight ), ]

    其中(oldsymbol{Theta})代表模型参数。同样地,如果每个样本只有一个标签,那么交叉熵损失可以简写成(ell(oldsymbol{Theta}) = -(1/n) sum_{i=1}^n log hat y_{y^{(i)}}^{(i)})。从另一个角度来看,我们知道最小化(ell(oldsymbol{Theta}))等价于最大化(exp(-nell(oldsymbol{Theta}))=prod_{i=1}^n hat y_{y^{(i)}}^{(i)}),即最小化交叉熵损失函数等价于最大化训练数据集所有标签类别的联合预测概率。

    模型预测及评价

    在训练好softmax回归模型后,给定任一样本特征,就可以预测每个输出类别的概率。通常,我们把预测概率最大的类别作为输出类别。如果它与真实类别(标签)一致,说明这次预测是正确的。在3.6节的实验中,我们将使用准确率(accuracy)来评价模型的表现。它等于正确预测数量与总预测数量之比。

    小结

    • softmax回归适用于分类问题。它使用softmax运算输出类别的概率分布。
    • softmax回归是一个单层神经网络,输出个数等于分类问题中的类别个数。
    • 交叉熵适合衡量两个概率分布的差异。

    注:本节与原书基本相同,原书此节传送门

    图像分类数据集(Fashion-MNIST)

    在介绍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.pyplot as plt
    import time
    import sys
    sys.path.append("..") # 为了导入上层目录的d2lzh_pytorch
    import d2lzh_pytorch as d2l
    

    下面,我们通过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()
        # 这里的_表示我们忽略(不使用)的变量
        _, figs = plt.subplots(1, len(images), figsize=(12, 12))
        for f, img, lbl in zip(figs, images, labels):
            f.imshow(img.view((28, 28)).numpy())
            f.set_title(lbl)
            f.axes.get_xaxis().set_visible(False)
            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))
    

    读取小批量

    我们将在训练数据集上训练模型,并将训练好的模型在测试数据集上评价模型的表现。前面说过,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两个变量。随着本书内容的不断深入,我们会进一步改进该函数。它的完整实现将在5.6节中描述。

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

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

    输出:

    1.57 sec
    

    小结

    • Fashion-MNIST是一个10类服饰分类数据集,之后章节里将使用它来检验不同算法的表现。
    • 我们将高和宽分别为(h)(w)像素的图像的形状记为(h imes w)(h,w)

    参考文献

    [1] LeCun, Y., Cortes, C., & Burges, C. http://yann.lecun.com/exdb/mnist/

    [2] Xiao, H., Rasul, K., & Vollgraf, R. (2017). Fashion-mnist: a novel image dataset for benchmarking machine learning algorithms. arXiv preprint arXiv:1708.07747.


    注:本节除了代码之外与原书基本相同,原书传送门# 3.6 softmax回归的从零开始实现

    这一节我们来动手实现softmax回归。首先导入本节实现所需的包或模块。

    import torch
    import torchvision
    import numpy as np
    import sys
    sys.path.append("..") # 为了导入上层目录的d2lzh_pytorch
    import d2lzh_pytorch as d2l
    

    softmax回归的从零开始实现

    获取和读取数据

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

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

    初始化模型参数

    跟线性回归中的例子一样,我们将使用向量表示每个样本。已知每个样本输入是高和宽均为28像素的图像。模型的输入向量的长度是 (28 imes 28 = 784):该向量的每个元素对应图像中每个像素。由于图像有10个类别,单层神经网络输出层的输出个数为10,因此softmax回归的权重和偏差参数分别为(784 imes 10)(1 imes 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个样本的标签的预测概率。与3.4节(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))
    

    输出:

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

    下面实现了3.4节(softmax回归)中介绍的交叉熵损失函数。

    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 + '
    ' + pred for true, pred in zip(true_labels, pred_labels)]
    
    d2l.show_fashion_mnist(X[0:9], titles[0:9])
    

    小结

    • 可以使用softmax回归做多类别分类。与训练线性回归相比,你会发现训练softmax回归的步骤和它非常相似:获取并读取数据、定义模型和损失函数并使用优化算法训练模型。事实上,绝大多数深度学习模型的训练都有着类似的步骤。

    注:本节除了代码之外与原书基本相同,原书传送门# 3.7 softmax回归的简洁实现

    我们在3.3节(线性回归的简洁实现)中已经了解了使用Pytorch实现模型的便利。下面,让我们再次使用Pytorch来实现一个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
    

    softmax回归的简洁实现

    获取和读取数据

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

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

    定义和初始化模型

    在3.4节(softmax回归)中提到,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
    

    小结

    • PyTorch提供的函数往往具有更好的数值稳定性。
    • 可以使用PyTorch更简洁地实现softmax回归。

    注:本节除了代码之外与原书基本相同,原书传送门# 3.8 多层感知机

    我们已经介绍了包括线性回归和softmax回归在内的单层神经网络。然而深度学习主要关注多层模型。在本节中,我们将以多层感知机(multilayer perceptron,MLP)为例,介绍多层神经网络的概念。

    多层感知机

    隐藏层

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

    图3.3 带有隐藏层的多层感知机

    在图3.3所示的多层感知机中,输入和输出个数分别为4和3,中间的隐藏层中包含了5个隐藏单元(hidden unit)。由于输入层不涉及计算,图3.3中的多层感知机的层数为2。由图3.3可见,隐藏层中的神经元和输入层中各个输入完全连接,输出层中的神经元和隐藏层中的各个神经元也完全连接。因此,多层感知机中的隐藏层和输出层都是全连接层。

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

    我们先来看一种含单隐藏层的多层感知机的设计。其输出(oldsymbol{O} in mathbb{R}^{n imes q})的计算为

    [egin{aligned} oldsymbol{H} &= oldsymbol{X} oldsymbol{W}_h + oldsymbol{b}_h,\ oldsymbol{O} &= oldsymbol{H} oldsymbol{W}_o + oldsymbol{b}_o, end{aligned} ]

    也就是将隐藏层的输出直接作为输出层的输入。如果将以上两个式子联立起来,可以得到

    [oldsymbol{O} = (oldsymbol{X} oldsymbol{W}_h + oldsymbol{b}_h)oldsymbol{W}_o + oldsymbol{b}_o = oldsymbol{X} oldsymbol{W}_holdsymbol{W}_o + oldsymbol{b}_h oldsymbol{W}_o + oldsymbol{b}_o. ]

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

    激活函数

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

    ReLU函数

    ReLU(rectified linear unit)函数提供了一个很简单的非线性变换。给定元素(x),该函数定义为

    [ ext{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')
    

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

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

    sigmoid函数

    sigmoid函数可以将元素的值变换到0和1之间:

    [ ext{sigmoid}(x) = frac{1}{1 + exp(-x)}. ]

    sigmoid函数在早期的神经网络中较为普遍,但它目前逐渐被更简单的ReLU函数取代。在后面“循环神经网络”一章中我们会介绍如何利用它值域在0到1之间这一特性来控制信息在神经网络中的流动。下面绘制了sigmoid函数。当输入接近0时,sigmoid函数接近线性变换。

    y = x.sigmoid()
    xyplot(x, y, 'sigmoid')
    

    依据链式法则,sigmoid函数的导数

    [ ext{sigmoid}'(x) = ext{sigmoid}(x)left(1- ext{sigmoid}(x) ight). ]

    下面绘制了sigmoid函数的导数。当输入为0时,sigmoid函数的导数达到最大值0.25;当输入越偏离0时,sigmoid函数的导数越接近0。

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

    tanh函数

    tanh(双曲正切)函数可以将元素的值变换到-1和1之间:

    [ ext{tanh}(x) = frac{1 - exp(-2x)}{1 + exp(-2x)}. ]

    我们接着绘制tanh函数。当输入接近0时,tanh函数接近线性变换。虽然该函数的形状和sigmoid函数的形状很像,但tanh函数在坐标系的原点上对称。

    y = x.tanh()
    xyplot(x, y, 'tanh')
    

    依据链式法则,tanh函数的导数

    [ ext{tanh}'(x) = 1 - ext{tanh}^2(x). ]

    下面绘制了tanh函数的导数。当输入为0时,tanh函数的导数达到最大值1;当输入越偏离0时,tanh函数的导数越接近0。

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

    多层感知机

    多层感知机就是含有至少一个隐藏层的由全连接层组成的神经网络,且每个隐藏层的输出通过激活函数进行变换。多层感知机的层数和各隐藏层中隐藏单元个数都是超参数。以单隐藏层为例并沿用本节之前定义的符号,多层感知机按以下方式计算输出:

    [egin{aligned} oldsymbol{H} &= phi(oldsymbol{X} oldsymbol{W}_h + oldsymbol{b}_h),\ oldsymbol{O} &= oldsymbol{H} oldsymbol{W}_o + oldsymbol{b}_o, end{aligned} ]

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

    小结

    • 多层感知机在输出层与输入层之间加入了一个或多个全连接隐藏层,并通过激活函数对隐藏层输出进行变换。
    • 常用的激活函数包括ReLU函数、sigmoid函数和tanh函数。

    注:本节除了代码之外与原书基本相同,原书传送门

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

    我们已经从上一节里了解了多层感知机的原理。下面,我们一起来动手实现一个多层感知机。首先导入实现所需的包或模块。

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

    定义模型参数

    我们在3.6节(softmax回归的从零开始实现)里已经介绍了,Fashion-MNIST数据集中图像形状为 (28 imes 28),类别数为10。本节中我们依然使用长度为 (28 imes 28 = 784) 的向量表示每一张图像。因此,输入个数为784,输出个数为10。实验中,我们设超参数隐藏单元个数为256。

    num_inputs, num_outputs, num_hiddens = 784, 10, 256
    
    W1 = torch.tensor(np.random.normal(0, 0.01, (num_inputs, num_hiddens)), dtype=torch.float)
    b1 = torch.zeros(num_hiddens, dtype=torch.float)
    W2 = torch.tensor(np.random.normal(0, 0.01, (num_hiddens, num_outputs)), dtype=torch.float)
    b2 = torch.zeros(num_outputs, dtype=torch.float)
    
    params = [W1, b1, W2, b2]
    for param in params:
        param.requires_grad_(requires_grad=True)
    

    定义激活函数

    这里我们使用基础的max函数来实现ReLU,而非直接调用relu函数。

    def relu(X):
        return torch.max(input=X, other=torch.tensor(0.0))
    

    定义模型

    同softmax回归一样,我们通过view函数将每张原始图像改成长度为num_inputs的向量。然后我们实现上一节中多层感知机的计算表达式。

    def net(X):
        X = X.view((-1, num_inputs))
        H = relu(torch.matmul(X, W1) + b1)
        return torch.matmul(H, W2) + b2
    

    定义损失函数

    为了得到更好的数值稳定性,我们直接使用PyTorch提供的包括softmax运算和交叉熵损失计算的函数。

    loss = torch.nn.CrossEntropyLoss()
    

    训练模型

    训练多层感知机的步骤和3.6节中训练softmax回归的步骤没什么区别。我们直接调用d2lzh_pytorch包中的train_ch3函数,它的实现已经在3.6节里介绍过。我们在这里设超参数迭代周期数为5,学习率为100.0。

    注:由于原书的mxnet中的SoftmaxCrossEntropyLoss在反向传播的时候相对于沿batch维求和了,而PyTorch默认的是求平均,所以用PyTorch计算得到的loss比mxnet小很多(大概是maxnet计算得到的1/batch_size这个量级),所以反向传播得到的梯度也小很多,所以为了得到差不多的学习效果,我们把学习率调得成原书的约batch_size倍,原书的学习率为0.5,这里设置成100.0。(之所以这么大,应该是因为d2lzh_pytorch里面的sgd函数在更新的时候除以了batch_size,其实PyTorch在计算loss的时候已经除过一次了,sgd这里应该不用除了)

    num_epochs, lr = 5, 100.0
    d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, batch_size, params, lr)
    

    输出:

    epoch 1, loss 0.0030, train acc 0.714, test acc 0.753
    epoch 2, loss 0.0019, train acc 0.821, test acc 0.777
    epoch 3, loss 0.0017, train acc 0.842, test acc 0.834
    epoch 4, loss 0.0015, train acc 0.857, test acc 0.839
    epoch 5, loss 0.0014, train acc 0.865, test acc 0.845
    

    小结

    • 可以通过手动定义模型及其参数来实现简单的多层感知机。
    • 当多层感知机的层数较多时,本节的实现方法会显得较烦琐,例如在定义模型参数的时候。

    注:本节除了代码之外与原书基本相同,原书传送门

    多层感知机的简洁实现

    下面我们使用PyTorch来实现上一节中的多层感知机。首先导入所需的包或模块。

    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
    

    定义模型

    和softmax回归唯一的不同在于,我们多加了一个全连接层作为隐藏层。它的隐藏单元个数为256,并使用ReLU函数作为激活函数。

    num_inputs, num_outputs, num_hiddens = 784, 10, 256
        
    net = nn.Sequential(
            d2l.FlattenLayer(),
            nn.Linear(num_inputs, num_hiddens),
            nn.ReLU(),
            nn.Linear(num_hiddens, num_outputs), 
            )
    
    for params in net.parameters():
        init.normal_(params, mean=0, std=0.01)
    

    读取数据并训练模型

    我们使用与3.7节中训练softmax回归几乎相同的步骤来读取数据并训练模型。

    注:由于这里使用的是PyTorch的SGD而不是d2lzh_pytorch里面的sgd,所以就不存在3.9节那样学习率看起来很大的问题了。

    batch_size = 256
    train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
    loss = torch.nn.CrossEntropyLoss()
    
    optimizer = torch.optim.SGD(net.parameters(), lr=0.5)
    
    num_epochs = 5
    d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, batch_size, None, None, optimizer)
    

    输出:

    epoch 1, loss 0.0030, train acc 0.712, test acc 0.744
    epoch 2, loss 0.0019, train acc 0.823, test acc 0.821
    epoch 3, loss 0.0017, train acc 0.844, test acc 0.842
    epoch 4, loss 0.0015, train acc 0.856, test acc 0.842
    epoch 5, loss 0.0014, train acc 0.864, test acc 0.818
    

    小结

    • 通过PyTorch可以更简洁地实现多层感知机。

    注:本节除了代码之外与原书基本相同,原书传送门

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

    在前几节基于Fashion-MNIST数据集的实验中,我们评价了机器学习模型在训练数据集和测试数据集上的表现。如果你改变过实验中的模型结构或者超参数,你也许发现了:当模型在训练数据集上更准确时,它在测试数据集上却不一定更准确。这是为什么呢?

    训练误差和泛化误差

    在解释上述现象之前,我们需要区分训练误差(training error)和泛化误差(generalization error)。通俗来讲,前者指模型在训练数据集上表现出的误差,后者指模型在任意一个测试数据样本上表现出的误差的期望,并常常通过测试数据集上的误差来近似。计算训练误差和泛化误差可以使用之前介绍过的损失函数,例如线性回归用到的平方损失函数和softmax回归用到的交叉熵损失函数。

    让我们以高考为例来直观地解释训练误差和泛化误差这两个概念。训练误差可以认为是做往年高考试题(训练题)时的错误率,泛化误差则可以通过真正参加高考(测试题)时的答题错误率来近似。假设训练题和测试题都随机采样于一个未知的依照相同考纲的巨大试题库。如果让一名未学习中学知识的小学生去答题,那么测试题和训练题的答题错误率可能很相近。但如果换成一名反复练习训练题的高三备考生答题,即使在训练题上做到了错误率为0,也不代表真实的高考成绩会如此。

    在机器学习里,我们通常假设训练数据集(训练题)和测试数据集(测试题)里的每一个样本都是从同一个概率分布中相互独立地生成的。基于该独立同分布假设,给定任意一个机器学习模型(含参数),它的训练误差的期望和泛化误差都是一样的。例如,如果我们将模型参数设成随机值(小学生),那么训练误差和泛化误差会非常相近。但我们从前面几节中已经了解到,模型的参数是通过在训练数据集上训练模型而学习出的,参数的选择依据了最小化训练误差(高三备考生)。所以,训练误差的期望小于或等于泛化误差。也就是说,一般情况下,由训练数据集学到的模型参数会使模型在训练数据集上的表现优于或等于在测试数据集上的表现。由于无法从训练误差估计泛化误差,一味地降低训练误差并不意味着泛化误差一定会降低。

    机器学习模型应关注降低泛化误差。

    模型选择

    在机器学习中,通常需要评估若干候选模型的表现并从中选择模型。这一过程称为模型选择(model selection)。可供选择的候选模型可以是有着不同超参数的同类模型。以多层感知机为例,我们可以选择隐藏层的个数,以及每个隐藏层中隐藏单元个数和激活函数。为了得到有效的模型,我们通常要在模型选择上下一番功夫。下面,我们来描述模型选择中经常使用的验证数据集(validation data set)。

    验证数据集

    从严格意义上讲,测试集只能在所有超参数和模型参数选定后使用一次。不可以使用测试数据选择模型,如调参。由于无法从训练误差估计泛化误差,因此也不应只依赖训练数据选择模型。鉴于此,我们可以预留一部分在训练数据集和测试数据集以外的数据来进行模型选择。这部分数据被称为验证数据集,简称验证集(validation set)。例如,我们可以从给定的训练集中随机选取一小部分作为验证集,而将剩余部分作为真正的训练集。

    然而在实际应用中,由于数据不容易获取,测试数据极少只使用一次就丢弃。因此,实践中验证数据集和测试数据集的界限可能比较模糊。从严格意义上讲,除非明确说明,否则本书中实验所使用的测试集应为验证集,实验报告的测试结果(如测试准确率)应为验证结果(如验证准确率)。

    (K)折交叉验证

    由于验证数据集不参与模型训练,当训练数据不够用时,预留大量的验证数据显得太奢侈。一种改善的方法是(K)折交叉验证((K)-fold cross-validation)。在(K)折交叉验证中,我们把原始训练数据集分割成(K)个不重合的子数据集,然后我们做(K)次模型训练和验证。每一次,我们使用一个子数据集验证模型,并使用其他(K-1)个子数据集来训练模型。在这(K)次训练和验证中,每次用来验证模型的子数据集都不同。最后,我们对这(K)次训练误差和验证误差分别求平均。

    欠拟合和过拟合

    接下来,我们将探究模型训练中经常出现的两类典型问题:一类是模型无法得到较低的训练误差,我们将这一现象称作欠拟合(underfitting);另一类是模型的训练误差远小于它在测试数据集上的误差,我们称该现象为过拟合(overfitting)。在实践中,我们要尽可能同时应对欠拟合和过拟合。虽然有很多因素可能导致这两种拟合问题,在这里我们重点讨论两个因素:模型复杂度和训练数据集大小。

    关于模型复杂度和训练集大小对学习的影响的详细理论分析可参见我写的这篇博客

    模型复杂度

    为了解释模型复杂度,我们以多项式函数拟合为例。给定一个由标量数据特征(x)和对应的标量标签(y)组成的训练数据集,多项式函数拟合的目标是找一个(K)阶多项式函数

    [hat{y} = b + sum_{k=1}^K x^k w_k ]

    来近似 (y)。在上式中,(w_k)是模型的权重参数,(b)是偏差参数。与线性回归相同,多项式函数拟合也使用平方损失函数。特别地,一阶多项式函数拟合又叫线性函数拟合。

    因为高阶多项式函数模型参数更多,模型函数的选择空间更大,所以高阶多项式函数比低阶多项式函数的复杂度更高。因此,高阶多项式函数比低阶多项式函数更容易在相同的训练数据集上得到更低的训练误差。给定训练数据集,模型复杂度和误差之间的关系通常如图3.4所示。给定训练数据集,如果模型的复杂度过低,很容易出现欠拟合;如果模型复杂度过高,很容易出现过拟合。应对欠拟合和过拟合的一个办法是针对数据集选择合适复杂度的模型。

    图3.4 模型复杂度对欠拟合和过拟合的影响

    训练数据集大小

    影响欠拟合和过拟合的另一个重要因素是训练数据集的大小。一般来说,如果训练数据集中样本数过少,特别是比模型参数数量(按元素计)更少时,过拟合更容易发生。此外,泛化误差不会随训练数据集里样本数量增加而增大。因此,在计算资源允许的范围之内,我们通常希望训练数据集大一些,特别是在模型复杂度较高时,例如层数较多的深度学习模型。

    多项式函数拟合实验

    为了理解模型复杂度和训练数据集大小对欠拟合和过拟合的影响,下面我们以多项式函数拟合为例来实验。首先导入实验需要的包或模块。

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

    生成数据集

    我们将生成一个人工数据集。在训练数据集和测试数据集中,给定样本特征(x),我们使用如下的三阶多项式函数来生成该样本的标签:

    [y = 1.2x - 3.4x^2 + 5.6x^3 + 5 + epsilon, ]

    其中噪声项(epsilon)服从均值为0、标准差为0.01的正态分布。训练数据集和测试数据集的样本数都设为100。

    n_train, n_test, true_w, true_b = 100, 100, [1.2, -3.4, 5.6], 5
    features = torch.randn((n_train + n_test, 1))
    poly_features = torch.cat((features, torch.pow(features, 2), torch.pow(features, 3)), 1) 
    labels = (true_w[0] * poly_features[:, 0] + true_w[1] * poly_features[:, 1]
              + true_w[2] * poly_features[:, 2] + true_b)
    labels += torch.tensor(np.random.normal(0, 0.01, size=labels.size()), dtype=torch.float)
    

    看一看生成的数据集的前两个样本。

    features[:2], poly_features[:2], labels[:2]
    

    输出:

    (tensor([[-1.0613],
             [-0.8386]]), tensor([[-1.0613,  1.1264, -1.1954],
             [-0.8386,  0.7032, -0.5897]]), tensor([-6.8037, -1.7054]))
    

    定义、训练和测试模型

    我们先定义作图函数semilogy,其中 (y) 轴使用了对数尺度。

    # 本函数已保存在d2lzh_pytorch包中方便以后使用
    def semilogy(x_vals, y_vals, x_label, y_label, x2_vals=None, y2_vals=None,
                 legend=None, figsize=(3.5, 2.5)):
        d2l.set_figsize(figsize)
        d2l.plt.xlabel(x_label)
        d2l.plt.ylabel(y_label)
        d2l.plt.semilogy(x_vals, y_vals)
        if x2_vals and y2_vals:
            d2l.plt.semilogy(x2_vals, y2_vals, linestyle=':')
            d2l.plt.legend(legend)
    

    和线性回归一样,多项式函数拟合也使用平方损失函数。因为我们将尝试使用不同复杂度的模型来拟合生成的数据集,所以我们把模型定义部分放在fit_and_plot函数中。多项式函数拟合的训练和测试步骤与3.6节(softmax回归的从零开始实现)介绍的softmax回归中的相关步骤类似。

    num_epochs, loss = 100, torch.nn.MSELoss()
    
    def fit_and_plot(train_features, test_features, train_labels, test_labels):
        net = torch.nn.Linear(train_features.shape[-1], 1)
        # 通过Linear文档可知,pytorch已经将参数初始化了,所以我们这里就不手动初始化了
        
        batch_size = min(10, train_labels.shape[0])    
        dataset = torch.utils.data.TensorDataset(train_features, train_labels)
        train_iter = torch.utils.data.DataLoader(dataset, batch_size, shuffle=True)
        
        optimizer = torch.optim.SGD(net.parameters(), lr=0.01)
        train_ls, test_ls = [], []
        for _ in range(num_epochs):
            for X, y in train_iter:
                l = loss(net(X), y.view(-1, 1))
                optimizer.zero_grad()
                l.backward()
                optimizer.step()
            train_labels = train_labels.view(-1, 1)
            test_labels = test_labels.view(-1, 1)
            train_ls.append(loss(net(train_features), train_labels).item())
            test_ls.append(loss(net(test_features), test_labels).item())
        print('final epoch: train loss', train_ls[-1], 'test loss', test_ls[-1])
        semilogy(range(1, num_epochs + 1), train_ls, 'epochs', 'loss',
                 range(1, num_epochs + 1), test_ls, ['train', 'test'])
        print('weight:', net.weight.data,
              '
    bias:', net.bias.data)
    

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

    我们先使用与数据生成函数同阶的三阶多项式函数拟合。实验表明,这个模型的训练误差和在测试数据集的误差都较低。训练出的模型参数也接近真实值:(w_1 = 1.2, w_2=-3.4, w_3=5.6, b = 5)

    fit_and_plot(poly_features[:n_train, :], poly_features[n_train:, :], 
                labels[:n_train], labels[n_train:])
    

    输出:

    final epoch: train loss 0.00010175639908993617 test loss 9.790256444830447e-05
    weight: tensor([[ 1.1982, -3.3992,  5.6002]]) 
    bias: tensor([5.0014])
    

    线性函数拟合(欠拟合)

    我们再试试线性函数拟合。很明显,该模型的训练误差在迭代早期下降后便很难继续降低。在完成最后一次迭代周期后,训练误差依旧很高。线性模型在非线性模型(如三阶多项式函数)生成的数据集上容易欠拟合。

    fit_and_plot(features[:n_train, :], features[n_train:, :], labels[:n_train],
                 labels[n_train:])
    

    输出:

    final epoch: train loss 249.35157775878906 test loss 168.37705993652344
    weight: tensor([[19.4123]]) 
    bias: tensor([0.5805])
    

    训练样本不足(过拟合)

    事实上,即便使用与数据生成模型同阶的三阶多项式函数模型,如果训练样本不足,该模型依然容易过拟合。让我们只使用两个样本来训练模型。显然,训练样本过少了,甚至少于模型参数的数量。这使模型显得过于复杂,以至于容易被训练数据中的噪声影响。在迭代过程中,尽管训练误差较低,但是测试数据集上的误差却很高。这是典型的过拟合现象。

    fit_and_plot(poly_features[0:2, :], poly_features[n_train:, :], labels[0:2],
                 labels[n_train:])
    

    输出:

    final epoch: train loss 1.198514699935913 test loss 166.037109375
    weight: tensor([[1.4741, 2.1198, 2.5674]]) 
    bias: tensor([3.1207])
    

    我们将在接下来的两个小节继续讨论过拟合问题以及应对过拟合的方法。

    小结

    • 由于无法从训练误差估计泛化误差,一味地降低训练误差并不意味着泛化误差一定会降低。机器学习模型应关注降低泛化误差。
    • 可以使用验证数据集来进行模型选择。
    • 欠拟合指模型无法得到较低的训练误差,过拟合指模型的训练误差远小于它在测试数据集上的误差。
    • 应选择复杂度合适的模型并避免使用过少的训练样本。

    注:本节除了代码之外与原书基本相同,原书传送门

    权重衰减

    上一节中我们观察了过拟合现象,即模型的训练误差远小于它在测试集上的误差。虽然增大训练数据集可能会减轻过拟合,但是获取额外的训练数据往往代价高昂。本节介绍应对过拟合问题的常用方法:权重衰减(weight decay)。

    方法

    权重衰减等价于 (L_2) 范数正则化(regularization)。正则化通过为模型损失函数添加惩罚项使学出的模型参数值较小,是应对过拟合的常用手段。我们先描述(L_2)范数正则化,再解释它为何又称权重衰减。

    (L_2)范数正则化在模型原损失函数基础上添加(L_2)范数惩罚项,从而得到训练所需要最小化的函数。(L_2)范数惩罚项指的是模型权重参数每个元素的平方和与一个正的常数的乘积。以3.1节(线性回归)中的线性回归损失函数

    [ell(w_1, w_2, b) = frac{1}{n} sum_{i=1}^n frac{1}{2}left(x_1^{(i)} w_1 + x_2^{(i)} w_2 + b - y^{(i)} ight)^2 ]

    为例,其中(w_1, w_2)是权重参数,(b)是偏差参数,样本(i)的输入为(x_1^{(i)}, x_2^{(i)}),标签为(y^{(i)}),样本数为(n)。将权重参数用向量(oldsymbol{w} = [w_1, w_2])表示,带有(L_2)范数惩罚项的新损失函数为

    [ell(w_1, w_2, b) + frac{lambda}{2n} |oldsymbol{w}|^2, ]

    其中超参数(lambda > 0)。当权重参数均为0时,惩罚项最小。当(lambda)较大时,惩罚项在损失函数中的比重较大,这通常会使学到的权重参数的元素较接近0。当(lambda)设为0时,惩罚项完全不起作用。上式中(L_2)范数平方(|oldsymbol{w}|^2)展开后得到(w_1^2 + w_2^2)。有了(L_2)范数惩罚项后,在小批量随机梯度下降中,我们将线性回归一节中权重(w_1)(w_2)的迭代方式更改为

    [egin{aligned} w_1 &leftarrow left(1- frac{etalambda}{|mathcal{B}|} ight)w_1 - frac{eta}{|mathcal{B}|} sum_{i in mathcal{B}}x_1^{(i)} left(x_1^{(i)} w_1 + x_2^{(i)} w_2 + b - y^{(i)} ight),\ w_2 &leftarrow left(1- frac{etalambda}{|mathcal{B}|} ight)w_2 - frac{eta}{|mathcal{B}|} sum_{i in mathcal{B}}x_2^{(i)} left(x_1^{(i)} w_1 + x_2^{(i)} w_2 + b - y^{(i)} ight). end{aligned} ]

    可见,(L_2)范数正则化令权重(w_1)(w_2)先自乘小于1的数,再减去不含惩罚项的梯度。因此,(L_2)范数正则化又叫权重衰减。权重衰减通过惩罚绝对值较大的模型参数为需要学习的模型增加了限制,这可能对过拟合有效。实际场景中,我们有时也在惩罚项中添加偏差元素的平方和。

    高维线性回归实验

    下面,我们以高维线性回归为例来引入一个过拟合问题,并使用权重衰减来应对过拟合。设数据样本特征的维度为(p)。对于训练数据集和测试数据集中特征为(x_1, x_2, ldots, x_p)的任一样本,我们使用如下的线性函数来生成该样本的标签:

    [y = 0.05 + sum_{i = 1}^p 0.01x_i + epsilon ]

    其中噪声项(epsilon)服从均值为0、标准差为0.01的正态分布。为了较容易地观察过拟合,我们考虑高维线性回归问题,如设维度(p=200);同时,我们特意把训练数据集的样本数设低,如20。

    %matplotlib inline
    import torch
    import torch.nn as nn
    import numpy as np
    import sys
    sys.path.append("..") 
    import d2lzh_pytorch as d2l
    
    n_train, n_test, num_inputs = 20, 100, 200
    true_w, true_b = torch.ones(num_inputs, 1) * 0.01, 0.05
    
    features = torch.randn((n_train + n_test, num_inputs))
    labels = torch.matmul(features, true_w) + true_b
    labels += torch.tensor(np.random.normal(0, 0.01, size=labels.size()), dtype=torch.float)
    train_features, test_features = features[:n_train, :], features[n_train:, :]
    train_labels, test_labels = labels[:n_train], labels[n_train:]
    

    从零开始实现

    下面先介绍从零开始实现权重衰减的方法。我们通过在目标函数后添加(L_2)范数惩罚项来实现权重衰减。

    初始化模型参数

    首先,定义随机初始化模型参数的函数。该函数为每个参数都附上梯度。

    def init_params():
        w = torch.randn((num_inputs, 1), requires_grad=True)
        b = torch.zeros(1, requires_grad=True)
        return [w, b]
    

    定义(L_2)范数惩罚项

    下面定义(L_2)范数惩罚项。这里只惩罚模型的权重参数。

    def l2_penalty(w):
        return (w**2).sum() / 2
    

    定义训练和测试

    下面定义如何在训练数据集和测试数据集上分别训练和测试模型。与前面几节中不同的是,这里在计算最终的损失函数时添加了(L_2)范数惩罚项。

    batch_size, num_epochs, lr = 1, 100, 0.003
    net, loss = d2l.linreg, d2l.squared_loss
    
    dataset = torch.utils.data.TensorDataset(train_features, train_labels)
    train_iter = torch.utils.data.DataLoader(dataset, batch_size, shuffle=True)
    
    def fit_and_plot(lambd):
        w, b = init_params()
        train_ls, test_ls = [], []
        for _ in range(num_epochs):
            for X, y in train_iter:
                # 添加了L2范数惩罚项
                l = loss(net(X, w, b), y) + lambd * l2_penalty(w)
                l = l.sum()
                
                if w.grad is not None:
                    w.grad.data.zero_()
                    b.grad.data.zero_()
                l.backward()
                d2l.sgd([w, b], lr, batch_size)
            train_ls.append(loss(net(train_features, w, b), train_labels).mean().item())
            test_ls.append(loss(net(test_features, w, b), test_labels).mean().item())
        d2l.semilogy(range(1, num_epochs + 1), train_ls, 'epochs', 'loss',
                     range(1, num_epochs + 1), test_ls, ['train', 'test'])
        print('L2 norm of w:', w.norm().item())
    

    观察过拟合

    接下来,让我们训练并测试高维线性回归模型。当lambd设为0时,我们没有使用权重衰减。结果训练误差远小于测试集上的误差。这是典型的过拟合现象。

    fit_and_plot(lambd=0)
    

    输出:

    L2 norm of w: 15.114808082580566
    

    使用权重衰减

    下面我们使用权重衰减。可以看出,训练误差虽然有所提高,但测试集上的误差有所下降。过拟合现象得到一定程度的缓解。另外,权重参数的(L_2)范数比不使用权重衰减时的更小,此时的权重参数更接近0。

    fit_and_plot(lambd=3)
    

    输出:

    L2 norm of w: 0.035220853984355927
    

    简洁实现

    这里我们直接在构造优化器实例时通过weight_decay参数来指定权重衰减超参数。默认下,PyTorch会对权重和偏差同时衰减。我们可以分别对权重和偏差构造优化器实例,从而只对权重衰减。

    def fit_and_plot_pytorch(wd):
        # 对权重参数衰减。权重名称一般是以weight结尾
        net = nn.Linear(num_inputs, 1)
        nn.init.normal_(net.weight, mean=0, std=1)
        nn.init.normal_(net.bias, mean=0, std=1)
        optimizer_w = torch.optim.SGD(params=[net.weight], lr=lr, weight_decay=wd) # 对权重参数衰减
        optimizer_b = torch.optim.SGD(params=[net.bias], lr=lr)  # 不对偏差参数衰减
        
        train_ls, test_ls = [], []
        for _ in range(num_epochs):
            for X, y in train_iter:
                l = loss(net(X), y).mean()
                optimizer_w.zero_grad()
                optimizer_b.zero_grad()
                
                l.backward()
                
                # 对两个optimizer实例分别调用step函数,从而分别更新权重和偏差
                optimizer_w.step()
                optimizer_b.step()
            train_ls.append(loss(net(train_features), train_labels).mean().item())
            test_ls.append(loss(net(test_features), test_labels).mean().item())
        d2l.semilogy(range(1, num_epochs + 1), train_ls, 'epochs', 'loss',
                     range(1, num_epochs + 1), test_ls, ['train', 'test'])
        print('L2 norm of w:', net.weight.data.norm().item())
    

    与从零开始实现权重衰减的实验现象类似,使用权重衰减可以在一定程度上缓解过拟合问题。

    fit_and_plot_pytorch(0)
    

    输出:

    L2 norm of w: 12.86785888671875
    

    fit_and_plot_pytorch(3)
    

    输出:

    L2 norm of w: 0.09631537646055222
    

    小结

    • 正则化通过为模型损失函数添加惩罚项使学出的模型参数值较小,是应对过拟合的常用手段。
    • 权重衰减等价于(L_2)范数正则化,通常会使学到的权重参数的元素较接近0。
    • 权重衰减可以通过优化器中的weight_decay超参数来指定。
    • 可以定义多个优化器实例对不同的模型参数使用不同的迭代方法。

    注:本节除了代码之外与原书基本相同,原书传送门

    丢弃法

    除了前一节介绍的权重衰减以外,深度学习模型常常使用丢弃法(dropout)[1] 来应对过拟合问题。丢弃法有一些不同的变体。本节中提到的丢弃法特指倒置丢弃法(inverted dropout)。

    方法

    回忆一下,3.8节(多层感知机)的图3.3描述了一个单隐藏层的多层感知机。其中输入个数为4,隐藏单元个数为5,且隐藏单元(h_i)(i=1, ldots, 5))的计算表达式为

    [h_i = phileft(x_1 w_{1i} + x_2 w_{2i} + x_3 w_{3i} + x_4 w_{4i} + b_i ight) ]

    这里(phi)是激活函数,(x_1, ldots, x_4)是输入,隐藏单元(i)的权重参数为(w_{1i}, ldots, w_{4i}),偏差参数为(b_i)。当对该隐藏层使用丢弃法时,该层的隐藏单元将有一定概率被丢弃掉。设丢弃概率为(p),那么有(p)的概率(h_i)会被清零,有(1-p)的概率(h_i)会除以(1-p)做拉伸。丢弃概率是丢弃法的超参数。具体来说,设随机变量(xi_i)为0和1的概率分别为(p)(1-p)。使用丢弃法时我们计算新的隐藏单元(h_i')

    [h_i' = frac{xi_i}{1-p} h_i ]

    由于(E(xi_i) = 1-p),因此

    [E(h_i') = frac{E(xi_i)}{1-p}h_i = h_i ]

    丢弃法不改变其输入的期望值。让我们对图3.3中的隐藏层使用丢弃法,一种可能的结果如图3.5所示,其中(h_2)(h_5)被清零。这时输出值的计算不再依赖(h_2)(h_5),在反向传播时,与这两个隐藏单元相关的权重的梯度均为0。由于在训练中隐藏层神经元的丢弃是随机的,即(h_1, ldots, h_5)都有可能被清零,输出层的计算无法过度依赖(h_1, ldots, h_5)中的任一个,从而在训练模型时起到正则化的作用,并可以用来应对过拟合。在测试模型时,我们为了拿到更加确定性的结果,一般不使用丢弃法。

    图3.5 隐藏层使用了丢弃法的多层感知机

    从零开始实现

    根据丢弃法的定义,我们可以很容易地实现它。下面的dropout函数将以drop_prob的概率丢弃X中的元素。

    %matplotlib inline
    import torch
    import torch.nn as nn
    import numpy as np
    import sys
    sys.path.append("..") 
    import d2lzh_pytorch as d2l
    
    def dropout(X, drop_prob):
        X = X.float()
        assert 0 <= drop_prob <= 1
        keep_prob = 1 - drop_prob
        # 这种情况下把全部元素都丢弃
        if keep_prob == 0:
            return torch.zeros_like(X)
        mask = (torch.rand(X.shape) < keep_prob).float()
        
        return mask * X / keep_prob
    

    我们运行几个例子来测试一下dropout函数。其中丢弃概率分别为0、0.5和1。

    X = torch.arange(16).view(2, 8)
    dropout(X, 0)
    
    dropout(X, 0.5)
    
    dropout(X, 1.0)
    

    定义模型参数

    实验中,我们依然使用3.6节(softmax回归的从零开始实现)中介绍的Fashion-MNIST数据集。我们将定义一个包含两个隐藏层的多层感知机,其中两个隐藏层的输出个数都是256。

    num_inputs, num_outputs, num_hiddens1, num_hiddens2 = 784, 10, 256, 256
    
    W1 = torch.tensor(np.random.normal(0, 0.01, size=(num_inputs, num_hiddens1)), dtype=torch.float, requires_grad=True)
    b1 = torch.zeros(num_hiddens1, requires_grad=True)
    W2 = torch.tensor(np.random.normal(0, 0.01, size=(num_hiddens1, num_hiddens2)), dtype=torch.float, requires_grad=True)
    b2 = torch.zeros(num_hiddens2, requires_grad=True)
    W3 = torch.tensor(np.random.normal(0, 0.01, size=(num_hiddens2, num_outputs)), dtype=torch.float, requires_grad=True)
    b3 = torch.zeros(num_outputs, requires_grad=True)
    
    params = [W1, b1, W2, b2, W3, b3]
    

    定义模型

    下面定义的模型将全连接层和激活函数ReLU串起来,并对每个激活函数的输出使用丢弃法。我们可以分别设置各个层的丢弃概率。通常的建议是把靠近输入层的丢弃概率设得小一点。在这个实验中,我们把第一个隐藏层的丢弃概率设为0.2,把第二个隐藏层的丢弃概率设为0.5。我们可以通过参数is_training来判断运行模式为训练还是测试,并只需在训练模式下使用丢弃法。

    drop_prob1, drop_prob2 = 0.2, 0.5
    
    def net(X, is_training=True):
        X = X.view(-1, num_inputs)
        H1 = (torch.matmul(X, W1) + b1).relu()
        if is_training:  # 只在训练模型时使用丢弃法
            H1 = dropout(H1, drop_prob1)  # 在第一层全连接后添加丢弃层
        H2 = (torch.matmul(H1, W2) + b2).relu()
        if is_training:
            H2 = dropout(H2, drop_prob2)  # 在第二层全连接后添加丢弃层
        return torch.matmul(H2, W3) + b3
    

    我们在对模型评估的时候不应该进行丢弃,所以我们修改一下d2lzh_pytorch中的evaluate_accuracy函数:

    # 本函数已保存在d2lzh_pytorch
    def evaluate_accuracy(data_iter, net):
        acc_sum, n = 0.0, 0
        for X, y in data_iter:
            if isinstance(net, torch.nn.Module):
                net.eval() # 评估模式, 这会关闭dropout
                acc_sum += (net(X).argmax(dim=1) == y).float().sum().item()
                net.train() # 改回训练模式
            else: # 自定义的模型
                if('is_training' in net.__code__.co_varnames): # 如果有is_training这个参数
                    # 将is_training设置成False
                    acc_sum += (net(X, is_training=False).argmax(dim=1) == y).float().sum().item() 
                else:
                    acc_sum += (net(X).argmax(dim=1) == y).float().sum().item() 
            n += y.shape[0]
        return acc_sum / n
    

    注:将上诉evaluate_accuracy写回d2lzh_pytorch后要重启一下jupyter kernel才会生效。

    训练和测试模型

    这部分与之前多层感知机的训练和测试类似。

    num_epochs, lr, batch_size = 5, 100.0, 256
    loss = torch.nn.CrossEntropyLoss()
    train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
    d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, batch_size, params, lr)
    

    输出:

    epoch 1, loss 0.0044, train acc 0.574, test acc 0.648
    epoch 2, loss 0.0023, train acc 0.786, test acc 0.786
    epoch 3, loss 0.0019, train acc 0.826, test acc 0.825
    epoch 4, loss 0.0017, train acc 0.839, test acc 0.831
    epoch 5, loss 0.0016, train acc 0.849, test acc 0.850
    

    注:这里的学习率设置的很大,原因同3.9.6节。

    简洁实现

    在PyTorch中,我们只需要在全连接层后添加Dropout层并指定丢弃概率。在训练模型时,Dropout层将以指定的丢弃概率随机丢弃上一层的输出元素;在测试模型时(即model.eval()后),Dropout层并不发挥作用。

    net = nn.Sequential(
            d2l.FlattenLayer(),
            nn.Linear(num_inputs, num_hiddens1),
            nn.ReLU(),
            nn.Dropout(drop_prob1),
            nn.Linear(num_hiddens1, num_hiddens2), 
            nn.ReLU(),
            nn.Dropout(drop_prob2),
            nn.Linear(num_hiddens2, 10)
            )
    
    for param in net.parameters():
        nn.init.normal_(param, mean=0, std=0.01)
    

    下面训练并测试模型。

    optimizer = torch.optim.SGD(net.parameters(), lr=0.5)
    d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, batch_size, None, None, optimizer)
    

    输出:

    epoch 1, loss 0.0045, train acc 0.553, test acc 0.715
    epoch 2, loss 0.0023, train acc 0.784, test acc 0.793
    epoch 3, loss 0.0019, train acc 0.822, test acc 0.817
    epoch 4, loss 0.0018, train acc 0.837, test acc 0.830
    epoch 5, loss 0.0016, train acc 0.848, test acc 0.839
    

    注:由于这里使用的是PyTorch的SGD而不是d2lzh_pytorch里面的sgd,所以就不存在3.9.6节那样学习率看起来很大的问题了。

    小结

    • 我们可以通过使用丢弃法应对过拟合。
    • 丢弃法只在训练模型时使用。

    参考文献

    [1] Srivastava, N., Hinton, G., Krizhevsky, A., Sutskever, I., & Salakhutdinov, R. (2014). Dropout: a simple way to prevent neural networks from overfitting. JMLR


    注:本节除了代码之外与原书基本相同,原书传送门

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

    前面几节里我们使用了小批量随机梯度下降的优化算法来训练模型。在实现中,我们只提供了模型的正向传播(forward propagation)的计算,即对输入计算模型输出,然后通过autograd模块来调用系统自动生成的backward函数计算梯度。基于反向传播(back-propagation)算法的自动求梯度极大简化了深度学习模型训练算法的实现。本节我们将使用数学和计算图(computational graph)两个方式来描述正向传播和反向传播。具体来说,我们将以带(L_2)范数正则化的含单隐藏层的多层感知机为样例模型解释正向传播和反向传播。

    正向传播

    正向传播是指对神经网络沿着从输入层到输出层的顺序,依次计算并存储模型的中间变量(包括输出)。为简单起见,假设输入是一个特征为(oldsymbol{x} in mathbb{R}^d)的样本,且不考虑偏差项,那么中间变量

    [oldsymbol{z} = oldsymbol{W}^{(1)} oldsymbol{x}, ]

    其中(oldsymbol{W}^{(1)} in mathbb{R}^{h imes d})是隐藏层的权重参数。把中间变量(oldsymbol{z} in mathbb{R}^h)输入按元素运算的激活函数(phi)后,将得到向量长度为(h)的隐藏层变量

    [oldsymbol{h} = phi (oldsymbol{z}). ]

    隐藏层变量(oldsymbol{h})也是一个中间变量。假设输出层参数只有权重(oldsymbol{W}^{(2)} in mathbb{R}^{q imes h}),可以得到向量长度为(q)的输出层变量

    [oldsymbol{o} = oldsymbol{W}^{(2)} oldsymbol{h}. ]

    假设损失函数为(ell),且样本标签为(y),可以计算出单个数据样本的损失项

    [L = ell(oldsymbol{o}, y). ]

    根据(L_2)范数正则化的定义,给定超参数(lambda),正则化项即

    [s = frac{lambda}{2} left(|oldsymbol{W}^{(1)}|_F^2 + |oldsymbol{W}^{(2)}|_F^2 ight), ]

    其中矩阵的Frobenius范数等价于将矩阵变平为向量后计算(L_2)范数。最终,模型在给定的数据样本上带正则化的损失为

    [J = L + s. ]

    我们将(J)称为有关给定数据样本的目标函数,并在以下的讨论中简称目标函数。

    正向传播的计算图

    我们通常绘制计算图来可视化运算符和变量在计算中的依赖关系。图3.6绘制了本节中样例模型正向传播的计算图,其中左下角是输入,右上角是输出。可以看到,图中箭头方向大多是向右和向上,其中方框代表变量,圆圈代表运算符,箭头表示从输入到输出之间的依赖关系。

    图3.6 正向传播的计算图

    反向传播

    反向传播指的是计算神经网络参数梯度的方法。总的来说,反向传播依据微积分中的链式法则,沿着从输出层到输入层的顺序,依次计算并存储目标函数有关神经网络各层的中间变量以及参数的梯度。对输入或输出(mathsf{X}, mathsf{Y}, mathsf{Z})为任意形状张量的函数(mathsf{Y}=f(mathsf{X}))(mathsf{Z}=g(mathsf{Y})),通过链式法则,我们有

    [frac{partial mathsf{Z}}{partial mathsf{X}} = ext{prod}left(frac{partial mathsf{Z}}{partial mathsf{Y}}, frac{partial mathsf{Y}}{partial mathsf{X}} ight), ]

    其中( ext{prod})运算符将根据两个输入的形状,在必要的操作(如转置和互换输入位置)后对两个输入做乘法。

    回顾一下本节中样例模型,它的参数是(oldsymbol{W}^{(1)})(oldsymbol{W}^{(2)}),因此反向传播的目标是计算(partial J/partial oldsymbol{W}^{(1)})(partial J/partial oldsymbol{W}^{(2)})。我们将应用链式法则依次计算各中间变量和参数的梯度,其计算次序与前向传播中相应中间变量的计算次序恰恰相反。首先,分别计算目标函数(J=L+s)有关损失项(L)和正则项(s)的梯度

    [frac{partial J}{partial L} = 1, quad frac{partial J}{partial s} = 1. ]

    其次,依据链式法则计算目标函数有关输出层变量的梯度(partial J/partial oldsymbol{o} in mathbb{R}^q)

    [frac{partial J}{partial oldsymbol{o}} = ext{prod}left(frac{partial J}{partial L}, frac{partial L}{partial oldsymbol{o}} ight) = frac{partial L}{partial oldsymbol{o}}. ]

    接下来,计算正则项有关两个参数的梯度:

    [frac{partial s}{partial oldsymbol{W}^{(1)}} = lambda oldsymbol{W}^{(1)},quadfrac{partial s}{partial oldsymbol{W}^{(2)}} = lambda oldsymbol{W}^{(2)}. ]

    现在,我们可以计算最靠近输出层的模型参数的梯度(partial J/partial oldsymbol{W}^{(2)} in mathbb{R}^{q imes h})。依据链式法则,得到

    [frac{partial J}{partial oldsymbol{W}^{(2)}} = ext{prod}left(frac{partial J}{partial oldsymbol{o}}, frac{partial oldsymbol{o}}{partial oldsymbol{W}^{(2)}} ight) + ext{prod}left(frac{partial J}{partial s}, frac{partial s}{partial oldsymbol{W}^{(2)}} ight) = frac{partial J}{partial oldsymbol{o}} oldsymbol{h}^ op + lambda oldsymbol{W}^{(2)}. ]

    沿着输出层向隐藏层继续反向传播,隐藏层变量的梯度(partial J/partial oldsymbol{h} in mathbb{R}^h)可以这样计算:

    [frac{partial J}{partial oldsymbol{h}} = ext{prod}left(frac{partial J}{partial oldsymbol{o}}, frac{partial oldsymbol{o}}{partial oldsymbol{h}} ight) = {oldsymbol{W}^{(2)}}^ op frac{partial J}{partial oldsymbol{o}}. ]

    由于激活函数(phi)是按元素运算的,中间变量(oldsymbol{z})的梯度(partial J/partial oldsymbol{z} in mathbb{R}^h)的计算需要使用按元素乘法符(odot)

    [frac{partial J}{partial oldsymbol{z}} = ext{prod}left(frac{partial J}{partial oldsymbol{h}}, frac{partial oldsymbol{h}}{partial oldsymbol{z}} ight) = frac{partial J}{partial oldsymbol{h}} odot phi'left(oldsymbol{z} ight). ]

    最终,我们可以得到最靠近输入层的模型参数的梯度(partial J/partial oldsymbol{W}^{(1)} in mathbb{R}^{h imes d})。依据链式法则,得到

    [frac{partial J}{partial oldsymbol{W}^{(1)}} = ext{prod}left(frac{partial J}{partial oldsymbol{z}}, frac{partial oldsymbol{z}}{partial oldsymbol{W}^{(1)}} ight) + ext{prod}left(frac{partial J}{partial s}, frac{partial s}{partial oldsymbol{W}^{(1)}} ight) = frac{partial J}{partial oldsymbol{z}} oldsymbol{x}^ op + lambda oldsymbol{W}^{(1)}. ]

    训练深度学习模型

    在训练深度学习模型时,正向传播和反向传播之间相互依赖。下面我们仍然以本节中的样例模型分别阐述它们之间的依赖关系。

    一方面,正向传播的计算可能依赖于模型参数的当前值,而这些模型参数是在反向传播的梯度计算后通过优化算法迭代的。例如,计算正则化项(s = (lambda/2) left(|oldsymbol{W}^{(1)}|_F^2 + |oldsymbol{W}^{(2)}|_F^2 ight))依赖模型参数(oldsymbol{W}^{(1)})(oldsymbol{W}^{(2)})的当前值,而这些当前值是优化算法最近一次根据反向传播算出梯度后迭代得到的。

    另一方面,反向传播的梯度计算可能依赖于各变量的当前值,而这些变量的当前值是通过正向传播计算得到的。举例来说,参数梯度(partial J/partial oldsymbol{W}^{(2)} = (partial J / partial oldsymbol{o}) oldsymbol{h}^ op + lambda oldsymbol{W}^{(2)})的计算需要依赖隐藏层变量的当前值(oldsymbol{h})。这个当前值是通过从输入层到输出层的正向传播计算并存储得到的。

    因此,在模型参数初始化完成后,我们交替地进行正向传播和反向传播,并根据反向传播计算的梯度迭代模型参数。既然我们在反向传播中使用了正向传播中计算得到的中间变量来避免重复计算,那么这个复用也导致正向传播结束后不能立即释放中间变量内存。这也是训练要比预测占用更多内存的一个重要原因。另外需要指出的是,这些中间变量的个数大体上与网络层数线性相关,每个变量的大小跟批量大小和输入个数也是线性相关的,它们是导致较深的神经网络使用较大批量训练时更容易超内存的主要原因。

    小结

    • 正向传播沿着从输入层到输出层的顺序,依次计算并存储神经网络的中间变量。
    • 反向传播沿着从输出层到输入层的顺序,依次计算并存储神经网络中间变量和参数的梯度。
    • 在训练深度学习模型时,正向传播和反向传播相互依赖。

    注:本节与原书基本相同,原书传送门

    数值稳定性和模型初始化

    理解了正向传播与反向传播以后,我们来讨论一下深度学习模型的数值稳定性问题以及模型参数的初始化方法。深度模型有关数值稳定性的典型问题是衰减(vanishing)和爆炸(explosion)。

    衰减和爆炸

    当神经网络的层数较多时,模型的数值稳定性容易变差。假设一个层数为(L)的多层感知机的第(l)(oldsymbol{H}^{(l)})的权重参数为(oldsymbol{W}^{(l)}),输出层(oldsymbol{H}^{(L)})的权重参数为(oldsymbol{W}^{(L)})。为了便于讨论,不考虑偏差参数,且设所有隐藏层的激活函数为恒等映射(identity mapping)(phi(x) = x)。给定输入(oldsymbol{X}),多层感知机的第(l)层的输出(oldsymbol{H}^{(l)} = oldsymbol{X} oldsymbol{W}^{(1)} oldsymbol{W}^{(2)} ldots oldsymbol{W}^{(l)})。此时,如果层数(l)较大,(oldsymbol{H}^{(l)})的计算可能会出现衰减或爆炸。举个例子,假设输入和所有层的权重参数都是标量,如权重参数为0.2和5,多层感知机的第30层输出为输入(oldsymbol{X})分别与(0.2^{30} approx 1 imes 10^{-21})(衰减)和(5^{30} approx 9 imes 10^{20})(爆炸)的乘积。类似地,当层数较多时,梯度的计算也更容易出现衰减或爆炸。

    随着内容的不断深入,我们会在后面的章节进一步介绍深度学习的数值稳定性问题以及解决方法。

    随机初始化模型参数

    在神经网络中,通常需要随机初始化模型参数。下面我们来解释这样做的原因。

    回顾3.8节(多层感知机)图3.3描述的多层感知机。为了方便解释,假设输出层只保留一个输出单元(o_1)(删去(o_2)(o_3)以及指向它们的箭头),且隐藏层使用相同的激活函数。如果将每个隐藏单元的参数都初始化为相等的值,那么在正向传播时每个隐藏单元将根据相同的输入计算出相同的值,并传递至输出层。在反向传播中,每个隐藏单元的参数梯度值相等。因此,这些参数在使用基于梯度的优化算法迭代后值依然相等。之后的迭代也是如此。在这种情况下,无论隐藏单元有多少,隐藏层本质上只有1个隐藏单元在发挥作用。因此,正如在前面的实验中所做的那样,我们通常将神经网络的模型参数,特别是权重参数,进行随机初始化。

    PyTorch的默认随机初始化

    随机初始化模型参数的方法有很多。在3.3节(线性回归的简洁实现)中,我们使用torch.nn.init.normal_()使模型net的权重参数采用正态分布的随机初始化方式。不过,PyTorch中nn.Module的模块参数都采取了较为合理的初始化策略(不同类型的layer具体采样的哪一种初始化方法的可参考源代码),因此一般不用我们考虑。

    Xavier随机初始化

    还有一种比较常用的随机初始化方法叫作Xavier随机初始化[1]。
    假设某全连接层的输入个数为(a),输出个数为(b),Xavier随机初始化将使该层中权重参数的每个元素都随机采样于均匀分布

    [Uleft(-sqrt{frac{6}{a+b}}, sqrt{frac{6}{a+b}} ight). ]

    它的设计主要考虑到,模型参数初始化后,每层输出的方差不该受该层输入个数影响,且每层梯度的方差也不该受该层输出个数影响。

    小结

    • 深度模型有关数值稳定性的典型问题是衰减和爆炸。当神经网络的层数较多时,模型的数值稳定性容易变差。
    • 我们通常需要随机初始化神经网络的模型参数,如权重参数。

    参考文献

    [1] Glorot, X., & Bengio, Y. (2010, March). Understanding the difficulty of training deep feedforward neural networks. In Proceedings of the thirteenth international conference on artificial intelligence and statistics (pp. 249-256).

    实战Kaggle比赛:房价预测

    作为深度学习基础篇章的总结,我们将对本章内容学以致用。下面,让我们动手实战一个Kaggle比赛:房价预测。本节将提供未经调优的数据的预处理、模型的设计和超参数的选择。我们希望读者通过动手操作、仔细观察实验现象、认真分析实验结果并不断调整方法,得到令自己满意的结果。

    Kaggle比赛

    Kaggle是一个著名的供机器学习爱好者交流的平台。图3.7展示了Kaggle网站的首页。为了便于提交结果,需要注册Kaggle账号。

    图3.7 Kaggle网站首页

    我们可以在房价预测比赛的网页上了解比赛信息和参赛者成绩,也可以下载数据集并提交自己的预测结果。该比赛的网页地址是 https://www.kaggle.com/c/house-prices-advanced-regression-techniques

    图3.8 房价预测比赛的网页信息。比赛数据集可通过点击“Data”标签获取

    图3.8展示了房价预测比赛的网页信息。

    获取和读取数据集

    比赛数据分为训练数据集和测试数据集。两个数据集都包括每栋房子的特征,如街道类型、建造年份、房顶类型、地下室状况等特征值。这些特征值有连续的数字、离散的标签甚至是缺失值“na”。只有训练数据集包括了每栋房子的价格,也就是标签。我们可以访问比赛网页,点击图3.8中的“Data”标签,并下载这些数据集。

    我们将通过pandas库读入并处理数据。在导入本节需要的包前请确保已安装pandas库,否则请参考下面的代码注释。

    # 如果没有安装pandas,则反注释下面一行
    # !pip install pandas
    
    %matplotlib inline
    import torch
    import torch.nn as nn
    import numpy as np
    import pandas as pd
    import sys
    sys.path.append("..") 
    import d2lzh_pytorch as d2l
    
    print(torch.__version__)
    torch.set_default_tensor_type(torch.FloatTensor)
    

    假设解压后的数据位于../../data/kaggle_house/目录,它包括两个csv文件。下面使用pandas读取这两个文件。

    train_data = pd.read_csv('../../data/kaggle_house/train.csv')
    test_data = pd.read_csv('../../data/kaggle_house/test.csv')
    

    训练数据集包括1460个样本、80个特征和1个标签。

    train_data.shape # 输出 (1460, 81)
    

    测试数据集包括1459个样本和80个特征。我们需要将测试数据集中每个样本的标签预测出来。

    test_data.shape # 输出 (1459, 80)
    

    让我们来查看前4个样本的前4个特征、后2个特征和标签(SalePrice):

    train_data.iloc[0:4, [0, 1, 2, 3, -3, -2, -1]]
    

    可以看到第一个特征是Id,它能帮助模型记住每个训练样本,但难以推广到测试样本,所以我们不使用它来训练。我们将所有的训练数据和测试数据的79个特征按样本连结。

    all_features = pd.concat((train_data.iloc[:, 1:-1], test_data.iloc[:, 1:]))
    

    预处理数据

    我们对连续数值的特征做标准化(standardization):设该特征在整个数据集上的均值为(mu),标准差为(sigma)。那么,我们可以将该特征的每个值先减去(mu)再除以(sigma)得到标准化后的每个特征值。对于缺失的特征值,我们将其替换成该特征的均值。

    numeric_features = all_features.dtypes[all_features.dtypes != 'object'].index
    all_features[numeric_features] = all_features[numeric_features].apply(
        lambda x: (x - x.mean()) / (x.std()))
    # 标准化后,每个数值特征的均值变为0,所以可以直接用0来替换缺失值
    all_features[numeric_features] = all_features[numeric_features].fillna(0)
    

    接下来将离散数值转成指示特征。举个例子,假设特征MSZoning里面有两个不同的离散值RL和RM,那么这一步转换将去掉MSZoning特征,并新加两个特征MSZoning_RL和MSZoning_RM,其值为0或1。如果一个样本原来在MSZoning里的值为RL,那么有MSZoning_RL=1且MSZoning_RM=0。

    # dummy_na=True将缺失值也当作合法的特征值并为其创建指示特征
    all_features = pd.get_dummies(all_features, dummy_na=True)
    all_features.shape # (2919, 331)
    

    可以看到这一步转换将特征数从79增加到了331。

    最后,通过values属性得到NumPy格式的数据,并转成Tensor方便后面的训练。

    n_train = train_data.shape[0]
    train_features = torch.tensor(all_features[:n_train].values, dtype=torch.float)
    test_features = torch.tensor(all_features[n_train:].values, dtype=torch.float)
    train_labels = torch.tensor(train_data.SalePrice.values, dtype=torch.float).view(-1, 1)
    

    训练模型

    我们使用一个基本的线性回归模型和平方损失函数来训练模型。

    loss = torch.nn.MSELoss()
    
    def get_net(feature_num):
        net = nn.Linear(feature_num, 1)
        for param in net.parameters():
            nn.init.normal_(param, mean=0, std=0.01)
        return net
    

    下面定义比赛用来评价模型的对数均方根误差。给定预测值(hat y_1, ldots, hat y_n)和对应的真实标签(y_1,ldots, y_n),它的定义为

    [sqrt{frac{1}{n}sum_{i=1}^nleft(log(y_i)-log(hat y_i) ight)^2}. ]

    对数均方根误差的实现如下。

    def log_rmse(net, features, labels):
        with torch.no_grad():
            # 将小于1的值设成1,使得取对数时数值更稳定
            clipped_preds = torch.max(net(features), torch.tensor(1.0))
            rmse = torch.sqrt(loss(clipped_preds.log(), labels.log()))
        return rmse.item()
    

    下面的训练函数跟本章中前几节的不同在于使用了Adam优化算法。相对之前使用的小批量随机梯度下降,它对学习率相对不那么敏感。我们将在之后的“优化算法”一章里详细介绍它。

    def train(net, train_features, train_labels, test_features, test_labels,
              num_epochs, learning_rate, weight_decay, batch_size):
        train_ls, test_ls = [], []
        dataset = torch.utils.data.TensorDataset(train_features, train_labels)
        train_iter = torch.utils.data.DataLoader(dataset, batch_size, shuffle=True)
        # 这里使用了Adam优化算法
        optimizer = torch.optim.Adam(params=net.parameters(), lr=learning_rate, weight_decay=weight_decay) 
        net = net.float()
        for epoch in range(num_epochs):
            for X, y in train_iter:
                l = loss(net(X.float()), y.float())
                optimizer.zero_grad()
                l.backward()
                optimizer.step()
            train_ls.append(log_rmse(net, train_features, train_labels))
            if test_labels is not None:
                test_ls.append(log_rmse(net, test_features, test_labels))
        return train_ls, test_ls
    

    (K)折交叉验证

    我们在3.11节(模型选择、欠拟合和过拟合)中介绍了(K)折交叉验证。它将被用来选择模型设计并调节超参数。下面实现了一个函数,它返回第i折交叉验证时所需要的训练和验证数据。

    def get_k_fold_data(k, i, X, y):
        # 返回第i折交叉验证时所需要的训练和验证数据
        assert k > 1
        fold_size = X.shape[0] // k
        X_train, y_train = None, None
        for j in range(k):
            idx = slice(j * fold_size, (j + 1) * fold_size)
            X_part, y_part = X[idx, :], y[idx]
            if j == i:
                X_valid, y_valid = X_part, y_part
            elif X_train is None:
                X_train, y_train = X_part, y_part
            else:
                X_train = torch.cat((X_train, X_part), dim=0)
                y_train = torch.cat((y_train, y_part), dim=0)
        return X_train, y_train, X_valid, y_valid
    

    (K)折交叉验证中我们训练(K)次并返回训练和验证的平均误差。

    def k_fold(k, X_train, y_train, num_epochs,
               learning_rate, weight_decay, batch_size):
        train_l_sum, valid_l_sum = 0, 0
        for i in range(k):
            data = get_k_fold_data(k, i, X_train, y_train)
            net = get_net(X_train.shape[1])
            train_ls, valid_ls = train(net, *data, num_epochs, learning_rate,
                                       weight_decay, batch_size)
            train_l_sum += train_ls[-1]
            valid_l_sum += valid_ls[-1]
            if i == 0:
                d2l.semilogy(range(1, num_epochs + 1), train_ls, 'epochs', 'rmse',
                             range(1, num_epochs + 1), valid_ls,
                             ['train', 'valid'])
            print('fold %d, train rmse %f, valid rmse %f' % (i, train_ls[-1], valid_ls[-1]))
        return train_l_sum / k, valid_l_sum / k
    

    输出:

    fold 0, train rmse 0.170585, valid rmse 0.156860
    fold 1, train rmse 0.162552, valid rmse 0.190944
    fold 2, train rmse 0.164199, valid rmse 0.168767
    fold 3, train rmse 0.168698, valid rmse 0.154873
    fold 4, train rmse 0.163213, valid rmse 0.183080
    5-fold validation: avg train rmse 0.165849, avg valid rmse 0.170905
    

    模型选择

    我们使用一组未经调优的超参数并计算交叉验证误差。可以改动这些超参数来尽可能减小平均测试误差。

    k, num_epochs, lr, weight_decay, batch_size = 5, 100, 5, 0, 64
    train_l, valid_l = k_fold(k, train_features, train_labels, num_epochs, lr, weight_decay, batch_size)
    print('%d-fold validation: avg train rmse %f, avg valid rmse %f' % (k, train_l, valid_l))
    

    有时候你会发现一组参数的训练误差可以达到很低,但是在(K)折交叉验证上的误差可能反而较高。这种现象很可能是由过拟合造成的。因此,当训练误差降低时,我们要观察(K)折交叉验证上的误差是否也相应降低。

    预测并在Kaggle提交结果

    下面定义预测函数。在预测之前,我们会使用完整的训练数据集来重新训练模型,并将预测结果存成提交所需要的格式。

    def train_and_pred(train_features, test_features, train_labels, test_data,
                       num_epochs, lr, weight_decay, batch_size):
        net = get_net(train_features.shape[1])
        train_ls, _ = train(net, train_features, train_labels, None, None,
                            num_epochs, lr, weight_decay, batch_size)
        d2l.semilogy(range(1, num_epochs + 1), train_ls, 'epochs', 'rmse')
        print('train rmse %f' % train_ls[-1])
        preds = net(test_features).detach().numpy()
        test_data['SalePrice'] = pd.Series(preds.reshape(1, -1)[0])
        submission = pd.concat([test_data['Id'], test_data['SalePrice']], axis=1)
        submission.to_csv('./submission.csv', index=False)
    

    设计好模型并调好超参数之后,下一步就是对测试数据集上的房屋样本做价格预测。如果我们得到与交叉验证时差不多的训练误差,那么这个结果很可能是理想的,可以在Kaggle上提交结果。

    train_and_pred(train_features, test_features, train_labels, test_data, num_epochs, lr, weight_decay, batch_size)
    

    输出:

    train rmse 0.162085
    

    上述代码执行完之后会生成一个submission.csv文件。这个文件是符合Kaggle比赛要求的提交格式的。这时,我们可以在Kaggle上提交我们预测得出的结果,并且查看与测试数据集上真实房价(标签)的误差。具体来说有以下几个步骤:登录Kaggle网站,访问房价预测比赛网页,并点击右侧“Submit Predictions”或“Late Submission”按钮;然后,点击页面下方“Upload Submission File”图标所在的虚线框选择需要提交的预测结果文件;最后,点击页面最下方的“Make Submission”按钮就可以查看结果了,如图3.9所示。

    图3.9 Kaggle预测房价比赛的预测结果提交页面

    小结

    • 通常需要对真实数据做预处理。
    • 可以使用(K)折交叉验证来选择模型并调节超参数。

    注:本节除了代码之外与原书基本相同,原书传送门

    作者:鄂河
    声明:本博客所有文章均来源于网络或本人原创,仅用于学习用途,欢迎评论区讨论,会逐一完善内容。
  • 相关阅读:
    Quartz 框架的应用
    Quartz定时任务学习(二)web应用/Quartz定时任务学习(三)属性文件和jar
    Quartz定时任务学习(一)简单任务
    Maven系列--"maven-compiler-plugin"的使用
    运用JMX监控Tomcat/Java jvisualvm简要说明
    [轻微]WEB服务器启用了OPTIONS方法/如何禁止DELETE,PUT,OPTIONS等协议访问应用程序/tomcat下禁用不安全的http方法
    [Ogre][地形]OgreTerrain的实现原理分析
    [Ogre][地形]OgreTerrain分析以及使用
    [算法][C]计算向量的角度
    [寻路][导航][算法][地图开发]寻路算法的对比优势2
  • 原文地址:https://www.cnblogs.com/panghuhu/p/14269116.html
Copyright © 2020-2023  润新知