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

Teil 5 · Machine Learning und KI in der Medizin

Lösungen

24 · Workflow für Prädiktionsmodelle und Data Leakage

Die Codeblöcke zu Aufgabe 1–4 setzen auf code/python.py auf: X, y, cv, X_train/X_test, y_train/y_test, NUMERIC und build_pipeline() stammen von dort. Der Bonus-Block läuft für sich allein.

Aufgabe 1 – Ehrliche Kreuzvalidierung

Python
from sklearn.model_selection import StratifiedKFold, cross_val_score
# build_pipeline() from code/python.py
auc = cross_val_score(build_pipeline(), X, y,
                      cv=StratifiedKFold(5, shuffle=True, random_state=42),
                      scoring="roc_auc").mean()

Ergebnis: CV-AUC ≈ 0,78. Die Pipeline passt Imputation und Skalierung in jeder Falte neu nur auf den Trainingsteil an, so sieht kein Validierungswert das Modell vorab.

Aufgabe 2 – Leakage sichtbar machen

Python
from sklearn.pipeline import make_pipeline
prep = make_pipeline(SimpleImputer(strategy="median"), StandardScaler())

X_all = prep.fit_transform(X[NUMERIC])            # on all data -> leakage
leaky = cross_val_score(LogisticRegression(max_iter=1000), X_all, y,
                        cv=cv, scoring="roc_auc").mean()
honest = cross_val_score(make_pipeline(prep, LogisticRegression(max_iter=1000)),
                         X[NUMERIC], y, cv=cv, scoring="roc_auc").mean()
print(f"leaky {leaky:.3f}   honest {honest:.3f}")   # leaky 0.787   honest 0.787

Bei reiner Skalierung (und Median-Imputation) ist der Effekt hier praktisch null (Mittelwert/SD/Median ändern sich kaum durch wenige Validierungszeilen). Bei Merkmalsauswahl ist er groß: Die Auswahl nutzt das Outcome aller Zeilen und greift gezielt Zufallskorrelationen ab (siehe code/python.py, Abschnitt 1: 0,86 vs. 0,73).

Aufgabe 3 – Tuning ohne Selbstbetrug

Python
grid = GridSearchCV(build_pipeline(), {"model__C": [0.01, 0.1, 1, 10]}, cv=cv, scoring="roc_auc")
grid.fit(X_train, y_train)
print(grid.best_score_)                                   # inner CV-AUC
print(roc_auc_score(y_test, grid.predict_proba(X_test)[:, 1]))   # Test-AUC

Ergebnis: innere CV-AUC 0,743 (bestes C = 0,01), Test-AUC 0,886. Erwartet hätte man das Gegenteil: Die innere CV-AUC ist tendenziell optimistisch, weil C auf ihr ausgewählt wurde. Dass der Testwert hier deutlich höher liegt, ist Zufall des Splits: Das Testset enthält nur 19 Todesfälle; das 95-%-KI seiner AUC reicht grob ±0,1. Der Testwert ist unverzerrt, aber unsicher; berichte ihn deshalb mit 95-%-KI (z. B. per Bootstrap) und lies ihn zusammen mit der CV-AUC.

Aufgabe 4 – Schwelle nach Nutzen

Python
import numpy as np
from sklearn.metrics import roc_curve
from sklearn.model_selection import cross_val_predict

# Choose the threshold on the training data (out-of-fold predictions), not on the test set
oof = cross_val_predict(grid.best_estimator_, X_train, y_train, cv=cv,
                        method="predict_proba")[:, 1]
fpr, tpr, thr = roc_curve(y_train, oof)
threshold = thr[np.where(tpr >= 0.80)[0][0]]

# evaluate on the test set exactly once
pred = grid.predict_proba(X_test)[:, 1] >= threshold
sens = pred[y_test == 1].mean()
spec = 1 - pred[y_test == 0].mean()
print(f"Schwelle {threshold:.2f}: Sensitivität {sens:.2f}, Spezifität {spec:.2f}")
# Schwelle 0.44: Sensitivität 0.89, Spezifität 0.54

Die Schwelle wird wie im README verlangt auf den Trainingsdaten festgelegt; auf dem Testset erreicht sie hier 89 % Sensitivität bei nur 54 % Spezifität. Höhere Sensitivität kostet Spezifität (mehr Fehlalarme). Gerechtfertigt, wenn ein übersehener Fall schwer wiegt und Folgediagnostik billig/risikoarm ist (z. B. Frühwarnung vor klinischer Verschlechterung).

Bonus – Gruppen-Leakage

Python
import pandas as pd
from sklearn.ensemble import RandomForestClassifier
from sklearn.impute import SimpleImputer
from sklearn.model_selection import StratifiedGroupKFold, StratifiedKFold, cross_val_score
from sklearn.pipeline import make_pipeline

vitals = pd.read_csv("https://datascience.medizin.dev/data/vitalwerte.csv")
cohort = pd.read_csv("https://datascience.medizin.dev/data/kohorte.csv")
df = vitals.merge(cohort[["patient_id", "alter", "crp_mg_l", "verstorben_30d"]], on="patient_id")
X = df[["herzfrequenz", "map_mmhg", "temperatur_c", "spo2_prozent", "alter", "crp_mg_l"]]
y = df["verstorben_30d"]
groups = df["patient_id"]             # up to four rows (days 0-3) per person

pipe = make_pipeline(SimpleImputer(strategy="median"),
                     RandomForestClassifier(n_estimators=300, random_state=42))
random_cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
group_cv = StratifiedGroupKFold(n_splits=5, shuffle=True, random_state=42)
auc_random = cross_val_score(pipe, X, y, cv=random_cv, scoring="roc_auc").mean()
auc_group = cross_val_score(pipe, X, y, cv=group_cv, groups=groups, scoring="roc_auc").mean()
print(f"zufällige Falten: {auc_random:.2f}   gruppierte Falten: {auc_group:.2f}")
# zufällige Falten: 0.83   gruppierte Falten: 0.65

Ohne Gruppierung landen Zeilen derselben Person in Train und Test. Der Random Forest erkennt die Person an ihren je Patient:in konstanten Werten (alter, crp_mg_l) wieder und „weiß“ ihr Outcome schon: 0,83 statt ehrlicher 0,65. Mit StratifiedGroupKFold bleibt jede Person in genau einer Falte. Mit einer logistischen Regression ist der Unterschied auf denselben Daten kaum sichtbar (0,70 vs. 0,70), weil sich ein lineares Modell einzelne Personen schlecht merken kann. Gruppen-Leakage ist also gerade bei flexiblen Modellen gefährlich.