Im Entwurfsmuster Workflow-Pipeline gehen wir das Problem an, eine durchgängig reproduzierbare Pipeline zu erzeugen, indem die Schritte in unserem ML-Prozess containerisiert und orchestriert werden. Die Containerisierung lässt sich explizit bewerkstelligen oder mithilfe eines Frameworks, das den Vorgang vereinfacht.
Ein einzelner Data Scientist mag in der Lage sein, die Schritte von Datenaufbereitung, Training und Modellbereitstellung (dargestellt in Abbildung 6-6) von Anfang bis Ende mit einem einzigen Skript oder Notebook zu absolvieren. Wenn jedoch jeder Schritt in einem ML-Prozess komplexer wird und mehr Personen in einer Organisation an dieser Codebasis mitwirken wollen, lässt sich die Ausführung dieser Schritte in einem einzelnen Notebook nicht mehr skalieren.
Abbildung 6-6: Die Schritte in einem typischen End-zu-End-ML-Workflow. Diese Darstellung ist nicht allumfassend, beinhaltet aber die häufigsten Schritte im ML-Entwicklungsprozess.
In der herkömmlichen Programmierung beschreibt man monolithische Anwendungen als solche, bei denen die gesamte Logik der Anwendung von einem einzigen Programm realisiert wird. Um ein kleines Feature in einer monolithischen App zu testen, müssen wir das gesamte Programm ausführen. Gleiches gilt für die Bereitstellung oder das Debuggen von monolithischen Anwendungen. Um einen kleinen Bugfix für einen Teil des Programms bereitzustellen, müssen Sie die gesamte Anwendung bereitstellen, was schnell unhandlich werden kann. Wenn sämtliche Teile der Codebasis untrennbar miteinander verknüpft sind, wird es für einzelne Entwickler:innen schwierig, Fehler zu beheben und unabhängig an verschiedenen Teilen der Anwendung zu arbeiten. In den letzten Jahren wurden monolithische Anwendungen zugunsten einer Microservices-Architektur ersetzt, bei der einzelne Teile der Geschäftslogik als isolierte (Mikro-)Codepakete gebaut und bereitgestellt werden. Mit Microservices wird eine große Anwendung in kleinere, besser handhabbare Teile zerlegt, sodass Entwickler:innen Teile einer Anwendung unabhängig voneinander erstellen, debuggen und bereitstellen können.
Diese Diskussion von Monolith versus Microservice bietet eine gute Analogie, wenn es darum geht, ML-Workflows zu skalieren, Zusammenarbeit zu ermöglichen und sicherzustellen, dass ML-Schritte über verschiedene Workflows reproduzierbar und wiederverwendbar sind. Wenn jemand ein ML-Modell allein aufbaut, ist die Iteration bei einem »monolithischen« Ansatz möglicherweise schneller. Oftmals funktioniert er auch, wenn eine Person aktiv an der Entwicklung und Verwaltung jedes Teils beteiligt ist: Erfassung und Vorverarbeitung der Daten, Modellentwicklung, Training und Bereitstellung. Wird aber dieser Workflow skaliert, könnten verschiedene Menschen oder Gruppen in einer Organisation für unterschiedliche Schritte zuständig sein. Um den ML-Workflow zu skalieren, brauchen wir eine Möglichkeit für das Team, das das Modell entwickelt, Versuche unabhängig vom Schritt der Datenvorverarbeitung auszuführen. Darüber hinaus müssen wir die Performance für jeden Schritt der Pipeline verfolgen und die von jedem Teil des Prozesses erzeugten Ausgabedateien verwalten.
Wenn die anfängliche Entwicklung für jeden Schritt abgeschlossen ist, müssen wir darüber hinaus Vorgänge wie erneutes Training planen oder ereignisgesteuerte Pipeline-Läufe erzeugen, die als Reaktion auf Änderungen in Ihrer Umgebung aufgerufen werden, weil zum Beispiel neue Trainingsdaten zu einem Bucket hinzugefügt werden. In derartigen Fällen wird es für die Lösung erforderlich sein, dass wir den gesamten Workflow von Anfang bis Ende in einem Aufruf ausführen können und dabei trotzdem noch in der Lage sind, die Ausgabe und die Fehler von einzelnen Schritten zu verfolgen.
Um die Probleme in den Griff zu bekommen, die mit der Skalierung von ML-Prozessen zusammenhängen, können wir jeden Schritt in unserem ML-Workflow zu einem separaten, containerisierten Dienst machen. Container garantieren, dass wir denselben Code in verschiedenen Umgebungen ausführen können und dass das Verhalten zwischen den einzelnen Läufen konsistent ist. Diese einzelnen containerisierten Schritte werden dann miteinander verkettet, sodass eine Pipeline entsteht, die sich mit einem REST-API-Aufruf ausführen lässt. Da Pipeline-Schritte in Containern laufen, können wir sie auf einem Entwicklungslaptop, mit einer lokalen Infrastruktur oder mit einem gehosteten Cloud-Dienst ausführen. Dieser Pipeline-Workflow erlaubt den Teammitgliedern, Pipeline-Schritte unabhängig voneinander zu entwickeln. Container ermöglichen es auch, eine komplette Pipeline von Anfang bis Ende reproduzierbar auszuführen, da sie Konsistenz zwischen der Abhängigkeit von Bibliotheksversionen und Laufzeitumgebungen garantieren. Und da die Containerisierung von Pipeline-Schritten eine Trennung von Belangen ermöglicht, können die einzelnen Schritte verschiedene Laufzeit- und Sprachversionen verwenden.
Es gibt viele Tools, mit denen sich Pipelines sowohl lokal als auch in der Cloud erstellen lassen. Dazu gehören Cloud AI Platform Pipelines (https://oreil.ly/nJo1p), TensorFlow Extended (TFX, https://oreil.ly/OznI3), Kubeflow Pipelines (KFP, https://oreil.ly/BoegQ), MLflow (https://mlflow.org/) und Apache Airflow (https://oreil.ly/63_GG). Um das Entwurfsmuster Workflow-Pipeline hier zu demonstrieren, definieren wir unsere Pipeline mit TFX und führen sie auf Cloud AI Platform Pipelines aus, einem gehosteten Dienst für die Ausführung von ML-Pipelines auf Google Cloud mit der zugrunde liegenden Containerinfrastruktur Google Kubernetes Engine (GKE).
Die als Komponenten bezeichneten Schritte in TFX-Pipelines sind sowohl in vorgefertigten als auch in anpassbaren Versionen verfügbar. Normalerweise nimmt die erste Komponente einer TFX-Pipeline Daten aus einer externen Quelle auf. Diese wird als ExampleGen-Komponente bezeichnet, wobei sich Example auf die ML-Terminologie für eine gelabelte im Training verwendete Instanz bezieht. Mit ExampleGen-Komponenten (https://oreil.ly/Sjx9F) können Sie Daten aus CSV-Dateien, TFRecords, BigQuery oder einer benutzerdefinierten Quelle beziehen. Zum Beispiel ist es mit der Komponente BigQueryExampleGen möglich, die Verbindung zu Daten, die in BigQuery gespeichert sind, mit unserer Pipeline herzustellen, indem wir eine Abfrage angeben, die die Daten abruft. Dann speichert sie diese Daten als TFRecords in einem GCS-Bucket, sodass sie von der nächsten Komponente verwendet werden können. Dies ist eine Komponente, die wir anpassen, indem wir ihr eine Abfrage übergeben. Konzeptionell sind diese ExampleGen-Komponenten auf die Datenerfassungsphase eines ML-Workflows ausgerichtet, der in Abbildung 6-6 skizziert ist.
Der nächste Schritt in diesem Workflow ist die Datenvalidierung. Nachdem wir die Daten eingelesen haben, können wir sie an andere Komponenten weitergeben, um sie zu transformieren oder zu analysieren, bevor ein Modell trainiert wird. Die Komponente StatisticsGen (https://oreil.ly/kX1QY) übernimmt Daten, die von einem ExampleGen-Schritt eingelesen wurden, und generiert zusammenfassende Statistiken über die bereitgestellten Daten. Die Komponente SchemaGen (https://oreil.ly/QpBlu) gibt das abgeleitete Schema aus unseren eingelesenen Daten aus. Mit der Ausgabe von SchemaGen führt der ExampleValidator (https://oreil.ly/UD7Uh) eine Anomalieerkennung auf unserem Datensatz durch und prüft auf Anzeichen von Datendrift oder potenzieller Verzerrung zwischen Training und Serving.3 Die Komponente Transform (https://oreil.ly/xsJYT) übernimmt ebenfalls die Ausgabe von SchemaGen und realisiert das Feature Engineering, um unsere Dateneingabe in das richtige Format für unser Modell zu transformieren. Dies kann die Konvertierung von Freiform-Texteingaben in Einbettungen, die Normalisierung numerischer Eingaben und vieles mehr beinhalten. Sobald unsere Daten aufbereitet sind, um sie in ein Modell einzuspeisen, können wir sie an die Komponente Trainer (https://oreil.ly/XFtR_) übergeben. Wenn wir unsere Trainer-Komponente einrichten, zeigen wir auf eine Funktion, die unseren Modellcode definiert, und wir können festlegen, wo wir das Modell trainieren möchten. Hier zeigen wir, wie Cloud AI Platform Training von dieser Komponente zu verwenden ist. Schließlich ist die Komponente Pusher (https://oreil.ly/qP8GU) dafür zuständig, das Modell bereitzustellen. Es gibt viele andere vorgefertigte Komponenten (https://oreil.ly/gHv_z), die von TFX bereitgestellt werden – wir haben hier nur ein paar aufgeführt, die wir in unserer Beispielpipeline verwenden.
Für dieses Beispiel greifen wir auf den NOAA-Hurrikan-Datensatz in BigQuery zurück, um ein Modell zu erstellen, das den SSHS-Code4 für einen Hurrikan ableitet. Die Features, die Komponenten und den Modellcode halten wir relativ kurz, um uns auf das Tooling für die Pipeline zu konzentrieren. Die nachstehend skizzierten Schritte unserer Pipeline folgen grob dem in Abbildung 6-6 dargestellten Workflow:
Wenn unsere Pipeline vollständig ist, können wir den gesamten oben skizzierten Vorgang mit einem einzigen API-Aufruf starten. Zunächst erläutern wir das Gerüst für eine typische TFX-Pipeline und wie sich diese auf AI Platform ausführen lässt.
Wie verwenden die tfx-Befehlszeilentools, um unsere Pipeline zu erstellen und aufzurufen. Neue Aufrufe einer Pipeline werden als Läufe bezeichnet, die sich von Aktualisierungen unterscheiden, die wir an der Pipeline selbst vornehmen, wie zum Beispiel das Hinzufügen einer neuen Komponente. Mit der TFX-CLI können wir beides bewerkstelligen. Wir können das Gerüst für unsere Pipeline in einem einzigen Python-Skript definieren, das zwei Hauptteile umfasst:
Die Pipeline (vollständiger Code auf GitHub unter https://github.com/GoogleCloudPlatform/ml-design-patterns/tree/master/06_reproducibility/workflow_pipeline) wird die oben definierten fünf Schritte oder Komponenten umfassen, und wir können unsere Pipeline mit dem folgenden Code definieren:
pipeline.Pipeline(
pipeline_name='huricane_prediction',
pipeline_root='path/to/pipeline/code',
components=[
bigquery_gen, statistics_gen, schema_gen, train, model_pusher
]
)
Um die von TFX bereitgestellte Komponente BigQueryExampleGen zu verwenden, übergeben wir die Abfrage, die unsere Daten abrufen wird. Diese Komponente können wir in einer Zeile Code definieren, wobei query unsere BigQuery-SQL-Abfrage als String enthält:
bigquery_gen = BigQueryExampleGen(query=query)
Ein weiterer Vorteil von Pipelines besteht darin, dass sie Tools mitbringen, um die Eingabe, die Ausgabeartefakte und die Protokolle für jede Komponente verfolgen zu können. Zum Beispiel ist die Ausgabe der Komponente statistics_gen eine Zusammenfassung unseres Datensatzes, wie Abbildung 6-7 zeigt. Bei statistics_gen (https://oreil.ly/wvq9n) handelt es sich um eine vorgefertigte Komponente, die in TFX verfügbar ist und die TF Data Validation verwendet, um zusammenfassende Statistiken über unseren Datensatz zu generieren.
Abbildung 6-7: Das Ausgabeartefakt der Komponente »statistics_gen« in einer TFX-Pipeline
Wir können die TFX-Pipeline auf Cloud AI Platform Pipelines ausführen, wo die systemnahen Details der Infrastruktur für uns verwaltet werden. Um eine Pipeline auf AI Platform bereitzustellen, verpacken wir unseren Pipeline-Code als Docker-Container (https://oreil.ly/rdXeb) und hosten ihn auf Google Container Registry (GCR, https://oreil.ly/m5wqD).6 Sobald unser containerisierter Pipeline-Code auf GCR gepusht wurde, erstellen wir die Pipeline mit der TFX-CLI:
tfx pipeline create \
--pipeline-path=kubeflow_dag_runner.py \
--endpoint='your-pipelines-dashboard-url' \
--build-target-image='gcr.io/your-pipeline-container-url'
Im obigen Befehl entspricht der Endpunkt der URL unseres AI-Platform-Pipelines-Dashboards. Wenn der Befehl abgearbeitet ist, sehen wir die eben erstellte Pipeline im Pipelines-Dashboard. Der Befehl create erzeugt eine Pipeline-Ressource, die sich aufrufen lässt, indem wir einen Lauf erstellen:
tfx run create --pipeline-name='your-pipeline-name' --endpoint='pipeline-url'
Nach dem Ausführen dieses Befehls können wir ein Diagramm sehen, das in Echtzeit aktualisiert wird, wenn unsere Pipeline jeden Schritt durchläuft. Im Pipelines-Dashboard können wir einzelne Schritte weiter untersuchen, um alle Artefakte, die sie generieren, Metadaten und mehr zu sehen. Abbildung 6-8 zeigt ein Beispiel der Ausgabe für einen einzelnen Schritt.
Wir könnten das Modell direkt in unserer containerisierten Pipeline auf GKE trainieren, doch TFX bietet ein Hilfsprogramm für die Verwendung von Cloud AI Platform Training als Teil unseres Prozesses. Zudem verfügt TFX über eine Erweiterung, um das trainierte Modell auf AI Platform Prediction bereitzustellen. Diese beiden Integrationen nutzen wir in unserer Pipeline. Mit AI Platform Training profitieren wir auch von spezialisierter Hardware für das Training unserer Modelle, wie zum Beispiel von GPUs oder TPUs – und das auf kostengünstige Weise. Des Weiteren gibt es eine Option, um verteiltes Training zu verwenden, was das Training beschleunigen und die Trainingskosten minimieren kann. Wir können einzelne Trainingsjobs und ihre Ausgabe innerhalb der AI-Platform-Konsole verfolgen.
Abbildung 6-8: Ausgabe der Komponente »schema_gen« einer ML-Pipeline. Die obere Menüleiste zeigt die für jeden einzelnen Pipeline-Schritt verfügbaren Daten.
![]() |
Wenn man eine Pipeline mit TFX oder Kubeflow Pipelines erstellt, hat das den Vorteil, nicht an Google Cloud gebunden zu sein. Den gleichen Code, den wir hier mit AI Platform Pipelines von Google demonstrieren, können wir auf Azure ML Pipelines (https://oreil.ly/A5Rxe), Amazon SageMaker (https://oreil.ly/A5Rxe) oder lokal ausführen. |
Um einen Trainingsschritt in TFX zu implementieren, verwenden wir die Komponente Trainer (https://oreil.ly/TGKcP) und übergeben ihr die Informationen zu den Trainingsdaten als Modelleingabe zusammen mit unserem Modelltrainingscode. TFX bietet eine Erweiterung, um den Trainingsschritt auf AI Platform auszuführen. Hierfür importieren wir tfx.extensions.google_cloud_ai_platform.trainer und geben Details zu unserer AI-Platform-Trainingskonfiguration an. Dazu gehören der Projektname, die Region und der GCR-Standort des Containers mit Trainingscode.
In ähnlicher Weise verfügt TFX auch über eine AI-Platform-Komponente Pusher (https://oreil.ly/bJavO), um trainierte Modelle auf AI Platform Prediction bereitzustellen. Um die Pusher-Komponente mit AI Platform zu verwenden, geben wir Details zum Namen und zur Version unseres Modells an zusammen mit einer Serving-Funktion, die AI Platform das Format der Eingabedaten mitteilt, die für unser Modell zu erwarten sind. Damit haben wir eine vollständige Pipeline, die Daten beschafft, sie analysiert, die Datentransformation durchführt und schließlich das Modell mit AI Platform trainiert und bereitstellt.
Ohne den ML-Code als Pipeline auszuführen, wäre es für andere schwierig, unsere Arbeit zuverlässig zu reproduzieren. Denn sie müssten unseren Code für Vorverarbeitung, Modellentwicklung, Training und Serving nehmen und versuchen, die gleiche Umgebung zu replizieren, in der wir ihn ausgeführt haben, und dabei Bibliotheksabhängigkeiten, Authentifizierung und mehr berücksichtigen. Wenn es eine Logik gibt, die die Auswahl nachgelagerter Komponenten basierend auf der Ausgabe vorgelagerter Komponenten steuert, muss diese Logik ebenfalls zuverlässig repliziert werden. Das Entwurfsmuster Workflow-Pipeline ermöglicht anderen, unseren gesamten ML-Workflow end-to-end sowohl lokal als auch in Cloud-Umgebungen auszuführen und zu überwachen, wobei sich die Ausgabe der einzelnen Schritte weiterhin debuggen lässt. Indem wir jeden Schritt der Pipeline containerisieren, versetzen wir andere in die Lage, sowohl die Umgebung, in der wir die Pipeline erstellt haben, als auch den gesamten Workflow, der in der Pipeline erfasst ist, zu reproduzieren. Das erlaubt uns auch, die Umgebung möglicherweise Monate später zu reproduzieren, um regulatorische Anforderungen zu unterstützen. Schließlich gibt uns das Dashboard mit TFX und AI Platform Pipelines eine Benutzeroberfläche, in der wir die bei jeder Pipeline-Ausführung erzeugten Ausgabeartefakte nachverfolgen können. Darauf geht der Abschnitt »Kompromisse und Alternativen« auf Seite 346 näher ein.
Da sich jede Pipeline-Komponente in ihrem eigenen Container befindet, können verschiedene Teammitglieder parallel arbeiten, indem sie separate Teile der Pipeline erstellen und testen. Dies ermöglicht eine schnellere Entwicklung und minimiert die Risiken, die durch einen eher monolithischen ML-Prozess entstehen, bei dem die Schritte untrennbar miteinander verknüpft sind. Zum Beispiel können sich die Paketabhängigkeiten und der Code für den Datenaufbereitungsschritt erheblich von denen der Modellbereitstellung unterscheiden. Indem man diese Schritte als Teile einer Pipeline erstellt, lässt sich jeder Teil in einem separaten Container mit eigenen Abhängigkeiten erzeugen und – wenn er fertiggestellt ist – in eine größere Pipeline einbinden.
Alles in allem verbindet das Entwurfsmuster Workflow-Pipeline die Vorteile eines gerichteten azyklischen Graphen (Directed Acyclic Graph, DAG) mit den Vorteilen vorgefertigter Komponenten aus Pipeline-Frameworks wie TFX. Da die Pipeline ein DAG ist, steht es uns frei, einzelne Schritte auszuführen oder die gesamte Pipeline end-to-end abarbeiten zu lassen. Darüber hinaus können wir jeden Schritt der Pipeline über verschiedene Läufe hinweg protokollieren und überwachen sowie Artefakte von jedem Schritt und der Pipeline-Ausführung an einem zentralen Ort nachverfolgen. Vorgefertigte Komponenten bieten eigenständige, einsatzbereite Schritte für gängige Komponenten von ML-Workflows, einschließlich Training, Bewertung und Inferenz. Diese Komponenten laufen als individuelle Container, wo auch immer wir unsere Pipeline ausführen möchten.
Anstatt ein Pipeline-Framework zu verwenden, besteht die Hauptalternative darin, die Schritte unseres ML-Workflows mit einem provisorischen Ansatz auszuführen, um die Notebooks und die von jedem Schritt erzeugte Ausgabe zu verfolgen. Natürlich ist es mit einem gewissen Aufwand verbunden, die verschiedenen Teile unseres ML-Workflows in eine organisierte Pipeline umzuwandeln. Dieser Abschnitt zeigt Ihnen einige Variationen und Erweiterungen des Entwurfsmusters Workflow-Pipeline: manuelles Erstellen von Containern, Automatisieren einer Pipeline mit Tools für kontinuierliche Integration und Bereitstellung (Continuous Integration/Continuous Delivery, CI/CD), Prozesse für die Überführung einer Workflow-Pipeline von der Entwicklung in die Produktion und alternative Tools, um Pipelines zu erstellen und zu orchestrieren. Außerdem werden wir untersuchen, wie sich Pipelines einsetzen lassen, um Metadaten zu verfolgen.
Unsere Pipeline müssen wir nicht unbedingt mit vorgefertigten oder anpassbaren TFX-Komponenten konstruieren – wir können auch eigene Container definieren und sie als Komponenten nutzen oder eine Python-Funktion in eine Komponente verwandeln.
Um die von TFX bereitgestellten containerbasierten Komponenten (https://oreil.ly/5ryEn) zu nutzen, übergeben wir der Methode create_container_component die Eingaben und Ausgaben für unsere Komponente und ein grundlegendes Docker-Image zusammen mit allen Einstiegspunktbefehlen für den Container. Zum Beispiel ruft die folgende containerbasierte Komponente das Befehlszeilentool bq auf, um einen BigQuery-Datensatz herunterzuladen:
component = create_container_component(
name='DownloadBQData',
parameters={
'dataset_name': string,
'storage_location': string
},
image='google/cloud-sdk:278.0.0',
,
command=[
'bq', 'extract', '--compression=csv', '--field_delimiter=,',
InputValuePlaceholder('dataset_name'),
InputValuePlaceholder('storage_location'),
]
)
Es ist am besten, ein Basis-Image zu verwenden, das bereits die meisten der benötigten Abhängigkeiten enthält. Wir verwenden hier das Google-Cloud-SDK-Image, das auch das Befehlszeilentool bq zur Verfügung stellt.
Genauso ist es möglich, eine benutzerdefinierte Python-Funktion mit dem Dekorator @component in eine TFX-Komponente zu konvertieren. Um das zu demonstrieren, nehmen wir einen Schritt an, der einen Cloud-Storage-Bucket erstellt und zur Vorbereitung von Ressourcen dient, die in unserer Pipeline verwendet werden. Diesen benutzerdefinierten Schritt definieren wir mit dem folgenden Code:
from google.cloud import storage
client = storage.Client(project="your-cloud-project")
@component
def CreateBucketComponent(
bucket_name: Parameter[string] = 'your-bucket-name',
) -> OutputDict(bucket_info=string):
client.create_bucket('gs://' + bucket_name)
bucket_info = storage_client.get_bucket('gs://' + bucket_name)
return {
'bucket_info': bucket_info
}
Dann können wir diese Komponente unserer Pipeline-Definition hinzufügen:
create_bucket = CreateBucketComponent(
bucket_name='my-bucket')
Pipelines werden Sie nicht nur über das Dashboard oder programmgesteuert über die CLI oder die API aufrufen, sondern Sie werden wahrscheinlich auch Läufe der Pipeline automatisieren wollen, wenn das Modell in der Produktion eingesetzt wird. So kann es beispielsweise sinnvoll sein, die Pipeline immer dann aufzurufen, wenn eine bestimmte Menge an neuen Trainingsdaten verfügbar ist. Oder wir möchten einen Pipeline-Lauf auslösen, wenn sich der Quellcode für die Pipeline ändert. Und wenn wir unsere Workflow-Pipeline mit CI/CD ausstatten, lassen sich Triggerereignisse einfacher mit Pipeline-Läufen verbinden.
Es sind viele verwaltete Dienste verfügbar, um Trigger einzurichten, die eine Pipeline starten, wenn wir ein Modell auf neuen Daten neu trainieren möchten. Wir könnten einen verwalteten Planungsdienst verwenden, um unsere Pipeline nach einem Zeitplan aufzurufen. Alternativ könnten wir einen serverlosen, ereignisbasierten Dienst wie Cloud Functions (https://oreil.ly/rVyzX) nutzen, um unsere Pipeline zu starten, wenn an einem Speicherort neue Daten hinzugefügt werden. In unserer Funktion könnten wir Bedingungen festlegen – etwa einen Schwellenwert für die Menge neuer Daten, die hinzugefügt werden müssen, damit ein Retraining erforderlich wird –, um einen neuen Pipeline-Lauf zu anzustoßen. Sobald genügend neue Trainingsdaten verfügbar sind, können wir einen Pipeline-Lauf instanziieren, um das Modell erneut zu trainieren und bereitzustellen, wie Abbildung 6-9 zeigt.
Abbildung 6-9: Ein CI/CD-Workflow, der über Cloud Functions eine Pipeline aufruft, wenn genügend neue Daten einem Speicherort hinzugefügt wurden
Möchten wir unsere Pipeline basierend auf Änderungen am Quellcode auslösen, kann ein verwalteter CI/CD-Dienst wie Cloud Build (https://oreil.ly/kz8Aa) helfen. Wenn Cloud Build unseren Code ausführt, wird er als Folge von containerisierten Schritten ausgeführt. Dieser Ansatz passt gut in den Kontext von Pipelines. Wir können Cloud Build mit GitHub Actions (https://oreil.ly/G2Xwv) oder GitLab Triggers (https://oreil.ly/m_dYr) in dem Repository, in dem sich unser Pipeline-Code befindet, verbinden. Wird der Code bestätigt, baut Cloud Build dann auf Basis des neuen Codes die Container, die zu unserer Pipeline gehören, und erzeugt einen Lauf.
Neben TFX sind Apache Airflow (https://oreil.ly/rQlqK) und Kubeflow Pipelines (https://oreil.ly/e_7zJ) Alternativen für die Implementierung des Musters Workflow-Pipeline. Wie TFX verarbeiten sowohl Airflow als auch KFP die Pipelines als DAG, bei dem der Workflow für jeden Schritt in einem Python-Skript definiert wird. Dann nehmen sie dieses Skript und stellen APIs bereit, um den Graphen auf der angegebenen Infrastruktur zu planen und zu orchestrieren. Sowohl Airflow als auch KFP sind Open Source und können daher lokal oder in der Cloud laufen.
Da man Airflow üblicherweise für das Data Engineering verwendet, kommt es auch für die Daten-ETL-Aufgaben einer Organisation infrage. Airflow bietet zwar robuste Tools, um Jobs auszuführen, wurde aber als universelle Lösung erstellt und nicht im Hinblick auf ML-Workloads entworfen. KFP hingegen wurde speziell für maschinelles Lernen konzipiert und arbeitet auf einer niedrigeren Ebene als TFX, ist dafür aber flexibler, wenn es darum geht, wie Pipeline-Schritte definiert werden. Während TFX seinen eigenen Orchestrierungsansatz implementiert, können wir bei KFP wählen, wie wir unsere Pipelines über seine API orchestrieren. Abbildung 6-10 fasst die Beziehung zwischen TFX, KFP und Kubeflow zusammen.
Abbildung 6-10: Die Beziehung zwischen TFX, Kubeflow Pipelines, Kubeflow und der zugrunde liegenden Infrastruktur. TFX operiert auf der höchsten Ebene und über Kubeflow Pipelines, wobei vorgefertigte Komponenten spezifische Ansätze für gängige Workflow-Schritte bieten. Die von Kubeflow Pipelines bereitgestellte API ermöglicht es, eine ML-Pipeline zu definieren und zu orchestrieren. Dadurch lassen sich die einzelnen Schritte flexibler implementieren. Sowohl TFX als auch KFP laufen auf Kubeflow, einer Plattform für die Ausführung von containerbasierten ML-Workloads auf Kubernetes. Alle Tools in dieser Grafik sind Open Source, sodass die zugrunde liegende Infrastruktur, auf der die Pipeline läuft, Sache des Benutzers ist – zu den Optionen gehören GKE, Anthos, Azure, AWS sowie lokale Tools.
Die Art, wie eine Pipeline aufgerufen wird, ändert sich oft, wenn wir von der Entwicklung in die Produktion übergehen. Wahrscheinlich werden wir unsere Pipeline von einem Notebook aus erstellen und als Prototyp bereitstellen. Dadurch ist es ohne Weiteres möglich, eine Notebook-Zelle auszuführen, um die Pipeline erneut aufzurufen, Fehler zu debuggen und den Code zu aktualisieren – alles in derselben Umgebung. Sobald alles für die Produktion bereit ist, können wir unseren Komponentencode und die Pipeline-Definition in ein einzelnes Skript verschieben. Mit der im Skript definierten Pipeline können wir Läufe planen und es anderen in unserer Organisation erleichtern, die Pipeline reproduzierbar aufzurufen. Ein Tool, das für die Überführung in die Produktion zur Verfügung steht, ist Kale (https://github.com/kubeflow-kale/kale). Es übernimmt Jupyter-Notebook-Code und konvertiert ihn mithilfe der Kubeflow Pipelines API in ein Skript.
Eine Produktionspipeline ermöglicht auch die Orchestrierung eines ML-Workflows. Unter Orchestrierung ist zu verstehen, dass wir unserer Pipeline Logik hinzufügen, um zu bestimmen, welche Schritte ausgeführt werden und welches Ergebnis diese Schritte haben werden. Zum Beispiel könnten wir uns dafür entscheiden, nur Modelle in der Produktion bereitzustellen, die eine Genauigkeit von 95 % oder mehr aufweisen. Wenn neu verfügbare Daten einen Pipeline-Lauf auslösen und ein aktualisiertes Modell trainieren, können wir mit zusätzlicher Logik die Ausgabe unserer Bewertungskomponente überprüfen, um die Bereitstellungskomponente auszuführen, wenn die Genauigkeit über unserem Schwellenwert liegt, oder andernfalls den Pipeline-Lauf beenden. Sowohl Airflow als auch Kubeflow Pipelines, die bereits in diesem Abschnitt erwähnt wurden, bieten APIs für die Pipeline-Orchestrierung.
Pipelines bieten mit der sogenannten Herkunftsverfolgung (Lineage Tracking) die Möglichkeit, Modellmetadaten und Artefakte zu verfolgen. Jedes Mal, wenn wir eine Pipeline aufrufen, wird eine Reihe von Artefakten generiert. Diese Artefakte könnten Datensatzzusammenfassungen, exportierte Modelle, Modellbewertungsergebnisse, Metadaten zu spezifischen Pipeline-Aufrufen und mehr enthalten. Die Herkunftsverfolgung erlaubt uns, den Verlauf unserer Modellversionen zusammen mit anderen Modellartefakten zu visualisieren. Zum Beispiel können wir in AI Platform Pipelines im Pipelines-Dashboard sehen, auf welchen Daten eine Modellversion trainiert worden ist, aufgeschlüsselt sowohl nach Datenschema als auch nach Datum. Abbildung 6-11 zeigt das Dashboard Lineage Explorer für eine TFX-Pipeline, die auf AI Platform läuft. Hier lassen sich die Eingabe- und Ausgabeartefakte verfolgen, die mit einem bestimmten Modell verbunden sind.
Abbildung 6-11: Der Abschnitt »Lineage Explorer« des Dashboards von AI Platform Pipelines für eine TFX-Pipeline
Wenn man die Artefakte, die während unseres Pipeline-Laufs erzeugt werden, per Herkunftsverfolgung verwaltet, profitiert man davon, dass die Herkunftsverfolgung sowohl Cloud-basierte als auch lokale Umgebungen unterstützt. Dadurch sind wir flexibel, wenn es darum geht, wo Modelle trainiert und bereitgestellt sowie die Modellmetadaten gespeichert werden. Darüber hinaus ist die Herkunftsverfolgung ein wichtiger Aspekt, um ML-Pipelines reproduzierbar zu machen, da sie Vergleiche zwischen Metadaten und Artefakten aus verschiedenen Pipeline-Läufen erlaubt.
Das Entwurfsmuster Feature Store vereinfacht die Verwaltung und Wiederverwendung von Features über Projekte hinweg, denn es entkoppelt den Prozess der Feature-Erstellung von der Entwicklung der Modelle, die diese Features verwenden.
Ein gutes Feature Engineering ist entscheidend für den Erfolg vieler ML-Lösungen. Allerdings ist es auch einer der zeitaufwendigsten Teile der Modellentwicklung. Einige Features erfordern erhebliches Domänenwissen, um Berechnungen korrekt auszuführen, und Änderungen in der Geschäftsstrategie können sich darauf auswirken, wie ein Feature berechnet werden sollte. Um eine konsistente Berechnung derartiger Features sicherzustellen, ist es besser, wenn diese Features unter der Kontrolle von Domänenexperten und nicht von ML Engineers stehen. Bei einigen Eingabefeldern lassen sich verschiedene Datendarstellungen wählen (siehe Kapitel 2), um sie für maschinelles Lernen besser geeignet zu machen. In der Regel experimentiert ein ML Engineers oder Data Scientist mit mehreren verschiedenen Transformationen, um festzustellen, welche hilfreich sind und welche nicht, bevor er entscheidet, welche Features das endgültige Modell umfassen wird. Die im Modell verwendeten Daten stammen oftmals nicht nur aus einer einzigen Quelle. Manche Daten kommen aus einem Data Warehouse, andere Daten liegen vielleicht als unstrukturierte Daten in einem Speicher-Bucket, und wieder andere Daten entstehen durch Streaming in Echtzeit. Die Struktur der Daten kann ebenfalls zwischen diesen Quellen variieren, sodass jede Eingabe ihre eigenen Schritte für das Feature Engineering benötigt, bevor sie in ein Modell eingespeist werden kann. Da diese Entwicklung oft auf einem virtuellen Computer oder einem persönlichen Rechner stattfindet, ist die Feature-Erzeugung an die Softwareumgebung gebunden, in der das Modell erstellt wird. Und je komplexer das Modell wird, desto komplizierter werden diese Datenpipelines.
Ein Ad-hoc-Ansatz, bei dem Features nach Bedarf von ML-Projekten erstellt werden, mag für die einmalige Modellentwicklung und das Training funktionieren, doch wenn Organisationen skalieren, ist diese Methode des Feature Engineerings nicht mehr praktikabel, und es entstehen erhebliche Probleme:
Kurz gesagt, bremst der Ad-hoc-Ansatz für das Feature Engineering die Modellentwicklung, bedeutet aber doppelten Aufwand und einen ineffizienten Arbeitsablauf. Darüber hinaus ist die Feature-Erstellung zwischen Training und Inferenz inkonsistent, birgt das Risiko von Verzerrungen zwischen Training und Serving oder führt zu Datenlecks, weil versehentlich Label-Informationen in die Eingabe der Modellpipeline gelangen.
Die Lösung besteht darin, einen gemeinsamen Feature-Speicher zu erstellen, einen zentralen Ort zum Speichern und Dokumentieren von Feature-Datensätzen, die dem Aufbau von ML-Modellen dienen und projekt- und teamübergreifend genutzt werden können. Der Feature-Speicher agiert als Schnittstelle zwischen den Pipelines des Data Engineers für die Feature-Erstellung und dem Workflow des Data Scientists, der Modelle mit diesen Features erstellt (siehe Abbildung 6-12). Auf diese Weise gibt es ein zentrales Repository, das vorberechnete Features aufnimmt, was die Entwicklungszeit verkürzt und die Feature-Erkennung erleichtert. Dadurch lassen sich auch die grundlegenden Prinzipien der Softwaretechnik – wie Versionierung, Dokumentation und Zugriffssteuerung – auf die erstellten Features anwenden.
Ein typischer Feature-Speicher wird mit zwei wichtigen Entwurfsmerkmalen aufgebaut: Werkzeuge, um große Feature-Datenmengen schnell zu verarbeiten, und eine Methode zum Speichern von Features, die sowohl den Zugriff mit geringer Latenz (für Inferenz) als auch den Zugriff auf große Batches (für das Modelltraining) unterstützt. Außerdem gibt es eine Metadatenschicht, die die Dokumentation und die Versionierung verschiedener Feature-Sätze vereinfacht, und eine API, die das Laden und Abrufen von Feature-Daten verwaltet.
Abbildung 6-12: Ein Feature-Speicher bildet eine Brücke zwischen Rohdatenquellen und dem Modelltraining und -Serving.
Die typische Arbeit eines Data oder ML Engineers besteht darin, Rohdaten (strukturierte oder gestreamte) aus einer Datenquelle zu lesen, verschiedene Transformationen auf den Daten mit dem bevorzugten Framework zur Verarbeitung der Daten anzuwenden und die transformierten Features im Feature-Speicher zu speichern. Anstatt Feature-Pipelines zu erstellen, um ein einzelnes ML-Modell zu unterstützen, entkoppelt das Muster Feature Store das Feature Engineering von der Modellentwicklung. Insbesondere werden Tools wie Apache Beam, Flink oder Spark häufig eingesetzt, um Daten im Batch wie auch in gestreamter Form zu verarbeiten. Damit treten ebenfalls weniger Training-Serving-Verzerrungen auf, da die Feature-Daten von denselben Pipelines zur Feature-Erstellung kommen.
Die erstellten Features werden in einem Datenspeicher untergebracht, um sie für Training und Serving abzurufen. Für das Abrufen der Features wird die Geschwindigkeit optimiert. Ein Modell in der Produktion, das eine Onlineanwendung unterstützt, muss gegebenenfalls Echtzeitvorhersagen innerhalb von Millisekunden produzieren, weshalb eine geringe Latenz entscheidend ist. Für das Training ist jedoch eine höhere Latenz kein Problem. Vielmehr liegt hier der Schwerpunkt auf einem hohen Durchsatz, da vergangenheitsbezogene Features in großen Batches für das Training abgerufen werden. Ein Feature-Speicher ist für beide Anwendungsfälle konzipiert, indem er verschiedene Datenspeicher für den Online- und den Offlinezugriff auf Features verwendet. So kann ein Feature-Speicher beispielsweise Cassandra oder Redis als Datenspeicher für das Onlineabrufen von Features verwenden und Hive oder BigQuery, um vergangenheitsbezogene Feature-Mengen in großen Batches abzurufen.
Letztlich beherbergt ein typischer Feature-Speicher viele verschiedene Feature-Sets mit Features, die aus unzähligen Rohdatenquellen erstellt wurden. Eine integrierte Metadatenschicht dokumentiert die Feature-Sets und bietet eine Registrierung für eine einfache Feature-Discovery und eine teamübergreifende Zusammenarbeit.
Als praktisches Beispiel für dieses Muster betrachten wir Feast (https://github.com/feast-dev), einen Open-Source-Feature-Speicher für maschinelles Lernen, der von Google Cloud und Gojek (https://oreil.ly/PszIn) entwickelt wurde. Er basiert auf Google-Cloud-Diensten (https://oreil.ly/ecJou), die BigQuery für das Offline-Modelltraining und Redis für das Online-Serving mit niedriger Latenz verwenden (siehe Abbildung 6-13). Die Feature-Erstellung geschieht mit Apache Beam, was konsistente Datenpipelines sowohl für die Batch- als auch für die Streamverarbeitung ermöglicht.
Um zu zeigen, wie dies in der Praxis funktioniert, verwenden wir einen öffentlichen BigQuery-Datensatz mit Informationen über Taxifahrten in New York City.7 Jede Zeile der Tabelle enthält einen Zeitstempel für das Zusteigen sowie Breiten- und Längengrad beim Zusteigen, Breiten- und Längengrad beim Aussteigen, die Anzahl der Fahrgäste und die Kosten der Taxifahrt. Das ML-Modell soll nun anhand dieser Merkmale die Kosten der Taxifahrt – als fare_amount bezeichnet – vorhersagen.
Dieses Modell profitiert vom Engineering zusätzlicher Features aus den Rohdaten. Da zum Beispiel die Kosten einer Taxifahrt auf der Entfernung und der Dauer der Fahrt basieren, ist die vorab berechnete Entfernung zwischen Zusteigen und Aussteigen ein nützliches Feature. Sobald dieses Feature im Datensatz berechnet ist, können wir es in einem Feature-Satz zur späteren Verwendung speichern.
Abbildung 6-13: Die Architektur des Feast-Feature-Speichers im Überblick. Feast baut auf Google BigQuery, Redis und Apache Beam auf.
Features zu Feast hinzufügen.In Feast werden Daten mithilfe von Feature-Sets gespeichert. Ein FeatureSet enthält das Datenschema und die Datenquelleninformationen, egal ob die Daten aus einem Pandas-Dataframe oder einem gestreamten Kafka-Topic stammen. Durch Feature-Sets weiß Feast, woher die Daten stammen, die es für ein Feature benötigt, wie sie eingelesen werden und welche grundlegenden Eigenschaften die Datentypen besitzen. Gruppen von Features können zusammen eingelesen und gespeichert werden, und Feature-Sets bieten effiziente Speichermechanismen und logisches Namespacing der Daten innerhalb dieser Speicher.
Sobald unser Feature-Set registriert ist, startet Feast einen Apache-Beam-Job, um den Feature-Speicher mit Daten aus der Quelle zu füllen. Ein Feature-Set wird verwendet, um sowohl Offline- als auch Online-Feature-Speicher zu generieren, wodurch sichergestellt wird, dass Entwickler:innen ihr Modell mit denselben Daten trainieren und bereitstellen. Feast gewährleistet, dass die Quelldaten mit dem erwarteten Schema des Feature-Sets übereinstimmen.
Um Feature-Daten in Feast einzulesen, sind vier Schritte zu absolvieren, wie Abbildung 6-14 zeigt.
Abbildung 6-14: Feature-Daten werden in vier Schritten nach Feast eingelesen: ein »FeatureSet« erzeugen, Entitäten und Features hinzufügen, das »FeatureSet« registrieren und die Feature-Daten in das »FeatureSet« einlesen.
Die vier Schritte sehen folgendermaßen aus:
Im Repository zu diesem Buch finden Sie ein Notebook mit dem vollständigen Code für dieses Beispiel (https://github.com/GoogleCloudPlatform/ml-design-patterns/blob/master/06_reproducibility/feature_store.ipynb).
Ein FeatureSet erstellen.Mit dem Python-SDK richten wir einen Client ein, um die Verbindung zu einer Feast-Bereitstellung herzustellen:
from feast import Client, FeatureSet, Entity, ValueType
# Mit einer vorhandenen Feast-Bereitstellung verbinden.
client = Client(core_url='localhost:6565')
Um zu überprüfen, ob der Client verbunden ist, geben wir die vorhandenen Feature-Sets mit dem Befehl client.list_feature_sets() aus. Falls es sich um eine neue Bereitstellung handelt, gibt der Befehl eine leere Liste zurück. Möchten Sie ein neues Feature-Set erstellen, instanziieren Sie die Klasse FeatureSet und übergeben dabei den Namen des Feature-Sets:
# Ein Feature-Set erstellen.
taxi_fs = FeatureSet("taxi_rides")
Entitäten und Features dem FeatureSet hinzufügen.Im Kontext von Feast bestehen Feature-Sets aus Entitäten und Features. Entitäten dienen als Schlüssel für die Suche nach Feature-Werten und verknüpfen Features zwischen verschiedenen Feature-Sets, wenn Datensätze für Training oder Serving erstellt werden. Die Entität fungiert als Bezeichner für ein maßgebliches Merkmal, das in Ihrem Datensatz vorkommt. Sie ist ein Objekt, das sich modellieren lässt und Informationen speichert. Im Kontext eines Mitfahr- oder Essenslieferdiensts könnte die maßgebliche Entität customer_id, order_id, driver_id oder restaurant_id sein, im Kontext eines Churn-Modells customer_id oder segment_id. In unserem Beispiel ist die Entität die taxi_id, ein eindeutiger Bezeichner für den Taxiunternehmer jeder Fahrt.
In diesem Stadium enthält das von uns erstellte Feature-Set namens taxi_rides weder Entitäten noch Features. Über den Client des Feast-Kerns können wir diese Daten einem Pandas-Dataframe entnehmen, der die Rohdateneingaben und -entitäten enthält, wie Tabelle 6-2 angibt.
Tabelle 6-2: Der Datensatz mit Taxifahrten enthält Informationen über Taxifahrten in New York. Die Entität »taxi_id« ist ein eindeutiger Bezeichner für den Taxiunternehmer jeder Fahrt.
Streaming-Datenquellen definieren, wenn ein Feature-Set erstellt wird
Benutzer können Streaming-Datenquellen definieren, wenn sie ein Feature-Set erstellen. Sobald ein Feature-Set mit einer Quelle registriert ist, füllt Feast automatisch seine Speicher mit Daten aus dieser Quelle. Das folgende Beispiel zeigt ein Feature-Set mit einer vom Benutzer bereitgestellten Quelle, das Streaming-Daten aus einem Kafka-Topic abruft:
feature_set = FeatureSet(
name="stream_feature",
entities=[
Entity("taxi_id", ValueType.INT64)
],
features=[
Feature("traffic_last_5min", ValueType.INT64)
],
source=KafkaSource(
brokers="mybroker:9092",
topic="my_feature_topic"
)
)
Der Zeitstempel pickup_datetime (Zeit beim Zusteigen) ist hier wichtig, da er benötigt wird, um Batch-Features abzurufen, und dazu dient, zeitlich korrekte Verknüpfungen für Batch-Features zu gewährleisten. Um ein zusätzliches Feature – wie zum Beispiel den euklidischen Abstand – zu generieren, laden Sie den Datensatz in einen Pandas-Dataframe und berechnen das Feature:
# Dataframe laden.
taxi_df = pd.read_csv("taxi-train.csv")
# Feature Engineering für euklidischen Abstand.
taxi_df['euclid_dist'] = taxi_df.apply(compute_dist, axis=1)
Mit der Methode .add(...) können wir Entitäten und Features dem Feature-Set hinzufügen. Alternativ erzeugt die Methode .infer_fields_from_df(...) die Entitäten und Features für unser FeatureSet direkt aus dem Pandas-Dataframe. Dazu spezifizieren Sie einfach den Spaltennamen, der die Entität repräsentiert. Das Schema und die Datentypen für die Features des Feature-Sets werden dann aus dem Dataframe abgeleitet:
# Die Features des Feature-Sets aus dem Pandas-Dataframe ableiten.
taxi_fs.infer_fields_from_df(taxi_df,
entities=[Entity(name='taxi_id', dtype=ValueType.INT64)],
replace_existing_features=True)
Das FeatureSet registrieren.Sobald das FeatureSet erzeugt ist, können wir es mit client.apply(taxi_fs) bei Feast registrieren. Um sich davon zu überzeugen, dass das Feature-Set korrekt registriert wurde oder um den Inhalt eines anderen Feature-Sets zu inspizieren, können Sie es mit der Methode .get_feature_set() abrufen:
print(client.get_feature_set("taxi_rides"))
Diese Anweisung gibt ein JSON-Objekt zurück, das das Datenschema für das Feature-Set taxi_rides enthält:
{
"spec": {
"name": "taxi_rides",
"entities": [
{
"name": "key",
"valueType": "INT64"
}
],
"features": [
{
"name": "dropoff_lon",
"valueType": "DOUBLE"
},
{
"name": "pickup_lon",
"valueType": "DOUBLE"
},
...
...
],
}
}
Feature-Daten in das FeatureSet einlesen.Wenn wir mit unserem Schema zufrieden sind, können wir die Feature-Daten aus dem Dataframe mit der Methode .ingest(...) in Feast einlesen. Der Methode übergeben wir das Feature-Set namens taxi_fs und den Dataframe taxi_df, aus dem die Feature-Daten gefüllt werden sollen:
# Feature-Daten für dieses spezifische Feature-Set in Feast einlesen.
client.ingest(taxi_fs, taxi_df)
Der Fortschritt beim Einlesen wird auf dem Bildschirm ausgegeben. Demnach haben wir 28.247 Zeilen in das Feature-Set taxi_rides in Feast eingelesen:
100%|..............|28247/28247 [00:02<00:00, 2771.19rows/s]
Ingestion complete!
Ingestion statistics:
Success: 28247/28247 rows ingested
In dieser Phase listet jetzt der Aufruf von client.list_feature_sets() das Feature-Set taxi_rides auf, das wir eben erstellt haben, und gibt [default/taxi_rides] zurück. Hier bezieht sich default auf den Projektbereich des Feature-Sets innerhalb von Feast. Dies lässt sich beim Instanziieren des Feature-Sets ändern, um bestimmte Feature-Sets im Projektzugriff zu halten.
Datensätze können sich mit der Zeit ändern, wodurch sich auch die Feature-Sets ändern. Sobald ein Feature-Set in Feast erstellt ist, lassen sich nur wenige Änderungen vornehmen. Die folgenden Änderungen sind zum Beispiel erlaubt:
|
|
|
Die folgenden Änderungen sind nicht erlaubt:
|
Sobald ein Feature-Set mit Features bestückt ist, können wir vergangenheitsbezogene oder Online-Features abrufen. Benutzer und Produktionssysteme rufen Feature-Daten über eine Serving-Datenzugriffsschicht von Feast ab. Da Feast sowohl Offline- als auch Onlinespeichertypen unterstützt, ist es üblich, für beide Typen Feast-Bereitstellungen vorzusehen, wie Abbildung 6-15 zeigt. In den beiden Feature-Speichern sind die gleichen Feature-Daten enthalten, was die Konsistenz zwischen Training und Serving sicherstellt.
Abbildung 6-15: Feature-Daten können entweder offline abgerufen werden, um vergangenheitsbezogene Features für das Modelltraining zu verwenden, oder online für das Serving.
Auf diese Bereitstellungen kann über einen separaten Online- und Batch-Client zugegriffen werden:
_feast_online_client = Client(serving_url='localhost:6566')
_feast_batch_client = Client(serving_url='localhost:6567',
core_url='localhost:6565')
Batch-Serving.BigQuery unterstützt es, vergangenheitsbezogene Features zum Trainieren eines Modells abzurufen. Der Zugriff darauf erfolgt mit dem Batch-Serving-Client über die Methode .get_batch_features(...). In unserem Beispiel übergeben wir Feast einen Pandas-Dataframe mit den Entitäten und Zeitstempeln, über die die Feature-Daten verknüpft werden. Dadurch ist Feast in der Lage, einen zeitpunktkorrekten Datensatz basierend auf den angeforderten Features zu erstellen:
# Eine Entität df aller Entitäten und Zeitstempel erstellen.
entity_df = pd.DataFrame(
{
"datetime": taxi_df.datetime,
"taxi_id": taxi_df.taxi_id,
}
)
Um vergangenheitsbezogene Features abzurufen, verweisen Sie auf die Features im Feature-Set nach dem Namen des Feature-Sets und dem Namen des Features, getrennt durch einen Doppelpunkt – zum Beispiel taxi_rides:pickup_lat:
FS_NAME = taxi_rides
model_features = ['pickup_lat',
'pickup_lon',
'dropoff_lat',
'dropoff_lon',
'num_pass',
'euclid_dist']
label = 'fare_amt'
features = model_features + [label]
# Trainingsdatensatz von Feast abrufen.
dataset = _feast_batch_client.get_batch_features(
feature_refs=[FS_NAME + ":" + feature for feature in features],
entity_rows=entity_df).to_dataframe()
Der Dataframe-Datensatz enthält jetzt alle Features und das Label für unser Modell – abgerufen direkt aus dem Feature-Speicher.
Online-Serving.Für das Online-Serving speichert Feast nur die neuesten Entitätswerte im Unterschied zum vergangenheitsbezogenen Serving, bei dem sämtliche historischen Werte gespeichert werden. Online-Serving mit Feast ist auf sehr geringe Latenz ausgelegt, und Feast bietet eine gRPC-API, die von Redis unterstützt wird. Um zum Beispiel Online-Features abzurufen, wenn Online-Vorhersagen mit dem trainierten Modell erfolgen sollen, verwenden wir die Methode .get_online_features(...), der wir die Features, die wir erfassen wollen, und die Entität übergeben:
# Online-Features für eine einzelne taxi_id abrufen.
online_features = _feast_online_client.get_online_features(
feature_refs=["taxi_rides:pickup_lat",
"taxi_rides:pickup_lon",
"taxi_rides:dropoff_lat",
"taxi_rides:dropoff_lon",
"taxi_rides:num_pass",
"taxi_rides:euclid_dist"],
entity_rows=[
GetOnlineFeaturesRequest.EntityRow(
fields={
"taxi_id": Value(
int64_val=5)
}
)
]
)
Dieser Code speichert online_features als Liste von Karten, wobei der Eintrag in der Liste die neuesten Feature-Werte für die bereitgestellte Entität enthält, hier taxi_id = 5:
field_values {
fields {
key: "taxi_id"
value {
int64_val: 5
}
}
fields {
key: "taxi_rides:dropoff_lat"
value {
double_val: 40.78923797607422
}
}
fields {
key: "taxi_rides:dropoff_lon"
value {
double_val: -73.96871948242188
}
...
Um für dieses Beispiel eine Online-Vorhersage zu erstellen, übergeben wir die Feldwerte aus dem in online_features zurückgegebenen Objekt als Pandas-Dataframe namens predict_df an model.predict:
predict_df = pd.DataFrame.from_dict(online_features_dict)
model.predict(predict_df)
Feature-Speicher funktionieren, weil sie das Feature Engineering von der Feature-Nutzung entkoppeln, sodass Feature-Entwicklung und -Erstellung unabhängig von der Nutzung der Features während der Modellentwicklung erfolgen können. Wenn Features zum Feature-Speicher hinzukommen, sind sie sofort sowohl für das Training als auch für das Serving verfügbar und werden an einem einzigen Ort gespeichert. Damit wird die Konsistenz zwischen Training und Serving des Modells gewährleistet.
Zum Beispiel empfängt ein Modell, das als kundenorientierte Anwendung bereitgestellt wird, vielleicht nur zehn Eingabewerte von einem Client, doch müssen diese zehn Eingaben gegebenenfalls per Feature Engineering in viele weitere Features transformiert werden, bevor sie an ein Modell gesendet werden. Diese konstruierten Features werden innerhalb des Feature-Speichers verwaltet. Es ist entscheidend, dass die Pipeline für das Abrufen von Features während der Entwicklung dieselbe ist wie beim Serving des Modells. Ein Feature-Speicher stellt diese Konsistenz sicher (siehe Abbildung 6-16).
Feast erreicht dies mithilfe von Beam auf dem Backend für Feature-Einlesepipelines, die Feature-Werte in die Feature-Sets schreiben, und verwendet Redis und BigQuery für das Abrufen von Features online bzw. offline (siehe Abbildung 6-17).8 Wie bei jedem Feature-Speicher kümmert sich die Eingabepipeline auch um Teilausfälle oder Racebedingungen, die dazu führen können, dass manche Daten in dem einen Speicher vorhanden sind, im anderen aber nicht.
Abbildung 6-16: Ein Feature-Speicher gewährleistet, dass die Feature-Engineering-Pipelines zwischen Training und Serving des Modells konsistent sind (siehe auch https://docs.feast.dev/).
Abbildung 6-17: Feast verwendet Beam im Backend für das Einlesen von Features sowie Redis und BigQuery für das Abrufen von Features online und offline.
Verschiedene Systeme können Daten in unterschiedlichen Raten produzieren, und jeder Feature-Speicher ist flexibel genug, um mit diesen unterschiedlichen Kadenzen umzugehen, sowohl beim Einlesen als auch beim Abrufen (siehe Abbildung 6-18).
Abbildung 6-18: Das Entwurfsmuster Feature Store kann sowohl die Anforderungen an eine hohe Skalierbarkeit der Daten für große Batches während des Trainings als auch an eine äußerst niedrige Latenz für das Serving von Onlineanwendungen erfüllen.
Zum Beispiel könnten Sensordaten in Echtzeit entstehen und jede Sekunde eintreffen, oder es könnte eine monatliche Datei geben, die von einem externen System generiert wird und eine Zusammenfassung der Transaktionen des letzten Monats enthält. Alle diese Daten müssen verarbeitet und in den Feature-Speicher eingelesen werden. Ebenso kann es verschiedene Zeithorizonte für das Abrufen von Daten aus dem Feature-Speicher geben. Beispielsweise kann eine benutzerorientierte Onlineanwendung mit sehr geringer Latenz arbeiten und sekundengenaue Features verwenden, während Features beim Trainieren des Modells offline als großer Batch abgerufen werden, aber mit höherer Latenz.
Es gibt keine einzelne Datenbank, die sowohl die Skalierung auf potenziell Terabyte-große Datenmengen als auch auf extrem niedrige Latenz in der Größenordnung von Millisekunden beherrscht. Der Feature-Speicher erreicht dies mit separaten Online- und Offline-Feature-Speichern und stellt sicher, dass Features in beiden Szenarios konsistent behandelt werden.
Schließlich fungiert ein Feature-Speicher als Repository mit Versionskontrolle für Feature-Datensätze, sodass sich die gleichen CI/CD-Praktiken der Code- und Modellentwicklung auf den Prozess des Feature Engineerings anwenden lassen. Das bedeutet, dass neue ML-Projekte mit einer Feature-Auswahl aus einem Katalog starten, anstatt das Feature Engineering von Grund auf neu zu absolvieren. Dadurch können Organisationen einen Skaleneffekt erzielen – wenn neue Features erzeugt und dem Feature-Speicher hinzugefügt werden, geht es einfacher und schneller, neue Modelle zu erstellen, die diese Features wiederverwenden.
Das hier besprochene Feast-Framework baut auf Google BigQuery, Redis und Apache Beam auf. Es gibt aber auch Feature-Speicher, die sich auf andere Tools und Tech Stacks stützen. Und obwohl ein Feature-Speicher die empfohlene Methode darstellt, Features in großem Umfang zu verwalten, bietet tf.transform eine alternative Lösung, die das Problem der Training-Serving-Verzerrung angeht, aber nicht die Wiederverwendbarkeit von Features. Zudem gibt es einige alternative Verwendungen eines Feature-Speichers, auf die wir bisher noch nicht näher eingegangen sind, beispielsweise wie ein Feature-Speicher mit Daten aus verschiedenen Quellen umgeht und mit Daten, die bei verschiedenen Kadenzen eintreffen.
Viele große Technologieunternehmen wie Uber, LinkedIn, Airbnb, Netflix und Comcast hosten ihre eigene Version eines Feature-Speichers, obwohl die Architekturen und Tools variieren. Michelangelo Palette von Uber basiert auf Spark/Scala und verwendet Hive für das Erstellen von Offline-Features und Cassandra für Online-Features. Hopsworks bietet einen anderen Open-Source-Feature-Speicher als Alternative zu Feast und basiert auf Dataframes, die Spark verwenden, sowie Pandas mit Hive für Offlinezugriffe und MySQL-Cluster für den Onlinezugriff auf Features. Airbnb hat einen eigenen Feature-Speicher als Teil seines ML-Produktions-Frameworks namens Zipline entwickelt. Es verwendet Spark und Flink für Feature-Engineering-Jobs und Hive für die Feature-Speicherung. Unabhängig vom verwendeten Tech Stack sind die primären Komponenten des Feature-Speichers die gleichen:
Wenn sich der Feature-Engineering-Code zwischen Training und Inferenz unterscheidet, besteht die Gefahr, dass die beiden Codequellen nicht konsistent sind. Dies führt zu einer Training-Serving-Verzerrung, und Modellvorhersagen sind möglicherweise nicht zuverlässig, da die Features nicht gleich sein können. Feature-Speicher umgehen dieses Problem, indem sie ihre Feature-Engineering-Jobs die Feature-Daten sowohl in eine Online- als auch in eine Offlinedatenbank schreiben lassen. Und obwohl ein Feature-Speicher selbst keine Feature-Transformationen durchführt, bietet er eine Möglichkeit, um die vorgelagerten Feature-Engineering-Schritte von der Modellbereitstellung zu trennen und eine zeitnahe Korrektheit zu gewährleisten.
Das in diesem Kapitel besprochene Entwurfsmuster Transformation erlaubt es zudem, Feature-Transformationen separat und reproduzierbar zu halten. Zum Beispiel lassen sich mit tf.transform Daten mit genau dem gleichen Code vorverarbeiten, der sowohl für das Training eines Modells als auch für die Bereitstellung von Vorhersagen in der Produktion verwendet wird. Eine Training-Serving-Verzerrung tritt daher nicht auf. So ist sichergestellt, dass die Feature-Engineering-Pipelines für Training und Serving konsistent sind.
Der Feature-Speicher bietet jedoch den zusätzlichen Vorteil der Wiederverwendbarkeit von Features, was tf.transform nicht hat. Obwohl tf.transform-Pipelines die Reproduzierbarkeit sicherstellen, werden Features nur für dieses Modell erstellt und entwickelt; andere Modelle und Pipelines können sie nicht einfach gemeinsam nutzen oder wiederverwenden.
Andererseits achtet tf.transform besonders darauf, dass die Feature-Erstellung während des Servings auf beschleunigter Hardware erfolgt, da sie Teil des Serving-Graphen ist. Feature-Speicher bieten diese Fähigkeit heute in der Regel nicht.
Beim Entwurfsmuster Modellversionierung wird Abwärtskompatibilität erreicht, indem ein geändertes Modell als Microservice mit einem anderen REST-Endpunkt bereitgestellt wird. Dies ist eine notwendige Voraussetzung für viele der anderen Muster, die dieses Kapitel beschreibt.
Wie wir bei der Datendrift (in Kapitel 1 eingeführt) gesehen haben, können Modelle im Laufe der Zeit veralten und müssen regelmäßig aktualisiert werden, um sicherzustellen, dass sie die Änderungsziele einer Organisation und die Umgebung, die mit ihren Trainingsdaten verbunden ist, widerspiegeln. Die Bereitstellung von Modellaktualisierungen in der Produktion wirkt sich unweigerlich darauf aus, wie sich Modelle bei neuen Daten verhalten. Um uns dieser Herausforderung zu stellen, brauchen wir einen Ansatz, der die Produktionsmodelle auf dem neuesten Stand hält, während die Abwärtskompatibilität für bestehende Modellbenutzer gewahrt bleibt.
Aktualisierungen an einem vorhandenen Modell können sich auf die Modellarchitektur beziehen, um die Genauigkeit zu verbessern, oder auf das Retraining eines Modells auf aktuelleren Daten, um der Datendrift zu begegnen. Derartige Änderungen erfordern höchstwahrscheinlich kein anderes Modellausgabeformat, wirken sich aber auf die Vorhersageergebnisse aus, die ein Benutzer vom Modell erhält. Nehmen wir als Beispiel an, wir erstellten ein Modell, das das Genre eines Buchs anhand seiner Beschreibung vorhersagt und die vorhergesagten Genres verwendet, um Empfehlungen für Benutzer zu geben. Unser anfängliches Modell haben wir auf einem Datensatz älterer klassischer Bücher trainiert, jetzt aber Zugriff erhalten auf neue Daten Tausender neuerer Büchern, die wir für das Training verwenden können. Das Training auf diesem aktualisierten Datensatz verbessert unsere Gesamtgenauigkeit des Modells, verringert aber leicht die Genauigkeit bei älteren »klassischen« Büchern. Um dies in den Griff zu bekommen, brauchen wir eine Lösung, die es Benutzern ermöglicht, eine ältere Version unseres Modells zu wählen, wenn sie dies wünschen.
Alternativ dazu könnten die Endbenutzer unseres Modells mehr Informationen darüber anfordern, wie das Modell zu einer bestimmten Vorhersage gelangt. In einem medizinischen Anwendungsfall möchte der Arzt vielleicht die Regionen in einem Röntgenbild sehen, die ein Modell dazu veranlasst haben, das Vorhandensein einer Krankheit vorherzusagen, anstatt sich allein auf das vorhergesagte Label zu verlassen. In diesem Fall müsste die Antwort eines bereitgestellten Modells aktualisiert werden, um die hervorgehobenen Regionen einzubeziehen. Dieser Vorgang wird als Erklärbarkeit bezeichnet und in Kapitel 7 näher erläutert.
Wenn wir Aktualisierungen für unser Modell bereitstellen, möchten wir wahrscheinlich auch verfolgen können, wie das Modell in der Produktion abschneidet, um die Performance mit vorherigen Iterationen zu vergleichen. Vielleicht möchten wir auch eine Möglichkeit haben, ein neues Modell nur mit einer Teilmenge unserer Benutzer zu testen. Sowohl die Performanceüberwachung als auch A/B-Tests werden zusammen mit anderen möglichen Modelländerungen schwer dadurch zu lösen sein, dass bei jeder Aktualisierung ein einzelnes Produktionsmodell ersetzt wird. Denn dabei werden Anwendungen außer Tritt gebracht, die sich auf ein bestimmtes Format unserer Modellausgabe verlassen. Um dies in den Griff zu bekommen, ist eine Lösung gefragt, bei der wir unser Modell kontinuierlich aktualisieren können, ohne vorhandene Benutzer zu beeinträchtigen.
Um Aktualisierungen eines Modells ordnungsgemäß zu verarbeiten, stellen Sie mehrere Modellversionen mit verschiedenen REST-Endpunkten bereit. Dies gewährleistet die Abwärtskompatibilität – indem mehrere Versionen eines zu einem bestimmten Zeitpunkt bereitgestellten Modells verfügbar gehalten werden, können Benutzer, die auf ältere Versionen angewiesen sind, den Dienst weiterhin nutzen. Die Versionierung ermöglicht auch eine feinstufige Performanceüberwachung und Analysefunktionen über verschiedene Versionen hinweg. Wir können Genauigkeits- und Nutzungsstatistiken vergleichen und anhand dieser Daten bestimmen, wann eine bestimmte Version offline genommen werden sollte. Wenn wir eine Modellaktualisierung nur mit einer kleinen Teilmenge von Benutzern testen möchten, macht es das Entwurfsmuster Modellversionierung möglich, A/B-Tests durchzuführen. Darüber hinaus ist bei der Modellversionierung jede bereitgestellte Version unseres Modells ein Microservice – somit werden Änderungen an unserem Modell von unserem Anwendungs-Frontend entkoppelt. Um Unterstützung für eine neue Version hinzuzufügen, müssen die Anwendungsentwickler:innen unseres Teams nur den Namen des API-Endpunkts ändern, der auf das Modell zeigt. Wenn eine neue Modellversion das Antwortformat des Modells ändert, müssen wir natürlich unsere App dementsprechend anpassen, doch Modell- und Anwendungscode bleiben immer noch getrennt. Data Scientists oder ML Engineers können demzufolge eine neue Modellversion bereitstellen und testen, ohne sich darum kümmern zu müssen, ob unsere Produktionsanwendung aussteigt.
Wenn wir von »Endbenutzern« unseres Modells sprechen, schließt dies zwei verschiedene Personengruppen ein. Machen wir den API-Endpunkt unseres Modells für Anwendungsentwickler:innen außerhalb unserer Organisation verfügbar, kann man sich diese Entwickler:innen als eine Art von Modellbenutzer vorstellen. Sie erstellen Anwendungen, die sich auf unser Modell stützen, um Vorhersagen für andere bereitzustellen. Am wichtigsten für diese Benutzer ist der Vorteil der Abwärtskompatibilität, die mit der Modellversionierung einhergeht. Wenn sich das Format der Antworten unseres Modells ändert, möchten Anwendungsentwickler:innen vielleicht eine ältere Modellversion verwenden, bis sie ihren Anwendungscode aktualisiert haben, um das neueste Antwortformat zu unterstützen.
Zur anderen Gruppe von Endbenutzern gehören diejenigen, die eine Anwendung einsetzen, die unser bereitgestelltes Modell aufruft. Unter anderem könnte dies eine Ärztin sein, der sich auf unser Modell verlässt, um in einem Bild das Vorhandensein einer Krankheit vorherzusagen, oder jemand, der unsere App zur Buchempfehlung verwendet, vielleicht die Businessabteilung unserer Organisation, die die Ausgabe eines von uns erstellten Umsatzvorhersagemodells analysiert usw. Bei dieser Gruppe von Benutzern ist es weniger wahrscheinlich, dass sie Probleme mit der Abwärtskompatibilität bekommen, doch vielleicht möchten sie selbst entscheiden können, ab wann sie ein neues Feature unserer App nutzen. Wenn sich Benutzer verschiedenen Gruppen zuordnen lassen (z. B. basierend auf ihrer App-Nutzung), können wir jeder Gruppe verschiedene Modellversionen je nach ihren Präferenzen anbieten.
Die Versionierung wollen wir an einem Modell, das Flugverspätungen vorhersagt, demonstrieren. Dieses Modell stellen wir auf Cloud AI Platform Prediction (https://oreil.ly/-GAVQ) bereit. Da wir uns in den vorherigen Kapiteln SavedModel von TensorFlow angesehen haben, verwenden wir hier ein XGBoost-Modell.
Nachdem wir das Modell trainiert haben, können wir es exportieren, um es für das Serving bereitzumachen:
model.save_model('model.bst')
Wenn wir dieses Modell auf AI Platform bereitstellen, müssen wir eine Modellversion erzeugen, die auf dieses model.bst in einem Cloud-Storage-Bucket zeigt.
In AI Platform können einer Modellressource viele Versionen zugeordnet sein. Um eine neue Version mit der gcloud-CLI zu erstellen, führen Sie den folgenden Befehl in einem Terminal aus:
gcloud ai-platform versions create 'v1' \
--model 'flight_delay_prediction' \
--origin gs://your-gcs-bucket \
--runtime-version=1.15 \
--framework 'XGBOOST' \
--python-version=3.7
Das bereitgestellte Modell ist nun über den Endpunkt /models/flight_delay_predictions/versions/v1 in einer HTTPS-URL zugänglich, die an unser Projekt gebunden ist. Da dies bislang die einzige bereitgestellte Version ist, gilt sie als Standardversion. Wenn wir also keine Version in unserer API-Anfrage angeben, verwendet der Vorhersagedienst die Version v1. Jetzt können wir Vorhersagen für unser bereitgestelltes Modell treffen, indem wir ihm Beispiele in einem Format senden, das das Modell erwartet – in diesem Fall ein 110-elementiges Array mit Dummy-codierten Flughafencodes (den vollständigen Code finden Sie im Notebook auf GitHub unter https://github.com/GoogleCloudPlatform/ml-design-patterns/blob/master/06_reproducibility/model_versioning.ipynb). Das Modell gibt den Ausgabewert einer Sigmoid-Aktivierungsfunktion zurück, d. h. einen Gleitkommawert zwischen 0 und 1, der die Wahrscheinlichkeit einer Verspätung von mehr als 30 Minuten für einen bestimmten Flug anzeigt.
Mit dem folgenden gcloud-Befehl setzen wir eine Vorhersageanfrage an unser bereitgestelltes Modell ab, wobei input.json eine Datei mit – durch Zeilenschaltungen getrennten – Beispielen ist, die zur Vorhersage gesendet werden sollen:
gcloud ai-platform predict --model 'flight_delay_prediction'
--version 'v1'
--json-request 'input.json'
Wenn wir fünf Beispiele zur Vorhersage senden, erhalten wir ein Array mit fünf Elementen zurück, das den Ausgaben der Sigmoid-Aktivierungsfunktionen für die einzelnen Testbeispiele entspricht und wie folgt aussehen könnte:
[0.019, 0.998, 0.213, 0.002, 0.004]
Da wir nun ein funktionsfähiges Modell in der Produktion haben, stellen wir uns vor, dass unser Data-Science-Team beschließt, das Modell von XGBoost auf TensorFlow umzustellen, weil dessen Genauigkeit besser ist und es Zugriff auf zusätzliche Werkzeuge im TensorFlow-Ökosystem bietet. Das Modell hat das gleiche Ein- und Ausgabeformat, doch die Architektur und das Format der exportierten Assets haben sich geändert. Statt in einer .bst-Datei ist das Modell jetzt im TensorFlow-SavedModel-Format gespeichert. Im Idealfall können wir unsere zugrunde liegenden Modell-Assets von unserem Anwendungs-Frontend getrennt halten – Anwendungsentwickler:innen können sich dann auf die Funktionalität der Anwendung konzentrieren und müssen sich nicht um eine Änderung im Modellformat kümmern, die gar keinen Einfluss auf die Art und Weise hat, wie Endbenutzer mit dem Modell interagieren. In einer derartigen Situation kann die Modellversionierung helfen. Wir stellen unser TensorFlow-Modell als zweite Version unter der gleichen flight_delay_prediction-Modellressource bereit. Endbenutzer können auf die neue Version für verbesserte Performance aktualisieren, indem sie einfach den Versionsnamen im API-Endpunkt ändern.
Um unsere zweite Version bereitzustellen, exportieren wir das Modell und kopieren es in ein neues Unterverzeichnis in dem Bucket, den wir bisher verwendet haben. Wir können den gleichen Bereitstellungsbefehl wie oben verwenden, ersetzen aber den Versionsnamen durch v2 und zeigen auf den Cloud-Storage-Speicherort des neuen Modells. Wie Abbildung 6-19 zeigt, können wir nun beide bereitgestellten Versionen in unserer Cloud-Konsole sehen.
Abbildung 6-19: Das Dashboard für die Verwaltung von Modellen und Versionen in der Cloud-AI-Platform-Konsole
Wir haben hier auch v2 als neue Standardversion festgelegt, damit Benutzer eine Antwort von v2 erhalten, wenn sie keine Version explizit angeben. Da die Ein- und Ausgabeformate unseres Modells gleich sind, können Clients upgraden, ohne sich über störende Änderungen Gedanken machen zu müssen.
![]() |
Sowohl Azure als auch AWS verfügen über ähnliche Dienste für die Modellversionierung. Azure stellt die Modellbereitstellung und -versionierung mit Azure Machine Learning (https://oreil.ly/Q7NWh) bereit. In AWS sind diese Dienste in SageMaker (https://oreil.ly/r98Ve) verfügbar. |
Ein ML Engineer, der eine neue Version eines Modells als ML-Modellendpunkt bereitstellt, möchte vielleicht ein API-Gateway wie Apigee verwenden, das die aufzurufende Modellversion bestimmt. Für diese Vorgehensweise gibt es verschiedene Gründe, einschließlich A/B-Tests einer neuen Version. Für A/B-Tests möchten Sie vielleicht ein Modell-Update mit einer zufällig ausgewählten Gruppe von 10 % der Anwendungsnutzer haben, um zu verfolgen, wie es sich auf die gesamte Auseinandersetzung mit der App auswirkt. Das API-Gateway bestimmt anhand der ID oder IP-Adresse eines Benutzers, welche bereitgestellte Modellversion aufgerufen werden soll.
Bei mehreren bereitgestellten Modellversionen ermöglicht AI Platform die Performanceüberwachung und -analyse über verschiedene Versionen hinweg. Somit können wir Fehler zu einer bestimmten Version verfolgen, den Datenverkehr überwachen und dies mit zusätzlichen Daten kombinieren, die wir in unserer Anwendung sammeln.
Versionierung, um mit neu verfügbaren Daten umzugehen
Neben dem Umgang mit Änderungen an unserem Modell selbst bietet sich Versionierung auch an, wenn neue Trainingsdaten verfügbar werden. Unter der Annahme, dass diese neuen Daten dem gleichen Schema folgen, das beim Training des ursprünglichen Modells verwendet wurde, muss unbedingt verfolgt werden, wann die Daten für jede neu trainierte Version erfasst wurden. Ein Ansatz für diese Verfolgung ist es, den Zeitstempelbereich jedes Trainingsdatensatzes im Namen einer Modellversion zu codieren. Wenn zum Beispiel die neueste Version eines Modells auf Daten aus dem Jahr 2019 trainiert wurde, könnten wir die Version v20190101_20191231 nennen.
In Kombination mit »Entwurfsmuster 18: Kontinuierliche Modellbewertung« auf Seite 245 (siehe Kapitel 5) hilft uns dieser Ansatz, zu bestimmen, wann ältere Modellversionen offline genommen werden sollten oder wie weit zurück die Trainingsdaten gehen müssten. Die fortlaufende Auswertung könnte uns dabei helfen, festzustellen, dass unser Modell am besten abschneidet, wenn es auf Daten aus den letzten zwei Jahren trainiert wurde. Dies könnte dann darüber Aufschluss geben, welche Versionen wir entfernen und wie viele Daten zu verwenden sind, wenn neuere Versionen trainiert werden.
Wir empfehlen zwar das Entwurfsmuster Modellversionierung gegenüber der Verwaltung einer einzelnen Modellversion, es gibt aber einige Implementierungsalternativen für oben skizzierte Lösung. Hier sehen wir uns andere serverlose und Open-Source-Tools für dieses Muster und den Ansatz der Erstellung mehrerer Serving-Funktionen an. Außerdem erörtern wir, wann man eine gänzlich neue Modellressource statt einer Version erstellen sollte.
Wir haben einen verwalteten Dienst verwendet, der speziell für die Versionierung von ML-Modellen entwickelt wurde, wir könnten aber ähnliche Ergebnisse mit anderen serverlosen Angeboten erreichen. Hinter den Kulissen ist jede Modellversion eine zustandslose Funktion mit bestimmten Eingabe- und Ausgabeformaten, die hinter einem REST-Endpunkt bereitgestellt wird. Demzufolge könnten wir einen Dienst wie beispielsweise Cloud Run (https://oreil.ly/KERBV) verwenden, um jede Version in einem separaten Container zu erzeugen und bereitzustellen. Jeder Container hat eine eindeutige URL und kann durch eine API-Anfrage aufgerufen werden. Bei diesem Ansatz sind wir flexibler darin, die bereitgestellte Modellumgebung zu konfigurieren, und können auch Funktionen wie die serverseitige Vorverarbeitung für Modelleingaben hinzufügen. In unserem obigen Beispiel der Flugverspätungen werden wir unseren Clients vielleicht nicht zumuten wollen, kategoriale Werte 1-aus-n zu codieren. Stattdessen könnten wir den Clients erlauben, die kategorialen Werte als Strings zu übergeben, und die Vorverarbeitung in unserem Container vornehmen.
Weshalb sollten wir einen verwalteten ML-Dienst wie AI Platform Prediction anstelle eines allgemeineren serverlosen Tools verwenden? Da AI Platform speziell für die Bereitstellung von ML-Modellen konzipiert ist, unterstützt es von Haus aus die Bereitstellung der Modelle mit GPUs, die für maschinelles Lernen optimiert sind. Zudem beherrscht es die Verwaltung von Abhängigkeiten. Als wir oben unser XGBoost-Modell bereitstellten, mussten wir uns nicht um die Installation der korrekten XGBoost-Version oder andere Bibliotheksabhängigkeiten kümmern.
Anstatt Cloud AI Platform oder ein anderes Cloud-basiertes serverloses Angebot für die Modellversionierung zu verwenden, könnten wir auch zu einem Open-Source-Tool wie TensorFlow Serving (https://oreil.ly/NzDA9) greifen. Für die Implementierung von TensorFlow Serving wird empfohlen, einen Docker-Container über das neueste tensorflow/serving-Docker-Image (https://oreil.ly/G0_Z7) zu verwenden. Mit Docker könnten wir dann das Modell auf jeder gewünschten Hardware bereitstellen, GPUs eingeschlossen. Die TensorFlow Serving-API bringt integrierte Unterstützung für die Modellversionierung mit und folgt einem ähnlichen Ansatz wie dem, den der Abschnitt »Lösung« beschrieben hat. Neben TensorFlow Serving gibt es weitere Open-Source-Modell-Serving-Optionen, unter anderem Seldon (https://oreil.ly/Cddpi) und MLFlow (https://mlflow.org/).
Anstatt mehrere Versionen bereitzustellen, ist es auch möglich, mehrere Serving-Funktionen für eine einzelne Version eines exportierten Modells zu definieren. Der Abschnitt »Entwurfsmuster 16: Zustandslose Serving-Funktion« auf Seite 225 (in Kapitel 5 eingeführt) hat erläutert, wie ein trainiertes Modell als zustandslose Funktion für das Serving in der Produktion zu exportieren ist. Dies ist besonders nützlich, wenn Modelleingaben eine Vorverarbeitung benötigen, um die vom Client gesendeten Daten in das Format zu transformieren, das das Modell erwartet.
Um den Anforderungen verschiedener Gruppen von Modellendbenutzern zu entsprechen, können wir mehrere Serving-Funktionen definieren, wenn wir unser Modell exportieren. Diese Serving-Funktionen sind Teil einer exportierten Modellversion, und dieses Modell wird an einem einzigen REST-Endpunkt bereitgestellt. In TensorFlow werden Serving-Funktionen mithilfe von Modellsignaturen implementiert. Diese definieren das Eingabe- und Ausgabeformat, das ein Modell erwartet. Mit dem Dekorator @tf.function können wir mehrere Serving-Funktionen definieren und jeder Funktion eine Eingabesignatur übergeben.
Im Anwendungscode, in dem wir unser bereitgestelltes Modell aufrufen, würden wir anhand der vom Client gesendeten Daten bestimmen, welche Serving-Funktion zu verwenden ist. Zum Beispiel würde eine Anfrage wie
{"signature_name": "get_genre", "instances": ... }
an die exportierte Signatur namens get_genre gesendet, während eine Anfrage wie
{"signature_name": "get_genre_with_explanation", "instances": ... }
an die exportierte Signatur namens get_genre_with_explanation gesendet würde.
Demzufolge kann die Bereitstellung mehrerer Signaturen das Problem der Abwärtskompatibilität lösen. Es besteht allerdings ein wesentlicher Unterschied – es gibt nur ein Modell, und wenn dieses Modell bereitgestellt wird, werden alle Signaturen gleichzeitig aktualisiert. In unserem ursprünglichen Beispiel, in dem das Modell geändert wurde, um nicht mehr nur ein Genre bereitzustellen, sondern mehrere Genres, hat sich die Modellarchitektur geändert. Der Ansatz mit mehreren Signaturen würde bei diesem Beispiel nicht funktionieren, da wir zwei verschiedene Modelle haben. Außerdem ist die Lösung mit mehreren Signaturen nicht geeignet, wenn wir verschiedene Versionen des Modells getrennt halten und die ältere Version mit der Zeit veralten lassen wollen.
Es ist besser, mehrere Signaturen zu verwenden als mehrere Versionen, wenn Sie künftig beide Modellversionen beibehalten möchten. In einem Szenario, in dem es einige Kunden gibt, die einfach nur die beste Antwort wünschen, und andere Kunden, die sowohl die beste Antwort als auch eine Erklärung bevorzugen, besteht ein zusätzlicher Vorteil darin, alle Signaturen mit einem neueren Modell zu aktualisieren, anstatt die Versionen jedes Mal einzeln aktualisieren zu müssen, wenn das Modell erneut trainiert und bereitgestellt wird.
Wie sehen Szenarios aus, in denen wir beide Versionen des Modells beibehalten möchten? Bei einem Textklassifizierungsmodell haben wir möglicherweise einige Kunden, die dem Modell den Rohtext senden müssen, und andere, die den Rohtext in Matrizen transformieren können, bevor sie eine Vorhersage bekommen. Basierend auf den Anfragedaten vom Client kann das Modell-Framework bestimmen, welche Serving-Funktion zu verwenden ist. Da die Übergabe von Texteinbettungsmatrizen an ein Modell weniger aufwendig ist als die Vorverarbeitung von Rohtext, ist dies ein Beispiel, bei dem mehrere Serving-Funktionen die serverseitige Verarbeitungszeit verringern könnten. Erwähnenswert ist auch, dass wir mehrere Serving-Funktionen mit mehreren Modellversionen haben können, auch wenn die Gefahr besteht, dass dies zu viel Komplexität erzeugt.
Manchmal ist es schwierig, zu entscheiden, ob eine andere Modellversion oder eine vollkommen neue Modellressource erstellt werden soll. Wir empfehlen, ein neues Modell zu erstellen, wenn sich die Vorhersageaufgabe eines Modells ändert. Eine neue Vorhersageaufgabe führt in der Regel zu einem anderen Modellausgabeformat, und wenn man dieses ändert, könnten vorhandene Clients nicht mehr funktionieren. Sofern Sie sich nicht sicher sind, ob eine neue Version oder ein neues Modell besser ist, überlegen Sie sich, ob Sie vorhandene Clients aktualisieren wollen. Wenn die Antwort »Ja« lautet, haben wir wahrscheinlich das Modell verbessert, ohne die Vorhersageaufgabe zu ändern. Dann genügt es, eine neue Version zu erstellen. Haben wir das Modell in einer Weise geändert, dass Benutzer entscheiden müssen, ob sie aktualisieren möchten, werden wir wahrscheinlich eine neue Modellressource erstellen.
Um dies in der Praxis an einem Beispiel zu sehen, kehren wir zu unserem Flugvorhersagemodell zurück. Das aktuelle Modell hat definiert, was es als Verspätung betrachtet (mehr als 30 Minuten Verspätung), doch unsere Endbenutzer haben diesbezüglich vielleicht andere Meinungen. Einige Benutzer sind der Ansicht, dass 15 Minuten bereits als Verspätung zählen, während andere denken, dass ein Flug erst dann verspätet ist, wenn er über eine Stunde später ankommt. Stellen wir uns vor, dass wir unseren Benutzern die Möglichkeit geben möchten, ihre eigene Definition von »verspätet« einzubringen, anstatt unsere zu verwenden. In diesem Fall kommt »Entwurfsmuster 5: Reframing« auf Seite 100 (in Kapitel 3 erläutert) infrage, um das Modell in ein Regressionsmodell zu ändern. Das Eingabeformat für dieses Modell bleibt gleich, doch die Ausgabe ist jetzt ein numerischer Wert, der die Verspätungsvorhersage darstellt.
Die Art und Weise, wie unsere Modellbenutzer diese Antwort analysieren, wird sich natürlich von der ersten Version unterscheiden. Mit unserem neuesten Regressionsmodell könnten sich App-Entwickler:innen dafür entscheiden, die vorhergesagte Verspätung anzuzeigen, wenn Benutzer nach Flügen suchen. Dabei wird etwas wie »Dieser Flug hat normalerweise mehr als 30 Minuten Verspätung« aus der ersten Version ersetzt. In diesem Szenario ist es am besten, eine neue Modellressource zu erstellen, vielleicht flight_model_regression genannt, um die Änderungen widerzuspiegeln. Auf diese Weise können die App-Entwickler:innen wählen, welche Version sie verwenden möchten, und wir können weiterhin Performanceaktualisierungen für jedes Modell vornehmen, indem wir neue Versionen bereitstellen.
Im Mittelpunkt dieses Kapitels stehen Entwurfsmuster, die sich mit verschiedenen Aspekten der Reproduzierbarkeit befassen. Beginnend mit dem Entwurfsmuster Transformation haben wir gesehen, wie dieses Muster eingesetzt wird, um die Reproduzierbarkeit der Datenvorbereitungsabhängigkeiten zwischen der Pipeline für das Training des Modells und der Pipeline für das Serving des Modells sicherzustellen. Hierzu werden die angewendeten Transformationen erfasst, um die Modelleingaben in die Modell-Features zu konvertieren. Das Entwurfsmuster Wiederholbare Aufteilung erfasst die Art und Weise, wie die Daten auf die Datensätze für Training, Validierung und Test aufgeteilt werden, um sicherzustellen, dass ein beim Training verwendetes Beispiel niemals für die Evaluierung oder das Testen verwendet wird, selbst wenn der Datensatz wächst.
Beim Entwurfsmuster Bridged Schema geht es darum, wie sich die Reproduzierbarkeit sicherstellen lässt, wenn ein Trainingsdatensatz ein Hybrid aus neueren Daten und älteren Daten mit einem anderen Schema ist. Dadurch lassen sich zwei Datensätze mit verschiedenen Schemas in konsistenter Weise für das Training kombinieren. Als Nächstes haben wir das Entwurfsmuster Windows Inference vorgestellt. Es gewährleistet, dass dynamisch und zeitabhängig berechnete Features korrekt zwischen Training und Serving wiederholt werden können. Dieses Entwurfsmuster ist vor allem dann nützlich, wenn ML-Modelle Features benötigen, die aus Aggregaten über Zeitfenstern berechnet werden.
Das Entwurfsmuster Workflow-Pipeline befasst sich mit dem Problem, eine end-to-end reproduzierbare Pipeline zu erstellen, indem die Schritte in unserem ML-Workflow containerisiert und orchestriert werden. Als Nächstes haben wir gesehen, wie das Entwurfsmuster Feature Store verwendet werden kann, um Reproduzierbarkeit und Wiederverwendbarkeit von Features über verschiedene ML-Jobs hinweg in den Griff zu bekommen. Zum Abschluss dieses Kapitels hat das Entwurfsmuster Modellversionierung gezeigt, wie sich Abwärtskompatibilität erreichen lässt, indem ein geändertes Modell als Microservice mit einem anderen REST-Endpunkt bereitgestellt wird.
Im nächsten Kapitel sehen wir uns Entwurfsmuster an, die dabei helfen, KI verantwortungsbewusst einzusetzen.