Polars und Datenqualität aus einem Data Contract

Track KI · M6 Datenseite, Bausteine 01 und 02 · ca. 65 Min.

Worum es geht

Viele KI-Anwendungen hängen an Daten, die vorher sauber sein müssen: Messwerte, Stammdaten, Exporte. Zwei Dinge brauchst du dafür. Erstens ein Werkzeug, das Tabellen schnell und lesbar verarbeitet (Polars, pandas). Zweitens eine Prüfung, die du nicht von Hand pflegst, sondern aus einem Data Contract (der Vereinbarung, wie jedes Feld aussehen soll) automatisch ableitest. Das ist der Teil von M6, der am direktesten an deine Data-Contract-Arbeit andockt.

Ehrlich vorab, was im Browser läuft: Polars ist in der Pyodide-Umgebung, mit der diese Lektion getestet wurde (Pyodide 0.28.1), nicht als Paket vorhanden. pandas läuft. Darum gilt in dieser Lektion:

  • Alle ausführbaren Beispiele und Übungen nutzen pandas (und reines Python).
  • Die Polars-Syntax steht als Textblock mit dem Hinweis “nicht im Browser ausführbar” daneben, jeweils im Vergleich zu pandas. Diese Polars-Blöcke wurden hier nicht ausgeführt (Polars ist auch lokal nicht installiert). Prüfe sie selbst im Lernlabor, nachdem du Polars bewusst selbst installiert hast (bitte prüfen, ob eine neuere Pyodide-Version Polars anbietet).
  • Lazy Evaluation (der Denkunterschied von Polars) lernst du mit einem kleinen selbstgebauten MiniLazy aus reinem Python. Das Prinzip ist dasselbe, die Mechanik ist stark vereinfacht.
  • Die Ideen aus Baustein 02 (Regeln aus dem Contract ableiten) sind unabhängig von der Bibliothek und laufen mit pandas vollständig.

Zeitplan ehrlich: etwa 25 Minuten Lesen, 40 Minuten für fünf Übungen. Die lokale Zusatzaufgabe (echte Dateien, uv run) kommt obendrauf. Wenn du morgens nur 30 Minuten hast: Lies bis einschließlich Übung 2 (Polars, lazy, Gruppieren). Der Rest (Data Contract, Übung 3 bis 5) ist ein guter zweiter Block.

Von JS/TS her gedacht

Idee JS/TS pandas Polars (nicht im Browser)
Tabelle Array von Objekten DataFrame DataFrame
Zeilen filtern rows.filter(r => r.status === "gueltig") df[df["status"] == "gueltig"] df.filter(pl.col("status") == "gueltig")
Spalte berechnen rows.map(r => ({...r, kwh: r.wert * 0.25})) df.assign(kwh=df["wert"] * 0.25) df.with_columns((pl.col("wert") * 0.25).alias("kwh"))
Gruppieren Object.groupBy(rows, r => r.id) plus Schleife df.groupby("id")["wert"].mean() df.group_by("id").agg(pl.col("wert").mean())
Ausführung sofort, Zeile für Zeile sofort (eager) scan_* plus .collect() (lazy)

Der große Unterschied zu deinem JS-Alltag: Du schreibst keine Schleife über Zeilen. Du beschreibst eine Operation auf einer ganzen Spalte (ein Ausdruck, expression), und die Bibliothek erledigt die Schleife schnell im Inneren (bei Polars in Rust, parallel auf allen Kernen, Quelle).

Und weil du SQL gut kennst, hier dieselben Ideen als Gegenüberstellung:

SQL pandas Polars (nicht im Browser)
WHERE status = 'gueltig' df[df["status"] == "gueltig"] df.filter(pl.col("status") == "gueltig")
GROUP BY zaehler_id df.groupby("zaehler_id") df.group_by("zaehler_id")
AVG(wert) ["wert"].mean() pl.col("wert").mean()
COUNT(*) .size() oder len(df) pl.len()
x IS NULL df["x"].isna() pl.col("x").is_null()
x IN ('a','b') df["x"].isin(["a", "b"]) pl.col("x").is_in(["a", "b"])
ORDER BY wert df.sort_values("wert") df.sort("wert")
HAVING COUNT(*) > 1 erst gruppieren, danach die Ergebniszeilen filtern erst agg, danach filter

