How does a dynamic Bayesian Bradley-Terry (BT) model, in which team strengths drift between matches, compare with traditional Elo ratings when predicting the 2026 FIFA World Cup champion?
Approach
Knit with Knit with Parameters in RStudio to change the
number of simulations, seed, back-test cut-off date, or to re-run the
sampler (refit). Posterior samples are cached in
.rds files, so a normal knit does not re-sample.
library(worldfootballR)
library(dplyr)
library(BradleyTerry2)
library(ggplot2)
library(gt)
library(tidyr)
library(stringr)
library(lubridate)
library(ggrepel)
library(viridis)
library(scales)
library(tibble)
library(Matrix)
library(glmnet)
Matches come from FBref via worldfootballR. The download
is cached in wc_raw_matches.rds next to this file; set the
refetch parameter to true to download
again.
comps <- c(
"FIFA World Cup",
"FIFA World Cup Qualification — AFC",
"FIFA World Cup Qualification — CAF",
"FIFA World Cup Qualification — CONCACAF",
"FIFA World Cup Qualification — CONMEBOL",
"FIFA World Cup Qualification — UEFA",
"UEFA Nations League",
"UEFA European Football Championship",
"UEFA European Football Championship Qualifying",
"AFC Asian Cup",
"Africa Cup of Nations",
"CONCACAF Gold Cup",
"CONMEBOL Copa América",
"International Friendlies (M)"
)
raw_file <- "wc_raw_matches.rds"
if (file.exists(raw_file) && !isTRUE(params$refetch)) {
df_raw <- readRDS(raw_file)
} else {
df_raw <- load_match_comp_results(comp_name = comps)
saveRDS(df_raw, raw_file)
}
cat(sprintf("%d raw matches, %d competitions\n", nrow(df_raw), n_distinct(df_raw$Competition_Name)))
## 12253 raw matches, 13 competitions
FBref attaches a country code to every team name
(e.g. "United States us", "eng England"). Only
lowercase codes are stripped, so names such as "DR Congo"
are not damaged. Competition names are normalised (em dash to hyphen,
accents removed) so that they match the weight tables.
clean_team_name <- function(name) {
name <- str_trim(name)
name <- str_replace(name, "^[a-z]{2,3}\\s+", "") # leading code: "us United States", "eng England"
name <- str_replace(name, "\\s+[a-z]{2,3}$", "") # trailing code: "France fr"
str_squish(name)
}
country_map <- c(
"USA" = "United States",
"Korea Republic" = "South Korea",
"Korea Rep" = "South Korea",
"Republic of Korea" = "South Korea",
"Iran" = "IR Iran",
"Ivory Coast" = "Cote d'Ivoire",
"Cape Verde" = "Cabo Verde",
"Czech Republic" = "Czechia",
"Bosnia" = "Bosnia and Herzegovina",
"Trinidad" = "Trinidad and Tobago",
"United Arab Emirates" = "UAE",
"China PR" = "China",
"Chinese Taipei" = "Taiwan",
"DPR Korea" = "North Korea"
)
standardise_name <- function(x) {
y <- clean_team_name(x)
idx <- match(y, names(country_map))
ifelse(is.na(idx), y, unname(country_map)[idx])
}
normalise_comp <- function(x) {
x <- str_replace_all(x, c("—" = "-", "–" = "-", "é" = "e"))
str_squish(x)
}
comp_weights <- c(
"FIFA World Cup" = 3.0,
"CONMEBOL Copa America" = 2.5,
"UEFA European Football Championship" = 2.5,
"Africa Cup of Nations" = 2.0,
"AFC Asian Cup" = 2.0,
"CONCACAF Gold Cup" = 2.0,
"FIFA World Cup Qualification - UEFA" = 1.8,
"FIFA World Cup Qualification - CONMEBOL" = 1.8,
"FIFA World Cup Qualification - AFC" = 1.5,
"FIFA World Cup Qualification - CAF" = 1.5,
"FIFA World Cup Qualification - CONCACAF" = 1.5,
"UEFA Nations League" = 1.5,
"UEFA European Football Championship Qualifying" = 1.3,
"International Friendlies (M)" = 0.5
)
elo_k_map <- c(
"FIFA World Cup" = 60,
"CONMEBOL Copa America" = 50,
"UEFA European Football Championship" = 50,
"Africa Cup of Nations" = 40,
"AFC Asian Cup" = 40,
"CONCACAF Gold Cup" = 40,
"FIFA World Cup Qualification - UEFA" = 40,
"FIFA World Cup Qualification - CONMEBOL" = 40,
"FIFA World Cup Qualification - AFC" = 32,
"FIFA World Cup Qualification - CAF" = 32,
"FIFA World Cup Qualification - CONCACAF" = 32,
"UEFA Nations League" = 35,
"UEFA European Football Championship Qualifying" = 30,
"International Friendlies (M)" = 20
)
lambda_decay <- log(2) / 912
# time-decay x competition weight, scaled to a maximum of 1
time_w <- function(d, ref_date) {
w <- exp(-lambda_decay * as.numeric(ref_date - d$Date)) * d$comp_weight
w / max(w)
}
df_clean <- df_raw %>%
rename(Competition = Competition_Name) %>%
filter(!is.na(HomeGoals), !is.na(AwayGoals), !is.na(Home), !is.na(Away)) %>%
mutate(
Competition = normalise_comp(Competition),
Date = as.Date(Date),
team1 = standardise_name(Home),
team2 = standardise_name(Away),
score1 = as.integer(HomeGoals),
score2 = as.integer(AwayGoals)
) %>%
filter(Date >= as.Date("2018-01-01")) %>%
filter(nchar(team1) > 2, nchar(team2) > 2) %>%
distinct(Date, team1, team2, score1, score2, .keep_all = TRUE) %>%
mutate(
comp_weight = unname(comp_weights[Competition]),
K = unname(elo_k_map[Competition])
)
# every competition must have found its weight (this was silently failing before)
stopifnot(!anyNA(df_clean$comp_weight), !anyNA(df_clean$K))
# keep teams with >= 8 matches
n_by_team <- table(c(df_clean$team1, df_clean$team2))
eligible_teams <- sort(names(n_by_team)[n_by_team >= 8])
df_clean <- df_clean %>%
filter(team1 %in% eligible_teams, team2 %in% eligible_teams) %>%
arrange(Date)
t_now <- max(df_clean$Date)
cat(sprintf("%d matches | %d teams | %s to %s\n", nrow(df_clean), length(eligible_teams),
min(df_clean$Date), t_now))
## 4262 matches | 193 teams | 2018-01-11 to 2025-07-06
cat(sprintf("Draw rate: %.1f%% | Mean goals per match: %.2f\n",
100 * mean(df_clean$score1 == df_clean$score2),
mean(df_clean$score1 + df_clean$score2)))
## Draw rate: 23.9% | Mean goals per match: 2.62
df_clean %>%
count(Competition, comp_weight, K, name = "matches") %>%
arrange(desc(comp_weight)) %>%
gt() %>%
tab_header(title = "Matches by competition", subtitle = "With importance weight and Elo K-factor")
| Matches by competition | |||
| With importance weight and Elo K-factor | |||
| Competition | comp_weight | K | matches |
|---|---|---|---|
| FIFA World Cup | 3.0 | 60 | 128 |
| CONMEBOL Copa America | 2.5 | 50 | 86 |
| UEFA European Football Championship | 2.5 | 50 | 102 |
| AFC Asian Cup | 2.0 | 40 | 102 |
| Africa Cup of Nations | 2.0 | 40 | 156 |
| CONCACAF Gold Cup | 2.0 | 40 | 124 |
| FIFA World Cup Qualification - UEFA | 1.8 | 40 | 258 |
| FIFA World Cup Qualification - AFC | 1.5 | 32 | 410 |
| FIFA World Cup Qualification - CAF | 1.5 | 32 | 96 |
| FIFA World Cup Qualification - CONCACAF | 1.5 | 32 | 13 |
| UEFA Nations League | 1.5 | 35 | 628 |
| UEFA European Football Championship Qualifying | 1.3 | 30 | 501 |
| International Friendlies (M) | 0.5 | 20 | 1658 |
Each model is wrapped in a function
fit_*(train, teams, ref_date) that returns team strengths
and a predict(team1, team2) function giving Win /
Draw / Loss probabilities at a neutral venue.
This lets the same code be used for the back-test and for the final
fit.
Bradley-Terry models P(i beats j) = plogis(a_i - a_j).
The original analysis dropped drawn matches, which throws away about a
quarter of all international games. Here each draw is split into half a
win for each side (two rows with half weight each), so every match
contributes. Fitting uses Firth’s bias-reduced estimation
(br = TRUE), which keeps teams with perfect records from
getting infinite abilities. A switch (use_draws = FALSE)
reproduces the original decisive-matches-only fit for comparison.
Win/draw/loss probabilities are derived from the BT “score”
p using a draw probability that is largest for even
matches: P(draw) = d * 4p(1 - p), where d is
the weighted draw rate in the training data.
wdl_from_p <- function(p, d_bar) {
pd <- d_bar * 4 * p * (1 - p)
cbind(W = p - pd / 2, D = pd, L = 1 - p - pd / 2)
}
weighted_draw_rate <- function(d, w) sum(w * (d$score1 == d$score2)) / sum(w)
fit_bt <- function(train, teams, ref_date, use_draws = TRUE) {
train$w <- time_w(train, ref_date)
dec <- train[train$score1 != train$score2, ]
dec$win1 <- as.integer(dec$score1 > dec$score2)
dec$win2 <- 1L - dec$win1
rows <- dec
if (use_draws) {
dr <- train[train$score1 == train$score2, ]
dr$w <- dr$w / 2
a <- dr; a$win1 <- 1L; a$win2 <- 0L
b <- dr; b$win1 <- 0L; b$win2 <- 1L
rows <- bind_rows(dec, a, b)
}
rows$team1 <- factor(rows$team1, levels = teams)
rows$team2 <- factor(rows$team2, levels = teams)
fit <- tryCatch(
BTm(cbind(win1, win2), team1, team2, weights = w, data = rows, id = "team", br = TRUE),
error = function(e)
BTm(cbind(win1, win2), team1, team2, weights = w, data = rows, id = "team"))
ab <- BTabilities(fit)
est <- setNames(ab[, "ability"], rownames(ab))
se <- setNames(ab[, "s.e."], rownames(ab))
est[is.na(est)] <- 0
full <- setNames(rep(0, length(teams)), teams); full[names(est)] <- est
fse <- setNames(rep(NA_real_, length(teams)), teams); fse[names(se)] <- se
d_bar <- weighted_draw_rate(train, train$w)
list(name = if (use_draws) "Bradley-Terry (draws as half-wins)" else "Bradley-Terry (decisive only)",
strength = full, se = fse, d_bar = d_bar,
predict = function(t1, t2) wdl_from_p(plogis(full[t1] - full[t2]), d_bar))
}
Competition-specific K-factor, margin-of-victory multiplier
log(|gd| + 1) + 1, draws as 0.5. All teams start at 1500
and ratings are updated match by match in date order. The full rating
history is kept for plotting.
fit_elo <- function(train, teams, ref_date) {
d <- train[order(train$Date), ]
r <- setNames(rep(1500, length(teams)), teams)
n <- nrow(d)
hist <- tibble(Date = as.Date(rep(NA, 2 * n)), team = character(2 * n), elo = numeric(2 * n))
for (k in seq_len(n)) {
h <- d$team1[k]; a <- d$team2[k]
Rh <- r[[h]]; Ra <- r[[a]]
Eh <- 1 / (1 + 10^((Ra - Rh) / 400))
g1 <- d$score1[k]; g2 <- d$score2[k]
Sh <- if (g1 > g2) 1 else if (g1 < g2) 0 else 0.5
delta <- d$K[k] * (log(abs(g1 - g2) + 1) + 1) * (Sh - Eh)
r[[h]] <- Rh + delta
r[[a]] <- Ra - delta
hist$Date[c(2 * k - 1, 2 * k)] <- d$Date[k]
hist$team[c(2 * k - 1, 2 * k)] <- c(h, a)
hist$elo[c(2 * k - 1, 2 * k)] <- c(r[[h]], r[[a]])
}
d_bar <- weighted_draw_rate(train, time_w(train, ref_date))
list(name = "Elo", strength = r, history = hist, d_bar = d_bar,
predict = function(t1, t2) wdl_from_p(1 / (1 + 10^((r[t2] - r[t1]) / 400)), d_bar))
}
The original “Poisson” rating was the z-score of goal difference per match, which is not a Poisson model. Here goals are modelled properly:
log(mu_scoring_team) = intercept + attack_i + conceding_j + home,
estimated by weighted, ridge-penalised Poisson regression
(glmnet, penalty chosen by cross-validation). The ridge
penalty stops teams with few matches from getting extreme ratings.home term (the team listed first) absorbs home
advantage so it does not contaminate team strengths. Predictions for the
World Cup are at a neutral venue (home = 0).Z ~ Poisson(lambda3), estimated from the weighted
covariance of the residual goals (floored at 0). Then
X = X1 + Z, Y = Y1 + Z, which allows goals to
be positively correlated (more draws and more high-scoring games than
independent Poisson). If the data show no positive correlation,
lambda3 = 0 and the model is independent Poisson (see the
limitations section for the fitted value).# W/D/L probabilities from the bivariate Poisson with marginal means m1, m2 and shared rate l3
biv_wdl <- function(m1, m2, l3, kmax = 10) {
out <- matrix(0, length(m1), 3, dimnames = list(NULL, c("W", "D", "L")))
for (n in seq_along(m1)) {
a <- max(m1[n] - l3, 0.02); b <- max(m2[n] - l3, 0.02)
pa <- dpois(0:kmax, a); pb <- dpois(0:kmax, b)
P <- matrix(0, kmax + 1, kmax + 1)
for (k in 0:kmax) {
sx <- c(rep(0, k), pa)[1:(kmax + 1)]
sy <- c(rep(0, k), pb)[1:(kmax + 1)]
P <- P + dpois(k, l3) * outer(sx, sy)
}
P <- P / sum(P)
out[n, ] <- c(sum(P[row(P) > col(P)]), sum(diag(P)), sum(P[row(P) < col(P)]))
}
out
}
fit_pois <- function(train, teams, ref_date) {
nt <- length(teams)
w <- time_w(train, ref_date)
n <- nrow(train)
i1 <- match(train$team1, teams); i2 <- match(train$team2, teams)
# long format: one row per team per match. columns = attack (nt) | conceding (nt) | home (1)
rows <- 1:(2 * n)
X <- sparseMatrix(
i = rep(rows, 3),
j = c(c(i1, i2), nt + c(i2, i1), rep(2 * nt + 1, 2 * n)),
x = c(rep(1, 4 * n), rep(c(1, 0), each = n)),
dims = c(2 * n, 2 * nt + 1))
X <- drop0(X)
y <- c(train$score1, train$score2)
ww <- c(w, w)
pf <- c(rep(1, 2 * nt), 0) # home effect is not penalised
set.seed(1)
cv <- cv.glmnet(X, y, family = "poisson", weights = ww, alpha = 0, nfolds = 5,
penalty.factor = pf, standardize = FALSE)
cf <- as.numeric(coef(cv, s = "lambda.min"))
int <- cf[1]
att <- setNames(cf[2:(nt + 1)], teams)
con <- setNames(cf[(nt + 2):(2 * nt + 1)], teams) # > 0 means the team concedes more
hom <- cf[2 * nt + 2]
m1 <- exp(int + att[train$team1] + con[train$team2] + hom)
m2 <- exp(int + att[train$team2] + con[train$team1])
l3 <- max(0, min(0.3, sum(w * (train$score1 - m1) * (train$score2 - m2)) / sum(w)))
list(name = "Bivariate Poisson", att = att, con = con, int = int, home = hom, l3 = l3,
strength = att - con,
predict = function(t1, t2) {
biv_wdl(exp(int + att[t1] + con[t2]), exp(int + att[t2] + con[t1]), l3)
})
}
The models above give one fixed rating per team, with fixed weights to forget old matches. The Bayesian model treats team strength as something that changes over time and learns how fast from the data.
Model. Time is split into half-year periods \(t = 1, \dots, T\). Team \(j\) has strength \(\beta_{j,t}\) in period \(t\).
\[ \beta_{j,1} = \sigma_0\, z_j, \qquad \beta_{j,t} = \beta_{j,t-1} + \sigma_{\text{drift}}\, \varepsilon_{j,t}, \qquad z_j, \varepsilon_{j,t} \sim N(0,1) \]
For a match in period \(t\), let \(d = \beta_{i,t} - \beta_{j,t} + h \cdot \text{home}\). The result probabilities are (Davidson form):
\[ P(i \text{ wins}) = \frac{e^{d/2}}{e^{d/2} + e^{-d/2} + \nu}, \quad P(\text{draw}) = \frac{\nu}{e^{d/2} + e^{-d/2} + \nu}, \quad P(j \text{ wins}) = \frac{e^{-d/2}}{e^{d/2} + e^{-d/2} + \nu} \]
Priors. \(\sigma_0 \sim N^+(0,1)\), \(\sigma_{\text{drift}} \sim N^+(0, 0.3)\), \(h \sim N(0, 0.5)\), \(\log\nu \sim N(0,1)\). The random-walk innovations are sampled in standardised (“non-centred”) form so that HMC explores the posterior efficiently.
Home advantage. home = 1 when the
first-listed team plays at home. It is 0 for tournament
finals (World Cup, Euros, Copa América, AFCON, Asian Cup, Gold Cup),
where the venue is treated as neutral. This is an assumption, because
the data has no venue flag for every match.
Sampling. Hamiltonian Monte Carlo with the No-U-Turn sampler (NUTS) in Stan. Competition weights are not used here: drift replaces time weighting, and every match counts equally in the likelihood.
The Stan program (dynamic_bt.stan):
// Dynamic Bayesian Bradley-Terry model with draws (Davidson form).
// Team strengths follow a Gaussian random walk over time periods.
//
// beta[j, 1] = sigma0 * z0[j]
// beta[j, t] = beta[j, t-1] + sigma_drift * eps[j, t-1]
//
// d = beta[team1, period] - beta[team2, period] + h * home
// P(team1 wins) = exp(d/2) / (exp(d/2) + exp(-d/2) + nu)
// P(draw) = nu / (exp(d/2) + exp(-d/2) + nu)
// P(team2 wins) = exp(-d/2)/ (exp(d/2) + exp(-d/2) + nu)
//
// y = 1: team2 wins, 2: draw, 3: team1 wins.
data {
int<lower=1> N; // matches
int<lower=1> J; // teams
int<lower=2> T; // time periods
array[N] int<lower=1, upper=J> team1;
array[N] int<lower=1, upper=J> team2;
array[N] int<lower=1, upper=T> period;
array[N] int<lower=1, upper=3> y;
vector<lower=0, upper=1>[N] home; // 1 if team1 plays at home, else 0 (neutral)
}
parameters {
vector[J] z0; // initial strengths (standardised)
matrix[J, T - 1] eps; // random-walk innovations (standardised)
real<lower=0> sigma0; // spread of initial strengths
real<lower=0> sigma_drift; // drift per period
real h; // home advantage (logit scale)
real log_nu; // draw parameter
}
transformed parameters {
matrix[J, T] beta;
real<lower=0> nu = exp(log_nu);
beta[, 1] = sigma0 * z0;
for (t in 2:T) {
beta[, t] = beta[, t - 1] + sigma_drift * eps[, t - 1];
}
}
model {
z0 ~ std_normal();
to_vector(eps) ~ std_normal();
sigma0 ~ normal(0, 1); // half-normal (lower bound 0)
sigma_drift ~ normal(0, 0.3); // half-normal
h ~ normal(0, 0.5);
log_nu ~ normal(0, 1);
for (n in 1:N) {
real d = beta[team1[n], period[n]] - beta[team2[n], period[n]] + h * home[n];
y[n] ~ categorical_logit([-d / 2, log_nu, d / 2]');
}
}
generated quantities {
array[N] int y_rep;
vector[N] log_lik;
for (n in 1:N) {
real d = beta[team1[n], period[n]] - beta[team2[n], period[n]] + h * home[n];
vector[3] lg = [-d / 2, log_nu, d / 2]';
y_rep[n] = categorical_logit_rng(lg);
log_lik[n] = categorical_logit_lpmf(y[n] | lg);
}
}
finals_comps <- c("FIFA World Cup", "UEFA European Football Championship", "AFC Asian Cup",
"Africa Cup of Nations", "CONCACAF Gold Cup", "CONMEBOL Copa America")
# half-year periods counted from 2018: 2018H1 = 1, 2018H2 = 2, ...
period_of <- function(date) (year(date) - 2018L) * 2L + ifelse(month(date) <= 6, 1L, 2L)
make_stan_data <- function(d, teams) {
per <- period_of(d$Date)
list(N = nrow(d), J = length(teams), T = max(per, 2L),
team1 = match(d$team1, teams), team2 = match(d$team2, teams),
period = per,
y = ifelse(d$score1 > d$score2, 3L, ifelse(d$score1 == d$score2, 2L, 1L)),
home = as.numeric(!d$Competition %in% finals_comps))
}
# Sample (or load from cache) the posterior. Returns a plain list so a cached knit does not need rstan.
fit_dyn_bt <- function(d, teams, cache_file) {
sdat <- make_stan_data(d, teams)
key <- list(N = sdat$N, J = sdat$J, T = sdat$T, iter = params$iter, chains = params$chains,
seed = params$seed, adapt_delta = params$adapt_delta, last = as.character(max(d$Date)))
if (file.exists(cache_file) && !isTRUE(params$refit)) {
obj <- readRDS(cache_file)
if (identical(obj$key, key)) return(obj)
}
if (!requireNamespace("rstan", quietly = TRUE))
stop("Package 'rstan' is needed to sample the posterior. Install it with install.packages('rstan').")
rstan::rstan_options(auto_write = TRUE)
# rstan appends -std=c++1y, but recent Stan versions need C++17. Makevars.win (in this folder) restores it.
if (file.exists("Makevars.win")) Sys.setenv(R_MAKEVARS_USER = normalizePath("Makevars.win"))
mod <- rstan::stan_model("dynamic_bt.stan")
fit <- rstan::sampling(mod, data = sdat, chains = params$chains, iter = params$iter,
warmup = params$iter / 2, seed = params$seed, refresh = 0,
cores = min(params$chains, parallel::detectCores()),
control = list(adapt_delta = params$adapt_delta, max_treedepth = 12))
sp <- rstan::get_sampler_params(fit, inc_warmup = FALSE)
hyp <- c("sigma0", "sigma_drift", "h", "nu")
bsum <- rstan::summary(fit, pars = "beta")$summary
ex <- rstan::extract(fit, pars = c("beta", "sigma0", "sigma_drift", "h", "nu", "y_rep"))
idx <- seq(1, dim(ex$beta)[1], by = 4) # thin to ~1000 draws for storage
obj <- list(
key = key, data = sdat, teams = teams,
diag = list(
divergences = sum(vapply(sp, function(x) sum(x[, "divergent__"]), numeric(1))),
depth_hits = sum(vapply(sp, function(x) sum(x[, "treedepth__"] >= 12), numeric(1))),
min_ebfmi = min(rstan::get_bfmi(fit)),
max_rhat = max(bsum[, "Rhat"], na.rm = TRUE),
min_neff = min(bsum[, "n_eff"], na.rm = TRUE),
runtime_secs = sum(rstan::get_elapsed_time(fit))),
hyper_summary = rstan::summary(fit, pars = hyp)$summary[, c("mean", "sd", "2.5%", "97.5%", "n_eff", "Rhat")],
hyper_trace = as.array(fit, pars = hyp), # iterations x chains x parameters
post = list(beta = ex$beta[idx, , , drop = FALSE], sigma0 = ex$sigma0[idx],
sigma_drift = ex$sigma_drift[idx], h = ex$h[idx], nu = ex$nu[idx],
y_rep = ex$y_rep[idx, , drop = FALSE]))
saveRDS(obj, cache_file)
obj
}
# Posterior predictive W/D/L probabilities at a neutral venue, k periods after the last fitted period.
# Strengths drift forward as a random walk, so predictions further ahead are less certain.
dyn_predict <- function(obj, t1, t2, k) {
b <- obj$post$beta
bT <- b[, , dim(b)[3]] # draws x teams
sd_drift <- obj$post$sigma_drift; nu <- obj$post$nu; S <- length(nu)
i1 <- match(t1, obj$teams); i2 <- match(t2, obj$teams)
out <- matrix(0, length(t1), 3, dimnames = list(NULL, c("W", "D", "L")))
for (m in seq_along(t1)) {
d <- bT[, i1[m]] - bT[, i2[m]]
if (k[m] > 0) d <- d + sd_drift * sqrt(2 * k[m]) * rnorm(S) # both teams drift
den <- exp(d / 2) + exp(-d / 2) + nu
out[m, ] <- c(mean(exp(d / 2) / den), mean(nu / den), mean(exp(-d / 2) / den))
}
out
}
Models are fitted only on matches before the cut-off (2022-01-01) and scored on every later match between teams that appear in the training data. Predictions are neutral-venue for all models, so this tests team strength only.
cutoff <- as.Date(params$cutoff)
train <- df_clean %>% filter(Date < cutoff)
test <- df_clean %>% filter(Date >= cutoff)
n_tr <- table(c(train$team1, train$team2))
bt_teams <- sort(names(n_tr)[n_tr >= 8])
train <- train %>% filter(team1 %in% bt_teams, team2 %in% bt_teams)
test <- test %>% filter(team1 %in% bt_teams, team2 %in% bt_teams)
ref_tr <- max(train$Date)
outcome <- ifelse(test$score1 > test$score2, 1L, ifelse(test$score1 == test$score2, 2L, 3L)) # W, D, L
score_probs <- function(P, o) {
P <- pmax(P, 1e-12); P <- P / rowSums(P)
n <- length(o)
O <- matrix(0, n, 3); O[cbind(1:n, o)] <- 1
cp <- t(apply(P, 1, cumsum)); co <- t(apply(O, 1, cumsum))
c(RPS = mean(0.5 * ((cp[, 1] - co[, 1])^2 + (cp[, 2] - co[, 2])^2)),
LogLoss = -mean(log(P[cbind(1:n, o)])),
Brier = mean(rowSums((P - O)^2)),
Accuracy = mean(max.col(P, ties.method = "first") == o))
}
m_bt0 <- fit_bt(train, bt_teams, ref_tr, use_draws = FALSE)
m_bt <- fit_bt(train, bt_teams, ref_tr, use_draws = TRUE)
m_elo <- fit_elo(train, bt_teams, ref_tr)
m_pois <- fit_pois(train, bt_teams, ref_tr)
# Dynamic Bayesian BT: posterior from the training matches only; strengths then drift forward
# k periods to each test match.
m_dyn_train <- fit_dyn_bt(train, bt_teams, "bt_dyn_train.rds")
k_ahead <- pmax(period_of(test$Date) - m_dyn_train$data$T, 0L)
set.seed(params$seed)
P_dyn <- dyn_predict(m_dyn_train, test$team1, test$team2, k_ahead)
freq <-as.numeric(prop.table(table(factor(
ifelse(train$score1 > train$score2, 1L, ifelse(train$score1 == train$score2, 2L, 3L)), levels = 1:3))))
P_base <- matrix(freq, nrow(test), 3, byrow = TRUE)
bt_results <- bind_rows(
tibble(Model = "Baseline (outcome frequencies)", !!!as.list(score_probs(P_base, outcome))),
tibble(Model = paste(m_bt0$name, "- static MLE"), !!!as.list(score_probs(m_bt0$predict(test$team1, test$team2), outcome))),
tibble(Model = paste(m_bt$name, "- static MLE"), !!!as.list(score_probs(m_bt$predict(test$team1, test$team2), outcome))),
tibble(Model = m_elo$name, !!!as.list(score_probs(m_elo$predict(test$team1, test$team2), outcome))),
tibble(Model = m_pois$name, !!!as.list(score_probs(m_pois$predict(test$team1, test$team2), outcome))),
tibble(Model = "Dynamic Bayesian BT (HMC)", !!!as.list(score_probs(P_dyn, outcome)))
)
bt_results %>%
gt() %>%
tab_header(title = "Out-of-sample accuracy",
subtitle = sprintf("Train: %s to %s (%d matches) | Test: %d matches from %s",
min(train$Date), ref_tr, nrow(train), nrow(test), cutoff)) %>%
fmt_number(columns = c(RPS, LogLoss, Brier), decimals = 4) %>%
fmt_percent(columns = Accuracy, decimals = 1) %>%
tab_style(style = cell_text(weight = "bold"),
locations = list(
cells_body(columns = RPS, rows = RPS == min(RPS)),
cells_body(columns = LogLoss, rows = LogLoss == min(LogLoss)),
cells_body(columns = Brier, rows = Brier == min(Brier)),
cells_body(columns = Accuracy, rows = Accuracy == max(Accuracy)))) %>%
tab_source_note("Best value in each column in bold. Lower is better except accuracy.")
| Out-of-sample accuracy | ||||
| Train: 2018-01-11 to 2021-12-30 (2116 matches) | Test: 1645 matches from 2022-01-01 | ||||
| Model | RPS | LogLoss | Brier | Accuracy |
|---|---|---|---|---|
| Baseline (outcome frequencies) | 0.2281 | 1.0588 | 0.6391 | 46.6% |
| Bradley-Terry (decisive only) - static MLE | 0.2009 | 1.0231 | 0.5938 | 56.4% |
| Bradley-Terry (draws as half-wins) - static MLE | 0.1888 | 0.9557 | 0.5639 | 56.7% |
| Elo | 0.1933 | 0.9682 | 0.5725 | 55.6% |
| Bivariate Poisson | 0.1894 | 0.9433 | 0.5594 | 55.0% |
| Dynamic Bayesian BT (HMC) | 0.1857 | 0.9313 | 0.5515 | 56.9% |
| Best value in each column in bold. Lower is better except accuracy. | ||||
teams_all <- eligible_teams
dyn <- fit_dyn_bt(df_clean, teams_all, "bt_dyn_final.rds")
f_elo <- fit_elo(df_clean, teams_all, t_now)
f_pois <- fit_pois(df_clean, teams_all, t_now)
cat(sprintf("Last match in data: %s | Poisson shared rate lambda3 = %.3f | home effect = %.3f (log scale)\n",
t_now, f_pois$l3, f_pois$home))
## Last match in data: 2025-07-06 | Poisson shared rate lambda3 = 0.000 | home effect = 0.208 (log scale)
Rules of thumb for a trustworthy posterior: R-hat below 1.01, effective sample size (ESS) in the hundreds, no divergent transitions, E-BFMI above 0.3, and no iterations hitting the maximum tree depth.
dg <- dyn$diag
tibble(
Check = c("Chains x post-warmup draws", "Divergent transitions", "Max R-hat (all strengths)",
"Min effective sample size (all strengths)", "Min E-BFMI", "Iterations at max tree depth",
"Sampling time (seconds, all chains)"),
Value = c(sprintf("%d x %d", params$chains, params$iter / 2), dg$divergences,
sprintf("%.3f", dg$max_rhat), sprintf("%.0f", dg$min_neff), sprintf("%.2f", dg$min_ebfmi),
dg$depth_hits, sprintf("%.0f", dg$runtime_secs))
) %>% gt() %>% tab_header(title = "HMC / NUTS diagnostics", subtitle = "Final model, all data")
| HMC / NUTS diagnostics | |
| Final model, all data | |
| Check | Value |
|---|---|
| Chains x post-warmup draws | 4 x 1000 |
| Divergent transitions | 0 |
| Max R-hat (all strengths) | 1.009 |
| Min effective sample size (all strengths) | 893 |
| Min E-BFMI | 0.81 |
| Iterations at max tree depth | 0 |
| Sampling time (seconds, all chains) | 6925 |
as.data.frame(dyn$hyper_summary) %>%
rownames_to_column("Parameter") %>%
mutate(Parameter = recode(Parameter, sigma0 = "sigma0 (initial spread)", sigma_drift = "sigma_drift (per half-year)",
h = "h (home advantage)", nu = "nu (draw parameter)")) %>%
gt() %>%
tab_header(title = "Posterior summaries: hyperparameters") %>%
fmt_number(c(mean, sd, `2.5%`, `97.5%`), decimals = 3) %>%
fmt_number(n_eff, decimals = 0) %>% fmt_number(Rhat, decimals = 3) %>%
cols_label(n_eff = "ESS", `2.5%` = "2.5%", `97.5%` = "97.5%")
| Posterior summaries: hyperparameters | ||||||
| Parameter | mean | sd | 2.5% | 97.5% | ESS | Rhat |
|---|---|---|---|---|---|---|
| sigma0 (initial spread) | 2.111 | 0.153 | 1.824 | 2.416 | 750 | 1.001 |
| sigma_drift (per half-year) | 0.167 | 0.050 | 0.061 | 0.259 | 211 | 1.025 |
| h (home advantage) | 0.503 | 0.053 | 0.401 | 0.603 | 5,812 | 1.000 |
| nu (draw parameter) | 0.927 | 0.037 | 0.857 | 1.002 | 3,820 | 1.000 |
tr <- dyn$hyper_trace
trace_df <- do.call(rbind, lapply(seq_len(dim(tr)[3]), function(p)
data.frame(iter = rep(seq_len(dim(tr)[1]), dim(tr)[2]), chain = factor(rep(seq_len(dim(tr)[2]), each = dim(tr)[1])),
parameter = dimnames(tr)[[3]][p], value = as.vector(tr[, , p]))))
ggplot(trace_df, aes(iter, value, colour = chain)) +
geom_line(alpha = 0.6, linewidth = 0.3) +
facet_wrap(~parameter, scales = "free_y", ncol = 2) +
scale_colour_viridis_d(end = 0.85) +
labs(title = "Trace plots of the hyperparameters",
subtitle = "Chains should overlap and look like noise around a constant level",
x = "Post-warmup iteration", y = NULL) +
theme_minimal(base_size = 12) + theme(legend.position = "bottom", plot.title = element_text(face = "bold"))
If the model is a good description of the data, results simulated
from the fitted model (y_rep) should look like the observed
results. Each check compares a feature of the real data with its
distribution across the replicated datasets. A posterior
predictive p-value (ppp) near 0 or 1 signals a feature the
model does not capture.
yo <- dyn$data$y # 1 = team 2 wins, 2 = draw, 3 = team 1 wins
yr <- dyn$post$y_rep # draws x matches
N <- length(yo)
S <- nrow(yr)
bpm <- apply(dyn$post$beta, c(2, 3), mean) # posterior-mean strengths (teams x periods)
gap <- abs(bpm[cbind(dyn$data$team1, dyn$data$period)] - bpm[cbind(dyn$data$team2, dyn$data$period)])
Check 1: overall result frequencies.
share_obs <- tabulate(yo, 3) / N
share_rep <- t(apply(yr, 1, function(r) tabulate(r, 3) / N))
lab <- c("Team 2 wins", "Draw", "Team 1 wins")
ppc1 <- bind_rows(lapply(1:3, function(k) tibble(outcome = lab[k], rep = share_rep[, k], obs = share_obs[k])))
ppp1 <- sapply(1:3, function(k) mean(share_rep[, k] >= share_obs[k])); names(ppp1) <- lab
ggplot(ppc1, aes(rep)) +
geom_histogram(bins = 30, fill = "#457B9D", alpha = 0.8) +
geom_vline(aes(xintercept = obs), colour = "#E63946", linewidth = 1) +
facet_wrap(~factor(outcome, levels = lab), scales = "free") +
scale_x_continuous(labels = percent_format(accuracy = 1)) +
labs(title = "PPC 1: share of each result", subtitle = "Histogram = replicated datasets, red line = observed",
x = NULL, y = "Replicated datasets") +
theme_minimal(base_size = 12) + theme(plot.title = element_text(face = "bold"))
Check 2: do draws become less likely as the strength gap grows? Matches are split into five bins by the size of the posterior-mean strength gap. The draw rate in each bin is compared with the replicated draw rate.
bin <- cut(gap, quantile(gap, 0:5 / 5), include.lowest = TRUE, labels = paste0("Q", 1:5))
ppc2 <- bind_rows(lapply(levels(bin), function(b) {
ii <- which(bin == b); dr <- rowMeans(yr[, ii, drop = FALSE] == 2)
tibble(bin = b, obs = mean(yo[ii] == 2), lo = quantile(dr, 0.05), hi = quantile(dr, 0.95),
med = median(dr), ppp = mean(dr >= mean(yo[ii] == 2)))
}))
ggplot(ppc2, aes(bin)) +
geom_linerange(aes(ymin = lo, ymax = hi), linewidth = 4, colour = "#A8DADC") +
geom_point(aes(y = med), shape = 95, size = 8, colour = "#457B9D") +
geom_point(aes(y = obs), size = 3.5, colour = "#E63946") +
scale_y_continuous(labels = percent_format(accuracy = 1)) +
labs(title = "PPC 2: draw rate by strength gap",
subtitle = "Band = 90% of replicated draw rates, red dot = observed (Q1 = evenly matched, Q5 = biggest gap)",
x = "Strength-gap quintile", y = "Draw rate") +
theme_minimal(base_size = 12) + theme(plot.title = element_text(face = "bold"))
Check 3: is home advantage stable over time? Share of wins for the first-listed team, per half-year period.
per_s <- sort(unique(dyn$data$period))
ppc3 <- bind_rows(lapply(per_s, function(p) {
ii <- which(dyn$data$period == p); w <- rowMeans(yr[, ii, drop = FALSE] == 3)
tibble(period = p, n = length(ii), obs = mean(yo[ii] == 3), lo = quantile(w, 0.05), hi = quantile(w, 0.95),
ppp = mean(w >= mean(yo[ii] == 3)))
})) %>% mutate(date = as.Date(sprintf("%d-%02d-01", 2018 + (period - 1) %/% 2, ifelse(period %% 2 == 1, 1, 7))))
ggplot(ppc3, aes(date)) +
geom_ribbon(aes(ymin = lo, ymax = hi), fill = "#A8DADC", alpha = 0.8) +
geom_line(aes(y = obs), colour = "#E63946", linewidth = 0.9) + geom_point(aes(y = obs), colour = "#E63946") +
scale_y_continuous(labels = percent_format(accuracy = 1)) +
labs(title = "PPC 3: first-listed team win share over time",
subtitle = "Band = 90% of replicated shares, red = observed", x = NULL, y = "Win share") +
theme_minimal(base_size = 12) + theme(plot.title = element_text(face = "bold"))
Check 4: team results. Points per game for every team with at least 30 matches: observed against the 90% replicated interval. About 10% of teams are expected to fall outside by chance.
pts1 <- c(0, 1, 3); pts2 <- c(3, 1, 0)
team_ppc <- bind_rows(lapply(seq_along(teams_all), function(j) {
a <- which(dyn$data$team1 == j); b <- which(dyn$data$team2 == j); n <- length(a) + length(b)
if (n < 30) return(NULL)
obs <- (sum(pts1[yo[a]]) + sum(pts2[yo[b]])) / n
rep <- (rowSums(matrix(pts1[yr[, a]], S)) + rowSums(matrix(pts2[yr[, b]], S))) / n
tibble(team = teams_all[j], n = n, obs = obs, mean_rep = mean(rep),
lo = quantile(rep, 0.05), hi = quantile(rep, 0.95), outside = obs < lo | obs > hi)
}))
ggplot(team_ppc, aes(mean_rep, obs, colour = outside)) +
geom_abline(slope = 1, intercept = 0, colour = "grey60") +
geom_errorbarh(aes(xmin = lo, xmax = hi), height = 0, alpha = 0.5) +
geom_point(size = 2) +
geom_text_repel(data = filter(team_ppc, outside), aes(label = team), size = 3, show.legend = FALSE) +
scale_colour_manual(values = c(`FALSE` = "#457B9D", `TRUE` = "#E63946"), labels = c("Inside", "Outside"), name = "Observed vs 90% band") +
labs(title = "PPC 4: points per game by team",
subtitle = "Horizontal bars = 90% replicated interval; points on the diagonal are perfectly reproduced",
x = "Replicated points per game (mean)", y = "Observed points per game") +
theme_minimal(base_size = 12) + theme(legend.position = "bottom", plot.title = element_text(face = "bold"))
tibble(
Check = c("Share: team 2 wins", "Share: draws", "Share: team 1 wins", "Draw rate: worst gap quintile",
"Win share: worst period", "Teams outside 90% band"),
`Statistic` = c(sprintf("ppp = %.2f", ppp1[1]), sprintf("ppp = %.2f", ppp1[2]), sprintf("ppp = %.2f", ppp1[3]),
sprintf("ppp = %.2f (%s)", ppc2$ppp[which.max(abs(ppc2$ppp - 0.5))], ppc2$bin[which.max(abs(ppc2$ppp - 0.5))]),
sprintf("ppp = %.2f", ppc3$ppp[which.max(abs(ppc3$ppp - 0.5))]),
sprintf("%d of %d (%.0f%%; about 10%% expected)", sum(team_ppc$outside), nrow(team_ppc), 100 * mean(team_ppc$outside)))
) %>% gt() %>% tab_header(title = "Posterior predictive check summary") %>%
tab_source_note("ppp = share of replicated datasets at least as large as the observed value. Values near 0 or 1 flag misfit.")
| Posterior predictive check summary | |
| Check | Statistic |
|---|---|
| Share: team 2 wins | ppp = 0.53 |
| Share: draws | ppp = 0.50 |
| Share: team 1 wins | ppp = 0.49 |
| Draw rate: worst gap quintile | ppp = 1.00 (Q5) |
| Win share: worst period | ppp = 0.01 |
| Teams outside 90% band | 0 of 121 (0%; about 10% expected) |
| ppp = share of replicated datasets at least as large as the observed value. Values near 0 or 1 flag misfit. | |
Posterior mean and 90% credible band of \(\beta_{j,t}\) for the eight strongest teams. A flat line would mean no drift.
Tn <- dim(dyn$post$beta)[3]
top8_dyn <- order(-colMeans(dyn$post$beta[, , Tn]))[1:8]
traj <- bind_rows(lapply(top8_dyn, function(j) {
tibble(team = teams_all[j], period = 1:Tn,
mean = apply(dyn$post$beta[, j, ], 2, mean),
lo = apply(dyn$post$beta[, j, ], 2, quantile, 0.05),
hi = apply(dyn$post$beta[, j, ], 2, quantile, 0.95))
})) %>% mutate(date = as.Date(sprintf("%d-%02d-01", 2018 + (period - 1) %/% 2, ifelse(period %% 2 == 1, 1, 7))))
ggplot(traj, aes(date, mean, colour = team, fill = team)) +
geom_ribbon(aes(ymin = lo, ymax = hi), alpha = 0.12, colour = NA) +
geom_line(linewidth = 1) +
scale_colour_viridis_d(option = "turbo") + scale_fill_viridis_d(option = "turbo") +
labs(title = "Dynamic team strength (posterior mean and 90% credible band)",
subtitle = "Top 8 teams by final strength; higher is stronger", x = NULL, y = "Strength (log-odds scale)",
colour = NULL, fill = NULL) +
theme_minimal(base_size = 12) + theme(legend.position = "bottom", plot.title = element_text(face = "bold"))
style_top3 <- function(g, rank_col, fill) {
g %>%
tab_style(cell_text(weight = "bold"), cells_body(columns = all_of(rank_col), rows = .data[[rank_col]] <= 3)) %>%
tab_style(cell_fill(color = fill), cells_body(rows = .data[[rank_col]] <= 3)) %>%
tab_options(table.font.size = 12)
}
bT <- dyn$post$beta[, , dim(dyn$post$beta)[3]] # posterior draws of final-period strengths (draws x teams)
bt_tbl <- tibble(team = teams_all, ability = colMeans(bT), se_bt = apply(bT, 2, sd),
lo = apply(bT, 2, quantile, 0.05), hi = apply(bT, 2, quantile, 0.95)) %>%
arrange(desc(ability)) %>% mutate(rank_bt = row_number())
pois_tbl <- tibble(team = teams_all, attack = f_pois$att[teams_all], defence = -f_pois$con[teams_all]) %>%
mutate(net = attack + defence) %>%
arrange(desc(net)) %>% mutate(rank_pois = row_number())
elo_tbl <- tibble(team = teams_all, elo = f_elo$strength[teams_all]) %>%
arrange(desc(elo)) %>% mutate(rank_elo = row_number())
bt_tbl %>% slice_head(n = 20) %>% dplyr::select(rank_bt, team, ability, se_bt, lo, hi) %>% gt() %>%
tab_header(title = "Top 20 teams: dynamic Bayesian Bradley-Terry strength",
subtitle = "Posterior of the final-period strength, log-odds scale") %>%
cols_label(rank_bt = "Rank", team = "Team", ability = "Posterior mean", se_bt = "Posterior SD",
lo = "5%", hi = "95%") %>%
fmt_number(c(ability, se_bt, lo, hi), decimals = 2) %>% style_top3("rank_bt", "lightgoldenrodyellow")
| Top 20 teams: dynamic Bayesian Bradley-Terry strength | |||||
| Posterior of the final-period strength, log-odds scale | |||||
| Rank | Team | Posterior mean | Posterior SD | 5% | 95% |
|---|---|---|---|---|---|
| 1 | Argentina | 4.63 | 0.67 | 3.61 | 5.83 |
| 2 | Colombia | 4.55 | 0.64 | 3.58 | 5.67 |
| 3 | Spain | 4.52 | 0.58 | 3.57 | 5.49 |
| 4 | Brazil | 4.15 | 0.62 | 3.16 | 5.13 |
| 5 | France | 4.08 | 0.54 | 3.22 | 4.94 |
| 6 | England | 3.68 | 0.54 | 2.84 | 4.58 |
| 7 | Italy | 3.67 | 0.57 | 2.74 | 4.63 |
| 8 | Portugal | 3.65 | 0.57 | 2.76 | 4.58 |
| 9 | Uruguay | 3.44 | 0.62 | 2.38 | 4.43 |
| 10 | Netherlands | 3.41 | 0.52 | 2.52 | 4.23 |
| 11 | Germany | 3.31 | 0.54 | 2.47 | 4.21 |
| 12 | Belgium | 3.24 | 0.57 | 2.28 | 4.12 |
| 13 | Croatia | 3.06 | 0.55 | 2.18 | 3.95 |
| 14 | Denmark | 2.99 | 0.54 | 2.11 | 3.82 |
| 15 | Japan | 2.98 | 0.56 | 2.08 | 3.90 |
| 16 | Venezuela | 2.97 | 0.65 | 1.92 | 4.04 |
| 17 | IR Iran | 2.89 | 0.60 | 1.93 | 3.93 |
| 18 | Senegal | 2.86 | 0.65 | 1.82 | 3.94 |
| 19 | Austria | 2.68 | 0.53 | 1.82 | 3.58 |
| 20 | Morocco | 2.67 | 0.59 | 1.69 | 3.69 |
pois_tbl %>% slice_head(n = 20) %>% dplyr::select(rank_pois, team, attack, defence, net) %>% gt() %>%
tab_header(title = "Top 20 teams: penalised bivariate Poisson",
subtitle = "Log-scale effects on goals; higher is better for both attack and defence") %>%
cols_label(rank_pois = "Rank", team = "Team", attack = "Attack", defence = "Defence", net = "Net strength") %>%
fmt_number(c(attack, defence, net), decimals = 2) %>% style_top3("rank_pois", "lightblue")
| Top 20 teams: penalised bivariate Poisson | ||||
| Log-scale effects on goals; higher is better for both attack and defence | ||||
| Rank | Team | Attack | Defence | Net strength |
|---|---|---|---|---|
| 1 | Spain | 0.86 | 0.81 | 1.68 |
| 2 | England | 0.77 | 0.74 | 1.51 |
| 3 | Brazil | 0.62 | 0.85 | 1.47 |
| 4 | Argentina | 0.73 | 0.73 | 1.46 |
| 5 | France | 0.73 | 0.72 | 1.45 |
| 6 | Colombia | 0.61 | 0.83 | 1.44 |
| 7 | Japan | 0.84 | 0.59 | 1.44 |
| 8 | Portugal | 0.72 | 0.65 | 1.37 |
| 9 | Germany | 0.86 | 0.45 | 1.31 |
| 10 | Uruguay | 0.42 | 0.81 | 1.23 |
| 11 | Belgium | 0.56 | 0.60 | 1.16 |
| 12 | Netherlands | 0.70 | 0.45 | 1.15 |
| 13 | Italy | 0.54 | 0.59 | 1.13 |
| 14 | Australia | 0.53 | 0.56 | 1.09 |
| 15 | IR Iran | 0.62 | 0.44 | 1.06 |
| 16 | Denmark | 0.39 | 0.63 | 1.02 |
| 17 | South Korea | 0.59 | 0.39 | 0.98 |
| 18 | Mexico | 0.29 | 0.68 | 0.97 |
| 19 | Morocco | 0.26 | 0.67 | 0.94 |
| 20 | United States | 0.45 | 0.44 | 0.89 |
elo_tbl %>% slice_head(n = 20) %>% dplyr::select(rank_elo, team, elo) %>% gt() %>%
tab_header(title = "Top 20 teams: Elo rating", subtitle = "Competition-weighted K, margin-of-victory multiplier") %>%
cols_label(rank_elo = "Rank", team = "Team", elo = "Elo") %>%
fmt_number(elo, decimals = 0) %>% style_top3("rank_elo", "lightgreen")
| Top 20 teams: Elo rating | ||
| Competition-weighted K, margin-of-victory multiplier | ||
| Rank | Team | Elo |
|---|---|---|
| 1 | Spain | 2,107 |
| 2 | Argentina | 2,042 |
| 3 | France | 1,942 |
| 4 | England | 1,910 |
| 5 | Germany | 1,910 |
| 6 | Colombia | 1,906 |
| 7 | Japan | 1,905 |
| 8 | Brazil | 1,871 |
| 9 | Portugal | 1,854 |
| 10 | IR Iran | 1,849 |
| 11 | Netherlands | 1,836 |
| 12 | South Korea | 1,813 |
| 13 | Italy | 1,808 |
| 14 | Mexico | 1,804 |
| 15 | Senegal | 1,798 |
| 16 | Uruguay | 1,782 |
| 17 | Austria | 1,779 |
| 18 | Iraq | 1,750 |
| 19 | Greece | 1,739 |
| 20 | Denmark | 1,738 |
The three strength scales are min-max normalised to 0-1 and averaged with equal weights. The top 48 teams on this composite are used as the 2026 field. (The data contains no 2026 qualifiers, so this is a “strongest 48” proxy, not the official draw.)
combined <- bt_tbl %>%
inner_join(pois_tbl, by = "team") %>%
inner_join(elo_tbl, by = "team") %>%
mutate(
norm_bt = (ability - min(ability)) / (max(ability) - min(ability)),
norm_pois = (net - min(net)) / (max(net) - min(net)),
norm_elo = (elo - min(elo)) / (max(elo) - min(elo)),
composite = (norm_bt + norm_pois + norm_elo) / 3
) %>%
arrange(desc(composite)) %>%
mutate(rank_composite = row_number())
combined %>% slice_head(n = 20) %>%
dplyr::select(rank_composite, team, ability, net, elo, composite) %>% gt() %>%
tab_header(title = md("**Top 20 overall teams**"),
subtitle = md("*Composite of Bradley-Terry, Poisson and Elo*")) %>%
cols_label(rank_composite = "Rank", team = "Team", ability = "BT ability",
net = "Poisson net", elo = "Elo", composite = "Composite") %>%
fmt_number(c(ability, net), decimals = 2) %>% fmt_number(elo, decimals = 0) %>%
fmt_number(composite, decimals = 3) %>%
tab_style(cell_fill(color = "#ffd700"), cells_body(rows = rank_composite <= 3)) %>%
tab_style(cell_fill(color = "#c0c0c0"), cells_body(rows = rank_composite %in% 4:5)) %>%
tab_style(cell_fill(color = "#cd7f32"), cells_body(rows = rank_composite %in% 6:10)) %>%
tab_source_note("Equal-weight average of min-max normalised BT, Poisson and Elo scores.")
| Top 20 overall teams | |||||
| Composite of Bradley-Terry, Poisson and Elo | |||||
| Rank | Team | BT ability | Poisson net | Elo | Composite |
|---|---|---|---|---|---|
| 1 | Spain | 4.52 | 1.68 | 2,107 | 0.996 |
| 2 | Argentina | 4.63 | 1.46 | 2,042 | 0.961 |
| 3 | Colombia | 4.55 | 1.44 | 1,906 | 0.918 |
| 4 | France | 4.08 | 1.45 | 1,942 | 0.913 |
| 5 | Brazil | 4.15 | 1.47 | 1,871 | 0.897 |
| 6 | England | 3.68 | 1.51 | 1,910 | 0.896 |
| 7 | Portugal | 3.65 | 1.37 | 1,854 | 0.866 |
| 8 | Germany | 3.31 | 1.31 | 1,910 | 0.865 |
| 9 | Japan | 2.98 | 1.44 | 1,905 | 0.864 |
| 10 | Netherlands | 3.41 | 1.15 | 1,836 | 0.833 |
| 11 | Italy | 3.67 | 1.13 | 1,808 | 0.832 |
| 12 | Uruguay | 3.44 | 1.23 | 1,782 | 0.826 |
| 13 | IR Iran | 2.89 | 1.06 | 1,849 | 0.810 |
| 14 | Belgium | 3.24 | 1.16 | 1,669 | 0.780 |
| 15 | Senegal | 2.86 | 0.89 | 1,798 | 0.780 |
| 16 | Mexico | 2.59 | 0.97 | 1,804 | 0.779 |
| 17 | Denmark | 2.99 | 1.02 | 1,738 | 0.779 |
| 18 | South Korea | 2.22 | 0.98 | 1,813 | 0.771 |
| 19 | Croatia | 3.06 | 0.89 | 1,728 | 0.766 |
| 20 | Morocco | 2.67 | 0.94 | 1,736 | 0.760 |
| Equal-weight average of min-max normalised BT, Poisson and Elo scores. | |||||
Format: 48 teams, 12 groups of 4 (teams seeded into groups by composite rank, one from each tier). The top two in each group plus the 8 best third-placed teams (32 teams) go to a knockout bracket. Group ties are broken by points, goal difference, goals scored, then at random. The knockout draw is random (the real bracket is fixed by FIFA’s pairing table, which is not modelled), all matches are on neutral ground, and drawn knockout matches are decided 50/50 (penalties).
For each model, a 48 x 48 table of match probabilities (or goal rates) is precomputed once, so the simulation itself is fast. The random seed is set once before all simulations so results are reproducible.
wc_teams <- combined$team[1:48]
nT <- length(wc_teams)
mat_from <- function(fun) { # fun(t1, t2) -> n x 3 matrix, applied to all ordered pairs
g <- expand.grid(i = 1:nT, j = 1:nT)
P <- fun(wc_teams[g$i], wc_teams[g$j])
lapply(1:3, function(k) matrix(P[, k], nT, nT))
}
P_elo <- mat_from(f_elo$predict)
# Bayesian model: posterior draws of the 48 teams' strengths at the tournament date.
# The tournament starts about 2 half-year periods after the last fitted period, so add random-walk drift.
Tn <- dim(dyn$post$beta)[3]
k_wc <- max(period_of(as.Date("2026-06-11")) - Tn, 0L)
S_post <- length(dyn$post$nu)
set.seed(params$seed + 1)
B_wc <- dyn$post$beta[, match(wc_teams, teams_all), Tn] +
sqrt(k_wc) * as.numeric(dyn$post$sigma_drift) * matrix(rnorm(S_post * nT), S_post, nT)
cat(sprintf("Strengths drifted forward %d half-year periods from the last data period to the tournament.\n", k_wc))
## Strengths drifted forward 1 half-year periods from the last data period to the tournament.
# one posterior draw -> 48 x 48 win and draw probability tables
draw_wdl <- function(m) {
s <- sample.int(nrow(m$beta), 1)
d <- outer(m$beta[s, ], m$beta[s, ], "-")
den <- exp(d / 2) + exp(-d / 2) + m$nu[s]
list(type = "wdl", pw = exp(d / 2) / den, pd = m$nu[s] / den)
}
M_pois <- local({
g <- expand.grid(i = 1:nT, j = 1:nT)
mu <- exp(f_pois$int + f_pois$att[wc_teams[g$i]] + f_pois$con[wc_teams[g$j]])
matrix(mu, nT, nT)
})
models <- list(
"Dynamic Bayesian BT" = list(type = "bayes", beta = B_wc, nu = dyn$post$nu),
"Bivariate Poisson" = list(type = "pois", a = pmax(M_pois - f_pois$l3, 0.02), l3 = f_pois$l3),
"Elo" = list(type = "wdl", pw = P_elo[[1]], pd = P_elo[[2]])
)
sim_match <- function(i, j, m) {
if (m$type == "wdl") {
u <- runif(1)
if (u < m$pw[i, j]) c(1L, 0L) else if (u < m$pw[i, j] + m$pd[i, j]) c(0L, 0L) else c(0L, 1L)
} else {
z <- rpois(1, m$l3)
c(rpois(1, m$a[i, j]) + z, rpois(1, m$a[j, i]) + z)
}
}
groups <- lapply(1:12, function(g) seq(g, 48, by = 12))
sim_group <- function(idx, m) {
pts <- gd <- gf <- numeric(4)
for (a in 1:3) for (b in (a + 1):4) {
r <- sim_match(idx[a], idx[b], m)
d <- r[1] - r[2]
gd[a] <- gd[a] + d; gd[b] <- gd[b] - d
gf[a] <- gf[a] + r[1]; gf[b] <- gf[b] + r[2]
if (d > 0) pts[a] <- pts[a] + 3 else if (d < 0) pts[b] <- pts[b] + 3 else { pts[a] <- pts[a] + 1; pts[b] <- pts[b] + 1 }
}
o <- order(-pts, -gd, -gf, runif(4))
list(team = idx[o], pts = pts[o], gd = gd[o], gf = gf[o])
}
sim_ko_round <- function(tm, m) {
k <- length(tm) / 2
out <- integer(k)
for (s in seq_len(k)) {
i <- tm[2 * s - 1]; j <- tm[2 * s]
r <- sim_match(i, j, m)
out[s] <- if (r[1] > r[2]) i else if (r[2] > r[1]) j else if (runif(1) < 0.5) i else j
}
out
}
sim_tournament <- function(m) {
if (m$type == "bayes") m <- draw_wdl(m) # new posterior draw for every simulated tournament
win <- run <- third <- integer(12)
tstat <- matrix(0, 12, 4)
for (g in 1:12) {
s <- sim_group(groups[[g]], m)
win[g] <- s$team[1]; run[g] <- s$team[2]; third[g] <- s$team[3]
tstat[g, ] <- c(s$pts[3], s$gd[3], s$gf[3], runif(1))
}
best <- third[order(-tstat[, 1], -tstat[, 2], -tstat[, 3], tstat[, 4])[1:8]]
tm <- sample(c(win, run, best))
while (length(tm) > 1) tm <- sim_ko_round(tm, m)
tm
}
n_sims <- as.integer(params$n_sims)
set.seed(params$seed)
run_model <- function(name) {
champ <- vapply(seq_len(n_sims), function(k) sim_tournament(models[[name]]), numeric(1))
tab <- table(factor(wc_teams[champ], levels = wc_teams))
tibble(Champion = names(tab), prob = as.numeric(tab) / n_sims, Model = name)
}
timing <- system.time({
all_champs <- bind_rows(lapply(names(models), run_model))
})
all_champs$Model <- factor(all_champs$Model, levels = names(models))
cat(sprintf("%s simulations x %d models in %.0f seconds (seed %s)\n",
format(n_sims, big.mark = ","), length(models), timing[["elapsed"]], params$seed))
## 10,000 simulations x 3 models in 61 seconds (seed 2026)
top_by_model <- all_champs %>% group_by(Model) %>% slice_max(prob, n = 10, with_ties = FALSE) %>% ungroup()
top_teams <- unique(top_by_model$Champion)
comparison <- all_champs %>%
filter(Champion %in% top_teams) %>%
mutate(prob = round(100 * prob, 1)) %>%
pivot_wider(names_from = Model, values_from = prob, values_fill = 0) %>%
mutate(Average = round((`Dynamic Bayesian BT` + `Bivariate Poisson` + Elo) / 3, 1)) %>%
arrange(desc(Average)) %>%
mutate(Rank = row_number()) %>%
dplyr::select(Rank, Champion, `Dynamic Bayesian BT`, `Bivariate Poisson`, Elo, Average)
comparison %>%
gt() %>%
tab_header(title = md("**2026 FIFA World Cup: probability of winning the tournament**"),
subtitle = sprintf("%s simulations per model", format(n_sims, big.mark = ","))) %>%
fmt_number(columns = -c(Rank, Champion), decimals = 1, pattern = "{x}%") %>%
tab_style(cell_fill(color = "#ffd700"), cells_body(rows = Rank <= 3)) %>%
tab_style(cell_fill(color = "#c0c0c0"), cells_body(rows = Rank %in% 4:5)) %>%
tab_style(cell_fill(color = "#cd7f32"), cells_body(rows = Rank %in% 6:10)) %>%
tab_source_note(sprintf("Monte Carlo standard error is at most %.2f percentage points (sqrt(0.25 / N)). A team missing from a model's top 10 shows 0.0%%.",
100 * sqrt(0.25 / n_sims)))
| 2026 FIFA World Cup: probability of winning the tournament | |||||
| 10,000 simulations per model | |||||
| Rank | Champion | Dynamic Bayesian BT | Bivariate Poisson | Elo | Average |
|---|---|---|---|---|---|
| 1 | Spain | 12.5% | 11.7% | 34.4% | 19.5% |
| 2 | Argentina | 15.2% | 6.4% | 19.8% | 13.8% |
| 3 | Colombia | 13.5% | 5.9% | 5.2% | 8.2% |
| 4 | France | 7.5% | 6.8% | 7.6% | 7.3% |
| 5 | Brazil | 8.3% | 6.4% | 3.1% | 5.9% |
| 6 | England | 4.4% | 7.9% | 4.8% | 5.7% |
| 7 | Japan | 1.6% | 6.9% | 4.9% | 4.5% |
| 8 | Portugal | 4.6% | 5.7% | 2.4% | 4.2% |
| 9 | Germany | 2.5% | 4.9% | 4.9% | 4.1% |
| 10 | Italy | 4.4% | 2.8% | 1.1% | 2.8% |
| 11 | Uruguay | 3.4% | 3.7% | 0.9% | 2.7% |
| 12 | Netherlands | 3.0% | 2.8% | 1.8% | 2.5% |
| 13 | IR Iran | 1.2% | 1.8% | 1.9% | 1.6% |
| Monte Carlo standard error is at most 0.50 percentage points (sqrt(0.25 / N)). A team missing from a model's top 10 shows 0.0%. | |||||
all_champs %>% group_by(Model) %>%
summarise(`Top-1 prob` = max(prob), `Top-5 combined` = sum(sort(prob, decreasing = TRUE)[1:5]),
`Effective no. of contenders` = 1 / sum(prob^2), .groups = "drop") %>%
gt() %>%
tab_header(title = "How concentrated is each model's forecast?") %>%
fmt_percent(c(`Top-1 prob`, `Top-5 combined`), decimals = 1) %>%
fmt_number(`Effective no. of contenders`, decimals = 1)
| How concentrated is each model's forecast? | |||
| Model | Top-1 prob | Top-5 combined | Effective no. of contenders |
|---|---|---|---|
| Dynamic Bayesian BT | 15.2% | 57.0% | 12.4 |
| Bivariate Poisson | 11.7% | 39.7% | 18.8 |
| Elo | 34.4% | 71.9% | 5.7 |
wc_theme <- theme_minimal(base_size = 13) +
theme(plot.title = element_text(face = "bold", size = 15),
plot.subtitle = element_text(size = 11, colour = "grey40"),
plot.caption = element_text(size = 9, colour = "grey55"),
axis.title = element_text(face = "bold"),
panel.grid.minor = element_blank(), legend.position = "bottom")
top10_elo <- elo_tbl$team[1:10]
snap <- f_elo$history %>%
filter(team %in% top10_elo) %>%
mutate(month = floor_date(Date, "month")) %>%
group_by(team, month) %>% slice_tail(n = 1) %>% ungroup()
end_labels <- snap %>% group_by(team) %>% slice_tail(n = 1) %>% ungroup()
ggplot(snap, aes(month, elo, colour = team, group = team)) +
geom_line(linewidth = 1.05, alpha = 0.9) +
geom_point(data = end_labels, size = 2.8) +
geom_label_repel(data = end_labels, aes(label = team), size = 3.1, nudge_x = 60,
direction = "y", segment.colour = "grey55") +
scale_colour_viridis_d(option = "turbo", guide = "none") +
scale_x_date(date_labels = "%b '%y", date_breaks = "6 months", expand = expansion(mult = c(0.02, 0.12))) +
labs(title = "Elo rating progression: top 10 teams",
subtitle = "Competition-weighted K-factor, margin-of-victory multiplier",
x = NULL, y = "Elo rating",
caption = "All teams start at 1500; last rating in each month shown") +
wc_theme + theme(axis.text.x = element_text(angle = 35, hjust = 1))
rank_cor <- combined %>% dplyr::select(BT = rank_bt, Poisson = rank_pois, Elo = rank_elo) %>% cor(method = "spearman")
cor_df <- as.data.frame(as.table(rank_cor)) %>% rename(Model1 = Var1, Model2 = Var2, Correlation = Freq)
ggplot(cor_df, aes(Model1, Model2, fill = Correlation, label = round(Correlation, 2))) +
geom_tile(color = "white", linewidth = 1) +
geom_text(size = 5, fontface = "bold") +
scale_fill_gradient2(low = "#E63946", mid = "white", high = "#2A9D8F",
midpoint = 0.5, limits = c(0, 1), name = "Spearman") +
labs(title = "Model agreement: rank correlation",
subtitle = "Higher means the models order the teams more similarly",
x = NULL, y = NULL,
caption = sprintf("Spearman correlation of ranks across all %d rated teams", nrow(combined))) +
theme_minimal(base_size = 14) +
theme(plot.title = element_text(face = "bold", hjust = 0.5),
plot.subtitle = element_text(hjust = 0.5, colour = "grey40"),
axis.text = element_text(face = "bold"), panel.grid = element_blank())
top8 <- unique(unlist(lapply(split(all_champs, all_champs$Model),
function(d) head(d$Champion[order(-d$prob)], 8))))
champion_race <- all_champs %>%
filter(Champion %in% top8) %>%
mutate(Champion = factor(Champion, levels = rev(unique(Champion[order(-ave(prob, Champion, FUN = mean))]))))
ggplot(champion_race, aes(prob, Champion, fill = Model)) +
geom_col(position = position_dodge(0.85), width = 0.75, alpha = 0.9) +
geom_text(aes(label = sprintf("%.1f%%", prob * 100)),
position = position_dodge(0.85), hjust = -0.15, size = 3.2) +
scale_fill_manual(values = c("Dynamic Bayesian BT" = "#E63946", "Bivariate Poisson" = "#457B9D", "Elo" = "#2A9D8F"),
name = "Model") +
scale_x_continuous(labels = percent_format(accuracy = 1), limits = c(0, max(champion_race$prob) * 1.15)) +
labs(title = "World Cup champion probabilities: top contenders",
subtitle = sprintf("%s simulations per model", format(n_sims, big.mark = ",")),
x = "Probability of winning the tournament", y = NULL,
caption = sprintf("Monte Carlo standard error at most %.1f percentage points | 2026 format (48 teams)",
100 * sqrt(0.25 / n_sims))) +
wc_theme + theme(panel.grid.major.y = element_blank())
The Bayesian model gives a full distribution for every team, not only a point estimate. The plot shows the 20 strongest teams with their 90% credible intervals. Overlapping intervals mean the data cannot separate those teams with confidence, which is why the champion probabilities are spread out.
bt_tbl %>%
slice_head(n = 20) %>%
ggplot(aes(ability, reorder(team, ability))) +
geom_linerange(aes(xmin = lo, xmax = hi), linewidth = 2.5, colour = "#A8DADC") +
geom_point(size = 3, colour = "#E63946") +
labs(title = "Strength of the top 20 teams: posterior mean and 90% credible interval",
subtitle = "Final period, log-odds scale (larger = stronger)",
x = "Strength", y = NULL,
caption = "Wider interval = less certain; intervals come from the HMC posterior") +
wc_theme
Corrections to the original script
all_champs was used but never created; it is now built
from the simulation output.Limitations
worldfootballR source, so South American teams are rated on
fewer competitive matches than other confederations.lambda3 = 0.000.
It is floored at zero, so a value of 0 means that, after allowing for
team strengths and home advantage, the two teams’ goals are not
positively correlated in this data and the model reduces to independent
Poisson. The bivariate machinery is kept so that the comparison is a
fair test of the idea, but it adds nothing here.iter (this needs refit: true).