Pular para o conteúdo principal

Scikit-Learn Tutorial: Baseball Analytics Pt 1

A scikit-learn tutorial to predicting MLB wins per season by modeling data to KMeans clustering model and linear regression models.
· 14 min leer

Explore com IA

A linguagem de programação Python é uma ótima opção para ciência de dados e analytics preditivo, pois vem equipada com vários pacotes que cobrem a maior parte das suas necessidades de análise. Para machine learning em Python, Scikit-learn (sklearn) é uma excelente escolha e é construída sobre NumPy, SciPy e Matplotlib (respectivamente: arrays N-dimensionais, computação científica e visualização de dados).

Neste tutorial, você vai ver como carregar dados de um banco com sqlite3, como explorar e melhorar a qualidade dos dados com pandas e matplotlib, e como usar o pacote Scikit-Learn para extrair insights reais a partir do seu conjunto de dados.

Se quiser fazer um curso de machine learning, confira o curso Supervised Learning with scikit-learn da DataCamp.

Parte 1: prevendo vitórias de times da MLB por temporada

Neste projeto, você vai testar vários modelos de machine learning do sklearn para prever o número de jogos que um time da Major League Baseball venceu em uma temporada, com base nas estatísticas do time e outras variáveis daquele ano. Se eu fosse apostador (e eu certamente sou), poderia construir um modelo usando dados históricos de temporadas anteriores para projetar a próxima. Dado o caráter de série temporal, você poderia criar indicadores como média de vitórias por ano nos últimos cinco anos, entre outros fatores, para montar um modelo bem preciso. Isso foge ao escopo deste tutorial; aqui, cada linha será tratada como independente. Cada linha dos dados representa um único time em um ano específico.

Sean Lahman compilou esses dados em seu site, e eles foram transformados em um banco sqlite aqui.

Importando os dados

Você vai ler os dados consultando um banco sqlite com o pacote sqlite3 e convertendo o resultado em um DataFrame com pandas. Os dados serão filtrados para incluir apenas times modernos atualmente ativos e apenas anos em que o time jogou 150 partidas ou mais.

Primeiro, baixe o arquivo “lahman2016.sqlite” (aqui). Em seguida, carregue o Pandas e renomeie para pd para agilizar o uso. Você deve lembrar que pd é o alias mais comum de Pandas. Por fim, carregue sqlite3 e conecte ao banco assim:

# import `pandas` and `sqlite3`import pandas as pdimport sqlite3# Connecting to SQLite Databaseconn = sqlite3.connect('lahman2016.sqlite')

Depois, escreva uma query, execute e busque os resultados.

# Querying Database for all seasons where a team played 150 or more games and is still active today. query = '''select * from Teams inner join TeamsFranchiseson Teams.franchID == TeamsFranchises.franchIDwhere Teams.G >= 150 and TeamsFranchises.active == 'Y';'''# Creating dataframe from query.Teams = conn.execute(query).fetchall()

Dica: se você quer aprender mais sobre como usar SQL com Python, considere fazer o curso Introduction to Databases in Python da DataCamp.

Usando pandas, então converta os resultados em um DataFrame e imprima as 5 primeiras linhas com o método head():

# Convert `Teams` to DataFrame
teams_df = pd.DataFrame(_____)

# Print out first 5 rows
print(_______________)

Cada coluna traz dados de um time em um ano específico. Algumas das variáveis mais importantes estão abaixo. A lista completa pode ser encontrada aqui.

  • yearID - Ano
  • teamID - Time
  • franchID - Franquia (liga com a tabela TeamsFranchise)
  • G - Jogos disputados
  • W - Vitórias
  • LgWin - Campeão da liga (Y ou N)
  • WSWin - Campeão da World Series (Y ou N)
  • R - Corridas anotadas
  • AB - At bats (idas ao bastão)
  • H - Rebatidas
  • HR - Home runs
  • BB - Walks (bases por bolas)
  • SO - Strikeouts (eliminações por strike)
  • SB - Bases roubadas
  • CS - Pego roubando base
  • HBP - Rebatedor atingido por arremesso
  • SF - Sacrifice flies (bolas de sacrifício)
  • RA - Corridas sofridas
  • ER - Corridas merecidas permitidas
  • ERA - Média de corridas merecidas
  • CG - Jogos completos
  • SHO - Shutouts (jogos sem sofrer corridas)
  • SV - Saves
  • IPOuts - Eliminações arremessadas (innings arremessados x 3)
  • HA - Rebatidas permitidas
  • HRA - Home runs sofridos
  • BBA - Walks permitidos
  • SOA - Strikeouts dos arremessadores
  • E - Erros
  • DP - Double plays
  • FP - Fielding percentage (aproveitamento defensivo)
  • name - Nome completo do time

