Set Piece Analytics

Intermediate 10 min read 234 views Nov 25, 2025

Set Piece Analytics

Set piece analytics examines the strategic importance of dead-ball situations including corner kicks, free kicks, throw-ins, and penalty kicks. These restarts represent crucial moments where teams can create high-quality scoring opportunities through rehearsed routines and optimal positioning. Research indicates that 30-40% of goals in professional football come from set pieces, making them a vital component of tactical analysis. Modern analytics quantify set piece effectiveness through metrics measuring delivery quality, movement patterns, positioning, and conversion rates, enabling teams to maximize returns from these valuable opportunities.

Key Concepts

Set piece analysis encompasses multiple types of restarts, each with distinct tactical considerations and analytical approaches:

  • Corner Kick Analysis: Examining delivery types (inswing, outswing, short corners), target zones, attacking runs, and defensive organization during corner kicks.
  • Free Kick Effectiveness: Analyzing direct free kicks (shots on goal) and indirect free kicks (crossing opportunities), including optimal shooting zones and wall positioning.
  • Throw-In Efficiency: Evaluating throw-in retention rates, progression from throw-ins, and long-throw strategies for creating goal-scoring chances.
  • Set Piece xG: Expected goals specifically from set piece situations, crucial for measuring quality of chances created from dead balls.
  • Delivery Accuracy: Precision of balls delivered into target zones, measuring whether set pieces reach intended areas and teammates.
  • Movement and Spacing: Analyzing attacking runs, blocking actions, and spatial positioning to create separation from markers.
  • Defensive Set Piece Organization: Evaluating defensive structures (zonal, man-marking, hybrid) and their effectiveness at preventing goals.
  • Set Piece Routines: Cataloging and analyzing rehearsed patterns including dummy runs, near-post flicks, and back-post attacks.
  • Second Ball Recovery: Tracking which team wins possession after initial set piece delivery, indicating organizational effectiveness.

Mathematical Foundation

Set Piece Conversion Rate:

Conversion % = (Goals from Set Pieces / Total Set Pieces) × 100

Set Piece xG:

SP_xG = Σ xG(shot) for all shots originating from set piece situations

Set Piece Performance vs Expected:

SP Performance = Actual Goals from Set Pieces - Set Piece xG

Corner Kick Danger Index:

CDI = (Shots × 1.0) + (Shots on Target × 1.5) + (Goals × 5.0) / Total Corners

Delivery Success Rate:

Delivery % = (Deliveries reaching target zone / Total deliveries) × 100

Free Kick Threat Zone:

Threat Score = f(distance, angle) where optimal zone is 18-25 yards, central angle

Threat = (1 / (1 + e^(0.05 × (distance - 20)))) × angle_factor

Set Piece Goal Contribution:

SP Goal % = (Goals from Set Pieces / Total Goals) × 100

Aerial Duel Win Rate (Set Pieces):

Aerial Win % = (Aerial Duels Won / Total Aerial Duels) × 100

Zone Delivery Accuracy:

Zone Accuracy = Deliveries to Zone(i) / Total Deliveries

Where zones include near post, far post, penalty spot, edge of six-yard box

Python Implementation


import pandas as pd
import numpy as np
from statsbombpy import sb
from mplsoccer import Pitch, VerticalPitch
import matplotlib.pyplot as plt
import seaborn as sns

# Load event data
matches = sb.matches(competition_id=2, season_id=44)
events = sb.events(match_id=3788741)

# Extract set piece events
def extract_set_pieces(events_df):
    """Extract all set piece events from match data"""
    # Corner kicks
    corners = events_df[
        (events_df['type'] == 'Pass') &
        (events_df['pass_type'] == 'Corner')
    ].copy()

    # Free kicks
    free_kicks = events_df[
        (events_df['type'].isin(['Pass', 'Shot'])) &
        (events_df['play_pattern'] == 'From Free Kick')
    ].copy()

    # Throw-ins
    throw_ins = events_df[
        (events_df['type'] == 'Pass') &
        (events_df['pass_type'] == 'Throw-in')
    ].copy()

    return {
        'corners': corners,
        'free_kicks': free_kicks,
        'throw_ins': throw_ins
    }

