← All projects

World Cup Predictor

A goals model built from twenty five years of international results, used to forecast the 2026 World Cup match by match.

Jul 2026 Python GitHub ↗
Poisson Python forecasting

The model

The model gives every team an attack and a defence strength, learned from past results, and uses them to work out the odds of any scoreline in a future match. Applied to every fixture in the World Cup and simulated across the bracket a few thousand times, it produces a forecast for the whole tournament. It is fixed before the 2026 World Cup starts, so it predicts the tournament rather than fitting it after the fact.

The strengths come from a Poisson model. Goals scored by each side are modelled as independent Poisson counts with a log link:

$$\log \lambda_{ij} = \mu + \alpha_i - \delta_j + h \cdot \mathbb{1}[\text{not neutral}]$$

Every team gets two numbers, an attack strength $\alpha$ and a defensive weakness $\delta$. For a given match the model pairs the home side's attack with the away side's defence, adds the intercept $\mu$ and a home advantage $h$ when the tie is not on neutral ground, and turns that into an expected goal count for each team. Goals are taken to arrive at that rate, which fixes the probability of every scoreline. The outer product of the two teams' distributions is a grid over all scorelines, and summing its lower triangle, diagonal and upper triangle gives the win, draw and loss probabilities.

The data

Results, scorers and shootouts all come from Mart Jürisoo's international football results dataset, which covers every recognised international since 1872. The one other input is FIFA's published table for slotting the eight best third-placed teams into the round of 32, which the whole-tournament simulation later needs.

We take international results from 2000 onwards, 23,933 matches, restricted to the 211 FIFA member associations. Membership is derived from the data rather than from a list: playing a qualifier or a World Cup is what membership means in practice. That drops regions, territories, unrecognised states and diaspora sides, none of which can reach a World Cup.

Every match carries the same weight, whether it is a friendly or a World Cup final. Qualifying is regional, so friendlies are the main occasion on which teams from different continents meet, and that cross-continental evidence is a lot of what the model relies on them for. We keep the match type label only to stratify the scores at the end.

Fitting and scoring

The data splits at the 2022 opening fixture. Everything before it trains the model, and everything from there to June 2026, some 3,513 matches, is held back to tune it. Tuning on that whole span rather than only the 64 World Cup fixtures inside it gives far more to work with. The 2026 tournament sits outside both.

The fit is a single call, and its two global coefficients read directly. exp(intercept) is the goal rate for a fixture between two average teams on neutral ground, and exp(home_adv) is the multiplicative home effect:

params = model.fit(df, model.CUTOFF_2022)

print(f"baseline goal rate  {np.exp(params.intercept):.2f} per team per match")
print(f"home advantage      x{np.exp(params.home_adv):.2f} when not on neutral ground")
baseline goal rate  1.05 per team per match
home advantage      x1.36 when not on neutral ground

Calibration comes before any single score, since it catches systematic bias that a summary number hides: whether each outcome is predicted about as often as it actually happens.

Table 1. Out of sample calibration. Mean predicted probability against observed frequency, 3513 matches.
outcomepredictedactual
home win 0.471 0.471
draw 0.216 0.235
away win 0.313 0.294

The home-win probability matches its frequency exactly. Draws are where it slips: the model expects 21.6% of matches to finish level, where 23.5% actually do. That gap does not close under any of the tuning below, so it looks structural rather than like something more tuning could fix. We come back to it under Dixon Coles.

A single score needs a baseline to beat. The floor predicts the unconditional outcome rates for every fixture, knowing nothing about the teams, and failing to beat it would mean the fitted strengths add nothing at all. Brier and log loss are both proper scores and lower is better; accuracy is included for readability and is not proper.

Table 2. Holdout scores against the base rates floor, 3513 matches. Brier and log loss lower is better, accuracy higher.
metricmodelbase rates
brier 0.5112 0.6365
log loss 0.8688 1.0549
accuracy 0.6009 0.4708

Two corrections

The fit above treats a result from 2000 as worth exactly as much as one from 2022, so each team's coefficients carry two decades of squad turnover. Weighting each match by 0.5 ** (age / half_life), measured back from the cutoff, fades that out. It is the only weighting in the fit, and the half life is the one number we sweep.

