This tutorial aims to teach you how to use regression analysis to understand relationships in sports data. The tutorial will teach you:

Simple linear regression:

A simple linear regression models the relationship between the independent variable X and a dependent variable Y, by estimating how much Y will change when X changes by a certain value. Y is also known as the response variable. X is known as the predictor variable ( eg weather, time of year etc)

The equation: Y = b0 + b1X.

Where

Multiple Linear Regression:

Now a multiple linear regression is simply the use of a linear regression with multiple variables:

The equation: Y = b0 = b1X1 + b2X2 + b3X3 + … bnXn + e
e = the residuals / model error. This defines the amount of variation in the model.

Multiple Linear Regression Assumptions:

L - Linearity: The relationship between X and Y is linear
I - Independence: Observations are independent of each other
N - Normality: Residuals are normally distributed
E - Equal variance: Constant variance across all levels of X

When to use

When not to use

Code in R

Load Required Packages

# install.packages("fitzRoy")  # Uncomment if not installed
# install.packages("tidyverse")
# install.packages("GGally")

library(tidyverse)
## ── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
## ✔ dplyr     1.1.4     ✔ readr     2.1.5
## ✔ forcats   1.0.0     ✔ stringr   1.5.1
## ✔ ggplot2   3.5.2     ✔ tibble    3.3.0
## ✔ lubridate 1.9.4     ✔ tidyr     1.3.1
## ✔ purrr     1.1.0     
## ── 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(GGally)
library(fitzRoy)

Dataset and data preparation

#Load data results
afl_data <- fetch_results_afltables(season = 2015:2023)

#preview
head(afl_data)
## # A tibble: 6 × 16
##    Game Date       Round Home.Team Home.Goals Home.Behinds Home.Points Away.Team
##   <dbl> <date>     <chr> <chr>          <int>        <int>       <int> <chr>    
## 1 14581 2015-04-02 R1    Carlton           11           12          78 Richmond 
## 2 14582 2015-04-04 R1    Melbourne         17           13         115 Gold Coa…
## 3 14583 2015-04-04 R1    Sydney            10           12          72 Essendon 
## 4 14584 2015-04-04 R1    Brisbane…         11            8          74 Collingw…
## 5 14585 2015-04-04 R1    Footscray         14           13          97 West Coa…
## 6 14586 2015-04-05 R1    St Kilda          11           12          78 GWS      
## # ℹ 8 more variables: Away.Goals <int>, Away.Behinds <int>, Away.Points <int>,
## #   Venue <chr>, Margin <int>, Season <dbl>, Round.Type <chr>,
## #   Round.Number <int>
# I am going to focus on the sydney swans home game and create a win and a margin variable

swans <- afl_data %>%
  filter(Home.Team == "Sydney") %>%
  mutate(
    Margin = Home.Points - Away.Points,
    Win = ifelse(Margin > 0, 1, 0)
  ) %>%
  select(Date, Season, Round, Home.Team, Away.Team, 
         Home.Points, Away.Points, Margin, Win, Venue)
head(swans)
## # A tibble: 6 × 10
##   Date       Season Round Home.Team Away.Team Home.Points Away.Points Margin
##   <date>      <dbl> <chr> <chr>     <chr>           <int>       <int>  <int>
## 1 2015-04-04   2015 R1    Sydney    Essendon           72          60     12
## 2 2015-04-18   2015 R3    Sydney    GWS               111          90     21
## 3 2015-05-02   2015 R5    Sydney    Footscray          73          77     -4
## 4 2015-05-16   2015 R7    Sydney    Geelong           120          77     43
## 5 2015-05-29   2015 R9    Sydney    Carlton           122          62     60
## 6 2015-06-26   2015 R13   Sydney    Richmond           77          95    -18
## # ℹ 2 more variables: Win <dbl>, Venue <chr>

Dataset variables we have

  • Date of match

  • Season

  • Round

  • Home team (Sydney)

  • Away team - opposition

  • Home points - Points scored by Sydney

  • Away points - Points scored by opposition

  • Venue - where match was played

  • Win - 1 = Win, 0 = Loss

  • Margin - Difference in scores (Home Points - Away Points)

So we could look into a question like:
What factors affect Sydney Swans’ home winning margin.

Exploratory Data Analysis

# Summary stats
summary(swans$Margin)
##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
##  -89.00   -7.50    9.50   17.25   42.25  171.00
swans %>%
  summarise(
    mean_margin = mean(Margin),
    median_margin = median(Margin),
    sd_margin = sd(Margin),
    min_margin = min(Margin),
    max_margin = max(Margin)
  )
