Prop Bet Pricing with Bootstrap Resampling

Before Super Bowl LVII, sportsbooks set Jalen Hurts' pass attempts at 31.5, with the over at even money. Here's how to turn his game log into a probability, how confident to be in it, and what a backtest says about the method.

Expected value in one paragraph

A betting line is a price, and a price implies a probability. At +100 you risk $100 to win $100, so you break even if the bet wins 50% of the time. At −120 you risk $120 to win $100 and need to win 54.5%. A bet is positive expected value (+EV) when your estimate of the true probability is higher than the break-even implied by the price. +EV doesn't mean a bet will win. It means that if you made the same bet many times, you'd come out ahead.

So pricing a prop comes down to estimating a probability. Here that's P(Hurts throws 32 or more passes) in the Super Bowl.

def implied_probability(money_line):
    """Break-even win probability for an American money line."""
    if money_line < 0:
        return -money_line / (-money_line + 100)
    return 100 / (money_line + 100)

implied_probability(+100)   # 0.50
implied_probability(-120)   # 0.545

The game log

The sample is Hurts' 33 games from 2021 and 2022, regular season and playoffs, up to (but not including) the Super Bowl. He averaged 29.8 attempts with a median of 28 and a standard deviation of 7.2. His range was wide, from 14 attempts against Detroit in 2021 to 48 against Kansas City the same year.

Figure 1: Jalen Hurts' pass attempts per game, 2021–22, with two candidate models. Bars are a density (share of games per attempt) so they share the axis with the curves.

A Poisson distribution is the textbook model for counts like pass attempts, and its one parameter, λ, is the average. But Poisson also fixes the variance to equal the mean. Hurts' variance was 1.74 times his mean. His games were spread out more than a Poisson allows, and the tails are where that matters most. More on that in the backtest.

How sure are we about his average?

Thirty-three games isn't many, and 29.8 is only an estimate of Hurts' "true" rate. Bootstrap resampling measures that uncertainty without any distributional assumptions: draw 33 games from his log with replacement, compute the mean, and repeat 10,000 times. The spread of those means shows how much the average could plausibly vary.

rng = np.random.default_rng(42)
attempts = hurts.attempts.to_numpy()          # 33 games before the Super Bowl

boot = rng.choice(attempts, size=(10_000, len(attempts)), replace=True).mean(axis=1)
lo, hi = np.quantile(boot, [0.025, 0.975])    # 27.4, 32.3
p_under_is_plus_ev = (boot < 31.5).mean()     # 0.92
Figure 2: 10,000 bootstrapped averages.

The line of 31.5 sits near the top of the interval. Only 8.4% of bootstrapped averages are above it.

From average to probability

Plug λ = 29.8 into a Poisson and P(32 or more attempts) = 36.9%. Rather than trusting that single number, we can run every plausible λ from the bootstrap through the same calculation:

Figure 3: Probability of the over as a function of Hurts' true average. The x-axis covers the middle 99% of bootstrapped averages. The over was priced at +100, implying 50%.
  • At the bottom of the 95% interval (27.4), the over hits only 21% of the time.
  • At the top (32.3), it hits 54%, and the over becomes the +EV side.
  • Across all resamples, the under is +EV 92% of the time.

Even if the under were priced at −120 (a typical price when the over is +100), the model's 63% chance of the under clears the 54.5% break-even. Other reasonable models agree: resampling his actual games directly gives 36.4% for the over, a negative binomial gives 38.7%, and a normal approximation 40.8%. Every version says the under.

What happened

Hurts threw 38 passes in Super Bowl LVII, completing 27 for 304 yards and a touchdown and adding three rushing touchdowns in a 38–35 loss to Kansas City. The over hit comfortably.

That doesn't make the under a bad bet, any more than one coin landing heads makes the coin unfair. The useful question is whether the method produces accurate probabilities, and the only way to answer that is to test it on many games.

Backtesting the method on 1,979 QB games

