从零手写AI工程:告别收藏夹吃灰的实战路径
1. 从零搭建AI工程能力为什么我劝你别再收藏夹吃灰ai-engineering-from-scratch这个标题第一次看到的时候我正蹲在工位上啃一个推荐系统的排序模块。说实话市面上讲AI入门的资料多如牛毛但真正敢叫from scratch的十个里有八个点进去是调包侠速成班——sklearn三行代码跑个鸢尾花分类就敢说自己从零开始了。我踩过这个坑所以当我自己动手整理这套从零构建AI工程能力的路线时我给自己定了一条死规矩凡是不能手写一遍核心逻辑的东西都不算from scratch。这个项目要解决的问题很具体一个有一定编程基础、但没系统接触过AI工程的人怎么在不依赖黑盒框架的前提下把从数据处理、模型训练、评估调优到部署上线的完整链路走通一遍。它适合三类人想转行AI但被各种框架绕晕的开发者、做了几年CRUD想补底层原理的后端、以及在校学生想做出一个能写进简历的完整项目。核心关键词就一个——ai-engineering-from-scratch重点在engineering和from scratch这两个词上前者意味着不只是跑通模型还要考虑工程化后者意味着你得知道每一行代码在干什么。我见过太多人收藏了几百个AI学习路线的帖子结果一年过去还在调包。问题出在哪因为大部分路线是知识清单而不是工程路径。知识清单告诉你要学线性代数、概率论、微积分但没告诉你当你面对一个CSV文件时第一步该做什么。工程路径不一样它从你手上有什么出发一步步推到你要交付什么。这篇文章我就按工程路径来拆把我在实际搭建这套体系时踩过的坑、做过的取舍、以及那些文档里不会写的细节全部摊开讲。2. 整体设计思路为什么我选择手写优先、框架后置2.1 核心矛盾学习效率和工程直觉的取舍搭建任何从零开始的学习路径第一个要回答的问题就是用不用现成框架用PyTorch还是TensorFlow用不用sklearn这个问题我纠结了很久最后定下来的原则是——手写优先框架后置但绝不排斥框架。为什么这么定因为框架的本质是封装而封装的代价是黑盒。你调一个model.fit()它帮你做了梯度计算、参数更新、正则化、学习率调度但你不知道里面发生了什么。当你遇到loss不下降、梯度爆炸、过拟合这些问题时你连排查的方向都没有。我见过一个真实案例一个同事用现成框架训练模型loss一直震荡他调了三天学习率都没用最后发现是数据里有一列特征没做归一化数值范围差了六个数量级。如果他手写过一遍梯度下降第一反应就会去检查输入数据的分布。但反过来如果你什么都手写效率会低到令人发指。手写一个卷积层做图像分类光是反向传播的推导就能耗掉一周而且大概率写出来的还不如框架快。所以我的策略是核心机制手写一遍理解原理后立刻切回框架。比如梯度下降你手写一遍线性回归的梯度更新理解链式法则怎么用然后就可以用框架了。再比如反向传播你手写一个两层神经网络的BP理解计算图的概念然后就可以用autograd了。这个取舍背后的逻辑是工程直觉比知识储备更重要。你不需要记住所有公式但你需要知道当X发生时大概率是Y出了问题。这种直觉只能通过亲手实现核心逻辑来培养看再多视频都没用。2.2 技术栈选型为什么是Python NumPy PyTorch技术栈的选择我考虑过几个方案。纯Python加NumPy是最轻量的但做深度学习时矩阵运算写起来太啰嗦TensorFlow生态成熟但API变动大新手容易被版本问题劝退PyTorch动态图友好调试方便社区活跃是目前最适合从零学起的框架。最终定下来的技术栈是这样的层级工具用途为什么选它基础运算NumPy矩阵运算、数据预处理几乎所有AI框架的底层都是它学一遍到处用手写阶段纯Python NumPy实现线性回归、逻辑回归、两层NN不依赖autograd强制理解梯度计算框架阶段PyTorchCNN、RNN、Transformer动态图调试友好文档质量高数据处理PandasCSV加载、特征工程表格数据处理的事实标准可视化Matplotlibloss曲线、混淆矩阵简单直接不花哨但够用部署FastAPI模型服务化轻量、异步、自带文档这个选型不是拍脑袋定的。我试过用JAX做手写阶段结果发现它的函数式范式对新手太不友好也试过用纯Python列表做矩阵运算结果一个1000x1000的矩阵乘法跑了半分钟。NumPy是那个刚好够用的平衡点——它帮你处理了底层的内存布局和向量化但没帮你做梯度计算你依然要自己推导反向传播。2.3 项目结构从数据到部署的完整链路整个项目的目录结构我调整过三次最终定下来的是按工程阶段而不是技术模块来划分。很多教程喜欢按数据预处理、模型、评估来分文件夹但实际工程中这些阶段是交织的——你在评估时发现数据有问题就得回头改预处理。所以我的结构是这样的ai-engineering-from-scratch/ ├── 01_data/ # 数据加载与探索 ├── 02_handwritten/ # 手写核心算法 ├── 03_framework/ # 框架实现 ├── 04_evaluation/ # 评估与调优 ├── 05_deployment/ # 部署与服务化 └── utils/ # 通用工具每个目录下都有独立的README.md记录这个阶段的思考过程和踩坑记录。这个习惯是我从一次惨痛经历中学来的半年前我做一个文本分类项目调参调了两周最后发现是数据加载时把label和feature搞反了。如果当时有记录翻一下就知道问题出在哪。所以现在我做任何项目都会在关键节点写决策日志——记录当时为什么这么选遇到了什么问题怎么解决的。3. 核心细节解析手写阶段到底要写什么3.1 线性回归不只是三行代码的事很多人觉得线性回归太简单不值得手写。但我的观点恰恰相反——线性回归是理解所有AI算法的钥匙。你手写一遍线性回归就理解了什么是假设函数、什么是损失函数、什么是梯度下降、什么是学习率。这四个概念贯穿所有深度学习模型。手写线性回归的核心是梯度推导。假设函数是 $h(x) wx b$损失函数是均方误差 $J \frac{1}{2m}\sum(h(x_i) - y_i)^2$那么对w的偏导是 $\frac{\partial J}{\partial w} \frac{1}{m}\sum(h(x_i) - y_i) \cdot x_i$对b的偏导是 $\frac{\partial J}{\partial b} \frac{1}{m}\sum(h(x_i) - y_i)$。这个推导你必须自己动手推一遍不然你永远记不住为什么要除以m为什么有个1/2。代码实现上关键是用向量化代替循环。我见过有人用for循环逐个样本更新参数跑一万条数据要几分钟。向量化之后一次矩阵运算就能搞定import numpy as np def gradient_descent(X, y, w, b, learning_rate, iterations): m len(y) losses [] for i in range(iterations): y_pred X.dot(w) b error y_pred - y dw (1/m) * X.T.dot(error) db (1/m) * np.sum(error) w w - learning_rate * dw b b - learning_rate * db loss (1/(2*m)) * np.sum(error**2) losses.append(loss) return w, b, losses这段代码里有个细节X.T.dot(error)的顺序不能反。我当初写反了结果维度对不上报错信息又很模糊排查了半小时。后来我养成了一个习惯——每次矩阵运算前先在纸上画出维度。比如X是(m, n)error是(m, 1)那么X.T是(n, m)乘起来就是(n, 1)正好是w的维度。这个习惯帮我省了无数调试时间。注意学习率的选择有个经验法则——先试0.01如果loss震荡就除以10如果下降太慢就乘以10。但这不是绝对的我遇到过一个问题学习率0.01时loss正常下降但降到某个值就卡住了换成0.001反而能继续降。后来发现是数据没有归一化导致不同特征的梯度尺度差异太大。所以归一化是调参的前提别急着调学习率先检查数据。3.2 逻辑回归从回归到分类的思维跃迁逻辑回归的名字有误导性它其实是分类算法。核心区别在于线性回归输出的是连续值逻辑回归输出的是概率。怎么把连续值变成概率答案是sigmoid函数$\sigma(z) \frac{1}{1e^{-z}}$。这个函数把任意实数映射到(0,1)区间正好可以解释为概率。手写逻辑回归的难点在损失函数。你不能用均方误差因为sigmoid函数是非线性的均方误差会导致损失函数非凸梯度下降容易陷入局部最优。正确的做法是用交叉熵损失$J -\frac{1}{m}\sum[y_i\log(\hat{y}_i) (1-y_i)\log(1-\hat{y}_i)]$。这个损失函数的梯度推导出来特别简洁$\frac{\partial J}{\partial w} \frac{1}{m}X^T(\hat{y} - y)$和线性回归的形式一模一样。这不是巧合而是广义线性模型的共性。代码实现上关键是要加一个数值稳定的处理。当sigmoid的输入很大或很小时$e^{-z}$会溢出。我当初没处理这个问题结果训练到一半loss变成NaN排查了半天才发现是溢出。解决方案是在sigmoid里做clipdef sigmoid(z): z np.clip(z, -500, 500) return 1 / (1 np.exp(-z))这个clip的范围不是随便定的。500是因为$e^{500}$已经接近浮点数的上限了再大就溢出。但实际使用中我一般clip到-50到50因为sigmoid在-50时输出已经接近0在50时接近1再大也没意义。实操心得逻辑回归的决策边界是线性的这意味着它只能处理线性可分的数据。如果你发现训练集准确率上不去别急着调参先画个散点图看看数据是不是线性可分的。我遇到过一个数据集两个类别呈同心圆分布逻辑回归怎么调都只有50%准确率换成核方法或者神经网络立刻就到95%以上。先看数据再选模型这个顺序不能反。3.3 两层神经网络反向传播的手写实现两层神经网络是手写阶段的终极Boss。它包含了前向传播、反向传播、激活函数、参数初始化等所有核心概念。你手写一遍后面学CNN、RNN、Transformer都是在这个基础上加东西。网络结构是这样的输入层 - 隐藏层ReLU激活- 输出层Sigmoid激活。前向传播很直接$Z_1 XW_1 b_1$$A_1 ReLU(Z_1)$$Z_2 A_1W_2 b_2$$A_2 Sigmoid(Z_2)$。反向传播是难点需要用到链式法则。输出层的梯度是$\frac{\partial J}{\partial Z_2} A_2 - Y$然后传到隐藏层$\frac{\partial J}{\partial Z_1} (\frac{\partial J}{\partial Z_2} W_2^T) \odot ReLU(Z_1)$其中$\odot$是逐元素乘法。这个推导我当初推了三遍才推对。第一遍忘了ReLU的导数第二遍矩阵转置搞错了第三遍才跑通。但推对之后我对反向传播的理解就彻底通了。后来看PyTorch的autograd文档一眼就明白它在干什么。参数初始化也有讲究。如果全部初始化为0那么所有神经元的输出都一样反向传播时梯度也一样网络永远学不到东西。正确的做法是用随机初始化而且要根据输入维度缩放。常用的方法是Xavier初始化$W \sim \mathcal{N}(0, \frac{1}{\sqrt{n_{in}}})$。我试过用标准正态分布初始化结果训练初期loss震荡得厉害换成Xavier之后稳定多了。def initialize_parameters(n_x, n_h, n_y): np.random.seed(42) W1 np.random.randn(n_h, n_x) * np.sqrt(1/n_x) b1 np.zeros((n_h, 1)) W2 np.random.randn(n_y, n_h) * np.sqrt(1/n_h) b2 np.zeros((n_y, 1)) return {W1: W1, b1: b1, W2: W2, b2: b2}注意np.random.seed(42)这行代码在调试时特别重要。神经网络每次初始化的结果不一样如果你不固定随机种子同一个bug可能这次出现下次不出现排查起来极其痛苦。我养成的习惯是调试阶段固定种子最终训练时再取消固定这样既能复现问题又能保证最终模型的泛化性。4. 实操过程从CSV到部署的完整走一遍4.1 数据加载与探索别急着写模型我见过太多人拿到数据直接往模型里塞结果训练效果差回头改模型改了半天最后发现是数据有问题。正确的顺序是先探索再清洗最后建模。探索阶段要做几件事看数据规模多少行多少列、看特征类型数值型还是类别型、看缺失值比例、看标签分布。这些用Pandas几行代码就能搞定import pandas as pd df pd.read_csv(data.csv) print(df.shape) print(df.dtypes) print(df.isnull().sum()) print(df[label].value_counts())我遇到过一个数据集标签分布是95%正例、5%负例。这种不平衡数据直接训练模型会倾向于预测正例准确率看起来有95%但实际毫无意义。解决方案是过采样少数类或者调整损失函数的权重。这个坑我踩过所以现在拿到数据第一件事就是看标签分布。缺失值处理也有讲究。数值型特征可以用均值或中位数填充类别型特征可以用众数填充。但如果缺失比例超过30%我一般直接删掉这一列因为填充引入的噪声可能比信息还多。这个阈值不是绝对的取决于特征的重要性。如果某个特征业务上很重要即使缺失50%也要想办法填充。4.2 特征工程模型效果的天花板有一句话在AI圈流传很广数据和特征决定了机器学习的上限而模型和算法只是逼近这个上限。我深以为然。同一个模型特征工程做得好和不好效果可能差十几个百分点。数值型特征的处理归一化减均值除标准差或者归一化缩放到0-1区间。归一化适用于数据分布接近正态的情况归一化适用于有明确边界的情况。我一般先用归一化因为它对异常值更鲁棒。但如果数据里有极端异常值归一化会被拉偏这时候要先处理异常值。类别型特征的处理独热编码One-Hot或者标签编码Label Encoding。独热编码适用于类别之间没有顺序关系的情况比如颜色标签编码适用于有顺序关系的情况比如评分。但独热编码有个问题如果类别很多比如用户ID维度会爆炸。这时候可以用目标编码或者嵌入Embedding。嵌入是深度学习的做法把高维稀疏向量映射到低维稠密向量效果通常比独热好。特征交叉是个提效的利器。比如年龄和收入两个特征单独看可能区分度不高但交叉之后可能发现年轻人高收入和老年人低收入是两个不同的群体。我做过一个实验在同一个数据集上不加特征交叉的AUC是0.78加了三个交叉特征之后AUC到了0.84。这个提升比换模型还大。实操心得特征工程不要一次做太多做一批训一次看效果。我当初一次性加了二十个特征结果模型效果反而下降了。排查了半天才发现是其中几个特征引入了噪声。后来我改成每次加3-5个特征对比效果好的留下差的删掉。虽然麻烦但能清楚知道每个特征的价值。4.3 模型训练与调优从瞎调到有章法模型训练的核心是调参。但调参不是瞎试而是有章法的。我的调参顺序是先调学习率再调网络结构最后调正则化。学习率是最重要的超参数。学习率太大loss震荡不收敛学习率太小收敛太慢。我一般用学习率扫描从0.1开始每次除以3跑几个epoch看loss曲线。找到loss下降最快的那个量级然后在这个量级附近细调。这个方法比网格搜索快得多。网络结构包括层数和每层的神经元数。层数越多模型表达能力越强但也越容易过拟合。我的经验是先从两层开始如果欠拟合再加层。神经元数一般是2的幂次比如64、128、256。但这不是绝对的我见过用96个神经元效果比128好的情况。所以还是要试。正则化是防止过拟合的手段。L2正则化在损失函数里加参数的平方和Dropout在训练时随机丢弃一部分神经元。我一般先用Dropout因为它实现简单且效果好。Dropout的概率通常设0.2到0.5我一般从0.3开始试。如果过拟合严重就加大如果欠拟合就减小。# 早停法的实现 best_loss float(inf) patience 10 counter 0 for epoch in range(max_epochs): train_loss train_one_epoch() val_loss validate() if val_loss best_loss: best_loss val_loss counter 0 save_model() else: counter 1 if counter patience: print(fEarly stopping at epoch {epoch}) break早停法是我最常用的技巧。它不需要改模型结构只需要监控验证集loss如果连续N个epoch不下降就停止训练。这个N一般设10到20。早停法不仅能防止过拟合还能节省训练时间。我做过对比不加早停训练100个epoch加早停平均30个epoch就停了效果还更好。4.4 模型部署从notebook到API模型训练好只是第一步部署上线才是工程化的关键。很多人模型训得好但不知道怎么部署结果只能停留在notebook里。部署的第一步是模型序列化。PyTorch用torch.save()保存模型参数但要注意保存的是state_dict而不是整个模型。保存整个模型的问题是它依赖训练时的代码结构如果代码改了加载就会失败。保存state_dict更灵活加载时只需要重新定义模型结构然后load_state_dict()。# 保存 torch.save(model.state_dict(), model.pth) # 加载 model MyModel() model.load_state_dict(torch.load(model.pth)) model.eval()model.eval()这行代码很关键。它会把Dropout和BatchNorm切换到推理模式。我当初忘了加这行结果推理结果每次都不一样排查了半天才发现是Dropout还在起作用。部署框架我选的是FastAPI。它比Flask快支持异步而且自带Swagger文档。定义一个预测接口大概长这样from fastapi import FastAPI from pydantic import BaseModel import torch app FastAPI() class InputData(BaseModel): features: list app.post(/predict) def predict(data: InputData): tensor torch.tensor(data.features).float() with torch.no_grad(): output model(tensor) return {prediction: output.tolist()}torch.no_grad()这行代码能节省内存因为推理时不需要计算梯度。这个细节很多人不知道但在高并发场景下能显著提升吞吐量。注意部署时一定要做输入校验。我遇到过一次线上事故用户传了一个空列表模型直接报错整个服务挂了。后来加了Pydantic的校验输入维度不对直接返回400错误不会影响服务稳定性。永远不要相信用户的输入这是工程化的基本素养。5. 常见问题与排查技巧实录5.1 Loss不下降从数据到代码的排查清单Loss不下降是最常见的问题可能的原因有十几种。我整理了一个排查清单按优先级排序排查项检查方法常见问题数据标签打印前10个样本的标签标签和特征搞反了数据归一化打印特征的均值和标准差特征尺度差异太大学习率试0.1、0.01、0.001太大导致震荡太小导致不收敛损失函数检查公式和实现符号写反了或者忘了除以m梯度计算数值梯度检验反向传播推导错误参数初始化检查初始loss全零初始化导致对称性激活函数检查是否用了sigmoid深层网络梯度消失数值梯度检验是个很实用的技巧。它的原理是用数值方法近似计算梯度和反向传播算出来的梯度对比如果差异很大说明反向传播写错了。实现方法是对每个参数加上一个很小的epsilon计算loss的变化除以epsilon就是数值梯度。def gradient_check(parameters, gradients, X, y, epsilon1e-7): parameters_values dict_to_vector(parameters) grad gradients_to_vector(gradients) num_parameters parameters_values.shape[0] grad_approx np.zeros(num_parameters) for i in range(num_parameters): param_plus parameters_values.copy() param_plus[i] epsilon param_minus parameters_values.copy() param_minus[i] - epsilon loss_plus compute_loss(vector_to_dict(param_plus), X, y) loss_minus compute_loss(vector_to_dict(param_minus), X, y) grad_approx[i] (loss_plus - loss_minus) / (2 * epsilon) diff np.linalg.norm(grad_approx - grad) / (np.linalg.norm(grad_approx) np.linalg.norm(grad)) return diff如果diff小于1e-7说明梯度计算正确如果大于1e-5说明有问题。这个技巧我每次手写新算法都会用能省下大量调试时间。5.2 过拟合识别与应对过拟合的表现是训练集loss持续下降但验证集loss先降后升。识别过拟合很简单画个loss曲线就一目了然。应对过拟合的手段有几种增加数据量是最根本的方法但很多时候数据就那么多没法增加。这时候可以用数据增强。图像数据可以旋转、翻转、裁剪文本数据可以同义词替换、随机插入删除表格数据可以加噪声。数据增强的本质是让模型看到更多不同但等价的样本从而学到更鲁棒的特征。正则化是另一种手段。L2正则化在损失函数里加$\lambda \sum w^2$其中$\lambda$是正则化强度。$\lambda$越大正则化越强模型越简单。我一般从1e-4开始试如果过拟合严重就加大到1e-3。Dropout是另一种正则化它在训练时随机将一部分神经元的输出置零。Dropout的概率一般设0.2到0.5。早停法是最简单也最常用的方法。它不需要改模型结构只需要在验证集loss不再下降时停止训练。我一般设patience为10到20即连续10到20个epoch验证集loss不下降就停止。实操心得过拟合和欠拟合的区分很重要。如果训练集loss都很高那是欠拟合需要增加模型复杂度如果训练集loss低但验证集loss高那才是过拟合需要正则化。我见过有人把欠拟合当成过拟合加了一堆正则化结果模型效果更差了。先看训练集loss再看验证集loss这个顺序不能反。5.3 梯度消失与梯度爆炸深层网络的噩梦梯度消失和梯度爆炸是深层网络的常见问题。梯度消失的表现是靠近输入层的参数几乎不更新loss下降极慢梯度爆炸的表现是loss变成NaN或者参数值变得极大。梯度消失的根本原因是链式法则的连乘效应。如果每层的梯度都小于1乘起来就会趋近于0如果每层都大于1乘起来就会趋近于无穷。sigmoid函数的导数最大是0.25所以用sigmoid作为激活函数时几层之后梯度就消失殆尽了。解决方案有几种换激活函数用ReLU代替sigmoid。ReLU在正区间的导数是1不会导致梯度消失。这是最常用的方法。用BatchNorm对每层的输出做归一化使梯度保持在合理范围。用残差连接让梯度可以绕过某些层直接传回去。这是ResNet的核心思想。梯度爆炸的解决方案是梯度裁剪。当梯度的范数超过某个阈值时把它缩放到阈值以内。这个阈值一般设1到5。PyTorch里一行代码就能搞定torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0)我做过一个实验在一个10层的网络上不加梯度裁剪时loss经常变成NaN加了之后训练稳定多了。这个技巧在RNN上尤其重要因为RNN的时间步展开后相当于一个很深的网络。5.4 部署时的性能问题从毫秒到微秒的优化模型部署后性能是另一个大问题。我遇到过一个案例模型推理一次要200毫秒QPS只有5完全达不到线上要求。优化之后降到了20毫秒QPS到了50。优化的手段有几种模型量化是把float32的参数转换成int8模型大小减少75%推理速度提升2-4倍。但量化会损失一点精度一般掉1-2个百分点。如果精度要求不高量化是性价比最高的优化手段。模型剪枝是去掉不重要的参数。具体做法是把绝对值小的参数置零然后重新训练微调。剪枝可以减少模型大小和计算量但实现起来比量化复杂。批处理是把多个请求合并成一个batch一起推理。GPU擅长并行计算batch size从1增加到32推理时间可能只增加50%但吞吐量提升了几十倍。这个优化在GPU部署时效果特别明显。# 批处理推理 app.post(/predict_batch) def predict_batch(data: BatchInput): tensor torch.tensor(data.features).float() with torch.no_grad(): outputs model(tensor) return {predictions: outputs.tolist()}注意批处理会增加延迟因为要等凑够一个batch才能推理。所以批处理适合吞吐量优先的场景不适合延迟敏感的场景。我一般会设一个超时时间比如10毫秒如果10毫秒内没凑够batch就用当前已有的请求推理。这个策略叫动态批处理能在延迟和吞吐之间取得平衡。6. 从手写到框架什么时候该切换手写阶段的目标是理解原理不是替代框架。当你手写完线性回归、逻辑回归、两层神经网络之后就应该切换到框架了。切换的时机是你能看着公式推导出代码也能看着代码反推出公式。切换到PyTorch之后你会发现很多事情变简单了。梯度不用自己算了autograd自动搞定GPU不用自己管了.to(device)一行代码优化器不用自己写了torch.optim.Adam直接调用。但这时候你和那些直接学框架的人区别在于你知道这些封装背后发生了什么。当loss不下降时你知道可能是梯度消失当模型过拟合时你知道可以用Dropout当训练太慢时你知道可以调batch size。我建议的切换路径是手写线性回归 - 手写逻辑回归 - 手写两层NN - PyTorch实现两层NN - PyTorch实现CNN - PyTorch实现Transformer。每一步都对比手写和框架的实现看看框架帮你省了哪些事这些事背后的原理是什么。这个路径走下来大概需要两到三个月取决于你每天投入的时间。但走完之后你对AI工程的理解会完全不一样。你不会再被各种框架的API绕晕因为你知道它们只是在封装你手写过的那些东西。最后分享一个我个人的习惯每学一个新算法我都会问自己三个问题——这个算法的假设是什么它的损失函数为什么这么设计它在什么情况下会失效这三个问题能帮你从会用提升到懂。ai-engineering-from-scratch的核心不是从零写代码而是从零建认知。代码可以忘但认知会跟着你走。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →