Welcome to another edition of the Hockey Handbook series! In this article I want to introduce you to Regularized Adjusted Plus Minus (RAPM).
How do we figure out who is actually good?
What makes a ‘good’ NHL player? How can we tell who is driving success for their team and who is simply a passenger?
Every hockey fan knows about the classic measurement of skater goodness – points! If you want to know who the top NHL players are, simply take a look at NHL.com and see who has the most goals and assists. But what about defensive ability? Plus/minus of course! If you don’t know what these stats are, check out this article on basic skater stats. These classic ‘boxscore’ stats serve a purpose in skater evaluation – getting more goals than the other team is how hockey games are won after all. But these days we have a lot of data available to us and this means we can do a lot better – particularly by understanding the processes that lead to the goals.
About 10-15 years ago NHL analysis started to move past the boxscore stats and ‘advanced’ stats entered the scene. The first ‘advanced’ that gained traction was Corsi, and that was followed by Expected Goals (xG) a few years later. If you aren’t familiar with these concepts you can learn more about Corsi here, and more about Expected Goals here. I’ll briefly explain the concepts now but I recommend checking out those articles for a much more thorough explanation.
Corsi is nothing more than another name for shot attempts. NHL.com defines shot attempts (Corsi) as: any time a player tries to shoot the puck. So, any shot on goal, blocked shot or missed shot is classified as a shot attempt. xG is the probability an individual shot attempt results in a goal – it is Corsi weighted by quality. On-ice Corsi and xG are stats for specific players, for example if Connor McDavid and his line mates take 4 shot attempts that sum to a total of 0.67 xG then Connor McDavid’s on-ice Corsi-For is 4 and his on-ice xG-For is 0.67. Any shot attempts by the other team when McDavid is on the ice contribute to his on-ice Corsi/xG-Against numbers.
We usually evaluate Corsi and xG over much longer time periods than a single shift (any Oilers shot attempt that McDavid is on the ice for in the entire 2025-26 season contributes to his seasons on-ice xG-For number) so it is common to normalize using a players total icetime and report the values as ‘per 60 minutes of playing (time-on-ice or TOI)’. The prefix ‘on-ice’ is typically dropped for brevity. McDavid’s on-ice xG-For is typically referred to as his xG-For (xGF) and his on-ice xG-Against is analogously called xG-Against (xGA).
The reason we care about xG and Corsi is because they are better predictors of future goalscoring than past goalscoring is. In general if you want to predict who will score the most goals in the future you are best off looking at a player’s xG and Corsi rather than actual goals. I won’t prove this here, but the two articles I linked above provide evidence for these claims.
On-ice Corsi and xG are not the end of the line. They paint a more complete picture of a player’s skill/impact than boxscore stats but they are still flawed. The biggest flaw in using on-ice stats to evaluate players as individuals is that there are other players on the ice. If there is an Oilers shot attempt while McDavid is on the ice he gets a +1 in his corsi column regardless of the opponents he is playing against and the teammates he is playing with. This is problematic – does a shot attempt against the Panthers while Barkov, Reinhart and Forsling are on the ice really tell us as much about McDavid’s offensive impact as a shot attempt against the Kraken’s fourth line and third pair defenders? Obviously not. Similarly, is it as difficult for the Oilers to generate a shot attempt when McDavid has Draisaitl and Hyman on his wings as it is when he has Kapanen and Lazar? Obviously the quality of teammates and opponents is very significant, and on-ice stats do not account for this.
This is where Regularized Adjusted Plus Minus (RAPM) comes in. RAPM is a technique that uses linear regression to estimate isolated player impacts. This is done using NHL data at a shift-by-shift level (this data is available on the public NHL API). I am going to explain how and why RAPM works, but first, let’s run through a couple examples of less complicated linear regression’s to make sure we have a solid baseline.
Linear Regression Example
Let’s start with a linear regression on a very simple dataset: Points per game played (GP) and On-Ice shooting percentage (SH%) for all skaters who played at least 41 games in the 2025-26 NHL season. We will use On-Ice SH% as the independent variable (predictor) in the regression and use it to build a model that predicts Points per GP. Here is a snippet of the data (606 lines in total):

