`API = "http://ergast.com/api/f1"`

```
%matplotlib inline
import json
import os
import warnings
from enum import Enum
from pathlib import Path
warnings.filterwarnings("ignore", category=UserWarning)
warnings.filterwarnings("ignore", category=FutureWarning)
import arviz as az
import numpy as np
import pandas as pd
import preliz as pz
import pymc as pm
import requests
import seaborn as sns
import tqdm
from matplotlib import pyplot as plt
from scipy import stats
sns.set_theme()
sns.set_style(style="darkgrid", rc={"axes.facecolor": ".9", "grid.color": ".8"})
sns.set_palette(palette="deep")
sns_c = sns.color_palette(palette="deep")
plt.rcParams["figure.figsize"] = [10, 7]
az.style.use("arviz-docgrid")
```

`WARNING (pytensor.tensor.blas): Using NumPy C-API based implementation for BLAS functions.`

Like many others I got into Formula 1 after binging through Drive to Survive. After enduring a nail biting (and ultimately bitterly disappointing) 2021 season, and attending the 2022 Canadian Grand Prix, I organized a small fantasy league for 2023, hosted on Fantasy GP.

Fantasy GP’s rules give each player a set budget to build a team consisting of 3 drivers and 3 constructor. During the season, drivers and constructors are awarded points for position finished during at the race (matching the actual world championship points awarded), as well as for bonuses such as beating their teammate and gaining positions during the race. This effectively brings lower midfied and backmarker drivers into the mix, since they can generate points from performance relative to their rivals.

This notebook will attempt to model individual driver and constructor performance in terms of Fantasy GP points earned, in order to provide a slightly more quantifiable basis for team construction. We focus on the most fundamental question - how many points per race is a given driver/constructor likely to earn. To answer this, we will create and evaluate a simple Bayesian linear model using the probabilistic programming framework PyMC.

Disclaimer

I have a fairly limited knowledge of Bayesian statistics and modeling; this notebook shouldn’t be considered authoritative of prescriptive in any way. It is mostly a collection of notes, reflecting ongoing learning.

We start by acquiring data for the 2023 season. The Ergast API hosts statistics for Formula 1 races from the 1950s to the present day and exposes convenient endpoints for querying both qualifying and grand prix results.

First, we define some convenience functions to query the raw json data:

`API = "http://ergast.com/api/f1"`

```
DATASET_PATH = {"gp": "results", "quali": "qualifying"}
def get_dataset(dataset: str, year: int, limit: int = 10000) -> dict:
path = DATASET_PATH[dataset]
url = f"{API}/{year}/{path}.json?limit={limit}"
response = requests.get(url)
response.raise_for_status()
return response.json()
def load_dataset(dataset: str, year: int) -> dict:
results_path = Path(f".data/{year}_{dataset}_results.json")
if results_path.exists():
with results_path.open() as f:
results = json.loads(f.read())
else:
results = get_dataset(dataset, year)
if not os.path.isdir(".data"):
os.makedirs(".data")
with results_path.open("w") as f:
f.write(json.dumps(results))
return results
```

```
gp_data = load_dataset("gp", 2023)
quali_data = load_dataset("quali", 2023)
```

Next, we unnest it into a row-wise format and load it as a Pandas dataframe for ease of use:

```
def gp_results_to_dataframe(json_data):
data = {}
for gp in json_data["MRData"]["RaceTable"]["Races"]:
for result in gp["Results"]:
data.setdefault("circuit", []).append(gp["Circuit"]["circuitId"])
data.setdefault("driver", []).append(result["Driver"]["code"])
data.setdefault("round", []).append(gp["round"])
data.setdefault("constructor", []).append(
result["Constructor"]["constructorId"]
)
data.setdefault("grid", []).append(result["grid"])
data.setdefault("position", []).append(result["position"])
data.setdefault("points", []).append(result["points"])
data.setdefault("fastest_lap", []).append(
result.get("FastestLap", {}).get("rank")
)
data.setdefault("status", []).append(result["status"])
return (
pd.DataFrame(data)
.astype(
{
"circuit": "category",
"driver": "category",
"round": "int",
"constructor": "category",
"status": "category",
"grid": "Int64",
"position": "Int64",
"points": "float32",
"fastest_lap": "Int64",
}
)
.set_index(["circuit", "driver"])
)
def quali_results_to_dataframe(json_data):
data = {}
for quali in json_data["MRData"]["RaceTable"]["Races"]:
for result in quali["QualifyingResults"]:
data.setdefault("circuit", []).append(quali["Circuit"]["circuitId"])
data.setdefault("driver", []).append(result["Driver"]["code"])
data.setdefault("round", []).append(quali["round"])
data.setdefault("constructor", []).append(
result["Constructor"]["constructorId"]
)
data.setdefault("qualifying_position", []).append(result["position"])
return (
pd.DataFrame(data)
.astype(
{
"circuit": "category",
"driver": "category",
"round": "int",
"constructor": "category",
"qualifying_position": "Int64",
}
)
.set_index(["circuit", "driver"])
)
```

```
gp_df = gp_results_to_dataframe(gp_data)
gp_df
```

round | constructor | grid | position | points | fastest_lap | status | ||
---|---|---|---|---|---|---|---|---|

circuit | driver | |||||||

bahrain | VER | 1 | red_bull | 1 | 1 | 25.0 | 6 | Finished |

PER | 1 | red_bull | 2 | 2 | 18.0 | 7 | Finished | |

ALO | 1 | aston_martin | 5 | 3 | 15.0 | 5 | Finished | |

SAI | 1 | ferrari | 4 | 4 | 12.0 | 14 | Finished | |

HAM | 1 | mercedes | 7 | 5 | 10.0 | 10 | Finished | |

... | ... | ... | ... | ... | ... | ... | ... | ... |

monza | STR | 14 | aston_martin | 20 | 16 | 0.0 | 17 | Finished |

HUL | 14 | haas | 13 | 17 | 0.0 | 10 | +1 Lap | |

MAG | 14 | haas | 19 | 18 | 0.0 | 15 | +1 Lap | |

OCO | 14 | alpine | 18 | 19 | 0.0 | 19 | Steering | |

TSU | 14 | alphatauri | 11 | 20 | 0.0 | <NA> | Engine |

280 rows × 7 columns

```
quali_df = quali_results_to_dataframe(quali_data)
quali_df
```

round | constructor | qualifying_position | ||
---|---|---|---|---|

circuit | driver | |||

bahrain | VER | 1 | red_bull | 1 |

PER | 1 | red_bull | 2 | |

LEC | 1 | ferrari | 3 | |

SAI | 1 | ferrari | 4 | |

ALO | 1 | aston_martin | 5 | |

... | ... | ... | ... | ... |

monza | ZHO | 14 | alfa | 16 |

GAS | 14 | alpine | 17 | |

OCO | 14 | alpine | 18 | |

MAG | 14 | haas | 19 | |

STR | 14 | aston_martin | 20 |

280 rows × 3 columns

Finally, we join the grand prix and qualifying dataframes together, forming the base dataset for our analysis; we also rename all entries credited to Liam Lawson (LAW) to Daniel Ricciardio (RIC), as Fantasy GP credits points from the temporary reserve driver to the driver they replace:

```
data_df = gp_df.join(quali_df[["qualifying_position"]]).rename(
level="driver", index={"LAW": "RIC"}
)
data_df
```

round | constructor | grid | position | points | fastest_lap | status | qualifying_position | ||
---|---|---|---|---|---|---|---|---|---|

circuit | driver | ||||||||

bahrain | VER | 1 | red_bull | 1 | 1 | 25.0 | 6 | Finished | 1 |

PER | 1 | red_bull | 2 | 2 | 18.0 | 7 | Finished | 2 | |

ALO | 1 | aston_martin | 5 | 3 | 15.0 | 5 | Finished | 5 | |

SAI | 1 | ferrari | 4 | 4 | 12.0 | 14 | Finished | 4 | |

HAM | 1 | mercedes | 7 | 5 | 10.0 | 10 | Finished | 7 | |

... | ... | ... | ... | ... | ... | ... | ... | ... | ... |

monza | STR | 14 | aston_martin | 20 | 16 | 0.0 | 17 | Finished | 20 |

HUL | 14 | haas | 13 | 17 | 0.0 | 10 | +1 Lap | 13 | |

MAG | 14 | haas | 19 | 18 | 0.0 | 15 | +1 Lap | 19 | |

OCO | 14 | alpine | 18 | 19 | 0.0 | 19 | Steering | 18 | |

TSU | 14 | alphatauri | 11 | 20 | 0.0 | <NA> | Engine | 11 |

280 rows × 8 columns

Fantasy GP points per team/driver are calculated in the following way:

- drivers earn the points scored during the grand prix, 10pts for taking pole position, 2pts per position gained on Sunday, and 5 points each for beating their teammate during qualifying and the race itself.
- constructors earn the points scored by each of their drivers during the grand prix, 1pt per position gained per driver, 5 points if both cars finish, 2 if only one car finishes.

To simplify the problem, we completely ignore sprint races (as should everyone).

The race finish status classification has several detailed categories:

`data_df["status"].dtype`

```
CategoricalDtype(categories=['+1 Lap', '+2 Laps', 'Accident', 'Brakes', 'Collision',
'Collision damage', 'Electrical', 'Engine', 'Finished',
'Mechanical', 'Oil leak', 'Overheating', 'Power loss',
'Retired', 'Steering', 'Undertray'],
, ordered=False, categories_dtype=object)
```

Fantasy GP scoring only cares whether a car finished or not, so we define the following set of categories to indiciate a finish:

`STATUS_FINISHED = ["Finished", "+1 Lap", "+2 Laps"]`

While our dataset has the WDC points scored per driver/constructor already, positions gained and performance vs. teammate need to be added in:

```
def add_scoring_cols(df: pd.DataFrame) -> pd.DataFrame:
return df.assign(
# Grid value of 0 indicates pit lane start; here we set that to 99
# to simplify the check for who won out in qualifying.
grid=lambda x: x["grid"].where(x["grid"] != 0, 20)
).assign(
# Positions gained compared to the starting grid position; scoring doesn't
# care about positions lost, so we set anything below 0 to 0.
positions_gained=lambda x: np.maximum(x["grid"] - x["position"], 0),
# Whether the driver won pole position
has_pole=lambda x: x["qualifying_position"] == 1,
# Whether the driver beat their teammate in qualifying
beat_teammate_quali=lambda x: x.groupby(
# Each group is per race, per constructor, so only 2 rows - one for each driver.
["circuit", "constructor"],
group_keys=False,
).apply(
# Smaller grid pos. = better; the grid position is compared
# to the reversed grid array in the group (essentially
# we create a cartesian product of the grid pos.)
lambda g: g["grid"]
< g["grid"].iloc[::-1].values
),
# Same as the previous column, but for finishing position in the race.
beat_teammate_race=lambda x: x.groupby(
["circuit", "constructor"], group_keys=False
).apply(
lambda g: (g["position"] < g["position"].iloc[::-1].values)
& g["status"].isin(STATUS_FINISHED)
),
has_fastest_lap=lambda x: x["fastest_lap"] == 1,
)
data_df = add_scoring_cols(data_df)
```

This leaves us with the following dataframe:

```
data_df[
[
"grid",
"positions_gained",
"beat_teammate_quali",
"beat_teammate_race",
"has_pole",
"has_fastest_lap",
]
]
```

grid | positions_gained | beat_teammate_quali | beat_teammate_race | has_pole | has_fastest_lap | ||
---|---|---|---|---|---|---|---|

circuit | driver | ||||||

bahrain | VER | 1 | 0 | True | True | True | False |

PER | 2 | 0 | False | False | False | False | |

ALO | 5 | 2 | True | True | False | False | |

SAI | 4 | 0 | False | True | False | False | |

HAM | 7 | 2 | False | True | False | False | |

... | ... | ... | ... | ... | ... | ... | ... |

monza | STR | 20 | 4 | False | False | False | False |

HUL | 13 | 0 | True | True | False | False | |

MAG | 19 | 1 | False | False | False | False | |

OCO | 18 | 0 | False | False | False | False | |

TSU | 11 | 0 | True | False | False | <NA> |

280 rows × 6 columns

Finally, we can score the Fantasy GP points; we define the scoring function for the driver to be the sum of:

- WDC points
- 10 for gaining pole position
- 5 for outqualifying their teammate
- 5 for outracing their teammate
- 2 per position gained at the end of the race

Note that a driver who did not finish the race on Sunday can still earn points from the qualifying session.

```
def score_driver(x):
return pd.Series(
x["has_pole"] * 10 + x["beat_teammate_quali"] * 5, dtype=float
) + pd.Series(
x["points"] + x["positions_gained"] * 2 + x["beat_teammate_race"] * 5,
dtype=float,
).where(
x["status"].isin(STATUS_FINISHED), 0
)
```

Scoring for the constructors is defined as:

- WDC points
- 5 points if both cars finished, 2 if only one did
- 1 point per position gained at the end of the race per car

```
def score_constructor(x):
finished = x["status"].isin(STATUS_FINISHED)
match finished.sum():
case 2:
finish_bonus = 5
case 1:
finish_bonus = 2
case _:
finish_bonus = 0
return x["points"].sum() + x[finished]["positions_gained"].sum() + finish_bonus
```

Just from the rules definition it is clear that drivers have a higher points earnign ceiling but much higher variance, whereas constructors are more consistent but earn fewer points.

```
def score_fantasy_points(df: pd.DataFrame) -> pd.DataFrame:
return df.assign(
driver_points=score_driver,
# Constructor points need to be joined back in on the grouping columns, in order to
# fill in the missing spots with duplicate values - since we have 20 drivers, but 10 constructors,
# the group has fewer rows and needs to be broadcast per group on the index.
constructor_points=lambda x: x.join(
x.groupby(["circuit", "constructor"])
.apply(score_constructor)
.rename("constructor_points"),
on=["circuit", "constructor"],
)["constructor_points"],
)
```

The scored dataframe looks like this:

