Chapter 13 Intermediate ~50 min read

Player Efficiency Rating (PER)

A comprehensive examination of John Hollinger's Player Efficiency Rating, one of the most influential all-in-one metrics in basketball history.

Understanding PER: The First All-in-One Metric

Few metrics have shaped basketball analytics discourse as profoundly as Player Efficiency Rating. Created by John Hollinger in the late 1990s and refined through the early 2000s, PER represented one of the first serious attempts to condense a player's entire box score contribution into a single number. The metric promised something revolutionary: the ability to compare players across positions, eras, and contexts using a standardized scale where 15.0 represented league average performance.

Understanding PER requires appreciating both its historical significance and its mathematical foundations. Hollinger built PER on the premise that positive statistical events (points, rebounds, assists, steals, blocks) should be credited while negative events (missed shots, turnovers) should be penalized. The genius of PER lay not just in this summation but in the careful weighting assigned to each component, derived from analysis of how different statistical categories correlated with team success.

The Mathematical Framework of PER

The PER calculation begins with what Hollinger termed "unadjusted PER" (uPER), a complex formula that processes raw box score statistics through a series of weighted multipliers. The formula accounts for all major statistical categories while adjusting for the pace at which a player's team operates. This pace adjustment proves critical because a player on a fast-paced team naturally accumulates more counting statistics than an equally productive player on a slower team.

The unadjusted PER formula contains over twenty terms, each representing a different aspect of player contribution. Points scored receive credit based on true shooting efficiency, meaning players who score efficiently receive more credit per point than volume scorers who achieve their totals through high usage. Assists are valued based on the estimated points they create, while rebounds are weighted differently for offensive and defensive boards to reflect their varying importance.

Field goal attempts and free throw attempts enter the formula as negative terms, representing the possessions consumed by shooting attempts. This consumption is then offset by the points generated, creating a net efficiency calculation for scoring. Turnovers receive negative weight proportional to the possession value they destroy. The defensive statistics—steals and blocks—receive credit based on their dual value: both creating turnovers or missed shots for opponents while often leading to transition opportunities.

Pace Adjustment and League Normalization

Raw statistical accumulation depends heavily on opportunities, which vary dramatically across teams and eras. A player averaging 25 points per game during the high-pace 1980s accumulated those points across more possessions than a player averaging 25 points in the slow-paced 2000s. PER addresses this through pace adjustment, dividing a player's production by their team's pace relative to league average.

The pace adjustment formula incorporates team pace, league pace, and individual minutes to ensure fair comparison across contexts. A player's uPER is divided by the factor (Team Pace / League Pace), effectively normalizing production to a league-average pace environment. This adjustment means a player with 20 uPER on a team playing at 105 possessions per game would have a higher adjusted PER than a player with the same uPER on a team playing at 95 possessions per game.

League normalization represents the final step in PER calculation. After pace adjustment, PER is scaled so that the league average always equals 15.0. This normalization allows for comparisons across seasons, as a 22 PER in 2010 theoretically represents the same relative quality as a 22 PER in 2020. The normalization also sets the standard deviation of PER to approximately 5 points, creating interpretive benchmarks: 20+ indicates all-star level play, 25+ suggests MVP-caliber performance, and values below 10 indicate replacement-level contribution.

Computing PER with R and Python

Implementing PER calculations allows analysts to verify published values and explore the metric's behavior. The calculation requires box score statistics and team pace data. Here is a Python implementation:

import pandas as pd
import numpy as np

