Introduction

The Australian Football League (AFL) is the premier Australian Rules Football competition in Australia, featuring 18 teams who play for the ultimate premiership success. Over the years, the league has expanded via the introduction of teams from Western Australia, South Australia, Queensland and New South Wales to form the AFL from the VFL, which featured solely Victorian teams. With the current 18 team league existing since 2012, this will become our bound for our analysis in this project.

Aim

The aim of this investigation is to determine whether, over time, home team’s have gradually scored more points in each contest. Furthermore, we wish to examine whether there is an observed seasonality trend of home team within the season, hypothesising that there is a drop in average home team scores in the middle months when games are affected by typical Winter weather conditions.

Methods

All analytics were conducted in R using RStudio. A Time Series analysis was employed, using the timetk, parsnip and rsample packages to model and forecast the scoring of the Home Team by month in the AFL.

Analysis

Installation of packages into R library

library(dplyr)
## 
## Attaching package: 'dplyr'
## The following objects are masked from 'package:stats':
## 
##     filter, lag
## The following objects are masked from 'package:base':
## 
##     intersect, setdiff, setequal, union
library(tidyverse)
## ── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
## ✔ forcats   1.0.1     ✔ readr     2.2.0
## ✔ ggplot2   4.0.3     ✔ stringr   1.6.0
## ✔ lubridate 1.9.5     ✔ tibble    3.3.1
## ✔ purrr     1.2.2     ✔ tidyr     1.3.2
## ── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
## ✖ dplyr::filter() masks stats::filter()
## ✖ dplyr::lag()    masks stats::lag()
## ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors
library(fitzRoy)
## Warning: package 'fitzRoy' was built under R version 4.6.1
library(ggplot2)
library(timetk)
library(modeltime)
library(lubridate)
library(xgboost)
library(parsnip)
library(rsample)
library(feasts)
## Loading required package: fabletools
## 
## Attaching package: 'fabletools'
## 
## The following object is masked from 'package:parsnip':
## 
##     null_model

Upload of data and data cleaning

#Wish to upload data from 2012 until 2025.
#This encompasses the 18 team seasons of the AFL and close-to uniform observations for each team as the home team
afl_data <- fitzRoy::fetch_results_afltables(season = 2012:2025) |>
  glimpse()
## Rows: 2,879
## Columns: 16
## $ Game         <dbl> 13960, 13961, 13962, 13963, 13964, 13965, 13966, 13967, 1…
## $ Date         <date> 2012-03-24, 2012-03-29, 2012-03-30, 2012-03-31, 2012-03-…
## $ Round        <chr> "R1", "R1", "R1", "R1", "R1", "R1", "R1", "R1", "R1", "R2…
## $ Home.Team    <chr> "GWS", "Richmond", "Hawthorn", "Melbourne", "Gold Coast",…
## $ Home.Goals   <int> 5, 12, 20, 11, 10, 15, 16, 12, 13, 9, 16, 14, 25, 11, 12,…
## $ Home.Behinds <int> 7, 9, 17, 12, 8, 12, 9, 15, 11, 9, 15, 10, 16, 16, 13, 15…
## $ Home.Points  <int> 37, 81, 137, 78, 68, 102, 105, 87, 89, 63, 111, 94, 166, …
## $ Away.Team    <chr> "Sydney", "Carlton", "Collingwood", "Brisbane Lions", "Ad…
## $ Away.Goals   <int> 14, 18, 16, 17, 19, 14, 15, 21, 13, 23, 13, 12, 9, 9, 8, …
## $ Away.Behinds <int> 16, 17, 19, 17, 23, 20, 11, 10, 7, 16, 8, 9, 4, 10, 16, 6…
## $ Away.Points  <int> 100, 125, 115, 119, 137, 104, 101, 136, 85, 154, 86, 81, …
## $ Venue        <chr> "Stadium Australia", "M.C.G.", "M.C.G.", "M.C.G.", "Carra…
## $ Margin       <int> -63, -44, 22, -41, -69, -2, 4, -49, 4, -91, 25, 13, 108, …
## $ Season       <dbl> 2012, 2012, 2012, 2012, 2012, 2012, 2012, 2012, 2012, 201…
## $ Round.Type   <chr> "Regular", "Regular", "Regular", "Regular", "Regular", "R…
## $ Round.Number <int> 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, …

