Model for tennis tournament

Hello! I am trying to create a simple model for estimating tennis players’ strengths. My idea is to get the final results from a tournament and infer a score for each player’s strength. I have tried a couple models but can’t get anything to work with reasonable results yet and would love to get some feedback about how to approach this.

Here is what I tried so far. I am using mock data for a tournament of this format:

players = ['A', 'B', 'C', 'D', ...]
matches = [
    {'player_a': 'A', 'player_b': 'B',
     'score': [(6, 4), (6, 4)]},
    {'player_a': 'C', 'player_b': 'D',
     'score': [(6, 2), (4, 6), (4, 6)]},
    ...
]

I tried a couple of different models. Since the overall model got a bit bigger I will try to describe only the relevant parts. The overall structure I made up goes like this:

  1. sample a strength for each player from uniform distribution in (0, 1) (or any other distribution)
for player in players:
    strength[player] = pyro.sample('{}_strength'.format(player),
                                   Uniform(0, 1))
  1. go through each match in the data and simulate a result for it
    a. to simulate a game simulate sets until one player wins
    b. to simulate a set simulate games until a player wins the set
    c. to simulate a game sample from a categorical distribution with two categories with the players’ strengths
a, b = strength[player_a], strength[player_b]

awin = pyro.sample('match_{}_set_{}_game_{}'.format(match_num, set_num, game_num),
                   Categorical(tensor([b, a])))

d. after each simulated set sample from a determined categorical distribution with the total score in order to record observed data like so:

pyro.sample('match_{}_set_{}_{}'.format(match_num, set_num, player_a), Categorical(eye(8)[games_a]), obs=match['score'][match_num][0])
pyro.sample('match_{}_set_{}_{}'.format(match_num, set_num, player_b), Categorical(eye(8)[games_b]), obs=match['score'][match_num][1])

Then for the guide I would create two parameters for each player’s strength and then sample the strength for the player with a beta distribution with the parameters.

I have tried a couple of different variations of this models but can’t get any reasonable results out of it. What am I missing?

Here is the whole model:

from collections import defaultdict

import numpy as np
from scipy import stats
from matplotlib import pyplot as plt
from torch import tensor, eye
from torch.distributions import constraints
import pyro
import pyro.optim
from pyro.distributions import Categorical, Uniform, Beta


players = ['Federer', 'Djokovic', 'Nadal', 'Dimitrov', 'Vavrinka', 'Murray']
matches = [
        {'player_a': 'Federer', 'player_b': 'Djokovic',
         'score': [(6, 4), (6, 4)]},
        {'player_a': 'Nadal', 'player_b': 'Dimitrov',
         'score': [(6, 2), (4, 6), (4, 6)]},
        {'player_a': 'Vavrinka', 'player_b': 'Murray',
         'score': [(6, 0), (6, 0)]},
        {'player_a': 'Federer', 'player_b': 'Murray',
         'score': [(6, 0), (6, 0)]},
        {'player_a': 'Nadal', 'player_b': 'Vavrinka',
         'score': [(6, 2), (5, 7), (6, 2)]},
        {'player_a': 'Dimitrov', 'player_b': 'Djokovic',
         'score': [(0, 6), (7, 6), (7, 6)]},
]

strength = {}


def game(i, set_num, game_num):
    player_a, player_b = matches[i]['player_a'], matches[i]['player_b']
    a, b = strength[player_a], strength[player_b]

    awin = pyro.sample('match_{}_set_{}_game_{}'.format(i, set_num, game_num),
                       Categorical(tensor([b, a])))

    return player_a if awin else player_b


def gameset(i, set_num):
    player_a, player_b = matches[i]['player_a'], matches[i]['player_b']

    games_a, games_b = 0, 0
    game_num = 1
    while True:
        if max(games_a, games_b) == 7 or \
                (games_a == 6 and games_b <= 4) or \
                (games_b == 6 and games_a <= 4):
            break

        if game(i, set_num, game_num) == player_a:
            games_a += 1
        else:
            games_b += 1

        game_num += 1

    fmt = 'match_{}_set_{}_{}'.format(i, set_num, '{}')
    pyro.sample(fmt.format(player_a), Categorical(eye(8)[games_a]))
    pyro.sample(fmt.format(player_b), Categorical(eye(8)[games_b]))

    return (games_a, games_b)


