ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从零实现AI工程:手写神经网络与反向传播核心指南

从零实现AI工程:手写神经网络与反向传播核心指南

“ai-engineering-from-scratch”这种命名方式,我太熟了。GitHub上一搜一大把,几乎每个热门方向都会冒出来几个“from scratch”仓库,但真正能让人从头走到尾、不半路劝退的,少之又少。这个标题的核心诱惑力在于“我什么都不要,我就想自己把轮子造一遍”——用最底层的方式理解AI工程,而不是直接pip install一个封装好的黑盒。这篇文章我就拿这个项目当引子,聊聊从零开始搞AI工程到底在搞什么、怎么搞、有哪些绕不开的坑,以及你在动手之前必须先想清楚的事。


1. 内容整体设计与思路拆解

1.1 这个“from scratch”项目到底想解决什么问题

先说说为什么这种项目会存在。你自己回顾一下学AI的经历:最早接触TensorFlow或者PyTorch的时候,是不是感觉自己在“调参工程师”的路上越走越远?model.fit(x, y)一行代码跑完,loss降了,准确率上去了,但问一句“反向传播到底怎么算的”“学习率为什么不能设太大”,大概率答不上来。

这就是“from scratch”项目存在的核心原因——它逼你一层层剥掉封装,亲手实现那些被框架隐藏起来的数学和工程细节。ai-engineering-from-scratch这个标题背后潜藏的需求也很明确:不是教你用某个框架,而是教你理解AI系统从数据输入到模型产出、再到部署上线的完整链路。它面向的不是完全没有编程经验的小白,而是那种已经会用框架、但总觉得心里没底的初学者,以及想从“调参侠”进阶为“真正的工程师”的开发者。

这类项目的设计思路通常会沿着一条主线展开:线性代数与微积分基础 → 手写感知机与反向传播 → 从零搭建小型神经网络框架 → 实现常见优化器与损失函数 → 理解数据流水线 → 模型训练与调参 → 部署与推理优化。每一步都要求你扔掉框架,用NumPy甚至纯Python实现,然后再回头去看框架源码,瞬间就会有一种“原来如此”的通透感。

1.2 为什么“自己造轮子”比“直接调框架”更值得

有人会觉得,这都什么年代了,PyTorch都出到2.x了,谁还手写神经网络?这种想法我能理解,但实际做下来,差距是巨大的。

直接调框架就像开自动挡汽车,手写实现就像开手动挡。你可能一辈子开自动挡也活得好好的,但如果你要修车、要改车、要在极端路况下脱离电子辅助系统行驶,那你必须知道变速箱和离合器是怎么配合的。AI工程也一样:框架帮你做好的事情越多,你对系统内部的理解就越薄弱,一旦遇到框架解决不了的问题,就会彻底抓瞎。

举个例子。很多人第一次在PyTorch里自定义一个Loss函数,顺手就写了return torch.mean((pred - target) ** 2),然后发现梯度算出来不对。原因是你没意识到mean操作会对batch维度做平均,而sum不会,这直接决定了梯度的量级,进而影响学习率的选择。这种细节,如果只是用框架封装好的MSELoss(),你一辈子都不会去理解它背后的数学含义,但自己实现一遍之后,你会对“为什么损失函数要求可微”“为什么batch size影响梯度稳定性”等概念理解得更加深刻。

再往大了说,ai-engineering-from-scratch并不只是“手写一个神经网络”这么简单。它真正的价值在于,它逼着你建立一套完整的工程思维:数据怎么清洗、特征怎么设计、模型怎么评估、过拟合怎么判断、部署时怎么做推理加速。这一整套东西,才是AI工程师和AI研究员最大的区别。

1.3 项目的适用人群与学习边界

在动手之前,先想清楚你到底适不适合这条路。根据我自己的经验,适合这个项目的人一般有这些特征:

  • 已经能用Python写代码,了解NumPy的基本操作
  • 用过至少一个深度学习框架,但只是停留在调用API层面
  • 数学基础一般不重要,但要有把公式变成代码的耐心
  • 不追求短期内产出“能跑的模型”,愿意花时间理解底层

