Nicht nur mit Python

Deep Learning: Training von TensorFlow-Modellen mit JVM-Sprachen

Christoph Henkelmann

© Shutterstock / Rawpixel.com

Zwar gibt es mit Frameworks wie DL4J mächtige und umfangreiche Machine-Learning-Lösungen für die JVM, dennoch kann es in der Praxis vorkommen, dass der Einsatz von TensorFlow notwendig wird. Das kann beispielsweise der Fall sein, wenn es einen bestimmten Algorithmus nur in einer TensorFlow-Implementierung gibt und der Portierungsaufwand in ein anderes Framework zu hoch ist. Zwar interagiert man mit TensorFlow über ein Python API, die zugrunde liegende Engine jedoch ist in C++ geschrieben. Mit Hilfe der TensorFlow-Java-Wrapper-Bibliothek kann man deshalb sowohl Training als auch Inferenz von TensorFlow-Modellen aus der JVM heraus betreiben, ohne auf Python angewiesen zu sein. So können bestehende Schnittstellen, Datenquellen und Infrastruktur mit TensorFlow integriert werden, ohne die JVM zu verlassen.

KI und Deep Learning sind immer noch in aller Munde und trotz einiger erster Rückschläge, beispielsweise im Bereich selbstfahrender Autos, ist das Potenzial von Deep Learning noch lange nicht ausgeschöpft. Auch gibt es noch viele Bereiche der IT, in denen das Thema gerade erst richtig Fahrt aufnimmt. Daher ist es besonders wichtig zu schauen, wie man Deep-Learning-Systeme auf der JVM realisieren kann, denn Java (sowohl die Sprache als auch die Plattform) sind immer noch dominierende Technologien im Enterprise-Bereich.

TensorFlow ist eins der wichtigsten Frameworks im Deep-Learning-Bereich und trotz der steigenden Popularität von Keras noch immer nicht wegzudenken, insbesondere da KI-Platzhirsch Google die Entwicklung weiter vorantreibt. In diesem Artikel wird gezeigt, wie TensorFlow aus einer JVM heraus sowohl für das Training von TensorFlow-Modellen als auch für die Inferenz genutzt werden kann.

Wofür ist die Kombination aus TensorFlow und JVM geeignet?

Möchte man auf der JVM Deep Learning betreiben, ist normalerweise DL4J das Mittel der Wahl, da es als einziges professionelles Deep Learning Framework wirklich auf der JVM zu Hause ist. TensorFlow wird hauptsächlich – wie viele Machine Learning Frameworks – mit Python genutzt. Es gibt jedoch Gründe, TensorFlow aus einem JVM-Kontext heraus zu nutzen:

  • Man möchte ein Verfahren nutzen, für das es bei TensorFlow eine Implementierung gibt, aber nicht bei DL4J, und der Portierungsaufwand ist zu hoch.
  • Man arbeitet mit einem Data-Science-Team, das gewohnt ist, mit TensorFlow und Python zu arbeiten, aber die Zielinfrastruktur läuft auf der JVM.
  • Die für das Training notwendigen Daten liegen in einer Java-Infrastruktur (Datenbanken, eigene Dateiformate, APIs), und um an die Daten zu gelangen, müsste existierender Schnittstellencode von Java nach Python portiert werden.

Die JVM-TensorFlow-Kombination ist also immer dann sinnvoll, wenn eine existierende Java-Umgebung vorhanden ist und aus personellen oder projekttechnischen Gründen trotzdem TensorFlow für Deep Learning genutzt werden sollte (Kasten: „TensorFlow und JVM – immer eine gute Idee?“).

TensorFlow und JVM – immer eine gute Idee?

