For this assignment, the collaborative activity of Module 9. Football Analysis with R from the MsC in Data Analytics in Football, I will comment about a couple of websites that provides free football data, as well as some useful libraries for R that can be used for analysis and presentation of the collected data. In order to so, I’ll use the match that took place on May 2nd 20216, between Chelsea and Tottenham Hotspur, nicknamed the “Battle of the Bridge. I will also analyze individual performance of Eden Hazard during this match as he was chosen as the MVP of the match.
Let’s begin.
For this assignment, we first need to install some libraries like
engsoccerdata, ggshakeR, and
statsBombR, as these will be used for:
engsoccerdata
— public data source: this is a free R package bundling English
and European league results back to 1888. No API key, no scraping, no
rate limits — just historical data, which I’ll use as my data source for
the match analysis.ggshakeR
— specialised library:, an analysis-and-visualisation package
built specifically for soccer data. I will use it to break down the
match end-to-end.StatsBombR
— data access layer:, the package this module already uses to
pull free match event data (competitions, matches, and full event
streams) from StatsBomb’s open data repository into R as clean data
frames. It does no analysis or plotting of its own — it’s the plumbing
that gets the Chelsea–Spurs match into R in the first place, which
ggshakeR then turns into the actual plots below.All three are R packages/libraries in the programming sense, as we
need to install.packages()/remotes::install_github() them
first, then library() them to load functions into your
session, same as any Python/npm package. We just need to do this
once.
knitr::opts_chunk$set(echo = TRUE, message = FALSE, warning = FALSE,
fig.align = "center")
options(repos = c(CRAN = "https://cran.r-project.org"))
cran_pkgs <- c("dplyr", "tidyr", "ggplot2", "remotes", "hexbin", "deldir")
missing_cran <- cran_pkgs[!cran_pkgs %in% installed.packages()[, 1]]
if (length(missing_cran) > 0) install.packages(missing_cran, quiet = TRUE)
if (!"engsoccerdata" %in% installed.packages()[, 1]) {
remotes::install_github("jalapic/engsoccerdata", upgrade = "never", quiet = TRUE)
}
if (!"StatsBombR" %in% installed.packages()[, 1]) {
remotes::install_github("statsbomb/StatsBombR", upgrade = "never", quiet = TRUE)
}
if (!"ggshakeR" %in% installed.packages()[, 1]) {
remotes::install_github("abhiamishra/ggshakeR", upgrade = "never", quiet = TRUE)
}
library(dplyr)
library(tidyr)
library(ggplot2)
engsoccerdataengsoccerdata (James Curley, maintained by Ryan Elmore)
is not a scraper or an API wrapper — it is the data, bundled
straight into an R package. It ships full match-result histories for
England’s top four tiers (1888–2025) plus ten other European leagues,
along with small helper functions for league tables and head-to-head
records. And because it’s just data and helper functions, there is
nothing to authenticate, nothing to break when a website redesigns its
front end, and it installs in seconds. This is why I selected it for
this assignment: most of other free sites requires you to create an
account, get an API Key, and use it as part of your requests, capping
you at a certain limit of requests per day; instead, to use
engsoccerdata we just need to install the package once and
that’s it.
Here’s an example of how to get English Premier League data and which seasons are available:
library(engsoccerdata)
top_flight <- england %>% filter(division == "1")
cat("Rows:", nrow(top_flight), "\n")
## Rows: 50570
cat("Seasons covered:", min(top_flight$Season), "-", max(top_flight$Season), "\n")
## Seasons covered: 1888 - 2024
The package also ships small convenience functions on top of the raw
results. For example, maketable() reconstructs a league
table for any season — here’s the final 2015/216 Premier League table,
which lines up exactly with the Leicester City title, title that was
secured for Leicester with the late equalizer from Eden Hazard in “The
Battle of The Bridge”.
maketable(df = england, Season = 2015, tier = 1) %>% head(6)
Another interesting function is games_between_sum(),
which gives an all-time head-to-head record. Chelsea vs. Tottenham
Hotspur, for example, has these numbers.
games_between_sum(england, "Chelsea", "Tottenham Hotspur")
As this library has data collected since 1888, we can also do deeper analysis on it. For example, “Home advantage” is one of football’s oldest truisms: we all believe that the Home team has some kind of advantange by simply playing in their own stadium, with their own fans rooting for them. If we analyze this metric using this library, we can aggregate the 136 seasons of top-flight results in one data frame, and we can actually check it:
by_season <- top_flight %>%
group_by(Season) %>%
summarise(
home_win_pct = mean(result == "H") * 100,
draw_pct = mean(result == "D") * 100,
away_win_pct = mean(result == "A") * 100,
.groups = "drop"
)
# Every season, in 136 years, where away wins outnumbered home wins:
by_season %>% filter(away_win_pct > home_win_pct)
There is exactly just one such season on record, the 2020/21, were this did not happen. In every other season since 1888, home teams won more often than away teams. That’s a strong, data-backed way to demonstrate that home advantage is real.
If your project needs long historical time series — trend analysis,
home-advantage studies, promotion/relegation patterns, “how has scoring
changed” questions — engsoccerdata gets you a clean data
frame in one line, with zero scraping fragility. It’s a good complement
to event-level packages like StatsBombR, which only cover a
handful of individual competitions in depth but nothing close to this
time span.
ggshakeRThere are multiple R libraries specialized in fetching, storing,
manipulating, transforming, and displaying the data. For example,
StatsBombR library, mentioned in the module knowledge
material, is purely a data-access layer — it gets
StatsBomb’s raw event data into R and does no plotting or modelling of
its own. Another example is worldfootballR, which is
soccer-focused too but is also just an extraction wrapper;
unfortunately, the latter it’s no longer actively maintained.
The library of ggshakeR sits one layer up: it’s an
analysis and visualization package/library built
specifically for soccer. It takes event data you already have (from
StatsBombR, Opta, or Understat for example) and turns it
into pitch-relative plots and possession-value models that would
otherwise take dozens of lines of custom ggplot2 code
each.
library(StatsBombR)
library(ggshakeR)
The following table describes some useful function families in
ggshakeR:
| Function | What it does |
|---|---|
plot_pass() |
Pass map for a player/team, split by outcome, with progressive/cross/switch filters |
plot_heatmap() |
Touch/event density map (bin, hex, density, or joint-density styles) |
plot_passnet() |
Team pass network — average position + connection strength between players |
plot_voronoi() |
Voronoi tessellation of a team’s average shape |
plot_shot() |
Shot map from Understat data (xG, result, location) |
calculate_threat() |
Karun Singh’s published Expected Threat (xT) model — values every pass/carry by how much it raises the probability of scoring soon after |
calculate_epv() |
Expected Possession Value, an alternative possession-value model |
To showcase the usage of the mentioned functions I’ll analyze now the
performance of Eden Hazard, MVP of the match “The Battle of The Bridge”.
That data we retrieved from the engsoccerdata we installed
before.
The relevance on this match relies on the fact that it decided the 2015/16 Premier League title: not for either team on the pitch, but for Leicester City. Tottenham led 2–0 at half-time (goals from Harry Kane and Son Heung-min) and just needed a draw to keep the title race alive. Chelsea manager Guus Hiddink brought Eden Hazard on for Pedro at the break. Gary Cahill pulled one back on 57 minutes, and Hazard curled in a stoppage-time equaliser, his first goal in a year, to make it 2–2. With this tie, Tottenham Hotspur lost any chance of becoming champions, and this confirmed Leicester as the title holders.
We pull the match from archive in engsoccerdata and we
complement it with extra data from StatsBombR (competition
2 = Premier League, season 27 = 2015/16, from StatsBomb’s free open
data) and cache the result locally so re-knitting the report doesn’t
re-download it every time:
cache_file <- "data/hazard_match_clean.rds"
if (file.exists(cache_file)) {
plotting_data <- readRDS(cache_file)
} else {
comp <- FreeCompetitions() %>% filter(competition_id == 2, season_id == 27)
matches <- FreeMatches(comp)
match <- matches %>% filter(match_id == 3754092) # Chelsea vs Tottenham, 2016-05-02
raw <- free_allevents(MatchesDF = match, Parallel = TRUE)
plotting_data <- allclean(raw) %>%
rename(x = location.x, y = location.y,
finalX = pass.end_location.x, finalY = pass.end_location.y)
dir.create("data", showWarnings = FALSE)
saveRDS(plotting_data, cache_file)
}
nrow(plotting_data)
## [1] 3299
For this part, we will use ggshakeR’s built-in xG-trend
function (plot_trendline()) which is designed for
season-long rolling averages across many matches, not a single
match’s shot-by-shot story — so for a single-match cumulative xG
timeline (“worm chart”) we combine ggshakeR’s data
(StatsBomb’s own xG model, shot.statsbomb_xg) with plain
dplyr/ggplot2. This is a natural way to work
with the package: it doesn’t try to cover every possible chart, and
plugs cleanly into the regular tidyverse for the rest.
shots <- plotting_data %>%
filter(type.name == "Shot") %>%
transmute(team.name, minute, xg = shot.statsbomb_xg,
outcome = shot.outcome.name, player.name,
is_goal = shot.outcome.name == "Goal") %>%
arrange(minute)
goals <- shots %>% filter(is_goal) %>% select(minute, team.name, player.name, xg)
goals
Below we can see every goal in this match, with its underlying goal chance:
Two of the four goals in this match were low-probability events, and Hazard’s title-deciding strike was one of them, which was exactly the “moment of individual quality” the eye-test remembers, now backed by the shot model rather than just the highlight reel.
cum <- shots %>%
arrange(minute) %>%
group_by(team.name) %>%
mutate(cum_xg = cumsum(xg)) %>%
ungroup()
teams <- unique(shots$team.name)
starts <- tibble(team.name = teams, minute = 0, cum_xg = 0)
ends <- cum %>% group_by(team.name) %>% slice_max(minute, n = 1) %>%
transmute(team.name, minute = 96, cum_xg)
step_data <- bind_rows(starts, cum %>% select(team.name, minute, cum_xg), ends) %>%
arrange(team.name, minute)
goal_points <- goals %>%
left_join(cum %>% select(team.name, minute, cum_xg), by = c("team.name", "minute"))
ggplot(step_data, aes(x = minute, y = cum_xg, color = team.name)) +
geom_step(linewidth = 1) +
geom_point(data = goal_points, aes(x = minute, y = cum_xg), size = 3) +
scale_color_manual(values = c("Chelsea" = "#034694", "Tottenham Hotspur" = "#132257")) +
labs(title = "Cumulative xG - Chelsea 2-2 Tottenham Hotspur (2 May 2016)",
subtitle = "Hazard's 82nd-minute strike (marked) completed Chelsea's comeback",
x = "Minute", y = "Cumulative xG", color = NULL,
caption = "Data: StatsBomb Open Data via StatsBombR + ggshakeR") +
theme_minimal(base_size = 12) +
theme(legend.position = "top")
Hazard played the entire second half (on at 45’, through to full time), recording 140 touches, a genuinely large involvement for a player introduced at the break.
And here comes another useful function from this library, the
Pass map (plot_pass()). We can also split
them by outcome:
hazard_passes <- plotting_data %>%
filter(player.name == "Eden Hazard", type.name == "Pass")
plot_pass(data = hazard_passes, data_type = "statsbomb", type = "sep")
Heat map (plot_heatmap()) of every
touch:
hazard_touches <- plotting_data %>%
filter(player.name == "Eden Hazard") %>%
select(x, y)
plot_heatmap(data = hazard_touches, type = "density") +
theme(legend.position = "none")
Brighter areas are where Hazard spent the most time on the ball; darker areas, the least. The bright patch in the inside-left channel and half-space matches his known role: a nominal left winger who drifted inside to link play and create shooting angles, rather than staying on the touchline.
A really interesting metrics in the world of football data anlytics
it’s the On-Ball Value (OBV): it’s a StatsBomb/Hudl proprietary metric
and, unfortunately, is not included in their free open data; it ships
only through their paid Event Data/IQ products. However,
ggshakeR gives us the closest open alternative:
calculate_threat(), an implementation of Karun Singh’s
published Expected Threat (xT) model. Instead of
StatsBomb’s proprietary scoring/conceding-probability model, xT values
every pass and carry by how much it moves the ball into pitch zones
associated with a higher chance of scoring soon after.
To obtain this xT for the players involved in this match, what we do is:
xt_data <- calculate_threat(data = plotting_data, type = "statsbomb") %>%
mutate(xT_added = xTEnd - xTStart)
# Match-wide leaderboard, passes and carries only
xt_leaderboard <- xt_data %>%
filter(type.name %in% c("Pass", "Carry"), !is.na(xT_added)) %>%
group_by(player.name, team.name) %>%
summarise(total_xT = sum(xT_added), actions = n(), .groups = "drop") %>%
arrange(desc(total_xT))
head(xt_leaderboard, 8)
Hazard ranks 4th on the pitch for total xT added (0.51, from 28 valid actions) despite playing only 51 minutes — a high rate of threat creation per action, driven by his passing while Chelsea chased the game. Note what this metric doesn’t capture, though: xT (like OBV) values passes and carries, not shots, so it says nothing about the decisive goal itself.
So, in order to provide more context, we paired it with the xG timeline to tell the buildup story: the xG chart (displayed earlier in this report) tells the finishing story, and together they cover what a single proprietary OBV number would have summarized in one figure.
When working with these kind of libraries, here or in any other coding language, we should evaluate their limitations to foresee any possible scenarios in which our projects can break, or which can make that the data is not being handled properly. To mention some:
plot_shot(), the Understat-based shot-map function,
currently fails for this analysis of Hazard; Understat.com changed the
internal JSON structure its page embeds, which broke
understatr (the scraper ggshakeR relies on for
that path). It’s a good reminder that scraped sources, however free,
carry real maintenance risk, unlike a bundled data set like
engsoccerdata or a maintained API wrapper like
StatsBombR.plot_heatmap(type = "hex") and
plot_voronoi() need the hexbin and
deldir packages installed as well. This is an example of
what could waste your time trying to debug why your project is not
running well: these dependencies are not properly documented as hard
dependencies, so users might ignore they are needed as well. These are
worth installing upfront, as we do in our ‘Installing Packages’
section.FreeMatches() actually
contains the team and match you want before building around it.This assignment shows that free football data is genuinely usable for
real analysis, but the kind of free source matters.
engsoccerdata is a bundled dataset: nearly
maintenance-free, and precisely because of that it let us ask a question
no live API easily supports — 136 years of results, and the one and only
season where away wins outnumbered home wins. StatsBombR is
a maintained API wrapper: reliable, but only as rich as the specific
competitions it covers, so it’s what we relied on to actually pull the
Chelsea–Tottenham Hotspur match. ggshakeR then showed what
a proper analysis library adds on top of that data: xG evolution, a pass
map, a heatmap, and a more complex metric such as xT, all in a few lines
each instead of hand-built ggplot2. Put together, the two
halves of this task told a fuller story about Eden Hazard’s “Battle of
the Bridge” than either data source could have on its own.