Heat Maps and Shot Zones

Beginner 10 min read 0 views Nov 27, 2025

Heat Maps: Density-Based Shooting Visualizations

Heat maps transform discrete shooting data into continuous density visualizations, revealing the underlying spatial patterns of shot selection and efficiency. Unlike shot charts that plot individual attempts, heat maps use kernel density estimation to create smooth probability surfaces that highlight shooting tendencies and hot zones.

Heat Maps vs Shot Charts

Visualization Approaches

Aspect Shot Charts Heat Maps
Data Representation Discrete points for each shot Continuous density surface
Visualization Method Individual markers with color coding Color gradient showing density
Best For Examining individual shots, outliers Identifying patterns, trends, zones
Data Density Can become cluttered with many shots Handles large datasets elegantly
Statistical Method Direct plotting Kernel density estimation
Interpretability Exact shot locations visible Overall tendencies and preferences

Kernel Density Estimation Basics

Kernel density estimation (KDE) is the statistical technique that powers heat maps. It estimates the probability density function of shot locations by placing a "kernel" (typically Gaussian) at each shot location and summing the contributions.

How KDE Works

  1. Kernel Function: A smooth, symmetric function centered at each data point
  2. Bandwidth: Controls the width of each kernel - smaller values show more detail, larger values create smoother surfaces
  3. Summation: All kernel contributions are summed to create the density estimate
  4. Normalization: The result is scaled so the total probability integrates to 1

KDE Formula

f̂(x) = (1/n) Σ Kh(x - xi)

Where:

  • f̂(x) = estimated density at location x
  • n = number of shots
  • Kh = kernel function with bandwidth h
  • xi = location of shot i

Bandwidth Selection

Choosing the right bandwidth is critical:

  • Too Small: Creates noisy, over-fitted heat maps with spurious detail
  • Too Large: Over-smooths the data, hiding important patterns
  • Optimal Methods: Scott's rule, Silverman's rule, or cross-validation
  • Basketball Context: Consider court dimensions and typical shooting zones

Python Implementation: NBA Heat Maps

Using the nba_api library with matplotlib and seaborn to create professional heat maps:

Complete Heat Map Analysis


import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from matplotlib.patches import Circle, Rectangle, Arc
from nba_api.stats.endpoints import shotchartdetail
from nba_api.stats.static import players, teams
from scipy.stats import gaussian_kde

def draw_court(ax=None, color='black', lw=2):
    """Draw NBA basketball court"""
    if ax is None:
        ax = plt.gca()

    # Basketball hoop
    hoop = Circle((0, 0), radius=7.5, linewidth=lw, color=color, fill=False)

    # Backboard
    backboard = Rectangle((-30, -7.5), 60, -1, linewidth=lw, color=color)

    # Paint/Lane
    outer_box = Rectangle((-80, -47.5), 160, 190, linewidth=lw, color=color, fill=False)
    inner_box = Rectangle((-60, -47.5), 120, 190, linewidth=lw, color=color, fill=False)

    # Free throw top arc
    top_free_throw = Arc((0, 142.5), 120, 120, theta1=0, theta2=180,
                         linewidth=lw, color=color, fill=False)
    bottom_free_throw = Arc((0, 142.5), 120, 120, theta1=180, theta2=0,
                            linewidth=lw, color=color, linestyle='dashed')

    # Restricted zone
    restricted = Arc((0, 0), 80, 80, theta1=0, theta2=180,
                     linewidth=lw, color=color)

    # Three point line
    corner_three_a = Rectangle((-220, -47.5), 0, 140, linewidth=lw, color=color)
    corner_three_b = Rectangle((220, -47.5), 0, 140, linewidth=lw, color=color)
    three_arc = Arc((0, 0), 475, 475, theta1=22, theta2=158,
                    linewidth=lw, color=color)

    # Center court
    center_outer_arc = Arc((0, 422.5), 120, 120, theta1=180, theta2=0,
                          linewidth=lw, color=color)

    # Add all elements to plot
    court_elements = [hoop, backboard, outer_box, inner_box,
                     top_free_throw, bottom_free_throw, restricted,
                     corner_three_a, corner_three_b, three_arc,
                     center_outer_arc]

    for element in court_elements:
        ax.add_patch(element)

    return ax

