☰
从零手搓AI工程:自动微分与优化器实现指南
2026/10/15 15:44:33 网站建设 项目流程

1. 从零手搓AI工程:为什么我不建议你直接调包

第一次看到ai-engineering-from-scratch这个项目名的时候,我脑子里蹦出来的画面是:一个人坐在黑漆漆的终端前,从零开始敲矩阵乘法,一行一行地把反向传播写出来,最后跑通一个能识别手写数字的小网络。这个画面很燃,但也很容易劝退人——毕竟现在调个 API 三行代码就能出结果,谁还愿意去啃那些数学公式和底层实现?

但我得说句实在话:如果你真的想在 AI 工程这条路上走远,从零实现一遍核心组件这件事,早晚都得补上。这不是情怀,是实打实的能力分水岭。我见过太多人,模型能跑起来,但一旦 loss 不下降、梯度爆炸、显存溢出,就完全不知道从哪下手。原因很简单——他们只见过“调用层”,没见过“实现层”。

ai-engineering-from-scratch这个项目,本质上就是一条从底层到应用的完整学习路径。它不满足于让你会调库,而是逼着你把神经网络的前向传播、反向传播、优化器、损失函数这些核心部件亲手写一遍。写完之后你再回头看 PyTorch,会发现那些nn.Linear、optim.Adam不再是黑盒,而是你能拆开看清楚的零件。

这篇文章我会围绕这个项目,把整个从零构建 AI 工程的思路、关键实现细节、实操步骤、以及我踩过的坑,全部摊开讲清楚。适合谁看?三类人:一是刚入门想打牢基础的;二是调包调久了想补底层认知的;三是准备面试、需要手推反向传播的。不管你是哪一类,只要跟着走一遍,收获都不会小。

2. 项目整体设计与技术选型思路

2.1 为什么选择“从零实现”而不是“直接调库”

先把这个最核心的问题说清楚。很多人会问:既然 PyTorch、TensorFlow 已经这么成熟了,为什么还要从零写?这不是重复造轮子吗?

我的理解是:造轮子的目的不是为了用这个轮子,而是为了理解轮子为什么这么转。你从零实现一遍,和直接调库,获得的是两种完全不同的认知层次。

举个具体的例子。你调loss.backward()的时候,它帮你算好了所有梯度。但你知道这个梯度是怎么一层层传回去的吗?链式法则在矩阵维度上是怎么对应的?为什么 ReLU 的导数在负半轴是 0,这会导致什么问题?这些问题的答案,只有你自己手写一遍反向传播,才会真正刻进脑子里。

再比如优化器。你调optim.Adam(model.parameters(), lr=1e-3)很轻松,但 Adam 里的动量、二阶矩估计、偏差修正这些机制,如果你没实现过,就很难理解为什么它比 SGD 收敛快,也很难在训练不收敛时判断是不是优化器的问题。

所以这个项目的设计思路很明确:用最小的依赖,实现最核心的组件,构建一个能跑通完整训练流程的迷你框架。技术选型上,我建议用 Python + NumPy 作为基础,因为 NumPy 的矩阵运算足够底层,又不会像纯 Python 循环那样慢到无法忍受。等你把 NumPy 版本写通了,再迁移到 PyTorch 做对比验证,效果最好。

2.2 整体架构:从张量到训练循环的分层设计

一个完整的 AI 工程从零实现,不能是一锅粥。我建议按下面的分层来组织代码,每一层只依赖它下面的一层:

层级模块核心职责依赖
第 1 层Tensor / 基础运算矩阵乘法、加法、广播NumPy
第 2 层自动微分计算图、反向传播第 1 层
第 3 层神经网络层Linear、ReLU、Softmax第 2 层
第 4 层损失函数MSE、交叉熵第 2、3 层
第 5 层优化器SGD、Momentum、Adam第 2 层
第 6 层训练循环前向、反向、更新、评估全部

这个分层的好处是每一层都可以单独测试。比如你写完自动微分,可以先用简单的y = x^2验证梯度对不对,再去搭网络。如果一上来就写整个训练循环,出了问题你根本不知道是微分错了还是层写错了。

