1 Introduction

A public source of data is gathered from; football-data.co.uk. This website provides historical standard statistics from all top leagues across the world alongside betting odds from various providers. The data is in the form of CSVs and ready to be used for quantitative analysis. This data is available for the purpose of helping betting ‘enthusiasts’ gain an edge over the bookmaker.

The library available in R which specialises in football is worldfootballR. It was created by Jason Zivkovic in January 2021 with the purpose of providing users the ability to extract and analyse detailed football statistics from websites such as FBref, Understat, Fotmob and Transfermarkt. Was archived in September 2025 because of a shift in attitude by these sites in allowing their websites to be scraped.

2 Define Workspace

path <- "C:/Users/karan/Documents/MSc/M9_Football_Analysis_with_R/Collab"
knitr::opts_knit$set(root.dir = path)
setwd(path)
getwd()
## [1] "C:/Users/karan/Documents/MSc/M9_Football_Analysis_with_R/Collab"

3 Libraries

library(tidyverse)
library(skimr)
library(ggrepel)

4 Reading data

url <- "https://www.football-data.co.uk/mmz4281/2425/E0.csv"
df <- read.csv(url)

4.1 Explore & Data Cleaning

head(df)

All 380 matches of PL season alongside 120 columns

dim(df)
## [1] 380 120

Summary of data with datatypes, completeness, mean etc

skim(df) %>%
  as.data.frame()
cat(names(df), sep = "\t\t")
## Div      Date        Time        HomeTeam        AwayTeam        FTHG        FTAG        FTR     HTHG        HTAG        HTR     Referee     HS      AS      HST     AST     HF      AF      HC      AC      HY      AY      HR      AR      B365H       B365D       B365A       BWH     BWD     BWA     BFH     BFD     BFA     PSH     PSD     PSA     WHH     WHD     WHA     X1XBH       X1XBD       X1XBA       MaxH        MaxD        MaxA        AvgH        AvgD        AvgA        BFEH        BFED        BFEA        B365.2.5        B365.2.5.1      P.2.5       P.2.5.1     Max.2.5     Max.2.5.1       Avg.2.5     Avg.2.5.1       BFE.2.5     BFE.2.5.1       AHh     B365AHH     B365AHA     PAHH        PAHA        MaxAHH      MaxAHA      AvgAHH      AvgAHA      BFEAHH      BFEAHA      B365CH      B365CD      B365CA      BWCH        BWCD        BWCA        BFCH        BFCD        BFCA        PSCH        PSCD        PSCA        WHCH        WHCD        WHCA        X1XBCH      X1XBCD      X1XBCA      MaxCH       MaxCD       MaxCA       AvgCH       AvgCD       AvgCA       BFECH       BFECD       BFECA       B365C.2.5       B365C.2.5.1     PC.2.5      PC.2.5.1        MaxC.2.5        MaxC.2.5.1      AvgC.2.5        AvgC.2.5.1      BFEC.2.5        BFEC.2.5.1      AHCh        B365CAHH        B365CAHA        PCAHH       PCAHA       MaxCAHH     MaxCAHA     AvgCAHH     AvgCAHA     BFECAHH     BFECAHA

The majority of columns (from 25 onward) are related to betting odds by various companies. The documentation that contains the full names of each column are found here: football-data.co.uk/notes.txt

Create new dataframe by filtering on the first 24 columns

pl_df <- df %>% 
  select(1:24)
dim(pl_df)
## [1] 380  24

Rename column names to full description to make it easier to understand

pl_df <- pl_df %>%
  rename(
    full_time_home_goals = FTHG,
    full_time_away_goals = FTAG,
    full_time_result = FTR,
    half_time_home_goals = HTHG,
    half_time_away_goals = HTAG,
    half_time_result = HTR,
    home_shots = HS,
    away_shots = AS,
    home_shots_on_target = HST,
    away_shots_on_target = AST,
    home_fouls = HF,
    away_fouls = AF,
    home_corners = HC,
    away_corners = AC,
    home_yellows = HY,
    away_yellows = AY,
    home_reds = HR,
    away_reds = AR
    )

