After my last breakdown of NFL metrics, sharp readers asked the obvious follow-up: “Great, but where do I actually find these numbers online?”
The answer is both frustrating and exciting. Frustrating, because some of the really juicy stats (coverage busts, blown assignments) are either proprietary or locked behind a paywall. Exciting, because you don’t actually need those subscriptions to start generating meaningful edges of your own. With the open-source tools available today, you can build solid proxies for almost everything I described, and do it for free.
What follows is a guide not only to where the data lives, but also how to get it into your own hands with Python. I’ll walk you through the main sources, show you exactly how to load them, and then demonstrate how to re-create (or approximate) the advanced metrics that sharp bettors use to gain an edge.
If you’re new to coding and want to dip your toes into the water, I suggest you start with a Colab or Jupyter notebook, paste each snippet of code into a new cell, and run the code.
The Free Data Universe You Have Access To
Let’s start with what’s free and reproducible:
Play-by-Play Data (PBP): Every snap since 1999, with outcomes, EPA (expected points added), win probability, air yards, sacks, turnovers, kick returns, and more. This is your bread and butter.
Next Gen Stats Aggregates (NGS): Weekly player stats, including average time to throw, air yards, yards after catch, separation, and rushing data. Not frame-by-frame tracking, but enough to power interesting models.
FTN Charting Data: Supplemental fields like whether a play was a screen, play-action, blitz, or pressured dropback.
Schedules with Vegas Lines: Historical and upcoming spreads, totals, and moneylines, courtesy of Lee Sharpe’s dataset.
These sources are all baked into the nflreadpy
library — the modern Python interface to the nflverse data.
Step One: Load the Toolbox
If you can run Python, you can run this:
pip install nflreadpy pandas
Then pull in the key datasets:
import pandas as pd
import nflreadpy as nfl
pbp = nfl.load_pbp([2024, 2025]).to_pandas()
ngs_qb = nfl.load_nextgen_stats([2024, 2025], stat_type=”passing”).to_pandas()
ftn = nfl.load_ftn_charting([2024, 2025]).to_pandas()
sched = nfl.load_schedules([2024, 2025]).to_pandas()
plays = pbp[(pbp[”pass_attempt”]==1)|(pbp[”rush_attempt”]==1)].copy()
plays[”success”] = plays[”epa”] > 0
That’s it, you’ve just imported millions of rows of NFL data. Everything that follows builds on these simple imports.
SUSG: Scripted vs. Unscripted Success
One of my favorite hidden indicators is SUSG, the drop-off between scripted plays (the first 15–20 a team scripts before kickoff) and everything else. The box score doesn’t tell you this story, but the play-by-play does.
Teams with high scripted success but steep drop-offs are vulnerable to second-half fades. That’s money for live betting, halftime spreads, or second-half totals.
def susg(df, n_scripted=15):
df = df.sort_values([”game_id”, “play_id”])
df[”off_play_idx”] = df.groupby([”game_id”,”posteam”]).cumcount() + 1
scripted = df[df[”off_play_idx”] <= n_scripted]
unscripted = df[df[”off_play_idx”] > n_scripted]
by = [”season”,”posteam”]
s_sr = scripted.groupby(by)[”success”].mean().rename(”sr_scripted”)
u_sr = unscripted.groupby(by)[”success”].mean().rename(”sr_unscripted”)
out = pd.concat([s_sr, u_sr], axis=1).dropna()
out[”SUSG”] = out[”sr_scripted”] - out[”sr_unscripted”]
return out.reset_index()
susg_table = susg(plays)
print(susg_table)
Run that, and you’ll see in black-and-white which teams are built on scripts versus real adaptability.
Third & Fourth Down Efficiency
A team can be “average” on paper yet terrifying when the game is on the line. Third and fourth downs are leverage points, and efficiency here is sticky enough to matter but volatile enough to identify regression.
money = plays[plays[”down”].isin([3,4])]
money_summary = money.groupby([”season”,”posteam”]).agg(
plays=(”play_id”,”count”),
epa_per_play=(”epa”,”mean”),
success_rate=(”success”,”mean”)
).reset_index()
print(money_summary)
You’ll immediately see teams that look unstoppable but only because they’ve been living on third-down miracles. When those dry up, the market often lags behind.
PRWR-TTTΔ: A Free Proxy
Pass Rush Win Rate (PRWR) is ESPN’s baby. But you can build a proxy: defensive pressure rate (sacks + QB hits per dropback) versus the quarterback’s average time to throw (from NGS).
When a defense that creates pressure meets a QB with a slow release, it’s hunting season.
dropbacks = pbp[pbp[”qb_dropback”]==1].copy()
dropbacks[”pressure”] = ((dropbacks[”sack”]==1)|(dropbacks[”qb_hit”]==1)).astype(int)
def_press = dropbacks.groupby(”defteam”)[”pressure”].mean()
ttt = ngs_qb.groupby(”team_abbr”)[”avg_time_to_throw”].mean()
pressure_vs_ttt = def_press.reset_index().merge(
ttt.reset_index(), left_on=”defteam”, right_on=”team_abbr”
)
print(pressure_vs_ttt.corr)
This won’t give you ESPN’s block-level granularity, but it will give you a very functional way to spot mismatch edges.
A note on pressure data: In the play-by-play feed, sacks are consistently tracked, but the qb_hit
flag isn’t always fully populated. That means when we define pressure as “sack OR QB hit,” we’re getting a reasonable proxy, but it will still undercount compared to true charting data from ESPN or PFF. For our purposes, spotting mismatches between fast pass rushes and slow-trigger QBs, this proxy is good enough, but just keep in mind that “pressure rate” in public data is always a little muted.
PTCR: Pressure-to-Turnover Conversion
It’s not pressure that wins games; it’s turnovers. Some defenses are unlucky: tons of pressure, few turnovers. Others are riding hot streaks where every hurry becomes a pick. Tracking PTCR shows you who’s due.
dropbacks[”turnover”] = ((dropbacks[”interception”]==1)|(dropbacks[”fumble_lost”]==1)).astype(int)
ptcr = dropbacks.groupby(”defteam”).agg(
pressures=(”pressure”,”sum”),
tos=(”turnover”,”sum”)
)
ptcr[”PTCR”] = ptcr[”tos”]/ptcr[”pressures”]
print(ptcr.sort_values(”PTCR”,ascending=False))
When PTCR runs hot, regression looms. When it runs cold, defenses are often primed to explode.
Explosive Play Rate Over Expected (xEPR)
Explosives win games but they’re also noisy. Some teams get lucky on busted coverages, others are inches away from breakouts. To separate skill from variance, model “expected” explosives based on down, distance, and field position.
plays[”explosive”] = (
((plays[”rush_attempt”]==1)&(plays[”yards_gained”]>=15)) |
((plays[”pass_attempt”]==1)&(plays[”yards_gained”]>=20))
).astype(int)
ctx = plays.dropna(subset=[”down”,”ydstogo”,”yardline_100”]).copy()
ctx[”ytg_bin”] = pd.cut(ctx[”ydstogo”], bins=[0,3,6,10,100], labels=[”1-3”,”4-6”,”7-10”,”11+”])
ctx[”fld_bin”] = pd.cut(ctx[”yardline_100”], bins=[0,20,40,60,80,100], labels=[”red”,”40-21”,”60-41”,”80-61”,”backed”])
exp_tbl = ctx.groupby([”season”,”down”,”ytg_bin”,”fld_bin”])[”explosive”].mean().reset_index()
print(exp_tbl)
Subtract expected from actual, and you’ve got xEPR. Teams living above expectation regress. Teams living below it break out.
Pressure on Deep Throws (15+ air yards)
We’ll look at QB hits on deep attempts (hits are recorded even on completions/incompletions; sacks aren’t attempts so we exclude them here).
deep = plays[(plays[”pass_attempt”] == 1) & (plays[”air_yards”] >= 15)].copy()
deep_def = deep.groupby([”season”,”defteam”]).agg(
attempts=(”play_id”,”count”),
qb_hits=(”qb_hit”,”sum”)
).reset_index()
deep_def[”hit_rate_on_deep”] = deep_def[”qb_hits”] / deep_def[”attempts”]
deep_def.sort_values(”hit_rate_on_deep”, ascending=False).head(10)
Kick Return Yards Over Expected (KRYOE)
In the new kickoff environment, hidden yards are back. A short-field offense is a touchdown waiting to happen. Here’s a basic version: compare actual return yards to the average return at that kickoff distance.
kos = pbp[(pbp[”kickoff_attempt”]==1)&(pbp[”return_yards”].notna())].copy()
kos[”dist_bin”] = pd.cut(kos[”kick_distance”], bins=[0,50,60,70,80,120])
exp_by_bin = kos.groupby(”dist_bin”)[”return_yards”].mean().rename(”exp_ret”).reset_index()
kos = kos.merge(exp_by_bin, on=”dist_bin”)
kos[”ryoe”] = kos[”return_yards”] - kos[”exp_ret”]
kryoe = kos.groupby(”return_team”)[”ryoe”].mean().reset_index()
print(kryoe)
Averages here swing small, but when they stack, field position drives totals and ATS covers.
The Frontier Metrics
Before we close, it’s worth circling back to a few of the categories from my original breakdown that you won’t find neatly wrapped inside nflreadpy
. They sit on the frontier, possible, but only if you’re willing to go further than downloading a dataset and running a groupby.
Adjusted Coverage Bust Rate (ACBR)
Coverage busts never show up in a box score, and they don’t live in public play-by-play either. True ACBR requires charting blown assignments from film or working with coverage assignment data. A DIY version could approximate busts by tagging wide-open completions (say, 5+ yards of separation at catch) or combining route depth with coverage shell guesses. It’s crude, but if you’re logging live feeds and game film, you can begin to approximate it.
Explosive Yards After Contact (EYAC)
Play-by-play tells you yards after the catch but not when contact occurred. To calculate EYAC, you’d need to know the contact point, something recorded in PFF charting or potentially trackable from player-coordinate feeds. With live tagging, you could build a version by noting first contact and measuring how often those plays go 10+ yards afterward.
Next Gen Coverage Responsibility (NGCR)
This is the toughest of the bunch. Determining who should have been responsible for a route isn’t about proximity; it’s about leverage, scheme, and disguise. To even scratch at NGCR, you’d need access to tracking coordinates for all 22 players, then cluster defenders based on coverage shells. It’s not impossible, especially if you experiment with live-tracking feeds, but it’s far beyond what’s in the open nflverse stack today.
Why This Matters
The point isn’t that you should feel discouraged because you don’t have every advanced stat on day one. It’s that there’s a ladder to climb. The early rungs, things like success rate splits, pressure-to-turnover ratios, and explosive play differentials, already expose plenty of market inefficiencies. If your goal is to build disciplined, durable betting models, those public metrics alone can carry real weight.
But if you ever feel the itch to go deeper, to chart, to tag, to build live feeds into your workflow, then ACBR, EYAC, and NGCR represent the next layer. They’re where pro teams and sportsbooks spend their time, and they’re also where the adventurous data scientist in you can start experimenting.
At the end of the day, the edge isn’t about mimicking someone else’s model. It’s about seeing where you can climb one rung higher than the next bettor. And whether that rung is as simple as tracking SUSG yourself or as ambitious as building a DIY coverage-bust model from live feeds, the climb is yours to make.
A lot of writers enjoy two things - bragging about themselves and acting like they are some mystical oracles that enjoy more agency than the rest. Looking at ArielCalista.com I can see that you have more to brag about than most any group of writers together and that you continue to deliver health and happiness outside your wagering analytics in untold health verticals. Your concern for life and your humility makes you even far more attractive than your looks and gym workouts. Many of us have followed you for 3 years and more and now we see that tech has moved forward enough for you to make good on your promise to raise the hood and see what the car runs on. This is an amazing first step - thank you. A lot of us have benefited from your betting advice or leadership, incredibly so. You've never charged us a penny for all the knowledge and information. With all your Illuminated themed books, I have to think not only are you the genuine article, you are a true IlluminAri.