0%

学习提示

上周,我们学完了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想战胜人类,还是早了一万年啊。

这是我这几个月来第一次只想把文章发到个人博客上,而不想发到其他平台上。

顺带一提,我是这样看待个人博客与其他平台的:个人博客写的东西,基本都是围绕我自己。只有非常关注我的人,才会看到这些内容。大众看不看得到,无所谓。我就是为了自己而写。我可以不管语法,不管内容,想怎么写怎么写。而在其他平台写的东西,多少有一点宣传的意思在内,会受到种种制约。

前几天,我去发表了一篇个人感想的文章。风格其实和我之前的博文差不多,但是主题更吸引人(尤其是标题),内容稍长。如我所意料地,相对我现在拥有的流量而言,这篇文章收获了空前的关注。

我开心也不开心。开心是因为,总算写的东西有很多人看了。而且,这篇文章很受欢迎,完全在我的意料之中。也就是说,我有估计文章传播力的判断力。真的只是为了提升文章的阅读数据,我可以轻松写出一堆文章来。

不开心的原因就比较复杂了。

上周六晚上,发文章之前,我突然心跳加速,紧张了起来。文章是几周前写的,也早早就决定了要发。我也不知道为什么突然会这样。发完文章,我就立刻去吃饭了,也不敢盯着发送的数据看。吃饭回来一看,文章果然大受关注。第二天,知乎也开始推送我的文章了,也确实引了一大批人。

看到大家的评论,我这才明白我在担心什么。

我那篇文章写得是什么?纯粹写的是我的心路历程,我做了什么,想了什么。我的经历多么可笑,我的想法多么龌龊。我根本不管这么多,只是原原本本地写了下来,然后发了出去。就好像没穿衣服站在街上一样。

当然,我之所以敢这么做,就是因为我已经完全看淡了过去的那些事情。我敢写出来,说明我把过去的那个“我”已经不当成我自己了。我把那段经历,完完全全当成一个商品在售卖。经历是个人组成的一部分。也就是说,我把自己灵魂的一部分割了下来,拿出去卖。

有些人分享经历,是为了获取认同。获得他人的赞,就像获得了他人的鼓励一样。

但是,我把自己的目的写得很明确:主要是为了流量,次要是为了分享。他人的赞,甚至是加油的祝福,对早已熬过去的我来说一点帮助也没有。

甚至我还有层次更高的考虑。我写这篇文章,就是要向大众展示真实的自己。我不怕别人看到自己的缺点。我不会去打造一个完美无缺的人。不论以后我做成了多大的事情,我都只是一个心思很普通的人。我要拿这篇文章去约束未来的自己。

当然,我准备发表这篇文章时,还有更多的考量。这些层次非常高的考量是我潜意识里计划的,我现在一下都总结不出来。我说的很多东西,现在听起来都是吹牛,但我相信过个几年回头来看,我现在说的东西都是必然的预言。

可是,这样的写作可持续吗?

不可能的。

我再怎么如自己所期盼得铁石心肠,也不愿意天天把自己的事情拿出去展示。不管是好的事情,还是坏的事情。讲好的事情,就是拿自己过去的成就吹嘘;讲坏的事情,就是卖惨,博同情。这两件事我都不愿意做。我更希望我的作品受到关注。那些东西是实实在在的应该拿出来展示的东西。

我的思绪有点乱,一下也表达不清我想说什么。好不容易写一次个人博客,我也不想改文章了。我就想想到哪写到哪。总之,以后,我会在分享个人经验的时候更加慎重。我既不要过分卖弄成就,也不要刻意地去塑造一个很惨的形象。发个人经历的文章之前,要更加慎重。

还有一点,我很不开心。对比之下,看技术文章的人寥寥无几,而输出情感的文章倒是喜闻乐见。这里面的道理我肯定懂。受众、花费的精力……很多东西决定了文章有没有人看。我从类型上非常能够理解。但心里就是不好受。

所以想来想去,思绪还是回到了之前那个问题上:我为什么要去做自媒体?

我一直没有单独谈这个问题。只在我个人博客的这个地方这个地方稍微谈了一下。现在可以认真想一下了。

我还确实是一个能不说谎就不说谎的人。哪怕是讲真话没什么意义,我也会毫不在意地表示自己的真实想法。我就是要让自己一直做一个坦荡的人。我在第一篇DL教程开头就讲了,“诲人不如诲己”。写文章,本来就是为了提升我自己的学习效率。也确实,自从开始在自媒体上发文章后,我每周都很固定地会写好教程,学习的效率高了很多。同时,还有几个人说愿意跟着教程学。我也不管是真的假的,只要能帮助到人,我就非常开心。因此,发教程,不管有没有人看,我都照发不误。

