Dieser Teil ist eine Ergänzung zu Trainiere ein eigenes Modell mit PhotonAI, da PhotonAI für viele spezifischere Anwendungsfälle nicht geeignet ist. Wenn das eigene Problem bereits zufriedenstellend mit einem PhotonAI Modell gelöst werden konnte, kann dieses Kapitel also übersprungen werden. Andererseits wird die Erstellung eines Modells in diesem Kapitel noch knapper behandelt und es kann durchaus sinnvoll sein, zunächst das vorherige Kapitel zu lesen. Abgesehen davon handelt es sich ebenfalls nicht um eine Einführung in TensorFlow oder die Erstellung von Machine Learning Modellen allgemein. Vielmehr soll lediglich ein einfaches Modell erstellt werden, das in den folgenden Teilen als Beispiel im Deployment- und Veröffentlichungs-Prozess dient. Die TensorFlow Doku bietet jedoch zahlreiche Tutorials und Erklärungen, um mehr über die Erstellung von Machine Learning Modellen mit TensorFlow zu lernen.

Inhaltsverzeichnis

Was ist TensorFlow?

TensorFlow

TensorFlow ist ein Framework, das insbesondere die Verarbeitung mehrdimensionaler Daten und das Training von tiefen Neuronalen Netzen ermöglicht. Es werden Schnittstellen zu zahlreichen Programmiersprachen angeboten, sodass eine Verwendung der fertigen Modelle beispielsweise auch auf mobilen Endgeräten möglich ist. Durch die hohe Anzahl verfügbarer Funktionen und Layer können individuelle und sehr spezifische Anforderungen erfüllt werden. Im direkten Vergleich zu PhotonAI ist die Verwendung dadurch jedoch auch komplexer und erfordert eine längere Einarbeitungszeit. Mit der Version 2.0 wurde Keras allerdings zur Standard-API und die Benutzung vereinfacht.

Für Python kann TensorFlow direkt über pip installiert werden:

pip install tensorflow


