/cdn.vox-cdn.com/uploads/chorus_image/image/71932709/1246656411.0.jpg)
Super Bowl Player Prop Analysis in Python
Introduction to sports betting and analyzing player props in Python. In this post, we look at this upcoming Super Bowl and analyze whether to take the over or under on Jalen Hurts 31.5 pass attempts (+100).
Player Props with Python
This post is the start of a series of posts (hopefully) on probability theory and the associated tools we have at our disposal in Python to apply to sports betting. We are starting this post a bit late in the NFL season (there's literally one game left in the season, and it's 2 days from now), but we're hoping to also come out with betting content related to the NBA this season, so stay tuned and join our mailing list for updates on that.
In previous content, we haven't focused too much on either betting or probability theory. That's partly because I never really bet before, and I generally stuck to what I knew which is traditional, redraft Fantasy Football. That all changed when I hit 2 parlays two weekends in a row this NFL playoffs (took Bengals over Bills money line and SF to cover the spread, then took KC money line and Eagles to cover the spread) and turned 25 dollars in 575. Not bragging since I probably just rewired my brain circuitry to never enjoy another NFL game again without a having a little action on (gamble at your own risk, please). I got the itch now, so to speak, which is half the reason I'm writing a lengthy post on doing this stuff in Python (and also planning on releasing a whole course).
The focus of this post will be on examining a potentially profitable bet for this year's Super Bowl. We will do so by teaching you a little bit about betting, probability, and pandas, numpy, matplotlib to help us make a decision on our chosen player prop.
Also, before we begin - none of this is financial advice. Bet at your own risk, and only with money you are absolutely willing to lose.
How to Think Like a Profitable Bettor
Our rule will be that if the probability we calculate of an outcome occurring is greater than the implied probability from the money line, then we take that bet. That should, in theory, make the bet positive expected value (or EV, for short).
It's worthy to note that just because we take a positive EV bet, doesn't make it likely to hit. We could take a positive EV bet where the edge is 1%, that is the calculated probability we get from our analysis is 44% but the book is giving us 43% odds. There's still a 56% chance we lose our money, or more likely than not. It's positive EV because that 1% profit margin will be realized over time, or over a series of many bets. The essence of having an edge in any probabilistic endeavour like sports betting is that the edge is unfolded over time, but there is of course an element of randomness that prevents a bettor from having strong predictability around individual occurrences (ie individual bets).
I personally learned this way of thinking through trading and investing, and it's exactly this mindset that's taught to successful investors. It applies equally here in sports betting just as well. The difference though is that it's actually easier to think this way in sports betting, because your R-factor, or reward-to-risk ratio, is already explicitly set by the money line, whereas in investing these things are more fluid and you must set the R-factor yourself.
Remember that we are implying the probabilities from the lines, but what the line is explicitly telling us is our reward-to-risk ratio (for each unit of risk, how much do we win?). If we have a bet that's +200, that's a 2 to 1 payout, and a 33% implied probability (100/(200+100)). 33% is also our breakeven point on any 2-1 bet, ever, no matter the endeavor. Which means, if we consistently took +200 bets, we would need a 33% win rate to breakeven. If we are able to push our win rate above 33%, even to say 35%, then we've developed an edge and positive EV.
This also means, that by definition, just taking sports bets at random, your EV is 0 and you are expected to lose no money and gain no money assuming Vegas is right over the long run (it's not a bad assumption to make).
This example assumes you use proper bet sizing and money management to not irresponsibly increase your risk of ruin, and also assumes no transaction costs which would push your EV below 0 (there's always transaction costs, so our perfect world example does fail).
This is also on average. Depending on how conservatively you bet, you may just lose all of your money just off bad luck (variance). For this reason, only ever bet money you are willing to lose.
In investing, literally no one will set that fixed payout structure for you, which can be the most challenging part of proper risk management, both intellectually and psychologically.
We have a head start when coming to sports betting, and we should probably take advantage of that fact (Along with other advantages, including the fact that sports betting markets are significantly less efficient than global financial markets).
Super Bowl Lines
We'll only be covering a single player prop here, since this post will end up being quite long. We'll be looking at the O/U for Jalen Hurts pass attempts, which is currently +100 for over 31.5 attempts on DraftKings. However, you're welcome to also apply this code to other player props that might be well-modeled by the distribution we'll discuss (Poisson), or even other types of props that may be well-modeled by different distributions (ie. Anytime TD scorer and a Binomial distribution).
Into the Code
We're going to be pulling our data from nfl-data-py , which conveniently provides weekly data alongside play-by-play data. We'll be pulling weekly stat data for Jalen Hurts, then processing and formatting their data to fit a distribution and estimate calculated probabilities.
We'll go into the theory as we write the code, since we've already talked a good amount of theory so far. Below, we pip install nfl-data-py , and then import the libraries we'll be using in this notebook.
%%capture
%pip install nfl-data-py
import nfl_data_py as nfl
import numpy as np
from matplotlib import pyplot as plt
import seaborn as sns
import warnings; warnings.simplefilter('ignore')
random = np.random.default_rng(42)
plt.style.use('ggplot')
We've pulled the lines below from Draftkings. You're welcome to use another book, or several books, but we're using only one here for the sake of simplicity. We'll set these lines and over/unders to some variables we can reference later in our code.
hurts_pa_ou = 31.5
hurts_pa_line = +100 # for the over
Let's start with something simple: the code block defines a function named implied_probability that takes in two parameters: money_line and round_n .
The money_line parameter is a betting line that represents the odds of a particular outcome happening in a sports event. A negative money line means the outcome is favored to happen, while a positive money line means the outcome is an underdog.
The round_n parameter is the number of decimal places to which the result of the calculation should be rounded. The default value of this parameter is 2, if it is not provided as an argument.
The function uses an if-else statement to determine the implied probability based on the value of the money_line parameter:
def implied_probability(money_line, round_n=2):
if money_line < 0:
return round(money_line / (money_line - 100), round_n)
else:
return round(1 - (money_line / (money_line + 100)), round_n)
ip_x = implied_probability(hurts_pa_line)
print(f'Implied probability of over {hurts_pa_ou} pass attempts for Hurts in the Super Bowl is: ', ip_x)
Implied probability of over 31.5 pass attempts for Hurts in the Super Bowl is: 0.5
Next, let's import weekly data for Hurts from nfl-data-py. We'll grab the attempts column from his DataFrame, and convert it to an array using the values attribute.
#load our data for hurts
weekly_data = nfl.import_weekly_data(years=range(2021, 2023), columns=['player_name', 'attempts'])
hurts_pa = weekly_data.loc[weekly_data['player_name'] == 'J.Hurts'].attempts.values
hurts_pa
Downcasting floats.
array([35, 23, 39, 48, 37, 26, 34, 14, 17, 23, 24, 31, 26, 29, 26, 43, 32,
31, 35, 25, 36, 25, 28, 27, 26, 25, 28, 39, 31, 37, 35, 24, 25])
plt.hist(hurts_pa, bins=25);
print('Sample size: ', len(hurts_pa))
print('Mean: ', hurts_pa.mean())
print('Median: ', np.median(hurts_pa))
print('Standard deviation: ', hurts_pa.std())
Sample size: 33
Mean: 29.818181818181817
Median: 28.0
Standard deviation: 7.098674846265517
Bootstrap Resampling
Here, we plotted Hurts passing attempt data and printed out some summary statistics to get a better sense of how his pass attempts are distributed. We're going to be mostly interested in the mean here.
In a moment, you'll see where we're going with all this, but essentially we are in the process of trying to get a sense of what probability distribution would best model the data-generating process for pass attempts and how well the the sample's summary statistics represent the potential population.
Generally speaking, when dealing with football data, we're going to have to be a bit creative as we don't always have the largest sample sizes. The larger our sample size, the more certain we can be that whatever statistics we pull from the sample represent the population.
Let's try doing some "bootstrap resampling" to find a confidence interval for what the mean might be (which will end up being the sole parameter we'll use for our distribution we'll fit to the data).
Bootstrap resampling is essentially a stats technique which name comes from the phrase "pulling yourself up by the bootstraps". Essentially it used in situations where you'd like to gather more samples to better estimate a statistic, like the mean, but don't have the means (no pun intended) to do so. Usually this is done in cases where repeating trials for a sample would be timely or costly, and so bootstrap resampling is the "economic" choice.
In our case, we'd have to just wait for Hurts to keep playing games for us to collect more samples, so we have no choice but to bootstrap resample.
Instead, we can draw samples from our sample (which can be thought of as Hurts "range of outcomes" for pass attempts) with replacement n times, where n is equal to length of our original data (which can be thought of as the number of samples of actual pass attempts available to us at this point in his career). Note, with replacement means that when we draw samples we allow duplicates. We then take whatever statistic we are trying to estimate on our new bootstrapped dataset, in this case the mean, and set it away in a separate array (xs below), and then examine the dataset for that summary statistic once we have completed a number of rounds.
If you want to learn more about bootstrapping, I'd suggest this article here.
# sampling with replacement first
xs = np.array([])
n_simulations = 10_000
for _ in range(n_simulations):
boot_x = np.random.choice(hurts_pa, size=len(hurts_pa), replace=True).mean()
xs = np.append(xs, boot_x)
xs
array([29.63636364, 31.42424242, 29.48484848, ..., 31.39393939,
29.42424242, 31.63636364])
sns.distplot(xs);
plt.gca().vlines(x=hurts_pa_ou, ymin=0, ymax=0.5, color='blue');
plt.gcf().legend(['Bootstrapped means', 'Hurts Pass Attempt O/U']);
#saving this for later
kde_data_x, kde_data_y = plt.gca().lines[0].get_data()
This graph shows us the distribution of bootstrapped means from random re-sampling of Jalen Hurts career. As you can see, there is a small chance that Jalen Hurts long-run pass attempt volume could be as high as 34 and as low as 24. It is highly unlikely that is the case based on what we have seen from Jalen Hurts, but it is not impossible. It is more likely that Jalen Hurts "true" long-run average is close to his current career average of 29 and the distribution reflects that. Also, notice that we are saving the data used to plot these lines as kde_data_x and kde_data_y . This will help us later visualize where along this distribution an under bet (or over bet, no spoilers) is +EV and -EV.
lower_bound, upper_bound = np.quantile(xs, 0.025), np.quantile(xs, 0.975)
lower_bound, upper_bound
(27.484848484848484, 32.24318181818181)
We can be fairly certain that Hurts long-run passing attempt average lies somewhere between 27.39 and 32.21 (only 2.5% of values fell below the former, and only 2.5% of values fell above the latter).
Modeling Pass Attempts
Next, we're going to model Jalen Hurts' pass attempts using a Poisson distribution and do a bunch of other fancy statistics to decide whether we take the over or under. To explain briefly, a Poisson distribution is a discrete probability distribution (meaning it's used to model discrete outputs like integers, as opposed to continuous outputs) where the timing of events are independent from each other. It has one parameter, lambda, which is the rate at which events occur per unit time. Here, we're going to use mean pass attempts per game as our lambda. We'll start off by using Hurts mean pass attempts / gm, but then we'll bring in the results of our bootstrap to model different, but also-likely, values for lambda.
First, we'll essentially stress test our model by calculating that long-run frequency of pass attempts / gm with the lower and upper bounds of our confidence interval for the bootstrap we did above.
I'm not sure if this is how pro better's do it. In investing, this is akin to the concept of "Margin of Safety" (you could also just call it a stress test, which is a commonly used term in financial modeling, or say sensitivity analysis). Margin of Safety is the concept of only buying up stocks where the calculated intrinsic value of the stock > market value by a wide margin (legendary investors like Buffet won't invest unless the stock is trading at at least a 20% discount), to protect yourself from uncertainty and essentially boost your edge. It's a way of reducing your risk and making sure you only take the surest sure things, even though a sure thing doesn't exist in investing or gambling.
To talk more about Poisson distributions and why we use one here to model pass attempts: the quintessential example of a Poisson process is a bus stop where the timing of one bus arrival is completely independent of when the last bus got there. You could be waiting 5 minutes, or 2 hours. Interestingly, the time between events in a Poisson distribution can be modeled via an exponential distribution, which is an example of a continuous probability distribution. A Poisson distribution seems like a good fit for passing attempts, considering that there is a more-or-less consistent time window across samples (not truly, while every game in the NFL is the same length in theory, there's no rule stating that the offense needs to be on the field for X amount of time each game. Not to mention - overtime), and the timing between pass attempts is independent (I think you could make a stronger case on this point here).
If this is going a little too fast for you, no worries, we will revisit these concepts in future posts.
#drawing from a Poisson distribution
poisson_arr = np.random.poisson(lam=hurts_pa.mean(), size=n_simulations)
calculated_probability = sum(poisson_arr > hurts_pa_ou) / n_simulations
print('Calculated probability of the over hitting: ', calculated_probability)
print ('Calculated probability of the under hitting: ', 1 - calculated_probability)
if calculated_probability > ip_x:
print('Take the over')
else:
print('Take the under')
Calculated probability of the over hitting: 0.3742
Calculated probability of the under hitting: 0.6258
Take the under
As we discussed above, however, we can't always be sure that our sample statistics generalize to the population well. This is why we used bootstrapping to come up with a distribution of likely values our true mean might be. Let's calculate a "best-case" calculated probability and "worst-case" calculated probability for Hurts using a lower bound of 2.5% and upper bound of 97.5%, or 95% confidence interval.
poisson_arr_lower_bound = np.random.poisson(lam=lower_bound, size=n_simulations)
calculated_probability_lower_bound = sum(poisson_arr_lower_bound > hurts_pa_ou) / n_simulations
print(round(calculated_probability_lower_bound, 4), round(ip_x, 4))
print(round(1 - calculated_probability_lower_bound, 4), round(ip_x, 4))
0.2208 0.5
0.7792 0.5
poisson_arr_upper_bound = np.random.poisson(lam=upper_bound, size=n_simulations)
calculated_probability_upper_bound = sum(poisson_arr_upper_bound > hurts_pa_ou) / n_simulations
print(round(calculated_probability_upper_bound, 4), round(ip_x, 4))
print(round(1 - calculated_probability_upper_bound, 4), round(ip_x, 4))
0.537 0.5
0.463 0.5
We can see here that in our "worst-case", that is, the upper bound of our confidence interval, the probability of the over hitting is 53% vs. 50% implied probability. Note that at this point we are more likely to take the under given the results we've seen, and are essentially working to invalidate our decision of taking the under.
We don't have to limit ourselves to seeing how our calculated probability changes at just these two points. We can actually show how our edge (the spread between calculated probability and implied probability, also our profit margin) changes given a change in our estimate for Hurts' true pass attempts / gm.
We can do this using the code below. This post is getting quite lengthy and this code block here is a bit complex, so we're going to breeze over some details to get to our final visualization. If you have any questions on the code, feel free to email me at [email protected].
def calculate_probability(lam, n_simulations=200_000):
"""
Function that quickly calculates probability of hitting the over for a given lam, with default n_simulations=200,000
"""
draws = np.random.poisson(lam=lam, size=n_simulations)
return sum(draws > hurts_pa_ou) / n_simulations
quantiles = np.arange(1, len(kde_data_x)) / len(kde_data_x)
y = np.array([calculate_probability(np.quantile(xs, i/len(quantiles))) for i in range(1, len(quantiles) + 1)])
x = np.array([np.quantile(xs, quantile) for quantile in quantiles])
We end up with this x data here that contains our calculated probability for each reasonably likely (within two bounds) Hurts' pass attempt / gm. We can plot this, along with the implied probability from the money line (where the spread between the implied probability and the calculated probability is our edge. When calculated probability < implied probability, then the under is +EV and over is -EV, and when calculated probability > implied probability, then the under is -EV and the over is +EV).
fig, ax = plt.subplots(figsize=(10, 6))
ax.plot(x, y);
ax.hlines(xmin=x.min(), xmax=x.max(), y=ip_x, color='blue', alpha=0.2, linestyle='--');
ax.fill_between(
x, y, ip_x, color='green', alpha=0.2, where=yip_x*0.99
);
ax.set_ylabel('Probability')
ax.set_xlabel('Jalen Hurts Pass Attempts / Gm.');
ax.plot(kde_data_x, kde_data_y);
ax.set_xlim([x.min(), x.max()]);
ax.legend(['Calculated Probability of Over', 'Implied Probability of Over from Money Line', 'Positive EV', 'Negative EV', 'Jalen Hurts Pass Attempts / GM'], bbox_to_anchor=(1.1, 1.05));
For now, how this plot was generated as not as important as understanding what it means. The blue line is simply our bootstrapped means distribution from earlier. We've labeled that as "Jalen Hurts pass attempts/GM". The dotted line marks the implied probability of over 31.5 pass attempts hitting according to the line at DraftKings. The solid red line is the calculated probability of the over hitting at each likely Hurts' pass attempt per game.
We can see that as Jalen Hurts' pass attempts / gm number gets smaller and smaller, our edge widens. As we get farther and farther on the right side of the distribution, we can see our edge narrows until it eventually turns -EV.
We can also see here that our under bet tilts to -EV after around 31.5. This makes sense given that was the over/under. Therefore, we can get the probability of our bet being +EV from the result our bootstrap.
probability_bet_is_positive_ev = sum(xs < hurts_pa_ou) / len(xs)
print('Probability the under is +EV: ', probability_bet_is_positive_ev)
Probability the under is +EV: 0.9135
To conclude, we found that the likelihood of the under hitting is somewhere around 63%, with a confidence interval of somewhere between 46% and 78%, and a 91% chance that the bet is +EV.
One thing that we have not incorporated is KC's pass defense. I challenge you to figure out a way to incorporate that information as well, which may tilt the odds a bit more in favor of the over. If everything was pointing towards the over, without incorporating everything we know about KC, then I'd be more inclined to take this bet. We just didn't have enough time to get to it in this post and the SB is in 2 days but it is something we will incorporate in future posts.
Thanks for reading and happy coding!