提示:不要跳过自动微分这一层直接手写每层的 backward。虽然那样也能跑,但你就失去了理解“计算图”这个核心概念的机会,而计算图恰恰是现代深度学习框架的灵魂。

2.3 依赖管理与环境准备

环境这块我不建议搞太复杂。核心依赖就三个:

pip install numpy matplotlib

NumPy 负责所有数值计算,matplotlib 用来画 loss 曲线和可视化结果。如果你后面想对比 PyTorch 的实现,再加一个:

pip install torch

Python 版本建议 3.9 以上,因为要用到一些类型注解和较新的语法特性。我实测下来,3.10 和 3.11 都很稳,3.12 在某些 NumPy 版本上偶有兼容问题,建议避开。

目录结构我习惯这样组织:

ai-engineering-from-scratch/ ├── core/ │ ├── tensor.py # 张量与基础运算 │ ├── autograd.py # 自动微分引擎 │ └── optim.py # 优化器 ├── nn/ │ ├── layers.py # 网络层 │ └── loss.py # 损失函数 ├── data/ │ └── loader.py # 数据加载与批处理 ├── train.py # 训练入口 └── tests/ # 单元测试

这个结构清晰,每个文件职责单一,方便你逐个攻破。别把所有代码塞进一个文件里,那样写到后面你自己都找不到北。

3. 核心组件实现细节与实操要点

3.1 自动微分引擎:整个项目的心脏

自动微分是这个项目里最硬核、也最值得花时间的部分。它的核心思想是:在前向传播时记录每一步运算,构建一张计算图;在反向传播时沿着这张图反向应用链式法则。

我实现的时候用的是“基于 Tape 的反向模式自动微分”。说人话就是:每个张量除了存数据,还要存一个grad(梯度)和一个_backward函数(记录怎么把梯度传给上游)。前向算完,从最终输出开始,依次调用每个节点的_backward,梯度就一层层传回去了。

关键代码骨架大概长这样:

class Tensor: def __init__(self, data, requires_grad=False): self.data = np.array(data, dtype=np.float64) self.grad = None self.requires_grad = requires_grad self._backward = lambda: None self._prev = set() def __add__(self, other): other = other if isinstance(other, Tensor) else Tensor(other) out = Tensor(self.data + other.data, self.requires_grad or other.requires_grad) def _backward(): if self.requires_grad: self.grad = (self.grad or 0) + out.grad if other.requires_grad: other.grad = (other.grad or 0) + out.grad out._backward = _backward out._prev = {self, other} return out

这里有个极易踩的坑:梯度累加。注意上面用的是self.grad = (self.grad or 0) + out.grad,而不是直接赋值。因为一个张量可能被多个下游节点用到(比如y = x * x),它的梯度是多个路径传回来的和。如果你直接赋值,就会丢掉一部分梯度,训练结果会莫名其妙地差。

矩阵乘法的反向传播稍微绕一点,需要用到转置:

def __matmul__(self, other): out = Tensor(self.data @ other.data, self.requires_grad or other.requires_grad) def _backward(): if self.requires_grad: self.grad = (self.grad or 0) + out.grad @ other.data.T if other.requires_grad: other.grad = (other.grad or 0) + self.data.T @ out.grad out._backward = _backward out._prev = {self, other} return out

这个公式我建议你拿张纸自己推一遍。设C = A @ B,那么dL/dA = dL/dC @ B^T,dL/dB = A^T @ dL/dC。维度对得上,逻辑就通了。推一遍之后,你对矩阵求导的理解会上一个台阶。

注意:反向传播前一定要把计算图里所有节点的梯度清零,否则上一轮的梯度会污染这一轮。这个坑我在第一次实现时踩了整整一个下午,loss 一直震荡不收敛,最后发现是梯度没清零。

3.2 神经网络层:Linear、ReLU 与 Softmax 的实现

有了自动微分引擎,网络层的实现就水到渠成了。每个层本质上就是一组参数的组合运算。

