I came across this interesting dataset on Kaggle related to the football simulation video game FIFA 20, with a very detailed breakdown of attributes of over 18,000 football players. It’s been a while since I’ve played a FIFA game, and an even longer time since I last worn my soccer boots, but this seemed like an excellent data set for some Exploratory Data Analysis. I also showcase ridgeline plots and network graphs with wrangled data, and take a stab at implementing and interpreting unsupervised machine learning in the form of Principal Components Analysis on our wide data set.
Libraries
library(tidyverse) #data wrangling
library(fishualize) #visualization
theme_set(theme_minimal())
Loading The Data
data <- read_csv("https://raw.githubusercontent.com/DesmondChoy/glaciers/master/content/post/players_20.csv")
glimpse(data)
## Rows: 18,278
## Columns: 104
## $ sofifa_id <dbl> 158023, 20801, 190871, 200389, 183277, 1...
## $ player_url <chr> "https://sofifa.com/player/158023/lionel...
## $ short_name <chr> "L. Messi", "Cristiano Ronaldo", "Neymar...
## $ long_name <chr> "Lionel Andrés Messi Cuccittini", "Crist...
## $ age <dbl> 32, 34, 27, 26, 28, 28, 27, 27, 33, 27, ...
## $ dob <date> 1987-06-24, 1985-02-05, 1992-02-05, 199...
## $ height_cm <dbl> 170, 187, 175, 188, 175, 181, 187, 193, ...
## $ weight_kg <dbl> 72, 83, 68, 87, 74, 70, 85, 92, 66, 71, ...
## $ nationality <chr> "Argentina", "Portugal", "Brazil", "Slov...
## $ club <chr> "FC Barcelona", "Juventus", "Paris Saint...
## $ overall <dbl> 94, 93, 92, 91, 91, 91, 90, 90, 90, 90, ...
## $ potential <dbl> 94, 93, 92, 93, 91, 91, 93, 91, 90, 90, ...
## $ value_eur <dbl> 95500000, 58500000, 105500000, 77500000,...
## $ wage_eur <dbl> 565000, 405000, 290000, 125000, 470000, ...
## $ player_positions <chr> "RW, CF, ST", "ST, LW", "LW, CAM", "GK",...
## $ preferred_foot <chr> "Left", "Right", "Right", "Right", "Righ...
## $ international_reputation <dbl> 5, 5, 5, 3, 4, 4, 3, 3, 4, 3, 3, 3, 3, 3...
## $ weak_foot <dbl> 4, 4, 5, 3, 4, 5, 4, 3, 4, 3, 4, 3, 4, 3...
## $ skill_moves <dbl> 4, 5, 5, 1, 4, 4, 1, 2, 4, 4, 5, 2, 3, 1...
## $ work_rate <chr> "Medium/Low", "High/Low", "High/Medium",...
## $ body_type <chr> "Messi", "C. Ronaldo", "Neymar", "Normal...
## $ real_face <chr> "Yes", "Yes", "Yes", "Yes", "Yes", "Yes"...
## $ release_clause_eur <dbl> 195800000, 96500000, 195200000, 16470000...
## $ player_tags <chr> "#Dribbler, #Distance Shooter, #Crosser,...
## $ team_position <chr> "RW", "LW", "CAM", "GK", "LW", "RCM", "G...
## $ team_jersey_number <dbl> 10, 7, 10, 13, 7, 17, 1, 4, 10, 11, 7, 2...
## $ loaned_from <chr> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, ...
## $ joined <date> 2004-07-01, 2018-07-10, 2017-08-03, 201...
## $ contract_valid_until <dbl> 2021, 2022, 2022, 2023, 2024, 2023, 2022...
## $ nation_position <chr> NA, "LS", "LW", "GK", "LF", "RCM", "SUB"...
## $ nation_jersey_number <dbl> NA, 7, 10, 1, 10, 7, 22, 4, NA, 10, 10, ...
## $ pace <dbl> 87, 90, 91, NA, 91, 76, NA, 77, 74, 93, ...
## $ shooting <dbl> 92, 93, 85, NA, 83, 86, NA, 60, 76, 86, ...
## $ passing <dbl> 92, 82, 87, NA, 86, 92, NA, 70, 89, 81, ...
## $ dribbling <dbl> 96, 89, 95, NA, 94, 86, NA, 71, 89, 89, ...
## $ defending <dbl> 39, 35, 32, NA, 35, 61, NA, 90, 72, 45, ...
## $ physic <dbl> 66, 78, 58, NA, 66, 78, NA, 86, 66, 74, ...
## $ gk_diving <dbl> NA, NA, NA, 87, NA, NA, 88, NA, NA, NA, ...
## $ gk_handling <dbl> NA, NA, NA, 92, NA, NA, 85, NA, NA, NA, ...
## $ gk_kicking <dbl> NA, NA, NA, 78, NA, NA, 88, NA, NA, NA, ...
## $ gk_reflexes <dbl> NA, NA, NA, 89, NA, NA, 90, NA, NA, NA, ...
## $ gk_speed <dbl> NA, NA, NA, 52, NA, NA, 45, NA, NA, NA, ...
## $ gk_positioning <dbl> NA, NA, NA, 90, NA, NA, 88, NA, NA, NA, ...
## $ player_traits <chr> "Beat Offside Trap, Argues with Official...
## $ attacking_crossing <dbl> 88, 84, 87, 13, 81, 93, 18, 53, 86, 79, ...
## $ attacking_finishing <dbl> 95, 94, 87, 11, 84, 82, 14, 52, 72, 90, ...
## $ attacking_heading_accuracy <dbl> 70, 89, 62, 15, 61, 55, 11, 86, 55, 59, ...
## $ attacking_short_passing <dbl> 92, 83, 87, 43, 89, 92, 61, 78, 92, 84, ...
## $ attacking_volleys <dbl> 88, 87, 87, 13, 83, 82, 14, 45, 76, 79, ...
## $ skill_dribbling <dbl> 97, 89, 96, 12, 95, 86, 21, 70, 87, 89, ...
## $ skill_curve <dbl> 93, 81, 88, 13, 83, 85, 18, 60, 85, 83, ...
## $ skill_fk_accuracy <dbl> 94, 76, 87, 14, 79, 83, 12, 70, 78, 69, ...
## $ skill_long_passing <dbl> 92, 77, 81, 40, 83, 91, 63, 81, 88, 75, ...
## $ skill_ball_control <dbl> 96, 92, 95, 30, 94, 91, 30, 76, 92, 89, ...
## $ movement_acceleration <dbl> 91, 89, 94, 43, 94, 77, 38, 74, 77, 94, ...
## $ movement_sprint_speed <dbl> 84, 91, 89, 60, 88, 76, 50, 79, 71, 92, ...
## $ movement_agility <dbl> 93, 87, 96, 67, 95, 78, 37, 61, 92, 91, ...
## $ movement_reactions <dbl> 95, 96, 92, 88, 90, 91, 86, 88, 89, 92, ...
## $ movement_balance <dbl> 95, 71, 84, 49, 94, 76, 43, 53, 93, 88, ...
## $ power_shot_power <dbl> 86, 95, 80, 59, 82, 91, 66, 81, 79, 80, ...
## $ power_jumping <dbl> 68, 95, 61, 78, 56, 63, 79, 90, 68, 69, ...
## $ power_stamina <dbl> 75, 85, 81, 41, 84, 89, 35, 75, 85, 85, ...
## $ power_strength <dbl> 68, 78, 49, 78, 63, 74, 78, 92, 58, 73, ...
## $ power_long_shots <dbl> 94, 93, 84, 12, 80, 90, 10, 64, 82, 84, ...
## $ mentality_aggression <dbl> 48, 63, 51, 34, 54, 76, 43, 82, 62, 63, ...
## $ mentality_interceptions <dbl> 40, 29, 36, 19, 41, 61, 22, 89, 82, 55, ...
## $ mentality_positioning <dbl> 94, 95, 87, 11, 87, 88, 11, 47, 79, 92, ...
## $ mentality_vision <dbl> 94, 82, 90, 65, 89, 94, 70, 65, 91, 84, ...
## $ mentality_penalties <dbl> 75, 85, 90, 11, 88, 79, 25, 62, 82, 77, ...
## $ mentality_composure <dbl> 96, 95, 94, 68, 91, 91, 70, 89, 92, 91, ...
## $ defending_marking <dbl> 33, 28, 27, 27, 34, 68, 25, 91, 68, 38, ...
## $ defending_standing_tackle <dbl> 37, 32, 26, 12, 27, 58, 13, 92, 76, 43, ...
## $ defending_sliding_tackle <dbl> 26, 24, 29, 18, 22, 51, 10, 85, 71, 41, ...
## $ goalkeeping_diving <dbl> 6, 7, 9, 87, 11, 15, 88, 13, 13, 14, 13,...
## $ goalkeeping_handling <dbl> 11, 11, 9, 92, 12, 13, 85, 10, 9, 14, 5,...
## $ goalkeeping_kicking <dbl> 15, 15, 15, 78, 6, 5, 88, 13, 7, 9, 7, 7...
## $ goalkeeping_positioning <dbl> 14, 14, 15, 90, 8, 10, 88, 11, 14, 11, 1...
## $ goalkeeping_reflexes <dbl> 8, 11, 11, 89, 8, 13, 90, 11, 9, 14, 6, ...
## $ ls <chr> "89+2", "91+3", "84+3", NA, "83+3", "82+...
## $ st <chr> "89+2", "91+3", "84+3", NA, "83+3", "82+...
## $ rs <chr> "89+2", "91+3", "84+3", NA, "83+3", "82+...
## $ lw <chr> "93+2", "89+3", "90+3", NA, "89+3", "87+...
## $ lf <chr> "93+2", "90+3", "89+3", NA, "88+3", "87+...
## $ cf <chr> "93+2", "90+3", "89+3", NA, "88+3", "87+...
## $ rf <chr> "93+2", "90+3", "89+3", NA, "88+3", "87+...
## $ rw <chr> "93+2", "89+3", "90+3", NA, "89+3", "87+...
## $ lam <chr> "93+2", "88+3", "90+3", NA, "89+3", "88+...
## $ cam <chr> "93+2", "88+3", "90+3", NA, "89+3", "88+...
## $ ram <chr> "93+2", "88+3", "90+3", NA, "89+3", "88+...
## $ lm <chr> "92+2", "88+3", "89+3", NA, "89+3", "88+...
## $ lcm <chr> "87+2", "81+3", "82+3", NA, "83+3", "87+...
## $ cm <chr> "87+2", "81+3", "82+3", NA, "83+3", "87+...
## $ rcm <chr> "87+2", "81+3", "82+3", NA, "83+3", "87+...
## $ rm <chr> "92+2", "88+3", "89+3", NA, "89+3", "88+...
## $ lwb <chr> "68+2", "65+3", "66+3", NA, "66+3", "77+...
## $ ldm <chr> "66+2", "61+3", "61+3", NA, "63+3", "77+...
## $ cdm <chr> "66+2", "61+3", "61+3", NA, "63+3", "77+...
## $ rdm <chr> "66+2", "61+3", "61+3", NA, "63+3", "77+...
## $ rwb <chr> "68+2", "65+3", "66+3", NA, "66+3", "77+...
## $ lb <chr> "63+2", "61+3", "61+3", NA, "61+3", "73+...
## $ lcb <chr> "52+2", "53+3", "46+3", NA, "49+3", "66+...
## $ cb <chr> "52+2", "53+3", "46+3", NA, "49+3", "66+...
## $ rcb <chr> "52+2", "53+3", "46+3", NA, "49+3", "66+...
## $ rb <chr> "63+2", "61+3", "61+3", NA, "61+3", "73+...
Without a data dictionary, I went to the website where the data was scrapped from and looked into a player’s profile to make sense of the columns. Removing some columns that we won’t be using, we’re left with 76 columns and over 18,000 rows of data.
data <- data %>%
select(-c(player_url, real_face, ls:rb))
dim(data)
## [1] 18278 76
Data Discrepancies
I note the website where the data was scrapped from has its data updated once every few days. So there might be discrepancies if you’re comparing the numbers in this data set to the latest ones featured on the website.
Additionally, I was doing a little fact-checking online and some columns don’t tally to real-life figures (understandably, FIFA 20 is a video game). For instance, the player with the highest release clause in real life is Karim Benzema at GBP846m. In this data set however, even after accounting for currency translations, Benzema does not even appear in the top 10 list for release clauses.
data %>%
select(short_name, club, release_clause_eur, wage_eur) %>%
arrange(desc(release_clause_eur))
## # A tibble: 18,278 x 4
## short_name club release_clause_eur wage_eur
## <chr> <chr> <dbl> <dbl>
## 1 L. Messi FC Barcelona 195800000 565000
## 2 Neymar Jr Paris Saint-Germain 195200000 290000
## 3 K. Mbappé Paris Saint-Germain 191700000 155000
## 4 E. Hazard Real Madrid 184500000 470000
## 5 K. De Bruyne Manchester City 166500000 370000
## 6 J. Oblak Atlético Madrid 164700000 125000
## 7 H. Kane Tottenham Hotspur 159800000 220000
## 8 V. van Dijk Liverpool 150200000 200000
## 9 M. Salah Liverpool 148900000 240000
## 10 M. ter Stegen FC Barcelona 143400000 250000
## # ... with 18,268 more rows
data %>%
select(short_name, club, release_clause_eur, wage_eur) %>%
filter(str_detect(short_name, "Benzema"))
## # A tibble: 1 x 4
## short_name club release_clause_eur wage_eur
## <chr> <chr> <dbl> <dbl>
## 1 K. Benzema Real Madrid 92300000 285000
Exploratory Data Analysis
Going through the data, it looks largely sanitized. One issue I noted is that a player can be assigned to many different player_positions.
data %>%
select(short_name, team_position, player_positions) %>%
sample_n(10)
## # A tibble: 10 x 3
## short_name team_position player_positions
## <chr> <chr> <chr>
## 1 M. Vihmann LCB CB
## 2 N. Al Ghamdi RES GK
## 3 J. Mendes SUB ST, RM
## 4 C. Gasper LB LB, CB
## 5 G. Ortíz SUB CB
## 6 D. Russo SUB GK
## 7 P. Vaquizo <NA> RW, RM
## 8 I. Opara RCB CB
## 9 A. Al Owayshir SUB GK
## 10 J. Bumbu RES CM, CDM
Let’s clean the data further by lumping the player’s position into broader categories - Goalkeeper (G), Defender (D), Defensive Midfielder (DM), Midfielder (M), Attacking Midfielder (AM), Forward (F) and Striker (ST). I’ll do this by inferring from the player’s player_positions. Fewer categories will benefit data visualization.
data <- data %>%
mutate(
position = case_when(
str_detect(player_positions, "GK") ~ "GK",
str_detect(player_positions, "LCB|CB|RCB|LB|RB") ~ "D",
str_detect(player_positions, "LWB|LDM|CDM|RDM|RWB") ~ "DM",
str_detect(player_positions, "LM|LCM|CM|RCM|RM") ~ "M",
str_detect(player_positions, "LAM|CAM|RAM") ~ "AM",
str_detect(player_positions, "LW|LF|CF|RF|RW") ~ "F",
str_detect(player_positions, "LS|ST|RS") ~ "ST"
)) %>%
mutate(position = fct_relevel(position, "GK", "D", "DM", "M", "AM", "F", "ST"))
Let’s check if position is mapped correctly. Cross referencing selected columns and using personal albeit rusty domain knowledge, it looks correct - Goalkeepers only have gk_diving points, Defenders have higher defending points relative to shooting and dribbling, and the more offensive positions like Forwards and Strikers score higher in the shooting category.
set.seed(2020)
#Checking if position is extracted correctly
data %>%
select(short_name,
position,
player_positions,
shooting:dribbling,
gk_diving) %>%
sample_n(10) %>%
arrange(position)
## # A tibble: 10 x 7
## short_name position player_positions shooting passing dribbling gk_diving
## <chr> <fct> <chr> <dbl> <dbl> <dbl> <dbl>
## 1 R. Ruiter GK GK NA NA NA 66
## 2 F. Woodman GK GK NA NA NA 66
## 3 F. Dimarco D LB, LM 33 66 61 NA
## 4 A. Espino D LB 59 62 64 NA
## 5 M. Pietrowski D RB 55 63 64 NA
## 6 P. Martinez D CB 45 63 69 NA
## 7 N. Pépé M RM, RW, ST 81 75 85 NA
## 8 Bruno Costa M CM 63 68 72 NA
## 9 D. Butterworth F ST, CF 56 45 54 NA
## 10 Li Haowen ST ST 56 39 51 NA
What is the overall score distributed across all players?
data %>%
ggplot(aes(overall)) +
geom_histogram(binwidth = 2, fill = "#00AFBB", color="#e9ecef", alpha=0.6) +
labs(
x = "Overall Score",
y = "Count",
title = "Using A Histogram to Analyze Distribution of Overall Scores",
subtitle = "The Overall scores variable has a normal bell-shaped distribution, implying the players are graded on a curve",
caption = "Source: FIFA 20 Complete Player Data Set"
) +
theme(
plot.title = element_text(face = "bold", size = 20),
plot.subtitle = element_text(size = 17)
)

