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.
path <- "C:/Users/karan/Documents/MSc/M9_Football_Analysis_with_R/Collab"
knitr::opts_knit$set(root.dir = path)## [1] "C:/Users/karan/Documents/MSc/M9_Football_Analysis_with_R/Collab"
All 380 matches of PL season alongside 120 columns
## [1] 380 120
Summary of data with datatypes, completeness, mean etc
## 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
## [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
)From the available data, here are three examples of the type of analysis that can be carried out
# 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)
)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))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()
)# 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))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()
)# 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
# 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))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()# 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”) )
# List of all functions available in worldfootballR with prefix of each source
## fb = fbRef, tm = TransferMarkt, understat = UnderStat
#ls("package:worldfootballR")# 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
# 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)# 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)
)## → 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)## 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))# 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()