09 深度神经网络框架的基础:自动微分

news/2025/3/3 19:43:12/文章来源:https://www.cnblogs.com/wangle1006/p/18749130

当神经网络的层数增加,结构变复杂后,如果只用纯python(再加Numpy)来实现,代码将变得异常复杂,且难以阅读和调试。此时,就需要引入一些著名的深度学习框架了,比如PyTorch, TensorFlow等。
运用这些框架,你往往只需要定义一个神经网络的架构,反向传播过程则是自动完成的,你无需手动的去实现这部分繁琐的链式求导过程。这个自动求导的机制,就叫做自动微分 (Automatic Differentiation)。自动微分是PyTorch等框架提供的重要功能。
自动微分是如何实现的?从程序设计的角度看,这是一个很有趣的问题,本篇文章我尝试用一些较为简洁的例子来研究这种自动化实现的方法。

一个简单的例子

我们先看一个极简的计算流程:

a = 2
b = a * 3    
c = a + 2
d = b + 4
e = c * d

用一个计算图(Computational Graph)去表达这样一个计算过程:

图中,当输入a = 2时,各个变量通过一系列的依次计算,最终得到e = 40,图中已标出各变量值(计算非常简单),这一过程,就相当于我们前文中讨论过的前向传播过程。
现在,我们希望计算最终结果e关于各个变量(a,b,c,d)的导数,由于都是简单的加法乘法运算,这一过程也比较简单,我们从后向前依次对各变量导数进行计算:

这一过程,就相当于我们前文中讨论过的反向传播过程,也就是运用链式法则,从后向前依次计算梯度的过程。
输入数据从前向后经过计算图网络得到最终结果,这个过程就像是水经过管道系统缓缓流动到出水口一样,这也就是框架TensorFlow中,flow一词的来源。

那么Tensor是什么概念呢?从上面的描述中,我们可以推测它应该是某种在计算网络中流动的“数据”的表示。我们再来看上述前向传播和反向传播过程。

计算流程的框架构建

当数据经过前向传播得到结果后,计算图的结构实际上已经确定了下来,此时,所有的求导所需信息(变量是多少,变量是怎么计算得到的)都应该已经确定了,不需要我们再提供其他额外信息。因此,反向求导过程自然可以用自动化的方式进行。而不用向上述一样手动计算。

为了构建这一“自动化”过程,我们需要在前向传播时,运用某种机制去记录下整个计算流程。观察上述计算图,这是一个典型的有向图结构。在这个图结构中的数据对象(比如b,c,d,e),如果它们不仅包含自己的值,还知道它们自己是通过哪些数据对象经过什么样的计算得到的,这样的话,有向图结构的信息就保存下来了。也就是整个计算流程就记录下来了。

为此,我们需要扩展各个数据对象的定义,它们不仅要记录本身的值,还要记录这个值是如何得到的。具体来说,它们的数据结构中应该包含如下信息:

  1. 数据值,这个是变量本身的值
  2. 导数(梯度),反向传播时各个变量的梯度数据,上文中我们计算了最终输出e关于所有变量(a、b、c、d)的导数
  3. 该数据是通过何种运算得到的,上文中,b是通过求积运算得到,c是通过求和运算得到
  4. 该数据是通过哪些其他数据得到的,上文中,b是通过a和3得到,e是通过c和d得到

明确了数据对象的扩展定义,我们在前向传播时,通过重写表达式中的运算符,就能将各个数据“链接”起来,形成计算图。这个扩展的数据结构(包含数据值,梯度值,以及它是如何运算得到的),就是深度学习框架中Tensor的概念。

是时候上代码了,我们把上述的信息用一个Tensor类来承载,其代码结构如下:

class Tensor(object):def __init__(self, data,creators=None, creation_op=None):self.data = dataself.grad = Noneself.creation_op = creation_op self.creators = creators def __add__(self, other):return Tensor(self.data+other.data,creators=[self,other],creation_op="add")def __mul__(self, other):return Tensor(self.data*other.data,creators=[self,other],creation_op="mul")def __str__(self):return str("data: "+self.data.__str__()+"\n"+"grad: "+self.grad.__str__()+"\n"+"creation_op: "+self.creation_op)

