1. Goal

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.

2. Setup

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

3. How to read an SPC chart

Every chart has a center line (the average) and upper and lower control limits (roughly 3 standard deviations either side).

4. Get the data

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, …

5. Prepare one team’s games

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

6. I chart: points scored per game

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")

Freeze the baseline

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.

List the signals

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.

Moving range (MR) chart

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.

7. I chart: winning margin

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.

8. P chart: kicking accuracy

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.

9. Takeaway

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.

10. Limitations