Our Elo Model

sports_racoon

There's a million ways to rank teams, but our is the best. Well maybe. Either way, analysis must start somewhere and an Elo model is great for exactly that.

Much has been written about Elo models, and while most implementations follow a similar pattern, they often glaze over a few important tidbits that make them tough to reproduce in practice. After all, what is science if you can't reproduce it?

Our goal here, like all of DecodedSports, is to decode the model by showing through examples how it works and how to implement it yourself with code you can play with and reuse!

The Ideal Model

Unfortunately, one likely does not exist, which is why we leverage many different models here at DecodedSports. In general, though, the ideal model would possess the below traits:

  • Human Explainable
  • Produces rankings
  • Predictions are probabilistic
  • Supports post-model modifiers
  • Resilient to time - never lags
  • Represents maximum information

Some models are not human explainable (e.g., Neural Networks), some do not enable rankings (e.g., Decision Trees), some do not enable prediction probabilities (e.g., handicapping), and many models require routine retraining as time progresses.

While most models enable predictions of some sport (spread, over/under, win probabilities), many do not support rankings because they are typically trained to predict an outcome, not necessarily the relative differences that produce those outcomes.

What is Elo?

The better question is "Who is Elo?" Arpad Elo (1903 - 1992) was a Hungarian-American physics professor and Chess Master who determined a mathematical method to calculate the skill level ratings of competitors.

Elo solves most of the above traits, but it is not the holy grail. Elo ratings lag and take time to adjust to new information. Elo struggles when ratings are first assigned before the model converges. Elo is semi-resilient to time, but hyper-parameters could likely be fined tuned by eras as sports evolve.

Elo provides prediction probabilities like a lot of models, BUT these predictions are only a by-product of arguably a more important trait that Elo possesses, which is the ability to rank.

Elo proved that the relative difference in ratings could be converted into a statistical probability that predicts the outcome of the competition.

Learn more about the Elo Rating System here.

Why Elo?

But I thought we could just rank teams based on their winning percentage??

Technically, yes. That is the simplest way of ranking teams and is how the majority of leagues around the world do it.

Some leagues take it a small step farther by assigning points for wins-ties-losses, e.g. NHL awards 2-pts for a win, 1-pt for a loss in over-time, and 0-pt for a loss in regulation-time.

These types of ranking systems are great for human comprehension, BUT they are awful at actually ranking teams. Consider the following example using a common system that awards 1-pt for a win and 0-pt for a loss.

If the #1 team beats the #30 team, why should they be awarded the same amount as the #30 team beating the #1 team? That would be like going to the roullette table in Vegas and every bet payout being worth the same amount. e.g. betting on red (47.4% win chance) awarding the same as betting on a straight (2.6% win chance). The casinos would go broke instantly, or vice versa if they paid out all bets as if it were like betting on red, then you would go instantly broke!

Clearly, the amount that a team is awarded towards their ranking should operate in the same fashion and thus be a function of the probabilities for either team winning. This is EXACTLY what an Elo model does!

For some extra fun reading, this same principle is related to how financial futures and options contract prices are calculated with the Black-Scholes Model.

Elo Rating Differences

Consider the below set of teams and their Elo ratings.

const TEAMS = {
    'Unicorns': 1700,
    'Racoons': 1650,
    'Pandas': 1600,
    'Bears': 1550,
    'Tigers': 1500,
    'Seals': 1450,
    'Sharks': 1400
};

We can calculate the probability for any notional matchup with the formula below.

function elo_probability(rating_diff) {
    return 1.0 / (Math.pow(10, (-rating_diff / 400)) + 1);
}

const rating_diff = TEAMS['Racoons'] - TEAMS['Tigers'];
>>> 150
elo_probability(rating_diff);
>>> 0.7034

The rating_diff above only uses the base difference in rating. In practice, it is actually better to adjust this situationally to account for various factors like:

  • start of season
  • home field advantage
  • weather
  • rest between matches
  • travel distance
  • etc.

The ability to adjust ratings situationally is another convenient trait of Elo, since they are not dependent on a pre-compiled black-box model.

