Esta es una de las preguntas populares de la entrevista sobre ciencia de datos que requiere que uno cree la ROC y curvas similares desde cero, es decir, sin datos disponibles. A los efectos de esta historia, asumiré que los lectores conocen el significado y los cálculos detrás de estas métricas, lo que representan y cómo se interpretan. Por lo tanto, me centraré en el aspecto de implementación del mismo. Comenzamos importando las bibliotecas necesarias (también importamos matemáticas porque ese módulo se usa en cálculos)
import pandas as pd import numpy as np import matplotlib.pyplot as plt import math
El primer paso es generar los datos "reales" de 1 (malos) y 0 (buenos), porque se utilizarán para calcular y comparar la precisión del modelo a través de las métricas antes mencionadas. Para este artículo, crearemos el "vector real" a partir de una distribución uniforme. Para el artículo siguiente y relacionado, utilizaremos la distribución binomial.
actual = np.random.randint(0, 2, 10000)
El código anterior genera 10.000 enteros aleatorios que pertenecen a [0,1], que es nuestro vector de la clase binaria real. Ahora, por supuesto, necesitamos otro vector de probabilidades para estas clases reales. Normalmente, estas probabilidades son el resultado de un modelo de aprendizaje automático. Sin embargo, aquí los generaremos aleatoriamente haciendo algunas suposiciones útiles. Supongamos que el modelo subyacente es un "modelo de regresión logística", por lo tanto, la función de enlace es logística o logit.
La siguiente figura describe la función logística estándar. Para un modelo de regresión logística, la expresión -k(x-x_0) se reemplaza por una 'puntuación'. La 'puntuación' es una suma ponderada de las características y parámetros del modelo.
Por lo tanto, cuando la 'puntuación' = 0, la función logística debe pasar por 0,5 en el eje Y. Esto se debe a que logit(p) = log-odds(p) = log(p/(1-p)) = 0 => p = 1-p => p =0,5. Observe también que cuando la 'puntuación' alcanza valores positivos o negativos altos, la función se mueve asintóticamente hacia 1 (malo) o 0 (bueno). Por tanto, cuanto mayor sea el valor absoluto de la 'puntuación', mayor será también la probabilidad prevista. ¿Pero qué estamos anotando? Estamos puntuando cada entrada de datos presente en nuestro "vector real". Entonces, si queremos asumir que nuestro modelo de regresión logística subyacente es hábil, es decir, predictivo; el modelo debería asignar puntuaciones comparativamente más altas a los malos frente a los bienes. Por lo tanto, los malos deberían tener puntuaciones más positivas (para garantizar que la probabilidad prevista sea cercana a 1) y los bienes deberían tener puntuaciones más negativas (para garantizar que la probabilidad prevista sea cercana a 0). Esto se conoce como ordenamiento por rangos según el modelo. En otras palabras, debería haber discriminación o separación entre las puntuaciones y, por tanto, las probabilidades previstas de males versus bienes. Desde entonces, hemos visto que la puntuación de 0 implica probabilidad de bien = probabilidad de mal = 0,5; esto significaría que el modelo no puede diferenciar entre el bien y el mal. Pero como sabemos que el punto de datos será bueno o malo, una puntuación de 0,5 es la peor puntuación posible del modelo. Esto nos da cierta intuición para pasar al siguiente paso.
Las puntuaciones se pueden generar aleatoriamente utilizando la distribución normal estándar con una media de 0 y una desviación estándar de 1. Sin embargo, queremos puntuaciones predichas diferentes para los bienes y los malos. También queremos que las malas puntuaciones sean más altas que las buenas. Por lo tanto, utilizamos la distribución normal estándar y cambiamos su media para crear una separación entre los bienes y los males.
# 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)
En el código antes mencionado, tomamos muestras de puntuaciones de malos y buenos de dos distribuciones normales estándar diferentes, pero las cambiamos para crear una separación entre las dos. Cambiamos las puntuaciones de malos (representados por el color azul en la imagen) 1 hacia la derecha y viceversa 1 hacia la izquierda. Esto asegura lo siguiente:
Por supuesto, podemos maximizar esta separación aumentando el parámetro 'shift' y asignándole valores superiores a 1. Sin embargo, en esta historia, no haremos eso. Exploraremos eso en las historias relacionadas posteriores. Ahora, veamos las probabilidades generadas por estos puntajes.
# 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)
Como se analizó anteriormente, cuando las 'puntuaciones' se pasan a través de la función logística, obtenemos las probabilidades. Es evidente que las malas probabilidades (color azul) son mayores (y sesgadas hacia 1), que las buenas probabilidades (color naranja) (y sesgadas hacia 0).
El siguiente paso es combinar los vectores reales y predichos en un único marco de datos para su análisis. Asignamos malas probabilidades donde la instancia de datos es realmente mala y viceversa.
# 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')
El siguiente paso es crear contenedores. Esto se debe a que las curvas que queremos generar son de naturaleza discreta. Para cada contenedor, calculamos nuestras métricas deseadas de forma acumulativa. En otras palabras, generamos funciones de distribución acumulativa para las variables aleatorias discretas: bienes y males.
n_bins = 50 bin_size = math.floor(len(predicted_df) / n_bins) curve_metrics = []
Deberíamos incluir los volúmenes de productos y bienes y tamaños de contenedores como referencia.
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 malos: 4915
número de bienes: 5085
número total de puntos de datos: 10000
Tamaño del contenedor: 200,0
Luego viene el fragmento de código principal donde hacemos los cálculos reales de las métricas subyacentes.
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 el bucle for se ejecutará desde 1 hasta n_bins, es decir, deja uno al final. Por lo tanto, tenemos n_bins + 1 como 'valor de parada'.
Para k = 1 a k = n_bins-1, hacemos cálculos acumulativos de verdaderos positivos, falsos positivos, falsos negativos, verdaderos negativos, bds acumulativos y bienes acumulativos utilizando bin_size.
Observe que el fragmento “predicted_df.loc[: k* bin_size-1, "actual"].sum()” se ejecutaría desde index = 0 hasta index = kbin_size-1. Por lo tanto, extrae el fragmento igual a k *bin_size. Por lo tanto, restamos 1 de k *bin_size
De manera similar, el fragmento “predicted_df.loc[(k*bin_size) : , "actual"].sum()” se ejecutaría desde index = k*bin_size hasta el índice final. Por lo tanto, si el contenedor es de 0 a 49 (tamaño 50), el cortador corre desde index = 50 (que es igual a bin_size) en adelante.
Para k = n_bins, simplemente lo extendemos al índice final del conjunto de datos. Donde, el fragmento “predicted_df.loc[:, "actual"].sum()” resume todos los errores a medida que la indexación se ejecuta desde index = 0 hasta el índice final del marco de datos. También podemos sustituirlo por “TP = bads”. FN y TN son ambos = 0 porque en el último corte asignamos todo como "malo". Por lo tanto, no queda ningún Falso Negativo (realmente malo) o Verdadero Negativo (realmente bueno). Porque la clase negativa no existe cuando k = n_bins.
Es útil comprobar cómo se ve la matriz acumulativa.
curve_metrics
Observe que para k = n_bins = 50, hemos acumulado todos los bienes (5085) y todos los males (4915).
Ahora estamos listos para realizar los cálculos reales necesarios para las curvas deseadas.
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"])
Eso es todo. Ahora tenemos todo lo necesario para trazar nuestras curvas.
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 las curvas confirman el uso de un modelo altamente cualificado (como habíamos formulado al principio). Esto completa la tarea de crear estas curvas desde cero.
Para reflexionar: “¿qué pasa con estas curvas cuando las clases están gravemente desequilibradas?” Este será el tema de la próxima historia.
Si te gusta esto, echa un vistazo a mis otras historias .