About the data source

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.

Load the packages we need

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)

Choosing the competition to analyse

Finding the competition and season we need

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

Pulling every match in the season

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

Season analysis

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.

Pulling the event-level data.

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…

Expected Goals & Attacking Output

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

Passing & Creation section

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

Defensive Actions section

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

Visualizations

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)