Jupyter Notebooks unter Git

Probleme bei der Versionsverwaltung von Jupyter Notebooks

Es gibt mehrere Probleme, um Jupyter Notebooks mit Git zu verwalten:

  • Die Metadaten von Zellen der Jupyter Notebooks ändern sich auch, wenn keine inhaltlichen Änderungen an den Zellen vorgenommen wurden. Damit werden Git-Diffs unnötig kompliziert.

  • Die Zeilen, die Git bei Merge-Konflikten in die *.ipynb-Dateien schreibt, führen dazu, dass die Notebooks nicht mehr gültiges JSON sind und von Jupyter deswegen nicht geöffnet werden kann: ihr erhaltet dann beim Öffnen die Fehlermeldung Error loading notebook.

    Konflikte treten besonders häufig in Notebooks auf, da Jupyter bei jeder Ausführung eines Notizbuchs Folgendes ändert:

    • Jede Zelle enthält eine Nummer, die angibt, in welcher Reihenfolge sie ausgeführt wurde. Wenn Team-Mitglieder die Zellen in unterschiedlicher Reihenfolge ausführen, hat jede einzelne Zelle einen Konflikt! Dies manuell zu beheben, würde sehr lange dauern.

    • Für jede Abbildung, z.B. einen Plot, nimmt Jupyter nicht nur das Bild selbst in das Notizbuch auf, sondern auch eine einfache Textbeschreibung, die die ID des Objekts enthält, z.B. <matplotlib.axes._subplots.AxesSubplot at 0x7fbc113dbe90>. Dies ändert sich jedes Mal, wenn ihr ein Notizbuch ausführt, und führt daher jedes Mal zu einem Konflikt, wenn zwei Personen diese Zelle ausführen.

    • Einige Ausgaben können nicht-deterministisch sein, z.B. ein Notebook, das Zufallszahlen verwendet oder mit einem Dienst interagiert, der im Laufe der Zeit unterschiedliche Ausgaben liefert.

    • Jupyter fügt dem Notizbuch Metadaten hinzu, die die Umgebung beschreiben, in der es zuletzt ausgeführt wurde, wie z.B. den Namen des Kernels. Dies variiert oft zwischen verschiedenen Installationen, und daher werden zwei Personen, die ein Notizbuch speichern (auch ohne andere Änderungen), oft einen Konflikt in den Metadaten haben.

nbdev2

