作为 AI 工程师,我们几乎每天都要和 Jupyter Notebook 打交道:探索数据、跑 baseline、对比实验、画图表分析结果。但很多人用着用着,本地目录里就堆满了untitled_final_v2.ipynb、test_final_真的不改了.ipynb这种文件。代码逻辑散落在各个 cell 里,换一台电脑跑不起来,同事想复用也只能“抄 cell”。最近我复盘了calmrocks/ai-engineer-notebooks这个仓库所代表的 AI 工程化 notebooks 工作流,结合自己踩过的坑,整理出一套从“能跑”到“能复用”的完整实践笔记。
这篇文章会从 notebook 的结构设计、环境管理、代码复用、可复现性、常见问题排查等方面展开,并且会给出一个完整的端到端案例,从数据探索到模型训练到模块化重构。无论你是刚接触 AI 工程的初学者,还是已经写了很久 notebook 但想改善组织方式的开发者,这篇都应该能帮到你。
1. 背景:为什么 AI 工程师需要一份“工程化”的 notebook
1.1 AI 工程师 notebook 的常见形态
AI 工程师的日常工作流通常包括:读数据、清洗、特征工程、训练模型、评估结果、画图总结。这些步骤天然适合用 notebook 来承载,因为每一步都能看到中间输出,可以边写边调。
常见的 notebook 使用方式大概是这样的:
- 所有逻辑一个文件写到底,从上往下依次执行。
- 每个 cell 都直接修改全局变量,cell 之间通过隐式状态传递数据。
- 训练参数、文件路径、模型超参数全部写死在代码里。
- 换环境时要手动重装依赖,经常出现 “在我电脑上是好的” 的问题。
这种方式在临时探索阶段效率很高,但一旦进入协作、项目交付、模型迭代阶段,就会暴露很多问题。
1.2 notebook 的天然问题
Notebook 本身作为一种交互式文档,优点是直观、反馈快。但它也是工程化的重灾区:
- 执行顺序混乱:不按顺序执行 cell,后面的 cell 可能引用到不存在的变量。
- 状态隐式依赖:数据清洗结果保存在内存里,一旦 kernel 重启,全部得重新跑。
- 低复用性:函数定义、训练逻辑散落在 cell 里,别的地方想直接用非常困难。
- 版本管理困难:
ipynb本质是 JSON,合并冲突是常态,代码 review 也困难。 - 可复现性差:依赖版本不锁定、随机种子不确定、路径写死,导致结果无法复现。
- 难以测试:notebook 里的函数逻辑很少写单元测试,回归风险高。
1.3calmrocks/ai-engineer-notebooks想解决什么
calmrocks/ai-engineer-notebooks这类项目所代表的思路,是把 notebook 当作 AI 工程工作流中的“入口”和“可视化报告”,而不是把所有代码都塞进 cell 里。它强调的是一种结构化的 notebook 组织方式:
- 每个 notebook 负责一条清晰的分析主线,而不是一个大杂烩。
- 可复用的数据读写、特征处理、模型封装逻辑尽量放到
.py模块中。 - 参数、路径、配置集中管理,notebook 里只保留业务分析流程。
- 通过环境锁定和随机种子控制,保证结果可复现。
我在看过这类思路之后,把自己工作里的所有 notebook 都做了一次重构,最大的感受是:同一套代码,从“只能自己看”变成了“能给别人用、能上生产测试线”。
2. 环境准备:搭建一个可复现的 AI 工程环境
在动手写 notebook 之前,先解决环境问题。很多项目跑不起来,第一原因不是代码错,而是环境不统一。
2.1 Python 与深度学习环境
本文示例以 Python 3.9+ 为主,深度学习框架可以根据你的实际需求选择 PyTorch 或 TensorFlow。建议先确认好以下信息:
- 操作系统:Windows / Linux / macOS
- Python 版本:建议 3.9 或 3.10
- CUDA 版本:如果本机有 NVIDIA GPU,需要确认 nvidia-smi 显示的 CUDA 版本
- 深度学习框架版本:例如 PyTorch 2.x
- 包管理工具:pip 或 conda
需要注意,不同环境的版本号差异很大,下面命令中的版本号只是示例思路,实际安装时请根据你的项目情况调整。
2.2 使用虚拟环境与依赖锁定
推荐的做法是:每个 AI 项目单独建一个虚拟环境,然后把依赖锁定到requirements.txt。
在项目根目录执行:
python -m venv venv source venv/bin/activate # Windows 使用 venv\Scripts\activate安装基础依赖:
pip install --upgrade pip pip install jupyter pip install pandas numpy matplotlib scikit-learn训练完模型后,把当前的准确依赖版本导出:
pip freeze > requirements.txt但pip freeze会包含大量传递依赖,建议在提交项目时额外维护一个requirements-base.txt,只记录直接依赖,便于他人理解。例如:
pandas>=2.0 numpy>=1.24 scikit-learn>=1.3 matplotlib>=3.7 jupyter>=1.02.3 目录规划
一个工程化的 AI 项目目录通常长这样:
ai-engineer-notebooks/ ├── data/ │ ├── raw/ # 原始数据 │ ├── processed/ # 清洗后数据 │ └── output/ # 模型输出、图表 ├── notebooks/ │ ├── 01-eda.ipynb # 探索性数据分析 │ ├── 02-feature-engineering.ipynb │ └── 03-train-evaluate.ipynb ├── src/ │ ├── config.py # 配置管理 │ ├── data_loader.py # 数据读取 │ ├── preprocessing.py # 预处理 │ └── models.py # 模型训练与评估 ├── tests/ │ └── test_preprocessing.py ├── requirements.txt └── README.md这样的分层设计,核心思想是:notebook 负责“讲业务故事”,src负责“沉淀可复用代码”。
3. 核心拆解:一份工程化 notebook 应该包含什么
在重构 notebook 时,可以按下面的标准 cell 结构来组织,而不是想到哪里写到哪里。
3.1 标准 cell 结构
无论是探索性分析、特征工程还是模型训练,一份规范的 AI notebook 建议包含以下几类 cell:
- 说明性 cell:用 Markdown 说明本 notebook 要解决什么问题,输出什么结论。
- 导入 cell:统一导入所有依赖库,放在最前面。
- 配置 cell:集中定义路径、参数、常量。
- 数据读取 cell:调用
src里的数据加载函数。 - 核心分析 cell:对应业务主线,例如特征分布、相关性分析、模型训练。
- 结果展示 cell:用表格、图表展示结果。
- 保存与记录 cell:把结果图片、指标、模型参数保存到指定目录。
这里的关键是:配置不要散落在多个 cell 里,更不要藏在代码深处。
3.2 参数与配置分离
很多 notebook 跑两次结果不一样,就是因为参数被改来改去,但又没有记录。更好的做法是使用一个集中的配置模块,比如src/config.py:
# 文件路径:src/config.py from pathlib import Path # 项目根目录 BASE_DIR = Path(__file__).resolve().parent.parent # 数据目录 RAW_DATA_DIR = BASE_DIR / "data" / "raw" PROCESSED_DATA_DIR = BASE_DIR / "data" / "processed" OUTPUT_DIR = BASE_DIR / "data" / "output" # 模型参数 MODEL_PARAMS = { "random_state": 42, "n_estimators": 200, "max_depth": 8, "test_size": 0.2, } # 随机种子 SEED = 42然后在 notebook 中这样使用:
# 文件路径:notebooks/03-train-evaluate.ipynb(第 3 个 cell) import sys from pathlib import Path # 将项目根目录加入模块搜索路径 sys.path.append(str(Path.cwd().parent)) from src.config import RAW_DATA_DIR, PROCESSED_DATA_DIR, OUTPUT_DIR, MODEL_PARAMS, SEED print("原始数据目录:", RAW_DATA_DIR) print("输出目录:", OUTPUT_DIR)这样做的好处是:修改参数时只改config.py,notebook 的逻辑保持稳定。
3.3 可复用代码抽取
判断一段逻辑要不要抽成.py函数,可以用一个简单的标准:你是否有超过一次的机会用同一段逻辑?
- 数据加载、重命名列、统一日期格式,基本都要抽出来。
- 缺失值统计、异常值处理,抽出来。
- 模型训练、评价指标计算,抽出来。
- 只在本 notebook 里一次性画图的代码,可以留在 notebook 里。
举个典型的坏味道:在 notebook 里反复出现这样的 cell——
# 坏味道:复制粘贴的加载逻辑 import pandas as pd df_train = pd.read_csv("../data/raw/train.csv") df_train["date"] = pd.to_datetime(df_train["date"]) df_train = df_train.drop_duplicates()如果第二个 notebook 也需要同样的预处理,又要复制一遍。应该抽成一个函数:
# 文件路径:src/data_loader.py import pandas as pd from pathlib import Path def load_clean_data(file_path): """加载 CSV 数据并做基础清洗。 参数: file_path: str 或 Path,CSV 文件路径。 返回: pandas.DataFrame,清洗后的数据。 """ df = pd.read_csv(file_path) df["date"] = pd.to_datetime(df["date"]) df = df.drop_duplicates().reset_index(drop=True) return df然后在 notebook 中这样调用:
from src.data_loader import load_clean_data df = load_clean_data(RAW_DATA_DIR / "train.csv") print(df.shape) print(df.dtypes)3.4 随机种子与可复现性
模型训练涉及随机过程时,一定要设置随机种子。否则同一个 notebook,同样的数据、同样的代码,两次训练出的指标可能差很多。
在训练前统一设置:
import numpy as np import random import torch def set_seed(seed: int): """固定随机种子,保证实验可复现。""" random.seed(seed) np.random.seed(seed) if torch.cuda.is_available(): torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) set_seed(SEED)这里把SEED放在config.py中统一管理,避免每次写 notebook 时都重复定义,也能防止某个 cell 忘了设置导致结果漂移。
4. 完整实战案例:从数据探索到模型训练的 notebook 重构
接下来,我们用一份简单的房价预测数据作为案例,展示一个工程化 notebook 的完整流程。案例数据是模拟生成的,重点是演示结构,而不是追求模型精度。
4.1 创建项目结构
先按照上面的目录规划,创建项目:
mkdir -p ai-engineer-notebooks/{data/{raw,processed,output},notebooks,src,tests} cd ai-engineer-notebooks touch README.md为了快速演示,我们先生成一份模拟数据。你也可以把自己手头的数据集放到data/raw目录下。
# 生成模拟房价数据(仅用于演示) import pandas as pd import numpy as np np.random.seed(42) n = 1000 df = pd.DataFrame({ "area": np.random.normal(120, 30, n), "bedrooms": np.random.randint(1, 5, n), "age": np.random.randint(0, 50, n), "price": np.random.normal(300, 80, n) }) df.to_csv("data/raw/house.csv", index=False) print("模拟数据已生成,shape:", df.shape)4.2 编写可复用模块
在src目录下,把数据读取、特征处理、模型训练拆成独立模块。
首先是数据加载模块:
# 文件路径:src/data_loader.py import pandas as pd from pathlib import Path def load_dataset(file_path: str, **kwargs) -> pd.DataFrame: """加载 CSV 数据。 参数: file_path: CSV 文件路径。 **kwargs: 传给 pd.read_csv 的其他参数。 返回: pd.DataFrame """ return pd.read_csv(file_path, **kwargs) def save_processed_data(df: pd.DataFrame, output_path: Path) -> None: """保存处理后的数据。 参数: df: 待保存的数据框。 output_path: 保存路径。 """ output_path.parent.mkdir(parents=True, exist_ok=True) df.to_csv(output_path, index=False)然后是特征处理模块:
# 文件路径:src/preprocessing.py import pandas as pd import numpy as np def fill_missing_values(df: pd.DataFrame, columns: list) -> pd.DataFrame: """使用中位数填充指定列的缺失值。 参数: df: 输入数据框。 columns: 需要填充的列名列表。 返回: 填充后的数据框。 """ for col in columns: if col in df.columns and df[col].isnull().any(): median_val = df[col].median() df[col] = df[col].fillna(median_val) return df def add_room_density(df: pd.DataFrame) -> pd.DataFrame: """添加卧室密度特征,表示单位面积内的卧室数量。 参数: df: 输入数据框。 返回: 新增 room_density 列后的数据框。 """ df = df.copy() df["room_density"] = df["bedrooms"] / df["area"] return df最后是模型训练模块:
# 文件路径:src/models.py from sklearn.ensemble import RandomForestRegressor from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score import numpy as np def train_random_forest(X_train, y_train, model_params: dict): """训练随机森林回归模型。 参数: X_train: 训练特征。 y_train: 训练标签。 model_params: 模型参数字典。 返回: 训练好的模型。 """ model = RandomForestRegressor(**model_params) model.fit(X_train, y_train) return model def evaluate_model(model, X_test, y_test): """评估回归模型。 参数: model: 训练好的模型。 X_test: 测试特征。 y_test: 测试标签。 返回: 包含 MAE、RMSE、R2 的字典。 """ y_pred = model.predict(X_test) mae = mean_absolute_error(y_test, y_pred) rmse = np.sqrt(mean_squared_error(y_test, y_pred)) r2 = r2_score(y_test, y_pred) return {"MAE": mae, "RMSE": rmse, "R2": r2}4.3 编写主 notebook
打开 notebook,按照下面结构编写 cell。
Cell 1:说明
# 房价预测 - 模型训练与评估 目标:基于面积、卧室数、房龄,训练随机森林回归模型,并输出评估指标。 数据来源:data/raw/house.csv 输出:模型指标字典、预测结果图。Cell 2:导入与配置
import sys from pathlib import Path import pandas as pd import matplotlib.pyplot as plt sys.path.append(str(Path.cwd().parent)) from src.config import RAW_DATA_DIR, MODEL_PARAMS, SEED, OUTPUT_DIR from src.data_loader import load_dataset, save_processed_data from src.preprocessing import fill_missing_values, add_room_density from src.models import train_random_forest, evaluate_model import numpy as np import random def set_seed(seed: int): random.seed(seed) np.random.seed(seed) set_seed(SEED)Cell 3:数据读取
df = load_dataset(RAW_DATA_DIR / "house.csv") print("数据形状:", df.shape) print(df.head())Cell 4:特征处理
# 模拟部分缺失值,用于演示填充逻辑 df.loc[0, "area"] = np.nan df_clean = fill_missing_values(df, columns=["area", "bedrooms"]) df_feat = add_room_density(df_clean) print(df_feat.head())Cell 5:训练集与测试集划分
from sklearn.model_selection import train_test_split X = df_feat[["area", "bedrooms", "age", "room_density"]] y = df_feat["price"] X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=SEED ) print("训练集样本数:", X_train.shape[0]) print("测试集样本数:", X_test.shape[0])Cell 6:训练与评估
model = train_random_forest(X_train, y_train, MODEL_PARAMS) metrics = evaluate_model(model, X_test, y_test) print("模型评估指标:") for metric_name, metric_value in metrics.items(): print(f"{metric_name}: {metric_value:.4f}")Cell 7:可视化与保存
y_pred = model.predict(X_test) plt.figure(figsize=(6, 6)) plt.scatter(y_test, y_pred, alpha=0.6) plt.xlabel("真实房价") plt.ylabel("预测房价") plt.title("真实值 vs 预测值") plt.plot([y_test.min(), y_test.max()], [y_test.min(), y_test.max()], "r--") OUTPUT_DIR.mkdir(parents=True, exist_ok=True) plt.savefig(OUTPUT_DIR / "prediction_result.png", dpi=150) plt.show() # 保存模型评估指标,方便后续对比实验 import json with open(OUTPUT_DIR / "metrics.json", "w", encoding="utf-8") as f: json.dump(metrics, f, ensure_ascii=False, indent=2)4.4 运行与验证
在项目根目录启动 Jupyter:
jupyter notebook打开notebooks/03-train-evaluate.ipynb,按顺序执行所有 cell。预期结果:
- 数据读取成功,输出 1000 行数据。
- 特征处理完成后,新增
room_density列。 - 模型训练完成后,输出 MAE、RMSE、R2 三个指标。
- 在
data/output目录下生成prediction_result.png图和metrics.json文件。
模拟数据的指标会比较一般,因为数据本身没有强规律,但整个流程跑通是有价值的。
4.5 结果说明与版本管理
跑通之后,有两个动作非常重要:
- 更新依赖清单:
pip freeze > requirements.txt- 给 notebook 加 Cell 元信息。在 notebook 最后的 Markdown cell 里记录实验日志:
## 实验记录 - 日期:2025-XX-XX - 数据:模拟生成的 house.csv - 模型:RandomForestRegressor,n_estimators=200,max_depth=8 - 指标:MAE=xx.xx,RMSE=xx.xx,R2=xx.xx - 备注:新增 room_density 特征后,R2 略有提升这样再看 notebook 时,不需要翻代码也能知道这次实验做了什么。
5. 常见问题与排查思路
在实际操作中,最容易遇到的问题集中在环境、路径和 kernel 状态上。我整理了下面这个排查表:
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
notebook 里 import 不到src模块 | Python 模块搜索路径没有包含项目根目录 | 在 notebook 开头执行sys.path.append(str(Path.cwd().parent)) |
| Kernel 重启后变量全部丢失 | notebook 默认不持久化内存变量 | 按 cell 顺序执行;把耗时结果提前保存到磁盘 |
| 换电脑后 notebook 跑不起来 | 依赖版本不一致、路径写死 | 使用requirements.txt锁定依赖;不要在代码里写绝对路径 |
| 同样代码两次结果不一样 | 未设置随机种子、不同库版本行为不同 | 在入口统一set_seed,并固定数据划分的 random_state |
| 项目里 notebook 太多,找不到对应关系 | 文件命名混乱 | 使用01-eda、02-feature、03-train的编号命名 |
| git diff 里看到大段 JSON | ipynb包含输出内容 | 安装nbstripout,提交前清理输出 |
| 数据文件路径中有中文或空格 | 配置文件转义问题 | 统一使用英文路径;用pathlib.Path代替字符串拼接 |
这里重点说一下最常遇到的模块导入问题。
如果在 notebook 中执行from src.data_loader import load_dataset报错ModuleNotFoundError: No module named 'src',大概率是因为 notebook 的工作目录和项目根目录不一致。在 notebook 第一个 cell 里加上以下代码即可:
import sys from pathlib import Path project_root = Path.cwd().parent if str(project_root) not in sys.path: sys.path.append(str(project_root))如果你对项目根目录的判定有更严格的需求,也可以从config.py中导入BASE_DIR:
import sys from pathlib import Path sys.path.append(str(Path(__file__).resolve().parent.parent)) # 这是 .py 文件里的做法但注意,__file__这种写法只适用于.py文件,在 notebook 中要用Path.cwd()或Path().resolve()来推测。
另外一个高频问题是:notebook 没按顺序执行。明明上面的 cell 里定义了变量,下面的 cell 却报错NameError。这通常是因为中间的 cell 执行失败,或者之前跳过了某个 cell。排查方式很简单:
- 点击菜单
Kernel -> Restart & Run All,看是否全流程都能跑通。 - 如果全流程能跑通,说明代码没问题,只是执行顺序乱了。
- 如果全流程也报错,说明有隐性依赖,需要检查变量定义是否放在引用之前。
6. 最佳实践与工程建议
结合calmrocks/ai-engineer-notebooks所代表的工程化思路,我总结了下面几条建议,可以直接用在自己的项目里。
6.1 Notebook 命名与组织
- 每个 notebook 只做一件事,对应一个编号。
- 命名中带上序号,例如
01-data-exploration.ipynb,方便浏览。 - 用 README 记录每个 notebook 的用途和依赖关系。
- 提交到 Git 前,用
nbstripout清理输出结果,减少 diff 噪声。
安装方式:
pip install nbstripout nbstripout --install6.2 配置管理
- 路径、超参数、随机种子统一放在
config.py。 - 不要在 notebook 中硬编码绝对路径。
- 涉及敏感信息(如数据库连接串)时,不要提交到仓库,使用环境变量或
.env文件,并加入.gitignore。 - 如果同一个项目要跑多组实验,可以使用类似于
config + dataclass的方式管理实验参数。
简单版本:
from dataclasses import dataclass @dataclass class ExperimentConfig: model_type: str = "random_forest" n_estimators: int = 200 max_depth: int = 8 test_size: float = 0.2 random_state: int = 426.3 异常处理与数据安全
在处理真实数据时,要特别注意边界情况:
- 数据读取时判断文件是否存在,避免下次路径改了直接报一个难懂的 FileNotFoundError。
- 特征处理后检查样本量是否仍合理。
- 涉及删除数据、覆盖文件的步骤,先备份或先写入临时文件。
- 如果需要访问数据库或其他敏感数据源,务必使用最小权限账号,并在测试环境验证。
示例:
from pathlib import Path file_path = RAW_DATA_DIR / "train.csv" if not file_path.exists(): raise FileNotFoundError(f"未找到数据文件:{file_path},请检查原始数据目录。")6.4 测试与质量保障
不要觉得 notebook 里的代码就不用测试。可复用的函数一旦抽到src中,就应该为它们编写单元测试。
比如src/preprocessing.py中的fill_missing_values,测试可以这样写:
# 文件路径:tests/test_preprocessing.py import pandas as pd import numpy as np from src.preprocessing import fill_missing_values def test_fill_missing_values(): df = pd.DataFrame({"area": [100, np.nan, 140], "price": [300, 320, 280]}) df_result = fill_missing_values(df, columns=["area"]) assert df_result["area"].isnull().sum() == 0 # 中位数填充,100、120、140 的中位数是 120 assert abs(df_result.loc[1, "area"] - 120) < 1e-6在项目根目录执行:
pytest tests/ -v虽然一个小函数也写测试看起来有点“重”,但对于 AI 工程化项目来说,数据预处理逻辑一旦出错,模型指标再高都没有意义。
6.5 性能与资源管理
- 大数据集不要轻易
pd.read_csv全量读入,优先用pd.read_csv(..., usecols=[...])只读所需列,或使用分块读取。 - 耗时的预处理结果保存为中间文件,避免每次重启 kernel 都重跑。
- 在 GPU 上训练大模型时,注意监控显存占用,用完及时释放不必要的大张量。
- 不要把大模型的权重文件直接放进 Git 仓库,可以使用 dvc 等方式管理数据与模型版本。
6.6 协作与 Review
- Pull Request 中提交 notebook 时,可以在描述中说明改动了哪个 cell、改了什么参数。
- 多人在同一 notebook 上工作时,优先拆分到不同 notebook,而不是大家一起改同一个文件。
- Review 代码时,重点关注
src下的.py文件,notebook 更多看结论和可视化是否合理。
7. 总结与下一步
这份笔记的核心就八个字:逻辑下沉,配置集中,notebook 留主线。通过把数据加载、预处理、模型训练等通用逻辑沉淀到src目录,把路径和参数集中到config.py,再让 notebook 专注于分析和展示,整个 AI 工程项目的可维护性会明显提升。calmrocks/ai-engineer-notebooks这类项目给我们的最大启示,并不是某一个具体函数,而是一种把 notebook 从“个人草稿本”变成“团队协作工具”的思路。
下一步你可以做三件事:
- 把自己最近一个 notebook 项目按文中的目录结构重构一遍,重点是把重复出现的代码抽成函数。
- 给已经稳定的数据预处理逻辑补上单元测试,用 pytest 跑通一条最小用例。
- 尝试使用
nbstripout和requirements.txt管理项目版本,让你的 notebook 真正变成可交付、可复现的工程资产。
如果你想进一步提升,可以继续学习 dvc 的数据版本管理、mlflow 的实验追踪,以及模型服务化部署。它们和工程化 notebook 结合之后,就能形成一套完整的 AI 工程闭环。