不适合的人也很明显:如果你只想快速做出一个Demo去面试或者交付项目,这个项目确实不是最高效的路径。它不是一门“速成课”,而是一个“筑基”的过程。不过话说回来,筑基完成的收益是长期复利式的,后续你再学任何新框架、新模型,吸收速度都会快得多。


2. 核心细节解析与实操要点

2.1 从零实现一个神经网络时最重要的是什么

很多人在从零手写神经网络时会陷入一个误区:一上来就写一个包含五六个隐藏层的深度网络,结果矩阵维度一错、梯度爆炸,调试了两三天都找不到问题。我的建议是:用最简结构跑通全流程,再加复杂度。先用一个单隐藏层、10个神经元的网络,在二分类任务上把所有流程走一遍,再去扩展。

从零实现神经网络的核心要素可以拆成几个模块来理解:

  • 前向传播:输入经过权重矩阵和偏置,逐层计算,得到预测输出。这个步骤虽然简单,但要在代码里注意每一层的输入维度与输出维度,它们是整个系统最容易出错的地方。

  • 损失函数:二分类用交叉熵,回归用均方误差。交叉熵在代码实现上有个细节——如果直接用-np.log(pred),当pred非常接近0时会出现数值溢出,所以实际工程里一般会在预测值上加一个极小量,比如1e-12。

  • 反向传播:这是整个实现里最核心也最容易出错的部分。关键是要把“链式法则”落实到具体代码上,一层一层地求梯度。很多人在这里卡住,是因为没有把每一层的“梯度流”理清楚。

  • 参数更新:最简单的梯度下降,W = W - learning_rate * dW。这里有个初学者容易忽略的点:dW是Loss对W的梯度,而梯度的方向和大小受到损失函数与当前参数的影响,所以学习率的初始值非常关键。

我自己在梳理反向传播时,通常会用一个非常具体的例子去验证代码。比如某层输入的梯度是什么、权重梯度是多少,都先在纸上算一遍,再跟代码的输出结果对比,确认一致后再继续往下写。这个验证习惯能帮你省掉无数排查的深夜。

2.2 数据流水线:AI工程里被低估的重头戏

ai-engineering-from-scratch这类仓库里,数据部分往往容易被忽略,因为“数据处理”听起来太普通了,远没有神经网络那么性感。但实际上,我做了这么多年AI工程,最大的感受是:模型代码只占整个系统的一小部分,数据管线和特征工程才是真正决定项目成败的地方。

具体到从零实现,你需要把一个原始数据集从文件一路处理到可以喂给模型的状态,这个过程通常包括:

  • 数据清洗:处理缺失值、去重、修正异常样本
  • 格式转换:把文本、图像、类别型变量转换成数值张量
  • 归一化/标准化:让不同特征的量纲不影响模型收敛
  • 划分训练集/验证集/测试集:注意不能混入数据泄漏

这里我踩过一个很经典的坑。训练一个图像分类模型时,没有对图像的像素做归一化,而是直接把0到255的原始像素值喂给网络。结果模型训练到100多个Epoch还无法收敛,把学习率调小了也不行。后来才意识到,这么大的输入值会让初始梯度非常大,直接把最优参数跳过。正确的做法是把像素缩放到0到1或者-1到1之间。

数据管线的另一个关键设计是“按batch加载”。很多初学者图省事,一次性把几万张图片全部加载进内存,然后循环训练。数据集小的时候没问题,稍微大一点——十个GB起步——内存直接爆掉。所以必须实现一个类似DataLoader的生成器,每次只加载一个batch的数据。这个习惯要在一开始就从零实现,否则后面用框架的时候,根本不知道batch_size这个参数在背后到底做了什么。

2.3 训练过程中的监控与评估:不能只看loss

在训练模型的时候,很多人盯着训练loss曲线下降就开心,但等到在测试集上一跑,效果一塌糊涂。这里的问题不在于模型,而在于你对训练过程的观察维度太单一了。

训练中必须同时监控几个指标:

  • 训练loss:用于判断模型是否还在学习
  • 验证集loss/准确率:用于判断模型是否过拟合
  • 梯度范数:梯度范数异常增大或变为NaN,说明出现了梯度爆炸或数值不稳定
  • 权重分布:用小提琴图或直方图检查每层权重是否保持合理的分布

我经常打一个比方:训练loss像是汽车的仪表盘,验证集指标像是导航地图,梯度范数像是发动机的转速表。只看仪表盘,你可能开得很爽,但方向已经偏了十万八千里。

实操中这里很有用的一个技巧是:在训练初期用很小的数据子集(比如128个样本)做一次“过拟合测试”。如果你的模型连这128个样本的loss都无法降到接近0,那说明代码逻辑或数据管线大概率有问题。这个测试能在几分钟内帮你过滤掉“逻辑写错”“标签错位”“维度对不上”这一类的低级bug。


3. 实操过程与核心环节实现

3.1 项目结构规划:一开始就避免“什么都堆在一起”

从零开始做一个AI工程项目,第一件事不是写模型,而是规划目录结构。很多人在这个环节偷懒,结果项目越写越乱,最后自己都找不到代码在哪。

我比较推荐的分层结构是这样的:

ai-engineering-from-scratch/ ├── data/ # 原始数据与中间处理结果 ├── src/ │ ├── models/ # 模型定义与层实现 │ ├── losses/ # 各类损失函数 │ ├── optimizers/ # 优化器实现 │ ├── data/ # 数据加载与预处理 │ └── utils/ # 可视化、指标计算等辅助工具 ├── experiments/ # 每个实验的单独目录 ├── notebooks/ # 探索性分析和可视化 └── README.md

这个结构的主要优点是“单一职责”。模型代码、训练代码、数据处理代码彼此解耦,改一个地方不会影响其他地方。如果你刚开始写项目,千万不要把模型、训练循环、数据预处理全塞进一个main.py里,那个文件超200行之后,维护成本就会指数级上升。

每次跑一个新实验时,我习惯在experiments/下建一个以时间戳命名的目录,里面存放本次实验的配置、日志、模型权重和可视化结果。这样后续想复盘“为什么这个模型效果好”时,可以直接对比不同实验之间的配置差异。

3.2 手写反向传播的完整流程记录

接下来用一个具体的例子,完整走一遍从零实现一个单隐藏层分类器的过程。这个例子基本是“Hello World”级别的CNN实现,但它包含了一套完整的反向传播流程,非常值得跟着走一遍。

先定义网络结构:输入层2个特征,隐藏层4个神经元,输出层1个神经元(二分类)。

import numpy as np def sigmoid(x): return 1 / (1 + np.exp(-x)) def sigmoid_derivative(x): s = sigmoid(x) return s * (1 - s) class NeuralNetwork: def __init__(self, input_size, hidden_size, output_size, learning_rate=0.1): # 使用He初始化,注意这里的标准差选择是有讲究的 self.W1 = np.random.randn(input_size, hidden_size) * np.sqrt(2 / input_size) self.b1 = np.zeros((1, hidden_size)) self.W2 = np.random.randn(hidden_size, output_size) * np.sqrt(2 / hidden_size) self.b2 = np.zeros((1, output_size)) self.lr = learning_rate def forward(self, X): self.z1 = np.dot(X, self.W1) + self.b1 self.a1 = sigmoid(self.z1) self.z2 = np.dot(self.a1, self.W2) + self.b2 self.a2 = sigmoid(self.z2) return self.a2 def backward(self, X, y, output): m = X.shape[0] # 二分类交叉熵损失对输出层的梯度 dloss = output - y.reshape(-1, 1) # 输出层参数梯度 dW2 = np.dot(self.a1.T, dloss) / m db2 = np.sum(dloss, axis=0, keepdims=True) / m # 隐藏层的误差项:链式法则 dz1 = np.dot(dloss, self.W2.T) * sigmoid_derivative(self.z1) dW1 = np.dot(X.T, dz1) / m db1 = np.sum(dz1, axis=0, keepdims=True) / m # 梯度下降更新 self.W2 -= self.lr * dW2 self.b2 -= self.lr * db2 self.W1 -= self.lr * dW1 self.b1 -= self.lr * db1