def get_shot_data(player_name, season='2023-24'):
    """Fetch shot data for a player"""
    # Get player ID
    player_dict = players.get_players()
    player = [p for p in player_dict if p['full_name'].lower() == player_name.lower()][0]
    player_id = player['id']

    # Get shot chart data
    shot_chart = shotchartdetail.ShotChartDetail(
        team_id=0,
        player_id=player_id,
        season_nullable=season,
        season_type_all_star='Regular Season',
        context_measure_simple='FGA'
    )

    # Convert to DataFrame
    shots_df = shot_chart.get_data_frames()[0]

    return shots_df

def create_basic_heat_map(shots_df, player_name, title_suffix=''):
    """Create a basic heat map using hexbin"""
    fig, ax = plt.subplots(figsize=(12, 11))

    # Draw court
    draw_court(ax, color='gray', lw=1)

    # Create hexbin heat map
    hexbin = ax.hexbin(shots_df['LOC_X'], shots_df['LOC_Y'],
                       gridsize=25, cmap='YlOrRd',
                       extent=(-250, 250, -47.5, 422.5),
                       alpha=0.8, edgecolors='white', linewidths=0.5)

    # Add colorbar
    cbar = plt.colorbar(hexbin, ax=ax)
    cbar.set_label('Shot Frequency', rotation=270, labelpad=20, fontsize=12)

    # Format plot
    ax.set_xlim(-250, 250)
    ax.set_ylim(-47.5, 422.5)
    ax.set_aspect('equal')
    ax.axis('off')

    plt.title(f'{player_name} Shot Heat Map{title_suffix}',
              fontsize=16, fontweight='bold', pad=20)
    plt.tight_layout()

    return fig, ax

def create_kde_heat_map(shots_df, player_name, bandwidth=15):
    """Create smooth heat map using KDE"""
    fig, ax = plt.subplots(figsize=(12, 11))

    # Draw court
    draw_court(ax, color='white', lw=1.5)

    # Prepare data
    x = shots_df['LOC_X'].values
    y = shots_df['LOC_Y'].values

    # Create grid for KDE
    xx, yy = np.mgrid[-250:250:100j, -47.5:422.5:100j]
    positions = np.vstack([xx.ravel(), yy.ravel()])

    # Calculate KDE
    values = np.vstack([x, y])
    kernel = gaussian_kde(values, bw_method=bandwidth/np.std(values))
    density = np.reshape(kernel(positions).T, xx.shape)

    # Plot heat map
    contourf = ax.contourf(xx, yy, density, levels=20, cmap='hot', alpha=0.7)

    # Add colorbar
    cbar = plt.colorbar(contourf, ax=ax)
    cbar.set_label('Shot Density', rotation=270, labelpad=20, fontsize=12)

    # Format plot
    ax.set_xlim(-250, 250)
    ax.set_ylim(-47.5, 422.5)
    ax.set_aspect('equal')
    ax.axis('off')
    ax.set_facecolor('#f0f0f0')
    fig.patch.set_facecolor('#f0f0f0')

    plt.title(f'{player_name} Shot Density (KDE)',
              fontsize=16, fontweight='bold', pad=20)
    plt.tight_layout()

    return fig, ax

def create_efficiency_heat_map(shots_df, player_name):
    """Create heat map showing shooting efficiency by zone"""
    fig, ax = plt.subplots(figsize=(12, 11))

    # Draw court
    draw_court(ax, color='black', lw=1.5)

    # Create grid for binning
    x_bins = np.linspace(-250, 250, 30)
    y_bins = np.linspace(-47.5, 422.5, 30)

    # Calculate shooting percentage in each bin
    shot_made = shots_df['SHOT_MADE_FLAG'].values
    x = shots_df['LOC_X'].values
    y = shots_df['LOC_Y'].values

    # Create 2D histogram for makes and attempts
    makes, _, _ = np.histogram2d(x[shot_made == 1], y[shot_made == 1],
                                bins=[x_bins, y_bins])
    attempts, _, _ = np.histogram2d(x, y, bins=[x_bins, y_bins])

    # Calculate shooting percentage (avoid division by zero)
    with np.errstate(divide='ignore', invalid='ignore'):
        efficiency = np.where(attempts >= 5, makes / attempts, np.nan)

    # Plot efficiency heat map
    im = ax.imshow(efficiency.T, origin='lower', cmap='RdYlGn',
                   extent=[-250, 250, -47.5, 422.5],
                   alpha=0.8, vmin=0, vmax=1, aspect='auto')

    # Add colorbar
    cbar = plt.colorbar(im, ax=ax)
    cbar.set_label('FG%', rotation=270, labelpad=20, fontsize=12)
    cbar.set_ticks([0, 0.25, 0.5, 0.75, 1.0])
    cbar.set_ticklabels(['0%', '25%', '50%', '75%', '100%'])

    # Format plot
    ax.set_xlim(-250, 250)
    ax.set_ylim(-47.5, 422.5)
    ax.set_aspect('equal')
    ax.axis('off')

    total_shots = len(shots_df)
    fg_pct = shots_df['SHOT_MADE_FLAG'].mean() * 100

    plt.title(f'{player_name} Shooting Efficiency Heat Map\n' +
              f'{total_shots} FGA | {fg_pct:.1f}% FG',
              fontsize=16, fontweight='bold', pad=20)
    plt.tight_layout()

    return fig, ax