Se você não está tão familiarizado com beisebol, aqui vai um resumo de como o jogo funciona, incluindo algumas das variáveis.

O beisebol é jogado entre dois times (que você verá nos dados por name ou teamID) com nove jogadores cada. As equipes se revezam atacando (rebater) e defendendo (campo). O time ao bastão tenta marcar corridas rebatendo a bola arremessada pelo pitcher do time de defesa e correndo, em sentido anti-horário, pelas quatro bases: primeira, segunda, terceira e home plate. A equipe de defesa tenta evitar corridas eliminando rebatedores ou corredores de base de várias maneiras, e uma corrida (R) é anotada quando um jogador completa o circuito e retorna ao home plate. Um jogador que chega em base com segurança tentará avançar nas rebatidas dos colegas, como em uma rebatida (H), roubo de base (SB) ou por outros meios.

baseball

Os times trocam ataque e defesa sempre que a equipe de campo registra três eliminações (outs). Um turno de ataque para cada time, começando pelo visitante, constitui uma entrada (inning). Um jogo tem nove entradas e vence quem tiver mais corridas ao final. O beisebol não tem cronômetro e, embora a maioria dos jogos termine na nona entrada, se houver empate após nove, o jogo vai para entradas extras e continua indefinidamente até que um time esteja à frente ao final de uma entrada extra.

Para uma explicação detalhada das regras, confira as regras oficiais da Major League Baseball.

Limpando e preparando os dados

Como você viu acima, o DataFrame não tem cabeçalhos de coluna. Você pode adicioná-los passando uma lista de nomes para o atributo columns do pandas.

# Adding column names to dataframe
cols = ['yearID','lgID','teamID','franchID','divID','Rank','G','Ghome','W','L','DivWin','WCWin','LgWin','WSWin','R','AB','H','2B','3B','HR','BB','SO','SB','CS','HBP','SF','RA','ER','ERA','CG','SHO','SV','IPouts','HA','HRA','BBA','SOA','E','DP','FP','name','park','attendance','BPF','PPF','teamIDBR','teamIDlahman45','teamIDretro','franchID','franchName','active','NAassoc']
teams_df.columns = ____

# Print the first rows of `teams_df`
print(______________)

# Print the length of `teams_df`
print(len(________))

A função len() mostra quantas linhas você tem: 2.287 não é um volume tão grande assim, então torcemos para não haver muitos valores ausentes.

Antes de avaliar a qualidade dos dados, vamos eliminar colunas desnecessárias ou derivadas da variável alvo (Wins). Aqui, o conhecimento do domínio faz muita diferença. Não adianta saber muito de código ou estatística se você não entende os dados com que trabalha. Ser fã de beisebol a vida toda certamente me ajudou neste projeto.

# Dropping your unnecesary column variables.
drop_cols = ['lgID','franchID','divID','Rank','Ghome','L','DivWin','WCWin','LgWin','WSWin','SF','name','park','attendance','BPF','PPF','teamIDBR','teamIDlahman45','teamIDretro','franchID','franchName','active','NAassoc']

df = teams_df.drop(drop_cols, axis=1)

# Print out first rows of `df`
print(_________)

Como você leu acima, valores nulos afetam a qualidade dos dados e podem causar problemas em algoritmos de machine learning.

Por isso, vamos removê-los em seguida. Há várias formas de eliminar valores nulos, mas primeiro é uma boa ideia exibir a contagem de nulos por coluna para decidir a melhor estratégia.

# Print out null values of all columns of `df`
print(_________.sum(axis=0).tolist())

E aqui aparece um trade-off: você precisa de dados limpos, mas não tem tanto volume para desperdiçar. Duas colunas têm poucos nulos: são 110 em SO (strikeouts) e 22 em DP (double plays). Outras duas têm muitos nulos: 419 em CS (pego roubando) e 1777 em HBP (rebatedor atingido por arremesso).

Se você eliminar as linhas com poucos nulos, perde pouco mais de 5% dos dados. Como estamos prevendo vitórias, corridas anotadas e corridas sofridas são altamente correlacionadas com o alvo. Queremos esses dados bem precisos.

Strikeouts (SO) e double plays (DP) não são tão críticos.

É melhor manter as linhas e preencher os nulos com a mediana de cada coluna usando fillna(). Já CS e HBP também não são tão importantes e têm nulos demais; o melhor é remover essas colunas.

# Eliminating columns with null values
df = df.drop(['CS','HBP'], axis=1)

# Filling null values
df['SO'] = df['SO'].fillna(df['SO'].median())
df['DP'] = df['DP'].fillna(df['DP'].median())

# Print out null values of all columns of `df`
print(df.isnull().sum(axis=0).tolist())

Explorando e visualizando os dados

Agora que você limpou os dados, dá para explorar um pouco. Algumas visualizações simples já ajudam a entender melhor o conjunto. O matplotlib é uma ótima biblioteca para visualização.

Importe matplotlib.pyplot e renomeie como plt para agilizar. Se você estiver em um notebook Jupyter, use o magic %matplotlib inline.

Comece plotando um histograma da coluna alvo para ver a distribuição de vitórias.

# import the pyplot module from matplotlibimport matplotlib.pyplot as plt# matplotlib plots inline  %matplotlib inline# Plotting distribution of winsplt.hist(df['W'])plt.xlabel('Wins')plt.title('Distribution of Wins')plt.show()

distribution graph

Observe que, se você não estiver usando um Jupyter notebook, precisa usar plt.show() para exibir os gráficos.

Imprima a média de vitórias (W) por ano. Você pode usar o método mean().

print(df['_'].______)

Ao explorar, pode ser útil criar faixas (bins) para a coluna alvo, mas não inclua nenhuma feature gerada a partir da coluna alvo quando for treinar o modelo. Incluir no treino uma coluna de rótulos derivada do alvo seria como dar as respostas da prova para o modelo.

Para criar seus rótulos de vitórias, crie uma função assign_win_bins que recebe um inteiro (vitórias) e retorna um inteiro de 1 a 5 conforme o valor.

Depois, crie a coluna win_bins aplicando apply() sobre a coluna de vitórias e passando a função assign_win_bins().

# Creating bins for the win column
def assign_win_bins(_):
    if W < 50:
        return 1
    if W >= 50 and W <= 69:
        return 2
    if W >= 70 and W <= 89:
        return 3
    if W >= 90 and W <= 109:
        return 4
    if W >= 110:
        return 5
        
# Apply `assign_win_bins` to `df['W']`    
df['win_bins'] = df['_'].apply(______________)

Agora vamos fazer um gráfico de dispersão com o ano no eixo x e vitórias no eixo y, colorindo pela coluna win_bins.

# Plotting scatter graph of Year vs. Winsplt.scatter(df['yearID'], df['W'], c=df['win_bins'])plt.title('Wins Scatter Plot')plt.xlabel('Year')plt.ylabel('Wins')plt.show()

scikit-learn project

Como dá para ver no gráfico, há poucas temporadas antes de 1900 e o jogo era bem diferente. Por isso, faz sentido eliminar essas linhas do conjunto.

# Filter for rows where 'yearID' is greater than 1900
df = df[___________________]

Ao lidar com dados contínuos e criar modelos lineares, valores inteiros como o ano podem trazer ruído. É improvável que “1950” tenha um relacionamento linear simples com o resto dos dados como o modelo inferiria.

Você pode evitar isso criando novas variáveis que rotulem os dados com base no yearID.

Quem acompanha beisebol sabe que, conforme a MLB evoluiu, surgiram “eras” em que a média de corridas por jogo aumentou ou diminuiu bastante. A “dead ball era” do início dos anos 1900 foi de poucos pontos; a “steroid era” na virada do século XXI teve muitas corridas.

