拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从零手搓AI工程:自动微分与神经网络实现指南

从零手搓AI工程:自动微分与神经网络实现指南

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

第一次看到ai-engineering-from-scratch这个项目名的时候,我脑子里蹦出来的画面是:一个人坐在终端前,从矩阵乘法开始,一行一行把神经网络敲出来,中间不碰任何高级框架。后来我自己真去走了一遍这条路,才发现它真正的价值不在于“不用框架”,而在于把AI工程里那些被封装掉的决策点重新暴露出来。

这个项目适合谁?我的判断是三类人:第一类是做应用开发、天天调API但对模型内部机制一知半解的工程师;第二类是准备面试、被问到“反向传播怎么推导”就卡壳的求职者;第三类是想自己搭一套小规模训练/推理流水线、但被各种框架的抽象层绕晕的独立开发者。如果你属于“能跑通demo但说不清为什么”的状态,那从零实现一遍是最快的补课方式。

我先把结论摆在这:从零实现的目的不是替代PyTorch,而是让你在以后用PyTorch时,知道每一行代码背后发生了什么。这个项目标题里的“from scratch”不是炫技,是一种学习策略。接下来我会把整个工程拆成设计思路、核心细节、实操流程、踩坑排查四个部分,把我自己走这条路时踩过的坑和总结的技巧都摊开讲。

2. 整体设计与技术选型:为什么是这套组合

2.1 从零实现的边界在哪里

“从零”这个词很容易被误解。有人以为是从二进制开始,有人以为连NumPy都不能用。我的实践结论是:从零的合理边界是“不依赖自动微分和神经网络高层API”,但允许使用NumPy做张量运算。原因很实际——如果你连矩阵乘法都手写Python循环,一个1024x1024的矩阵乘就要跑几十秒,根本没法迭代调试,学习效率会被性能问题拖垮。

所以这个项目的技术栈我建议这样定:

层级选择理由
数值计算NumPy提供张量存储和基础运算,但不提供自动微分
自动微分自己实现这是从零工程的核心,必须手写
网络层自己实现Linear、激活函数、损失函数全部手写
优化器自己实现SGD、Momentum、Adam逐个手写
训练循环自己实现前向、反向、参数更新全流程手写
数据加载NumPy + 简单批处理不引入DataLoader抽象

这套组合的好处是:每一个环节你都能看到数据是怎么流动的。用PyTorch的时候,loss.backward()一行就搞定,但这一行背后是计算图的构建、拓扑排序、链式法则的逐节点应用。自己实现一遍,你才会真正理解为什么有些操作会断开梯度、为什么in-place操作会出问题。

2.2 自动微分的设计取舍

自动微分有两种主流实现方式:前向模式和反向模式。前向模式适合输入维度远小于输出维度的场景,反向模式适合输出维度远小于输入维度的场景。深度学习里参数动辄百万千万,损失却是一个标量,所以反向模式是必然选择。

反向模式的核心是构建计算图。每个张量操作都会生成一个节点,记录输入、输出和局部梯度。反向传播时从损失节点出发,按拓扑逆序把梯度传回去。这里有个关键设计决策:用动态图还是静态图。

我选的是动态图,也就是每次前向传播都重新构建计算图。原因很简单——动态图调试友好,你可以在任意位置打断点看中间结果,而且实现起来代码量小得多。静态图需要先定义再运行,对学习来说反而增加了理解成本。PyTorch早期能火起来,动态图功不可没。

具体实现上,我设计了一个Tensor类,它有两个核心属性:data存数值,grad存梯度。每个Tensor还记录_backward函数和_prev集合(前驱节点)。前向运算时,除了算出结果,还要定义好这个结果如何把梯度传回给输入。这就是所谓的“定义局部梯度”。

2.3 为什么不用现成的自动微分库

有人会问:JAX、Autograd这些库不也能做自动微分吗,为什么还要自己写?我的回答是:用库和写库是两种完全不同的学习深度。用Autograd,你知道有grad函数可以求导;写一遍自动微分,你知道链式法则在计算图上是怎么一步步执行的,知道为什么需要拓扑排序,知道为什么有些操作要特殊处理。

而且自己写一遍之后,再去看PyTorch的autograd源码,会发现思路完全一致,只是工程实现更复杂、性能优化更极致。这种“看穿抽象层”的感觉,是直接调包永远给不了的。

3. 核心细节解析:自动微分的实现要点

3.1 Tensor类的设计

Tensor类是整个工程的基石。我把它设计得尽量简洁,只保留必要属性:

class Tensor: def __init__(self, data, _children=(), _op=''): self.data = np.array(data, dtype=np.float64) self.grad = np.zeros_like(self.data) self._backward = lambda: None self._prev = set(_children) self._op = _op