Table 3. Half life sweep, scored on the holdout. Columns are days, None is no decay.
metricNone3000200015001000700500350
brier 0.5112 0.5048 0.5034 0.5029 0.5033 0.5052 0.5086 0.5138
log loss 0.8688 0.8586 0.8563 0.8552 0.8558 0.8588 0.8647 0.8743
accuracy 0.6009 0.6035 0.6035 0.6043 0.6049 0.6015 0.5995 0.5929

The curve is shallow, but every decayed fit beats the undecayed one, and the Brier score is lowest at a half life of 1500 days:

best = min(half_lives[1:], key=lambda h: sweep[str(h)]["brier"])
tuned = model.fit(df, model.CUTOFF_2022, best)
half life 1500 days

The draw deficit survives the decay. Its cause is the independence assumption: real matches drift towards a level score in a way that two independent Poisson counts cannot express, and the effect concentrates in low scoring games. Dixon and Coles (1997) correct it by multiplying four cells of the scoreline grid by a factor built from a single parameter $\rho$:

$$\tau(0,0) = 1 - \lambda\mu\rho \qquad \tau(0,1) = 1 + \lambda\rho$$ $$\tau(1,0) = 1 + \mu\rho \qquad \tau(1,1) = 1 - \rho$$

Negative $\rho$ moves probability onto 0-0 and 1-1 and off 1-0 and 0-1, which is to say onto draws. The four factors are built so that total probability is unchanged, so nothing needs renormalising afterwards:

def tau(home_goals, away_goals, rate_home, rate_away, rho):
    factor = np.ones(np.broadcast(home_goals, away_goals, rate_home, rate_away).shape)
    factor = np.where((home_goals == 0) & (away_goals == 0), 1 - rate_home * rate_away * rho, factor)
    factor = np.where((home_goals == 0) & (away_goals == 1), 1 + rate_home * rho, factor)
    factor = np.where((home_goals == 1) & (away_goals == 0), 1 + rate_away * rho, factor)
    factor = np.where((home_goals == 1) & (away_goals == 1), 1 - rho, factor)
    return factor

We estimate $\rho$ in a second stage, by maximum likelihood on the training matches with the goal rates held fixed. That keeps the regression intact. A joint fit would mean hand writing the likelihood over 400 parameters, and the bias from holding the rates fixed is second order.

dc = model.fit(df, model.CUTOFF_2022, best, dixon_coles=True)
rho -0.0238
Table 4. What the Dixon Coles correction does to calibration, per outcome.
outcomepoissondixon colesactual
home win 0.4651 0.4628 0.4708
draw 0.2196 0.2242 0.2348
away win 0.3153 0.3130 0.2943

The correction closes roughly a third of the draw gap. Its effect on the pooled holdout scores is small, a Brier of 0.5026 against 0.5029, because most matches are never close enough for the four adjusted cells to make a difference. What it mainly buys is a better-shaped forecast, not a better aggregate score.

One more split is worth making before the settings are fixed. The pooled score is dominated by friendlies and qualifiers, where lopsided fixtures make outcomes easy to call. Stratifying by match type separates skill on the target distribution from skill on the easy tail.

Table 5. Holdout scores split by match type. The World Cup column is 64 matches.
metricWorld CupFIFAFriendlyOTHER
brier 0.5922 0.4565 0.5317 0.5099
log loss 1.0192 0.7802 0.8993 0.8671
accuracy 0.5312 0.6451 0.5848 0.5952

World Cup matches are the hardest column by a clear margin, which is no surprise when a tournament of qualified teams has no easy fixtures to pad the score. That makes 0.5922 the yardstick for the 2026 result; the pooled 0.5026 is flattered by all the lopsided games.

The 2026 tournament

That settles the last of the choices. The final model uses a half life of 1500 days and the Dixon Coles correction. Refitting it with the cutoff moved to the 2026 opening fixture lets it see every match up to the day before the tournament, the whole holdout included. No 2026 result goes into the fit.

current = model.fit(df, model.CUTOFF_2026, best, dixon_coles=True)
trained on 23829 matches, up to 2026-06-10
half life 1500 days, rho -0.0168
goal rate  1.05 per team per match
home adv   x1.29 when not on neutral ground

This fit gives each side's rating going into the tournament, its attack plus defence on the log scale. The numbers mean little on their own, so the table is worth reading for the order of the teams and for how far each has moved since 2022. That movement is not just recent form either, as the 1500 day half life keeps the 2022 tournament in the picture.

