☰
Jupyter Notebook如何打包成EXE:nbconvert导出脚本与PyInstaller实战
2026/9/30 4:53:58 网站建设 项目流程

经常会有人跑来问我:我的模型在 Jupyter Notebook 里跑得好好的,数据清洗、画图、导出报表都没问题,现在想把整个工具发给一个不会装 Python 的同事,双击就能用,怎么搞成 .exe?

先说结论:Jupyter Notebook 本身不会帮你生成 .exe。它是个交互式笔记本环境,不是编译器,也不是打包器。所谓“Jupyter Notebook 生成 .exe 文件”,完整的链路其实分两步:先利用 nbconvert 把 notebook 里的代码导出成标准 Python 脚本,再用 PyInstaller 这类工具把脚本打包成 Windows 可执行文件。这篇文章我会把整条路走完,用最直接的方式,把从 notebook 到 .exe 的所有环节、参数、坑都掰开讲透,适合刚接触打包的新手,也适合已经踩过几个坑、想一次性搞定的人。

1. 先把Notebook转成标准Python脚本

1.1 理解Jupyter和.exe之间的断层

Jupyter Notebook 的本质是一个 JSON 格式的文档,里面按“单元格”组织代码、Markdown 和输出信息。PyInstaller 这类打包工具根本不认识这种格式,它只认常规的.py文件,所以第一步永远是把 ipynb 变成 py。

有人可能会想:我直接把 notebook 里的代码复制到一个.py文件里不就行了?小脚本确实可以,但两个很现实的痛点会在后面冒出来:一是单元格顺序容易出错,复制时漏掉某个定义;二是 notebook 里常见的交互式命令、魔术命令、动态路径,在脚本化之后需要额外处理。用 nbconvert 转出来的脚本至少结构是完整的,后续清理成本低很多。

1.2 使用nbconvert完成基础转换

nbconvert 是 Jupyter 自带的命令行工具,不需要额外安装。打开终端,进入 notebook 所在目录,执行:

jupyter nbconvert --to script my_tool.ipynb

执行完以后,目录下会多一个my_tool.py,这就是导出的标准 Python 脚本。它把每个代码单元格串联成自上而下执行的顺序,Markdown 单元格会被保留为注释掉的内容,方便以后阅读。

如果你不是每次都想敲命令,也可以在 Jupyter Notebook 里直接放一个单元格,用!调用命令行工具:

!jupyter nbconvert --to script my_tool.ipynb

这样就不用来回切换窗口,写完 notebook 顺手就导出了。还有一个值得留意的点:nbconvert --to script默认会覆盖同名文件,但这个副本和 notebook 是独立的,后续对.py的所有修改都不会反向影响 notebook。

1.3 转换后必做的代码清理

转换出来并不代表能直接打包,我习惯按下面几个步骤清理,缺一个都会在后面给你颜色看。

第一,清除魔术命令。notebook 里常用的%matplotlib inline、%%time、%load_ext autoreload这类魔术命令在脚本环境下要么报错,要么毫无意义。直接删掉,或者用一个条件判断包一层:

try: get_ipython().run_line_magic("matplotlib", "inline") except: pass

第二,移除交互式输出。notebook 里你可能会写plt.show()、display(df),这在打包成 exe 后其实没问题,但如果你的目标是一个不需要弹窗的批量任务,这些行会多出不必要的窗口。根据用途决定去留。

第三,整理if __name__ == "__main__":入口。导出的脚本是纯顺序执行,打包工具不会管你有没有入口函数,但为了让代码在被其他模块导入时不误执行,把主流程包进这个判断里是好习惯。

1.4 处理输入输出和路径依赖

这条是重点,也是后面很多诡异问题的根源。

notebook 里你经常写相对路径,比如df = pd.read_csv("./data.csv")。在 Jupyter 里,当前工作目录是 notebook 所在的目录,运行没问题。但打包成 exe 以后,工作目录变成 exe 被启动时的目录。如果你在资源管理器里双击 exe,工作目录就是 exe 所在目录;如果在命令行里带路径启动,工作目录又变成了当前命令行目录。路径一旦对不上,程序必然报文件找不到。

