The Importance of Rim Finishing
Finishing at the rim represents the highest-efficiency scoring opportunity in basketball. Shots in the restricted area convert at rates exceeding 60% league-wide, far higher than any other zone. Yet rim finishing ability varies dramatically—some elite finishers convert at 70% or higher while others struggle below 55%.
Contest Level and Finishing
Not all rim attempts are equal. A dunk in transition faces a different challenge than a contested layup through multiple defenders. Wide-open rim attempts convert at very high rates—often above 70% league-wide. Tightly contested attempts present the true test of finishing skill, with league average dropping to around 50%.
def analyze_rim_finishing(shot_data):
"""Analyze rim finishing by contest level"""
rim_shots = shot_data[shot_data['SHOT_ZONE_BASIC'] == 'Restricted Area'].copy()
def categorize_contest(distance):
if distance >= 6: return 'Wide Open'
elif distance >= 4: return 'Open'
elif distance >= 2: return 'Tight'
else: return 'Very Tight'
rim_shots['CONTEST_LEVEL'] = rim_shots['CLOSEST_DEFENDER_DISTANCE'].apply(categorize_contest)
player_rim_stats = rim_shots.groupby(['PLAYER_NAME', 'CONTEST_LEVEL']).agg({
'SHOT_MADE_FLAG': ['sum', 'count', 'mean']
}).round(3)
return player_rim_stats
Finish Types and Skill Profiles
Players finish at the rim in different ways. Some excel as straight-line drivers who overpower defenders with speed and strength. Others succeed through change of direction and craftiness. The most elite finishers combine multiple tools—they can power through contact or finesse around shot blockers.
Rim Attempts in Context
Getting to the rim matters as much as finishing once there. Rim attempt rate (percentage of shots at the rim) helps identify players who prioritize high-efficiency opportunities. Elite offensive players often show both high rim attempt rates and strong finishing ability.
Impact of Rim Protection
Finishing rates are affected by the rim protection faced. Players who regularly attack elite shot blockers face different challenges than those avoiding such matchups. Team-level rim protection affects all opposing finishers.
Implementation in R
# Calculate Expected Points Added (EPA) from shot quality
library(tidyverse)
calculate_shot_quality <- function(shot_data) {
shot_data %>%
mutate(
# Base expected value by shot type and distance
base_xfg = case_when(
shot_type == "rim" ~ 0.65,
shot_type == "short_mid" ~ 0.40,
shot_type == "long_mid" ~ 0.38,
shot_type == "corner_3" ~ 0.38,
shot_type == "above_break_3" ~ 0.36,
TRUE ~ 0.40
),
# Defender distance adjustment
defender_adj = case_when(
closest_defender_dist >= 6 ~ 0.10, # Wide open
closest_defender_dist >= 4 ~ 0.05, # Open
closest_defender_dist >= 2 ~ 0.00, # Contested
TRUE ~ -0.08 # Tightly contested
),
# Touch time adjustment (catch and shoot bonus)
touch_adj = ifelse(touch_time < 2, 0.03, 0),
# Final expected FG%
xfg = base_xfg + defender_adj + touch_adj,
# Expected points
xpts = xfg * ifelse(shot_value == 3, 3, 2),
# Points added vs expected
pts_added = made * shot_value - xpts
)
}
shots <- read_csv("shot_tracking.csv")
shot_quality <- calculate_shot_quality(shots)
# Aggregate by player
player_shot_quality <- shot_quality %>%
group_by(player_name) %>%
summarise(
shots = n(),
actual_pts = sum(made * shot_value),
expected_pts = sum(xpts),
pts_added = actual_pts - expected_pts,
avg_xfg = mean(xfg),
actual_fg_pct = mean(made),
.groups = "drop"
) %>%
filter(shots >= 200) %>%
arrange(desc(pts_added))
print(head(player_shot_quality, 15))
# Shot selection quality analysis
library(tidyverse)
analyze_shot_selection <- function(shot_data) {
shot_data %>%
group_by(player_name) %>%
summarise(
total_shots = n(),
avg_xpts = mean(xpts),
# Shot quality distribution
wide_open_pct = mean(closest_defender_dist >= 6) * 100,
rim_rate = mean(shot_type == "rim") * 100,
three_rate = mean(shot_value == 3) * 100,
mid_range_rate = mean(shot_type %in% c("short_mid", "long_mid")) * 100,
.groups = "drop"
) %>%
mutate(
# Shot selection score (higher = better shot selection)
shot_selection_score = (rim_rate * 0.4 + three_rate * 0.4 -
mid_range_rate * 0.2 + wide_open_pct * 0.3) / 10
) %>%
filter(total_shots >= 200) %>%
arrange(desc(shot_selection_score))
}
shot_selection <- analyze_shot_selection(shots)
print(head(shot_selection, 15))
Implementation in R
# Calculate Expected Points Added (EPA) from shot quality
library(tidyverse)
calculate_shot_quality <- function(shot_data) {
shot_data %>%
mutate(
# Base expected value by shot type and distance
base_xfg = case_when(
shot_type == "rim" ~ 0.65,
shot_type == "short_mid" ~ 0.40,
shot_type == "long_mid" ~ 0.38,
shot_type == "corner_3" ~ 0.38,
shot_type == "above_break_3" ~ 0.36,
TRUE ~ 0.40
),
# Defender distance adjustment
defender_adj = case_when(
closest_defender_dist >= 6 ~ 0.10, # Wide open
closest_defender_dist >= 4 ~ 0.05, # Open
closest_defender_dist >= 2 ~ 0.00, # Contested
TRUE ~ -0.08 # Tightly contested
),
# Touch time adjustment (catch and shoot bonus)
touch_adj = ifelse(touch_time < 2, 0.03, 0),
# Final expected FG%
xfg = base_xfg + defender_adj + touch_adj,
# Expected points
xpts = xfg * ifelse(shot_value == 3, 3, 2),
# Points added vs expected
pts_added = made * shot_value - xpts
)
}
shots <- read_csv("shot_tracking.csv")
shot_quality <- calculate_shot_quality(shots)
# Aggregate by player
player_shot_quality <- shot_quality %>%
group_by(player_name) %>%
summarise(
shots = n(),
actual_pts = sum(made * shot_value),
expected_pts = sum(xpts),
pts_added = actual_pts - expected_pts,
avg_xfg = mean(xfg),
actual_fg_pct = mean(made),
.groups = "drop"
) %>%
filter(shots >= 200) %>%
arrange(desc(pts_added))
print(head(player_shot_quality, 15))
# Shot selection quality analysis
library(tidyverse)
analyze_shot_selection <- function(shot_data) {
shot_data %>%
group_by(player_name) %>%
summarise(
total_shots = n(),
avg_xpts = mean(xpts),
# Shot quality distribution
wide_open_pct = mean(closest_defender_dist >= 6) * 100,
rim_rate = mean(shot_type == "rim") * 100,
three_rate = mean(shot_value == 3) * 100,
mid_range_rate = mean(shot_type %in% c("short_mid", "long_mid")) * 100,
.groups = "drop"
) %>%
mutate(
# Shot selection score (higher = better shot selection)
shot_selection_score = (rim_rate * 0.4 + three_rate * 0.4 -
mid_range_rate * 0.2 + wide_open_pct * 0.3) / 10
) %>%
filter(total_shots >= 200) %>%
arrange(desc(shot_selection_score))
}
shot_selection <- analyze_shot_selection(shots)
print(head(shot_selection, 15))