U-net
在准备这节课时,我看了很多比较老的论文,想象如果作者有了我们现在的这些现代技术会怎样,我尝试用更现代的方式将论文内容重新实现。我最近一直在重建我们下面会看到的模型架构,名叫U-net. 是一种更现代的方式重建的。我们现在向你们展示的,这份语义分割论文,使用了最先进的技术和Camvid数据集,正确率高达91.5%。这周我达到了94.1%。
稍后我会展示我用的模型。我们一直在不断地优化,这其实就是个不断使用各种现代技巧的过程。我们今天会给你们展示其中的多个技巧。还有一些会在课程的第二部分中看到。
所以我要做的就是,我们要使用U-Net来实现。我们已经用过U-Net了,在那之后我把它优化了一下。我们之前用过U-net了,当我们做CamVid分割时使用过它,但不清楚它做了什么。现在我们能够理解我在做什么。首先,我们需要做的第一件事就是理解图像分割的基本概念。你们回头看下我们的CamVid notebook代码,在CamVid notebook里,你会记起,基本上我们做的就是为这些拍摄的照片的每个像素添加一个类别。因此当将data.show_batch
用于某事,这是一个SegmentationItemList
.
它会自动展示这些经过颜色编码的像素。
这是一个骑自行车的人,
程序需要知道这是什么。它需要真正知道行人是什么样的,它需要准确地知道行人在哪,知道这是行人的胳膊,不是他们的购物篮的一部分。要完成这个任务,它需要真正的理解图片的很多内容才能完成编码任务。事实上程序也是这样做的。如果你查看我们最好的模型结果,我肉眼是看不到任何一个像素的。我知道其中肯定会有一些错误,但出错的区域小到肉眼看不到。它是怎么做到的?
我们所用的方法,能够得到如此好的结果,不出所料是用了预训练。
开始,我们使用了一个ResNet34,你可以在这里看到 unet_learner(data, models.resnet34,...)
,如果你不写pretrained=False
,默认情况,用的是pretrained=True
。为什么不呢?
我们开始用了一个ResNet34,模型构架是
就这样每次Conv减半,再减半,再减半,再减半,直到像素尺寸减到28x28,共有1024个通道。
这是U-Net的下采样(downsampling)路径(左半边叫做下采样)。我们的只是一个 ResNet34,你可以用learn.summary()看到这是一个ResNet34。
你可以看到它的大小持续减半,通道数量持续增加。
使用ResNet架构,输入224个像素的图像,会得到512通道的7x7像素尺寸。在特征图上,这这样的网络相当小。从某种程度上说,我们最终要得到的结果要、和原始图片的尺寸大小一致。我们怎样做呢?怎样做增加网格尺寸的计算来增加网格大小?以我们目前的经验,没有做这个的方法。我们可以用一个步长是1的卷积做计算来保持网格大小,或者步长是2的卷积做计算来把网格大小减半。
那怎样来加倍网格大小呢?我们做一个stride half conv(步长是一半的卷积),也就是deconvolution(反卷积),也被叫做transpose convolution(转置卷积)。
有一个极好的论文,叫A guide to convolution arithmetic for deep learning,里面有一个很棒的图片展示了一个3x3卷积核进行反卷积(half Conv)的大致情况。如果你有一个2x2的输入,如蓝色方块所示。你不单单在周围添加两个像素的padding,还在每个像素之间添加一个像素的padding。现在,如果你把这个3x3的核放在这里,这里,和这里,你可以看到这个3x3的核沿着通常的路径进行移动。最终你会将一个2x2的输出变成5x5的输出。如果你只在每一边的周围添加一个像素的padding,你会得到一个4x4的输出。
这就是增加分辨率的方法。这是一两年之前,人们使用的方法。有另外一个提升图像分辨率的方法。这其实是一个笨方法,很明显这是一个笨方法,有几个原因。一个原因是,看看左边这些阴影的区域,几乎所有这些像素都是白色的。像素值基本都是0。这是多么浪费,多么浪费时间,多么浪费算力。这里其实什么都没有做。
还有,我们到这个3x3的区域时,9个像素里有2个不是白的,但是左边这个,9个像素里只有1个不是白的。这就像有不同数量的信息被送入不同区域的卷积。这样做很不合理,比如抛弃这样的信息,执行不必要的运算,允许不同区域的卷积,处理不同数量的信息。现在大家通常的做法,非常简单。如果你有个,比如说,2x2的输入,这些是像素的值(a,b,c,d),你想创建一个4x4的网格,为什么不直接这样做?
我现在把2x2放大到4x4。我没有做什么有意义的运算,现在这个4×4的网格上面,我可以做一个步长是1的卷积,所以我执行了这个运算得到了4×4的网格。
一个upsample(上采样)被叫做 nearest neighbor interpolation(最近邻插值)。这很便捷,非常好。使用最近邻插值,然后做一次步长是1的卷积,现在你已经执行了某种运算,这种运算使用了,这里没有0的4x4矩阵做了一些计算,这样很好,因为得到了A和B的混合值,这也许就是你需要的。
另外一个方法是,不再使用最近邻插值,你可以用双线性插值(bilinear interpolation),就是不再把a复制到所有不同的格子,而是取附近格子的加权平均,周围的单元值来作为差值。例如,你想知道这个格子应该填充什么值,你会去估算,这里应该是3个a,2个c,1个d,2个b,
你就可以求出平均值。不是很准确,但八九不离十了。只要用双线性插值法找加权平均值,任何区域的值你可以找到,这是非常标准的方法。每次你在电脑屏幕上看一个图片,改变它的大小,就是在做双线性插值。所以你可以做一个双线性插值,然后做一个步长是1的卷积。这就是人们在用的,以后也会用的方法。这一部分就讲这么多,在课程第二部分,我们会学到,fastai库实际上做的是pixel shuffle,也叫sub pixel convolutions。它不是特别复杂,但今天没时间讲它。它们的思路都是一样的。所有这些东西都是让我们做一个卷积,来把尺寸加倍。
这就是我们的上采样(upsampling)路径。它让我们的图像从28x28到54x54,持续放大,这很好。在U-Net出来之前人们用的这样的方法,但它效果不是很好,不奇怪,在这个28x28特征图里,怎么会有足够的信息来重新构建成一个572x572的输出空间?这是很难的任务。你倾向于牺牲一些细节,来得到你想要的的结果。 那Olaf Ronneberger et al. 做的是,他们说,嘿,让我们添加一个skip connection,和一个identity connection,很神奇,这个方法甚至出现在Resnet之前。这真是一个巨大的飞跃,令人印象深刻。但与其添加一个skip connection来跳过每两个卷积层,他们把skip connection添加在了灰线这里的。
换句话说,他们把跳跃连接添加到了从下采样过程到上采样过程的相同尺寸的地方。他们没有用加法,这就是为什么你可以看到这里白色和蓝色方块挨在一起,他们没有相加,而是两边连接(concatenate)在一起。所以基本上就像是紧密的积木拼在一起。但skip connection跳过越来越多的架构,所以在这里(顶部灰箭头),
你就是将输入像素接入最后几层的计算。这样就能够很方便解决图像分割中的细节。因为你基本上拿到了所有细节。缺点是,到这里(右上方)你没有做太多层的计算,
只有4层。所以你最好能在这个阶段,做了所有必要的计算来识别出这是一个骑自行车的人还是一个行人,但是你也可以在之后在上面说明一些内容。比如说,这是不是他们鼻子末端的确切像素,或者这是不是树的起点。这个效果很好,这就是U-Net。
这是一段fastai里的U-net代码,关键的东西是encoder。这个encoder代表U-Net的下采样部分,
换句话说,在我们这个例子里,是ResNet-34。在很多情况下,他们有像这样比较老的架构,但是就像我说的,用ResNet替换更老的架构,效果会提升很多。特别是如果他们经过了预训练效果会更好。在我们这个例子里也是如此。所以从我们的编码器开始。 所以,我们的U-Net的layers是一个encoder,然后batch norm,再之后是ReLU,然后middle_conv(也就是conv_layer,conv_layer)。
提醒一下,在fastai里conv_layer只是一个conv,ReLU,batch norm 。所以,这个middle_conv是最底部的两个额外的步骤(蓝色线框)。
在最底部,只是用来做少量的计算。最好尽可能地添加计算层。所以是编码batch norm,ReLU,然后两个卷积层。然后我们遍历这些索引(sfs_idxs)。
这些索引是什么?我们发现它们是这些步长是2的卷积出现的层数,我们把它放进一个索引的数组里。然后我们可以遍历它,基本上对这里每一个Index建立UnetBlock来告诉我们有多少个上采样通道以及有多少个交叉连接。这些灰线叫cross connection,至少我是这样叫的。 这就是UnetBlock里主要的工作。
像我说的,我们做了一些调整,我们用了更好的encoder,我们在整个上采样也做了一些调整,用了pixel shuffle,我们用了另外一个叫ICNR的技巧,还有一个技巧,我们上周讲过的,不仅仅获得卷积结果,再把它传递下去,我们还抓取了输入像素,并把它们做成了交叉连接。也就是这里的last_cross。
你可以看到我们append一个有原始输入值的res_block(你可以看到我合并了层)。 这就是UnetBlock里的主要内容,
UnetBlock一定要在每一步下采样点存储激活值,实现这个的方式,就是我们在上节课学的,使用hook。我们把hook放到ResNet34里存储激活值,每当有一个步长是2的卷积时,这里你可以看到,我们取这个hook(self.hook=hook)。
然后获取hook存储的结果,然后我们直接用torch.cat,这样我们就能连接上采样卷积和我们做过batch norm处理过的hook存储的结果,然后我们对它做两次卷积运算。
你在家可以尝试修改这里(最后一行)。每次你看到这样的两个卷积,这里有个明显的问题:如果用ResNet block替代会怎样?你可以试试吧这两个卷积层替换成Resnet Block,你可能会得到更好的结果。这是我在看一个架构时,想要看到的东西,像是一行有两列,可能就需要一个ResNet block了。
好了,这就是U-Net,它超越了ResNet和DenseNet,这令人惊奇。它却没有在机器学习会议发表过,它实际是在MICCAI发表的,一个专业的医学图像计算会议。很多年里,在医学图像圈子之外都没有多少人知道它。实际上,用U-Net的人轻松赢得了Kaggle的分割比赛,这是它第一次被医学图像圈子之外的人注意到。然后,逐渐地,一些机器学习学术圈的人开始关注,现在每个人都喜欢U-Net,我很高兴,它棒极了。
identity connection,不管是相加的方式还是连接的方式,非常有用。它们可以让我们接近很多重要任务的最佳成绩(state of the art)。所以现在我想在另外一个任务里使用它。
下一个我想讲的任务是图像修复。图像修复是处理一张图片,现在我们不会创建一个分割遮罩,而是要尝试生成一个更好的图片。有很多方面的更好,这可能是很多不同的图像,而我们应用图片生成技术可以做的是:
- 把低分辨率图片变成高分辨率
- 把黑白图片变成彩色的
- 把被裁剪掉一部分的图片里被裁剪的那部分补全
- 拍一张照片再把它转化成素描
- 把照片变成莫奈风格的画
上完课你就知道怎么做了。
在这里,我们要做图像修复,把一个低分辨率的、低画质的、写有字的JPEG,变成高分辨率的、高画质的、移除了文字的图片。
提问:为什么在调用conv2(conv1(x))之前做concat,不是在调用之后?[49:50]
因为如果你在concat之前做卷积,没办法让两部分的通道相互作用,这样你不会有任何结果。记住,2D conv,它其实是3D的。它跨越了两个维度,但每个维度做一个点积,是秩为3的三维张量的点积。行×列×通道数。简单来说,我们想要尽可能多的交互作用。我们想说的是,我们下采样路径的部分,还有上采样路径的部分,如果你看下他们的组合,你会发现有趣的事情。所以简单来说,你想要在你的每一步计算中,有尽可能多的交互作用。
提问: 在DenseNet里,当图片/特征图的尺寸在每层间是不同时,怎样把每层连接起来? [50:54]
这是一个很好的问题。如果你有一个步长是2的卷积,你不能保持DenseNet,这就是密集网络的实际情况。在DenseNet里,dense block不断增长,这样你得到了越来越多的通道。然后你做一个步长是2的卷积,不用DenseBlock。然后这一步就过去了。然后你再走几个DenseBlocks,然后它过去了。在实践中,dense block实际上不会一直保留所有的信息,而是传递到每一个步长为2的Convs。还有很多方法处理这些瓶颈层,基本上就是让我重置一下。这也可以让我们把内存控制住,在那时我们可以决定我们要多少通道。