1. 从零手搓AI工程:为什么我不建议你直接调包
第一次看到ai-engineering-from-scratch这个项目名的时候,我正坐在工位上啃一个调了三天都没收敛的推荐模型。当时第一反应是:又来了一个“从零实现”的玩具仓库。毕竟市面上打着“from scratch”旗号的项目,十个里有八个是拿 NumPy 把sklearn的fit和predict重写一遍,跑个鸢尾花数据集就敢说自己实现了机器学习。
但真正把仓库拉下来跑通之后,我改主意了。这个项目的价值不在于它实现了多少算法,而在于它把 AI 工程里那些被框架封装得严严实实的环节,一层一层剥开给你看。从张量的内存布局,到反向传播的链式求导,再到训练循环里的梯度累积和混合精度,它逼着你直面每一个细节。我后来把这个仓库推荐给了组里两个刚转行做算法的应届生,让他们花两周时间把核心模块手敲一遍,效果比看十篇综述论文都管用。
这个项目适合谁?如果你已经会用 PyTorch 或 TensorFlow 搭模型,但说不清楚loss.backward()背后到底发生了什么;如果你面试时被问到“手写一个反向传播”就心里发虚;如果你想知道工业级训练框架里那些amp、gradient checkpointing、distributed sampler到底在解决什么问题——那这个项目就是为你准备的。它不教你调参,不教你刷榜,它教你的是AI 工程的底层肌肉记忆。
我打算按我自己带新人的路子,把这个项目的核心脉络拆开讲一遍。不是照着 README 念,而是告诉你每个模块为什么这么设计、实际跑的时候会遇到什么坑、以及怎么把这些从零实现的代码和工业级框架对应起来。
2. 项目整体架构与设计哲学拆解
2.1 为什么选择“自底向上”而不是“自顶向下”
大多数 AI 入门教程走的是自顶向下的路子:先告诉你model = ResNet50(),再告诉你model.fit(),最后才解释卷积核是什么。这种路径上手快,但后患无穷。我见过太多工作两三年的算法工程师,能熟练调 HuggingFace 的Trainer,却说不清楚DataLoader的num_workers设成 0 和设成 8 到底差在哪里,更别提自己写一个高效的collate_fn了。
ai-engineering-from-scratch反其道而行,它从最底层的标量求导开始,一层一层往上搭。这个选择背后的逻辑很清晰:只有理解了每一层的抽象成本,你才能在做工程决策时知道该在哪里妥协。比如你知道矩阵乘法在底层是怎么分块调度的,才会明白为什么batch_size不是越大越好;你知道反向传播需要缓存哪些中间变量,才会理解为什么torch.no_grad()能省显存。
我实际带人的经验是,自底向上的学习曲线确实陡,前两周会很痛苦,但一旦跨过那个坎,后面学任何框架都是降维打击。这个项目的章节编排就是按这个思路来的:先实现一个能求导的标量引擎,再扩展到张量,然后是神经网络层、损失函数、优化器,最后才是训练循环和工程优化。
2.2 核心模块的依赖关系与学习路径
整个项目的模块依赖关系不是线性的,而是有交叉的。我整理了一个实际学习时比较顺的路径,和官方 README 的顺序略有不同,但我觉得对新手更友好:
| 阶段 | 核心模块 | 前置依赖 | 预计耗时 | 关键产出 |
|---|---|---|---|---|
| 第一阶段 | 标量自动微分引擎 | 无 | 3-5天 | 理解计算图与链式法则 |
| 第二阶段 | 张量基础与广播机制 | 标量引擎 | 5-7天 | 手写矩阵乘法与广播 |
| 第三阶段 | 神经网络层实现 | 张量基础 | 7-10天 | 全连接、卷积、注意力 |
| 第四阶段 | 损失函数与优化器 | 神经网络层 | 3-5天 | 交叉熵、Adam |
| 第五阶段 | 训练循环与工程优化 | 全部前置 | 7-10天 | 梯度累积、混合精度 |
这个路径的关键在于第二阶段不能跳。我见过有人标量引擎学得很快,觉得自己懂了,直接跳到神经网络层,结果在张量广播那里卡死。广播机制是后面所有操作的基石,(B, C, H, W)和(C, 1, 1)相加时到底发生了什么,不亲手实现一遍是记不住的。
2.3 与工业级框架的映射关系
这个项目最容易被低估的价值,是它和 PyTorch 的对应关系。我在每个模块学完之后,都会做一件事:打开 PyTorch 源码,找到对应的实现,对比两者的差异。这个习惯让我对框架的理解深了很多。
举个例子,项目里实现标量自动微分时,每个Value对象都持有一个_backward函数。这对应 PyTorch 里的grad_fn和Function类。但 PyTorch 做了大量优化:它用 C++ 实现核心逻辑,用torch.autograd.Function暴露 Python 接口,还引入了grad_fn的链表结构来管理计算图。你在项目里手写的那个简单版本,能帮你理解retain_graph=True到底在保留什么,以及为什么有时候不设这个参数会报“尝试第二次反向传播”的错。
再比如优化器部分,项目里实现的 SGD 和 Adam 都是最朴素的版本。工业级框架里的 Adam 有amsgrad、weight_decay的各种变体,还有foreach实现来加速。但核心的动量更新和偏差校正逻辑,和项目里手写的一模一样。你手写过一遍,再看文档里的参数说明,就不会一头雾水。
3. 核心细节解析与实操要点
3.1 标量自动微分引擎:计算图的构建与反向传播
这是整个项目最精妙的部分,也是我建议花最多时间的地方。项目实现了一个Value类,每个实例代表计算图中的一个节点,持有data、grad、_backward和_prev四个核心属性。
先看前向传播。当你写c = a + b时,Value.__add__被调用,它创建一个新的Value对象,data是a.data + b.data,_prev是{a, b},同时定义_backward函数:把c.grad累加到a.grad和b.grad上。这里有个关键细节:梯度是累加的,不是覆盖的。因为一个节点可能被多条路径使用,比如d = a + a,a的梯度应该是2 * d.grad。这个累加逻辑在反向传播时至关重要。
反向传播的实现是一个拓扑排序加链式法则的过程。项目里通常用递归实现:从输出节点开始,先递归处理所有_prev节点,再调用自己的_backward。但这里有个坑:如果计算图有共享节点,递归会导致重复计算。工业级实现会用拓扑排序保证每个节点只处理一次。我在实际跑的时候,用了一个简单的记忆化集合来避免重复,效果很好。
注意:手写反向传播时,最容易出错的地方是忘记在
_backward里乘以本地的偏导数。比如乘法节点的_backward应该是a.grad += b.data * self.grad,而不是a.grad += self.grad。这个错误在简单例子上看不出来,一旦网络深了就会导致梯度完全错误。
我实测下来,标量引擎跑一个两层 MLP 在 MNIST 上,训练速度大概是 PyTorch 的千分之一。但这个速度差异恰恰是教学价值所在:你会直观感受到每一次前向和反向到底做了多少计算。等你切换到张量版本时,那种“原来可以这么快”的冲击感,会让你对向量化的理解深刻得多。
3.2 张量实现:从广播机制到内存布局
张量模块是整个项目里代码量最大、也最容易写崩的部分。核心要解决三个问题:数据存储、广播规则、以及反向传播时的梯度归约。
数据存储方面,项目通常用一个扁平的float数组加一个shape元组来表示。这个设计对应 PyTorch 的storage和size。关键操作是strides的计算:对于一个(3, 4)的矩阵,行方向的步长是 4,列方向的步长是 1。理解了 strides,你就能明白为什么transpose操作在 PyTorch 里几乎不耗时——它只是交换了 strides,没有移动实际数据。
广播机制是新手最容易翻车的地方。项目里实现广播时,需要先把两个张量的 shape 对齐到相同长度,然后从右往左逐维比较:相等或其中一个为 1 才能广播。反向传播时,被广播的维度需要做sum归约。比如(3, 1)和(3, 4)相加,结果的梯度回传到第一个张量时,需要在第 1 维上求和,变成(3, 1)。
我踩过的一个坑是:广播后的梯度归约,sum的keepdims参数必须设为True,否则维度会对不上。这个细节在 PyTorch 里被自动处理了,但手写时必须自己管。我当时调了两个小时才发现是这里的问题,后来养成了一个习惯:每次实现完广播的反向,都用一个简单的数值梯度检查来验证。
# 数值梯度检查的简化示例 def check_grad(f, x, eps=1e-6): # 解析梯度 y = f(x) y.backward() analytic = x.grad.copy() # 数值梯度 numeric = np.zeros_like(x.data) for i in range(x.data.size): x.data.flat[i] += eps y1 = f(x).data.sum() x.data.flat[i] -= 2 * eps y2 = f(x).data.sum() x.data.flat[i] += eps numeric.flat[i] = (y1 - y2) / (2 * eps) return np.allclose(analytic, numeric, atol=1e-4)这个检查函数我建议每实现一个新算子就跑一遍,能省下大量调试时间。
3.3 神经网络层:全连接、卷积与注意力的手写实现
全连接层相对简单,核心就是y = x @ W + b,反向传播时dW = x.T @ dy,dx = dy @ W.T。但这里有个工程细节:权重初始化。项目里通常会实现 Xavier 或 Kaiming 初始化,背后的逻辑是保持前向传播时每层的方差一致。我实测过,用np.random.randn直接初始化,网络深了之后激活值会指数级衰减或爆炸,训练根本起不来。
卷积层的实现是重头戏。项目里一般用im2col加矩阵乘法的方式,而不是直接写多层循环。im2col把输入特征图按卷积核的感受野展开成一个大矩阵,然后和展平的卷积核做矩阵乘法。这个技巧在工业级框架里也是标准做法,因为矩阵乘法有高度优化的 BLAS 库可用。手写一遍im2col,你就能理解为什么卷积的计算量是O(N * C_out * C_in * K * K * H * W),以及为什么depthwise separable convolution能省那么多计算。
注意力机制的实现是另一个关键点。项目里通常从最朴素的softmax(Q @ K.T / sqrt(d)) @ V开始,然后逐步加入mask、dropout、multi-head的拆分与合并。这里最容易出错的是mask的处理:在softmax之前,需要把被 mask 的位置设成一个极大的负数,而不是 0。因为softmax(0)是有值的,会污染注意力分布。这个细节在 PyTorch 的scaled_dot_product_attention里被封装了,但手写时必须自己注意。
提示:实现多头注意力时,
head_dim通常是d_model / num_heads。如果除不尽,要么调整d_model,要么用padding。我见过有人直接取整,结果后面 reshape 时维度对不上,排查了半天。
3.4 损失函数与优化器:数值稳定性与偏差校正
交叉熵损失看似简单,但数值稳定性是魔鬼。直接算-log(softmax(x))在x很大或很小时会溢出。项目里通常用 log-sum-exp 技巧:log_softmax(x) = x - max(x) - log(sum(exp(x - max(x))))。这个max的减法操作是必须的,它把指数运算的输入限制在(-inf, 0]范围内,避免上溢。
优化器部分,SGD 的动量实现有个细节:动量是梯度的指数移动平均,不是梯度的累加。项目里通常写成v = beta * v + (1 - beta) * grad,然后param -= lr * v。但有些实现会省略(1 - beta)这个因子,导致动量的尺度随beta变化。两种写法在beta接近 1 时差异很大,我建议按 PyTorch 的写法来,保持一致性。
Adam 的偏差校正是另一个容易忽略的点。因为m和v初始化为 0,在训练初期它们的期望值会偏向 0。偏差校正就是除以(1 - beta^t)来修正这个偏差。不写这一步,训练初期的更新步长会异常小,收敛变慢。我实测过,在 Transformer 训练里,去掉偏差校正会导致前几百步几乎不收敛。
| 优化器 | 核心公式 | 关键参数 | 常见坑 |
|---|---|---|---|
| SGD | p -= lr * g | lr | 学习率过大导致震荡 |
| SGD+Momentum | v = βv + g; p -= lr * v | lr, β | 忘记(1-β)因子 |
| Adam | m = β1m + (1-β1)g; v = β2v + (1-β2)g²; p -= lr * m̂ / (√v̂ + ε) | lr, β1, β2, ε | 忘记偏差校正 |
4. 实操过程与核心环节实现
4.1 环境准备与项目结构梳理
先把仓库克隆到本地,我建议用 Python 3.10 以上的版本,因为项目里用了一些类型注解的新语法。依赖方面,核心只需要 NumPy,但如果你想跑可视化对比,可以装 Matplotlib。我个人的习惯是建一个独立的虚拟环境,避免和现有的 PyTorch 环境冲突。
python -m venv venv_scratch source venv_scratch/bin/activate # Windows 用 venv_scratch\Scripts\activate pip install numpy matplotlib项目结构通常是按模块分目录的:micrograd/放标量引擎,tensor/放张量实现,nn/放网络层,optim/放优化器,train/放训练循环。我建议不要一上来就按目录顺序读,而是先跑通examples/里的端到端例子,有个整体感知,再回头啃细节。
4.2 手写训练循环:从数据加载到参数更新
训练循环是串联所有模块的地方。项目里通常实现一个最朴素的版本:
for epoch in range(num_epochs): for x_batch, y_batch in dataloader: # 前向传播 logits = model(x_batch) loss = cross_entropy(logits, y_batch) # 反向传播 model.zero_grad() loss.backward() # 参数更新 optimizer.step()这个循环里,zero_grad()的位置很关键。如果放在backward()之后,梯度会累积到下一次迭代;如果忘记调用,梯度会一直累加,导致更新步长越来越大。PyTorch 默认是累积梯度的,所以必须手动清零。这个设计是为了支持梯度累积(在小显存上模拟大 batch),但新手经常在这里翻车。
数据加载部分,项目里通常实现一个简单的DataLoader,支持batch_size、shuffle和drop_last。shuffle的实现要注意:每个 epoch 都要重新打乱索引,而不是只打乱一次。我见过有人把打乱后的索引缓存起来,结果每个 epoch 的数据顺序都一样,相当于没打乱。
4.3 梯度累积与混合精度的工程实现
梯度累积是显存不够时的标准操作。核心思想是:跑多个 micro-batch,把梯度累加起来,再统一更新一次参数。实现上就是在backward()之后不立即step(),而是等累积到一定步数再更新。
accumulation_steps = 4 for i, (x_batch, y_batch) in enumerate(dataloader): logits = model(x_batch) loss = cross_entropy(logits, y_batch) / accumulation_steps loss.backward() if (i + 1) % accumulation_steps == 0: optimizer.step() model.zero_grad()注意loss要除以accumulation_steps,否则累积后的梯度会是原来的accumulation_steps倍,相当于学习率放大了。这个细节我踩过坑:当时忘了除,结果训练 loss 直接飞了,排查了半天才发现是梯度尺度问题。
混合精度训练在项目里通常用float16模拟。核心是维护一份float32的权重副本,前向和反向用float16计算,参数更新时把梯度转回float32再更新。这样做的好处是显存占用减半,计算速度提升(在支持 Tensor Core 的硬件上)。但float16的动态范围窄,容易下溢,所以通常还要配一个loss scaling:把 loss 放大一个系数,反向传播后再把梯度缩小回来。
注意:手写混合精度时,
softmax、layernorm这类对数值范围敏感的操作,最好保持在float32下计算。我实测过,全用float16跑 Transformer,训练几百步后 loss 就会变成 NaN。
4.4 一个完整的端到端训练实例
我拿项目里的 MNIST 分类例子跑了一遍,记录一下关键参数和结果。模型是一个两层 MLP:784 -> 256 -> 10,激活函数用 ReLU,损失用交叉熵,优化器用 Adam,学习率1e-3,batch size 64,训练 10 个 epoch。
| 配置项 | 值 | 说明 |
|---|---|---|
| 输入维度 | 784 | 28x28 展平 |
| 隐藏层 | 256 | ReLU 激活 |
| 输出维度 | 10 | 对应 0-9 |
| 优化器 | Adam | lr=1e-3, β1=0.9, β2=0.999 |
| Batch Size | 64 | 共 938 个 batch |
| Epochs | 10 | 约 5 分钟(纯 NumPy) |
训练过程中,第 1 个 epoch 的 loss 从 2.3 降到 0.5 左右,准确率到 85%。第 5 个 epoch 准确率到 92%,第 10 个 epoch 到 95% 左右。这个结果和 PyTorch 版本对比,差距在 0.5% 以内,说明手写实现的正确性没问题。速度上,纯 NumPy 版本每个 epoch 约 30 秒,PyTorch 在 GPU 上约 1 秒,差距主要来自向量化程度和硬件加速。
5. 常见问题与排查技巧实录
5.1 梯度爆炸与消失的排查思路
梯度爆炸最直接的表现是 loss 变成 NaN 或者突然跳到很大的值。排查时,我习惯在反向传播后打印每一层梯度的范数。如果某一层的梯度范数超过 10,基本可以确定是爆炸了。常见的解决方法有:梯度裁剪(clip_grad_norm_)、降低学习率、换用更稳定的初始化。
梯度消失的表现是 loss 下降极慢,或者深层网络的梯度接近 0。排查时同样看梯度范数,如果深层梯度比浅层小几个数量级,就是消失了。解决方法包括:换用 ReLU 激活、加 BatchNorm、用残差连接。我在手写项目里遇到过一次梯度消失,原因是用了 Sigmoid 激活且初始化太小,换成 Kaiming 初始化后问题解决。
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| Loss NaN | 梯度爆炸/除零 | 打印梯度范数 | 梯度裁剪、降 lr |
| Loss 不降 | 梯度消失/学习率过小 | 检查各层梯度 | 换激活、加 BN |
| Loss 震荡 | 学习率过大 | 观察 loss 曲线 | 降 lr、加动量 |
| 准确率卡住 | 过拟合/欠拟合 | 对比训练验证集 | 加正则、调模型 |
5.2 数值稳定性问题的定位与修复
数值稳定性问题在从零实现时特别常见,因为框架帮你处理了很多边界情况。我遇到过的典型问题包括:log(0)导致-inf、exp上溢、sqrt负数、除法分母为 0。
定位这类问题,我通常用np.seterr(all='raise')让 NumPy 在遇到无效操作时直接抛异常,而不是静默返回inf或nan。这样能快速定位到出问题的代码行。修复方法就是加epsilon:log(x + 1e-8)、sqrt(x + 1e-8)、x / (y + 1e-8)。这个epsilon取多少有讲究,太小起不到保护作用,太大会影响精度。我一般用1e-8到1e-6之间,具体看数据范围。
提示:在
softmax里,除了减最大值,还要注意exp的结果求和后可能为 0(如果所有输入都是极小的负数)。加一个epsilon到分母上能避免除零。
5.3 性能瓶颈分析与优化技巧
纯 NumPy 实现的性能瓶颈通常在两个地方:Python 循环和内存分配。Python 循环的开销很大,尤其是嵌套循环。优化方法就是尽量向量化,把循环转成矩阵操作。比如计算卷积时,不要用四层循环遍历(N, C_out, H, W),而是用im2col转成矩阵乘法。
内存分配方面,频繁创建临时数组会导致大量的内存分配和回收。优化方法是预分配缓冲区,或者用out参数复用数组。我在实现 Adam 时,把m和v的更新写成原地操作,速度提升了约 20%。
另一个容易被忽略的瓶颈是数据类型。NumPy 默认用float64,但深度学习通常float32就够了。把数据类型改成float32,内存占用减半,速度也能提升 30% 左右。这个改动在项目里通常只需要在初始化时指定dtype。
5.4 从零实现与框架实现的差异对照
学完这个项目后,我建议做一次系统的对照,把每个模块和 PyTorch 的对应实现列出来。这个对照过程能帮你把零散的知识点串成体系。
| 项目模块 | PyTorch 对应 | 关键差异 |
|---|---|---|
| 标量 Value | Tensor + grad_fn | PyTorch 用 C++ 实现,支持动态图 |
| 张量广播 | torch.broadcast_tensors | PyTorch 自动处理梯度归约 |
| 全连接层 | nn.Linear | PyTorch 有融合的 addmm 算子 |
| 卷积层 | nn.Conv2d | PyTorch 用 cuDNN 高度优化 |
| 交叉熵 | nn.CrossEntropyLoss | PyTorch 融合了 log_softmax 和 nll_loss |
| SGD/Adam | optim.SGD/Adam | PyTorch 支持 foreach 加速和参数分组 |
这个对照表我建议自己动手填一遍,不要直接抄。填的过程中你会发现很多之前没注意到的细节,比如 PyTorch 的CrossEntropyLoss其实包含了log_softmax,所以输入应该是 logits 而不是概率。这个设计是为了数值稳定性,和项目里手写的版本思路一致。
6. 从手写实现到工业级工程的进阶路径
6.1 如何把项目经验迁移到实际工作
这个项目最大的价值,是让你在遇到框架报错时不再慌张。比如你看到RuntimeError: Trying to backward through the graph a second time,如果你手写过计算图,立刻就能明白是计算图被释放了,需要设retain_graph=True或者重新前向。再比如你调DataLoader的num_workers遇到死锁,如果你手写过数据加载,就知道是collate_fn里有不可序列化的对象。
我在实际工作中,用这个项目的知识解决过几个具体问题。一次是模型训练到一半 loss 突然爆炸,我通过打印各层梯度范数,定位到是某一层的权重初始化有问题,换用 Kaiming 初始化后解决。另一次是显存不够,我用梯度累积加混合精度,在单卡上跑起了原本需要多卡的 batch size。
6.2 后续可以深入的方向
手写完基础模块后,有几个方向可以继续深入。一是分布式训练:理解all_reduce、broadcast这些集合通信操作,以及数据并行和模型并行的区别。二是计算图优化:了解算子融合、内存复用、常量折叠这些编译期优化技术。三是自定义算子:用 CUDA 或 Triton 写高性能算子,替换掉框架里的通用实现。
我个人的建议是先把分布式训练搞明白,因为这是工业界最常用的。你可以从手写一个简单的all_reduce开始,用multiprocessing模拟多进程,理解梯度同步的过程。然后再看 PyTorch 的DistributedDataParallel,会发现它的设计思路和你的手写版本一脉相承。
6.3 我踩过的几个印象深刻的坑
第一个坑是广播后的梯度归约。当时实现(B, 1)和(B, T)相加,反向传播时忘了对第 1 维求和,导致梯度形状不对。排查了很久才发现是sum的keepdims没设。这个坑让我养成了写完反向传播就跑数值梯度检查的习惯。
第二个坑是Adam 的偏差校正。我一开始觉得偏差校正可有可无,结果训练 Transformer 时前几百步几乎不收敛。后来加上(1 - beta^t)的校正,收敛速度立刻正常了。这个坑让我明白,优化器里的每个参数都有其存在的理由,不能想当然。
第三个坑是混合精度的 loss scaling。我一开始没加 loss scaling,直接跑float16,结果梯度下溢,参数根本不更新。后来加了动态 loss scaling,训练才正常。这个坑让我对浮点数的动态范围有了直观认识。
6.4 给不同基础读者的学习建议
如果你是完全的新手,我建议先花一周时间把 Python 和 NumPy 的基础打牢,特别是数组的广播和索引操作。然后从标量引擎开始,不要跳步,每个模块都手敲一遍,跑通例子后再看下一个。
如果你有 PyTorch 使用经验但没深入过底层,我建议你重点看张量实现和反向传播部分,把项目里的实现和 PyTorch 的源码对照着看。你会发现很多之前“知其然不知其所以然”的地方,突然就通了。
如果你已经是有经验的工程师,我建议你直接看训练循环和工程优化部分,然后尝试用项目里的思路去优化你现有的代码。比如把某个 Python 循环改成向量化实现,或者给模型加上梯度累积,实测一下性能提升。
最后再分享一个小技巧:这个项目里的代码,我建议你不要直接复制粘贴,而是关掉参考,自己从空白文件开始写。遇到卡住的地方再回去看。这个过程会很痛苦,但效果比照着抄好十倍。我当初就是这么逼自己的,虽然多花了一倍时间,但那些知识点到现在都记得清清楚楚。