IodidDOCUMENTATION

formulas/tier-list.mdx

/doc/formulas/tier-list

PUBLIC DOCUMENTATION

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:

java
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:

text
score = 0.50·z(adjustedWinrate) + 0.45·z(pickrate) + 0.05·z(banrate)

Tiers:

TierScore condition
S+score >= 2
Sscore >= 1
Ascore >= 0.25
Bscore > -0.25
Cscore >= -1
Dscore < -1

Implementation tier(score) (:424-430).

2. Bayesian adjustedWinrate

ChampionTierAnalyzer.java:281-293,295-310:

java
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 of picks among all eligible champions of the role (robust vs outliers).
  • Role average — aggregate wins/picks on 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:

java
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):

java
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:

java
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 games among the champion's matchups (same shrinkage concept).
  • weightedDelta positive = champion favored vs opponent, negative = counter.
  • Internal ordering by weightedDelta asc then championId.

Reliable filter

ChampionTierAnalyzer.java:362-369:

java
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:

java
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:

java
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:

java
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+y min 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 greater x+y wins.
  • 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).