Kampf den schlechten Programmiergewohnheiten

Code in Machine-Learning-Modellen: Komplexität vermeiden

David Tan

© Shutterstock / sdecoret

Es ist bekannt, dass der Code für Machine Learning (ML) schnell unordentlich werden kann. Mit welchen Techniken lassen sich schlechte Programmiergewohnheiten vermeiden, die den Code unnötig kompliziert machen? Und welche Methoden erleichtern den Umgang mit der Komplexität?

Code, mit dem ML-Modelle trainiert werden sollen, wird normalerweise in Jupyter Notebooks geschrieben. Er steckt zum einen voller nebensächlicher Elemente (z. B. Druckanweisungen, gut lesbare DataFrames und Datenvisualisierungen), zum anderen voller Glue Code ohne Abstraktionen, Modularisierung und automatisierte Tests. Bei Notebooks, die Menschen den Machine-Learning-Prozess näherbringen sollen, mag das akzeptabel sein. In einem echten Projekt führt es jedoch zu einem Chaos, das niemand mehr überblicken kann. Schlechte Programmiergewohnheiten resultieren in schwer verständlichem Code, dessen Änderung mühsam und fehleranfällig ist. Für Data Scientists und EntwicklerInnen wird es dadurch immer schwieriger, ihre ML-Lösungen weiterzuentwickeln.

Wodurch wird Code kompliziert?

Eine der wichtigsten Techniken zur Bewältigung von Softwarekomplexität besteht darin, die Systeme so zu gestalten, dass die Entwickler immer nur mit einem kleinen Teil des Ganzen arbeiten müssen.

John Ousterhout, Erfinder der Open-Source Schriftsprache Tcl.

Bevor wir etwas gegen die Komplexität unternehmen können, müssen wir sie erst einmal näher betrachten. Komplex ist etwas, das aus mehreren miteinander verbundenen Teilen besteht. Mit jedem beweglichen Teil, das wir ergänzen, machen wir den Code komplexer und müssen ein weiteres Element im Auge behalten. Zwar können wir der grundlegenden Komplexität eines Problems nicht entgehen – und sollten es auch gar nicht versuchen. Jedoch machen wir die Dinge oft unnötig kompliziert und erhöhen die kognitive Belastung, zum Beispiel mit den folgenden schlechten Angewohnheiten:

  • Fehlende Abstraktionen: Wenn wir den gesamten Code in ein einziges Python-Notebook oder -Skript schreiben, ohne ihn in Funktionen oder Klassen zu abstrahieren, zwingen wir die Leserin oder den Leser, viele Zeilen Code zu lesen, um das „Wie“ zu verstehen und herauszufinden, wozu der Code dient.
  • Lange Funktionen, die mehrere Dinge erledigen: Dies zwingt uns, Zwischenzustände von Datentransformationen im Kopf zu behalten, während wir schon an einem anderen Teil der Funktion arbeiten.
  • Verzicht auf Unit-Tests: Beim Refactoring können wir nur durch den Neustart des Kernels und die Ausführung des/der gesamten Notebooks überprüfen, ob alles noch funktioniert. Wir müssen uns mit der ganzen Komplexität der Codebasis auseinandersetzen, selbst wenn wir nur an einem kleinen Teil davon arbeiten möchten.

Komplexität ist unvermeidlich, aber sie lässt sich aufgliedern. Wenn wir in unserem Zuhause nicht genau überlegen, was sinnvollerweise wo seinen Platz hat, entsteht Unordnung, und eine eigentlich einfache Aufgabe (z. B. das Auffinden eines Schlüssels) kostet unnötig Zeit und Nerven. So ist es auch mit unserer Codebasis. Für Datenbereinigung, Feature Engineering, Fehlerkorrekturen, Verarbeitung neuer Daten usw. wird laufend neuer Code hinzugefügt. Wenn wir unsere Codebasis nicht sorgsam pflegen und ein regelmäßiges Refactoring vornehmen (wofür wir allerdings Unittests brauchen), sind Unordnung und Komplexität garantiert.

Im Folgenden stellen wir nicht nur häufige schlechte Gewohnheiten vor, die die Komplexität erhöhen, sondern auch gute Gewohnheiten, die den Umgang mit Komplexität erleichtern:

  • ​Ordnung im Code halten
  • Funktionen nutzen, um Komplexität durch Abstraktion zu reduzieren
  • Code möglichst schnell aus Jupyter-Notebooks verschieben
  • Testgetriebene Entwicklung verwenden
  • Kleine und häufige Commits vornehmen

Gewohnheiten zur Verringerung der Komplexität