def compare_players_heat_map(player_names, season='2023-24'):
    """Create side-by-side heat maps for player comparison"""
    n_players = len(player_names)
    fig, axes = plt.subplots(1, n_players, figsize=(12 * n_players, 11))

    if n_players == 1:
        axes = [axes]

    for idx, player_name in enumerate(player_names):
        ax = axes[idx]

        # Get data
        shots_df = get_shot_data(player_name, season)

        # Draw court
        draw_court(ax, color='gray', lw=1)

        # Create heat map
        hexbin = ax.hexbin(shots_df['LOC_X'], shots_df['LOC_Y'],
                          gridsize=25, cmap='YlOrRd',
                          extent=(-250, 250, -47.5, 422.5),
                          alpha=0.8, edgecolors='white', linewidths=0.5)

        # Format
        ax.set_xlim(-250, 250)
        ax.set_ylim(-47.5, 422.5)
        ax.set_aspect('equal')
        ax.axis('off')

        ax.set_title(f'{player_name}\n{len(shots_df)} Shots',
                    fontsize=14, fontweight='bold')

        # Add colorbar to last subplot
        if idx == n_players - 1:
            cbar = plt.colorbar(hexbin, ax=ax)
            cbar.set_label('Shot Frequency', rotation=270, labelpad=20)

    plt.suptitle(f'{season} Shot Distribution Comparison',
                fontsize=18, fontweight='bold', y=0.98)
    plt.tight_layout()

    return fig, axes

# Example usage
if __name__ == '__main__':
    # Single player analysis
    player = 'Stephen Curry'
    shots = get_shot_data(player, '2023-24')

    # Create different heat map types
    fig1, ax1 = create_basic_heat_map(shots, player)
    plt.savefig(f'{player.replace(" ", "_")}_basic_heatmap.png', dpi=300, bbox_inches='tight')

    fig2, ax2 = create_kde_heat_map(shots, player, bandwidth=20)
    plt.savefig(f'{player.replace(" ", "_")}_kde_heatmap.png', dpi=300, bbox_inches='tight')

    fig3, ax3 = create_efficiency_heat_map(shots, player)
    plt.savefig(f'{player.replace(" ", "_")}_efficiency_heatmap.png', dpi=300, bbox_inches='tight')

    # Player comparison
    players_to_compare = ['Stephen Curry', 'Damian Lillard', 'Trae Young']
    fig4, axes4 = compare_players_heat_map(players_to_compare, '2023-24')
    plt.savefig('player_comparison_heatmap.png', dpi=300, bbox_inches='tight')

    print(f"Generated heat maps for {player}")
    print(f"\nShot Summary:")
    print(f"Total Shots: {len(shots)}")
    print(f"FG%: {shots['SHOT_MADE_FLAG'].mean() * 100:.1f}%")
    print(f"3P Attempts: {sum(shots['SHOT_TYPE'] == '3PT Field Goal')}")
    print(f"2P Attempts: {sum(shots['SHOT_TYPE'] == '2PT Field Goal')}")

R Implementation: hoopR and ggplot2

Creating sophisticated heat maps using R's hoopR package and ggplot2's density visualization capabilities:

R Heat Map Workflow


