3.5. 线性回归的简洁实现
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 SageMaker Studio Lab 中打开 Notebook

在过去的十年里,深度学习见证了一场寒武纪大爆发。大量的技术、应用和算法的涌现,其速度远超过去几十年的发展。这是多种因素 fortuitous 结合的结果,其中之一就是许多开源深度学习框架提供了强大的免费工具。Theano (Bergstra et al., 2010)、DistBelief (Dean et al., 2012) 和 Caffe (Jia et al., 2014) 可以说是第一代得到广泛应用的此类模型。与早期的(开创性的)工作如 SN2 (Simulateur Neuristique) (Bottou and Le Cun, 1988)(其提供了一种类似 Lisp 的编程体验)相比,现代框架提供了自动微分和 Python 的便利性。这些框架使我们能够自动化和模块化实现基于梯度的学习算法的重复性工作。

第 3.4 节 中,我们只依赖于 (i) 用于数据存储和线性代数的张量;和 (ii) 用于计算梯度的自动微分。实际上,由于数据迭代器、损失函数、优化器和神经网络层非常普遍,现代库也为我们实现了这些组件。在本节中,我们将向您展示如何使用深度学习框架的高级 API 来简洁地实现 第 3.4 节 中的线性回归模型。

import numpy as np
import torch
from torch import nn
from d2l import torch as d2l
from mxnet import autograd, gluon, init, np, npx
from mxnet.gluon import nn
from d2l import mxnet as d2l

npx.set_np()
import jax
import optax
from flax import linen as nn
from jax import numpy as jnp
from d2l import jax as d2l
No GPU/TPU found, falling back to CPU. (Set TF_CPP_MIN_LOG_LEVEL=0 and rerun for more info.)
import numpy as np
import tensorflow as tf
from d2l import tensorflow as d2l

3.5.1. 定义模型

当我们在 第 3.4 节 中从头开始实现线性回归时,我们明确定义了我们的模型参数,并编写了使用基本线性代数运算来产生输出的计算。你*应该*知道如何这样做。但是,一旦你的模型变得更加复杂,并且你几乎每天都必须这样做时,你会很高兴得到帮助。这种情况类似于从头开始编写自己的博客。做一两次是有益且有启发性的,但如果你花一个月的时间重复造轮子,那你将是一个糟糕的 Web 开发人员。

对于标准操作,我们可以使用框架的预定义层,这使我们能够专注于用于构建模型的层,而不用担心它们的实现。回想一下 图 3.1.2 中描述的单层网络架构。该层被称为*全连接*层,因为它的每个输入都通过矩阵-向量乘法连接到它的每个输出。

在 PyTorch 中,全连接层在 LinearLazyLinear 类中定义(自 1.8.0 版起可用)。后者允许用户*仅*指定输出维度,而前者还要求指定有多少输入进入该层。指定输入形状不方便,可能需要不平凡的计算(例如在卷积层中)。因此,为简单起见,我们将尽可能使用这种“惰性”层。

class LinearRegression(d2l.Module):  #@save
    """The linear regression model implemented with high-level APIs."""
    def __init__(self, lr):
        super().__init__()
        self.save_hyperparameters()
        self.net = nn.LazyLinear(1)
        self.net.weight.data.normal_(0, 0.01)
        self.net.bias.data.fill_(0)

在 Gluon 中,全连接层在 Dense 类中定义。由于我们只想生成一个标量输出,我们将其设置为 1。值得注意的是,为方便起见,Gluon 不要求我们为每个层指定输入形状。因此,我们不需要告诉 Gluon 有多少输入进入这个线性层。当我们第一次通过模型传递数据时,例如,当我们稍后执行 net(X) 时,Gluon 会自动推断出每个层的输入数量,从而实例化正确的模型。我们稍后会更详细地描述这是如何工作的。

class LinearRegression(d2l.Module):  #@save
    """The linear regression model implemented with high-level APIs."""
    def __init__(self, lr):
        super().__init__()
        self.save_hyperparameters()
        self.net = nn.Dense(1)
        self.net.initialize(init.Normal(sigma=0.01))
class LinearRegression(d2l.Module):  #@save
    """The linear regression model implemented with high-level APIs."""
    lr: float

    def setup(self):
        self.net = nn.Dense(1, kernel_init=nn.initializers.normal(0.01))

在 Keras 中,全连接层在 Dense 类中定义。由于我们只想生成一个标量输出,我们将其设置为 1。值得注意的是,为方便起见,Keras 不要求我们为每个层指定输入形状。我们不需要告诉 Keras 有多少输入进入这个线性层。当我们第一次尝试通过模型传递数据时,例如,当我们稍后执行 net(X) 时,Keras 会自动推断出每个层的输入数量。我们稍后会更详细地描述这是如何工作的。

