PyTorch中在反向传播前为什么要手动将梯度清零?

简单的理由是因为PyTorch默认会对梯度进行累加。

至于为什么PyTorch有这样的特点,在网上找到的解释是说由于PyTorch的动态图和autograd机制使得其非常灵活,这也意味着你可以得到对一个张量的梯度,然后再次用该梯度进行计算,然后又可重新计算对新操作的梯度,对于何时停止前向操作并没有一个确定的点。所以自动设置梯度为0比较棘手,因为你不知道什么时候一个计算会结束以及什么时候又会有一个新的开始。

默认累加的好处是当在多任务中对前面共享部分的tensor进行了多次计算操作后,调用不同任务loss的backward,那些tensor的梯度会自动累加,缺点是当你不想先前的梯度影响到当前梯度的计算时需要手动清零。

举一个很简单的例子,当你GPU显存较少时,你又想要调大batch-size,此时你就可以利用PyTorch的这个性质进行梯度的累加来进行backward。

或者当你训练生成对抗网络等需要相互依赖的网络时,此方案可以让你较好应用各种trick来进行训练(例如不同batch-size,同步或者异步训练等)。

这种模式提供给用户更多的自由度,把梯度玩出花样,比如说梯度累加(gradient accumulation)

传统的训练函数,一个batch是这么训练的:

for i, (image, label) in enumerate(train_loader):
    # 1. forward
    pred = model(image)
    loss = criterion(pred, label)

    # 2. backward
    loss.backward()

    # 3. update parameters of net
    optimizer.step() 
 
    # 4. reset gradient
    optimizer.zero_grad()             
  1. model.forward():前向推理,计算损失函数;
  2. loss.backward():反向传播,计算当前梯度;
  3. optimizer.step():根据梯度更新网络参数;
  4. optimizer.zero_grad():清空梯度;

简单的说就是进来一个 batch 的数据,计算一次梯度,更新一次网络


使用梯度累加是这么写的:

for i, (image, label) in enumerate(train_loader):
    # 1. forward
    pred = model(image)
    loss = criterion(pred, label)

    # 2. backward
    loss = loss / accumulation_steps  
    loss.backward()

    # 3. update parameters of net
    if (i + 1) % accumulation_steps == 0:
        # 4.1 update parameters of net
        optimizer.step()    

        # 4.2 reset gradient
        optimizer.zero_grad()    
  1. optimizer.zero_grad():清空过往梯度
  2. model.forward():前向推理,计算损失函数;
  3. loss.backward():反向传播,计算当前梯度;
  4. optimizer.step():多次循环步骤 2-3,梯度累加一定次数后,根据梯度更新网络参数,然后清空梯度

总结来说:梯度累加就是,每次获取1个batch的数据,计算1次梯度,梯度不清空,不断累加,累加一定次数后,根据累加的梯度更新网络参数,然后清空梯度,进行下一次循环。

一定条件下,batchsize 越大训练效果越好,梯度累加则实现了 batchsize 的变相扩大,如果accumulation_steps 为 8,则batchsize '变相' 扩大了8倍,使用时需要注意,学习率也要适当放大。


更新1:关于BN是否有影响,BN的估算是在forward阶段就已经完成的,并不冲突

As far as I know, batch norm statistics get updated on each forward pass, so no problem if you don't do .backward() every time.

更新2:根据 @李韶华 的分享,可以适当调低BN自己的momentum参数

bn自己有个momentum参数:x_new_running=(1 - momentum) * x_running + momentum * x_new_observed. momentum越接近0,老的running stats记得越久,所以可以得到更长序列的统计信息

个人拙见:

各位答主都答到了zero_grad()的好处。那我试着从code的角度解释解释,一般来说是如下模板

optimizer.zero_grad()             ## 梯度清零
preds = model(inputs)             ## inference
loss = criterion(preds, targets)  ## 求解loss
loss.backward()                   ## 反向传播求解梯度
optimizer.step()                  ## 更新权重参数
  1. 由于pytorch的动态计算图,当我们使用loss.backward()和opimizer.step()进行梯度下降更新参数的时候,梯度并不会自动清零。并且这两个操作是独立操作。
  2. backward():反向传播求解梯度。
  3. step():更新权重参数。

基于以上几点,正好说明了pytorch的一个特点是每一步都是独立功能的操作,因此也就有需要梯度清零的说法,如若不显示的进行optimizer.zero_grad()这一步操作,backward()的时候就会累加梯度,也就有了各位答主所说到的梯度累加这种trick。

原因在于在PyTorch中,计算得到的梯度值会进行累加

而这样的好处可以从内存消耗的角度来看

在PyTorch中,multi-task任务一个标准的train from scratch流程为

for idx, data in enumerate(train_loader):
    xs, ys=data
    pred1=model1(xs)
    pred2=model2(xs)
    

    loss1=loss_fn1(pred1, ys)
    loss2=loss_fn2(pred2, ys)
    
    ******
    loss=loss1 + loss2
    optmizer.zero_grad()
    loss.backward()
    ++++++
    optmizer.step()

从PyTorch的设计原理上来说,在每次进行前向计算得到pred时,会产生一个用于梯度回传的计算图,这张图储存了进行back propagation需要的中间结果,当调用了.backward()后,会从内存中将这张图进行释放

上述代码执行到******时,内存中是包含了两张计算图的,而随着求和得到loss,这两张图进行了合并,而且大小的变化可以忽略

执行到++++++时,得到对应的grad值并且释放内存。这样,训练时必须存储两张计算图,而如果loss的来源组成更加复杂,内存消耗会更大

为了减小每次的内存消耗,借助梯度累加,又有 \\partial(l1+l2)/\\partial(x)=\\partial(l1)/\\partial(x)+\\partial(l2)/\\partial(x) ,有如下变种


for idx, data in enumerate(train_loader):
    xs, ys = data
    
    optmizer.zero_grad()
    # 计算d(l1)/d(x)
    pred1 = model1(xs) #生成graph1
    loss1 = loss_fn1(pred1, ys)
    loss1.backward()  #释放graph1

    # 计算d(l2)/d(x)
    pred2 = model2(xs)#生成graph2
    loss2 = loss_fn2(pred2, ys)
    loss2.backward()  #释放graph2

    # 使用d(l1)/d(x)+d(l2)/d(x)进行优化
    optmizer.step()

可以从代码中看出,利用梯度累加,可以在最多保存一张计算图的情况下进行multi-task任务的训练。

另外一个理由就是在内存大小不够的情况下叠加多个batch的grad作为一个大batch进行迭代,因为二者得到的梯度是等价的

综上可知,这种梯度累加的思路是对内存的极大友好,是由FAIR的设计理念出发的。


平台注册入口