Um das Deployment später einfacher zu machen und Kompatibilitätsprobleme zu vermeiden, ist es immer ratsam innerhalb einer virtuellen Umgebung zu arbeiten. Python bietet hierfür viele Möglichkeiten. Wir verwenden *Virtualenv* bzw. das seit Python 3.3 mitgelieferte Modul *[venv](https://docs.python.org/3/library/venv.html)*. Eine andere bekannte Alternative ist *Anaconda*.

Vorbereitung der Daten

Wie auch schon bei PhotonAI nutzen wir zum Einlesen unserer Daten die Methode read_csv() aus dem Python-Package Pandas. Da wir in diesem Fall auch Spalten verwenden möchten, die nicht-nummerische Werte enthalten, bereiten wir die entsprechenden Werte schon beim Einlesen vor. Über den Parameter converters können Funktionen angegeben werden, die dann auf jeden einzelnen Wert einer Spalte angewandt werden. Hier haben wir uns eine eigene Funktion str_to_category() definiert, die vorhandene Leerzeichen am Anfang und Ende der Strings entfernt, alles in Kleinbuchstaben konvertiert und noch ein paar andere Schritte unternimmt. Anschließend entfernen wir die Spalte price aus unseren Daten und speichern sie stattdessen separat als label.

Data preprocessing
# Load data and split into labels and features
data = pd.read_csv('../data/vw.csv', converters={
    'model': str_to_category,
    'transmission': str_to_category,
    'fuelType': str_to_category,
})
label = data.pop('price')
Unsere Funktion, die wir auf den Strings aufrufen, ist sehr einfach, aber kann natürlich beliebig erweitert werden:
def str_to_category(string):
    """
    Converts a string to a category.
    """
    return string.strip(' \t\n').lower().replace(' ', '_').replace('-', '')

Da unser Modell nur Zahlen verarbeiten kann, müssen wir auch unsere kategorischen Variablen in eben solche umwandeln. Dazu können wir den OrdinalEncoder() aus dem Python-Package sklearn verwenden. Dieser nummeriert alle gefundenen Kategorien durch und sortiert diese im Anschluss jeweils durch ihre eindeutige Nummer. Auch wenn dies eine einfache Möglichkeit darstellt, kategorische Daten in Zahlen umzuwandeln, sollte für jeden Anwendungsfall der Sinn einer solchen Umwandlung geprüft werden, da auf diese Weise implizit Ähnlichkeiten zwischen Kategorien suggeriert werden, die nicht korrekt sind. Selbst in diesem Beispiel sind die zugeordneten Zahlen nicht ganz richtig, weil eine eindeutige Sortierung der verschiedenen Automodelle nicht existiert. Alternativ kann ein OneHotEncoder() verwendet werden, der den Kategorien Vektoren mit jeweils nur einem Eintrag zuordnet und dadurch sicherstellt, dass die Distanz zwischen je zwei Kategorien immer gleich ist. Für unser einfaches Beispiel mit wenigen Kategorien belassen wir es jedoch bei dem OrdinalEncoder().

# Encode categorical data
model_enc = preprocessing.OrdinalEncoder()
data.loc[:, 'model'] = model_enc.fit_transform(data.loc[:, ['model']])
transmission_enc = preprocessing.OrdinalEncoder()
data.loc[:, 'transmission'] = transmission_enc.fit_transform(data.loc[:, ['transmission']])
fuelType_enc = preprocessing.OrdinalEncoder()
data.loc[:, 'fuelType'] = fuelType_enc.fit_transform(data.loc[:, ['fuelType']])

Um unser Modell am Ende bewerten zu können, müssen wir einen Teil unserer Daten im Vorfeld abspalten. Wir verwenden 20% unserer Daten nicht für das Training, um mit ihnen anschließende eine Evaluation durchführen zu können. Bei der Aufteilung ist es wichtig, die Reihenfolge der Feature und Label zu erhalten, damit diese weiter korrekt zugeordnet werden können. Zum Glück hilft uns hierbei die Funktion train_test_split() aus sklearn. Sie ordnet die einzelnen Datenpunkte zufällig dem Trainings- bzw. Test-Datensatz zu, sodass am Ende das gewünschte Größen-Verhältnis entsteht und achtet dabei darauf, die Pärchen aus Feature und Label zu erhalten. Damit wir bei mehreren Durchläufen, beispielsweise nach der Anpassung unseres Modells, immer den gleichen Test-Datensatz verwenden und so die Ergebnisse vergleichbar sind, gibt es die Möglichkeit über den Parameter random_state einen Seed zu setzen. Voraussetzung ist natürlich, dass sich die Eingabe der Funktion dabei nicht ändert.

# Split data into training and test sets
train_data, test_data, train_label, test_label = train_test_split(data, label, test_size=0.2, random_state=42)

Als Letztes müssen wir unsere Daten und Label noch skalieren. Hierzu verwenden wir den StandardScaler() ebenfalls aus sklearn. Durch die Skalierung können wir sicherstellen, dass nicht einzelne Feature alleine durch ihre Größe das Modell dominieren. Die Parameter für den Scaler wählen wir ausschließlich anhand der Trainingsdaten (siehe fit_transform() statt nur transform()). Dies ist wichtig, um eine korrekte Evaluation zu gewährleisten. Später im produktiven Einsatz ist es nämlich ebenfalls nicht möglich einen Scaler anhand eines einzelnen Datenpunktes zu definieren. Zudem würden die Ergebnisse durch einen Scaler, der für einen anderen Bereich definiert wurde, verfälscht.

# Normalize data
data_scaler = preprocessing.StandardScaler()
train_data = data_scaler.fit_transform(train_data)
test_data = data_scaler.transform(test_data)

# Normalize labels
label_scaler = preprocessing.StandardScaler()
train_label = label_scaler.fit_transform(train_label.values.reshape(-1, 1))
test_label = label_scaler.transform(test_label.values.reshape(-1, 1))

Erstellung und Training des Modells

Anders als bei dem PhotonAI-Beispiel werden wir keine Hyperparametersuche durchführen und auch nicht mehrere Modelle vergleichen. Wir definieren stattdessen nur ein konkretes Modell. Da wir nur acht Feature besitzen, entscheiden wir uns für ein einfaches Multilayer-Perceptron (MLP) mit zwei Hidden-Layern der Größe 64 und mit ReLU-Aktivierung:


# Create the model
model = Sequential([
    layers.InputLayer(input_shape=(train_data.shape[1],)),
    layers.Dense(64, activation=tf.nn.relu),
    layers.Dense(64, activation=tf.nn.relu),
    layers.Dense(1)
])

Bevor wir das Modell trainieren können, müssen wir es noch kompilieren. Dabei müssen wir auch einen Optimizer und eine Loss-Funktion angeben. Mit dem Parameter metrics können wir weitere Metriken angeben, die wir dann während des Trainings zur Bewertung unseres Modells verwenden können. Die Methode summary() gibt auf der Kommandozeile eine Zusammenfassung des Modells aus. Auf diese Weise kann beispielsweise die Zahl der trainierbaren Parameter nochmal kontrolliert werden.

# Compile the model
model.compile(optimizer='adam', loss='mse', metrics=['mae'])
model.summary()

Anschließend ist das Modell bereit für das Training. Neben der Batch Size und der Epochen Anzahl können wir hier auch die Größe eines Validation Sets angeben. Nach jeder Epoche werden der Loss und die anderen Metriken auch auf diesen Daten berechnet, um zum Beispiel Overfitting rechtzeitig erkennen zu können.

# Train the model
model.fit(train_data, train_label, batch_size=64, epochs=50, validation_split=0.2)

Während des Trainings werden wir in der Konsole regelmäßig über den Trainingsfortschritt informiert. Dadurch können wir einschätzen wie lange das Training noch etwa braucht und kontrollieren, dass der Loss tatsächlich abnimmt und unser Modell konvergiert.

Evaluation und Speicherung

Sobald das Training abgeschlossen ist, können wir die zuvor zur Seite gelegten Test-Daten dazu nutzen, die Qualität unseres Modells zu evaluieren. Um unabhängige und vergleichbare Ergebnisse zu bekommen, ist es wichtig, dass wir diese Daten weder direkt noch indirekt (wie die Validation-Daten als Abbruchbedingung) während des Trainings verwendet haben. Die Berechnung der Scores übernimmt TensorFlow für uns und benötigt dazu lediglich die Test-Daten zusammen mit den Ground Truth-Labeln.

# Evaluate the model
model.evaluate(test_data, test_label)

Als Ausgabe erhalten wir unseren Loss sowie die Ergebnisse der anderen angegebenen Metriken. Diese Werte geben uns zwar einen guten Anhaltspunkt für die Qualität unseres Modells, mithilfe des Python-Packages matplotlib können wir die Ergebnisse jedoch auch plotten, um diese anschaulicher darzustellen. Anders als bei PhotonAI müssen wir uns hier selber um sinnvolle Darstellungen kümmern. Da wir in diesem Beispiel ein Regressions-Modell trainiert haben, ist die Darstellung als Scatterplot hilfreich. Bei einem Klassifikations-Modell wäre dagegen eine Confusion-Matrix noch einfacher lesbar.

Während des Preprocessings haben wir unseren Daten und Label normalisiert. Damit wir die Ergebnisse in unserem Plot besser einordnen können, sollten wir die Normalisierung nun rückgängig machen. Die Scaler besitzen dazu praktischerweise die Methode inverse_transform(), die wir einfach für unsere Predictions und Ground Truth-Label aufrufen können. Zusätzlich plotten wir als Orientierung noch eine Linie, welche die optimalen Predictions angibt.

# Plot some predictions
predictions = model.predict(test_data)
predictions = label_scaler.inverse_transform(predictions)
test_label = label_scaler.inverse_transform(test_label)
plt.scatter(test_label, predictions, s=0.1)
plt.plot([0, test_label.max()], [0, test_label.max()], '--', color='red')
plt.xlabel('True Values')
plt.ylabel('Predictions')
plt.show()


Anhand des erstellten Plots können wir einfach erkennen, dass die vorhergesagten Preise unseres Modells ungefähr den tatsächlichen Preisen entsprechen. Bei höherpreisigen Fahrzeugen nehmen die Abweichungen aufgrund von weniger Datenpunkten zwar zu, für unseren Anwendungsfall reicht uns die Qualität allerdings.

Damit wir das Modell nun zu einem späteren Zeitpunkt verwenden können ohne es neu trainieren zu müssen, ist es notwendig dieses manuell zu speichern. Doch nicht nur unser trainiertes Modell muss gespeichert werden. Damit wir die Eingabedaten korrekt vorbereiten können, brauchen wir auch alle Encoder (zur Konvertierung der kategorischen Variablen in Zahlen) und alle Scaler (zur Normalisierung unserer Feature und Label). Alle diese Objekte müssen wir speichern. Dazu können wir entweder ein neues Objekt konstruieren, dass alle benötigten Dinge enthält, oder wir speichern sie als einzelne Dateien. Wir haben uns für letztere Möglichkeit entschieden.

# Save the model, encoder and scaler
Path('../models/').mkdir(parents=True, exist_ok=True)
model.save('../models/model.h5')
pickle.dump(model_enc, open('../models/model_enc.pkl', 'wb'))
pickle.dump(transmission_enc, open('../models/transmission_enc.pkl', 'wb'))
pickle.dump(fuelType_enc, open('../models/fuelType_enc.pkl', 'wb'))
pickle.dump(data_scaler, open('../models/data_scaler.pkl', 'wb'))
pickle.dump(label_scaler, open('../models/label_scaler.pkl', 'wb'))

Verwendung des Modells

Analog zur Speicherung unserer Objekte müssen wir diese nun zunächst wieder aus den einzelnen Dateien laden:

# Load model, encoder and scaler
model = tf.keras.models.load_model('../models/model.h5')
model_enc = pickle.load(open('../models/model_enc.pkl', 'rb'))
transmission_enc = pickle.load(open('../models/transmission_enc.pkl', 'rb'))
fuelType_enc = pickle.load(open('../models/fuelType_enc.pkl', 'rb'))
data_scaler = pickle.load(open('../models/data_scaler.pkl', 'rb'))
label_scaler = pickle.load(open('../models/label_scaler.pkl', 'rb'))

Anschließend definieren wir eine eigene Eingabe und bereiten diese mithilfe der Encoder und Scaler für unser trainiertes MLP vor. Damit wir die verschiedenen Feature nicht vertauschen, erzeugen wir wieder ein Pandas-Dataframe und benennen alle Spalten. Stattdessen könnten wir auch nur ein Numpy-Array mit den Werten anlegen. Das Modell hat abgesehen von der Reihenfolge jedoch keine Möglichkeit zu überprüfen, ob unsere Werte tatsächlich zu dem jeweiligen Feature passen. Deshalb sollte drauf geachtet werden, die Feature Reihenfolge aus dem Trainingsdatensatz zu erhalten.

# Define and prepare test data
dummy_data = pd.DataFrame({
    'model': [str_to_category('T-Roc')],
    'year': [2019],
    'transmission': [str_to_category('Manual')],
    'mileage': [12132],
    'fuelType': [str_to_category('Petrol')],
    'tax': [145],
    'mpg': [42.7],
    'engineSize': [2.0]
})
dummy_data.loc[:, 'model'] = model_enc.transform(dummy_data.loc[:, ['model']])
dummy_data.loc[:, 'transmission'] = transmission_enc.transform(dummy_data.loc[:, ['transmission']])
dummy_data.loc[:, 'fuelType'] = fuelType_enc.transform(dummy_data.loc[:, ['fuelType']])
dummy_data = data_scaler.transform(dummy_data)

Drei unserer Feature enthalten zudem kategorische Daten statt Zahlen, die wir erst mithilfe der Encoder in Zahlen konvertiert haben. Bei der Erzeugung der Encoder wurden alle Kategorien aufgenommen, die in den Trainingsdaten enthalten waren. Dadurch können sie nun auch nur noch diese Kategorien in Zahlen übersetzen. Bei der Eingabe neuer Daten in das Modell, sollte dies berücksichtigt werden. Auch die genaue Schreibweise und eventuelle Leerzeichen am Anfang oder Ende führen dazu, dass die Encoder eine Kategorie nicht mehr erkennen. Für das Einlesen unserer Trainingsdaten hatten wir uns jedoch schon eine Funktion str_to_category() definiert, die für uns viele Stolperfallen entfernt. Falls wir uns trotzdem nicht mehr sicher sind, welche Kategorien zur Auswahl stehen, können wir uns mit dem Attribut categories_ alle Kategorien anzeigen lassen, die ein Encoder kennt:

>> import pickle
>> fuelType_enc = pickle.load(open('../models/fuelType_enc.pkl', 'rb'))
>> print(fuelType_enc.categories_)
[array(['diesel', 'hybrid', 'other', 'petrol'], dtype=object)]

Abschließend können wir unsere kodierten und normalisierten Daten an unser Modell übergeben, welches basierend darauf eine Vorhersage berechnet. Diese Vorhersage müssen wir nur noch mit dem Label-Scaler zurückskalieren, um den Preis zu erhalten:

# Predict
result = model(dummy_data)
result = label_scaler.inverse_transform(result)[0, 0]
print(result)

Als Ergebnis wird etwa 27516.51 ausgegeben, was nah an dem Ergebnis des PhotonAI-Modells liegt und ebenfalls einem realistischen Preis entspricht.

Wir konnten mit TensorFlow also ein eigenes Modell entwerfen, trainieren und nun sogar verwenden. Dafür waren zwar deutlich mehr manuelle Schritte notwendig, die uns zuvor von der PhotonAI Hyperpipe abgenommen wurden, im Ausgleich konnten wir jedoch auch kategorische Feature für unsere Vorhersage nutzen.

  • Keine Stichwörter