Escalando Cartola com Cadeias de Markov e Programação Linear

Motivação

Meu time foi rebaixado, provavelmente não teremos Cartola FC para Série B em 2020.

Resumo

O time está em uma má fase!

Times de futebol passam por diferentes fases, e a impressão é sempre que os resultados resultados anteriores influenciam nos jogos seguintes. A maioria dos modelos que utilizamos para modelagem, assumem que os dados são i.i.d(independent and identically distributed), caso o jogo anterior realmente influencie no resultado, essa premissa não é verdadeira, e talvez um modelo para trabalhar com dados sequenciais funcione melhor.

Créditos:

  • Todas as bases utilizadas nesse estudo, são parte do trabalho conduzido pelo Henrique Gomide, e estão disponíveis nesse repositório.
  • Vários dos conceitos aplicados nesse estudo, tem como referência o trabalho do professor John Paisley da universidade de Columbia, para montar um ranking de clubes de basquete universitário com Cadeias de Markov. Todo o conceito aplicado está disponível aqui.
  • A otimização linear teve como referência o artigo feito por Gupta, Akhil na International Conference on Sports Engineering ICSE-2017, disponível no link.

Gerando lista com todos jogadores

Para gerar a nossa matriz de transições, precisamos manter a quantidade de jogadores única. Para isso, vamos usar o arquivo de média dos jogadores, apenas para conseguir todos os jogadores que atuaram no ano e a sua posição.


In [1]:
import pandas as pd

In [2]:
medias = pd.read_csv(r'..\..\data\2019\2019-medias-jogadores.csv')

In [3]:
medias.head()


Out[3]:
player_slug player_id player_nickname player_team player_position price_cartoletas score_mean score_no_cleansheets_mean diff_home_away_s n_games ... A_mean I_mean FS_mean FF_mean G_mean DD_mean DP_mean status price_diff last_points
0 paulo-andre 37604 Paulo André 293 zag 3.91 0.725000 -0.525000 -0.149439 4 ... 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 0.000000 Provável 0.11 1.7
1 evandro 37614 Evandro 277 mei 5.48 2.938889 2.938889 1.593564 18 ... 0.277778 0.111111 1.722222 0.277778 0.055556 0.000000 0.000000 Dúvida -1.78 2.8
2 betao 37646 Betão 314 zag 3.97 1.914286 0.842857 -0.625981 28 ... 0.000000 0.000000 1.000000 0.107143 0.000000 0.000000 0.000000 Provável -0.14 0.1
3 rafael-moura 37655 Rafael Moura 290 ata 7.86 3.331818 3.331818 0.701920 22 ... 0.045455 0.318182 1.181818 1.000000 0.409091 0.000000 0.000000 Provável 2.89 22.2
4 fabio 37656 Fábio 283 gol 10.35 3.445714 1.731429 0.131504 35 ... 0.000000 0.000000 0.142857 0.000000 0.000000 1.257143 0.057143 Provável 0.42 2.0

5 rows × 26 columns


In [4]:
medias.shape


Out[4]:
(692, 26)

In [5]:
medias.columns


Out[5]:
Index(['player_slug', 'player_id', 'player_nickname', 'player_team',
       'player_position', 'price_cartoletas', 'score_mean',
       'score_no_cleansheets_mean', 'diff_home_away_s', 'n_games',
       'score_mean_home', 'score_mean_away', 'shots_x_mean', 'fouls_mean',
       'RB_mean', 'PE_mean', 'A_mean', 'I_mean', 'FS_mean', 'FF_mean',
       'G_mean', 'DD_mean', 'DP_mean', 'status', 'price_diff', 'last_points'],
      dtype='object')

Quantidade única de jogadores é do mesmo tamanho do Dataframe.


In [6]:
qtd_atletas = len(medias['player_id'].unique())
print(qtd_atletas)


692

Para o contexto desse estudo, vamos analisar cada posição utilizada no Cartola separadamente. Sendo assim criamos uma lista com todas as posições existentes no arquivo médias.


In [7]:
posicoes = medias['player_position'].unique()

Para facilitar a localização de cada jogador nas matrizes que construirmos, vamos criar um índice baseado no rankeamento do "player_id". Como teremos matrizes para cada posição, criamos um ranking para cada posição.


In [8]:
medias['Rank'] = None
for posicao in posicoes:
    rank = medias[medias['player_position'] == posicao].player_id.rank(method='min')
    rank = rank - 1
    medias.iloc[rank.index,-1] = rank

In [9]:
colunas_unicos = ['Rank','player_id','player_position']
atletas = medias[colunas_unicos].drop_duplicates()

In [10]:
atletas.head()


Out[10]:
Rank player_id player_position
0 0 37604 zag
1 0 37614 mei
2 1 37646 zag
3 0 37655 ata
4 0 37656 gol

