引言
这是第一章《环境搭建与运行原理》的最后一篇。前 9 篇我们构建了一条完整的工具链:解释器(第 2 篇)、隔离环境(第 4 篇)、包管理(第 5 篇)、REPL(第 6 篇)、规范(第 7 篇)、调试(第 8 篇)、IDE(第 9 篇)。它们的共同点是”脚本 + 命令行”的形态:代码写成文件、整体运行、输出堆在终端里。但有一类工作用这种形态很别扭——数据探索:你面对一批数据,需要反复”换个角度看看”,每个角度都是一段临时代码;你还要把”为什么这样分析、得出了什么”记录在旁边,供日后复用或向他人展示。
本文介绍的 Jupyter Notebook 正是为这类工作而生的交互式工作台:代码、运行结果、图、叙述性文字,全部组织在一份文档里,而且每段代码都能独立、反复地运行。读完本文,你会理解 notebook 的文档结构与前后端架构,掌握单元格操作、快捷键与魔法命令,并完整走一遍”提出问题 → 写代码 → 看输出 → 写结论”的数据探索闭环。读者应先掌握第 6 篇 REPL 的交互思维——Jupyter 的”一个格子一个格子地执行”正是 REPL 的文档化升级版。
出身与原理:IPython 血统、内核架构与 .ipynb 文档
Jupyter 项目有一个清晰的传承:它的内核 IPython(Interactive Python 的缩写)由 Fernando Pérez 于 2001 年启动,是那个年代最强悍的 Python 交互环境;2014 年,IPython 团队把”交互式 Notebook”能力从语言绑定中剥离,成立了独立的 Jupyter 项目——名字取自三类最初支持语言的缩写:Julia、Python、R,寓意”面向数据科学的通用交互平台”。这项工作的代表性论文 Jupyter Notebooks – a publishing format for reproducible computational workflows(Kluyver 等,2015 年发表)至今仍是引用 Jupyter 时的标准出处。
理解 notebook 需要抓住三层结构:
第一层:文档格式。 一个 notebook 文件的后缀是 .ipynb(interactive python notebook),本质是一份 JSON 文本。它由一组 单元格(cell) 组成,每个单元格都带有 cell_type(代码 code / 叙述 markdown / 原始 raw)、源代码 source、执行输出 outputs 与执行计数 execution_count 等字段。正因它是 JSON,notebook 才能被版本管理、被其他程序解析、被 nbconvert 转换为脚本或网页。我们稍后会用真实工具生成并检查一份 .ipynb。
第二层:运行架构。 notebook 采用”前端-内核”分离架构:浏览器中的界面只是客户端,真正执行代码的是后台的**内核(kernel)**进程——对 Python 而言就是 IPython 内核(ipykernel)。前端把代码通过消息协议(底层是 ZeroMQ 与 websocket)发给内核,内核执行后把输出原路送回。这个架构有两个直接推论:其一,不同内核间前端界面一模一样,换一种语言只是换内核;其二,内核是有状态的长驻进程——x = 1 在单元格 1 执行后,单元格 2 再引用 x 完全正常,变量、导入、函数定义跨单元格存活,这正是 2000 年 IPython 交互式方言的延续。
第三层:交互语义。 与第 6 篇 REPL 一样,notebook 的每个代码单元格输入后即刻可运行(快捷键运行),输出紧跟单元格下方显示;不同的是输出会被持久化进文档,形成”代码-输出-叙述”交错的记录。这个思想可追溯到计算机科学家 Donald Knuth 1984 年提出的 文学编程(literate programming):程序面向读者叙事,而非只面向机器。notebook 是文学编程在数据科学领域最成功的落地形态。
安装与启动:一行命令开箱
安装与启动都极简(建议在前面创建的虚拟环境里执行):
pip install jupyterlab
jupyter lab
jupyterlab(JupyterLab)是 Jupyter 的新一代前端,比经典 jupyter notebook 功能更全(多标签、文件浏览器、终端)。执行 jupyter lab 后,终端会打印一屏启动日志,浏览器自动打开工作台主页;日志里那行 http://localhost:8888/lab 是本地服务地址——别关终端窗口,关闭它服务即停。想用经典界面则执行 jupyter notebook,二者共用同一套 .ipynb 文件。在主页左侧的文件浏览器里新建 Notebook(Python 3 内核),一个空 notebook 就绪。
单元格操作:编辑模式、命令模式与快捷键
notebook 的每个单元格有两种模式:编辑模式(可输入代码/文字,单元格边框为绿色)与命令模式(对整个单元格操作,边框为蓝色)。用 Enter 进入编辑模式、Esc 返回命令模式。所有高频操作都有快捷键,以下一组是 Jupyter 官方文档中确定存在的,值得先背熟:
- Shift+Enter:运行当前单元格并选中下一个(最常用);
- Ctrl+Enter:运行当前单元格但不移动;
- Alt+Enter:运行当前单元格并在下方新建一个;
- 命令模式下:A 在上方插入单元格,B 在下方插入;
- 命令模式下:dd 删除当前单元格;
- 命令模式下:m 把单元格切换为 Markdown(叙述),y 切回 Code(代码);
- 命令模式下:H 呼出完整的快捷键帮助面板。
单元格类型切换后别慌:Markdown 单元格里写的是渲染后的叙述文字,不是代码;按 m 的瞬间”代码变成了文字”是预期行为,Shift+Enter 运行它就会渲染出排版效果——标题用 #、列表用 -、行内代码用反引号、加粗与斜体两对标记。Markdown 还支持用美元符号包裹的 LaTeX 数学公式,例如 $E = mc^2$、$\bar{x} = \frac{1}{n}\sum_{i=1}^{n} x_i$ 会被 MathJax 渲染成排版公式——写数据分析结论时,这是给变量”上户口”的标准姿势。
魔法命令:notebook 里的增强指令
IPython 内核给交互环境增加了一类内置指令,叫魔法命令(magic command),以 % 开头的是行魔法(作用于本行),以 %% 开头的是单元魔法(作用于整个单元格)。它们是 IPython 的扩展能力,不属于 Python 语法——这一点在易错点里会再强调。以下四个是数据探索里最高频的,全部真实可用(示例输出为本文作者在 3.12 + IPython 9 上实际运行的结果):
%timeit 语句:反复执行一段代码,给出平均耗时与标准差,是性能直觉的来源:
%timeit sum(range(100))
真实输出(数值随机器而异,量级稳定):
590 ns ± 13.4 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each)
%%timeit:同一命令的单元版本,用作测试整个代码块:
%%timeit
total = sum(range(1000))
真实输出:10.1 μs ± 280 ns per loop (mean ± std. dev. of 7 runs, 100,000 loops each)——注意 %%timeit 会重复执行整个单元格,所以单元格里不要放有副作用的代码(例如写文件)。
!命令:把整行作为系统 shell 命令执行(Windows 上走 PowerShell/cmd,Linux/macOS 走 bash):
!echo hello-from-notebook
输出 hello-from-notebook。这对”做完分析顺手看一眼目录里有什么”极其顺手:!dir(Windows)或 !ls -l(Unix 风格)即可。
%pwd 与 %who:前者显示内核当前工作目录,后者列出当前命名空间里你自定义的变量名。这两个都是”你在哪、你有什么”的导航类命令,用于防止在长会话里迷失。
%who
真实输出:x y ——列出了本会话定义过的变量名(空格与制表符分隔)。此外还有 %load 文件(把文件内容读进单元格)与 %run 脚本(把脚本当作模块执行并保留其变量),第 9 篇提到过 VS Code 让代码流向 notebook,%load 则是反向流动:把已验证的 .py 脚本捞回 notebook 继续加工。
实战闭环:一次真实的成绩数据分析
现在把以上全部组装进一个小案例,它对应的 notebook 由四个单元格组成。第一步,生成模拟数据(用随机数与标准库统计模块,不依赖第三方库):
import random
random.seed(42)
scores = [random.randint(40, 100) for _ in range(30)]
第二步,计算集中趋势与离散程度——注意 statistics 是标准库,直接可用:
from statistics import mean, median, pstdev
mean(scores), median(scores), pstdev(scores)
第三步,写结论(Markdown 单元格):
# 成绩概览
这 30 个模拟成绩中,平均值约为 64.5 分,中位数为 62 分,
中位数略低于平均值,说明分布右偏(高分段尾巴更长);
总体标准差约 17.6 分,离散程度中等。
用公式表达:$\bar{x} \approx 64.5$,$s \approx 17.6$。
(以上数值来自 random.seed(42) 固定种子下的真实运行结果;想验证”种子决定数据”,把 seed 那一行删掉重跑整个文档,全部数值都会变。)
三个单元格运行下来,你会亲身体会 notebook 与脚本的核心差异:每个单元格都是快照可单独重跑——改一行、Shift+Enter、输出即时更新,叙述文字跟着改,整个过程不需要重跑其他单元格。这就是数据探索工作流的形态:快的反馈循环 + 活的文档。
再看一眼文档本质,验证”notebook 是 JSON”的说法。上面这个 notebook 保存后,用 Python 重新读入并检查结构(nbformat 是官方库,nbclient 能让代码在没有浏览器的情况下执行 notebook——它们也是我们验证本例输出真实的工具):
import nbformat
nb = nbformat.read("scores_demo.ipynb", as_version=4)
print([cell.cell_type for cell in nb.cells])
输出 ['code', 'code', 'markdown'],与手工构建的文档应和一致。在这个结构上还能做一件大事:无浏览器执行。用 nbclient 的 NotebookClient 对一个 notebook 调用 execute(),等价于”从头到尾按顺序运行一遍所有单元格”——这是可复现分析(reproducibility)的基石:任何拿到 .ipynb 的人,都能确定性地重现你的结果,而不是依赖你随手敲出的顺序。
分享与版本控制:nbconvert 与 diff 噪音
分析做完要交付,.ipynb 虽好,但并非所有场合都适合直接分发。nbconvert 是官方转换器,可把 notebook 导出为多种格式:
jupyter nbconvert --to script scores_demo.ipynb
jupyter nbconvert --to html scores_demo.ipynb
--to script 把每个单元格提取成一段 Python 脚本(单元格间用 # In[1]: 注释隔开),适合”把研究成果转成正式代码”;--to html 则是保留输出的静态网页,适合给不看代码的人。这两个命令真实可运行(本文验证时生成了 126 字节的脚本文件,内容与单元格一一对应)。
版本控制是另一个常见需求。.ipynb 是 JSON,每次运行都会更新 outputs 字段——于是提交一次代码改动,diff 里却塞满了输出内容的变化,这正是”notebook + Git 体验差”的根源。工程对策有两个:提交前用 jupyter nbconvert --to notebook --clear-output --inplace scores_demo.ipynb 清空输出(--clear-output 清空、--inplace 原地保存,都是真实选项);或者对比工具直接用社区标准 nbdime(专门面向 notebook 的 diff/merge 工具)。记住原则:输出是缓存,代码是真相——入库的是代码,输出留给本地。
易错点与陷阱
1. 执行顺序 ≠ 视觉顺序。 notebook 允许任意单元格在任何时刻被运行,于是一个”看起来自上而下”的文档,实际执行可能是 3→1→2。后果很隐蔽:单元格 3 依赖单元格 1 的变量,但单元格 2 又改了那个变量——程序不报错,结果却对不上视觉顺序。这是 notebook 最经典的研究可信度陷阱(不少论文复现事故源于此)。对策:分析告一段落,用菜单里的 “Restart Kernel and Run All Cells…” 从头到尾重跑一遍,能一次跑通才说明文档是自洽的;这是 JupyterLab 的真实菜单项,值得养成肌肉记忆。
2. 把魔法命令当 Python 语法。 %timeit、%%timeit、! 全部属于 IPython 内核的扩展,不是 Python 语言的一部分。把 %%timeit 原样复制进 .py 文件运行,立刻语法错误(% 在 Python 里是取模运算符,%%timeit 这种写法无法解析)——本文验证时 python magic_bad.py 直接报 SyntaxError。反过来,.py 文件里能写的东西 notebook 里都能写,但魔法命令只能活在 notebook(或 IPython 交互环境)里,交付正式代码时务必把它们替换为 timeit 模块等价的纯 Python 写法。
3. 关闭浏览器以为程序停了,内核却还在跑。 前端-内核架构的另一面:浏览器标签页关掉,内核进程不一定退出(长时间运算可能仍在后台继续),服务器列表里能看到这些”孤儿内核”。反之,重启内核(Kernel 菜单)会清空所有变量、导入与函数定义,之前依赖的”上次运行的隐式状态”全部失效。规矩:需要干净环境就重启内核;用完一次性的重型计算,主动用 Kernel 菜单关闭对应内核,别让机器背着无主进程。
4. 依赖全局变量而非显式参数。 因为变量跨单元格存活,很多分析代码养成了”直读上一个单元格的全局量”的写法。这在 notebook 里一时无碍,但同样的代码搬进 .py 或换台机器跑,立刻崩。工程化的好习惯:把关键分析封装成函数,参数显式传入;单元格里只放”读取数据 → 调用函数 → 展示结果”的薄壳。这与第 4 章函数与模块化的道理一脉相承,notebook 恰恰是最容易滑向”面条式全局量”的环境。
小结
Jupyter Notebook 是面向数据探索的交互式工作台:.ipynb 本质是 JSON 文档,按单元格组织代码、输出与叙述(文学编程思想的落地);运行遵循”前端-内核”架构,内核是长驻的 IPython 进程,变量跨单元格存活。本文覆盖了它的完整使用面:安装启动(pip install jupyterlab + jupyter lab)、单元格操作与快捷键(Shift+Enter 运行、A/B 增格、dd 删除、m/y 切换类型)、Markdown 与 MathJax 数学、魔法命令(%timeit、%%timeit、!、%pwd、%who),并用一个成绩分析的完整闭环演示了数据探索工作流;最后讨论了 nbconvert 导出、—clear-output 清缓存与四个高频陷阱(执行顺序≠视觉顺序、魔法命令出不了 notebook、孤儿内核、全局变量依赖)。至此,第一章的环境与工具链全部就绪:第 2 篇到第 10 篇,从装解释器到分析数据,你已经具备继续学习第二章”变量、类型与运算符”的全部前置条件。
练习与思考题
- 用
pip install jupyterlab启动 notebook,把本文”成绩数据分析”的四个单元格完整重做一遍,并回答:为什么random.seed(42)这个单元格必须在生成数据的单元格之前运行?(答案导向:seed 确定随机序列;顺序颠倒则数据不同,且”Restart and Run All”会暴露依赖顺序。) - 在 notebook 中分别执行
%timeit与%%timeit测同一段代码,两者耗时数量级有何差别?结合单元格重复执行机制解释原因。(答案导向:%%timeit把整个单元格放入计时循环重复执行;若单元格内有副作用则结果失真。) - 用
nbformat读取你刚才保存的 notebook,打印第一个代码单元格的source与outputs字段,观察”无执行计数”与”有执行计数”的差异,说出execution_count为None意味着什么。(答案导向:从未执行过该单元格。) - 综合应用题:把成绩分析写成”函数 + 数据读取 + 展示”的三段式单元格,然后用
jupyter nbconvert --to script导出脚本并直接python运行,体会 notebook 内运行与脚本运行的差异,并总结:什么情况下你会选择 notebook、什么情况下选择脚本?(答案导向:探索与叙述用 notebook,交付与自动化用脚本。)