去年我给自己列了个项目,名字就叫 ai-engineering-from-scratch。听起来像个开源仓库,其实它更像一份自我训练计划:不借助任何AI平台的现成能力,从一条原始数据、一台普通笔记本开始,把AI工程全链路亲手做一遍。为什么要这么折腾?因为我在实际业务里吃过亏——直接调用现成模型框架很快,但一旦线上数据长得和训练集不一样、预测结果需要解释、模型隔三差五给你出点怪问题的时候,你手里全是黑盒,连从哪里下手排查都不知道。
这个项目做完之后,回头看它解决的问题其实很清晰:它让我真正理解了AI工程链条上每个环节的“为什么”,也沉淀了一套可以迁移到真实业务里的最小工程模板。这篇文章不是泛泛而谈AI工程概念,而是记录我实际动手时怎么选型、怎么写代码、怎么避坑,希望给准备自己从零跑一遍AI项目的朋友一点参考。不管你是刚转算法的数据新人,还是想补齐工程能力的后端开发者,只要能装好Python,都能沿着这条路线走通一遍。
1. 为什么我坚持从零搭建AI工程,而不是直接套用现成框架
1.1 调包一时爽,排障火葬场
我早期做数据分析的时候,处理分类问题基本就是sklearn.LogisticRegression一把梭。Pipeline一写、fit一跑、准确率一打印,看起来整个流程天衣无缝。但问题出在“看起来”上。有一次模型在测试集上准确率到了88%,一上线,业务方隔天就跑过来说预测结果特别离谱——老客户被预测成高流失、新注册高活跃用户反而被判为要流失。我去排查时发现,训练样本里的特征和时间线上的实时数据分布根本不一致,但因为所有环节都藏在封装层后面,我连“哪个环节出的错”都定位不到。
调包本身没有错,错的是我对封装层内部发生了什么一无所知。AI工程说到底是数据流、特征流、模型流、服务流四条线的组合。你跳过中间任何一环的理解,遇到问题时就只能瞎试。这种时候我才下定决心,要用 from scratch 的方式把整条链路自己搭一遍。先不谈性能优化,至少每个环节的长相和边界要搞明白。
1.2 从零做一遍,才理解数据是怎么“流”起来的
从零搭建之后,我发现最大的收获不是“会写逻辑回归了”,而是脑子里真的有了数据流的概念。原始 CSV 从data/raw.csv读进来,经过清洗变成干净的 DataFrame,再经过特征工程变成模型输入矩阵,模型训练后产出参数文件,接口服务再把“新数据”变成“新预测”。每一步的输入和输出都清清楚楚。
这种认知在日常项目里非常值钱。因为大多数业务问题到最后都是数据问题,而数据问题的定位方式就是沿着数据流一段一段检查。如果你从零搭过一遍,你会习惯性地问:新进来的数据有没有经过同样的清洗逻辑?缺失值填充的规则是不是和训练时一致?标准化用的均值方差是不是训练集上的?这些问题不自己动手做过,很容易忽略。
1.3 少折腾的技术栈选型
我从零搭建不是说要连 Python 解释器都自己写,而是尽量用轻量、透明的工具,避开“重型框架”带来的心智负担。技术栈选型如下:
| 模块 | 选型 | 选择理由 |
|---|---|---|
| 语言 | Python 3.10 | 机器学习和后端生态最全,语法直观 |
| 数据处理 | pandas 2.x | 表格型数据的清洗、过滤、分组都很顺手 |
| 数值计算 | NumPy 1.24 | 手写模型训练逻辑的基础计算库 |
| 模型算法 | 手写逻辑回归 | 参数、梯度、正则全部可见,理解最核心的训练过程 |
| 特征切分 | scikit-learn 的 train_test_split | 只用它的数据切分能力,不用它的训练封装 |
| 接口框架 | FastAPI + uvicorn | 轻量、自带请求校验和接口文档,适合快速上线 |
| 模型持久化 | joblib | 保存模型参数和标准化器,简单可靠 |
| 环境管理 | venv + pip | 系统自带,无额外成本 |
这套组合就一个原则:所有关键逻辑都暴露在眼前,不藏黑盒。你要真想理解AI工程,就不要一上来就上大而全的ML平台,也别一上来就调深度学习的封装接口。先用最笨的方式把最小闭环跑通,后面再换任何高级工具都来得及。
2. 从零动手前,建议先准备好三样东西
2.1 心态:把“跑通”和“跑对”分开
从零搭建最大的坑是“想得太容易”。你以为一个下午就能跑通,实际上光是数据清洗就可能折腾一整天。我建议把心态调整为“先跑通,再跑对,最后跑稳”。第一版代码哪怕很丑,先让它从头走到尾能输出一个预测结果;然后逐步检查每一段逻辑是否符合预期;最后再考虑代码结构、并发和监控。
这里有一个特别实用的习惯:每一步都打印中间结果。清洗后打印df.shape,特征工程后打印X.head(),训练时每200个epoch打印一次 loss,评估时打印混淆矩阵。这些“看得见的输出”是你定位问题的唯一线索。千万别闷头一路写完再统一调试,那样出错时根本不知道是哪一步污染了数据。
2.2 数据:先找一个小而脏的数据集练手
很多人一上来就想去跑完整版 Kaggle 竞赛数据,我劝你别这么干。数据太大,你的注意力全花在等待和哄GPU上,反而学不到工程要点。我选的是一个客户流失预测数据集,字段也就十几个,正负样本比例大致在 80:20,既有数值列也有类别列,非常适合练习。我甚至故意往里面塞了一些脏东西:重复行、年龄=负数、Balance列混入字符串、数值列出现缺失。为什么这么做?因为脏数据才是真实业务里的常态,如果一开始就拿着干干净净的数据集,你根本练不到清洗能力。
数据文件建议固定放在data/raw.csv,处理完的写回data/processed/。这一步看似简单,但能帮你建立“原始数据永远只读、派生数据单独落盘”的习惯,后面做实验对比会非常省心。
2.3 工具链:一台能装Python的机器就够
做这种最小闭环项目,不需要GPU也不需要服务器集群,一台普通笔记本完全够用。唯一的要求是Python环境稳定,依赖包能正常装。我的目录结构是这样的:
ai-engineering-from-scratch/ ├── data/ │ ├── raw.csv │ └── processed/ ├── models/ │ └── logistic_customer_churn.joblib ├── src/ │ ├── data_prep.py │ ├── features.py │ ├── train.py │ └── serve.py ├── experiments/ │ └── exp_001.yaml ├── README.md └── requirements.txt我强烈建议你不要用 Jupyter Notebook 一路写到天荒地老。Notebook适合做探索性分析,但训练和服务逻辑最好尽早放进 .py 脚本里。原因很简单:脚本才是工程化的最小载体,能进Git、能跑测试、能被接口调用。从零练工程,练的就是这种“把杂乱的探索代码变成干净模块”的能力。
3. 从零跑通AI项目:数据、特征、模型、服务四段式实操
3.1 第一步:清理数据,别让脏数据毁掉后续一切
数据清洗是AI工程里最枯燥但最关键的一步。我常用的几条规则:重复行直接删除;数值列中明显不合理的值(比如年龄为负、金额字段变成字符串)转成NaN再填充;类别列统一大小写,去除首尾空格;有少量缺失时优先用中位数填充。中位数比均值稳健,因为很多业务数据是偏态分布,均值会被少数极端值带跑偏。
实际代码大概是这样的:
import pandas as pd # 读取原始数据 df = pd.read_csv("data/raw.csv") print("原始形状:", df.shape) # 重复行处理 df = df.drop_duplicates().reset_index(drop=True) # 明显错误值处理:年龄范围控制在 18~100 df["Age"] = pd.to_numeric(df["Age"], errors="coerce") df.loc[df["Age"] < 18, "Age"] = pd.NA df.loc[df["Age"] > 100, "Age"] = pd.NA # 数值列统一转类型,并用中位数填充缺失 df["Balance"] = pd.to_numeric(df["Balance"], errors="coerce") df["Balance"] = df["Balance"].fillna(df["Balance"].median()) # 类别列标准化 for col in ["Gender", "Geography"]: df[col] = df[col].astype(str).str.strip().str.lower()这里我要特别提醒:填充缺失值的逻辑必须记录下来。因为在模型上线后,实时进来的新数据很可能还是同一个问题——Balance为空、Age带负号,这些都要走和训练时一模一样的清洗规则。我见过太多项目,训练时精心清洗,接口服务里却完全忘了这回事,最终导致预测结果和一坨烂数据混在一起,谁也说不清是谁的锅。
3.2 第二步:特征工程与数据集切分,注意避坑
特征工程这一步,我的建议是先不要急着一口气做花哨特征,先把最基础的处理做对。第一,数值特征要做标准化。为什么不直接用原始数值?因为逻辑回归这类线性模型的梯度更新对尺度非常敏感,如果某个特征是“收入”量级在几万,另一个特征是“活跃天数”量级在几十,梯度更新时大尺度特征会主导参数方向,模型收敛会变得很慢,甚至不收敛。标准化公式就是(x - mean) / std,但有个大坑:mean 和 std 必须只用训练集来计算,切分之后再计算。
我在这个环节犯过一个典型的错:先对全部数据计算均值方差做标准化,再随机切分训练集和测试集。表面看没什么问题,但测试集的均值方差已经被“偷看”了,模型评估结果会偏乐观,上线后一遇到新数据就露馅。正确做法是先把数据集切好,再在训练集上计算标准化参数,然后把这个参数保存下来,应用到测试集和未来的推理数据上。
from sklearn.model_selection import train_test_split features = ["CreditScore", "Age", "Balance", "EstimatedSalary", "IsActiveMember"] X = df[features] y = df["Churn"] # 先切分,stratify保证训练集和测试集的正负比例一致 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, stratify=y, random_state=42 ) # 只用训练集算均值方差 mean = X_train.mean() std = X_train.std() X_train_scaled = (X_train - mean) / std X_test_scaled = (X_test - mean) / std # 保存标准化参数,供服务和上线阶段使用 import joblib joblib.dump({"mean": mean, "std": std}, "models/scaler.joblib")另外,stratify=y这个参数非常关键。客户流失数据集里流失样本通常只占20%左右,如果不做分层切分,随机切分可能会让测试集里几乎没有流失样本,最后算出来的准确率虚高但没有业务意义。遇到分类问题,我基本都会带stratify。
3.3 第三步:用NumPy手写逻辑回归,搞懂梯度更新
这一节是整个项目的灵魂。为什么非要从零写逻辑回归?因为只有当你亲手写出损失函数、梯度表达式和参数更新规则,你才能理解fit背后在做什么。逻辑回归的预测函数是 sigmoid 函数,输出值可以理解为“正类的概率”。损失函数用二元交叉熵,再加上 L2 正则避免参数过大。
核心代码如下:
import numpy as np def sigmoid(z): # 防止指数溢出,clip到合理范围 z = np.clip(z, -20, 20) return 1 / (1 + np.exp(-z)) def compute_loss(y, y_prob, w, reg=1e-4): eps = 1e-12 ce = -np.mean(y * np.log(y_prob + eps) + (1 - y) * np.log(1 - y_prob + eps)) l2 = reg * np.sum(w ** 2) / 2 return ce + l2 def train_logistic(X, y, lr=0.1, epochs=1000, reg=1e-4): n_samples, n_features = X.shape w = np.zeros(n_features) b = 0.0 for epoch in range(epochs): z = X @ w + b y_prob = sigmoid(z) error = y_prob - y # 梯度:交叉熵损失对参数的偏导 dw = (X.T @ error) / n_samples + reg * w db = np.mean(error) w -= lr * dw b -= lr * db if epoch % 200 == 0: loss = compute_loss(y, y_prob, w, reg) print(f"epoch {epoch}, loss {loss:.4f}") return w, b代码逻辑很直接:初始化权重为0,计算梯度,沿负梯度方向更新参数。np.clip那行是为了防止 sigmoid 函数内部算exp(1000)得到无穷大,这是数值稳定性里非常常见的处理。训练结束后,记得把w和b连同前面保存的mean、std、特征列表一起打包存成一个模型文件,后面服务化时只用加载这一个文件就够了。
3.4 第四步:评估指标与实验记录
模型训练完之后,别急着高兴。评估阶段最常见的误判是“准确率高 = 模型好”。在客户流失这种类别不平衡的场景里,如果90%的样本是不流失,模型只要全部预测成不流失,准确率就有90%。所以我要看的是混淆矩阵、精确率、召回率、F1分数这组指标。
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, roc_auc_score y_prob = sigmoid(X_test_scaled @ w + b) y_pred = (y_prob >= 0.5).astype(int) print("accuracy:", accuracy_score(y_test, y_pred)) print("precision:", precision_score(y_test, y_pred)) print("recall:", recall_score(y_test, y_pred)) print("f1:", f1_score(y_test, y_pred)) print("auc:", roc_auc_score(y_test, y_prob))说句实话,我从零手写的逻辑回归在效果上不一定比sklearn版本强,但它的价值在于:我能清楚说出每个指标背后对应业务的含义。比如召回率低意味着很多真正流失的客户没被识别出来,对运营来说就是“漏了该挽回的人”;精确率低则意味着推送了太多无用短信打扰正常人。如果你接的是业务项目,评估维度一定要结合业务成本来看,而不是盯着一个数字自我满足。
同时,每个实验都应该留下记录。我会在experiments/exp_001.yaml里写清数据路径、随机种子、学习率、迭代次数、正则系数、最终指标。实际项目里你可能要跑几十个实验,没有记录就等于白跑。
3.5 第五步:用FastAPI把模型包成可调用的服务
模型训练完了不能锁死在Notebook里,要变成别人能调用的接口。我选择 FastAPI 是因为它自带参数校验和自动生成接口文档,请求和响应都能用清晰的JSON格式来定义,对前后端协作非常友好。
from fastapi import FastAPI from pydantic import BaseModel import joblib import numpy as np app = FastAPI() artifact = joblib.load("models/logistic_customer_churn.joblib") class Customer(BaseModel): CreditScore: float Age: float Balance: float EstimatedSalary: float IsActiveMember: int @app.post("/predict") def predict(customer: Customer): x = np.array([[ customer.CreditScore, customer.Age, customer.Balance, customer.EstimatedSalary, customer.IsActiveMember ]]) mean = artifact["mean"].values std = artifact["std"].values x_scaled = (x - mean) / std z = x_scaled @ artifact["w"] + artifact["b"] p = 1 / (1 + np.exp(-z)) return { "probability": float(p[0]), "churn": bool(p[0] >= 0.5) }接口定义清楚了,还要实际启动一次才能确认全链路没问题。命令很简单:
uvicorn src.serve:app --host 0.0.0.0 --port 8000然后可以模拟一个客户请求来测试:
curl -X POST "http://localhost:8000/predict" \ -H "Content-Type: application/json" \ -d '{"CreditScore":600,"Age":35,"Balance":40000,"EstimatedSalary":90000,"IsActiveMember":1}'如果返回了概率和标签,恭喜你,从数据到模型再到服务的完整闭环已经通了。这一步做完,你手里就是一个真正能上线的最小AI应用。
4. 从零实测遇到的7类问题及排查方案
从零搭项目最大的乐趣就是不断踩坑再爬出来。我把这次真实遇到的高频问题整理成三组,方便你按图索骥排查。
4.1 数据类问题:泄漏、分布漂移、切分不当
问题1:数据泄漏,模型指标虚高。我第一次做标准化时,是先整表算 mean 和 std,再切分训练测试集。结果训练和测试准确率都高得离谱,但上线后立刻原形毕露。根因是测试集信息在预处理阶段就被模型“偷看”了。修复方法是先切分、后拟合标准化参数,而且这个参数必须跟随模型一起固化。
问题2:正负样本比例失衡导致切分失效。不设置stratify时,流失样本可能在训练集里只有一点点,模型根本没学到流失用户的特征,预测结果自然一塌糊涂。修复方法就是确保分类任务里切分时用stratify=y,如果数据是时间序列,那就要换成时间窗口切分,不能用随机切分。
问题3:预测阶段出现训练时没见过的类别值。比如训练时Geography只有 “france”、 “germany”、 “spain”,上线后突然来了一条 “china”。如果用的是 one-hot,新类别会变成全零特征向量,直接丢掉信息。稳妥的做法是在特征工程阶段先固定类别列表,遇到未知类别统一映射到other,或者回退到训练集中出现频率最高的那个类别。
4.2 训练类问题:学习率、梯度、随机种子
问题4:sigmoid计算溢出导致NaN。训练时如果特征没有标准化,或者数值特别大,np.exp(1000)直接爆成无穷大,loss 变成 NaN。修复方法是给 sigmoid 的输入做np.clip(z, -20, 20),同时在交叉熵内部加一个eps=1e-12防止log(0)。
问题5:loss剧烈震荡,不收敛。通常就是学习率太大。我训练loss曲线就像心电图一样上下乱跳时,第一反应是lr=0.1改成lr=0.01,再不行就改成0.001。调整的同时也要观察训练轮数,epoch太小模型还没完全收敛,epoch太大会有冗余计算,经验值是先用1000轮观察loss曲线趋势,再决定是否提前停止。
问题6:模型结果无法复现。某一天跑出来的准确率是0.83,隔天再跑变成0.79,多半是随机种子的锅。数据切分有随机性,权重初始化也带随机性。从零搭建时,建议在数据切分、任何涉及随机过程的地方都明确设置random_state=42,并把随机种子写进实验记录文件里。严格来说,这不算什么高级技巧,但没有它,你连自己都骗得过。
4.3 工程类问题:形状、编码、并发
问题7:NumPy数组形状不对,接口报错。训练时我习惯用(n_samples, n_features)的形状,但接口里传入单个样本时经常写成(n_features,),一维数组和二维数组做矩阵乘法时虽然能算,但维度语义早就乱了。我现在的习惯是:进入模型前先np.array([[...]])强制变成(1, n_features),并在处理完所有字段后打印x.shape做视觉校验。这个小动作能省很多调试时间。
问题8:类别编码在模型保存后和请求端不一致。训练时处理的是小写字符串,接口端传进来的却是首字母大写。这个问题的恶心之处在于,很多情况下代码不会报错,只是预测结果莫名其妙。解决方案是做一个统一的转换函数,训练和请求都走同一个入口,绝对避免两边各写一套特征逻辑。
5. 把工程化意识补上:从“代码能跑”到“稳定运行”
5.1 给代码分层,把“探索代码”和“生产代码”分开
从零搭完这个项目后,我明显感觉到,代码组织方式决定了你后期维护的幸福感。Notebook是探索空间,适合画图、看分布、试特征,但到训练和服务阶段我会把稳定逻辑抽到src/下的 .py 模块里。比如data_prep.py负责清洗,features.py负责特征加工,train.py负责训练和保存产物,serve.py负责接口推理。每个模块的职责单一,互相之间只通过明确的输入输出契约交流。
这样分层的直接好处是:你能对每一层单独做测试。比如接口服务出问题时,先查是不是特征加工和训练时不一致,再查是不是模型文件没更新,而不是一头扎进几万行代码里找逻辑。
5.2 模型、数据与参数做版本管理,确保能回滚
真实业务中,“改了模型导致指标下降”这种事太常见了。所以我现在对模型产物、数据文件和实验参数都有版本意识。数据文件至少带日期后缀,模型文件名包含训练日期和关键指标,比如logistic_customer_churn_20250611_acc0.84.joblib。实验参数全部存到experiments/下的YAML里,配合Git历史,想回滚到上一版模型就只需要改一下服务里的模型路径。
这里有个经验:永远保留至少上一个版本的模型文件。不要每次训练完就覆盖旧的。模型回滚的成本往往比你想象的高,一旦新模型上线后业务反馈异常,能在十分钟内切回旧模型,比现场调参救火要靠谱得多。
5.3 日志、监控与可观测性不能省
模型服务上线只是开始,不是结束。我用 Python 内置的logging给接口增加了请求日志:记录请求时间、输入特征摘要、预测概率、停留时间。刚开始你可能会觉得“多此一举”,真遇到线上问题后你就会感谢这些日志。因为你需要回答的问题是:“昨天下午三点到四点,为什么这批客户的预测率突然偏高?”没有日志,你只能猜。
更进一步,可以用简单的统计量监控特征漂移。每隔一段时间,把线上推理数据的特征分布和训练集的分布做个对比,如果某个特征的均值和方差明显偏移,说明线上环境的数据已经变了,模型需要重新评估。这里不需要巨复杂的平台,一个定时脚本加一张报表就能管事。
5.4 文档和复现清单:为自己留一个“逃生通道”
最后一个工程化习惯是写文档。不一定要写长篇大论,而是把“别人能复现这个项目”需要的东西写清楚:安装步骤、数据来源、数据清洗规则、训练命令、启动服务命令、实验记录表。我在 README 里会写清完整流程,在TROUBLE_SHOOTING.md里记录常见坑和对应解法。这个东西在团队协作里特别重要,否则别人接手你的模型项目时,光靠读代码至少要消耗两三天,而且极易踩进你已经踩过的坑。
写文档这件事,本质上是为未来的自己留下一张逃生地图。换一个角度想,AI工程里的“工程”二字,很大程度就体现在这些容易被忽略的规范里。
现在再回头看这个 ai-engineering-from-scratch 项目,最值钱的不是那个准确率刚过0.84的模型,而是我终于能在黑盒出错时,按图索骥找到具体环节了。最明显的改变是:遇到线上预测异常,我不再一头雾水地重启服务,而是能冷静地沿着“数据清洗 → 特征工程 → 标准化 → 模型参数 → 接口输入”这条链路逐段检查。如果你也想自己动手跑一遍,我的建议是把目标定小一点,先做出一个最小闭环,让模型能训练、接口能调用,再逐步加数据、加监控、加优化。踩坑的过程虽然笨拙,但每一坑都会逼你看清这个环节的底层逻辑。这条路走完,你学到的就不只是“怎么用AI”,而是“怎么把一个AI项目稳稳立起来”。