我们用这个类再去写计算流程:

a = Tensor(2)
b = a * Tensor(3)    
c = a + Tensor(2)
d = b + Tensor(4)
e = c * dprint(e)
print(e.creators[0])
print(e.creators[1])
print(e.creation_op)

运行结果:

40
4
10
mul

从运行结果可以看到,输出e的结果为40,与之前我们的计算相符,它是由两个数4和10通过运算“mul”得到的。

至此,我们通过构建前向传播计算过程,得到了运算结果,并且将计算过程通过计算图保存了下来。这里我们注意到所有的Tensor中的grad还都是None。下一步,我们就要借助这个计算图,对所有变量进行梯度计算。

自动求导的思路

我们从e这个数据看,e是输出,我们希望找到的是它关于其他变量的导数,e关于自己的导数是1,所以e的grad为1。这是梯度计算的起点。
在e这个Tensor中,我们可以知道e是通过d和c相乘得到,因此,e关于d和c的导数可以轻易得到,分别为c和d的值,也就是4和10,e将它们分别传递给d和c这两个Tensor。
在d这个Tensor中,它从Tensor e那里接收到了\(∂e/∂d\),将其赋值给自身的grad变量,并且它还知道自己是通过Tensor b,和一个Tensor 4相加得到,通过链式法则他把自己的grad,也就是\(∂e/∂d\),乘以\(∂d/∂b\),得到\(∂e/∂b\),并把它传递给Tensor b。同理,也将梯度传递给Tensor 4,但由于Tensor 4是个常量,我们无需关心他的梯度。
同样的,Tensor c从Tensor e那里接收到\(∂e/∂c\),赋值自身的grad后,将其乘以\(∂c/∂a\),得到\(∂e/∂a\),传递给Tensor a。

从上述描述中,我们可以抽象出一般规律,也就是反向传播过程中,每个Tensor都是接收到上一个Tensor传递过来的导数,对自身grad赋值,然后通过自己保存的creators信息(也就是该Tensor是通过哪些Tensors经过什么计算得到的),用链式法则为自己的creators计算梯度并传递给它们。我们用代码示意这一过程:

    def backward(self, grad=-1): if(grad==-1):self.grad = 1else:self.grad = grad  #注意!这里还需修改if(self.creation_op == "add"): self.creators[0].backward(self.grad) self.creators[1].backward(self.grad)  if(self.creation_op == "mul"): self.creators[0].backward(self.grad*self.creators[1].data) self.creators[1].backward(self.grad*self.creators[0].data)

这里我们用grad=-1这个默认入参来区别梯度计算的起点,也就是e的grad设置为1。

对于重复使用的变量

当我们继续往下进行梯度计算时,会发现一个问题,对于输入Tensor a来说,有两条路径都可以将梯度传递给a:一条是由Tensor c中,计算\(∂e/∂c\)乘以\(∂c/∂a\),得到\(∂e/∂a\);另一条是在Tensor b中,\(∂e/∂b\)乘以\(∂b/∂a\)得到\(∂e/∂a\)。如果使用上述代码,a的grad将被其中一个覆盖掉。

从我们之前手算的过程来看,\(∂e/∂a\)应当是两条路径上梯度之和,也就是\(∂e/∂a=∂e/∂c*∂c/∂a+∂e/∂b*∂b/∂a\) 这也是符合链式法则原理的。对于重复使用的Tensor,它的梯度应为所有反向路径上传递到它的梯度之和。

因此,我们的Tensor类应改为:

