【问题标题】:Understanding accumulated gradients in PyTorch了解 PyTorch 中的累积梯度
【发布时间】:2020-09-15 22:15:48
【问题描述】:

我正在尝试理解PyTorch 中梯度累积的内部工作原理。我的问题与这两个有点相关:

Why do we need to call zero_grad() in PyTorch?

Why do we need to explicitly call zero_grad()?

对第二个问题的已接受答案的评论表明,如果 minibatch 太大而无法在单个前向传递中执行梯度更新,则可以使用累积梯度,因此必须将其拆分为多个子批次。

考虑以下玩具示例:

import numpy as np
import torch


class ExampleLinear(torch.nn.Module):

    def __init__(self):
        super().__init__()
        # Initialize the weight at 1
        self.weight = torch.nn.Parameter(torch.Tensor([1]).float(),
                                         requires_grad=True)

    def forward(self, x):
        return self.weight * x


if __name__ == "__main__":
    # Example 1
    model = ExampleLinear()

    # Generate some data
    x = torch.from_numpy(np.array([4, 2])).float()
    y = 2 * x

    optimizer = torch.optim.SGD(model.parameters(), lr=0.01)

    y_hat = model(x)          # forward pass

    loss = (y - y_hat) ** 2
    loss = loss.mean()        # MSE loss

    loss.backward()           # backward pass

    optimizer.step()          # weight update

    print(model.weight.grad)  # tensor([-20.])
    print(model.weight)       # tensor([1.2000]

这正是人们所期望的结果。现在假设我们要利用梯度累积逐个样本处理数据集:

    # Example 2: MSE sample-by-sample
    model2 = ExampleLinear()
    optimizer = torch.optim.SGD(model2.parameters(), lr=0.01)

    # Compute loss sample-by-sample, then average it over all samples
    loss = []
    for k in range(len(y)):
        y_hat = model2(x[k])
        loss.append((y[k] - y_hat) ** 2)
    loss = sum(loss) / len(y)

    loss.backward()     # backward pass
    optimizer.step()    # weight update

    print(model2.weight.grad)  # tensor([-20.])
    print(model2.weight)       # tensor([1.2000]

再次如预期的那样,在调用.backward() 方法时计算梯度。

最后是我的问题:“幕后”到底发生了什么?

我的理解是计算图是动态更新的,从 <PowBackward><AddBackward> <DivBackward>loss 变量的操作,并且除了每个前向传递使用的数据的任何信息都不会保留在任何地方loss 张量,可以更新直到向后传递。

上述段落中的推理是否有任何警告?最后,在使用梯度累积时是否有任何最佳实践可以遵循(即我在示例 2 中使用的方法是否会适得其反)?

【问题讨论】:

  • 您的model2 描述中有一个小误解。 没有梯度累积与描述中的描述不同。您只调用一次 loss.backward() 并且 not 为每个小批量(这里只有 1 个样本)。梯度计算,也就是累积,是在 PyTorch 中用 C++ 编写的。正确的梯度累积示例,请看gradient accumulation gist
  • @kmario23 是的,我的错。显然,在第二个例子中,梯度在我脑海中累积只是因为平均(梯度)=梯度(平均),没有为图形分配“物理”值。

标签: python deep-learning pytorch gradient-descent


【解决方案1】:

您实际上并没有累积梯度。如果你有一个单独的.backward() 调用,那么离开optimizer.zero_grad() 就没有效果,因为梯度已经从一开始就为零(技术上None 但它们将是 自动初始化为零)。

两个版本之间的唯一区别是计算最终损失的方式。第二个示例的 for 循环执行与第一个示例中 PyTorch 相同的计算,但是您单独执行它们,并且 PyTorch 无法优化(并行化和矢量化)您的 for 循环,这在 GPU 上产生了特别惊人的差异,前提是张量并不小。

在进行梯度累积之前,让我们从你的问题开始:

最后是我的问题:“幕后”到底发生了什么?

当且仅当其中一个操作数已经是计算图的一部分时,才会在计算图中跟踪对张量的每个操作。当您设置张量的requires_grad=True 时,它会创建一个具有单个顶点的计算图,即张量本身,它将在图中保持叶子。使用该张量的任何操作都会创建一个新顶点,这是操作的结果,因此从操作数到它有一条边,跟踪执行的操作。

a = torch.tensor(2.0, requires_grad=True)
b = torch.tensor(4.0)
c = a + b # => tensor(6., grad_fn=<AddBackward0>)

a.requires_grad # => True
a.is_leaf # => True

b.requires_grad # => False
b.is_leaf # => True

c.requires_grad # => True
c.is_leaf # => False

每个中间张量都自动需要梯度并且有一个grad_fn,它是计算关于其输入的偏导数的函数。由于链式法则,我们可以以相反的顺序遍历整个图来计算关于每个叶子的导数,这是我们想要优化的参数。这就是反向传播的概念,也称为反向模式微分。有关更多详细信息,我建议阅读Calculus on Computational Graphs: Backpropagation

PyTorch 使用了这个确切的想法,当您调用 loss.backward() 时,它会以相反的顺序遍历图形,从 loss 开始,并计算每个顶点的导数。每当到达叶子时,该张量的计算导数就会存储在其.grad 属性中。

在您的第一个示例中,这将导致:

MeanBackward -> PowBackward -> SubBackward -> MulBackward`

第二个示例几乎相同,不同之处在于您手动计算平均值,并且对于损失计算的每个元素都有多个路径,而不是单一路径。澄清一下,单路径也计算每个元素的导数,但在内部,这再次打开了一些优化的可能性。

# Example 1
loss = (y - y_hat) ** 2
# => tensor([16.,  4.], grad_fn=<PowBackward0>)

# Example 2
loss = []
for k in range(len(y)):
    y_hat = model2(x[k])
    loss.append((y[k] - y_hat) ** 2)
loss
# => [tensor([16.], grad_fn=<PowBackward0>), tensor([4.], grad_fn=<PowBackward0>)]

在任何一种情况下,都会创建一个仅反向传播一次的单个图,这就是它不被视为梯度累积的原因。

梯度累积

梯度累积是指在更新参数之前执行多次向后传递的情况。目标是为多个输入(批次)提供相同的模型参数,然后根据所有这些批次更新模型的参数,而不是在每个批次之后执行更新。

让我们重温您的示例。 x 的大小为 [2],即我们整个数据集的大小。出于某种原因,我们需要基于整个数据集计算梯度。当使用批量大小为 2 时自然会出现这种情况,因为我们将同时拥有整个数据集。但是如果我们只能有大小为 1 的批次会发生什么?我们可以像往常一样单独运行它们并在每批之后更新模型,但是我们不会计算整个数据集的梯度。

我们需要做的是使用相同的模型参数单独运行每个样本,并在不更新模型的情况下计算梯度。现在你可能会想,这不就是你在第二个版本中所做的吗?几乎,但不完全是,您的版本中存在一个关键问题,即您使用的内存量与第一个版本相同,因为您有相同的计算,因此计算图中的值数量相同。

我们如何释放内存?我们需要摆脱前一批的张量以及计算图,因为这会使用大量内存来跟踪反向传播所需的一切。调用.backward() 时,计算图会自动销毁(除非指定了retain_graph=True)。

def calculate_loss(x: torch.Tensor) -> torch.Tensor:
    y = 2 * x
    y_hat = model(x)
    loss = (y - y_hat) ** 2
    return loss.mean()


# With mulitple batches of size 1
batches = [torch.tensor([4.0]), torch.tensor([2.0])]

optimizer.zero_grad()
for i, batch in enumerate(batches):
    # The loss needs to be scaled, because the mean should be taken across the whole
    # dataset, which requires the loss to be divided by the number of batches.
    loss = calculate_loss(batch) / len(batches)
    loss.backward()
    print(f"Batch size 1 (batch {i}) - grad: {model.weight.grad}")
    print(f"Batch size 1 (batch {i}) - weight: {model.weight}")

# Updating the model only after all batches
optimizer.step()
print(f"Batch size 1 (final) - grad: {model.weight.grad}")
print(f"Batch size 1 (final) - weight: {model.weight}")

输出(为了便于阅读,我删除了包含消息的参数):

Batch size 1 (batch 0) - grad: tensor([-16.])
Batch size 1 (batch 0) - weight: tensor([1.], requires_grad=True)
Batch size 1 (batch 1) - grad: tensor([-20.])
Batch size 1 (batch 1) - weight: tensor([1.], requires_grad=True)
Batch size 1 (final) - grad: tensor([-20.])
Batch size 1 (final) - weight: tensor([1.2000], requires_grad=True)

如您所见,模型对所有批次都保持相同的参数,而梯度是累积的,最后只有一次更新。请注意,损失需要按批次进行缩放,以便在整个数据集上具有与使用单个批次相同的重要性。

虽然在此示例中,在执行更新之前使用了整个数据集,但您可以轻松更改它以在一定数量的批次后更新参数,但您必须记住在执行优化步骤后将梯度归零.一般配方是:

accumulation_steps = 10
for i, batch in enumerate(batches):
    # Scale the loss to the mean of the accumulated batch size
    loss = calculate_loss(batch) / accumulation_steps
    loss.backward()
    if (i + 1) % accumulation_steps == 0:
        optimizer.step()
        # Reset gradients, for the next accumulated batches
        optimizer.zero_grad()

您可以在HuggingFace - Training Neural Nets on Larger Batches: Practical Tips for 1-GPU, Multi-GPU & Distributed setups 中找到处理大批量的方法和更多技术。

【讨论】:

  • 感谢您提供如此全面的答案!。您能否也澄清一下这句话:'即您使用的内存量与第一个版本相同'?我是否正确理解您根据权重和梯度来指代内存,这些权重和梯度将在示例 2 的两个反向传递中使用,因为计算图被修改而不是破坏以适应第二次损失计算?
  • @VikingCat 是的,图需要存储相关信息来执行反向传播,无论是一步一步还是一次,图都需要相同的信息,只有在调用@987654342后才会清除@.
  • @kmario23 我说过第二个示例的 for 循环与 PyTorch 在第一个示例中所做的事情相同,但 PyTorch 并行化/矢量化了第一个版本。而且你添加源是对的,所以我做到了。
  • @MichaelJungo 我想还是有一个错字,“使用 for 循环单独进行每个计算正是第一个版本中发生的情况,....”跨度>
  • @MichaelJungo - if (i - 1) % accumulation_steps == 0 应该是 if (i + 1) % accumulation_steps == 0 吗?
猜你喜欢
  • 2021-09-26
  • 2019-04-19
  • 1970-01-01
  • 2021-01-04
  • 2020-12-28
  • 2023-03-27
  • 2021-03-24
  • 2021-06-08
  • 1970-01-01
相关资源
最近更新 更多