Como avaliar seu modelo de classificação

Sete passos para fazer uma boa avaliação do seu modelo de classificação

Marcelo Randolfo
Data Hackers
23 min readFeb 16, 2020

--

Photo by Helloquence on Unsplash

O procedimento mais comum após treinar um modelo de Machine Learning é testá-lo para que saibamos se o modelo é capaz de generalizar bem para novos dados. Se o modelo é capaz de prever muito bem os dados de treino, mas é ruim ao prever dados de teste, temos um problema de overfitting. Mas a questão é:

como saber se seu modelo é bom ou ruim?

Temos algumas métricas conhecidadas, como acurácia, precisão, área sob a curva ROC, mas o que elas realmente querem dizer? Uma acurácia de 90% é algo bom? Devemos medir utilizando os dados de treino ou dados de teste? O que é validação cruzada?

Bom, este artigo vai tentar responder de forma simples todas essas questões.

Como todo mundo já está cansado dos dados do Titanic ou da Iris, vou utilizar como exemplo os dados do Machine Learning Repository sobre doenças cardíacas. O processamento e análise desses dados podem ser vistos aqui e aqui. Além disso, todos os códigos feitos neste artigo podem ser acessados aqui.

Erros mais comuns

Suponha que você está treinando um modelo de Machine Learning que tenta prever se um paciente tem ou não uma doença cardíaca, a partir de informações como idade, sexo, pressão arterial, entre outras coisas. Após todo o processo de data mining, você chega a seu DataFrame final e separa os dados em treino e teste.

from sklearn.model_selection import train_test_splitX = df_model.drop('num', axis = 1)
y = df_model['num']
SEED = 42
np.random.seed(SEED)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size = 0.20)
print('O dataset de treino possui {} pacientes e o de
treino {} pacientes.'
.format(X_train.shape[0], X_test.shape[0]))
Out >>> O dataset de treino possui 229 pacientes e o de treino 58
pacientes.

Na sua opinião, o modelo mais adequado para fazer esse tipo de previsão é o de Árvore de Decisão com um max_depth igual a 2 (o objetivo aqui não é discutir a escolha do modelo ou dos hiperparâmetros, essa situação é só ilustrativa).

from sklearn.tree import DecisionTreeClassifier
from sklearn.metrics import accuracy_score
np.random.seed(SEED)model = DecisionTreeClassifier(max_depth=2)
model.fit(X_train, y_train)
predict = model.predict(X_test)
accuracy = accuracy_score(y_test, predict) * 100
rint ("A acurácia foi de {:.2f}%.".format(accuracy))Out >>> A acurácia foi de 79.31%.

Você treina seu modelo com os dados de treino, testa a acurácia do modelo com os dados de teste e obtém uma acurácia de 79,31%. Aproximadamente, a cada cem casos, seu modelo foi capaz de prever corretamente setenta e nove vezes se uma pessoa tem ou não doença cardíaca. Digamos que o procedimento atual de detecção de doença cardíaca tem uma acurácia de 80%, nesse caso seu modelo está pior que o procedimento que já é utilizado. Você tenta melhorar seu modelo utilizando agora um max_depth igual a três.

np.random.seed(SEED)model = DecisionTreeClassifier(max_depth=3)
model.fit(X_train, y_train)
predict = model.predict(X_test)
accuracy = accuracy_score(y_test, predict) * 100
print ("A acurácia foi de {:.2f}%.".format(accuracy))
Out >>> A acurácia foi de 82.76%.

Novamente treina seu modelo com os mesmos dados de treino, testa com os mesmos dados de treino e obtém agora uma acurácia de 82,76%! Ótimo, seu modelo agora é melhor que o procedimento atual, simplesmente ajustando um hiperparâmetro. Você então recebe informações novas sobre dez pacientes e utiliza seu modelo para fazer as previsões. Espera-se que seu modelo acerte pelo menos oito casos, que é o que o procedimento padrão consegue prever.

X_news = df_aux.drop('num', axis = 1)
y_news = df_aux['num']
predict = model.predict(X_news)
accuracy = accuracy_score(y_news, predict) * 100
print ("A acurácia foi de {:.2f}%.".format(accuracy))
Out >>> A acurácia foi de 60.00%.

Mas os resultados mostram que seu modelo previu corretamente somente seis casos. Bem pior que o resultado que você obteve antes. Essa situação ilustra três erros: tentar melhorar o modelo utilizando os resultados dos dados de teste, não considerar a dispersão da acurácia e ter somente uma métrica de avaliação.

O problema de overfitting ocorre quando seu modelo não generaliza bem para novos dados, ele é bom somente nos dados de treino. Quando você obtém o resultado da acurácia do modelo, muda algum hiperparâmetro e testa novamente com os mesmos dados, você está fazendo um overfitting nos seus dados de teste! Seu modelo será bom para os dados de treino e para aqueles dados de teste específicos. Quando você utilizar novos dados, seu modelo não vai ser capaz de generalizar.