这正常地发下去,倒也没什么关系。可是,发完个人经历感想,受到了一定程度的关注后,我反而开始动摇了:“是不是以后都得写这种文章才有人看?”,“我写技术文章还有意义吗?”,之类的想法不断涌现。

我必须得重新审视一下我搞自媒体的意义了。

我向来就讨厌迎合他人,迎合社会,迎合任何人订下的评价标准。开始发自媒体,我还以为是自己终于肯变通一下,肯去迎合一下这个世界的规则了。甚至当时我还嘲笑了自己的不坚定。

但现在我发现,我根本没变。我就是一个无比自傲的人。我就是能够创作出好的东西,我就是能给他人带来价值。凭什么我要去迎合别人的想法?凭什么我要去宣传?我不服气啊。

我之前之所以没想这么多,就是如前面所写的,我一直抱有的是提升自我,顺便分享给他人的心态。

但我一想到要最优化我在自媒体上的名气,我就很不服气。我就是不像刻意去为了提升影响力而提升影响力。我要靠我展示出来的真正的东西来提升影响力。无论是艺术作品,还是教程,还是科研产出,还是其他什么的。我不想花一丝一毫在宣传上。我就只想把自己的东西做到极致,让他人主动来我这里看东西。

没办法,我内心的理性都很清楚,这个世界的规则是怎么样的,自媒体是怎么运行的。理性思考指出,我想要的事情是不太可能的。

这就是一个站着还是跪着的抉择。我没有膝盖,跪不下来,只会站着;我也没有脖子,不会向下看,只会盯着前方看。做不到的事情,就是不想做。

其实,我想搞自媒体,最初的想法就是不希望未来出现这些事。我以后要做游戏,不希望做了好游戏没人玩。所以,我想着是做视频。我的口才好,在某些领域一定能大受欢迎。但我还是害怕,害怕没人看。有人(包括我内心里质疑的声音)这就会说了:“你害怕,不就是做不好吗?还找什么借口?”那我表述得再详细一点。假如我做了自以为100分的东西,客观上能打80分,但是只收获了0.01分的关注度,换你来你受得了吗?而且创作是一个不断进步的过程,可能开始还不太习惯,做着做着就能发挥全部的才能了。这一开始就没了创作热情,还怎么做下去呢?再有,做视频,最重要的其实是时间上的成本。我暂时想不出一个时间成本低、视频节目效果好、有干货的视频项目。所以不做。

那问题又传递下去了。不敢做游戏,所以计划做视频;不敢做视频,所以计划写文章。这才是我最后决定搞文字自媒体的原因。

现在,问题又绕回来了。就是如我所料地,在没有流量的前提下,写得好但是不吸引人的东西,就是没人看。我又不太肯去最优化流量,去想法设法地宣传。这下好了,又陷入死结了。

我发完上一篇个人感想,受到一定程度的欢迎后,我的大脑一下子就想到了这么多,所以一下低沉了起来。我花了大约一小时,才把这些想法组织起来,理解了自己的心理的成因。我的思维也太跳跃了吧。

再仔细一想,问题也很简单:还做不做下去?到了该抉择的时候了。

不做也没什么关系。对我来说,靠实力把名气打响是很简单的。我现在在搞科研,搞得好,自然能收获名誉。这种名气与实力挂钩的领域是我最推崇的。另外,我也可以直接去做游戏。游戏做完了,我觉得做得足够好了,再去想该怎么宣传。不要担心卖不出去,搞好质量就行了。

对,我就是有这样的自信。不靠现在的自媒体,不去刻意宣传,还是刻意把名气打响。

只是到时候不叫座就怪不得别人了。一切都是自己菜。

那要不就还是做下去。不忘初心。

初心是什么?就是分享。发文章是零成本的事情,有人看就是赚到。

也不去做什么宣传,该怎么样就怎么样了。

我的性格就是不适合做自媒体。我讨厌过度拟合一个目标,讨厌为了目的不择手段。在受到他人赞誉的时候也没有那么强的正反馈。

但我还有一个足以忽视其他一切条件的个性:我不相信有我做不好的事情。

一般的人质疑我,我笑一笑也就过去了。问题是我开始自己质疑自己做不好了,这就令我很焦躁了。

