← Notes

Das Modell war nie das Problem: was ich beim Aufräumen meiner Credit Risk API gelernt habe

August 19, 2026

Ich hatte ein Modell trainiert, in eine FastAPI verpackt, dockerisiert und deployed. Es lief. Und trotzdem stimmte etwas nicht: Fast jedes Profil, das ich in die Demo eingab, kam als hohes Ausfallrisiko zurück. Wer als Kredithistorie „alles pünktlich bezahlt" wählte, bekam ein schlechteres Ergebnis als jemand mit dokumentiertem Zahlungsverzug.

Mein erster Gedanke war: Das Modell taugt nichts.

Er war falsch. Der Weg zu dieser Erkenntnis war der lehrreichste Teil des ganzen Projekts.

Live: API-Dokumentation · GitHub

Der Ausgangspunkt

Datenbasis ist das UCI German Credit Dataset: 1.000 historische Kreditanträge, 20 Merkmale, 700 gute gegen 300 ausgefallene Kredite. Klein genug für schnelle Durchläufe, groß genug für die typischen Probleme echter Daten.

Das Modell ist ein XGBoost-Klassifikator. Für tabellarische Daten dieser Größe ist Gradient Boosting nach wie vor der Standard, ein neuronales Netz hätte hier nichts gewonnen.

Wegen der Schieflage von 70 zu 30 wurde beim Training scale_pos_weight gesetzt: Ein übersehener Ausfall zählt in der Fehlerrechnung 2,33-fach. Ohne diese Gewichtung hätte das Modell gelernt, fast immer „gut" zu sagen. Damit läge es in 70 Prozent der Fälle richtig, ohne irgendetwas verstanden zu haben.

Diese Gewichtung ist richtig, und sie ist die Wurzel des Problems. Sie verschiebt alle Ausgaben systematisch nach oben. Was das Modell ausgibt, ist danach keine Ausfallwahrscheinlichkeit mehr, sondern ein Score auf einer bewusst vorsichtigen Skala.

Fund 1: Eine Entscheidung, die niemand getroffen hat

In der ersten Fassung stand in model.py diese Zeile:

risk_class = int(model.predict(features)[0])

model.predict() ist bei XGBoost nichts anderes als: nimm die Wahrscheinlichkeit und schneide bei 0.5 ab. Diese 0.5 ist der Standardwert der Bibliothek. Sie unterstellt zweierlei. Erstens, dass der Score eine echte Wahrscheinlichkeit ist. Zweitens, dass beide Fehlerarten gleich teuer sind. Bei mir stimmte keines von beidem.

Die Dokumentation des Datensatzes liefert die Kosten sogar mit: Einen schlechten Kredit durchzuwinken kostet 5, einen guten abzulehnen kostet 1.

Damit lässt sich die Schwelle ausrechnen, statt sie zu erben. Für jeden möglichen Schnitt die Gesamtkosten bilden und das Minimum nehmen:

Kosten(t) = 5 × übersehene Ausfälle + 1 × unnötige Ablehnungen

Gemessen habe ich das nicht auf einem einzelnen Testsatz, sondern auf Out-of-fold-Vorhersagen für alle 1.000 Zeilen. Jede Zeile wurde also von einem Modell bewertet, das sie beim Training nie gesehen hatte.

Schwelle 0.50     Kosten 679
Schwelle 0.20     Kosten 539

21 Prozent günstiger. Konkret: Von 300 Ausfällen rutschen bei 0.5 ganze 106 durch, bei 0.20 nur 35. Der Preis sind mehr Fehlalarme. Weil ein Ausfall fünfmal so teuer ist, lohnt der Tausch trotzdem.

Und dann wurde es interessant

Bei der kostenoptimalen Schwelle würden 63 Prozent aller Anträge abgelehnt. Eine Bank, die zwei Drittel ihrer Antragsteller abweist, hat kein Geschäft mehr.

Die Rechnung ist mathematisch korrekt und betriebswirtschaftlich unbrauchbar, weil die Kostenmatrix nur Fehler zählt. Dass die Bank an einem guten Kredit verdient, kommt darin nicht vor.

Statt die Zahl blind zu übernehmen, habe ich sie als untere von zwei Grenzen verwendet und daraus drei Zonen gemacht:

| Zone | Anteil der Anträge | tatsächliche Ausfallrate | Konsequenz | |---|---|---|---| | unter 0.20 | 37 % | 9 % | automatisch annehmen | | 0.20 bis 0.50 | 29 % | 25 % | manuelle Prüfung | | über 0.50 | 34 % | 57 % | ablehnen |

So arbeiten Kreditinstitute tatsächlich. Der Mittelbereich geht an einen Menschen. Die untere Grenze ist gerechnet, die obere ist eine Entscheidung, und der Unterschied gehört dokumentiert.

Die Gegenprobe: Schwelle auf einer Hälfte der Daten bestimmt, Kosten auf der anderen gemessen, dreißigmal wiederholt. Kosten pro Fall 0,557 gegenüber 0,679 bei 0.5. Der Vorteil überträgt sich also auf ungesehene Daten, er ist nicht auswendig gelernt.

Fund 2: Ein einzelner Split lügt

Ursprünglich stand in diesem Artikel „AUC-ROC von 0,813". Diese Zahl stammt von genau einer zufälligen Aufteilung in 800 Trainings- und 200 Testzeilen.

Aufgefallen ist mir das bei einer ganz anderen Frage. Die kategorischen Merkmale werden alphabetisch durchnummeriert, wodurch bei purpose die harmloseste Kategorie zwischen den beiden riskantesten landet. Meine Vermutung: Das schadet, One-Hot-Encoding müsste besser sein.

Drei Varianten gemessen, erst auf dem einzelnen Split:

ordinal       AUC 0.813
kategorial    AUC 0.816    besser

Dann dasselbe mit fünffacher Kreuzvalidierung:

ordinal       0.792 ± 0.022
kategorial    0.783 ± 0.019    schlechter
onehot        0.777 ± 0.020

Die Reihenfolge dreht sich um. Und alle drei Abstände sind kleiner als die Streuung zwischen den Durchläufen, es gibt also schlicht keinen messbaren Unterschied.

Wäre ich dem ersten Vergleich gefolgt, hätte ich den Eingabe-Vertrag der API umgestellt, das Demo-Widget umgebaut und deployed. Für einen Unterschied, den es nicht gibt.

Geblieben ist die ehrliche Kennzahl: 0,79 ± 0,02, kreuzvalidiert. Weniger schön als 0,813, aber es ist die Zahl, die hält.

Fund 3: Die Beschriftung log

Bleibt die Sache mit der Kredithistorie. Im Widget stand bei Wert 0 „Alles pünktlich bezahlt". Wer das wählte, erwartete ein niedriges Risiko und bekam ein hohes.

Ein Blick in die Rohdaten:

| Kategorie | Ausfallrate | |---|---| | A30, keine Kredite aufgenommen oder alle zurückgezahlt | 62,5 % | | A31, alle bei dieser Bank zurückgezahlt | 57,1 % | | A32, laufende Kredite pünktlich | 31,9 % | | A33, Zahlungsverzug in der Vergangenheit | 31,8 % | | A34, laufende Kredite bei anderen Banken | 17,1 % |

Das Modell hatte recht. Die ersten beiden Kategorien beschreiben Menschen ohne laufenden Kredit. In diesen Daten ist ein laufender, bedienter Kredit ein gutes Zeichen: Irgendjemand hat die Person bereits geprüft und für tragfähig befunden. Wer gar keine Verbindlichkeit hat, ist für eine Bank ein unbeschriebenes Blatt.

Meine Beschriftung hatte den Wert bewertet, statt ihn zu beschreiben. Aus „Alles pünktlich bezahlt" wurde „Noch keine Kredite aufgenommen", und die falsche Erwartung war weg.