Além disso, ao separar os dados em treino e teste, a função train_test_split escolheu aleatoriamente quais dados ficariam em qual grupo. Se ao invés de usar a seed 42 você usasse qualquer outro número, você teria outras informações no grupo de treino e teste e a acurácia do modelo não seria 82,76%. Esse valor não é fixo, treinando e testando o modelo com outros dados você iria obter outros valores. Nesse caso você teria que treinar e testar várias vezes com diferentes informações para ter um intervalo de quão bom seu modelo pode ser e quão ruim também.

A acurácia nos diz quantos acertos seu modelo teve, mas quantos destes acertos foram de pessoas com doença cardíaca? Se seu objetivo é criar um modelo que prevê se uma pessoa tem doença cardíaca mas ele só consegue prever quando uma pessoa não tem, ele não é muito útil. Pra isso temos que usar outras métricas de avaliação.

Para não cometer esses erros e avaliar seu modelo corretamente, siga esses sete passos.

1º passo: não use seu dataset de treino!

Assim que você separar seus dados em treino e teste, esqueça que você tem esses dados de teste! “Mas espera, como vou saber se meu modelo é bom? Como vou construir o intervalo que você acabou de falar?”. Calma, esse é o próximo passo.

# divisao entre treino e teste do dataframe original
from sklearn.model_selection import train_test_split
X = df.drop('num', axis = 1)
y = df['num']SEED = 23
np.random.seed(SEED)
X_train, X_test, y_train, y_test = train_test_split
(X, y, test_size = 0.20, stratify = y)
print('O dataset de treino possui {} pacientes e o de
treino {} pacientes.'
.format(X_train.shape[0], X_test.shape[0]))
Out >>> O dataset de treino possui 237 pacientes e o de treino 60
pacientes.

2º passo: use a validação cruzada!

A validação cruzada nos ajuda a não cometer o erro de usar os dados de teste para avaliar e tentar melhorar o modelo, além de ajudar a criar um intervalo de quão bom o modelo é.

Nessa imagem podemos ver como o validação cruzada funciona.

Processo de treino e teste da validação cruzada por folds.
Scikit-learn: Cross Validation

Nossos dados de treino são divididos em, por exemplo, 5 partes (folds). O primeiro modelo é treinado utilizando os folds 2, 3, 4 e 5 e o teste é feito sob o fold 1, o segundo modelo é treinado utilizando os folds 1, 3, 4 e 5 e testado no fold 2, e assim sucessivamente.

Nesse caso, teremos cinco métricas diferentes, ou seja, vamos ter uma noção melhor de como nosso modelo pode se sair! E o melhor, sem usar os dados de teste!

Para fazer a validação cruzada vamos utilizar a função cross_val_score, que faz todo esse processo de forma automatizada.

from sklearn.model_selection import cross_val_scoreSEED = 42
np.random.seed(SEED)
model = DecisionTreeClassifier(max_depth=3)
results = cross_val_score(model, X_train,
y_train, cv = 5, scoring = 'accuracy')
def intervalo(results):
mean = results.mean()
dv = results.std()
print('Acurácia média: {:.2f}%'.format(mean*100))
print('Intervalo de acurácia: [{:.2f}% ~ {:.2f}%]'
.format((mean - 2*dv)*100, (mean + 2*dv)*100))
intervalo(results)Out >>> Acurácia média: 74.66%
Intervalo de acurácia: [67.38% ~ 81.95%]

Dividindo nossos dados em cinco folds e testando, temos que para os cinco modelos estimados, temos uma acurácia média de 74,66%, mas dentro de um intervalo de 67,38% até 81,95%. Em média, a cada 100 casos, espera-se que o modelo acerte sobre a situação de saúde do paciente 75 vezes. Mas esse resultado pode variar entre 68 até 82 casos.

O problema de usar a função cross_val_score desse jeito é que ela separa os dados em folds de forma direta. Por exemplo, se temos 100 dados, o primeiro fold terá os dados de 1 a 20, o segundo fold terá os dados de 21 a 40, até o quinto fold que terá os dados 81 a 100. Nesse caso, ficamos dependentes de como as informações estão dispostas.

Antes de dividir os dados nos folds é melhor misturarmos as informações, para eliminar qualquer influência da disposição dos dados. A função cross_val_score não faz isso, mas no parâmetro cv, ao invés de passar o número de folds, podemos passar uma função que separa os folds de outras maneiras. No caso, podemos usar a função KFold, que além de separar os dados em folds, faz essa "mistura" ao passar o parâmetro shuffle igual a True.

from sklearn.model_selection import KFoldnp.random.seed(SEED)
cv = KFold(n_splits = 5, shuffle = True)
model = DecisionTreeClassifier(max_depth=3)
results = cross_val_score(model, X_train, y_train, cv = cv)
intervalo(results)Out >>> Acurácia média: 72.57%
Intervalo de acurácia: [62.50% ~ 82.64%]

Fazendo o shuffle dos dados antes de dividir em folds não melhorou muito a acurácia do modelo, até porque nossos dados já estavam “misturados” quando dividimos os dados em treino e teste.

Além do KFold, temos a função StratifiedKFold, que garante que em todos os folds a proporção de informações de pacientes com e sem doença cardíaca será a mesma. Isso é importante pois pode ocorrer de algum fold não ter nenhuma informação de pacientes com doença cardíaca, o que prejudicaria o treino e o teste. Não é o caso aqui, mas o StratifiedKFold é ainda mais importante quando os dados estão muito desbalanceados, ou seja, quando uma categoria é muito mais comum que a outra.

from sklearn.model_selection import StratifiedKFoldnp.random.seed(SEED)
cv = StratifiedKFold(n_splits = 5, shuffle = True)
model = DecisionTreeClassifier(max_depth=3)
results = cross_val_score(model, X_train, y_train, cv = cv)
intervalo(results)Out >>> Acurácia média: 75.97%
Intervalo de acurácia: [71.26% ~ 80.67%]

Utilizando o StratifiedKFold temos que ao treinar e testar os modelos com os folds contendo a mesma proporção de informações de pacientes com e sem doença cardíaca, o intervalo inferior fica maior. Nesse caso, espera-se que o modelo seja capaz de acertar pelo menos 70% dos casos.

3º passo: hiperparâmetros e estimadores promissores

Além de possibilitar criar métricas mais confiáveis sobre a qualidade do modelo, a validação cruzada nos permite ainda testar diferentes hiperparâmetros e/ou estimadores. O objetivo aqui não é explorar os hiperparâmetros, ou como escolher um estimador, para isso temos este artigo sobre otimização de hiperparâmetros e este Cheat Sheet do Scikit-Learn sobre escolha de estimador.

Testando diferentes hiperparâmetros

max_depth = [3, 2, 4]
for item in max_depth:
np.random.seed(SEED)
cv = StratifiedKFold(n_splits = 5, shuffle = True)
model = DecisionTreeClassifier(max_depth=item)
results = cross_val_score(model, X_train, y_train, cv = cv,
scoring = 'accuracy')
mean = results.mean()
dv = results.std()
print('Acurácia média - Max Depth {}: {:.2f}%'
.format(item, mean*100))
print('Intervalo de acurácia -
Max Depth {}: [{:.2f}% ~ {:.2f}%]\n'
.format(item, (mean - 2*dv)*100, (mean + 2*dv)*100))
Out >>> Acurácia média - Max Depth 3: 75.97%
Intervalo de acurácia - Max Depth 3: [71.26% ~ 80.67%]
Acurácia média - Max Depth 2: 74.74%
Intervalo de acurácia - Max Depth 2: [61.41% ~ 88.08%]

Acurácia média - Max Depth 4: 73.44%
Intervalo de acurácia - Max Depth 4: [62.86% ~ 84.02%]

Pelos resultados temos que o Max Depth igual a 3 produz o melhor resultado. Os demais valores até possuem um intervalo superior maior, mas também há a possibilidade do modelo ter uma acurácia de aproximadamente 60%, além da acurácia média ser mais baixa.

Testando diferentes estimadores

from sklearn.svm import SVC
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
np.random.seed(SEED)
cv = StratifiedKFold(n_splits = 5, shuffle = True)
model = DecisionTreeClassifier(max_depth=3)
model_svc = SVC()
model_log = LogisticRegression(solver='liblinear')
model_rand = RandomForestClassifier(n_estimators=100)
models = [model, model_svc, model_log, model_rand]
name = ['Árvore de Decisão', 'SVC',
'Regressão Logística', 'Random Forest']
count = 0
for item in models:
np.random.seed(SEED)
results = cross_val_score(item, X_train, y_train, cv = cv,
scoring = 'accuracy')
mean = results.mean()
dv = results.std()
print('Acurácia média - Modelo {}: {:.2f}%'
.format(name[count], mean*100))
print('Intervalo de acurácia -
Modelo {}: [{:.2f}% ~ {:.2f}%]\n'
.format(name[count],
(mean - 2*dv)*100, (mean + 2*dv)*100))
count += 1
Out >>> Acurácia média - Modelo Árvore de Decisão: 75.97%
Intervalo de acurácia - Modelo Árvore de Decisão:
[71.26% ~ 80.67%]

Acurácia média - Modelo SVC: 67.52%
Intervalo de acurácia - Modelo SVC:
[56.79% ~ 78.25%]

Acurácia média - Modelo Regressão Logística: 84.85%
Intervalo de acurácia - Modelo Regressão Logística:
[74.00% ~ 95.70%]
Acurácia média - Modelo Random Forest: 83.15%
Intervalo de acurácia - Modelo Random Forest:
[72.41% ~ 93.89%]

Pelos resultados temos que o modelo de regressão logística produz os melhores resultados para a acurácia. Nesse caso, as demais análises serão feitas para esse modelo.

4º passo: outras métricas

A métrica utilizada até então foi a acurácia, que mede o tanto de previsões corretas em relação a todas as previsões. Para um conjunto de dados balanceados ela é uma boa medida de avaliação do modelo, mas em conjuntos nos quais a categoria alvo possui menos ocorrências a acurácia deve ser analisada com cuidado.

Por exemplo, digamos que seu conjunto de dados tenha 100 pessoas, 90 saudáveis e 10 com doença cardíaca. Se seu modelo tem uma acurácia de 90% pode ser que ele esteja acertando somente sobre as pessoas que não têm a doença. Simplesmente decidir de forma arbitrária que todo mundo está saudável vai ter o mesmo poder de previsão que seu modelo. Então, devemos analisar outras métricas além da acurácia ao avaliar um modelo de classificação.

Precisão

Podemos definir a precisão de um modelo de Machine Learning como a proporção de predições corretas de uma categoria em relação a todas as previsões feitas dessa categoria. As previsões corretas da categoria alvo são chamadas de Verdadeiros Positivos (true positive — TP), e as previsões incorretas para a categoria alvo são chamada de Falsos Positivos (false positive — FP). No nosso caso, um verdadeiro positivo é uma situção onde o modelo previu que o paciente tinha uma doença cardíaca e ele realmente tinha, enquando um falso positivo é uma situação onde o modelo previu que o paciente tinha doença cardíaca mas na verdade ele era saudável.

A fórmula da precisão para a categoria alvo é:

Precisão é igual ao número de casos de verdadeiros positivos dividido pela soma de verdadeiros positivos com falsos positivos
Fórmula: Precisão.

Também há a precisão da categoria que não é o alvo, categoria 0, no nosso caso pessoas saudáveis, mas ela não costuma ser usada. Assim como fizemos para a acurácia, podemos construir um intervalo para a precisão.

def intervalo_prec(results):
mean = results.mean()
dv = results.std()
print('Precisão média: {:.2f}%'.format(mean*100))
print('Intervalo de Precisão: [{:.2f}% ~ {:.2f}%]'
.format((mean - 2*dv)*100, (mean + 2*dv)*100))
np.random.seed(SEED)
cv = StratifiedKFold(n_splits = 5, shuffle = True)
model = LogisticRegression(solver='liblinear')
results = cross_val_score(model, X_train, y_train, cv = cv,
scoring = 'precision')
intervalo_prec(results)Out >>> Precisão média: 86.16%
Intervalo de Precisão: [74.66% ~ 97.65%]

Pelos resultados, nosso modelo tem uma precisão média de 86,16%, em um intervalo de 74,66% até 97,65%, ou seja, em todas as situações em que o modelo considerou o paciente como sendo da categoria alvo, ele esteve certo, em média, 86% vezes.

Assim como a acurácia é uma métrica que deve ser analisada com cuidado, a precisão também é. Digamos que eu te apresente um modelo com precisão de 100%. Todas as vezes que ele considerou um paciente como sendo da categoria alvo, ele esteva certo. Ótimo, não é? Talvez não. Pode ser que ele indicou somente duas pessoas como sendo da categoria alvo, e essas pessoas realmente eram. Mas tiveram várias outras que eram da categoria alvo e o modelo deixou passar. Essa situação nos leva a outra métrica, o recall.

Recall

A medida de recall de um modelo de Machine Learning é definido como a proporção de previsões corretas da categoria alvo, Verdadeiros Positivos em relação a soma dos verdadeiros positivos com os Falsos Negativos (false negativo — FN). Falsos negativos são os casos da categoria alvo que seu modelo previu como se fosse da categoria 0.

A fórmula do recall para a categoria alvo é:

Recall é igual ao número de casos de verdadeiros positivos dividido pela soma de verdadeiros positivos com falsos negativos.
Fórmula: Recall

Aproveitando o exemplo anterior de um modelo com precisão de 100%, se houvessem ao todo 10 casos da categoria alvo, a precisão seria 100% mas o recall seria 20%, dado que teriamos oito casos de falsos negativos.

def intervalo_recall(results):
mean = results.mean()
dv = results.std()
print('Recall médio: {:.2f}%'.format(mean*100))
print('Intervalo de Recall: [{:.2f}% ~ {:.2f}%]'
.format((mean - 2*dv)*100, (mean + 2*dv)*100))
np.random.seed(SEED)
cv = StratifiedKFold(n_splits = 5, shuffle = True)
model = LogisticRegression(solver='liblinear')
results = cross_val_score(model, X_train, y_train, cv = cv, scoring = 'recall')
intervalo_recall(results)Out >>> Recall médio: 79.91%
Intervalo de Recall: [64.30% ~ 95.53%]

No nosso modelo, o recall médio é de 79,91% em um intervalo de 64,30% até 95,53%.

Trade-off: Precisão x Recall

No nosso exemplo de um modelo com 100% de precisão vimos que o recall seria baixo. Da mesma forma, se tivéssemos um modelo com recall muito alto, ou seja, um modelo que não deixa passar nenhum caso da categoria alvo, a precisão seria baixa. Essa relação entre precisão e recall é um trade-off, se aumentarmos um, diminuímos o outro.

