Field Tilt
Beginner
10 min read
0 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
Related Topics
Quick Actions