Direkt zum Hauptbereich

die Vorhersage von Bundesligaspielen in R


Fußballspiele vorherzusagen kann vielleicht als Volkssport beschrieben werden: Angebote zum Tippen gibt es zuhauf und manche sehen die Chance auf das große Geld. Grund genug selbst eine Vorhersage zu treffen und vielleicht sogar Geld beim Tippen zu gewinnen.

1. Daten einlesen

Als erstes laden wir die benötigten Libraries und die notwendigen Daten:

library(httr)
library(jsonlite)
library(dplyr)
library(tidyr)
library(stats)

## 1. Daten aufbereiten
jahre <- c(2022, 2023, 2024, 2025)
all_seasons_data <- data.frame()
for (k in 1:length (jahre)) {
  url <- paste0("https://api.openligadb.de/getmatchdata/bl1/", jahre[k])
  res <- GET(url)
  json_text <- content(res, "text", encoding = "UTF-8") #Text extrahieren, in dataframe umwandeln
  temp_df <- fromJSON(json_text, flatten = TRUE)
  all_seasons_data <- bind_rows(all_seasons_data, temp_df)
  Sys.sleep (0.2)  #verlangsamen, damit sich der loop nicht selbst stört
}

df_clean <- all_seasons_data %>%
  unnest(matchResults, names_sep = "_") %>% # matchResults werden als Listen geliefert; wir lesen sie aus
  filter(matchResults_resultTypeID == 2) %>% # Nur das Endergebnis behalten
  #unnest(goals, names_sep = "_") %>%
  mutate(Spieltag = as.factor(.$group.groupOrderID)) %>%
  mutate(Heim = .$team1.teamName) %>%
  mutate(Gast = .$team2.teamName) %>%
  mutate(ToreHeim = matchResults_pointsTeam1) %>%
  mutate(ToreGast = matchResults_pointsTeam2) %>%
  mutate(Datum = as.Date(substr(matchDateTime, 1, 10))) %>%
  select(Spieltag, Heim, Gast, ToreHeim, ToreGast, Datum)

head(df_clean)
## # A tibble: 6 × 6
##   Spieltag Heim                     Gast            ToreHeim ToreGast Datum     
##   <fct>    <chr>                    <chr>              <int>    <int> <date>    
## 1 1        Eintracht Frankfurt      FC Bayern Münc…        1        6 2022-08-05
## 2 1        FC Augsburg              SC Freiburg            0        4 2022-08-06
## 3 1        VfL Bochum               1. FSV Mainz 05        1        2 2022-08-06
## 4 1        Borussia Mönchengladbach TSG Hoffenheim         3        1 2022-08-06
## 5 1        1. FC Union Berlin       Hertha BSC             3        1 2022-08-06
## 6 1        VfL Wolfsburg            SV Werder Brem…        2        2 2022-08-06

2. Modell erstellen

a) Tore vorhersagen

Nun können wir ein Modell erstellen. Ziel ist es, künftige Tore anhand alter Spielstände vorherzusagen; mit einem stärkeren Fokus auf kürzliche Spiele. Die Poisson-Regression bietet sich an, da sie stets positive Ergebnisse liefert. In der Vorbereitung hierfür verdoppeln wir jede Partie: einmal für Heim, einmal für Gast. Zudem legen wir ein Trainings- und Testdatensatz an:

# Datum formatieren und Gewicht berechnen (jüngere Spiele zählen mehr)
max_date <- max(df_clean$Datum)
model_data_multi <- bind_rows(
  df_clean %>% mutate(Team = Heim, Gegner = Gast, Tore = ToreHeim, Heimvorteil = 1),
  df_clean %>% mutate(Team = Gast, Gegner = Heim, Tore = ToreGast, Heimvorteil = 0)
) %>%
  mutate(
    days_ago = as.numeric(difftime(max_date, Datum, units = "days")),
    gewicht = exp(-0.003 * days_ago) # Decay-Faktor 0.003 für längere Zeiträume
  )

