# NBA Team Ratings, Pt. 2

In NBA Team Ratings, Pt. 1, I built a simple Bayesian Hierarchical Model achieved all our goals, i.e. applying shrinkage, taking matchups and home-court into account. That said, it left me wanting a bit more.
Specifically, that model ignores an immense amount of data, only looking at points per possession.
What if, instead of treating points per possession as a continuous variable, we estimated the probability of a team scoring 0, 1, 2, 3, or 4 points for each possession?
This would both give us useful insights into the way teams succeeded or failed, and also potentially model some of the non-linear dynamics of team matchups.

Starting off, our model of each game will be a multinomial distribution: given $ n $ trials of an event with probabilities $ p_1, p_2, \cdots, p_m $ for each potential outcome, it models the count of each outcome.
In our case, $ n $ is the number of possessions and the $ p_i $'s are the probabilities of scoring $ i $ points:

\begin{align}
\overrightarrow{x_i} &= [x_{i,0}, x_{i,1}, \cdots, x_{i,4}] \thicksim \text{Multi} \left( \text{poss}_i, \vec{p}_i \right) \notag \\
% \text{where } \overrightarrow{x_i} &= [x_{i,0}, x_{i,1}, \cdots, x_{i,4}] \notag \\
\text{where } \overrightarrow{x_i} &\equiv \text{number of possessions with 0, 1, ..., 4 points scored in the }i^\text{th} \text{ game} \notag \\
\text{poss}_i &\equiv \text{possessions in the }i^\text{th} \text{ game} \notag \\
\vec{p}_i &= [p_{i,0}, p_{i, 1}, \cdots, p_{i,4}] \notag \\
&\equiv \text{probability of 0, 1, ..., 4 points scored in a possession} \notag
\end{align}

Where things get a bit complex is in calculating $ \vec{p}_i $ for each matchup.
Say we know the average NBA team scores zero points in a possession 33\% of the time.
What percentage of possessions do we expect to have zero points when an offense that averages 50\% plays a defense that averages 25\%?
One way to combine these is through an extension of the "log5" method popularized by Bill James, by multiplying normalized odds.

First off, odds express the relative probability of an event occuring, i.e. odds are the likelihood of an event occurring, divided by the likelihood of it not occurring: $ \text{"odds"} = \frac{p}{1 - p} $.
In our NBA example, the odds of the league average team scoring no points are $ \frac{0.33}{1 - 0.33} = 0.5 $, while the offense has odds of $ \frac{0.5}{1 - 0.5} = 1 $, and the defense is at $ \frac{0.25}{1 - 0.25} = 0.33 $.
Next, we normalize each team's odds in relation to league average: the offense has $ \frac{1}{0.5} = 2 $, while the defense has $ \frac{0.33}{0.5} = 0.67 $, i.e. the offense's odds of scoring zero points are twice that of the average team, while the defense's odds are two thirds.
Finally, we multiply the normalized odds together to get a combined set of odds.

\begin{align}
\text{Given } \mathbb{P}(y) &= p_0, \text{ } \mathbb{P}(y|x_i) = p_i \notag \\
\mathbb{P}(y|x_i, x_2, \cdots, x_n) &= p \notag \\
\text{odds}_i &= \frac{p_i}{1 - p_i} \notag \\
\text{odds} &= \text{odds}_0 \prod_{i=1}^{n} \frac{\text{odds}_i}{\text{odds}_0} \notag
\end{align}

Here, each $x_i$ represents a factor, e.g. that the Phoenix Suns are on offense, that the Philadelphia 76'ers are on defense, or that the home team is on offense.
To get a combined probability out the other end, we simply reverse our original transformation of probability to odds, as follows:

\begin{align}
p &= \frac{\text{odds}}{1 + \text{odds}} \notag
\end{align}

