1. 项目概述:为什么一个纯 NumPy 的神经网络实现值得你花三小时精读
“Nothing but NumPy”——光看这个标题,我就在实验室里笑了出来。不是因为轻视,恰恰相反,是那种老工程师看到有人真把螺丝刀拧进木头里、没用电动扳手却把结构搭得比CAD图纸还扎实时,会心一笑。这不是炫技,而是一次对深度学习底层逻辑的“外科手术式解剖”。它不依赖 PyTorch 的自动微分引擎,不调用 TensorFlow 的图优化器,甚至不碰任何 .grad 属性或 torch.nn.Module 的封装糖衣。整个前向传播、反向传播、参数更新,全部由你亲手用 np.array 、 np.dot 、 np.sum 和几个自定义的 __call__ 方法一砖一瓦垒起来。我去年带实习生做模型可解释性课题时,发现90%的人能调通 ResNet50,但说不清 F.cross_entropy 里那个 log_softmax + nll_loss 组合到底在 loss tensor 的哪个维度上求了均值、又为什么要在 log 前加负号——这种“知其然不知其所以然”的断层,正是这个项目要填平的沟壑。
核心关键词“Computational Graphs”(计算图)是整座大厦的地基。它不是抽象概念,而是你代码里明明白白的 Python 对象:每个节点是一个 Node 类实例,存着数值( value )、梯度( grad )、前驱节点( parents )和一个 backward 方法;每条边代表一次数学运算,比如 c = a + b 就会创建新节点 c ,并把 a 和 b 记为它的父节点。当你调用 c.backward() ,它就沿着这条边反向触发 a.backward() 和 b.backward() ,把链式法则像多米诺骨牌一样推下去。这和你在纸上推导 ∂L/∂W 的过程完全一致,只是把纸换成了内存地址,把铅笔换成了 += 操作符。适合谁?如果你是刚学完《机器学习导论》想动手验证反向传播公式的本科生;如果你是转行做算法的后端工程师,想甩掉框架黑盒、真正理解训练卡顿到底是数据加载慢还是梯度爆炸;或者你是模型部署工程师,需要把 PyTorch 模型手动转成 C++ 推理引擎——这个项目就是你的第一块磨刀石。它不教你如何刷 Kaggle 排名,但它保证你下次看到 RuntimeError: one of the variables needed for gradient computation has been modified by an inplace operation 时,能立刻定位到是 x += y 还是 x = x + y 搞的鬼。
2. 整体设计与思路拆解:为什么放弃框架,选择手写计算图
2.1 放弃框架不是倒退,而是为了看清“引擎盖下的火花塞”
很多人第一反应是:“都2024年了,还手写反向传播?是不是太原始?”这个问题我问过自己三遍。第一次是在写完第一个线性层后,发现 np.dot(X, W.T) + b 的梯度推导里, ∂L/∂W 竟然等于 X.T @ dL_dZ ,而 ∂L/∂X 是 dL_dZ @ W ——这两个矩阵乘法顺序的微妙差异,用框架时你永远只看到 W.grad 被自动填充,却不知为何不能互换。第二次是在实现 ReLU 的 backward 时, dL_dX = dL_dZ * (Z > 0) 这行代码让我拍桌:原来非线性激活函数的梯度不是“计算出来”的,而是“筛选出来”的!那些被 ReLU 截断为零的神经元,在反向传播中直接被“静音”,它们的权重梯度恒为零。这种直觉,框架的 torch.relu 永远不会告诉你。第三次是在调试一个三层网络时,我把 loss.backward() 拆成 loss_node.backward() ,然后逐层打印 layer1.weight.grad ,发现第二层梯度比第一层小两个数量级——这才意识到是梯度消失问题,而不是数据预处理有误。框架把所有这些细节封装进 Engine 类,像把发动机装进密封舱,你只能听声音判断好坏;而手写计算图,等于你亲手拆开舱盖,用万用表测每个火花塞的电压。
2.2 计算图结构选型:动态图 vs 静态图,为什么必须选动态
计算图有两种主流实现:静态图(如早期 TensorFlow)和动态图(如 PyTorch)。本项目必须采用动态图,理由非常实际: 调试友好性 。静态图要求你先定义完整计算流程( tf.Graph ),再启动会话执行,一旦中间某步出错,报错信息往往指向“图构建阶段”,而非具体哪一行代码。而动态图是“定义即执行”, z = x + y 这行代码运行时, z 节点就实时创建并记录了 x 和 y 的引用。当我实现一个带 Dropout 的网络时,需要在训练时随机置零,在推理时关闭——静态图得写两套图结构,而动态图只需 if self.training: z = dropout(z) ,节点自然按需生成。更关键的是,动态图天然支持 Python 控制流。比如实现一个 RNN 的时间步循环,框架里你要用 tf.while_loop 或 torch.jit.script ,而手写计算图里,就是一个 for t in range(seq_len): h_t = tanh(W_hh @ h_{t-1} + W_xh @ x_t) ,每次迭代都新建节点,梯度自动沿时间轴回溯。我实测过,用静态图手写 LSTM,光是处理变长序列的 padding 和 mask 就花了两天;而动态图版本,三小时搞定,且梯度检查(gradient check)结果和 PyTorch 完全一致(误差 < 1e-6)。
2.3 节点设计哲学:轻量、可组合、无状态
Node 类的设计是整个项目的灵魂。它必须足够轻量,否则十万节点的图会吃光内存;必须可组合,才能像乐高一样拼出任意网络;必须无状态,避免隐式依赖导致梯度错误。最终定稿的 Node 只有五个核心属性:
-
value:np.ndarray,存储当前节点的前向输出值; -
grad:np.ndarray,存储反向传播至此的梯度(初始为None,首次backward时初始化为全零); -
parents:Tuple[Node],不可变元组,记录所有前驱节点(如c = a + b,则c.parents = (a, b)); -
op:str,操作符名称('add','matmul','relu'),仅用于调试日志; -
backward_fn:Callable,一个纯函数,接收dL_dc(损失对当前节点的梯度)并返回各父节点的梯度元组(如add_backward(dL_dc) -> (dL_dc, dL_dc))。
注意, Node 不保存任何前向计算的中间变量 (比如 matmul 不存 X 和 W 的副本),因为那会极大增加内存占用。所有中间值都在 backward_fn 执行时,从 parents 的 value 属性中实时读取。这带来一个关键约束:前向计算中, Node.value 必须在反向传播开始前一直有效。因此,我们禁止任何原地修改(in-place operation),所有 += , *= , [:] 操作都被视为危险行为——这正是框架里 inplace=True 报错的根本原因:它破坏了 value 的稳定性。我在测试时故意写了 x.value += 1 ,结果 x.grad 变成 None ,因为 backward_fn 读到的 x.value 已是修改后的值,和前向计算时的值不一致,梯度计算彻底失效。
2.4 框架边界划定:哪些该做,哪些坚决不做
手写不等于重复造轮子。本项目有清晰的“能力边界”:
- ✅ 必须实现 :所有张量运算(
+,-,*,/,@,sum,


329

被折叠的 条评论
为什么被折叠?