```
data_df = score_fantasy_points(data_df)
data_df[["driver_points", "constructor_points"]]
```

driver_points | constructor_points | ||
---|---|---|---|

circuit | driver | ||

bahrain | VER | 45.0 | 48.0 |

PER | 18.0 | 48.0 | |

ALO | 29.0 | 32.0 | |

SAI | 17.0 | 14.0 | |

HAM | 19.0 | 23.0 | |

... | ... | ... | ... |

monza | STR | 8.0 | 12.0 |

HUL | 10.0 | 6.0 | |

MAG | 2.0 | 6.0 | |

OCO | 0.0 | 4.0 | |

TSU | 5.0 | 3.0 |

280 rows × 2 columns

Note

The constructor points are duplicated per race, since we broadcast those across both drivers.

Now we visualize the data to get a better idea of its properties. First, some basic box plots for each driver/constructor:

```
sns.set_theme(style="ticks")
f, ax = plt.subplots(figsize=(12, 10))
sns.boxplot(
data=data_df.reset_index(),
x="driver_points",
y="driver",
palette="pastel",
whis=[0, 100],
)
sns.stripplot(
x="driver_points",
y="driver",
data=data_df.reset_index(),
hue="circuit",
linewidth=0,
size=5,
dodge=True,
palette="muted",
)
ax.xaxis.grid(True)
ax.set(xlabel="points")
ax.set_title("Driver Fantasy GP points 2023");
```

For convenience, we create a constructor-specific dataframe by taking the first row for each constructor for each race. This gets rid of the duplicate data (as constructor points were duplicated across both drivers):

`const_data_df = data_df.groupby(["circuit", "constructor"]).first().reset_index()`

```
sns.set_theme(style="ticks")
f, ax = plt.subplots(figsize=(12, 6))
sns.boxplot(
data=const_data_df.reset_index(),
x="constructor_points",
y="constructor",
palette="pastel",
whis=[0, 100],
)
sns.stripplot(
x="constructor_points",
y="constructor",
data=const_data_df.reset_index(),
hue="circuit",
palette="muted",
linewidth=0,
size=4,
dodge=True,
)
ax.xaxis.grid(True)
ax.set(xlabel="points")
ax.set_title("Constructor Fantasy GP points 2023");
```

These look quite similar and expose the brutal reality of Red Bull’s total dominance over the field. Matching our intuition from the rules definition, drivers appear to earn more and have wider variance in points scored, comapred to teams, with the exception of Max Verstappen and his absurd consistency.

Next, we plot the kernel density estimate of the driver points to gain a better idea of the overall distribution:

```
g = sns.displot(
data_df.reset_index(),
x="driver_points",
col="driver",
kind="kde",
rug=True,
col_wrap=3,
fill=True,
)
g.set_axis_labels("points", "density");
```

```
g = sns.displot(
const_data_df.reset_index(),
x="constructor_points",
col="constructor",
col_wrap=5,
kind="kde",
height=4,
rug=True,
fill=True,
)
g.set_axis_labels("points", "density");
```

The distribution of points is spread and somewhat multi-peaked in both cases; again, constructors appear score in a much more consistent range compared to drivers. The pooled observed points distributions are:

```
f, axs = plt.subplots(1, 2, figsize=(12, 5), sharey=True)
sns.kdeplot(data_df.reset_index(), x="driver_points", fill=True, ax=axs[0])
sns.kdeplot(const_data_df.reset_index(), x="constructor_points", fill=True, ax=axs[1])
axs[0].set_title("Overall driver points density 2023")
axs[0].set(xlabel="driver points")
axs[1].set_title("Overall constructor points density 2023")
axs[1].set(xlabel="constructor points")
f.tight_layout();
```

Given that the point distributions between drivers and constructors appear very similar, we’ll focus on constructing models of the driver points, which we can later fit to the constructor data with minor adjustments.

Our first model will be the simplest possible - we just directly estimate the mean points for each driver individually (an intercept-only, unpooled linear model).

The points data has several properties that stem from the Fantasy GP ruleset:

- points are discrete
- points are strictly bound between 0 and 84 (25 for p1 + 1 for fastest lap + 10 for pole position + 10 for beating teammate + 19 * 2 for maximum possible positions gained)
- most drivers have a few 0 point finishes, but overall it’s very unlikely for a driver to score 0 points consistently.

Because we are modeling discrete, strictly positive points, one of the count distributions is likely an appropriate choice for the likelihood. The Poisson distribution could be a good choice, but the observations appear overdispersed, as measured by the coefficient of dispersion:

```
data_df = data_df.reset_index().astype(
{
"circuit": "category",
"driver": "category",
}
)
dispersion = (
data_df.groupby("driver")["driver_points"].var()
/ data_df.groupby("driver")["driver_points"].mean()
)
dispersion
```

```
driver
ALB 2.016575
ALO 2.458689
BOT 4.663685
DEV 2.808279
GAS 12.389453
HAM 2.238189
HUL 3.298566
LEC 8.306281
MAG 4.909699
NOR 5.876923
OCO 7.848030
PER 3.748745
PIA 6.062937
RIC 2.466667
RUS 5.968760
SAI 6.063976
SAR 6.434705
STR 5.173077
TSU 2.345168
VER 1.030361
ZHO 7.205955
Name: driver_points, dtype: float64
```

Given that, we will pick the Negative Binomial as our likelihood, since it handles overdispersion better^{1}.

A Negative Binomial likelihood is defined by two parameters - its mean μ, and a shape parameter α. We’ll need to set priors on both.

The mean is restricted to be positive only. The standard way of defining a negative binomial linear model is to set a Normal prior on the mean and use the exponentiation as the inverse link function to make it positive and usable for parameterizing the likelihood. For the actual prior parameters it would be reasonable to go with a fairly uninformative prior, such as `Normal(mu=0, sigma=1)`

; in this case, prior knowledge and the ruleset gives us a hunch that we can reasonably expect most drivers’ mean points per race to be somewhere between 5 (backmarker, occasionally beats their teammate, maybe gains a place) and 30 (top driver & team, consistently getting and winning from pole position).

To come up with parameter values for this, we use the PreliZ library to find a maximum entropy Normal distribution with 90% of its mass between 5 and 30 (we take the natural log of those, given the exponential inverse link):

```
mu_dist = pz.Normal()
pz.maxent(mu_dist, np.log(5), np.log(30), 0.90);
```

The shape parameter α is also strictly positive. Its effect on the likelihood can be best understood by plotting a Negative Binomial for a few different values, while keeping the mean fixed:

```
f, axs = plt.subplots(2, 2, figsize=(12, 6), sharey=True)
pz.NegativeBinomial(alpha=0.5, mu=15).plot_pdf(ax=axs[0, 0], legend=False)
axs[0, 0].set_title(f"Negative Binomial with μ: 15, α: 0.5")
pz.NegativeBinomial(alpha=5, mu=15).plot_pdf(ax=axs[0, 1], legend=False)
axs[0, 1].set_title(f"Negative Binomial with μ: 15, α: 5")
pz.NegativeBinomial(alpha=50, mu=15).plot_pdf(ax=axs[1, 0], legend=False)
axs[1, 0].set_title(f"Negative Binomial with μ: 15, α: 50")
pz.NegativeBinomial(alpha=500, mu=15).plot_pdf(ax=axs[1, 1], legend=False)
axs[1, 1].set_title(f"Negative Binomial with μ: 15, α: 500");
```

