前向传播与反向传播
在单层神经网络的优化算法里,我们讲到优化算法是为了寻找模型参数使得网络的损失值最小,这里详细介绍一下应用的基础——反向传播算法。
在神经网络中,梯度计算是通过反向传播算法来实现的。反向传播算法用于计算损失函数相对于网络参数(如权重和偏置)的梯度,从而进行参数的更新和优化。梯度计算的过程可以分为两个关键步骤:前向传播和反向传播。
前向传播:
输入数据通过网络的每一层,从输入层传递到输出层。在每个神经元中,进行线性变换和激活函数操作,计算每个神经元的输出值。
前向传播过程中,将输入数据逐层传递,直到得到最终的输出预测结果。
以前面的多层感知机为例:
其中表示激活函数。
设损失函数,记作L,真实标签是,
则
图 前向传播计算图
反向传播:
在前向传播之后,计算损失函数相对于网络参数的梯度,以衡量预测结果与实际标签之间的差异。
在计算梯度的过程中,反向传播算法的基本思想是通过计算输出误差对网络中各个权重的偏导数,然后根据这些偏导数结合使用梯度下降法则调整权重,使得网络的输出误差逐渐减小,使得网络能够更好地逼近真实标签。它使用了链式法则来计算网络中每个神经元的偏导数。涉及到每个神经元的激活函数导数、权重和偏置的导数等。
反向传播算法的关键在于通过链式法则计算每个神经元的误差贡献,并根据这些误差贡献来调整权重。它使得神经网络能够学习如何将输入映射到期望的输出,并且可以处理大量的训练样本以提高网络的泛化能力。
反向传播算法通常结合使用梯度下降法则来进行权重的调整。梯度下降法则是一种基于优化的方法,通过沿着误差曲面的负梯度方向进行迭代,以找到误差最小的权重值。反向传播算法利用了梯度下降法则来计算权重调整量,从而优化神经网络的性能。
反向传播实例计算
这是一个简单的两层神经网络,输入数据是1*2的向量,中间一个含有3个神经元的隐藏层,输出层为1*1的向量,简化起见,偏置b设为0,激活函数选择ReLu,根据前面推导的公式,
给定值,,并给定参数初始化值均为0.5;标签值(真实值)y=2;
则 ;
;
;
到了这里,神经网络的前向传播过程就已经完成了,最后输出的便是前向神经网络计算出来的预测结果,并不一定是准确值(真实的标签值y)
因此,最终输出的预测结果与y是有一定出入,然后利用上文所说的损失函数和优化算法来更新参数,使得误差最小。
最常见的损失函数就是最小二乘法损失函数:
所以这里的总损失值(误差) :
现在开始进行反向传播,反向传播的目的是为了更新权重W,至于每个权重W对最后的误差造成了多少影响,可以用整体误差对特定权重参数求偏导得出:这是根据链式法则得到的
下面分别计算上面链式当中的最后一个偏导:
到这里我们就计算出整体误差L对的偏导值,这个值的含义就是指这个参数对最后整体误差的影响程度(叫做梯度值),可正可负。
梯度更新
计算得到损失函数对参数的偏导值之后,便可以根据设置的超参数学习率η利用梯度下降法来更新参数,这里设置成0.1:
再来计算一下前面层的参数偏导数:
同上,根据学习率更新参数即可。
这就是对一个前向传播中的某个参数进行梯度更新的过程,当然这个更新过程不可能是一次完成的,在进行完一次反向传播更新梯度后紧接着会进行下一个前向传播,然后重新计算误差,这样多次循环之后会使得损失值逐渐降低,当达到某个设定值时这个循环更新梯度的过程就会停止,从而完成一整个训练流程。
总结起来,神经元梯度计算的过程是通过反向传播算法,在每一层中根据当前层的输出和上一层的梯度,计算当前层的梯度。这样,根据梯度计算的结果,可以使用特定的梯度更新算法来更新模型的参数。常用的梯度更新算法包括随机梯度下降(Stochastic Gradient Descent,SGD)、Adam等
步骤总结
以下是反向传播算法的基本步骤:
正向传播:将输入特征通过网络前向传递,输出网络的预测值。
计算预测误差:将网络的预测值与真实值(标签)代入损失函数,计算得到网络的预测误差。
反向传播:根据链式法则,将输出层的预测误差回传到前面各层,逐层计算预测误差对每个参数的导数。
计算参数更新值:根据预测误差对每个参数的导数,结合设置的超参数学习率,计算每个参数的更新量(一般是导数*学习率,这就是优化算法作用的位置,可以为梯度下降法等)。
更新权重:一般是原参数减去参数的更新量,更新它们的数值。
重复以上步骤:重复执行步骤1到步骤5,直到网络的输出误差达到可接受的范围或达到预定的训练轮数。
思考以下几个问题:
1.参数权重矩阵和个别参数更新的关系:如上图是函数的变量是以矩阵的形式体现的,但在计算参数偏导时是具体到每个参数的,所以注意求偏导时函数要展开成针对参数矩阵里特定的参数。
2.反向传播的顺序;从的计算可以看到,前面网络层的参数更新依赖后面网络层的参数,所以参数如果过小,经过连乘效应,前面的参数基本得不到更新,这就是所谓的参数消失。
3.网络保存着前向传播计算得到的值,包括隐藏层,所以在反向传播计算时候可以直接拿到这些值,不过这也需要存储空间。
PyTorch 的 Autograd代码实现
自动求导 (autograd) 是 PyTorch,乃至其他大部分深度学习框架中的重要组成部分。了解自动求导背后的原理和规则,对我们写出一个更干净整洁甚至更高效的 PyTorch 代码是十分重要的。
即使现在已经有了很多封装好的 API,我们在写一个自己的网络的时候,可能几乎都不用去注意求导这些问题,因为这些 API 已经在私底下处理好了这些事情。现在我们往往只需要,搭建个想要的模型,处理好数据的载入,调用现成的 optimizer和 loss function,直接开始训练就好了。仔细一想,好像连需要设置 requires_grad=True 的地方好像都没有。有人可能会问,那我们去了解自动求导还有什么用啊?
原因有很多,比较宽泛的理由如可以帮我们更深入地了解 PyTorch ,更重要的是,当我们想使用一个 PyTorch 默认中并没有的 loss function 的时候,比如目标检测模型YOLO的loss,我们可能就得自己去实现。鉴于现在官方支持的 loss function 并不多,而且深度学习领域日新月异,很多新的效果很好的 loss function 层出不穷,如果要用的话可能需要我们自己来实现。基于这个原因,我们了解一下自动求导机制还是很有必要的。
计算图
先简单地介绍一下什么是计算图(Computational Graphs),前面我们介绍神经网络模型的时候多次强调要注重数据的特征流动,而计算图就是用来描述数据特征的流动情况的,在前向传播里已经绘制了计算图。
计算图通常包含两种元素,一个是 tensor,另一个是 Function。张量 tensor 就是数据特征,那么Function呢? Function 指的是在计算图中某个节点(node)所进行的运算,比如加减乘除卷积等等之类的,Function 内部有 forward() 和 backward() 两个方法,分别应用于正向、反向传播。
a = torch.tensor(2.0, requires_grad=True)
b = a.exp()
print(b)# tensor(7.3891, grad_fn=<ExpBackward>)
在我们做正向传播的过程中,除了执行 forward() 操作之外,还会同时会为反向传播做一些准备,为反向计算图添加 Function 节点。在上边这个例子中,变量 b 在反向传播中所需要进行的操作是 <ExpBackward>
一个具体的例子
了解了基础知识之后,现在我们来看一个具体的计算例子,并画出它的正向和反向计算图。假如我们需要计算这么一个模型:
l1 = input x w1
l2 = l1 + w2
l3 = l1 x w3
l4 = l2 x l3
loss = mean(l4)
这个例子比较简单,涉及的最复杂的操作是求平均,但是如果我们把其中的加法和乘法操作换成卷积,那么其实和神经网络类似。我们可以简单地画一下它的计算图:
下面给出了对应的代码,我们定义了input,w1,w2,w3 这三个变量,其中 input 不需要求导结果。根据 PyTorch 默认的求导规则,对于 l1 来说,因为有一个输入需要求导(也就是 w1 需要),所以它自己默认也需要求导,即 requires_grad=True。在整张计算图中,只有 input 一个变量是requires_grad=False 的。正向传播过程的具体代码如下:
input = torch.ones([2, 2], requires_grad=False)
w1 = torch.tensor(2.0, requires_grad=True)
w2 = torch.tensor(3.0, requires_grad=True)
w3 = torch.tensor(4.0, requires_grad=True)
l1 = input * w1
l2 = l1 + w2
l3 = l1 * w3
l4 = l2 * l3
loss = l4.mean()
print(w1.data, w1.grad, w1.grad_fn)
# tensor(2.) None None
print(l1.data, l1.grad, l1.grad_fn)
# tensor([[2., 2.],# [2., 2.]]) None <MulBackward0 object at 0x000001EBE79E6AC8>
print(loss.data, loss.grad, loss.grad_fn)
# tensor(40.) None <MeanBackward0 object at 0x000001EBE79D8208>
正向传播的结果基本符合我们的预期。我们可以看到,变量 l1 的 grad_fn 储存着乘法操作符 <MulBackward0>,用于在反向传播中指导导数的计算。而 w1 是用户自己定义的,不是通过计算得来的,所以其 grad_fn 为空;同时因为还没有进行反向传播,grad 的值也为空。接下来,我们看一下如果要继续进行反向传播,计算图应该是什么样子:
反向图也比较简单,从 loss 这个变量开始,通过链式法则,依次计算出各部分的导数。
接下来我们继续运行代码,并检查一下结果和自己算的是否一致:
loss.backward()
print(w1.grad, w2.grad, w3.grad)
# tensor(28.) tensor(8.) tensor(10.)
print(l1.grad, l2.grad, l3.grad, l4.grad, loss.grad)
# None None None None None
首先我们需要注意一下的是,在之前写程序的时候我们给定的 w 们都是一个常数,利用了广播的机制实现和常数和矩阵的加法乘法,比如 w2 + l1,实际上我们的程序会自动把 w2 扩展成 [[3.0, 3.0], [3.0, 3.0]],和 l1 的形状一样之后,再进行加法计算,计算的导数结果实际上为 [[2.0, 2.0], [2.0, 2.0]],为了对应常数输入,所以最后 w2 的梯度返回为矩阵之和 8 。另外还有一个问题,虽然 w 开头的那些和我们的计算结果相符,但是为什么 l1,l2,l3,甚至其他的部分的求导结果都为空呢?想要解答这个问题,我们得明白什么是叶子张量。
叶子张量
对于任意一个张量来说,我们可以用 tensor.is_leaf 来判断它是否是叶子张量(leaf tensor)。在反向传播过程中,只有 is_leaf=True 的时候,需要求导的张量的导数结果才会被最后保留下来。
对于 requires_grad=False 的 tensor 来说,我们约定俗成地把它们归为叶子张量。但其实无论如何划分都没有影响,因为张量的 is_leaf 属性只有在需要求导的时候才有意义。
我们真正需要注意的是当 requires_grad=True 的时候,如何判断是否是叶子张量:当这个 tensor 是用户创建的时候,它是一个叶子节点,当这个 tensor 是由其他运算操作产生的时候,它就不是一个叶子节点。我们来看个例子:
a = torch.ones([2, 2], requires_grad=True)
print(a.is_leaf)
# True
b = a + 2
print(b.is_leaf)
# False# 因为 b 不是用户创建的,是通过计算生成的
为什么要搞出这么个叶子张量的概念出来?原因是为了节省内存(或显存)。我们来想一下,那些非叶子结点,是通过用户所定义的叶子节点的一系列运算生成的,也就是这些非叶子节点都是中间变量,一般情况下,用户不会去使用这些中间变量的导数,所以为了节省内存,它们在用完之后就被释放了。
回头看一下之前的反向传播计算图,在图中的叶子节点我用绿色标出了。可以看出来,被叫做叶子,可能是因为游离在主干之外,没有子节点,因为它们都是被用户创建的,不是通过其他节点生成。对于叶子节点来说,它们的 grad_fn 属性都为空;而对于非叶子结点来说,因为它们是通过一些操作生成的,所以它们的 grad_fn 不为空。
我们有办法保留中间变量的导数吗?当然有,通过使用 tensor.retain_grad() 就可以:
# 和前边一样
# ...
loss = l4.mean()
l1.retain_grad()
l4.retain_grad()
loss.retain_grad()
loss.backward()
print(loss.grad)
# tensor(1.)
print(l4.grad)
# tensor([[0.2500, 0.2500],# [0.2500, 0.2500]])
print(l1.grad)# tensor([[7., 7.],# [7., 7.]])
如果我们只是想进行 debug,只需要输出中间变量的导数信息,而不需要保存它们,我们还可以使用 tensor.register_hook,例子如下:
# 和前边一样
# ...
loss = l4.mean()
l1.register_hook(lambda grad: print('l1 grad: ', grad))
l4.register_hook(lambda grad: print('l4 grad: ', grad))
loss.register_hook(lambda grad: print('loss grad: ', grad))
loss.backward()
# loss grad: tensor(1.)
# l4 grad: tensor([[0.2500, 0.2500],# [0.2500, 0.2500]])
# l1 grad: tensor([[7., 7.],# [7., 7.]])
print(loss.grad)
# None# loss 的 grad 在 print 完之后就被清除掉了
到此为止,我们已经讨论完了这个实例中的正向传播和反向传播的有关内容了。回过头来看, input 其实很像神经网络输入的图像,w1, w2, w3 则类似卷积核的参数,而 l1, l2, l3, l4 可以表示4个卷积层输出,如果我们把节点上的加法乘法换成卷积操作的话。实际上这个简单的模型,很像我们平时的神经网络的简化版,通过这个例子,相信大家多少也能对神经网络的正向和反向传播过程有个大致的了解了吧。
inplace 操作
现在我们来看一下本篇的重点,inplace operation。可以说,我们求导时候大部分的 bug,都出在使用了 inplace 操作上。现在我们以 PyTorch 不同的报错信息作为驱动,来讲一讲 inplace 操作吧。第一个报错信息:
RuntimeError: one of the variables needed for gradient computation has been modified by an inplace operation: balabala...
之前写代码的时候经常报这个错,原因是对 inplace 操作不了解。要搞清楚为什么会报错,我们先来了解一下什么是 inplace 操作:inplace 指的是在不更改变量的内存地址的情况下,直接修改变量的值。我们来看两种情况,大家觉得这两种情况哪个是 inplace 操作,哪个不是?或者两个都是 inplace?
# 情景 1
a = a.exp()
# 情景 2
a[0] = 10
答案是:情景1不是 inplace,类似 Python 中的 i=i+1, 而情景2是 inplace 操作,类似 i+=1。
# 我们要用到 id() 这个函数,其返回值是对象的内存地址
# 情景 1
a = torch.tensor([3.0, 1.0])
print(id(a))
# 2112716404344
a = a.exp()
print(id(a))
# 2112715008904#
在这个过程中 a.exp() 生成了一个新的对象,然后再让 a# 指向它的地址,所以这不是个 inplace 操作
# 情景 2
a = torch.tensor([3.0, 1.0])
print(id(a))
# 2112716403840
a[0] = 10
print(id(a), a)
# 2112716403840 tensor([10., 1.])# inplace 操作,内存地址没变
PyTorch 是怎么检测 tensor 发生了 inplace 操作呢?答案是通过 tensor._version 来检测的。我们还是来看个例子:
a = torch.tensor([1.0, 3.0], requires_grad=True)
b = a + 2
print(b._version) # 0
loss = (b * b).mean()
b[0] = 1000.0
print(b._version) # 1
loss.backward()
# RuntimeError: one of the variables needed for gradient computation has been modified by an inplace operation ...
每次 tensor 在进行 inplace 操作时,变量 _version 就会加1,其初始值为0。在正向传播过程中,求导系统记录的 b 的 version 是0,但是在进行反向传播的过程中,求导系统发现 b 的 version 变成1了,所以就会报错了。但是还有一种特殊情况不会报错,就是反向传播求导的时候如果没用到 b 的值(比如 y=x+1, y 关于 x 的导数是1,和 x 无关),自然就不会去对比 b 前后的 version 了,所以不会报错。
上边我们所说的情况是针对非叶子节点的,对于 requires_grad=True 的叶子节点来说,要求更加严格了,甚至在叶子节点被使用之前修改它的值都不行。我们来看一个报错信息:
RuntimeError: leaf variable has been moved into the graph interior
这个意思通俗一点说就是你的一顿 inplace 操作把一个叶子节点变成了非叶子节点了。我们知道,非叶子节点的导数在默认情况下是不会被保存的,这样就会出问题了。举个小例子:
a = torch.tensor([10., 5., 2., 3.], requires_grad=True)
print(a, a.is_leaf)
# tensor([10., 5., 2., 3.], requires_grad=True) True
a[:] = 0
print(a, a.is_leaf)
# tensor([0., 0., 0., 0.], grad_fn=<CopySlices>) False
loss = (a*a).mean()
loss.backward()
# RuntimeError: leaf variable has been moved into the graph interior
我们看到,在进行对 a 的重新 inplace 赋值之后,表示了 a 是通过 copy operation 生成的,grad_fn 都有了,所以自然而然不是叶子节点了。本来是该有导数值保留的变量,现在成了导数会被自动释放的中间变量了,所以 PyTorch 就给你报错了。还有另外一种情况:
a = torch.tensor([10., 5., 2., 3.], requires_grad=True)
a.add_(10.)
# 或者 a += 10.
# RuntimeError: a leaf Variable that requires grad has been used in an in-place operation.
这个更厉害了,不等到你调用 backward,只要你对需要求导的叶子张量使用了这些操作,马上就会报错。那是不是需要求导的叶子节点一旦被初始化赋值之后,就不能修改它们的值了呢?我们如果在某种情况下需要重新对叶子变量赋值该怎么办呢?有办法!
# 方法一
a = torch.tensor([10., 5., 2., 3.], requires_grad=True)
print(a, a.is_leaf, id(a))
# tensor([10., 5., 2., 3.], requires_grad=True) True 2501274822696
a.data.fill_(10.)
# 或者 a.detach().fill_(10.)
print(a, a.is_leaf, id(a))
# tensor([10., 10., 10., 10.], requires_grad=True) True 2501274822696
loss = (a*a).mean()
loss.backward()print(a.grad)
# tensor([5., 5., 5., 5.])
# 方法二
a = torch.tensor([10., 5., 2., 3.], requires_grad=True)
print(a, a.is_leaf)# tensor([10., 5., 2., 3.], requires_grad=True) True
with torch.no_grad():
a[:] = 10.
print(a, a.is_leaf)
# tensor([10., 10., 10., 10.], requires_grad=True) True
loss = (a*a).mean()loss.backward()print(a.grad)
# tensor([5., 5., 5., 5.])
修改的方法有很多种,核心就是修改那个和变量共享内存,但requires_grad=False 的版本的值,比如通过 tensor.data或者tensor.detach()我们需要注意的是,要在变量被使用之前修改,不然等计算完之后再修改,还会造成求导上的问题,会报错的。
为什么 PyTorch 的求导不支持绝大部分 inplace 操作呢?从上边我们也看出来了,因为真的很 tricky。比如有的时候在一个变量已经参与了正向传播的计算,之后它的值被修改了,在做反向传播的时候如果还需要这个变量的值的话,我们肯定不能用那个后来修改的值吧,但没修改之前的原始值已经被释放掉了,我们怎么办?
一种可行的办法就是我们在 Function 做 forward 的时候每次都开辟一片空间储存当时输入变量的值,这样无论之后它们怎么修改,都不会影响了,反正我们有备份在存着。但这样有什么问题?这样会导致内存(或显存)使用量大大增加。因为我们不确定哪个变量可能之后会做 inplace 操作,所以我们每个变量在做完 forward 之后都要储存一个备份,成本太高了。除此之外,inplace operation 还可能造成很多其他求导上的问题。
总之,我们在实际写代码的过程中,没有必须要用 inplace operation 的情况,而且支持它会带来很大的性能上的牺牲,所以 PyTorch 不推荐使用 inplace 操作,当求导过程中发现有 inplace 操作影响求导正确性的时候,会采用报错的方式提醒。但这句话反过来说就是,因为只要有 inplace 操作不当就会报错,所以如果我们在程序中使用了 inplace 操作却没报错,那么说明我们最后求导的结果是正确的,没问题的。这就是我们常听见的没报错就没有问题。
动态图,静态图
可能大家都听说过,PyTorch 使用的是动态图(Dynamic Computational Graphs)的方式,而 TensorFlow 使用的是静态图(Static Computational Graphs)。所以二者究竟有什么区别呢,我们本节来就来讨论这个事情。
所谓动态图,就是每次当我们搭建完一个计算图,然后在反向传播结束之后,整个计算图就在内存中被释放了。如果想再次使用的话,必须从头再搭一遍,参见下边这个例子。而以 TensorFlow 为代表的静态图,每次都先设计好计算图,需要的时候实例化这个图,然后送入各种输入,重复使用,只有当会话结束的时候创建的图才会被释放(不知道这里我对 tf.Session 的理解对不对,如果有错误希望大佬们能指正一下),就像我们之前举的那个水管的例子一样,设计好水管布局之后,需要用的时候就开始搭,搭好了就往入口加水,什么时候不需要了,再把管道都给拆了。
# 这是一个关于 PyTorch 是动态图的例子:
a = torch.tensor([3.0, 1.0], requires_grad=True)
b = a * a
loss = b.mean()
loss.backward()
# 正常
loss.backward() # RuntimeError,已经被释放了
# 第二次:从头再来一遍
a = torch.tensor([3.0, 1.0], requires_grad=True)
b = a * a
loss = b.mean()
loss.backward() # 正常
从描述中我们可以看到,理论上来说,静态图在效率上比动态图要高。因为首先,静态图只用构建一次,然后之后重复使用就可以了;其次静态图因为是固定不需要改变的,所以在设计完了计算图之后,可以进一步的优化,比如可以将用户原本定义的 Conv 层和 ReLU 层合并成 ConvReLU 层,提高效率。
但是,深度学习框架的速度不仅仅取决于图的类型,还很其他很多因素,比如底层代码质量,所使用的底层 BLAS 库等等等都有关。从实际测试结果来说,至少在主流的模型的训练时间上,PyTorch 有着至少不逊于静态图框架 Caffe,TensorFlow 的表现。
除了动态图之外,PyTorch 还有一个特性,叫 eager execution。意思就是当遇到 tensor 计算的时候,马上就回去执行计算,也就是,实际上 PyTorch 根本不会去构建正向计算图,而是遇到操作就执行。真正意义上的正向计算图是把所有的操作都添加完,构建好了之后,再运行神经网络的正向传播。
正是因为 PyTorch 的两大特性:动态图和 eager execution,所以它用起来才这么顺手,简直就和写 Python 程序一样舒服,debug 也非常方便。除此之外,我们从之前的描述也可以看出,PyTorch 十分注重占用内存(或显存)大小,没有用的空间释放很及时,可以很有效地利用有限的内存。
张量的求导机制
tensor.requires_grad
当我们创建一个张量 (tensor) 的时候,如果没有特殊指定的话,那么这个张量是默认是不需要求导的。我们可以通过 tensor.requires_grad 来检查一个张量是否需要求导。
在张量间的计算过程中,如果在所有输入中,有一个输入需要求导,那么输出一定会需要求导;相反,只有当所有输入都不需要求导的时候,输出才会不需要。
举一个比较简单的例子,比如我们在训练一个网络的时候,我们从 DataLoader 中读取出来的一个 mini-batch的数据,这些输入默认是不需要求导的,其次,网络的输出我们没有特意指明需要求导吧,Ground Truth 我们也没有特意设置需要求导吧。这么一想,哇,那我之前的那些 loss 咋还能自动求导呢?其实原因就是上边那条规则,虽然输入的训练数据是默认不求导的,但是,我们的 model 中的所有参数,它默认是求导的,这么一来,其中只要有一个需要求导,那么输出的网络结果必定也会需要求的。来看个实例:
input = torch.randn(8, 3, 50, 100)
print(input.requires_grad)
# False
net = nn.Sequential(nn.Conv2d(3, 16, 3, 1),
nn.Conv2d(16, 32, 3, 1))
for param in net.named_parameters():
print(param[0], param[1].requires_grad)
# 0.weight True
# 0.bias True
# 1.weight True
# 1.bias True
output = net(input)
print(output.requires_grad)
# True
但是,大家请注意前边只是举个例子来说明。在写代码的过程中,不要把网络的输入和 Ground Truth 的 requires_grad 设置为 True。虽然这样设置不会影响反向传播,但是需要额外计算网络的输入和 Ground Truth 的导数,增大了计算量和内存占用不说,这些计算出来的导数结果也没啥用。因为我们只需要神经网络中的参数的导数,用来更新网络,其余的导数都不需要。
好了,有个这个例子做铺垫,那么我们来得寸进尺一下。我们试试把网络参数的 requires_grad 设置为 False 会怎么样,同样的网络:
input = torch.randn(8, 3, 50, 100)
print(input.requires_grad)
# False
net = nn.Sequential(nn.Conv2d(3, 16, 3, 1),
nn.Conv2d(16, 32, 3, 1))
for param in net.named_parameters():
param[1].requires_grad = False
print(param[0], param[1].requires_grad)
# 0.weight False
# 0.bias False
# 1.weight False
# 1.bias False
output = net(input)
print(output.requires_grad)
# False
这样有什么用处?用我们可以通过这种方法,在训练的过程中冻结部分网络,让这些层的参数不再更新,这在迁移学习中很有用处。我们来看一个 官方 Tutorial: FINETUNING TORCHVISION MODELS 给的例子:
model = torchvision.models.resnet18(pretrained=True)
for param in model.parameters():
param.requires_grad = False
# 用一个新的 fc 层来取代之前的全连接层
# 因为新构建的 fc 层的参数默认 requires_grad=True
model.fc = nn.Linear(512, 100)
# 只更新 fc 层的参数
optimizer = optim.SGD(model.fc.parameters(), lr=1e-2, momentum=0.9)
# 通过这样,我们就冻结了 resnet 前边的所有层,
# 在训练过程中只更新最后的 fc 层中的参数。
torch.no_grad()
当我们在做 evaluating 的时候(不需要计算导数),我们可以将推断(inference)的代码包裹在 with torch.no_grad(): 之中,以达到 暂时 不追踪网络参数中的导数的目的,总之是为了减少可能存在的计算和内存消耗。看官方 Tutorial 给出的例子:
x = torch.randn(3, requires_grad = True)
print(x.requires_grad)
# True
print((x ** 2).requires_grad)
# True
with torch.no_grad():
print((x ** 2).requires_grad)
# False
print((x ** 2).requires_grad)
# True
反向传播及网络的更新
有了网络输出之后,我们怎么根据这个结果来更新我们的网络参数呢。我们以一个非常简单的自定义网络来讲解这个问题,这个网络包含2个卷积层,1个全连接层,输出的结果是20维的,类似分类问题中我们一共有20个类别,网络如下:
class Simple(nn.Module):
def __init__(self):
super().__init__()
self.conv1 = nn.Conv2d(3, 16, 3, 1, padding=1, bias=False)
self.conv2 = nn.Conv2d(16, 32, 3, 1, padding=1, bias=False)
self.linear = nn.Linear(32*10*10, 20, bias=False)
def forward(self, x):
x = self.conv1(x)
x = self.conv2(x)
x = self.linear(x.view(x.size(0), -1))
return x
接下来我们用这个网络,来研究一下整个网络更新的流程:
# 创建一个很简单的网络:两个卷积层,一个全连接层
model = Simple()
# 为了方便观察数据变化,把所有网络参数都初始化为 0.1
for m in model.parameters():
m.data.fill_(0.1)
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(model.parameters(), lr=1.0)
model.train()
# 模拟输入8个 sample,每个的大小是 10x10,
# 值都初始化为1,让每次输出结果都固定,方便观察
images = torch.ones(8, 3, 10, 10)
targets = torch.ones(8, dtype=torch.long)
output = model(images)
print(output.shape)
# torch.Size([8, 20])
loss = criterion(output, targets)
print(model.conv1.weight.grad)
# None
loss.backward()
print(model.conv1.weight.grad[0][0][0])
# tensor([-0.0782, -0.0842, -0.0782])
# 通过一次反向传播,计算出网络参数的导数,
# 因为篇幅原因,我们只观察一小部分结果
print(model.conv1.weight[0][0][0])
# tensor([0.1000, 0.1000, 0.1000], grad_fn=<SelectBackward>)
# 我们知道网络参数的值一开始都初始化为 0.1 的
optimizer.step()
print(model.conv1.weight[0][0][0])
# tensor([0.1782, 0.1842, 0.1782], grad_fn=<SelectBackward>)
# 回想刚才我们设置 learning rate 为 1,这样,
# 更新后的结果,正好是 (原始权重 - 求导结果) !
optimizer.zero_grad()
print(model.conv1.weight.grad[0][0][0])
# tensor([0., 0., 0.])
# 每次更新完权重之后,我们记得要把导数清零,
# 不然下次会得到一个和上次计算一起累加的结果。
# 当然,zero_grad() 的位置,可以放到前边去,
# 只要保证在计算导数前,参数的导数是清零的就好。
这里我们把整个网络参数的值都传到 optimizer 里面了,这种情况下我们调用 model.zero_grad(),效果是和 optimizer.zero_grad() 一样的。这个知道就好,建议大家坚持用 optimizer.zero_grad()。我们现在来看一下如果没有调用 zero_grad(),会怎么样吧:
# ...
# 代码和之前一样
model.train()
# 第一轮
images = torch.ones(8, 3, 10, 10)
targets = torch.ones(8, dtype=torch.long)
output = model(images)
loss = criterion(output, targets)
loss.backward()
print(model.conv1.weight.grad[0][0][0])
# tensor([-0.0782, -0.0842, -0.0782])
# 第二轮
output = model(images)
loss = criterion(output, targets)
loss.backward()
print(model.conv1.weight.grad[0][0][0])
# tensor([-0.1564, -0.1684, -0.1564])
我们可以看到,第二次的结果正好是第一次的2倍。第一次结束之后,因为我们没有更新网络权重,所以第二次反向传播的求导结果和第一次结果一样,加上上次我们没有将 loss 清零,所以结果正好是2倍。
tensor.detach()
接下来我们来探讨两个 0.4.0 版本更新产生的遗留问题。第一个,tensor.data 和 tensor.detach()。
在 0.4.0 版本以前,.data 是用来取 Variable 中的 tensor 的,但是之后 Variable 被取消,.data 却留了下来。现在我们调用 tensor.data,可以得到 tensor的数据 + requires_grad=False 的版本,而且二者共享储存空间,也就是如果修改其中一个,另一个也会变。因为 PyTorch 的自动求导系统不会追踪 tensor.data 的变化,所以使用它的话可能会导致求导结果出错。官方建议使用 tensor.detach() 来替代它,二者作用相似,但是 detach 会被自动求导系统追踪,使用起来很安全[2]。我们来看个例子吧:
a = torch.tensor([7., 0, 0], requires_grad=True)
b = a + 2
print(b)
# tensor([9., 2., 2.], grad_fn=<AddBackward0>)
loss = torch.mean(b * b)
b_ = b.detach()
b_.zero_()
print(b)
# tensor([0., 0., 0.], grad_fn=<AddBackward0>)
# 储存空间共享,修改 b_ , b 的值也变了
loss.backward()
# RuntimeError: one of the variables needed for gradient computation has been modified by an inplace operation
这个例子中,b 是用来计算 loss 的一个变量,我们在计算完 loss 之后,进行反向传播之前,修改 b 的值。这么做会导致相关的导数的计算结果错误,因为我们在计算导数的过程中还会用到 b 的值,但是它已经变了(和正向传播过程中的值不一样了)。在这种情况下,PyTorch 选择报错来提醒我们。但是,如果我们使用 tensor.data的时候,结果是这样的:
a = torch.tensor([7., 0, 0], requires_grad=True)
b = a + 2
print(b)
# tensor([9., 2., 2.], grad_fn=<AddBackward0>)
loss = torch.mean(b * b)
b_ = b.data
b_.zero_()
print(b)
# tensor([0., 0., 0.], grad_fn=<AddBackward0>)
loss.backward()
print(a.grad)
# tensor([0., 0., 0.])
# 其实正确的结果应该是:# tensor([6.0000, 1.3333, 1.3333])
这个导数计算的结果明显是错的,但没有任何提醒,之后再 Debug 会非常痛苦。所以,建议大家都用 tensor.detach()
CPU and GPU
接下来我们来说另一个问题,是关于 tensor.cuda() 和 tensor.to(device) 的。后者是 0.4.0 版本之后后添加的,当 device 是 GPU 的时候,这两者并没有区别。那为什么要在新版本增加后者这个表达呢,是因为有了它,我们直接在代码最上边加一句话指定 device ,后面的代码直接用to(device) 就可以了:
device = torch.device("cuda") if torch.cuda.is_available() else torch.device("cpu")
a = torch.rand([3,3]).to(device)
pass
b = torch.rand([3,3]).to(device)
pass
c = torch.rand([3,3]).to(device)
而之前版本的话,当我们每次在不同设备之间切换的时候,每次都要用 if cuda.is_available() 判断能否使用 GPU,很麻烦。
if torch.cuda.is_available():
a = torch.rand([3,3]).cuda()
pass
if torch.cuda.is_available():
b = torch.rand([3,3]).cuda()
pass
if torch.cuda.is_available():
c = torch.rand([3,3]).cuda()
关于使用 GPU 还有一个点,在我们想把 GPU tensor 转换成 Numpy 变量的时候,需要先将 tensor 转换到 CPU 中去,因为 Numpy 是 CPU-only 的。其次,如果 tensor 需要求导的话,还需要加一步 detach,再转成 Numpy 。例子如下:
x = torch.rand([3,3], device='cuda')
x_ = x.cpu().numpy()
y = torch.rand([3,3], requires_grad=True, device='cuda').
y_ = y.cpu().detach().numpy()
# y_ = y.detach().cpu().numpy() 也可以
# 二者好像差别不大?我们来比比时间:
start_t = time.time()
for i in range(10000):
y_ = y.cpu().detach().numpy()
print(time.time() - start_t)
# 1.1049120426177979
start_t = time.time()
for i in range(10000):
y_ = y.detach().cpu().numpy()
print(time.time() - start_t)
# 1.115112543106079# 时间差别不是很大,当然,这个速度差别可能和电脑配置有关。
tensor.item()
我们在提取 loss 的纯数值的时候,常常会用到 loss.item(),其返回值是一个 Python 数值 (python number)。不像从 tensor 转到 numpy (需要考虑 tensor 是在 cpu,还是 gpu,需不需要求导),无论什么情况,都直接使用 item() 就完事了。如果需要从 gpu 转到 cpu 的话,PyTorch 会自动帮你处理。
但注意 item() 只适用于 tensor 只包含一个元素的时候。因为大多数情况下我们的 loss 就只有一个元素,所以就经常会用到 loss.item()。如果想把含多个元素的 tensor 转换成 Python list 的话,要使用 tensor.tolist()。
x = torch.randn(1, requires_grad=True, device='cuda')
print(x)
# tensor([-0.4717], device='cuda:0', requires_grad=True)
y = x.item()
print(y, type(y))
# -0.4717346727848053 <class 'float'>
x = torch.randn([2, 2])
y = x.tolist()
print(y)# [[-1.3069953918457031, -0.2710231840610504], [-1.26217520236969, 0.5559719800949097]]
参考资料
https://zh.d2l.ai/index.html
https://zhuanlan.zhihu.com/p/69294347
https://zhuanlan.zhihu.com/p/67184419
标签:loss,tensor,23,requires,番外,torch,pytorch,print,grad From: https://blog.csdn.net/weixin_42251091/article/details/144650474