aoi学院

Aisaka's Blog, School of Aoi, Aisaka University

深度学习-CS231n Lecture 8 [2017版]

  • CS231n - Deep Learning Software

CPU 和 GPU 对比

CPU 与 GPU 的比较

CPU和GPU都是通用的计算机器,它们可以执行程序和一些指令。但它们在性质上是非常不同的。

CPU只有为数不多的核数,利用多线程技术,可以在硬件上同时运行多个线程,CPU可以同时运行二十种操作,虽然数量不大,但是CPU的线程实际上非常有用,它们可以实现很多操作,并且运行速度非常快,而且它们的运作相互独立。

高端的GPU有成千上万个核,GPU第一个缺点就是它的每一个核运行速度都非常缓慢,第二个缺点是每一个核可以执行的操作没有CPU多,不能跟CPU的核和GPU的核进行直接比较。 GPU的核没有办法独立操作,它们需要共同协作,即GPU多个核共同执行同一项任务,而不是每个核做各自的事情。GPU有大量核数,当我们需要同时执行多种操作的时候,GPU并行处理能力非常棒,但这些事情本质上是相同的。GPU和CPU还有一点需要指明的是内存的概念,CPU有高速缓存,但是相对比较小,我们拥有CPU的大部分缓存,都是依赖于系统内存(在台式机上RAM的容量),而GPU在芯片中内置了RAM,如果是系统RAM和GPU通信,会带来严重的性能瓶颈,所以GPU基本上自己自带相对较大的内存。GPU也有自己的缓存系统,所以在GPU内存和GPU核之间有多级缓存,实际上跟CPU的多级缓存是相似的。

CPU对于通信处理来说是足够的,它们可以做各种各样的事,GPU更擅长于处理高度并行处理的算法,其中一个典型的性能很好又完全适用于GPU的一个算法就是矩阵乘法,CPU可能会串行计算。在这种平行任务,特别是矩阵变得非常庞大的时候,GPU表现更好。


性能测试

在这些网络上做实验,GPU性能是CPU的64~76倍。

优化过的cuDNN VS 原生CUDA版代码:

所以用GPU时,我们应该使用cuDNN,可以获得更好的性能。


实践中的问题

在实际中有一个问题是,在训练网络的时候模型可能存储在GPU上,比如模型的权重存储在GPU内存上,但是庞大的数据集却存储在电脑硬盘上,如果不小心就可能让从硬盘读取数据的环节成为训练速度的瓶颈,因为GPU速度非常快,它计算正向,反向传播的速度非常快,但是从旋转的机械硬盘上串行地读取数据会拖慢训练速度。有一些解决办法,如果训练数据集非常小,可以把整个数据集读到内存中,也可以用固态硬盘代替机械硬盘,这样读入速度会有较大提升。另外一些常用的思路是在CPU上使用多线程来预读数据,把数据读出内存或者读出硬盘,存储到内存上,这样就能连续不断地将缓存数据比较高效地送给GPU,但是这些做法不容易实现,因为GPU太快了,如果不快速将数据发送给GPU,仅仅读取数据这一项就会拖慢整个训练过程。

  • 学生提问1:在写代码时,怎样才能有效地避免前面提到的问题?
  • 从软件上来说,可能能做到的最有效的事情是设定好CPU的预读内容,比如避免这种比较笨的序列化操作,先把数据从硬盘里读出来,等待小批量的数据,一批一批地读完,然后依次送到GPU上,做正向和反向传播,按顺序这样做。如果有多个CPU线程在后台从硬盘中搬运出数据,这样可以把这些过程交错着运行起来,在GPU运行的同时,CPU的后台线程从硬盘中读取数据,主线程等待这些工作完成,在它们之间做一些同步化,让整个流程并行起来。但是这些实现起来比较麻烦,好在现在主流深度学习框架已经替我们完成了这些工作。

常用深度学习框架

被主流使用的第一代深度学习框架大多是由学术界完成的,但是下一代深度学习框架全部由工业届产生。

计算图思想和深度学习框架

无论何时我们进行深度学习,都要考虑构建一个计算图来计算任何我们想要计算的函数。比如在线性分类器的情况下,我们对数据X和权值W进行矩阵乘法或者做一些Loss函数来计算损失,也会使用一些正则项,我们企图把这些不同的操作拼起来,成为图结构。

在大型神经网络面前,这些图结构会非常复杂,有很多不同的层不同的激活函数,不同的权重,如果我们画成计算图会非常庞大,甚至不能画出来,所以深度学习框架意义重大,我们使用这些框架有三个主要原因:

  1. 这些框架可以使我们非常轻松地构建和使用一个庞大地计算图,而且不用自己去管那些细节的东西。

  2. 可以很方便地自动计算梯度,可以处理所有反向传播的细节,所有我们可以仅仅考虑如何写出我们网络的前向传播,反向传播会直接被给出。

  3. 这些东西可以高效地在GPU上运行,我们不用关注一些底层的硬件上的细节,像cuBLAS,cuDNN,CUDA以及数据在CPU和GPU内存之间的移动。

我们可能能自己用numpy构建一些简单地神经网络,但是Numpy的缺点是它不能运行在GPU上,而且这种情况下只能自己计算梯度。


tensorflow

如图所示,tensorflow中通常可以把计算划分为两个主要阶段,1.首先我们先用一段代码来定义我们的计算图,就是上半部分红色框的部分,2.然后可以定义自己的图,这个图会运行数次,实际上可以将数据输入到计算图中,去实现任意想要的运算。这是tf中非常通用的一种模式,首先用一段代码构建图,然后运行图模型,重复利用它。

代码分析

对红色框中代码进行分析:

从顶部的代码可以看到,我们定义了X,Y和W,并且创建了这些变量的tf.placeholder对象,所以这些变量会成为图中的输入结点,这些结点会是图中的入口结点,当我们运行图模型时,会输入数据将它们放到我们计算图中的输入槽中,然后我们用这些输入槽,就是这些符号变量,在这些符号变量上执行各种tensorflow操作以便构建我们想要运行在这些变量上的计算。因此基于这些情况我们做了一个矩阵乘法,使用tf.maximum来实现relu的非线性特性,然后用另一个矩阵乘法来计算我们的输出预测结果,然后我们又用基本的张量运算来计算欧式距离,以及计算目标值y和预测值之间的L2损失。需要指出的是,这里几行代码没做任何实质上的运算,目前系统里还没有任何数据,我们只是建立计算图数据结构,来告诉tensorflow当输入真实数据时,我们希望最终执行什么操作。因此这仅仅是建立图模型,没有做任何操作。然后在完成损失值运算后的一行代码是让tf去直接计算损失值在w1和w2方向上的梯度。

对蓝色框中代码进行分析:

这时候我们已经完成了计算图的构建,内存中已经存储了计算图数据结构的数据,现在我们进入一个tensorflow的会话,来实际运行计算图并且输入数据。一旦我们进入会话,就需要建立具体的数据,来输入给计算图。所以大多数时候,tf只是从Numpy数组接受数据,这里我们只是用Numpy为X Y和w1 w2创建具体的数值,并在字典中进行存储。这时候我们才是真正在运行计算图,可以看到我们调用了session.run来执行部分计算图的运算,并且计算需要的值然后向numpy数组再次返回具体的数值。至此我们只是在计算图上完成了正向和反向传播,如果想训练网络,我们只需添加几行代码。


变量保存在计算图中

但是这里有个问题,我们每次用计算图进行前向传播时,我们实际上在对权重进行输入,我们将权重保存成numpy的形式,我们直接将它输入到计算图中,当计算图执行完毕,我们会得到这些梯度值,这些梯度值的个数与权重值的个数一致,这意味着我们每次运行图的时候,我们将从numpy数组中复制权重到tensorflow中才能得到梯度,然后从tf中复制梯度到numpy数组,我们如果只是在cpu中运行可能不是大问题,但是我们曾谈到CPU和GPU之间的传输瓶颈,要在CPU和GPU之间进行数据复制非常耗费资源,所以如果我们的网络非常大,权重值和梯度非常多,这样做就很耗费资源而且很慢,因为每个时钟周期都在CPU和GPU之间拷贝资源。tensorflow已经有了解决方案,这个想法就是将权重w1和w2定义为变量,而不是每次前向传播时都将它们作为需要输入网络的占位符,变量可以存在计算图中,并且当我们在不同时间运行相同计算图时,它都可以保存在计算图中,就是因为它们存在于计算图中,我们需要告诉tf,如何对它进行初始化。在上面的代码中,我们从计算图外部输入这些值,所以我们在numpy中对它进行初始化,但是现在它们存在于计算图中,因此tf复制初始化这些值,所以我们要执行tf.Variable(tf,random_normal((D,H))等操作,如下图:

同样也不是真正地初始化它们,当运行这些代码时,它只是告诉tf,我们需要怎么样进行初始化。在之前的例子中,我们在计算图中计算梯度,然后以numpy array的形式更新权重参数,然后在下一次迭代时利用这些更新过的权重参数,但是我们现在希望在计算图中操作,更新参数的操作也需要成为计算图中的一个操作,所以现在我们利用这个赋值函数,其能够在计算图中改变参数值:

然后这些变化的值会在计算图的多次迭代之后,仍然保存。

当我们执行计算图,训练这个网络的时候,需要首先进行一个全局参数的初始化操作,告诉tf来设置计算图中的这些操作,如下图中第一个箭头所示。当完成初始化后,我们可以以此又一次地运行计算图,这里我们只输入了数据X和标签Y,计算图中的权重参数,我们要求tf帮我们计算loss,如下图第2个箭头所示。

全部代码如下:


变量的更新

但是这里有一个bug,如果我们运行这个代码,把loss打印出来,发现它并没有进行训练:

因为我们必须明确地告诉tensorflow,我们想用new_w1和new_w2来执行操作,我们在显存中建立了这么大的计算图架构,当我们执行操作时,只告诉tf我们想计算loss,但没有执行更新的操作。所以这个问题的解决方案是,我们必须明确地告诉tf来执行这些更新操作,我们需要做的一件事是,我们应该添加new_w1和new_w2作为输出。但是这里还有一个问题,这些new_w1和new_w2都是非常大的tensor,当我们告诉tensorflow我们需要这些输出时,我们在每次迭代中就会在GPU和CPU之间执行这些操作,这样比较麻烦。所以这里有一个小技巧,我们在图中添加一个仿制结点:

因为这些仿制数据的独立性,我们就可以说仿制结点的更新有new_w1和new_w2的数据依赖性,现在当我们执行计算图时,我们同时计算loss和这个仿制节点,这个仿制节点并不返回任何值,因为我们放入节点这个数据依赖保证了当我们执行了更新操作后我们使用了更新的权重参数值。

  • 学生问题2:我们在上面的程序中,w1和w2放入了计算图中,但X和Y依然用了numpy数据,我们为什么不把X,Y也放入计算图中。

  • 回答:在很多任务中,X和Y是数据集的mini batch,所以它们在每次迭代时都是变化的,因此我们想要在每次迭代都要输入不同的值。若X,Y不是变化的,那么可以将它们放入计算图中。

  • 学生问题3:updates的含义

  • 回答:我们想要的输出是loss和updates,updates并不是一个真的数值,updates返回的是空,因为这个依赖意思是更新依赖于这些赋值操作,但是这些赋值操作在计算图里面都存储于GPU显存中,所以我们在GPU中执行这些更新操作, 而不需要把更新数值从图中拷贝出来,所以updates返回的是空。

  • 学生问题4:为什么tf.group()返回为空?

  • 回答:这是tf的小技巧,在某种方式上group()返回了tf的内部节点操作,我们需要这些节点操作来构建图,但是当我们执行图时,在session.run操作里,我们告诉它我们想要从更新中计算具体值,然后执行完这步操作后它返回空。在tf在构建图和构建图时的真实输出值之间中经常有这种间接操作,当我们执行计算图时实际上会得到一个具体值,但执行更新之后,输出就是空。

  • 学生问题5:为什么loss返回的是值,但update返回的就是空?

  • 回答:这只是更新操作的方法,loss是我们计算的一个值,当我们告诉tf我们想要计算一个tensor时,我们会得到一个具体值。但更新可以看成是一种特殊的数据类型,它并不返回值,它返回为空。


优化器

在我们想要执行不同赋值操作的地方,我们需要利用tf.group(),这比较麻烦,tf有便捷的操作来做这些事,就是优化器optimizer。

这里我们用tf.train.GradientDescentOptimizer函数,我们传入学习率这个参数值,然后调用optimizer.minimize来最小化我们的损失函数,通过这个调用我们可以知道这些变量w1和w2在默认情况下被标记为可训练,因此在optimizer.minimize里面,它会进入计算图并在计算图中添加节点,然后计算图计算关于w1和w2的损失梯度,然后用updates执行更新操作,以及进行分组和分配操作,最终会给我们一个更新值。当我们在循环中运行计算图,我们采用相同的模式来计算损失值和更新值,每次我们让计算图进行更新,它就会计算并更新计算图。

还有一个问题是,在上面例子中,我们没有在网络结构中放入偏差,我们使用偏差时必须初始化偏差并让它们保持正确的形式,如下图:

在这个例子中,我们只是显示地说明了X和Y,它们分别是数据和标签的占位符。

现在我们使用h=tf.layers.dense,(相当于定义了隐藏层),我们把X作为输入,单元数为H, 在这一行里,它设置了w1和b1(也就是偏差),它设置了形状正确的变量,并让这些变量存在于计算图中,但对于我们来说可以是隐藏的(这一整句话博主没听懂,如果有错误希望大家能帮忙指出来)。这段代码使用xavier.intializer()来为这些变量建立一个初始化策略,在之前的代码里,我们用tf.randomnormal来做这些事,但现在这种做法帮我们处理了一些细节,而且只是输出了一个h,我们可以看到,这个层里的激活函数是relue激活函数,所以这种方法可以定义很多细节。

人们在tf上建立了许多不同的高级库,其中计算图是相对较低级水平的事物,当我们正在使用神经网络时,我们有层和权重的概念,比起原始的计算图,这些概念是稍微高一点的层次,我们通常考虑的也是稍微高一些的层次,所以这些包可以从高一些的层次出发帮助我们运行神经网络。


tensorboard和分布式运行

我们可以在tf中添加一些代码,从而使用tensorboard画出训练过程中的loss曲线和其他一些内容。tf也可以分布式运行,可以将一张计算图图分块,从而在不同的计算机上运行,目前进行分布式运行,tf是最合适地选择


PyTorch

pytorch内部明确定义了三层对象。

pytorch的张量对象就像numpy数组,它是一种最基本的数组,与深度学习无关,但可以在GPU上运行。

pytorch的变量对象,就是计算图中的节点,这些节点构成了计算图,从而计算梯度等等。

pytorchde的module对象,它是一个神经网络层,可以将这些module组合起来,建立一个大的网络。

对比pytorch和tensorflow,我们可以把张量对应为tf中的numpy array。

pytorch中的变量,与tf中的张量,变量或占位符相似,它们在计算图中都算一个节点。

pytorch的module可以等价为tf.slim,tf.layers或sonnet或其他更高层次架构。

pytorch有一点需要注意的是,因为它的抽象层次很高,而且有像module这样好用得高层抽象模块,这样选择就少了,使用nn.Module就能得到很好的结果,不用担心要使用哪些更高层的封装。

代码解析

我们只使用了张量而没使用numpy完成了两层神经网络,首先建立一些随机数据:

然后使用一些操作进行前向传播:

然后按步骤进行反向传播:

然后手动更新权值:


张量

Pytorch的张量和Numpy最大的差别是张量可以在Gpu上运行。我们在CPU上运行的时候,用的dtype是:

而如果要用GPU运行,只需要把dtype改成:

所以我们可以把张量看成numpy加GPU


变量

使用变量的代码如下:

一旦我们把张量转到变量,我们就建立了计算图,可以自动做梯度和其他计算,下图中如果X是一个变量,那么X.data就是张量,x.grad就是另外一个变量,包含了损失对张量x.data的梯度。x.grad.data则是包含这些梯度值的实际的张量。pytorch的张量和变量有相同的API,任何在pythrch张量上可行的代码都可以用变量替换,而且运行同样的代码。

当我们建立了变量,每一次对变量的构造器的调用,都封装了一个张量 ,并且设置了一个二值的标记,告诉构造器我们需不需要计算在该变量上的梯度。

然后是前向传播,与之前使用张量的实现完全一样,因为API是相同的,然后计算预测值,然后计算损失。

然后调用loss.backwards()就可以得到我们需要的所有梯度值

然后我们可以用梯度对权值进行更新:

这些操作看上去都像是numpy的方式,除了梯度是自动求解。

需要注意的是,tf和pytorch中有一点不同就是tf中,我们先构建显示的图,然后重复运行它,而在pytorch中,我们在每次做前向传播时,都要构建一个新的图,这使程序看上去更加简洁。还有就是在pytorch中,我们可以自己定义张量的前向和反向,来构造新的自动求解grad的函数:

然后把这些操作放在计算图中,跟我们以前用Numpy一样:

但是一般情况下我们不需要这种操作,因为基本pytorch都定义好了。


nn结构

用nn结构的代码如下:

nn是一种更高级的封装,我们把模型定义为一些层的组合

循环体中每一次迭代时,我们都可以在模型中前向传送数据得到预测值,把预测值放入损失函数,得到损失值:

然后调用Loss.backward自动计算所有梯度:

然后在模型的所有参数上循环,进行显示的梯度下降操作来更新模型:

我们又一次看到每次做前向传播时,都建立了一张新的计算图。

Pytorch也提供了优化操作,将参数更新的流程抽象出来,并执行Adam之类的更新法则,这里我们建立了一个optimizer对象,告诉它我们想要对模型中的参数进行优化,并且可以设置学习率等超参数:

在计算了梯度之后,我们就可以调用optimizer.step来更新模型中的所有参数:


定义nn模块

我们还可以定义自己的nn模块,我们要写自己的类,这个类把整个模型定义成nn模块中的一个新的类,一个模块可以看成是神经网络中的一层或多层,它可以包含其他模块或者可训练的权重等。

上面例子中,我们通过定义自己的nn模块类,重现之前的两层网络:

在类的初始化操作时,我们分配了linear1和linear2,建立新的模块对象,然后存在类中

现在在前向传播时,我们可以使用自己的内部模块,也可以对变量使用任意的autograd操作来计算网络的输出,下面是forward操作的内容,输入作为一个变量,然后把输入的变量传给self.linear1作为第一层,用clamp函数去计算relu,再把输出传给第二层,然后就得到我们的输出y_pred:

训练的代码基本上与之前一样,建立优化器,不断循环,每次迭代中喂数据给模型,计算梯度


dataloaders

一个dataloader可以建立分批处理,也可以执行上面提到的多线程,它可以用多线程在后台来建立很多批处理和硬盘加载,所以dataloader可以打包数据

在这个示例中当需要执行自己的数据的时候,我们可以编写自己的数据集类可以读取特殊类型的数据。然后在dataloader里打包并且训练它们:

然后我们迭代dataloader对象,每次迭代的过程中都可以产生分批数据,然后在其内部重排数据,多线程加载数据。


预训练模型

pytorch提供了一些预先训练好的模型,如下图,只要让torchvision.model.alexnet(pretrained=True)

然后让它在后台跑,下载预先训练好的权重,然后我们再训练自己的模型。


Visdom

pytorch的visdom包可以可视化很多损失统计,它与tensorboard的不同之处是,tensorboard可以可视化网络结构,但它不可以。


  • 常用的深度学习框架必备的关键点:易制作的大规模计算图(Computational Graphs);易根据计算图计算梯度;使用GPU。

  • TensorFlow的Tensorboard可视化程度较高,风场方面观察计算图中的权重变化。

TensorFlow的参考链接
预训练的模型地址

静态和动态图

pytorch和tensorflow最大的不同是静态图和动态图。

tensorflow有两个操作阶段,第一步建立计算图,然后运行很多次同一个图,我们把这个叫做静态计算图。

pytorch不同的是,当我们建立一个新的计算图的时候,它会每一次前向传播都更新,所以称为动态计算图。


静态图的优势

其中一个关于静态图的优点是我们只会建一次图,然后不断复用它,然后整个框架会结合一些操作在图上做优化。一些优化器可以很容易地在静态图上进行优化,因为图始终不变。但是对于动态图可能没那么容易。

另外静态图的优点是执行序列化,对于静态图来说,一旦我们建立了图,在内存中就有了这种数据结构代表我的整个网络结构,现在我们可以用这个数据结构在磁盘中序列化,当我们有了整个网络的结构就可以存在文件中,然后可以之后再加载它们,然后运行计算图而不是再去访问最早的代码去建立图,所以在一些部署方案中比较实用。比如我们可以在序列化网络后把它部署到C++环境中,就没必要去重新用原先的代码来建立图所以这是静态图的优势。


动态图的优势

动态图的一个优点是会让代码在某些场景看起来更简洁。

比如我们想做一些条件运算,根据变量z的值想做不同的操作来计算y

在pytorch中我们用的是动态图,所以非常简单,我们可以只用正常的python控制流来操控这个事,因为我们每次都要建图,每次都会执行这个操作,在每次前向传播中可以选择不同的条件来建立不同的图,当我们结束建立,我们还可以反向传播

在tensorflow中就比较复杂了,因为我们只建一次图,这样的控制流操作就需要在tf图中一个明确地操作,所以有tf.cond在tf中,就像是if语句,但它被放到计算图中而不是python那样的控制流中,问题就是我们只会建一次图,所有的可能的控制流路径我们都需要提前建立好,在我们运行前放到图的函数中,这就意味着控制流的操作比较特殊,与python控制流操作不同

在循环关系式中,比如yt=(y(t-1)+xt)*w,每次次我们计算这个公式得到不同大小的数据序列,因为输入序列X的大小不一。

在pytorch中我们可以不关心输入序列的大小,只希望计算相同的关系式。我们在pytorch中只需要一个普通的python循环去循环我们希望展开的次数,并不依赖输入数据的大小,我们的计算图会最终是不同的大小,但是没关系,因为一次只进行一个单元的一次反向传播

但是在tf中,实现起来比较麻烦,由于我们需要首先构建所有的图,所以这个控制流的循环结构中需要在tf图中设置成一个显示的节点。tf几乎用自己的计算图重构了所有编程语言,任何控制流操作,任何数据结构都需要合并在计算图中,因此我们不能够用熟悉的Python命令在写我们喜欢的风格的代码,我们需要重新学习整个tf定义的的控制流操作方法命令。

在循环神经网络中,比如图像描述,我们使用循环网络在一个不同长度的序列上运行,在这个例子中,要生成用来描述图片的句子,依赖于输入数据的序列,句子大小不一样,输入数据的大小也就不一样,这时候动态图就很方便。

还有比如图像问答中,问题的长度不一样,输入的数据也不一样,动态图此时非常方便。

人们可以用动态图做很多cool,具有创造性的应用


caffe

caffe不同于上述架构,很多时候我们不需要写任何代码,就可以训练网络,里面有预存的二进制文件,只需要修改一些配置,不需要写任何代码。

首先将数据格式转换成LMDB格式或者HDF5格式,或者可以将图像文件夹或文本文件夹转换成可以进入caffe的脚本,然后定义计算图的结构,而不需要写代码,只需要修改prototex文件设置计算图的结构。

这些文件的缺点之一是当网络非常大时,这种设置非常不友好,比如152层的resnet结构被预训练在caffe中,它的prototxt文件有7000行,当然prototxt文件不需要自己写,可以用Python脚本生成。

然后在其他peototxt文件里设置学习率,优化算法

完成了所有配置后,运行caffe二进制文件利用train命令运行

caffe有个模型库,放了很多预训练模型,caffe有Python接口,但没有很好的说明文档,需要我们通过源代码来看如何调用接口。

caffe具有很好的前向传播模型,很适合产品化,但是它不依赖Python,在产品应用方面比较好用。


tensorflow、pytorch 和 caffe 的比较

google尝试建立一个网络结构,适用于所有深度学习场景,所有的效果集合在一个架构上也很好,我们只需要学习一种架构就可以在所有的场景上应用,包括分布式系统,产品部署,手机端,科研等所有应用场景。

facebook采用不同的策略,Pytorch更专业一点,针对科学研究将想法写成研究性代码和更快的迭代实现时pytorch非常容易实现。但如果产品化时,比如手机端,pytorch支持不太友好。而caffe在这种产品化情况时表现地很好