我的习惯是:在脚本开头用pathlib把基础路径固定下来,下面所有读写都基于它:

import sys from pathlib import Path if getattr(sys, "frozen", False): BASE_DIR = Path(sys.executable).resolve().parent else: BASE_DIR = Path(__file__).resolve().parent

这段代码的逻辑是:程序被 PyInstaller 冻结(打包)之后,sys.executable指向 exe 文件本身,所以BASE_DIR就是 exe 所在的目录;没打包时,__file__指向脚本文件,所以BASE_DIR就是脚本所在目录。这样一来,不管你是直接跑 py,还是双击 exe,资源文件和输出文件都在同一个稳定位置。后续所有路径写成BASE_DIR / "data" / "input.csv"这种形式就不会出问题。

2. 选对打包工具:为什么PyInstaller是主力

2.1 三种常见打包器横向对比

正常参与选择的打包器主要是这三个:PyInstaller、Nuitka、cx_Freeze。py2exe 现在已经很少用,新项目基本不考虑。

工具核心机制适用场景缺点
PyInstaller分析脚本import关系,收集依赖,生成可执行文件绝大多数Python项目,上手快反编译难度低,启动稍慢
Nuitka把Python代码编译成C再编译成机器码对性能、反编译有要求的工具编译时间长,配置复杂
cx_Freeze类似于PyInstaller的依赖收集跨平台需求较强时插件和隐藏导入处理更繁琐

日常“notebook转exe”的需求,不管里面用了 pandas、numpy、matplotlib 还是 openpyxl,PyInstaller 都是最稳的默认选项。它社区活跃、文档全、对主流科学计算库的支持最好。Nuitka 我一般只在两种情况下用:一是代码比较敏感,想降低被直接反编译读源码的概率;二是程序启动速度要求极高,比如命令行小工具。除此之外,PyInstaller 就够用了。

2.2 PyInstaller的核心参数速查

PyInstaller 常用参数不多,但每个都很关键,这里整理成一张表,后面实操会反复用到:

参数作用
--onefile打包成单一 exe 文件,运行时临时解压到临时目录
--onedir生成一个文件夹,内含 exe 和依赖文件,启动更快
--windowed不显示控制台窗口,适合GUI程序
--noconsole同--windowed,都是关闭控制台
--icon指定 exe 图标文件
--name指定生成的 exe 文件名
--hidden-import手动添加PyInstaller分析不到的模块
--add-data把数据文件、配置文件打进包
--collect-all收集某个包的全部子模块和数据,适合复杂包
--clean清除缓存后重新打包
--noconfirm覆盖输出目录时不需要确认

这里有个概念必须先搞清楚:--onefile并不是真的把所有东西都直接变成一个文件,它只是把依赖打包进一个自解压程序,运行时在临时目录里展开。所以 onefile 型的 exe 双击后启动慢是正常现象,第一次还要更慢。如果你的程序本身比较大,比如带 pandas、numpy,我强烈建议先用--onedir发布,等确认逻辑没问题,再考虑要不要做成单文件。

2.3 建立干净的打包环境

用全局 Python 环境打包大型项目是大忌。你的计算机里可能装了 TensorFlow、torch、各种乱七八糟的包,PyInstaller 分析依赖时会把这些统统扫进去,最终结果就是 exe 体积爆炸,甚至无法运行。

正确的做法是单独建一个干净的虚拟环境,只装项目运行所需的依赖,然后在这个环境里安装 PyInstaller 并执行打包。如果你平时用 conda,可以这样操作:

conda create -n exe_build python=3.11 -y conda activate exe_build pip install pandas matplotlib openpyxl pyinstaller

虚拟环境建好之后,把前面导出的my_tool.py放进一个工作目录,再在这个环境里执行打包命令。用干净环境的目的有两个:一是控制体积,打包工具只看到该有的依赖;二是避免依赖版本冲突,尤其是pandas、numpy这类重库,全局环境里版本一多,打包出来经常出现“DLL load failed”这种问题。

