Data Science · Klinik Klinische Datenanalyse & Machine Learning
Ansicht
Lerntiefe
Codeansicht
Farbschema

Teil 5 · Machine Learning und KI in der Medizin

Lösungen

32 · Modelleinsatz, Monitoring und Governance

Aufgabe 1 – Modell speichern und laden

Python
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

Python
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

Python
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

Python
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.