<jupyter_start><jupyter_text>Main Model Logic/Driver<jupyter_code># Roughly follows https://pytorch.org/tutorials/beginner/nn_tutorial.html#

# Model hyperparameters
num_epochs = 500
batch_size = 100
learning_rate = 0.0001

# Load data as torch.tensors
_, x_train, y_train = load_model_data('train')
_, x_validate, y_validate = load_model_data('dev')

# Define our model layers by decreasing powers of two
model = nn.Sequential( *log_two_layers(x_train.shape[1]) )
optimizer = optim.SGD(model.parameters(), lr=learning_rate)

train_ds = TensorDataset(x_train, y_train)
train_dl = DataLoader(train_ds, batch_size=batch_size, shuffle=True)

validate_ds = TensorDataset(x_validate, y_validate)
validate_dl = DataLoader(validate_ds, batch_size=batch_size * 2)

# L1 loss is more robust to outliers
loss_func = F.l1_loss
# loss_func = F.mse_loss

avg_validation_losses = []
for epoch in range(num_epochs):
    model.train()
    for xb, yb in train_dl:
        pred = model(xb)
        loss = loss_func(pred, yb)
        
        loss.backward()
        optimizer.step()
        optimizer.zero_grad()

    model.eval()
    with torch.no_grad():
        epoch_loss = sum(loss_func(model(xb), yb) for xb, yb in validate_dl)
        avg_validation_losses.append( epoch_loss / len(validate_dl) )
plt.xlabel('Epoch')
plt.ylabel('L1 Validation Loss')
plt.plot(avg_validation_losses)
plt.show()
i_t, x_test, y_test = load_model_data('test')

y_predicted = model(x_test)
# pd.Series are way easier to handle than tensors
y_hat = pd.Series(y_predicted.detach().squeeze(), index=i_t)
y = pd.Series(y_test.detach().squeeze(), index=i_t)

odds = load_odds_data('test').dropna()
intersection = odds.index.intersection(i_t)

# Filter games, since our prepared NBA data doesn't contain
# every game in the season.
odds = odds.loc[intersection]
y_hat = y_hat.loc[intersection]
y = y.loc[intersection]
# Check that both data sources agree on the points scored
assert (y == odds['pts']).all()
assert len(odds) == len(y) == len(y_hat)
wins = pd.DataFrame(index=['ou', 'spread'], columns=['open', 'close'])

# First let's compute the over/unders by group and sum
actual_totals = y.groupby(level='GAME_ID').sum()
model_totals = y_hat.groupby(level='GAME_ID').sum().round()

# Now let's look at spreads
# dropna here means we're comparing the second teams always
actual_spreads = y.groupby(level='GAME_ID').diff().dropna()
model_spreads = y_hat.groupby(level='GAME_ID').diff().dropna().round()

for kind in ('open', 'close'):
    # We generously count a push as a win
    bookie = odds.groupby(level='GAME_ID')[f'ou_{kind}'].first()
    long = (model_totals < bookie) & (actual_totals <= bookie)
    short = (model_totals > bookie) & (actual_totals >= bookie)
    push = model_totals == bookie
    correct_bets = long | short | push
    wins.at['ou', kind] = correct_bets.mean()
    
    # We need to divided be -2 here because the favorite is always given a negative
    # spread, but we actually a positive difference with the opponent
    bookie = odds.groupby(level='GAME_ID')[f'spread_{kind}'].diff().dropna() / -2
    long = (model_spreads > bookie) & (actual_spreads > bookie)
    short = (model_spreads < bookie) & (actual_spreads < bookie)
    push = model_spreads == bookie
    correct_bets = long | short | push
    wins.at['spread', kind] = correct_bets.mean()
    
wins<jupyter_output><empty_output>
{'fraction_non_alphanumeric': 0.12943262411347517, 'fraction_numerical': 0.005319148936170213, 'mean_word_length': 4.595041322314049, 'pattern_counts': {'":': 0, '<': 9, '<?xml version=': 0, '>': 9, 'https://': 1, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '1', 'dir': 'jupyter-structured-clean-dedup', 'id': '478843', 'license': 'MIT', 'n_tokens_mistral': 1224, 'n_tokens_neox': 1185, 'n_words': 284, 'path': 'playground.ipynb', 'repo_name': 'matteosantama/bookmaker', 'size': '3384'}