Mas o que define a precisão ou o recall de um modelo? Digamos que eu aceite abrir mão de precisão para que meu recall seja alto. Isso é possível? A resposta é sim, não de forma direta, mas sim.

Para entender como fazer isso, temos que saber que todo modelo de classificação tem uma função ou um valor de decisão que divide os casos entre sendo da categoria alvo e não sendo. Esse valor é o limiar do modelo, ou threshold.

Se um caso específico tiver um score, valor calculado utilizando a função de decisão, superior ao threshold, ele faz parte da categoria alvo. Caso contrário, ele faz parte da categoria 0. Na regressão logística esse threshold é igual a zero. Pra ilustrar melhor essa relação, temos o seguinte gráfico.

from sklearn.model_selection import cross_val_predict
from sklearn.metrics import precision_recall_curve
np.random.seed(SEED)
cv = StratifiedKFold(n_splits = 5, shuffle = True)
model = LogisticRegression(solver='liblinear')
y_scores = cross_val_predict(model, X_train, y_train, cv = cv,
method = 'decision_function')
precisions, recalls, thresholds = precision_recall_curve(y_train,
y_scores)
fig, ax = plt.subplots(figsize = (12,3))plt.plot(thresholds, precisions[:-1], 'b--', label = 'Precisão')
plt.plot(thresholds, recalls[:-1], 'g-', label = 'Recall')
plt.xlabel('Threshold')
plt.legend(loc = 'center right')
plt.ylim([0,1])
plt.title('Precisão x Recall', fontsize = 14)
plt.show()
O gráfico descreve a relação entre precisão e o recall. Na medida que a precisão aumenta, o recall diminui.

Para obter esse gráfico, primeiro temos que obter os scores de cada caso. Para obter esses valores primeiro utilizamos a função cross_val_predict com o parâmetro method igual a decision_function. Basicamente, a regressão logística, e outros estimadores também, atribuem um valor para cada caso. Esse valor então é comparado ao threshold, se for maior, o caso pertence a categoria alvo, se for menor, a outra categoria.

No nosso modelo, todos os scores foram comparados a zero, que é o valor padrão. Com esse threshold, podemos observar no gráfico que a precisão é de 86%, enquanto o recall é aproximadamente 80% quando o threshold é zero. Valores esses que achamos inicialmente no cálculo de precisão e recall médios.

Mas se ao invés de comparar com o valor padrão de zero, o threshold fosse -2. Bom, pelo gráfico o recall seria de aproximadamente 95% enquanto a precisão seria próxima a 60%. Para confirmar isso, basta comparar os scores dos casos com o threshold de -2.

from sklearn.metrics import precision_score, recall_scorey_train_pred_recall_90 = (y_scores > -2)print('Nova precisão: {:.4f}'
.format(precision_score(y_train,y_train_pred_recall_90)))
print('Novo recall: {:.4f}'
.format(recall_score(y_train,y_train_pred_recall_90)))
Out >>> Nova precisão: 0.6145
Novo recall: 0.9358

Ótimo! Com um outro threshold temos um recall maior, com uma precisão menor. Não conseguimos definir o threshold de forma direta no modelo de regressão logística, ele sempre será zero. Então para diferentes valores de threshold devemos obter os scores dos casos e depois comparar.

Isso nos dá a liberdade de escolhermos o recall e a precisão que quisermos! Essa característica será discutida com mais detalhes no sexto passo.

Matriz de confusão e relatório de classificação

Uma outra forma de visualizar como seu modelo de classificação está performando é olhar para a matriz de confusão (confusion matrix). A matriz nos mostra o número de casos em que o nosso modelo acertou ou errou em cada categoria.

Para visualizarmos a matriz, primeiro temos que obter as previsões do modelo. Pra isso vamos usar novamente a função cross_val_predict, mas dessa vez sem passar o parâmetro method.

from sklearn.model_selection import cross_val_predict
from sklearn.metrics import confusion_matrix
np.random.seed(SEED)
cv = StratifiedKFold(n_splits = 5, shuffle = True)
model = LogisticRegression(solver='liblinear')
y_pred = cross_val_predict(model, X_train, y_train, cv = cv)
fig, ax = plt.subplots()
sns.heatmap(confusion_matrix(y_train, y_pred), annot=True,
ax=ax, fmt='d', cmap='Reds')
ax.set_title("Matriz de Confusão", fontsize=18)
ax.set_ylabel("True label")
ax.set_xlabel("Predicted Label")
plt.tight_layout()
Matriz de 2 linhas e 2 colunas, linhas representam valores reais e colunas valores previstos pelo modelo.

A matriz de confusão é uma matriz 2x2, onde as linhas representaram os valores reais e as colunas os valores preditos. O primeiro valor, nos mostra quantas vezes o modelo previu corretamente casos da classe 0, enquanto o último valor nos mostra o número de vezes que o modelo previu corretamente casos da categoria alvo.

Os dois valores fora da diagonal principal nos mostra o número de vezes que o modelo errou em sua previsão. Na primeira linha está a quantidade de vezes que o modelo fez a previsão errada de um caso da categoria alvo. Esses erros são chamados de falsos positivos. Enquanto na segunda linha está a quantidade de vezes que o modelo fez a previsão errada de um caso da categoria zero. Erro conhecido como falso negativo.

Por esses valores nós conseguimos calcular a acurácia, precisão e recall, mas uma forma mais rápida de visualizar essas métricas é através do relatório de classificação, obtido usando a função classification_report.

from sklearn.metrics import classification_report# relatório do modelo
print('Relatório de classificação:\n', classification_report(y_train, y_pred, digits=4))
Out >>> Relatório de classificação:
precision recall f1-score support
0 0.8382 0.8906 0.8636 128
1 0.8614 0.7982 0.8286 109
accuracy 0.8481 237
macro avg 0.8498 0.8444 0.8461 237
weighted avg 0.8489 0.8481 0.8475 237

Além de mostrar as métricas para a categoria alvo, o relatório mostra os valores para a categoria 0 também. Além disso, temos a métrica do f1-score, que é uma média harmônica entre a precisão e o recall. O f1-score será alto quando ambas as métricas forem altas e similares, ou seja, o f1-score é maior quando há um “meio termo” entre precisão e recall.

Curva ROC

A curva das características operacionais do receptor (Receiver operating characteristic — ROC) tem um nome horrível mas é a ferramenta mais comum para avaliar modelos de classificação. A curva ROC mostra a relação entre a taxa de verdadeiros positivos (true positive rate — TPR) e a taxa de falsos positivos (false positive rate — FPR) para diferentes thresholds.

A TPR nos mostra a taxa de casos em que a categoria alvo foi classificada corretamente, ou seja, a TPR é um outro nome para o recall, pois ambas as métricas medem a mesma coisa. A FPR mede a taxa de casos em que a categoria 0 foi incorretamente classificada como sendo da categoria alvo.

Vamos visualizar o gráfico da curva ROC para entender melhor. Para plotar o gráfico da curva ROC utilizamos a função roc_curve e tivemos que passar como parâmetros nosso y_train e os scores dos casos.

from sklearn.metrics import roc_curvefpr, tpr, thresholds = roc_curve(y_train, y_scores)fig, ax = plt.subplots(figsize = (12,4))
plt.plot(fpr, tpr, linewidth=2, label = 'Logistic Regression')
plt.plot([0,1], [0,1], 'k--')
plt.axis([0, 1, 0, 1])
plt.xlabel('Taxa de Falsos Positivos')
plt.ylabel('Taxa de Verdadeiros Positivos')
plt.legend(loc = 'lower right')
plt.title('Curva ROC', fontsize = 14)
plt.show()
Gráfico onde há uma linha dividindo a imagem em duas partes. Na parte superior há uma linha azul, que é a curva ROC do modelo

Pra entender melhor a curva ROC vamos começar pelos extremos. Considere o ponto (0.0, 0.0) na parte baixa e esquerda do gráfico. Nesse ponto, o threshold usado para classificar os casos em sendo da categoria alvo ou não, previu que todos os casos eram da categoria 0. Nesse caso, o modelo não cometeu nenhum erro de falso positivo, ou seja, nenhum caso da categoria 0 foi prevista incorretamente como sendo da categoria alvo. Mas também, não acertou nenhum caso para a categoria alvo. Basicamente, seu modelo não cometeu nenhum erro porque também nem tentou.

Agora vamos para o outro extremo no ponto (1.0, 1.0), na parte alta e direita do gráfico. Nesse ponto, o threshold usado previu que todos os casos eram da categoria alvo. Ótimo, nesse threshold seu modelo previu corretamente todos os casos da categoria alvo, mas também classificou de forma errada todos os casos da categoria 0.

Nenhum dos extremos é bom, então temos que achar um meio termo. Vamos pensar: o que identifica um modelo bom? No caso, é um modelo que prevê corretamente o maior número possível de casos da variável alvo, mas cometendo o menor número de erros possíveis de falsos positivos. Então um modelo bom é aquele que define um score que maximiza a TPR ao mesmo tempo que minimiza a FPR.

Com isso, se a curva ROC de um modelo se aproxima bastante do ponto (0.0, 1.0), na parte alta e esquerda do gráfico, esse modelo é bom. Uma outra forma de visualizar isso é quanto mais longe a curva ROC estiver da linha pontilhada, que é a curva ROC de um modelo aleatório, melhor.

Uma métrica para medir essa proximidade com o ponto (0.0, 1.0) é a área sob a curva ROC (area under the curve — AUC), quanto mais próximo, maior é a área sob a curva ROC. Para medir essa área podemos usar a função roc_auc_score.

from sklearn.metrics import roc_auc_score
print('Área sob a curva ROC: {:.4f}'
.format(roc_auc_score(y_train, y_scores)))
Out >>> Área sob a curva ROC: 0.8997

