Erstelle eine API mit Flask

Download this notebook

Nachdem wir ein eigenes Modell erstellt haben, können wir uns nun damit beschäftigen, wie wir dieses veröffentlichen, um es in verschiedenen Anwendungen nutzen zu können. Als ersten Schritt benötigen wir dazu eine API, die später über das Internet erreichbar sein soll. Es gibt für Python verschiedene Frameworks, die uns bei der Erstellung unterstützen können. Zwei der bekanntesten sind Django und Flask. Wir nutzen in diesem Tutorial Flask, welches sehr schlank ist und dadurch einen schnellen Einstieg ermöglicht.

Wir werden uns jedoch ausschließlich mit der Programmierung des Backends beschäftigen. Über den Browser kann dieses am Ende des Kapitels zwar schon getestet werden, für eine Veröffentlichung wird allerdings ein zusätzliches Frontend, beispielsweise in Form einer Webseite oder App, benötigt, welches dann auf unser Backend zurückgreift.

Was ist Flask?

Welcome to Flask - Flask Documentation

Flask ist ein extrem schlankes Webframework und wird mit einem eigenen Webserver für die Entwicklung ausgeliefert. Dadurch ist es möglich sehr schnell ein erstes eigenes Webinterface bereitzustellen. Die wenigen Vorgaben seitens Flask lassen viel Spielraum für eigene individuelle Lösungen, aber sorgen dadurch auch nicht automatisch für einheitlichen sauberen Code. Flask nutzt die Template Engine Jinja mit der auch ein Frontend gebaut werden könnte. Der mitgelieferte Webserver ist nur für die Entwicklungszeit gedacht und sollte bei der Veröffentlichung durch eine stabilere und sicherere Alternative ersetzt werden. Wir werden zusätzlich das Package Flask CORS einführen, welches nötig ist, wenn Frontend und Backend nicht auf dem gleichen Server laufen.

Flask und Flask CORS können direkt über pip installiert werden:

pip install Flask

pip install flask-cors

Grundgerüst aufbauen

Die gesamte Funktionalität unserer App ist in dem Ordner 3_1_Flask/app gespeichert. Diese werden wir später im Docker benutzen. Die App beinhaltet neben main.py auch die Ordner models, in dem wir unser Modell aus 1_2_Tensorflow abspeichern, und utils, in dem die bekannte Datei utils.py liegt.

Wir bauen und erklären in diesem Tutorial die Datei main.py. Die Datei kann hier in unserem Git eingesehen werden.

Zunächst importieren wir alle benötigten Packages:

# Some model related imports
import pickle
import re
import pandas as pd
import tensorflow as tf

# New Flask related imports
from flask import Flask, request, jsonify
from flask_cors import CORS

# Our own helper function
from utils.utils import str_to_category

Anschließend kann ein neues App-Objekt angelegt werden. Zusätzlich verwenden wir CORS, um unser Backend auch zusammen mit einem separaten Frontend nutzen zu können. Wird das Frontend beispielsweise direkt auch mit der Jinja Template Engine und Flask erstellt, ist dieser Schritt nicht notwendig und das flask_cors Package wird nicht benötigt. Da in den meisten Fällen jedoch eine App oder ähnliches als Frontend dient und es in dem Fall sonst zu Problemen kommen kann, wird dieses Package hier vorgestellt. Alle späteren Anweisungen sind jedoch unabhängig von diesem Schritt.

app = Flask(__name__)
CORS(app)

Grundsätzlich funktioniert Flask sehr einfach. Es können normale Python-Funktionen geschrieben werden, die lediglich mit einem Decorator ergänzt werden müssen. In diesem wird der Pfad angegeben und optional können weitere Einstellungen getätigt werden. Der Rückgabewert der Funktion wird als Antwort auf die Anfrage zurückgesandt. Flask bietet Funktionen wie jsonify() an, die Python Objekte entsprechend vorbereiten und dazu im konkreten Fall in ein JSON-Objekt umwandeln:

@app.route('/hello')
def hello_world():
    return jsonify({'message': 'Hello World!'})

Da wir auch in diesem Fall eine Vorhersage mithilfe der fertig trainierten Modelle machen wollen, können wir uns an unserem Test-Skript (siehe hier) orientieren. Alle benötigten Modelle hatten wir bereits als Dateien gespeichert. Auf sie wird während der Predictions nur noch lesend zugegriffen, weshalb es ausreichend ist, wenn wir sie einmalig zu Beginn einlesen:

# 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 benötigen wir noch eine Schnittstelle, über die Anfragen für eine Preisvorhersage an unseren Server geschickt werden können. Im Gegensatz zu unserer hello_world() Funktion sollen diese Anfragen jedoch auch nutzerspezifische Parameter enthalten.

Eingaben entgegennehmen

Um Eingaben entgegenzunehmen, gibt es verschiedene Möglichkeiten. Eine einfache und häufig sinnvolle Variante ist die explizite Angabe der Parameter als Teil des Pfades. Wir erweitern dazu unser Hello World-Beispiel zu einer individuellen Grüß-Funktion:

