ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从零手写 micrograd:一次彻底搞懂反向传播与神经网络

从零手写 micrograd:一次彻底搞懂反向传播与神经网络

从零手写 micrograd:一次彻底搞懂反向传播与神经网络

最近跟着 Karpathy 的 micrograd 做了一遍练习,从Value类一路搭到 MLP 训练,感觉对"神经网络到底在算什么"这件事终于祛魅了。这篇博客把整个实现拆开讲一遍,适合已经会写 PyTorch 但对底层一知半解的朋友。


一、为什么要从"一个数"开始

我们平时写 PyTorch:

loss.backward()optimizer.step()

两行代码梯度就出来了。但backward()到底做了什么?为什么必须zero_grad()?为什么权重更新是p.data -= lr * p.grad而不是别的?

micrograd 的思路是:把整个自动微分系统压缩到几十行代码,只支持标量(一个数就是一个Value),这样你能把每个细节都看清楚。神经网络本质上就是一大堆标量运算的组合,理解标量版本,张量版本只是多了维度而已。


二、核心:Value 类

所有东西都围绕这个类展开:

classValue:def__init__(self,data,_children=(),_op="",label=""):self.data=float(data)# 前向值self.grad=0.0# 反向梯度(∂L/∂self)self._backward=lambda:None# 怎么把梯度传给孩子self._prev=set(_children)# 计算图上的父节点self._op=_op# 运算符(调试用)self.label=label

三个关键字段:

字段含义
data前向传播算出来的数值
grad损失对这个节点的偏导数
_backward一个闭包,记录"我该怎么把梯度乘上局部导数,累加给我的孩子"

_prev记录了计算图的结构:谁生了我。用set而不是list主要是为了图结构去重——比如a + a,self和other是同一个对象,_prev里只留一个a,遍历图时不会重复列出同一个父节点。

但要区分清楚:a + a的 2 倍梯度,不是set去重带来的,而是来自闭包里的两次+=:

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

self和other指向同一个对象,这两行各加一次,结果自然就是 2 倍。_prev用set只是让图更干净、拓扑排序少做无用遍历。

这也是+=而不是=的深层原因:同一个节点可能被多条路径使用(DAG 的菱形依赖),梯度必须从每条路径累加,写成=会覆盖掉之前路径的贡献。

小提示:visited = set()能按对象身份去重,前提是Value没有定义__eq__/__hash__。如果哪天给Value加了按数值比较的__eq__,拓扑排序就可能把"数值相同但其实是两个不同节点"误判成同一个,梯度会丢。micrograd 故意不定义__eq__,就是为了保住这个身份语义。


三、算子的反向传播

这是整个项目最核心的部分。每个算子只需要做两件事:在前向时算出data,在闭包里定义"局部导数如何乘out.grad"。

1. 加法