# Load required libraries
library(hoopR)
library(tidyverse)
library(ggplot2)
library(sportyR)
library(viridis)
library(MASS)  # For 2D kernel density

# Function to get NBA shot data
get_nba_shots <- function(player_name, season = 2024) {
  # Get player box scores and shot data
  # Note: hoopR provides play-by-play data which includes shot locations

  pbp <- load_nba_pbp(seasons = season)

  # Filter for specific player's shots
  player_shots <- pbp %>%
    filter(
      str_detect(text, player_name),
      type.text %in% c("Field Goal Made", "Field Goal Missed",
                       "Three Point Field Goal Made",
                       "Three Point Field Goal Missed")
    ) %>%
    filter(!is.na(coordinate_x), !is.na(coordinate_y)) %>%
    mutate(
      made = str_detect(type.text, "Made"),
      shot_value = ifelse(str_detect(type.text, "Three"), 3, 2),
      # Convert coordinates to standard basketball court dimensions
      x = coordinate_x,
      y = coordinate_y
    )

  return(player_shots)
}

# Function to draw NBA court using sportyR
draw_nba_court <- function() {
  geom_basketball("nba", display_range = "offense")
}

# Basic heat map with stat_density_2d
create_basic_heatmap <- function(shots_df, player_name) {
  p <- ggplot(shots_df, aes(x = x, y = y)) +
    draw_nba_court() +
    stat_density_2d(
      aes(fill = after_stat(level)),
      geom = "polygon",
      alpha = 0.7,
      bins = 20
    ) +
    scale_fill_viridis(
      option = "plasma",
      name = "Density"
    ) +
    labs(
      title = paste(player_name, "Shot Heat Map"),
      subtitle = paste("Total Shots:", nrow(shots_df))
    ) +
    theme_void() +
    theme(
      plot.title = element_text(hjust = 0.5, size = 16, face = "bold"),
      plot.subtitle = element_text(hjust = 0.5, size = 12),
      legend.position = "right"
    ) +
    coord_fixed()

  return(p)
}

# Advanced KDE heat map with custom bandwidth
create_kde_heatmap <- function(shots_df, player_name, bandwidth = c(2, 2)) {
  # Calculate 2D kernel density
  kde <- kde2d(
    shots_df$x,
    shots_df$y,
    h = bandwidth,
    n = 100,
    lims = c(range(shots_df$x), range(shots_df$y))
  )

  # Convert to data frame for ggplot
  kde_df <- expand.grid(x = kde$x, y = kde$y) %>%
    mutate(density = as.vector(kde$z))

  p <- ggplot(kde_df, aes(x = x, y = y, fill = density)) +
    draw_nba_court() +
    geom_tile(alpha = 0.8) +
    scale_fill_gradient(
      low = "white",
      high = "red",
      name = "Shot\nDensity"
    ) +
    labs(
      title = paste(player_name, "KDE Heat Map"),
      subtitle = sprintf("Bandwidth: (%.1f, %.1f) | n = %d",
                        bandwidth[1], bandwidth[2], nrow(shots_df))
    ) +
    theme_void() +
    theme(
      plot.title = element_text(hjust = 0.5, size = 16, face = "bold"),
      plot.subtitle = element_text(hjust = 0.5, size = 12),
      legend.position = "right"
    ) +
    coord_fixed()

  return(p)
}

# Shooting efficiency heat map
create_efficiency_heatmap <- function(shots_df, player_name, grid_size = 30) {
  # Create grid and calculate efficiency
  shots_grid <- shots_df %>%
    mutate(
      x_bin = cut(x, breaks = grid_size, labels = FALSE),
      y_bin = cut(y, breaks = grid_size, labels = FALSE)
    ) %>%
    group_by(x_bin, y_bin) %>%
    summarise(
      attempts = n(),
      makes = sum(made),
      fg_pct = makes / attempts,
      x_mid = mean(x),
      y_mid = mean(y),
      .groups = "drop"
    ) %>%
    filter(attempts >= 5)  # Minimum attempts threshold

  p <- ggplot(shots_grid, aes(x = x_mid, y = y_mid)) +
    draw_nba_court() +
    geom_tile(aes(fill = fg_pct), alpha = 0.8) +
    scale_fill_gradient2(
      low = "red",
      mid = "yellow",
      high = "green",
      midpoint = 0.45,
      labels = scales::percent,
      name = "FG%",
      limits = c(0, 1)
    ) +
    labs(
      title = paste(player_name, "Shooting Efficiency Heat Map"),
      subtitle = sprintf("Overall FG%%: %.1f%% | Total: %d FGA",
                        mean(shots_df$made) * 100, nrow(shots_df))
    ) +
    theme_void() +
    theme(
      plot.title = element_text(hjust = 0.5, size = 16, face = "bold"),
      plot.subtitle = element_text(hjust = 0.5, size = 12),
      legend.position = "right"
    ) +
    coord_fixed()

  return(p)
}

