How common is Roughing the Passer?
The NFL’s enforcement of this rule has long been a source of frustration for fans. Minor incidental contact with a quarterback can suddenly trigger a 15-yard penalty and an automatic first down, which can easily change the course of a game.
On the other hand, the quarterback position has become so important that an injury usually leads to an obvious drop in play quality. The league needs to protect their stars but fans still expect a full-contact sport.
How much contact with the quarterback is okay? That standard has change over the years—most notably in 2009, 2018, and 2022, which I’ll highlight below.
Let’s take a look at how many Roughing the Passer calls are made each season and how it’s changed over time. For a few reasons, more passes are attempted today than 25 years ago. We can account for this by analyzing the rate of penalties. In other words, how many Roughing calls are made per pass attempt.
1. Prepare the data.
This script will use play-by-play data from nfl-data-py, a fantastic Python library that makes life easier for the sports/data nerds out there. It provides a wide range of NFL info, from scores to rosters to weather. Play-by-play data is available beginning with the 1999-2000 season so that’s where we’ll start.
import_pbp_data()
returns a pandas Dataframe with everything we need. Its one required parameter, years
, accepts a range of values, but 24 seasons is too much for my PC to handle. I’ll break the code into a for
loop and process each season individually.
import nfl_data_py as nfl for n in range(1999, 2024): df = nfl.import_pbp_data(years=[n])
There are 372 columns in the Dataframe so I won’t try to display the whole thing here. Fortunately this script only needs two of them.
Our approach will be to check (1) the number of passing plays and (2) the number of Roughing the Passer penalties. We can then divide those figures to calculate a “roughing rate”.
We need to look at the play_type column (pass, run, extra point, etc.) and penalty_type (false start, delay of game, etc.). For each season in the loop, append roughing rate to the y_roughing_rate
list.
x_season = range(1999, 2024) y_roughing_rate = [] for season in x_season: df = nfl.import_pbp_data(years=[season]) passing_plays = df[df['play_type'] == "pass"].shape[0] roughing_penalties = df[df['penalty_type'] == "Roughing the Passer"].shape[0] y_roughing_rate.append(roughing_penalties / passing_plays * 100)
Now we have two lists and we can create a bar graph.
x_season = [1999, 2000, 2001, ...] y_roughing_rate = [0.38428, 0.43396, 0.43397, ...]
2. Plot the data.
This plot uses a custom NFL-themed Matplotlib style that I’ll link at the bottom of this post. I think it does a pretty good job of copying the leagues branding but not so good that I’ll get a cease-and-desist from Roger Goodell.
Create an axes object and then call bar()
. We’ll do a couple style things later so specify a layer, zorder=2
.
import matplotlib.pyplot as plt plt.style.use("wollen_nfl.mplstyle") fig, ax = plt.subplots() ax.bar(x_season, y_roughing_rate, width=0.6, zorder=2)
x-ticks are rotated 60 degrees so they can be more densely packed along the horizontal axis. y-tick labels need to be formatted with a % sign because we multiplied values by 100 in the code above.
x_ticks = range(1999, 2024) ax.set_xticks(x_ticks) plt.setp(ax.xaxis.get_majorticklabels(), rotation=60, ha="right", rotation_mode="anchor") ax.set_xlim(1998.2, 2023.6) y_ticks = arange(0.0, 0.9, 0.1) ax.set_yticks(y_ticks, labels=[f"{n:.1f}%" for n in y_ticks]) ax.set_ylim(0, y_ticks[-1] * 1.01) ax.set_ylabel("% of Pass Plays") ax.set_title("NFL • Roughing the Passer Penalties")
I mentioned that we’ll highlight three rule changes…
(1) In his 2008 season opener, Tom Brady tore both his ACL and MCL when a defender lunged at his knees. It didn’t appear to be a dirty play but Brady missing the season was a significant loss for the league. Before the next season began, Roughing the Passer was amended to prohibit forcibly hitting a passer’s lower leg.
(2) In 2017, a Vikings defender tackled Aaron Rodgers and the impact of his bodyweight broke Rodgers’ collarbone. Once again, after the season concluded, Roughing the Passer was updated to address the incident. The rule now prohibits defenders from landing on a quarterback with all or most of their weight.
(3) In the 2022 season, the league attempted to cut down on penalties and improve the competitive balance discussed above. The rule now permits some incidental contact to the head, neck, and lower leg areas.
Let’s label the rule changes on our plot to give the viewer more context. Use annotate()
to place text and an arrow at each point. I like to package annotations into a list of tuples and iterate through them.
highlight_coords = [(2008, "Tom Brady\nknee", "center"), (2017, "Aaron Rodgers\ncollarbone", "center"), (2022, '"Incidental\ncontact"\nchange', "left")] for year, text, align in highlight_coords: ax.annotate(text, ha=align, xy=(year, y_roughing_rate[year - 1999]), xytext=(year, y_roughing_rate[year - 1999] + 0.1), arrowprops={"arrowstyle": "wedge", "color": "#DFDFDF"})
Credit the data source with text()
in the upper-right corner.
x_left, x_right = ax.get_xlim() x_span = x_right - x_left ax.text(x_right - x_span * 0.005, y_ticks[-1] * 0.992, "Data: nfl-data-py.", ha="right", va="top")
I’d like to create a background gradient rather than using a solid color. The colors will be blue and slightly darker blue, so the effect will be subtle, but I think it will add some needed depth.
Use the colour library to generate a list of colors. I’ve found the easiest way to draw a gradient across the whole figure is to use imshow()
and set clip_on=False
. The approach is to create a tall, narrow 2-D array and stretch its extent
well beyond the window limits. Set zorder
to 0 so it’s drawn on the bottom layer.
from colour import Color color_list = [c.rgb for c in Color("#0067B1").range_to(Color("#013369"), 100)] gradient_array = [[c] for c in color_list] plt.imshow(gradient_array, extent=(1990, 2030, -0.1, 1.2), interpolation="bilinear", aspect="auto", clip_on=False, zorder=0)
As a final touch, include the NFL logo with low alpha
to create a watermark effect. Setting zorder
to 1 will layer it beneath the bars and above the background.
from matplotlib.offsetbox import OffsetImage, AnnotationBbox ab = AnnotationBbox(OffsetImage(plt.imread("nfl_logo.png"), zoom=0.15, alpha=0.03), (x_left, y_ticks[-1]), box_alignment=(0, 1), frameon=False, zorder=1) ax.add_artist(ab)
Finally, save the figure with a bumped dpi
.
plt.savefig("roughing_rate_nfl.png", dpi=150)
3. The output.
You can see that Roughing the Passer calls have been less common over the past two seasons. The decision to allow slightly more incidental contact has successfully reduced the penalty rate.
It’s worth remembering that when officials are directed to judge contact differently, players update their behavior as well. For example, calls became more frequent after the 2009 rule change but then leveled off around 2011, possibly because defenders had adapted to the change. A new equilibrium was reached and (hopefully) the rule had its desired effect. The idea isn’t solely to punish; it’s to change how the game is played.
Full code:
import nfl_data_py as nfl import matplotlib.pyplot as plt from matplotlib.offsetbox import OffsetImage, AnnotationBbox from numpy import arange x_season = range(1999, 2024) y_roughing_rate = [] for season in x_season: df = nfl.import_pbp_data(years=[season]) passing_plays = df[df['play_type'] == "pass"].shape[0] roughing_penalties = df[df['penalty_type'] == "Roughing the Passer"].shape[0] y_roughing_rate.append(roughing_penalties / passing_plays * 100) plt.style.use("wollen_nfl.mplstyle") fig, ax = plt.subplots() ax.bar(x_season, y_roughing_rate, width=0.6, zorder=2) x_ticks = range(1999, 2024) ax.set_xticks(x_ticks) plt.setp(ax.xaxis.get_majorticklabels(), rotation=60, ha="right", rotation_mode="anchor") ax.set_xlim(1998.2, 2023.6) y_ticks = arange(0.0, 0.9, 0.1) ax.set_yticks(y_ticks, labels=[f"{n:.1f}%" for n in y_ticks]) ax.set_ylim(0, y_ticks[-1] * 1.01) ax.set_ylabel("% of Pass Plays") ax.set_title("NFL • Roughing the Passer Penalties") highlight_coords = [(2008, "Tom Brady\nknee", "center"), (2017, "Aaron Rodgers\ncollarbone", "center"), (2022, '"Incidental\ncontact"\nchange', "left")] for year, text, align in highlight_coords: ax.annotate(text, ha=align, xy=(year, y_roughing_rate[year - 1999]), xytext=(year, y_roughing_rate[year - 1999] + 0.1), arrowprops={"arrowstyle": "wedge", "color": "#DFDFDF"}) x_left, x_right = ax.get_xlim() x_span = x_right - x_left ax.text(x_right - x_span * 0.005, y_ticks[-1] * 0.992, "Data: nfl-data-py.", ha="right", va="top") color_list = [c.rgb for c in Color("#0067B1").range_to(Color("#013369"), 100)] gradient_array = [[c] for c in color_list] plt.imshow(gradient_array, extent=(1990, 2030, -0.1, 1.2), interpolation="bilinear", aspect="auto", clip_on=False, zorder=0) ab = AnnotationBbox(OffsetImage(plt.imread("nfl_logo.png"), zoom=0.15, alpha=0.03), (x_left, y_ticks[-1]), box_alignment=(0, 1), frameon=False, zorder=1) ax.add_artist(ab) plt.savefig("roughing_rate_nfl.png", dpi=150)