0%

今天,刚收到公众号官方的两条通知,说我的文章涉嫌整合他人内容,被暂时取消原创声明功能。

一开始,我还以为是系统的检测算法出了故障,赶紧低声下气地提交了申诉,说明全网上我的文章都是由我自己完成的。

当我回过头来阅读通知时,愕然发现了“经用户投诉且经平台审核”这几个字,一时间怒火中烧:哦,原来我是被诬告了。

我是两三个月前才在开始在公开媒体上创作的,对一些规则可能不熟。有些长期做自媒体的人或许会劝我:唉,这种事正常啊。只要去申诉,给你恢复就行了。而且你看,就封了两天,之后不就正常了?忍一忍就过去了。你不要玻璃心了。

是啊,从利益的角度来看,这件事不就是让我两天发不了“原创”的文章?两天过后一切损失都抹平了。

才怪呢。

这件事对我真正的影响,是损害了我的名誉。

说实话,你可以说我水平差劲,说我没钱没势,说我狂妄自大。背后说,当面讲,拿着个大喇叭对全国人民喊。我都会不以为然。

问题是,对于我的作品,对于我辛辛苦苦创作出来的受到了客观认可的作品,你不能诋毁它。甚至不是去挖苦文章的内容,而是拿最恶劣的抄袭来指控我。这是对我名声的侮辱,对所有有尊严的创作者的侮辱,也是你们创作平台自己的耻辱。

通知里说“有用户举报”。我不知道是不是真的有人举报。如果是真的,那我也奈何不了那个人。在这件事上,我是弱势的一方。我也不知道是谁干的,也没有受到什么严重的经济损失,没有任何追责的可能。可能别人就是觉得好玩,顺手按了个举报按钮呢?我除了骂一句“此人卑鄙无耻”以外,也做不了什么。

真正有问题的是微信公众号的官方。你们的审核人员心慵意懒,玩忽职守。手握审核大权而不知善用,身着公正之衣而不辩是非。不察之下竟把抄袭之罪强加于光明磊落的原创作者,以至于颠倒是非,污人清白,真是岂有此理!

你以为你们平台做起来靠的是什么?靠的是你们掌握的数以亿计的流量?别开玩笑了。给你们带来价值的,是会下金蛋的鸡。看着满棚的金鸡,几位手持饲料的奴仆倒好像也长出了翅膀,以为自己也能下出金蛋一般,觉得随手杀掉一两只鸡也无所谓。真是可笑至极。

我这里还要好心奉劝一下所有的创作平台,烦请你们给审核人员的评估指标中加一个错审率,加大造成冤情的惩罚。同时,在认定冤情的申诉通过后,把“对不起”三个大字好好地打在私信里。

仔细一想,这事也怪不了公众号平台,整个环境毕竟就是这样的。

每天在平台上发送的内容那么多,审核员能够把每篇文章都过一遍都实属不易,出几个纰漏也是情理之中。这些道理我肯定都懂,也可以理解。

但趁着这口气,我还要发表一下对于中国内容创作平台的看法。

以前,去网上查编程知识的时候,查出来的全是低劣的复制粘贴文章。想要搜个教程,还要跳过那万年不变的前几个网站,去后面几个搜索结果的跟帖中翻出学习资源来。想在网络中找精品资源,可谓是沙里淘金,海底捞针。

现在,我学有小成,想在网上分享一些学习的心得。可是,又关注者寥寥。

是我不会用搜索引擎吗?是我写不出好的文章吗?

我看,是这个互联网的运行机制有问题啊。

在“后来者居上”的论坛中,优秀的帖子还是会被顶起,随后贴上“精品”的标签,供后人赏读。

而在以推荐机制为主的封闭创作平台当道之后,本来就稀有的精品内容便沉入了泥潭之下。只推荐自己喜欢的内容,有谁不乐意呢?坐揽着源源不断的流量,那哪平台不开心呢?这就是大势所趋啊。

平台只知道流量,只知道赚钱。但这也没有办法。很多平台看似规模宏大,实际上,他们还烧着投资人的钱,他们自己还身陷囹圄,入不敷出。因此,他们只能想尽一切办法,赶快扩大规模,赶快收割流量,赶快盈利。然而,哪怕真有一日,他们开始盈利了,也只会在只知道赚钱的道路上转不过弯,忘记了当年平台是怎么火起来的。

公益性地维护一个优质的内容平台。这种看上去吃力不讨好的事情,小平台不会做,大平台也不会做。

按他们这样下去,中国互联网上优质的文章只会越来越少见,看不到更好的未来啊。

质量和金钱,真的就是互斥的关系吗?

我看,只是运营这些平台的人太菜了吧。

一来,他们过于浮躁。在指定最优化目标时,只想到了赚钱,却不知道往里面加一点点的“情怀”。

二来,他们水平低下。但凡掺入了一些不赚钱的因素,就觉得要运营不下去了。

三来,他们目光短浅。以为创造没有利润的精品是在浪费时间,实际上有内涵的事物在多年后能够带来超出金钱的价值。

等我有钱了,我能够把这一切都做好。

我知道,十多年的寒窗苦读,对多数人来讲并不是什么愉快的经历。很多时候,并不是自己没有学好,而是教育的方法有问题。这一问题在大学之后尤为突出。倘若当年能够收获一些优质的知识,也不至于会走那么多的弯路。

等我有钱了,我会设法建立一个吸引优秀创作者的平台,把优质的内容结合并组织起来,把名声打响,让大家都能来这里学习。我不仅要做一些“公益”的事情,我还要赚钱,我要把平台持久地运营下去。我会扶正互联网的创作风气,还互联网一个蓬勃发展的未来。

在这篇文章里,我也只能随口嚷嚷。诸君把这些话当作笑谈即可。不过,在当下,我还是会慢慢地行动着,创作着。

如果未来优秀的中文内容越来越多,说不定不再是我们计算机学生抱着一堆机械工业出版社的黑书,而是美国的教授拿着一本本从中文英化过去的参考书。

在这篇文章中,我会介绍如何用TensorFlow实现下面4个模型:

  1. ResNet-18
  2. ResNet-18 无跳连
  3. ResNet-50
  4. ResNet-50 无跳连

实现结束后,我会在一个简单的数据集上训练这4个模型。从实验结果中,我们能直观地看出ResNet中残差连接的作用。

项目链接:https://github.com/SingleZombie/DL-Demos

主要代码在dldemos/ResNet/tf_main.py这个文件里。

模型实现

主要结构

ResNet中有跳连的结构,直接用tf.keras.Sequenctial串行模型不太方便。因此,我们要自己把模型的各模块连起来,对应的TensorFlow写法是这样的:

1
2
3
4
5
6
7
8
9
10
11
# Initialize input
input = layers.Input(input_shape)

# Get output
output = ...

# Build model
model = models.Model(inputs=input, outputs=output)
print(model.summary())
return model

layers.Input创建一个输入张量后,就可以对这个张量进行计算,并在最后用tf.keras.models.Model把和该张量相关的计算图搭起来。

接下来,我们看看这个output具体是怎么算出来的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
def init_model(
input_shape=(224, 224, 3), model_name='ResNet18', use_shortcut=True):
# Initialize input
input = layers.Input(input_shape)

# Get output
x = layers.Conv2D(64, 7, (2, 2), padding='same')(input)
x = layers.MaxPool2D((3, 3), (2, 2))(x)

if model_name == 'ResNet18':
x = identity_block_2(x, 3, use_shortcut)
x = identity_block_2(x, 3, use_shortcut)
x = convolution_block_2(x, 3, 128, 2, use_shortcut)
x = identity_block_2(x, 3, use_shortcut)
x = convolution_block_2(x, 3, 256, 2, use_shortcut)
x = identity_block_2(x, 3, use_shortcut)
x = convolution_block_2(x, 3, 512, 2, use_shortcut)
x = identity_block_2(x, 3, use_shortcut)
elif model_name == 'ResNet50':

def block_group(x, fs1, fs2, count):
x = convolution_block_3(x, 3, fs1, fs2, 2, use_shortcut)
for i in range(count - 1):
x = identity_block_3(x, 3, fs1, fs2, use_shortcut)
return x

x = block_group(x, 64, 256, 3)
x = block_group(x, 128, 512, 4)
x = block_group(x, 256, 1024, 6)
x = block_group(x, 512, 2048, 3)
else:
raise NotImplementedError(f'No such model {model_name}')

x = layers.AveragePooling2D((2, 2), (2, 2))(x)
x = layers.Flatten()(x)
output = layers.Dense(1, 'sigmoid')(x)

# Build model
model = models.Model(inputs=input, outputs=output)
print(model.summary())
return model

构建模型时,我们需要给出输入张量的形状。同时,这个函数用model_name控制模型的结构,use_shortcut控制是否使用跳连。

1
2
def init_model(
input_shape=(224, 224, 3), model_name='ResNet18', use_shortcut=True):

在ResNet中,主要有两种残差块。

第一种是上图中实线连接的,这种残差块的输入输出形状相同,输入可以直接加到激活函数之前的输出上;第二种是上图中虚线连接的,这种残差块输入输出形状不同,需要用一个1x1卷积调整宽高和通道数。

此外,每种残差块用两种实现方式。

第一种实现方式如上图左半部分所示,这样的残差块由两个通道数相同的3x3卷积构成,只有一个需要决定的通道数;第二种实现方式采用了瓶颈(bottlenect)结构,先用1x1卷积降低了通道数,再进行3x3卷积,共有两个要决定的通道数(第1, 2个卷积和第3个卷积的通道数),如上图右半部分所示。

代码中,我用identity_block_2, identity_block_3分别表示输入输出相同的残差块的两种实现,convolution_block_2, convolution_block_3分别表示输入输出不同的残差块的两种实现。这些代码会在下一小节里给出。

现在,我们来看看该如何用这些模块构成ResNet-18和ResNet-50。首先,我们看一看原论文中这几个ResNet的结构图。

对于这两种架构,它们一开始都要经过一个大卷积层和一个池化层,最后都要做一次平均池化并输入全连接层。不同之处在于中间的卷积层。ResNet-18和ResNet-50使用了实现方式不同且个数不同的卷积层组。

在代码中,开始的大卷积及池化是这样写的:

1
2
x = layers.Conv2D(64, 7, (2, 2), padding='same')(input)
x = layers.MaxPool2D((3, 3), (2, 2))(x)

ResNet-18的实现是:

1
2
3
4
5
6
7
8
9
if model_name == 'ResNet18':
x = identity_block_2(x, 3, use_shortcut)
x = identity_block_2(x, 3, use_shortcut)
x = convolution_block_2(x, 3, 128, 2, use_shortcut)
x = identity_block_2(x, 3, use_shortcut)
x = convolution_block_2(x, 3, 256, 2, use_shortcut)
x = identity_block_2(x, 3, use_shortcut)
x = convolution_block_2(x, 3, 512, 2, use_shortcut)
x = identity_block_2(x, 3, use_shortcut)

其中,identity_block_2的参数分别为输入张量、卷积核边长、是否使用短路。convolution_block_2的参数分别为输入张量、卷积核边长、输出通道数、步幅、是否使用短路。

ResNet-50的实现是:

1
2
3
4
5
6
7
8
9
10
11
elif model_name == 'ResNet50':
def block_group(x, fs1, fs2, count):
x = convolution_block_3(x, 3, fs1, fs2, 2, use_shortcut)
for i in range(count - 1):
x = identity_block_3(x, 3, fs1, fs2, use_shortcut)
return x

x = block_group(x, 64, 256, 3)
x = block_group(x, 128, 512, 4)
x = block_group(x, 256, 1024, 6)
x = block_group(x, 512, 2048, 3)

其中,identity_block_3的参数分别为输入张量、卷积核边长、中间和输出通道数、是否使用短路。convolution_block_3的参数分别为输入张量、卷积核边长、中间和输出通道数、步幅、是否使用短路。

最后是计算分类输出的代码:

1
2
3
x = layers.AveragePooling2D((2, 2), (2, 2))(x)
x = layers.Flatten()(x)
output = layers.Dense(1, 'sigmoid')(x)

残差块实现

1
2
3
4
5
6
7
8
9
10
11
12
def identity_block_2(x, f, use_shortcut=True):
_, _, _, C = x.shape
x_shortcut = x
x = layers.Conv2D(C, f, padding='same')(x)
x = layers.BatchNormalization(axis=3)(x)
x = layers.ReLU()(x)
x = layers.Conv2D(C, f, padding='same')(x)
x = layers.BatchNormalization(axis=3)(x)
if use_shortcut:
x = x + x_shortcut
x = layers.ReLU()(x)
return x

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def convolution_block_2(x, f, filters, s: int, use_shortcut=True):
x_shortcut = x
x = layers.Conv2D(filters, f, strides=(s, s), padding='same')(x)
x = layers.BatchNormalization(axis=3)(x)
x = layers.ReLU()(x)
x = layers.Conv2D(filters, f, padding='same')(x)
x = layers.BatchNormalization(axis=3)(x)
if use_shortcut:
x_shortcut = layers.Conv2D(filters, 1, strides=(s, s),
padding='valid')(x_shortcut)
x_shortcut = layers.BatchNormalization(axis=3)(x_shortcut)
x = x + x_shortcut
x = layers.ReLU()(x)
return x

1
2
3
4
5
6
7
8
9
10
11
12
13
def identity_block_3(x, f, filters1, filters2, use_shortcut=True):
x_shortcut = x
x = layers.Conv2D(filters1, 1, padding='valid')(x)
x = layers.BatchNormalization(axis=3)(x)
x = layers.Conv2D(filters1, f, padding='same')(x)
x = layers.BatchNormalization(axis=3)(x)
x = layers.ReLU()(x)
x = layers.Conv2D(filters2, 1, padding='valid')(x)
x = layers.BatchNormalization(axis=3)(x)
if use_shortcut:
x = x + x_shortcut
x = layers.ReLU()(x)
return x

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def convolution_block_3(x, f, filters1, filters2, s: int, use_shortcut=True):
x_shortcut = x
x = layers.Conv2D(filters1, 1, strides=(s, s), padding='valid')(x)
x = layers.BatchNormalization(axis=3)(x)
x = layers.Conv2D(filters1, f, padding='same')(x)
x = layers.BatchNormalization(axis=3)(x)
x = layers.ReLU()(x)
x = layers.Conv2D(filters2, 1, padding='valid')(x)
x = layers.BatchNormalization(axis=3)(x)
if use_shortcut:
x_shortcut = layers.Conv2D(filters2, 1, strides=(s, s),
padding='same')(x_shortcut)
x_shortcut = layers.BatchNormalization(axis=3)(x_shortcut)
x = x + x_shortcut
x = layers.ReLU()(x)
return x

这些代码中有一个细节要注意:在convolution_block_3中,stride=2是放在第一个还是第二个卷积层中没有定论。不同框架似乎对此有不同的实现方式。这里是把它放到了第一个1x1卷积里。

实验结果

在这个项目中,我已经准备好了数据集预处理的代码。可以轻松地生成数据集并用TensorFlow训练模型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def main():
train_X, train_Y, test_X, test_Y = get_cat_set(
'dldemos/LogisticRegression/data/archive/dataset',
train_size=500,
test_size=50)
print(train_X.shape) # (m, 224, 224, 3)
print(train_Y.shape) # (m , 1)

#model = init_model()
#model = init_model(use_shortcut=False)
model = init_model(model_name='ResNet50')
# model = init_model(model_name='ResNet50', use_shortcut=False)
model.compile(optimizer='adam',
loss='binary_crossentropy',
metrics=['accuracy'])

model.fit(train_X, train_Y, epochs=20, batch_size=16)
model.evaluate(test_X, test_Y)

为了让训练尽快结束,我只训了20个epoch,且使用的数据集比较小。我在ResNet-18中使用了3000个训练样本,ResNet-50中使用了1000个训练样本。数据的多少不影响对比结果,我们只需要知道模型的训练误差,便足以比较这四个模型了。

以下是我在四个实验中得到的结果。