Merke: Ein SQL-WHERE filtert vor dem Gruppieren, HAVING danach. Das gilt im Dataframe genauso: Die Reihenfolge der Schritte bestimmt das Ergebnis.

Expressions statt Zeilenschleifen

Die Daten dieses Abschnitts: fünf Zählerwerte, davon eine Zeile mit Status ungueltig.

Ausgabe beim Ausführen: Beide Wege liefern 138.6. Die Maske ist [True, True, True, False, True]: eine Spalte aus Wahrheitswerten (boolean mask), erzeugt durch einen einzigen Vergleich. Der Vergleich messwerte["status"] == "gueltig" läuft über alle Zeilen, ohne dass du for schreibst. In JS wäre das rows.map(r => r.status === "gueltig").

Derselbe Gedanke in Polars (nicht im Browser ausführbar, nicht ausgeführt):

import polars as pl

df = pl.DataFrame({
    "zaehler_id": ["Z-1", "Z-1", "Z-2", "Z-2", "Z-2"],
    "wert": [182.4, 179.9, 95.0, -5.2, 97.1],
    "status": ["gueltig", "gueltig", "gueltig", "ungueltig", "gueltig"],
})
df.filter(pl.col("status") == "gueltig").select(pl.col("wert").mean())

In Polars beschreibt pl.col("status") == "gueltig" einen Ausdruck (ein Bauplan, noch kein Ergebnis). Erst filter oder select wendet ihn auf den DataFrame an. Der Vorteil: Polars sieht mehrere Ausdrücke gleichzeitig und kann sie parallel rechnen.

Falle: iterrows() fühlt sich vertraut an, ist aber genau die Schleife, die du vermeiden willst. Sie ist langsam, und die Zeilen kommen als Series mit einheitlichem Typ zurück (gemischte Spalten werden dabei ggf. zu object). Wenn du dich beim Schreiben von for ... in df erwischt, suche den Ausdruck.

Lazy gegen eager

Eager (eifrig): jeder Schritt rechnet sofort. So arbeitet pandas, und so arbeitet dein map/filter in JS. Lazy (faul): jeder Schritt wird nur notiert. Gerechnet wird erst bei .collect(). Dann sieht die Bibliothek die ganze Kette und kann sie optimieren, zum Beispiel Filter nach vorn ziehen, damit spätere Schritte weniger Zeilen sehen.

So sieht es in Polars aus (Beispiel der Quelle, nicht im Browser ausführbar):

import polars as pl

df = pl.scan_csv("messwerte.csv")  # lazy, liest noch nichts vollständig ein
ergebnis = (
    df.filter(pl.col("status") == "gueltig")
      .group_by("zaehler_id")
      .agg(pl.col("wert").mean().alias("mittelwert"))
      .collect()  # erst hier wird tatsächlich ausgeführt
)

Den Mechanismus bauen wir winzig nach. MiniLazy merkt sich Schritte in einer Liste (plan) und führt sie erst in collect() aus. Der Zähler berechnet zählt, wie oft eine Funktion auf eine Zeile angewendet wurde. Mit optimiere=True zieht collect() alle Filter vor die map-Schritte. (Das ist nur erlaubt, wenn ein Filter nicht von einer berechneten Spalte abhängt. Echte Optimierer prüfen das, unser Spielzeug vertraut dir.)

Ausgabe beim Ausführen: nach dem Bauen: 0, dann nach collect(): 12 Zeilen im Ergebnis: 4, dann optimiert: 10 gleiches Ergebnis: True. Durchgerechnet: Ohne Optimierung läuft map über alle 6 Zeilen (6), danach der Filter über 6 Zeilen (6), zusammen 12. Mit Optimierung läuft der Filter zuerst über 6 Zeilen, vier überleben, dann rechnet map nur diese 4 (6 + 4 = 10). Das Ergebnis ist gleich, nur die Arbeit ist kleiner. Das ist der Gewinn von lazy.

Hinweis

Merke: Die Zahlen hier zählen Funktionsaufrufe, keine Zeit. Zeit im Browser zu messen ist unzuverlässig, und bei 6 Zeilen wäre sie ohnehin nur Rauschen. Die Quelle sagt es ähnlich: Bei kleinen, einmaligen Auswertungen ist pandas oft schneller aufgeschrieben. Polars zahlt sich bei wiederholter Verarbeitung oder in relevanter Größe aus (ab niedrigen Millionen Zeilen, Quelle).

