Lec5-Automatic Differentiation Implementation
Auto Differentiation Implementation
Basic Knowledge
OOP in Python class
call method
在Python中,__call__
方法是一个特殊的方法,它允许一个类的实例表现得像一个函数。当你定义了一个类,并在该类中实现了__call__
方法,你就可以通过直接调用实例来执行这个方法,就像调用一个函数一样。
这里是一个简单的例子来说明__call__
方法的用法:
|
|
在这个例子中,Greeter
类有一个__init__
方法来初始化实例,还有一个__call__
方法来定义当实例被调用时应该执行的操作。当我们创建了一个Greeter
的实例并调用它时,实际上是调用了__call__
方法,它返回了一个问候语。
__call__
方法通常用于创建可调用的对象,这在某些设计模式中非常有用,比如工厂模式、单例模式等。此外,它也常用于装饰器中,允许装饰器返回的对象能够被调用。
new method
在Python中,__new__
方法是一个特殊的静态方法,用于创建一个类的新实例。它是在__init__
方法之前被调用的,并且是创建对象实例的第一个步骤。__new__
方法主要负责创建一个对象,而__init__
方法则用于初始化这个对象。
__new__
方法通常用于以下情况:
-
继承不可变类型:比如元组、字符串等,它们是不可变的,不能使用
__init__
进行初始化,因为它们在创建时就已经完成了初始化。在这种情况下,可以通过重写__new__
方法来创建新的实例。 -
控制实例的创建:在某些情况下,你可能想要控制对象的创建过程,比如单例模式,或者在创建对象时进行一些特殊的处理。
-
继承自内置类型:当你想要继承自Python的内置类型时,你需要重写
__new__
方法来创建实例,因为内置类型通常不提供__init__
方法。
__new__
方法的基本语法如下:
|
|
在这个例子中,__new__
方法首先调用super()
来创建类的实例,然后可以进行一些额外的操作,最后返回这个实例。注意,__new__
方法必须返回一个实例对象。
这里是一个简单的例子,展示了如何使用__new__
方法:
|
|
在这个例子中,Singleton
类通过重写__new__
方法实现了单例模式,确保了全局只有一个实例。
Data Structure in NEEDLE
Lazy or Eager Evaluation
自动微分的lazy(惰性)模式和eager(急切)模式是深度学习框架中处理计算图的两种不同方式。它们各有优劣,适用于不同的场景。
Lazy模式:
- 优点:
- 灵活性高,可以动态地构建计算图,支持条件分支和循环等控制流结构。
- 调试友好,因为操作是按需执行的,所以可以使用传统的Python调试工具。
- 适合于研究和开发阶段,因为可以即时看到每个操作的效果。
- 缺点:
- 性能可能不如eager模式,因为它不支持一些优化,如操作融合。
- 内存消耗可能更高,因为不会进行一些优化来减少内存使用。
Eager模式:
- 优点:
- 性能通常更好,因为它允许在执行前对计算图进行优化,如操作融合和常量折叠。
- 可以减少运行时的内存消耗,因为优化后的计算图可能更高效。
- 适合于生产环境,因为它提供了更快的执行速度。
- 缺点:
- 灵活性较低,不支持动态图结构,因为计算图是在运行前构建的。
- 调试可能不如lazy模式方便,因为需要考虑计算图的结构。
在实际应用中,选择哪种模式取决于具体的需求。例如,PyTorch默认使用eager模式,因为它的动态性和易用性适合于研究和开发。而TensorFlow在早期版本中使用静态图,但后来引入了eager execution来提供更灵活的编程体验。在生产环境中,通常会使用图模式来优化性能。
根据搜索结果,PyTorch的eager模式允许即时执行操作,使得调试更加直观,并且可以使用Pythonic的控制流结构,而不是预先定义的静态图 。而TensorFlow的eager模式则提供了与PyTorch类似的动态图计算模式,使得操作可以立即执行,而不是先构建计算图 。这些特性使得eager模式在某些情况下更加方便,尤其是在需要快速迭代和调试的研究环境中。然而,对于需要高性能的生产环境,图模式通常更受青睐,因为它可以通过各种优化手段来提高执行效率 。
Details in math
summation
要从数学的角度推导出 Summation
的梯度,首先我们需要理解 summation
操作的基本原理以及它对输入张量的影响。假设我们有一个张量 a
,并且我们通过 sum
操作对其进行求和,那么:
-
Summation 操作:
定义一个张量a
,其形状为(a_1, a_2, ..., a_n)
。当我们对a
的某些轴(axes
)执行求和操作时,输出张量的形状将变小,丢失掉那些被求和的维度。例如:- 如果对所有维度求和,输出将是一个标量。
- 如果只对某些维度求和,输出的张量形状会保持不变,但会丢失那些被求和的维度。
-
梯度的推导:
我们的目标是推导f(a) = sum(a)
对输入a
的梯度。换句话说,给定sum(a)
对输入a
的输出out
和输出的梯度out_grad
,我们要计算f
对a
的梯度。这个梯度表示的实际上是 反向传播中如何将梯度从out_grad
传播回输入a
。 -
不考虑轴的情况:
对于没有指定轴的简单总和操作sum(a)
,即对所有元素求和的情况:[ f(a) = \sum_{i} a_i ]
求和操作的梯度对于每个元素是均匀的。如果我们对
out = f(a)
的标量有一个梯度out_grad
,则对每个输入元素a_i
的梯度是相同的,也就是out_grad
。因此,对a
的梯度是一个与a
形状相同的张量,每个位置的值都是out_grad
。 -
考虑特定轴的情况:
如果我们只对a
的某些轴axes
进行求和,输出张量的形状会变小,丢失掉被求和的维度。要把out_grad
传播回到原始输入张量a
,我们需要通过 广播(broadcasting) 来扩展out_grad
的形状,使其与a
的形状相同。这是通过以下步骤实现的:- 首先,确定哪些轴被求和(即
axes
)。 - 接着,我们将
out_grad
形状扩展为与原始输入a
的形状匹配。通过reshape
和broadcast
操作,可以将out_grad
的形状调整为与a
的形状兼容。 - 这意味着我们将
out_grad
的值复制到所有求和的轴上。
具体的梯度操作为:
[ \text{grad}_a = \text{broadcast_to}(\text{reshape}(out_grad, \text{expanded_shape}), \text{original_shape}) ]
其中,
expanded_shape
是将out_grad
的形状在求和的轴上扩展为1
,然后通过广播将其匹配原始输入张量的形状。 - 首先,确定哪些轴被求和(即
梯度的推导总结:
- 当我们对所有维度求和时,梯度会均匀地分布到每个输入元素上,每个位置的梯度都是
out_grad
。 - 当对某些维度求和时,我们需要将
out_grad
扩展到与输入相同的形状,这通过reshape
和broadcast
实现,使得求和操作的反向传播能够正确传播梯度。
reshape
Reshape
操作的梯度推导其实相对直观。重塑(reshape
)操作不会改变数据本身,只是改变数据在内存中的排列方式。因此,在反向传播时,reshape
操作的梯度可以直接按照反向的形状变化来进行重新排列。
-
Reshape 操作的基本概念:
Reshape
操作的目的是将张量a
的形状从原始的形状input_shape
转换为目标形状target_shape
,但保持元素的顺序不变。数据的内存布局保持不变,只是更改了它的形状。 -
Reshape 的梯度计算: 因为
reshape
操作只是改变了张量的形状而不改变其数据内容,在反向传播过程中,梯度的计算非常简单。对于一个通过reshape
产生的输出张量的梯度out_grad
,其梯度传播到输入张量时,仅需将out_grad
重塑回输入张量的形状。
具体来说,假设:
- 输入张量的形状为
input_shape
,即a.shape = input_shape
; - 通过
reshape
操作,张量变成了target_shape
; - 我们在反向传播中得到了
out_grad
,它的形状为target_shape
。
我们要计算输入张量 a
的梯度。由于 reshape
并没有改变数据内容,只需将 out_grad
的形状重塑为 input_shape
。
- 实现梯度的逻辑:
因此,
Reshape
操作的梯度实现非常简单,只需要将out_grad
重新变换回原始输入张量的形状即可。这可以通过以下代码实现:
|
|
- 直观理解:
- 在前向过程中,
reshape
只是重新排列数据的形状,而不改变数据的值。 - 在反向传播时,我们只需要将
out_grad
的形状重新排列为输入张量的形状,以便梯度能够正确传播到输入。
因为 reshape
操作本身不会影响数据值,梯度传播过程也是简单的形状变换,因此 Reshape
操作的梯度推导非常直接。
broadcast
你提供的代码是 BroadcastTo
操作的梯度实现,它正确地处理了广播操作的反向传播问题。让我们逐步分析它的逻辑:
-
输入与前向传播:
BroadcastTo
操作的目的是将输入张量a
扩展为一个新的形状self.shape
。前向传播中,通过调用array_api.broadcast_to(a, self.shape)
将输入a
广播到self.shape
。 -
反向传播的目标: 在反向传播时,我们需要将梯度
out_grad
传播回输入张量a
,即形状为node.inputs[0].shape
的张量。在广播过程中,某些维度可能是通过扩展为更大值实现的,这些扩展的维度需要在反向传播中进行处理(通过求和恢复到原始维度的大小)。 -
主要步骤:
Step 1: 确定输入形状和目标形状的差异
|
|
node.inputs[0].shape
是原始输入张量的形状。self.shape
是广播后的形状。- 如果
self.shape
的长度大于node.inputs[0].shape
的长度,那么需要在前面补上1
来匹配维度的数量。这是因为广播允许在高维度的前面插入1
以适应目标形状。
Step 2: 找到需要求和的轴
你提供的代码是 BroadcastTo
操作的梯度实现,它正确地处理了广播操作的反向传播问题。让我们逐步分析它的逻辑:
-
输入与前向传播:
BroadcastTo
操作的目的是将输入张量a
扩展为一个新的形状self.shape
。前向传播中,通过调用array_api.broadcast_to(a, self.shape)
将输入a
广播到self.shape
。 -
反向传播的目标: 在反向传播时,我们需要将梯度
out_grad
传播回输入张量a
,即形状为node.inputs[0].shape
的张量。在广播过程中,某些维度可能是通过扩展为更大值实现的,这些扩展的维度需要在反向传播中进行处理(通过求和恢复到原始维度的大小)。 -
主要步骤:
Step 1: 确定输入形状和目标形状的差异
|
|
node.inputs[0].shape
是原始输入张量的形状。self.shape
是广播后的形状。- 如果
self.shape
的长度大于node.inputs[0].shape
的长度,那么需要在前面补上1
来匹配维度的数量。这是因为广播允许在高维度的前面插入1
以适应目标形状。
Step 2: 找到需要求和的轴
|
|
self.shape
是广播后的形状,shape
是经过扩展的原始输入形状。- 遍历目标形状
self.shape
,如果目标形状和扩展后的输入形状在某个维度不相同(即广播发生),则将该维度的索引添加到axes
列表中。 - 这些轴是需要在反向传播时进行求和的轴,因为这些轴在前向传播时通过广播扩展了。
Step 3: 求和并调整形状
|
|
- 求和:在指定的轴
axes
上对out_grad
进行求和,以消除广播的效果,恢复到广播前的形状。 - 调整形状:求和后,使用
reshape
将求和后的张量调整为输入张量的原始形状node.inputs[0].shape
,确保梯度的形状与输入张量匹配。
- 代码分析总结:
- 广播维度匹配:通过在输入形状前面补
1
来处理输入张量和目标张量维度数量不匹配的情况。 - 识别需要求和的轴:通过比较目标形状和扩展后的输入形状,找出哪些维度是广播导致扩展的,并在这些维度上进行求和。
- 重塑梯度:最终将经过求和的梯度重塑回输入张量的形状,以便正确地反向传播梯度。
- 直观理解:
- 在前向传播中,广播是将较小形状的张量扩展为更大形状。
- 在反向传播中,我们要逆转这种扩展,即将扩展的维度的梯度“合并”,这就是通过在这些维度上求和实现的。
这段代码很好地实现了 BroadcastTo
操作的梯度计算,既考虑了输入和输出形状的差异,也正确处理了广播扩展的维度的反向求和。
list, tuple, and dict
在Python中,list
(列表)、tuple
(元组)和dict
(字典)是三种常用的数据结构,它们各自有独特的特性和用途。下面是它们的区别与联系:
List(列表)
- 类型:可变序列。
- 元素:可以包含任何类型的元素,包括另一个列表。
- 索引:通过索引访问元素,索引从0开始。
- 操作:可以进行增加、删除、修改等操作。
- 用途:当你需要一个可以改变大小的序列时使用。
Tuple(元组)
- 类型:不可变序列。
- 元素:可以包含任何类型的元素,包括另一个元组。
- 索引:通过索引访问元素,索引从0开始。
- 操作:一旦创建,不能修改(不能增加、删除或修改元素)。
- 用途:当你需要一个不需要改变的序列时使用,通常用于保护数据不被改变。
Dict(字典)
- 类型:可变容器。
- 元素:存储键值对(key-value pairs),键必须是不可变类型,值可以是任何类型。
- 索引:通过键访问元素,而不是索引。
- 操作:可以添加、删除或修改键值对。
- 用途:当你需要存储关联数据时使用,例如,存储对象的属性。
联系
- 序列:
list
和tuple
都是序列类型,可以进行迭代,并且支持许多相似的操作,如索引、切片等。 - 可迭代:
list
、tuple
和dict
都是可迭代的,这意味着它们可以用于循环和其他期望可迭代对象的场合。 - 内置方法:它们都有许多内置方法来支持常见的操作,如添加、删除、查找等。
区别
- 可变性:
list
是可变的,而tuple
是不可变的。dict
也是可变的。 - 元素类型:
dict
存储的是键值对,而list
和tuple
存储的是元素序列。 - 性能:对于需要频繁修改元素的场景,
list
更合适;对于不需要修改的场景,tuple
更合适,因为它的不可变性可以提高性能。 - 存储效率:由于
tuple
的不可变性,它通常比list
在存储上更高效。 - 访问方式:
dict
通过键访问元素,而list
和tuple
通过索引访问。
示例
|
|