Blog

Pandas DataFrame Validierung mit Pydantic - Teil 2

Im ersten Teil der Artikelserie haben wir uns Dynamische Typisierung, Pydantic und Decorators angeschaut.

In diesem Teil werden wir lernen, wie diese Konzepte für die Validierung von Pandas DataFrames kombiniert werden können.

  1. Kombination von Decorators, Pydantic und Pandas
  2. Definieren wir uns ein richtiges Raumschiff!
  3. Fazit

1. Kombination von Decorators, Pydantic und Pandas

import pandas as pd
from pydantic.main import ModelMetaclass
from typing import List

def validate_data_schema(data_schema: ModelMetaclass):
    """This decorator will validate a pandas.DataFrame against the given data_schema."""

    def Inner(func):
        def wrapper(*args, **kwargs):
            res = func(*args, **kwargs)
            if isinstance(res, pd.DataFrame):
                # check result of the function execution against the data_schema
                df_dict = res.to_dict(orient="records")
                
                # Wrap the data_schema into a helper class for validation
                class ValidationWrap(BaseModel):
                    df_dict: List[data_schema]
                # Do the validation
                _ = ValidationWrap(df_dict=df_dict)
            else:
                raise TypeError("Your Function is not returning an object of type pandas.DataFrame.")

            # return the function result
            return res
        return wrapper
    return Inner

Das war ziemlich einfach. Machen wir weiter.

Nur ein Scherz ;) Wir schauen uns das natürlich etwas genauer an.

Der Decorator nimmt zunächst ein Pydantic-Modell data_schema (z.B. die
DictValidator-Klasse von Teil 1) als Input.

Inner: und Wrapper: sind notwenige Verschachtelungen um die Ausführung der Funktion die wir dekorieren. Das Ergebnis der dekorierten Funktion wird im res-Objekt gespeichert werden.

Wenn das res-Objekt kein Pandas DataFrame ist, wird ein TypeError ausgeworfen.

Wenn es sich bei res um einen pandas DataFrame handelt, wird der DataFrame in ein List[Dict] umgewandelt. Dann wird eine kleine ValidationWrap-Hilfsklasse erstellt und es erfolgt die eigentliche Validierung durch Übergeben des df_dict an die
ValidationWrap-Klasse.

Wenn ein Problem mit dem Inhalt des pandas DataFrame auftritt, der nicht zum definierten Pydantic-Modell passt, wird der Pydantic-Validation-Error ausgegeben.

Wenn alles in Ordnung ist, wird das res-Objekt zurückgegeben.

Testen wir das an einem Beispiel!

from pydantic import BaseModel, Field
# We expect a DataFrame with the columns id, name and height.
# id must be an int >= 1, name a string shorter than 20, and height is a float between 0 and 250.
class AvatarFrameDefinition(BaseModel):
    id: int = Field(..., ge=1)
    name: str = Field(..., max_length=20)
    height: float = Field(..., ge=0, le=250, description="Height in cm.")


@validate_data_schema(data_schema=AvatarFrameDefinition)
def return_user_avatars(user_id: int) -> pd.DataFrame:
    # Let's use the user_id as the height for the Mustermann avatar to trigger the validation.
    return pd.DataFrame(
         [
         {"id": user_id, "name": "Sebastian", "height": 178.0},
         {"id": user_id, "name": "Max", "height": 218.0},
         {"id": user_id, "name": "Mustermann", "height": user_id },
        ]
    )
return_user_avatars(user_id=42)  # works

from pydantic import ValidationError

try:
    return_user_avatars(user_id=342) # gives an error!
except ValidationError as e:
    print(e)
1 validation error for ValidationWrap
df_dict -> 2 -> height
  ensure this value is less than or equal to 250 (type=value_error.number.not_le; limit_value=250)

Der Decorator funktioniert genau wie erwartet! Nur um sicher zu gehen, testen wir ihn noch ein bisschen mehr!

Übergeben wir ihm einen Float als user_id. Da wir im Pydantic-Modell die user_id als Integer definiert haben, sollten wir einen Fehler erhalten.

return_user_avatars(34.0)  # works? T.T

Zunächst sehen wir, dass Pycharm unsere Typ-Hinweise erkennt, denn die 34.0 ist kein Integer, sondern ein Float, und wird somit farblich hervorgehoben. Aber Pydantic wirft trotzdem keinen Fehler. Die Funktion wird ohne Probleme ausgeführt, was ist also los? Ist unsere Idee am Ende doch nicht so gut?

An dieser Stelle müssen wir besser verstehen, wie Pydantic funktioniert. Denn wir müssen genau angeben, wie streng wir bei der Bewertung sein wollen.

Wenn wir in Pydantic einen Typ als Integer definieren, versucht Pydantic, int(value) aufzurufen, und wenn dies keinen Fehler auslöst, ist alles in Ordnung. Aber das kann zu unerwünschten Fehlern führen, daher müssen wir etwas genauer sein.

