The Analytical Case Against Mid-Range
Basic expected value arithmetic makes the case against mid-range shots compelling. A league-average mid-range shot (roughly 40% from 10-22 feet) produces 0.80 expected points per attempt. A league-average three-pointer (roughly 36%) produces 1.08 expected points. A league-average rim attempt (roughly 62%) produces 1.24 expected points.
The shift has been dramatic. In 2000, mid-range shots constituted roughly 40% of all field goal attempts. By 2020, that figure had fallen below 20%.
When Mid-Range Makes Sense
Shot clock desperation represents the most obvious case. With the shot clock expiring and no rim or three-point opportunity available, a makeable mid-range shot beats a turnover.
Elite mid-range shooters change the expected value calculation. While league-average mid-range efficiency is poor, shooters like Kevin Durant or Chris Paul consistently exceed 50% from mid-range. At 50%, a two-point jumper produces 1.00 expected points—competitive with average three-point shooting.
Playoff basketball often compresses offensive options as defenses in elimination games pack the paint and contest threes more aggressively.
def analyze_mid_range_value(shot_data):
"""Analyze when mid-range shots provide value"""
mid_range = shot_data[
(shot_data['SHOT_DISTANCE'] >= 10) &
(shot_data['SHOT_DISTANCE'] < 24) &
(shot_data['SHOT_TYPE'] != '3PT Field Goal')
].copy()
player_midrange = mid_range.groupby('PLAYER_NAME').agg({
'SHOT_MADE_FLAG': ['sum', 'count', 'mean']
})
player_midrange.columns = ['Makes', 'Attempts', 'MR_PCT']
player_midrange['Expected_PTS'] = player_midrange['MR_PCT'] * 2
# Identify efficient mid-range shooters
efficient_shooters = player_midrange[player_midrange['Expected_PTS'] >= 1.0]
return efficient_shooters
The Elite Mid-Range Artists
Kevin Durant's combination of height, shooting stroke, and shot creation makes contested mid-range jumpers high-percentage for him. DeMar DeRozan has built an entire offensive identity around mid-range mastery. Chris Paul's mid-range game reflects shot selection, timing, and creating quality looks.
Team Strategy Implications
For most players, reducing mid-range attempts remains good strategy. However, maintaining a credible mid-range threat preserves offensive versatility. Playoff roster construction may benefit from mid-range capability when standard approaches are shut down.
Implementation in R
# Calculate defensive impact from tracking
library(tidyverse)
calculate_defensive_metrics <- function(defensive_data) {
defensive_data %>%
group_by(defender_id, defender_name) %>%
summarise(
# Shots defended
shots_defended = n(),
# Opponent shooting
opp_fg_pct = mean(shot_made),
opp_xfg_pct = mean(expected_fg_pct),
dfg_diff = opp_fg_pct - opp_xfg_pct,
# By zone
rim_shots_defended = sum(shot_zone == "rim"),
rim_dfg_pct = mean(shot_made[shot_zone == "rim"]),
three_defended = sum(shot_zone == "three"),
three_dfg_pct = mean(shot_made[shot_zone == "three"]),
# Contest quality
avg_contest_dist = mean(contest_distance),
tight_contest_rate = mean(contest_distance < 2),
.groups = "drop"
)
}
defensive_data <- read_csv("defensive_tracking.csv")
defensive_metrics <- calculate_defensive_metrics(defensive_data)
# Best rim protectors
rim_protectors <- defensive_metrics %>%
filter(rim_shots_defended >= 100) %>%
arrange(rim_dfg_pct) %>%
select(defender_name, rim_shots_defended, rim_dfg_pct, avg_contest_dist) %>%
head(15)
print(rim_protectors)
# Matchup tracking analysis
library(tidyverse)
analyze_matchups <- function(matchup_data) {
matchup_data %>%
group_by(defender_name, offensive_player_name) %>%
summarise(
matchup_poss = n(),
pts_allowed = sum(points),
pts_per_poss = round(pts_allowed / matchup_poss, 2),
fg_pct_allowed = round(mean(shot_made[shot_attempt == 1]), 3),
.groups = "drop"
) %>%
filter(matchup_poss >= 20)
}
matchups <- read_csv("matchup_tracking.csv")
matchup_analysis <- analyze_matchups(matchups)
# Find specific defender-offensive player matchups
best_defenders <- matchup_analysis %>%
group_by(defender_name) %>%
summarise(
matchups = n(),
avg_pts_per_poss = mean(pts_per_poss),
.groups = "drop"
) %>%
filter(matchups >= 10) %>%
arrange(avg_pts_per_poss)
print(head(best_defenders, 15))
Implementation in R
# Calculate defensive impact from tracking
library(tidyverse)
calculate_defensive_metrics <- function(defensive_data) {
defensive_data %>%
group_by(defender_id, defender_name) %>%
summarise(
# Shots defended
shots_defended = n(),
# Opponent shooting
opp_fg_pct = mean(shot_made),
opp_xfg_pct = mean(expected_fg_pct),
dfg_diff = opp_fg_pct - opp_xfg_pct,
# By zone
rim_shots_defended = sum(shot_zone == "rim"),
rim_dfg_pct = mean(shot_made[shot_zone == "rim"]),
three_defended = sum(shot_zone == "three"),
three_dfg_pct = mean(shot_made[shot_zone == "three"]),
# Contest quality
avg_contest_dist = mean(contest_distance),
tight_contest_rate = mean(contest_distance < 2),
.groups = "drop"
)
}
defensive_data <- read_csv("defensive_tracking.csv")
defensive_metrics <- calculate_defensive_metrics(defensive_data)
# Best rim protectors
rim_protectors <- defensive_metrics %>%
filter(rim_shots_defended >= 100) %>%
arrange(rim_dfg_pct) %>%
select(defender_name, rim_shots_defended, rim_dfg_pct, avg_contest_dist) %>%
head(15)
print(rim_protectors)
# Matchup tracking analysis
library(tidyverse)
analyze_matchups <- function(matchup_data) {
matchup_data %>%
group_by(defender_name, offensive_player_name) %>%
summarise(
matchup_poss = n(),
pts_allowed = sum(points),
pts_per_poss = round(pts_allowed / matchup_poss, 2),
fg_pct_allowed = round(mean(shot_made[shot_attempt == 1]), 3),
.groups = "drop"
) %>%
filter(matchup_poss >= 20)
}
matchups <- read_csv("matchup_tracking.csv")
matchup_analysis <- analyze_matchups(matchups)
# Find specific defender-offensive player matchups
best_defenders <- matchup_analysis %>%
group_by(defender_name) %>%
summarise(
matchups = n(),
avg_pts_per_poss = mean(pts_per_poss),
.groups = "drop"
) %>%
filter(matchups >= 10) %>%
arrange(avg_pts_per_poss)
print(head(best_defenders, 15))