Contents

Lec5-Automatic Differentiation Implementation

Auto Differentiation Implementation

Basic Knowledge

OOP in Python class

call method

在Python中,__call__方法是一个特殊的方法,它允许一个类的实例表现得像一个函数。当你定义了一个类,并在该类中实现了__call__方法,你就可以通过直接调用实例来执行这个方法,就像调用一个函数一样。

这里是一个简单的例子来说明__call__方法的用法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class Greeter:
    def __init__(self, name):
        self.name = name

    def __call__(self):
        return f"Hello, {self.name}!"

# 创建Greeter类的实例
greeter = Greeter("Kimi")

# 调用实例,就像它是一个函数
print(greeter())  # 输出: Hello, Kimi!

在这个例子中,Greeter类有一个__init__方法来初始化实例,还有一个__call__方法来定义当实例被调用时应该执行的操作。当我们创建了一个Greeter的实例并调用它时,实际上是调用了__call__方法,它返回了一个问候语。

__call__方法通常用于创建可调用的对象,这在某些设计模式中非常有用,比如工厂模式、单例模式等。此外,它也常用于装饰器中,允许装饰器返回的对象能够被调用。

new method

在Python中,__new__方法是一个特殊的静态方法,用于创建一个类的新实例。它是在__init__方法之前被调用的,并且是创建对象实例的第一个步骤。__new__方法主要负责创建一个对象,而__init__方法则用于初始化这个对象。

__new__方法通常用于以下情况:

  1. 继承不可变类型:比如元组、字符串等,它们是不可变的,不能使用__init__进行初始化,因为它们在创建时就已经完成了初始化。在这种情况下,可以通过重写__new__方法来创建新的实例。

  2. 控制实例的创建:在某些情况下,你可能想要控制对象的创建过程,比如单例模式,或者在创建对象时进行一些特殊的处理。

  3. 继承自内置类型:当你想要继承自Python的内置类型时,你需要重写__new__方法来创建实例,因为内置类型通常不提供__init__方法。

__new__方法的基本语法如下:

1
2
3
4
5
6
class MyClass(metaclass=type):
    def __new__(cls, *args, **kwargs):
        # 创建实例的代码
        instance = super(MyClass, cls).__new__(cls)
        # 可以在这里进行一些初始化操作
        return instance

在这个例子中,__new__方法首先调用super()来创建类的实例,然后可以进行一些额外的操作,最后返回这个实例。注意,__new__方法必须返回一个实例对象。

这里是一个简单的例子,展示了如何使用__new__方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class Singleton:
    _instance = None

    def __new__(cls, *args, **kwargs):
        if not cls._instance:
            cls._instance = super(Singleton, cls).__new__(cls)
        return cls._instance

# 测试单例模式
s1 = Singleton()
s2 = Singleton()
print(s1 is s2)  # 输出 True,说明s1和s2是同一个实例

在这个例子中,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 操作对其进行求和,那么:

  1. Summation 操作
    定义一个张量 a,其形状为 (a_1, a_2, ..., a_n)。当我们对 a 的某些轴(axes)执行求和操作时,输出张量的形状将变小,丢失掉那些被求和的维度。例如:

    • 如果对所有维度求和,输出将是一个标量。
    • 如果只对某些维度求和,输出的张量形状会保持不变,但会丢失那些被求和的维度。
  2. 梯度的推导
    我们的目标是推导 f(a) = sum(a) 对输入 a 的梯度。换句话说,给定 sum(a) 对输入 a 的输出 out 和输出的梯度 out_grad,我们要计算 fa 的梯度。这个梯度表示的实际上是 反向传播中如何将梯度从 out_grad 传播回输入 a

  3. 不考虑轴的情况
    对于没有指定轴的简单总和操作 sum(a),即对所有元素求和的情况:

    [ f(a) = \sum_{i} a_i ]

    求和操作的梯度对于每个元素是均匀的。如果我们对 out = f(a) 的标量有一个梯度 out_grad,则对每个输入元素 a_i 的梯度是相同的,也就是 out_grad。因此,对 a 的梯度是一个与 a 形状相同的张量,每个位置的值都是 out_grad

  4. 考虑特定轴的情况
    如果我们只对 a 的某些轴 axes 进行求和,输出张量的形状会变小,丢失掉被求和的维度。要把 out_grad 传播回到原始输入张量 a,我们需要通过 广播(broadcasting) 来扩展 out_grad 的形状,使其与 a 的形状相同。这是通过以下步骤实现的:

    • 首先,确定哪些轴被求和(即 axes)。
    • 接着,我们将 out_grad 形状扩展为与原始输入 a 的形状匹配。通过 reshapebroadcast 操作,可以将 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 扩展到与输入相同的形状,这通过 reshapebroadcast 实现,使得求和操作的反向传播能够正确传播梯度。

