7.3. 填充和步幅
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 Colab 中打开 Notebook
在 SageMaker Studio Lab 中打开 Notebook

回想一下 图 7.2.1 中卷积的例子。输入的高度和宽度都是3,卷积核的高度和宽度都是2,得到的输出表示的维度是 \(2\times2\)。假设输入形状是 \(n_\textrm{h}\times n_\textrm{w}\),卷积核形状是 \(k_\textrm{h}\times k_\textrm{w}\),那么输出形状将是 \((n_\textrm{h}-k_\textrm{h}+1) \times (n_\textrm{w}-k_\textrm{w}+1)\):我们只能将卷积核移动到像素用完为止。

在下文中,我们将探讨几种技术,包括填充和步幅卷积,它们可以更精确地控制输出的大小。作为动机,请注意,由于卷积核的宽度和高度通常大于 \(1\),在应用了许多连续的卷积之后,我们最终得到的输出往往比输入小得多。如果我们从一个 \(240 \times 240\) 像素的图像开始,经过10层 \(5 \times 5\) 的卷积后,图像会缩小到 \(200 \times 200\) 像素,切掉了图像的 \(30 \%\),并随之抹去了原始图像边界上的任何有趣信息。填充(Padding)是处理此问题的最流行工具。在其他情况下,我们可能希望大幅减小维度,例如,如果我们发现原始输入分辨率过高。步幅卷积(Strided convolutions)是一种在这些情况下可以提供帮助的流行技术。

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

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

7.3.1. 填充

如上所述,应用卷积层时一个棘手的问题是,我们往往会丢失图像边缘的像素。考虑 图 7.3.1,它描绘了像素利用率作为卷积核大小和图像内位置的函数。角落里的像素几乎完全没有被使用。

../_images/conv-reuse.svg

图 7.3.1 大小分别为 \(1 \times 1\)\(2 \times 2\)\(3 \times 3\) 的卷积的像素利用率。

由于我们通常使用小的卷积核,对于任何给定的卷积,我们可能只会丢失几个像素,但随着我们应用许多连续的卷积层,这可能会累积起来。解决这个问题的一个直接方法是在输入图像的边界周围添加额外的填充像素,从而增加图像的有效大小。通常,我们将额外像素的值设置为零。在 图 7.3.2 中,我们对一个 \(3 \times 3\) 的输入进行填充,将其大小增加到 \(5 \times 5\)。相应的输出则增加到一个 \(4 \times 4\) 的矩阵。阴影部分是第一个输出元素以及用于输出计算的输入和卷积核张量元素:\(0\times0+0\times1+0\times2+0\times3=0\)

../_images/conv-pad.svg

图 7.3.2 带填充的二维互相关。

通常,如果我们总共添加 \(p_\textrm{h}\) 行填充(大约一半在顶部,一半在底部)和总共 \(p_\textrm{w}\) 列填充(大约一半在左侧,一半在右侧),输出形状将是

(7.3.1)\[(n_\textrm{h}-k_\textrm{h}+p_\textrm{h}+1)\times(n_\textrm{w}-k_\textrm{w}+p_\textrm{w}+1).\]

这意味着输出的高度和宽度将分别增加 \(p_\textrm{h}\)\(p_\textrm{w}\)

在许多情况下,我们会希望设置 \(p_\textrm{h}=k_\textrm{h}-1\)\(p_\textrm{w}=k_\textrm{w}-1\),以使输入和输出具有相同的高度和宽度。这将使得在构建网络时更容易预测每一层的输出形状。假设这里的 \(k_\textrm{h}\) 是奇数,我们将在高度的两侧各填充 \(p_\textrm{h}/2\) 行。如果 \(k_\textrm{h}\) 是偶数,一种可能是输入顶部填充 \(\lceil p_\textrm{h}/2\rceil\) 行,底部填充 \(\lfloor p_\textrm{h}/2\rfloor\) 行。我们也会以同样的方式填充宽度的两侧。

卷积神经网络通常使用具有奇数高度和宽度值的卷积核,例如1、3、5或7。选择奇数大小的卷积核的好处是,我们可以在顶部和底部填充相同数量的行,在左侧和右侧填充相同数量的列,同时保持维度不变。

此外,这种使用奇数卷积核和填充来精确保持维度的做法提供了一个文书上的便利。对于任何二维张量 X,当卷积核大小为奇数,并且所有侧面的填充行数和列数相同时,从而产生与输入具有相同高度和宽度的输出时,我们知道输出 Y[i, j] 是通过输入和卷积核以 X[i, j] 为中心的窗口进行互相关计算得到的。

在下面的例子中,我们创建一个高度和宽度为3的二维卷积层,并在所有侧面应用1个像素的填充。给定一个高度和宽度为8的输入,我们发现输出的高度和宽度也是8。

# We define a helper function to calculate convolutions. It initializes the
# convolutional layer weights and performs corresponding dimensionality
# elevations and reductions on the input and output
def comp_conv2d(conv2d, X):
    # (1, 1) indicates that batch size and the number of channels are both 1
    X = X.reshape((1, 1) + X.shape)
    Y = conv2d(X)
    # Strip the first two dimensions: examples and channels
    return Y.reshape(Y.shape[2:])

