← All methods

Player Percentile Rankings

Summary

For each season, rank every player's per-90 and advanced metrics against their cohort. Cohorts are role-based, derived from formationPlace in match-event data and (for midfielders) from clustering of pass-1 percentile vectors:

  • Goalkeeper
  • Defender
  • Midfielder · Ball-winner / Progressor / Creator
  • Attacker · Centre Forward / Winger

A player at the 85th percentile for goals per 90 among Centre Forwards scored more per 90 minutes than 85% of the other CFs who played 200+ minutes that season. Sub-cohorts smaller than 25 player-seasons fall back to the broad position label so percentiles stay meaningful at low sample sizes.

Surfaced on individual player profile pages (/players/{id}) as horizontal percentile bar charts grouped into Attacking, Build-up, and Defensive categories. Also computed for career aggregates (all seasons combined) and stored with season = 0.

Data

  • Source: player_season_stats joined to player_seasons for position labels. The per-90 rates and totals are precomputed upstream; the percentile pipeline reads those values and ranks them.
  • Time range: every CPL season from 2019 onward, plus a career aggregate.
  • Minimum minutes: 200. Players below this threshold are excluded from both the cohort and the ranking. This removes spot appearances and late-season signings whose per-90 rates are unstable.
  • Position source: derived from match_players, using the most frequent position across a player's appearances in that season. player_seasons is not used because it is only populated by the live sync for recent seasons, missing historical data. A player who moves from Midfielder to Forward mid-season will be ranked in whichever position they played most often.
  • Stored in: player_season_percentiles table, one row per (player_id, season). Includes cohort_size so the UI can show how many peers the ranking is relative to.

Choices

Role-based cohorts, not just position

Per-90 stats are only meaningful within position groups, but position alone is too coarse: a centre-forward and a wide forward have very different statistical profiles even though both are "Forwards". A cluster-validation pass on the live archetype map tested which proposed sub-cohort splits the data actually supports. The cohorts that ship are:

Position Cohort scheme Why
Goalkeeper one cohort clean single cluster in validation
Defender one cohort a CB/FB split did not survive — clusters separate by role (defensive vs attacking) rather than position, with each cluster roughly 50/50 CB/FB
Midfielder Ball-winner / Progressor / Creator a CM/AM split did not survive; the embedding cleanly separates by role instead
Forward Centre Forward / Winger data supports the split (CF cluster ~80% pure)

Cohort assignment

Cohorts are assigned once per (player, season) in two passes:

  1. Pass 1 — formationPlace lookup. Modal formationPlace from match_players.stats_json (1=GK, 2/6=FB, 3-5=CB, 7/11=W, 8=CM, 9=CF, 10=AM) maps to a broad cohort. Midfield positions (8, 10) all land in a single broad Midfielder bucket for now.
  2. Pass 2 — midfielder role clustering. Pool the pass-1 percentile vectors of every midfielder cross-season, run KMeans(k=3), then map each cluster to a role label by ranking the centroid against per-role signature features (tackles → ball-winner; passes → progressor; crosses / chances created → creator). The cluster IDs themselves aren't deterministic across reruns, so the signature match is what fixes the role labels.

Players whose modal formationPlace is missing (a small minority in the current data) fall back to the broad position label from match_players.position. The cohort used to compute a player's bars is stored alongside the percentiles so the UI can show vs Midfielder · Creator etc.

Pool design: per-season for broad, cross-season for sub

Per-season sub-cohort sizes are too small in CPL for granular ranking (~10 ball-winners per season, splitting a midfielder cohort that's only ~30 strong). Validation found role assignments to be season- invariant by design, which justifies pooling sub-cohorts cross-season. The pool layout is:

Cohort Pool Reason
Goalkeeper / Defender / broad Midfielder / broad Attacker per-season enough volume per year, preserves league-trend handling
Midfielder · Ball-winner / Progressor / Creator cross-season role is season-invariant; pooling is what makes the cohort viable
Attacker · Centre Forward / Winger cross-season same

Every player-season's per-90 is ranked against its cohort pool. The cohort_size written alongside the percentile reflects whichever pool was used (per-season size for broad; cross-season size for sub-cohort).

Min-cohort fallback (size 25)

If a sub-cohort's cross-season pool has fewer than 25 player-seasons the cohort isn't viable; every member rolls up to the broad parent (Midfielder or Attacker). Broad cohorts always ship even when per-season size is small — they're the final safety net. The UI surfaces cohort_size so the user can judge granularity.

Cohort percentiles, not league-wide

Per-90 stats are only meaningful within position-or-role groups. A Defender with 2 goals per 90 is remarkable; a Forward with 2 goals per 90 is expected. Ranking across positions would bury defenders and goalkeepers in attacking metrics and inflate them in defensive ones.

Minimum 200 minutes

200 minutes (roughly 2 full matches) is the same threshold used by the player quadrant charts. It is aggressive enough to remove noise from single-sub cameos but lenient enough to include rotation players and mid-season signings who earn a meaningful sample. In a typical CPL season this yields 100-180 qualified players across all positions.

Midpoint percentile rank

The percentile is computed as the midpoint between the strict-less and less-or-equal counts:

pctl = ((below + (below + equal)) / 2) / n * 100

This avoids the artifact where the best player in every cohort is always exactly 100.0 and the worst is always 0.0. Instead, a sole leader gets a percentile that reflects the cohort size (e.g., 98.9 among 48 players, not 100.0).

Advanced metric keys

Percentiles are computed for both raw per-90 keys (from per90_json) and derived advanced metrics (from build_player_advanced()). The profile page displays only the advanced metric percentiles since they have cleaner, more meaningful names. The raw per-90 percentiles are still stored for potential future use (e.g., a detailed stats breakdown page).

Career percentiles stored as season = 0

Career aggregates recompute per-90 rates from summed totals across all seasons, then rank against the career aggregates of all other players in the same position. This is stored with season = 0 as a sentinel.

Validation

Cohort sizes match expectations

2023 season: 48 Forwards, 56 Midfielders, 38 Defenders, 15 Goalkeepers = 157 total. This matches player_season_stats rows with >= 200 minutes and a non-null position.

Top scorers rank high in goals

Spot-checked 2023: Joao Morelli (99.0th percentile among Forwards for goals per 90), Woobens Pacius (96.9), William Akio (94.8). These were the leading scorers that season.

Poacher profile is coherent

Jordan Hamilton 2023: 90.6th percentile goals, 99.0th shots, 3.1st tackles. High finishing volume, no defensive contribution. This is the expected shape for a pure striker.

Percentiles sum sensibly

The median player in any cohort should be near the 50th percentile. The midpoint formula guarantees the mean percentile across a cohort is exactly 50.0.

Failure modes

  • Small cohorts. Goalkeepers have 15-20 per season. A percentile among 15 peers has ~7% granularity (each rank step is 6.7 percentile points). The UI shows cohort size to contextualize this.
  • Position misclassification. If the SDP API labels a versatile player as "Midfielder" in one season and "Forward" the next, their percentile peers shift. This is a data quality issue upstream.
  • 200-minute cliff. A player with 199 minutes is excluded entirely while a player with 201 minutes is included. No smoothing or weighting is applied. This is intentional: partial inclusion via confidence weighting would add complexity without clear benefit at these sample sizes.
  • Career aggregates mix eras. A player's career per-90 rates blend 2019 data (8 teams, 28 matches) with 2025 data (8 teams, 28 matches), treating all minutes equally. League-wide scoring rates may have shifted, making cross-era comparisons noisy.
  • No minutes weighting. A player with 201 minutes and a player with 2500 minutes contribute equally to the cohort distribution. The 201- minute player's per-90 rates are more volatile but are treated as equally precise.

What would make it better

  1. Minutes-weighted percentiles. Weight each player's contribution to the cohort distribution by their minutes played, so high-sample players anchor the distribution and low-sample players have less influence on where the boundaries sit.
  2. Confidence bands. Show a range (e.g., 70th-92nd) instead of a point estimate, derived from bootstrap resampling of the player's per-match stats. Especially valuable for low-minutes players.
  3. Cross-season normalization. Adjust per-90 rates for league-wide trends before ranking, so a 2019 Forward is compared fairly to a 2025 Forward in career aggregates.
  4. Goalkeeper-specific metrics. The current metric set is outfield- oriented. Goalkeeper percentiles need save percentage, distribution accuracy, and high claims per 90 to be meaningful.
  5. Re-run the archetype map on the new percentile vectors. The map's k=6 split was tuned against position cohorts; the role-based vectors will likely shift cluster boundaries.