Vamos fazer um gráfico que indique o nível de pontuação em cada ano.

Comece criando os dicionários runs_per_year e games_per_year. Percorra o dataframe com iterrows(). Preencha runs_per_year com anos como chaves e o total de corridas anotadas no ano. Preencha games_per_year com anos e o total de jogos disputados naquele ano.

# Create runs per year and games per year dictionaries
runs_per_year = {}
games_per_year = {}

for i, row in df.iterrows():
    year = row['yearID']
    runs = row['R']
    games = row['G']
    if year in runs_per_year:
        runs_per_year[year] = runs_per_year[year] + runs
        games_per_year[year] = games_per_year[year] + games
    else:
        runs_per_year[year] = runs
        games_per_year[year] = games
        
print(runs_per_year)
print(games_per_year)

Depois, crie o dicionário mlb_runs_per_game. Percorra games_per_year com items(). Preencha mlb_runs_per_game com anos como chaves e o número de corridas por jogo (na liga toda) como valor.

# Create MLB runs per game (per year) dictionary
mlb_runs_per_game = {}
for k, v in games_per_year.items():
    year = k
    games = v
    runs = runs_per_year[year]
    mlb_runs_per_game[year] = runs / games
    
print(mlb_runs_per_game)

Por fim, crie o gráfico a partir de mlb_runs_per_game, com anos no eixo x e corridas por jogo no eixo y.

# Create lists from mlb_runs_per_game dictionarylists = sorted(mlb_runs_per_game.items())x, y = zip(*lists)# Create line plot of Year vs. MLB runs per Gameplt.plot(x, y)plt.title('MLB Yearly Runs per Game')plt.xlabel('Year')plt.ylabel('MLB Runs per Game')plt.show()

baseball analytics scikit-learn

Adicionando novas features

Agora que você entendeu melhor as tendências de pontuação, pode criar novas variáveis que indiquem a “era” à qual cada linha pertence com base no yearID. Vamos seguir a mesma lógica da criação de win_bins.

Desta vez, vamos criar colunas dummies, uma por era. Você pode usar get_dummies().

# Creating "year_label" column, which will give your algorithm information about how certain years are related 
# (Dead ball eras, Live ball/Steroid Eras)

def assign_label(____):
    if year < 1920:
        return 1
    elif year >= 1920 and year <= 1941:
        return 2
    elif year >= 1942 and year <= 1945:
        return 3
    elif year >= 1946 and year <= 1962:
        return 4
    elif year >= 1963 and year <= 1976:
        return 5
    elif year >= 1977 and year <= 1992:
        return 6
    elif year >= 1993 and year <= 2009:
        return 7
    elif year >= 2001:
        return 8
        
# Add `year_label` column to `df`    
df['year_label'] = df['yearID'].apply(___________)

dummy_df = pd.get_dummies(df['year_label'], prefix='era')

# Concatenate `df` and `dummy_df`
df = pd.concat([df, dummy_df], axis=1)

print(df.head())

Como você já calculou as corridas por jogo da MLB por ano, adicione essa informação ao conjunto.

# Create column for MLB runs per game from the mlb_runs_per_game dictionary
def assign_mlb_rpg(year):
    return mlb_runs_per_game[year]

df['mlb_rpg'] = df['yearID'].apply(assign_mlb_rpg)

Agora, converta os anos em décadas criando colunas dummies por década. Depois, remova as colunas que não precisa mais.

# Convert years into decade bins and creating dummy variables
def assign_decade(year):
    if year < 1920:
        return 1910
    elif year >= 1920 and year <= 1929:
        return 1920
    elif year >= 1930 and year <= 1939:
        return 1930
    elif year >= 1940 and year <= 1949:
        return 1940
    elif year >= 1950 and year <= 1959:
        return 1950
    elif year >= 1960 and year <= 1969:
        return 1960
    elif year >= 1970 and year <= 1979:
        return 1970
    elif year >= 1980 and year <= 1989:
        return 1980
    elif year >= 1990 and year <= 1999:
        return 1990
    elif year >= 2000 and year <= 2009:
        return 2000
    elif year >= 2010:
        return 2010
    
