Teil 5 · Machine Learning und KI in der Medizin
Lösungen
32 · Modelleinsatz, Monitoring und Governance
Aufgabe 1 – Modell speichern und laden
.py schreiben und mit dem ▶-Knopf in VS Code ausführen – oder Zeile für Zeile in die Python-Konsole. Setzt die in Modul 02 eingerichtete Umgebung voraus.import joblib, sklearn, tempfile from pathlib import Path # Train pipe.fit(X_train, y_train) # Save artefact = { "pipeline": pipe, "sklearn_version": sklearn.__version__, "target": "verschlechterung", "train_size": len(X_train), "test_auc": roc_auc_score(y_test, pipe.predict_proba(X_test)[:, 1]), } with tempfile.TemporaryDirectory() as d: p = Path(d) / "modell.joblib" joblib.dump(artefact, p) loaded = joblib.load(p) # Verify identical predictions import numpy as np assert np.allclose( pipe.predict_proba(X_test), loaded["pipeline"].predict_proba(X_test) ), "Predictions differ after reload!" print("OK – identische Vorhersagen nach Reload.")
Zusätzlich zum Pipeline-Objekt empfiehlt sich: sklearn-Version, Datum, Trainingsgröße, Test-AUC, SHA-256-Prüfsumme der Trainingsdaten.
Aufgabe 2 – Prävalenz-Shift: ROC-AUC vs. PR-AUC
.py schreiben und mit dem ▶-Knopf in VS Code ausführen – oder Zeile für Zeile in die Python-Konsole. Setzt die in Modul 02 eingerichtete Umgebung voraus.import numpy as np from sklearn.metrics import roc_auc_score, average_precision_score rng = np.random.default_rng(42) for sf in [0.0, 0.5, 1.0]: n_extra = int(sf * (y_test == 1).sum()) pos_idx = y_test[y_test == 1].index extra_idx = rng.choice(pos_idx, size=n_extra, replace=True) if n_extra > 0 else [] Xs = pd.concat([X_test] + ([X_test.loc[extra_idx]] if n_extra > 0 else [])) ys = pd.concat([y_test] + ([y_test.loc[extra_idx]] if n_extra > 0 else [])) proba = pipe.predict_proba(Xs)[:, 1] roc = roc_auc_score(ys, proba) pr = average_precision_score(ys, proba) print(f"Shift {sf:.0%}: n={len(ys)} Positivrate={ys.mean():.1%} " f"ROC-AUC={roc:.3f} PR-AUC={pr:.3f}")
Tatsächlicher Lauf: Shift 0 % → ROC-AUC 0,849 / PR-AUC 0,672 (Positivrate 34,4 %); Shift 50 % → ROC-AUC 0,835 / PR-AUC 0,734 (43,8 %); Shift 100 % → ROC-AUC 0,850 / PR-AUC 0,820 (51,2 %).
ROC-AUC bleibt näherungsweise konstant (0,849 → 0,835 → 0,850, Schwankung ist
Rauschen, kein Trend), weil ROC-AUC nur von der Rangordnung der Scores
innerhalb jeder Klasse abhängt. Duplizieren bereits vorhandener positiver Fälle
fügt keine neuen Score-Werte hinzu, jedes Duplikat hat exakt denselben Score wie
sein Original, also verschiebt es die Rangordnung zwischen Positiv- und
Negativ-Scores nicht. PR-AUC steigt dagegen deutlich (0,672 → 0,820), weil
Precision (TP / (TP + FP)) direkt von der Prävalenz abhängt: bei mehr Positiven
im Nenner-relevanten Bereich sinkt der Anteil falsch-positiver Fälle relativ zu
den (jetzt häufigeren) richtig-positiven, die Precision-Recall-Kurve verbessert
sich rein durch die veränderte Klassenbalance. Das ist die Kernlektion: ROC-AUC
ist prävalenzunabhängig, PR-AUC nicht, bei starkem Klassenungleichgewicht oder
erwartetem Prävalenz-Shift ist PR-AUC deshalb oft die aussagekräftigere Metrik.
Aufgabe 3 – Subgruppenanalyse mit Konfidenzintervall
.py schreiben und mit dem ▶-Knopf in VS Code ausführen – oder Zeile für Zeile in die Python-Konsole. Setzt die in Modul 02 eingerichtete Umgebung voraus.import pandas as pd import numpy as np from sklearn.metrics import roc_auc_score def bootstrap_auc_ci(y, proba, n_boot=1000, seed=42): rng = np.random.default_rng(seed) y, proba = np.asarray(y), np.asarray(proba) boot_aucs = [] for _ in range(n_boot): idx = rng.integers(0, len(y), len(y)) if len(np.unique(y[idx])) < 2: continue boot_aucs.append(roc_auc_score(y[idx], proba[idx])) return np.percentile(boot_aucs, [2.5, 97.5]) cohort = pd.read_csv("https://schradern.github.io/data-science-coach/data/kohorte.csv") cohort["geschlecht_clean"] = cohort["geschlecht"].replace({"w": "weiblich"}) merged = pd.read_csv("https://schradern.github.io/data-science-coach/data/notizen.csv").merge(cohort[["patient_id", "geschlecht_clean"]], on="patient_id") _, test_idx = train_test_split(merged.index, test_size=0.25, stratify=merged["verschlechterung"], random_state=42) test = merged.loc[test_idx] proba = pipe.predict_proba(test["notiz"])[:, 1] for g in ["weiblich", "maennlich"]: mask = (test["geschlecht_clean"] == g).values y_g, p_g = test.loc[mask, "verschlechterung"].to_numpy(), proba[mask] auc = roc_auc_score(y_g, p_g) lo, hi = bootstrap_auc_ci(y_g, p_g) print(f"{g}: AUC = {auc:.3f} [95%-CI {lo:.3f}-{hi:.3f}] (n={mask.sum()})")
Tatsächlicher Lauf: Weiblich AUC 0,880 [95%-CI 0,787–0,952] (n=61); Männlich AUC 0,807 [95%-CI 0,689–0,909] (n=64). Die Konfidenzintervalle überlappen sich stark (0,787–0,909 gemeinsam abgedeckt), der Punktschätzer-Unterschied von ≈0,07 ist bei diesen Stichprobengrößen statistisch nicht von Zufall zu unterscheiden, klinisch relevant wäre erst eine Lücke, die auch nach Bootstrap-CI bestehen bleibt. Adressieren würde man eine bestätigte Lücke (nicht überlappende CIs) durch: (1) größere, unabhängige Kohorte zur Bestätigung, (2) Datenanalyse nach Ursache (Dokumentationsunterschiede, Ereignisraten), (3) gruppenspezifische Schwellen oder Kalibrierung.
Aufgabe 4 – Model Card ausfüllen
Felder ohne prospektive Studie nicht befüllbar:
- Leistungsmetriken auf externen Kohorten (anderer Klinik, anderer Zeitraum)
- Subgruppenleistung auf Populationen, die im Testsplit gar nicht oder zu klein vertreten sind (z. B. Ethnizität, seltene Altersgruppen) — Geschlecht und ein Altersgruppen-Split (Schwelle 65 Jahre) lassen sich mit dem vorhandenen Testsplit inkl. Bootstrap-CI berechnen (siehe README Abschnitt 4).
- Kalibrierung in der Zielklinik
- Post-Market Surveillance Ergebnisse
- Ergebnis der klinischen Evaluierungsstudie (MDR-Anforderung)
Bonus – Kalibrierungs-Drift
.py schreiben und mit dem ▶-Knopf in VS Code ausführen – oder Zeile für Zeile in die Python-Konsole. Setzt die in Modul 02 eingerichtete Umgebung voraus.import numpy as np import pandas as pd from sklearn.calibration import calibration_curve import matplotlib.pyplot as plt # Re-create the 100%-shift test set from Aufgabe 2 (Xs_max/ys_max): duplicate # every positive test case once, same construction as sf=1.0 above. rng = np.random.default_rng(42) pos_idx = y_test[y_test == 1].index extra_idx = rng.choice(pos_idx, size=(y_test == 1).sum(), replace=True) Xs_max = pd.concat([X_test, X_test.loc[extra_idx]]) ys_max = pd.concat([y_test, y_test.loc[extra_idx]]) fig, ax = plt.subplots(figsize=(5.5, 5)) for label, Xs, ys in [("kein Shift", X_test, y_test), ("100% Shift", Xs_max, ys_max)]: proba = pipe.predict_proba(Xs)[:, 1] frac_pos, mean_pred = calibration_curve(ys, proba, n_bins=5) ece = np.mean(np.abs(frac_pos - mean_pred)) ax.plot(mean_pred, frac_pos, marker="o", label=f"{label} (ECE={ece:.3f})") ax.plot([0,1],[0,1], ls="--", color="gray", label="Perfekt kalibriert") ax.set_xlabel("Mittlere Vorhersagewahrscheinlichkeit") ax.set_ylabel("Beobachtete Häufigkeit") ax.set_title("Kalibrierungskurve: Shift-Effekt") ax.legend(); plt.tight_layout(); plt.show()
Tatsächlicher Lauf: ECE steigt von 0,127 (kein Shift, n=125, Positivrate 34,4 %) auf 0,173 (100 % Shift, n=168, Positivrate 51,2 %). Der ECE steigt mit zunehmendem Shift, obwohl (wie in Aufgabe 2 gezeigt) die ROC-AUC nahezu unverändert bleibt, das Modell gibt für die veränderte, höhere Ereignisrate weiterhin die alten (zu niedrigen) Wahrscheinlichkeiten aus. Das ist die komplementäre Lektion zu Aufgabe 2: Ranking (AUC) kann intakt bleiben, während die Kalibrierung unter Prävalenz-Shift trotzdem kaputtgeht, Rekalibrierung (Platt Scaling) auf der neuen Grundrate wäre dann nötig.