ResNet-18

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
Epoch 1/20
63/63 [==============================] - 75s 1s/step - loss: 1.9463 - accuracy: 0.5485
Epoch 2/20
63/63 [==============================] - 71s 1s/step - loss: 0.9758 - accuracy: 0.5423
Epoch 3/20
63/63 [==============================] - 81s 1s/step - loss: 0.8490 - accuracy: 0.5941
Epoch 4/20
63/63 [==============================] - 73s 1s/step - loss: 0.8309 - accuracy: 0.6188
Epoch 5/20
63/63 [==============================] - 72s 1s/step - loss: 0.7375 - accuracy: 0.6402
Epoch 6/20
63/63 [==============================] - 77s 1s/step - loss: 0.7932 - accuracy: 0.6769
Epoch 7/20
63/63 [==============================] - 78s 1s/step - loss: 0.7782 - accuracy: 0.6713
Epoch 8/20
63/63 [==============================] - 76s 1s/step - loss: 0.6272 - accuracy: 0.7147
Epoch 9/20
63/63 [==============================] - 77s 1s/step - loss: 0.6303 - accuracy: 0.7059
Epoch 10/20
63/63 [==============================] - 74s 1s/step - loss: 0.6250 - accuracy: 0.7108
Epoch 11/20
63/63 [==============================] - 73s 1s/step - loss: 0.6065 - accuracy: 0.7142
Epoch 12/20
63/63 [==============================] - 74s 1s/step - loss: 0.5289 - accuracy: 0.7754
Epoch 13/20
63/63 [==============================] - 73s 1s/step - loss: 0.5005 - accuracy: 0.7506
Epoch 14/20
63/63 [==============================] - 73s 1s/step - loss: 0.3961 - accuracy: 0.8141
Epoch 15/20
63/63 [==============================] - 74s 1s/step - loss: 0.4417 - accuracy: 0.8121
Epoch 16/20
63/63 [==============================] - 74s 1s/step - loss: 0.3761 - accuracy: 0.8136
Epoch 17/20
63/63 [==============================] - 73s 1s/step - loss: 0.2764 - accuracy: 0.8809
Epoch 18/20
63/63 [==============================] - 71s 1s/step - loss: 0.2698 - accuracy: 0.8878
Epoch 19/20
63/63 [==============================] - 72s 1s/step - loss: 0.1483 - accuracy: 0.9457
Epoch 20/20
63/63 [==============================] - 72s 1s/step - loss: 0.2495 - accuracy: 0.9079

ResNet-18 无跳连

text
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
Epoch 1/20
63/63 [==============================] - 63s 963ms/step - loss: 1.4874 - accuracy: 0.5111
Epoch 2/20
63/63 [==============================] - 62s 990ms/step - loss: 0.7654 - accuracy: 0.5386
Epoch 3/20
63/63 [==============================] - 65s 1s/step - loss: 0.6799 - accuracy: 0.6210
Epoch 4/20
63/63 [==============================] - 62s 990ms/step - loss: 0.6891 - accuracy: 0.6086
Epoch 5/20
63/63 [==============================] - 65s 1s/step - loss: 0.7921 - accuracy: 0.5182
Epoch 6/20
63/63 [==============================] - 65s 1s/step - loss: 0.7123 - accuracy: 0.5643
Epoch 7/20
63/63 [==============================] - 64s 1s/step - loss: 0.7071 - accuracy: 0.5173
Epoch 8/20
63/63 [==============================] - 64s 1s/step - loss: 0.6653 - accuracy: 0.6227
Epoch 9/20
63/63 [==============================] - 65s 1s/step - loss: 0.6675 - accuracy: 0.6249
Epoch 10/20
63/63 [==============================] - 64s 1s/step - loss: 0.6959 - accuracy: 0.6130
Epoch 11/20
63/63 [==============================] - 66s 1s/step - loss: 0.6730 - accuracy: 0.6182
Epoch 12/20
63/63 [==============================] - 63s 1s/step - loss: 0.6321 - accuracy: 0.6491
Epoch 13/20
63/63 [==============================] - 63s 992ms/step - loss: 0.6413 - accuracy: 0.6569
Epoch 14/20
63/63 [==============================] - 63s 1s/step - loss: 0.6130 - accuracy: 0.6885
Epoch 15/20
63/63 [==============================] - 62s 988ms/step - loss: 0.6750 - accuracy: 0.6056
Epoch 16/20
63/63 [==============================] - 66s 1s/step - loss: 0.6341 - accuracy: 0.6526
Epoch 17/20
63/63 [==============================] - 68s 1s/step - loss: 0.6384 - accuracy: 0.6676
Epoch 18/20
63/63 [==============================] - 65s 1s/step - loss: 0.5750 - accuracy: 0.6997
Epoch 19/20
63/63 [==============================] - 63s 997ms/step - loss: 0.5932 - accuracy: 0.7094
Epoch 20/20
63/63 [==============================] - 62s 990ms/step - loss: 0.6133 - accuracy: 0.6420

ResNet-50

text
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
Epoch 1/20
63/63 [==============================] - 72s 1s/step - loss: 3.4660 - accuracy: 0.4970
Epoch 2/20
63/63 [==============================] - 67s 1s/step - loss: 1.3429 - accuracy: 0.5686
Epoch 3/20
63/63 [==============================] - 68s 1s/step - loss: 1.0294 - accuracy: 0.5616
Epoch 4/20
63/63 [==============================] - 68s 1s/step - loss: 0.7920 - accuracy: 0.6186
Epoch 5/20
63/63 [==============================] - 70s 1s/step - loss: 0.6698 - accuracy: 0.6773
Epoch 6/20
63/63 [==============================] - 70s 1s/step - loss: 0.6884 - accuracy: 0.7289
Epoch 7/20
63/63 [==============================] - 70s 1s/step - loss: 0.7144 - accuracy: 0.6399
Epoch 8/20
63/63 [==============================] - 69s 1s/step - loss: 0.7088 - accuracy: 0.6698
Epoch 9/20
63/63 [==============================] - 68s 1s/step - loss: 0.6385 - accuracy: 0.6446
Epoch 10/20
63/63 [==============================] - 69s 1s/step - loss: 0.5389 - accuracy: 0.7417
Epoch 11/20
63/63 [==============================] - 71s 1s/step - loss: 0.4954 - accuracy: 0.7832
Epoch 12/20
63/63 [==============================] - 73s 1s/step - loss: 0.4489 - accuracy: 0.7782
Epoch 13/20
63/63 [==============================] - 69s 1s/step - loss: 0.3987 - accuracy: 0.8257
Epoch 14/20
63/63 [==============================] - 72s 1s/step - loss: 0.3228 - accuracy: 0.8519
Epoch 15/20
63/63 [==============================] - 70s 1s/step - loss: 0.2089 - accuracy: 0.9235
Epoch 16/20
63/63 [==============================] - 69s 1s/step - loss: 0.4766 - accuracy: 0.7756
Epoch 17/20
63/63 [==============================] - 75s 1s/step - loss: 0.2148 - accuracy: 0.9181
Epoch 18/20
63/63 [==============================] - 70s 1s/step - loss: 0.3086 - accuracy: 0.8623
Epoch 19/20
63/63 [==============================] - 69s 1s/step - loss: 0.3544 - accuracy: 0.8732
Epoch 20/20
63/63 [==============================] - 70s 1s/step - loss: 0.0796 - accuracy: 0.9704

ResNet-50 无跳连

text
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
Epoch 1/20
63/63 [==============================] - 60s 882ms/step - loss: 1.2093 - accuracy: 0.5034
Epoch 2/20
63/63 [==============================] - 56s 892ms/step - loss: 0.8433 - accuracy: 0.4861
Epoch 3/20
63/63 [==============================] - 59s 931ms/step - loss: 0.7512 - accuracy: 0.5235
Epoch 4/20
63/63 [==============================] - 62s 991ms/step - loss: 0.7395 - accuracy: 0.4887
Epoch 5/20
63/63 [==============================] - 62s 990ms/step - loss: 0.7770 - accuracy: 0.5316
Epoch 6/20
63/63 [==============================] - 60s 945ms/step - loss: 0.7408 - accuracy: 0.4947
Epoch 7/20
63/63 [==============================] - 67s 1s/step - loss: 0.7345 - accuracy: 0.5434
Epoch 8/20
63/63 [==============================] - 62s 984ms/step - loss: 0.7214 - accuracy: 0.5605
Epoch 9/20
63/63 [==============================] - 60s 950ms/step - loss: 0.7770 - accuracy: 0.4784
Epoch 10/20
63/63 [==============================] - 60s 956ms/step - loss: 0.7171 - accuracy: 0.5203
Epoch 11/20
63/63 [==============================] - 63s 994ms/step - loss: 0.7045 - accuracy: 0.4921
Epoch 12/20
63/63 [==============================] - 63s 1s/step - loss: 0.6884 - accuracy: 0.5430
Epoch 13/20
63/63 [==============================] - 60s 958ms/step - loss: 0.7333 - accuracy: 0.5278
Epoch 14/20
63/63 [==============================] - 61s 966ms/step - loss: 0.7050 - accuracy: 0.5106
Epoch 15/20
63/63 [==============================] - 59s 943ms/step - loss: 0.6958 - accuracy: 0.5622
Epoch 16/20
63/63 [==============================] - 60s 954ms/step - loss: 0.7398 - accuracy: 0.5172
Epoch 17/20
63/63 [==============================] - 69s 1s/step - loss: 0.7104 - accuracy: 0.5023
Epoch 18/20
63/63 [==============================] - 74s 1s/step - loss: 0.7411 - accuracy: 0.4747
Epoch 19/20
63/63 [==============================] - 67s 1s/step - loss: 0.7056 - accuracy: 0.4706
Epoch 20/20
63/63 [==============================] - 81s 1s/step - loss: 0.7901 - accuracy: 0.4898

对比ResNet-18和ResNet-50,可以看出,ResNet-50的拟合能力确实更强一些。

对比无跳连的ResNet-18和ResNet-50,可以看出,ResNet-50的拟合能力反而逊于ResNet-18。这符合ResNet的初衷,如果不加残差连接的话,过深的网络反而会因为梯度问题而有更高的训练误差。

此外,不同模型的训练速度也值得一讲。在训练数据量减少到原来的1/3后,ResNet-50和ResNet-18的训练速度差不多。ResNet-50看上去比ResNet-18多了很多层,网络中间也使用了通道数很大的卷积,但整体的参数量并没有增大多少,这多亏了能降低运算量的瓶颈结构。

总结

在这篇文章中,我展示了ResNet-18和ResNet-50的TensorFlow实现。这份代码包括了经典ResNet中两种残差块的两种实现,完整地复现了原论文的模型模块。同时,经实验分析,我验证了ResNet残差连接的有效性。

未来我还会写一篇ResNet的PyTorch实现,并附上论文的详细解读。

学习提示

上周,我们学完了CNN的基础组成模块。而从这周开始,我们要换一种学习方式:我们会认识一些经典的CNN架构,从示例中学习。一方面来说,通过了解他人的网络,阅读他人的代码,我们能够更快地掌握如何整合CNN的基础模块;另一方面,CNN架构往往泛化能力较强,学会了其他任务中成熟的架构,可以把这些架构直接用到我们自己的任务中。

接下来,我们会按照CNN的发展历史,认识许多CNN架构。首先是经典网络:

  • LeNet-5
  • AlexNet
  • VGG

之后是近年来的一些网络:

  • ResNet
  • Inception
  • MobileNet

我们不会把这些研究的论文详细过一遍,而只会学习各研究中最精华的部分。学有余力的话,最好能在课后把论文自己过一遍。

课堂笔记

经典网络

LeNet-5

LeNet-5是用于手写数字识别(识别0~9的阿拉伯数字)的网络。它的结构如下:

网络是输入是一张[32, 32, 1]的灰度图像,输入经过4个卷积+池化层,再经过两个全连接层,输出一个0~9的数字。这个网络和我们上周见过的网络十分相似,数据体的宽和高在不断变小,而通道数在不断变多。

这篇工作是1998年发表的,当时的神经网络架构和现在我们学的有不少区别:

  • 当时padding还没有得到广泛使用,数据体的分辨率会越降越小。
  • 当时主要使用平均池化,而现在最大池化更常见。
  • 网络只输出一个值,表示识别出来的数字。而现在的多分类任务一般会输出10个值并使用softmax激活函数。
  • 当时激活函数只用sigmoid和tanh,没有人用ReLU。
  • 当时的算力没有现在这么强,原工作在计算每个通道卷积时使用了很多复杂的小技巧。而现在我们直接算就行了。

LeNet-5只有6万个参数。随着算力的增长,后来的网络越来越大了。

AlexNet

AlexNet是2012年发表的有关图像分类的CNN结构。它的输入是[227, 227, 3]的图像,输出是一个1000类的分类结果。

原论文里写的是输入形状是[224, 224, 3],但实际上这个分辨率是有问题的,按照这个分辨率是算不出后续结果的分辨率的。但现在一些框架对AlexNet的复现中,还是会令输入的分辨率是224。这是因为框架在第一层卷积中加了一个padding的操作,强行让后续数据的分辨率和原论文对上了。

AlexNet和LeNet-5在架构上十分接近。但是,AlexNet做出了以下改进:

  • AlexNet用了更多的参数,一共有约6000万个参数。
  • 使用ReLU作为激活函数。

AlexNet还提出了其他一些创新,但与我们要学的知识没有那么多关系:

  • 当时算力还是比较紧张,AlexNet用了双GPU训练。论文里写了很多相关的工程细节。
  • 使用了Local Response Normalization这种归一化层。现在几乎没人用这种归一化。

AlexNet中的一些技术在今天看来,已经是常识般的存在。而在那个年代,尽管深度学习在语音识别等任务上已经初露锋芒,人们还没有开始重视深度学习这项技术。正是由于AlexNet这一篇工作的出现,计算机视觉的研究者开始关注起了深度学习。甚至在后来,这篇工作的影响力已经远超出了计算机视觉社区。

VGG-16

VGG-16也是一个图像分类网络。VGG的出发点是:为了简化网络结构,只用3x3等长(same)卷积和2x2最大池化。

可以看出,VGG也是经过了一系列的卷积和池化层,最后使用全连接层和softmax输出结果。顺带一提,VGG-16里的16表示有16个带参数的层。

VGG非常庞大,有138M(1.38亿)个参数。但是它简洁的结构吸引了很多人的关注。

吴恩达老师鼓励大家去读一读这三篇论文。可以先看AlexNet,再看VGG。LeNet有点难读,可以放到最后去读。

ResNets(基于残差的网络)

非常非常深的神经网络是很难训练的,这主要是由梯度爆炸/弥散问题导致的。在这一节中,我们要学一种叫做“跳连(skip connection)”的网络模块连接方式。使用跳连,我们能让浅层模块的输出直接对接到深层模块的输入上,进而搭建基于残差的网络,解决梯度爆炸/弥散问题,训练深达100层的网络。

残差块

回忆一下,在全连接网络中,假如我们有中间层的输出$a^{[l]}, a^{[l+2]}$,$a^{[l+2]}$是怎么由$a^{[l]}$算出来的呢?我们之前用的公式如下:

也就是说,$a^{[l]}$要经过一个线性层、一个激活函数、一个线性层、一个激活函数,才能传递到$a^{[l+2]}$,这条路径非常长:

而在残差块(Residual block)中,我们使用了一种新的连接方法:

$a^{[l]}$的值被直接加到了第二个ReLU层之前的线性输出上,这是一种类似电路中短路的连接方法(又称跳连)。这样,浅层的信息能更好地传到深层了。

使用这种方法后,计算公式变更为:

残差块中还有一个要注意的细节。$a^{[l+2]}=g(z^{[l+2]}+a^{[l]})$这个式子能够成立,实际上是默认了$a^{[l+2]}, a^{[l]}$的维度相同。而一旦$a^{[l+2]}$的维度发生了变化,就需要用下面这种方式来调整了。

$a^{[l+2]}=g(z^{[l+2]}+W’a^{[l]})$

我们可以用一个$W’$来完成维度的转换。为了方便理解,我们先让所有$a$都是一维向量,$W’$是矩阵。这样,假设$a^{[l+2]}$的长度是256,$a^{[l]}$的长度是128,则$W’$的形状就是$256 \times 128$。

但实际上,$a$是一个三维的图像张量,三个维度的长度都可能发生变化。因此,对于图像,上式中的$W’$应该表示的是一个卷积操作。通过卷积操作,我们能够减小图像的宽高,调整图像的通道数,使得$a^{[l]}$和$a^{[l+2]}$的维度完全相同。

残差网络

在构建残差网络ResNet时,只要把这种残差块一个一个拼接起来即可。或者从另一个角度来看,对于一个“平坦网络”(”plain network”, ResNet论文中用的词,用于表示非残差网络),我们只要把线性层两两打包,添加跳连即可。

残差块起到了什么作用呢?让我们看看在网络层数变多时,平坦网络和残差网络训练误差的变化趋势:

理论上来说,层数越深,训练误差应该越低。但在实际中,对平坦网络增加深度,反而会让误差变高。而使用ResNet后,随着深度增加,训练误差起码不会降低了。

正是有这样的特性,我们可以用ResNet架构去训练非常深的网络。

为什么ResNet是有这样的特性呢?我们还是从刚刚那个ResNet的公式里找答案。

假设我们设计好了一个网络,又给它新加了一个残差块,即多加了两个卷积层,那么最后的输出可以写成:

由于正则化的存在,所有$W$和$b$都倾向于变得更小。极端情况下,$W, b$都变为0了。那么,

再不妨设$g=ReLU$。则因为$a^{[l]}$也是ReLU的输出,有

这其实是一个恒等映射,也就是说,新加的残差块对之前的输出没有任何影响。网络非常容易学习到恒等映射。这样,最起码能够保证较深的网络不比浅的网络差。

准备好了所有基础知识,我们来看看完整的ResNet长什么样。

ResNet有几个参数量不同的版本。这里展示的叫做ResNet-34。完整的网络很长,我们只用关注其中一小部分就行了。

