Finding a player's cheapest lookalike, and predicting All-Stars from box-score data alone.
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:
- "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. - "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
- Why this project
- Key results
- Repository structure
- The data
- Methodology
- Reproducing the analysis
- Limitations and what I would do differently
- Authors
- License
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.
| 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.
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.
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 |
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 |
|---|---|
![]() |
![]() |
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_ajwas dropped — it is a linear combination ofro_aj+rd_aj.partidos_jugados/partidos_titularwere 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-encodepuntos_aj(r = 0.83).
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.
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.
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 |
|---|---|
![]() |
![]() |
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.
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) |
|---|---|
![]() |
![]() |
Projected back onto the component plane, the flagged players sit almost exclusively on the right-hand, high-usage side:
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.
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).
The decision to keep these players matters: they define the extremes the clustering later needs.
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.
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 |
|---|---|---|
![]() |
![]() |
![]() |
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.
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.
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 |
|---|---|
![]() |
![]() |
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:
| 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 |
|---|---|
![]() |
![]() |
Performance.
| 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.
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.csvPaths 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.
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
log1pweighting 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 inget_sustitutos()and can shrink a candidate list for no good reason.
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
Released under the MIT License. The underlying NBA statistics are the property of their original source and are included here for educational purposes.























