1. 为什么我要从零写一个深度学习框架
动手写框架这件事,我在刚入行那几年是想都不敢想的。那时候觉得 TensorFlow、PyTorch 这种体量的东西,背后是几百个博士和几千个工程师的集体智慧,跟我一个普通开发者没什么关系。直到有一次,我在调试一个自定义算子的时候,发现底层报错信息完全看不懂,翻源码翻了整整两天才定位到一个张量步长的问题。那次经历让我意识到,如果一直停留在“调包”的层面,遇到真正棘手的问题时就会非常被动。
于是我开始尝试自己写一个极简的深度学习框架。目的不是造一个能替代 PyTorch 的轮子,而是通过亲手实现每一个核心模块,把自动求导、计算图、张量运算这些概念从“知道”变成“理解”。这个系列的第一篇,我想先把架构设计这件事聊透。因为架构决定了后面所有代码的组织方式,如果一开始方向就偏了,写到后面会非常痛苦。
这篇文章适合谁看?如果你已经会用 PyTorch 或 TensorFlow 训练模型,但对反向传播的底层实现只有模糊印象;如果你能看懂 Python 代码,但不确定一个框架从零开始应该先写什么、后写什么;如果你也想通过造轮子来加深理解,但不知道从哪里下手——那这篇内容应该能帮到你。我会把架构设计的思路、模块划分的理由、以及我在这个过程中踩过的坑,尽量完整地分享出来。
2. 整体架构设计思路拆解
2.1 先想清楚:框架到底要解决什么问题
在动手画架构图之前,我强迫自己先用一句话回答:这个框架的输入是什么,输出是什么,中间经历了什么。答案其实很朴素——输入是数据和模型定义,输出是梯度,中间的核心是“如何自动地、正确地计算出梯度”。
围绕这个核心,框架需要具备几个基本能力。第一是张量运算,因为所有神经网络的计算最终都归结为矩阵乘法、加法、激活函数这些操作。第二是自动求导,这是深度学习框架区别于普通数值计算库的关键。第三是计算图的构建与执行,它决定了运算的顺序和依赖关系。第四是参数管理与优化器,负责根据梯度更新权重。
把这四件事想清楚之后,架构的轮廓就出来了。我选择的是基于动态图的 define-by-run 模式,也就是每次前向传播时实时构建计算图,而不是像早期 TensorFlow 那样先定义静态图再执行。这个选择的原因后面会详细说,但简单来讲,动态图更符合 Python 的使用直觉,调试起来也方便得多。
2.2 为什么选择动态图而不是静态图
静态图的思路是“先定义、后运行”。你先把整个计算流程描述成一个图结构,框架再对这个图进行优化和执行。这种方式的优势在于图一旦确定,就可以做大量的编译期优化,比如算子融合、内存复用、并行调度。但它的缺点也很明显:调试困难,因为你看不到中间结果;灵活性差,因为图的结构在运行前就固定了。
动态图则是“边运行、边建图”。每次前向传播的时候,框架根据实际执行的操作动态地记录计算历史。这样做的好处是你可以像写普通 Python 代码一样写模型,用 print 打印中间结果,用断点调试,非常直观。代价是失去了部分全局优化的机会,但对于一个学习性质的框架来说,可调试性和可理解性远比极致性能重要。
我最终选择动态图,还有一个很实际的原因:实现起来代码量更少,逻辑更清晰。静态图需要你先设计一套图描述语言或者 IR,然后再写一个执行引擎,复杂度直接翻倍。而动态图只需要在每次运算时记录一下操作和输入输出关系,后面反向传播时沿着记录链回溯就行了。
2.3 核心模块的划分与依赖关系
整个框架我划分成了五个核心模块,它们之间的依赖关系是自底向上的。
最底层是张量模块,负责数据的存储和基础运算。它不依赖任何其他模块,是整个框架的基石。往上是自动求导模块,它在张量运算的基础上记录计算历史,并实现反向传播。再往上是计算图模块,负责管理节点之间的依赖关系,实际上自动求导模块和计算图模块是紧密耦合的,我倾向于把它们放在一起考虑。
然后是神经网络层模块,它基于张量和自动求导,封装了全连接层、激活层、损失函数等常用组件。最上层是优化器模块,它接收自动求导计算出的梯度,按照一定的策略更新参数。
这种分层设计的好处是每一层只依赖它下面的层,不会出现循环依赖。我在早期版本中曾经把优化器和自动求导混在一起写,结果代码很快就变得难以维护。后来把优化器独立出来之后,整个结构清晰了很多。
2.4 一个关键决策:张量是否要支持 GPU
这个问题我想了很久。支持 GPU 意味着需要写 CUDA 核函数或者调用 cuBLAS 之类的库,代码复杂度会急剧上升。而且对于学习框架设计原理来说,GPU 并不是必须的。CPU 上的实现同样能讲清楚自动求导和计算图的逻辑。
所以我最终决定,第一版只支持 CPU,用 NumPy 作为底层的数值计算库。这样做的好处是我可以把精力集中在框架本身的架构上,而不是被 GPU 的内存管理和核函数编写分散注意力。当然,架构上我会预留扩展点,比如在张量类中抽象出一个 device 属性,后面如果想加 GPU 支持,只需要实现对应的后端即可。
提示:如果你也想自己写一个框架,我强烈建议第一版不要碰 GPU。先把 CPU 版本的自动求导跑通,理解清楚计算图的构建和反向传播的链路,再去考虑硬件加速的事情。
3. 核心模块的详细设计与实操要点
3.1 张量模块:不只是 NumPy 的包装
张量是框架中最基础的数据结构。它需要存储两部分信息:数据本身和元信息。数据就是一个多维数组,元信息包括形状、数据类型、是否需要梯度等。
我设计的张量类大致长这样:内部用一个 NumPy 数组来存实际数据,同时维护一个requires_grad标志来表示这个张量是否需要计算梯度。如果requires_grad为 True,还需要一个grad属性来存储反向传播时累积的梯度值。
这里有一个容易忽略的细节:张量的运算结果是否需要梯度,取决于它的输入中是否有需要梯度的张量。比如c = a + b,如果 a 需要梯度而 b 不需要,那么 c 也需要梯度。这个判断逻辑必须写在每一个运算函数里,否则反向传播链就会断掉。
另一个关键点是计算历史的记录。每次对张量做运算时,除了返回结果,还需要记录“这个结果是由哪个操作、哪些输入产生的”。我采用的方式是在张量对象上挂一个_ctx属性,里面保存操作类型和输入张量的引用。这样在反向传播时,就可以沿着这个引用链一路回溯。
class Tensor: def __init__(self, data, requires_grad=False): self.data = np.array(data, dtype=np.float32) self.requires_grad = requires_grad self.grad = None self._ctx = None # 记录计算历史 def __add__(self, other): other = other if isinstance(other, Tensor) else Tensor(other) out = Tensor(self.data + other.data, requires_grad=self.requires_grad or other.requires_grad) if out.requires_grad: out._ctx = ("add", self, other) return out上面这段代码是整个框架最核心的几行之一。它展示了张量运算的基本模式:计算结果、判断是否需要梯度、记录计算历史。后面的所有运算,包括乘法、矩阵乘法、激活函数,都遵循同样的模式。
注意:在记录计算历史时,一定要保存输入张量的引用,而不是只保存数据。因为反向传播时需要用到输入张量的值来计算梯度,如果只保存数据副本,不仅浪费内存,还可能因为数据被修改而导致梯度计算错误。
3.2 自动求导模块:反向传播的引擎
自动求导的核心思想是链式法则。如果y = f(x),那么dy/dx = f'(x)。如果z = g(y),那么dz/dx = g'(y) * f'(x)。在计算图中,每个节点只需要知道如何计算自己相对于输入的局部梯度,然后把这些局部梯度沿着图反向传播回去就行了。
我在实现时采用了一种“反向拓扑排序”的策略。具体来说,从最终输出节点开始,按照计算历史的逆序依次访问每个节点。对于每个节点,根据它记录的操作类型,调用对应的反向函数,计算出输入张量的梯度,并累积到输入张量的grad属性上。
这里有一个关键问题:梯度累积。如果一个张量被多个后续操作使用,那么它的梯度应该是所有路径传回来的梯度之和。比如y = x * 2和z = x * 3,那么x的梯度应该是dy/dx + dz/dx = 2 + 3 = 5。所以在反向传播时,每次计算出梯度后,不是直接赋值,而是累加。
def backward(self, grad=None): if grad is None: grad = np.ones_like(self.data) if self.grad is None: self.grad = grad else: self.grad += grad if self._ctx is None: return op, left, right = self._ctx if op == "add": if left.requires_grad: left.backward(grad) if right.requires_grad: right.backward(grad) elif op == "mul": if left.requires_grad: left.backward(grad * right.data) if right.requires_grad: right.backward(grad * left.data) # 其他操作类似上面这段代码展示了反向传播的基本框架。对于加法操作,梯度直接传递给两个输入;对于乘法操作,梯度需要乘以另一个输入的值。这就是链式法则在计算图上的具体体现。
实操心得:在实现反向传播时,最容易出错的地方是梯度的形状。比如矩阵乘法
C = A @ B,A 的形状是 (m, n),B 的形状是 (n, p),C 的形状是 (m, p)。那么dA = dC @ B.T,dB = A.T @ dC。如果形状对不上,NumPy 会报错或者产生错误的结果。我建议每实现一个操作的反向传播,都先用数值梯度验证一下。
3.3 计算图模块:动态构建与内存管理
在动态图模式下,计算图不是预先定义的,而是随着前向传播的进行逐步构建的。每次运算都会创建一个新的节点,节点之间通过输入输出关系连接。这种“边运行边建图”的方式,使得图的结构完全由实际的代码执行路径决定。
这样做的一个直接后果是:每次前向传播都会构建一张新的计算图。对于训练循环来说,这意味着每处理一个 batch 的数据,都会生成一张新的图。这听起来很浪费,但实际上正是动态图的灵活性所在。因为不同的 batch 可能有不同的序列长度、不同的分支路径,静态图很难处理这种变化。
内存管理是计算图模块中另一个需要认真考虑的问题。在前向传播过程中,每个中间结果都需要保存下来,因为反向传播时要用到。如果模型很深,中间结果会占用大量内存。PyTorch 的做法是在反向传播过程中逐步释放不再需要的中间结果,我在框架中也采用了类似的策略:当一个节点的梯度计算完成后,如果它的所有下游节点都已经处理完毕,就可以释放它的数据。
不过在第一版中,我并没有实现复杂的内存回收机制,而是简单地依赖 Python 的垃圾回收。因为对于学习性质的框架来说,先把逻辑跑通比优化内存更重要。如果你后面想把这个框架用于实际训练,内存管理是一个必须认真对待的问题。
3.4 神经网络层模块:从张量到模型
有了张量和自动求导,搭建神经网络层就变成了一个相对直接的事情。一个全连接层本质上就是y = x @ W + b,其中 W 和 b 是需要梯度的参数。激活函数如 ReLU 就是y = max(0, x),它的反向传播也很简单:如果输入大于 0,梯度直接传递;否则梯度为 0。
我在设计层模块时,采用了一个基类Layer,它定义了forward和parameters两个接口。所有具体的层都继承这个基类,实现自己的前向逻辑,并返回需要优化的参数列表。这样做的好处是,后面写优化器时,只需要遍历模型的parameters()就能拿到所有需要更新的张量,不需要关心模型的具体结构。
class Linear(Layer): def __init__(self, in_features, out_features): self.W = Tensor(np.random.randn(in_features, out_features) * 0.01, requires_grad=True) self.b = Tensor(np.zeros(out_features), requires_grad=True) def forward(self, x): return x @ self.W + self.b def parameters(self): return [self.W, self.b]上面这个全连接层的实现非常简洁,但它已经包含了神经网络层的核心要素:参数初始化、前向计算、参数暴露。损失函数如均方误差MSE = mean((y_pred - y_true)^2)也可以用同样的方式实现,只需要定义好前向计算和对应的反向传播逻辑。
提示:参数初始化对训练效果影响很大。全连接层的权重我通常用小的随机数初始化,比如乘以 0.01,避免初始输出过大导致梯度爆炸。偏置项初始化为 0 是常见做法。这些细节在写框架时看起来不起眼,但实际训练时差别很明显。
3.5 优化器模块:参数更新的策略
优化器的职责很简单:拿到梯度,按照某种规则更新参数。最基础的随机梯度下降(SGD)就是param = param - lr * grad。虽然简单,但它是理解更复杂优化器的基础。
我在实现优化器时,把它设计成一个独立的类,接收模型的参数列表和学习率作为输入。每次调用step()方法时,遍历所有参数,根据它们的grad属性进行更新。然后调用zero_grad()清空梯度,为下一次迭代做准备。
class SGD: def __init__(self, params, lr=0.01): self.params = params self.lr = lr def step(self): for p in self.params: if p.grad is not None: p.data -= self.lr * p.grad def zero_grad(self): for p in self.params: p.grad = None这段代码虽然短,但它揭示了优化器的本质:优化器不关心模型结构,只关心参数和梯度。这种解耦设计使得你可以轻松地替换不同的优化器,比如后面要实现的 Momentum、Adam 等,只需要修改参数更新的公式即可。
4. 实操过程与核心环节实现
4.1 从零搭建一个可训练的全连接网络
理论说了这么多,最终还是要落到代码上。我以一个简单的两层全连接网络为例,展示整个框架是如何协同工作的。这个网络的任务是拟合一个非线性函数,比如y = sin(x)。
首先定义模型结构:输入维度 1,隐藏层维度 16,输出维度 1。隐藏层使用 ReLU 激活,输出层不加激活。损失函数用均方误差。
class TwoLayerNet: def __init__(self): self.fc1 = Linear(1, 16) self.fc2 = Linear(16, 1) def forward(self, x): h = self.fc1.forward(x) h = relu(h) return self.fc2.forward(h) def parameters(self): return self.fc1.parameters() + self.fc2.parameters()然后写训练循环。每个迭代做四件事:前向传播计算预测值,计算损失,反向传播计算梯度,优化器更新参数。
model = TwoLayerNet() optimizer = SGD(model.parameters(), lr=0.01) for epoch in range(1000): x = Tensor(np.random.uniform(-3, 3, (32, 1))) y_true = Tensor(np.sin(x.data)) y_pred = model.forward(x) loss = mse_loss(y_pred, y_true) loss.backward() optimizer.step() optimizer.zero_grad() if epoch % 100 == 0: print(f"Epoch {epoch}, Loss: {loss.data:.4f}")这个训练循环看起来和 PyTorch 的写法非常相似,这不是巧合。因为 PyTorch 的核心设计理念就是“让用户像写普通 Python 代码一样写模型”,而我在设计这个框架时也遵循了同样的原则。通过亲手实现这些逻辑,我对loss.backward()背后发生了什么有了完全不同的理解。
4.2 梯度验证:确保反向传播的正确性
写完反向传播之后,最重要的一步是验证它的正确性。我用的方法是数值梯度检验。对于任意一个参数,它的数值梯度可以通过有限差分来近似:(f(x + eps) - f(x - eps)) / (2 * eps)。把这个数值梯度和反向传播计算出的解析梯度对比,如果误差在 1e-5 以内,就说明实现是正确的。
def numerical_gradient(f, x, eps=1e-5): grad = np.zeros_like(x.data) it = np.nditer(x.data, flags=['multi_index']) while not it.finished: idx = it.multi_index old_val = x.data[idx] x.data[idx] = old_val + eps f_pos = f().data.sum() x.data[idx] = old_val - eps f_neg = f().data.sum() grad[idx] = (f_pos - f_neg) / (2 * eps) x.data[idx] = old_val it.iternext() return grad这个检验方法虽然计算量大,但对于小规模的网络来说完全可行。我在开发过程中,每实现一个新的操作,都会用这个方法验证一遍。有几次就是靠它发现了反向传播中的符号错误和形状错误。
实操心得:梯度检验时,建议把网络调小,比如输入输出都设为 2 维,隐藏层设为 3 维。这样数值梯度的计算量可以接受,而且一旦出错,定位问题也更容易。另外,记得在检验前把参数随机种子固定,保证每次运行的结果可复现。
4.3 计算图的构建过程实录
为了更直观地理解动态图的构建过程,我以y = (x1 + x2) * x3这个表达式为例,追踪一下计算图是如何一步步建立的。
第一步,x1 + x2触发Tensor.__add__,创建一个新的张量t1,其_ctx记录为("add", x1, x2)。第二步,t1 * x3触发Tensor.__mul__,创建张量y,其_ctx记录为("mul", t1, x3)。此时计算图的结构是:x1 和 x2 指向 t1,t1 和 x3 指向 y。
反向传播时,从 y 开始。y 的梯度初始化为 1。根据_ctx中的"mul"操作,t1 的梯度为1 * x3.data,x3 的梯度为1 * t1.data。然后 t1 继续反向传播,根据"add"操作,x1 和 x2 的梯度都等于 t1 的梯度。整个过程完全由_ctx中记录的信息驱动,不需要任何额外的图结构。
这种设计的美妙之处在于,计算图不是显式存储的,而是隐含在张量的引用关系中。每个张量只需要知道自己是怎么来的,反向传播就能自动完成。这也是动态图框架代码量少、逻辑清晰的根本原因。
4.4 性能优化的几个实用技巧
虽然这个框架的主要目的是学习,但在实现过程中我还是做了一些性能优化,让它在小规模任务上不至于太慢。
第一个优化是避免不必要的张量创建。在反向传播时,很多中间梯度其实不需要保存为完整的张量对象,直接用 NumPy 数组计算即可。我在反向传播的内部实现中,尽量使用 NumPy 的原地操作,减少内存分配。
第二个优化是批处理矩阵运算。全连接层的前向传播x @ W + b中,x 是一个 batch 的数据,矩阵乘法会自动利用 NumPy 的底层优化。这比逐个样本计算要快得多。
第三个优化是延迟梯度清零。标准的做法是每次迭代后调用zero_grad(),但实际上可以在下一次反向传播时再清零,这样可以避免一次不必要的遍历。不过为了代码清晰,我还是保留了显式的zero_grad()。
| 优化手段 | 效果 | 实现难度 |
|---|---|---|
| 原地操作 | 减少内存分配 | 低 |
| 批处理矩阵运算 | 利用 BLAS 加速 | 低 |
| 延迟梯度清零 | 减少一次遍历 | 中 |
| 中间结果释放 | 降低内存峰值 | 高 |
上面这张表总结了我在第一版中考虑过的优化手段。对于学习性质的框架,前两项就足够了。后两项如果要做,需要更精细的内存管理,容易引入 bug,建议在框架稳定之后再考虑。
5. 常见问题与排查技巧实录
5.1 梯度为 None 或形状不匹配
这是最常见的问题。表现是训练完全不收敛,或者报错说grad是 None。根本原因通常是计算链在某处断了。比如某个操作没有正确设置requires_grad,或者反向传播函数中没有把梯度传递给某个输入。
排查方法是逐层打印梯度的形状和值。从损失函数开始,往前检查每一层的梯度是否存在、形状是否正确。如果某一层的梯度是 None,就检查它的前向传播是否记录了计算历史,以及反向传播是否被正确调用。
另一个常见原因是原地操作破坏了计算图。比如x.data += 1这种操作会直接修改张量的数据,但不会记录到计算历史中。如果这个张量后续参与了需要梯度的运算,反向传播时就会用到被修改后的数据,导致梯度错误。正确的做法是使用x = x + 1,这样会创建一个新的张量并记录计算历史。
5.2 损失值变成 NaN
训练过程中损失突然变成 NaN,通常意味着梯度爆炸或者出现了除零操作。可能的原因包括:学习率太大、参数初始化不当、损失函数中有 log(0) 或除以零的情况。
我的排查步骤是这样的:首先把学习率调小一个数量级,看是否还会出现 NaN。如果问题依旧,检查损失函数的实现,确保没有对零取对数或者除以零。然后在反向传播的每个节点打印梯度的最大值和最小值,找到梯度异常的那一层。
注意:ReLU 激活函数在输入为负时梯度为 0,这本身没问题。但如果某一层的输出全部为负,那么这一层的梯度就全部为 0,参数永远不会更新。这种情况叫做“神经元死亡”。解决办法是使用 LeakyReLU 或者调整初始化方式,让初始输出有正有负。
5.3 训练速度异常缓慢
如果发现训练速度比预期慢很多,可以从几个方面排查。第一,检查是否在每次迭代中都重新创建了计算图而没有释放旧图,导致内存持续增长。第二,检查是否有 Python 层面的循环拖慢了矩阵运算,比如逐元素操作而不是批量操作。第三,检查 NumPy 是否链接了优化的 BLAS 库,比如 OpenBLAS 或 MKL,这会让矩阵乘法的速度差好几倍。
还有一个容易被忽略的点是数据类型。如果张量默认使用 float64,计算量会是 float32 的两倍。我在框架中统一使用 float32,既节省内存又加快计算。你可以在创建张量时显式指定dtype=np.float32。
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 梯度为 None | 计算链断裂 | 逐层打印梯度 |
| 损失 NaN | 梯度爆炸/除零 | 调小学习率,检查损失函数 |
| 训练缓慢 | 内存泄漏/数据类型 | 检查内存增长,统一 float32 |
| 不收敛 | 初始化不当/学习率 | 梯度检验,调整超参数 |
这张表可以作为日常调试的速查参考。实际遇到的问题可能更复杂,但大多数情况下都能从这几个方向找到线索。
5.4 反向传播中的循环引用问题
在实现计算图时,如果处理不当,可能会产生循环引用。比如两个张量互相引用对方的_ctx,导致 Python 的垃圾回收器无法释放它们。这个问题在长时间训练中会逐渐暴露出来,表现为内存持续增长。
我的解决办法是在反向传播完成后,手动断开计算历史中的引用。具体来说,在backward()函数的最后,把当前节点的_ctx设为 None。这样当反向传播走到图的起点时,整条链上的引用都会被释放。虽然这样做会让计算图无法被重复使用,但对于训练场景来说,每次迭代都会构建新的图,旧图本来就不需要保留。
提示:如果你想让计算图支持多次反向传播,比如计算二阶梯度,那就不能断开引用。这种情况下需要更复杂的内存管理策略,比如使用弱引用或者显式的图生命周期管理。第一版框架不建议做这么复杂。
5.5 参数更新后梯度没有清零
这是一个很隐蔽的 bug。如果你忘记调用zero_grad(),梯度会在每次反向传播时累加,导致参数更新量越来越大,训练很快就会发散。表现是损失先下降然后突然爆炸。
排查方法很简单:在每次optimizer.step()之后打印几个参数的梯度值,看看它们是否在每次迭代后归零。如果没有归零,就检查zero_grad()是否被正确调用。我在框架中把zero_grad()设计成优化器的方法,而不是模型的方法,就是为了让用户不容易忘记——因为优化器是必须显式创建和调用的,把清零逻辑放在这里更符合直觉。
6. 后续扩展方向与个人体会
这个框架的第一版到这里就基本成型了。它虽然简单,但已经具备了深度学习框架的核心要素:张量运算、自动求导、计算图、神经网络层和优化器。用这个框架训练一个小型的全连接网络,拟合简单的非线性函数,完全没有问题。
后续如果要继续扩展,我觉得有几个方向值得尝试。第一个是卷积层和池化层的实现,这是处理图像数据的基础。卷积的反向传播比全连接层复杂一些,但原理是一样的,都是链式法则的应用。第二个是更复杂的优化器,比如 Momentum 和 Adam,它们的实现只需要在 SGD 的基础上增加一些状态变量。第三个是计算图的优化,比如算子融合和内存复用,这需要对计算图做静态分析,是一个很有挑战性的方向。
我个人在实际操作中的体会是,写框架最大的收获不是框架本身,而是对深度学习底层原理的理解。以前用 PyTorch 的时候,loss.backward()就是一个黑盒,现在我知道它背后发生了什么,知道梯度是如何一步步传回去的,知道为什么有些操作会导致梯度消失或爆炸。这种理解在调试复杂模型时非常有用,因为你能从原理出发去分析问题,而不是盲目地试错。
最后再分享一个小技巧:如果你也想写自己的框架,建议从最简单的标量自动求导开始,比如实现一个支持加减乘除的标量类,然后逐步扩展到张量。这样每一步的复杂度都可控,遇到问题也容易定位。我一开始就想直接写张量版本,结果在形状处理上卡了很久,后来退回去从标量做起,反而更快地理解了核心逻辑。