# !rm -rf temp_repo src
# !git clone https://github.com/ringhilterra/analytics_cup_research.git temp_repo
# !mv temp_repo/src .
# !rm -rf temp_repo
# !pip install mplsoccer -qIf running in Google Colab, uncomment the cell below. Skip if running locally.
The Active Support Index (ASI)
Quantifying Off-Ball Movement When It Matters Most
Author: Ryan Inghilterra
Core Question: When a player is pressured, how actively are teammates moving to provide passing options?
Metrics Framework:
| Level | Metric | Formula | Interpretation |
|---|---|---|---|
| Per Event | Active Support Ratio | \(\frac{\text{Active Supporters}}{\text{Nearby Teammates}}\) | Support quality in a single pressure moment |
| Per Player | Player ASI | \(\frac{\text{Active Support Count}}{\text{Support Opportunities}}\) | How often a player moves to support under pressure |
| Per Team | Team ASI | \(1 - \text{Static Rate}\) | Team’s off-ball movement culture |
Definitions: - Active Supporter: Teammate within 35m AND moving >2 m/s - Static Rate: Proportion of events with zero active supporters
Higher values = better off-ball support.
Validation highlights: - ASI correlates with season-level physical output (r = 0.74, p < 0.001) - ASI aligns with positional demands (midfielders 59% vs defenders 45%, p < 0.001) - Team ASI differentiates playing styles (98.3% Perth Glory to 91.5% Macarthur FC) - 58% of players show declining support in H2 (fatigue signal)
# Core imports
import pandas as pd
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings('ignore')
# Display settings
pd.set_option('display.max_columns', 20)
pd.set_option('display.width', 200)
plt.style.use('dark_background')
# src imports - core
from src import ASIDataLoader, ASICalculator
from src import fetch_match_data, process_all_matches, get_top_players_all_matches, get_team_stats_all_matches, plot_multi_match_team_comparison
from src.asi_data_loader import ASIDataLoader, print_match_summary
from src.asi_core import ASICalculator, ASIConfig
from src.asi_visualizations import ASIVisualizer
from src.nb_helper import get_detail_results_summary, team_stat_asi_summary, get_event_by_id, plot_pressure_from_event_id
# src imports - validation & analysis
from src import calculate_position_stats, plot_position_validation, test_position_significance, print_significance_result
from src import calculate_time_based_asi, get_time_bin_stats, plot_time_trend, compare_halves, print_half_comparison
from src import calculate_player_fatigue, plot_fatigue_comparison, print_fatigue_summary
from src import load_physical_aggregates, merge_asi_with_physical, calculate_physical_correlation, plot_asi_physical_correlation, print_physical_validation_summarySingle Match Analysis
MATCH_ID = 1886347 # Auckland (Home) vs Newcastle (Away) - final score: (2, 0)
# MATCH_ID = 1899585 ## <- can easily change match_idData Loading
# First fetch/download data for single match - tracking and event data from SkillCorner GitHub
fetch_match_data(MATCH_ID, verbose=True, skip_if_exists=True) # Downloads and processes match data to ./data/1886347/Match 1886347 data already exists, skipping fetch.
PosixPath('data/1886347')
# Load match
loader = ASIDataLoader(data_dir="./data")
match_data = loader.load_match(MATCH_ID)
# Print summary
print_match_summary(match_data)============================================================
MATCH 1886347
Auckland FC 2 - 0 Newcastle United Jets FC
------------------------------------------------------------
Tracking rows: 956,076
Event rows: 5,079
Pressure events: 773
Pitch: 104m x 68m
Validated: Yes
============================================================
# show processed tracking data with velocity (speed)
display_cols = ['frame', 'team_id', 'short_name','number', 'x', 'y', 'vx', 'vy', 'speed', 'match_time_min', 'ball_x', 'ball_y', 'player_role.acronym']
match_data.tracking_df.head(3)[display_cols]| frame | team_id | short_name | number | x | y | vx | vy | speed | match_time_min | ball_x | ball_y | player_role.acronym | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 10 | 4177 | L. Verstraete | 6 | 11.70 | 6.73 | -1.242857 | -0.407143 | 1.307845 | 0.0 | 0.32 | 0.38 | DM |
| 1 | 10 | 4177 | F. Gallegos | 28 | 10.16 | -2.12 | -0.292857 | -1.042857 | 1.083197 | 0.0 | 0.32 | 0.38 | AM |
| 2 | 10 | 4177 | N. Pijnaker | 4 | 16.78 | -3.67 | 0.257143 | -0.289286 | 0.387051 | 0.0 | 0.32 | 0.38 | LCB |
match_data.events_df.iloc[:, :8].tail(3) # peak at dynamic_events| event_id | frame_start | frame_end | event_type | event_subtype | player_id | team_id | player_name | |
|---|---|---|---|---|---|---|---|---|
| 5076 | 8_998 | 58907 | 58932 | player_possession | NaN | 285188 | 4177 | A. Paulsen |
| 5077 | 7_2542 | 58907 | 58932 | passing_option | NaN | 43829 | 4177 | N. Moreno |
| 5078 | 7_2543 | 58907 | 58932 | passing_option | NaN | 163972 | 4177 | M. Mata |
Calculating ASI Per Pressure Event
Configurable Thresholds: | Parameter | Value | Rationale | |———–|——-|———–| | Proximity | 35m | Maximum realistic passing range under pressure | | Velocity | 2 m/s | Threshold separating walking (~1.4 m/s) from jogging/running |
# can adjust these values
PROX_THRESH = 35
VELOCITY_THRESH = 2
config = ASIConfig(proximity_threshold_m=PROX_THRESH, velocity_threshold_ms=VELOCITY_THRESH)
print(config.proximity_threshold_m)
print(config.velocity_threshold_ms)35
2
# Calculate ASI metrics for each pressure event
calculator = ASICalculator(match_data, config=config)
results_df = calculator.process_all_pressure_events()
print(results_df.shape)(773, 17)
print("\nPressure Event Results (first 8)")
display_cols = ['frame_start', 'period', 'pressed_player_name', 'num_teammates_nearby', 'num_active_supporters', 'active_support_ratio']
results_df[display_cols].head(8)
Pressure Event Results (first 8)
| frame_start | period | pressed_player_name | num_teammates_nearby | num_active_supporters | active_support_ratio | |
|---|---|---|---|---|---|---|
| 0 | 56 | 1 | A. Šušnjar | 8 | 4 | 0.5000 |
| 1 | 74 | 1 | M. Natta | 8 | 6 | 0.7500 |
| 2 | 235 | 1 | F. De Vries | 4 | 2 | 0.5000 |
| 3 | 253 | 1 | D. Ingham | 9 | 8 | 0.8889 |
| 4 | 314 | 1 | L. Gillion | 5 | 5 | 1.0000 |
| 5 | 345 | 1 | F. Gallegos | 6 | 3 | 0.5000 |
| 6 | 499 | 1 | A. Šušnjar | 7 | 0 | 0.0000 |
| 7 | 549 | 1 | F. Gallegos | 9 | 6 | 0.6667 |
Example (Row 1): A. Šušnjar under pressure - 8 teammates within 35m → 4 moving >2 m/s → Active Support Ratio = 0.50 - Half his nearby teammates were actively creating options; half were static.
# Get detailed result for a single event
detailed_results = calculator.get_detailed_results()
# Show one event in detail
event = detailed_results[1]
get_detail_results_summary(event)
Detailed Analysis - Single Pressure Event
============================================================
Event ID: 9_1
Frame: 74
Type: recovery_press
Pressed Player: M. Natta
Position: (-16.9, 20.6)
Support Metrics:
Teammates Nearby (<35m): 8
Active Supporters (>2m/s): 6
Active Support Ratio: 0.75
Teammate Details:
C. Timmins | Dist: 14.8m | Speed: 3.8 m/s | ACTIVE
D. Ingham | Dist: 56.4m | Speed: 1.6 m/s | FAR
R. Scott | Dist: 30.2m | Speed: 1.6 m/s | STATIC
A. Šušnjar | Dist: 15.9m | Speed: 3.1 m/s | ACTIVE
P. Cancar | Dist: 29.2m | Speed: 1.9 m/s | STATIC
Pressure Moment Visualization
Visualize pressure events with active/static support color coding. Use plot_pressure_from_event_id(event_id, ...) to generate a visualization for any pressure event.
High Active Support Example
# Initialize visualizer
visualizer = ASIVisualizer(match_data)plot_pressure_from_event_id("9_3", detailed_results, match_data, loader) # frame 253, Player = D.Ingham
D. Ingham (#14, Newcastle) pressed while facing his own goal. Active Support Ratio = 0.89 — 8 of 9 nearby teammates actively moving. Only the goalkeeper was static (already open). Note #4 sprinting back at 3.7 m/s to offer a passing lane.
Low Active Support Example
plot_pressure_from_event_id("9_790", detailed_results, match_data, loader) # frame 47507, Player = L.Gillion
L. Gillion (#14, Auckland) pressed near the 18-yard box. Active Support Ratio = 0.11 — only 1 of 9 nearby teammates moving (#17 at 2.2 m/s). Most teammates were static “ball-watching” rather than creating options. This is exactly the scenario coaches want to identify and correct.
Player ASI Analysis
Calculate and rank players by their Active Support Index.
# Calculate player ASI scores
player_scores = calculator.calculate_player_asi_scores(detailed_results)
print(f"Player ASI Scores: Total players analyzed: {len(player_scores)}")
# Display top 10 with minimum 20 opportunities
top_players = player_scores[player_scores['opportunities'] >= 20].head(10)
print(f"\nTop 10 Players (min 20 opportunities):")
exclude_cols = ['player_id', 'player_number', 'match_name', 'matches_count']
top_players.drop(columns=exclude_cols).head(10)Player ASI Scores: Total players analyzed: 29
Top 10 Players (min 20 opportunities):
| player_name | team_name | player_role_acronym | active_support_count | opportunities | asi_score | |
|---|---|---|---|---|---|---|
| 0 | C. Timmins | Newcastle United Jets FC | LDM | 214 | 278 | 0.7698 |
| 1 | D. Wilmering | Newcastle United Jets FC | AM | 39 | 54 | 0.7222 |
| 2 | F. Gallegos | Auckland FC | AM | 231 | 330 | 0.7000 |
| 3 | M. Scarcella | Newcastle United Jets FC | AM | 27 | 39 | 0.6923 |
| 4 | L. Gillion | Auckland FC | LW | 181 | 269 | 0.6729 |
| 5 | L. Verstraete | Auckland FC | DM | 227 | 345 | 0.6580 |
| 6 | J. Vidic | Newcastle United Jets FC | LDM | 25 | 38 | 0.6579 |
| 7 | N. Moreno | Auckland FC | RW | 46 | 70 | 0.6571 |
| 8 | L. Bayliss | Newcastle United Jets FC | AM | 136 | 210 | 0.6476 |
| 9 | L. Rogerson | Auckland FC | RW | 120 | 193 | 0.6218 |
Player ASI measures how often a player actively moves to support teammates under pressure — the proportion of support opportunities where they were moving >2 m/s.
# Player leaderboard visualization
fig = visualizer.plot_player_leaderboard(player_scores, top_n=None, min_opportunities=20, show=True)
Midfielders cluster at the top — their role demands constant movement. Defenders and goalkeepers rank lower, which is expected given their positional responsibilities. Standout: C. Timmins (LDM) leads with 77% ASI, providing active support in 214 of 278 opportunities — the most reliable off-ball mover in this match.
Team ASI Comparison
Calculate and compare Active Support Index between teams for the match.
# Calculate team-level ASI
team_stats = calculator.calculate_team_asi_scores(results_df)
# provide summary
team_stat_asi_summary(team_stats)Team ASI Comparison
============================================================
Auckland FC:
Total pressure events (when pressed): 402
Team ASI (1 - static rate): 96.0%
Static Rate (0 active supporters): 4.0%
Avg Active Supporters: 3.92
Avg Teammates Nearby: 7.49
Newcastle United Jets FC:
Total pressure events (when pressed): 371
Team ASI (1 - static rate): 94.6%
Static Rate (0 active supporters): 5.4%
Avg Active Supporters: 4.13
Avg Teammates Nearby: 7.73
# Team comparison visualization
fig = visualizer.plot_team_comparison(team_stats, show=True)
| Metric | What it Measures | Interpretation |
|---|---|---|
| Team ASI | 1 - static_rate — Proportion of pressure events where at least one teammate was actively moving | Higher = Better. A team with 95% ASI means only 5% of pressure moments had zero active support. |
| Avg Active Supporters | Mean number of teammates moving >2 m/s within 35m when ball carrier is pressed | Higher = Better. More teammates in motion = more passing options and defensive support. |
| Static Rate | Proportion of pressure events where no nearby teammate was moving >2 m/s | Lower = Better. High static rate indicates teammates “ball-watching” instead of creating options. |
Quick Read: Higher Team ASI + Lower Static Rate = better off-ball support culture. Avg Active Supporters shows intensity — teams averaging 3+ are creating strong movement.
Run on all 10 matches
# Process all 10 matches (will take a couple of minutes)
results_all = process_all_matches(verbose=False)# Top 10 players across all matches
top_players_all = get_top_players_all_matches(results_all['all_player_scores'], min_opportunities=50, top_n=300)
exclude_cols = ['player_id', 'player_number', 'match_name']
top_players_all.drop(columns=exclude_cols).head(10)| player_name | team_name | player_role_acronym | active_support_count | opportunities | matches_count | asi_score | |
|---|---|---|---|---|---|---|---|
| 0 | N. Pennington | Perth Glory Football Club | LM | 211 | 261 | 1 | 0.8084 |
| 1 | H. Steele | Central Coast Mariners Football Club | LM | 184 | 228 | 1 | 0.8070 |
| 2 | C. Timmins | Newcastle United Jets FC | LDM | 214 | 278 | 1 | 0.7698 |
| 3 | L. Verstraete | Auckland FC | DM | 688 | 936 | 4 | 0.7350 |
| 4 | D. Wilmering | Newcastle United Jets FC | AM | 39 | 54 | 1 | 0.7222 |
| 5 | P. Makrillos | Macarthur FC | CF | 78 | 108 | 1 | 0.7222 |
| 6 | J. Lauton | Western United | RM | 120 | 167 | 2 | 0.7186 |
| 7 | Z. Schreiber | Melbourne City FC | DM | 178 | 249 | 1 | 0.7149 |
| 8 | T. Gomulka | Perth Glory Football Club | RM | 183 | 256 | 1 | 0.7148 |
| 9 | A. Thurgate | Western United | LM | 372 | 524 | 2 | 0.7099 |
fig = visualizer.plot_player_leaderboard(top_players_all, top_n=25, min_opportunities=20)
Wide midfielders (LM, RM) and central midfielders (DM, AM) dominate the top 25 — positions requiring constant off-ball movement. Only 2 players exceed 80% ASI: N. Pennington (80.8%) and H. Steele (80.7%), both left midfielders, marking them as elite off-ball supporters. The 75th percentile threshold (~70%) separates good from exceptional active supporters.
# Team comparison across all matches
team_summary = get_team_stats_all_matches(results_all['all_team_stats'])
team_summary| team_name | total_pressure_events | total_static_events | avg_active_supporters | avg_teammates_nearby | matches_played | overall_static_rate | overall_team_asi | |
|---|---|---|---|---|---|---|---|---|
| 0 | Perth Glory Football Club | 287 | 5 | 4.30 | 7.66 | 1 | 0.0174 | 0.9826 |
| 1 | Auckland FC | 1368 | 37 | 4.18 | 7.71 | 4 | 0.0270 | 0.9730 |
| 2 | Melbourne City FC | 828 | 36 | 3.62 | 7.22 | 2 | 0.0435 | 0.9565 |
| 3 | Western United | 647 | 29 | 4.40 | 7.60 | 2 | 0.0448 | 0.9552 |
| 4 | Brisbane Roar FC | 453 | 21 | 3.56 | 7.53 | 1 | 0.0464 | 0.9536 |
| 5 | Wellington Phoenix FC | 598 | 28 | 3.72 | 7.77 | 2 | 0.0468 | 0.9532 |
| 6 | Adelaide United Football Club | 392 | 19 | 3.57 | 7.49 | 1 | 0.0485 | 0.9515 |
| 7 | Newcastle United Jets FC | 371 | 20 | 4.13 | 7.73 | 1 | 0.0539 | 0.9461 |
| 8 | Central Coast Mariners Football Club | 266 | 15 | 3.63 | 7.39 | 1 | 0.0564 | 0.9436 |
| 9 | Melbourne Victory Football Club | 674 | 39 | 4.00 | 7.91 | 2 | 0.0579 | 0.9421 |
| 10 | Sydney Football Club | 862 | 50 | 3.68 | 7.74 | 2 | 0.0580 | 0.9420 |
| 11 | Macarthur FC | 317 | 27 | 3.41 | 7.39 | 1 | 0.0852 | 0.9148 |
fig = plot_multi_match_team_comparison(team_summary)
Perth Glory (98.3%) and Auckland (97.3%) lead in Team ASI, demonstrating strong off-ball support cultures. Macarthur FC (91.5%) shows the most room for improvement. The 7-point spread suggests ASI can differentiate team playing styles.
Validation: ASI by Position
To validate that ASI captures meaningful off-ball behavior, we examine whether positions with higher movement demands show correspondingly higher ASI scores.
# Calculate ASI stats by position category
category_stats = calculate_position_stats(top_players_all)
category_stats| mean_asi | std_asi | num_players | total_opportunities | |
|---|---|---|---|---|
| position_category | ||||
| Defensive/Central Mid | 0.627 | 0.083 | 15 | 5683 |
| Attacking Mid/Winger | 0.586 | 0.099 | 62 | 15705 |
| Forward | 0.515 | 0.101 | 35 | 9023 |
| Defender | 0.452 | 0.079 | 61 | 20367 |
| Goalkeeper | 0.113 | 0.059 | 12 | 2334 |
# Visualize ASI by position category
fig = plot_position_validation(category_stats)
# Statistical significance test
result = test_position_significance(top_players_all)
print_significance_result(result)Midfielders vs Defenders:
Midfielders mean ASI: 59.4% (n=77)
Defenders mean ASI: 45.2% (n=61)
Mann-Whitney U: 4092, p = 4.00e-14 Significant (p < 0.001)
Validation Result: Position categories rank as expected — attacking midfielders and wingers show significantly higher ASI than defenders (p < 0.001). This confirms ASI captures positional movement demands rather than random variation.
Validation: Season Physical Output
Does ASI from tracking data reflect real physical effort? We validate by comparing player ASI scores (from 10 tracking matches) against season-level physical aggregates (175 matches, A-League 2024/25).
# Merge ASI scores with physical aggregates
merged_physical = merge_asi_with_physical(top_players_all, min_opportunities=50)
print(f"Players with ASI and physical data: {len(merged_physical)}")
# Calculate and display correlation
corr_stats = calculate_physical_correlation(merged_physical)
print_physical_validation_summary(corr_stats)Players with ASI and physical data: 167
Physical Aggregates Validation
==================================================
Players analyzed: 167
Pearson r: 0.739
p-value: 4.47e-30
Quartile Comparison (M/min during possession):
Low ASI (Q1): 128.8 m/min
High ASI (Q4): 153.2 m/min
Difference: +24.4 m/min (+19%)
# Visualize ASI vs physical output correlation
fig = plot_asi_physical_correlation(merged_physical)
Result: ASI correlates strongly with meters per minute during possession (r = 0.74, p < 0.001). Players in the top ASI quartile cover 24 m/min more than the bottom quartile — a 19% increase in work rate. This external validation confirms ASI captures genuine physical effort: players who actively support teammates under pressure are the same players who cover the most ground when their team has possession.
Time-Based ASI Analysis
Does active support decline as the match progresses? We analyze ASI trends over 15-minute intervals to identify potential fatigue patterns.
# Calculate time-based ASI across all 10 matches
all_results_time_df = calculate_time_based_asi(loader, config)
print(f"Total pressure events with time data: {len(all_results_time_df)}")Total pressure events with time data: 7063
# Calculate ASI by time bin
time_asi = get_time_bin_stats(all_results_time_df)
time_asi| avg_support_ratio | avg_active_supporters | num_events | |
|---|---|---|---|
| time_bin | |||
| 0-15 | 0.552 | 4.055 | 1283 |
| 15-30 | 0.534 | 3.784 | 990 |
| 30-45 | 0.527 | 3.770 | 1128 |
| 45-60 | 0.542 | 3.956 | 1302 |
| 60-75 | 0.548 | 3.984 | 1013 |
| 75-90 | 0.502 | 3.683 | 992 |
| 90+ | 0.513 | 3.730 | 355 |
# Visualize ASI trend over match time
fig = plot_time_trend(time_asi)
# Compare First Half vs Second Half
half_result = compare_halves(all_results_time_df)
print_half_comparison(half_result)
half_result['half_stats']
First Half vs Second Half ASI:
H1 mean: 53.7%, H2 mean: 53.1%
Mann-Whitney U: 6298558, p = 0.459 (not significant)
| avg_ratio | std_ratio | avg_active | num_events | |
|---|---|---|---|---|
| half | ||||
| First Half | 0.537 | 0.303 | 3.877 | 3565 |
| Second Half | 0.531 | 0.303 | 3.872 | 3498 |
Observation: Active support ratio shows a slight downward trend within each half, with the lowest values appearing in the final 15 minutes (50.2%). However, the overall first half vs second half difference is not statistically significant (p = 0.46). This suggests fatigue-related decline in off-ball support may exist but requires larger sample sizes to confirm. The pattern warrants further investigation with more match data.
Player Fatigue Analysis
Which players maintain their off-ball support throughout the match vs those who fade? We compare each player’s ASI in the first half vs second half.
# Calculate player fatigue metrics across all 10 matches
qualified = calculate_player_fatigue(loader, config, min_opps=15)
print(f"Players with 15+ opportunities in both halves: {len(qualified)}")Players with 15+ opportunities in both halves: 151
# Show top maintainers (lowest fatigue drop)
# Note: Negative fatigue_drop = H2 improvement (player increased ASI after halftime)
qualified[['player_name', 'h1_asi', 'h2_asi', 'fatigue_drop', 'total_opps']].round(3).head(10)| player_name | h1_asi | h2_asi | fatigue_drop | total_opps | |
|---|---|---|---|---|---|
| 76 | J. Yull | 0.457 | 0.699 | -0.241 | 202.0 |
| 58 | D. Pierias | 0.231 | 0.465 | -0.234 | 209.0 |
| 144 | L. Bayliss | 0.604 | 0.784 | -0.181 | 210.0 |
| 142 | E. Adams | 0.419 | 0.595 | -0.176 | 215.0 |
| 129 | J. Randall | 0.606 | 0.777 | -0.171 | 202.0 |
| 68 | H. Van Der Saag | 0.403 | 0.554 | -0.150 | 185.0 |
| 153 | M. Ruhs | 0.498 | 0.643 | -0.145 | 313.0 |
| 204 | F. Talladira | 0.403 | 0.538 | -0.135 | 233.0 |
| 193 | A. Bugarija | 0.667 | 0.800 | -0.133 | 179.0 |
| 44 | J. Brimmer | 0.525 | 0.653 | -0.128 | 661.0 |
# Visualize: Top 5 Maintainers vs Top 5 Faders
fig = plot_fatigue_comparison(qualified, top_n=5)
print_fatigue_summary(qualified)
Fatigue Analysis Summary (151 players):
Players who improved H1→H2: 63
Players who declined H1→H2: 87
Avg fatigue drop: 1.0%
Key Finding: More players show declining off-ball support in the second half (58%) than improving (42%), with an average 1% drop. However, individual variation is substantial — some players significantly increase their movement after halftime (possibly “warming into” the match), while others show clear fatigue patterns. This player-level analysis could help coaches identify which players need earlier substitution or targeted conditioning work.
Impact & Use Cases
| Stakeholder | Application |
|---|---|
| Scouts | Identify “highly-active supporter” players who consistently work off-ball to support teammates under pressure |
| Coaches | Diagnose static tendencies; design training to improve support patterns |
| Analysts | Compare team playing styles; evaluate support quality by zone |
| Players | Objective feedback on off-ball contribution |
Future Improvements
- Context-aware “active support”: Account for whether a player is already open (opponent proximity). A stationary player in space may be better positioned than one running into traffic.
- Advanced support classification: Incorporate velocity direction vectors and pass opening angle calculations to distinguish between movement toward useful space versus movement away from the play.