class Tensor(object):def __init__(self, data,creators=None, creation_op=None):self.data = dataself.grad = Noneself.creation_op = creation_op self.creators = creators def backward(self, grad=-1): if(grad==-1):self.grad = 1else:if (self.grad is None):self.grad = gradelse:self.grad+=gradif(self.creation_op == "add"): self.creators[0].backward(self.grad) self.creators[1].backward(self.grad)  if(self.creation_op == "mul"): self.creators[0].backward(self.grad*self.creators[1].data) self.creators[1].backward(self.grad*self.creators[0].data)def __add__(self, other):return Tensor(self.data+other.data,creators=[self,other],creation_op="add")def __mul__(self, other):return Tensor(self.data*other.data,creators=[self,other],creation_op="mul")def __str__(self):return str(self.data.__str__())

我们验证一下结果:

a = Tensor(2)
b = a * Tensor(3)    
c = a + Tensor(2)
d = b + Tensor(4)
e = c * de.backward()print(a.grad)

得到:

22

与我们的手算结果相符。

真实框架中的Tensor——多维数据的容器

在上述的讨论中,我们的Tensor中只包含一个标量,事实上,在深度学习框架中,Tensor(张量)是核心数据结构,它可以包含不同维度的数据。

  • 标量(0维张量)​:单个数值,如 5
  • 向量(1维张量)​:一维数组,如 [1, 2, 3]
  • 矩阵(2维张量)​:二维表格,如 [[1,2], [3,4]]
  • 高阶张量(3维及以上)​:例如图像数据可表示为 [batch_size, 高度, 宽度, 通道数]

它类似于Numpy的ndarray,我们现在用ndarray来代替原来的标量数据,看看代码有什么变化:

import numpy as npclass Tensor(object):def __init__(self, data,creators=None, creation_op=None):self.data = np.array(data)self.grad = Noneself.creation_op = creation_op self.creators = creators def backward(self, grad=None): if(grad is None):self.grad = Tensor(np.ones_like(self.data))else:if (self.grad is None):self.grad = gradelse:self.grad+=gradif(self.creation_op == "add"): self.creators[0].backward(self.grad) self.creators[1].backward(self.grad)  if(self.creation_op == "sub"):self.creators[0].backward(Tensor(self.grad.data), self)self.creators[1].backward(Tensor(self.grad.__neg__().data), self)if(self.creation_op == "mul"): self.creators[0].backward(self.grad*self.creators[1]) self.creators[1].backward(self.grad*self.creators[0])                  if(self.creation_op == "mm"):c0 = self.creators[0]c1 = self.creators[1]new = self.grad.mm(c1.transpose())c0.backward(new)new = self.grad.transpose().mm(c0).transpose()c1.backward(new)def __add__(self, other):return Tensor(self.data+other.data,creators=[self,other],creation_op="add")def __sub__(self, other):return Tensor(self.data - other.data,creators=[self,other],creation_op="sub")def __mul__(self, other):return Tensor(self.data*other.data,creators=[self,other],creation_op="mul")def mm(self, x):return Tensor(self.data.dot(x.data),creators=[self,x],creation_op="mm")def __str__(self):return str(self.data.__str__())

代码中有如下变化:

  1. 将数据data作为ndarray处理,将梯度grad作为Tensor处理
  2. 添加了减法和向量矩阵乘法支持,其中向量矩阵乘法在神经网络中运用频繁
  3. 对反向传播起点的梯度,采用和其维度一致的全1矩阵代替

我们验证其对矩阵乘法的求导可行性:

a = Tensor([2, 2, 3])
b = Tensor([2, 3, 4]) 
c = Tensor([[1,2,3],[2,3,4],[3,4,5]])d = a * c
print(d)d.backward(Tensor(np.array([1,1,1])))
print(a.grad.data)
print(c.grad.data)

结果:

[[ 2  4  9][ 4  6 12][ 6  8 15]]
[[1 2 3][2 3 4][3 4 5]]
[2 2 3]

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/893103.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

WebSocket调试工具深度对比:Postman与Apipost功能实测解析