And here is a figure that shows a scatterplot of On-Ice SH% vs. Points per GP and the regression line: the model that results from a linear regression on the dataset.

The line is chosen such that it minimizes the sum squared error (SSE) of the predictions. Choose any data point on the figure above and draw a vertical line from it to the regression line. Measure the length of that line and square it. That’s the squared error for that particular value of On-Ice SH%. The SSE is simply the sum of the squared errors of every data point. Basically, the regression line is the line that fits the data the best, on average. If we shifted the line, or tilted it in any direction and changed its slope it would be a worse model of the entire dataset (the SSE would increase). The R-squared value for the line is 0.532. R-squared is a measure of how well the variance in On-Ice SH% explains the variance in Points per GP. 53% of the variance is explained by the model, and 47% is left unexplained.
The equation of the line is:
Points per GP = 0.096 x (On-Ice SH%) – 0.55
0.096 is the slope of the line and the regression coefficient for On-Ice SH%. The regression coefficient tells us the impact that On-Ice SH% has on Points per GP. That is, for every 1% increase in On-Ice SH% we should expect a player’s Points per GP to increase by 0.096. The -0.55 term is called the intercept, it tells us the predicted Points per GP if the On-Ice SH% is exactly 0. Obviously it is nonsense to have a negative Points per GP but it’s not the end of the world that our model has a negative intercept. You can conceptualize the intercept like this: ‘the intercept does its best to account for the stuff in the data that can’t be explained by the predictors’. This is a highly non-technical way of looking at it, but it’s essentially true. In this case we are trying to predict Points per GP with extremely limited information → only On-Ice SH%, AND we are forcing it to assume a linear model (the data isn’t actually linear, looks more like a banana than a line, so an exponential function would be a more realistic model). The intercept was chosen because it works best given our limited information and choice of model, not because it ‘means’ something.
The slight non-linear relationship between On-Ice SH% and Points per GP indicates that our model needs some work and suggests that the regression coefficient isn’t a very good indicator of the ‘true’ impact of On-Ice SH% on Points per GP. If we improved the model by adding more information that we suspect is relevant to predicting Points per GP we could get a better estimate of the impact. So let’s do it. To improve the model we will add 3 more predictors → On-Ice xGF per GP, Shots per GP, IsForward. ‘IsForward’ is a simple column of data that contains a 1 if the skater is a forward and a 0 if the skater is a defenceman.

