NFL Playoff Predictions

A screenshot of FiveThirtyEight’s NFL game predictions page

I am, almost against my will, a big NFL fan. I vow to myself at the start of each year that my Sunday ritual of watching football from noon to 10:30 pm will be a thing of the past, that I’ll spend that time reading, or writing, or composing an epic poem in iambic pentameter, or developing the next revolutionary app… alas, I’m in the middle of my 17th straight NFL-saturated Sunday. Only 2 more hours to go this season… and then four more weeks of playoffs! Oy vey. Hey, what can I say? American football players are the best athletes in the world, and it’s a stimulating, strategical, exhilarating game. It’s America’s new pastime… and, apparently, mine too.

Okay, enough about me- on with the show! As the playoffs are about to commence, I thought it would be a good time to go through the simple process of calculating the odds of individual games and, consequently, estimating the probability that each team will win the Super Bowl!

ELO

As you’d expect, in a game between two teams, the team with the higher ELO is “expected” to win. The bigger the discrepancy, the higher the probability of the “better” team winning. For football, a difference of 25 points corresponds to about a 1-point expected differential. For example, if Team A, ranked 1500, plays Team B, ranked 1600, the model would expect B to win by about (1600–1500)/25 = 4 points. If B were ranked 1700, they’d be (1700–1500)/25 = 8 point favorites.

There is an additional boost given to the home team; home-field advantage traditionally amounts to about 2.7 points, bad said difference is slightly smaller this season, due to emptier stadiums. Hence, we’ll give home teams a 2-point advantage, which amounts to 2 * 25 = 50 ELO points.

The big question is: by how much do we adjust each team’s rating after the game? As it turns out, a favorite gets fewer points for winning than an “underdog”. The mathematical reason is that we want the “expected” points for each team to be 0. Here’s the formula we’ll be using for win probability:

Pr(A) = 1 / (10^(-(Elo_A-Elo_B)/400) + 1) // Team A's win prob
Pr(B) = 1 - Pr(A) // Team B's win prob

And this is the formula that calculates how much a team’s rating should be adjusted- whether up or down- after a game:

winner_change = 20 * (1-player_win_prob);
loser_change = -20 * player_win_prob;

JavaScript Representations

  • Team Name
  • Team ELO Rating
  • Team Seeding (basically, a number from 1–7 which is used to determine whom a team faces in the playoffs, and whether they play at home or away)

There are 14 teams in this year’s NFL playoffs- as of writing, 13 of the 14 have been determined. I’ll go ahead and assume, for the purpose of this article, that the Redskins will take the final playoff spot (which is not at all a guarantee!). Here’s what the lineup looks like:

I’ll manually convert these into Objects:

const teams = {
'kc': {
'name': 'Kansas City Chiefs',
'conference': 'AFC',
'ELO': 1722,
'seed': 1
}, 'buf':
{
'name': 'Buffalo Bills',
'conference': 'AFC',
'ELO': 1725,
'seed': 2
}, 'pit':
{
'name': 'Pittsburgh Steelers',
'conference': 'AFC',
'ELO': 1587,
'seed': 3
}, 'ten':
{
'name': 'Tennessee Titans',
'conference': 'AFC',
'ELO': 1585,
'seed': 4
}, 'bal':
{
'name': 'Baltimore Ravens',
'conference': 'AFC',
'ELO': 1725,
'seed': 5
}, 'cle':
{
'name': 'Cleveland Browns',
'conference': 'AFC',
'ELO': 1550,
'seed': 6
}, 'ind':
{
'name': 'Indianapolis Colts',
'conference': 'AFC',
'ELO': 1597,
'seed': 7
}, 'gb':
{
'name': 'Green Bay Packers',
'conference': 'NFC',
'ELO': 1703,
'seed': 1
}, 'no':
{
'name': 'New Orleans Saints',
'conference': 'NFC',
'ELO': 1730,
'seed': 2
}, 'sea':
{
'name': 'Seattle Seahawks',
'conference': 'NFC',
'ELO': 1615,
'seed': 3
}, 'was':
{
'name': 'Washington Redskins',
'conference': 'NFC',
'ELO': 1466,
'seed': 4
}, 'tb':
{
'name': 'Tampa Bay Buccaneers',
'conference': 'NFC',
'ELO': 1642,
'seed': 5
}, 'lar':
{
'name': 'Los Angeles Rams',
'conference': 'NFC',
'ELO': 1586,
'seed': 6
}, 'chi':
{
'name': 'Chicago Bears',
'conference': 'NFC',
'ELO': 1515,
'seed': 7
}
}

Next, we’ll develop a function that takes in two team names and determines the probability of each one winning:

function homeWinProb(awayTeam, homeTeam, isNeutral=false) {
const [awayELO, homeELO] = [awayTeam.ELO, homeTeam.ELO + (isNeutral ? 0 : 50)];
return 1 / (10 ** (-(homeELO-awayELO)/400) + 1);
}

Let’s try inserting next weekend’s matchups to see what we get:

const matchups = [
[teams.buf, teams.ind],
[teams.pit, teams.cle],
[teams.ten, teams.bal],
[teams.no, teams.chi],
[teams.sea, teams.lar],
[teams.was, teams.tb]
];
for (const matchup of matchups) {
const [home, away] = matchup;
console.log(`The #${home.seed} seed ${home.name} have a ${(homeWinProb(away, home) * 100).toFixed(1)}% chance of beating the #${away.seed} seed ${away.name}.`);
}

As a football fan, I can confirm that these numbers make sense! The Bills and Saints are the two best teams in the Wild Card round, so they ought to have the best winning chances. The Titans and Redskins are two of the weakest teams, so it makes sense for them to have lower probabilities.

Now we need to come with functions that do the following:

  • Simulate games and adjust ELO after each outcome
  • Properly determine matchups, based on seeding
  • Eliminate losing teams and pass winning teams onto the next round
  • Make sure one team remains at the end, which will be our champion!

To keep track of each conference’s teams, we’ll create an object like this:

const bracket = {
'AFC': {
'current_teams' : [teams.buf, teams.pit, teams.ten, teams.bal, teams.cle, teams.ind],
'next_teams': [teams.kc],
},
'NFC': {
'current_teams' : [teams.no, teams.sea, teams.was, teams.tb, teams.lar, teams.chi],
'next_teams': [teams.gb]
}
};

Basically, we want to do the following:

  1. Take teams in the current_team arrays and match them up
  2. Simulate each matchup
  3. Eliminate the loser and add the winner to the next_teams array
  4. After each round, set current_teams to next_teams and set next_teams to an empty array ([])

Simulate Game

function simulateGame(awayTeam, homeTeam) {
console.log(`#${homeTeam.seed} ${homeTeam.name} vs. #${awayTeam.seed} ${awayTeam.name}`);
const winProb = homeWinProb(awayTeam, homeTeam);
const homeWon = Math.random() < winProb;
const adjustment = 20 * ((homeWon ? 1 : 0) - winProb);
homeTeam.ELO += adjustment;
awayTeam.ELO -= adjustment;
console.log(`${homeWon ? homeTeam.name : awayTeam.name} win!`);
return homeWon;
}

Here’s our doRound() function, which goes through the current_teams array for both conferences and does matchups until the array is empty, pushing the winner into the next_teams array:

function doRound(bracket) {
for (const key of ['AFC', 'NFC']) {
const roundObj=bracket[key];
currTeams = roundObj.current_teams;
currTeams.sort((a,b)=>a.seed>b.seed?1:-1);
while (currTeams.length > 0) {
const [home, away]=[currTeams.shift(), currTeams.pop()];
roundObj.next_teams.push(simulateGame(away,home)? home : away);
}
[roundObj.next_teams, roundObj.current_teams]=[roundObj.current_teams, roundObj.next_teams];
}
return bracket;
}

Next, we add the doTournament() function, which executes doRound() until only one team is left for both conferences. Then, it matches up the conference champions and simulates the “Super Bowl”!

function doTournament(bracket) {
while (bracket.AFC.current_teams.length>1 || bracket.NFC.current_teams.length>1) {
bracket=doRound(bracket);
}
const [AFC, NFC] = [bracket.AFC.current_teams[0], bracket.NFC.current_teams[0]];
return simulateGame(AFC, NFC, true) ? NFC : AFC;
}

So now, let’s create a simulator, which runs a bunch of “tournaments” and keeps track of how many times each team wins:

function simulator(N=100) {
const winnerObj={};
for (let i=0;i<N;i++) {
const teams=JSON.parse(JSON.stringify(teamsOriginal));
const bracket = {
'AFC': {
'current_teams' : [teams.buf, teams.pit, teams.ten, teams.bal, teams.cle, teams.ind],
'next_teams': [teams.kc],
},
'NFC': {
'current_teams' : [teams.no, teams.sea, teams.was, teams.tb, teams.lar, teams.chi],
'next_teams': [teams.gb]
}
};
const winner=doTournament(bracket);
winnerObj[winner.name]=(winnerObj[winner.name] || 0)+1
}
const winners = Object.keys(winnerObj).map(team=>[team, winnerObj[team]]);
winners.sort((a,b)=>a[1]>b[1]?1:-1);
winners.forEach(team=>console.log(`${team[0]}: ${(team[1]/N * 100).toFixed(1)}%`));
return winnerObj;
}

Let’s see what happens when we run this simulator with a large number, say N=10,000:

Not bad! This presentation is a bit hard to follow, but we see that the #1 seed from each conference- Packers and Chiefs- have by far the most Super Bowl wins, and the weaker teams have the fewest.

Let’s add a bit of code that will let us print out all teams, in descending order of most wins, and with totals expressed in percentages:

const winners = Object.keys(winnerObj).map(team=>[team, winnerObj[team]]);
winners.sort((a,b)=>a[1]>b[1]?1:-1);
winners.forEach(team=>console.log(`${team[0]}: ${(team[1]/N * 100).toFixed(1)}%`));

Now, let’s run our simulator function, with an even larger N (100,000) this time:

Finally, let’s see how this compares to FiveThirtyEight:

Voila! The order is virtually the same, and the numbers themselves are all very similar. Looks like this simulator was a success!

Conclusion

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store