Fitting NBA Three-Point Percentage

Group players according to their 2015 three-point percentages, then assign every player in a group the average observed 2016 percentage for that group. Narrower groups give the model more flexibility to follow the observed 2016 percentages.

Both seasons are already observed. The same players’ 2016 percentages are used to calculate the group averages and to assess how closely the fitted values match the data. The resulting RMSE is training error: it describes fit to these players, not how accurately 2015 percentage would forecast a new player or a future season.

The dataset contains one row for every NBA player-season from 1997 through 2016. It has not been filtered according to season or number of three-point attempts. Statistics for players who appeared on multiple teams in one season have already been combined so that each player has at most one row per season.

Prepare the Data

  1. Download nba_player_tpp.csv, save it in your data folder, and run the following code to load it as a tbl called shooting_raw.
shooting_raw <- read_csv("data/nba_player_tpp.csv")

The columns are:

  • Player: player name
  • Season: season
  • TPM: three-pointers made
  • TPA: three-pointers attempted
  • TPP: three-point percentage
  1. Use filter() to keep only the 2015 and 2016 seasons and player-seasons with at least 100 three-point attempts. Save the resulting tbl as shooting_filtered.

  2. Some players met the attempt requirement in only one of the two seasons. Starting with shooting_filtered:

    • group the data by Player
    • use filter() and n() to keep players with two rows
    • ungroup the data
    • select Player, Season, TPA, and TPP
    • use pivot_wider() to put the 2015 and 2016 values in separate columns

Save the result as shooting. It should have one row per player and columns named TPA_2015, TPA_2016, TPP_2015, and TPP_2016.

shooting <- shooting_filtered %>%
  group_by(Player) %>%
  filter(...) %>%
  ungroup() %>%
  select(...) %>%
  pivot_wider(
    names_from = ...,
    values_from = ...
  )
  1. Use head() to inspect shooting. Then make a scatterplot with TPP_2015 on the horizontal axis and TPP_2016 on the vertical axis. Use alpha to make overlapping points easier to see, and give the axes informative labels.

Model 0: The Overall Mean

Begin with a constant model that does not use TPP_2015: assign every player the overall mean of TPP_2016 as a fitted value.

  1. Use mutate() and mean() to add a column called yhat_0 containing this fitted value for every player.

The fitted-value plots use the same layered structure as the plots in Lecture 5: the first geom_point() layer shows the observed 2016 percentages, and the second geom_point() layer shows the model’s fitted values.

  1. Create the first layered plot. Plot the observed TPP_2016 values in transparent gray and the yhat_0 fitted values in red. Save the result as plot_0.

  2. Compute the training RMSE of yhat_0. Save the result as a one-row tbl called rmse_0.

RMSE=mean((observedfitted value)2)RMSE = \sqrt{\text{mean}\left((\text{observed} - \text{fitted value})^2\right)}

Model 1: Two Groups

We can use 2015 three-point percentage by dividing the players into two groups: those below the overall 2015 average and those above it. Within each group, we will use the average observed TPP_2016 as the fitted value.

  1. Use cut() to divide TPP_2015 at its overall mean. Then group the data by the resulting intervals and add the fitted value as a column called yhat_1. Remember to ungroup the data and remove the temporary bins column when you finish.

The endpoints 0.15 and 0.60 are slightly beyond the range of percentages in this dataset. The middle cut point should be mean(shooting$TPP_2015).

shooting <- shooting %>%
  mutate(bins = cut(
    TPP_2015,
    breaks = c(0.15, mean(shooting$TPP_2015), 0.60),
    include.lowest = TRUE
  )) %>%
  group_by(...) %>%
  mutate(yhat_1 = ...) %>%
  ungroup() %>%
  select(-bins)
  1. Recreate the same layered plot, but replace yhat_0 with yhat_1. Use blue for the fitted-value layer, change the title, and save the result as plot_1.

Models 2–4: Increasing the Number of Bins

We will now repeat the same process with increasingly narrow intervals. The seq() function will create evenly spaced cut points between 0.15 and 0.60.

  1. Divide TPP_2015 into intervals of width 0.05 and add the within-bin fitted value as yhat_2. Then create plot_2 using the same two layers as above, with green points for the fitted values.
seq(from = 0.15, to = 0.60, by = 0.05)
  1. Repeat the process using intervals of width 0.025. Call the fitted values yhat_3, use purple fitted-value points, and save the plot as plot_3.

  2. Repeat the process one more time using intervals of width 0.01. Call the fitted values yhat_4, use orange fitted-value points, and save the plot as plot_4.

Compare the Fitted Values Visually

  1. Run the code below to reshape the five fitted-value columns into a long tbl called fit_plot_data. We will use this long tbl only for the faceted plot in this question.
fit_plot_data <- shooting %>%
  select(Player, TPP_2015, TPP_2016, starts_with("yhat_")) %>%
  pivot_longer(
    cols = starts_with("yhat_"),
    names_to = "Model",
    values_to = "Fitted_Value"
  )

Use fit_plot_data to make a faceted plot with one panel for each model. In every panel, use one point layer for the observed TPP_2016 values in transparent gray and another point layer for the corresponding Fitted_Value values in blue. Use facet_wrap() with Model as the faceting variable.

Compare Training Error

  1. Using shooting, compute the training RMSE of all five fitted-value columns in a one-row tbl called model_rmse.

  2. Reshape the one-row model_rmse tbl from Question 14 into a two-column tbl named model_rmse_long, with columns named Model and RMSE. Then make a point-and-line plot of RMSE across the five models. The model names should appear on the horizontal axis in order from rmse_0 through rmse_4.

As the bins become narrower, the fitted values can follow the observed outcomes increasingly closely, so training RMSE decreases. A bin containing one player would reproduce that player’s 2016 percentage exactly, but that does not establish a pattern that will hold for another player or season. In the next lecture, a straight line will replace these abrupt jumps and allow all of the players to inform the fit across the range of 2015 percentages.