题目
题目链接
实现基本自动微分操作(Basic Automatic Differentiation)是一种常用的自动微分方法,用于计算函数的导数。
所谓的自动计算微分,就是通过计算图,从输入到输出,反向传播,计算每个节点的导数。而这个计算图,通俗点来说就是高中所学到的链式法则需要画的函数关系图,也可以理解为深度学习中神经网络的结构图。
举个例子来说:假设有函数\(f(x) = ReLU(x^2 + 2x + 1)\),那么其计算图可以大致表示为以下几层:
- 第一层为输入值x
- 第二层为两个节点,分别为\(x^2\)和\(2x\)
- 第三层为一个节点,为\(x^2 + 2x + 1\)
- 第四层为一个节点,为\(ReLU(x^2 + 2x + 1)\),也即是\(f(x)\)
那么在前向传播中,从输入开始,根据计算图的结构,就可以算出来每一个节点的值;在反向传播中,从输出开始,根据计算图的结构,即每条边对应的函数的导数是可知的,就可以算出来每一个节点的导数。这里不再赘述。
在本题中,Value类的前驱节点通过children参数初始化为一个集合,则是因为在实际上,对于一个节点来说,并不需要知道前驱节点的顺序,只需要知道前驱节点是谁即可。而对于整个计算图来说,并不需要知道每一层的连接方式具体是什么,只需要知道每一层有哪些节点即可。
总的来说,整个流程可以归结为以下几个步骤:
- 构建计算图(神经网络),这在main函数中已经给出。
- 前向传播,计算每个节点的值
从输入开始,根据计算图的结构,计算每个节点的值。 - 反向传播,计算每个节点的导数
从输出开始,根据计算图的结构,计算每个节点的导数,这里用到的最关键的技术就是链式法则,此处不再赘述。由于选择集合作为前驱节点的存储结构,你需要通过拓扑排序来计算每个节点的导数。具体细节可参考图论的算法。
值得注意的是,在本题的代码中,你需要将梯度进行累加,而不是直接赋值。其原因在于,一个节点往往会被多个节点所使用,因此需要将所有使用该节点的节点的梯度进行累加。
标准代码如下
class Value:def __init__(self, data, _children=(), _op=""):self.data = dataself.grad = 0self._backward = lambda: Noneself._prev = set(_children)self._op = _opdef __add__(self, other):other = other if isinstance(other, Value) else Value(other)out = Value(self.data + other.data, (self, other), "+")def _backward():self.grad += out.gradother.grad += out.gradout._backward = _backwardreturn outdef __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.gradother.grad += self.data * out.gradout._backward = _backwardreturn outdef relu(self):out = Value(0 if self.data < 0 else self.data, (self,), "ReLU")def _backward():self.grad += (out.data > 0) * out.gradout._backward = _backwardreturn outdef 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 = 1for v in reversed(topo):v._backward()def __repr__(self):return f"Value(data={self.data}, grad={self.grad})"