This analysis uses StatsBomb’s open data: a free, publicly released subset of the match and event data StatsBomb otherwise sells commercially to professional clubs, leagues and media companies as part of a much larger paid catalogue covering many more competitions and seasons. StatsBomb makes this smaller open selection available on GitHub for research, learning and personal development, on the condition that StatsBomb is credited as the source whenever the data is used (the “Whilst we are keen to share data…” message printed throughout this document is StatsBomb’s own attribution reminder, built into their functions).
It’s worth distinguishing the data from the tool used to access it. StatsBomb open data is the raw match and event-level dataset itself, published as JSON files in StatsBomb’s public repository. StatsBombR, the R package used throughout this document, is not the data — it is simply a library of functions that request, parse and clean that published data into R-friendly data frames, saving the user from writing that parsing logic by hand.
This analysis uses two R packages. StatsBombR is the official package for accessing StatsBomb’s free, publicly released match and event data; rather than scraping a website, it reads directly from StatsBomb’s open GitHub data repository, returning ready-to-use data frames of competitions, matches, and event-level data. dplyr is used alongside it for filtering, transforming, and summarising this data.
# install.packages("devtools") # run once if not already installed
# devtools::install_github("statsbomb/StatsBombR") # run once if not already installed
library(StatsBombR)
## Loading required package: dplyr
##
## Attaching package: 'dplyr'
## The following objects are masked from 'package:stats':
##
## filter, lag
## The following objects are masked from 'package:base':
##
## intersect, setdiff, setequal, union
## Loading required package: stringi
## Loading required package: stringr
## Loading required package: tibble
## Loading required package: rvest
## Loading required package: RCurl
## Loading required package: doParallel
## Loading required package: foreach
## Loading required package: iterators
## Loading required package: parallel
## Loading required package: httr
## Loading required package: jsonlite
## Loading required package: purrr
##
## Attaching package: 'purrr'
## The following object is masked from 'package:jsonlite':
##
## flatten
## The following objects are masked from 'package:foreach':
##
## accumulate, when
## Loading required package: sp
## Loading required package: tidyr
##
## Attaching package: 'tidyr'
## The following object is masked from 'package:RCurl':
##
## complete
## Warning: replacing previous import 'jsonlite::flatten' by 'purrr::flatten' when
## loading 'StatsBombR'
## Warning: replacing previous import 'foreach::when' by 'purrr::when' when
## loading 'StatsBombR'
## Warning: replacing previous import 'foreach::accumulate' by 'purrr::accumulate'
## when loading 'StatsBombR'
library(dplyr)
StatsBombR provides a function, FreeCompetitions(), that returns every competition and season currently available in StatsBomb’s open data, along with their ID numbers.
We are looking to analyse the 2015/16 La Liga season, a dramatic title race which went down to the wire and ended with the Top 3 teams separated by just three points.
competitions <- FreeCompetitions()
## [1] "Whilst we are keen to share data and facilitate research, we also urge you to be responsible with the data. Please credit StatsBomb as your data source when using the data and visit https://statsbomb.com/media-pack/ to obtain our logos for public use."
la_liga_1516 <- competitions %>%
filter(competition_name == "La Liga", season_name == "2015/2016")
print(la_liga_1516)
## competition_id season_id country_name competition_name competition_gender
## 1 11 27 Spain La Liga male
## competition_youth competition_international season_name
## 1 FALSE FALSE 2015/2016
## match_updated match_updated_360 match_available_360
## 1 2025-04-23T13:59:22.835792 2021-06-13T16:17:31.694 <NA>
## match_available
## 1 2025-04-23T13:59:22.835792
StatsBombR has a function, FreeMatches(), that takes the competition/season row we just found and returns one row per match — teams, score, date, and match ID — for the whole campaign.
la_liga_matches <- FreeMatches(la_liga_1516)
## [1] "Whilst we are keen to share data and facilitate research, we also urge you to be responsible with the data. Please credit StatsBomb as your data source when using the data and visit https://statsbomb.com/media-pack/ to obtain our logos for public use."
dplyr::glimpse(la_liga_matches)
## Rows: 380
## Columns: 42
## $ match_id <int> 3825739, 3825848, 3825895, 3825894, 382…
## $ match_date <chr> "2016-01-17", "2015-09-23", "2015-09-23…
## $ kick_off <chr> "17:00:00.000", "20:00:00.000", "22:00:…
## $ home_score <int> 5, 2, 2, 0, 3, 4, 4, 2, 3, 2, 0, 3, 2, …
## $ away_score <int> 1, 2, 0, 2, 1, 2, 1, 0, 1, 1, 2, 1, 0, …
## $ match_status <chr> "available", "available", "available", …
## $ match_status_360 <chr> "unscheduled", "unscheduled", "unschedu…
## $ last_updated <chr> "2024-05-16T14:06:52.149840", "2024-08-…
## $ last_updated_360 <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA,…
## $ match_week <int> 20, 5, 5, 36, 36, 38, 38, 38, 38, 38, 3…
## $ competition.competition_id <int> 11, 11, 11, 11, 11, 11, 11, 11, 11, 11,…
## $ competition.country_name <chr> "Spain", "Spain", "Spain", "Spain", "Sp…
## $ competition.competition_name <chr> "La Liga", "La Liga", "La Liga", "La Li…
## $ season.season_id <int> 27, 27, 27, 27, 27, 27, 27, 27, 27, 27,…
## $ season.season_name <chr> "2015/2016", "2015/2016", "2015/2016", …
## $ home_team.home_team_id <int> 220, 221, 208, 219, 223, 214, 223, 1041…
## $ home_team.home_team_name <chr> "Real Madrid", "Levante UD", "Las Palma…
## $ home_team.home_team_gender <chr> "male", "male", "male", "male", "male",…
## $ home_team.home_team_group <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA,…
## $ home_team.managers <list> [<data.frame[1 x 6]>], [<data.frame[1 …
## $ home_team.country.id <int> 214, 214, 214, 214, 214, 214, 214, 214,…
## $ home_team.country.name <chr> "Spain", "Spain", "Spain", "Spain", "Sp…
## $ away_team.away_team_id <int> 1041, 322, 213, 216, 221, 322, 208, 222…
## $ away_team.away_team_name <chr> "Sporting Gijón", "Eibar", "Sevilla", "…
## $ away_team.away_team_gender <chr> "male", "male", "male", "male", "male",…
## $ away_team.away_team_group <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA,…
## $ away_team.managers <list> [<data.frame[1 x 6]>], [<data.frame[1 …
## $ away_team.country.id <int> 214, 214, 214, 214, 214, 214, 214, 214,…
## $ away_team.country.name <chr> "Spain", "Spain", "Spain", "Spain", "Sp…
## $ metadata.data_version <chr> "1.1.0", "1.1.0", "1.1.0", "1.1.0", "1.…
## $ metadata.shot_fidelity_version <chr> "2", "2", "2", "2", "2", "2", "2", "2",…
## $ metadata.xy_fidelity_version <chr> "2", "2", "2", "2", "2", "2", "2", "2",…
## $ competition_stage.id <int> 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, …
## $ competition_stage.name <chr> "Regular Season", "Regular Season", "Re…
## $ stadium.id <int> 353, 350, 357, 4658, 346, 351, 346, 465…
## $ stadium.name <chr> "Bernabéu", "Estadio Ciudad de Valencia…
## $ stadium.country.id <int> 214, 214, 214, 214, 214, 214, 214, 214,…
## $ stadium.country.name <chr> "Spain", "Spain", "Spain", "Spain", "Sp…
## $ referee.id <int> 221, NA, 2728, 1008, 2480, 2692, 2602, …
## $ referee.name <chr> "Alberto Undiano Mallenco", NA, "Carlos…
## $ referee.country.id <int> 214, NA, 214, 214, 214, 214, 214, 214, …
## $ referee.country.name <chr> "Spain", NA, "Spain", "Spain", "Spain",…
Checking that every team is actually represented
team_match_counts <- la_liga_matches %>%
select(match_id, home_team.home_team_name, away_team.away_team_name) %>%
tidyr::pivot_longer(cols = c(home_team.home_team_name, away_team.away_team_name), values_to = "team") %>%
count(team, sort = TRUE)
print(team_match_counts, n = Inf)
## # A tibble: 20 × 2
## team n
## <chr> <int>
## 1 Athletic Club 38
## 2 Atlético Madrid 38
## 3 Barcelona 38
## 4 Celta Vigo 38
## 5 Eibar 38
## 6 Espanyol 38
## 7 Getafe 38
## 8 Granada 38
## 9 Las Palmas 38
## 10 Levante UD 38
## 11 Málaga 38
## 12 RC Deportivo La Coruña 38
## 13 Rayo Vallecano 38
## 14 Real Betis 38
## 15 Real Madrid 38
## 16 Real Sociedad 38
## 17 Sevilla 38
## 18 Sporting Gijón 38
## 19 Valencia 38
## 20 Villarreal 38
2015/16 La Liga table
home_stats <- la_liga_matches %>%
transmute(
team = home_team.home_team_name,
goals_for = home_score,
goals_against = away_score,
result = case_when(home_score > away_score ~ "W", home_score == away_score ~ "D", TRUE ~ "L")
)
away_stats <- la_liga_matches %>%
transmute(
team = away_team.away_team_name,
goals_for = away_score,
goals_against = home_score,
result = case_when(away_score > home_score ~ "W", away_score == home_score ~ "D", TRUE ~ "L")
)
season_table <- bind_rows(home_stats, away_stats) %>%
group_by(team) %>%
summarise(
played = n(),
wins = sum(result == "W"),
draws = sum(result == "D"),
losses = sum(result == "L"),
goals_for = sum(goals_for),
goals_against = sum(goals_against),
goal_diff = goals_for - goals_against,
points = wins * 3 + draws,
.groups = "drop"
) %>%
arrange(desc(points), desc(goal_diff))
print(season_table, n = Inf)
## # A tibble: 20 × 9
## team played wins draws losses goals_for goals_against goal_diff points
## <chr> <int> <int> <int> <int> <int> <int> <int> <dbl>
## 1 Barcelona 38 29 4 5 112 29 83 91
## 2 Real Madr… 38 28 6 4 110 34 76 90
## 3 Atlético … 38 28 4 6 63 18 45 88
## 4 Villarreal 38 18 10 10 44 35 9 64
## 5 Athletic … 38 18 8 12 58 45 13 62
## 6 Celta Vigo 38 17 9 12 51 59 -8 60
## 7 Sevilla 38 14 10 14 51 50 1 52
## 8 Málaga 38 12 12 14 38 35 3 48
## 9 Real Soci… 38 13 9 16 45 48 -3 48
## 10 Real Betis 38 11 12 15 34 52 -18 45
## 11 Valencia 38 11 11 16 46 48 -2 44
## 12 Las Palmas 38 12 8 18 45 53 -8 44
## 13 Eibar 38 11 10 17 49 61 -12 43
## 14 Espanyol 38 12 7 19 40 74 -34 43
## 15 RC Deport… 38 8 18 12 45 61 -16 42
## 16 Sporting … 38 10 9 19 40 62 -22 39
## 17 Granada 38 10 9 19 46 69 -23 39
## 18 Rayo Vall… 38 9 11 18 52 73 -21 38
## 19 Getafe 38 9 9 20 37 67 -30 36
## 20 Levante UD 38 8 8 22 37 70 -33 32
Advanced statistics: metric definitions
This table extends the season summary with a set of advanced per-90 metrics, calculated from StatsBomb’s event-level data (shot-by-shot, pass-by-pass and defensive-action records for every match) rather than the match-result totals used in the season table.
Expected goals and attacking output
xG (Expected Goals) estimates the likelihood that a given shot results in a goal, based on factors such as shot location, angle, and assist type; a team’s total xG reflects the quality of chances created over the season.
Gls − xG (Goals minus Expected Goals) measures finishing performance relative to chance quality: a positive value indicates a team scored more than its chances warranted (clinical finishing), a negative value indicates the opposite (wasteful finishing).
xGA (Expected Goals Against) is the xG value of shots faced, indicating the quality of chances conceded.
xGD (Expected Goals Difference) is xG minus xGA, an expected-goal-based equivalent of goal difference, indicating overall attacking-versus-defensive balance independent of finishing variance. Shots/90 is the number of shots taken per 90 minutes played.
Passing and chance creation
Passing % is the proportion of attempted passes that successfully reached a teammate.
Progressive Passes/90 counts passes per 90 minutes that move the ball significantly closer to the opponent’s goal, capturing a team’s ability to advance play through passing rather than dribbling or long balls.
Crosses/90 is the number of crosses played into the box per 90 minutes.
Key Passes/90 counts passes per 90 minutes that directly lead to a shot by a teammate, reflecting a team’s chance-creation output through passing specifically.
Defensive actions
Pressures/90 counts instances per 90 minutes where a team applies pressure to the opponent in possession, aiming to force a rushed or lost ball.
High Turnovers/90 counts the number of times per 90 minutes a team regains possession in a high (attacking) area of the pitch, often as a direct result of pressing.
Interceptions/90 counts the number of opposition passes intercepted per 90 minutes.
PPDA (Passes Allowed Per Defensive Action) measures pressing intensity: the number of passes the opposition is allowed to complete in their own defensive two-thirds before a team commits a defensive action there; a lower PPDA indicates a more aggressive, higher-intensity press.
free_allevents() retrieves the full shot, pass, pressure and defensive-action log for every match in la_liga_matches, and allclean() standardises and unpacks it (splitting out location coordinates, etc.) into a usable format.
la_liga_events_raw <- free_allevents(MatchesDF = la_liga_matches, Parallel = TRUE)
## [1] "Whilst we are keen to share data and facilitate research, we also urge you to be responsible with the data. Please credit StatsBomb as your data source when using the data and visit https://statsbomb.com/media-pack/ to obtain our logos for public use."
la_liga_events <- allclean(la_liga_events_raw)
## Joining with `by = join_by(id)`
## Joining with `by = join_by(id)`
## Joining with `by = join_by(id)`
## Joining with `by = join_by(id)`
## Joining with `by = join_by(id)`
## Joining with `by = join_by(id)`
## Joining with `by = join_by(id)`
## Joining with `by = join_by(id)`
## Joining with `by = join_by(id)`
## Joining with `by = join_by(id)`
## Joining with `by = join_by(id)`
## Joining with `by = join_by(id)`
## Joining with `by = join_by(id)`
## Joining with `by = join_by(id)`
## Joining with `by = join_by(id)`
## Joining with `by = join_by(id)`
## Joining with `by = join_by(id)`
## Joining with `by = join_by(period, match_id)`
dplyr::glimpse(la_liga_events)
## Rows: 1,295,354
## Columns: 184
## $ id <chr> "8e5643f6-37ec-4fd2-af85-9e5ab1f9e5b6…
## $ index <int> 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12…
## $ period <int> 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1…
## $ timestamp <chr> "00:00:00.000", "00:00:00.000", "00:0…
## $ minute <int> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0…
## $ second <int> 0, 0, 0, 0, 0, 1, 1, 1, 3, 3, 4, 6, 6…
## $ possession <int> 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2…
## $ duration <dbl> 0.000000, 0.000000, 0.000000, 0.00000…
## $ related_events <list> <NULL>, <NULL>, "48cee729-422a-4532-…
## $ location <list> <NULL>, <NULL>, <NULL>, <NULL>, <60,…
## $ under_pressure <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ out <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ counterpress <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ off_camera <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ type.id <int> 35, 35, 18, 18, 30, 42, 43, 30, 42, 4…
## $ type.name <chr> "Starting XI", "Starting XI", "Half S…
## $ possession_team.id <int> 217, 217, 217, 217, 217, 217, 217, 21…
## $ possession_team.name <chr> "Barcelona", "Barcelona", "Barcelona"…
## $ play_pattern.id <int> 1, 1, 1, 1, 9, 9, 9, 9, 9, 9, 9, 9, 9…
## $ play_pattern.name <chr> "Regular Play", "Regular Play", "Regu…
## $ team.id <int> 217, 213, 217, 213, 217, 217, 217, 21…
## $ team.name <chr> "Barcelona", "Sevilla", "Barcelona", …
## $ tactics.formation <int> 433, 4231, NA, NA, NA, NA, NA, NA, NA…
## $ tactics.lineup <list> [<data.frame[11 x 5]>], [<data.frame…
## $ player.id <int> NA, NA, NA, NA, 5246, 4320, 4320, 432…
## $ player.name <chr> NA, NA, NA, NA, "Luis Alberto Suárez …
## $ position.id <int> NA, NA, NA, NA, 23, 21, 21, 21, 2, 2,…
## $ position.name <chr> NA, NA, NA, NA, "Center Forward", "Le…
## $ pass.length <dbl> NA, NA, NA, NA, 2.10000, NA, NA, 35.1…
## $ pass.angle <dbl> NA, NA, NA, NA, -1.570796, NA, NA, 2.…
## $ pass.end_location <list> <NULL>, <NULL>, <NULL>, <NULL>, <60.…
## $ pass.switch <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ pass.cross <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ pass.assisted_shot_id <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ pass.shot_assist <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ pass.no_touch <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ pass.goal_assist <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ pass.outswinging <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ pass.miscommunication <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ pass.aerial_won <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ pass.through_ball <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ pass.cut_back <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ pass.inswinging <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ pass.deflected <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ pass.recipient.id <int> NA, NA, NA, NA, 4320, NA, NA, 6400, N…
## $ pass.recipient.name <chr> NA, NA, NA, NA, "Neymar da Silva Sant…
## $ pass.height.id <int> NA, NA, NA, NA, 1, NA, NA, 1, NA, NA,…
## $ pass.height.name <chr> NA, NA, NA, NA, "Ground Pass", NA, NA…
## $ pass.body_part.id <int> NA, NA, NA, NA, 40, NA, NA, 40, NA, N…
## $ pass.body_part.name <chr> NA, NA, NA, NA, "Right Foot", NA, NA,…
## $ pass.type.id <int> NA, NA, NA, NA, 65, NA, NA, NA, NA, N…
## $ pass.type.name <chr> NA, NA, NA, NA, "Kick Off", NA, NA, N…
## $ pass.outcome.id <int> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ pass.outcome.name <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ pass.technique.id <int> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ pass.technique.name <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ carry.end_location <list> <NULL>, <NULL>, <NULL>, <NULL>, <NUL…
## $ ball_receipt.outcome.id <int> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ ball_receipt.outcome.name <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ clearance.right_foot <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ clearance.aerial_won <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ clearance.head <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ clearance.left_foot <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ clearance.other <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ clearance.body_part.id <int> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ clearance.body_part.name <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ duel.type.id <int> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ duel.type.name <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ duel.outcome.id <int> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ duel.outcome.name <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ interception.outcome.id <int> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ interception.outcome.name <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ ball_recovery.recovery_failure <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ dribble.overrun <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ dribble.nutmeg <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ dribble.outcome.id <int> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ dribble.outcome.name <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ goalkeeper.end_location <list> <NULL>, <NULL>, <NULL>, <NULL>, <NUL…
## $ goalkeeper.outcome.id <int> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ goalkeeper.outcome.name <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ goalkeeper.type.id <int> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ goalkeeper.type.name <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ goalkeeper.position.id <int> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ goalkeeper.position.name <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ goalkeeper.technique.id <int> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ goalkeeper.technique.name <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ goalkeeper.body_part.id <int> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ goalkeeper.body_part.name <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ shot.statsbomb_xg <dbl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ shot.end_location <list> <NULL>, <NULL>, <NULL>, <NULL>, <NUL…
## $ shot.key_pass_id <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ shot.freeze_frame <list> <NULL>, <NULL>, <NULL>, <NULL>, <NUL…
## $ shot.one_on_one <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ shot.open_goal <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ shot.aerial_won <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ shot.first_time <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ shot.technique.id <int> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ shot.technique.name <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ shot.body_part.id <int> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ shot.body_part.name <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ shot.type.id <int> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ shot.type.name <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ shot.outcome.id <int> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ shot.outcome.name <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ block.deflection <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ block.offensive <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ `50_50.outcome.id` <int> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ `50_50.outcome.name` <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ foul_committed.advantage <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ foul_committed.offensive <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ foul_committed.type.id <int> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ foul_committed.type.name <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ foul_committed.card.id <int> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ foul_committed.card.name <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ foul_won.advantage <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ foul_won.defensive <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ substitution.outcome.id <int> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ substitution.outcome.name <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ substitution.replacement.id <int> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ substitution.replacement.name <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ match_id <int> 265839, 265839, 265839, 265839, 26583…
## $ competition_id <int> 11, 11, 11, 11, 11, 11, 11, 11, 11, 1…
## $ season_id <int> 27, 27, 27, 27, 27, 27, 27, 27, 27, 2…
## $ pass.straight <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ shot.deflected <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ dribble.no_touch <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ bad_behaviour.card.id <int> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ bad_behaviour.card.name <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ miscontrol.aerial_won <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ injury_stoppage.in_chain <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ block.save_block <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ foul_won.penalty <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ foul_committed.penalty <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ goalkeeper.punched_out <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ ball_recovery.offensive <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ player_off.permanent <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ goalkeeper.success_in_play <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ goalkeeper.shot_saved_off_target <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ shot.saved_off_target <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ shot.redirect <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ goalkeeper.lost_out <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ goalkeeper.lost_in_play <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ goalkeeper.shot_saved_to_post <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ shot.saved_to_post <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ shot.follows_dribble <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ goalkeeper.success_out <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ half_end.early_video_end <lgl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ location.x <dbl> NA, NA, NA, NA, 60.0, 60.0, 60.0, 57.…
## $ location.y <dbl> NA, NA, NA, NA, 40.0, 37.9, 37.9, 39.…
## $ carry.end_location.x <dbl> NA, NA, NA, NA, NA, NA, 57.1, NA, NA,…
## $ carry.end_location.y <dbl> NA, NA, NA, NA, NA, NA, 39.0, NA, NA,…
## $ pass.end_location.x <dbl> NA, NA, NA, NA, 60.0, NA, NA, 39.9, N…
## $ pass.end_location.y <dbl> NA, NA, NA, NA, 37.9, NA, NA, 69.6, N…
## $ shot.end_location.x <dbl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ shot.end_location.y <dbl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ shot.end_location.z <dbl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ shot_impact_height <dbl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ player.name.GK <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ player.id.GK <int> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ location.x.GK <dbl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ location.y.GK <dbl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ DistToGoal <dbl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ DistToKeeper <dbl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ AngleToGoal <dbl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ AngleToKeeper <dbl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ AngleDeviation <dbl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ avevelocity <dbl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ DistSGK <dbl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ density <dbl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ density.incone <dbl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ distance.ToD1 <dbl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ distance.ToD2 <dbl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ AttackersBehindBall <dbl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ DefendersBehindBall <dbl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ DefendersInCone <dbl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ InCone.GK <dbl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ DefArea <dbl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ distance.ToD1.360 <dbl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ distance.ToD2.360 <dbl> NA, NA, NA, NA, NA, NA, NA, NA, NA, N…
## $ milliseconds <dbl> 0, 0, 0, 0, 509, 508, 508, 710, 855, …
## $ ElapsedTime <dbl> 0.000, 0.000, 0.000, 0.000, 0.509, 1.…
## $ StartOfPossession <dbl> 0.000, 0.000, 0.000, 0.000, 0.509, 0.…
## $ TimeInPoss <dbl> 0.000, 0.000, 0.000, 0.000, 0.000, 0.…
## $ TimeToPossEnd <dbl> 0.000, 0.000, 0.000, 0.000, 51.726, 5…
shots <- la_liga_events %>%
filter(type.name == "Shot")
team_xg_for <- shots %>%
group_by(team.name) %>%
summarise(shots = n(), xg = sum(shot.statsbomb_xg, na.rm = TRUE), .groups = "drop") %>%
rename(team = team.name)
match_teams <- la_liga_matches %>%
select(match_id, home_team.home_team_name, away_team.away_team_name)
match_team_xg <- shots %>%
group_by(match_id, team.name) %>%
summarise(match_xg = sum(shot.statsbomb_xg, na.rm = TRUE), .groups = "drop")
team_xg_against <- match_team_xg %>%
left_join(match_teams, by = "match_id") %>%
mutate(opponent = if_else(team.name == home_team.home_team_name, away_team.away_team_name, home_team.home_team_name)) %>%
group_by(opponent) %>%
summarise(xga = sum(match_xg, na.rm = TRUE), .groups = "drop") %>%
rename(team = opponent)
attacking_table <- season_table %>%
select(team, played, goals_for) %>%
left_join(team_xg_for, by = "team") %>%
left_join(team_xg_against, by = "team") %>%
mutate(
xG_per90 = round(xg / played, 2),
Gls_minus_xG_per90 = round((goals_for - xg) / played, 2),
xGA_per90 = round(xga / played, 2),
xGD_per90 = round((xg - xga) / played, 2),
Shots_per90 = round(shots / played, 2)
) %>%
select(team, xG_per90, Gls_minus_xG_per90, xGA_per90, xGD_per90, Shots_per90) %>%
arrange(desc(xGD_per90))
print(attacking_table, n = Inf)
## # A tibble: 20 × 6
## team xG_per90 Gls_minus_xG_per90 xGA_per90 xGD_per90 Shots_per90
## <chr> <dbl> <dbl> <dbl> <dbl> <dbl>
## 1 Barcelona 2.41 0.54 0.86 1.55 15.9
## 2 Real Madrid 2.13 0.77 1.17 0.96 18.9
## 3 Atlético Madrid 1.39 0.26 0.68 0.72 12.7
## 4 Sevilla 1.6 -0.26 1.19 0.41 12.4
## 5 Athletic Club 1.32 0.2 1.07 0.25 12.2
## 6 Celta Vigo 1.32 0.02 1.31 0.01 11.7
## 7 Málaga 1.14 -0.14 1.13 0.01 12
## 8 Eibar 1.32 -0.03 1.31 0.01 11.2
## 9 Real Sociedad 1.21 -0.03 1.21 0 12.3
## 10 Villarreal 0.98 0.18 1.05 -0.07 9.13
## 11 RC Deportivo La … 1.09 0.09 1.26 -0.17 12.0
## 12 Espanyol 1.17 -0.12 1.41 -0.24 10.5
## 13 Rayo Vallecano 1.33 0.04 1.58 -0.26 13.2
## 14 Valencia 1.23 -0.02 1.5 -0.27 10.8
## 15 Real Betis 1.05 -0.15 1.46 -0.42 10.7
## 16 Getafe 1.04 -0.06 1.47 -0.43 11.5
## 17 Sporting Gijón 1.08 -0.03 1.53 -0.46 10
## 18 Granada 1.05 0.16 1.53 -0.48 11.4
## 19 Las Palmas 0.97 0.21 1.52 -0.55 11.1
## 20 Levante UD 0.97 0.01 1.52 -0.55 11.7
Progressive pass isn’t a field StatsBomb provides directly, so it has to be defined by a distance-moved-toward-goal threshold. We shall use the standard definition: a pass qualifies as progressive if it moves the ball at least 30 yards closer to goal when both ends of the pass are in the team’s own half, at least 15 yards closer when it crosses from the team’s own half into the attacking half, or at least 10 yards closer when both ends are already in the attacking half. Flagging this since it’s a judgement call baked into the numbers, not a StatsBomb-labelled field like the others.
passes <- la_liga_events %>%
filter(type.name == "Pass")
passing_table <- passes %>%
group_by(team.name) %>%
summarise(
attempted = n(),
completed = sum(is.na(pass.outcome.name)),
crosses = sum(pass.cross, na.rm = TRUE),
key_passes = sum(pass.shot_assist, na.rm = TRUE),
.groups = "drop"
) %>%
rename(team = team.name)
progressive_passes <- passes %>%
filter(!is.na(location.x), !is.na(pass.end_location.x)) %>%
mutate(
progress = pass.end_location.x - location.x,
is_progressive = case_when(
location.x < 60 & pass.end_location.x < 60 ~ progress >= 30,
location.x < 60 & pass.end_location.x >= 60 ~ progress >= 15,
location.x >= 60 & pass.end_location.x >= 60 ~ progress >= 10,
TRUE ~ FALSE
)
) %>%
group_by(team.name) %>%
summarise(progressive = sum(is_progressive, na.rm = TRUE), .groups = "drop") %>%
rename(team = team.name)
passing_creation_table <- season_table %>%
select(team, played) %>%
left_join(passing_table, by = "team") %>%
left_join(progressive_passes, by = "team") %>%
mutate(
Passing_Pct = round(100 * completed / attempted, 1),
Progressive_Passes_per90 = round(progressive / played, 2),
Crosses_per90 = round(crosses / played, 2),
Key_Passes_per90 = round(key_passes / played, 2)
) %>%
select(team, Passing_Pct, Progressive_Passes_per90, Crosses_per90, Key_Passes_per90) %>%
arrange(desc(Passing_Pct))
print(passing_creation_table, n = Inf)
## # A tibble: 20 × 5
## team Passing_Pct Progressive_Passes_p…¹ Crosses_per90 Key_Passes_per90
## <chr> <dbl> <dbl> <dbl> <dbl>
## 1 Barcelona 86.1 134. 12 9.24
## 2 Real Madrid 84.6 130. 18.0 12.3
## 3 Las Palmas 80.3 120. 8.63 7.39
## 4 Celta Vigo 78.6 141. 9.47 7.13
## 5 Valencia 76.8 127. 12.3 6.89
## 6 Atlético M… 76.4 127. 13.1 8.11
## 7 Villarreal 74.9 124. 9 5.82
## 8 Real Socie… 74.9 139 17.0 7.84
## 9 Rayo Valle… 74.9 133. 15.8 8.26
## 10 Sevilla 74.8 123. 16.6 8.18
## 11 Getafe 73.8 118. 11.0 7.26
## 12 Athletic C… 73.5 141. 14.1 7.37
## 13 Real Betis 73.3 118. 10.5 6.79
## 14 Espanyol 73.1 122. 11.3 6.34
## 15 RC Deporti… 73 137. 12.1 7.76
## 16 Levante UD 72.9 125. 12.5 7.79
## 17 Granada 72.2 124. 10.4 6.55
## 18 Málaga 71.8 132. 12.8 8.08
## 19 Sporting G… 70.7 123. 12.7 6.08
## 20 Eibar 67.2 146. 13.4 6.47
## # ℹ abbreviated name: ¹Progressive_Passes_per90
attacking_table <- attacking_table %>%
mutate(across(where(is.numeric), ~formatC(.x, format = "f", digits = 2)))
passing_creation_table <- passing_creation_table %>%
mutate(
Passing_Pct = round(as.numeric(Passing_Pct), 2) # in case this was carried over at 1 decimal
) %>%
mutate(across(where(is.numeric), ~formatC(.x, format = "f", digits = 2)))
print(attacking_table, n = Inf)
## # A tibble: 20 × 6
## team xG_per90 Gls_minus_xG_per90 xGA_per90 xGD_per90 Shots_per90
## <chr> <chr> <chr> <chr> <chr> <chr>
## 1 Barcelona 2.41 0.54 0.86 1.55 15.89
## 2 Real Madrid 2.13 0.77 1.17 0.96 18.87
## 3 Atlético Madrid 1.39 0.26 0.68 0.72 12.71
## 4 Sevilla 1.60 -0.26 1.19 0.41 12.42
## 5 Athletic Club 1.32 0.20 1.07 0.25 12.24
## 6 Celta Vigo 1.32 0.02 1.31 0.01 11.74
## 7 Málaga 1.14 -0.14 1.13 0.01 12.00
## 8 Eibar 1.32 -0.03 1.31 0.01 11.16
## 9 Real Sociedad 1.21 -0.03 1.21 0.00 12.26
## 10 Villarreal 0.98 0.18 1.05 -0.07 9.13
## 11 RC Deportivo La … 1.09 0.09 1.26 -0.17 12.03
## 12 Espanyol 1.17 -0.12 1.41 -0.24 10.50
## 13 Rayo Vallecano 1.33 0.04 1.58 -0.26 13.18
## 14 Valencia 1.23 -0.02 1.50 -0.27 10.79
## 15 Real Betis 1.05 -0.15 1.46 -0.42 10.66
## 16 Getafe 1.04 -0.06 1.47 -0.43 11.47
## 17 Sporting Gijón 1.08 -0.03 1.53 -0.46 10.00
## 18 Granada 1.05 0.16 1.53 -0.48 11.42
## 19 Las Palmas 0.97 0.21 1.52 -0.55 11.13
## 20 Levante UD 0.97 0.01 1.52 -0.55 11.66
print(passing_creation_table, n = Inf)
## # A tibble: 20 × 5
## team Passing_Pct Progressive_Passes_p…¹ Crosses_per90 Key_Passes_per90
## <chr> <chr> <chr> <chr> <chr>
## 1 Barcelona 86.10 134.47 12.00 9.24
## 2 Real Madrid 84.60 129.76 17.97 12.29
## 3 Las Palmas 80.30 119.87 8.63 7.39
## 4 Celta Vigo 78.60 141.21 9.47 7.13
## 5 Valencia 76.80 126.74 12.29 6.89
## 6 Atlético M… 76.40 127.26 13.13 8.11
## 7 Villarreal 74.90 123.58 9.00 5.82
## 8 Real Socie… 74.90 139.00 16.97 7.84
## 9 Rayo Valle… 74.90 132.82 15.76 8.26
## 10 Sevilla 74.80 122.68 16.63 8.18
## 11 Getafe 73.80 118.29 11.05 7.26
## 12 Athletic C… 73.50 140.71 14.13 7.37
## 13 Real Betis 73.30 117.63 10.47 6.79
## 14 Espanyol 73.10 121.92 11.32 6.34
## 15 RC Deporti… 73.00 136.95 12.08 7.76
## 16 Levante UD 72.90 125.47 12.50 7.79
## 17 Granada 72.20 124.34 10.42 6.55
## 18 Málaga 71.80 131.95 12.79 8.08
## 19 Sporting G… 70.70 123.05 12.74 6.08
## 20 Eibar 67.20 146.50 13.42 6.47
## # ℹ abbreviated name: ¹Progressive_Passes_per90
pressures_ints <- la_liga_events %>%
filter(type.name %in% c("Pressure", "Interception")) %>%
group_by(team.name, type.name) %>%
summarise(n = n(), .groups = "drop") %>%
tidyr::pivot_wider(names_from = type.name, values_from = n, values_fill = 0) %>%
rename(team = team.name, pressures = Pressure, interceptions = Interception)
high_turnovers <- la_liga_events %>%
filter(type.name == "Ball Recovery", location.x >= 80) %>%
group_by(team.name) %>%
summarise(high_turnovers = n(), .groups = "drop") %>%
rename(team = team.name)
defensive_events_zone <- la_liga_events %>%
filter(type.name == "Pressure" | (type.name == "Duel" & duel.type.name == "Tackle") | type.name == "Interception",
location.x < 80) %>%
group_by(match_id, team.name) %>%
summarise(def_actions = n(), .groups = "drop")
opp_passes_zone <- passes %>%
filter(location.x < 80) %>%
group_by(match_id, team.name) %>%
summarise(passes_in_zone = n(), .groups = "drop")
ppda_table <- defensive_events_zone %>%
left_join(match_teams, by = "match_id") %>%
mutate(opponent = if_else(team.name == home_team.home_team_name, away_team.away_team_name, home_team.home_team_name)) %>%
left_join(opp_passes_zone, by = c("match_id", "opponent" = "team.name")) %>%
group_by(team = team.name) %>%
summarise(
total_def_actions = sum(def_actions, na.rm = TRUE),
total_opp_passes = sum(passes_in_zone, na.rm = TRUE),
.groups = "drop"
) %>%
mutate(PPDA = round(total_opp_passes / total_def_actions, 2))
defensive_table <- season_table %>%
select(team, played) %>%
left_join(pressures_ints, by = "team") %>%
left_join(high_turnovers, by = "team") %>%
left_join(ppda_table %>% select(team, PPDA), by = "team") %>%
mutate(
Pressures_per90 = round(pressures / played, 2),
High_Turnovers_per90 = round(high_turnovers / played, 2),
Interceptions_per90 = round(interceptions / played, 2)
) %>%
select(team, Pressures_per90, High_Turnovers_per90, Interceptions_per90, PPDA) %>%
mutate(across(where(is.numeric), ~formatC(.x, format = "f", digits = 2))) %>%
arrange(PPDA)
print(defensive_table, n = Inf)
## # A tibble: 20 × 5
## team Pressures_per90 High_Turnovers_per90 Interceptions_per90 PPDA
## <chr> <chr> <chr> <chr> <chr>
## 1 Celta Vigo 154.63 11.45 18.29 1.96
## 2 Valencia 172.24 9.34 17.08 2.02
## 3 Rayo Vallecano 150.13 12.45 14.92 2.16
## 4 Getafe 162.68 11.53 17.95 2.28
## 5 Real Sociedad 152.00 12.32 18.26 2.33
## 6 Barcelona 122.61 13.11 9.11 2.35
## 7 Espanyol 155.45 10.05 16.11 2.40
## 8 Real Betis 147.13 11.26 16.47 2.41
## 9 Sevilla 139.66 11.95 13.32 2.45
## 10 Athletic Club 164.29 12.76 17.42 2.46
## 11 Las Palmas 137.18 9.29 16.13 2.46
## 12 Granada 151.24 10.45 15.34 2.46
## 13 RC Deportivo … 151.63 11.21 17.95 2.48
## 14 Sporting Gijón 157.42 9.92 18.26 2.50
## 15 Eibar 164.39 12.66 16.55 2.54
## 16 Atlético Madr… 165.79 14.39 17.26 2.56
## 17 Real Madrid 132.00 12.66 16.84 2.61
## 18 Málaga 138.32 12.53 16.32 2.61
## 19 Levante UD 142.00 11.39 16.05 2.70
## 20 Villarreal 141.34 9.50 16.00 2.85
Chart 1 — Goals vs xG scatter
library(ggplot2)
library(ggrepel)
goals_vs_xg <- season_table %>%
select(team, goals_for) %>%
left_join(team_xg_for %>% select(team, xg), by = "team")
ggplot(goals_vs_xg, aes(x = xg, y = goals_for)) +
geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "grey60") +
geom_point(size = 2, color = "#1F4E79") +
ggrepel::geom_text_repel(aes(label = team), size = 2.1) +
labs(
title = "La Liga 2015/16 — Goals Scored vs Expected Goals (xG)",
x = "Expected Goals (xG)", y = "Goals Scored"
) +
theme_minimal(base_size = 12)
Chart 2 - Average ball recovery position by team
recovery_position <- la_liga_events %>%
filter(type.name == "Ball Recovery", !is.na(location.x)) %>%
group_by(team.name) %>%
summarise(avg_recovery_x = mean(location.x, na.rm = TRUE), .groups = "drop") %>%
rename(team = team.name) %>%
arrange(avg_recovery_x)
ggplot(recovery_position, aes(x = avg_recovery_x, y = reorder(team, avg_recovery_x))) +
geom_col(fill = "#1F4E79") +
geom_vline(xintercept = 60, linetype = "dashed", color = "grey50") +
labs(
title = "La Liga 2015/16 — Average Ball Recovery Position by Team",
subtitle = "Higher value = recoveries won further up the pitch (dashed line = halfway)",
x = "Average recovery x-position (0 = own goal line, 120 = opponent's goal line)",
y = NULL
) +
theme_minimal(base_size = 12)