Bootstrap

神经网络基础 | 给定条件下推导对应的卷积层参数

神经网络基础 | 给定条件下推导对应的卷积层参数

在这里插入图片描述

按照 PyTorch 文档中 给定的设置:

H o u t = ⌊ H i n + 2 × padding [ 0 ] − dilation [ 0 ] × ( kernel_size [ 0 ] − 1 ) − 1 stride [ 0 ] + 1 ⌋ H_{out} = \left\lfloor\frac{H_{in} + 2 \times \text{padding}[0] - \text{dilation}[0] \times (\text{kernel\_size}[0] - 1) - 1}{\text{stride}[0]} + 1\right\rfloor Hout=stride[0]Hin+2×padding[0]dilation[0]×(kernel_size[0]1)1+1

W o u t = ⌊ W i n + 2 × padding [ 1 ] − dilation [ 1 ] × ( kernel_size [ 1 ] − 1 ) − 1 stride [ 1 ] + 1 ⌋ W_{out} = \left\lfloor\frac{W_{in} + 2 \times \text{padding}[1] - \text{dilation}[1] \times (\text{kernel\_size}[1] - 1) - 1}{\text{stride}[1]} + 1\right\rfloor Wout=stride[1]Win+2×padding[1]dilation[1]×(kernel_size[1]1)1+1

其中涉及到了几个卷积的参数:

  • 输入的尺寸, i i i H i n H_{in} Hin W i n W_{in} Win
  • 输出的尺寸, o o o H o u t H_{out} Hout W i n W_{in} Win
  • 对应维度上的单侧 padding, p p p padding \text{padding} padding
  • 对应维度上的扩张率, d d d dilation \text{dilation} dilation
  • 卷积核尺寸, k k k kernel_size \text{kernel\_size} kernel_size
  • 卷积滑动步长, s s s stride \text{stride} stride

一般来说,H 方向和 W 方向的参数是一样的,所以后续的介绍中仅考虑单一 H 方向。

另外,该运算规则对于 nn.Unfold 这类操作同样是满足的。

已知: i , o , k , d i, o, k, d i,o,k,d

这里的 o o o 其实也可以理解为沿着指定方向,在特定参数约束下的实际执行计算的窗口数量。

由于涉及到扩张率 d d d,所以我们应该直接考虑等效的卷积核 k ′ = ( k − 1 ) × d + 1 k' = (k-1) \times d + 1 k=(k1)×d+1。注意,这里的 k ′ k' k 仅用来表示滑窗大小,而并非表示实际的参与计算的元素数量。实际参与计算的依然只有 k k k 个元素。前者可以用于计算实际的滑窗次数,而后者在这里的推导中并不需要考虑。

已知实际窗口 k ′ k' k,我们可以获得滑窗步长 s = ⌈ i − k ′ o − 1 ⌉ s = \left \lceil \frac{i-k'}{o-1} \right \rceil s=o1ik。这里要注意向上取整的操作,由于实际步长需要为整数,所以这里如果除不尽的话需要凑到整数,但是又不能向下取整,因为向下取整会导致滑窗无法完全覆盖所有输入数据,而向上取整,则可以尽可能充分的覆盖整个轴向的数据,而多出来的部分,则可以通过 padding 策略来进行补齐。于是我们也由此可以获得整体的 padding 数,即 ⌈ s × ( o − 1 ) + k ′ − i ⌉ \left \lceil s \times (o-1) + k' - i \right \rceil s×(o1)+ki。也就是通过新的 stride 和等效的 kernel_size,重新计算一次输入尺寸,多出来的部分就是 padding 的数量。

关于 padding 的计算实际上需要考虑框架实际的需求,对于 PyTorch 而言,Conv2d 和 Unfold 都是针对 H 和 W 两个方向的两侧同时进行相同的 padding 操作,也就是说,左右各自对应的 p p p 是一样的。上下也是类似。所以我们这里提供的 p p p 应该是单侧的 padding 值,而通过 stride 直接作差获得的是单轴上的总 padding 数 p t o t a l p_{total} ptotal。所以需要取一半。此时又面临了向上取整还是向下取整的问题。考虑这个问题我们就得了解卷积操作究竟是如何对待 padding 的。实际上,padding 后的输入,在卷积时,如果最后一个窗口内的元素数量不够,那么这个窗口就会被舍弃,也就不会赋到输出变量里。所以只要输入 padding 后在最后一个滑窗位置之后的位置上凑不够一个新的滑窗,那么其就是等价的。所以我们对获得的 padding 直接除以 2 并向上取整即可: p ′ = ⌈ p t o t a l 2 ⌉ p' = \left \lceil \frac{p_{total}}{2} \right \rceil p=2ptotal

我们将代码整理下,可以得到用于计算这些量的函数:

