Auswertung: Die Genauigkeit lügt
Kurz gesagt: Ein Modell wird mit Daten ausgewertet, die es nicht gesehen hat: Die Konfusionsmatrix zählt die vier Ausgänge, und Präzision und Trefferquote sagen mehr als die Genauigkeit.
Problem
99 % Genauigkeit, null Betrugsfälle erkannt
Ein Kollege zeigt dir sein Betrugsmodell: „99 % Genauigkeit!“ Klingt großartig. Hier ist ein Modell, das dasselbe erreicht, ohne etwas zu tun:
int frauds = 0;for (int y : fraud) frauds += y;// „immer normal“: blockt nie, deshalb liegt es bei jeder ehrlichen Zahlung richtigdouble accuracy = 100.0 * (4000 - frauds) / 4000;System.out.printf("Zahlungen: 4000, Betrugsfälle: %d%n", frauds);System.out.printf("„immer normal“: Genauigkeit %.2f%%, erkannte Betrugsfälle: 0 von %d%n", accuracy, frauds);Zahlungen: 4000, Betrugsfälle: 40„immer normal“: Genauigkeit 99.00%, erkannte Betrugsfälle: 0 von 40Das ist ein Monat mit 4.000 Zahlungen, und 40 davon sind Betrug: einer von hundert. Das Modell „immer normal“ stoppt nichts. Bei 3.960 ehrlichen Zahlungen liegt es richtig, bei 40 Betrugsfällen falsch. Genauigkeit 99,00 %, erkannte Betrugsfälle: null.
Die Genauigkeit (accuracy) ist der Anteil der richtigen Antworten. Wenn eine Klasse 99 % ausmacht, ist das Modell, das immer diese Klasse sagt, zu 99 % genau. Die Zahl stimmt, aber sie beantwortet nicht die Frage des Shops: „Wie viele Betrugsfälle erkennen wir, und wie viele ehrliche Kunden ärgern wir?“
Sieh es dir an
Der Monat im Code
Echte Zahlungen kommen aus der Datenbank des Shops. Hier wird der Monat nach einer Regel erzeugt, damit er klein und in jeder Sprache gleich ist. Jede Zahlung ist mit einer Wahrscheinlichkeit von 1 % Betrug. Betrugsfälle sind meist das 3- bis 12-Fache des üblichen Betrags, manchmal ein kleiner Test (0,3× bis 1,3×). Ehrliche Zahlungen liegen meist beim 0,2- bis 2,2-Fachen, manchmal ist es ein großer Einkauf bis zum 6-Fachen.
Die Zufallszahlen kommen aus einem kleinen Generator mit Seed (ein LCG): Derselbe Seed liefert in jeder Sprache und bei jedem Lauf dieselben Zahlen.
// ein Generator mit Seed: in jeder Sprache dieselben Zahlenclass Lcg { private long state;
Lcg(long seed) { state = seed; }
double next() { state = (state * 1664525 + 1013904223) % 4294967296L; return state / 4294967296.0; }}// ein Monat Zahlungen: etwa 1 von 100 ist BetrugLcg rng = new Lcg(319);double[] ratios = new double[4000];int[] fraud = new int[4000];for (int i = 0; i < 4000; i++) { boolean isFraud = rng.next() < 0.01; if (isFraud) // Betrug: meist 3× bis 12×, manchmal ein kleiner Test ratios[i] = rng.next() < 0.85 ? 3 + 9 * rng.next() : 0.3 + rng.next(); else // ehrlich: meist 0.2× bis 2.2×, manchmal ein großer Einkauf ratios[i] = rng.next() < 0.03 ? 2.5 + 3.5 * rng.next() : 0.2 + rng.next() + rng.next(); fraud[i] = isFraud ? 1 : 0;}4294967296L ist 2³². Die Multiplikation läuft in long, damit das Ergebnis dasselbe ist wie in den anderen Sprachen.Das Merkmal ist dasselbe wie in der Lektion zur Klassifikation: der Betrag im Vergleich zum üblichen Betrag des Kunden.
Die Idee
Training und Test
Bevor etwas gemessen wird, gibt es eine wichtigere Frage: Mit welchen Daten messen wir? Hier ist das einfachste „Modell“: Es merkt sich die Beträge der Betrugsfälle, die es gesehen hat, und stoppt nur diese. Der Monat wird gemischt und geteilt: 70 % zum Trainieren, 30 % zum Prüfen.
// Mischen (Fisher–Yates) mit demselben Generatorint[] order = new int[4000];for (int i = 0; i < 4000; i++) order[i] = i;for (int i = 3999; i > 0; i--) { int j = (int) (rng.next() * (i + 1)); int t = order[i]; order[i] = order[j]; order[j] = t;}int[] train = Arrays.copyOfRange(order, 0, 2800);int[] test = Arrays.copyOfRange(order, 2800, 4000);System.out.printf("Training: %d Zahlungen, %d Betrugsfälle%n", train.length, countFrauds(fraud, train));System.out.printf("Test: %d Zahlungen, %d Betrugsfälle%n", test.length, countFrauds(fraud, test));// merkt sich die Beträge der Betrugsfälle, die es gesehen hatSet<Double> seen = new HashSet<>();for (int i : train) if (fraud[i] == 1) seen.add(ratios[i]);report("Auswendiglernen auf den Trainingsdaten", seen, ratios, fraud, train);report("Auswendiglernen auf den Testdaten", seen, ratios, fraud, test);Training: 2800 Zahlungen, 24 BetrugsfälleTest: 1200 Zahlungen, 16 BetrugsfälleAuswendiglernen auf den Trainingsdaten: Genauigkeit 100.00%, erkannt 24 von 24Auswendiglernen auf den Testdaten: Genauigkeit 98.67%, erkannt 0 von 16Arrays.copyOfRange(order, 0, 2800) erzeugt ein neues Array mit den Zeilen 0 bis 2799. HashSet und Set kommen aus java.util.Bei den Zahlungen, aus denen es gelernt hat, ist das Modell perfekt: 100 %, 24 von 24. Bei neuen erkennt es keinen einzigen Betrug, weil ein neuer Betrug nie genau derselbe Betrag ist wie ein alter. Das Modell hat nichts über Betrug gelernt, es hat sich die Fälle nur gemerkt.
Darum werden die Daten vor dem Training in zwei Teile geteilt:
- Trainingsdaten (training set): Das Modell lernt aus ihnen;
- Testdaten (test set): Sie werden am Ende zum Messen benutzt, und das Modell sieht sie bis dahin nicht.
Der Test steht für die Zahlungen von morgen. Das Mischen vor dem Teilen ist wichtig: Wenn der Monat nach Datum sortiert ist, besteht der Test nur aus der letzten Woche.
Sieh es dir an
Die Konfusionsmatrix
Jetzt das echte Modell: die logistische Regression aus der Lektion zur Klassifikation. Die Funktion ist dieselbe, nur bekommt sie zusätzlich rows: welche Zeilen sie benutzen soll. Sie lernt nur aus den Trainingsdaten:
// lernt nur aus den Zeilen in rowsstatic double[] trainLogistic(double[] xs, int[] ys, int[] rows, double rate, int epochs) { double w = 0, b = 0; int n = rows.length; for (int epoch = 0; epoch < epochs; epoch++) { double gw = 0, gb = 0; for (int i : rows) { double p = sigmoid(w * xs[i] + b); gw += (p - ys[i]) * xs[i]; gb += p - ys[i]; } w -= rate * gw / n; b -= rate * gb / n; } return new double[] {w, b};}Eine Testzahlung kann auf vier Arten enden. Das Modell stoppt oder lässt durch, und die Zahlung ist Betrug oder ehrlich:
| Modell stoppt | Modell lässt durch | |
|---|---|---|
| Betrug | gestoppter Betrug (TP) | übersehener Betrug (FN) |
| ehrlich | gestoppter Ehrlicher (FP) | durchgelassener Ehrlicher (TN) |
Das ist die Konfusionsmatrix (confusion matrix). Die Buchstaben stehen für wahr/falsch positiv/negativ. „Positiv“ ist die Klasse, die wir suchen: Betrug.
static int[] confusion(double[] ratios, int[] fraud, int[] rows, double w, double b, double threshold) { int tp = 0, fp = 0, fn = 0, tn = 0; for (int i : rows) { boolean stop = block(ratios[i], w, b, threshold); if (stop && fraud[i] == 1) tp++; // gestoppter Betrug else if (stop) fp++; // gestoppter ehrlicher Kunde else if (fraud[i] == 1) fn++; // übersehener Betrug else tn++; // durchgelassener ehrlicher Kunde } return new int[] {tp, fp, fn, tn};}double[] m = trainLogistic(ratios, fraud, train, 1, 1000);double w = m[0], b = m[1];int[] c = confusion(ratios, fraud, test, w, b, 0.5);int tp = c[0], fp = c[1], fn = c[2], tn = c[3];double accuracy = 100.0 * (tp + tn) / test.length;double precision = 100.0 * tp / (tp + fp);double recall = 100.0 * tp / (tp + fn);System.out.printf("w = %.4f, b = %.4f%n", w, b);System.out.println("gestoppter Betrug (TP): " + tp);System.out.println("gestoppte Ehrliche (FP): " + fp);System.out.println("übersehener Betrug (FN): " + fn);System.out.println("durchgelassene Ehrliche (TN): " + tn);System.out.printf("Genauigkeit %.2f%%, precision %.2f%%, recall %.2f%%%n", accuracy, precision, recall);w = 1.0702, b = -7.1054gestoppter Betrug (TP): 7gestoppte Ehrliche (FP): 0übersehener Betrug (FN): 9durchgelassene Ehrliche (TN): 1184Genauigkeit 99.25%, precision 100.00%, recall 43.75%Die Genauigkeit beträgt 99,25 %, ein wenig mehr als die 98,67 % von „immer normal“ im selben Test. Trotzdem erkennt das Modell 7 von 16 Betrugsfällen und stoppt keinen einzigen ehrlichen Kunden. Die beiden Genauigkeiten sind fast gleich, und die Modelle sind völlig verschieden. Wir brauchen Zahlen, die auf die Betrugsfälle schauen.
Sieh es dir an
Die Matrix füllt sich
static int[] confusion(double[] ratios, int[] fraud, int[] rows, double w, double b, double threshold) { int tp = 0, fp = 0, fn = 0, tn = 0; for (int i : rows) { boolean stop = block(ratios[i], w, b, threshold); if (stop && fraud[i] == 1) tp++; // gestoppter Betrug else if (stop) fp++; // gestoppter ehrlicher Kunde else if (fraud[i] == 1) fn++; // übersehener Betrug else tn++; // durchgelassener ehrlicher Kunde } return new int[] {tp, fp, fn, tn};}int[] c = confusion(ratios, fraud, test, w, b, 0.5);int tp = c[0], fp = c[1], fn = c[2], tn = c[3];double accuracy = 100.0 * (tp + tn) / test.length;double precision = 100.0 * tp / (tp + fp);double recall = 100.0 * tp / (tp + fn);Schritt für Schritt, in Worten
- 1200 Testzahlungen, die das Modell nie gesehen hat, Schwellenwert 0,5. Die Zeile ist die Wahrheit, die Spalte ist, was das Modell gesagt hat.
- Zuerst die 16 Betrugsfälle, einer nach dem anderen: 7 gestoppt (TP), 9 übersehen (FN).
- Dann die Ehrlichen: 0 gestoppt (FP), 1184 durchgelassen (TN). Fast alles landet in einer Ecke.
- Die Genauigkeit ist die Diagonale: (7 + 1184) / 1200 = 99,25 %. „Immer normal“ erreicht im selben Test 98,67 %.
- Die Präzision ist die Spalte „stoppen“: Wie viele der gestoppten Zahlungen waren Betrug? 7 / 7 = 100,00 %.
- Die Trefferquote ist die Zeile „Betrug“: Wie viele der Betrugsfälle haben wir erkannt? 7 / 16 = 43,75 %.
- Die Genauigkeit betrachtet alles und ist hoch. Präzision und Trefferquote betrachten die Betrugsfälle: Jeder Stopp ist ein Betrug, aber wir erkennen weniger als die Hälfte.
1.184 der 1.200 Zahlungen liegen in der Ecke unten rechts: durchgelassene Ehrliche. Darum ist die Genauigkeit bei jedem Modell hoch, das ehrliche Zahlungen durchlässt. Das Interessante steht in der oberen Zeile.
Die Idee
Präzision und Trefferquote in den Worten des Shops
Zwei Fragen, zwei Zahlen:
- Präzision (precision): Wie viele der gestoppten Zahlungen sind wirklich Betrug? TP / (TP + FP). Eine niedrige Präzision bedeutet viele verärgerte ehrliche Kunden.
- Trefferquote (recall): Wie viele aller Betrugsfälle haben wir erkannt? TP / (TP + FN). Eine niedrige Trefferquote bedeutet übersehene Betrugsfälle und verlorenes Geld.
Beim Schwellenwert 0,5 hat das Modell eine Präzision von 100 % und eine Trefferquote von 43,75 %. Jeder Stopp ist ein Betrug, aber es erkennt weniger als die Hälfte. Bei „immer normal“ ist die Trefferquote 0 %, und du siehst das sofort, ohne dich mit 99 % zu täuschen.
Die beiden ziehen in entgegengesetzte Richtungen. Um mehr Betrugsfälle zu erkennen (Trefferquote), stoppst du mehr verdächtige Zahlungen, und einige davon sind ehrlich (die Präzision sinkt).
Probier es aus
Lücke füllen
Die Präzision teilt durch alle gestoppten Zahlungen. Wodurch teilt die Trefferquote?
double precision = 100.0 * tp / (tp + fp);
double recall = 100.0 * tp / (tp + );Die Trefferquote ist TP / (TP + FN): die erkannten Betrugsfälle geteilt durch alle Betrugsfälle, erkannte und übersehene. Die Präzision schaut auf die Spalte „stoppen“ der Matrix, und die Trefferquote schaut auf die Zeile „Betrug“.
Probier es aus
Sage die Ausgabe voraus
Zehn Zahlungen: die Wahrheit (1 = Betrug) und was das Modell gestoppt hat (1 = gestoppt). Was wird ausgegeben?
int[] actual = {1, 0, 0, 1, 0, 0, 0, 1, 0, 0};int[] blocked = {1, 1, 0, 0, 0, 1, 0, 1, 0, 0};int tp = 0, fp = 0, fn = 0;for (int i = 0; i < actual.length; i++) { if (blocked[i] == 1 && actual[i] == 1) tp++; else if (blocked[i] == 1) fp++; else if (actual[i] == 1) fn++;}System.out.printf("precision %.2f%%, recall %.2f%%%n", 100.0 * tp / (tp + fp), 100.0 * tp / (tp + fn));4 Zahlungen werden gestoppt, 2 davon sind Betrug (TP = 2, FP = 2): Die Präzision ist 2 / 4 = 50 %. Es gibt 3 Betrugsfälle, und 2 sind erkannt (FN = 1): Die Trefferquote ist 2 / 3 ≈ 66,67 %. Die Genauigkeit (7 von 10) ist eine dritte Zahl, die nichts über die Betrugsfälle sagt.
Die Idee
Der Schwellenwert und die Kosten in Euro
Der Schwellenwert aus der Lektion zur Klassifikation bekommt jetzt Zahlen. Dasselbe Modell auf demselben Test, bei vier Schwellenwerten:
for (double threshold : new double[] {0.5, 0.3, 0.1, 0.02}) { int[] c = confusion(ratios, fraud, test, w, b, threshold); int tp = c[0], fp = c[1], fn = c[2]; System.out.printf("Schwellenwert %.2f: TP %d, FP %d, FN %d → precision %.2f%%, recall %.2f%%%n", threshold, tp, fp, fn, 100.0 * tp / (tp + fp), 100.0 * tp / (tp + fn));}Schwellenwert 0.50: TP 7, FP 0, FN 9 → precision 100.00%, recall 43.75%Schwellenwert 0.30: TP 10, FP 1, FN 6 → precision 90.91%, recall 62.50%Schwellenwert 0.10: TP 11, FP 13, FN 5 → precision 45.83%, recall 68.75%Schwellenwert 0.02: TP 14, FP 28, FN 2 → precision 33.33%, recall 87.50%Je niedriger der Schwellenwert, desto mehr Betrugsfälle erkennen wir (die Trefferquote steigt von 43,75 % auf 87,5 %) und desto mehr ehrliche Kunden stoppen wir (die Präzision fällt von 100 % auf 33,33 %). Was ist besser? Die Antwort steht in Euro. Sagen wir, ein übersehener Betrug kostet 250 € (die Ware und die Rückbuchungsgebühr), und ein gestoppter ehrlicher Kunde kostet 10 € (ein Anruf beim Support und verlorene Zeit). Die Preise sind ein Beispiel:
// Beispielpreise: ein übersehener Betrug und ein gestoppter ehrlicher Kundeint missedCost = 250, blockedCost = 10;double best = -1;int bestCost = Integer.MAX_VALUE;for (double threshold : new double[] {0.5, 0.3, 0.1, 0.02}) { int[] c = confusion(ratios, fraud, test, w, b, threshold); int cost = c[2] * missedCost + c[1] * blockedCost; System.out.printf("Schwellenwert %.2f: %d × %d € + %d × %d € = %d €%n", threshold, c[2], missedCost, c[1], blockedCost, cost); if (cost < bestCost) { bestCost = cost; best = threshold; }}System.out.printf("günstigster Schwellenwert: %.2f%n", best);Schwellenwert 0.50: 9 × 250 € + 0 × 10 € = 2250 €Schwellenwert 0.30: 6 × 250 € + 1 × 10 € = 1510 €Schwellenwert 0.10: 5 × 250 € + 13 × 10 € = 1380 €Schwellenwert 0.02: 2 × 250 € + 28 × 10 € = 780 €günstigster Schwellenwert: 0.02Mit diesen Preisen ist der strengste Schwellenwert der billigste: 0,02, mit 780 € statt 2.250 € bei 0,5. Ein übersehener Betrug kostet so viel wie 25 gestoppte Kunden, also lohnt es sich, öfter zu stoppen. Wenn ein gestoppter Kunde für immer geht und viel mehr kostet, sieht die Antwort anders aus. Das ist die Übung am Ende.
Eine Warnung. Hier wird der Schwellenwert mit dem Test gewählt. Der Test hat also schon an einer Entscheidung teilgenommen, und die 780 € sind ein wenig optimistischer als die Wahrheit. In einem echten Projekt wird der Schwellenwert mit einem dritten Teil der Daten gewählt, den Validierungsdaten (validation set), und der Test läuft nur einmal, ganz am Ende. Wie, steht in der nächsten Lektion.
Probier es aus
Finde den Fehler
Ein Kollege teilt die Daten so. Seine Testzahlen sind hervorragend, und im Shop erkennt das Modell viel weniger. In welcher Zeile steckt der Fehler?
Wähle die Zeile mit dem Fehler.
Der Test nimmt die ersten 1200 Zeilen, und die stecken auch in den Trainingsdaten (den ersten 2800). Das Modell wird mit Zahlungen geprüft, die es gesehen hat, deshalb sind die Zahlen zu gut. Der Test muss der Rest sein: von 2800 bis 4000. Das ist ein Datenleck (data leakage): Der Test weiß etwas, das die Zahlungen von morgen nicht wissen werden.
Die Idee
Wann es wehtut
- Genauigkeit bei ungleichen Klassen. Betrug, Krankheit, Ausfälle, Spam: Wenn das, was du suchst, selten ist, ist die Genauigkeit fast nutzlos. Schau auf die Matrix, die Präzision und die Trefferquote.
- Ein Test, der den Trainingsdaten ähnlicher sieht als der Zukunft. Betrüger ändern ihre Tricks. Wenn der Test mit vergangenen Monaten gemischt ist, ist er leichter als der nächste Monat. Bei solchen Daten ist der Test der neuere Zeitraum: Du lernst bis zum 30. September und misst den Oktober.
- Wenige Beispiele der seltenen Klasse. Der Test hat 16 Betrugsfälle. Ein Betrug mehr oder weniger verschiebt die Trefferquote um 6,25 %. Die Zahlen hier sind für die Lektion, und echte Teams sammeln Tausende Betrugsfälle, bevor sie der zweiten Stelle trauen.
- Eine Zahl für alles. Präzision und Trefferquote sind zwei, weil es zwei Fehler gibt und sie unterschiedlich viel kosten. Wenn du sie zu einer Zahl zusammenpresst, verlierst du die Entscheidung über die Kosten.
- Ein oft angefasster Test. Jedes Mal, wenn du das Modell änderst, weil der Test „schlecht“ gesagt hat, wird der Test ein bisschen zum Training.
Genauer, für Neugierige
F1. Das harmonische Mittel aus Präzision und Trefferquote: 2 · P · R / (P + R). Eine Zahl, die fällt, wenn eine der beiden niedrig ist. Sie ist nützlich zum Vergleichen, kennt aber die Kosten der Fehler nicht.
Die Kurve. Wenn du den Schwellenwert von 1 auf 0 bewegst und die Präzision gegen die Trefferquote zeichnest, erhältst du die PR-Kurve. Die Fläche darunter (PR AUC) misst das Modell ohne einen bestimmten Schwellenwert. Die verwandte ROC-Kurve (Anteil der erkannten Betrugsfälle gegen Anteil der gestoppten Ehrlichen) ist bekannter, sieht aber bei 1 zu 100 besser aus, als sie ist, weil es so viele ehrliche Zahlungen gibt.
Der Schwellenwert aus den Kosten. Wenn die Wahrscheinlichkeiten des Modells stimmen, ist der billigste Schwellenwert die Kosten eines gestoppten Kunden geteilt durch die Summe der beiden Kosten: 10 / (10 + 250) ≈ 0,04. Das Programm hat unter den vier Schwellenwerten, die es probiert hat, 0,02 gefunden. Das Modell hier ist mit 1 % Betrug gelernt, seine Wahrscheinlichkeiten liegen also nahe an den echten. Ein Modell, das mit 40 % Betrug gelernt wurde (wie in der Lektion zur Klassifikation), sieht Betrug zu oft, und sein Schwellenwert muss höher sein.
Teilen nach Klassen. Bei 40 Betrugsfällen kann ein zufälliges Teilen 30 ins Training und 10 in den Test schicken oder umgekehrt. Ein stratifiziertes Teilen behält in beiden Teilen denselben Anteil an Betrugsfällen.
Im echten Code. In scikit-learn sind das train_test_split (mit stratify= für die Klassen), confusion_matrix, precision_score, recall_score und precision_recall_curve. In ML.NET liefern TrainTestSplit und BinaryClassificationMetrics dasselbe. Das Beobachten des Modells in der Produktion (ob die Trefferquote Monat für Monat sinkt) gehört zu MLOps und wird in ZeroToOps unterrichtet.
printf mit %% schreibt das Zeichen % selbst. Das Format hängt von der Locale ab: Mit einer deutschen siehst du ein Komma.Übung
Der teure ehrliche Kunde
Löse das in deinem Editor.
Der Shop verkauft Abonnements. Ein irrtümlich gestoppter Kunde wechselt oft zur Konkurrenz, deshalb kostet ein gestoppter ehrlicher Kunde 60 €, und ein übersehener Betrug kostet weiterhin 250 €. Lass das Kostenprogramm mit 60 € laufen und finde den billigsten Schwellenwert.
Schwellenwert 0.50: 9 × 250 € + 0 × 60 € = 2250 €Schwellenwert 0.30: 6 × 250 € + 1 × 60 € = 1560 €Schwellenwert 0.10: 5 × 250 € + 13 × 60 € = 2030 €Schwellenwert 0.02: 2 × 250 € + 28 × 60 € = 2180 €günstigster Schwellenwert: 0.30Jetzt ist 0,3 der billigste: Er stoppt 10 von 16 Betrugsfällen und nur 1 ehrlichen Kunden. Das Modell ist dasselbe, die Daten sind dieselben, und der Schwellenwert ist ein anderer, weil die Fehler unterschiedlich viel kosten.
Weitere Aufgaben
- Ein feineres Raster von Schwellenwerten. Probiere die Schwellenwerte von 0,02 bis 0,5 in Schritten von 0,02. Gibt es einen billigeren als 0,3?
- Test nach Zeit. Nimm statt zu mischen die ersten 2800 Zahlungen zum Trainieren und die letzten 1200 für den Test. Wie viele Betrugsfälle sind jetzt im Test, und wie hat sich die Trefferquote bei 0,3 verändert?
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: HashMap und HashSet · Was maschinelles Lernen ist · Gradientenabstieg · Klassifikation: Betrug oder nicht
- Geht weiter in: Überanpassung: Das Modell, das auswendig lernt · Das Training von Anfang bis Ende · Die Ziffern auf dem Kassenbon · Evaluation: ein Testset für den Assistenten
- Aufgaben zum Thema: Wie ein Modell lernt