reshape

Reshape 操作的梯度推导其实相对直观。重塑(reshape)操作不会改变数据本身,只是改变数据在内存中的排列方式。因此,在反向传播时,reshape 操作的梯度可以直接按照反向的形状变化来进行重新排列。

  1. Reshape 操作的基本概念Reshape 操作的目的是将张量 a 的形状从原始的形状 input_shape 转换为目标形状 target_shape,但保持元素的顺序不变。数据的内存布局保持不变,只是更改了它的形状。

  2. 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

  1. 实现梯度的逻辑: 因此,Reshape 操作的梯度实现非常简单,只需要将 out_grad 重新变换回原始输入张量的形状即可。这可以通过以下代码实现:
1
2
3
4
5
6
def gradient(self, out_grad, node):
    # 获取输入张量的形状
    input_shape = node.inputs[0].shape
    
    # 将输出梯度 reshaped 回原始输入的形状
    return reshape(out_grad, input_shape)
  1. 直观理解
  • 在前向过程中,reshape 只是重新排列数据的形状,而不改变数据的值。
  • 在反向传播时,我们只需要将 out_grad 的形状重新排列为输入张量的形状,以便梯度能够正确传播到输入。

因为 reshape 操作本身不会影响数据值,梯度传播过程也是简单的形状变换,因此 Reshape 操作的梯度推导非常直接。

broadcast

你提供的代码是 BroadcastTo 操作的梯度实现,它正确地处理了广播操作的反向传播问题。让我们逐步分析它的逻辑:

  1. 输入与前向传播BroadcastTo 操作的目的是将输入张量 a 扩展为一个新的形状 self.shape。前向传播中,通过调用 array_api.broadcast_to(a, self.shape) 将输入 a 广播到 self.shape

  2. 反向传播的目标: 在反向传播时,我们需要将梯度 out_grad 传播回输入张量 a,即形状为 node.inputs[0].shape 的张量。在广播过程中,某些维度可能是通过扩展为更大值实现的,这些扩展的维度需要在反向传播中进行处理(通过求和恢复到原始维度的大小)。

  3. 主要步骤

Step 1: 确定输入形状和目标形状的差异

1
2
shape = list(node.inputs[0].shape)  # 获取输入张量的形状
shape = [1] * (len(self.shape) - len(shape)) + shape  # 对输入形状进行扩展,保持与目标形状的长度一致
  • node.inputs[0].shape 是原始输入张量的形状。
  • self.shape 是广播后的形状。
  • 如果 self.shape 的长度大于 node.inputs[0].shape 的长度,那么需要在前面补上 1 来匹配维度的数量。这是因为广播允许在高维度的前面插入 1 以适应目标形状。

Step 2: 找到需要求和的轴 你提供的代码是 BroadcastTo 操作的梯度实现,它正确地处理了广播操作的反向传播问题。让我们逐步分析它的逻辑:

  1. 输入与前向传播BroadcastTo 操作的目的是将输入张量 a 扩展为一个新的形状 self.shape。前向传播中,通过调用 array_api.broadcast_to(a, self.shape) 将输入 a 广播到 self.shape

  2. 反向传播的目标: 在反向传播时,我们需要将梯度 out_grad 传播回输入张量 a,即形状为 node.inputs[0].shape 的张量。在广播过程中,某些维度可能是通过扩展为更大值实现的,这些扩展的维度需要在反向传播中进行处理(通过求和恢复到原始维度的大小)。

  3. 主要步骤

Step 1: 确定输入形状和目标形状的差异

1
2
shape = list(node.inputs[0].shape)  # 获取输入张量的形状
shape = [1] * (len(self.shape) - len(shape)) + shape  # 对输入形状进行扩展,保持与目标形状的长度一致
  • node.inputs[0].shape 是原始输入张量的形状。
  • self.shape 是广播后的形状。
  • 如果 self.shape 的长度大于 node.inputs[0].shape 的长度,那么需要在前面补上 1 来匹配维度的数量。这是因为广播允许在高维度的前面插入 1 以适应目标形状。

