1 Introduction

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:

  • Linear regression
  • Regression decision trees
  • Random forests

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.

2 Data and Software Requirements

2.1 Required Data

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.csv
  • afl-player-stats-2013.csv
  • afl-player-stats-2014.csv
  • afl-player-stats-2015.csv
  • afl-player-stats-2016.csv
  • afl-player-stats-2017.csv
  • afl-player-stats-2018.csv
  • afl-player-stats-2019.csv
  • afl-player-stats-2020.csv
  • afl-player-stats-2021.csv
  • afl-player-stats-2022.csv
  • afl-player-stats-2023.csv
  • afl-player-stats-2024.csv
  • afl-player-stats-2025.csv
  • afl-player-stats-2026.csv

Data 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.

2.2 Required Packages

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)

3 Importing the Data

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), ]

4 Inspecting the Data

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

5 Matching Players Between Seasons

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

6 Separating Historical and Current Data

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

7 Defining Career Peak Performance

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

8 Defining Player Levels Using the 2026 AFL

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.

  • Below Average: bottom 30%
  • Average: 30% to 70%
  • Good: 70% to 90%
  • Elite: top 10%
# 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"
    )
  )
}

9 Creating the Career Peak Outcome

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

10 Creating Young Player Snapshots

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

11 Position-Specific Predictor Variables

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]]
  )
}

12 Prediction Models

Three models are tested.

12.1 Linear Regression

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

12.2 Regression Decision Tree

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

12.3 Random Forest

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

13 Cross-Validation and Model Selection

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
  }
}

14 Selected Models

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

15 Overall Model Performance

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

16 Performance by Age

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

17 Model Comparison

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

18 Predicted Versus Actual Career Peaks

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

19 Fitting the Final Models

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

20 Example Regression Tree

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

21 Identifying Young Players in 2026

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

22 Preparing the 2026 Players

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

23 Predicting Career Peaks

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
  )

24 Classifying the Predictions

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

25 Creating the Prediction Table

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
    ),
  ]

26 Creating a 2026 Player Comparison

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

27 Final Results

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")
)
Predicted Career Peaks for AFL Players Aged 21 or Younger in 2026
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

28 Distribution of Predicted Player Levels

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

29 Highest Predicted Career Peaks

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

30 Exporting the Results

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
)