Ordnung im Code halten

Unordentlicher Code ist kompliziert, weil er sich nur schwer verstehen und ändern lässt. Das Ändern von Code als Reaktion auf Geschäftsanforderungen wird dadurch immer schwieriger und manchmal sogar unmöglich.

Eine dieser schlechten Programmiergewohnheiten (auch „Code Smell“ genannt) ist toter Code. Dabei handelt es sich um Code, der ausgeführt wird, dessen Ergebnis aber in keiner weiteren Berechnung verwendet wird. Toter Code ist eine weitere überflüssige Sache, die EntwicklerInnen beim Programmieren im Kopf behalten müssen. Zur Veranschaulichung zwei Codebeispiele:

# bad example

df = get_data()

print(df)

# do_other_stuff()

# do_some_more_stuff()

df.head()

print(df.columns)

# do_so_much_stuff()

model = train_model(df)
# good example

df    = get_data()

model = train_model(df)

Vorgehensweisen für sauberen Code wurden bereits ausführlich für mehrere Sprachen beschrieben, einschließlich Python. Wir haben diese Grundsätze für „Clean Code“ angepasst. Sie finden sich in diesem clean-code-ml-Repository:

  • Design (Code-Beispiele)
    • Interna nicht offenlegen (Details zur Implementierung verbergen)
  • Überflüssiges (Code-Beispiele)
    • Toten Code entfernen
    • Print-Anweisungen vermeiden – auch geschönte Print-Anweisungen, wie head(), df.describe() und df.plot()
  • Variablen (Code-Beispiele)
    • Namen der Variablen sollten Zweck anzeigen
  • Funktionen (Code-Beispiele)
    • DRY-Prinzip („Don’t repeat yourself“) mithilfe von Funktionen anwenden
    • Funktionen sollten eine einzige Aufgabe erledigen

Funktionen nutzen, um Komplexität durch Abstraktion zu reduzieren

Funktionen vereinfachen unseren Code, indem sie komplizierte Umsetzungsdetails in einer Abstraktion zusammenfassen und durch eine einfachere Darstellung ersetzen – ihren Namen.

 Hierzu ein Beispiel: Sie sind in einem Restaurant und bekommen eine Speisekarte. Anstatt der Namen der Gerichte finden Sie in der Speisekarte das Rezept für jedes Gericht. Eines der Gerichte beispielsweise lautet wie folgt:

Schritt 1. In einem großen Topf Öl erhitzen. Karotten, Zwiebeln und Sellerie dazugeben und so lange rühren, bis die Zwiebel weich ist. Kräuter und Knoblauch hinzufügen und ein paar Minuten weiter kochen.

Schritt 2. Linsen, Tomaten und Wasser dazugeben. Die Suppe aufkochen und dann bei geringerer Hitze 30 Minuten köcheln lassen. Spinat hinzufügen und weiter kochen, bis der Spinat weich ist. Zum Schluss mit Essig, Salz und Pfeffer abschmecken.

Es wäre einfacher gewesen, wenn in der Speisekarte diese ganzen Schritte (also die Umsetzungsdetails) verborgen geblieben wären und wir stattdessen den Namen des Gerichts erfahren hätten. (Hier ging es übrigens um Linsensuppe.)

 Zur Veranschaulichung hier ein Codebeispiel aus einem Notebook im Titanic-Wettbewerb von Kaggle – vor und nach dem Refactoring.

# bad example
pd.qcut(df['Fare'], q=4, retbins=True)[1] # returns array([0., 7.8958, 14.4542, 31.275, 512.3292])

df.loc[ df['Fare'] <= 7.90, 'Fare'] = 0 df.loc[(df['Fare'] > 7.90) & (df['Fare'] <= 14.454), 'Fare'] = 1 df.loc[(df['Fare'] > 14.454) & (df['Fare'] <= 31), 'Fare'] = 2 df.loc[ df['Fare'] > 31, 'Fare'] = 3
df['Fare'] = df['Fare'].astype(int)
df['FareBand'] = df['Fare']
# good example (after refactoring into functions)
df['FareBand'] = categorize_column(df['Fare'], num_bins=4)

Was haben wir durch die Abstraktion in Funktionen und die damit geringere Komplexität erreicht?

  • Lesbarkeit. Anhand der Schnittstelle (also categorize_column()) lässt sich erkennen, welche Aufgabe die Funktion hat. Es ist nicht nötig, jede Zeile zu lesen oder im Internet Dinge zu recherchieren (z. B. qcut). Wer anhand des Namens und der Verwendung noch nicht verstanden hat, wozu die Funktion dient, kann sich die Unittests oder die Definition der Funktion ansehen.
  • Da es sich nun um eine Funktion handelt, können wir leicht einen Unittest dafür schreiben. Wenn wir versehentlich das Verhalten der Funktion ändern, scheitern die Unittests, und wir erhalten innerhalb von Millisekunden eine Rückmeldung.
  • Um an einer beliebigen Spalte dieselbe Transformation vorzunehmen (z. B. „Alter“ oder „Einkommen“), brauchen wir nur jeweils eine Zeile Code anstatt mehrerer.

Durch das Refactoring in Funktionen kann das gesamte Notebook einfacher und eleganter gestaltet werden:

# bad example
See notebook

# good example
df = impute_nans(df, categorical_columns=['Embarked'],
                     Continuous_columns =['Fare', 'Age'])
df = add_derived_title(df)
df = encode_title(df)
df = add_is_alone_column(df)
df = add_categorical_columns(df)
X, y = split_features_and_labels(df)
# an even better example. Notice how this reads like a story
prepare_data = compose(impute_nans, 
                       add_derived_title, 
                       encode_title, 
                       add_is_alone_column, 
                       add_categorical_columns,
                       split_features_and_labels)

X, y = prepare_data(df)

Die mentale Last ist nun deutlich geringer. Wir müssen nicht mehr zeilenweise Umsetzungsdetails lesen, um den gesamten Fluss zu verstehen. Stattdessen wird die Komplexität durch Abstraktionen (d. h. Funktionen) verringert: Wir erfahren, was die Funktionen tun, müssen uns aber nicht die Mühe machen, herauszufinden, wie sie es tun.

Code möglichst schnell aus Jupyter-Notebooks verschieben

Im Bereich Innenausstattung gibt es ein Konzept – das Gesetz der ebenen Flächen („Law of Flat Surfaces“) –, nach dem jede ebene Fläche in einem Zuhause Unordnung anzieht. Jupyter-Notebooks sind die ebenen Flächen der ML-Welt.

Natürlich eignen sich Jupyter-Notebooks hervorragend für schnelle Prototypen. Wir neigen allerdings dazu, viele Dinge hineinzuschreiben: Glue Code, Print-Anweisungen, geschönte Print-Anweisungen – df.describe() oder df.plot() –, nicht genutzte Import-Anweisungen und sogar Stack-Traces. Trotz bester Absichten gilt: Solange es die Notebooks gibt, kann leicht Unordnung entstehen.

Notebooks sind nützlich, weil sie uns schnelles Feedback geben. Und genau darauf kommt es häufig an, wenn wir an einem neuen Datensatz und einem neuen Problem arbeiten. Aber: Je länger die Notebooks werden, desto schwieriger wird es, Feedback darüber zu erhalten, ob unsere Änderungen erfolgreich waren.

Extrahieren wir unseren Code hingegen in Funktionen und Python-Module, erhalten wir durch Unittests innerhalb von Sekunden Rückmeldung zu unseren Änderungen, selbst wenn es sich um Tausende von Funktionen handelt.

Abbildung 1: Je größer die Menge an Code, desto schwerer können Notebooks uns schnelles Feedback darüber geben, ob alles erwartungsgemäß funktioniert.

Deswegen ist unser Ziel, den Code so früh wie möglich von den Notebooks in Python-Module und -Pakete zu überführen. So bleiben sie innerhalb der sicheren Grenzen von Unittests und deren Funktionsdomänen. Dank der Struktur für die logische Organisation von Code und Tests lässt sich Komplexität besser bewältigen, und es wird leichter, unsere ML-Lösung weiterzuentwickeln. 

Wie lässt sich Code aus Jupyter-Notebooks verschieben? Liegt der Code bereits in einem Jupyter-Notebook vor, empfiehlt sich folgende Vorgehensweise:

Die Einzelheiten zu jedem Prozessschritt (z. B. die Ausführung von Tests im Beobachtungsmodus) lassen sich dem clean-code-ml-Repository entnehmen.

Testgetriebene Entwicklung verwenden

Bisher ging es um die Durchführung von Tests, nachdem der Code bereits in das Notebook geschrieben wurde. Das ist zwar nicht ideal, aber immer noch deutlich besser als gar keine Unittests zu haben.