Table 6. Rating and rank movement over the 2022 to 2026 cycle.
teamrankwasmovedbeforeafterchange
Argentina 1 2 1 2.581 2.632 0.051
Brazil 2 1 -1 3.044 2.591 -0.453
Spain 3 3 0 2.557 2.589 0.032
England 4 4 0 2.381 2.429 0.048
France 5 6 1 2.330 2.336 0.006
Portugal 6 5 -1 2.356 2.325 -0.031
Colombia 7 11 4 2.160 2.220 0.060
Netherlands 8 7 -1 2.247 2.174 -0.073
Belgium 9 9 0 2.198 2.159 -0.039
Germany 10 8 -2 2.204 2.152 -0.052
Uruguay 11 12 1 2.079 2.000 -0.079
Italy 12 10 -2 2.189 1.981 -0.208
Morocco 13 27 14 1.571 1.962 0.392
Denmark 14 13 -1 2.067 1.943 -0.124
Croatia 15 14 -1 1.857 1.897 0.040
Switzerland 16 15 -1 1.835 1.858 0.023
Ecuador 17 18 1 1.698 1.827 0.129
Japan 18 29 11 1.537 1.798 0.261
Norway 19 30 11 1.479 1.753 0.274
Austria 20 33 13 1.436 1.703 0.267

The tournament itself is then scored on all 104 matches.

Table 7. The 2026 World Cup, 104 matches, scored against the floor and against the 2022 World Cup stratum of the holdout.
metricmodelbase rates2022 holdout
brier 0.4829 0.6359 0.5922
log loss 0.8226 1.0538 1.0192
accuracy 0.6731 0.4712 0.5312

The result is better than the 2022 column on all three metrics, with 0.6731 accuracy against a 0.4712 floor. This is not evidence that the model improved. A single tournament of 104 matches is a small sample, and the 2026 knockout draw was less even than 2022, so much of the margin is the tournament rather than the model. What the number does show is that the model still beats its floor by a wide margin on data that went into no part of the tuning.

The matches it got wrong say more than the aggregate score. These are the ones it gave the lowest probability to the outcome that actually happened, so a low given means it was confident and wrong.

Table 8. The ten matches the model got most wrong. Given is the probability it put on what happened.
datehome_teamaway_teamscorep_homep_drawp_awayoutcomegiven
2026-06-15 Spain Cape Verde 0-0 0.852 0.112 0.036 draw 0.112
2026-06-20 Ecuador CuraƧao 0-0 0.839 0.124 0.037 draw 0.124
2026-07-05 Brazil Norway 1-2 0.623 0.223 0.155 away win 0.155
2026-06-13 Qatar Switzerland 1-1 0.087 0.162 0.752 draw 0.162
2026-06-23 England Ghana 0-0 0.739 0.187 0.074 draw 0.187
2026-06-17 Portugal DR Congo 1-1 0.710 0.201 0.089 draw 0.201
2026-06-21 Uruguay Cape Verde 2-2 0.677 0.227 0.096 draw 0.227
2026-06-24 South Africa South Korea 1-0 0.233 0.308 0.459 home win 0.233
2026-06-15 Saudi Arabia Uruguay 1-1 0.108 0.245 0.647 draw 0.245
2026-06-29 Germany Paraguay 1-1 0.575 0.245 0.180 draw 0.245

Eight of the ten are draws, and seven of those are a strong favourite held by a weaker side. This is the same draw deficit from earlier, this time landing on the matches the model was most confident about. Dixon Coles raises draw probabilities across the board, but these fixtures needed far more than the average lift, and one global $\rho$ has no way to give the confident games extra weight.

The knockout bracket

The knockout stage needs nothing more than a working match model. The 2026 bracket is held as a directed graph, one node per fixture and an edge from each tie to the one its winner feeds. A topological sort gives the order to play them in, so a tie is reached only once both its feeders have produced a winner, and the single sink is the final.

graph = model.bracket(model.knockout_ties(final_test))
odds = model.simulate_bracket(graph, current, runs=10_000)
31 fixtures, 30 edges

The round of 32 pairings are the real ones, and the tree above them is recovered from who met whom in each later round. This is fixed tournament structure rather than hindsight about results, since the shape of the draw was set before the tournament began. Scorelines are sampled from the full grid rather than the win, draw and loss summary, and a level result is settled by a coin flip, since shootouts are close to unpredictable.