Players Without Club Contracts
I noticed the variables team_position and team_jersey_number have missing values. We can ascertain the relationship between both columns by counting the NAs and checking if they’re equal.
data %>%
select(contains("team")) %>%
map_df(~sum(is.na(.)))
## # A tibble: 1 x 2
## team_position team_jersey_number
## <int> <int>
## 1 240 240
Why do certain players have no team_position ?
data %>%
filter(is.na(team_position)) %>%
select(
short_name,
club,
nation_position,
player_positions,
team_position,
team_jersey_number,
value_eur
) %>%
head(10)
## # A tibble: 10 x 7
## short_name club nation_position player_positions team_position
## <chr> <chr> <chr> <chr> <chr>
## 1 E. Scheti~ Urug~ RCB CB <NA>
## 2 J. Sildero Urug~ RM CAM, RM <NA>
## 3 J. Frenda~ Urug~ LCB CB, CDM <NA>
## 4 S. Ardero Urug~ LM CAM, LM, LW <NA>
## 5 L. Dálves Urug~ ST ST, CF <NA>
## 6 S. Mandíq~ Ecua~ RS ST <NA>
## 7 J. Serend~ Urug~ GK GK <NA>
## 8 M. Baldona Urug~ RDM CDM, CM <NA>
## 9 M. Nérez Urug~ LB LB, LM <NA>
## 10 A. Lunev Russ~ SUB GK <NA>
## # ... with 2 more variables: team_jersey_number <dbl>, value_eur <dbl>
It seems these players entered the database because they represented their country but aren’t contracted with any clubs. If they are good enough to represent their country, shouldn’t they at least be good enough for some entry-level clubs? As a player-manager in FIFA 20, is this an untapped pool of talent waiting to be exploited?
Let’s explore if this phenomenon is restricted to only certain geographical areas.
library(countrycode)
library(tidytext)
data %>%
filter(is.na(team_position)) %>%
mutate(continent = countrycode(club,
origin = "country.name",
destination = "continent")) %>%
mutate(continent = replace_na(continent, "Europe")) %>%
add_count(club) %>%
mutate(club = reorder_within(club, n, continent)) %>%
ggplot(aes(club, n / n, fill = continent)) +
geom_col(show.legend = FALSE) +
coord_flip() +
facet_wrap( ~ continent, scales = "free_y") +
scale_x_reordered() +
scale_fill_fish_d(option = "Antennarius_commerson") +
labs(
x = "",
y = "",
title = "Continents With Highest # Of International Players With No Clubs/Contracts",
subtitle = "A higher percentage of players come from Africa, Americas and Europe.\nPerhaps these are countries with no well-established soccer leagues?",
caption = "Source: FIFA 20 Complete Player Data Set"
) +
theme(
plot.title = element_text(face = "bold", size = 20),
plot.subtitle = element_text(size = 17),
strip.background = element_blank(),
strip.text = element_text(face = "bold", size = 15),
)