def match(i, best_of=3):
    score = []
    set_num = 1
    while max(sum(1 if s[0] > s[1] else 0 for s in score),
              sum(0 if s[0] > s[1] else 1 for s in score)) < best_of//2+1:
        score.append(gameset(i, set_num))
        set_num += 1

    player_a, player_b = matches[i]['player_a'], matches[i]['player_b']
    return player_a, player_b, score


def tournament():
    for idx, player in enumerate(players):
        strength[player] = pyro.sample('{}_strength'.format(player),
                                       Uniform(0, 1))

    results = []
    for i in range(len(matches)):
        results.append(match(i))
    return results


def get_conditioned_tournament():
    data = {}

    for i, match in enumerate(matches):
        set_num = 1
        player_a, player_b = match['player_a'], match['player_b']
        for s0, s1 in match['score']:
            fmt = 'match_{}_set_{}_{}'.format(i, set_num, '{}')
            data[fmt.format(player_a)] = tensor(s0)
            data[fmt.format(player_b)] = tensor(s1)

            # stupid way to give values to unobserved individual games
            game_num = 1
            if s0 == 7 and s1 == 6:
                for _ in range(5):
                    data['match_{}_set_{}_game_{}'.format(i, set_num, game_num)] = tensor(0)
                    game_num += 1
                for _ in range(6):
                    data['match_{}_set_{}_game_{}'.format(i, set_num, game_num)] = tensor(1)
                    game_num += 1
                data['match_{}_set_{}_game_{}'.format(i, set_num, game_num)] = tensor(0)
                game_num += 1
                data['match_{}_set_{}_game_{}'.format(i, set_num, game_num)] = tensor(1)
            elif s1 == 7 and s0 == 6:
                for _ in range(5):
                    data['match_{}_set_{}_game_{}'.format(i, set_num, game_num)] = tensor(1)
                    game_num += 1
                for _ in range(6):
                    data['match_{}_set_{}_game_{}'.format(i, set_num, game_num)] = tensor(0)
                    game_num += 1
                data['match_{}_set_{}_game_{}'.format(i, set_num, game_num)] = tensor(1)
                game_num += 1
                data['match_{}_set_{}_game_{}'.format(i, set_num, game_num)] = tensor(0)
            elif s0 > s1:
                for _ in range(s1):
                    data['match_{}_set_{}_game_{}'.format(i, set_num, game_num)] = tensor(0)
                    game_num += 1
                for _ in range(s0):
                    data['match_{}_set_{}_game_{}'.format(i, set_num, game_num)] = tensor(1)
                    game_num += 1
            else:
                for _ in range(s0):
                    data['match_{}_set_{}_game_{}'.format(i, set_num, game_num)] = tensor(1)
                    game_num += 1
                for _ in range(s1):
                    data['match_{}_set_{}_game_{}'.format(i, set_num, game_num)] = tensor(0)
                    game_num += 1

            set_num += 1

    return pyro.condition(tournament, data=data)


conditioned_tournament = get_conditioned_tournament()


def tournament_guide():
    for idx, player in enumerate(players):
        a = pyro.param('{}_power_a'.format(player), tensor(3.0),
                       constraint=constraints.interval(3.0, 30.0))
        b = pyro.param('{}_power_b'.format(player), tensor(3.0),
                       constraint=constraints.interval(3.0, 30.0))
        pyro.sample('{}_strength'.format(player),
                    Beta(a, b))


def run_model():
    pyro.clear_param_store()
    pyro.enable_validation(True)
    svi = pyro.infer.SVI(model=conditioned_tournament,
                         guide=tournament_guide,
                         # optim=pyro.optim.SGD({'lr': 0.001, 'momentum': 0.1}),
                         optim=pyro.optim.Adam({"lr": 0.005, "betas": (0.95, 0.999)}),
                         loss=pyro.infer.Trace_ELBO())
    params = defaultdict(list)
    num_steps = 500
    for t in range(num_steps):
        if t % 100 == 0:
            print(t)
        params['losses'].append(svi.step())

    return params


def show(player):
    b = stats.beta(pyro.param(player+'_power_a').item(), pyro.param(player+'_power_b').item())
    plt.plot(b.pdf(np.arange(0,1,0.01)))
    plt.show()


params = run_model()
plt.plot(params['losses'])
plt.show()
for player in players:
    print(player,
          pyro.param('{}_power_a'.format(player)),
          pyro.param('{}_power_b'.format(player)))
    show(player)