1. from-scratch路线到底在解决什么问题我经常在技术社区看到两类AI开发者一类是调包侠熟练叫得出各种Transformer变体却连反向传播的梯度推导都写不出来另一类是CVer转行简历上写满了精通深度学习实际连一个自定义损失函数都调试不明白。这背后缺的其实不是知识量而是一套从第一性原理想清楚AI系统如何运作的能力。ai-engineering-from-scratch这个项目本质上就是一条绕开黑盒的路不依赖现成框架从最底层逐步构建出可用的神经网络系统。标题里的from-scratch不是噱头它传递了一个很具体的信号你要用荒原开垦式的思路亲自实现张量运算、自动求导、网络层、优化器、训练循环而不是import一行完事。这套路径的价值在于当你把每个组件都亲手写一遍之后再回头去读PyTorch或TensorFlow的源码文档会突然通透了——原来那些API的默认行为、那些莫名报错、那些参数设置建议全部能找到底层原因。这个项目适合谁我建议三类人重点参考一是刚进入AI领域但被框架惯坏的新人需要建立扎实的底层理解二是算法工程师想转AI工程方向需要补上系统实现能力三是准备面试但总觉得原理说不深的同学。说白了读过这篇文章并且动手复现一遍的人以后再面对轮子时会多一份从容不再惧怕框架升级、API改动或者环境迁移。有一点我想先说明from-scratch并不意味着什么都自己造、效率优先。我的主张是用最简代码实现正向传播、反向传播和训练闭环但数据结构设计和代码组织上参考工程化实践这样学到的既有原理又接近真实项目结构不至于走偏。2. 一切的原点从零手写张量与自动求导很多教程讲从零实现神经网络起步就是numpy手写反向传播。但我认为更扎实的做法是先分清楚张量Tensor和自动求导Autograd这两个地基概念因为神经网络的每一层本质上都只是定义在这两个地基上的普通函数。2.1 张量不只是多维数组它还要会记账我们平时说的张量就是多维数组但在一个可训练的网络里张量需要额外携带两样东西数据的数值以及数据的来历——也就是这个张量是由哪些操作计算出来的。这样设计的目的是为了后续自动求导时能顺着来历把梯度传回去。一个极简的、可用的张量实现长这样class Tensor: def __init__(self, data, requires_gradFalse, children(), op): self.data data # numpy数组 self.requires_grad requires_grad self.grad None self._children children # 记录产生当前张量的父张量 self._op op # 记录操作类型 def backward(self, gradNone): if grad is None: grad np.ones_like(self.data) self.grad grad # 计算相对于每个子节点的局部雅可比并递归传递 if self._op add: self._children[0].backward(grad) self._children[1].backward(grad) elif self._op mul: self._children[0].backward(grad * self._children[1].data) self._children[1].backward(grad * self._children[0].data) # ... 更多操作matmul、relu、sigmoid、log、exp这段代码看着简陋但它抓住了一个关键点每个Tensor都知道自己是怎么被算出来的。当损失函数调用backward时梯度会沿着这个计算图从后往前传播。你不需要预先写死某个网络结构任何用Tensor运算搭建的复合函数都能自动求导这就是动态计算图的雏形。2.2 手动推一遍链式法则胜过看十遍文档如果是纯工程调用的角色不手写自动求导也说得过去。但如果你真的想理解为什么学习率太大会导致loss震荡、为什么梯度会消失那就必须自己推一遍链式法则。举一个最简单的例子一个两层的非线性网络y_hat W2 * relu(W1 * x b1) b2假设损失是MSEL (y_hat - y)^2。手工对W2求导需要知道L对y_hat的导数2*(y_hat - y)再乘上y_hat对W2的导数relu的输出。而对W1求导就需要链式法则连续穿过两层每一层都留下一个局部导数最后连乘起来。这就是反向传播的全部秘密——不是什么高深的数学就是可微函数复合后的链式求导。手写这个过程的收获不只是学会求导而是你会发现autograd库本质上做的事情就是把你写的forward函数翻译成一系列局部导数按相反顺序做乘法累加。PyTorch的autograd引擎远比我的MiniTensor复杂但原理完全一致向前构建计算图向后传播梯度。提示真正动手时我强烈建议你从标量、向量逐步过渡到矩阵。很多人一上来直接奔着矩阵求导去会被维度搞晕。先用标量版本打通代码链路再扩展到矩阵形状踩坑会少很多。2.3 反向传播的工程坑内存、顺序、梯度累积自己实现autograd最大的坑之一是梯度累积污染。如果你的网络里有参数共享比如同一个权重矩阵被两个分支使用那么梯度应该是两条路径的和而不是覆盖。这意味着你在backward时不能直接赋值grad current_grad而要用grad current_grad。这个细节在框架里被封装好了新手很难感知但手写实现时必须特别处理。另一个坑是计算图的保留与释放。每次iteration的forward都会构建新图但和框架不同我们要自己控制图的释放否则内存会像漏水的桶一样不停增长。实践做法是在一次backward结束后把每个Tensor的_children清除或直接丢弃整张图引用。还有一个顺序问题backward的递归顺序必须是拓扑序。如果你用递归实现天然满足从输出回传但如果你尝试用循环实现就要先对计算图做拓扑排序。这也是为什么很多Mini-Autograd实现看起来短小精悍实际直接照搬容易出错的真正原因。3. 从零搭一个MLP维度、初始化和激活函数的血泪教训有了张量和autograd打底接下来就可以搭建神经网络层了。大部分从零手写项目会选择MLP作为第一个完整网络因为它结构清晰、训练过程可控又足以暴露一大批工程细节。这里我不打算只贴代码重点说说那些实际写了之后才会发现的坑。3.1 Layer类的设计哲学参数注册与状态管理设计一个Linear层本质上就是管理一组weight和bias并定义forward中的矩阵乘法。但如果你只做到这一步后面会有麻烦因为网络里往往有多个层你需要一个机制统一管理所有参数。class Layer: def parameters(self): return [] class Linear(Layer): def __init__(self, in_features, out_features): self.weight Tensor(np.random.randn(in_features, out_features) * 0.1, requires_gradTrue) self.bias Tensor(np.zeros(out_features), requires_gradTrue) def __call__(self, x): return x.matmul(self.weight) self.bias def parameters(self): return [self.weight, self.bias]这里有一个我个人强烈建议的工程习惯每个Layer类必须提供parameters()方法。这样顶层网络可以做统一遍历实现梯度清零、参数更新、模型保存。否则层一多代码会变成一团乱麻。初始化这里是个重点。很多初学from-scratch的人会用np.random.randn直接初始化结果训练一个10层MLP发现前两层的梯度已经小到没法更新。原因无非是方差随层数爆炸或消失。解决这个问题至少要理解Xavier初始化线性层权重按sqrt(1/fan_in)缩放确保信号穿过每一层时方差基本不变激活函数选ReLU时权重比例通常用sqrt(2/fan_in)这就是Kaiming初始化的核心思想。3.2 激活函数为什么是ReLU以及它带来的两个坑ReLU在工程上的流行有充分理由计算简单max(0,x)、梯度不衰减x0时梯度恒为1、缓解梯度消失。但自己实现它的时候会碰到两个让人头疼的问题。第一个是Dead ReLU。如果某层的输出在训练中整体落在负区间它对应的梯度恒为0意味着这个单元再也不会被激活。这个问题在框架里也有但因为你手工实现会更清晰地感受到它训练一段时间后发现loss卡住检查梯度全是0最后定位到是学习率太大导致一部分神经元的输入长期为负。解决思路包括降低学习率、改用Leaky ReLU、或者用更稳健的初始化。第二个是in-place操作的更新语义。如果你实现relu时直接用x.data[x.data 0] 0这在数学上没问题但会破坏autograd需要的计算图信息因为父节点被原地修改了。正确做法是创建新的Tensor。这背后其实是框架设计中数据不可变原则的体现只是大多数使用者从未感知。3.3 损失函数与优化器的选择逻辑手写网络还有一个容易忽略的地方损失函数和优化器不是随便选一个的配件它们与网络的数值范围强绑定。MLP做二分类任务输出层往往接一个Sigmoid然后配BCELoss多分类就接SoftmaxCrossEntropy。如果你自己手写过Softmax的求导很快就会明白为什么交叉熵要和Softmax组合使用两者的导数合在一起得到极其简洁的(y_hat - y)表达式数值稳定且没有中间项的数值爆炸风险。优化器方面我建议从SGD开始但实现的时候顺便支持Momentum。Momentum的实现极其简单无非是维护一个速度变量v 0.9 * v - lr * grad param.data v但它的效果非常明显。我发现很多手写项目的loss曲线剧烈震荡问题就出在没有mementum遇到峡谷地形时SGD容易来回横跳。加一行动量代码后曲线平滑度会有质的提升。4. 训练闭环数据迭代、验证集与超参数视野网络搭建只是第一步真正暴露问题的从来都是训练闭环。很多同学第一次手写训练循环时只写了forward、loss、backward、update四行结果看起来逻辑正确loss却完全不下降或者下降后又反弹查来查去毫无头绪。这一节我把自己的排查经验拆开讲。4.1 mini-batch训练的形状问题与退化loss训练闭环最经典的bug是维度不匹配。你在纸上推导时一切完美但代码跑起来后矩阵乘法会产生奇怪广播或者梯度形状对不上。我的排查方法很简单# forward之后 print(x.shape, y_hat.shape, y.shape) # backward之后 for p in model.parameters(): print(p.data.shape, p.grad.shape)不允许任何一个形状是歪的。这个方法听着基础却真的能救回大量调试时间。另一个容易被忽视的问题是loss单调不降。如果代码逻辑完全正确但loss几十个epoch一动不动先不要怀疑网络结构而是检查数据标准化。我一直强调MLP这类网络对输入尺度极度敏感特征里如果一列是0-1的归一化值另一列是动辄上千的原始值模型训练基本会失败。手写实现时没有框架的默认行为兜底数据预处理做得不好代码再好也没用。4.2 训练/验证的语义分离手写时更要注意框架里分离训练集和验证集往往只换一个参数但在from-scratch实现里你需要刻意维护两套状态训练阶段要开dropout、要更新BN的running stats、要计算梯度验证阶段所有这些都要关闭且必须用no_grad包裹否则验证过程也会构建计算图并占用内存。我第一版实现犯过一个错验证时忘记切断autograd结果每跑完一个epoch内存占用翻一倍到第三个epoch直接OOM。原因就是验证过程积累的计算图没有释放。后来我在训练/验证切换时都显式加上self.training标志来控制行为并确保验证分支不触发梯度记录。这一点面向工程实践的同学务必重视。4.3 超参数的第一个安全区间作为参考我给出手写MLP最稳妥的一组起始参数网络结构3层全连接每层32-64个神经元激活ReLU优化器SGD Momentum0.9学习率0.01到0.1之间起手批大小16或32归一化输入特征全部Z-score标准化训练轮数先给50轮观察loss趋势为什么要强调安全区间因为手写网络没有框架帮你自动调参或做梯度裁剪任何一个参数跑飞都会让训练变得不可诊断。先用保守区间验证整个链路是通的再逐步加深加宽网络排查问题的难度会降低一个数量级。5. 手写代码会遇到的问题排查清单从逻辑对到真正能跑训练闭环能跑起来之后面临的真实挑战就变成各种边界情况和数值问题。我花了很长时间踩透这些坑这里梳理成一份可复现的排查清单每一个都有实际案例支撑。5.1 数值稳定性问题log(0)、除以0和Inf神经网络计算里最常见的数值杀手之一是损失函数中出现log(0)。如果用朴素方式实现交叉熵当模型输出经过softmax后概率接近0时log就会趋向负无穷进而导致loss为Inf、梯度为NaN。框架内部都有数值稳定的融合版实现但手写时需要自己规避。以二分类交叉熵为例稳妥的写法是# 不直接算 log(p)而是对 logits 做数值稳定处理 def binary_cross_entropy_with_logits(logits, targets): # 通过 logaddexp 技巧避免 exp 溢出 loss np.logaddexp(0, -logits) * (1 - targets) \ np.logaddexp(0, logits) * targets return loss.mean()这里的关键思路永远不要在概率空间中做log运算而是把log与softmax的exp抵消掉。这个技巧在框架源码里随处可见但它真正值钱的是你理解了为什么会这样设计。5.2 梯度为NaN/Inf的系统性排查顺序训练到一半loss突然变成NaN是手写工程最让人崩溃的问题。我整理了一条自己的排查路径按概率从高到低排列学习率过大导致loss发散把lr降到1e-3甚至1e-4验证输入数据有NaN或Inf前向计算后被放大自定义损失函数里出现了不稳定的log/exp组合梯度累积逻辑出错导致梯度过大权重初始化方差过大深层网络激活值爆炸。这条路径的价值在于它按最容易出问题排序而不是从数学角度从前往后查。实际项目里80%的NaN问题出在1和2不用一开始就去啃复杂的理论。5.3 两个看起来没关系但其实致命的隐藏问题手写训练还会遇到两个隐性问题第一个是数据shuffle。如果你每个epoch都用相同顺序的mini-batch模型会记住序列模式验证loss会出现周期性波动。解决很简单每个epoch对数据索引做一次随机排列即可但别忘了同时打乱特征和标签的对应关系。第二个是随机种子管理。from-scratch项目往往没有框架帮你管理随机状态每次运行结果都不一样。如果实验无法复现后续所有调参都像是在打靶。我习惯在模型构建、数据shuffle、优化器初始化三处都设置np.random.seed(42)保证每次调试有可比性。6. 从玩具网络到可用的AI系统差的不只是参数规模把这个MLP训练到收敛之后很多人会有一种原来深度学习就这的感觉。但这个项目真正值得投入时间的地方在于它帮助你建立了一套从底层向上生长的系统观。接下来我想聊聊当你不满足于玩具网络时还有哪些维度值得扩展。6.1 用同样的框架去手写CNN、RNN或Transformerfrom-scratch的优势是你已经有一个可用的autograd引擎后续添加新网络结构不需要重写训练闭环只需要实现新Layer的forward和对应的局部梯度。以CNN为例核心难点在于卷积运算的可视化实现——用im2col把输入展开成矩阵卷积就变成一次矩阵乘法反向传播也会变得清晰很多。这个过程会让你理解为什么GPU对矩阵乘法高度优化、为什么现代框架要引入算子融合。而Transformer的方向差不多核心是手写Attention的QKV计算和mask逻辑当你自己实现过一个只能跑CPU的mini-attention之后再去看那些百页论文会觉得亲切得多。6.2 性能优化的工程视角向量化、内存复用和算子融合手写实现经常会遇到一个痛点同样的网络结构框架实现比我写的手工版本快几十倍。这不只是别人用了C写底层这么简单更多是思路差异。以ReLU为例最简单实现是逐元素判断但如果你用numpy的向量化操作整批数据一次性处理性能立涨十倍。再进一步把ReLU的激活计算融合到前一个线性层的输出上减少一次中间存储又是可观的提升。这就是算子融合的思路雏形。当你自己试着做这些优化就会明白为什么框架动不动就提kernel fusion和graph optimization而不只是觉得那些是空洞的概念。6.3 工程化能力迁移训练管线、模型存储与部署接口按我的经验完整做完这个项目之后再去看模型部署的工程坑会比直接看部署文档理解得快很多。比如你知道模型保存时要把哪些参数序列化就不会随意pickle整个类你知道forward与autograd可以分离自由设计推理时的图结构也就能看懂为什么框架要设计no_grad和eval模式。如果哪一天你想把这个mini框架向外扩展我的建议顺序是先加模型保存与加载这个难度最低但价值最大再加一个简单的DataLoader抽象支持shuffle、batch、并行预载然后考虑自定义优化器Adam其实实现起来并不复杂最后尝试用矩阵化重写核心卷积层。每一步扩展都会强化你对框架设计取舍的直觉这正是从会用AI走向能做AI工程的分水岭。7. 耗时数月踩坑后的路线微调与最终建议如果让我复盘整个from-scratch项目最值得调整的地方我会给出几个明确的路线微调帮助后来者避免我曾经走过的弯路。第一不要一开始就追求完全无库。很多人被from-scratch这几个字误导连numpy都不想用试图用纯Python循环写矩阵乘法。我认为那是错的项目目标应该是理解原理而不是自我折磨。numpy本身就是数学计算的基础抽象用它来实现张量、实现卷积完全符合from-scratch的精神——因为你仍然没有用任何深度学习框架。第二每一层网络写完后立刻做数值验证。我当时采用的做法是写一个简单的梯度检查函数通过数值差分估算梯度与autograd算出来的梯度做对比如果误差超过1e-3就停下来查bug。这个习惯帮我抓到了无数隐藏问题。def grad_check(x, fn, autograd_grad, eps1e-5): 数值差分梯度检查验证手工反向传播的正确性 approx (fn(x eps) - fn(x - eps)) / (2 * eps) return np.abs(approx - autograd_grad).max()每次新增一个操作、一个新Layer都先跑一遍梯度检查比攒到最后一次性调试轻松太多。第三每完成一个阶段写一份可运行的MiniDemo。比如完成autograd之后写一个线性回归demo完成MLP之后写一个MNIST分类demo完成CNN之后写一个CIFAR-10的简单分类demo。这个每阶段一个说明书的做法既是阶段性的自我验证也是将来面试时可以试讲的项目亮点。最后我想说from-scratch这条路最大的价值不在知识本身它有一种祛魅的力量——当你亲手写过那一千多行代码亲手调通那无数个NaN和shape报错之后AI对你来说不再是魔法而是一套可以用逻辑和代码解释的系统。以后再碰见新模型、新框架你都会有一种这个我大概知道它内部怎么走的从容感。如果条件允许我强烈建议召集几个志同道合的朋友一起做这个项目原理部分自己啃但调试和验证环节群里互助踩坑效率会高很多。毕竟这种项目一个人闷头写容易放弃几个人结伴走反而能真的走完。
阅读完成 · 觉得有帮助?