2024年我把一个GitHub仓库从零推到接近1000 star,仓库名就叫ai-engineering-from-scratch。这个项目的初衷非常朴素:市面上的AI课程要么教你调包,要么从头开始推导三个月数学,中间那条"真刀真枪把模型跑起来、调好、部署出去"的路几乎没人系统讲。所以我干脆自己建了一条路线,不依赖任何高级框架,用最基础的Python和NumPy从第一行代码开始搭出可训练、可评估、可上线的AI系统。这篇博文就把这个项目的设计思路、踩坑记录和价值拆解全部写出来,想入行AI工程或者正在转型做机器学习工程师的朋友,可以直接拿这份路线当参考。
先说清楚一件事:ai-engineering-from-scratch不是一个传统意义上的教程仓库,它更像一张"自学者作战地图"。它的核心主张是——不把任何库当作黑盒。框架当然可以用,但前提是你已经用手写代码理解过底层的梯度、反向传播、数据流和评估逻辑。为什么这么较真?我后面会详细解释。在这里只需要记住:AI工程的能力分两种,一种叫"会用",一种叫"能造"。前者决定你能不能干活,后者决定你能不能解决别人解决不了的问题。这个项目押注的是后者。
1. 为什么"从零开始"反而是AI工程的最短路径
很多人一听"从零手写"就觉得浪费时间,理由是生产环境里没人会自己写反向传播。这话对了一半:生产环境确实没人从零写反向传播,但这不代表你不需要理解反向传播。我做过一次实验,让两个基础差不多的新人同时进入一个推荐系统项目。一个直接上手PyTorch,三天跑通了一个DeepFM模型;另一个先用NumPy手写了一个简单的逻辑回归,再进入同一个项目。结果一周之后,差距拉开了——前者遇到loss不下降时只会调学习率,后者会直接打开模型中间层检查梯度流向,五分钟定位到特征缩放出了问题。
差别在哪?出问题时的诊断能力。框架把95%的复杂性藏起来了,但藏起来不等于消失。生产环境的AI系统出故障,80%的情况不是模型架构有问题,而是数据、特征、梯度、评估这些"底层细节"出了问题。如果你对这些细节的运作机制没有体感,就只能靠猜。
从零开始还有一个不可替代的好处:彻底摆脱"调包恐惧"。我见过太多开发者,一看到模型的源码就头皮发麻,觉得自己"不配"去读。这种恐惧的根源是你从没建立过"我也能实现这些"的自信。当你真的用200行NumPy写出一个能跑到90%准确率的线性回归,再回过头看PyTorch的nn.Linear,你会觉得那只是一个带缓存和自动求导的封装。自信来自掌控感,掌控感来自亲手构建。
还有个工程上的理由。从零开始意味着你几乎不依赖重框架,这让项目在不同环境下都能稳定运行。ai-engineering-from-scratch整个仓库的核心依赖只有numpy和matplotlib,训练一个小型MNIST分类器不需要先配好CUDA环境。对初学者来说,少一个环境坑就少一次劝退;对教学场景来说,依赖越少越容易复现。
当然也有反面。从零开始的代价是初期进度慢,如果本身Deadline紧逼,确实不适合从零开始搞。我的建议是:如果是为了学东西,从零开始;如果是为了出活,直接用框架。但每个AI工程师职业生涯里至少要有一次"从零把训练循环写穿"的完整经历。
2. 技能树优先级:数学、代码、系统,三刀切在哪
ai-engineering-from-scratch的路线图经过三次迭代,第一版贪多,把微积分、线性代数、概率论、凸优化全铺进去了,结果大多数人第一周就放弃。第二版又太偏工程,直接上手写数据管道,结果是代码能跑但看不懂结果。最终版把技能树砍成三条线,每条的深度都刚好卡在"够用且能进阶"的分界线上。
2.1 数学:只学"会算"不学"会证"
AI工程需要的数学和数学系需要的数学完全是两码事。工程需要的数学是"看得懂公式、能手动推导小规模梯度、知道每个操作在干什么"。数学系要求的是完备性和证明严谨性,对不起,那不是我们的核心目标。
具体砍法是这样:
- 线性代数:掌握矩阵乘法、转置、逆、特征分解的意义即可。重点不是计算,而是维度——你能不能一眼看出两个矩阵能不能乘,相乘之后维度变化是什么。这在写前向传播和反向传播时是救命技能。
- 微积分:核心就两件事——链式法则和偏导数。反向传播本质就是链式法则的工程化实现。你不用会证明,但给你一个复合函数,你要能徒手写出逐层梯度表达式。
- 概率论/统计:训练集和测试集为什么要分层采样?A/B测试怎么判断显著?confidence interval和模型误差有什么关系?这些用到的都是基础概率论,重点在理解"不确定性的表达方式"。
我经常用一句话概括这个数学量级:"你能徒手推导出逻辑回归的梯度更新公式,你的数学就够用了。"后面的学习缺什么补什么,不用系统学完再开工。
2.2 代码:从"能跑"到"能写干净的抽象层"
数学砍完之后,代码能力就得扛起重任。from scratch项目的代码功力要求是分层的:
第一层是表达能力——能把数学公式翻译成向量化代码。这一层需要熟练使用NumPy的广播机制、einsum、矩阵索引。很多人写出来的训练循环跑得很慢,不是机器不行,而是写了一层Pythonfor循环,把向量运算拆成了标量运算。向量化不是优化技巧,是基本功。
第二层是抽象能力——你的代码不能是一坨从上到下堆完的函数,要有清晰的模块边界。我在项目里把代码按六个模块切分:数据加载、预处理、模型定义、训练循环、评估器、可视化工具。每个模块可以独立跑通、独立测试。这样做的好处你两周后就会感受到:换一个数据集时,你只需要改数据加载模块,别的都不用动。
第三层是调试能力——梯度检查(gradient check)是最重要的工程习惯。手写反向传播最大的隐患是:前向传播算对了,梯度算错了,但模型还能往下降一点,你根本发现不了。我通常的做法是写一个数值梯度的辅助函数,用中心差分法验证手写梯度。误差超过1e-5就说明反向传播有bug,马上修,绝不拖着。
2.3 系统思维:从模型到服务的全局观
这是AI工程和AI算法的最本质区别。算法工程师的交付物是"一个高指标的模型文件",AI工程师的交付物是"一个稳定运行的AI功能"。
系统思维在from scratch项目中的体现是三个强制要求:
- 你必须把训练好的模型序列化保存并重新加载,不能每次预测都重新训练。
- 你必须写一个最简单的推理服务,让外部进程可以请求你的模型并进行预测。
- 你必须给模型加输入校验——如果有人传了一个字符串、一个空张量、一个维度不对的数组,系统要报错而不是默默崩溃。
这三个要求看似简单,但它们把"做模型"和"做系统"之间最重要的那层窗户纸捅破了。很多跑通Notebook就觉得自己会了AI的人,恰恰死在这三个要求上。
3. 手写训练循环的完整拆解:从线性回归到两层神经网络
ai-engineering-from-scratch的主线是三条:线性回归、逻辑回归、两层神经网络。这三步循序渐进,每一步都补一个新核心知识点。很多人觉得线性回归太"小儿科",但我故意把它放在第一位——因为它是建立"前向传播-损失计算-反向传播-参数更新"完整闭环的最小模型,在这个闭环里建立的直觉,会直接迁移到后面的大模型上。
3.1 线性回归:第一次完整跑通优化闭环
线性回归的模型长这样:y_hat = X @ w + b。损失函数是均方误差。优化的目标是找到一组w和b让损失最小。
整个训练循环代码如下,总共不到30行:
import numpy as np # 生成模拟数据 y = 3*x1 - 2*x2 + 1 + 噪声 rng = np.random.default_rng(42) X = rng.normal(size=(1000, 2)) true_w = np.array([3.0, -2.0]) true_b = 1.0 y = X @ true_w + true_b + rng.normal(scale=0.1, size=1000) # 初始化参数 w = np.zeros(2) b = 0.0 lr = 0.1 n_epochs = 100 n_samples = len(X) loss_history = [] for epoch in range(n_epochs): # 前向传播 y_pred = X @ w + b loss = ((y_pred - y) ** 2).mean() loss_history.append(loss) # 反向传播(手写梯度) grad_w = (2 / n_samples) * X.T @ (y_pred - y) grad_b = (2 / n_samples) * (y_pred - y).sum() # 参数更新 w -= lr * grad_w b -= lr * grad_b print(f"学到的 w: {w}, 真实 w: {true_w}") print(f"学到的 b: {b:.4f}, 真实 b: {true_b}")这段代码最大的教学价值在于它把梯度是向量的平均值这个直觉烙印在脑子里。grad_w的计算可以理解为:每个样本对梯度的贡献求和之后除以样本数,再把学习率乘上。很多人一开始搞不清楚为什么要除n_samples,其实不除也能收敛,只是loss的绝对数值会被样本数放大,学习率的调节范围变得很不稳定。统一除以样本数之后,学习率的选择就和样本量解耦了,这才是批量梯度下降的标准做法。
3.2 逻辑回归:理解分类问题是概率问题
有了线性回归的闭环基础,逻辑回归只需改两个东西:输出的激活函数和损失函数。模型输出经过sigmoid压缩到0和1之间,损失函数换成交叉熵。
这里重点讲一个常见的困惑:为什么不能继续用均方误差做分类?
我直接在项目里跑了对比实验。用均方误差做二分类的loss,训练过程会出现"梯度饱和":当模型预测概率接近0或1时,sigmoid的导数趋近于0,导致跨过决策边界之后模型就"不动了",难以自我纠正。而交叉熵在预测值和真实值差得远的时候梯度大,让模型快速纠正方向。这个差别就是回归任务的损失函数假设了高斯噪声,分类任务的损失函数假设了伯努利分布——你选的loss必须和你的数据生成方式匹配。
这个问题的本质,值得你从零推导后彻底想明白。
3.3 两层神经网络:理解"特征工程自动化"的原理
线性模型的问题是表达力受限。如果数据不是线性可分的——比如XOR问题——你再怎么调参,单个线性模型都白搭。解决办法就是加一层隐藏层。这个加层动作的本质是:让模型自己学到一个新的特征表示。
我在项目里写了一个隐藏层16个神经元、输出层1个神经元的两层网络。反向传播的关键是用链式法则把输出层的误差"传回"隐藏层,这需要你会写矩阵形式的梯度。写到这里,线性代数的"维度视角"优势完全体现出来了——每一层的梯度形状必须和该层的权重形状一致,能写出正确的矩阵乘法不是靠背公式,而是靠维度匹配思维。
这两层网络是我在全项目里最推荐大家反复重写的部分。不只是为了调参,而是因为绝大多数复杂神经网络的反向传播原理和它一模一样。你把这两层的链式法则吃透,后面看CNN、RNN、Transformer的论文时,至少不会被梯度计算卡住。正如我常对项目读者说的:你手写不出两层网络的反向传播,看十篇Transformer论文也只是看个热闹。
4. 从"能收敛"到"结果可信":数据管道与评估体系的工程必修课
手写模型只是第一步。ai-engineering-from-scratch走到中段之后,我会强制要求自己处理一个真实数据集——经典的选择是Kaggle的Titanic或House Prices。这一步的重点从"模型训练"切换到"数据工程":数据清洗、特征变换、训练/验证/测试集合划分、评估指标选择。说白了,就是把你从"模型玩具"带到"实验结果可信"的工程素养锻造场。
4.1 数据集划分:没有留出集的模型评估都是自欺欺人
初学者最常见也是最致命的问题,就是用同一份数据既调参又评估。你不断调整模型让它在训练集上的loss更低,最终的结果是模型把训练集的噪声也背下来了——这就是过拟合。解决方法是三组数据各司其职:
- 训练集:用于计算梯度、更新参数。
- 验证集:用于做超参数调优,比如选学习率、选隐藏层神经元数。
- 测试集:模型彻底定稿前永远不碰它,最后只用一次,用于给出最终评估。
在from scratch项目中我用一个数据划分函数来规范这个流程,每次运行都会固定随机种子,保证结果可复现:
from sklearn.model_selection import train_test_split X_train, X_temp, y_train, y_temp = train_test_split( X, y, test_size=0.3, random_state=42, stratify=y ) X_val, X_test, y_val, y_test = train_test_split( X_temp, y_temp, test_size=0.5, random_state=42, stratify=y )注意这里的stratify=y,它确保分类问题中训练集、验证集、测试集的类别比例和原始数据一致。如果忽略这一步,切出来的分布可能偏差很大,最后模型在测试集上的表现根本不能代表真实场景。
4.2 特征工程:统计变换背后的算法逻辑
from scratch路线对特征工程的态度是:不要求你手工设计复杂特征,但你必须理解每类变换背后的算法逻辑。
- 标准化(均值0,方差1):如果你用的是带梯度下降的模型,特征尺度不一致会导致参数更新路径扭曲,收敛缓慢。这是算法层面的原因,不是玄学。
- One-Hot编码:分类特征直接当数值输入是灾难——
2是1的两倍吗?one-hot本质上是把类别放到一个互相正交的向量空间里,消除这种"数值Meaning"。 - 对数变换:处理长尾分布特征(如房价、收入)时,对数变换可以把极度右偏的分布拉回近正态,从而稳定模型训练。
在项目里我会同时提供"变换前"和"变换后"两个模型的对比实验。很多学员在这节课后反馈说,他们第一次直观看到同样一个模型,仅靠特征变换就能提升5-8个百分点的准确率。这种体感比任何理论空谈都有说服力——从统计到算法,每一环都有明确目的,你操作得越明白,对结果就越有底气。
4.3 评估指标:Accuracy之外的世界
对于分类问题,大部分人第一反应就是准确率。但在绝大多数真实业务场景里,这是最危险的指标。
举一个项目的实例:信用欺诈检测任务中,10000个样本里只有100个欺诈,正类占比1%。如果你的模型无脑把所有样本预测为"非欺诈",准确率是99%——看起来完美,实际上什么都没学会。这时候正确指标是精确率(Precision)和召回率(Recall),以及它们的调和平均F1。精确率关注"你抓到的欺诈中,多少是真的",召回率关注"所有真实欺诈中,你抓到了多少"。这两个指标往往此消彼长,你需要根据业务场景决定哪些错要少犯。
用几条简单的NumPy代码,配合自制混淆矩阵可视化,项目里把这些指标从公式一路推到实操。把每个评分指标和业务含义绑定起来,这个习惯会让你在未来任何一个AI项目里游刃有余——不管模型多花哨,评估是AI工程的一切,因为只有评估能让"看起来不错"变成"真的不错"。
5. 把模型送上线:从一个Sleep函数开始的最简部署路线
模型在Notebook里跑得好,不等于它能被用户使用。AI工程与AI算法的分水岭,就藏在"送上线"这条路上。from scratch项目的最后一环,要用最朴素的方式打通"训练→序列化→加载→推理服务"这条链路。不是让你上K8s,而是让你体验"模型成为一个对外功能"的完整转变。
5.1 序列化:用NumPy自己的格式存模型参数
一个模型的本质是结构+参数。对于我项目里的两层网络,序列化只需要存下各层的weight和bias两个矩阵。
model_params = { "W1": model.W1, "b1": model.b1, "W2": model.W2, "b2": model.b2, } np.savez("model_params.npz", **model_params) # 加载 loaded_params = np.load("model_params.npz") W1 = loaded_params["W1"]这里故意不引入pickle或torch.save,就是为了逼你思考:模型的"可移植性"来自对自身结构的清晰认知。你写一次这个加载流程,以后遇到任何框架的模型格式转换,都更清楚背后发生了什么。模型不再是一个内存里活蹦乱跳的对象,而是一个可以用文件传递的资产。
5.2 推理服务:把predict暴露成一个HTTP接口
项目里用FastAPI写了一个极简服务端。这个接口做的事情很简单:接收JSON输入,解析成NumPy数组,调用模型的predict方法,返回预测结果。代码大约30行,但对工程习惯的塑造是全方位的。
from fastapi import FastAPI, HTTPException from pydantic import BaseModel import numpy as np app = FastAPI() model = load_model("model_params.npz") class PredictRequest(BaseModel): features: list[float] @app.post("/predict") def predict(req: PredictRequest): try: x = np.array(req.features).reshape(1, -1) prob = model.predict_proba(x)[0, 1] return {"probability": prob, "prediction": int(prob >= 0.5)} except Exception as e: raise HTTPException(status_code=400, detail=str(e))这里有个看起来不显眼但极其重要的细节:输入校验。如果用户少传了一个特征,或者传了一个字符串,系统不能默默崩溃,必须返回一个清晰的400错误。很多公司的AI服务出P0事故,不是模型出了问题,而是线上收到了从未见过的数据格式。输入校验这层防御,是AI工程的基础素养——模型是功能的一部分,但不是全部。
5.3 环境问题:为什么我坚持不在这部分引入Docker
可能有人会问,为什么不顺手上Docker?我的回答是:先学会跑,再学会飞。对于第一次接触部署的学员,先把"进程如何启动、接口如何发起请求、模型如何加载"这条主链路跑通,比引入容器化技术重要百倍。你一上来就Docker Compose、K8s,遇到故障时根本分不清是模型的问题还是基础设施的问题。
等你能用纯手动方式启动一个推理服务,下次在Docker里封装它就只是机械动作了。把每一层叠加的"为什么"都吃透再往上盖楼,反而是最快的路径。项目文档里我给了一条从uvicorn app:app --reload到用curl发请求的完整实测步骤,保证不靠任何额外工具就能验证服务正确。
6. AI工程自学的三大坑和我的对应解法
ai-engineering-from-scratch发布后,我收到的issue和各种私信超过500条。筛掉重复问题,自学者踩的坑高度集中在这三个上。如果你正在走这条路,希望这些经验能让你少掉一半头发。
6.1 坑一:数学恐惧导致永远迈不出第一步
解法:用代码当主线,数学当支线。你不需要先学完线性代数和微积分再开始写代码,那样你永远开不了工。我的建议是直接跑一遍线性回归代码,把每一行对应到公式上,遇到不懂的数学概念再回头定点补。用问题驱动学习,而不是用教材驱动学习。数学是为了让你理解代码在干什么,不是拦路虎。实践是最快的认知路径,不是理论学习完之后才进行的验证环节。
6.2 坑二:复现失败就怀疑自己,然后放弃
解法:复现的顺序错了。不是"照着大牛的代码一行行敲完就完了",而是要把整体拆成功能块来验证。举例:你在写逻辑回归时,可以先单独测试sigmoid函数的输出是否在0-1之间,再测试loss函数:输入一个极端的预测值和真实值,看loss是否接近预期。如果你把每个模块单独验证过,最后组装时出了问题,你至少知道问题不在模块内部,而在模块之间的连接。这一套"自底向上逐块验证"的工程方法,能让你的调试时间从以天计缩减为以小时计。
6.3 坑三:追求"最新最好的模型",忽视基本功
解法:Twitter上每天都有"XYZ模型刷新了SOTA"的推送,但那跟你没关系。你连两层网络的反向传播都没闭卷写出来过,去看十亿参数的架构就是在沙子上盖楼。这个项目能坚持走到最后的学员都有一个共同特征:延迟满足能力强。他们愿意花两个周末只搞懂一个链式法则的工程应用,而这份耐心在一年后换来的,是看任何新模型论文时迅速抓住核心机制的能力。AI工程这个领域最大的护城河不是知识量,是底层理解力。每过两年模型架构就可能换一波,但反向传播不会退休,数据分布思维不会过时,评估工程的严谨性永远重要。把手伸进黑盒一次,之后所有的盒子在你眼里都是透明的。
7. 这个项目后续还能怎么扩展:从手写MLP到打通LLM微调链路
ai-engineering-from-scratch目前止步于两层神经网络和基础部署,但这不是终点。我最近在做的事,是把这条从零搭建路线延伸到LLM时代。新增的路线图分三步走:
- 从零实现注意力机制:用NumPy实现一个单头注意力,这是通往Transformer的最小单位。理解
Q、K、V三个矩阵怎么配合、怎么经过softmax加权求和,是理解大模型生成原理的唯一捷径。 - 用纯Python实现一个"微型GPT":在字符级文本上训练一个非常小的Transformer,让它学会生成可读的文本。这个项目会彻底打掉你对大模型的"魔法感"——你会亲眼看到,所谓智能的生成,本质上是一连串矩阵乘法和概率抽样。
- 打通从手写模型到微调开源模型:最终目标是让读者用自己已在
from scratch项目中练出的底层理解力,去驾驭HuggingFace上的真实大模型。
很多学员问我为什么要做这个方向的扩展。我的回答是:底层原理的通用性超出你的想象。你手写的两层网络权重更新迭代过程里学到的梯度直觉,在训练GPT时依然完全成立;你手写注意力机制时建立的维度意识,在理解多模态融合时同样适用。框架和模型都会过时,但这些底层能力不会。这也是ai-engineering-from-scratch这个名字想传递的最终信号:不要害怕从零开始,因为从零开始的人,恰恰拥有最坚实的起点。
结合我个人运行这个项目一整年的经验,最大的体会是:AI工程之路从来不是陡峭的悬崖,而是一长串缓坡,缓坡的可怕之处在于你感觉不到自己在上坡。但只要你每一步都把手里的代码理解透彻,再回头看起点,就会发现当初觉得遥不可及的东西,早已成了你日常思考的一部分。如果这篇文章让你产生了一点"我也能试试"的念头,不用再想,去把第一个线性回归的循环敲出来吧。