Quanto mais próximo de 1.0 é a área sob a curva ROC, melhor é o modelo. No nosso modelo temos uma área sob a curva de 0.8997. Uma forma de avaliar esse valor é compará-lo com a área sob a curva ROC de um outro modelo.

Quando avaliamos diferentes estimadores, vimos que o Random Forest também apresentava uma acurácia média elevada. Então vamos ver qual é a área sob a curva ROC do Random Forest e comparar esse valor com a do nosso modelo de regressão logística.

O parâmetro method igual a decision function não funciona para o modelo Random Forest. O modelo classifica um caso como sendo ou não da categoria alvo em relação a probabilidade daquele caso ser ou não da categoria alvo. Se essa probabilidade for maior que 0.5, o caso pertence a categoria alvo, caso contrário, pertence a categoria 0. Para obter essa probabilidade para o Random Forest chamamos o parâmetro method igual a predict_proba.

np.random.seed(SEED)
cv = StratifiedKFold(n_splits = 5, shuffle = True)
model_rf = RandomForestClassifier(n_estimators=100)y_prob_forest = cross_val_predict(model_rf, X_train, y_train,
cv = cv, method = 'predict_proba')
y_scores_forest = y_prob_forest[:,1]
fpr_forest, tpr_forest, thresholds_forest = roc_curve(
y_train, y_scores_forest)
print('Área sob a curva ROC - Logistic Regression: {:.4f}'
.format(roc_auc_score(y_train, y_scores)))
print('Área sob a curva ROC - Random Forest: {:.4f}'
.format(roc_auc_score(y_train, y_scores_forest)))
fig, ax = plt.subplots(figsize = (12,4))
plt.plot(fpr, tpr, linewidth=2, label = 'Logistic Regression')
plt.plot(fpr_forest, tpr_forest, linewidth=2,
label = 'Random Forest')
plt.plot([0,1], [0,1], 'k--')
plt.axis([0, 1, 0, 1])
plt.xlabel('Taxa de Falsos Positivos')
plt.ylabel('Taxa de Verdadeiros Positivos')
plt.legend(loc = 'lower right')
plt.title('Curva ROC', fontsize = 14)
plt.show()Out >>> Área sob a curva ROC - Logistic Regression: 0.8997
Área sob a curva ROC - Random Forest: 0.8956
Mesmo gráfico anterior, mas agora com duas linhas. Uma da curva ROC do modelo logístico e outra do Random Forest.

Pelos resultados temos que a área sob a curva ROC de ambos os modelos são próximas, mas para o modelo de regressão logística é um pouco superior.

5º passo: utilidade do seu modelo

Qual é o objetivo do seu modelo de Machine Learning? O que você espera prever com ele? É interessante perder um pouco de precisão pra ter um recall maior?

Essas são perguntas que você sempre tem de fazer ao avaliar seu modelo de ML. As métricas estão aí para te ajudar, mas a decisão de como usá-las é sempre sua. Vamos considerar nosso exemplo do modelo de previsão para doenças cardíacas. O objetivo é usar características pessoais de cada paciente para prever se ele tem ou não doença cardíaca, e nosso modelo utilizando a regressão logística como estimador, apresentou uma precisão média de 86,16% e um recall médio de 79,91% para a categoria alvo.

O que isso significa na prática? Digamos que esse modelo seja implementado em algum hospital, por exemplo, e que ele prevê que 100 pacientes tenham doença cardíaca. Dada a nossa precisão, em média, desses 100 pacientes, 14 são saudáveis e nosso modelo errou ao classificá-los. Agora de acordo com nosso recall, em média, de 100 pacientes com doenças cardíacas, 20 não terão o diagnóstico de ter a doença.

Agora, o que acontece com as pessoas que foram classificadas de forma errada? Segundo a Organização Pan-Americana da Saúde, a maioria das doenças cardiovasculares podem ser evitadas por meio de hábitos mais saudáveis como: não fumar ou beber em excesso, praticar exercícios físicos regularmente, entre outros. Então se ao prever que um paciente tem doença cardíaca, o tratamento indicado for uma vida mais saudável, não é tão problemático assim ter uma precisão mais baixa. Agora, um recall baixo já é um problema, pois classificar um paciente com doença cardíaca como saudável pode causar problemas mais sérios pra ele no futuro.

Então ter um recall mais alto em detrimento de uma precisão mais baixa não é uma má ideia. Como visto no gráfico da relação recall x precisão, ao mudar o threshold do modelo para -2, o modelo passa a ter uma precisão de 61,45% e um recall de 93,58%, o que para o nosso caso é mais interessante.

Claro, não sou médico. O que estou propondo aqui é só uma reflexão sobre o que é melhor, ter precisão ou recall mais altos? E não há resposta única. Cada modelo terá uma situação diferente. Se o procedimento para pacientes que o modelo previu que têm doença cardíaca fosse uma cirurgia invasiva e/ou arriscada, o melhor modelo seria aquele que tivesse 99,9% de precisão.

Então avalie seu modelo de acordo com o seu objetivo!

