5.2. 多层感知机的实现
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 SageMaker Studio Lab 中打开 Notebook

多层感知机(MLP)的实现比简单的线性模型要复杂不了多少。关键的概念区别在于我们现在连接了多个层。

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

npx.set_np()
import jax
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 tensorflow as tf
from d2l import tensorflow as d2l

5.2.1. 从零开始实现

让我们再次从头开始实现这样一个网络。

5.2.1.1. 初始化模型参数

回想一下,Fashion-MNIST包含10个类别,每张图像由一个\(28 \times 28 = 784\)的灰度像素值网格组成。和之前一样,我们暂时忽略像素之间的空间结构,因此我们可以把它看作是一个有784个输入特征和10个类别的分类数据集。首先,我们将实现一个带有一个隐藏层和256个隐藏单元的MLP。层数和它们的宽度都是可调的(它们被认为是超参数)。通常,我们选择层宽为2的较大次幂。由于硬件中内存的分配和寻址方式,这在计算上是高效的。

再次,我们将用几个张量来表示我们的参数。注意,对于每一层,我们都必须跟踪一个权重矩阵和一个偏置向量。像往常一样,我们为损失函数关于这些参数的梯度分配内存。

在下面的代码中,我们使用 nn.Parameter 将一个类属性自动注册为由 autograd 跟踪的参数 (2.5节)。

class MLPScratch(d2l.Classifier):
    def __init__(self, num_inputs, num_outputs, num_hiddens, lr, sigma=0.01):
        super().__init__()
        self.save_hyperparameters()
        self.W1 = nn.Parameter(torch.randn(num_inputs, num_hiddens) * sigma)
        self.b1 = nn.Parameter(torch.zeros(num_hiddens))
        self.W2 = nn.Parameter(torch.randn(num_hiddens, num_outputs) * sigma)
        self.b2 = nn.Parameter(torch.zeros(num_outputs))

在下面的代码中,我们首先定义和初始化参数,然后启用梯度跟踪。

class MLPScratch(d2l.Classifier):
    def __init__(self, num_inputs, num_outputs, num_hiddens, lr, sigma=0.01):
        super().__init__()
        self.save_hyperparameters()
        self.W1 = np.random.randn(num_inputs, num_hiddens) * sigma
        self.b1 = np.zeros(num_hiddens)
        self.W2 = np.random.randn(num_hiddens, num_outputs) * sigma
        self.b2 = np.zeros(num_outputs)
        for param in self.get_scratch_params():
            param.attach_grad()

在下面的代码中,我们使用 flax.linen.Module.param 来定义模型参数。

class MLPScratch(d2l.Classifier):
    num_inputs: int
    num_outputs: int
    num_hiddens: int
    lr: float
    sigma: float = 0.01

    def setup(self):
        self.W1 = self.param('W1', nn.initializers.normal(self.sigma),
                             (self.num_inputs, self.num_hiddens))
        self.b1 = self.param('b1', nn.initializers.zeros, self.num_hiddens)
        self.W2 = self.param('W2', nn.initializers.normal(self.sigma),
                             (self.num_hiddens, self.num_outputs))
        self.b2 = self.param('b2', nn.initializers.zeros, self.num_outputs)

在下面的代码中,我们使用 tf.Variable 来定义模型参数。

class MLPScratch(d2l.Classifier):
    def __init__(self, num_inputs, num_outputs, num_hiddens, lr, sigma=0.01):
        super().__init__()
        self.save_hyperparameters()
        self.W1 = tf.Variable(
            tf.random.normal((num_inputs, num_hiddens)) * sigma)
        self.b1 = tf.Variable(tf.zeros(num_hiddens))
        self.W2 = tf.Variable(
            tf.random.normal((num_hiddens, num_outputs)) * sigma)
        self.b2 = tf.Variable(tf.zeros(num_outputs))

5.2.1.2. 模型

为确保我们了解所有工作原理,我们将自己实现ReLU激活函数,而不是直接调用内置的 relu 函数。

def relu(X):
    a = torch.zeros_like(X)
    return torch.max(X, a)
def relu(X):
    return np.maximum(X, 0)