3. 完整打包实操:从命令到配置

3.1 基础打包命令与产物验证

假设你的脚本已经清理干净,名字叫my_tool.py,在虚拟环境exe_build已激活的状态下,最简单的打包命令是:

pyinstaller --noconfirm --clean my_tool.py

不指定任何模式时,PyInstaller 默认生成onedir类型。执行完之后,目录下出现build和dist两个文件夹。dist/my_tool/里面就有my_tool.exe和一堆依赖文件。此时要做的第一件事就是双击运行它,确认和你在 notebook 里跑出来的结果完全一致。

如果只是想快速生成一个单文件 exe,用这条:

pyinstaller --noconfirm --clean --onefile my_tool.py

注意:--onefile打包完毕之后,exe 文件会出现在dist/目录下。强烈建议打包完成之后,把 exe 复制到一个全新的目录里测试一遍,不要直接在dist里双击。因为dist目录里可能残留了上一次打包的其他依赖文件,如果某些资源是靠相对路径加载的,exe 在dist里能找到,复制出去后就找不到了,问题就会被掩盖。

3.2 为exe添加图标、版本信息和资源文件

程序要发给别人用,裸奔的默认图标非常劝退。加图标很简单,准备一个.ico格式的图片文件,然后追加参数:

pyinstaller --noconfirm --clean --onefile --icon=tool.ico --name=my_tool my_tool.py

图标必须是 ICO 格式。网上有很多在线转换工具,也可以先用 Python 的Pillow库自己转,几十行代码就能搞定。如果你在意版本信息,比如文件描述、版本号、公司名,可以在 Windows 上写一个.rc文件或者用 PyInstaller 的--version-file参数。不过对小工具来说,这一步不是必须的。

如果你的程序还依赖图片、配置文件、模板文件,例如一个 Excel 报表模板,必须用--add-data把它打包进去。Windows 下的分隔符是分号,Linux 下是冒号,别记混了:

pyinstaller --noconfirm --clean --onefile --add-data "templates;templates" my_tool.py

这里的写法把本地的templates整个目录打进去,解压后的路径相对于临时目录。你在脚本里读取这个文件时,不能直接写相对路径,而是要通过sys._MEIPASS来访问。PyInstaller 的--onefile模式会把资源解压到一个临时目录,这个路径存在sys._MEIPASS变量里。所以最稳的做法是在脚本里写一个兼容函数,同时处理打包和未打包的场景:

import sys from pathlib import Path def resource_path(relative_path): if hasattr(sys, "_MEIPASS"): return Path(sys._MEIPASS) / relative_path return Path(__file__).parent / relative_path

读取模板的时候用resource_path("templates/report.xlsx"),就不会出现找不到文件的问题了。

3.3 处理隐藏导入和数据文件

PyInstaller 通过静态分析 import 语句来收集依赖,但有些库会在运行时动态导入子模块,尤其是 pandas、sklearn、scipy 这类科学计算库。最常见的报错是运行 exe 时提示ModuleNotFoundError,它通常指向一个你根本没主动 import 的模块。

解决方式很简单,往命令行里追加--hidden-import。比如你发现打包后的程序报错No module named 'pandas._libs.tslibs.timedeltas',可以这样补:

pyinstaller --noconfirm --clean --onefile --hidden-import="pandas._libs.tslibs.timedeltas" my_tool.py

但手动一个个补很累,还有更粗暴但有效的方式:--collect-all。它会收集某个包的所有子模块、数据文件、动态库,适合那些依赖关系特别复杂的库。比如页面里用了sklearn,建议直接:

pyinstaller --noconfirm --clean --onefile --collect-all sklearn my_tool.py

代价就是体积暴涨。所以我的建议是:能明确指定隐藏导入就少用--collect-all,只有实在排查不出漏了什么模块时才用。

3.4 在Notebook里内置一键打包

如果经常要更新脚本并重新打包,每次都在终端敲命令太啰嗦。其实可以回到 notebook 里,专门开一个单元格,把打包命令封装起来:

