library(tidyverse)   # readr, dplyr, tidyr, ggplot2, purrr, stringr
library(knitr)       # kable() for tidy tables

1 Overview

When COVID-19 forced European football to restart in empty stadiums, researchers at the University of Leeds and Northumbria University treated it as a natural experiment on home advantage. Analysing 4,844 matches across 11 countries, McCarrick et al. found that home teams’ advantage roughly halved without fans — from 0.39 to 0.22 extra points per game and from 0.29 to 0.15 extra goals per game — while also winning 0.7 fewer corners, taking 1.3 fewer shots, and conceding more fouls to the referee’s whistle.

The article I am working from is the University of Leeds summary, “How empty stadiums affected football during pandemic”, which reports on the peer-reviewed paper “Home advantage during the COVID-19 pandemic: Analyses of European football leagues” (Psychology of Sport and Exercise, 2021).

My goal in this assignment is not to replicate their statistics. It is to take the raw, heavily abbreviated match data those kinds of studies rely on and turn it into a clean, well-named data frame that could support that analysis — and several later ones.

2 About the Data

2.1 Source

The data is English Premier League match-by-match results originally published by football-data.co.uk, the same source behind the English Premier League (EPL) Match Data 2000-2025 dataset on Kaggle. I use 25 seasons, 2000–01 through 2024–25, which is 9,500 matches.

Each season lives in its own CSV file, all sharing an identical 22-column schema. The files are read directly from a public GitHub repository, so this document is reproducible on any machine — nothing is read from my local disk.

2.2 The raw column names

The raw files use terse abbreviations. This table is the data dictionary I will use to rename them:

data_dictionary <- tribble(
  ~raw_name, ~meaning,                                    ~keep,
  "Date",    "Match date",                                "yes",
  "HomeTeam","Home team name",                            "yes",
  "AwayTeam","Away team name",                            "yes",
  "FTHG",    "Full Time Home Goals",                      "yes",
  "FTAG",    "Full Time Away Goals",                      "yes",
  "FTR",     "Full Time Result: H / D / A  (TARGET)",     "yes",
  "HTHG",    "Half Time Home Goals",                      "no",
  "HTAG",    "Half Time Away Goals",                      "no",
  "HTR",     "Half Time Result",                          "no",
  "Referee", "Match referee",                             "yes",
  "HS",      "Home Shots",                                "yes",
  "AS",      "Away Shots",                                "yes",
  "HST",     "Home Shots on Target",                      "yes",
  "AST",     "Away Shots on Target",                      "yes",
  "HF",      "Home Fouls committed",                      "yes",
  "AF",      "Away Fouls committed",                      "yes",
  "HC",      "Home Corners",                              "yes",
  "AC",      "Away Corners",                              "yes",
  "HY",      "Home Yellow cards",                         "yes",
  "AY",      "Away Yellow cards",                         "yes",
  "HR",      "Home Red cards",                            "yes",
  "AR",      "Away Red cards",                            "yes"
)

kable(data_dictionary, caption = "Raw columns, their meaning, and whether I keep them")
Raw columns, their meaning, and whether I keep them
raw_name meaning keep
Date Match date yes
HomeTeam Home team name yes
AwayTeam Away team name yes
FTHG Full Time Home Goals yes
FTAG Full Time Away Goals yes
FTR Full Time Result: H / D / A (TARGET) yes
HTHG Half Time Home Goals no
HTAG Half Time Away Goals no
HTR Half Time Result no
Referee Match referee yes
HS Home Shots yes
AS Away Shots yes
HST Home Shots on Target yes
AST Away Shots on Target yes
HF Home Fouls committed yes
AF Away Fouls committed yes
HC Home Corners yes
AC Away Corners yes
HY Home Yellow cards yes
AY Away Yellow cards yes
HR Home Red cards yes
AR Away Red cards yes

FTR is the obvious target (dependent) variable: it is what a match-outcome model would predict, and its values are the single-letter codes H, D, and A that the assignment asks us to translate into something a human can read.

3 Step 1 — Load the raw data from the web

Each season is a separate file, so I build the 25 URLs programmatically and read them into one data frame with purrr::map_dfr(). The season column is created from the file code as the files are stacked, so I never lose track of which season a row came from.

base_url <- "https://raw.githubusercontent.com/AnissSahraoui/DATA607/main/data/raw"

season_codes <- c("0001","0102","0203","0304","0405","0506","0607","0708","0809",
                  "0910","1011","1112","1213","1314","1415","1516","1617","1718",
                  "1819","1920","2021","2122","2223","2324","2425")

