KI-Training: Verlässliche Ergebnisse mit Cross Validation

Um Aussagen über die Leistung einer künstlichen Intelligenz zu treffen, können verschiedene Parameter herangezogen werden. Häufig werden hier die Genauigkeit, loss (zu Deutsch in etwa “Fehler”), oder der F1-Score verwendet. Unabhängig von der Metrik können hier jedoch handwerkliche Fehler gemacht werden, die die Aussagekraft stark mindern.

Training und Validation

Der Fehler (loss) eines neuronalen Netzes gibt zwar Aufschluss darüber, wie gut es eine Aufgabe erledigt, ist aber meistens wenig anschaulich. Für diesen Blogpost möchte ich noch die Genauigkeit (accuracy) des Netzes betrachten. Diese Metrik ist natürlich nur für Klassifikationsanwendungen sinnvoll, also für Probleme, bei denen beispielsweise ein Bild einer Kategorie zugeordnet wird.

In diesem Blogpost möchte ich die Thematik am Beispiel des Digits Datasets des UCI Machine Learning Repository verdeutlichen. Das Digit Dataset enthält 1797 Bilder von handgeschriebenen Zahlen, jeweils 8×8 Pixel groß. Diese Problemstellung bietet sich für einen Blogpost an, da sie auch auf einer CPU gut berechenbar ist. Das Modell das nötig ist, um die Daten klassifizieren zu können ist denkbar klein:

def get_model():
    # we need a layer that acts as input.
    # shape of that input has to be known and depends on data.
    input_layer = layers.Input(shape=(config.NUM_FEATURES,))

    # hidden layers are the model's power to fit data.
    # number of neurons and type of layers are crucial.
    # idea behind decreasing number of units per layer:
    # increase the "abstraction" in each layer...
    hidden_layer = layers.Dense(units=64)(input_layer)
    hidden_layer = layers.Dense(units=32)(hidden_layer)

    # last layer represents output.
    # activation of each neuron corresponds to the models decision of
    # choosing that class.
    # softmax ensures that all activations summed up are equal to 1.
    # this lets one interpret that output as a probability
    output_layer = layers.Dense(units=config.NUM_DIGITS, activation='softmax')(hidden_layer)

    # actual creation of the model with in- and output layers
    model = engine.Model(inputs=[input_layer], outputs=[output_layer])
    # transform into a trainable model by specifying the optimizing function
    # (here stochastic gradient descent),
    # as well as the loss (eg. how big of an error is produced by the model)
    # track the model's accuracy as an additional metric (only possible for classification)
    model.compile(optimizer='sgd', loss='categorical_crossentropy', metrics=['accuracy'])

    return model

Dieses Modell kann nun auf dem Digits Datenset trainiert werden. Der folgende Codeausschnitt ist der Übersichtlichkeit halber etwas gekürzt, den kompletten Quellcode finden Sie auf unserem Github.

# seed the random generator, for reproducible results
from numpy import random
random.seed(1337)
# same for tensorflow
import tensorflow as tf
tf.set_random_seed(42)

from datetime import datetime
from os import path

import numpy
from PIL import Image
from keras import callbacks
from sklearn import datasets

from digits.models.mlp import get_model

from digits.util import image_to_ndarray
from digits.config import NUM_DIGITS, MAX_FEATURE

# load prepared data set containing 1797 digits as 8x8 images
digit_features, digit_classes = datasets.load_digits(n_class=NUM_DIGITS, return_X_y=True)
num_samples = digit_classes.shape[0]

# normalize features, see documentation of sklearn.datasets.load_digits!
# neural networks work best with normalized data
digit_features /= MAX_FEATURE

# we need so called "one-hot" vectors
# one-hots are vectors, where all entries are 0 except the target class, which is 1
digit_labels = numpy.zeros(shape=(num_samples, NUM_DIGITS))
for index, digit_class in enumerate(digit_classes):
    digit_labels[index][digit_class] = 1.

# get a neural net, that can fit our problem
model = get_model()

# training the model
model.fit(
    train_x, train_y,
    batch_size=32, epochs=30, 
    validation_split=.2
)

Für die Reproduzierbarkeit von Modelltrainings sind random.seed(1337) sowie tf.set_random_seed(42) sehr wichtig. Bevor ein neuronales Netz trainiert wird, werden alle Modellparameter mit zufälligen Werten initialisiert, sowie das Datenset (zufällig) gemischt. Würde man also ohne diese beiden Zeilen das gleiche Modell mehrfach auf den gleichen Daten trainieren, erhielte man jedes Mal unterschiedliche Ergebnisse. Dies stellt natürlich ein Problem dar, wenn man verschiedene Netzarchitekturen vergleichen möchte (vgl. unser Artikel zur Suche von optimalen Hyperparametern mittels Grid Search).

