Run this notebook online:Binder or Colab: Colab

5.1. 层和块

当我们第一次介绍神经网络时,我们关注的是具有单一输出的线性模型。在这里,整个模型只由一个神经元组成。注意,单个神经元(1)接受一些输入;(2)生成相应的标量输出;(3)具有一组相关 参数(parameters),这些参数可以更新以优化某些感兴趣的目标函数。然后,当我们开始考虑具有多个输出的网络,我们就利用矢量化算法来描述整层神经元。像单个神经元一样,层(1)接受一组输入,(2)生成相应的输出,(3)由一组可调整参数描述。当我们使用softmax回归时,一个单层本身就是模型。然而,即使我们随后引入了多层感知机,我们仍然可以认为该模型保留了上面所说的基本结构。

有趣的是,对于多层感知机而言,整个模型及其组成层都是这种结构。整个模型接受原始输入(特征),生成输出(预测),并包含一些参数(所有组成层的参数集合)。同样,每个单独的层接收输入(由前一层提供)生成输出(到下一层的输入),并且具有一组可调参数,这些参数根据从下一层反向传播的信号进行更新。

虽然你可能认为神经元、层和模型为我们的业务提供了足够的抽象,但事实证明,我们经常发现谈论比单个层大但比整个模型小的组件更方便。例如,在计算机视觉中广泛流行的ResNet-152结构就有数百层。这些层是 由层组的重复模式组成。一次只实现一层这样的网络会变得很乏味。这种问题不是我们幻想出来的,这种设计模式在实践中很常见。上面提到的ResNet结构赢得了2015年ImageNet和COCO计算机视觉比赛的识别和检测任务 [He et al., 2016a],目前ResNet结构仍然是许多视觉任务的首选结构。在其他的领域,如自然语言处理和语音,层以各种重复模式排列的类似结构现在也是普遍存在。

为了实现这些复杂的网络,我们引入了神经网络的概念。块可以描述单个层、由多个层组成的组件或整个模型本身。使用块进行抽象的一个好处是可以将一些块组合成更大的组件,这一过程通常是递归的。这一点在 fig_blocks 中进行了说明。通过定义代码来按需生成任意复杂度的块,我们可以通过简洁的代码实现复杂的神经网络。

多个层被组合成块,形成更大的模型。 .. _fig_blocks:

从编程的角度来看,块(Block) 由表示。它的任何子类都必须定义一个将其输入转换为输出的正向传播函数,并且必须存储任何必需的参数。注意,有些块不需要任何参数。最后,为了计算梯度,块必须具有反向传播函数。幸运的是,在定义我们自己的块时,由于自动微分(在 Section 2.5 中引入)提供了一些后端实现,我们只需要考虑正向传播函数和必需的参数。

首先,我们回顾一下多层感知机( Section 4.3 )的代码。下面的代码生成一个网络,其中包含一个具有256个单元和ReLU激活函数的全连接的隐藏层,然后是一个具有10个隐藏单元且不带激活函数的全连接的输出层。

%load ../utils/djl-imports
NDManager manager = NDManager.newBaseManager();

int inputSize = 20;
NDArray x = manager.randomUniform(0, 1, new Shape(2, inputSize)); // (2, 20) shape

Model model = Model.newInstance("lin-reg");

SequentialBlock net = new SequentialBlock();

net.add(Linear.builder().setUnits(256).build());
net.add(Activation.reluBlock());
net.add(Linear.builder().setUnits(10).build());
net.setInitializer(new NormalInitializer(), Parameter.Type.WEIGHT);
net.initialize(manager, DataType.FLOAT32, x.getShape());

model.setBlock(net);

下面的例子里,我们使用一个最简化的 Translator 来处理输入和输出。NoopTranslator 是一个无操作 Translator。它不对输入的数据进行任何操作,Translator 接收到的输入是什么,它会原样返回同样的数据。譬如说,接收到一个 NDList 就会返回一个 NDList。当然,我们也可以定义并使用复杂数据处理模型。譬如说,定义一个我们自己的 Translator,这个 Translator 按照我们的需要进行前处理和后处理。

Translator translator = new NoopTranslator();

此处,我们使用 translator 创建一个 Predictor

NDList xList = new NDList(x);

Predictor predictor = model.newPredictor(translator);

((NDList) predictor.predict(xList)).singletonOrThrow();
ND: (2, 10) gpu(0) float32
[[ 0.0042, -0.0039, -0.0049, -0.0034, -0.0049, -0.0034, -0.0012, -0.005 , -0.0002, -0.0035],
 [ 0.0042, -0.0028, -0.0028, -0.0021, -0.0031, -0.0013, -0.0006, -0.003 ,  0.0002, -0.0045],
]

注意x 必须打包在一个 NDList 中输入,因为它之前的数据类型是一个 NDArray。 另外,predict() 的输出是一个 Object,这个输出结果需要转换成 NDListNoopTranslator 的输出是一个 NDListNDList 类可以调用后续的 singletonOrThrow() 函数。下面的例子,我们用初始化 SequentialBlock 的方法创建了一个自己的模型,并将这个模型赋值给了变量 net。然后,我们重复地调用这个模型的 add() 函数,按模型应有的运行次序添加它的运行层。简而言之,SequentialBlock 定义了一个特殊的 AbstractBlock 抽象块,各个抽象块之间维持着一个固定的运行次序。模型的 add() 函数,就是将这些抽象块 AbstractBlock 依次定义好了它们的次序,谁先谁后的次序。值得注意的是,每个加进去的“层”,都是一个 Linear 类。这个 Linear 本身,就是 AbstractBlock 的子类。它的 forward() 函数,实现得超级简单,它把每个“块”窜在了一起,前一个“块”的输出,就是下一个“块”的输入。

5.1.1. 自定义块

要想直观地了解块是如何工作的,最简单的方法可能就是自己实现一个。在实现我们自定义块之前,我们简要总结一下每个块必须提供的基本功能:

  1. 将输入数据作为其正向传播函数的参数。

  2. 通过正向传播函数来生成输出。请注意,输出的形状可能与输入的形状不同。例如,我们上面模型中的第一个全连接的层接收任意维的输入,但是返回一个维度256的输出。

  3. 计算其输出关于输入的梯度,可通过其反向传播函数进行访问。通常这是自动发生的。

  4. 存储和访问正向传播计算所需的参数。

  5. 根据需要初始化模型参数。

在下面的代码片段中,我们从零开始编写一个块。它包含一个多层感知机,其具有256个隐藏单元的隐藏层和一个10维输出层。注意,下面的MLP类继承了表示块的类。我们的实现将严重依赖父类,只需要实现我们自己的正向传播函数 forwardInternal(), getOutputShapes()initializeChildBlocks() 函数。

class MLP extends AbstractBlock {

    private static final byte VERSION = 1;

    private Block flattenInput;
    private Block hidden256;
    private Block output10;

    // Declare a layer with model parameters. Here, we declare two fully
    // connected layers
    public MLP(int inputSize) {
        super(VERSION); // Dont need to worry about this

        flattenInput = addChildBlock("flattenInput", Blocks.batchFlattenBlock(inputSize));
        hidden256 = addChildBlock("hidden256", Linear.builder().setUnits(256).build());// Hidden Layer
        output10 = addChildBlock("output10", Linear.builder().setUnits(10).build()); // Output Layer
    }

    @Override
    // Define the forward computation of the model, that is, how to return
    // the required model output based on the input x
    protected NDList forwardInternal(
            ParameterStore parameterStore,
            NDList inputs,
            boolean training,
            PairList<String, Object> params) {
        NDList current = inputs;
        current = flattenInput.forward(parameterStore, current, training);
        current = hidden256.forward(parameterStore, current, training);
        // We use the Activation.relu() function here
        // Since it takes in an NDArray, we call `singletonOrThrow()`
        // on the NDList `current` to get the NDArray and then
        // wrap it in a new NDList to be passed
        // to the next `forward()` call
        current = new NDList(Activation.relu(current.singletonOrThrow()));
        current = output10.forward(parameterStore, current, training);
        return current;
    }