Übung 1: Lazy vorhersagen

Sage vorher, wie oft MiniLazy eine Funktion auf eine Zeile anwendet. Es gibt drei Teile: nach dem Bauen der Kette (vor collect()), nach collect() und nach collect(optimiere=True). Trage ein Tupel mit drei ganzen Zahlen ein. Die Klasse MiniLazy steht schon bereit.

Zähle pro Schritt, auf wie viele Zeilen er angewendet wird, und wie viele Zeilen den Schritt überleben. Die Reihenfolge der Schritte entscheidet, wie viele Zeilen der nächste Schritt noch sieht.

antwort = (0, 20, 15)
antwort

Teil 1: lazy, also 0. Teil 2: map über 8 Zeilen (8), erster Filter über 8 Zeilen (8, vier überleben), zweiter Filter über 4 Zeilen (4): 20. Teil 3: erster Filter über 8 (8), zweiter über 4 (4, drei überleben), map über 3: 15.

Gruppieren und Aggregieren

groupby teilt die Zeilen nach den Werten einer Spalte in Gruppen und fasst jede Gruppe mit einer Funktion zusammen (aggregate, hier mean). In SQL kennst du das als GROUP BY mit AVG.

Ausgabe beim Ausführen: Z-1 hat Mittelwert 181.15 bei Anzahl 2, Z-2 hat 96.05 bei Anzahl 2. Das dict ist {'Z-1': 181.15, 'Z-2': 96.05}. Beachte die Reihenfolge der Schritte: Erst filtern, dann gruppieren. Die ungültige Zeile (-5.2) fließt dadurch gar nicht in Z-2 ein.

Dasselbe in SQL und in Polars (nicht im Browser ausführbar, nicht ausgeführt):

SELECT zaehler_id, AVG(wert) AS mittelwert, COUNT(*) AS anzahl
FROM messwerte
WHERE status = 'gueltig'
GROUP BY zaehler_id;
df.filter(pl.col("status") == "gueltig").group_by("zaehler_id").agg(
    pl.col("wert").mean().alias("mittelwert"),
    pl.len().alias("anzahl"),
)

Ein Unterschied, den du dir merken solltest (allgemeines Fachwissen, bitte prüfen): Die Reihenfolge der Gruppen im Ergebnis von Polars’ group_by ist nicht garantiert, solange du sie nicht ausdrücklich festlegst. Sortiere vor dem Vergleichen oder Anzeigen.

Übung 2: Mittelwert je Gruppe

Schreibe mittel_je_dienst(df). Die Funktion bekommt einen DataFrame mit den Spalten dienst, ms (Antwortzeit) und status ("ok" oder "fehler"). Sie soll den Mittelwert von ms je dienst liefern, aber nur über Zeilen mit status == "ok". Ein Dienst, der keine ok-Zeile hat, kommt im Ergebnis nicht vor. Dazu kommt ein zweiter Parameter min_anzahl (Standard 1): Ein Dienst erscheint nur, wenn er mindestens min_anzahl ok-Zeilen hat, weil ein Mittelwert aus einer einzigen Messung wenig aussagt. Gezählt werden nur ok-Zeilen. Rückgabe: ein dict {dienst: mittelwert} (eine pandas-Series oder ein DataFrame mit zwei Spalten werden auch akzeptiert).

Testfälle: Für die Beispieldaten unten ist das Ergebnis mit dem Standard {"auth": 100.0, "shop": 200.0} (der Dienst mail hat nur einen Fehler und fehlt). Mit min_anzahl=3 bleibt nur {"shop": 200.0} übrig, denn auth hat nur zwei ok-Zeilen.

Eine Maske für die richtigen Zeilen, dann gruppieren und aggregieren. Überlege, in welcher Reihenfolge, und was ein SQL-WHERE davor bzw. ein HAVING danach bedeuten würde. Zum Zählen brauchst du pro Gruppe mehr als eine Kennzahl.

import pandas as pd

antworten = pd.DataFrame({
    "dienst": ["auth", "auth", "shop", "shop", "shop", "mail", "shop"],
    "ms": [120, 80, 300, 900, 100, 50, 200],
    "status": ["ok", "ok", "ok", "fehler", "ok", "fehler", "ok"],
})

def mittel_je_dienst(df, min_anzahl=1):
    nur_ok = df[df["status"] == "ok"]
    gruppen = nur_ok.groupby("dienst")["ms"].agg(["mean", "count"])
    gruppen = gruppen[gruppen["count"] >= min_anzahl]
    return gruppen["mean"].to_dict()

print(mittel_je_dienst(antworten))
print(mittel_je_dienst(antworten, 3))
mittel_je_dienst

Data Contract: Prüfregeln ableiten

Ein Data Contract beschreibt pro Feld, wie es aussehen soll: Typ, Pflicht, Wertebereich, erlaubte Werte, Eindeutigkeit. Die Prüfung von Hand nachzubauen ist doppelte Arbeit, und sie veraltet. Besser: die Regeln aus dem Contract erzeugen. Der Contract ändert sich, die Regeln ziehen automatisch mit.

Im Lernlabor liegt uebung/data_contract_marktlokation.yaml. Beim Einlesen mit yaml.safe_load entsteht eine Struktur aus dict und list, die genau so aussieht wie diese hier (wir schreiben sie direkt als Python, weil PyYAML nicht zum Browser-Paketsatz dieser Lektion gehört). Die Schlüssel fields, name, type, required, enum, pattern übernehmen wir aus der echten Datei. min, max und unique sind eine Erweiterung für diese Lektion, die echte Datei kennt sie nicht.

Jede Regel wird ein Tripel (feld, regel, wert). Das ist die Zwischenform: Daten, kein Code. Aus Daten kannst du später Prüfungen bauen, anzeigen, vergleichen oder testen.

Ausgabe beim Ausführen: 9 Regeln, zum Beispiel ('zaehler_id', 'typ', 'string'), ('zaehler_id', 'pflicht', True), ('wert', 'min', 0), ('wert', 'max', 10000). Zwei Dinge fallen auf. Erstens "min" in f statt f.get("min"): Ein Minimum von 0 ist falsy, und if f.get("min") würde genau diese Regel verlieren. Zweitens: Das Feld kommentar bekommt nur die Typ-Regel, weil der Contract sonst nichts über es sagt.

Jetzt die Prüfung. Sie bekommt die Rohdaten und den Contract und zählt Verstöße je Regel. Die Rohdaten haben bewusst einen fehlenden Wert, eine negative Zahl und einen unerlaubten Status. Hier steht die Prüfung für Pflicht und min. Die übrigen Regeln (max, erlaubt, eindeutig) baust du in Übung 4 selbst dazu.

Ausgabe beim Ausführen: {('zaehler_id', 'pflicht'): 1, ('wert', 'pflicht'): 1, ('wert', 'min'): 1}, und darunter die eine Zeile mit -5.2. Du siehst, welche Regel verletzt ist und wie oft, und mit roh[roh["wert"] < 0] bekommst du die betroffenen Zeilen selbst zum Ausgeben (Verstöße zählen und ausgeben).

Zwei Details im Code, die später Fehler verursachen. s.isna().sum() zählt fehlende Werte (None und NaN). Und ein Vergleich wie s < 0 liefert für ein fehlendes NaN False, ein fehlender Wert ist also kein min-Verstoß, sondern nur ein Pflicht-Verstoß (wenn das Feld Pflicht ist). Jede Regel zählt ihren eigenen Fall, damit du Verstöße nicht doppelt meldest.

Übung 3: Regeln aus dem Contract ableiten

Schreibe leite_regeln_ab(contract) für den Contract BESTELLUNGEN unten, und zwar so allgemein, dass sie für jeden Contract dieser Form funktioniert. Rückgabe: eine Liste von Tripeln (feld, regel, wert) (die Reihenfolge ist egal). Die Regelnamen und Werte:

  • type im Feld: (feld, "typ", der_typ_text)
  • required ist wahr: (feld, "pflicht", True)
  • unique ist wahr: (feld, "eindeutig", True)
  • min oder max vorhanden (auch 0): (feld, "min", zahl) bzw. (feld, "max", zahl)
  • enum vorhanden: (feld, "erlaubt", tuple_der_werte)

