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
Glossary