pytorch的错误demo:backword()在循环的第二次运行才报错,为什么第一次运行正常?

代码是故意写错的demo,我知道它会报这个错,但不知道为什么要在while循环中第2次运行到第44行的时候才报错。 import torch from…
关注者
7
被浏览
4,029

2 个回答

先说结论:错误是因为in-place操作导致的


一、一些基础知识

在讨论这个问题之前,我先简单的提一提几个下文中可能会涉及到的概念

1、tensor._version

这是一个用来记录张量曾经做过的in-place操作次数的属性,我们来看几个简单的例子:

import torch
a = torch.tensor([1.])     # 新建一个张量
print(a._version, id(a))   # 这个张量尚未进行过in-place操作,因此version值为0
a += torch.tensor([1.])    # 这是一个in-place操作
print(a._version, id(a))   # 张量的version值加1
a[:] = torch.tensor([2.])  # 这也是一个in-place操作
print(a._version, id(a))   # 张量的version值加1
a = torch.tensor([2.])     # 这不是一个in-place操作(注意张量的地址)
print(a._version, id(a))   # 这个“新”张量的version为0
# 输出如下
0 4311377216
1 4311377216
2 4311377216
0 4313051200

不过,为什么PyTorch要为张量添加一个这样的属性呢? 这是因为,擅自修改张量的值会导致神经网络在反向传播时依据链式法求得的结果是错误的。因此,PyTorch会在反向传播结束后保存神经网络中每一个权重的version值;并在下一次反向传播开始时检查它们的version值是否发生了变化(变化即意味着程序在反向传播之外进行了额外的的in-place操作)。

2、计算图

PyTorch是动态图框架,计算图在反向传播时被系统自动构建,并在反向传播结束后被系统自动释放。 那么,系统是依据什么构建计算图的呢? 答案是,系统依据张量的grad_fn属性(该属性在正向传播时由系统自动记录)来构建计算图,所有requires_grad = True的张量都会被包含在这个计算图中。


二、分析程序运行

接下来我将会尽量详细的分析程序的运行情况。


1、在实例化神经网络后,我们添加以下代码观察神经网络的状态

model = Model()
# 添加以下三行
print('w1._version:', model.w1._version, ' ---- w1.grad:', model.w1.grad)
print('w2._version:', model.w2._version, ' ---- w2.grad:', model.w2.grad)
print('w3._version:', model.w3._version, ' ---- w3.grad:', model.w3.grad)
# 输出如下
w1._version: 0  ---- w1.grad: None
w2._version: 0  ---- w2.grad: None
w3._version: 0  ---- w3.grad: None

这个结果显而易见:由于PyTorch是动态图框架,计算图在反向传播时才会被系统自动构建。此时的神经网络刚刚被创建,因此每一个权重的version值都是0,grad值都是None。


2、在第一次循环的第一次反向传播结束以后,我们添加以下代码观察神经网络的状态

loss1.backward(retain_graph=True)
# 添加以下三行
print('w1._version:', model.w1._version, ' ---- w1.grad:', model.w1.grad)
print('w2._version:', model.w2._version, ' ---- w2.grad:', model.w2.grad)
print('w3._version:', model.w3._version, ' ---- w3.grad:', model.w3.grad)
# 输出如下
w1._version: 1  ---- w1.grad: tensor(...)
w2._version: 1  ---- w2.grad: tensor(...)
w3._version: 0  ---- w3.grad: None

这个结果很好理解:与y1_pred有关的权重是w1与w2,因此在反向传播时,系统构建了一个包含w1与w2的计算图,并在反向传播后更新了w1与w2;因此我们看到w1与w2的version值加1,grad值也不再为None。 那么,w3是否在这个计算图中呢? 答案是,不在。因为w3并没有参与这次正向传播的过程,w3的grad值仍然等于None(等价于w3.requires_grad = False),因此系统在自动构建计算图时并没有将w3加入计算图中。


3、在第一次循环的第二次反向传播结束以后,我们添加以下代码观察神经网络的状态

loss2.backward(retain_graph=True)
# 添加以下三行
print('w1._version:', model.w1._version, ' ---- w1.grad:', model.w1.grad)
print('w2._version:', model.w2._version, ' ---- w2.grad:', model.w2.grad)
print('w3._version:', model.w3._version, ' ---- w3.grad:', model.w3.grad)
# 输出如下
w1._version: 2  ---- w1.grad: tensor(...)
w2._version: 2  ---- w2.grad: tensor(...)
w3._version: 1  ---- w3.grad: tensor(...)  # 不再是None

这个结果就不太好理解了:与y2_pred有关的权重是w1与w3,因此在反向传播时,系统构建了一个包含w1与w3的计算图,并在反向传播后更新了w1与w3,w3的grad值也不再为None。然而值得注意的是,w2的version值也加了1,这就说明w2也在计算图中。 为什么w2并没有参与这次正向传播的过程,却被包含在了计算图中呢?

要回答这个问题,先让我们思考一下这个问题: 这两次反向传播使用的是同一个计算图吗? 答案是肯定的。在第一次反向传播结束以后,我们创建了一个包含w1与w2的计算图,并使用retain_graph=True保留了它;在第二次反向传播时,我们在计算图中添加了一个节点w3(而不是释放上一个计算图,重新创建一个仅包含w1与w3的新计算图)。现在我们知道了,在第二次反向传播结束以后,虽然w2的值没有更新(因为w2与y2_pred无关),但是w2的version值还是加了1(因为w2在计算图中,系统认为它参与了更新,虽然值不变)。

上文还提到过,PyTorch会在反向传播结束后保存神经网络中每一个权重的version值来确保运算的正确性。 那么,此时系统记录的version值是多少呢?第一次反向传播与第二次反向传播是一起记录还是分开记录?如果是分别记录,值又各是多少? 首先,第一次反向传播与第二次反向传播记录是分开记录verison值的,毕竟不是同一个反向传播,怎么可能混为一谈;又由于系统在计算图释放时才记录version值,因此:

在第二次循环的第一次反向传播开始时,各节点的version值应该为:

w1._version: 2
w2._version: 2
w3._version: 1

在第二次循环的第二次反向传播开始时,各节点的version值应该为:

w1._version: 2
w2._version: 2
w3._version: 1


4、在第二次循环的第一次反向传播结束以后,我们继续观察神经网络的状态

w1._version: 3  ---- w1.grad: tensor(...)
w2._version: 3  ---- w2.grad: tensor(...)
w3._version: 2  ---- w3.grad: tensor(...)

现在这个结果就很容易理解了:w1、w2与w3都在计算图中,因此在第一次反向传播结束以后,它们的version值都加了1。


5、现在到了报错的位置,我们来看看这里为什么会报错

上文曾提到过,在第二次循环的第二次反向传播开始时,各节点的version值应该为:

w1._version: 2
w2._version: 2
w3._version: 1

然而,现在各节点的version值为:

w1._version: 3  ---- w1.grad: tensor(...)
w2._version: 3  ---- w2.grad: tensor(...)
w3._version: 2  ---- w3.grad: tensor(...)

因此,在第二次循环的第二次反向传播开始时,系统比对w1、w2与w3的verison值时(由于w3在反向传播的第一环,因此先比对w3的version值),发现了问题:w3的version应该为1,而现在w3的version为2!

因此,系统报错:

RuntimeError: ....[torch.FloatTensor [1]] is at version 2; expected version 1 instead.


三、研究解决办法


1、构建只包含部分节点的计算图

之所以出现这种错误,是因为系统自动构建的计算图中,总是含有我们不想要的节点。为了使系统构建我们想要的计算图,我们会很自然的想到使用requires_grad = False来修改系统自动生成的计算图。于是,我们对程序作出如下修改:

...
while True:
    model.w3.requires_grad = False  # 添加此行,使系统建立的计算图不包括w3
    optim.zero_grad()
    loss1.backward(retain_graph=True)
    optim.step()
    model.w2.requires_grad = False  # 添加此行,使系统建立的计算图不包括w2
    optim.zero_grad()
    loss2.backward(retain_graph=True)
    optim.step()

此时,我们运行程序,看到屏幕上不断的输出:

...
n=100
n=101
...

成功了!但是等等,为了保险起见,让我们再来检查一下version的输出:

# 第一次循环
w1._version: 1
w2._version: 1
w3._version: 0
w1._version: 2
w2._version: 2
w3._version: 0
# 第二次循环
w1._version: 3
w2._version: 3
w3._version: 0
w1._version: 4
w2._version: 4
w3._version: 0

咦?看起来w3的权重一直没有更新,这并不是我们想要的结果。 但是这是为什么呢? 其实原因很简单,在构建计算图之前使权重的requires_grad = False,可以使计算图中不包含该节点;在构建计算图之后使权重的requires_grad = False,只能使计算图停止更新该节点的权重,并不能从计算图中删去该节点!因此,我们添加的第二句话并不能达到我们希望的效果。


2、使用clone()

既然无法通过修改计算图的方法实现这个神经网络,我们还有没有别的方法呢?有的,我们可以使用clone()这个方法修改神经网络:

...
class Model(torch.nn.Module):
    def __init__(self):
        super(Model, self).__init__()
        self.w1 = nn.Parameter(torch.tensor([2.], requires_grad=True))
        self.w2 = nn.Parameter(torch.tensor([3.], requires_grad=True))
        self.w3 = nn.Parameter(torch.tensor([4.], requires_grad=True))
    def forward(self, a):
        s = a * self.w1
        w2_ = torch.clone(self.w2)
        c = s * w2_
        w3_ = torch.clone(self.w3)
        d = s * w3_
        return c, d
...

此时系统构建的计算图包含w1、w2_与w3_,这样可以避免系统在检查version值时报错。但是这样也导致了系统预防in-place的能力降低。


3、建议

其实,在实际中并不建议使用这样的神经网络。因为这种神经网络从优化的角度来看并不合理:若两部分神经网络在权重的更新上有量级的差距,那么大概率会使神经网络的效率低下,损害优化过程。因此,我们常常会采用这样的网络来进行训练:

...
while True:
    y1_pred, y2_pred = model(x)
    loss1 = mse(y1_pred, y1)