# Sortieren nach Datum zur Sicherheit
model_data_multi <- model_data_multi %>% arrange(Datum)
# Stichtag festlegen (z.B. alles nach dem 01.01.2024 ist Test)
stichtag <- as.Date("2025-11-30")
train_data <- model_data_multi %>% filter(Datum < stichtag)
test_data  <- model_data_multi %>% filter(Datum >= stichtag)
head(train_data)
## # A tibble: 6 × 12
##   Spieltag Heim            Gast  ToreHeim ToreGast Datum      Team  Gegner  Tore
##   <fct>    <chr>           <chr>    <int>    <int> <date>     <chr> <chr>  <int>
## 1 1        Eintracht Fran… FC B…        1        6 2022-08-05 Eint… FC Ba…     1
## 2 1        Eintracht Fran… FC B…        1        6 2022-08-05 FC B… Eintr…     6
## 3 1        FC Augsburg     SC F…        0        4 2022-08-06 FC A… SC Fr…     0
## 4 1        VfL Bochum      1. F…        1        2 2022-08-06 VfL … 1. FS…     1
## 5 1        Borussia Mönch… TSG …        3        1 2022-08-06 Boru… TSG H…     3
## 6 1        1. FC Union Be… Hert…        3        1 2022-08-06 1. F… Herth…     3
## # ℹ 3 more variables: Heimvorteil <dbl>, days_ago <dbl>, gewicht <dbl>

Nun generieren wir das Modell: es schätzt für jedes Team eine Offensivstärke und für jeden Gegner eine Defensivschwäche. Dies bedeutet natürlich, dass ein Spiel nur dann vorhergesagt werden kann, wenn das Modell die beiden Teams kennt. Da jedes Spiel doppelt im Datensatz ist, sind die Beobachtungen nicht mehr unabhängig. Für die Parameterschätzung ist das oft akzeptabel, die Standardfehler sind jedoch tendenziell zu klein (was für die reine Vorhersage meist zweitrangig ist).

# Das Modell trainieren
poisson_model_multi <- glm(Tore ~ Heimvorteil + Team + Gegner, 
                           family = poisson(link = "log"), 
                           data = train_data,
                           weights = gewicht)

b) Wahrscheinlichkeiten

Wir können zudem die Wahrscheinlichkeiten eines Sieges berechnen. Dazu benutzen wir die Poisson-Verteilung, die dem Modell zugrunde liegt. Wir wissen bereits, wie wir die erwarteten Tore berechnen. Die Poisson-Verteilung erlaubt es uns, die Wahrscheinlichkeit für jedes spezifische Endergebnis (0:0, 1:0, 2:1 usw.) zu bestimmen. Mit der Annahme, dass die Torzahl beider Teams unabhängige Zufallsvariablen sind, berechnen wir so die Wahrscheinlichkeiten für Sieg (1), Unentschieden (X) und Niederlage (2):

calc_match_probs <- function(xG_h, xG_g) {
  max_goals <- 8 # Etwas höher für mehr Präzision
  # Matrix der Tor-Wahrscheinlichkeiten (Heim Tore x Gast Tore)
  prob_matrix <- outer(dpois(0:max_goals, xG_h), 
                       dpois(0:max_goals, xG_g))
  win  <- sum(prob_matrix[lower.tri(prob_matrix, diag = FALSE)]) # Untere Hälfte: Heim > Gast
  draw <- sum(diag(prob_matrix))                                 # Diagonale: Heim = Gast
  loss <- sum(prob_matrix[upper.tri(prob_matrix, diag = FALSE)]) # Obere Hälfte: Heim < Gast
  # Normalisieren auf 100%
  total <- win + draw + loss
  return(c(W_Heim = win/total, W_Remis = draw/total, W_Gast = loss/total))
}

c) Anwendung auf ein Beispiel

Im Folgenden simulieren wir ein Beispiel:

neues_spiel_heim <- data.frame(Team = "FC Bayern München", 
                               Gegner = "Borussia Dortmund", 
                               Heimvorteil = 1)
exp_goals_heim <- predict(poisson_model_multi, neues_spiel_heim, type = "response") #Vorhersage der xG-Werte
neues_spiel_gast <- data.frame(Team = "Borussia Dortmund", 
                               Gegner = "FC Bayern München", 
                               Heimvorteil = 0)
exp_goals_gast <- predict(poisson_model_multi, neues_spiel_gast, type = "response")
cat("Erwartete Tore Heim:", round(exp_goals_heim, 2), 
    "\nErwartete Tore Gast:", round(exp_goals_gast, 2))
## Erwartete Tore Heim: 2.57 
## Erwartete Tore Gast: 1.23

Wahrscheinlichkeiten für das Bayern vs. Dortmund Beispiel:

ergebnis_wahrscheinlichkeit <- calc_match_probs(exp_goals_heim, exp_goals_gast)
print(round(ergebnis_wahrscheinlichkeit * 100, 2))
##  W_Heim W_Remis  W_Gast 
##   66.21   17.32   16.46

3. Güte des Modells

Bevor wir weitermachen, wollen wir noch die Güte des Modells anhand des Testatensatzes evaluieren:

#Zuerst berechnen wir die erwarteten Tore für jede Zeile im Testset
test_data$pred_tore <- predict(poisson_model_multi, newdata = test_data, type = "response")

# Da jedes Spiel doppelt ist (Heim- & Gastsicht), transformieren wir es in eine Zeile pro Spiel
test_results_wide <- test_data %>%
  group_by(Datum, Heim, Gast, ToreHeim, ToreGast) %>%
  summarise(
    xG_Heim = pred_tore[Heimvorteil == 1],
    xG_Gast = pred_tore[Heimvorteil == 0],
    .groups = "drop"
  )

# Wahrscheinlichkeiten und Güte-Metriken berechnen
# 1X2 Wahrscheinlichkeiten für jedes Spiel berechnen
probs <- t(mapply(calc_match_probs, test_results_wide$xG_Heim, test_results_wide$xG_Gast))
test_results_wide <- cbind(test_results_wide, probs)

# Tatsächliche Ergebnisse binär kodieren (für Brier Score & Log-Loss)
test_results_wide <- test_results_wide %>%
  mutate(
    act_Heim  = as.numeric(ToreHeim > ToreGast),
    act_Remis = as.numeric(ToreHeim == ToreGast),
    act_Gast  = as.numeric(ToreHeim < ToreGast)
  )

# --- METRIKEN BERECHNEN ---

# 1. MSE (Mean Squared Error) für die Tore
mse_tore <- mean((test_results_wide$ToreHeim - test_results_wide$xG_Heim)^2 + 
                 (test_results_wide$ToreGast - test_results_wide$xG_Gast)^2)

# 2. Brier Score (Genauigkeit der Wahrscheinlichkeiten)
# (Summe der quadratischen Abweichungen der 3 Ausgänge)
brier_score <- mean((test_results_wide$W_Heim  - test_results_wide$act_Heim)^2 + 
                    (test_results_wide$W_Remis - test_results_wide$act_Remis)^2 + 
                    (test_results_wide$W_Gast  - test_results_wide$act_Gast)^2)

# 3. Log-Loss (Logarithmische Fehlermasse)
eps <- 1e-15 # Um log(0) zu vermeiden
log_loss <- -mean(
  test_results_wide$act_Heim  * log(pmax(test_results_wide$W_Heim, eps)) +
  test_results_wide$act_Remis * log(pmax(test_results_wide$W_Remis, eps)) +
  test_results_wide$act_Gast  * log(pmax(test_results_wide$W_Gast, eps))
)

# Erstelle die Ausgabe als übersichtlichen Data Frame
guete_ausgabe <- data.frame(
    Anzahl_Spiele = nrow(test_results_wide),
    MSE           = round(mse_tore, 4),
    Brier_Score   = round(brier_score, 4),
    Log_Loss      = round(log_loss, 4)
)
print(guete_ausgabe)
##   Anzahl_Spiele    MSE Brier_Score Log_Loss
## 1            83 3.0351      0.6125   1.0278

Anzahl_Spiele Das Testset muss groß genug sein, mind 18.36 Spiele

MSE - 1.3 bis 1.5: sehr guter Wert in dem sich auch professionelle Wettmodelle bewegen - 1.6 bis 2.0: Akzeptabel. Das Modell erfasst die grundlegende Stärke der Teams, hat aber Schwierigkeiten bei Ausreißern (hohe Siege/Niederlagen). - 2.5: Schwach. Das Modell ist kaum besser als der Durchschnittswert aller Tore. Der MSE bestraft große Abweichungen quadratisch. Sage ich ein 1:1 vorher, das Spiel endet aber 5:0, dann ist der MSE extem hoch. Da Fußball zufallsbehaftet ist und Tore selten fallen, hat der Datensatz also ein hohes Rauschen.

Log-Loss Besser als MSE. Es bewertet, wie sicher das Modell bei der richtigen Tendenz (Wahrscheinlichkeit) war. Professionelle Modelle liegen hier oft zwischen 0.95 und 1.05. Je niedriger, desto besser „kalibriert“ sind die Wahrscheinlichkeiten.

Brier-Score Er misst die Genauigkeit der Wahrscheinlichkeiten. Ein Wert unter 0.55 ist im Fußball bereits sehr kompetitiv. Alles über 0.66 deutet darauf hin, dass das Modell schlechter würfelt als der Zufall.

5. Predict

Nun können wir loslegen und unsere Vorhersage starten:

## Predict
predictions_df <- all_seasons_data %>%
  mutate(Spieltag = as.factor(.$group.groupOrderID)) %>%
  mutate(Heim = .$team1.teamName) %>%
  mutate(Gast = .$team2.teamName) %>%
  mutate(Datum = as.Date(substr(matchDateTime, 1, 10))) %>%
  filter(Datum > as.Date(Sys.time())) %>% #alle zukünftigen Spieltage
  drop_na(Spieltag) %>%
  select(Spieltag, Heim, Gast, Datum)

# 1. Liste aller Teams abrufen, die das Modell kennt
bekannte_teams <- levels(factor(train_data$Team))

# 2. Leere Spalten für die Vorhersagen im predictions_df vorbereiten
predictions_df$ExpToreHeim <- NA
predictions_df$ExpToreGast <- NA
predictions_df$W_Heim  <- NA
predictions_df$W_Remis <- NA
predictions_df$W_Gast  <- NA

for (i in 1:nrow(predictions_df)) {
  heim <- predictions_df$Heim[i]
  gast <- predictions_df$Gast[i]
  
  if (heim %in% bekannte_teams & gast %in% bekannte_teams) {
    # 1. Erwartete Tore berechnen
    h_data <- data.frame(Team = heim, Gegner = gast, Heimvorteil = 1)
    exp_h  <- predict(poisson_model_multi, h_data, type = "response")
    predictions_df$ExpToreHeim[i] <- round(exp_h, 2)
    
    a_data <- data.frame(Team = gast, Gegner = heim, Heimvorteil = 0)
    exp_g  <- predict(poisson_model_multi, a_data, type = "response")
    predictions_df$ExpToreGast[i] <- round(exp_g, 2)
    
    # 2. Wahrscheinlichkeiten mit deiner Funktion berechnen
    probs <- calc_match_probs(exp_h, exp_g)
    
    # 3. Ergebnisse in den Dataframe schreiben
    predictions_df$W_Heim[i]  <- round(probs["W_Heim"], 4)
    predictions_df$W_Remis[i] <- round(probs["W_Remis"], 4)
    predictions_df$W_Gast[i]  <- round(probs["W_Gast"], 4)
    
  } else {
    next
  }
}

# 4. Nur Spiele mit Vorhersagen anzeigen
final_preds <- predictions_df %>% filter(!is.na(ExpToreHeim))

# der nächste Spieltag
final_preds %>%
  filter(Datum == min(as.numeric(Datum))) %>%
  print()