Lassen Sie uns conint (constricted int) und confloat und constr von Pydantic verwenden, um den strikten Modus zusammen mit unseren Bedingungen zu verwenden. (Würden keine speziellen Bedingungen wie user_id > 0 vorliegen, könnten wir auch direkt den Typ StrictInt importieren).

from typing import Optional
from pydantic import BaseModel, conint, confloat, constr

class StrictAvatarFrameDefinition(BaseModel):

    id: conint(strict=True, ge=1)
    name: constr(max_length=20)
    height: Optional[confloat(strict=True, ge=0, le=250)] = Field(None, description="Height in cm.")

@validate_data_schema(data_schema=StrictAvatarFrameDefinition)
def return_user_avatars(user_id: int) -> pd.DataFrame:
    # Let's use the user_id as the height for the Mustermann avatar to trigger the validation.
    user_avatars = pd.DataFrame(
         [
         {"id": user_id, "name": "Sebastian", "height": 178},
         {"id": user_id, "name": "Max", "height": 218},
         {"id": user_id, "name": "Mustermann", "height": user_id },
        ]
    )

    return user_avatars
try:
    return_user_avatars(user_id=42.0) # does not work anymore
except ValidationError as e:
    print(e)
3 validation errors for ValidationWrap
df_dict -> 0 -> id
  value is not a valid integer (type=type_error.integer)
df_dict -> 1 -> id
  value is not a valid integer (type=type_error.integer)
df_dict -> 2 -> id
  value is not a valid integer (type=type_error.integer)

Wir sehen also, dass Pydantic ein eigenes Paket mit einer eigenen Syntax ist, mit der man sich vertraut machen muss. Hat man sich einmal daran gewöhnt, kann Pydantic eine echte Hilfe sein, um die Typ-Konsistenz, Ausgabe und Inhalt für beliebig komplexe DataFrames zu validieren.

Beachten Sie auch, dass der Decorator nicht angefasst wurde, um unseren Float-Fehler zu beheben, sondern nur die Pydantic-Modelldefinition angepasst werden musste. Dies zeigt wie flexibel und nützlich Decorators sind.

Lassen Sie uns zum Schluss noch ein komplexers Beispiel anschauen.

2. Definieren wir uns ein richtiges Raumschiff!

Zuerst definieren wir die Schiffstypen mit einer Enum-Klasse, um sicherzustellen, dass niemand versucht, ein außerirdisches Raumschiff zu definieren.

from enum import Enum
class SpaceShipTypes(
    str,
    Enum,
):
    """Possible spaceship Types."""

    light_fighter = "light_fighter"
    cruiser = "cruiser"
    battlecruiser = "battlecruiser"
    destroyer = "destroyer"
    death_star = "death_star"

Jetzt definieren wir unsere SpaceShipClass.

Ein richtiges Raumschiff braucht

  • ein ship_type (entspricht der obigen Enum-Klasse)
  • eine ssin [Raumschiff-Identifikationsnummer] (definiert als Zeichenkette mit 12 Stellen, die mit 2 Buchstaben beginnt, gefolgt von 9 Buchstaben/Zahlen, und mit einer Zahl endet), die wir mit einem regulären Ausdruck validieren.
  • ein build_date. Wir werden einen benutzerdefinierten Validator verwenden, der überprüft, dass das Raumschiff nicht in der Zukunft gebaut wurde (obwohl theoretisch möglich) und auch nicht vor der Ära der Raumfahrt.
  • ein deprecation_date (für steuerliche Zwecke). Wir werden überprüfen, ob es größer als das Datum der Fertigstellung des Raumschiffes ist.
import datetime
from pydantic import validator

# Let's define our SpaceShipClass!
class SpaceShipClass(BaseModel):
    ship_type: SpaceShipTypes  # the previous defined Enum class
    
    ssin: str = Field(  # ssin validation using regex
        ...,
        max_length=12,
        min_length=12,
        regex="^([A-Z]{2})([0-9A-Z]{9})([0-9]{1})$",
        title="SSIN",
        description="A spaceship Identification Numbers.",
    )

    build_date: datetime.date = Field(  # build date with custom @validator below
        ...,
        title="Build Date",
        description="The date the spaceship was produced. Has to be a str in ISO 8601 format, like: 2020-09-25.",
    )

    @validator("build_date")
    def build_date_ok(cls, build_date):
        """Validate build_date value."""
        min_build_date = datetime.date(year=1961, month=4, day=12)
        if build_date > datetime.date.today():
            raise ValueError("Build date should not be in the future.")
        elif build_date < min_build_date:
            raise ValueError(
                "Build date must be larger than {min_date}.".format(min_date=min_build_date),
            )
        return build_date

    # Deprecation date with custom @validator that ensures that it is larger than the build date.
    # By adding a `values` field to the @validator we can access previously defined elements of the class.
    deprecation_date: datetime.date = Field(
        None,
        title="Deprecation Date",
        description="The date the spaceship will retire. Has to be a str in ISO 8601 format, like: 2020-09-25.",
    )

    @validator("deprecation_date")
    def deprecation_date_ok(cls, deprecation_date, values):
        """Validate the build_date value."""
        if "build_date" in values:  
            # 'if "build_date" in values:' is necessary because if the validation already fails on the build_date variable,
            # it will not be present for validation of this field, which would therefore create a key error
            # (behaviour given by the Pydantic package).
            if deprecation_date <= values["build_date"]:
                raise ValueError("The deprecation date must be larger than the build date.")
        return deprecation_date