Historical prop lines aren't in the public data, so this backtest checks the probabilities instead of the profits. For every QB start from 2021–2024 with at least eight prior starts, I set a QB-specific line at his trailing average and at 4 and 8 attempts above and below it (alternate lines, in sportsbook terms). Then I priced each line two ways, with a Poisson fit on the mean and by resampling his prior games, and compared both to what actually happened.

Figure 4: Predicted vs. actual over rate, by how far the line is from the QB's average. 1,979 QB starts from 2021–24, priced on each QB's last two seasons of starts. The dark bar is the actual result.

The results show a clear failure mode. On a line 8 attempts above a QB's average, Poisson said the over would hit 7.5% of the time; it actually hit 11.8%, more than 50% more often. On a line 8 below, Poisson said 90.6% and reality was 79.5%. In 96% of the QB games in the sample, the quarterback's prior games varied more than Poisson assumes (median ratio 1.78). A Poisson model will keep finding "edges" on extreme lines that aren't real.

Resampling real games handles the tails far better (12.8% and 83.1%). It does lean 1–4 points toward the over at every line, likely because league-wide pass attempts were falling over this period (from 34.1 per QB start in 2020 to 31.8 in 2024) and a two-season window lags that trend.

Fixing the tails

The backtest points to a fix. If pass attempts vary more than a Poisson allows, use a distribution that has a separate variance parameter. A negative binomial is the standard choice for overdispersed counts: it has the same mean as the Poisson, but its variance can be larger. It can be fit from just a QB's sample mean and variance (the method of moments).

from scipy import stats

def nb_params(x, weights=None):
    """Mean and variance, optionally weighting recent games more."""
    w = np.ones(len(x)) if weights is None else weights
    m = np.average(x, weights=w)
    v = np.average((x - m) ** 2, weights=w) * len(x) / (len(x) - 1)
    return m, v

def p_over(line, m, v):
    if v <= m:                                   # not overdispersed: fall back to Poisson
        return 1 - stats.poisson.cdf(int(line), m)
    n, p = m * m / (v - m), m / v                # scipy's (n, p) parameterization
    return 1 - stats.nbinom.cdf(int(line), n, p)

# Recency weighting: a game 8 starts ago counts half as much as the latest one
weights = 0.5 ** (np.arange(len(prior))[::-1] / 8)
p_over(line, *nb_params(prior, weights))

I added three variants to the same backtest: a plain negative binomial, one that weights recent games more (a half-life of eight starts), and one that also shifts the expected attempts by the point spread. The spread adjustment is fit on 2015–2020 games, before the backtest window, using how much more or less favored a team is than usual. To compare methods with one number, I averaged how far each method's predicted over rate was from the actual rate across the five lines.

Figure 5: Average calibration error by method, across the five lines in Figure 4. Lower is better.
  • The negative binomial cuts Poisson's error by more than half (2.6 points vs. 5.9), about the same as resampling the games, and it has the best Brier score of the three.
  • Weighting recent games helps the most, bringing the error down to 1.8 points. Pass attempts were falling league-wide over this period, and a recency-weighted average follows that trend faster.
  • The point spread didn't help. A team favored by seven more points than usual threw only about half a pass fewer (−0.07 attempts per point), and adding it made the calibration slightly worse. Game script drives attempts, but the pregame spread predicts very little of a game's actual script.

For the Super Bowl itself none of this changes the call. The recency-weighted negative binomial puts the over at 37%, the same as the Poisson, because Hurts' recent games averaged almost exactly his overall mean.

What's left to try

  • Price against real closing lines. The final test of a betting model is whether it beats the closing price, not whether it's calibrated. That needs historical prop lines, which aren't in the public data.
  • Model the team, not just the QB. Opponent pass defense, offensive pace, and the team's pass rate over expected would all shift the mean more directly than the spread does.

Data and code

Weekly QB stats from nflverse. The line (31.5, over +100) is the pregame sportsbook price quoted in the original version of this post. The Super Bowl itself is excluded from Hurts' sample so the model only uses what was known before the game. Every number and chart comes from football-analysis/prop_bootstrap.py. Nothing here is betting advice.

← Back to all posts