df['decade_label'] = df['yearID'].apply(assign_decade)
decade_df = pd.get_dummies(df['decade_label'], prefix='decade')
df = pd.concat([df, decade_df], axis=1)

# Drop unnecessary columns
df = df.drop(['yearID','year_label','decade_label'], axis=1)

Em beisebol, o que decide é quantas corridas você marca e quantas permite. Você pode aumentar bastante a acurácia criando colunas que são razões de outras colunas. Corridas por jogo e corridas sofridas por jogo serão ótimas features para adicionar.

Com o Pandas, isso é simples: crie uma nova coluna dividindo R por G para obter R_per_game.

# Create new features for Runs per Game and Runs Allowed per Game
df['R_per_game'] = df['R'] / df['G']
df['RA_per_game'] = df['RA'] / df['G']

Agora veja como essas duas novas variáveis se relacionam com a coluna de vitórias fazendo dois gráficos de dispersão. Coloque corridas por jogo no eixo x de um gráfico e corridas sofridas por jogo no eixo x do outro. Em ambos, use W no eixo y.

# Create scatter plots for runs per game vs. wins and runs allowed per game vs. winsfig = plt.figure(figsize=(12, 6))ax1 = fig.add_subplot(1,2,1)ax2 = fig.add_subplot(1,2,2)ax1.scatter(df['R_per_game'], df['W'], c='blue')ax1.set_title('Runs per Game vs. Wins')ax1.set_ylabel('Wins')ax1.set_xlabel('Runs per Game')ax2.scatter(df['RA_per_game'], df['W'], c='red')ax2.set_title('Runs Allowed per Game vs. Wins')ax2.set_xlabel('Runs Allowed per Game')plt.show()

clusters

Antes de partir para modelos de machine learning, é útil ver como cada variável se correlaciona com o alvo. O Pandas facilita isso com corr().

df.corr()['W']

Outra feature que você pode adicionar são rótulos derivados de um algoritmo de clusterização K-means do sklearn. K-means é um algoritmo simples que particiona os dados em k centróides indicados. Cada ponto é atribuído ao cluster cujo centróide tem a menor distância euclidiana a ele.

Saiba mais sobre K-means aqui.

Primeiro, crie um DataFrame sem a variável alvo:

attributes = ['G','R','AB','H','2B','3B','HR','BB','SO','SB','RA','ER','ERA','CG',
'SHO','SV','IPouts','HA','HRA','BBA','SOA','E','DP','FP','era_1','era_2','era_3','era_4','era_5','era_6','era_7','era_8','decade_1910','decade_1920','decade_1930','decade_1940','decade_1950','decade_1960','decade_1970','decade_1980','decade_1990','decade_2000','decade_2010','R_per_game','RA_per_game','mlb_rpg']

data_attributes = df[attributes]

# Print the first rows of `df`
print(df.head())

Um ponto a definir antes de usar K-means é a quantidade de clusters. Você pode ter uma noção melhor usando a função silhouette_score() do sklearn. Ela retorna o coeficiente de silhueta médio. Quanto maior, melhor — a medida tende a cair conforme você aumenta o número de clusters.

# Import necessary modules from `sklearn` 
from sklearn.cluster import KMeans
from sklearn import metrics

# Create silhouette score dictionary
s_score_dict = {}
for i in range(2,11):
    km = KMeans(n_clusters=i, random_state=1)
    l = km.fit_predict(data_attributes)
    s_s = metrics.silhouette_score(data_attributes, l)
    s_score_dict[i] = [s_s]

# Print out `s_score_dict`
print(s_score_dict)

Agora você pode inicializar o modelo. Defina 6 clusters e random state como 1. Calcule as distâncias euclidianas de cada ponto com fit_transform() e visualize os clusters com um scatter plot.

# Create K-means model and determine euclidian distances for each data pointkmeans_model = KMeans(n_clusters=6, random_state=1)distances = kmeans_model.fit_transform(data_attributes)# Create scatter plot using labels from K-means model as colorlabels = kmeans_model.labels_plt.scatter(distances[:,0], distances[:,1], c=labels)plt.title('Kmeans Clusters')plt.show()