    @Override
    public Shape[] getOutputShapes(Shape[] inputs) {
        Shape[] current = inputs;
        for (Block block : children.values()) {
            current = block.getOutputShapes(current);
        }
        return current;
    }

    @Override
    public void initializeChildBlocks(NDManager manager, DataType dataType, Shape... inputShapes) {
        hidden256.initialize(manager, dataType, new Shape(1, inputSize));
        output10.initialize(manager, dataType, new Shape(1, 256));
    }
}

让我们首先关注正向传播函数。注意,它以X作为输入,计算带有激活函数的隐藏表示,并输出其未归一化的输出值。在这个MLP实现中,两个层都是实例变量。要了解这为什么是合理的,可以想象实例化两个多层感知机(net1net2),并根据不同的数据对它们进行训练。当然,我们希望它们学到两种不同的模型。

注意,除非我们实现一个新的运算符,否则我们不必担心反向传播函数或参数初始化,系统将自动生成这些。让我们试一下。

MLP net = new MLP(inputSize);
net.setInitializer(new NormalInitializer(), Parameter.Type.WEIGHT);
net.initialize(manager, DataType.FLOAT32, x.getShape());

model.setBlock(net);

Predictor predictor = model.newPredictor(translator);

((NDList) predictor.predict(xList)).singletonOrThrow();
ND: (2, 10) gpu(0) float32
[[ 0.001 , -0.0002,  0.0031,  0.0045,  0.003 ,  0.0038,  0.0037,  0.0045,  0.0009,  0.002 ],
 [ 0.0018, -0.0006,  0.0028,  0.0001,  0.0041,  0.0021,  0.0013, -0.0008,  0.0011,  0.0026],
]

块抽象的一个主要优点是它的多功能性。我们可以子类化块以创建层(如全连接层的类)、整个模型(如上面的MLP类)或具有中等复杂度的各种组件。我们在接下来的章节中充分利用了这种多功能性,比如在处理卷积神经网络时。

5.1.2. 顺序块

现在我们可以更仔细地看看SequentialBlock类是如何工作的。回想一下SequentialBlock的设计是为了把其他模块串起来。为了构建我们自己的简化的MySequential,我们只需要定义两个关键函数: 1. 一种将块逐个追加到列表中的函数。 2. 一种正向传播函数,用于将输入按追加块的顺序传递给块组成的“链条”。

下面的MySequential类提供了与默认SequentialBlock类相同的功能。

class MySequential extends AbstractBlock {

    private static final byte VERSION = 1;

    public MySequential() {
        super(VERSION);
    }

    public MySequential add(Block block) {
        // Here, block is an instance of a Block subclass, and we assume it has
        // a unique name. We add the child block to the children BlockList
        // with `addChildBlock()` which is defined in AbstractBlock.
        if (block != null) {
            addChildBlock(block.getClass().getSimpleName(), block);
        }
        return this;
    }

    @Override
    protected NDList forwardInternal(
            ParameterStore parameterStore,
            NDList inputs,
            boolean training,
            PairList<String, Object> params) {
        NDList current = inputs;
        for (Block block : children.values()) {
            // BlockList guarantees that members will be traversed in the order
            // they were added
            current = block.forward(parameterStore, current, training);
        }
        return current;
    }

    @Override
    // Initializes all child blocks
    public void initializeChildBlocks(NDManager manager, DataType dataType, Shape... inputShapes) {
        Shape[] shapes = inputShapes;
        for (Block child : getChildren().values()) {
            child.initialize(manager, dataType, shapes);
            shapes = child.getOutputShapes(shapes);
        }
    }

    @Override
    public Shape[] getOutputShapes(Shape[] inputs) {
        Shape[] current = inputs;
        for (Block block : children.values()) {
            current = block.getOutputShapes(current);
        }
        return current;
    }
}

add()函数向children添加一个块。你可能会想知道为什么每个AbstractBlock都有一个children属性。简而言之,children的主要优点是,在块的参数初始化过程中,Block 知道在children中查找需要初始化参数的子块。