Nun, das ist eine Menge Code, um sicherzustellen, dass wir Raumschiffe ordnungsgemäß definieren!

Lassen Sie uns das in einer Raumschiff-Erstellungsfunktion verwenden.

@validate_data_schema(data_schema=SpaceShipClass)
def diy_ss(ship_type, ssin, build_date, deprecation_date):
    return pd.DataFrame(
        {"ship_type": ship_type,
         "ssin": ssin,
         "build_date": build_date,
         "deprecation_date": deprecation_date},
        index = [0]
    )
diy_ss(
    ship_type="battlecruiser",
    ssin="DE342INWT944",
    build_date="2020-09-25",
    deprecation_date="2030-09-25"
)

Das erstellen der "DE342INWT944" hat super funktioniert. Jetzt überprüfen wir noch ein paar Fälle von Raumschiffen, die nicht vernünftig definiert wurden.

try:
    diy_ss(
        ship_type= "battlecruiser",
        ssin= "FR123",
        build_date= "2020-09-25",
        deprecation_date= "2030-09-25"
    )
except ValueError as e:
    print(e)
1 validation error for ValidationWrap
df_dict -> 0 -> ssin
  ensure this value has at least 12 characters (type=value_error.any_str.min_length; limit_value=12)
try:
    diy_ss(
        ship_type= "battlecruiser",
        ssin= "DE342INWT944",
        build_date= "1900-09-25",
        deprecation_date= "2030-09-25"
    )
except ValueError as e:
    print(e)
1 validation error for ValidationWrap
df_dict -> 0 -> build_date
  Build date must be larger than 1961-04-12. (type=value_error)
try:
    diy_ss(
        ship_type= "battlecruiser",
        ssin= "DE342INWT944",
        build_date= "2030-09-25",
        deprecation_date= "2030-09-25"
    )
except ValueError as e:
    print(e)
1 validation error for ValidationWrap
df_dict -> 0 -> build_date
  Build date should not be in the future. (type=value_error)
try:
    diy_ss(
        ship_type= "battlecruiser",
        ssin= "DE342INWT944",
        build_date= "2020-09-25",
        deprecation_date= "2010-09-25"
    )
except ValueError as e:
    print(e)
1 validation error for ValidationWrap
df_dict -> 0 -> deprecation_date
  The deprecation date must be larger than the build date. (type=value_error)

Das erstellen der "DE342INWT944" hat super funktioniert. Jetzt überprüfen wir noch ein paar Fälle von Raumschiffen, die nicht vernünftig definiert wurden.

Die komplexe Validierung läuft wie am Schnürchen.

Zwar müssen wir uns erst an die Pydantic-Syntax gewöhnen, um den Dreh raus zu bekommen. Aber die meisten Funktionen können wir mit diesem Konzept nun schon im laufenden Betrieb validieren.

3. Fazit

Im ersten Teil dieser Artikelserie haben wir die Nachteile der dynamischen Typisierung in Bezug auf Datenqualität und Code-Wartbarkeit diskutiert. Wir gaben eine Einführung in das Pydantic-Paket und zeigten, wie Decorators arbeiten.

In diesem Teil haben wir die beiden Konzepte kombiniert, um zu zeigen, wie man eine einfache, aber flexible und (dank Pydantic) leistungsstarke Methode zur Durchführung komplexer DataFrame-Validierungen erstellt. Mit der Decorator-Syntax kann diese Validierungsmethode sehr einfach zu einer bestehenden Codebasis hinzugefügt werden.

Auf diese Weise können Unit-Tests für Funktionen, die DataFrames zurückgeben, reduziert und die Datenqualität innerhalb von Produktionspipelines trotzdem sichergestellt werden.

Wir haben auch gezeigt, wie wichtig es ist, das Pydantic-Paket und die Syntax wirklich zu verstehen, um sicherzustellen, dass tatsächlich das validiert wird, was es zu validieren galt.

Dieser Artikel zeigt eine interessante Möglichkeit, bestehende Python-Pakete und neuere Konzepte zu kombinieren, um einige Probleme von Python selbst zu umgehen.

Weitere Teile der Artikelreihe:

by Sebastian Cattes

Pandas DataFrame Validierung mit Pydantic