1. 从零手搓AI工程:为什么我不建议你直接调包
第一次看到ai-engineering-from-scratch这个项目名的时候,我脑子里蹦出来的画面是:一个人坐在黑漆漆的终端前,拒绝所有现成的框架,从矩阵乘法开始一行一行敲出一个能跑的大模型。这个理解对了一半。对的那一半是,这个方向确实强调“从底层理解”,错的那一半是,它并不是让你拒绝工具,而是让你在调用model.fit()之前,先搞清楚fit里面到底发生了什么。
我做了十多年工程,带过不少新人,发现一个特别普遍的现象:很多人能熟练地用几行代码跑通一个分类任务,但你问他反向传播里梯度是怎么流动的、学习率调大调小到底影响了什么、为什么batch size会影响收敛,他答不上来。这不是人的问题,是学习路径的问题。现成的框架把太多东西封装掉了,封装是好事,但如果你连封装里面是什么都不知道,出了问题就只能靠猜。
ai-engineering-from-scratch这个方向要解决的就是这个问题。它适合三类人:第一类是想真正理解AI底层原理的学生或转行者,第二类是在工作中被各种“玄学调参”折磨、想搞清楚背后逻辑的工程师,第三类是准备面试、需要手推公式和手写核心模块的求职者。这篇文章我会把这条学习路线上最关键的几个环节拆开讲,包括整体思路怎么设计、核心模块怎么手写、实操中会遇到什么坑、以及我自己踩过的那些教训。
需要提前说明的是,从零实现不等于生产环境从零造轮子。学习阶段手写是为了理解,生产阶段该用框架还是用框架,这两件事不矛盾。我见过有人走极端,学完之后什么都要自己写,结果项目延期三个月,这就本末倒置了。
2. 整体学习路线的设计与思路拆解
2.1 为什么选择“自底向上”而不是“自顶向下”
大部分人的AI入门路径是自顶向下的:先学调库,跑通一个demo,有成就感,然后再慢慢往下挖。这条路不是不行,但它有个致命问题——你会在“能跑”和“知道为什么能跑”之间卡很久,而且一旦遇到框架不支持的场景,你就束手无策了。
自底向上的路径反过来:先搞明白一个神经元怎么算,再搞明白一层网络怎么组织,然后是反向传播、优化器、正则化,最后才是用框架。这条路前期慢,前两周你可能连一个像样的demo都跑不出来,但一旦打通,后面学任何框架都是降维打击。因为你看到nn.Linear(128, 64)的时候,脑子里浮现的是矩阵形状变换和初始化策略,而不是一个黑盒。
我个人的建议是两条路结合:用自底向上的方式理解核心概念,同时用自顶向下的方式保持成就感。具体做法是,每手写完一个模块,就用框架的对应模块验证一遍,看结果是否一致。这种“手写-对照”的循环,学习效率比单纯看教程高得多。
2.2 技术栈选型:为什么是Python加NumPy
有人会问,既然是从零实现,为什么不用C++或者纯Python列表?答案很实际:你需要一个能让你专注在算法逻辑上、而不是内存管理上的语言。Python加NumPy是这个平衡点的最优解。
NumPy提供了向量化运算,让你不用写三重循环就能做矩阵乘法,同时它又足够底层,你能清楚地看到每一步的数据形状变化。如果你用PyTorch的tensor,虽然API类似,但自动求导会帮你把最难的部分藏起来,这就失去了从零实现的意义。如果你用纯Python列表,一个简单的矩阵乘法就能慢到你怀疑人生,调试效率极低。
这里有个细节值得说:NumPy的广播机制(broadcasting)是很多人从零实现时的第一个拦路虎。比如一个形状为(32, 128)的矩阵和一个形状为(128,)的向量相加,NumPy会自动把后者扩展成(32, 128)。这个机制在实现偏置项加法时非常方便,但如果你不理解它,调试时会看到一堆莫名其妙的形状错误。我的建议是,在从零实现的阶段,尽量显式地写出形状变换,比如用np.tile或者reshape,虽然啰嗦,但能帮你建立清晰的形状直觉。
2.3 模块拆解:把大问题切成可验证的小块
一个完整的AI工程从零实现,可以拆成下面这些模块,我按依赖顺序排列:
| 模块 | 核心内容 | 验证方式 |
|---|---|---|
| 基础运算 | 矩阵乘法、广播、激活函数 | 与NumPy内置函数对比 |
| 前向传播 | 全连接层、卷积层 | 手动计算小样本验证 |
| 损失函数 | MSE、交叉熵 | 用已知答案的简单例子验证 |
| 反向传播 | 链式法则、梯度计算 | 数值梯度校验 |
| 优化器 | SGD、Momentum、Adam | 观察损失下降曲线 |
| 正则化 | Dropout、L2、BatchNorm | 对比训练集和验证集表现 |
| 训练循环 | 数据加载、epoch、评估 | 端到端跑通小数据集 |
这个拆解的关键在于“可验证”。每写完一个模块,你都要有一个独立的验证手段,而不是等到整个网络跑起来才发现问题。数值梯度校验是这里面最重要的技巧,后面我会详细讲。
3. 核心模块的手写实现与关键细节
3.1 矩阵乘法:一切的地基
矩阵乘法看起来简单,但它是整个深度学习的地基。我见过太多人在这里栽跟头,不是因为不会算,而是因为形状对不上。
先明确规则:(m, n)的矩阵乘以(n, p)的矩阵,结果是(m, p)。中间那个n必须相等。这个规则听起来废话,但在实际写代码时,转置漏了、维度搞反了,都是家常便饭。
手写矩阵乘法的朴素实现是三重循环,时间复杂度是 O(mnp)。这个实现只适合用来理解原理,实际跑的时候必须用NumPy的np.dot或者@运算符。但我的建议是,你一定要亲手写一遍三重循环的版本,然后用小矩阵对比结果。为什么?因为写完之后你会对“为什么GPU对矩阵乘法加速这么明显”有切身体会——三重循环的访存模式太糟糕了,而GPU的并行架构正好能把这个过程打散成大量独立的小计算。
这里有个实操心得:在实现全连接层的时候,输入通常是(batch_size, input_dim),权重是(input_dim, output_dim),输出是(batch_size, output_dim)。这个约定要固定下来,不要一会儿用行向量一会儿用列向量,否则后面反向传播推导梯度时你会疯掉。我个人的习惯是统一用“行是样本,列是特征”的约定,这样每一行就是一个样本的完整表示,调试时打印出来很直观。
3.2 激活函数:非线性从哪里来
如果没有激活函数,再深的网络也等价于一个线性变换。激活函数的作用是引入非线性,让网络能拟合复杂的函数关系。
最常用的几个激活函数,手写起来都不难,但细节很多:
ReLU 的公式是max(0, x),前向传播一行搞定。但反向传播时,你需要记住哪些位置的值大于0,因为只有这些位置的梯度才能通过。实现的时候通常会在前向传播时保存一个mask,反向传播时直接用。这个mask的保存会占用显存,所以后来有了各种改进版本,但在从零实现的阶段,用mask是最直观的。
Sigmoid 的公式是1 / (1 + exp(-x)),它的导数有个很优雅的性质:sigmoid(x) * (1 - sigmoid(x))。这意味着你只需要保存前向传播的输出,就能算出梯度,不需要额外存储。但Sigmoid有个致命问题叫“梯度消失”:当输入很大或很小时,导数趋近于0,深层网络的梯度传着传着就没了。这也是为什么现在隐藏层基本不用Sigmoid,只在二分类的输出层用。
Tanh 和Sigmoid类似,但输出范围是(-1, 1),导数性质是1 - tanh(x)^2。它比Sigmoid好一点,因为输出是零中心的,但梯度消失问题依然存在。
注意:实现Softmax的时候一定要做数值稳定处理。直接算
exp(x)当x很大时会溢出,标准做法是先减去最大值:exp(x - max(x))。这个操作不改变数学结果,但能避免溢出。我当初第一次实现的时候没做这个处理,结果训练到一半损失变成NaN,排查了半天才发现是溢出。
3.3 损失函数:衡量好坏的标尺
损失函数是网络优化的目标,选错了损失函数,训练再久也白搭。
均方误差(MSE)适合回归问题,公式是mean((y_pred - y_true)^2)。它的梯度是2 * (y_pred - y_true) / n,非常直观。但MSE对异常值很敏感,因为误差被平方了,一个离谱的预测会主导整个梯度。
交叉熵(Cross-Entropy)适合分类问题,公式是-sum(y_true * log(y_pred))。配合Softmax使用时,梯度会简化成y_pred - y_true,这个形式非常优雅,计算也稳定。这也是为什么分类任务几乎都用交叉熵而不是MSE。
手写交叉熵时有个坑:log(0)是负无穷。虽然Softmax的输出理论上不会精确等于0,但浮点数精度下可能非常接近0,导致log出一个很大的负数。标准做法是在log里面加一个极小值,比如1e-12。这个技巧叫“平滑”,能避免数值问题。
3.4 反向传播:最核心也最容易出错的部分
反向传播是整个从零实现里最核心、也最容易出错的部分。它的本质是链式法则,但当你面对一个多层网络时,手动推导每一层的梯度很容易漏项或搞错符号。
我的建议是分两步走:先推导单个神经元的梯度,再推广到整个网络。单个神经元的前向是z = w * x + b,a = activation(z)。反向时,你需要计算dL/dw、dL/db,以及传给前一层的dL/dx。这三个量的推导过程,我强烈建议你在纸上手推一遍,不要直接抄代码。
推导完之后,用数值梯度校验来验证你的实现。数值梯度的原理是:f'(x) ≈ (f(x + h) - f(x - h)) / (2h),其中h取一个很小的值,比如1e-5。把数值梯度和你的解析梯度对比,如果相对误差在1e-6量级,说明实现正确。这个技巧能帮你抓出99%的反向传播bug。
提示:数值梯度校验很慢,不要在整个网络上做,只在小样本、小网络上验证。我通常会用2个样本、3维输入、2层网络来校验,几秒钟就能跑完。
3.5 优化器:从SGD到Adam的演进逻辑
优化器决定了参数怎么更新。最基础的随机梯度下降(SGD)就是w = w - lr * grad,简单粗暴,但问题很多:学习率难调、容易陷入局部最优、在鞍点附近震荡。
Momentum引入了“动量”的概念,相当于给梯度下降加了一个惯性。更新时不仅考虑当前梯度,还考虑之前的累积方向。这能加速收敛,也能帮助冲出局部最优。实现上就是维护一个速度变量v = beta * v + (1 - beta) * grad,然后用v更新参数。
Adam是目前最常用的优化器,它结合了Momentum和RMSProp的思想,同时维护梯度的一阶矩(均值)和二阶矩(方差),并对两者做偏差修正。Adam的好处是对学习率不那么敏感,默认的lr=0.001在大多数场景下都能work。但Adam也有缺点,在某些任务上泛化能力不如调好的SGD,这也是为什么有些论文还在用SGD加Momentum。
手写Adam的时候,偏差修正是最容易漏的一步。因为一阶矩和二阶矩初始化为0,在训练初期它们的估计是有偏的,需要除以(1 - beta^t)来修正。漏掉这一步,训练初期的更新会偏小,收敛变慢。
4. 完整实操流程:从零训练一个手写数字识别网络
4.1 数据准备与预处理
我们以MNIST手写数字识别为例,走一遍完整流程。MNIST有60000张训练图和10000张测试图,每张是28x28的灰度图。数据预处理包括三步:归一化、展平、独热编码。
归一化是把像素值从[0, 255]缩放到[0, 1],做法是除以255。这一步很重要,因为未归一化的输入会导致梯度尺度差异巨大,训练不稳定。展平是把28x28的图变成784维的向量,因为我们的全连接层接受的是向量输入。独热编码是把标签从数字变成向量,比如数字3变成[0,0,0,1,0,0,0,0,0,0],这样才能和Softmax输出做交叉熵。
import numpy as np def load_and_preprocess(): # 假设数据已经下载为numpy格式 x_train = np.load('x_train.npy').astype(np.float32) / 255.0 y_train = np.load('y_train.npy') x_test = np.load('x_test.npy').astype(np.float32) / 255.0 y_test = np.load('y_test.npy') # 展平 x_train = x_train.reshape(-1, 784) x_test = x_test.reshape(-1, 784) # 独热编码 def one_hot(y, num_classes=10): return np.eye(num_classes)[y] y_train = one_hot(y_train) y_test = one_hot(y_test) return x_train, y_train, x_test, y_test4.2 网络结构定义与初始化
我们用一个简单的两层网络:输入784维,隐藏层128维,输出10维。激活函数用ReLU,输出层用Softmax。
权重初始化很关键。如果全部初始化为0,所有神经元的梯度相同,网络学不到东西。如果初始化太大,激活值会饱和,梯度消失。常用的方法是He初始化,适用于ReLU:std = sqrt(2 / input_dim)。这个公式的推导基于“保持前向传播时方差不变”的原则,用起来效果很稳。
class TwoLayerNet: def __init__(self, input_dim=784, hidden_dim=128, output_dim=10): # He初始化 self.W1 = np.random.randn(input_dim, hidden_dim) * np.sqrt(2.0 / input_dim) self.b1 = np.zeros(hidden_dim) self.W2 = np.random.randn(hidden_dim, output_dim) * np.sqrt(2.0 / hidden_dim) self.b2 = np.zeros(output_dim) def forward(self, x): self.x = x self.z1 = x @ self.W1 + self.b1 self.a1 = np.maximum(0, self.z1) # ReLU self.z2 = self.a1 @ self.W2 + self.b2 # Softmax with numerical stability exp_z = np.exp(self.z2 - np.max(self.z2, axis=1, keepdims=True)) self.probs = exp_z / np.sum(exp_z, axis=1, keepdims=True) return self.probs4.3 反向传播与参数更新
反向传播的核心是计算每一层的梯度。对于我们的两层网络,梯度计算如下:
输出层的梯度是dL/dz2 = probs - y_true,这个简化形式是Softmax加交叉熵的经典结果。然后dL/dW2 = a1.T @ dL/dz2,dL/db2 = sum(dL/dz2, axis=0)。传到隐藏层的梯度是dL/da1 = dL/dz2 @ W2.T,经过ReLU的反向是dL/dz1 = dL/da1 * (z1 > 0),最后dL/dW1 = x.T @ dL/dz1,dL/db1 = sum(dL/dz1, axis=0)。
def backward(self, y_true, lr=0.01): batch_size = self.x.shape[0] # 输出层梯度 dz2 = (self.probs - y_true) / batch_size dW2 = self.a1.T @ dz2 db2 = np.sum(dz2, axis=0) # 隐藏层梯度 da1 = dz2 @ self.W2.T dz1 = da1 * (self.z1 > 0) # ReLU导数 dW1 = self.x.T @ dz1 db1 = np.sum(dz1, axis=0) # 参数更新 self.W2 -= lr * dW2 self.b2 -= lr * db2 self.W1 -= lr * dW1 self.b1 -= lr * db14.4 训练循环与超参数选择
训练循环包括前向传播、计算损失、反向传播、参数更新四个步骤。超参数方面,batch size我通常从64或128开始,学习率从0.01开始试。如果损失震荡,说明学习率太大;如果损失下降太慢,说明学习率太小。
def train(model, x_train, y_train, epochs=10, batch_size=128, lr=0.01): n_samples = x_train.shape[0] for epoch in range(epochs): # 打乱数据 indices = np.random.permutation(n_samples) x_shuffled = x_train[indices] y_shuffled = y_train[indices] total_loss = 0 for i in range(0, n_samples, batch_size): x_batch = x_shuffled[i:i+batch_size] y_batch = y_shuffled[i:i+batch_size] probs = model.forward(x_batch) # 交叉熵损失 loss = -np.mean(np.sum(y_batch * np.log(probs + 1e-12), axis=1)) total_loss += loss model.backward(y_batch, lr) avg_loss = total_loss / (n_samples // batch_size) print(f"Epoch {epoch+1}, Loss: {avg_loss:.4f}")实测下来,这个简单网络在MNIST上跑10个epoch,测试准确率能到97%左右。这个数字不算高,但作为从零实现的验证已经足够了。如果你想进一步提升,可以加Dropout、BatchNorm,或者换成卷积网络。
5. 常见问题与排查技巧实录
5.1 损失变成NaN:数值稳定性的坑
损失变成NaN是从零实现时最常见的问题,没有之一。原因通常有三个:学习率太大导致梯度爆炸、log(0)导致负无穷、除零错误。
排查顺序是这样的:先把学习率调小10倍,如果还是NaN,检查损失函数里有没有加平滑项。交叉熵的log里面必须加1e-12,Softmax的指数必须减去最大值。如果这两个都做了还是NaN,检查权重初始化,如果初始权重太大,第一轮前向传播就会溢出。
我自己的经验是,在训练循环里加一个断言:assert not np.isnan(loss), "Loss is NaN"。这样一旦出现NaN,程序立刻停下来,你能马上定位到是哪个batch、哪个epoch出的问题,而不是跑完整个训练才发现结果全废了。
5.2 梯度校验不通过:形状和符号的陷阱
数值梯度校验不通过,通常是因为形状搞错了或者符号反了。形状问题最常见的是转置漏了,比如dW = x.T @ dz写成了x @ dz。符号问题通常是链式法则里漏了一个负号,或者减法顺序反了。
排查技巧是逐层校验。先只校验最后一层的梯度,确认没问题后再往前推。每一层的梯度单独校验,比一次性校验整个网络容易定位问题。另外,数值梯度的h不要取太小,1e-5是个比较稳的值,太小了会被浮点误差淹没。
5.3 训练不收敛:学习率和初始化的影响
训练不收敛的表现是损失不下降或者震荡。最常见的原因是学习率太大,梯度更新步子太大,在最优解附近来回跳。解决办法是把学习率调小,比如从0.01降到0.001。
另一个原因是初始化不当。如果权重全部初始化为0,网络对称,学不到东西。如果初始化太大,激活值饱和,梯度消失。He初始化或Xavier初始化能解决大部分问题。Xavier适用于Sigmoid和Tanh,He适用于ReLU。
还有一个容易被忽略的原因是数据没有打乱。如果训练数据按类别排序,每个batch的梯度方向会非常一致,导致训练不稳定。每个epoch开始前打乱数据,是个简单但有效的技巧。
5.4 过拟合:正则化的正确打开方式
过拟合的表现是训练集准确率很高,验证集准确率很低。解决办法有几种:加L2正则化、加Dropout、减少网络容量、增加数据。
L2正则化是在损失函数里加上权重的平方和,loss = original_loss + lambda * sum(W^2)。它的效果是让权重趋向于小值,从而降低模型复杂度。lambda通常取1e-4到1e-2之间。
Dropout是在训练时随机“丢弃”一部分神经元,让网络不依赖某些特定的神经元。实现上就是在激活值后面乘一个随机mask,然后除以保留概率。注意Dropout只在训练时用,推理时要关掉。
注意:Dropout和BatchNorm一起用时有个坑,就是方差偏移问题。简单说,Dropout改变了激活值的方差,而BatchNorm依赖方差做归一化,两者叠加会导致推理时的结果和训练时不一致。解决办法是把Dropout放在BatchNorm后面,或者用其他正则化方式替代。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 损失NaN | 学习率太大、log(0)、溢出 | 检查损失函数、调小学习率 | 加平滑项、减最大值、调小lr |
| 梯度校验失败 | 形状错误、符号错误 | 逐层校验 | 检查转置、检查链式法则 |
| 不收敛 | 学习率不当、初始化不当 | 观察损失曲线 | 调lr、换初始化方法 |
| 过拟合 | 模型太复杂、数据太少 | 对比训练/验证曲线 | 加正则化、加数据 |
| 训练慢 | batch太小、实现低效 | 计时各步骤 | 增大batch、向量化 |
6. 从零实现之后:如何把经验用到实际工程中
手写一遍之后,你对框架的理解会完全不一样。看到nn.Linear你会想到权重初始化和矩阵形状,看到optim.Adam你会想到一阶矩二阶矩和偏差修正,看到loss.backward()你会想到计算图的构建和链式法则的传播。这种理解带来的直接好处是,调参不再是玄学,而是有逻辑的试错。
但我也要提醒一句,从零实现是为了理解,不是为了替代框架。实际工程中,框架的自动求导、GPU加速、分布式训练这些能力,手写版本根本比不了。正确的做法是:用从零实现建立直觉,用框架做实际项目,遇到问题时用底层知识去分析和解决。
后续如果想继续深入,有几个方向可以扩展。一是手写卷积层和池化层,理解CNN的核心运算。二是手写注意力机制,理解Transformer的基础。三是手写一个简单的自动求导引擎,理解计算图的原理。这三个方向任何一个深入下去,都能让你对AI工程的理解再上一个台阶。
我个人在实际操作中的体会是,从零实现最大的价值不在于你写出了什么,而在于你调试的过程中被迫建立的直觉。那些形状错误、梯度消失、数值溢出,每一个坑踩过之后,都会变成你后来排查问题的肌肉记忆。这种记忆,是看多少教程都换不来的。