Steht etwas nicht im Contract, oder ist required ausdrücklich False, entsteht dafür keine Regel.

Pro Schlüssel eine eigene Bedingung. Frage dich bei min und max: Was passiert mit dem Wert 0, wenn du nur auf “wahr oder falsch” prüfst? Und was bei "required": False mit einer Prüfung “Schlüssel ist vorhanden”?

BESTELLUNGEN = {
    "contract": "bestellungen",
    "fields": [
        {"name": "bestell_id", "type": "string", "required": True, "unique": True},
        {"name": "menge", "type": "int", "required": True, "min": 1, "max": 100},
        {"name": "kanal", "type": "string", "required": True, "enum": ["web", "app", "telefon"]},
        {"name": "notiz", "type": "string", "required": False},
    ],
}

def leite_regeln_ab(contract):
    regeln = []
    for f in contract["fields"]:
        n = f["name"]
        if "type" in f:
            regeln.append((n, "typ", f["type"]))
        if f.get("required"):
            regeln.append((n, "pflicht", True))
        if f.get("unique"):
            regeln.append((n, "eindeutig", True))
        if "min" in f:
            regeln.append((n, "min", f["min"]))
        if "max" in f:
            regeln.append((n, "max", f["max"]))
        if "enum" in f:
            regeln.append((n, "erlaubt", tuple(f["enum"])))
    return regeln

print(leite_regeln_ab(BESTELLUNGEN))
leite_regeln_ab

Rohdaten gegen die Regeln prüfen

Jetzt verbindest du beides: Die Regeln kommen aus dem Contract, die Prüfung zählt Verstöße. Das Ergebnis ist ein dict {(feld, regel): anzahl}, in dem nur Regeln stehen, die mindestens einmal verletzt wurden. Ein leeres dict heißt: die Daten sind sauber.

Vier Regeln brauchen besondere Aufmerksamkeit:

Regel Zählt Falle
pflicht fehlende Werte (None, NaN) Leerer String zählt nicht als fehlend, außer du legst es fest
min, max Werte außerhalb des Bereichs Der Grenzwert selbst ist erlaubt. Fehlende Werte sind hier kein Verstoß (das meldet pflicht)
erlaubt vorhandene Werte, die nicht in der Liste stehen ~s.isin(liste) zählt NaN mit, denn NaN ist nicht in der Liste
eindeutig überzählige Vorkommen (zweites, drittes, …) fehlende Werte sollen nicht als doppelt zählen, obwohl s.duplicated() zwei NaN für gleich hält. Schließe sie vorher aus

Für eindeutig legen wir in dieser Lektion fest: gezählt wird die Anzahl der überzähligen Zeilen. Kommt ein Wert wie "L2" dreimal vor, sind das 2 Verstöße (das erste Vorkommen ist erlaubt). In den Beispieldaten unten steht "L2" zweimal, das ist 1 Verstoß. s.duplicated() liefert genau das, weil es das erste Vorkommen als False markiert. keep=False würde dagegen alle Vorkommen markieren, also 3.

Übung 4: Prüfung vervollständigen

Ergänze pruefe(df, contract) um max, erlaubt und eindeutig. Pflicht und min stehen schon da. Die Schlüssel des Ergebnisses sind (feld, "pflicht"), (feld, "min"), (feld, "max"), (feld, "erlaubt") und (feld, "eindeutig"). Werte sind ganze Zahlen (Anzahl der Verstöße), nur Regeln mit mindestens einem Verstoß kommen vor.

Für die Beispieldaten unten ist das Ergebnis: {('lieferschein_nr', 'pflicht'): 1, ('lieferschein_nr', 'eindeutig'): 1, ('gewicht_kg', 'min'): 1, ('lager', 'erlaubt'): 1} (Reihenfolge egal).

Was liefern Vergleiche und isin für fehlende Werte, und was soll für sie gelten? Für eindeutig zählst du überzählige Vorkommen, aber nicht die fehlenden Werte. Wie schließt du diese vorher aus?

import pandas as pd

