Field Tilt

Beginner 10 min read 1 views Nov 27, 2025
# Field Tilt ## Overview Field tilt measures territorial dominance by analyzing the location of actions, possession, and player positions. This metric reveals which team controls specific areas of the pitch and maintains offensive or defensive pressure. ## Measurement Approaches ### Action-Based Tilt Percentage of actions (passes, shots, touches) occurring in each half. ### Position-Based Tilt Average field position of ball and players over time. ### Time-Based Tilt Duration spent in attacking vs defensive zones. ### Expected Goals Tilt Balance of xG opportunities created in each half. ## Python Implementation ```python import pandas as pd import numpy as np import matplotlib.pyplot as plt import seaborn as sns from scipy.ndimage import gaussian_filter import matplotlib.patches as mpatches from matplotlib.patches import Rectangle class FieldTiltAnalyzer: """Analyze field tilt and territorial dominance.""" def __init__(self, event_data, pitch_length=105): """ Initialize field tilt analyzer. Parameters: ----------- event_data : pd.DataFrame Event data with columns: team, event_type, x, y, timestamp, minute pitch_length : float Pitch length for calculations """ self.event_data = event_data.copy() self.pitch_length = pitch_length self.tilt_metrics = {} def calculate_action_based_tilt(self, team): """ Calculate field tilt based on action location. Parameters: ----------- team : str Team to analyze (assumes attacking right to left) """ team_events = self.event_data[self.event_data['team'] == team].copy() # Count actions in each half own_half = len(team_events[team_events['x'] < 50]) opponent_half = len(team_events[team_events['x'] >= 50]) total = own_half + opponent_half if total > 0: tilt_pct = (opponent_half / total) * 100 else: tilt_pct = 50.0 return { 'team': team, 'tilt_percentage': round(tilt_pct, 2), 'actions_own_half': own_half, 'actions_opponent_half': opponent_half, 'total_actions': total } def calculate_average_field_position(self, team): """ Calculate average field position (territorial control). Parameters: ----------- team : str Team to analyze """ team_events = self.event_data[self.event_data['team'] == team] # Weight different event types weights = { 'Pass': 1.0, 'Carry': 1.5, 'Shot': 2.0, 'Tackle': 0.5, 'Interception': 0.5 } weighted_x = 0 total_weight = 0 for _, event in team_events.iterrows(): weight = weights.get(event['event_type'], 1.0) weighted_x += event['x'] * weight total_weight += weight avg_position = weighted_x / total_weight if total_weight > 0 else 50 return { 'team': team, 'average_field_position': round(avg_position, 2), 'territorial_advantage': round(avg_position - 50, 2) # Positive = more attacking } def calculate_temporal_tilt(self, team, time_bins=10): """ Calculate field tilt over time periods. Parameters: ----------- team : str Team to analyze time_bins : int Number of time periods to analyze """ team_events = self.event_data[self.event_data['team'] == team].copy() # Add time bins max_minute = team_events['minute'].max() team_events['time_bin'] = pd.cut( team_events['minute'], bins=time_bins, labels=range(1, time_bins + 1) ) # Calculate tilt per bin tilt_by_time = [] for time_bin in range(1, time_bins + 1): bin_events = team_events[team_events['time_bin'] == time_bin] if len(bin_events) > 0: opponent_half_pct = ( len(bin_events[bin_events['x'] >= 50]) / len(bin_events) * 100 ) else: opponent_half_pct = 50.0 tilt_by_time.append({ 'time_period': time_bin, 'tilt_percentage': round(opponent_half_pct, 2) }) return pd.DataFrame(tilt_by_time) def calculate_zone_dominance(self, team): """ Calculate dominance by pitch zones. Parameters: ----------- team : str Team to analyze """ team_events = self.event_data[self.event_data['team'] == team].copy() opponent = self.event_data[self.event_data['team'] != team]['team'].iloc[0] opponent_events = self.event_data[self.event_data['team'] == opponent].copy() # Define zones zones = { 'Defensive Third': (0, 33), 'Middle Third': (33, 67), 'Attacking Third': (67, 100) } zone_dominance = [] for zone_name, (x_min, x_max) in zones.items(): team_actions = len(team_events[ (team_events['x'] >= x_min) & (team_events['x'] < x_max) ]) opponent_actions = len(opponent_events[ (opponent_events['x'] >= x_min) & (opponent_events['x'] < x_max) ]) total_actions = team_actions + opponent_actions dominance_pct = ( (team_actions / total_actions * 100) if total_actions > 0 else 50.0 ) zone_dominance.append({ 'zone': zone_name, 'team_actions': team_actions, 'opponent_actions': opponent_actions, 'dominance_percentage': round(dominance_pct, 2) }) return pd.DataFrame(zone_dominance) def visualize_field_tilt(self, team, figsize=(14, 10)): """ Visualize field tilt with heatmap and metrics. Parameters: ----------- team : str Team to visualize figsize : tuple Figure size """ fig = plt.figure(figsize=figsize) gs = fig.add_gridspec(3, 2, height_ratios=[2, 1, 1], hspace=0.3, wspace=0.3) # 1. Heatmap ax1 = fig.add_subplot(gs[0, :]) self._plot_territorial_heatmap(ax1, team) # 2. Tilt over time ax2 = fig.add_subplot(gs[1, 0]) self._plot_tilt_timeline(ax2, team) # 3. Zone dominance ax3 = fig.add_subplot(gs[1, 1]) self._plot_zone_dominance(ax3, team) # 4. Summary metrics ax4 = fig.add_subplot(gs[2, :]) self._plot_summary_metrics(ax4, team) fig.suptitle(f'Field Tilt Analysis - {team}', fontsize=18, fontweight='bold', y=0.98) return fig def _plot_territorial_heatmap(self, ax, team): """Plot territorial control heatmap.""" team_events = self.event_data[self.event_data['team'] == team] # Draw pitch pitch = Rectangle((0, 0), 100, 100, linewidth=2, edgecolor='white', facecolor='#1e8449') ax.add_patch(pitch) ax.plot([50, 50], [0, 100], color='white', linewidth=2) # Create 2D histogram if len(team_events) > 0: heatmap, xedges, yedges = np.histogram2d( team_events['x'], team_events['y'], bins=(25, 20), range=[[0, 100], [0, 100]] ) # Smooth heatmap heatmap_smooth = gaussian_filter(heatmap, sigma=1.5) # Plot extent = [xedges[0], xedges[-1], yedges[0], yedges[-1]] im = ax.imshow( heatmap_smooth.T, extent=extent, origin='lower', cmap='RdYlGn', alpha=0.6, aspect='auto' ) plt.colorbar(im, ax=ax, fraction=0.046, pad=0.04, label='Action Density') ax.set_title('Territorial Control Heatmap', fontsize=12, fontweight='bold') ax.set_xlim(-2, 102) ax.set_ylim(-2, 102) ax.set_aspect('equal') ax.axis('off') def _plot_tilt_timeline(self, ax, team): """Plot field tilt over time.""" temporal_tilt = self.calculate_temporal_tilt(team, time_bins=10) ax.plot(temporal_tilt['time_period'], temporal_tilt['tilt_percentage'], marker='o', linewidth=2, markersize=8, color='#1f77b4') ax.axhline(y=50, color='red', linestyle='--', alpha=0.5, label='Neutral') ax.fill_between(temporal_tilt['time_period'], temporal_tilt['tilt_percentage'], 50, alpha=0.3, color='#1f77b4') ax.set_xlabel('Match Period', fontweight='bold') ax.set_ylabel('Tilt % (Opponent Half)', fontweight='bold') ax.set_title('Field Tilt Over Time', fontweight='bold') ax.grid(True, alpha=0.3) ax.legend() ax.set_ylim(0, 100) def _plot_zone_dominance(self, ax, team): """Plot zone dominance bar chart.""" zone_dom = self.calculate_zone_dominance(team) colors = ['#d62728' if x < 50 else '#2ca02c' for x in zone_dom['dominance_percentage']] bars = ax.barh(zone_dom['zone'], zone_dom['dominance_percentage'], color=colors, alpha=0.7, edgecolor='black') ax.axvline(x=50, color='black', linestyle='--', linewidth=2) # Add percentage labels for i, (bar, pct) in enumerate(zip(bars, zone_dom['dominance_percentage'])): ax.text(pct + 2, i, f'{pct}%', va='center', fontweight='bold') ax.set_xlabel('Dominance %', fontweight='bold') ax.set_title('Zone Dominance', fontweight='bold') ax.set_xlim(0, 100) ax.grid(True, alpha=0.3, axis='x') def _plot_summary_metrics(self, ax, team): """Plot summary metrics table.""" ax.axis('off') # Calculate metrics action_tilt = self.calculate_action_based_tilt(team) avg_position = self.calculate_average_field_position(team) # Create summary text summary = f""" FIELD TILT SUMMARY Overall Tilt: {action_tilt['tilt_percentage']}% (Opponent Half) Average Field Position: {avg_position['average_field_position']} Territorial Advantage: {avg_position['territorial_advantage']:+.2f} Actions in Opponent Half: {action_tilt['actions_opponent_half']} Actions in Own Half: {action_tilt['actions_own_half']} Total Actions: {action_tilt['total_actions']} """ ax.text(0.5, 0.5, summary, transform=ax.transAxes, fontsize=11, verticalalignment='center', horizontalalignment='center', bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.5), family='monospace') def generate_tilt_report(self, team): """Generate comprehensive field tilt report.""" report = { 'action_tilt': self.calculate_action_based_tilt(team), 'average_position': self.calculate_average_field_position(team), 'temporal_tilt': self.calculate_temporal_tilt(team), 'zone_dominance': self.calculate_zone_dominance(team) } self.tilt_metrics[team] = report return report # Example Usage if __name__ == "__main__": # Generate sample event data np.random.seed(42) events = [] teams = ['Team A', 'Team B'] for minute in range(90): for _ in range(np.random.randint(8, 15)): # Team A has more attacking presence team = np.random.choice(teams, p=[0.55, 0.45]) event_type = np.random.choice( ['Pass', 'Carry', 'Shot', 'Tackle', 'Interception'], p=[0.6, 0.2, 0.08, 0.07, 0.05] ) # Team A more likely to be in opponent half if team == 'Team A': x = np.random.beta(3, 2) * 100 # Skewed toward opponent half else: x = np.random.beta(2, 3) * 100 # Skewed toward own half y = np.random.uniform(10, 90) events.append({ 'team': team, 'event_type': event_type, 'x': x, 'y': y, 'minute': minute, 'timestamp': minute * 60 + np.random.uniform(0, 60) }) event_data = pd.DataFrame(events) # Analyze field tilt analyzer = FieldTiltAnalyzer(event_data) print("FIELD TILT ANALYSIS - Team A\n") report = analyzer.generate_tilt_report('Team A') print("Action-Based Tilt:") print(f" Tilt Percentage: {report['action_tilt']['tilt_percentage']}%") print(f" Opponent Half Actions: {report['action_tilt']['actions_opponent_half']}") print(f" Own Half Actions: {report['action_tilt']['actions_own_half']}") print("\nAverage Field Position:") print(f" Position: {report['average_position']['average_field_position']}") print(f" Territorial Advantage: {report['average_position']['territorial_advantage']:+.2f}") print("\nZone Dominance:") print(report['zone_dominance']) # Visualize fig = analyzer.visualize_field_tilt('Team A') plt.savefig('field_tilt_analysis.png', dpi=300, bbox_inches='tight') print("\nField tilt visualization saved as 'field_tilt_analysis.png'") ``` ## R Implementation ```r library(tidyverse) library(ggplot2) library(patchwork) # Field Tilt Analyzer FieldTiltAnalyzer <- R6::R6Class("FieldTiltAnalyzer", public = list( event_data = NULL, tilt_metrics = list(), initialize = function(event_data) { self$event_data <- event_data }, calculate_action_based_tilt = function(team) { team_events <- self$event_data %>% filter(team == !!team) own_half <- sum(team_events$x < 50) opponent_half <- sum(team_events$x >= 50) total <- own_half + opponent_half tilt_pct <- if (total > 0) (opponent_half / total) * 100 else 50.0 list( team = team, tilt_percentage = round(tilt_pct, 2), actions_own_half = own_half, actions_opponent_half = opponent_half, total_actions = total ) }, calculate_average_field_position = function(team) { team_events <- self$event_data %>% filter(team == !!team) weights <- c( 'Pass' = 1.0, 'Carry' = 1.5, 'Shot' = 2.0, 'Tackle' = 0.5, 'Interception' = 0.5 ) weighted_data <- team_events %>% mutate(weight = weights[event_type]) %>% replace_na(list(weight = 1.0)) avg_position <- weighted.mean(weighted_data$x, weighted_data$weight) list( team = team, average_field_position = round(avg_position, 2), territorial_advantage = round(avg_position - 50, 2) ) }, calculate_temporal_tilt = function(team, time_bins = 10) { team_events <- self$event_data %>% filter(team == !!team) max_minute <- max(team_events$minute) team_events <- team_events %>% mutate( time_bin = cut(minute, breaks = time_bins, labels = 1:time_bins) ) tilt_by_time <- team_events %>% group_by(time_bin) %>% summarise( tilt_percentage = round(sum(x >= 50) / n() * 100, 2), .groups = 'drop' ) %>% mutate(time_period = as.numeric(time_bin)) return(tilt_by_time) }, calculate_zone_dominance = function(team) { opponent_team <- unique(self$event_data$team[self$event_data$team != team])[1] zones <- tibble( zone = c('Defensive Third', 'Middle Third', 'Attacking Third'), x_min = c(0, 33, 67), x_max = c(33, 67, 100) ) zone_dominance <- zones %>% rowwise() %>% mutate( team_actions = sum(self$event_data$team == team & self$event_data$x >= x_min & self$event_data$x < x_max), opponent_actions = sum(self$event_data$team == opponent_team & self$event_data$x >= x_min & self$event_data$x < x_max), total_actions = team_actions + opponent_actions, dominance_percentage = round( if (total_actions > 0) (team_actions / total_actions) * 100 else 50.0, 2 ) ) %>% select(zone, team_actions, opponent_actions, dominance_percentage) return(zone_dominance) }, visualize_field_tilt = function(team) { # Temporal tilt plot temporal_data <- self$calculate_temporal_tilt(team) p1 <- ggplot(temporal_data, aes(x = time_period, y = tilt_percentage)) + geom_line(color = '#1f77b4', size = 1.2) + geom_point(size = 3, color = '#1f77b4') + geom_ribbon(aes(ymin = 50, ymax = tilt_percentage), fill = '#1f77b4', alpha = 0.3) + geom_hline(yintercept = 50, color = 'red', linetype = 'dashed') + labs(title = 'Field Tilt Over Time', x = 'Match Period', y = 'Tilt % (Opponent Half)') + theme_minimal() + theme(plot.title = element_text(face = 'bold')) # Zone dominance plot zone_data <- self$calculate_zone_dominance(team) p2 <- ggplot(zone_data, aes(x = dominance_percentage, y = zone)) + geom_col(aes(fill = dominance_percentage > 50), alpha = 0.7) + geom_vline(xintercept = 50, linetype = 'dashed', size = 1) + geom_text(aes(label = paste0(dominance_percentage, '%')), hjust = -0.2, fontface = 'bold') + scale_fill_manual(values = c('TRUE' = '#2ca02c', 'FALSE' = '#d62728'), guide = 'none') + labs(title = 'Zone Dominance', x = 'Dominance %', y = NULL) + theme_minimal() + theme(plot.title = element_text(face = 'bold')) # Combine plots combined <- p1 / p2 + plot_annotation( title = paste('Field Tilt Analysis -', team), theme = theme(plot.title = element_text(face = 'bold', size = 16)) ) return(combined) }, generate_tilt_report = function(team) { report <- list( action_tilt = self$calculate_action_based_tilt(team), average_position = self$calculate_average_field_position(team), temporal_tilt = self$calculate_temporal_tilt(team), zone_dominance = self$calculate_zone_dominance(team) ) self$tilt_metrics[[team]] <- report return(report) } ) ) # Example usage set.seed(42) # Generate sample data event_data <- map_dfr(1:90, function(minute) { n_events <- sample(8:15, 1) tibble( team = sample(c('Team A', 'Team B'), n_events, replace = TRUE, prob = c(0.55, 0.45)), event_type = sample( c('Pass', 'Carry', 'Shot', 'Tackle', 'Interception'), n_events, replace = TRUE, prob = c(0.6, 0.2, 0.08, 0.07, 0.05) ), minute = minute, timestamp = minute * 60 + runif(n_events, 0, 60) ) %>% mutate( x = if_else(team == 'Team A', rbeta(n(), 3, 2) * 100, rbeta(n(), 2, 3) * 100), y = runif(n(), 10, 90) ) }) # Analyze analyzer <- FieldTiltAnalyzer$new(event_data) cat("FIELD TILT ANALYSIS - Team A\n\n") report <- analyzer$generate_tilt_report('Team A') cat("Action-Based Tilt:\n") cat(sprintf(" Tilt Percentage: %.2f%%\n", report$action_tilt$tilt_percentage)) cat(sprintf(" Opponent Half: %d actions\n", report$action_tilt$actions_opponent_half)) cat("\nAverage Field Position:\n") cat(sprintf(" Position: %.2f\n", report$average_position$average_field_position)) cat(sprintf(" Advantage: %+.2f\n", report$average_position$territorial_advantage)) cat("\nZone Dominance:\n") print(report$zone_dominance) # Visualize p <- analyzer$visualize_field_tilt('Team A') ggsave('field_tilt_r.png', p, width = 12, height = 10, dpi = 300) cat("\nVisualization saved\n") ``` ## Interpretation Guidelines 1. **Tilt > 60%**: Strong territorial dominance 2. **Tilt 50-60%**: Moderate attacking control 3. **Tilt < 50%**: Defensive/counter-attacking approach 4. **Average Position > 55**: Consistent attacking presence 5. **Zone Dominance**: High attacking third % indicates pressure ## Applications - **Tactical Evaluation**: Assess territorial control effectiveness - **Match Analysis**: Understand periods of dominance - **Opposition Study**: Identify defensive vulnerabilities - **Performance Metrics**: Quantify attacking intent - **Strategic Planning**: Design territorial strategies

Discussion

Have questions or feedback? Join our community discussion on Discord or GitHub Discussions.
Table of Contents
Quick Actions
Glossary