Running a linear regression on this data yields an R-squared = 0.918 and these model equations (no longer a 2-D line, but still linear):
Raw:
Points per GP = 0.035 x (On-Ice SH%) + 0.093 x (Shots per GP) + 0.43 x (On-Ice xGF per GP) + 0.17 x (IsForward) – 0.54
Standardized:
Points per GP = 0.076 x (On-Ice SH%) + 0.066 x (Shots per GP) + 0.15 x (On-Ice xGF per GP) + 0.08 x (IsForward) + 0.47
Our model now explains ~92% of the variance in Points per GP, thanks to our three new predictors. We also have a new regression coefficient for On-Ice SH% as well as coefficients for the three new predictors. Our new coefficient for On-Ice SH% is a more accurate representation of its true impact, because the model building process took the impacts of the other relevant predictors into account while it was determining it.
I included two versions of the model equation, raw and standardized. The raw version uses the input data ‘as-is’: the exact shooting percentages, xGF and shot rates. The regression coefficients from this equation have the same interpretation as in our simpler example above: e.g. an increase in Shots per GP from 7 to 8 increases the Points per GP by 0.093.
The standardized equation is a bit different. This version transforms each of the predictor columns to have a mean of zero and a standard deviation of 1. With each input scaled to the same magnitude we can use the regression coefficients to make claims about the relative impact of the predictors. For example, On-Ice xGF per GP is about twice as important for predicting Points per GP than any of the other predictors, as its regression coefficient is approximately double the coefficients of the other predictors. Another nice feature of the standardizing the predictors is that the intercept now has meaning – it is league average Points per GP.
What we have learned about linear regression:
- We can use regression coefficients to estimate the impact that predictors (independent variables) have on dependent variables
- Increasing the number of (relevant!) predictor variables in a regression can improve the model and improve our confidence that the regression coefficients represent the ‘true’ impact of the predictors
- In some cases (and RAPM is one such case) the regression coefficients tell us the relative importance of the predictors
RAPM: The Setup
RAPM is just a really big regression. In our simple linear regression example above, we had a dataset of 606 NHL players who played at least 41 games in the 2025-26 season → each row of the dataset corresponded to one of those players, and we had a few columns that represented those players’ aggregated stats. By running the regression we were able to find out the impact that having high/low values in those stats has on Points per GP.
In the case of RAPM, rather than each row being a player, each row is a single shift in a given time period. A shift is defined as a period of time where the same players are on the ice; if even one player makes a substitution a new shift begins. The time period of shifts can be any length, though typically you want it to be quite long for stability purposes. For the examples in this article we will use the entire 2025-26 season (even strength only) as the time period. The columns in the shift dataset are described below:
- The ‘target’ column: This is the output/dependent variable. In this article we will use Expected Goals/60 (xG/60), but the output could be many other variables (I run RAPM’s for Goals, Shots, Corsi, Fenwick, etc.). The target was Points per GP in our simpler example.
- The predictor variables columns: There are two types of predictor variable columns, player columns and context columns.
- Player columns: Binary variables for every player in the NHL who played at least 1 shift in the time period. For example, Connor McDavid has a column, and it is filled in with 1’s for all the shifts he was on the ice for, and 0 otherwise. Each one of the 940 players in our dataset is handled similarly.
- Context columns: These describe the game state on any particular shift, including score state, home/away, if the shift is part of a back-to-back, shift start location (offensive/neutral/defensive zone) and if the shift started as the result of a penalty kill or powerplay expiring.
Running a regression on this dataset will give us coefficients for every player and context column. Thus we will have measures of each player’s impact and the impact of the contextual variables. The model building process considers the impact of all the variables simultaneously, just like in our smaller example, so the regression coefficient for the players will represent their isolated impact; independent of their linemates, opponents, and the context. Pretty fucking baller eh? We will discuss this baller-ness in more detail soon, but we have gotten a bit ahead of ourselves. There are some more modelling details we need to discuss first.
In practice the dataset is a bit more complex than how I described it above. We duplicate every shift so that both teams involved in each shift have their offense and defense represented. A necessary outcome of this is that we actually have 2 columns for each player in the dataset, one for them on offense and one for them on defense. This means we get two regression coefficients for each player as well, one representing their offensive impact and one representing their defensive impact.
I recognize that the purpose of these duplications is not immediately obvious, so I will illustrate with an example. Imagine a single shift in a game between the Leafs and Bruins where the Bruins generate 5 xG/60 and the Leafs only generate 2 xG/60. With only one row for each shift, what would the target variable be? That’s a trick question, we have two values and one row, it would not work. So instead we use 2 rows, one from the perspective of the Leafs on offense where:
- The target column entry is 2
- All Leafs players on the ice have 1’s in their offense columns and 0’s in their defense columns
- All Bruins players on the ice have 0’s in their offense columns and 1’s in their defense columns
- Every other player in the dataset (so everyone in the NHL) has a 0 in both their columns (offense and defense)
- All the context columns are filled from the perspective of the Leafs (e.g. if the Leafs were playing on the second half of a back-to-back there would be a 1 in the back-to-back column)
And one from the perspective of the Bruins on offense where:
- The target column entry is 5
- All Bruins players on the ice have 1’s in their offense columns and 0’s in their defense columns
- All Leafs players on the ice have 0’s in their offense columns and 1’s in their defense columns
- Every other player in the dataset (so everyone in the NHL) has a 0 in both their columns (offense and defense)
- All the context columns are filled from the perspective of the Bruins (e.g. if the Bruins were home there would be a 1 in the home column)
By duplicating the shifts rows and player columns we capture the offense and defense for both teams and correctly assign the players role in each situation → this structure ensures that the model sees the Leafs players on defence for the 5 xG/60 produced by the Bruins and on offense for the 2 xG/60 that they produced themselves.
To summarize, the full dataset for the 2025-26 season (even strength) includes:
- 1 target column (xG/60): Contains the xG/60 produced by the team on offense for each shift. This is the only column that does not contain solely 1’s and 0’s.
- 12 context columns: These are: down1, down2, down3, up1, up2, up3, home, btb, offZoneStart, defZoneStart, ppExpired, pkExpired. A tie game is signified by down1, down2, down3, up1, up2 and up3 all containing 0. We do not include a ‘tied’ column to avoid collinearity. Neutral zone/on-the-fly starts are encoded in a similar way: both offZoneStart and defZoneStart are 0. Finally, home and btb’s 0 values correspond to a team being away and not playing in a back-to-back, respectively.
- 1,880 player columns: One column for offense and one column for defense for 940 skaters.
- 919,728 rows: 459,864 even strength shifts, duplicated to capture the offense and defense for both teams involved.
This may be ‘explanation overkill’ but I really want to hammer home what this dataset looks like for the 2025-26 season at even strength. Every shift from the 2025-26 season is in the dataset twice – once from the perspective of each team involved in the shift. This means each team gets its own row for each shift. In each row all of the context variable columns are filled with 1’s and 0’s according to the perspective of the team on offense. There are 10 player columns filled with 1’s, the offensive teams players offense columns and the defensive teams players defense columns. Every other player column has a 0 in it because those players are not on the ice and thus had no impact on that shift. Because there are many rows with many different combinations of players on offense and defense, and because every coefficient is estimated simultaneously – by looking at the entire set of shifts at once – the algorithm will be able to parse out how much each individual player is impacting the game.
Ok, we have the full dataset all scoped out, we are ALMOST ready to run the regression. I first want to mention how the RAPM regression differs from our simpler example from before. There are a couple extra steps we introduce to improve the modelling. These extra steps transform the model from a vanilla linear regression into a weighted-ridge regression. You can understand the model outputs without paying much attention to the distinction, however, if you are interested in doing RAPM modelling yourself then these concepts are worth understanding. I will link sources at the end of this article that provide more info, but quickly mention them here for completeness:
- Weights: We weight each shift by its length. We do this so that longer shifts get the relative importance they deserve compared to shorter shifts. The weight is included as a multiplier on that particular shift’s squared error. Mathematically this works because larger weight equates to larger ‘base’ error, so the algorithm will prioritize shrinking the error for heavily weighted shifts more (by prioritizing coefficient accuracy for players on those shifts).
- Ridge penalties: We apply ridge penalties because the dataset is highly collinear. Collinearity occurs when many of the features track each other closely. For example, Makar and Toews play together a lot and have a lot of 1’s in the same rows. Without ridge penalties if there is randomly noisy data for a few shifts where they are not playing together, the algorithm might overreact and decide that Toews is a god-like player who impacts xG/60 at a Gretzky level and Makar is worse than Ryan Reaves. The amount of penalization is determined using cross-validation, which is outside the scope of this article. The inclusion of ridge penalties adds bias to the model, which means that the coefficients received from are not 100% true estimates – they are likely smaller in magnitude than the true, unbiased coefficients.
RAPM: The Outputs
Running the regression produces coefficient values for all the contextual and player predictor columns, and an intercept. This is analogous to our simple regression finding the best coefficients and intercept for On-Ice SH%, On-Ice xGF per GP, Shots per GP, and IsForward for predicting Points per GP – only now rather than having 4 predictors and ~600 rows of data we have 1,892 predictors and almost 1 million rows of data. We can write the RAPM model equation in the same way that we could for the simple regressions:
xGF per 60 =
2.286 + 0.152 x (down1) + 0.248 x (down2) +
… all other context predictors and their coefficients …
McDavid_OFF_Coeff x (McDavid_OFF) + McDavid_DEF_Coeff x (McDavid_DEF) +
… OFF and DEF terms for all other players in the RAPM dataset
This equation has 1,893 terms – the 1,880 player terms, the 12 context variable terms and the intercept – but practically it functions the same as the equations calculated with the simple regressions.
The table below contains the values of the coefficients for the contextual predictor variables.
| Contextual Predictors | Coefficient (xGF per 60) |
|---|---|
| down1 | 0.152053542 |
| down2 | 0.247832813 |
| down3 | 0.234105883 |
| up1 | -0.030124225 |
| up2 | -0.127120843 |
| up3 | -0.177126267 |
| home | 0.098430429 |
| btb | -0.114857637 |
| offZoneStart | 0.841238506 |
| defZoneStart | -0.740341762 |
| ppExpired | 2.040845739 |
| pkExpired | -0.511796508 |
| intercept | 2.2855487 |
These coefficients are the isolated impacts of the contextual factors we included in the model. For example, all other things being equal they tell us that:
- A team that is down by 1 goal produces 0.152 xGF per 60 more than average.
- A team that starts their shift in the offensive zone produces 0.841 more xGF per 60 than average.
- A team playing at home produces 0.098 more xGF per 60 than average.
Because the model contains the contextual predictor variables, the contextual effects are isolated from the player coefficients. For example, if we didn’t use RAPM, offensive impact would be confounded with offensive zone start frequency because players who are given more offensive zone starts by their coaches have higher on-ice offensive numbers. The figure below shows On-Ice xGF/60 vs Offensive Zone Starts/60 for skaters in 2025-26 with more than 500 minutes of ice time. A positive correlation exists between offensive zone starts and on-ice xGF.