@lru_cache()
def get_unfold_params_v0(width, num_kernels, kernel_size=8, dilation=1):
    real_kernel_size = (kernel_size - 1) * dilation + 1
    if width <= real_kernel_size:
        padding = math.ceil((real_kernel_size - width) / 2)
        assert width + padding <= real_kernel_size <= width + 2 * padding
        stride = padding + 1
        num_kernels = 1
    else:
        stride = math.ceil((width - real_kernel_size) / (num_kernels - 1))
        if stride == 1:
            num_kernels = width - real_kernel_size + 1
        padding = math.ceil((stride * (num_kernels - 1) + real_kernel_size - width) / 2)
    params = dict(kernel_size=kernel_size, stride=stride, padding=padding, dilation=dilation)

    if _DEBUG:
        print(dict(width=width, num_kernels=num_kernels, **params))
    if not (
        stride >= 1
        or padding >= 0
        or width <= stride * (num_kernels - 1) + real_kernel_size <= width + padding * 2
    ):
        raise ValueError(f"valid params does not exist for {dict(width=width, num_kernels=num_kernels, **params)}")
    return params, width, num_kernels

这里额外考虑了:

  • 当输入宽度小于指定核大小时,这个时候直接 padding 就行,但是同时需要修改送入的 num_kernels 参数。当然,如果要严格限定,那这里可以改为报错即可。
  • 当输入宽度大于指定核大小时,此时需要考虑超出的量
    • 如果超出的量并不能大于 num_kernels - 1,此时虽然向上取整后结果为 1,但是每个未取整的 stride 的真实值是小于 1 的。这样使用取整更新过后 stride 计算时,会造成不必要的 padding。所以此时我们更新下 o o o,也就是将其直接设为 s = 1 s=1 s=1 的情况下对应的结果,此时的 padding 数量为 0。当然,如果这里并不想更改 o o o,那么直接计算 padding 即可。
  • 对输出的参数进行一个简单的约束:
    • s s s 要大于等于 1;
    • p p p 要大于等于 0;
    • i ≤ s × ( o − 1 ) + k ′ ≤ i + 2 p i \le s \times (o - 1) + k' \le i +2p is×(o1)+ki+2p。最大为 padding 后的尺寸,最小则为原始尺寸。

已知: i , k , s , d i, k, s, d i,k,s,d

这里的输入不再限定输出的尺寸,而是提供了初始的步长约束 s s s

同样的,先计算真实的卷积核大小 k ′ = ( k − 1 ) × d + 1 k' = (k-1) \times d + 1 k=(k1)×d+1

按照一般情况,输入核小于输入尺寸,此时我们可以计算得到的对应的输出尺寸: o = ⌈ i − k ′ s + 1 ⌉ o = \left \lceil \frac{i-k'}{s} + 1 \right \rceil o=sik+1

由于输出尺寸并不是严格使用输入的 s s s 计算获得的,这里涉及到了一个取整的过程,所以实际上对应的 stride 也发生了改变,我们有必要依此对 stride 进行一下更新: s ′ = ⌈ i − k ′ o − 1 ⌉ s'= \left \lceil \frac{i-k'}{o-1} \right \rceil s=o1ik

输出尺寸得到后就该计算单侧 padding 的大小了,这里同样使用向上取整: ⌈ k ′ + s ′ × ( o − 1 ) − i 2 ⌉ \left \lceil \frac{k'+s' \times (o-1) - i}{2} \right \rceil 2k+s×(o1)i

对应的代码为:

@lru_cache()
def get_unfold_params_v1(width, kernel_size=8, stride=8, dilation=1):
    real_kernel_size = (kernel_size - 1) * dilation + 1
    if width <= real_kernel_size:
        padding = math.ceil((real_kernel_size - width) / 2)
        assert width + padding <= real_kernel_size <= width + 2 * padding
        stride = padding + 1
        num_kernels = 1
    else:
        num_kernels = math.ceil((width - real_kernel_size) / stride) + 1
        if num_kernels == 1:
            stride = width - real_kernel_size
            padding = 0
        else:
            stride = math.ceil((width - real_kernel_size) / (num_kernels - 1))
            padding = math.ceil((real_kernel_size + stride * (num_kernels - 1) - width) / 2)
    params = dict(kernel_size=kernel_size, stride=stride, padding=padding, dilation=dilation)

    if _DEBUG:
        print(dict(width=width, num_kernels=num_kernels, **params))
    if not (
        stride >= 1
        or padding >= 0
        or width <= stride * (num_kernels - 1) + real_kernel_size <= width + padding * 2
    ):
        raise ValueError(f"valid params does not exist for {dict(width=width, num_kernels=num_kernels, **params)}")
    return params, width, num_kernels
;