The Three-Point Revolution
The three-point revolution transformed basketball offense, making analysis of three-point shooting essential for understanding the modern game. Beyond simple percentage, tracking data enables nuanced examination: shot selection quality, shot creation difficulty, defender closeouts, catch-and-shoot versus off-dribble performance, and spatial distribution around the arc.
Three-point shooting presents unique analytical challenges because of its high variance. A 38% shooter makes only 4 more threes per 100 attempts than a 34% shooter—a difference that takes hundreds of shots to reliably detect.
Shot Quality Around the Arc
Defender distance is the primary factor. Wide-open threes (defender 6+ feet) convert at roughly 40% league-wide, while tightly contested attempts fall to around 32%. Shot type matters—catch-and-shoot threes convert at higher rates than pull-up threes. Corner threes are closer and typically more open, converting at higher rates.
def analyze_three_point_shooting(shot_data):
"""Comprehensive three-point shooting analysis"""
threes = shot_data[shot_data['SHOT_TYPE'] == '3PT Field Goal'].copy()
threes['LOCATION'] = np.where(
threes['SHOT_ZONE_AREA'].isin(['Left Corner 3', 'Right Corner 3']),
'Corner', 'Above-the-Break'
)
threes['CONTEST'] = pd.cut(
threes['CLOSEST_DEFENDER_DISTANCE'],
bins=[-np.inf, 2, 4, 6, np.inf],
labels=['Very Tight', 'Tight', 'Open', 'Wide Open']
)
player_analysis = threes.groupby(['PLAYER_NAME']).agg({
'SHOT_MADE_FLAG': ['sum', 'count', 'mean'],
'CLOSEST_DEFENDER_DISTANCE': 'mean'
})
return player_analysis
Shot Creation and Self-Created Threes
Some players take threes created by teammates; others create their own through dribble moves or step-backs. Self-created threes are harder to get and make, but extremely valuable because they can come at any time against any defense. Players like Stephen Curry who create and convert difficult threes at high rates provide enormous offensive value.
Volume and Efficiency Tradeoffs
More three-point attempts generally come with lower percentage because marginal attempts are lower quality. The question isn't what percentage a player shoots but how much three-point value they generate. Volume at reasonable efficiency often beats efficiency at low volume.
Sample Size and Regression
Three-point shooting is notoriously variable. Hot starts regress downward; cold starts regress upward. Tracking data helps by providing additional predictors beyond outcome—shot quality predicts future success beyond just past percentage.
Implementation in R
# Analyze contested shot performance
library(tidyverse)
categorize_shot_contest <- function(shot_data) {
shot_data %>%
mutate(
contest_level = case_when(
closest_defender_dist >= 6 ~ "Wide Open",
closest_defender_dist >= 4 ~ "Open",
closest_defender_dist >= 2 ~ "Contested",
TRUE ~ "Tightly Contested"
)
)
}
analyze_contest_performance <- function(shot_data) {
shot_data %>%
categorize_shot_contest() %>%
group_by(player_name, contest_level) %>%
summarise(
attempts = n(),
makes = sum(made),
fg_pct = round(mean(made), 3),
.groups = "drop"
) %>%
pivot_wider(
names_from = contest_level,
values_from = c(attempts, fg_pct),
names_sep = "_"
)
}
shots <- read_csv("shot_tracking.csv")
contest_analysis <- analyze_contest_performance(shots)
# Calculate "tough shot" ability
tough_shot_ability <- contest_analysis %>%
mutate(
tight_contest_diff = `fg_pct_Tightly Contested` - 0.35,
total_tight_shots = `attempts_Tightly Contested`
) %>%
filter(total_tight_shots >= 50) %>%
arrange(desc(tight_contest_diff))
print(head(tough_shot_ability, 15))
# Shot creation vs catch-and-shoot
library(tidyverse)
analyze_shot_creation <- function(shot_data) {
shot_data %>%
mutate(
shot_creation_type = case_when(
dribbles == 0 ~ "Catch & Shoot",
dribbles <= 2 ~ "Quick Pull-up",
dribbles <= 5 ~ "Off Dribble",
TRUE ~ "High Dribble"
)
) %>%
group_by(player_name, shot_creation_type) %>%
summarise(
attempts = n(),
fg_pct = round(mean(made), 3),
avg_defender_dist = mean(closest_defender_dist),
.groups = "drop"
) %>%
pivot_wider(
names_from = shot_creation_type,
values_from = c(attempts, fg_pct),
names_sep = "_"
)
}
creation_analysis <- analyze_shot_creation(shots)
# Self-creation efficiency
self_creators <- creation_analysis %>%
mutate(
creation_volume = `attempts_Off Dribble` + `attempts_High Dribble`,
creation_efficiency = (`fg_pct_Off Dribble` * `attempts_Off Dribble` +
`fg_pct_High Dribble` * `attempts_High Dribble`) /
creation_volume
) %>%
filter(creation_volume >= 100) %>%
arrange(desc(creation_efficiency))
print(head(self_creators, 15))
Implementation in R
# Analyze contested shot performance
library(tidyverse)
categorize_shot_contest <- function(shot_data) {
shot_data %>%
mutate(
contest_level = case_when(
closest_defender_dist >= 6 ~ "Wide Open",
closest_defender_dist >= 4 ~ "Open",
closest_defender_dist >= 2 ~ "Contested",
TRUE ~ "Tightly Contested"
)
)
}
analyze_contest_performance <- function(shot_data) {
shot_data %>%
categorize_shot_contest() %>%
group_by(player_name, contest_level) %>%
summarise(
attempts = n(),
makes = sum(made),
fg_pct = round(mean(made), 3),
.groups = "drop"
) %>%
pivot_wider(
names_from = contest_level,
values_from = c(attempts, fg_pct),
names_sep = "_"
)
}
shots <- read_csv("shot_tracking.csv")
contest_analysis <- analyze_contest_performance(shots)
# Calculate "tough shot" ability
tough_shot_ability <- contest_analysis %>%
mutate(
tight_contest_diff = `fg_pct_Tightly Contested` - 0.35,
total_tight_shots = `attempts_Tightly Contested`
) %>%
filter(total_tight_shots >= 50) %>%
arrange(desc(tight_contest_diff))
print(head(tough_shot_ability, 15))
# Shot creation vs catch-and-shoot
library(tidyverse)
analyze_shot_creation <- function(shot_data) {
shot_data %>%
mutate(
shot_creation_type = case_when(
dribbles == 0 ~ "Catch & Shoot",
dribbles <= 2 ~ "Quick Pull-up",
dribbles <= 5 ~ "Off Dribble",
TRUE ~ "High Dribble"
)
) %>%
group_by(player_name, shot_creation_type) %>%
summarise(
attempts = n(),
fg_pct = round(mean(made), 3),
avg_defender_dist = mean(closest_defender_dist),
.groups = "drop"
) %>%
pivot_wider(
names_from = shot_creation_type,
values_from = c(attempts, fg_pct),
names_sep = "_"
)
}
creation_analysis <- analyze_shot_creation(shots)
# Self-creation efficiency
self_creators <- creation_analysis %>%
mutate(
creation_volume = `attempts_Off Dribble` + `attempts_High Dribble`,
creation_efficiency = (`fg_pct_Off Dribble` * `attempts_Off Dribble` +
`fg_pct_High Dribble` * `attempts_High Dribble`) /
creation_volume
) %>%
filter(creation_volume >= 100) %>%
arrange(desc(creation_efficiency))
print(head(self_creators, 15))