Are these players without contracts because overall performance is trailing far behind potential? Let’s analyze it further across position.
library(ggridges)
data %>%
filter(is.na(team_position)) %>%
pivot_longer(overall:potential, names_to = "rating", values_to = "values") %>%
mutate(rating = as_factor(rating)) %>%
ggplot(aes(values, position, fill = rating)) +
geom_density_ridges(scale = 1, alpha = 0.5) +
scale_fill_fish_d(option = "Antennarius_commerson") +
theme_ridges(center_axis_labels = TRUE) +
labs(
x = NULL,
y = NULL,
fill = "Score",
title = "Players Without Contracts: Comparing Overall vs Potential Score Across Positions",
subtitle = "Not playing up to one's potential seems to be pervasive across all field positions",
caption = "Source: FIFA 20 Complete Player Data Set"
) +
theme(
plot.title = element_text(face = "bold", size = 20),
plot.subtitle = element_text(size = 17),
strip.background = element_blank(),
strip.text = element_text(face = "bold", size = 15),
legend.title = element_text(face = "bold", size = 15)
)

Testing our hypothesis if it is indeed a performance gap that is resulting in these players to be contract-less, can we then expect players with contracts to be playing much closer to their potential?
data %>%
filter(!is.na(team_position)) %>%
pivot_longer(overall:potential, names_to = "rating", values_to = "values") %>%
mutate(rating = as_factor(rating)) %>%
ggplot(aes(values, position, fill = rating)) +
geom_density_ridges(scale = 1, alpha = 0.5) +
scale_fill_fish_d(option = "Antennarius_commerson") +
theme_ridges(center_axis_labels = TRUE) +
labs(
x = NULL,
y = NULL,
fill = "Score",
title = "Players With Contracts: Comparing Overall vs Potential Score Across Positions",
subtitle = "Ironically, the gap between overall and potential performance is even wider for players with club contracts,\nalbeit with longer right tails. This could also be due to a larger sample size.",
caption = "Source: FIFA 20 Complete Player Data Set"
) +
theme(
plot.title = element_text(face = "bold", size = 20),
plot.subtitle = element_text(size = 17),
strip.background = element_blank(),
strip.text = element_text(face = "bold", size = 15),
legend.title = element_text(face = "bold", size = 15)
)

