从零手写 micrograd:一次彻底搞懂反向传播与神经网络 从零手写 micrograd一次彻底搞懂反向传播与神经网络最近跟着 Karpathy 的 micrograd 做了一遍练习从Value类一路搭到 MLP 训练感觉对神经网络到底在算什么这件事终于祛魅了。这篇博客把整个实现拆开讲一遍适合已经会写 PyTorch 但对底层一知半解的朋友。一、为什么要从一个数开始我们平时写 PyTorchloss.backward()optimizer.step()两行代码梯度就出来了。但backward()到底做了什么为什么必须zero_grad()为什么权重更新是p.data - lr * p.grad而不是别的micrograd 的思路是把整个自动微分系统压缩到几十行代码只支持标量一个数就是一个Value这样你能把每个细节都看清楚。神经网络本质上就是一大堆标量运算的组合理解标量版本张量版本只是多了维度而已。二、核心Value 类所有东西都围绕这个类展开classValue:def__init__(self,data,_children(),_op,label):self.datafloat(data)# 前向值self.grad0.0# 反向梯度∂L/∂selfself._backwardlambda:None# 怎么把梯度传给孩子self._prevset(_children)# 计算图上的父节点self._op_op# 运算符调试用self.labellabel三个关键字段字段含义data前向传播算出来的数值grad损失对这个节点的偏导数_backward一个闭包记录我该怎么把梯度乘上局部导数累加给我的孩子_prev记录了计算图的结构谁生了我。用set而不是list主要是为了图结构去重——比如a aself和other是同一个对象_prev里只留一个a遍历图时不会重复列出同一个父节点。但要区分清楚a a的 2 倍梯度不是set去重带来的而是来自闭包里的两次def_backward():self.grad1.0*out.grad other.grad1.0*out.gradself和other指向同一个对象这两行各加一次结果自然就是 2 倍。_prev用set只是让图更干净、拓扑排序少做无用遍历。这也是而不是的深层原因同一个节点可能被多条路径使用DAG 的菱形依赖梯度必须从每条路径累加写成会覆盖掉之前路径的贡献。小提示visited set()能按对象身份去重前提是Value没有定义__eq__/__hash__。如果哪天给Value加了按数值比较的__eq__拓扑排序就可能把数值相同但其实是两个不同节点误判成同一个梯度会丢。micrograd 故意不定义__eq__就是为了保住这个身份语义。三、算子的反向传播这是整个项目最核心的部分。每个算子只需要做两件事在前向时算出data在闭包里定义局部导数如何乘out.grad。1. 加法def__add__(self,other):otherotherifisinstance(other,Value)elseValue(other)outValue(self.dataother.data,(self,other),)def_backward():self.grad1.0*out.grad other.grad1.0*out.grad out._backward_backwardreturnout先解释第一行。这是个 Python 三元表达式等价于ifisinstance(other,Value):pass# 已经是 Value原样保留else:otherValue(other)# 不是 Value包一层它让a 3这种写法也能工作写法other类型处理a b两个 ValueValue保持原样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因为∂(ab)∂a1\frac{\partial (ab)}{\partial a} 1∂a∂(ab)​1。根据链式法则∂L∂a∂L∂out⋅∂out∂aout.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×12. 乘法def__mul__(self,other):otherotherifisinstance(other,Value)elseValue(other)outValue(self.data*other.data,(self,other),*)def_backward():self.gradother.data*out.grad other.gradself.data*out.grad out._backward_backwardreturnout乘法的局部导数比较有意思∂(a⋅b)∂ab\frac{\partial (a \cdot b)}{\partial a} b∂a∂(a⋅b)​b所以对方的data就成了自己的梯度系数。这就是为什么乘法需要存下对方的值——前向时算过闭包直接引用。3. tanh激活函数deftanh(self):tmath.tanh(self.data)outValue(t,(self,),tanh)def_backward():self.grad(1-t**2)*out.grad out._backward_backwardreturnoutdtanh⁡(x)dx1−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 指数outValue(self.data**other,(self,),f**{other})def_backward():# d/dx (x^n) n * x^(n-1)self.gradother*(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不是没考虑到而是有意收缩功能边界教学主线是链式法则不是数学定义域xyx^yxy对yyy求导是xyln⁡xx^y \ln xxylnx要求x0x0x0会引入 domain error 甚至复数神经网络里**几乎永远是常数指数平方、开方、MSE。如果真要支持Value ** Value改动如下体会一下多一个可导输入要多多少东西常数指数当前Value 指数前向self.data ** otherself.data ** other.data_children(self,)(self, other)反向更新只self.gradself.gradother.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):returnselfotherdef__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.grad1.0fornodeinreversed(topo):node._backward()这里做了两件事1. 拓扑排序。从输出节点 DFS把每个节点所有孩子访问完之后才入栈得到一个从叶子到根的顺序。要保证算某个节点的梯度时它的所有下游更靠近 loss 的方向都已经把贡献传进来了。2. 逆序遍历 初始化。输出对自身的导数显然是 1∂L/∂L1\partial L / \partial L 1∂L/∂L1所以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.bValue(random.uniform(-1,1))def__call__(self,x):actsum((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[i1])foriinrange(len(nouts))]def__call__(self,x):forlayerinself.layers:xlayer(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.grad0.0为什么要zero_grad因为_backward里用的是为了共享节点累加正确。如果不清零上一轮的梯度会残留下来和这一轮叠加就错了。PyTorch 里optimizer.zero_grad()也是同一个道理。完整的训练循环forstepinrange(60):zero_grad(model)# 1. 清梯度ypred[model(x)forxinxs]losssum((yp-ygt)**2foryp,ygtinzip(ypred,ys))loss.backward()# 2. 反向forpinmodel.parameters():p.data-lr*p.grad# 3. 更新注意三点p.data - ...而不是直接改p。grad算完之后就用完了接着就是梯度下降参数沿梯度反方向走一小步。loss 用的是平方误差(yp−ygt)2(yp - ygt)^2(yp−ygt)2。yp - ygt里ygt是 float会自动走__rsub__这里 notebook 里__sub__处理了这种情况yp (-ygt)-ygt走__radd__。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我总结了几个可以迁移到日常开发的点局部化梯度计算。每个算子只负责我该怎么把 out.grad 传给孩子不关心全局。这种设计让算子可组合、可扩展——加一个新算子只要写十几行。闭包存中间值。反向时要用到的值比如乘法的other.data、tanh 的t前向时就捕获进闭包避免重复计算。拓扑排序是反向传播的地基。任意 DAG 的梯度计算都依赖它且必须是孩子先入栈 逆序执行。注意区分build产出的是正向拓扑序reversed之后才是反向传播要的逆拓扑序。是 DAG 的核心约束。一旦图里有共享节点梯度就必须累加否则会漏掉某条路径的贡献反过来这也强制了训练前必须清梯度。data和grad分离。参数本身和它的梯度是两个独立对象更新时只改data梯度保留到下一次清零。有意的功能边界。__pow__只支持常数指数、不支持Value ** Value不是疏忽而是取舍。把边界用assert讲清楚比默默报底层TypeError友好得多。九、常见疑问 FAQQ1other 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才能算上游这正好对应计算图上的逆拓扑序。所以反向传播 按逆拓扑序遍历计算图。拓扑排序本身是通用图论工具不是为反向传播发明的两者是恰好契合。Q5build的后序本身就是拓扑序为什么还要reversedbuild的后序是输入在前、输出在后的正向拓扑序。反向传播要的是输出在前、输入在后所以必须反转。这个逆字是理解backward的关键。Q6visited到底防的是什么防 DAG 里的共享节点被重复递归。没有它a会被访问多次topo里出现重复梯度累加出错复杂度还可能指数爆炸。Q7a a的梯度为什么是 2而不是被set去重成 1set去重的是_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 行值得一读。