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

Teil 3 – Explorative Datenanalyse und deskriptive Statistik

09 · Deskriptive Statistik und die Table 1

Dauer~60 min
VoraussetzungModule 06, 08 (Transformation und EDA)
Lernziele
  • Lage- und Streuungsmaße (Mittelwert, Median, SD, IQR) erklären und situationsgerecht auswählen können.
  • Häufigkeiten und Anteile für kategoriale Variablen berechnen.
  • Eine klinische „Table 1" automatisiert erstellen und korrekt interpretieren.
  • Wissen, wann Mittelwert vs. Median angemessen ist, und warum das klinisch einen Unterschied macht.
  • Dasselbe Ergebnis in pandas + tableone (Python) und gtsummary (R) erzeugen.
Auf dieser Seite

Klinischer Aufhänger

Abbildung: Verteilungsvergleich von Alter, SOFA-Score und CRP nach 30-Tage-Mortalität. Verstorbene (rot) weisen bei Alter und SOFA-Score deutlich rechtsverschobene Verteilungen auf; bei CRP ist der Unterschied gering. Gepunktete Linien markieren die jeweiligen Mediane.
Abb. 1 · Verteilungsvergleich von Alter, SOFA-Score und CRP nach 30-Tage-Mortalität. Verstorbene (rot) weisen bei Alter und SOFA-Score deutlich rechtsverschobene Verteilungen auf; bei CRP ist der Unterschied gering. Gepunktete Linien markieren die jeweiligen Mediane. · Code ansehen

Jedes klinische Paper beginnt mit einer „Table 1": Alter, Geschlecht, Komorbiditäten, Aufnahmestatus, getrennt nach Hauptgruppen (Überlebende vs. Verstorbene). Sie zeigt, ob die Gruppen vergleichbar sind und ob ein Selektionsbias erkennbar ist. Wir bauen sie einmal manuell, einmal mit einem Paket.

1 Lagemaße: Mittelwert vs. Median

Mittelwert (arithmetisches Mittel): Summe aller Werte geteilt durch die Anzahl. Reagiert empfindlich auf Ausreißer.

Median: Der mittlere Wert nach Sortierung. Robust gegenüber Ausreißern.

Situation Empfehlung
Normalverteilung, keine Ausreißer Mittelwert ± SD
Schiefe Verteilung (z. B. Liegedauer, CRP) Median [IQR]
Kategoriale Daten n (%)

Klinisches Beispiel: Krankenhausverweildauer. Wenige Patient:innen liegen extrem lang, das zieht den Mittelwert nach oben, während der Median den typischen Verlauf besser abbildet.

Python
import numpy as np
los = [3, 4, 5, 4, 3, 6, 4, 5, 28]   # 28 = langer Auslieger
print(f"Mittelwert: {np.mean(los):.1f}")    # 6.9  – irreführend hoch
print(f"Median:     {np.median(los):.1f}")  # 4.0  – typischer Wert
R
los <- c(3, 4, 5, 4, 3, 6, 4, 5, 28)
cat(sprintf("Mittelwert: %.1f\n", mean(los)))    # 6.9
cat(sprintf("Median:     %.1f\n", median(los)))  # 4.0
Fallstrick

Viele Journals verlangen Mittelwert ± SD auch für schiefe Variablen, reproduzierbar, aber klinisch irreführend. Prüfe die Author Guidelines und ergänze, falls SD gefordert ist, zusätzlich Median [IQR].

Für FortgeschritteneVertiefung

Bei sehr kleinen Gruppen (n < 10) sind weder Mittelwert noch Median stabil, berichte dann besser alle Einzelwerte tabellarisch und verzichte auf zusammenfassende Statistik.

2 Streuungsmaße: SD vs. IQR

Standardabweichung (SD): Durchschnittliche Abweichung vom Mittelwert. Sinnvoll bei annähernder Normalverteilung. Berichtet als Mittelwert ± SD.

Interquartilsabstand (IQR): Abstand zwischen Q1 (25. Perzentil) und Q3 (75. Perzentil). Robust gegenüber Ausreißern. Berichtet als Median [Q1; Q3].

Python
import pandas as pd

cohort = pd.read_csv("https://schradern.github.io/data-science-coach/data/kohorte.csv")
age = cohort["alter"]
print(f"Alter: {age.mean():.1f} ± {age.std():.1f} Jahre (Mittelwert ± SD)")
print(f"Alter: {age.median():.0f} [{age.quantile(0.25):.0f}; {age.quantile(0.75):.0f}] Jahre (Median [IQR])")
R
library(readr)
cohort <- read_csv("https://schradern.github.io/data-science-coach/data/kohorte.csv", show_col_types = FALSE)
age <- cohort$alter
cat(sprintf("Alter: %.1f ± %.1f Jahre (Mittelwert ± SD)\n", mean(age), sd(age)))
cat(sprintf("Alter: %.0f [%.0f; %.0f] Jahre (Median [IQR])\n",
            median(age), quantile(age, 0.25), quantile(age, 0.75)))
Fallstrick

sd() in R (und std() in pandas) verwendet standardmäßig Bessel's Korrektur (Nenner n−1, nicht n), korrekt für Stichproben, aber für die deskriptive Beschreibung einer Vollkohorte manchmal missverständlich. In numpy.std(ddof=0) ist der Nenner n; pandas-std() verwendet n−1.

Für FortgeschritteneVertiefung

Für ordinale Variablen wie sofa_score (klinisch 0–24, in unserer Kohorte 0–11, ganzzahlig) ist Median [IQR] fast immer vorzuziehen, beide vermeiden halbe Einheiten, während SD Nachkommastellen erzeugt, die eine falsche Präzision suggerieren.

3 Häufigkeiten für kategoriale Variablen

Kategoriale Variablen werden als absolute Häufigkeit n und relativer Anteil % berichtet.

Python
import pandas as pd

cohort = pd.read_csv("https://schradern.github.io/data-science-coach/data/kohorte.csv")
# Absolute and relative frequencies
print(cohort["aufnahmegrund"].value_counts())
print(cohort["aufnahmegrund"].value_counts(normalize=True).mul(100).round(1))
R
library(readr)
cohort <- read_csv("https://schradern.github.io/data-science-coach/data/kohorte.csv", show_col_types = FALSE)
table(cohort$aufnahmegrund)
round(prop.table(table(cohort$aufnahmegrund)) * 100, 1)
Fallstrick

Ein 0/1-Wert für diabetes sieht numerisch aus, pandas und R mitteln ihn unbemerkt. Das Ergebnis (z. B. 0,34) entspricht zwar dem Anteil, wird in einer Tabelle aber leicht als Mittelwert fehlinterpretiert. Deklariere ihn immer explizit als Faktor/Kategorie oder nutze value_counts().

Für FortgeschritteneVertiefung

pd.crosstab(cohort["raucherstatus"], cohort["diabetes"], normalize="index") liefert in einer Zeile eine zeilenweise normierte Kreuztabelle, ideal für Untergruppen-Anteile ohne eigene groupby-Logik.

4 „Table 1" mit tableone (Python) / gtsummary (R)

Der vollständige Code steht in code/python.py und code/r.R. Hier die Kernidee:

Python
import pandas as pd
from tableone import TableOne

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")
df["geschlecht"] = df["geschlecht"].replace({"w": "weiblich"})

columns     = ["alter", "geschlecht", "aufnahmegrund", "diabetes",
               "hypertonie", "sofa_score", "crp_mg_l", "verweildauer_tage"]
categorical = ["geschlecht", "aufnahmegrund", "diabetes", "hypertonie"]

table1 = TableOne(df, columns=columns, categorical=categorical,
                  groupby="verstorben_30d", pval=True, missing=True)
print(table1.tabulate(tablefmt="simple"))
R
library(readr)
library(gtsummary)

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") |>
  mutate(
    verstorben_30d = factor(verstorben_30d, levels = c(0, 1),
                            labels = c("Überlebt", "Verstorben")),
    diabetes   = factor(diabetes,   levels = c(0, 1), labels = c("nein", "ja")),
    hypertonie = factor(hypertonie, levels = c(0, 1), labels = c("nein", "ja"))
  )

df |>
  select(alter, geschlecht, aufnahmegrund, diabetes,
         hypertonie, sofa_score, crp_mg_l, verweildauer_tage, verstorben_30d) |>
  tbl_summary(by = verstorben_30d,
              statistic = list(all_continuous()  ~ "{median} [{p25}; {p75}]",
                               all_categorical() ~ "{n} ({p}%)"),
              missing = "ifany") |>
  add_p() |>
  add_overall()
Fallstrick

pval=True in tableone (und add_p() in gtsummary) erzeugt automatisch p-Werte, der Test wird jedoch still nach Variablentyp gewählt: bei nicht-normalverteilten Variablen Kruskal-Wallis / Mann-Whitney-U (korrekt), bei binären Variablen Chi-Quadrat. Lies stets table1.tabulate() und prüfe die Testauswahl, statt sie blind zu übernehmen.

Für FortgeschritteneVertiefung

add_overall() in gtsummary fügt in einer Zeile eine Gesamtspalte hinzu, die die Kohortengröße auf einen Blick zeigt, Standard in klinischen Publikationen.

Fallstrick

laktat_mmol_l steht mit pval=True in der Table 1, obwohl seine Missingness nicht zufällig ist, sie hängt vom sofa_score ab (Modul 14 zeigt das mit Zahlen). Der Gruppenvergleichs-p-Wert wird also nur über die vollständigen Fälle berechnet, einer selektierten Teilstichprobe, nicht über die gesamte Kohorte. Das ist hier vertretbar, weil tableones missing=True die Fehlquote (86 fehlend, 17,2 %) direkt daneben ausweist, sodass Lesende die Selektion einschätzen können. Ein p-Wert auf einer Variable mit informativer Missingness ist ohne die Fehlquote wertlos. bga_ph steht deshalb bewusst nicht in dieser Table 1: Sein Fehlen hängt vom Outcome selbst ab (verstorben_30d, siehe Modul 14), ein Gruppenvergleichs-p-Wert über die Outcome-Gruppen hinweg würde also auf einer Variablen berechnet, deren Missingness durch genau diese Gruppierung bestimmt ist, ein Zirkelschluss. Das ist ein konkretes Beispiel für die Falle: Bevor eine Variable mit p-Wert in eine Table 1 aufgenommen wird, prüfe, wovon ihre Missingness abhängt (siehe Modul 14).

5 Qualitätsindikatoren und SPC (Statistical Process Control)

In der klinischen Versorgungssteuerung und im Qualitätsmanagement (QM) reichen einfache Tabellen oft nicht aus. Hier müssen Prozesse über die Zeit überwacht werden, um Veränderungen (z. B. nach der Einführung eines neuen Triage-Systems oder Hygienestandards) objektiv zu bewerten.

Qualitätsindikatoren (QIs)

Klinische Qualitätsindikatoren messen die Qualität der Versorgung. Typische Beispiele sind:

  • Prozessindikatoren: Mittlere Wartezeit in der Notaufnahme (in Minuten), Anteil der leitliniengerechten Antibiotika-Gaben binnen 1 Stunde bei Sepsis.
  • Ergebnisindikatoren: 30-Tage-Reakmissionsrate (Wiederaufnahme), ZVK-assoziierte Infektionsraten pro 1000 Patiententage.

Statistical Process Control (SPC)

SPC nutzt grafische Methoden, um zwischen zufälligem Rauschen (Common Cause Variation) und systematischen Änderungen (Special Cause Variation) zu unterscheiden. Das einfachste und nützlichste Werkzeug hierfür ist das Run Chart (Verlaufschart).

Abbildung: SPC Run Chart (Verlaufschart) für die monatliche mittlere Wartezeit in einer Notaufnahme. Die gestrichelte graue Linie zeigt den Baseline-Median. Nach Monat 12 ist ein Shift von 12 aufeinanderfolgenden Punkten unter dem Median erkennbar, was auf eine erfolgreiche Prozessverbesserung hindeutet.
Abb. 2 · SPC Run Chart (Verlaufschart) für die monatliche mittlere Wartezeit in einer Notaufnahme. Die gestrichelte graue Linie zeigt den Baseline-Median. Nach Monat 12 ist ein Shift von 12 aufeinanderfolgenden Punkten unter dem Median erkennbar, was auf eine erfolgreiche Prozessverbesserung hindeutet. · Code ansehen

code/python.py/code/r.R (Abschnitt 6) reproduzieren dieses Run Chart aus denselben Zahlen und speichern eine eigene Kopie neben dem Skript (spc_run_chart_demo.png/spc_run_chart_demo_r.png).

Sonderereignisse erkennen (Regeln für Run Charts)

Ein Prozess hat sich systematisch verändert, wenn eine der folgenden Regeln verletzt wird:

  1. Shift (Verschiebung): ≥ 6 aufeinanderfolgende Punkte liegen alle oberhalb oder alle unterhalb des Medians.
  2. Trend: ≥ 5 aufeinanderfolgende Punkte steigen kontinuierlich an oder fallen kontinuierlich ab.
  3. Astronomical Point (Ausreißer): Ein einzelner Wert weicht extrem und offensichtlich ab (Sonderereignis, z. B. Systemausfall).

In Python – Run Chart zeichnen

Python
import matplotlib.pyplot as plt
import numpy as np

# Wartezeit-Daten (Monate 1-24)
months = np.arange(1, 25)
wait_times = [43.5, 47.2, 41.8, 46.0, 48.5, 42.1, 45.2, 49.0, 44.1, 46.5, 43.0, 45.5,
              34.5, 31.2, 33.8, 30.5, 29.1, 32.4, 35.0, 31.8, 30.0, 32.5, 33.1, 28.5]

median_baseline = np.median(wait_times[:12])

fig, ax = plt.subplots(figsize=(8, 4))
ax.plot(months, wait_times, "o-", color="#2A5C8A", label="Messwert")
ax.axhline(median_baseline, color="gray", linestyle="--", label="Baseline-Median")

# Shift hervorheben (Monate 13-24 liegen alle unter Median)
ax.plot(months[12:], wait_times[12:], "o-", color="#B5482E", label="Shift (Prozessverbesserung)")
ax.set_xlabel("Monat")
ax.set_ylabel("Wartezeit (Minuten)")
ax.legend()
plt.show()

In R – Run Chart zeichnen

R
library(tidyverse)

df_spc <- tibble(
  monat = 1:24,
  wartezeit = c(43.5, 47.2, 41.8, 46.0, 48.5, 42.1, 45.2, 49.0, 44.1, 46.5, 43.0, 45.5,
                34.5, 31.2, 33.8, 30.5, 29.1, 32.4, 35.0, 31.8, 30.0, 32.5, 33.1, 28.5)
)

median_baseline <- median(df_spc$wartezeit[1:12])

ggplot(df_spc, aes(x = monat, y = wartezeit)) +
  geom_line(color = "#2A5C8A") +
  geom_point(color = "#2A5C8A") +
  geom_hline(yintercept = median_baseline, linetype = "dashed", color = "gray") +
  # Shift rot faerben
  geom_point(data = df_spc %>% filter(monat > 12), color = "#B5482E", size = 2.5) +
  labs(x = "Monat", y = "Wartezeit (Minuten)", title = "Wartezeit-Verlaufschart") +
  theme_minimal()

Wann du Hilfe holst. Sobald aus der Table 1 formale Gruppenvergleiche mit p-Werten werden oder SPC-Kontrollgrenzen ein reales Qualitätssignal auslösen sollen (Intervention, Meldung), gehört die Wahl von Test, Korrektur und Regelbasis mit einer Statistikerin abgestimmt — eine deskriptive Tabelle ist noch kein Inferenzverfahren.

Fallstricke und Merksätze

  • Mittelwert ± SD bei schiefen Daten ist irreführend. CRP, Liegedauer, Laktat sind fast immer rechtsschief, stets Median [IQR] berichten.

  • p-Werte in Table 1 sind kein Ziel. Sie beschreiben nur, ob Gruppenunterschiede durch Zufall entstanden sein könnten, bei Beobachtungsstudien oft irrelevant.

  • Fehlende Werte immer ausweisen. missing=True / missing="ifany" muss aktiviert sein, Lesende sollen wissen, worauf die Analyse beruht.

  • Kategoriale Variablen als solche deklarieren. Ein gemittelter 0/1-Wert für Diabetes ist keine klinische Aussage.

  • Rundungsregel: Anteile auf eine Dezimalstelle, absolute Zahlen ganzzahlig.

Selbstcheck

Bei schiefer Verteilung oder Ausreißern (z. B. Liegedauer, CRP, Laktat); der Mittelwert wird durch Extremwerte verzerrt, der Median nicht.
Er prüft, ob Gruppenunterschiede durch Zufall entstanden sein könnten; bei Beobachtungsstudien mit nicht-randomisierten Gruppen ist diese Frage klinisch meist uninteressant.
Sonst berechnen pandas/R einen Mittelwert (z. B. 0,34), der keinen klinischen Sinn ergibt; korrekt ist n (%).