Dazu kamen zwei handfeste Fehler im Widget. Bei purpose sendete „Umschulung" eine 9 statt einer 8, und bei housing waren „Eigentum" und „kostenlos wohnen" schlicht vertauscht. Beides entstand beim Abtippen einer Encoding-Tabelle aus einem Textdokument. Inzwischen liefert die API ihren Vertrag selbst aus, unter /schema, und im Widget wird nichts mehr abgeschrieben.

Was daraus geworden ist

Das Training kommt nicht mehr aus einem Notebook, sondern aus einem Skript, das reproduzierbar durchläuft und neben dem Modell eine feature_mapping.json erzeugt: Feature-Reihenfolge, Encoding-Tabellen, Wertebereiche, Schwellen. Eine Quelle für Modell, API und Widget.

Die API baut ihre Eingabe als benannten DataFrame statt als Zahlenreihe. Vorher hing die Zuordnung still an der Feldreihenfolge, und zwei vertauschte Felder ergaben eine plausibel aussehende falsche Antwort ohne jede Fehlermeldung. Jetzt vergleicht XGBoost die Spaltennamen und bricht ab.

23 Tests sichern das ab: Zonen-Grenzen mit den exakten Schwellwerten, das dokumentierte Encoding, drei feste Profile mit erwarteter Zone, ein Test, der eine Anfrage mit vertauschter Feldreihenfolge schickt und dasselbe Ergebnis verlangt, und ein Vertragstest, der die Wertebereiche in schemas.py gegen die Mapping-Datei prüft.

Jeder Trainingslauf wird in MLflow protokolliert, mit Parametern, Metriken und Modell.

Deployment

Docker-Container auf Google Cloud Run in Frankfurt. Der Dienst skaliert auf null, wenn niemand da ist, und wird bei der ersten Anfrage wieder hochgefahren. Das macht sich als Wartezeit von einigen Sekunden bemerkbar, weshalb das Widget darauf hinweist.

Der /predict-Endpunkt verlangt einen Schlüssel, den nur die Proxy-Route dieser Seite kennt. Der Browser sieht weder die Adresse der API noch den Schlüssel, und die Route bremst zu viele Anfragen aus derselben Quelle aus.

Was offen bleibt

Ein paar Dinge weiß ich nicht, und die gehören dazu.

Die auffälligsten Kategorien sind am schwächsten belegt. A30 und A31 mit ihren 62,5 und 57,1 Prozent Ausfallrate umfassen nur 40 beziehungsweise 49 Fälle. Bei 40 Personen und 25 Ausfällen liegt die Unsicherheit im Bereich von mehr als zehn Prozentpunkten. Das Modell nimmt diese Gruppen ernster, als die Datenlage es hergibt.

Der Score ist bewusst nicht kalibriert. Er taugt zum Sortieren und für die Zoneneinteilung, aber er ist keine Ausfallwahrscheinlichkeit. Wollte man eine, müsste man das Modell nachkalibrieren und die Schwellen neu bestimmen.

Die Schwellen hängen am Modell. Jedes Neutraining verändert die Score-Verteilung, also müssten 0.20 und 0.50 neu hergeleitet werden. Automatisch passiert das bisher nicht.

Und der Datensatz ist von 1994. Das ist eine technische Übung, kein einsatzfähiges Kreditmodell. Was hier belastbar ist, ist der Weg dorthin, nicht die Zahl am Ende.

Fazit

Der ML-Teil war der kleinste. Das Modell mit AUC 0,79 war von Anfang an in Ordnung. Es hat die Daten korrekt gelernt, einschließlich der kontraintuitiven Zusammenhänge darin.

Kaputt war alles drumherum. Eine Schwelle, die ein Bibliotheks-Standardwert war statt einer Entscheidung. Eine Kennzahl aus einer einzigen zufälligen Aufteilung. Beschriftungen, die die Daten bewerteten, statt sie zu beschreiben. Zahlen, die an drei Stellen von Hand gepflegt wurden.

Nichts davon hätte ein besseres Modell behoben.

Ausprobieren: Live-Demo auf der Startseite · API-Dokumentation · GitHub