Run this notebook online:Binder or Colab: Colab

5.2. 参数管理

一旦我们选择了架构并设置了超参数,我们就进入了训练阶段。此时,我们的目标是找到使损失函数最小化的参数值。经过训练后,我们将需要使用这些参数来做出未来的预测。此外,有时我们希望提取参数,以便在其他环境中复用它们,将模型保存到磁盘,以便它可以在其他软件中执行,或者为了获得科学的理解而进行检查。

大多数情况下,我们可以忽略声明和操作参数的具体细节,而只依靠深度学习框架来完成繁重的工作。然而,当我们离开具有标准层的层叠架构时,我们有时会陷入声明和操作参数的麻烦中。在本节中,我们将介绍以下内容:

  • 访问参数,用于调试、诊断和可视化。

  • 参数初始化。

  • 在不同模型组件间共享参数。

我们首先关注具有单隐藏层的多层感知机。

%load ../utils/djl-imports
public SequentialBlock getNet() {
    SequentialBlock net = new SequentialBlock();
    net.add(Linear.builder().setUnits(8).build());
    net.add(Activation.reluBlock());
    net.add(Linear.builder().setUnits(1).build());
    return net;
}
NDManager manager = NDManager.newBaseManager();

NDArray x = manager.randomUniform(0, 1, new Shape(2, 4));

SequentialBlock net = new SequentialBlock();

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

ParameterStore ps = new ParameterStore(manager, false);
net.forward(ps, new NDList(x), false).head(); // forward computation
ND: (2, 1) gpu(0) float32
[[-2.03669551e-05],
 [-1.32092864e-05],
]

5.2.1. 参数访问

我们来看一下如何从已有模型中访问参数。对于存在嵌套块的复杂模型,我们需要递归整个树来提取每个子块的参数。DJL 提供了 Block.getParameters() 函数来简化参数的访问。这是的我们可以通过索引或参数的名称来访问模型的任意参数。这就像模型是一个表一样。每层的参数都在其属性中。如下所示,我们可以检查第二个全连接层的参数。

ParameterList params = net.getParameters();
// Print out all the keys (unique!)
for (var pair : params) {
    System.out.println(pair.getKey());
}

// Use the unique key to access the Parameter
NDArray dense0Weight = params.get("01Linear_weight").getArray();
NDArray dense0Bias = params.get("01Linear_bias").getArray();

// Use indexing to access the Parameter
NDArray dense1Weight = params.valueAt(2).getArray();
NDArray dense1Bias = params.valueAt(3).getArray();

System.out.println(dense0Weight);
System.out.println(dense0Bias);

System.out.println(dense1Weight);
System.out.println(dense1Bias);
01Linear_weight
01Linear_bias
03Linear_weight
03Linear_bias
weight: (8, 4) gpu(0) float32 hasGradient
[[ 0.0014, -0.0122,  0.0031,  0.0111],
 [-0.0004, -0.0071, -0.0129, -0.0088],
 [-0.0006, -0.0082,  0.0143, -0.0013],
 [ 0.0028,  0.0083, -0.0075, -0.0138],
 [ 0.01  , -0.0114, -0.0035,  0.0054],
 [-0.015 , -0.0122,  0.0124, -0.0027],
 [-0.0147, -0.0099,  0.0028,  0.0095],
 [ 0.0079, -0.0132,  0.0047,  0.0124],
]

bias: (8) gpu(0) float32 hasGradient
[0., 0., 0., 0., 0., 0., 0., 0.]

weight: (1, 8) gpu(0) float32 hasGradient
[[ 0.0084,  0.0148,  0.0031,  0.004 , -0.0089,  0.0029, -0.0037, -0.0014],
]

bias: (1) gpu(0) float32 hasGradient
[0.]

输出的结果告诉我们一些重要的事情。首先,这个全连接层包含两个参数,分别是该层的权重和偏置。两者都存储为单精度浮点数(float32)。注意,参数名称允许我们唯一地标识每个参数,即使在包含数百个层的网络中也是如此。

5.2.1.1. 目标参数

注意,每个参数都表示为参数 Parameter 类的一个实例。参数是复合的对象,包含值、梯度和额外信息。除了值之外,我们还可以访问每个参数的梯度。由于我们还没有调用这个网络的反向传播,所以参数的梯度处于初始状态。

dense0Weight.getGradient();
ND: (8, 4) gpu(0) float32
[[0., 0., 0., 0.],
 [0., 0., 0., 0.],
 [0., 0., 0., 0.],
 [0., 0., 0., 0.],
 [0., 0., 0., 0.],
 [0., 0., 0., 0.],
 [0., 0., 0., 0.],
 [0., 0., 0., 0.],
]

5.2.1.2. 从嵌套块收集参数

让我们看看,如果我们将多个块相互嵌套,参数命名约定是如何工作的。

public SequentialBlock block1() {
    SequentialBlock net = new SequentialBlock();
    net.add(Linear.builder().setUnits(32).build());
    net.add(Activation.reluBlock());
    net.add(Linear.builder().setUnits(16).build());
    net.add(Activation.reluBlock());
    return net;
}

public SequentialBlock block2() {
    SequentialBlock net = new SequentialBlock();
    for (int i = 0; i < 4; i++) {
        net.add(block1());
    }
    return net;
}

SequentialBlock rgnet = new SequentialBlock();
rgnet.add(block2());
rgnet.add(Linear.builder().setUnits(10).build());
rgnet.setInitializer(new NormalInitializer(), Parameter.Type.WEIGHT);
rgnet.initialize(manager, DataType.FLOAT32, x.getShape());

rgnet.forward(ps, new NDList(x), false).singletonOrThrow();
ND: (2, 10) gpu(0) float32
[[-9.05861164e-15, -1.80095078e-14, -2.33998527e-14, -1.86868902e-14,  7.10750259e-15,  5.75573922e-15,  9.72335378e-16,  1.06593548e-14,  9.80970201e-15, -8.17016641e-15],
 [-4.27109291e-15, -7.85593921e-15, -9.57490109e-15, -7.16382689e-15,  2.99069440e-15,  2.62443375e-15,  6.40666075e-16,  4.29879427e-15,  4.13538595e-15, -3.19015266e-15],
]

现在我们已经设计了网络,让我们看看它是如何组织的。

rgnet
SequentialBlock(2, 4) {
    SequentialBlock(2, 4) {
            SequentialBlock(2, 4) {
                    Linear(2, 4) -> (2, 32)
                    ReLU(2, 32) -> (2, 32)
                    Linear(2, 32) -> (2, 16)
                    ReLU(2, 16) -> (2, 16)
            } -> (2, 16)
            SequentialBlock(2, 16) {
                    Linear(2, 16) -> (2, 32)
                    ReLU(2, 32) -> (2, 32)
                    Linear(2, 32) -> (2, 16)
                    ReLU(2, 16) -> (2, 16)
            } -> (2, 16)
            SequentialBlock(2, 16) {
                    Linear(2, 16) -> (2, 32)
                    ReLU(2, 32) -> (2, 32)
                    Linear(2, 32) -> (2, 16)
                    ReLU(2, 16) -> (2, 16)
            } -> (2, 16)
            SequentialBlock(2, 16) {
                    Linear(2, 16) -> (2, 32)
                    ReLU(2, 32) -> (2, 32)
                    Linear(2, 32) -> (2, 16)
                    ReLU(2, 16) -> (2, 16)
            } -> (2, 16)
    } -> (2, 16)
    Linear(2, 16) -> (2, 10)
} -> (2, 10)

我们也可以用列表的方式打印出所有的参数名称:

/* Parameters for RgNet */
for (var param : rgnet.getParameters()) {
    System.out.println(param.getValue().getArray());
}
weight: (32, 4) gpu(0) float32 hasGradient
[ Exceed max print size ]
bias: (32) gpu(0) float32 hasGradient
[0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., ... 12 more]

weight: (16, 32) gpu(0) float32 hasGradient
[ Exceed max print size ]
bias: (16) gpu(0) float32 hasGradient
[0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.]

weight: (32, 16) gpu(0) float32 hasGradient
[ Exceed max print size ]
bias: (32) gpu(0) float32 hasGradient
[0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., ... 12 more]

weight: (16, 32) gpu(0) float32 hasGradient
[ Exceed max print size ]
bias: (16) gpu(0) float32 hasGradient
[0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.]

weight: (32, 16) gpu(0) float32 hasGradient
[ Exceed max print size ]
bias: (32) gpu(0) float32 hasGradient
[0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., ... 12 more]

weight: (16, 32) gpu(0) float32 hasGradient
[ Exceed max print size ]
bias: (16) gpu(0) float32 hasGradient
[0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.]

weight: (32, 16) gpu(0) float32 hasGradient
[ Exceed max print size ]
bias: (32) gpu(0) float32 hasGradient
[0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., ... 12 more]