4.2 Analysis

From the available data, here are three examples of the type of analysis that can be carried out

4.2.1 Effect of Playing at Home

# Home Advantage Breakdown

home_advantage_summary <- pl_df %>%
  summarise(
    total_matches = n(),
    home_wins = sum(full_time_result == "H"),
    away_wins = sum(full_time_result == "A"),
    draws = sum(full_time_result == "D"),
    
    home_win_pct = round((home_wins / total_matches) * 100, 1),
    away_win_pct = round((away_wins / total_matches) * 100, 1),
    
    avg_home_goals = round(mean(full_time_home_goals), 2),
    avg_away_goals = round(mean(full_time_away_goals), 2),
    
    # Shot Conversion Rate (%) = (Goals / Total Shots) * 100
    
    home_shot_conversion = round((sum(full_time_home_goals) / sum(home_shots)) * 100, 1),
    away_shot_conversion = round((sum(full_time_away_goals) / sum(away_shots)) * 100, 1)
  )
# Display summary table

home_advantage_summary

Difference in shot conversion when playing at home or away for each team. Some teams significantly better at home and vice versa. Teams performed at same level no matter the location e.g. Liverpool as Champions and Spurs nearly Relegated.

# Calculate home performance per team
home_stats <- pl_df %>%
  group_by(team = HomeTeam) %>%
  summarise(
    home_games = n(),
    home_goals = sum(full_time_home_goals),
    home_shots = sum(home_shots),
    home_conversion = round((home_goals / home_shots) * 100, 1)
  )

# Calculate away performance per team
away_stats <- pl_df %>%
  group_by(team = AwayTeam) %>%
  summarise(
    away_games = n(),
    away_goals = sum(full_time_away_goals),
    away_shots = sum(away_shots),
    away_conversion = round((away_goals / away_shots) * 100, 1)
  )

# Merge and compute the home advantage differential
team_efficiency <- inner_join(home_stats, away_stats, by = "team") %>%
  mutate(
    conversion_diff = home_conversion - away_conversion
  ) %>%
  arrange(desc(conversion_diff))
team_efficiency
efficiency_long <- team_efficiency %>%
  
  select(team, home_conversion, away_conversion) %>%
  
  pivot_longer(cols = c(home_conversion, away_conversion), 
               
               names_to = "location", 
               
               values_to = "conversion_rate") %>%
  
  mutate(location = ifelse(location == "home_conversion", "home", "away"))


# Plot Home vs Away Conversion Rates

ggplot(efficiency_long, aes(x = reorder(team, conversion_rate), y = conversion_rate, fill = location)) +
  
  geom_bar(
    stat = "identity", 
    position = position_dodge(width = 0.8), 
    width = 0.65
  ) +
  coord_flip() +
  scale_fill_manual(values = c("home" = "#2a9d8f", "away" = "#e76f51")) +
  labs(
    title = "Goal Conversion Rate: Home vs. Away",
    subtitle = "Percentage of Total Shots Converted into Goals",
    x = "Team",
    y = "Conversion Rate (%)",
    fill = "Match Location"
  ) +
  theme_minimal() +
  
  theme(
    panel.grid.major.y = element_line(color = "gray90", linewidth = 0.5),
    panel.grid.minor.y = element_blank()
  )

4.2.2 Shooting Efficiency

# Unpivot home and away shooting data into a single team-level summary

home_attacking <- pl_df %>%
  select(team = HomeTeam, goals = full_time_home_goals,
         shots = home_shots, target = home_shots_on_target)

away_attacking <- pl_df %>%
  select(team = AwayTeam, goals = full_time_away_goals,
         shots = away_shots, target = away_shots_on_target)

# Aggregate metrics 

