神经网络基础 | 给定条件下推导对应的卷积层参数
按照 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′=(k−1)×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=⌈o−1i−k′⌉。这里要注意向上取整的操作,由于实际步长需要为整数,所以这里如果除不尽的话需要凑到整数,但是又不能向下取整,因为向下取整会导致滑窗无法完全覆盖所有输入数据,而向上取整,则可以尽可能充分的覆盖整个轴向的数据,而多出来的部分,则可以通过 padding 策略来进行补齐。于是我们也由此可以获得整体的 padding 数,即 ⌈ s × ( o − 1 ) + k ′ − i ⌉ \left \lceil s \times (o-1) + k' - i \right \rceil ⌈s×(o−1)+k′−i⌉。也就是通过新的 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 + 1if width <= real_kernel_size:padding = math.ceil((real_kernel_size - width) / 2)assert width + padding <= real_kernel_size <= width + 2 * paddingstride = padding + 1num_kernels = 1else:stride = math.ceil((width - real_kernel_size) / (num_kernels - 1))if stride == 1:num_kernels = width - real_kernel_size + 1padding = 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 >= 1or padding >= 0or 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 i≤s×(o−1)+k′≤i+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′=(k−1)×d+1。
按照一般情况,输入核小于输入尺寸,此时我们可以计算得到的对应的输出尺寸: o = ⌈ i − k ′ s + 1 ⌉ o = \left \lceil \frac{i-k'}{s} + 1 \right \rceil o=⌈si−k′+1⌉。
由于输出尺寸并不是严格使用输入的 s s s 计算获得的,这里涉及到了一个取整的过程,所以实际上对应的 stride 也发生了改变,我们有必要依此对 stride 进行一下更新: s ′ = ⌈ i − k ′ o − 1 ⌉ s'= \left \lceil \frac{i-k'}{o-1} \right \rceil s′=⌈o−1i−k′⌉
输出尺寸得到后就该计算单侧 padding 的大小了,这里同样使用向上取整: ⌈ k ′ + s ′ × ( o − 1 ) − i 2 ⌉ \left \lceil \frac{k'+s' \times (o-1) - i}{2} \right \rceil ⌈2k′+s′×(o−1)−i⌉。
对应的代码为:
@lru_cache()
def get_unfold_params_v1(width, kernel_size=8, stride=8, dilation=1):real_kernel_size = (kernel_size - 1) * dilation + 1if width <= real_kernel_size:padding = math.ceil((real_kernel_size - width) / 2)assert width + padding <= real_kernel_size <= width + 2 * paddingstride = padding + 1num_kernels = 1else:num_kernels = math.ceil((width - real_kernel_size) / stride) + 1if num_kernels == 1:stride = width - real_kernel_sizepadding = 0else: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 >= 1or padding >= 0or 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