Blog

Pandas DataFrame Validierung mit Pydantic

Mit wachsender Code Basis und zunehmender Verflechtung von Klassen, Methoden und Funktionen, wird es zunehmend schwierig beim Coden mit der dynamischen Typisierung in Python zu arbeiten.

Ein klares Verständis dafür, welcher Typ der Rückgabewert einer Funktion hat - und auch welchen Input-Typ die Funktion erwartet - hilft ungemein beim Vermeiden von Bugs und dem Anpassungen und erweitern von Code. Die Frage ist nur, wie man die enorme Flexibilität durch dynamische Typisierung in Python mit der Sicherheit und Eindeutigkeit statischer Typisierung verheiraten kann.

In diesem Artikel greifen wir diese Frage auf und zeigen einen Weg Funktionen die Pandas DataFrames zurück geben zu validieren. Dafür werden wir das Pydantic Paket zusammen mit einem benutzerdefinierten Decorator verwenden, um eine bequeme und dennoch ausgeklügelte Methode zur Validierung von Funktionen vorzunehmen.

Inhalt

Der Artikel ist wie folgt strukturiert:

Teil 1:

  • Dynamische Typisierung - Eine kurze Einführung zur dynamischen Typisierung und wieso output Validierung sinnvoll ist.
  • Pydantic - Eine kurze Einführung in das Pydantic Paket.
  • Decorators - Eine kurze Einführung in Decorators in Python.

Teil 2:

  1. Kombination von Decorators, Pydantic und Pandas - Wir kombinieren die Punkte 1. und 2. um zu zeigen, wie sie zur Output-Validierung verwendet werden können.
  2. Definieren wir uns ein richtiges Raumschiff! - Eine komplexe Output-Validierung von DataFrames mit benutzerdefinierter und spaltenübergreifender Validierung.
  3. Fazit

Leser die sich bereits mit Pydantic Klassen auskennen (Beispielsweise aus FastApi) können den Anfang von 2. überspringen, und Leser die sich bereits mit dem Erstellen von eigenen Decorator Funktionen auskennen, können Abschnitt 3. überspringen.

1. Dynamische Typisierung

Python ist (zum Glück) keine statisch typisierte Sprache. Deshalb können wir so schnell und einfach unseren Code entwickeln. Der Nachteil einer dynamisch typisierten Programmiersprache besteht darin, dass wir während der Laufzeit des Programms auf mehr Probleme stoßen können, als es mit einem streng statischen Typisierungsschema der Fall wäre. Doch immerhin wird in Python zumindest ein Fehler ausgeben, anstatt Typen einfach zu konvertieren wie JavaScript.

Question: "Why is JavaScript called a weakly typed language?"Answer: "Because every week you come into the office and are suprised what type your output will have this week."

Da wir einen Typfehler natürlich abfangen wollen bevor der Code in Produktion läuft, schreiben wir Tests, die überprüfen, ob das Ergebnis einer Funktion dem gewünschten Typ entspricht (z. B. über isinstance(res, str)). Mit einer umfangreichen Testsuite sind wir in der Lage, viele mögliche Bugs und Fehler zu erfassen, bevor sie auftreten. Und wir garantieren, dass eine Änderung an der Funktion, die die erwartete Ausgabe ungewollt verändert, noch während der Entwicklung erkannt wird.

Wie ich eingangs erwähnt habe, liegt der Grund für die Tests darin, dass wir eine dynamisch typisierte Sprache verwenden und die Geschwindigkeit und Flexibilität beibehalten möchten. Aber wir wollen auch die Vorteile nutzen, die aus einem klaren Verständnis dafür resultieren, welche Typen in unsere Funktion eingehen und welche Typen aus unseren Funktionen/Properties/Methoden/... hervorgehen. Andernfalls wird es immer schwieriger, die ständig wachsende Code-Basis zu warten und zu debuggen.

Type Annotation

Type Annotations sind eine beliebte Möglichkeit zu erkennen, welche Typen in eine Funktion ein- und ausgehen sollen. Sie ermöglichen es auch Pycharm/VScode, uns ein Highlight zu geben, wenn wir versuchen eine 3 an eine Funktion zu übergeben, die eigentlich eine "3" als String erwartet. Es gibt sogar Tools wie MyPy, die in die CI/CD-Jenkins-Pipeline integriert werden können, um die Type Annotations zu erzwingen und die Pipeline fehlschlagen lassen, wenn z.B. ein Integer an einen String-Parameter übergeben wird.

