Passing Sonars Football Viz in Python: Full Walkthrough

Aleks Kapich
7 min readJul 25, 2024

--

Plot explores pass direction, quantity and average length at the same time

In this post, I’m going to walk through the steps necessary for creating passing sonars visualization, the same as displayed above. We will use free event data shared by StatsBomb (highly recommendable resource for obtaining quality football data for free). At the very bottom of this piece, there’s a Python function ready to copy and use in Your projects.

Those familiar with how to fetch the StatsBomb data in Python may skip this section. First, we gotta import a dedicated StatsBomb package.

You can explore all the competitions for which they released free data with code below.

from statsbombpy import sb

sb.competitions()

For our viz, I chose the 2008/2009 Champions League final, where Barcelona defeated Manchester United 2–0.

From this data frame, we need to take competition_id and season_id (16 and 41 respectively) to obtain information about matches within the competition. The match_id we need is 3750201.

Instead of fetching data frame with complete match data, it’s enough for us to focus just on pass data. It will be stored in the passes data frame.

MATCH_ID = 3750201
TEAM = 'Barcelona'

passes = sb.events(match_id=MATCH_ID, split=True, flatten_attrs=False)["passes"]

passes = passes[passes['team']==TEAM]

df = passes[['pass', 'player']]

Our main points of interest are columns pass and player.

Let’s take a look how a single cell in the pass column looks like.

It contains a lot of nested information from which we especially need to extract pass length and pass angle.

df['angle'] = [df['pass'][i]['angle'] for i in df.index]
df['length'] = [df['pass'][i]['length'] for i in df.index]

Here comes the moment where classic data manipulation libraries, pandas & numpy, step in. Every sonar consists of triangles representing information about passes in a particular angle range, hence we’re willing to bin the pass angles. Pandas provides a dedicated function cut for binning the data. Let’s divide it into 20 bins (angles are represented in radians). Parameter labels set to False means the function returns only integer indicators of the bins, and parameter include_lowest set to True assures that the first interval is left-inclusive.

import pandas as pd
import numpy as np

df['angle_bin'] = pd.cut(
df['angle'],
bins=np.linspace(-np.pi,np.pi,21),
labels=False,
include_lowest=True
)

Our data frame currently looks like this:

We’re ready to calculate the average pass length and amount of passes within each bin for every player. That crucial data will be stored in the new data frame sonar_df.

# average length
sonar_df = df.groupby(["player", "angle_bin"], as_index=False)
sonar_df = sonar_df.agg({"length": "mean"})

# counting passes for each angle bin
pass_amt = df.groupby(['player', 'angle_bin']).size().to_frame(name = 'amount').reset_index()

# concatenating the data
sonar_df = pd.concat([sonar_df, pass_amt["amount"]], axis=1)

The plot also takes into consideration the average locations where the passes took place. For this calculation, we ought to cast back to passes data frame which contains coordinates for each pass. The column location stores lists with each of them containing x and y values for passes. We should unpack these values before averaging them.

# extracting coordinates
passes["x"], passes["y"] = zip(*passes["location"])

average_location = passes.groupby('player').agg({'x': ['mean'], 'y': ['mean']})
average_location.columns = ['x', 'y']

sonar_df = sonar_df.merge(average_location, left_on="player", right_index=True)

Finally, we managed to accumulate all the data necessary for our viz in one data frame.

There’s one more detail though — what about the substitutions? In our case, we will include in our chart solely players featured in the starting XI. Fortunately, StatsBomb allows us to easily obtain information about lineups for the matches too.

lineups = sb.lineups(match_id=MATCH_ID)[TEAM]
lineups['starter'] = [
lineups['positions'][i][0]['start_reason']=='Starting XI'
if lineups['positions'][i]!=[]
else None
for i in range(len(lineups))
]
lineups = lineups[lineups["starter"]==True]

startingXI =lineups['player_name'].to_list()

sonar_df = sonar_df[sonar_df['player'].isin(startingXI)]

Finally, we may move on to plotting the pitch. That will require a few more imports — mplsoccer is a library developed to facilitate football visualization in Python and it comes in handy with ready importable football pitches. Module patches from matplotlib will help us with plotting triangular segments of sonars.

from mplsoccer import Pitch
import matplotlib.pyplot as plt
import matplotlib.patches as pat

At first, let’s just plot the pitch, embellishing it with some dark color.

fig ,ax = plt.subplots(figsize=(13, 8),constrained_layout=False, tight_layout=True)
fig.set_facecolor('#0e1117')
ax.patch.set_facecolor('#0e1117')
pitch = Pitch(pitch_type='statsbomb', pitch_color='#0e1117', line_color='#c7d5cc')
pitch.draw(ax=ax)

Now, the focal point of the whole post — plotting sonars. Triangles will be colored using a 3-degree scale. For each player from the starting eleven, we iterate over his 20 pass bins and plot the corresponding sonar segment. Thanks to integer indications of the bins we can easily calculate the start and end angles of each segment by multiplying (360°/20) by the bin number (20 equals the amount of bins).

for player in startingXI:
for _, row in sonar_df[sonar_df.player == player].iterrows():
degree_left_start = 198

color = "gold" if row.amount < 3 else "darkorange" if row.amount < 5 else '#9f1b1e'

n_bins = 20
degree_left = degree_left_start +(360 / n_bins) * (row.angle_bin)
degree_right = degree_left - (360 / n_bins)