nbdev2 bietet eine Reihe von Git-Hooks, die saubere Git-Diffs bereitstellen, die die meisten Git-Konflikte automatisch lösen und sicherstellen, dass alle verbleibenden Konflikte vollständig innerhalb der Standard-Jupyter-Notebook-Umgebung aufgelöst werden können:

  • Ein neuer git merge-Treiber bietet notebook-native Konfliktmarkierungen, die dazu führen, dass Notebooks direkt in Jupyter geöffnet werden können, auch wenn es Git-Konflikte gibt. Lokale und entfernte Änderung werden jeweils als separate Zellen im Notizbuch angezeigt, so dass ihr die Version, die ihr nicht behalten möchtet, einfach löschen oder die beiden Zellen nach Bedarf kombinieren könnt.

    Siehe auch

    nbdev.merge docs

  • Git-Merges lokal zu lösen ist äußerst hilfreich, aber wir müssen sie auch Remote lösen. Wenn z.B. ein Merge-Request eingereicht wird und dann jemand anderes dasselbe Notebook überträgt, bevor der Merge-Request zusammengeführt wird, könnte dieser einen Konflikt hervorrufen:

       "outputs": [
        {
    <<<<<< HEAD
         "execution_count": 8,
    ======
         "execution_count": 5,
    >>>>>> 83e94d58314ea43ccd136e6d53b8989ccf9aab1b
         "metadata": {},
    

    Der save hook von nbdev2 entfernt automatisch alle unnötigen Metadaten (einschließlich execution_count) und nicht-deterministischen Zellausgaben; d.h., dass es keine sinnlosen Konflikte wie den obigen gibt, da diese Informationen gar nicht erst in den Commits gespeichert werden.

Um loszulegen, folgt den Anweisungen in Git-Friendly Jupyter.

jq

Im Notebook-Dateiformat nbformat können auch die Ergebnisse der Berechnungen gespeichert werden. Dies können auch Base-64-codierte Blobs für Bilder und andere Binärdaten sein, die üblicherweise nicht in eine Versionsverwaltung übernommen werden sollen. Diese können zwar manuell entfernt werden mit Cell ‣ All Output ‣ Clear, ihr müsst diese Schritte jedoch vor jedem git add ausführen, und es löst auch eine zweite Ursache für das Rauschen in git diff nicht, nämlich dasjenige in den Metadaten.

Um nun systematisch vergleichbare Versionen von Notebooks in der Versionsverwaltung zu erhalten, können wir jq verwenden, einen leichtgewichtigen JSON-Prozessor. Zwar benötigt man einige Zeit um jq einzurichten da es eine eigene Abfrage-/Filtersprache mitbringt, aber meist sind schon die Standardeinstellungen gut gewählt.

Installation

jq kann installiert werden mit:

$ sudo apt install jq
$ brew install jq

Beispiel

Ein typischer Aufruf ist:

jq --indent 1  \
  '(.cells [] | select (has ("output")) | .outputs) = []
  | (.cells [] | select (has ("execution_count")) | .execution_count) = null
  | .metadata = {"language_info": {"name": "python", "pygments_lexer": "ipython3"}}
  | .Cells []. Metadaten = {}
  '  example.ipynb

Jede Zeile innerhalb der einfachen Anführungszeichen definiert einen Filter – die erste wählt alle Einträge aus der Liste cells aus und löscht die Ausgaben. Der nächste Eintrag setzt alle Ausgaben zurück. Der dritte Schritt löscht die Metadaten des Notebooks und ersetzt sie durch ein Minimum an erforderlichen Informationen, damit das Notebook noch ohne Beanstandungen ausgeführt werden kann, folgendes eingeben:wenn es mit nbsphinx formatiert sind. Die vierte Filterzeile, .cells []. metadata = {}, löscht alle Metainformationen. Falls ihr bestimmte Metainformationen beibehalten wollt, könnt ihr dies hier angeben.

Einrichten

  1. Um euch die Arbeit zu erleichtern, könnt ihr einen Alias in der ~/.bashrc-Datei anlegen:

    alias nbstrip_jq="jq --indent 1 \
        '(.cells[] | select(has(\"outputs\")) | .outputs) = []  \
        | (.cells[] | select(has(\"execution_count\")) | .execution_count) = null  \
        | .metadata = {\"language_info\": {\"name\": \"python\", \"pygments_lexer\": \"ipython3\"}} \
        | .cells[].metadata = {} \
        '"
    
  2. Anschließend könnt ihr bequem im Terminal folgendes eingeben:

    $ nbstrip_jq example.ipynb > stripped.ipynb
    
  3. Wenn ihr von einem bereits vorhandenen Notebook ausgeht, solltet ihr zunächst einen filter-Commit hinzufügen, indem ihr einfach die neu gefilterte Version eures Notebooks ohne die unerwünschten Metadaten einlest. Nachdem ihr mit git add das Notebook hinzugefügt habt, könnt ihr mit git diff --cached schauen, ob der Filter auch wirklich gewirkt hat bevor ihr dann git commit -m 'filter' angebt.

  4. Wenn ihr diesen Filter für alle Git-Repositories verwenden wollt, könnt ihr euer Git auch global konfigurieren:

    1. Zunächst fügt ihr in ~/.gitconfig folgendes hinzu:

      [core]
      attributesfile = ~/.gitattributes
      
      [filter "nbstrip_jq"]
      clean = "jq --indent 1 \
              '(.cells[] | select(has(\"outputs\")) | .outputs) = []  \
              | (.cells[] | select(has(\"execution_count\")) | .execution_count) = null  \
              | .metadata = {\"language_info\": {\"name\": \"python\", \"pygments_lexer\": \"ipython3\"}} \
              | .cells[].metadata = {} \
              '"
      smudge = cat
      required = true
      
      clean

      wird beim Hinzufügen von Änderungen in den Bühnenbereich angewendet.

      smudge

      wird beim Zurücksetzen des Arbeitsbereichs durch Änderungen aus dem Bühnenbereich angewendet.

    2. Anschließend müsst ihr in ~/.gitattributes nur noch folgendes angeben:

      *.ipynb filter=nbstrip_jq
      
  5. Wenn ihr anschließend mit git add euer Notebook in den Bühnenbereich übernehmt, wird der nbstrip_jq-Filter angewendet.

    Bemerkung

    git diff zeigt euch jedoch keine Änderungen zwischen Arbeits- und Bühnenbereich an. Lediglich mit git diff --staged könnt ihr erkennen, dass nur die gefilterten Änderungen übernommen wurden.

    Warnung

    clean und smudge-Filter spielen oft nicht gut mit git rebase über solche gefilterten Commits hinweg zusammen. Dann solltet ihr vor dem Rebase diese Filter deaktivieren.

  6. Und es gibt noch ein weiteres Problem: Wenn ein solches Notebook erneut ausgeführt wird, zeigt zwar git diff keine Änderungen an, git status jedoch schon. Daher sollte in der ~/.bashrc-Datei folgendes eingetragen sein um schnell das jeweilige Arbeitsverzeichnis reinigen zu können:

    function nbstrip_all_cwd {
        for nbfile in *.ipynb; do
            echo "$( nbstrip_jq $nbfile )" > $nbfile
        done
        unset nbfile
    }
    

ReviewNB

ReviewNB löst das Problem, Merge-Requests mit Notebooks durchzuführen. Die Code-Review-GUI von GitLab funktioniert nur bei zeilenbasierten Dateiformaten, wie z.B. Python-Skripten. Meistens bevorzuge ich jedoch, die Quelltext-Notebooks zu prüfen, weil:

  • ich die Dokumentation und die Tests überprüfen möchte, nicht nur die Implementierung

  • ich die Änderungen an den Zellausgaben sehen möchte, wie Diagrammen und Tabellen, nicht nur den Code.

Für diesen Zweck ist ReviewNB perfekt.

nbdime

nbdime ist ein GUI für nbformat-Diffs und ersetzt nbdiff. Es versucht lokal Content-Aware-Diffing sowie das Merging von Notebooks, beschränkt sich nicht nur auf die Darstellung von Diffs, sondern verhindert auch, dass unnötige Änderungen eingecheckt werden. Es ist jedoch nicht kompatibel mit nbdev2.

nbstripout

nbstripout automatisiert Clear all outputs. Es nutzt auch nbformat und ein paar Automagien um git config einzurichten. Meines Erachtens hat es jedoch zwei Nachteile:

  • es beschränkt sich auf den problematischen Metadaten-Abschnitt

  • es ist langsam.