attacking_efficiency <- bind_rows(home_attacking, away_attacking) %>%
  group_by(team) %>%
  summarise(
    matches = n(),
    total_goals = sum(goals, na.rm = TRUE),
    total_shots = sum(shots, na.rm = TRUE),
    total_target = sum(target, na.rm = TRUE),
    
    # Derived Metrics
    shots_per_game = round(total_shots / matches, 1),
    shooting_accuracy_pct = round((total_target / total_shots) * 100, 1),
    target_conversion_pct = round((total_goals / total_target) * 100, 1),
    overall_conversion_pct = round((total_goals / total_shots) * 100, 1)
  ) %>%
  arrange(desc(target_conversion_pct))
attacking_efficiency
efficiency_long <- team_efficiency %>%
  
  select(team, home_conversion, away_conversion) %>%
  
  pivot_longer(cols = c(home_conversion, away_conversion), 
               
               names_to = "location", 
               
               values_to = "conversion_rate") %>%

  mutate(location = ifelse(location == "home_conversion", "home", "away"))
# Calculate league averages for quadrant baselines
avg_acc <- mean(attacking_efficiency$shooting_accuracy_pct)
avg_conv <- mean(attacking_efficiency$target_conversion_pct)

ggplot(attacking_efficiency, aes(x = shooting_accuracy_pct, y = target_conversion_pct)) +
  
  # Average lines
  geom_vline(xintercept = avg_acc, linetype = "dashed", color = "gray60") +
  geom_hline(yintercept = avg_conv, linetype = "dashed", color = "gray60") +
  
  # High-contrast points
  geom_point(color = "#2B7A78", size = 4, alpha = 0.85) +
  
  #  Smart repelling text labels (prevents clipping & missing labels)
  geom_text_repel(
    aes(label = team),
    size = 3.2,
    fontface = "bold",
    box.padding = 0.35,
    point.padding = 0.3,
    max.overlaps = Inf
  ) +
  
  labs(
    title = "Attacking Precision vs. Finishing Quality",
    subtitle = "Dashed lines represent PL average",
    x = "Shooting Accuracy (% Shots on Target)",
    y = "% Shots on Target to Goals"
  ) +
  theme_minimal(base_size = 12) +
  # Fix margin clipping for y-axis title
  theme(
    plot.title = element_text(face = "bold", size = 14),
    plot.margin = margin(t = 10, r = 15, b = 10, l = 20),
    panel.grid.minor = element_blank()
  )

4.2.3 Halftime Score vs Fulltime Result

# Create a halftime vs fulltime table
ht_ft_matrix <- pl_df %>%
  count(half_time_result, full_time_result) %>%
  group_by(half_time_result) %>%
  mutate(
    total_HT_games = sum(n),
    probability_pct = round((n / total_HT_games) * 100, 1)
  )

Total of 9 possible scenarios at halftime with the amount of times it happened and the full time result

ht_ft_matrix
# Summary of Game State Outcomes

comeback_summary <- pl_df %>%
  mutate(
    # Identify half-time state from the home perspective
    HT_state = case_when(
      half_time_home_goals > half_time_away_goals ~ "Home Winning",
      half_time_home_goals < half_time_away_goals ~ "Away Winning",
      TRUE ~ "Drawn at HT"
    ),
    # Check if the team trailing at HT won or drew at FT
    comeback_occurred = case_when(
      HT_state == "Home Winning" & full_time_result %in% c("D", "A") ~ TRUE,
      HT_state == "Away Winning" & full_time_result %in% c("D", "H") ~ TRUE,
      TRUE ~ FALSE
    )
  )

# Team-Level Points Dropped / Recovered Analysis

team_comebacks <- bind_rows(
  # Home perspective
  pl_df %>% 
    filter(half_time_home_goals < half_time_away_goals) %>%
    select(team = HomeTeam, FT_result = full_time_result) %>%
    mutate(recovered = ifelse(FT_result == "H", 3, ifelse(FT_result == "D", 1, 0))),
  
  # Away perspective
  pl_df %>% 
    filter(half_time_away_goals < half_time_home_goals) %>%
    select(ream = AwayTeam, FT_result = full_time_result) %>%
    mutate(recovered = ifelse(FT_result == "A", 3, ifelse(FT_result == "D", 1, 0)))
) %>%
  group_by(team) %>%
  summarise(
    games_trailing_HT = n(),
    points_recovered = sum(recovered),
    avg_points_recovered_per_trailing_game = round(points_recovered / games_trailing_HT, 2)
  ) %>%
  arrange(desc(points_recovered))