Lässt man dieses Training laufen kann man das Ergebnis folgendermaßen (hier mittels TensorBoard) visualisieren:

Diese Graphen stellen nur für die Werte während des Trainings dar, interessanter für die tatsächliche Performance des Netzes sind die Werte während der Validierungsphase, also auf Daten, die das Netz vorher noch nie gesehen hat.

Auf den ersten Blick sehen diese Ergebnisse schon sehr vielversprechend aus, die Genauigkeit des Modell bewegt sich sogar schon nach kurzer Zeit im Bereich von 90% und aufwärts. Dennoch muss man das Ganze auch kritisch beäugen: Es wurde lediglich ein Durchlauf gemacht, und nachdem das Aufteilen der Daten in Trainings- und Validationsset zufällig geschehen ist, kann man nicht sicher sein, dass für andere Aufteilungen nicht schlechtere Ergebnisse erzielt würden.

Verlässliche Ergebnisse mit Cross Validation

Die Idee für Cross Validation ist, nun mehrere Durchläufe auf dem gleichen Datenset zu machen, es dabei aber jedes mal unterschiedlich in Trainins- und Validationsset zu teilen. Je nachdem, wie man die Aufteilung durchführt, spricht man auch von k-fold Cross Validation oder von one-out Cross Validation. Im ersten Fall teilt man das Datenset in k gleichgroße Blöcke, auch folds genannt.

Man trainiert jetzt das Modell k-mal und reserviert in jedem Durchlauf einen der folds für die Validierung und nutzt die restlichen für das Training. Dieses Verfahren ist ähnlich zum sogenannten bootstrapping, allerdings werden hier Datenpunkte nicht doppelt verwendet. Zwischen jedem Durchlauf wird das Modell auf den Ursprungszustand zurückgesetzt. Mit diesen Anpassungen sieht der Trainingsvorgang nun wie folgt aus.

# seed the random generator, for reproducible results
from numpy import random
random.seed(1337)
# same for tensorflow
import tensorflow as tf
tf.set_random_seed(42)

from datetime import datetime
from os import path

import numpy
from PIL import Image
from sklearn import datasets

from sklearn.model_selection import KFold

from digits.models.mlp import get_model

from digits.util import image_to_ndarray
from digits.config import NUM_DIGITS, MAX_FEATURE

# load prepared data set containing 1797 digits as 8x8 images
digit_features, digit_classes = datasets.load_digits(n_class=NUM_DIGITS, return_X_y=True)
num_samples = digit_classes.shape[0]

# normalize features, see documentation of sklearn.datasets.load_digits!
# neural networks work best with normalized data
digit_features /= MAX_FEATURE

# we need so called "one-hot" vectors
# one-hots are vectors, where all entries are 0 except the target class, which is 1
digit_labels = numpy.zeros(shape=(num_samples, NUM_DIGITS))
for index, digit_class in enumerate(digit_classes):
    digit_labels[index][digit_class] = 1.

# get a neural net, that can fit our problem and remember its initial weights
model = get_model()
initial_weights = model.get_weights()

# initialize the cross validation folds api
kfold = KFold(6, shuffle=True, random_state=42)

# iterate over all possible fold combinations
fold = 0
for train, test in kfold.split(digit_features):
    # split the data into features and labels depending on the fold
    train_x, train_y = digit_features[train], digit_labels[train]
    test_x, test_y = digit_features[test], digit_labels[test]

    # reset the model's weights
    model.set_weights(initial_weights)

    # training the model
    model.fit(
        train_x, train_y,
        batch_size=32, epochs=30, 
        validation_split=.0, 
        validation_data=(test_x, test_y)
    )

    fold += 1

Für unser Beispiel ist k=6, wir erhalten also sechs Trainingsläufe, die wir genau wie im ersten Beispiel visualisieren können.

Keiner der sechs Trainingsläufe fällt hier durch besonders schlechte Genauigkeits- oder Fehlerwerte auf, man kann also nun mit großer Sicherheit sagen, dass das Modell die Daten sehr gut abbildet.

Die Wahl von k erfolgt nicht nach festen Regeln, dennoch ist k=10 ein Wert, der sich im Lauf der Jahre bewiesen hat und einen guten Mittelweg zwischen Rechenaufwand und Zuverlässigkeit darstellt.

Zusammenfassung

Man kann k-fold crossvalidation einsetzen, um Befangenheit (bias) zu entfernen, welche durch die Auswahl von Trainings- und Validationsdatenset eingeführt werden kann. Besonders hilfreich ist diese Technik bei kleinen und mittelgroßen neuronalen Netzen. Bei riesigen Netzen, wie beispielsweise Inception V3, ist der gesteigerte Trainingsaufwand nicht praktikabel.

Wenn Sie Anmerkungen, Fragen oder Ideen zu unserem Artikel haben, freuen wir uns immer über eine Mail an blog@neuroforge.de.