As the 2020 season was affected by the COVID-19 outbreak, the AFL put measures in place to protect the players. One of these measures was to shorten the quarters to a flat 20 minutes rather than the 20 minutes plus “time on”, which accounts for all stoppages of play in the initial 20 minutes. Therefore, as a result of the differing quarter lengths and the clear impact this would have on scoring, the 2020 seasons are removed from the dataset.

#Remove the 2020 season from the dataset - COVID affected season
afl_data <- afl_data %>%
  filter(year(Date) != 2020)
#Confirm the Year 2020 not in the dataset
print(unique(afl_data$Season))
##  [1] 2012 2013 2014 2015 2016 2017 2018 2019 2021 2022 2023 2024 2025

As seen, no 2020 season evident so data successfully removed ahead of analysis and the data visualisation and time series analysis can commence.

Exploratory Data Analysis

#Create a histogram of Home Team points scored
ggplot() +
  geom_histogram(aes(x = Home.Points), data = afl_data)
## `stat_bin()` using `bins = 30`. Pick better value `binwidth`.

The Home points scored by the teams show a slightly right skewed distribution, which is feasible as there are likely to be games where a team is able to outclass their opposition and out up a much larger score than usual.

#Facet wrap the above plot by Season - try observe any general histogram trends by season
ggplot() +
  geom_histogram(aes(x = Home.Points), data = afl_data) +
  facet_wrap(~Season)
## `stat_bin()` using `bins = 30`. Pick better value `binwidth`.

#Facet wrap by round number to observe any trends
#Is the aim question regarding a dip of scoring in middle months/rounds observed
ggplot() +
  geom_histogram(aes(x = Home.Points), data = afl_data) +
  facet_wrap(~Round.Number)
## `stat_bin()` using `bins = 30`. Pick better value `binwidth`.

Subsetting by season shows no major difference, if anything, more compact distributions in more recent seasons indicating high outlier scores are less common. Having seen this, lets commence the Time Series analysis to determine whether there actually is a discernable change in the home team score’s across time. Furthermore, subsetting by round shows much of the outlying, high scores occur at the opposite ends of the season while the distribution remains similar without the high scores through the middle rounds of the season. This lends into the theory that the home team’s average scores fluctuate within the season, with the middle months rain affected and resulting in lower average scores compared to the start and end of season which features more dry weather and favourable game conditions.

#Facet wrap by round type - is scoring pattern similar regular vs finals?
ggplot() +
  geom_histogram(aes(x = Home.Points), data = afl_data) +
  facet_wrap(~Round.Type)
## `stat_bin()` using `bins = 30`. Pick better value `binwidth`.

Although distributions will be hard to ascertain due to the volume of games in regular season vs finals, this test shows that much of the scores occurring in Finals games are similar to those in the regular season. Furthermore, the distribution of home team scores in finals shows a histogram forming of similar shape to the regular season, with the mean and median potentially occurring at a similar point.

#Commence Time Series Analysis process with data splitting into training and testing sets

# Convert data frame to tsibble for time series package compatability
afl_data_ts <- afl_data |>
  dplyr::mutate(Date = tsibble::yearmonth(Date)) |>
  dplyr::group_by(Date) |>
  dplyr::summarise(Home.Points = mean(Home.Points, na.rm = TRUE)) |>
  tsibble::as_tsibble(index = Date)

#Split the data set up - this time series split will use preset proportion of 75 train/25 test
afl_split <- rsample::initial_time_split(afl_data_ts)

# Pass - Splits to training and testing objects
afl_train <- rsample::training(afl_split)
afl_test <- rsample::testing(afl_split)

