Ein Framework zur Automatisierung – Wie man Airflow einrichtet

Im ersten Teil dieses Blogs haben wir darüber gesprochen, was ein DAG ist, wie man dieses mathematische Konzept in der Projektplanung und -programmierung anwendet und warum wir bei STATWORX beschlossen haben, Airflow statt anderer Workflow-Manager einzusetzen. In diesem Teil werden wir jedoch etwas technischer und untersuchen eine recht informative Hello-World-Programmierung und wie man Airflow für verschiedene Szenarien einrichtet, mit denen man konfrontiert werden könnte. Wenn du dich nur für den technischen Teil interessierst und deshalb den ersten Teil nicht lesen willst, aber trotzdem eine Zusammenfassung möchtest, findest du hier eine Zusammenfassung:

  • DAG ist die Abkürzung für „Directed Acyclic Graph“ und kann als solcher Beziehungen und Abhängigkeiten darstellen.
  • Dieser letzte Aspekt kann im Projektmanagement genutzt werden, um deutlich zu machen, welche Aufgaben unabhängig voneinander ausgeführt werden können und welche nicht.
  • Die gleichen Eigenschaften können in der Programmierung genutzt werden, da Software bestimmen kann, welche Aufgaben gleichzeitig ausgeführt werden können oder in welcher Reihenfolge die anderen beendet werden (oder fehlschlagen) müssen.

Warum haben wir Airflow gewählt:

  1. Kein Cron – Mit Airflows integriertem Scheduler müssen wir uns nicht auf Cron verlassen, um unsere DAG zu planen und verwenden nur ein Framework (nicht wie Luigi).
  2. Code Bases – In Airflow werden alle Workflows, Abhängigkeiten und das Scheduling in Python Code durchgeführt. Daher ist es relativ einfach, komplexe Strukturen aufzubauen und die Abläufe zu erweitern.
  3. Sprache – Python ist eine Sprache, die man relativ leicht erlernen kann und Python Kenntnisse war in unserem Team bereits vorhanden.

Vorbereitung

Der erste Schritt war die Einrichtung einer neuen, virtuellen Umgebung mit Python und virtualenv.

$pip install virtualenv # if it hasn't been installed yet   
$cd  # change into home 

# create a separated folder with all environments  
$mkdir env   
$cd env   
$virtualenv airflow

Sobald die Umgebung erstellt wurde, können wir sie immer dann verwenden, wenn wir mit Airflow arbeiten wollen, so dass wir nicht in Konflikt mit anderen Abhängigkeiten geraten.

$source ~/env/airflow/bin/activate  

Dann können wir alle Python-Pakete installieren, die wir benötigen.

$ pip install -U pip setuptools wheel \
psycopg2\
Cython \
pytz \
pyOpenSSL \
ndg-httpsclient \
pyasn1 \
psutil \
apache-airflow[postgres]\  

A Small Breeze

Sobald unser Setup fertig ist, können wir überprüfen, ob Airflow korrekt installiert ist, indem wir airflow version in Bash eingeben und du solltest etwas wie dieses sehen:

version-sequential

Anfänglich läuft Airflow mit einer SQLite-Datenbank, die nicht mehr als eine DAG-Aufgabe gleichzeitig ausführen kann und daher ausgetauscht werden sollte, sobald du dich ernsthaft damit befassen willst oder musst. Doch dazu später mehr. Beginnen wir nun mit dem typischen Hello-World-Beispiel. Navigiere zu deinem AIRFLOW_HOME-Pfad, der standardmäßig ein Ordner namens airflow in deinem Stammverzeichnis ist. Wenn du das ändern willst, editiere die Umgebungsvariable mit export AIRFLOW_HOME=/your/new/path und rufe airflow version noch einmal auf.

# ~/airflow/dags/HelloWorld.py  

from airflow import DAG  
from airflow.operators.dummy_operator import DummyOperator  
from airflow.operators.python_operator import PythonOperator  
from datetime import datetime, timedelta  

def print_hello():
    return 'Hello world!'  

dag = DAG('hello_world',
            description='Simple tutorial DAG',
            start_date= datetime.now() - timedelta(days= 4),  
            schedule_interval= '0 12 * * *'  
         )  

dummy_operator= DummyOperator(task_id= 'dummy_task', retries= 3, dag= dag)  

hello_operator= PythonOperator(task_id= 'hello_task', python_callable= print_hello, dag= dag)  

dummy_operator >> hello_operator # same as  dummy_operator.set_downstream(hello_operator)  

Die ersten neun Zeilen sollten einigermaßen selbsterklärend sein, nur der Import der notwendigen Bibliotheken und die Definition der Hello-World-Funktion passieren hier. Der interessante Teil beginnt in Zeile zehn. Hier definieren wir den Kern unseres Workflows, ein DAG-Objekt mit dem Identifier hello _world in diesem Fall und eine kleine Beschreibung, wofür dieser Workflow verwendet wird und was er tut (Zeile 10). Wie du vielleicht schon vermutet hast, definiert das Argument start_date das Anfangsdatum des Tasks. Dieses Datum sollte immer in der Vergangenheit liegen. Andernfalls würde die Aufgabe ausgelöst werden und immer wieder nachfragen, ob sie ausgeführt werden kann, und als solche bleibt sie aktiv, bis sie geplant ist. Das schedule_interval definiert die Zeiträume, in denen der Graph ausgeführt werden soll. Wir setzen sie entweder mit einer Cron-ähnlichen Notation auf (wie oben) oder mit einem syntaktischen Hilfsmittel, das Airflow übersetzen kann. Im obigen Beispiel definieren wir, dass die Aufgabe täglich um 12:00 Uhr laufen soll. Die Tatsache, dass sie täglich laufen soll, hätte auch mit schedule_interval='@daily ausgedrückt werden können. Die Cron-Notation folgt dem Schema Minute - Stunde - Tag (des Monats) - Monat - Tag (der Woche), etwa mi h d m wd. Mit der Verwendung von * als Platzhalter haben wir die Möglichkeit, in sehr flexiblen Intervallen zu planen. Nehmen wir an, wir wollen, dass ein Job jeden ersten Tag des Monats um zwölf Uhr ausgeführt wird. In diesem Fall wollen wir weder einen bestimmten Monat noch einen bestimmten Wochentag und ersetzen den Platzhalter durch eine Wildcard * ( min h d * *). Da es um 12:00 laufen soll, ersetzen wir mi mit 0 und h mit 12. Schließlich geben wir noch den Tag des Monats als 1 ein und erhalten unsere endgültige Cron-Notation 0 12 1 * *. Wenn wir nicht so spezifisch sein wollen, sondern lediglich täglich oder stündlich, beginnend mit dem Startdatum Ausführungen benötigen, können wir Airflows Hilfsmittel verwenden – @daily, @hourly, @monthly oder @yeary.

Sobald wir diese DAG-Instanz haben, können wir damit beginnen, sie mit einer Aufgabe zu füllen. Instanzen von Operatoren in Airflow repräsentieren diese. Hier initiieren wir einen DummyOperator und einen PythonOperator. Beiden muss eine eindeutige id zugewiesen werden, aber dieses Mal muss sie nur innerhalb des Workflows eindeutig sein. Der erste Operator, den wir definieren, ist ein DummyOperator, der überhaupt nichts tut. Wir wollen nur, dass er unseren Graphen füllt und dass wir Airflow mit einem möglichst einfachen Szenario testen können. Der zweite ist ein PythonOperator. Neben der Zuordnung zu einem Graphen und der id benötigt der Operator eine Funktion, die ausgeführt wird, sobald die Aufgabe ausgelöst wird. Nun können wir unsere Funktion hello_world verwenden und über den PythonOperator an unseren Workflow anhängen.

Bevor wir unseren Ablauf schließlich ausführen können, müssen wir noch die Beziehung zwischen unseren Aufgaben herstellen. Diese Verknüpfung wird entweder mit den binären Operatoren << und >> oder durch den Aufruf der Methoden set_upstream und set_downstream vorgenommen. Auf diese Weise können wir die Abhängigkeit einstellen, dass zuerst der DummyOperator laufen und erfolgreich sein muss, bevor unser PythonOperator ausgeführt wird.

Nun da unser Code in Ordnung ist, sollten wir ihn testen. Dazu sollten wir ihn direkt im Python-Interpreter ausführen, um zu prüfen, ob wir einen Syntaxfehler haben. Führe ihn also entweder in einer IDE oder im Terminal mit dem Befehl python hello_world.py aus. Wenn der Interpreter keine Fehlermeldung ausgibt, kannst du dich glücklich schätzen, dass du es nicht allzu sehr vermasselt hast. Als nächstes müssen wir überprüfen, ob Airflow unsere DAG mit airflow list_dags kennt. Jetzt sollten wir unsere hello_world id in der gedruckten Liste sehen. Wenn dies der Fall ist, können wir mit airflow list_task hello_world überprüfen, ob jede Aufgabe ihm zugewiesen ist. Auch hier sollten wir einige bekannte IDs sehen, nämlich dummy_task und hello_task. So weit so gut, zumindest die Zuweisung scheint zu funktionieren. Als nächstes steht ein Unit-Test der einzelnen Operatoren mit airflow test dummy_task 2018-01-01 und airflow test hello_task 2018-01-01 an. Hoffentlich gibt es dabei keine Fehler, und wir können fortfahren.

