Dan Vatterott

Data Scientist

Modeling the relative speed of Hot Wheels Cars

I recently came across a really interesting modeling problem when playing with my kids. It all started when my 5yr old received this Hot Wheels T-Rex Transporter.

The transporter comes with a race-track where the winner triggers a trap door, dropping the loser to the ground. Obviously our first task when playing with this toy was determining which car was fastest. I figured the system would be relatively deterministic and finding the fastest car would be a simple matter of racing each car and passing the winner of a given race onto the next race. The final winner should be the fastest car.

I noticed pretty quickly that the race outcomes were much more stochastic than I anticipated (even when controlling for race-lane and censoring bad starts). Undeterred, I realized I would need to model the race outcomes in order to estimate the fastest car.

In this modeling problem there's a couple constraints that make the problem a little more interesting: 1) I'm only going to record the outcome of races a limited number of times. I have limited patience. My 5yr old is similar. My 3yr old, has less. 2) I have limited control over which cars my kids choose to race.

The easiest way to handle this problem would be to race each car against each competitor N times (balancing for race-lane across trials). I could estimate N by racing two cars against each other repeatedly to estimate the race-to-race variance.

Needless to say, this wasn't going to happen. My 5yr old is going to choose the fastest car that she's willing to risk losing. My 3yr old is going to choose her favorite car. Repeatedly.

Another interesting feature of this problem is the race cars compete against each other. This means in a given race, I can learn about both the cars. I wanted to create a model that took advantage of this feature.

Okay, before I get too deep into the problem, I should introduce the 12 cars. Here they are.

We had the patience to record 37 races (not bad!). That said, let's get into it.

 import sys
 import pandas as pd
 import numpy as np
 from matplotlib import pyplot as plt
 from pandas.plotting import register_matplotlib_converters
 from matplotlib.ticker import FuncFormatter
 import matplotlib.ticker as mtick
 register_matplotlib_converters()

 pct_formatter = FuncFormatter(lambda prop, _: "{:.1%}".format(prop))
 params = {"ytick.color" : "k",
           "xtick.color" : "k",
           "axes.labelcolor" : "k",
           "axes.edgecolor" : "k",
           "figure.figsize": (10,6),
           "axes.facecolor": [0.9, 0.9, 0.9],
           "axes.grid": True,
           "axes.labelsize": 10,
           "axes.titlesize": 10,
           "xtick.labelsize": 8,
           "ytick.labelsize": 8,
           "legend.fontsize": 8,
           "legend.facecolor": "w"}

 %matplotlib inline
 plt.rcParams.update(params)

 # helper funcs
 def getData():
     return (pd.read_csv('./data/HotWheels.csv')
             .assign(Winner=lambda x: x['Winner'] - 1)
             )

 def removeLaneCoding(tmp, num):
     return (tmp[[f'Lane{num}', 'Winner']]
             .assign(won=lambda x: x['Winner'] == (num-1))
             .rename(columns={f'Lane{num}': 'racer'})
             .drop(columns=['Winner'])
             )

 def explodeRows():
     tmp = getData()
     tmp = pd.concat([
         removeLaneCoding(tmp, 1),
         removeLaneCoding(tmp, 2)
     ])
     return tmp

Here's a quick look at the data. I originally encoded the car in lane 1, the car in lane 2, and which lane won. I updated the final column to whether lane2 won which will make modeling a little easier.

 df = getData()
 print(len(df))
 df.head()

37

  Lane1 Lane2 Winner
0 Spikey Mom 1
1 Whitey Orange 1
2 BlackBlur CandyCane 0
3 Reddy Nick 0
4 Yellow Spidey 1

One of my first questions is which lane is faster. Lane2 won 21 of 37 races (57%). Hard to draw too much of a conclusion here though because I made no attempt to counterbalance cars across the lanes.

 (getData()
  .assign(_=lambda x: 'Lane2')
  .groupby('_')
  .agg({'Winner': ['sum', 'count', 'mean']})
  .reset_index()
  )
  _ Winner    
    sum count mean
0 Lane2 21 37 0.567568

A natural next question is to look at the performance of the individual cars. To do this, I need to transform the data such that each race is two rows representing the outcome from each car's perspective.

 df = explodeRows()
 print(len(df))
 df.head()

74

  racer won
0 Spikey False
1 Whitey False
2 BlackBlur True
3 Reddy True
4 Yellow False

Now to group by car at look at the number of races, number of wins, and proportion of wins.

 (explodeRows()
  .groupby('racer')
  .agg({'won': ['sum', 'count', 'mean']})
  .reset_index()
  .sort_values('racer')
  )
  racer won    
    sum count mean
