# NBA Team Ratings, Pt. 1

For each game, we observe the number of points, possessions, home court, and the teams involved.
Then, for each side of the court, we model the points scored (normalized by possessions) as a normal distribution with mean determined by the teams along with home court.

\begin{align}
x_i &\thicksim \mathcal{N}\left(\frac{\text{poss}_i}{100} \left( \mu + \text{off}_i - \text{def}_i + \delta_\text{home} \text{home} \right), \frac{\text{poss}_i}{100}\sigma_{\text{game}}^2 \right) \notag \\
\text{where } x_i &\equiv \text{points scored in the }i^\text{th} \text{ game} \notag \\
\text{poss}_i &\equiv \text{possessions in the }i^\text{th} \text{ game} \notag \\
\delta_\text{home} &\equiv \begin{cases} 1 & \text{if home team is on offense} \\ -1 & \text{otherwise} \end{cases} \notag \\
\mu &\equiv \text{league average scoring rate per 100 possessions} \notag
\end{align}

This model has four parameters of interest:
1. $ \overrightarrow{\text{off}} $, the points scored per 100 possessions above league average for each team
2. $ \overrightarrow{\text{def}} $, the points allowed per 100 possessions above league average for each team
3. $ \text{home} $, the points scored per 100 possessions above league average by the home team
4. $ \sigma_{\text{game}} $, the variance for the number of points scored per 100 possessions

