NFL QB Analysis with nflfastR
Passer rating has a maximum of 158.3 for reasons nobody can explain without a diagram. I wanted a quarterback stat I could actually justify, so I went and computed one from every pass of the 2025 season.
Here is my problem with the standard quarterback numbers. Completion percentage rewards checking down. Passing yards reward playing from behind. Touchdowns depend enormously on whether your team gets to the red zone. Passer rating combines four of these into a formula with a hard ceiling of 158.3 and coefficients that were picked in 1973.
None of them answer the question I actually care about: when this guy drops back, is his team better off?
Expected points, and why EPA answers that
The idea behind expected points is simple enough to explain at dinner. Look at every time in NFL history a team has had, say, 2nd and 7 from their own 31-yard line. Count how many points they ended up scoring on that drive, on average. That average is the expected points of that situation.
Now run a play. You are in a new situation with a new expected-points value. The difference between the two is EPA — expected points added. A 6-yard completion on 3rd and 5 is worth a lot. A 6-yard completion on 3rd and 12 is worth almost nothing, and both show up identically in a box score.
nflfastR computes this for every play and hands it to you in a
column. Getting a full season is genuinely three lines:
library(nflfastR)
pbp <- load_pbp(2025)
nrow(pbp)
#> [1] 48771
48,771 plays across 285 games — 272 regular season plus 13 playoff. It downloads in a few seconds because nflfastR is reading a prebuilt release, not scraping anything.
Filtering down to plays that mean something
Not every row is a real dropback. Two-point conversions have no
down value. Spikes are intentional incompletions to stop the
clock. Leaving either in drags every quarterback toward zero, because a spike
is guaranteed negative EPA and nobody threw it trying to complete it.
That leaves 19,639 dropbacks from 36 quarterbacks with at least 200 attempts. Below 200 the rate stats are mostly noise — a backup who threw 40 passes in garbage time can post numbers that look like an MVP.
The bug that cost me four points of completion percentage
My first version had Jordan Love completing 62.3% of his passes. I looked at that for a while before it bothered me enough to check. Nobody good completes 62% anymore; the league average is around 65%.
The problem is a distinction I did not know existed. A sack is a
pass play, but it is not a pass attempt. It doesn't count in the
denominator of completion percentage, and it doesn't count as an attempt in
any official stat line. But play_type == "pass" includes sacks,
so I was dividing completions by dropbacks:
att = n(), # this counts sacks
comp_pct = comp / att
dropbacks = n(),
sacks = sum(sack, na.rm = TRUE),
att = dropbacks - sacks, # true attempts
comp_pct = comp / att
Love went from 62.3% to 65.1%. The whole distribution moved into a believable range — 56.6% at the bottom, 70.0% at the top, median 64.5%.
I found this by squinting at one number. What I should have done — and what the script does now — is assert that the league median completion percentage lands somewhere near 65%. A number I can look up in ten seconds is a better test than my own judgement about whether output looks right.
Worth noting: EPA per play was unaffected, because sacks should count against a quarterback there. Getting sacked genuinely loses expected points. The two metrics want different denominators, and that is the entire lesson.
What the 2025 season actually looked like
The two measures agree more than I expected, but not completely, and the disagreements are the interesting part.
Jordan Love led the league at 0.250 EPA per play with a CPOE of +5.3 — accurate and productive, the combination you want. Matthew Stafford is the outlier that makes the chart worth looking at: second in EPA at 0.221, but a CPOE of just +0.7. He completed passes at almost exactly the expected rate for the throws he attempted, and still produced enormous value, because the throws he attempted were hard and deep. 52 touchdowns against 9 interceptions on 712 attempts.
The opposite corner is where accuracy doesn't translate. Drake Maye posted the best CPOE in the league at +8.0 — genuinely the most accurate passer relative to difficulty — and finished third in EPA rather than first, because a lot of that accuracy came on shorter throws.
The spread is smaller than I assumed. The gap from first to twelfth is about 0.16 expected points per play. Over 550 dropbacks that is roughly 88 points across a season, which is real, but it is not the chasm that the way people talk about quarterbacks implies.
What EPA doesn't tell you
I want to be careful here, because it would be easy to finish this and act like I've solved quarterback evaluation.
EPA is credited entirely to the passer, and a pass involves at least three other people: whoever blocked, whoever ran the route, and whoever called the play. A quarterback throwing behind a great line to great receivers will post a better EPA than the same quarterback somewhere worse. The metric cannot separate them, and neither can I.
It also has nothing to say about scrambles — I filtered to
play_type == "pass", so a quarterback who takes off and gains 12
yards gets no credit at all in these numbers. For someone like Jalen Hurts
that's a meaningful chunk of value that this analysis simply throws away.
So it is better than passer rating, and it is not the answer. It is one honest number, computed the same way for everybody, that I can explain from first principles. That is genuinely all I was after.