If we simply evaluated players using their on-ice xGF/60 numbers we would not be accounting for the leg up (or leg down) that their coaches give them depending on their usage. The RAPM regression considers the impact of all the predictor variables simultaneously. Including zone starts (or any other predictor) in the dataset allows the algorithm to correctly determine the part of player production that comes from starting shifts in the offensive zone and isolates that from the player’s personal impact, the impact of his teammates and opponents, and the impact of the other context.
Let’s move on to the most interesting RAPM outputs, the player coefficients. Here are the top 10 offensive players by RAPM xGF per 60 in the 2025-26 season.
| Player | xGF per 60 (OFF) |
|---|---|
| Nathan MacKinnon | 0.589 |
| Brandon Hagel | 0.547 |
| Jordan Spence | 0.464 |
| Connor McDavid | 0.440 |
| Jesper Bratt | 0.424 |
| Quinn Hughes | 0.382 |
| Lane Hutson | 0.370 |
| Jordan Kyrou | 0.356 |
| Thomas Novak | 0.349 |
| Jack Eichel | 0.344 |
These players being on the ice increased their teams xGF per 60 more than anyone else in the NHL. Nathan MacKinnon had the largest impact in the 2025-26 season – his presence increased xGF per 60 by approximately 0.59. This is the change in xGF per 60 that MacKinnon produced regardless of which of his teammates he was playing with, which players he was playing against, what the score of the game was, how much rest he and his opponents had, if he was home or away, etc.
The rest of the list largely passes the sniff test, though I suppose Jordan Spence being as high as he is may raise some eyebrows. Spence had ridiculously good numbers in 2025-26. According to his with/without splits from Natural Stat Trick his presence on the ice made his teammates numbers significantly better (you can see for yourself here). However, he was 4th on his team in ice time per game among defensemen with over 50 games played, suggesting he wasn’t heavily leaned on by his coach to play the toughest minutes. RAPM might be overrating him due to favourable matchups. Matchups are something RAPM is built to account for but if there is a limited sample of shifts with Spence matched up against elite competition, the model may have a relatively difficult time parsing out his true value compared to a first-pair defender who gets a more direct comparison. I don’t feel comfortable saying the Spence rating is wrong or right at the time of writing – this is a ‘wait and see’ question. He is certainly worth spending a late round fantasy pick on in the 2026-27 season.
Here are the top 10 defensive players by RAPM xGF per 60 in the 2025-26 season. I have also included their ranking in the analogous on-ice stat – Expected Goals Against/60 (xGA/60).
| Player | xGF per 60 (DEF) | On-Ice Stat Rank |
|---|---|---|
| Connor Murphy | -0.280 | 79 |
| Vincent Desharnais | -0.276 | 59 |
| Jack Drury | -0.260 | 1 |
| Moritz Seider | -0.251 | 146 |
| Jaccob Slavin | -0.246 | 32 |
| Pontus Holmberg | -0.240 | 11 |
| Garnet Hathaway | -0.232 | 2 |
| Oskar Bäck | -0.229 | 4 |
| Jason Dickinson | -0.226 | 245 |
| Brandon Duhaime | -0.226 | 42 |
These players being on the ice decreased their opponents xGF per 60 more than anyone else in the NHL. Defensive skill can be more difficult to intuit than offense, but many names on this list pass the sniff test – Seider, Slavin, Murphy, Hathaway, etc are all players we typically associate with being strong defensively.
To further illustrate the value of RAPM, I want to compare where players are ranked in vanilla on-ice stats to where they are ranked in RAPM stats (see the third column of the table above). A good example for this is Moritz Seider – in the 2025-26 season he is ranked as the 4th most impactful defensive player in the league using RAPM, but he had the 146th best On-Ice xGA/60 (among skaters with a least 300 minutes of ice time). As a reminder, on-ice xGA/60 is calculated by dividing all the expected goals against the Red Wings when Seider is on the ice by his total icetime. It does not account for the skill of the opposing players he is being matched up against, the skill of his teammates, or the contextual factors. And of course his RAPM rating does account for all these things. So, even though the bare result is an 146th best xGA/60 when Seider is on the ice his RAPM rating suggests that without him on the ice those results would be A LOT worse. Case in point – every player he played at least 100 minutes with during the 2025-26 season had a better CF% when playing with Seider vs. playing without him (Seider’s CF% with/withouts). Seider’s relatively worse on-ice results are a product of him being used against tough competition and playing with teammates that aren’t as good defensively as he is. His RAPM coefficient accounts for those factors and assigns him the credit he deserves.
I’ve used the with/without numbers a couple times now, because they are useful for verifying that the RAPM numbers make sense. However, they aren’t as powerful as RAPM because they don’t isolate the quality of teammates as thoroughly as RAPM does, and they don’t account for quality of competition or the in-game context at all.
I hope I have done enough to convince you that RAPM is awesome. Here is the argument summarized in two sentences:
RAPM uses shift-by-shift regression modelling to isolate player impacts from that of their teammates, opponents, and in-game context factors. Because every player is accounted for simultaneously, RAPM provides a better estimate of true impact than on-ice stats alone.
Wrap Up
This article introduces the core concepts of RAPM modelling and argues that on the basis of those concepts RAPM is the best method we have for evaluating NHL player impact. The modelling methodology presented in this article is not novel – RAPM modelling has been used in hockey for at least the past 7-8 years (see the acknowledgement section for more details). However, the DataDrivenHockey RAPM implementation is slightly more complex than the procedure described here – it captures the residual impacts of players who leave the ice seconds before an event occurs (because their play had an influence on that event happening) – but a full explanation and justification of that procedure is a story for another time.
Acknowledgement
Regularized Adjusted Plus Minus was first used in NBA analysis. It was brought into the hockey sphere by Brian McDonald, and popularized (at least in my opinion) by Evolving Hockey. My RAPM implementation is heavily based on the work of Evolving Hockey, with additional inspiration from Micah Blake McCurdy (HockeyViz). The idea for the ppExpired and pkExpired contextual variables comes from Patrick Bacon (TopDownHockey). The DataDrivenHockey RAPM implementation is done in python using the “Ridge” model from the scikit-learn library.


Leave a Reply