Predictions

The examples use the static bivariate Poisson model fitted in Static models and the weekly-dynamic model fitted in Dynamic models; the latter leaves the last four match days out-of-sample:

library(footBayes)
library(dplyr)
library(ggplot2)
library(bayesplot)
library(loo)

n_iter <- 1000
n_chains <- 4
seed <- 2026

data("italy")
italy <- as.data.frame(italy)

italy_2000 <- italy %>%
  filter(Season == "2000") %>%
  arrange(Date) %>%
  select(periods = Season, home_team = home, away_team = visitor,
         home_goals = hgoal, away_goals = vgoal)

fit_bp <- stan_foot(
  data = italy_2000,
  model = "biv_pois",
  chains = n_chains,
  parallel_chains = n_chains,
  iter_sampling = n_iter,
  seed = seed
)

fit_weekly <- stan_foot(
  data = italy_2000,
  model = "biv_pois",
  dynamic_type = "weekly",
  predict = 36,
  chains = n_chains,
  parallel_chains = n_chains,
  iter_sampling = n_iter,
  seed = seed
)

Predicting the results of future matches is often the final goal of a football model. This article shows how to obtain out-of-sample predictions with footBayes and how to read the output of the three functions that summarize them:

Function Question it answers Output
foot_prob() How is a given match likely to end? Home win, draw and away win probabilities, most likely score, score probability heatmap
foot_round_robin() Which team is favoured in each future match? Home win probability of every out-of-sample match, in a round-robin grid
foot_rank() What will the final league table look like? Predicted final points of each team, observed against simulated

How predictions work

Leaving matches out-of-sample

Predictions are requested when the model is fitted, through the predict argument of stan_foot() (and of mle_foot()). The model is fitted to all the rows of data except the last predict rows, which are treated as future matches. For this reason the data must be sorted in chronological order, with the matches to predict at the bottom.

In the Setup, fit_weekly uses predict = 36. The 2000/2001 Serie A has 18 teams, i.e. 9 matches per match day, so the last four match days (from 31 to 34) are left out-of-sample. Their observed results are not used to fit the model, but they are kept in data so that the functions below can compare the predictions with what actually happened.

The posterior predictive distribution

For a Bayesian model, the predictions come from the posterior predictive distribution of the future data \(\tilde{\mathcal{D}}\),

\[p(\tilde{\mathcal{D}} \mid \mathcal{D}) = \int p(\tilde{\mathcal{D}} \mid \boldsymbol{\theta}) \, \pi(\boldsymbol{\theta} \mid \mathcal{D}) \, d\boldsymbol{\theta},\]

which averages the sampling model over the posterior distribution of the parameters, and therefore accounts for the full uncertainty about the team abilities and the other parameters. In practice it is obtained by simulation: for each of the \(S\) posterior draws \(\boldsymbol{\theta}^{(s)}\), Stan simulates the goals \((\tilde{x}^{(s)}, \tilde{y}^{(s)})\) of every out-of-sample match (they are stored as y_prev in the fitted object). Any probability of interest is then the proportion of simulations in which the event occurs. For instance, the probability of a home win is

\[p_{\text{home}} = \text{Pr}(\tilde{X} > \tilde{Y}) \approx \frac{1}{S} \sum_{s=1}^S I(\tilde{x}^{(s)} > \tilde{y}^{(s)}).\]

With 4 chains of 1000 iterations, every probability below is computed from \(S = 4000\) simulated results of each match.

Match probabilities with foot_prob

foot_prob() takes the fitted model, the same data used for the fit and, optionally, the home_team and away_team of the matches of interest. It requires a model fitted with predict > 0, and the selected matches must be among the out-of-sample ones. Consider, for instance, Reggina Calcio - AC Milan, one of the out-of-sample matches:

foot_prob(
  object = fit_weekly, data = italy_2000,
  home_team = "Reggina Calcio", away_team = "AC Milan"
)
#> $prob_table
#>        home_team away_team prob_h prob_d prob_a         mlo
#> 1 Reggina Calcio  AC Milan  0.251  0.249  0.501 0-1 (0.124)
#> 
#> $prob_plot

The prob_table element contains one row per match:

  • prob_h, prob_d, prob_a: the posterior probabilities of a home win, a draw and an away win, which sum to 1;
  • mlo: the most likely outcome, i.e. the single score with the highest probability, followed by that probability in brackets.

The model favours AC Milan, with an away win probability close to 0.5, and the most likely score is 0-1. Even this score has a probability of only 0.124: football scores are very uncertain, and a single score is rarely predicted with high probability.

The prob_plot element is a heatmap of the probabilities of all the scores, with the home goals on the horizontal axis and the away goals on the vertical axis: the darker the cell, the more likely the score. The red square marks the observed result, a 2-1 home win. The model assigned it a small but non-negligible probability (recall that football is about rare events).

A few other ways of calling the function:

# several matches: home_team and away_team are matched by position
foot_prob(fit_weekly, italy_2000,
  home_team = c("Reggina Calcio", "AS Roma"),
  away_team = c("AC Milan", "Parma AC")
)

# all the out-of-sample matches
foot_prob(fit_weekly, italy_2000)

With more than one match, the heatmap shows one panel per match, limited to the scores from 0-0 to 4-4. For the Student-\(t\) model, which describes the goal difference, only the table with the three outcome probabilities is returned. foot_prob() also accepts the output of mle_foot() fitted with predict > 0: in this case, it obtains the probabilities by simulating the goals from the maximum likelihood estimates and returns only the table.

Home win probabilities with foot_round_robin

foot_round_robin() gives an overview of all the out-of-sample matches at once, arranged as in a round-robin tournament:

foot_round_robin(object = fit_weekly, data = italy_2000)

How to read the plot:

  • each column is a home team and each row an away team, so that every cell is a match; the black cells on the diagonal are the impossible matches of a team against itself;
  • the coloured cells are the out-of-sample matches, and the colour encodes the posterior probability of a home win: the redder the cell, the more the home team is favoured, whereas white cells denote matches in which the home team is unlikely to win (an away win or a draw is more likely);
  • the scores written in the cells are the observed results of the in-sample matches, i.e. the matches used to fit the model; the out-of-sample matches show a dash.

For instance, Lazio Roma - Udinese Calcio (0.577) and Parma AC - Hellas Verona (0.538) are among the matches with the strongest home favourites. In contrast, AS Bari - AS Roma (0.151) is one of the least likely home wins, with the last team of the league hosting the champion.

The same probabilities are returned in the round_table element, with one row per out-of-sample match (Home_prob is the home win probability, Observed is a dash for the matches not used in the fit):

#> $round_table
#>              Home           Away Home_prob Observed
#> 1   Hellas Verona     Bologna FC     0.266        -
#> 2           Inter     Bologna FC     0.341        -
#> 3  Udinese Calcio     SSC Napoli     0.375        -
#> 4  ACF Fiorentina     SSC Napoli     0.429        -
#> 5        US Lecce     Lazio Roma     0.160        -
#> 6           Inter     Lazio Roma     0.198        -
#> 7  Brescia Calcio        AS Bari     0.513        -
#> 8  Reggina Calcio        AS Bari     0.475        -
#> 9  Udinese Calcio Vicenza Calcio     0.306        -
#> 10 Brescia Calcio Vicenza Calcio     0.389        -
#> 11        AS Roma       Parma AC     0.340        -
#> 12       US Lecce       Parma AC     0.144        -
#> 13        AS Roma       AC Milan     0.360        -
#> 14 Reggina Calcio       AC Milan     0.234        -
#> 15  Hellas Verona     AC Perugia     0.266        -
#> 16       Juventus     AC Perugia     0.488        -
#> 17 ACF Fiorentina       Atalanta     0.319        -
#> 18       Juventus       Atalanta     0.426        -
#> 19     SSC Napoli        AS Roma     0.160        -
#> 20        AS Bari        AS Roma     0.151        -
#> 21     Lazio Roma Udinese Calcio     0.577        -
#> 22       Atalanta Udinese Calcio     0.430        -
#> 23     Bologna FC       US Lecce     0.434        -
#> 24 Vicenza Calcio       US Lecce     0.439        -
#> 25       AC Milan Brescia Calcio     0.414        -
#> 26     AC Perugia Brescia Calcio     0.301        -
#> 27     SSC Napoli  Hellas Verona     0.341        -
#> 28       Parma AC  Hellas Verona     0.538        -
#> 29     AC Perugia Reggina Calcio     0.362        -
#> 30       Atalanta Reggina Calcio     0.337        -
#> 31     Lazio Roma ACF Fiorentina     0.510        -
#> 32       AC Milan ACF Fiorentina     0.513        -
#> 33     Bologna FC       Juventus     0.215        -
#> 34 Vicenza Calcio       Juventus     0.224        -
#> 35        AS Bari          Inter     0.300        -
#> 36       Parma AC          Inter     0.528        -

Use the teams argument to restrict the grid to a subset of teams, and output = "table" or output = "plot" to return only one of the two elements.

