Player Similarity
Summary
For every qualified player-season, rank the 10 most similar other player-seasons within the same position cohort, using cosine similarity on the percentile vector. Cohorts are cross-season: a 2025 Forward is matched against every other Forward from 2019 onward, including their own earlier seasons. Matches surface on individual player profile pages as a clickable table; selecting a match overlays their percentile rankings on the current player's bars.
Data
- Source:
player_season_percentiles(excluding career aggregates whereseason = 0). Similarity is a pure derived product of the percentile table. - Feature set: the union of keys ending in
_pctlpresent in any row, taken at compute time. Currently 66 keys: one percentile per raw per-90 stat plus one per advanced metric. The_min/_maxcohort range fields are ignored since they are constant within a cohort and carry no player-specific signal. - Cohorts: four (Goalkeeper, Defender, Midfielder, Forward). A
player's bucket is their dominant position for that season, inherited
from
player_season_percentiles. Similarity is computed only within a cohort. - Minimum minutes: inherited from the percentile table (200 in default seasons, 90 in 2020 Island Games). Players below the threshold are absent from the source data and therefore absent from similarity.
- Stored in:
player_season_similarwith PK(player_id, season, rank). Each source player-season stores topTOP_N = 10rows.
Choices
Cosine on percentile vectors
Cosine is equal-weight, scale-invariant, and well-behaved in high-dimensional percentile space. All feature values are on the same 0-100 scale, so raw cosine is directly interpretable as "how similarly shaped are these two players' strengths and weaknesses."
Alternatives considered:
- Euclidean: sensitive to absolute differences, which are noise-prone when one player has a thin profile.
- Weighted cosine by metric group: would require picking weights per position. The equal-weight default is defensible as v1; weighting is a natural follow-up if specific use cases demand it.
- PCA or learned embedding: would trade interpretability for compression. At the current cohort sizes, unnecessary.
Masked similarity over shared keys
For each pair (A, B), cosine is computed only over the keys both
players have populated. This handles the case where outfield players
lack goalkeeper stats and vice versa (within-cohort this is rare but
still occurs for optional keys like set-piece attempts). Pairs with
fewer than MIN_SHARED_KEYS = 12 shared features are skipped entirely;
matching on a handful of keys produces spurious high similarities.
This makes similarity slightly asymmetric: sim(A, B) may exclude
a key A has but B doesn't, so A's top-10 may not include B even if B's
top-10 includes A. This is a real property, not a bug. Attempting to
symmetrize by always comparing on the universal key set would throw
away information about what each player actually did.
Cross-season within position
Percentile values are already cross-era comparable in the sense that they are all 0-100 relative-to-peers measurements. A 2019 Forward at the 80th percentile for chances created is "elite among his 2019 peers" and is matched to a 2025 Forward "elite among his 2025 peers." This enables comparisons like "who plays most like 2024 Salter in league history?"
The limitation: if the absolute production at the 80th percentile has drifted across seasons (league evolution, rule changes, squad-size changes), the match is on relative shape, not absolute level. The UI does not claim otherwise; the output is labeled "cross-season, same position."
Top 10 per source
Enough to surface a plausible top-of-class and tail, small enough to store cheaply (~10 × 1200 source rows = 12k rows total). Cursor paginate if this ever grows.
Same-player, other-seasons included
A player often appears in their own top matches, usually their adjacent-season selves. Explicitly kept: "2025 Salter looks most like 2022 Salter with some drift toward Pacius" is useful signal. The UI labels these rows so they are not mistaken for a bug.
Validation
Position purity
All 10 matches for a source player-season are guaranteed same-position by construction, since cosine is computed within-bucket. Audited as a zero-row invariant: no row in the similar table joins to a different position than the source row.
Monotone decreasing similarity
For any source, rank 1 carries the highest similarity and values decrease from there. Audited as a zero-row invariant: no row scores higher than its predecessor's similarity.
Distribution is non-pathological
Sample check after full compute:
- min ≈ 0.76, max ≈ 0.99, mean ≈ 0.92 across ~11.5k rows
- only ~0.1% of rows above 0.98 (not a cluster of near-ties)
If these shift sharply, the feature key set or the masking threshold probably regressed.
Coherence spot-checks
Known forwards should match forwards with similar play style:
- 2025 Samuel Salter (20G top scorer): top matches Woobens Pacius 2023, Osaze De Rosario 2023, Easton Ongaro 2021, Kwasi Poku 2024. All high-volume forwards. Pattern holds.
- 2025 Marco Carducci (GK): top matches are Carducci's own earlier seasons and Callum Irving. Expected for a veteran keeper with a long, consistent profile.
Failure modes
- Percentile-space comparability. Percentiles are relative-to-peers within a single season cohort. A 2019 80th-percentile Forward and a 2025 80th-percentile Forward are not guaranteed to have the same absolute production, even though their percentile vectors match. Cosine similarity captures shape, not level.
- Cohort boundary hops. A player who plays primarily Defender in 2023 and primarily Midfielder in 2024 appears in two different cohorts; their two player-seasons cannot match each other. Correct by construction, but occasionally surprising when a player feels "the same" across a role change.
- 2020 Island Games thinness. Short season plus a 90-minute
threshold yields cohorts of 6-12 per position, many with thin stat
profiles. The
MIN_SHARED_KEYSguard catches most spurious matches, but similarities involving 2020 rows carry real uncertainty. - Feature key drift. If the percentile pipeline's per-90 or advanced metric set changes, the similarity feature set changes on the next compute run. Feature keys are logged so the change is visible; similarity results should be treated as version-tied to the percentile compute that fed them.
- Masking asymmetry. A's top-10 may not include B even when B's top-10 includes A. Real and documented; not attempted to "fix."
- Same-player early matches. New players with only 1-2 seasons always match a diverse set of peers; veterans match their own earlier selves first. This is fine but means the distribution of first-rank matches by age is skewed and should not be read as a general similarity statistic.
What would make it better
- Role-aware sub-cohorts. Split Midfielders into central and wide, Defenders into centre-backs and full-backs, Forwards into target men and creators. Requires better position data than the SDP single-label field, but would sharpen both matches and percentile cohorts upstream.
- Minutes-weighted filtering. Filter candidate pool to players with comparable minutes (e.g., within 40% of the source), so a rotation player isn't matched to a 2500-minute starter just because both had similar per-90 shapes.
- Learned feature weights. If we eventually have a ground truth signal for "successful transfer" or "league-to-league equivalence," weight features by importance. Out of scope until such a signal exists.
- Bidirectional similarity. Compute a mutual similarity score (e.g., geometric mean of A->B and B->A cosines over each's shared key set) and expose it as an alternative ranking.
- Era-adjusted percentiles. Rebase percentiles against a multi-season reference cohort instead of per-season cohorts before computing similarity, to let cross-era comparisons compare absolute levels too. Would interact with the percentile methodology.