Knockout bracket with each tie labelled by its most likely winner and shaded by that probability
Figure 1. The bracket before any of it was played, simulated 10,000 times. Each tie shows the team most likely to come out of it, shaded by that probability. Later rounds show whoever is most likely to occupy the slot, which is why no team owns a node outright.

Argentina is the favourite at 17%, and no team clears a fifth. The field is flat because the model carries a 30% draw rate and settles shootouts on a coin flip, so a favourite's edge in any single tie is small and compounds slowly over five rounds.

The same bracket filled in with actual results, colour coded by how close the model was
Figure 2. The same tree with the tournament filled in, shaded by how close the model got on each tie. The bold line traces Spain's route to the trophy.

The shading has four states rather than a plain right and wrong. A tie that finished level went to penalties, which the model treats as a coin flip by construction, so losing one is not a modelling error and is not counted as one. Setting those aside, the model called 24 of 27 non shootout ties: three with the exact scoreline, twenty one with the right team through, four lost on penalties and three wrong. One of the three wrong ones is the final itself.

Simulating the whole tournament

Everything above starts from the real round of 32, so every number in it is conditional on the group stage having gone as it did. Argentina's 17% is really the chance given that they topped their group and landed in that half of the draw. A team that went out in the groups has a probability of zero, which is correct after the fact but useless as a forecast.

Running the whole tournament from scratch drops that conditioning. We simulate all seventy two group matches, rank each group on points, then goal difference, then goals scored, take the eight best third-placed teams, and play the knockout out from there.

Three pieces of tournament structure have to be supplied, though none of them is a guess. The groups come straight from the fixtures: each team plays the three others in its group, so the match graph splits into twelve components of four. The round of 32 template is read off the real bracket. And which slot each group winner meets depends on which eight of the twelve groups the qualifying third-placed teams come from, so it is set by FIFA's published table, one of 495 possible mappings.

Table 9. Probability of winning the tournament, simulated from the group stage against simulated from the real round of 32.
teamfrom the group stagefrom the real round of 32
Brazil 0.126 0.121
Spain 0.125 0.118
Argentina 0.112 0.166
England 0.089 0.093
France 0.073 0.071
Portugal 0.070 0.059
Belgium 0.051 0.054
Colombia 0.048 0.059
Germany 0.047 0.052
Netherlands 0.045 0.050
Morocco 0.027 0.024
Uruguay 0.025 0.000

Placing the two columns side by side shows what the conditioning was doing. Argentina falls from 16.6% to 11.2% once they have to play through the group stage rather than start past it, and Uruguay drops from 2.5% to zero, having gone out in the groups. Spain, who went on to win the whole thing, sat at 12.5% before a ball was kicked, a thousandth of a point behind Brazil.

The morning of the final

Everything above holds the model at the day before the tournament, which is what a fair forecast requires. This last section asks a different question: what would the model have said on 19 July, having seen the whole tournament except the match about to be played?

The cutoff moves to the final itself, so the fit sees all 103 preceding matches. Nothing else changes. Spain and Argentina each arrive with six more matches on their record than the frozen model had.

eve = model.fit(df, model.FINAL_2026, best, dixon_coles=True)
trained on 23932 matches, up to 2026-07-18
rho -0.0176, against -0.0168 before the tournament

This fit has seen almost the whole tournament, so it is used only for the forecast below and its ratings are not comparable with the earlier ones. The final is still held out, so the prediction is out of sample, though one match is more a demonstration than a test.

Table 10. The final, forecast before the tournament and on the morning of the match. It finished 1-0 to Spain after extra time.
outcomebefore the tournamentmorning of the final
Spain win 0.335 0.366
draw 0.306 0.304
Argentina win 0.359 0.330

Six matches of evidence flip the favourite. Before the tournament the model put Argentina ahead by 2.4 points; on the morning of the final it put Spain ahead by 3.6. Both forecasts are close to a three way split, which is what a final between the sides ranked first and third should produce.

Table 11. Most likely scorelines for the final under both fits, showing every scoreline in either top six.
scorelinebeforefinal morning
1-1 0.137 0.137
1-0 0.125 0.131
0-0 0.128 0.125
0-1 0.131 0.122
2-1 0.069 0.073
2-0 0.065 0.072
1-2 0.072 0.068
0-2 0.071 0.063

It finished 1-0 to Spain after extra time, the second most likely scoreline on the morning of the match at 13.1%, with the right result also at second favourite. On a match the fit never saw, that is a close call, though a single result carries little weight either way.