Progressive Passes
Beginner
10 min read
1 views
Nov 27, 2025
# Progressive Passes
## Overview
Progressive passes measure forward ball movement toward the opponent's goal. These metrics identify players and teams who advance play effectively, breaking defensive lines and creating attacking opportunities.
## Definition Criteria
A pass is considered **progressive** if it meets one of these criteria:
1. **Distance-based**: Moves ball ≥10 meters toward goal (in own half) or ≥5 meters (in opponent half)
2. **Zone-based**: Advances ball into next attacking zone
3. **Line-breaking**: Bypasses opponent's defensive line
## Key Metrics
- **Progressive Passes**: Total forward passes meeting criteria
- **Progressive Distance**: Cumulative meters gained
- **Progression Rate**: % of passes that are progressive
- **Final Third Entries**: Passes into attacking zone
## Python Implementation
```python
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from matplotlib.patches import Rectangle, FancyArrowPatch
import matplotlib.patches as mpatches
class ProgressivePassAnalyzer:
"""Analyze progressive passes and forward ball progression."""
def __init__(self, pass_data, pitch_length=105, pitch_width=68):
"""
Initialize progressive pass analyzer.
Parameters:
-----------
pass_data : pd.DataFrame
Pass data with columns: player, team, x_start, y_start,
x_end, y_end, outcome (coordinates normalized 0-100)
pitch_length : float
Pitch length in meters
pitch_width : float
Pitch width in meters
"""
self.pass_data = pass_data.copy()
self.pitch_length = pitch_length
self.pitch_width = pitch_width
self._calculate_distances()
def _calculate_distances(self):
"""Calculate actual distances in meters."""
# Convert normalized coordinates to meters
self.pass_data['x_start_m'] = self.pass_data['x_start'] * self.pitch_length / 100
self.pass_data['y_start_m'] = self.pass_data['y_start'] * self.pitch_width / 100
self.pass_data['x_end_m'] = self.pass_data['x_end'] * self.pitch_length / 100
self.pass_data['y_end_m'] = self.pass_data['y_end'] * self.pitch_width / 100
# Calculate pass distance
self.pass_data['pass_distance'] = np.sqrt(
(self.pass_data['x_end_m'] - self.pass_data['x_start_m'])**2 +
(self.pass_data['y_end_m'] - self.pass_data['y_start_m'])**2
)
# Calculate forward progression (x-axis only)
self.pass_data['forward_distance'] = (
self.pass_data['x_end_m'] - self.pass_data['x_start_m']
)
def identify_progressive_passes(self, method='distance'):
"""
Identify progressive passes.
Parameters:
-----------
method : str
'distance', 'zone', or 'combined'
"""
if method == 'distance':
self._progressive_distance_based()
elif method == 'zone':
self._progressive_zone_based()
else: # combined
self._progressive_distance_based()
dist_prog = self.pass_data['is_progressive'].copy()
self._progressive_zone_based()
zone_prog = self.pass_data['is_progressive'].copy()
self.pass_data['is_progressive'] = dist_prog | zone_prog
return self.pass_data
def _progressive_distance_based(self):
"""Distance-based progressive pass identification."""
conditions = []
# Own half: ≥10m forward
own_half = self.pass_data['x_start_m'] < (self.pitch_length / 2)
own_half_prog = own_half & (self.pass_data['forward_distance'] >= 10)
# Opponent half: ≥5m forward
opp_half = self.pass_data['x_start_m'] >= (self.pitch_length / 2)
opp_half_prog = opp_half & (self.pass_data['forward_distance'] >= 5)
self.pass_data['is_progressive'] = own_half_prog | opp_half_prog
def _progressive_zone_based(self):
"""Zone-based progressive pass identification."""
# Define zones (thirds)
def get_zone(x):
if x < 35:
return 1 # Defensive third
elif x < 70:
return 2 # Middle third
else:
return 3 # Attacking third
self.pass_data['start_zone'] = self.pass_data['x_start'].apply(get_zone)
self.pass_data['end_zone'] = self.pass_data['x_end'].apply(get_zone)
# Progressive if advances at least one zone
self.pass_data['is_progressive'] = (
self.pass_data['end_zone'] > self.pass_data['start_zone']
)
def calculate_player_metrics(self):
"""Calculate progressive pass metrics by player."""
# Filter successful passes only
successful = self.pass_data[self.pass_data['outcome'] == 'Complete'].copy()
player_stats = successful.groupby('player').agg({
'is_progressive': ['sum', 'count'],
'forward_distance': 'sum',
'pass_distance': 'mean'
}).reset_index()
player_stats.columns = [
'player', 'progressive_passes', 'total_passes',
'total_progression_distance', 'avg_pass_distance'
]
# Calculate rates
player_stats['progression_rate'] = (
player_stats['progressive_passes'] / player_stats['total_passes'] * 100
).round(2)
player_stats['progressive_distance_per_pass'] = (
player_stats['total_progression_distance'] /
player_stats['progressive_passes']
).round(2)
# Count final third entries
final_third_entries = successful[
(successful['x_end'] >= 70) & (successful['x_start'] < 70)
].groupby('player').size().reset_index(name='final_third_entries')
player_stats = player_stats.merge(final_third_entries, on='player', how='left')
player_stats['final_third_entries'] = player_stats['final_third_entries'].fillna(0).astype(int)
return player_stats.sort_values('progressive_passes', ascending=False)
def calculate_team_metrics(self):
"""Calculate team-level progressive metrics."""
successful = self.pass_data[self.pass_data['outcome'] == 'Complete'].copy()
team_stats = successful.groupby('team').agg({
'is_progressive': ['sum', 'count'],
'forward_distance': ['sum', 'mean'],
'pass_distance': 'mean'
}).reset_index()
team_stats.columns = [
'team', 'progressive_passes', 'total_passes',
'total_progression', 'avg_progression', 'avg_pass_distance'
]
team_stats['progression_rate'] = (
team_stats['progressive_passes'] / team_stats['total_passes'] * 100
).round(2)
return team_stats
def visualize_progressive_passes(self, player=None, team=None, sample_size=50):
"""
Visualize progressive passes on pitch.
Parameters:
-----------
player : str, optional
Specific player to visualize
team : str, optional
Specific team to visualize
sample_size : int
Maximum passes to display
"""
# Filter data
plot_data = self.pass_data[
(self.pass_data['is_progressive'] == True) &
(self.pass_data['outcome'] == 'Complete')
].copy()
if player:
plot_data = plot_data[plot_data['player'] == player]
if team:
plot_data = plot_data[plot_data['team'] == team]
# Sample if too many
if len(plot_data) > sample_size:
plot_data = plot_data.sample(sample_size, random_state=42)
# Create figure
fig, ax = plt.subplots(figsize=(14, 10))
# Draw pitch
self._draw_pitch(ax)
# Draw progressive passes
for _, row in plot_data.iterrows():
# Color based on progression distance
color_intensity = min(row['forward_distance'] / 30, 1.0)
color = plt.cm.YlOrRd(color_intensity)
# Arrow width based on total pass distance
width = 0.15 + (row['pass_distance'] / 50) * 0.35
arrow = FancyArrowPatch(
(row['x_start'], row['y_start']),
(row['x_end'], row['y_end']),
arrowstyle='->,head_width=0.8,head_length=1.5',
color=color,
linewidth=width,
alpha=0.6,
zorder=5
)
ax.add_patch(arrow)
# Title
title = 'Progressive Passes'
if player:
title += f' - {player}'
elif team:
title += f' - {team}'
ax.set_title(title, fontsize=16, fontweight='bold', pad=20)
# Legend
legend_elements = [
mpatches.Patch(facecolor='yellow', edgecolor='black',
label='Short progression (< 10m)'),
mpatches.Patch(facecolor='orange', edgecolor='black',
label='Medium progression (10-20m)'),
mpatches.Patch(facecolor='red', edgecolor='black',
label='Long progression (> 20m)')
]
ax.legend(handles=legend_elements, loc='upper left', fontsize=10)
plt.tight_layout()
return fig
def _draw_pitch(self, ax):
"""Draw soccer pitch."""
# Pitch outline
pitch = Rectangle((0, 0), 100, 100, linewidth=2,
edgecolor='white', facecolor='#1e8449')
ax.add_patch(pitch)
# Halfway line
ax.plot([50, 50], [0, 100], color='white', linewidth=2)
# Thirds
ax.plot([33.33, 33.33], [0, 100], color='white',
linewidth=1, linestyle='--', alpha=0.5)
ax.plot([66.67, 66.67], [0, 100], color='white',
linewidth=1, linestyle='--', alpha=0.5)
# Center circle
circle = plt.Circle((50, 50), 8.7, color='white', fill=False, linewidth=2)
ax.add_patch(circle)
# Penalty boxes
penalty_left = Rectangle((0, 21.1), 15.7, 57.8, linewidth=2,
edgecolor='white', facecolor='none')
penalty_right = Rectangle((84.3, 21.1), 15.7, 57.8, linewidth=2,
edgecolor='white', facecolor='none')
ax.add_patch(penalty_left)
ax.add_patch(penalty_right)
ax.set_xlim(-2, 102)
ax.set_ylim(-2, 102)
ax.set_aspect('equal')
ax.axis('off')
def analyze_progression_patterns(self):
"""Analyze patterns in progressive passing."""
progressive = self.pass_data[self.pass_data['is_progressive'] == True].copy()
patterns = {
'total_progressive': len(progressive),
'avg_progression_distance': progressive['forward_distance'].mean().round(2),
'max_progression': progressive['forward_distance'].max().round(2),
'progression_by_zone': progressive.groupby('start_zone')['forward_distance'].agg([
'count', 'mean'
]).round(2).to_dict()
}
return patterns
# Example Usage
if __name__ == "__main__":
# Generate sample pass data
np.random.seed(42)
passes = []
players = [f'Player {i}' for i in range(1, 12)]
teams = ['Team A', 'Team B']
for _ in range(500):
team = np.random.choice(teams)
player = np.random.choice(players)
# Start position
x_start = np.random.uniform(10, 90)
y_start = np.random.uniform(10, 90)
# End position (with forward bias)
forward_movement = np.random.exponential(15)
lateral_movement = np.random.normal(0, 10)
x_end = min(x_start + forward_movement, 95)
y_end = np.clip(y_start + lateral_movement, 5, 95)
outcome = np.random.choice(['Complete', 'Incomplete'], p=[0.80, 0.20])
passes.append({
'player': player,
'team': team,
'x_start': x_start,
'y_start': y_start,
'x_end': x_end,
'y_end': y_end,
'outcome': outcome
})
pass_data = pd.DataFrame(passes)
# Analyze progressive passes
analyzer = ProgressivePassAnalyzer(pass_data)
analyzer.identify_progressive_passes(method='distance')
# Player metrics
print("Top Progressive Passers:")
player_metrics = analyzer.calculate_player_metrics()
print(player_metrics.head(5))
print()
# Team metrics
print("Team Progressive Metrics:")
team_metrics = analyzer.calculate_team_metrics()
print(team_metrics)
print()
# Patterns
print("Progression Patterns:")
patterns = analyzer.analyze_progression_patterns()
print(f"Total Progressive Passes: {patterns['total_progressive']}")
print(f"Average Progression: {patterns['avg_progression_distance']}m")
print(f"Maximum Progression: {patterns['max_progression']}m")
# Visualize
fig = analyzer.visualize_progressive_passes(team='Team A', sample_size=40)
plt.savefig('progressive_passes.png', dpi=300, bbox_inches='tight')
print("\nProgressive passes visualization saved")
```
## R Implementation
```r
library(tidyverse)
library(ggplot2)
# Progressive Pass Analyzer
ProgressivePassAnalyzer <- R6::R6Class("ProgressivePassAnalyzer",
public = list(
pass_data = NULL,
pitch_length = 105,
pitch_width = 68,
initialize = function(pass_data, pitch_length = 105, pitch_width = 68) {
self$pass_data <- pass_data
self$pitch_length <- pitch_length
self$pitch_width <- pitch_width
self$calculate_distances()
},
calculate_distances = function() {
self$pass_data <- self$pass_data %>%
mutate(
x_start_m = x_start * self$pitch_length / 100,
y_start_m = y_start * self$pitch_width / 100,
x_end_m = x_end * self$pitch_length / 100,
y_end_m = y_end * self$pitch_width / 100,
pass_distance = sqrt((x_end_m - x_start_m)^2 + (y_end_m - y_start_m)^2),
forward_distance = x_end_m - x_start_m
)
},
identify_progressive_passes = function(method = 'distance') {
if (method == 'distance') {
self$progressive_distance_based()
} else if (method == 'zone') {
self$progressive_zone_based()
} else {
# Combined
self$progressive_distance_based()
dist_prog <- self$pass_data$is_progressive
self$progressive_zone_based()
self$pass_data$is_progressive <- dist_prog | self$pass_data$is_progressive
}
return(self$pass_data)
},
progressive_distance_based = function() {
self$pass_data <- self$pass_data %>%
mutate(
is_progressive = case_when(
x_start_m < (self$pitch_length / 2) & forward_distance >= 10 ~ TRUE,
x_start_m >= (self$pitch_length / 2) & forward_distance >= 5 ~ TRUE,
TRUE ~ FALSE
)
)
},
progressive_zone_based = function() {
get_zone <- function(x) {
case_when(
x < 35 ~ 1,
x < 70 ~ 2,
TRUE ~ 3
)
}
self$pass_data <- self$pass_data %>%
mutate(
start_zone = get_zone(x_start),
end_zone = get_zone(x_end),
is_progressive = end_zone > start_zone
)
},
calculate_player_metrics = function() {
successful <- self$pass_data %>%
filter(outcome == 'Complete')
player_stats <- successful %>%
group_by(player) %>%
summarise(
progressive_passes = sum(is_progressive),
total_passes = n(),
total_progression_distance = sum(forward_distance[is_progressive]),
avg_pass_distance = mean(pass_distance),
final_third_entries = sum(x_end >= 70 & x_start < 70),
.groups = 'drop'
) %>%
mutate(
progression_rate = round(progressive_passes / total_passes * 100, 2),
progressive_distance_per_pass = round(
total_progression_distance / pmax(progressive_passes, 1), 2
)
) %>%
arrange(desc(progressive_passes))
return(player_stats)
},
calculate_team_metrics = function() {
successful <- self$pass_data %>%
filter(outcome == 'Complete')
team_stats <- successful %>%
group_by(team) %>%
summarise(
progressive_passes = sum(is_progressive),
total_passes = n(),
total_progression = sum(forward_distance[is_progressive]),
avg_progression = mean(forward_distance[is_progressive]),
avg_pass_distance = mean(pass_distance),
.groups = 'drop'
) %>%
mutate(
progression_rate = round(progressive_passes / total_passes * 100, 2)
)
return(team_stats)
},
visualize_progressive_passes = function(player = NULL, team = NULL, sample_size = 50) {
plot_data <- self$pass_data %>%
filter(is_progressive == TRUE, outcome == 'Complete')
if (!is.null(player)) {
plot_data <- plot_data %>% filter(player == !!player)
}
if (!is.null(team)) {
plot_data <- plot_data %>% filter(team == !!team)
}
if (nrow(plot_data) > sample_size) {
plot_data <- plot_data %>% slice_sample(n = sample_size)
}
# Create plot
p <- ggplot() +
# Pitch
geom_rect(aes(xmin = 0, xmax = 100, ymin = 0, ymax = 100),
fill = '#1e8449', color = 'white', size = 1) +
geom_vline(xintercept = 50, color = 'white', size = 1) +
geom_vline(xintercept = c(33.33, 66.67), color = 'white',
size = 0.5, linetype = 'dashed', alpha = 0.5) +
# Progressive passes
geom_segment(
data = plot_data,
aes(x = x_start, y = y_start, xend = x_end, yend = y_end,
color = forward_distance, size = pass_distance),
arrow = arrow(length = unit(0.2, 'cm'), type = 'closed'),
alpha = 0.6
) +
scale_color_gradient2(
low = 'yellow', mid = 'orange', high = 'red',
midpoint = 15, name = 'Forward\nProgression (m)'
) +
scale_size_continuous(range = c(0.3, 1.5), guide = 'none') +
coord_fixed(ratio = 1) +
labs(
title = if (!is.null(player)) paste('Progressive Passes -', player)
else if (!is.null(team)) paste('Progressive Passes -', team)
else 'Progressive Passes'
) +
theme_void() +
theme(
plot.title = element_text(face = 'bold', size = 16, hjust = 0.5),
legend.position = 'right'
)
return(p)
},
analyze_progression_patterns = function() {
progressive <- self$pass_data %>%
filter(is_progressive == TRUE)
patterns <- list(
total_progressive = nrow(progressive),
avg_progression_distance = round(mean(progressive$forward_distance), 2),
max_progression = round(max(progressive$forward_distance), 2),
by_zone = progressive %>%
group_by(start_zone) %>%
summarise(
count = n(),
avg_distance = round(mean(forward_distance), 2),
.groups = 'drop'
)
)
return(patterns)
}
)
)
# Example usage
set.seed(42)
# Generate sample data
pass_data <- tibble(
player = sample(paste('Player', 1:11), 500, replace = TRUE),
team = sample(c('Team A', 'Team B'), 500, replace = TRUE),
x_start = runif(500, 10, 90),
y_start = runif(500, 10, 90)
) %>%
mutate(
x_end = pmin(x_start + rexp(500, 1/15), 95),
y_end = pmin(pmax(y_start + rnorm(500, 0, 10), 5), 95),
outcome = sample(c('Complete', 'Incomplete'), 500, replace = TRUE, prob = c(0.80, 0.20))
)
# Analyze
analyzer <- ProgressivePassAnalyzer$new(pass_data)
analyzer$identify_progressive_passes(method = 'distance')
cat("Top Progressive Passers:\n")
player_metrics <- analyzer$calculate_player_metrics()
print(head(player_metrics, 5))
cat("\nTeam Progressive Metrics:\n")
team_metrics <- analyzer$calculate_team_metrics()
print(team_metrics)
cat("\nProgression Patterns:\n")
patterns <- analyzer$analyze_progression_patterns()
cat(sprintf("Total Progressive: %d\n", patterns$total_progressive))
cat(sprintf("Average Progression: %.2fm\n", patterns$avg_progression_distance))
cat(sprintf("Maximum Progression: %.2fm\n", patterns$max_progression))
# Visualize
p <- analyzer$visualize_progressive_passes(team = 'Team A', sample_size = 40)
ggsave('progressive_passes_r.png', p, width = 14, height = 10, dpi = 300)
cat("\nVisualization saved\n")
```
## Practical Applications
1. **Player Recruitment**: Identify progressive passers
2. **Tactical Analysis**: Assess build-up effectiveness
3. **Opposition Scouting**: Study opponent progression patterns
4. **Performance Evaluation**: Measure attacking contribution
5. **Training Focus**: Improve forward passing quality
## Best Practices
- Consider pass completion rate alongside volume
- Analyze progression by field zone
- Compare against positional benchmarks
- Account for tactical context
- Combine with chance creation metrics
Discussion
Have questions or feedback? Join our community discussion on
Discord or
GitHub Discussions.
Table of Contents
Related Topics
Quick Actions