team_comebacks
ggplot(ht_ft_matrix, aes(x = half_time_result, y = probability_pct, fill = full_time_result)) +
  geom_bar(stat = "identity", position = "stack", width = 0.6) +
  geom_text(aes(label = paste0(probability_pct, "%")), 
            position = position_stack(vjust = 0.5), 
            color = "white", size = 3.5) +
  scale_fill_manual(
    values = c( "A" = "#f05151", "D" = "#707070", "H" = "#45ad4e"),
    labels = c("A" = "Away Win", "D" = "Draw", "H" = "Home Win")
  ) +
  scale_x_discrete(
    limits = c("H", "D", "A"),
    labels = c("A" = "Away Leading at HT", "D" = "Tied at HT",
               "H" = "Home Leading at HT")) +
  labs(
    title = "Full-Time Outcome Probability based on Half-Time Result",
    x = "Half-Time Result",
    y = "Probability (%)",
    fill = "Full-Time Result"
  ) +
  theme_minimal()

5 worldfootballR

# Install pre-built binary directly (No GitHub, No Rtools required) into Console install.packages( “worldfootballR”, repos = c(“https://jaseziv.r-universe.dev”, “https://cloud.r-project.org”) )

library(worldfootballR)
#Documentation to help with using functions

#help(package = "worldfootballR")
# List of all functions available in worldfootballR with prefix of each source

## fb = fbRef, tm = TransferMarkt, understat = UnderStat

#ls("package:worldfootballR")

5.1 FBref

5.1.1 Shooting Insight for Players

# Loads player/team stats for the Big 5 leagues (stat_type can be: "standard", "shooting", "passing", "defense", "possession")

epl_player_shooting <- load_fb_big5_advanced_season_stats(
  season_end_year = 2025,
  stat_type = "shooting",
  team_or_player = "player"
) %>%
  filter(Comp == "Premier League")
## → Data last updated 2025-09-18 17:40:11.6921770572662 UTC
head(epl_player_shooting)

5.1.2 Top 10

# Using this to show Top 10 xG Overperformers (Clinical Finishers)


clinical_finishers <- epl_player_shooting %>%
  mutate(
    Gls_Standard = as.numeric(Gls_Standard),
    xG_Expected = as.numeric(xG_Expected),
    Mins_Per_90 = as.numeric(Mins_Per_90), ## Proportion of available mins played
    xG_Diff = Gls_Standard - xG_Expected
  ) %>%
  # Filter for players with at least 10 full 90-minute appearances
  filter(Mins_Per_90 >= 10) %>%
  select(Player, Squad, Mins_Per_90, Gls_Standard, xG_Expected, xG_Diff) %>%
  arrange(desc(xG_Diff))

head(clinical_finishers, 10)

5.1.3 Visual - Top 10

# Prepare data with formatted labels

top_finishers_plot <- clinical_finishers %>%
  head(10) %>%
  mutate(Player = fct_reorder(Player, xG_Diff)) # Order bars by performance

# Plot Bar Chart

ggplot(top_finishers_plot, aes(x = xG_Diff, y = Player, fill = xG_Diff)) +
  geom_col(width = 0.7) +
  geom_vline(xintercept = 0, linetype = "dashed", color = "gray40") +
  geom_text(aes(label = sprintf("%+.2f", xG_Diff)), 
            hjust = -0.2, size = 3.5, fontface = "bold") +
  scale_fill_gradient(low = "#ddbaf5", high = "#8611d4") +
  scale_x_continuous(expand = expansion(mult = c(0, 0.2))) +
  labs(
    title = "Top 10 Clinical Finishers (EPL 2024/25)",
    subtitle = "Players overperforming their Expected Goals (Goals - xG)",
    x = "Expected Goals Difference (G - xG)",
    y = NULL
  ) +
  theme_minimal() +
  theme(
    legend.position = "none",
    panel.grid.major.y = element_blank(),
    plot.title = element_text(face = "bold", size = 14)
  )

