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-062. 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.23Wahrscheinlichkeiten 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.463. 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.0278Anzahl_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.2235Photo by Alexander Nadrilyanski: https://www.pexels.com/photo/photo-of-men-playing-soccer-during-daytime-3651674/