Run this notebook online:Binder or Colab: Colab

6.2. 图像的卷积

现在我们了解了卷积层在理论上的工作原理, 我们准备看看它们在实践中是如何工作的。 基于我们对卷积神经网络的研究 作为探索图像数据结构的有效架构, 我们坚持以图像为榜样。

6.2.1. 互关联算子

回想一下,严格来说,卷积层 是一个(轻微的)用词不当,因为它们表达的操作 更准确地说是互相关。 在卷积层中,输入数组 和一个相关内核数组相结合 通过互相关操作生成输出阵列。 让我们暂时忽略频道,看看它是如何工作的 具有二维数据和隐藏表示。 In Fig. 6.2.1, 输入是一个二维数组 高度为3,宽度为3。 我们将数组的形状标记为\(3 \times 3\) 或 (\(3\), \(3\)). 内核的高度和宽度都是\(2\). 请注意,在深度学习研究社区, 一个过滤器,或者只是图层的权重。 内核窗口的形状 由内核的高度和宽度给出 (这里是:math:2 times 2)。

https://raw.githubusercontent.com/d2l-ai/d2l-en/master/img/correlation.svg

Fig. 6.2.1 Two-dimensional cross-correlation operation. The shaded portions are the first output element and the input and kernel array elements used in its computation: \(0\times0+1\times1+3\times2+4\times3=19\).

在二维互相关运算中, 我们从卷积窗口开始 在输入数组的左上角 然后将其滑动到输入阵列上, 从左到右,从上到下。 当卷积窗口滑动到某个位置时, 该窗口中包含的输入子数组 内核数组相乘(elementwise) 然后将得到的数组求和 产生单个标量值。 这个结果给出了输出数组的值 在相应的位置。 这里,输出数组的高度为2,宽度为2 这四个元素是从 二维互相关运算:

(6.2.1)\[\begin{split}0\times0+1\times1+3\times2+4\times3=19,\\ 1\times0+2\times1+4\times2+5\times3=25,\\ 3\times0+4\times1+6\times2+7\times3=37,\\ 4\times0+5\times1+7\times2+8\times3=43.\end{split}\]

请注意,沿每个轴,输出 略小于输入。 因为内核的宽度和高度都大于1, 我们只能正确地计算互相关 对于内核完全位于图像中的位置, 输出大小由输入大小 \(H \times W\)给出 减去卷积内核的大小 \(h \times w\) 通过 \((H-h+1) \times (W-w+1)\)。 这是因为我们需要足够的空间 在图像上’移动’卷积核 (稍后我们将看到如何保持大小不变 通过在图像边界周围填充零 这样就有足够的空间来移动内核)。 接下来,我们在 corr2d 函数中实现这个过程, 它接受输入数组 X 和内核数组 K 并返回输出数组 Y.

但首先我们将导入相关的库。

%load ../utils/djl-imports
public NDArray corr2d(NDArray X, NDArray K){
    // 计算二维互关联。
    int h = (int) K.getShape().get(0);
    int w = (int) K.getShape().get(1);

    NDArray Y = manager.zeros(new Shape(X.getShape().get(0) - h + 1, X.getShape().get(1) - w + 1));

    for(int i=0; i < Y.getShape().get(0); i++){
        for(int j=0; j < Y.getShape().get(1); j++){
            Y.set(new NDIndex(i + "," + j), X.get(i + ":" + (i+h) + "," + j + ":" + (j+w)).mul(K).sum());
        }
    }

    return Y;
}

我们可以构造输入数组X和内核数组K 从上图来看 验证上述实现的输出 二维互相关运算。

NDManager manager = NDManager.newBaseManager();
NDArray X = manager.create(new float[]{0,1,2,3,4,5,6,7,8}, new Shape(3,3));
NDArray K = manager.create(new float[]{0,1,2,3}, new Shape(2,2));
System.out.println(corr2d(X, K));
ND: (2, 2) gpu(0) float32
[[19., 25.],
 [37., 43.],
]

6.2.2. 卷积层

卷积层将输入和内核相互关联 并添加标量偏差以产生输出。 卷积层的两个参数 是内核和标量偏差。 当训练基于卷积层的模型时, 我们通常会随机初始化内核, 就像我们使用完全连接的层一样。

我们现在准备实现一个二维卷积层 基于上面定义的 corr2d 函数。 在 ConvolutionalLayer 构造函数中, 我们声明 weightbias 为两个类参数。 正向计算函数 forward 调用corr2d 函数并添加偏差。 与 \(h \times w\) 互相关一样 我们也提到卷积层 作为 \(h \times w\) 卷积。

public class ConvolutionalLayer{

    private NDArray w;
    private NDArray b;

    public NDArray getW(){
        return w;
    }

    public NDArray getB(){
        return b;
    }

    public ConvolutionalLayer(Shape shape){
        NDManager manager = NDManager.newBaseManager();
        w = manager.create(shape);
        b = manager.randomNormal(new Shape(1));
        w.setRequiresGradient(true);
    }

    public NDArray forward(NDArray X){
        return corr2d(X, w).add(b);
    }

}

6.2.3. 图像中的目标边缘检测

让我们花一点时间来分析卷积层的简单应用: 检测图像中物体的边缘 通过找到像素变化的位置。 首先,我们构造一个 \(6\times 8\) 像素的’图像’。 中间四列为黑色(0),其余为白色(1)。

X = manager.ones(new Shape(6,8));
X.set(new NDIndex(":" + "," + 2 + ":" + 6), 0f);
System.out.println(X);
ND: (6, 8) gpu(0) float32
[[1., 1., 0., 0., 0., 0., 1., 1.],
 [1., 1., 0., 0., 0., 0., 1., 1.],
 [1., 1., 0., 0., 0., 0., 1., 1.],
 [1., 1., 0., 0., 0., 0., 1., 1.],
 [1., 1., 0., 0., 0., 0., 1., 1.],
 [1., 1., 0., 0., 0., 0., 1., 1.],
]

接下来,我们构造一个内核K ,高度为\(1\),宽度为\(2\)。 当我们对输入进行互相关运算时, 如果水平相邻的元素相同, 输出为0。否则,输出为非零。

K = manager.create(new float[]{1, -1}, new Shape(1,2));

我们已经准备好执行互相关操作 参数为X(我们的输入)和K(我们的内核)。 正如你所看到的,我们从白色到黑色的边缘检测到1 和-1表示从黑色到白色的边缘。 所有其他输出值为\(0\)

NDArray Y = corr2d(X, K);
Y
ND: (6, 7) gpu(0) float32
[[ 0.,  1.,  0.,  0.,  0., -1.,  0.],
 [ 0.,  1.,  0.,  0.,  0., -1.,  0.],
 [ 0.,  1.,  0.,  0.,  0., -1.,  0.],
 [ 0.,  1.,  0.,  0.,  0., -1.,  0.],
 [ 0.,  1.,  0.,  0.,  0., -1.,  0.],
 [ 0.,  1.,  0.,  0.,  0., -1.,  0.],
]

我们现在可以将内核应用于转置图像。 正如所料,它消失了。内核K只检测垂直边缘。

corr2d(X.transpose(), K);
ND: (8, 5) 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., 0., 0., 0.],
 [0., 0., 0., 0., 0.],
]

6.2.4. 学习内核

用有限差分[1, -1]设计一个边缘检测器非常简洁 如果我们知道这正是我们想要的。 然而,当我们看到更大的内核时, 并考虑连续的卷积层, 可能无法具体说明 每个过滤器应该手动执行的操作。

现在让我们看看是否可以学习从X生成Y的内核 只查看(输入、输出)对。 我们首先构造一个卷积层 并将其内核初始化为随机数组。 接下来,在每次迭代中,我们将使用平方误差 将Y 与卷积层的输出进行比较。 然后我们可以计算梯度来更新权重。 为了简单起见,在这个卷积层中, 我们将忽略这种偏见。

这一次,我们将使用DJL内置的BlockConv2d类。

X = X.reshape(1,1,6,8);
Y = Y.reshape(1,1,6,7);

Loss l2Loss = Loss.l2Loss();
// 构造一个具有1个输出通道和一个
// 形核(1,2)。为了简单起见,我们忽略了这里的偏见
Block block = Conv2d.builder()
                .setKernelShape(new Shape(1, 2))
                .optBias(false)
                .setFilters(1)
                .build();

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

// 二维卷积层使用四维输入和输出
// 输出格式为(例如,通道、高度、宽度),其中批次
// 大小(批次中的示例数)和通道数均为1

ParameterList params = block.getParameters();
NDArray wParam = params.get(0).getValue().getArray();
wParam.setRequiresGradient(true);

NDArray lossVal = null;
ParameterStore parameterStore = new ParameterStore(manager, false);

NDArray lossVal = null;

for (int i = 0; i < 10; i++) {

    wParam.setRequiresGradient(true);

    try (GradientCollector gc = Engine.getInstance().newGradientCollector()) {
        NDArray yHat = block.forward(parameterStore, new NDList(X), true).singletonOrThrow();
        NDArray l = l2Loss.evaluate(new NDList(Y), new NDList(yHat));
        lossVal = l;
        gc.backward(l);
    }
    // 更新内核
    wParam.subi(wParam.getGradient().mul(0.40f));

    if((i+1)%2 == 0){
        System.out.println("batch " + (i+1) + " loss: " + lossVal.sum().getFloat());
    }
}
batch 2 loss: 0.12571818
batch 4 loss: 0.09935227
batch 6 loss: 0.07851635
batch 8 loss: 0.062050212
batch 10 loss: 0.049037326

请注意,经过10次迭代后,错误已降至一个较小的值。现在我们来看看我们学习的内核数组。

ParameterList params = block.getParameters();
NDArray wParam = params.get(0).getValue().getArray();
wParam
weight: (1, 1, 1, 2) gpu(0) float32 hasGradient
[[[[ 0.4475, -0.4477],
  ],
 ],
]

事实上,学习到的内核阵列正在接近 到我们之前定义的内核数组K

6.2.5. 互关联和卷积

回想一下我们在信件前一节中的观察 在互相关算子和卷积算子之间。 上图显示了这种对应关系。 只需将内核从左下角翻转到右上角。 在这种情况下,总和中的索引被恢复, 然而,同样的结果也可以得到。 与深度学习文献中的标准术语保持一致, 我们将继续提到互相关运算 作为一种卷积,严格地说,它略有不同。

6.2.6. 总结

  • 二维卷积层的核心计算是二维互相关运算。在最简单的形式中,它对二维输入数据和内核执行互相关操作,然后添加偏差。

  • 我们可以设计一个内核来检测图像中的边缘。

  • 我们可以从数据中学习内核的参数。

6.2.7. 练习

  1. 构造一个带有对角边的图像X

    • 如果对其应用内核K,会发生什么?

    • 如果变换X顺序,会发生什么?

    • 如果变换K顺序,会发生什么?

  2. 当您尝试自动查找我们创建的 Conv2d 类的渐变时,您会看到什么样的错误消息?

  3. 如何通过更改输入和内核数组将互相关运算表示为矩阵乘法?

  4. 手动设计一些内核。

    • 二阶导数的核的形式是什么?

    • 拉普拉斯算子的核心是什么?

    • 积分的核心是什么?

    • 获得\(d\)导数的内核最小大小是多少?