These paremeters each have a prior distribution (since we're being Bayesian about it):

\begin{align}
    \overrightarrow{\text{off}} &\thicksim \mathcal{N}(0, \sigma_{\text{off}} ^ 2) \notag \\
    \overrightarrow{\text{def}} &\thicksim \mathcal{N}(0, \sigma_{\text{def}} ^ 2) \notag \\
    \text{home} &\thicksim \text{Gamma}(1.5, 0.5) \notag \\
    \sigma_{\text{game}} &\thicksim \text{Gamma}(5, 2) \notag
\end{align}

Note that each prior has its own set of parameters, aka hyperparameters, and specifically, that the priors for $ \overrightarrow{\text{off}} $ and $ \overrightarrow{\text{def}} $ have parameters with their own priors, aka hyperpriors.
The $ \text{home} $ and $ \sigma_{\text{game}} $ parameters use a Gamma prior.
This distribution is conveniently continuous and positive, making it a common one for the prior of a positive parameter like the standard deviation.
For that reason, I've also used it for the two hyperpriors:

\begin{align}
    \sigma_{\text{off}} &\thicksim \text{Gamma}(2, 0.5) \notag \\
    \sigma_{\text{def}} &\thicksim \text{Gamma}(1.5, 0.4) \notag \\
\end{align}

I haven't been too careful with setting these priors since this model is mostly for demonstration, but I've run it on the 2019 NBA Regular Season:


```python
import pymc3 as pm
from matplotlib import pyplot as plt
import pandas as pd
import numpy as np

from pynba import halfgames_from_file, team_id_to_abb, plot_ratings, use_blackontrans_style


league = "nba"
year = 2019
season_type = "Regular Season"
```


```python
halfgames = halfgames_from_file(league, year, season_type)
```


```python
n_teams = halfgames["off_team_id"].unique().shape[0]
team_id_to_team_ind = {
    team_id: team_ind
    for team_ind, team_id in enumerate(halfgames["off_team_id"].unique())
}
team_id_to_team_abb = team_id_to_abb(league, year)
team_abb_to_team_id = {
    team_abb: team_id
    for team_id, team_abb in team_id_to_team_abb.items()
}
team_ind_to_team_abb = {
    team_ind: team_id_to_team_abb[team_id]
    for team_id, team_ind in team_id_to_team_ind.items()
}
points_mu = halfgames["points_scored"].sum() / halfgames["possession_num"].sum() * 100
off_index = (
    halfgames["off_team_id"].map(team_id_to_team_ind).to_numpy()
)
def_index = (
    halfgames["def_team_id"].map(team_id_to_team_ind).to_numpy()
)
home_index = (
    (halfgames["off_team_id"] != halfgames["home_team_id"])
    .to_numpy()
    .astype(int)
) * 2 - 1


with pm.Model() as model:
    sigma_game = pm.Gamma("sigma_game", mu=5, sigma=2)
    sigma_off = pm.Gamma("sigma_off", mu=2, sigma=0.5)
    sigma_def = pm.Gamma("sigma_def", mu=1.5, sigma=0.4)
    home = pm.Gamma("home", mu=1.5, sigma=0.5)
    teams_off = pm.Normal("teams_off", mu=0, sigma=sigma_off, shape=n_teams)
    teams_def = pm.Normal("teams_def", mu=0, sigma=sigma_def, shape=n_teams)
    poss_norm = halfgames["possession_num"].to_numpy() / 100
    pm.Normal(
        "points",
        mu=poss_norm * (points_mu + teams_off[off_index] - teams_def[def_index] + home * home_index),
        sigma=poss_norm * sigma_game,
        observed=halfgames["points_scored"].to_numpy()
    )
```


```python
with model:
    trace = pm.sample(
        draws=10000,
        init="adapt_diag",
        chains=4,
        random_seed=42,
        return_inferencedata=False,
    )
```

    2021-05-17 21:47:06 UTC | /home/jupyter/venv/lib/python3.8/site-packages/pymc3/sampling.py:sample @ 495 | INFO | Auto-assigning NUTS sampler...
    2021-05-17 21:47:06 UTC | /home/jupyter/venv/lib/python3.8/site-packages/pymc3/sampling.py:init_nuts @ 2081 | INFO | Initializing NUTS using adapt_diag...
    2021-05-17 21:47:14 UTC | /home/jupyter/venv/lib/python3.8/site-packages/pymc3/sampling.py:sample @ 556 | INFO | Multiprocess sampling (4 chains in 4 jobs)
    2021-05-17 21:47:14 UTC | /home/jupyter/venv/lib/python3.8/site-packages/pymc3/sampling.py:_print_step_hierarchy @ 232 | INFO | NUTS: [teams_def, teams_off, home, sigma_def, sigma_off, sigma_game]




<div>
    <style>
        /* Turns off some styling */
        progress {
            /* gets rid of default border in Firefox and Opera. */
            border: none;
            /* Needs to be in here for Safari polyfill so background images work as expected. */
            background-size: auto;
        }
        .progress-bar-interrupted, .progress-bar-interrupted::-webkit-progress-bar {
            background: #F44336;
        }
    </style>
  <progress value='44000' class='' max='44000' style='width:300px; height:20px; vertical-align: middle;'></progress>
  100.00% [44000/44000 00:45<00:00 Sampling 4 chains, 0 divergences]
</div>



    2021-05-17 21:48:00 UTC | /home/jupyter/venv/lib/python3.8/site-packages/pymc3/sampling.py:sample @ 628 | INFO | Sampling 4 chains for 1_000 tune and 10_000 draw iterations (4_000 + 40_000 draws total) took 46 seconds.



```python
results = pd.DataFrame({
    "team": [team_ind_to_team_abb[ind] for ind in range(n_teams)],
    "net_scoring_above_average": trace["teams_off"].mean(0) + trace["teams_def"].mean(0),
    "off_scoring_above_average": trace["teams_off"].mean(0),
    "def_scoring_above_average": trace["teams_def"].mean(0),
    "league": league,
    "year": year,
    "season_type": season_type,
})

results.sort_values("net_scoring_above_average", ascending=False)
```




<div>
<style scoped>
    .dataframe tbody tr th:only-of-type {
        vertical-align: middle;
    }

    .dataframe tbody tr th {
        vertical-align: top;
    }

    .dataframe thead th {
        text-align: right;
    }
</style>
<table border="1" class="dataframe">
  <thead>
    <tr style="text-align: right;">
      <th></th>
      <th>team</th>
      <th>net_scoring_above_average</th>
      <th>off_scoring_above_average</th>
      <th>def_scoring_above_average</th>
      <th>league</th>
      <th>year</th>
      <th>season_type</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th>4</th>
      <td>MIL</td>
      <td>6.210715</td>
      <td>2.802680</td>
      <td>3.408035</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>2</th>
      <td>GSW</td>
      <td>5.181949</td>
      <td>4.292881</td>
      <td>0.889067</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>15</th>
      <td>TOR</td>
      <td>4.262034</td>
      <td>2.190613</td>
      <td>2.071421</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>21</th>
      <td>UTA</td>
      <td>3.961912</td>
      <td>0.298230</td>
      <td>3.663683</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>17</th>
      <td>HOU</td>
      <td>3.784661</td>
      <td>4.048864</td>
      <td>-0.264203</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>29</th>
      <td>POR</td>
      <td>3.508986</td>
      <td>3.373137</td>
      <td>0.135849</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>22</th>
      <td>DEN</td>
      <td>3.437770</td>
      <td>2.023928</td>
      <td>1.413842</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>0</th>
      <td>BOS</td>
      <td>2.932447</td>
      <td>1.154435</td>
      <td>1.778012</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>3</th>
      <td>OKC</td>
      <td>2.686181</td>
      <td>-0.140427</td>
      <td>2.826608</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>8</th>
      <td>IND</td>
      <td>2.030188</td>
      <td>-0.486166</td>
      <td>2.516354</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>1</th>
      <td>PHI</td>
      <td>1.689404</td>
      <td>1.467895</td>
      <td>0.221509</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>19</th>
      <td>SAS</td>
      <td>1.419684</td>
      <td>1.933909</td>
      <td>-0.514225</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>23</th>
      <td>LAC</td>
      <td>0.864585</td>
      <td>1.477342</td>
      <td>-0.612757</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>11</th>
      <td>ORL</td>
      <td>0.280951</td>
      <td>-1.289209</td>
      <td>1.570159</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>6</th>
      <td>BKN</td>
      <td>-0.223051</td>
      <td>-0.804554</td>
      <td>0.581502</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>10</th>
      <td>MIA</td>
      <td>-0.498958</td>
      <td>-2.427690</td>
      <td>1.928733</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>7</th>
      <td>DET</td>
      <td>-0.698599</td>
      <td>-1.298317</td>
      <td>0.599718</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>20</th>
      <td>SAC</td>
      <td>-0.713353</td>
      <td>0.041432</td>
      <td>-0.754785</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>5</th>
      <td>CHA</td>
      <td>-0.867620</td>
      <td>1.065196</td>
      <td>-1.932815</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>24</th>
      <td>DAL</td>
      <td>-0.871049</td>
      <td>-0.698385</td>
      <td>-0.172665</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>16</th>
      <td>NOP</td>
      <td>-0.956585</td>
      <td>0.852863</td>
      <td>-1.809448</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>28</th>
      <td>LAL</td>
      <td>-0.965957</td>
      <td>-1.885082</td>
      <td>0.919124</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>18</th>
      <td>MIN</td>
      <td>-1.034573</td>
      <td>0.836243</td>
      <td>-1.870816</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>9</th>
      <td>MEM</td>
      <td>-1.803026</td>
      <td>-3.420818</td>
      <td>1.617792</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>27</th>
      <td>WAS</td>
      <td>-2.018878</td>
      <td>0.512290</td>
      <td>-2.531167</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>12</th>
      <td>ATL</td>
      <td>-4.065261</td>
      <td>-1.511523</td>
      <td>-2.553738</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>25</th>
      <td>PHX</td>
      <td>-6.408621</td>
      <td>-3.452818</td>
      <td>-2.955804</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>26</th>
      <td>CHI</td>
      <td>-6.488680</td>
      <td>-4.154362</td>
      <td>-2.334318</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>13</th>
      <td>NYK</td>
      <td>-6.939783</td>
      <td>-4.419421</td>
      <td>-2.520363</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>14</th>
      <td>CLE</td>
      <td>-7.786211</td>
      <td>-2.140490</td>
      <td>-5.645721</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
  </tbody>
</table>
</div>



In addition to plotting the offensive and defensive ratings of each team, I've annotated the difference between each team's "raw" ratings and their modeled ones.
We can see that although the model is factoring in strength of schedule and home-court advantage, it's primary effect is "shrinking" the raw ratings.
This is due to the priors for each team rating, which make extreme values less likely.


```python
use_blackontrans_style()

fig = plt.figure(figsize=(8, 8))
ax = fig.add_subplot(1, 1, 1)
for team, off_aa, def_aa in zip(results["team"], results["off_scoring_above_average"], results["def_scoring_above_average"]):
    team_id = team_abb_to_team_id[team]
    filt = halfgames["off_team_id"] == team_id
    raw_off = halfgames.loc[filt, "points_scored"].sum() / halfgames.loc[filt, "possession_num"].sum() * 100 - points_mu
    filt = halfgames["def_team_id"] == team_id
    raw_def = points_mu - halfgames.loc[filt, "points_scored"].sum() / halfgames.loc[filt, "possession_num"].sum() * 100
    ax.annotate("", xy=(off_aa, def_aa), xytext=(raw_off, raw_def), arrowprops={"arrowstyle": "->", "color": "white"})
plot_ratings(results, ax)

```

bc62f03aea93f9a9064c241c23efd91219e446ff
ipynb
Jupyter Notebook
notebooks/NBA Team Ratings, Pt. 1.ipynb
matteosox/nba
e993bd1167242fd18355bbf78a8d8245978ab3a2
Apache-2.0
2021-11-05T15:02:49.000Z
2021-12-19T14:06:02.000Z
notebooks/NBA Team Ratings, Pt. 1.ipynb
matteosox/nba
e993bd1167242fd18355bbf78a8d8245978ab3a2
Apache-2.0
2021-04-04T05:22:19.000Z
2022-03-08T06:26:22.000Z
notebooks/NBA Team Ratings, Pt. 1.ipynb
matteosox/nba
e993bd1167242fd18355bbf78a8d8245978ab3a2
Apache-2.0
Qwen/Qwen-72B
1. YES
2. YES