一开始,网络还是用一个大卷积核大步幅的卷积以及一个池化操作快速降低图像的宽度,再把数据传入残差块中。和我们刚刚学的一样,残差块有两种,一种是维度相同可以直接相加的(实线),一种是要调整维度的(虚线)。整个网络就是由这若干个这样的残差块组构成。经过所有残差块后,还是和经典的网络一样,用全连接层输出结果。

这里,我们只学习了残差连接的基本原理。ResNet的论文里还有更多有关网络结构、实验的细节。最好能读一读论文。当然,这周的编程实战里我们也会复现ResNet,以加深对其的理解。

Inception 网络

我们已经见过不少CNN的示例了。当我们仿照它们设计自己的网络时,或许会感到迷茫:有3x3, 5x5卷积,有池化,该怎么选择每一个模块呢?Inception网络给了一个解决此问题的答案:我全都要。

Inception网络用到了一种特殊的1x1卷积。我们会先学习1x1卷积,再学习Inception网络的知识。

1x1卷积

用1x1的卷积核去卷一幅图像,似乎是一件很滑稽的事情。假设一幅图像的数字是[1, 2, 3],卷积核是[2],那么卷出来的图像就是[2, 4, 6]。这不就是把每个数都做了一次乘法吗?

对于通道数为1的图像,1x1卷积确实没什么大用。而当通道数多起来后,1x1卷积的意义就逐渐显现出来了。思考一下,对多通道的图像做1x1卷积,就是把某像素所有通道的数字各乘一个数,求和,加一个bias,再通过激活函数。这是计算一个输出结果的过程,而如果有多个卷积核,就可以计算出多个结果。(下图中,蓝色的数据体是输入图像,黄色的数据体是1x1的卷积核。两个数据体重合部分的数据会先做乘法,再求和,加bias,经过激活函数。)

这个过程让你想起了什么?没错,正是最早学习的全连接网络。1x1卷积,实际上就是在各通道上做了一次全连接的计算。1x1卷积的输入通道数,就是全连接层上一层神经元的数量;1x1卷积核的数量,就是这一层神经元的数量。

1x1卷积主要用于变换图像的通道数。比如要把一个192通道数的图像变成32通道的,就应该用32个1x1卷积去卷原图像。

Inception块的原理

在Inception网络中,我们会使用这样一种混合模块:对原数据做1x1, 3x3, 5x5卷积以及最大池化,得到通道数不同的数据体。这些数据体会被拼接起来,作为整个模块的输出。

值得注意的是,这里的池化操作和我们之前见过的不太一样。为了保持输出张量的宽高,这个池化的步幅为1,且使用了等长填充。另外,为了调整池化操作输出的通道数,这条数据处理路线上还有一个用1x1卷积变换通道数的操作。这份图省略了很多这样的细节,下一节我们会见到这幅图的完整版。

在实现这样一种模块时,会碰到计算量过大的问题。比如把上面$28 \times 28 \times 192$的数据体用$5 \times 5$卷积卷成$28 \times 28 \times 32$的数据体,需要多少次乘法计算呢?对每个像素单独考虑,一个通道上的卷积要做$5 \times 5$此乘法,192个通道的卷积要做$192 \times 5 \times 5$次乘法。32个这样的卷积在$28 \times 28$的图片上要做$28 \times 28 \times 32 \times 192 \times 5 \times 5 \approx 120M$次乘法。这个计算量太大了。

为此,我们可以巧妙地先利用1x1卷积减少通道数,再做5x5卷积。这样,计算量就少得多了。

这样一种两头大,中间小的结构被形象地称为瓶颈(bottlenect)。这种结构被广泛用在许多典型网络中。

Inception网络

有了之前的知识,我们可以看Inception模块的完整结构了。1x1卷积没有什么特别的。为了减少3x3卷积和5x5卷积的计算量,做这两种卷积之前都会用1x1卷积减少通道数。而为了改变池化结果的通道数,池化后接了一个1x1卷积操作。

实际上,理解了Inception块,也就能看懂Inception网络了。如下图所示,红框内的模块都是Inception块。而这个网络还有一些小细节:除了和普通网络一样在网络的最后使用softmax输出结果外,这个网络还根据中间结果也输出了几个结果。当然,这些都是早期网络的设计技巧了。

MobileNet

MobileNet,顾名思义,这是一种适用于移动(mobile)设备的神经网络。移动设备的计算资源通常十分紧缺,因此,MobileNet对网络的计算量进行了极致的压缩。

减少卷积运算量

再回顾一遍,一次卷积操作中主要的计算量如下:

计算量这么大,主要问题出在每一个输出通道都要与每一个输入通道“全连接”上。为此,我们可以考虑让输出通道只由部分的输入通道决定。这样一种卷积的策略叫逐深度可分卷积(Depthwise Separable Convolution)。

这里的depthwise是“逐深度”的意思,但我觉得“逐通道”这个称呼会更容易理解一点。

逐深度可分卷积分为两步:逐深度卷积(depthwise convolution),逐点卷积(pointwise convolution)。逐深度卷积生成新的通道,逐点卷积把各通道的信息关联起来。

之前,要对下图中的三通道图片做卷积,需要3个卷积核分别处理3个通道。而在逐深度卷积中,我们只要1个卷积核。这个卷积核会把输入图像当成三个单通道图像来看待,分别对原图像的各个通道进行卷积,并生成3个单通道图像,最后把3个单通道图像拼回一个三通道图像。也就是说,逐深度卷积只能生成一幅通道数相同的新图像。

逐深度卷积可以通过设置卷积在编程框架中的groups参数来实现。参见我讲解卷积的文章

下一步,是逐点卷积,也就是1x1卷积。它用来改变图片的通道数。

之前的卷积有2160次乘法,现在只有432+240=672次,计算量确实减少了不少。实际上,优化后计算量占原计算量的比例是:

其中$n_c’$是输出通道数,$f$是卷积核边长。一般来说计算量都会少10倍。

网络结构

知道了MobileNet的基本思想,我们来看几个不同版本的MobileNet。

MobileNet v1

13个逐深度可分卷积模块,之后接通常的池化、全连接、softmax。

MobileNet v2

两个改进:

  1. 残差连接
  2. 扩张(expansion)操作

残差连接和ResNet一样。这里我们关注一下第二个改进。

在MobileNet v2中,先做一个扩张维度的1x1卷积,再做逐深度卷积,最后做之前的逐点1x1卷积。由于最后的逐点卷积起到的是减小维度的作用,所以最后一步操作也叫做投影。

这种架构很好地解决了性能和效果之间的矛盾:在模块之间,数据的通道数只有3,占用内存少;在模块之内,更高通道的数据能拟合更复杂的函数。

EfficientNet

EfficientNet能根据设备的计算能力,自动调整网络占用的资源。

让我们想想,哪些因素决定了一个网络占用的运算资源?我们很快能想到下面这些因素:

  • 图像分辨率
  • 网络深度
  • 特征的长度(即卷积核数量或神经元数量)

在EfficientNet中,我们可以在这三个维度上缩放网络,动态改变网络的计算量。EfficientNet的开源实现中,一般会提供各设备下的最优参数。

卷积网络实现细节

使用开源实现

由于深度学习项目涉及很多训练上的细节,想复现一个前人的工作是很耗时的。最好的学习方法是找到别人的开源代码,在现有代码的基础上学习。

深度学习的开源代码一般在GitHub上都能找到。如果是想看PyTorch实现,可以直接去GitHub上搜索OpenMMLab。

使用迁移学习

如第三门课第二周所学,我们可以用迁移学习,导入别人训练好的模型里的权重为初始权重,加速我们自己模型的训练。

还是以多分类任务的迁移学习为例(比如把一个1000分类的分类器迁移到一个猫、狗、其他的三分类模型上)。迁移后,新的网络至少要删除输出层,并按照新的多分类个数,重新初始化一个输出层。之后,根据新任务的数据集大小,冻结网络的部分参数,从导入的权重开始重新训练网络的其他部分:

当然,可以多删除几个较深的层,也可以多加入几个除了输出层以外的隐藏层。

数据增强

由于CV任务总是缺少数据,数据增强是一种常见的提升网络性能的手段。

常见的改变形状的数据增强手段有:

  • 镜像
  • 裁剪
  • 旋转
  • 扭曲

此外,还可以改变图像的颜色。比如对三个颜色通道都随机加一个偏移量。

数据增强有一些实现上的细节:数据的读取及增强是放在CPU上运行的,训练是放在CPU或GPU上运行的。这两步其实是独立的,可以并行完成。最常见的做法是,在CPU上用多进程(发挥多核的优势)读取数据并进行数据增强,之后把数据搬到GPU上训练。

计算机视觉的现状与相关建议

一般来说,算法从两个来源获取知识:标注的数据,人工设计的特征。这二者是互补的关系。对于人工智能任务来说,如果有足够的数据,设计一个很简单的网络就行了;而如果数据量不足,则需要去精心设计网络结构。

像语音识别这种任务就数据充足,用简单的网络就行了。而大部分计算机视觉任务都处于数据不足的状态。哪怕计算机视觉中比较基础的图像分类任务,都需要设计结构复杂的网络,更不用说目标检测这些更难的任务了。

如果你想用深度学习模型参加刷精度的比赛,可以使用以下几个小技巧:

  • 同时开始训练多个网络,算结果时取它们的平均值。
  • 对图像分类任务,可以把图像随机裁剪一部分并输入网络,多次执行这一步骤并取平均分类结果。

也就是说,只是为了提高精度的话,可以想办法对同一份输入执行多次条件不同的推理,并对结果求平均。当然,实际应用中是不可能用性能这么低的方法。

总结

这节课是CNN中最重要的一节课。通过学习一些经典的CNN架构,我们掌握了很多有关搭建CNN的知识。总结一下:

  • 早期CNN
    • 卷积、池化、全连接
    • 边长减小,通道数增加
  • ResNet
    • 为什么使用ResNet?
    • 梯度问题是怎么被解决的?
    • 残差块的一般结构
    • 输入输出通道数不同的残差块
    • 了解ResNet的结构(ResNet-18, ResNet-50)
  • Incpetion 网络
    • 1x1卷积
    • 用1x1卷积减少计算量
    • Inception网络的基本模块
  • MobileNet
    • 逐深度可分卷积
    • MobileNet v2中的瓶颈结构

这节课介绍的都是比较前沿的CNN架构。在深度学习技术日新月异的今天,最好的学习方式是读论文,尽快一步一步跟上最新的技术。这堂课中提及的比较新的几篇论文,都有很高的阅读价值。

我打算在学完CNN的四周课后,暂时不去学第五门课,而是去阅读这些经典CNN论文并分享一下笔记。

在这周的代码实战里,我会分享一下如何用TensorFlow和PyTorch编写ResNet,同时介绍两种框架的进阶用法。

在之前的文章中,我介绍了如何用NumPy实现卷积正向传播
在这篇文章里,我会继续介绍如何用NumPy复现二维卷积的反向传播,并用PyTorch来验证结果的正确性。通过阅读这篇文章,大家不仅能进一步理解卷积的实现原理,更能领悟到一般算子的反向传播实现是怎么推导、编写出来的。

项目网址:https://github.com/SingleZombie/DL-Demos/tree/master/dldemos/BasicCNN

本文代码在dldemos/BasicCNN/np_conv_backward.py这个文件里。

实现思路

回忆一下,在正向传播中,我们是这样做卷积运算的:

1
2
3
4
5
6
7
8
9
10
11
for i_h in range(h_o):
for i_w in range(w_o):
for i_c in range(c_o):
h_lower = i_h * stride
h_upper = i_h * stride + f
w_lower = i_w * stride
w_upper = i_w * stride + f
input_slice = input_pad[h_lower:h_upper, w_lower:w_upper, :]
kernel_slice = weight[i_c]
output[i_h, i_w, i_c] = np.sum(input_slice * kernel_slice)
output[i_h, i_w, i_c] += bias[i_c]

我们遍历输出图像的每一个位置,选择该位置对应的输入图像切片和卷积核,做一遍乘法,再加上bias。

其实,一轮运算写成数学公式的话,就是一个线性函数y=wx+b。对w, x, b求导非常简单:

1
2
3
dw_i = x * dy
dx_i = w * dy
db_i = dy

在反向传播中,我们只需要遍历所有这样的线性运算,计算这轮运算对各参数的导数的贡献即可。最后,累加所有的贡献,就能得到各参数的导数。当然,在用代码实现这段逻辑时,可以不用最后再把所有贡献加起来,而是一算出来就加上。

1
2
3
dw += x * dy
dx += w * dy
db += dy

这里要稍微补充一点。在前向传播的实现中,我加入了dilation, groups这两个参数。为了简化反向传播的实现代码,只展示反向传播中最精华的部分,我在这份卷积实现中没有使用这两个参数。

代码实现

在开始实现反向传播之前,我们先思考一个问题:反向传播的函数应该有哪些参数?从数学上来讲,反向传播和正向传播的参数是相反的。设正向传播的输入是A_prev, W, b(输入图像、卷积核组、偏差),则应该输出Z(输出图像)。那么,在反向传播中,应该输入dZ,输出dA_prev, dW, db。可是,在写代码时,我们还需要一些其他的输入参数。

我的反向传播函数的函数定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def conv2d_backward(dZ: np.ndarray, cache: Dict[str, np.ndarray], stride: int,
padding: int) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
"""2D Convolution Backward Implemented with NumPy

Args:
dZ: (np.ndarray): The derivative of the output of conv.
cache (Dict[str, np.ndarray]): Record output 'Z', weight 'W', bias 'b'
and input 'A_prev' of forward function.
stride (int): Stride for convolution.
padding (int): The count of zeros to pad on both sides.

Outputs:
Tuple[np.ndarray, np.ndarray, np.ndarray]: The derivative of W, b,
A_prev.
"""

虽然我这里把所有参数都写在了一起,但从逻辑上来看,这些参数应该分成三个类别。在编程框架中,这三类参数会储存在不同的地方。

  • dZ: 反向传播函数真正的输入。
  • cache: 正向传播中的一些中间变量Z, W, b。由于我们必须在一个独立的函数里完成反向传播,这些中间变量得以输入参数的形式供函数访问。
  • stride, padding: 这两个参数是卷积的属性。如果卷积层是用一个类表示的话,这些参数应该放在类属性里,而不应该放在反向传播的输入里。

给定这三类参数,就足以完成反向传播计算了。下面我来介绍conv2d_backward的具体实现。

首先,获取cache中的参数,并且新建储存梯度的张量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
W = cache['W']
b = cache['b']
A_prev = cache['A_prev']
dW = np.zeros(W.shape)
db = np.zeros(b.shape)
dA_prev = np.zeros(A_prev.shape)

_, _, c_i = A_prev.shape
c_o, f, f_2, c_k = W.shape
h_o, w_o, c_o_2 = dZ.shape

assert (f == f_2)
assert (c_i == c_k)
assert (c_o == c_o_2)

之后,为了实现填充操作,我们要把A_prevdA_prev都填充一下。注意,算完了所有梯度后,别忘了要重新把dA_prevdA_prev_pad里抠出来。

1
2
3
4
A_prev_pad = np.pad(A_prev, [(padding, padding), (padding, padding),
(0, 0)])
dA_prev_pad = np.pad(dA_prev, [(padding, padding), (padding, padding),
(0, 0)])

接下来,就是梯度的计算了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
for i_h in range(h_o):
for i_w in range(w_o):
for i_c in range(c_o):
h_lower = i_h * stride
h_upper = i_h * stride + f
w_lower = i_w * stride
w_upper = i_w * stride + f

input_slice = A_prev_pad[h_lower:h_upper, w_lower:w_upper, :]
# forward
# kernel_slice = W[i_c]
# Z[i_h, i_w, i_c] = np.sum(input_slice * kernel_slice)
# Z[i_h, i_w, i_c] += b[i_c]

# backward
dW[i_c] += input_slice * dZ[i_h, i_w, i_c]
dA_prev_pad[h_lower:h_upper,
w_lower:w_upper, :] += W[i_c] * dZ[i_h, i_w, i_c]
db[i_c] += dZ[i_h, i_w, i_c]

在算导数时,我们应该对照着正向传播的计算,算出每一条计算对导数的贡献。如前文所述,卷积操作只是一个简单的y=wx+b,把对应的w, x, b从变量里正确地取出来并做运算即可。

最后,要把这些导数返回。别忘了把填充后的dA_prev恢复一下。

1
2
3
4
5
if padding > 0:
dA_prev = dA_prev_pad[padding:-padding, padding:-padding, :]
else:
dA_prev = dA_prev_pad
return dW, db, dA_prev

这里有一个细节:如果padding==0,则在取切片时范围会变成[0:-0],这样会取出一个长度为0的切片,而不是我们期望的原长度的切片。因此,要特判一下padding<=0的情况。

单元测试

为了方便地进行单元测试,我使用了pytest这个单元测试库。可以直接pip一键安装:

1
pip install pytest

之后就可以用pytest执行我的这份代码,代码里所有以test_开头的函数会被认为是单元测试的主函数。

1
pytest dldemos/BasicCNN/np_conv_backward.py

单元测试函数的定义如下:

1
2
3
4
5
@pytest.mark.parametrize('c_i, c_o', [(3, 6), (2, 2)])
@pytest.mark.parametrize('kernel_size', [3, 5])
@pytest.mark.parametrize('stride', [1, 2])
@pytest.mark.parametrize('padding', [0, 1])
def test_conv(c_i: int, c_o: int, kernel_size: int, stride: int, padding: str):

@pytest.mark.parametrize用于设置单元测试参数的可选值。我设置了4组参数,每组参数有2个可选值,经过排列组合后可以生成2^4=16个单元测试,pytest会自动帮我们执行不同的测试。

在单元测试中,我打算测试conv2d在各种输入通道数、输出通道数、卷积核大小、步幅、填充数的情况。

测试函数是这样写的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
def test_conv(c_i: int, c_o: int, kernel_size: int, stride: int, padding: str):

# Preprocess
input = np.random.randn(20, 20, c_i)
weight = np.random.randn(c_o, kernel_size, kernel_size, c_i)
bias = np.random.randn(c_o)

torch_input = torch.from_numpy(np.transpose(
input, (2, 0, 1))).unsqueeze(0).requires_grad_()
torch_weight = torch.from_numpy(np.transpose(
weight, (0, 3, 1, 2))).requires_grad_()
torch_bias = torch.from_numpy(bias).requires_grad_()

# forward
torch_output_tensor = torch.conv2d(torch_input, torch_weight, torch_bias,
stride, padding)
torch_output = np.transpose(
torch_output_tensor.detach().numpy().squeeze(0), (1, 2, 0))

cache = conv2d_forward(input, weight, bias, stride, padding)
numpy_output = cache['Z']

assert np.allclose(torch_output, numpy_output)

# backward
torch_sum = torch.sum(torch_output_tensor)
torch_sum.backward()
torch_dW = np.transpose(torch_weight.grad.numpy(), (0, 2, 3, 1))
torch_db = torch_bias.grad.numpy()
torch_dA_prev = np.transpose(torch_input.grad.numpy().squeeze(0),
(1, 2, 0))

dZ = np.ones(numpy_output.shape)
dW, db, dA_prev = conv2d_backward(dZ, cache, stride, padding)

assert np.allclose(dW, torch_dW)
assert np.allclose(db, torch_db)
assert np.allclose(dA_prev, torch_dA_prev)

整个测试函数可以分成三部分:变量预处理、前向传播、反向传播。在前向传播和反向传播中,我们要分别用刚编写的卷积核PyTorch中的卷积进行计算,并比较两个运算结果是否相同。

预处理时,我们要创建NumPy和PyTorch的输入。

1
2
3
4
5
6
7
8
9
10
# Preprocess
input = np.random.randn(20, 20, c_i)
weight = np.random.randn(c_o, kernel_size, kernel_size, c_i)
bias = np.random.randn(c_o)

torch_input = torch.from_numpy(np.transpose(
input, (2, 0, 1))).unsqueeze(0).requires_grad_()
torch_weight = torch.from_numpy(np.transpose(
weight, (0, 3, 1, 2))).requires_grad_()
torch_bias = torch.from_numpy(bias).requires_grad_()

之后是正向传播。计算结果和中间变量会被存入cache中。

1
2
3
4
5
6
7
8
9
10
# forward
torch_output_tensor = torch.conv2d(torch_input, torch_weight, torch_bias,
stride, padding)
torch_output = np.transpose(
torch_output_tensor.detach().numpy().squeeze(0), (1, 2, 0))

cache = conv2d_forward(input, weight, bias, stride, padding)
numpy_output = cache['Z']

assert np.allclose(torch_output, numpy_output)

最后是反向传播。在那之前,要补充说明一下如何在PyTorch里手动求一些数据的导数。在PyTorch中,各个张量默认是不可训练的。为了让框架知道我们想求哪几个参数的导数,我们要执行张量的required_grad_()方法,如:

1
2
torch_input = torch.from_numpy(np.transpose(
input, (2, 0, 1))).unsqueeze(0).requires_grad_()

这样,在正向传播时,PyTorch就会自动把对可训练参数的运算搭成计算图了。

正向传播后,对结果张量调用backward()即可执行反向传播。但是,PyTorch要求调用backward()的张量必须是一个标量,也就是它不能是矩阵,不能是任何长度大于1的数据。而这里PyTorch的卷积结果又是一个四维张量。因此,我把PyTorch卷积结果做了求和,得到了一个标量,用它来调用backward()

1
2
torch_sum = torch.sum(torch_output_tensor)
torch_sum.backward()

这样,就可以用tensor.grad获取tensor的导数了,如

1
2
3
torch_weight.grad
torch_bias.grad
torch_input.grad

整个反向传播测试的代码如下。

1
2
3
4
5
6
7
8
9
10
# backward
torch_sum = torch.sum(torch_output_tensor)
torch_sum.backward()
torch_dW = np.transpose(torch_weight.grad.numpy(), (0, 2, 3, 1))
torch_db = torch_bias.grad.numpy()
torch_dA_prev = np.transpose(torch_input.grad.numpy().squeeze(0),
(1, 2, 0))

dZ = np.ones(numpy_output.shape)
dW, db, dA_prev = conv2d_backward(dZ, cache, stride, padding)

再补充一下,在求导时,运算结果的导数是1。因此,新建dZ时,我用的是np.ones(全1张量)。同理,PyTorch也会默认运算结果的导数为1,即这里torch_sum.grad==1。而执行加法运算不会改变导数,所以torch_output_tensor.grad也是一个全是1的张量,和NumPy的dZ的值是一模一样的。

写完单元测试函数后,运行前面提到的单元测试命令,pytest就会输出很多测试的结果。

1
pytest dldemos/BasicCNN/np_conv_backward.py

如果看到了类似的输出,就说明我们的代码是正确的。

1
==== 16 passed in 1.04s ====

反向传播的编写思路

通过阅读上面的实现过程,相信大家已经明白如何编写卷积的反向传播了。接下来,我将总结一下实现一般算子的正向、反向传播的思路。无论是用NumPy,还是PyTorch等编程框架,甚至是纯C++,这种思路都是适用的。

一开始,我们要明白,一个算子总共会涉及到这些参数:

  • 输入与输出:算子的输入张量和输出张量。正向传播和反向传播的输入输出恰好是相反的。
  • 属性:算子的超参数。比如卷积的stride, padding
  • 中间变量:前向传播传递给反向传播的变量。

一般情况下,我们应该编写一个算子类。在初始化算子类时,算子的属性就以类属性的形式存储下来了。

在正向传播时,我们按照算子定义直接顺着写下去就行。这个时候,可以先准备好cache变量,但先不去管它,等写到反向传播的时候再处理。

接着,编写反向传播。由于反向传播和正向传播的运算步骤相似,我们可以直接把正向传播的代码复制一份。在这个基础上,思考每一步正向传播运算产生了哪些导数,对照着写出导数计算的代码即可。这时,我们会用到一些正向传播的中间结果,这下就可以去正向传播代码里填写cache,在反向传播里取出来了。

最后,写完了算子,一定要做单元测试。如果该算子有现成的实现,用现成的实现来对齐运算结果是最简单的一种实现单元测试的方式。

总结

在这篇文章中,我介绍了以下内容:

  • 卷积反向传播的NumPy实现
  • 如何用PyTorch手动求导
  • 如何编写完整的算子单元测试
  • 实现算子正向传播、反向传播的思路

如果你也想把代码基础打牢,一定一定要像这样自己动手从头写一份代码。在写代码,调bug的过程中,一定会有很多收获。

由于现在的编程框架都比较成熟,搞科研时基本不会碰到自己动手写底层算子的情况。但是,如果你想出了一个特别棒的idea,想出了一个全新的神经网络模块,却在写代码时碰到了阻碍,那可就太可惜了。学一学反向传播的实现还是很有用的。

在模型部署中,反向传播可能完全派不上用场。但是,一般框架在实现算子的正向传播时,是会照顾反向传播的。也就是说,如果抛掉反向传播,正向传播的实现或许可以写得更加高效。这样看来,了解反向传播的实现也是很有帮助的。我们可以用这些知识看懂别人的正向传播、反向传播的实现,进而优化代码的性能。

附录:完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
from typing import Dict, Tuple

import numpy as np
import pytest
import torch


def conv2d_forward(input: np.ndarray, weight: np.ndarray, bias: np.ndarray,
stride: int, padding: int) -> Dict[str, np.ndarray]:
"""2D Convolution Forward Implemented with NumPy

Args:
input (np.ndarray): The input NumPy array of shape (H, W, C).
weight (np.ndarray): The weight NumPy array of shape
(C', F, F, C).
bias (np.ndarray | None): The bias NumPy array of shape (C').
Default: None.
stride (int): Stride for convolution.
padding (int): The count of zeros to pad on both sides.

Outputs:
Dict[str, np.ndarray]: Cached data for backward prop.
"""
h_i, w_i, c_i = input.shape
c_o, f, f_2, c_k = weight.shape

assert (f == f_2)
assert (c_i == c_k)
assert (bias.shape[0] == c_o)

input_pad = np.pad(input, [(padding, padding), (padding, padding), (0, 0)])

def cal_new_sidelngth(sl, s, f, p):
return (sl + 2 * p - f) // s + 1

h_o = cal_new_sidelngth(h_i, stride, f, padding)
w_o = cal_new_sidelngth(w_i, stride, f, padding)

output = np.empty((h_o, w_o, c_o), dtype=input.dtype)

for i_h in range(h_o):
for i_w in range(w_o):
for i_c in range(c_o):
h_lower = i_h * stride
h_upper = i_h * stride + f
w_lower = i_w * stride
w_upper = i_w * stride + f
input_slice = input_pad[h_lower:h_upper, w_lower:w_upper, :]
kernel_slice = weight[i_c]
output[i_h, i_w, i_c] = np.sum(input_slice * kernel_slice)
output[i_h, i_w, i_c] += bias[i_c]

cache = dict()
cache['Z'] = output
cache['W'] = weight
cache['b'] = bias
cache['A_prev'] = input
return cache


def conv2d_backward(dZ: np.ndarray, cache: Dict[str, np.ndarray], stride: int,
padding: int) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
"""2D Convolution Backward Implemented with NumPy

Args:
dZ: (np.ndarray): The derivative of the output of conv.
cache (Dict[str, np.ndarray]): Record output 'Z', weight 'W', bias 'b'
and input 'A_prev' of forward function.
stride (int): Stride for convolution.
padding (int): The count of zeros to pad on both sides.

Outputs:
Tuple[np.ndarray, np.ndarray, np.ndarray]: The derivative of W, b,
A_prev.
"""
W = cache['W']
b = cache['b']
A_prev = cache['A_prev']
dW = np.zeros(W.shape)
db = np.zeros(b.shape)
dA_prev = np.zeros(A_prev.shape)

_, _, c_i = A_prev.shape
c_o, f, f_2, c_k = W.shape
h_o, w_o, c_o_2 = dZ.shape

assert (f == f_2)
assert (c_i == c_k)
assert (c_o == c_o_2)

A_prev_pad = np.pad(A_prev, [(padding, padding), (padding, padding),
(0, 0)])
dA_prev_pad = np.pad(dA_prev, [(padding, padding), (padding, padding),
(0, 0)])

for i_h in range(h_o):
for i_w in range(w_o):
for i_c in range(c_o):
h_lower = i_h * stride
h_upper = i_h * stride + f
w_lower = i_w * stride
w_upper = i_w * stride + f

input_slice = A_prev_pad[h_lower:h_upper, w_lower:w_upper, :]
# forward
# kernel_slice = W[i_c]
# Z[i_h, i_w, i_c] = np.sum(input_slice * kernel_slice)
# Z[i_h, i_w, i_c] += b[i_c]

# backward
dW[i_c] += input_slice * dZ[i_h, i_w, i_c]
dA_prev_pad[h_lower:h_upper,
w_lower:w_upper, :] += W[i_c] * dZ[i_h, i_w, i_c]
db[i_c] += dZ[i_h, i_w, i_c]

if padding > 0:
dA_prev = dA_prev_pad[padding:-padding, padding:-padding, :]
else:
dA_prev = dA_prev_pad
return dW, db, dA_prev


@pytest.mark.parametrize('c_i, c_o', [(3, 6), (2, 2)])
@pytest.mark.parametrize('kernel_size', [3, 5])
@pytest.mark.parametrize('stride', [1, 2])
@pytest.mark.parametrize('padding', [0, 1])
def test_conv(c_i: int, c_o: int, kernel_size: int, stride: int, padding: str):

# Preprocess
input = np.random.randn(20, 20, c_i)
weight = np.random.randn(c_o, kernel_size, kernel_size, c_i)
bias = np.random.randn(c_o)

torch_input = torch.from_numpy(np.transpose(
input, (2, 0, 1))).unsqueeze(0).requires_grad_()
torch_weight = torch.from_numpy(np.transpose(
weight, (0, 3, 1, 2))).requires_grad_()
torch_bias = torch.from_numpy(bias).requires_grad_()

# forward
torch_output_tensor = torch.conv2d(torch_input, torch_weight, torch_bias,
stride, padding)
torch_output = np.transpose(
torch_output_tensor.detach().numpy().squeeze(0), (1, 2, 0))

cache = conv2d_forward(input, weight, bias, stride, padding)
numpy_output = cache['Z']

assert np.allclose(torch_output, numpy_output)

# backward
torch_sum = torch.sum(torch_output_tensor)
torch_sum.backward()
torch_dW = np.transpose(torch_weight.grad.numpy(), (0, 2, 3, 1))
torch_db = torch_bias.grad.numpy()
torch_dA_prev = np.transpose(torch_input.grad.numpy().squeeze(0),
(1, 2, 0))

dZ = np.ones(numpy_output.shape)
dW, db, dA_prev = conv2d_backward(dZ, cache, stride, padding)

assert np.allclose(dW, torch_dW)
assert np.allclose(db, torch_db)
assert np.allclose(dA_prev, torch_dA_prev)

《深度学习专项》只介绍了卷积的stride, padding这两个参数。实际上,编程框架中常用的卷积还有其他几个参数。在这篇文章里,我会介绍如何用NumPy复现PyTorch中的二维卷积torch.conv2d的前向传播。如果大家也想多学一点的话,建议看完本文后也自己动手写一遍卷积,彻底理解卷积中常见的参数。

项目网址:https://github.com/SingleZombie/DL-Demos/tree/master/dldemos/BasicCNN

本文代码在dldemos/BasicCNN/np_conv.py这个文件里。

卷积参数介绍

torch.conv2d类似,在这份实现中,我们的卷积应该有类似如下的函数定义(张量的形状写在docstring中):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def conv2d(input: np.ndarray,
weight: np.ndarray,
stride: int,
padding: int,
dilation: int,
groups: int,
bias: np.ndarray = None) -> np.ndarray:
"""2D Convolution Implemented with NumPy

Args:
input (np.ndarray): The input NumPy array of shape (H, W, C).
weight (np.ndarray): The weight NumPy array of shape
(C', F, F, C / groups).
stride (int): Stride for convolution.
padding (int): The count of zeros to pad on both sides.
dilation (int): The space between kernel elements.
groups (int): Split the input to groups.
bias (np.ndarray | None): The bias NumPy array of shape (C').
Default: None.

Outputs:
np.ndarray: The output NumPy array of shape (H', W', C')
"""

我们知道,对于不加任何参数的卷积,其计算方式如下:

此图中,下面蓝色的区域是一张$4 \times 4$的输入图片,输入图片上深蓝色的区域是一个$3 \times 3$的卷积核。这样,会生成上面那个$2 \times 2$的绿色的输出图片。每轮计算输出图片上一个深绿色的元素时,卷积核所在位置会标出来。

接下来,使用类似图例,我们来看看卷积各参数的详细解释。

stride(步幅)

每轮计算后,卷积核向右或向下移动多格,而不仅仅是1格。每轮移动的格子数用stride表示。上图是stride=2的情况。

padding(填充数)

卷积开始前,向输入图片四周填充数字(最常见的情况是填充0),填充的数字个数用padding表示。这样,输出图片的边长会更大一些。一般我们会为了让输出图片和输入图片一样大而调整padding,比如上图那种padding=1的情况。

dilation(扩充数)

被卷积的相邻像素之间有间隔,这个间隔等于dilation。等价于在卷积核相邻位置之间填0,再做普通的卷积。上图是dilation=2的情况。

dliated convolution 被翻译成空洞卷积。

groups(分组数)

下图展示了输入通道数12,输出通道数6的卷积在两种不同groups下的情况。左边是group=1的普通卷积,右边是groups=3的分组卷积。在具体看分组卷积的介绍前,大家可以先仔细观察这张图,看看能不能猜出分组卷积是怎么运算的。

当输入图片有多个通道时,卷积核也应该有相同数量的通道。输入图片的形状是(H, W, C)的话,卷积核的形状就应该是(f, f, C)。

但是,这样一轮运算只能算出一张单通道的图片。为了算多通道的图片,应该使用多个卷积核。因此,如果输入图片的形状是(H, W, C),想要生成(H, W, C’)的输出图片,则应该有C’个形状为(f, f, C)的卷积核,或者说卷积核组的形状是(C’, f, f, C)。

