1. 项目定位:这套"AI工程"到底在讲什么
1.1 "from scratch"是在刷题还是真能落地?
"ai-engineering-from-scratch"这个名字,最开始是一个朋友丢给我的GitHub仓库名,问我值不值得花时间跟一遍。我当时的第一个反应是:又一个"手写神经网络"的玩具项目。这些年看过太多"三天学会AI""从零手写Transformer"的东西了,大部分都是把某个公开教程的代码复制一遍,配上华丽的README,点进去全是坑。
但真正刷完这套内容之后,我发现自己判断错了。它没有讲"用PyTorch一行搭建模型"这种套话,而是真的从矩阵乘法、梯度下降、反向传播的计算图开始,一行一行把AI工程里最核心的组件撸出来。它的重点不是"写代码",而是"建立对AI系统全生命周期的工程直觉"——从数据处理、特征工程、模型训练、评估到部署上线,每个环节都会问你一个致命问题:这段流程你只知其然,还是也知其所以然?
所以如果你问我这套东西适合谁,我的答案很明确:适合那些已经会用框架调模型,但总觉得哪里差点意思的人;也适合准备从传统后端、客户端转岗AI工程方向的人。它不适合零基础小白(至少你得会Python基础语法),也不适合只是想快速出结果的生产环境Busy工程师——那直接调成熟框架更快。
1.2 为什么我不建议你直接跳到PyTorch
常见的AI学习路径是:先学Python,然后直接开PyTorch/TensorFlow,跑通MNIST,就觉得自己会AI了。这条路我走过,结果就是典型的"框架熟练工"——我能在十分钟内用三层网络过一遍CIFAR-10,但你要是问我为什么这个学习率会不收敛、为什么Embedding层要这么设计、为什么模型在测试集上表现不错但上线后完全失灵,我大概率会支支吾吾。
这个问题的根源在于:框架帮你隐藏了太多东西。你调用loss.backward()的时候,框架替你完成了全部的导数计算和图调度;你调用model.fit()的时候,框架帮你做了batch切分、梯度更新和打得乱七八糟的日志。这些黑盒子让你的"AI能力"变成了一层薄薄的调用记录,底层任何参数翻脸,你都无从下手排查。
这套"from scratch"的路线本质上是逆行——把框架藏起来的东西重新摊开给你看。我自己的体会是:一旦亲手实现过一次反向传播,再回去用任何框架,都会有完全不同的视角。你不再是在"使用"一个模型,而是在"审视"一个模型。这才是AI工程能力的分水岭。
2. 核心技术点:从数学到代码的落地拆解
2.1 前向传播:把矩阵运算落进你的代码里
AI工程的地基是前向传播——数据从输入层流经隐藏层到达输出层的整个过程。太多教程把这一步讲成玄学,其实剥开外皮,核心就是一个公式:
z = X @ W + b a = activation(z)X是输入数据(形状[batch_size, input_dim]),W是权重矩阵(形状[input_dim, hidden_dim]),b是偏置(形状[hidden_dim]),@是矩阵乘法,activation是激活函数。
关键理解在于:为什么这就能表示"学习"?因为W和b是模型里唯一可变的参数,前向传播把输入映射到输出的能力,完全由这两个矩阵决定。训练的过程,就是不停地调整W和b让输出更接近真实标签。这不是什么高深道理,但它解释了一件事——模型不是"写"出来的规律,而是"搜"出来的规律,搜索方向由梯度决定。
激活函数为什么必须是非线性的?很多入门材料只说"为了引入非线性",没解释清楚。想象一下,如果不用激活函数,多层线性变换的叠加本质上还是线性变换:
Z2 = (X @ W1 + b1) @ W2 + b2 = X @ (W1 @ W2) + (b1 @ W2 + b2)看到没,两层网络和一个单层的表达能力完全一样,深度立刻报废。只有加入ReLU、Sigmoid这类非线性函数,层数越多、表示能力才越强。这一步是我自己手写的时候突然拍大腿想通的,比看十篇博客都有用。
补充一个实操细节:初始化的W不能是零矩阵。如果所有权重初始化为零,每层所有神经元会接收到相同的梯度,更新后依然对称,等于整个网络只有一个神经元在工作。常用做法是随机初始化,让不同神经元一开始就"各有想法"。
2.2 反向传播的直觉理解与代码实现
反向传播是AI工程里最劝退的部分,但它的核心思想其实一句话就能说清:前向传播算的是"模型的输出",反向传播算的是"预测错了,每个参数该为错误负责多少"。
数学上靠的是链式法则。假设损失是L,我们要更新某个权重w,就需要计算dL/dw。路径是w → z → a → L,那么:
dL/dw = dL/da * da/dz * dz/dw每个中间量都是前一步的导数。反向传播的聪明之处在于:把计算过程组织成从输出层向输入层逐层传递梯度,每一层只需要计算自己这部分的局部导数,然后乘以上一层传下来的误差信号。这样不管网络多深,每次更新参数的时间复杂度都是O(网络总参数量),而不是用数值微分那样每个参数都要额外跑一遍前向传播。
我用纯Python实现过一个例子,核心代码长这样(仅展示逻辑骨架):
def backward(self, dout): # dout是从上一层传回来的梯度 d_cache, residual = self.cache # 前向传播时暂存的中间量 dx = dout @ self.W.T dw = d_cache.T @ dout db = np.sum(dout, axis=0) # 参数更新,lr是学习率 self.W -= lr * dw self.b -= lr * db return dx这里最容易踩的坑是:忘记np.sum处理偏置梯度。b是向量,但dout是矩阵(每个样本都会贡献一个梯度),所以要沿着batch维度求和。我第一版跑起来损失不降,排查一小时之后发现就是这一行的问题。
2.3 训练循环里的隐藏细节:batch、epoch和shuffle
说到训练,很多教程代码是这么写的:
for epoch in range(100): for batch in dataloader: loss = train_step(batch)看起来简单,但里面隐藏了三个反直觉的细节。第一,为什么要分batch而不是把全部数据一次喂进去?因为数据集大的时候,一次性算全部样本的梯度,内存根本扛不住;另外全量梯度方向是一个精确但"死板"的方向,分batch产生的随机噪声反而有助于跳出局部极小点。第二,epoch是什么概念?一个epoch等于所有样本都被模型见过一次。epoch数量不是越多越好——训练集loss持续下降,但验证集loss一旦开始回升,就是过拟合的明确信号。第三,shuffle为什么必要?如果不打乱数据顺序,模型会学到数据分布里的位置相关性——比如前5000条全是好评、后5000条全是差评,那模型实际在学"位置分类"而不是"内容分类"。
从工程角度看,训练循环还有一层要求:要能观测、能断点续训、能复现。我自己的习惯是每个epoch结束至少记录五个值:train_loss、val_loss、train_acc、val_acc、learning_rate,然后固定随机种子保证每次跑出来的结果可对比。这些习惯在"from scratch"阶段就要养成,否则后面走工程化迟早吃亏。
3. 工程化实操:AI系统的完整生命周期
3.1 数据切分的黄金比例与三种隐蔽的数据泄漏
模型写得再花哨,数据切分不对,全盘皆输。业界约定俗成的比例是:训练集/验证集/测试集大致为6/2/2。但比例只是表面,真正的坑在于切分方式本身。
最经典的"隐蔽泄漏"有三种。第一种是按原始顺序直接切分,但数据本身有时间趋势——比如用一月份到九月份的数据训练,十月份的数据做验证,模型学到的季节性模式会在验证集上表现得"意外的好",上线之后这个幻象立刻破裂。第二种是在切分之前就做了全局归一化,正确做法是只在训练集上计算均值和标准差,然后用同一套参数去处理验证集和测试集。第三种是特征工程时无意引入了未来信息——比如用了整条样本的统计量去填充缺失值,这在时序场景里等于作弊。
我踩过最惨的一次就是第二种。做回归任务,标准化时用了全量数据的均值方差,验证集上指标漂亮得像做梦,上线后真实用户数据进来直接乱飞。后来排查半天发现就是这个切分前标准化的标准错误——属于那种所有教程都没写但实战必踩的坑。
3.2 模型评估的三大误区:准确率、F1和PR曲线
评估模型是不是"好",最朴素的想法是看准确率(Accuracy),但准确率在类别不平衡的数据集上会骗人。举个极端例子:10000条数据里只有100条是正样本,你瞎猜全部输出负类,准确率已经99%,但这个模型没有任何使用价值。所以工程上要引入精确率(Precision)、召回率(Recall)和F1分数。
- 精确率:预测为正类的样本里,有多少是真的正类(衡量"抓得准不准")
- 召回率:真实正类样本里,有多少被模型抓出来了(衡量"抓得全不全")
- F1 = 2 * P * R / (P + R),是两者的调和平均
选哪个指标取决于业务。内容风控场景,宁可误杀不能漏过,F1要偏召回率;推荐系统里,推了10条用户点了1条还算能接受,但用户真正喜欢的都没推出来就是大问题,这就要偏精确率。所以评估指标不是单选题,而是业务目标翻译题。
还有一个实操细节:如果模型输出的是概率,那阈值默认取0.5,但这个默认值往往不是最优的。画PR曲线、找到"拐点"再定阈值,通常比闷头用0.5效果好得多。这一步虽然不增加任何模型复杂度,但实际收益往往比换一个更强的模型骨架都大。
3.3 从训练到服务的部署要点
模型训练完只是工程的开始。部署环节有几个容易忽略但致命的点。
第一,训练环境和推理环境的一致性。训练时用float32,推理时为了性能压成float16,如果不在意精度差异直接上生产,某些对数值敏感的任务(比如排序模型)效果断崖式下跌。第二,特征处理的代码必须和训练时保持完全同源。条件允许的情况下,直接把训练阶段的预处理函数封装成独立模块,推理服务只调用这个模块,不要在新环境重新实现一遍——两者的off-by-one、默认值差异,比模型本身的缺陷难查十倍。第三,线上监控不只是看QPS和延迟,还要看模型输出的分布漂移——用户输入习惯变了,模型输出概率的平均值也会跟着变,这类信号是质检模型是否需要重新训练的第一线索。
4. 完整案例:从零构建一个文本情感分类器
4.1 需求定义与基线选择
这里我用一个具体的例子,把前面所有的知识点串起来。任务很简单:给一段中文短文本,判断情感倾向是正面还是负面。数据集我用了自己整理的2000条影评,正负各半。
动手之前先定义清楚三件事:输入是什么(文本字符串),输出是什么(0/1标签),评价指标是什么(F1)。然后我特意先去跑了一个"无脑基线"——直接把所有文本预测为多数类(即全预测为正面,因为训练集正负平衡,这个基线的准确率就是50%)。跑基线不是为了展示多厉害,而是为了锚定后续所有工作的下限:如果我的模型连这个基线都超不过,说明问题出在数据或代码层面,而不是模型选择上。
4.2 数据准备与特征工程
文本数据不能直接喂给神经网络,要变成数值。我用了一个比较原始的方案:词表映射 + 词袋特征。
先做分词(这里用最朴素的按空格切分,中文先按字切分,方便演示),然后构建词表:
from collections import Counter def build_vocab(texts, min_count=3): counter = Counter() for text in texts: counter.update(text.split()) # 过滤掉出现次数少于min_count的低频词 vocab = {word: idx for idx, (word, count) in enumerate(counter.items()) if count >= min_count} return vocabmin_count=3是个经验值,太低会把噪音词放进词表增大维度,太高会丢掉有区分度的词。然后每个样本变成一个和词表一样长的稀疏向量,出现过的词对应位置填1:
def text_to_vector(text, vocab): vec = np.zeros(len(vocab)) for word in text.split(): if word in vocab: vec[vocab[word]] = 1 return vec这一步做完,每个样本就是一个2000维左右的二值向量。特征选择直接简化,能跑通流程优先。
4.3 模型实现与训练调参
我用了两层全连接网络,结构是input_dim(2000) → hidden_dim(128) → 1,输出层不带激活函数,用Sigmoid把输出压到(0,1)区间,配合二分类交叉熵损失训练。
关键参数的选择我有意识地和"拍脑袋"做了区分:
- 学习率0.1:Adam优化器配0.1在我们这个浅层网络上问题不大,但如果是SGD优化器,0.1已经偏大,可能直接发散。我选用这个值,是先用0.01跑了一轮看loss下降速度,再翻倍到0.1确认没有振荡才定下的。
- batch_size=32:2000条数据分成63个batch左右,每轮参数更新63次,训练速度和收敛效果的折中。
- epochs=30:同时观察train_loss和val_loss,如果val_loss连续5个epoch不再下降,就提前停止。
训练过程中我记录了一个小细节:前5个epoch里train_loss从0.69降到0.45,但val_loss几乎没动——典型的轻微过拟合苗头。我在第5个epoch之后加了dropout=0.3(训练时随机丢弃30%的隐藏层神经元),val_loss立刻开始跟上下跌。这是从"框架熟练工"到"工程直觉"的最直观一步:你知道dropout这个参数存在,和你知道"这个任务这个阶段该打开它",完全是两回事。
4.4 评估与上线建议
最终模型在测试集上的F1稳在0.86左右,比瞎猜的0.50基线提升了整整0.36。我还顺手画了PR曲线,发现阈值取0.45比默认的0.5能再提高0.01的F1——别小看这1个百分点,线上效果它就是实打实的用户感知差异。
部署的代价值得提一句:由于我用纯Numpy实现的模型,推理速度其实不错,单条文本毫秒级返回。但生产环境里我不会这么干——原因是纯手写网络在工程维护上代价太高。我会把权重导出成NumPy数组,然后用成熟的推理引擎加载,或者直接迁移到PyTorch的定义中。手写实现是"学习工具",不是"生产工具",这个边界要划清楚。
5. 常见问题与避坑实录
5.1 损失值不下降,先别急着调模型
这条我反复踩过,现在第一反应永远不是"换模型",而是按这个顺序排查:
| 排查项 | 检查方法 | 常见原因 |
|---|---|---|
| 数据预处理 | 打印几个样本,检查标签和特征是否对齐 | 索引错位、标准化用了全量数据 |
| 梯度计算 | 用数值梯度对比解析梯度 | np.sum漏加、维度转置错误 |
| 学习率 | 打印lr,尝试缩小/放大10倍 | 过大发散、过小原地踏步 |
| 损失函数 | 先跑少量batch看loss是否为预期初值 | 标签错误、多分类用了二分类损失 |
我自己最冤的一次是:训练数据里混进了NaN值,前向传播算出来的loss直接变成NaN,但我盯着代码看了半小时没发现,最后用np.isnan扫了一遍数据才揪出来。这个教训就是——遇到训练异常,先怀疑数据,再怀疑梯度,最后才怀疑模型结构。
5.2 过拟合的识别与解决信号
过拟合是AI工程里出现频率最高的"慢性病"。识别它不需要等训练结束,只要训练过程中出现"train_loss持续下降、val_loss开始回升"这个剪刀差,就是铁证。解决办法按优先级排:
- 增加数据(最有效但成本最高)
- 降低模型容量(减少隐藏层宽度、深度)
- 加正则化(L2正则、dropout)
- 数据增强(文本里做同义词替换,图像里做旋转裁剪)
其中一个反直觉的经验是:dropout不是加得越多越好。我在一个文本任务上把dropout从0.3加到0.5,val_loss反而up了——因为模型容量本身就不大,过度dropout直接把表达能力从"过拟合"推到"欠拟合"了。正常起始值是0.3,调到0.5还没效果的话,优先考虑加数据而不是继续调这个参数。
5.3 什么时候回Numpy手写,什么时候必须用框架
作为一路手打过来的人,我的建议是:前两个项目务必手写全流程,后面的项目一律用成熟框架。
"from scratch"的价值是让你建立微观心智模型,你亲手写过一次反向传播,以后排查框架问题时脑子里会有计算图的坐标系。但一旦进入生产环境,用Numpy手写模型就是给自己制造麻烦——缺少自动求导、缺少GPU并行优化、缺少分布式训练支持,这些都是硬伤。
我现在的习惯是这样的:新创意、新算法结构先用PyTorch快速验证;如果验证过程遇到诡异问题,再拆出来用Numpy写一个最小复现,在最小例子上定位问题;确认无误后回到框架代码里修。这套流程兼顾了框架的效率和手写的诊断力,算是我从"from scratch"里收获的最大财富。
结尾的话
这套"ai-engineering-from-scratch"刷下来,最值钱的不是那些代码,而是你被迫回答了很多"为什么"。为什么要有激活函数、为什么用交叉熵不用MSE、为什么参数要随机初始化、为什么Loss降不下去先查数据——这些问题以前躺在框架的封装层底下,你不主动往下挖,永远不会遇到它们。
我在实际带新人时也发现了这个规律:一个工程师是不是真的理解了AI工程,不看他能叫出多少模型名字,而看他遇到问题时的排查顺序和判断依据。手写一遍,不是终点,而是起点——它把你从"调用者"变成"理解者",之后再用任何框架,都只是工具选择问题,而不是能力天花板问题。
最后再多说一句技术层面的体会:从零实现不是目的,但它是通往"工程直觉"最短路程。如果你也在学AI工程的路口犹豫是直接上框架还是先打基础,我可以负责任地说——先把底层打通,再抄近路,后面省下的时间远比你想象的要多。