Skip to content

cofrian/nba-scouting-analytics

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

NBA Scouting Analytics

Finding a player's cheapest lookalike, and predicting All-Stars from box-score data alone.

R R Markdown Methods License

Coursework for Modelos Descriptivos y Predictivos I — BSc in Data Science, Universitat Politècnica de València (UPV).

An end-to-end multivariate analysis of all 508 NBA players from the 2022–23 regular season, built around two questions a real front office asks:

  1. "We want this player but we can't afford him or can't close the trade. Who plays like him?"PCA + k-means clustering feeding a get_sustitutos() lookup that returns same-role, same-position alternatives.
  2. "Can you spot an All-Star from the numbers alone?"PLS-DA classifier that catches 100% of All-Stars in the held-out test set at 95% specificity — and whose "mistakes" turn out to be an interesting finding rather than a bug.

📄 Full written report (PDF, Spanish) · 📊 Analysis notebooks · 🖼️ All figures


Table of contents


Why this project

Raw NBA box scores punish you twice. First, volume masquerades as skill: a starter logging 36 minutes will out-produce an efficient bench player on every counting stat, so any naive model just re-discovers "who plays a lot". Second, twenty-odd correlated statistics describe what is really a much smaller number of playing styles.

This project fixes the first problem with a minute-adjusted scale, collapses the second with PCA, and then puts the resulting space to work on two concrete decisions — who can replace whom, and who looks like an All-Star.

Key results

Question Method Headline result
What actually distinguishes players? PCA 2 components explain 69.9% of variance — one purely offensive, one purely defensive
Are the extreme players data errors? Hotelling's T² + SCR No — they are Giannis, Embiid, Dončić, Trae Young, Lillard; the model's outliers are the league's stars
Who plays like player X? k-means (k=10) on Manhattan distance get_sustitutos("T.J. McConnell")Davion Mitchell, Devonte' Graham, Malcolm Brogdon
Can box scores identify an All-Star? PLS-DA (3 components) 100% sensitivity / 95% specificity on test; R²Y = 0.68, Q² = 0.66
What makes an All-Star, numerically? VIP scores Scoring volume (puntos_aj, VIP 1.53) and free-throw aggression (tl_intentados_aj, 1.44) dominate

The most interesting result is a failure. The PLS-DA model produces 7 false positives on the test set — players whose statistics say All-Star but who were not selected. All-Star voting is 50% fan vote, so a stats-only model cannot reproduce it by construction. Those "false" positives are arguably the model identifying players who deserved selection on merit but lacked the market. See PLS-DA.

Repository structure

nba-scouting-analytics/
├── analysis/
│   ├── 01_data_cleaning.Rmd     # Dedup, minute-adjusted scaling, outlier policy → 508 players
│   ├── 02_pca.Rmd               # Variable selection, PCA, T² / SCR diagnostics
│   ├── 03_clustering.Rmd        # Manhattan distance, Ward vs k-means vs PAM, get_sustitutos()
│   └── 04_pls_da.Rmd            # Class balancing, PLS-DA, VIP, train/test evaluation
├── data/
│   ├── raw/
│   │   └── nba_2022_2023.csv                    # 679 rows as exported, one per player-team stint
│   └── processed/
│       ├── nba_2022_2023_ajustado_limpio.csv    # 508 players, minute-adjusted (output of 01)
│       ├── nba_pca.csv                          # PCA-ready feature subset (input to 03, 04)
│       └── nba_pca.RData                        # Same, as an R object with PCA scores
├── docs/
│   └── informe_completo_es.pdf  # Full written report with discussion (Spanish)
├── figures/                     # Every plot referenced below
└── README.md

The notebooks are numbered because they form a pipeline: 01 writes the file 02 reads, and 02 writes the file 03 and 04 read.

The data

Per-game statistics for every player in the 2022–23 NBA regular season.

Raw rows 679 (one per player per team — traded players appear multiple times)
Players after cleaning 508
Variables 23 after processing (points, rebounds split offensive/defensive, assists, steals, blocks, turnovers, fouls, shooting volume and accuracy, plus posicion, edad, all_stars)
Class balance All-Stars are ~5% of the league — a severe imbalance that drives the PLS-DA design