# Hexagonal binning heat map
create_hexbin_heatmap <- function(shots_df, player_name, bins = 30) {
  p <- ggplot(shots_df, aes(x = x, y = y)) +
    draw_nba_court() +
    geom_hex(bins = bins, alpha = 0.8) +
    scale_fill_viridis(
      option = "inferno",
      name = "Shot\nCount"
    ) +
    labs(
      title = paste(player_name, "Hexbin Shot Heat Map"),
      subtitle = paste("Total Shots:", nrow(shots_df))
    ) +
    theme_void() +
    theme(
      plot.title = element_text(hjust = 0.5, size = 16, face = "bold"),
      plot.subtitle = element_text(hjust = 0.5, size = 12),
      legend.position = "right"
    ) +
    coord_fixed()

  return(p)
}

# Contour plot for shot density
create_contour_heatmap <- function(shots_df, player_name) {
  p <- ggplot(shots_df, aes(x = x, y = y)) +
    draw_nba_court() +
    geom_density_2d_filled(alpha = 0.7, bins = 15) +
    scale_fill_viridis_d(
      option = "turbo",
      name = "Density\nLevel"
    ) +
    labs(
      title = paste(player_name, "Shot Density Contours"),
      subtitle = paste("Total Shots:", nrow(shots_df))
    ) +
    theme_void() +
    theme(
      plot.title = element_text(hjust = 0.5, size = 16, face = "bold"),
      plot.subtitle = element_text(hjust = 0.5, size = 12),
      legend.position = "right"
    ) +
    coord_fixed()

  return(p)
}

# Compare multiple players
compare_players_heatmap <- function(player_names, season = 2024) {
  # Get data for all players
  all_shots <- map_dfr(player_names, ~{
    get_nba_shots(.x, season) %>%
      mutate(player = .x)
  })

  p <- ggplot(all_shots, aes(x = x, y = y)) +
    draw_nba_court() +
    stat_density_2d(
      aes(fill = after_stat(level)),
      geom = "polygon",
      alpha = 0.6
    ) +
    scale_fill_viridis(option = "plasma", name = "Density") +
    facet_wrap(~player, ncol = 3) +
    labs(
      title = "Player Shot Distribution Comparison",
      subtitle = paste("Season:", season)
    ) +
    theme_void() +
    theme(
      plot.title = element_text(hjust = 0.5, size = 16, face = "bold"),
      plot.subtitle = element_text(hjust = 0.5, size = 12),
      strip.text = element_text(size = 12, face = "bold"),
      legend.position = "bottom"
    ) +
    coord_fixed()

  return(p)
}

# Shot type breakdown heat map
create_shot_type_heatmap <- function(shots_df, player_name) {
  p <- ggplot(shots_df, aes(x = x, y = y)) +
    draw_nba_court() +
    geom_density_2d_filled(alpha = 0.7, bins = 12) +
    scale_fill_viridis_d(option = "mako", name = "Density") +
    facet_wrap(~shot_value, labeller = labeller(
      shot_value = c("2" = "2-Point Shots", "3" = "3-Point Shots")
    )) +
    labs(
      title = paste(player_name, "Shot Type Distribution"),
      subtitle = sprintf("2PT: %d | 3PT: %d",
                        sum(shots_df$shot_value == 2),
                        sum(shots_df$shot_value == 3))
    ) +
    theme_void() +
    theme(
      plot.title = element_text(hjust = 0.5, size = 16, face = "bold"),
      plot.subtitle = element_text(hjust = 0.5, size = 12),
      strip.text = element_text(size = 12, face = "bold"),
      legend.position = "right"
    ) +
    coord_fixed()

  return(p)
}

