The Lineup Revolution
Every NBA team plays multiple lineups during games, and lineup composition dramatically affects performance. The same players can produce very different results in different combinations. Lineup analysis examines which combinations work, why they work, and how to optimize playing time distribution to maximize wins.
Lineup Plus-Minus
The basic measure of lineup effectiveness is plus-minus: points scored minus points allowed during a lineup's minutes together. However, raw plus-minus carries substantial noise—small samples, opponent variation, and random shooting fluctuation can produce misleading results. Analysts apply various adjustments to extract signal from lineup data.
def analyze_lineup_performance(lineup_data, min_minutes=50):
"""Analyze lineup effectiveness with minimum sample filter"""
lineup_summary = lineup_data.groupby('LINEUP').agg({
'PLUS_MINUS': 'sum',
'MINUTES': 'sum',
'POSS': 'sum',
'PTS': 'sum',
'OPP_PTS': 'sum'
})
# Filter to minimum minutes
lineup_summary = lineup_summary[lineup_summary['MINUTES'] >= min_minutes]
# Calculate per-100 rates
lineup_summary['NET_RTG'] = (
(lineup_summary['PTS'] - lineup_summary['OPP_PTS']) /
lineup_summary['POSS'] * 100
)
return lineup_summary.sort_values('NET_RTG', ascending=False)
Small Sample Challenges
Lineup analysis faces severe small sample problems. Most lineups play only a few hundred possessions per season—far too few for reliable statistical inference. Even starting lineups rarely exceed 1000 possessions. This limitation means lineup data is suggestive rather than definitive, requiring Bayesian thinking that incorporates prior expectations about player quality.
Lineup Synergies and Conflicts
Some player combinations produce results better than individual contributions would predict; others underperform expectations. Identifying these synergies and conflicts helps optimize rotations. Players might synergize through complementary skills (shooter + playmaker), spacing (multiple threats create driving lanes), or defensive compatibility (switching versatility).
Rotation Optimization
Given lineup performance data and player constraints (minutes limits, rest requirements, matchup considerations), rotation optimization seeks the playing time distribution that maximizes expected wins. This is a complex optimization problem that teams increasingly address through analytics. The goal is getting the best lineups on court for the most minutes without exceeding individual player limits.
Implementation in R
# Lineup performance analysis
library(tidyverse)
analyze_lineups <- function(lineup_data) {
lineup_data %>%
group_by(lineup_id, players) %>%
summarise(
minutes = sum(minutes),
possessions = sum(possessions),
plus_minus = sum(plus_minus),
pts_scored = sum(pts),
pts_allowed = sum(opp_pts),
.groups = "drop"
) %>%
mutate(
net_rtg = round((pts_scored - pts_allowed) / possessions * 100, 1),
off_rtg = round(pts_scored / possessions * 100, 1),
def_rtg = round(pts_allowed / possessions * 100, 1)
) %>%
filter(minutes >= 50) %>%
arrange(desc(net_rtg))
}
lineups <- read_csv("lineup_data.csv")
lineup_analysis <- analyze_lineups(lineups)
# Best lineups
best_lineups <- lineup_analysis %>%
select(players, minutes, net_rtg, off_rtg, def_rtg) %>%
head(15)
print(best_lineups)
# Lineup optimization model
library(tidyverse)
library(lpSolve)
optimize_lineup <- function(player_stats, max_minutes = 48 * 5) {
# Simple optimization: maximize net rating weighted by minutes
n_players <- nrow(player_stats)
# Objective: maximize net rating contribution
objective <- player_stats$net_rtg * player_stats$avg_minutes
# Constraint: total minutes <= max_minutes
constraint_matrix <- matrix(player_stats$avg_minutes, nrow = 1)
constraint_dir <- "<="
constraint_rhs <- max_minutes
# Solve
solution <- lp("max", objective, constraint_matrix,
constraint_dir, constraint_rhs, binary.vec = 1:n_players)
player_stats[solution$solution == 1, ]
}
players <- read_csv("player_net_ratings.csv")
optimal <- optimize_lineup(players)
print(optimal)
Implementation in R
# Lineup performance analysis
library(tidyverse)
analyze_lineups <- function(lineup_data) {
lineup_data %>%
group_by(lineup_id, players) %>%
summarise(
minutes = sum(minutes),
possessions = sum(possessions),
plus_minus = sum(plus_minus),
pts_scored = sum(pts),
pts_allowed = sum(opp_pts),
.groups = "drop"
) %>%
mutate(
net_rtg = round((pts_scored - pts_allowed) / possessions * 100, 1),
off_rtg = round(pts_scored / possessions * 100, 1),
def_rtg = round(pts_allowed / possessions * 100, 1)
) %>%
filter(minutes >= 50) %>%
arrange(desc(net_rtg))
}
lineups <- read_csv("lineup_data.csv")
lineup_analysis <- analyze_lineups(lineups)
# Best lineups
best_lineups <- lineup_analysis %>%
select(players, minutes, net_rtg, off_rtg, def_rtg) %>%
head(15)
print(best_lineups)
# Lineup optimization model
library(tidyverse)
library(lpSolve)
optimize_lineup <- function(player_stats, max_minutes = 48 * 5) {
# Simple optimization: maximize net rating weighted by minutes
n_players <- nrow(player_stats)
# Objective: maximize net rating contribution
objective <- player_stats$net_rtg * player_stats$avg_minutes
# Constraint: total minutes <= max_minutes
constraint_matrix <- matrix(player_stats$avg_minutes, nrow = 1)
constraint_dir <- "<="
constraint_rhs <- max_minutes
# Solve
solution <- lp("max", objective, constraint_matrix,
constraint_dir, constraint_rhs, binary.vec = 1:n_players)
player_stats[solution$solution == 1, ]
}
players <- read_csv("player_net_ratings.csv")
optimal <- optimize_lineup(players)
print(optimal)