这段代码里有几个非常容易踩坑的细节。第一个是初始化标准差的选择,如果全用np.random.randn生成初始权重,隐藏层越多,梯度消失就越严重。这里用了He初始化,它根据上一层的神经元数量动态调整初始权重分布,能有效缓解深层网络梯度消失的问题。

第二个是损失函数与激活函数的搭配。二分类任务我用的是交叉熵,输出层激活函数是sigmoid,这两个函数组合正好有一个很好的数学性质——输出层的梯度等于output - y。如果你换成均方误差加sigmoid,梯度公式会复杂很多,而且收敛速度明显变慢。

第三个是对batch的归一化。梯度除以m之后,学习率的含义就与batch大小无关了。很多人在这里少了除以m这一步,导致batch size一改,模型学习就变得不稳定。

3.3 训练循环与验证评估的代码实现

网络写完之后,训练循环是整个工程的主干,它要协调数据加载、前向传播、反向传播、指标记录和日志输出。这个循环的代码模式几乎在所有深度学习框架中都是通用的,从零实现一遍,你之后切换到PyTorch或TensorFlow时会非常自然。

def train(model, X_train, y_train, X_val, y_val, epochs=1000, print_freq=100): train_losses, val_losses, val_accs = [], [], [] for epoch in range(epochs): # 前向与反向 output = model.forward(X_train) model.backward(X_train, y_train, output) if (epoch + 1) % print_freq == 0: train_loss = binary_cross_entropy(output, y_train) val_output = model.forward(X_val) val_loss = binary_cross_entropy(val_output, y_val) val_acc = accuracy(val_output, y_val) train_losses.append(train_loss) val_losses.append(val_loss) val_accs.append(val_acc) print(f"Epoch {epoch+1}/{epochs} | Train Loss: {train_loss:.4f} | Val Loss: {val_loss:.4f} | Val Acc: {val_acc:.2f}%") return train_losses, val_losses, val_accs

这个循环导出三个核心指标:训练loss、验证loss、验证准确率。验证集的重要性在这里体现得淋漓尽致——如果训练loss不断下降,而验证loss开始反弹上升,说明模型开始过拟合了,这时候就应当考虑减少训练轮数、增加数据量、加入正则化或者降低模型容量。

我在实际训练中还有一个习惯,就是把每个epoch的指标分别保存下来,而不是只在屏幕上打印。后面对比实验时会非常方便,可以用Matplotlib画出多条loss曲线,一眼看出哪个配置收敛更快、哪个更稳定。


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

4.1 模型输出总是NaN:数值稳定性问题

从零实现深度学习项目,最让人崩溃的问题之一就是训练过程中loss突然变成NaN。排查这个问题的思路要按优先级顺序来:

第一步检查学习率。学习率太大是NaN最常见的原因,尤其是深层网络。当你发现loss在某一轮开始剧烈震荡然后变成NaN,先把学习率除以10或100再试一次。

第二步检查输入数据是否有NaN或无穷大。这个问题很隐蔽,因为很多数据预处理步骤不会显式检查数据的值域范围。一旦某个特征出现NaN,它会一路传染到梯度计算中。

第三步检查损失函数是否出现了log(0)或除以0的情况。交叉熵里-log(0)就是无穷大,所以需要加一个极小的epsilon值,比如1e-12。

第四步检查网络初始化。如果初始权重值设得太大,激活值可能在第一层就饱和,导致梯度消失或爆炸。

4.2 损失下降但准确率纹丝不动:优化目标与指标不匹配

这个现象也很常见:训练loss从0.6降到0.2,但准确率始终在50%左右,像是没有学习到任何东西。这个问题通常出在数据不平衡或标签噪声上。

拿一个二分类任务举例,如果正负样本比例是99比1,那模型只需要把所有样本都预测为负类,loss就会非常低,准确率也能达到99%。但你的目标可能是把少数类的召回率提上去,这时候就要换评估指标,比如F1-score、AUC等。

另外,如果数据集中存在大量错误标注的样本,模型会尝试拟合这些噪声,导致loss很难降到理想的低值。这时候可以检查一下训练样本中是否存在明显的标注错误,尤其是边界情况。

4.3 训练速度极慢:看看瓶颈在数据还是模型