# Turn "0001" into the readable label "2000-01"
season_label <- function(code) {
  start <- as.integer(substr(code, 1, 2))
  start_year <- if_else(start >= 90, 1900L + start, 2000L + start)
  paste0(start_year, "-", substr(code, 3, 4))
}

epl_raw <- map_dfr(
  season_codes,
  function(code) {
    read_csv(file.path(base_url, paste0("season-", code, ".csv")),
             show_col_types = FALSE) |>
      mutate(season = season_label(code), .before = 1)
  }
)

dim(epl_raw)
## [1] 9500   23

4 Step 2 — Inspect what arrived

Before changing anything, I look at the structure and a few rows. This is where you catch a column that was read as text when it should be a number, or a date that did not parse.

glimpse(epl_raw)
## Rows: 9,500
## Columns: 23
## $ season   <chr> "2000-01", "2000-01", "2000-01", "2000-01", "2000-01", "2000-…
## $ Date     <date> 2000-08-19, 2000-08-19, 2000-08-19, 2000-08-19, 2000-08-19, …
## $ HomeTeam <chr> "Charlton", "Chelsea", "Coventry", "Derby", "Leeds", "Leicest…
## $ AwayTeam <chr> "Man City", "West Ham", "Middlesbrough", "Southampton", "Ever…
## $ FTHG     <dbl> 4, 4, 1, 2, 2, 0, 1, 1, 3, 2, 2, 2, 1, 1, 3, 4, 3, 1, 0, 5, 0…
## $ FTAG     <dbl> 0, 2, 3, 2, 0, 0, 0, 0, 1, 0, 0, 0, 1, 1, 0, 2, 2, 2, 1, 3, 0…
## $ FTR      <chr> "H", "H", "A", "D", "H", "D", "H", "H", "H", "H", "H", "H", "…
## $ HTHG     <dbl> 2, 1, 1, 1, 2, 0, 0, 0, 2, 1, 1, 1, 1, 0, 0, 2, 1, 0, 0, 1, 0…
## $ HTAG     <dbl> 0, 0, 1, 2, 0, 0, 0, 0, 1, 0, 0, 0, 1, 1, 0, 0, 1, 1, 0, 2, 0…
## $ HTR      <chr> "H", "H", "D", "A", "H", "D", "D", "D", "H", "H", "H", "H", "…
## $ Referee  <chr> "Rob Harris", "Graham Barber", "Barry Knight", "Andy D'Urso",…
## $ HS       <dbl> 17, 17, 6, 6, 17, 5, 16, 8, 20, 19, 17, 12, 13, 12, 13, 15, 9…
## $ AS       <dbl> 8, 12, 16, 13, 12, 5, 3, 14, 15, 9, 7, 14, 15, 11, 8, 9, 10, …
## $ HST      <dbl> 14, 10, 3, 4, 8, 4, 10, 2, 6, 9, 12, 3, 8, 6, 8, 10, 4, 4, 12…
## $ AST      <dbl> 4, 5, 9, 6, 6, 3, 2, 7, 5, 6, 4, 6, 6, 4, 4, 4, 5, 5, 2, 4, 8…
## $ HF       <dbl> 13, 19, 15, 11, 21, 12, 8, 10, 14, 7, 25, 14, 10, 9, 17, 24, …
## $ AF       <dbl> 12, 14, 21, 13, 20, 12, 8, 21, 13, 13, 20, 16, 7, 18, 15, 14,…
## $ HC       <dbl> 6, 7, 8, 5, 6, 5, 6, 2, 3, 7, 10, 6, 4, 5, 3, 7, 9, 6, 11, 8,…
## $ AC       <dbl> 6, 7, 4, 8, 4, 4, 1, 9, 4, 1, 11, 4, 6, 5, 5, 3, 6, 5, 5, 3, …
## $ HY       <dbl> 1, 1, 5, 1, 1, 2, 1, 3, 0, 0, 2, 0, 1, 2, 2, 3, 0, 5, 3, 0, 1…
## $ AY       <dbl> 2, 2, 3, 1, 3, 3, 1, 1, 0, 1, 4, 1, 4, 1, 1, 3, 3, 3, 3, 1, 2…
## $ HR       <dbl> 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0…
## $ AR       <dbl> 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 2, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0…
head(epl_raw, 5)

The Date column came through as a real Date type and the count columns as numbers, so no type repair is needed.

5 Step 3 — Select a subset of columns

I drop the three half-time columns (HTHG, HTAG, HTR). They are legitimate data, but a match’s half-time state is a consequence of the same game rather than information you would have before kickoff, so it is not useful for the kind of pre-match prediction I want to do later. Everything else is kept.