# 1 row and column is padded on either side, so a total of 2 rows or columns
# are added
conv2d = nn.LazyConv2d(1, kernel_size=3, padding=1)
X = torch.rand(size=(8, 8))
comp_conv2d(conv2d, X).shape
torch.Size([8, 8])
# We define a helper function to calculate convolutions. It initializes
# the convolutional layer weights and performs corresponding dimensionality
# elevations and reductions on the input and output
def comp_conv2d(conv2d, X):
    conv2d.initialize()
    # (1, 1) indicates that batch size and the number of channels are both 1
    X = X.reshape((1, 1) + X.shape)
    Y = conv2d(X)
    # Strip the first two dimensions: examples and channels
    return Y.reshape(Y.shape[2:])

# 1 row and column is padded on either side, so a total of 2 rows or columns are added
conv2d = nn.Conv2D(1, kernel_size=3, padding=1)
X = np.random.uniform(size=(8, 8))
comp_conv2d(conv2d, X).shape
[22:06:32] ../src/storage/storage.cc:196: Using Pooled (Naive) StorageManager for CPU
(8, 8)
# We define a helper function to calculate convolutions. It initializes
# the convolutional layer weights and performs corresponding dimensionality
# elevations and reductions on the input and output
def comp_conv2d(conv2d, X):
    # (1, X.shape, 1) indicates that batch size and the number of channels are both 1
    key = jax.random.PRNGKey(d2l.get_seed())
    X = X.reshape((1,) + X.shape + (1,))
    Y, _ = conv2d.init_with_output(key, X)
    # Strip the dimensions: examples and channels
    return Y.reshape(Y.shape[1:3])
# 1 row and column is padded on either side, so a total of 2 rows or columns are added
conv2d = nn.Conv(1, kernel_size=(3, 3), padding='SAME')
X = jax.random.uniform(jax.random.PRNGKey(d2l.get_seed()), shape=(8, 8))
comp_conv2d(conv2d, X).shape
(8, 8)
# We define a helper function to calculate convolutions. It initializes
# the convolutional layer weights and performs corresponding dimensionality
# elevations and reductions on the input and output
def comp_conv2d(conv2d, X):
    # (1, 1) indicates that batch size and the number of channels are both 1
    X = tf.reshape(X, (1, ) + X.shape + (1, ))
    Y = conv2d(X)
    # Strip the first two dimensions: examples and channels
    return tf.reshape(Y, Y.shape[1:3])
# 1 row and column is padded on either side, so a total of 2 rows or columns
# are added
conv2d = tf.keras.layers.Conv2D(1, kernel_size=3, padding='same')
X = tf.random.uniform(shape=(8, 8))
comp_conv2d(conv2d, X).shape
TensorShape([8, 8])

当卷积核的高度和宽度不同时,我们可以通过为高度和宽度设置不同的填充数,使输出和输入具有相同的高度和宽度。

# We use a convolution kernel with height 5 and width 3. The padding on either
# side of the height and width are 2 and 1, respectively
conv2d = nn.LazyConv2d(1, kernel_size=(5, 3), padding=(2, 1))
comp_conv2d(conv2d, X).shape
torch.Size([8, 8])
# We use a convolution kernel with height 5 and width 3. The padding on
# either side of the height and width are 2 and 1, respectively
conv2d = nn.Conv2D(1, kernel_size=(5, 3), padding=(2, 1))
comp_conv2d(conv2d, X).shape
(8, 8)
# We use a convolution kernel with height 5 and width 3. The padding on
# either side of the height and width are 2 and 1, respectively
conv2d = nn.Conv(1, kernel_size=(5, 3), padding=(2, 1))
comp_conv2d(conv2d, X).shape
(8, 8)
# We use a convolution kernel with height 5 and width 3. The padding on
# either side of the height and width are 2 and 1, respectively
conv2d = tf.keras.layers.Conv2D(1, kernel_size=(5, 3), padding='same')
comp_conv2d(conv2d, X).shape
TensorShape([8, 8])

7.3.2. 步幅

在计算互相关时,我们从输入张量的左上角开始,然后向右和向下滑动卷积窗口。在前面的例子中,我们默认每次滑动一个元素。然而,有时,无论是为了计算效率还是因为我们希望降采样,我们会一次移动我们的窗口超过一个元素,跳过中间的位置。如果卷积核很大,这尤其有用,因为它捕获了底层图像的大面积区域。

我们将每次滑动所遍历的行数和列数称为步幅。到目前为止,我们使用的步幅都是1,无论是高度还是宽度。有时,我们可能希望使用更大的步幅。图 7.3.3 显示了一个二维互相关操作,垂直步幅为3,水平步幅为2。阴影部分是输出元素以及用于输出计算的输入和卷积核张量元素:\(0\times0+0\times1+1\times2+2\times3=8\)\(0\times0+6\times1+0\times2+0\times3=6\)。我们可以看到,当生成第一列的第二个元素时,卷积窗口向下滑动了三行。当生成第一行的第二个元素时,卷积窗口向右滑动了两列。当卷积窗口在输入上继续向右滑动两列时,没有输出,因为输入元素无法填满窗口(除非我们再添加一列填充)。