Da wir nun unseren Beispiel-Workflow bereitstellen konnten, müssen wir Airflow zunächst vollständig starten. Dazu sind drei Befehle erforderlich, bevor wir mit der manuellen Auslösung unserer Aufgabe fortfahren können.

  1. airflow initdb um die Datenbank zu initiieren, in der Airflow die Arbeitsabläufe und ihre Zustände speichert:
    initdb-sequential
  2. airflow webserver, um den Webserver auf localhost:8080 zu starten, von wo aus wir die Weboberfläche erreichen können:
    webserver-sequential
  3. airflow scheduler, um den Scheduling-Prozess der DAGs zu starten, damit die einzelnen Workflows ausgelöst werden können:scheduler-sequential
  4. airflow trigger_dag hello_world um unseren Workflow auszulösen und ihn in den Zeitplan aufzunehmen.

Jetzt können wir entweder einen Webbrowser öffnen und zu der entsprechenden Website navigieren oder open http://localhost:8080/admin/ im Terminal aufrufen, und es sollte uns zu einer Webseite wie dieser führen.

web-ui

Unten solltest du deine Kreation sehen und der hellgrüne Kreis zeigt an, dass unser Ablauf geplant ist und ausgeführt wird. Jetzt müssen wir nur noch warten, bis er ausgeführt wird. In der Zwischenzeit können wir über das Einrichten von Airflow sprechen und darüber, wie wir einige der anderen Executors verwenden können.

Das Backend

Wie bereits erwähnt – sobald wir uns ernsthaft mit der Ausführung unserer Graphen beschäftigen wollen, müssen wir das Backend von Airflow ändern. Anfänglich wird eine einfache SQLite-Datenbank verwendet, die Airflow darauf beschränkt, jeweils nur eine Aufgabe sequenziell auszuführen. Daher werden wir zunächst die angeschlossene Datenbank auf PostgreSQL umstellen. Falls du Postgres noch nicht installiert hast und Hilfe dabei brauchst, empfehle ich dir diesen Wiki-Artikel. Ich könnte den Prozess nicht so gut beschreiben wie die Seite. Für diejenigen, die mit einem Linux-basierten System arbeiten (sorry, Windows), versucht es mit sudo apt-get install postgresql-client oder mit homebrew auf einem Mac – brew install postgresql. Eine andere einfache Möglichkeit wäre die Verwendung eines Docker-Containers mit dem entsprechenden image.

Nun erstellen wir eine neue Datenbank für Airflow, indem wir im Terminal psql createdb airflow eingeben, in dem alle Metadaten gespeichert werden. Als nächstes müssen wir die Datei airflow.cfg bearbeiten, die in dem AIRFLOW_HOME-Ordner erscheinen sollte (der wiederum standardmäßig airflow in Ihrem Home-Verzeichnis ist) und die Schritte 1 – 4 von oben (initdb…) neu starten. Starte nun deinen Lieblingseditor und suche nach Zeile 32 sql_alchemy_conn =. Hier werden wir den SQLite Connection String durch den von unserem PostgreSQL-Server und einen neuen Treiber ersetzen. Diese Zeichenkette wird zusammengesetzt aus:

postgresql+psycopg2://IPADRESS:PORT/DBNAME?user=USERNAME&password=PASSWORD  

Der erste Teil teilt sqlalchemy mit, dass die Verbindung zu PostgreSQL führen wird und dass es den psycopg2-Treiber verwenden soll, um sich mit diesem zu verbinden. Falls du Postgres lokal installiert hast (oder in einem Container, der auf localhost mappt) und den Standard-Port von 5432 nicht geändert hast, könnte IPADRESS:PORT in localhost:5432 oder einfach localhost übersetzt werden. Der DBNAME würde in unserem Fall in airflow geändert werden, da wir ihn nur zu diesem Zweck erstellt haben. Die letzten beiden Teile hängen davon ab, was du als Sicherheitsmaßnahmen gewählt hast. Schließlich könnten wir eine Zeile erhalten haben, die wie folgt aussieht:

sql_alchemy_conn = postgresql+psycopg2://localhost/airflow?user=postgres&password=password  

Wenn wir dies getan haben, können wir auch unseren Executor in Zeile 27 von „Executor = SequentialExecutor“ in einen „Executor = LocalExecutor“ ändern. Auf diese Weise wird jede Aufgabe als Unterprozess gestartet und die Parallelisierung findet lokal statt. Dieser Ansatz funktioniert hervorragend, solange unsere Aufträge nicht zu kompliziert sind oder auf mehreren Rechnern laufen sollen.

Sobald wir diesen Punkt erreicht haben, brauchen wir Celery als Executor. Dabei handelt es sich um eine asynchrone Task/Job-Warteschlange, die auf verteilter Nachrichtenübermittlung basiert. Um den CeleryExecutor zu verwenden, benötigen wir jedoch ein weiteres Stück Software – einen Message Broker. Ein Message Broker ist ein zwischengeschaltetes Programmmodul, das eine Nachricht von der „Sprache“ des Senders in die des Empfängers übersetzt. Die beiden gängigsten Optionen sind entweder redis oder rabbitmq. Verwende das, womit du dich am wohlsten fühlst. Da wir rabbitmq verwendet haben, wird der gesamte Prozess mit diesem Broker fortgesetzt, sollte aber für redis mehr oder weniger analog sein.

Wiederum ist es für Linux- und Mac-Benutzer mit apt/homebrew ein Einzeiler, ihn zu installieren. Tippe einfach in dein Terminal sudo apt-get install rabbitmq-server oder brew install rabbitmq ein und fertig. Als nächstes brauchen wir einen neuen Benutzer mit einem Passwort und einen virtuellen Host. Beides – Benutzer und Host – kann im Terminal mit dem rabbitsmqs Kommandozeilen-Tool rabbitmqctl erstellt werden. Nehmen wir an, wir wollen einen neuen Benutzer namens myuser mit mypassword und einen virtuellen Host als myvhost erstellen. Dies kann wie folgt erreicht werden:

$ rabbitmqctl add_user myuser mypassword  
$ rabbitmqctl add_vhost myvhost  

Doch nun zurück zur Airflows-Konfiguration. Navigiere in deinem Editor zur Zeile 230, und du wirst hoffentlich broker_url = sehen. Dieser Connection-String ist ähnlich wie der für die Datenbank und wird nach dem Muster BROKER://USER:PASSWORD@IP:PORT/HOST aufgebaut. Unser Broker hat das Akronym amqp, und wir können unseren neu erstellten Benutzer, das Passwort und den Host einfügen. Sofern du nicht den Port geändert hast oder einen Remote Server verwendest, sollte deine Zeile in etwa so aussehen:

broker_url = amqp://myuser:mypassword@localhost:5672/myvhost  

Als nächstes müssen wir Celery Zugriff auf unsere airflow-Datenbank gewähren und die Zeile 232 mit:

db+postgresql://localhost:5432/airflow?user=postgres&password=password

Dieser String sollte im Wesentlichen dem entsprechen, den wir zuvor verwendet haben. Wir müssen nur den Treiber psycopg2 weglassen und stattdessen db+ am Anfang hinzufügen. Und das war’s! Du solltest nun alle drei Executors in der Hand haben und die Einrichtung ist abgeschlossen. Unabhängig davon, welchen Executor du gewählt hast, musst du, sobald du die Konfiguration geändert hast, die Schritte 1-4 – Initialisierung der DB, Neustart des Schedulers und des Webservers – erneut ausführen. Wenn du dies jetzt tust, wirst du feststellen, dass sich die Eingabeaufforderung leicht verändert hat, da sie anzeigt, welchen Executor du verwendest.

webserver-celery

Schluss & Ausblick

Airflow ist ein einfach zu bedienender, codebasierter Workflow-Manager mit einem integrierten Scheduler und mehreren Executors, die je nach Bedarf skaliert werden können.

Wenn du einen Ablauf sequenziell ausführen willst oder wenn es nichts gibt, was gleichzeitig laufen könnte, sollten die Standard-SQLite-Datenbank und der sequenzielle Executor die Aufgabe erfüllen.

Wenn du Airflow verwenden willst, um mehrere Aufgaben gleichzeitig zu starten und so die Abhängigkeiten zu verfolgen, solltest du zuerst die Datenbank und einen LocalExecutor für lokale Mehrfachverarbeitung verwenden. Dank Celery sind wir sogar in der Lage, mehrere Maschinen zu verwenden, um noch fortgeschrittenere und komplexere Workflows ohne viel Aufwand und Sorgen auszuführen.

Recently, we at STATWORX faced the usual situation where we needed to transform a proof of concept (POC) into something that could be used in production. The „new“ aspect of this transformation was that the POC was loaded with a tiny amount (a few hundred megabytes) but had to make ready for a waste amount of data (terabytes). The focus was to build pipelines of data which connect all the single pieces and automate the whole workflow from the database, ETL (Extract-Transform-Load) and calculations, to the actual application. Thus, the simple master-script which calls one script after another was not an option anymore. It was relatively clear that a program or a framework which uses DAG’s was necessary. So, within this post, I will swiftly go over what a DAG is in this context, what alternatives we considered and which one we have chosen in the end. Further, there will be a second part explaining more detailed how the workflow with Airflow looks like, e.g., some hello-world program and the whole setup.

So what’s a DAG?

DAG is the acronym for Directed Acyclic Graph and is a mathematical concept to represent points/knots in relation to each other visually without any cycles but in a precise order. In other words, it is just a bunch of knots which are connected to each other (left part of the image below). Next, we add relationships between each of them (middle part of the picture below) which point in a particular direction, and lastly, we restrict the connections to not form any cycles in between the knots (right part of the images below).