WebSocket调试工具深度对比:Postman与Apipost功能实测解析 作为长期从事实时通讯系统开发的工程师,WebSocket协议在开发中是非常常见的。作为一种常见的 Web 协议,其与 Restful API 有着本质的不同。Restful API是基于请求-响应模式的单向通信,而 WebSocket 提供全双工通信…

leetcode hot 18

解题思路:这题思路就是用某个数据结构记录需要被置0的行和列,一般需要两次遍历。我采用集合的形式,最省空间的方法就是用数组的第一行和第一列来记录,但是要遍历一遍是是否第一行或第一列有0。 class Solution {public void setZeroes(int[][] matrix) {Set<Integer> …

中国版Workday从上千家大型跨国企业的实践总结:员工体验很重要

20世纪90年代中期,从Oracle、SAP等国外ERP巨头进入中国,并拿下华为、联想等一流企业,至今也依然保持着中国市场的优势地位。即便到了“国外企业应用软件在中国逐步式微”的今天,SAP约10000亿元的市值,也远超用友的约1000亿元市值。其中很大一个原因在于,中国企业的聪明—…

易路iBuilder—国内首个HR智能体管理平台,高效管理企业数字劳动力

易路iBuilder智能体平台不仅是工具集合,更是企业数字化转型的 “智能中枢”。它通过知识驱动、流程重构、人机共融,帮助企业将AI Agent从“执行者”升级为“战略伙伴”,最终实现 “人类定义价值,机器放大价值”的新工作范式。随着大模型技术的商业应用日益成熟,AI数字员工…

进入cmd如何切换到D盘

win+r打开cmd 输入D:要英文字符的冒号 就可以进行切换了,其他盘同理

2025并发编程面试 -基础面试

一、Java中为什么内存不可见?(高德) 因为Java中的线程是由CPU去调度的,而CPU中包含了L1~L3的高速缓存,当CPU调度某个线程时,会将JVM中数据拉取到CPU高速缓存中。因为CPU现在基本都是多核的,所以其他CPU内核如果也获取了相同的数据,并且有写操作的发生,就会导致多个CPU…

并发编程面试 -基础面试

一、Java中为什么内存不可见?(高德) 因为Java中的线程是由CPU去调度的,而CPU中包含了L1~L3的高速缓存,当CPU调度某个线程时,会将JVM中数据拉取到CPU高速缓存中。因为CPU现在基本都是多核的,所以其他CPU内核如果也获取了相同的数据,并且有写操作的发生,就会导致多个CPU…

如何在 NocoBase 中实现 CRM 的线索转化

旨在通过简洁的小需求示例与小技巧分享,帮助你快速熟悉并掌握 NocoBase 的核心功能与最佳实践,让你轻松上手、高效开发。1. 引言 本教程将一步一步地引导您如何在 NocoBase 中实现 CRM 的商机转化(Opportunity Conversion)功能。我们将介绍如何创建所需的 collections(数据…

leetcode hot 17

解题思路:不允许用除法,所以可以考虑前缀和的思路。最终结果的任意一个索引对应的数都是nums数组对应索引左边所有数的乘积*右边所有数的乘积,所以先遍历两遍用两个数组记录,然后最后再相乘写入对应的索引的值。(可以更省空间的话就只在结果数组上操作,左边照常,右边的话…

Appple 超级体验官招募!大声说出你的宝藏 App~

2025 年依旧是属于 AI 的时代,技术飞速迭代,创新层出不穷,而 Apple 生态继续为我们带来了无数令人惊叹的 App。它们或以卓越的用户体验脱颖而出,或凭借强大的功能助力我们的日常工作与生活,又或许凭借独特的创意与设计让人眼前一亮。为了让更多人发现这些隐藏在 Apple 生态…

leetcode hot 16

解题思路:最简单的方法就是浪费空间的方法。还有一种巧法,先将整个数组反转,然后将需要移动的后面的k(如果超过数组长度就取余)个位置的数反转,最后将剩下的数反转。 class Solution {public void rotate(int[] nums, int k) {k = k%nums.length;reverse(nums,0,nums.len…