LIEFERUNGEN = {
    "contract": "lieferungen",
    "fields": [
        {"name": "lieferschein_nr", "type": "string", "required": True, "unique": True},
        {"name": "gewicht_kg", "type": "float", "min": 0.1, "max": 500},
        {"name": "lager", "type": "string", "enum": ["HH", "MUC", "B"]},
    ],
}

lieferungen = pd.DataFrame({
    "lieferschein_nr": ["L1", "L2", "L2", None, "L3"],
    "gewicht_kg": [5.0, 0.05, 20.0, 500.0, None],
    "lager": ["HH", "MUC", "XX", None, "B"],
})

def pruefe(df, contract):
    verstoesse = {}
    for f in contract["fields"]:
        n = f["name"]
        s = df[n]
        if f.get("required"):
            k = int(s.isna().sum())
            if k:
                verstoesse[(n, "pflicht")] = k
        if "min" in f:
            k = int((s < f["min"]).sum())
            if k:
                verstoesse[(n, "min")] = k
        if "max" in f:
            k = int((s > f["max"]).sum())
            if k:
                verstoesse[(n, "max")] = k
        if "enum" in f:
            k = int((~s.isin(f["enum"]) & s.notna()).sum())
            if k:
                verstoesse[(n, "erlaubt")] = k
        if f.get("unique"):
            k = int(s.dropna().duplicated().sum())
            if k:
                verstoesse[(n, "eindeutig")] = k
    return verstoesse

print(pruefe(lieferungen, LIEFERUNGEN))
pruefe

Falle: Schweigen ist keine Freigabe

Ein Contract, der ein Feld nicht erwähnt, heißt nicht “keine Regel nötig”. Oft heißt es: noch nicht spezifiziert (Quelle). Dein Code kann den Unterschied nicht erkennen: leite_regeln_ab erzeugt für ein Feld ohne Angaben einfach nichts, und pruefe meldet dann nichts. “Keine Meldung” sieht aus wie “alles in Ordnung”. Genau das ist die Falle.

Gegenmittel: Lass die Pipeline eine dritte Antwort geben. Neben “Regel erfüllt” und “Regel verletzt” gibt es “nicht spezifiziert” (Feld in den Daten, aber ohne Regel im Contract). Das meldest du sichtbar und fragst beim Eigentümer des Contracts nach, statt die Lücke als Erlaubnis zu lesen. Bei den Spalten der Rohdaten sieht das so aus:

Ausgabe beim Ausführen: ['operator']. Die Spalte operator ist in den Daten, aber nicht im Contract. Die Pipeline weiß das nun und kann es melden.

Übung 5: Was tust du mit einer unbekannten Spalte?

Eine Pipeline prüft eingehende Tarifdaten gegen den Contract tarife (er kennt tarif_id und preis mit min: 0). In den Rohdaten steht eine zusätzliche Spalte rabatt_prozent mit Werten von minus 5 bis 140. Der Contract erwähnt sie nicht. Das Team will die Pipeline heute noch freigeben. Was ist die beste Reaktion?

  1. Eine plausible Regel selbst festlegen (zum Beispiel 0 bis 100) und Zeilen, die dagegen verstoßen, aus dem Ergebnis entfernen.

  2. Die Spalte durchlassen, weil der Contract sie nicht einschränkt und die Pipeline nur Regeln prüft, die es gibt.

  3. Die Spalte stillschweigend aus dem Ergebnis löschen, damit nur Felder mit Regeln weiterlaufen.

  4. Die Spalte als nicht spezifiziert melden, beim Contract-Eigentümer nachfragen und sie bis dahin nicht freigeben.

Frage dich, wer die Bedeutung der Spalte festlegen darf, und was die Daten-Konsumenten danach über sie annehmen. Dann: Welche Option macht die Lücke sichtbar, ohne dass du selbst Fakten erfindest?

antwort = "d"
antwort

Merksatz

Polars (und pandas) rechnen mit Ausdrücken auf ganzen Spalten statt mit Zeilenschleifen, Polars zusätzlich lazy und damit optimierbar, und eine Datenprüfung gehört nicht von Hand geschrieben, sondern aus dem Data Contract abgeleitet, wobei Schweigen im Contract “nicht spezifiziert” heißt und nicht “erlaubt”.

Prüfstein

Dein Contract sagt für wert nur min: 0, aber nichts über max. Eine Lieferung enthält wert = 9999999. Was meldet deine Prüfung heute, was sollte sie melden, und welche Frage stellst du dem Contract-Eigentümer?

