1. 从零搭建AI工程体系,为什么我劝你别一上来就啃论文
"ai-engineering-from-scratch"这个标题,第一次看到的时候我以为是又一个教你调包的速成教程。点进去翻了翻,发现它想做的事情比调包大得多——它试图回答一个很具体的问题:一个懂点Python但没碰过机器学习的人,怎么一步步把AI工程这套东西真正搭起来,而不是停留在pip install和model.fit()的层面。
我自己带过几个从后端转AI方向的同事,也面试过不少号称"做过AI项目"的候选人。一个很普遍的现象是:他们能说出Transformer的公式,能背出注意力机制的推导,但你让他从零写一个能跑通的训练循环,或者解释清楚为什么batch size调大之后学习率要跟着动,就开始含糊了。这就是典型的"论文读了一堆,工程能力为零"。
这个项目要解决的就是这个断层。它面向的不是要发论文的研究者,而是想把AI真正落地成产品、服务或者工具的工程师。核心思路很朴素:先把最小可运行的系统跑起来,再一层层往上加东西,每加一层都搞清楚为什么加、加了会怎样。适合谁看?有基本编程能力、想系统补齐AI工程能力的开发者,以及那些做过一些demo但总觉得根基不牢的人。
我下面要拆的,不只是这个项目本身的结构,更是它背后那套"从零构建"的方法论——这套方法论我在实际工作中反复验证过,确实比东一榔头西一棒子地学要高效得多。
2. 整体设计思路:为什么是"从零"而不是"从框架"
2.1 先搞清楚"从零"到底指什么
很多人对"from scratch"有误解,以为是要手写CUDA核函数、自己实现反向传播的每一个偏导数。不是的。这个项目的"从零"指的是:不依赖高层封装,用最基础的组件把AI系统的核心链路搭出来,理解每一环的输入输出和依赖关系。
打个比方,学做菜。你可以直接买料理包加热,这是调包;你也可以跟着菜谱一步步切菜、调味、控火,这是"从零"。料理包能让你吃饱,但换个菜你就不会了。从零做一遍,你掌握的是火候和调味的逻辑,之后做什么菜都能迁移。
具体到AI工程,这个"从零"的边界大概是这样:
- 数据处理:自己写Dataset和DataLoader,而不是直接用现成的管道
- 模型定义:用基础算子搭网络结构,理解每一层的张量形状变化
- 训练循环:手写前向、损失、反向、更新这四步
- 评估与调优:自己实现指标计算和超参搜索逻辑
- 部署:把模型导出、封装成可调用的服务
框架当然要用,但要在你理解了底层之后再用。顺序反了,你就永远是个调包侠。
2.2 技术选型的取舍逻辑
项目在选型上做了一个很关键的取舍:用PyTorch而不是TensorFlow。这个选择背后有实际考量。
PyTorch的动态图机制对初学者更友好。你写一行y = model(x),它立刻就能执行,出错了当场报错,调试体验接近普通Python代码。TensorFlow早期的静态图需要先定义再运行,中间隔了一层,新手很容易懵。虽然现在TF也支持动态图了,但生态和社区习惯上,PyTorch在研究和快速原型场景里还是更顺手。
另一个取舍是:不碰分布式训练和混合精度这些高级话题,至少在入门阶段不碰。原因很简单——你连单卡训练都没跑明白,上来就搞多卡通信,除了复制粘贴配置之外学不到任何东西。先把单机单卡吃透,后面扩展是水到渠成的事。
提示:选型没有绝对的对错,关键是知道你为什么选它。如果你所在团队的生产环境是TensorFlow Serving,那学TF也没问题。但作为学习路径,PyTorch的反馈循环更短,试错成本更低。
2.3 分层递进的内容架构
整个项目的内容组织是分层递进的,我把它归纳成四层:
| 层级 | 核心内容 | 目标 |
|---|---|---|
| 基础层 | 张量操作、自动求导、线性代数回顾 | 建立数值计算直觉 |
| 核心层 | 模型定义、损失函数、优化器、训练循环 | 能独立训练一个模型 |
| 进阶层 | 数据增强、正则化、学习率调度、模型评估 | 让模型效果可控可复现 |
| 应用层 | 模型导出、服务封装、推理优化 | 把模型变成能用的东西 |
这个分层的好处是每一层都有明确的"毕业标准"。基础层毕业的标准是你能手算一个简单网络的前向传播;核心层毕业的标准是你能不看教程写出一个完整的训练脚本;进阶层毕业的标准是你能诊断模型过拟合还是欠拟合并给出对策;应用层毕业的标准是你能把一个模型部署成API并处理并发请求。
每一层都不是孤立的,下一层会反复用到上一层的东西。这种螺旋上升的结构,比按知识点平铺直叙要扎实得多。
3. 核心细节拆解:那些教程里不会细说的关键点
3.1 张量操作:别小看reshape和permute
张量操作看起来简单,但它是后面所有内容的地基。我见过太多人在模型报错的时候卡在形状不匹配上,根源就是对张量的维度语义没搞清楚。
举个实际例子。假设你有一个batch的图片数据,形状是[batch, channels, height, width],也就是[32, 3, 224, 224]。现在你要把它送进一个全连接层,全连接层期望的输入是[batch, features]。你需要做的是:
# 错误做法:直接reshape x = x.reshape(32, -1) # 这样会把channels、height、width混在一起 # 正确做法:先permute再reshape x = x.permute(0, 2, 3, 1) # [32, 224, 224, 3] x = x.reshape(32, -1) # [32, 224*224*3]为什么顺序很重要?因为reshape是按内存顺序重排的,而permute是改变维度的语义顺序。如果你先reshape,得到的数据在语义上是乱的——原本属于同一个通道的像素被拆散了。这个坑我在实际项目里踩过,模型能训练但效果奇差,排查了两天才发现是数据重排的问题。
注意:每次做维度变换之后,打印一下形状,并且用一个小例子验证数值对不对。比如创建一个
[2, 3]的张量,手动算一下permute之后每个位置的值应该是什么,跟代码输出对一下。这个习惯能帮你省下大量调试时间。
3.2 自动求导:理解计算图的生命周期
PyTorch的自动求导机制是核心中的核心。它的工作原理是:每次你对一个requires_grad=True的张量做操作,PyTorch会在后台构建一张计算图,记录操作的历史。当你调用.backward()的时候,它沿着这张图反向传播梯度。
这里有几个关键细节:
第一,梯度是累加的,不是替换的。每次调用.backward(),梯度会加到已有的.grad上。所以标准的训练循环里,每轮开始前必须调用optimizer.zero_grad()把梯度清零。忘了这一步,梯度会越积越大,训练直接发散。
第二,计算图在每次前向传播时重新构建。这意味着你不能跨batch保留计算图,否则内存会爆。如果你需要保留,得用retain_graph=True,但这通常意味着你的代码结构有问题。
第三,torch.no_grad()不只是省内存。在评估和推理阶段,你不需要计算梯度,用with torch.no_grad():包裹起来,既能减少内存占用,又能加速计算。这个习惯在生产环境里尤其重要。
# 标准训练循环的骨架 for epoch in range(num_epochs): model.train() for batch_x, batch_y in train_loader: optimizer.zero_grad() # 清零梯度 output = model(batch_x) # 前向传播 loss = criterion(output, batch_y) # 计算损失 loss.backward() # 反向传播 optimizer.step() # 更新参数 model.eval() with torch.no_grad(): # 评估阶段不计算梯度 for batch_x, batch_y in val_loader: output = model(batch_x) # 计算评估指标这个骨架看起来简单,但每一行都有存在的理由。我建议你在初期把这个骨架默写几遍,直到不用查资料就能写出来。
3.3 损失函数与优化器的搭配逻辑
损失函数和优化器的选择不是随意的,它们之间有搭配逻辑。
分类问题用交叉熵损失,回归问题用均方误差,这是基本常识。但更深一层的问题是:为什么分类不用均方误差?因为交叉熵的梯度在预测偏离真实标签时更大,收敛更快;而均方误差在分类场景下梯度会饱和,学得慢。这个解释在数学上可以推导,但直观理解就是:交叉熵对"自信的错误"惩罚更重。
优化器方面,SGD是最基础的,但实际项目中Adam系列更常用。原因在于Adam对学习率不那么敏感,自适应地调整每个参数的更新幅度。对于初学者来说,Adam能让你在不太调参的情况下就跑出一个能看的结果,降低了入门门槛。
但这里有个反直觉的点:在某些任务上,精心调参的SGD最终能比Adam达到更好的泛化性能。所以如果你追求极致效果,SGD+学习率调度+动量是值得尝试的。不过这是进阶话题,入门阶段先用Adam把流程跑通。
| 场景 | 推荐损失函数 | 推荐优化器 | 理由 |
|---|---|---|---|
| 多分类 | CrossEntropyLoss | Adam | 梯度信号强,收敛快 |
| 二分类 | BCEWithLogitsLoss | Adam | 数值稳定,内置sigmoid |
| 回归 | MSELoss | Adam/SGD | 标准选择,视精度要求调整 |
| 序列生成 | CrossEntropyLoss | Adam | 配合teacher forcing |
3.4 数据管道:被低估的性能瓶颈
很多人把注意力全放在模型结构上,忽略了数据管道。实际上,在不少项目里,数据加载才是训练速度的瓶颈。GPU利用率上不去,往往不是模型太慢,而是数据供给跟不上。
自己写Dataset的时候,核心是实现__len__和__getitem__两个方法。__getitem__里做的是:根据索引读取一条数据,做必要的预处理,返回张量。DataLoader负责把这些单条数据组装成batch,并且可以用多进程加速。
class MyDataset(Dataset): def __init__(self, data_paths, labels, transform=None): self.data_paths = data_paths self.labels = labels self.transform = transform def __len__(self): return len(self.data_paths) def __getitem__(self, idx): # 读取数据 data = load_data(self.data_paths[idx]) label = self.labels[idx] # 应用变换 if self.transform: data = self.transform(data) return data, label关键参数是num_workers。设为0表示在主进程里加载数据,设大于0会启动子进程并行加载。经验值是设为CPU核心数的一半到全部。但要注意,Windows上多进程加载有时会有问题,Linux上更稳定。
另一个容易忽略的点是pin_memory。设为True会把数据放到锁页内存里,加速从CPU到GPU的传输。当你的GPU利用率忽高忽低的时候,试试打开这个选项。
4. 实操过程:从零训练一个图像分类器
4.1 环境准备与依赖安装
先把环境搭起来。我推荐用conda创建独立环境,避免污染系统Python。
conda create -n ai-scratch python=3.10 conda activate ai-scratch pip install torch torchvision numpy matplotlib tqdm版本选择上,PyTorch 2.x系列已经比较稳定,CUDA版本根据你的显卡驱动来选。如果没有GPU,CPU版本也能跑,只是慢一些。验证安装:
import torch print(torch.__version__) print(torch.cuda.is_available()) # 有GPU的话应该是True提示:不要小看环境问题。我见过太多人卡在版本不兼容上,浪费半天时间。建议把安装命令记在一个
requirements.txt里,换机器的时候直接复用。
4.2 数据准备与预处理
我们用一个经典的图像分类数据集来演示。假设数据已经下载好了,目录结构是:
data/ train/ class_0/ class_1/ ... val/ class_0/ class_1/ ...预处理的核心是标准化。把像素值从[0, 255]缩放到[0, 1],再减去均值除以标准差。均值和标准差通常用数据集的统计值,ImageNet的常用值是mean=[0.485, 0.456, 0.406],std=[0.229, 0.224, 0.225]。
from torchvision import transforms train_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(), # 数据增强 transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) val_transform = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])注意训练集用了随机水平翻转做增强,验证集不用。这是基本原则:增强只作用于训练数据,验证和测试数据要保持原始分布。
4.3 模型定义:从简单到复杂
入门阶段,先定义一个简单的卷积网络,不要一上来就ResNet。
import torch.nn as nn class SimpleCNN(nn.Module): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 32, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(64, 128, kernel_size=3, padding=1), nn.ReLU(), nn.AdaptiveAvgPool2d(1) ) self.classifier = nn.Linear(128, num_classes) def forward(self, x): x = self.features(x) x = x.flatten(1) x = self.classifier(x) return x这个网络的结构逻辑是:卷积层提取特征,池化层降维,最后用全局平均池化把空间维度压成1,再接全连接层输出类别分数。AdaptiveAvgPool2d(1)的好处是不管输入图片多大,输出都是[batch, 128, 1, 1],避免了手动计算全连接层输入维度的问题。
4.4 训练循环与参数选择
把前面几块拼起来,形成完整的训练脚本。
import torch from torch.utils.data import DataLoader from torchvision.datasets import ImageFolder # 数据 train_dataset = ImageFolder('data/train', transform=train_transform) val_dataset = ImageFolder('data/val', transform=val_transform) train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4, pin_memory=True) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=4, pin_memory=True) # 模型、损失、优化器 device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = SimpleCNN(num_classes=len(train_dataset.classes)).to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) # 训练 num_epochs = 20 for epoch in range(num_epochs): model.train() running_loss = 0.0 for batch_x, batch_y in train_loader: batch_x, batch_y = batch_x.to(device), batch_y.to(device) optimizer.zero_grad() output = model(batch_x) loss = criterion(output, batch_y) loss.backward() optimizer.step() running_loss += loss.item() # 验证 model.eval() correct = 0 total = 0 with torch.no_grad(): for batch_x, batch_y in val_loader: batch_x, batch_y = batch_x.to(device), batch_y.to(device) output = model(batch_x) _, predicted = output.max(1) total += batch_y.size(0) correct += predicted.eq(batch_y).sum().item() print(f'Epoch {epoch+1}/{num_epochs}, Loss: {running_loss/len(train_loader):.4f}, Val Acc: {correct/total:.4f}')学习率设为1e-3是Adam的常用起点。batch size设为32是内存和梯度稳定性的折中。如果显存够大,可以调到64或128,但学习率也要相应调整——经验法则是batch size翻倍,学习率也翻倍或开方。
4.5 训练过程监控与调优
训练过程中要盯住几个信号:
- 训练损失持续下降但验证准确率不涨:过拟合了,加正则化或数据增强
- 训练损失不降:学习率太大或模型容量不够
- 损失震荡剧烈:学习率太大或batch size太小
- 验证准确率波动大:验证集太小或模型不稳定
我一般会在训练脚本里加一个简单的早停机制:如果验证准确率连续5个epoch没有提升,就停止训练。这能省下不少时间。
best_acc = 0.0 patience = 5 counter = 0 for epoch in range(num_epochs): # ... 训练和验证 ... if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), 'best_model.pth') counter = 0 else: counter += 1 if counter >= patience: print(f'Early stopping at epoch {epoch+1}') break5. 常见问题与排查技巧实录
5.1 形状不匹配:最常见的报错
形状错误几乎每个新手都会遇到。排查思路是:从报错信息里找到出错的层,打印输入形状,对照该层期望的输入形状。
一个实用技巧是在forward方法里加打印:
def forward(self, x): print(f'Input shape: {x.shape}') x = self.features(x) print(f'After features: {x.shape}') x = x.flatten(1) print(f'After flatten: {x.shape}') x = self.classifier(x) return x跑一个batch,看形状变化是否符合预期。确认之后再把打印删掉。
5.2 损失不下降:从学习率开始查
损失不下降的原因很多,但排查顺序建议是:
- 学习率是不是太大了?试试除以10
- 数据标签是不是对的?随机抽几条看看
- 模型输出是不是常数?打印几个样本的输出
- 损失函数用对了吗?分类用交叉熵,回归用MSE
我遇到过一个案例:损失一直卡在ln(num_classes)附近,也就是随机猜测的水平。查了半天发现是数据加载的时候标签和图片没对应上,图片和标签错位了。这种问题只能靠仔细检查数据管道来发现。
5.3 显存不够:几个立竿见影的招
显存不够的时候,按这个顺序尝试:
- 减小batch size(最直接)
- 用
torch.cuda.empty_cache()清理缓存 - 检查有没有在训练循环里累积计算图(比如把loss存到列表里忘了detach)
- 用混合精度训练(进阶)
注意:
loss.item()和loss.detach()的区别要搞清楚。loss.item()返回Python标量,不占显存;loss.detach()返回一个不需要梯度的张量,也不占计算图。但如果你直接把loss存进列表,整个计算图都会被保留,显存会迅速爆掉。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 损失为NaN | 学习率过大/数据有异常值 | 打印损失和输入 | 降低学习率/检查数据 |
| 验证准确率远低于训练 | 过拟合 | 对比训练和验证曲线 | 加正则化/数据增强 |
| 训练速度慢 | 数据加载瓶颈 | 看GPU利用率 | 增加num_workers/pin_memory |
| 显存溢出 | batch太大/计算图累积 | 看显存占用 | 减小batch/检查detach |
| 准确率不涨 | 模型容量不足/学习率不当 | 看损失曲线 | 加深模型/调学习率 |
5.5 几个我踩过的坑
第一个坑:忘了optimizer.zero_grad()。症状是损失一开始下降然后突然爆炸。原因是梯度累积导致更新步长越来越大。这个错误很低级但很常见,尤其是在你复制粘贴代码的时候。
第二个坑:验证的时候忘了model.eval()。BatchNorm和Dropout在训练和评估模式下的行为不同。忘了切换会导致验证结果不稳定,而且会污染BatchNorm的统计量。
第三个坑:数据增强用在了验证集上。这会导致验证指标虚高,因为模型在"见过"的增强数据上评估。记住:增强只用于训练。
第四个坑:保存模型的时候只保存了state_dict,加载的时候忘了重建模型结构。正确的做法是保存state_dict,加载时先实例化模型再load_state_dict。
6. 从训练到部署:让模型真正能用
6.1 模型导出与格式选择
训练完的模型要能脱离训练代码独立运行。PyTorch提供了几种导出方式:
torch.save(model.state_dict(), path):保存参数,加载时需要模型定义torch.save(model, path):保存整个模型,但不推荐,因为依赖具体的类定义torch.jit.script(model):把模型编译成TorchScript,可以脱离Python运行torch.onnx.export(model, ...):导出成ONNX格式,跨框架通用
生产环境推荐TorchScript或ONNX。TorchScript适合继续在PyTorch生态里用,ONNX适合部署到其他推理引擎。
# TorchScript导出 model.eval() example_input = torch.randn(1, 3, 224, 224).to(device) traced_model = torch.jit.trace(model, example_input) traced_model.save('model_traced.pt') # 加载 loaded_model = torch.jit.load('model_traced.pt')6.2 推理服务的封装
把模型封装成一个简单的HTTP服务,用FastAPI是最快的方式。
from fastapi import FastAPI, File, UploadFile from PIL import Image import io app = FastAPI() model = torch.jit.load('model_traced.pt') model.eval() @app.post('/predict') async def predict(file: UploadFile = File(...)): image = Image.open(io.BytesIO(await file.read())).convert('RGB') tensor = val_transform(image).unsqueeze(0) with torch.no_grad(): output = model(tensor) prob = torch.softmax(output, dim=1) conf, pred = prob.max(1) return {'class': pred.item(), 'confidence': conf.item()}这个服务能处理单张图片的推理请求。生产环境还需要考虑批处理、并发、超时等问题,但作为起点已经够用了。
6.3 推理性能的几个优化点
推理和训练的关注点不同。训练看重吞吐量,推理看重延迟。几个实用的优化:
- 用
torch.no_grad()包裹推理代码,减少内存占用 - 把模型设为
eval()模式,固定BatchNorm和Dropout - 如果延迟要求高,考虑把模型量化成INT8
- 批处理请求能提高吞吐量,但会增加单条延迟
提示:不要过早优化推理性能。先把功能跑通,有了真实的性能数据之后再针对性优化。我见过太多人在没有基准测试的情况下瞎调,最后发现瓶颈根本不在模型上。
7. 我在这条路上的一些真实体会
从零搭建AI工程能力这件事,最大的障碍不是数学,也不是编程,而是耐心。你会在形状不匹配上卡半天,会在损失不下降的时候怀疑人生,会在环境配置上浪费一整个下午。这些都是正常的。
我的建议是:不要跳过任何一个"简单"的步骤。你觉得张量操作太基础不想练,后面就会在模型调试的时候付出代价。你觉得训练循环太模板化不想手写,后面就搞不清楚什么时候该调什么参数。
这个项目最值钱的地方,不是它教了你多少知识点,而是它逼着你把整条链路走通一遍。走通之后,你再去看那些高级框架和工具,会发现它们只是把你手写过的步骤封装得更优雅而已。底层的东西没变,你只是换了个更顺手的工具。
最后分享一个我自己的习惯:每学完一个模块,就写一篇简短的笔记,用自己的话把核心逻辑复述一遍。如果写不出来,说明还没真懂。这个习惯帮我省下了大量"以为自己会了"的时间。