As α increases, the tails shrink. We don’t want very high values, as that will restrict the tails too much, but very small values result in absurdly long tails. To handle that, we use a Gamma distribution, with mass strongly concentrated between 1 and 20:

```
alpha_dist = pz.Gamma()
pz.maxent(alpha_dist, 1, 20, .9);
```

Finally, we prepare the data for use within the model. We factorize the `driver`

column into labels and an index:

`idx, labels = pd.factorize(data_df["driver"], sort=True)`

`labels`

is a categorical variable over the driver names:

```
CategoricalIndex(['ALB', 'ALO', 'BOT', 'DEV', 'GAS', 'HAM', 'HUL', 'LEC',
'MAG', 'NOR', 'OCO', 'PER', 'PIA', 'RIC', 'RUS', 'SAI',
'SAR', 'STR', 'TSU', 'VER', 'ZHO'],
categories=['ALB', 'ALO', 'BOT', 'DEV', ..., 'STR', 'TSU', 'VER', 'ZHO'], ordered=False, dtype='category')
```

`idx`

is the flattened (race number, driver) index into the observed race result rows:

```
array([19, 11, 1, 15, 5, 17, 14, 2, 4, 0, 18, 16, 8, 3, 6, 20, 9,
10, 7, 12, 11, 19, 1, 14, 5, 15, 7, 10, 4, 8, 18, 6, 20, 3,
12, 16, 9, 2, 0, 17, 19, 5, 1, 17, 11, 9, 6, 12, 20, 18, 2,
15, 4, 10, 3, 16, 8, 14, 0, 7, 11, 19, 7, 1, 15, 5, 17, 14,
9, 18, 12, 0, 8, 4, 10, 16, 6, 2, 20, 3, 19, 11, 1, 14, 15,
5, 7, 4, 10, 8, 18, 17, 2, 0, 6, 20, 9, 3, 12, 16, 19, 1,
10, 5, 14, 7, 4, 15, 9, 12, 2, 3, 20, 0, 18, 11, 6, 16, 8,
17, 19, 5, 14, 11, 15, 17, 1, 10, 20, 4, 7, 18, 12, 3, 6, 0,
9, 8, 2, 16, 19, 1, 5, 7, 15, 11, 0, 10, 17, 2, 12, 4, 9,
18, 6, 20, 8, 3, 14, 16, 19, 7, 11, 9, 1, 15, 14, 5, 17, 4,
0, 20, 16, 10, 2, 12, 3, 8, 18, 6, 19, 9, 5, 12, 14, 11, 1,
0, 7, 15, 16, 2, 6, 17, 20, 18, 3, 4, 8, 10, 19, 9, 11, 5,
12, 14, 7, 15, 1, 17, 0, 2, 13, 6, 18, 20, 8, 16, 10, 4, 19,
11, 7, 5, 1, 14, 9, 10, 17, 18, 4, 2, 20, 0, 8, 13, 16, 6,
15, 12, 19, 1, 4, 11, 15, 5, 9, 0, 12, 10, 17, 6, 13, 2, 18,
8, 14, 20, 7, 16, 19, 11, 15, 7, 14, 5, 0, 9, 1, 2, 13, 12,
16, 20, 4, 17, 6, 8, 10, 18])
```

We also extract the observed points:

```
driver_points = data_df["driver_points"]
driver_points
```

```
0 45.0
1 18.0
2 29.0
3 17.0
4 19.0
...
275 8.0
276 10.0
277 2.0
278 0.0
279 5.0
Name: driver_points, Length: 280, dtype: float64
```

Next, we define the model with PyMC. As discussed previously, we think that the process that generates points can be seen as a Negative Binomial distribution, so that is set as the likelihood. The mean (μ) аnd shape (α) are the parameters that we want to infer from the data. Finally, the prior guesses for these parameters are set from the `mu_dist`

and `alpha_dist`

objects we computed previously.

This translates to:

```
with pm.Model(coords={"driver": labels, "idx": idx}) as unpooled_negb:
μ = pm.Normal("μ", mu_dist.mu, mu_dist.sigma, dims="driver")
μ_ = pm.Deterministic("μ_", pm.math.exp(μ), dims="driver")
α = pm.Gamma("α", alpha_dist.alpha, alpha_dist.beta, dims="driver")
y = pm.NegativeBinomial(
"y", alpha=α[idx], mu=μ_[idx], observed=driver_points, dims="idx"
)
```

To see if the choice for priors is acceptable, we sample from the prior predictive and plot the results:

```
with unpooled_negb:
unpooled_prior_samples = pm.sample_prior_predictive(samples=1000)
```

`Sampling: [y, α, μ]`

`az.plot_ppc(unpooled_prior_samples, group="prior", figsize=(12, 6));`

The prior predictive is quite wide, with a very long and somewhat fat tail, but overall the data could be plausibly seen as coming from it.

Next, we perform the actual inference step - we sample from the posterior and the posterior predictive distribution:

```
with unpooled_negb:
unpooled_idata = pm.sample(target_accept=0.9, idata_kwargs={"log_likelihood": True})
pm.sample_posterior_predictive(unpooled_idata, extend_inferencedata=True)
```

```
Auto-assigning NUTS sampler...
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (4 chains in 4 jobs)
NUTS: [μ, α]
Sampling 4 chains for 1_000 tune and 1_000 draw iterations (4_000 + 4_000 draws total) took 5 seconds.
Sampling: [y]
```

100.00% [8000/8000 00:04<00:00 Sampling 4 chains, 0 divergences]

100.00% [4000/4000 00:00<00:00]

There are no divergences detected, which is a good first sign that the model has converged.

To check the sampling process we plot the traces for the μ parameter. What we are looking for is similar densities on the left (indicating that the chains have converged to the same posterior distribution), and noisy/patternless trace plots on the right (which would indicate that the sampler was able to efficiently run through the sample space)^{2}:

`az.plot_trace(unpooled_idata, var_names=["μ"], compact=False);`

There are a few kinks here and there (see RIC), but overall these look good.

Next we plot the energy plot and BFMI. Ideally the marginal energy and energy transition distributions should overlap as much as possible, and BFMI should be to be above .3:

```
ax = az.plot_energy(unpooled_idata)
ax.set_title("Energy Plot");
```

Finally, we check the summary statistics for the μ parameter:

- bulk and tail effective sample size (
`ess_bulk`

and`ess_tail`

respectively) should be > 400, otherwise the estimation of the rest of the summary statistics is considered unreliable^{3} `r_hat`

should ideally equal 1, with values over 1.01 convergence issues^{4}`mcse_{mean, sd}`

is the Monte Carlo Standard Error, or the error introduced by approximating the posterior with a finite number of samples; lower = better^{5}

`az.summary(unpooled_idata, var_names=["μ"])`

mean | sd | hdi_3% | hdi_97% | mcse_mean | mcse_sd | ess_bulk | ess_tail | r_hat | |
---|---|---|---|---|---|---|---|---|---|

μ[ALB] | 2.559 | 0.113 | 2.356 | 2.786 | 0.002 | 0.001 | 5027.0 | 2717.0 | 1.0 |

μ[ALO] | 3.124 | 0.101 | 2.925 | 3.308 | 0.002 | 0.001 | 4219.0 | 2969.0 | 1.0 |

μ[BOT] | 2.523 | 0.190 | 2.158 | 2.878 | 0.003 | 0.002 | 4131.0 | 2642.0 | 1.0 |

μ[DEV] | 1.766 | 0.220 | 1.350 | 2.179 | 0.004 | 0.003 | 3568.0 | 2298.0 | 1.0 |

μ[GAS] | 2.459 | 0.221 | 2.039 | 2.874 | 0.003 | 0.002 | 4298.0 | 2631.0 | 1.0 |

μ[HAM] | 3.085 | 0.099 | 2.907 | 3.279 | 0.001 | 0.001 | 4774.0 | 2917.0 | 1.0 |

μ[HUL] | 2.159 | 0.151 | 1.888 | 2.452 | 0.002 | 0.002 | 4008.0 | 2570.0 | 1.0 |

μ[LEC] | 2.723 | 0.209 | 2.311 | 3.114 | 0.003 | 0.002 | 4991.0 | 2884.0 | 1.0 |

μ[MAG] | 2.030 | 0.258 | 1.552 | 2.523 | 0.004 | 0.003 | 4224.0 | 2823.0 | 1.0 |

μ[NOR] | 2.695 | 0.146 | 2.416 | 2.960 | 0.002 | 0.002 | 4055.0 | 2487.0 | 1.0 |

μ[OCO] | 2.275 | 0.280 | 1.764 | 2.818 | 0.004 | 0.003 | 4511.0 | 2902.0 | 1.0 |

μ[PER] | 3.283 | 0.101 | 3.086 | 3.472 | 0.001 | 0.001 | 4856.0 | 3012.0 | 1.0 |

μ[PIA] | 1.901 | 0.276 | 1.373 | 2.416 | 0.005 | 0.003 | 3901.0 | 2514.0 | 1.0 |

μ[RIC] | 2.338 | 0.226 | 1.948 | 2.791 | 0.003 | 0.002 | 4746.0 | 2388.0 | 1.0 |

μ[RUS] | 2.776 | 0.165 | 2.480 | 3.097 | 0.002 | 0.002 | 4741.0 | 2662.0 | 1.0 |

μ[SAI] | 2.661 | 0.176 | 2.338 | 3.005 | 0.003 | 0.002 | 4280.0 | 2802.0 | 1.0 |

μ[SAR] | 1.835 | 0.451 | 1.023 | 2.665 | 0.007 | 0.005 | 4104.0 | 2613.0 | 1.0 |

μ[STR] | 2.182 | 0.260 | 1.709 | 2.681 | 0.004 | 0.003 | 3834.0 | 2291.0 | 1.0 |

μ[TSU] | 2.420 | 0.122 | 2.196 | 2.660 | 0.002 | 0.001 | 4081.0 | 2713.0 | 1.0 |

μ[VER] | 3.742 | 0.074 | 3.607 | 3.886 | 0.001 | 0.001 | 5036.0 | 2571.0 | 1.0 |

μ[ZHO] | 2.255 | 0.237 | 1.798 | 2.699 | 0.003 | 0.003 | 4903.0 | 2624.0 | 1.0 |

All of the sampling diagnistics look good, which means the model has converged and we can move on to posterior predictive checks and model evaluation.

Next we examine the posterior predictive distribution, which will help validate that the predictions the model is making make sense given the data. Below we plot several posterior graphs (from top to bottom, left to right):

- the (grouped) posterior predictive distribution should closely replicate the pattern seen in the observed data
^{6} - the Bayesian p-value is the probability of the predicted data being equal to or less than the observed data. The ideal/expected outcome is represented by the dashed line (0.5), with the solid line being the KDE of the predicted data; the u-value represents the same idea as the p-value, but per observation - it should be roughly uniform
^{7} - LOO-PIT is the probability integral transform checed with LOO-CV; this should be close to uniform; difference between LOO-PIT empirical cumulative distribution function and the uniform cdf
^{8}

```
def plot_posterior_predictive_checks(idata):
f = plt.figure(figsize=(12, 12))
ax_ppc = f.add_subplot(3, 1, 1)
az.plot_ppc(idata, ax=ax_ppc)
ax_ppc.set_title("Posterior predictive checks")
ax_bpv = f.add_subplot(3, 2, 3)
az.plot_bpv(idata, kind="p_value", ax=ax_bpv)
ax_bpv.set_title("Bayesian p_value")
ax_uv = f.add_subplot(3, 2, 4)
az.plot_bpv(idata, kind="u_value", ax=ax_uv)
ax_uv.set_title("Marginal p_value")
ax_loo_pit_ecdf = f.add_subplot(3, 2, 5)
az.plot_loo_pit(idata, y="y", ecdf=True, ax=ax_loo_pit_ecdf)
ax_loo_pit_ecdf.set_title("LOO-PIT (ecdf=True)")
ax_loo_pit = f.add_subplot(3, 2, 6)
az.plot_loo_pit(idata, y="y", ecdf=False, ax=ax_loo_pit)
ax_loo_pit.set_title("LOO-PIT (ecdf=False)")
f.tight_layout()
return (ax_ppc, ax_bpv, ax_uv, ax_loo_pit_ecdf, ax_loo_pit)
```

`plot_posterior_predictive_checks(unpooled_idata);`

From the posterior predictive plots it seems that:

- the posterior predictive samples roughly look like tha observed data, but the fit around the right tail isn’t the best.
- the p-value plot shows that the model’s predictions are somewhat shifted to the right and the u-value plot indicates that the model is missing observations in the left tail
^{9} - LOO-PIT shows the model is likely biased, which agrees with the p/u-value plots.

Lastly, let’s plot the posterior predictive distributions for a few individual drivers:

```
fig, axs = plt.subplots(2, 2, figsize=(12, 6))
DriverIdx = Enum("Driver", zip(labels.categories, labels.codes))
az.plot_ppc(unpooled_idata, coords={"idx": [DriverIdx.VER.value]}, ax=axs[0, 0])
axs[0, 0].set_title("VER")
az.plot_ppc(unpooled_idata, coords={"idx": [DriverIdx.SAR.value]}, ax=axs[0, 1])
axs[0, 1].set_title("SAR")
az.plot_ppc(unpooled_idata, coords={"idx": [DriverIdx.LEC.value]}, ax=axs[1, 0])
axs[1, 0].set_title("LEC")
az.plot_ppc(unpooled_idata, coords={"idx": [DriverIdx.HAM.value]}, ax=axs[1, 1])
axs[1, 1].set_title("HAM");
```