League table with foot_rank

Predicting the final league table is one of the most popular tasks in football analytics. foot_rank() converts the simulated results into points (3 for a win, 1 for a draw, 0 for a loss) and computes, for each team, the distribution of its final points. It works in two ways, depending on how the model was fitted:

Model fitted with Points of the played matches Points of the future matches What you get
predict = 0 (or missing) simulated from the model (\(\mathcal{D}^{\text{rep}}\)) none in-sample reconstruction of the table: a model check
predict > 0 observed simulated (\(\tilde{\mathcal{D}}\)) out-of-sample prediction of the final table

The visualize argument controls the output: "aggregated" returns the final table and a plot of the final points of all the teams, whereas "individual" (the default) plots the cumulative points of each team over the match days. In the rank_table element, obs. points are the points actually obtained at the end of the season, and median, q25 and q75 are the median and the 25% and 75% quantiles of the simulated final points. In the plots, the blue points/lines are the observed points, the darker band is the 50% credible interval and the lighter band the 95% credible interval.

In-sample reconstruction

The static model fit_bp has no out-of-sample matches, hence the whole season is replicated from the model:

foot_rank(object = fit_bp, data = italy_2000, visualize = "aggregated")
#> $rank_table
#>             teams obs. points median q25 q75
#> 1         AS Roma          75     62  56  68
#> 2        Juventus          73     62  56  68
#> 3      Lazio Roma          69     59  53  65
#> 4        Parma AC          56     55  50  62
#> 5        AC Milan          49     51  45  57
#> 6        Atalanta          44     48  42  54
#> 7  Brescia Calcio          44     48  41  53
#> 8  ACF Fiorentina          43     47  41  53
#> 9           Inter          51     46  40  52
#> 10     AC Perugia          42     45  39  51
#> 11     Bologna FC          43     45  39  50
#> 12 Udinese Calcio          38     42  37  48
#> 13 Vicenza Calcio          36     40  34  46
#> 14       US Lecce          37     40  35  46
#> 15 Reggina Calcio          37     39  33  45
#> 16     SSC Napoli          36     39  33  45
#> 17  Hellas Verona          37     38  32  44
#> 18        AS Bari          20     31  25  37
#> 
#> $rank_plot

The observed points of 14 of the 18 teams fall inside the 50% band, but the model compresses the table: the three top teams (AS Roma, Juventus, Lazio Roma) obtained more points than predicted, whereas AS Bari, last with 20 points, obtained fewer. This compression is typical of static models with exchangeable abilities, which shrink the teams towards the average and assume that the strength of a team does not change during the season.

Out-of-sample prediction

fit_weekly left the last four match days out-of-sample. The points of the first 30 match days are the observed ones, and only the last four match days are simulated:

foot_rank(object = fit_weekly, data = italy_2000, visualize = "aggregated")
#> $rank_table
#>             teams obs. points median q25 q75
#> 1         AS Roma          75     74  72  76
#> 2      Lazio Roma          69     70  68  72
#> 3        Juventus          73     68  66  70
#> 4        Parma AC          56     57  55  59
#> 5        AC Milan          49     54  51  55
#> 6           Inter          51     48  47  50
#> 7      Bologna FC          43     48  46  49
#> 8        Atalanta          44     47  46  49
#> 9      AC Perugia          42     45  43  47
#> 10 ACF Fiorentina          43     44  42  45
#> 11 Brescia Calcio          44     42  40  44
#> 12 Udinese Calcio          38     38  37  40
#> 13 Vicenza Calcio          36     37  36  39
#> 14       US Lecce          37     35  34  37
#> 15 Reggina Calcio          37     34  33  36
#> 16     SSC Napoli          36     32  31  34
#> 17  Hellas Verona          37     32  31  34
#> 18        AS Bari          20     24  22  25
#> 
#> $rank_plot

Since only 12 points out of 102 are still to be played, the intervals are much narrower than in the in-sample reconstruction. The model correctly predicts AS Roma as the champion and AS Bari as the last team. Juventus, however, obtained more points than predicted in the last four match days (73 against a median of 68).

With visualize = "individual", the plot shows the race of the selected teams over the season, with the observed cumulative points up to match day 30 followed by the simulated points of the last four match days and their credible intervals:

foot_rank(
  object = fit_weekly, data = italy_2000,
  teams = c("AS Roma", "Juventus", "Lazio Roma", "AC Milan"),
  visualize = "individual"
)

When predicting out-of-sample, foot_rank() requires the out-of-sample matches to belong to a single season (or value of periods) and to include at least two complete match days.