A team wins by 40 points one week and loses by 30 the next. Is that real change, or just normal game-to-game variation? Statistical process control (SPC) charts help separate the two.
In this tutorial you will use AFL results to build:
Research question: How much game-to-game variation is normal for an AFL team, and can SPC identify games that fall outside that typical variation?
You can follow along with any AFL team. We use Geelong as the example, and you can change it with one line.
Load these R packages. Use the install_packages()
function if you do not have them already:
library(tidyverse)
## ── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
## ✔ dplyr 1.2.1 ✔ readr 2.2.0
## ✔ forcats 1.0.1 ✔ stringr 1.6.0
## ✔ ggplot2 4.0.3 ✔ tibble 3.3.1
## ✔ lubridate 1.9.5 ✔ tidyr 1.3.2
## ✔ purrr 1.2.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)
library(qicharts2)
library(janitor)
##
## Attaching package: 'janitor'
##
## The following objects are masked from 'package:stats':
##
## chisq.test, fisher.test
Every chart has a center line (the average) and upper and lower control limits (roughly 3 standard deviations either side).
qicharts2) suggests the
team’s typical level has shifted.fetch_results_afltables() downloads match results from
AFL Tables. We take three seasons. This needs an internet
connection.
results_raw <- fetch_results_afltables(season = 2022:2024) |>
clean_names()
glimpse(results_raw)
## Rows: 639
## Columns: 16
## $ game <dbl> 15984, 15985, 15986, 15987, 15988, 15989, 15990, 15991, 1…
## $ date <date> 2022-03-16, 2022-03-17, 2022-03-18, 2022-03-19, 2022-03-…
## $ round <chr> "R1", "R1", "R1", "R1", "R1", "R1", "R1", "R1", "R1", "R2…
## $ home_team <chr> "Melbourne", "Carlton", "St Kilda", "Geelong", "GWS", "Br…
## $ home_goals <int> 14, 14, 12, 20, 13, 11, 11, 12, 12, 13, 17, 15, 10, 7, 10…
## $ home_behinds <int> 13, 17, 13, 18, 14, 14, 12, 10, 8, 12, 5, 10, 15, 14, 9, …
## $ home_points <int> 97, 101, 85, 138, 92, 80, 78, 82, 80, 90, 107, 100, 75, 5…
## $ away_team <chr> "Footscray", "Richmond", "Collingwood", "Essendon", "Sydn…
## $ away_goals <int> 11, 11, 15, 11, 17, 10, 8, 11, 16, 16, 10, 8, 15, 19, 12,…
## $ away_behinds <int> 5, 10, 12, 6, 10, 9, 10, 17, 11, 6, 17, 10, 7, 6, 10, 11,…
## $ away_points <int> 71, 76, 102, 72, 112, 69, 58, 83, 107, 102, 77, 58, 97, 1…
## $ venue <chr> "M.C.G.", "M.C.G.", "Docklands", "M.C.G.", "Stadium Austr…
## $ margin <int> 26, 25, -17, 66, -20, 11, 20, -1, -27, -12, 30, 42, -22, …
## $ season <dbl> 2022, 2022, 2022, 2022, 2022, 2022, 2022, 2022, 2022, 202…
## $ 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, …
The raw data has one row per game, with a home side and an away side. We pick one team and create columns from that team’s point of view.
team <- "Geelong" # change this to any AFL team you want, e.g. "Collingwood"
team_games <- results_raw |>
filter(home_team == team | away_team == team) |>
mutate(
is_home = home_team == team,
goals = if_else(is_home, home_goals, away_goals),
behinds = if_else(is_home, home_behinds, away_behinds),
points_for = if_else(is_home, home_points, away_points),
points_against = if_else(is_home, away_points, home_points),
margin = points_for - points_against,
shots = goals + behinds
) |>
arrange(date) |>
mutate(game_no = row_number()) |>
select(game_no, date, season, round, is_home, goals, behinds, shots, points_for, points_against, margin)
head(team_games, 10)
## # A tibble: 10 × 11
## game_no date season round is_home goals behinds shots points_for
## <int> <date> <dbl> <chr> <lgl> <int> <int> <int> <int>
## 1 1 2022-03-19 2022 R1 TRUE 20 18 38 138
## 2 2 2022-03-25 2022 R2 FALSE 10 17 27 77
## 3 3 2022-04-02 2022 R3 FALSE 16 8 24 104
## 4 4 2022-04-08 2022 R4 TRUE 11 14 25 80
## 5 5 2022-04-18 2022 R5 FALSE 11 14 25 80
## 6 6 2022-04-24 2022 R6 FALSE 17 19 36 121
## 7 7 2022-04-30 2022 R7 TRUE 10 6 16 66
## 8 8 2022-05-07 2022 R8 FALSE 12 16 28 88
## 9 9 2022-05-14 2022 R9 FALSE 11 14 25 80
## 10 10 2022-05-21 2022 R10 TRUE 11 16 27 82
## # ℹ 2 more variables: points_against <int>, margin <int>
Each row is now one game for the chosen team, in date order.
game_no is our time axis (1, 2, 3, …). We’ll treat the
whole 2022 season as the baseline (a “normal” period)
and then see whether 2023 and 2024 look different.
n_baseline <- sum(team_games$season == 2022)
n_baseline
## [1] 25
An I chart is used for a measurement taken once per time period, here is the team’s points in a game.
qic(game_no, points_for,
data = team_games,
chart = "i",
title = paste(team, "- points scored per game"),
xlab = "Game number (2022 to 2024)",
ylab = "Points scored")
The freeze argument fixes the center line and limits
using only the first n_baseline games. Later games are then
compared against the 2022 standard.
qic(game_no, points_for,
data = team_games,
chart = "i",
freeze = n_baseline,
title = paste(team, "- points scored (baseline = 2022)"),
xlab = "Game number (2022 to 2024)",
ylab = "Points scored")
What the chart shows: With 2022 as the baseline, Geelong’s average score was 99.1 points, with control limits of 35.8 and 162.4 (about 63 points either side of the average). Only one game falls outside the limits: game 71, the match against West Coast on 24 August 2024 (168 points). The center line is solid, so there is no run signal, meaning the team’s typical scoring level did not clearly shift in 2023 or 2024.
Set return.data = TRUE to get the chart’s data as a
table, then filter to the flagged games.
chart_data <- qic(game_no, points_for,
data = team_games, chart = "i", freeze = n_baseline,
return.data = TRUE)
team_games |>
select(game_no, date, season, round, points_for) |>
left_join(
chart_data |> select(game_no = x, cl, ucl, lcl, sigma.signal, runs.signal),
by = "game_no"
) |>
filter(sigma.signal | runs.signal)
## # A tibble: 1 × 10
## game_no date season round points_for cl ucl lcl sigma.signal
## <int> <date> <dbl> <chr> <int> <dbl> <dbl> <dbl> <lgl>
## 1 71 2024-08-24 2024 R25 168 99.1 162. 35.8 TRUE
## # ℹ 1 more variable: runs.signal <lgl>
sigma.signal = TRUE means the game was outside the
control limits. runs.signal = TRUE means the game is part
of an unusually long run on one side of the center line.
The I chart looks at the level of scoring. A moving range (MR) chart looks at the change from one game to the next (the absolute difference between consecutive games). It flags sudden jumps, even if the score is within the I chart limits. I and MR charts are normally read as a pair.
qic(game_no, points_for,
data = team_games,
chart = "mr",
freeze = n_baseline,
title = paste(team, "- moving range of points scored"),
xlab = "Game number (2022 to 2024)",
ylab = "Change in points from previous game")
What the chart shows: The average change between consecutive games is 23.8 points, with an upper limit of 77.7. Again the only game flagged is game 71: Geelong scored 89 in the previous game and 168 in this one, a swing of about 79 points. The match was a 168 to 75 win over West Coast on 24 August 2024, consistent with the I chart. This analysis does not adjust for opposition strength, so the match report would be the next place to look for context (opposition, injuries, conditions). Several other swings of about 70 to 76 points (for example around games 29, 41, 42 and 54) came close to the limit but did not exceed it.
Margin (points for minus points against) is positive for a win and negative for a loss. The center line shows the team’s typical margin.
qic(game_no, margin,
data = team_games,
chart = "i",
freeze = n_baseline,
title = paste(team, "- margin per game (baseline = 2022)"),
xlab = "Game number (2022 to 2024)",
ylab = "Margin (points)")
What the chart shows: In the 2022 baseline year
Geelong’s average margin was +32.6 points per game (limits -73.6 to
+138.9). No single game is outside the limits, but the center line is
dashed, which is how qicharts2 flags as
run: a long sequence of games on one side of the center
line. From about game 26 onward (2023 and 2024), most margins sit below
the 2022 average, so the typical result shifted downwards even though no
individual game was extreme. The heaviest losses (about -64 points) are
still within the limits. The baseline matters here: an average margin of
+32.6 suggests 2022 was a very strong year, so later seasons look weaker
by comparison.
A p chart is used for a proportion when the number of attempts changes from period to period. Here, the proportion is goals out of scoring shots (goals + behinds). Games with more shots give more reliable percentages, so the control limits automatically get narrower for these games.
In qic(), y is the number of successes
(goals) and n is the number of attempts (shots).
qic(game_no, goals,
n = shots,
data = team_games,
chart = "p",
freeze = n_baseline,
title = paste(team, "- Goal accuracy (goals / scoring shots)"),
xlab = "Game number (2022 to 2024)",
ylab = "Proportion of shots that are goals")
What the chart shows: Geelong’s baseline accuracy was 52.6% (limits about 22.7% to 82.6%, wider in games with fewer shots). No game is flagged, so goal-kicking accuracy was stable across the three seasons. This helps interpret game 71: its accuracy (26 goals from 38 shots, about 68%) is well inside the limits, so the big score came from taking many scoring shots, not from unusually accurate kicking.
In the 2022 baseline, Geelong scored about 99 points per game, normal game-to-game variation spanned roughly 36 to 162 points, and the typical change between consecutive games was about 24 points. Across 2022 to 2024, only one game was flagged on both the I and MR charts: the 168 to 75 point win over West Coast on 24 August 2024. Goal accuracy was stable throughout. The more notable pattern was on the margin chart, where a run of games below the 2022 average margin of +32.6 suggests Geelong’s typical winning margin fell after 2022. A flagged game or run tells us where to look, not why it happened. Opposition strength, injuries and conditions would need further investigation.