Esta é uma das perguntas populares das entrevistas de ciência de dados que exige a criação do ROC e curvas semelhantes do zero, ou seja, nenhum dado disponível. Para os propósitos desta história, assumirei que os leitores estão cientes do significado e dos cálculos por trás dessas métricas, do que elas representam e como são interpretadas. Portanto, vou me concentrar no aspecto de implementação do mesmo. Começamos importando as bibliotecas necessárias (também importamos matemática porque esse módulo é usado em cálculos)
import pandas as pd import numpy as np import matplotlib.pyplot as plt import math
O primeiro passo é gerar os dados 'reais' de 1s (ruins) e 0s (bens), pois estes serão usados para calcular e comparar a precisão do modelo por meio das métricas mencionadas. Para este artigo, criaremos o “vetor real” da distribuição uniforme. Para o artigo subsequente e relacionado, usaremos a distribuição binomial.
actual = np.random.randint(0, 2, 10000)
O código acima gera 10.000 inteiros aleatórios pertencentes a [0,1] que é o nosso vetor da classe binária real. Agora, é claro que precisamos de outro vetor de probabilidades para estas classes reais. Normalmente, essas probabilidades são resultados de um modelo de aprendizado de máquina. No entanto, aqui iremos gerá-los aleatoriamente fazendo algumas suposições úteis. Vamos supor que o modelo subjacente seja um 'modelo de regressão logística', portanto, a função de ligação é logística ou logit.
A figura abaixo descreve a função logística padrão. Para um modelo de regressão logística, a expressão -k(x-x_0) é substituída por uma 'pontuação'. A 'pontuação' é uma soma ponderada dos recursos e parâmetros do modelo.
Assim, quando a 'pontuação' = 0, a função logística deve passar por 0,5 no eixo Y. Isso ocorre porque logit(p) = log-odds(p) = log(p/(1-p)) = 0 => p = 1-p => p =0,5. Observe também que quando a 'pontuação' atinge valores positivos altos ou negativos altos, a função se move assintoticamente em direção a 1 (ruim) ou 0 (bom). Assim, quanto maior for o valor absoluto da 'pontuação', maior também será a probabilidade prevista. Mas o que estamos marcando? Estamos pontuando cada entrada de dados presente em nosso ‘vetor real’. Então, se quisermos assumir que o nosso modelo de regressão logística subjacente é qualificado, ou seja, preditivo; o modelo deve atribuir pontuações comparativamente mais altas para bens ruins versus bens. Assim, os produtos ruins deveriam ter pontuações mais positivas (para garantir que a probabilidade prevista esteja próxima de 1) e os bens deveriam ter pontuações mais negativas (para garantir que a probabilidade prevista estivesse próxima de 0). Isso é conhecido como ordenação de classificação pelo modelo. Por outras palavras, deve haver discriminação ou separação entre as pontuações e, portanto, as probabilidades previstas de bens maus versus bens. Desde então, vimos que a pontuação 0 implica probabilidade de bom = probabilidade de ruim = 0,5; isso significaria que o modelo é incapaz de diferenciar entre o bem e o mal. Mas como sabemos que o ponto de dados será realmente bom ou ruim, uma pontuação de 0,5 é a pior pontuação possível do modelo. Isso nos dá alguma intuição para passar para a próxima etapa.
As pontuações podem ser geradas aleatoriamente usando a distribuição Normal Padrão com média 0 e desvio padrão de 1. No entanto, queremos pontuações previstas diferentes para produtos ruins e bons. Também queremos que as pontuações ruins sejam maiores que as pontuações boas. Assim, utilizamos a distribuição normal padrão e deslocamos a sua média para criar uma separação entre os bons e os maus.
# scores for bads bads = np.random.normal(0, 1, actual.sum()) + 1 # scores for goods goods = np.random.normal(0, 1, len(actual) - actual.sum()) - 1 plt.hist(bads) plt.hist(goods)
No código mencionado acima, amostramos pontuações ruins e pontuações boas de duas distribuições normais padrão diferentes, mas as mudamos para criar uma separação entre as duas. Deslocamos as pontuações ruins (representadas pela cor azul na imagem) em 1 para a direita e vice-versa em 1 para a esquerda. Isso garante o seguinte:
É claro que podemos maximizar esta separação aumentando o parâmetro 'shift' e atribuindo-lhe valores superiores a 1. Contudo, nesta história, não faremos isso. Exploraremos isso nas histórias relacionadas subsequentes. Agora, vejamos as probabilidades geradas por essas pontuações.
# prob for bads bads_prob = list((map(lambda x: 1/(1 + math.exp(-x)), bads))) # prob for goods goods_prob = list((map(lambda x: 1/(1 + math.exp(-x)), goods))) plt.hist(bads_prob) plt.hist(goods_prob)
Conforme discutido anteriormente, quando as “pontuações” são empurradas através da função logística, obtemos as probabilidades. É evidente que as probabilidades ruins (cor azul) são maiores (e distorcidas para 1) do que as probabilidades boas (cor laranja) (e distorcidas para 0).
A próxima etapa é combinar os vetores reais e previstos em um único quadro de dados para análise. Atribuímos probabilidades ruins onde a instância de dados é realmente ruim e vice-versa
# create predicted array bads = 0 goods = 0 predicted = np.zeros((10000)) for idx in range(0, len(actual)): if actual[idx] == 1: predicted[idx] = bads_prob[bads] bads += 1 else: predicted[idx] = goods_prob[goods] goods += 1 actual_df = pd.DataFrame(actual, columns=['actual']) predicted_df = pd.DataFrame(predicted, columns=['predicted']) predicted_df = pd.concat([actual_df, predicted_df], axis = 1) predicted_df = predicted_df.sort_values(['predicted'], ascending = False).reset_index() predicted_df = predicted_df.drop(columns = 'predicted')
A próxima etapa é criar caixas. Isso ocorre porque as curvas que queremos gerar são de natureza discreta. Para cada compartimento, calculamos nossas métricas desejadas cumulativamente. Em outras palavras, geramos funções de distribuição cumulativa para as variáveis aleatórias discretas – bens e males.
n_bins = 50 bin_size = math.floor(len(predicted_df) / n_bins) curve_metrics = []
Devemos listar os volumes de produtos ruins e de mercadorias e os tamanhos dos compartimentos para nossa referência
print("number of bads:", bads) print("number of goods:", goods) print("number of total data points:", len(actual)) print("bin size:", len(predicted_df) / n_bins)
número de coisas ruins: 4915
número de mercadorias: 5085
número total de pontos de dados: 10.000
tamanho do compartimento: 200,0
A seguir vem o trecho de código principal onde fazemos os cálculos reais das métricas subjacentes
for k in range(1, n_bins + 1): if k < n_bins: TP = predicted_df.loc[ : k*bin_size-1, "actual"].sum() FP = k*bin_size - TP FN = predicted_df.loc[(k*bin_size) : , "actual"].sum() TN = len(actual) - k*bin_size - FN cum_bads = predicted_df.loc[ : k*bin_size-1, "actual"].sum() cum_goods = k*bin_size - cum_bads else: TP = predicted_df.loc[ : , "actual"].sum() FP = len(actual) - TP FN = 0 TN = 0 cum_bads = bads cum_goods = goods curve_metrics.append([k, TP, FP, TN, FN, cum_bads, cum_goods])
Observe que o loop for iria de 1 a n_bins, ou seja, ele deixaria um no final. Portanto, temos n_bins + 1 como o ‘valor de parada’.
Para k = 1 a k = n_bins-1, fazemos cálculos cumulativos de verdadeiros positivos, falsos positivos, falsos negativos, verdadeiros negativos, bds cumulativos e bens cumulativos usando bin_size.
Observe que o snippet “predicted_df.loc[: k* bin_size-1, "actual"].sum()” seria executado de index = 0 a index = kbin_size-1. Assim, retira o pedaço igual a k *bin_size. Portanto, subtraímos 1 de k *bin_size
Da mesma forma, o snippet “predicted_df.loc[(k*bin_size) : , "actual"].sum()” seria executado de index = k*bin_size até o índice final. Assim, se o bin for de 0 a 49 (tamanho 50), o snipper roda a partir de index = 50 (que é igual a bin_size) em diante
Para k = n_bins, apenas o estendemos ao índice final do conjunto de dados. Onde, o snippet “predicted_df.loc[ : , "actual"].sum()” resume todos os problemas à medida que a indexação vai do índice = 0 até o índice final do dataframe. Também podemos substituí-lo por “TP = bads”. FN e TN são ambos = 0 porque no último ponto de corte atribuímos tudo como “ruim”. Portanto, não resta nenhum falso negativo (realmente ruim) ou verdadeiro negativo (realmente bom). Porque a classe negativa não existe quando k = n_bins.
É útil verificar como é a matriz cumulativa.
curve_metrics
Observe que para k = n_bins = 50, acumulamos todos os bens (5.085) e todos os bens ruins (4.915).
Agora estamos prontos para fazer os cálculos reais necessários para as curvas desejadas
curve_metrics_df = pd.DataFrame(curve_metrics, columns=["cut_off_index", "TP", "FP", "TN", "FN", "cum_bads", "cum_goods"]) curve_metrics_df["cum%bads"] = curve_metrics_df["cum_bads"] / (actual.sum()) curve_metrics_df["cum%goods"] = curve_metrics_df["cum_goods"] / (len(actual) - actual.sum()) curve_metrics_df["precision"] = curve_metrics_df["TP"] / (curve_metrics_df["TP"] + curve_metrics_df["FP"]) curve_metrics_df["recall"] = curve_metrics_df["TP"] / (curve_metrics_df["TP"] + curve_metrics_df["FN"]) curve_metrics_df["sensitivity"] = curve_metrics_df["TP"] / (curve_metrics_df["TP"] + curve_metrics_df["FN"]) # specificity is the recall on the negative class curve_metrics_df["specificity"] = curve_metrics_df["TN"] / (curve_metrics_df["TN"] + curve_metrics_df["FP"])
É isso. Temos tudo o que é necessário para traçar nossas curvas agora.
plt.plot(curve_metrics_df["cum%goods"], curve_metrics_df["cum%bads"], label ="roc curve") plt.xlabel("cum%goods") plt.ylabel("cum%bads") plt.title("ROC Curve") plt.legend() plt.show() plt.plot(1 - curve_metrics_df["specificity"], curve_metrics_df["sensitivity"], label ="sensitivity specificity curve") plt.xlabel("1 - Specificity") plt.ylabel("Sensitivity") plt.title("Sensitivity vs 1-Specificity Curve") plt.legend() plt.show() plt.plot(curve_metrics_df["recall"], curve_metrics_df["precision"], label ="precision recall curve") plt.xlabel("Precision") plt.ylabel("Recall") plt.title("Precision Recall Curve") plt.legend() plt.show()
Todas as curvas confirmam a utilização de um modelo altamente qualificado (como havíamos formulado no início). Isso completa a tarefa de criar essas curvas do zero.
Alimento para reflexão - “o que acontece com essas curvas quando as classes estão gravemente desequilibradas?” Este será o tema da próxima história.
Se você gostou disso, dê uma olhada nas outras histórias minhas.