# Analyze corner kick effectiveness
def analyze_corners(corners_df, events_df):
    """Comprehensive corner kick analysis"""
    total_corners = len(corners_df)

    # Track outcomes within 5 seconds of corner
    corner_outcomes = []

    for idx, corner in corners_df.iterrows():
        corner_time = corner['timestamp']
        corner_team = corner['team']

        # Find subsequent events within 5 seconds
        subsequent = events_df[
            (events_df['timestamp'] > corner_time) &
            (events_df['timestamp'] <= corner_time + pd.Timedelta(seconds=5))
        ]

        outcome = {
            'corner_id': idx,
            'team': corner_team,
            'delivery_complete': pd.isna(corner.get('pass_outcome')),
            'shot_created': len(subsequent[subsequent['type'] == 'Shot']) > 0,
            'goal_scored': len(subsequent[(subsequent['type'] == 'Shot') & (subsequent['shot_outcome'] == 'Goal')]) > 0,
            'clearance': len(subsequent[subsequent['type'] == 'Clearance']) > 0
        }

        # Target zone analysis
        if isinstance(corner.get('pass_end_location'), list):
            end_x, end_y = corner['pass_end_location'][0], corner['pass_end_location'][1]
            outcome['target_zone'] = classify_corner_zone(end_x, end_y)
        else:
            outcome['target_zone'] = 'unknown'

        corner_outcomes.append(outcome)

    results_df = pd.DataFrame(corner_outcomes)

    stats = {
        'total_corners': total_corners,
        'delivery_success_rate': (results_df['delivery_complete'].sum() / total_corners * 100) if total_corners > 0 else 0,
        'shot_conversion_rate': (results_df['shot_created'].sum() / total_corners * 100) if total_corners > 0 else 0,
        'goal_conversion_rate': (results_df['goal_scored'].sum() / total_corners * 100) if total_corners > 0 else 0,
        'clearance_rate': (results_df['clearance'].sum() / total_corners * 100) if total_corners > 0 else 0
    }

    return results_df, stats

def classify_corner_zone(x, y):
    """Classify corner delivery target zone"""
    # Penalty area zones
    if x >= 102 and x <= 120:
        if y >= 30 and y <= 36:  # Six-yard box
            return 'six_yard_near'
        elif y >= 44 and y <= 50:
            return 'six_yard_far'
        elif y >= 36 and y <= 44:
            return 'penalty_spot'
        elif y < 30 or y > 50:
            return 'edge_of_box'
    return 'outside_box'

# Free kick analysis
def analyze_free_kicks(free_kicks_df):
    """Analyze free kick effectiveness by location and type"""
    # Separate direct shots vs. crosses
    direct_shots = free_kicks_df[free_kicks_df['type'] == 'Shot'].copy()
    indirect_fks = free_kicks_df[free_kicks_df['type'] == 'Pass'].copy()

    # Analyze direct free kicks
    if len(direct_shots) > 0:
        direct_stats = {
            'total_shots': len(direct_shots),
            'on_target': len(direct_shots[direct_shots['shot_outcome'].isin(['Goal', 'Saved'])]),
            'goals': len(direct_shots[direct_shots['shot_outcome'] == 'Goal']),
            'avg_distance': direct_shots.apply(
                lambda x: np.sqrt((120 - x['location'][0])**2 + (40 - x['location'][1])**2) if isinstance(x['location'], list) else 0,
                axis=1
            ).mean()
        }
    else:
        direct_stats = {'total_shots': 0}

    # Analyze indirect free kicks
    if len(indirect_fks) > 0:
        indirect_stats = {
            'total_crosses': len(indirect_fks),
            'completion_rate': (indirect_fks['pass_outcome'].isna().sum() / len(indirect_fks) * 100) if len(indirect_fks) > 0 else 0
        }
    else:
        indirect_stats = {'total_crosses': 0}

    return {'direct': direct_stats, 'indirect': indirect_stats}