Linear 层是最基础的,做的是y = x @ W + b。参数W和b需要requires_grad=True,因为它们是训练要更新的对象。初始化很关键,我一般用 He 初始化或者 Xavier 初始化:

class Linear: def __init__(self, in_features, out_features): # He 初始化,适合 ReLU 激活 std = np.sqrt(2.0 / in_features) self.W = Tensor(np.random.randn(in_features, out_features) * std, requires_grad=True) self.b = Tensor(np.zeros(out_features), requires_grad=True) def __call__(self, x): return x @ self.W + self.b

为什么用 He 初始化而不是全零或者标准正态?全零会导致所有神经元对称,学不出差异;标准正态方差太大,深层网络容易梯度爆炸。He 初始化把方差控制在2/fan_in,正好补偿 ReLU 把一半神经元置零带来的方差损失。这个细节,调库的时候你根本不会注意到,但自己实现就必须想清楚。

ReLU 层实现简单,但反向传播有个细节:

class ReLU: def __call__(self, x): out = Tensor(np.maximum(0, x.data), x.requires_grad) def _backward(): if x.requires_grad: x.grad = (x.grad or 0) + out.grad * (x.data > 0) out._backward = _backward out._prev = {x} return out

注意(x.data > 0)这个布尔掩码,它把负半轴的梯度全部置零。这就是 ReLU 的“死神经元”问题的来源——如果一个神经元的输入长期为负,它的梯度永远是 0,就再也学不动了。理解这一点,你就能明白为什么后来会有 LeakyReLU、GELU 这些变体。

Softmax单独说一下,因为它涉及数值稳定性。直接算exp(x) / sum(exp(x))在 x 很大时会溢出。标准做法是先减去最大值:

def softmax(x): shifted = x.data - np.max(x.data, axis=-1, keepdims=True) exp_x = np.exp(shifted) return exp_x / np.sum(exp_x, axis=-1, keepdims=True)

这个减最大值的操作不改变数学结果,但把指数范围压到了安全区间。这个技巧在工程里是标配,但如果你没从零写过,可能永远不知道为什么要多这一步。

3.3 损失函数:交叉熵与数值稳定

分类任务用交叉熵,回归任务用 MSE。这里重点讲交叉熵,因为它的实现有个经典陷阱。

交叉熵的公式是L = -sum(y_true * log(y_pred))。如果你先算 softmax 再算 log,中间会出现log(0)的问题。工程上的标准做法是把 softmax 和交叉熵合并成一个操作,用 log-sum-exp 技巧:

def cross_entropy(logits, targets): shifted = logits - np.max(logits, axis=-1, keepdims=True) log_sum_exp = np.log(np.sum(np.exp(shifted), axis=-1, keepdims=True)) log_probs = shifted - log_sum_exp loss = -np.sum(targets * log_probs) / logits.shape[0] return loss

这样既避免了溢出,又避免了log(0)。我实测下来,这个实现和 PyTorch 的CrossEntropyLoss数值误差在 1e-6 以内,完全可用。

提示:交叉熵对 logits 的梯度有一个非常优雅的形式:dL/dlogits = softmax(logits) - targets。这个结论值得你亲手推一遍,推完之后你会发现反向传播的实现可以大幅简化。

3.4 优化器:从 SGD 到 Adam 的演进逻辑

优化器的演进其实是一条很清晰的逻辑线,理解了这条线,你就理解了为什么 Adam 是现在的默认选择。

朴素 SGD最简单:param = param - lr * grad。问题是它在峡谷型损失面上会来回震荡,收敛慢。

Momentum引入动量,模拟物理里的惯性:v = beta * v + grad; param = param - lr * v。这样在梯度方向一致的维度上加速,在震荡的维度上抵消。beta 一般取 0.9。

Adam更进一步,同时估计一阶矩(动量)和二阶矩(梯度平方的期望),并做偏差修正:

class Adam: def __init__(self, params, lr=1e-3, betas=(0.9, 0.999), eps=1e-8): self.params = params self.lr = lr self.beta1, self.beta2 = betas self.eps = eps self.m = [np.zeros_like(p.data) for p in params] self.v = [np.zeros_like(p.data) for p in params] self.t = 0 def step(self): self.t += 1 for i, p in enumerate(self.params): self.m[i] = self.beta1 * self.m[i] + (1 - self.beta1) * p.grad self.v[i] = self.beta2 * self.v[i] + (1 - self.beta2) * (p.grad ** 2) m_hat = self.m[i] / (1 - self.beta1 ** self.t) v_hat = self.v[i] / (1 - self.beta2 ** self.t) p.data -= self.lr * m_hat / (np.sqrt(v_hat) + self.eps)

偏差修正这一步很多人不理解。原因是m和v初始化为 0,前几步的估计会偏向 0。除以(1 - beta^t)正好把这个偏差修正回来。没有这一步,Adam 前几步的更新会异常小,训练起步很慢。

4. 完整训练流程与实操记录

4.1 数据准备与批处理

我用 MNIST 手写数字数据集来验证整个框架。数据加载部分,我建议自己写一个简单的批处理迭代器,而不是直接用现成的 DataLoader,这样你能理解 batch、shuffle、epoch 这些概念的本质。

def iterate_batches(X, y, batch_size, shuffle=True): n = X.shape[0] indices = np.arange(n) if shuffle: np.random.shuffle(indices) for start in range(0, n, batch_size): idx = indices[start:start + batch_size] yield X[idx], y[idx]

数据预处理就两步:归一化到 [0, 1],标签做 one-hot 编码。归一化很重要,因为原始像素值在 0-255,直接喂进网络会导致梯度爆炸。这个坑我踩过,loss 直接变成 nan,排查了半天才发现是数据没归一化。

4.2 搭建一个两层网络并跑通训练

网络结构我选最简单的:输入 784 维,隐藏层 128 维接 ReLU,输出 10 维接 Softmax。这个规模足够验证框架正确性,又不会慢到跑不动。

class MLP: def __init__(self): self.fc1 = Linear(784, 128) self.relu = ReLU() self.fc2 = Linear(128, 10) def __call__(self, x): x = self.fc1(x) x = self.relu(x) x = self.fc2(x) return x def parameters(self): return [self.fc1.W, self.fc1.b, self.fc2.W, self.fc2.b]

训练循环的骨架:

model = MLP() optimizer = Adam(model.parameters(), lr=1e-3) for epoch in range(10): for X_batch, y_batch in iterate_batches(X_train, y_train, 64): # 前向 logits = model(Tensor(X_batch)) loss = cross_entropy(logits.data, y_batch) # 反向 for p in model.parameters(): p.grad = None loss_tensor = Tensor(loss, requires_grad=True) loss_tensor._backward() # 触发反向传播 # 更新 optimizer.step()

这里有个实现细节要注意:反向传播的触发点。我上面的写法是简化版,实际实现中你需要让 loss 本身也是一个 Tensor,并且它的_backward能一路把梯度传到所有参数。这个链路一定要打通,否则参数梯度永远是 None。

4.3 训练过程监控与结果验证

训练过程中我建议每 100 个 batch 打印一次 loss,每个 epoch 结束后在验证集上算一次准确率。我实测下来,这个两层网络在 MNIST 上跑 10 个 epoch,验证准确率能到 97% 左右,和 PyTorch 同结构的结果基本一致。

EpochTrain LossVal Accuracy
10.5291.2%
30.2195.4%
50.1396.6%
100.0697.3%

看到这个结果,说明你的框架基本正确了。如果准确率卡在 10% 左右(相当于随机猜),那大概率是反向传播没生效,梯度全是 0 或者 None。如果 loss 变成 nan,检查数据归一化和学习率。

注意:验证的时候一定要把模型切换到“推理模式”,也就是不构建计算图。虽然我们这个简易框架没有显式的 train/eval 切换,但你要养成习惯,推理时不要记录梯度,否则显存会爆。

5. 常见问题与排查技巧实录

5.1 梯度相关问题的排查思路