0 BigChain 4 5 0.800000
1 BlackBlur 5 8 0.625000
2 Bluey 6 9 0.666667
3 CandyCane 5 15 0.333333
4 Mom 3 4 0.750000
5 Nick 3 7 0.428571
6 Orange 7 10 0.700000
7 Reddy 1 3 0.333333
8 Spidey 2 4 0.500000
9 Spikey 0 2 0.000000
10 Whitey 0 2 0.000000
11 Yellow 1 5 0.200000

To create the model, I need to numerically encode the cars' identities. The only tricky thing here is not all cars have raced in both lanes.

 df = getData()
 racer_set = set(df['Lane1'].tolist() + df['Lane2'].tolist())
 racers = {x: i for i, x in enumerate(racer_set)}
 lane1_idx = np.array(df['Lane1'].map(racers))
 lane2_idx = np.array(df['Lane2'].map(racers))
 coords = {'racer': racers}
 print(coords)

{'racer': {'Whitey': 0, 'Yellow': 1, 'Bluey': 2, 'BigChain': 3, 'CandyCane': 4, 'Spidey': 5, 'Nick': 6, 'Spikey': 7, 'Mom': 8, 'Orange': 9, 'BlackBlur': 10, 'Reddy': 11}}

Now to design a model. The outcome is whether lane 2 won the race. The inputs are an intercept term (which I name lane2) and the quality of the two cars in the race. I figured these cars would be compared by subtracting one from another. I think this makes more sense than something like a ratio.....but feel free to let me know if you think otherwise.

 import pymc as pm
 import arviz as az

 with pm.Model(coords=coords) as model:
     # global model parameters
     lane2 = pm.Normal('lane2', mu=0, sigma=2)
     sd_racer = pm.HalfNormal('sd_racer', sigma=1)

     # team-specific model parameters
     mu_racers = pm.Normal('mu_racer', mu=0, sigma=sd_racer, dims='racer')

     # model
     μ = lane2 + mu_racers[lane2_idx] - mu_racers[lane1_idx]
     θ = pm.math.sigmoid(μ)
     yl = pm.Bernoulli('yl', p=θ, observed=df['Winner'])
     trace = pm.sample(2000, chains=4)

 az.plot_trace(trace, var_names=["lane2"], compact=False);

 az.summary(trace, kind="diagnostics")

Onto results. First, is lane 2 faster. Here's a table with of lane2's (the intercept's) posterior.

 (az.hdi(trace)["lane2"]
  .sel({"hdi": ["lower", "higher"]})
  .to_dataframe()
  .reset_index()
  .assign(_='lane2')
  .pivot(index='_', columns='hdi', values='lane2')
  .reset_index()
  .assign(median=trace.posterior["lane2"].median(dim=("chain", "draw")).to_numpy())
  .assign(width=lambda x: x['higher'] - x['lower'])
  [['lower', 'median', 'higher', 'width']]
  )
hdi lower median higher width
0 -0.292225 0.546044 1.481515 1.77374

Let's look at the portion of the intercept's posterior that is greater than 0. The model is 90% sure that lane 2 is faster.

 (trace
  .posterior["lane2"]
  .to_dataframe()
  .reset_index()
  .assign(greater_0=lambda x: x['lane2'] > 0)['greater_0'].mean()
  )

0.899875

Next, onto the measurement of the cars. Candy Cane is my 3yr olds favorite so lots of trials there. It also pretty consistently loses. The model picks up on this. Not surprised to see Orange near the top. Big Chain also seems quite fast. I was not aware of how well Bluey was performing.

I'm not sure how to measure this (maybe it's the widthe of hdi interval) but I'm curious which cars I'd learn the most about given 1 additional race.

 (az.hdi(trace)["mu_racer"]
  .sel({"hdi": ["lower", "higher"]})
  .to_dataframe()
  .reset_index()
  .pivot(index='racer', columns='hdi', values='mu_racer')
  .reset_index()
  .merge(trace.posterior["mu_racer"].median(dim=("chain", "draw")).to_dataframe('median'),
         on='racer',
         how='left')
  .sort_values(by='median')
  .assign(width=lambda x: x['higher'] - x['lower'])
  [['racer', 'lower', 'median', 'higher', 'width']]
  )
  racer lower median higher width
3 CandyCane -1.630189 -0.477509 0.385684 2.015874
11 Yellow -1.732217 -0.281237 0.735534 2.467751
9 Spikey -1.861409 -0.230835 0.939880 2.801289
10 Whitey -1.798544 -0.181691 0.958850 2.757394
5 Nick -1.182705 -0.082876 0.941209 2.123914
8 Spidey -1.248341 -0.037978 1.116357 2.364698
7 Reddy -1.215164 0.021394 1.376155 2.591319
4 Mom -1.041651 0.127130 1.415593 2.457243
1 BlackBlur -0.747250 0.189628 1.351777 2.099027
0 BigChain -0.742637 0.298039 1.720762 2.463398
2 Bluey -0.606904 0.338542 1.570626 2.177530
6 Orange -0.519564 0.375396 1.543926 2.063490

Next I wanted to get a feel for how the cars compared in terms of how often I expect one car to win against another. I compared this by looking at the share of a car's posterior (racer_x) that was greater than the median of each other car's posterior (racer_y).

Only 2% of Candy Cane's posterior is greater than Orange's median!

 results = trace.posterior["mu_racer"].to_dataframe().reset_index()
 means = results.groupby('racer')['mu_racer'].median().reset_index()
 comparisons = (results
                .assign(key=0)
                .merge(means.assign(key=0), on='key', how='inner')
                .drop(columns='key')
                .assign(racerx_win_prob=lambda x: x['mu_racer_x'] > x['mu_racer_y'])
                .groupby(['racer_x', 'racer_y'])['racerx_win_prob']
                .mean()
                .reset_index()
                )
 (comparisons
  .pivot(index='racer_x', columns='racer_y', values='racerx_win_prob')
  )
racery BigChain BlackBlur Bluey CandyCane Mom Nick Orange Reddy Spidey Spikey Whitey Yellow
racerx                        
BigChain 0.499875 0.581875 0.472875 0.952750 0.631125 0.803625 0.446500 0.714750 0.768125 0.888375 0.866375 0.908000
BlackBlur 0.415125 0.500000 0.388125 0.936250 0.555625 0.749125 0.359500 0.660375 0.711875 0.847125 0.819625 0.872375
Bluey 0.529875 0.628000 0.500000 0.970000 0.674250 0.840500 0.470500 0.763625 0.808125 0.913375 0.892625 0.930125
CandyCane 0.028625 0.052250 0.023000 0.500000 0.070875 0.193000 0.019250 0.122000 0.157750 0.313250 0.275625 0.355000
Mom 0.355750 0.445250 0.329125 0.883625 0.500000 0.669250 0.303750 0.585000 0.630875 0.779250 0.749625 0.807500
Nick 0.184750 0.257625 0.165375 0.790625 0.308625 0.500000 0.148625 0.397375 0.458500 0.633000 0.585500 0.670375
Orange 0.559625 0.652250 0.529375 0.979875 0.708500 0.856250 0.500000 0.786000 0.824625 0.927875 0.906625 0.943250
Reddy 0.289250 0.361250 0.265875 0.832625 0.406000 0.588250 0.247750 0.500000 0.549625 0.704125 0.665750 0.735625
Spidey 0.228875 0.297000 0.205625 0.806250 0.347250 0.543000 0.189500 0.442125 0.500000 0.664750 0.624750 0.696875
Spikey 0.131750 0.190250 0.116875 0.653750 0.226000 0.380875 0.104375 0.300375 0.344625 0.500000 0.465625 0.539500
Whitey 0.161500 0.208625 0.144375 0.693625 0.246875 0.418750 0.133125 0.332250 0.379125 0.538500 0.500000 0.572250
Yellow 0.095625 0.142375 0.083375 0.624250 0.176000 0.340250 0.073125 0.246250 0.300000 0.462000 0.426375 0.500000

I visually depict each car's posterior in the next two plots. Not much sticks out to be beyond... I have some more playing to do!

 _, ax = plt.subplots()

 trace_hdi = az.hdi(trace)
 ax.scatter(racers.values(), trace.posterior["mu_racer"].median(dim=("chain", "draw")), color="C0", alpha=1, s=100)
 ax.vlines(
     racers.values(),
     trace_hdi["mu_racer"].sel({"hdi": "lower"}),
     trace_hdi["mu_racer"].sel({"hdi": "higher"}),
     alpha=0.6,
     lw=5,
     color="C0",
 )
 ax.set_xticks(list(racers.values()), racers.keys())
 ax.set_xlabel("Car")
 ax.set_ylabel("Posterior Car Speed")
 ax.set_title("HDI of Car Speed");

 class TeamLabeller(az.labels.BaseLabeller):
     def make_label_flat(self, var_name, sel, isel):
         sel_str = self.sel_to_str(sel, isel)
         return sel_str
 ax = az.plot_forest(trace, var_names=["mu_racer"], labeller=TeamLabeller())
 ax[0].set_title("Car Speed");

statistics

Comments