In programming, we can use this model and define every single task as a knot in the graph. Every job that can be done independently will be an initial knot with no predecessors and as such will have no relation point towards him. From there on we will link those tasks which are directly dependent on it. Continuing this process and connecting all task to the graph we can manifest a whole project into a visual guide. Even though this might be trivial for simple projects like ‚First do A then B and finally C‘, once our workflow reaches a certain size or needs to be scalable, this won’t be the case anymore. Following this, it is advisable to express it in a DAG such that all the direct and indirect dependencies are expressed. This representation isn’t just a way to show the context visually such that non-technical people could grasp what is happening, but also gives a useful overview of all processes which could run concurrently, and which sequentially.

Just imagine if you have a workflow made up out of several dozen tasks (like the one above) some of which need to run sequentially and some that could run in parallel. Just imagine if one of these tasks failed without the DAG it wouldn’t be clear what should happen next. Which one needs to wait until the failed one finally succeed? Which can keep running since they do not depend on it? With a DAG this question can be answered quickly and doesn’t even come up if a program is keeping track of it. Due to this convenience, a lot of software and packages adopted this representation to automate workflows.

What were we looking for?

As mentioned, we were looking for a piece of software, a framework or at least a library as an orchestrator which works based on a DAG as we would need to keep track of the whole jobs manually otherwise – when does a task start, what if a task fails – what does it mean to the whole workflow. This is especially necessary as the flow needed to be executed every week and therefore monitoring it by hand would be tedious. Moreover, due to the weekly schedule an inbuilt or advanced scheduler would be a huge plus. Why Advanced? – There are simple schedulers like cron which are great for starting a specific job at a specific time but would not integrate with the workflow. So, one that also keeps track of the DAG would be great. Finally, it would also be required that we could extend the workflow easily. As it needed to be scalable it would be helpful if we could call a script – e.g., to clean data- several times just with a different argument – for different batches of data- as different nodes in the workflow without much overhead and code.

What were our options?

Once we settled on the decision that we need to implement an orchestrator which is based on a DAG, a wide variety of software, framework and packages popped up in the Google search. It was necessary to narrow down the amount of possibility so only a few were left which we could examine in depth. We needed something that was neither to heavily based on a GUI since it limits the flexibility and scalability. Nor should it be too code-intensive or in an inconvenient programming language since it could take long to pick it up and get everybody on board. So, options like Jenkins or WAF were thrown out right away. Eventually, we could narrow it down to three options:

Option 1 – Native Solution: Cloud-Orchestrator

Since the POC was deployed on a cloud, the first option was, therefore, rather obvious – we could use one of the cloud native orchestrators. They offered us a simple GUI to define our DAGs, a scheduler and were designed to pipe data like in our case. Even though this sounds good, the inevitable problem was that such GUIs are restricted, one would need to pay for the transactions, and there would be no fun at all without coding. Nevertheless, we kept the solution as a backup plan.

Option 2 – Apaches Hadoop Solutions: Oozie or Azkaban

Oozie and Azkaban are both open-source workflow manager written in Java and designed to integrate with Hadoop Systems. Therefore, they are both designed to execute DAGs, are scalable and have an integrated scheduler. While Oozie tries to offer high flexibility in exchange for usability, Azkaban has the tradeoff the other way around. As such, orchestration is only possible through the WebUI in the case of Azkaban. Oozie, on the other hand, relies on XML-Files or Bash to manage and schedule your work.

Option 3 – Pythonic Solution: Luigi or Airflow

Luigi and Airflow are both workflow managers written in Python and available as open-source frameworks.

Luigi was developed in 2011 by Spotify and was designed to be as general as possible – in contrast to Oozie or Azkaban which were intended for Hadoop. The main difference compared to the other two is that Luigi is code- rather than GUI-based. The executable workflows are all defined by Python code rather than in a user interface. Luigi’s WebUI offers high usabilitiy, like searching, filtering or monitoring the graphs and tasks.

Similar to this is Airflow which was developed by Airbnb and opened up in 2015. Moreover, it was accepted in the Apache Incubator since 2016. Like Luigi, it is also code-based with an interface to increase usability. Furthermore, it comes with an integrated scheduler so that one doesn’t need to rely on cron.

Our decision

Our first criterion for further filtering was that we wanted a code-based orchestrator. Even though interfaces are relatively straightforward to pick up and easy to get used to, it would come at the cost of slower development. Moreover, editing and extending would also be time-consuming if every single adjustment would require clicking instead of reusing function or code snippets. Therefore, we turned down option number one – the local Cloud-Orchestrator. The loss of flexibility shouldn’t be underestimated. Any learning or takeaways with an independent orchestrator could likely apply to any other project. This wouldn’t be the case for a native one, as it is bound to the specific environment.

The most significant difference between the other two options were the languages in which they operate. Luigi and Airflow are Python-based while Oozie and Azkaban are based on Java, and Bash scripts. Also, this decision was easy to determine, as Python is an excellent scripting language, easy to read, fast to learn and simple to write. With the aspect of flexibility and scalability in mind, Python offered us a better utility compared the (compiled) programming language Java. Moreover, the workflow definition needed to be either done in a GUI (again) or with XML. Thus, we could also exclude option two.

The last thing to settle was either to use Spotify’s Luigi or Airbnbs Airflow. It was a decision between taking the mature and stable or go with the young star of the workflow managers. Both projects are still maintained and highly active on GitHub with over several thousand commits, several hundred stars and several hundred of contributors. Nevertheless, there was one aspect which was mainly driving our decision – cron. Luigi can only schedule jobs with the usage of cron, unlike Airflow which has an integrated scheduler. But, what is actually the problem with cron?

Cron works fine if you want one job done at a specified time. However, once you want to schedule several jobs which depend on each other, it gets tricky. Cron does not regard these dependencies whether one task scheduled job needs to wait until the predecessor is finished. Let’s say we want a job to run every five minutes and transport some real-time data from a database. In case everything goes fine, we will not run into trouble. A job will start, it will finish, the next one starts and so on. However, what if the connection to the database isn’t working? Job one will start but never finishes. Five minutes later the second one will do the same while job one will still be active. This might continue until the whole machine is blocked by unfinished jobs or crashes. With Airflow, such a scenario could be easily avoided as it automatically stops starting new jobs when requirements are not met.

Summarizing our decision

We chose apache Airflow over the other alternatives base on:

  1. No cron – With Airflow´s included scheduler we don’t need to rely on cron to schedule our DAG and only use one framework (not like Luigi).

  2. Code Bases – In Airflow all workflows, dependencies, and schedulings are done in Python code. Therefore, it is rather easy to build complex structures and extend the flows.

  3. Language – Python is a language somewhat natural to pick up and was available in our team.

Thus, Airflow fulfills all our needs. With it, we have an orchestrator which keeps track of the workflow we define by code using Python. Therefore, we could also easily extend the workflow in any directions – more data, more batches, more steps in the process or even on multiple machines concurrently won’t be a problem anymore. Further, Airflow also includes a nice visual interface of the workflows such that one could also easily monitor it. Finally, Airflow allows us to renounce crone as it comes with an advanced onboard scheduler. One that can not only start a task but also keeps track of each and is highly customizable in its execution properties.

In the second Part of this blog we will look deeper into Airflow, how to use it and how to configure it for multiple scenarios of usage.

Im vorherigen Teil dieser STATWORX Reihe haben wir uns mit verschiedenen Datenstrukturen auseinander gesetzt. Darunter jene, die uns in Python direkt ‚Out of the box‘ zur Verfügung stehen, als auch NumPy’s ndarrays. Bei den nativen Containern (z.B. Tuples oder Listen) konnten wir feststellen, dass nur die Listen unseren Anforderungen im Rahmen der Arbeit mit Daten – veränderbar und indizierbar – erfüllen. Jedoch waren diese relativ unflexibel und langsam, sobald wir versuchten, diese für rechenintensive mathematische Operationen zu nutzen. Zum einen mussten wir Operationen per Schleife auf die einzelnen Elemente anwenden und zum anderen waren Anwendungen aus der linearen Algebra, wie Matrizenmultiplikation nicht möglich. Daher wandten wir unsere Aufmerksamkeit den ndarrays von NumPy zu. Da NumPy den Kern der wissenschaftlichen Python-Umgebung darstellt, werden wir uns in diesem Teil genauer mit den Arrays befassen. Wir betrachten ihre Struktur tiefergehend und untersuchen woher die verbesserte Performance kommt. Abschließend werden wir darauf eingehen, wie man seine Analyse bzw. seine Ergebnisse speichern und erneut laden kann.

Attribute und Methoden

N-Dimensionen

Wie sämtliche Konstrukte in Python sind auch die ndarrays ein Objekt mit Methoden und Attributen. Das für uns interessanteste Attribut bzw. die interessanteste Eigenschaft ist, neben der Effizienz, die Multidimensionalität. Wie wir schon im letzten Teil gesehen haben, ist es einfach ein zweidimensionales Array zu erschaffen ohne dabei Objekte ineinander zu verschachteln, wie es bei Listen der Fall wäre. Stattdessen können wir einfach angeben, wie groß das jeweilige Objekt sein soll, wobei eine beliebige Dimensionalität gewählt werden kann. Typischerweise wird dies über das Argument ndim angegeben. NumPy bietet uns zusätzlich die Möglichkeit beliebig große Arrays außerordentlich simpel umzustrukturieren. Die Umstrukturierung der Dimensionalität eines ndarray erfolgt dabei durch die reshape()Methode. Ihr wird ein Tupel oder eine Liste mit der entsprechenden Größe übergeben. Um ein umstrukturiertes Array zu erhalten, muss die Anzahl der Elemente mit der angegebenen Größe kompatibel sein.

# 2D-Liste 
list_2d = [[1,2], [3,4]]

# 2D-Array
array_2d = np.array([1,2,3,4]).reshape((2,2))

# 10D-Array
array_10d = np.array(range(10), ndmin=10)

Um an strukturelle Informationen eines Arrays wie zum Beispiel der Dimensionalität zu gelangen, können wir die Attribute ndim, shape oder size aufrufen. So bietet uns beispielsweise ndim Aufschluss über die Anzahl der Dimensionen, während uns size verrät, wie viele Elemente sich in dem jeweiligen Array befinden. Das Attribut shape verbindet diese Informationen und gibt an, wie die jeweiligen Einträge auf die Dimensionen aufgeteilt sind.

# Erstellung eines 3x3x3 Arrays mit den Zahlen 0 bis 26 
Arr = np.arange(3*3*3).reshape(3,3,3)

# Anzahl der Dimension
Arr.ndim # =3

# Anzahl der Elemente , 27
Arr.size # =27 

# Detaillierte Aufgliederung dieser beiden Informationen 
Arr.shape # = (3,3,3) Drei Elemente pro Dimension 

Indizierung

Nach dem wir nun herausfinden können wie unser ndarray aufgebaut ist, stellt sich die Frage, wie wir die einzelnen Elemente oder Bereiche eines Arrays auswählen können. Diese Indizierung beziehungsweise das Slicing erfolgt dabei prinzipiell wie bei Listen. Durch die []-Notation können wir auch bei den Arrays einen einzelnen Index oder per :-Syntax ganze Folgen abrufen. Die Indizierung per Index ist relativ simpel. Zum Beispiel erhalten wir den ersten Wert des Arrays durch Arr[0] und durch ein vorangestelltes - erhalten wir den letzten Wert des Arrays durch Arr[-1]. Wollen wir jedoch eine Sequenz von Daten abrufen, können wir die :-Syntax nutzen. Diese folgt dem Schema [ Start : Ende : Schritt ], wobei sämtliche Argumente optional sind. Dabei ist anzumerken, dass nur die Daten exklusiv des angegebenen Ende ausgegeben werden. Somit erhalten wir durch Arr[0:2] nur die ersten beiden Einträge. Die Thematik wird in der folgenden Grafik verdeutlicht.

Array Indizierung

Wollen wir das gesamte Array mit dieser Logik auswählen, kann man auch den Start und / oder das Ende weglassen wodurch es automatisch ergänzt wird. So könnten wir mit Arr[:2] vom Ersten bis zum zweiten Element oder mit Arr[1:] vom Zweiten bis zum Letzten Element selektieren.

Als nächstes wollen wir auf das bisher ausgelassene Argument Schritt eingehen. Dies erlaubt es uns die Schrittweite, zwischen dem Element, zwischen Start und Ende festzulegen. Wollen wir beispielsweise nur jedes zweite Element des gesamten Arrays können wir den Start und das Ende weglassen und nur eine Schrittweite von 2 definieren – Arr[::2]. Wie bei der umgedrehten Indizierung, ist auch eine umgedrehte Schrittweite durch negative Werte möglich. Demnach führt eine Schrittweite von -1 dazu, dass das Array in umgedrehter Reihenfolge ausgegeben wird.

arrarr = np.array([1,1,2,2,3,3]) 
arr = np.array([1,2,3])
arrarr[::2] == arr
rra = np.array([3,2,1]) 
arr[::-1] == rra & rra[::-1] ==arr  
True

Sofern wir diesen nun auf ein Array übertragen wollen, welches nicht im eindimensionalen Raum, sondern in einem mehrdimensionalen Raum vorliegt, können wir einfach jede weitere Dimension als eine weitere Achse betrachten. Demzufolge können wir auch das Slicen eines eindimensionalen Arrays relativ leicht auf höhere Dimensionen übertragen. Hierfür müssen wir nur jede Dimension einzeln zerteilen und die einzelnen Befehle nur per Kommata trennen. Um so anhand dieser Syntax eine gesamte Matrix der Größe 3×3 zu selektieren, müssen wir also die gesamte erste und zweite Achse auswählen. Analog zu vorher würden wir also zweimal [:] benutzen. Dieses würden wir nun in einer Klammer formulieren als [:,:]. Dieses Schema lässt sich für beliebig viele Achsen erweitern. Hier ein paar weitere Beispiele:

arr = np.arange(8).reshape((2,2,2))
#das ganze Array 
arr[:,:,:]
# Jeweils das erste Element 
arr[0,0,0]
# Jeweils das letzte Element 
arr[-1,-1,-1]

Rechnen mit Arrays

UFuncs

Wie schon des Öfteren innerhalb dieses und des letzten Beitrages erwähnt, liegt die große Stärke von NumPy darin, dass das Rechnen mit ndarray äußerst performant ist. Der Grund dafür liegt zunächst an den Arrays die ein effizienter Speicher sind und es ermöglichen höherdimensionale Räume mathematisch abzubilden. Der große Vorteil von NumPy liegt dabei jedoch vor allem an den Funktionen die wir zur Verfügung gestellt bekommen. So ist es erst durch die Funktionen möglich, nicht mehr über die einzelnen Elemente per Schleife zu iterieren, sondern das gesamte Objekt übergeben zu können und auch nur eins wieder herauszubekommen. Diese Funktionen werden ‚UFuncs‘ genannt und zeichnen sich dadurch aus, dass sie so konstruiert und kompiliert sind, um auf einem gesamten Array zu arbeiten. Sämtliche Funktionen, die uns durch NumPy zugänglich sind, besitzen diese Eigenschaften, so auch die np.sqrt-Funktion, die wir im letzten Teil genutzt haben. Hinzu kommen auch noch die speziellen – extra für Arrays – definierten mathematischen Operatoren, wie +, -, * . Da auch diese letztendlich nur Methoden eines Objektes sind (z.B. ist a.__add__(b) das Gleiche wie a + b), wurden die Operatoren für NumPy Objekte als Ufunc-Methoden implementiert, um eine effiziente Kalkulation zu gewährleisten.
Die entsprechenden Funktionen könnten wir auch direkt ansprechen:

Operator Equivalente ufunc Beschreibung
+ np.add Addition (e.g., 1 + 1 = 2)
- np.subtract Subtraktion (e.g., 3 - 2 = 1)
- np.negative Unäre Negation (e.g., -2)
* np.multiply Multiplikation (e.g., 2 * 3 = 6)
/ np.divide Division (e.g., 3 / 2 = 1.5)
// np.floor_divide Division ohne Rest (e.g. ,3 // 2 = 1)
** np.power Exponent (e.g., 2 ** 3 = 8)
% np.mod Modulo/Rest (e.g., 9 % 4 = 1)

Dynamische Dimensionen via Broadcasting

Ein weiterer Vorteil von Arrays besteht außerdem darin, dass eine dynamische Anpassung der Dimensionen durch Broadcasting stattfindet, sobald eine mathematische Operation ausgeführt wird. Wollen wir also einen 3×1 Vektor und eine 3×3 Matrix elementweise miteinander multiplizieren, wäre dieses in der Algebra nicht trivial zu lösen. NumPy ’streckt‘ daher den Vektor zu einer weiteren 3×3 Matrix und führt dann die Multiplikation aus.

Dabei erfolgt die Anpassung über drei Regeln:

  • Regel 1: Wenn sich zwei Arrays in der Anzahl der Dimensionen unterscheiden, wird das kleine Array angepasst mit zusätzlichen Dimensionen auf der linken Seite, z.B. (3,2) -> (1,3,2)
  • Regel 2: Sofern sich die Arrays in keiner Dimension gleichen, wird das Array mit einer unären Dimension gestreckt, wie es im oberen Beispiel der Fall war (3x1) -> 3x*3
  • Regel 3: Sofern weder Regel 1 noch Regel 2 greifen, wird ein Fehler erzeugt.
# Regel 1
arr_1 = np.arange(6).reshape((3,2))
arr_2 = np.arange(6).reshape((1,3,2))
arr_2*arr_1 # ndim = 1,3,2

# Regel 2
arr_1 = np.arange(6).reshape((3,2))
arr_2 = np.arange(2).reshape((2))
arr_2*arr_1 # ndim = 3,2

# Regel 3 
arr_1 = np.arange(6).reshape((3,2))
arr_2 = np.arange(6).reshape((3,2,1))
arr_2*arr_1 # Error, da rechts aufgefüllt werden müsste und nicht links 
ValueErrorTraceback (most recent call last)

<ipython-input-4-d4f0238d53fd> in <module>()
     12 arr_1 = np.arange(6).reshape((3,2))
     13 arr_2 = np.arange(6).reshape((3,2,1))
---> 14 arr_2*arr_1 # Error da rechts aufgefüllt werden müsste und nicht links
ValueError: operands could not be broadcast together with shapes (3,2,1) (3,2) 

