{"id":3472,"date":"2026-08-06T07:00:22","date_gmt":"2026-08-06T12:00:22","guid":{"rendered":"https:\/\/wollen.org\/blog\/?p=3472"},"modified":"2026-08-06T01:41:52","modified_gmt":"2026-08-06T06:41:52","slug":"how-often-does-a-touchdown-favorite-win","status":"publish","type":"post","link":"https:\/\/wollen.org\/blog\/2026\/08\/how-often-does-a-touchdown-favorite-win\/","title":{"rendered":"How often does a touchdown favorite win?"},"content":{"rendered":"<p>It&#8217;s August, which means we&#8217;ve officially reached the month that football starts. We had the World Cup in June-July so the summer sports drought wasn&#8217;t as miserable as usual, but it&#8217;s always nice to have kickoff in our sights.<\/p>\n<p>Today the question is: how often does a 7-point favorite win the game? In other words, what&#8217;s the relationship between point spread and win percentage?<\/p>\n<p>I have a college football dataset we can use to calculate win percentages. This topic also gives us an opportunity to work with <strong>nonlinear<\/strong> regression. In general I&#8217;m a strong believer in plain, ordinary simple linear regression (or multiple regression) and its ability to outperform more sophisticated models, but sometimes we&#8217;ll encounter data that looks like this:<\/p>\n<figure id=\"attachment_3478\" aria-describedby=\"caption-attachment-3478\" style=\"width: 600px\" class=\"wp-caption aligncenter\"><a href=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/08\/linear_example.png\"><img loading=\"lazy\" decoding=\"async\" class=\"wp-image-3478 size-full\" src=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/08\/linear_example.png\" alt=\"\" width=\"600\" height=\"600\" srcset=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/08\/linear_example.png 600w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/08\/linear_example-300x300.png 300w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/08\/linear_example-150x150.png 150w\" sizes=\"auto, (max-width: 600px) 100vw, 600px\" \/><\/a><figcaption id=\"caption-attachment-3478\" class=\"wp-caption-text\">Randomly generated nonlinear data. A simple linear regression fails to describe the relationship between variables.<\/figcaption><\/figure>\n<p>You don&#8217;t need any technical definitions to know the linear model is misspecified. The relationship between X and Y obviously follows a curved path.<\/p>\n<p>Rather than a simple linear regression of this form:<\/p>\n<p><a href=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/08\/latex_linear.png\"><img loading=\"lazy\" decoding=\"async\" class=\"aligncenter size-full wp-image-3479\" src=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/08\/latex_linear.png\" alt=\"\" width=\"208\" height=\"33\" \/><\/a>where we calculate slope (<strong>m<\/strong>) and intercept (<strong>b<\/strong>), we need a nonlinear model of this form:<\/p>\n<p><a href=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/08\/latex_quadratic.png\"><img loading=\"lazy\" decoding=\"async\" class=\"aligncenter size-full wp-image-3480\" src=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/08\/latex_quadratic.png\" alt=\"\" width=\"353\" height=\"44\" srcset=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/08\/latex_quadratic.png 353w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/08\/latex_quadratic-300x37.png 300w\" sizes=\"auto, (max-width: 353px) 100vw, 353px\" \/><\/a><\/p>\n<p>We can use SciPy to calculate coefficients <strong>A<\/strong>, <strong>B<\/strong>, and <strong>C<\/strong> and fit a model. Of course a real statistician would use Greek symbols and better notation but it really isn&#8217;t necessary. It boils down to a parabola equation. Occasionally you&#8217;ll want a higher-order polynomial but the approach is the same.<\/p>\n<p>Our college football spread-win% data is nonlinear, similar to the randomly generated data above. Let&#8217;s work on building a model.<\/p>\n<hr \/>\n<h4>1. Prepare the data.<\/h4>\n<p>Start by loading the dataset into a pandas DataFrame.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">import pandas as pd\r\n\r\npd.set_option(\"display.expand_frame_repr\", False)    # Improves printing to screen.\r\n\r\ndf = pd.read_csv(\"cfb_spreads_1978-2025.csv\", parse_dates=['date'])\r\n\r\nprint(df.head())<\/pre>\n<p>The output:<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\">        date  season            away               home  score_away  score_home whos_favored  spread\r\n0 1978-09-01    1978      Penn State             Temple          10           7         away    24.5\r\n1 1978-09-02    1978  Arkansas State              Tulsa          20          21         home     1.0\r\n2 1978-09-02    1978        Nebraska            Alabama           3          20         home    11.5\r\n3 1978-09-02    1978  West Texas A&amp;M  Mississippi State           0          28         home    12.0\r\n4 1978-09-09    1978   Florida State           Syracuse          28           0         away     1.5<\/pre>\n<p>So each row represents one football game. We&#8217;ll have to somehow group by <em>spread<\/em> and then check how often the favorite wins at each value.<\/p>\n<p>For this analysis let&#8217;s filter our DataFrame down to the most recent 20 seasons. The game has changed a lot in 20 years but it&#8217;s changed even more since 1978. 20 years will leave us plenty of data to work with.<\/p>\n<p>Let&#8217;s ignore zero-point &#8220;pick&#8217;em&#8221; spreads because those are obviously 50\/50 games. Let&#8217;s also cap the regression at 14-point favorites. Beyond that mark there isn&#8217;t enough data.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">df = df[df['season'] &gt;= 2006]\r\n\r\ndf = df[(df['spread'] &gt;= 1) &amp; (df['spread'] &lt;= 14)]<\/pre>\n<p>We&#8217;re left with 10,375 games. That will be plenty.<\/p>\n<p>Now we need a column to tell us whether the favorite won the game. <em>Apply<\/em> a custom function row-wise.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">def check_if_fav_wins(row):\r\n    if row['whos_favored'] == \"away\":\r\n        return row['score_away'] &gt; row['score_home']\r\n    else:\r\n        return row['score_home'] &gt; row['score_away']\r\n\r\n\r\ndf['fav_wins'] = df.apply(check_if_fav_wins, axis=1)\r\n\r\nprint(df.tail())<\/pre>\n<p>The output is below. You can see the favorite won in four of the five most recent games.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\">            date  season         away               home  score_away  score_home whos_favored  spread  fav_wins\r\n29612 2026-01-02    2025  Wake Forest  Mississippi State          43          29         home     3.0     False\r\n29613 2026-01-02    2025      Arizona                SMU          19          24         home     2.5      True\r\n29615 2026-01-08    2025   Miami (FL)           Ole Miss          31          27         away     3.0      True\r\n29616 2026-01-09    2025       Oregon            Indiana          22          56         home     3.0      True\r\n29617 2026-01-19    2025   Miami (FL)            Indiana          21          27         home     7.5      True<\/pre>\n<p>If your instinct is to <code>groupby<\/code> the <em>spread<\/em> column and go from there, it&#8217;s a good thought and you <span style=\"text-decoration: underline;\">could<\/span> make that work. But there&#8217;s a better option. When we want to combine values of one column and count multiple values within another column, <code>crosstab<\/code> is the perfect tool.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">df2 = pd.crosstab(df['spread'], df['fav_wins'])\r\n\r\nprint(df2.head())<\/pre>\n<p>The output:<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\">fav_wins  False  True \r\nspread                \r\n1.0         176    177\r\n1.5         165    224\r\n2.0         138    128\r\n2.5         344    379\r\n3.0         405    546\r\n<\/pre>\n<p>Now we can create a win percentage column using the <em>True<\/em> and <em>False<\/em> columns. This will tell us how often a 1-point favorite wins, and a 1.5-point favorite, and so on up to 14.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">df2['win_pct'] = df2[True] \/ (df2[True] + df2[False]) * 100\r\n\r\nprint(df2.head())<\/pre>\n<p>One more time, the output is below. This passes the smell test in my opinion. Small favorites win a little more than 50% of the time but there&#8217;s a lot of noise in the data.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\">fav_wins  False  True    win_pct\r\nspread                          \r\n1.0         176   177  50.141643\r\n1.5         165   224  57.583548\r\n2.0         138   128  48.120301\r\n2.5         344   379  52.420470\r\n3.0         405   546  57.413249<\/pre>\n<hr \/>\n<h4>2. Fit a model.<\/h4>\n<p>We need a nonlinear regression line for our scatter plot. SciPy makes this easy with its <code>curve_fit<\/code> method. We just have to define a function and pass it into <code>curve_fit<\/code> along with the data.<\/p>\n<p>The method returns values for each coefficient as well as a covariance matrix NumPy array. We won&#8217;t be using the array so assign it to a throwaway underscore.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">from scipy.optimize import curve_fit\r\n\r\n\r\ndef model(x, a, b, c):\r\n    return a * x**2 + b * x + c\r\n\r\n\r\n(coef_a, coef_b, coef_c), _ = curve_fit(f=model,\r\n                                        xdata=df2.index,\r\n                                        ydata=df2['win_pct'])<\/pre>\n<p>That&#8217;s all there is to it. We can use these three values to plot the curve defined above:<\/p>\n<p><a href=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/08\/latex_quadratic.png\"><img loading=\"lazy\" decoding=\"async\" class=\"aligncenter size-full wp-image-3480\" src=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/08\/latex_quadratic.png\" alt=\"\" width=\"353\" height=\"44\" srcset=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/08\/latex_quadratic.png 353w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/08\/latex_quadratic-300x37.png 300w\" sizes=\"auto, (max-width: 353px) 100vw, 353px\" \/><\/a><\/p>\n<p>Before moving on to plotting, let&#8217;s create x and y regression line lists.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">x_reg = df2.index.tolist()\r\ny_reg = [coef_a * x**2 + coef_b * x + coef_c for x in x_reg]<\/pre>\n<hr \/>\n<h4>3. Plot the data.<\/h4>\n<p>The Matplotlib code is straightforward and I&#8217;ve covered it 50 times before on the blog, so I&#8217;ll keep this brief. I use a custom Matplotlib style that will be linked at the bottom of this post.<\/p>\n<p>We have a scatter plot using <code>df2<\/code> data and the regression line is drawn on top. I also include a horizontal dotted line representing a 50% winning percentage. The regression line equation is displayed at the top of the figure using <code>text()<\/code>.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">import matplotlib.pyplot as plt\r\n\r\nplt.style.use(\"wollen_cfb.mplstyle\")\r\nfig, ax = plt.subplots()\r\n\r\nwhite = \"#F1F1F1\"\r\nyellow = \"#FFD139\"\r\ngray = \"#444\"\r\n\r\nax.scatter(df2.index,\r\n           df2['win_pct'],\r\n           color=white,\r\n           ec=gray,\r\n           lw=0.6,\r\n           zorder=2)\r\n\r\nax.plot(x_reg,\r\n        y_reg,\r\n        color=yellow,\r\n        zorder=3)\r\n\r\nax.plot([-100, 100],\r\n        [50, 50],\r\n        color=white,\r\n        linestyle=\"--\",\r\n        lw=0.8,\r\n        zorder=1)\r\n\r\nx_ticks = range(15)\r\ny_ticks = range(45, 95, 5)\r\n\r\nax.set(xticks=x_ticks,\r\n       xticklabels=[f\"-{n}\" if n != 0 else \"PK\" for n in x_ticks],\r\n       xlim=(-0.3, 14.3),\r\n       yticks=y_ticks,\r\n       yticklabels=[f\"{n}%\" for n in y_ticks],\r\n       ylim=(44.5, 90.5))\r\n\r\nfirst_month = df['date'].min().strftime('%b %Y')\r\nfinal_month = df['date'].max().strftime('%b %Y')\r\nax.set_title(f\"NCAA Football  \u2022  Spread and Win %  \u2022  {first_month} \u2013 {final_month}  \u2022  n={df.shape[0]:,}\")\r\n\r\nax.text(x=7,\r\n        y=89.5,\r\n        s=f\"y = {coef_a:.2f}x\u00b2 + {coef_b:.2f}x + {coef_c:.2f}\",\r\n        size=11,\r\n        weight=\"500\",\r\n        ha=\"center\",\r\n        va=\"top\",\r\n        bbox={\"fc\": \"#005EB8\", \"ec\": \"None\"})\r\n\r\nplt.savefig(\"cfb_spread_regression.png\", dpi=200)<\/pre>\n<hr \/>\n<h4>4. The output.<\/h4>\n<p><a href=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/08\/cfb_spread_regression.png\"><img loading=\"lazy\" decoding=\"async\" class=\"aligncenter size-full wp-image-3498\" src=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/08\/cfb_spread_regression.png\" alt=\"\" width=\"2000\" height=\"2000\" srcset=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/08\/cfb_spread_regression.png 2000w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/08\/cfb_spread_regression-300x300.png 300w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/08\/cfb_spread_regression-1024x1024.png 1024w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/08\/cfb_spread_regression-150x150.png 150w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/08\/cfb_spread_regression-768x768.png 768w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/08\/cfb_spread_regression-1536x1536.png 1536w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/08\/cfb_spread_regression-800x800.png 800w\" sizes=\"auto, (max-width: 2000px) 100vw, 2000px\" \/><\/a><\/p>\n<p>It&#8217;s very blue! To answer the original question, <strong>a 7-point favorite wins about 69% of the time in college football<\/strong>.<\/p>\n<p>The important point is that there are diminishing returns to win% as point spread grows larger. The difference between a 3- and 4-point favorite is bigger than the difference between 13 and 14. That&#8217;s why we couldn&#8217;t do a simple linear regression and draw a straight line through the data.<\/p>\n<p>Remember how we excluded pick&#8217;em spreads from the regression. According to our model there is no measurable difference between a 1-point favorite and a 1-point underdog. They&#8217;re all coinflip games. It makes sense but it&#8217;s not something I would have expected. To be honest I&#8217;d still prefer my team to be favored.<\/p>\n<hr style=\"width: 60%;\" \/>\n<p>I won&#8217;t post the code but I want to show the same analysis for college basketball. I included moneyline conversions in the upper-left corner.<\/p>\n<p>The correlation is stronger despite using roughly the same amount of data. It&#8217;s fair to say there are fewer surprises in basketball.<\/p>\n<p><a href=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/08\/ncaabb_example.png\"><img loading=\"lazy\" decoding=\"async\" class=\"aligncenter size-full wp-image-3501\" src=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/08\/ncaabb_example.png\" alt=\"\" width=\"1000\" height=\"1000\" srcset=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/08\/ncaabb_example.png 1000w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/08\/ncaabb_example-300x300.png 300w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/08\/ncaabb_example-150x150.png 150w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/08\/ncaabb_example-768x768.png 768w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/08\/ncaabb_example-800x800.png 800w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><\/a><\/p>\n<hr \/>\n<p><a href=\"https:\/\/wollen.org\/misc\/cfb_spread_regression_2026.zip\"><strong>Download the data.<\/strong><\/a><\/p>\n<p><strong>Full code:<\/strong><\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">import pandas as pd\r\nfrom scipy.optimize import curve_fit\r\nimport matplotlib.pyplot as plt\r\n\r\n\r\ndef check_if_fav_wins(row):\r\n    if row['whos_favored'] == \"away\":\r\n        return row['score_away'] &gt; row['score_home']\r\n    else:\r\n        return row['score_home'] &gt; row['score_away']\r\n\r\n\r\ndef model(x, a, b, c):\r\n    return a * x**2 + b * x + c\r\n\r\n\r\npd.set_option(\"display.expand_frame_repr\", False)\r\n\r\ndf = pd.read_csv(\"cfb_spreads_1978-2025.csv\", parse_dates=['date'])\r\n\r\ndf = df[df['season'] &gt;= 2006]\r\n\r\ndf = df[(df['spread'] &gt;= 1) &amp; (df['spread'] &lt;= 14)]\r\n\r\ndf['fav_wins'] = df.apply(check_if_fav_wins, axis=1)\r\n\r\ndf2 = pd.crosstab(df['spread'], df['fav_wins'])\r\n\r\ndf2['win_pct'] = df2[True] \/ (df2[True] + df2[False]) * 100\r\n\r\n(coef_a, coef_b, coef_c), _ = curve_fit(f=model,\r\n                                        xdata=df2.index,\r\n                                        ydata=df2['win_pct'])\r\n\r\nx_reg = df2.index.tolist()\r\ny_reg = [coef_a * x**2 + coef_b * x + coef_c for x in x_reg]\r\n\r\nplt.style.use(\"wollen_cfb.mplstyle\")\r\nfig, ax = plt.subplots()\r\n\r\nwhite = \"#F1F1F1\"\r\nyellow = \"#FFD139\"\r\ngray = \"#444\"\r\n\r\nax.scatter(df2.index,\r\n           df2['win_pct'],\r\n           color=white,\r\n           ec=gray,\r\n           lw=0.6,\r\n           zorder=2)\r\n\r\nax.plot(x_reg,\r\n        y_reg,\r\n        color=yellow,\r\n        zorder=3)\r\n\r\nax.plot([-100, 100],\r\n        [50, 50],\r\n        color=white,\r\n        linestyle=\"--\",\r\n        lw=0.8,\r\n        zorder=1)\r\n\r\nx_ticks = range(15)\r\ny_ticks = range(45, 95, 5)\r\n\r\nax.set(xticks=x_ticks,\r\n       xticklabels=[f\"-{n}\" if n != 0 else \"PK\" for n in x_ticks],\r\n       xlim=(-0.3, 14.3),\r\n       yticks=y_ticks,\r\n       yticklabels=[f\"{n}%\" for n in y_ticks],\r\n       ylim=(44.5, 90.5))\r\n\r\nfirst_month = df['date'].min().strftime('%b %Y')\r\nfinal_month = df['date'].max().strftime('%b %Y')\r\nax.set_title(f\"NCAA Football  \u2022  Spread and Win %  \u2022  {first_month} \u2013 {final_month}  \u2022  n={df.shape[0]:,}\")\r\n\r\nax.text(x=7,\r\n        y=89.5,\r\n        s=f\"y = {coef_a:.2f}x\u00b2 + {coef_b:.2f}x + {coef_c:.2f}\",\r\n        size=11,\r\n        weight=\"500\",\r\n        ha=\"center\",\r\n        va=\"top\",\r\n        bbox={\"fc\": \"#005EB8\", \"ec\": \"None\"})\r\n\r\nplt.savefig(\"cfb_spread_regression.png\", dpi=200)<\/pre>\n<p>&nbsp;<\/p>\n","protected":false},"excerpt":{"rendered":"<p>It&#8217;s August, which means we&#8217;ve officially reached the month that football starts. We had the World Cup in June-July so the summer sports drought wasn&#8217;t as miserable as usual, but it&#8217;s always nice to have kickoff in our sights. Today<\/p>\n","protected":false},"author":1,"featured_media":3473,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[59,215,469],"tags":[694,693,692,22,334,122,120,56,466,24,126,697,30,46,356,25,117,202,357,63,116,141,696,695],"class_list":["post-3472","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-sports","category-betting","category-stats","tag-cfb","tag-college","tag-crosstab","tag-data","tag-dataframe","tag-dataset","tag-football","tag-groupby","tag-linear-regression","tag-matplotlib","tag-mplstyle","tag-nonlinear-regression","tag-pandas","tag-plot","tag-point-spread","tag-python","tag-regression","tag-scatter-plot","tag-spread","tag-statistics","tag-stats","tag-visualization","tag-win-percentage","tag-win"],"_links":{"self":[{"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/posts\/3472","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/comments?post=3472"}],"version-history":[{"count":30,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/posts\/3472\/revisions"}],"predecessor-version":[{"id":3509,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/posts\/3472\/revisions\/3509"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/media\/3473"}],"wp:attachment":[{"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/media?parent=3472"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/categories?post=3472"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/tags?post=3472"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}