# Plot - Testing and training data using ggplot()
afl_train |>
  autoplot(Home.Points)
## Warning: `autoplot.tbl_ts()` was deprecated in fabletools 0.6.0.
## ℹ Please use `ggtime::autoplot.tbl_ts()` instead.
## ℹ Graphics functions have been moved to the {ggtime} package. Please use
##   `library(ggtime)` instead.
## This warning is displayed once per session.
## Call `lifecycle::last_lifecycle_warnings()` to see where this warning was
## generated.

afl_test |>
  autoplot(Home.Points)

#Model the train and test of the data on one plot
bind_rows(
  afl_train |> 
    dplyr::mutate(type = "train"),
  afl_test |>  
    dplyr::mutate(type = "test")
  ) |> 
  dplyr::mutate(Date = as.Date(Date)) |>
  ggplot(aes(x = Date, y = Home.Points, col=type)) +
  geom_line()

As evidenced in the above plot, which colour codes the training and testing sets, the trained data has a lot more variability in its monthly averages. These also start at a much higher point, with home teams averaging close to 100 points through the month in the 2012-2013 seasons. This is in contrast to the testing set, which is more compact and indicate that home teams are scoring at a more constant level of 85-90 points on average per month with some fluctuation.

#Examine model diagnostics of the time series data using the timetk package
#Allow to examine seasonal and long-term trend, remainders, monthly distributions and more

# Plot - Seasonal diagnostics
afl_data_ts |>
  dplyr::mutate(Date = as.Date(Date)) |>
  timetk::plot_seasonal_diagnostics(.date_var = Date,
                            .value= Home.Points)
# Plot - Anomaly detection
afl_data_ts |>
  dplyr::mutate(Date = as.Date(Date)) |>
  timetk::plot_anomaly_diagnostics(.date_var = Date,
                 .value = Home.Points)
## frequency = 7 observations per 1 year
## trend = 36 observations per 5 years
## Warning: `aes_string()` was deprecated in ggplot2 3.0.0.
## ℹ Please use tidy evaluation idioms with `aes()`.
## ℹ See also `vignette("ggplot2-in-packages")` for more information.
## ℹ The deprecated feature was likely used in the timetk package.
##   Please report the issue at
##   <https://github.com/business-science/timetk/issues>.
## This warning is displayed once per session.
## Call `lifecycle::last_lifecycle_warnings()` to see where this warning was
## generated.
# Plot - ACF/PACF
afl_data_ts |>
  dplyr::mutate(Date = as.Date(Date)) |>
  timetk::plot_acf_diagnostics(.date_var = Date,
                 .value = Home.Points)
## Max lag exceeds data available. Using max lag: 91
# Plot - STL diagnostics
afl_data_ts |>
  dplyr::mutate(Date = as.Date(Date)) |>
  timetk::plot_stl_diagnostics(.date_var = Date,
                 .value = Home.Points)
## frequency = 7 observations per 1 year
## trend = 36 observations per 5 years

When examining the diagnostic plots on the AFL Time Series data, it can be seen that the trend of Home Points scored by month has actually been trending down, disputing the hypothesis that the home points scored on average was increasing. There is, however, a small tail showing an increase again in home points scored on average by month in the past 2 seasons (2024 and 2025). Furthermore, investigating the remainder and season adjusted plots highlight the data is relatively controlled, with small fluctuations apart from some outliers around 2015 and 2016 where monthly scores bottomed out and then peaked on average. The ACF and PACF plots show most of the data points are contained within the bounds, showing weak auto-correlation and no seasonality trend that can be clearly observed. This would inidcate that there is not a seasonal trend of high scores early and late in the season (drier weather) with lower scores on average in the middle of the season when the Winter weather may impact the games. Furthermore, the season diagnostics prove this as the box-plots for each month are centred at relatively similar points along the scale, indicating there is no major fluctuation in home points scored throughout the season.

