Pytorch官方英文文档:https://pytorch.org/docs/stable/torch.html?
Pytorch中文文档:https://pytorch-cn.readthedocs.io/zh/latest/
1. 计算图与Pytorch的动态图机制
1. 1 计算图
前面已经整理了张量的一系列操作,而深度学习啊, 其实就是对各种张量进行操作,随着操作的种类和数量的增大,会导致各种想不到的问题,比如多个操作之间该并行还是顺序执行,底层的设备如何协同,如何避免冗余的操作等,这些问题都会影响我们算法的执行效率,甚至会出现一些bug。 而计算图就是为了解决这些问题而产生的, 那么什么是计算图呢?
计算图是用来描述运算的有向无环图。 主要有两个因素: 节点和边。 其中节点表示数据,如向量,矩阵,张量, 而边表示运算,如加减乘除,卷积等。下面我们看一下具体这东西具体是什么样子:
使用计算图的好处不仅让计算看起来更加简洁,还有个更大的优势就是让梯度求导也变得更加方便。下面我们看看y对w进行求导的过程:
y对w求导,就是从计算图中找到所有y到w的路径。把各个路径的导数进行求和。我们通过程序来验证一下:
1 | w = torch.tensor([1.], requires_grad=True) |
下面,我们基于这个计算图来说几个张量里面重要的属性:
1.叶子节点这个属性(还记得张量的属性里面有一个is_leaf吗):
叶子节点: 用户创建的节点, 比如上面的x和w。叶子节点是非常关键的,在上面的正向计算和反向计算中,其实都是依赖于我们叶子节点进行计算的。
is_leaf: 指示张量是否是叶子节点。
为什么要设置叶子节点的这个概念的? 主要是为了节省内存,因为我们在反向传播完了之后,非叶子节点的梯度是默认被释放掉的。我们可以根据上面的那个计算过程,来看看w,x, a, b, y的is_leaf属性,和它们各自的梯度情况
1 | #查看叶子结点 |
我们可以发现, 只有w, x的is_leaf属性是True, 说明这俩是叶子节点。 gradient上,也只有叶子节点的梯度被保留了下来, a, b, y的梯度都默认释放了,所以是空。但是我们如果想用这里面的某个梯度呢? 比如我想保留a的梯度, 那么可以使用retain_grad()方法。 就是在执行反向传播之前, 执行一行代码:a.retain_grad()即可。
1 | w = torch.tensor([1.], requires_grad=True) |
2.grad_fn: 记录创建该张量时所用的方法(函数),记录这个方法主要用于梯度的求导。要不然怎么知道具体是啥运算?
1 | w = torch.tensor([1.], requires_grad=True) |
这个属性,会记录变量具体是怎么得到的, 比如两数相加,或者两数相乘,这样反向计算梯度的时候才能使用相应的法则求变量的梯度。 当然知道是用于反向传播即可。
1.2 Pytorch的动态图机制
根据计算图的搭建方式,可以将计算图分为动态图和静态图。
- 静态图: 先搭建图,后运算。高效,不灵活(TensorFlow)
- 动态图: 运算与搭建同时进行。灵活,易调节(Pytorch)
这个就类似于旅游的时候你找旅行团还是自己去旅游一样,找旅行团的这种就类似于静态图,游览的路线和流程都已经确定,跟着走就行了。 如果你熟悉TensorFlow的话,会知道TensorFlow的计算方式,是先把图搭建好,然后开启一个会话, 在那里面才开始喂入数据进行流动计算, 在这个过程中,张量就会根据搭建好的图进行计算。 我们可以看看上面的那个例子:
1 | # 声明两个常量 |
而自己去旅游这个就类似于动态图,游览的路线和流程都不确定,想去哪去哪,随时调整。Pytorch就是采用的这种机制,这种机制就是边建图边执行,从上面的例子中也能看出来,比较灵活, 有错误可以随时改,也更接近我们一般的想法。毕竟没有谁做一件事情之前就能把所有的流程都能规划好,一般人都是有一个大体的框架,然后一步一步边走边调整。依然是上面的例子:
1 | w = torch.tensor([1.], requires_grad=True) |
这里会发现直接就算出了y的结果,这说明上面的每一步都进行了计算。
2. Pytorch的自动求导机制
Pytorch自动求导机制使用的是torch.autograd.backward方法, 功能就是自动求取梯度。
- tensors表示用于求导的张量,如loss。
- retain_graph表示保存计算图, 由于Pytorch采用了动态图机制,在每一次反向传播结束之后,计算图都会被释放掉。如果我们不想被释放,就要设置这个参数为True
- create_graph表示创建导数计算图,用于高阶求导。
- grad_tensors表示多梯度权重。如果有多个loss需要计算梯度的时候,就要设置这些loss的权重比例。
这时候我们就有疑问了啊? 我们上面写代码的过程中并没有见过这个方法啊? 我们当时不是直接y.backward()吗? 哪有什么torch.autograd.backward()啊? 其实,当我们执行y.backward()的时候,背后其实是在调用后面的这个函数, 不行? 我们来调试一下子就清楚了:
我们在这一行打断点,然后进行调试。 我们进入这个函数之后,会发现:
这样就清楚了,这个backward函数就是在调用这个自动求导的函数。
backward()里面有个参数叫做retain_graph, 这个是控制是否需要保留计算图的, 默认是不保留,即一次反向传播之后,计算图就会被释放掉,这时候,如果再次调用y.backward, 就会报错:
报错信息就是说的计算图已经释放掉了。 所以我们把第一次用反向传播的那个retain_graph设置为True就OK了:
1 | y.backward(retain_graph=True) |
这里面还有一个比较重要的参数叫做grad_tensors, 这个是当有多个梯度的时候,控制梯度的权重,这个是什么意思,依然拿上面的那个举例:
上面这个过程会报错,这时候我们就需要用到gradient这个参数了, 给两个梯度设置权重,最后得到的w的梯度就是带权重的这两个梯度之和。
1 | grad_tensors = torch.tensor([1., 1.]) |
除了backward()方法,还有一个比较常用的方法叫做:torch.autograd.grad(), 这个方法的功能是求取梯度, 这个可以实现高阶的求导。
- outputs: 用于求导的张量, 如loss
- inputs: 需要梯度的张量, 如上面例子的w
- create_graph: 创建导数计算图,用于高阶求导
- retain_graph: 保存计算图
- grad_outputs: 多梯度权重
拿个例子看一下就明白了这个方法如何使用了:
1 | x = torch.tensor([3.], requires_grad=True) |
这个函数还允许对多个自变量求导数:
1 | x1 = torch.tensor(1.0,requires_grad = True) # x需要被求导 |
关于Pytorch的自动求导系统要注意:
1.梯度不自动清零 就是每一次反向传播,梯度都会叠加上去, 这个要注意, 举个例子:
1 | w = torch.tensor([1.], requires_grad=True) |
会发现,每次w的梯度都会累加, 执行了四次,最后是20了。这样就会发生错误了,尤其是训练神经网络的时候特别注意。毕竟我们肯定不是训练一次,所以每一次反向传播之后,我们要手动的清除梯度
这里会发现个zero_(), 这里有个下划线,这个代表原位操作, 后面第三条会详细说。
2.依赖于叶子节点的节点, requires_grad默认为True, 这是啥意思?
拿上面的计算图过来解释一下,依赖于叶子节点的节点,在上面图中w,x是叶子节点,而依赖于叶子节点的节点,其实这里说的就是a,b, 也就是a,b默认就是需要计算梯度的。 这个也好理解,因为计算w,x的梯度的时候是需要先对a, b进行求导的,要用到a, b的梯度,所以这里直接默认a, b是需要计算梯度的。 在代码中,也就是这个意思:
1 | w = torch.tensor([1.], requires_grad=True) |
3.叶子节点不可执行in-place(这个in-place就是原位操作)
首先先看看什么是in-place操作, 这个操作就是在原始内存当中去改变这个数据,这个理解起来的话,其实就是这个意思: 我们拿一个a+1的例子看一下,我们知道数字的话理论上是一个不可变数据对象,类似于字符串,元组这种,比如我假设a=1, 然后我执行a=a+1, 这样的话,a虽然是2,但是这两个a其实指向的对象是不一样的,原来的1并没有改变,执行a+1, 是新建了一个对象出来, 然后改变了原来a的指向。 这就是数字的不可变现象。 如果不理解,对比一下子就清楚了, 我们知道列表示可变的,假设a=[1,5,3],我们可以a.append(4),此时a指向的对象就变成了[1,5,3,4],但其实是在原对象[1,2,3]上进行的添加,此过程没有新对象产生。 我们还可以a.sort(), 这时候a指向的对象就变成了[1, 3, 4, 5], 但依然是原对象上进行的改变。 说清楚了吧? 没有的话可以看看我python查缺补漏的第一篇文章, 那里面说的更详细些。这里重点说原位操作, 将数字进行原位操作之后, 这个数字就类似于列表这种,是在本身的内存当中改变的数,这时候就没有新对象建立出来。 a+=1就是一种原位操作。 我们看个例子吧:
1 | a = torch.ones((1,)) |
好了,原位操作差不多理解了吧。 其实比较简单。 那么为什么叶子节点不能进行原位操作呢? 先看看叶子节点进行原位操作是怎么回事? 下面这个报错:
这是为什么呢? 这个要从计算图求取梯度的过程来理解,依然得把计算图拿过来:
我们来看这个求取梯度的过程, 我们要求w的梯度的时候,我们发现会先∂y/∂a=w+1, 然后∂a/∂w, 也就是说反向传播的过程的∂y/∂a就用到了w, 这时候是怎么找到w的呢? 其实正向传播的时候,会把w的地址给记下来,然后反向传播的这一步,就是根据这个地址去找w的值。 如果在反向传播之前,就用原位操作把这个w的值给变了,那么反向传播再拿到这个w的值的时候,就出错了。 所以Pytorch不允许对叶子使用原位操作。 这就类似于去超市买东西存包取包的过程,假设我们去超市,需要把包先存到柜子,管理员给了我们一个号码牌10号,我们把包存进了10号柜子, 但如果管理员把10号柜子的东西换成了别人的包,你购物回来之后再拿10号的牌子去取自己的包,发现不在了,不就出错了?
前面已经学习了数据的载体张量,学习了如何通过前向传播搭建计算图,同时通过计算图进行梯度的求解,有了数据,计算图和梯度,我们就可以正式的训练机器学习模型了。接下来,我们就玩一个逻辑回归模型吧。
3. 逻辑回归模型
逻辑回归模型是线性的二分类模型,模型的表达式如下:
我们是根据这个y的取值进行分类的,当取值小于0.5, 就判别为类别0, 大于0.5, 就判别为类别1.
那为什么称为线性呢?我们可以对比一下线性回归和逻辑回归的区别:
- 线性回归: 自变量是X, 因变量是y, 关系: y=wx + b, 图像是一条直线。是分析自变量x和因变量y(标量)之间关系的方法。 注意这里的线性是针对于w进行说的, 一个w只影响一个x。决策边界是一条直线
- 逻辑回归:自变量是X, 因变量是y, 只不过这里的y变成了概率。 关系:
图像也是一条直线。 是分析自变量x与因变量y(概率)之间的关系。 这里注意不要只看到那个sigmoid函数就感觉逻辑回归是非线性的。因为这个sigmoid函数在这里只是为了更好的描述分类置信度。 如果我们不用这个函数,其实也是可以进行二分类的,比如wx+b>0, 我们判定为1, wx+b<0, 我们判定类别0, 这样其实也是可以的,就会发现,依然是一个w只影响一个y, 决策边界是一条直线。所以依然是线性的。 关于线性和非线性模型的区别,这个解释不错:
- 数据模块(数据采集,清洗,处理等)
- 建立模型(各种模型的建立)
- 损失函数的选择(根据不同的任务选择不同的损失函数),有了loss就可以求取梯度
- 得到梯度之后,我们会选择某种优化方式去进行优化
- 然后迭代训练
后面建立各种模型,都是基于这五大步骤进行, 这个就相当于一个逻辑框架了。
下面就基于上面的五个步骤,看看Pytorch是如何建立一个逻辑回归模型,并分类任务的。我们下面一步一步来:
数据生成
这里我们使用随机生成的方式,生成2类样本(用0和1表示), 每一类样本100个, 每一个样本两个特征。
1 | """数据生成""" |
建立模型
这里我们使用两种方式建立我们的逻辑回归模型,一种是Pytorch的sequential方式,这种方式就是简单,易懂,就类似于搭积木一样,一层一层往上搭。 另一种方式是继承nn.Module这个类搭建模型,这种方式非常灵活,能够搭建各种复杂的网络。
1 | """建立模型""" |
另外一种方式,Sequential的方法:
1 | lr_net = torch.nn.Sequential( |
选择损失函数
关于损失函数的详细介绍,后面会专门整理一篇, 这里我们使用二进制交叉熵损失
1 | """选择损失函数""" |
选择优化器
优化器的知识,后面也是单独会有一篇文章,这里我们就用比较常用的SGD优化器。关于这些参数,这里不懂没有问题,后面会单独的讲, 这也就是为啥要系统学习一遍Pytorch的原因, 就比如这个优化器,我们虽然知道这里用了SGD,但是我们可能并不知道还有哪些常用的优化器,这些优化器通常用在什么情况下。
1 | """选择优化器""" |
迭代训练模型
这里就是我们的迭代训练过程了, 基本上也比较简单,在一个循环中反复训练,先前向传播,然后计算梯度,然后反向传播, 更新参数,梯度清零。
1 | """模型训练""" |
别看这么多,后面都是绘图的过程,不是重点,重点训练就是前面的那几步。 我们可以看看结果:
这就是我们的逻辑回归模型进行二分类的问题了。
4. 总结梳理
今天的学习内容结束,下面依然是快速的总结一下,首先基于前面的张量的知识我们又更进一步,学习了计算图的机制,计算图说白了就是描述运算过程的图, 有了这个图梯度求导的时候非常方便。 然后学习了Pytorch的动态图机制,区分了一下动态图和静态图。 然后学习了Pytorch的自动求导机制,认识了两个比较常用的函数torch.autograd.backward()和torch.autograd.grad()函数, 关于自动求导要记得三个注意事项: 梯度手动清零,叶子节点不能原位操作,依赖于叶子节点的节点默认是求梯度。 最后我们根据上面的所学知识建立了一个逻辑回归模型实现了一个二分类的任务, 下面依然是一张导图把这次学习的知识拎起来:
Ok, 后面我们就学习Pytorch的数据读取机制DataLoader和Dataset了, Rush 😉