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

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

Python
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}")
R
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

Python
# 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%}")
R
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

Python
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))
R
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

Python
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)
R
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

Python
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)
R
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

Python
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)
R
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

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