一是 PyTorch 提供了自动求导机制,二是对 GPU 的支持。
现在已经有了很多封装好的 API,我们在写一个自己的网络的时候,可能几乎都不用去注意求导这些问题,因为这些 API 已经在私底下处理好了这些事情。现在我们往往只需要,搭建个想要的模型,处理好数据的载入,调用现成的 optimizer 和 loss function,直接开始训练就好了。仔细一想,好像连需要设置 requires_grad=True 的地方好像都没有。有人可能会问,那我们去了解自动求导还有什么用啊?
原因有很多,可以帮我们更深入地了解 PyTorch 这些宽泛的理由我就不说了,我举一个例子:当我们想使用一个 PyTorch 默认中并没有的 loss function 的时候,比如目标检测模型 YOLO 的 loss,我们可能就得自己去实现。如果我们不熟悉基本的 PyTorch 求导机制的话,对于实现过程中比如 tensor 的 inplace 操作等很容易出错,导致需要话很长时间去 debug,有的时候即使定位到了错误的位置,也不知道如何去修改。相反,如果我们理清楚了背后的原理,我们就能很快地修改这些错误,甚至根本不会去犯这些错误。鉴于现在官方支持的 loss function 并不多,而且深度学习领域日新月异,很多新的效果很好的 loss function 层出不穷,如果要用的话可能需要我们自己来实现。基于这个原因,我们了解一下自动求导机制还是很有必要的。
计算图(Computational Graphs)通常包含两种元素,一个是 tensor,另一个是 Function。张量 tensor 不必多说,但是大家可能对 Function 比较陌生。这里 Function 指的是在计算图中某个节点(node)所进行的运算,比如加减乘除卷积等等之类的,Function 内部有 forward() 和 backward() 两个方法,分别应用于正向、反向传播。
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
具体自动求导机制见链接。
对于任意一个张量来说,我们可以用 tensor.is_leaf 来判断它是否是叶子张量(leaf tensor)。在反向传播过程中,只有 is_leaf=True 的时候,需要求导的张量的导数结果才会被最后保留下来。
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 完之后就被清除掉了
这个函数的功能远远不止打印导数信息用以 debug,但是一般很少用,所以这里就不扩展了。
input 其实很像神经网络输入的图像,w1, w2, w3 则类似卷积核的参数,而 l1, l2, l3, l4 可以表示4个卷积层输出,如果我们把节点上的加法乘法换成卷积操作的话。实际上这个简单的模型,很像我们平时的神经网络的简化版
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 指的是在不更改变量的内存地址的情况下,直接修改变量的值。我们来看两种情况,
# 情景 1
a = a.exp() # 类似于i=i+1,不是inplace
#情景 2
a[0] = 10 # 类似于i+=1,是inplace
可以测试一下:
# 我们要用到 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 操作,内存地址没变
检测 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 ...
这个错误是指: 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=) False
loss = (a*a).mean()
loss.backward()
# RuntimeError: leaf variable has been moved into the graph interior
还有另外一种情况:
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.
那是不是需要求导的叶子节点一旦被初始化赋值之后,就不能修改它们的值了呢?我们如果在某种情况下需要重新对叶子变量赋值该怎么办呢?有办法!
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.])
为什么 PyTorch 的求导不支持绝大部分 inplace 操作呢?比如有的时候在一个变量已经参与了正向传播的计算,之后它的值被修改了,在做反向传播的时候如果还需要这个变量的值的话,我们肯定不能用那个后来修改的值吧,但没修改之前的原始值已经被释放掉了,我们怎么办?一种可行的办法就是我们在 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 层,提高效率。
当然,在 9102 年的今天,动态图和静态图直接的界限已经开始慢慢模糊。PyTorch 模型转成 Caffe 模型越来越方便,而 TensorFlow 也加入了一些动态图机制。
除了动态图之外,PyTorch 还有一个特性,叫 eager execution。意思就是当遇到 tensor 计算的时候,马上就回去执行计算,也就是,实际上 PyTorch 根本不会去构建正向计算图,而是遇到操作就执行。真正意义上的正向计算图是把所有的操作都添加完,构建好了之后,再运行神经网络的正向传播。
正是因为 PyTorch 的两大特性:动态图和 eager execution,所以它用起来才这么顺手,简直就和写 Python 程序一样舒服,debug 也非常方便。除此之外,我们从之前的描述也可以看出,PyTorch 十分注重占用内存(或显存)大小,没有用的空间释放很及时,可以很有效地利用有限的内存。
本篇文章主要讨论了 PyTorch 的 Autograd 机制和使用 inplace 操作不当可能会导致的各种报错。在实际写代码的过程中,涉及需要求导的部分,不建议大家使用 inplace 操作。除此之外我们还比较了动态图和静态图框架,PyTorch 作为动态图框架的代表之一,对初学者非常友好,而且运行速度上不逊于静态图框架,再加上现在通过 ONNX 转换为其他框架的模型用以部署也越来越方便,我觉得是一个非常称手的深度学习工具。