#Time tk works on plain tibbles, so 2nd dataframe created as a tibble rather than tsibble

#Time series dataset 2 - tibble dataset
afl_data_ts2 <- afl_data_ts |>
  dplyr::mutate(Date = as.Date(Date)) |>
  tibble::as_tibble() 

#Conduct another data set split on the tibble data - same 75/25 split used
afl_split_2 <- rsample::initial_time_split(afl_data_ts2)

# Create - Testing and training data
afl_train_2 <- rsample::training(afl_split_2)
afl_test_2 <- rsample::testing(afl_split_2)

#Model the training vs testing data on one plot - much the same as the ggplot method on the tsibble dataframe
afl_split_2 |>
  timetk::tk_time_series_cv_plan() |>
  timetk::plot_time_series_cv_plan(.date_var = Date,
                                   .value = Home.Points)

For the Time Series analysis, 3 different models have been chosen to create the analysis and forecasting. These are the ARIMA xgBoosted regression, the Exponential smoothing using Error/Trend/Seasonality (ETS) and the Naive regression fit. These 3 models fit in vastly different ways, which lends into the purpose of selecting such differing methods to observe the differences in forecasts and whether any of the 3 can achieve a possible forecast on the data which has been discovered to have no seasonal trends.

#Auto ARIMA xgBoosted regression
xg_arima_fit <- arima_boost() |>
  parsnip::set_engine("auto_arima_xgboost") |>
  parsnip::fit(Home.Points ~ Date, data = afl_train_2)
## frequency = 7 observations per 1 year
#Exponential smoothing fit using ETS
ets_fit <- exp_smoothing() |>
  parsnip::set_engine("ets") |>
  parsnip::fit(Home.Points ~ Date, data = afl_train_2)
## frequency = 7 observations per 1 year
#Naive regression fit - just uses prior datapoint
naive_fit <- naive_reg() |>
  set_engine("naive") |>
  fit(Home.Points ~ Date, data = afl_train_2)

# Compare - Model Fits - modeltime_table()
afl_models <- modeltime::modeltime_table(
                             xg_arima_fit,
                             ets_fit,
                             naive_fit)

# Print - Table output
print(afl_models)
## # Modeltime Table
## # A tibble: 3 × 3
##   .model_id .model   .model_desc 
##       <int> <list>   <chr>       
## 1         1 <fit[+]> ARIMA(0,1,1)
## 2         2 <fit[+]> ETS(A,A,N)  
## 3         3 <fit[+]> NAIVE
# Calibrate - Regression models using modeltime_calibrate() on testing data
calibrate_afl <- afl_models |>
  modeltime::modeltime_calibrate(new_data = afl_test_2)

print(calibrate_afl)
## # Modeltime Table
## # A tibble: 3 × 5
##   .model_id .model   .model_desc  .type .calibration_data
##       <int> <list>   <chr>        <chr> <list>           
## 1         1 <fit[+]> ARIMA(0,1,1) Test  <tibble [23 × 4]>
## 2         2 <fit[+]> ETS(A,A,N)   Test  <tibble [23 × 4]>
## 3         3 <fit[+]> NAIVE        Test  <tibble [23 × 4]>
#Calibrate the models on the testing dataset, overlaying the predictions on top of the observed testing data

calibrate_afl |>
  modeltime::modeltime_forecast(actual_data = afl_data_ts2, new_data = afl_test_2) |>
  modeltime::plot_modeltime_forecast()

When examining the model calibration over the testing data, we can see the general trend of the ETS model is a slow decline of average home team points, despite the consistent but non-patterned fluctuations of the monthly average of home team points scored. Furthermore, the ARIMA and Naive models both predict with a flat, linear line, both fitting around the middle but providing no fluctuating predictions based off the observed model.

#Examine the model accuracy metrics for its performance

calibrate_afl |>
  modeltime::modeltime_accuracy()
