Teil 1: In einfachen Schritten mit Deep Learning durchstarten

Schnelleinstieg in Deeplearning4j – Teil 1: Grundlagen und Methoden

Paul Dubs

© Shutterstock / Sashkin

Dieser Artikel zeigt in zwei Teilen, wie man in kürzester Zeit den Einstieg in Deeplearning4j (DL4J) schafft. Anhand eines Beispiels, in dem vorhergesagt werden soll, ob ein Kunde seine Bank verlassen wird, wird jeder Schritt eines typischen Arbeitsablaufs betrachtet. Der erste Teil befasst sich mit den Grundlagen bzw. Vorrausetzungen, um die ersten Schritte im Umgang mit Deeplearning4j meistern zu können.

Deep Learning, d. h. die Verwendung von tiefen, mehrschichtigen neuronalen Netzen, ist der große Treiber des aktuellen Booms rund um Machine Learning. Von großen Sprüngen in der Qualität automatischer Übersetzungen über autonomes Fahren bis hin zum Schlagen von Großmeistern in dem Spiel Go macht diese Technik vielfach Schlagzeilen.

Deeplearning4j, auch DL4J genannt, ist eine Java-Bibliothek für Deep Learning. Zu ihr gehört zudem eine ganze Familie von weiteren Bibliotheken, die die Verwendung von Deep-Learning-Modellen mit Java vereinfachen. Als eine Alternative zu den vielen Pythonbasierten Frameworks bietet DL4J einen Weg, wie Deep Learning auch in Enterprise-Umgebungen einfach in den Produktivbetrieb gebracht werden kann. Der vollständige Code des Artikels, inklusive Trainingsdaten, befindet sich auf GitHub.

Einbindung ins Projekt

DL4J kann wie viele andere Java-Bibliotheken einfach als eine weitere Abhängigkeit in das Build-Tool der Wahl aufgenommen werden. In diesem Artikel werden die dafür notwendigen Angaben im Maven-Format gemacht, also so, wie sie in der pom.xml-Datei stehen würden. Natürlich kann man auch ein anderes Build-Tool wie Gradle oder SBT verwenden.

Eine Verwendung ohne Build-Tools ist für DL4J jedoch nicht vorgesehen, da es selbst auch eine Vielzahl von direkten und transitiven Abhängigkeiten hat. Deswegen gibt es auch keine einzelne .jar-Datei, die man manuell als Abhängigkeit in seiner IDE angeben könnte.

DL4J und die dazugehörigen Bibliotheken sind modular aufgebaut, sodass man seine Abhängigkeiten den Bedürfnissen des Projekts anpassen kann. Gerade für Einsteiger kann das jedoch die Verwendung verkomplizieren, da es nicht zwangsläufig offensichtlich ist, welches Untermodul benötigt wird, um eine bestimmte Klasse verfügbar zu machen.

Die verwendeten Versionen aller DL4J-Module sollten immer gleich sein. Um das zu vereinfachen, definieren wir eine Property, die wir im Folgenden immer verwenden werden, um die Versionsangabe zu machen. DL4J ist im Moment kurz vor seinem 1.0-Release und in diesem Artikel verwenden wir Version 1.0.0-beta3, die erst vor Kurzem erschienen ist.

<properties>
 <dl4j.version>1.0.0-beta3</dl4j.version>
</properties>

Für Einsteiger ist es ratsam, mit dem Modul deeplearning4j-core zu beginnen. Das zieht viele weitere Module transitiv mit sich und erlaubt somit gleich die Nutzung einer Vielzahl von Features, ohne dass man sich auf die Suche nach der richtigen Abhängigkeit machen muss. Der Nachteil ist, dass man beim Bündeln aller Abhängigkeiten in ein Uber-JAR eine große Datei bekommt.

<dependency>
  <groupId>org.deeplearning4j</groupId>
  <artifactId>deeplearning4j-core</artifactId>
  <version>${dl4j.version}</version>
</dependency>

DL4J unterstützt mehrere Backends, die die Verwendung von CPU oder GPU ermöglichen. Die Wahl des Backend geschieht dabei im einfachsten Fall über die Angabe einer Abhängigkeit. Um das CPU Backend zu verwenden, wird nd4j-native-platform benötigt. Für das GPU Backend wird hingegen nd4j-cuda-X.Y-platform verwendet, wobei X.Y durch die installierte CUDA-Version ausgetauscht werden sollte. Unterstützt werden im Augenblick CUDA 8.0, 9.0 und 9.2.

<dependency>
  <groupId>org.nd4j</groupId>
  <artifactId>nd4j-native-platform</artifactId>
  <version>${dl4j.version}</version>
</dependency>

Beide Backends setzen auf die Verwendung von nativen Binarys, weswegen die Plattformmodule auch die Binaries für alle unterstützten Plattformen einbinden. Das ermöglicht die Verteilung auf mehrere unterschiedliche Plattformen, ohne dafür jeweils eine einzelne spezialisierte JAR-Datei erstellen zu müssen. Die aktuell unterstützten Plattformen für die jeweiligen CPUs sind:

  • Linux (PPC64LE, x86_64)
  • Windows (x86_64)
  • macOS (x86_64)
  • Android (ARM, ARM64, x86, x86_64)

Für CUDA-fähige GPUs:

  • Linux (PPC64LE, x86_64)
  • macOS (x86_64)
  • Windows (x86_64)

Aufgrund dessen, dass einige DL4J-eigene Abhängigkeiten noch nicht voll mit neueren Java-Versionen kompatibel sind,
können unter Umständen ein paar Workarounds notwendig sein um Java Versionen neuer als Java 8 zu verwenden. Der Beispiel Code im Quickstart with DL4J GitHub Repository beinhaltet diese und ist mit Java 11 lauffähig.

Zuletzt fügen wir noch einen Logger zu unseren Abhängigkeiten hinzu. DL4J benötigt einen mit dem SLF4J-API kompatiblen Logger, um seine Informationen mit uns zu teilen. Für unser Beispiel verwenden wir hier Logback Classic.

<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.2.3</version>
</dependency>
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)

ND4J: Fundament für Deeplearning4j

Wie schon an der Angabe des Backend zu sehen ist, bildet ND4J das Fundament, auf dem DL4J aufbaut. ND4J ist eine Bibliothek für schnelle Tensormathematik mit Java. Um die Hardware maximal auszunutzen, werden dabei praktisch alle Berechnungen außerhalb der JVM durchgeführt. Auf diese Weise können sowohl CPU-Features wie z. B. AVX-Vektorinstruktionen als auch GPUs verwendet werden.

Wenn eine GPU verwendet wird, sollte man jedoch berücksichtigen, dass gerade für Deep Learning oftmals eine recht potente GPU notwendig ist, um einen Geschwindigkeitsvorteil gegenüber einer CPU zu erlangen. Das gilt insbesondere für Notebook-GPUs, die vor der aktuellen GeForce-1000er-Serie erschienen sind, und selbst auf dem Desktop sollte man zumindest mit einer GeForce GTX 960 mit 4 GB RAM aufwarten. Der Grund für diese Empfehlung liegt darin, dass GPUs insbesondere bei Berechnungen mit großen Datenmengen glänzen – diese großen Datenmengen benötigen aber auch entsprechend viel RAM und dieser ist erst bei den stärkeren Modellen in ausreichender Menge vorhanden.

Laden der Daten

Machine Learning jeder Art beginnt zunächst einmal immer damit, dass Daten gesammelt und geladen werden müssen. Auch bei Deep Learning ist das nicht anders. Dieser Artikel konzentriert sich auf tabellarische Daten, die als CSV vorliegen. Das Vorgehen für andere Dateiformate oder -arten wie Bilder ist jedoch ähnlich.

Grundsätzlich gilt: Wenn man schnell zu guten Ergebnissen kommen möchte, sollte man seine Daten und das zu lösende Problem gut verstehen. Etwas Expertenwissen um die Daten und das generelle Problemfeld sowie eine entsprechende Vorbereitung der Daten können hierbei die Modellkomplexität und Trainingszeit in vielen Fällen bedeutend reduzieren.

Ein weiterer zu beachtender Punkt ist, dass man seine Daten für das Training eines Modells in mindestens zwei Teile aufteilen muss. Der Großteil der Daten, üblicherweise etwa 80 Prozent, wird zum Training verwendet und somit als Trainingsmenge bezeichnet. Die restlichen Daten, üblicherweise etwa 20 Prozent, werden zur Untersuchung der Qualität des Modells verwendet und als Testmenge bezeichnet. Insbesondere bei weitergehendem Tuning ist es sogar üblich, weitere 10 Prozent der Trainingsdaten für eine Validationsmenge zu reservieren, mit der man prüft, ob das Modell nicht zu sehr auf die Testmenge zugeschnitten wurde.

Bei der Wahl der Daten für die Testmenge sollte beachtet werden, dass sie eine repräsentative Teilmenge aller Daten ausmachen. Das ist notwendig, um die Aussagekraft des Modells ordnungsgemäß überprüfen zu können.

Der in diesem Artikel verwendete Datensatz stammt von Kaggle, einer Plattform für Data-Science- und Machine-Learning-Wettbewerbe. Er besteht aus tabellarischen Daten und beinhaltet nicht nur rein numerische, sondern auch kategorische Daten. Er wurde bereits etwas vorverarbeitet und in Trainingsmenge und Testmenge aufgeteilt (Abb. 1).

Abb. 1: Kundendaten einer Bank

Abb. 1: Kundendaten einer Bank

Der Datensatz besteht aus Kundendaten einer Bank. Jede Zeile stellt dabei einen Kunden dar und beinhaltet in der Spalte „Exited“ auch die Information, ob der Kunde die Bank verlassen hat. Die Problemstellung, die wir in diesem Beispiel lösen wollen, ist das Training eines Modells, das anhand dieser Kundendaten vorhersagen kann, ob ein Kunde die Bank verlassen wird. Es ist also ein klassisches Klassifikationsproblem mit zwei Klassen: „Kunde wird bleiben“ und „Wird die Bank verlassen“.

DataVec

Wie alle anderen statistischen Machine-Learning-Verfahren funktioniert auch Deep Learning nur mit numerischen Daten. DataVec ist eine DL4J-Bibliothek, die uns beim Laden, Analysieren und Konvertieren unserer Daten in das notwendige Format unterstützt. Um sie zu verwenden, müssen wir keine weitere Abhängigkeit angeben, da sie bereits vom Modul deeplearning4j-core mitgeladen wird.

Im Beispiel werden wir die drei Kernkonzepte von DataVec antreffen. Diese bestehen aus dem InputSplit, RecordReader und TransformProccess. Man kann sie als jene Schritte verstehen, die von den Daten durchlaufen werden müssen, um von Rohdaten zu tatsächlich verwendbaren Daten angereichert zu werden (Abb. 2).

Abb. 2: Der Weg von Rohdaten zu tatsächlich verwendbaren Daten

Abb. 2: Der Weg von Rohdaten zu tatsächlich verwendbaren Daten

Wir beginnen damit, dass ein FileSplit erzeugt wird. Diesem geben wir den Ordner an, in dem sich unsere Trainingsmenge befindet. Die Aufgabe des InputSplits wird sein, dem RecordReader jeweils einen einzelnen Input anzubieten. Deswegen gibt es außer dem FileSplit auch noch eine Reihe weiterer Implementierungen, die z. B. Daten aus einem InputStream oder auch einer Collection bereitstellen können.

Dadurch, dass wir auch ein optionales Random-Objekt mitgeben, wird der FileSplit die Dateien in einer zufälligen Reihenfolge durchgehen. Das wird im späteren Training noch wichtig.

Random random = new Random();
  random.setSeed(0xC0FFEE);
  FileSplit inputSplit = new FileSplit(new File("X:/Churn_Modelling/Train/"), random);

Unsere Daten liegen im CSV-Format vor, deswegen verwenden wir einen CSVRecordReader. Der wird mit dem zuvor erstellten InputSplit initialisiert. Der RecordReader wird im weiteren Verlauf den Input, den er vom InputSplit bekommt, nehmen und in ein oder mehrere Beispiele aufteilen. Diese Beispiele sind in Form von Records vom RecordReader abrufbar.

CSVRecordReader recordReader = new CSVRecordReader();
  recordReader.initialize(inputSplit);

Auch für den RecordReader gibt es eine Reihe von anderen Implementierungen, die etwa Excel-Dateien, Bilder, Videos oder auch (via JDBC verbundene) Datenbanken lesen können.

Records sind im Prinzip nichts weiter als eine Liste von Werten. Und gerade im Fall vom CSVRecordReader sind diese Werte zunächst einmal alle Strings. Um damit weiterarbeiten zu können, müssen wir ein Schema für unsere Daten definieren. Ähnlich zu einem Schema, wie man es auch von einer SQL-Datenbank kennt, geben wir hier vor, welche Arten von Werten sich in einem Record befinden können. Aufgrund dessen, dass ein Record eben eine Liste von Werten ist, müssen wir an dieser Stelle auch auf die Reihenfolge der Angaben aufpassen, da diese mit der Reihenfolge in unseren CSVDateien übereinstimmen muss.

Hier lohnt es sich bereits, seine Daten zumindest oberflächlich gut zu kennen. So haben wir für die Spalten „Geography” und „Gender” bereits alle möglichen Werte im Schema vorgegeben und die mit den Zahlen 0 und 1 bezeichneten Angaben für „Has Credit Card“, „Is Active Member“ und „Exited“ als kategorisch angegeben, anstatt sie als Integer zu deklarieren (Listing 1). Das kommt daher, dass erfahrungsgemäß solche Ja/Nein-Angaben als kategorische Daten (mit One-Hot Encoding, s.u.) besser funktionieren.

Schema schema = new Schema.Builder()
  .addColumnsInteger("Row Number", "Customer Id")
  .addColumnString("Surname")
  .addColumnInteger("Credit Score")
  .addColumnCategorical("Geography", "France", "Germany", "Spain")
  .addColumnCategorical("Gender", "Female", "Male")
  .addColumnsInteger("Age", "Tenure")
  .addColumnDouble("Balance")
  .addColumnInteger("Num Of Products")
  .addColumnCategorical("Has Credit Card", "0", "1")
  .addColumnCategorical("Is Active Member", "0", "1")
  .addColumnDouble("Estimated Salary")
  .addColumnCategorical("Exited", "0", "1")
  .build();

Im nächsten Schritt werden wir die Daten zunächst analysieren lassen. Dafür verwenden wir ein neues Feature, das nicht vom deeplearning4j-core-Modul verfügbar gemacht wird, und müssen daher eine weitere Abhängigkeit hinzufügen.

<dependency>
  <groupId>org.datavec</groupId>
  <artifactId>datavec-local</artifactId>
  <version>${dl4j.version}</version>
</dependency>

Danach ist das Ausführen der Analyse einfach: Wir geben Schema und RecordReader als Parameter an und bekommen ein Ergebnis.

DataAnalysis analysis = AnalyzeLocal.analyze(schema, recordReader);
  HtmlAnalysis.createHtmlAnalysisFile(analysis, new File("X:/Churn_Modelling/analysis.html"));

Zusätzlich lassen wir uns das Ergebnis noch in von Menschen lesbarer Form als eine HTML-Datei ausgeben. Mithilfe der Analyseergebnisse können wir uns noch weiter mit den Daten vertraut machen. Wir bekommen für jede Spalte eine Auswertung darüber, wie sich ihre Werte verteilen, sowie ein Histogramm als Visualisierung dieser Verteilung. Wir achten hier insbesondere auf den Wertebereich der numerischen Spalten. Für das Training von neuronalen Netzen wird empfohlen, dass jede Eingabe in einem Bereich zwischen -1 und 1 liegt, da es sonst schnell dazu kommen kann, dass man den funktionalen Bereich von Aktivierungsfunktionen und Regularisierungsmethoden verlässt – der Effekt ist, dass das Modell nicht lernt.

Wir sehen in Abbildung 3, dass der Wertebereich in vielen Fällen außerhalb des -1-bis-1-Bereichs liegt. Wir werden diese Daten also normalisieren müssen, bevor wir sie weiterverwenden können. Es gibt aber außer der Normalisierung auch noch weitere Vorbereitungsarbeit zu erledigen.

Abb. 3: Daten und ihre Wertebereiche

Abb. 3: Daten und ihre Wertebereiche

In Listing 2 treffen wir auf den letzten Schritt vor der Vektorisierung der Daten: den TransformProcess. Wir verwenden ihn, um das umzusetzen, was wir aus der Analyse gelernt haben. Es wird damit begonnen, dass wir die Angaben Row Number, Customer Id und Surname entfernen, da sie für das Problem nutzlos erscheinen und im Zweifelsfall eher zu Trainingsproblemen führen könnten.

TransformProcess transformProcess = new TransformProcess.Builder(schema)
  .removeColumns("Row Number", "Customer Id", "Surname")
  .categoricalToOneHot("Geography", "Gender", "Has Credit Card", "Is Active Member")
  .integerToOneHot("Num Of Products", 1, 4)
  .normalize("Tenure", Normalize.MinMax, analysis)
  .normalize("Age", Normalize.Standardize, analysis)
  .normalize("Credit Score", Normalize.Log2Mean, analysis)
  .normalize("Balance", Normalize.Log2MeanExcludingMin, analysis)
  .normalize("Estimated Salary", Normalize.Log2MeanExcludingMin, analysis)
  .build();

Schema finalSchema = transformProcess.getFinalSchema();

Die Spalten, die wir von Anfang an als kategorisch eingestuft haben, lassen wir in eine One-Hot-Kodierung transformieren. Das bedeutet, dass jede Kategorie eine eigene Spalte erhält und nur genau dann eine 1 in dieser Spalte steht, wenn die entsprechende Kategorie in der ursprünglichen Spalte stand. Diese Art von Kodierung ist immer dann nützlich, wenn man Daten hat, die keine natürliche Übersetzung in eine numerische Form haben und sich auch nicht ordnen lassen. So wäre es in unserem Beispiel nicht sinnvoll, die Länder der Geography-Spalte als 0, 1, 2 zu kodieren, da jedes Land jeden dieser Werte annehmen könnte. Stattdessen werden daraus drei unterschiedliche Spalten, die sich als „Ist in [Land]“ interpretieren lassen.

Diese Transformation lässt sich auch auf Spalten mit Integerwerten anwenden, was im Beispiel für die Spalte Num Of Products geschieht. Obwohl diese Angabe numerisch gemacht wurde, hilft hier etwas Intuition bezüglich der Daten weiter, denn ein Kunde, der mehr Produkte der Bank verwendet, ist von der Art her wahrscheinlich anders als ein Kunde, der nur ein einziges verwendet.

Bei der Wahl der angemessenen Normalisierung für die restlichen Spalten lassen wir uns durch die Ergebnisse der Analyse leiten. Als Faustregel kann man bei gleichverteilten Werten eine MinMax-Normalisierung anwenden, bei normalverteilten Werten nimmt man eine Standardize-Normalisierung und bei Werten, die einen großen Bereich abdecken, ist häufig eine logarithmische Normalisierung passend. Diese Faustregel gibt uns den Anfangsschritt, in der Praxis sollte man jedoch auch andere Normalisierungen in Betracht ziehen und evaluieren, ob sie vielleicht besser für das eigene Problem geeignet sind. So haben wir in diesem Beispiel auch eine logarithmische Normalisierung für den Wert von Credit Score vorgenommen, obwohl die Daten in der Analyse die typische Glockenform einer Normalverteilung zeigen.

Die meisten Normalisierungsmethoden benötigen statistische Angaben über den Datensatz, um zu funktionieren. Auch hier hilft uns die zuvor gemachte Analyse. Da die Analyse auch das ursprüngliche Schema kennt, kann der TransferProcess direkt mit dem angegebenen Spaltennamen auf die Analyse zugreifen.

Als Nächstes geht es an die Vektorisierung der Daten. Üblicherweise werden bei Deep Learning nicht alle Trainingsdaten in jedem Trainingsschritt berücksichtigt, sondern nur ein Bruchteil davon. Dieses Verfahren wird als „Mini-Batching“ und jeder volle Durchlauf durch die Daten als „Epoche“ bezeichnet. Um nicht in jeder Epoche dieselben Mini-Batches zu bekommen, haben wir den InputSplit am Anfang zusammen mit einem Random-Objekt instanziiert.

Die Größe des Mini-Batch, die sogenannte Batch Size, bestimmt dabei, wie viele Beispiele das Modell während jedes Trainingsschritts sieht, und beeinflusst somit auch das Trainingsregime ganz maßgeblich. Eine größere Batchsize führt dazu, dass es weniger Mini-Batches gibt und somit auch weniger Trainingsschritte in jeder Epoche durchgeführt werden. Gleichzeitig bekommt das Modell aber auch mehr Daten zu sehen und kann damit unter Umständen bessere Muster finden.

int batchSize = 80;

Wir setzen den Wert für unseren Fall auf 80, da wir 8 000 Beispiele in der Trainingsmenge haben und das somit zu glatten 100 Mini-Batches führt. Erzeugt wird es mit dem soeben definierten TransformProcess und einem neuen RecordReader, der zunächst die CSV-Daten einlesen und sofort transformieren wird. Wie auch zuvor initialisieren wir ihn mit dem InputSplit, der auf unsere Trainingsdaten verweist.

TransformProcessRecordReader trainRecordReader = new TransformProcessRecordReader(new CSVRecordReader(), transformProcess);
trainRecordReader.initialize(inputSplit);

Dann wird ein DataSetIterator erzeugt, der die transformierten Daten aus dem RecordReader liest, sie vektorisiert und sich um den Aufbau der Mini-Batches kümmert. Da wir ein Klassifikationsproblem lösen wollen, verwenden wir die classification-Methode, um anzugeben, in welcher Spalte unser Label, also der Sollwert, gefunden werden kann und wie viele mögliche Klassen es gibt. Diesen DataSetIterator werden wir im zweiten Teil des Artikels für das Training des Modells verwenden.

RecordReaderDataSetIterator trainIterator = new RecordReaderDataSetIterator.Builder(trainRecordReader, batchSize)
  .classification(finalSchema.getIndexOfColumn("Exited"), 2)
  .build();

Wer DataVec nicht verwenden möchte, kann auch selbst das Interface DataSetIterator implementieren. Das ist meist nur dann notwendig, wenn man besondere Bedürfnisse für die Vorverarbeitung seiner Daten oder besondere Ansprüche beim Erzeugen von Mini-Batches hat.

Im nächsten Teil geht es dann um das Training und die Verwendung des Modells.

Geschrieben von
Paul Dubs
Paul Dubs
Paul hat einen Master of Science in Informatik der TU Darmstadt und verfügt über mehr als ein Jahrzehnt Berufserfahrung in der Softwareentwicklung. Als Open-Source-Entwickler arbeitet er am Deeplearning4J-Projekt mit und wenn Sie jemals eine Frage im Deeplearning4J Gitter gestellt haben, besteht eine gute Chance, dass er Ihre Frage beantwortet hat.
Kommentare

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
4000
  Subscribe  
Benachrichtige mich zu: