EPA Metrics and Team Wins

Expected Points Added is the standard efficiency stat in football analytics. Across four seasons and 128 team-seasons, here's how well each version of it lines up with winning, and which ones can predict the rest of a season.

What EPA measures

Every play starts from a down, distance, and field position that's worth some number of expected points. 1st & 10 at your own 25 is worth about +1.1; 1st & 10 at the opponent's 25 is worth about +4.1. EPA is the change in that value caused by the play. Averaging it over a season gives one efficiency number per team, on offense (higher is better) and defense (EPA allowed, where lower is better), and it can be split into passing and rushing.

The question here is simple: which of those numbers move with wins? I built every metric for every team from 2020 through 2023 and correlated each with that season's win percentage. Ties count as half a win, and win percentage handles the switch from 16 to 17 games.

plays = pbp[(pbp.season_type == "REG") & ((pbp["pass"] == 1) | (pbp.rush == 1))]

off = plays.groupby(["season", "posteam"])
dfn = plays.groupby(["season", "defteam"])
team = pd.DataFrame({
    "off_epa":  off.epa.mean(),
    "off_pass": plays[plays["pass"] == 1].groupby(["season", "posteam"]).epa.mean(),
    "off_rush": plays[plays.rush == 1].groupby(["season", "posteam"]).epa.mean(),
    "def_epa":  dfn.epa.mean().values,  # EPA allowed: lower is better
})
team["net_epa"] = team.off_epa - team.def_epa
team = team.join(win_pct)  # from the schedule: wins + 0.5 * ties, over games played

# Flip defense so every correlation reads "higher = better"
corr = {m: team[m].corr(team.win_pct) * (-1 if m.startswith("def") else 1)
        for m in ["net_epa", "off_epa", "off_pass", "off_rush", "def_epa"]}

How strongly each metric tracks winning

Figure 1: Correlation with win percentage, 2020–2023. Pearson r across 128 team-seasons. Defensive metrics are sign-flipped so that a stronger defense counts as positive.

Three patterns stand out in Figure 1.

  • Net EPA is almost as good as the scoreboard. Point differential is the gold standard for team strength, and net EPA per play nearly matches it (0.885 vs. 0.890) without using a single point scored.
  • Passing beats rushing on both sides of the ball. Offensive pass EPA correlates at 0.73 and offensive rush EPA at 0.46. On defense the gap is wider: pass defense 0.52, run defense 0.20.
  • Offense beats defense. A team's offensive EPA tracks its record about 1.6 times as strongly as its defensive EPA (0.75 vs. 0.48).

Net EPA and win percentage

Figure 2 shows the strongest of these relationships. The fitted line implies that every +0.10 of net EPA per play is worth about 14.7 points of win percentage, or roughly 2.5 extra wins over 17 games. The distance of each dot from the line is a measure of luck: close-game record, turnover timing, and other things EPA doesn't credit.

Figure 2: Net EPA per play vs. win %, every team-season 2020–2023.

The biggest outlier is the 2022 Vikings: 13 wins with a net EPA of almost exactly zero, about 4.5 more wins than the line expects. That team went 11–0 in one-score games. They lost to the Giants in the first round of the playoffs, and the next season they won 7. At the other end, the 2020 Falcons (4 wins, −3.3) blew several fourth-quarter leads, and the 2021 Bills (11 wins) were second in the league in net EPA but won 2.5 fewer games than the line expects.

The 2023 map

Putting offense and defense on separate axes shows how each team won. San Francisco's offense was far ahead of the field. Cleveland and Baltimore had the two best defenses, and Cleveland made the playoffs despite a bottom-five offense. Six of the seven teams in the upper-right quadrant made the playoffs; New Orleans was the exception.

Figure 3: Offense vs. defense EPA per play, 2023. Playoff teams in blue.

Which metrics predict the rest of a season?

Lining up with wins is one thing. A more useful test is prediction: if all you know is a team's first half of the season, which number best predicts its second half? I split each season at its midpoint and correlated each first-half metric with the team's second-half win percentage.

Figure 4: First-half metric vs. second-half win %. 128 team-seasons split at the middle week. Defensive metrics sign-flipped.

In these four seasons, a team's first-half record (r = 0.33) was a worse predictor of its second half than its first-half net EPA (0.42) or even its offensive EPA alone (0.40). The usual explanation is that win-loss records are noisy: one-score games mostly come down to luck, and EPA filters much of that out. The next section tests whether that edge survives on seasons the analysis never saw.

Defense is a different story. First-half defensive EPA predicts the second half at only 0.17, and run defense at −0.02, which is effectively nothing. Offensive performance is a stable skill; defensive performance swings from week to week. This lines up with a long-standing finding in football analytics: build around the passing offense, and don't overpay for a defense that has played well for half a season.

Testing it on seasons the model never saw

Everything above uses 2020–2023. Since then, two more seasons have finished, which makes for a clean out-of-sample test. Before running it, though, it's worth asking how precise the original numbers are.