Another way to compare performance between contracted and non-contracted players would be to use skill sets such as pace, dribbling and shooting that are applicable across all position (excluding Goalkeepers because they have their own unique skill sets such as diving).
labels <-
c("D" = "Defender",
"DM" = "Defensive Midfielder",
"M" = "Midfielder",
"AM" = "Attacking Midfielder",
"F" = "Forward",
"ST" = "Striker")
data %>%
mutate(contracted = case_when(is.na(team_position) ~ "No",
TRUE ~ "Yes")) %>%
pivot_longer(pace:physic, names_to = "skills", values_to = "values") %>%
filter(!position == "GK") %>%
mutate(skills = reorder_within(skills, values, position)) %>%
ggplot(aes(values, skills, fill = contracted)) +
geom_boxplot() +
geom_text(
data = . %>%
group_by(position, skills) %>%
mutate(percentile = values > quantile(values, 0.99999, na.rm = TRUE)) %>%
filter(percentile == 1),
aes(label = paste(short_name, "\n", str_wrap(club, width = 15), sep = ""), colour = position),
size = 3,
check_overlap = TRUE,
fontface = "bold",
vjust = "inward", hjust = "inward",
show.legend = F
) +
facet_wrap( ~ position, scales = "free_y", labeller = as_labeller(labels)) +
scale_y_reordered() +
scale_colour_fish_d(option = "Antennarius_commerson") +
scale_fill_fish_d(option = "Hypsypops_rubicundus") +
labs(
x = "Values",
y = "Skills",
fill = "Under Contract?",
title = "Players With And Without Contracts In FIFA 20 - How Do They Compare?",
subtitle = "Using traits like dribbling, passing and shooting, players with only international appearances and\nno club contracts don't appear to be less skilled.\nHowever there are significantly more outliers among players with contracts.\nThe extreme outliers and their clubs are highlighted.",
caption = "Source: FIFA 20 Complete Player Data Set"
) +
theme(
plot.title = element_text(face = "bold", size = 20),
plot.subtitle = element_text(size = 17),
strip.background = element_blank(),
strip.text = element_text(face = "bold", size = 15),
legend.title = element_text(face = "bold", size = 15),
legend.position = "top"
)

