1. 从零手搓AI工程:为什么我不建议你直接调包
很多人一上来就想用现成的框架把模型跑起来,觉得“能出结果就行”。但如果你真的想搞明白AI工程到底是怎么回事,我的建议是:先别急着调包,从零手搓一遍。这个项目标题“ai-engineering-from-scratch”本身就点明了核心——不是教你调API,而是让你理解每一层是怎么搭起来的。
我见过太多人,简历上写着“熟悉深度学习”,结果连反向传播的链式法则都推不明白,梯度消失的原因也说不到点子上。这不是能力问题,是学习路径的问题。直接调包就像开自动挡的车,能上路,但发动机怎么工作、变速箱怎么换挡,你一概不知。一旦遇到需要自定义层、自定义损失函数、或者调试梯度异常的情况,就彻底抓瞎了。
这个项目的价值在于,它强迫你面对最原始的问题:一个神经元怎么算?一层网络怎么组织?损失函数怎么定义?梯度怎么求?参数怎么更新?这些问题的答案,在框架里是几行代码,在底层却是一整套数学和工程体系。我自己的经验是,手搓过一遍之后,再看PyTorch或TensorFlow的源码,很多设计决策就豁然开朗了。
适合读这篇内容的人,我大致分三类:第一类是有一定编程基础,但没系统学过AI工程的学生或转行者;第二类是用过框架但总觉得“心里没底”的开发者;第三类是想深入理解底层机制,以便做性能优化或自定义扩展的工程师。如果你属于这三类中的任何一类,接下来的内容应该对你有用。
2. 手搓之前必须想清楚的几件事
2.1 为什么选NumPy而不是直接上PyTorch
手搓AI工程,第一个要做的决策就是:用什么工具?我的答案是NumPy。原因很简单:NumPy只提供数组运算和基础线性代数,不提供自动求导,不提供神经网络层,不提供优化器。这意味着你必须自己实现这些东西。而PyTorch虽然也支持底层操作,但它的自动求导机制太方便了,你很容易不自觉地依赖它,最后又变成了调包。
用NumPy手搓,你会被迫面对几个核心问题:前向传播怎么组织?反向传播的梯度怎么算?参数怎么存储和更新?这些问题在框架里是隐式的,在NumPy里是显式的。显式的好处是,你真正理解了每一步在做什么。比如,当你自己实现一个全连接层时,你会清楚地知道权重矩阵的形状是(输入维度,输出维度),偏置向量的形状是(输出维度,),前向传播就是输入矩阵乘以权重矩阵再加上偏置向量。这些细节在框架里被封装了,但在NumPy里你必须自己想清楚。
当然,NumPy也不是没有代价。它的计算效率不如专门优化的框架,尤其是在大规模矩阵运算时。但手搓的目的不是追求性能,而是追求理解。等你理解了之后,再用框架做性能优化,那就是另一回事了。
2.2 从标量到向量:计算图的思维转变
手搓AI工程,最大的思维转变是从标量计算转向向量化计算。刚开始学的时候,很多人会用一个循环来处理每个样本,比如:
for i in range(batch_size): for j in range(input_dim): for k in range(output_dim): output[i][k] += input[i][j] * weight[j][k]这种写法在逻辑上是对的,但效率极低,而且容易出错。正确的做法是把整个batch作为一个矩阵,用矩阵乘法一次性完成。这不仅仅是性能问题,更是思维方式的问题。向量化思维要求你把所有操作都看成矩阵或张量运算,而不是逐个元素处理。
我自己的经验是,手搓的时候一定要从一开始就坚持向量化。哪怕一开始不习惯,也要强迫自己用矩阵运算来表达。比如,全连接层的前向传播就是output = input @ weight + bias,反向传播的梯度计算也是矩阵运算。这样写出来的代码不仅简洁,而且更容易验证正确性。
2.3 环境准备:最小依赖原则
手搓AI工程,环境准备要遵循最小依赖原则。你只需要NumPy和Matplotlib就够了。NumPy用于数值计算,Matplotlib用于可视化训练过程。不需要安装PyTorch、TensorFlow、Keras等框架,也不需要CUDA、cuDNN等GPU加速库。这样做的好处是,你完全掌控了代码的每一个细节,不会因为框架的版本问题或隐藏行为而困惑。
具体安装命令如下:
pip install numpy matplotlib如果你用的是Anaconda,也可以用conda安装:
conda install numpy matplotlib安装完成后,建议创建一个单独的目录,比如ai-from-scratch,然后在里面组织你的代码。我习惯把不同模块放在不同文件里,比如layers.py放网络层,losses.py放损失函数,optimizers.py放优化器,model.py放模型组装逻辑。这样结构清晰,也方便后续扩展。
3. 核心组件的手搓实现与原理拆解
3.1 全连接层:从矩阵乘法到梯度推导
全连接层是神经网络最基本的组件。它的前向传播很简单:Y = X @ W + b,其中X是输入矩阵,形状为(batch_size, input_dim),W是权重矩阵,形状为(input_dim, output_dim),b是偏置向量,形状为(output_dim,),Y是输出矩阵,形状为(batch_size, output_dim)。
但反向传播就没那么直观了。假设损失函数L对Y的梯度是dY,那么我们需要计算L对W、b、X的梯度。根据链式法则:
- dW = X.T @ dY
- db = sum(dY, axis=0)
- dX = dY @ W.T
这三个公式是手搓全连接层的核心。我建议你亲手推导一遍,不要直接抄。推导的过程会让你真正理解为什么dW是X的转置乘以dY,而不是其他形式。推导完之后,再用NumPy实现,代码大概是这样:
class Linear: def __init__(self, input_dim, output_dim): self.W = np.random.randn(input_dim, output_dim) * 0.01 self.b = np.zeros(output_dim) self.dW = None self.db = None self.X = None def forward(self, X): self.X = X return X @ self.W + self.b def backward(self, dY): self.dW = self.X.T @ dY self.db = np.sum(dY, axis=0) dX = dY @ self.W.T return dX注意权重的初始化,我用的是np.random.randn(input_dim, output_dim) * 0.01,这是为了打破对称性,同时避免初始值过大导致梯度爆炸。偏置初始化为零是常见做法,因为对称性已经被权重打破了。
3.2 激活函数:ReLU与Sigmoid的取舍
激活函数是神经网络的非线性来源。没有激活函数,再多层的网络也等价于一个线性变换。手搓的时候,最常用的两个激活函数是ReLU和Sigmoid。
ReLU的定义是f(x) = max(0, x),它的导数在x>0时为1,在x<=0时为0。ReLU的优点是计算简单,梯度不会饱和,适合深层网络。缺点是存在“死亡ReLU”问题,即某些神经元可能永远输出0,梯度永远为0,参数不再更新。
Sigmoid的定义是f(x) = 1 / (1 + exp(-x)),它的导数是f(x) * (1 - f(x))。Sigmoid的优点是输出在(0,1)之间,适合做概率输出。缺点是梯度容易饱和,当x很大或很小时,梯度接近0,导致梯度消失。
我自己的经验是,隐藏层用ReLU,输出层根据任务选择。如果是二分类,输出层用Sigmoid;如果是多分类,输出层用Softmax;如果是回归,输出层不用激活函数。手搓的时候,ReLU的实现很简单:
class ReLU: def __init__(self): self.mask = None def forward(self, X): self.mask = X > 0 return X * self.mask def backward(self, dY): return dY * self.maskSigmoid的实现稍微复杂一点,但也不难:
class Sigmoid: def __init__(self): self.out = None def forward(self, X): self.out = 1 / (1 + np.exp(-X)) return self.out def backward(self, dY): return dY * self.out * (1 - self.out)注意Sigmoid的导数用到了前向传播的输出,所以需要在forward里保存下来。这是手搓时容易忽略的细节。
3.3 损失函数:交叉熵与均方误差的适用场景
损失函数衡量模型输出与真实标签的差距。手搓的时候,最常用的两个损失函数是均方误差(MSE)和交叉熵(Cross Entropy)。
MSE的定义是L = mean((Y_pred - Y_true)^2),它的梯度是dL/dY_pred = 2 * (Y_pred - Y_true) / batch_size。MSE适合回归任务,输出是连续值。
交叉熵的定义是L = -mean(Y_true * log(Y_pred)),它的梯度是dL/dY_pred = -Y_true / Y_pred / batch_size。交叉熵适合分类任务,输出是概率分布。注意,交叉熵通常和Softmax一起使用,因为Softmax的输出是概率分布,交叉熵可以直接衡量两个分布的差异。
我自己的经验是,分类任务一律用交叉熵,回归任务用MSE。手搓的时候,交叉熵的实现要注意数值稳定性,避免log(0)的情况。通常的做法是在log里面加一个很小的数,比如1e-7:
class CrossEntropyLoss: def __init__(self): self.Y_pred = None self.Y_true = None def forward(self, Y_pred, Y_true): self.Y_pred = Y_pred self.Y_true = Y_true Y_pred_clipped = np.clip(Y_pred, 1e-7, 1 - 1e-7) loss = -np.mean(Y_true * np.log(Y_pred_clipped)) return loss def backward(self): return -self.Y_true / (self.Y_pred + 1e-7) / self.Y_true.shape[0]注意np.clip的使用,它把预测值限制在[1e-7, 1-1e-7]之间,避免log(0)或log(负数)的情况。这是手搓时容易忽略的数值稳定性问题。
3.4 优化器:SGD与Momentum的实战对比
优化器负责根据梯度更新参数。最简单的优化器是随机梯度下降(SGD),更新规则是W = W - learning_rate * dW。SGD的优点是简单,缺点是容易陷入局部最优,收敛速度慢。
Momentum在SGD的基础上引入了动量项,更新规则是v = momentum * v - learning_rate * dW,W = W + v。动量项的作用是加速收敛,减少震荡。我自己的经验是,Momentum通常比SGD收敛更快,尤其是在损失函数曲面比较崎岖的情况下。
手搓SGD的实现很简单:
class SGD: def __init__(self, learning_rate=0.01): self.learning_rate = learning_rate def update(self, layer): layer.W -= self.learning_rate * layer.dW layer.b -= self.learning_rate * layer.dbMomentum的实现稍微复杂一点,需要保存每个参数的速度:
class Momentum: def __init__(self, learning_rate=0.01, momentum=0.9): self.learning_rate = learning_rate self.momentum = momentum self.vW = None self.vb = None def update(self, layer): if self.vW is None: self.vW = np.zeros_like(layer.W) self.vb = np.zeros_like(layer.b) self.vW = self.momentum * self.vW - self.learning_rate * layer.dW self.vb = self.momentum * self.vb - self.learning_rate * layer.db layer.W += self.vW layer.b += self.vb注意速度的初始化,我用的是np.zeros_like,这样速度的形状和参数一致。动量的典型值是0.9,学习率的典型值是0.01。这两个值不是绝对的,需要根据具体任务调整。
4. 组装与训练:从模块到完整模型
4.1 模型组装:顺序容器的设计思路
有了全连接层、激活函数、损失函数和优化器,接下来就是把它们组装成一个完整的模型。我习惯用一个顺序容器(Sequential)来管理层的顺序,类似PyTorch的nn.Sequential。顺序容器的核心逻辑是:前向传播时按顺序调用每一层的forward,反向传播时按相反顺序调用每一层的backward。
class Sequential: def __init__(self): self.layers = [] def add(self, layer): self.layers.append(layer) def forward(self, X): for layer in self.layers: X = layer.forward(X) return X def backward(self, dY): for layer in reversed(self.layers): dY = layer.backward(dY) return dY这个设计很简单,但足够灵活。你可以往里面加任意层,只要它们实现了forward和backward方法。我自己的经验是,顺序容器适合大多数前馈网络,但如果需要更复杂的结构(比如残差连接、多分支),就需要自定义模型类。
4.2 训练循环:批次、迭代与收敛判断
训练循环是模型训练的核心。一个典型的训练循环包括以下步骤:
- 从训练集中抽取一个批次的数据
- 前向传播,计算预测值
- 计算损失
- 反向传播,计算梯度
- 更新参数
- 重复以上步骤,直到达到指定的迭代次数或损失收敛
手搓的时候,我建议把训练循环写成一个函数,方便复用:
def train(model, loss_fn, optimizer, X_train, Y_train, batch_size, epochs): num_samples = X_train.shape[0] for epoch in range(epochs): indices = np.random.permutation(num_samples) X_shuffled = X_train[indices] Y_shuffled = Y_train[indices] for i in range(0, num_samples, batch_size): X_batch = X_shuffled[i:i+batch_size] Y_batch = Y_shuffled[i:i+batch_size] Y_pred = model.forward(X_batch) loss = loss_fn.forward(Y_pred, Y_batch) dY = loss_fn.backward() model.backward(dY) for layer in model.layers: if hasattr(layer, 'W'): optimizer.update(layer) print(f"Epoch {epoch+1}, Loss: {loss:.4f}")注意np.random.permutation的使用,它打乱样本顺序,避免模型学到样本的顺序信息。批次大小通常取32、64、128等,具体取决于数据集大小和内存限制。我自己的经验是,批次太小会导致训练不稳定,批次太大会导致内存不足,需要根据实际情况调整。
4.3 一个完整示例:手写数字分类
为了验证手搓的模型是否有效,我通常会用MNIST手写数字分类任务做测试。MNIST数据集包含60000张训练图片和10000张测试图片,每张图片是28x28的灰度图,标签是0-9的数字。
首先,加载和预处理数据:
from sklearn.datasets import fetch_openml from sklearn.model_selection import train_test_split mnist = fetch_openml('mnist_784', version=1) X = mnist.data.to_numpy() / 255.0 Y = mnist.target.to_numpy().astype(int) X_train, X_test, Y_train, Y_test = train_test_split(X, Y, test_size=0.2, random_state=42)然后,把标签转换成one-hot编码:
def one_hot(Y, num_classes=10): return np.eye(num_classes)[Y] Y_train_onehot = one_hot(Y_train) Y_test_onehot = one_hot(Y_test)接着,组装模型:
model = Sequential() model.add(Linear(784, 128)) model.add(ReLU()) model.add(Linear(128, 64)) model.add(ReLU()) model.add(Linear(64, 10)) model.add(Softmax()) loss_fn = CrossEntropyLoss() optimizer = Momentum(learning_rate=0.01, momentum=0.9)注意最后一层用了Softmax,把输出转换成概率分布。Softmax的实现如下:
class Softmax: def __init__(self): self.out = None def forward(self, X): X_shifted = X - np.max(X, axis=1, keepdims=True) exp_X = np.exp(X_shifted) self.out = exp_X / np.sum(exp_X, axis=1, keepdims=True) return self.out def backward(self, dY): return dY注意X_shifted的使用,它是为了数值稳定性,避免exp溢出。这是手搓Softmax时容易忽略的细节。
最后,训练模型:
train(model, loss_fn, optimizer, X_train, Y_train_onehot, batch_size=64, epochs=10)训练完成后,在测试集上评估:
Y_pred = model.forward(X_test) Y_pred_labels = np.argmax(Y_pred, axis=1) accuracy = np.mean(Y_pred_labels == Y_test) print(f"Test Accuracy: {accuracy:.4f}")我自己的经验是,这个简单的模型在MNIST上通常能达到95%以上的准确率。如果达不到,可能是学习率太大或太小,或者批次大小不合适,需要调整超参数。
5. 踩过的坑与性能调优经验
5.1 梯度消失与梯度爆炸的排查过程
手搓神经网络,最容易遇到的问题就是梯度消失或梯度爆炸。梯度消失的表现是:损失下降很慢,甚至不下降;梯度爆炸的表现是:损失变成NaN,或者参数变成NaN。
我第一次手搓的时候,用的是Sigmoid激活函数,结果训练了十几个epoch,损失几乎没变化。后来排查发现,Sigmoid的导数在输入较大或较小时接近0,导致梯度在反向传播过程中逐层衰减,到第一层时已经接近0了。解决方案是把隐藏层的激活函数换成ReLU,梯度消失问题就缓解了。
梯度爆炸通常是因为学习率太大,或者权重初始化太大。我遇到过一次,损失直接变成NaN,排查发现是学习率设成了0.1,对于那个模型来说太大了。把学习率降到0.01之后,训练就稳定了。另一个解决方案是梯度裁剪,即把梯度限制在一个范围内:
def clip_gradients(grad, max_norm=1.0): norm = np.linalg.norm(grad) if norm > max_norm: grad = grad * max_norm / norm return grad梯度裁剪在RNN等深层网络中特别有用,可以防止梯度爆炸。
5.2 数值稳定性:Softmax与交叉熵的联合优化
Softmax和交叉熵联合使用时,有一个数值稳定性问题。如果先算Softmax,再算交叉熵,中间结果可能溢出或下溢。更好的做法是把Softmax和交叉熵合并成一个操作,直接计算损失,避免中间结果的数值问题。
合并后的损失函数通常叫Softmax Cross Entropy,它的前向传播是:
def softmax_cross_entropy(Y_pred, Y_true): Y_pred_shifted = Y_pred - np.max(Y_pred, axis=1, keepdims=True) exp_Y = np.exp(Y_pred_shifted) softmax_Y = exp_Y / np.sum(exp_Y, axis=1, keepdims=True) loss = -np.mean(Y_true * np.log(softmax_Y + 1e-7)) return loss, softmax_Y反向传播的梯度是softmax_Y - Y_true,这个梯度形式非常简洁,而且数值稳定。我自己的经验是,手搓的时候尽量用合并后的版本,避免分开计算带来的数值问题。
5.3 学习率与批次大小的调参心得
学习率和批次大小是手搓神经网络时最重要的两个超参数。学习率太大,训练不稳定,损失可能震荡或爆炸;学习率太小,训练太慢,收敛需要很多epoch。批次大小太大,内存不够,而且可能陷入局部最优;批次大小太小,训练不稳定,梯度噪声大。
我自己的调参经验是:先从学习率0.01、批次大小64开始,观察损失曲线。如果损失下降太慢,把学习率调大到0.05或0.1;如果损失震荡或爆炸,把学习率调小到0.001或0.0001。批次大小通常取32、64、128,如果内存够大,可以取256或512。
另外,学习率衰减也是一个有用的技巧。随着训练进行,逐渐减小学习率,可以让模型更稳定地收敛。简单的实现是每过几个epoch,把学习率乘以0.9:
if epoch % 5 == 0: optimizer.learning_rate *= 0.9这个技巧在训练后期特别有用,可以避免在最优解附近震荡。
5.4 过拟合的识别与应对
过拟合的表现是:训练集损失持续下降,但测试集损失开始上升。识别过拟合的方法是:在训练过程中同时监控训练集和测试集的损失。如果测试集损失开始上升,说明模型开始过拟合了。
应对过拟合的方法有几种:一是增加数据量,但手搓的时候通常没有更多数据;二是正则化,比如L2正则化,在损失函数中加入权重的平方和;三是Dropout,随机丢弃一部分神经元,减少模型复杂度。
L2正则化的实现是在损失函数中加入lambda * sum(W^2),梯度中加入2 * lambda * W。Dropout的实现是在前向传播时随机把一部分神经元的输出置零,反向传播时只更新未被置零的神经元。我自己的经验是,L2正则化通常比Dropout更容易实现,效果也不错。
6. 手搓之后:如何平滑过渡到主流框架
6.1 从NumPy到PyTorch的思维映射
手搓过一遍之后,再用PyTorch,你会发现很多概念是一一对应的。比如,你手搓的Linear层对应nn.Linear,你手搓的ReLU对应nn.ReLU,你手搓的CrossEntropyLoss对应nn.CrossEntropyLoss,你手搓的SGD对应optim.SGD。这种对应关系让你看框架代码时不再迷茫,因为你知道每一行背后在做什么。
更重要的是,你理解了自动求导的价值。手搓的时候,你需要手动推导每个操作的梯度,手动实现反向传播。PyTorch的autograd自动帮你做这些事,你只需要定义前向传播,反向传播自动完成。这种便利是建立在你理解反向传播原理的基础上的,否则你只是机械地调用loss.backward(),不知道背后发生了什么。
6.2 哪些轮子值得自己造,哪些不值得
手搓的目的是理解原理,不是替代框架。在实际项目中,我建议只手搓那些你真正需要理解的组件,比如自定义层、自定义损失函数、自定义优化器。其他的标准组件,直接用框架提供的就好,没必要重复造轮子。
比如,如果你要做图像分类,直接用nn.Conv2d、nn.BatchNorm2d、nn.ReLU、nn.Linear这些标准层就好,没必要自己手搓卷积。但如果你要设计一个新的注意力机制,或者一个新的损失函数,那就值得自己手搓,因为你需要完全掌控它的行为。
我自己的经验是,手搓一遍之后,再看框架源码,很多设计决策就理解了。比如,为什么PyTorch的CrossEntropyLoss内部合并了Softmax?因为数值稳定性。为什么nn.Linear的权重初始化用Kaiming初始化?因为要适应ReLU的方差特性。这些细节,手搓过的人一看就懂,没手搓过的人可能永远不知道。
6.3 后续可以深入的方向
手搓完一个简单的前馈网络之后,你可以继续深入几个方向。一是卷积神经网络(CNN),手搓卷积层和池化层,理解卷积的局部连接和权重共享。二是循环神经网络(RNN),手搓RNN单元和LSTM单元,理解序列建模和梯度传播。三是注意力机制,手搓Self-Attention和Multi-Head Attention,理解Transformer的核心。
每个方向都可以从零手搓一遍,然后再用框架实现。这样反复几次,你对AI工程的理解就会从“会用”上升到“懂原理”。我自己的体会是,手搓一遍的痛苦是值得的,因为它让你在遇到问题时,知道从哪里入手排查,而不是盲目地调参或换框架。
最后分享一个小技巧:手搓的时候,一定要写单元测试。比如,你可以用数值梯度检验解析梯度是否正确。数值梯度的计算是(f(x+eps) - f(x-eps)) / (2*eps),把它和解析梯度对比,如果差异很小,说明解析梯度是对的。这个技巧在实现自定义层或自定义损失函数时特别有用,可以帮你快速定位梯度计算的错误。