1. 项目概述从零手写一个神经网络不是调库是真正理解它怎么呼吸你有没有盯着 PyTorch 的model.train()和loss.backward()这两行代码发过呆明明只写了两行背后却像有千军万马在奔腾——权重在改、梯度在流、损失在降。可它们到底怎么动的为什么一动就准这种“黑箱感”不是初学者的专利我带过不少三年经验的工程师一问反向传播的细节眼神就开始飘。这篇东西就是为了解决这个“飘”的问题。我们不碰任何现成框架不用torch.nn.Linear连numpy都只当个画图工具用。我们要用最原始的 Python 类一行一行亲手搭出一个能算梯度、能更新参数、能预测房价的神经网络。核心关键词就三个神经网络、反向传播、梯度下降。它不是给想速成的算法工程师看的而是给那些晚上睡不着、总想把backward()里那个for循环拆开揉碎了看的人准备的。你不需要数学博士背景但得愿意跟着我一起在纸上推导一个3x² - 4x 5的导数再把它变成代码里一个.grad属性。最终你会得到一个不到 200 行的ML_Framework类它能干的事和你每天在 Jupyter 里pip install torch后调用的底层引擎本质上一模一样。区别只在于这一次每一行代码你都亲手写过每一处梯度你都亲眼见过它从输出端一层一层逆流而上。2. 核心设计思路为什么放弃“造轮子”选择“造发动机”很多人看到“从零实现神经网络”第一反应是这不就是重复造轮子吗TensorFlow、PyTorch 不香吗这个问题我问过自己不下十遍。答案很实在轮子是用来跑的发动机才是用来理解的。当你用torch.optim.SGD时你信任它但当你亲手写出p.data p.data - learning_rate * p.grad这一行时你才真正“拥有”了它。所以我们的设计从一开始就没打算做一个功能齐全的“库”而是要打造一个能清晰映射数学概念的“计算引擎”。整个架构就围绕一个核心矛盾展开如何让计算机自动、正确地计算任意复杂函数的导数你看那个抛物线函数f(x) 3x² - 4x 5它的导数f(x) 6x - 4人眼一眼就能看出。但一个包含百万参数、几十层嵌套的 ResNet你怎么算靠人脑不可能。靠符号微分太慢。靠数值微分精度差还费资源。我们选的是第三条路自动微分Automatic Differentiation而且是其中最精巧的“反向模式”Reverse Mode。它的思想非常朴素把任何一个复杂函数都拆解成一系列最基础的“原子操作”——加、减、乘、除、幂、sin、cos……就像乐高积木。每个原子操作我们都提前定义好它的“前向计算规则”和“反向传播规则”。比如c a * b前向就是算出c的值反向就是当我告诉你c对最终损失的梯度dc/dL是多少时我能立刻算出a和b对损失的梯度da/dL和db/dL。这个过程就是我们代码里那个build_topo()函数在做的事它不关心你算的是房价还是图像分类它只负责把所有参与计算的变量按照它们被创建的依赖顺序排成一个“拓扑序列”。这个序列保证了一件事当你从后往前遍历它时每一个变量的梯度都已经被它所有的“子节点”也就是它参与计算出来的下游变量贡献过了。这就像工厂流水线零件A组装完交给BB组装完交给C最后质检员Loss发现问题反馈给CC再反馈给BB再反馈给A。没有拓扑排序你就没法保证这个反馈路径是完整且无遗漏的。所以我们放弃封装好的nn.Module不是因为它不好而是因为它的“好”恰恰掩盖了这个最精妙的流水线逻辑。我们要的是把流水线的每一个传送带、每一个机械臂都暴露在阳光下。3. 核心细节解析Value 类——一个会“记住自己怎么来的”变量整个项目的灵魂就藏在ML_Framework这个类里。别被名字唬住它就是一个披着“机器学习框架”外衣的、升级版的 Pythonfloat。它的核心是四个属性data、grad、_prev和_backward。我们来逐个掰开揉碎。3.1 data数据的“肉身”一切计算的起点self.data就是你输入的那个数字比如房价数据里的1.0代表100,000美元或者权重初始化的0.09。它是最朴实无华的部分就是个数值。但请注意它只负责存储“当前值”它不关心自己是怎么来的也不关心别人怎么用它。它就像一个沉默的工人只管干活不管调度。3.2 _prev一张“家谱图”记录自己的“父母”这是整个自动微分的基石。self._prev是一个set里面存的是所有“直接参与生成了我”的变量。举个例子a ML_Framework(2.0) b ML_Framework(3.0) c a.times(b) # c a * b在这个例子里c._prev就会是{a, b}。c清楚地知道自己是a和b生的。如果c又参与了下一步计算d c.plus(5)那么d._prev就是{c}而c._prev依然是{a, b}。这样整张计算图就形成了一个有向无环图DAGd是根节点a和b是叶子节点输入。这张“家谱图”就是build_topo()函数遍历的依据。没有它backward()就像一个没有地图的快递员根本不知道该把梯度“包裹”送到谁家。3.3 _backward一个“承诺”关于自己死后该如何“回馈”self._backward是一个函数但它不是立刻执行的而是一个“承诺”Promise。在变量刚被创建时它被设为一个空的lambda: None。它的含义是“当我收到上游传来的梯度时我知道该怎么把它分发给我所有的‘父母’即_prev里的变量。” 这个函数的具体内容是在每一次原子操作如times,plus被调用时由操作本身动态定义的。比如times方法的内部会这样定义_backwarddef _backward(): a.grad b.data * self.grad # da/dL db/dL * b b.grad a.data * self.grad # db/dL da/dL * a看到了吗这里没有魔法只有高中数学的乘积法则(uv) uv uv。self.grad就是c对最终损失L的梯度dc/dL而a.grad和b.grad就是a和b对L的梯度da/dL和db/dL。这个_backward函数就是c对自己“父母”的回馈协议。3.4 grad梯度的“容器”一个会自我累积的累加器self.grad是一个标量初始为0.0。它的作用是累积所有来自下游的梯度。为什么是“累积”因为一个变量可能被多个下游变量同时使用。想象一下a不仅参与了c a * b还参与了e a 10。那么a对最终损失的总梯度就应该是c反馈给它的da/dL加上e反馈给它的da/dL。所以_backward函数里写的永远是而不是。这是一个极其关键的设计点。很多初学者在这里栽跟头以为梯度是一次性分配的结果发现训练不收敛就是因为忘了清零。这也是为什么我们在每个 epoch 开始前要调用model.zero_value()—— 它不是清零模型而是清零所有grad容器为下一轮全新的梯度累积做准备。grad就像一个邮箱_backward是邮递员_prev是收件人地址簿而build_topo()就是邮局的智能分拣系统。提示grad的初始值必须是0.0而不是None。因为操作要求左值必须是一个可变的数值类型。如果设为None第一次就会报错。这是一个实操中踩过的坑看似小却能让整个反向传播流程在第一步就崩溃。4. 实操过程与核心环节实现从抛物线到房价预测的完整旅程现在我们把上面所有抽象的概念拧成一股绳走一遍完整的实操流程。整个过程分为三步构建计算图、执行前向传播、执行反向传播。我们以预测房价为例但为了讲清楚原理先回到那个最简单的抛物线f(x) 3x² - 4x 5。4.1 构建计算图把数学公式翻译成对象关系假设我们想求x 2.0处的导数。我们不会去背公式而是用ML_Framework把整个公式“搭”出来x ML_Framework(2.0) # 输入变量 w1 ML_Framework(3.0) # 权重 w1对应 3x² 中的 3 w2 ML_Framework(-4.0) # 权重 w2对应 -4x 中的 -4 b ML_Framework(5.0) # 偏置 b # 构建 f(x) w1 * x^2 w2 * x b x2 x.times(x) # x² term1 w1.times(x2) # w1 * x² term2 w2.times(x) # w2 * x y term1.plus(term2).plus(b) # w1*x² w2*x b执行完这段代码我们就得到了一个y对象。此时y._prev是{term1.plus(term2), b}而term1.plus(term2)._prev又是{term1, term2}……以此类推一张完整的、指向明确的计算图就诞生了。你可以把它想象成一棵倒长的树y是树根x,w1,w2,b是树叶。4.2 执行前向传播一次“顺流而下”的计算前向传播就是沿着计算图从叶子节点输入开始一层一层向上计算直到得到最终的输出y.data。这个过程是完全确定性的没有任何技巧就是按部就班地执行每个节点的forward逻辑。在我们的ML_Framework里这个过程是隐式的发生在你调用times、plus等方法时。y.data的值就是3*(2.0)² - 4*(2.0) 5 12 - 8 5 9.0。这就是模型对x2.0的“预测”。4.3 执行反向传播一次“逆流而上”的梯度分发这才是真正的重头戏。目标是求出x对y的导数dy/dx。我们调用y.backward()y.grad 1.0 # 设定根节点的梯度为 1.0因为 dy/dy 1 topo build_topo(y) # 获取拓扑排序列表例如 [x, w1, w2, b, x2, term1, term2, y] for node in reversed(topo): # 从 y 开始逆序遍历 node._backward() # 执行每个节点的“回馈协议”让我们手动模拟一下reversed(topo)的前几步第一步node y。y._backward()是空的因为y是最终输出没有下游什么也不做。第二步node term1.plus(term2)。它的_backward会把y.grad即1.0平均分给term1和term2所以term1.grad 1.0,term2.grad 1.0。第三步node term1。term1 w1 * x2它的_backward会执行w1.grad x2.data * term1.grad和x2.grad w1.data * term1.grad。代入数值w1.grad 4.0 * 1.0 4.0x2.grad 3.0 * 1.0 3.0。第四步node x2。x2 x * x它的_backward会执行x.grad x.data * x2.grad两次因为x出现了两次。所以x.grad 2.0 * 3.0 6.0然后再 2.0 * 3.0 6.0最终x.grad 12.0。恭喜我们得到了x.grad 12.0。而根据解析解f(x) 6x - 4当x2.0时f(2) 12 - 4 8。咦怎么是12.0而不是8.0别慌这是因为我们漏掉了term2的贡献继续往下算term2 w2 * x它的_backward会执行w2.grad x.data * term2.grad和x.grad w2.data * term2.grad。term2.grad是1.0来自第二步所以x.grad (-4.0) * 1.0 -4.0。最终x.grad 12.0 (-4.0) 8.0。完美吻合这个手动推导的过程就是反向传播的全部奥义它不是在算一个全局的导数而是在算每一个局部的、微小的“影响”然后把所有这些微小的影响沿着它们产生的路径精准地叠加起来。这就是为什么它叫“链式法则”的工程实现版。4.4 房价预测实战把理论焊死在业务场景上现在把这套逻辑套用到房价上。我们的数据是卧室数 (x)价格 (y)归一化价格 (y_norm)1100,0001.02200,0002.03300,0003.0我们期望模型学到y_norm 1.0 * x 0.0这个线性关系。模型结构就是一个单层感知机y_pred w * x b。# 初始化 w ML_Framework(0.09) # 初始权重 b ML_Framework(-0.9) # 初始偏置 x_input ML_Framework(1.0) # 1卧室 # 前向y_pred w * x b y_pred w.times(x_input).plus(b) # 计算损失MSE (y_pred - y_true)^2 y_true ML_Framework(1.0) diff y_pred.minus(y_true) loss diff.times(diff) # (y_pred - y_true)^2 # 反向 loss.backward() # 这一步会自动计算出 w.grad 和 b.grad # 更新参数w w - lr * w.grad lr 0.05 w.data w.data - lr * w.grad b.data b.data - lr * b.grad这个循环就是训练的全部。loss.backward()是魔法发生的地方它触发了前面描述的整个拓扑排序和梯度分发流程。w.grad和b.grad就是损失loss对w和b的偏导数告诉我们要往哪个方向、走多远去调整它们才能让loss变小。经过 100 轮迭代w会无限趋近于1.0b会无限趋近于0.0。这就是梯度下降的全部力量它不聪明它只是固执地、一小步一小步地朝着“下坡”最陡的方向走。注意loss.backward()必须在每次forward之后立即调用。如果你在forward后又做了其他计算比如z y_pred.plus(10)那么loss.backward()就会错误地把梯度也分发给z导致w.grad和b.grad的值被污染。这是另一个高频错误务必养成“forward - loss - backward - update”的严格顺序习惯。5. 常见问题与排查技巧实录那些让你抓耳挠腮的“幽灵Bug”在亲手实现反向传播的过程中我遇到过太多次那种“代码看起来天衣无缝结果就是不收敛”的情况。这些问题往往藏得很深不像语法错误那样一眼就能看到。我把它们整理成一张速查表全是血泪教训。问题现象根本原因排查与解决技巧实操心得Loss 不下降甚至爆炸式增长grad没有清零导致梯度持续累积在每个 epoch 开始前务必检查model.zero_value()是否被正确调用并确认它确实遍历了所有参数weights和bias。可以在zero_value()里加一句print(Zeroing:, p.data, p.grad)来验证。我第一次遇到这个问题时花了整整两天。最后发现是zero_value()里漏掉了bias只清了weights。bias.grad一直累积导致每次更新都带着巨大的历史包袱。从此以后我的zero_value()函数第一行永远是print(Zeroing all parameters...)。Loss 下降极慢像蜗牛爬学习率learning_rate设置过小或grad的量级本身太小用print(fEpoch {epoch}, w.grad{w.grad:.6f}, b.grad{b.grad:.6f})监控梯度大小。如果grad长期在1e-6以下说明模型几乎“感觉不到”误差要么是学习率太小试试0.1或1.0要么是初始化权重太小把w初始化为1.0而不是0.09。学习率不是超参数它是你的“油门踏板”。不要迷信教科书上的0.01。在简单任务上大胆用0.1甚至1.0只要 Loss 不爆炸就说明你踩对了位置。等模型跑顺了再慢慢收油。Loss 在某个值附近剧烈震荡学习率learning_rate设置过大导致在最优解附近来回“蹦迪”观察 Loss 曲线如果它像心电图一样上下跳动基本可以确诊。解决方案是将学习率降低一个数量级比如从0.1降到0.01并观察震荡是否平息。这个问题有个很形象的比喻你在一个碗底找最低点如果迈的步子太大你就会从碗的这一边跳到另一边永远落不到底。learning_rate就是你每一步的长度。grad为nanNot a Number计算过程中出现了0/0、inf * 0或log(0)等非法运算最常见的原因是softmax或log操作。在ML_Framework的log方法里强制加入if self.data 0: self.data 1e-15的保护。另外检查所有除法操作确保分母永远不会为0。nan是最可怕的 Bug因为它会像病毒一样传染。一个nan的grad经过操作会让所有后续的grad都变成nan。所以一旦发现nan立刻在所有涉及log、div、pow的地方加上防御性编程。模型预测结果完全不相关Loss 巨大且恒定backward()没有被正确触发或者build_topo()没有覆盖到所有参数在backward()函数末尾加一句print(fBackward executed for {self})。运行时你应该能看到所有参数w,b都被打印出来。如果只看到loss说明build_topo()的visited集合没把它们包含进去检查_prev关系是否建立正确。这个 Bug 的本质是计算图“断了”。w和b没有被loss“看见”所以loss的梯度根本传不到它们身上。build_topo()就是那个“探照灯”它照不到的地方就是梯度的黑暗地带。除了这些还有一个终极排查技巧手动计算一个小例子的梯度然后和你的代码输出对比。比如就用f(x) x²x3手动算f(3)6然后运行你的代码看x.grad是不是6.0。如果手动算得对代码输出不对那问题一定出在_backward的实现逻辑里。这个“黄金标准”测试能帮你瞬间定位到问题的核心模块避免在迷宫里兜圈子。6. 工具与环境轻装上阵专注原理这个项目最大的魅力就在于它的“极简主义”。我们不需要一个庞大的深度学习环境只需要最基础的 Python 生态。整个开发和调试我都是在一个干净的venv虚拟环境中完成的。6.1 核心依赖少即是多Python 3.8这是唯一硬性要求。我们不使用任何async或walrus海象操作符等新特性就是为了保证最大兼容性。venv是 Python 自带的无需额外安装。NumPy (1.21)仅用于数据预处理和绘图。np.arange()生成坐标点plt.plot()画抛物线。它不参与任何核心的梯度计算。你可以把它看作一个“画笔”而不是“引擎”。Matplotlib (3.5)同上纯可视化工具。没有它你依然可以跑通整个反向传播流程只是看不到那条漂亮的抛物线。提示我强烈建议你不要安装torch或tensorflow。不是因为它们不好而是因为它们的符号如tensor、Variable会和你正在构建的ML_Framework类产生心理暗示冲突。当你看到torch.tensor(2.0)你的大脑会本能地切换到“框架思维”而不是“原理思维”。保持环境的纯粹是保持思维纯粹的第一步。6.2 开发环境一个编辑器一个终端足矣我用的是 VS Code但任何支持 Python 的编辑器都可以。关键配置只有两个Python 扩展提供语法高亮和基础调试。Code Runner 扩展让我能一键运行当前文件省去频繁切到终端的麻烦。调试时我几乎不用图形化调试器。我的武器是print()。但不是乱打而是有策略地打在__init__里print(fCreated {self} with data{self.data})在每个times、plus方法的开头print(f{self} {a} * {b})在backward()的for循环里print(fProcessing {node}, grad{node.grad})这些print语句会把你带进计算图的内部让你亲眼看到数据和梯度是如何流动的。它比任何断点都更直观因为它展示的是“过程”而不是某个静止的“快照”。6.3 代码组织一个文件一气呵成整个项目我只用了一个.py文件scratch_nn.py。它没有src目录没有tests目录没有requirements.txt。文件结构如下scratch_nn.py ├── class ML_Framework │ ├── __init__ │ ├── times / plus / minus / ... │ └── backward / build_topo ├── class SingleLayerNeuron ├── def squared_error_loss ├── if __name__ __main__: │ ├── 数据准备 │ ├── 模型初始化 │ ├── 训练循环 │ └── 预测与输出这种“扁平化”结构是为了消除所有外部干扰。当你打开这个文件你看到的就是神经网络的全部心脏。没有import语句的噪音没有包管理的烦恼。你的心神可以百分之百地聚焦在a.grad b.data * self.grad这一行代码上思考它背后的数学意义。这是一种刻意为之的“信息茧房”只为守护你对原理的专注。7. 经验总结与延伸思考当“造轮子”成为一种修行写完最后一行print(fPredicted price for a 5-bedroom house: ${predicted_price_denormalized:.2f})看着屏幕上跳出的$498000.00那一刻的感觉很难用语言形容。它不像用 PyTorch 跑出一个 SOTA 结果那样让人兴奋而是一种更深沉、更笃定的平静。因为你知道这个498000不是框架施舍给你的而是你亲手用最基础的加减乘除一砖一瓦垒起来的。这个项目教会我的远不止是反向传播的代码怎么写。它让我彻底明白了所谓“人工智能”其内核并非玄之又玄的“智能”而是一套无比坚实、无比优美的数学工程学。gradient descent是牛顿在 17 世纪就写下的古老智慧backpropagation是 1986 年 Rumelhart 等人在《Nature》上发表的严谨论文而automatic differentiation则是现代编译器技术与微积分的一次完美联姻。我们今天所惊叹的“大模型”不过是把这些古老而强大的工具用硅基芯片以惊人的规模并行执行而已。它的伟大在于规模而不在于原理的颠覆。所以如果你正站在 TensorFlow 和 PyTorch 的巨人的肩膀上感到一丝丝的不安和疏离那么请一定试试亲手写一个ML_Framework。它不会让你立刻成为算法大神但它会给你一种无与伦比的“掌控感”。当你下次再看到optimizer.step()你心里会说“哦它不过是在执行p.data - lr * p.grad。” 当你再看到loss.backward()你心里会说“它正在我的计算图上进行一场精密的拓扑排序和梯度分发。”这就是“从零开始”的终极价值它不是为了取代轮子而是为了让你在每一次转动轮子时都能清晰地听见齿轮咬合的声音感受到力的传递理解速度与加速度的关系。它把一种模糊的敬畏转化成了具体的、可触摸的、属于你自己的知识肌肉。这条路很窄也很慢但走过去的人再也不会在技术的洪流中迷失方向。因为他的锚已经深深扎进了数学的基岩里。