The objective of this assignment, the Individual Task for Module 9 Football Analysis with R from the MsC in Data Analytics in Football, is talent detection: building a reproducible pipeline in R that reads a real performance data set (FBREF, 2024/25 season, five major European leagues, provided by the module instructor), builds a composite score (rating) for wingers (player position I selected for this task), visualizes the top performers with radar charts, and finally runs a similarity algorithm to find players with a performance profile close to Luis Díaz (Liverpool; player I selected for this assignment), finding the potential replacement candidates.
This was probably the data analysis activity that was performed at Liverpool once Luis Diaz was starting to negotiate with Bayern Munich for a transfer.
The structure of this report follows the methodology used throughout the module (reading → description/first transformations → sample & variable selection → data processing → scoring → radar → similarity), adapting the reading, scoring, similarity and radar-chart techniques taught in the module to this specific data set and target player.
The working directory is the folder containing this .Rmd
file (RStudio default), where the file
FBREF_BigPlayers_2425.csv is also stored.
knitr::opts_knit$set(root.dir = getwd())
In addition to reading the file, I need to install some packages that will provide the functions to manipulate, transform, process, analyse the data to later present it with the radar charts, and other types of graphics.
# install.packages(c("tidyverse", "fmsb", "lsa", "scales", "knitr"))
library(tidyverse) # reading + wrangling (dplyr, readr, ggplot2...)
library(knitr) # kable() tables
library(scales) # alpha() for radar chart transparency
library(fmsb) # radarchart()
library(lsa) # cosine similarity
The file FBREF_BigPlayers_2425.csv contains
season 2024/25 performance statistics (basic and
advanced) for players in the five major European leagues: La Liga,
Premier League, Bundesliga, Serie A, and Ligue 1. Fields are
semicolon-separated (;), and something that I noticed for
this file, is that I need to take into account the accents when reading
some names as the file is UTF-8 encoded.
To read and store my data, I do the following:
data_players <- read.csv(
file = "FBREF_BigPlayers_2425.csv",
sep = ";",
encoding = "UTF-8",
stringsAsFactors = FALSE
)
dim(data_players)
## [1] 3972 72
To analyze the structure of the read data I can use the following command:
str(data_players[, 1:20])
## 'data.frame': 3972 obs. of 20 variables:
## $ Player : chr "Abdoulie Ceesay" "Adam Aznou" "Adam Dźwigała" "Adam Hložek" ...
## $ Squad : chr "St. Pauli" "Bayern Munich" "St. Pauli" "Hoffenheim" ...
## $ Nation : chr "GAM" "MAR" "POL" "CZE" ...
## $ Pos : chr "FW" "DF" "DF,MF" "FW,MF" ...
## $ Age : int 20 18 28 22 27 31 27 20 25 33 ...
## $ MP : num 7 2 16 27 32 30 28 21 19 2 ...
## $ Min : int 60 17 373 1871 1598 1902 1455 1451 1263 180 ...
## $ Gls : int 0 0 0 8 4 11 3 1 7 0 ...
## $ G.PK : int 0 0 0 8 4 10 3 1 7 0 ...
## $ Ast : int 0 0 0 4 1 4 4 0 2 0 ...
## $ xG : num 0 0 0.4 5.6 3.3 7.4 1 0.6 4.1 0 ...
## $ xAG : num 0 0 0 3.5 1.2 3.1 4.9 0.2 1.3 0.1 ...
## $ Gls.90 : num 0 0 0 0.38 0.23 0.52 0.19 0.06 0.5 0 ...
## $ G.PK.90 : num 0 0 0 0.38 0.23 0.47 0.19 0.06 0.5 0 ...
## $ Ast.90 : num 0 0 0 0.19 0.06 0.19 0.25 0 0.14 0 ...
## $ xG.90 : num 0 0 0.09 0.27 0.18 0.35 0.06 0.03 0.29 0 ...
## $ xAG.90 : num 0.03 0 0 0.17 0.07 0.15 0.3 0.01 0.1 0.06 ...
## $ Competition: chr "Bundesliga" "Bundesliga" "Bundesliga" "Bundesliga" ...
## $ Sh : num 0 0 6 59 39 41 15 9 27 0 ...
## $ Sh.90 : num 0 0 1.45 2.84 2.2 1.94 0.93 0.56 1.92 0 ...
The dataframe has 3972 player-season records and 72 variables:
identification / context fields (Player,
Squad, Nation, Pos,
Age, Competition, MP,
Min) and a large set of per-90 and percentage
performance metrics covering attacking output (goals, assists,
xG, xAG), shooting, passing, dribbling, defensive actions and
goalkeeping.
And, in order to verify the different leagues and positions that the players listed in the file play in, I can use the next block of code. This, for example, will help me later with the similarity algorithm, as I can filter by wingers to look for a player whose playing style is close to Luis Diaz’s.
unique(data_players$Competition)
## [1] "Bundesliga" "Eredivisie" "La Liga" "Ligue 1"
## [5] "Premier League" "Primeira Liga" "Serie A"
unique(data_players$Pos)
## [1] "FW" "DF" "DF,MF" "FW,MF" "MF,FW" "MF" "GK" "MF,DF" "DF,FW"
## [10] "FW,DF"
I can see that “Primeira Liga” is also included in the dataset.
I need to ensure that my data is the types I need for my
calculations. So, for example, I check that Min (minutes
played) and Age are numeric, and with them, I create a
MaxMinutes reference per competition (matches played by the
competition leader MP_Squad × 90), which I will later use
to compute the % of minutes played. Applying this
filter is key for my analysis: I keep only players with a representative
sample of minutes.
class(data_players$Min); class(data_players$Age)
## [1] "integer"
## [1] "integer"
data_players <- data_players %>%
mutate(MaxMinutes = MP_Squad * 90,
PctMinutes = round(100 * Min / MaxMinutes, 1))
# Quick look: top goal contributors (Gls + Ast) across the 5 leagues
data_players %>%
mutate(GA = Gls + Ast) %>%
arrange(desc(GA)) %>%
select(Player, Squad, Competition, Gls, Ast, GA) %>%
head(10) %>%
kable(format = "markdown", align = "c")
| Player | Squad | Competition | Gls | Ast | GA |
|---|---|---|---|---|---|
| Mohamed Salah | Liverpool | Premier League | 29 | 18 | 47 |
| Viktor Gyökeres | Sporting CP | Primeira Liga | 39 | 7 | 46 |
| Harry Kane | Bayern Munich | Bundesliga | 26 | 9 | 35 |
| Kylian Mbappé | Real Madrid | La Liga | 31 | 3 | 34 |
| Mateo Retegui | Atalanta | Serie A | 25 | 8 | 33 |
| Sem Steijn | Twente | Eredivisie | 24 | 7 | 31 |
| Robert Lewandowski | Barcelona | La Liga | 27 | 2 | 29 |
| Alexander Isak | Newcastle Utd | Premier League | 23 | 6 | 29 |
| Michael Olise | Bayern Munich | Bundesliga | 12 | 15 | 27 |
| Raphinha | Barcelona | La Liga | 18 | 9 | 27 |
During european football seasons, there is the winter transfer
window, so it’s possible that a player has more than one entry in my
dataset, one per team. In such cases, I will keep only the entry
with the most minutes played, since it is the most
representative sample of his current performance level. This guarantees
every Player name is unique in the dataset, which the rest
of the script (radar chart, similarity tool) relies on.
n_before <- nrow(data_players)
data_players <- data_players %>%
group_by(Player) %>%
slice_max(Min, n = 1, with_ties = FALSE) %>%
ungroup() %>%
as.data.frame()
cat("Rows removed (secondary stints):", n_before - nrow(data_players), "\n")
## Rows removed (secondary stints): 232
cat("Player names still duplicated:", sum(duplicated(data_players$Player)))
## Player names still duplicated: 0
With that, now I can start using the data for my similarity analysis.
To better comprehend my target, let me check first the data I have for Diaz.
target_player <- "Luis Díaz"
data_players %>% filter(Player == target_player) %>%
select(Player, Squad, Nation, Pos, Age, Competition, Min, PctMinutes) %>%
kable(format = "markdown", align = "c")
| Player | Squad | Nation | Pos | Age | Competition | Min | PctMinutes |
|---|---|---|---|---|---|---|---|
| Luis Díaz | Liverpool | COL | FW | 27 | Premier League | 2399 | 70.1 |
As I can see from the table, Luis Díaz played 2,399 minutes in the Premier League 2024/25 (≈ 70% of available minutes), which makes him a full-minutes reference for the analysis.
With the target defined, now I must define the variables I will use for my similar players lookup. With the knowledge obtain from the master, I consider that the relevant metrics to analize for forward players are: offensive duels, crosses, dribbles, finishing, goal contribution, xG, pressing intensity. From the FBREF data, I can directly obtain certain metrics, like the number of scored goals; however, there are others that will require some data manipulation, like successful dribbles or Pressures. The table above will explain how I will obtain the metrics for this analysis
| Conceptual variable | FBREF metric used | Rationale |
|---|---|---|
| Goals | Gls/90 |
Direct match |
| Goal assists | Ast/90 |
Direct match |
| xG | xG/90 |
Direct match |
| Finishing | G-xG |
Goals above expected = finishing quality |
| Dribbles (offensive duels, volume) | Dribbles/90 |
Take-on attempts per 90 |
| Successful dribbles (offensive duels won) | Dribbles% |
Take-on success rate |
| Crosses / Accurate crosses / Crosses into box | CrsPA/90 |
Crosses into the penalty area per 90 (no raw cross-accuracy stat available in this file) |
| Pressures | Recov/90 |
Ball recoveries per 90, closest available proxy for pressing/defensive work rate |
| Key passes | KP/90 |
Chance creation via passing |
| Shot/Goal-creating actions | SCA/90 |
Overall involvement in creating shots |
To avoid over-weighting crossing as the only creativity metric, I
added KP/90 and SCA/90 , since a modern winger
like Díaz also creates chances by cutting inside.
With the following block I set the variables
metrics <- c("Gls/90", "Ast/90", "xG/90", "Dribbles/90", "Dribbles%",
"CrsPA/90", "G-xG", "Recov/90", "SCA/90", "KP/90")
# Column names as they appear after read.csv() (special characters replaced by '.')
metrics_cols <- make.names(metrics)
metrics_cols
## [1] "Gls.90" "Ast.90" "xG.90" "Dribbles.90" "Dribbles."
## [6] "CrsPA.90" "G.xG" "Recov.90" "SCA.90" "KP.90"
I keep forwards/wingers (Pos containing
“FW”) from the five major leagues, with at
least 30% of the maximum possible minutes in their
competition — enough to be a representative sample while still allowing
emerging/rotation players to be considered as potential
replacements.
select_players <- function(data, pos, leagues, pct_min){
data_leagues <- data %>% filter(Competition %in% leagues)
data_sample <- data_leagues %>%
filter(grepl(pos, Pos)) %>%
filter(PctMinutes >= pct_min)
return(data_sample)
}
leagues <- c("La Liga", "Premier League", "Bundesliga", "Serie A", "Ligue 1")
df_wingers <- select_players(
data = data_players, pos = "FW", leagues = leagues, pct_min = 30
)
cat("Number of forwards/wingers in the sample:", nrow(df_wingers), "\n")
## Number of forwards/wingers in the sample: 457
cat("Target player included in the sample:", target_player %in% df_wingers$Player)
## Target player included in the sample: TRUE
Now, from my filtered list of player, I obtain the statistics I mentioned before:
filter_players <- function(data, metrics_cols, metrics_labels){
data_filter <- data %>%
select(Player, Squad, Competition, Age, all_of(metrics_cols))
colnames(data_filter) <- c("Player", "Squad", "Competition", "Age", metrics_labels)
rownames(data_filter) <- 1:nrow(data_filter)
return(data_filter)
}
df_wingers_sample <- filter_players(
data = df_wingers, metrics_cols = metrics_cols, metrics_labels = metrics
)
head(df_wingers_sample)
To avoid metrics measured on different scales dominating the score,
for example Gls/90 ranges 0–1 while Recov/90
can be > 10, every metric is normalized to a common [0,
1] range. The closer the metric is to 1, the better a player
performed under that metric.
normalize <- function(x, na.rm = TRUE){
return((x - min(x, na.rm = na.rm)) / (max(x, na.rm = na.rm) - min(x, na.rm = na.rm)))
}
df_wingers_norm <- df_wingers_sample
for (m in metrics){
df_wingers_norm[, m] <- normalize(df_wingers_sample[, m])
}
summary(df_wingers_norm[, metrics])
## Gls/90 Ast/90 xG/90 Dribbles/90
## Min. :0.0000 Min. :0.0000 Min. :0.0000 Min. :0.00000
## 1st Qu.:0.1304 1st Qu.:0.1379 1st Qu.:0.1316 1st Qu.:0.08114
## Median :0.2261 Median :0.2414 Median :0.2105 Median :0.14254
## Mean :0.2529 Mean :0.2711 Mean :0.2407 Mean :0.17176
## 3rd Qu.:0.3391 3rd Qu.:0.3621 3rd Qu.:0.3070 3rd Qu.:0.24123
## Max. :1.0000 Max. :1.0000 Max. :1.0000 Max. :1.00000
## Dribbles% CrsPA/90 G-xG Recov/90
## Min. :0.0000 Min. :0.0000 Min. :0.0000 Min. :0.0000
## 1st Qu.:0.3978 1st Qu.:0.0400 1st Qu.:0.3691 1st Qu.:0.2268
## Median :0.5087 Median :0.1200 Median :0.4497 Median :0.3268
## Mean :0.4996 Mean :0.1783 Mean :0.4589 Mean :0.3435
## 3rd Qu.:0.5975 3rd Qu.:0.2700 3rd Qu.:0.5302 3rd Qu.:0.4446
## Max. :1.0000 Max. :1.0000 Max. :1.0000 Max. :1.0000
## SCA/90 KP/90
## Min. :0.0000 Min. :0.0000
## 1st Qu.:0.2762 1st Qu.:0.1678
## Median :0.3927 Median :0.2552
## Mean :0.4163 Mean :0.2885
## 3rd Qu.:0.5308 3rd Qu.:0.3741
## Max. :1.0000 Max. :1.0000
The next step will be to define some weights for the selected metrics, to reflect the relative importance of each dimension for a modern winger like Luis Díaz: goal threat and finishing carry the most weight, followed by creativity (assists, key passes, chance creation), dribbling and crossing, and finally pressing/defensive work rate.
weights <- c(
`Gls/90` = 0.15,
`Ast/90` = 0.10,
`xG/90` = 0.10,
`Dribbles/90` = 0.10,
`Dribbles%` = 0.10,
`CrsPA/90` = 0.10,
`G-xG` = 0.10,
`Recov/90` = 0.05,
`SCA/90` = 0.10,
`KP/90` = 0.10
)
cat("Sum of weights:", sum(weights))
## Sum of weights: 1
And with the weights, now I can assess each metric for each player, and the total sum will be my score.
calc_scoring <- function(data, weights, metrics, columns_return, n = NULL){
data_w <- data
for (m in metrics){
data_w[, m] <- data_w[, m] * weights[m]
}
data_w$`Final score` <- rowSums(data_w[, metrics])
data_w$`Final score` <- round(10 * data_w$`Final score`, 3)
data_w <- data_w[order(-data_w$`Final score`), c(columns_return, "Final score")]
rownames(data_w) <- 1:nrow(data_w)
if (is.null(n)) n <- nrow(data_w)
return(data_w[1:n, ])
}
And here are the results:
df_score_wingers <- calc_scoring(
data = df_wingers_norm, weights = weights, metrics = metrics,
columns_return = c("Player", "Squad", "Competition", "Age"), n = 15
)
kable(df_score_wingers, format = "markdown", align = "c")
| Player | Squad | Competition | Age | Final score |
|---|---|---|---|---|
| Omar Marmoush | Eint Frankfurt | Bundesliga | 25 | 6.483 |
| Michael Olise | Bayern Munich | Bundesliga | 22 | 6.095 |
| Ousmane Dembélé | PSG | Ligue 1 | 27 | 6.076 |
| Mohamed Salah | Liverpool | Premier League | 32 | 5.991 |
| Lamine Yamal | Barcelona | La Liga | 17 | 5.625 |
| Raphinha | Barcelona | La Liga | 27 | 5.413 |
| Bukayo Saka | Arsenal | Premier League | 22 | 5.321 |
| Florian Wirtz | Leverkusen | Bundesliga | 21 | 5.296 |
| Rayan Cherki | Lyon | Ligue 1 | 20 | 5.245 |
| Kylian Mbappé | Real Madrid | La Liga | 25 | 5.206 |
| Franck Honorat | Gladbach | Bundesliga | 27 | 5.162 |
| Bryan Mbeumo | Brentford | Premier League | 24 | 5.105 |
| Harry Kane | Bayern Munich | Bundesliga | 31 | 5.084 |
| Matheus Cunha | Wolves | Premier League | 25 | 4.958 |
| Ademola Lookman | Atalanta | Serie A | 26 | 4.957 |
And where does L. Diaz rank?
df_score_all <- calc_scoring(
data = df_wingers_norm, weights = weights, metrics = metrics,
columns_return = c("Player", "Squad", "Competition", "Age")
)
target_rank <- which(df_score_all$Player == target_player)
cat("Luis Díaz rank in sample:", target_rank, "out of", nrow(df_score_all), "\n")
## Luis Díaz rank in sample: 45 out of 457
df_score_all %>% filter(Player == target_player) %>%
kable(format = "markdown", align = "c")
| Player | Squad | Competition | Age | Final score |
|---|---|---|---|---|
| Luis Díaz | Liverpool | Premier League | 27 | 4.303 |
I will compare then Luis Díaz against the top 4 highest-scored wingers to visualize strengths/weaknesses across the 10 metrics jointly. In order to do so, I will construct a radar chart analyzing all metrics in one go.
I started by selecting the players:
top_players <- df_score_wingers %>% filter(Player != target_player) %>%
slice_head(n = 4) %>% pull(Player)
radar_players <- c(target_player, top_players)
radar_players
## [1] "Luis Díaz" "Omar Marmoush" "Michael Olise" "Ousmane Dembélé"
## [5] "Mohamed Salah"
Then I proceeded to find the p5 to p95 limits:
min_max_df <- rbind(
apply(df_wingers_sample[, metrics], 2, function(x) quantile(x, probs = .95)),
apply(df_wingers_sample[, metrics], 2, function(x) quantile(x, probs = .05))
)
rownames(min_max_df) <- c("p95", "p5")
min_max_df
## Gls/90 Ast/90 xG/90 Dribbles/90 Dribbles% CrsPA/90 G-xG Recov/90 SCA/90
## p95 0.72 0.37 0.642 1.782 55.6 0.5 4.14 4.090 5.174
## p5 0.00 0.00 0.070 0.210 25.0 0.0 -2.82 0.994 1.366
## KP/90
## p95 1.852
## p5 0.340
And after clipping the values outside p5–p95, I prepare my data to the printed in the graphic.
data_radar <- df_wingers_sample[df_wingers_sample$Player %in% radar_players, ]
for (p in radar_players){
for (m in metrics){
value_m <- data_radar[data_radar$Player == p, m]
if (value_m < min_max_df["p5", m]){
data_radar[data_radar$Player == p, m] <- min_max_df["p5", m]
} else if (value_m > min_max_df["p95", m]){
data_radar[data_radar$Player == p, m] <- min_max_df["p95", m]
}
}
}
rownames(data_radar) <- data_radar$Player
df_final_plot <- rbind(min_max_df, data_radar[radar_players, metrics])
df_final_plot
And now the chart:
create_radarchart <- function(data, color, vlabels = colnames(data), vlcex = 0.7,
caxislabels = NULL, title = NULL){
radarchart(
data, axistype = 1,
pcol = color, pfcol = scales::alpha(color, 0.4),
plwd = 2, plty = 1,
cglcol = "grey", cglty = 1, cglwd = 0.8,
axislabcol = "grey30",
vlcex = vlcex, vlabels = vlabels,
caxislabels = caxislabels, title = title
)
}
colors_radar <- c("#E4002B", "#00AFBB", "#cccc00", "#FC4E07", "#7D3C98")
op <- par(mar = c(1, 2, 2, 2))
create_radarchart(data = df_final_plot, color = colors_radar)
legend("bottom", legend = rownames(df_final_plot[-c(1, 2), ]), horiz = FALSE, ncol = 2,
bty = "n", pch = 20, col = colors_radar, text.col = "black", cex = 0.7, pt.cex = 1.5)
title(main = "Luis Díaz vs. Top-Scored Wingers | 5 Major Leagues | Season 24/25",
cex.main = 1, col.main = "#5D6D7E")
par(op)
As I can see, Luis Díaz stands out clearly in goal threat and
dribbling volume relative to the sample’s p5–p95 range, which is
consistent with his high Final score in the ranking
above.
The final step is a talent-detection / replacement-finding tool: given the same 10 metrics, I scale the data and compute cosine similarity between every winger in the sample and Luis Díaz, returning the most similar profiles.
similarity_tool <- function(data, player, pos, leagues, pct_min, age_max,
metrics_cols, metrics_labels, distance = "cosine", n = 5){
set.seed(123)
data_leagues <- data[data$Competition %in% leagues, ]
data_max_matches <- data_leagues %>%
group_by(Competition) %>% summarise(maxMinutes = 90 * max(MP_Squad))
data_full <- merge(data_leagues, data_max_matches, by = "Competition", all.x = TRUE)
data_pos <- data_full %>%
filter(grepl(pos, Pos)) %>%
select(Player, Squad, Min, Age, maxMinutes, all_of(metrics_cols))
data_pos$minMinutes <- (pct_min * data_pos$maxMinutes) / 100
data_final <- data_pos[data_pos$Min > data_pos$minMinutes & data_pos$Age <= age_max, ]
data_final <- data_final %>% select(-minMinutes, -maxMinutes)
if (player %in% unique(data_final$Player)){
cat(paste(player, "is in the analysis sample.\n"))
} else {
data_final <- rbind(
data_final,
data[data$Player == player, c("Player", "Squad", "Min", "Age", metrics_cols)]
)
cat(paste(player, "is NOT in the analysis sample (added manually).\n"))
}
data_final_norm <- scale(data_final[, metrics_cols])
rownames(data_final_norm) <- data_final$Player
if (distance == "cosine"){
players_df <- t(data_final_norm)
sim_cosine <- lsa::cosine(players_df)
player_sim <- sim_cosine[, player]
df_sim <- as.data.frame(player_sim)
colnames(df_sim) <- "Similarity"
df_sim$Similarity <- normalize(df_sim$Similarity)
df_sim$Similarity <- round(100 * df_sim$Similarity, 2)
df_sim$Player <- data_final$Player
df_sim$Squad <- data_final$Squad
df_sim <- df_sim[df_sim$Player != player, ]
final_df <- df_sim[order(-df_sim$Similarity), ]
} else {
mat_dist <- as.matrix(dist(data_final_norm, method = "euclidean"))
player_dist <- mat_dist[, player]
df_sim <- as.data.frame(player_dist)
colnames(df_sim) <- "Distance"
df_sim$Player <- data_final$Player
df_sim$Squad <- data_final$Squad
df_sim <- df_sim[df_sim$Player != player, ]
d95 <- quantile(df_sim$Distance, 0.95)
df_sim$Similarity <- round((1 - (df_sim$Distance / d95)) * 100, 2)
final_df <- df_sim[order(-df_sim$Similarity), ]
}
rownames(final_df) <- 1:nrow(final_df)
final_df <- final_df[1:n, c("Player", "Squad", "Similarity")]
data_to_join <- data %>%
select(Player, Squad, all_of(metrics_cols))
colnames(data_to_join) <- c("Player", "Squad", metrics_labels)
final_df <- merge(final_df, data_to_join, by = c("Player", "Squad"), all.x = TRUE)
final_df <- final_df[order(-final_df$Similarity), ]
rownames(final_df) <- 1:n
return(final_df)
}
Useful as a benchmark of players with an equivalent overall performance level to Luis Díaz.
sim_diaz_overall <- similarity_tool(
data = data_players, player = target_player, pos = "FW", leagues = leagues,
pct_min = 30, age_max = 40,
metrics_cols = metrics_cols, metrics_labels = metrics,
distance = "cosine", n = 8
)
## Luis Díaz is in the analysis sample.
kable(sim_diaz_overall, format = "markdown", align = "c")
| Player | Squad | Similarity | Gls/90 | Ast/90 | xG/90 | Dribbles/90 | Dribbles% | CrsPA/90 | G-xG | Recov/90 | SCA/90 | KP/90 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Dani Raba | Leganés | 94.61 | 0.38 | 0.29 | 0.36 | 1.59 | 50.5 | 0.14 | 0.5 | 3.10 | 5.12 | 1.62 |
| Vinicius Júnior | Real Madrid | 92.50 | 0.44 | 0.32 | 0.41 | 2.73 | 42.9 | 0.20 | 0.6 | 2.37 | 5.39 | 1.93 |
| Omar Marmoush | Eint Frankfurt | 91.46 | 0.93 | 0.56 | 0.55 | 2.71 | 45.5 | 0.06 | 6.2 | 3.00 | 5.66 | 2.00 |
| Leroy Sané | Bayern Munich | 91.39 | 0.60 | 0.27 | 0.56 | 1.30 | 39.0 | 0.23 | 0.8 | 2.73 | 4.18 | 1.20 |
| Raphinha | Barcelona | 91.23 | 0.57 | 0.29 | 0.61 | 1.44 | 50.5 | 0.50 | -1.2 | 2.61 | 5.17 | 2.53 |
| Ousmane Dembélé | PSG | 90.54 | 1.09 | 0.31 | 0.86 | 1.45 | 44.2 | 0.10 | 4.4 | 1.34 | 5.72 | 2.17 |
| Florian Wirtz | Leverkusen | 90.20 | 0.38 | 0.46 | 0.36 | 2.65 | 49.1 | 0.26 | 0.6 | 3.65 | 5.66 | 1.84 |
| Mohamed Salah | Liverpool | 90.06 | 0.77 | 0.48 | 0.67 | 1.53 | 42.3 | 0.39 | 3.8 | 2.66 | 4.51 | 2.32 |
For succession-planning purposes, I restrict the sample to players 23 years old or younger, which highlights emerging talent with a performance profile close to Díaz’s — the most useful group for a recruitment/replacement decision.
sim_diaz_u23 <- similarity_tool(
data = data_players, player = target_player, pos = "FW", leagues = leagues,
pct_min = 20, age_max = 23,
metrics_cols = metrics_cols, metrics_labels = metrics,
distance = "cosine", n = 8
)
## Luis Díaz is NOT in the analysis sample (added manually).
kable(sim_diaz_u23, format = "markdown", align = "c")
| Player | Squad | Similarity | Gls/90 | Ast/90 | xG/90 | Dribbles/90 | Dribbles% | CrsPA/90 | G-xG | Recov/90 | SCA/90 | KP/90 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Michael Olise | Bayern Munich | 89.61 | 0.46 | 0.58 | 0.38 | 2.12 | 47.7 | 0.38 | 2.3 | 3.09 | 6.51 | 2.65 |
| Nick Woltemade | Stuttgart | 89.09 | 0.67 | 0.11 | 0.57 | 1.11 | 43.7 | 0.00 | 1.8 | 1.89 | 4.38 | 1.32 |
| Florian Wirtz | Leverkusen | 88.98 | 0.38 | 0.46 | 0.36 | 2.65 | 49.1 | 0.26 | 0.6 | 3.65 | 5.66 | 1.84 |
| Mason Greenwood | Marseille | 88.38 | 0.67 | 0.16 | 0.52 | 1.59 | 41.9 | 0.41 | 4.7 | 2.59 | 3.95 | 1.38 |
| Amad Diallo | Manchester Utd | 87.72 | 0.38 | 0.28 | 0.22 | 1.58 | 46.6 | 0.27 | 3.3 | 4.19 | 4.59 | 1.88 |
| Cole Palmer | Chelsea | 87.28 | 0.42 | 0.23 | 0.49 | 1.38 | 45.1 | 0.46 | -2.3 | 2.86 | 5.70 | 2.35 |
| Rayan Cherki | Lyon | 85.96 | 0.35 | 0.49 | 0.22 | 1.60 | 49.5 | 0.10 | 3.0 | 2.77 | 6.44 | 2.50 |
| Zuriko Davitashvili | Saint-Étienne | 85.43 | 0.29 | 0.26 | 0.27 | 1.39 | 37.4 | 0.12 | 0.9 | 3.55 | 3.85 | 1.52 |
And with the above table, I can see that the profiles that matches the most Luis Diaz are M. Olise (Bayern Munich), Nick Woltemada (Stuttgart), and Florian Wirtz (Bayern Leverkusen). A good thing to note here is that there is no more than 2% difference in the similarity score between the first 5 players of that table.
The third player in that table, Florian Wirtz, was the player signed by Liverpool to replace Luis Diaz after his exit to Bayern Munich. Liverpool paid an initial fee of £100 million for the player, with the total package potentially rising to £116 million through £16 million in structured add-ons.
From the analyzed data, I can conclude that Liverpool must have followed the same pattern to draft the list of players, and ultimately, making the final decision on which player to approach, looking for one that could have performed in a similar way to Diaz based on the data collected from the Season 2024/25.
In addition to that:
The composite score places Luis Díaz in the top
~10% of forwards/wingers in the 2024/25 season across the five major
European leagues (he ranks 45 of 457, and scores 4.303), driven mainly
by goal threat (Gls/90, xG/90), dribbling
volume/success and involvement in chance creation (SCA/90,
KP/90)
The radar chart shows his profile is more balanced towards dribbling and direct goal contribution than towards pure crossing volume, compared to the other top-scored wingers.
The cosine-similarity algorithm (unrestricted age) surfaces wingers with an overall comparable statistical profile to Díaz, useful as a “level” benchmark.
Restricting the similarity search to U23 players produces a shortlist of younger wingers with a statistically similar performance profile, the most actionable output for talent detection/succession planning, since these are players who could realistically be developed or recruited as future replacements.