有时候模型没问题,数据也没问题,但就是训练得非常慢。这时候需要定位瓶颈是在CPU数据加载上还是在GPU计算上。

我常用的一个方法是做一个简单的计时输出,样例代码如下:

import time start_time = time.time() batch_data = next(data_loader) data_time = time.time() - start_time start_time = time.time() output = model(batch_data) compute_time = time.time() - start_time print(f"Data loading: {data_time:.4f}s | Compute: {compute_time:.4f}s")

如果data_time远大于compute_time,说明数据管线是瓶颈,解决方向是预读取、多线程加载、或者把数据提前转换成内存中的张量;如果compute_time是瓶颈,那就要考虑简化模型结构、使用混合精度训练或增加硬件资源。

4.4 常见问题速查表

问题现象可能原因优先排查方式
Loss持续不降学习率过大或过小调整学习率,尝试指数扫描
Loss为NaN输入含NaN/无穷大,或学习率过大检查数据值域,降低学习率
训练loss低但验证loss高过拟合增加正则化,减少模型容量
验证集准确率极低数据泄漏或标签错位检查训练集/测试集划分与数据标签
梯度参数异常大数值不稳定检查初始化方法和损失函数搭配
同一份代码结果每次不同随机种子未固定设置np.random.seed()和Python的random.seed()

这六类问题覆盖了从零实现AI工程时80%以上的“跑不通”场景。遇到问题不要慌,按表格里的顺序一条条排查,基本能定位问题所在。


5. 学习路线扩展与进阶方向

5.1 从手写神经网络到现代深度学习框架的过渡

如果你已经完成了从零实现神经网络的过程,我建议你不要直接跳到GPT级别的模型去“挑战”,而是先做一件很有价值的事情:打开你之前用的深度学习框架的源码,找到对应实现,对比你的代码和框架代码之间的差异。

以PyTorch为例,你可以去查看torch.nn.Linear的源码,会发现它内部做的事情其实和你手写的Dense层几乎一模一样——存储权重和偏置参数,做一个矩阵乘法和加法。你还会发现框架加入了设备管理、梯度自动记录、Dtype转换等工程细节,这些封装是必要的,但它们不影响你对核心逻辑的理解。

这个过程的精华在于,你会突然理解框架中很多“看似魔法”的东西刚好对应你自己实现过的每个模块。比如loss.backward()其实就是你手写的backward()方法的自动化版本,optimizer.step()就是你在backward里做的W -= lr * dW那一步。从此再看框架,你不会再有“这是黑盒”的感觉。

5.2 后续值得挑战的核心方向

走完从零实现的基础阶段之后,ai-engineering-from-scratch的精神依然可以沿用到很多进阶方向。我梳理了几个很值得挑战的路线,它们每一个都能继续深化你的理解:

  • 从零实现CNN和RNN:理解卷积操作、池化的梯度流,以及序列模型的时间步展开。
  • 从零实现一个简单的Transformer:自注意力机制、位置编码、LayerNorm的细节,都是当前所有大语言模型的基础。
  • 从零实现一个小的自动求导引擎:这就相当于自己写一个微型PyTorch,对理解衍生计算图有奇效。
  • 从零实现模型推理优化:做量化、剪枝、算子融合,直接用NumPy或C语言重建推理过程,深入理解部署环节。

每一个方向的“从零实现”都会让你对AI工程的某一个侧面建立更深的肌肉记忆。而这类项目的核心价值,本质上是在训练一种“手里没有锤子也能造出钉子”的能力。

5.3 关于“抄作业”式学习的最后忠告

在做这个项目的过程中,有一个心态很重要:你可以参考别人的实现,但必须亲手把代码敲一遍、把每一行弄懂。我见过太多人“一顿CV然后跑通”,最后让他解释这个项目干了什么,说不出三句话。那种情况下,跑通的不过是个精致的Demo,而不是工程能力。

正确的做法是:先自己试着写,卡住的地方去看参考实现,理解了之后再用自己的思路重写一遍。循环几轮之后,这些代码才算真正长在你脑子里,日后无论遇到什么AI相关问题,你都具备从原理层面独立分析的能力。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进