Notebook 最危险的时刻,是它第一次被塞进 cron。此前它是个很好的探索工具:改一个参数、看一段输出、画一张图,反馈循环以秒计。此后它变成一个没人敢改的黑盒——单元格顺序靠记忆、状态藏在解释器里、失败时不知道该从哪一格重跑。
问题不在 Notebook 本身,而在把探索工具当交付物用。这篇把两者的边界划清楚,并给出从 Notebook 到可调度脚本的具体路径。
语境为 Python 3.11+ / Linux,Jupyter 侧以 nbclient / papermill 生态为准。
1. Notebook 适合什么,不适合什么
| 用途 | 合适度 | 原因 |
|---|---|---|
| 探索数据、验证假设 | 高 | 单格执行,反馈在秒级 |
| 可视化与图表调参 | 高 | 图形就地渲染,改一行看一次 |
| 教学与演示 | 高 | 文字、代码、输出交织,叙事完整 |
| 一次性分析、结论交付 | 中 | 结论可读,但过程难复现 |
| 生产定时调度 | 低 | 无参数契约、失败不可重试、状态不可控 |
| 长期维护的公共逻辑 | 低 | 无法被正常 import、无法单元测试 |
| 多人协作改同一份代码 | 低 | diff 不可读,冲突几乎无法合 |
判断标准很简单:这段代码需要被别人 import 吗?需要在失败后自动重跑吗? 任一为是,它就不该只活在 .ipynb 里。
2. 执行顺序陷阱:隐藏状态
Notebook 的单元格不保证按文档顺序执行。你可以在第 12 格用第 30 格定义的变量,因为解释器里它恰好还在。
# 第 3 格
rate = 0.05
# 第 18 格(先跑过一次,rate 被改成 0.08)
rate = 0.08
# 第 7 格(视觉上在 rate=0.05 之后,但运行时读到 0.08)
total = amount * rate
这份文档从上往下读是自洽的,从头到尾执行一遍却不是。这就是隐藏状态:真正的执行顺序不存在于文件里。
| 症状 | 表征 | 后果 |
|---|---|---|
| 变量来自更靠后的单元格 | 只用 Shift+Enter 顺序跑过 |
换机器、换人 Run All 结果不同 |
| 同名变量被多次覆盖 | 同一变量出现在多格 | 读到的是最后执行的那次,不是最后定义的那次 |
| 删掉某格后仍能跑 | 依赖来自内存残留 | 重启后立刻 NameError |
| 输出与代码不一致 | 输出是旧代码留下的 | 交付的数字属于不存在的代码版本 |
Restart & Run All 是唯一可信的验证。它清空内核再从头执行;如果结果和你的预期不同,说明之前看到的数字是巧合。养成"交付前必做一次 Restart & Run All"的习惯,比任何代码规范都有效。
3. 把逻辑抽成模块的三步
抽离的目标是让 Notebook 只剩"调用与展示"。分三步,每步都能独立验证。
第一步:找出纯计算单元。 不碰文件、不碰网络、不依赖 Notebook 全局变量的那些格。它们通常是清洗规则、指标公式、格式转换。
第二步:抽成带签名的函数,放进包。 关键是参数显式传入,不读全局。
# src/report/metrics.py
from __future__ import annotations
import pandas as pd
def monthly_summary(
df: pd.DataFrame,
*,
exclude_status: tuple[int, ...] = (9, 10),
min_amount: float = 0.01,
) -> pd.DataFrame:
"""按 dt、channel 汇总订单数与 GMV。纯函数,不读写外部资源。"""
df = df[~df["status"].isin(exclude_status)]
df = df[pd.to_numeric(df["amount"], errors="coerce") >= min_amount]
return df.groupby(["dt", "channel"], as_index=False).agg(
order_cnt=("order_id", "nunique"),
gmv=("amount", "sum"),
)
第三步:Notebook 里只留一行调用,并给这个函数写测试。
# 原来的 Notebook 单元格,现在只剩三行
from report.metrics import monthly_summary
summary = monthly_summary(raw, exclude_status=(9, 10))
summary.head()
| 抽离前 | 抽离后 | 变化 |
|---|---|---|
| 逻辑散在 5 个格 | 一个函数 | 可被 import |
| 参数读全局变量 | 关键字参数 | 可测试、可复用 |
| 只能人眼看结果 | pytest 断言 | 可回归 |
| 改口径要改 Notebook | 改模块 + 跑测试 | 变更可审计 |
抽离的边界:能写成纯函数的一律抽走,留在 Notebook 里的只有 IO 和展示。
4. papermill:把 Notebook 变成带参数的执行单元
Notebook 本身没有参数契约,papermill 补上这一层。
$ pip install papermill
$ papermill report.ipynb out/report-2025-03.ipynb \
-p month 2025-03 \
-p mode full \
--kernel python3
Notebook 里声明参数格,用 parameters 标签标记,papermill 会用它覆盖:
# 参数格(在 Jupyter 里给这个 cell 打上 parameters 标签)
month = "2025-01"
mode = "sample"
out_dir = "out"
| 能力 | 做法 | 价值 |
|---|---|---|
| 注入参数 | -p key value 或 -r 传 YAML |
不改文件就能换参数 |
| 产出已执行副本 | 指定输出路径 | 交付物自带输出,可复查 |
| 失败即异常 | 非 0 退出码 | 能被调度器捕获 |
| 批量跑 | -f params.yaml |
一次跑多组参数 |
# params.yaml
month: "2025-03"
mode: "full"
papermill 只解决"参数化执行",不解决"工程化"。它让 Notebook 可以被调度器调用,但隐藏状态、不可测试、diff 不可读这三个问题依然存在。它是过渡方案,不是终点。
5. 版本控制的麻烦与清理方式
.ipynb 是 JSON,一次执行会把输出、执行计数、内核元数据全部写回文件。结果是一个实际上没人能 review 的 diff。
| 噪音来源 | 表现 | 处理 |
|---|---|---|
| 输出单元 | outputs: [...] 里嵌 base64 图片 |
提交前清空,或用 nbstripout |
| 执行计数 | execution_count: 17 |
强制置为 null |
| 内核元数据 | python_version、随机 id |
固定或剥离 |
| 输出里的时间戳 | 每次运行都变 | 清空输出即消失 |
# 装成 git filter:提交时自动剥离输出
$ pip install nbstripout
$ nbstripout --install
$ nbstripout --install --attributes .gitattributes
# 手工清理一次
$ jupyter nbconvert --clear-output --inplace report.ipynb
# .gitattributes
*.ipynb filter=nbstripout
*.ipynb diff=ipynb
*.ipynb merge=union
清理之后,Notebook 的 diff 只剩代码格的变化,review 才有意义。要保留某次交付的输出,就把它导出成 HTML 或已执行副本,另存一份——别让"交付证据"和"源代码"挤在同一条主线里。
6. 为什么生产环境用脚本 + 调度器
| 维度 | Notebook | 脚本 |
|---|---|---|
| 入口 | 无明确入口 | main() + 退出码 |
| 参数 | 靠改代码或 papermill 注入 | argparse,契约清晰 |
| 失败处理 | 抛异常停在某格 | 非 0 退出,可重试 |
| 日志 | 分散在输出单元 | 统一到 stdout/stderr |
| 依赖 | 依赖内核环境 | 锁文件 + 独立环境 |
| 可测试 | 基本不可 | pytest 直接调函数 |
| 资源 | 常驻内核 | 一次进程,跑完释放 |
调度器要的是三件东西:能启动、能判断成败、能重跑。Notebook 三件都不天然具备。
# 调度器实际调用的形态
$ python -m report.run --month 2025-03 --mode full --out-dir /data/out
$ echo $?
0
python -m 从包根启动,避免 sys.path 猜测;退出码给调度器判断成败;日志走 stdout 由采集器收走。这三条比任何"最佳实践"都实际。
7. 两者的现实协作流程
四个阶段,每阶段有明确的交付物。
| 阶段 | 在哪做 | 产出 | 完成判据 |
|---|---|---|---|
| 1 探路 | Notebook | 结论、图表、可疑点 | 问题被回答,口径被确认 |
| 2 模块化 | 包 + Notebook | src/ 里的纯函数 |
函数有测试,Notebook 只调用 |
| 3 脚本化 | 脚本 | python -m xxx 可跑 |
参数化、退出码、日志齐全 |
| 4 定时跑 | 调度器 | cron/Airflow DAG | 失败告警、可重跑、幂等 |
不要跳过第 2 步直接到第 3 步。 探路时写在 Notebook 里的逻辑往往带着临时假设,直接抄进脚本会把假设一起抄过去;先抽成函数、补上测试,假设才会被显式写成参数。
反过来,也不要在第 1 步就追求工程化。探索期写完整测试的代价,通常高于重写一遍。
8. 排错:五种"Notebook 又不对了"
| 现象 | 原因 | 处理 |
|---|---|---|
ModuleNotFoundError: report |
内核的 cwd 不是项目根 | 在项目根启动 jupyter lab,或装成可编辑包 |
| 改了模块但 Notebook 还是旧行为 | 内核缓存了旧 import | 重启内核;或 %load_ext autoreload + %autoreload 2 |
Run All 结果与手工跑不同 |
隐藏状态 | 以 Run All 为准,删掉跨格依赖 |
| 提交的 diff 巨大且不可读 | 输出与执行计数进了版本库 | 装 nbstripout |
| 换机器结果变了 | 依赖版本不一致 | 锁文件 + 固定内核环境 |
%autoreload 只是省重启,不能替代"重启后验证":它同样会掩盖依赖顺序问题。
%load_ext autoreload
%autoreload 2
9. 边界:什么时候不必离开 Notebook
| 情形 | 建议 |
|---|---|
| 只跑一次、结论给一个人看 | 就留在 Notebook |
| 每次都要人看着调参数 | Notebook 更高效 |
| 需要图形交互 | Notebook 明显优于脚本 |
| 会被别人调用或定时执行 | 必须脚本化 |
| 逻辑会被复制到第二个项目 | 必须模块化 |
一句话:Notebook 是工作台,脚本是产线。 工作台上可以有半成品,产线上不行。
10. 自测
- 为什么"从头到尾按顺序跑过一遍"不能证明 Notebook 是对的?
Restart & Run All具体验证了什么?- 抽离三步里,第一步的判断标准是什么?
- papermill 解决了 Notebook 的哪个问题,又没有解决哪个?
nbstripout在 git 里起作用的机制是什么?- 调度器需要脚本提供哪三件东西?