weight: (16, 32) gpu(0) float32 hasGradient
[ Exceed max print size ]
bias: (16) gpu(0) float32 hasGradient
[0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.]

weight: (10, 16) gpu(0) float32 hasGradient
[ Exceed max print size ]
bias: (10) gpu(0) float32 hasGradient
[0., 0., 0., 0., 0., 0., 0., 0., 0., 0.]

因为层是分层嵌套的,所以我们也可以像通过嵌套列表索引一样访问它们。例如,我们下面访问第一个主要的块,其中第二个子块的第一层的偏置项。

Block majorBlock1 = rgnet.getChildren().get(0).getValue();
Block subBlock2 = majorBlock1.getChildren().valueAt(1);
Block linearLayer1 = subBlock2.getChildren().valueAt(0);
NDArray bias = linearLayer1.getParameters().valueAt(1).getArray();
bias
bias: (32) gpu(0) float32 hasGradient
[0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., ... 12 more]

5.2.2. 参数初始化

我们知道了如何访问参数,现在让我们看看如何正确地初始化参数。我们在 Section 4.8 中讨论了良好初始化的必要性。DJL 提供多种初始化类(Initializer), 也允许创建自定义初始化方法。

对每个类型的参数,DJL 提供不同的默认初始化的方法。例如:权重参数默认使用 XavierInitializer 初始化,而将偏置参数设置为0。

5.2.2.1. 内置初始化类

让我们首先调用内置的初始化器,且将偏置参数设置为0。

我们先来看一下如何将所有参数初始化为给定的常数(比如42),我们可以使用内置的 ConstantInitializer 类。ConstantInitializer 初始化器会把所有权重参数(类型为 WEIGHT)初始值设为 42。

注意:如果参数已经被初始化,setInitializer() 函数并不会对参数有任何影响, 下面的代码不会把权重重置为 42。

net.setInitializer(new ConstantInitializer(1), Parameter.Type.WEIGHT);
net.initialize(manager, DataType.FLOAT32, x.getShape());
Block linearLayer = net.getChildren().get(0).getValue();
NDArray weight = linearLayer.getParameters().get(0).getValue().getArray();
weight
weight: (8, 4) gpu(0) float32 hasGradient
[[ 0.0014, -0.0122,  0.0031,  0.0111],
 [-0.0004, -0.0071, -0.0129, -0.0088],
 [-0.0006, -0.0082,  0.0143, -0.0013],
 [ 0.0028,  0.0083, -0.0075, -0.0138],
 [ 0.01  , -0.0114, -0.0035,  0.0054],
 [-0.015 , -0.0122,  0.0124, -0.0027],
 [-0.0147, -0.0099,  0.0028,  0.0095],
 [ 0.0079, -0.0132,  0.0047,  0.0124],
]

我们创建一个新的块,使用同样的代码,这次所有的权重都被初始化为 42:

net = getNet();
net.setInitializer(new ConstantInitializer(42), Parameter.Type.WEIGHT);
net.initialize(manager, DataType.FLOAT32, new Shape(2, 4));
Block linearLayer = net.getChildren().get(0).getValue();
NDArray weight = linearLayer.getParameters().get(0).getValue().getArray();
weight
weight: (8, 4) gpu(0) float32 hasGradient
[[42., 42., 42., 42.],
 [42., 42., 42., 42.],
 [42., 42., 42., 42.],
 [42., 42., 42., 42.],
 [42., 42., 42., 42.],
 [42., 42., 42., 42.],
 [42., 42., 42., 42.],
 [42., 42., 42., 42.],
]

下面的代码将所有权重参数初始化为标准差为0.01的高斯随机变量 (NormalInitializer):

net = getNet();
net.setInitializer(new NormalInitializer(), Parameter.Type.WEIGHT);
net.initialize(manager, DataType.FLOAT32, new Shape(2, 4));
Block linearLayer = net.getChildren().valueAt(0);
NDArray weight = linearLayer.getParameters().valueAt(0).getArray();
weight
weight: (8, 4) gpu(0) float32 hasGradient
[[-0.0177,  0.0105,  0.0094,  0.0044],
 [-0.0022, -0.0001,  0.0036, -0.004 ],
 [-0.0125, -0.0027,  0.0097,  0.0101],
 [ 0.0065, -0.002 ,  0.0073, -0.0172],
 [ 0.0097,  0.0089, -0.0052, -0.0107],
 [-0.0029,  0.0028, -0.0105, -0.0018],
 [ 0.0054,  0.003 ,  0.002 ,  0.0024],
 [ 0.015 ,  0.0065,  0.0025,  0.0031],
]

我们也可以直接访问参数,调用参数类的 Parameter.setInitializer() 函数。

下面我们使用 XavierInitializer 初始化方法初始化第一层,然后第二层初始化为常量值 1:

net = getNet();
ParameterList params = net.getParameters();

params.get("01Linear_weight").setInitializer(new NormalInitializer());
params.get("03Linear_weight").setInitializer(Initializer.ONES);

net.initialize(manager, DataType.FLOAT32, new Shape(2, 4));

System.out.println(params.valueAt(0).getArray());
System.out.println(params.valueAt(2).getArray());
weight: (8, 4) gpu(0) float32 hasGradient
[[ 1.03194425e-02, -5.16256923e-03,  1.49893127e-02, -9.96422488e-03],
 [-5.34938462e-03, -6.33946294e-03, -4.42463439e-03, -7.65433488e-03],
 [-1.48659190e-02, -1.14449351e-04,  5.29603940e-03,  1.84792597e-02],
 [-1.92370117e-05, -1.97547884e-03,  7.31727993e-03, -2.26524519e-03],
 [ 1.10019129e-02,  3.86447809e-03, -4.61107912e-03,  2.51215941e-04],
 [ 1.87088195e-02, -7.39725074e-03,  8.77287239e-03,  2.78021814e-03],
 [ 1.32617233e-02,  5.17962780e-03, -8.07685487e-04, -2.58272141e-03],
 [ 2.88970931e-03,  2.10267995e-02,  5.90961566e-03, -2.41519650e-03],
]

weight: (1, 8) gpu(0) float32 hasGradient
[[1., 1., 1., 1., 1., 1., 1., 1.],
]

5.2.2.2. 自定义初始化

有时,DJL 没有提供我们需要的初始化方法。在下面的例子中,我们使用以下的分布为任意权重参数\(w\)定义初始化方法:

(5.2.1)\[\begin{split}\begin{aligned} w \sim \begin{cases} U(5, 10) & \text{ with probability } \frac{1}{4} \\ 0 & \text{ with probability } \frac{1}{2} \\ U(-10, -5) & \text{ with probability } \frac{1}{4} \end{cases} \end{aligned}\end{split}\]

在这里,我们定义了Initializer类的子类。我们只需要实现 initialize 函数,该函数接受 NDManager, ShapeDataType 参数:

class MyInit implements Initializer {

    public MyInit() {}

    @Override
    public NDArray initialize(NDManager manager, Shape shape, DataType dataType) {
        System.out.printf("Init %s\n", shape.toString());
        // Here we generate data points
        // from a uniform distribution [-10, 10]
        NDArray data = manager.randomUniform(-10, 10, shape, dataType);
        // We keep the data points whose absolute value is >= 5
        // and set the others to 0.
        // This generates the distribution `w` shown above.
        NDArray absGte5 = data.abs().gte(5); // returns boolean NDArray where
                                             // true indicates abs >= 5 and
                                             // false otherwise
        return data.mul(absGte5); // keeps true indices and sets false indices to 0.
                                  // special operation when multiplying a numerical
                                  // NDArray with a boolean NDArray
    }

}
net = getNet();
net.setInitializer(new MyInit(), Parameter.Type.WEIGHT);
net.initialize(manager, DataType.FLOAT32, x.getShape());
Block linearLayer = net.getChildren().valueAt(0);
NDArray weight = linearLayer.getParameters().valueAt(0).getArray();
weight
Init (8, 4)
Init (1, 8)
weight: (8, 4) gpu(0) float32 hasGradient
[[ 6.6257, -8.5503,  8.3288, -8.2527],
 [-0.    , -0.    , -5.4408, -0.    ],
 [ 7.4446, -0.    ,  9.2359, -9.6584],
 [-8.7024, -7.9791, -6.4699,  0.    ],
 [ 5.192 ,  6.1485, -0.    ,  0.    ],
 [-7.9928, -0.    , -0.    , -0.    ],
 [-0.    ,  8.7888,  0.    , -0.    ],
 [-0.    ,  7.804 ,  8.9475, -7.8331],
]

注意,我们始终可以直接设置参数。

高级用户请注意:不能在GarbageCollector范围内调整参数,以避免误导自动微分机制。

