Source: UEFA.com

Simulating UEFA Champions League Draw With Python

Exciting European football matches are in your hand!

Natih Bernan
The Startup
Published in
7 min readDec 13, 2020

--

Champions League just finished all their group stage games last week, and we will not hear that iconic anthem at the start of every match again until late February. 16 teams are already qualified, but they are yet to know who will they face in the next round, up until the drawing that will be held in Switzerland on December 14th. By using Python, we may know the clubs' fate a little ahead in time and channel our inner “fortune-teller”. In this article, we will discuss how to do a simulation of the Champions League Round of 16 Draw with Python.

For those of you who don’t follow football, UEFA Champions League (UCL) is an annual football competition held by the European football organization, UEFA. It is one of the most famous sports competition in the world, where 32 top European teams are participating at the beginning of the competition. They were divided into 8 groups with 4 teams in each group. The top 2 teams of each group will advance to the Round of 16 to play head-to-head against each other, competing for the most prestigious trophy in Europe.

Champions League Trophy. Source: UEFA.com

Drawing Restrictions

As I mentioned before, there are 16 teams that are coming from 8 different groups, with 8 teams being group winners (seeded) and the other 8 teams are group runner-ups (unseeded). We will import the teams' data to Python with Pandas library. You can download the .csv file here.

import pandas as pd
all_teams = pd.read_csv('UCL_roundof16.csv')
display(all_teams)

There are some restrictions in the drawing process as follows:

  1. Each seeded teams will play against one of the unseeded teams
  2. Teams from the same group in the group stage cannot play against each other
  3. Teams from the same country cannot play against each other

To accommodate these restrictions, we need to update our dataset so that it includes the list of eligible opponents for each team.

# initiate new columns
all_teams['Possible Opponents'] = [[]] * 16
all_teams['Number of Possible Opponents'] = [0] * 16
all_teams['Picked'] = [False] * 16
# fill the new columns
for i in range(16):
all_teams['Possible Opponents'][i] = []
for j in range(16):
if (all_teams['Seeded'][i] != all_teams['Seeded'][j])& (all_teams['Country'][i] != all_teams['Country'][j]) & (all_teams['Group'][i] != all_teams['Group'][j]):
all_teams['Possible Opponents'][i].append(all_teams['Abbreviation'][j])
all_teams['Number of Possible Opponents'][i] = len(all_teams['Possible Opponents'][i])

So we have our updated teams’ data as follows:

We can see that there is a potential “big match” as FC Barcelona is unseeded due to finishing second in Group G behind Juventus, so they are up against one of the group winners who are also the favorites to win the competition, such as Bayern Munchen, Liverpool, and Paris Saint-Germain. But which team do they most likely will play with? Let’s find out!

Drawing Process: Pick the Ball, Reveal Your Fate!

If you never follow a live UCL Draw before, you can see one from last year in this video, just to get a glimpse of how it’s done.

The drawing process is quite simple. Here are the steps.

  1. Pick one ball which represents one of the seeded teams. Let’s call this first team Team X. Remove Team X from the draw so that it won’t get picked up twice.
  2. Put balls that represent all the teams who are eligible to play against Team X (based on the table that we created before) in a bowl, then pick one ball among them, we will call this Team Y. Remove Team Y from the draw so that it won’t get picked up twice. There we have our first fixture, Team X vs Team Y.
  3. Repeat this process until all teams got picked.

As we go through, we might end up in a condition where a team only has one team left that they are eligible to play. For example, suppose the teams that aren’t picked yet are Manchester City, Chelsea, Atletico Madrid, and Sevilla.

Between these teams, Chelsea can’t play against Sevilla as they are coming from the same group (restriction 2), therefore they have to play against Atletico Madrid. This also means that the last fixture will be between Manchester City and Sevilla. I will call condition like this “Implied Fixtures”, and we will see how do we overcome this in the code shortly.

Let’s Code!

We will divide the code into three parts based on the steps above. But before that, we will create a Pandas DataFrame where we can store the fixtures that already drawn.

first_team = [''] * 8
vs = ['vs'] * 8
second_team = [''] * 8
fixtures = pd.DataFrame({'First Team': first_team, 'vs': vs, 'Second Team': second_team})

Pick the first ball

Here we pick our first ball, which will represent the seeded team.

# pick the first ballm = 0
seeded_teams_left = len(all_teams[(all_teams['Seeded'] == True) & (all_teams['Picked'] == False)])
first = random.randint(0, seeded_teams_left - 1)
fixtures['First Team'][m] = all_teams['Abbreviation'][first]
all_teams['Picked'][all_teams['Abbreviation'] == fixtures['First Team'][m]] = True
display(fixtures.loc[[m]])

You picked the Italian champion, Juventus! They are eligible to play against Atletico Madrid, Borussia Monchengladbach, FC Porto, Sevilla, and RB Leipzig. Which one would it be?

Pick the second ball

Next, we will pick the second ball that is eligible to play against a team from the first ball. We also remove the teams that already drawn from our dataset so it won’t get drawn twice.

# pick the second ballsecond = random.randint(0, all_teams['Number of Possible Opponents'][first] - 1)
fixtures['Second Team'][m] = all_teams['Possible Opponents'][first][second]
# remove teams that already drawn from datasetall_teams['Picked'][all_teams['Abbreviation'] == fixtures['Second Team'][m]] = True
all_teams = all_teams[all_teams['Picked'] == False].reset_index(drop = True)
for i in range(len(all_teams)):
if fixtures['First Team'][m] in all_teams['Possible Opponents'][i]:
all_teams['Possible Opponents'][i].remove(fixtures['First Team'][m])
if fixtures['Second Team'][m] in all_teams['Possible Opponents'][i]:
all_teams['Possible Opponents'][i].remove(fixtures['Second Team'][m])
all_teams['Number of Possible Opponents'][i] = len(all_teams['Possible Opponents'][i])
m += 1
display(fixtures.loc[[m-1]])

And Juventus will play Atletico Madrid. This would be a great match surely!

Implied Fixtures

We now will create a function to check if our latest draw would result in an implied fixture (this is unlikely after drawing just one fixture, but there is no harm in checking). There is an implied fixture if the minimum number of possible opponents in the remaining teams is equal to 1.

if min(all_teams['Number of Possible Opponents']) > 1:
print('No implied fixtures. Continue picking ball for the first team')
else:
team_index = all_teams[all_teams['Number of Possible Opponents'] == 1].index[0]
if all_teams['Seeded'][team_index] == True:
first = team_index
fixtures['First Team'][m] = all_teams['Abbreviation'][first]
all_teams['Picked'][all_teams['Abbreviation'] == fixtures['First Team'][m]] = True

second = random.randint(0, all_teams['Number of Possible Opponents'][first] - 1)
fixtures['Second Team'][m] = all_teams['Possible Opponents'][first][second]
all_teams['Picked'][all_teams['Abbreviation'] == fixtures['Second Team'][m]] = True

all_teams = all_teams[all_teams['Picked'] == False].reset_index(drop = True)
else:
second = team_index
fixtures['Second Team'][m] = all_teams['Abbreviation'][second]
all_teams['Picked'][all_teams['Abbreviation'] == fixtures['Second Team'][m]] = True

fixtures['First Team'][m] = all_teams['Possible Opponents'][second][0]
all_teams['Picked'][all_teams['Abbreviation'] == fixtures['First Team'][m]] = True

all_teams = all_teams[all_teams['Picked'] == False].reset_index(drop = True)
for i in range(len(all_teams)):
if fixtures['First Team'][m] in all_teams['Possible Opponents'][i]:
all_teams['Possible Opponents'][i].remove(fixtures['First Team'][m])
if fixtures['Second Team'][m] in all_teams['Possible Opponents'][i]:
all_teams['Possible Opponents'][i].remove(fixtures['Second Team'][m])
all_teams['Number of Possible Opponents'][i] = len(all_teams['Possible Opponents'][i])
m += 1
print('There is an implied fixture. Please check again')
display(fixtures.loc[[m-1]])

The result shows that there are no implied fixtures, so we can continue by picking the first and second ball again.

Final Result

As we go on, here is the full result of our draw.

Manchester City vs FC Barcelona would be one to look forward to, as Pep Guardiola would try to beat his ex-club and have a chance to give City their very first UCL trophy. There are some other interesting matchups as well, such as Juventus vs Atletico Madrid that we mentioned earlier, and English champion Liverpool against last year's semifinalist, RB Leipzig.

But this is just one simulation. We may want to repeat this process for a certain number of iterations to get better “predictions”. Here is the result after I did 100,000 iterations.

We can see that 31% of the simulations include Real Madrid vs RB Leipzig, the highest proportion of any other fixtures. Another high-occurrence matchup is Juventus against Borussia Monchengladbach. As for FC Barcelona, out of their 6 possible opponents, they are most likely to play against Bayern Munchen with a 22% chance. This might be bad news for Messi and co, since we all know that last season they were heavily beaten by the German champions. Only time will tell whether they can take revenge or suffer another defeat, if this fixture actually happens.

Conclusions

There we have it, we have done Champions League Round of 16 draw simulation using Python. If you want to play around with the code, go check my Google Colab notebook here, and see if you can get other interesting results. If you have any feedback please feel free to leave a comment. Good luck to all the football fans, I hope your team would get a good result!

--

--