We are reasonably confident that the model has converged and that there were no issues during sampling. Predictions seem to be somewhat biased, but still reasonable for our purposes. Now it’s time to check the posterior distributions of the estimated parameter μ:

`az.plot_posterior(unpooled_idata, var_names=["μ"]);`

The estimates look well formed; to make comparing the parameter value between drivers easier, we plot a forest of the 94% HDI ^{10}:

`az.plot_forest(unpooled_idata, var_names=["μ"], combined=True, figsize=(12, 6));`

The probability distributions for the means broadly agree with our informal observations throughout the season - VER is scoring high above the rest, in an extremely consistent range, with HAM, ALO, and PER close to eachother for second place, a mixed, variable set of results for the midfield, and a few clear backmarker drivers.

To translate the estimated parameter values to points, we can plot the transformed μ_ value (remember that this is just the exponentiated μ):

`az.plot_forest(unpooled_idata, var_names=["μ_"], combined=True, figsize=(12, 6));`

Finally, we inspect PSIS-LOO-CV to get an idea of the performance of the model:

`az.loo(unpooled_idata)`

```
Computed from 4000 posterior samples and 280 observations log-likelihood matrix.
Estimate SE
elpd_loo -954.78 14.99
p_loo 44.52 -
There has been a warning during the calculation. Please check the results.
------
Pareto k diagnostic values:
Count Pct.
(-Inf, 0.5] (good) 274 97.9%
(0.5, 0.7] (ok) 5 1.8%
(0.7, 1] (bad) 1 0.4%
(1, Inf) (very bad) 0 0.0%
```

`elpd_loo`

^{11} will be more useful when comparing this baseline model to other, more complicated ones, but the fact that Pareto k values are all significantly below 0.7 is a good sign that the model can generalize decently.

One of the common issues with both Poisson and Negative Binomial models is that they do not generate zeroes often enough. This can be alleviated by using a mixture model that inflates the number of zeroes, which `PyMC`

already has an implementation of - the Zero Inflated Negative Binomial likelihood.

To test that extra zeroes might result in a better fit, we define a new model with the same priors as before, but using the mixture likelihood. One additional parameter, ψ, is needed, so we have to set a prior over that. ψ is the probability of a non-zero value, between 0 and 1. We will use a Beta as the prior distribution, and given the ruleset and reliability of modern F1 cars, our prior belief is that 0 point finishes aren’t all that likely. To express that, we take the maximum entropy Beta whose mass is concentrated between .8 and .99:

```
psi_dist = pz.Beta()
pz.maxent(psi_dist, 0.7, 0.99, 0.75);
```

The new model is trivial to define:

```
with pm.Model(coords={"driver": labels, "idx": idx}) as zi_unpooled_negb:
μ = pm.Normal("μ", mu_dist.mu, mu_dist.sigma, dims="driver")
μ_ = pm.Deterministic("μ_", pm.math.exp(μ), dims="driver")
α = pm.Gamma("α", alpha_dist.alpha, alpha_dist.beta, dims="driver")
ψ = pm.Beta("ψ", psi_dist.alpha, psi_dist.beta, dims="driver")
y = pm.ZeroInflatedNegativeBinomial(
"y",
psi=ψ[idx],
alpha=α[idx],
mu=μ_[idx],
observed=driver_points,
dims="idx",
)
```

We perform the same prior predictive check:

```
with zi_unpooled_negb:
zi_unpooled_prior_samples = pm.sample_prior_predictive(samples=1000)
```

`Sampling: [y, α, μ, ψ]`

`az.plot_ppc(zi_unpooled_prior_samples, group="prior");`

Sample:

```
with zi_unpooled_negb:
zi_unpooled_idata = pm.sample(
target_accept=0.9, idata_kwargs={"log_likelihood": True}
)
pm.sample_posterior_predictive(zi_unpooled_idata, extend_inferencedata=True)
```

```
Auto-assigning NUTS sampler...
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (4 chains in 4 jobs)
NUTS: [μ, α, ψ]
Sampling 4 chains for 1_000 tune and 1_000 draw iterations (4_000 + 4_000 draws total) took 10 seconds.
Sampling: [y]
```

100.00% [8000/8000 00:09<00:00 Sampling 4 chains, 0 divergences]

100.00% [4000/4000 00:05<00:00]

The traceplots look about as good as before:

`az.plot_trace(zi_unpooled_idata, var_names=["μ"], compact=False);`

BFMI and energy are reasonable:

```
ax = az.plot_energy(zi_unpooled_idata)
ax.set_title("Energy Plot");
```

`r_hat`

and ESS look good as well:

`az.summary(zi_unpooled_idata, var_names=["μ"])`

mean | sd | hdi_3% | hdi_97% | mcse_mean | mcse_sd | ess_bulk | ess_tail | r_hat | |
---|---|---|---|---|---|---|---|---|---|

μ[ALB] | 2.558 | 0.111 | 2.343 | 2.762 | 0.001 | 0.001 | 6450.0 | 3026.0 | 1.0 |

μ[ALO] | 3.123 | 0.100 | 2.942 | 3.322 | 0.001 | 0.001 | 6609.0 | 2903.0 | 1.0 |

μ[BOT] | 2.655 | 0.128 | 2.417 | 2.900 | 0.002 | 0.001 | 6154.0 | 2859.0 | 1.0 |

μ[DEV] | 1.907 | 0.183 | 1.581 | 2.273 | 0.002 | 0.001 | 7928.0 | 2908.0 | 1.0 |

μ[GAS] | 2.499 | 0.208 | 2.115 | 2.907 | 0.003 | 0.002 | 6758.0 | 2526.0 | 1.0 |

μ[HAM] | 3.082 | 0.097 | 2.898 | 3.264 | 0.001 | 0.001 | 7940.0 | 2852.0 | 1.0 |

μ[HUL] | 2.222 | 0.139 | 1.965 | 2.484 | 0.002 | 0.001 | 6692.0 | 2804.0 | 1.0 |

μ[LEC] | 2.876 | 0.143 | 2.590 | 3.128 | 0.002 | 0.001 | 6002.0 | 2722.0 | 1.0 |

μ[MAG] | 2.237 | 0.149 | 1.947 | 2.507 | 0.002 | 0.001 | 6121.0 | 2629.0 | 1.0 |

μ[NOR] | 2.700 | 0.144 | 2.436 | 2.974 | 0.002 | 0.001 | 7691.0 | 3154.0 | 1.0 |

μ[OCO] | 2.511 | 0.166 | 2.197 | 2.832 | 0.002 | 0.002 | 6137.0 | 2785.0 | 1.0 |

μ[PER] | 3.282 | 0.105 | 3.080 | 3.478 | 0.001 | 0.001 | 6747.0 | 2638.0 | 1.0 |

μ[PIA] | 2.078 | 0.183 | 1.698 | 2.395 | 0.002 | 0.002 | 5952.0 | 2692.0 | 1.0 |

μ[RIC] | 2.335 | 0.216 | 1.906 | 2.717 | 0.003 | 0.002 | 7119.0 | 2722.0 | 1.0 |

μ[RUS] | 2.850 | 0.135 | 2.612 | 3.122 | 0.002 | 0.001 | 5916.0 | 2873.0 | 1.0 |