How precise are the correlations?

With only 128 team-seasons, every r in Figure 1 has real uncertainty. Resampling the team-seasons 5,000 times gives a 95% interval for each:

Metricr with win %95% interval
Point differential / game0.890.85 – 0.92
Net EPA / play0.890.85 – 0.92
Offense EPA / play0.750.66 – 0.83
Offense pass EPA / play0.730.63 – 0.81
Defense pass EPA / play0.520.37 – 0.64
Defense EPA / play0.480.33 – 0.61
Offense rush EPA / play0.460.33 – 0.58
Defense rush EPA / play0.200.04 – 0.36

The broad conclusions hold up well. Offensive EPA beat defensive EPA in 99.9% of resamples, and offensive pass EPA beat offensive rush EPA in all of them. Fine distinctions don't: defensive pass EPA, defensive EPA, and offensive rush EPA have overlapping intervals, so their order in Figure 1 could easily change with different seasons.

rng = np.random.default_rng(11)
boot = {m: [] for m in metrics}
for _ in range(5000):
    sample = season.iloc[rng.integers(0, len(season), len(season))]  # resample team-seasons
    for m in metrics:
        sign = -1 if m.startswith("def") else 1
        boot[m].append(sign * sample[m].corr(sample.win_pct))

ci = {m: np.quantile(v, [0.025, 0.975]) for m, v in boot.items()}
(np.array(boot["off_epa"]) > np.array(boot["def_epa"])).mean()       # 0.999

Does this season predict next season?

Pairing every team's season with its following season (2020→21 through 2024→25, 160 pairs), here's how well each metric predicts the next year's win percentage:

Metric this seasonr with this season's win %r with next season's win %
Offense EPA / play0.730.38
Net EPA / play0.890.37
Point differential / game0.900.37
Win %1.000.37
Defense EPA / play0.530.13

Across a full offseason of roster turnover, almost everything regresses to the same place: about r = 0.37. Offensive EPA alone does as well as anything. Defensive EPA keeps only a third of that (0.13), which is the most consistent result in this post: it shows up within a season and across seasons.

A real forecast: the second halves of 2024 and 2025

The final test is to build forecasting models on 2020–2023 and use them, unchanged, on 2024 and 2025. Each model sees a team's first half of the season and predicts its second-half win percentage. I measured each one by its average miss, in wins, across all 64 teams.

from sklearn.linear_model import LinearRegression

train = pd.concat([season_halves(yr) for yr in [2020, 2021, 2022, 2023]])
test = pd.concat([season_halves(yr) for yr in [2024, 2025]])

features = ["net_epa", "win_pct"]                       # first-half values
lm = LinearRegression().fit(train[features], train.win_pct_2nd)
forecast = lm.predict(test[features])

miss_in_wins = np.abs(forecast - test.win_pct_2nd) * test.games_2nd
miss_in_wins.mean()                                     # 1.59
Figure 5: Average forecast miss over the second half of the 2024 and 2025 seasons. All models were fit on 2020–2023 only. Lower is better.

Every approach beats the naive guess of a .500 finish (1.84 wins off), but the EPA models didn't beat the simplest forecast of all: assume the team keeps its first-half record (1.52 wins off, against 1.57 for net EPA). In 2024–25, first-half record correlated with the second half at r = 0.52, well above the 0.33 from 2020–23, while net EPA stayed about the same (0.43).

Is that a real reversal? A paired bootstrap over the 64 teams puts the difference between the two forecasts at +0.05 wins, with a 95% interval of −0.19 to +0.28. That's no detectable difference in either direction. Over a half-season, first-half record and first-half EPA carry about the same information. The apparent edge for EPA in 2020–23 was within the noise.

Figure 6: Net EPA + record forecast vs. actual second-half win %, every 2024 and 2025 team. The diagonal is a perfect forecast.

Even the best models miss by a lot: the typical team finishes about 1.5 wins away from its forecast over eight or nine games. The biggest misses were 2025 Arizona, Indianapolis, and Kansas City (4.1–4.7 wins off) and 2024 Chicago (4.0). Most of a half-season's outcome is simply not predictable from the first half.

Caveats

  • These are correlations, not causes. A team that's winning faces different play-calling (see Run Rate and Game Script: 49ers vs. Vikings, 2023), which feeds back into its EPA.
  • EPA per play is unadjusted for opponent strength and for garbage time. Weighting by win probability or adjusting for schedule usually raises the correlations a little.
  • The forecast test covers only two seasons (64 teams), so it can rule out a large edge for EPA over record, not a small one.

Data and code

Play-by-play and schedules from nflverse, 2020–2023 regular seasons, plus 2024–2025 for the out-of-sample tests. Plays are designed runs and dropbacks with a valid EPA. Every number and chart comes from football-analysis/wins_with_epa.py.

← Back to all posts