Auch wenn es gute Gründe für diese Kombination geben kann, ist es auch wichtig, anzusprechen, was dagegensprechen kann. Insbesondere die Wahl von TensorFlow sollte gut bedacht sein:

  • TensorFlow ist kein geeignetes Framework für Deep- oder Machine-Learning-Anfänger.
  • TensorFlow ist nicht benutzerfreundlich: Das API ändert sich schnell, oft ist in dem Wust von Anleitungen nicht klar, welcher Weg der geeignete ist.
  • TensorFlow ist nicht besser, nur weil es von Google ist: Deep Learning ist Mathematik, und die ist für alle gleich. TensorFlow erzeugt keine „schlaueren“ KIs als andere Frameworks. Auch ist es nicht schneller als die Alternativen (allerdings auch nicht „dümmer“ oder langsamer).

Möchte man in Deep Learning einsteigen und auf der JVM bleiben, ist der Einsatz von DL4J absolut zu empfehlen. Gerade für professionelle Enterprise-Projekte ist DL4J eine gute Wahl. Aber auch wenn man sozusagen über den Zaun blicken und ein wenig Python ausprobieren möchte, lohnt es sich, nach TensorFlow-Alternativen zu schauen. Hier ist man aktuell bei Keras besser aufgehoben, einem wesentlich angenehmeren API sei Dank.

Wie funktioniert TensorFlow?

Bevor man ein neues Framework einsetzt, ist es wichtig, sich ein wenig damit auseinanderzusetzen, was unter der Haube passiert (Kasten: „TensorFlow Begriffs-Cheat-Sheet“). Bei TensorFlow denkt man zunächst einmal an KI und neuronale Netze, aber technisch gesehen handelt es sich vor allem um ein Framework, das komplexe, sich immer wieder wiederholende parallele Berechnungen auf Tensoren ausführen kann – und das, wenn möglich, GPU-beschleunigt. Auch wenn Deep Learning das Hauptanwendungsgebiet für TensorFlow ist, kann man auch beliebige andere Berechnungen damit durchführen.

Ein TensorFlow-Programm – oder besser: die Konfiguration einer Berechnung – ist in TensorFlow immer wie ein Graph aufgebaut. Die Knoten des Graphen stellen Operationen dar, wie etwa Addieren oder Multiplizieren, aber auch Laden und Speichern. Alles was TensorFlow tut, findet in den Knoten eines vorher definierten Berechnungsgraphen statt.

Die Knoten (Operationen) des Graphen sind durch Kanten verbunden, durch die die Daten in Form von Tensoren fließen. Daher auch der Name TensorFlow.

ML Conference 2019

Workshop: Machine Learning 101++ using Python

mit Dr. Pieter Buteneers (Chatlayer.ai)

Honey Bee Conservation using Deep Learning

mit Thiago da Silva Alves, Jean Metz (JArchitects)

Alle Berechnungen in TensorFlow finden in einer sogenannten Session statt. In der Session wird entweder ein fertiger Graph geladen oder stückweise durch API-Aufrufe ein neuer Graph erzeugt. Spezielle Knoten im Graphen können Variablen enthalten. Damit der Graph funktioniert, müssen diese initialisiert werden. Ist das geschehen und existiert eine Session mit fertigem, initialisiertem Graphen, interagiert man mit TensorFlow nur noch durch das Aufrufen von Operationen im Graphen. Was dabei berechnet wird, hängt davon ab, welche Ausgabeknoten des Graphen abgefragt werden. Es wird also nicht der gesamte Graph ausgeführt, sondern nur die Operationen, die Input für den abgefragten Knoten liefern, dann wiederum deren Eingabeknoten etc. zurück zu den Eingabeoperationen, die mit dem notwendigen Inputtensoren befüllt sein müssen.

Das wichtige bei TensorFlow ist, dass alle Operationen automatisch für den Nutzer differenziert werden – das braucht man für das Training von neuronalen Netzen. Da es jedoch automatisch geschieht, kann man es als Anwender getrost ausblenden.

Der Graph wird in der Regel durch ein Python API definiert. Er ist zwar mit Hilfsprogrammen graphisch darstellbar (Abb. 1), solche Darstellungen dienen aber nur dem Debugging, es wird nicht grafisch programmiert wie in einer visuellen Programmiersprache wie etwa LabView.