In [11]:
atletas.shape


Out[11]:
(692, 3)

Partidas

Os resultados das partidas irão gerar informações para a matriz de transição, sendo assim utilizamos o arquivo com todas as partidas de 2019 e selecionamos treino e teste mais adiante.


In [12]:
partidas = pd.read_csv(r'..\..\data\2019\2019_partidas.csv')

Uma das hipóteses testadas nesse estudo, é o impacto da quantidade de gols do time na performance do jogador. Para facilitar a utilização desses dados, vamos normalizar as colunas de quantidade de gols dos time de casa e visitante.


In [13]:
partidas['home_score_norm'] = partidas['home_score'] / max(partidas['home_score'])
partidas['away_score_norm'] = partidas['away_score'] / max(partidas['away_score'])

In [14]:
partidas.head()


Out[14]:
date home_team away_team home_score away_score round home_score_norm away_score_norm
0 2019-04-28 284 277 1 2 1 0.166667 0.4
1 2019-04-27 282 314 2 1 1 0.333333 0.2
2 2019-04-28 354 341 4 0 1 0.666667 0.0
3 2019-04-28 275 356 4 0 1 0.666667 0.0
4 2019-04-27 276 263 2 0 1 0.333333 0.0

In [15]:
partidas.shape


Out[15]:
(379, 8)

Dados das rodadas

Agora, vamos importar os dados de performance de todos os jogadores em todas as rodadas de 2019, deixando uma coluna de identificação da rodada para podermos iterar nela.


In [16]:
df_partidas = pd.DataFrame()
for rodada in range(1,39):
    df_rodada = pd.read_csv(r'..\..\data\2019\rodada-{}.csv'.format(rodada))
    df_rodada['round'] = rodada
    df_partidas =df_partidas.append(df_rodada,sort=False)

In [17]:
df_partidas.shape


Out[17]:
(30581, 34)

Para o contexto desse estudo não vamos analisar a performance de técnicos.


In [18]:
df_partidas = df_partidas[df_partidas['atletas.posicao_id'] != 'tec']

Para colocar cada jogador em uma posição específica na matriz, vamos trazer a informação de ranking que criamos para o dataframe de partidas.


In [19]:
df_partidas = df_partidas.set_index('atletas.atleta_id').join(atletas.set_index('player_id'))

In [20]:
df_partidas.head()


Out[20]:
Unnamed: 0 atletas.nome atletas.slug atletas.apelido atletas.foto atletas.rodada_id atletas.clube_id atletas.posicao_id atletas.status_id atletas.pontos_num ... A CA I CV PP GC DP round Rank player_position
37604 116 Paulo André Cren Benini paulo-andre Paulo André https://s.glbimg.com/es/sde/f/2019/03/30/d13b3... 1 293 zag Nulo 0.0 ... NaN NaN NaN NaN NaN NaN NaN 1 0.0 zag
37604 336 Paulo André Cren Benini paulo-andre Paulo André https://s.glbimg.com/es/sde/f/2019/03/30/d13b3... 2 293 zag Provável 0.0 ... NaN NaN NaN NaN NaN NaN NaN 2 0.0 zag
37604 83 Paulo André Cren Benini paulo-andre Paulo André https://s.glbimg.com/es/sde/f/2019/03/30/d13b3... 3 293 zag Dúvida -3.3 ... NaN 1.0 NaN NaN NaN NaN NaN 3 0.0 zag
37604 305 Paulo André Cren Benini paulo-andre Paulo André https://s.glbimg.com/es/sde/f/2019/03/30/d13b3... 4 293 zag Provável 4.5 ... NaN 1.0 NaN NaN NaN NaN NaN 4 0.0 zag
37604 83 Paulo André Cren Benini paulo-andre Paulo André https://s.glbimg.com/es/sde/f/2019/03/30/d13b3... 5 293 zag Nulo 0.0 ... NaN 1.0 NaN NaN NaN NaN NaN 5 0.0 zag

5 rows × 35 columns


In [21]:
df_partidas['Rank']


Out[21]:
37604       0.0
37604       0.0
37604       0.0
37604       0.0
37604       0.0
          ...  
106831    181.0
106831    181.0
106832      NaN
106832      NaN
106834      NaN
Name: Rank, Length: 29825, dtype: float64

Removendo jogadores não cadastrados

Como a base para construção da nossa matriz é a tabela de atletas que criamos, caso algum jogador apareça na rodada e não esteja na tabela, desconsideramos esse jogador.


In [22]:
df_partidas.drop(df_partidas[df_partidas['Rank'].isnull()].index, inplace=True)

In [23]:
df_partidas['Rank'] = df_partidas['Rank'].astype(int)

Matriz M de estados

Para cada posição(atacante, zagueiro, etc.), iniciamos uma matriz de zeros com tamanho d x d, sendo d=quantidade de jogadores únicos.

Exemplo com atacantes

Para o restante do estudo vamos analisar os resultados para os atacantes.


In [24]:
import numpy as np

In [25]:
posicao = 'ata'

In [26]:
qtd_atletas = len(atletas[atletas.player_position == posicao])
M = np.zeros((qtd_atletas,qtd_atletas))

In [27]:
M


Out[27]:
array([[0., 0., 0., ..., 0., 0., 0.],
       [0., 0., 0., ..., 0., 0., 0.],
       [0., 0., 0., ..., 0., 0., 0.],
       ...,
       [0., 0., 0., ..., 0., 0., 0.],
       [0., 0., 0., ..., 0., 0., 0.],
       [0., 0., 0., ..., 0., 0., 0.]])

In [28]:
M.shape


Out[28]:
(182, 182)

Atualizando a matriz

  1. Selecionar partida.
  2. Selecionar os jogadores que atuaram pelo time da casa.
  3. Selecionar os jogadores que atuaram pelo time visitante.
  4. Avaliar cada jogador do time da casa contra cada jogador do time visitante.
  5. Atualizar a matriz de transições de acordo com a regra*:

Para um jogo específico, j1 é o índice do jogador avaliado do time da casa e j2 o índice do jogador avaliado do time visitante.

$$\hat{M}_{j1j1} \ \leftarrow \ \hat{M}_{j1j1} \ +\ gols\_time_{j1} \ +\ \frac{pontos_{j1}}{pontos_{j1} +pontos_{j2}}$$$$\hat{M}_{j2j2} \ \leftarrow \ \hat{M}_{j2j2} \ +\ gols\_time_{j2} \ +\ \frac{pontos_{j2}}{pontos_{j1} +pontos_{j2}}$$$$\hat{M}_{j1j2} \ \leftarrow \ \hat{M}_{j1j2} \ +\ gols\_time_{j2} \ +\ \frac{pontos_{j2}}{pontos_{j1} +pontos_{j2}}$$$$\hat{M}_{j2j1} \ \leftarrow \ \hat{M}_{j2j1} \ +\ gols\_time_{j1} \ +\ \frac{pontos_{j1}}{pontos_{j1} +pontos_{j2}}$$

*Regras para atacantes


In [29]:
df_partidas_posicao = df_partidas[df_partidas['atletas.posicao_id'] == posicao].copy()

In [30]:
for partida in range(len(partidas)-1): #Vamos deixar a última partida de fora para testes
    df_rodada = df_partidas_posicao[df_partidas_posicao['round'] == partidas['round'][partida]]
    jogadores_casa = df_rodada[df_rodada['atletas.clube_id'] == partidas['home_team'][partida]]
    jogadores_visitantes = df_rodada[df_rodada['atletas.clube_id'] == partidas['away_team'][partida]]
    
    for j_casa in range(len(jogadores_casa)):
        for j_visitante in range(len(jogadores_visitantes)):
            score_casa = 0
            score_visitante = 0
            
            pontos_j_casa = jogadores_casa['atletas.pontos_num'].iloc[j_casa]
            pontos_j_visitante = jogadores_visitantes['atletas.pontos_num'].iloc[j_visitante]
            
            soma =  pontos_j_casa + pontos_j_visitante 
            if soma != 0:
                score_casa = pontos_j_casa / soma
                score_visitante = pontos_j_visitante / soma
            
            j1 = jogadores_casa['Rank'].iloc[j_casa]
            j2 = jogadores_visitantes['Rank'].iloc[j_visitante]
                
            M[j1,j1] = M[j1,j1] + partidas['home_score_norm'][partida] + score_casa
            M[j1,j2] = M[j1,j2] + partidas['away_score_norm'][partida] + score_visitante
            M[j2,j1] = M[j2,j1] + partidas['home_score_norm'][partida] + score_casa
            M[j2,j2] = M[j2,j2] + partidas['away_score_norm'][partida] + score_visitante

In [31]:
M


Out[31]:
array([[ 1.89125212e+02,  1.33333333e+00,  3.66666667e-01, ...,
         4.13333333e-01,  0.00000000e+00,  0.00000000e+00],
       [-1.66666667e-01,  1.39137439e+02,  0.00000000e+00, ...,
         3.33333333e-01,  0.00000000e+00,  0.00000000e+00],
       [ 2.73333333e+00,  1.16666667e+00,  1.49798548e+02, ...,
         3.33333333e-01,  3.33333333e-01,  3.33333333e-01],
       ...,
       [ 1.48666667e+00,  1.20000000e+00,  0.00000000e+00, ...,
         4.45256140e+01,  0.00000000e+00,  0.00000000e+00],
       [ 0.00000000e+00,  0.00000000e+00,  1.20000000e+00, ...,
         0.00000000e+00,  3.33333333e+00,  0.00000000e+00],
       [ 0.00000000e+00,  0.00000000e+00,  1.20000000e+00, ...,
         0.00000000e+00,  0.00000000e+00,  3.33333333e+00]])