这里有几个细节值得说。第一,data强制转成float64,因为整数类型在梯度计算时会出问题,而且float32在累加时精度损失明显,调试阶段用float64更稳。第二,grad初始化为全零,形状和data一致。第三,_backward默认是空函数,具体运算会覆盖它。第四,_prev用集合存前驱节点,反向传播时用来做拓扑排序。

注意:_prev用set而不是list,是因为同一个张量可能被多个路径引用,用集合可以自动去重。但这也带来一个问题——集合是无序的,拓扑排序时需要额外处理。

3.2 前向运算与局部梯度定义

以加法为例,前向运算很简单,但反向传播需要定义清楚:

def __add__(self, other): other = other if isinstance(other, Tensor) else Tensor(other) out = Tensor(self.data + other.data, (self, other), '+') def _backward(): self.grad += out.grad other.grad += out.grad out._backward = _backward return out

这里的关键是梯度累加而不是赋值。为什么?因为一个张量可能在前向传播中被多次使用,每次使用都会贡献一份梯度,这些梯度必须累加。如果写成赋值,就会丢失其他路径的梯度贡献。这是新手最容易犯的错误之一。

乘法稍微复杂一点:

def __mul__(self, other): other = other if isinstance(other, Tensor) else Tensor(other) out = Tensor(self.data * other.data, (self, other), '*') def _backward(): self.grad += other.data * out.grad other.grad += self.data * out.grad out._backward = _backward return out

乘法的局部梯度是“对方的数值乘以输出的梯度”。这个规则来自链式法则:d(a*b)/da = b,所以a的梯度是b * out.grad。理解这一点,后面所有运算的反向传播都是同样的套路。

3.3 反向传播的拓扑排序

反向传播不能简单地递归调用_backward,因为一个节点可能被多个后续节点依赖,必须等所有依赖它的节点都处理完,才能处理它自己。这就是拓扑排序的作用。

def backward(self): topo = [] visited = set() def build_topo(v): if v not in visited: visited.add(v) for child in v._prev: build_topo(child) topo.append(v) build_topo(self) self.grad = np.ones_like(self.data) for node in reversed(topo): node._backward()

这段代码的逻辑是:先深度优先遍历整个计算图,把节点按“依赖顺序”排好,然后逆序执行_backward。self.grad初始化为全1,是因为损失函数对标量求导,自身梯度是1。

实操心得:拓扑排序用递归实现简单,但计算图很深时会爆栈。生产级实现会用迭代加显式栈,但学习阶段递归足够,代码可读性更好。

3.4 广播机制的处理

NumPy的广播是个好东西,但自动微分里处理广播很麻烦。比如形状(3, 4)的张量加上形状(4,)的张量,结果形状是(3, 4)。反向传播时,(4,)那个张量的梯度需要把(3, 4)的梯度在第一个维度上求和,才能变回(4,)。

def __add__(self, other): other = other if isinstance(other, Tensor) else Tensor(other) out = Tensor(self.data + other.data, (self, other), '+') def _backward(): self.grad += out.grad other.grad += unbroadcast(out.grad, other.data.shape) out._backward = _backward return out def unbroadcast(grad, shape): while grad.ndim > len(shape): grad = grad.sum(axis=0) for i, dim in enumerate(shape): if dim == 1: grad = grad.sum(axis=i, keepdims=True) return grad

unbroadcast函数的作用是把梯度“压回”原始形状。逻辑是:先去掉多余的维度(在维度0上求和),再把大小为1的维度求和并保持维度。这个函数是广播场景下自动微分的核心工具,写一次到处用。

4. 实操流程:从零搭一套可训练的网络

4.1 环境准备与项目结构

环境很简单,一个Python 3.8+,装个NumPy就行:

pip install numpy

项目结构我建议这样组织,方便后续扩展:

ai-engineering-from-scratch/ ├── engine/ │ ├── tensor.py # Tensor类和自动微分 │ ├── nn.py # 网络层和损失函数 │ └── optim.py # 优化器 ├── data/ │ └── loader.py # 数据加载 ├── train.py # 训练脚本 └── tests/ └── test_grad.py # 梯度校验

这个结构的好处是职责清晰:engine目录放核心引擎,data放数据处理,train.py是入口。后面想加新层、新优化器,直接往对应文件里加就行。

4.2 实现网络层

先实现最基础的Linear层:

class Linear: def __init__(self, in_features, out_features): # He初始化,适合ReLU激活 scale = np.sqrt(2.0 / in_features) self.weight = Tensor(np.random.randn(in_features, out_features) * scale) self.bias = Tensor(np.zeros(out_features)) def __call__(self, x): return x @ self.weight + self.bias def parameters(self): return [self.weight, self.bias]

初始化用He初始化而不是全零或标准正态,是因为全零会导致对称性问题(所有神经元学到同样的东西),标准正态在深层网络里会导致梯度消失或爆炸。He初始化把方差控制在2/fan_in,配合ReLU激活能让信号在前向和反向传播中保持稳定。