def__add__(self,other):other=otherifisinstance(other,Value)elseValue(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=_backwardreturnout

先解释第一行。这是个 Python 三元表达式,等价于:

ifisinstance(other,Value):pass# 已经是 Value,原样保留else:other=Value(other)# 不是 Value,包一层

它让a + 3这种写法也能工作:

写法other类型处理
a + b(两个 Value)Value保持原样
a + 3int包成Value(3)
a + 2.5float包成Value(2.5)

因为后面的_backward要访问other.data和other.grad,如果other是裸的3,就会AttributeError: 'int' object has no attribute 'data'。先统一包成Value,后面就不用到处判断类型了。代价是每次a + 3都临时 new 一个Value——它的.grad会随反向累加,但外面拿不到引用,对用户无害。

为什么是1.0?因为∂(a+b)∂a=1\frac{\partial (a+b)}{\partial a} = 1∂a∂(a+b)​=1。根据链式法则:

∂L∂a=∂L∂out⋅∂out∂a=out.grad×1\frac{\partial L}{\partial a} = \frac{\partial L}{\partial out} \cdot \frac{\partial out}{\partial a} = \text{out.grad} \times 1∂a∂L​=∂out∂L​⋅∂a∂out​=out.grad×1

2. 乘法

def__mul__(self,other):other=otherifisinstance(other,Value)elseValue(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=_backwardreturnout

乘法的局部导数比较有意思:∂(a⋅b)∂a=b\frac{\partial (a \cdot b)}{\partial a} = b∂a∂(a⋅b)​=b,所以对方的data就成了自己的梯度系数。这就是为什么乘法需要存下对方的值——前向时算过,闭包直接引用。

3. tanh(激活函数)

deftanh(self):t=math.tanh(self.data)out=Value(t,(self,),"tanh")def_backward():self.grad+=(1-t**2)*out.grad out._backward=_backwardreturnout

dtanh⁡(x)dx=1−tanh⁡2(x)\frac{d\tanh(x)}{dx} = 1 - \tanh^2(x)dxdtanh(x)​=1−tanh2(x),用输出的t直接算就行,不用重算math.tanh。这也提示了一个工程习惯:前向时能留下的中间结果就留下,反向时省一次计算。

4. 幂运算(常数指数)

def__pow__(self,other):assertisinstance(other,(int,float)),"只支持 int/float 指数"out=Value(self.data**other,(self,),f'**{other}')def_backward():# d/dx (x^n) = n * x^(n-1)self.grad+=other*(self.data**(other-1))*out.grad out._backward=_backwardreturnout

这个算子和前面三个有个显著区别:other是普通数字,不是Value。所以:

  • 前向写self.data**other,不写other.data——other是int/float,根本没有.data;
  • _children只有(self,),指数不建图;
  • _backward里只更新self.grad,因为指数是常数、没有梯度可传。

为什么不做成Value ** Value?不是没考虑到,而是有意收缩功能边界:

  1. 教学主线是链式法则,不是数学定义域;
  2. xyx^yxy对yyy求导是xyln⁡xx^y \ln xxylnx,要求x>0x>0x>0,会引入 domain error 甚至复数;
  3. 神经网络里**几乎永远是常数指数(平方、开方、MSE)。

如果真要支持Value ** Value,改动如下(体会一下"多一个可导输入"要多多少东西):

常数指数(当前)Value 指数
前向self.data ** otherself.data ** other.data
_children(self,)(self, other)
反向更新只self.gradself.grad+other.grad
额外数学无math.log(self.data)

第一行开头的assert原 notebook 里没有,建议补上。没有它时,a ** Value(3)会抛TypeError: unsupported operand type(s) for ** or pow(): 'float' and 'Value'——报错来自 Python 底层,用户很难看出是"这个库不支持 Value 指数"。加一行断言能把边界讲清楚。

顺带说:__add__里之所以敢写other.data,正是因为它先做了isinstance包装;__pow__没做这一步,就不能写other.data。两处的写法差异,本质是"指数是否是变量"的差异。

5. 语法糖

def__neg__(self):returnself*-1def__sub__(self,other):returnself+(-other)def__radd__(self,other):returnself+otherdef__rmul__(self,other):returnself*other

__radd__/__rmul__让3 * a(左边是 int)也能走Value的逻辑。__sub__直接复用加法,超省事。


四、backward:拓扑排序 + 逆序链式法则

单步的反向都写好了,还差一件大事:按什么顺序调用它们。

defbackward(self):topo,visited=[],set()defbuild(v):ifvnotinvisited:visited.add(v)forcinv._prev:build(c)topo.append(v)build(self)self.grad=1.0fornodeinreversed(topo):node._backward()

这里做了两件事:

1. 拓扑排序。从输出节点 DFS,把每个节点"所有孩子访问完"之后才入栈,得到一个从叶子到根的顺序。要保证:算某个节点的梯度时,它的所有下游(更靠近 loss 的方向)都已经把贡献传进来了。

2. 逆序遍历 + 初始化。输出对自身的导数显然是 1(∂L/∂L=1\partial L / \partial L = 1∂L/∂L=1),所以self.grad = 1.0。然后逆序调用每个节点的_backward(),梯度就从输出一路流回输入。

⚠️ 这里一个经典坑:如果图里有共享节点(DAG),拓扑排序和+=必须配合使用,否则梯度会漏加。Notebook 里a + a的检查a.grad == 2.0就是在验证这个机制。

Notebook 里那个拓扑检查挺直观:

拓扑顺序(从子到父): ['f', 'b', 'a', 'e', 'd', 'L'] 反向传播应走逆序: ['L', 'd', 'e', 'a', 'b', 'f']

L是最终输出,先算它的_backward,再顺着图往回推。这就是 “reverse-mode autodiff” 名字的来历。


五、从 Value 到神经网络:三层封装

有了自动微分,搭网络就轻松了。三个类层层嵌套:

Neuron(一个神经元)

classNeuron:def__init__(self,nin):self.w=[Value(random.uniform(-1,1))for_inrange(nin)]self.b=Value(random.uniform(-1,1))def__call__(self,x):act=sum((wi*xiforwi,xiinzip(self.w,x)),self.b)returnact.tanh()defparameters(self):returnself.w+[self.b]

sum的第二个参数self.b是初始值——这样就把偏置塞进了求和里,等价于Σwx + b。每个神经元做的事情就是:加权求和 → tanh 激活。这就是感知机。

Layer(一层)

classLayer:def__init__(self,nin,nout):self.neurons=[Neuron(nin)for_inrange(nout)]def__call__(self,x):outs=[n(x)forninself.neurons]returnouts[0]iflen(outs)==1elseouts

一层就是同一输入喂给 nout 个神经元,输出一个向量。那个outs[0] if len == 1的小技巧,让最后一层输出单个标量而不是[Value],方便后续算 loss。

MLP(多层感知机)

classMLP:def__init__(self,nin,nouts):sz=[nin]+nouts self.layers=[Layer(sz[i],sz[i+1])foriinrange(len(nouts))]def__call__(self,x):forlayerinself.layers:x=layer(x)returnxdefparameters(self):return[pforlayerinself.layersforpinlayer.parameters()]

sz = [nin] + nouts生成层尺寸列表,比如MLP(2, [4, 4, 1])会得到[2, 4, 4, 1],相邻两个数构成一层。前向传播就是一层层串联,简单到有点不真实。

parameters()用列表推导把所有层的参数拍平成一个列表,训练时要遍历的就是它。


六、训练循环:四步走

defzero_grad(model):forpinmodel.parameters():p.grad=0.0

为什么要zero_grad?因为_backward里用的是+=(为了共享节点累加正确)。如果不清零,上一轮的梯度会残留下来,和这一轮叠加,就错了。PyTorch 里optimizer.zero_grad()也是同一个道理。

完整的训练循环:

forstepinrange(60):zero_grad(model)# 1. 清梯度ypred=[model(x)forxinxs]loss=sum((yp-ygt)**2foryp,ygtinzip(ypred,ys))loss.backward()# 2. 反向forpinmodel.parameters():p.data-=lr*p.grad# 3. 更新

注意三点:

  1. p.data -= ...而不是直接改p。grad算完之后就"用完了",接着就是梯度下降:参数沿梯度反方向走一小步。
  2. loss 用的是平方误差:(yp−ygt)2(yp - ygt)^2(yp−ygt)2。yp - ygt里ygt是 float,会自动走__rsub__(这里 notebook 里__sub__处理了这种情况,yp + (-ygt),-ygt走__radd__)。
  3. 60 步之后 loss 从 6.36 降到 0.04,确实收敛了。

测试数据:

xs=[[1,2],[2,-1],[-1,1],[-2,-3]]ys=[1,-1,-1,1]

这是个 XOR 风格的任务,用 2-4-4-1 的网络能拟合得很好。loss 曲线一路下降,说明梯度方向是对的——反向传播没写错。


七、检查点背后的设计思想

Notebook 里的几个检查,每个都在验证一个关键机制:

检查验证内容相关机制
a + a→a.grad == 2.0共享节点梯度累加闭包两次+=;visited去重
a ** 2前向/反向正确常数指数算子_children只有(self,);只更新self.grad
两次独立反向梯度一致清零正确性zero_grad的必要性
loss 从 6.36 降到 0.04整体正确性反向 + 梯度下降

这些测试是写 autodiff 系统的"最小验证集",任何自研框架都该过这几关。


八、几条值得记住的工程原则

翻完 notebook,我总结了几个可以迁移到日常开发的点:

  1. 局部化梯度计算。每个算子只负责"我该怎么把 out.grad 传给孩子",不关心全局。这种设计让算子可组合、可扩展——加一个新算子只要写十几行。
  2. 闭包存中间值。反向时要用到的值(比如乘法的other.data、tanh 的t),前向时就捕获进闭包,避免重复计算。
  3. 拓扑排序是反向传播的地基。任意 DAG 的梯度计算都依赖它,且必须是"孩子先入栈 + 逆序执行"。注意区分:build产出的是正向拓扑序,reversed之后才是反向传播要的逆拓扑序。
  4. +=是 DAG 的核心约束。一旦图里有共享节点,梯度就必须累加,否则会漏掉某条路径的贡献;反过来,这也强制了训练前必须清梯度。
  5. data和grad分离。参数本身和它的梯度是两个独立对象,更新时只改data,梯度保留到下一次清零。
  6. 有意的功能边界。__pow__只支持常数指数、不支持Value ** Value,不是疏忽而是取舍。把边界用assert讲清楚,比默默报底层TypeError友好得多。

九、常见疑问 FAQ

Q1:other = other if isinstance(other, Value) else Value(other)在干什么?

三元表达式,等价于"是Value就留着,不是就包成Value"。目的是让a + 3也能工作——后面_backward要访问other.data/other.grad,裸的3没有这些属性。

Q2:__pow__里为什么写self.data**other而不是other.data?

因为那个版本把other当作常数(int/float),常数没有.data。只有把other也当成Value(像__add__那样先包装)时,才该写other.data,同时_children要变成(self, other)、_backward要多更新other.grad、还要引入math.log。

Q3:__pow__没支持Value ** Value,是没考虑到吗?

不是,是有意收缩边界。教学重点是链式法则;x^y对y求导要ln x,有定义域问题;神经网络里**几乎总是常数指数。建议补一行assert isinstance(other, (int, float))让报错清晰。

Q4:拓扑排序和反向传播到底什么关系?

链式法则要求"先拿到下游的 grad,才能算上游",这正好对应计算图上的逆拓扑序。所以反向传播 = 按逆拓扑序遍历计算图。拓扑排序本身是通用图论工具,不是为反向传播发明的,两者是"恰好契合"。

Q5:build的后序本身就是拓扑序,为什么还要reversed?

build的后序是"输入在前、输出在后"的正向拓扑序。反向传播要的是"输出在前、输入在后",所以必须反转。这个"逆"字是理解backward的关键。

Q6:visited到底防的是什么?

防 DAG 里的共享节点被重复递归。没有它,a会被访问多次,topo里出现重复,梯度累加出错,复杂度还可能指数爆炸。

Q7:a + a的梯度为什么是 2,而不是被set去重成 1?

set去重的是_prev这个图结构,不是梯度计算。梯度 2 倍来自闭包里对self和other各执行一次+=——它们是同一个对象,所以实际加了两次。


十、从这里可以继续往哪走

micrograd 只支持标量,实际框架(PyTorch 的Tensor)支持高维数组,但原理完全一样——只是把"一个值一个 grad"变成"一个张量一个 grad",把_backward里那几行标量乘法变成矩阵乘法。真正的差别在性能:

  • 并行:GPUs 上的张量运算
  • 算子融合:减少 kernel launch
  • 计算图复用:静态图 vs 动态图
  • 更聪明的内存管理:中间结果什么时候能释放

但如果你是从概念层面想搞懂"深度学习为什么能工作",micrograd 已经把所有魔法都拆干净了。


结语

这篇文章的实践价值在于:我把"神经网络的训练"从一句话拆成了四个可以白板推导的公式——

∂L∂a=∂L∂out⋅∂out∂a,反向顺序 = 计算图的逆拓扑序,p←p−η∇pL,梯度必须清零\frac{\partial L}{\partial a} = \frac{\partial L}{\partial out}\cdot\frac{\partial out}{\partial a},\quad \text{反向顺序 = 计算图的逆拓扑序},\quad p \leftarrow p - \eta \nabla_p L,\quad \text{梯度必须清零}∂a∂L​=∂out∂L​⋅∂a∂out​,反向顺序=计算图的逆拓扑序,p←p−η∇p​L,梯度必须清零

写完之后再看 PyTorch 代码,每一行都能对上号了。强烈建议自己动手敲一遍,特别是那三个_backward闭包,敲过和读过的理解完全不同。

完整代码就在上面的 notebook 里,一共不到 200 行,值得一读。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进