欢迎来到第七课!这是课程第一部分的最后一课。这节课内容比较多。不要担心。这是因为,我想让你们在课程第二部分开始之前有足够的事情做。今天讲的一些东西,我不会讲太多细节。只会指出一些东西。我会说,我们今天不会讲它。然后,在课程第二部分,会学到这些内容的细节。今天会很快地学很多东西。你可能要多看几遍,做些实验,来完全理解。我们特意这样做,给你们一些东西,让你们在接下来一两个月里能过得充实有趣。
首先,我要展示一个很酷的东西,它是几个学生做的。Reshama和Nidhin开发了一个Android和iOS app,在这个Reshma在论坛发的文章 里可以看到,他们有一个怎样创建可以真正发布到Google Play Store和Apple App Stroe的Android和iOS app的demo,这很酷。这是我知道的App Store里的第一个使用fastai的东西。非常感谢Reshama,她为fast.ai社区和机器学习社区,和女性机器学习社区 做出了贡献。她做了很多了不起的事情:提供了大量了不起的文档和教程,组织社区等等。谢谢你,Reshama。祝贺这个app发布。
MNIST CNN【2:04】
可以看到,今天我们有很多notebook。第一个要学的notebook是lesson7-resnet-mnist.ipynb。我想看一些我们上周讲过的关于卷积和卷积神经网络的东西,然后基于上述知识差不多是从头开始构建更加先进的深度学习框架。我说的从头,不是重新实现一遍先前学过的notebook,不过我会借助一下先前保存的PyTorch数据。我们使用MNIST数据集。URLs.MNIST
有完整的MNIST数据集,我们经常只用它的一个子集。
这里有一个训练集文件夹和一个测试集文件夹。当我读入数据集的时候,我要演示一些data blocks API的细节,你看到过它们是怎样用的。通常,我们通常由于用了data blocks API会把所有的函数调用代码放在一个cell里,,但现在让我们来一个cell一个cell运行。
先说是 ITemList 的类型 。这里是一个存放image的item list。然后是从哪里得到文件名list呢?这里,我们这里以递归的方式遍历文件夹, 这是文件的所在位置(path参数)。
你可以传入参数,最终函数参数会传递到pillow模块中,因为pillow或者PIL实际上管理着这些图片。这种情况下,它们是黑白的,不是RGB,所以你要用Pillow的convert_mode='L'。在这个Python Imaging Library的文档里可以看到它的convert modes的更多细节。convert_mode='L'这一个参数代表是MNIST的灰度图。
il.items[0]
PosixPath('/home/jhoward/.fastai/data/mnist_png/training/8/56315.png')
ItemList具有items
的属性,而这个item
特性是由你设定的,这里你的设置就是一列文件的名称,这就是从文件夹获得的(文件名称)。
defaults.cmap='binary'
通常,你展示的图片都是彩色的。这里,我们希望用binary color map(二值图)。在fastai里,你可以设置默认的color map。参考matplotlib文档,可以查看更多关于cmp和color map(色标图)的信息。defaults.cmap='binary' 会设置fastai的默认color map。
il
ImageItemList (70000 items)
[Image (1, 28, 28), Image (1, 28, 28), Image (1, 28, 28), Image (1, 28, 28), Image (1, 28, 28)]...
Path: /home/jhoward/.fastai/data/mnist_png
我们的image item list包含70,000个条目,这是一堆1x28x28的图片。记住PyTorch把channel放在第一个维度。它们是一个通道的28x28。你可能会想,为什么不是只有28x28的矩阵,而是1x28x28的张量。因为这样更简单。所有Conv2d里的东西和其它的东西都是处理秩是3的张量的,所以你要用一个值是1的维度放在前面。当fastai读到只有一个通道的图片时,它会自动为你做这个。
这个
.items
属性包含了可以用来创建图像的信息,这里它是文件名,如果你直接给item list后面加上索引([0]),你可以得到一个实际的 image对象。image对象有一个show方法,这就是那张图片。
你得到一个ImageItemList后,你把它分成训练集和验证集。你通常需要验证集。如果你不需要,你可以用.no_split()
方法,创建一个空的验证集。调用函数时,你必须设定如何分割初始数据,其中一个选项就是no_split()
。
这就是规定。首先创建item list
,然后决定怎样划分。这个例子里,我们按照文件夹名称来划分。
MNIST的验证集文件夹是testing。在fastai里,我用和Kaggle一样的做法。训练集是你用来训练模型的数据集,验证集(validation set)的输出是有标签的,用于验证你的模型是否能够正常运行,测试集(test set)没有公开的标签。你用它作为输入进行输出的预测,或者你可以把它们发送至竞赛平台,或者把它们发送给第三方,由他们进行标注或测试等等。所以,尽管你数据集里的文件夹叫testing,但并不意味着文件夹里的数据就是测试集(test set)。
这里有标签,所以这是验证集。
如果你想一次性预测很多组数据,与其一个一个地来做,不如使用fastai里的test=
来设置,来表示这里输入的数据是没有标签的,我只用它们做输出推断。
sd
ItemLists;
Train: ImageItemList (60000 items)
[Image (1, 28, 28), Image (1, 28, 28), Image (1, 28, 28), Image (1, 28, 28), Image (1, 28, 28)]...
Path: /home/jhoward/.fastai/data/mnist_png;
Valid: ImageItemList (10000 items)
[Image (1, 28, 28), Image (1, 28, 28), Image (1, 28, 28), Image (1, 28, 28), Image (1, 28, 28)]...
Path: /home/jhoward/.fastai/data/mnist_png;
Test: None
可以看到,我划分后的数据是一个训练集和一个验证集。
(path/'training').ls()
[PosixPath('/home/jhoward/.fastai/data/mnist_png/training/8'),
PosixPath('/home/jhoward/.fastai/data/mnist_png/training/5'),
PosixPath('/home/jhoward/.fastai/data/mnist_png/training/2'),
PosixPath('/home/jhoward/.fastai/data/mnist_png/training/3'),
PosixPath('/home/jhoward/.fastai/data/mnist_png/training/9'),
PosixPath('/home/jhoward/.fastai/data/mnist_png/training/6'),
PosixPath('/home/jhoward/.fastai/data/mnist_png/training/1'),
PosixPath('/home/jhoward/.fastai/data/mnist_png/training/4'),
PosixPath('/home/jhoward/.fastai/data/mnist_png/training/7'),
PosixPath('/home/jhoward/.fastai/data/mnist_png/training/0')]
在训练集里,每个类别都有一个文件夹。
ll = sd.label_from_folder()
我们取这个划分的数据,调用label_from_folder()
。
首先你创建一个MNIST数据集,然后划分它,然后标注它。
现在你可以看到,数据分成了输入x和输出y两块, Y就是类别对象。类别对象基本就是一个分类。
x,y = ll.train[0]
如果你对一个label list后面加上索引,
你会得到一个自变量x和因变量y。
这里,x是可以用show画出来的图片对象,y是可以打印出来的类别对象.这里输出的y的值是8.
下一个我们可以做的事情是添加变换(transform)。这里,我们不使用普通的get_transforms函数,因为我们正在做数字识别,用于识别的数字不应该左右翻转,这会改变数字的含义。你也不能旋转太多,这也会改变含义。因为这些图片很小,又经过了变焦处理,这导致这些图片非常模糊,不易辨认,因此通常对这类手写数字的小图片,你可以直接在原图上使用随机填充,因此这里我用了随机填充指令rand_pad()函数。这个指令里,对输入执行了两种变换,分别是填充和随机剪裁(random crop),因此你需要在
rand_pad
前面加上*. 对list里的数据进行两种变换,这就是我们调用的变换。这里的空数组([])指的是验证集的变换,验证集的数据没有进行变换。现在我们得到变换后有标注的list,我们就可以设置批量样本数,然后调用数据堆(databunch),加上.normalize()函数后缀进行归一化。这里我们不使用预训练的模型,因此就没有理由在这里加上imagenet_stats.如果你调用normalize而不传stats参数,程序会随机抓取每个批量的样本,然后根据批量数据决定使用哪一种归一化的stats方法,这是个好方法,如果没有预训练模型的话。现在我们已经有了数据堆了,这个数据堆里含有x,y组成的数据集,我们之前已经见过了。有趣的是,训练集中的数据已经做过数据增强了,因为你对数据进行过了变换,
plot.mult()
是一个fastai的函数,我们可以调用一些函数显示图片,这么些行列的每一行每一列,这里的函数就可以抓取图片了,从训练集中抓取第一张图片,
由于你每次从训练集中抓取数据,都要从硬盘上加载,而在加载时都会同步对图片进行变换。有时候人们就会问,你的原始图片经过了多少次变换?这个问题的回答是无穷多次。我们每次从数据集中抓取一组数据,就会同时对数据进行一次随机变换,每个人得到的结果都会有一点不同。请看这里,
如果数字8的图显示很多次,数字‘8’所在的位置都会有一些不同,因为我们对图片做了随机填充。我们可以从数据堆中抓取出一个批次的数据,数据堆是有数据加载器(data loader)的,data loader让你每次抓取一个batch的数据,
那么现在你已经抓取了x batch和y batch,批次数×通道数×行数×列数。所有的fastai数据堆都有show_batch()函数以合理的方式展示数据内容。
以上就是简单的介绍,用data block API加载数据。
Basic CNN with batchnorm
让我们开始构造一个简单的CNN卷积网络,输入是28×28。我喜欢这样定义函数,当我构建模型架构的函数时,我不厌其烦做的就是每次重新定义架构函数,因为我不想调用和之前相同的函数变量,因为我会忘记细节容易犯错,这里我的卷积网络kerne_size =3, stride=2 padding =1,让我们创建一个运用到这些参数的简单函数。一个卷积网络,这里的strde参数值是每步略去一个像素,stride =2,它每次跳跃2个格子,这意味着我们每次做卷积运算时,得到的网络尺寸会减半,我这里打上了备注(#)
model = nn.Sequential(
conv(1, 8), # 14
nn.BatchNorm2d(8),
nn.ReLU(),
conv(8, 16), # 7
nn.BatchNorm2d(16),
nn.ReLU(),
conv(16, 32), # 4
nn.BatchNorm2d(32),
nn.ReLU(),
conv(32, 16), # 2
nn.BatchNorm2d(16),
nn.ReLU(),
conv(16, 10), # 1
nn.BatchNorm2d(10),
Flatten() # remove (1,1) grid
)
‘#‘后面显示了每次运算后新的grid size是什么。在第一次卷积,输入是一个通道,因为输入是灰度图,只有一个通道,输出的通道数目就看你的需求了。你总是可以决定,要设置多少个过滤器,不管是不是一个全连接层,全连接层的情况下就是相乘矩阵的宽度,或者在2D卷积的情况下,对应的就是过滤器的数目。这里我设定为8,stride = 2,28×28的图像输入,变成了具有8个通道的14×14的特征图,因此我们就得到了8×14×14的张量激活值。然后做BatchNormalization,再然后做ReLu。
下一个卷积层的输入过滤器数量,必须等于上一个卷积层的过滤器数量。
我们可以持续增加通道的数量,因为stride =2,会减少图片的2D尺寸/网格尺寸,注意这边的grid size从7降到了4,因为你对16×7×7的图像做了stride运算,就会得到Math.celling(7/2)=4.
数据再经过BatchNorm ReLU Conv(卷积层)就变成2×2了,数据再经过BatchNorm ReLU卷积层就变成1×1了。这些计算过后,现在的输出尺寸就是10×1×1了,能理解吧。现在我们的网格大小就是一了。输出的数据的尺寸不是一个长度为10的向量,而是一个10×1×1的三阶张量。不过损失函数的输入一般是个向量而非三阶张量,那你可以对三阶张量调动Flatten()函数,flatten()函数所做的就是移除任何单位轴,得到的结果就是一个长度为10的向量。这就是我们想要的。
这就是创建出的卷积神经网络。我们把以上步骤变成learner学习器,把data和mdel作为输入参数,还有可选的参数metrics,损失函数使用的还是先前的CrossEntropyloss。
learn = Learner(data, model, loss_func = nn.CrossEntropyLoss(), metrics=accuracy)
learn.summary()
================================================================================
Layer (type) Output Shape Param #
================================================================================
Conv2d [128, 8, 14, 14] 80
________________________________________________________________________________
BatchNorm2d [128, 8, 14, 14] 16
________________________________________________________________________________
ReLU [128, 8, 14, 14] 0
________________________________________________________________________________
Conv2d [128, 16, 7, 7] 1168
________________________________________________________________________________
BatchNorm2d [128, 16, 7, 7] 32
________________________________________________________________________________
ReLU [128, 16, 7, 7] 0
________________________________________________________________________________
Conv2d [128, 32, 4, 4] 4640
________________________________________________________________________________
BatchNorm2d [128, 32, 4, 4] 64
________________________________________________________________________________
ReLU [128, 32, 4, 4] 0
________________________________________________________________________________
Conv2d [128, 16, 2, 2] 4624
________________________________________________________________________________
BatchNorm2d [128, 16, 2, 2] 32
________________________________________________________________________________
ReLU [128, 16, 2, 2] 0
________________________________________________________________________________
Conv2d [128, 10, 1, 1] 1450
________________________________________________________________________________
BatchNorm2d [128, 10, 1, 1] 20
________________________________________________________________________________
Lambda [128, 10] 0
________________________________________________________________________________
Total params: 12126
现在我们可以调动learn.summary()函数,可以验证输入1×28×28-->第一个卷积层-->8×14×14-->第二个卷积层-->16×7×7,依次32×4×4,16×2×2,10×1×1,flatten过后的输出层,被称为lambda.
你可以看到10×1×1,现在变成了孤零零的10,批次里的每个输入,对应的输出就是一个长度为10的向量。所以整个mini-batch的输出就是128×10的矩阵。
我们浏览一遍就是为了确认里面没有问题,我们可以取到我们之前创建的X的mini batch,把它放到GPU,直接调用model。
xb = xb.cuda()
model(xb).shape
Out[49] : torch.Size([128, 10])
每一个PyTorch 模块,我们都可以把它当作一个函数,它返回了一个128x10的结果,和我们预期的一样。
这就是我们怎样直接得到预测值的 。调用lr_find,fit_one_cycle()就好了,我们的CNN得到了98.6%的准确率。
learn.lr_find(end_lr=100)
learn.recorder.plot()
这是从头训练的,当然,没有用预训练的结果。我们构建了我们自己的网络架构。这是你能想到的最简单架构。用了18秒训练。这就是怎样创建一个很准确的数字识别程序,很简单。
重构(Refactor) 15:42
我们把这个重构一下。不再总是写conv、batch norm、ReLU,fastai里已经有conv_layer()函数,可以让你创建conv、batch norm、ReLU的组合。
conv_layer函数还有很多其它的选项可供选择调整,不过基础版本就是我展示的这样。因此,我们可以这样重构它:
def conv2(ni,nf): return conv_layer(ni,nf,stride=2)
model = nn.Sequential(
conv2(1, 8), # 14
conv2(8, 16), # 7
conv2(16, 32), # 4
conv2(32, 16), # 2
conv2(16, 10), # 1
Flatten() # remove (1,1) grid
)
learn = Learner(data, model, loss_func = nn.CrossEntropyLoss(), metrics=accuracy)
learn.fit_one_cycle(10, max_lr=0.1)
Total time: 00:53
和之前的神经网络一样。让我们再多训练一点,精确度能高达99.1%。如果在训练一分多钟,这很酷。
ResNet-ish 16:24
怎样能提升它呢?我们要创建一个更深的网络,要创建更深的网络一个很简单的方法是,在每个stride = 2的conv后添加一个stride = 1的conv。
因为步长是1的conv不会改变特征图的大小,你可以随意加。但有一个问题,是这个论文指出的,这是一个非常非常有影响力的论文,叫Deep Residual Learning for Image Recognition ,作者是微软研究院的Kaiming He和他的同事。
他们说,让我们来看看训练错误率。先不考虑其他指标(比如验证误差,测试误差等),只看看在CIFAR-10数据集上训练的网络的训练错误率。我们尝试用一个20层的网络,每层是3x3的卷积层,就像我刚刚给你们看的网络一样,只是没有batch norm。他们在训练集上训练了一个20层的网络和一个56层的网络。
这个56层的网络有更多的参数。中网络中有很多步长是1的卷积层。这个有更多参数的网络应该会过拟合得更严重,对吧?因此你会期望这个56层的网络的训练错误率很快收敛到0附近,但这没有发生。它比这个20层规模的网络误差还大。
当看到奇怪的事情发生时,真正优秀的研究者不会说“噢,不,它没有效果”,他们会说“这很有意思”。Kaiming He说“这很有意思,发生了什么?”。他说“我不知道,但我知道,我可以拿这个56层的网络,生成一个新的版本,跟之前的版本本质上一样的,让它效果至少和这个20层的神经网络一样好,他的方法是:
每2次卷积层,把这两个卷积的输入和这两个卷积的结果加到一起。“换句话说,不再是用:
而是用:
(这里与原论文表述的不太一致)
这样设定的话,他56层的卷积神经网络就有意义了,因为,他的理论证明了训练结果应该至少与20层的版本结果相当。因为你总是可以设定C2和C1的权重为0,对于20层以后更深的卷积层可以这么做。因为输入值x的输出值在20层后仍然是x。
所以这个如你所见(箭头所指)的地方被称为identity Connection(一致链接)。前后是一致的,没有发生什么变化,也被称为Skip Connection(跳跃连接)。
上述就是理论逻辑,是论文中提到的直觉解释。创建的这个模型,性能可以媲美20层的神经网络。也许这个神经网络包含28个隐藏层,实际上你可以跳过里面的很多卷积层。接下来发生了什么呢?他赢了那年的ImageNet比赛。他轻松地赢了比赛。事实上,直到今天,我们还在使用它。我们去年在ImageNet训练速度上破了记录。ResNet是革命性的。
ResBlock Trick 20::36
任何时候,如果你喜欢做点研究,这里有个技巧,任何时候你发现某个模型,无论是做医学图像分割,还是生成式对抗网络,或者其他目的,只要是几年前提出的,就有可能忘记将Resnet的思路考虑在内,这就是我们常说的ResBlock。
因此,如果用一系列ResBlock代替卷积路径,你几乎总能得到更好更快的结果,这是个小妙招。
可视化神经网络的损失空间 Visualizing the Loss Landscape of Neural Nets 26:16
Rachel、David、Sylvain和我刚刚参加NeurIPS会议回来,在那里,我们看到了一个新的展示,他们提出怎样可视化神经网络的损失平面,这很酷。这是一个了不起的论文,现在在看这个课的人,会理解这篇论文里最重要的概念。你可以现在阅读它。你不用全部理解它,但我敢肯定你会发现它很有意义。
如果你画一个x和y的图,这是权重空间的两个投影,z是损失度。当在权重空间中移动时,一个56层的没有skip connection的神经网络,损失平面非常凹凸不平。
所以这里没有太多进展,因为被限制在这些沟壑中。但同样拥有identity connection的网络的损失空间是这样的(右侧图)。2015年Kaiming He怎么就认识到图一所示的情况不应该发生,并且有方法可以解决,然后花了三年时间,人们才知道为什么这能解决它。这让我回忆起几周前我们讲的batch norm,人们有时会在事情发生过后一段时间,才意识到是什么起了作用。
class ResBlock(nn.Module):
def __init__(self, nf):
super().__init__()
self.conv1 = conv_layer(nf,nf)
self.conv2 = conv_layer(nf,nf)
def forward(self, x): return x + self.conv2(self.conv1(x))
在代码里,按照刚刚讲过的,我们创建了一个ResBlock。(这里的是实现也与论文不一致,但论坛里有按照论文实现这个的人说没什么区别;老师自己说先relu再batchnormlization效果稍好一点。)
我们创建了一个nn.Module,创建两个conv_layer,(记住,一个
conv_layer
是Conv2d BatchNorm、ReLU、又一个conv_layer:Conv2d ReLU BatchNorm),我们创建了两个这样的东西,然后代入forward里,我们运行conv1(x)
,然后对它运行conv2
,然后添加x
。这是fastai里的res_block函数,你可以直接调用res_block,传入参数,比如需要多少个过滤器。
这是我在notebook里定义的ResBlock,用这个ResBlock,我们可以用其中一个,我才复制了之前的卷积神经网络(CNN)除了最后一个之conv2,我在每个con2d后面添加了一个res_block,这样现在的层数是之前的三倍,因此这个网络可以做更多的计算。但不会更难优化。
然后让我们再重构一次。因为我用了conv2 res_block很多次,我们来把它们提取到一个mini的sequential模型里,这就把它重构成了这样:
如果你在尝试新的架构,持续重构可以让你更少出错。很少人这样做。你看到的大多数研究人员写的代码都很粗陋难看,因为不做重构错误重重,所以不要这样做。你们都是coder,养成好习惯,工作更轻松。
好了,这是ResNet架构。像之前一样做lr_find
,fit
一会儿,得到了99.54%。
learn = Learner(data, model, loss_func = nn.CrossEntropyLoss(), metrics=accuracy)
learn.lr_find(end_lr=100)
learn.recorder.plot()
learn.fit_one_cycle(12, max_lr=0.05)
这很有意义,因为我们是用一个从头开始构建模型架,也是从头开始训练模型,我还没有在其他地方见过这种架构,完全是首创。就0.45%这个误差而言,基本是三四年前这个数据集的最佳成绩(state of the art)。
现在MNIST被认为是一个非常简单的数据集,我不能说“哇,我们打破了一些记录”这样的话。有人得到了更低的错误率。但我要说,ResNet的现在依然非常实用,这是我们在快速训练ImageNet中所使用的全部东西。还有一个原因是,它很流行,因此其作者花了大量时间来优化它的库,因此使训练更为迅速。某些新式风格架构的模型,使用separable(可分离卷积)或group convolutions(分组卷积),通常在实践中并不能十分快速地训练。
如果你看看fastai里res_block的定义,你会看到它和这里的有点不同,
这是因为我创建了一个叫MergeLayer的东西。MergeLayer就是,我们先暂时跳过这个dense不讲它。在forward()定义这里返回(x+x.orig)。你可以看到一些ResNet风格的。什么是x.orig?如果你创建一个叫做SequentialEX的专门的sequential模型,这里的意思是fastai序列扩展。它就像一个普通的sequential模型,但我们把输入保存在x.orig。所以这个SequentialEx(conv_layer(), conv_layer(), MergeLayer())和jupyter中的(ResBlock)效果是一样的。你可以用SequentialEx和MergeLayer轻松创建你自己的ResNet block代码。
当你创建MergeLayer时,你可以选择设置dense=True,这会发生什么?如果你这样做,它不会运行x+x.orig,它会运行cat([x,x.orig])。
换句话说,
与其在跳跃连接里做加法,它做了个拼接运算,这很有意思。因为,当输入进入你的ResBlock,而你使用了拼接而不是加法,这就不能再称为ResBlock了,而是要称为DenseBlock。这种网络结构也不再称为ResNet,而是它叫DenseNet。
DenseNet是在ResNet提出一年后提出的,如果你阅读DenseNet论文,你会发现它看上去非常复杂和不同,但实际在代码上是一样的。 只不过这里的+换成了cat(concatenate)方法。
如果输入进入DenseBlock,在这里经过几次卷积,
然后你得到了输出,然后得到identity connection,记住,它不是用加法,它用了concat,这里是通道轴,它会变得略大一点。然后,再经过下一个dense block,最后,我们像以前一样,得到卷积的结果,但这次的identity block比较大。只是这次变大了。
可以看到,由于使用dense block,它变得越来越大,有意思的是,确切的输入其实在这儿。
因此,不管无论你的网络有多少层,原始输入像素都在identity block里。原始的第一层特征也是,原始的第二层特征也是。可以想象,DenseNet很耗内存。但有方法可以处理这个。就是时不时的,执行一次常规卷积来压缩通道。但它们仍会耗费内存。不过用的参数更少。因此它们往往在小数据集上效果很好。此外由于它(DenseNet)可以使原始输入像素在路径上传播,因此在图像数据方面效果很好,因为在做图像分割时你会需要,重建图片分辨率,原始像素在这里是非常有用的。
这就是Resnet的内容,还有一个重要原因就是,除了Resnets本身很棒之外,促使我们介绍ResNets是因为这些跳跃连接,在其他地方也很有用,ResNets显得格外有用,在于其不同的方式来设计模型架构,用于图像分割。