激活函数我实现ReLU和Tanh两个:

class ReLU: def __call__(self, x): out = Tensor(np.maximum(0, x.data), (x,), 'relu') def _backward(): x.grad += (x.data > 0) * out.grad out._backward = _backward return out def parameters(self): return []

ReLU的反向传播是“输入大于0的地方梯度原样传回,否则为0”。这个实现里(x.data > 0)生成布尔数组,乘以out.grad就实现了门控效果。

损失函数用交叉熵,它比均方误差更适合分类任务:

def cross_entropy(logits, targets): # logits: (batch, num_classes) # targets: (batch,) 整数标签 max_logits = np.max(logits.data, axis=1, keepdims=True) exp_logits = np.exp(logits.data - max_logits) probs = exp_logits / np.sum(exp_logits, axis=1, keepdims=True) batch_size = logits.data.shape[0] loss_data = -np.log(probs[np.arange(batch_size), targets] + 1e-12).mean() out = Tensor(loss_data, (logits,), 'cross_entropy') def _backward(): grad = probs.copy() grad[np.arange(batch_size), targets] -= 1 grad /= batch_size logits.grad += grad * out.grad out._backward = _backward return out

这里有个数值稳定性的技巧:减去最大值再取指数。因为exp对大数会溢出,减去最大值后最大的指数是0,不会溢出。这个技巧在softmax实现里是标配,但很多教程不讲,新手直接np.exp(logits)很容易遇到inf。

4.3 实现优化器

SGD最简单:

class SGD: def __init__(self, params, lr=0.01): self.params = params self.lr = lr def step(self): for p in self.params: p.data -= self.lr * p.grad def zero_grad(self): for p in self.params: p.grad = np.zeros_like(p.data)

Adam稍微复杂,但它是实践中最常用的优化器:

class Adam: def __init__(self, params, lr=0.001, 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) def zero_grad(self): for p in self.params: p.grad = np.zeros_like(p.data)

Adam的核心是一阶矩和二阶矩的指数移动平均,加上偏差修正。偏差修正是因为初始时m和v都是0,前几步的估计会偏向0,除以(1 - beta^t)可以修正这个偏差。这个细节很多手写实现会漏掉,导致训练初期不稳定。

4.4 组装训练循环

把上面这些拼起来,就是一个完整的训练流程:

# 构建模型 model = [ Linear(784, 128), ReLU(), Linear(128, 64), ReLU(), Linear(64, 10) ] # 收集参数 params = [] for layer in model: params.extend(layer.parameters()) # 优化器 optimizer = Adam(params, lr=0.001) # 训练循环 for epoch in range(10): for batch_x, batch_y in data_loader: # 前向传播 x = Tensor(batch_x) for layer in model: x = layer(x) # 计算损失 loss = cross_entropy(x, batch_y) # 反向传播 optimizer.zero_grad() loss.backward() # 更新参数 optimizer.step() print(f"Epoch {epoch}, Loss: {loss.data:.4f}")

这个循环里,zero_grad必须在backward之前调用,否则梯度会跨批次累加。这是新手最常见的bug之一——忘了清零梯度,结果loss不降反升。

4.5 梯度校验:确保实现正确

手写自动微分最容易出错的地方是局部梯度定义。我强烈建议写一个梯度校验函数,用数值梯度对比解析梯度:

def check_gradient(f, x, eps=1e-6): # 数值梯度 num_grad = np.zeros_like(x.data) it = np.nditer(x.data, flags=['multi_index']) while not it.finished: idx = it.multi_index old = x.data[idx] x.data[idx] = old + eps f_pos = f(x).data x.data[idx] = old - eps f_neg = f(x).data x.data[idx] = old num_grad[idx] = (f_pos - f_neg) / (2 * eps) it.iternext() # 解析梯度 x.grad = np.zeros_like(x.data) out = f(x) out.backward() # 对比 diff = np.abs(num_grad - x.grad).max() print(f"最大误差: {diff:.2e}") return diff < 1e-5

数值梯度用中心差分(f(x+eps) - f(x-eps)) / (2*eps),精度比前向差分高一个量级。eps取1e-6是个经验值,太小会受浮点误差影响,太大会引入截断误差。校验通过的标准是最大误差小于1e-5,如果超过这个值,说明局部梯度定义有问题。

实操心得:梯度校验不要只测一个函数,要把每个运算单独测一遍,再测组合运算。我当初就是单独测加法乘法都对,组合起来就错,最后发现是广播的unbroadcast写漏了一个维度。

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

5.1 梯度爆炸与消失

训练深层网络时,梯度可能指数级增大或缩小。判断方法很简单:打印每层参数的梯度范数,如果某层梯度范数超过1e3或者小于1e-7,基本就是这个问题。