def relu(X):
    return jnp.maximum(X, 0)
def relu(X):
    return tf.math.maximum(X, 0)

由于我们忽略了空间结构,我们将每个二维图像 reshape 成一个长度为 num_inputs 的扁平向量。最后,我们只用几行代码就实现了我们的模型。因为我们使用了框架内置的autograd,所以这就是它所需要的一切。

@d2l.add_to_class(MLPScratch)
def forward(self, X):
    X = X.reshape((-1, self.num_inputs))
    H = relu(torch.matmul(X, self.W1) + self.b1)
    return torch.matmul(H, self.W2) + self.b2
@d2l.add_to_class(MLPScratch)
def forward(self, X):
    X = X.reshape((-1, self.num_inputs))
    H = relu(np.dot(X, self.W1) + self.b1)
    return np.dot(H, self.W2) + self.b2
@d2l.add_to_class(MLPScratch)
def forward(self, X):
    X = X.reshape((-1, self.num_inputs))
    H = relu(jnp.matmul(X, self.W1) + self.b1)
    return jnp.matmul(H, self.W2) + self.b2
@d2l.add_to_class(MLPScratch)
def forward(self, X):
    X = tf.reshape(X, (-1, self.num_inputs))
    H = relu(tf.matmul(X, self.W1) + self.b1)
    return tf.matmul(H, self.W2) + self.b2

5.2.1.3. 训练

幸运的是,MLP的训练循环与softmax回归完全相同。我们定义模型、数据和训练器,最后在模型和数据上调用 fit 方法。

model = MLPScratch(num_inputs=784, num_outputs=10, num_hiddens=256, lr=0.1)
data = d2l.FashionMNIST(batch_size=256)
trainer = d2l.Trainer(max_epochs=10)
trainer.fit(model, data)
../_images/output_mlp-implementation_d1b2f2_67_0.svg
model = MLPScratch(num_inputs=784, num_outputs=10, num_hiddens=256, lr=0.1)
data = d2l.FashionMNIST(batch_size=256)
trainer = d2l.Trainer(max_epochs=10)
trainer.fit(model, data)
../_images/output_mlp-implementation_d1b2f2_70_0.svg
model = MLPScratch(num_inputs=784, num_outputs=10, num_hiddens=256, lr=0.1)
data = d2l.FashionMNIST(batch_size=256)
trainer = d2l.Trainer(max_epochs=10)
trainer.fit(model, data)
../_images/output_mlp-implementation_d1b2f2_73_0.svg
model = MLPScratch(num_inputs=784, num_outputs=10, num_hiddens=256, lr=0.1)
data = d2l.FashionMNIST(batch_size=256)
trainer = d2l.Trainer(max_epochs=10)
trainer.fit(model, data)
../_images/output_mlp-implementation_d1b2f2_76_0.svg

5.2.2. 简洁实现

正如你可能预期的那样,通过依赖高级API,我们可以更简洁地实现MLP。

5.2.2.1. 模型

与我们对softmax回归的简洁实现(4.5节)相比,唯一的区别是我们添加了*两个*全连接层,而之前我们只添加了*一个*。第一个是隐藏层,第二个是输出层。

class MLP(d2l.Classifier):
    def __init__(self, num_outputs, num_hiddens, lr):
        super().__init__()
        self.save_hyperparameters()
        self.net = nn.Sequential(nn.Flatten(), nn.LazyLinear(num_hiddens),
                                 nn.ReLU(), nn.LazyLinear(num_outputs))
class MLP(d2l.Classifier):
    def __init__(self, num_outputs, num_hiddens, lr):
        super().__init__()
        self.save_hyperparameters()
        self.net = nn.Sequential()
        self.net.add(nn.Dense(num_hiddens, activation='relu'),
                     nn.Dense(num_outputs))
        self.net.initialize()
class MLP(d2l.Classifier):
    num_outputs: int
    num_hiddens: int
    lr: float

    @nn.compact
    def __call__(self, X):
        X = X.reshape((X.shape[0], -1))  # Flatten
        X = nn.Dense(self.num_hiddens)(X)
        X = nn.relu(X)
        X = nn.Dense(self.num_outputs)(X)
        return X
class MLP(d2l.Classifier):
    def __init__(self, num_outputs, num_hiddens, lr):
        super().__init__()
        self.save_hyperparameters()
        self.net = tf.keras.models.Sequential([
            tf.keras.layers.Flatten(),
            tf.keras.layers.Dense(num_hiddens, activation='relu'),
            tf.keras.layers.Dense(num_outputs)])

之前,我们为模型定义了 forward 方法,以使用模型参数转换输入。这些操作本质上是一个流水线:你接受一个输入,并应用一个转换(例如,与权重进行矩阵乘法后加上偏置),然后重复地使用当前转换的输出作为下一个转换的输入。然而,你可能已经注意到这里没有定义 forward 方法。实际上,MLPModule 类(3.2.2节)继承了 forward 方法,只需调用 self.net(X)X 是输入),现在它被定义为通过 Sequential 类的一系列转换。Sequential 类抽象了前向过程,使我们能够专注于转换。我们将在 6.1.2节 中进一步讨论 Sequential 类的工作原理。

5.2.2.2. 训练

训练循环与我们实现softmax回归时完全相同。这种模块化使我们能够将有关模型架构的问题与正交的考虑因素分开。

model = MLP(num_outputs=10, num_hiddens=256, lr=0.1)
trainer.fit(model, data)
../_images/output_mlp-implementation_d1b2f2_97_0.svg
model = MLP(num_outputs=10, num_hiddens=256, lr=0.1)
trainer.fit(model, data)
../_images/output_mlp-implementation_d1b2f2_100_0.svg
model = MLP(num_outputs=10, num_hiddens=256, lr=0.1)
trainer.fit(model, data)
../_images/output_mlp-implementation_d1b2f2_103_0.svg
model = MLP(num_outputs=10, num_hiddens=256, lr=0.1)
trainer.fit(model, data)
../_images/output_mlp-implementation_d1b2f2_106_0.svg

5.2.3. 总结

现在我们有了更多设计深度网络的实践,从单层到多层深度网络的步骤不再构成那么大的挑战。特别是,我们可以重用训练算法和数据加载器。不过请注意,从头开始实现MLP仍然很麻烦:命名和跟踪模型参数使得扩展模型变得困难。例如,想象一下想要在第42层和第43层之间插入另一层。除非我们愿意进行顺序重命名,否则这可能现在是第42b层。此外,如果我们从头开始实现网络,框架进行有意义的性能优化会困难得多。

尽管如此,你现在已经达到了1980年代后期的技术水平,当时全连接深度网络是神经网络建模的首选方法。我们的下一个概念性步骤将是考虑图像。在此之前,我们需要回顾一些统计基础知识以及如何高效计算模型的细节。

5.2.4. 练习

  1. 改变隐藏单元数 num_hiddens 并绘制其数量如何影响模型准确度的图。这个超参数的最佳值是多少?

  2. 尝试添加一个隐藏层,看看它如何影响结果。

  3. 为什么插入一个只有一个神经元的隐藏层是个坏主意?可能会出现什么问题?

  4. 改变学习率如何改变你的结果?在所有其他参数固定的情况下,哪个学习率能给你最好的结果?这与训练轮数有什么关系?

  5. 让我们联合优化所有超参数,即学习率、训练轮数、隐藏层数以及每层隐藏单元数。

    1. 通过优化所有这些参数,你能得到的最好结果是什么?

    2. 为什么处理多个超参数更具挑战性?

    3. 描述一种联合优化多个参数的有效策略。

  6. 对于一个有挑战性的问题,比较框架实现和从零开始实现的速度。它如何随着网络复杂度的变化而变化?

  7. 测量对齐良好和未对齐矩阵的张量-矩阵乘法速度。例如,测试维度为1024、1025、1026、1028和1032的矩阵。

    1. 这在GPU和CPU之间有何不同?

    2. 确定你的CPU和GPU的内存总线宽度。

  8. 尝试不同的激活函数。哪一个效果最好?

  9. 网络权重的初始化有区别吗?这重要吗?