Aber Type Annotations sind nur eine rein visuelle Hilfe für uns und/oder die IDE. Sie validieren nicht die Eingabe / Ausgabe von Funktionen (und beschleunigen leider den Code nicht wie in Julia). Sie sind auch fast unmöglich zusammen mit DataFrames zu verwenden, und die meisten unserer Funktionen beschäftigen sich mit Pandas DataFrames.

Ziele

  • flexibler Code
  • robuster/stabiler Code in der Produktion
  • Tests/Datenvalidierung

Es gibt viele Möglichkeiten Pandas DataFrames oder die Funktionen aus denen sie resultieren zu testen. Wir stellen sicher, dass alle Spalten vorhanden sind, dass alle Spalten den richtigen Typ haben, dass die Wertebereiche sinnvoll sind, dass die fehlenden Werte sinnvoll sind und so weiter. Und wir wollen auf jeden Fall, dass diese Eigenschaften auch in der Produktions-Pipeline vorliegen.

Mit Unit-Tests sind alle diese Überprüfungen kein Problem. Um sicherzustellen, dass die Testsuite in unter 3 Minuten läuft, verwenden wir in allen unseren Tests sinnvolle Dummy-Daten. Dies erlaubt uns das Testen in der Entwicklung, aber wir testen eben nicht während der Produktions-Pipeline an den realen Daten.

Hier kommt Pydantic ins Spiel.

2. Pydantic

Pydantic ist ein großartiges Tool zur Eingabevalidierung, das beispielsweise im
FastApi-Paket verwendet wird. Pydantic ermöglicht es uns, komplexe Datenstrukturen zu definieren und benutzerdefinierte @validator-Methoden hinzuzufügen, die bei Verletzung der Vorgaben eine sinnvolle Fehlermeldung ausgeben.

Der folgende Code überprüft beispielsweise, ob jede gegebene Eingabe für diese Klasse diese Bedingungen erfüllt:

  • id >= 1,
  • len(name) <= 20,
  • height == None or 0 <= height <= 250
from typing import Optional

from pydantic import BaseModel, Field

class DictValidator(BaseModel):
    id: int = Field(..., ge=1)
    name: str = Field(..., max_length=20)
    height: float = Field(..., ge=0, le=250, description="Height in cm.")
DictValidator(id=1, name='Sebastian', height=178.0)
DictValidator(**{"id": 1, "name": "Sebastian", "height": 178.0})

Der nächste Code-Chunk zeigt, wie bei einer ungültigen Eingabe eine sinnvolle Fehlermeldung zurückgegeben wird (hier id < 1).

from pydantic import ValidationError
# try except so the code chunks run but shows the error raised.
try:
# gives an error because id < 1:
    DictValidator(**{"id": 0, "name": "Sebastian", "height": 178.0})
except ValidationError as e:
    print(e)
1 validation error for DictValidator
id
  ensure this value is greater than or equal to 1 (type=value_error.number.not_ge; limit_value=1)

Damit können wir jedes Dictionary, das der Pydantic-Klasse übergeben wird, ganz einfach validieren.

Kombination von Pydantic und Pandas

Wie hilft uns das bei der Validierung von Pandas DataFrames? Durch die Transformation der DataFrames in Dictionaries!

pd.DataFrame().to_dict(orient="records") erstellt ein Dictionary, das wir an die Pydantic-Klasse übergeben können.

import pandas as pd
person_data = pd.DataFrame([{"id": 1, "name": "Sebastian", "height": 178},
                            {"id": 2, "name": "Max", "height": 218},
                            {"id": 3, "name": "Mustermann", "height": 151}])
#    id        name  height
# 0   1   Sebastian     178
# 1   2         Max     218
# 2   3  Mustermann     151

person_data.to_dict(orient="records")
[{'id': 1, 'name': 'Sebastian', 'height': 178},
 {'id': 2, 'name': 'Max', 'height': 218},
 {'id': 3, 'name': 'Mustermann', 'height': 151}]

Jetzt müssen wir nur noch jedes dieser Dictionaries an den Validator weitergeben.

Dafür können wir eine kleine Pydantic-Helferklasse verwenden:

from typing import List
class PdVal(BaseModel):
    df_dict: List[DictValidator]

PdVal(df_dict=person_data.to_dict(orient="records"))
PdVal(df_dict=[DictValidator(id=1, name='Sebastian', height=178.0), DictValidator(id=2, name='Max', height=218.0), DictValidator(id=3, name='Mustermann', height=151.0)])

Jetzt können wir beliebig komplexe Pydantic-Klassen erstellen, die den Inhalt unserer DataFrames definieren. Und dank der to_dict-Transformation können wir die Validierung auch on-the-fly während unserer Pipeline durchführen.

# error because height for id 3 is > 250:
wrong_person_data = pd.DataFrame([{"id": 1, "name": "Sebastian", "height": 178},
                                  {"id": 2, "name": "Max", "height": 218},
                                  {"id": 3, "name": "Mustermann", "height": 251}])
#    id        name  height
# 0   1   Sebastian     178
# 1   2         Max     218
# 2   3  Mustermann     251

try:
    PdVal(df_dict=wrong_person_data.to_dict(orient="records"))
except ValidationError as e:
    print(e)
1 validation error for PdVal
df_dict -> 2 -> height
  ensure this value is less than or equal to 250 (type=value_error.number.not_le; limit_value=250)

Jetzt können wir uns alle Unit-Tests sparen .

Eigentlich sparen wir uns mit Pydantic nicht wirklich das Schreiben der Tests, wir bringen sie nur an eine andere Stelle und führen die Tests jedes Mal aus, wenn unsere Funktion/Methode/Eigenschaft aufgerufen wird.

Hier ist eine kleine Beispielfunktion:

# Let's use the user_id as the height for the Mustermann avatar to trigger the validation.
def return_user_avatars(user_id: int) -> pd.DataFrame:
    user_avatars = pd.DataFrame(
         [
         {"id": user_id, "name": "Herr", "height": 178.0},
         {"id": user_id, "name": "Max", "height": 218.0},
         {"id": user_id, "name": "Mustermann", "height": user_id},
        ]
    )
    _ = PdVal(df_dict=user_avatars.to_dict(orient="records"))
    return user_avatars
# works
return_user_avatars(34)

# does not work because height 342 > 250:
try:
    return_user_avatars(342)
except ValidationError as e:
    print(e)
1 validation error for PdVal
df_dict -> 2 -> height

Es wäre sehr mühsam _ = PdVal(df_dict=user_avatars.to_dict(orient="records")) in jede Funktion einzufügen, aber die PdVal-Klasse funktioniert nur mit dem Schema id, name und height.

Wir brauchen also hier noch etwas wiederverwendbares, aber auch flexibles. Zum Glück hat uns der Weihnachtsblog-Artikel genau das vorgestellt: wiederverwendbare und flexible Decorators!

Was wir wollen ist

  • unsere Funktion wie gewohnt zu schreiben
  • dann die DataFrame-Ausgabe definieren mithilfe der benutzerdefinierten Validierung
  • und das dann mit so wenig Aufwand wie möglich kombinieren

Schreiben wir also einen Decorator dafür. Eine kleine Einführung in Decorators finden Sie in unserem Blogartikel zur Python Weihnachtsdekoration.

3. Decorators

Ein Decorator nimmt eine Funktion und fügt zusätzliche Funktionalität an diese Funktion an, ohne die Funktion selbst zu berühren.

Hier ist die grundlegende Definition.

def basic_decorator_definition():
    def Inner(func):
        def wrapper(*args, **kwargs):
            # add functionality here
            res = func(*args, **kwargs)
            # or here
            return res
        return wrapper
    return Inner

Der Decorator ist eine Funktion.

Inner nimmt die Funktion, die es dekoriert, als Eingabe.

Wrapper fügt einige Funktionen innerhalb einer Wrapper-Funktion hinzu und gibt dann das Ergebnis der Funktion zurück.

(Im Weihnachtsblogartikel hatte der decorative_christmas_break_timer keine innere Funktion um den Wrapper, da wir keine Eingabe an den Decorator übergeben haben. Sobald wir dies tun möchten, müssen wir die Inner Syntax hinzufügen.)

Fazit

In diesem Teil des Artikels haben wir die Nachteile der dynamischen Typisierung in Python in Bezug auf Datenqualität und Code-Wartbarkeit diskutiert. Es gab eine Einführung in das Pydantic-Paket und wir haben gezeigt, wie Decorators funktionieren.

Im zweiten Teil des Artikels werden wir sehen, wie wir diese Konzepte für unsere Pandas DataFrame-Validierung verwenden können.

Weitere Teile der Artikelreihe:

by Sebastian Cattes

Pandas DataFrame Validierung mit Pydantic - Teil 2