Jupyter+git 问题现已解决
最初发布于 fast.ai 博客
Jupyter notebook 默认情况下无法很好地与 git 配合使用。借助 nbdev2,Jupyter+git 问题已经完全解决了。它提供了一系列钩子(hooks),可以生成干净的 git diff,自动解决大多数 git 冲突,并确保任何剩余的冲突都可以在标准的 Jupyter notebook 环境中完全解决。要开始使用,请按照Git 友好型 Jupyter 页面上的说明进行操作。
Jupyter+git 问题
Jupyter notebook 是科学家、工程师、技术作家、学生、教师等的强大工具。它们提供了一个理想的笔记本环境,用于交互式地探索数据和代码、编写程序,并将结果文档化为仪表板、书籍或博客。
但是,当与他人协作时,这个理想的环境就会变得面目全非。这是因为 git 等工具(异步协作最流行的方法)会让 notebook 文件变得不可用。字面意义上的不可用。如果您和同事都修改了同一个 notebook 单元格(在很多情况下,仅仅是执行一个单元格而没有更改其内容),然后尝试稍后打开该 notebook 文件,就会看到以下情况:
造成此问题的原因在于 Jupyter notebook 使用的格式(JSON)与 git 冲突标记默认假定的格式(纯文本行)之间存在根本性的不兼容。当 git 向 notebook 文件添加冲突标记时,就会出现以下情况:
"source": [
<<<<<< HEAD
"z=3\n",
======
"z=2\n",
>>>>>> a7ec1b0bfb8e23b05fd0a2e6cafcb41cd0fb1c35
"z"
]
那不是有效的 JSON,因此 Jupyter 无法打开它。冲突在 notebook 文件中尤其常见,因为每次运行 notebook 时,Jupyter 都会更改以下内容:
- 每个单元格都包含一个数字,指示其运行顺序。如果您和同事以不同的顺序运行单元格,每个单元格都会发生冲突!手动修复这将花费很长时间
- 对于每个图表,例如一个绘图,Jupyter 不仅会在 notebook 中包含图像本身,还会包含一个纯文本描述,其中包含对象的
id
(类似于内存地址),例如<matplotlib.axes._subplots.AxesSubplot at 0x7fbc113dbe90>
。每次执行 notebook 时,此内容都会更改,因此每当两个人执行此单元格时,都会产生冲突 - 某些输出可能是不确定的,例如使用随机数的 notebook,或者与提供随时间变化的输出的服务(例如天气服务)交互的 notebook
- Jupyter 会向 notebook 添加元数据,描述其上次运行的环境,例如内核的名称。这通常因安装而异,因此两个人保存同一个 notebook(即使没有其他更改)也经常会在元数据中产生冲突。
所有这些对 notebook 文件的更改还会使 notebook 的 git diff 变得非常冗长。这会使代码审查成为一项挑战,并使 git 仓库比必要时更庞大。
这些问题导致许多 Jupyter 用户觉得使用 notebook 协作是一种笨拙、容易出错且令人沮丧的体验。(我们甚至看到社交媒体上有人将 Jupyter 的 notebook 格式描述为“愚蠢”或“糟糕”,尽管他们表示喜欢该软件!)
然而事实证明,Jupyter 和 git 可以配合得非常好,完全没有上述问题。所需要的只是一点特殊的软件……
解决方案
Jupyter 和 git 都是设计精良的软件系统,提供了许多强大的可扩展机制。事实证明,我们可以利用这些机制来完全自动地解决 Jupyter+git 问题。我们在上一节中确定了两类问题
- git 冲突导致 notebook 文件损坏
- 由于元数据和输出导致的不必要的冲突。
在我们新发布的开源 Jupyter 开发平台 nbdev2 中,我们解决了上述每个问题
- 一个用于 git 的新合并驱动程序(merge driver)提供了“notebook 原生”的冲突标记,使 notebook 文件即使存在 git 冲突,也能直接在 Jupyter 中打开
- 一个用于 Jupyter 的新保存钩子(save hook)会自动移除所有不必要的元数据和非确定性的单元格输出。
使用 nbdev 的合并驱动程序时,冲突在 Jupyter 中的显示如下
如您所见,本地和远程更改在 notebook 中分别清楚地显示为单独的单元格,您可以简单地删除不需要的版本,或根据需要合并这两个单元格。
使合并驱动程序工作的技术非常迷人——让我们深入了解细节!
nbdev2 的 git 合并驱动程序
这里提供了 git 合并驱动程序的摘要——有关完整详情和源代码,请参阅nbdev.merge
文档。令人惊讶的是,整个实现只有 58 行代码!
基本思想是首先“撤销”产生冲突的原始 git 合并,然后在单元格级别(而不是行级别)“重做”它,并且仅查看单元格源代码(而非输出或元数据)。“撤销”过程很简单:只需创建冲突文件的两个副本(分别代表本地和远程版本),遍历每个 git 冲突标记,并用本地或远程版本的代码替换冲突部分。
现在我们有了原始的本地和远程 notebook 文件,我们可以使用execnb.nbio
加载 json,这将为每个 notebook 提供一个单元格数组。现在我们进入有趣的部分——仅基于单元格源代码创建单元格级别的 diff。
Python 标准库在 difflib
模块中包含一个非常灵活且有效的 diff 算法实现。特别是 SequenceMatcher
类为实现您自己的冲突解决系统提供了基本构建块。我们将两组单元格(远程和本地)传递给 SequenceMatcher(...).get_matching_blocks()
,它会返回一个列表,其中包含每个匹配的单元格部分(即 没有冲突/差异的部分)。然后我们可以遍历每个匹配的部分并将其复制到最终的 notebook 中,并遍历每个不匹配的部分,复制远程和本地的每个单元格(并在它们之间添加单元格来标记冲突)。
要使 SequenceMatcher
与 notebook 单元格(在 nbdev 中由 NbCell
类表示)一起工作,只需向 NbCell
添加 __hash__
和 __eq__
方法。在每种情况下,这些方法的定义都只查看实际的源代码,而不查看任何元数据或输出。因此,SequenceMatcher
将仅显示源代码中的差异,而忽略其他所有差异。
通过一行配置,我们可以要求 git 在合并更改时调用我们的 python 脚本,而不是其默认的基于行的实现。nbdev_install_hooks
会自动设置此配置,因此运行它之后,git 冲突变得不那么常见,并且永远不会导致 notebook 文件损坏。
nbdev2 的 Jupyter 保存钩子
在本地解决 git 合并非常有帮助,但我们也需要在远程解决它们。例如,如果贡献者提交了拉取请求(PR),而在该 PR 合并之前,其他人又提交了对同一 notebook 文件的更改,则该 PR 现在可能存在如下冲突
"outputs": [
{
<<<<<< HEAD
"execution_count": 7,
======
"execution_count": 5,
>>>>>> a7ec1b0bfb8e23b05fd0a2e6cafcb41cd0fb1c35
"metadata": {},
此冲突表明两位贡献者以不同的顺序运行了单元格(或者可能其中一位在 notebook 的上方添加了几个单元格),因此他们的提交具有冲突的执行计数(execution counts)。在修复此冲突之前,GitHub 将拒绝允许此 PR 合并。
但我们当然根本不在乎这种冲突——无论 notebook 中是否存储了执行计数,以及具体是哪个计数,都无关紧要。所以我们确实更倾向于完全忽略这种差异!
幸运的是,Jupyter 提供了一个“预保存”(pre-save)钩子,允许在每次保存 notebook 时运行代码。nbdev 利用此钩子来移除所有不必要的元数据(包括 execution_count
)。这意味着不会有像上面那样无意义的冲突,因为首先就没有提交会存储这些信息。
背景
在 fast.ai,我们一切都使用 Jupyter。我们所有库的所有测试、文档和模块源代码都完全在 notebook 中开发(当然是使用 nbdev!)而且我们也为所有库使用 git。我们的一些仓库有数百名贡献者。因此,解决 Jupyter+git 问题对我们至关重要。这里提出的解决方案是许多人多年工作的结果。
我们的第一个方法由 Stas Bekman 和我开发,是使用 git “smudge” 和 “clean” 过滤器,这些过滤器在提交时自动重写所有 notebook json,以移除不必要的元数据。这有所帮助,但 git 经常会进入一种奇怪的状态,导致无法合并。
在 nbdev v1 中,Sylvain Gugger 创建了一个名为 nbdev_fix_merge
的神奇工具,它使用非常巧妙的自定义逻辑来手动修复 notebook 文件中的合并冲突,以确保它们可以在 Jupyter 中打开。对于 nbdev v2,我对库的每个部分都进行了彻底重写,我意识到我们可以用上面描述的 SequenceMatcher
方法替换自定义逻辑。
Wasim Lorgat 意识到,通过将 smudge/clean 的逻辑移至 nbdev 保存钩子中,我们可以解决 smudge/clean 问题;而通过将手动修复的逻辑移至 git 合并驱动程序中,可以避免手动修复步骤。这解决了最后剩余的问题!(Wasim 在我们首次讨论这些突出问题后,在短短两天内就找到了解决所有问题的方法,这让我非常震惊……)
结果
nbdev2 中的新工具,我们在过去几个月一直在内部使用,彻底改变了我们的工作流程。Jupyter+git 问题已经完全解决了。我没有看到不必要的冲突,单元格级别的合并像魔法一样奏效,少数几次我和协作者修改了同一个单元格的源代码,在 Jupyter 中修复冲突也变得简单方便。
后记:其他 Jupyter+git 工具
ReviewNB
还有一个我们在将 Jupyter 与 git 结合使用时发现非常有用的工具,那就是 ReviewNB。ReviewNB 解决了使用 notebook 文件进行拉取请求(pull requests)的问题。GitHub 的代码审查 GUI 只对基于行的文件格式(例如纯 Python 脚本)效果很好。这对于 nbdev 导出的 Python 模块没有问题,我也经常直接在 Python 文件而不是源 notebook 文件上进行审查。
然而,很多时候我宁愿在源 notebook 文件上进行审查,因为
- 我想审查文档和测试,而不仅仅是实现代码
- 我想查看单元格输出的变化,例如图表和表格,而不仅仅是代码。
为此,ReviewNB 是完美的。就像 nbdev 使 git 合并和提交对 Jupyter 友好一样,ReviewNB 使代码审查对 Jupyter 友好。一张图片胜过千言万语,所以与其试图解释,我不如直接展示这张来自 ReviewNB 网站的图片,其中展示了他们的界面中 PR 的样子
另一种解决方案:Jupytext
解决 Jupyter+git 问题的另一个潜在解决方案是使用 Jupytext。Jupytext 以基于行的格式而非 JSON 格式保存 notebook 文件。这意味着所有常用的 git 机制,例如合并和 PR,都能正常工作。Jupytext 甚至可以使用 Quarto 的格式 qmd
作为保存 notebook 的格式,然后用于生成网站。
如果您想保存单元格输出(这通常是我想要的,因为我的许多 notebook 运行时间很长——例如训练深度学习模型),Jupytext 可能有点棘手。虽然 Jupytext 可以将输出保存到链接的 ipynb
文件中,但管理这种链接关系会变得复杂,最终又回到了 Jupyter+git 问题!如果您不需要保存输出,那么您可能会觉得 Jupytext 足够了——尽管您当然会错过 ReviewNB 的基于单元格的代码审查功能,而且您的用户在浏览 GitHub 时也无法正常阅读您的 notebook 文件。
nbdime
还有一个有趣的项目叫做 nbdime,它有自己的 git 驱动程序和过滤器。由于它们与 nbdev 并不是很兼容(部分原因在于它们以不同的方式解决了一些相同的问题),我没有怎么使用过它们,所以无法提供有根据的意见。不过,我有时会使用 nbdime 的 Jupyter 扩展,它提供了一个类似于 ReviewNB 的视图,但用于本地更改而非 PR。
如果您想自己尝试,请按照Git 友好型 Jupyter 页面上的说明开始操作。