MySequential的正向传播函数被调用时,每个添加的块都按照它们被添加的顺序执行。现在可以使用我们的MySequential类重新实现多层感知机。

MySequential net = new MySequential();
net.add(Linear.builder().setUnits(256).build());
net.add(Activation.reluBlock());
net.add(Linear.builder().setUnits(10).build());

net.setInitializer(new NormalInitializer(), Parameter.Type.WEIGHT);
net.initialize(manager, DataType.FLOAT32, x.getShape());

Model model = Model.newInstance("my-sequential");
model.setBlock(net);

Predictor predictor = model.newPredictor(translator);
((NDList) predictor.predict(xList)).singletonOrThrow();
ND: (2, 10) gpu(0) float32
[[ 0.0054, -0.0037,  0.0034,  0.0021, -0.0048,  0.0027, -0.0022,  0.0045,  0.0014,  0.0005],
 [ 0.0053, -0.0016,  0.004 , -0.0018, -0.0056,  0.0016, -0.003 ,  0.0026,  0.0012, -0.0009],
]

注意,MySequential的用法与之前为SequentialBlock类编写的代码相同(如 Section 4.3 中所述)。

5.1.3. 在正向传播函数中执行代码

SequentialBlock类使模型构造变得简单,允许我们组合新的结构,而不必定义自己的类。然而,并不是所有的架构都是简单的顺序结构。当需要更大的灵活性时,我们需要定义自己的块。例如,我们可能希望在正向传播函数中执行 Java 的控制流。此外,我们可能希望执行任意的数学运算,而不是简单地依赖预定义的神经网络层。

你可能已经注意到,到目前为止,我们网络中的所有操作都对网络的激活值及网络的参数起作用。然而,有时我们可能希望合并既不是上一层的结果也不是可更新参数的项。我们称之为常数参数(constant parameters)。例如,我们需要一个计算函数\(f(\mathbf{x},\mathbf{w}) = c \cdot \mathbf{w}^\top \mathbf{x}\)的层,其中\(\mathbf{x}\)是输入,\(\mathbf{w}\)是我们的参数,\(c\)是某个在优化过程中没有更新的指定常量。因此我们实现了一个FixedHiddenMLP类,如下所示。

class FixedHiddenMLP extends AbstractBlock {

    private static final byte VERSION = 1;

    private Block hidden20;
    private NDArray constantParamWeight;
    private NDArray constantParamBias;

    public FixedHiddenMLP() {
        super(VERSION);
        hidden20 = addChildBlock("denseLayer", Linear.builder().setUnits(20).build());
    }

    @Override
    protected NDList forwardInternal(
            ParameterStore parameterStore,
            NDList inputs,
            boolean training,
            PairList<String, Object> params) {
        NDList current = inputs;

        // Fully connected layer
        current = hidden20.forward(parameterStore, current, training);
        // Use the constant parameters NDArray
        // Call the NDArray internal method `linear()` to do calculation
        current = Linear.linear(current.singletonOrThrow(), constantParamWeight, constantParamBias);
        // Relu Activation
        current = new NDList(Activation.relu(current.singletonOrThrow()));
        // Reuse the fully connected layer. This is equivalent to sharing
        // parameters with two fully connected layers
        current = hidden20.forward(parameterStore, current, training);

        // Here in Control flow, we return the scalar
        // for comparison
        while (current.head().abs().sum().getFloat() > 1) {
            current.head().divi(2);
        }
        return new NDList(current.head().abs().sum());
    }

    @Override
    public void initializeChildBlocks(NDManager manager, DataType dataType, Shape... inputShapes) {
        Shape[] shapes = inputShapes;
        for (Block child : getChildren().values()) {
            child.initialize(manager, dataType, shapes);
            shapes = child.getOutputShapes(shapes);
        }
        // Initialize constant parameter layer
        constantParamWeight = manager.randomUniform(-0.07f, 0.07f, new Shape(20, 20));
        constantParamBias = manager.zeros(new Shape(20));
    }

    @Override
    public Shape[] getOutputShapes(Shape[] inputs) {
        return new Shape[]{new Shape(1)}; // we return a scalar so the shape is 1
    }
}

在这个FixedHiddenMLP模型中,我们实现了一个隐藏层,其权重(self.rand_weight)在实例化时被随机初始化,之后为常量。这个权重不是一个模型参数,因此它永远不会被反向传播更新。然后,网络将这个固定层的输出通过一个全连接层。

注意,在返回输出之前,我们的模型做了一些不寻常的事情。我们运行了一个while循环,在\(L_1\)范数大于\(1\)的条件下,将输出向量除以\(2\),直到它满足条件为止。最后,我们返回了X中所有项的和。据我们所知,没有标准的神经网络执行这种操作。注意,此特定操作在任何实际任务中可能都没有用处。我们的重点只是向你展示如何将任意代码集成到神经网络计算的流程中。

xList
NDList size: 1
0 : (2, 20) float32
FixedHiddenMLP net = new FixedHiddenMLP();

net.setInitializer(new NormalInitializer(), Parameter.Type.WEIGHT);
net.initialize(manager, DataType.FLOAT32, x.getShape());

Model model = Model.newInstance("fixed-mlp");
model.setBlock(net);

Predictor predictor = model.newPredictor(translator);
((NDList) predictor.predict(xList)).singletonOrThrow();
ND: () gpu(0) float32
0.006

我们可以混合搭配各种组合块的方法。在下面的例子中,我们以一些想到的方法嵌套块。

class NestMLP extends AbstractBlock {

    private static final byte VERSION = 1;

    private SequentialBlock net;
    private Block dense;

    private Block test;

    public NestMLP() {
        super(VERSION);
        net = new SequentialBlock();
        net.add(Linear.builder().setUnits(64).build());
        net.add(Activation.reluBlock());
        net.add(Linear.builder().setUnits(32).build());
        net.add(Activation.reluBlock());
        addChildBlock("net", net);

        dense = addChildBlock("dense", Linear.builder().setUnits(16).build());
    }

    @Override
    protected NDList forwardInternal(
            ParameterStore parameterStore,
            NDList inputs,
            boolean training,
            PairList<String, Object> params) {
        NDList current = inputs;

        // Fully connected layer
        current = net.forward(parameterStore, current, training);
        current = dense.forward(parameterStore, current, training);
        current = new NDList(Activation.relu(current.singletonOrThrow()));
        return current;
    }

    @Override
    public Shape[] getOutputShapes(Shape[] inputs) {
        Shape[] current = inputs;
        for (Block block : children.values()) {
            current = block.getOutputShapes(current);
        }
        return current;
    }

    @Override
    public void initializeChildBlocks(NDManager manager, DataType dataType, Shape... inputShapes) {
        Shape[] shapes = inputShapes;
        for (Block child : getChildren().values()) {
            child.initialize(manager, dataType, shapes);
            shapes = child.getOutputShapes(shapes);
        }
    }
}

SequentialBlock chimera = new SequentialBlock();

chimera.add(new NestMLP());
chimera.add(Linear.builder().setUnits(20).build());
chimera.add(new FixedHiddenMLP());

chimera.setInitializer(new NormalInitializer(), Parameter.Type.WEIGHT);
chimera.initialize(manager, DataType.FLOAT32, x.getShape());
Model model = Model.newInstance("chimera");
model.setBlock(chimera);

Predictor predictor = model.newPredictor(translator);
((NDList) predictor.predict(xList)).singletonOrThrow();
ND: () gpu(0) float32
 1.28119018e-08

5.1.4. 小结

  • 层也是块。

  • 一个块可以由许多层组成。

  • 一个块可以由许多块组成。

  • 块可以包含代码。

  • 块负责大量的内部处理,包括参数初始化和反向传播。

  • 层和块的顺序连接由SequentialBlock块处理。

5.1.5. 练习

  1. 实现一个块,它以两个块为参数,例如net1net2,并返回正向传播中两个网络的串联输出。这也被称为平行块。

  2. 假设你想要连接同一网络的多个实例。实现一个工厂函数,该函数生成同一个块的多个实例,并在此基础上构建更大的网络。