clusters

Agora adicione os rótulos dos clusters ao conjunto como uma nova coluna. Também inclua a string “labels” na lista attributes para uso posterior.

Antes de construir o modelo, separe os dados em treino e teste. Se você treinar e testar no mesmo conjunto, o modelo pode decorar os dados e superajustar, o que degrada o desempenho em dados novos.

Sem estresse: há várias formas de fazer cross-validation.

Aqui, vamos amostrar aleatoriamente 75% para train e usar os 25% restantes como test. Crie a lista numeric_cols com todas as colunas usadas no modelo. Depois, crie o DataFrame data a partir de df com essas colunas. Em seguida, gere os conjuntos train e test amostrando o DataFrame data.

# Create new DataFrame using only variables to be included in models
numeric_cols = ['G','R','AB','H','2B','3B','HR','BB','SO','SB','RA','ER','ERA','CG','SHO','SV','IPouts','HA','HRA','BBA','SOA','E','DP','FP','era_1','era_2','era_3','era_4','era_5','era_6','era_7','era_8','decade_1910','decade_1920','decade_1930','decade_1940','decade_1950','decade_1960','decade_1970','decade_1980','decade_1990','decade_2000','decade_2010','R_per_game','RA_per_game','mlb_rpg','labels','W']
data = df[numeric_cols]
print(data.head())

# Split data DataFrame into train and test sets
train = data.sample(frac=0.75, random_state=1)
test = data.loc[~data.index.isin(train.index)]

x_train = train[attributes]
y_train = train['W']
x_test = test[attributes]
y_test = test['W']

Escolhendo métrica de erro e modelo

A métrica que usaremos para avaliar o modelo é o Mean Absolute Error (MAE). Ela mede o quão próximas as previsões ficam dos resultados. Neste caso, indica o valor absoluto médio pelo qual sua previsão errou o alvo.

Ou seja, se em média suas previsões erram por 5 vitórias, sua métrica será 5.

O primeiro modelo será uma regressão linear. Você pode importar LinearRegression e mean_absolute_error de sklearn.linear_model e sklearn.metrics, respectivamente, criar o modelo lr, ajustar, prever e calcular o MAE.

# Import `LinearRegression` from `sklearn.linear_model`
from sklearn.linear_model import ___________________

# Import `mean_absolute_error` from `sklearn.metrics`
from sklearn.metrics import mean_absolute_error

# Create Linear Regression model, fit model, and make predictions
lr = LinearRegression(normalize=True)
lr.fit(x_train, y_train)
predictions = lr.predict(x_test)

# Determine mean absolute error
mae = mean_absolute_error(y_test, predictions)

# Print `mae`
print(___)

Se você recall from above, the average number of wins was about 79 wins. On average, the model is off by only 2.687 wins.

Now try a Ridge regression model. Import RidgeCV from sklearn.linear_model and create model rrm. The RidgeCV model allows you to set the alpha parameter, which is a complexity parameter that controls the amount of shrinkage (read more here). The model will use cross-validation to deterime which of the alpha parameters you provide is ideal.

Again, fit your model, make predictions and determine the mean absolute error.

# Import `RidgeCV` from `sklearn.linear_model`
from sklearn.linear_model import _______

# Create Ridge Linear Regression model, fit model, and make predictions
rrm = RidgeCV(alphas=(0.01, 0.1, 1.0, 10.0), normalize=True)
rrm.fit(x_train, y_train)
predictions_rrm = rrm.predict(x_test)

# Determine mean absolute error
mae_rrm = mean_absolute_error(y_test, predictions_rrm)
print(________)

This model performed slightly better, and is off by 2.673 wins, on average.

Sports Analytics & Scikit-Learn

This concludes the first part of this tutorial series in which you have seen how you can use scikit-Learn to analyze sports data. You imported the data from an SQLite database, cleaned it up, explored aspects of it visually, and engineered several new features. You learned how to create a K-means clustering model, a couple different Linear Regression models, and how to test your predictions with the mean absolute error metric.

In the second part, you’ll see how to use classification models to predict which players make it into the MLB Hall of Fame.

Temas

Relacionado

Tutorial

Tutorial de regressão linear no R