不知道你有没有碰到过这种场景:手里拿到一份别人发过来的.ipynb文件,或者自己在 Jupyter Notebook 里反复调了一下午,最终要交付的却是一个干净的.py脚本。Notebook 里的代码块、Markdown 笔记、中间试错的输出,全都混在一个 JSON 包装的格式里。提交到 Git 仓库时 diff 乱成一团,部署到服务器上又离不开 Jupyter 环境,想用 argparse 做参数化跑批量任务更是无从下手。
把.ipynb转成.py看似是个小需求,但背后牵扯到执行结果怎么处理、魔法命令怎么保留、相对路径会不会失效、多 cell 之间的变量依赖怎么捋清,这些坑如果不提前搞清楚,转出来的.py文件根本不能直接用。这篇文章我会把从最简单到工程化的几种转换方式全部过一遍,包括你可能会踩到的坑和我自己实测下来的处理经验,希望能帮你一次性搞明白这件事。
1. 为什么要做这个转换:场景决定方案
先别急着找工具,先想清楚一件事:你转出来的.py文件要拿来干什么?转换目的不同,采用的方式完全不一样。
1.1 版本管理:Git 才是真正的痛点
最常见的需求是版本控制。Notebook 文件本身是 JSON 格式,里面塞了代码、输出、执行次数、元数据。哪怕你只是改了一行代码,整个文件的 diff 也可能变得非常难以阅读,因为输出结果、执行顺序信息都会跟着变化。代码评审的时候,对方看到的一大堆内容,根本分不清哪一行是你真正改动的。
这种情况下,转成一个干净的.py文件,只保留代码逻辑,就是最简单有效的办法。但这里有个关键细节:Notebook 里的代码往往带有执行顺序的隐含依赖,而.py文件是顺序执行的,这一点在转换时要特别留意。
1.2 生产部署:脱离 Jupyter 环境
很多团队会用 Notebook 做研究和试验,但最终上生产环境的服务,还是需要脚本。生产环境通常不会装一套完整的 Jupyter 服务,而且 Notebook 的工作模式是交互式的,一个 cell 出错不会停掉整个执行链,这对生产任务来说是致命的。
转成.py之后,脚本可以用标准的 Python 方式运行,配合 cron 定时、容器化部署、消息队列消费等场景。如果是深度学习训练任务,还可以直接在命令行用python train.py --epochs 100 --batch_size 64这样去控制,而这些在 Notebook 里做起来会非常别扭。
1.3 代码复用与模块化
如果你在 Notebook 里写了一些不错的函数和逻辑,想放到正式的项目里,让它被其他模块引用或打包成 pip 包,那你必须把代码转移到标准的.py文件里。Notebook 中的变量、函数和 cell 之间的关系,无法被外部直接import使用。
不过这种需求下,我建议不要简单地做文件格式转换,而是要做一次真正的代码重构:把纯函数放到model.py、utils.py这类模块文件里,Notebook 只保留调用流程。如果直接整个转成一个大.py文件,后期维护起来会非常痛苦。
1.4 理解 Notebook 的本质:它就是个 JSON
在动手转换之前,建议先建立一个底层认知。.ipynb文件本质上是 JSON,外面包裹了一层结构化的字段:
cells:单元格列表,每个单元格有cell_type(code、markdown、raw)、source(代码原文)、outputs(执行结果)等字段。metadata:Kernel 信息、语言信息等。nbformat:Notebook 格式版本号,目前主流是 4.x。
所以,任何转换工具的原理,本质都是读取这个 JSON,提取source字段,把 code 单元格的代码按顺序拼接成一个.py文件。理解了这一点,后面遇到那些奇怪的报错,你就能迅速定位问题。
2. 最直接的方案:Jupyter Notebook 自带的导出功能
如果你只是偶尔转一次,而且笔记本还在运行,那么最快的方式就是直接用 Jupyter Notebook 自带的导出功能,不用写任何代码,也不用安装额外的东西。
2.1 操作步骤与导出结果
在打开的.ipynb页面里,点击菜单栏的File→Download as→Python (.py),浏览器就会自动下载一个.py文件。
这个方式背后调用的其实也是nbconvert工具,将 Notebook 转换为 Python 脚本。导出的.py文件里,代码 cell 会被原样保留为可执行代码,而 Markdown cell 会被转换成注释,用#开头,穿插在代码中间。这样既能保留你的实验笔记,又不影响脚本执行。
2.2 这个方式的隐患
听起来很简单,但它有几个实际问题:
第一,导出的位置取决于当前浏览器下载设置,文件可能被下载到某个不易找到的目录,而不是 Notebook 文件所在目录,文件多了以后管理会很麻烦。
第二,如果你改了代码但还没保存,导出的是 Notebook 最近一次保存到磁盘的版本,而不是你当前编辑器里显示的最新内容。这个非常容易踩坑,我见过不止一次,有人改了半天的代码,导出之后才发现是旧版本。
第三,它是手动操作,无法批量处理,一次只能处理一个文件,不适合需要经常同步的场景。
所以这个方法适合什么情况呢?临时拿到一个文件、手头没有配置命令行环境、只需一次性转换的时候。
3. 命令行批量转换:nbconvert 的完整用法
如果项目里有一批.ipynb要转成.py,或者希望把转换过程固化到工作流里,那就得用命令行工具。
3.1 环境准备与基础命令
nbconvert是 Jupyter 生态的组成部分,如果你已经安装了 Jupyter Notebook,它会自动包含。如果没有,单独安装即可:
pip install jupyter nbconvert最基础的转换命令:
jupyter nbconvert --to script notebook.ipynb执行完成后,会在同一目录下生成一个notebook.py,这就是转换后的脚本。注意,它不会覆盖原文件,两者会同时存在。
3.2 常用参数和进阶用法
默认输出到原目录,如果想指定输出目录和文件名,可以用:
jupyter nbconvert --to script notebook.ipynb --output my_script --output-dir ./scripts这里--output只写文件名主体,不用加.py后缀。--output-dir指定存放目录,目录需要提前建好,否则会报错。
批量转换也很直接,在 bash 里加上循环:
for f in *.ipynb; do jupyter nbconvert --to script "$f" --output-dir ./py_scripts done这样会把当前目录下所有.ipynb转成.py放到py_scripts目录,文件名字保持一致。
我平时用的一个批量转换脚本还会顺带打印执行日志,方便排查:
for f in notebooks/*.ipynb; do echo "转换中: $f" jupyter nbconvert --to script "$f" --output-dir ./scripts --log-level WARN if [ $? -eq 0 ]; then echo "成功: ${f%.ipynb}.py" else echo "失败: $f" fi done--log-level WARN可以隐藏掉一大堆无关紧要的提示信息,只有出现真正的警告或错误时才会显示,批量处理时画面会干净很多。
3.3 隐藏的坑:模板路径和自定义导出
nbconvert的转换工作基于模板。默认的 script 模板会把 Markdown cell 转成#开头的注释,Code cell 原样输出。如果你的 Notebook 里有 raw cell,它会被原样输出,不经过任何处理。
如果你想定制导出的格式,可以自己写模板。例如,你想在生成的.py文件顶部自动插入一段版权声明或导入语句,可以创建一个自定义模板文件,指定--template参数引用它。这里给一个最简单实用的模板思路:先写一个my_template.py.j2,放在本地目录,然后转换时引用。
jupyter nbconvert --to script notebook.ipynb --template my_template.py.j2模板语法是 Jinja2 的。对大多数场景来说,默认模板已经够用了,自定义模板属于进阶需求,等真正有需要再研究也不迟。
4. 自己写 Python 脚本:实现完全可控的转换
nbconvert虽然方便,但它的行为是固定的。有些需求它满足不了,比如:去掉所有代码输出、过滤掉某些魔法命令、把 Markdown 转成#注释的同时按特定格式整理,或者按 cell 执行顺序生成注释分隔线。
这时候,自己写一个转换脚本反而是更好的选择。前面说过,.ipynb本质是 JSON,所以读进来、提取内容、拼字符串、写文件,事情就做完了。
4.1 极简版转换器
下面这个脚本是我常用基础版,可以直接复制使用:
import json import sys import pathlib def ipynb_to_py(ipynb_path, py_path): with open(ipynb_path, "r", encoding="utf-8") as f: nb = json.load(f) lines = [] cell_count = 0 for cell in nb.get("cells", []): cell_type = cell.get("cell_type", "code") source = "".join(cell.get("source", [])) if cell_type == "markdown": lines.extend(["# " + line for line in source.strip().split("\n")]) lines.extend([""]) elif cell_type == "code": cell_count += 1 if cell_count > 1: lines.extend([""]) lines.extend(["# --- Cell {} ---".format(cell_count)]) lines.extend(source.split("\n")) lines.extend([""]) with open(py_path, "w", encoding="utf-8") as f: f.writelines("\n".join(lines)) print("转换完成,共处理 {} 个代码单元格,输出到 {}".format(cell_count, py_path)) if __name__ == "__main__": src = pathlib.Path(sys.argv[1]) dst = pathlib.Path(sys.argv[2]) ipynb_to_py(src, dst)使用方式:
python convert_ipynb.py input.ipynb output.py这段脚本做了几件事:读取 JSON,遍历cells;Markdown 单元格的每一行前加#变成注释;代码单元格原样保留,并在每个代码单元格前插入一行# --- Cell N ---作为分隔线。这样做的好处是,生成的文件读起来结构清晰,哪个 cell 对应什么逻辑一目了然。
4.2 去掉输出和魔法命令
如果是用于部署的脚本,通常不需要 Notebook 里的执行输出,也不需要像%matplotlib inline这样的魔法命令。你可以修改代码,跳过包含魔法命令的行:
MAGIC_PREFIXES = ("%", "!") def clean_code_source(source_lines): cleaned = [] for line in source_lines: stripped = line.strip() if stripped.startswith(MAGIC_PREFIXES): continue cleaned.append(line) return cleaned把!pip install这类 shell 魔法命令、%matplotlib inline这类自身有依赖环境的函数的指令都过滤掉,脚本才能在没有 Jupyter 环境的机器上正常运行。注意,过滤时直接用startswith判断即可,不用写复杂的正则。
4.3 处理相对路径问题
在 Notebook 里,当前工作目录通常是 Notebook 文件所在目录。而生成的.py落地之后,运行时的当前目录取决于你在哪个目录执行python xxx.py。两者不一致时,原本能正常读取的数据文件路径就会失效。
解决这个问题,最好在生成的脚本开头,自动加上一段路径切换逻辑。我自己常用的写法是:
import os import sys # 自动切换到脚本所在目录,保证相对路径稳定 os.chdir(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))这段代码放到转换脚本里,生成的.py文件在运行时,无论从哪个目录调用,工作目录都会被切回脚本自身所在的位置,相对路径就不会出错了。
4.4 保留函数定义而忽略重复执行
Notebook 的一个特点是可以重复运行同一个 cell,而且前一次执行产生的变量会保留在内存中。但.py文件每次运行都是全新开始。如果在 Notebook 里,你先定义了一个函数,接着在另一个 cell 里调用它,转换后的.py文件仍然会保留这个顺序,因为代码 cell 本身是按顺序拼接的。
但有个容易忽略的问题:如果 Notebook 中某个 cell 被多次执行,或者有覆盖式定义,比如第一次df = pd.read_csv("a.csv"),第二次df = pd.read_csv("b.csv")时,转换脚本会把两次都写进.py文件。这在 Notebook 里只是历史痕迹,但在脚本里就是重复工作。所以转出来之后,最好检查一遍是否有重复定义、重复赋值的代码,必要时手动删减。
5. 工程化实践:让转换自动化融入日常开发
手动转换总会忘。如果项目里存在“Notebook 写原型,py 脚本上生产”的常规流程,我更推荐把转换做成自动化环节。
5.1 用 Git Hook 自动同步转换文件
一种常见的做法是在 Git 的pre-commit阶段检查:如果某个.ipynb被修改了,那么对应的.py文件也需要同步更新。这样可以让所有改动在提交节点保持一致。
一个简单的 hooks 脚本思路如下:先在项目里准备一个scripts/convert_notebooks.py脚本(可以直接用上面那段自己写的转换逻辑),然后在.git/hooks/pre-commit里写:
#!/bin/bash # 检查改动的 .ipynb 文件,批量转换 changed_notebooks=$(git diff --cached --name-only --diff-filter=ACM | grep '\.ipynb$' || true) if [ -n "$changed_notebooks" ]; then echo "检测到 Notebook 变更,生成对应的 .py 文件..." python scripts/convert_notebooks.py $changed_notebooks git add . fi这样在提交时,所有改动的 Notebook 都会自动更新对应的.py文件,并一起提交进去。不过注意,这个操作会把转换结果直接git add,所以转换脚本必须稳定、可复现,否则会引入大量无意义 diff。
5.2 通过 Makefile 管理转换任务
如果你不喜欢 Git Hook 这种略微隐式的操作,也可以在项目根目录放一个Makefile,把转换固化成一个显式的命令:
PY_FILES := $(patsubst notebooks/%.ipynb, scripts/%.py, $(wildcard notebooks/*.ipynb)) convert: $(PY_FILES) scripts/%.py: notebooks/%.ipynb @mkdir -p scripts jupyter nbconvert --to script $< --output-dir scripts --log-level WARN @echo "已转换: $< -> $@" clean: rm -f scripts/*.py在项目根目录执行make convert,所有notebooks/下的 ipynb 就会自动转换到scripts/。这种方式是可重复执行的,团队成员用起来也简单,只依赖 Make 和 nbconvert 两个常见的工具。
5.3 CI/CD 中用转换后的脚本做测试
有些场景下,Notebook 里的代码是否真的能在干净环境里跑通,只有在 CI 里才能验证。一个可行的思路是:在 CI 工作流中先执行jupyter nbconvert --to script --execute来直接运行整个 Notebook,再运行生成的.py文件进行校验。
nbconvert有一个--execute参数,可以在转换前先执行所有的代码单元格,保留输出结果。这样可以在转换的同时验证 Notebook 的可执行性。如果某个 cell 抛异常,转换会中断并给出错误信息。这在收尾检查时非常好用。
jupyter nbconvert --to script --execute --inplace notebook.ipynb注意--inplace表示直接在当前 Notebook 文件里写执行结果,这是一种比较容易误操作的方式,因为它会修改原始文件。我建议保留一个备份,或者在副本上执行。
6. 常见问题与排查技巧实录
下面这些问题,都是我实际帮同事处理转换时遇到过的,几乎每个月都会碰到一次。
6.1 导出的 .py 中文乱码
默认情况下,Python 3 源码文件使用 UTF-8 编码,正常不会乱码。但如果你的 Notebook 元数据里指定了其他编码,或者转换时的读取编码不对,就可能导致乱码。
排查方法很简单:用file命令查看文件编码,再用 Python 的open函数指定encoding="utf-8"读取。如果源文件本身没问题,问题可能出在source字段里的特殊字符。稳妥的做法是在你自己的转换脚本里,统一用encoding="utf-8"读写两侧文件。
6.2 Notebook 中的魔法命令让脚本无法运行
Notebook 里的%matplotlib inline、%load_ext autoreload、%%time这类魔法命令,放到.py脚本里执行时大多会报错,因为这些命令是 IPython Kernel 提供的,标准 Python 解释器不认识。
处理方式前面已经提过,就是在转换时过滤掉%和!开头的行。但有一点要特别注意:!pip install xxx这种命令如果被过滤掉,脚本在部署环境时可能缺少依赖。所以更稳妥的做法是,把环境依赖统一写在requirements.txt或environment.yml里,而不是在 Notebook 中安装。
6.3 转换后的脚本运行顺序与 Notebook 不一致
Notebook 允许任意顺序执行 cell,而脚本是自上而下顺序执行的。如果你的 Notebook 里有这样的结构:cell 3 使用了 cell 1 定义的变量,但你在操作时先把 cell 3 跑了一遍,然后才去跑 cell 1,那么 Notebook 是正确的,但转成脚本后就是错误代码。
这个问题只能靠人工检查。我有个习惯:转完.py后,先看一遍脚本,把每个 cell 的依赖关系梳理出来,如果有前向引用,就在函数定义处加上if __name__ == "__main__"保护,或者调整代码顺序。
6.4 输出内容太多,nbconvert 处理超时
如果你的 Notebook 里有大量print输出或很长的 DataFrame 展示,nbconvert转换时可能会因为处理大量输出而变得很慢,甚至直接超时。
解决方法是转换前清空输出。Notebook 的Edit → Clear Outputs → Clear All Outputs会把所有输出清空,文件体积瞬间变小,转换速度大幅提升。或者在命令行里用jupyter nbconvert --ClearOutputPreprocessor.enabled=True --to script notebook.ipynb,同样可以。
6.5 转换后 # 注释的前缀混乱
当 Markdown 里有结构化的列表、标题(# 标题)时,转换工具会在每行前加一个#,但原有内容里的#是 Markdown 标题的语法,加完之后可能出现# # 标题这样双重井号的情况。这在脚本里不致命,只是看起来比较乱。
如果你在意这个细节,可以在自己的转换脚本里对 Markdown 源做一次清理:先把多余的#合并成#,确保注释美观。也可以接受,反正不影响运行。
6.6 ipynb 文件损坏或非标准结构
有些.ipynb文件是手工编辑过的,或者由第三方工具生成,缺少某些字段。直接用json.load读取可能报错,用nbconvert也可能会抛 KeyError。
面对这种情况,我建议先用 Python 做一次容错读取,设置default值,保证cells字段缺失时也能继续处理。例如在 4.1 的脚本里,nb.get("cells", [])就是容错处理。对于真正损坏的文件,则只能从 Git 或备份中恢复,或者找对方重新导出。
7. 一些适用场景的综合对比与最终建议
到这里,三种主要方式都讲完了。我整理了一张自己的选型参考表,每次处理 Notebook 转换时,我基本会按照这张表来选路。
| 需求场景 | 推荐方案 | 原因 |
|---|---|---|
| 偶尔转单个文件,人在电脑前 | Jupyter 自带导出 | 零安装,操作直观,适合临时救急 |
| 项目里有一批 Notebook,需要批量处理 | 命令行 nbconvert | 支持循环批量,稳定可靠,团队可复现 |
| 需要自定义格式、过滤魔法命令、清理输出 | 自定义转换脚本 | 逻辑完全可控,想怎么处理都行 |
| 要把转换流程固化到团队协作中 | Git Hook / Makefile | 自动化最能保证一致性,省去手动操作 |
| 需要确保 Note book 代码本身可运行 | nbconvert --execute | 执行即校验,发现问题当场暴露 |
我的个人建议是:第一个先学会用jupyter nbconvert --to script,它是功能与复杂度之间的最佳平衡点。命令行的灵活性已经能满足 80% 的场景,而且可批量、可脚本化。剩下 20% 的特殊需求,再动手写自定义转换脚本,这样的投入产出比最高。
如果你在公司团队中工作,我强烈推荐把转换脚本做成交付物的一部分:不管你是用Makefile还是用自定义转换脚本,都把它放仓库里,README 里写清楚用法。这样每个人拿到的 Notebook 和.py都能保持同步,代码评审也会顺畅很多。
说一个实际场景吧——有一个项目,负责人交给我一个 Notebook,让我把里面的图像处理的算法提取到生产脚本里,同时还要保证数据路径正确。我当时就是用自写的转换脚本,过滤了所有的%开头魔法命令和输出,加上了os.chdir自动切换到脚本目录,再把核心算法抽成独立函数。那一次经历让我深刻体会到:直接简单转出来的文件只能算半成品,真正能用于生产环境,一定要做路径、命令清理和函数结构的二次整理。
8. 最后补充几个平时最容易忽略的小细节
转换这件事本身不难,但细节决定了转出来的东西能不能直接用。建议你在转换之后,把这几个点过一遍。
第一,转换后认真读一遍脚本,特别是 cell 之间的数据流。Notebook 里的运行顺序带有很多“藏起来”的状态,比如某个变量可能是某次手动执行的结果,并没有在代码里体现。这类历史遗留问题,转成脚本后绝不会自动解决,只能人工清理。
第二,关于%matplotlib inline,在 Notebook 里它是标配,但在脚本里如果你还想看到图,需要改成plt.show()。转换脚本时最好将这样一个映射关系处理好,否则图上不显示,很容易误以为代码出错了。
第三,ipynb 转为 py 后,尽量保持函数化的写法,而不是一大串脚本逻辑。Notebook 天然是脚本式的,每块代码执行都产生副作用。到了.py里,如果稍后要测试、维护、复用,函数化会轻松很多。你可以在转换后手动包装一下,把每个 cell 的逻辑抽象为函数,再在__main__里按顺序调用。
第四,不要忘了正则转换后的文件大小变化。Notebook 中如果存了大量输出图像或者长文本输出,文件会很大,转换为.py后会瘦身,但反过来也说明 Notebook 里塞了一堆不该提交的东西。如果想要清爽的 Git diff,建议从一开始就统一提交策略:要么只提交.py,要么自动同步且只提交最小的 diff。
我自己现在的习惯比较固定:Notebook 只用作交互探索和可视化分析,一旦逻辑稳定,会立刻手动把核心逻辑迁移到src/目录下的正式模块中,Notebook 里只保留调用代码。这样.ipynb转.py这件事就不再是一个需要反复做的操作,而是一个规范流程里的普通环节。这比你找到一个天衣无缝的转换工具,要靠谱得多。