@app.route('/hello/<string:name>', methods=['GET'])
def hello_name(name: str):
    return jsonify({'message': f'Hello {name}!'})

Flask erkennt die entsprechenden Teile des Pfades und achtet dabei sogar auf den Datentyp. Die gefundenen Werte werden unserer Funktion dann als normale Parameter übergeben, sodass diese wie bei jeder anderen Python-Funktion einfach verwendet werden können. In unserem Fall erwarten wir jedoch acht verschiedene Eingaben, deren Reihenfolge bei dieser Methode exakt stimmen muss. Wir haben uns deshalb für eine andere Variante entschieden.

Wie in dem Decorator oben bereits zu sehen ist, haben wir dieses Mal einen zusätzlichen Parameter methods=['GET'] gesetzt. Auf diese Weise ist es möglich die HTTP-Methode auszuwählen. Standardmäßig wird bereits GET verwendet, aber es wäre ebenso möglich POST anzugeben oder mehrere Methoden zu erlauben, die dann erst in der Funktion entsprechend bearbeitet werden. Hätten wir als Frontend beispielsweise ein HTML-Formular, würde sich POST eher anbieten. Da wir jedoch noch kein Frontend besitzen, aber unsere API später gerne einfach testen würden, verwenden wir GET. Dadurch können wir unsere Parameter in einem Query-String direkt als Teil unserer URL kodieren und diese mit einem Browser ohne weitere Hilfsmittel aufrufen.

Ein Query-String ist Teil einer URL und kann mehrere benannte Parameter enthalten. Als Markierung des Beginns dient ein ?. Anschließend folgt der Query-String selbst bestehend aus Paaren field1=value1, die mithilfe von & oder ; verbunden werden. Da alle Namen und Werte Teil der URL sind, müssen diese entsprechend kodiert werden. So wird beispielsweise ein Leerzeichen durch + oder %20 ersetzt.

Wir können bei dieser Methode keine genauen Angaben machen, welche Parameter wir brauchen. Stattdessen stellt uns Flask alle übergebenen Parameter in einem Request Object als eine Art Dictionary bereit.

@app.route('/api/car-price', methods=['GET'])
def predict():
    # Get data from request
    data = request.args

Da wir als Eingabe für unser Modell immer alle Parameter benötigen und zudem auch die Datentypen stimmen müssen, überprüfen wir beides bevor wir die Parameter weiterverarbeiten:

if 'model' not in data:
    return jsonify({'message': 'Please provide model'}), 400
elif str_to_category(data['model']) not in model_enc.categories_[0]:
    return jsonify({'message': 'Model not supported'}), 400
else:
    car_model = str_to_category(data['model'])
 
    if 'year' not in data:
            ...

Für das Automodell erwarten wir beispielsweise den Parameter model. Wir prüfen zunächst, ob dieser überhaupt in der Anfrage enthalten ist. Wenn dies der Fall ist, stellen wir anschließend noch sicher, dass das angegebene Automodell von unserem Encoder auch konvertiert werden kann bzw. es in unseren Trainingsdaten enthalten war. Ist eine der Bedingungen nicht erfüllt, können wir mit Flask einfach eine Fehlermeldung mit einem entsprechenden Fehlercode zurückgeben. Zusätzlich sollten wir das Problem kurz und konkret benennen, um die spätere Benutzung zu vereinfachen.

Kommt es während des Programmablaufs zu unerwarteten Fehlern, sodass unsere Methode nicht korrekt beendet werden kann, kümmert sich Flask darum, eine Fehlermeldung an den Client zurückzusenden. Dabei handelt es sich meist um den Fehlercode 500 Internal Server Error. Da (außerhalb des Debug-Modus) ansonsten jedoch keine genauere Fehlerbeschreibung übermittelt wird, ist eine genauere Identifikation des Problems für den Client nicht möglich (aber häufig natürlich auch nicht gewünscht). Wir wollen in unserem Fall den Client auf eine fehlerhafte Eingabe hinweisen und ihn möglichst bei der Korrektur unterstützen. Dafür verwenden wir den Fehlercode 400 Bad Request und ergänzen eine konkrete fehlerspezifische Nachricht.

Ist der Parameter vorhanden und gültig, konvertieren und speichern wir diesen zwischen, um auch die weiteren Parameter analog zu prüfen. Haben wir dies für alle Parameter getan, können wir unser Dataframe konstruieren und mithilfe unseres Modells einen möglichen Preis vorhersagen:

# Create dataframe
df = pd.DataFrame({
    'model': [car_model],
    'year': [year],
    'transmission': [transmission],
    'mileage': [mileage],
    'fuelType': [fuel_type],
    'tax': [tax],
    'mpg': [mpg],
    'engineSize': [engine_size]
})
 
# Encode and scale data
df.loc[:, 'model'] = model_enc.transform(df.loc[:, ['model']])
df.loc[:, 'transmission'] = transmission_enc.transform(df.loc[:, ['transmission']])
df.loc[:, 'fuelType'] = fuelType_enc.transform(df.loc[:, ['fuelType']])
df = data_scaler.transform(df)
 
# Predict
result = model(df)
result = label_scaler.inverse_transform(result)[0, 0]
result = round(max(result, 0.0), 2)
return jsonify({'predicted_price': result})

Die Schritte hierfür sind nahezu identisch zu unserem Test-Skript (siehe hier), weshalb wir auf diese nicht weiter eingehen. Lediglich die letzten beiden Zeilen haben wir ergänzt, um die Rückgabe unseres Servers etwas schöner zu machen. So runden wir unsere Vorhersage auf zwei Nachkommastellen und setzen zudem negative Vorhersagen auf 0. Den resultierenden Preis verpacken wir zudem wieder in ein JSON-Dictionary. Natürlich können hier jedoch auch vollkommen andere Antworten konstruiert werden. Es ist lediglich wichtig, auf eine Absprache zwischen Front- und Backend zu achten.

Flask ausführen

Unsere neu geschriebene API können wir dank des in Flask mitgelieferten Webservers direkt auf unserem Computer testen. Hierfür reicht der Aufruf von run() auf unserem zu Beginn erzeugten app-Objekt:

if __name__ == '__main__':
    app.run(debug=True)

Wir können unser Skript dann einfach wie jedes andere Python-Skript ausführen. Während der Entwicklung ist es zudem sinnvoll den Parameter debug=True zu setzen. Dadurch zeigt Flask bei Problemen die komplette Fehlermeldung direkt im Browser an und versteckt diese nicht hinter dem zuvor erwähnten generischen Server Error. Wird kein anderer Port angegeben, läuft der Flask Server üblicherweise auf Port 5000. Du erreichst diesen also unter localhost:5000. Falls du keine Methode für den Root-Pfad / geschrieben hast, wirst du unter dieser Adresse allerdings eine Fehlermeldung erhalten. Du kannst jedoch einfach einen entsprechenden Pfad ergänzen. Für die URL http://localhost:5000/hello/REACH erhalten wir beispielsweise die folgende Antwort:

{
  "message": "Hello REACH!"
}

Der Server ist bisher jedoch nur auf deinem lokalen Computer verfügbar. Um dies zu ändern, kannst du einen Host angeben. Die Adresse 0.0.0.0 sorgt dafür, dass dein Server über alle öffentlichen IP-Adressen deines Systems erreichbar sein soll. Dies solltest du natürlich nur tun, wenn du den anderen Teilnehmenden in deinem Netzwerk vertraust. Abhängig von den sonstigen Netzwerkeinstellungen kannst du dann jedoch auch von anderen Geräten in deinem Netzwerk deinen Server aufrufen.

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

Wir können nun unser Modell testen, indem wir eine entsprechende Anfrage als Query-String senden (wir benutzen hier dieselben Eingaben wie im Tensorflow-Tutorial): http://localhost:5000/api/car-price?model=T-Roc&year=2019&transmission=manual&mileage=12123&fuelType=petrol&tax=145&mpg=42.7&engineSize=2.0. Als Ausgabe erhalten wir wie erwartet:

{
  "predicted_price": 26004.03
}

Bei ungültigen Eingaben (http://localhost:5000/api/car-price?model=Cybertruck) erhalten wir stattdessen den Fehlercode 400 und unsere zuvor definierte Fehlermeldung:

{
  "message": "Car model not supported"
}

Produktionsserver

Statt über einen Methodenaufruf im Skript kannst du deinen Server auch über einen eigenen Flask-Befehl in der Kommandozeile starten. Hierzu musst du jedoch zunächst den Namen deines Skripts in einer Environment-Variable FLASK_APP hinterlegen. Der Befehl lautet anschließend einfach flask run.

Da der mitgelieferte Server jedoch nur für die Entwicklung gedacht ist und schlecht skaliert, sollten wir diesen durch einen anderen Server ersetzen. Es gibt zahlreiche Alternativen, wir werden Gunicorn verwenden, da dieser gut zusammen mit Flask funktioniert und ebenfalls einfach einzurichten ist. Der Server funktioniert nur auf UNIX Systemen und kann dort einfach mit pip installiert werden. Nachdem wir in den Ordner /app gewechselt sind, reicht anschließend ein einzelner Aufruf in der Kommandozeile, ähnlich zu dem Flask-eigenen Server:

pip install gunicorn
gunicorn --bind 0.0.0.0:5000 main:app

Da wir unseren Server später innerhalb eines Docker Containers und ohnehin nicht auf unserem eigenen Computer betreiben wollen, ist es nicht schlimm, wenn die Entwicklung auf einem nicht-UNIX System erfolgt oder Gunicorn auf deiner lokalen Maschine nicht funktioniert.