Tier List
ChampionTierAnalyzer algorithm — Bayesian winrate, weighted z-score, matchups, tier thresholds and k-means for role eligibility.
Tier List — algorithm
Source:
src/main/java/com/safjnest/lol/service/ChampionTierAnalyzer.java
The tier list is computed entirely in Java after reading ChampionStatsDocument matrices for the last 3 patches. No pre-computed aggregate in Mongo.
1. Weights and thresholds
ChampionTierAnalyzer.java:23-31:
private static final double WINRATE_WEIGHT = 0.50;
private static final double PICKRATE_WEIGHT = 0.45;
private static final double BANRATE_WEIGHT = 0.05;
private static final double S_PLUS_SCORE = 2;
private static final double S_SCORE = 1;
private static final double A_SCORE = 0.25;
private static final double B_SCORE = -0.25;
private static final double C_SCORE = -1;
Final score per champion:
score = 0.50·z(adjustedWinrate) + 0.45·z(pickrate) + 0.05·z(banrate)
Tiers:
| Tier | Score condition |
|---|---|
| S+ | score >= 2 |
| S | score >= 1 |
| A | score >= 0.25 |
| B | score > -0.25 |
| C | score >= -1 |
| D | score < -1 |
Implementation tier(score) (:424-430).
2. Bayesian adjustedWinrate
ChampionTierAnalyzer.java:281-293,295-310:
double roleAverageWinrate = roleAverageWinrate(eligible); // global wins/picks for the role
double priorStrength = medianPicks(eligible); // median picks among champions of the role
double adjustedWinrate = (wins + priorStrength * roleAverageWinrate) / (picks + priorStrength);
- Prior =
medianPicks(:306-310) — median ofpicksamong all eligible champions of the role (robust vs outliers). - Role average — aggregate
wins/pickson the role. - If
priorStrength == 0(no picks) → empty role →List.of().
Effect: champions with few picks regress toward the role average; with many picks the adjusted value tends to the observed winrate. Example: prior 500, roleAvg 0.50, champion 10W/20P (50%) → (10+250)/(20+500)=0.50; champion 800W/1000P (80%) → (800+250)/(1000+500)=0.70 (little shrinkage).
3. Moments with posterior variance and z-score
ChampionTierAnalyzer.java:381-415:
private static Moments adjustedWinrateMoments(List<SourceChampion> values, double priorStrength) {
double mean = avg(adjustedWinrate);
double variance = 0;
for (SourceChampion v : values) {
double posteriorVariance = v.adjustedWinrate() * (1 - v.adjustedWinrate())
/ (v.statistics().picks() + priorStrength + 1);
variance += pow(v.adjustedWinrate() - mean, 2) + posteriorVariance;
}
return new Moments(mean, sqrt(variance / n));
}
private static Moments moments(List<SourceChampion> values, Metric metric) {
// pickrate/banrate: classic empirical variance
}
private static double z(Double value, Moments m) {
return value == null || m.deviation() == 0 ? 0 : (value - m.mean()) / m.deviation();
}
- Adjusted winrate moments — add Bernoulli
posteriorVariance(p(1-p)/(n+prior+1)) to penalize uncertainty on small samples (shrinkage toward wider variance). - Pickrate/banrate — simple empirical variance.
- z-score —
(value - mean) / deviation; if deviation is 0 → 0 (avoids division by zero).
The score therefore measures how many standard deviations above/below the role mean a champion sits, weighted by winrate/pick/ban.
4. Per-role analysis
analyzeRole (:59-86):
List<SourceChampion> eligible = sourceChampions(filter, source, eligibleForRole, champions);
double roleAverageWinrate = roleAverageWinrate(eligible);
double priorStrength = medianPicks(eligible);
List<SourceChampion> values = adjustedChampions(eligible, roleAverageWinrate, priorStrength);
Moments winrates = adjustedWinrateMoments(values, priorStrength);
Moments pickrates = moments(values, Metric.PICKRATE);
Moments banrates = moments(values, Metric.BANRATE);
for (SourceChampion v : values) {
double score = z(v.adjustedWinrate(), winrates)*0.50 + z(v.statistics().pickrate(), pickrates)*0.45 + z(v.statistics().banrate(), banrates)*0.05;
MatchupAnalysis matchups = matchups(filter, v, eligibleForRole, champions);
result.add(new Champion(..., tier(score), score, ..., counters(matchups), strongAgainst(matchups)));
}
result.sort(comparingDouble(Champion::tierScore).reversed().thenComparing(c -> c.champion().id()));
Final ordering: tierScore desc, then championId asc (deterministic).
5. Matchup — adjustedWinrate and weightedDelta
ChampionTierAnalyzer.java:326-375:
private static MatchupAnalysis matchups(Filter filter, SourceChampion value, ...) {
List<Matchup> roleMatchups = roleMatchups(filter, value.matchups(), roleEligibility);
double priorStrength = medianMatchupGames(roleMatchups); // median games among matchups of the champion
if (priorStrength == 0) return empty;
for (Matchup src : roleMatchups) {
double rawWinrate = (double) src.wins() / src.games();
double adjustedWinrate = (src.wins() + priorStrength * value.adjustedWinrate()) / (src.games() + priorStrength);
double weightedDelta = adjustedWinrate - value.adjustedWinrate();
// ...
}
}
private static double medianMatchupGames(List<Matchup> values) { return median(games); }
- Prior matchup = median
gamesamong the champion's matchups (same shrinkage concept). weightedDeltapositive = champion favored vs opponent, negative = counter.- Internal ordering by
weightedDeltaasc thenchampionId.
Reliable filter
ChampionTierAnalyzer.java:362-369:
private static List<Matchup> reliable(MatchupAnalysis matchups, boolean strong) {
List<Matchup> result = new ArrayList<>();
for (Matchup m : matchups.values()) {
if (m.games() < matchups.priorStrength()) continue; // sample >= prior
if ((strong && m.weightedDelta() > 0) || (!strong && m.weightedDelta() < 0)) result.add(m);
}
return result;
}
A matchup is reliable only if games >= priorStrength and weightedDelta sign is consistent (positive for strongAgainst, negative for counters). Avoids noise on low samples or ambiguous delta.
Limit 3
ChampionTierAnalyzer.java:312-324,377-379:
private static List<Matchup> counters(MatchupAnalysis m) {
List<Matchup> r = reliable(m, false);
r.sort(comparingDouble(Matchup::weightedDelta).thenComparing(c -> c.champion().id()));
return limited(r);
}
private static List<Matchup> strongAgainst(MatchupAnalysis m) {
List<Matchup> r = reliable(m, true);
r.sort(comparingDouble(Matchup::weightedDelta).reversed().thenComparing(c -> c.champion().id()));
return limited(r);
}
private static List<Matchup> limited(List<Matchup> v) {
return v.size() <= 3 ? v : new ArrayList<>(v.subList(0, 3));
}
At most 3 counters and 3 strong-against per champion, ordered by most extreme delta.
6. Role eligibility — k-means k=2
Before computing tiers, roleEligibility (:88-101) filters which champions are actually playable in a role (avoids off-role noise).
Feature
ChampionTierAnalyzer.java:143-161:
Map<Integer, Long> totalPicks = totalPicks(buckets); // per champion, sum over all roles
long offRoleResolution = offRoleResolution(buckets, totalPicks); // min off-role picks
List<RolePoint> points = rolePoints(buckets, totalPicks, offRoleResolution);
// for each (filterKey, championId) with picks>0:
double share = picks / total;
double boundedShare = share < 1 || offRoleResolution <= 0 ? share : picks / (picks + offRoleResolution);
RolePoint = ( log1p(picks), logit(boundedShare) ) // logit = ln(share/(1-share))
log1p(picks) = ln(1+picks)— log scale for picks.logit(share)— transforms share (0,1) to logit scale, bounded to avoid infinity when share=1.
k-means k=2 on normalized points
ChampionTierAnalyzer.java:169-263:
boolean[] highCluster(List<RolePoint> points) {
if (points.size() < 2) return allTrue;
Moments xMoments = pointMoments(points, true); // mean/std on x
Moments yMoments = pointMoments(points, false); // mean/std on y
List<ClusterPoint> normalized = normalized(points, xMoments, yMoments); // z-score
int low = projectionIndex(normalized, false); // min(x+y)
int high = projectionIndex(normalized, true); // max(x+y)
Centroid first = normalized.get(low), second = normalized.get(high); // init at extremes
// Lloyd iteration until convergence:
// assign nearest centroid (euclidean), recompute centroids as mean
// If a cluster becomes empty → allTrue (fallback)
int eligible = eligibleCluster(first, second); // cluster with greater x+y, or dominates on both axes
return assignments == eligible;
}
- Z-score normalization on x and y separately (
pointMoments+normalized). - Init at points with
x+ymin and max projection (deterministic k-means++). - Iteration until stable; euclidean distance
pow(x-cx,2)+pow(y-cy,2). - Eligible selection (
eligibleCluster:259-263): if one centroid dominates the other on both axes, it wins; otherwise the one with greaterx+ywins. - Fallback — if
buckets.size()==1(single role) → all eligible; if convergence fails → all eligible.
Thus only champions in the high cluster (many picks + high share) are considered for the tier list of the role; sporadic off-role picks end up in the low cluster and are excluded.
7. Invariants
medianPicks == 0→ empty role → no champion analyzed.- Z-score with deviation 0 → score 0 → tier B (neutral).
- Reliable matchup requires sample ≥ prior and consistent sign → max 3 per side.
- K-means is deterministic (init on extremes, tie-break on
championId).
