2665 字
约 8 分钟
16
S10-X07 · Jupyter 与脚本的边界:探索与生产

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. 自测

  1. 为什么"从头到尾按顺序跑过一遍"不能证明 Notebook 是对的?
  2. Restart & Run All 具体验证了什么?
  3. 抽离三步里,第一步的判断标准是什么?
  4. papermill 解决了 Notebook 的哪个问题,又没有解决哪个?
  5. nbstripout 在 git 里起作用的机制是什么?
  6. 调度器需要脚本提供哪三件东西?
S10-X07 · Jupyter 与脚本的边界:探索与生产
https://www.clxhxhhr.top/posts/5749/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。