../_images/conv-stride.svg

图 7.3.3 高度和宽度步幅分别为3和2的互相关。

通常,当高度的步幅为 \(s_\textrm{h}\),宽度的步幅为 \(s_\textrm{w}\) 时,输出形状为

(7.3.2)\[\lfloor(n_\textrm{h}-k_\textrm{h}+p_\textrm{h}+s_\textrm{h})/s_\textrm{h}\rfloor \times \lfloor(n_\textrm{w}-k_\textrm{w}+p_\textrm{w}+s_\textrm{w})/s_\textrm{w}\rfloor.\]

如果我们设置 \(p_\textrm{h}=k_\textrm{h}-1\)\(p_\textrm{w}=k_\textrm{w}-1\),那么输出形状可以简化为 \(\lfloor(n_\textrm{h}+s_\textrm{h}-1)/s_\textrm{h}\rfloor \times \lfloor(n_\textrm{w}+s_\textrm{w}-1)/s_\textrm{w}\rfloor\)。更进一步,如果输入的高度和宽度可以被高度和宽度的步幅整除,那么输出形状将是 \((n_\textrm{h}/s_\textrm{h}) \times (n_\textrm{w}/s_\textrm{w})\)

下面,我们将高度和宽度的步幅都设置为2,从而将输入的高度和宽度减半。

conv2d = nn.LazyConv2d(1, kernel_size=3, padding=1, stride=2)
comp_conv2d(conv2d, X).shape
torch.Size([4, 4])
conv2d = nn.Conv2D(1, kernel_size=3, padding=1, strides=2)
comp_conv2d(conv2d, X).shape
(4, 4)
conv2d = nn.Conv(1, kernel_size=(3, 3), padding=1, strides=2)
comp_conv2d(conv2d, X).shape
(4, 4)
conv2d = tf.keras.layers.Conv2D(1, kernel_size=3, padding='same', strides=2)
comp_conv2d(conv2d, X).shape
TensorShape([4, 4])

让我们看一个稍微复杂一点的例子。

conv2d = nn.LazyConv2d(1, kernel_size=(3, 5), padding=(0, 1), stride=(3, 4))
comp_conv2d(conv2d, X).shape
torch.Size([2, 2])
conv2d = nn.Conv2D(1, kernel_size=(3, 5), padding=(0, 1), strides=(3, 4))
comp_conv2d(conv2d, X).shape
(2, 2)
conv2d = nn.Conv(1, kernel_size=(3, 5), padding=(0, 1), strides=(3, 4))
comp_conv2d(conv2d, X).shape
(2, 2)
conv2d = tf.keras.layers.Conv2D(1, kernel_size=(3,5), padding='valid',
                                strides=(3, 4))
comp_conv2d(conv2d, X).shape
TensorShape([2, 1])

7.3.3. 总结与讨论

填充可以增加输出的高度和宽度。这通常用于使输出与输入具有相同的高度和宽度,以避免不希望的输出缩小。此外,它确保所有像素被同等频繁地使用。通常我们选择在输入高度和宽度的两侧进行对称填充。在这种情况下,我们称之为 \((p_\textrm{h}, p_\textrm{w})\) 填充。最常见的是我们设置 \(p_\textrm{h} = p_\textrm{w}\),这种情况下我们简单地称我们选择填充 \(p\)

类似的惯例也适用于步幅。当水平步幅 \(s_\textrm{h}\) 和垂直步幅 \(s_\textrm{w}\) 相同时,我们简单地称之为步幅 \(s\)。步幅可以降低输出的分辨率,例如,对于 \(n > 1\),将输出的高度和宽度减小到输入高度和宽度的 \(1/n\)。默认情况下,填充为0,步幅为1。

到目前为止,我们讨论的所有填充都只是用零来扩展图像。这有显著的计算优势,因为它实现起来非常简单。此外,可以设计算子来隐式利用这种填充,而无需分配额外的内存。同时,它允许卷积神经网络通过学习“空白”的位置来编码图像内的隐式位置信息。除了零填充外,还有许多替代方案。Alsallakh 等人 (2020) 对这些方案进行了广泛的概述(尽管没有明确指出除非出现伪影,否则何时使用非零填充)。

7.3.4. 练习

  1. 对于本节中最后的代码示例,其卷积核大小为 \((3, 5)\),填充为 \((0, 1)\),步幅为 \((3, 4)\),计算输出形状,检查其是否与实验结果一致。

  2. 对于音频信号,步幅为2对应什么?

  3. 实现镜像填充,即边界值被简单地镜像以扩展张量的填充方式。

  4. 大于1的步幅在计算上有什么好处?

  5. 大于1的步幅可能有什么统计上的好处?

  6. 如何实现步幅为 \(\frac{1}{2}\)?它对应什么?什么时候它会有用?