Run this notebook online: or 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
,这个输出结果需要转换成 NDList
,NoopTranslator
的输出是一个 NDList
,NDList
类可以调用后续的
singletonOrThrow()
函数。下面的例子,我们用初始化
SequentialBlock
的方法创建了一个自己的模型,并将这个模型赋值给了变量
net
。然后,我们重复地调用这个模型的 add()
函数,按模型应有的运行次序添加它的运行层。简而言之,SequentialBlock
定义了一个特殊的 AbstractBlock
抽象块,各个抽象块之间维持着一个固定的运行次序。模型的 add()
函数,就是将这些抽象块 AbstractBlock
依次定义好了它们的次序,谁先谁后的次序。值得注意的是,每个加进去的“层”,都是一个
Linear
类。这个 Linear
本身,就是 AbstractBlock
的子类。它的
forward()
函数,实现得超级简单,它把每个“块”窜在了一起,前一个“块”的输出,就是下一个“块”的输入。
5.1.1. 自定义块¶
要想直观地了解块是如何工作的,最简单的方法可能就是自己实现一个。在实现我们自定义块之前,我们简要总结一下每个块必须提供的基本功能:
将输入数据作为其正向传播函数的参数。
通过正向传播函数来生成输出。请注意,输出的形状可能与输入的形状不同。例如,我们上面模型中的第一个全连接的层接收任意维的输入,但是返回一个维度256的输出。
计算其输出关于输入的梯度,可通过其反向传播函数进行访问。通常这是自动发生的。
存储和访问正向传播计算所需的参数。
根据需要初始化模型参数。
在下面的代码片段中,我们从零开始编写一个块。它包含一个多层感知机,其具有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
实现中,两个层都是实例变量。要了解这为什么是合理的,可以想象实例化两个多层感知机(net1
和net2
),并根据不同的数据对它们进行训练。当然,我们希望它们学到两种不同的模型。
注意,除非我们实现一个新的运算符,否则我们不必担心反向传播函数或参数初始化,系统将自动生成这些。让我们试一下。
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 SequentialBlock net;
private Block dense;
private Block test;
public NestMLP() {
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. 练习¶
实现一个块,它以两个块为参数,例如
net1
和net2
,并返回正向传播中两个网络的串联输出。这也被称为平行块。假设你想要连接同一网络的多个实例。实现一个工厂函数,该函数生成同一个块的多个实例,并在此基础上构建更大的网络。