NDArray weightLayer = net.getChildren().valueAt(0)
    .getParameters().valueAt(0).getArray();
weightLayer.addi(7);
weightLayer.divi(9);
weightLayer.set(new NDIndex(0, 0), 2020); // set the (0, 0) index to 2020
weightLayer;
weight: (8, 4) gpu(0) float32 hasGradient
[[ 2.02000000e+03, -1.72258914e-01,  1.70319784e+00, -1.39188871e-01],
 [ 7.77777791e-01,  7.77777791e-01,  1.73247501e-01,  7.77777791e-01],
 [ 1.60495734e+00,  7.77777791e-01,  1.80398369e+00, -2.95377105e-01],
 [-1.89152926e-01, -1.08786263e-01,  5.89005165e-02,  7.77777791e-01],
 [ 1.35466743e+00,  1.46094930e+00,  7.77777791e-01,  7.77777791e-01],
 [-1.10314421e-01,  7.77777791e-01,  7.77777791e-01,  7.77777791e-01],
 [ 7.77777791e-01,  1.75430942e+00,  7.77777791e-01,  7.77777791e-01],
 [ 7.77777791e-01,  1.64489305e+00,  1.77194095e+00, -9.25637856e-02],
]

5.2.3. 参数绑定

有时我们希望在多个层间共享参数。让我们看看如何优雅地做这件事。在下面,我们定义一个稠密层,然后使用它的参数来设置另一个层的参数。

SequentialBlock net = new SequentialBlock();

// 我们需要给共享层一个名称,以便可以引用它的参数。
Block shared = Linear.builder().setUnits(8).build();
SequentialBlock sharedRelu = new SequentialBlock();
sharedRelu.add(shared);
sharedRelu.add(Activation.reluBlock());

net.add(Linear.builder().setUnits(8).build());
net.add(Activation.reluBlock());
net.add(sharedRelu);
net.add(sharedRelu);
net.add(Linear.builder().setUnits(10).build());

NDArray x = manager.randomUniform(-10f, 10f, new Shape(2, 20), DataType.FLOAT32);

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

net.forward(ps, new NDList(x), false).singletonOrThrow();
ND: (2, 10) gpu(0) float32
[[-2.62551134e-06, -3.01498244e-06, -2.94692222e-06, -2.83837562e-06,  3.73046760e-06, -3.88857700e-07, -2.41486123e-06, -4.59238845e-06, -5.03715228e-07, -1.35955634e-06],
 [-1.61932644e-06, -1.91413915e-06, -1.66435439e-06,  4.33045898e-07,  2.45808315e-06, -1.27472930e-07,  1.34428774e-07, -2.85747376e-07,  8.07879019e-07,  2.58755534e-07],
]
// Check that the parameters are the same
NDArray shared1 = net.getChildren().valueAt(2)
    .getParameters().valueAt(0).getArray();
NDArray shared2 = net.getChildren().valueAt(3)
    .getParameters().valueAt(0).getArray();
shared1.eq(shared2);
ND: (8, 8) gpu(0) boolean
[[ true,  true,  true,  true,  true,  true,  true,  true],
 [ true,  true,  true,  true,  true,  true,  true,  true],
 [ true,  true,  true,  true,  true,  true,  true,  true],
 [ true,  true,  true,  true,  true,  true,  true,  true],
 [ true,  true,  true,  true,  true,  true,  true,  true],
 [ true,  true,  true,  true,  true,  true,  true,  true],
 [ true,  true,  true,  true,  true,  true,  true,  true],
 [ true,  true,  true,  true,  true,  true,  true,  true],
]

这个例子表明第二层和第三层的参数是绑定的。它们不仅值相等,而且由相同的张量表示。因此,如果我们改变其中一个参数,另一个参数也会改变。你可能会想,当参数绑定时,梯度会发生什么情况?答案是由于模型参数包含梯度,因此在反向传播期间第二个隐藏层和第三个隐藏层的梯度会加在一起。

5.2.4. 小结

  • 我们有几种方法可以访问、初始化和绑定模型参数。

  • 我们可以使用自定义初始化方法。

5.2.5. 练习

  1. 使用 Section 5.1 中定义的FancyMLP模型,访问各个层的参数。

  2. 查看初始化模块文档以了解不同的初始化方法。

  3. 构建包含共享参数层的多层感知机并对其进行训练。在训练过程中,观察模型各层的参数和梯度。

  4. 为什么共享参数是个好主意?