Fixing NBA Shot Times Using Tracking Data
This article is follow-up to this article which explores how to identify precise shot times using SportVU NBA tracking data. For background on the methods and limitations I will be addressing read that article first.
Solutions:
I will provide augmented methods for finding shot times. They are as follows:
Method 1: User Defined Time Window
In this method we impose a time window around the officially reported shot time and only consider the movement within that window. We study the data to construct an appropriate window, and we can adjust our window to fine tune. We then apply the traditional Savitzky-Golay method to identify the greatest moment of vertical acceleration.
Reported shot time, \(R\), is often the time the ball went all the way through the net on a made shot, or the time the rebound is gathered on a missed shot (really more like the time the ball hits the ground after the shot for a made shot). Thus we only need to consider a one-sided time window of \(t\) seconds before the reported shot time: \([R, R+t]\).
After trial and error, I found that looking at the prior \(t=6\) seconds before reported shot time is a large enough window to ensure that the actual moment of the shot is in the window while excluding erroneous movement.
Positives
This method directly addresses the primary issue of the legacy version by removing movement data of previous possessions from consideration.
Limitations
How will this method perform when there are multiple shots within the defined time window (eg. putbacks, tip-ins..)? What is the reported shot time is significantly inaccurate and the window we construct does not contain the data we need? Hint: we will fail to find, and the function will return an empty row.
Method 2: Moment Closest to Reported Location
Rather than using the Savitzky-Golay filter, this straight-forward method, we look at the ball's location throughout a play and compare it to the reported shot location \((x_r, y_r)\) from the provided shot logs. Then, we simply take the information \( (t_i, x_i, y_i) \) of the moment, \(i\), when the ball is closest to that reported location.
We find \(i\) such that we minimize the expression
\[ D_i = \sqrt{(x_r - x_i)^2 + (y_r - y_i)^2} \]Positives
No loss of data (ie all rows/shots accounted for) since S-G filter is not applied.
Keeps shot locations consistently close to reported. Other methods have a tendency to significantly alter shot
locations.
Limitations
Does not limit time window. If we do not restrict our time window, then we are considering movement that may
belong to other events. Since the data is converted to be one-sided (half-court), the moment when the ball is
closest to the reported shot location may come at the wrong time.
Ex: Finding shot time for a layup by team A. If the prior shot is a made
shot by team B, then the ball is close to the reported shot location directly after the prior made shot.
In another case the ball may pass through the location of the shot earlier in the possession (ex: post up, kick
out, driving shot near the rim; the ball is near the location of the shot multiple times during the possession).
Method 2.5: Window Based on Proximity to Reported Shot Location
In this method we impose a condition that the ball must be within some user defined distance from the reported shot location. Then we run the original Savitzky-Golay method on that subset of the rows. This method seems to be an ideal mix of the previous two methods. However, consider the following limitation:
Since this method filters based on a condition, it does not ensure a continuous time window. This is a major complication because the Savitzky-Golay filter needs to be fed continuous time-series data.
Method 3: Time Window Around Moment Closest to Reported Shot Location
Uses the same procedure as method 2 to find the moment the ball is closest to shot locations, then implements SG-filter method on a time window of \([t-1, t+3] \) seconds around that moment.
Positives:
Most effective method for identifying and constraining the time window that the shot is within.
Uses SG-filter method to accurately identify precise shot time.
Limitations:
The more restrictive we make our windows, the greater the chance that we miss the moment of the shot. This leads to missing rows in output. The legacy function has a perfect success rate (eg it gives a fixed version of each row, but many of those rows are duplicates/inaccurate). The methods we are using (so far) have failed some rows, and this leads to loss of data (rows where the methods fail don't show up in the output dataframe).
Results:
Lets look at how the different methods I have presented compare across a number of shots. I will continue to use
data from the Phoenix @ Golden State game that I used in the previous article. Below are charts of the first
eight
shots from that game with the shot times that each method returned marked on the x-axis. The distance from the
original shot location is also included as an alternate measure of accuracy.
I intentionally chose to present consecutive shots to shine light on the strenght and weaknesses of each method. We see that the methods that only rely on a shot window or on proximity to reported location are prone to errors in much the same way that the legacy function does. Method 3 successfully filters out this noise, but is slightly my inaccurate when it comes to actually identifying shot time. Despite this inaccuracy it seems to be the most effective method for identifying shot times. Here is the code for the third method:
def correct_shotsv3(game_shots, movement, events):
fixed_shots = pd.DataFrame(columns=game_shots.columns)
for ind, shot in game_shots.iterrows():
try:
event_id = shot["GAME_EVENT_ID"]
original_time = shot["MINUTES_REMAINING"] * 60 + shot["SECONDS_REMAINING"]
x_loc_original = shot["LOC_X"]
y_loc_original = shot["LOC_Y"]
##Refine initial time window to consider as in Method 1
movement_around_shot = movement.loc[
(movement["event_id"].isin([event_id, event_id - 1]))
& (movement["game_clock"] <= original_time + 6)
& (movement["game_clock"] >= original_time - 2)
].drop_duplicates(subset=["game_clock"])
##Calculate distance from ball to original shot location
movement_around_shot["ball_dist_from_shot"] = movement_around_shot.apply(
lambda row: euclidean(
(row["x_loc"], row["y_loc"]),
(x_loc_original, y_loc_original),
)
if row["team_id"] == -1
else np.nan,
axis=1,
)
min_dist_ind = movement_around_shot.loc[movement_around_shot["game_clock"] <= original_time+6]["ball_dist_from_shot"].idxmin()
min_dist_time = movement_around_shot.loc[min_dist_ind, "game_clock"]
##Limit window to consider movement around time when ball is closest to reported shot location
movement_around_shot = movement_around_shot.loc[
(movement_around_shot["game_clock"] <= min_dist_time + 3)
& (movement_around_shot["game_clock"] >= min_dist_time - 1)
].drop_duplicates(subset=["game_clock"])
game_clock_time = movement_around_shot.query("team_id == -1")[
"game_clock"
].values
ball_height = movement_around_shot.query("team_id == -1")["radius"].values
##Implement Savitzky-Golay filter to find moment of greatest vertical acceleration
size = 10
order = 3
params = (game_clock_time, ball_height, size, order)
position_smoothed = smooth(*params, deriv=0)
acceleration_smoothed = smooth(*params, deriv=2)
max_ind = np.argmax(position_smoothed)
shot_window = acceleration_smoothed[max(0, max_ind - 25) : max_ind]
shot_min_ind = np.argmin(shot_window)
shot_ind = max_ind - shot_min_ind
shot_time = game_clock_time[shot_ind]
quarter = movement_around_shot["quarter"].values[0]
movement_around_shot = movement_around_shot.query(
"game_clock == @shot_time"
)
shot["QUARTER"] = quarter
shot["SHOT_TIME"] = shot_time
shot["LOC_X"] = movement_around_shot.query("team_id == -1")["x_loc"].values[
0
]
shot["LOC_Y"] = movement_around_shot.query("team_id == -1")["y_loc"].values[
0
]
shot["ORIGINAL_TIME"] = original_time
shot["LOC_X_ORIGINAL"] = x_loc_original
shot["LOC_Y_ORIGINAL"] = y_loc_original
shot["DIST_FROM_ORIGINAL"] = euclidean(
(shot["LOC_X"], shot["LOC_Y"]), (x_loc_original, y_loc_original)
)
except Exception:
print(f"V3 Error processing shot with event_id {shot['GAME_EVENT_ID']}")
continue
fixed_shots = pd.concat([fixed_shots, pd.DataFrame([shot])], ignore_index=True)
return fixed_shots
As a final consideration. Examine how each method alters the locations of the shots:
We can observe that the inaccuracy assosciated with Method 3 significantly impacts the shot locations. I think the best way to do it would be to take the time from the advanced methods, Method 3 in particular, but use the original reported location since precise shot locations are super important in the grand scheme of things.
Next Steps:
The purpose of this experiment was to define a methodology to identify shot times using NBA tracking data. Having accurate data in necassary to perform accurate analysis. In my case, I wanted to find the closest defender on each shot. Using this closest defender data I aim to analyze shot quality. Then I will integrate lineup rotation data from the NBA API to calculate on/off statistics for closest defender. This will give me an idea of what players contribute to good offense through their impact on the shots that their teammates get when they are on the court. All in the pursuit of qualifying the gravity that players have, how they contort opposiung defenses through their shooting and movement.
-Nick