We make match-agnostic adjustments as follows.

function shift_elo_prematch(pre_elo, n, elo_avg, elo_revert){
    if (n > 0) {
        // not season start
        return pre_elo;
    }
    // season start
    const reverted_elo = elo_avg * elo_revert;
    const existing_elo = pre_elo * (1 - elo_revert);
    return existing_elo + reverted_elo;
}

and then match-aware adjustments as follows:

function calc_rating_diff(pre_elo1, pre_elo2,
                          rest1, rest2,
                          hfa_mod, rest_mult){
    let rating_diff = pre_elo1 - pre_elo2;
    rating_diff += hfa_mod;
    rating_diff += rest_mult * (rest1 - rest2);
    return rating_diff;
}

const rating_diff = calc_rating_diff(
    TEAMS['Racoons'], TEAMS['Sharks'],
    rest1=2, rest2=1,
    hfa_mod=25, rest_mult=2
)
>>> 277
elo_probability(rating_diff)
>>> 0.8313

We can see that after adjusting for home field advantage, rest, etc. that the rating_diff is now 277, giving a 13-basis point boost to the home team win probability.

Updating Elo

Once a match concludes, the above rating_diff, the margin of victory, and binary outcome (win (1) or loss (0)) of the home team, is then used to update the shift in rating for each team.

An important trait of Elo is that rating changes are always zero-sum, meaning if one team loses 75 rating points, then the other team will gain 75 rating points.

function shift_elo(rating_diff, mov,
                   k_n, k_start, k_end, k_rate,
                   ac_sensitivity, ac_scale,
                   mov_mod, mov_scale) {
    // Adjust rating diff based on outcome
    let winner_rating_diff = rating_diff;
    winner_rating_diff = mov > 0 ? rating_diff : mov == 0 ? 0 : -rating_diff;  // home win : (tie : away win)

    const win1 = mov > 0 ? 1 : 0;
    mov = Math.max(Math.abs(mov), 1);
    const auto_corr_adj = ac_scale / (winner_rating_diff * ac_sensitivity + ac_scale);
    const mov_mult = mov_scale * Math.log(mov) + mov_mod;
    // const mov_mult = (mov + mov_mod) ** mov_scale // alternative mov smoothing formula

    const k = k_n <= k_rate ? k_start : k_end;
    const win_prob1 = elo_probability(rating_diff);
    const expectation_adj = win1 - win_prob1;
    const shift = k * expectation_adj * mov_mult * auto_corr_adj;
    
    return shift;
}

Using our previously calculated values, we now arrive at the final updated ratings.

const rating_diff = 277;

const team1_n = 2;
const team2_n = 3;
const home_team_score = 9;
const away_team_score = 3;
const mov = home_team_score - away_team_score;

const shift = shift_elo(rating_diff, mov,
                        n=Math.max(team1_n, team2_n),
                        k_start=MAGIC_VALUES['k_start'],
                        k_end=MAGIC_VALUES['k_end'],
                        k_rate=MAGIC_VALUES['k_rate'],
                        ac_sensitivity=MAGIC_VALUES['ac_sensitivity'],
                        ac_scale=MAGIC_VALUES['ac_scale'],
                        mov_mod=MAGIC_VALUES['mov_mod'],
                        mov_scale=MAGIC_VALUES['mov_scale']);
>>> 16.9916
TEAMS['Racoons'] += shift
>>> 1666.9916
TEAMS['Tigers'] -= shift
>>> 1483.0084

What if the underdog Tigers had won?

const rating_diff = 277;

const team1_n = 2;
const team2_n = 3;
const home_team_score = 3;
const away_team_score = 9;
const mov = home_team_score - away_team_score;

const shift = shift_elo(rating_diff, mov,
                        n=Math.max(team1_n, team2_n),
                        k_start=MAGIC_VALUES['k_start'],
                        k_end=MAGIC_VALUES['k_end'],
                        k_rate=MAGIC_VALUES['k_rate'],
                        ac_sensitivity=MAGIC_VALUES['ac_sensitivity'],
                        ac_scale=MAGIC_VALUES['ac_scale'],
                        mov_mod=MAGIC_VALUES['mov_mod'],
                        mov_scale=MAGIC_VALUES['mov_scale']);
>>> -86.0529
TEAMS['Racoons'] += shift
>>> 1563.9471
TEAMS['Tigers'] -= shift
>>> 1586.0529

Why when the favored team (Racoons) won, the shift was only 16, but when the underdog team (Tigers) won, the shift was 86?

Well, that's the beauty of Elo! The relative difference in ratings implies a significant amount of information about the outcome of a match. When a team that is favored to win at 83% ends up winning, well "congrats?", i.e. they should not be rewarded much for such an easy feat. However, when they end up losing by 6 points in a big upset then that calls for a huge celebration by the underdog which Elo heavily rewards them for.

83% means 83%!

The Elo model always wants to represent as closely as possible the true probabilities of an outcome. When Elo says "83%" it truly means "83%"! Meaning, if you were to replay this Tigers vs. Racoons matchup a hundred times, then the Racoons would win 83 times and the Tigers would win 17.

Humans struggle interpreting probabilities like this and thus treat very high or very low probabilities as binary outcomes, exclaiming "How could this ever happen?!?" when the 83% favored team loses. Well Elo told you it would (not could) happen 17 times.

We most commonly experience this frustration with weather forecasts, seeing 15% chance of rain and then wondering why we are sitting wet at the football game. Meteorologists are actually the best in the business at meaning the probabilities they give. If you were to keep a log of all the days you read "15% chance of rain" and record what happens afterwards, I'd bet good money that it magically turns out to rain 15% of the time.

Elo Pitfalls

  • Lagging
    • The fact that the model needs to shift after a match means it lags. These adjustments are always playing catchup. Otherwise, Elo would only yield predictions of either 100% or 0% and be correct every time.
  • Sensitive Hyper-parameters
    • There are no universal hyper-parameters that consistently work across different sports, or even different eras of the same sport. This requires constant fine-tuning and is sensitive to over-fitting.
  • Rating Inflation/Deflation
    • When a team wins a lot (or loses a lot), their rating begins to skew. While it is true that a consistently winning team will see smaller and smaller ratings adjustments (as demonstrated above), their rating does still continue to go up nonetheless. At a certain point, their rating could be so high that even losing 20 back-to-back matches would still leave them with an overly inflated rating.

Magic Values!

You may have noticed some questionable constants being used in the code. Developers call these "Magic Values" due to their mysterious nature and just magically working, without logical explanation or reasoning for their use in the code.

The magic values we use here are for the hyper-parameters of the Elo model. We've calculated them via machine learning with a genetic algorithm, which deserves its own blog post later.

const MAGIC_VALUES = {
    // Hyper-parameters from base Elo formula
    'k_rate': 10, // k-Factor rate of decrease, number of games
    'k_start': 20,  // K-factor starting value
    'k_end': 15,  // K-factor final value

    // Match-agnostic Hyper-parameters
    'elo_revert': 0.2, // portion to mean revert at start of season
    'elo_avg': 1550,  // Expected Elo mean to revert towards

    // Match-aware Hyper-parameters
    'hfa_mod': 10,  // home-field advantage
    'rest_mult': 2,  // days rest multiplier
    'ac_sensitivity': 0.005, // auto-correlation
    'ac_scale': 100,  // auto-correlation
    'mov_mod': 0.5,  // margin of victory
    'mov_scale': 2,  // margin of victory
}

A Hypothetical Season

Putting it all together, we can now simulate games between our teams.

The resulting team rankings are:

Notice that the sum of the 'change' column is zero, demonstrating the zero-sum system that an Elo model operates in.

Model Performance

You can always check the live status of our models here.

Next Steps

Stay tuned for future articles adding to our understanding of Elo:

  • How to hyper-tune Elo's magic values
  • Measuring Elo's performance
  • Elo compared to other rating models
  • Ratings vs. Predicting