pass_wedge = pat.Wedge(
center=(row.x, row.y),
r=row.length*0.16, # scaling the sonar segments
theta1=degree_right,
theta2=degree_left,
facecolor=color,
edgecolor="black",
alpha=0.6
)
ax.add_patch(pass_wedge)

At last, we would like to know to whom each sonar refers. Unless we wish to have sonars labeled with full names of players like Lionel Andrés Messi Cuccittini we can just map them onto shorter names we’re used to.

barcelona_dict = {
'Andrés Iniesta Luján': 'Andrés Iniesta',
'Carles Puyol i Saforcada': 'Carles Puyol',
'Gerard Piqué Bernabéu': 'Gerard Piqué',
'Gnégnéri Yaya Touré': 'Yaya Touré',
'Lionel Andrés Messi Cuccittini': 'Lionel Messi',
"Samuel Eto''o Fils": "Samuel Eto'o",
'Sergio Busquets i Burgos': 'Sergio Busquets',
'Seydou Kéita': 'Seydou Kéita',
'Sylvio Mendes Campos Junior': 'Sylvinho',
'Thierry Henry': 'Thierry Henry',
'Víctor Valdés Arribas': 'Víctor Valdés',
'Xavier Hernández Creus': 'Xavi',
}

for _, row in average_location.iterrows():
if row.name in startingXI:
annotation_text = barcelona_dict[row.name]

pitch.annotate(
annotation_text,
xy=(row.x, row.y-4.5),
c='white',
va='center',
ha='center',
size=9,
fontweight='bold',
ax=ax
)

ax.set_title(
f"Barcelona vs Manchester United: Champions League 2008/2009 Final\nPassing Sonars for Barcelona (starting XI)",
fontsize=18, color="w", fontfamily="Monospace", fontweight='bold', pad=-8
)

pitch.annotate(
text='Sonar length corresponds to average pass length\nSonar color corresponds to pass frequency (dark = more)',
xy=(0.5, 0.01), xycoords='axes fraction', fontsize=10, color='white', ha='center', va='center', fontfamily="Monospace", ax=ax
)

Below full code packed into an universal function:

from statsbombpy import sb
import pandas as pd
import numpy as np
from mplsoccer import Pitch
import matplotlib.pyplot as plt
import matplotlib.patches as pat


def passing_sonar(MATCH_ID, TEAM):
passes = sb.events(match_id=MATCH_ID, split=True, flatten_attrs=False)["passes"]
passes = passes[passes['team']==TEAM]
df = passes[['pass', 'player']]

df['angle'] = [df['pass'][i]['angle'] for i in df.index]
df['length'] = [df['pass'][i]['length'] for i in df.index]
df['angle_bin'] = pd.cut(
df['angle'],
bins=np.linspace(-np.pi,np.pi,21),
labels=False,
include_lowest=True
)

sonar_df = df.groupby(["player", "angle_bin"], as_index=False)
sonar_df = sonar_df.agg({"length": "mean"})
pass_amt = df.groupby(['player', 'angle_bin']).size().to_frame(name = 'amount').reset_index()
sonar_df = pd.concat([sonar_df, pass_amt["amount"]], axis=1)

passes["x"], passes["y"] = zip(*passes["location"])
average_location = passes.groupby('player').agg({'x': ['mean'], 'y': ['mean']})
average_location.columns = ['x', 'y']
sonar_df = sonar_df.merge(average_location, left_on="player", right_index=True)

lineups = sb.lineups(match_id=MATCH_ID)[TEAM]
lineups['starter'] = [
lineups['positions'][i][0]['start_reason']=='Starting XI'
if lineups['positions'][i]!=[]
else None
for i in range(len(lineups))
]
lineups = lineups[lineups["starter"]==True]
startingXI =lineups['player_name'].to_list()
sonar_df = sonar_df[sonar_df['player'].isin(startingXI)]

fig ,ax = plt.subplots(figsize=(13, 8),constrained_layout=False, tight_layout=True)
fig.set_facecolor('#0e1117')
ax.patch.set_facecolor('#0e1117')
pitch = Pitch(pitch_type='statsbomb', pitch_color='#0e1117', line_color='#c7d5cc')
pitch.draw(ax=ax)

for player in startingXI:
for _, row in sonar_df[sonar_df.player == player].iterrows():
degree_left_start = 198

color = "gold" if row.amount < 3 else "darkorange" if row.amount < 5 else '#9f1b1e'

n_bins = 20
degree_left = degree_left_start +(360 / n_bins) * (row.angle_bin)
degree_right = degree_left - (360 / n_bins)

pass_wedge = pat.Wedge(
center=(row.x, row.y),
r=row.length*0.16,
theta1=degree_right,
theta2=degree_left,
facecolor=color,
edgecolor="black",
alpha=0.6
)
ax.add_patch(pass_wedge)


for _, row in average_location.iterrows():
if row.name in startingXI:
annotation_text = row.name

pitch.annotate(
annotation_text,
xy=(row.x, row.y-4.5),
c='white',
va='center',
ha='center',
size=9,
fontweight='bold',
ax=ax
)


pitch.annotate(
text='Sonar length corresponds to average pass length\nSonar color corresponds to pass frequency (dark = more)',
xy=(0.5, 0.01), xycoords='axes fraction', fontsize=10, color='white', ha='center', va='center', fontfamily="Monospace", ax=ax
)

return fig

Thank you for reading my piece. Any feedback will be greatly appreciated. For more football content, visit my Twitter/X.

--

--