epl_subset <- epl_raw |>
  select(
    season, Date, HomeTeam, AwayTeam,
    FTHG, FTAG, FTR,          # goals and the target
    HS, AS, HST, AST,         # shots
    HC, AC,                   # corners
    HF, AF,                   # fouls
    HY, AY, HR, AR,           # cards
    Referee
  )

ncol(epl_raw)      # before
## [1] 23
ncol(epl_subset)   # after
## [1] 20

6 Step 4 — Give the columns meaningful names

rename() takes new_name = old_name. I use snake_case throughout, spell out home and away rather than H and A, and say shots_on_target instead of HST. The names are longer, but every one of them is self-explanatory six months from now.

epl_named <- epl_subset |>
  rename(
    match_date            = Date,
    home_team             = HomeTeam,
    away_team             = AwayTeam,
    home_goals            = FTHG,
    away_goals            = FTAG,
    full_time_result      = FTR,
    home_shots            = HS,
    away_shots            = AS,
    home_shots_on_target  = HST,
    away_shots_on_target  = AST,
    home_corners          = HC,
    away_corners          = AC,
    home_fouls            = HF,
    away_fouls            = AF,
    home_yellow_cards     = HY,
    away_yellow_cards     = AY,
    home_red_cards        = HR,
    away_red_cards        = AR,
    referee               = Referee
  )

names(epl_named)
##  [1] "season"               "match_date"           "home_team"           
##  [4] "away_team"            "home_goals"           "away_goals"          
##  [7] "full_time_result"     "home_shots"           "away_shots"          
## [10] "home_shots_on_target" "away_shots_on_target" "home_corners"        
## [13] "away_corners"         "home_fouls"           "away_fouls"          
## [16] "home_yellow_cards"    "away_yellow_cards"    "home_red_cards"      
## [19] "away_red_cards"       "referee"

7 Step 5 — Replace the non-intuitive abbreviations

This is the step the assignment calls out explicitly. The target variable full_time_result stores H, D, and A. On its own, A is ambiguous — it could plausibly mean “Away win” or “Abandoned”. I recode it to full text and make it an ordered factor so that it always sorts Home Win → Draw → Away Win in tables and plots.

epl_decoded <- epl_named |>
  mutate(
    full_time_result = recode(full_time_result,
                              "H" = "Home Win",
                              "D" = "Draw",
                              "A" = "Away Win"),
    full_time_result = factor(full_time_result,
                              levels = c("Home Win", "Draw", "Away Win"))
  )

count(epl_decoded, full_time_result)

8 Step 6 — Add derived variables

A few columns that are not in the raw file but that I will want repeatedly. crowd_status is the one that connects this data frame back to the article: the Premier League played behind closed doors from the 17 June 2020 restart until the end of the 2020–21 season.

# Premier League behind-closed-doors window.
# Caveat: a handful of December 2020 fixtures and the final matchday (23 May 2021)
# admitted limited crowds, so this flag is a good approximation, not a perfect one.
bcd_start <- as.Date("2020-06-17")
bcd_end   <- as.Date("2021-05-19")

epl_clean <- epl_decoded |>
  mutate(
    crowd_status = if_else(match_date >= bcd_start & match_date <= bcd_end,
                           "Behind closed doors", "Fans present"),
    crowd_status = factor(crowd_status, levels = c("Fans present", "Behind closed doors")),
    goal_difference = home_goals - away_goals,          # positive = home team ahead
    total_goals     = home_goals + away_goals,
    home_points     = case_when(                        # league points won by the home team
      full_time_result == "Home Win" ~ 3L,
      full_time_result == "Draw"     ~ 1L,
      full_time_result == "Away Win" ~ 0L
    ),
    away_points     = case_when(
      full_time_result == "Home Win" ~ 0L,
      full_time_result == "Draw"     ~ 1L,
      full_time_result == "Away Win" ~ 3L
    )
  ) |>
  relocate(crowd_status, .after = match_date)

count(epl_clean, crowd_status)

9 Step 7 — Check the finished data frame

A transformation is not done until you have looked for missing and impossible values.

# How many missing values are in each column?
missing_by_column <- epl_clean |>
  summarise(across(everything(), ~ sum(is.na(.)))) |>
  pivot_longer(everything(), names_to = "column", values_to = "n_missing")

cat("Total missing values in the data frame:", sum(missing_by_column$n_missing), "\n")
## Total missing values in the data frame: 0
missing_by_column |>
  filter(n_missing > 0) |>
  kable(caption = "Columns containing missing values (empty table means none)")
