Skip to main content

NFL

Ten charts and tables from one season of nflverse play-by-play: team logos on a scatter, bars in team colors, a standings table, small multiples, a quarterback headshot chart, relocated franchises, an interactive plot, a field in team colors and a tier list. The data comes from the nflverse releases through sportsdataverse.nfl; sdvplot takes the abbreviations straight from it.

import matplotlib.pyplot as plt
import polars as pl
import sportsdataverse.nfl as nfl

import sdvplot

SEASON = 2025
CAPTION = f"Data: nflverse via sportsdataverse-py | {SEASON} regular season"

pbp = nfl.load_nfl_pbp([SEASON])
plays = pbp.filter(
pl.col("season_type") == "REG",
pl.col("play_type").is_in(["pass", "run"]),
pl.col("epa").is_not_null(),
)
plays.height
32941

1. Offense vs defense EPA per play​

The chart every NFL season ends with: each team's offensive EPA per play against the EPA per play its defense allowed, with the team's logo as the point. The defense axis is flipped so the good teams sit top right.

offense = plays.group_by("posteam").agg(off_epa=pl.col("epa").mean(), off_sr=pl.col("success").mean())
defense = plays.group_by("defteam").agg(def_epa=pl.col("epa").mean(), def_sr=pl.col("success").mean())
teams = offense.join(defense, left_on="posteam", right_on="defteam").rename({"posteam": "team"})

fig, ax = plt.subplots(figsize=(9, 6))
ax.axvline(teams["off_epa"].mean(), color="grey", lw=0.8, ls="--")
ax.axhline(teams["def_epa"].mean(), color="grey", lw=0.8, ls="--")
ax.scatter(teams["off_epa"], teams["def_epa"], s=0) # sets the axis limits; the logos are the marks
ax.margins(0.08)
sdvplot.add_logos(ax, teams["off_epa"], teams["def_epa"], teams["team"], league="nfl", season=SEASON, height=0.07)
ax.invert_yaxis()
for x, y, text in [(0.98, 0.98, "good offense, good defense"), (0.02, 0.02, "bad offense, bad defense")]:
ax.text(
x,
y,
text,
transform=ax.transAxes,
ha="right" if x > 0.5 else "left",
va="top" if y > 0.5 else "bottom",
color="grey",
fontsize=9,
)
ax.set(xlabel="Offense EPA per play", ylabel="Defense EPA per play allowed (better is up)")
ax.set_title(f"NFL offense vs defense, {SEASON}", loc="left", fontweight="bold")
fig.text(0.99, 0.01, CAPTION, ha="right", fontsize=8, color="grey")
plt.show()

png

2. A ranked bar chart with logos on the axis​

Offensive success rate, sorted, each bar in its team's primary color from team_colors, and axis_logos swapping the abbreviations under the bars for logos.

from matplotlib.ticker import MultipleLocator, PercentFormatter

ranked = teams.sort("off_sr", descending=True)

fig, ax = plt.subplots(figsize=(10, 5))
ax.bar(ranked["team"], ranked["off_sr"], color=sdvplot.team_colors(ranked["team"], "nfl").to_list())
ax.set_ylim(ranked["off_sr"].min() - 0.02, ranked["off_sr"].max() + 0.01)
ax.yaxis.set_major_locator(MultipleLocator(0.04))
ax.yaxis.set_major_formatter(PercentFormatter(1, decimals=0))
ax.spines[["top", "right"]].set_visible(False)
sdvplot.axis_logos(ax, "x", league="nfl", season=SEASON, height=0.06)
ax.set_title(f"Offensive success rate, {SEASON}", loc="left", fontweight="bold")
fig.text(0.99, 0.01, CAPTION, ha="right", fontsize=8, color="grey")
plt.show()

png

3. Division standings table​

Records come from the schedule (load_nfl_schedule), divisions from load_nfl_teams. great_tables draws the table; gt_sdv_logos turns the abbreviation column into logos, data_color shades the point differential and gt_theme_sdv gives it the SportsDataverse look.

from great_tables import GT

from sdvplot.great_tables import gt_sdv_logos, gt_theme_sdv

schedule = nfl.load_nfl_schedule([SEASON]).filter(pl.col("game_type") == "REG")
games = pl.concat(
[
schedule.select("week", team="home_team", pf="home_score", pa="away_score"),
schedule.select("week", team="away_team", pf="away_score", pa="home_score"),
]
)
divisions = nfl.load_nfl_teams().select(team="team_abbr", division="team_division")
names = sdvplot.teams("nfl").select("team_id", "name")

