文章目录
- pytorch中的Autograd
- 计算图
- 叶子张量
- inplace操作
- PyTorch的两大特点
- 动态图
- eager execution
- PyTorch中的Variable
- 参考文献
pytorch中的Autograd
pytorch提供了自动求导机制和对GPU的支持
了解自动求导背后的原理和规则:当使用pytorch中没有的loss function时,需要我们自己写loss function
计算图
假设我们有一个复杂的神经网络模型,我们把它想象成一个错综复杂的管道结构,不同的管道之间通过节点连接起来,我们有一个注水口,一个出水口。我们在入口注入数据之后,数据就沿着设定好的管道路线缓缓流动到出水口,这时候我们就完成了一次正向传播。想象一下输入的 tensor 数据在管道中缓缓流动的场景,这就是为什么 TensorFlow 叫 TensorFlow 的原因
计算图中的两个元素:tensor和Function
- Function:在计算中某个节点所进行的计算,比如加、减、乘、除、卷积
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)
x = 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 =x * 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 的值也为空
x = [1.0, 1.0, 1.0, 1.0]
w1 = [2.0, 2.0, 2.0, 2.0]
w2 = [3.0, 3.0, 3.0, 3.0]
w3 = [4.0, 4.0, 4.0, 4.0]
l1 = x * w1 = [2.0, 2.0, 2.0, 2.0]
l2 = l1 + w2 = [5.0, 5.0, 5.0, 5.0]
l3 = l1 * w3 = [8.0, 8.0, 8.0, 8.0]
l4 = l2 * l3 = [40.0, 40.0, 40.0, 40.0]
loss = mean(l4) = 40.0
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
由于l1
、l2
、l3
、l4
均未设置requires_grad=True
,所以PyTorch不会自动追踪其梯度
叶子张量
对于任意一个张量来说,我们可以用 tensor.is_leaf
来判断它是否是叶子张量(leaf tensor)。在反向传播过程中,只有 is_leaf=True
的时候,需要求导的张量的梯度结果才会被最后保留下来
当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
不为空
inplace操作
inplace 指的是在不更改变量的内存地址的情况下,直接修改变量的值
我们来看两种情况,大家觉得这两种情况哪个是 inplace 操作,哪个不是?或者两个都是 inplace?
# 情景 1
a = a.exp()
# 情景 2
a[0] = 10
答案是:情景1不是 inplace,类似 Python 中的 i=i+1
, 而情景2是 inplace 操作,类似 i+=1
接下来以 PyTorch 不同的报错信息作为驱动介绍inplace操作
第一个报错信息:
RuntimeError: one of the variables needed for gradient computation has been modified by an inplace operation
# 我们要用到 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._version
检测tensor是否发生inplace操作
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 进行重新赋值后,a 变成了通过复制操作<CopySlices>
生成的张量,它不再是叶子节点。原本应该保留梯度值的变量,现在却成为了梯度会被自动释放的中间变量
另外一种情况:
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.])
我们需要注意的是,要在变量被使用之前修改,不然等计算完之后再修改,还会造成求导上的问题
为什么 PyTorch 的求导不支持绝大部分 inplace 操作呢?从上边我们也看出来了,因为真的很 tricky。比如有的时候在一个变量已经参与了正向传播的计算,之后它的值被修改了,在做反向传播的时候如果还需要这个变量的值的话,我们肯定不能用那个后来修改的值吧,但没修改之前的原始值已经被释放掉了,我们怎么办?一种可行的办法就是我们在 Function 做 forward 的时候每次都开辟一片空间储存当时输入变量的值,这样无论之后它们怎么修改,都不会影响了,反正我们有备份在存着。但这样有什么问题?这样会导致内存(或显存)使用量大大增加。因为我们不确定哪个变量可能之后会做 inplace 操作,所以我们每个变量在做完 forward 之后都要储存一个备份,成本太高了
PyTorch 不推荐使用 inplace 操作,当求导过程中发现有 inplace 操作影响求导正确性的时候,会采用报错的方式提醒。但这句话反过来说就是,因为只要有 inplace 操作不当就会报错,所以如果我们在程序中使用了 inplace 操作却没报错,那么说明我们最后求导的结果是正确的
PyTorch的两大特点
PyTorch 的两大特点是动态图和eager execution,这使得它的使用非常流畅,几乎和编写 Python 程序一样舒适,同时调试过程也极为方便;同时PyTorch 十分注重占用内存(或显存)大小,没有用的空间释放很及时,可以很有效地利用有限的内存
动态图
PyTorch 使用的是动态图(Dynamic Computational Graphs)的方式,而 TensorFlow 使用的是静态图(Static Computational Graphs)
- 动态图:每次当我们搭建完一个计算图,然后在反向传播结束之后,整个计算图就在内存中被释放了。如果想再次使用的话,必须从头再搭一遍
- 静态图:每次都先设计好计算图,需要的时候实例化这个图,然后送入各种输入,重复使用,只有当会话结束的时候创建的图才会被释放
# 这是一个关于 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() # 正常
理论上,静态图的效率比动态图的效率高。因为静态图只需要一次构建便可重复使用
eager execution
当遇到 tensor 计算的时候,马上就回去执行计算,实际上 PyTorch 根本不会去构建正向计算图,而是遇到操作就执行。真正意义上的正向计算图是把所有的操作都添加完,构建好了之后,再运行神经网络的正向传播
PyTorch中的Variable
tensor是硬币的话,那Variable就是钱包,它记录着里面的钱的多少,和钱的流向
torch0.4版本以后 torch.tensor()
就可以搞定所有
《PyTorch核心概念:从梯度、计算图到连续性的全面解析(一)》
《PyTorch核心概念:从梯度、计算图到连续性的全面解析(三)》
参考文献
1、PyTorch 的 Autograd
2、Pytorch入坑二:autograd 及Variable