Introduction

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.

Setting the Workspace

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

Reading the file

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

Structure and variable types

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.

Competitions and Positions

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.

Transforming the Data

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

Double Player Entries

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.

Looking for The Best Match to Diaz

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.

Variables of interest for a winger profile

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"

Sample selection

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

Extracting Metrics of Interest

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)

Min-Max normalization

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

Score Calculation

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

Radar Chart: Top Valued Wingers

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

Radar 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.

Similarity Algorithm: Players Similar to Luis Díaz

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)
}

Overall Similar Players

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

Replacement Candidates: Young Wingers (U23) Similar to Luis Díaz

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.

Signing Supported on Data

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.

Conclusions

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.