def calculate_per(player_stats, team_stats, league_stats):
    """
    Calculate Player Efficiency Rating (PER)
    """
    # Factor calculation
    factor = (2/3) - (0.5 * (league_stats['AST'] / league_stats['FGM'])) / 
             (2 * (league_stats['FGM'] / league_stats['FTM']))

    # Value of Possession
    vop = league_stats['PTS'] / (league_stats['FGA'] - league_stats['ORB'] +
                                   league_stats['TOV'] + 0.44 * league_stats['FTA'])

    # Defensive Rebound Percentage
    drbp = (league_stats['TRB'] - league_stats['ORB']) / league_stats['TRB']

    # Unadjusted PER calculation
    uper = (1 / player_stats['MIN']) * (
        player_stats['FG3M'] +
        (2/3) * player_stats['AST'] +
        (2 - factor * (team_stats['AST'] / team_stats['FGM'])) * player_stats['FGM'] +
        (player_stats['FTM'] * 0.5 * (1 + (1 - team_stats['AST'] / team_stats['FGM']) +
                                        (2/3) * (team_stats['AST'] / team_stats['FGM']))) -
        vop * player_stats['TOV'] -
        vop * drbp * (player_stats['FGA'] - player_stats['FGM']) -
        vop * 0.44 * (0.44 + (0.56 * drbp)) * (player_stats['FTA'] - player_stats['FTM']) +
        vop * (1 - drbp) * player_stats['DRB'] +
        vop * drbp * player_stats['ORB'] +
        vop * player_stats['STL'] +
        vop * drbp * player_stats['BLK'] -
        player_stats['PF'] * ((league_stats['FTM'] / league_stats['PF']) -
                                0.44 * (league_stats['FTA'] / league_stats['PF']) * vop)
    )

    # Pace adjustment
    pace_adjustment = league_stats['PACE'] / team_stats['PACE']
    aper = uper * pace_adjustment

    # League normalization (scaling to 15.0 average)
    per = aper * (15 / league_stats['AVG_PER'])

    return round(per, 1)

Strengths and Applications of PER

PER excels at identifying high-volume, high-efficiency offensive players. The metric rewards players who convert possessions efficiently while accumulating traditional counting statistics. Historical analyses using PER consistently identify consensus great players—Michael Jordan, LeBron James, Shaquille O'Neal, and other dominant forces—as the top performers. This face validity helped establish PER as a trusted metric among casual analysts and media members.

The single-number output makes PER exceptionally useful for quick comparisons and discussions. Fantasy basketball participants use PER to evaluate overall production, while journalists reference it when making award arguments. The 15.0 baseline and intuitive scale (higher is better, with clear thresholds at 15, 20, and 25) enable meaningful interpretation without deep statistical knowledge.

Critical Limitations and Controversies

Despite its popularity, PER has faced substantial criticism from the analytics community. The most fundamental criticism concerns PER's complete reliance on box score statistics, which capture only a fraction of player contribution. Defense, in particular, presents a major blind spot—while blocks and steals enter the formula, the vast majority of defensive value (positioning, contest quality, help defense, communication) remains invisible to PER.

The positive credit given to rebounds has drawn criticism as potentially rewarding poor team defense. A team that forces difficult shots and limits opponent offensive rebounds may see its defenders record fewer rebounds than a team allowing easy misses. The player on the worse defensive team could actually record higher PER for accumulating rebounds that only existed due to poor team defense.

In the contemporary analytics landscape, PER occupies an interesting position. Serious analysts recognize its limitations and prefer more sophisticated metrics like RPM, RAPTOR, or EPM that incorporate tracking data and plus-minus information. However, PER remains widely cited in media and public discourse, meaning analysts must understand it to engage with broader basketball conversations.

Implementation in R

# Calculate Player Efficiency Rating (PER)
library(tidyverse)

calculate_per <- function(player_stats, league_stats) {
  # League averages for normalization
  lg_pace <- league_stats$pace
  lg_per <- 15.0  # League average PER is always 15

  player_stats %>%
    mutate(
      # uPER formula (simplified Hollinger formula)
      factor <- (2/3) - (0.5 * (lg_ast / lg_fgm)) / (2 * (lg_fgm / lg_ftm)),
      vop <- lg_pts / (lg_fga - lg_oreb + lg_tov + 0.44 * lg_fta),
      drbp <- (lg_reb - lg_oreb) / lg_reb,

      # Calculate uPER
      uper = (1 / min) * (
        fg3m +
        (2/3) * ast +
        (2 - factor * (team_ast / team_fgm)) * fgm +
        (ftm * 0.5 * (1 + (1 - (team_ast / team_fgm)) +
                      (2/3) * (team_ast / team_fgm))) -
        vop * tov -
        vop * drbp * (fga - fgm) -
        vop * 0.44 * (0.44 + (0.56 * drbp)) * (fta - ftm) +
        vop * (1 - drbp) * (reb - oreb) +
        vop * drbp * oreb +
        vop * stl +
        vop * drbp * blk -
        pf * ((lg_ftm / lg_pf) - 0.44 * (lg_fta / lg_pf) * vop)
      ),

      # Pace adjustment
      per = uper * (lg_pace / team_pace)
    )
}