# Throw-in retention analysis
def analyze_throw_ins(throw_ins_df):
    """Analyze throw-in retention and effectiveness"""
    total_throws = len(throw_ins_df)

    if total_throws == 0:
        return {}

    retention_stats = {
        'total_throw_ins': total_throws,
        'retention_rate': (throw_ins_df['pass_outcome'].isna().sum() / total_throws * 100),
        'forward_throws': len(throw_ins_df[
            throw_ins_df.apply(
                lambda x: x.get('pass_end_location', [0, 0])[0] > x.get('location', [0, 0])[0] if isinstance(x.get('location'), list) and isinstance(x.get('pass_end_location'), list) else False,
                axis=1
            )
        ]),
        'backward_throws': len(throw_ins_df[
            throw_ins_df.apply(
                lambda x: x.get('pass_end_location', [0, 0])[0] < x.get('location', [0, 0])[0] if isinstance(x.get('location'), list) and isinstance(x.get('pass_end_location'), list) else False,
                axis=1
            )
        ])
    }

    return retention_stats

# Visualize corner kick target zones
def plot_corner_targets(corners_df):
    """Visualize where corners are delivered"""
    pitch = Pitch(pitch_type='statsbomb', pitch_color='#22312b', line_color='white',
                  half=True, pad_top=10)
    fig, ax = pitch.draw(figsize=(12, 8))

    # Filter corners with end locations
    corners_with_location = corners_df[
        corners_df['pass_end_location'].apply(lambda x: isinstance(x, list))
    ]

    if len(corners_with_location) > 0:
        x_coords = corners_with_location['pass_end_location'].apply(lambda x: x[0])
        y_coords = corners_with_location['pass_end_location'].apply(lambda x: x[1])

        # Successful vs unsuccessful
        successful = corners_with_location['pass_outcome'].isna()

        pitch.scatter(x_coords[successful], y_coords[successful],
                     ax=ax, s=100, c='#00ff00', marker='o',
                     edgecolors='white', linewidths=2, alpha=0.7,
                     label='Completed')

        pitch.scatter(x_coords[~successful], y_coords[~successful],
                     ax=ax, s=100, c='#ff0000', marker='x',
                     linewidths=2, alpha=0.7,
                     label='Incomplete')

    plt.legend(loc='upper left')
    plt.title('Corner Kick Target Zones', fontsize=16, color='white', pad=20)
    plt.tight_layout()
    return fig

# Set piece xG calculation
def calculate_set_piece_xg(events_df):
    """Calculate expected goals from set piece situations"""
    set_piece_shots = events_df[
        (events_df['type'] == 'Shot') &
        (events_df['play_pattern'].isin(['From Corner', 'From Free Kick', 'From Throw In']))
    ].copy()

    # Group by play pattern
    xg_by_type = set_piece_shots.groupby('play_pattern').agg({
        'shot_statsbomb_xg': ['sum', 'mean', 'count'],
        'shot_outcome': lambda x: (x == 'Goal').sum()
    })

    xg_by_type.columns = ['total_xg', 'avg_xg', 'shots', 'goals']
    xg_by_type['performance_vs_xg'] = xg_by_type['goals'] - xg_by_type['total_xg']

    return xg_by_type

# Team set piece comparison
def compare_team_set_pieces(events_df, team1, team2):
    """Compare set piece effectiveness between two teams"""
    comparison = {}

    for team in [team1, team2]:
        team_events = events_df[events_df['team'] == team]

        set_pieces = extract_set_pieces(team_events)

        # Corner analysis
        corner_results, corner_stats = analyze_corners(set_pieces['corners'], events_df)

        # Free kick analysis
        fk_stats = analyze_free_kicks(set_pieces['free_kicks'])

        # Throw-in analysis
        ti_stats = analyze_throw_ins(set_pieces['throw_ins'])

        comparison[team] = {
            'corners': corner_stats,
            'free_kicks': fk_stats,
            'throw_ins': ti_stats
        }

    return comparison