Abb. 1: Ein (kleiner) Ausschnitt aus einem TensorFlow-Graphen: Die Zahlen an den Kanten zeigen die Größe des Tensors an, der durch sie fließt, die Pfeile die Richtung

Abb. 1: Ein (kleiner) Ausschnitt aus einem TensorFlow-Graphen: Die Zahlen an den Kanten zeigen die Größe des Tensors an, der durch sie fließt, die Pfeile die Richtung

Obwohl in den meisten Beispielen Python genutzt wird, um mit TensorFlow zu interagieren, ist die eigentliche Engine in C/C++ geschrieben. Man kann TensorFlow also mit jeder Sprache nutzen, die C-Funktionen aufrufen kann. Damit können wir also auch von der JVM aus Berechnungen in TensorFlow durchführen.

TensorFlow Begriffs-Cheat-Sheet

  • Tensor: Die Grundlage für Berechnungen in TensorFlow. Ein Tensor ist eigentlich ein Objekt aus der linearen Algebra, für unsere Zwecke reicht es aber völlig aus, einen Tensor als mehrdimensionales Array (meist aus float– oder double-Werten, manchmal auch char oder boolean) zu betrachten. TensorFlow nutzt Tensoren für alles. Alle Daten, die TensorFlow konsumiert, produziert und intern nutzt sind in Tensoren verpackt – daher auch der Name
  • Graph: Die Definition von TensorFlow-Berechnungsabläufen wird üblicherweise in einer Datei mit dem Namen graph.pb im ProtoBuf-Binärformat gespeichert, analog zu einer Java .class-Datei.
  • Training: Beim Training eines Machine-Learning-Verfahrens werden dem Algorithmus immer wieder Daten und erwartete Ergebnisse vorgelegt, woraufhin dieser die internen Parameter des Modells anpasst, um das Ergebnis zu verbessern. Manchmal spricht man hier auch von „Lernen“, obwohl es mit menschlichem Lernen wenig zu tun hat.
  • Training: Je nach Anwendung möchte man mit einem Machine-Learning-Verfahren klassifizieren, vorhersagen, übersetzen, Inhalte erzeugen und vieles mehr. All diese Anwendungen werden unter dem Begriff Inferenz zusammengefasst. Inferenz bedeutet also so viel wie „ein Verfahren anwenden, um ein Ergebnis zu erhalten“. Also das, was wir nach dem Training im Liveeinsatz die meiste Zeit tun möchten. Bei der Inferenz lernt ein Verfahren nichts.
  • Modell: Die gelernten Parameter eines Machine-Learning-Verfahrens, zum Beispiel eines neuronalen Netzes; das ist das Ergebnis des Lernprozesses und nötig, um Ergebnisse zu erhalten (quasi der Variablenzustand des Graphen). Es wird über mehrere Dateien verteilt gespeichert in einer *.index– und mehreren *.data-Dateien, zum Beispiel *.data-0000-of-0001, wobei die erste Zahl die fortlaufende Nummer der Datei angibt, die zweite die Gesamtanzahl.
  • Session: Kontext, in dem TensorFlow ausgeführt wird, wie eine laufende JVM-Instanz; um TensorFlow zu nutzen, muss man eine Session erzeugen, in die ein Graph geladen wird, der mit einem Modell initialisiert wird. In Java muss eine JVM-Instanz gestartet werden, in die Klassen geladen werden, die mit Konstruktorparametern instanziiert werden.

TensorFlow-Training und -Inferenz unter Python

Das Training eines TensorFlow-Modells unter Python (Kasten: „tf.data oder Feeding?“) lässt sich in folgende Schritte unterteilen:

  • Erzeugen des Graphen, entweder über mehrere API Calls, die den Graphen zusammensetzen, oder durch das Laden einer *.pb-Datei, die den Graphen enthält
  • Erzeugen einer Session für den Graphen
  • Initialisieren der Graphvariablen, entweder durch Aufrufen einer speziellen Operation im Graphen, die die Variablen mit Defaultwerten füllt, oder durch Laden eines vortrainierten Modells

