机器学习系统或者SysML&DL笔记(一)
在使用过TVM、TensorRT等优秀的机器学习编译优化系统以及Pytorch、Keras等深度学习框架后,总觉得有必要从理论上对这些系统进行一些分析,虽然说在实践中学习是最快最直接的(指哪儿打哪儿、不会哪儿查哪儿),但恶补一些关于系统设计的一些知识还是非常有用了,权当是巩固一些基础了。
前言
在使用过TVM、TensorRT等优秀的机器学习编译优化系统以及Pytorch、Keras等深度学习框架后,总觉得有必要从理论上对这些系统进行一些分析,虽然说在实践中学习是最快最直接的(指哪儿打哪儿、不会哪儿查哪儿),但恶补一些关于系统设计的一些知识还是非常有用了,权当是巩固一些基础了。
因此,有必要学习了解一下机器学习系统的设计和思想。如果不是很了解机器学习系统的设计,可以看下知乎上关于这个问题的回答:相比AI算法研究,计算机系统研究没落了吗?
以下是本系列文章的笔记来源:
注意,这一系列文章并不是传统的深度学习或者机器学习讲解,关于这部分的讲解可以看CS231n或者吴恩达的入门课,该系列文章适合在深度学习方面有经验有了解的童鞋。
附一张CSE 599W这门课(偶像tqchen主讲)一开始的讲解说明:
这门课主要的重点不在深度学习理论算法,而是与深度学习有关,机器学习系统方面的讲解。
深度学习是做什么的
不管怎么说都要简单说一下深度学习这个概念,但凡涉及到深度学习,总是脱离不了以下一些东西:
由上图可知,深度学习所包含的基本内容,也就是以下四大部分:
- 模型的设计(最核心的地方,不同的任务设计的模型不同,使用的具体算法也不同)
- 目标函数,训练策略(用于训练模型权重参数的目标函数,也就是损失函数,以及一些训练的方法)
- 正则化和初始化(与训练过程中的模型权重信息有关系,加快训练的收敛速度,属于训练部分)
- 足够多的数据(机器学习亦或深度学习都是由数据驱动着了,没有数据的话就什么都不是)
我们在学习深度学习的过程中,难免会有一些实践,于是我们就利用以下这些优秀的深度学习库去实现我们自己设计的模型,并且设计好损失函数,一些正则化策略,最终利用这些库去读入我们需要新联的数据,然后运行等待即可。
这些优秀的深度学习库(caffe、Mxnet、TF、Pytorch)我们应该都耳熟能详了,那么这些深度学习库的大概结构都是什么呢?
大部分的深度学习库都是由以下三个部分组成
- 用户API
- 系统组件
- 底层架构
大多数的我们其实并不需要接触除了用户API以外的其他两个部分,而用户API也是深度学习库最顶层的部分。
Logistic Regression
拿个例子简单说一下用户的API,首先我们有一个逻辑回归的任务,就拿最熟悉的MNIST数据集来进行演示,我们通过输入每个数字的向量化数据,然后通过全连接层去计算这些向量并且利用softmax函数进行预测。整个网络模型很简单已有一层全连接层。
接下来我们看一下使用numpy和tinyflow(这是一个迷你的深度学习系统库,麻雀虽小五脏俱全,值得我们去学习)去训练一个简单的神经网络识别MNIST手写数据库的例子:
上述代码中最重要的也就是三个部分(在上图右侧已经列了出来):
- 前向计算:因为只有一个全连接层,所以我们的计算也很简单hk=wkTxihk=wkTxi,其中xixi为输入的数据,wkTwkT为权重,hkhk为权重向量和数据向量点乘的结果。计算出来结果后我们使用softmax函数对其进行分类计算得到对应十个数字的概率向量(最后输出的向量包含10个元素,分别为每个数字的可能性)
- 反向求导:我们求出权重W关于极大似然损失的导数,这里我们人工写了出来,在右图第二部分
- 梯度更新:我们简单对权重W进行更新,更新值为学习率乘以W的梯度,即w←w−η∇wL(w)w←w−η∇wL(w),也就是我们经常用的SGD
整个深度学习库中最重要的也就是上述三个部分,大部分的深度学习库已经帮我们实现了上述的代码,我们不需要重复造轮子,直接使用即可。
尝试使用TensorFlow like API
接下来,我们使用类似于TensorFlow的API来对上述描述的代码进行重构,使用TensorFlow的API格式来实现上述代码实现的训练过程。
那么,为什么要使用TensorFlow类型的API进行演示而不是采用Pytorch呢?其实TensorFlow与Pytorch构建的图类型很不一样,虽然在最近一段时间TensorFlow已经出现了eager mode
,但经典的TensorFlow是采用静态图来构建整个训练过程的。
也就是说,在TF中构建的是静态图,而在Pytorch中构建的是动态图:
其实关于静态图和动态图对于这个话题已经经过了很多的讨论,动态图灵活多变,而静态图虽然没有动态图灵活,但是因为提前都确定好了输入参数,计算方式等等过程,系统可以针对这些特点来对计算进行规划,所以在计算过程中的性能比动态图是要高一些的。
首先我们确定要进行的前向数据以及操作算子:
- 输入x为float32类型的向量,[None,784]中None表示当前输入的batch-size未知
- W为权重信息,维度为[784,10]
- y为前向计算函数,利用算子函数嵌套定义了整个计算过程。
接下来设定了损失函数,可以看到
cross_entropy = tf.reduce_mean(-tf.reduce_sum(y_*tf.log(y), reduction_indices=[1]))
这句中声明了一个损失函数,使用的是分类任务中最常用的交叉熵损失,注意y_
代表存放么一个数据的分类label,每一个数据的分类标签是一个元素数量为10的向量,用于与预测出来的值进行比对。具体的公式在下图右侧。
接下来设定了自动求导的过程,这一步中我们要求的是权重向量对损失值(也就是交叉熵损失后计算出来的值)的梯度,这里我们只是声明了一个函数,但是其中的计算过程是比较复杂的(也就是我们常说的自动求导的过程),这个之后会进行讲解,自动求导是我们必须要掌握的,直接手撸是必须的。
设定好了损失函数,接下来我们就要拿计算出来的梯度值来更新网络的权重信息了,在这个例子中使用SGD随机梯度下降的方式进行权重更新,我们只需要设定学习率这个超参数就可以。
因为在TF中,所有的声明式都仅仅是对要计算的流程进行声明,实际上并没有计算(这里可以称为一个懒计算的方法),也就是所谓的静态图方式定义好了整个网络以及训练的步骤,只是没有开始运作而已。
在运行完sess.run
一句后,整个网络才开始正式执行起来。
这几张ppt的内容讲述了一个典型的神经网络库的运行流程(TF类型的,如果是Pytorch的话略有不同),通过这个我们可以知道一个深度学习框架的基本作用。接下来要说的就是上述过程中最为重要的自动求导过程。
计算图(Computation Graph)
计算图是实现自动求导的基础,也是每个深度学习框架必须实现的部分,接下来我们简单说说计算图是什么?
要说起计算图首先简单提一下声明式编程,正如下图中所展示的,计算图本质上是一种声明式语言,怎么说比较合适,这种语言不同于我们平时所说的python和C++,它是一种DSL(领域语言)或者一种迷你语言,这种语言深入嵌入到Python和C++中,也就是我们使用Python去操作,使用C++去具体实现。
是声明式编程不是简单地一条接一条地执行我们的指令,而是根据我们给出的所有指令创建一个计算图(computing graph)。这个图被内部优化和编译成可执行的 C++ 代码。这样我们就能同时利用上两个世界的最优之处:Python 带来的开发速度和 C++ 带来的执行速度。
最终形态就是我们提前设定好了计算逻辑,其他的交给系统就行了,具体怎么优化就不用我们去操心。
下图中就是一个简单的例子,我们定义计算的逻辑a*b+3
这个逻辑,其中node表示计算内容,箭头表示依赖关系。
下图中我们就利用TF-like API去实现一个简单的t=softmax(W∗x)t=softmax(W∗x)的过程,这个过程需要的数据(x、W)和算子(matmult、softmax)都已经提前定义好了。
接下来是设置损失函数的计算部分:
然后设定与自动求导相关的梯度下降部分:
接下来是设定梯度更新的方式:
在设定玩所有的计算逻辑后,这里正式开始计算的过程,只有执行完sess.run
这句后,整个计算图才开始真正的进行计算了。
上面关于自动求导的具体过程并没有展示,这个过程将在下一节中进行讲解,不过关于自动求导的具体流程也可以在CS231n这门课中见到,具体在Backpropagation and Neutal Networks这一节中。
numpy 与 TF-program 的比较
之前我们使用了numpy与TF-like API的方式模拟了一个简单的Logistic Regression的过程,也就是实现利用一个简单的全连接网络去识别手写数字集MNIST的过程,这个过程中我们使用了两种不同的方式去构建,用计算图来说就是动态图(numpy)与静态图(TF),而用语言类型来说就是命令式编程(numpy)和声明式编程(TF)。
其实上述的numpy的例子也可以使用Pytorch做演示,因为Pytorch是一个类numpy的深度学习库,其操作的算子逻辑和numpy几乎一致,可以说是一个利用动态图结构的领域语言。
也就是说关于numpy与 TF-program 的比较,可以等同于关于动态图和静态图的比较,两者各有优缺点,但是如果追求性能和开发效率的话,静态图更胜一筹,但是如果追求灵活性和可拓展性,那么动态图的优势就展现出来了。
未完待续
这节课的末尾提出了一些后续要讲的内容,首当其冲的就是计算图的优化。
计算图优化在TVM中这个深度学习编译器中占了很大的篇幅,正如下面所说,我们建立了计算图,因为这个计算图是静态的,所以我们可以在底层对其进行尽可能地优化,从而加快神经网络运行的速度,那么如何去优化呢?这可就是一个大学问,总之就是我们可优化的空间很大,而关于优化的具体细节放在之后进行描述。
另外一点,数据并行也是重点解决的问题之一,毕竟现在的数据越来越多,显卡计算能力虽然每年提升,但是内存(具体点就是显存)的提升有限。那么如何更高效更快地训练大量的数据,直接的途径就是并行分布式训练,如何处理并行期间出现的问题也是一个重点的方向。
相关参考
http://dlsys.cs.washington.edu/schedule
https://ucbrise.github.io/cs294-ai-sys-sp19/
https://blog.csdn.net/tealex/article/details/75333222
机器学习系统SyeML笔记三——自动微分
接下来我们就正式开始进入深度学习库的内部,来探索其具体的实现过程了。这节课的主要话题是**Backpropagation and Automatic Differentiation**,对于深度学习从业者,这是一个必须要掌握的技术,我们不光要理解其原理,更多的是将代码手撸出来。
前言
这篇文章先介绍一下自动微分吧,这一个月来对我来说真的是多事之秋,能遇上的各种事情几乎都遇上了,但愿生活节奏和工作方面能够慢慢平稳下来。
第一部分的传送门:机器学习系统或者SysML&DL笔记(一),第二部分是要介绍一些网络结构,但是没有完善,所以先把第三部分先发出来。
接下来我们就正式开始进入深度学习库的内部,来探索其具体的实现过程了。这节课的主要话题是Backpropagation and Automatic Differentiation,对于深度学习从业者,这是一个必须要掌握的技术,我们不光要理解其原理,更多的是将代码手撸出来。
以下是本系列文章的笔记来源:
同时本文部分内容也参考深度学习花书第六章《深度前馈网络》。
微分方法
我们在实际训练中,模型训练的过程主要分这几步:
- 输入图像
- 图像通过神经网络层提取到了特征信息
- 将网络层最后输出的信息输入predictor中产生最终的预测结果,一般神经网络的最后一层为输出层,这个例子中的输出层就是sigmoid层
- 根据预测结果得到损失值,进行训练(不同任务采取的损失函数和优化器不同)
正如上图所示,使用的predictor为Sigmoid function:
S(x)=11+e−x=exex+1S(x)=1+e−x1=ex+1ex
这个函数可以将输入的数据转化0-1
之间,充当激活神经元的作用。那么我们既然得到了神经网络的输出,接下来就是将输出与标签信息一同输入损失函数(L(w)L(w))去计算出损失值,随后通过优化器(SGD)对权重数据(ww)进行更新。
上述这个过程中涉及到了两个子过程:
- 前向传播(前馈网络可以被视为一种高效的非线性函数近似器)
- 反向求导与梯度更新(仅仅使得损失函数达到一个非常小的值,但并不能保证全局收敛)
线性模型,如逻辑回归和线性回归,是非常吸引人的 ,因为无论是通过闭解形式还是还是用凸优化,他都能够高效且可靠地拟合。线性模型也有明显的缺陷,那就是该模型的能力被局限在线性函数里,所以它无法理解任何两个输入变量见的相互作用
好了稍微有点啰嗦,那么这个过程中,反向求导的计算部分是怎样的?我们如何求导去更新梯度的值呢?这个时候就涉及到了自动微分的概念,说起自动微分,我们先列举下目前存在的一些微分方法:
- 手动微分
- 数值微分
- 符号微分
- 自动微分
手动微分
手动微分,顾名思义,手动微分法就是需要我们手动编写出代价函数、激活函数的求导代码,硬编码这些函数的求导方法,如果这些函数后面有调整该函数的求导方法又要重新实现,可以说是又麻烦又容易出错。
那为什么还需要手动微分,是因为有些我们自定义的算子可能比较复杂,涉及到了新的运算,目前深度学习库中实现的算子还没有提供。或者说这个算子比较特殊,虽然可以用很多算子组合起来,但是速度方面没有直接重新写一个一体的高效率,这样下来就需要重新手动写前向和反向过程了。
例如在Pytorch中我们可以使用C++拓展Cuda来编写前向和反向求导功能函数接入Pytorch中当成拓展使用。
数值微分
数值微分就是利用导数的定义来实现的,我们只需要一个F函数(目标函数)和一个需要求解的输入x即可计算出相关的梯度。当h(一般取值1e-5或者1e-6)取值很小的时候,下方的等式成立:
但是这种计算方式有两个问题(自行维基百科):
- Roundoff Error
- Truncation Error
下面的一种使用Center形式计算导数的方法可以缓解Truncation错误但是依然会有可能Round的错误。
总结一句,不具备现实的使用场景,一般用于检验其他方式求解出来导数的正确性,例如在CS231n中notebook中经常遇到的:
# As we did for the SVM, use numeric gradient checking as a debugging tool.
# The numeric gradient should be close to the analytic gradient.
from cs231n.gradient_check import grad_check_sparse
f = lambda w: softmax_loss_naive(w, X_dev, y_dev, 0.0)[0]
grad_numerical = grad_check_sparse(f, W, grad, 10)
其中grad_check_sparse
的具体实现为:
def grad_check_sparse(f, x, analytic_grad, num_checks=10, h=1e-5):
"""
sample a few random elements and only return numerical
in this dimensions.
"""
for i in range(num_checks):
ix = tuple([randrange(m) for m in x.shape])
oldval = x[ix]
x[ix] = oldval + h # increment by h
fxph = f(x) # evaluate f(x + h)
x[ix] = oldval - h # increment by h
fxmh = f(x) # evaluate f(x - h)
x[ix] = oldval # reset
grad_numerical = (fxph - fxmh) / (2 * h)
grad_analytic = analytic_grad[ix]
rel_error = (abs(grad_numerical - grad_analytic) /
(abs(grad_numerical) + abs(grad_analytic)))
print('numerical: %f analytic: %f, relative error: %e'
%(grad_numerical, grad_analytic, rel_error))
符号微分
符号微分作为一种比较常用的微分法,在Matlab、Octave软件中我们经常能够遇到。
符号微分,顾名思义就是利用符号上面的算式来对微分符号表达式进行简化,然后进行求解。此时我们输入的是一个用符号表达式组成的树(或者叫做计算图),然后通过微分规则对这些表达式进行化简。使用到的微分规则有sum rule、product rule、chain rule等等,也就是利用计算机去帮你微分,也因此限定了规则,必须是closed-form,不能有循环和条件结构等等。
这种方法一旦遇到非常复杂的公式很容易导致“表达式膨胀”,就如上图中间的公式所示,这种情况下计算速度会大大降低。
总结下,虽然这样来说,计算梯度的解析表达式是很直观的,但是数值化地求解这样的表示是在计算上的代价可能会很大。
BP(BackproPagation) 反向传播
说道自动微分,必须提一下反向传播。
反向传播是我们听过最多的一种反向求导的方法,在CS231n中有较为详细的讲解,所以这里不进行详细的描述了,仅仅是说一下反向求导的缺点:
- 我们需要保存前向传播中的中间输出变量以在反向求导的时候使用它
- 缺少灵活性,例如我们会计算梯度的梯度(gradient of gradient)。
自动微分
终于说到自动微分了。
上部分中我们简单聊了聊BP(反向传播,具体可以看CS231n相关的链接),为什么BP不好呢?是因为其每一步都会保存了上一步中的计算出来的缓冲数据,这样在每次进行反向传播的时候占用的内存比较高。
而拿自动微分来说,自动微分的核心概念是延迟计算的思想。
首先,我们同样选取出一个目标函数,然后求输出值对两个权重参数(W1、W2W1、W2)的导数。
首先我们求出1/x
的导数−1/x2−1/x2,注意,此时我们仅仅是将这个表达式写了出来,并没有计算这个−1/x2−1/x2的值。
接下来是前项与1的加法操作的导数,这个导数很显然是1。
再对exp
这个op进行求导,同样可以得到导数exp(x)
。
最后我们可以得到JJ对W1W1的偏导,注意这里的偏导是将之前所有得到的式子全部将相应的op的输入代入,然后依次相乘起来。
对于W2W2来说也同样如此。
可以发现,自动求导和BP最主要的区别是,自动求导没有提前计算出每个算子的导数,仅仅是将计算式子表达出来而已,直到我们确实需要求这个值的时候,整个计算流程才算开始执行。
这是一种延迟计算的思想,我们将所有要计算的路线都规划好之后再进行计算,这样一是可以不用提前计算出中间变量,二是可以根据我们导出的计算节点的拓扑关系进行一些优化之类的工作,总的来说比较灵活。
实现Autodiff算法
那我们尝试实现这样一个autodiff的算法,这也是CSE 599W: Systems for ML这门课中的assignment-1。阅读下文之前可以先看看这个assignment的相关介绍。
首先我们讲一下算法的基本流程:
我们根据目标函数f=(ex+1)∗exf=(ex+1)∗ex去求每个变量x1,x2,x3,x4x1,x2,x3,x4的导数。相应的伪代码如下:
这里我们是逆序推导的方式,也就是从输出端一直推导到输入端,而每一个节点都称之为node,例如x4=x2∗x3x4=x2∗x3,这时x4x4就相当于一个node,要注意这个node与这个节点的op也有关系,比如这个的op就是mul也就是乘法。
然后我们计算输出的导数(其实就是x4x4的导数),显然是1,因为x4x4就是输出么,这里的node_to_grad
储存的是每个node对应的导数。
这里x4‾x4就表示x4x4这个node的输出导数。
然后我们走到了第一个节点,x4x4,我们接着往上走,准备去求x4x4两个输入的导数(x2,x3x2,x3),再提醒一下,这里是逆序。
grad <- sum partial adjoints from output edges
这句执行的语句是将目前关于这个node的所有输出的边缘导数相加起来,这样说可能有点难理解。我们之后会说。
然后我们求x4x4两个输入node的导数,因为是乘法操作,所以x2x2的导数是x3x3而x3x3的导数是x2x2。
从图中可以看到x3x3的相对应的导数是x2‾1x21(为什么是x2‾1x21,后面解释),x2x2的相对应的导数是x3‾x3,而且红色连线的乘号也表示,如果要求x2x2的导数,则df/dx2=x4‾∗x3‾df/dx2=x4∗x3。同理,另一个也是如此。
然后我们将已经求出的所有关于node的导数先放到node_to_grad
这个map中,以便后续使用。
接下来我们将x3x3的输出边缘导数都加起来,得到grad,然后用做x3x3的输入的upstream gradient
。
因为x3x3的输入为x2+1x2+1,这里第二次对x2x2进行求导,与上次不同,这次是在x2+1x2+1这个式子中。求出的导数记为x2‾2x22。
这里就对x2x2进行求导后,得到了x3=x2+1x3=x2+1这个式子中,x2x2的导数。为什么要专门强调一下这个呢?是因为我们的x2x2在两个node中都出现了,我们拿最开始的目标函数来展示:f=(ex+1)∗exf=(ex+1)∗ex,其中x2x2这个node就相当于exex这个op,可以发现exex这个op在这个式子中出现了两次,分别是(ex+1)(ex+1)与exex中,利用求导法则中的乘法法则,分别得到了两个导数值。
这时关于x2x2的两个导数为x2‾1x21和x2‾2x22。
将x2x2的两个导数为x2‾1x21和x2‾2x22进行求和也就是x2‾x2,可以看到下面的红色连接的计算路径x2‾=x4‾∗x2‾1+x2‾1(x4‾∗x3‾)x2=x4∗x21+x2