An diesem Punkt sollte man noch anmerken, dass Broadcasting nur für elementweise Operationen gilt. Sofern wir uns der Matrizenmultiplikation bedienen, müssen wir selber dafür sorgen, dass unsere Dimensionen stimmen. Wollen wir bNueispielhaft eine 3×1 Matrix mit einem Array der Größe 3 multiplizieren, wird nicht wie in Regel 2 die kleine Matrix links erweitert, sondern direkt ein Fehler erzeugt.

arr_1 = np.arange(3).reshape((3,1))
arr_2 = np.arange(3).reshape((3))

# Fehler 
arr_1@arr_2
ValueErrorTraceback (most recent call last)

<ipython-input-5-8f2e35257d22> in <module>()
      3 
      4 # Fehler
----> 5 arr_1@arr_2
ValueError: shapes (3,1) and (3,) not aligned: 1 (dim 1) != 3 (dim 0)

Bekannter und verständlicher als diese dynamische Anpassung sollten Aggregation als Beispiel für Broadcasting sein.
Neben den direkten Funktionen für die Summe oder den Mittelwert, lassen sich diese nämlich auch über die Operatoren abbilden. So kann man die Summe eines Array nicht nur per np.sum(x, axis=None) erhalten, sondern auch über np.add.reduce(x, axis = None ). Diese Form des Operatoren-Broadcasting erlaubt es uns auch die jeweilige Operation akkumuliert anzuwenden, um so rollierende Werte herauszubekommen. Über die Angabe der axis können wir bestimmen, entlang welcher Achse die Operation ausgeführt werden soll. Im Fall von np.sum oder np.mean ist None der Standard. Dies bedeutet, dass sämtliche Achsen einbezogen werden und ein Skalar entsteht. Sofern wir das Array jedoch nur um eine Achse reduzieren möchten, können wir den jeweiligen Index der Achse angeben:

# Reduziere alle Achsen 
np.add.reduce(arr_1, axis=None)

# Reduzieren der dritten Achse 
np.add.reduce(arr, axis=2)

# Kummulierte Summe 
np.add.accumulate(arr_1)

# Kummmuliertes Produkt 
np.multiply.accumulate(arr_1)

Speichern und Laden von Daten

Als letztes wollen wir nun auch noch in der Lage sein unsere Ergebnisse zu speichern und beim nächsten Mal zu laden. Hierbei stehen uns in NumPy generell zwei Möglichkeiten offen. Nummer 1 ist die Verwendung von textbasierten Formaten wie z.B. .csv durch savetxt und loadtxt. Werfen wir nun einen Blick auf die wichtigsten Eigenschaften dieser Funktionen, wobei der Vollständigkeit halber sämtliche Argumente aufgelistet werden, jedoch nur Bezug auf die wichtigsten genommen wird.

Das Speichern von Daten erfolgt dem Namen entsprechend durch die Funktion:

  • np.savetxt(fname, X, fmt='%.18e', delimiter=' ', newline='n', header='', footer='', comments='# ', encoding=None)

Über diesen Befehl können wir ein Objekt X in einer Datei fname speichern. Hierbei ist es prinzipiell egal, in welchem Format wir es speichern wollen, da das Objekt in Klartext gespeichert wird. Somit spielt es keine Rolle, ob wir als Suffix txt oder csv anfügen, die Namen entsprechen dabei nur Konventionen. Welche Werte zur Separierung genutzt werden sollen, geben wir durch die Schlüsselworte delimeter und newline an, welche im Fall eines csv ein ',' zur Separierung der einzelnen Werte / Spalten und ein n für eine neue Reihe sind. Per header und footer können wir optional angeben, ob wir weitere (String) Informationen an den Anfang oder das Ende der Datei schreiben wollen. Durch die Angabe von fmt – was für Format steht – können wir beeinflussen, ob und wie die einzelnen Werte formatiert werden sollen, also ob, wie und wie viele Stellen vor und nach dem Komma angezeigt werden sollen. Hierdurch können wir die Zahlen z.B. besser lesbar machen oder den Bedarf an Speicher auf der Festplatte verringern in dem wir die Präzision senken. Ein simples Beispiel wäre fmt = %.2 würde sämtliche Zahlen auf die zweite Nachkommastelle Runden ( 2.234 -> 2.23).

Das Laden der vorher gespeicherten Daten erfolgt durch die Funktion loadxt, die viele Argumente besitzt, die mit den Funktionen zum Speichern der Objekte übereinstimmt.

  • np.loadtxt(fname, dtype=<class 'float'>, comments='#', delimiter=None, converters=None, skiprows=0, usecols=None, unpack=False, ndmin=0, encoding='bytes')

Die Argumente fname und delimiter besitzen die selbe Funktionalität und Standardwerte wie beim Speichern der Daten. Durch skiprows kann angegeben werden, ob und wie viele Zeilen übersprungen werden sollen und durch usecols wird mit einer Liste von Indizes bestimmt, welche Spalten eingelesen werden sollen.

# Erstelle ein Bespiel
x = np.arange(100).reshape(10,10)

# Speicher es als CSV
np.savetxt('example.txt',x)

# Erneutes Laden 
x = np.loadtxt(fname='example.txt')

# Überspringen der ersten fünf Zeilen 
x = np.loadtxt(fname='example.txt', skiprows=5)

# Lade nur die erste und letzte Spalte 
x = np.loadtxt(fname='example.txt', usecols= (0,-1))

Die zweite Möglichkeit Daten zu speichern sind binäre .npy Dateien. Hierdurch werden die Daten komprimiert, wodurch Sie zwar weniger Speicherplatz benötigen, jedoch auch nicht mehr direkt lesbar sind wie zum Beispiel txt oder csv Dateien. Darüber hinaus sind auch die Möglichkeiten beim Laden und Speichern vergleichsweise limitiert.

  • np.save(file, arr, allow_pickle=True, fix_imports=True)
  • np.load(file, mmap_mode=None, allow_pickle=True, fix_imports=True, encoding='ASCII')

Für uns sind lediglich file und arr interessant. Wie der Name wahrscheinlich vermuten lässt, können wir durch das Argument file wieder angeben, in welcher Datei unser Array arr gespeichert werden soll. Analog dazu können wir auch beim Laden per load die zu ladende Datei über file bestimmen.

# Komprimieren und Speichern 
np.save('example.npy', x )

# Laden der komprimierten Datei
x = np.load('example.npy')

Vorschau

Da wir uns nun mit dem mathematischen Kern der Data Science Umgebung vertraut gemacht haben, können wir uns im nächsten Teil damit beschäftigen, unseren Daten etwas mehr inhaltliche Struktur zu verpassen. Hierfür werden wir die nächste große Bibliothek erkunden – nämlich Pandas. Dabei werden uns mit den zwei Hauptobjekten – Series und DataFrame – der Bibliothek bekannt machen durch die wir wir bestehende Datensätze nutzen und diese direkt modifizieren und manipulieren können.

Zu Beginn ein kurzer Rückblick in unserem ersten Blog Beitrag zum Thema Data Science mit Python. Wir haben uns mit mit einigen grundlegenden Python-Werkzeugen auseinander gesetzt haben, die uns es ermöglicht, mit IPython oder auch mit Jupyter Notebooks sehr interaktiv zu arbeiten. In diesem Teil stellen wir Euch nun Möglichkeiten vor Zahlen und Variablen eine Struktur zu geben sowie Berechnungen von Array/Matrizen durchzuführen. Schauen wir uns also zuerst einmal an, welche Möglichkeiten uns ‚Out of the box‘ zur Verfügung stehen.

Vorstellung von Datenstrukturen in Python

Um mehrere Objekte, diese können z.B. Zahlen, Zeichen, Wörter, Sätze bzw. jegliches Python-Objekt sein, in eine Art Container zu packen, bietet uns Python unterschiedliche Möglichkeiten an, so gibt es:

  • Tupel
  • Sets
  • Listen
  • Dictionaries

Data Science impliziert bereits durch seinen Namen, dass viel mit Daten gearbeitet wird, so ist ein wichtiges Kriterium für eine Datenstruktur, dass sich Daten verändern lassen und sie zudem indiziert sind. Diese Anforderungen wird nur von Listen und Dictonaries erfüllt. Bei Tupeln sind die Daten zwar indiziert aber können nicht verändert werden. Sets erfüllen weder die Anforderung der Indizierung noch der Datenmanipulation. So lassen sich zwar Elemente hinzufügen und entfernen, aber nicht direkt verändern. Ihr Anwendungsbereich liegt vor allem in der Mengenlehre wie man sie aus der Mathematik kennt. Für einen schnellen Einstieg in Data Science stellen wir Euch nun Dictionaries und Listen als praktische Datenstrukturen in Python vor.

Dictionaries

Ein Dictonary zu Deutsch Lexikon bzw. Wörterbuch könnt ihr Euch Wort wörtlich so vorstellen. Es verbindet allgemein gesagt, ein Objekt – dieses kann beliebiger Natur sein – mit einem einzigartigen Schlüssel. Dopplungen innerhalb eines Dictonarys werden daher ausgeschlossen. Somit bieten sich diese eher für die Strukturierung von verschiedenen Variablen zu einem Datensatz, als jeden Eintrag einzeln zu Speichern. Wie ein dict() beispielhaft aufgebaut ist, seht ihr im folgenden Code Auszug:

# Beispiel Aufbau ohne der Funktion 'dict()'
example_dict_1 = {'Zahl': 1, 'Satz': 'Beispiel Satz in einem Dict'}

# Beispiel Aufbau mit der Funktion 'dict()'
example_dict_2 = dict([('Zahl', 1), ('Satz', 'Beispiel Satz in einem Dict')])

Vergleichen wir die verschiedenen Möglichkeiten ein Dictonary zu erstellen, so fällt auf, dass die erste Möglichkeit einfacher aufgebaut ist. Das eindeutige Erkennungsmerkmal für ein Dictonary sind die geschweiften Klammern. Ein Richtig oder Falsch wie man das Dictonary erstellt gibt es allerdings nicht.
Nachdem wir nun ein Dictonary erstellt haben, möchten wir Euch zuerst zeigen, wie man zum einen Elemente aufruft und zum anderen sie ersetzen kann. Abschließend seht ihr noch ein Beispiel wie man die Existenz eines Elements überprüft.

# Auswahl eines Elements aus einem dict
example_dict_1['Zahl']
# Ausgabe: 1

# Ändern des Inhaltes eines Elements aus einem dict
example_dict_2['Satz'] = 'Dies ist nun ein neuer Satz'

# Überprüfen der Existenz eines Elements in einem dict
'Satz' in example_dict_2

# Ausgabe: True, da Element in dict vorhanden
'Zahl1' in example_dict_2
# Ausgabe: False, da Element in dict vorhanden

Listen

Kommen wir nun zu unserer zweiten „Data Science“ Datenstruktur in Python: Listen. Sie lassen sich ähnlich zu Dictionaries in einer Zeile erstellen, zeichnen sich im Gegensatz aber dazu aus, dass sie keine feste Zuweisung von Elementen über einen Schlüssel vornehmen. Die Elemente einer Liste lassen sich daher über ihren Index aufrufen. An dieser Stelle eine kurze Anmerkung zu Indizierung in Python. Der Index beginnt mit der Zahl 0 und wird im Sinne der natürlichen Zahlen stufenweisen hochgezählt: 0,1,2,3,… der letzte Index kann zwar eine beliebige hohe, natürliche Zahl kann aber auch einfach über die Zahl -1 aufgerufen werden. Die Funktionsweise verdeutlichen wir gleich.
Erstellen wir eine Liste, so wird der Beginn und das Ende einer Liste durch eine eckige Klammer verdeutlicht. An dieser Stelle sei noch einmal betont, dass der Datentyp der in der Liste gespeichert wird, nicht für jedes Element identisch sein muss. Zahlen, Strings und Co. lassen sich beliebig mischen.

# Erstellen einer Liste
demo_list = [1, 2, 4, 5, 6, 'test']

Die Auswahl von Elementen gliedert sich in zwei Punkte auf:

  • Auswahl einzelner Elemente
  • Auswahl mehrere Elements

Ersteres funktioniert über den Index sehr einfach, für Zweiteres muss ein Doppelpunkt bis zu dem jeweiligen nächsten Index gesetzt werden. Möchte man also die ersten drei Elemente (Index: 0,1,2) auswählen, so muss der Index nach dem Doppelpunkt 3 betragen. Die Zuweisung von neuen Daten/Elementen an eine bestimmte Indexstelle einer Liste erfolgt ähnlich zu einem Dictionary.

# Auswahl eines Elements (genauer: Auswahl des ersten Elements)
demo_list[0]
# Ausgabe: 1

# Auswahl mehrerer Elementen (genauer: Auswahl der ersten drei Elemente)
demo_list[:3]
# Ausgabe: 1, 2, 3

# Auswahl des letzen Elements der Liste
demo_list[-1]
# Ausgabe: 'test'

# Zuweisung eines neuen Elements
demo_list[3] = 3
# Die Liste hat danach folgende Struktur [1, 2, 4, 3, 6, 'test']

Einen Nachteil von Listen ist allerdings, dass sie im Wesentlich nur zum Speichern von Daten geeignet sind. Einfache mathematische Funktionen können zwar von Element zu Element angewandt werden, für komplexe Matrizen- oder Vektor-Algebra bedarf es anderer Werkzeuge wie z.B. die Bibliothek NumPy.

Einführung in NumPy

NumPy ermöglicht es uns durch seine eingeführten Multi-Dimensionalen-Arrays (kurz ndarrays) ist, komplexe mathematische Operationen und Algorithmen einfach und effizient durchzuführen. Da NumPy im Normalfall nicht direkt installiert ist, müssen wir dieses z.B. durch pip oder conda manuell erledigen. Sofern eine aktuelle Python-Version ( >=3.3) installiert ist, sollte pip direkt verfügbar sein. Wir können sodann einfach im Terminal per pip install numpy btw. pip3 install numpy NumPy installieren. Für diejenigen die Anaconda nutzten, sollte NumPy direkt verfügbar sein. Um sicher zu gehen kann man jedoch per conda install NumPy sicherstellen, dass NumPy vorhanden ist bzw. es updaten.

Ein einfaches Beispiel kann zeigen wie effizient und nützlich NumPy ist. Nehmen wir an, wir haben einige Datenpunkte und wollen eine mathematische Operation vornehmen, wie etwa die Wurzel ziehen. Hierzu soll unsere Liste li dienen.

li = [1,3,5,6,7,6,4,3,4,5,6,7,5,3,2,1,3,5,7,8,6,4,2,3,5,6,7]

Da Pythons Mathe-Modul math jeweils nur eine Zahl als Input nimmt, bleibt uns nichts anderes übrig, als die Wurzel per Listcomprehension zu zuweisen. Listcomprehension ermöglich eine sehr kompakte Form der Listenerstellung.

import math
s = [math.sqrt(i) for i in li]

Währendessen können wir mit NumPy direkt auf dem gesamten Array mit einem geringen Aufwand arbeiten zu betreiben.

import numpy as np 
arr = np.array(li)
s = np.sqrt(arr) 

Wertet man die Laufzeiten der Operationen aus, so dauert es mit dem math Modul 3,3 Mikrosekunden, verwenden wir hingegen NumPy so reduziert sich die Laufzeit um ein Drittel auf 0,9 Mikrosekunden. Dieser Aspekt unterstreicht die effiziente Implementierung von Arrays in NumPy. Sie eignen sich daher sehr gut, um auch mit relativ vielen Daten gut zurecht zu kommen. Darüber hinaus ermöglichen eine Vielzahl von Funktionen, Möglichkeiten zur Konstruktion, Transformation und Restrukturierung von Arrays ohne vorab Listen zu definieren. Hierüber möchten wir Euch abschließend noch einen Überblick geben.
Eine Matrix mit Zufallszahlen können wir sehr schnell erstellen. Ist man sich über die Struktur seiner Daten unsicher, so kann man sich diese über das Attribut shape ausgeben lassen.

# 25x1 große Matrix mit einem Mittelwert von 20 und Standartabweichung von 10 
ran = np.random.randn(25,1) * 10 + 20

# Struktur eines Arrays/Matrix
print(ran.shape)

In der Praxis kommt es allerdings häufig vor, dass die vorliegenden Daten nicht unbedingt der gewünschten Struktur entsprechen. NumPy bietet für dieses Problem verschiedene Funktionen an, so können die Arrays mit reshape transformiert werden oder mit hstack/ vstack horizontal oder vertikal angeordnet werden. Bei reshape wird die gewünschte Struktur als Liste übergeben.

# Umstrukturierung der Zufallszahlen
ran = ran.reshape([5,5])

# Zweite Zufallsmatrix 
ran2 = np.random.randn(25,1) * 5 + 1

# Stapeln  zu 25x2
vstack = np.vstack([ran, ran2])

# Verbinden zu 50x1 
hstack= np.hstack([ran, ran2])

NumPy bildet somit ein solides Grundgerüst um schnell mit Zahlen zu hantieren. Für diejenigen, die Erfahrung mit linearer Algebra haben muss an dieser Stelle noch dazu gesagt werden, dass ndarrays keine Matrizen sind! Worauf ich hier hinaus will ist, dass ndarrays sich nicht wie Matrizen verhalten wenn es z.B. um Multiplikation geht. ndarrays multiplizieren Element für Element. Somit kann auch ein 4×1 Array quadriert werden ohne es zu transponieren. Jedoch lässt NumPy dennoch die Standard Matrizenmultiplikation zu mit der np.dot()-Funktion, oder der Operation @

# Element * Element 
np.ones([4,1]) * np.ones([4,1]) 

# oder Matrizenmultiplikation 
np.ones([1,4]) @ np.ones([4,1]) == np.dot(np.ones([1,4]) , np.ones([4,1]) ) == np.ones([1,4]).dot(np.ones([4,1]))

Fazit

In diesem Blogbeitrag haben wir die wesentlichen Datenstrukturen, die sich zum Arbeiten mit unterschiedlichen Datenelement eignen, kennengelernt diese sind Listen und Dictonary. Ihr solltet diese sowohl Erstellen wie auch Manipulieren können. Auch das Abrufen von Elementen sollte für Euch ab sofort kein Problem mehr darstellen. Für die Verarbeitung von Zahlen und Matrizen hat die Bibliothek NumPy bewiesen, dass sie eine performant Umsetzung von Berechnung ermöglicht.

Vorschau

Im nächsten Teil dieser Reihe werden wir uns noch etwas tiefer gehend mit NumPy beschäftigen. Da NumPy und die ndarrays den Kern der wissenschaftlichen Umgebung in Python darstellen und wir immer wieder auf Sie stoßen werden ist daher ein gutes Verständnis dieser von fast schon obligatorisch. Im folgenden Teil werden wir uns genauer mit den wichtigsten Eigenschaften – Attributen und Methoden – vertraut machen.

Teil 0 – Vorschau und Werkzeuge

In Sachen Datenaufbereitung, Datenformatierung und statistischer Auswertung oder kurz Data Science, war (und hier in Deutschland ist immer noch) R die Sprache der Wahl. Global hat Python hier deutlich an Popularität gewonnen und ist mittlerweile sogar vorherrschend in diesem Gebiet (siehe Studie von KDnuggets). Daher soll diese Reihe schon einmal einen Einblick geben „Warum Python?“, und wie die Sprache in Gebieten der „Datenwissenschaft“ funktioniert. Dementsprechend widmen wir uns hier zuerst einmal der Frage „Warum Python?“ sowie einer Beschreibung nützlicher Tools.

Danach folgt ein Blick auf die Datenstruktur in Python mit Pandas, sowie das mathematische Ökosystem mit NumPy und SciPy. Damit wir unsere Beobachtungen oder mathematischen Transformationen auch veranschaulichen können, schauen wir kurz auf Matplotlib. Am interessantesten ist jedoch die Validierung gegebener Hypothesen oder Vermutungen, die wir bezüglich unserer Daten haben, und welche wir mit Hilfe von Statsmodels oder SciKit-Learn (SKLearn) erledigen werden. Problematisch ist bis hier jedoch, dass es sich bei fast all diesen Modulen und Erweiterungen Pythons um sehr grundlegende Frameworks handelt. Mit anderen Worten, wenn man weiß, was man will und diese Werkzeuge einzusetzen weiß, sind sie überaus mächtig. Jedoch setzt dieses eine intensive Auseinandersetzung mit ihren Objekten und Funktionen voraus. Daher haben sich diverse Wrapper um diese Bibliotheken gelegt, um uns das Leben zu vereinfachen.

Was statistische Visualisierung angeht, erledigt Seaborn nun für uns das ‚heavy lifting‘ und wer seine Graphiken lieber interaktiv hat, dem helfen Bokeh und Altair. Selbst für das maschinelle Lernen (Maschine Learning, ML) gibt es zahlreiche Wrapper, wie zum Beispiel MlXtend für den klassischen Bereich oder Keras im Bereich DeepLearning.

Warum Python

Dementsprechend beginnt die Reise mit der Rechtfertigung ihrer selbst – Warum Python? Im Gegensatz zu R ist Python eine Sprache für jeglichen Zweck. Folglich ist Python nicht nur flexibler, was den Umgang mit nicht-numerischen Objekten angeht, sondern bietet durch seinen objektorientierten Programmieransatz (OOP) die Möglichkeit jedes Objekt frei zu manipulieren oder zu kreieren. Diese zeigt sich vor allem dann, wenn ein Datensatz entweder sehr ‚unsauber‘ ist oder die in ihm enthaltenen Informationen nicht ausreichen. In Python stehen einem zahlreiche Bibliotheken offen, um Daten auf nicht konventionelle Methode zu bereinigen oder das World Wide Web direkt nach Informationen zu durchforsten oder zu scrappen. Zwar kann dies auch in R getan werden, allerdings nur unter gehörigem Mehraufwand. Sobald man sich jedoch noch weiter weg von den eigentlichen Daten bewegt (z.B. Einrichtung einer API) muss sich R komplett geschlagen geben.

In diesem Zuge möchte ich noch einmal betonen, was numerische Objekte und den Umgang mit (relativ sauberen) Daten angeht, wird Python immer etwas hinter R hinterherhinken. Zum einen weil R genau für diesen Zweck geschrieben wurde und zum anderen, weil neuste Forschungsergebnisse zuerst in R umgesetzt werden. Genau hier kann man sich entscheiden welche Sprache einem mehr bietet. Will man immer up to date sein, neuste Methoden und Algorithmen anwenden und sich dabei auf das Wesentliche konzentrieren, dann ist wahrscheinlich R die bessere Wahl. Will man jedoch eher Daten-Pipelines bauen, für jedes Problem eine Antwort finden und dabei immer nahe an seinen Daten sein, dann ist man wohl mit Python besser beraten.

python-r-other-2016-2017

source : KDnuggets Online-Poll

Werkzeuge

Bevor wir uns nun Pandas und dem Kernbereich von Data Science widmen, möchte ich noch ein Multifunktionstool vorstellen, welches für mich im Alltag geradezu unentbehrlich geworden ist: Jupyter.

Es ist eine Erweiterung der IPython Konsole und ermöglicht einem seinen Code in verschiedenen Zellen zu schreiben, diesen auszuführen und somit auch lange Skripte effektiv zu unterteilen.

#definition einer Funktion in einer Zelle
def sayhello(to ):
    'Print hello to somboday'
    print(f'Hello {to}')

def returnhello(to):
    'Return a string which says Hello to somboday'
    return f'Hello {to}'    
# Introspektion des Docstrings
returnhello?
# Aufruf dieser Funktion in einer weiteren Zelle 
# Schreibt die Nachricht 
sayhello('Statworx-Blog')
Hello Statworx-Blog
# gibt ein Objekt zurück welches vom Notebook formatiert wird 
returnhello('Statworx-Blog') 
'Hello Statworx-Blog'

Das besondere hierbei ist sowohl das interaktive Element, z.B. kann man sich jederzeit schnell und einfach Feedback geben lassen wie ein Objekt aussieht, als auch IDE Elemente wie die Introspektion durch ?. Jupyter kann vor allem durch seine schöne Visualisierung punkten. Wie man in der dritten Zelle (returnhello('Statworx-Blog')) sehen kann, wird immer das letzte Objekt einer Zelle visualisiert. In diesem Fall mag es nur ein String gewesen sein, den wir natürlich mit print einfach ausgeben lassen können. Jedoch werden wir im weiteren Verlauf sehen, dass gerade diese Visualisierung bei Daten unheimlich nützlich ist. Darüber hinaus können die Blöcke aber auch anderes interpretiert werden. Beispielhaft kann man sie im Rohzustand belassen ohne zu kompilieren oder als Markdown verwenden, um den Code nebenbei zu dokumentieren (oder diesen Beitrag hier zu schreiben).

Da Jupyter Notebooks letzten Endes auf der IPython Shell basieren, können wir natürlich auch sämtliche Features und magics benutzten. Hierdurch können automatische Vervollständigungen genutzt werden. Man erhält Zugang zu den Docstring der einzelnen Funktionen und Klassen sowie die Zeilen- und Zellen-magics in IPython. Einzelne Blöcke sind hierdurch für mehr als nur Python Code verwendbar. Zum Beispiel kann Code schnell und einfach auf seine Geschwindigkeit geprüft werden mit %timeit, die System Shell direkt mit %%bash angesprochen oder deren Output direkt aufgenommen werden mit ! am Anfang der Zeile. Darüber hinaus könnte auch Ruby-Code durch %%ruby, Javascript mit %%js ausgeführt oder sogar zwischen Python2 und Python3 %%python2 & %%python3 in der jeweiligen Zelle gewechselt werden.

%timeit returnhello('Statworx-Blog')
170 ns ± 3.77 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)
%%bash
ls
Blog_Python.html
Blog_Python.ipynb
Blog_Python.md

Eine Anmerkung, die hier auch nicht fehlen darf, ist, dass Jupyter nicht nur exklusiv für Python ist, sondern auch mit R oder Julia verwendet werden kann. Die jeweiligen Kerne sind zwar nicht ‚Out of the Box‘ enthalten, aber sofern man eine andere Sprache benutzen möchte / soll, ist es in meinen Augen den geringen Aufwand wert.

Wem das Ganze nicht gefällt und lieber eine gute alte IDE für Python haben möchte, dem stehen eine Vielzahl an Möglichkeiten offen. Diverse Multi-Sprachen IDE’s mit einem Python-Plug-In (Visuale Studio, Eclipse, etc.) oder jene, die direkt auf Python zugeschnitten sind. Zum letzteren gehören PyCharm und Spyder.

PyCharm wurde von JetBrains entworfen und ist in zwei Versionen erhältlich, der Community Version (CE) und der Pro-Version. Während die CE ein opensource Projekt mit ’nur‘ intelligentem Editor, graphischen Debugger, VSC-Support und Introspektion ist, enthält die Pro Version gegen ein Entgelt einen Support für Web-Devolopment. Während so die Pflege der IDE gesichert ist, fehlt mir persönlich die Optimierung für Data Science spezifische Anwendung (IPython Konsole). Spyder jedoch, als wissenschaftliches opensource Projekt, bietet alles was man sich wünscht. Ein Editor (nicht ganz so intelligent wie die JetBrains Version) für Skripte, einen Explorer für Variablen und Dateien und eine IPython Konsole.

Das letzte Tool, welches benötigt wird und nicht fehlen darf, ist ein Editor. Dieser sollte vor allem schnell sein, um jede Datei zumindest im Roh-Format öffnen zu können. Nötig ist dieses, da man oft durch einen kurzen Blick z.B. in CSV-files schnell einen Eindruck über die Kodierung bekommt: welches Zeichen trennt Zeilen und welches trennt Spalten. Zum anderen kann man schnell und einfach andere Skripte aufrufen, ohne dass man Warten muss bis die IDE geladen ist.

Vorschau

Da wir nun ein Arsenal an Werkzeugen haben, um uns ins Datamunging zu stürzen, können wir uns im nächsten Teil dieser Reihe direkt mit Pandas, seinen DataFrames und dem mathematischen Ökosystem befassen.