Lokal: Contract und Messwerte mit echten Dateien

Diese Aufgabe stellt die beiden Übungen der Quelle (Baustein 01 und 02) mit den Beispieldateien im Lernlabor. Sie läuft lokal, nicht im Browser. Es werden keine Pakete installiert und keine API-Schlüssel gebraucht. Fehlen pyyaml, pandas oder polars, läuft das Skript trotzdem und sagt dir, was fehlt (installiere nichts, ohne es bewusst zu entscheiden).

Teil B (diesen zuerst machen): Contract, Regeln, Prüfung. Das Skript leitet aus uebung/data_contract_marktlokation.yaml Regeln ab (Pflicht, erlaubte Werte, Muster) und prüft damit uebung/beispieldaten.json. Die Regel pattern fehlt absichtlich in pruefe_zeile.

cd lernlabor
uv run python uebung/ki/ki_18_contract_pruefen.py

Ohne Änderung zeigt das Skript (ausgeführt mit Python 3 und pyyaml): 2 von 3 ungueltigen Zeilen erkannt, 2 von 2 gueltigen durchgelassen. Die Zeile mit der zu kurzen marktlokations_id rutscht durch. Ergänze die Regel pattern mit re.fullmatch, bis dort 3 von 3 steht. Danach: Schreibe selbst 5 weitere Regeln für Felder, die im Contract stehen (zum Beispiel für gueltig_ab, ein Datum im Format YYYY-MM-DD), und beobachte, welche der drei absichtlich ungültigen Zeilen welche Regel auslöst.

Teil A (danach): Mittelwert in drei Wegen.

uv run python uebung/ki/ki_18_contract_pruefen.py --messwerte

Das Skript rechnet den Mittelwert je zaehler_id (nur status == "gueltig") aus uebung/messwerte_export.csv in reinem Python, mit pandas und mit Polars (je nachdem, was installiert ist). Bei 24 Zeilen misst du vor allem den Start der Bibliothek, nicht die Rechenzeit. Erzeuge deshalb eine CSV mit zwei Millionen Zeilen in einer Temp-Datei und miss neu. Die Beispieldatei selbst änderst du nicht. Notiere, ab welcher Größe Polars für dich schneller und lesbarer wird.

Ein Muster aus dem Contract prüfst du nur bei Feldern, die einen Wert haben. Welche Funktion des Moduls re verlangt, dass das ganze Feld passt?

# in pruefe_zeile, nach der Regel "erlaubt":
elif regel == "pattern" and v is not None and not re.fullmatch(wert, str(v)):
    verstoesse.append(f"{feld}: {v!r} passt nicht auf das Muster")

Danach zeigt das Skript 3 von 3 ungueltigen Zeilen erkannt, 2 von 2 gueltigen durchgelassen.

Selbstcheck


Quelle: quellen/kursbuch-lerninhalte.md, Modul M6, Bausteine “01 Polars statt/neben pandas” und “02 Datenqualitätsprüfungen aus einem Data Contract ableiten” (Zeilen 1007 bis 1068: Kernidee mit scan_csv/collect, Merksatz zur Größe, FeldRegel/pruefe, Stolperfalle Schweigen). Polars ist Rust-basiert, nutzt alle Kerne und wertet lazy aus (Quelle). Über die Quelle hinaus (allgemeines Fachwissen, mit pandas und reinem Python lokal sowie in Pyodide ausgeführt): MiniLazy als Vereinfachung des Lazy-Prinzips, Verhalten von NaN in Vergleichen, isin und duplicated, die Zählweise “überzählige Vorkommen”, die Erweiterung des Contracts um min, max, unique. Pandas lief in Pyodide 0.28.1 (pandas 2.3.1), lokal mit pandas 2.3.3. Nicht ausgeführt sind alle Polars-Blöcke (Polars ist weder im Browser-Testaufbau noch lokal installiert), die SQL-Blöcke, und die Aussage zur Reihenfolge der Gruppen in Polars (bitte prüfen). Konkrete Polars-Versionen nennt die Quelle nicht (bitte prüfen). Die Beispieldateien im Lernlabor sind erfunden (lernlabor/uebung/README.md).