Expected Points Per Shot
Expected Points Per Shot (xPTS)
Introduction
Expected Points per Shot (xPTS) is an advanced metric that quantifies the value of shot attempts by considering their location, context, and historical conversion rates. Unlike simple field goal percentage, xPTS accounts for the fact that different shots have different point values and success probabilities, providing a more nuanced evaluation of shot quality and player efficiency.
This metric is fundamental to modern basketball analytics, enabling teams to optimize shot selection, evaluate player decision-making, and design offensive systems that maximize scoring efficiency.
What Expected Points Per Shot Measures
Core Concept
Expected Points per Shot represents the average number of points a team or player can expect to score from a particular shot attempt, based on:
- Shot Location: Distance from basket and court position
- Shot Type: Two-point vs. three-point attempts
- Historical Success Rate: League-wide or player-specific conversion rates
- Point Value: Expected points = FG% × Points Available
Mathematical Foundation
The basic xPTS calculation:
xPTS = P(make) × Points_if_made
For example:
- A corner three with 40% success rate: 0.40 × 3 = 1.20 xPTS
- A mid-range shot with 42% success rate: 0.42 × 2 = 0.84 xPTS
- A layup with 65% success rate: 0.65 × 2 = 1.30 xPTS
Key Insights
- Shot Efficiency Threshold: 1.0 point per shot (50% on 2PT, 33.3% on 3PT)
- Elite Shots: Above 1.15 xPTS (rim attempts, open corner threes)
- Poor Shots: Below 0.85 xPTS (contested mid-range, deep threes)
- League Average: Approximately 1.05-1.10 points per shot
Location and Context Factors
Shot Location Zones
| Zone | Distance | Typical FG% | xPTS | Value Rating |
|---|---|---|---|---|
| Restricted Area | 0-4 feet | 63-68% | 1.26-1.36 | Elite |
| Paint (Non-RA) | 4-8 feet | 40-45% | 0.80-0.90 | Below Average |
| Mid-Range | 8-16 feet | 38-42% | 0.76-0.84 | Poor |
| Long Mid-Range | 16-23 feet | 38-40% | 0.76-0.80 | Poor |
| Corner Three | 22-23.75 feet | 38-40% | 1.14-1.20 | Elite |
| Above-the-Break 3 | 23.75+ feet | 35-37% | 1.05-1.11 | Good |
Contextual Modifiers
Shot value varies significantly based on context:
1. Defender Distance
- Open (6+ feet): +8-12% FG%, +0.15-0.25 xPTS
- Wide Open (8+ feet): +12-18% FG%, +0.25-0.40 xPTS
- Contested (2-4 feet): -6-10% FG%, -0.12-0.20 xPTS
- Tightly Contested (0-2 feet): -12-18% FG%, -0.25-0.35 xPTS
2. Shot Clock Situation
- Early Clock (18-24 sec): Better shot selection, +0.05-0.10 xPTS
- Mid Clock (8-18 sec): Baseline efficiency
- Late Clock (0-7 sec): Forced attempts, -0.10-0.20 xPTS
3. Touch Time
- Catch-and-Shoot (0-2 sec): Higher efficiency, +0.08-0.15 xPTS
- Quick Decision (2-4 sec): Baseline efficiency
- Prolonged (4+ sec): Lower efficiency, -0.05-0.12 xPTS
4. Dribbles Before Shot
- 0 Dribbles: Highest 3PT%, +0.10-0.15 xPTS
- 1-2 Dribbles: Baseline efficiency
- 3-6 Dribbles: Declining efficiency, -0.05-0.10 xPTS
- 7+ Dribbles: Isolation situations, context-dependent
5. Play Type
- Transition: +0.10-0.18 xPTS (reduced defense)
- Spot-Up: +0.05-0.12 xPTS (rhythm shooting)
- Off Screen: +0.03-0.08 xPTS (defensive confusion)
- Isolation: -0.02-0.08 xPTS (set defense)
- Post-Up: -0.05-0.12 xPTS (typically mid-range)
Python Implementation: Calculating xPTS
Basic xPTS Calculator
import pandas as pd
import numpy as np
from typing import Dict, List, Tuple
class ExpectedPointsCalculator:
"""Calculate expected points per shot based on location and context."""
def __init__(self, shot_data: pd.DataFrame):
"""
Initialize with historical shot data.
Parameters:
-----------
shot_data : DataFrame with columns:
- x, y: shot coordinates
- shot_made: boolean
- shot_type: '2PT' or '3PT'
- defender_distance: float
- shot_clock: float
"""
self.shot_data = shot_data
self.zone_stats = self._calculate_zone_stats()
def _calculate_zone_stats(self) -> Dict:
"""Calculate success rates by zone."""
zones = {
'restricted_area': (0, 4),
'paint': (4, 8),
'short_mid': (8, 16),
'long_mid': (16, 23),
'corner_three': 'corner',
'atb_three': 'above_break'
}
stats = {}
for zone_name, zone_def in zones.items():
if zone_name in ['corner_three', 'atb_three']:
zone_shots = self.shot_data[
self.shot_data['zone'] == zone_def
]
else:
min_dist, max_dist = zone_def
zone_shots = self.shot_data[
(self.shot_data['shot_distance'] >= min_dist) &
(self.shot_data['shot_distance'] < max_dist)
]
if len(zone_shots) > 0:
fg_pct = zone_shots['shot_made'].mean()
pts_value = 3 if '3' in zone_name else 2
xpts = fg_pct * pts_value
stats[zone_name] = {
'attempts': len(zone_shots),
'fg_pct': fg_pct,
'xpts': xpts
}
return stats
def calculate_shot_xpts(self, shot: Dict) -> float:
"""
Calculate expected points for a single shot.
Parameters:
-----------
shot : Dict with keys:
- zone: shot zone
- defender_distance: float
- shot_clock: float
- touch_time: float
- dribbles: int
Returns:
--------
float : Expected points for the shot
"""
# Base xPTS from zone
base_xpts = self.zone_stats.get(shot['zone'], {}).get('xpts', 1.0)
# Contextual adjustments
adjustments = 0.0
# Defender distance modifier
if shot['defender_distance'] >= 6:
adjustments += 0.15
elif shot['defender_distance'] >= 4:
adjustments += 0.08
elif shot['defender_distance'] < 2:
adjustments -= 0.20
# Shot clock modifier
if shot['shot_clock'] < 4:
adjustments -= 0.15
elif shot['shot_clock'] > 18:
adjustments += 0.08
# Touch time modifier (for jump shots)
if shot.get('touch_time', 2) < 2:
adjustments += 0.10
# Dribbles modifier
dribbles = shot.get('dribbles', 0)
if dribbles == 0:
adjustments += 0.05
elif dribbles >= 5:
adjustments -= 0.08
return max(0.0, base_xpts + adjustments)
def calculate_player_xpts(self, player_shots: pd.DataFrame) -> Dict:
"""
Calculate expected points metrics for a player.
Returns:
--------
Dict with xPTS metrics and shot selection analysis
"""
player_shots['xpts'] = player_shots.apply(
lambda row: self.calculate_shot_xpts(row.to_dict()),
axis=1
)
total_xpts = player_shots['xpts'].sum()
total_attempts = len(player_shots)
avg_xpts = total_xpts / total_attempts if total_attempts > 0 else 0
# Actual points scored
player_shots['points_scored'] = (
player_shots['shot_made'] *
player_shots['shot_type'].map({'2PT': 2, '3PT': 3})
)
total_points = player_shots['points_scored'].sum()
# Points above/below expectation
points_above_expected = total_points - total_xpts
return {
'total_attempts': total_attempts,
'total_xpts': total_xpts,
'avg_xpts_per_shot': avg_xpts,
'total_points': total_points,
'points_above_expected': points_above_expected,
'shooting_efficiency': total_points / total_attempts if total_attempts > 0 else 0,
'expected_efficiency': avg_xpts,
'shot_quality_grade': self._grade_shot_quality(avg_xpts)
}
def _grade_shot_quality(self, xpts: float) -> str:
"""Grade shot quality based on xPTS."""
if xpts >= 1.15:
return 'Elite'
elif xpts >= 1.05:
return 'Good'
elif xpts >= 0.95:
return 'Average'
elif xpts >= 0.85:
return 'Below Average'
else:
return 'Poor'
# Example usage
if __name__ == "__main__":
# Sample shot data
shots = pd.DataFrame({
'shot_distance': [3, 5, 15, 18, 24, 25, 2, 10],
'zone': ['restricted_area', 'paint', 'short_mid', 'long_mid',
'corner_three', 'atb_three', 'restricted_area', 'short_mid'],
'shot_made': [True, False, True, False, True, True, True, False],
'shot_type': ['2PT', '2PT', '2PT', '2PT', '3PT', '3PT', '2PT', '2PT'],
'defender_distance': [1.5, 3.2, 4.5, 2.8, 7.5, 5.5, 0.8, 3.5],
'shot_clock': [15, 8, 5, 3, 18, 12, 20, 6],
'touch_time': [0.5, 2.1, 3.5, 2.8, 1.2, 1.8, 0.3, 4.2],
'dribbles': [0, 1, 3, 2, 0, 1, 0, 4]
})
# Calculate xPTS
calc = ExpectedPointsCalculator(shots)
# Individual shot analysis
for idx, shot in shots.iterrows():
xpts = calc.calculate_shot_xpts(shot.to_dict())
actual_pts = shot['shot_made'] * (3 if shot['shot_type'] == '3PT' else 2)
print(f"Shot {idx + 1}: Zone={shot['zone']}, "
f"xPTS={xpts:.2f}, Actual={actual_pts}")
# Player summary
player_metrics = calc.calculate_player_xpts(shots)
print("\nPlayer Expected Points Summary:")
for metric, value in player_metrics.items():
if isinstance(value, float):
print(f"{metric}: {value:.3f}")
else:
print(f"{metric}: {value}")
Advanced Spatial xPTS Model
import numpy as np
from scipy.spatial import distance
from sklearn.gaussian_process import GaussianProcessRegressor
from sklearn.gaussian_process.kernels import RBF, ConstantKernel
class SpatialXPTSModel:
"""Advanced spatial model for expected points using Gaussian processes."""
def __init__(self):
self.model = None
self.kernel = ConstantKernel(1.0) * RBF(length_scale=5.0)
def prepare_features(self, shot_data: pd.DataFrame) -> np.ndarray:
"""
Prepare feature matrix for modeling.
Features:
- x, y coordinates
- shot distance
- angle to basket
- defender distance
- shot clock
"""
features = []
for _, shot in shot_data.iterrows():
x, y = shot['x'], shot['y']
dist = np.sqrt(x**2 + y**2)
angle = np.arctan2(y, x)
feature_vector = [
x,
y,
dist,
angle,
shot.get('defender_distance', 4.0),
shot.get('shot_clock', 12.0),
shot.get('touch_time', 2.0),
shot.get('dribbles', 1)
]
features.append(feature_vector)
return np.array(features)
def train(self, shot_data: pd.DataFrame):
"""Train the spatial xPTS model."""
X = self.prepare_features(shot_data)
# Target: points per shot (0, 2, or 3)
y = (shot_data['shot_made'] *
shot_data['shot_type'].map({'2PT': 2, '3PT': 3}))
self.model = GaussianProcessRegressor(
kernel=self.kernel,
n_restarts_optimizer=10,
alpha=0.1
)
self.model.fit(X, y)
def predict_xpts(self, shot_features: np.ndarray) -> Tuple[float, float]:
"""
Predict expected points with uncertainty.
Returns:
--------
Tuple of (mean_xpts, std_xpts)
"""
if self.model is None:
raise ValueError("Model must be trained first")
mean, std = self.model.predict(
shot_features.reshape(1, -1),
return_std=True
)
return mean[0], std[0]
def generate_court_heatmap(self,
resolution: int = 50,
defender_dist: float = 4.0,
shot_clock: float = 12.0) -> np.ndarray:
"""
Generate xPTS heatmap for entire court.
Parameters:
-----------
resolution : Grid resolution
defender_dist : Default defender distance
shot_clock : Default shot clock
Returns:
--------
2D array of xPTS values
"""
# Court dimensions (half court)
x_range = np.linspace(-25, 25, resolution)
y_range = np.linspace(0, 47, resolution)
heatmap = np.zeros((resolution, resolution))
for i, x in enumerate(x_range):
for j, y in enumerate(y_range):
dist = np.sqrt(x**2 + y**2)
angle = np.arctan2(y, x)
features = np.array([[
x, y, dist, angle,
defender_dist, shot_clock, 1.5, 1
]])
xpts, _ = self.predict_xpts(features)
heatmap[j, i] = max(0, xpts)
return heatmap
# Example: Train and generate heatmap
if __name__ == "__main__":
# Load comprehensive shot data
shot_data = pd.read_csv('shots_data.csv')
# Train model
spatial_model = SpatialXPTSModel()
spatial_model.train(shot_data)
# Generate heatmap
heatmap = spatial_model.generate_court_heatmap()
print(f"Court xPTS Heatmap generated: {heatmap.shape}")
print(f"Average court xPTS: {heatmap.mean():.3f}")
print(f"Max xPTS location: {heatmap.max():.3f}")
R Implementation: Shot Value Modeling
Comprehensive xPTS Analysis Framework
library(tidyverse)
library(mgcv)
library(ggplot2)
library(viridis)
library(gridExtra)
# Expected Points Calculator Class
ExpectedPointsAnalyzer <- R6::R6Class(
"ExpectedPointsAnalyzer",
public = list(
shot_data = NULL,
xpts_model = NULL,
initialize = function(shot_data) {
self$shot_data <- shot_data %>%
mutate(
shot_distance = sqrt(x^2 + y^2),
shot_angle = atan2(y, x),
shot_value = ifelse(shot_type == "3PT", 3, 2),
points_scored = shot_made * shot_value
)
},
calculate_zone_xpts = function() {
# Calculate expected points by zone
zone_stats <- self$shot_data %>%
group_by(zone) %>%
summarise(
attempts = n(),
makes = sum(shot_made),
fg_pct = mean(shot_made),
avg_shot_value = mean(shot_value),
xpts = mean(shot_made) * mean(shot_value),
actual_pps = mean(points_scored),
.groups = 'drop'
) %>%
mutate(
efficiency_rating = xpts / 1.0, # Normalized to 1.0 baseline
value_grade = case_when(
xpts >= 1.15 ~ "Elite",
xpts >= 1.05 ~ "Good",
xpts >= 0.95 ~ "Average",
xpts >= 0.85 ~ "Below Average",
TRUE ~ "Poor"
)
) %>%
arrange(desc(xpts))
return(zone_stats)
},
build_xpts_model = function() {
# Build GAM model for expected points
model_formula <- as.formula(
"points_scored ~ s(x, y, k = 50) +
s(defender_distance) +
s(shot_clock) +
s(touch_time) +
dribbles +
shot_type"
)
self$xpts_model <- gam(
model_formula,
data = self$shot_data,
family = gaussian(),
method = "REML"
)
# Add predictions to data
self$shot_data <- self$shot_data %>%
mutate(
xpts = predict(self$xpts_model, newdata = .),
points_above_expected = points_scored - xpts
)
return(summary(self$xpts_model))
},
analyze_player_shot_selection = function(player_id) {
player_shots <- self$shot_data %>%
filter(player == player_id)
if (nrow(player_shots) == 0) {
return(NULL)
}
# Shot distribution by value
shot_distribution <- player_shots %>%
mutate(
shot_quality = case_when(
xpts >= 1.15 ~ "Elite (1.15+)",
xpts >= 1.05 ~ "Good (1.05-1.15)",
xpts >= 0.95 ~ "Average (0.95-1.05)",
xpts >= 0.85 ~ "Below Avg (0.85-0.95)",
TRUE ~ "Poor (<0.85)"
)
) %>%
count(shot_quality) %>%
mutate(pct = n / sum(n) * 100)
# Overall metrics
metrics <- player_shots %>%
summarise(
total_shots = n(),
total_points = sum(points_scored),
total_xpts = sum(xpts),
avg_xpts = mean(xpts),
actual_pps = mean(points_scored),
expected_pps = mean(xpts),
points_above_expected = sum(points_above_expected),
pae_per_shot = mean(points_above_expected),
elite_shot_pct = mean(xpts >= 1.15) * 100,
poor_shot_pct = mean(xpts < 0.85) * 100
)
return(list(
distribution = shot_distribution,
metrics = metrics
))
},
compare_shot_selection = function(player_ids) {
# Compare shot selection quality across players
comparison <- map_df(player_ids, function(pid) {
player_shots <- self$shot_data %>%
filter(player == pid)
if (nrow(player_shots) == 0) return(NULL)
player_shots %>%
summarise(
player = first(player),
attempts = n(),
avg_xpts = mean(xpts),
actual_pps = mean(points_scored),
elite_shot_pct = mean(xpts >= 1.15) * 100,
good_shot_pct = mean(xpts >= 1.05) * 100,
poor_shot_pct = mean(xpts < 0.85) * 100,
rim_attempt_pct = mean(zone == "restricted_area") * 100,
three_attempt_pct = mean(shot_type == "3PT") * 100,
mid_range_pct = mean(zone %in% c("short_mid", "long_mid")) * 100
)
}) %>%
arrange(desc(avg_xpts))
return(comparison)
},
calculate_lineup_xpts = function(lineup_id) {
# Calculate expected points for specific lineup
lineup_shots <- self$shot_data %>%
filter(lineup == lineup_id)
lineup_summary <- lineup_shots %>%
summarise(
possessions = n_distinct(possession_id),
total_shots = n(),
shots_per_possession = n() / n_distinct(possession_id),
total_xpts = sum(xpts),
total_points = sum(points_scored),
xpts_per_possession = sum(xpts) / n_distinct(possession_id),
points_per_possession = sum(points_scored) / n_distinct(possession_id),
shot_quality = mean(xpts),
shooting_performance = mean(points_scored) - mean(xpts)
)
return(lineup_summary)
}
)
)
# Shot Value Visualization Functions
plot_xpts_heatmap <- function(shot_data, xpts_model) {
# Create grid for predictions
grid <- expand.grid(
x = seq(-25, 25, length.out = 100),
y = seq(0, 47, length.out = 100)
) %>%
mutate(
shot_distance = sqrt(x^2 + y^2),
shot_angle = atan2(y, x),
defender_distance = 4.0, # Average
shot_clock = 12.0,
touch_time = 2.0,
dribbles = 1,
shot_type = ifelse(shot_distance > 23.75, "3PT", "2PT")
)
# Predict xPTS
grid$xpts <- predict(xpts_model, newdata = grid)
grid$xpts <- pmax(grid$xpts, 0) # Floor at 0
# Plot
p <- ggplot(grid, aes(x = x, y = y, fill = xpts)) +
geom_tile() +
scale_fill_viridis(
name = "xPTS",
limits = c(0, 1.4),
option = "plasma"
) +
coord_fixed() +
labs(
title = "Expected Points Per Shot Heatmap",
subtitle = "Shot value by court location (neutral context)",
x = "Court Width (feet)",
y = "Court Length (feet)"
) +
theme_minimal() +
theme(
plot.title = element_text(size = 16, face = "bold"),
legend.position = "right"
)
return(p)
}
plot_shot_quality_distribution <- function(analyzer, player_id) {
analysis <- analyzer$analyze_player_shot_selection(player_id)
if (is.null(analysis)) return(NULL)
# Distribution plot
p1 <- ggplot(analysis$distribution, aes(x = reorder(shot_quality, -n), y = pct)) +
geom_col(aes(fill = shot_quality), show.legend = FALSE) +
geom_text(aes(label = sprintf("%.1f%%", pct)), vjust = -0.5) +
scale_fill_manual(values = c(
"Elite (1.15+)" = "#00C853",
"Good (1.05-1.15)" = "#64DD17",
"Average (0.95-1.05)" = "#FFB300",
"Below Avg (0.85-0.95)" = "#FF6F00",
"Poor (<0.85)" = "#D50000"
)) +
labs(
title = sprintf("Shot Quality Distribution: %s", player_id),
x = "Shot Quality Category",
y = "Percentage of Shots"
) +
theme_minimal() +
theme(axis.text.x = element_text(angle = 45, hjust = 1))
return(p1)
}
plot_player_comparison <- function(analyzer, player_ids) {
comparison <- analyzer$compare_shot_selection(player_ids)
# Shot quality comparison
p1 <- ggplot(comparison, aes(x = reorder(player, avg_xpts), y = avg_xpts)) +
geom_col(aes(fill = avg_xpts)) +
geom_hline(yintercept = 1.0, linetype = "dashed", color = "red") +
geom_text(aes(label = sprintf("%.2f", avg_xpts)), hjust = -0.2) +
scale_fill_gradient2(
low = "#D50000",
mid = "#FFB300",
high = "#00C853",
midpoint = 1.0,
name = "Avg xPTS"
) +
coord_flip() +
labs(
title = "Average Expected Points Per Shot",
x = "Player",
y = "xPTS"
) +
theme_minimal()
# Shot selection breakdown
p2 <- comparison %>%
select(player, rim_attempt_pct, three_attempt_pct, mid_range_pct) %>%
pivot_longer(-player, names_to = "shot_type", values_to = "percentage") %>%
ggplot(aes(x = player, y = percentage, fill = shot_type)) +
geom_col(position = "stack") +
scale_fill_manual(
values = c(
"rim_attempt_pct" = "#00C853",
"three_attempt_pct" = "#2979FF",
"mid_range_pct" = "#FF6F00"
),
labels = c("Rim Attempts", "Three-Pointers", "Mid-Range"),
name = "Shot Type"
) +
coord_flip() +
labs(
title = "Shot Selection Breakdown",
x = "Player",
y = "Percentage of Shots"
) +
theme_minimal()
grid.arrange(p1, p2, ncol = 2)
}
# Example usage
if (FALSE) {
# Load shot data
shots <- read_csv("shot_data.csv")
# Initialize analyzer
analyzer <- ExpectedPointsAnalyzer$new(shots)
# Calculate zone xPTS
zone_stats <- analyzer$calculate_zone_xpts()
print(zone_stats)
# Build predictive model
model_summary <- analyzer$build_xpts_model()
print(model_summary)
# Analyze player shot selection
player_analysis <- analyzer$analyze_player_shot_selection("LeBron James")
print(player_analysis$metrics)
# Compare multiple players
comparison <- analyzer$compare_shot_selection(
c("LeBron James", "Stephen Curry", "Kevin Durant")
)
print(comparison)
# Visualizations
heatmap <- plot_xpts_heatmap(shots, analyzer$xpts_model)
print(heatmap)
quality_plot <- plot_shot_quality_distribution(analyzer, "Stephen Curry")
print(quality_plot)
comparison_plot <- plot_player_comparison(
analyzer,
c("LeBron James", "Stephen Curry", "Kevin Durant", "Giannis Antetokounmpo")
)
}
Time-Based xPTS Analysis
library(lubridate)
library(zoo)
analyze_xpts_trends <- function(shot_data) {
# Analyze how xPTS changes over time
shot_data <- shot_data %>%
mutate(
game_date = as.Date(game_date),
season = substr(season_id, 2, 5)
)
# Season-level trends
season_trends <- shot_data %>%
group_by(season) %>%
summarise(
total_shots = n(),
avg_xpts = mean(xpts),
elite_shot_pct = mean(xpts >= 1.15) * 100,
three_point_rate = mean(shot_type == "3PT") * 100,
rim_attempt_rate = mean(zone == "restricted_area") * 100,
mid_range_rate = mean(zone %in% c("short_mid", "long_mid")) * 100,
.groups = 'drop'
)
# Rolling average xPTS by date
daily_xpts <- shot_data %>%
group_by(game_date) %>%
summarise(avg_xpts = mean(xpts), .groups = 'drop') %>%
arrange(game_date) %>%
mutate(
xpts_ma_10 = rollmean(avg_xpts, k = 10, fill = NA, align = "right"),
xpts_ma_30 = rollmean(avg_xpts, k = 30, fill = NA, align = "right")
)
# Plot trends
p1 <- ggplot(season_trends, aes(x = season, y = avg_xpts, group = 1)) +
geom_line(size = 1.2, color = "#2979FF") +
geom_point(size = 3, color = "#2979FF") +
geom_hline(yintercept = 1.0, linetype = "dashed", color = "red") +
labs(
title = "League Average xPTS Trends",
subtitle = "Evolution of shot quality over seasons",
x = "Season",
y = "Average xPTS"
) +
theme_minimal()
p2 <- ggplot(season_trends, aes(x = season, group = 1)) +
geom_line(aes(y = three_point_rate, color = "3PT Rate"), size = 1) +
geom_line(aes(y = rim_attempt_rate, color = "Rim Rate"), size = 1) +
geom_line(aes(y = mid_range_rate, color = "Mid-Range Rate"), size = 1) +
scale_color_manual(
values = c("3PT Rate" = "#2979FF", "Rim Rate" = "#00C853", "Mid-Range Rate" = "#FF6F00"),
name = "Shot Type"
) +
labs(
title = "Shot Selection Evolution",
x = "Season",
y = "Percentage of Shots"
) +
theme_minimal()
return(list(
season_trends = season_trends,
daily_xpts = daily_xpts,
plots = list(p1 = p1, p2 = p2)
))
}
Player Shot Selection Analysis
Evaluating Decision-Making Quality
Expected points analysis reveals crucial insights about player shot selection:
1. Shot Quality Profiles
| Player Type | Avg xPTS | Elite Shot % | Poor Shot % | Characteristics |
|---|---|---|---|---|
| Elite Rim Finisher | 1.20-1.30 | 65-75% | <10% | Prioritizes high-percentage looks at rim |
| 3PT Specialist | 1.15-1.25 | 70-85% | <5% | Mostly catch-and-shoot threes, few mid-range |
| Balanced Scorer | 1.05-1.15 | 45-60% | 15-25% | Mix of rim, three, and mid-range attempts |
| ISO Heavy | 0.95-1.05 | 30-45% | 25-40% | Creates own shots, many contested attempts |
| Mid-Range Dependent | 0.85-0.95 | <30% | 40-60% | High volume of inefficient mid-range shots |
2. Shot Creation vs. Shot Quality Trade-off
Players face a fundamental trade-off between creating their own shots and taking high-quality attempts:
- High Creation, Lower Quality: ISO players average 0.95-1.05 xPTS but create offense when needed
- Low Creation, Higher Quality: Spot-up shooters average 1.15-1.20 xPTS but need teammates to create
- Elite Creators: Rare players who maintain 1.10+ xPTS despite high creation burden
3. Points Above Expected (PAE)
Comparing actual scoring to expected points reveals shooting performance:
- Elite Shooters: +0.10 to +0.20 points per shot above expected
- Average Shooters: -0.03 to +0.05 points per shot
- Poor Shooters: -0.10 to -0.20 points per shot below expected
4. Context-Specific Performance
| Situation | xPTS Impact | Strategic Implication |
|---|---|---|
| Wide Open | +0.25 to +0.35 | Maximize off-ball movement to create space |
| Early Clock | +0.08 to +0.12 | Push pace in transition for quality looks |
| Late Clock | -0.15 to -0.20 | Avoid stagnant offense, move ball early |
| Catch-and-Shoot | +0.12 to +0.18 | Create kickout opportunities from drives |
| Heavy ISO | -0.08 to -0.12 | Use sparingly in critical moments |
5. Shot Selection Optimization Strategies
- Maximize Rim Attempts: Target 35-45% of shots at rim (1.25+ xPTS)
- Increase Three-Point Rate: 35-45% of shots from three (1.05-1.15 xPTS)
- Minimize Mid-Range: Reduce to <20% of attempts (0.80-0.85 xPTS)
- Optimize Shot Clock Usage: Take shots before 7-second mark when possible
- Create Space: Prioritize plays that generate 4+ feet of defender distance
Strategic Implications
Team Offensive Design
Expected points analysis fundamentally reshapes offensive strategy:
1. Shot Profile Optimization
Modern NBA offenses target a bimodal shot distribution:
- Rim Attempts: 40-45% of shots (1.25-1.35 xPTS)
- Three-Pointers: 40-45% of shots (1.05-1.15 xPTS)
- Mid-Range: 10-20% of shots (0.80-0.85 xPTS)
Expected Impact: Moving from league-average (2015) to optimized (2024) distribution increases expected offense from 1.00 to 1.12 PPP
2. Spacing and Geometry
Court spacing directly impacts xPTS through defender distance:
- Five-Out Spacing: Maximizes driving lanes, +0.10-0.15 xPTS on rim attempts
- Corner Three Placement: Shortest three-point distance, highest xPTS (1.15-1.20)
- Paint Clearing: Reduces help defense, increases rim conversion by 8-12%
- Weak-Side Action: Creates drive-and-kick opportunities with +0.15-0.25 xPTS
3. Play Type Efficiency
| Play Type | Avg xPTS | Usage % | Strategic Value |
|---|---|---|---|
| Transition | 1.25-1.35 | 12-18% | Push pace to maximize transition opportunities |
| Spot-Up | 1.10-1.18 | 20-25% | Create drive-and-kick sequences |
| Cut | 1.20-1.30 | 8-12% | Punish overhelping defenses |
| Pick-and-Roll | 1.00-1.10 | 25-30% | Primary half-court initiator |
| Isolation | 0.95-1.05 | 8-12% | End-of-clock and matchup exploitation |
| Post-Up | 0.90-1.00 | 5-8% | Specific matchup advantages only |
4. Personnel Deployment
Optimize lineup construction based on xPTS generation:
- Rim Pressure: 1-2 players who attack rim at 1.25+ xPTS
- Floor Spacing: 3-4 players shooting 37%+ from three (1.10+ xPTS)
- Shot Creation: 2-3 players who can create above 1.05 xPTS
- Movement Shooting: Multiple players effective off-ball (1.15+ xPTS)
5. Defensive Strategy
Use xPTS to prioritize defensive coverage:
- Rim Protection: Highest priority (1.30+ xPTS if uncontested)
- Corner Three Defense: Close out aggressively (1.15-1.20 xPTS)
- Above-the-Break Threes: Contest but don't overcommit (1.05-1.10 xPTS)
- Mid-Range: Give up long twos willingly (0.80-0.85 xPTS)
- Late Clock Defense: Force difficult attempts below 0.90 xPTS
6. Game Management
Expected points informs situational decision-making:
- End-of-Quarter Possessions: Target 1.15+ xPTS or hold for next period
- Late-Game Scenarios: Know required xPTS threshold based on time/score
- Bonus Situations: Drive for fouls (expected value includes FT attempts)
- Timeout Usage: Call timeout if possession trending toward <0.90 xPTS
7. Player Development
Focus development on skills that maximize xPTS:
- Rim Finishing: Convert 1.25 xPTS opportunities at high rate
- Corner Three Shooting: Elite 1.20 xPTS skill
- Decision-Making: Select shots above 1.05 xPTS threshold
- Off-Ball Movement: Create high-xPTS catch-and-shoot looks
- Driving: Generate rim attempts and three-point kickouts
Analytics-Driven Decision Framework
Teams use xPTS thresholds to guide real-time decisions:
- Possession Evaluation: Is current action trending toward 1.05+ xPTS shot?
- Shot Decision: Does this attempt meet minimum xPTS threshold?
- Play Call: Which action historically generates highest xPTS?
- Lineup Selection: Which five-man unit maximizes xPTS generation?
- Opponent Analysis: Which xPTS categories do they struggle defending?
Practical Applications
Front Office Applications
- Player Evaluation: Assess shot quality independent of shooting performance
- Trade Analysis: Project how player's shot profile fits team system
- Contract Valuation: Pay premium for players generating 1.15+ xPTS
- Draft Scouting: Identify prospects with good shot selection habits
Coaching Applications
- Play Design: Create actions targeting high-xPTS zones
- Player Feedback: Show objective data on shot selection quality
- Opponent Scouting: Identify defensive weaknesses by xPTS allowed
- Lineup Optimization: Maximize floor spacing and shot quality
Player Applications
- Shot Selection: Develop feel for high-value attempts
- Skill Development: Focus on shots with highest xPTS impact
- Role Understanding: Accept role based on xPTS generation ability
- Decision-Making: Pass up good shots for great shots (xPTS difference)
Broadcast and Media Applications
- Real-Time Analysis: Contextualize shot difficulty and value
- Performance Evaluation: Separate luck from skill in shooting
- Strategic Discussion: Explain modern offensive principles
- Historical Comparison: Adjust for era differences in shot selection
Limitations and Considerations
Model Limitations
- Sample Size: Small samples lead to unstable xPTS estimates
- Context Availability: Not all contextual factors tracked in every dataset
- Player Ability: League-average xPTS may not reflect individual skill
- Game State: Score and time effects not fully captured
- Defense Quality: Individual defender ability not in basic models
Interpretation Cautions
- Volume vs. Efficiency: Lower-usage players often show higher xPTS
- Role Differences: Shot creators expected to have lower xPTS
- Team Context: xPTS influenced by teammate spacing and passing
- Possession Value: One low-xPTS shot better than turnover
Advanced Considerations
- Regression to Mean: xPTS is mean expectation, not guarantee
- Clutch Context: Late-game possessions have different optimal xPTS
- Defensive Tradeoffs: Forcing low-xPTS shots while giving up offensive rebounds
- Foul Drawing: xPTS doesn't capture and-one or shooting foul value
Summary
Expected Points per Shot (xPTS) is a foundational metric in modern basketball analytics, quantifying shot quality through the lens of location, context, and historical success rates. By moving beyond simple shooting percentages, xPTS enables sophisticated analysis of player decision-making, team offensive design, and strategic optimization.
Key Takeaways:
- Elite shots (1.15+ xPTS) come at the rim and from corner threes
- Modern offenses target bimodal distribution: rim attempts and three-pointers
- Context matters: defender distance, shot clock, and play type significantly impact value
- Shot selection quality often matters more than shooting ability
- Expected points framework guides offensive design, player development, and game strategy
As tracking data becomes more sophisticated and models incorporate deeper context, xPTS analysis will continue evolving, providing ever-more-precise evaluation of shot value and strategic decision-making in basketball.