Depois de processar todas as partidas de todos os jogadores, vamos normalizar M para que todas as colunas somem 1.


In [32]:
M = M / np.sum(M,axis=1)

Distribuição estacionária

Agora que temos a nossa matriz de transição pronta, podemos calcular a distribuição estacionária.


In [33]:
evals, evecs = np.linalg.eig(M.T)
evec1 = evecs[:,np.isclose(evals, 1)]

evec1 = evec1[:,0]
stationary = evec1 / evec1.sum()
stationary = stationary.real

Por final geramos uma array de tamanho d, lembrando que uma posição i aqui está relacionada a posição i no ranking de ids que criamos no começo do estudo.

Podemos notar que as probabilidades são muito baixas, sendo muito difícil selecionar um jogador apenas pela probabilidades aqui geradas.


In [34]:
stationary


Out[34]:
array([ 0.00709695,  0.00615471,  0.00482594,  0.00694504,  0.01169433,
        0.00470028,  0.00340639,  0.00602075,  0.01388707,  0.00426904,
        0.01132313,  0.01201425,  0.0074333 ,  0.0070755 ,  0.00721605,
        0.01254784,  0.00884598,  0.0034889 ,  0.00459704,  0.00811447,
        0.00389371,  0.01438393,  0.00504572,  0.00471931,  0.00820365,
        0.01650182,  0.00597158,  0.00851101,  0.01258323,  0.00442217,
        0.00710118,  0.00683491,  0.00147972,  0.0062735 ,  0.01321127,
        0.00165815,  0.00248103,  0.00859438,  0.00218015,  0.00373836,
        0.00805596,  0.00738451,  0.00573467,  0.00740401,  0.00729592,
        0.00492663,  0.00407701,  0.00298243,  0.00879831,  0.00376915,
        0.00971908,  0.00167764, -0.00045752,  0.00113731,  0.00259699,
        0.01257637,  0.01269735,  0.00743134,  0.00319426,  0.01218161,
        0.0029924 ,  0.00325711,  0.00372088,  0.00265928,  0.00132734,
        0.00088727,  0.01810294,  0.00538199,  0.00673407,  0.01437957,
        0.00405051,  0.01112214,  0.00403702,  0.00512249,  0.00393759,
        0.00335308,  0.00569006,  0.00358389,  0.01334014,  0.00776384,
        0.00467949,  0.00183235,  0.00418572,  0.00650434,  0.00443584,
        0.00301788,  0.0064478 ,  0.00122525,  0.01675702,  0.00378148,
        0.00488265,  0.01074932,  0.00531649,  0.00388133,  0.00429433,
        0.00855186,  0.01188618,  0.02073499,  0.00243295,  0.00672155,
        0.00376402,  0.00277235,  0.00165963,  0.00668665,  0.00250041,
        0.0038504 ,  0.00592579,  0.00376299,  0.00259107,  0.00208737,
        0.00089896,  0.00668376,  0.00145035,  0.0060252 ,  0.00538587,
        0.00959674,  0.00582033,  0.00176743,  0.00287855,  0.00327843,
        0.00144609,  0.00495491,  0.00434568,  0.01231942,  0.00139781,
        0.00366682,  0.00243818,  0.00628904,  0.00355093,  0.00694195,
        0.00528205,  0.0029838 ,  0.00477606,  0.00304162,  0.00979501,
        0.00080995,  0.01014144,  0.00483413,  0.0064588 ,  0.00472622,
        0.00152552,  0.00313748,  0.00416676,  0.00329779,  0.00274631,
        0.0075244 ,  0.00123672,  0.00361224,  0.00366485,  0.00558777,
        0.00231455,  0.0026847 ,  0.00434569,  0.00263306,  0.00487249,
        0.00299977,  0.00273558,  0.00663123,  0.00295881,  0.00879456,
        0.00703377,  0.00292686,  0.00844336,  0.00220506,  0.00198465,
        0.00949338,  0.002309  ,  0.00357865,  0.00440057,  0.00080723,
        0.00156054,  0.00153621,  0.00345359,  0.00587878,  0.00354855,
        0.0020333 ,  0.00129726,  0.00172759,  0.00480724,  0.00316807,
        0.00217934,  0.00217934])

Podemos verificar por exemplo quem teve probabilidade maior que 1.5%.

O fato dos jogadores como Gabriel e Bruno Henrique aparecem entre os 5 maiores, pode ser um indicador que a regra criada para uma comparação de pontos + quantidade de gols marcada pelo time, pode estar dando maior peso para esses jogadores, o que é uma coisa boa. :)


In [35]:
medias[medias.player_position == posicao][list(stationary > 0.015)]


Out[35]:
player_slug player_id player_nickname player_team player_position price_cartoletas score_mean score_no_cleansheets_mean diff_home_away_s n_games ... I_mean FS_mean FF_mean G_mean DD_mean DP_mean status price_diff last_points Rank
142 dudu 68920 Dudu 275 ata 18.90 5.780645 5.780645 0.809397 31 ... 0.290323 3.677419 0.903226 0.290323 0.0 0.0 Provável -2.06 7.3 25
331 gabriel 83257 Gabriel 262 ata 19.57 9.617857 9.617857 1.010896 28 ... 0.607143 1.071429 1.214286 0.892857 0.0 0.0 Provável -2.37 -0.8 66
423 bruno-henrique 90285 Bruno Henrique 262 ata 19.74 7.653125 7.653125 2.569387 32 ... 0.281250 2.187500 1.125000 0.593750 0.0 0.0 Provável -0.78 -0.3 88
447 rony 91607 Rony 293 ata 13.94 5.351724 5.351724 0.983257 29 ... 0.448276 2.172414 1.310345 0.206897 0.0 0.0 Nulo 0.76 6.7 97

4 rows × 27 columns

Calculando a distribuição para todas posições

Para as posições de defesa, vamos substituir a pontuação referente aos gols marcado pelo time, por uma variável binária, referente a ter sofrido gol no jogo. Caso o time não tenha levado gol no jogo, damos pontuação de 1, se a defesa foi vazada o valor é 0.

No meio-campo fazemos uma combinação das regras de defesa e ataque.


In [36]:
stationaries = {}

for posicao in posicoes:
    qtd_atletas = len(atletas[atletas.player_position == posicao])
    M = np.zeros((qtd_atletas,qtd_atletas))

    df_partidas_posicao = df_partidas[df_partidas['atletas.posicao_id'] == posicao].copy()

    for partida in range(len(partidas)-1): #Vamos deixar a última partida de fora para testes
        df_rodada = df_partidas_posicao[df_partidas_posicao['round'] == partidas['round'][partida]]
        jogadores_casa = df_rodada[df_rodada['atletas.clube_id'] == partidas['home_team'][partida]]
        jogadores_visitantes = df_rodada[df_rodada['atletas.clube_id'] == partidas['away_team'][partida]]

        for j_casa in range(len(jogadores_casa)):
            for j_visitante in range(len(jogadores_visitantes)):
                score_casa = 0
                score_visitante = 0

                pontos_j_casa = jogadores_casa['atletas.pontos_num'].iloc[j_casa]
                pontos_j_visitante = jogadores_visitantes['atletas.pontos_num'].iloc[j_visitante]

                soma =  pontos_j_casa + pontos_j_visitante 
                if soma != 0:
                    score_casa = pontos_j_casa / soma
                    score_visitante = pontos_j_visitante / soma

                def_n_vazada_casa = 0 if partidas['away_score_norm'][partida] > 0 else 1
                def_n_vazada_visitante = 0 if partidas['home_score_norm'][partida] > 0 else 1
                
                if posicao == 'ata':
                    pontos_casa = partidas['home_score_norm'][partida] + score_casa
                    pontos_visitante = partidas['away_score_norm'][partida] + score_visitante
                elif posicao == 'mei':
                    pontos_casa = partidas['home_score_norm'][partida] + def_n_vazada_casa + score_casa
                    pontos_visitante = partidas['away_score_norm'][partida] + def_n_vazada_visitante + score_visitante
                else:
                    pontos_casa = def_n_vazada_casa + score_casa
                    pontos_visitante = def_n_vazada_visitante + score_visitante                  
                    
                j1 = jogadores_casa['Rank'].iloc[j_casa]
                j2 = jogadores_visitantes['Rank'].iloc[j_visitante]               

                M[j1,j1] = M[j1,j1] + pontos_casa
                M[j1,j2] = M[j1,j2] + pontos_visitante
                M[j2,j1] = M[j2,j1] + pontos_casa
                M[j2,j2] = M[j2,j2] + pontos_visitante

    M = M / np.sum(M,axis=1)

    evals, evecs = np.linalg.eig(M.T)
    evec1 = evecs[:,np.isclose(evals, 1)]

    evec1 = evec1[:,0]
    stationary = evec1 / evec1.sum()
    stationary = stationary.real

    stationaries[posicao] = stationary