Now that we have a way of combining probabilities to predict observations in a model, we need to define the parameters of our model.
\begin{align}
\overrightarrow{\text{off}}_i &\equiv \text{probability the ith team will score 0, 1, ..., 4 points on offense} \notag \\
\overrightarrow{\text{def}}_i &\equiv \text{probability the ith team will score 0, 1, ..., 4 points on defense} \notag \\
\overrightarrow{\text{home}}_i &\equiv \text{probability the home team will score 0, 1, ..., 4 points on offense} \notag \\
&\text{where } \overrightarrow{\text{off}}_i \text{, } \overrightarrow{\text{def}}_i \text{, and } \overrightarrow{\text{home}} \text{ are of shape } [p_0, p_1, \cdots, p_4] \notag
\end{align}

Defining priors for these model parameters requires introducing a new distribution, the Dirichlet, or multivariate beta distribution.
This distribution is commonly used in Bayesian statistics because it is the conjugate prior of the categorical, and more relevant to us, multinomial distributions.
It is a family of continuous, multivariate probability distributions parameterized by a concentration vector $\overrightarrow{\alpha}$.
We'll use it to define the $ \overrightarrow{\text{off}}_i $, $ \overrightarrow{\text{def}}_i $, and $ \overrightarrow{\text{home}} $ parameters:
\begin{align}
\overrightarrow{\text{off}}_i &\thicksim \text{Dir}(\overrightarrow{\alpha}_{\text{off}}) \notag \\
\overrightarrow{\text{def}}_i &\thicksim \text{Dir}(\overrightarrow{\alpha}_{\text{def}}) \notag \\
\overrightarrow{\text{home}} &\thicksim \text{Dir}(\overrightarrow{\alpha}_{\text{home}}) \notag
\end{align}


The mean of the Dirichlet distribution is defined by an L1-normalized $ \overrightarrow{\alpha} $:
\begin{align}
\operatorname{\mathbb{E}} \left(\text{Dir}(\overrightarrow{\alpha})\right) &= \frac{\overrightarrow{\alpha}}{\alpha_0} \notag \\
\text{where } \alpha_0 &\equiv \sum_i{\alpha_i} \notag
\end{align}

Since the variance is conveniently proportional to $ \frac{1}{\alpha_0^2} $, this allows us to parametrize $ \overrightarrow{\alpha}_{\text{off}} $, $ \overrightarrow{\alpha}_{\text{def}} $, and $ \overrightarrow{\alpha}_{\text{home}} $ as simply proportional to our prior for their probabilities.
For $ \overrightarrow{\text{off}}_i $ and $ \overrightarrow{\text{def}}_i $, our prior is simply league average, but for $ \overrightarrow{\text{home}} $, I used a prior relative to league average based on the improved performance of home teams in previous seasons.
I also chose to fix $ \alpha_{0,\text{home}} $ based on some experimentation, but used hyperpriors for the $ \alpha_{0,\text{off}}$ and $\alpha_{0,\text{def}}$ hyperparameters, using the familiar Gamma distribution:
\begin{align}
\alpha_{0,\text{off}} &\thicksim \text{Gamma}(1{,}000, 300) \notag \\
\alpha_{0,\text{def}} &\thicksim \text{Gamma}(2{,}000, 500) \notag \\
\alpha_{0,\text{home}} &\equiv 100{,}000 \notag
\end{align}

Running this new model on the 2019 NBA Regular Season yields similar results despite a completely different model formulation:


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

from pynba import possessions_from_file, team_id_to_abb, plot_ratings, use_blackontrans_style


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


```python
possessions = possessions_from_file(league, year, season_type)
```


```python
possessions["points_scored_corrected"] = possessions["points_scored"].map(lambda val: min(val, 4))
grouped = possessions.groupby(by=["off_team_id", "def_team_id", "home_team_id"], as_index=False)
matchups = grouped.agg(
    {
        "league": "first",
        "year": "first",
        "season_type": "first",
        "date": "first",
        "points_scored_corrected": "sum",
        "possession_num": "count",
    }
)
results = np.array([
    [(subgroup["points_scored_corrected"] == ind).sum() for ind in range(5)]
    for _, subgroup in grouped
])
```


