SVP100 - Hitting The Wall
Data analysis on a running race
- 1. Import packages and set configurations
- 2. Generate unformatted table of race data
- 3. Calculate the time for each runner between checkpoints
- 4. Calculate the minutes per KM for each runner between checkpoints
- 5. Calculate averages for three groups
- 6. Plot results on line chart
In August of 2017 I ran the SVP100. This is a 100KM trail running race starting in Newmarket (Suffolk, UK) and ending in Manning Tree (Essex, UK). In regular distance marathons, its widely believed runners "hit the wall" at around 30KM mark (or 70% through the race). In this notebook, I want to see if the same holds true over a 100KM distance.
From a high level, the code below performs the following:
- Scrape results data from the race website (using Beautiful Soup)
- Render scraped data into a DataFrame (using Pandas)
- Format (or wrangle) the data into formats we can work with
- Present results in time series graph (using Seaborn)
import datetime
import time
import requests
from bs4 import BeautifulSoup
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
Then we set the plotting configuration. I really like the "fivethirtyeight" stylesheet which generates plots in the style used by fivethirtyeight.com.
%matplotlib inline
%pylab inline
# Set style to fivethirtyeight to create clean and clear looking graphs
plt.style.use('fivethirtyeight')
# Define a dictionary containing default plotting configurations
params = {'legend.fontsize': 'small',
'figure.figsize': (12, 4.5),
'axes.labelsize': 'small',
'axes.titlesize':'medium',
'xtick.labelsize':'small',
'ytick.labelsize':'small'}
pylab.rcParams.update(params)
Next define some constants that we'll use in the notebook. First we define a URL which has a table which contains race result data. Then we have some details about the race distance.
BASE_URL = "http://www.svp100.co.uk/results-"
YEAR = 2017
# Constants of race details
DISTANCE_BETWEEN_CPS = [19.312, 17.702, 16.094, 17.702, 9.657, 13.679, 7.242]
CPS_IN_KM = cp_miles = [(i * 1.60934) for i in [12, 23, 33, 44, 50, 58.5, 63]]
def df_builder(base_url=BASE_URL, year=YEAR):
"""
This function returns a pandas DataFrame which contain data scraped from the race website.
The data is unformatted.
Attributes:
-----------
base_url (str): the url which contains race data in HTML
year (int): the year for we would like data
"""
# Scrape the data from the race website
url = base_url + str(year)
r = requests.get(url).text
soup = BeautifulSoup(r, 'lxml')
# Find tables from the html
rows = soup.find_all('tr')[1:]
# Collect and format column names for the dataframe
column_html = soup.find_all('th')[:]
columns = [i.contents[0].lower().replace("/","").replace(" ", "_") for i in column_html if i.contents[0]]
# Build a dataframe
data = []
for line in rows:
row = line.find_all('td')
row_list = []
for counter, value in enumerate(row):
row_list.append(row[counter].string)
data.append(row_list)
# Remove columns we don't need
df = pd.DataFrame(data, columns=columns).drop(columns = ["name", "club", "mf", "bib", "total_time"]).set_index("position")
return df
Generate a DataFrame of race results for the year 2017.
unformatted_df = df_builder(BASE_URL, YEAR)
unformatted_df.head(2)
Scanning the data, we notice a few things...
-
The "time" related columns are strings representing a time of day. We'll need to convert these to datetimes so we can calculate the time (in seconds) between each checkpoint.
-
There are some None values which will need to be removed.
unformatted_df = unformatted_df.dropna().copy()
Now onto our function...
def calculate_time_to_checkpoints(df):
"""
This function returns a pandas DataFrame which contains time in seconds it took each
runner to reach a checkpoint
Attributes:
-----------
df (DataFrame): a dataframe of race results
"""
# List out a set of columns which we'll need to convert into datimes
dt_cols = ["start", "cp1", "cp2", "cp3", "cp4", "cp5", "cp6", "finish"]
# Convert these columns to datetimes
for col in dt_cols:
df[col] = df[col].apply(lambda x: pd.to_datetime("2017-08-08 " + str(x)))
# Then calculate the time in seconds between each checkpoint
for start, end in zip(dt_cols, dt_cols[1:]):
df[f"time_to_{end}"] = (df[end] - df[start]).apply(lambda x: x.seconds)
# Finally lets drop the old 'time of day' columns
df = df.drop(columns=dt_cols).rename(columns={"time_to_finish": "time_to_cp7"}).copy()
return df
df_time_to_cp = calculate_time_to_checkpoints(unformatted_df)
df_time_to_cp.head(3)
def calc_mins_per_km(df):
"""
This function returns a pandas DataFrame which contains min per km
it took each runner to move between checkpoints
Attributes:
-----------
df (DataFrame): a dataframe of race results
"""
df = df.copy()
for cp_time, distance in zip(df.columns, DISTANCE_BETWEEN_CPS):
df[f"min_per_km_{cp_time[-3:]}"] = df[cp_time].apply(lambda x: (x / distance) / 60)
df.drop(columns=[cp_time], inplace=True)
df.columns = CPS_IN_KM
return df
The DataFrame below shows how long it took to move between each checkpoint. In this DataFrame we've also replaced the checkpoint numbers with their distances in kilometers.
df_min_per_km = calc_mins_per_km(df_time_to_cp)
df_min_per_km.head(3)
all_runners = pd.DataFrame(df_min_per_km.mean(), columns=["all_runners"])
top_10_runners = pd.DataFrame(df_min_per_km.head(10).mean(), columns=["top_10"])
me = pd.DataFrame(df_min_per_km.filter(items=['7'], axis=0).mean(), columns=["me"])
plots = all_runners.join(top_10_runners, lsuffix = "_all", rsuffix = "_t10").join(me)
plots.index.rename("kilometer", inplace=True)
plots
Looking at the results below, we can see that all three groups move in a similar pattern, slowing down substantially at 80KM, before picking up the pace for the final 20KM.
plots.plot(y = ["all_runners","top_10","me"], figsize=(12,7))
plt.title('Average pace of runners (minutes per km)')
plt.ylabel('Minutes per km', fontsize="small")
plt.xlabel('Kilometers')
plt.show()