ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Jupyter Notebook转EXE全流程:nbconvert到PyInstaller实战

Jupyter Notebook转EXE全流程:nbconvert到PyInstaller实战 先回答一个很多人问过我的问题能不能真的把一个jupyter notebook直接变成.exe交给别人双击运行答案是可以但不是把.ipynb文件本身拿去打包而是把它转成.py脚本再用打包工具封装成可执行文件。这篇文章就围绕这条完整链路展开——从 notebook 到.py从.py再到.exe中间有哪些坑、哪些参数、哪些路径问题我都会写清楚,顺便把热词里提到的网址打包 exe这类延伸需求也一并聊透。这个需求的典型场景通常是你花了一晚上在 jupyter notebook 里调好了一个数据处理脚本、一个自动化报表、或者一个小工具现在要交给同事用。同事电脑上没有 Python、没有 Anaconda、更不会运行 notebook,你唯一能交付的就是一个双击就能跑的.exe。如果你正好是这种情况这篇文章就是给你准备的。1. 先想清楚从 .ipynb 到 .exe本质上要经过哪几步先说一个最常见的认知误区很多人以为打包工具能直接识别.ipynb文件。实际上 PyInstaller 也好、Nuitka 也好它们能处理的是.py源码而不是 notebook 这种 JSON 格式。所以整条链路的第一步永远是把 notebook 里的代码抽出来变成干净的 Python 脚本第二步才是用打包工具把脚本连同解释器和依赖库一起封装成可执行文件。那我为什么不建议手动复制粘贴因为手动操作太容易漏东西了。notebook 里除了代码单元还有 Markdown 说明、图像输出、交互组件的状态手动复制会把这些杂质一起带过来。更好的做法是用 nbconvert 做自动转换它会在输出时自动剥离非代码内容生成的.py文件通常可以直接运行。再来说二次确认的问题转换完之后必须先在本地用 Python 直接把.py跑一遍。这一步特别重要因为 notebook 有记忆状态——你在 notebook 里连续运行了几十个单元格每个变量的值都还留在内存里。但转换成.py脚本之后脚本是从头执行到尾的没有中间状态。如果你的代码逻辑依赖了某个之前在别的单元格里定义的变量转成脚本后就会直接NameError。所以我在实战中总结的完整路径是在 notebook 里用jupyter nbconvert --to script把.ipynb转成.py在命令行用纯 Python 环境跑一遍这个.py确认没有运行时报错对代码做清理删掉魔法命令、调试输出、%matplotlib inline这类 notebook 专用语句用 PyInstaller 带参数打包生成 exe在没有 Python 的干净环境可以用虚拟机里测试 exe每一步都有坑后面我会按顺序展开。尤其是第四、五步最容易出问题我先在这里打个预防针打包工具的自动探测不是万能的很多依赖它找不到数据文件它不会主动带图标和版本信息也需要通过参数或 spec 文件指定。2. 用 nbconvert 把 notebook 转成干净脚本命令与清理细节假设我的 notebook 文件叫report.ipynb在命令行里执行jupyter nbconvert --to script report.ipynb执行完之后同目录下会生成report.py转换逻辑很简单——每个代码单元格的内容会被依次写进文件里Markdown 单元格被注释掉代码单元格之间用注释标记分隔。这个脚本基本可用但不能直接拿去打包因为里面通常还残留着 notebook 专属语法。最常见的就是魔法命令。比如你曾经在 notebook 里写过%matplotlib inline %load_ext autoreload %autoreload 2%matplotlib inline在脚本环境下直接运行会报错因为它依赖 notebook 后端。%load_ext autoreload在脚本里也没有意义。这些行在转换为.py后虽然会被原样保留但运行时大概率抛异常。手动删掉或者注释掉就行。第二个要处理的是tqdm类进度条。notebook 里经常用tqdm.notebook显示进度条它生成的是 HTML 组件转成脚本后要么不显示、要么报错。建议统一改成from tqdm import tqdm这样在终端里也能看到进度。第三个要注意的是打印输出量。notebook 里每个单元格的输出是独立的量大点没关系。但转成脚本后所有输出会全量打到控制台如果代码里有print(df.head())这种调试语句脚本运行速度会肉眼可见地变慢。我建议打包前把这类调试输出统一注释掉只保留真正需要展示的结果。清理完语法层面的问题还要处理一个更隐蔽的坑相对路径失效。在 notebook 里工作目录默认是启动 jupyter 的目录而且 notebook 文件本身的位置经常不是代码逻辑的基准。你如果写过pd.read_csv(data.csv)这种代码在 notebook 里能跑通是因为data.csv刚好在当前工作目录。但转成脚本后工作目录取决于你从哪个路径执行脚本一旦换目录相对路径就全乱了。我的建议是在转换后脚本的最顶部加上一段硬化路径的代码让脚本以自身所在目录为基准去寻找文件import os import sys BASE_DIR os.path.dirname(os.path.abspath(__file__)) os.chdir(BASE_DIR)这段代码能让脚本无论从哪里被调用都把当前目录切到脚本自身所在的目录。后面打包成 exe 后__file__会指向 exe 解压后的临时目录这个原理后面会细讲但先把脚本阶段跑通最重要。3. PyInstaller 基础打包参数怎么选依赖怎么控制体积怎么瘦身脚本准备好后进入核心打包环节。目前 Python 生态里打包 Windows exe 的主流方案我实际用过的有四个PyInstaller、Nuitka、cx_Freeze、py2exe。直接说结论无脑首推 PyInstaller理由很简单社区活跃、资料多、对 pandas、numpy、matplotlib 这类重型库的兼容性最好。Nuitka 因为带编译优化运行效率更高、也更难被反编译但配置门槛高对不熟悉 C 编译链的人来说劝退率很高。cx_Freeze 和 py2exe 基本属于历史遗留选择除非项目里有特殊要求否则不用考虑。安装 PyInstallerpip install pyinstaller然后先跑一个最简单的打包命令验证整个流程通不通pyinstaller --onefile --clean report.py这里我解释一下几个核心参数为什么这么设--onefile把程序打包成单个 exe 文件。缺点启动时会先自解压到临时目录速度略慢优点是交付方便给同事发一个文件就行。--clean每次打包前清空缓存避免旧的构建文件干扰新的打包结果。打包改过代码后发现行为没变多半是没加这个参数。--windowed或--noconsole如果程序是图形界面、不需要控制台窗口就加这个参数。如果你的脚本有 print 输出想保留一个黑窗口显示输出日志就别加。这里不要盲目加取决于你的实际程序类型。初次打包成功后你会看到dist目录下生成了 exe同时项目根目录多了build目录和report.spec文件。report.spec是 PyInstaller 的配置文件后续所有深度定制都靠它。接下来处理打包体积。很多人看到 exe 动辄 200MB 甚至更大就慌其实这是正常的因为 PyInstaller 是打包解释器它会把 Python 解释器和程序实际 import 到的所有库全部塞进去。数据科学相关的库pandas、numpy、matplotlib体积本来就大一个 matplotlib 就能带来近 100MB。想瘦身我的经验是这样尽量用虚拟环境打包。为你的项目单独建一个干净的 venv只安装当前脚本需要的库而不是在 base 环境里打包——base 环境装了一堆用不上的包PyInstaller 的依赖分析虽然会淘汰未引用的模块但某些库之间的隐式关联还是会把多余的东西带进来。如果只是用 pandas 处理表格可以考虑用更精简的方式替代比如纯 csv 模块 openpyxl体积能少一半以上。matplotlib 只画一种图时可以在导入后禁用不需要的后端和字体缓存这个属于进阶优化普通场景先用不上。先跑通、再优化这是打包工作流里最务实的原则。第一次打包出来的 exe 能正常双击运行就已经成功一半了。4. 深度定制 spec 文件数据文件、隐藏依赖和资源路径的正确姿势随着打包次数变多你会发现命令行参数只是入门配置真正决定打包成败的是report.spec文件。每次用命令行打包时PyInstaller 都会根据参数生成一个 spec 文件你可以手动改它再执行pyinstaller report.spec来按配置打包。看一个最典型的 spec 示例# report.spec # -*- mode: python ; coding: utf-8 -*- a Analysis( [report.py], pathex[], binaries[], datas[(data.xlsx, .), (config.ini, .)], hiddenimports[pandas._libs.tslibs.timedeltas], hookspath[], hooksconfig{}, runtime_hooks[], excludes[], noarchiveFalse, ) pyz PYZ(a.pure) exe EXE( pyz, a.scripts, a.binaries, a.datas, [], namereport, debugFalse, bootloader_ignore_signalsFalse, stripFalse, upxTrue, consoleTrue, iconapp.ico, )这个文件里最常用的是三个配置项datas把数据文件打进包里的入口。格式是(源文件路径, 目标目录)。例如(data.xlsx, .)表示将data.xlsx放到 exe 解压后临时目录的根目录代码里用pd.read_excel(data.xlsx)就能读取。如果你有多个数据文件还可以用(data_folder, data_folder)把整个目录塞进去。hiddenimports显式告诉 PyInstaller 哪些模块必须包含。依赖分析器偶尔会漏掉动态 import 的库这时程序运行时就会报ModuleNotFoundError。把漏掉的模块名写在这里就能强制打入。icon指定 exe 的图标。真正复杂的是资源文件的路径处理。上面提到过--onefile模式下exe 运行时会把依赖解压到一个临时目录__file__指向的是那个临时目录而不是 exe 所在的目录。也就是说你的代码里如果写os.path.join(os.path.dirname(__file__), data.xlsx)在--onefile模式下找的其实是临时目录数据文件也正好被打进了临时目录所以能对得上。但如果你还想在 exe旁边生成输出文件就不能依赖__file__了得在执行时判断自己是处于开发模式还是打包模式。我推荐在入口脚本顶部加这样一段判定逻辑import os import sys if getattr(sys, frozen, False): # 打包后的 exe 运行时 BASE_DIR os.path.dirname(sys.executable) # exe 所在目录 RESOURCE_DIR sys._MEIPASS # 临时资源目录 else: # 开发环境运行 .py 时 BASE_DIR os.path.dirname(os.path.abspath(__file__)) RESOURCE_DIR BASE_DIR def resource_path(relative_path): return os.path.join(RESOURCE_DIR, relative_path)读取数据文件用resource_path(data.xlsx)输出文件则写到BASE_DIR这样无论在开发环境还是打包后文件路径都不会错。这个模式值得当成套路记下来90% 的exe 在别人电脑上找不到文件的报错根源都是这里。5. 打包完跑不起来一份可以直接照着查的排错清单打包过程中你几乎一定会遇到下面几种报错我把排查链路写出来遇到问题直接对号入座。情况一双击 exe 后完全没反应或者报Failed to execute script report这个提示背后的信息量很少真正的错误原因被 PyInstaller 吞掉了。我的排查方法是在consoleTrue的情况下,在命令行里运行 exedist\report.exe控制台会直接打印出 Python 异常堆栈然后按异常类型处理如果是ModuleNotFoundError: No module named xxx说明 PyInstaller 漏掉了这个依赖。解决方案是把模块名加进 spec 的hiddenimports。如果报的是找不到文件FileNotFoundError那基本就是路径问题用上面那套resource_path逻辑重新处理。情况二exe 在开发机正常运行换台电脑就打不开优先检查三件事第一目标机器是否为 64 位系统第二是否缺少 VC 运行库大多数 Windows 10/11 自带老系统需要装第三是否被杀毒软件拦截。第三点是最容易忽视的--onefile模式的 exe 本质是自解压 运行很多杀软会把这个行为当木马处理。遇到这种情况可以让对方把 exe 所在目录加入杀软白名单。这确实会带来体验问题但不是代码层面的毛病。情况三matplotlib 图形不显示、闪退这是打包脚本里最容易出问题的库之一。notebook 里写的%matplotlib inline在打包后肯定不能用你需要显式指定后端为TkAgg并且确保 PyInstaller 打包了 Tkinter 的相关文件import matplotlib matplotlib.use(TkAgg)如果加了这行还是不行就在 spec 的hiddenimports里补上tkinter和matplotlib.backends.backend_tkagg。另外matplotlib 的字体缓存文件在首次运行时会在用户目录生成某些精简环境下可能没有写权限稳妥做法是在代码里指定一个可写目录作为字体缓存import os os.environ[MPLCONFIGDIR] os.path.join(BASE_DIR, .matplotlib)情况四多进程程序multiprocessing打包后频繁崩溃Windows 上创建子进程会重新启动 Python 解释器而打包后的 exe 内部机制跟普通 Python 环境不同。这种问题通常要靠multiprocessing.freeze_support()解决import multiprocessing if __name__ __main__: multiprocessing.freeze_support() # 主逻辑只要用了多进程这一行就一定要加。PyInstaller 的官方文档里也专门强调过。情况五exe 启动特别慢这是--onefile的固有缺陷——需要把文件解压到临时目录。如果程序依赖 pandas、numpy 这种大体积库启动时间会从 3 秒到 10 秒不等。如果无法接受可以改用目录模式打包去掉--onefile交付一个文件夹启动速度会快很多。6. 延伸需求别人想要的是notebook 网页流程而不是纯逻辑脚本有一类需求在热词里也出现过jupyter notebook 网页版网址打包 exe。这类人其实不是要运行业务逻辑而是想让一个带交互界面的 notebook 服务或一个 Web 应用变成exe后双击可用。对这种需求思路跟前面完全不一样不是把.ipynb转成.py再打包而是把本地跑的 Web 服务壳变成一个桌面应用。我用过的方案是nativefier它本质上是用 Electron 把任意网址封装成一个跨平台桌面程序。如果目标是让用户打开 exe 后自动启动 Jupyter 服务并打开浏览器需要在启动脚本里先jupyter notebook拉起服务再用 nativefier 生成的壳打开对应端口。但这里我建议你先冷静一下如果目标用户只是需要点开一个界面、填几个参数、看一个结果那直接用jupyter notebook作为交互载体本身就是最重的方案。更合理的路线是把 notebook 里的逻辑改写成简单的交互脚本比如用tkinter做一个带输入框和按钮的界面再按前面说的方式打包成一个实用的工具 exe体验远比套个 Electron 壳流畅体积还小。反过来说如果 notebook 里的核心价值在于可调试、可修改、可展示过程本身那交付.ipynb文件并让对方安装 Anaconda 才是正解。强行打包成 exe 反而把计算过程可视化这个价值给丢了。7. 大脚本和重型依赖场景进阶优化与替代工具参考当你的脚本进入了重型依赖阶段——比如用到 torch、tensorflow、opencv 这类几十 GB 级别的库PyInstaller 打包会非常痛苦体积大、依赖分析出错概率高、打包时间动不动就十分钟以上。这一节我给出两个进阶方案供参考。第一个是 Nuitka。它可以把你写的 Python 代码翻译成 C 代码并编译成原生二进制再利用 MinGW 或 MSVC 生成 exe。相比 PyInstallerNuitka 打出来的程序运行性能有一定提升反编译难度也高得多——你同事或客户拿到手的是真正的二进制文件而不是解压后的 Python 字节码。缺点是配置复杂官方文档写得比较绕遇到依赖问题需要手动指定--include-package、--include-data-files。第二个思路是 GraalVM Native Image。热词里也出现了 graalvm打包成exe。GraalVM 能把 JVM 语言打成原生可执行文件但它跟 Python 生态的结合并不像 PyInstaller 那么开箱即用对于 pure Python 脚本来说目前主流还是前两种方案。如果哪天有人跟你说他用 GraalVM 打包了个 Python exe 很顺利大概率是用了 GraalPy 这类第三方集成而这种方案在处理 numpy/pandas 时都有兼容性门槛普通项目不建议轻易尝试。我的个人建议是默认用 PyInstaller项目跑通后再决定要不要引入 Nuitka 或 GraalVM。试图一上来就上重型方案只会让notebook 转 exe任务从 1 天延长到 1 周。8. 一处特殊场景脚本里出现隐藏依赖时的处理技巧hiddenimports 这个概念对新手来说比较抽象我展开多说一点。PyInstaller 在分析依赖时只会扫描import语句、字符串字面量、部分函数调用里能看得到的模块。但如果你的代码里有动态导入或者某个第三方库的内部用了__import__()、importlib.import_module()甚至字符串拼接模块名的方式加载子模块PyInstaller 就扫不到它们运行 exe 时就会报模块不存在。举一个我印象很深的真实例子某次打包一个调用了pandas的脚本本机测试完全正常但换到同事电脑上运行时报了ModuleNotFoundError: No module named pandas._libs.tslibs.timedeltas。这个模块藏在 pandas 内部是延迟加载的。解决方式就是在 spec 的hiddenimports列表里手动补上它。如果不想改 spec 文件也可以用命令行参数pyinstaller --hidden-import pandas._libs.tslibs.timedeltas --onefile report.py排查到底缺了哪个隐式依赖也有一个笨办法在开发环境跑 python 时先import你的主脚本然后递归打印sys.modules里的所有模块名跟 PyInstaller 的 build 日志里的模块列表对比两边一对照就能找出疏漏。这个方法虽然费时间但特别管用。9. 最后的交付验证没有 Python 的干净环境是唯一的验收标准代码改好了exe 也打出来了但请不要立刻发给同事。我见过太多人踩同一个坑在本机测一切正常发出去了对方双击报错自己又复现不了最后只能远程协助。为什么会这样因为本机有完整 Python 开发环境exe 运行时的很多系统路径、环境变量恰好能对上而干净环境没有。所以我的验收习惯是这样的找一台没有安装 Python、Anaconda 的 Windows 虚拟机或者朋友的电脑把 exe 复制进去。关闭杀软白名单测试一次开着杀软再测一次确保没有被误杀。测试所有交互路径不光是正常路径还有输入错误参数、文件不存在、路径含中文等边界情况。exe 打包后对中文路径的支持相比脚本环境会差一些能避开就避开。测试输出文件是否生成在了预期位置尤其是用户数据必须落在 exe 旁边或用户目录而不是临时目录。只有这一步做完了你才能真正放心地把 exe 交付出去。你交付的不只是一个能运行的文件而是一份在没有 Python 的世界里也能正常工作的成果。最后分享一个我自己的习惯打包项目我会单独维护一个build_notes.md记录每次打包的参数、遇到的报错和解决方案。这个文件在交接给别人的时候特别有用因为几个月后你大概率会忘记当初是怎么解决那些刁钻问题的而你的同事可能会拿着同一个项目问你这个 exe 是怎么打出来的。把今天这篇文章里的思路和排查清单保存下来再结合你自己的实际记录下次再遇到这类需求基本就不会卡住超过半小时了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进