```python
n_teams = matchups["off_team_id"].unique().shape[0]
team_id_to_team_ind = {
    team_id: team_ind
    for team_ind, team_id in enumerate(matchups["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()
}
mu = results.sum(0) / results.sum()
off_index = (
    matchups["off_team_id"].map(team_id_to_team_ind).to_numpy()
)
def_index = (
    matchups["def_team_id"].map(team_id_to_team_ind).to_numpy()
)
home_index = (
    (matchups["off_team_id"] != matchups["home_team_id"])
    .to_numpy()
    .astype(int)
)

with pm.Model() as model:
    alpha0_off = pm.Gamma("alpha0_off", mu=1000, sigma=300)
    alpha0_def = pm.Gamma("alpha0_def", mu=2000, sigma=500)
    alpha_off = mu * alpha0_off
    alpha_def = mu * alpha0_def
    alpha_home = (mu + np.array([-0.006, 0, 0.004, 0.002, 0])) * 100000
    teams_off = pm.Dirichlet("team_off", alpha_off, shape=(n_teams, 5))
    teams_def = pm.Dirichlet("team_def", alpha_def, shape=(n_teams, 5))
    hc_off = pm.Dirichlet("hc_off", alpha_home)
    hc_def = 2 * mu - hc_off
    offense = teams_off[off_index]
    defense = teams_def[def_index]
    home_court = pm.math.stack([hc_off, hc_def])[home_index]
    mu0 = np.tile((mu / (1 - mu)), (off_index.shape[0], 1))
    p0 = offense / (1 - offense) * defense / (1 - defense) * home_court / (1 - home_court) / mu0 ** 2
    p = p0 / (p0 + 1)
    pm.Multinomial("game_result", n=matchups["possession_num"].to_numpy().reshape(-1, 1), p=p, observed=results)
```


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

    2021-07-10 21:21:39 UTC | /home/jupyter/venv/lib/python3.8/site-packages/pymc3/sampling.py:sample @ 495 | INFO | Auto-assigning NUTS sampler...
    2021-07-10 21:21:39 UTC | /home/jupyter/venv/lib/python3.8/site-packages/pymc3/sampling.py:init_nuts @ 2081 | INFO | Initializing NUTS using adapt_diag...
    2021-07-10 21:23:15 UTC | /home/jupyter/venv/lib/python3.8/site-packages/pymc3/sampling.py:sample @ 556 | INFO | Multiprocess sampling (4 chains in 4 jobs)
    2021-07-10 21:23:15 UTC | /home/jupyter/venv/lib/python3.8/site-packages/pymc3/sampling.py:_print_step_hierarchy @ 232 | INFO | NUTS: [hc_off, team_def, team_off, alpha0_def, alpha0_off]




<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 44:17<00:00 Sampling 4 chains, 0 divergences]
</div>



    2021-07-10 22:07:34 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 2658 seconds.



```python
off_probs = trace["team_off"].mean(0)
off_ratings = ((off_probs - mu).dot(np.arange(5).reshape(-1, 1)) * 100).reshape(-1)
def_probs = trace["team_def"].mean(0)
def_ratings = ((mu - def_probs).dot(np.arange(5).reshape(-1, 1)) * 100).reshape(-1)
```