records = games.group_by("team").agg(
W=(pl.col("pf") > pl.col("pa")).sum(),
L=(pl.col("pf") < pl.col("pa")).sum(),
T=(pl.col("pf") == pl.col("pa")).sum(),
PF=pl.col("pf").sum(),
PA=pl.col("pa").sum(),
)
# resolve maps the schedule's abbreviations to sdvplot team ids, which carry the full names
standings = (
records.with_columns(
team_id=sdvplot.resolve(records["team"], "nfl"),
Diff=pl.col("PF") - pl.col("PA"),
pct=(pl.col("W") + pl.col("T") / 2) / (pl.col("W") + pl.col("L") + pl.col("T")),
)
.join(names, on="team_id")
.join(divisions, on="team")
.sort(["division", "pct", "Diff"], descending=[False, True, True])
.select("division", logo="team", Team="name", W="W", L="L", T="T", PF="PF", PA="PA", Diff="Diff")
)
limit = standings["Diff"].abs().max() # a color scale centered on zero

(
GT(standings, groupname_col="division")
.pipe(gt_sdv_logos, "logo", league="nfl", season=SEASON, height=22)
.cols_label(logo="")
.data_color(columns="Diff", palette=["#b2182b", "#f7f7f7", "#1b7837"], domain=[-limit, limit])
.tab_header(title=f"{SEASON} NFL standings", subtitle="Regular season, by division")
.tab_source_note(CAPTION)
.pipe(gt_theme_sdv, density="compact")
)

4. Small multiples by division with plotnine​

Each team's running point differential through the season, one panel per division. scale_color_sdv colors the lines by team and geom_sdv_logos puts each logo just past the end of its line. The logo layer gets its own data (the last week per team) with the division column, so plotnine draws each logo in its own panel.

from plotnine import aes, facet_wrap, geom_hline, geom_line, ggplot, labs, scale_x_continuous, theme, theme_minimal

from sdvplot.plotnine import geom_sdv_logos, scale_color_sdv

running = (
games.sort("week")
.with_columns(diff=(pl.col("pf") - pl.col("pa")).cum_sum().over("team"))
.join(divisions, on="team")
)
ends = running.group_by("team", maintain_order=True).last().with_columns(week=pl.col("week") + 1.5)

(
ggplot(running.to_pandas(), aes("week", "diff", color="team"))
+ geom_hline(yintercept=0, color="grey", size=0.3)
+ geom_line(size=0.8)
+ geom_sdv_logos(
aes("week", "diff", team="team"),
data=ends.to_pandas(),
league="nfl",
season=SEASON,
height=0.13,
inherit_aes=False,
)
+ scale_color_sdv("nfl")
+ scale_x_continuous(breaks=[1, 6, 12, 18], limits=(1, 20))
+ facet_wrap("division", ncol=4)
+ labs(x="Week", y="Point differential", title=f"Running point differential by division, {SEASON}", caption=CAPTION)
+ theme_minimal()
+ theme(figure_size=(10, 6), legend_position="none")
)

png

5. A win probability chart with a logo in the title​

nflverse's home_wp traced through Super Bowl LX. title_image sets the title with the winner's logo beside it. The fills use the teams' colors from team_colors; when two primaries are the same, one team switches to its secondary color.

from sdvplot.matplotlib import title_image

game = nfl.load_nfl_schedule([SEASON]).filter(pl.col("game_type") == "SB").row(0, named=True)
wp = pbp.filter(pl.col("game_id") == game["game_id"], pl.col("home_wp").is_not_null()).select(
minute=(3600 - pl.col("game_seconds_remaining")) / 60, away_wp=1 - pl.col("home_wp")
)
away, home = game["away_team"], game["home_team"]
away_color, home_color = sdvplot.team_colors([away, home], "nfl")
if away_color == home_color: # both teams' primary is the same navy: use the away team's second color
away_color = sdvplot.team_colors(away, "nfl", which="secondary")

fig, ax = plt.subplots(figsize=(9, 5))
ax.fill_between(
wp["minute"], 0.5, wp["away_wp"], where=wp["away_wp"] >= 0.5, color=away_color, alpha=0.8, interpolate=True
)
ax.fill_between(
wp["minute"], 0.5, wp["away_wp"], where=wp["away_wp"] < 0.5, color=home_color, alpha=0.8, interpolate=True
)
ax.plot(wp["minute"], wp["away_wp"], color="black", lw=0.8)
ax.set(xlim=(0, 60), ylim=(0, 1), xticks=[0, 15, 30, 45, 60], xlabel="Minutes played", ylabel=f"{away} win probability")
ax.axhline(0.5, color="grey", lw=0.6)
winner = away if game["away_score"] > game["home_score"] else home
title_image(
ax,
winner,
f"Super Bowl LX: {away} {game['away_score']}, {home} {game['home_score']}",
league="nfl",
season=SEASON,
height=28,
loc="left",
fontweight="bold",
)
fig.text(0.99, 0.01, "Data: nflverse via sportsdataverse-py", ha="right", fontsize=8, color="grey")
plt.show()

png

6. A quarterback leaderboard with headshots​

nflverse identifies players by gsis id (passer_player_id). add_headshots takes those ids with id_system="gsis" and looks up each player's headshot through the nflverse player table; the bars take each quarterback's team color.

qbs = (
pbp.filter(pl.col("season_type") == "REG", pl.col("passer_player_id").is_not_null(), pl.col("epa").is_not_null())
.group_by("passer_player_id")
.agg(
name=pl.col("passer_player_name").first(),
team=pl.col("posteam").last(),
plays=pl.len(),
epa=pl.col("epa").mean(),
)
.filter(pl.col("plays") >= 300)
.sort("epa", descending=True)
.head(16)
.reverse() # barh draws from the bottom up, so the leader ends on top
)

fig, ax = plt.subplots(figsize=(9, 6))
ax.barh(qbs["name"], qbs["epa"], height=0.7, color=sdvplot.team_colors(qbs["team"], "nfl").to_list())
sdvplot.add_headshots(
ax,
qbs["epa"] + 0.012,
list(range(qbs.height)),
qbs["passer_player_id"],
league="nfl",
id_system="gsis",
height=0.06,
)
ax.set_xlim(0, qbs["epa"].max() + 0.03)
ax.spines[["top", "right"]].set_visible(False)
ax.set_xlabel("EPA per dropback")
ax.set_title(f"Top 16 quarterbacks by EPA per dropback, {SEASON} (300+ dropbacks)", loc="left", fontweight="bold")
fig.text(0.99, 0.01, CAPTION, ha="right", fontsize=8, color="grey")
plt.show()

png

7. Relocated franchises and their eras​

The schedules use the abbreviation a team had that season: OAK until 2019, SD until 2016, STL until 2015. resolve with one season per row maps every era to the same franchise id, and add_logos with one season per point draws the mark the team wore that year.

history = nfl.load_nfl_schedule(list(range(2012, SEASON + 1))).filter(pl.col("game_type") == "REG")
wins = (
pl.concat(
[
history.select("season", team="home_team", win=pl.col("result") > 0),
history.select("season", team="away_team", win=pl.col("result") < 0),
]
)
.filter(pl.col("team").is_in(["OAK", "LV", "SD", "LAC", "STL", "LA"]))
.group_by("season", "team")
.agg(wins=pl.col("win").sum())
.sort("season")
)
wins = wins.with_columns(team_id=sdvplot.resolve(wins["team"], "nfl", season=wins["season"]))
wins.group_by("team_id", "team").agg(first=pl.col("season").min(), last=pl.col("season").max()).sort("team_id", "first")
team_idteamfirstlast
13OAK20122019
13LV20202025
14STL20122015
14LA20162025
24SD20122016
24LAC20172025
moves = {"13": (2020, "Las Vegas"), "24": (2017, "Los Angeles"), "14": (2016, "Los Angeles")}
fig, axes = plt.subplots(3, 1, figsize=(10, 6), sharex=True, sharey=True)
for ax, (team_id, (moved, city)) in zip(axes, moves.items(), strict=True):
rows = wins.filter(pl.col("team_id") == team_id)
ax.plot(rows["season"], rows["wins"], color=sdvplot.team_colors(team_id, "nfl"), lw=1.5)
ax.axvline(moved - 0.5, color="grey", lw=0.8, ls="--")
ax.text(moved - 0.6, 15, f"moves to {city}", fontsize=8, color="grey", va="top", ha="right")
sdvplot.add_logos(ax, rows["season"], rows["wins"], rows["team"], league="nfl", season=rows["season"], height=0.32)
ax.set_ylim(-1, 16)
ax.set_ylabel("Wins")
ax.spines[["top", "right"]].set_visible(False)
axes[0].set_title("Three relocated franchises, each season in that season's logo", loc="left", fontweight="bold")
axes[-1].set_xticks(range(2012, SEASON + 1, 2))
fig.text(0.99, 0.01, "Data: nflverse via sportsdataverse-py | regular season", ha="right", fontsize=8, color="grey")
plt.show()

png

8. An interactive Plotly scatter​

Dropback EPA against rushing EPA, with hover text. add_logos works on a Plotly figure the same way: a transparent marker trace carries the hover, and the logos are layout images. The axis ranges are set first, with some room at the edges, so no logo is cut off.

import plotly.graph_objects as go

split = plays.group_by("posteam").agg(
pass_epa=pl.col("epa").filter(pl.col("play_type") == "pass").mean(),
rush_epa=pl.col("epa").filter(pl.col("play_type") == "run").mean(),
pass_rate=(pl.col("play_type") == "pass").mean(),
)

fig = go.Figure(
go.Scatter(
x=split["rush_epa"],
y=split["pass_epa"],
mode="markers",
marker={"size": 30, "opacity": 0},
customdata=split.select("posteam", "pass_rate").rows(),
hovertemplate="%{customdata[0]}<br>pass EPA %{y:.3f}<br>rush EPA %{x:.3f}"
"<br>pass rate %{customdata[1]:.0%}<extra></extra>",
)
)


def padded(values, share=0.08): # an axis range with room for the logos at the edges
low, high = values.min(), values.max()
return [low - share * (high - low), high + share * (high - low)]


fig.update_layout(
xaxis_range=padded(split["rush_epa"]),
yaxis_range=padded(split["pass_epa"]),
title=f"Passing vs rushing EPA per play, {SEASON}",
xaxis_title="Rushing EPA per play",
yaxis_title="Dropback EPA per play",
template="plotly_white",
width=800,
height=560,
)
sdvplot.add_logos(fig, split["rush_epa"], split["pass_epa"], split["posteam"], league="nfl", season=SEASON, height=0.08)
fig

9. A field in team colors​

surface("nfl", team) draws an NFL field with sportypy, end zones in the team's colors. On top: every Seattle touchdown from scrimmage in the regular season, from the line of scrimmage to the end zone, placed by the side of the field the play went to.

import logging

import numpy as np

logging.getLogger("matplotlib.font_manager").setLevel(logging.ERROR) # sportypy asks for fonts few systems have

tds = pbp.filter(
pl.col("season_type") == "REG",
pl.col("posteam") == "SEA",
pl.col("td_team") == "SEA",
pl.col("play_type").is_in(["pass", "run"]),
).with_columns(side=pl.coalesce("pass_location", "run_location"))
lane = {"left": 15.0, "middle": 0.0, "right": -15.0}
rng = np.random.default_rng(1)

fig, ax = plt.subplots(figsize=(10, 5.5))
sdvplot.surface("nfl", "SEA", season=SEASON, ax=ax, center_logo=0.18, display_range="in_bounds_only")
for row in tds.iter_rows(named=True):
y = lane.get(row["side"], 0.0) + rng.uniform(-6, 6)
color = "#69be28" if row["play_type"] == "pass" else "#ffffff"
ax.annotate(
"",
xy=(53, y),
xytext=(50 - row["yardline_100"], y),
arrowprops={"arrowstyle": "->", "color": color, "lw": 1.4},
zorder=20,
)
ax.set_title(
f"Seattle's {tds.height} touchdowns from scrimmage, {SEASON}: green = pass, white = run",
loc="left",
fontweight="bold",
)
fig.text(0.99, 0.01, CAPTION, ha="right", fontsize=8, color="grey")
plt.show()

png

10. Team tiers​

A tier list from net EPA per play (offense minus defense), drawn by team_tiers on sdvplotR's Tiermaker theme. tier_no and team are the only columns it needs; the order within each tier comes from the data.

from sdvplot.matplotlib import team_tiers

sizes = [5, 7, 7, 7, 6] # teams per tier, top to bottom
tier_of_rank = [tier for tier, n in enumerate(sizes, start=1) for _ in range(n)]
tiers = (
teams.with_columns(net=pl.col("off_epa") - pl.col("def_epa"))
.sort("net", descending=True)
.with_columns(tier_no=pl.Series(tier_of_rank))
.select("tier_no", "team")
)

fig = team_tiers(
tiers,
"nfl",
title=f"NFL tiers by net EPA per play, {SEASON}",
subtitle="offense EPA/play minus defense EPA/play allowed",
caption=CAPTION,
tier_desc={1: "Contenders", 2: "Good", 3: "Middle", 4: "Flawed", 5: "Rebuilding"},
)
plt.show()

png

Run it yourself​

Download the notebook (outputs cleared) or open it on GitHub.