## # A tibble: 1 × 5
##   mean_margin median_margin sd_margin min_margin max_margin
##         <dbl>         <dbl>     <dbl>      <int>      <int>
## 1        17.2           9.5      40.2        -89        171
#Key stats: Mean margin - sydney wins by ~17 points on average at home
#median margin: 9.50 points
#range: -89 to 171 (biggest loss to biggest win)


# Visual Data Plots

#distribution of margoins (wins vs losses)
ggplot(swans, aes(x = Margin, fill = factor(Win))) +
  geom_histogram(bins = 20, color = "white", alpha = 0.7) +
  geom_vline(xintercept = 0, linetype = "dashed", color = "black", size = 1) +
  scale_fill_manual(values = c("0" = "darkred", "1" = "darkgreen"),
                    labels = c("Loss", "Win")) +
  labs(title = "Distribution of Sydney Home Game Margins",
       subtitle = "Positive = Win, Negative = Loss",
       x = "Margin (Points)",
       y = "Count",
       fill = "Result") +
  theme_minimal()
## Warning: Using `size` aesthetic for lines was deprecated in ggplot2 3.4.0.
## ℹ Please use `linewidth` instead.
## This warning is displayed once every 8 hours.
## Call `lifecycle::last_lifecycle_warnings()` to see where this warning was
## generated.

#observations - More wins than losses - roughly normally distributed


# Margin over time
ggplot(swans, aes(x = Season, y = Margin, color = factor(Win))) +
  geom_point(alpha = 0.6, size = 2) +
  geom_smooth(method = "lm", color = "darkblue", se = TRUE) +
  geom_hline(yintercept = 0, linetype = "dashed") +
  scale_color_manual(values = c("0" = "red", "1" = "green"),
                     labels = c("Loss", "Win")) +
  labs(title = "Sydney Home Margins Over Time",
       x = "Season",
       y = "Margin (Points)",
       color = "Result") +
  theme_minimal()
## `geom_smooth()` using formula = 'y ~ x'

#observation - remains relatively linear across the seasons

# Margin by opposition score
ggplot(swans, aes(x = Away.Points, y = Margin, color = factor(Win))) +
  geom_point(alpha = 0.6, size = 2) +
  geom_smooth(method = "lm", color = "darkblue", se = TRUE) +
  geom_hline(yintercept = 0, linetype = "dashed") +
  scale_color_manual(values = c("0" = "red", "1" = "green"),
                     labels = c("Loss", "Win")) +
  labs(title = "Sydney Margin vs Opposition Score",
       x = "Opposition Points",
       y = "Margin (Points)",
       color = "Result") +
  theme_minimal()
## `geom_smooth()` using formula = 'y ~ x'

#Observation  - Clear negative relationship between opposition score and margin

Model Building

# Start simple 
#Model 1 - Season only
model1 <- lm(Margin ~ Season, data = swans)
summary(model1)
## 
## Call:
## lm(formula = Margin ~ Season, data = swans)
## 
## Residuals:
##      Min       1Q   Median       3Q      Max 
## -106.307  -24.752   -7.763   24.987  153.811 
## 
## Coefficients:
##               Estimate Std. Error t value Pr(>|t|)
## (Intercept)   46.85150 3049.37004   0.015    0.988
## Season        -0.01466    1.51044  -0.010    0.992
## 
## Residual standard error: 40.42 on 102 degrees of freedom
## Multiple R-squared:  9.239e-07,  Adjusted R-squared:  -0.009803 
## F-statistic: 9.423e-05 on 1 and 102 DF,  p-value: 0.9923
#Model 2 - opposition score 
model2 <- lm(Margin ~ Away.Points, data = swans)
summary(model2)
## 
## Call:
## lm(formula = Margin ~ Away.Points, data = swans)
## 
## Residuals:
##     Min      1Q  Median      3Q     Max 
## -55.922 -19.677  -2.488  16.866 100.896 
## 
## Coefficients:
##             Estimate Std. Error t value Pr(>|t|)    
## (Intercept) 117.8421     9.1566   12.87   <2e-16 ***
## Away.Points  -1.4041     0.1225  -11.46   <2e-16 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 26.72 on 102 degrees of freedom
## Multiple R-squared:  0.5631, Adjusted R-squared:  0.5588 
## F-statistic: 131.4 on 1 and 102 DF,  p-value: < 2.2e-16
#Model 3 - both predictors
model3 <- lm(Margin ~ Season + Away.Points, data = swans)
summary(model3)
## 
## Call:
## lm(formula = Margin ~ Season + Away.Points, data = swans)
## 
## Residuals:
##     Min      1Q  Median      3Q     Max 
## -56.470 -18.737  -4.172  18.077 103.502 
## 
## Coefficients:
##              Estimate Std. Error t value Pr(>|t|)    
## (Intercept) 1478.2097  2024.9532   0.730    0.467    
## Season        -0.6737     1.0028  -0.672    0.503    
## Away.Points   -1.4088     0.1230 -11.454   <2e-16 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 26.79 on 101 degrees of freedom
## Multiple R-squared:  0.565,  Adjusted R-squared:  0.5564 
## F-statistic:  65.6 on 2 and 101 DF,  p-value: < 2.2e-16

Interpreting Models

Interpreting Model 1:

Interpreting Model 2

Interpreting Model 3

Model Comparison

# AIC comparison (lower is better)
AIC(model1, model2, model3)
##        df       AIC
## model1  3 1068.5781
## model2  3  982.4675
## model3  4  984.0038
# Likelihood ratio test for nested models
anova(model1, model3)
## Analysis of Variance Table
## 
## Model 1: Margin ~ Season
## Model 2: Margin ~ Season + Away.Points
##   Res.Df    RSS Df Sum of Sq      F    Pr(>F)    
## 1    102 166643                                  
## 2    101  72487  1     94156 131.19 < 2.2e-16 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
anova(model2, model3)
## Analysis of Variance Table
## 
## Model 1: Margin ~ Away.Points
## Model 2: Margin ~ Season + Away.Points
##   Res.Df   RSS Df Sum of Sq      F Pr(>F)
## 1    102 72811                           
## 2    101 72487  1    323.92 0.4513 0.5032

Interpretation

AIC (Akaike Information Criterion)

  • Measures model quality while penalising complexity (extra variables)

  • Lower AIC = better model

  • Balances fit and simplicity

  • Use to compare any models (nested or non-nested)

ANOVA (Analysis of Variance)

  • Tests if adding variables significantly improves the model

  • Only works for nested models

  • p < 0.05 = additional variable / variables significantly improves model

  • p > 0.05 = additional variables / variables does not improve model

AIC results

  • Model 1 (Season only): Highest AIC (worst model)

  • Model 2 (Away.Points only): Lowest AIC (best model)

  • Model 3 (Both): Similar to Model 2 - AIC will score more complicated models higher so Model 2 is best.

ANOVA Results:
Adding Away points to season is highly significant whereas adding season to away points does not improve the model beyond what Away.points already explains

Best Model = Model 2

  • AIC prefers simplicity (only 1 predictor)

  • Explains 56% of variance

  • Season adds nothing meaningful

Check Model Assumptions for Model 2

# Diagnostic plots
par(mfrow = c(2, 2))
plot(model2)

Diagnostic plot interpretations

Residuals vs Fitted

  • To look for? Random scatter around the horizontal red line at zero

  • Model 2: Mostly random scatter, though there are three outliers labeled (100, 23, 50)

  • Linearity assumption is reasonably met

Q-Q Plot

  • To look for? Points should follow the diagonal dashed line

  • Model 2: Most points follow the line well, with some deviation at the extremes (outliers 100, 23)

  • Normality assumption is reasonably met

Scale-Location

  • To look for? Horizontal red line with random scatter (checks equal variance)

  • Model 2: Slight upward trend in the red line, some clustering of points

  • Acceptable

Residuals vs Leverage

  • To look for? Points inside Cook’s distance lines

  • Model 2: All points are well within Cook’s distance boundaries

  • No influential outliers

From the analysis we can see:

  • Opposition strength is the key factor affecting Sydney’s home margin

  • For every additional point the opposition scores, Sydney’s margin decreases (negative coefficient)

  • The relationship between opposition score and Sydney’s winning margin makes intuitive sense, when opposition score less Sydney will win by larger margins, where when oppositions scores are high, Sydney’s winning margin shrinks or they lose

  • Season does not significantly improve the model, therefore meaning Sydney’s home performance has been relatively stable throughout the years

  • Opposition score alone explains a significant portion of variance in margin

  • Model assumptions were met

Final Model - Model 2: Margin = 117.84 - 1.40 × Away.Points

  • If opposition scores 60 points: Margin = 117.84 - 1.40(60) = +33.84 (Win by large amount)

  • If opposition scores 80 points: Margin = 117.84 - 1.40(80) = +5.84 (narrow win)

  • If opposition scores 90 points: Margin = 117.84 - 1.40(90) = -8.16 (narrow loss)

Summary of what we have learnt in this tutorial

  • Linear regression models relationship between continuous variables

  • Start with simple models then add complexity

  • Check assumptions with diagnostic plots

  • R squared explains % of variance

  • Compare models based on lowest AIC and ANOVA tests

  • Use when all assumptions are appropriately met