Escalando para a rodada

No cálculo da distribuição, deixamos a última rodada de 2019 de fora, agora podemos utilizá-la para testar o nosso modelo.


In [37]:
rodada = 38

Primeiro vamos criar um DataFrame somente com as informações da rodada e colocar as probabilidades que encontramos referente a cada jogador.


In [38]:
df_rodada = df_partidas[df_partidas['round'] == rodada].copy()
df_rodada['Rank'] = df_rodada['Rank'].astype(int)
df_rodada['probs'] = 0

In [39]:
for jogador in range(len(df_rodada)):
    posicao = df_rodada.iloc[jogador]['player_position']
    rank = df_rodada.iloc[jogador]['Rank']
    if rank:
        df_rodada.iloc[jogador,-1] = stationaries[posicao][rank]

Vamos utilizar também do recurso de status e só trabalhar com jogadores em status Provável.


In [40]:
df_rodada = df_rodada[df_rodada['atletas.status_id'] == 'Provável'].copy()

In [41]:
df_rodada.head()


Out[41]:
Unnamed: 0 atletas.nome atletas.slug atletas.apelido atletas.foto atletas.rodada_id atletas.clube_id atletas.posicao_id atletas.status_id atletas.pontos_num ... CA I CV PP GC DP round Rank player_position probs
37655 4 Rafael Martiniano de Miranda Moura rafael-moura Rafael Moura https://s.glbimg.com/es/sde/f/2019/07/16/854eb... 38 290 ata Provável 22.2 ... 4.0 7.0 1.0 NaN NaN NaN 38 0 ata 0.000000
37656 3 Fábio Deivson Lopes Maciel fabio Fábio https://s.glbimg.com/es/sde/f/2018/05/18/d4072... 38 283 gol Provável 2.0 ... 2.0 NaN NaN NaN NaN 2.0 38 0 gol 0.000000
37694 42 Henrique Pacheco de Lima henrique Henrique https://s.glbimg.com/es/sde/f/2018/05/18/f4c3f... 38 283 mei Provável 3.7 ... 5.0 1.0 NaN NaN NaN NaN 38 2 mei 0.005886
38162 19 Frederico Chaves Guedes fred Fred https://s.glbimg.com/es/sde/f/2018/05/18/d0c4a... 38 283 ata Provável 0.0 ... 11.0 14.0 NaN NaN NaN NaN 38 3 ata 0.006945
38279 17 Wellington Pereira do Nascimento wellington-paulista Wellington Paulista https://s.glbimg.com/es/sde/f/2019/03/23/2138d... 38 356 ata Provável 0.5 ... 7.0 15.0 NaN NaN NaN NaN 38 5 ata 0.004700

5 rows × 36 columns

Otimizando a escalação

Agora que temos as probabilidades de cada jogador, precisamos gerar a melhor escalação possível de acordo com as restrições de quantidade de "cartoletas" e quantidade de jogadores por posição.

Para isso vamos usar Programação Linear para maximizar a soma das probabilidades, restringindo a escalação escolhida e a quantidade de cartoletas.

Para os exemplos abaixo, vou usar a formação 4-3-3 e um total de 140 cartoletas.


In [42]:
formacao = {
    'ata': 3,
    'mei': 3,
    'lat': 2,
    'zag': 2,
    'gol':1
}

cartoletas = 140

Programação Linear

Podemos representar esse problema com a seguinte notação matemática.

  • zi, probabilidade de cada jogador i
  • ci, custo de cada jogador i
  • yi, valor binário indicando se o jogador i foi escalado ou não
  • n, total de jogadores
  • ai, valor binário indicando se o jogador i é atacante
  • mi, valor binário indicando se o jogador i é meio-campista
  • li, valor binário indicando se o jogador i é laterai
  • zi, valor binário indicando se o jogador i é zagueiro
  • gi, valor binário indicando se o jogador i é goleiro
$$ Max. \sum^n_{i=1}{z}_{i} * {y}_{i}$$

Restrições: $$ \sum^n_{i=1}{c}_{i} * {y}_{i} <= 140 $$ $$ \sum^n_{i=1}{a}_{i} * {y}_{i} = 3 $$ $$ \sum^n_{i=1}{m}_{i} * {y}_{i} = 3 $$ $$ \sum^n_{i=1}{l}_{i} * {y}_{i} = 2 $$ $$ \sum^n_{i=1}{z}_{i} * {y}_{i} = 2 $$ $$ \sum^n_{i=1}{g}_{i} * {y}_{i} = 1 $$

As variáveis que entraram na equação são relacionadas as posições, custo e probabilidade. Sendo assim, vamos criar dicionários com cada uma dessas informações relacionadas ao nome do jogador para facilitar a montagem do problema.