解决方案有几个:第一,用He初始化或Xavier初始化,根据激活函数选择;第二,加BatchNorm层,把每层输入归一化;第三,用梯度裁剪,把梯度范数限制在阈值内:

def clip_gradients(params, max_norm=1.0): total_norm = np.sqrt(sum(np.sum(p.grad ** 2) for p in params)) if total_norm > max_norm: scale = max_norm / (total_norm + 1e-6) for p in params: p.grad *= scale

梯度裁剪在RNN和Transformer训练里几乎是标配,能有效防止梯度爆炸导致的训练崩溃。

5.2 损失不下降的排查顺序

损失不下降是最常见的问题,我总结了一个排查顺序:

排查项检查方法常见原因
梯度是否清零打印两次backward后的grad忘记zero_grad
梯度是否有效打印grad的范数局部梯度写错
学习率是否合适尝试1e-2到1e-5太大震荡,太小不收敛
数据是否正常打印输入输出范围数据未归一化
标签是否对齐打印几个样本标签和输入错位
初始化是否合理打印初始loss全零初始化

按这个顺序排查,90%的问题能在10分钟内定位。我踩过最坑的一次是数据没归一化,输入值在0到255之间,导致第一层梯度巨大,训练直接发散。归一化到0到1之后立刻正常。

5.3 数值稳定性问题

除了前面说的softmax减最大值,还有几个地方要注意。第一,log(0)会得到-inf,所以交叉熵里要加1e-12。第二,sqrt(0)的梯度是无穷大,Adam里的eps就是防这个。第三,除法运算的反向传播要小心分母为0:

def __truediv__(self, other): other = other if isinstance(other, Tensor) else Tensor(other) out = Tensor(self.data / other.data, (self, other), '/') def _backward(): self.grad += (1 / other.data) * out.grad other.grad += (-self.data / other.data ** 2) * out.grad out._backward = _backward return out

如果other.data里有0,这里会出inf。实践中要么保证输入非零,要么加个eps。

5.4 性能优化技巧

纯NumPy实现性能肯定不如PyTorch,但有几个技巧能显著提速。第一,批处理,一次处理一个batch而不是单样本,能充分利用NumPy的向量化。第二,避免不必要的拷贝,比如p.data -= lr * p.grad是原地操作,比p.data = p.data - lr * p.grad快。第三,用np.float32替代float64,内存和计算量都减半,但调试阶段还是建议float64。

我实测下来,一个784-128-64-10的MLP,MNIST数据集,用float32加批处理,一个epoch大概20秒,完全能接受。如果慢到无法忍受,先检查是不是哪里写了Python循环而不是向量化操作。

5.5 常见问题速查表

现象可能原因解决方法
loss变成nan学习率太大或数值溢出降低学习率,检查softmax稳定性
loss不降梯度未清零或初始化问题检查zero_grad,换He初始化
训练集准确率高但测试集低过拟合加Dropout或L2正则
梯度全为0激活函数饱和或梯度断链换ReLU,检查计算图连接
训练速度慢未批处理或用了float64批处理,换float32
参数不更新优化器没step或梯度为0检查step调用和梯度范数

这张表是我自己踩坑总结的,基本覆盖了从零实现AI工程时80%的问题。遇到问题先查表,能省很多时间。

6. 从零实现之后:这套东西还能怎么用

走完一遍从零实现,最大的收获不是“我会手写神经网络了”,而是对框架的每一个API都有了直觉。比如看到torch.nn.CrossEntropyLoss,我知道它内部做了softmax加负对数似然,知道它为什么要求输入是logits而不是概率。看到optimizer.zero_grad(),我知道不清零会导致梯度累加。这种直觉,是直接调包永远建立不起来的。

这套代码后续可以往几个方向扩展。第一,加卷积层和池化层,理解CNN的参数量计算和感受野。第二,加RNN或LSTM,理解序列建模里的梯度流动。第三,加BatchNorm和Dropout,理解训练和推理阶段的差异。第四,把NumPy换成自己写的CUDA kernel,理解GPU并行计算。每扩展一个方向,你对AI工程的理解就深一层。

我个人在实际操作中的体会是:从零实现一遍,比看十篇教程都管用。因为看教程你是被动的,实现的时候你是主动的,每一个bug都在逼你理解底层原理。我当初卡在广播的梯度处理上整整一个下午,但搞懂之后,再看任何涉及广播的代码都一目了然。这种“卡住再突破”的过程,才是真正学到东西的时刻。

最后分享一个小技巧:实现完之后,用同一组数据跑一遍你的实现和PyTorch,对比loss曲线和最终准确率。如果两者接近,说明你的实现是对的;如果差很多,说明某个环节有问题。这个对比测试是检验实现质量的终极标准,比任何单元测试都靠谱。

返回列表