# Defensive set piece analysis
def analyze_defensive_set_pieces(events_df, team_name):
    """Analyze team's defensive performance on set pieces"""
    # Set pieces against the team
    opponent_corners = events_df[
        (events_df['type'] == 'Pass') &
        (events_df['pass_type'] == 'Corner') &
        (events_df['team'] != team_name)
    ]

    # Goals conceded from set pieces
    goals_conceded = events_df[
        (events_df['type'] == 'Shot') &
        (events_df['shot_outcome'] == 'Goal') &
        (events_df['play_pattern'].isin(['From Corner', 'From Free Kick'])) &
        (events_df['team'] != team_name)
    ]

    defensive_stats = {
        'corners_against': len(opponent_corners),
        'goals_conceded_set_pieces': len(goals_conceded),
        'goals_per_corner_against': (len(goals_conceded) / len(opponent_corners)) if len(opponent_corners) > 0 else 0
    }

    return defensive_stats

# Example execution
set_pieces = extract_set_pieces(events)

print("Corner Kick Analysis:")
corner_results, corner_stats = analyze_corners(set_pieces['corners'], events)
print(corner_stats)

print("
Free Kick Analysis:")
fk_stats = analyze_free_kicks(set_pieces['free_kicks'])
print(fk_stats)

print("
Throw-In Analysis:")
ti_stats = analyze_throw_ins(set_pieces['throw_ins'])
print(ti_stats)

print("
Set Piece xG:")
sp_xg = calculate_set_piece_xg(events)
print(sp_xg)

# Visualization
corner_plot = plot_corner_targets(set_pieces['corners'])
plt.show()

R Implementation


library(tidyverse)
library(StatsBombR)
library(ggsoccer)

# Load match data
competitions <- FreeCompetitions()
matches <- FreeMatches(competitions %>% filter(competition_name == "Premier League"))
events <- get.matchFree(matches$match_id[1])

# Extract set piece events
extract_set_pieces <- function(events_data) {
  list(
    corners = events_data %>%
      filter(type.name == "Pass" & pass.type.name == "Corner"),

    free_kicks = events_data %>%
      filter(play_pattern.name == "From Free Kick"),

    throw_ins = events_data %>%
      filter(type.name == "Pass" & pass.type.name == "Throw-in")
  )
}

# Analyze corner kick effectiveness
analyze_corners <- function(corners_data, all_events) {
  if(nrow(corners_data) == 0) return(list())

  # For each corner, track what happens next
  corner_outcomes <- corners_data %>%
    rowwise() %>%
    mutate(
      delivery_complete = is.na(pass.outcome.name),
      target_zone = classify_corner_zone(
        pass.end_location.x,
        pass.end_location.y
      )
    ) %>%
    ungroup()

  # Summary statistics
  stats <- corner_outcomes %>%
    summarise(
      total_corners = n(),
      delivery_success_rate = mean(delivery_complete, na.rm = TRUE) * 100,
      near_post = sum(target_zone == "near_post", na.rm = TRUE),
      far_post = sum(target_zone == "far_post", na.rm = TRUE),
      penalty_spot = sum(target_zone == "penalty_spot", na.rm = TRUE)
    )

  list(outcomes = corner_outcomes, stats = stats)
}

# Classify corner delivery zones
classify_corner_zone <- function(x, y) {
  if(is.na(x) || is.na(y)) return("unknown")

  if(x >= 102 && x <= 120) {
    if(y >= 30 && y <= 36) return("six_yard_near")
    if(y >= 44 && y <= 50) return("six_yard_far")
    if(y >= 36 && y <= 44) return("penalty_spot")
    if(y < 30 || y > 50) return("edge_of_box")
  }
  return("outside_box")
}

# Free kick analysis
analyze_free_kicks <- function(free_kicks_data) {
  # Direct shots
  direct_shots <- free_kicks_data %>%
    filter(type.name == "Shot")

  # Indirect (crosses)
  indirect <- free_kicks_data %>%
    filter(type.name == "Pass")

  list(
    direct = direct_shots %>%
      summarise(
        total_shots = n(),
        on_target = sum(shot.outcome.name %in% c("Goal", "Saved"), na.rm = TRUE),
        goals = sum(shot.outcome.name == "Goal", na.rm = TRUE),
        avg_distance = mean(sqrt((120 - location.x)^2 + (40 - location.y)^2), na.rm = TRUE)
      ),

    indirect = indirect %>%
      summarise(
        total_crosses = n(),
        completion_rate = mean(is.na(pass.outcome.name), na.rm = TRUE) * 100
      )
  )
}

# Throw-in retention analysis
analyze_throw_ins <- function(throw_ins_data) {
  throw_ins_data %>%
    summarise(
      total_throw_ins = n(),
      retention_rate = mean(is.na(pass.outcome.name), na.rm = TRUE) * 100,
      forward_throws = sum(pass.end_location.x > location.x, na.rm = TRUE),
      backward_throws = sum(pass.end_location.x < location.x, na.rm = TRUE)
    )
}

# Visualize corner kick targets
plot_corner_targets <- function(corners_data) {
  corners_with_location <- corners_data %>%
    filter(!is.na(pass.end_location.x) & !is.na(pass.end_location.y))

  ggplot(corners_with_location) +
    annotate_pitch(dimensions = pitch_statsbomb) +
    theme_pitch() +
    coord_flip(xlim = c(80, 120), ylim = c(0, 80)) +
    geom_point(
      aes(x = pass.end_location.x, y = pass.end_location.y,
          color = is.na(pass.outcome.name),
          shape = is.na(pass.outcome.name)),
      size = 4,
      alpha = 0.7
    ) +
    scale_color_manual(
      values = c("TRUE" = "#00ff00", "FALSE" = "#ff0000"),
      labels = c("Completed", "Incomplete"),
      name = "Outcome"
    ) +
    scale_shape_manual(
      values = c("TRUE" = 16, "FALSE" = 4),
      labels = c("Completed", "Incomplete"),
      name = "Outcome"
    ) +
    labs(
      title = "Corner Kick Target Zones",
      subtitle = "Delivery end positions"
    ) +
    theme(
      plot.title = element_text(hjust = 0.5, size = 16, face = "bold"),
      plot.subtitle = element_text(hjust = 0.5)
    )
}

# Calculate set piece xG
calculate_set_piece_xg <- function(events_data) {
  events_data %>%
    filter(
      type.name == "Shot",
      play_pattern.name %in% c("From Corner", "From Free Kick", "From Throw In")
    ) %>%
    group_by(play_pattern.name) %>%
    summarise(
      shots = n(),
      total_xg = sum(shot.statsbomb_xg, na.rm = TRUE),
      avg_xg = mean(shot.statsbomb_xg, na.rm = TRUE),
      goals = sum(shot.outcome.name == "Goal", na.rm = TRUE),
      performance_vs_xg = goals - total_xg,
      .groups = "drop"
    )
}

# Team set piece comparison
compare_team_set_pieces <- function(events_data, team1, team2) {
  results <- list()

  for(team in c(team1, team2)) {
    team_events <- events_data %>% filter(team.name == team)

    sp <- extract_set_pieces(team_events)

    results[[team]] <- list(
      corners = analyze_corners(sp$corners, events_data)$stats,
      free_kicks = analyze_free_kicks(sp$free_kicks),
      throw_ins = analyze_throw_ins(sp$throw_ins)
    )
  }

  results
}

# Defensive set piece analysis
analyze_defensive_set_pieces <- function(events_data, team_name) {
  # Corners against
  opponent_corners <- events_data %>%
    filter(
      type.name == "Pass",
      pass.type.name == "Corner",
      team.name != team_name
    )

  # Goals conceded from set pieces
  goals_conceded <- events_data %>%
    filter(
      type.name == "Shot",
      shot.outcome.name == "Goal",
      play_pattern.name %in% c("From Corner", "From Free Kick"),
      team.name != team_name
    )

  list(
    corners_against = nrow(opponent_corners),
    goals_conceded_set_pieces = nrow(goals_conceded),
    goals_per_corner = nrow(goals_conceded) / nrow(opponent_corners)
  )
}

# Set piece routine identification
identify_set_piece_routines <- function(corners_data, events_data) {
  # Analyze first touch after corner
  routines <- corners_data %>%
    mutate(
      routine_type = case_when(
        pass.height.name == "Ground Pass" ~ "short_corner",
        pass.end_location.x < 108 ~ "near_post",
        pass.end_location.x >= 108 && pass.end_location.x < 114 ~ "penalty_spot",
        pass.end_location.x >= 114 ~ "far_post",
        TRUE ~ "other"
      )
    ) %>%
    group_by(team.name, routine_type) %>%
    summarise(
      frequency = n(),
      success_rate = mean(is.na(pass.outcome.name)) * 100,
      .groups = "drop"
    ) %>%
    arrange(team.name, desc(frequency))

  return(routines)
}

# Execute analysis
set_pieces <- extract_set_pieces(events)

cat("Corner Kick Analysis:
")
corner_analysis <- analyze_corners(set_pieces$corners, events)
print(corner_analysis$stats)

cat("
Free Kick Analysis:
")
fk_analysis <- analyze_free_kicks(set_pieces$free_kicks)
print(fk_analysis)

cat("
Throw-In Analysis:
")
ti_analysis <- analyze_throw_ins(set_pieces$throw_ins)
print(ti_analysis)

cat("
Set Piece xG:
")
sp_xg <- calculate_set_piece_xg(events)
print(sp_xg)

# Visualizations
corner_plot <- plot_corner_targets(set_pieces$corners)
print(corner_plot)

# Routine analysis
routines <- identify_set_piece_routines(set_pieces$corners, events)
print(routines)

Practical Applications

Attacking Set Piece Optimization: Teams use set piece analytics to design and refine rehearsed routines. By analyzing which delivery zones and movement patterns generate the highest xG, coaches can create training drills that maximize goal-scoring probability. For example, data might reveal that near-post flick-ons create better chances than direct far-post deliveries, leading to tactical adjustments.

Defensive Organization: Set piece analytics inform defensive structure decisions. Teams analyze whether zonal marking, man-marking, or hybrid systems are most effective against specific opponents. Tracking where goals are conceded from set pieces helps identify defensive weaknesses that need addressing in training.

Opposition Scouting: Detailed analysis of opponent set piece tendencies allows teams to prepare specific defensive schemes. If data shows a team consistently delivers corners to the near post, defenders can anticipate and position accordingly. Similarly, identifying key set piece takers and their preferred delivery types enables targeted defensive planning.

Player Recruitment: Set piece specialists are valuable assets. Analytics identify players with strong delivery accuracy, aerial dominance in set piece situations, and ability to score from dead balls. Metrics like set piece xA (expected assists) and aerial duel success rates help evaluate potential signings.

In-Game Decision Making: Live tracking of set piece effectiveness helps coaches make real-time adjustments. If a particular routine repeatedly fails, teams can switch to alternative approaches. Similarly, if an opponent struggles defending certain delivery types, teams can exploit those weaknesses.

Key Takeaways

  • Set pieces account for 30-40% of goals in professional football, making them critical for team success
  • Corner kick effectiveness should be measured beyond just goals, including shot creation and xG generated
  • Delivery accuracy to target zones is often more important than delivery completion, as dangerous areas may have lower completion rates
  • Low and driven corners typically generate higher quality chances than lofted deliveries
  • Near-post routines often create more dangerous situations than direct far-post deliveries due to deflection opportunities
  • Short corners can be effective for retaining possession but typically generate lower xG than deliveries into the box
  • Free kick effectiveness varies dramatically by distance and angle, with optimal zones between 18-25 yards
  • Throw-in retention is crucial in the attacking third, where loss of possession can lead to dangerous counter-attacks
  • Defensive set piece organization should be evaluated through goals conceded relative to xG against from set pieces
  • Second ball recovery after set pieces is a key indicator of overall team organization and spacing

Discussion

Have questions or feedback? Join our community discussion on Discord or GitHub Discussions.