In [43]:
df_rodada.set_index('atletas.slug',inplace=True)
z = df_rodada['probs'].to_dict()
c = df_rodada['atletas.preco_num'].to_dict()

dummies_posicao = pd.get_dummies(df_rodada['atletas.posicao_id'])
dummies_posicao = dummies_posicao.to_dict()

Primeiro, iniciamos o problema de otimização e definimos uma função objetivo.


In [44]:
from pulp import LpMaximize, LpProblem, lpSum, LpVariable

prob = LpProblem("Melhor_Escalacao", LpMaximize)
y = LpVariable.dicts("Atl",df_rodada.index,0,1,cat='Binary')
prob += lpSum([z[i] * y[i] for i in y])

Agora adicionamos todas as restrições e calculamos.


In [45]:
prob += lpSum([c[i] * y[i] for i in y]) <= cartoletas, "Limite de Cartoletas"   
prob += lpSum([dummies_posicao['ata'][i] * y[i] for i in y]) == formacao['ata'], "Quantidade Atacantes"
prob += lpSum([dummies_posicao['lat'][i] * y[i] for i in y]) == formacao['lat'], "Quantidade Laterais"
prob += lpSum([dummies_posicao['mei'][i] * y[i] for i in y]) == formacao['mei'], "Quantidade Meio"
prob += lpSum([dummies_posicao['zag'][i] * y[i] for i in y]) == formacao['zag'], "Quantidade Zagueiros"
prob += lpSum([dummies_posicao['gol'][i] * y[i] for i in y]) == formacao['gol'], "Quantidade Goleiro"

In [46]:
prob.solve()


Out[46]:
1

Os jogadores escalados que maximizam as probabilidades dentro das restrições, ficam com o valor 1 para a variável de atletas.


In [47]:
escalados = []
for v in prob.variables():
    if v.varValue == 1:
        atleta = v.name.replace('Atl_','').replace('_','-')
        escalados.append(atleta)
        print(atleta, "=", v.varValue)


bruno-henrique = 1.0
carlos-sanchez = 1.0
diego-alves = 1.0
diogo-barbosa = 1.0
dudu = 1.0
everton-ribeiro = 1.0
gerson = 1.0
lucas-verissimo = 1.0
marcos-rocha = 1.0
pablo-mari = 1.0
vagner-love = 1.0

In [48]:
colunas = ['atletas.posicao_id','atletas.clube.id.full.name','atletas.pontos_num','atletas.preco_num']
df_rodada.loc[escalados][colunas]


Out[48]:
atletas.posicao_id atletas.clube.id.full.name atletas.pontos_num atletas.preco_num
atletas.slug
bruno-henrique mei Palmeiras 4.4 11.58
bruno-henrique ata Flamengo -0.3 19.74
carlos-sanchez mei Santos 16.5 16.71
diego-alves gol Flamengo -5.3 7.19
diogo-barbosa lat Palmeiras 5.4 13.62
dudu ata Palmeiras 7.3 18.90
everton-ribeiro mei Flamengo 0.1 11.37
gerson mei Flamengo 3.9 9.54
lucas-verissimo zag Santos 9.2 10.76
marcos-rocha lat Palmeiras 10.6 17.15
pablo-mari zag Flamengo -1.0 7.21
vagner-love ata Corinthians -1.3 4.69

Podemos verificar qual foi o total de pontos que essa escalação somaria na última rodada.


In [49]:
df_rodada.loc[escalados]['atletas.pontos_num'].sum()


Out[49]:
49.50000000000001

Também o custo total.


In [50]:
df_rodada.loc[escalados]['atletas.preco_num'].sum()


Out[50]:
148.46

Incluindo Palpites

Agora a cereja do bolo...

Até aqui, o nosso modelo compara posição por posição, os jogadores contra seus adversários. Simulando, de certa forma, qual jogador que deveríamos escolher, para cada posição, considerando a sequência de jogos que esse jogador teve.

No cálculo da distribuição estacionária, podemos notar que as probabilidades são muito semelhantes, ficando difícil escolher jogadores com muita certeza. Faz todo sentido, o futebol é rodeado de incertezas. No entanto essa estatística pode nos ajudar a escalar o time automaticamente.

Podemos então utilizar a probabilidade gerada pela cadeia de Markov para selecionar um time baseado em alguns palpites que temos para os jogos. Vamos fazer um sistema simples, onde distribuimos 10 pontos, para a importância de alguns fatores. Por exemplo, eu considero que jogar em casa é um fator importante, e também gosto de apostar em times que além de jogar em casa, vão pegar adversários que nas últimas posições no campeonato. Então, dei as seguintes notas para a última rodada:

  • 5 pontos - jogar em casa
  • 3 pontos - Internacional
  • 2 pontos - Fortaleza