class LinearRegression(d2l.Module):  #@save
    """The linear regression model implemented with high-level APIs."""
    def __init__(self, lr):
        super().__init__()
        self.save_hyperparameters()
        initializer = tf.initializers.RandomNormal(stddev=0.01)
        self.net = tf.keras.layers.Dense(1, kernel_initializer=initializer)

forward 方法中,我们只需调用预定义层的内置 __call__ 方法来计算输出。

@d2l.add_to_class(LinearRegression)  #@save
def forward(self, X):
    return self.net(X)
@d2l.add_to_class(LinearRegression)  #@save
def forward(self, X):
    return self.net(X)
@d2l.add_to_class(LinearRegression)  #@save
def forward(self, X):
    return self.net(X)
@d2l.add_to_class(LinearRegression)  #@save
def forward(self, X):
    return self.net(X)

3.5.2. 定义损失函数

MSELoss 类计算均方误差(不含 (3.1.5) 中的 \(1/2\) 因子)。默认情况下,MSELoss 返回样本损失的平均值。它比我们自己实现更快(也更容易使用)。

@d2l.add_to_class(LinearRegression)  #@save
def loss(self, y_hat, y):
    fn = nn.MSELoss()
    return fn(y_hat, y)

loss 模块定义了许多有用的损失函数。为了速度和方便,我们放弃实现自己的损失函数,而选择内置的 loss.L2Loss。因为它返回的是每个样本的平方误差,我们使用 mean 来对小批量上的损失进行平均。

@d2l.add_to_class(LinearRegression)  #@save
def loss(self, y_hat, y):
    fn = gluon.loss.L2Loss()
    return fn(y_hat, y).mean()
@d2l.add_to_class(LinearRegression)  #@save
def loss(self, params, X, y, state):
    y_hat = state.apply_fn({'params': params}, *X)
    return optax.l2_loss(y_hat, y).mean()

MeanSquaredError 类计算均方误差(不含 (3.1.5) 中的 \(1/2\) 因子)。默认情况下,它返回样本损失的平均值。

@d2l.add_to_class(LinearRegression)  #@save
def loss(self, y_hat, y):
    fn = tf.keras.losses.MeanSquaredError()
    return fn(y, y_hat)

3.5.3. 定义优化算法

小批量随机梯度下降是优化神经网络的标准工具,因此 PyTorch 在 optim 模块中支持它以及该算法的多种变体。当我们实例化一个 SGD 实例时,我们指定要优化的参数(可通过 self.parameters() 从我们的模型中获得),以及优化算法所需的学习率(self.lr)。

@d2l.add_to_class(LinearRegression)  #@save
def configure_optimizers(self):
    return torch.optim.SGD(self.parameters(), self.lr)

小批量随机梯度下降是优化神经网络的标准工具,因此 Gluon 通过其 Trainer 类支持它以及该算法的多种变体。请注意,Gluon 的 Trainer 类代表优化算法,而我们在 第 3.2 节 中创建的 Trainer 类包含训练方法,即重复调用优化器来更新模型参数。当我们实例化 Trainer 时,我们指定要优化的参数(可通过 net.collect_params() 从我们的模型 net 中获得)、我们希望使用的优化算法(sgd)以及优化算法所需的超参数字典。

@d2l.add_to_class(LinearRegression)  #@save
def configure_optimizers(self):
    return gluon.Trainer(self.collect_params(),
                         'sgd', {'learning_rate': self.lr})
@d2l.add_to_class(LinearRegression)  #@save
def configure_optimizers(self):
    return optax.sgd(self.lr)

小批量随机梯度下降是优化神经网络的标准工具,因此 Keras 在 optimizers 模块中支持它以及该算法的多种变体。

@d2l.add_to_class(LinearRegression)  #@save
def configure_optimizers(self):
    return tf.keras.optimizers.SGD(self.lr)

3.5.4. 训练

你可能已经注意到,通过深度学习框架的高级 API 来表达我们的模型需要更少的代码行。我们不必单独分配参数、定义损失函数或实现小批量随机梯度下降。一旦我们开始处理更复杂的模型,高级 API 的优势将大大增加。

现在我们已经准备好了所有的基本部分,训练循环本身与我们从头开始实现的那个相同。所以我们只需调用在 第 3.2.4 节 中引入的 fit 方法,它依赖于 第 3.4 节fit_epoch 方法的实现,来训练我们的模型。

model = LinearRegression(lr=0.03)
data = d2l.SyntheticRegressionData(w=torch.tensor([2, -3.4]), b=4.2)
trainer = d2l.Trainer(max_epochs=3)
trainer.fit(model, data)
../_images/output_linear-regression-concise_bee6dc_87_0.svg
model = LinearRegression(lr=0.03)
data = d2l.SyntheticRegressionData(w=np.array([2, -3.4]), b=4.2)
trainer = d2l.Trainer(max_epochs=3)
trainer.fit(model, data)
../_images/output_linear-regression-concise_bee6dc_90_0.svg
model = LinearRegression(lr=0.03)
data = d2l.SyntheticRegressionData(w=jnp.array([2, -3.4]), b=4.2)
trainer = d2l.Trainer(max_epochs=3)
trainer.fit(model, data)
../_images/output_linear-regression-concise_bee6dc_93_0.svg
model = LinearRegression(lr=0.03)
data = d2l.SyntheticRegressionData(w=tf.constant([2, -3.4]), b=4.2)
trainer = d2l.Trainer(max_epochs=3)
trainer.fit(model, data)
../_images/output_linear-regression-concise_bee6dc_96_0.svg

下面,我们比较通过在有限数据上训练学到的模型参数和生成我们数据集的实际参数。要访问参数,我们访问所需层的权重和偏置。与我们从头开始的实现一样,请注意我们的估计参数与其真实对应物非常接近。

@d2l.add_to_class(LinearRegression)  #@save
def get_w_b(self):
    return (self.net.weight.data, self.net.bias.data)
w, b = model.get_w_b()

print(f'error in estimating w: {data.w - w.reshape(data.w.shape)}')
print(f'error in estimating b: {data.b - b}')
error in estimating w: tensor([ 0.0094, -0.0030])
error in estimating b: tensor([0.0137])
@d2l.add_to_class(LinearRegression)  #@save
def get_w_b(self):
    return (self.net.weight.data(), self.net.bias.data())
w, b = model.get_w_b()
@d2l.add_to_class(LinearRegression)  #@save
def get_w_b(self, state):
    net = state.params['net']
    return net['kernel'], net['bias']

w, b = model.get_w_b(trainer.state)
@d2l.add_to_class(LinearRegression)  #@save
def get_w_b(self):
    return (self.get_weights()[0], self.get_weights()[1])

w, b = model.get_w_b()

3.5.5. 总结

本节包含了(本书中)第一个利用现代深度学习框架(如 MXNet (Chen et al., 2015)、JAX (Frostig et al., 2018)、PyTorch (Paszke et al., 2019) 和 Tensorflow (Abadi et al., 2016)) 提供的便利来实现深度网络。我们使用了框架的默认设置来加载数据、定义层、损失函数、优化器和训练循环。只要框架提供了所有必要的功能,通常使用它们是一个好主意,因为这些组件的库实现往往经过性能高度优化和可靠性适当测试。同时,尽量不要忘记这些模块是*可以*直接实现的。这对于希望站在模型开发前沿的有抱负的研究人员尤为重要,因为你们将发明新的组件,而这些组件不可能存在于任何当前的库中。

在 PyTorch 中,data 模块提供了数据处理工具,nn 模块定义了大量的神经网络层和常见的损失函数。我们可以通过用以 _ 结尾的方法替换它们的值来初始化参数。注意,我们需要指定网络的输入维度。虽然现在这很简单,但当我们想要设计具有许多层的复杂网络时,它可能会产生显著的连锁反应。需要仔细考虑如何参数化这些网络以实现可移植性。

在 Gluon 中,data 模块提供了数据处理工具,nn 模块定义了大量的神经网络层,而 loss 模块定义了许多常见的损失函数。此外,initializer 提供了许多参数初始化的选择。为方便用户,维度和存储是自动推断的。这种惰性初始化的一个后果是,在参数被实例化(和初始化)之前,你不能尝试访问它们。

在 TensorFlow 中,data 模块提供了数据处理工具,keras 模块定义了大量的神经网络层和常见的损失函数。此外,initializers 模块提供了各种模型参数初始化的方法。网络的维度和存储是自动推断的(但要小心不要在参数被初始化之前尝试访问它们)。

3.5.6. 练习

  1. 如果将小批量上的总损失替换为小批量上损失的平均值,您需要如何更改学习率?

  2. 查看框架文档,看看提供了哪些损失函数。特别是,用 Huber 的鲁棒损失函数替换平方损失。也就是说,使用损失函数

    (3.5.1)\[\begin{split}l(y,y') = \begin{cases}|y-y'| -\frac{\sigma}{2} & \textrm{ 如果 } |y-y'| > \sigma \\ \frac{1}{2 \sigma} (y-y')^2 & \textrm{ 否则}\end{cases}\end{split}\]
  3. 您如何访问模型权重的梯度?

  4. 如果更改学习率和训练周期数,对解决方案有什么影响?它会持续改进吗?

  5. 当您改变生成的数据量时,解决方案如何变化?

    1. 绘制 \(\hat{\mathbf{w}} - \mathbf{w}\)\(\hat{b} - b\) 的估计误差作为数据量的函数。提示:以对数方式而不是线性方式增加数据量,即 5, 10, 20, 50, ..., 10,000,而不是 1000, 2000, ..., 10,000。

    2. 为什么提示中的建议是合适的?