Using boxplots to identify medians, variance and outliers, I conclude that there are other reasons other than overall score and generic skills driving the non-contractual state of these players.
Take for example Defenders - we want a higher defending score and the median for non-contract players is higher than players with contracts. Similarly, the median for passing scores is higher for non-contract players, and the same can be said for shooting scores for Forwards and Strikers.
One other thing you might have observed is that there are significantly more outliers for contracted players. The crème de la crème, so to speak.
What Makes A Player Stand Out From The Crowd?
Two columns, player_tags and player_traits, contain text descriptions of certain players’ characteristics e.g. Playmaker, Acrobat, etc, and popular traits e.g. Crowd Favourite, Beat Offside Trap, Argues with Officials, etc.
data %>%
select(short_name, club, position, player_tags, player_traits) %>%
na.omit() %>%
sample_n(10)
## # A tibble: 10 x 5
## short_name club position player_tags player_traits
## <chr> <chr> <fct> <chr> <chr>
## 1 D. Djigla Chamois Niorta~ M #Speedster Early Crosser
## 2 T. Buhagiar Sydney FC AM #Speedster, #Ac~ Early Crosser
## 3 A. Ibargüen Club América M #Speedster, #Ac~ Diver, Selfish, Crowd~
## 4 B. Boutobba Montpellier HSC M #Acrobat Injury Free, Selfish,~
## 5 K. Tierney Arsenal D #Engine Early Crosser
## 6 N. Maksimov~ Napoli D #Strength Injury Prone, Long Pa~
## 7 S. Mendoza Amiens SC M #Speedster Argues with Officials~
## 8 N. Dovedan 1. FC Nürnberg M #Acrobat Diver, Finesse Shot, ~
## 9 B. McKay Swansea City M #Acrobat Selfish, Early Crosse~
## 10 S. Kutschke FC Ingolstadt ~ ST #Aerial Threat,~ Diver, Long Passer (C~
We can visualize the relationship between tags and traits using a network graph. First we have to wrangle our data into a long format using separate and pivot_longer, remove the tag “CPU AI Only”, and use pairwise_cor to find pairwise correlation across the words as that will form the basis of our network graph.
library(widyr)
library(igraph)
library(ggraph)
corr <- data %>%
select(short_name, club, position, player_tags, player_traits) %>%
separate(player_tags, paste("tag", 1:10, sep = ""), sep = ", ") %>%
separate(player_traits, paste("trait", 1:8, sep = ""), sep = ", ") %>%
pivot_longer(tag1:trait8, names_to = "tag", values_to = "description", values_drop_na = T) %>%
mutate(description = str_replace(description, "#", "")) %>%
filter(!str_detect(description, "CPU AI Only")) %>%
group_by(description) %>%
filter(n() > 100) %>%
pairwise_cor(description, short_name, sort = TRUE)
corr
## # A tibble: 552 x 3
## item1 item2 correlation
## <chr> <chr> <dbl>
## 1 Crowd Favourite Selfish 0.388
## 2 Selfish Crowd Favourite 0.388
## 3 Speedster Early Crosser 0.288
## 4 Early Crosser Speedster 0.288
## 5 Speedster Acrobat 0.262
## 6 Acrobat Speedster 0.262
## 7 Aerial Threat Strength 0.252
## 8 Strength Aerial Threat 0.252
## 9 Avoids Using Weaker Foot Giant Throw-in 0.223
## 10 Giant Throw-in Avoids Using Weaker Foot 0.223
## # ... with 542 more rows
Tidy Text Mining with R has a great tutorial on how to construct network graphs.
The igraph package has many powerful functions for manipulating and analyzing networks. One way to create an igraph object from tidy data is the
graph_from_data_frame()function, which takes a data frame of edges with columns for “from”, “to”, and edge attributes.
set.seed(2020)
corr %>%
filter(correlation > .03) %>%
graph_from_data_frame() %>%
ggraph(layout = "fr") +
geom_edge_link(aes(edge_alpha = correlation), show.legend = FALSE) +
geom_node_point(color = "lightblue", size = 5) +
geom_node_text(
aes(label = name),
repel = TRUE,
fontface = "bold",
vjust = -1,
hjust = -0.2
) +
labs(
title = "Visualizing Correlation Of Players' Tags and Traits With A Network Graph",
subtitle = "Crowd favourites tend to be selfish acrobats that excel at beating offside traps and arguing with officials\nDoes any particular player come to mind?",
caption = "Source: FIFA 20 Complete Player Data Set"
) +
theme(
plot.title = element_text(face = "bold", size = 20),
plot.subtitle = element_text(size = 17),
strip.background = element_blank(),
strip.text = element_text(face = "bold", size = 15),
)