μ[SAI] | 2.728 | 0.154 | 2.448 | 3.019 | 0.002 | 0.001 | 5981.0 | 2686.0 | 1.0 |

μ[SAR] | 2.036 | 0.213 | 1.672 | 2.461 | 0.003 | 0.002 | 4655.0 | 2695.0 | 1.0 |

μ[STR] | 2.420 | 0.135 | 2.167 | 2.681 | 0.002 | 0.001 | 6232.0 | 2915.0 | 1.0 |

μ[TSU] | 2.417 | 0.123 | 2.177 | 2.641 | 0.001 | 0.001 | 7217.0 | 2763.0 | 1.0 |

μ[VER] | 3.742 | 0.074 | 3.609 | 3.890 | 0.001 | 0.001 | 6489.0 | 2464.0 | 1.0 |

μ[ZHO] | 2.424 | 0.161 | 2.105 | 2.713 | 0.002 | 0.001 | 6419.0 | 2791.0 | 1.0 |

Posterior predictive checks look somewhat better:

- the posterior predictive fits about as well, with a slightly thinner tail
- p-value is still shifted
- u-value is much closer to uniform
- LOO-PIT still suggests bias, but it looks slightly better overall; the right tail of the posterior predictive still looks too fat and goes well above the maximum points possible in the ruleset, likely causing the bias and shifted predictions.

`plot_posterior_predictive_checks(zi_unpooled_idata);`

We plot just the forest of transformed means:

`az.plot_forest(zi_unpooled_idata, var_names=["μ_"], combined=True, figsize=(12, 6));`

As expected, compared to the basic model, the means have been pulled towards zero slightly and the HDIs have shrunk.

Now that we have two models it’s worth running a comparison to see which performs better ^{12}:

```
comparison = az.compare(
{"unpooled": unpooled_idata, "zero inflated unpooled": zi_unpooled_idata}
)
comparison
```

rank | elpd_loo | p_loo | elpd_diff | weight | se | dse | warning | scale | |
---|---|---|---|---|---|---|---|---|---|

zero inflated unpooled | 0 | -922.394375 | 40.951957 | 0.000000 | 0.787666 | 17.265214 | 0.000000 | False | log |

unpooled | 1 | -954.778337 | 44.522890 | 32.383962 | 0.212334 | 14.990548 | 11.918236 | True | log |

`az.plot_compare(comparison);`

The zero-inflated model clearly seems to perform better ^{13}.

We have a reasonable looking, if a bit biased model for driver points, which we’ll now use to inform fantasy team construction.

When picking drivers, we consider the following:

- how many points per race is the driver expected to bring and how reliably
- how likely is it that a given driver will outperform another driver, and by what magnitude
- how underpriced/overpriced is a a driver given their performance

While price analysis is out of the scope of this notebook, the points estimation model can help us with the first two questions.

Below, we compare each driver to their rivals, using the difference of predicted points per race. To do so, we take the predictions generated from the posterior predictive distribution for each driver, subtract the two vectors, and take the mean. This reduces the posterior distributions to a point estimate, but still incorporates the uncertainty of the model, as we work directly with values generated from the posterior. The value can be seen both as confidence driver x is better than driver y - higher is better, and as the points we expect driver x to earn over driver y on average.

Caution

The plot below isn’t symmetric over the right diagonal - we are strictly comparing the driver on the X axis to the driver on the Y axis.

```
def make_comparison(idata, idx, groups, size=1000):
columns = {}
for idx_a, name_a in enumerate(groups):
for idx_b, name_b in enumerate(groups):
pp_a = idata.posterior_predictive["y"][:, :, idx == idx_a].values.flatten()
pp_b = idata.posterior_predictive["y"][:, :, idx == idx_b].values.flatten()
samples_a = np.random.choice(pp_a, size=size)
samples_b = np.random.choice(pp_b, size=size)
if idx_a != idx_b:
points_over = (samples_a - samples_b).mean()
else:
points_over = 0
columns.setdefault(name_a, []).append(points_over)
comparison_df = pd.DataFrame.from_dict(columns).set_index(groups)
mask = np.triu(np.ones_like(comparison_df, dtype=bool))
return comparison_df, mask
```

`comparison_df, mask = make_comparison(zi_unpooled_idata, idx, labels)`

```
sns.set_theme(style="white")
cmap = sns.diverging_palette(230, 20, as_cmap=True)
f, ax = plt.subplots(figsize=(11, 9))
ax = sns.heatmap(
comparison_df,
cmap=cmap,
mask=mask,
annot=True,
center=0,
square=True,
linewidths=0.5,
cbar_kws={"shrink": 0.5},
annot_kws={"fontsize": 8},
).set(title="Driver points expected over rival");
```

We’ll run the same loop for the constructor comparison - first we define a slightly modified zero inflated model, then we sample, validate convergence, and finally use the posterior and posterior predictive to evaluate constructor peformance.

Data preparation is identical - we factorize the constructors and take the points

```
const_idx, const_labels = pd.factorize(const_data_df["constructor"], sort=True)
const_points = const_data_df["constructor_points"]
```

We adjust the priors slightly, to account for differences in the constructors scoring system:

```
mu_const_dist = pz.Normal()
pz.maxent(mu_const_dist, np.log(5), np.log(30), 0.9);
```

```
alpha_const_dist = pz.Gamma()
pz.maxent(alpha_const_dist, 1, 10, .9);
```

```
psi_const_dist = pz.Beta()
pz.maxent(psi_const_dist, .9, .99, .9);
```

```
with pm.Model(
coords={"constructor": const_labels, "idx": const_idx}
) as const_zi_unpooled_model:
μ = pm.Normal("μ", mu_const_dist.mu, mu_const_dist.sigma, dims="constructor")
μ_ = pm.Deterministic("μ_", pm.math.exp(μ), dims="constructor")
α = pm.Gamma("α", alpha_const_dist.alpha, alpha_const_dist.beta, dims="constructor")
ψ = pm.Beta("ψ", psi_const_dist.alpha, psi_const_dist.beta, dims="constructor")
y = pm.ZeroInflatedNegativeBinomial(
"y",
psi=ψ[const_idx],
alpha=α[const_idx],
mu=μ_[const_idx],
observed=const_points,
dims="idx",
)
```

```
with const_zi_unpooled_model:
const_prior_pred = pm.sample_prior_predictive(samples=1000)
```

`Sampling: [y, α, μ, ψ]`

`az.plot_ppc(const_prior_pred, group="prior", figsize=(12, 6));`

```
with const_zi_unpooled_model:
const_idata = pm.sample(target_accept=0.9, idata_kwargs={"log_likelihood": True})
pm.sample_posterior_predictive(const_idata, extend_inferencedata=True)
```

```
Auto-assigning NUTS sampler...
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (4 chains in 4 jobs)
NUTS: [μ, α, ψ]
Sampling 4 chains for 1_000 tune and 1_000 draw iterations (4_000 + 4_000 draws total) took 6 seconds.
Sampling: [y]
```