## Warning: There were 2 warnings in `dplyr::mutate()`.
## The first warning was:
## ℹ In argument: `.nested.col = purrr::map(...)`.
## Caused by warning:
## ! A correlation computation is required, but `estimate` is constant and has 0
## standard deviation, resulting in a divide by 0 error. `NA` will be returned.
## ℹ Run `dplyr::last_dplyr_warnings()` to see the 1 remaining warning.
## # A tibble: 3 × 9
##   .model_id .model_desc  .type   mae  mape  mase smape  rmse      rsq
##       <int> <chr>        <chr> <dbl> <dbl> <dbl> <dbl> <dbl>    <dbl>
## 1         1 ARIMA(0,1,1) Test   3.95  4.51 0.600  4.57  5.01 NA      
## 2         2 ETS(A,A,N)   Test   6.71  7.52 1.02   7.91  7.84  0.00886
## 3         3 NAIVE        Test   3.78  4.43 0.573  4.36  4.79 NA

Examining the model accuracy and assessment metrics, it is clear the exponential smoothing method struggles in all cpacities to model the data. This is a strong indication of the lack of seasonality and trend observed in the data which this method relies on, contradicting the belief that home team’s score more year-on-year, while fluctuations in season occur as a result of weather season trends. Beyond this, the Naive model suprisingly has the best error metrics of the 3, likely indicating the final monthly observation is a close occurence to the total mean and the Naive model’s flat, linear line sits in a window which is considered feasibly normal for a home team to score on average. However, the Naive and ARIMA models do not display an r-squared statistic, indicating the constant linear line produced in the prediction on the new data which fails to model any variance. Meanwhile, as the ETS model is a constant, declining linear prediction, the variance modelled by this method is very low and indicated by a r-squared statistic value below 0.01.

# Refit - Calibrated model on m750 data
refit_afl <- calibrate_afl |>
  modeltime::modeltime_refit(data = afl_data_ts2)
## frequency = 7 observations per 1 year
## frequency = 7 observations per 1 year
# Forecast - Forwards by 5 years and plot the forecasts
refit_afl |> 
  modeltime::modeltime_forecast(h = "5 years", actual_data = afl_data_ts2) |> 
  modeltime::plot_modeltime_forecast()

As evidenced by the forecasted model, the lack of seasonal trend in the data of fluctuating average scores within the season has made it difficult for any of the 3 models to predict the future average scores for home teams within each month. Furthermore, as evidenced by the Exponential Smoothing method which uses the Error, Trend and Seasonality components, it is forecasting a decline in average home points scored by month moving forward. With the model’s inability to identify seasonality, this prediction is a linear line, however, incorporates the trend of a small decline in average monthly scores by the home team and factored this into the model. We also see that this ETS Exponential Smoothing method has the largest confidence interval bound, indicating that the model has struggled to identify the forecast going forward. In terms of the ARIMA xgBoosted model, this has only been able to forecast a linear trend line, much like the Naive model, indicating the predictor chosen was insufficient to produce seasonal time series trends which would work for such analysis.

Conclusion and Further Considerations

Ultimately, this introduction into Time Series analysis on AFL Home Team scoring showed that sometimes, the hypothesised prediction of what may be evident is not true. In both cases, the seasonal trend of lower scoring in the middle of the season due to more wet weather games, and the steady increase, on average, of Home team scoring in the AFL were disproved through this analysis. The trend of home team scoring is on a steady decline, with a small uptick observed late in the dataframe but not enough to distinctly say there is an trending increase in home team scoring.

Moving forwarding, considering such analysis on the Away Team points as well as the Total Points in a game would be interesting to see if such trends are also observed in those factors or if it is different. Additionally, considering this analysis at a week-by-week level could also be beneficial for to see whether home teams (or the other considered levels of points scoring in AFL) perform better at certain points of the season, as well as whether there is a seasonal trend of scoring on a week-by-week basis. Additionally, with the AFL being such a data-rich sport, further statistics of interest can be considered as potentially having seasonal trends (disposals, tackles and marks).