##   Spieltag                Heim                     Gast      Datum ExpToreHeim
## 1       22 Bayer 04 Leverkusen             FC St. Pauli 2026-02-14        2.18
## 2       22 Eintracht Frankfurt Borussia Mönchengladbach 2026-02-14        2.30
## 3       22    SV Werder Bremen        FC Bayern München 2026-02-14        1.01
## 4       22      TSG Hoffenheim              SC Freiburg 2026-02-14        1.89
## 5       22        Hamburger SV       1. FC Union Berlin 2026-02-14        0.87
## 6       22       VfB Stuttgart               1. FC Köln 2026-02-14        2.19
##   ExpToreGast W_Heim W_Remis W_Gast
## 1        0.61 0.7361  0.1748 0.0892
## 2        1.47 0.5618  0.1976 0.2405
## 3        2.96 0.0985  0.1352 0.7664
## 4        1.48 0.4739  0.2227 0.3034
## 5        1.06 0.2943  0.3127 0.3930
## 6        1.31 0.5736  0.2029 0.2235

Photo by Alexander Nadrilyanski: https://www.pexels.com/photo/photo-of-men-playing-soccer-during-daytime-3651674/

Beliebte Posts aus diesem Blog

Sentiment-Analyse von deutschen Texten in R

Eine Sentiment-Analyse funktioniert im Grunde wiefolgt: die einzelnen Wörter werden eines Textes werden mit bestimmten Bibliotheken abgeglichen und eine Einteilung in "positiv/negativ" oder ein hinterlegter Sentiment-Wert abgegriffen. Die Summe dieser Werte ergibt schließlich den Sentiment-Score des ganzen Texts. Für englische Texte sind in R bereits Bibliotheken hinterlegt (z.B. im Package tidytext ). Für deutsche Texte habe ich auf meiner Recherche die Bibliothek  SentiWS  der Universität Leipzig gefunden. Die rund 16.000 positiven und 18.000 negativen Wörter sind mit einer Wertspanne von -1 zu 1 hinterlegt. Das Problem ist, dass diese in zwei Textdateien kommen, deren Format erst aufbereitet werden muss. So sieht die Bibliothek beim Einlesen aus: Mit folgendem Code habe ich mir die Bibliothek operationalisiert: library(dplyr) # SentiWS - Dateien hier runterladen: https://wortschatz.uni-leipzig.de/en/download # a) negative Wörter # die Textdatei einlesen negat...

Was ist fremd?

brandy74 "Malstunde" Some rights reserved. www.piqs.de Der Begriff Fremdheit wird benutzt zur Charakterisierung einer Beziehung. Immer muss etwas bekannt sein um es auch als fremd zu bezeichnen; andernfalls kann es nicht beschrieben werden. Wissenschaftlich wird die Fremdheit oft auch als die Gleichzeitigkeit von Nähe und Entferntheit, von Verbundenheit und Getrenntheit charakterisiert. Wer demnach etwas als fremd bezeichnet, unterscheidet die Welt an dieser Stelle in ein Innen und ein Außen. Das Fremde sei jenseits einer einer imaginären Grenze. Diese Grenzen können unterschiedlich lokalisiert werden. Bei der kulturellen Fremdheit werden andere kulturelle Verhaltensweisen und Ansichten identifiziert und als fremd bezeichnet. Bei der sozialen Fremdheit ist der Fremde hingegen Teil der eigenen Gesellschaft, der eigenen Gemeinschaft. Durch die Zuschreibung der sozialen Fremdheit wird er aus dem eigenen Bereich, also dem eigenen sozialen Milieu, exkludiert. Drückt sich...

Migration und Bevölkerungsentwicklung: Solidarität und Selbsthilfe

Aus: Neue Potenziale - zur Lage der Nation in Deutschland , Juni 2014,  Berlin-Institut für Bevölkerung und Entwicklung Vor ein paar Wochen war ich auf einem sehr spannenden Vortrag am ifo-Institut in München von Herrn Dr. Klingholz, Direktor des Berlin Instituts für Bevölkerung und Entwicklung. Der Vortrag widmete sich einerseits der Zusammensetzung und dem Bildungs- wie Integrationsgrad deutscher Migranten und andererseits der zukünftigen Bevölkerungsentwicklung in Teilen der Welt und deren Auswirkungen auf die Migration in Europa, bzw. Deutschland. Polarisierend Unterteilt man die Migranten(1) nach Gruppen hinsichtlich ihrer Herkunftsländer, so zeigt sich oft eine starke Polarisierung des Bildungsgrades. Beispiel Rumänien und Polen. Zwar ist der Anteil der Migranten aus Rumänien und Polen ohne Bildungsabschluss wesentlich höher als der Anteil der Einheimischen. Umgekehrt ist der Anteil an Akademikern bei Migranten aus Rumänien und Polen höher als bei Einheimischen. A...