Es gibt einen Mythos, der besagt, testgetriebene Entwicklung (TDD) ließe sich nicht auf Projekte im Bereich Machine Learning anwenden. Wir halten das schlichtweg für falsch. Bei einem ML-Projekt dreht es sich beim Großteil des Codes um Datentransformationen (z. B. Datenbereinigung und Feature Engineering) und nur bei einem kleinen Teil der Codebasis um echtes Machine Learning. Solche Datentransformationen können als reine Funktionen geschrieben werden, die bei gleicher Eingabe die gleiche Ausgabe produzieren. Wir können daher TDD anwenden und ihre Vorteile nutzen. So kann uns TDD beispielsweise helfen, große und komplexe Datentransformationen in kleine, handhabbare Probleme aufzuteilen, die wir nacheinander verarbeiten können.

Ob der eigentliche ML-Teil des Codes funktioniert wie erwartet, können wir herausfinden, indem wir Funktionstests schreiben und prüfen, ob die Metriken des Modells (Accuracy, Precision usw.) über unserem erwarteten Schwellenwert liegen. Mit anderen Worten: Diese Tests belegen, dass das Modell erwartungsgemäß funktioniert. Hier ein Beispiel für einen solchen Test:

import unittest 
from sklearn.metrics import precision_score, recall_score

from src.train import prepare_data_and_train_model

class TestModelMetrics(unittest.TestCase):
    def test_model_precision_score_should_be_above_threshold(self):
        model, X_test, Y_test = prepare_data_and_train_model()
        Y_pred = model.predict(X_test)

        precision = precision_score(Y_test, Y_pred)

        self.assertGreaterEqual(precision, 0.7)

Kleine und häufige Commits vornehmen

Ohne kleine und häufige Commits erhöhen wir die mentale Belastung. Während wir an einem bestimmten Problem arbeiten, wird bei Änderungen für frühere Probleme immer noch angezeigt, dass kein Commit stattgefunden hat. Das lenkt nicht nur unseren Blick ab, sondern auch unser Unterbewusstsein. Es erschwert die Konzentration auf das aktuelle Problem. 

Betrachten Sie die folgenden zwei Bilder. Können Sie feststellen, an welcher Funktion wir gerade arbeiten? Bei welchem Bild fällt es Ihnen leichter?

Abbildung 3: Selbsttest zur Übersichtlichkeit.

Abbildung 4: Selbsttest zur Übersichtlichkeit.

Kleine und häufige Commits bringen mehrere Vorteile mit sich:

  • Es gibt weniger visuelle Ablenkungen und kognitive Last.
  • Wenn ein Commit stattgefunden hat, müssen wir nicht mehr befürchten, funktionierenden Code versehentlich zu zerstören.
  • Neben rot-grün-Refactoring haben wir auch die Möglichkeit zu Rot-rot-rot-rückgängig machen. Wenn wir versehentlich etwas kaputt machen, können wir einfach auf den letzten Commit zurückgreifen und einen neuen Anlauf nehmen. So verschwenden wir keine Zeit damit, Probleme rückgängig zu machen, die bei dem Versuch entstanden sind, das eigentliche Problem zu beheben.

Wie klein sollte so ein Commit sein? Einen Commit sollte man immer dann vornehmen, wenn eine einzelne Gruppe logisch verknüpfter Änderungen und Testdurchläufe vorliegt. Dabei sollte man nach dem Wörtchen „und“ in der Commit-Nachricht Ausschau halten, z. B. „Explorative Datenanalyse hinzufügen und Sätze in Tokens aufsplitten und Muster-Trainingscode restrukturieren“. Diese drei Änderungen sollten in drei logische Commits aufgeteilt werden. In dieser Situation können Sie git add –patch nutzen, um Code für den Commit in kleinere Portionen aufzusplitten.

Fazit

Ich bin kein hervorragender Programmierer. Nur ein guter Programmierer mit hervorragenden Gewohnheiten.

Kent Beck, Pionier im Bereich Extreme Programming und xUnit-Test-Frameworks

Diese guten Gewohnheiten helfen uns dabei, die Komplexität in Machine-Learning- und Data-Science-Projekten unter Kontrolle zu halten. Dies wiederum führt zu agileren und produktiveren Datenprojekten.

Weitere Informationen darüber, wie Unternehmen Machine Learning dank Continuous-Delivery-Methoden agiler machen, finden sich hier und hier.

Geschrieben von
David Tan

David has been with ThoughtWorks for 2 years, and was working in the government sector in a non-technical role before he decided to embark on a career in software engineering. Over the last two years, he has worked on several machine learning side projects on tasks such as stock market price prediction, fraud protection, and beer quantity image recognition. He is also a trainer for the ThoughtWorks JumpStart! program.

David is passionate about agile software development and knowledge sharing. During his free time he enjoys spending time with his family as a new dad.

Kommentare

Hinterlasse einen Kommentar

avatar
4000
  Subscribe  
Benachrichtige mich zu: