This tutorial aims to teach you how to use regression analysis to understand relationships in sports data. The tutorial will teach you:
Linear regression definition and key principles
When to use linear regression modelling in sports analytics
How to build and compare multiple regression models in R
How to check model assumptions through the use of diagnostic plots
How to interpret regression coefficients
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
b0 = Intercept (value of Y when X = 0 )
b1 = Slope (change in Y per unit change in X)
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 predicting a continuous outcome (speed, distance, score)
Relationships are linear
Sufficient sample size
When not to use
Outcome is count data (0,1,2,3,4 … ) use poisson regression
Your outcome is binary (win/loss) – use logistic
Assumptions are violated.
# 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>
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.
# 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
# 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 Model 1:
Season coefficient: -0.015
p-value: 0.992 (not significant)
R-squared: 0.00 (explains 0% of variance)
Conclusion: Season has no effect on Sydney’s home margin
Interpreting Model 2
Intercept: 117.84 points
Away.Points coefficient: -1.40 (highly significant, p < 0.001) (negative coefficient)
For every additional point the opposition scores, Sydney’s margin decreases by 1.40 points
R-squared: 0.56 (explains 56% of variance)
Conclusion: Opposition score is a strong predictor of margin
Interpreting Model 3
Includes both Season and Away.Points
Away.Points remains highly significant
Season adds minimal value
# 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
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
AIC prefers simplicity (only 1 predictor)
Explains 56% of variance
Season adds nothing meaningful
# 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