Do Soccer Players Age Like Wine?
To investigate a player’s score as the years fly by, we bucket age into two categories - before and after twenty five (mean age is 25.3 years), and use ggscatmat() against variables such as value_eur, wage_eur, overall and potential. This gives a scatterplot matrix with density plots on the diagonal and correlation printed in the upper triangle.
library(GGally)
data %>%
mutate(
age_segment = case_when(
age < 23 ~ "Age<23",
between(age, 23, 25) ~ "Age 23-25",
between(age, 26, 29) ~ "Age 26-29",
TRUE ~ "Age>29"
)
) %>%
select(age, value_eur, wage_eur, overall, potential, age_segment) %>%
ggscatmat(columns = 1:5, color = "age_segment") +
scale_color_fish_d(option = "Antennarius_commerson") +
labs(title = "Players Aged <23 +ve Correlated With Higher Value/Wages/Overall Score",
subtitle = "Beyond 29 years of age, we start to see negative correlation",
color = "Age Segment",
caption = "Source: FIFA 20 Complete Player Data Set") +
theme(
plot.title = element_text(face = "bold", size = 20),
plot.subtitle = element_text(size = 17),
legend.title = element_text(face = "bold")
)

There is some positive correlation observed between players aged 23 and younger and their associated value_eur, wage_eur and overall score. Conversely, beyond 29 years of age, we observe some negative correlation too between age and value_eur, wage_eur and overall score.
This isn’t too surprising as the average retirement age of a football player is said to be 35 years old, according to the Professional Footballers’ Association.
Bargain Hunting At Clubs
Information about the biggest and richest clubs are easily available online and buying a star player from any of those clubs would easily cost a fortune. What I reckon could be more interesting is identifying clubs by a metric I call pot_per_dollar: A player’s potential score divided by his current value_eur. Summing all of the players, we get the club’s pot_per_dollar.
Clubs with the highest pot_per_dollar are great for bargain hunting because there is a good chance to find high potential players with a cheap asking price that you could snap up. We can further segment our data by continent to address geographical-related transfer constraints, if any.
#adding continents
geography <- data %>%
mutate(continent = countrycode(nationality,
origin = "country.name",
destination = "continent")) %>%
#place Brexit countries back into Europe
mutate(continent = replace_na(continent, "Europe"))
geography %>%
select(short_name, club, nationality, continent) %>%
sample_n(10)
## # A tibble: 10 x 4
## short_name club nationality continent
## <chr> <chr> <chr> <chr>
## 1 D. Parrett Stevenage England Europe
## 2 L. McGee Portsmouth England Europe
## 3 Ignasi Miquel Girona FC Spain Europe
## 4 J. Rollinson Stevenage England Europe
## 5 R. Uehara Júbilo Iwata Japan Asia
## 6 D. Cvitanich Racing Club Argentina Americas
## 7 Zheng Dalun Tianjin Quanjian FC China PR Asia
## 8 O. Oularé Standard de Liège Belgium Europe
## 9 W. Marinus FC Emmen Netherlands Europe
## 10 I. Sissoko RC Strasbourg Alsace France Europe
#identify the clubs with the highest potential per wage dollar paid
list <- geography %>%
#remove players with no clubs and no wages paid, and some players with 0 value
filter(!is.na(team_position),
value_eur > 0) %>%
select(short_name, club, potential, value_eur, nationality, continent) %>%
#each player's potential per dollar of wages paid. Higher is better.
mutate(pot_per_dollar = potential / value_eur) %>%
group_by(club) %>%
summarise(total_pot = sum(pot_per_dollar)) %>%
arrange(desc(total_pot)) %>%
head(20)
geography %>%
#filter data set for clubs in list
filter(club %in% list$club,
value_eur > 0) %>%
select(short_name, club, potential, value_eur, nationality, continent) %>%
mutate(
pot_per_dollar = potential / value_eur,
club = fct_reorder(club, pot_per_dollar, .fun = sum)
) %>%
ggplot(aes(club, pot_per_dollar)) +
geom_col(aes(fill = continent)) +
coord_flip() +
scale_fill_fish_d(option = "Antennarius_commerson") +
labs(
x = "Club",
y = "Club's Total Potential Per Value",
fill = "Continent",
title = "Bargain Hunting: Clubs With High Potential Players At Lowest Prices",
subtitle = "Sorted by this metric, we can see promising clubs can be found in Asia and Europe leagues.",
caption = "Source: FIFA 20 Complete Player Data Set"
) +
theme(
plot.title = element_text(face = "bold", size = 20),
plot.subtitle = element_text(size = 17),
strip.background = element_blank(),
strip.text = element_text(face = "bold", size = 15),
legend.title = element_text(face = "bold")
)