Columns containing missing values (empty table means none)
column n_missing
# Sanity checks: goals should never be negative, and the recoded result
# should always agree with the goals actually scored.
epl_clean |>
  summarise(
    n_matches          = n(),
    n_seasons          = n_distinct(season),
    n_teams            = n_distinct(home_team),
    negative_goals     = sum(home_goals < 0 | away_goals < 0),
    result_mismatches  = sum(
      (goal_difference > 0 & full_time_result != "Home Win") |
      (goal_difference == 0 & full_time_result != "Draw") |
      (goal_difference < 0 & full_time_result != "Away Win")
    )
  ) |>
  kable(caption = "Sanity checks on the cleaned data frame")
Sanity checks on the cleaned data frame
n_matches n_seasons n_teams negative_goals result_mismatches
9500 25 46 0 0

Two things to note. The data frame has no missing values at all — unusual, and worth stating explicitly rather than assuming. And zero result mismatches means the recoding in Step 5 is consistent with every scoreline: no row claims a “Home Win” while the away side scored more. The 46 distinct teams across 25 seasons is also the number you would expect, given promotion and relegation churn.

glimpse(epl_clean)
## Rows: 9,500
## Columns: 25
## $ season               <chr> "2000-01", "2000-01", "2000-01", "2000-01", "2000…
## $ match_date           <date> 2000-08-19, 2000-08-19, 2000-08-19, 2000-08-19, …
## $ crowd_status         <fct> Fans present, Fans present, Fans present, Fans pr…
## $ home_team            <chr> "Charlton", "Chelsea", "Coventry", "Derby", "Leed…
## $ away_team            <chr> "Man City", "West Ham", "Middlesbrough", "Southam…
## $ home_goals           <dbl> 4, 4, 1, 2, 2, 0, 1, 1, 3, 2, 2, 2, 1, 1, 3, 4, 3…
## $ away_goals           <dbl> 0, 2, 3, 2, 0, 0, 0, 0, 1, 0, 0, 0, 1, 1, 0, 2, 2…
## $ full_time_result     <fct> Home Win, Home Win, Away Win, Draw, Home Win, Dra…
## $ home_shots           <dbl> 17, 17, 6, 6, 17, 5, 16, 8, 20, 19, 17, 12, 13, 1…
## $ away_shots           <dbl> 8, 12, 16, 13, 12, 5, 3, 14, 15, 9, 7, 14, 15, 11…
## $ home_shots_on_target <dbl> 14, 10, 3, 4, 8, 4, 10, 2, 6, 9, 12, 3, 8, 6, 8, …
## $ away_shots_on_target <dbl> 4, 5, 9, 6, 6, 3, 2, 7, 5, 6, 4, 6, 6, 4, 4, 4, 5…
## $ home_corners         <dbl> 6, 7, 8, 5, 6, 5, 6, 2, 3, 7, 10, 6, 4, 5, 3, 7, …
## $ away_corners         <dbl> 6, 7, 4, 8, 4, 4, 1, 9, 4, 1, 11, 4, 6, 5, 5, 3, …
## $ home_fouls           <dbl> 13, 19, 15, 11, 21, 12, 8, 10, 14, 7, 25, 14, 10,…
## $ away_fouls           <dbl> 12, 14, 21, 13, 20, 12, 8, 21, 13, 13, 20, 16, 7,…
## $ home_yellow_cards    <dbl> 1, 1, 5, 1, 1, 2, 1, 3, 0, 0, 2, 0, 1, 2, 2, 3, 0…
## $ away_yellow_cards    <dbl> 2, 2, 3, 1, 3, 3, 1, 1, 0, 1, 4, 1, 4, 1, 1, 3, 3…
## $ home_red_cards       <dbl> 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1…
## $ away_red_cards       <dbl> 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 2, 0, 0, 0, 1, 0, 0…
## $ referee              <chr> "Rob Harris", "Graham Barber", "Barry Knight", "A…
## $ goal_difference      <dbl> 4, 2, -2, 0, 2, 0, 1, 1, 2, 2, 2, 2, 0, 0, 3, 2, …
## $ total_goals          <dbl> 4, 6, 4, 4, 2, 0, 1, 1, 4, 2, 2, 2, 2, 2, 3, 6, 5…
## $ home_points          <int> 3, 3, 0, 1, 3, 1, 3, 3, 3, 3, 3, 3, 1, 1, 3, 3, 3…
## $ away_points          <int> 0, 0, 3, 1, 0, 1, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0…
head(epl_clean, 10)

This is the deliverable: 9,500 matches × 25 columns, every name readable, the target variable spelled out in words, and no dependency on my local machine.

10 A First Look at the Data

The cleaned frame is now easy to ask questions of. Two quick looks — the assignment does not require these, but they show the transformation actually paid off.

10.1 Home advantage over 25 seasons

season_summary <- epl_clean |>
  group_by(season) |>
  summarise(
    home_win_pct     = mean(full_time_result == "Home Win") * 100,
    covid_affected   = any(crowd_status == "Behind closed doors"),
    .groups = "drop"
  )

ggplot(season_summary, aes(x = season, y = home_win_pct, fill = covid_affected)) +
  geom_col() +
  scale_fill_manual(values = c("FALSE" = "grey65", "TRUE" = "#c0392b"),
                    labels = c("Normal season", "COVID-affected season"),
                    name = NULL) +
  labs(
    title = "Home win percentage, English Premier League 2000-01 to 2024-25",
    subtitle = "2020-21, played almost entirely without fans, is the lowest of the 25 seasons",
    x = NULL, y = "Home wins (% of matches)"
  ) +
  theme_minimal(base_size = 11) +
  theme(axis.text.x = element_text(angle = 60, hjust = 1),
        legend.position = "top")

10.2 Fans present vs. behind closed doors

The article reported home advantage falling from 0.39 to 0.22 points per game. Here is the same comparison computed from my cleaned frame, using only Premier League matches:

epl_clean |>
  group_by(crowd_status) |>
  summarise(
    matches                 = n(),
    `home pts advantage`    = round(mean(home_points) - mean(away_points), 2),
    `home goal advantage`   = round(mean(goal_difference), 2),
    `home shot advantage`   = round(mean(home_shots - away_shots), 2),
    `home corner advantage` = round(mean(home_corners - away_corners), 2),
    `home foul difference`  = round(mean(home_fouls - away_fouls), 2),
    `home yellow diff`      = round(mean(home_yellow_cards - away_yellow_cards), 2),
    .groups = "drop"
  ) |>
  kable(caption = "Home-team advantage with and without crowds (EPL only, 2000-01 to 2024-25)")
Home-team advantage with and without crowds (EPL only, 2000-01 to 2024-25)
crowd_status matches home pts advantage home goal advantage home shot advantage home corner advantage home foul difference home yellow diff
Fans present 9038 0.51 0.37 2.89 1.29 -0.54 -0.34
Behind closed doors 462 0.01 0.06 1.24 0.75 0.48 -0.02

The direction matches the published study on every measure, and in the Premier League the effect is even starker than the pooled European figure. Home advantage did not merely halve — it very nearly vanished. With fans, the home side collected 0.51 more points per game; behind closed doors, 0.01 — statistically indistinguishable from no advantage at all. Home wins fell from 46.1% of matches to 39.0%, while away wins rose from 29.0% to 38.7%, leaving the two almost level. The foul column flips sign: home teams were normally whistled for fewer fouls than their opponents (−0.54 per game), but in empty stadiums they were whistled for more (+0.48).

My figures are larger than the article’s because the comparison groups differ: the paper pooled 11 countries over a short matched window, while my “fans present” baseline is 24 seasons of English football. The 462 behind-closed-doors matches are also a small sample, so this is a descriptive comparison, not a causal estimate.

11 Findings and Recommendations

11.1 How I would extend, verify, or update this work

  • Verify the crowd flag. My crowd_status variable is a date range, and it is wrong for the handful of December 2020 fixtures and the final 2020–21 matchday that admitted limited crowds. Joining a per-match attendance figure would replace an approximation with a fact, and would let me treat crowd size as continuous rather than on/off.
  • Control for schedule strength. The behind-closed-doors period was also a period of congested fixtures, five substitutes, and no pre-season. Any of those could explain part of the effect I attributed to the crowd. A model with team and season fixed effects would separate them.
  • Update with more leagues. Adding the Championship, Bundesliga, La Liga and Serie A files from the same source would take one more loop and would let me check whether the effect size differs by country, which is what the original paper actually tested.
  • Move toward prediction. With full_time_result as the target and the shot, corner and card columns as features, this frame is ready for a multinomial classification model. The honest version requires care: shots and corners are recorded during the match, so a genuine pre-match model has to use rolling team form computed from prior fixtures instead.
  • Look at referees. The referee column is untouched here, but the article’s finding about fouls and cards is really a claim about officials. Grouping card rates by referee and crowd status is a natural follow-up.

12 References