如分组卷积示意图的左图所示,对于普通卷积,每一个输出通道都需要用到所有输入通道的数据。为了减少计算量,我们可以把输入通道和输出通道分组。每组的输出通道仅由该组的输入通道决定。如示意图的右图所示,我们令分组数groups=3,这样,一共有6个卷积核,每组的输入通道有4个,输出通道有2个(即使用2个卷积核)。这时候,卷积核组的形状应该是(C’=6, f, f, C=4)。

groups最常见的应用是令groups=C,即depth-wise convolution。《深度学习专项》第四门课第二周会介绍有关的知识。

代码实现

理解了所有参数,下面让我们来用NumPy实现这样一个卷积。

完整的代码是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
def conv2d(input: np.ndarray,
weight: np.ndarray,
stride: int,
padding: int,
dilation: int,
groups: int,
bias: np.ndarray = None) -> np.ndarray:
"""2D Convolution Implemented with NumPy

Args:
input (np.ndarray): The input NumPy array of shape (H, W, C).
weight (np.ndarray): The weight NumPy array of shape
(C', F, F, C / groups).
stride (int): Stride for convolution.
padding (int): The count of zeros to pad on both sides.
dilation (int): The space between kernel elements.
groups (int): Split the input to groups.
bias (np.ndarray | None): The bias NumPy array of shape (C').
Default: None.

Outputs:
np.ndarray: The output NumPy array of shape (H', W', C')
"""
h_i, w_i, c_i = input.shape
c_o, f, f_2, c_k = weight.shape