Nach diesen drei Schritten hat man eine lauffähige TensorFlow-Session mit einem funktionierenden Modell. Wollen wir dieses nun (weiter-)trainieren, werden immer wieder die folgenden drei Schritte in einer Schleife ausgeführt, bis das Modell genug gelernt hat – entweder indem man vorher eine feste Anzahl an Trainingsschritten festlegt oder indem man wartet, bis der Fehler beim Training unter ein gewisses Maß sinkt:

  • Eingabedaten in Arrays packen und Eingabetensoren zuordnen
  • Ausgabeknoten auswählen und in eine Liste packen
  • Die Session ausführen: Über einen speziellen Befehl wird die Session veranlasst, die notwendigen Operationen zum Erzeugen der gewählten Ausgaben auszuführen

Wo aber findet hier das Training statt? Es geschieht durch das Ausführen der richtigen Ausgabeknoten. Für TensorFlow gibt es keinen Unterschied zwischen Training und Inferenz, es werden einfach mathematische Operationen im Berechnungsgraphen ausgeführt. Führen diese dazu, dass ein neuronales Netz bessere Gewichte lernt, um ein Problem zu lösen, sprechen wir von Training. Die API-Aufrufe für Training und jede andere Art von Nutzung sind jedoch die gleichen.

Beim Training besteht unser Input aus den zu lernenden Daten (zum Beispiel einem Bild als zweidimensionalem Tensor und dem Label „Hund“ oder „Katze“ in Form einer Integer-ID in einem nulldimensionalen Tensor). Durch das Ausführen der richtigen Knoten aktualisiert TensorFlow manche Variablen im Graphen, um die Vorhersage zu verbessern. Der Hauptunterschied zwischen Training und Inferenz ist, dass wir während des Trainings in regelmäßigen Abständen den aktuellen Zustand der Graphvariablen – die sich ja ständig ändern – speichern, während das bei der Inferenz nutzlos ist, da sie konstant bleiben.

tf.data oder Feeding?

Trainiert man ein TensorFlow-Modell in Python, ergeben sich zwei Möglichkeiten, Trainingsdaten in den Graphen zu laden: das tf.data API oder sogenanntes „feeding“, also das Übergeben einzelner Daten für jeden Berechnungsschritt. Das tf.data API ist intern in C implementiert, direkt in den Graphen integriert und daher sehr schnell – dafür aber auch kompliziert in der Nutzung und sehr schwer zu debuggen. Die Feeding-Methode ist leicht zu nutzen und verständlich, man braucht aber zur Laufzeit Python-Code – daher bremst hier meistens Python die teurere Grafikkarte aus und wertvolle GPU-Kapazität wird nicht genutzt. Welchen Ansatz nutzen wir aber nun in Java? Glücklicherweise ist Java um Größenordnungen schneller als Python, daher erhalten wir hier das Beste aus beiden Welten: Wir können die leicht zu verstehende Feeding-Methode nutzen und bekommen trotzdem die volle Performance. Aus diesem Grund lassen wir in diesem Artikel das tf.data API außen vor, wir brauchen es einfach nicht.

Das TensorFlow Java API

Da TensorFlow intern in C/C++ implementiert ist, können wir nun alle Operationen, die in Python für das Training oder die Inferenz notwendig sind, über JNI aufrufen.

Erfreulicherweise muss man sich nicht mehr selbst die Mühe machen, das Low-Level C API mit JNI zu wrappen. Diese Aufgabe hat Google bereits für uns erledigt, die notwendigen Bibliotheken stehen wie gewohnt auf Maven Central zur Verfügung. Es gibt vier unterschiedliche Artefakte, alle in der Gruppe org.tensorflow:

  • tensorflow: Ein Metapaket mit Abhängigkeiten auf libtensorflow und libtensorflow_jni; um Verwirrung zu vermeiden, sollte es nicht benutzt werden.
  • libtensorflow: Das API, gegen das man in Java programmiert; das ist die Compile- und Runtime-Abhängigkeit und der zentrale Einstiegspunkt.
  • libtensorflow_jni: Enthält die nativen CPU-Abhängigkeiten für libtensorflow; dieses Artefakt braucht man zur Laufzeit, wenn man einen Rechner ohne GPU nutzt; es enthält nativen Code für Windows, Linux und Mac; TensorFlow ist komplett enthalten, man muss auf dem ausführenden System weder Python oder TensorFlow installieren.
  • libtensorflow_jni_gpu: Das GPU-Äquivalent zu libtensorflow_jni; diese Abhängigkeit sollte man nutzen, wenn man einen Rechner mit NVIDIA GPU nutzt und Cuda und CuDNN korrekt installiert sind; es funktioniert nur unter Windows und Linux, unter macOS es gibt keinen GPU-Support für TensorFlow.

Die Versionsnummern der Java Wrapper entsprechen der Versionsnummer der enthaltenen TensorFlow-Version. Hier sollte man einfach immer das neueste stabile Release geben. Aufpassen muss man nur, wenn der Code auf einem Rechner mit GPU ausgeführt werden soll (Kasten: „Auswahl der zu nutzenden GPU“). Nicht jede TensorFlow-Version unterstützt jede CUDA- und CuDNN-Version. (CUDA ist ein spezieller NVIDIA-Treiber, um Grafikkarten für parallele Berechnungen zu nutzen, CuDNN eine auf CUDA basierende Bibliothek für neuronale Netze.) Es muss darauf geachtet werden, das CUDA- und TensorFlow-Version zueinander passen. Aktuell unterstützen alle TensorFlow-Versionen ab 1.13 die gleiche CUDA-Version: 10.0. Hier hat man mit einer Java-basierten Lösung bei der Installation der fertigen Software schon einen großen Vorteil gegenüber einer Python-Software. Dank Maven bringt unser resultierendes Artefakt alle Abhängigkeiten bereits mit. Weder Python noch TensorFlow noch irgendwelche Python-Bibliotheken müssen vorinstalliert und die Installationen mit einem Tool wie Anaconda verwaltet werden.

Es sollte nicht die Top-Level-Abhängigkeit tensorflow genutzt werden, sondern besser direkt libtensorflow und eine der *_jni-Implementierungen. Grund hierfür ist, dass das tensorflow-Artefakt eine Abhängigkeit auf libtensorflow_jni (die CPU-Variante) mitbringt. Fügt man nun libtensorflow_jni_gpu hinzu, wird trotzdem der CPU-native Code genutzt und man wundert sich, warum trotz GPU alles so langsam läuft. Die Gradle-Abhängigkeiten für das TensorFlow-Training auf der GPU sehen dann z.B. so aus:

compile "org.tensorflow:libtensorflow:1.14.0"
runtimeOnly "org.tensorflow:libtensorflow_jni_gpu:1.14.0"

Das für Training und Inferenz notwendige Java API ist einfach und überschaubar. Wichtig sind nur vier Klassen: Graph, Session, Tensor und Tensors. Wie diese richtig eingesetzt werden, sehen wir jetzt, indem wir die Python-typischen Trainingsschritte in Java nachbauen.

TensorFlow-Training unter Java

Der erste Schritt im Training ist die Definition des Graphen. Leider müssen wir hier gleich zu Beginn den ersten, aber einzigen Kompromiss eingehen. Zwar kann auch ein Graph über das Java API schrittweise aufgebaut werden, aber bei vielen Knotentypen erzeugt das Python API viele notwendige Helferknoten automatisch mit, die für die reibungslose Nutzung des Graphen notwendig sind. Um das in Java nachzubauen, bedürfte es eines sehr detaillierten Wissens um die Interna des Python API. Daher muss dieser Schritt im Vorfeld einmal in Python durchgeführt werden. Die resultierende Graph-Datei legen wir dann als Java Resource ab, um sie dann wieder in der JVM zu laden. Das Speichern des aktuellen Graphen in Python ist denkbar einfach:

with open(filename, 'wb') as f:
  f.write(tf.get_default_graph().as_graph_def().SerializeToString())

Wichtig dabei: Auch wenn hier SerializeToString() aufgerufen wird, ist das Resultat doch eine Binärdatei. Bequemlichkeitshalber sollte man hier auch gleich die initialisierten Variablen speichern. Zwar ist das Initialisieren der Variablen im Graphen von der JVM aus einfach, wählt man aber immer diesen Ablauf, ist es hinterher leicht, bei komplexen Modellen sogenanntes Transfer Training zu betreiben. Dabei wird ein bereits existierender Zustand eines Modells weitertrainiert und angepasst (Listing 1).

# Dieser Python Befehl legt einen Knoten zum Initialisieren an
init_op = tf.global_variables_initializer()
# Der Saver ist eine Hilfsklasse, die in Python ein Modell speichert
saver = tf.train.Saver()
# Speichern ist eine Graphenoperation 
# und kann nur in einer Session ausgeführt werden
with tf.Session() as sess:
  # Variablen initialisieren
  sess.run(init_op)
  # Zustand speichern
  save_path = saver.save(sess, filename)

Nun haben wir Graph und Modell gespeichert und können es in Java trainieren und den Graphen ausführen. Die folgenden Beispiele sind der Kürze halber in Kotlin, lassen sich aber auf jede JVM-Sprache übertragen:

//leeren Graphen erzeugen
val graph = Graph()
//*.pb Datei laden - entweder aus einer Datei oder aus den Ressourcen
val graphDefBytes = javaClass.getResource(resourceName).readBytes()
//Graphen aus Datei rekonstruieren
graph.importGraphDef(graphDefBytes)

Nun haben wir den TensorFlow-Graphen in der JVM geladen. Um etwas damit zu machen, brauchen wir eine Session:

val session = Session(graph)

Bevor wir richtig loslegen können, muss nur noch der letzte Stand der Variable geladen werden. Das kann entweder die initial in Python gespeicherte Datei sein oder der letzte Stand eines vorangegangenen Trainings, zum Beispiel um ein Training fortzusetzen. Das Laden von Variablen ist nur eine Operation im TensorFlow-Graph. Diese Operation braucht als Input einen in einen Tensor gepackten String, der den Namen der *.index-Datei ohne das Suffix enthält, also foo statt foo.index.

Hier brauchen wir zum ersten Mal die Tensors-Klasse. Diese enthält Hilfsfunktionen, um Java-Datentypen in Tensor-Objekte zu verpacken. Dabei wird automatisch darauf geachtet, dass der Tensor die richtige Form hat. Wichtig für jedes Tensor-Objekt: Es enthält Speicher, der außerhalb der JVM allokiert wurde. Daher muss es manuell geschlossen werden, wofür es das Closable-Interface implementiert. In Java muss für jeden Tensor ein eigener try{…} finally { tensor.close(); } Block angelegt werden. In Kotlin geht das zum Glück viel einfacher mit use:

Tensors.create(path).use { pathTensor ->
  session.runner().feed("save/Const", pathTensor)
                  .addTarget("save/restore_all")
                  .run()
}

Hier sieht man alle notwendigen Teile einer TensorFlow-Aktion auf der JVM:

  • Es wird ein Runner für die Session erzeugt; diese Klasse hat ein Builder API, das definiert, was ausgeführt werden soll.
  • Der Inputknoten für das Laden und Speichern („save/Const“) wird mit dem Tensor gefüllt, der den Dateinamen enthält.
  • Der Zielknoten für das Laden wird als Ziel definiert.
  • Die Aktion wird ausgeführt.

Der Trick für alle Operationen ist es, ihre Namen zu kennen. Da man aber den Graphen selbst zuvor baut und man beim Erzeugen eines Knotens den Namen definieren kann, kann man diese selbst wählen. Ausnahme sind die Knoten zum Laden und Speichern, die immer die hier angegebenen Namen haben.

Auswahl der zu nutzenden GPU

Auf Systemen mit mehreren GPUs möchte man manchmal nicht alle GPUs blockieren, zum Beispiel um mehrere Trainings parallel laufen zu lassen. Hierzu kann man den TensorFlow-Graphen, der normalerweise automatisch die GPU oder GPUs allokiert, so konfigurieren, dass nur eine bestimmte GPU genutzt wird. Das hat aber den großen Nachteil, dass dann der Graph auf eine bestimmte GPU „hart verdrahtet“ wird und nur noch auf dieser GPU einsetzbar ist. Viel bequemer ist es, die GPUs per Umgebungsvariable ein- bzw. auszublenden, bevor man die JVM startet. Das geht leicht mit der Umgebungsvariable CUDA_VISIBLE_DEVICES. Hier kann man eine kommaseparierte Liste von CUDA-Devices angeben, die in der aktuellen Shell sichtbar sein sollen. Vorsicht: Die Nummerierung beginnt bei 1, nicht bei 0. Folgender Konsolenbefehl aktiviert beispielsweise nur die zweite Grafikkarte für TensorFlow (oder andere Deep Learning Frameworks):

export CUDA_VISIBLE_DEVICES=2

Nun haben wir bereits alle Operationen gesehen, die es zur Interaktion mit TensorFlow von der JVM aus braucht. Einen Trainingsschritt durchzuführen, ist nun denkbar einfach. Nehmen wir an, unser Input ist ein Array aus geladenen Bildern. Die Schwarz-Weiß-Werte der Pixel sind in float-Werte im Bereich 0-1 umgewandelt. Jedes Bild gehört zu einer Klasse, die durch einen int-Wert definiert ist, zum Beispiel 0 = Hund, 1 = Katze. Dann ist der Input für einen Batch (es werden immer mehrere Bilder gleichzeitig trainiert) ein float[][]-Array, das die Bilder enthält, und ein int[]-Array, das die zu lernenden Klassen enthält. Ein Trainingsschritt kann nun wie folgt ausgeführt werden (Listing 2).

fun train(inputs: Array<FloatArray>, labels: IntArray) {
  withResources {
    val results: List<Tensor<*>> = session.runner()
      .feed("inputs", Tensors.create(inputs).use())
      .feed("labels", Tensors.create(labels).use())
      .fetch("total_loss:0")
      .fetch(“accuracy:0")
      .fetch("prediction")
      .addTarget("optimize").run().useAll()
    val trainingError = results[0].floatValue()
    val accuracy      = results[1].floatValue()
    val prediction    = results[2].intValue()
  }
}

Wir sehen wieder das gleiche Muster: Ein Runner wird erzeugt, die Inputs in Tensoren gepackt, das Ziel ausgewählt („optimize„) und die Aktion ausgeführt. Eine Neuerung haben wir jetzt allerdings: Wir erhalten Werte zurück. Die Namen der zurückzugebenden Knoten werden mit fetch definiert. Die Namen enthalten hier noch ein Suffix: „:0“. Das heißt, dass es sich um Knoten mit mehreren Outputs handelt, das :0-Suffix bedeutet, dass der Output des Knotens mit Index 0 zurückgegeben werden soll.

Der Output ist eine Liste von Tensor-Objekten. Die kann man in diverse Primitivtypen und Arrays umwandeln, um das Ergebnis verfügbar zu machen. Wichtig hierbei: Auch die vom API erzeugten Tensor-Objekte müssen geschlossen werden. Normalerweise müsste hierzu in einem finally-Block über die Einträge der Liste iteriert und diese geschlossen werden. Das ist allerdings sehr unhandlich und schlecht zu lesen. Deshalb ist es nützlich, ein erweitertes use API in Kotlin zu definieren, mit dem innerhalb eines Blocks mehrere Objekte mit use oder useAll (für Listen von Closables) markiert werden, die dann anschließend sicher geschlossen werden (Listing 3).

class Resources : AutoCloseable {
  private val resources = mutableListOf<AutoCloseable>()

  fun <T: AutoCloseable> T.use(): T {
    resources += this
    return this
  }

  fun <T: Collection<AutoCloseable>> T.useAll(): T {
    resources.addAll(this)
    return this
  }

  override fun close() {
    var exception: Exception? = null
    for (resource in resources.reversed()) {
      try {
        resource.close()
      } catch (closeException: Exception) {
        if (exception == null) {
          exception = closeException
        } else {
          exception.addSuppressed(closeException)
        }
      }
    }
    if (exception != null) throw exception
  }
}

inline fun <T> withResources(block: Resources.() -> T): T = 
  Resources().use(block)

Mit diesem nützlichen Trick können nun alle Tensoren innerhalb eines TensorFlow-Aufrufs bequem und sicher geschlossen werden.

Bei der Inferenz unter Java wird es wirklich einfach. Wir erinnern uns: Jede Aktion auf dem TensorFlow-Graphen wird durchgeführt, indem Eingabeknoten mit Inputtensoren befüllt und die richtigen Ausgabeknoten abgefragt werden. Für unser Beispiel oben heißt das: Der Code bleibt gleich, es werden lediglich die Inputs für die richtige Lösung (labels) nicht gesetzt. Logisch, denn die kennen wir ja noch nicht. Bei der Ausgabe rufen wir die Knoten für die Fehlerberechnung und die Aktualisierung des neuronalen Netzes nicht auf (total_loss:0, accuracy:0, optimize), wir lernen also nicht. Stattdessen fragen wir nur das Ergebnis ab (prediction). Da für die Berechnung des Ergebnisses der Input der Lösungen nicht nötig ist, funktioniert alles wie bisher: Es kommt zu keinem Fehler, da der Teil des Graphen, der das neuronale Netz trainiert, inaktiv bleibt.

Praktische Erfahrungen

Das hier vorgestellte Verfahren ist nicht nur ein interessantes Experiment, der Autor hat es bereits in mehreren kommerziellen Projekten erfolgreich eingesetzt. Dabei haben sich mehrere Vorteile im praktischen Einsatz herauskristallisiert:

  • Das Java API ist schnell und effizient: Es ergeben sich keine Performanceeinbußen im Vergleich zur reinen Python-Anwendung. Ganz im Gegenteil: Da Java gerade für Aufgaben wie das Einlesen von Daten und Preprocessing sehr viel schneller als Python ist, ist es sogar leichter, einen performanten Trainingsprozess zu implementieren.
  • Das Training läuft über mehrere Tage absolut stabil, Googles Java-Implementierung hat sich als sehr verlässlich herausgestellt.
  • Das Deployment des fertigen Produkts ist wesentlich einfacher als das von auf Python-basierten, da nur eine Java-Laufzeitumgebung und die richtigen CUDA-Treiber vorhanden sein müssen – alle Abhängigkeiten sind Teil der Java-TensorFlow-Bibliothek.
  • Das Low-Level-Persistenz-API von TensorFlow (wie hier vorgestellt) ist leichter zu nutzen als viele der „offiziellen“ Verfahren wie beispielsweise Estimators.

Der einzige wirkliche Nachteil ist, dass ein Teil des Projekts weiterhin Python-basiert ist – die Definition des Graphen. Man braucht also ein Team, das zumindest zum Teil auch in der Python-Welt zu Hause ist.

Geschrieben von
Christoph Henkelmann
Christoph Henkelmann
Christoph Henkelmann ist selbstständiger IT-Berater, Entwickler und Gründer aus Köln. Neben seiner Beratertätigkeit in der von ihm mit gegründeten Kölner Agentur TheAppGuys bloggt er unter https://divis.io über aktuelle ML-Themen und arbeitet an KI-Projekten und Neugründungen.
Kommentare

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
4000
  Subscribe  
Benachrichtige mich zu: