【Scala PyTorch深度学习】PyTorch On Scala 系列课程 第三章 06 :张量自动微分【AI Infra 3.0】[PyTorch Scala 硕士研一课程]
PyTorch Scala 高校计算机硕士研一课程章节 3: Autograd 自动微分有效地训练神经网络需要调整模型参数以最小化损失函数这通常使用梯度下降或其变体。这个过程的主要部分是计算损失函数相对于每个参数的梯度数学上表示为损失 LL和参数 ww的 ∂L∂w∂w∂L。对于复杂模型手动推导和实现这些梯度计算是不切实际的。本章介绍 PyTorch 的自动微分引擎 Autograd它旨在自动计算这些梯度。我们将了解PyTorch 如何在对张量执行操作时动态构建计算图。你将学习如何使用requires_gradTrue标记张量以进行梯度计算使用.backward()触发反向传播以计算梯度以及查看存储在.grad属性中的结果梯度。我们还将涉及梯度累积使用optimizer.zero_grad()清零梯度的必要性以及如何使用torch.no_grad()等上下文临时禁用梯度计算以提高推理或评估期间的效率。自动微分的原理训练神经网络包括迭代地调整模型参数权重和偏置以最小化损失函数。这种调整依赖于知晓每个参数的微小变化如何影响最终的损失值。从数学上讲这种敏感性由损失函数对每个参数的梯度来体现。对于损失 LL和参数 ww我们需要计算 ∂L∂w∂w∂L。对于非常简单的模型手动使用微积分规则计算这些梯度是可行的但对于当今常见的深度多层网络来说这很快就会变得异常复杂且容易出错。想象一下为拥有数百万参数的模型推导导数这时**自动微分AD**就派上用场了。AD 是一系列以数值方式评估由计算机程序定义的函数导数的方法。与符号微分它操作数学表达式常导致复杂且低效的公式或数值微分它使用有限差分近似导数可能存在截断和舍入误差不同AD 通过在构成整体计算的基本运算加法、乘法、三角函数等层面系统地应用微积分的链式法则高效地计算出精确的梯度。链式法则AD 的核心其核心是AD 依赖于链式法则。如果有一系列函数例如 yf(x)yf(x) 和 zg(y)zg(y)链式法则告诉我们如何找到复合函数 zg(f(x))zg(f(x)) 对 xx的导数dzdxdzdy⋅dydxdxd**zdyd**z⋅dxd**yAD 将复杂的计算分解为一系列基本运算。然后它计算每个小步骤的局部导数并使用链式法则将它们组合起来以得到整体梯度。考虑一个简单例子L(w⋅xb)2L(w⋅xb)2。令 yw⋅xbyw⋅xb。则 Ly2Ly2。 要找到 ∂L∂w∂w∂L链式法则给出∂L∂w∂L∂y⋅∂y∂w∂w∂L∂y∂L⋅∂w∂y我们知道 ∂L∂y2y∂y∂L2y和 ∂y∂wx∂w∂yx。将 yy代回我们得到∂L∂w(2(w⋅xb))⋅x∂w∂L(2(w⋅xb))⋅xAD 自动完成这个过程即使运算链非常长。正向模式与反向模式在 AD 中应用链式法则主要有两种方式正向模式正向累积通过从输入到输出遍历计算步骤来计算导数。它计算一个输入的改变如何影响所有中间变量和最终输出。当输入数量相对于输出数量较少时它比较高效。反向模式反向累积通过从最终输出到输入反向遍历计算步骤来计算导数。它计算最终输出的改变如何受到所有中间变量和输入的影响。当输出数量相对于输入数量较少时这种模式明显更高效这正是深度学习中的情况因为我们通常只有一个标量损失值和数百万个参数损失函数的输入。PyTorch 的 Autograd 系统使用反向模式自动微分。Autograd 如何使用 AD当您对requires_grad属性设置为True的 PyTorch 张量执行操作时PyTorch 会在后台构建一个有向无环图DAG。这个图通常被称为计算图它记录了操作序列节点和涉及的张量边。让我们直观地看一下 L(a⋅xb)2L(a⋅xb)2 的简单计算图假设 aa、xx和 bb是输入张量或之前计算的结果并且我们想得到 ∂L∂a∂a∂L、∂L∂x∂x∂L和 ∂L∂b∂b∂L。输入运算axbdL/dadL/dxdL/dbdL/d(ax)^2dL/dyL (损失)dL/dL1L(a⋅xb)2L(a⋅xb)2 计算图的表示。实线表示正向传播构建图。虚线表示反向传播期间梯度的流动应用链式法则。当您在最终输出张量通常是标量损失 LL上调用.backward()时Autograd 会从该输出开始并反向遍历图。在每个步骤节点它根据后续节点的梯度和当前节点执行运算的局部导数来计算梯度有效地应用了链式法则。然后对每个需要梯度的张量如模型参数计算出的梯度会累积到它们的.grad属性中。这种机制使 PyTorch 能够自动计算由张量运算序列定义的任意复杂模型的梯度让您摆脱手动推导的繁琐且容易出错的任务。接下来的章节将演示如何实际使用 Autograd 的功能定义需要梯度的张量、隐式构建计算图、触发反向传播、访问梯度以及控制梯度计算。PyTorch 计算图PyTorch 运用计算图作为其通过 Autograd 自动计算梯度的底层机制。每次你执行涉及需要梯度的张量运算时你很快会了解如何指定这一点PyTorch 都会动态构建一个图表示计算序列。可以将此图视为一个有向无环图 (DAG)其中节点代表张量或对其执行的运算。边代表数据张量的流动以及运算和张量之间的函数依赖关系。考虑一个简单的计算importtorch// Tensors that require gradientsvalxtorch.tensor(2.0,requires_gradtrue)valwtorch.tensor(3.0,requires_gradtrue)valbtorch.tensor(1.0,requires_gradtrue)// Operationsvalyw*x// Intermediate result yvalzyb// Final result zprintln(fResult z: {z})输出Result z: 7.0当这些代码行执行时PyTorch 会在后台构建一个图。它看起来像这样x (数据2.0, requires_gradTrue)w (数据3.0, requires_gradTrue)b (数据1.0, requires_gradTrue)y (wx 的结果)grad_fnz (yb 的结果)grad_fn这是 z(w∗x)bz(w∗x)b的计算图表示。蓝色框代表输入张量黄色椭圆代表运算绿色框代表输出/中间张量。边表示数据流和运算间的依赖关系。由运算产生的张量上的grad_fn属性指向创建它们的函数。动态特性PyTorch 计算图的一个重要特点是它们的动态特性。与那些要求你在运行计算前定义整个图结构的框架不同PyTorch 会在你的 Python 代码执行时动态构建图。灵活性这允许标准的 Python 控制流语句如if条件或for循环直接影响每次迭代中的图结构。如果你的模型架构需要在正向传播期间根据输入数据进行更改PyTorch 会很自然地处理这种情况。调试动态图通常更容易使用标准 Python 工具进行调试因为图的构建与你熟悉的程序执行同时进行。正向传播和反向传播正向传播当你执行张量运算如y w * x时你正在进行正向传播。PyTorch 会记录所涉及的运算和张量从而构建图。由 Autograd 追踪的运算产生的张量将具有grad_fn属性如示例中的y和z。此属性引用了创建该张量的函数并保存了对其输入的引用从而形成了图中的反向链接。用户创建的张量如x、w、b通常具有grad_fnNone。反向传播当你稍后在标量张量通常是最终的损失值上调用.backward()时Autograd 会从该节点向后遍历此图。它使用微积分的链式法则在每一步都由grad_fn指引以计算标量输出相对于最初标记为requires_gradTrue的张量通常是模型参数或输入的梯度。叶张量和梯度在 Autograd 中叶张量这些是位于图“开始”处的张量。通常它们是用户直接创建的张量例如使用torch.tensor()、torch.randn()且requires_gradTrue。模型参数nn.Parameter我们稍后会看到也是叶张量。非叶张量中间张量这些是在图内运算后产生的张量如上文的y和z。它们关联着grad_fn。默认情况下在.backward()调用期间计算的梯度仅保留并累积在具有requires_gradTrue的叶张量的.grad属性中。中间张量的梯度会进行计算但使用后通常会被丢弃以节省内存除非另有明确请求例如使用.retain_grad()。理解这种图结构对于掌握 Autograd 的工作方式非常重要。它将你在模型中定义的正向计算与优化所需的梯度计算直接关联起来。接下来我们将研究如何使用requires_grad来明确控制梯度跟踪。张量与梯度计算 (requires_grad)神经网络训练的根基在于计算损失函数相对于模型参数的梯度。PyTorch 的 Autograd 引擎自动处理这项复杂的任务。但是 Autograd 怎么知道哪些计算需要被追踪以便进行微分呢答案在于 PyTorch 张量的一个特定属性requires_grad。requires_grad属性每个 PyTorch 张量都有一个布尔属性名为requires_grad。此属性充当一个标志告诉 Autograd 是否应记录涉及此张量的操作以便稍后进行可能的梯度计算。默认情况下当你创建一个张量时它的requires_grad属性被设置为False。importtorch.*// 默认行为requires_grad 为 Falsevalxtorch.tensor([1.0,2.0,3.0])println(fTensor x: {x})println(fx.requires_grad: {x.requires_grad})// 显式创建另一个张量并将 requires_grad 设置为 Falsevalytorch.tensor([4.0,5.0,6.0],requires_gradfalse)println(f\nTensor y: {y})println(fy.requires_grad: {y.requires_grad})这种默认行为对效率来说是合理的。在典型的工作流程中许多张量不需要梯度。例如输入数据或目标标签通常是固定的不需要计算相对于它们自身的梯度。不必要地追踪操作会消耗额外的内存和计算资源。启用梯度追踪要指示 PyTorch 追踪某个特定张量的操作并准备进行梯度计算你需要将其requires_grad属性设置为True。有两种主要方式可以做到这一点在张量创建时将requires_gradTrue作为参数传递给张量创建函数。// 在创建时启用梯度追踪valwtorch.tensor([0.5,-1.0],requires_gradtrue)println(fTensor w: {w})println(fw.requires_grad: {w.requires_grad})在张量创建后原地修改对现有张量使用原地方法.requires_grad_(True)。// 在创建后启用梯度追踪valbtorch.tensor([0.1])println(fTensor b (before): {b})println(fb.requires_grad (before): {b.requires_grad})// 在创建后启用梯度追踪b.requires_grad_(true)println(f\nTensor b (after): {b})println(fb.requires_grad (after): {b.requires_grad})重要提示梯度计算通常只对浮点张量如torch.float32或torch.float64有意义。导数涉及连续变化这与浮点类型相符。尝试对整数张量设置requires_gradTrue通常会导致错误或出现意料之外的行为因为梯度并非以相同方式为离散值定义的。如果你尝试计算直接涉及被追踪操作的整数张量的梯度PyTorch 通常会抛出RuntimeError。// 尝试对整数张量设置 requires_gradtry:valint_tensortorch.tensor([1,2],dtypetorch.int64,requires_gradtrue)// 这一行可能不会立即出错但后续涉及它的 backward() 调用会出错。println(fInteger tensor created with requires_gradTrue: {int_tensor.requires_grad})// 让我们尝试一个简单的操作这可能会在以后导致问题valresultint_tensor*2.0// 乘以浮点数看看是否会引起问题println(fResult requires_grad: {result.requires_grad})// 如果我们尝试反向传播这很可能会失败// result.backward()catch(e:RuntimeError)println(f\n对整数张量设置 requires_grad 时出错: {e})# 最佳实践对需要梯度的参数/计算使用浮点张量 float_tensortorch.tensor([1.0,2.0],requires_gradTrue)print(f\n已创建 requires_gradTrue 的浮点张量: {float_tensor.requires_grad})requires_grad的传播重要的一点是requires_grad状态会在操作中传播。如果参与操作的任何输入张量具有requires_gradTrue则该操作产生的输出张量将自动具有requires_gradTrue。这确保了涉及参数通常具有requires_gradTrue的整个计算链都得到追踪。让我们通过一个例子说明// 定义张量x输入、w权重、b偏置valxtorch.tensor([1.0,2.0])// 输入数据不需要梯度valwtorch.tensor([0.5,-1.0],requires_gradtrue)// 权重参数追踪梯度valbtorch.tensor([0.1],requires_gradtrue)// 偏置参数追踪梯度println(fx requires_grad: {x.requires_grad})println(fw requires_grad: {w.requires_grad})println(fb requires_grad: {b.requires_grad})// 执行操作y w * x b// 注意PyTorch 处理 b 的广播valintermediatew*x println(f\nintermediate (w * x) requires_grad: {intermediate.requires_grad})valyintermediateb println(fy requires_grad: {y.requires_grad})注意即使x不需要梯度但由于w需要梯度所以w * x的结果 (intermediate) 也需要梯度。接着由于intermediate需要梯度并且b也需要最终输出y也具有requires_gradTrue。.grad_fn属性这种传播与 PyTorch 构建计算图的方式紧密关联。当一个新张量由某个操作创建并且其requires_grad为True时PyTorch 会将一个.grad_fn属性附加到这个新张量上。该属性引用了执行此操作的函数例如AddBackward0或MulBackward0并且知道如何在反向传播过程中计算相应的梯度。用户直接创建的张量如我们上面的x、w和b示例在图中被认为是“叶”张量。如果它们具有requires_gradTrue它们的.grad_fn为None因为它们不是由图中被追踪的操作创建的。对需要梯度的张量进行操作所产生的张量是“非叶”张量并将具有.grad_fn。让我们查看前面示例中的.grad_fnprintln(f\nx.grad_fn: {x.grad_fn})println(fw.grad_fn: {w.grad_fn})println(fb.grad_fn: {b.grad_fn})println(fintermediate.grad_fn: {intermediate.grad_fn})// 乘法的结果println(fy.grad_fn: {y.grad_fn})// 加法的结果你可以看到x、w和b我们的叶张量的grad_fn为None。相比之下intermediate有一个MulBackward0函数而y有一个AddBackward0函数这表明了创建它们的那些操作。这条grad_fn引用链就是Autograd 使用的动态计算图。叶张量操作与结果xrequires_gradFalsegrad_fnNone*输入wrequires_gradTruegrad_fnNone输入brequires_gradTruegrad_fnNone输入intermediaterequires_gradTruegrad_fn输出yrequires_gradTruegrad_fn输出输入y w * x b的计算图简化视图。需要梯度的张量用蓝色突出显示。注意操作符*、如何创建新张量intermediate、y如果通过其输入启用了梯度追踪这些新张量将通过grad_fn引用其创建操作。通过对我们希望优化的张量通常是模型参数如权重w和偏置b设置requires_gradTrue我们让 Autograd 能够构建此图并将计算从最终输出通常是损失追溯到这些参数为使用.backward()进行梯度计算的步骤做好准备我们将在接下来介绍这一点。执行反向传播 (backward())在使用 PyTorch 时张量会被设置并且需要梯度的张量会被标记为requires_gradTrue。PyTorch 会在其动态计算图中忠实地跟踪这些张量上的操作。为了计算这些梯度backward()方法是必不可少的。backward()方法是驱动 PyTorch 自动微分的引擎。当在一个张量上调用它时通常是模型最终的标量损失值它会启动使用链式法则在整个计算图中计算梯度。它会计算被调用的张量相对于图中所有requires_gradTrue的“叶”张量的梯度这些通常是你的模型参数或你需要梯度的初始输入。启动梯度计算你几乎总是在一个标量张量上调用backward()这通常是你的损失函数计算的结果。例如如果loss包含代表模型批次误差的单个数值importtorch.*// 示例设置想象这些是模型的结果valxtorch.tensor(2.0,requires_gradtrue)valwtorch.tensor(3.0,requires_gradtrue)valbtorch.tensor(1.0,requires_gradtrue)// 执行一些操作构建图valyw*xb// y 3*2 1 7vallossy*y// loss 7*7 49 (a scalar)// 反向传播之前梯度为 Noneprintln(fGradient for x before backward: {x.grad})println(fGradient for w before backward: {w.grad})println(fGradient for b before backward: {b.grad})// 计算梯度loss.backward()// 反向传播之后梯度被填充println(fGradient for x after backward: {x.grad})// d(loss)/dx d(y^2)/dx 2*y*(dy/dx) 2*y*w 2*7*3 42println(fGradient for w after backward: {w.grad})// d(loss)/dw d(y^2)/dw 2*y*(dy/dw) 2*y*x 2*7*2 28println(fGradient for b after backward: {b.grad})// d(loss)/db d(y^2)/db 2*y*(dy/db) 2*y*1 2*7*1 14输出Gradient for x before backward: None Gradient for w before backward: None Gradient for b before backward: None Gradient for x after backward: 42.0 Gradient for w after backward: 28.0 Gradient for b after backward: 14.0如你所见调用loss.backward()计算了梯度 ∂loss∂x∂x∂loss、∂loss∂w∂w∂loss 和 ∂loss∂b∂b∂loss 并将它们存储在x、w和b张量各自的.grad属性中。为什么在标量上调用.backward()Autograd 被设计用于计算雅可比向量积 (JVP)。当你在一个标量张量 LL上调用backward()时它隐式地等同于以 1.01.0 的起始梯度调用backward()。这使得 PyTorch 可以使用链式法则从标量损失向后传播高效地计算所有参数 pp的梯度 ∂L∂p∂p∂L。如果你尝试在一个非标量张量即包含多个元素的张量上调用.backward()PyTorch 无法隐式知道如何根据最终未显式的标量损失来为该张量中每个元素的梯度加权。你将收到一个运行时错误要求提供gradient参数// 继续前面的例子但使用非标量 yvalx_vectortorch.tensor([2.0,4.0],requires_gradtrue)valwtorch.tensor(3.0,requires_gradtrue)valbtorch.tensor(1.0,requires_gradtrue)// y_non_scalar 现在是非标量张量包含两个元素[7.0, 13.0]valy_non_scalarw*x_vectorbtry:y_non_scalar.backward()# 这将导致错误catch(e:RuntimeError)println(fError calling backward() on non-scalar: {e})// 要使其工作需要提供一个与 y_non_scalar 形状匹配的梯度张量// 这代表了某个最终损失相对于 y_non_scalar 的梯度。// 为演示目的我们使用 torch.ones_like(y_non_scalar)valgrad_tensortorch.ones_like(y_non_scalar)y_non_scalar.backward(gradientgrad_tensor)println(fGradient for x_vector after y_non_scalar.backward(gradient...): {x_vector.grad})println(fGradient for w after y_non_scalar.backward(gradient...): {w.grad})输出Error calling backward() on non-scalar: grad can be implicitly created only for scalar outputs Gradient for x_vector after y_non_scalar.backward(gradient...): tensor([3., 3.]) Gradient for w after y_non_scalar.backward(gradient...): 6.0在大多数标准的训练循环中你将计算一个单一的标量损失值代表一个批次或样本的误差并直接在该标量上调用loss.backward()无需提供gradient参数。计算图和梯度流loss.backward()触发对创建loss的操作图进行反向遍历。输入 (requires_gradTrue)操作输出 (标量) x2.0w3.0b1.0d(loss)/dxd(loss)/dwy7.0d(loss)/dbwxbpow(2)d(loss)/dyloss49.0 subgraphcluster_intermediateloss.backward() 从这里开始一个简化的计算图显示了输入x、w、b、中间结果y和最终的标量loss。虚线红色箭头说明了在loss.backward()过程中计算x、w和b梯度所经过的路径。默认情况下PyTorch 在backward()被调用后会清除计算图的中间缓冲区以节省内存。这意味着如果你需要在图的相同部分多次调用backward()这种情况较少通常用于高级技术或调试你需要在第一次backward()调用时传入retain_graphTrue。然而对于标准训练你会构建一个图计算损失调用backward()更新权重然后为下一个批次重复这个过程这会构建一个新的图。理解backward()对于在 PyTorch 中训练模型非常重要。它是将模型输出和损失函数与需要调整的参数联系起来的机制。在接下来的章节中我们将了解优化器如何访问和使用这些计算出的梯度。