assert (f == f_2)
assert (c_i % groups == 0)
assert (c_o % groups == 0)
assert (c_i // groups == c_k)
if bias is not None:
assert (bias.shape[0] == c_o)

f_new = f + (f - 1) * (dilation - 1)
weight_new = np.zeros((c_o, f_new, f_new, c_k), dtype=weight.dtype)
for i_c_o in range(c_o):
for i_c_k in range(c_k):
for i_f in range(f):
for j_f in range(f):
i_f_new = i_f * dilation
j_f_new = j_f * dilation
weight_new[i_c_o, i_f_new, j_f_new, i_c_k] = \
weight[i_c_o, i_f, j_f, i_c_k]

input_pad = np.pad(input, [(padding, padding), (padding, padding), (0, 0)])

def cal_new_sidelngth(sl, s, f, p):
return (sl + 2 * p - f) // s + 1

h_o = cal_new_sidelngth(h_i, stride, f_new, padding)
w_o = cal_new_sidelngth(w_i, stride, f_new, padding)

output = np.empty((h_o, w_o, c_o), dtype=input.dtype)

c_o_per_group = c_o // groups

for i_h in range(h_o):
for i_w in range(w_o):
for i_c in range(c_o):
i_g = i_c // c_o_per_group
h_lower = i_h * stride
h_upper = i_h * stride + f_new
w_lower = i_w * stride
w_upper = i_w * stride + f_new
c_lower = i_g * c_k
c_upper = (i_g + 1) * c_k
input_slice = input_pad[h_lower:h_upper, w_lower:w_upper,
c_lower:c_upper]
kernel_slice = weight_new[i_c]
output[i_h, i_w, i_c] = np.sum(input_slice * kernel_slice)
if bias:
output[i_h, i_w, i_c] += bias[i_c]
return output

先回顾一下我们要用到的参数。

1
2
3
4
5
6
7
def conv2d(input: np.ndarray,
weight: np.ndarray,
stride: int,
padding: int,
dilation: int,
groups: int,
bias: np.ndarray = None) -> np.ndarray:

再次提醒,input的形状是(H, W, C),卷积核组weight的形状是(C', H, W, C_k)。其中C_k = C / groups。同时C'也必须能够被groups整除。bias的形状是(C')

一开始,把要用到的形状从shape里取出来,并检查一下形状是否满足要求。

1
2
3
4
5
6
7
8
9
h_i, w_i, c_i = input.shape
c_o, f, f_2, c_k = weight.shape

assert (f == f_2)
assert (c_i % groups == 0)
assert (c_o % groups == 0)
assert (c_i // groups == c_k)
if bias is not None:
assert (bias.shape[0] == c_o)

回忆一下,空洞卷积可以用卷积核扩充实现。因此,在开始卷积前,可以先预处理好扩充后的卷积核。我们先算好扩充后卷积核的形状,并创建好新的卷积核,最后用多重循环给新卷积核赋值。

1
2
3
4
5
6
7
8
9
10
f_new = f + (f - 1) * (dilation - 1)
weight_new = np.zeros((c_o, f_new, f_new, c_k), dtype=weight.dtype)
for i_c_o in range(c_o):
for i_c_k in range(c_k):
for i_f in range(f):
for j_f in range(f):
i_f_new = i_f * dilation
j_f_new = j_f * dilation
weight_new[i_c_o, i_f_new, j_f_new, i_c_k] = \
weight[i_c_o, i_f, j_f, i_c_k]

接下来,我们要考虑padding。np.pad就是填充操作使用的函数。该函数第一个参数是输入,第二个参数是填充数量,要分别写出每个维度上左上和右下的填充数量。我们只填充图片的前两维,并且左上和右下填的数量一样多。因此,填充的写法如下:

1
input_pad = np.pad(input, [(padding, padding), (padding, padding), (0, 0)])

预处理都做好了,马上要开始卷积计算了。在计算开始前,我们还要把算出输出张量的形状并将其初始化。

1
2
3
4
5
6
7
def cal_new_sidelngth(sl, s, f, p):
return (sl + 2 * p - f) // s + 1

h_o = cal_new_sidelngth(h_i, stride, f_new, padding)
w_o = cal_new_sidelngth(w_i, stride, f_new, padding)

output = np.empty((h_o, w_o, c_o), dtype=input.dtype)

为严谨起见,我这里用统一的函数计算了卷积后的宽高。不考虑dilation的边长公式由cal_new_sidelngth表示。如果对这个公式不理解,可以自己推一推。而考虑dilation时,只需要把原来的卷积核长度f换成新卷积核长度f_new即可。

初始化output时,我没有像前面初始化weight_new一样使用np.zeros,而是用了np.empty。这是因为weight_new会有一些地方不被访问到,这些地方都应该填0。而output每一个元素都会被访问到并赋值,可以不用令它们初值为0。理论上,np.empty这种不限制初值的初始化方式是最快的,只是使用时一定别忘了要先给每个元素赋值。这种严谨的算法实现思维还是挺重要的,尤其是在用C++实现高性能的底层算法时。

终于,可以进行卷积计算了。这部分的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
c_o_per_group = c_o // groups

for i_h in range(h_o):
for i_w in range(w_o):
for i_c in range(c_o):
i_g = i_c // c_o_per_group
h_lower = i_h * stride
h_upper = i_h * stride + f_new
w_lower = i_w * stride
w_upper = i_w * stride + f_new
c_lower = i_g * c_k
c_upper = (i_g + 1) * c_k
input_slice = input_pad[h_lower:h_upper, w_lower:w_upper,
c_lower:c_upper]
kernel_slice = weight_new[i_c]
output[i_h, i_w, i_c] = np.sum(input_slice * kernel_slice)
if bias:
output[i_h, i_w, i_c] += bias[i_c]

来一点一点看这段代码。

c_o_per_group = c_o // groups预处理了每组的输出通道数,后面会用到这个数。

为了填入输出张量每一处的值,我们应该遍历输出张量的每一个元素的下标:

1
2
3
for i_h in range(h_o):
for i_w in range(w_o):
for i_c in range(c_o):

做卷积时,我们要获取两个东西:被卷积的原图像上的数据、卷积用的卷积核。所以,下一步应该去获取原图像上的数据切片。这个切片可以这样表示

1
2
input_slice = input_pad[h_lower:h_upper, w_lower:w_upper,
c_lower:c_upper]

宽和高上的截取范围很好计算。只要根据stride确认截取起点,再加上f_new就得到了截取终点。

1
2
3
4
h_lower = i_h * stride
h_upper = i_h * stride + f_new
w_lower = i_w * stride
w_upper = i_w * stride + f_new

比较难想的是考虑groups后,通道上的截取范围该怎么获得。这里,不妨再看一次分组卷积的示意图:

获取通道上的截取范围,就是获取右边那幅图中的输入通道组。究竟是红色的1-4,还是绿色的5-8,还是黄色的9-12。为了知道是哪一个范围,我们要算出当前输出通道对应的组号(颜色),这个组号由下面的算式获得:

1
i_g = i_c // c_o_per_group

有了组号,就可以方便地计算通道上的截取范围了。

1
2
c_lower = i_g * c_k
c_upper = (i_g + 1) * c_k

整个获取输入切片的代码如下:

1
2
3
4
5
6
7
8
9
i_g = i_c // c_o_per_group
h_lower = i_h * stride
h_upper = i_h * stride + f_new
w_lower = i_w * stride
w_upper = i_w * stride + f_new
c_lower = i_g * c_k
c_upper = (i_g + 1) * c_k
input_slice = input_pad[h_lower:h_upper, w_lower:w_upper,
c_lower:c_upper]

而卷积核就很容易获取了,直接选中第i_c个卷积核即可:

1
kernel_slice = weight_new[i_c]

最后是卷积运算,别忘了加上bias。

1
2
3
output[i_h, i_w, i_c] = np.sum(input_slice * kernel_slice)
if bias:
output[i_h, i_w, i_c] += bias[i_c]

写完了所有东西,返回输出结果。

1
return output

单元测试

为了方便地进行单元测试,我使用了pytest这个单元测试库。可以直接pip一键安装:

1
pip install pytest

之后就可以用pytest执行我的这份代码,代码里所有以test_开头的函数会被认为是单元测试的主函数。

1
pytest dldemos/BasicCNN/np_conv.py

完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@pytest.mark.parametrize('c_i, c_o', [(3, 6), (2, 2)])
@pytest.mark.parametrize('kernel_size', [3, 5])
@pytest.mark.parametrize('stride', [1, 2])
@pytest.mark.parametrize('padding', [0, 1])
@pytest.mark.parametrize('dilation', [1, 2])
@pytest.mark.parametrize('groups', ['1', 'all'])
@pytest.mark.parametrize('bias', [False])
def test_conv(c_i: int, c_o: int, kernel_size: int, stride: int, padding: str,
dilation: int, groups: str, bias: bool):
if groups == '1':
groups = 1
elif groups == 'all':
groups = c_i

if bias:
bias = np.random.randn(c_o)
torch_bias = torch.from_numpy(bias)
else:
bias = None
torch_bias = None

input = np.random.randn(20, 20, c_i)
weight = np.random.randn(c_o, kernel_size, kernel_size, c_i // groups)

torch_input = torch.from_numpy(np.transpose(input, (2, 0, 1))).unsqueeze(0)
torch_weight = torch.from_numpy(np.transpose(weight, (0, 3, 1, 2)))
torch_output = torch.conv2d(torch_input, torch_weight, torch_bias, stride,
padding, dilation, groups).numpy()
torch_output = np.transpose(torch_output.squeeze(0), (1, 2, 0))

numpy_output = conv2d(input, weight, stride, padding, dilation, groups,
bias)

assert np.allclose(torch_output, numpy_output)

其中,单元测试函数的定义如下:

1
2
3
4
5
6
7
8
9
@pytest.mark.parametrize('c_i, c_o', [(3, 6), (2, 2)])
@pytest.mark.parametrize('kernel_size', [3, 5])
@pytest.mark.parametrize('stride', [1, 2])
@pytest.mark.parametrize('padding', [0, 1])
@pytest.mark.parametrize('dilation', [1, 2])
@pytest.mark.parametrize('groups', ['1', 'all'])
@pytest.mark.parametrize('bias', [False])
def test_conv(c_i: int, c_o: int, kernel_size: int, stride: int, padding: str,
dilation: int, groups: str, bias: bool):

先别管上面那一堆装饰器,先看一下单元测试中的输入参数。在对某个函数进行单元测试时,要测试该函数的参数在不同取值下的表现。我打算测试我们的conv2d在各种输入通道数、输出通道数、卷积核大小、步幅、填充数、扩充数、分组数、是否加入bias的情况。

@pytest.mark.parametrize用于设置单元测试参数的可选值。我设置了6组参数,每组参数有2个可选值,经过排列组合后可以生成2^6=64个单元测试,pytest会自动帮我们执行不同的测试。

在测试函数内,我先预处理了一下输入的参数,并生成了随机的输入张量,使这些参数和conv2d的参数一致。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def test_conv(c_i: int, c_o: int, kernel_size: int, stride: int, padding: str,
dilation: int, groups: str, bias: bool):
if groups == '1':
groups = 1
elif groups == 'all':
groups = c_i

if bias:
bias = np.random.randn(c_o)
torch_bias = torch.from_numpy(bias)
else:
bias = None
torch_bias = None

input = np.random.randn(20, 20, c_i)
weight = np.random.randn(c_o, kernel_size, kernel_size, c_i // groups)

为了确保我们实现的卷积和torch.conv2d是对齐的,我们要用torch.conv2d算一个结果,作为正确的参考值。

1
2
3
4
5
torch_input = torch.from_numpy(np.transpose(input, (2, 0, 1))).unsqueeze(0)
torch_weight = torch.from_numpy(np.transpose(weight, (0, 3, 1, 2)))
torch_output = torch.conv2d(torch_input, torch_weight, torch_bias, stride,
padding, dilation, groups).numpy()
torch_output = np.transpose(torch_output.squeeze(0), (1, 2, 0))

由于torch里张量的形状格式是NCHW,weight的形状是C’Cff,我这里做了一些形状上的转换。

之后,调用我们自己的卷积函数:

1
2
numpy_output = conv2d(input, weight, stride, padding, dilation, groups,
bias)

最后,验证一下两个结果是否对齐:

1
assert np.allclose(torch_output, numpy_output)

运行前面提到的单元测试命令,pytest会输出很多测试的结果。

1
pytest dldemos/BasicCNN/np_conv.py

如果看到了类似的输出,就说明我们的代码是正确的。

1
========== 64 passed in 1.20s ===============

总结

在这篇文章中,我介绍了torch.conv2d的等价NumPy实现。同时,我还详细说明了卷积各参数(stride, padding, dilation, groups)的意义。通过阅读本文,相信大家能够深刻地理解一轮卷积是怎么完成的。

如果你也想把这方面的基础打牢,一定一定要自己动手从头写一份代码。在写代码,调bug的过程中,一定会有很多收获。

相比torch里的卷积,这份卷积实现还不够灵活。torch里可以自由输入卷积核的宽高、stride的宽高。而我们默认卷积核是正方形,宽度和高度上的stride是一样的。不过,要让卷积更灵活一点,只需要稍微修改一些预处理数据的代码即可,卷积的核心实现代码是不变的。

其实,在编程框架中,卷积的实现都是很高效的,不可能像我们这样先扩充卷积核,再填充输入图像。这些操作都会引入很多冗余的计算量。为了尽可能利用并行加速卷积的运算,卷积的GPU实现使用了一种叫做im2col的算法。这种算法会把每次卷积乘加用到的输入图像上的数据都放进列向量中,把卷积乘加转换成一次矩阵乘法。有兴趣的话欢迎搜索这方面的知识。

这篇文章仅介绍了卷积操作的正向传播。有了正向传播,反向传播倒没那么了难了。之后有时间的话我会再分享一篇用NumPy实现卷积反向传播的文章。

参考资料

本文中的动图来自于 https://github.com/vdumoulin/conv_arithmetic

本文中分组卷积的图来自于论文 https://www.researchgate.net/publication/321325862_CondenseNet_An_Efficient_DenseNet_using_Learned_Group_Convolutions

学完了CNN的基本构件,看完了用TensorFlow实现的CNN,让我们再用PyTorch来搭建一个CNN,并用这个网络完成之前那个简单的猫狗分类任务。

这份PyTorch实现会尽量和TensorFlow实现等价。同时,我也会分享编写此项目过程中发现的PyTorch与TensorFlow的区别。

项目网址:https://github.com/SingleZombie/DL-Demos/tree/master/dldemos/BasicCNN

获取数据集

和之前几次的代码实战任务一样,我们这次还用的是Kaggle上的猫狗数据集。我已经写好了数据预处理的函数。使用如下的接口即可获取数据集:

1
2
3
4
5
6
train_X, train_Y, test_X, test_Y = get_cat_set(
'dldemos/LogisticRegression/data/archive/dataset',
train_size=1500,
format='nchw')
print(train_X.shape) # (m, 3, 224, 224)
print(train_Y.shape) # (m, 1)

这次的数据格式和之前项目中的有一些区别。

在使用全连接网络时,每一个输入样本都是一个一维向量。之前在预处理数据集时,我做了一个flatten操作,把图片的所有颜色值塞进了一维向量中。而在CNN中,对于卷积操作,每一个输入样本都是一个三维张量。用OpenCV读取完图片后,不用对图片Resize,直接拿过来用就可以了。

另外,在用NumPy实现时,我们把数据集大小N当作了最后一个参数;在用TensorFlow时,张量格式是”NHWC(数量-高度-宽度-通道数)”。而PyTorch中默认的张量格式是”NCHW(数量-通道数-高度-宽度)”。因此,在预处理数据集时,我令format='nchw'

初始化模型

根据课堂里讲的CNN构建思路,我搭了一个这样的网络。

由于这个二分类任务比较简单,我在设计时尽可能让可训练参数更少。刚开始用一个大步幅、大卷积核的卷积快速缩小图片边长,之后逐步让图片边长减半、深度翻倍。

这样一个网络用PyTorch实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
def init_model(device='cpu'):
model = nn.Sequential(nn.Conv2d(3, 16, 11, 3), nn.BatchNorm2d(16),
nn.ReLU(True), nn.MaxPool2d(2, 2),
nn.Conv2d(16, 32, 5), nn.BatchNorm2d(32),
nn.ReLU(True), nn.MaxPool2d(2, 2),
nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64),
nn.ReLU(True), nn.Conv2d(64, 64, 3),
nn.BatchNorm2d(64), nn.ReLU(True),
nn.MaxPool2d(2, 2), nn.Flatten(),
nn.Linear(3136, 2048), nn.ReLU(True),
nn.Linear(2048, 1), nn.Sigmoid()).to(device)

def weights_init(m):
if isinstance(m, nn.Conv2d):
torch.nn.init.xavier_normal_(m.weight)
m.bias.data.fill_(0)
elif isinstance(m, nn.BatchNorm2d):
m.weight.data.normal_(1.0, 0.02)
m.bias.data.fill_(0)
elif isinstance(m, nn.Linear):
torch.nn.init.xavier_normal_(m.weight)
m.bias.data.fill_(0)

model.apply(weights_init)

print(model)
return model

让我们从函数定义开始一点一点看起。

1
def init_model(device='cpu'):

在PyTorch中,所有张量所在的运算设备需要显式指定。我们的模型中带有可学习参数,这些参数都是张量。因此,在初始化模型时,我们要决定参数所在设备。最常见的设备是'cpu''cuda:0'。对于模块或者张量,使用x.to(device)即可让对象x中的数据迁移到设备device上。

接着,是初始化模型结构。

1
2
3
4
5
6
7
8
9
10
model = nn.Sequential(nn.Conv2d(3, 16, 11, 3), nn.BatchNorm2d(16),
nn.ReLU(True), nn.MaxPool2d(2, 2),
nn.Conv2d(16, 32, 5), nn.BatchNorm2d(32),
nn.ReLU(True), nn.MaxPool2d(2, 2),
nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64),
nn.ReLU(True), nn.Conv2d(64, 64, 3),
nn.BatchNorm2d(64), nn.ReLU(True),
nn.MaxPool2d(2, 2), nn.Flatten(),
nn.Linear(3136, 2048), nn.ReLU(True),
nn.Linear(2048, 1), nn.Sigmoid()).to(device)

torch.nn.Sequential()用于创建一个串行的网络(前一个模块的输出就是后一个模块的输入)。网络各模块用到的初始化参数的介绍如下:

  • Conv2d: 输入通道数、输出通道数、卷积核边长、步幅、填充个数padding。
  • BatchNormalization: 输入通道数。
  • ReLU: 一个bool值inplace。是否使用inplace,就和用a += 1还是a + 1一样,后者会多花一个中间变量来存结果。
  • MaxPool2d: 卷积核边长、步幅。
  • Linear(全连接层):输入通道数、输出通道数。

相比TensorFlow,PyTorch里的模块更独立一些,不能附加激活函数,不能直接直接写上初始化方法。

TensorFlow是静态图(会有一个类似“编译”的过程,把模块串起来),除了第一个模块外,后续模块都可以不指定输入通道数。而PyTorch是动态图,需要指定某些模块的输入通道数。

根据之前的设计,把参数填入这些模块即可。

由于PyTorch在初始化模块时不能自动初始化参数,我们要手动写上初始化参数的逻辑。

在此之前,要先认识一下torch.nn.Moduleapply函数。

1
model.apply(weights_init)

PyTorch的模型模块torch.nn.Module是自我嵌套的。一个torch.nn.Module的实例可能由多个torch.nn.Module的实例组成。model.apply(func)可以对某torch.nn.Module实例的所有某子模块执行func函数。我们使用的参数初始化函数叫做weights_init,所以用上面那行代码就可以初始化所有模块。

初始化参数函数是这样写的:

1
2
3
4
5
6
7
8
9
10
def weights_init(m):
if isinstance(m, nn.Conv2d):
torch.nn.init.xavier_normal_(m.weight)
m.bias.data.fill_(0)
elif isinstance(m, nn.BatchNorm2d):
m.weight.data.normal_(1.0, 0.02)
m.bias.data.fill_(0)
elif isinstance(m, nn.Linear):
torch.nn.init.xavier_normal_(m.weight)
m.bias.data.fill_(0)

其中,m就是子模块的示例。通过对其进行类型判断,我们可以对不同的模块执行不同的初始化方式。初始化的函数都在torch.nn.init,我这里用的是torch.nn.init.xavier_normal_

理论上写了batch normalization的话前一个模块就不用加bias。为了让代码稍微简单一点,我没有做这个优化。

模型初始化完后,调用print(model)可以查看网络各层的参数信息。

text
1
2
3
4
5
6
7
8
Sequential(
(0): Conv2d(3, 16, kernel_size=(11, 11), stride=(3, 3))
(1): BatchNorm2d(16, eps=1e-05, momentum=0.1, affine=True, track_running_stats=True)
(2): ReLU(inplace=True)
(3): MaxPool2d(kernel_size=2, stride=2, padding=0, dilation=1, ceil_mode=False)
......
(18): Linear(in_features=2048, out_features=1, bias=True)
(19): Sigmoid()

准备优化器和loss

初始化完模型后,可以用下面的代码初始化优化器与loss。

1
2
3
model = init_model(device)
optimizer = torch.optim.Adam(model.parameters(), 5e-4)
loss_fn = torch.nn.BCELoss()

torch.optim.Adam可以初始化一个Adam优化器。它的第一个参数是所有可训练参数,直接对一个torch.nn.Module调用.parameters()即可一键获取参数。它的第二个参数是学习率,这个可以根据实验情况自行调整。

torch.nn.BCELoss是二分类用到的交叉熵误差。这里只是对它进行了初始化。在调用时,使用方法是loss(input, target)input是用于比较的结果,target是被比较的标签。

训练与推理

接下来,我们来编写模型训练和推理(准确来说是评估)的代码。

先看训练函数。

1
2
3
4
5
6
7
8
def train(model: nn.Module,
train_X: np.ndarray,
train_Y: np.ndarray,
optimizer: torch.optim.Optimizer,
loss_fn: nn.Module,
batch_size: int,
num_epoch: int,
device: str = 'cpu'):

在训练时,我们采用mini-batch策略。因此,开始迭代前,我们要编写预处理mini-batch的代码。

这部分的代码讲解请参考我之前有关优化算法的文章

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
m = train_X.shape[0]
indices = np.random.permutation(m)
shuffle_X = train_X[indices, ...]
shuffle_Y = train_Y[indices, ...]
num_mini_batch = math.ceil(m / batch_size)
mini_batch_XYs = []
for i in range(num_mini_batch):
if i == num_mini_batch - 1:
mini_batch_X = shuffle_X[i * batch_size:, ...]
mini_batch_Y = shuffle_Y[i * batch_size:, ...]
else:
mini_batch_X = shuffle_X[i * batch_size:(i + 1) * batch_size, ...]
mini_batch_Y = shuffle_Y[i * batch_size:(i + 1) * batch_size, ...]
mini_batch_X = torch.from_numpy(mini_batch_X)
mini_batch_Y = torch.from_numpy(mini_batch_Y).float()
mini_batch_XYs.append((mini_batch_X, mini_batch_Y))
print(f'Num mini-batch: {num_mini_batch}')

PyTorch有更方便的实现mini-batch的方法。但为了少引入一些新知识,我这里没有使用。后续文章中会对这部分内容进行介绍。

这里还有一些有关PyTorch的知识需要讲解。torch.from_numpy可以把一个NumPy数组转换成torch.Tensor。由于标签Y是个整形张量,而PyTorch算loss时又要求标签是个float,这里要调用.float()把张量强制类型转换到float型。同理,其他类型也可以用类似的方法进行转换。

分配好了mini-batch后,就可以开心地调用框架进行训练了。

1
2
3
4
5
6
7
8
9
10
11
12
for e in range(num_epoch):
for mini_batch_X, mini_batch_Y in mini_batch_XYs:
mini_batch_X = mini_batch_X.to(device)
mini_batch_Y = mini_batch_Y.to(device)
mini_batch_Y_hat = model(mini_batch_X)
loss: torch.Tensor = loss_fn(mini_batch_Y_hat, mini_batch_Y)

optimizer.zero_grad()
loss.backward()
optimizer.step()

print(f'Epoch {e}. loss: {loss}')

由于GPU计算资源有限,只有当我们需要计算某数据时,才把数据用to(device)放到对应设备上。

直接用model(x)即可让模型model执行输入x的前向传播。

之后几行代码就属于训练的常规操作了。先计算loss,再清空优化器的梯度,做反向传播,最后调用优化器更新所有参数。

推理并评估的函数定义如下:

1
2
3
4
def evaluate(model: nn.Module,
test_X: np.ndarray,
test_Y: np.ndarray,
device='cpu'):

它的实现和之前的NumPy版本极为类似,这里不再重复讲解了。

1
2
3
4
5
6
7
test_X = torch.from_numpy(test_X).to(device)
test_Y = torch.from_numpy(test_Y).to(device)
test_Y_hat = model(test_X)
predicts = torch.where(test_Y_hat > 0.5, 1, 0)
score = torch.where(predicts == test_Y, 1.0, 0.0)
acc = torch.mean(score)
print(f'Accuracy: {acc}')

main函数

做好了所有准备,现在可以把所有的流程串起来了。让我们看看main函数的所有代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def main():
train_X, train_Y, test_X, test_Y = get_cat_set(
'dldemos/LogisticRegression/data/archive/dataset',
train_size=1500,
format='nchw')
print(train_X.shape) # (m, 3, 224, 224)
print(train_Y.shape) # (m, 1)

device = 'cuda:0'
num_epoch = 20
batch_size = 16
model = init_model(device)
optimizer = torch.optim.Adam(model.parameters(), 5e-4)
loss_fn = torch.nn.BCELoss()
train(model, train_X, train_Y, optimizer, loss_fn, batch_size, num_epoch,
device)
evaluate(model, test_X, test_Y, device)

这里,我们先准备好了数据集,再初始化好了模型、优化器、loss,之后训练,最后评估。

这里的cuda:0可以改成cpu,这样所有运算都会在CPU上完成。

实验结果

由于数据量较少,我只执行了20个epoch。loss已经降到很低了。

text
1
poch 19. loss: 0.0308767631649971

但是,测试集上的精度非常低。

text
1
Accuracy: 0.5824999809265137

在完成本项目时,我本来想让这次的PyTorch实现和上次的TensorFlow实现完全等价。但是,上次的loss大概是0.06,准确率是0.74。可以看出,在训练误差上PyTorch模型没什么问题,而准确率却差了很多。我猜测是TensorFlow的代码过于“高级”,隐藏了很多细节。也许它默认的配置里使用了某些正则化手段。而在今天这份PyTorch实现中,我们没有使用任何正则化的方法。

不管怎么说,从训练的角度来看,相比前几周用的全连接网络,CNN的效果出彩很多。相信加入更多训练数据,并使用一些正则化方法的话,模型在测试集上的表现会更好。

PyTorch和TensorFlow在使用体验和性能上更有优劣。相比TensorFlow的高度封装的函数,PyTorch要手写的地方会多一点。不过,在项目逐渐复杂起来,高度封装的函数用不了了之后,还是PyTorch写起来会更方便一点。毕竟PyTorch是动态图,可以随心所欲地写前向推理的过程。也正因为如此,PyTorch的性能会略逊一些。

使用编程框架是不是很爽?可不要得意忘形哦。在之后的文章中,我还会介绍卷积的等价NumPy实现,让我们重温一下“难用”的NumPy,打下坚实的编程基础。

学完了CNN的基本构件,让我们用TensorFlow来搭建一个CNN,并用这个网络完成之前那个简单的猫狗分类任务。

项目网址:https://github.com/SingleZombie/DL-Demos/tree/master/dldemos/BasicCNN

获取数据集

和之前几次的代码实战任务一样,我们这次还用的是Kaggle上的猫狗数据集。我已经写好了数据预处理的函数。使用如下的接口即可获取数据集:

1
2
3
4
train_X, train_Y, test_X, test_Y = get_cat_set(
'dldemos/LogisticRegression/data/archive/dataset', train_size=1500)
print(train_X.shape) # (m, 224, 224, 3)
print(train_Y.shape) # (m , 1)

这次的数据格式和之前项目中的有一些区别。

在使用全连接网络时,每一个输入样本都是一个一维向量。在预处理数据集时,我就做了一个flatten操作,把图片的所有颜色值塞进了一维向量中。而在CNN中,对于卷积操作,每一个输入样本都是一个三维张量。在用OpenCV读取完图片后,不用对图片Resize,直接拿过来用就可以了。

另外,在用NumPy实现时,我们把数据集大小m当作了最后一个参数。而TensorFlow默认张量是”NHWC(数量-高度-宽度-通道数)”格式。在此项目中,我们是按照TensorFlow的格式预处理数据的。

初始化模型

根据课堂里讲的CNN构建思路,我搭了一个这样的网络。

由于这个二分类任务比较简单,我在设计时尽可能让可训练参数更少。刚开始用一个大步幅、大卷积核的卷积快速缩小图片边长,之后逐步让图片边长减半、深度翻倍。

这样一个网络用TensorFlow实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def init_model(input_shape=(224, 224, 3)):
model = tf.keras.Sequential([
tf.keras.layers.Conv2D(16, 11, (3, 3), input_shape=input_shape),
tf.keras.layers.BatchNormalization(3),
tf.keras.layers.ReLU(),
tf.keras.layers.MaxPool2D(),
tf.keras.layers.Conv2D(32, 5),
tf.keras.layers.BatchNormalization(3),
tf.keras.layers.ReLU(),
tf.keras.layers.MaxPool2D(),
tf.keras.layers.Conv2D(64, 3, padding='same'),
tf.keras.layers.BatchNormalization(3),
tf.keras.layers.ReLU(),
tf.keras.layers.Conv2D(64, 3),
tf.keras.layers.BatchNormalization(3),
tf.keras.layers.ReLU(),
tf.keras.layers.MaxPool2D(),
tf.keras.layers.Flatten(),
tf.keras.layers.Dense(2048, 'relu'),
tf.keras.layers.Dense(1, 'sigmoid')
])

model.summary()

return model

tf.keras.Sequential()用于创建一个串行的网络(前一个模块的输出就是后一个模块的输入)。网络各模块用到的初始化参数的介绍如下:

  • Conv2D: 输出通道数、卷积核边长、步幅(要用一个数对表示)、填充方法。
  • BatchNormalization: 做归一化的维度(全填3即可)。
  • Dense(全连接层):输出通道数、激活函数。

根据之前的设计,把参数填入这些模块即可。

另外,TensorFlow维护的是静态图。一种比较简单的建图方法是在第一层里给出input_shape参数,让框架提前算好后续每一层中间结果的形状。

建图成功后,调用model.summary()可以查看网络各层的形状、参数量信息。

训练与推理

有了数据集和模型,用TensorFlow训练是一件很简单的事情。

1
2
3
4
5
6
7
8
9
10
11
12
13
def main():
train_X, train_Y, test_X, test_Y = get_cat_set(
'dldemos/LogisticRegression/data/archive/dataset', train_size=1500)
print(train_X.shape) # (m, 224, 224, 3)
print(train_Y.shape) # (m , 1)

model = init_model()
model.compile(optimizer='adam',
loss='binary_crossentropy',
metrics=['accuracy'])

model.fit(train_X, train_Y, epochs=20, batch_size=16)
model.evaluate(test_X, test_Y)

使用init_model初始化模型后,用compile填入模型的优化器、误差函数、评估指标信息。之后,只要用fit输入训练输入、训练标签、epoch数、batch size即可开始训练。训练结束后,用evaluate输入测试输入、测试标签即可在测试集上评估模型。

TensorFlow的这些函数确实非常方便,这里test_X, test_Y, train_X, train_Y其实都是NumPy里的ndarray,可以不用显式地把它们转换成TensorFlow里的张量。

实验结果

由于数据量较少,20个epoch后模型在训练集上的精度就快满了:

1
2
Epoch 20/20
188/188 [==============================] - 23s 121ms/step - loss: 0.0690 - accuracy: 0.9776

测试集上的精度就没那么高了:

1
13/13 [==============================] - 1s 30ms/step - loss: 1.0136 - accuracy: 0.7375

相比前几周用的全连接网络,CNN的效果出彩很多。相信加入更多训练数据的话,模型在测试集上的表现会更好。

另外,TensorFlow的高度封装的函数确实很好用,寥寥几行代码就完成了训练配置、训练、评估。相比用NumPy从零写代码,编程框架的开发效率会高上很多。

下篇文章里,我会介绍本项目的等价PyTorch实现。大家届时可以比较一下两个框架的区别。

前排提示:这周的课有很多知识点都在图中,一定要仔细地看一看图。

课堂笔记

计算机视觉

CV(Computer Vision, 计算机视觉)是计算机科学的一个研究领域。该领域研究如何让计算机“理解”图像,从而完成一些只有人类才能完成的高级任务。这些高级任务有:图像分类、目标检测、风格转换等。

想具体了解有哪些计算机视觉任务,可以直接去访问OpenMMLab的GitHub主页:https://github.com/open-mmlab 。我随手整理了一下:图像分类、目标检测、语义分割、图像补全、光流、图像超分辨率、自动抠图、姿态识别、视频插帧、视频目标跟踪、文字识别与理解、图像生成、视频理解、3D目标检测与语义分割……

现在,大多数前沿CV算法是用深度学习实现的。

但是,在CV任务上使用我们之前学的经典神经网络,会碰到一个问题:神经网络输入层的通道数与输入图像尺寸正相关。对于一幅$64\times64\times3$的图像,输入的通道数是$12288$;而对于一幅$1000\times1000\times3$的图像,输入的通道数就高达$3\times 10^6$了。而网络第一层的参数量又与输入的通道数正相关。对于一个通道数高达$3\times 10^6$的输入,假设网络第一个隐藏层有$1000$个神经元,那么这一层的$W$将有$1000 \times 3\times 10^6=3\times 10^9$个参数。有这么多参数,除非有海量的数据,不然网络非常容易过拟合。现有的数据量和计算资源还是跑不动参数这么多的网络的。

因此,在CV中,我们一般不使用之前学的经典神经网络架构,而是使用一种新的网络架构——CNN(Convolutional Neural Network, 卷积神经网络)。

教材这一段的引入新知识组织得非常棒,从参数量的角度自然而然地从全连接网络过度到卷积神经网络。

让我们从卷积神经网络最简单的构件——卷积学起,一步一步认识卷积神经网络。

边缘检测

卷积是一种定义在图像上的操作。在深度学习时代之前,它最常用于图像处理。让我们来看看卷积在图像处理中的一个经典应用——边缘检测,通过这个应用来学习卷积。

边缘检测的示意图如上所示。输入一张图片,我们希望计算机能够检测出图像纵向和横向的边缘,把有边缘的地方标成白色,没有边缘的地方标成黑色。

我们可以用卷积实现边缘检测。让我们来看看卷积运算是怎么样对数据进行操作的。

卷积有两个输入:一幅图像和一个卷积核(英文是kernel,也叫做filter滤波器),其中卷积核是一个二维矩阵。我们这里假设图像是一幅单通道$6 \times 6$的矩阵,卷积核是一个$3 \times 3$的矩阵。经过卷积后,我们会得到一个$4 \times 4$的单通道图像(稍后会介绍$4 \times 4$是怎么算出来的)。

卷积操作会依次算出输出图像中每一个格子的值。对于输出左上角第一个格子,它的计算方法如下:

首先,我们把$3 \times 3$的卷积核“套”在输入图像的左上角。之后,我们把同一个位置的两个数字乘起来。比如图像左上角第一行是$3 0 1$,卷积核第一行是$1 0 1$,做完乘法运算后应该得到$3 0 -1$。最后,把所有乘法结果加起来,这个和就是输出中第一个格子的值。通过计算,这个值是$-5$,我们把它填入到输出图像中。

按同样的道理,我们可以填完第一行剩下的格子:

从第二行开始,卷积核要往下移一格。

以此类推,我们可以填完所有格子。大家明白了为什么输出是$4 \times 4$的图像吗?没错,把$3 \times 3$的卷积核放到$6 \times 6$的图像上,只有$4 \times 4$个位置能放得下。

学会了卷积,该怎么用卷积完成边缘检测呢?我们可以看下面这个例子:

来看左边那幅图像,它左侧是白的,右侧是灰的。很明显,中间有一条纵向的边缘。当我们用图中那个卷积核对图像做卷积操作后,输出的图像中间是白色的(非0值),两侧是黑色的。输出图像用白色标出了原图像的纵向边缘,达到了边缘检测的目的。

刚刚那个卷积核只能检测纵向的边缘。大家应该能猜出,如果我们把卷积核转一下,就能检测横向的边缘了。

实际上,不仅是横向和纵向,我们还可以通过改变卷积核,检测出图像45°,30°的边缘。同时,卷积核里面的数值也不一定是1和-1,还有各种各样的取值方法。如果大家感兴趣,可以参考数字图像处理中有关边缘检测的介绍。

卷积与交叉相关

其实,现在我们在课堂上学的和编程框架里用的卷积,在数学上叫做“交叉相关(cross-correlation)”。数学中真正的那个卷积,要先对卷积核做一个旋转180°的操作,再做我们现在的那个卷积的操作。相比交叉相关,数学中的那个卷积能够满足交换律、结合律等一些实用的性质。

但是,在图像处理中,我们是从工程的角度而不是理科的角度使用卷积。要实现多次卷积的操作,只要拿图像多卷几次就好了,不用考虑结合律等复杂的性质。对于计算机来说,旋转卷积核180°是一个费时而多余的操作。因此,我们现在说到的卷积,实际上是一个简化版的卷积,即交叉相关。

如果大家对这方面的知识感兴趣,欢迎阅读网上的这篇文章:https://zhuanlan.zhihu.com/p/33194385

填充

卷积后,图像的边长会变小。比如刚刚那个$6 \times 6$的图像经$3 \times 3$卷积后,会得到一个$4 \times 4$的图像。这是因为原图像中只有$4 \times 4$个位置放得下卷积核。

更一般地,如果原图像大小为$n \times n$,卷积核大小$f \times f$,则卷积后的图像为$(n-f+1) \times (n-f+1)$。

卷积操作导致的这种“缩水”现象有两个缺点:1)图像的分辨率会越来越小。最坏的情况下,图像变成了$1 \times 1$的大小,再也无法进行卷积操作了。2)图像中间的数据会被算到多次,而边缘处数据被算的次数较少。

填充(padding) 操作可以解决这些的问题:在做卷积操作之前,我们可以往图像四周填充一些像素,使得卷积操作后的图像大小不变。比如$6 \times 6$的图像做$3 \times 3$卷积时,可以先把图像填充成$8 \times 8$。这样,卷积后的图像还能保持$6 \times 6$的大小。

填充操作有两个参数:填充的数据和向四周填充的宽度。对于填充的数据,一般情况下,全部填0即可。而对于填充宽度,其取决于卷积核的大小。为了让图像大小不变,我们应该让填充宽度$p$满足$n+2p-f+1=n$,解得$p=\frac{f-1}{2}$。为了让$p$是整数,卷积核边长最好是奇数。

解释一下$n+2p-f+1=n$这个方程的左侧是怎么得来的。由于填充是上下、左右都填,填充后的图像边长是$n+2p$。根据开始的卷积后图像边长公式$n-f+1$,我们可以得到填充+卷积后边长公式$n+2p-f+1。$

加入了填充操作后,我们可以把卷积分成两类:有效卷积等长卷积。前者不做填充操作,只对图像的有效区域做卷积。而后者会在卷积前做一次填充,保证整个操作的前后图像大小不变。

跨步卷积

跨步卷积的英文是strided convolution。strided来源于动词stride,表示“大步走”。我没有在网上找到一个合适的对这里的strided的翻译。我觉得直接翻译成“跨步卷积”就挺好。

还有一个翻译的小细节:做名词时,stride应翻译成“步幅”,而“步长”的英文应该是step。二者在描述人类行走时略有区别。

之前,每做完一次卷积后,我们都会让卷积核往右移1格;每做完一行卷积后,我们都会让卷积核往下移1格。但实际上,我们可以让卷积核移动得更快一点。卷积核每次移动的长度$s$称为步幅(stride).

跨步卷积的部分计算示意图(第1, 2, 4次计算)如下:

可以看到,步幅改变后,输出图像的边长也改变了。一般地,卷积后图像边长满足下面这个公式,大家可以自行推导验证一下:

其中$\lfloor x \rfloor$表示去掉$x$的小数部分,只保留其整数部分,即向下取整。

在3D数据体上卷积

之前我们学的卷积都是定义在一个二维单通道图像上的。在一个三通道的图像上,应该怎么进行卷积呢?

其实,对3D数据体的卷积是类似的。对于一个有3个通道的图像,卷积核也应该有3个通道。这样,图像和卷积核就从面变成了体。和2D时一样,我们把两个数据体对应位置的元素相乘,最后再把乘法的结果加起来,放到输出图像对应的格子中。

我认为,把三通道的图像表示成$3 \times 6 \times 6$更好理解一些。这样,输入图像的其实是一个二维图像的数组,$3 \times 3 \times 3$的卷积核其实也是一个$3 \times 3$卷积核的数组。我们把数组中下标一样的图像和卷积核做卷积,最后把所有数组的结果加到一起。

图像是用CHW(通道-高-宽)还是HWC表示,这件事并没有一个定论。似乎TensorFlow是用HWC,PyTorch是用CHW。这门课默认使用的是HWC。

既然输入都可以是多通道图像了,输出图像是不是也可以有多个通道呢?是的,我们只要用多个卷积核来卷图像,就可以得到一个多通道的图像了。

总结一下,假如输入图像的形状是$n \times n \times n_c$,卷积核的形状则是$f \times f \times n_c$。注意这个$n_c$必须是同一个数。假如有$n_c’$个卷积核,则输出图像的形状是$(n - f + 1) \times (n - f + 1) \times (n_c’)$。

在某些框架中,卷积核数量会也会当成卷积核的一个维度,比如可以用$n_c’ \times f \times f \times n_c$来表示一个卷积核组。

卷积神经网络中的卷积层

现在,我们已经掌握了卷积的基本知识,让我们来看看卷积神经网络中的卷积层长什么样。

卷积在卷积层中的地位,就和乘法操作在传统神经网络隐藏层中的地位一样。因此,在卷积层中,除了基础的卷积操作外,还有添加偏移量、使用激活函数这两步。注意,每有一个输出通道,就有一个$b$。

现在,我们可以总结一下一个卷积层中涉及的所有中间变量以及它们的形状了。

池化层与全连接层

池化层执行的池化操作和卷积类似,都是拿一个小矩阵盖在图像上,根据被小矩阵盖住的元素来算一个结果。因此,池化也有池化边长$f$和池化步幅$s$这两个参数。而与卷积不同的是,池化是一个没有可学习参数的操作,它的结果完全取决于输入。比如对于最大池化,每一步计算都会算出被覆盖区域的最大值。

比如上图中,我们令池化边长为2,步幅为2。这样,就等于把一个$4 \times 4$的图像分成了$2 \times 2$个等大的区域。对于每一个区域,我们算一个最大值。

一般情况下,最常用的池化就是这种边长为2,步幅为2的池化。做完该操作后,图像的边长会缩小至原来的$\frac{1}{2}$。

除了最大池化,还有计算区域内所有数平均值的平均池化。但现在几乎只用最大池化,不用平均池化了。

没有人知道池化层究竟为什么这么有用。一种可能的解释是:池化层忽略了细节,保留了关键信息,使后续网络能够只关注之前输出的最值/平均值。

全连接层其实就是我们之前学的经典神经网络中的层。前一层的每一个神经元和后一层的每一个神经元直接都有连接。当然,在把图像喂入全连接层之前,一定别忘了做flatten操作,把图像中所有数据平铺成一个一维向量。

CNN示例

学完了CNN所有的基础构件,我们或许会感到疑惑:每个卷积层、池化层、全连接层都有那么多超参数,而且层与层之间可以随意地排列组合。该怎么搭建一个CNN呢?不急,让我们来看一个CNN的实例:

这个网络是经典网络LeNet-5的改进版,它被用于一个10-分类任务。我们会在下周正式学习这个网络。现在,让我们通过概览这个网络来找出一些搭建CNN的规律。

网络按照“卷积-池化-卷积-池化-全连接-全连接-softmax”的顺序执行。通常情况下,CNN都是执行若干次卷积,后面跟一次池化。等所有卷积核池化做完,才会做全连接操作。全连接之后就是由softmax激活的输出层。

另外,图像的形状也有一些规律。在卷积核池化的过程中,图像的边长不断变小,而通道数会不断变大。

下周,我们会继续认识一些经典的CNN架构,这些经典架构能够启发我们,帮助我们更好地搭建自己的CNN。

为什么用卷积?

这周,我们一直都在讲卷积。而卷积具体有哪些优点呢?

首先,卷积最大的优势就是需要的参数量少。回想这周开头讲的参数量问题。对于图像数据,如果用全连接网络的话,网络的参数会非常多。而卷积的两个性质,使得需要的参数量大大降低。这两个性质是权重共享与稀疏连接。

权重共享:对于输入图像的所有位置来说,卷积核的参数是共享的。这种设计是十分合理的。比如在边缘检测中,只要我们用同样一个[[1, 0, -1], [1, 0, -1], [1, 0, -1]]的卷积核卷网络,就能检测出垂直方向的边缘。这样,卷积操作的参数量就只由卷积核参数决定,而与图像大小无关。

稀疏连接:卷积核的大小通常很小,也就是卷积操作的一个输出只会由少部分的输入决定。这样,相比一个输出要由所有输入决定的全连接网络,参数量得到进一步的减少。

除了减少参数量外,这两个特性还让网络更加不容易过拟合。回想之前学过的dropout,卷积的这些特性就和扔掉了部分激活输出一样。

另外,卷积操作还适合捕捉平移不变性(translation invariance)。这个词的意思是说,如果一张图里画了一个小猫,如果你把图片往右移动几格,那么图片里还是一个小猫。由于同样的卷积操作会用在所有像素上,这种平移后不变的特性非常容易被CNN捕捉。

总结

在这堂课中,我们认识了CNN的三大基础构件:卷积、池化、全连接。其中,卷积和池化是新学的知识。这堂课的内容非常多,也非常重要,让我们来回顾一下。

  • CNN 的优点
    • CNN 与全连接网络的参数比较
    • 权重共享、稀疏连接
  • 卷积操作
    • 基本运算流程
    • 填充
    • 步幅
    • 示例:边缘检测
  • 卷积层
    • 对多通道图像卷积
    • 输出多通道图像
    • 加上bias,送入激活函数
  • 池化层
    • 运算流程
    • 最大池化与平均池化
  • CNN 示例
    • 如何组合不同类别的层:卷积接池化,最后全连接。
    • 图像边长变小,通道数变大。

由于深度学习编程框架通常会帮我们实现好卷积,卷积的实现细节倒没有那么重要。在这周的课里,最重要的是一些宏观的知识。我们要知道卷积有哪些参数、哪些超参数,了解卷积的优点。同时,还要知道卷积和其他构件是如何组成一个CNN的。

在这周的编程实战里,我们会用框架(TensorFlow和PyTorch)实现一个简单的CNN,完成图像分类任务。有时间多的话,我还会介绍一下如何用NumPy实现卷积的正向和反向传播。

第三阶段回顾

在过去两周里,我们学习了改进深度学习模型的一些策略。让我们来回顾一下。

首先,我们应该设置好任务的目标。选取开发/测试集时,应参考实际应用中使用的数据分布。设置优化指标时,应使用单一目标。可以设置一个最优化目标和多个满足目标。

在搭建模型时,我们可以根据现有的数据量、问题的难易度,选择端到端学习或者是多阶段学习。

训练模型前,如果有和该任务相似的预训练模型,我们可以采取迁移学习,把其他任务的模型权重搬过来;如果我们的模型要完成多个相似的任务,可以同时训练多个任务的模型。

有了目标,搭好了模型之后,就可以开始训练模型了。有了训练好的模型后,我们可以根据模型的训练误差、训练开发误差、开发误差来诊断模型当前存在的问题。当然,在诊断之前,我们可以先估计一下人类在该问题上的最低误差,以此为贝叶斯误差的一个估计。通过比较贝叶斯误差和训练误差,我们能知道模型是否存在偏差问题;通过比较训练误差和训练开发误差,我们能知道模型是否存在方差问题;通过比较训练开发误差和开发误差,我们能知道模型是否存在数据不匹配问题。

另一方面,如果在改进模型时碰到了问题,不妨采取错误分析技术,看看模型究竟错在哪。我们可以拿出开发集的一个子集,统计一下模型的具体错误样例,看看究竟是模型在某些条件下表现得不好,还是标错的数据太多了。

这些内容可能比较偏向于工程经验,没有过多的数学理论。但是,相信大家在搭建自己的深度学习项目时,这些知识一定能派上用场。

第四阶段预览

在这之后,我们要分别学习两大类神经网络:处理图像的网络和处理序列数据的网络。在第四门课《卷积神经网络》中,我们就会学习能够处理图像问题的卷积神经网络。一起来看看接下来要学的内容吧。

《卷积神经网络》的课需花四周学完。第一周,我们会学习卷积神经网络的基本构件,建立对卷积神经网络的基本认识,为后续的学习做准备。具体的内容有:

  • 卷积操作
    • 从卷积核到卷积
    • 卷积的属性——填充、步幅
    • 卷积层
  • 池化操作
  • 卷积神经网络示例

最简单的计算机视觉任务是图像分类。第二周,我们将学习一系列图像分类网络。这些网络不仅能在图像分类上取得优秀的成绩,还是很多其他计算机视觉任务的基石。通过学习它们,我们不仅能见识一些经典网络的架构,更能从中学习到搭建卷积神经网络的一般规律。其内容有:

  • 早期神经网络
    • LeNet-5
    • AlexNet
    • VGG
  • 残差神经网络
  • Inception 网络
  • MobileNet
  • 搭建卷积网络项目
    • 使用开源代码
    • 迁移学习
    • 数据增强

第三周,我们将学习计算机视觉中一个比较热门的任务——目标检测。目标检测要求算法不仅能辨别出图片中的物体,还要能把物体精确地框出来。我们会一步一步学习如何搭建完成目标检测的卷积神经网络:

  • 目标定位与关键点检测
  • 使用卷积神经网络的目标检测
    • 滑动窗口算法
    • 基于卷积的滑动窗口
  • YOLO 算法
    • 结合目标定位与滑动窗口
    • 交并比(IoU)
    • NMS(非极大值抑制)
    • 锚框(Anchor boxes)
  • R-CNN 系列算法简介

此外,这周还会稍微提及另一个计算机视觉任务——语义分割的基本知识:

  • 基于U-Net的语义分割
    • 反卷积
    • U-Net架构

最后一周,第四周,我们又会认识两个新任务:人脸检测与神经网络风格迁移。具体的内容有:

  • 人脸检测
    • 人脸检测问题与一次性学习
    • 孪生神经网络
    • 三元组误差
    • 转化成二分类问题
  • 神经网络风格迁移
    • 风格迁移简介
    • 利用神经网络学到的东西
    • 风格迁移中的误差
    • 推广到1维和3维

相比之前的课,学习第四门课时需要花更多的精力,主要因为以下几点:

  1. 课程难度变高。
  2. 课程的编程练习很多。
  3. 课堂上介绍了很多论文作为拓展学习的方向。

如果你未来要以计算机视觉为研究方向的话,这四周的内容一定要认真掌握。同时,编程练习和论文阅读也不能落下。据我估计,如果要打好计算机视觉方向上的坚实的基础,至少还要多花费两周时间去认真阅读经典论文,做好相关的技术调研。

在未来的几周里,我仅会上传课堂笔记,并尽最大可能复现一下课后的习题。在所有的五门课上完后(大约2个月后),我会回过头来补充计算机视觉相关的论文阅读笔记、项目实现笔记,对视频课中没来得及讲完的内容查缺补漏,以呈现一套翔实的深度学习学习笔记,辅助大家更好地入门深度学习。

拍照时,我们可能辛辛苦苦地找了个角度,却忘记了调整光线,拍出了黑乎乎的照片:

这种情况下,最常见的补救方法是P图。打开PhotoShop,按下”ctrl+m”,就能够打开调整图像亮度的界面:

这个界面中间灰色的区域表示图像的亮度分布。坐标轴横轴表示亮度,纵轴表示对应亮度的像素的数量。可以看出,整幅图片非常暗,亮度低的像素占了大多数。

为了提亮图片,我们可以调整中间那条曲线。这条曲线表示如何把某一种亮度映射到另一种亮度上。初始情况下,曲线是$y=x$,也就是不改变原图片的亮度。由于低亮度的像素占比较多,我打算构造一个对低亮度像素进行较大增强,而尽可能保持高亮度像素的曲线。其运行结果如下:

嗯,不错。看起来图像确实变亮了不少。但感觉图片看上去还不够自然。有没有一种自动帮我们提亮图像的工具呢?

Zero-DCE就是一个利用深度学习自动调亮图片的算法。让我们看看它的运行结果:

哇!这也太强了。除了效果好之外,Zero-DCE还有许多亮点:

  • 不需要带标注的数据,甚至不需要参考数据(这里的参考数据指一张暗图对应的亮图)!
  • 训练数据少,训练时间短,只需约30分钟。
  • 推理速度极快。在手机上也能实时运行。

让我们来读一下Zero-DCE的论文,看看这个算法是怎么实现的。看完论文后,我还会解读一下官方的PyTorch代码实现。

Zero-DCE 论文解读

核心思想

自从CNN(卷积神经网络)火了以后,很多图像问题都可以用CNN来解决:把图像输入进CNN,乱卷一通,最后根据任务的需要,输出分类的概率(图像分类)、检测框和类别(目标检测)或另一幅图像(超分辨率)。

同时,对于输出也是一幅图像的问题,人们会利用GAN(生成对抗网络)能生成图像的特性,尝试用GAN来解决问题。比如在超分辨率任务中,GAN就得到了广泛的使用。

而图像提亮问题恰好就是一个输入、输出都是图像的问题。在此之前,既有基于CNN的方法,也有基于GAN的方法。人们尝试构造更精巧的网络,希望网络能输出亮度更合适的图像。

可是,Zero-DCE别出心裁,返璞归真地用了一种更简单的方式来生成亮度更合适的图像。还记得本文开头提到的,PhotoShop里的那个亮度映射曲线吗?实际上,我们只需要一条简简单单的曲线,把不同亮度的像素映射到一个新的亮度上,就足以产生亮度恰好合适的图像了。Zero-DCE就是用神经网络来拟合一条亮度映射曲线,再根据曲线和原图像生成提亮图像。整个计算过程是可导的,可以轻松地用梯度下降法优化神经网络。

另外,与其他一些任务不同,「亮度」是一个很贴近数学的属性。对于物品的种类、文字的意思这种抽象信息,我们很难用数字来表达。而亮度用一个数字来表示就行了。因此,在图像提亮问题中,我们不一定需要带标签的训练数据,而是可以根据图像本身的某些性质,自动判断出一幅图像是不是“亮度合理”的。

为了让计算机自动判断生成图像的亮度、与原图像的相似度等和图像质量相关的属性,Zero-DCE在训练中使用了一些新颖的误差函数。通过用这些误差函数约束优化过程,算法既能保证生成出来的图片亮度合理,又能保证图片较为真实、贴近原图像。

拟合亮度映射的曲线、不需要标签的误差函数,这两项精巧的设计共同决定了Zero-DCE算法的优势。原论文总结了该工作的三条贡献:

  1. 这是第一个不需要参考结果的低光照增强网络,直接避免了统计学习中的过拟合问题。算法能够适应不同光照条件下的图片。
  2. 该工作设计了一种随输入图像而变的映射曲线。该曲线是高阶的。每个像素有一条单独的曲线。曲线能高效地完成映射过程。
  3. 本作的方法表明,在缺乏参考图像时,可以设计一个与任务相关而与参考图像无关的误差,以完成深度图像增强模型的训练。

除了学术上的贡献外,算法也十分易用。算法的提亮效果优于其他方法,训练速度和推理速度更是冠绝一方。

接下来,让我们详细探究一下亮度映射曲线、误差函数这两大亮点究竟是怎么设计的。

提亮曲线

本文使用的亮度映射曲线被称作提亮曲线(Light-Enhancement Curve, LE-curve)。设计该曲线时,应满足几个原则:

  1. 由于亮度值落在区间$[0, 1]$,为保证亮度值的值域不变,曲线在0处值要为0,在1处值要为1。
  2. 曲线必须是单调递增的。不然可能会出现图像中原本较亮的地方反而变暗。
  3. 曲线公式必须简单,以保证可导。

因此,本作使用了如下的公式描述曲线:

其中,$\mathbf{x}$是像素坐标,$\alpha \in [-1, 1]$是可学习参数,$LE(I(\mathbf{x}); \alpha)$是输入$I(\mathbf{x})$的增强图像(三个颜色通道分别处理)。这个函数非常巧妙,大家可以验证一下它是不是满足刚刚那三条原则。

$\alpha$是公式里唯一一个可变参数。我们来看看不同的$\alpha$能产生怎样的曲线;

可以看出,$\alpha$虽然能够上下调节曲线,但由于曲线本质上是一个二次函数,曲线的变化还不够丰富。为了拟合更复杂的曲线,本作迭代嵌套了这个函数。也就是说:

一般地,

迭代嵌套开始那个二次函数,就能够表示一个更高次的函数了。每一轮迭代,都有一个新的参数$\alpha_n$。本作令最大的$n$为8,即调用二次函数8次,拟合某个$2^8$次函数。

但是,我们不希望每个像素都用同样的提亮函数。比如如果图像中某个地方亮着灯,那么这个地方的像素值就不用改变。因此,每个像素应该有独立的$\alpha$。最终的提亮函数为:

这一改动还是很有必要的。下图显示了某输入图片在不同像素处的$\alpha$的绝对值:

可以看出,在较亮的地方,图像没有变化,$\alpha$几乎为0;而在较暗的地方,$\alpha$的数值也较大。

知道了要拟合的目标曲线的公式,下面我们来看看拟合该曲线的神经网络长什么样。

由于要拟合的数据不是很复杂,本作使用到的网络DCE-Net非常简单。它一共有7层(6个隐藏层,1个输出层)。所有层都是普通的3x3等长(stride=1)卷积层。为保持相邻像素间的联系,卷积层后不使用Batch Normalization。隐藏层激活函数为ReLU,由于输出落在$[-1, 1]$,输出层的激活函数是tanh。如图所示,6个隐藏层使用了和U-Net类似的对称跳连。3、4层的输出会拼接到一起再送入第5层,2、5层输出拼接送入第6层,1、6层输出拼接送入第7层。经过输出层后,每个像素有24个通道——有RGB 3个颜色通道,每个通道有8个参数。

似乎开源代码里没有去掉Batch Normalization。

看完了网络结构与其输出的意义,我们继续看一下误差函数是怎么设置的。

无需参考的误差函数

为了能不使用参考数据,本作精心设计了四个误差函数,以从不同的角度约束增强后的图像。

空间一致误差(Spatial Consistency Loss)

图像增强后,我们肯定不希望图像的内容发生改变。更准确一点描述,我们不希望某像素的值和其相邻像素的值的差发生过大的改变。因此,我们可以设置下述误差:

,其中$K$是像素数,$i$是对像素的遍历。$\Omega(i)$是第$i$个像素的4邻域。$Y, I$分别是增强图像和输入图像。

但实际上,我们的要求不必那么苛刻,不用要求每个像素和周围像素的相对值都不改变。在实现中,$i$其实是一个$4 \times 4$的一个“大像素”区域,每个大像素的值是其中所有像素值的平均值。在实现时,大像素可以通过平均池化来求得。因此,上式中的$K$其实指的是大像素的数量,$Y, I$分别是增强图像和输入图像经池化后得到的图像。

曝光控制误差(Exposure Control Loss)

为了不让某些地方过暗,某些地方过亮,我们可以让极端亮度更少,即让每个像素的亮度更靠近某个中间值。这个约束可以用如下的误差函数表达:

,其中常数$E$描述了亮度的中间值,根据经验可以取0.6。和之前的$Y$类似,这里的$Y$也是一个大像素区域中亮度的平均值。大像素宽度可调,文中使用的宽度是16。$M$是大像素的总个数。

颜色恒定误差(Color Constancy Loss)

根据前人研究中的某些结论,图像某一颜色通道的数值不应显著超出其他通道。因此,有如下误差:

,这里,$(p, q)$遍历了三个颜色通道中所有两两组合,$J_p$表示颜色通道$p$的亮度平均值。

光照平滑误差(Illumination Smoothness Loss)

为了保持相邻像素的单调关系,即让相邻像素之间的亮度改变不是那么显著,我们需要让相邻像素间的参数$\alpha \in A$更相近一点。这种要求可以这样表示:

,其中,$N$是迭代次数,$\nabla_x, \nabla_y$分别是水平和垂直的梯度算子。对于图像,水平梯度和垂直梯度就是和左方、上方相邻像素之间的数值的差。

网上公开出来的论文中,这个公式少了一个左绝对值号。

总误差

总误差即上述四个误差的加权和:

理论上,描述4个量的相对加权关系至少要3个权重(默认剩下一个权重为1)。但是,原论文只写了两个权重。而代码里却有3个权重。我认为是论文没写清楚。

在开源代码中,上述四个权重分别为$W_1=1, W_2=10, W_3=5, W_4=200$。

这四个误差中,有几个误差的作用十分重要。大家可以看看去掉某项误差后,网络的复原效果:

去掉$L_{spa}$后,生成出来的图像勉强还行。剩下的误差,哪怕去掉任何一个,生成图像的效果都会很差劲。

Zero-DCE++

Zero-DCE是发表在CVPR会议上的。之后,Zero-DCE的拓展版Zero-DCE++发到了TPAMI期刊上。期刊版版面足够,原论文中一些来不及讲清的地方(比如空间一致误差)在期刊版中都有更详尽的说明。大家如果想读论文,建议直接读期刊版本的。论文层层递进,逻辑非常清楚,非常适合从头到尾读一遍。

Zero-DCE++在方法上主要是对性能上进行了一些增强,而没有改进原作的核心思想。拓展点有:

  1. 和MobileNet类似,把普通卷积替换成更快的逐通道可分卷积(depthwise separable convolution)。
  2. 经研究,8次迭代中,每次的参数$\alpha$都差不多。因此,可以让网络只输出3个值,而不是24个值。
  3. 由于该任务对图像尺寸不敏感,为了减小卷积开销,可以一开始对图像下采样,最后再上采样回来。

经优化后,参数量减少8倍,运算量在一般大小的图像上减少上百倍,训练只需20分钟。

总结

Zero-DCE是一个简单优美的低光照增强算法。该算法巧妙地建模了光照增强问题,并创造性地使用了和参考数据无关的误差,竟然让基于深度学习的低光照增强算法做到了训练块、性能高、对数据要求低。希望这篇文章用到的思想也能启发其他图像任务。

然而,本文的第一作者在指导我们时说道:“低光图片增强问题要解决两件事:图像去模糊和亮度增强。而Zero-DCE只能完成后者。同时,低光图片的特例也非常多。现在想做一个低光照增强的商业应用是很困难的。”是啊,想让低光照增强落地,用手机瞬间点亮拍暗了的照片,任重而道远啊。

Zero-DCE 开源代码的使用

代码可以在 https://github.com/Li-Chongyi/Zero-DCE 里找到。

由于算法没那么复杂,实现所需的代码并不多。同时,这份代码也写得比较工整清楚。整份代码读起来还是非常轻松的。

安装与使用

直接clone仓库:

1
git clone git@github.com:Li-Chongyi/Zero-DCE.git

之后,切到内侧的文件夹:

1
cd Zero-DCE/Zero-DCE_code

直接运行脚本就行了:

1
python lowlight_test.py 

注意!!这份代码对Windows不太友好,有一处路径操作写得不好。在lowlight_test.py这份文件中,有一坨完成os.makedirs()的代码,建议改成:

1
2
3
dir, fn = os.path.split(result_path)
if not os.path.exists(dir):
os.makedirs(dir)

同时,代码用VSCode打开后编辑,会出现莫名其妙的缩进不对齐问题。建议拿个格式化工具修一下。为了编辑这份代码,我不得不把所有缩进重新调了一遍。

这是我跑的一个结果,效果很不错:

代码选读

代码实现中有一些可以讲一讲的地方。

看一下神经网络的实现:

整个神经网络部分还是很简明的。

那个求第一个误差空间一致误差L_spa的代码是很炫酷的。让我们忽略掉那个合成大像素的操作,直接看一下这里和相邻像素的差是怎么实现的。

首先,这里定义了一堆“参数”。

之后,这些参数被扔进了卷积里,用来卷原图像和增强图像。这是在干什么呢?

原来啊,在深度学习时代之前,卷积本来就是图像处理里的一个普普通通的操作。开始那张图定义的不是参数,而是3x3常量卷积核。用那几个卷积核卷积图像,可以得到图像和上下左右之间的差。

这种写法很帅,但是增加了很多计算量。文件里有很多没删干净的代码,不知道是不是本来还有其他设计。

在第四个误差L_TV里,也有一个要算和相邻像素之间的差的梯度计算。这份实现就写得老实多了。

这份代码中就是这里有一点难看懂,其他地方都是非常基础的PyTorch调用,非常适合初学者用来学习PyTorch。

彩蛋

其实我的头像一开始也拍得很暗。我是拿PS把这张照片提亮的。

非常凑巧,我在提亮这张照片时,也是用PS里的那个曲线迭代了几次。每次的曲线也恰巧都是一个二次函数。其实现过程和这篇工作如出一辙。

那么,让Zero-DCE来增强这幅图像,能达到怎样的效果呢?

看来,这个算法还是不太行啊。脸部的光照过于均匀,以至于失去了真实性。头发也白了。比我自己P的差多了。而且,我根本不会用PS,只是随手调了一下,P得也不是很好。AI想战胜人类,还是早了一万年啊。