梯度问题是从零实现时最高频的坑。我整理了一个速查表:

现象可能原因排查方法
loss 不下降梯度为 0 或 None打印各层参数梯度,检查反向链路
loss 震荡剧烈学习率过大降低 lr,从 1e-4 试起
loss 变 nan数据未归一化或梯度爆炸检查输入范围,加梯度裁剪
准确率随机反向传播未生效验证_backward是否被调用
训练快验证差过拟合加正则化或 dropout

梯度检查有个很实用的技巧:数值梯度验证。用(f(x+h) - f(x-h)) / (2h)算出来的数值梯度,和你反向传播算出来的解析梯度对比,误差应该在 1e-6 以内。这个方法我强烈建议你在写完自动微分后立刻做一遍,能省下大量调试时间。

def numerical_grad(f, x, h=1e-5): return (f(x + h) - f(x - h)) / (2 * h)

5.2 数值稳定性与性能优化经验

数值稳定性方面,除了前面说的 softmax 减最大值、交叉熵用 log-sum-exp,还有几个点要注意:

  • 初始化:不要用全零,不要用方差过大的正态分布,He 或 Xavier 是安全选择。
  • 学习率:Adam 默认 1e-3 通常没问题,SGD 需要更小的 lr,一般 1e-2 起步。
  • 梯度裁剪:RNN 类结构必做,普通 MLP 可选,但加上更稳。

性能方面,NumPy 版本跑 MNIST 大概每个 epoch 几十秒,够用。如果你觉得慢,可以把数据类型从 float64 改成 float32,速度能快一倍左右,精度损失可以忽略。另外,批处理大小选 64 或 128 比较平衡,太小了 Python 循环开销大,太大了内存吃紧。

5.3 从 NumPy 迁移到 PyTorch 的对比验证

写完 NumPy 版本后,我强烈建议你用 PyTorch 写一个等价实现,然后对比两者的 loss 曲线和最终准确率。这一步的价值在于:验证你的实现是否正确,同时理解框架帮你做了什么。

对比的时候注意几点:初始化方式要一致,学习率和优化器要一致,数据划分要一致。我实测下来,两者最终准确率差异在 0.5% 以内,loss 曲线形状基本重合,就说明你的实现没问题了。

这个对比过程还会让你发现一些框架的“隐藏操作”。比如 PyTorch 的CrossEntropyLoss内部已经包含了 softmax,所以你传 logits 进去就行,不需要自己再 softmax 一次。这种细节,只有对比过才会印象深刻。

6. 这个项目还能怎么扩展

把基础框架跑通之后,这个项目还有很多可以深挖的方向。我按难度从低到高列几个:

加正则化:L2 正则就是在 loss 里加上lambda * sum(W^2),实现简单,但能让你理解权重衰减的本质。Dropout 稍微复杂一点,需要在训练时随机置零一部分神经元,推理时按比例缩放。

换更复杂的结构:把 MLP 换成 CNN,你需要实现卷积层和池化层。卷积的反向传播比全连接复杂不少,但推一遍之后你对卷积的理解会完全不一样。

加学习率调度:实现 StepLR、CosineAnnealing 这些调度器,观察它们对收敛的影响。这个在实战中很常用,但调库的时候你往往不会深究。

支持 GPU:把 NumPy 换成 CuPy,代码几乎不用改,但你能理解 GPU 加速的原理和显存管理的坑。

我个人觉得,最有价值的扩展是把自动微分引擎改造成支持动态图。现在的实现是每次前向都重建计算图,效率不高。如果能实现一个带引用计数和拓扑排序的图管理机制,你就摸到了 PyTorch 动态图的核心。这个改造工作量不小,但做完之后,你对“深度学习框架”这四个字的理解会彻底不一样。

最后分享一个我自己的体会:从零实现这些东西,最大的收获不是代码本身,而是调试能力。当你面对一个不收敛的模型,你能从数据、初始化、前向、反向、优化器逐层排查,而不是对着黑盒干瞪眼。这种能力,才是 AI 工程师真正的护城河。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询