{"cells":[{"metadata":{"_uuid":"1b803db10e0ab6a35912c7130dfce3e2e61d6548"},"cell_type":"markdown","source":"# NFL Punt Analytics Competition\n\nThis notebook documents my submission to a Kaggle competition aimed at reducing the rate of concussions during punt plays.\n\n- Author: Karl Pazdernik\n- Date: 2018/01/09"},{"metadata":{"trusted":true,"_uuid":"64a23b069146da064c3fd5ef32413bd8e04363cf"},"cell_type":"code","source":"%matplotlib inline","execution_count":null,"outputs":[]},{"metadata":{"_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","trusted":true},"cell_type":"code","source":"## Load necessary packages ##\n\nimport os\nimport seaborn as sns\nimport numpy as np # linear algebra\nimport pandas as pd # data processing, CSV file I/O (e.g. pd.read_csv)\nimport matplotlib as plt\nfrom sklearn.linear_model import LogisticRegression, LogisticRegressionCV\nfrom sklearn.metrics import classification_report, confusion_matrix\nfrom scipy.spatial.distance import pdist,squareform\nfrom scipy.stats import chisquare\nfrom tqdm.autonotebook import tqdm\nfrom matplotlib import pyplot as plt\nfrom matplotlib import cm\nfrom glob import glob\nfrom tqdm.autonotebook import tqdm\nfrom datetime import timedelta\nfrom shapely.geometry import LineString, Point\nfrom toolz.itertoolz import sliding_window\nfrom itertools import combinations\n\nsns.set_style('darkgrid')","execution_count":null,"outputs":[]},{"metadata":{"_uuid":"9facaa669edcb495cefcc629a185b1b718560e27","trusted":true},"cell_type":"code","source":"# distance to mph\ndis2mph = 10 * 3600 / 1760 ","execution_count":null,"outputs":[]},{"metadata":{"_uuid":"d1c2a0069c417d84bcceaef93f34faf8dda3da5d","trusted":true},"cell_type":"code","source":"## Read in auxiliary data ##\n\nreview = pd.read_csv('../input/video_review.csv')\nplayers = pd.read_csv('../input/player_punt_data.csv').drop_duplicates('GSISID').set_index('GSISID')['Position']\nplayers_all = pd.read_csv('../input/player_punt_data.csv')\nroles = pd.read_csv('../input/play_player_role_data.csv')","execution_count":null,"outputs":[]},{"metadata":{"_uuid":"601f61a564e9b4900b6b8a7a217e72eb5298490b"},"cell_type":"markdown","source":"To create more interesting slices of the data, we grouped the punt and punt return formations into the following categories:\n- Punter (P)\n- Punt Returner (PR)\n- PFB\n- Gunners (G)\n- Corners (V)\n- Players along or near the line (Line)"},{"metadata":{"trusted":true,"_uuid":"5d1df2b0641b854866acac1a49a57c03d5b935e7"},"cell_type":"code","source":"## Group the roles ##\n\nroles['RoleGroup'] = ''\nroles.loc[roles.Role == 'P','RoleGroup'] = 'P'\nroles.loc[roles.Role == 'PR','RoleGroup'] = 'PR'\nroles.loc[roles.Role == 'PFB','RoleGroup'] = 'PFB'\nroles.loc[roles.Role.str[0] == 'G','RoleGroup'] = 'G'\nroles.loc[roles.Role.str[0] == 'V','RoleGroup'] = 'V'\nroles.loc[roles.RoleGroup == '','RoleGroup'] = 'Line'\n\nroles.head()","execution_count":null,"outputs":[]},{"metadata":{"_uuid":"bede8e4f113b3d26e1b3d629ce4987b3d5fbbadd"},"cell_type":"markdown","source":"## Data Exploration\n\nThere are 37 concussion plays provided with a variety of auxiliary information. First we check to see if there are any obvious factors that correlate with concussion.\n\nThe major finding is that concussions are predominantly because of a multi-player collisions from opposite teams involving the helmet, i.e. not simply caused by hitting the ground.\n\nNote, although none of the injuries are listed as been related to a turnover, that does not mean that they are not related to turnover worthy plays. For example, the concussion in game 274 play 3609 was caused by the punter running on a fake punt, losing control of the ball while untouched and then being hit high to ensure that the ball would remain loose. Upon reviewing the play-by-play information, Seattle recovered the ball, so it wasn't officially listed as a turnover but it would possess the same behavior as if Seattle had lost the ball."},{"metadata":{"trusted":true,"_uuid":"b4218581e76fbbff8fba525df81cf8c9fcbfc1db"},"cell_type":"code","source":"review.Player_Activity_Derived.value_counts().plot.bar()\nplt.show()","execution_count":null,"outputs":[]},{"metadata":{"trusted":true,"_uuid":"2af279f5558c142553c05d2e32145f225e3e732f"},"cell_type":"code","source":"review.Friendly_Fire.value_counts().plot.bar()\nplt.show()","execution_count":null,"outputs":[]},{"metadata":{"trusted":true,"_uuid":"b83ec3f3c68f65fe9a6e2b3c60acd6ee6dc1d6ca"},"cell_type":"code","source":"review.Turnover_Related.value_counts().plot.bar()\nplt.show()","execution_count":null,"outputs":[]},{"metadata":{"trusted":true,"_uuid":"c86b57a203ffb084ee1431067ffa295c86ca6991"},"cell_type":"code","source":"review.Primary_Impact_Type.value_counts().plot.bar()\nplt.show()","execution_count":null,"outputs":[]},{"metadata":{"trusted":true,"_uuid":"6a17f1bb14fba41b831b96bf0c55820c18d4c70b"},"cell_type":"code","source":"review.Primary_Partner_Activity_Derived.value_counts().plot.bar()\nplt.show()","execution_count":null,"outputs":[]},{"metadata":{"trusted":true,"_uuid":"b8770e4750e3c5d1569e3efe7ff8f50b7a6a77ba"},"cell_type":"code","source":"## Add player information to the review data ##\n\nreview = pd.read_csv('../input/video_review.csv')\nreview = review.merge(players.reset_index(), on='GSISID', how='inner')\nreview = review.merge(roles, on=['Season_Year','GameKey','PlayID','GSISID'], how='inner')\nreview.head()","execution_count":null,"outputs":[]},{"metadata":{"_uuid":"fe0466cda3b5219836e384259b067f43fb7afffb"},"cell_type":"markdown","source":"Looking for trends in player roles within the formation, it isn't immediately obvious that there are any trends aside from punt returners are the most likely to be concussed."},{"metadata":{"trusted":true,"_uuid":"6fd27c9bd4a3effceb8ad11fe043d031957973f8"},"cell_type":"code","source":"review.Role.value_counts().plot.bar()\nplt.figure()\nreview.RoleGroup.value_counts().plot.bar()\nplt.show()","execution_count":null,"outputs":[]},{"metadata":{"_uuid":"bf6115596184584cd181c534518cc8aa13dd1161"},"cell_type":"markdown","source":"To determine if any position group is more likely to be concussed, we can build an data frame with both observed and expected concussions given player group. Using the \"roles\" data, can obtain the average number of players within each position group across all plays. If we assume that each player has an equally likely chance of being concussed on the play, we can test the hypothesis that the observed concussion counts match our expection.\n\nA formal chi-squared goodness-of-fit test suggests that even in this small dataset, there is moderate statistical evidence (p-value close to 0.05) that the observed counts of concussion for each position group does not match expectation. In particular, we see that the punt returner is a particularly risky position, the gunner is somewhat risky, and the corners tend to be fairly safe."},{"metadata":{"trusted":true,"_uuid":"8feddc804123c292557c63394c864418d9815734"},"cell_type":"code","source":"rcounts = pd.DataFrame(roles.groupby(['Season_Year','GameKey','PlayID','RoleGroup'])['Role'].count()).reset_index()\nrave = pd.DataFrame(rcounts.groupby(['RoleGroup'])['Role'].mean()).reset_index()\nrave['Expected'] = rave.Role/22*review.shape[0]\n\nEC = pd.DataFrame(review.RoleGroup.value_counts()).reset_index()\nEC.columns = ['RoleGroup','Observed']\n\nrave = rave.merge(EC, on='RoleGroup', how='inner')\n\nXstat, Xp = chisquare(rave.Observed, f_exp=rave.Expected)\nprint('Goodnees of fit p-value = %3f' %Xp)\n\nrave","execution_count":null,"outputs":[]},{"metadata":{"_uuid":"678c68cfcf2d48e41b057333af466382e699872f"},"cell_type":"markdown","source":"## Data Augmentation\n\nSince the descriptions for the incidents are not particularly informative, I reviewed each video individually to assess whether I could determine the primary reason for the concussion. My hand labels are provided below. When I could not determine where or how the concussion occurred, the entry was left blank.\n\nSome basic definitions:\n- Collision = standard hit, nothing unusual observed\n- Engaged = the would-be tackler is being blocked by one opponent when another opponent delivers and extra hit\n- Friendly Fire = hit by a teammate, likely difficult to avoid\n- Pile up = another tackler adding to the tackle once the returner is/appears to be down or going down\n- Poor blocking/tackling = technique that would endanger the blocker/blocked/tackler/tackled, especially hits to the head\n- Pushed from behind/side = the player is pushed into a concussion by the opponent, either from behind (illegal) or from the side"},{"metadata":{"scrolled":true,"trusted":true,"_uuid":"322464d8366a4eaae6c094882e71a4579a000617"},"cell_type":"code","source":"review['Possible_Cause'] = ['pushed from behind','engaged','poor tackling','poor tackling','pushed from side','poor blocking',\\\n                           'poor blocking','engaged friendly fire','pile up','','','collision',\\\n                           'poor tackling','illegal hit','turnover','poor blocking','poor tackling','engaged',\\\n                           'friendly fire','poor tackling','poor blocking','poor blocking','friendly cross fire','collision',\\\n                           'poor tackling','collision','poor tackling','poor blocking','friendly fire','collision',\\\n                            'poor blocking','poor blocking','poor tackling','poor blocking','collision','poor blocking',\\\n                           'poor tackling']\nreview['Blindsided'] = ['No','Yes','No','No','Yes','No',\\\n                       'No','Yes','No','','','Yes',\\\n                       'No','Yes','No','No','No','Yes',\\\n                       'No','No','No','Yes','No','Yes',\\\n                       'No','No','No','Yes','No','No',\\\n                        'Yes','No','No','Yes','No','No',\\\n                       'No']\n\n## Note: Mislabeling exists on the player jersey number and friendly fire was missed when a player was pushed into another\n\nreview[['GameKey', 'PlayID', 'Possible_Cause', 'Blindsided']]\n#review","execution_count":null,"outputs":[]},{"metadata":{"trusted":true,"_uuid":"0b01451570bd72c3053951112f43f86a6907c3d4"},"cell_type":"code","source":"## Summary table of hand curated labels\n\npd.crosstab(review.Possible_Cause, review.Blindsided, margins=True)","execution_count":null,"outputs":[]},{"metadata":{"_uuid":"9948bb25c898034552d0c33c97648b758a2a674a"},"cell_type":"markdown","source":"### Observation 1\n\nAlthough the sample size is limited, especially when 2 video clips are inconclusive, the most common trend is poor tackling/blocking (19/35). Players on punt and punt return teams are often predominantly reserves and so usually have less experience and are trying to make \"splash plays\" to get noticed. Continuing to promote \"heads up\" tackling is likely the best strategy to minimize these plays and perhaps a re-emphasis on avoiding unnecessary roughness (Rule 12, Section 2, Article 6i).\n\n\n### Rule Change 1\n\nHowever, in terms of a rule change, one option is to better position referees to notice and call unnecessary roughness. On many of the plays observed, no yellow flag was thrown for unnecessary roughness. While this may be due to a referee's unwillingness to throw a flag on a questionable hit, I also noticed that there may not be adequate attention paid to blocking during a punt. According to the \"official responsibilities\", as outlined by NFL operations, only the Field Judge is responsible for enforcing rules on blocking during a punt. Of the seven referees on the field at any time, only three will be near the returner where the most vicious hits occur. However, the Down Judge and Line Judge are only responsible for the line of scrimmage. After players begin to move down the field, these judges could run alongside and look for infractions regarding blocking, which would include unnecessary roughness. This would better enforce the rules without the need of adding extra officials.\n"},{"metadata":{"_uuid":"ff40c8c3daa8592a412708750ea9431f5e7d50e7"},"cell_type":"markdown","source":"### Observation 2\n\nAnother highly likely cause for concussion is being blindsided (11/35). The remaining causes vary but tend to be the result of routine collisions or unavoidable contact. Players are taught to \"keep their head on a swivel\", but not everyone is adept at this. \n\nSince a concussion is caused from a blow to the head, in extreme cases causing the brain to be jolted within the skull, it seems that the best way to prevent concussions is the reduce the likelihood of these violent hits. What makes a hit violent is the amount of force exerted on the hit, so following this logic, we should consider the speed, direction, and mass of players involved in collisions.\n"},{"metadata":{"_uuid":"4e8c9d06cc1c517622a32ce0686e375543b47d2a"},"cell_type":"markdown","source":"## Add Player Weight by Position\n\nWithout building a complicated physics model that quantifies the transfer of force between to moving players, we can at least assess whether or not the weight of a player has an impact. While the player weight isn't immediately available in this data, we can use a rudimentary form of imputation as a sanity check.\n\nThe average weight for each position was collected from https://public.tableau.com/en-us/s/gallery/height-and-weight-nfl. It is from 2015, but I am assuming that these averages haven't changed significantly since then. Note: OLB is taken as the average between DE and LB, since a 3-4 OLD tends to be larger than your typical ILB or MLB."},{"metadata":{"trusted":true,"_uuid":"cac7ac3133258c36e31c85955ebe679e380a0a56"},"cell_type":"code","source":"pos = ['DE', 'DT', 'NT', 'LB', 'ILB', 'OLB', 'MLB', 'CB', 'S', 'FS', 'SS', \\\n       'QB', 'RB', 'FB', 'WR', 'TE', 'OL', \\\n       'K', 'P', 'LS']\nwgt = [283.1, 312.8, 312.8, 246.0, 246.0, (283.1+246.0)/2, 246.0, 200.2, 200.2, 200.2, 200.2, \\\n       224.1, 220.2, 220.2, 222.4, 222.4, 314.0, \\\n       202.3, 213.2, 245.3]\n\nsuppl = pd.DataFrame(pos)\nsuppl.columns = ['position']\nsuppl['weight'] = wgt\n\nsuppl","execution_count":null,"outputs":[]},{"metadata":{"_uuid":"403ac7b547c7153113516bab1d270b69ea389205"},"cell_type":"markdown","source":"## Angle and Speed at Collisions\n\nThe following code is designed to extract the angle and speed of colliding players at the time of collisions using the RFID data."},{"metadata":{"_cell_guid":"79c7e3d0-c299-4dcb-8224-4455121ee9b0","_uuid":"d629ff2d2480ee46fbb7e2d37f6b5fab8052498a","trusted":true},"cell_type":"code","source":"def viz_play(iplay, gamekey, playid):\n    prole = roles.query('GameKey == @gamekey and PlayID == @playid')\n    long_snapper = prole.query('Role == \"PLS\"').GSISID.iloc[0]\n    punter = prole.query('Role == \"P\"').GSISID.iloc[0]\n    iplay = iplay.query('GSISID in @prole.GSISID')\n    assert len(iplay.GSISID.unique()) <= 22\n    iplay.Time = pd.to_datetime(iplay.Time)\n    iplay = iplay.sort_values('Time')\n\n    scrimmage = iplay.query('GSISID == @long_snapper and Event == \"ball_snap\"').x.iloc[0]\n\n    flip = 1\n    punter = iplay.query('GSISID == @punter and Event == \"ball_snap\"').x.iloc[0]\n    if punter > scrimmage:\n        flip = -1\n\n    rplay = review.query('GameKey == @gamekey and PlayID == @playid')\n\n    snap = iplay.query('Event == \"ball_snap\"').Time.iloc[0]\n    end = iplay.query('Event in [\"tackle\", \"punt_downed\", \"out_of_bounds\", \"fair_catch\", \"touchdown\"]').Time.iloc[-1] + timedelta(seconds=1.5)\n\n    iplay = iplay[iplay.Time.between(snap, end)]\n\n    for x, player in iplay.groupby('GSISID'):\n        viridis = cm.viridis(np.linspace(0, 1, player.shape[0]))\n        plasma = cm.plasma(np.linspace(0, 1, player.shape[0]))\n        cividis = cm.cividis(np.linspace(0, 1, player.shape[0]))\n        inferno = cm.inferno(np.linspace(0, 1, player.shape[0]))\n        colors = cividis\n        alpha = 0.1\n        zorder = 0\n        if int(rplay.iloc[0].GSISID) == x:\n            colors = viridis\n            alpha = .6\n            zorder = 2\n        if (rplay.Primary_Partner_GSISID.notnull().iloc[0]\n            and rplay.Primary_Partner_GSISID.iloc[0] != 'Unclear' \n            and int(rplay.Primary_Partner_GSISID.iloc[0]) == x):\n            colors = plasma\n            alpha = .6\n            zorder = 1\n        plt.scatter(-flip * player.y + (flip > 0) * 53.3, flip * (player.x - scrimmage), c=colors, alpha=alpha, zorder=zorder)\n    plt.xlim(-3, 55)\n    plt.title(f'gamekey {gamekey} playid {playid}')\n    plt.tight_layout()","execution_count":null,"outputs":[]},{"metadata":{"_uuid":"e217f963091a003ce53df6e8a43bea74baf9861b","trusted":true},"cell_type":"code","source":"def get_collisions(play, gamekey, playid, start, end, prole):\n    total = (end - start).total_seconds() / 0.1 - 1\n    data = []\n    blocks = []\n    timepoints = 2\n    prev = set()\n    for ts in tqdm(sliding_window(timepoints, play[play.Time.between(start, end)].Time.unique()), total=total, leave=False):\n        new_prev = set()\n        rev = review.query('GameKey == @gamekey and PlayID == @playid')\n        try:\n            injpair = set([int(rev.GSISID), int(rev.Primary_Partner_GSISID)])\n        except ValueError:\n            injpair = -1\n        iframe = play.query('Time == @ts[1]').sort_values('GSISID')\n        gsis_s = iframe.GSISID.values.astype(int)\n        pairs = squareform(pdist(iframe[['x', 'y']])) < 5\n        for i, j in zip(*pairs.nonzero()):\n            if i >= j:\n                continue\n            gsis1 = gsis_s[i]\n            gsis2 = gsis_s[j]\n            assert gsis1 < gsis2\n\n            locs1 = play.query('Time in @ts and GSISID == @gsis1')\n            if locs1.shape[0] != timepoints:\n                continue\n            locs2 = play.query('Time in @ts and GSISID == @gsis2')\n            if locs2.shape[0] != timepoints:\n                continue\n            x1 = locs1[['x', 'y']].values\n            nx1 = locs1[['nx', 'ny']].values\n            # sometimes players slow down so need 2x diff e.g game 448 play 2792\n            #ls1 = LineString(np.append(x1[-1], np.diff(x1, axis=0) + x1[-1, :], 0))\n            ls1 = LineString(np.c_[x1[-1], 2*np.diff(x1, axis=0).ravel() + x1[-1, :]].T)\n            x2 = locs2[['x', 'y']].values\n            nx2 = locs2[['nx', 'ny']].values\n            #ls2 = LineString(np.append(x2[-1], np.diff(x2, axis=0) + x2[-1, :], 0))\n            ls2 = LineString(np.c_[x2[-1], 2*np.diff(x2, axis=0).ravel() + x2[-1, :]].T)\n            if ((Point(x1[0]).distance(Point(x2[0])) < 1.5)\n                and ((np.abs(np.diff(x1, axis=0) - np.diff(x2, axis=0))).sum() < 0.25)):\n                spd1 = locs1.iloc[0].dis * dis2mph\n                spd2 = locs2.iloc[0].dis * dis2mph\n                ang1 = np.rad2deg(np.arctan2(*locs1.iloc[:2][['x', 'y']].diff().iloc[1].values))\n                ang2 = np.rad2deg(np.arctan2(*locs2.iloc[:2][['x', 'y']].diff().iloc[1].values))\n                x = ang1 - ang2\n                diffa = min(x % 360, abs((x % 360) - 360) % 360)\n                pos1 = players.loc[gsis1]\n                pos2 = players.loc[gsis2]\n                    \n                blocks.append({\n                    'gamekey': gamekey,\n                    'playid': playid,\n                    'x': np.mean([x2[-1, 0], x1[-1, 0]]),\n                    'y': np.mean([x2[-1, 1], x1[-1, 1]]),\n                    'nx': np.mean([nx2[-1, 0], nx1[-1, 0]]),\n                    'ny': np.mean([nx2[-1, 1], nx1[-1, 1]]),\n                    'gsis1': gsis1,\n                    'role1': prole.loc[gsis1, 'Role'],\n                    'position1': pos1,\n                    'gsis2': gsis2,\n                    'role2': prole.loc[gsis2, 'Role'],\n                    'position2': pos2,\n                    'spd1': spd1,\n                    'spd2': spd2,\n                    'time': pd.to_datetime(ts[1]),\n                    'angle1': ang1,\n                    'angle2': ang2,\n                    'angle_diff': diffa,\n                })\n            if ls1.distance(ls2) < 0.5 and Point(x1[0]).distance(Point(x2[0])) > 1.0:\n                new_prev.add((gsis1, gsis2))\n                if (gsis1, gsis2) not in prev:\n                \n                    spd1 = locs1.iloc[0].dis * dis2mph\n                    spd2 = locs2.iloc[0].dis * dis2mph\n                    ang1 = np.rad2deg(np.arctan2(*locs1.iloc[:2][['x', 'y']].diff().iloc[1].values))\n                    ang2 = np.rad2deg(np.arctan2(*locs2.iloc[:2][['x', 'y']].diff().iloc[1].values))\n                    x = ang1 - ang2\n                    diffa = min(x % 360, abs((x % 360) - 360) % 360)\n                    #ix = ls1.intersection(ls2)\n\n                    pos1 = players.loc[gsis1]\n                    pos2 = players.loc[gsis2]\n\n    #                 if set([gsis1, gsis2]) == injpair:\n    #                     print(list(ls1.coords()))\n\n                    data.append({\n                        'gamekey': gamekey,\n                        'playid': playid,\n                        'x': np.mean([x2[-1, 0], x1[-1, 0]]),\n                        'y': np.mean([x2[-1, 1], x1[-1, 1]]),\n                        'nx': np.mean([nx2[-1, 0], nx1[-1, 0]]),\n                        'ny': np.mean([nx2[-1, 1], nx1[-1, 1]]),\n                        'gsis1': gsis1,\n                        'role1': prole.loc[gsis1, 'Role'],\n                        'position1': pos1,\n                        'gsis2': gsis2,\n                        'role2': prole.loc[gsis2, 'Role'],\n                        'position2': pos2,\n                        'spd1': spd1,\n                        'spd2': spd2,\n                        'time': pd.to_datetime(ts[1]),\n                        'angle1': ang1,\n                        'angle2': ang2,\n                        'angle_diff': diffa,\n                        'injury': set([gsis1, gsis2]) == injpair\n                    })\n        prev = new_prev\n    data = pd.DataFrame(data)\n    blocks = pd.DataFrame(blocks)\n    return data, blocks","execution_count":null,"outputs":[]},{"metadata":{"trusted":true,"_uuid":"7004994caeabc73d5fb322487c160e5444117c77"},"cell_type":"code","source":"## Vizualize motion ##\n\nplays = pd.read_csv('../input/NGS-2016-reg-wk13-17.csv')\ngamekey, playid = 281, 1526\nviz_play(plays.query('GameKey == @gamekey and PlayID == @playid'), gamekey, playid)","execution_count":null,"outputs":[]},{"metadata":{"_uuid":"46e14cd74d162d17746cd54899b036cd3aa22c3f","trusted":true},"cell_type":"code","source":"all_blocks = []\nall_collisions = []\nfor ngs in tqdm(glob('../input/NGS*.csv')):\n    plays = pd.read_csv(ngs)\n    for (gamekey, playid), play in tqdm(plays.merge(review[['GameKey', 'PlayID']]).groupby(['GameKey', 'PlayID']), leave=False):\n        if play.empty:\n            continue\n        play.Time = pd.to_datetime(play.Time)\n        \n        \n        prole = roles.query('GameKey == @gamekey and PlayID == @playid')\n        long_snapper = prole.query('Role == \"PLS\"').GSISID.iloc[0]\n        punter = prole.query('Role == \"P\"').GSISID.iloc[0]\n        scrimmage = play.query('GSISID == @long_snapper and Event == \"ball_snap\"').x.iloc[0]\n\n        flip = 1\n        punter = play.query('GSISID == @punter and Event == \"ball_snap\"').x.iloc[0]\n        if punter > scrimmage:\n            flip = -1\n            \n        play['nx'] = -flip * play.y + (flip > 0) * 53.3\n        play['ny'] = flip * (play.x - scrimmage)\n            \n        start = play.query('Event == \"ball_snap\"').Time.iloc[0]\n        end = play.query('Event in [\"tackle\", \"punt_downed\", \"out_of_bounds\", \"fair_catch\", \"touchdown\"]').Time.iloc[-1] + timedelta(seconds=1.5)\n\n        prole = roles.query('GameKey == @gamekey and PlayID == @playid')[['GSISID', 'Role']]\n        play = play.merge(prole, on=('GSISID'))\n        prole = prole.set_index('GSISID')\n        #play = play.sort_values(by=('Time', 'GSISID'))\n        play = play.sort_values(['Time', 'GSISID'])\n\n        a, b = get_collisions(play, gamekey, playid, start, end, prole)\n        all_collisions.append(a)\n        all_blocks.append(b)","execution_count":null,"outputs":[]},{"metadata":{"_uuid":"c80e8e2e836ce68992c8f36753a2620353111a11","trusted":true},"cell_type":"code","source":"collisions = pd.concat(all_collisions, ignore_index=True)\n#blocks = pd.concat(all_blocks, ignore_index=True)","execution_count":null,"outputs":[]},{"metadata":{"trusted":true,"_uuid":"a832a71d543eadd385446eb09dd8c6035250f192"},"cell_type":"code","source":"## Data Engineering ##\ncollisions['sumspd'] = collisions.spd1 + collisions.spd2\ncollisions['maxspd'] = collisions[['spd1','spd2']].max(axis=1)\ncollisions['position'] = collisions.position1 + '-' + collisions.position2\ncollisions['role'] = collisions.role1 + '-' + collisions.role2\n\n## Add auxiliary information ##\npunt = collisions.merge(review, left_on=['gamekey','playid'], right_on=['GameKey','PlayID'], how='inner')","execution_count":null,"outputs":[]},{"metadata":{"_uuid":"72571c348299e0be0c0dcc7a179d77ec952bef4a"},"cell_type":"markdown","source":"Note that not all concussion hits could be detected using the heuristic algorithm developed above. However, I was able to locate 31/37 such hits."},{"metadata":{"trusted":true,"_uuid":"3509cbd28642365f5ad060fd186df9dcb15ec10c"},"cell_type":"code","source":"print(punt.shape)\nprint('%i concussion collisions detected' %sum(punt.injury))\npunt.head()","execution_count":null,"outputs":[]},{"metadata":{"trusted":true,"_uuid":"8b3a5a28939bc277da7f8decb31ef29a78a60419"},"cell_type":"code","source":"punt_wgt = punt.merge(suppl, left_on='position1', right_on='position', how='outer')\npunt_wgt = punt_wgt.merge(suppl, left_on='position2', right_on='position', how='outer')\npunt_wgt['weight_diff'] = abs(punt_wgt.weight_y - punt_wgt.weight_x)\n\npunt_wgt = punt_wgt.dropna(subset=['angle1', 'angle2'])\npunt_wgt = punt_wgt.reset_index(drop=True)\n\nprint(punt_wgt.shape)\npunt_wgt.tail()","execution_count":null,"outputs":[]},{"metadata":{"_uuid":"231b6d841be55dcb8f72818ce0230ef78711e0b0"},"cell_type":"markdown","source":"Whether comparing angle to maximum speed or combined speed of the colliding player, there appears to be some evidence that the combination of high speeds and angle between perpendicular and head on leads to an increase in concussion risk."},{"metadata":{"trusted":true,"_uuid":"fcd054f76ae4017e4ba8719e0985b10146486d89"},"cell_type":"code","source":"sns.scatterplot(x='angle_diff', y='maxspd', hue='injury', style='injury', data=punt, s=100)\nplt.ylabel('Maximum Speed')\nplt.xlabel('Angle Difference')\nplt.show()","execution_count":null,"outputs":[]},{"metadata":{"trusted":true,"_uuid":"703631bc9ec861ee19cfd4d4b5ccbe2e3ec9e49a"},"cell_type":"code","source":"sns.scatterplot(x='angle_diff', y='sumspd', hue='injury', style='injury', data=punt, s=100)\nplt.ylabel('Combined Speed')\nplt.xlabel('Angle Difference')\nplt.show()","execution_count":null,"outputs":[]},{"metadata":{"_uuid":"2819d10f0feceffbde1a45d1eab0102441a4fdd6"},"cell_type":"markdown","source":"The plot below includes the size of the point scaled by the estimated difference in weight between the two players. There does not appear to be any obvious trend with respect to the weight differential between colliding players, so we will not include this factor in any additional modeling."},{"metadata":{"trusted":true,"_uuid":"53c03492782ed970d3a4e23178d0735d1bf024db"},"cell_type":"code","source":"sns.scatterplot(data=punt_wgt, x='angle_diff', y='sumspd', size='weight_diff', style='injury', hue='injury')\nplt.xlabel('Angle Difference')\nplt.ylabel('Combined Speed')\nplt.show()","execution_count":null,"outputs":[]},{"metadata":{"_uuid":"077dd3cc9faf540188ffe1b461ebc240acdf90af"},"cell_type":"markdown","source":"## Logistic Regression\n\nTo test this theory, I fit a logistic regression model to the data with the sum of the players' speed, the difference in the angle of their trajectories during collision, and the interaction of these two factors. The maximum speed was also tested, but the sum of the speeds was more predictive of concussion.\n\nFive-fold cross-validation was used to ensure reproducibility of results. Given the imbalance in the data, summary metrics such as F1-score are misleading. For the purposes of this exercise, we want to increase the recall as much as possible while maintaining a fairly high precision (above 0.95)."},{"metadata":{"trusted":true,"_uuid":"9ccd07b204b737733c0355b50d5ec2638b8f8fca"},"cell_type":"code","source":"X = np.vstack((punt.angle_diff, punt.sumspd, punt.angle_diff*punt.sumspd)).T\ny = punt.injury\nalpha = 0.1\n\nprint(X.shape, y.shape)","execution_count":null,"outputs":[]},{"metadata":{"trusted":true,"_uuid":"f602d93e89bd0e99d620784490d3ab17d7de600a"},"cell_type":"code","source":"clf_cv = LogisticRegressionCV(cv=5, random_state=0).fit(X, y)\n\n\n#inj_clf_cv = clf_cv.predict(X)\ninj_clf_cv = clf_cv.predict_proba(X)[:,1] > alpha\n\n\nprint(classification_report(y,inj_clf_cv))\nprint(confusion_matrix(y,inj_clf_cv))","execution_count":null,"outputs":[]},{"metadata":{"_uuid":"79e0b55ff72041dfb3cd2153343176eb043e4d82"},"cell_type":"markdown","source":"To visualize this pattern, I plotted the regions of higher risks of concussion in blue and lower risk of concussion in red. As expected, the combination of high speeds and large angle differential leads to higher rates of concussion."},{"metadata":{"trusted":true,"_uuid":"4a96983f2941ef030fe3af86b4b53c742a5b732c"},"cell_type":"code","source":"# Parameters\nn_classes = 2\nplot_colors = \"ryb\"\nplot_step = 0.02\n\nplt.figure(figsize=(6,5))\n\nx_min, x_max = X[:, 0].min() - .1, X[:, 0].max() + .1\ny_min, y_max = X[:, 1].min() - .1, X[:, 1].max() + .1\nxx, yy = np.meshgrid(np.arange(x_min, x_max, plot_step), np.arange(y_min, y_max, plot_step))\n\n\nZ = clf_cv.predict(np.c_[xx.ravel(), yy.ravel(), xx.ravel()*yy.ravel()])\nZ = clf_cv.predict_proba(np.c_[xx.ravel(), yy.ravel(), xx.ravel()*yy.ravel()])[:,1] > alpha\nZ = Z.reshape(xx.shape)\ncs = plt.contourf(xx, yy, Z, cmap=plt.cm.RdYlBu)\n\nplt.ylabel('Speed')\nplt.xlabel('Angle')\n\n\n\n# Plot the training points\nfor i, color in zip(range(n_classes), plot_colors):\n    idx = np.where(y == i)\n    plt.scatter(X[idx, 0], X[idx, 1], c=color, cmap=plt.cm.RdYlBu, edgecolor='black', s=15)\n\nplt.grid(True)\nplt.ylabel(\"Combined Speed\")\nplt.xlabel(\"Angle Difference\")\nplt.title('Concussion Probability Regions')\nplt.show()","execution_count":null,"outputs":[]},{"metadata":{"_uuid":"7372725df0e29129e5e64d5115453f515273fcb6"},"cell_type":"markdown","source":"More important is likely the distinction between illegal hits and unavoidable contact. So, we removed the hits that could have been prevented by better technique or that were simply illegal.\n\nBy removing these preventable concussions, we see that the remaining injuries are most likely due to the interaction between speed and angle of collision."},{"metadata":{"trusted":true,"_uuid":"149e48bc27f5f75f1e914eb0237f57327d2523df"},"cell_type":"code","source":"punt_sub = punt.query('Possible_Cause != \"poor tackling\" & Possible_Cause != \"poor blocking\" & Possible_Cause != \"illegal hit\" & Possible_Cause != \"pushed from behind\" & Possible_Cause != \"pushed from side\"')\nprint('%i concussion collisions detected' %sum(punt_sub.injury))\npunt_sub.shape","execution_count":null,"outputs":[]},{"metadata":{"trusted":true,"_uuid":"eac9aa8c825d96824a90ff443cd5988bd4f6049b"},"cell_type":"code","source":"X = np.vstack((punt_sub.angle_diff, punt_sub.sumspd, punt_sub.angle_diff*punt_sub.sumspd)).T\ny = punt_sub.injury\nalpha = 0.1\n\nprint(X.shape, y.shape)\n\nclf_cv = LogisticRegressionCV(cv=5, random_state=0).fit(X, y)\n\n\n#inj_clf_cv = clf_cv.predict(X)\ninj_clf_cv = clf_cv.predict_proba(X)[:,1] > alpha\n\n\nprint(classification_report(y,inj_clf_cv))\nprint(confusion_matrix(y,inj_clf_cv))","execution_count":null,"outputs":[]},{"metadata":{"trusted":true,"_uuid":"50e3d11934cf4a823e42d309e898b6a0356090e3"},"cell_type":"code","source":"# Parameters\nn_classes = 2\nplot_colors = \"ryb\"\nplot_step = 0.02\n\nplt.figure(figsize=(6,5))\n\nx_min, x_max = X[:, 0].min() - .1, X[:, 0].max() + .1\ny_min, y_max = X[:, 1].min() - .1, X[:, 1].max() + .1\nxx, yy = np.meshgrid(np.arange(x_min, x_max, plot_step), np.arange(y_min, y_max, plot_step))\n\n\nZ = clf_cv.predict(np.c_[xx.ravel(), yy.ravel(), xx.ravel()*yy.ravel()])\nZ = clf_cv.predict_proba(np.c_[xx.ravel(), yy.ravel(), xx.ravel()*yy.ravel()])[:,1] > alpha\nZ = Z.reshape(xx.shape)\ncs = plt.contourf(xx, yy, Z, cmap=plt.cm.RdYlBu)\n\nplt.ylabel('Speed')\nplt.xlabel('Angle')\n\n\n\n# Plot the training points\nfor i, color in zip(range(n_classes), plot_colors):\n    idx = np.where(y == i)\n    plt.scatter(X[idx, 0], X[idx, 1], c=color, cmap=plt.cm.RdYlBu, edgecolor='black', s=15)\n\nplt.grid(True)\nplt.ylabel(\"Combined Speed\")\nplt.xlabel(\"Angle Difference\")\nplt.title('Concussion Probability Regions')\nplt.show()","execution_count":null,"outputs":[]},{"metadata":{"_uuid":"2fa950879929eff4e0843578ae54469dd1a88281"},"cell_type":"markdown","source":"### Observation 3\n\nTo reduce the rate of concussion on punt returns, we need to reduce the potential for violent hits, here defined as large speeds from significantly different angles of motion. But how and where do these hits occurs?\n\nBelow is a plot of all collisions and their relative location to the line of scrimmage. Although all positions are susceptible to a violent hit, it is obvious that these hits typically occur down field in a region near to the returner. The best approach to improving player safety, therefore, is reduce contact with or near the returner.\n\nNote that this plot also supports the previously suggested rule change of having the Down Judge and Line Judge monitor more activity down field."},{"metadata":{"_uuid":"f15c15170eee1af35d0df409d1471b0472580707","trusted":true},"cell_type":"code","source":"sns.scatterplot(x='nx', y='ny', data=collisions.query('injury == False'), color='blue', s=70, alpha=0.5, label='normal')\nsns.scatterplot(x='nx', y='ny', data=collisions.query('injury == True'), color='red', s=70, alpha=1.0, label='injury')\nplt.title('Relative location of injuries')\nplt.ylabel('distance down field')\nplt.xlabel('width of field')\nplt.show()","execution_count":null,"outputs":[]},{"metadata":{"_uuid":"2b30241b4e0788be37e6e88ba1a68302dd55262e"},"cell_type":"markdown","source":"### Rule Change 2\n\nFair Catch: Rule 10. Section 2. Article 1.\nAward the receiving team an additional 5 yards from the spot of reception if a fair catch is called for and successfully received. Optional: Do not award the additional 5 yards if the punt is received within a team's own 5 yard line.\n\n#### Motivation\n\nThis will reduce the number of punt plays where a return happens. The returner is incentivized to take the fair catch because he is guaranteed 5 yards. The returner will only return the ball when he has plenty of cushion and thus is not in danger of getting hit immediately. Injuries are dramatically less likely when the return doesn’t happen.\n\n\n\n### Rule Change 3\n\nPunt Out of Bounds: Rule 9. Section 4. Article 4.\nAward the kicking team the shorter of 5 yards or half the distance to the goal line if the punt travels out of bounds.\n\n#### Motivation\n\nThis will reduce the number of punt plays where a return happens. The punter is incentivized to kick the ball out of bounds because the kicking team will receive an extra five yards and guaranteed no return yards. Punts without returns have dramatically lower probability of resulting in injuries.\n\n\n\n\n## Conclusion\n\nAdding a potential 5 yard reward for both the kicking and receiving teams will not improve player safety, but add a layer of intrigue to the punt return. Instead of the common concerns fans have while watching the punt returns, namely injuries, penalties, and turnovers, the precision of punters will be on full display, testing how close a punter can put the ball to the sideline without entering the field of play. The returner will be challenged to make tip-toe sideline catches in order to gain the extra 5 yards and will need to think carefully about not catching the ball when it sails beyond their own 10 yard line.\n\nIn the event that a punt returner decides to catch and return the punt, however, the added support of sideline judges near the action should help catch unnecessary roughness and, hopefully, increase the rate of safe and clean play. Also, by excluding fair catches within a team's own 5 yard line, the excitement of a punt downed near the goal line and the potential for a safety will remain.\n"},{"metadata":{"trusted":true,"_uuid":"3e6a63637db5d62e60032662bfe5ddc65194a551"},"cell_type":"code","source":"","execution_count":null,"outputs":[]}],"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"name":"python","version":"3.6.6","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"}},"nbformat":4,"nbformat_minor":1}