In [51]:
jogar_em_casa = 5

times = {
    'Internacional':3,
    'Fortaleza':2
}

Agora, aumentamos as probabilidades dos jogadores que se enquadram nessa regra, multiplicando o seu valor atual, pela porcentagem de pontos que demos a ele, por exemplo:

  • Jogadores que jogam em casa = Probabilidade * 150%
  • Jogadores Internacional = Probabilidade * 130%
  • Jogadores Fortaleza = Probabilidade * 120%

In [52]:
times_casa = partidas[partidas['round'] == rodada]['home_team']
df_rodada.loc[df_rodada['atletas.clube_id'].isin(times_casa),'probs'] = df_rodada.loc[
    df_rodada['atletas.clube_id'].isin(times_casa),'probs'] * (jogar_em_casa / 10 + 1)

In [53]:
for time in times:
    df_rodada.loc[df_rodada['atletas.clube.id.full.name'] == time,'probs'] = df_rodada.loc[
        df_rodada['atletas.clube.id.full.name'] == time,'probs'] * (times[time] / 10 + 1)

In [54]:
z = df_rodada['probs'].to_dict()

Programação Linear

Podemos otimizar a equação novamente.


In [55]:
from pulp import LpMaximize, LpProblem, lpSum, LpVariable

prob = LpProblem("Melhor_Escalacao", LpMaximize)
y = LpVariable.dicts("Atl",df_rodada.index,0,1,cat='Binary')
prob += lpSum([z[i] * y[i] for i in y])

In [56]:
prob += lpSum([c[i] * y[i] for i in y]) <= cartoletas, "Limite de Cartoletas"   
prob += lpSum([dummies_posicao['ata'][i] * y[i] for i in y]) == formacao['ata'], "Quantidade Atacantes"
prob += lpSum([dummies_posicao['lat'][i] * y[i] for i in y]) == formacao['lat'], "Quantidade Laterais"
prob += lpSum([dummies_posicao['mei'][i] * y[i] for i in y]) == formacao['mei'], "Quantidade Meio"
prob += lpSum([dummies_posicao['zag'][i] * y[i] for i in y]) == formacao['zag'], "Quantidade Zagueiros"
prob += lpSum([dummies_posicao['gol'][i] * y[i] for i in y]) == formacao['gol'], "Quantidade Goleiro"

In [57]:
prob.solve()


Out[57]:
1

Por fim geramos uma nova escalação, que levou em consideração os pesos que colocamos acima.


In [58]:
escalados = []
for v in prob.variables():
    if v.varValue == 1:
        atleta = v.name.replace('Atl_','').replace('_','-')
        escalados.append(atleta)
        print(atleta, "=", v.varValue)


carlos-sanchez = 1.0
cassio = 1.0
diogo-barbosa = 1.0
lucas-verissimo = 1.0
marcos-rocha = 1.0
mateus-vital = 1.0
osvaldo = 1.0
romarinho = 1.0
vagner-love = 1.0
victor-cuesta = 1.0
yago-pikachu = 1.0

Ao avaliar a escalação abaixo, geramos dessa vez uma pontuação de 81 pontos, 60% a mais que o resultado anterior.

Outro ponto interessante é que usamos menos cartoletas do que na escalação anterior. Uma oportunidade é usar esse modelo para fazer escalações mais baratas, quando o objetivo for valorização. Para isso, basta colocar o limite que deseja no total de cartoletas.


In [59]:
colunas = ['atletas.posicao_id','atletas.clube.id.full.name','atletas.pontos_num','atletas.preco_num']
df_rodada.loc[escalados][colunas]


Out[59]:
atletas.posicao_id atletas.clube.id.full.name atletas.pontos_num atletas.preco_num
atletas.slug
carlos-sanchez mei Santos 16.5 16.71
cassio gol Corinthians -3.8 7.20
diogo-barbosa lat Palmeiras 5.4 13.62
lucas-verissimo zag Santos 9.2 10.76
marcos-rocha lat Palmeiras 10.6 17.15
mateus-vital mei Corinthians 3.2 7.59
osvaldo ata Fortaleza 13.4 8.26
romarinho ata Fortaleza 1.7 5.96
vagner-love ata Corinthians -1.3 4.69
victor-cuesta zag Internacional 15.6 16.50
yago-pikachu mei Vasco 10.9 10.46

In [60]:
df_rodada.loc[escalados]['atletas.pontos_num'].sum()


Out[60]:
81.4

In [61]:
df_rodada.loc[escalados]['atletas.preco_num'].sum()


Out[61]:
118.9

TODO

  • Reavaliar o efeito de jogadores que acumulam mais pontos, simplesmente pelo fato de jogarem mais partidas.