import subprocess cmd = [ "pyinstaller", "--noconfirm", "--clean", "--onefile", "--name", "my_tool", "my_tool.py", ] subprocess.run(cmd, check=True)

前提是当前 Python 环境里已经安装了 PyInstaller,并且你导出的my_tool.py就在 notebook 同级目录。每次修改完 notebook,先nbconvert重新导出脚本,再运行这个单元格,exe 自动重新生成。这就是很多人说的“Jupyter Notebook 生成 exe 文件”最接近的字面意思,本质上还是依赖 PyInstaller。

如果你希望更自动化,还可以把 nbconvert 也一起包进来:

import subprocess subprocess.run(["jupyter", "nbconvert", "--to", "script", "my_tool.ipynb"], check=True) subprocess.run( ["pyinstaller", "--noconfirm", "--clean", "--onefile", "--name", "my_tool", "my_tool.py"], check=True, )

这样每次在 notebook 里运行这个单元格,就能完成“导出脚本 + 打包 exe”的一键操作,非常省事。

4. 实战中反复踩到的坑

4.1 双击后没反应或者闪退

出现这种问题,第一件事是不要双击,而是打开命令提示符,进入 exe 所在目录,手动执行。例如:

cd /d D:\dist my_tool.exe

控制台会直接显示 Python 异常信息,十有八九是某个模块没有导入成功,或者某个数据文件路径不对。看到具体的报错,基本就能定位了。

如果你用的--windowed模式,控制台窗口被屏蔽,那么所有 print 输出都看不到,闪退之后很难排查。建议在打包调试阶段先不要加--windowed,让控制台打印所有日志;确认一切正常之后,再改成--windowed打包最终版本。

如果程序连 main 都没进就退出,通常是依赖缺失,检查一下是不是没有在干净环境里打包,或者漏了--collect-all。这类问题九成以上都能靠减少环境干扰解决。

4.2 路径问题:资源文件找不到了

路径问题也算是最常见的坑。很多人写的脚本里有相对路径,比如data/input.csv,在 notebook 里跑没问题,打包后 exe 一看不到data文件,二来工作目录也不固定。前面提到的BASE_DIR配合resource_path是基础解法。

另外一个建议:不要在代码里拼路径用字符串相加,全部用pathlib.Path。字符串拼接在跨平台和不同启动方式下特别容易漏分隔符,用Path能避免大量低级错误。

4.3 依赖库体积过大

一个只干了点数据处理的脚本,用 pandas 和 openpyxl,打包出来可能就 100 多 MB,很多人会以为是自己哪里弄错了。其实不是,pandas 本身就大,numpy 和 matplotlib 更是重量级,PyInstaller 把它们整个打包就是这体积。

如果确实想瘦身,可以考虑三条路:

一是只引用用到的库的子模块,减少 import 范围。但 pandas 这类库本身 import 机制决定了很多模块会连带加载,效果有限。

二是用--exclude-module排除完全用不到的模块。比如你不用 matplotlib,但某个间接依赖把 matplotlib 带进来了,可以在命令里加--exclude-module matplotlib。

三是改用更轻量的替代库。比如只想读取 Excel,不一定非用 pandas,用openpyxl或者xlrd单独处理就小很多。如果是从 notebook 里做分析才开始用 pandas 的,那这步就不太值得了。

4.4 杀毒软件误报

PyInstaller 打包的 exe 被 Windows Defender 或其他杀毒软件报毒,是这两年出现得越来越多的问题。根本原因是 PyInstaller 生成的 exe 结构是“自解压+动态加载 dll”,这种模式跟很多加壳木马的特征相似,杀软会误判。

最实用的缓解手段有这几个:用--onedir代替--onefile,降低误报率;给 exe 添加数字签名;把代码用 Nuitka 编译掉部分 Python 特征。注意,数字签名需要证书,个人开发者可能没有,但至少可以试一下免费的证书服务或者自签名证书,虽然自签名不能完全消除警告,但能稍微降低误报概率。

4.5 多进程与并发问题

如果 notebook 里用了multiprocessing,打包成 exe 后经常遇到“子进程无限重启”或者“运行时卡死”的问题。原因在于 PyInstaller 会修改sys.frozen,multiprocessing 需要重新启动 exe 来创建子进程,如果没有正确处理入口,子进程也会走一遍主逻辑。

解决办法是在主模块入口处一定要有:

if __name__ == "__main__": multiprocessing.freeze_support()

而且绝大多数情况下,freeze_support()必须无条件在 importmultiprocessing之后立即执行。另外,如果要打包成--onefile,multiprocessing 的并发处理会更麻烦,建议优先用--onedir测试通过之后再说。

5. 从脚本到小工具:进阶扩展思路

5.1 给exe加一个简单的交互界面

很多人把 notebook 里的脚本打包成 exe,不是自己用,而是给业务同事用。纯命令行对同事不友好,这时候就得加交互界面。

最快的方式是用gradio或者streamlit做网页界面,脚本里启动一个本地服务,浏览器自动打开,然后你再打包 exe。不过这样一来,体积会多出不少。如果只是一个表单、一个按钮、一个结果展示,用tkinter更轻。写完界面之后,PyInstaller 打包时注意加--windowed,避免同时弹出黑色控制台窗口。

举个例子,你用 tkinter 写了一个小窗口,点击“开始处理”按钮就执行 notebook 里的核心函数,那么打包命令变成:

pyinstaller --noconfirm --clean --windowed --onefile --name=数据清洗工具 my_tool_gui.py

这种用法适合做内部工具,不需要服务器,双击就能让同事用起来。

5.2 使用命令行参数和配置文件

如果不想做界面,也不想让程序死板地只能处理固定文件,那就在脚本里解析命令行参数:

import argparse parser = argparse.ArgumentParser() parser.add_argument("--input", required=True) parser.add_argument("--output", default="./output.xlsx") args = parser.parse_args()

打包后用命令行的方式调用:

my_tool.exe --input 原始数据.xlsx --output 结果.xlsx

这样同一个 exe 就能被批处理脚本循环调用,适合批量任务。也可以把参数写到配置文件里,脚本启动时读取,避免每次敲命令。对于 notebook 转过来的脚本,这种改造难度极低,收益却很高。

5.3 自动化打包流程

当你的 notebook 脚本更新频繁,每次手动导出、打包、复制、改名挺麻烦的。可以在虚拟环境里准备一个build.bat脚本,用来一键打出指定名字的 exe:

call conda activate exe_build jupyter nbconvert --to script my_tool.ipynb pyinstaller --noconfirm --clean --onefile --name=my_tool my_tool.py pause

以后每次更新完 notebook,双击这个 bat 文件,等它跑完,dist/my_tool.exe就是最新的。再进一步,还可以在 bat 里加一条自动复制命令,把 exe 复制到一个共享盘或者指定发布目录,省掉手动搬运的步骤。

如果团队已经有 CI 系统,比如 GitHub Actions,也可以配一个 Windows Runner,在里面创建 Python 环境、安装依赖、执行 nbconvert、执行 pyinstaller,最后把生成的 exe 作为 artifact 上传。这样每次提交代码就能自动产出 exe,省心不少。

根据我个人很偏执的习惯,还有一个必须经常提醒自己的点:任何时候打包,都要在发布前把 exe 放到一台没有装过 Python 的机器上测一遍,或者至少在当前机器上临时把 Python 环境变量屏蔽掉再试。因为开发机里往往隐藏着大量 Python 解释器可以帮忙兜底,只要打包漏了依赖,exe 在开发机上可能碰巧还能跑,但到了用户手里立刻就废。

如果你真的只想要一个简单结论,记住这句话:Jupyter Notebook 生成 exe,就是“nbconvert 导出脚本 + pyinstaller 打包”,中间所有时间都花在路径、隐藏依赖和体积控制上。把干净环境准备好,把入口和路径搞对,你大概率一次就能打出能用的 exe。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询