Step 2: 找到需要求和的轴

1
2
3
4
axes = []
for i, s in enumerate(self.shape):
    if i >= len(shape) or s != shape[i]:
        axes.append(i)
  • self.shape 是广播后的形状,shape 是经过扩展的原始输入形状。
  • 遍历目标形状 self.shape,如果目标形状和扩展后的输入形状在某个维度不相同(即广播发生),则将该维度的索引添加到 axes 列表中。
  • 这些轴是需要在反向传播时进行求和的轴,因为这些轴在前向传播时通过广播扩展了。

Step 3: 求和并调整形状

1
return reshape(summation(out_grad, tuple(axes)), node.inputs[0].shape)
  • 求和:在指定的轴 axes 上对 out_grad 进行求和,以消除广播的效果,恢复到广播前的形状。
  • 调整形状:求和后,使用 reshape 将求和后的张量调整为输入张量的原始形状 node.inputs[0].shape,确保梯度的形状与输入张量匹配。
  1. 代码分析总结
  • 广播维度匹配:通过在输入形状前面补 1 来处理输入张量和目标张量维度数量不匹配的情况。
  • 识别需要求和的轴:通过比较目标形状和扩展后的输入形状,找出哪些维度是广播导致扩展的,并在这些维度上进行求和。
  • 重塑梯度:最终将经过求和的梯度重塑回输入张量的形状,以便正确地反向传播梯度。
  1. 直观理解
  • 在前向传播中,广播是将较小形状的张量扩展为更大形状。
  • 在反向传播中,我们要逆转这种扩展,即将扩展的维度的梯度“合并”,这就是通过在这些维度上求和实现的。

这段代码很好地实现了 BroadcastTo 操作的梯度计算,既考虑了输入和输出形状的差异,也正确处理了广播扩展的维度的反向求和。

list, tuple, and dict

在Python中,list(列表)、tuple(元组)和dict(字典)是三种常用的数据结构,它们各自有独特的特性和用途。下面是它们的区别与联系:

List(列表)

  • 类型:可变序列。
  • 元素:可以包含任何类型的元素,包括另一个列表。
  • 索引:通过索引访问元素,索引从0开始。
  • 操作:可以进行增加、删除、修改等操作。
  • 用途:当你需要一个可以改变大小的序列时使用。

Tuple(元组)

  • 类型:不可变序列。
  • 元素:可以包含任何类型的元素,包括另一个元组。
  • 索引:通过索引访问元素,索引从0开始。
  • 操作:一旦创建,不能修改(不能增加、删除或修改元素)。
  • 用途:当你需要一个不需要改变的序列时使用,通常用于保护数据不被改变。

Dict(字典)

  • 类型:可变容器。
  • 元素:存储键值对(key-value pairs),键必须是不可变类型,值可以是任何类型。
  • 索引:通过键访问元素,而不是索引。
  • 操作:可以添加、删除或修改键值对。
  • 用途:当你需要存储关联数据时使用,例如,存储对象的属性。

联系

  • 序列listtuple 都是序列类型,可以进行迭代,并且支持许多相似的操作,如索引、切片等。
  • 可迭代listtupledict 都是可迭代的,这意味着它们可以用于循环和其他期望可迭代对象的场合。
  • 内置方法:它们都有许多内置方法来支持常见的操作,如添加、删除、查找等。

区别

  • 可变性list 是可变的,而 tuple 是不可变的。dict 也是可变的。
  • 元素类型dict 存储的是键值对,而 listtuple 存储的是元素序列。
  • 性能:对于需要频繁修改元素的场景,list 更合适;对于不需要修改的场景,tuple 更合适,因为它的不可变性可以提高性能。
  • 存储效率:由于 tuple 的不可变性,它通常比 list 在存储上更高效。
  • 访问方式dict 通过键访问元素,而 listtuple 通过索引访问。

示例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# List
my_list = [1, 2, 3]
my_list.append(4)  # 可变

# Tuple
my_tuple = (1, 2, 3)
# my_tuple[0] = 4  # 不可变,会报错

# Dict
my_dict = {'name': 'Kimi', 'age': 30}
my_dict['age'] = 31  # 可变