100.00% [8000/8000 00:05<00:00 Sampling 4 chains, 0 divergences]

100.00% [4000/4000 00:03<00:00]

`az.plot_trace(const_idata, var_names=["μ"], compact=False);`

`plot_posterior_predictive_checks(const_idata);`

`az.loo(const_idata)`

```
Computed from 4000 posterior samples and 140 observations log-likelihood matrix.
Estimate SE
elpd_loo -472.93 9.48
p_loo 12.07 -
------
Pareto k diagnostic values:
Count Pct.
(-Inf, 0.5] (good) 139 99.3%
(0.5, 0.7] (ok) 1 0.7%
(0.7, 1] (bad) 0 0.0%
(1, Inf) (very bad) 0 0.0%
```

`az.plot_forest(const_idata, var_names=["μ_"], combined=True, figsize=(12, 6));`

```
sns.set_theme(style="white")
cmap = sns.diverging_palette(230, 20, as_cmap=True)
f, ax = plt.subplots(figsize=(11, 9))
comparison_df, mask = make_comparison(const_idata, const_idx, const_labels)
ax = sns.heatmap(
comparison_df,
cmap=cmap,
mask=mask,
annot=True,
center=0,
square=True,
linewidths=0.5,
cbar_kws={"shrink": 0.5},
annot_kws={"fontsize": 8},
).set(title="Constructor points expected over rival");
```

None of the results here are that surprising, but it’s nonetheless useful to confirm our intuition for who’s a good pick and who isn’t. That said, the estimates should be taken with more than a grain of salt - the model we used is quite rudimentary (for one, it doesn’t account for performance trends as the season progresses), and modeling the summed fantasy points directly leads to a lot of detail getting obscured and left out.

```
%load_ext watermark
%watermark -n -u -v -iv
```

```
The watermark extension is already loaded. To reload it, use:
%reload_ext watermark
Last updated: Tue Sep 05 2023
Python implementation: CPython
Python version : 3.11.4
IPython version : 8.14.0
numpy : 1.25.2
tqdm : 4.66.1
json : 2.0.9
preliz : 0.3.2
scipy : 1.10.1
arviz : 0.16.1
matplotlib: 3.7.2
pymc : 5.7.2
pandas : 2.1.0
sys : 3.11.4 (main, Jun 28 2023, 19:51:46) [GCC]
requests : 2.31.0
seaborn : 0.12.2
```

see the intro to this for more details: https://www.pymc.io/projects/examples/en/latest/generalized_linear_models/GLM-negative-binomial-regression.html↩︎

see this for a quick summary of model checking techniques: https://www.pymc.io/projects/docs/en/stable/learn/core_notebooks/pymc_overview.html#model-checking↩︎

see: https://bayesiancomputationbook.com/markdown/chp_02.html#effective-sample-size↩︎

see: https://bayesiancomputationbook.com/markdown/chp_02.html#potential-scale-reduction-factor-hat-r↩︎

see: https://bayesiancomputationbook.com/markdown/chp_02.html#monte-carlo-standard-error↩︎

see: https://bayesiancomputationbook.com/markdown/chp_02.html#understanding-your-predictions↩︎

see: https://bayesiancomputationbook.com/markdown/chp_02.html#fig-posterior-predictive-check-pu-values↩︎

see: https://oriolabrilpla.cat/en/blog/posts/2019/loo-pit-tutorial.html↩︎

for a detailed guide on interpreting the bpv plots see: https://bayesiancomputationbook.com/markdown/chp_02.html#fig-posterior-predictive-many-examples↩︎

see: https://sjster.github.io/introduction_to_computational_statistics/docs/Production/PyMC3.html#hdi↩︎

see: https://bayesiancomputationbook.com/markdown/chp_02.html#cross-validation-and-loo↩︎

see: https://bayesiancomputationbook.com/markdown/chp_02.html#model-comparison↩︎

for much more information on cross validation see: https://avehtari.github.io/modelselection/CV-FAQ.html#12_What_is_the_interpretation_of_ELPD__elpd_loo__elpd_diff↩︎

The snippet below is part of my attempt to implement a lockfree linked list, as described by Harris, with the additional optimizations by Fomitchev, Ruppert. The original Harris algorithm uses the least significant bit of the “successor” pointer in each node of the linked list as a deletion mark, to achieve a two phase removal - first, a node is logically deleted by tagging its successor pointer with the mark, and at a later point it is unlinked completely. The optimizations by Fomitchev and Ruppert add a second possible tag at the next least significant bit, as well as a backlink to a previous node, in order to shorten the length and amount of traversals of the linked list a process has to make. The new tag “flags” that the node after the current one is being deleted, and that the flagged node should not be marked until after the deletion of its successor is fully completed.

The two tags need to be manipulated individually when the algorithm is setting the metadata of a node, and (for convenience) as one chunk for when the actual pointer, clear of tags, is needed:

```
const MARK_BIT: usize = 1 << 0;
const FLAG_BIT: usize = 1 << 1;
const ALL_TAGS: usize = MARK_BIT + FLAG_BIT;
fn tag_at<T>(ptr: *const T, tag: usize, value: bool) -> *const T {
(ptr as usize & !tag | (tag * value as usize)) as *const T
}
fn is_tagged<T>(ptr: *const T, tag: usize) -> bool {
(ptr as usize & tag) == tag
}
```

The constants define the tag locations for the mark and flag, as well as the “mask” (for a lack of a better term) which covers all tags, so that they can be cleared in one go. The mask is just the sum of the tags that it needs to cover - since each tag bit is just an integer with only one of the bits set, e.g. `0b01c`

= for the least significant and `0b10`

for the next least significant, their sum produces an integer whose set bits correspond to the location of all tags. This can be used for things more interesting than clearing the pointer - for example, it allows for multibit tags or storing (small) integer values, all in one function call.

The tagging logic is only slightly different from how it is usually implemented. First, the pointer and the inverse of the tag bit are `and`

-ed, which results in an integer whose tag bit is unset. This in a sense isolates that specific tag, as the integer now is in a “clean” state with respect to it. The result is `or`

-ed with the tag bit value, which will either set the tag bit or keep it unset (the tag value is just the tag bit or 0).

To illustrate, here is a contrived example:

Initially,

`let ptr = 1010101100001011`

,`let tag = 1 << 1`

, and`let value = false`

. Both tags are set.`ptr & !tag = 1010101100001011 & 1111111111111101 = 1010101100001001`

. Note that only the targeted tag is unset, otherwise the pointer is the same. It is clean with respect to the target tag.`(ptr & !tag) | 0 = 1010101100001001 | 0 = 1010101100001001`

Checking if a tag is set is done just by =and=-ing the pointer and the tag bits and checking that that results in the tag bits. Lastly, since the tag can be multiple bits, clearing the pointer for regular usage can be done by just `tag_at(ptr, ALL_TAGS, false)`

.

Note that working with tagged pointers is tricky and dangerous, as accidentally dereferencing an unclean pointer will lead to Fun. In Rust, taking the raw pointer produced by the tagging function and dereferencing it would be an `unsafe`

operation, which forces the implementor to take special note of where and how it is used.