Method & data sources
Doctrine: every number a player sees must be recomputable from source data; every mechanic states its rules; nothing shown is vibes.
The vector space
12,392 player-seasons, 1996-97 through 2025-26, from stats.nba.com per-100-possession splits (pace-adjusted at the door). Every season requires MIN ≥ 800 minutes played — a real rotation season, not garbage-time noise.
Three words this page keeps using
- z-score
- How far above or below average something is, measured in "typical gaps." 0 = exactly average. +1 = a solid notch above. +2 = clearly elite. −1 = a notch below. Roughly two-thirds of players land between −1 and +1. We cap it at ±4 so one freak season can't distort the rest.
- era-z
- The same idea, but a player is only ever compared to the players he actually played against that season — never to another decade. That's why a 1997 center and a 2026 guard can sit in one honest space: each is scored against his own league, then the two scores are compared. It is not an era adjustment applied after the fact.
- mask
- A flag that says "this was never measured" — as opposed to "this was measured and it was zero." Tracking data doesn't exist before 2013-14, so those seasons carry a mask instead of a guessed number. Nothing is filled in silently.
- per 100 possessions
- Every rate on this site is per 100 possessions, not per game. A 2026 game has far more possessions than a 1997 game, so per-game numbers would quietly reward playing in a fast era. Per-100 asks the same question of every player: what does he do with the ball he gets? Expect bigger-looking numbers — a 30-point scorer is nearer 37 per 100.
Why three stats show a percentile, not a number
3P%, FG% and FT% are smoothed toward the league average by attempts
before the model ever sees them — otherwise a player who took four
threes and made three would read as a 75% shooter. That smoothing cannot
be undone, so printing a projected "41.2%" would be inventing a number
the model never held. We show where he'd rank in the league
instead. Every other stat is converted back to a real per-100 rate, and
every one of those conversions is
re-checked before deploy (verify_accuracy.py V15).
Each player-season is 14 features, z-scored within its own season (era-honest — a 1997 center and a 2026 guard share one honest space):
| Feature | What it measures |
|---|---|
PTS | scoring volume |
AST | playmaking |
OREB | offensive glass |
DREB | defensive glass |
STL | steals |
BLK | rim protection |
TOV | turnovers |
FG3A | three-point volume |
FGA | shot volume |
FTA | rim pressure (FTs) |
FG3_PCT | three-point accuracy |
FG_PCT | finishing |
FT_PCT | free-throw touch |
PLUS_MINUS | on-court impact |
Cleaning: dedupe on (player, season); missing values fall back to the season mean with a mask; attempt-weighted empirical-Bayes shrinkage on FG3%/FT%/FG%; z-scores clipped at ±4σ.
The map
The 3D league map (in-game) projects the 14-dim vector down to 3 axes via PCA, minmax-scaled to [0,1] for rendering. The client's projection is an exact affine recovery of the build-time PCA + minmax map — not re-fit, so the on-screen position is the honest one. Axes are named from their feature correlations, not guessed:
- PC1 — Paint vs perimeter: bigs (boards, blocks) ↔ shooters (3PA, 3P%)
- PC2 — Scoring load: high-usage scorers (PTS, FGA, FTA) ↔ low-usage role players
- PC3 — Ball in hand: off-ball, low-event ↔ handlers (AST, STL, TOV)
Archetypes
8 k-means clusters computed at build time on the same 14-dim era-normalized vectors, seeded for reproducibility. Names describe each centroid honestly (its strongest traits), not a scouting label:
- Three-Point Accuracy (Low Turnovers)
- Scoring Volume + Shot Volume
- Defensive Glass + Rim Pressure (FTs)
- Three-Point Volume + Three-Point Accuracy
- Offensive Glass (Low On-Court Impact)
- Rim Protection + Offensive Glass
- Offensive Glass + Defensive Glass
- Playmaking + Steals
The client never re-derives cluster membership independently — it uses the label the pipeline computed, verified identical for all 12,392 players by the accuracy harness (check V2, below).
The Trend Research page's Archetype Eras section asks how that
population of 8 archetypes shifted over three decades, and re-fits a second, independent
layer of archetypes within five era windows to see whether names and centroids
persist across eras. The method, quoted verbatim from assets/archetypes_time.json:
layer 1: per-season share of the 8 global k-means archetypes (labels from vectors.json, no re-fit); layer 2: k-means K=8 re-fit within five era windows (seeded, numpy), named from top-2 centroid sigmas; lineage = nearest predecessor-era centroid by cosine in the Procrustes root frame (era-geometry-corrected); shares are of charted players (MIN>=800), not all rosters — stated scope
The Deadline
Midseason move = an in-season TEAM_ID change with ≥15 games and ≥12 minutes per game on both sides, from 2015-16 through 2025-26 game logs (nba_api PlayerGameLogs). Deltas shown: per-36 points, a context-adjusted plus-minus, and a points-per-shot-attempt proxy, before vs. after the move.
333 movers were analyzed to build the daily-set pool (25 who thrived, 25 who cratered after moving). The composite Thrived/Cratered score is a stated blend, not a truth claim — and "midseason move" is not the same claim as an officially reported trade (some are trades, some are waiver/free-agency moves mid-season); this project only has clean team-change dates, so it says exactly that and no more.
Fader or Finisher
Each player-season is split at its own game-sequence midpoint (not the calendar All-Star break) with ≥25 games and ≥12 minutes per game required on both halves, per-36 rates, 2015-16 through 2025-26. The quiz pool (600 rounds) is limited to unambiguous deltas — a 1.5–6.0 per-36 swing — so ties and noise-level changes never appear as puzzles.
The Skills Lens
Every charted player-season is graded 0–99 on twelve skills. Each skill is a fixed, published linear composite of the same era-z per-100 features the game already ships (e.g. Perimeter Shooting = 0.55·FG3A + 0.45·FG3%; full table in docs/SKILLS_LENS.md), converted to a percentile within its own season pool — so every era carries the same grade distribution, and a 90 in 1997 means exactly what a 90 in 2026 means. Badges fire at 90+, gold at 97+.
The fused chimera on the reveal card is graded through the same composites against a pooled all-era quantile table (assets/skill_probe.json) — that blend never existed as a real season, so its reference pool is all 12,392 charted seasons, stated as such.
Limitations, stated plainly: "Two-Way Impact" is NBA.com on-court plus-minus per 100 possessions (with a small stated scoring stabilizer) — it is not BPM, RAPM, or any adjusted-impact metric, and small-minute seasons can spike it. Free-throw and 3-point percentages are empirical-Bayes shrunk by attempts upstream, but tiny-sample tails remain. Skills that need play-type, hustle, or tracking data (post play, screening, off-ball movement) are deliberately absent until those sources land with honest masks. The promoted MTNN embedding (pipeline/train_mtnn.py, exported to assets/mtnn_embeddings.f32) scores daily puzzles and neighbor search when it clears its recall and purity gates; skill composites on the reveal card stay transparent.
How to read the model's two scores
- recall@10 — "can it find the same guy next year?"
- Hide a player's next season, then ask the model for the 10 nearest player-seasons to this one. Recall is how often his own next season is in that top 10. Higher is better; 1.0 would mean always. A career changes year to year, so this is a test of whether the model tracks a person, not just a stat line.
- purity@20 — "do a player's neighbors look like him?"
- Take a player's 20 nearest neighbors from other eras and ask what fraction share his archetype. Higher means the space groups basketball roles rather than decades. ~0.12 would be random (8 archetypes); the seventies aren't a role.
- "held-out" — and a correction
- A score is only meaningful if the model never saw the answer while
learning. We found that earlier training did include the very
pairs used to score recall, which inflated it toward a perfect 1.0.
That is fixed (
pipeline/leakfree.py): the model now trains only on players it is not later graded on, and the honest numbers are lower and real. A number that looks perfect usually means the test was broken.
Playoff Lens (postseason as a distinct regime). The whole charted space is regular season. When playoff data is loaded, the Skills Lens shows a player-season's regular-season vs playoff splits — points per 100, minutes, usage, true shooting — and a stated riser/fader verdict from the points-per-100 difference. It also shows the team's series path (R1 → Finals opponents and W–L) and an expandable game log from stats.nba.com leaguegamelog. The header outcome badge is the season result (NBA Champion, Finals, etc.) — a Conf. finals row in the path is a series they played on the way, not the outcome. Round labels are era-honest: champions through 2001-02 often finished with 15 wins (best-of-5 first round) and are still labeled Champion. Same-season honors (All-NBA, All-Star, Finals MVP from Basketball-Reference) render beside the outcome. "Riser" and "Fader" describe the change in raw postseason production against a shorter, tougher sample; they are not a clutch or leadership claim. The MTNN treats playoffs as its own tower family (with a playoff_riser head plus series/close-game scoring features from the game logs) — research lane only; the game shows the transparent splits and path, and nothing at all until the postseason data lands.
Wide-matrix skills (post / transition / motor + three gravities). Six skills the box score can't express are graded from richer feeds — Post Play and Transition from synergy play-type frequency + points-per-possession, Motor from hustle stats (screen assists, deflections, loose balls, charges, box-outs), and three kinds of gravity: Shooting Gravity (pull-up 3-point volume weighted heaviest, plus 3-point volume and accuracy — the off-dribble threat that pulls defenders out, so a movement shooter like Curry tops it, not a stationary spot-up specialist), Rim Gravity (shot-blocking + contested shots minus opponent field-goal percentage allowed — the interior deterrence that warps offenses, topped by rim protectors like Wembanyama), and Disruption Gravity (steals + deflections + charges drawn — perimeter defensive warp that bends passing lanes, topped by disruptors like Marcus Smart). These are masked: tracked only from 2015-16 on, so earlier seasons show "not tracked this era" rather than a guessed grade. All three gravities are stated proxies from public NBA.com tracking + the box score — not Second Spectrum gravity or matchup-difficulty data. Same era-z percentile grading as the core twelve.
Next-season profile (predicted vs actual). The MTNN
next_profile head reads a player-season embedding and predicts
the next season's 14-d era-z game vector. On the Skills Lens profile, prior
seasons show predicted vs actual (charted next-year vector) with a
mean absolute error on primary stats. For the latest charted season
(currently 2025-26), next-year stats are not available yet — the UI
shows the prediction only and labels it as such. Values are geometry-implied
z-scores, not a minutes or pace forecast. Asset:
assets/next_profile_eval.json (rebuild via
pipeline/export_next_profile_eval.py).
Steals of the Draft. Pairs what was expected of a player on draft night (the rookie-scale slot implied by his pick) against what he actually produced (his career mean skill grade, weighted by minutes played, so a short cameo season counts less than a full one). Both are turned into percentile ranks and subtracted. A player who beat his expectation is a steal; one who fell short of it, a bust. One hot season never drives the board.
The two boards use deliberately different filters. A steal has to have proven it, so we require five charted seasons — and undrafted players are included, because going unpicked through all 60 selections is the largest expectation there is to beat. A bust is the opposite: filtering on seasons played would quietly delete the very players who washed out. So instead we ask only that enough time has passed to judge (five years since the draft), and we let a short career count as evidence rather than as a disqualifier. A bust's whole career must also fall inside our data window, which begins in 1996-97 — otherwise a star whose prime we never charted looks like a player who never arrived. Undrafted players cannot be busts: there was no expectation for them to miss.
Playoff credit is a bonus, never a penalty. Reaching the postseason is mostly a team outcome, so a steal stranded on a bad franchise loses nothing. A player who showed up in the playoffs — both how often he got there and how much he produced once there — gains up to ten points.
Two honest limits. Because both sides are percentile ranks, the scale runs out at the top: once a player is among the very best, further greatness cannot lift his score much, so an undrafted role player can edge out an undrafted-adjacent superstar. And the career grade is a flat average of twelve skills, which under-rates specialists — an elite defender who never shot is not fully paid for what made him great. This board is a descriptive gap between expectation and measured production. It is not a re-draft, and not a verdict on a player's value.
Data sources
- stats.nba.com via
nba_api—leaguedashplayerstats(Base/Advanced/Scoring),leaguedashplayerbiostats,leaguedashptstats(player tracking, 2013-14 onward only — masked before, never imputed), andPlayerGameLogs(2015-16 through 2025-26 for The Deadline and Fader-or-Finisher). - Basketball-Reference — per-season positions (99.7% coverage; unmatched seasons render as —) and current player contracts where salary history is unavailable elsewhere.
- Every season/endpoint response is cached locally; re-runs resume from cache. stats.nba.com throttles aggressively, so builds are deliberately incremental.
The League Drift
The Trend Research page asks a different question than the rest of the
site: not "how good was this player" but "how much did the shape of the league's
14-dim vector space itself rotate, season to season." The method, quoted verbatim
from assets/drift.json:
orthogonal Procrustes on consecutive-season shared players (>=30); rotation = mean principal angle of Q vs identity; residual = normalized Frobenius after alignment; no scaling (z-spaces pre-normalized); chained transforms map any season into the 1996-97 root frame; axisDrift = 1-|Q_ii|, a stated proxy
The chained root-frame transforms power Trend Research drift charts; the Era Twin daily puzzle now uses the promoted MTNN embedding for cross-decade matching (same space as Chimera scoring).
Career Shapes (career trajectories)
The Trend Research page's Career Shapes section asks a career-level question on top of the per-season archetypes above: not "what archetype was this season" but "how did this player's own archetype label move across his whole career." Careers require at least 4 charted seasons; each season keeps its own per-season global archetype label (never career-static). The taxonomy is rule-based, stated plainly:
- stable — one archetype covers at least 75% of the charted seasons.
- reinvention — exactly one sustained switch: a modal archetype covering at least 75% of the seasons before the switch, and a different modal archetype covering at least 75% of the seasons after it.
- late-bloom — a reinvention whose switch lands at or after the 60% mark of the career.
- migrator — three or more distinct archetypes, none ever reaching 60% share.
- drifter — everything else: two or more archetypes, no sustained switch.
Era comparison: mean transition rate (archetype changes per season-pair) for careers whose
midpoint falls in each decade — do careers migrate more now? Correlates shown
(career length, mean plus-minus z) are observed, with selection effects stated —
trajectory class is an outcome of a career, not something assigned to it. The method, quoted
verbatim from assets/trajectories.json:
careers >=4 charted seasons; per-season global archetype labels (never career-static); taxonomy rule-based as documented (stable >=75% modal; reinvention = one sustained >=75%/>=75% switch; late-bloom = switch at >=60% career index; migrator = 3+ archetypes none >=60%); era comparison by career-midpoint decade; correlates are observed with selection effects — trajectory class is an outcome, not an assignment
The accuracy harness
pipeline/verify_accuracy.py gates every deploy — a non-zero exit blocks shipping. It checks:
- V1 —
vectors.jsoninternal integrity: dimensions, ranges, no duplicate (name, season) rows, valid map coordinates. - V2 — cluster labels: recomputes nearest-centroid assignment from member means and confirms the client's centroid method reproduces the pipeline's labels for all 12,392 players.
- V3 —
deadline.json: recomputes every quiz mover's deltas directly from the raw game logs and requires an exact match (0.01 rounding tolerance) — 100% sample, not a spot-check. - V4 — Chimera determinism: the seeded daily target is checked across 30 sampled dates for stability and for always satisfying the low-similarity (MTNN cosine < 0.3) constraint between its two donor seasons.
Attribution, license & limitations
Attribution: statistics sourced from stats.nba.com and Basketball-Reference; all figures shown are derived, era-normalized aggregates, not raw scrapes republished verbatim.
License: this repository does not yet publish a license file — treat the code as all-rights-reserved pending one. Source data remains subject to stats.nba.com's and Basketball-Reference's own terms.
Limitations, stated plainly:
- No lineup or on/off data is used anywhere on this site — nothing here claims to measure who a player shared the floor with.
- "Midseason move" is a team-ID-change detection, not verified trade metadata — a move mid-season is not automatically an officially reported trade.
- Player tracking features (drives, touches, speed/distance) only exist from 2013-14 onward; earlier seasons carry an explicit missing-data mask rather than an imputed guess.
- Position coverage from Basketball-Reference is 99.7%, not 100% — a small number of season rows render without a position.
- Archetype names describe statistical cluster centroids, not scouting judgment or team role.