Teil 0 – Vorschau und Werkzeuge

In Sachen Datenaufbereitung, Datenformatierung und statistischer Auswertung oder kurz Data Science, war (und hier in Deutschland ist immer noch) R die Sprache der Wahl. Global hat Python hier deutlich an Popularität gewonnen und ist mittlerweile sogar vorherrschend in diesem Gebiet (siehe Studie von KDnuggets). Daher soll diese Reihe schon einmal einen Einblick geben „Warum Python?“, und wie die Sprache in Gebieten der „Datenwissenschaft“ funktioniert. Dementsprechend widmen wir uns hier zuerst einmal der Frage „Warum Python?“ sowie einer Beschreibung nützlicher Tools.

Danach folgt ein Blick auf die Datenstruktur in Python mit Pandas, sowie das mathematische Ökosystem mit NumPy und SciPy. Damit wir unsere Beobachtungen oder mathematischen Transformationen auch veranschaulichen können, schauen wir kurz auf Matplotlib. Am interessantesten ist jedoch die Validierung gegebener Hypothesen oder Vermutungen, die wir bezüglich unserer Daten haben, und welche wir mit Hilfe von Statsmodels oder SciKit-Learn (SKLearn) erledigen werden. Problematisch ist bis hier jedoch, dass es sich bei fast all diesen Modulen und Erweiterungen Pythons um sehr grundlegende Frameworks handelt. Mit anderen Worten, wenn man weiß, was man will und diese Werkzeuge einzusetzen weiß, sind sie überaus mächtig. Jedoch setzt dieses eine intensive Auseinandersetzung mit ihren Objekten und Funktionen voraus. Daher haben sich diverse Wrapper um diese Bibliotheken gelegt, um uns das Leben zu vereinfachen.

Was statistische Visualisierung angeht, erledigt Seaborn nun für uns das ‚heavy lifting‘ und wer seine Graphiken lieber interaktiv hat, dem helfen Bokeh und Altair. Selbst für das maschinelle Lernen (Maschine Learning, ML) gibt es zahlreiche Wrapper, wie zum Beispiel MlXtend für den klassischen Bereich oder Keras im Bereich DeepLearning.

Warum Python

Dementsprechend beginnt die Reise mit der Rechtfertigung ihrer selbst – Warum Python? Im Gegensatz zu R ist Python eine Sprache für jeglichen Zweck. Folglich ist Python nicht nur flexibler, was den Umgang mit nicht-numerischen Objekten angeht, sondern bietet durch seinen objektorientierten Programmieransatz (OOP) die Möglichkeit jedes Objekt frei zu manipulieren oder zu kreieren. Diese zeigt sich vor allem dann, wenn ein Datensatz entweder sehr ‚unsauber‘ ist oder die in ihm enthaltenen Informationen nicht ausreichen. In Python stehen einem zahlreiche Bibliotheken offen, um Daten auf nicht konventionelle Methode zu bereinigen oder das World Wide Web direkt nach Informationen zu durchforsten oder zu scrappen. Zwar kann dies auch in R getan werden, allerdings nur unter gehörigem Mehraufwand. Sobald man sich jedoch noch weiter weg von den eigentlichen Daten bewegt (z.B. Einrichtung einer API) muss sich R komplett geschlagen geben.

In diesem Zuge möchte ich noch einmal betonen, was numerische Objekte und den Umgang mit (relativ sauberen) Daten angeht, wird Python immer etwas hinter R hinterherhinken. Zum einen weil R genau für diesen Zweck geschrieben wurde und zum anderen, weil neuste Forschungsergebnisse zuerst in R umgesetzt werden. Genau hier kann man sich entscheiden welche Sprache einem mehr bietet. Will man immer up to date sein, neuste Methoden und Algorithmen anwenden und sich dabei auf das Wesentliche konzentrieren, dann ist wahrscheinlich R die bessere Wahl. Will man jedoch eher Daten-Pipelines bauen, für jedes Problem eine Antwort finden und dabei immer nahe an seinen Daten sein, dann ist man wohl mit Python besser beraten.

python-r-other-2016-2017

source : KDnuggets Online-Poll

Werkzeuge

Bevor wir uns nun Pandas und dem Kernbereich von Data Science widmen, möchte ich noch ein Multifunktionstool vorstellen, welches für mich im Alltag geradezu unentbehrlich geworden ist: Jupyter.

Es ist eine Erweiterung der IPython Konsole und ermöglicht einem seinen Code in verschiedenen Zellen zu schreiben, diesen auszuführen und somit auch lange Skripte effektiv zu unterteilen.

#definition einer Funktion in einer Zelle
def sayhello(to ):
    'Print hello to somboday'
    print(f'Hello {to}')

def returnhello(to):
    'Return a string which says Hello to somboday'
    return f'Hello {to}'    
# Introspektion des Docstrings
returnhello?
# Aufruf dieser Funktion in einer weiteren Zelle 
# Schreibt die Nachricht 
sayhello('Statworx-Blog')
Hello Statworx-Blog
# gibt ein Objekt zurück welches vom Notebook formatiert wird 
returnhello('Statworx-Blog') 
'Hello Statworx-Blog'

Das besondere hierbei ist sowohl das interaktive Element, z.B. kann man sich jederzeit schnell und einfach Feedback geben lassen wie ein Objekt aussieht, als auch IDE Elemente wie die Introspektion durch ?. Jupyter kann vor allem durch seine schöne Visualisierung punkten. Wie man in der dritten Zelle (returnhello('Statworx-Blog')) sehen kann, wird immer das letzte Objekt einer Zelle visualisiert. In diesem Fall mag es nur ein String gewesen sein, den wir natürlich mit print einfach ausgeben lassen können. Jedoch werden wir im weiteren Verlauf sehen, dass gerade diese Visualisierung bei Daten unheimlich nützlich ist. Darüber hinaus können die Blöcke aber auch anderes interpretiert werden. Beispielhaft kann man sie im Rohzustand belassen ohne zu kompilieren oder als Markdown verwenden, um den Code nebenbei zu dokumentieren (oder diesen Beitrag hier zu schreiben).

Da Jupyter Notebooks letzten Endes auf der IPython Shell basieren, können wir natürlich auch sämtliche Features und magics benutzten. Hierdurch können automatische Vervollständigungen genutzt werden. Man erhält Zugang zu den Docstring der einzelnen Funktionen und Klassen sowie die Zeilen- und Zellen-magics in IPython. Einzelne Blöcke sind hierdurch für mehr als nur Python Code verwendbar. Zum Beispiel kann Code schnell und einfach auf seine Geschwindigkeit geprüft werden mit %timeit, die System Shell direkt mit %%bash angesprochen oder deren Output direkt aufgenommen werden mit ! am Anfang der Zeile. Darüber hinaus könnte auch Ruby-Code durch %%ruby, Javascript mit %%js ausgeführt oder sogar zwischen Python2 und Python3 %%python2 & %%python3 in der jeweiligen Zelle gewechselt werden.

%timeit returnhello('Statworx-Blog')
170 ns ± 3.77 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)
%%bash
ls
Blog_Python.html
Blog_Python.ipynb
Blog_Python.md

Eine Anmerkung, die hier auch nicht fehlen darf, ist, dass Jupyter nicht nur exklusiv für Python ist, sondern auch mit R oder Julia verwendet werden kann. Die jeweiligen Kerne sind zwar nicht ‚Out of the Box‘ enthalten, aber sofern man eine andere Sprache benutzen möchte / soll, ist es in meinen Augen den geringen Aufwand wert.

Wem das Ganze nicht gefällt und lieber eine gute alte IDE für Python haben möchte, dem stehen eine Vielzahl an Möglichkeiten offen. Diverse Multi-Sprachen IDE’s mit einem Python-Plug-In (Visuale Studio, Eclipse, etc.) oder jene, die direkt auf Python zugeschnitten sind. Zum letzteren gehören PyCharm und Spyder.

PyCharm wurde von JetBrains entworfen und ist in zwei Versionen erhältlich, der Community Version (CE) und der Pro-Version. Während die CE ein opensource Projekt mit ’nur‘ intelligentem Editor, graphischen Debugger, VSC-Support und Introspektion ist, enthält die Pro Version gegen ein Entgelt einen Support für Web-Devolopment. Während so die Pflege der IDE gesichert ist, fehlt mir persönlich die Optimierung für Data Science spezifische Anwendung (IPython Konsole). Spyder jedoch, als wissenschaftliches opensource Projekt, bietet alles was man sich wünscht. Ein Editor (nicht ganz so intelligent wie die JetBrains Version) für Skripte, einen Explorer für Variablen und Dateien und eine IPython Konsole.

Das letzte Tool, welches benötigt wird und nicht fehlen darf, ist ein Editor. Dieser sollte vor allem schnell sein, um jede Datei zumindest im Roh-Format öffnen zu können. Nötig ist dieses, da man oft durch einen kurzen Blick z.B. in CSV-files schnell einen Eindruck über die Kodierung bekommt: welches Zeichen trennt Zeilen und welches trennt Spalten. Zum anderen kann man schnell und einfach andere Skripte aufrufen, ohne dass man Warten muss bis die IDE geladen ist.

Vorschau

Da wir nun ein Arsenal an Werkzeugen haben, um uns ins Datamunging zu stürzen, können wir uns im nächsten Teil dieser Reihe direkt mit Pandas, seinen DataFrames und dem mathematischen Ökosystem befassen.