This project attempts to predict the future peak performance of the AFL’s young players 21 and under who have already debuted and played in the AFL based on historical data of how players their age have performed since 2012.
This analysis uses AFL player data from 2012 to 2026, where data from 2012 to 2025 was used to develop and evaluate the prediction models, and players from 2026 are then used as the current group whose peaks are predicted.
Players of different ages and positions were modelled separately and the Wheel-O player ratings were used as a numeric value to valuate the peak of a player numerically.
Three modelling approaches were tested:
The model with the lowest cross validated Root Mean Squared Error (RMSE) for each age and position combination is chosen to make the final prediction.
The predicted career peaks are then classified as Below Average, Average, Good or Elite by comparing them with actual AFL Player Ratings from the 2026 season at the same position.
This analysis requires AFL player statistics files for each season from 2012 to 2026.
The files should be saved in the same folder as this R Markdown document and named:
afl-player-stats-2012.csvafl-player-stats-2013.csvafl-player-stats-2014.csvafl-player-stats-2015.csvafl-player-stats-2016.csvafl-player-stats-2017.csvafl-player-stats-2018.csvafl-player-stats-2019.csvafl-player-stats-2020.csvafl-player-stats-2021.csvafl-player-stats-2022.csvafl-player-stats-2023.csvafl-player-stats-2024.csvafl-player-stats-2025.csvafl-player-stats-2026.csvData source: Wheel-O Ratings AFL statistics.
The main variables used were player name, age, position, matches played, AFL Player Rating, disposals, contested possessions, marks, tackles, goals, clearances and hitouts.
The following packages are required.
If they have not previously been installed, they will need to be installed before running the code.
# load libraries
library(tidyverse)
library(GGally)
library(Hmisc)
library(performance)
library(see)
library(rpart)
library(rpart.plot)
library(randomForest)
Each season is originally stored as a separate CSV file.
# import data
afl12 <- read.csv("afl-player-stats-2012.csv")
afl13 <- read.csv("afl-player-stats-2013.csv")
afl14 <- read.csv("afl-player-stats-2014.csv")
afl15 <- read.csv("afl-player-stats-2015.csv")
afl16 <- read.csv("afl-player-stats-2016.csv")
afl17 <- read.csv("afl-player-stats-2017.csv")
afl18 <- read.csv("afl-player-stats-2018.csv")
afl19 <- read.csv("afl-player-stats-2019.csv")
afl20 <- read.csv("afl-player-stats-2020.csv")
afl21 <- read.csv("afl-player-stats-2021.csv")
afl22 <- read.csv("afl-player-stats-2022.csv")
afl23 <- read.csv("afl-player-stats-2023.csv")
afl24 <- read.csv("afl-player-stats-2024.csv")
afl25 <- read.csv("afl-player-stats-2025.csv")
afl26 <- read.csv("afl-player-stats-2026.csv")
A season variable is added so each row still shows which season it came from after the data is combined.
# add season
afl12$Season <- 2012
afl13$Season <- 2013
afl14$Season <- 2014
afl15$Season <- 2015
afl16$Season <- 2016
afl17$Season <- 2017
afl18$Season <- 2018
afl19$Season <- 2019
afl20$Season <- 2020
afl21$Season <- 2021
afl22$Season <- 2022
afl23$Season <- 2023
afl24$Season <- 2024
afl25$Season <- 2025
afl26$Season <- 2026
The datasets are then combined into one data frame.
# combine data
afl <- bind_rows(afl12, afl13, afl14, afl15, afl16,
afl17, afl18, afl19, afl20, afl21,
afl22, afl23, afl24, afl25, afl26)
# arrange columns
afl <- afl[, c("Player", "Season",
names(afl)[!names(afl) %in% c("Player", "Season")])]
# sort data
afl <- afl[order(afl$Player, afl$Season), ]
Some basic checks are done to make sure the data has imported correctly.
# inspect data
head(afl)
## Player Season Team Age Age_Decimal Matches
## 1 Aaron Black 2012 North Melbourne 22 22.08 3
## 677 Aaron Black 2013 North Melbourne 23 23.08 18
## 1354 Aaron Black 2014 North Melbourne 24 24.08 25
## 2017 Aaron Black 2015 North Melbourne 25 25.08 3
## 3338 Aaron Black 2017 Geelong 27 27.08 4
## 3994 Aaron Black 2018 Geelong 28 28.08 3
## RatingPoints_Avg Supercoach_Avg DreamTeamPoints_Avg CoachesVotes_Total
## 1 7.233 49.667 39.000 0
## 677 7.994 76.556 70.944 11
## 1354 7.076 56.680 54.760 0
## 2017 5.567 42.000 35.667 0
## 3338 7.925 66.250 73.750 0
## 3994 6.933 66.333 62.667 0
## CoachesVotes_Avg CoachesVotes_MatchesPolled BnFVotes_Total TimeOnGround
## 1 0.000 0 0 72.667
## 677 0.611 3 0 90.778
## 1354 0.000 0 0 88.160
## 2017 0.000 0 0 88.000
## 3338 0.000 0 0 83.500
## 3994 0.000 0 0 69.333
## Kicks Handballs Disposals DisposalEfficiency KickingEfficiency
## 1 3.333 4.333 7.667 69.565 80.000
## 677 8.000 5.833 13.833 67.470 61.151
## 1354 6.600 3.560 10.160 68.898 64.848
## 2017 4.667 4.000 8.667 73.077 78.571
## 3338 8.750 7.000 15.750 68.254 54.286
## 3994 7.667 5.667 13.333 80.000 73.913
## HandballEfficiency KickPercentage Inside50s Rebound50s Clangers
## 1 61.538 43.478 0.667 0.000 0.667
## 677 76.000 57.831 2.389 0.278 1.833
## 1354 76.404 64.961 2.040 0.120 1.280
## 2017 66.667 53.846 0.667 0.000 1.000
## 3338 85.714 55.556 3.000 0.250 1.750
## 3994 88.235 57.500 1.000 3.333 2.000
## DisposalsPerClanger Turnovers DisposalsPerTurnover MetresGained
## 1 11.500 1.000 7.667 103.000
## 677 7.545 3.000 4.686 206.059
## 1354 7.938 2.040 4.980 170.000
## 2017 8.667 2.667 3.250 123.333
## 3338 9.000 3.750 4.200 251.000
## 3994 6.667 2.000 6.667 182.000
## MetresGainedPerDisposal ContestedPossessions UncontestedPossessions
## 1 13.435 4.667 3.000
## 677 14.657 5.611 8.333
## 1354 16.732 3.640 6.760
## 2017 14.231 3.333 5.667
## 3338 15.937 3.750 11.750
## 3994 13.650 4.667 9.000
## TotalPossessions ContestedPossessionRate Intercepts GroundBallGets
## 1 7.667 60.870 0.333 4.000
## 677 13.944 40.239 1.000 4.118
## 1354 10.400 35.000 0.640 2.320
## 2017 9.000 37.037 1.667 2.667
## 3338 15.500 24.194 1.250 3.000
## 3994 13.667 34.146 3.667 4.000
## GroundBallGetsForward50 CentreBounceAttendances CentreClearances
## 1 2.333 0 0
## 677 1.235 0 0
## 1354 0.960 0 0
## 2017 1.000 0 0
## 3338 1.000 0 0
## 3994 0.000 0 0
## StoppageClearances TotalClearances Marks ContestedMarks MarksInside50
## 1 0.000 0.000 2.667 0.667 1.000
## 677 0.111 0.111 4.778 0.667 2.111
## 1354 0.040 0.040 4.400 0.520 1.440
## 2017 0.000 0.000 2.333 0.333 0.667
## 3338 0.250 0.250 5.250 0.500 0.500
## 3994 0.333 0.333 5.667 0.333 0.000
## InterceptMarks MarksOnLead FreesFor FreesAgainst FreesDiff RuckContests
## 1 0.000 1.333 0.000 0.667 -0.667 0.000
## 677 0.176 1.941 0.833 0.667 0.167 1.353
## 1354 0.120 1.720 0.640 0.440 0.200 0.000
## 2017 0.000 0.000 0.333 0.667 -0.333 2.333
## 3338 0.000 1.250 0.250 0.500 -0.250 0.000
## 3994 0.667 0.333 0.333 0.333 0.000 0.000
## RuckContestPercentage Hitouts HitoutsWinPercentage HitoutsToAdvantage
## 1 0.000 0.000 NA 0.000
## 677 1.482 0.278 21.739 0.118
## 1354 0.000 0.000 NA 0.000
## 2017 2.509 0.667 28.571 0.000
## 3338 0.000 0.000 NA 0.000
## 3994 0.000 0.000 NA 0.000
## HitoutsToAdvantagePercentage Tackles TacklesInside50
## 1 NA 2.000 1.333
## 677 40 2.389 0.941
## 1354 NA 2.040 0.960
## 2017 0 1.333 0.667
## 3338 NA 2.750 1.500
## 3994 NA 3.000 0.000
## ContestDefensiveOneOnOnes ContestDefensiveLossPercentage
## 1 0.000 NA
## 677 0.294 20.000
## 1354 0.280 71.429
## 2017 0.000 NA
## 3338 0.000 NA
## 3994 0.667 0.000
## ContestOffensiveOneOnOnes ContestOffensiveWinPercentage Goals_Total
## 1 1.000 33.333 3
## 677 3.529 23.333 33
## 1354 2.600 12.308 26
## 2017 2.000 16.667 1
## 3338 2.250 33.333 5
## 3994 0.000 NA 0
## Goals_Avg Behinds ShotsAtGoal GoalAssists ScoreboardImpact GoalAccuracy
## 1 1.000 0.333 1.667 0.667 10.333 60.000
## 677 1.833 1.278 3.647 0.500 15.278 48.387
## 1354 1.040 0.920 2.280 0.680 11.240 45.614
## 2017 0.333 0.333 1.000 0.000 2.333 33.333
## 3338 1.250 0.500 2.000 0.250 9.500 62.500
## 3994 0.000 0.000 0.000 0.000 0.000 NA
## ScoreInvolvements ScoreInvolvementPercentage ScoreLaunches KickIns
## 1 3.333 13.699 0.333 0
## 677 6.588 23.830 0.529 0
## 1354 4.920 19.159 0.160 0
## 2017 2.333 11.667 0.667 0
## 3338 4.750 19.792 0.500 0
## 3994 1.667 6.250 0.000 0
## KickInsPlayOnPercentage Bounces OnePercenters PressureActs
## 1 NA 0.000 0.333 9.667
## 677 NA 0.056 1.500 10.529
## 1354 NA 0.080 1.440 9.160
## 2017 NA 0.000 1.667 8.333
## 3338 NA 1.000 1.000 12.750
## 3994 NA 0.333 2.333 8.667
## PressureActsDefensiveHalf Spoils Position FirstPossessions
## 1 1.667 0.333 <NA> NA
## 677 2.294 1.059 Key Forward NA
## 1354 2.480 1.080 <NA> NA
## 2017 4.000 1.667 <NA> NA
## 3338 3.500 1.000 <NA> 0
## 3994 6.000 2.000 <NA> 0
## FirstPossessionToClearance HitoutToFirstPossession HitoutToClearance
## 1 NA NA NA
## 677 NA NA NA
## 1354 NA NA NA
## 2017 NA NA NA
## 3338 NA NA NA
## 3994 NA NA NA
## DisposalRetentionPercentage KicksIntoF50 KickIntoF50RetentionPercentage
## 1 NA NA NA
## 677 NA NA NA
## 1354 NA NA NA
## 2017 NA NA NA
## 3338 NA NA NA
## 3994 NA NA NA
## AssistedMetresGained NetMetresGained KickMetresGained RetainedMetresGained
## 1 NA NA NA NA
## 677 NA NA NA NA
## 1354 NA NA NA NA
## 2017 NA NA NA NA
## 3338 NA NA NA NA
## 3994 NA NA NA NA
## KickMetresGainedPerKick HandballMetresGained
## 1 NA NA
## 677 NA NA
## 1354 NA NA
## 2017 NA NA
## 3338 NA NA
## 3994 NA NA
## HandballMetresGainedPerHandball Equity_PreClearance Equity_PostClearance
## 1 NA NA NA
## 677 NA NA NA
## 1354 NA NA NA
## 2017 NA NA NA
## 3338 NA NA NA
## 3994 NA NA NA
## Equity_Possession Equity_BallUse xThreatPerKick ThreatRating
## 1 NA NA NA NA
## 677 NA NA NA NA
## 1354 NA NA NA NA
## 2017 NA NA NA NA
## 3338 NA NA NA NA
## 3994 NA NA NA NA
## xRetainPerKick RetentionRating GroundBallGetsDefensive50 HardBallGets
## 1 NA NA NA NA
## 677 NA NA NA NA
## 1354 NA NA NA NA
## 2017 NA NA NA NA
## 3338 NA NA NA NA
## 3994 NA NA NA NA
## LooseBallGets PostClearanceContestedPossessions
## 1 NA NA
## 677 NA NA
## 1354 NA NA
## 2017 NA NA
## 3338 NA NA
## 3994 NA NA
## PostClearanceGroundBallGets GathersFromHitout CrumbingPossessions
## 1 NA NA NA
## 677 NA NA NA
## 1354 NA NA NA
## 2017 NA NA NA
## 3338 NA NA NA
## 3994 NA NA NA
## HandballReceives AverageFieldPosition Defensive50Possessions
## 1 NA NA NA
## 677 NA NA NA
## 1354 NA NA NA
## 2017 NA NA NA
## 3338 NA NA NA
## 3994 NA NA NA
## DefensiveMidfieldPossessions AttackingMidfieldPossessions
## 1 NA NA
## 677 NA NA
## 1354 NA NA
## 2017 NA NA
## 3338 NA NA
## 3994 NA NA
## Forward50Possessions CentreBounceAttendancePercentage CentreClearanceRate
## 1 NA NA NA
## 677 NA NA NA
## 1354 NA NA NA
## 2017 NA NA NA
## 3338 NA NA NA
## 3994 NA NA NA
## RuckHardBallGets ScoreAssists xScoreShots xScorePerShot
## 1 NA NA NA NA
## 677 NA NA NA NA
## 1354 NA NA NA NA
## 2017 NA NA NA NA
## 3338 NA NA NA NA
## 3994 NA NA NA NA
## xScoreRatingPerShot xScoreShots_Set xScorePerShot_Set
## 1 NA NA NA
## 677 NA NA NA
## 1354 NA NA NA
## 2017 NA NA NA
## 3338 NA NA NA
## 3994 NA NA NA
## xScoreRatingPerShot_Set xScoreShots_General xScorePerShot_General
## 1 NA NA NA
## 677 NA NA NA
## 1354 NA NA NA
## 2017 NA NA NA
## 3338 NA NA NA
## 3994 NA NA NA
## xScoreRatingPerShot_General KickInPercentage
## 1 NA NA
## 677 NA NA
## 1354 NA NA
## 2017 NA NA
## 3338 NA NA
## 3994 NA NA
dim(afl)
## [1] 9976 127
names(afl)
## [1] "Player" "Season"
## [3] "Team" "Age"
## [5] "Age_Decimal" "Matches"
## [7] "RatingPoints_Avg" "Supercoach_Avg"
## [9] "DreamTeamPoints_Avg" "CoachesVotes_Total"
## [11] "CoachesVotes_Avg" "CoachesVotes_MatchesPolled"
## [13] "BnFVotes_Total" "TimeOnGround"
## [15] "Kicks" "Handballs"
## [17] "Disposals" "DisposalEfficiency"
## [19] "KickingEfficiency" "HandballEfficiency"
## [21] "KickPercentage" "Inside50s"
## [23] "Rebound50s" "Clangers"
## [25] "DisposalsPerClanger" "Turnovers"
## [27] "DisposalsPerTurnover" "MetresGained"
## [29] "MetresGainedPerDisposal" "ContestedPossessions"
## [31] "UncontestedPossessions" "TotalPossessions"
## [33] "ContestedPossessionRate" "Intercepts"
## [35] "GroundBallGets" "GroundBallGetsForward50"
## [37] "CentreBounceAttendances" "CentreClearances"
## [39] "StoppageClearances" "TotalClearances"
## [41] "Marks" "ContestedMarks"
## [43] "MarksInside50" "InterceptMarks"
## [45] "MarksOnLead" "FreesFor"
## [47] "FreesAgainst" "FreesDiff"
## [49] "RuckContests" "RuckContestPercentage"
## [51] "Hitouts" "HitoutsWinPercentage"
## [53] "HitoutsToAdvantage" "HitoutsToAdvantagePercentage"
## [55] "Tackles" "TacklesInside50"
## [57] "ContestDefensiveOneOnOnes" "ContestDefensiveLossPercentage"
## [59] "ContestOffensiveOneOnOnes" "ContestOffensiveWinPercentage"
## [61] "Goals_Total" "Goals_Avg"
## [63] "Behinds" "ShotsAtGoal"
## [65] "GoalAssists" "ScoreboardImpact"
## [67] "GoalAccuracy" "ScoreInvolvements"
## [69] "ScoreInvolvementPercentage" "ScoreLaunches"
## [71] "KickIns" "KickInsPlayOnPercentage"
## [73] "Bounces" "OnePercenters"
## [75] "PressureActs" "PressureActsDefensiveHalf"
## [77] "Spoils" "Position"
## [79] "FirstPossessions" "FirstPossessionToClearance"
## [81] "HitoutToFirstPossession" "HitoutToClearance"
## [83] "DisposalRetentionPercentage" "KicksIntoF50"
## [85] "KickIntoF50RetentionPercentage" "AssistedMetresGained"
## [87] "NetMetresGained" "KickMetresGained"
## [89] "RetainedMetresGained" "KickMetresGainedPerKick"
## [91] "HandballMetresGained" "HandballMetresGainedPerHandball"
## [93] "Equity_PreClearance" "Equity_PostClearance"
## [95] "Equity_Possession" "Equity_BallUse"
## [97] "xThreatPerKick" "ThreatRating"
## [99] "xRetainPerKick" "RetentionRating"
## [101] "GroundBallGetsDefensive50" "HardBallGets"
## [103] "LooseBallGets" "PostClearanceContestedPossessions"
## [105] "PostClearanceGroundBallGets" "GathersFromHitout"
## [107] "CrumbingPossessions" "HandballReceives"
## [109] "AverageFieldPosition" "Defensive50Possessions"
## [111] "DefensiveMidfieldPossessions" "AttackingMidfieldPossessions"
## [113] "Forward50Possessions" "CentreBounceAttendancePercentage"
## [115] "CentreClearanceRate" "RuckHardBallGets"
## [117] "ScoreAssists" "xScoreShots"
## [119] "xScorePerShot" "xScoreRatingPerShot"
## [121] "xScoreShots_Set" "xScorePerShot_Set"
## [123] "xScoreRatingPerShot_Set" "xScoreShots_General"
## [125] "xScorePerShot_General" "xScoreRatingPerShot_General"
## [127] "KickInPercentage"
str(afl)
## 'data.frame': 9976 obs. of 127 variables:
## $ Player : chr "Aaron Black" "Aaron Black" "Aaron Black" "Aaron Black" ...
## $ Season : num 2012 2013 2014 2015 2017 ...
## $ Team : chr "North Melbourne" "North Melbourne" "North Melbourne" "North Melbourne" ...
## $ Age : int 22 23 24 25 27 28 30 19 20 21 ...
## $ Age_Decimal : num 22.1 23.1 24.1 25.1 27.1 ...
## $ Matches : int 3 18 25 3 4 3 1 12 23 24 ...
## $ RatingPoints_Avg : num 7.23 7.99 7.08 5.57 7.92 ...
## $ Supercoach_Avg : num 49.7 76.6 56.7 42 66.2 ...
## $ DreamTeamPoints_Avg : num 39 70.9 54.8 35.7 73.8 ...
## $ CoachesVotes_Total : num 0 11 0 0 0 0 0 0 0 22 ...
## $ CoachesVotes_Avg : num 0 0.611 0 0 0 0 0 0 0 0.917 ...
## $ CoachesVotes_MatchesPolled : int 0 3 0 0 0 0 0 0 0 4 ...
## $ BnFVotes_Total : int 0 0 0 0 0 0 0 0 0 6 ...
## $ TimeOnGround : num 72.7 90.8 88.2 88 83.5 ...
## $ Kicks : num 3.33 8 6.6 4.67 8.75 ...
## $ Handballs : num 4.33 5.83 3.56 4 7 ...
## $ Disposals : num 7.67 13.83 10.16 8.67 15.75 ...
## $ DisposalEfficiency : num 69.6 67.5 68.9 73.1 68.3 ...
## $ KickingEfficiency : num 80 61.2 64.8 78.6 54.3 ...
## $ HandballEfficiency : num 61.5 76 76.4 66.7 85.7 ...
## $ KickPercentage : num 43.5 57.8 65 53.8 55.6 ...
## $ Inside50s : num 0.667 2.389 2.04 0.667 3 ...
## $ Rebound50s : num 0 0.278 0.12 0 0.25 ...
## $ Clangers : num 0.667 1.833 1.28 1 1.75 ...
## $ DisposalsPerClanger : num 11.5 7.54 7.94 8.67 9 ...
## $ Turnovers : num 1 3 2.04 2.67 3.75 ...
## $ DisposalsPerTurnover : num 7.67 4.69 4.98 3.25 4.2 ...
## $ MetresGained : num 103 206 170 123 251 ...
## $ MetresGainedPerDisposal : num 13.4 14.7 16.7 14.2 15.9 ...
## $ ContestedPossessions : num 4.67 5.61 3.64 3.33 3.75 ...
## $ UncontestedPossessions : num 3 8.33 6.76 5.67 11.75 ...
## $ TotalPossessions : num 7.67 13.94 10.4 9 15.5 ...
## $ ContestedPossessionRate : num 60.9 40.2 35 37 24.2 ...
## $ Intercepts : num 0.333 1 0.64 1.667 1.25 ...
## $ GroundBallGets : num 4 4.12 2.32 2.67 3 ...
## $ GroundBallGetsForward50 : num 2.33 1.24 0.96 1 1 ...
## $ CentreBounceAttendances : num 0 0 0 0 0 ...
## $ CentreClearances : num 0 0 0 0 0 0 1 0 0.087 0 ...
## $ StoppageClearances : num 0 0.111 0.04 0 0.25 0.333 3 0 0.174 0 ...
## $ TotalClearances : num 0 0.111 0.04 0 0.25 0.333 4 0 0.261 0 ...
## $ Marks : num 2.67 4.78 4.4 2.33 5.25 ...
## $ ContestedMarks : num 0.667 0.667 0.52 0.333 0.5 ...
## $ MarksInside50 : num 1 2.111 1.44 0.667 0.5 ...
## $ InterceptMarks : num 0 0.176 0.12 0 0 0.667 0 0 0.043 0.083 ...
## $ MarksOnLead : num 1.33 1.94 1.72 0 1.25 ...
## $ FreesFor : num 0 0.833 0.64 0.333 0.25 ...
## $ FreesAgainst : num 0.667 0.667 0.44 0.667 0.5 0.333 0 0.667 0.783 0.708 ...
## $ FreesDiff : num -0.667 0.167 0.2 -0.333 -0.25 0 0 -0.5 -0.261 0.375 ...
## $ RuckContests : num 0 1.35 0 2.33 0 ...
## $ RuckContestPercentage : num 0 1.48 0 2.51 0 ...
## $ Hitouts : num 0 0.278 0 0.667 0 0 0 0 0.87 0 ...
## $ HitoutsWinPercentage : num NA 21.7 NA 28.6 NA ...
## $ HitoutsToAdvantage : num 0 0.118 0 0 0 0 0 0 0.261 0 ...
## $ HitoutsToAdvantagePercentage : num NA 40 NA 0 NA NA NA NA 30 NA ...
## $ Tackles : num 2 2.39 2.04 1.33 2.75 ...
## $ TacklesInside50 : num 1.333 0.941 0.96 0.667 1.5 ...
## $ ContestDefensiveOneOnOnes : num 0 0.294 0.28 0 0 0.667 0 0 0.13 0.042 ...
## $ ContestDefensiveLossPercentage : num NA 20 71.4 NA NA ...
## $ ContestOffensiveOneOnOnes : num 1 3.53 2.6 2 2.25 ...
## $ ContestOffensiveWinPercentage : num 33.3 23.3 12.3 16.7 33.3 ...
## $ Goals_Total : int 3 33 26 1 5 0 1 6 30 44 ...
## $ Goals_Avg : num 1 1.833 1.04 0.333 1.25 ...
## $ Behinds : num 0.333 1.278 0.92 0.333 0.5 ...
## $ ShotsAtGoal : num 1.67 3.65 2.28 1 2 ...
## $ GoalAssists : num 0.667 0.5 0.68 0 0.25 0 0 0.083 0.174 0.5 ...
## $ ScoreboardImpact : num 10.33 15.28 11.24 2.33 9.5 ...
## $ GoalAccuracy : num 60 48.4 45.6 33.3 62.5 ...
## $ ScoreInvolvements : num 3.33 6.59 4.92 2.33 4.75 ...
## $ ScoreInvolvementPercentage : num 13.7 23.8 19.2 11.7 19.8 ...
## $ ScoreLaunches : num 0.333 0.529 0.16 0.667 0.5 0 2 0 0.348 0.125 ...
## $ KickIns : num 0 0 0 0 0 0 0 0 0 0 ...
## $ KickInsPlayOnPercentage : num NA NA NA NA NA NA NA NA NA NA ...
## $ Bounces : num 0 0.056 0.08 0 1 0.333 0 0 0.087 0.208 ...
## $ OnePercenters : num 0.333 1.5 1.44 1.667 1 ...
## $ PressureActs : num 9.67 10.53 9.16 8.33 12.75 ...
## $ PressureActsDefensiveHalf : num 1.67 2.29 2.48 4 3.5 ...
## $ Spoils : num 0.333 1.059 1.08 1.667 1 ...
## $ Position : chr NA "Key Forward" NA NA ...
## $ FirstPossessions : num NA NA NA NA 0 0 1 0 0.217 0 ...
## $ FirstPossessionToClearance : num NA NA NA NA NA NA 100 NA 60 NA ...
## $ HitoutToFirstPossession : num NA NA NA NA NA NA NA NA 40 NA ...
## $ HitoutToClearance : num NA NA NA NA NA NA NA NA 30 NA ...
## $ DisposalRetentionPercentage : num NA NA NA NA NA ...
## $ KicksIntoF50 : num NA NA NA NA NA ...
## $ KickIntoF50RetentionPercentage : num NA NA NA NA NA ...
## $ AssistedMetresGained : num NA NA NA NA NA ...
## $ NetMetresGained : num NA NA NA NA NA ...
## $ KickMetresGained : num NA NA NA NA NA ...
## $ RetainedMetresGained : num NA NA NA NA NA ...
## $ KickMetresGainedPerKick : num NA NA NA NA NA ...
## $ HandballMetresGained : num NA NA NA NA NA ...
## $ HandballMetresGainedPerHandball : num NA NA NA NA NA ...
## $ Equity_PreClearance : num NA NA NA NA NA ...
## $ Equity_PostClearance : num NA NA NA NA NA ...
## $ Equity_Possession : num NA NA NA NA NA ...
## $ Equity_BallUse : num NA NA NA NA NA ...
## $ xThreatPerKick : num NA NA NA NA NA ...
## $ ThreatRating : num NA NA NA NA NA ...
## $ xRetainPerKick : num NA NA NA NA NA ...
## [list output truncated]
# check observations per season
table(afl$Season)
##
## 2012 2013 2014 2015 2016 2017 2018 2019 2020 2021 2022 2023 2024 2025 2026
## 676 677 663 665 656 656 658 655 654 674 684 663 658 668 669
# check unique players
length(unique(afl$Player))
## [1] 1912
Duplicate player, team and season combinations are also checked.
# check duplicate player, team & season observations
duplicates <- duplicated(afl[, c("Player", "Team", "Season")])
# display number of duplicates
sum(duplicates)
## [1] 0
# show duplicates
afl[duplicates, ]
## [1] Player Season
## [3] Team Age
## [5] Age_Decimal Matches
## [7] RatingPoints_Avg Supercoach_Avg
## [9] DreamTeamPoints_Avg CoachesVotes_Total
## [11] CoachesVotes_Avg CoachesVotes_MatchesPolled
## [13] BnFVotes_Total TimeOnGround
## [15] Kicks Handballs
## [17] Disposals DisposalEfficiency
## [19] KickingEfficiency HandballEfficiency
## [21] KickPercentage Inside50s
## [23] Rebound50s Clangers
## [25] DisposalsPerClanger Turnovers
## [27] DisposalsPerTurnover MetresGained
## [29] MetresGainedPerDisposal ContestedPossessions
## [31] UncontestedPossessions TotalPossessions
## [33] ContestedPossessionRate Intercepts
## [35] GroundBallGets GroundBallGetsForward50
## [37] CentreBounceAttendances CentreClearances
## [39] StoppageClearances TotalClearances
## [41] Marks ContestedMarks
## [43] MarksInside50 InterceptMarks
## [45] MarksOnLead FreesFor
## [47] FreesAgainst FreesDiff
## [49] RuckContests RuckContestPercentage
## [51] Hitouts HitoutsWinPercentage
## [53] HitoutsToAdvantage HitoutsToAdvantagePercentage
## [55] Tackles TacklesInside50
## [57] ContestDefensiveOneOnOnes ContestDefensiveLossPercentage
## [59] ContestOffensiveOneOnOnes ContestOffensiveWinPercentage
## [61] Goals_Total Goals_Avg
## [63] Behinds ShotsAtGoal
## [65] GoalAssists ScoreboardImpact
## [67] GoalAccuracy ScoreInvolvements
## [69] ScoreInvolvementPercentage ScoreLaunches
## [71] KickIns KickInsPlayOnPercentage
## [73] Bounces OnePercenters
## [75] PressureActs PressureActsDefensiveHalf
## [77] Spoils Position
## [79] FirstPossessions FirstPossessionToClearance
## [81] HitoutToFirstPossession HitoutToClearance
## [83] DisposalRetentionPercentage KicksIntoF50
## [85] KickIntoF50RetentionPercentage AssistedMetresGained
## [87] NetMetresGained KickMetresGained
## [89] RetainedMetresGained KickMetresGainedPerKick
## [91] HandballMetresGained HandballMetresGainedPerHandball
## [93] Equity_PreClearance Equity_PostClearance
## [95] Equity_Possession Equity_BallUse
## [97] xThreatPerKick ThreatRating
## [99] xRetainPerKick RetentionRating
## [101] GroundBallGetsDefensive50 HardBallGets
## [103] LooseBallGets PostClearanceContestedPossessions
## [105] PostClearanceGroundBallGets GathersFromHitout
## [107] CrumbingPossessions HandballReceives
## [109] AverageFieldPosition Defensive50Possessions
## [111] DefensiveMidfieldPossessions AttackingMidfieldPossessions
## [113] Forward50Possessions CentreBounceAttendancePercentage
## [115] CentreClearanceRate RuckHardBallGets
## [117] ScoreAssists xScoreShots
## [119] xScorePerShot xScoreRatingPerShot
## [121] xScoreShots_Set xScorePerShot_Set
## [123] xScoreRatingPerShot_Set xScoreShots_General
## [125] xScorePerShot_General xScoreRatingPerShot_General
## [127] KickInPercentage
## <0 rows> (or 0-length row.names)
The variables being used are then checked for missing values.
# select variables used in analysis
analysis_variables <- c(
"Player",
"Season",
"Team",
"Age",
"Age_Decimal",
"Matches",
"RatingPoints_Avg",
"TimeOnGround",
"Disposals",
"ContestedPossessions",
"Marks",
"Tackles",
"Goals_Avg",
"TotalClearances",
"Hitouts"
)
# check missing values
colSums(is.na(afl[, analysis_variables]))
## Player Season Team
## 0 0 0
## Age Age_Decimal Matches
## 0 0 0
## RatingPoints_Avg TimeOnGround Disposals
## 0 0 0
## ContestedPossessions Marks Tackles
## 0 0 0
## Goals_Avg TotalClearances Hitouts
## 0 0 0
The same player can appear across many seasons.
Names alone cannot always be used because two different players can have the same name.
An approximate birth year is calculated using season and decimal age.
# create approximate birth year
afl$BirthYear <- afl$Season - afl$Age_Decimal
# set birth year tolerance for matching players
birth_tolerance <- 0.02
# create empty player group variable
afl$PlayerGroup <- NA_integer_
Players with the same name are matched if their estimated birth years are within 0.02 years.
# match players with the same name using birth year tolerance
for (player in unique(afl$Player)) {
# find rows for each player name
player_rows <- which(afl$Player == player)
# sort rows by approximate birth year
player_rows <- player_rows[order(afl$BirthYear[player_rows])]
# begin with first player group
group <- 1
group_start <- afl$BirthYear[player_rows[1]]
afl$PlayerGroup[player_rows[1]] <- group
# compare remaining observations
if (length(player_rows) > 1) {
for (i in 2:length(player_rows)) {
# create new player group if birth year differs by more than 0.02
if (abs(afl$BirthYear[player_rows[i]] -
group_start) > birth_tolerance) {
group <- group + 1
group_start <- afl$BirthYear[player_rows[i]]
}
afl$PlayerGroup[player_rows[i]] <- group
}
}
}
# create player identifier
afl$PlayerID <- paste(
afl$Player,
afl$PlayerGroup,
sep = "_"
)
The new player identifier is then checked.
# check duplicate player identifiers within seasons
duplicates_id <- duplicated(
afl[, c("PlayerID", "Season")]
)
# display number of duplicates
sum(duplicates_id)
## [1] 0
# show duplicates
afl[duplicates_id, ]
## [1] Player Season
## [3] Team Age
## [5] Age_Decimal Matches
## [7] RatingPoints_Avg Supercoach_Avg
## [9] DreamTeamPoints_Avg CoachesVotes_Total
## [11] CoachesVotes_Avg CoachesVotes_MatchesPolled
## [13] BnFVotes_Total TimeOnGround
## [15] Kicks Handballs
## [17] Disposals DisposalEfficiency
## [19] KickingEfficiency HandballEfficiency
## [21] KickPercentage Inside50s
## [23] Rebound50s Clangers
## [25] DisposalsPerClanger Turnovers
## [27] DisposalsPerTurnover MetresGained
## [29] MetresGainedPerDisposal ContestedPossessions
## [31] UncontestedPossessions TotalPossessions
## [33] ContestedPossessionRate Intercepts
## [35] GroundBallGets GroundBallGetsForward50
## [37] CentreBounceAttendances CentreClearances
## [39] StoppageClearances TotalClearances
## [41] Marks ContestedMarks
## [43] MarksInside50 InterceptMarks
## [45] MarksOnLead FreesFor
## [47] FreesAgainst FreesDiff
## [49] RuckContests RuckContestPercentage
## [51] Hitouts HitoutsWinPercentage
## [53] HitoutsToAdvantage HitoutsToAdvantagePercentage
## [55] Tackles TacklesInside50
## [57] ContestDefensiveOneOnOnes ContestDefensiveLossPercentage
## [59] ContestOffensiveOneOnOnes ContestOffensiveWinPercentage
## [61] Goals_Total Goals_Avg
## [63] Behinds ShotsAtGoal
## [65] GoalAssists ScoreboardImpact
## [67] GoalAccuracy ScoreInvolvements
## [69] ScoreInvolvementPercentage ScoreLaunches
## [71] KickIns KickInsPlayOnPercentage
## [73] Bounces OnePercenters
## [75] PressureActs PressureActsDefensiveHalf
## [77] Spoils Position
## [79] FirstPossessions FirstPossessionToClearance
## [81] HitoutToFirstPossession HitoutToClearance
## [83] DisposalRetentionPercentage KicksIntoF50
## [85] KickIntoF50RetentionPercentage AssistedMetresGained
## [87] NetMetresGained KickMetresGained
## [89] RetainedMetresGained KickMetresGainedPerKick
## [91] HandballMetresGained HandballMetresGainedPerHandball
## [93] Equity_PreClearance Equity_PostClearance
## [95] Equity_Possession Equity_BallUse
## [97] xThreatPerKick ThreatRating
## [99] xRetainPerKick RetentionRating
## [101] GroundBallGetsDefensive50 HardBallGets
## [103] LooseBallGets PostClearanceContestedPossessions
## [105] PostClearanceGroundBallGets GathersFromHitout
## [107] CrumbingPossessions HandballReceives
## [109] AverageFieldPosition Defensive50Possessions
## [111] DefensiveMidfieldPossessions AttackingMidfieldPossessions
## [113] Forward50Possessions CentreBounceAttendancePercentage
## [115] CentreClearanceRate RuckHardBallGets
## [117] ScoreAssists xScoreShots
## [119] xScorePerShot xScoreRatingPerShot
## [121] xScoreShots_Set xScorePerShot_Set
## [123] xScoreRatingPerShot_Set xScoreShots_General
## [125] xScorePerShot_General xScoreRatingPerShot_General
## [127] KickInPercentage BirthYear
## [129] PlayerGroup PlayerID
## <0 rows> (or 0-length row.names)
# count player groups for each player name
player_groups <- aggregate(
PlayerGroup ~ Player,
data = afl,
FUN = function(x) length(unique(x))
)
# display names belonging to multiple players
player_groups[player_groups$PlayerGroup > 1, ]
## Player PlayerGroup
## 1 Aaron Black 2
## 63 Alwyn Davey 2
## 281 Callum Brown 2
## 1573 Sam Butler 2
## 1788 Tom Lynch 2
# find names belonging to multiple players
multiple_players <- player_groups$Player[
player_groups$PlayerGroup > 1
]
# inspect players with the same name
afl[
afl$Player %in% multiple_players,
c(
"Player",
"Season",
"Team",
"Age_Decimal",
"BirthYear",
"PlayerGroup",
"PlayerID"
)
]
## Player Season Team Age_Decimal BirthYear
## 1 Aaron Black 2012 North Melbourne 22.08 1989.92
## 677 Aaron Black 2013 North Melbourne 23.08 1989.92
## 1354 Aaron Black 2014 North Melbourne 24.08 1989.92
## 2017 Aaron Black 2015 North Melbourne 25.08 1989.92
## 3338 Aaron Black 2017 Geelong 27.08 1989.92
## 3994 Aaron Black 2018 Geelong 28.08 1989.92
## 6635 Aaron Black 2022 West Coast 30.01 1991.99
## 32 Alwyn Davey 2012 Essendon 28.63 1983.37
## 707 Alwyn Davey 2013 Essendon 29.63 1983.37
## 7338 Alwyn Davey 2023 Essendon 19.84 2003.16
## 7998 Alwyn Davey 2024 Essendon 20.84 2003.16
## 3423 Callum Brown 2017 Collingwood 19.67 1997.33
## 4088 Callum Brown 2018 Collingwood 20.67 1997.33
## 4744 Callum Brown 2019 Collingwood 21.67 1997.33
## 5392 Callum Brown 2020 Collingwood 22.67 1997.33
## 6049 Callum Brown 2021 Collingwood 23.67 1997.33
## 6050 Callum Brown 2021 Greater Western Sydney 21.37 1999.63
## 6723 Callum Brown 2022 Collingwood 24.67 1997.33
## 6724 Callum Brown 2022 Greater Western Sydney 22.37 1999.63
## 7406 Callum Brown 2023 Greater Western Sydney 23.37 1999.63
## 8072 Callum Brown 2024 Greater Western Sydney 24.37 1999.63
## 8727 Callum Brown 2025 Greater Western Sydney 25.37 1999.63
## 9391 Callum Brown 2026 Greater Western Sydney 26.37 1999.63
## 553 Sam Butler 2012 West Coast 26.96 1985.04
## 1218 Sam Butler 2013 West Coast 27.96 1985.04
## 1887 Sam Butler 2014 West Coast 28.96 1985.04
## 2553 Sam Butler 2015 West Coast 29.96 1985.04
## 3208 Sam Butler 2016 West Coast 30.96 1985.04
## 3863 Sam Butler 2017 West Coast 31.96 1985.04
## 7193 Sam Butler 2022 Hawthorn 19.88 2002.12
## 7864 Sam Butler 2023 Hawthorn 20.88 2002.12
## 8525 Sam Butler 2024 Hawthorn 21.88 2002.12
## 9189 Sam Butler 2025 Hawthorn 22.88 2002.12
## 9861 Sam Butler 2026 Hawthorn 23.88 2002.12
## 643 Tom Lynch 2012 Adelaide 22.29 1989.71
## 644 Tom Lynch 2012 Gold Coast 20.16 1991.84
## 1311 Tom Lynch 2013 Adelaide 23.29 1989.71
## 1312 Tom Lynch 2013 Gold Coast 21.16 1991.84
## 1976 Tom Lynch 2014 Adelaide 24.29 1989.71
## 1977 Tom Lynch 2014 Gold Coast 22.16 1991.84
## 2641 Tom Lynch 2015 Adelaide 25.29 1989.71
## 2642 Tom Lynch 2015 Gold Coast 23.16 1991.84
## 3296 Tom Lynch 2016 Adelaide 26.29 1989.71
## 3297 Tom Lynch 2016 Gold Coast 24.16 1991.84
## 3952 Tom Lynch 2017 Adelaide 27.29 1989.71
## 3953 Tom Lynch 2017 Gold Coast 25.16 1991.84
## 4611 Tom Lynch 2018 Adelaide 28.29 1989.71
## 4612 Tom Lynch 2018 Gold Coast 26.16 1991.84
## 5262 Tom Lynch 2019 Adelaide 29.29 1989.71
## 5263 Tom Lynch 2019 Richmond 27.16 1991.84
## 5909 Tom Lynch 2020 Adelaide 30.29 1989.71
## 5910 Tom Lynch 2020 Richmond 28.16 1991.84
## 6583 Tom Lynch 2021 Adelaide 31.29 1989.71
## 6584 Tom Lynch 2021 Richmond 29.16 1991.84
## 7268 Tom Lynch 2022 Richmond 30.16 1991.84
## 7934 Tom Lynch 2023 Richmond 31.16 1991.84
## 8592 Tom Lynch 2024 Richmond 32.16 1991.84
## 9254 Tom Lynch 2025 Richmond 33.16 1991.84
## 9923 Tom Lynch 2026 Richmond 34.16 1991.84
## PlayerGroup PlayerID
## 1 1 Aaron Black_1
## 677 1 Aaron Black_1
## 1354 1 Aaron Black_1
## 2017 1 Aaron Black_1
## 3338 1 Aaron Black_1
## 3994 1 Aaron Black_1
## 6635 2 Aaron Black_2
## 32 1 Alwyn Davey_1
## 707 1 Alwyn Davey_1
## 7338 2 Alwyn Davey_2
## 7998 2 Alwyn Davey_2
## 3423 1 Callum Brown_1
## 4088 1 Callum Brown_1
## 4744 1 Callum Brown_1
## 5392 1 Callum Brown_1
## 6049 1 Callum Brown_1
## 6050 2 Callum Brown_2
## 6723 1 Callum Brown_1
## 6724 2 Callum Brown_2
## 7406 2 Callum Brown_2
## 8072 2 Callum Brown_2
## 8727 2 Callum Brown_2
## 9391 2 Callum Brown_2
## 553 1 Sam Butler_1
## 1218 1 Sam Butler_1
## 1887 1 Sam Butler_1
## 2553 1 Sam Butler_1
## 3208 1 Sam Butler_1
## 3863 1 Sam Butler_1
## 7193 2 Sam Butler_2
## 7864 2 Sam Butler_2
## 8525 2 Sam Butler_2
## 9189 2 Sam Butler_2
## 9861 2 Sam Butler_2
## 643 1 Tom Lynch_1
## 644 2 Tom Lynch_2
## 1311 1 Tom Lynch_1
## 1312 2 Tom Lynch_2
## 1976 1 Tom Lynch_1
## 1977 2 Tom Lynch_2
## 2641 1 Tom Lynch_1
## 2642 2 Tom Lynch_2
## 3296 1 Tom Lynch_1
## 3297 2 Tom Lynch_2
## 3952 1 Tom Lynch_1
## 3953 2 Tom Lynch_2
## 4611 1 Tom Lynch_1
## 4612 2 Tom Lynch_2
## 5262 1 Tom Lynch_1
## 5263 2 Tom Lynch_2
## 5909 1 Tom Lynch_1
## 5910 2 Tom Lynch_2
## 6583 1 Tom Lynch_1
## 6584 2 Tom Lynch_2
## 7268 2 Tom Lynch_2
## 7934 2 Tom Lynch_2
## 8592 2 Tom Lynch_2
## 9254 2 Tom Lynch_2
## 9923 2 Tom Lynch_2
Only 2012 to 2025 is used to train and test the prediction models.
The 2026 data is kept separate for the final predictions.
# create historical dataset excluding 2026
historical_afl <- afl[
afl$Season <= 2025,
]
The most common historical position is also found for each player.
# retain recorded historical positions
position_data <- historical_afl[
!is.na(historical_afl$Position) &
historical_afl$Position != "",
c("PlayerID", "Position")
]
# identify most common historical position for each player
player_position <- aggregate(
Position ~ PlayerID,
data = position_data,
FUN = function(x) {
names(sort(table(x), decreasing = TRUE))[1]
}
)
# rename position variable
names(player_position)[2] <- "PlayerPosition"
# inspect historical player positions
table(player_position$PlayerPosition)
##
## Gen. Defender Gen. Forward Key Defender Key Forward Mid-Forward
## 378 349 187 171 66
## Midfielder Ruck
## 360 101
The model predicts the highest AFL Player Rating a player reaches during their career.
A player’s peak is their highest RatingPoints_Avg in a
season where they played at least 10 matches.
# create historical career peak dataset
peak_data <- historical_afl[
historical_afl$Matches >= 10,
]
# sort career seasons by highest player rating
peak_data <- peak_data[
order(
peak_data$PlayerID,
-peak_data$RatingPoints_Avg,
-peak_data$Matches
),
]
# retain highest rated career season for each player
peak_reference <- peak_data[
!duplicated(peak_data$PlayerID),
]
# identify final historical season
historical_last_season <- max(
historical_afl$Season
)
Only players who were approximately 28 or older by 2025 are used so younger players who may still improve are not treated as having completed careers.
# retain players old enough to have reached mature career stage
peak_reference <- peak_reference[
(historical_last_season -
peak_reference$BirthYear) >= 28,
]
# add historical player position
peak_reference <- merge(
peak_reference,
player_position,
by = "PlayerID",
all.x = TRUE
)
# remove players without recorded position
peak_reference <- peak_reference[
!is.na(peak_reference$PlayerPosition) &
peak_reference$PlayerPosition != "",
]
# inspect historical career peak data
dim(peak_reference)
## [1] 833 131
summary(peak_reference$RatingPoints_Avg)
## Min. 1st Qu. Median Mean 3rd Qu. Max.
## 2.625 8.555 10.570 10.755 12.827 22.213
table(peak_reference$PlayerPosition)
##
## Gen. Defender Gen. Forward Key Defender Key Forward Mid-Forward
## 204 145 111 74 31
## Midfielder Ruck
## 211 57
The predicted ratings are also placed into player levels.
The levels are based on actual 2026 Player Ratings so they are compared against the current AFL.
Only players with at least 10 matches are used.
# create 2026 cutoff dataset
cutoff_data <- afl[
afl$Season == 2026 &
afl$Matches >= 10 &
!is.na(afl$Position) &
afl$Position != "" &
!is.na(afl$RatingPoints_Avg),
]
# inspect 2026 cutoff data
dim(cutoff_data)
## [1] 472 130
table(cutoff_data$Position)
##
## Gen. Defender Gen. Forward Key Defender Key Forward Mid-Forward
## 100 77 57 54 30
## Midfielder Ruck
## 126 28
The cutoffs are calculated separately for each position.
# create empty positional cutoff table
position_cutoffs <- data.frame()
# calculate rating cutoffs from 2026 players only
for (position in sort(unique(cutoff_data$Position))) {
# select 2026 ratings for each position
position_ratings <-
cutoff_data$RatingPoints_Avg[
cutoff_data$Position == position
]
# calculate position specific percentiles
cutoffs <- quantile(
position_ratings,
probs = c(0.30, 0.70, 0.90),
na.rm = TRUE
)
# add position to cutoff table
position_cutoffs <- rbind(
position_cutoffs,
data.frame(
PlayerPosition = position,
AverageCutoff = as.numeric(cutoffs[1]),
GoodCutoff = as.numeric(cutoffs[2]),
EliteCutoff = as.numeric(cutoffs[3])
)
)
}
# display positional rating cutoffs
position_cutoffs
## PlayerPosition AverageCutoff GoodCutoff EliteCutoff
## 1 Gen. Defender 7.1020 9.5746 10.9518
## 2 Gen. Forward 6.8804 9.3038 10.5768
## 3 Key Defender 6.8376 8.7910 10.0662
## 4 Key Forward 6.4915 8.5794 10.0946
## 5 Mid-Forward 7.7873 10.5491 13.9553
## 6 Midfielder 8.4055 11.8320 14.4155
## 7 Ruck 9.4038 12.2654 15.4655
A function is then used to place each rating into one of the four levels.
# create position specific rating classification function
classify_position_rating <- function(rating, position) {
category <- rep(
NA_character_,
length(rating)
)
for (i in 1:length(rating)) {
# skip missing ratings or positions
if (is.na(rating[i]) ||
is.na(position[i])) {
next
}
# convert position to character
player_position_name <-
as.character(position[i])
# find cutoffs for player's position
player_cutoffs <- position_cutoffs[
position_cutoffs$PlayerPosition ==
player_position_name,
]
# skip positions without cutoff information
if (nrow(player_cutoffs) == 0) {
next
}
# classify player rating
if (rating[i] <
player_cutoffs$AverageCutoff) {
category[i] <- "Below Average"
} else if (
rating[i] <
player_cutoffs$GoodCutoff
) {
category[i] <- "Average"
} else if (
rating[i] <
player_cutoffs$EliteCutoff
) {
category[i] <- "Good"
} else {
category[i] <- "Elite"
}
}
# return ordered category
factor(
category,
levels = c(
"Below Average",
"Average",
"Good",
"Elite"
)
)
}
The historical career peak variables are kept so they can be matched with how players performed when they were younger.
# select career peak variables
peak_model <- peak_reference[, c(
"PlayerID",
"Season",
"Age",
"Age_Decimal",
"Matches",
"RatingPoints_Avg"
)]
# rename peak variables
names(peak_model) <- c(
"PlayerID",
"PeakSeason",
"PeakAge",
"PeakAgeDecimal",
"PeakMatches",
"PeakRating"
)
Players aged 18 to 21 are modelled separately.
For example, a 19-year-old midfielder is compared against historical 19-year-old midfielders.
# create historical young player dataset
historical_young <- historical_afl[
historical_afl$Age >= 18 &
historical_afl$Age <= 21,
]
# find unique historical young players
young_player_ids <- unique(
historical_young$PlayerID
)
# create empty historical snapshot dataset
young_model <- data.frame()
For each age, only data that was available by that age is used.
The player’s best qualifying season up to that age is selected.
# create age specific historical snapshots
for (player_id in young_player_ids) {
# select young seasons for each player
player_young_all <- historical_young[
historical_young$PlayerID == player_id,
]
# sort seasons chronologically
player_young_all <- player_young_all[
order(player_young_all$Season),
]
# identify ages recorded for player
player_ages <- sort(
unique(player_young_all$Age)
)
# create separate snapshot for each age
for (prediction_age in player_ages) {
# only use ages 18 to 21
if (prediction_age < 18 ||
prediction_age > 21) {
next
}
# find player's observation at prediction age
age_rows <- player_young_all[
player_young_all$Age ==
prediction_age,
]
# skip age if no observation exists
if (nrow(age_rows) == 0) {
next
}
# use latest season at prediction age
age_rows <- age_rows[
order(age_rows$Season),
]
prediction_row <-
age_rows[nrow(age_rows), ]
# identify prediction season
prediction_season <-
prediction_row$Season
# find qualifying seasons available by this age
available_young <- player_young_all[
player_young_all$Age <=
prediction_age &
player_young_all$Season <=
prediction_season &
player_young_all$Matches >= 3,
]
# skip if no qualifying season is available
if (nrow(available_young) == 0) {
next
}
# count qualifying young seasons available
young_seasons_played <-
nrow(available_young)
# sort available seasons by highest player rating
available_young <- available_young[
order(
-available_young$RatingPoints_Avg,
-available_young$Matches
),
]
# select best season available by this age
selected_season <-
available_young[1, ]
# identify career history available at prediction
player_history <- historical_afl[
historical_afl$PlayerID ==
player_id &
historical_afl$Season <=
prediction_season &
historical_afl$Matches >= 3,
]
# find highest rating already achieved
career_high_at_prediction <- max(
player_history$RatingPoints_Avg,
na.rm = TRUE
)
# identify position at prediction age
player_position_age <-
prediction_row$Position
# use selected season position if current position is missing
if (is.na(player_position_age) ||
player_position_age == "") {
player_position_age <-
selected_season$Position
}
# use historical position if position is still missing
if (is.na(player_position_age) ||
player_position_age == "") {
historical_position <-
player_position$PlayerPosition[
player_position$PlayerID ==
player_id
]
if (length(historical_position) > 0) {
player_position_age <-
historical_position[1]
}
}
# create historical age snapshot
player_summary <- data.frame(
PlayerID = player_id,
Player = prediction_row$Player,
YoungTeam = prediction_row$Team,
PlayerPosition = player_position_age,
PredictionSeason = prediction_season,
PredictionAge = prediction_age,
YoungSeasonsPlayed =
young_seasons_played,
SelectedSeason =
selected_season$Season,
CareerHighAtPrediction =
career_high_at_prediction,
YoungMatches =
selected_season$Matches,
YoungRating =
selected_season$RatingPoints_Avg,
YoungTOG =
selected_season$TimeOnGround,
YoungDisposals =
selected_season$Disposals,
YoungContestedPossessions =
selected_season$ContestedPossessions,
YoungMarks =
selected_season$Marks,
YoungTackles =
selected_season$Tackles,
YoungGoals =
selected_season$Goals_Avg,
YoungClearances =
selected_season$TotalClearances,
YoungHitouts =
selected_season$Hitouts
)
# add snapshot to historical dataset
young_model <- rbind(
young_model,
player_summary
)
}
}
The young player data is then matched with the player’s eventual career peak.
# remove snapshots without recorded position
young_model <- young_model[
!is.na(young_model$PlayerPosition) &
young_model$PlayerPosition != "",
]
# combine young snapshots with eventual career peak
model_data <- merge(
young_model,
peak_model,
by = "PlayerID"
)
# sort modelling data
model_data <- model_data[
order(
model_data$PredictionAge,
model_data$PlayerPosition,
model_data$Player
),
]
# classify actual career peak relative to position
model_data$PeakCategory <-
classify_position_rating(
model_data$PeakRating,
model_data$PlayerPosition
)
# inspect modelling data
dim(model_data)
## [1] 725 25
table(model_data$PredictionAge)
##
## 19 20 21
## 143 253 329
table(model_data$PlayerPosition)
##
## Gen. Defender Gen. Forward Key Defender Key Forward Mid-Forward
## 200 137 76 54 21
## Midfielder Ruck
## 222 15
table(
model_data$PredictionAge,
model_data$PlayerPosition
)
##
## Gen. Defender Gen. Forward Key Defender Key Forward Mid-Forward Midfielder
## 19 36 27 14 10 6 48
## 20 73 49 25 21 6 76
## 21 91 61 37 23 9 98
##
## Ruck
## 19 2
## 20 3
## 21 10
table(model_data$PeakCategory)
##
## Below Average Average Good Elite
## 94 216 169 246
Different statistics are more important for different positions.
For example, hitouts are important for rucks while goals are more important for forwards.
Because of this, different variables are used for each position.
# create position specific detailed formulas
position_formulas <- list()
# create general defender formula
position_formulas[["Gen. Defender"]] <-
PeakRating ~
YoungRating +
YoungMatches +
YoungDisposals +
YoungContestedPossessions +
YoungMarks +
YoungTackles
# create general forward formula
position_formulas[["Gen. Forward"]] <-
PeakRating ~
YoungRating +
YoungMatches +
YoungDisposals +
YoungMarks +
YoungTackles +
YoungGoals
# create key defender formula
position_formulas[["Key Defender"]] <-
PeakRating ~
YoungRating +
YoungMatches +
YoungMarks +
YoungContestedPossessions
# create key forward formula
position_formulas[["Key Forward"]] <-
PeakRating ~
YoungRating +
YoungMatches +
YoungMarks +
YoungGoals
# create mid-forward formula
position_formulas[["Mid-Forward"]] <-
PeakRating ~
YoungRating +
YoungDisposals +
YoungGoals
# create midfielder formula
position_formulas[["Midfielder"]] <-
PeakRating ~
YoungRating +
YoungMatches +
YoungDisposals +
YoungContestedPossessions +
YoungTackles +
YoungClearances
# create ruck formula
position_formulas[["Ruck"]] <-
PeakRating ~
YoungRating +
YoungHitouts +
YoungClearances
Smaller groups use fewer variables so the model does not become too complicated for the amount of data available.
# create reduced position specific formulas
reduced_position_formulas <- list()
# create reduced general defender formula
reduced_position_formulas[["Gen. Defender"]] <-
PeakRating ~
YoungRating +
YoungDisposals +
YoungMarks
# create reduced general forward formula
reduced_position_formulas[["Gen. Forward"]] <-
PeakRating ~
YoungRating +
YoungDisposals +
YoungGoals
# create reduced key defender formula
reduced_position_formulas[["Key Defender"]] <-
PeakRating ~
YoungRating +
YoungMarks
# create reduced key forward formula
reduced_position_formulas[["Key Forward"]] <-
PeakRating ~
YoungRating +
YoungGoals
# create reduced mid-forward formula
reduced_position_formulas[["Mid-Forward"]] <-
PeakRating ~
YoungRating +
YoungGoals
# create reduced midfielder formula
reduced_position_formulas[["Midfielder"]] <-
PeakRating ~
YoungRating +
YoungDisposals +
YoungClearances
# create reduced ruck formula
reduced_position_formulas[["Ruck"]] <-
PeakRating ~
YoungRating +
YoungHitouts
# create function to choose predictor formula
choose_model_formula <- function(
position,
sample_size
) {
# use young rating only for small samples
if (sample_size < 10) {
return(
PeakRating ~ YoungRating
)
}
# use reduced formula for medium samples
if (sample_size < 20) {
return(
reduced_position_formulas[[position]]
)
}
# use detailed formula for larger samples
return(
position_formulas[[position]]
)
}
Three models are tested.
Linear regression uses the relationship between the young player statistics and career peak to make a prediction.
# create linear regression cross validation function
cross_validate_linear <- function(
data,
formula,
folds
) {
# create empty prediction variable
predicted <- rep(
NA,
nrow(data)
)
# identify number of folds
number_folds <- max(folds)
# run cross validation
for (fold in 1:number_folds) {
# create training data
cv_train <- data[
folds != fold,
]
# create validation data
cv_validation <- data[
folds == fold,
]
# fit linear regression
linear_model <- lm(
formula,
data = cv_train
)
# predict validation data
fold_prediction <- predict(
linear_model,
newdata = cv_validation
)
# ensure predicted peak is not below rating already achieved
fold_prediction <- pmax(
fold_prediction,
cv_validation$CareerHighAtPrediction
)
# save predictions
predicted[
folds == fold
] <- fold_prediction
}
# calculate RMSE
rmse <- sqrt(
mean(
(predicted -
data$PeakRating)^2,
na.rm = TRUE
)
)
# calculate MAE
mae <- mean(
abs(
predicted -
data$PeakRating
),
na.rm = TRUE
)
# calculate R squared
r2 <- 1 -
sum(
(data$PeakRating -
predicted)^2,
na.rm = TRUE
) /
sum(
(data$PeakRating -
mean(
data$PeakRating,
na.rm = TRUE
))^2,
na.rm = TRUE
)
# classify predictions
predicted_category <-
classify_position_rating(
predicted,
data$PlayerPosition
)
# calculate category accuracy
accuracy <- mean(
predicted_category ==
data$PeakCategory,
na.rm = TRUE
) * 100
# return results
list(
Predicted = predicted,
RMSE = rmse,
MAE = mae,
R2 = r2,
CategoryAccuracy = accuracy
)
}
A regression tree splits players into groups based on their statistics and gives each group a predicted peak rating.
# create regression tree cross validation function
cross_validate_tree <- function(
data,
formula,
folds
) {
# create empty prediction variable
predicted <- rep(
NA,
nrow(data)
)
# identify number of folds
number_folds <- max(folds)
# run cross validation
for (fold in 1:number_folds) {
# create training data
cv_train <- data[
folds != fold,
]
# create validation data
cv_validation <- data[
folds == fold,
]
# fit regression tree
tree_model <- rpart(
formula,
data = cv_train,
method = "anova",
control = rpart.control(
cp = 0.01,
minsplit = 5,
minbucket = 2,
maxdepth = 4
)
)
# predict validation data
fold_prediction <- predict(
tree_model,
newdata = cv_validation
)
# ensure predicted peak is not below rating already achieved
fold_prediction <- pmax(
fold_prediction,
cv_validation$CareerHighAtPrediction
)
# save predictions
predicted[
folds == fold
] <- fold_prediction
}
# calculate RMSE
rmse <- sqrt(
mean(
(predicted -
data$PeakRating)^2,
na.rm = TRUE
)
)
# calculate MAE
mae <- mean(
abs(
predicted -
data$PeakRating
),
na.rm = TRUE
)
# calculate R squared
r2 <- 1 -
sum(
(data$PeakRating -
predicted)^2,
na.rm = TRUE
) /
sum(
(data$PeakRating -
mean(
data$PeakRating,
na.rm = TRUE
))^2,
na.rm = TRUE
)
# classify predictions
predicted_category <-
classify_position_rating(
predicted,
data$PlayerPosition
)
# calculate category accuracy
accuracy <- mean(
predicted_category ==
data$PeakCategory,
na.rm = TRUE
) * 100
# return results
list(
Predicted = predicted,
RMSE = rmse,
MAE = mae,
R2 = r2,
CategoryAccuracy = accuracy
)
}
A random forest creates many decision trees and combines the results.
500 trees are used.
# create random forest cross validation function
cross_validate_forest <- function(
data,
formula,
folds
) {
# create empty prediction variable
predicted <- rep(
NA,
nrow(data)
)
# identify number of folds
number_folds <- max(folds)
# run cross validation
for (fold in 1:number_folds) {
# create training data
cv_train <- data[
folds != fold,
]
# create validation data
cv_validation <- data[
folds == fold,
]
# fit random forest
forest_model <- randomForest(
formula,
data = cv_train,
ntree = 500,
importance = TRUE
)
# predict validation data
fold_prediction <- predict(
forest_model,
newdata = cv_validation
)
# ensure predicted peak is not below rating already achieved
fold_prediction <- pmax(
fold_prediction,
cv_validation$CareerHighAtPrediction
)
# save predictions
predicted[
folds == fold
] <- fold_prediction
}
# calculate RMSE
rmse <- sqrt(
mean(
(predicted -
data$PeakRating)^2,
na.rm = TRUE
)
)
# calculate MAE
mae <- mean(
abs(
predicted -
data$PeakRating
),
na.rm = TRUE
)
# calculate R squared
r2 <- 1 -
sum(
(data$PeakRating -
predicted)^2,
na.rm = TRUE
) /
sum(
(data$PeakRating -
mean(
data$PeakRating,
na.rm = TRUE
))^2,
na.rm = TRUE
)
# classify predictions
predicted_category <-
classify_position_rating(
predicted,
data$PlayerPosition
)
# calculate category accuracy
accuracy <- mean(
predicted_category ==
data$PeakCategory,
na.rm = TRUE
) * 100
# return results
list(
Predicted = predicted,
RMSE = rmse,
MAE = mae,
R2 = r2,
CategoryAccuracy = accuracy
)
}
Cross-validation is used to test the models on players they were not trained on.
Each age and position group is split into up to five groups.
All three models use the same groups.
The model with the lowest RMSE is selected.
# create empty model comparison table
model_results <- data.frame()
# create empty cross validated prediction variables
model_data$CrossValidatedPrediction <- NA
model_data$SelectedModel <- NA
# set seed for reproducible cross validation
set.seed(123)
# compare models for each age and position
for (prediction_age in 18:21) {
for (
position in sort(
unique(model_data$PlayerPosition)
)
) {
# identify exact age and position rows
model_rows <-
model_data$PredictionAge ==
prediction_age &
model_data$PlayerPosition ==
position
# select exact age and position data
age_position_data <-
model_data[model_rows, ]
# require at least five historical observations
if (nrow(age_position_data) < 5) {
next
}
# identify appropriate predictor formula
model_formula <-
choose_model_formula(
position,
nrow(age_position_data)
)
# identify number of folds
number_folds <- min(
5,
nrow(age_position_data)
)
# create same folds for all three models
folds <- sample(
rep(
1:number_folds,
length.out =
nrow(age_position_data)
)
)
# cross validate linear regression
linear_cv <- cross_validate_linear(
age_position_data,
model_formula,
folds
)
# cross validate regression tree
tree_cv <- cross_validate_tree(
age_position_data,
model_formula,
folds
)
# cross validate random forest
forest_cv <- cross_validate_forest(
age_position_data,
model_formula,
folds
)
# add linear regression results
model_results <- rbind(
model_results,
data.frame(
Age = prediction_age,
Position = position,
N = nrow(age_position_data),
Model = "Linear Regression",
RMSE = linear_cv$RMSE,
MAE = linear_cv$MAE,
R2 = linear_cv$R2,
CategoryAccuracy =
linear_cv$CategoryAccuracy
)
)
# add regression tree results
model_results <- rbind(
model_results,
data.frame(
Age = prediction_age,
Position = position,
N = nrow(age_position_data),
Model = "Regression Tree",
RMSE = tree_cv$RMSE,
MAE = tree_cv$MAE,
R2 = tree_cv$R2,
CategoryAccuracy =
tree_cv$CategoryAccuracy
)
)
# add random forest results
model_results <- rbind(
model_results,
data.frame(
Age = prediction_age,
Position = position,
N = nrow(age_position_data),
Model = "Random Forest",
RMSE = forest_cv$RMSE,
MAE = forest_cv$MAE,
R2 = forest_cv$R2,
CategoryAccuracy =
forest_cv$CategoryAccuracy
)
)
# identify winning model
current_results <- model_results[
model_results$Age ==
prediction_age &
model_results$Position ==
position,
]
winning_model <-
current_results$Model[
which.min(
current_results$RMSE
)
]
# save linear regression predictions if selected
if (
winning_model ==
"Linear Regression"
) {
model_data$CrossValidatedPrediction[
model_rows
] <- linear_cv$Predicted
}
# save regression tree predictions if selected
if (
winning_model ==
"Regression Tree"
) {
model_data$CrossValidatedPrediction[
model_rows
] <- tree_cv$Predicted
}
# save random forest predictions if selected
if (
winning_model ==
"Random Forest"
) {
model_data$CrossValidatedPrediction[
model_rows
] <- forest_cv$Predicted
}
# save selected model
model_data$SelectedModel[
model_rows
] <- winning_model
}
}
This shows which model had the lowest RMSE for each age and position.
# create selected model results table
selected_model_results <- data.frame()
# identify winning model for each age and position
for (prediction_age in 18:21) {
for (
position in sort(
unique(model_data$PlayerPosition)
)
) {
# select model results
current_results <- model_results[
model_results$Age ==
prediction_age &
model_results$Position ==
position,
]
# skip combinations without model results
if (nrow(current_results) == 0) {
next
}
# identify model with lowest RMSE
winning_result <- current_results[
which.min(
current_results$RMSE
),
]
# add winning model result
selected_model_results <- rbind(
selected_model_results,
winning_result
)
}
}
# display selected models
knitr::kable(
selected_model_results,
digits = 3,
caption = "Best model for each age and position"
)
| Age | Position | N | Model | RMSE | MAE | R2 | CategoryAccuracy | |
|---|---|---|---|---|---|---|---|---|
| 3 | 19 | Gen. Defender | 36 | Random Forest | 2.492 | 2.026 | -0.055 | 30.556 |
| 6 | 19 | Gen. Forward | 27 | Random Forest | 2.924 | 2.287 | 0.275 | 40.741 |
| 9 | 19 | Key Defender | 14 | Random Forest | 1.510 | 1.254 | 0.406 | 57.143 |
| 11 | 19 | Key Forward | 10 | Regression Tree | 2.437 | 1.929 | -0.043 | 70.000 |
| 13 | 19 | Mid-Forward | 6 | Linear Regression | 1.818 | 1.622 | -0.631 | 33.333 |
| 16 | 19 | Midfielder | 48 | Linear Regression | 3.110 | 2.420 | 0.204 | 39.583 |
| 19 | 20 | Gen. Defender | 73 | Linear Regression | 2.660 | 2.094 | 0.043 | 31.507 |
| 22 | 20 | Gen. Forward | 49 | Linear Regression | 2.319 | 1.898 | 0.386 | 51.020 |
| 27 | 20 | Key Defender | 25 | Random Forest | 2.150 | 1.799 | -0.118 | 48.000 |
| 30 | 20 | Key Forward | 21 | Random Forest | 1.449 | 1.106 | 0.595 | 57.143 |
| 33 | 20 | Mid-Forward | 6 | Random Forest | 2.025 | 1.868 | 0.189 | 33.333 |
| 36 | 20 | Midfielder | 76 | Random Forest | 2.683 | 2.147 | 0.357 | 52.632 |
| 37 | 21 | Gen. Defender | 91 | Linear Regression | 2.310 | 1.902 | 0.160 | 32.967 |
| 42 | 21 | Gen. Forward | 61 | Random Forest | 1.923 | 1.644 | 0.502 | 44.262 |
| 43 | 21 | Key Defender | 37 | Linear Regression | 2.195 | 1.810 | 0.029 | 37.838 |
| 46 | 21 | Key Forward | 23 | Linear Regression | 1.854 | 1.577 | 0.435 | 69.565 |
| 49 | 21 | Mid-Forward | 9 | Linear Regression | 3.606 | 3.291 | -0.049 | 22.222 |
| 52 | 21 | Midfielder | 98 | Linear Regression | 2.430 | 1.950 | 0.529 | 47.959 |
| 57 | 21 | Ruck | 10 | Random Forest | 3.780 | 3.275 | -0.660 | 40.000 |
# display number of times each model was selected
table(selected_model_results$Model)
##
## Linear Regression Random Forest Regression Tree
## 9 9 1
The selected models are then combined to check the overall performance.
# classify cross validated predictions
model_data$CrossValidatedCategory <-
classify_position_rating(
model_data$CrossValidatedPrediction,
model_data$PlayerPosition
)
# retain historical observations with predictions
historical_results_data <-
model_data[
!is.na(
model_data$CrossValidatedPrediction
),
]
# calculate overall RMSE
overall_rmse <- sqrt(
mean(
(
historical_results_data$
CrossValidatedPrediction -
historical_results_data$
PeakRating
)^2
)
)
# calculate overall MAE
overall_mae <- mean(
abs(
historical_results_data$
CrossValidatedPrediction -
historical_results_data$
PeakRating
)
)
# calculate overall R squared
overall_r2 <- 1 -
sum(
(
historical_results_data$
PeakRating -
historical_results_data$
CrossValidatedPrediction
)^2
) /
sum(
(
historical_results_data$
PeakRating -
mean(
historical_results_data$
PeakRating
)
)^2
)
# calculate overall category accuracy
overall_category_accuracy <- mean(
historical_results_data$
CrossValidatedCategory ==
historical_results_data$
PeakCategory,
na.rm = TRUE
) * 100
# create overall results table
overall_results <- data.frame(
RMSE = overall_rmse,
MAE = overall_mae,
R2 = overall_r2,
CategoryAccuracy =
overall_category_accuracy
)
# display overall historical model performance
knitr::kable(
overall_results,
digits = 3,
caption = "Overall cross-validated model performance"
)
| RMSE | MAE | R2 | CategoryAccuracy |
|---|---|---|---|
| 2.457 | 1.967 | 0.38 | 43.333 |
The following confusion matrix shows how often each player level was correctly or incorrectly predicted.
# create confusion matrix
table(
Actual =
historical_results_data$PeakCategory,
Predicted =
historical_results_data$
CrossValidatedCategory,
useNA = "ifany"
)
## Predicted
## Actual Below Average Average Good Elite
## Below Average 15 58 20 1
## Average 7 103 81 23
## Good 5 52 62 49
## Elite 2 34 76 132
The model performance is also checked separately for each age.
# calculate model performance by age
age_results <- data.frame()
# calculate performance separately for each age
for (prediction_age in 18:21) {
# select age data
age_data <- historical_results_data[
historical_results_data$
PredictionAge ==
prediction_age,
]
# skip ages without predictions
if (nrow(age_data) == 0) {
next
}
# calculate age RMSE
age_rmse <- sqrt(
mean(
(
age_data$
CrossValidatedPrediction -
age_data$PeakRating
)^2
)
)
# calculate age MAE
age_mae <- mean(
abs(
age_data$
CrossValidatedPrediction -
age_data$PeakRating
)
)
# calculate age R squared
age_r2 <- 1 -
sum(
(
age_data$PeakRating -
age_data$
CrossValidatedPrediction
)^2
) /
sum(
(
age_data$PeakRating -
mean(age_data$PeakRating)
)^2
)
# calculate age category accuracy
age_accuracy <- mean(
age_data$
CrossValidatedCategory ==
age_data$PeakCategory,
na.rm = TRUE
) * 100
# add age results
age_results <- rbind(
age_results,
data.frame(
Age = prediction_age,
N = nrow(age_data),
RMSE = age_rmse,
MAE = age_mae,
R2 = age_r2,
CategoryAccuracy =
age_accuracy
)
)
}
# display model performance by age
knitr::kable(
age_results,
digits = 3,
caption = "Prediction performance by player age"
)
| Age | N | RMSE | MAE | R2 | CategoryAccuracy |
|---|---|---|---|---|---|
| 19 | 141 | 2.702 | 2.110 | 0.323 | 41.135 |
| 20 | 250 | 2.459 | 1.954 | 0.358 | 45.600 |
| 21 | 329 | 2.343 | 1.915 | 0.423 | 42.553 |
The graph compares the RMSE of the three models.
Lower RMSE is better.
# create model comparison plot
model_plot <- ggplot(
model_results,
aes(
x = Model,
y = RMSE
)
) +
geom_col() +
facet_grid(
Age ~ Position,
scales = "free_y"
) +
labs(
title =
"AFL Peak Rating Model Comparison",
subtitle =
"Lower cross-validated RMSE indicates better prediction",
x = "Model",
y = "Cross-Validated RMSE"
) +
theme_minimal() +
theme(
axis.text.x = element_text(
angle = 45,
hjust = 1
)
)
# display model comparison plot
model_plot
This graph compares predicted career peaks with the actual career peaks.
The dashed line shows a perfect prediction.
# create predicted versus actual historical peak rating plot
historical_plot <- ggplot(
historical_results_data,
aes(
x = CrossValidatedPrediction,
y = PeakRating
)
) +
geom_point(alpha = 0.7) +
geom_abline(
slope = 1,
intercept = 0,
linetype = "dashed"
) +
facet_wrap(~ PredictionAge) +
labs(
title =
"Predicted vs Actual Career Peak AFL Player Rating",
subtitle =
"Best performing model selected for each age and position",
x =
"Predicted Career Peak Player Rating",
y =
"Actual Career Peak Player Rating"
) +
theme_minimal()
# display historical prediction plot
historical_plot
The best model for each age and position is fitted again using all available historical players in that group.
# create empty final model list
final_models <- list()
# create empty final model information table
final_model_information <-
data.frame()
# fit winning model for each age and position
for (
i in 1:nrow(selected_model_results)
) {
# identify age
prediction_age <-
selected_model_results$Age[i]
# identify position
position <- as.character(
selected_model_results$Position[i]
)
# identify winning model
winning_model <- as.character(
selected_model_results$Model[i]
)
# select historical data
age_position_data <- model_data[
model_data$PredictionAge ==
prediction_age &
model_data$PlayerPosition ==
position,
]
# identify appropriate model formula
model_formula <-
choose_model_formula(
position,
nrow(age_position_data)
)
# create model key
model_key <- paste(
prediction_age,
position,
sep = "_"
)
# fit linear regression if selected
if (
winning_model ==
"Linear Regression"
) {
final_models[[model_key]] <- lm(
model_formula,
data = age_position_data
)
}
# fit regression tree if selected
if (
winning_model ==
"Regression Tree"
) {
final_models[[model_key]] <- rpart(
model_formula,
data = age_position_data,
method = "anova",
control = rpart.control(
cp = 0.01,
minsplit = 5,
minbucket = 2,
maxdepth = 4
)
)
}
# fit random forest if selected
if (
winning_model ==
"Random Forest"
) {
final_models[[model_key]] <-
randomForest(
model_formula,
data = age_position_data,
ntree = 500,
importance = TRUE
)
}
# save final model information
final_model_information <- rbind(
final_model_information,
data.frame(
Age = prediction_age,
Position = position,
N = nrow(age_position_data),
SelectedModel = winning_model,
RMSE =
selected_model_results$RMSE[i],
MAE =
selected_model_results$MAE[i],
R2 =
selected_model_results$R2[i],
CategoryAccuracy =
selected_model_results$
CategoryAccuracy[i],
ModelKey = model_key
)
)
}
# display final model information
knitr::kable(
final_model_information,
digits = 3,
caption = "Final models used for prediction"
)
| Age | Position | N | SelectedModel | RMSE | MAE | R2 | CategoryAccuracy | ModelKey |
|---|---|---|---|---|---|---|---|---|
| 19 | Gen. Defender | 36 | Random Forest | 2.492 | 2.026 | -0.055 | 30.556 | 19_Gen. Defender |
| 19 | Gen. Forward | 27 | Random Forest | 2.924 | 2.287 | 0.275 | 40.741 | 19_Gen. Forward |
| 19 | Key Defender | 14 | Random Forest | 1.510 | 1.254 | 0.406 | 57.143 | 19_Key Defender |
| 19 | Key Forward | 10 | Regression Tree | 2.437 | 1.929 | -0.043 | 70.000 | 19_Key Forward |
| 19 | Mid-Forward | 6 | Linear Regression | 1.818 | 1.622 | -0.631 | 33.333 | 19_Mid-Forward |
| 19 | Midfielder | 48 | Linear Regression | 3.110 | 2.420 | 0.204 | 39.583 | 19_Midfielder |
| 20 | Gen. Defender | 73 | Linear Regression | 2.660 | 2.094 | 0.043 | 31.507 | 20_Gen. Defender |
| 20 | Gen. Forward | 49 | Linear Regression | 2.319 | 1.898 | 0.386 | 51.020 | 20_Gen. Forward |
| 20 | Key Defender | 25 | Random Forest | 2.150 | 1.799 | -0.118 | 48.000 | 20_Key Defender |
| 20 | Key Forward | 21 | Random Forest | 1.449 | 1.106 | 0.595 | 57.143 | 20_Key Forward |
| 20 | Mid-Forward | 6 | Random Forest | 2.025 | 1.868 | 0.189 | 33.333 | 20_Mid-Forward |
| 20 | Midfielder | 76 | Random Forest | 2.683 | 2.147 | 0.357 | 52.632 | 20_Midfielder |
| 21 | Gen. Defender | 91 | Linear Regression | 2.310 | 1.902 | 0.160 | 32.967 | 21_Gen. Defender |
| 21 | Gen. Forward | 61 | Random Forest | 1.923 | 1.644 | 0.502 | 44.262 | 21_Gen. Forward |
| 21 | Key Defender | 37 | Linear Regression | 2.195 | 1.810 | 0.029 | 37.838 | 21_Key Defender |
| 21 | Key Forward | 23 | Linear Regression | 1.854 | 1.577 | 0.435 | 69.565 | 21_Key Forward |
| 21 | Mid-Forward | 9 | Linear Regression | 3.606 | 3.291 | -0.049 | 22.222 | 21_Mid-Forward |
| 21 | Midfielder | 98 | Linear Regression | 2.430 | 1.950 | 0.529 | 47.959 | 21_Midfielder |
| 21 | Ruck | 10 | Random Forest | 3.780 | 3.275 | -0.660 | 40.000 | 21_Ruck |
If a regression tree was chosen as the best model for one of the groups, one example is shown below.
# display first selected regression tree
selected_tree_models <-
final_model_information[
final_model_information$
SelectedModel ==
"Regression Tree",
]
if (nrow(selected_tree_models) > 0) {
tree_key <-
selected_tree_models$
ModelKey[1]
rpart.plot(
final_models[[tree_key]],
type = 2,
extra = 101,
fallen.leaves = TRUE
)
}
Players aged 18 to 21 in 2026 are selected.
# identify players aged 18 to 21 in 2026
current_young_2026 <- afl[
afl$Season == 2026 &
afl$Age >= 18 &
afl$Age <= 21,
]
# inspect current young players
dim(current_young_2026)
## [1] 150 130
table(current_young_2026$Age)
##
## 19 20 21
## 36 56 58
Their previous young seasons are also kept.
# create young dataset including 2026
future_young_data <- afl[
afl$Age >= 18 &
afl$Age <= 21 &
afl$Matches >= 3 &
afl$Season <= 2026,
]
# find player identifiers for current young players
future_player_ids <- unique(
current_young_2026$PlayerID
)
# create empty future prediction dataset
future_2026 <- data.frame()
The 2026 players are prepared in the same way as the historical players.
# create one prediction observation for each current player
for (player_id in future_player_ids) {
# find player's 2026 observation
current_player <-
current_young_2026[
current_young_2026$PlayerID ==
player_id,
]
# identify current age
current_age <-
current_player$Age[1]
# select qualifying seasons available by current age
player_young <- future_young_data[
future_young_data$PlayerID ==
player_id &
future_young_data$Age <=
current_age,
]
# skip players without qualifying season
if (nrow(player_young) == 0) {
next
}
# sort seasons by highest player rating
player_young <- player_young[
order(
-player_young$RatingPoints_Avg,
-player_young$Matches
),
]
# select best season available
selected_season <-
player_young[1, ]
# count qualifying young seasons
young_seasons_played <-
nrow(player_young)
# identify career history available in 2026
player_history <- afl[
afl$PlayerID ==
player_id &
afl$Season <= 2026 &
afl$Matches >= 3,
]
# find highest rating already achieved
career_high_at_prediction <- max(
player_history$RatingPoints_Avg,
na.rm = TRUE
)
# identify current position
player_position_2026 <-
current_player$Position[1]
# use selected season position if current position is missing
if (
is.na(player_position_2026) ||
player_position_2026 == ""
) {
player_position_2026 <-
selected_season$Position
}
# use historical position if still missing
if (
is.na(player_position_2026) ||
player_position_2026 == ""
) {
historical_position <-
player_position$PlayerPosition[
player_position$PlayerID ==
player_id
]
if (
length(historical_position) > 0
) {
player_position_2026 <-
historical_position[1]
}
}
# create future player summary
player_summary <- data.frame(
PlayerID = player_id,
Player =
current_player$Player[1],
YoungTeam =
current_player$Team[1],
PlayerPosition =
player_position_2026,
PredictionAge =
current_age,
YoungSeasonsPlayed =
young_seasons_played,
SelectedSeason =
selected_season$Season,
CareerHighAtPrediction =
career_high_at_prediction,
YoungMatches =
selected_season$Matches,
YoungRating =
selected_season$RatingPoints_Avg,
YoungTOG =
selected_season$TimeOnGround,
YoungDisposals =
selected_season$Disposals,
YoungContestedPossessions =
selected_season$
ContestedPossessions,
YoungMarks =
selected_season$Marks,
YoungTackles =
selected_season$Tackles,
YoungGoals =
selected_season$Goals_Avg,
YoungClearances =
selected_season$
TotalClearances,
YoungHitouts =
selected_season$Hitouts
)
# add player to future dataset
future_2026 <- rbind(
future_2026,
player_summary
)
}
# remove players without position
future_2026 <- future_2026[
!is.na(
future_2026$PlayerPosition
) &
future_2026$PlayerPosition != "",
]
# inspect future prediction data
dim(future_2026)
## [1] 139 18
table(
future_2026$PredictionAge
)
##
## 19 20 21
## 32 56 51
table(
future_2026$PlayerPosition
)
##
## Gen. Defender Gen. Forward Key Defender Key Forward Mid-Forward
## 24 45 6 19 12
## Midfielder Ruck
## 28 5
table(
future_2026$PredictionAge,
future_2026$PlayerPosition
)
##
## Gen. Defender Gen. Forward Key Defender Key Forward Mid-Forward Midfielder
## 19 6 10 1 2 3 9
## 20 8 23 3 7 4 11
## 21 10 12 2 10 5 8
##
## Ruck
## 19 1
## 20 0
## 21 4
Each player uses the model for their age and position.
The prediction is their estimated highest career Player Rating.
# create empty prediction variables
future_2026$PredictedPeakRatingRaw <- NA
future_2026$PredictedPeakRating <- NA
future_2026$ModelUsed <- NA
future_2026$HistoricalSampleSize <- NA
# predict 2026 players
for (i in 1:nrow(future_2026)) {
# identify age
player_age <-
future_2026$PredictionAge[i]
# identify position
player_position_name <-
as.character(
future_2026$PlayerPosition[i]
)
# create model key
model_key <- paste(
player_age,
player_position_name,
sep = "_"
)
# check model exists
if (
model_key %in%
names(final_models)
) {
# predict career peak
future_2026$
PredictedPeakRatingRaw[i] <-
predict(
final_models[[model_key]],
newdata = future_2026[i, ]
)
# identify model information
model_information <-
final_model_information[
final_model_information$
ModelKey ==
model_key,
]
# save selected model
future_2026$ModelUsed[i] <-
model_information$
SelectedModel[1]
# save historical sample size
future_2026$
HistoricalSampleSize[i] <-
model_information$N[1]
}
}
# ensure predicted peak is not below rating already achieved
future_2026$PredictedPeakRating <-
pmax(
future_2026$
PredictedPeakRatingRaw,
future_2026$
CareerHighAtPrediction
)
Each predicted peak is then placed into one of the four player levels.
# classify predicted peak relative to 2026 position results
future_2026$PredictedCategory <-
classify_position_rating(
future_2026$
PredictedPeakRating,
future_2026$
PlayerPosition
)
# identify predictions without enough historical data
future_2026$PredictionStatus <-
ifelse(
is.na(
future_2026$
PredictedPeakRating
),
"Insufficient Historical Data",
"Prediction Available"
)
The predictions are placed into a table and sorted from highest to lowest.
# create final prediction table
future_predictions <- future_2026[, c(
"Player",
"YoungTeam",
"PlayerPosition",
"PredictionAge",
"SelectedSeason",
"YoungRating",
"CareerHighAtPrediction",
"PredictedPeakRating",
"PredictedCategory",
"ModelUsed",
"HistoricalSampleSize",
"PredictionStatus"
)]
# sort by predicted peak rating
future_predictions <-
future_predictions[
order(
future_predictions$
PredictedPeakRating,
decreasing = TRUE,
na.last = TRUE
),
]
Each prediction is also given a comparison to a 2026 player at the same position with the closest rating.
Each comparison player can only be used once.
# sort predictions from highest to lowest
future_predictions <- future_predictions[
order(
future_predictions$PredictedPeakRating,
decreasing = TRUE,
na.last = TRUE
),
]
# create empty 2026 player comparison column
future_predictions$`2026 Player Comparison` <- NA_character_
# create list of 2026 players already used as comparisons
used_comparisons <- character(0)
# find unique closest rated 2026 player for each prediction
for (i in 1:nrow(future_predictions)) {
# identify player's position
player_position_current <- as.character(
future_predictions$PlayerPosition[i]
)
# identify predicted peak rating
predicted_rating <- future_predictions$PredictedPeakRating[i]
# select unused 2026 players at the same position
comparison_players <- cutoff_data[
cutoff_data$Position == player_position_current &
!is.na(cutoff_data$RatingPoints_Avg) &
!(cutoff_data$Player %in% used_comparisons),
]
# find closest unused player
if (nrow(comparison_players) > 0 &&
!is.na(predicted_rating)) {
# calculate difference from predicted rating
rating_difference <- abs(
comparison_players$RatingPoints_Avg -
predicted_rating
)
# find closest unused player
closest_player <- comparison_players[
which.min(rating_difference),
]
# add player name and 2026 rating
future_predictions$`2026 Player Comparison`[i] <- paste0(
closest_player$Player,
" (",
round(closest_player$RatingPoints_Avg, 2),
")"
)
# mark comparison player as used
used_comparisons <- c(
used_comparisons,
closest_player$Player
)
}
}
The final table keeps the main information needed to understand the predictions.
# create final display table
final_prediction_table <- future_predictions[, c(
"Player",
"YoungTeam",
"PlayerPosition",
"CareerHighAtPrediction",
"PredictedPeakRating",
"PredictedCategory",
"2026 Player Comparison"
)]
# rename columns
names(final_prediction_table) <- c(
"Name",
"Team",
"Position",
"Current Rating",
"Predicted Peak",
"Player Level",
"2026 Player Comp"
)
# round current rating
final_prediction_table$`Current Rating` <- round(
final_prediction_table$`Current Rating`,
2
)
# round predicted peak
final_prediction_table$`Predicted Peak` <- round(
final_prediction_table$`Predicted Peak`,
2
)
# display final table
knitr::kable(
final_prediction_table,
caption = "Predicted Career Peaks for AFL Players Aged 21 or Younger in 2026",
align = c("l", "l", "l", "r", "r", "l", "l")
)
| Name | Team | Position | Current Rating | Predicted Peak | Player Level | 2026 Player Comp | |
|---|---|---|---|---|---|---|---|
| 47 | Hugo Garcia | St Kilda | Midfielder | 13.03 | 15.69 | Elite | Izak Rankine (15.11) |
| 37 | Harley Reid | West Coast | Midfielder | 11.73 | 15.62 | Elite | Bailey Smith (14.88) |
| 97 | Nick Watson | Hawthorn | Gen. Forward | 15.55 | 15.55 | Elite | Nick Watson (15.55) |
| 56 | Jagga Smith | Carlton | Midfielder | 11.18 | 15.40 | Elite | Kysaiah Pickett (14.86) |
| 45 | Harvey Thomas | Greater Western Sydney | Midfielder | 11.18 | 14.34 | Good | Caleb Serong (14.39) |
| 68 | Joel Freijah | Western Bulldogs | Mid-Forward | 12.13 | 14.29 | Elite | Brent Daniels (14.32) |
| 130 | William Green | Sydney | Ruck | 9.30 | 14.25 | Good | Tristan Xerri (14.28) |
| 35 | Finn O’Sullivan | North Melbourne | Midfielder | 9.82 | 14.20 | Good | Lachie Neale (14.17) |
| 17 | Charlie Banfield | St Kilda | Midfielder | 6.50 | 13.55 | Good | Clayton Oliver (13.62) |
| 108 | Ryley Sanders | Western Bulldogs | Midfielder | 10.51 | 13.32 | Good | Finn Callaghan (13.45) |
| 94 | Mitchell Edwards | Geelong | Ruck | 7.84 | 13.02 | Good | Rowan Marshall (12.68) |
| 31 | Darcy Wilson | St Kilda | Mid-Forward | 10.97 | 12.98 | Good | Touk Miller (12.4) |
| 100 | Oliver Francou | West Coast | Midfielder | 5.10 | 12.95 | Good | Jason Horne-Francis (12.94) |
| 30 | Daniel Curtin | Adelaide | Midfielder | 9.76 | 12.73 | Good | Will Ashcroft (12.69) |
| 46 | Hugh Boxshall | St Kilda | Midfielder | 7.30 | 12.65 | Good | Josh Ward (12.56) |
| 95 | Murphy Reid | Fremantle | Gen. Forward | 10.51 | 12.60 | Elite | Kade Chandler (13) |
| 124 | Vigo Visentini | Essendon | Ruck | 6.63 | 12.59 | Good | Tim English (12.49) |
| 129 | Willem Duursma | West Coast | Midfielder | 8.36 | 12.51 | Good | Jack Steele (12.52) |
| 44 | Harvey Langford | Melbourne | Midfielder | 10.07 | 12.41 | Good | Jack Ross (12.42) |
| 28 | Cooper Trembath | North Melbourne | Key Forward | 11.87 | 12.36 | Elite | Jake Stringer (11.88) |
| 113 | Sam Swadling | Collingwood | Midfielder | 9.48 | 12.34 | Good | Andrew Brayshaw (12.21) |
| 125 | Wil Dawson | North Melbourne | Ruck | 5.54 | 12.22 | Average | Marc Pittonet (12.32) |
| 48 | Hussien El Achkar | Essendon | Gen. Forward | 7.69 | 11.95 | Elite | Beau McCreery (11.36) |
| 24 | Connor O’Sullivan | Geelong | Key Defender | 8.88 | 11.89 | Elite | Harris Andrews (11.95) |
| 16 | Cameron Nairn | Hawthorn | Mid-Forward | 2.65 | 11.85 | Good | Darcy Wilson (10.97) |
| 49 | Isaac Kako | Essendon | Gen. Forward | 8.69 | 11.73 | Elite | Charlie Cameron (11.16) |
| 81 | Leonardo Lombard | Gold Coast | Gen. Forward | 8.95 | 11.71 | Elite | Ben Keays (11.07) |
| 85 | Logan Morris | Brisbane | Key Forward | 9.76 | 11.52 | Elite | Daniel McStay (10.83) |
| 98 | Noah Mraz | Hawthorn | Key Defender | 10.20 | 11.39 | Elite | Callum Wilkie (11.13) |
| 52 | Jack Ough | Greater Western Sydney | Midfielder | 6.90 | 11.35 | Average | Scott Pendlebury (11.33) |
| 41 | Harry Dean | Carlton | Key Defender | 6.94 | 11.27 | Elite | James Sicily (10.83) |
| 126 | Will Graham | Gold Coast | Mid-Forward | 9.43 | 11.23 | Good | Jordan De Goey (10.83) |
| 22 | Cody Curtin | Brisbane | Key Forward | 1.12 | 11.02 | Elite | Josh Treacy (10.8) |
| 25 | Cooper Duff-Tytler | West Coast | Key Forward | 4.50 | 11.02 | Elite | Charlie Curnow (10.77) |
| 23 | Colby McKercher | North Melbourne | Gen. Forward | 9.17 | 10.99 | Elite | Bayley Fritsch (10.8) |
| 88 | Luke Trainor | Richmond | Gen. Defender | 4.16 | 10.97 | Elite | Lachie Whitfield (10.99) |
| 82 | Levi Ashcroft | Brisbane | Midfielder | 8.59 | 10.96 | Average | Will Setterfield (10.95) |
| 103 | Patrick Retschko | Richmond | Midfielder | 7.08 | 10.95 | Average | Tim Kelly (10.93) |
| 75 | Kane McAuliffe | Richmond | Midfielder | 8.52 | 10.85 | Average | Matthew Kennedy (10.73) |
| 66 | Jobe Shanahan | West Coast | Key Forward | 7.16 | 10.84 | Elite | Logan McDonald (10.46) |
| 109 | Sam Cumming | Richmond | Midfielder | 7.05 | 10.82 | Average | Sam Berry (10.72) |
| 76 | Koltyn Tholstrup | Melbourne | Gen. Defender | 8.48 | 10.74 | Good | Jarman Impey (10.73) |
| 11 | Bo Allan | West Coast | Midfielder | 6.35 | 10.72 | Average | Ryley Sanders (10.51) |
| 33 | Dyson Sharp | Essendon | Mid-Forward | 7.61 | 10.70 | Good | James Rowbottom (10.81) |
| 111 | Sam Lalor | Richmond | Mid-Forward | 9.76 | 10.69 | Good | Toby Greene (10.82) |
| 2 | Angus Clarke | Essendon | Mid-Forward | 5.32 | 10.62 | Good | Joel Freijah (10.44) |
| 26 | Cooper Hynes | Western Bulldogs | Mid-Forward | 6.29 | 10.62 | Good | Toby Bedford (9.88) |
| 92 | Matthew Carroll | Carlton | Midfielder | 9.32 | 10.61 | Average | Tom Atkins (10.46) |
| 14 | Caleb Windsor | Melbourne | Mid-Forward | 8.80 | 10.53 | Average | Sam Lalor (9.76) |
| 9 | Beau Addinsall | Gold Coast | Midfielder | 6.44 | 10.49 | Average | Jy Simpkin (10.45) |
| 114 | Sullivan Robey | Essendon | Mid-Forward | 8.65 | 10.46 | Average | Patrick Lipinski (9.62) |
| 123 | Ty Gallop | Brisbane | Key Defender | 7.17 | 10.43 | Elite | Mac Andrew (10.44) |
| 115 | Taj Hotton | Richmond | Mid-Forward | 8.27 | 10.38 | Average | Stephen Coniglio (9.04) |
| 71 | Josaia Delana | Greater Western Sydney | Gen. Forward | 6.32 | 10.13 | Good | Josh Rachele (10.11) |
| 104 | Phoenix Gothard | Greater Western Sydney | Gen. Forward | 9.68 | 10.05 | Good | Patrick Dangerfield (10.08) |
| 1 | Alix Tauru | St Kilda | Gen. Defender | 5.27 | 10.00 | Good | Callum Mills (10) |
| 73 | Josh Lai | Port Adelaide | Gen. Defender | 6.58 | 9.98 | Good | Andrew McGrath (9.87) |
| 57 | Jai Murray | Gold Coast | Midfielder | 3.89 | 9.95 | Average | Sam Flanders (9.97) |
| 116 | Talor Byrne | Carlton | Gen. Forward | 5.86 | 9.92 | Good | Kai Lohmann (9.96) |
| 110 | Sam Grlj | Richmond | Gen. Defender | 5.39 | 9.87 | Good | Caleb Daniel (9.87) |
| 54 | Jacob Farrow | Essendon | Gen. Defender | 5.80 | 9.84 | Good | Trent Rivers (9.74) |
| 36 | Hamish Davis | West Coast | Midfielder | 5.85 | 9.74 | Average | Daniel Curtin (9.76) |
| 6 | Archie Roberts | Essendon | Gen. Defender | 8.65 | 9.72 | Good | Luke Parker (9.67) |
| 87 | Luke Kennedy | Western Bulldogs | Gen. Forward | 6.34 | 9.71 | Good | Phoenix Gothard (9.68) |
| 58 | Jake Rogers | Gold Coast | Gen. Forward | 7.57 | 9.67 | Good | Seth Campbell (9.63) |
| 136 | Zak Johnson | Essendon | Gen. Defender | 5.83 | 9.67 | Good | Harry Himmelberg (9.6) |
| 53 | Jack Whitlock | Port Adelaide | Key Forward | 4.19 | 9.66 | Good | Shannon Neale (9.66) |
| 139 | Zeke Uwland | Gold Coast | Midfielder | 5.70 | 9.61 | Average | Brayden Cook (9.7) |
| 101 | Oliver Greeves | Hawthorn | Gen. Forward | 4.18 | 9.58 | Good | Harry Sharp (9.5) |
| 10 | Billy Wilson | Carlton | Gen. Defender | 7.55 | 9.58 | Good | Dan Houston (9.56) |
| 67 | Joe Berry | Port Adelaide | Gen. Forward | 6.99 | 9.57 | Good | Paul Curtis (9.76) |
| 99 | Noah Roberts-Thomson | Richmond | Gen. Forward | 5.20 | 9.51 | Good | Joe Richards (9.34) |
| 84 | Logan Evans | Port Adelaide | Gen. Defender | 7.08 | 9.50 | Average | Mason Redman (9.48) |
| 65 | Jesse Dattoli | Sydney | Gen. Forward | 8.18 | 9.35 | Good | Alex Neal-Bullen (9.3) |
| 4 | Archer Day-Wicks | Essendon | Gen. Forward | 4.93 | 9.34 | Good | Bailey Humphrey (9.09) |
| 39 | Harrison Ramm | Port Adelaide | Key Defender | 4.73 | 9.32 | Good | Sam De Koning (9.18) |
| 42 | Harry Kyle | Sydney | Gen. Defender | 7.32 | 9.30 | Average | Joel Jeffrey (9.08) |
| 27 | Cooper Lord | Carlton | Midfielder | 8.06 | 9.26 | Average | Ed Langdon (9.27) |
| 77 | Lachie Jaques | Western Bulldogs | Gen. Defender | 4.91 | 9.24 | Average | Bailey Williams (8.98) |
| 96 | Nate Caddy | Essendon | Key Forward | 6.88 | 9.23 | Good | Darcy Fogarty (9.24) |
| 117 | Tobie Travaglia | St Kilda | Gen. Defender | 6.00 | 9.02 | Average | Keidean Coleman (8.95) |
| 74 | Josh Lindsay | West Coast | Gen. Defender | 6.88 | 8.94 | Average | Daniel Rioli (8.89) |
| 51 | Jack Ison | Carlton | Gen. Forward | 4.68 | 8.92 | Average | Malcolm Rosas (8.89) |
| 69 | Jonty Faull | Richmond | Key Forward | 4.26 | 8.87 | Good | Jacob van Rooyen (8.81) |
| 40 | Harry Armstrong | Richmond | Key Forward | 3.64 | 8.78 | Good | Mitch Lewis (8.59) |
| 107 | Riley Hardeman | North Melbourne | Gen. Defender | 5.92 | 8.67 | Average | Josh Weddle (8.65) |
| 135 | Xavier Taylor | Melbourne | Gen. Defender | 5.37 | 8.67 | Average | Nick Haynes (8.65) |
| 62 | Jay Polkinghorne | Geelong | Key Forward | 5.30 | 8.65 | Good | Cooper Trembath (8.58) |
| 121 | Tom Gross | West Coast | Gen. Forward | 6.55 | 8.62 | Average | Jamie Elliott (8.72) |
| 91 | Matt Whitlock | North Melbourne | Key Forward | 0.37 | 8.60 | Good | Jack Darling (8.48) |
| 72 | Josh Dolan | Western Bulldogs | Gen. Forward | 6.32 | 8.58 | Average | Campbell Lake (8.42) |
| 122 | Tom Hanily | Sydney | Gen. Forward | 6.19 | 8.55 | Average | Will Hayward (8.33) |
| 93 | Max Kondogiannis | Essendon | Midfielder | 5.16 | 8.54 | Average | Roan Steele (8.53) |
| 133 | Xavier Bamert | Port Adelaide | Gen. Forward | 3.43 | 8.50 | Average | Ben Ainsworth (8.32) |
| 20 | Christopher Scerri | Fremantle | Gen. Forward | 6.28 | 8.48 | Average | Gryan Miers (8.3) |
| 134 | Xavier Lindsay | Melbourne | Gen. Defender | 7.01 | 8.43 | Average | Karl Worner (8.4) |
| 18 | Charlie West | Collingwood | Key Forward | 5.34 | 8.39 | Average | Mitch Owens (8.44) |
| 50 | Jack Dalton | Hawthorn | Gen. Forward | 4.32 | 8.33 | Average | Michael Frederick (8.27) |
| 105 | Rhys Unwin | Essendon | Gen. Forward | 4.85 | 8.33 | Average | Jack Ginnivan (8.27) |
| 29 | Daniel Annable | Brisbane | Midfielder | 4.87 | 8.32 | Below Average | Ollie Wines (8.32) |
| 64 | Jed Walter | Gold Coast | Key Forward | 6.29 | 8.28 | Average | Harry McKay (8.12) |
| 119 | Tom Burton | Richmond | Gen. Forward | 4.48 | 8.11 | Average | Zac Taylor (8.11) |
| 8 | Balyn O’Brien | Port Adelaide | Gen. Defender | 4.23 | 8.05 | Average | Kyle Langford (8) |
| 80 | Latrelle Pickett | Melbourne | Gen. Forward | 5.47 | 8.03 | Average | Rhylee West (8.05) |
| 127 | Will Lorenz | Port Adelaide | Gen. Defender | 6.58 | 7.91 | Average | Nathan Broad (7.89) |
| 59 | James Leake | Greater Western Sydney | Gen. Defender | 6.32 | 7.91 | Average | Luke Ryan (7.89) |
| 61 | Jaxon Artemis | Essendon | Gen. Defender | 5.15 | 7.85 | Average | Lachlan Bramble (7.86) |
| 79 | Lachy Dovaston | North Melbourne | Gen. Forward | 2.47 | 7.79 | Average | Brad Close (7.79) |
| 118 | Tom Anastasopoulos | Port Adelaide | Gen. Forward | 3.66 | 7.66 | Average | Angus Anderson (7.68) |
| 13 | Caiden Cleary | Sydney | Gen. Forward | 6.33 | 7.62 | Average | Tom Cole (7.62) |
| 63 | Jayden Nguyen | Essendon | Gen. Forward | 6.18 | 7.61 | Average | Jack Higgins (7.61) |
| 15 | Calsher Dear | Hawthorn | Key Forward | 5.59 | 7.60 | Average | Patrick Voss (7.58) |
| 112 | Sam Marshall | Brisbane | Midfielder | 4.61 | 7.54 | Below Average | Steele Sidebottom (7.53) |
| 38 | Harrison Oliver | Greater Western Sydney | Gen. Defender | 3.04 | 7.53 | Average | Billy Wilson (7.55) |
| 21 | Cody Angove | Greater Western Sydney | Gen. Forward | 5.72 | 7.49 | Average | Colby McKercher (7.44) |
| 90 | Malakai Champion | West Coast | Gen. Forward | 5.31 | 7.46 | Average | Liam Ryan (7.28) |
| 78 | Lachlan Gulbin | Gold Coast | Gen. Forward | 4.72 | 7.39 | Average | Ben Long (7.22) |
| 19 | Christian Moraes | Port Adelaide | Gen. Forward | 5.10 | 7.30 | Average | Matthew Cottrell (7.16) |
| 32 | Dylan Patterson | Gold Coast | Gen. Forward | 4.84 | 7.25 | Average | Arthur Jones (7.12) |
| 128 | Will McLachlan | Brisbane | Gen. Forward | 2.22 | 7.24 | Average | Archie Perkins (7.08) |
| 5 | Archer Reid | West Coast | Key Forward | 4.83 | 7.24 | Average | Jobe Shanahan (7.16) |
| 138 | Zane Zakostelsky | Brisbane | Key Defender | 5.23 | 7.14 | Average | Ty Gallop (7.17) |
| 3 | Angus Hastie | St Kilda | Gen. Defender | 4.29 | 7.01 | Below Average | Blake Howes (7.06) |
| 137 | Zane Duursma | North Melbourne | Gen. Forward | 5.27 | 6.93 | Average | Joe Berry (6.99) |
| 131 | William Hayes | Collingwood | Gen. Forward | 5.44 | 6.90 | Average | Oliver Wiltshire (6.44) |
| 120 | Tom Cochrane | Port Adelaide | Gen. Forward | 4.93 | 6.85 | Below Average | Isaac Kako (6.39) |
| 83 | Liam Fawcett | Richmond | Key Forward | 4.74 | 6.85 | Average | Mykelti Lefau (6.85) |
| 43 | Harvey Johnston | West Coast | Gen. Forward | 3.93 | 6.69 | Below Average | Caiden Cleary (6.33) |
| 60 | Jasper Alger | Richmond | Gen. Forward | 5.72 | 6.52 | Below Average | Josh Dolan (6.32) |
| 55 | Jacob Newton | West Coast | Gen. Forward | 4.13 | 6.49 | Below Average | Charlie Spargo (6.2) |
| 7 | Ashton Moir | Carlton | Gen. Forward | 4.81 | 6.41 | Below Average | Bailey Laurie (6.04) |
| 34 | Ethan Read | Gold Coast | Key Forward | 4.47 | 6.13 | Below Average | Joel Amartey (6.09) |
| 12 | Bodie Ryan | Hawthorn | Gen. Defender | 2.80 | 5.92 | Below Average | Bailey Macdonald (5.94) |
| 106 | Riley Hamilton | Greater Western Sydney | Gen. Forward | 2.51 | 5.81 | Below Average | Talor Byrne (5.86) |
| 70 | Jordan Croft | Western Bulldogs | Key Forward | 3.27 | 5.77 | Below Average | Matthew Jefferson (5.72) |
| 89 | Luker Kentfield | Melbourne | Key Forward | 1.93 | 4.94 | Below Average | Tom Lynch (4.75) |
| 132 | William McCabe | Hawthorn | Mid-Forward | 1.82 | 2.66 | Below Average | Jack Watkins (4.24) |
| 102 | Oliver Hannaford | Greater Western Sydney | Gen. Forward | 0.60 | 2.62 | Below Average | Xavier Bamert (3.43) |
| 86 | Louis Emmett | Western Bulldogs | Ruck | 0.97 | NA | NA | NA |
This shows how many players were predicted in each player level.
# display predicted categories
table(
future_predictions$PredictedCategory,
useNA = "ifany"
)
##
## Below Average Average Good Elite <NA>
## 15 58 45 20 1
The graph below shows the 30 highest predicted career peaks.
# remove players without a prediction
future_plot_data <- future_predictions[
!is.na(future_predictions$PredictedPeakRating),
]
# select top 30 predictions
future_plot_data <- head(
future_plot_data,
30
)
# create future prediction plot
future_plot <- ggplot(
future_plot_data,
aes(
x = reorder(Player, PredictedPeakRating),
y = PredictedPeakRating
)
) +
geom_col() +
coord_flip() +
labs(
title = "Predicted Career Peak Ratings for Young AFL Players in 2026",
subtitle = "Players aged 21 or younger",
x = "Player",
y = "Predicted Career Peak Rating"
) +
theme_minimal()
# display future prediction plot
future_plot
The results are saved as CSV files so they can be opened in Excel or another spreadsheet program.
# save positional rating cutoffs
write.csv(
position_cutoffs,
"position_rating_cutoffs.csv",
row.names = FALSE
)
# save complete model comparison
write.csv(
model_results,
"linear_tree_random_forest_results.csv",
row.names = FALSE
)
# save selected models
write.csv(
selected_model_results,
"selected_age_position_models.csv",
row.names = FALSE
)
# save final model information
write.csv(
final_model_information,
"final_age_position_models.csv",
row.names = FALSE
)
# save full future predictions
write.csv(
future_predictions,
"2026_young_player_peak_predictions.csv",
row.names = FALSE
)
# save simplified final table
write.csv(
final_prediction_table,
"2026_final_player_predictions_21U.csv",
row.names = FALSE
)