说我做不好自媒体,说我不肯去迎合他人,说我太傲慢了。

好,我忍。

我就必须证明一下自己了。

我会再做一段时间自媒体,稍微花点心思去总结一下他人的经验,制定更详细的计划,更有智谋地完成自媒体运营这件事。

同样,我会坚持几个底限:不去刷数据;不去写没有价值的东西;不过度宣传;不影响我的主业。

如果我发现,搞自媒体要花太多精力,影响到我的其他计划的话,我就放弃。否则,我会用这件事来展示我的能力,搞一个有影响的自媒体出来。

其实写这篇文章之前,我心里基本就有了答案。写一遍只是让自己更清醒一点。我可以再次宣告道:请世人见证我的成就。

今天跟喝醉了一样,但我从来不喝酒。所以,也可以这样说,我怎么都喝不醉。

冲了个澡,醒酒了。运营自媒体和个人博客完全是两个东西。自媒体必须从用户的角度来考虑文章题材。我还是按照老计划,先零成本地把技术博客给写好来。之后,用一种处于博客和自媒体的中间形式来运营。偏自媒体的文章中,主要关注两种形式:一篇长篇大作和定期更新长期连载的小体量作品。此外,夹杂一些和大学教育、编程有关的非技术的感想文章。没必要为了文章关注量这种小事担心,做好手中的事就行了。

学习提示

这周要学习四项内容:错误分析、使用不匹配的数据、完成多个任务的学习、端到端学习。

其中,前两项内容是对上一周内容的扩展。学完这些知识后,我们能更好地决定下一步的改进计划。通过处理分布不匹配的数据,我们能够学会如何诊断一种新的问题:数据不匹配问题。之后,我们使用错误分析技术,找到模型具体的错误样例,进一步改进模型。

后两项内容分别是两项深度学习的应用技巧。我们会学习迁移学习、多任务学习这两种处理多个学习任务的方法。我们还会学习如何用深度学习把问题一步到位地解决,而不是分好几个步骤。

课堂笔记

错误分析

分析具体错误

当我们想提升模型的准确率时,一种做法是统计模型输出错误的样例,看看哪类数据更容易让模型出错。

比如,在提升一个小猫分类器的准确率时,我们可以去看看分类器最容易把其他哪种动物错分类成小猫。经过调查后,我们可能会发现一些小狗长得很像小猫,分类器在这些小狗图片上的表现不佳:

这时,我们可以考虑去提升模型在小狗图片上的表现。

但是,在决定朝着某个方向改进模型之前,我们应该先做一个数据分析,看看这样的改进究竟有没有意义。我们可以去统计100张分类错误的开发集图片,看看这些图片里有多少张是小狗。如果小狗图片的数量很小,比如说只有5张,那么无论我们再怎么提升模型辨别小狗的能力,我们顶多把10%的错误率降到9.5%,提升微乎其微;但如果错分为小狗图片的数量很多,比如有50张,那么我们最优情况下可以把错误率从10%降到5%,这个提升就很显著了。

更系统地,我们可以建立一套同时分析多个改进方向的数据分析方法。比如说,在小猫的错误样例中,一些输入样本是很像小猫的小狗,一些输入样本是其他大型猫科动物,一些输入样本过于模糊。我们可以挑一些错误的样例,分别去记录这些错误样例的出现情况:

在这个表格中,我们可以记录每张分类错误的图片是由哪一种错误引起的,并留下一些备注。

调研已有问题的同时,我们还可以顺便去发现一些新的问题。比如我们可能会发现某些错分类的图片加了滤镜。发现这个新问题后,我们可以去表格中新建“滤镜”这一列。

手动分析完所有样例后,我们统计每种错误的百分比,看看改进哪种问题的价值更大。

清理标错的数据

在有监督学习中,标注数据往往是人工完成的,数据的标签有误也是情理之中的事。那么,如果数据中有标错的数据,它们会对模型的表现有什么影响呢?

首先,来看训练集有误的影响。事实上,深度学习算法对随机错误的容忍度很高。如果有少量样本是不小心标错的,那么它们对训练结果几乎没有影响。但是,如果数据中有系统性错误,比如白色的小狗全部被标成了小猫,那问题就大了,因为模型也会学到数据集中这种错误的规律。

接着,我们来看开发集有误的影响。为了确认标错数据的影响,我们应该用刚刚的表格统计法,顺便调查一下标错数据的比例:

在开发集误差不同时,标错数据产生的影响也不同。假设我们分别有一个开发集误差为10%的分类器和一个误差为2%的分类器:

对于第一个分类器,总体占比0.6%的错标数据相对于10%的开发集错误率几乎可以忽略。但是,对于第二个误差为2%的分类器,0.6%的错标数据就显得占比很大了。在这种情况下,假如有同一个模型有两个权重记录点,一个误差为2.1%,一个误差为1.9%。由于误差的存在,我们不好说第二个记录点就优于第一个记录点。回想一下,开发集本来的目的就是帮助我们选择一个在开发集上表现更好的模型。分辨不出更好的模型,开发集就失效了。因此,我们必须要去纠正一下这些开发集中的错标数据。

在纠正错标数据时,我们要注意以下几点:

  • 由于开发集和测试集应来自同一个分布,纠正数据的过程应该在开发集和测试集上同步进行。
  • 不仅要检查算法输出错误的样本中的错标样本,还要考虑那些标注错误却输出正确的样本。
  • 不一定要去训练集上纠正错标样本,因为训练集和开发集/测试集可以来自不同的分布。

吴恩达老师建议道,尽管很多人会因为检查数据这件事很琐碎而不愿意去一个一个检查算法输出错误的样本,但他还是鼓励大家这样做。他在自己领导的深度学习项目中,经常亲自去检查错误样本。检查错误样本往往能够确认算法之后的改进方向,在这件事上花时间绝对是值得的。

快速构建第一个系统,再迭代更新

在面对一个全新的深度学习问题时,我们不应该一上来就花很多时间去开发一个复杂的系统,而是应该按照下面的步骤尽快开始迭代:

  • 快速建立开发集、测试集和评估指标以树立一个目标。
  • 快速构建一个初始的系统。
  • 使用偏差和方差分析、错误分析来获取后续任务的优先级。

简而言之,就是:快速构建第一个系统,再迭代更新。

当然,如果你在这个问题上已经很有经验了,或者这个问题已经有很多的科研文献,那么一上来就使用一套较为复杂却十分成熟的系统也是可以的。

这种快速迭代的思想同样适用于人生中的其他任务。比如,软件开发中,敏捷开发指的就是快速开发出原型,再逐步迭代。同样,我们在计划做一件事时,不必事先就想得面面俱到,可以尽快下手,再逐渐去改良做法。

不匹配的训练集与开发/测试集

在不同分布上训练与测试

到目前为止,我们已经多次学习过,开发集和测试集的分布必须一致,但是它们与训练集的分布不一定要一致。让我们来看一个实际的例子:

假设我们要开发一个小猫分类的手机程序。我们有两批数据,第一批是从网站上爬取的高清图片,共200,000张;第二批是使用手机摄像头拍摄上传的图片,有10,000张。最终,用户在使用我们的手机程序时,也是要通过拍照上传。

现在,有一个问题:该如何划分训练集、测试集、开发集呢?

一种方法是把所有数据混在一起,得到210,000张图片。之后,按照某种比例划分三个集合,比如按照205,000/2,500/2,500的比例划分训练/测试/开发集。

这种方法有一个问题:我们的开发集和测试集中有很多高清图片。但是,用户最终上传的图片可能都不是高清图片,而是模糊的收集摄像图片。在开发集和测试集中混入更简单的高清图片会让评估结果偏好,不能反映模型在实际应用中的真正表现。

另一种方法是只用手机拍摄的图片作为开发集和测试集。我们可以从手机拍摄的图片里选5,000张放进训练集里,剩下各放2,500张到开发/训练集里。这样的话,开发集和测试集就能更好地反映模型在我们所期望的指标上的表现了。

总结来说,如果我们有来自不同分布的数据,我们应该谨慎地划分训练集与开发/测试集,尽可能让开发/测试集只包含我们期待的分布中的数据,哪怕这样做会让训练集和开发/测试集的分布不一致。

不同数据分布下的偏差与方差问题

在之前的学习中,我们一直把机器学习模型的改进问题分为偏差问题和方差问题两种。而在使用不匹配的数据分布后,我们会引入一个新的分布不匹配问题。

还是在刚刚提到的小猫分类模型中,我们用第二种方法设置了分布不一致的训练集和开发/训练集。假设我们得到了1%的训练误差和10%的开发误差。但是,我们使用了不同分布的数据,开发/测试集的数据可能比训练数据要难得多。我们难以分辨更高的开发误差是过拟合导致的,还是开发集比训练集难度更高导致的。

为了区分这两种问题,我们需要划分出一个只评估一种问题的新数据集——训练开发集(Training-dev set)。训练开发集的用法和我们之前用的开发集类似,但是其数据分布和训练集一致,而不参与训练。通过比较模型在训练集和训练开发集上的准确度,我们就能单独评估模型的方差,进而拆分过拟合问题和数据不匹配问题了。

加入了这个数据集后,让我们对几个示例进行改进问题分析。

假设人类在小猫分类上的失误率是0%。现在,有以下几个不同准确率的模型:

误差/样本 1 2 3 4
训练误差 1% 1 10% 10%
训练开发误差 9% 1.5% 11% 11%
开发误差 10% 10 12% 20%
问题诊断 高方差 数据不匹配 高偏差 高偏差、数据不匹配

也就是说,在多出了数据不匹配问题后,我们可以通过加入一个训练开发集来区分不同的问题。

当然,数据不匹配不一定会加大误差。如果开发/测试集上的数据更加简单,模型有可能取得比训练集还低的误差。

结合上周的知识,总结一下,考虑数据不匹配问题后,我们应该建立如下的表格:

首先,我们要知道训练集上人类的表现,以此为贝叶斯误差的一个估计。之后,我们要测训练误差和训练开发误差。训练误差和人类表现之间的差距为可规避偏差,训练开发误差和训练误差之间的差距为方差。最后,我们计算开发/测试集误差,这个误差和训练开发误差之间的差距为数据不匹配造成的差距。

一般来说,只把上述内容填入表格即可明确当前模型存在的问题。不过,如果我们能够获取开发/测试数据分布上的人类误差和训练误差,把上表填满,我们就能获取更多的启发。比如上表中,如果我们发现在开发/测试数据上人类的表现也是6%,这就说明开发/测试数据对于人类来说比较难,但是对模型来说比较简单。

完成多个任务

迁移学习

深度学习的一大强大之处,就是一个深度学习模型在某任务中学习到的知识,能够在另一项任务中使用。比如在计算机视觉中,目标检测等更难的任务会把图像分类任务的模型作为其模型组成的一部分。这种技术叫做迁移学习

假如我们有一个通用图像识别的数据集和一个医学图像识别数据集,我们可以先训好一个通用的图像识别模型,再对模型做一些调整,换医学图像数据上去再训练出一个医学图像识别模型。

具体来说,以上图中展示的情况为例,我们可以在训练完通用图像识别模型后,删掉最后一个输出层,初始化一个符合医学图像识别任务要求的输出层。之后,我们使用医学图像来训练。在这个过程中,如果新数据较少,我们既可以只训练最后的输出层,而保持其他层参数不变;如果新数据够多,我们可以让所有参数都参与训练。

这里还要介绍两个重要的深度学习名词。如果换新数据后要训练所有参数,则换数据前的训练过程称为预训练(pre-training) ,换数据后的训练过程称为微调(fine-tuning)

在上面的例子中,我们只是删掉了一个输出层,加了一个输出层。实际上,删哪些层换哪些层都没有一定的标准。如果任务变得更难了,我们可以删一个输出层,再加几个隐藏层和一个输出层。

迁移学习最常见的场合,是我们想完成训练数据较少的B任务,却在相似的A任务中有大量的训练数据。这时,我们就可以先学A任务,再迁移到B任务上。如果A、B任务的数据量差不多,那迁移学习就没什么意义了,因为同样是一份数据,对于B任务来说,一份B任务的数据肯定比一份A任务的数据要有用得多。

另外,迁移学习之所以能有效,是因为神经网络的浅层总能学到一些和任务无关,而之和数据相关的知识。因此,A任务和B任务要有一样的输入,且A任务的浅层特征能够帮助到任务B。

多任务学习

在刚刚学的迁移学习中,模型会先学任务A,再学任务B。而在另一个面向多个任务的学习方法中,模型可以并行地学习多个任务。这种学习方法叫做多任务学习

还是来先看一个例子。在开发无人驾驶车时,算法要分别识别出一张图片中是否有人行道、汽车、停止路牌、红绿灯……。识别每一种物体是否存在,都是一个二分类问题。使用多任务学习,我们可以让一个模型同时处理多个任务,即把模型的输出堆叠起来:

这里,一定要区分多个二分类问题和多分类问题。多分类中,一个物体只可能属于多个类别中的一种;而多个二分类问题中,图片可以被同时归为多个类别。

使用多任务学习时,除了输出数据格式需要改变,网络结构和损失函数也需要改变。多个二分类任务的网络结构和多分类的类似,都要在最后一层输出多个结果;而误差和多分类的不一样,不使用softmax,而是使用多个sigmoid求和(每个sigmoid对应一个二分类任务)。

此外,多个二分类任务和多分类任务还有一个不同。在执行多分类学习时,由于所有任务都用统一的数据,数据的标注可能有缺失。比如某几张图片可能没有标出红绿灯,另外几张图片又没有标出人行道。在多任务学习中,我们是允许数据中出现“模糊不清”的现象的,可以把没有标注的数据标成”?”。这样,碰到标注是”?”的数据时,我们就不对这一项进行损失函数的计算。

和迁移学习一样,多任务学习在使用上有一些要求。

首先,所有任务都必须受益于相同的浅层特征。这是显而易见的。

其次,每类任务的数据集都要差不多大。在迁移学习中,我们有比较大的数据集A和比较小的数据集B。而在迁移学习中,假如我们有100项任务,每种数据有1000条数据。对于每一项任务来说,其他99项任务的99000条数据就像数据集A一样,自己的1000条数据就像数据集B一样。

最后,经研究,只有当神经网络模型足够大时,使用多任务学习才至少不比分别学习每个任务差。

在实践中,迁移学习比多任务学习常见得多。

端到端深度学习

深度学习的另一大强大之处,就是端到端(end-to-end)学习。这项技术可以让搭建学习算法简单很多。让我们先看看端到端学习具体是指什么。

不使用深度学习的话,一项任务可能会被拆成多个子步骤。比如在NLP(自然语言处理)中,为了让电脑看懂人类的语言,传统方法会先提取语言中的词语,再根据语法组织起词语,最后再做进一步的处理。而在端到端学习中,深度学习可能一步就把任务完成了。比如说机器翻译这项NLP任务,用深度学习的话,输入是某语言的句子,输出就是另一个语言的句子,中间不需要有其他任何步骤。

相较于多步骤的方法,端到端学习的方法需要更多的数据。仅在数据足够的情况下,端到端学习才是有效的。下面,我们来看一个反例。

在人脸识别任务中,输入是一张图片,输出是图片中人脸的身份。这里有一个问题:识别人脸之前,算法需要先定位人脸的位置。如果使用端到端学习的话,学习算法要花很长时间才能学会找到人脸并识别人脸的身份。

相比之下,我们可以把这个人物拆成两个阶段:第一阶段,算法的输入是图片,输出是一个框,框出了人脸所在位置;第二阶段,输入是框里的人脸,输出是人脸的身份。学习算法可以轻松地完成这两个子问题,这种非端到端的方法反而更加通用。

总结一下,非端到端学习想要优于端到端学习,必须满足两个条件:每个子任务都比较简单;每个子任务的数据很多,而整个任务的数据很少。

那么,具体哪些情况下该用端到端学习,哪些情况下不用呢?我们来看看端到端学习的优缺点:

优点:

  • 让数据说话。相较于手工设计的某些步骤,端到端学习能够从海量数据中发现于更适合计算机理解的统计规律。
  • 减少手工设计的工作量,让设计者少花点精力。

缺点:

  • 可能需要大量的数据。
  • 排除了可能有用的手工设计的东西。比如人脸识别中,显然,找出人脸是一个绕不过去的子步骤。

归根结底,还是数据量决定了是否使用端到端学习。在复杂的任务中,要达成端到端需要非常非常多的数据,在不能够获取足够数据之前,还是使用多阶段的方法好;而对于简单的任务,可能要求的数据不多,直接用端到端学习就能很好地完成任务了。

总结

这周的知识点如下:

  • 错误分析
    • 用表格做错误分析
    • 统计错标数据
  • 数据不匹配
    • 何时使用数据分布不同的训练集和开发/测试集
    • 训练开发集
    • 如何诊断数据不匹配问题
  • 完成多个任务
    • 迁移学习的定义与常见做法
    • 预训练、微调
    • 多任务学习的定义
    • 多个二分类任务
    • 迁移学习与多任务学习的优劣、使用场景
  • 端到端深度学习
    • 认识端到端学习的例子
    • 何时使用端到端学习

和上周一样,这周的知识都是一些只需要了解的概念,没有什么很复杂的公式。大家可以较为轻松地看完这周的内容。

另外,这周也没有官方的编程作业。