Passing Networks (Soccer)
Beginner
10 min read
1 views
Nov 27, 2025
# Passing Networks (Soccer)
## Overview
Passing networks visualize the structure and connectivity of team play through nodes (players) and edges (passes). This graph-based approach reveals tactical patterns, key playmakers, and team cohesion.
## Network Metrics
### Node Metrics
- **Degree Centrality**: Number of passing connections
- **Betweenness Centrality**: Players who bridge different groups
- **Closeness Centrality**: Players central to overall network
### Edge Metrics
- **Pass Frequency**: Number of passes between players
- **Pass Completion**: Success rate of connections
- **Pass Distance**: Average length of connections
## Python Implementation
```python
import pandas as pd
import numpy as np
import networkx as nx
import matplotlib.pyplot as plt
import seaborn as sns
from scipy.spatial import Voronoi
import matplotlib.patches as mpatches
class PassingNetworkAnalyzer:
"""Analyze and visualize soccer passing networks."""
def __init__(self, pass_data, player_positions=None):
"""
Initialize passing network analyzer.
Parameters:
-----------
pass_data : pd.DataFrame
Pass events with columns: passer, receiver, x_start, y_start,
x_end, y_end, outcome
player_positions : pd.DataFrame, optional
Average positions with columns: player, x, y
"""
self.pass_data = pass_data
self.player_positions = player_positions
self.network = None
self.metrics = None
def build_network(self, min_passes=3):
"""
Build passing network graph.
Parameters:
-----------
min_passes : int
Minimum passes required for edge
"""
# Aggregate passes between players
pass_counts = self.pass_data.groupby(['passer', 'receiver']).agg({
'outcome': ['count', lambda x: (x == 'Complete').sum()]
}).reset_index()
pass_counts.columns = ['passer', 'receiver', 'total_passes', 'completed_passes']
pass_counts['completion_rate'] = (
pass_counts['completed_passes'] / pass_counts['total_passes']
)
# Filter by minimum passes
pass_counts = pass_counts[pass_counts['total_passes'] >= min_passes]
# Create directed graph
G = nx.DiGraph()
for _, row in pass_counts.iterrows():
G.add_edge(
row['passer'],
row['receiver'],
weight=row['total_passes'],
completion_rate=row['completion_rate']
)
self.network = G
return G
def calculate_network_metrics(self):
"""Calculate network centrality metrics."""
if self.network is None:
self.build_network()
metrics = pd.DataFrame({
'player': list(self.network.nodes()),
'degree_centrality': list(nx.degree_centrality(self.network).values()),
'betweenness_centrality': list(nx.betweenness_centrality(self.network).values()),
'closeness_centrality': list(nx.closeness_centrality(self.network).values()),
'in_degree': [self.network.in_degree(n) for n in self.network.nodes()],
'out_degree': [self.network.out_degree(n) for n in self.network.nodes()]
})
# Add PageRank (influence metric)
pagerank = nx.pagerank(self.network, weight='weight')
metrics['pagerank'] = metrics['player'].map(pagerank)
# Sort by overall influence
metrics = metrics.sort_values('pagerank', ascending=False)
self.metrics = metrics
return metrics
def calculate_average_positions(self):
"""Calculate average player positions from pass data."""
if self.player_positions is not None:
return self.player_positions
# Calculate from pass start positions
passer_positions = self.pass_data.groupby('passer').agg({
'x_start': 'mean',
'y_start': 'mean'
}).reset_index()
passer_positions.columns = ['player', 'x', 'y']
# Calculate from pass end positions
receiver_positions = self.pass_data.groupby('receiver').agg({
'x_end': 'mean',
'y_end': 'mean'
}).reset_index()
receiver_positions.columns = ['player', 'x', 'y']
# Combine and average
all_positions = pd.concat([passer_positions, receiver_positions])
avg_positions = all_positions.groupby('player').agg({
'x': 'mean',
'y': 'mean'
}).reset_index()
self.player_positions = avg_positions
return avg_positions
def visualize_network(self, figsize=(14, 10), min_passes=5):
"""
Visualize passing network on soccer pitch.
Parameters:
-----------
figsize : tuple
Figure size
min_passes : int
Minimum passes to display edge
"""
if self.network is None:
self.build_network(min_passes=min_passes)
if self.player_positions is None:
self.calculate_average_positions()
# Create pitch
fig, ax = plt.subplots(figsize=figsize)
ax.set_xlim(0, 105)
ax.set_ylim(0, 68)
ax.set_aspect('equal')
# Draw pitch
self._draw_pitch(ax)
# Prepare position dictionary
pos_dict = {}
for _, row in self.player_positions.iterrows():
pos_dict[row['player']] = (row['x'], row['y'])
# Draw edges (passes)
edge_weights = [self.network[u][v]['weight'] for u, v in self.network.edges()]
max_weight = max(edge_weights) if edge_weights else 1
for u, v in self.network.edges():
weight = self.network[u][v]['weight']
completion = self.network[u][v]['completion_rate']
if u in pos_dict and v in pos_dict:
x_start, y_start = pos_dict[u]
x_end, y_end = pos_dict[v]
# Edge width based on pass frequency
width = (weight / max_weight) * 8
# Color based on completion rate
alpha = 0.3 + (completion * 0.5)
ax.annotate('',
xy=(x_end, y_end),
xytext=(x_start, y_start),
arrowprops=dict(
arrowstyle='->,head_width=0.4,head_length=0.8',
color='blue',
lw=width,
alpha=alpha,
connectionstyle="arc3,rad=0.1"
)
)
# Draw nodes (players)
if self.metrics is None:
self.calculate_network_metrics()
for _, row in self.player_positions.iterrows():
player = row['player']
# Node size based on PageRank
player_metrics = self.metrics[self.metrics['player'] == player]
if not player_metrics.empty:
pagerank = player_metrics.iloc[0]['pagerank']
node_size = 200 + (pagerank * 2000)
else:
node_size = 200
ax.scatter(row['x'], row['y'],
s=node_size,
c='red',
edgecolors='black',
linewidths=2,
zorder=10,
alpha=0.8)
# Player label
ax.text(row['x'], row['y'],
player.split()[-1] if ' ' in player else player,
fontsize=9,
fontweight='bold',
ha='center',
va='center',
zorder=11)
ax.set_title('Passing Network', fontsize=16, fontweight='bold', pad=20)
ax.axis('off')
# Add legend
legend_elements = [
mpatches.Patch(color='none', label='Node size: Player influence (PageRank)'),
mpatches.Patch(color='none', label='Edge width: Pass frequency'),
mpatches.Patch(color='none', label='Edge opacity: Completion rate')
]
ax.legend(handles=legend_elements, loc='upper right', fontsize=10)
plt.tight_layout()
return fig
def _draw_pitch(self, ax):
"""Draw soccer pitch markings."""
# Pitch outline
ax.plot([0, 0, 105, 105, 0], [0, 68, 68, 0, 0], color='white', linewidth=2)
# Halfway line
ax.plot([52.5, 52.5], [0, 68], color='white', linewidth=2)
# Center circle
circle = plt.Circle((52.5, 34), 9.15, color='white', fill=False, linewidth=2)
ax.add_patch(circle)
# Penalty areas
ax.plot([0, 16.5, 16.5, 0], [13.84, 13.84, 54.16, 54.16], color='white', linewidth=2)
ax.plot([105, 88.5, 88.5, 105], [13.84, 13.84, 54.16, 54.16], color='white', linewidth=2)
# Goal areas
ax.plot([0, 5.5, 5.5, 0], [24.84, 24.84, 43.16, 43.16], color='white', linewidth=2)
ax.plot([105, 99.5, 99.5, 105], [24.84, 24.84, 43.16, 43.16], color='white', linewidth=2)
ax.set_facecolor('#1e8449')
def identify_key_passers(self, top_n=5):
"""Identify most influential passers."""
if self.metrics is None:
self.calculate_network_metrics()
key_passers = self.metrics.nlargest(top_n, 'pagerank')[
['player', 'pagerank', 'degree_centrality', 'betweenness_centrality']
]
return key_passers
def analyze_pass_triangles(self):
"""Find common passing triangles (3-player combinations)."""
if self.network is None:
self.build_network()
# Find all triangles (3-cliques)
triangles = [clique for clique in nx.enumerate_all_cliques(self.network.to_undirected())
if len(clique) == 3]
# Calculate triangle strength
triangle_strength = []
for triangle in triangles:
# Sum of all pass weights in triangle
total_weight = 0
for i in range(3):
for j in range(i+1, 3):
if self.network.has_edge(triangle[i], triangle[j]):
total_weight += self.network[triangle[i]][triangle[j]]['weight']
if self.network.has_edge(triangle[j], triangle[i]):
total_weight += self.network[triangle[j]][triangle[i]]['weight']
triangle_strength.append({
'players': triangle,
'total_passes': total_weight
})
# Sort by strength
triangle_strength.sort(key=lambda x: x['total_passes'], reverse=True)
return triangle_strength[:10] # Top 10 triangles
# Example Usage
if __name__ == "__main__":
# Generate sample passing data
np.random.seed(42)
players = [f'Player {i}' for i in range(1, 12)]
passes = []
for _ in range(300):
passer = np.random.choice(players)
# Create realistic passing patterns
receiver = np.random.choice([p for p in players if p != passer])
passes.append({
'passer': passer,
'receiver': receiver,
'x_start': np.random.uniform(20, 80),
'y_start': np.random.uniform(10, 58),
'x_end': np.random.uniform(25, 85),
'y_end': np.random.uniform(12, 56),
'outcome': np.random.choice(['Complete', 'Incomplete'], p=[0.82, 0.18])
})
pass_data = pd.DataFrame(passes)
# Analyze network
analyzer = PassingNetworkAnalyzer(pass_data)
# Build and analyze
network = analyzer.build_network(min_passes=3)
print(f"Network has {network.number_of_nodes()} players and {network.number_of_edges()} connections\n")
# Calculate metrics
metrics = analyzer.calculate_network_metrics()
print("Network Centrality Metrics:")
print(metrics.head(5))
# Key passers
print("\nKey Passers:")
print(analyzer.identify_key_passers())
# Passing triangles
print("\nTop Passing Triangles:")
triangles = analyzer.analyze_pass_triangles()
for i, tri in enumerate(triangles[:5], 1):
print(f"{i}. {' - '.join(tri['players'])}: {tri['total_passes']} passes")
# Visualize
fig = analyzer.visualize_network(min_passes=5)
plt.savefig('passing_network.png', dpi=300, bbox_inches='tight', facecolor='#1e8449')
print("\nPassing network saved as 'passing_network.png'")
```
## R Implementation
```r
library(tidyverse)
library(igraph)
library(ggplot2)
library(ggraph)
# Passing Network Analyzer
PassingNetworkAnalyzer <- R6::R6Class("PassingNetworkAnalyzer",
public = list(
pass_data = NULL,
player_positions = NULL,
network = NULL,
metrics = NULL,
initialize = function(pass_data, player_positions = NULL) {
self$pass_data <- pass_data
self$player_positions <- player_positions
},
build_network = function(min_passes = 3) {
# Aggregate passes
pass_counts <- self$pass_data %>%
group_by(passer, receiver) %>%
summarise(
total_passes = n(),
completed_passes = sum(outcome == 'Complete'),
.groups = 'drop'
) %>%
mutate(completion_rate = completed_passes / total_passes) %>%
filter(total_passes >= min_passes)
# Create graph
edges <- pass_counts %>%
select(from = passer, to = receiver,
weight = total_passes, completion_rate)
G <- graph_from_data_frame(edges, directed = TRUE)
self$network <- G
return(G)
},
calculate_network_metrics = function() {
if (is.null(self$network)) {
self$build_network()
}
metrics <- tibble(
player = V(self$network)$name,
degree_centrality = degree(self$network, mode = 'all', normalized = TRUE),
betweenness_centrality = betweenness(self$network, normalized = TRUE),
closeness_centrality = closeness(self$network, mode = 'all', normalized = TRUE),
in_degree = degree(self$network, mode = 'in'),
out_degree = degree(self$network, mode = 'out'),
pagerank = page_rank(self$network)$vector
) %>%
arrange(desc(pagerank))
self$metrics <- metrics
return(metrics)
},
calculate_average_positions = function() {
if (!is.null(self$player_positions)) {
return(self$player_positions)
}
# From passer positions
passer_pos <- self$pass_data %>%
group_by(player = passer) %>%
summarise(x = mean(x_start), y = mean(y_start), .groups = 'drop')
# From receiver positions
receiver_pos <- self$pass_data %>%
group_by(player = receiver) %>%
summarise(x = mean(x_end), y = mean(y_end), .groups = 'drop')
# Average both
avg_positions <- bind_rows(passer_pos, receiver_pos) %>%
group_by(player) %>%
summarise(x = mean(x), y = mean(y), .groups = 'drop')
self$player_positions <- avg_positions
return(avg_positions)
},
visualize_network = function(min_passes = 5) {
if (is.null(self$network)) {
self$build_network(min_passes = min_passes)
}
if (is.null(self$metrics)) {
self$calculate_network_metrics()
}
if (is.null(self$player_positions)) {
self$calculate_average_positions()
}
# Prepare layout from positions
layout_matrix <- self$player_positions %>%
arrange(player) %>%
select(x, y) %>%
as.matrix()
# Scale to pitch dimensions
layout_matrix[, 1] <- layout_matrix[, 1] / max(layout_matrix[, 1]) * 105
layout_matrix[, 2] <- layout_matrix[, 2] / max(layout_matrix[, 2]) * 68
# Create edge data
edge_data <- as_data_frame(self$network, what = 'edges') %>%
left_join(self$player_positions, by = c('from' = 'player')) %>%
rename(x_start = x, y_start = y) %>%
left_join(self$player_positions, by = c('to' = 'player')) %>%
rename(x_end = x, y_end = y)
# Node data with metrics
node_data <- self$player_positions %>%
left_join(self$metrics, by = 'player') %>%
mutate(
node_size = 3 + (pagerank * 20),
label = word(player, -1)
)
# Create plot
p <- ggplot() +
# Pitch background
geom_rect(aes(xmin = 0, xmax = 105, ymin = 0, ymax = 68),
fill = '#1e8449', color = 'white', size = 1) +
# Edges (passes)
geom_segment(
data = edge_data,
aes(x = x_start, y = y_start, xend = x_end, yend = y_end,
size = weight, alpha = completion_rate),
color = 'blue',
arrow = arrow(length = unit(0.2, 'cm'), type = 'closed')
) +
# Nodes (players)
geom_point(
data = node_data,
aes(x = x, y = y, size = node_size),
color = 'red', fill = 'red', shape = 21, stroke = 2
) +
# Labels
geom_text(
data = node_data,
aes(x = x, y = y, label = label),
size = 3, fontface = 'bold', color = 'white'
) +
scale_size_continuous(range = c(0.5, 4), guide = 'none') +
scale_alpha_continuous(range = c(0.3, 0.8), guide = 'none') +
coord_fixed(ratio = 1) +
labs(title = 'Passing Network',
subtitle = 'Node size: Influence | Edge width: Pass frequency | Edge opacity: Completion rate') +
theme_void() +
theme(
plot.title = element_text(face = 'bold', size = 16, hjust = 0.5),
plot.subtitle = element_text(size = 10, hjust = 0.5),
plot.background = element_rect(fill = 'white')
)
return(p)
},
identify_key_passers = function(top_n = 5) {
if (is.null(self$metrics)) {
self$calculate_network_metrics()
}
key_passers <- self$metrics %>%
slice_max(pagerank, n = top_n) %>%
select(player, pagerank, degree_centrality, betweenness_centrality)
return(key_passers)
},
analyze_pass_triangles = function() {
if (is.null(self$network)) {
self$build_network()
}
# Find triangles (3-cliques)
undirected_net <- as.undirected(self$network, mode = 'collapse')
triangles <- cliques(undirected_net, min = 3, max = 3)
# Calculate strength
triangle_strength <- map_dfr(triangles, function(tri) {
players <- V(self$network)$name[tri]
# Sum weights
total_weight <- 0
for (i in 1:2) {
for (j in (i+1):3) {
edge_id1 <- get.edge.ids(self$network, c(players[i], players[j]))
edge_id2 <- get.edge.ids(self$network, c(players[j], players[i]))
if (edge_id1 > 0) total_weight <- total_weight + E(self$network)$weight[edge_id1]
if (edge_id2 > 0) total_weight <- total_weight + E(self$network)$weight[edge_id2]
}
}
tibble(
players = paste(players, collapse = ' - '),
total_passes = total_weight
)
}) %>%
arrange(desc(total_passes)) %>%
slice_head(n = 10)
return(triangle_strength)
}
)
)
# Example usage
set.seed(42)
# Generate sample data
players <- paste('Player', 1:11)
pass_data <- tibble(
passer = sample(players, 300, replace = TRUE),
receiver = sample(players, 300, replace = TRUE),
x_start = runif(300, 20, 80),
y_start = runif(300, 10, 58),
x_end = runif(300, 25, 85),
y_end = runif(300, 12, 56),
outcome = sample(c('Complete', 'Incomplete'), 300, replace = TRUE, prob = c(0.82, 0.18))
) %>%
filter(passer != receiver)
# Analyze
analyzer <- PassingNetworkAnalyzer$new(pass_data)
cat("Building network...\n")
network <- analyzer$build_network(min_passes = 3)
cat(sprintf("Network: %d players, %d connections\n\n",
vcount(network), ecount(network)))
cat("Network Metrics:\n")
metrics <- analyzer$calculate_network_metrics()
print(head(metrics, 5))
cat("\nKey Passers:\n")
print(analyzer$identify_key_passers())
cat("\nTop Passing Triangles:\n")
triangles <- analyzer$analyze_pass_triangles()
print(head(triangles, 5))
# Visualize
p <- analyzer$visualize_network(min_passes = 5)
ggsave('passing_network_r.png', p, width = 14, height = 10, dpi = 300)
cat("\nPassing network visualization saved\n")
```
## Interpretation Guidelines
1. **Central Players**: High PageRank indicates influential playmakers
2. **Bridge Players**: High betweenness connects different team areas
3. **Isolated Players**: Low degree may indicate tactical issues
4. **Strong Connections**: Thick edges show frequent partnerships
5. **Network Density**: More connections indicate fluid passing
## Applications
- **Tactical Analysis**: Identify team structure and playing patterns
- **Player Roles**: Understand individual contributions to build-up
- **Opposition Analysis**: Study opponent passing tendencies
- **Formation Validation**: Verify intended tactical setup
- **Partnership Identification**: Find effective player combinations
Discussion
Have questions or feedback? Join our community discussion on
Discord or
GitHub Discussions.
Table of Contents
Quick Actions