Modelo de Classificação de Estilo Musical

Recentemente, fiz um dos projetos disponíveis na plataforma do Datacamp – tenho uma assinatura anual que fiz no início da pandemia. A ideia era criar um modelo que classificasse o estilo musical com base em algumas características da música. Este post é dedicado a explicar um pouco do código e dar uma base com outros posts para que você consiga acompanhar o conteúdo dele. Enfim, bora aprender um modelo legal de classificação de estilo musical em Python!

INTRODUÇÃO

Primeiro, baixe os datasets que serão utilizados no projeto. Eles podem ser encontrados aqui e aqui. Note que temos dados estruturados e um problema de aprendizado supervisionado. I.e, os dados possuem rótulos – sabemos qual música é hip-hop e qual é rock, e queremos fazer um modelo que consiga prever com dados novos qual estilo da música.

O notebook completoque fiz com a resolução é autoexplicativo e está neste link aqui. Caso não veja necessidade das instruções neste post, você pode ir direto ao notebook.

Iniciamos a resolução importando os dados:

# Importa as bibliotecas
import pandas as pd

# lê o dataset com as musicas
tracks = pd.read_csv('fma-rock-vs-hiphop.csv')

# lê o dataset com as características de cada estilo
echonest_metrics = pd.read_json('echonest-metrics.json',precise_float=True)

# une os dois datasets
echo_tracks = pd.merge(echonest_metrics,
tracks[['track_id','genre_top']],
on='track_id')

# verifica como ficou o dataset resultante da união
echo_tracks.head()

DIVISÃO DE TESTE E TREINO

Agora, temos um dataset preparado para rodar qualquer modelo das principais bibliotecas em Python. Porém, antes de rodar o modelo, precisamos separar os dados em TREINO e TESTE. Isso porque queremos construir o modelo com uma parte dos nossos dados e testá-lo em outra parte. Isso é algo feito de praxe para se evitar o overfitting – que você pode aprender lendo os posts Overfitting e Cross Validation e Explicando overfitting com uma anedota.

ATENÇÃO: Esta outra parte, chamada de treino, é uma simulação do mundo real. Sendo assim, seriam dados novos, que não utilizamos no modelo. Por esse motivo, TODO pré-processamento deve ser feito após a divisão em treino e teste. Quer preencher os valores nulos? Depois da divisão. Quer aplicar um PCA? Depois da divisão. Veja mais sobre o assunto no post Data Leakage, o erro que até os grandes cometem.

Enfim, vamos ao código:

# variáveis utilizaremos para fazer a predição
features = echo_tracks.drop(columns=['genre_top', 'track_id'], axis=1)

# variável que queremos prever
labels = echo_tracks['genre_top']


# divisão em treino e teste
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(features, labels, test_size=0.3, random_state=0)
PIPELINES E RANDOM FOREST

Lembra que eu falei que devemos fazer todo pré-processamento depois da divisão? Então, existe um jeito fácil de se evitar isso: utilizar PIPELINES.

Nunca ouviu falar disso? Então veja os posts Introdução aos Pipelines no Scikit-Learn e Pipeline usando Scikit-Learn: Exemplos Práticos. No fim das contas, nada mais é do que reunir diversas etapas do projeto em conjunto, enquanto definimos os parâmetros.

Voltando ao nosso modelo, vamos aplicar um pipeline que imputa missing utilizando o valor da mediana e aplica um StandardScale() – uma forma de padronização -, algo que não é tão necessário para árvores, mas que deixei porque achei legal mostrar a transformação para o pessoal pegar legal como criar um pipeline:

# MODELO COM PIPELINE
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler, MinMaxScaler
from sklearn.compose import ColumnTransformer
from sklearn.decomposition import PCA
from sklearn.tree import DecisionTreeClassifier
from sklearn.impute import SimpleImputer

# Criando o Preprocessamento no Pipeline
numeric_transformer = Pipeline(steps=[
('imputer', SimpleImputer(strategy='median')),
('scaler', StandardScaler())])

preprocessor = ColumnTransformer(
transformers=[
('num', numeric_transformer, X_train.columns)])

# Pipeline com random forest
from sklearn.ensemble import RandomForestClassifier
rf = Pipeline(steps=[('preprocessor', preprocessor),
('classifier', RandomForestClassifier())])

# treina o modelo
rf.fit(X_train, y_train)
BREVE PARÊNTESES 1: ÁRVORE DE DECISÃO E RANDOM FOREST

Primeiro, é preciso saber que uma Random Forest é um algoritmo que combina diversas árvores de decisão. Sendo assim, você precisa começar entendendo o que é uma árvore de decisão. Abaixo, um exemplo retirado do post Árvore de Decisão (bem mais completo e recomendado):

Agora, você já está apto para entender uma Random Forest, ou, traduzido diretamente, uma Floresta Aleatória. Abaixo, um exemplo que acredito já deixar bem claro como funciona o algoritmo:

Fonte: https://medium.com/analytics-vidhya/mathematics-behind-random-forest-and-xgboost-ea8596657275
BREVE PARÊNTESES 2: FUNCIONAMENTO DA RANDOM FOREST

De forma intuitiva, o algoritmo funciona da seguinte maneira:

  • Coletamos uma amostra do dataset original, podendo haver repetições;
  • Começamos a construção de uma árvore de decisão a partir do dataset gerado pela amostragem acima;
  • Na hora de selecionar qual a feature do primeiro nó (=root node), consideramos um subconjunto das features disponíveis no dataset;
  • Na hora de escolher a feature do nó seguinte, também selecionamos a partir de um subconjunto das variáveis que restaram;
  • Seguimos fazendo isso até finalizar a árvore;
  • Note: o subconjunto de features pode ter tamanho 2, 3, …, n. É preciso escolher o que gera melhor desempenho;
  • Pegue o processo e repita centenas de vezes. I.e., construímos centenas de árvores de decisão;
  • Como usamos as árvores?
  • Pegue o primeiro data point (o primeiro “indivíduo” do nosso conjunto de dados) e rode ele na primeira árvore. Suponha que a gente esteja construindo um modelo para previsão de bom ou mau pagador. A previsão da primeira árvore é que o primeiro indivíduo é um mau pagador. Aí, rodamos para a segunda árvore. Ela também diz que ele será um bom pagador. Repetimos isso para todas árvores. Vemos qual opção recebeu mais votos. Como a maioria das árvores teve como previsão que o indivíduo seria um bom pagador, nossa previsão é que ele será um bom pagador.

Termo importante:

  • Ensemble learning: Uso de múltiplos algoritmos para obter melhor desempenho preditivo.

Para uma explicação mais completa, você pode consultar este vídeo (forma intuitiva) ou este material (visão matemática).

RETORNANDO AO PIPELINE

Veja que criamos nosso modelo com os dados de treino. Na sequência, é preciso avaliar sua capacidade de predição em novos dados. Sendo assim, utilizamos os dados de treino. O que faremos é: (I) realizar predições para os dados de teste utilizando a função rf.predict(); (II) verificar a acurácia do modelo (acertos/total); (III) a acurácia pode nos enganar (principalmente para dados desbalanceados), então vamos verificar também outras métricas com o classification report e a matriz de confusão:

# predicoes
y_pred = rf.predict(X_test)

# Acurácia do modelo
from sklearn.metrics import accuracy_score
print("Random Forest Accuracy: \n",accuracy_score(y_test, y_pred))

# Classification report
from sklearn.metrics import classification_report
class_rep_rf = classification_report(y_test, y_pred)

print("Random Forest: \n", class_rep_rf)

# Matriz de Confusao
from sklearn.metrics import confusion_matrix
confusion_matrix(y_test, y_pred)

COMO LIDAR COM DADOS DESBALANCEADOS?

Veja que o modelo foi bem. Entretanto, ele está errando um pouco mais quando tenta predizer o estilo hip-hop. Isso ocorre porque os dados estão desbalanceados, não temos tanta ocorrência de hip-hop quanto temos de rock. Se você parar para refletir, vai ver que isso que está acontecendo aqui é bem comum em outros setores. Em fraude, por exemplo, temos bem menos ocorrência de fraudadores do que de não-fraudadores. Como lidar com isso?

Bom, há várias alternativas aqui. Vamos tratar hoje de CLASS WEIGHT. Quero abordá-la por ser algo simples, que já é parâmetro nos modelos do scikit-learn e que, curiosamente, não aparece nos cursos com tanta frequência – embora existam vários posts falando sobre o tema no Kaggle e Medium.

Bom, como o próprio nome diz, estamos falando de dar pesos a classes diferentes. Imagine que seu modelo trata de fraudadores. Neste caso, a classe 0 é de não-fraudador e a classe 1 é de fraudador. A classe 1 é bem menos presente nos dados, já que temos bem poucos fraudadores. Pode ser que a diferença seja de algo como 100 pra 1. Neste caso, o class weight serve para dar pesos de forma que essa classe ganhe força no treino.

CERTO, MAS COMO IMPLEMENTAR E QUAL PESO ESCOLHER?

A implementação ocorre na forma de parâmetro do modelo e como um dict do Python. Imagine, por exemplo, que você queira dar peso 100 para a classe 1 e peso 1 para a classe 0, você faria algo do tipo:

rf = RandomForestClassifier(class_weight={0:1, 1:100}

Simples assim. Outro truque ótimo é utilizar o parâmetro igual a “balanced”, isso por si só é capaz de resolver alguns problemas:

rf = RandomForestClassifier(class_weight={"balanced"}

Tá, mas e se nada disso der certo?

Bom, então você pode testar outros valores. Vamos voltar ao nosso exemplo de classificar estilo musical. O que eu fiz foi testar diferentes valores para as classes, mas buscando algo próximo da presença delas. A gente tem 4 vezes mais rock do que hip-hop nos nossos dados, então podemos testar algo como hip-hop recebendo peso 4 e rock peso 1. Melhor ainda, e se a gente for testando vários valores entre 2 e 8 para hip-hop, mantendo rock em 1?

Foi o que eu fiz com a seguinte função:

from sklearn.metrics import precision_recall_fscore_support

def trying_class_weight(class_weight):
""" FUNÇÃO PARA TESTAR O RESULTADO DE DIFERENTES PESOS PARA AS CLASSES """
# Criando o Preprocessamento no Pipeline
numeric_transformer = Pipeline(
steps=[
("imputer", SimpleImputer(strategy="median")),
("scaler", StandardScaler()),
]
)

preprocessor = ColumnTransformer(
transformers=[("num", numeric_transformer, X_train.columns)]
)

# Pipeline com random forest
rf = Pipeline(
steps=[
("preprocessor", preprocessor),
("classifier", RandomForestClassifier(class_weight=class_weight)),
]
)

# Fita o modelo
rf.fit(X_train, y_train)

# predicoes
y_pred = rf.predict(X_test)

# Matriz de Confusao
cm = confusion_matrix(y_test, y_pred)
accuracy = (cm[0, 0] + cm[1, 1]) / len(y_test)

precision, recall, fscore, support = score(y_test, y_pred, average="macro")

return accuracy, fscore

Veja que a função testa vários valores e retorna a acurácia e f1-score. Honestamente, não sei se foi a melhor abordagem, então aceito sugestões de mudanças. De qualquer forma, ela resolve o problema.

Bom, agora que a função está criada, eu fiz duas coisas: (1) testei os resultados para “balanced”, None e 1:4; (2) fiz um loop para testar vários valores entre 2 e 8:

# Primeiro, algumas tentativas padrão:
accuracy, fscore = trying_class_weight("balanced")
print("For balanced:")
print(accuracy)
print(fscore)

accuracy, fscore = trying_class_weight(None)
print("For None:")
print(accuracy)
print(fscore)

accuracy, fscore = trying_class_weight({"Hip-Hop": 1, "Rock": 4})
print("For 1:4")
print(accuracy)
print(fscore)

Eu sei que o print ficou meio feião, então você pode corrigir aí com o {.2f}.format(). Ah, e eu esqueci que além do valor numérico e do “balanced”, class_weight também aceita balanced_subsample como parâmetro:

accuracy, fscore = trying_class_weight("balanced_subsample")
print("For balanced subsample:")
print(accuracy)
print(fscore)

Novamente, eu sei que o output está feio e eu até teria vergonha disso, não fosse o fato que eu estou escrevendo este post enorme em minhas férias rs. Então eu acredito que vocês podem relevar essa formatação né?

Agora, a parte (2):

# Vamos testando alguns números entre 2 e 8 para rock (100 tentativas)
weights = []
accuracies = []
f1scores = []

# trying_class_weight é uma função que aplica o peso e ve acuracia e f1-score
for i in np.linspace(2, 8, 100):
accuracy, fscore = trying_class_weight({"Hip-Hop": 1, "Rock": i})
weights.append(i)
accuracies.append(accuracy)
f1scores.append(fscore) # será que dá para criar um dict aqui?

print(max(accuracies))
print(weights[accuracies.index(max(accuracies))])

Opa, para um peso de 2.5454… parece que a acurácia deu uma melhoradinha legal, quase chegamos em 93%. Vamos ver o reporte completo:

Uai, mas a acurácia mudou?

Sim, lembre-se de como funciona a random forest. Ela vai sortear features para ir treinando em cada árvore. Então toda vez que você rodar, vai ter um resultado diferente. O que o animal aqui tinha que ter feito é ter colocado uma seed nela. Vacilo total!

LOOP ALTERNATIVO

Vasculhando um pouco a internet, encontrei outro loop interessante para testar pesos diferentes para as classes:

# Vamos de tentar isso aqui que achei pela internet e ainda não entendi bem a lógica matemática 
weights = []
accuracies = []
f1scores = []
for i in np.linspace(0.0, 1, 100):
accuracy, fscore = trying_class_weight({"Hip-Hop": i, "Rock": 1 - i})
    weights.append(i)
    accuracies.append(accuracy)
    f1scores.append(fscore)

print(max(accuracies))
print(weights[accuracies.index(max(accuracies))]

Este vou deixar somente para fins de curiosidade. Eu não costumo aplicar e, honestamente, não entendi a ideia por trás. Fiquem à vontade para comentar aí o que acharam. Como costumo dizer, estou aqui aprendendo com vocês. Eu vou vendo e jogando aqui!

EXTRA: TESTANDO DIFERENTES MODELOS COM PIPELINE

Um último ponto aqui, veja como o Pipeline é maneiro para testar diferentes abordagens. Abaixo, testamos diversos modelos com os parâmetros default só para ver como o classificador se comporta para diferentes algoritmos:

from sklearn.metrics import accuracy_score, log_loss
from sklearn.neighbors import KNeighborsClassifier
from sklearn.svm import SVC, LinearSVC, NuSVC
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import (
RandomForestClassifier,
AdaBoostClassifier,
GradientBoostingClassifier,
)
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis
from sklearn.discriminant_analysis import QuadraticDiscriminantAnalysis
from sklearn.linear_model import LogisticRegression

classifiers = [
KNeighborsClassifier(3),
SVC(kernel="rbf", C=0.025, probability=True),
SVC(),
LogisticRegression(),
DecisionTreeClassifier(),
RandomForestClassifier(),
AdaBoostClassifier(),
GradientBoostingClassifier(),
]

for classifier in classifiers:
pipe = Pipeline(steps=[("preprocessor", preprocessor), ("classifier", classifier)])
pipe.fit(X_train, y_train)
print(classifier)
print("model score: %.3f" % pipe.score(X_test, y_test))
CONSIDERAÇÕES FINAIS

Há outras técnicas de undersampling/oversampling, como SMOTE. Você poderia também usar o random sampling para dar peso a indivíduos do dataset. De qualquer forma, como nosso modelo tem acurácia de ~93% e o f1-score de Hip-Hop chegou a 0.79. Creio que a gente pode considerar essa missão, de criar um classificador para estilo musical, como cumprida. Num futuro próximo, quem sabe, podemos testar essas outras alternativas. Aliás, eu com certeza vou testar. Por isso, acompanhem o blog e o @EstatSite no Twitter para ver como isso irá evoluir.

Se quiser ver mais posts de modelos de machine learning, veja os posts XGBoost em PythonRegressão Logística em PythonClassificador Random Forest em Python e Regressão Linear no Python.

Encontrou algum erro? Pode comentar aí. Eu estou aprendendo, assim como vocês. Cometo erros como todo mundo, zero medo de admitir isso.

E aí? Gostou do conteúdo? Se inscreva para receber todas as novidades. Deixe seu e-mail em INSCREVA-SE na barra à direita, logo abaixo de pesquisar. E, por favor, não deixe de comentar, dar seu feedback e, principalmente, compartilhar com seus amigos. De verdade, isso faz toda a diferença. Além disso, você também pode acompanhar mais do meu trabalho seguindo a conta de Twitter @EstatSite ou por alguma das redes que você encontra em Sobre o Estatsite / Contato, como meu canal de Youtube Canal do Yukio. E se você gosta de tecnologia, escute o Pitacotech!

Bons estudos!

Deixe um comentário

O seu endereço de email não será publicado. Campos obrigatórios marcados com *