5.2 UnderStat

5.2.1 Detailed Shot Insight for Players

# 1. Load pre-scraped shot data reliably
epl_shots <- load_understat_league_shots(league = "EPL")
## → Data last updated 2025-09-18 18:43:25.4242129325867 UTC
# 2. Filter for your desired season (e.g., "2023" or "2024") and clean metrics
shot_level_clean <- epl_shots %>%
  filter(season == 2024) %>%  # Adjust to 2023 or 2024
  select(
    id, player, team = home_team, minute, result, 
    X, Y, xG, shotType, situation
  ) %>%
  mutate(
    xG = as.numeric(xG),
    X_m = as.numeric(X) * 105,
    Y_m = as.numeric(Y) * 68,
    Distance_m = round(sqrt((105 - X_m)^2 + (34 - Y_m)^2), 1)
  )

# Preview cleaned output
head(shot_level_clean)

5.2.2 Detailed Shot Insight for Teams

## Group all rows into teams and store in one dataframe 

team_finishing_efficiency <- shot_level_clean %>%
  group_by(team) %>%
  summarise(
    Total_Shots = n(),
    Goals_Scored = sum(result == "Goal"),
    Total_xG = round(sum(xG), 2),
    
    # Core Quality Metrics
    xG_per_Shot = round(mean(xG), 3),            # Average probability per attempt
    Avg_Shot_Distance = round(mean(Distance_m), 1),# Average distance from goal (m)
    
    # Finishing Efficiency Indicator
    Finishing_Overperformance = round(Goals_Scored - Total_xG, 2)
  ) %>%
  arrange(desc(xG_per_Shot))
team_finishing_efficiency

5.2.3 Visual - Team Finishing

# short name for team names

team_finishing_efficiency <- team_finishing_efficiency %>%
  mutate(team_code = case_match(
    team,
    "Manchester City" ~ "MCI",
    "Manchester United" ~ "MUN",
    "Newcastle United" ~ "NEW",
    "Nottingham Forest" ~ "NFO",
    "Wolverhampton Wanderers" ~ "WOL",
    "Aston Villa" ~ "AVL",
    "Bournemouth" ~ "BOU",
    "Southampton" ~ "SOU",
    "Tottenham" ~ "TOT",
    .default = toupper(substr(team, 1, 3))
  ))

# League averages for x and y axis

avg_dist <- mean(team_finishing_efficiency$Avg_Shot_Distance, na.rm = TRUE)
avg_xg   <- mean(team_finishing_efficiency$xG_per_Shot, na.rm = TRUE)

# Plot with average quadrant lines

ggplot(team_finishing_efficiency, aes(x = Avg_Shot_Distance, y = xG_per_Shot, label = team_code)) +
  
  
  # Vertical line for average shot distance
  geom_vline(xintercept = avg_dist, linetype = "dashed", color = "gray50", linewidth = 0.6) +
  # Horizontal line for average xG per shot
  geom_hline(yintercept = avg_xg, linetype = "dashed", color = "gray50", linewidth = 0.6) +
  
  
  geom_point(aes(size = Total_Shots, color = Finishing_Overperformance), alpha = 0.8) +
  geom_text(vjust = -1, size = 3, check_overlap = TRUE) +
  
  
  scale_color_gradient2(
    low = "#eb412a", mid = "gray70", high = "#2b5c8f", midpoint = 0,
    name = "Goals - xG"
  ) +
  labs(
    title = "Shot Efficiency (EPL 24/25)",
    subtitle = "Dashed lines represent Premier League averages",
    x = "Average Shot Distance (M)",
    y = "Expected Goals per Shot (xG / Shot)",
    size = "Total Shots"
  ) +
  theme_minimal()