Principal Components Analysis
tidymodels allows for seamless and fuss-free Principal Components Analysis (PCA) implementation, and this wide data set is great with nearly 40 variables.
As the webpage reference for recipes::step_pca() describes it:
Principal component analysis (PCA) is a transformation of a group of variables that produces a new set of artificial features or components. These components are designed to capture the maximum amount of information (i.e. variance) in the original variables. Also, the components are statistically independent from one another. This means that they can be used to combat large inter-variables correlations in a data set.
We first load the tidymodels meta package, then filter players with no teams and all goalkeepers (they have a separate skill set), followed by selecting of columns. Let’s also segment age per its quantiles and use it as variables for PCA.
library(skimr)
geography$age %>%
skim()
| Name | Piped data |
| Number of rows | 18278 |
| Number of columns | 1 |
| _______________________ | |
| Column type frequency: | |
| numeric | 1 |
| ________________________ | |
| Group variables | None |
Variable type: numeric
| skim_variable | n_missing | complete_rate | mean | sd | p0 | p25 | p50 | p75 | p100 | hist |
|---|---|---|---|---|---|---|---|---|---|---|
| data | 0 | 1 | 25.28 | 4.66 | 16 | 22 | 25 | 29 | 42 | ▆▇▆▂▁ |
library(tidymodels)
pca_rec <- geography %>%
filter(!is.na(team_jersey_number), !position == "GK") %>%
mutate(age_segment = case_when(
age < 23 ~ "Age<23",
between(age, 23, 25) ~ "Age 23-25",
between(age, 26, 29) ~ "Age 26-29",
TRUE ~ "Age>29")) %>%
select(
#identifiers
sofifa_id, short_name, long_name, club, value_eur,
#categorical
preferred_foot, position, continent, age_segment,
#numeric
overall:potential, team_jersey_number, skill_moves,
pace:physic, attacking_crossing:defending_sliding_tackle
) %>%
recipe( ~ .) %>%
update_role(sofifa_id, short_name, long_name, club, value_eur, new_role = "id") %>%
step_dummy(preferred_foot, position, continent, age_segment) %>%
step_normalize(all_predictors()) %>%
step_pca(all_predictors())
Note that we did not define an outcome/response in our recipe() function, so this is unsupervised learning. Our recipe contains:
update_role()is used to indicate variables that should not be included when executing pre-processing withprep()later on, and that these variables are only included as identifiers.- Because PCA only takes in numeric data, we convert our categorical data to numbers with
step_dummy(), which converts nominal data (e.g. character or factors) into one or more numeric binary model terms for the levels of the original data. - PCA is sensitive to outliers so
step_normalize()essentially scales and centers all numeric variables to have a standard deviation of one and a mean of zero. - Finally,
step_pca()implements Principal Component Analysis.
The recipe only begins executing the pre-processing steps after we run prep().
pca_prep <- pca_rec %>%
prep()
pca_prep
## Data Recipe
##
## Inputs:
##
## role #variables
## id 5
## predictor 43
##
## Training data contained 16042 data points and no missing data.
##
## Operations:
##
## Dummy variables from preferred_foot, position, continent, age_segment [trained]
## Centering and scaling for overall, potential, ... [trained]
## PCA extraction with overall, potential, ... [trained]
We can use tidy() to extract our PCA results in a convenient tidy format which we can then subsequently visualize.
#tidy(df,3) lets us extract the third recipe step i.e. step_pca
tidied_pca <- tidy(pca_prep, 3)
tidied_pca %>%
filter(component %in% paste0("PC", 1:4)) %>%
mutate(component = fct_inorder(component)) %>%
ggplot(aes(value, terms, fill = terms)) +
geom_col(show.legend = FALSE) +
facet_wrap( ~ component, nrow = 1) +
scale_fill_fish_d(option = "Antennarius_commerson") +
labs(
x = "Value",
y = "Traits",
fill = "",
title = "PCA: A High-Level Overview Of The First Four Features Created",
subtitle = NULL,
caption = "Source: FIFA 20 Complete Player Data Set"
) +
theme(
plot.title = element_text(face = "bold", size = 20),
strip.background = element_blank(),
strip.text = element_text(face = "bold", size = 15)
)

Let’s dive deeper into the top 15 traits that defines each of these four features.
tidied_pca %>%
filter(component %in% paste0("PC", 1:4)) %>%
group_by(component) %>%
top_n(20, abs(value)) %>%
ungroup() %>%
mutate(terms = reorder_within(terms, abs(value), component)) %>%
ggplot(aes(abs(value), terms, fill = value > 0)) +
geom_col() +
facet_wrap(~component, scales = "free_y") +
scale_y_reordered() +
scale_fill_fish_d(option = "Antennarius_commerson") +
labs(
x = "Absolute Value of Contribution",
y = NULL,
fill = "Positive?",
title = "PCA: Visualizing The First Four Features (PC1-PC4) Created",
subtitle = "Interpretation: As PC1 gets more negative, it captures offensive midfielder/playmaker traits;\nPC3: Hallmarks of a classic striker (strength, finishing, shooting) vs Movement/Agility traits",
caption = "Source: FIFA 20 Complete Player Data Set"
) +
theme(
plot.title = element_text(face = "bold", size = 20),
plot.subtitle = element_text(size = 17),
strip.background = element_blank(),
strip.text = element_text(face = "bold", size = 15),
legend.title = element_text(face = "bold")
)

Below is my interpretation for PC1-PC4:
- PC1: Negative indicates offensive midfielder / playmaker traits
- PC2: Negative indicates defender / defensive midfielder traits
- PC3: Striker traits (+ve) versus defenders with high movement-related traits (-ve)
- PC4: Passing-related traits (+ve) versus finesse-related traits (-ve)
Now let’s assess how accurate our PCA features are by visualizing the most valuable players against our features.
#Getting our top 50 valuable players ex GKs
valuable <- geography %>%
filter(!is.na(team_jersey_number),
!position == "GK") %>%
arrange(desc(value_eur)) %>%
select(long_name) %>%
slice(1:100)
juice(pca_prep) %>%
filter(long_name %in% valuable$long_name) %>%
left_join(geography %>% select(sofifa_id, position)) %>%
ggplot(aes(PC1, PC3, label = paste(
short_name, " ", "(", str_wrap(club, width = 20), ")", "\n", "EUR", value_eur/1000000, "m", sep = ""))) +
geom_point(aes(color = position), alpha = 0.75, size = 5) +
geom_text(
check_overlap = TRUE,
vjust = "inward",
hjust = "inward",
size = 4,
fontface = "bold") +
scale_colour_fish_d(option = "Hypsypops_rubicundus") +
scale_x_continuous() +
labs(
x = "PC1",
y = "PC3",
color = "Position",
title = "FIFA 20: Top 100 Most Valuable Soccer Players (ex GKs) Mapped Against PC1 and PC3",
subtitle = "Offensive midfielders/playmakers are towards the left, defenders are mapped towards the right,\nStrikers can be found towards the top, while defensive roles with high movement scores are towards the bottom.",
caption = "Source: FIFA 20 Complete Player Data Set"
) +
theme(
plot.title = element_text(face = "bold", size = 20),
plot.subtitle = element_text(size = 17),
legend.title = element_text(face = "bold"),
legend.position = "top"
)

To my knowledge it looks like the positions are mapped out fairly accurately. Well-known players such as Messi, Ronaldo and Neymar have dazzled the world with their dribbling and shooting - and have been aptly mapped towards the left most of this chart.
Revisiting our bargain hunting theme, let’s run PCA on the top 100 players with the highest pot_per_dollar.
pot_dollar <- geography %>%
filter(!is.na(team_jersey_number),
!position == "GK",
value_eur > 0) %>%
mutate(pot_per_dollar = potential / value_eur) %>%
arrange(desc(pot_per_dollar)) %>%
select(long_name, pot_per_dollar) %>%
slice(1:100)
juice(pca_prep) %>%
filter(long_name %in% pot_dollar$long_name) %>%
left_join(geography %>% select(sofifa_id, position)) %>%
ggplot(aes(PC1, PC3, label = paste(
short_name, " ", "(", str_wrap(club, width = 20), ")", "\n", "EUR", value_eur/1000, "k", sep = ""))) +
geom_point(aes(color = position), alpha = 0.75, size = 5) +
geom_text(
check_overlap = TRUE,
hjust = "left",
size = 4,
fontface = "bold"
) +
scale_colour_fish_d(option = "Hypsypops_rubicundus") +
labs(
x = "PC1",
y = "PC3",
color = "Position",
title = "FIFA 20: Top 100 Soccer Players (ex GKs) With The Highest Potential Per Value Dollar",
subtitle = "Note: x-axis has shifted significantly from the previous chart, with PC1 indicating a selection bias towards players with defensive traits.",
caption = "Source: FIFA 20 Complete Player Data Set"
) +
theme(
plot.title = element_text(face = "bold", size = 20),
plot.subtitle = element_text(size = 17),
legend.title = element_text(face = "bold"),
legend.position = "top"
)

First and foremost, what’s interesting about this chart is the x-axis. Our previous Top 50 Most Valuable Players chart had a strong bias towards negative PC1 values (players with offensive traits), while our current chart has a strong bias towards positive PC1 values(players with defensive traits) and we consequently see significantly more defenders showing up.
Finally, recall PC2 was the feature created that was tilted towards defender / defensive midfielder traits. Let’s pick four of the defenders located furthest to the right and map PC2 components against their strongest traits.
library(gghighlight)
PC2 <- tidied_pca %>%
filter(component == "PC2") %>%
top_n(15, abs(value))
geography %>%
filter(str_detect(short_name, "T. French|Y. Fujita|A. Bennett|J. McCombe")) %>%
select(
short_name, club, player_positions, position,
overall:potential, pace:physic,attacking_crossing:defending_sliding_tackle
) %>%
pivot_longer(
c(overall:potential, pace:physic, attacking_crossing:defending_sliding_tackle),
names_to = "traits",
values_to = "scores"
) %>%
group_by(short_name) %>%
top_n(20, scores) %>%
ungroup() %>%
ggplot(aes(reorder_within(traits, scores, short_name), scores)) +
geom_col(aes(fill = short_name), show.legend = F) +
gghighlight(traits %in% PC2$terms, calculate_per_facet = T) +
coord_flip() +
facet_wrap(~ short_name, scales = "free_y") +
scale_x_reordered() +
scale_fill_fish_d(option = "Antennarius_commerson") +
labs(
x = "Traits",
y = "Scores",
title = "How Does PC2 Components Map Onto These Four Defenders' Top Traits?",
subtitle = "Not surprisingly, the top PC2 components are featured strongly across all four players' traits",
caption = "Source: FIFA 20 Complete Player Data Set"
) +
theme(
plot.title = element_text(face = "bold", size = 20),
plot.subtitle = element_text(size = 17),
strip.background = element_blank(),
strip.text = element_text(face = "bold", size = 15),
)

I think there are plenty of questions that warrant further investigation here, and personally this blog post really made me appreciate PCA and the intuition it brings. It’s been one of my long-time visualization goals to use network graphs and ridgeline plots, and its great to be able to add them to my EDA toolkit. A big shout-out to Wen Hao for spotting some errors in my initial charts!