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

Teil 4 · Statistische Inferenz und medizinische Statistik

Lösungen

10 · Inferenzstatistik und Hypothesentests

Vergleiche zuerst mit deinem eigenen Versuch. Statistische Ehrlichkeit bedeutet: immer p + Effektgröße + Konfidenzintervall berichten, nie p allein.


Aufgabe 1 – p-Wert interpretieren

Python
import pandas as pd
import numpy as np
from scipy import stats

cohort = pd.read_csv("https://schradern.github.io/data-science-coach/data/kohorte.csv")
labs   = pd.read_csv("https://schradern.github.io/data-science-coach/data/labor.csv")
df     = cohort.merge(labs, on="patient_id", how="left")

with_diabetes    = df.loc[df["diabetes"] == 1, "sofa_score"].dropna()
without_diabetes = df.loc[df["diabetes"] == 0, "sofa_score"].dropna()

t_stat, p = stats.ttest_ind(with_diabetes, without_diabetes, equal_var=False)

# Cohen's d (pooled SD)
pooled_sd = np.sqrt(
    ((len(with_diabetes) - 1) * with_diabetes.std()**2 +
     (len(without_diabetes) - 1) * without_diabetes.std()**2) /
    (len(with_diabetes) + len(without_diabetes) - 2)
)
d = (with_diabetes.mean() - without_diabetes.mean()) / pooled_sd

# 95-%-CI of the difference (Welch approximation)
se_diff = np.sqrt(with_diabetes.var(ddof=1) / len(with_diabetes) +
                  without_diabetes.var(ddof=1) / len(without_diabetes))
df_welch = (with_diabetes.var(ddof=1) / len(with_diabetes) +
            without_diabetes.var(ddof=1) / len(without_diabetes))**2 / (
    (with_diabetes.var(ddof=1) / len(with_diabetes))**2 / (len(with_diabetes) - 1) +
    (without_diabetes.var(ddof=1) / len(without_diabetes))**2 / (len(without_diabetes) - 1))
t_crit   = stats.t.ppf(0.975, df=df_welch)
diff     = with_diabetes.mean() - without_diabetes.mean()
ci_lo, ci_hi = diff - t_crit * se_diff, diff + t_crit * se_diff

print(f"Diabetes:         n={len(with_diabetes)}, SOFA={with_diabetes.mean():.2f}")
print(f"kein Diabetes:    n={len(without_diabetes)}, SOFA={without_diabetes.mean():.2f}")
print(f"Welch-t={t_stat:.3f}, p={p:.4f}, Cohen's d={d:.3f}")
print(f"Differenz={diff:.2f}, 95%-KI=[{ci_lo:.2f}, {ci_hi:.2f}]")
R
suppressPackageStartupMessages(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")

with_d    <- df |> filter(diabetes == 1) |> pull(sofa_score) |> na.omit()
without_d <- df |> filter(diabetes == 0) |> pull(sofa_score) |> na.omit()

t_res <- t.test(with_d, without_d, var.equal = FALSE)
print(t_res)

# Cohen's d
pooled_sd <- sqrt(((length(with_d)-1)*var(with_d) + (length(without_d)-1)*var(without_d)) /
                  (length(with_d) + length(without_d) - 2))
d <- (mean(with_d) - mean(without_d)) / pooled_sd
cat(sprintf("Cohen's d = %.3f\n", d))

Befund: Patient:innen mit Diabetes haben einen höheren SOFA-Score (Mittel ≈ 4,59) als Patient:innen ohne Diabetes (≈ 3,48). Die Differenz beträgt ~1,11 Punkte (95-%-KI ≈ [0,64; 1,57]), Welch-t ≈ 4,69, p < 0,001, Cohen's d ≈ 0,54 (mittlerer Effekt).

p-Wert in eigenen Worten: Wenn es in der Population keinen Unterschied im SOFA-Score zwischen Diabetiker:innen und Nicht-Diabetiker:innen gäbe, würden wir eine mindestens so große Teststatistik mit einer Wahrscheinlichkeit von p < 0,001 beobachten, der p-Wert sagt nichts darüber aus, wie wahrscheinlich diese Nullhypothese selbst ist.

Häufige Fehldeutung: „p < 0,001 bedeutet, dass H₀ mit 99,9 %-Wahrscheinlichkeit falsch ist." Falsch: Der p-Wert ist P(Daten | H₀), nicht P(H₀ | Daten). Letzteres wäre eine Bayesianische Aussage, die einen Prior erfordert.


Aufgabe 2 – Normalverteilung prüfen

Python
laktat = df["laktat_mmol_l"].dropna()
w_stat, p_sw = stats.shapiro(laktat)
print(f"Shapiro-Wilk: W={w_stat:.4f}, p={p_sw:.2e}")
print(f"n={len(laktat)}, Schiefe={laktat.skew():.2f}")
print(f"Normalverteilung abgelehnt (p<0.05): {p_sw < 0.05}")
R
laktat <- df$laktat_mmol_l |> na.omit()
sw_res <- shapiro.test(laktat)
print(sw_res)
cat(sprintf("Schiefe (approx.) = %.2f\n",
            (mean(laktat) - median(laktat)) / sd(laktat)))

Befund: W ≈ 0,891, p < 0,001, die Normalverteilungshypothese wird klar abgelehnt. Laktat ist mit Schiefe ≈ 1,39 deutlich rechtsschief: die meisten Patient:innen haben niedrige Werte (~1–2 mmol/l), wenige haben sehr hohe Werte (septischer Schock).

Trennschärfe: Ab N > 200–300 ist Shapiro-Wilk so mächtig, dass er klinisch unwichtige Abweichungen als signifikant ausweist. Immer visualisieren (Histogramm, QQ-Plot) und den Shapiro-Test als ergänzendes Hilfsmittel sehen, nicht als alleinige Entscheidungsgrundlage.

Robustere Alternative: Der Mann-Whitney-U-Test (Wilcoxon-Rangsummentest) benötigt keine Verteilungsannahme und ist bei schiefen Daten wie Laktat oder CRP die sicherere Wahl.


Aufgabe 3 – Chi-Quadrat-Test

Python
import pandas as pd

# Binarize smoking status: active vs. former/never
df2 = df.copy()
df2["smoker_active"] = (df2["raucherstatus"] == "aktiv").astype(int)

ct = pd.crosstab(df2["smoker_active"], df2["verstorben_30d"],
                 rownames=["smoker_active"], colnames=["verstorben_30d"])
print(ct)

chi2, p_chi2, dof, _ = stats.chi2_contingency(ct, correction=False)
cramers_v = np.sqrt(chi2 / (ct.values.sum() * (min(ct.shape) - 1)))

print(f"χ²={chi2:.3f}, df={dof}, p={p_chi2:.4f}, Cramér's V={cramers_v:.3f}")
R
df2 <- df |> mutate(smoker_active = if_else(raucherstatus == "aktiv", 1L, 0L))
ct  <- table(smoker_active = df2$smoker_active, verstorben_30d = df2$verstorben_30d)
print(ct)

chi_res   <- chisq.test(ct, correct = FALSE)
print(chi_res)
cramers_v <- sqrt(chi_res$statistic / (sum(ct) * (min(dim(ct)) - 1)))
cat(sprintf("Cramér's V = %.3f\n", cramers_v))

Befund: χ² ≈ 0,355, df = 1, p ≈ 0,551, Cramér's V ≈ 0,027. Es gibt keinen statistisch bedeutsamen Zusammenhang zwischen aktivem Rauchen und 30-Tage-Mortalität in dieser Kohorte. Cramér's V nahe 0 bestätigt: der Effekt ist vernachlässigbar klein.

Klinische Interpretation: Abwesenheit von Evidenz ist keine Evidenz für Abwesenheit eines Effekts. Um einen kleinen, aber klinisch relevanten Effekt (z. B. Cramér's V ≈ 0,1) mit 80 % Power nachzuweisen, wäre eine deutlich größere Kohorte nötig. Konfounder wie Aufnahmegrund könnten den Effekt außerdem verdecken.


Aufgabe 4 – Konfidenzintervalle vergleichen

Python
groups = ["Sepsis", "Pneumonie", "Herzinsuffizienz"]

for group in groups:
    g  = df.loc[df["aufnahmegrund"] == group, "laktat_mmol_l"].dropna()
    n  = len(g)
    se = g.std() / np.sqrt(n)
    ci = stats.t.interval(0.95, df=n - 1, loc=g.mean(), scale=se)
    print(f"{group}: n={n}, Mittel={g.mean():.2f}, 95%-KI=[{ci[0]:.2f}, {ci[1]:.2f}]")
R
groups <- c("Sepsis", "Pneumonie", "Herzinsuffizienz")
for (gr in groups) {
  g  <- df |> filter(aufnahmegrund == gr) |> pull(laktat_mmol_l) |> na.omit()
  ci <- t.test(g, conf.level = 0.95)$conf.int
  cat(sprintf("%s: n=%d, Mittel=%.2f, 95%%-KI=[%.2f, %.2f]\n",
              gr, length(g), mean(g), ci[1], ci[2]))
}

Befund:

Gruppe n Mittel (mmol/l) 95-%-KI
Sepsis 87 2,38 [2,08; 2,69]
Herzinsuffizienz 90 2,33 [2,02; 2,65]
Pneumonie 105 2,00 [1,71; 2,28]

Die KI von Sepsis und Herzinsuffizienz überschneiden sich stark, ein formaler Test würde keinen signifikanten Unterschied ergeben. Überlappende KI der Einzelgruppen schließen Signifikanz jedoch nicht automatisch aus: Das KI der Differenz ist die korrekte Grundlage. Hier ist die Überlappung groß genug, um Skepsis zu rechtfertigen. Das niedrigste Laktat bei Pneumonie ist klinisch plausibel.


Aufgabe 5 – Multiples Testen

Python
from statsmodels.stats.multitest import multipletests

markers = ["leukozyten_g_l", "haemoglobin_g_dl", "kreatinin_mg_dl",
           "laktat_mmol_l", "natrium_mmol_l"]

results = []
for m in markers:
    alive = df.loc[df["verstorben_30d"] == 0, m].dropna()
    dead  = df.loc[df["verstorben_30d"] == 1, m].dropna()
    t_s, p_s = stats.ttest_ind(alive, dead, equal_var=False)
    pooled_sd = np.sqrt(((len(alive)-1)*alive.std()**2 + (len(dead)-1)*dead.std()**2) /
                        (len(alive) + len(dead) - 2))
    d_val = (alive.mean() - dead.mean()) / pooled_sd
    results.append({"marker": m, "t": t_s, "p_raw": p_s, "cohens_d": d_val})

import pandas as pd
res = pd.DataFrame(results)

_, p_bonf, _, _ = multipletests(res["p_raw"], method="bonferroni")
_, p_bh,   _, _ = multipletests(res["p_raw"], method="fdr_bh")
res["p_bonferroni"] = p_bonf
res["p_bh"]         = p_bh
res["sig_bonf"]     = p_bonf < 0.05
res["sig_bh"]       = p_bh   < 0.05

print(res[["marker", "t", "p_raw", "cohens_d", "p_bonferroni", "p_bh",
           "sig_bonf", "sig_bh"]].round(4).to_string(index=False))
R
suppressPackageStartupMessages(library(tidyverse))
markers <- c("leukozyten_g_l", "haemoglobin_g_dl", "kreatinin_mg_dl",
             "laktat_mmol_l", "natrium_mmol_l")

results <- map_dfr(markers, function(m) {
  alive <- df |> filter(verstorben_30d == 0) |> pull(!!sym(m)) |> na.omit()
  dead  <- df |> filter(verstorben_30d == 1) |> pull(!!sym(m)) |> na.omit()
  test  <- t.test(alive, dead, var.equal = FALSE)
  sp    <- sqrt(((length(alive)-1)*var(alive) + (length(dead)-1)*var(dead)) /
               (length(alive) + length(dead) - 2))
  tibble(marker = m, t = test$statistic, p_raw = test$p.value,
         cohens_d = (mean(alive) - mean(dead)) / sp)
})

results <- results |>
  mutate(p_bonferroni = p.adjust(p_raw, "bonferroni"),
         p_bh         = p.adjust(p_raw, "BH"),
         sig_bonf     = p_bonferroni < 0.05,
         sig_bh       = p_bh < 0.05)
print(results)

Befund: leukozyten_g_l zeigt einen deutlichen, hoch signifikanten Unterschied zwischen Überlebenden und Verstorbenen (t ≈ −5,77, p < 0,0001, Cohen's d ≈ −0,69, mittlerer bis großer Effekt), der auch nach Bonferroni- und Benjamini-Hochberg-Korrektur signifikant bleibt. Die übrigen vier Marker (Hämoglobin, Kreatinin, Laktat, Natrium) zeigen keinen signifikanten Unterschied (alle p_raw > 0,05, |d| < 0,25); laktat_mmol_l liegt mit p_raw ≈ 0,07 am nächsten an der Grenze, verliert die (ohnehin nicht erreichte) Signifikanz nach Korrektur aber vollständig.

Was auffällt: Anders als man vielleicht erwartet, trennen die Labormarker Überlebende von Verstorbenen nicht gleichmäßig schlecht, Leukozyten sind hier ein starker, robuster Prädiktor, während Laktat trotz seiner klinischen Prominenz in dieser Kohorte nur einen kleinen, nicht signifikanten Unterschied zeigt. sofa_score und alter bleiben zusätzlich relevante Prädiktoren (→ Modul 08 (Explorative Datenanalyse und Datenvisualisierung)). Bei 5 Tests ist der Unterschied zwischen Bonferroni und BH gering; bei 50 oder 500 Tests (Omics-Daten) wäre BH deutlich mächtiger.

Wann BH bevorzugen? Wenn viele explorative Hypothesen geprüft werden und man den Anteil falsch-positiver Befunde kontrollieren will, nicht jede einzelne Falsch-Positiv-Rate. In konfirmatorischen Studien mit wenigen vorab festgelegten Tests ist Bonferroni angemessener.


Bonus – Power-Analyse

Python
from statsmodels.stats.power import TTestIndPower

sepsis    = df.loc[df["aufnahmegrund"] == "Sepsis",  "laktat_mmol_l"].dropna()
no_sepsis = df.loc[df["aufnahmegrund"] != "Sepsis",  "laktat_mmol_l"].dropna()

_, p_t = stats.ttest_ind(sepsis, no_sepsis, equal_var=False)
pooled_sd = np.sqrt(((len(sepsis)-1)*sepsis.std()**2 +
                     (len(no_sepsis)-1)*no_sepsis.std()**2) /
                    (len(sepsis) + len(no_sepsis) - 2))
d_lac = (sepsis.mean() - no_sepsis.mean()) / pooled_sd

analysis = TTestIndPower()
# Observed power given actual sample sizes
power_obs = analysis.power(effect_size=abs(d_lac),
                            nobs1=len(sepsis),
                            ratio=len(no_sepsis) / len(sepsis),
                            alpha=0.05, alternative="two-sided")
# Required n per group for power = 0.8
n_needed = analysis.solve_power(effect_size=abs(d_lac), power=0.8,
                                 ratio=1.0, alpha=0.05)

print(f"Sepsis:    n={len(sepsis)}, mean={sepsis.mean():.2f}")
print(f"kein Sep.: n={len(no_sepsis)}, mean={no_sepsis.mean():.2f}")
print(f"Cohen's d={d_lac:.3f}, t-p={p_t:.4f}")
print(f"Observed power = {power_obs:.3f}")
print(f"n per group for power=0.8: ~{int(np.ceil(n_needed))}")
R
# install.packages("pwr") # once
library(pwr)

# Parameters from observed data (approximate)
d_lac    <- 0.197   # observed Cohen's d
n_sepsis <- 87
n_other  <- 327

# Harmonic mean n for unequal groups
n_harm <- 2 / (1/n_sepsis + 1/n_other)
power_res <- pwr.t.test(d = d_lac, n = n_harm / 2,
                         sig.level = 0.05, type = "two.sample",
                         alternative = "two.sided")
print(power_res)

# Required n per group for power = 0.8
pwr.t.test(d = d_lac, power = 0.8, sig.level = 0.05,
           type = "two.sample", alternative = "two.sided")

Befund: Der beobachtete Effekt ist klein (d ≈ 0,197, t-Test p ≈ 0,10, nicht signifikant). Die beobachtete Power bei den vorliegenden Stichprobengrößen (n_Sepsis ≈ 87, n_nicht-Sepsis ≈ 327) beträgt ~37 %. Für Power = 0,8 wären je Gruppe etwa n ≈ 405 erforderlich (prospektive Fallzahlplanung für eine künftige Studie mit diesem Effekt als Planungsannahme).

Was Power = 0,8 bedeutet: In 8 von 10 Wiederholungsstudien, unter der Annahme, dass der wahre Effekt d = 0,197 besteht, würde der Test korrekt die Nullhypothese ablehnen. Diese Aussage ist nur für die Planung einer neuen Studie sinnvoll.

Stolperstein — die „beobachtete Power" ist (fast) wertlos: Die oben berechnete Power aus dem tatsächlich gemessenen Effekt der bereits durchgeführten Studie als nachträgliche Erklärung für das nicht-signifikante Ergebnis zu verwenden ("die Studie war halt underpowered, deshalb kein p < 0,05") ist ein bekannter Zirkelschluss (Hoenig & Heisey 2001): Beobachtete Power ist eine deterministische, monoton fallende Funktion des p-Werts und liefert damit keine Information, die der p-Wert nicht schon enthält, ein nicht signifikantes Ergebnis erzeugt immer eine niedrig wirkende beobachtete Power, unabhängig davon, ob der wahre Effekt existiert oder nicht. Genau davor warnt Modul 13 (Studiendesign und Power) explizit: „Power nach dem Ergebnis ist meist wertlos. Nutze beobachtete Effekte nicht als nachträgliche Rechtfertigung." Richtig eingesetzt wird eine Power-Berechnung vor der Datenerhebung, mit einem a priori geplanten (nicht dem beobachteten) Effekt, zur Fallzahlplanung, so wie das n ≈ 405-Ergebnis oben für eine künftige Studie sinnvoll ist. Eine kleine Studie, die keinen Effekt findet, ist ein Designproblem (zu wenig Power, um einen echten kleinen Effekt zuverlässig zu erkennen), kein Beweis für „kein Effekt".