Teil 3 · Explorative Datenanalyse und deskriptive Statistik
Lösungen
08 · Explorative Datenanalyse und Datenvisualisierung
Vergleiche zuerst mit deinem eigenen Versuch. Es gibt oft mehrere richtige Wege.
Aufgabe 1 – Erste Erkundung
.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 matplotlib matplotlib.use("Agg") import pandas as pd labs = pd.read_csv("https://schradern.github.io/data-science-coach/data/labor.csv") df = pd.read_csv("https://schradern.github.io/data-science-coach/data/kohorte.csv").merge(labs, on="patient_id", how="left") for col in ["bmi", "kreatinin_mg_dl", "haemoglobin_g_dl"]: s = df[col] print(f"{col}: Mittel={s.mean():.2f} Median={s.median():.2f} " f"SD={s.std():.2f} Schiefe={s.skew():.2f}")
library(tidyverse) cohort <- read_csv("https://schradern.github.io/data-science-coach/data/kohorte.csv", show_col_types = FALSE) labs <- read_csv("https://schradern.github.io/data-science-coach/data/labor.csv", show_col_types = FALSE) df <- left_join(cohort, labs, by = "patient_id") for (col in c("bmi", "kreatinin_mg_dl", "haemoglobin_g_dl")) { cat(sprintf("%-20s Mittel=%.2f Median=%.2f SD=%.2f\n", col, mean(df[[col]], na.rm=TRUE), median(df[[col]], na.rm=TRUE), sd(df[[col]], na.rm=TRUE))) }
Befund: kreatinin_mg_dl ist mit einer Schiefe von ~1,27 am stärksten
rechtsschief (Mittel ≈ 1,23, Median ≈ 1,02 mg/dl). Das ist klinisch plausibel:
die meisten Patient:innen haben ein normales Kreatinin, aber schwere Niereninsuffizienz
treibt die Verteilung nach rechts.
Aufgabe 2 – Häufigkeiten und fehlende Werte
.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.# Raucherstatus – absolut und relativ print(df["raucherstatus"].value_counts()) print(df["raucherstatus"].value_counts(normalize=True).round(3)) # Fehlende Werte – alle Spalten print(df.isna().sum()[df.isna().sum() > 0]) # Anteil Laktat fehlend anteil = df["laktat_mmol_l"].isna().mean() print(f"laktat_mmol_l fehlend: {anteil:.1%}")
df |> count(raucherstatus, sort = TRUE) |> mutate(anteil = n / sum(n)) colSums(is.na(df))[colSums(is.na(df)) > 0] mean(is.na(df$laktat_mmol_l))
Befund: „nie" (~47 %), „ehemalig" (~31 %), „aktiv" (~22 %). Fehlende Werte
gibt es bei gewicht_kg/bmi (~6 %, zufällig, MCAR) und laktat_mmol_l
(~17 %). Das Laktat-Fehlen ist informativ, aber MAR (nicht MNAR): die
Fehlwahrscheinlichkeit hängt vom beobachteten sofa_score ab, Laktat wird bei
höherem SOFA-Score (Verdacht auf Schock/Sepsis) klinisch häufiger gemessen,
fehlt also seltener bei schwer kranken Patient:innen (siehe data/README.md
und Modul 14 (Umgang mit fehlenden Werten)).
Aufgabe 3 – Ausreißer
.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.series = df["kreatinin_mg_dl"].dropna() q1, q3 = series.quantile(0.25), series.quantile(0.75) iqr = q3 - q1 upper = q3 + 1.5 * iqr n_high = (series > upper).sum() print(f"IQR={iqr:.2f} Obergrenze={upper:.2f} Ausreißer oben: {n_high}") print(series[series > upper].sort_values(ascending=False).head(5))
vals <- df$kreatinin_mg_dl q1 <- quantile(vals, 0.25, na.rm = TRUE) q3 <- quantile(vals, 0.75, na.rm = TRUE) upper <- q3 + 1.5 * (q3 - q1) sum(vals > upper, na.rm = TRUE) sort(vals[vals > upper], decreasing = TRUE) |> head(5)
Befund: ~15 Werte liegen oberhalb der Obergrenze (~3,37 mg/dl), der höchste Wert bei ~5,6 mg/dl. Auf einer Intensivstation klinisch plausibel, kein Datenfehler, sondern akutes Nierenversagen. Nicht ohne weiteres entfernen.
Aufgabe 4 – Histogramm
.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 matplotlib.pyplot as plt order = (df.groupby("aufnahmegrund")["verweildauer_tage"] .median().sort_values(ascending=False).index.tolist()) fig, axs = plt.subplots(1, len(order), figsize=(15, 4), sharey=True) for ax, grund in zip(axs, order): sub = df.loc[df["aufnahmegrund"] == grund, "verweildauer_tage"] ax.hist(sub, bins=15, color="#4C72B0", edgecolor="white", alpha=0.8) ax.set_title(grund, fontsize=8) ax.set_xlabel("Tage") axs[0].set_ylabel("Anzahl Patient:innen") fig.suptitle("Verweildauer je Aufnahmegrund") plt.tight_layout() fig.savefig("verweildauer_nach_aufnahmegrund.png", dpi=120)
library(ggplot2) order_lvl <- df |> group_by(aufnahmegrund) |> summarise(m = median(verweildauer_tage)) |> arrange(desc(m)) |> pull(aufnahmegrund) p_los <- ggplot(df, aes(x = verweildauer_tage)) + geom_histogram(bins = 15, fill = "#4C72B0", colour = "white", alpha = 0.8) + facet_wrap(~ factor(aufnahmegrund, levels = order_lvl), nrow = 1) + labs(x = "Tage", y = "Anzahl", title = "Verweildauer je Aufnahmegrund") + theme_minimal(base_size = 9) ggsave("verweildauer_nach_aufnahmegrund_r.png", p_los, width = 15, height = 4, dpi = 120)
Befund: Sepsis zeigt den höchsten Median (~6,5 Tage) und die breiteste Verteilung mit Werten bis 20 Tage, Ausdruck schwerer Verläufe mit Multiorganversagen. Alle Gruppen zeigen leichte Rechtsschiefe (wenige Langlieger).
Aufgabe 5 – Boxplot
.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 seaborn as sns order = (df.groupby("raucherstatus")["sofa_score"] .median().sort_values(ascending=False).index.tolist()) fig, ax = plt.subplots(figsize=(7, 4)) sns.boxplot(data=df, x="raucherstatus", y="sofa_score", order=order, palette="Set2", ax=ax) ax.set_xlabel("Raucherstatus") ax.set_ylabel("SOFA-Score bei Aufnahme") ax.set_title("SOFA-Score nach Raucherstatus") fig.tight_layout() fig.savefig("sofa_nach_raucherstatus.png", dpi=120)
order_lvl <- df |> group_by(raucherstatus) |> summarise(m = median(sofa_score)) |> arrange(desc(m)) |> pull(raucherstatus) p_sofa <- ggplot(df, aes(x = factor(raucherstatus, levels = order_lvl), y = sofa_score, fill = raucherstatus)) + geom_boxplot(outlier.size = 1.5, show.legend = FALSE) + scale_fill_brewer(palette = "Set2") + labs(x = "Raucherstatus", y = "SOFA-Score", title = "SOFA-Score nach Raucherstatus") + theme_minimal() ggsave("sofa_nach_raucherstatus_r.png", p_sofa, width = 7, height = 4, dpi = 120)
Befund: Die Gruppen zeigen starke Überlappung und nur geringe Unterschiede. Das ist ein typisches Beispiel dafür, warum EDA nicht mit Kausalschlüssen verwechselt werden darf, Confounding durch Alter und Aufnahmegrund ist wahrscheinlich.
Aufgabe 6 – Streudiagramm und Korrelation
.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.from scipy import stats sub = df[["crp_mg_l", "verweildauer_tage"]].dropna() r, p = stats.pearsonr(sub["crp_mg_l"], sub["verweildauer_tage"]) print(f"Pearson r = {r:.3f}, p = {p:.4f}") fig, ax = plt.subplots(figsize=(6, 5)) colors = {0: "#4C72B0", 1: "#DD8452"} for outcome, label in [(0, "überlebt"), (1, "verstorben")]: s = df[df["verstorben_30d"] == outcome] ax.scatter(s["crp_mg_l"], s["verweildauer_tage"], c=colors[outcome], label=label, alpha=0.4, s=18, edgecolors="none") ax.set_xlabel("CRP (mg/l)") ax.set_ylabel("Verweildauer (Tage)") ax.set_title(f"CRP vs. Verweildauer (r={r:.2f}, p={p:.3f})") ax.legend() fig.savefig("crp_verweildauer_streudiagramm.png", dpi=120)
sub <- df |> filter(!is.na(crp_mg_l), !is.na(verweildauer_tage)) r_val <- cor(sub$crp_mg_l, sub$verweildauer_tage) cat(sprintf("Pearson r = %.3f\n", r_val)) p_crp <- ggplot(df |> filter(!is.na(crp_mg_l)), aes(x = crp_mg_l, y = verweildauer_tage, colour = factor(verstorben_30d, labels = c("überlebt","verstorben")))) + geom_point(alpha = 0.4, size = 1.5) + scale_colour_manual(values = c("#4C72B0", "#DD8452")) + labs(x = "CRP (mg/l)", y = "Verweildauer (Tage)", colour = NULL, title = "CRP vs. Verweildauer nach Mortalität") + theme_minimal() ggsave("crp_verweildauer_streudiagramm_r.png", p_crp, width = 6, height = 5, dpi = 120)
Befund: Der Pearson-Koeffizient liegt nahe 0 (r ≈ −0,09) und ist bei diesem Stichprobenumfang (N = 500) formal signifikant (p ≈ 0,047), aber praktisch bedeutungslos (r² < 1 % erklärte Varianz). Das Streudiagramm bestätigt dies: eine diffuse Punktewolke ohne erkennbares Muster. Ein Paradebeispiel dafür, dass ein signifikantes p bei großem N nicht automatisch eine klinisch relevante Aussage liefert, immer Effektgröße und Streudiagramm mitlesen.
Bonus – Korrelationsmatrix
.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.numeric_cols = ["alter", "sofa_score", "crp_mg_l", "laktat_mmol_l", "verweildauer_tage", "leukozyten_g_l", "haemoglobin_g_dl", "kreatinin_mg_dl", "natrium_mmol_l", "verstorben_30d"] corr = df[numeric_cols].corr().round(2) print(corr["verstorben_30d"].drop("verstorben_30d") .sort_values(key=abs, ascending=False))
numeric_cols <- c("alter","sofa_score","crp_mg_l","laktat_mmol_l", "verweildauer_tage","leukozyten_g_l","haemoglobin_g_dl", "kreatinin_mg_dl","natrium_mmol_l","verstorben_30d") korr <- round(cor(df[numeric_cols], use = "pairwise.complete.obs"), 2) sort(korr["verstorben_30d", -which(colnames(korr)=="verstorben_30d")], decreasing = TRUE)
Befund: sofa_score (r ≈ 0,36) korreliert mit Abstand am stärksten mit
verstorben_30d, gefolgt von leukozyten_g_l (r ≈ 0,24) und alter
(r ≈ 0,22). Nicht überraschend: der SOFA-Score wurde klinisch für die
Intensivprognose entwickelt, höheres Alter ist ein bekannter Risikofaktor, und
erhöhte Leukozyten sind ein klassisches Entzündungs-/Infektzeichen. Die übrigen
Labormarker (u. a. CRP, Kreatinin) zeigen kaum linearen Zusammenhang,
nichtlineare oder kombinierte Effekte werden erst in Modul 12 sichtbar.