熟悉 TensorFlow 的读者知道,在调用其卷积 conv2d
的时候,TensorFlow 有两种填充方式,分别是 padding = 'SAME' 和 padding = 'VALID',其中前者是默认值。如果卷积的步幅(stride)取值为 1,那么 padding = 'SAME' 就是指特征映射的分辨率在卷积前后保持不变,而 padding = 'VALID' 则是要下降 k - 1 个像素(即不填充,k 是卷积核大小)。比如,对于长度为 5 的特征映射,如果卷积核大小为 3,那么两种填充方式对应的结果是:
padding = 'SAME' 为了保持特征映射分辨率不变,需要在原特征映射四周填充不定大小的 0,然后再计算卷积,而 padding = 'VALID' 则是自然计算,不做任何填充。对于步幅 stride = 1,
slim.conv2d(kernel_size=k, padding='SAME', ...)
和
torch.nn.Conv2d(kernel_size=k, padding=k // 2, ...)
结果是一致的,如果权重是一样的话。但如果步幅 stride = 2,则两者的结果会有差异,比如对于 224x224 分辨率的特征映射,指定 k = 5,虽然两者的结果都得到 112x112 分辨率的特征映射,但结果却是不同的。比如,在输入和权重都一样的情况下,我们得到结果(运行后面给出的的代码:compare_conv.py,将第 22 行简化为:p = k // 2,将第 66/67 行注释掉):
Shape: (1, 112, 112, 16)
Shape: (1, 112, 112, 16)
y_tf:
[[ 0.15286588 -0.13643302 -0.09014875 0.25550553 0.05999924 -0.01149828
-0.30093179 -0.13394017 -0.16866598 0.17772882 0.08939055 -0.15882357
0.02846589 0.18959665 0.09113002 0.13065471]]
y_pth:
[[ 0.01814898 -0.26733394 0.16750193 0.25537257 0.21831602 0.31476249
0.01923549 -0.0464759 -0.02368551 0.05874638 -0.26061299 -0.33947413
-0.20543707 -0.05527851 0.00162258 0.10928829]]
你也可以尝试将 torch.nn.Conv2d()
中的 padding 改成其它值,但得到的特征映射要么分辨率不对,要么值不对。
这种差异是由 TensorFlow 和 Pytorch 在卷积运算时使用的填充方式不同导致的。Pytorch 在填充的时候,上、下、左、右各方向填充的大小是一样的,但 TensorFlow 却允许不一样。我们以一个实际例子来说明这个问题。假设输入的特征映射的分辨率(resolution)为 ,卷积核(kernel size)大小为 ,步幅(stride)为 (),空洞率(dilation)为 ,那么输出的特征映射的大小将变为 ,其中
为了算出总填充的大小 ,考虑到目标特征映射的宽、高是:
就得到
即:
因此,最终的总填充大小为:
但因为 Pytorch 总是上、下、左、右 4 个方向的填充量都一样大,因此
这样就会出现
的情况。比如,当 时,,而 ,就算人为的设成 ,也避免不了矛盾。另一方面,我们来看 TensorFlow 的填充方式:padding = 'SAME'。因为 TensorFlow 允许不同方向填充不同的大小,而且遵循上小下大、左小右大的原则,因此对于总填充大小 来说,上、下、左、右的填充量分别是:
对于我们举的特殊例子来说,,因此填充量分别是 ,相比于 Pytorch 的 或者 ,自然结果就不一致。
知道了以上内容,为了消除 Pytorch 与 TensorFlow 填充方面的差别,采取一个简单而有效的策略:
- 先对输入的特征映射按填充量:
进行 0 填充; - 然后接不做任何填充的卷积:
torch.nn.Conv2d(padding=0, ...)
。
以下为这个策略的验证代码(命名为 compare_conv.py):
# -*- coding: utf-8 -*-
"""
Created on Sat Dec 14 16:44:31 2019
@author: shirhe-lyh
"""
import numpy as np
import tensorflow as tf
import torch
tf.enable_eager_execution()
np.random.seed(123)
tf.set_random_seed(123)
torch.manual_seed(123)
h = 224
w = 224
k = 5
s = 2
p = k // 2 if s == 1 else 0
x_np = np.random.random((1, h, w, 3))
x_tf = tf.constant(x_np)
x_pth = torch.from_numpy(x_np.transpose(0, 3, 1, 2))
def pad(x, kernel_size=3, dilation=1):
"""For stride = 2 or stride = 3"""
pad_total = dilation * (kernel_size - 1) - 1
pad_beg = pad_total // 2
pad_end = pad_total - pad_beg
x_padded = torch.nn.functional.pad(
x, pad=(pad_beg, pad_end, pad_beg, pad_end))
return x_padded
conv_tf = tf.layers.Conv2D(filters=16,
padding='SAME',
kernel_size=k,
strides=(s, s))
# Tensorflow prediction
with tf.GradientTape(persistent=True) as t:
t.watch(x_tf)
y_tf = conv_tf(x_tf).numpy()
print('Shape: ', y_tf.shape)
conv_pth = torch.nn.Conv2d(in_channels=3,
out_channels=16,
kernel_size=k,
stride=s,
padding=p)
# Reset parameters
weights_tf, biases_tf = conv_tf.get_weights()
conv_pth.weight.data = torch.tensor(weights_tf.transpose(3, 2, 0, 1))
conv_pth.bias.data = torch.tensor(biases_tf)
# Pytorch prediction
conv_pth.eval()
with torch.no_grad():
if s > 1:
x_pth = pad(x_pth, kernel_size=k)
y_pth = conv_pth(x_pth)
y_pth = y_pth.numpy().transpose(0, 2, 3, 1)
print('Shape: ', y_pth.shape)
# Compare results
print('y_tf: ')
print(y_tf[:, h//s-1, 0, :])
print('y_pth: ')
print(y_pth[:, h//s-1, 0, :])
运行该代码,Pytorch 和 TensorFlow 的输出结果是一致的:
Shape: (1, 112, 112, 16)
Shape: (1, 112, 112, 16)
y_tf:
[[ 0.15286588 -0.13643302 -0.09014875 0.25550553 0.05999924 -0.01149828
-0.30093179 -0.13394017 -0.16866598 0.17772882 0.08939055 -0.15882357
0.02846589 0.18959665 0.09113002 0.13065471]]
y_pth:
[[ 0.15286588 -0.13643302 -0.09014875 0.25550553 0.05999924 -0.01149828
-0.30093179 -0.13394017 -0.16866598 0.17772882 0.08939055 -0.15882357
0.02846589 0.18959665 0.09113002 0.13065471]]
注:因为 slim.conv2d
等二维卷积函数都是调用的底层类 tf.layers.Conv2D
,因此拿 tf.layers.Conv2D
和 torch.nn.Conv2d
来做对比。