```python
results_df = pd.DataFrame({
    "team": [team_ind_to_team_abb[ind] for ind in range(n_teams)],
    "net_scoring_above_average": off_ratings + def_ratings,
    "off_scoring_above_average": off_ratings,
    "def_scoring_above_average": def_ratings,
    "league": league,
    "year": year,
    "season_type": season_type,
})

results_df.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>12</th>
      <td>MIL</td>
      <td>6.514540</td>
      <td>3.146588</td>
      <td>3.367953</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>7</th>
      <td>GSW</td>
      <td>5.594340</td>
      <td>4.637548</td>
      <td>0.956792</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>24</th>
      <td>TOR</td>
      <td>4.441016</td>
      <td>2.327319</td>
      <td>2.113697</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>8</th>
      <td>HOU</td>
      <td>4.162117</td>
      <td>4.397545</td>
      <td>-0.235429</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>25</th>
      <td>UTA</td>
      <td>3.990294</td>
      <td>0.373729</td>
      <td>3.616566</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>20</th>
      <td>POR</td>
      <td>3.693816</td>
      <td>3.550877</td>
      <td>0.142939</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>6</th>
      <td>DEN</td>
      <td>3.569358</td>
      <td>2.111179</td>
      <td>1.458179</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>1</th>
      <td>BOS</td>
      <td>3.028115</td>
      <td>1.238901</td>
      <td>1.789215</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>23</th>
      <td>OKC</td>
      <td>2.663322</td>
      <td>-0.084723</td>
      <td>2.748045</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>17</th>
      <td>IND</td>
      <td>2.035635</td>
      <td>-0.513273</td>
      <td>2.548908</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>18</th>
      <td>PHI</td>
      <td>1.834752</td>
      <td>1.550283</td>
      <td>0.284469</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>22</th>
      <td>SAS</td>
      <td>1.592312</td>
      <td>2.181690</td>
      <td>-0.589378</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>9</th>
      <td>LAC</td>
      <td>1.000700</td>
      <td>1.548013</td>
      <td>-0.547313</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>16</th>
      <td>ORL</td>
      <td>0.168599</td>
      <td>-1.424785</td>
      <td>1.593384</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>14</th>
      <td>BKN</td>
      <td>-0.243152</td>
      <td>-0.789682</td>
      <td>0.546530</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>21</th>
      <td>SAC</td>
      <td>-0.707013</td>
      <td>-0.003210</td>
      <td>-0.703804</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>29</th>
      <td>CHA</td>
      <td>-0.756276</td>
      <td>1.147352</td>
      <td>-1.903628</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>11</th>
      <td>MIA</td>
      <td>-0.780095</td>
      <td>-2.693527</td>
      <td>1.913432</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>28</th>
      <td>DET</td>
      <td>-0.780359</td>
      <td>-1.442858</td>
      <td>0.662499</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>3</th>
      <td>NOP</td>
      <td>-0.836531</td>
      <td>0.932292</td>
      <td>-1.768823</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>5</th>
      <td>DAL</td>
      <td>-0.945833</td>
      <td>-0.863206</td>
      <td>-0.082627</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>13</th>
      <td>MIN</td>
      <td>-0.973287</td>
      <td>0.872847</td>
      <td>-1.846134</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>10</th>
      <td>LAL</td>
      <td>-1.033815</td>
      <td>-2.046979</td>
      <td>1.013165</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>27</th>
      <td>WAS</td>
      <td>-1.943052</td>
      <td>0.585976</td>
      <td>-2.529028</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>26</th>
      <td>MEM</td>
      <td>-1.991411</td>
      <td>-3.615139</td>
      <td>1.623728</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>0</th>
      <td>ATL</td>
      <td>-4.306885</td>
      <td>-1.707600</td>
      <td>-2.599285</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>19</th>
      <td>PHX</td>
      <td>-6.543554</td>
      <td>-3.640806</td>
      <td>-2.902748</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>4</th>
      <td>CHI</td>
      <td>-6.706195</td>
      <td>-4.482405</td>
      <td>-2.223790</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>15</th>
      <td>NYK</td>
      <td>-7.208345</td>
      <td>-4.755111</td>
      <td>-2.453234</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
    <tr>
      <th>2</th>
      <td>CLE</td>
      <td>-7.741308</td>
      <td>-2.322653</td>
      <td>-5.418655</td>
      <td>nba</td>
      <td>2019</td>
      <td>Regular Season</td>
    </tr>
  </tbody>
</table>
</div>




```python
use_blackontrans_style()

fig = plt.figure(figsize=(8, 8))
ax = fig.add_subplot(1, 1, 1)
points_mu = possessions["points_scored_corrected"].sum() / possessions.shape[0] * 100
for team, off_aa, def_aa in zip(results_df["team"], results_df["off_scoring_above_average"], results_df["def_scoring_above_average"]):
    team_id = team_abb_to_team_id[team]
    filt = matchups["off_team_id"] == team_id
    raw_off = matchups.loc[filt, "points_scored_corrected"].sum() / matchups.loc[filt, "possession_num"].sum() * 100 - points_mu
    filt = matchups["def_team_id"] == team_id
    raw_def = points_mu - matchups.loc[filt, "points_scored_corrected"].sum() / matchups.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_df, ax)

```

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