# Example usage
if (interactive()) {
  # Get shot data
  player <- "Stephen Curry"
  shots <- get_nba_shots(player, season = 2024)

  # Create various heat maps
  p1 <- create_basic_heatmap(shots, player)
  ggsave(paste0(gsub(" ", "_", player), "_basic_heatmap.png"),
         p1, width = 10, height = 10, dpi = 300)

  p2 <- create_kde_heatmap(shots, player, bandwidth = c(3, 3))
  ggsave(paste0(gsub(" ", "_", player), "_kde_heatmap.png"),
         p2, width = 10, height = 10, dpi = 300)

  p3 <- create_efficiency_heatmap(shots, player)
  ggsave(paste0(gsub(" ", "_", player), "_efficiency_heatmap.png"),
         p3, width = 10, height = 10, dpi = 300)

  p4 <- create_hexbin_heatmap(shots, player, bins = 25)
  ggsave(paste0(gsub(" ", "_", player), "_hexbin_heatmap.png"),
         p4, width = 10, height = 10, dpi = 300)

  p5 <- create_contour_heatmap(shots, player)
  ggsave(paste0(gsub(" ", "_", player), "_contour_heatmap.png"),
         p5, width = 10, height = 10, dpi = 300)

  # Compare players
  players_to_compare <- c("Stephen Curry", "Damian Lillard", "Trae Young")
  p6 <- compare_players_heatmap(players_to_compare, season = 2024)
  ggsave("player_comparison_heatmap.png", p6, width = 15, height = 10, dpi = 300)

  # Shot type breakdown
  p7 <- create_shot_type_heatmap(shots, player)
  ggsave(paste0(gsub(" ", "_", player), "_shot_type_heatmap.png"),
         p7, width = 12, height = 6, dpi = 300)

  # Print summary
  cat(sprintf("\nHeat maps generated for %s\n", player))
  cat(sprintf("Total Shots: %d\n", nrow(shots)))
  cat(sprintf("FG%%: %.1f%%\n", mean(shots$made) * 100))
  cat(sprintf("3PA: %d (%.1f%%)\n",
              sum(shots$shot_value == 3),
              sum(shots$shot_value == 3) / nrow(shots) * 100))
}

Zone-Based vs Continuous Heat Maps

Zone-Based Heat Maps

  • Method: Divide court into predefined zones (paint, midrange, corner 3, etc.)
  • Aggregation: Calculate statistics for each discrete zone
  • Advantages:
    • Matches how coaches discuss shot selection
    • Easier to interpret and communicate
    • Aligns with NBA tracking zones
    • Better for small sample sizes
  • Disadvantages:
    • Arbitrary zone boundaries
    • Loses spatial precision
    • Discontinuities at zone edges
  • Best For: Strategic analysis, coaching decisions, comparing to league averages

Continuous Heat Maps (KDE)

  • Method: Use kernel density estimation to create smooth probability surface
  • Aggregation: Each location influenced by nearby shots based on kernel function
  • Advantages:
    • No arbitrary boundaries
    • Smooth, visually appealing
    • Reveals subtle spatial patterns
    • Better for large datasets
  • Disadvantages:
    • Requires bandwidth selection
    • Can over-smooth or under-smooth
    • Less interpretable numerically
    • Computationally more intensive
  • Best For: Visualizing tendencies, identifying hot spots, exploratory analysis

Hybrid Approach

The most effective analysis often combines both methods:

  1. Continuous heat maps for initial exploration and pattern identification
  2. Zone-based statistics for quantitative analysis and decision-making
  3. Overlay zones on heat maps to combine visual and statistical insights

Player Comparison and Shot Selection Patterns

Common Shooting Profiles Revealed by Heat Maps

Elite Three-Point Specialist

Pattern: High density around three-point arc, especially corners and wings

Example Players: Stephen Curry, Klay Thompson, Duncan Robinson

Heat Map Characteristics:

  • Intense red zones at 23-24 feet from basket
  • Strong corner presence (both sides)
  • Minimal paint activity
  • Above-the-break three concentrated at top and wings

Rim Attacker

Pattern: Concentrated density in restricted area and paint

Example Players: Giannis Antetokounmpo, Zion Williamson, Rudy Gobert

Heat Map Characteristics:

  • Hottest zones within 5 feet of basket
  • Limited perimeter activity
  • Asymmetric patterns based on driving hand
  • Dunker spot concentrations

Midrange Maestro

Pattern: Heat concentrated in 10-20 foot range

Example Players: DeMar DeRozan, Chris Paul, Kawhi Leonard

Heat Map Characteristics:

  • Elbow and free-throw line hot spots
  • Baseline midrange zones
  • Pull-up regions off dribble
  • Post-up areas on both blocks

Well-Rounded Scorer

Pattern: Relatively uniform distribution across multiple zones

Example Players: Kevin Durant, LeBron James, Luka Doncic

Heat Map Characteristics:

  • Multiple distinct hot zones
  • Threat from all three levels
  • Balanced left-right distribution
  • Adaptable shot selection

Comparative Heat Map Analysis

Key Comparison Dimensions

1. Spatial Distribution
  • Concentration: Specialist vs. diverse shot profile
  • Symmetry: Balanced vs. side preference
  • Distance: Rim-to-three vs. midrange comfort
2. Volume Patterns
  • Peak Intensity: Maximum shot frequency zones
  • Coverage Area: Court percentage with significant activity
  • Shot Distribution: 3PA rate, rim attempt rate, midrange usage
3. Efficiency Overlay
  • Hot Zones: High-volume AND high-efficiency areas
  • Cold Zones: Should-avoid areas with poor efficiency
  • Opportunity Zones: High efficiency but underutilized
4. Temporal Evolution
  • Season-to-Season: Shot profile development
  • Career Arc: Adaptation to changing athleticism
  • Situational: Clutch vs. regular time patterns

Statistical Distance Metrics for Comparison

To quantify differences between players' shot distributions:

Kullback-Leibler Divergence

Measures how one probability distribution differs from another:

DKL(P || Q) = Σ P(x) log(P(x) / Q(x))

Lower values indicate more similar shot distributions

Earth Mover's Distance (Wasserstein)

Minimum "work" required to transform one distribution into another:

Accounts for spatial proximity of shot locations

Better for comparing shooting patterns than simple overlap metrics

Practical Applications

1. Player Development

Shot Selection Optimization

  • Identify Inefficient Zones: Areas with high volume but low efficiency
  • Develop Weak Areas: Focus training on underutilized efficient zones
  • Track Progress: Compare heat maps season-over-season
  • Example: Young player taking many long 2s (inefficient) should migrate to rim or three-point line

Individual Workout Design

  • Weight practice shots based on game distribution
  • Simulate game conditions for high-frequency zones
  • Address asymmetries (left vs. right side)

2. Defensive Game Planning

Opponent Scouting

  • Force to Cold Zones: Design defensive schemes to push players to low-efficiency areas
  • Deny Hot Spots: Extra attention to high-volume, high-efficiency zones
  • Side Preference: Overplay strong side if heat map shows asymmetry
  • Pick-and-Roll Coverage: Adjust based on ball-handler's shooting heat map

Example Defensive Adjustment

Against a left-side corner three specialist:

  • Rotate early to left corner
  • Allow more space on right side
  • Close out harder on left wing
  • Trap or blitz when player drifts to left corner

3. Lineup Optimization

Spacing Analysis

  • Perimeter Spacing: Combine players with complementary three-point heat maps
  • Paint Congestion: Avoid too many rim-dependent players together
  • Floor Balance: Ensure coverage across all offensive zones

Role Definition

  • Identify true floor spacers (consistent three-point density)
  • Designate rim runners (high restricted area density)
  • Utilize midrange bailout options in late clock

4. Trade and Free Agency Evaluation

Fit Assessment

  • Offensive Fit: Does player's shot distribution complement existing roster?
  • System Compatibility: Can player thrive in team's typical shot locations?
  • Versatility: Heat map breadth indicates adaptability

Projection and Decline Detection

  • Year-over-year heat map migration from rim to perimeter may indicate declining athleticism
  • Increasing three-point density can signal skill development
  • Shrinking effective zone coverage suggests aging or injury concerns

5. Broadcasting and Analytics

Storytelling with Data

  • Visual Narratives: Heat maps make statistical stories accessible to general audience
  • Player Comparisons: Side-by-side heat maps instantly show stylistic differences
  • Historical Context: Evolution of shooting locations over eras

Real-Time Analysis

  • Live updating heat maps during games
  • Situational heat maps (clutch time, playoff vs. regular season)
  • Matchup-specific patterns (vs. specific defenders or schemes)

6. Quantifying the Three-Point Revolution

League-Wide Trends

  • Density Migration: Heat maps shifting from midrange to three-point arc and rim
  • Corner Three Emphasis: Increasing density in corner three zones
  • Paint Density: Despite more threes, elite teams maintain rim pressure

Era Comparison

Comparing average NBA heat maps across decades:

  • 1990s: Uniform distribution, strong midrange presence
  • 2000s: Beginning of midrange decline, increasing three-point corners
  • 2010s: Accelerated three-point adoption, "barbell" distribution
  • 2020s: Extreme three-and-rim strategy, minimal midrange

Advanced Heat Map Techniques

Contextual Heat Maps

  • Defender Distance: Heat maps filtered by closest defender distance (open vs. contested)
  • Time of Game: First quarter vs. fourth quarter shot distributions
  • Score Differential: Leading vs. trailing shot selection patterns
  • Home vs. Away: Court familiarity impact on shot locations
  • Back-to-Back Games: Fatigue effect on shot distance
  • Post All-Star Break: Seasonal adaptation and fatigue

Differential Heat Maps

Subtract one heat map from another to highlight differences:

  • Player A - Player B: Positive areas show where A shoots more, negative where B shoots more
  • This Season - Last Season: Identify shot profile evolution
  • Actual - Expected: Compare player's shots to position average
  • Team - League Average: Identify system-driven tendencies

Multi-Dimensional Heat Maps

  • 3D Heat Maps: X, Y coordinates plus time or defender distance
  • Animated Heat Maps: Show evolution throughout season or career
  • Interactive Heat Maps: User can filter by various conditions in real-time
  • Layered Heat Maps: Combine frequency, efficiency, and volume in single visualization

Best Practices and Considerations

Data Quality

  • Sample Size: Minimum ~100 shots for meaningful heat maps; 200+ for zone-based efficiency
  • Data Source: NBA tracking data is most accurate; play-by-play has limitations
  • Coordinate Precision: Tracking data provides exact locations vs. approximated locations
  • Missing Data: Handle missing coordinates appropriately, don't drop silently

Visualization Choices

  • Color Scales: Sequential for density/frequency, diverging for efficiency, perceptually uniform (viridis, plasma)
  • Transparency: Allow underlying court to remain visible
  • Resolution: Balance detail vs. noise; higher resolution needs more data
  • Court Boundaries: Respect actual shot-taking areas, don't extend heat into impossible regions

Interpretation Caveats

  • Causation: Heat maps show where shots occur, not why
  • Context Missing: Shot quality, play type, defender identity not captured
  • Team Effects: Individual heat maps influenced by teammates and system
  • Survivor Bias: Only successful positioning sequences result in shots

Resources and Tools

Python Libraries

  • nba_api: Official NBA statistics API wrapper
  • py-Goldsberry: Basketball analytics and visualization
  • matplotlib: Core plotting library
  • seaborn: Statistical visualization with better defaults
  • scipy: KDE and statistical functions
  • plotly: Interactive heat map visualizations

R Packages

  • hoopR: NBA data access and basketball analytics
  • sportyR: Court drawing and sports visualizations
  • ggplot2: Grammar of graphics visualization
  • MASS: kde2d for kernel density estimation
  • viridis: Perceptually uniform color scales

Online Tools

  • NBA.com Shot Charts: Official interactive heat maps
  • Basketball Reference: Historical shooting data
  • Cleaning the Glass: Advanced shot location analytics
  • Positive Residual: Interactive shot visualizations

Key Takeaways

  • Heat maps use kernel density estimation to transform discrete shots into continuous probability surfaces
  • They excel at revealing spatial patterns that are difficult to see in shot charts
  • Both zone-based and continuous approaches have value depending on the analysis goal
  • Player comparisons via heat maps reveal distinct shooting profiles and tendencies
  • Practical applications span player development, defensive strategy, roster construction, and media
  • Proper bandwidth selection and sufficient sample size are critical for meaningful results
  • Heat maps should complement, not replace, other shooting analytics

Discussion

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