die Hot-Dog-Ökonomie

Diego Torres Silvestre " Ice Creams, Hot Dogs & Pretzels" Some rights reserved. www.piqs.de Man stelle sich eine Wirtschaft vor, in der nur zwei Güter hergestellt würden: Würstchen und Brötchen. Konsumenten würden Hotdogs kaufen; also jeweils ein Brötchen mit einer Wurst. Die Fertigung geschieht durch Menschenhand. So fing Paul Krugman 1997 einen Artikel für das Online-Magazine Slate an, in welchem er den Zusammenhang von Technologie, Jobs und Kapitalismus erklären will. Er fährt fort, dass in dieser Wirtschaft 120 Millionen Arbeiter beschäftigt sind, was einer Vollbeschäftigung entspreche. Zur Herstellung einer Wurst oder eines Brötchens benötige es zwei Arbeitstage. Die 60 Millionen Angestellten in der Brötchenproduktion und genauso viele in der Wurstfabrikationen produzieren demnach täglich 30 Millionen Brötchen und Würste. Angenommen es komme eine verbesserte Technologie auf, mit deren Hilfe ein Arbeiter zur Herstellung einer Wurst nur noch einen Tag ...

die schöne Welt von Red Bull

Till Krech "wroooom" Some rights reserved. www.piqs.de Red Bull – vom Marktführer für Energiegetränke zum kommenden Medienimperium? Das Magazin Fast Company vergab in der Liste „The World´s 50 Most Innovative Companies“ den 29. Platz an Red Bull für genau diese Entwicklung. Gebündelt unter dem Dach der Red Bull Media House GmbH besitzt der Konzern mittlerweile verschiedene Medienbeteiligungen und Neugründungen. Kritiker bezeichnen es als eine gewaltige Marketingmaschine. Rund ein Drittel des Umsatzes wird für die Pflege des Marktauftritts ausgegeben. Eine firmeninterne Nachrichtenagentur sammelt Inhalte zu einen der vielen weltweiten aufsehenerregenden Red-Bull-Ereignisse, um sie externen Medien gebündelt und aufbereitet zur Verfügung zu stellen. Über eigene Medien werden die Konsumenten sogar direkt erreicht. Das 2007 gegründete Hochglanzmagazin "Red Bulletin" hat bereits eine Auflage von 5 Millionen Heften erreicht und wird mehrspraching in zwö...

Verspargelung der Landschaft

FZ 18: "Mount Idarkopf" www.piqs.de Some Rights reserved. Vielleicht ist es, weil ich erst 22 Jahre alt bin. Vielleicht weil es bei meiner Heimatstadt schon seit mehr als zehn Jahren ein Windrad gibt. Aber das Argument einer Verspargelung der Landschaft durch Windräder zählt für mich nicht. Ich komme aus Baden-Württemberg. Insofern verfolgt mich das Argument der Verspargelung der Landschaft durch den ehemaligen baden-württembergischen Ministerpräsidenten Erwin Teufel fast genauso lange wie das Windrad vor meiner Haustür. Das Argument wird immer wieder von jenen hervorgebracht, welche gegen die Aufstellung von Windrädern sind. Die einen fürchten um die Landschaft, andere finden sie einfach nicht schön und noch andere bringen es nur als Vorwand. Besonders die Nähe zur Atomwirtschaft fällt einem bei der hießigen CDU auf. In Baden-Württemberg ist der Fall bei den Windrädern vielleicht ein bisschen spezieller. Wenn man hier die Windenergie effizient nutzen will, so...