Methodology

1. Cleaning and the minute-adjusted scale

Notebook: analysis/01_data_cleaning.Rmd

Traded players. 140 rows were duplicate player names — normal in the NBA, where players change teams mid-season. The source data already provides a TOT row aggregating each traded player's full season, so we keep the TOT row and drop the per-team stints rather than re-aggregating by hand.

The volume problem. Every counting stat is confounded by minutes played. We rescale each statistic to a per-minute rate, then re-weight it by playing time using log1p:

puntos_aj = (puntos_pp / minutos_por_partido) * log1p(minutos_por_partido)

The division removes the volume advantage; the log1p factor gives some credit back to players who sustain production over heavy minutes, without letting minutes dominate again. The net effect: a hyper-efficient player who plays 6 minutes a night no longer outranks a starter, and a starter no longer outranks everyone by default.

Outlier policy. IQR flagged a long list of outliers. Rather than deleting them, we checked who they were — and most were All-Stars with 60+ games. Those are real signal and were kept. The ones we did remove were outliers with fewer than 10 games played, where the per-minute adjustment amplifies noise from a tiny sample. That took 539 → 508 players.

Offensive variables Defensive variables
Scatter matrix, offensive variables Scatter matrix, defensive variables

2. PCA — the offence/defence plane

Notebook: analysis/02_pca.Rmd

Variable selection was the substantive work here, not the PCA itself. Feeding in all 23 variables would have produced a first component that just measured shot volume. So, guided by the correlation matrix:

  • rebotes_aj was dropped — it is a linear combination of ro_aj + rd_aj.
  • partidos_jugados / partidos_titular were held out as supplementary variables (projected but not used to fit). Including them would have made PC1 a starter-vs-bench axis instead of a performance axis.
  • Made-shot variables (tiros_anotados_aj, tiros_2_*, triples_anotados_aj, tl_anotados_aj) were dropped in favour of attempt variables. Attempts describe offensive role; makes just re-encode puntos_aj (r = 0.83).

Correlation matrix of candidate variables

Holding the two out was the right call: projected onto the finished model (in blue below), partidos_jugados and partidos_titular are so dominant they would have bent the whole plane towards playing time.

Active and supplementary variable contributions

Component selection. The elbow rule and the Kaiser/average rule agree on two components, together explaining 69.9% of variance. The third component drops below the 10% threshold.

Scree plot

Interpretation. The two axes split cleanly along basketball's oldest dividing line:

  • PC1 (39.6%) — the offensive axis: puntos_aj, tl_intentados_aj, asistencias_aj, and — pointing the same way — perdidas_aj. Players who create and score also turn the ball over; usage carries risk.
  • PC2 (30.3%) — the defensive axis: ro_aj, rd_aj, tapones_aj, faltas_aj. Players who fight for boards and protect the rim also foul more.
Variable loadings Players, coloured by position
PCA loadings PCA scores by position

The score plot validates the model against something we already know: PG/SG cluster on the offensive axis, C/PF on the defensive axis, and SF sit in between — the PCA recovered positional roles without ever being shown a position label.

PCA biplot

Diagnostics — and a nice finding. Hotelling's T² flags players who are extreme within the model; SCR (squared reconstruction error) flags players the model cannot explain.

Hotelling's T² Distance to model (SCR)
Hotelling T2 SCR

Projected back onto the component plane, the flagged players sit almost exclusively on the right-hand, high-usage side:

Outliers on the score plot

At a 95% limit you would expect ~25 of 508 players to exceed T² by chance; we observed 9 more than that. But inspecting the contribution plots shows these are not data errors — they are the poles of NBA performance:

  • Damian Lillard — scoring, threes, free throws, assists: a dominant offensive engine.
  • Giannis Antetokounmpo — points plus offensive and defensive rebounds: total paint control.
  • Joel Embiid — interior scoring and free throws.
  • Trae Young — creation and finishing, with the turnovers that come with it.
  • Walker Kessler — the mirror image: elite blocks and rebounds, minimal offence.

T2 outlier contributions

The SCR outliers are more subtle — they are players whose style doesn't fit the two-axis story: Ben Simmons (assists without a shooting profile), Brook Lopez and Jaren Jackson Jr. (blocks beyond what the model predicts), Jimmy Butler (an unusual free-throw/three-point mix).

SCR outlier contributions

The decision to keep these players matters: they define the extremes the clustering later needs.

3. Clustering — the substitute finder

Notebook: analysis/03_clustering.Rmd

Why Manhattan distance. Euclidean, Pearson and Manhattan were all tested. Manhattan won on interpretability: it makes the cost of substitution linear and fully decomposable. If two players differ by 2 assists and 3 rebounds, their distance is exactly 5 — a GM can read the distance as a shopping list of what they'd give up.

Manhattan distance heatmap

Why k = 10, when every metric says k = 2. Silhouette and WSS agree on k=2 across Ward, k-means and PAM.

k-means Ward PAM
k-means optimal k Ward optimal k PAM optimal k

We deliberately overrode it. k=2 is statistically optimal and operationally worthless: sorting 508 players into two buckets tells a GM nothing. The task needs roles, and roles are fine-grained. At k=10 the clusters range from 23 to 82 players — small enough to be a real shortlist. This is a case where the objective function and the business objective genuinely disagree, and the business objective should win.

Why k-means over Ward/PAM. At k=10 the silhouette comparison shows k-means assigning nearly every player cleanly to its cluster, with far fewer negative-silhouette misassignments than Ward.

Silhouette comparison at k=10

The deliverable. get_sustitutos() takes a player name and returns everyone in the same cluster who plays the same position — because a cluster-mate who plays centre is no use when you're replacing a point guard.

get_sustitutos("T.J. McConnell")
#> Davion Mitchell, Devonte' Graham, Malcolm Brogdon, ...

McConnell is a bench floor-general who runs the second unit. Every name returned is exactly that archetype — bench guards who create for others and control the ball while the starters rest. A second test on Santi Aldama returned Bobby Portis, Bol Bol and Grant Williams: all high-upside bigs stuck behind a star (Giannis, Banchero and Tatum respectively), who rebound, defend and stretch the floor. The model reproduced a piece of scouting knowledge it was never given.

The ten clusters map onto a recognisable roster hierarchy — franchise players, second options, high-usage connectors, injury-prone starting bigs, rotation pieces, veteran minute-fillers, garbage-time bench and development players. Full write-up of each cluster in the report.

4. PLS-DA — predicting All-Stars

Notebook: analysis/04_pls_da.Rmd

The imbalance problem. All-Stars are ~5% of the league. Left alone, a classifier maximises accuracy by predicting "no" forever and scoring 95%.

Why partial upsampling. Balancing all the way to 1:1 would mean replicating a handful of genuine All-Star rows dozens of times — inviting the model to memorise a few individuals. We upsampled to 2:1 (No:Yes) instead: enough signal for the model to learn the pattern, not so much duplication that it overfits to specific players. The test set was left untouched at its natural imbalance, so test metrics reflect reality.

Component selection. R²Y plateaus around 0.68 by the third component while Q² peaks and begins to decline — the classic stop signal. Three components.

R² / Q² vs components Model overview and permutation test
R2 Q2 PLS-DA overview

Final model: R²X = 0.607, R²Y = 0.679, Q² = 0.655. A permutation test (pR²Y = 0.05, pQ² = 0.05) confirms the discrimination is not chance — models trained on shuffled labels land far below the real one.

What separates All-Stars. VIP > 1 identifies five drivers:

VIP scores

Variable VIP Reading
puntos_aj 1.53 Scoring volume is the single strongest signal
tl_intentados_aj 1.44 Free-throw attempts — offensive aggression, drawing contact
perdidas_aj 1.29 Turnovers, as a consequence of high usage
partidos_titular 1.20 Being a starter
asistencias_aj 1.05 Creation for others

Component 1 alone correlates r = 0.79 with the label; component 2 adds a defensive nuance (robos_aj, tapones_aj) at r = 0.36; component 3 contributes almost nothing (r = 0.17), confirming the choice to stop.

Loadings t vs u correlation per component
PLS-DA loadings t vs u

Performance.

Confusion matrices

Train (upsampled) Test (natural balance)
Accuracy 93.5% 95.4%
Sensitivity (All-Stars caught) 92.3% 100% (8/8)
Specificity 94.0% 95.1% (137/144)
Precision (PPV) 88.6% 53.3% (8/15)

The 7 false positives are the most interesting output of this project. The model missed zero All-Stars but flagged 7 non-All-Stars as All-Stars, dragging precision to 53%. That is not a modelling failure — it is a construct validity finding. All-Star selection is 50% fan vote and 50% coaches/media. A model that sees only box scores is measuring merit; the actual label measures merit + popularity. The players it "wrongly" flags are precisely those with All-Star production and sub-All-Star market. In a scouting context — where you want to find undervalued players rather than reproduce a popularity contest — those false positives are the product, and paying for them with precision to keep sensitivity at 100% is the right trade.

Reproducing the analysis

Requirements: R ≥ 4.0 and RStudio (or rmarkdown::render from the console).

install.packages(c(
  # Core / wrangling
  "tidyverse", "dplyr", "purrr", "readr", "tidyr", "knitr",
  # PCA and visualisation
  "FactoMineR", "factoextra", "corrplot", "ggplot2", "ggrepel",
  "GGally", "gridExtra", "patchwork", "viridis",
  # Clustering
  "cluster", "NbClust", "clValid", "ggsci",
  # PLS-DA
  "caret"
))

# ropls lives on Bioconductor, not CRAN
install.packages("BiocManager")
BiocManager::install("ropls")

Then knit the notebooks in order — each one consumes the previous one's output:

rmarkdown::render("analysis/01_data_cleaning.Rmd")   # → data/processed/nba_2022_2023_ajustado_limpio.csv
rmarkdown::render("analysis/02_pca.Rmd")             # → data/processed/nba_pca.RData
rmarkdown::render("analysis/03_clustering.Rmd")      # reads nba_pca.RData
rmarkdown::render("analysis/04_pls_da.Rmd")          # reads nba_pca.csv

Paths inside the notebooks are relative to analysis/, which is where knitr sets the working directory by default. The clustering notebook fixes its seed (set.seed(42)), so cluster labels are stable across runs.

Limitations and what I would do differently

Being explicit about where this analysis is soft:

  • k=10 is a judgement call, not a result. It is defensible on operational grounds, but it is not validated. A cleaner approach would be to define a scouting-relevant objective (e.g. do cluster-mates actually get signed as replacements for one another?) and tune k against that, rather than overriding silhouette by argument.
  • One season, no context. Everything here is 2022–23 box scores. No opponent adjustment, no pace adjustment, no lineup context, no injury data, no salary — and salary is precisely the constraint that motivates the substitute finder in the first place. Adding contract data would turn a "who plays like him" tool into a "who plays like him and fits the cap" tool, which is the actually useful version.
  • get_sustitutos() is a lookup, not a ranking. It returns cluster-mates unordered. Ranking candidates by their Manhattan distance to the target player would be a small change with a large usability payoff.
  • The log1p weighting is a defensible heuristic, not a principled estimator. It works, and the position recovery in the PCA score plot suggests it isn't distorting things — but its exact form was chosen by reasoning, not validated against a criterion.
  • 8 All-Stars in the test set is a very small denominator. "100% sensitivity" reads better than it should: it means 8 of 8. Repeated cross-validation would give a far more honest interval than a single split.
  • Positions come from the raw data as compound labels (SF-PF, PG-SG), which fragments the position filter in get_sustitutos() and can shrink a candidate list for no good reason.

Authors

Coursework for Modelos Descriptivos y Predictivos I (MDPI) — BSc in Data Science, Universitat Politècnica de València (UPV). Group A2-26.

Mateo Alís · Sergio Ortiz · Manuel Caballero · Joel Porcar · María Porta · Xavier Ventura

License

Released under the MIT License. The underlying NBA statistics are the property of their original source and are included here for educational purposes.

About

NBA scouting engine: PCA + k-means to find a player's statistical replacements, and PLS-DA to predict All-Stars from box-score data alone. R / R Markdown.

Topics

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors