1.实现一个小型自动微分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
class Value:

def __init__(self, data, _children=(), _op='', label=''):
self.data = data
self.grad = 0.0
self._backward = lambda: None
self._prev = set(_children)
self._op = _op
self.label = label

def __repr__(self):
return f"Value(data={self.data})"

def __add__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data + other.data, (self, other), '+')

def _backward():
self.grad += 1.0 * out.grad
other.grad += 1.0 * out.grad
out._backward = _backward

return out

def __mul__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data * other.data, (self, other), '*')

def _backward():
self.grad += other.data * out.grad
other.grad += self.data * out.grad
out._backward = _backward

return out

def __pow__(self, other):
assert isinstance(other, (int, float)), "only supporting int/float powers for now"
out = Value(self.data**other, (self,), f'**{other}')

def _backward():
self.grad += other * (self.data ** (other - 1)) * out.grad
out._backward = _backward

return out

def __rmul__(self, other): # other * self
return self * other

def __truediv__(self, other): # self / other
return self * other**-1

def __neg__(self): # -self
return self * -1

def __sub__(self, other): # self - other
return self + (-other)

def __radd__(self, other): # other + self
return self + other

def tanh(self):
x = self.data
t = (math.exp(2*x) - 1)/(math.exp(2*x) + 1)
out = Value(t, (self, ), 'tanh')

def _backward():
self.grad += (1 - t**2) * out.grad
out._backward = _backward

return out

def exp(self):
x = self.data
out = Value(math.exp(x), (self, ), 'exp')

def _backward():
self.grad += out.data * out.grad # NOTE: in the video I incorrectly used = instead of +=. Fixed here.
out._backward = _backward

return out


def backward(self):

topo = []
visited = set()
def build_topo(v):
if v not in visited:
visited.add(v)
for child in v._prev:
build_topo(child)
topo.append(v)
build_topo(self)

self.grad = 1.0
for node in reversed(topo):
node._backward()
    • 这是一个很简单的python类,PyTorch底层也是这样实现的吗?
    • 是的,PyTorch也是通过类似的方式实现自动微分的,但它在底层是使用C++和CUDA实现的,为了提升程序的计算性能。PyTorch使用了一个叫做Autograd的系统,它通过构建计算图来跟踪操作,并在反向传播时计算梯度。
    • Value类的设计思路?
    • 神经网络中参数的优化依赖梯度下降,而梯度下降依赖于损失对每个参数的梯度。这一梯度的计算是通过微分的链式法则实现的,简单来讲,损失对当前变量的梯度,就等于损失对当前变量的父节点的梯度乘以父节点对当前变量的梯度。因此,我们在每个节点内部保存了该节点的所有孩子节点(self._prev),以及这些孩子节点进行的运算(self._op)以计算当前节点对孩子节点的梯度,还保存了当前节点自身的梯度(self.grad),二者相乘,便可以得到当前节点的孩子节点的梯度。但这里有一个问题:当前节点梯度的计算(可能)依赖于当前节点的兄弟节点以及当前节点的父节点,因此Value类的梯度计算方法_backward无法计算当前节点(self)的梯度,它只能计算当前节点的孩子节点的梯度。也就是说,Value类对象的_backward属性是一个函数(方法),该函数的作用是给当前节点的孩子节点的梯度赋值(实际上是累加+=)。因此,链上的第一个节点(即损失L)的梯度是没有人给它赋值的,我们直接在backward方法中将其显式赋值为1.0。
    • 为什么要进行拓扑排序?为什么不递归(如DFS,BFS)计算链上节点的梯度?
    • 从数学上讲,由于梯度的累加设计,使用不带visited标记的DFS也可以实现梯度的计算。例如:
    1
    2
    3
    4
    def _backward(self):
    for child in self._prev:
    child.grad += self.grad * child._op_grad(self)
    child._backward()
    • 但是,考虑两条路线,均从O出发,最终到达L:(1)O->X->Y->A->B->C->D->E->F->L;(2)O->X->Y->A->D->L。如果使用上述的DFS方法,会导致重复计算节点A及其之前的节点梯度,这一冗余计算是指数级的,因此我们需要使用其他方法来处理“同一节点出现在多条路径”的问题。这里我们使用拓扑排序,保证每个节点的所有父节点梯度一定先于该节点的梯度被计算,这样就在最大程度上减少了冗余计算。值得一提的是,这里的拓扑排序算法是基于DFS实现的,而不是一般的Kahn算法(维护入度=0的队列),用DFS算法可行的原因是,DFS的本质是沿着一条路径一直走到尽头,因此使用“后序遍历”(topo.append(v)在递归后面)可以实现拓扑排序的效果,且代码更简洁。
    • 为什么_backward方法中是累加(+=)而不是直接赋值(=)?
    • 这么做的目的很显然,从上面的例子也可以看出来。如果一个节点参与了多条路径的计算(有环),那么它的梯度应该是来自多条路径的梯度之和,如果直接赋值,那么就会覆盖之前计算的梯度,导致最终的梯度不正确。这也是为什么PyTorch中的梯度是累加的,而不是直接赋值的,因此在每次反向传播之前,通常需要将梯度清零(例如optimizer.zero_grad())。