import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import statsmodels.formula.api as smf
import statsmodels.api as sm
from scipy.stats import chi2
from sklearn.metrics import confusion_matrix, classification_report, roc_curve, auc
42) np.random.seed(
Klassifikationsleistung bewerten
Im vorherigen Kapitel haben wir die logistische Regression kennengelernt und damit die Wahrscheinlichkeit modelliert, dass ein Pinguin zur Art Gentoo gehört, basierend auf seinem Körpergewicht. Das war jedoch die einfachstmögliche logistische Regression mit nur einer numerischen x-Variable.
Genauso wie bei unseren linearen Modellen können wir auch bei der logistischen Regression mehrere Effekte ins Modell aufnehmen. Als Beispiel erweitern wir unser Pinguin-Modell um eine kategorielle Variable: das Geschlecht. Wir werden also zwei Modelle vergleichen:
-
Modell 1:
species_binary ~ body_mass_g
(wie in Kapitel 5.1) -
Modell 2:
species_binary ~ body_mass_g + C(sex)
(neu)
# Palmer Penguins Datensatz laden und vorbereiten
= 'https://raw.githubusercontent.com/SchmidtPaul/ExampleData/refs/heads/main/palmer_penguins/palmer_penguins.csv'
csv_url = pd.read_csv(csv_url)
penguins
# Farbschema
= {'Adelie': '#FF8C00', 'Gentoo': '#159090'}
colors
# Daten für Adelie und Gentoo vorbereiten
= penguins[penguins['species'].isin(['Adelie', 'Gentoo'])].copy()
penguins_binary = penguins_binary.dropna(subset=['body_mass_g', 'sex'])
penguins_binary
# Binäre Kodierung: Adelie = 0, Gentoo = 1
'species_binary'] = penguins_binary['species'].map({'Adelie': 0, 'Gentoo': 1}) penguins_binary[
Modelle anpassen
Modell 1
Beginnen wir dort, wo wir im letzten Kapitel aufgehört haben, mit demselben Datensatz und denselben beiden Arten.
# Modell 1: Nur Körpergewicht (wie in Kapitel 5.1)
= smf.logit('species_binary ~ body_mass_g', data=penguins_binary)
model1 = model1.fit()
result1
print(result1.summary())
Optimization terminated successfully.
Current function value: 0.181899
Iterations 9
Logit Regression Results
==============================================================================
Dep. Variable: species_binary No. Observations: 265
Model: Logit Df Residuals: 263
Method: MLE Df Model: 1
Date: Di, 19 Aug 2025 Pseudo R-squ.: 0.7356
Time: 11:46:01 Log-Likelihood: -48.203
converged: True LL-Null: -182.31
Covariance Type: nonrobust LLR p-value: 2.793e-60
===============================================================================
coef std err z P>|z| [0.025 0.975]
-------------------------------------------------------------------------------
Intercept -27.9475 4.028 -6.938 0.000 -35.842 -20.053
body_mass_g 0.0063 0.001 6.950 0.000 0.005 0.008
===============================================================================
Code zeigen/verstecken
# Modell 1 visualisieren (wie in Kapitel 5.1)
= plt.subplots(figsize=(9, 5), layout='tight')
fig, ax
# Originaldaten
= penguins_binary[penguins_binary['species'] == 'Adelie']
adelie_data = penguins_binary[penguins_binary['species'] == 'Gentoo']
gentoo_data
'body_mass_g'], adelie_data['species_binary'],
ax.scatter(adelie_data[=0.6, color=colors['Adelie'], label='Adelie', s=50)
alpha'body_mass_g'], gentoo_data['species_binary'],
ax.scatter(gentoo_data[=0.6, color=colors['Gentoo'], label='Gentoo', s=50)
alpha
# Logistische Kurve
= np.linspace(penguins_binary['body_mass_g'].min(),
x_range 'body_mass_g'].max(), 200)
penguins_binary[= result1.predict(pd.DataFrame({'body_mass_g': x_range}))
predictions
'red', linewidth=3, label='Logistische Regression')
ax.plot(x_range, predictions, =0.5, color='gray', linestyle='--', alpha=0.7, label='50%-Marke')
ax.axhline(y
'Körpergewicht (g)')
ax.set_xlabel('Wahrscheinlichkeit P(Gentoo)')
ax.set_ylabel('Modell 1: Nur Körpergewicht')
ax.set_title(
ax.legend()True, alpha=0.3)
ax.grid(
plt.show()
Modell 2
Nun wollen wir das Geschlecht als zusätzlichen Faktor einbeziehen. Um dies zu visualisieren, können wir die Punkte durch unterschiedliche Symbole darstellen - allerdings würden wir bei so vielen Punkten die Symbolformen kaum wahrnehmen. Deshalb wenden wir einen kleinen Visualisierungs-Trick an: Wir verschieben männliche und weibliche Punkte leicht auf der y-Achse, damit die Symbole nicht überlappen. Natürlich liegen alle Punkte eigentlich nur exakt auf 0 oder 1, aber diese Darstellung bringt einen klaren Mehrwert für das Verständnis. Das gleiche gilt dann auch für die jeweilige Kurve.
# Modell 2: Körpergewicht + Geschlecht
= smf.logit('species_binary ~ body_mass_g + C(sex)', data=penguins_binary)
model2 = model2.fit() result2
Optimization terminated successfully.
Current function value: 0.010528
Iterations 16
Code zeigen/verstecken
# Erweiterte Visualisierung mit Geschlecht
= plt.subplots(figsize=(9, 5), layout='tight')
fig, ax
# Leichter Versatz für bessere Sichtbarkeit
= 0.01
offset
# Datenpunkte mit verschiedenen Symbolen und leichtem Versatz
for species in ['Adelie', 'Gentoo']:
for sex in ['male', 'female']:
= penguins_binary[
data_subset 'species'] == species) &
(penguins_binary['sex'] == sex)
(penguins_binary[
]
if len(data_subset) > 0:
# Leichter Versatz je nach Geschlecht
= data_subset['species_binary'].copy()
y_values if sex == 'male':
= y_values + offset
y_values else:
= y_values - offset
y_values
# Symbol je nach Geschlecht
= 'o' if sex == 'male' else '^'
marker
'body_mass_g'], y_values,
ax.scatter(data_subset[=0.6, color=colors[species],
alpha=marker, s=50,
marker=f'{species} {sex}')
label
# Separate Kurven für männlich und weiblich mit Versatz
= np.linspace(penguins_binary['body_mass_g'].min(),
x_range 'body_mass_g'].max(), 200)
penguins_binary[
for sex in ['male', 'female']:
= pd.DataFrame({'body_mass_g': x_range, 'sex': sex})
pred_data = result2.predict(pred_data)
predictions
# Versatz für die Kurven
if sex == 'male':
+= offset
predictions else:
-= offset
predictions
= '--' if sex == 'male' else ':'
linestyle 'red', linewidth=3,
ax.plot(x_range, predictions, =linestyle, label=f'Vorhersage {sex}')
linestyle
=0.5, color='gray', linestyle=':', alpha=0.7)
ax.axhline(y
'Körpergewicht (g)')
ax.set_xlabel('Wahrscheinlichkeit P(Gentoo)')
ax.set_ylabel('Modell 2: Körpergewicht + Geschlecht')
ax.set_title(
ax.legend()True, alpha=0.3)
ax.grid(
plt.show()
In der Abbildung sehen wir die zwei parallelen Sigmoid-Kurven: eine gestrichelte Linie für männliche Pinguine und eine gepunktete für weibliche. Diese Verschiebung der Kurven zeigt den Effekt des Geschlechts.
Interpretation des Geschlechts-Effekts
Schauen wir uns die Koeffizienten systematisch an. Der Koeffizient C(sex)[T.male]
zeigt den Effekt des männlichen Geschlechts, wobei die Dummy-Codierung “female” als Referenzkategorie verwendet.
# Modell-Zusammenfassung anzeigen
print(result2.summary())
Logit Regression Results
==============================================================================
Dep. Variable: species_binary No. Observations: 265
Model: Logit Df Residuals: 262
Method: MLE Df Model: 2
Date: Di, 19 Aug 2025 Pseudo R-squ.: 0.9847
Time: 11:46:02 Log-Likelihood: -2.7899
converged: True LL-Null: -182.31
Covariance Type: nonrobust LLR p-value: 1.089e-78
==================================================================================
coef std err z P>|z| [0.025 0.975]
----------------------------------------------------------------------------------
Intercept -216.1125 158.246 -1.366 0.172 -526.270 94.045
C(sex)[T.male] -46.3532 33.610 -1.379 0.168 -112.227 19.521
body_mass_g 0.0551 0.040 1.365 0.172 -0.024 0.134
==================================================================================
Possibly complete quasi-separation: A fraction 0.95 of observations can be
perfectly predicted. This might indicate that there is complete
quasi-separation. In this case some parameters will not be identified.
Genau wie bei linearen Modellen wird für die Referenzgruppe (weiblich) 0 addiert, für die andere Gruppe (männlich) wird der geschätzte Koeffizient addiert. Das passiert auf der Logit-Skala:
- Weiblicher Pinguin: Logit = β₀ + β₁ × Körpergewicht + 0
- Männlicher Pinguin: Logit = β₀ + β₁ × Körpergewicht + β₂
# Koeffizienten extrahieren
= result2.params['Intercept']
beta0 = result2.params['body_mass_g']
beta1 = result2.params['C(sex)[T.male]']
beta2
print("Koeffizienten des erweiterten Modells:")
print(f"β₀ (Intercept): {beta0:.4f}")
print(f"β₁ (Körpergewicht): {beta1:.6f}")
print(f"β₂ (männlich): {beta2:.4f}")
Koeffizienten des erweiterten Modells:
β₀ (Intercept): -216.1125
β₁ (Körpergewicht): 0.055054
β₂ (männlich): -46.3532
Konkrete Beispielrechnung
Betrachten wir zwei Pinguine mit identischem Körpergewicht (4500g), aber unterschiedlichem Geschlecht:
# Beispielrechnung für 4500g Pinguine
= 4500
mass_example
# Weiblicher Pinguin
= beta0 + beta1 * mass_example + 0 # + 0 für Referenzgruppe
logit_female = 1 / (1 + np.exp(-logit_female))
prob_female
# Männlicher Pinguin
= beta0 + beta1 * mass_example + beta2 # + β₂ für männlich
logit_male = 1 / (1 + np.exp(-logit_male))
prob_male
print(f"4500g Pinguin - Geschlechtsvergleich:")
print(f"Weiblich: Logit = {logit_female:.4f} → P(Gentoo) = {prob_female:.4f} ({prob_female*100:.1f}%)")
print(f"Männlich: Logit = {logit_male:.4f} → P(Gentoo) = {prob_male:.4f} ({prob_male*100:.1f}%)")
print(f"Differenz: {logit_male - logit_female:.4f} (entspricht genau β₂)")
4500g Pinguin - Geschlechtsvergleich:
Weiblich: Logit = 31.6311 → P(Gentoo) = 1.0000 (100.0%)
Männlich: Logit = -14.7220 → P(Gentoo) = 0.0000 (0.0%)
Differenz: -46.3532 (entspricht genau β₂)
Der Koeffizient β₂ ist genau die konstante Verschiebung zwischen den beiden Kurven auf der Logit-Skala. Diese Verschiebung ist über alle Körpergewichte hinweg gleich, was zu den parallelen Sigmoid-Kurven führt, die wir in der Abbildung sehen.
# Verschiebung für verschiedene Gewichte demonstrieren
= [4000, 4500, 5000]
test_weights
print("Konstante Logit-Verschiebung über alle Gewichte:")
for weight in test_weights:
= beta0 + beta1 * weight + 0
logit_f = beta0 + beta1 * weight + beta2
logit_m = logit_m - logit_f
difference print(f"{weight}g: Differenz = {difference:.4f}")
Konstante Logit-Verschiebung über alle Gewichte:
4000g: Differenz = -46.3532
4500g: Differenz = -46.3532
5000g: Differenz = -46.3532
Schließlich hier mal wieder ein Beispiel von desmos.com, bei dem wir den prinzipiellen Einfluß unserer Variablen - wenn auch mit ganz anderen Werten - auf die Kurve sehen können:
Warnung: Ein “zu gutes” Modell?
Bei Modell 2 erhalten wir eine Warnung: “Possibly complete quasi-separation”. Das ist ein potenzielles Problem, das darauf hinweist, dass unser Modell die Daten fast perfekt trennen kann. Dies passiert hier, weil die Kombination aus Körpergewicht und Geschlecht eine nahezu perfekte Vorhersage der Pinguinart ermöglicht - es gibt kaum noch Überlappungsbereiche zwischen den Gruppen.
# Detailanalyse der Datenverteilung
print("Gewichtsverteilung nach Art und Geschlecht:")
= penguins_binary.groupby(['species', 'sex'])['body_mass_g'].agg(['min', 'max', 'mean']).round(0)
weight_analysis print(weight_analysis)
Gewichtsverteilung nach Art und Geschlecht:
min max mean
species sex
Adelie female 2850.0 3900.0 3369.0
male 3325.0 4775.0 4043.0
Gentoo female 3950.0 5200.0 4680.0
male 4750.0 6300.0 5485.0
Die biologische Erklärung: Sexualdimorphismus (Größenunterschiede zwischen Männchen und Weibchen) kombiniert mit Artunterschieden führt dazu, dass die vier Gruppen (Adelie ♂/♀, Gentoo ♂/♀) sehr unterschiedliche Gewichtsbereiche haben.
Modellvergleich
Welches Modell ist objektiv besser? Betrachten wir verschiedene uns bekannte Bewertungsmetriken:
# Modellvergleich: Statistische Maße
print("MODELLVERGLEICH")
print("=" * 50)
print(f"Modell 1 - Log-Likelihood: {result1.llf:.3f}")
print(f"Modell 1 - AIC: {result1.aic:.3f}")
print(f"Modell 1 - BIC: {result1.bic:.3f}")
print(f"Modell 1 - Pseudo R²: {result1.prsquared:.3f}")
print(f"\nModell 2 - Log-Likelihood: {result2.llf:.3f}")
print(f"Modell 2 - AIC: {result2.aic:.3f}")
print(f"Modell 2 - BIC: {result2.bic:.3f}")
print(f"Modell 2 - Pseudo R²: {result2.prsquared:.3f}")
# Likelihood-Ratio-Test
= 2 * (result2.llf - result1.llf)
lr_stat = result2.df_model - result1.df_model
df_diff = 1 - chi2.cdf(lr_stat, df_diff)
p_value
print(f"\nLikelihood-Ratio-Test:")
print(f"LR-Statistik: {lr_stat:.3f}")
print(f"p-Wert: {p_value:.3f}")
MODELLVERGLEICH
==================================================
Modell 1 - Log-Likelihood: -48.203
Modell 1 - AIC: 100.407
Modell 1 - BIC: 107.566
Modell 1 - Pseudo R²: 0.736
Modell 2 - Log-Likelihood: -2.790
Modell 2 - AIC: 11.580
Modell 2 - BIC: 22.319
Modell 2 - Pseudo R²: 0.985
Likelihood-Ratio-Test:
LR-Statistik: 90.827
p-Wert: 0.000
Alle statistischen Maße sprechen klar für Modell 2: deutlich höheres Pseudo-R², niedrigere AIC/BIC-Werte und ein hochsignifikanter Likelihood-Ratio-Test1.
Klassifikationsleistung bewerten
Während statistische Maße wie AIC und Pseudo-R² die Modellgüte aus wahrscheinlichkeitstheoretischer Sicht bewerten, ist in Machine Learning-Anwendungen oft die Klassifikationsleistung entscheidend. Hier transformieren wir die Wahrscheinlichkeitsschätzungen in konkrete Entscheidungen und bewerten, wie gut unsere Modelle bei der tatsächlichen Klassifikation abschneiden.
Der Übergang von Wahrscheinlichkeiten zu Klassifikationen erfolgt wie gesagt durch einen Schwellenwert (meist 0.5): Wahrscheinlichkeiten ≥ 0.5 werden als “Gentoo” klassifiziert, Werte < 0.5 als “Adelie”. Diese scheinbar simple Entscheidung hat weitreichende Konsequenzen für die Bewertung der Modellleistung.
Schauen wir also mal wie oft unser Modell die Arten korrekt klassifiziert.
# Vorhersagen für beide Modelle
= result1.predict(penguins_binary)
prob1 = result2.predict(penguins_binary)
prob2
# Binäre Klassifikationen (Schwellenwert 0.5)
= (prob1 >= 0.5).astype(int)
pred1 = (prob2 >= 0.5).astype(int)
pred2
# Wahre Werte
= penguins_binary['species_binary'] y_true
Wir können auch hier mal eine Versuch starten zu visualisieren welche Punkte genau laut unseren Modellen falsch klassifiziert wurden. In der folgenden Abbildung sind all die Punkte, die laut Modell genau der falschen Art zugeordnet wurden, zusätzlich auf der Kurve eingezeichnet und mit einer vertikalen Linie mit dem eigentlichen Punkt verbunden:
Code zeigen/verstecken
# Visualisierung der falsch klassifizierten Punkte
= plt.subplots(1, 2, figsize=(15, 6), layout='tight')
fig, axes
# Leichter Versatz für bessere Sichtbarkeit
= 0.01
offset
# SUBPLOT 1: Modell 1
= axes[0]
ax
# Falsch klassifizierte Punkte - vertikale Linien zuerst (hinter allem)
= pred1 != y_true
wrong_indices = penguins_binary[wrong_indices]
wrong_data
if len(wrong_data) > 0:
for _, row in wrong_data.iterrows():
= result1.predict(pd.DataFrame({'body_mass_g': [row['body_mass_g']]}))[0]
pred_val = colors[row['species']]
species_color
# Vertikale Linie in der gleichen Farbe (hinter allem)
'body_mass_g'], row['body_mass_g']],
ax.plot([row['species_binary'], pred_val],
[row[=species_color, linewidth=2, alpha=0.8, zorder=1);
color
# Logistische Kurve
= np.linspace(penguins_binary['body_mass_g'].min(),
x_range 'body_mass_g'].max(), 200)
penguins_binary[= result1.predict(pd.DataFrame({'body_mass_g': x_range}));
predictions_curve 'red', linewidth=3, zorder=3);
ax.plot(x_range, predictions_curve,
# Alle Datenpunkte (halbtransparent)
for species in ['Adelie', 'Gentoo']:
= penguins_binary[penguins_binary['species'] == species]
data_subset 'body_mass_g'], data_subset['species_binary'],
ax.scatter(data_subset[=0.5, color=colors[species], s=50, zorder=2);
alpha
# Falsch klassifizierte Punkte auf der Kurve (über der roten Linie)
if len(wrong_data) > 0:
for _, row in wrong_data.iterrows():
= result1.predict(pd.DataFrame({'body_mass_g': [row['body_mass_g']]}))[0]
pred_val = colors[row['species']]
species_color
# Punkt auf der Kurve in der richtigen Farbe
'body_mass_g'], pred_val,
ax.scatter(row[=species_color, s=50, alpha=0.5, zorder=4);
color
=0.5, color='gray', linestyle='--', alpha=0.7, zorder=2);
ax.axhline(y'Körpergewicht (g)');
ax.set_xlabel('Wahrscheinlichkeit P(Gentoo)');
ax.set_ylabel('Modell 1: Falsch klassifizierte Punkte');
ax.set_title(True, alpha=0.3, zorder=0);
ax.grid(
# SUBPLOT 2: Modell 2
= axes[1]
ax
# Falsch klassifizierte Punkte - vertikale Linien zuerst (hinter allem)
= pred2 != y_true
wrong_indices2 = penguins_binary[wrong_indices2]
wrong_data2
if len(wrong_data2) > 0:
for _, row in wrong_data2.iterrows():
= result2.predict(pd.DataFrame({'body_mass_g': [row['body_mass_g']],
pred_val 'sex': [row['sex']]}))[0]
= colors[row['species']]
species_color
# Versatz für ursprünglichen Punkt
= row['species_binary']
original_y if row['sex'] == 'male':
+= offset
original_y else:
-= offset
original_y
# Versatz für Punkt auf der Kurve
= pred_val
curve_y if row['sex'] == 'male':
+= offset
curve_y else:
-= offset
curve_y
# Vertikale Linie in der gleichen Farbe (hinter allem)
'body_mass_g'], row['body_mass_g']],
ax.plot([row[
[original_y, curve_y], =species_color, linewidth=2, alpha=0.8, zorder=1);
color
# Vorhersagekurven mit Versatz
for sex in ['male', 'female']:
= pd.DataFrame({'body_mass_g': x_range, 'sex': sex})
pred_data = result2.predict(pred_data)
predictions_curve
# Versatz für die Kurven
if sex == 'male':
+= offset
predictions_curve else:
-= offset
predictions_curve
= '--' if sex == 'male' else ':'
linestyle 'red', linewidth=3,
ax.plot(x_range, predictions_curve, =linestyle, zorder=3);
linestyle
# Alle Datenpunkte mit Geschlechter-Symbolen und Versatz (halbtransparent)
for species in ['Adelie', 'Gentoo']:
for sex in ['male', 'female']:
= penguins_binary[
data_subset 'species'] == species) &
(penguins_binary['sex'] == sex)
(penguins_binary[
]
if len(data_subset) > 0:
# Leichter Versatz je nach Geschlecht
= data_subset['species_binary'].copy()
y_values if sex == 'male':
= y_values + offset
y_values else:
= y_values - offset
y_values
# Symbol je nach Geschlecht
= 'o' if sex == 'male' else '^'
marker
'body_mass_g'], y_values,
ax.scatter(data_subset[=0.5, color=colors[species],
alpha=marker, s=50, zorder=2);
marker
# Falsch klassifizierte Punkte auf den Kurven (über den roten Linien)
if len(wrong_data2) > 0:
for _, row in wrong_data2.iterrows():
= result2.predict(pd.DataFrame({'body_mass_g': [row['body_mass_g']],
pred_val 'sex': [row['sex']]}))[0]
= colors[row['species']]
species_color
# Versatz für Punkt auf der Kurve
if row['sex'] == 'male':
+= offset
pred_val else:
-= offset
pred_val
# Punkt auf der entsprechenden Kurve in der richtigen Farbe
= 'o' if row['sex'] == 'male' else '^'
marker 'body_mass_g'], pred_val,
ax.scatter(row[=species_color, s=50, marker=marker, alpha=0.5, zorder=4);
color
=0.5, color='gray', linestyle=':', alpha=0.7, zorder=2);
ax.axhline(y'Körpergewicht (g)');
ax.set_xlabel('Wahrscheinlichkeit P(Gentoo)');
ax.set_ylabel('Modell 2: Falsch klassifizierte Punkte');
ax.set_title(True, alpha=0.3, zorder=0)
ax.grid(
plt.show()
Confusion Matrix: Die Grundlage aller Klassifikationsmetriken
Wenn wir aber nun anfangen auszuzählen wie oft das Modell korrekt und falsch klassifiziert haben, dann sollten wir es auch gleich richtig machen - nämlich mit der Confusion Matrix. Die Confusion Matrix (Konfusionsmatrix) ist das Herzstück der Klassifikationsbewertung. Sie zeigt alle vier möglichen Kombinationen von tatsächlichen und vorhergesagten Klassen in einer 2×2-Tabelle. Jede Zelle hat eine spezifische Bedeutung:
- True Positives (TP): Korrekt als Gentoo klassifizierte Gentoo-Pinguine
-
True Negatives (TN): Korrekt als Adelie klassifizierte Adelie-Pinguine
- False Positives (FP): Fälschlicherweise als Gentoo klassifizierte Adelie-Pinguine
- False Negatives (FN): Fälschlicherweise als Adelie klassifizierte Gentoo-Pinguine
# Confusion Matrices
= confusion_matrix(y_true, pred1)
cm1 = confusion_matrix(y_true, pred2)
cm2
print("Confusion Matrix - Modell 1:")
print(" Vorhergesagt")
print(" Adelie Gentoo")
print(f"Tatsächlich")
print(f"Adelie {cm1[0,0]:6d} {cm1[0,1]:6d}")
print(f"Gentoo {cm1[1,0]:6d} {cm1[1,1]:6d}")
print("\nConfusion Matrix - Modell 2:")
print(" Vorhergesagt")
print(" Adelie Gentoo")
print(f"Tatsächlich")
print(f"Adelie {cm2[0,0]:6d} {cm2[0,1]:6d}")
print(f"Gentoo {cm2[1,0]:6d} {cm2[1,1]:6d}")
Confusion Matrix - Modell 1:
Vorhergesagt
Adelie Gentoo
Tatsächlich
Adelie 135 11
Gentoo 13 106
Confusion Matrix - Modell 2:
Vorhergesagt
Adelie Gentoo
Tatsächlich
Adelie 145 1
Gentoo 1 118
Die Confusion Matrix ist deshalb so wertvoll, weil sie nicht nur die Gesamtleistung zeigt, sondern auch welche Art von Fehlern das Modell macht. In unserem Fall sehen wir, dass Modell 1 gelegentlich beide Arten verwechselt (11 FP, 13 FN), während Modell 2 fast perfekt ist (nur 1 FP, 1 FN).
# Berechnung der Metriken
def calculate_metrics(cm):
= cm.ravel()
tn, fp, fn, tp
= tp / (tp + fn) # True Positive Rate, Recall
sensitivity = tn / (tn + fp) # True Negative Rate
specificity = tp / (tp + fp) # Positive Predictive Value
precision = (tp + tn) / (tp + tn + fp + fn)
accuracy
return sensitivity, specificity, precision, accuracy
= calculate_metrics(cm1)
sens1, spec1, prec1, acc1 = calculate_metrics(cm2)
sens2, spec2, prec2, acc2
print("KLASSIFIKATIONSMETRIKEN")
print("=" * 50)
print(" Modell 1 Modell 2")
print(f"Sensitivity (Recall) {sens1:.3f} {sens2:.3f}")
print(f"Specificity {spec1:.3f} {spec2:.3f}")
print(f"Precision {prec1:.3f} {prec2:.3f}")
print(f"Accuracy {acc1:.3f} {acc2:.3f}")
KLASSIFIKATIONSMETRIKEN
==================================================
Modell 1 Modell 2
Sensitivity (Recall) 0.891 0.992
Specificity 0.925 0.993
Precision 0.906 0.992
Accuracy 0.909 0.992
Sensitivity und Specificity: Fokus auf einzelne Klassen
Sensitivity (auch Recall oder True Positive Rate genannt) misst den Anteil der korrekt identifizierten positiven Fälle. In unserem Kontext: “Von allen Gentoo-Pinguinen, wie viele hat das Modell richtig erkannt?” Modell 1 erreicht 89.1%, Modell 2 beeindruckende 99.2%. Sensitivity ist besonders wichtig in Anwendungen, wo das Übersehen positiver Fälle schwerwiegende Folgen hat (z.B. Krankheitsdiagnose).
Specificity (True Negative Rate) ist das Pendant für negative Fälle: “Von allen Adelie-Pinguinen, wie viele wurden korrekt als solche erkannt?” Beide Modelle zeigen hier exzellente Werte (92.5% bzw. 99.3%). Specificity ist entscheidend, wenn False Positives problematisch sind (z.B. bei Spam-Filtern, wo wichtige E-Mails nicht fälschlicherweise blockiert werden sollen).
Das Verhältnis zwischen Sensitivity und Specificity ist oft ein Trade-off: Erhöht man den Schwellenwert, steigt die Specificity (weniger False Positives), aber die Sensitivity sinkt (mehr False Negatives). Dieses Spannungsfeld ist zentral für die Optimierung von Klassifikationsmodellen.
Precision: Die Verlässlichkeit positiver Vorhersagen
Precision (auch Positive Predictive Value) beantwortet eine andere Frage: “Von allen als Gentoo klassifizierten Pinguinen, wie viele sind tatsächlich Gentoo?” Mit 90.6% (Modell 1) bzw. 99.2% (Modell 2) zeigen beide Modelle hohe Präzision.
Der Unterschied zwischen Precision und Sensitivity wird oft missverstanden: Sensitivity fragt “Wie viele der echten Gentoos habe ich gefunden?”, während Precision fragt “Wie viele meiner Gentoo-Vorhersagen sind richtig?”. In unausgewogenen Datensätzen kann diese Unterscheidung kritisch werden.
Accuracy: Der intuitive, aber oft irreführende Gesamtwert
Accuracy ist die wohl intuitivste Metrik: “Wie viele Vorhersagen insgesamt waren richtig?” Beide Modelle zeigen hervorragende Accuracy-Werte (90.9% bzw. 99.2%). Allerdings ist Accuracy bei unausgewogenen Datensätzen problematisch. Wenn 95% der Fälle zur Mehrheitsklasse gehören, erzielt ein “dummer” Klassifikator, der immer die Mehrheitsklasse vorhersagt, bereits 95% Accuracy. In unserem Fall ist der Datensatz relativ ausgewogen (146 Adelie, 119 Gentoo), weshalb Accuracy hier aussagekräftig ist.
ROC-Kurve und AUC: Schwellenwert-unabhängige Bewertung
Nun haben wir schon so einiges ausgerechnet und verglichen, aber was wir weiterhin nicht verändert haben ist der Schwellenwert von 0.5, der die Wahrscheinlichkeiten in binäre Klassifikationen umwandelt. Auch dieser ist ja letztendlich eine Stellschraube, die die Performance des jeweiligen Modells beeinflusst. Anstatt nun noch ein paar weitere Schwellenwerte auszuprobieren, probieren wir direkt alle und fassen das Ergebnis visuell zusammen:
# ROC-Kurven
= roc_curve(y_true, prob1);
fpr1, tpr1, _ = roc_curve(y_true, prob2);
fpr2, tpr2, _
= auc(fpr1, tpr1);
auc1 = auc(fpr2, tpr2);
auc2
= plt.subplots(figsize=(8, 6), layout='tight')
fig, ax
=2, alpha=0.5, label=f'Modell 1 (AUC = {auc1:.3f})');
ax.plot(fpr1, tpr1, linewidth=2, alpha=0.5, label=f'Modell 2 (AUC = {auc2:.3f})');
ax.plot(fpr2, tpr2, linewidth0, 1], [0, 1], 'k--', alpha=0.5, label='Zufall (AUC = 0.5)');
ax.plot([
'False Positive Rate (1 - Specificity)');
ax.set_xlabel('True Positive Rate (Sensitivity)');
ax.set_ylabel('ROC-Kurven: Modellvergleich');
ax.set_title(;
ax.legend()True, alpha=0.3)
ax.grid(
plt.show()
print(f"AUC-Werte:")
print(f"Modell 1: {auc1:.3f}")
print(f"Modell 2: {auc2:.3f}")
AUC-Werte:
Modell 1: 0.979
Modell 2: 1.000
Die ROC-Kurve (Receiver Operating Characteristic) ist ein mächtiges Werkzeug, das die Leistung über alle möglichen Schwellenwerte hinweg visualisiert. Sie zeigt das Verhältnis von True Positive Rate (Sensitivity) zu False Positive Rate (1 - Specificity). Eine perfekte ROC-Kurve würde durch die Punkte (0,0), (0,1), (1,1) verlaufen - ein rechter Winkel in der oberen linken Ecke.
AUC (Area Under the Curve) fasst die ROC-Kurve in einer einzigen Zahl zusammen, indem die Fläche unter der Kurve berechnet wird. Ein AUC von 1.0 bedeutet perfekte Klassifikation, 0.5 entspricht purem Zufall. Modell 1 erreicht bereits exzellente 0.979, Modell 2 ist mit 1.0 praktisch perfekt.
Der große Vorteil der AUC liegt darin, dass sie als einzelner Kennwert die gesamte ROC-Kurve zusammenfasst und damit die Schwellenwert-Unabhängigkeit der Modellleistung kompakt quantifiziert. Das macht sie besonders nützlich für den Vergleich und die Auswahl von Modellen.
Precision-Recall-Kurve: Alternative für unausgewogene Datensätze
Neben der ROC-Kurve gibt es eine weitere nennenswerte Bewertungsmethode: die Precision-Recall-Kurve. Diese zeigt das Verhältnis von Precision zu Recall (Sensitivity) über alle möglichen Schwellenwerte. Während ROC-Kurven bei stark unausgewogenen Datensätzen irreführend optimistisch sein können, bleiben Precision-Recall-Kurven auch in solchen Fällen aussagekräftig.
from sklearn.metrics import precision_recall_curve, average_precision_score
# Precision-Recall-Kurven
= precision_recall_curve(y_true, prob1)
prec1, rec1, _ = precision_recall_curve(y_true, prob2)
prec2, rec2, _
# Average Precision Score (Fläche unter PR-Kurve)
= average_precision_score(y_true, prob1)
ap1 = average_precision_score(y_true, prob2)
ap2
= plt.subplots(figsize=(8, 6), layout='tight')
fig, ax
=2, alpha=0.5, label=f'Modell 1 (AP = {ap1:.3f})');
ax.plot(rec1, prec1, linewidth=2, alpha=0.5, label=f'Modell 2 (AP = {ap2:.3f})');
ax.plot(rec2, prec2, linewidth
# Baseline für zufällige Klassifikation
= len(y_true[y_true==1]) / len(y_true)
baseline =baseline, color='gray', linestyle='--', alpha=0.7,
ax.axhline(y=f'Baseline (AP = {baseline:.3f})');
label
'Recall (Sensitivity)');
ax.set_xlabel('Precision');
ax.set_ylabel('Precision-Recall-Kurven: Modellvergleich');
ax.set_title(;
ax.legend()True, alpha=0.3)
ax.grid(
plt.show()
print(f"Average Precision Scores:")
print(f"Modell 1: {ap1:.3f}")
print(f"Modell 2: {ap2:.3f}")
print(f"Baseline (Zufall): {baseline:.3f}")
Average Precision Scores:
Modell 1: 0.974
Modell 2: 1.000
Baseline (Zufall): 0.449
Wann ROC vs. Precision-Recall verwenden?
ROC-Kurven sind ideal bei ausgewogenen Datensätzen und wenn sowohl True Positives als auch True Negatives gleich wichtig sind. Sie zeigen die Gesamttrennfähigkeit sehr gut.
Precision-Recall-Kurven sind besser bei unausgewogenen Datensätzen geeignet, besonders wenn die positive Klasse selten ist. Sie fokussieren auf die Leistung bei der Minderheitsklasse und sind weniger von der großen Anzahl True Negatives beeinflusst.
Beispiel: Bei einem Datensatz mit 95% negativen und 5% positiven Fällen kann ein Klassifikator mit 95% Accuracy alle Fälle als negativ klassifizieren. Die ROC-Kurve würde gut aussehen (hohe Specificity), aber die Precision-Recall-Kurve würde die schlechte Leistung bei der wichtigen Minderheitsklasse enthüllen.
In unserem relativ ausgewogenen Pinguin-Datensatz (55% Adelie, 45% Gentoo) zeigen beide Kurventypen ähnliche Ergebnisse. Dennoch ist es wichtig, beide Perspektiven zu kennen, da sie unterschiedliche Aspekte der Modellleistung beleuchten.
Die Landschaft der Metriken: Wann welche verwenden?
Die verschiedenen Metriken ergänzen sich und haben unterschiedliche Stärken:
- Confusion Matrix: Liefert das vollständige Bild aller Fehlertypen
- Sensitivity: Wichtig bei hohen Kosten für False Negatives (z.B. Krebsdiagnose)
- Specificity: Entscheidend bei hohen Kosten für False Positives (z.B. Terrorismus-Screening)
- Precision: Relevant bei ressourcenbegrenzten Nachfolgeaktionen (z.B. Marketingkampagnen)
- Accuracy: Sinnvoll bei ausgewogenen Datensätzen und gleichen Kosten für alle Fehlertypen
- AUC (ROC): Ideal für Modellvergleiche bei ausgewogenen Datensätzen
- Average Precision (PR): Besser für unausgewogene Datensätze und Fokus auf Minderheitsklasse
In unserem Fall zeigen alle Metriken eindeutig die Überlegenheit von Modell 2. Die nahezu perfekten Werte (alle >99%) bestätigen jedoch auch unsere Warnung bezüglich quasi-complete separation: Das Modell ist möglicherweise zu spezifisch für unseren Datensatz optimiert.
sklearn: Vorgefertigte Funktionen für effiziente Analyse
Die meisten der Berechnungen, die wir manuell durchgeführt haben, können mit sklearn-Funktionen deutlich effizienter erledigt werden. Die scikit-learn-Bibliothek ist ja wie gesagt mehr auf Machine Learning ausgereichtet und bietet vorgefertigte Funktionen für praktisch alle Klassifikationsmetriken:
from sklearn.metrics import (classification_report, roc_auc_score,
average_precision_score, confusion_matrix,
RocCurveDisplay, PrecisionRecallDisplay,
ConfusionMatrixDisplay)
print(classification_report(y_true, pred1,
=['Adelie', 'Gentoo'],
target_names=3))
digits
print(f"ROC AUC: {roc_auc_score(y_true, prob1):.3f}")
print(f"Precision-Recall AUC: {average_precision_score(y_true, prob1):.3f}")
precision recall f1-score support
Adelie 0.912 0.925 0.918 146
Gentoo 0.906 0.891 0.898 119
accuracy 0.909 265
macro avg 0.909 0.908 0.908 265
weighted avg 0.909 0.909 0.909 265
ROC AUC: 0.979
Precision-Recall AUC: 0.974
# Confusion Matrix Plot
= plt.subplots(figsize=(6, 5), layout='tight')
fig, ax
ConfusionMatrixDisplay.from_predictions(
y_true, pred1, =['Adelie', 'Gentoo'],
display_labels=ax
ax;
) plt.show()
# ROC Curve Plot
= plt.subplots(figsize=(6, 5), layout='tight')
fig, ax
RocCurveDisplay.from_predictions(
y_true, prob1,=ax
ax;
) plt.show()
# Precision-Recall Curve Plot
= plt.subplots(figsize=(6, 5), layout='tight')
fig, ax
PrecisionRecallDisplay.from_predictions(
y_true, prob1,=ax
ax;
) plt.show()
Mit nur wenigen Zeilen Code erhalten wir eine vollständige Bewertung beider Modelle:
-
classification_report()
liefert Precision, Recall, F1-Score und Support für jede Klasse -
confusion_matrix()
erstellt die Konfusionsmatrix -
roc_auc_score()
undaverage_precision_score()
berechnen die AUC-Werte
Der F1-Score, den wir vorher nicht erwähnt hatten, ist das harmonische Mittel von Precision und Recall: F1 = 2 × (Precision × Recall) / (Precision + Recall)
. Er ist besonders nützlich, wenn man ein einzelnes Maß für die Balance zwischen Precision und Recall benötigt.
Die sklearn-Funktionen sparen nicht nur Zeit, sondern reduzieren auch Fehlerquellen bei der manuellen Berechnung und bieten konsistente Implementierungen, die in der gesamten Data Science-Community verwendet werden.
- Machine Learning Fundamentals: The Confusion Matrix
- Machine Learning Fundamentals: Sensitivity and Specificity
- ROC and AUC, Clearly Explained!
- ROC-Kurve und AUC-Wert (Einfach erklärt)
- ROC & AUC - A Visual Explanation of Receiver Operating Characteristic Curves and Area Under the Curve
- Precision & Recall - Accuracy Is Not Enough
Übungen
Übung 1: Titanic-Überlebensanalyse
In dieser Übung wendest du die Konzepte der multiplen logistischen Regression auf den Titanic-Datensatz an. Führe einen systematischen Modellvergleich mit allen Kombinationen der Variablen age
, sex
und pclass
durch.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
import statsmodels.formula.api as smf
from sklearn.metrics import confusion_matrix, roc_curve, auc
42)
np.random.seed(
# Titanic-Datensatz über seaborn laden
= sns.load_dataset("titanic")
titanic
# Fehlende Werte entfernen für age, sex und pclass
= titanic[['survived', 'age', 'sex', 'pclass']].dropna()
titanic_clean
print(f"Anzahl Beobachtungen: {len(titanic_clean)}")
print(f"Überlebensrate: {titanic_clean['survived'].mean():.3f}")
Anzahl Beobachtungen: 714
Überlebensrate: 0.406
Aufgabe: Passe die folgenden 7 Modelle an und erstelle eine Vergleichstabelle sowie ROC-Kurven:
survived ~ age
-
survived ~ C(sex)
survived ~ C(pclass)
survived ~ age + C(sex)
survived ~ age + C(pclass)
survived ~ C(sex) + C(pclass)
survived ~ age + C(sex) + C(pclass)
Die Ergebnistabelle soll folgende Spalten enthalten: Modell, AIC, Pseudo_R2, Log_Likelihood, Accuracy, Sensitivity, Specificity, Precision.
Zusätzlich erstelle ROC-Kurven für alle Modelle in einem Plot mit AUC-Werten in der Legende.
Fußnoten
Zur Erinnerung: Letzter testet, ob das der zusätzliche Faktor Geschlecht das Modell signifikant verbessert. Ein p-Wert < 0.05 zeigt, dass Modell 2 statistisch signifikant besser ist als Modell 1.↩︎