Any regular-season analysis of the NFL would be incomplete without some awareness of the conference standings. Standings have implications for both the playoffs and the draft, and understanding those implications can help explain counter-intuitive phenomena—especially near the end of the regular season.
In this tutorial, we're going to explore whether we can reproduce the final 2024 conference standings. The main focus will be sourcing from NFLData.jl and performing basic dataframe operations rather than an exact result that reflects the NFL rulebook. The code could be adapted to handle other points in time or simulated results.
Some familiarity with Julia, DataFrames.jl, and with the NFL is helpful. Everything here should be accessible for users familiar with the nflverse but new to Julia.
Preparing the Environment
For this tutorial, we need to import a few packages.
using Chain
using DataFrames
using NFLData Depending on your environment, you may need to add these packages to the environment first. If this errors, you have two options:
pkg> add Chain, DataFrames, NFLDataorimport Pkg; Pkg.add(["Chain", "DataFrames", "NFLData"])
Either will modify your environment to make these packages available.[1]
Why Chain?
I started using Julia after several years of Python. Python syntax supports building up expressions by chaining methods to the end of previous results.
For example:
input_df.groupby([
"key1", "key2",
]).agg(
value_mean=pd.NamedAgg(column="value", aggfunc=np.mean),
value_std=pd.NamedAgg(column="value", aggfunc=np.std),
).plot() This syntax arises somewhat naturally when iterating in a Jupyter notebook.
Julia supports a similar piping syntax.[2]
input_df |>
(x -> groupby(x, [:key1, :key2])) |>
(x -> combine(x, :value => mean => :value_mean, :value => std => :value_std)) I find that syntax to be clunky. With Chain, the same example reads a lot cleaner.
@chain input_df begin
groupby([:key1, :key2])
combine(
:value => mean => :value_mean,
:value => std => :value_std,
)
end The Chain documentation has a number of (better) examples.
Loading Data
We source data with NFLData.load_teams() and NFLData.load_schedules().
Teams
first(load_teams(), 3)3×16 DataFrame
Row │ team_abbr team_name team_id team_nick team_conf team_division team_color team_color2 team_color3 team_color4 team_logo_wikipedia team_logo_espn team_wordmark team_conference_logo team_league_logo team_logo_squared
│ String String Int64 String String String String String String? String? String String String String String String
─────┼─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
1 │ ARI Arizona Cardinals 3800 Cardinals NFC NFC West #97233F #000000 #ffb612 #a5acaf https://upload.wikimedia.org/wik… https://a.espncdn.com/i/teamlogo… https://github.com/nflverse/nflv… https://github.com/nflverse/nflv… https://raw.githubusercontent.co… https://github.com/nflverse/nflv…
2 │ ATL Atlanta Falcons 200 Falcons NFC NFC South #A71930 #000000 #a5acaf #a30d2d https://upload.wikimedia.org/wik… https://a.espncdn.com/i/teamlogo… https://github.com/nflverse/nflv… https://github.com/nflverse/nflv… https://raw.githubusercontent.co… https://github.com/nflverse/nflv…
3 │ BAL Baltimore Ravens 325 Ravens AFC AFC North #241773 #9E7C0C #9e7c0c #c60c30 https://upload.wikimedia.org/wik… https://a.espncdn.com/i/teamlogo… https://github.com/nflverse/nflv… https://github.com/nflverse/nflv… https://raw.githubusercontent.co… https://github.com/nflverse/nflv… To make joins slightly easier, we source the team data and rename with select.
team_df = @chain load_teams() begin
select(
:team_abbr => :team,
:team_name => :name,
:team_conf => :conf,
:team_division => :division,
)
end
first(team_df, 10)10×4 DataFrame
Row │ team name conf division
│ String String String String
─────┼───────────────────────────────────────────────
1 │ ARI Arizona Cardinals NFC NFC West
2 │ ATL Atlanta Falcons NFC NFC South
3 │ BAL Baltimore Ravens AFC AFC North
4 │ BUF Buffalo Bills AFC AFC East
5 │ CAR Carolina Panthers NFC NFC South
6 │ CHI Chicago Bears NFC NFC North
7 │ CIN Cincinnati Bengals AFC AFC North
8 │ CLE Cleveland Browns AFC AFC North
9 │ DAL Dallas Cowboys NFC NFC East
10 │ DEN Denver Broncos AFC AFC West Schedules & Results
games_df = @chain load_schedules() begin
subset(
:season => x -> x .== 2024,
:game_type => x -> x .== "REG",
)
end;
first(games_df, 5)5×46 DataFrame
Row │ game_id season game_type week gameday weekday gametime away_team away_score home_team home_score location result total overtime old_game_id gsis nfl_detail_id pfr pff espn ftn away_rest home_rest away_moneyline home_moneyline spread_line away_spread_odds home_spread_odds total_line under_odds over_odds div_game roof surface temp wind away_qb_id home_qb_id away_qb_name home_qb_name away_coach home_coach referee stadium_id stadium
│ String Int64 String Int64 Date String Time? String Int64? String Int64? String Int64? Int64? Int64? Int64 Int64? String? String Int64? Int64 Int64? Int64 Int64 Int64? Int64? Float64? Int64? Int64? Float64? Int64? Int64? Int64 String String Int64? Int64? String? String? String? String? String String String? String String
─────┼───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
1 │ 2024_01_BAL_KC 2024 REG 1 2024-09-05 Thursday 20:20:00 BAL 20 KC 27 Home 7 47 0 2024090500 59508 missing 202409050kan missing 401671789 6449 7 7 124 -148 3.0 -118 -102 46.0 -110 -110 0 outdoors grass 67 8 00-0034796 00-0033873 Lamar Jackson Patrick Mahomes John Harbaugh Andy Reid Shawn Hochuli KAN00 GEHA Field at Arrowhead Stadium
2 │ 2024_01_GB_PHI 2024 REG 1 2024-09-06 Friday 20:15:00 GB 29 PHI 34 Neutral 5 63 0 2024090600 59509 missing 202409060phi missing 401671805 6450 7 7 110 -130 2.0 -110 -110 49.5 -112 -108 0 outdoors missing missing 00-0036264 00-0036389 Jordan Love Jalen Hurts Matt LaFleur Nick Sirianni Ron Torbert SAO00 Arena Corinthians
3 │ 2024_01_PIT_ATL 2024 REG 1 2024-09-08 Sunday 13:00:00 PIT 18 ATL 10 Home -8 28 0 2024090800 59510 missing 202409080atl missing 401671744 6451 7 7 160 -192 4.0 -110 -110 43.0 -115 -105 0 closed fieldturf missing missing 00-0036945 00-0029604 Justin Fields Kirk Cousins Mike Tomlin Raheem Morris Brad Rogers ATL97 Mercedes-Benz Stadium
4 │ 2024_01_ARI_BUF 2024 REG 1 2024-09-08 Sunday 13:00:00 ARI 28 BUF 34 Home 6 62 0 2024090801 59511 missing 202409080buf missing 401671617 6452 7 7 250 -310 6.5 -105 -115 46.0 -112 -108 0 outdoors a_turf 61 20 00-0035228 00-0034857 Kyler Murray Josh Allen Jonathan Gannon Sean McDermott Tra Blake BUF00 New Era Field
5 │ 2024_01_TEN_CHI 2024 REG 1 2024-09-08 Sunday 13:00:00 TEN 17 CHI 24 Home 7 41 0 2024090802 59512 missing 202409080chi missing 401671719 6453 7 7 164 -198 4.0 -108 -112 43.0 -110 -110 0 outdoors grass 67 8 00-0039152 00-0039918 Will Levis Caleb Williams Brian Callahan Matt Eberflus Shawn Smith CHI98 Soldier Field nrow(games_df)272
extrema(games_df[!, :week])(1, 18)
272 games are included over 18 weeks of football.
ismissing.(games_df[!, :result]) |> sum0
Results are available for every game.
Cleaning Data
For conference standing purposes, I would consider games_df to be a "wide" format.[3] We have one row per game; we want one row per team. We can obtain a "long" format by concatenating a "home" dataframe and an "away" dataframe.
Conference & Division Indicators
To compute conference (division) records, we need to know whether both teams are in the same conference (division). We join with team_df to add the conference and division fields, and compute the indicators with transform.
games_df = @chain games_df begin
leftjoin(
team_df,
on = :away_team => :team,
renamecols = "" => "_away",
)
leftjoin(
team_df,
on = :home_team => :team,
renamecols = "" => "_home",
)
transform(
[:conf_home, :conf_away] => ((h,a) -> h .== a) => :is_conf,
[:division_home, :division_away] => ((h,a) -> h .== a) => :is_division,
)
end;
Home & Away Interpretations
We have to do two things to interpret the data correctly:
home_scoreandaway_scoreneed to be converted intopoints_forandpoints_against.result—which ishome_score - away_score—requires some logic to become W/L/T indicators.
Most of the other fields are simply passed through the select.
away_df = @chain games_df begin
select(
:away_team => :team,
:away_team => (x -> false) => :is_home,
:away_score => :points_for,
:home_score => :points_against,
:result => (x -> x .< 0) => :is_win,
:result => (x -> x .> 0) => :is_loss,
:result => (x -> x .== 0) => :is_tie,
:is_conf,
:is_division,
)
end;
home_df = @chain games_df begin
select(
:home_team => :team,
:home_team => (x -> true) => :is_home,
:home_score => :points_for,
:away_score => :points_against,
:result => (x -> x .> 0) => :is_win,
:result => (x -> x .< 0) => :is_loss,
:result => (x -> x .== 0) => :is_tie,
:is_conf,
:is_division,
)
end;
df = vcat(home_df, away_df)
first(df, 5)5×9 DataFrame
Row │ team is_home points_for points_against is_win is_loss is_tie is_conf is_division
│ String Bool Int64? Int64? Bool Bool Bool Bool Bool
─────┼────────────────────────────────────────────────────────────────────────────────────────────
1 │ KC true 27 20 true false false true false
2 │ PHI true 34 29 true false false true false
3 │ ATL true 10 18 false true false false false
4 │ BUF true 34 28 true false false false false
5 │ CHI true 24 17 true false false false false
Just to make sure we've done this correctly, we verify some totals.
sum(df[!, :is_division]) == 192 # 4 teams * 3 opponents * 2 meetings * 8 divisionstrue
sum(df[!, :points_for]) == sum(df[!, :points_against])true
sum(df[!, :is_win]) == sum(df[!, :is_loss])true
sum(df[!, :is_tie]) % 2 == 0true
Aggregation
We know from the tiebreaking procedures that ties count as a half-win for each team. Since we will be repeating this calculation a number of times, it is a good candidate for a function.
function winning_percentage(wins, losses, ties)
total_wins = wins + 0.5 * ties
total_games = wins + losses + ties
return total_wins / total_games
endwinning_percentage (generic function with 1 method)
For a dense representation of the record, we can combine it into a string.
recordstr(wins, losses, ties) = "$wins - $losses - $ties"recordstr (generic function with 1 method)
We start by computing team totals.
total_df = @chain df begin
groupby(:team)
combine(
:is_win => sum => :wins,
:is_loss => sum => :losses,
:is_tie => sum => :ties,
:points_for => sum => :points_for,
:points_against => sum => :points_against,
)
transform(
[:wins, :losses, :ties] => ((w,l,t) -> winning_percentage.(w,l,t)) => :pct,
[:points_for, :points_against] => ((pf,pa) -> pf .- pa) => :net_pts,
)
end
first(sort(total_df, :pct, rev=true), 5)5×8 DataFrame
Row │ team wins losses ties points_for points_against pct net_pts
│ String Int64 Int64 Int64 Int64 Int64 Float64 Int64
─────┼─────────────────────────────────────────────────────────────────────────────
1 │ KC 15 2 0 385 326 0.882353 59
2 │ DET 15 2 0 564 342 0.882353 222
3 │ PHI 14 3 0 463 303 0.823529 160
4 │ MIN 14 3 0 432 332 0.823529 100
5 │ BUF 13 4 0 525 368 0.764706 157
Then we compute the home, away, conference, and division records.
location_df = @chain df begin
groupby([:team, :is_home])
combine(
:is_win => sum => :wins,
:is_loss => sum => :losses,
:is_tie => sum => :ties,
)
transform(
[:wins, :losses, :ties] => ((w,l,t) -> recordstr.(w,l,t)) => :record,
)
end;
division_df = @chain df begin
groupby([:team, :is_division])
combine(
:is_win => sum => :wins,
:is_loss => sum => :losses,
:is_tie => sum => :ties,
)
transform(
[:wins, :losses, :ties] => ((w,l,t) -> recordstr.(w, l ,t)) => :record,
[:wins, :losses, :ties] => ((w,l,t) -> winning_percentage.(w, l ,t)) => :pct,
)
end;
conf_df = @chain df begin
groupby([:team, :is_conf])
combine(
:is_win => sum => :wins,
:is_loss => sum => :losses,
:is_tie => sum => :ties,
)
transform(
[:wins, :losses, :ties] => ((w,l,t) -> recordstr.(w, l ,t)) => :record,
[:wins, :losses, :ties] => ((w,l,t) -> winning_percentage.(w, l ,t)) => :pct,
)
end;
Finally, we combine them into a single dataframe.
final_df = @chain total_df begin
leftjoin(
team_df,
on = :team,
)
leftjoin(
subset(location_df, :is_home),
on = :team,
renamecols = "" => "_home",
)
leftjoin(
subset(location_df, :is_home => ByRow(!)),
on = :team,
renamecols = "" => "_away",
)
leftjoin(
subset(division_df, :is_division),
on = :team,
renamecols = "" => "_division",
)
leftjoin(
subset(conf_df, :is_conf),
on = :team,
renamecols = "" => "_conf",
)
leftjoin(
subset(conf_df, :is_conf => ByRow(!)),
on = :team,
renamecols = "" => "_non_conf",
)
end
names(final_df)39-element Vector{String}:
"team"
"wins"
"losses"
"ties"
"points_for"
"points_against"
"pct"
"net_pts"
"name"
"conf"
"division"
"is_home_home"
"wins_home"
"losses_home"
"ties_home"
"record_home"
"is_home_away"
"wins_away"
"losses_away"
"ties_away"
"record_away"
"is_division_division"
"wins_division"
"losses_division"
"ties_division"
"record_division"
"pct_division"
"is_conf_conf"
"wins_conf"
"losses_conf"
"ties_conf"
"record_conf"
"pct_conf"
"is_conf_non_conf"
"wins_non_conf"
"losses_non_conf"
"ties_non_conf"
"record_non_conf"
"pct_non_conf"
Using this dataframe, let's show the league standings and see how they compare.
@chain final_df begin
select(
:team,
:name,
:wins,
:losses,
:ties,
:pct,
:points_for,
:points_against,
:net_pts,
)
sort([
order(:pct, rev=true),
order(:name),
])
end32×9 DataFrame
Row │ team name wins losses ties pct points_for points_against net_pts
│ String String? Int64 Int64 Int64 Float64 Int64 Int64 Int64
─────┼────────────────────────────────────────────────────────────────────────────────────────────────────
1 │ DET Detroit Lions 15 2 0 0.882353 564 342 222
2 │ KC Kansas City Chiefs 15 2 0 0.882353 385 326 59
3 │ MIN Minnesota Vikings 14 3 0 0.823529 432 332 100
4 │ PHI Philadelphia Eagles 14 3 0 0.823529 463 303 160
5 │ BUF Buffalo Bills 13 4 0 0.764706 525 368 157
6 │ BAL Baltimore Ravens 12 5 0 0.705882 518 361 157
7 │ WAS Washington Commanders 12 5 0 0.705882 485 391 94
8 │ GB Green Bay Packers 11 6 0 0.647059 460 338 122
9 │ LAC Los Angeles Chargers 11 6 0 0.647059 402 301 101
10 │ DEN Denver Broncos 10 7 0 0.588235 425 311 114
11 │ HOU Houston Texans 10 7 0 0.588235 372 372 0
12 │ LA Los Angeles Rams 10 7 0 0.588235 367 386 -19
13 │ PIT Pittsburgh Steelers 10 7 0 0.588235 380 347 33
14 │ SEA Seattle Seahawks 10 7 0 0.588235 375 368 7
15 │ TB Tampa Bay Buccaneers 10 7 0 0.588235 502 385 117
16 │ CIN Cincinnati Bengals 9 8 0 0.529412 472 434 38
17 │ ARI Arizona Cardinals 8 9 0 0.470588 400 379 21
18 │ ATL Atlanta Falcons 8 9 0 0.470588 389 423 -34
19 │ IND Indianapolis Colts 8 9 0 0.470588 377 427 -50
20 │ MIA Miami Dolphins 8 9 0 0.470588 345 364 -19
21 │ DAL Dallas Cowboys 7 10 0 0.411765 350 468 -118
22 │ SF San Francisco 49ers 6 11 0 0.352941 389 436 -47
23 │ CAR Carolina Panthers 5 12 0 0.294118 341 534 -193
24 │ CHI Chicago Bears 5 12 0 0.294118 310 370 -60
25 │ NO New Orleans Saints 5 12 0 0.294118 338 398 -60
26 │ NYJ New York Jets 5 12 0 0.294118 338 404 -66
27 │ JAX Jacksonville Jaguars 4 13 0 0.235294 320 435 -115
28 │ LV Las Vegas Raiders 4 13 0 0.235294 309 434 -125
29 │ NE New England Patriots 4 13 0 0.235294 289 417 -128
30 │ CLE Cleveland Browns 3 14 0 0.176471 258 435 -177
31 │ NYG New York Giants 3 14 0 0.176471 273 415 -142
32 │ TEN Tennessee Titans 3 14 0 0.176471 311 460 -149
We really care about the conference standings.
rank(x; rev::Bool=false) = sortperm(sortperm(x; rev))rank (generic function with 1 method)
rank_df = @chain final_df begin
groupby(:division)
transform(
:pct => (x -> rank(x; rev=true)) => :division_rank,
)
transform(
:division_rank => (x -> x .== 1) => :division_leader,
)
groupby([:conf, :division_leader])
transform(
:pct => (x -> rank(x; rev=true)) => :conference_rank,
)
transform(
[:division_leader, :conference_rank] => ByRow((l,r) -> l ? r : r+4) => :conference_rank,
)
sort(:conference_rank)
end;
AFC Standings
subset(rank_df, :conf => (x -> x .== "AFC"))16×42 DataFrame
Row │ team wins losses ties points_for points_against pct net_pts name conf division is_home_home wins_home losses_home ties_home record_home is_home_away wins_away losses_away ties_away record_away is_division_division wins_division losses_division ties_division record_division pct_division is_conf_conf wins_conf losses_conf ties_conf record_conf pct_conf is_conf_non_conf wins_non_conf losses_non_conf ties_non_conf record_non_conf pct_non_conf division_rank division_leader conference_rank
│ String Int64 Int64 Int64 Int64 Int64 Float64 Int64 String? String? String? Bool? Int64? Int64? Int64? String? Bool? Int64? Int64? Int64? String? Bool? Int64? Int64? Int64? String? Float64? Bool? Int64? Int64? Int64? String? Float64? Bool? Int64? Int64? Int64? String? Float64? Int64 Bool Int64
─────┼────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
1 │ KC 15 2 0 385 326 0.882353 59 Kansas City Chiefs AFC AFC West true 8 0 0 8 - 0 - 0 false 7 2 0 7 - 2 - 0 true 5 1 0 5 - 1 - 0 0.833333 true 10 2 0 10 - 2 - 0 0.833333 false 5 0 0 5 - 0 - 0 1.0 1 true 1
2 │ BUF 13 4 0 525 368 0.764706 157 Buffalo Bills AFC AFC East true 8 0 0 8 - 0 - 0 false 5 4 0 5 - 4 - 0 true 5 1 0 5 - 1 - 0 0.833333 true 9 3 0 9 - 3 - 0 0.75 false 4 1 0 4 - 1 - 0 0.8 1 true 2
3 │ BAL 12 5 0 518 361 0.705882 157 Baltimore Ravens AFC AFC North true 6 2 0 6 - 2 - 0 false 6 3 0 6 - 3 - 0 true 4 2 0 4 - 2 - 0 0.666667 true 8 4 0 8 - 4 - 0 0.666667 false 4 1 0 4 - 1 - 0 0.8 1 true 3
4 │ HOU 10 7 0 372 372 0.588235 0 Houston Texans AFC AFC South true 5 3 0 5 - 3 - 0 false 5 4 0 5 - 4 - 0 true 5 1 0 5 - 1 - 0 0.833333 true 8 4 0 8 - 4 - 0 0.666667 false 2 3 0 2 - 3 - 0 0.4 1 true 4
5 │ LAC 11 6 0 402 301 0.647059 101 Los Angeles Chargers AFC AFC West true 5 3 0 5 - 3 - 0 false 6 3 0 6 - 3 - 0 true 4 2 0 4 - 2 - 0 0.666667 true 8 4 0 8 - 4 - 0 0.666667 false 3 2 0 3 - 2 - 0 0.6 2 false 5
6 │ PIT 10 7 0 380 347 0.588235 33 Pittsburgh Steelers AFC AFC North true 5 3 0 5 - 3 - 0 false 5 4 0 5 - 4 - 0 true 3 3 0 3 - 3 - 0 0.5 true 7 5 0 7 - 5 - 0 0.583333 false 3 2 0 3 - 2 - 0 0.6 2 false 6
7 │ DEN 10 7 0 425 311 0.588235 114 Denver Broncos AFC AFC West true 6 2 0 6 - 2 - 0 false 4 5 0 4 - 5 - 0 true 3 3 0 3 - 3 - 0 0.5 true 6 6 0 6 - 6 - 0 0.5 false 4 1 0 4 - 1 - 0 0.8 3 false 7
8 │ CIN 9 8 0 472 434 0.529412 38 Cincinnati Bengals AFC AFC North true 3 5 0 3 - 5 - 0 false 6 3 0 6 - 3 - 0 true 3 3 0 3 - 3 - 0 0.5 true 6 6 0 6 - 6 - 0 0.5 false 3 2 0 3 - 2 - 0 0.6 3 false 8
9 │ IND 8 9 0 377 427 0.470588 -50 Indianapolis Colts AFC AFC South true 5 3 0 5 - 3 - 0 false 3 6 0 3 - 6 - 0 true 3 3 0 3 - 3 - 0 0.5 true 7 5 0 7 - 5 - 0 0.583333 false 1 4 0 1 - 4 - 0 0.2 2 false 9
10 │ MIA 8 9 0 345 364 0.470588 -19 Miami Dolphins AFC AFC East true 5 3 0 5 - 3 - 0 false 3 6 0 3 - 6 - 0 true 3 3 0 3 - 3 - 0 0.5 true 6 6 0 6 - 6 - 0 0.5 false 2 3 0 2 - 3 - 0 0.4 2 false 10
11 │ NYJ 5 12 0 338 404 0.294118 -66 New York Jets AFC AFC East true 3 5 0 3 - 5 - 0 false 2 7 0 2 - 7 - 0 true 2 4 0 2 - 4 - 0 0.333333 true 5 7 0 5 - 7 - 0 0.416667 false 0 5 0 0 - 5 - 0 0.0 3 false 11
12 │ NE 4 13 0 289 417 0.235294 -128 New England Patriots AFC AFC East true 2 6 0 2 - 6 - 0 false 2 7 0 2 - 7 - 0 true 2 4 0 2 - 4 - 0 0.333333 true 3 9 0 3 - 9 - 0 0.25 false 1 4 0 1 - 4 - 0 0.2 4 false 12
13 │ LV 4 13 0 309 434 0.235294 -125 Las Vegas Raiders AFC AFC West true 2 6 0 2 - 6 - 0 false 2 7 0 2 - 7 - 0 true 0 6 0 0 - 6 - 0 0.0 true 3 9 0 3 - 9 - 0 0.25 false 1 4 0 1 - 4 - 0 0.2 4 false 13
14 │ JAX 4 13 0 320 435 0.235294 -115 Jacksonville Jaguars AFC AFC South true 3 5 0 3 - 5 - 0 false 1 8 0 1 - 8 - 0 true 3 3 0 3 - 3 - 0 0.5 true 4 8 0 4 - 8 - 0 0.333333 false 0 5 0 0 - 5 - 0 0.0 3 false 14
15 │ CLE 3 14 0 258 435 0.176471 -177 Cleveland Browns AFC AFC North true 2 6 0 2 - 6 - 0 false 1 8 0 1 - 8 - 0 true 2 4 0 2 - 4 - 0 0.333333 true 3 9 0 3 - 9 - 0 0.25 false 0 5 0 0 - 5 - 0 0.0 4 false 15
16 │ TEN 3 14 0 311 460 0.176471 -149 Tennessee Titans AFC AFC South true 1 7 0 1 - 7 - 0 false 2 7 0 2 - 7 - 0 true 1 5 0 1 - 5 - 0 0.166667 true 3 9 0 3 - 9 - 0 0.25 false 0 5 0 0 - 5 - 0 0.0 4 false 16
This correctly assigns the playoff teams, but it requires the tiebreaker to handle the three 4-13 teams.
NFC Standings
subset(rank_df, :conf => (x -> x .== "NFC"))16×42 DataFrame
Row │ team wins losses ties points_for points_against pct net_pts name conf division is_home_home wins_home losses_home ties_home record_home is_home_away wins_away losses_away ties_away record_away is_division_division wins_division losses_division ties_division record_division pct_division is_conf_conf wins_conf losses_conf ties_conf record_conf pct_conf is_conf_non_conf wins_non_conf losses_non_conf ties_non_conf record_non_conf pct_non_conf division_rank division_leader conference_rank
│ String Int64 Int64 Int64 Int64 Int64 Float64 Int64 String? String? String? Bool? Int64? Int64? Int64? String? Bool? Int64? Int64? Int64? String? Bool? Int64? Int64? Int64? String? Float64? Bool? Int64? Int64? Int64? String? Float64? Bool? Int64? Int64? Int64? String? Float64? Int64 Bool Int64
─────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
1 │ DET 15 2 0 564 342 0.882353 222 Detroit Lions NFC NFC North true 7 2 0 7 - 2 - 0 false 8 0 0 8 - 0 - 0 true 6 0 0 6 - 0 - 0 1.0 true 11 1 0 11 - 1 - 0 0.916667 false 4 1 0 4 - 1 - 0 0.8 1 true 1
2 │ PHI 14 3 0 463 303 0.823529 160 Philadelphia Eagles NFC NFC East true 8 1 0 8 - 1 - 0 false 6 2 0 6 - 2 - 0 true 5 1 0 5 - 1 - 0 0.833333 true 9 3 0 9 - 3 - 0 0.75 false 5 0 0 5 - 0 - 0 1.0 1 true 2
3 │ SEA 10 7 0 375 368 0.588235 7 Seattle Seahawks NFC NFC West true 3 6 0 3 - 6 - 0 false 7 1 0 7 - 1 - 0 true 4 2 0 4 - 2 - 0 0.666667 true 6 6 0 6 - 6 - 0 0.5 false 4 1 0 4 - 1 - 0 0.8 1 true 3
4 │ TB 10 7 0 502 385 0.588235 117 Tampa Bay Buccaneers NFC NFC South true 5 4 0 5 - 4 - 0 false 5 3 0 5 - 3 - 0 true 4 2 0 4 - 2 - 0 0.666667 true 8 4 0 8 - 4 - 0 0.666667 false 2 3 0 2 - 3 - 0 0.4 1 true 4
5 │ MIN 14 3 0 432 332 0.823529 100 Minnesota Vikings NFC NFC North true 8 1 0 8 - 1 - 0 false 6 2 0 6 - 2 - 0 true 4 2 0 4 - 2 - 0 0.666667 true 9 3 0 9 - 3 - 0 0.75 false 5 0 0 5 - 0 - 0 1.0 2 false 5
6 │ WAS 12 5 0 485 391 0.705882 94 Washington Commanders NFC NFC East true 7 2 0 7 - 2 - 0 false 5 3 0 5 - 3 - 0 true 4 2 0 4 - 2 - 0 0.666667 true 9 3 0 9 - 3 - 0 0.75 false 3 2 0 3 - 2 - 0 0.6 2 false 6
7 │ GB 11 6 0 460 338 0.647059 122 Green Bay Packers NFC NFC North true 6 3 0 6 - 3 - 0 false 5 3 0 5 - 3 - 0 true 1 5 0 1 - 5 - 0 0.166667 true 6 6 0 6 - 6 - 0 0.5 false 5 0 0 5 - 0 - 0 1.0 3 false 7
8 │ LA 10 7 0 367 386 0.588235 -19 Los Angeles Rams NFC NFC West true 5 4 0 5 - 4 - 0 false 5 3 0 5 - 3 - 0 true 4 2 0 4 - 2 - 0 0.666667 true 6 6 0 6 - 6 - 0 0.5 false 4 1 0 4 - 1 - 0 0.8 2 false 8
9 │ ATL 8 9 0 389 423 0.470588 -34 Atlanta Falcons NFC NFC South true 4 5 0 4 - 5 - 0 false 4 4 0 4 - 4 - 0 true 4 2 0 4 - 2 - 0 0.666667 true 7 5 0 7 - 5 - 0 0.583333 false 1 4 0 1 - 4 - 0 0.2 2 false 9
10 │ ARI 8 9 0 400 379 0.470588 21 Arizona Cardinals NFC NFC West true 6 3 0 6 - 3 - 0 false 2 6 0 2 - 6 - 0 true 3 3 0 3 - 3 - 0 0.5 true 4 8 0 4 - 8 - 0 0.333333 false 4 1 0 4 - 1 - 0 0.8 3 false 10
11 │ DAL 7 10 0 350 468 0.411765 -118 Dallas Cowboys NFC NFC East true 2 7 0 2 - 7 - 0 false 5 3 0 5 - 3 - 0 true 3 3 0 3 - 3 - 0 0.5 true 5 7 0 5 - 7 - 0 0.416667 false 2 3 0 2 - 3 - 0 0.4 3 false 11
12 │ SF 6 11 0 389 436 0.352941 -47 San Francisco 49ers NFC NFC West true 4 5 0 4 - 5 - 0 false 2 6 0 2 - 6 - 0 true 1 5 0 1 - 5 - 0 0.166667 true 4 8 0 4 - 8 - 0 0.333333 false 2 3 0 2 - 3 - 0 0.4 4 false 12
13 │ CHI 5 12 0 310 370 0.294118 -60 Chicago Bears NFC NFC North true 4 5 0 4 - 5 - 0 false 1 7 0 1 - 7 - 0 true 1 5 0 1 - 5 - 0 0.166667 true 3 9 0 3 - 9 - 0 0.25 false 2 3 0 2 - 3 - 0 0.4 4 false 13
14 │ CAR 5 12 0 341 534 0.294118 -193 Carolina Panthers NFC NFC South true 3 6 0 3 - 6 - 0 false 2 6 0 2 - 6 - 0 true 2 4 0 2 - 4 - 0 0.333333 true 4 8 0 4 - 8 - 0 0.333333 false 1 4 0 1 - 4 - 0 0.2 3 false 14
15 │ NO 5 12 0 338 398 0.294118 -60 New Orleans Saints NFC NFC South true 3 6 0 3 - 6 - 0 false 2 6 0 2 - 6 - 0 true 2 4 0 2 - 4 - 0 0.333333 true 4 8 0 4 - 8 - 0 0.333333 false 1 4 0 1 - 4 - 0 0.2 4 false 15
16 │ NYG 3 14 0 273 415 0.176471 -142 New York Giants NFC NFC East true 1 8 0 1 - 8 - 0 false 2 6 0 2 - 6 - 0 true 0 6 0 0 - 6 - 0 0.0 true 1 11 0 1 - 11 - 0 0.0833333 false 2 3 0 2 - 3 - 0 0.4 4 false 16
Because we haven't coded the tiebreaker, our NFC standings lead to incorrect NFC West division winner, putting SEA in the playoffs instead of LA.
Tiebreakers
The tiebreaking algorithm requires a number of pair-wise calculations between the tied teams. It would be challenging to do an exhaustive calculation of these values and conduct a multi-column sort.[4]
As it turns out, LA and SEA have the same head-to-head record, division record, common record, and conference record. The tie is only broken with strength of victory. Implementing this correctly would require quite a bit more code than shown here. This may be addressed in a future tutorial.
| [1] | Depending on what you expect to get from this tutorial, this may be a good use case for a temporary environment. |
| [2] | This example requires using Statistics.
|
| [3] | For a detailed explanation of "wide" and "long" formats, DataFrames.jl has a section about reshaping data. |
| [4] | Up to the coin-toss, the tiebreaking algorithm could probably be structured as an alternate ordering. That is way beyond the scope of this tutorial. |
Last modified: December 04, 2025.
Website built with Franklin.jl, the pure-sm template, and the Julia programming language.