Jupyter+git 问题现已解决

以前,在 Jupyter 中使用 git 可能会导致冲突并损坏 notebook 文件。使用 nbdev2 后,这个问题已经完全解决了。
作者

Jeremy Howard

发布日期

August 25, 2022

最初发布于 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 文件造成什么影响

造成此问题的原因在于 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 问题。我们在上一节中确定了两类问题

  1. git 冲突导致 notebook 文件损坏
  2. 由于元数据和输出导致的不必要的冲突。

在我们新发布的开源 Jupyter 开发平台 nbdev2 中,我们解决了上述每个问题

  1. 一个用于 git 的新合并驱动程序(merge driver)提供了“notebook 原生”的冲突标记,使 notebook 文件即使存在 git 冲突,也能直接在 Jupyter 中打开
  2. 一个用于 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 页面上的说明开始操作。