# Load data
players <- read_csv("player_stats.csv")
league <- read_csv("league_averages.csv")

per_ratings <- calculate_per(players, league)

# Top PER players
top_per <- per_ratings %>%
  filter(min >= 1000) %>%
  arrange(desc(per)) %>%
  select(player_name, pts, per) %>%
  head(20)

print(top_per)
# PER analysis and visualization
library(tidyverse)
library(ggplot2)

# PER distribution by position
per_by_position <- per_ratings %>%
  filter(min >= 500) %>%
  ggplot(aes(x = position, y = per, fill = position)) +
  geom_boxplot() +
  labs(title = "PER Distribution by Position",
       x = "Position", y = "PER") +
  theme_minimal() +
  theme(legend.position = "none")

print(per_by_position)

# Historical PER leaders
per_leaders <- per_ratings %>%
  filter(min >= 2000) %>%
  group_by(season) %>%
  slice_max(per, n = 1) %>%
  ungroup()

print(per_leaders)

Implementation in R

# Calculate Player Efficiency Rating (PER)
library(tidyverse)

calculate_per <- function(player_stats, league_stats) {
  # League averages for normalization
  lg_pace <- league_stats$pace
  lg_per <- 15.0  # League average PER is always 15

  player_stats %>%
    mutate(
      # uPER formula (simplified Hollinger formula)
      factor <- (2/3) - (0.5 * (lg_ast / lg_fgm)) / (2 * (lg_fgm / lg_ftm)),
      vop <- lg_pts / (lg_fga - lg_oreb + lg_tov + 0.44 * lg_fta),
      drbp <- (lg_reb - lg_oreb) / lg_reb,

      # Calculate uPER
      uper = (1 / min) * (
        fg3m +
        (2/3) * ast +
        (2 - factor * (team_ast / team_fgm)) * fgm +
        (ftm * 0.5 * (1 + (1 - (team_ast / team_fgm)) +
                      (2/3) * (team_ast / team_fgm))) -
        vop * tov -
        vop * drbp * (fga - fgm) -
        vop * 0.44 * (0.44 + (0.56 * drbp)) * (fta - ftm) +
        vop * (1 - drbp) * (reb - oreb) +
        vop * drbp * oreb +
        vop * stl +
        vop * drbp * blk -
        pf * ((lg_ftm / lg_pf) - 0.44 * (lg_fta / lg_pf) * vop)
      ),

      # Pace adjustment
      per = uper * (lg_pace / team_pace)
    )
}

# Load data
players <- read_csv("player_stats.csv")
league <- read_csv("league_averages.csv")

per_ratings <- calculate_per(players, league)

# Top PER players
top_per <- per_ratings %>%
  filter(min >= 1000) %>%
  arrange(desc(per)) %>%
  select(player_name, pts, per) %>%
  head(20)

print(top_per)
# PER analysis and visualization
library(tidyverse)
library(ggplot2)

# PER distribution by position
per_by_position <- per_ratings %>%
  filter(min >= 500) %>%
  ggplot(aes(x = position, y = per, fill = position)) +
  geom_boxplot() +
  labs(title = "PER Distribution by Position",
       x = "Position", y = "PER") +
  theme_minimal() +
  theme(legend.position = "none")

print(per_by_position)

# Historical PER leaders
per_leaders <- per_ratings %>%
  filter(min >= 2000) %>%
  group_by(season) %>%
  slice_max(per, n = 1) %>%
  ungroup()

print(per_leaders)
Chapter Summary

You've completed Chapter 13: Player Efficiency Rating (PER).