Expected Points Per Shot

Beginner 10 min read 0 views Nov 27, 2025

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:

  1. Possession Evaluation: Is current action trending toward 1.05+ xPTS shot?
  2. Shot Decision: Does this attempt meet minimum xPTS threshold?
  3. Play Call: Which action historically generates highest xPTS?
  4. Lineup Selection: Which five-man unit maximizes xPTS generation?
  5. 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.

Discussion

Have questions or feedback? Join our community discussion on Discord or GitHub Discussions.