import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import statsmodels.api as sm
import statsmodels.formula.api as smf
import scipy.stats as stats
42) # für reproduzierbare Ergebnisse np.random.seed(
ANCOVA und Moderationsanalyse
In den vorangegangenen Kapiteln haben wir verschiedene statistische Modelle kennengelernt: Modelle mit einer oder mehreren kategorischen Variablen (t-Test, ANOVA), sowie Modelle mit einer oder mehreren metrischen Variablen (einfache und multiple lineare Regression, Polynomregression). Doch natürlich kann es auch vorkommen, dass wir beide Arten von Variablen in einem Modell kombinieren möchten.
Genau diese Kombination ist der Fokus dieses Kapitels. Wir werden zwei verwandte Ansätze kennenlernen: die ANCOVA (Analysis of Covariance) und die Moderationsanalyse. Der entscheidende Unterschied zwischen beiden liegt darin, ob die kategorische und die metrische Variable miteinander interagieren oder nicht.
Ein Beispiel zur Motivation
Betrachten wir zunächst ein Beispiel aus dem Palmer Penguins Datensatz. Stellen wir uns vor, wir interessieren uns für den Zusammenhang zwischen der Schnabeltiefe (bill_depth_mm
) und dem Körpergewicht (body_mass_g
):
# Palmer Penguins Datensatz laden
= 'https://raw.githubusercontent.com/SchmidtPaul/ExampleData/refs/heads/main/palmer_penguins/palmer_penguins.csv'
csv_url = pd.read_csv(csv_url)
penguins
# Definiere Farben für die Pinguinarten
= {'Adelie': '#FF8C00', 'Chinstrap': '#A034F0', 'Gentoo': '#159090'}
colors
# Daten für die Analyse vorbereiten
= penguins[['body_mass_g', 'bill_depth_mm', 'species']].dropna() penguins_clean
Wenn wir zunächst wirklich nur diese beiden Variablen betrachten, können wir eine Regressionsgerade wie folgt anpassen:
Code zeigen/verstecken
# Erster Plot: Ohne Berücksichtigung der Arten
= plt.subplots(figsize=(10, 6), layout='tight')
fig, ax
# Alle Datenpunkte ohne Farbunterscheidung
'bill_depth_mm'], penguins_clean['body_mass_g'],
ax.scatter(penguins_clean[=0.6, color='gray', s=50)
alpha
# Einfache Regressionsgerade über alle Daten
= smf.ols('body_mass_g ~ bill_depth_mm', data=penguins_clean).fit()
model_simple = np.linspace(penguins_clean['bill_depth_mm'].min(),
x_range 'bill_depth_mm'].max(), 100)
penguins_clean[= model_simple.predict(pd.DataFrame({'bill_depth_mm': x_range}))
y_pred ='red', linewidth=2,
ax.plot(x_range, y_pred, color=f'Einfache Regression (R² = {model_simple.rsquared:.3f})')
label
'Schnabeltiefe (mm)')
ax.set_xlabel('Körpergewicht (g)')
ax.set_ylabel('Zusammenhang ohne Berücksichtigung der Pinguinarten')
ax.set_title(
ax.legend() plt.show()
Die einfache lineare Regression zeigt zwar einen schwachen Zusammenhang, doch schon mit dem bloßen Augen wirken die Punkte eigenartig verteilt. Das ganze klärt sich schnell auf, wenn wir die Pinguinarten berücksichtigen. Hier ist bereits ein Vorgeschmack auf das, was wir am Ende dieses Kapitels erreichen werden:
Code zeigen/verstecken
# Zweiter Plot: Mit Berücksichtigung der Arten (Vorschau)
= plt.subplots(figsize=(10, 6), layout='tight')
fig, ax
# Datenpunkte nach Arten gefärbt
for species in penguins_clean['species'].unique():
= penguins_clean[penguins_clean['species'] == species]
data 'bill_depth_mm'], data['body_mass_g'],
ax.scatter(data[=0.7, color=colors[species], s=50, label=species)
alpha
# Separate Regressionsgeraden für jede Art (Moderationsanalyse)
for species in penguins_clean['species'].unique():
= penguins_clean[penguins_clean['species'] == species]
data if len(data) > 1: # Nur wenn genügend Datenpunkte vorhanden
= smf.ols('body_mass_g ~ bill_depth_mm', data=data).fit()
model_species = np.linspace(data['bill_depth_mm'].min(),
x_range_species 'bill_depth_mm'].max(), 100)
data[= model_species.predict(pd.DataFrame({'bill_depth_mm': x_range_species}))
y_pred_species =colors[species],
ax.plot(x_range_species, y_pred_species, color=2, linestyle='--')
linewidth
'Schnabeltiefe (mm)')
ax.set_xlabel('Körpergewicht (g)')
ax.set_ylabel('Zusammenhang mit Berücksichtigung der Pinguinarten')
ax.set_title(
ax.legend() plt.show()
Die Unterschiede sind dramatisch! Statt eines schwach negativen Gesamtzusammenhangs sehen wir nun drei verschiedene Beziehungen für die drei Pinguinarten. Jede Art hat nicht nur eine andere Steigung, sondern auch einen anderen Achsenabschnitt. Alle drei Steigungen sind im Gegensatz zur Steigung davor positiv
Dies zeigt uns, dass die Pinguinart den Zusammenhang zwischen Schnabeltiefe und Körpergewicht moderiert - ein klassischer Fall für eine Moderationsanalyse. Aber der Reihe nach: Beginnen wir zunächst mit der ANCOVA.
Teil 1: ANCOVA (Analysis of Covariance)
Die ANCOVA (Analysis of Covariance) kombiniert die Varianzanalyse (ANOVA) mit der linearen Regression. Sie ermöglicht es uns, Gruppenunterschiede zu untersuchen, während wir gleichzeitig für eine Kovariable (eine metrische Variable) kontrollieren.
Der mtcars Datensatz
Für unsere ANCOVA verwenden wir den klassischen mtcars
Datensatz, der verschiedene Eigenschaften von Automobilen aus den 1970er Jahren enthält:
# mtcars Datensatz laden
= sm.datasets.get_rdataset('mtcars').data
mtcars # Relevante Variablen extrahieren und vorbereiten
= mtcars[['mpg', 'am', 'hp']].copy()
mtcars_clean # Faktor-Variable für bessere Lesbarkeit umkodieren
'transmission'] = mtcars_clean['am'].map({0: 'Automatik', 1: 'Manuell'})
mtcars_clean[
# Definiere Farben für die Getriebearten
= {'Automatik': '#00923f', 'Manuell': '#ad0000'}
transmission_colors
mtcars_clean
mpg am hp transmission
rownames
Mazda RX4 21.0 1 110 Manuell
Mazda RX4 Wag 21.0 1 110 Manuell
Datsun 710 22.8 1 93 Manuell
Hornet 4 Drive 21.4 0 110 Automatik
Hornet Sportabout 18.7 0 175 Automatik
Valiant 18.1 0 105 Automatik
Duster 360 14.3 0 245 Automatik
Merc 240D 24.4 0 62 Automatik
Merc 230 22.8 0 95 Automatik
Merc 280 19.2 0 123 Automatik
Merc 280C 17.8 0 123 Automatik
Merc 450SE 16.4 0 180 Automatik
Merc 450SL 17.3 0 180 Automatik
Merc 450SLC 15.2 0 180 Automatik
Cadillac Fleetwood 10.4 0 205 Automatik
Lincoln Continental 10.4 0 215 Automatik
Chrysler Imperial 14.7 0 230 Automatik
Fiat 128 32.4 1 66 Manuell
Honda Civic 30.4 1 52 Manuell
Toyota Corolla 33.9 1 65 Manuell
Toyota Corona 21.5 0 97 Automatik
Dodge Challenger 15.5 0 150 Automatik
AMC Javelin 15.2 0 150 Automatik
Camaro Z28 13.3 0 245 Automatik
Pontiac Firebird 19.2 0 175 Automatik
Fiat X1-9 27.3 1 66 Manuell
Porsche 914-2 26.0 1 91 Manuell
Lotus Europa 30.4 1 113 Manuell
Ford Pantera L 15.8 1 264 Manuell
Ferrari Dino 19.7 1 175 Manuell
Maserati Bora 15.0 1 335 Manuell
Volvo 142E 21.4 1 109 Manuell
Für unsere ANCOVA konzentrieren wir uns auf drei Variablen:
-
mpg
(miles per gallon): Kraftstoffverbrauch - unsere abhängige Variable- Achtung: Im Gegensatz zur in Deutschland üblichen Einheit Liter pro 100 km, gilt bei miles per gallon ein höherer Wert für einen sparsameren Kraftstoffverbrauch, da der Bezug zwischen gefahrener Strecke und verbrauchtem Kraftstoff umgekehrt ist.
-
am
(transmission): Getriebeart (0 = automatik, 1 = manuell) - unser Faktor -
hp
(horsepower): Motorleistung in PS - unsere Kovariable
print("Deskriptive Statistik nach Getriebearten:")
= mtcars_clean.groupby('transmission')[['mpg', 'hp']].agg(['mean', 'std', 'count'])
desc_stats print(desc_stats.round(2))
Deskriptive Statistik nach Getriebearten:
mpg hp
mean std count mean std count
transmission
Automatik 17.15 3.83 19 160.26 53.91 19
Manuell 24.39 6.17 13 126.85 84.06 13
Bevor wir mit der eigentlichen ANCOVA beginnen, verschaffen wir uns einen ersten Überblick über unsere Daten. Die deskriptive Statistik zeigt bereits interessante Unterschiede zwischen den Getriebearten, die wir in der folgenden Abbildung genauer betrachten:
Code zeigen/verstecken
# Explorative Datenanalyse
= plt.subplots(1, 2, figsize=(12, 5), layout='tight')
fig, (ax1, ax2)
# Links: Kraftstoffverbrauch nach Getriebeart (Stufenvergleich)
# Jitter für bessere Sichtbarkeit
42)
np.random.seed(for i, transmission in enumerate(['Automatik', 'Manuell']):
= mtcars_clean[mtcars_clean['transmission'] == transmission]
data = np.random.normal(i, 0.05, len(data))
x_jitter 'mpg'], alpha=0.7, s=60,
ax1.scatter(x_jitter, data[=transmission_colors[transmission])
color
# Mittelwert als gestrichelte Linie für jede Gruppe
= data['mpg'].mean()
mean_val =mean_val, xmin=i-0.2, xmax=i+0.2,
ax1.hlines(y="black", linestyles='--', linewidth=2)
colors
0, 1])
ax1.set_xticks(['Automatik', 'Manuell'])
ax1.set_xticklabels(['Kraftstoffverbrauch (mpg)')
ax1.set_ylabel('Kraftstoffverbrauch nach Getriebeart')
ax1.set_title(
# Rechts: Scatterplot Motorleistung vs. Kraftstoffverbrauch
'hp'], mtcars_clean['mpg'], alpha=0.7, s=60, color='gray')
ax2.scatter(mtcars_clean['Motorleistung (PS)')
ax2.set_xlabel('Kraftstoffverbrauch (mpg)')
ax2.set_ylabel('Kraftstoffverbrauch vs. Motorleistung')
ax2.set_title(
plt.show()
Die linke Grafik bestätigt den deutlichen Unterschied im Kraftstoffverbrauch zwischen den Getriebearten, während die rechte Grafik die Verteilung der Motorleistung in unserem Datensatz zeigt.
Schritt 1: Einfache ANOVA (nur Getriebeart)
Beginnen wir mit einer einfachen ANOVA, die nur die Getriebeart berücksichtigt:
# Einfache ANOVA: mpg ~ transmission
= smf.ols('mpg ~ C(transmission)', data=mtcars_clean).fit()
model_anova = sm.stats.anova_lm(model_anova, typ=2)
anova_simple
print("ANOVA: Kraftstoffverbrauch ~ Getriebeart")
print(anova_simple.round(5))
print(f"\nKoeffizienten:")
print(model_anova.params)
print(f"\nR² = {model_anova.rsquared:.3f}")
ANOVA: Kraftstoffverbrauch ~ Getriebeart
sum_sq df F PR(>F)
C(transmission) 405.15059 1.0 16.86028 0.00029
Residual 720.89660 30.0 NaN NaN
Koeffizienten:
Intercept 17.147368
C(transmission)[T.Manuell] 7.244939
dtype: float64
R² = 0.360
Die ANOVA zeigt einen signifikanten Unterschied (p < 0.001) zwischen Automatik- und Schaltgetrieben. Manuelle Getriebe scheinen im Durchschnitt deutlich sparsamer zu sein.
Schritt 2: Das Problem der konfundierenden Variable
Aber ist dieser Unterschied fair? Schauen wir uns die Verteilung der Motorleistung (PS) zwischen den beiden Getriebearten an:
Code zeigen/verstecken
# Visualisierung der PS-Verteilung nach Getriebeart
= plt.subplots(figsize=(10, 6), layout='tight')
fig, ax
# Scatterplot: PS vs. Kraftstoffverbrauch, gefärbt nach Getriebeart
for transmission in ['Automatik', 'Manuell']:
= mtcars_clean[mtcars_clean['transmission'] == transmission]
data 'hp'], data['mpg'], alpha=0.7, s=80,
ax.scatter(data[=transmission, color=transmission_colors[transmission])
label
# Gruppenmittelwerte als Rauten einzeichnen
for transmission in ['Automatik', 'Manuell']:
= mtcars_clean[mtcars_clean['transmission'] == transmission]
data = data['hp'].mean()
mean_hp = data['mpg'].mean()
mean_mpg = transmission_colors[transmission]
color ='D', s=200, color=color,
ax.scatter(mean_hp, mean_mpg, marker='black', linewidth=2, zorder=5,
edgecolor=f'{transmission} (Mittelwert)')
label
'Motorleistung (PS)')
ax.set_xlabel('Kraftstoffverbrauch (mpg)')
ax.set_ylabel('Kraftstoffverbrauch vs. Motorleistung nach Getriebeart')
ax.set_title(
ax.legend() plt.show()
Hier wird das Problem deutlich: Die Fahrzeuge mit Automatikgetriebe in diesem Datensatz haben im Durchschnitt deutlich mehr PS als die mit manuellen Getrieben! Die Rauten zeigen die Gruppenmittelwerte - aber diese liegen bei unterschiedlichen PS-Werten. Dies ist eine konfundierende Variable - der scheinbare Vorteil manueller Getriebe könnte einfach daran liegen, dass sie tendenziell in schwächeren (und damit sparsameren) Fahrzeugen verbaut werden.
Im Idealfall möchten wir ja wissen, ob die Getriebeart selbst einen Einfluss auf den Kraftstoffverbrauch hat, unabhängig von der Motorleistung. Leider haben wir aber zumindest in diesem Datensatz keine gleichmäßige Verteilung der Motorleistung zwischen den Getriebearten.
Das Problem konfundierender Variablen begegnet uns in vielen Bereichen:
Marketing: Kunden, die Newsletter abonniert haben, kaufen mehr. Ist der Newsletter so überzeugend? Oder sind Newsletter-Abonnenten einfach generell kauffreudigere und engagiertere Kunden?
Ernährung: Menschen, die täglich Vitaminpräparate nehmen, sind gesünder. Liegt es an den Vitaminen oder daran, dass diese Personen generell einen gesünderen Lebensstil (mehr Sport, bewusstere Ernährung) pflegen?
Technik: Laptops der Marke X haben weniger Reparaturen. Ist die Qualität besser? Oder werden diese Geräte hauptsächlich von weniger intensiven Nutzern verwendet?
Ökologie: In Gebieten mit mehr Bäumen leben mehr Vogelarten. Liegt es an den Bäumen selbst oder an anderen Faktoren wie Wasserverfügbarkeit, Klima oder geringerer Urbanisierung, die sowohl Bäume als auch Vögel begünstigen?
Bildungsforschung: Schüler aus Privatschulen schneiden bei Standardtests besser ab. Ist das der Beweis für bessere Privatschulen? Oder erklärt der sozioökonomische Hintergrund der Familien (höhere Bildung der Eltern, mehr Unterstützung zu Hause) den Unterschied?
Schritt 3: Test auf Interaktion - Voraussetzung für ANCOVA
Bevor wir eine ANCOVA durchführen können, müssen wir eine wichtige Voraussetzung1 prüfen: Es darf keine signifikante Interaktion zwischen dem Faktor (Getriebeart) und der Kovariable (Motorleistung) geben. Nur wenn diese Voraussetzung erfüllt ist, können wir sinnvoll für die Kovariable “korrigieren”.
# Test auf Interaktion: Vollständiges Modell mit Interaktionsterm
= smf.ols('mpg ~ C(transmission) * hp', data=mtcars_clean).fit()
model_interaction = sm.stats.anova_lm(model_interaction, typ=3)
anova_interaction
print("Modell mit Interaktion: mpg ~ transmission * hp")
print(anova_interaction.round(5))
print(f"\nKoeffizienten:")
print(model_interaction.params.round(3))
print(f"\nR² = {model_interaction.rsquared:.3f}")
Modell mit Interaktion: mpg ~ transmission * hp
sum_sq df F PR(>F)
Intercept 1303.96568 1.0 148.76111 0.00000
C(transmission) 33.59715 1.0 3.83288 0.06029
hp 182.93652 1.0 20.87006 0.00009
C(transmission):hp 0.00525 1.0 0.00060 0.98065
Residual 245.43404 28.0 NaN NaN
Koeffizienten:
Intercept 26.625
C(transmission)[T.Manuell] 5.218
hp -0.059
C(transmission)[T.Manuell]:hp 0.000
dtype: float64
R² = 0.782
Wie wir sehen können, ist die Interaktion nicht signifikant (p > 0.05). Das ist wichtig, denn es bedeutet, dass der Einfluss der Motorleistung auf den Kraftstoffverbrauch für beide Getriebearten gleich ist. Mit anderen Worten: Die Steigung der Beziehung zwischen PS und mpg ist für Automatik- und Schaltgetriebe parallel.
Interessant ist auch, dass in diesem Modell mit Interaktionsterm der Haupteffekt der Getriebeart nicht mehr signifikant ist (p > 0.05). Das zeigt bereits, dass die Motorleistung der wichtigere erklärende Faktor ist und dass nach Kontrolle für die PS der Unterschied zwischen den Getriebearten stark abnimmt - genau das, was wir schon in Schritt 2 anhand der Rauten (Gruppenmittelwerte bei unterschiedlichen PS-Werten) vermutet haben.
Schritt 4: ANCOVA - das korrigierte Modell
Da die Interaktion nicht signifikant ist, können wir sie aus dem Modell entfernen und die eigentliche ANCOVA durchführen:
# ANCOVA: mpg ~ transmission + hp (ohne Interaktion)
= smf.ols('mpg ~ C(transmission) + hp', data=mtcars_clean).fit()
model_ancova = sm.stats.anova_lm(model_ancova, typ=2)
anova_ancova
print("ANCOVA: mpg ~ transmission + hp")
print(anova_ancova.round(5))
print(f"\nKoeffizienten:")
print(model_ancova.params.round(3))
print(f"\nR² = {model_ancova.rsquared:.3f}")
print(f"Adjustiertes R² = {model_ancova.rsquared_adj:.3f}")
ANCOVA: mpg ~ transmission + hp
sum_sq df F PR(>F)
C(transmission) 202.23503 1.0 23.89518 0.00003
hp 475.45731 1.0 56.17789 0.00000
Residual 245.43929 29.0 NaN NaN
Koeffizienten:
Intercept 26.585
C(transmission)[T.Manuell] 5.277
hp -0.059
dtype: float64
R² = 0.782
Adjustiertes R² = 0.767
Das mathematische Modell der ANCOVA lautet:
\[\text{mpg}_i = \beta_0 + \beta_1 \cdot \text{transmission}_i + \beta_2 \cdot \text{hp}_i + \varepsilon_i\]
Dabei ist transmission
eine Dummy-Variable (0 für Automatik, 1 für Manuell).
Interessant: Nachdem wir die nicht-signifikante Interaktion entfernt haben, wird der Haupteffekt der Getriebeart wieder leicht signifikant (je nach exakten p-Werten). Dies zeigt die Feinheiten statistischer Modellierung - kleine Änderungen im Modell können die Signifikanz beeinflussen.
Schritt 5: Visualisierung der parallelen Regressionsgeraden
Die ANCOVA geht von parallelen Regressionsgeraden aus - beide Getriebearten haben die gleiche Steigung bezüglich der Motorleistung, aber möglicherweise unterschiedliche Achsenabschnitte:
Code zeigen/verstecken
# Visualisierung der ANCOVA: Parallele Regressionsgeraden
= plt.subplots(figsize=(10, 6), layout='tight')
fig, ax
# Datenpunkte
for transmission in ['Automatik', 'Manuell']:
= mtcars_clean[mtcars_clean['transmission'] == transmission]
data 'hp'], data['mpg'], alpha=0.7, s=80,
ax.scatter(data[=transmission, color=transmission_colors[transmission])
label
# Parallele Regressionsgeraden (ANCOVA-Modell)
= np.linspace(mtcars_clean['hp'].min(), mtcars_clean['hp'].max(), 100)
hp_range
# Koeffizienten aus dem ANCOVA-Modell
= model_ancova.params[0]
intercept = model_ancova.params[1]
transmission_effect = model_ancova.params[2]
hp_effect
# Automatik (transmission = 0)
= intercept + hp_effect * hp_range
y_auto =transmission_colors['Automatik'], linewidth=2, linestyle='--',
ax.plot(hp_range, y_auto, color='Automatik (ANCOVA)')
label
# Manuell (transmission = 1)
= (intercept + transmission_effect) + hp_effect * hp_range
y_manual =transmission_colors['Manuell'], linewidth=2, linestyle='--',
ax.plot(hp_range, y_manual, color='Manuell (ANCOVA)')
label
# Ursprüngliche Gruppenmittelwerte als transparente Rauten (aus Schritt 2)
for transmission in ['Automatik', 'Manuell']:
= mtcars_clean[mtcars_clean['transmission'] == transmission]
data = data['hp'].mean()
mean_hp = data['mpg'].mean()
mean_mpg = transmission_colors[transmission]
color ='D', s=200, color=color,
ax.scatter(mean_hp, mean_mpg, marker=0.5, edgecolor='black', linewidth=1, zorder=4,
alpha=f'{transmission} (urspr. Mittelwert)')
label
# Korrigierte Werte bei durchschnittlicher PS als Dreiecke
= mtcars_clean['hp'].mean()
mean_hp_overall # Vorhersagen bei durchschnittlicher PS
= intercept + hp_effect * mean_hp_overall
y_auto_corrected = (intercept + transmission_effect) + hp_effect * mean_hp_overall
y_manual_corrected
='^', s=200,
ax.scatter(mean_hp_overall, y_auto_corrected, marker=transmission_colors['Automatik'], edgecolor='black', linewidth=2, zorder=5,
color=f'Automatik (korrigiert bei {mean_hp_overall:.1f} PS)')
label='^', s=200,
ax.scatter(mean_hp_overall, y_manual_corrected, marker=transmission_colors['Manuell'], edgecolor='black', linewidth=2, zorder=5,
color=f'Manuell (korrigiert bei {mean_hp_overall:.1f} PS)')
label
# Vertikale Linie bei durchschnittlicher PS
=mean_hp_overall, color='gray', linestyle=':', alpha=0.7,
ax.axvline(x=f'Durchschnittliche PS ({mean_hp_overall:.1f})')
label
'Motorleistung (PS)')
ax.set_xlabel('Kraftstoffverbrauch (mpg)')
ax.set_ylabel('ANCOVA: Parallele Regressionsgeraden mit Korrektureffekt')
ax.set_title(=(1.05, 1), loc='upper left')
ax.legend(bbox_to_anchor plt.show()
Die parallelen Geraden zeigen das Kernprinzip der ANCOVA: Der Effekt der Motorleistung auf den Kraftstoffverbrauch ist für beide Getriebearten gleich (gleiche Steigung), aber die Ausgangsniveaus können sich unterscheiden.
Die transparenten Rauten zeigen nochmals die ursprünglichen Gruppenmittelwerte aus Schritt 2 - diese lagen bei unterschiedlichen PS-Werten und führten zu einem “unfairen” Vergleich. Die Dreiecke hingegen zeigen die korrigierten Werte bei der durchschnittlichen Motorleistung von 146.7 PS. Der Abstand zwischen den Dreiecken ist deutlich kleiner als zwischen den Rauten - das ist der Korrektureffekt der ANCOVA!
Schritt 6: Der Korrektureffekt - Vorher vs. Nachher
Um zu verdeutlichen, wie stark die Kovariable unsere Schlüsse beeinflusst, vergleichen wir die ursprünglichen Mittelwerte mit den korrigierten Werten:
# Vergleich: Ursprüngliche vs. korrigierte Mittelwerte
print("Der Korrektureffekt der ANCOVA:")
print("=" * 40)
# 1. Ursprüngliche Mittelwerte
print("\n1. Ursprüngliche Mittelwerte (ohne Korrektur):")
= mtcars_clean.groupby('transmission')['mpg'].mean()
original_means for transmission in ['Automatik', 'Manuell']:
print(f" {transmission}: {original_means[transmission]:.2f} mpg")
= original_means['Manuell'] - original_means['Automatik']
original_diff print(f" Unterschied (Manuell - Automatik): {original_diff:.2f} mpg")
# 2. Korrigierte Werte bei durchschnittlicher PS
= mtcars_clean['hp'].mean()
mean_hp print(f"\n2. Korrigierte Werte bei durchschnittlicher PS ({mean_hp:.1f} PS):")
# Vorhersagen bei durchschnittlicher PS
= pd.DataFrame({
pred_data 'transmission': ['Automatik', 'Manuell'],
'hp': [mean_hp, mean_hp]
})
= model_ancova.predict(pred_data)
predictions for i, transmission in enumerate(['Automatik', 'Manuell']):
print(f" {transmission}: {predictions.iloc[i]:.2f} mpg")
= predictions.iloc[1] - predictions.iloc[0]
corrected_diff print(f" Unterschied (korrigiert): {corrected_diff:.2f} mpg")
# 3. Zusammenfassung der Veränderung
print(f"\n3. Zusammenfassung:")
= abs(original_diff) - abs(corrected_diff)
reduction = (reduction / abs(original_diff)) * 100
percentage_reduction print(f" Reduktion des Unterschieds: {reduction:.2f} mpg ({percentage_reduction:.1f}%)")
print(f"\n4. Visueller Vergleich:")
print(f" Rauten in Schritt 2: Zeigen den ursprünglichen Unterschied von {original_diff:.2f} mpg")
print(f" Dreiecke in Schritt 5: Zeigen den korrigierten Unterschied von {corrected_diff:.2f} mpg")
print(f" → Die ANCOVA 'schiebt' die Vergleichspunkte auf eine faire Linie!")
Der Korrektureffekt der ANCOVA:
========================================
1. Ursprüngliche Mittelwerte (ohne Korrektur):
Automatik: 17.15 mpg
Manuell: 24.39 mpg
Unterschied (Manuell - Automatik): 7.24 mpg
2. Korrigierte Werte bei durchschnittlicher PS (146.7 PS):
Automatik: 17.95 mpg
Manuell: 23.22 mpg
Unterschied (korrigiert): 5.28 mpg
3. Zusammenfassung:
Reduktion des Unterschieds: 1.97 mpg (27.2%)
4. Visueller Vergleich:
Rauten in Schritt 2: Zeigen den ursprünglichen Unterschied von 7.24 mpg
Dreiecke in Schritt 5: Zeigen den korrigierten Unterschied von 5.28 mpg
→ Die ANCOVA 'schiebt' die Vergleichspunkte auf eine faire Linie!
ANCOVA-Fazit: Die Kovariable “Motorleistung” erklärt einen großen Teil der Variation im Kraftstoffverbrauch. Nach Kontrolle für die PS wird der Unterschied zwischen den Getriebearten deutlich kleiner. Dies zeigt, wie wichtig es ist, konfundierende Variablen in der Analyse zu berücksichtigen, um faire Vergleiche zwischen Gruppen zu ermöglichen.
Teil 2: Moderationsanalyse
Während die ANCOVA keine Interaktion zwischen Faktor und Kovariable voraussetzt, gibt es Situationen, in denen diese Interaktion durchaus von Interesse ist. Wenn die Beziehung zwischen der Kovariable und der abhängigen Variable je nach Gruppe unterschiedlich ist, sprechen wir von einer Moderationsanalyse.
Palmer Penguins: Arten moderieren die Beziehung
Kehren wir zu unserem Palmer Penguins Beispiel zurück und untersuchen, ob die Pinguinart die Beziehung zwischen Schnabeltiefe und Körpergewicht moderiert:
# Moderationsanalyse: body_mass_g ~ species * bill_depth_mm
= smf.ols('body_mass_g ~ C(species) * bill_depth_mm', data=penguins_clean).fit()
model_moderation = sm.stats.anova_lm(model_moderation, typ=3)
anova_moderation
print("Moderationsanalyse: Körpergewicht ~ Art × Schnabeltiefe")
print(anova_moderation.round(5))
print(f"\nKoeffizienten:")
for param, value in model_moderation.params.items():
print(f"{param}: {value:.2f}")
print(f"\nR² = {model_moderation.rsquared:.3f}")
print(f"Adjustiertes R² = {model_moderation.rsquared_adj:.3f}")
Moderationsanalyse: Körpergewicht ~ Art × Schnabeltiefe
sum_sq df F PR(>F)
Intercept 5.270569e+04 1.0 0.41841 0.51818
C(species) 3.098247e+04 2.0 0.12298 0.88432
bill_depth_mm 1.047004e+07 1.0 83.11681 0.00000
C(species):bill_depth_mm 2.074479e+06 2.0 8.23416 0.00032
Residual 4.232519e+07 336.0 NaN NaN
Koeffizienten:
Intercept: -283.28
C(species)[T.Chinstrap]: 247.06
C(species)[T.Gentoo]: -175.71
bill_depth_mm: 217.15
C(species)[T.Chinstrap]:bill_depth_mm: -12.53
C(species)[T.Gentoo]:bill_depth_mm: 152.29
R² = 0.807
Adjustiertes R² = 0.804
Das mathematische Modell der Moderationsanalyse lautet:
\[\text{body\_mass}_i = \beta_0 + \beta_1 \cdot \text{species1}_i + \beta_2 \cdot \text{species2}_i + \beta_3 \cdot \text{bill\_depth}_i\] \[+ \beta_4 \cdot \text{species1}_i \cdot \text{bill\_depth}_i + \beta_5 \cdot \text{species2}_i \cdot \text{bill\_depth}_i + \varepsilon_i\]
Visualisierung: Drei verschiedene Regressionsgeraden
Im Gegensatz zur ANCOVA mit parallelen Geraden erhalten wir hier drei unterschiedliche Regressionsgeraden:
Code zeigen/verstecken
# Visualisierung der Moderationsanalyse
= plt.subplots(figsize=(12, 8), layout='tight')
fig, ax
# Datenpunkte nach Arten
for species in penguins_clean['species'].unique():
= penguins_clean[penguins_clean['species'] == species]
data 'bill_depth_mm'], data['body_mass_g'],
ax.scatter(data[=0.7, color=colors[species], s=60, label=f'{species} (Daten)')
alpha
# Separate Regressionsgeraden für jede Art basierend auf dem Interaktionsmodell
= np.linspace(penguins_clean['bill_depth_mm'].min(),
bill_depth_range 'bill_depth_mm'].max(), 100)
penguins_clean[
# Einfachere Methode: Separate Modelle für jede Art
= penguins_clean['species'].unique()
species_list for species in species_list:
= penguins_clean[penguins_clean['species'] == species]
species_data = smf.ols('body_mass_g ~ bill_depth_mm', data=species_data).fit()
species_model
# Vorhersagen für diese Art
= np.linspace(species_data['bill_depth_mm'].min(),
x_range_species 'bill_depth_mm'].max(), 50)
species_data[= species_model.predict(pd.DataFrame({'bill_depth_mm': x_range_species}))
y_pred_species
=colors[species], linewidth=3,
ax.plot(x_range_species, y_pred_species, color=f'{species} (Regression)')
label
'Schnabeltiefe (mm)')
ax.set_xlabel('Körpergewicht (g)')
ax.set_ylabel('Moderationsanalyse: Verschiedene Steigungen und Achsenabschnitte')
ax.set_title(=(1.05, 1), loc='upper left')
ax.legend(bbox_to_anchor plt.show()
Eine häufige Frage ist: Warum brauchen wir den bill_depth_mm
Term, wenn wir doch schon C(species):bill_depth_mm
haben?
Die Antwort liegt mal wieder in der Referenzgruppen-Parametrisierung: Der Haupteffekt bill_depth_mm
ist die Steigung für die Referenzgruppe (hier: Adelie-Pinguine). Die Interaktionsterme zeigen dann die Abweichungen von dieser Referenz-Steigung für die anderen Arten.
So entstehen die drei verschiedenen Steigungen: - Adelie: Referenz-Steigung (Koeffizient von bill_depth_mm
) - Chinstrap: Referenz-Steigung + Interaktions-Koeffizient
- Gentoo: Referenz-Steigung + Interaktions-Koeffizient
Der dramatische Unterschied: Simpson’s Paradox in Aktion
Hier wird besonders deutlich, warum die Moderationsanalyse so wichtig ist. Ohne Berücksichtigung der Pinguinarten hätten wir einen völlig falschen Schluss gezogen!
Erinnern wir uns an die erste Analyse:
- Einfache Regression (ignoriert Arten): Schwach negativer Zusammenhang (R² = 0.056)
- Interpretation: “Mit zunehmender Schnabeltiefe nimmt das Körpergewicht ab”
Aber die Moderationsanalyse zeigt das Gegenteil:
- Artspezifische Regressionen: Alle drei Steigungen sind positiv!
- Korrekte Interpretation: “Innerhalb jeder Art nimmt das Körpergewicht mit zunehmender Schnabeltiefe zu”
Dieses Phänomen ist ein klassisches Beispiel für Simpson’s Paradox: Ein Trend in aggregierten Daten verschwindet oder kehrt sich sogar um, wenn die Daten in sinnvolle Untergruppen aufgeteilt werden. Die scheinbar negative Gesamtbeziehung entsteht nur dadurch, dass:
- Gentoo-Pinguine generell schwerer sind UND größere Schnäbel haben
-
Adelie-Pinguine generell leichter sind UND kleinere Schnäbel haben
- Die Unterschiede zwischen den Arten überlagern die innerartlichen Beziehungen
Ohne die Berücksichtigung der Moderation durch die Pinguinart hätten wir eine biologisch unsinnige Schlussfolgerung gezogen. Die Moderationsanalyse deckt die wahren, biologisch plausiblen Zusammenhänge auf: Innerhalb jeder Art sind dickere Schnäbel mit höherem Körpergewicht assoziiert.
Übersicht: Die fünf Grundszenarien
Zum Abschluss betrachten wir eine systematische Übersicht über die verschiedenen Modelltypen, die wir nun kennengelernt haben. Diese Szenarien unterscheiden sich darin, welche Parameter in der linearen Gleichung enthalten sind:
Code zeigen/verstecken
# Künstliche Daten für die Demonstration der Grundszenarien
42)
np.random.seed(= np.linspace(0, 10, 100)
x = plt.subplots(2, 3, figsize=(15, 10), layout='tight')
fig, axes = axes.flatten()
axes
# Szenario 1: Nur Achsenabschnitt (y ~ 1)
= 5 + np.random.normal(0, 0.5, 100)
y1 0].scatter(x, y1, alpha=0.6, s=30)
axes[0].axhline(y=5, color='red', linewidth=2)
axes[0].set_title('Szenario 1:\nEin Achsenabschnitt\nKeine Steigung\ny ~ 1')
axes[0].set_xlabel('x')
axes[0].set_ylabel('y')
axes[
# Szenario 2: Nur Steigung (y ~ x - 1)
= 0.5 * x + np.random.normal(0, 0.5, 100)
y2 1].scatter(x, y2, alpha=0.6, s=30)
axes[1].plot(x, 0.5 * x, color='red', linewidth=2)
axes[1].set_title('Szenario 2:\nKein Achsenabschnitt\nEine Steigung\ny ~ x - 1')
axes[1].set_xlabel('x')
axes[1].set_ylabel('y')
axes[
# Szenario 3: Faktor mit 2 Stufen (y ~ factor)
= np.repeat([0, 1], 50)
group = 3 + 2 * group + np.random.normal(0, 0.5, 100)
y3 = ['blue', 'orange']
colors_simple for g in [0, 1]:
= group == g
mask 2].scatter(x[mask], y3[mask], alpha=0.6, s=30, color=colors_simple[g],
axes[=f'Gruppe {g+1}')
label2].axhline(y=3 + 2*g, color=colors_simple[g], linewidth=2)
axes[2].set_title('Szenario 3:\nMehrere Achsenabschnitte\nKeine Steigung\ny ~ factor')
axes[2].set_xlabel('x')
axes[2].set_ylabel('y')
axes[2].legend()
axes[
# Szenario 4: Zwei verschiedene Steigungen (y ~ x:factor - 1)
= (0.3 + 0.4 * group) * x + np.random.normal(0, 0.5, 100)
y4 for g in [0, 1]:
= group == g
mask 3].scatter(x[mask], y4[mask], alpha=0.6, s=30, color=colors_simple[g],
axes[=f'Gruppe {g+1}')
label3].plot(x, (0.3 + 0.4*g) * x, color=colors_simple[g], linewidth=2)
axes[3].set_title('Szenario 4:\nKein Achsenabschnitt\nMehrere Steigungen\ny ~ x:factor - 1')
axes[3].set_xlabel('x')
axes[3].set_ylabel('y')
axes[3].legend()
axes[
# Szenario 5: Vollständige Regressionsgeraden (y ~ x * factor)
= (2 + 3 * group) + (0.3 + 0.4 * group) * x + np.random.normal(0, 0.5, 100)
y5 for g in [0, 1]:
= group == g
mask 4].scatter(x[mask], y5[mask], alpha=0.6, s=30, color=colors_simple[g],
axes[=f'Gruppe {g+1}')
label4].plot(x, (2 + 3*g) + (0.3 + 0.4*g) * x, color=colors_simple[g], linewidth=2)
axes[4].set_title('Szenario 5:\nMehrere Achsenabschnitte\nMehrere Steigungen\ny ~ x * factor')
axes[4].set_xlabel('x')
axes[4].set_ylabel('y')
axes[4].legend()
axes[
# Leeres Subplot entfernen
5].remove()
axes[
plt.tight_layout() plt.show()
Die ANCOVA entspricht einem Spezialfall ohne Interaktionsterm2, während die Moderationsanalyse dem vollständigen Szenario 5 entspricht.
Unterschied zwischen ANCOVA und Moderationsanalyse
Zusammenfassend lässt sich der Hauptunterschied so formulieren:
ANCOVA (Analysis of Covariance):
- Annahme: Keine Interaktion zwischen Faktor und Kovariable
-
Ergebnis: Parallele Regressionsgeraden (gleiche Steigung, verschiedene Achsenabschnitte)
- Interpretation: Die Kovariable hilft dabei, “fairere” Gruppenvergleiche zu erstellen
- Verwendung: Wenn man Gruppenunterschiede um eine kontinuierliche Variable bereinigen möchte
Moderationsanalyse:
- Annahme: Interaktion zwischen Faktor und kontinuierlicher Variable ist von Interesse
- Ergebnis: Verschiedene Regressionsgeraden (verschiedene Steigungen UND Achsenabschnitte)
- Interpretation: Der Faktor moderiert die Beziehung zwischen kontinuierlicher und abhängiger Variable
- Verwendung: Wenn der Zusammenhang zwischen zwei Variablen gruppenspezifisch ist
Beide Ansätze sind wichtige Werkzeuge in der statistischen Modellierung und ergänzen die Palette der linearen Modelle, die wir in den vorangegangenen Kapiteln kennengelernt haben. Sie zeigen, wie flexibel und mächtig der Rahmen der linearen Modelle ist, um komplexe Beziehungen zwischen verschiedenen Arten von Variablen zu modellieren.