6º passo: fazendo o teste

Considerando todos os passos de treino e teste com validação cruzada, escolha de estimador, hiperparâmetro, métricas e tudo mais, chegamos ao modelo final: o modelo de regressão logística! Isso tudo sem utilizar o nosso conjunto de dados de teste. Então vamos ver como nosso modelo se sai com novos dados.

Lembrando que pela validação cruzada, é esperado que a acurácia esteja entre 74,00% e 95,70%, a precisão esteja entre 74,66% e 97,65% e o recall entre 64,30% e 95,53%. Esses são os valores esperados para a categoria alvo.

np.random.seed(SEED)final_model = LogisticRegression(solver='liblinear')
final_model.fit(X_train, y_train)
y_pred = final_model.predict(X_test)
y_prob = final_model.predict_proba(X_test)
# imprimir relatório de classificação
print("Relatório de Classificação:\n",
classification_report(y_test, y_pred, digits=4))
# imprimir a área sob a curva
print("AUC: {:.4f}\n".format(roc_auc_score(y_test,y_prob[:,1])))
Out >>> Relatório de Classificação:
precision recall f1-score support
0 0.7561 0.9688 0.8493 32
1 0.9474 0.6429 0.7660 28
accuracy 0.8167 60
macro avg 0.8517 0.8058 0.8076 60
weighted avg 0.8454 0.8167 0.8104 60
AUC: 0.8873

De acordo com o relatório de classificação, a acurácia e a precisão apresentaram resultados dentro do intervalo esperado, enquanto o recall ficou um pouco abaixo do intervalo inferior. Então com a validação cruzada, nós temos informações mais realistas de como o nosso modelo performa com novos dados. Diferente de uma medida pontual que é obtida quando testamos somente uma vez com o conjunto de teste.

“E se os resultados fossem muito diferentes do esperado?”. Bom, nesse caso seu modelo não está conseguindo generalizar as previsões para novos dados. Acredito que a melhor estratégia é voltar do início, tentar obter novos dados para o treino, verificar se as features que estão sendo usadas são boas, testar outros hiperparâmetros, outros estimadores. O ponto é: só não modifique algo agora que obteve os resultados para os dados de teste. Lembre-se: você vai causar um overfitting com seus dados de teste e seu modelo vai continuar não generalizando bem novos dados.

Mas esses resultados são para um threshold padrão de zero. Mas no nosso caso específico, é interessante comparar os scores com um threshold de -2 para ter um recall mais alto. Para fazer isso, não podemos mais utilizar a função cross_val_predict , pois não estamos mais fazendo validação cruzada, nosso teste é sobre os dados de teste. Para calcular os scores das previsões, utilizamos agora a função decision_function sobre o nosso modelo e passamos como parâmetro o conjunto X_test. Com o resultado dos scores basta compará-los com o threshold de -2.

y_scores_final = final_model.decision_function(X_test)
y_test_pred_recall_90 = (y_scores_final > -2)
print('Nova precisão: {:.4f}'
.format(precision_score(y_test,y_test_pred_recall_90)))
print('Novo recall: {:.4f}'
.format(recall_score(y_test,y_test_pred_recall_90)))
Out >>> Nova precisão: 0.6429
Novo recall: 0.9643

Utilizando o -2 como threshold, a precisão do modelo é de 64,29% e o recall é de 96,43%. Nesse caso, quando nosso modelo indicar que uma pessoa tem doença cardíaca ele vai errar mais, porém, menos pacientes com a doença serão considerados saudáveis de forma errada.

7º passo: tudo OK?

Depois de toda essa análise a pergunta é: o modelo atende às minhas necessidades? Se a resposta for sim, você estiver satisfeito com os resultados, ele apresentar métricas satisfatórias, então pronto! Modelo finalizado.

Agora é só treinar um modelo utilizando todos os seus dados e fazer o deploy. Lembrando que o threshold ideal no nosso caso é o -2, então as previsões vão ser feitas com base nessa comparação.

np.random.seed(SEED)deploy_model = LogisticRegression(solver='liblinear')
deploy_model.fit(X, y)
def previsao_threshold(data, threshold = -2):
y_previsao = deploy_model.decision_function(data)
y_threshold = (y_previsao > threshold)
print(y_previsao)
print(y_threshold)
if threshold == True:
print('Paciente diagnosticado com doença cardíaca.')
else:
print('Paciente liberado')

Então é isso. Espero que esse artigo tenha lhe ajudado a entender melhor o processo de avaliação de um modelo: o que não fazer, quais são as métricas usadas, o que elas significam, como o modelo define a previsão para uma categoria, entre outras coisas.

A principal referência desse artigo é o livro Hands-On Machine Learning With Scikit-Learn and TensorFlow do autor Aurélien Géron. Esse livro é excelente! Uma combinação muito boa de teoria, prática e código. Se você não leu, fica a sugestão de leitura!

Qualquer dúvida, opinião, sugestão ou correção, entre em contato comigo no LinkedIn!

--

--