Para clasificación binaria. Todos aman el área bajo la métrica de la curva (AUC), pero nadie lo dirige directamente en su función de pérdida. En su lugar, la gente usa una función proxy como la entropía cruzada binaria (BCE).
Esto funciona bastante bien, la mayoría de las veces. Pero nos quedamos con una pregunta persistente: ¿podríamos obtener una puntuación más alta con una función de pérdida más cercana a la naturaleza para AUC?
Parece probable ya que BCE realmente tiene muy poca relación con AUC. Ha habido muchos intentos para encontrar una función de pérdida que se dirige más directamente a AUC. (Una táctica común es alguna forma de función de pérdida de rango, como la pérdida de rango de bisagra). Sin embargo, en la práctica, nunca ha surgido un ganador claro. No ha habido un desafío serio para BCE.
También hay consideraciones más allá del rendimiento. Dado que BCE es esencialmente diferente a AUC, BCE tiende a portarse mal en el tramo final de entrenamiento donde estamos tratando de dirigirlo hacia la puntuación AUC más alta.
Una buena parte de la optimización de AUC en realidad termina ocurriendo en el ajuste de hiperparametros. La detención temprana se convierte en una necesidad incómoda ya que el modelo puede divergir bruscamente en cualquier momento desde su puntaje alto.
Nos gustaría una función de pérdida que nos brinde puntajes más altos y menos problemas.
Presentamos tal función aquí.
Mi definición de trabajo favorita de AUC es esta: llamemos a las etiquetas de la clase binaria "Black" (0) y "White" (1). Elija un elemento negro al azar y deje que X sea su valor predicho. Ahora elija un elemento blanco aleatorio con el valor y . Entonces,
AUC = La probabilidad de que los elementos estén en el orden correcto. Es decir, x < y .
Eso es todo. Para cualquier conjunto de puntos como el conjunto de entrenamiento, podemos obtener esta probabilidad haciendo un cálculo de fuerza bruta. Escanee el conjunto de todos los pares blancos/negros posibles y cuente la porción que está a la derecha.
Podemos ver que la puntuación AUC no es diferenciable (una curva suave con respecto a cualquier x o y .) Tome un elemento (cualquier color) y muévalo lo suficientemente ligeramente como para que no golpee un elemento vecino. El AUC permanece igual. Una vez que el punto cruza a un vecino, tenemos la oportunidad de voltear una de las comparaciones x <y, lo que cambia el AUC. Entonces el AUC no hace transiciones suaves.
Ese es un problema para las redes neuronales, donde necesitamos una función de pérdida diferenciable.
Por lo tanto, nos propusimos encontrar una función diferenciable que esté cerca posible de AUC.
Regresé a través de la literatura existente y no encontré nada que funcionara en la práctica. Finalmente me encontré con un curioso código que alguien había registrado en la base de código TFLearn.
Sin fanfarria, prometió una liberación diferenciable de BCE en forma de una nueva función de pérdida.
( No lo intentes, explota ).
def roc_auc_score(y_pred, y_true):
"""Bad code, do not use.
ROC AUC Score.
Approximates the Area Under Curve score, using approximation based on
the Wilcoxon-Mann-Whitney U statistic.
Yan, L., Dodier, R., Mozer, M. C., & Wolniewicz, R. (2003).
Optimizing Classifier Performance via an Approximation to the Wilcoxon-Mann-Whitney Statistic.
Measures overall performance for a full range of threshold levels.
Arguments:
y_pred: `Tensor`. Predicted values.
y_true: `Tensor` . Targets (labels), a probability distribution.
"""
with tf.name_scope("RocAucScore"):
pos = tf.boolean_mask(y_pred, tf.cast(y_true, tf.bool))
neg = tf.boolean_mask(y_pred, ~tf.cast(y_true, tf.bool))
.
.
more bad code)
No funciona en absoluto. (Explotar es en realidad el menor de sus problemas). Pero menciona el documento en el que se basaba.
Aunque el documento es antiguo , que se remonta a 2003, descubrí que con un poco de trabajo, alguna extensión de las matemáticas y la codificación cuidadosa, en realidad funciona. Es súper rápido, con velocidad comparable a BCE (y igual de vectorizable para una GPU/MPP) *. En mis pruebas, proporciona puntajes AUC más altos que BCE, es menos sensible a la tasa de aprendizaje (evitando la necesidad de un programador en mis pruebas) y elimina por completo la necesidad de detener temprano.
Ok, pasemos al documento original: optimizar el rendimiento del clasificador a través de una aproximación a la estadística Wilcoxon - Mann - Whitney .
Los autores, Yan et. Al , motivar la discusión escribiendo el puntaje AUC en una forma particular. Recuerde nuestro ejemplo donde calculamos AUC haciendo un recuento de fuerza bruta sobre el conjunto de posibles pares en blanco/negro para encontrar la porción que está ordenada a la derecha. Sea B el conjunto de valores negros y w el conjunto de valores blancos. Todos los pares posibles están dados por el producto cartesiano B x W. Para contar los pares ordenados correctamente que escribimos:
Esto es realmente solo esforzarse por la notación matemática para decir 'Cuenta los pares ordenados a la derecha'. Si dividimos esta suma por el número total de pares, | B | * | W |, obtenemos exactamente la métrica AUC. (Históricamente, esto se llama estadística normalizada de Wilcoxon-Mann-Whitney (WMW)).
Para hacer una función de pérdida a partir de esto, podríamos voltear la comparación X <Y con X> Y para penalizar pares ordenados mal. El problema, por supuesto, es ese salto discontinuo cuando X cruza y.
Yan et. Encuestas de AL , y luego rechazan, los pasos activos pasados utilizando aproximaciones continuas a la función de paso (Heaviside), como una curva sigmoidea. Luego sacan esto de un sombrero:
Yann consiguió esta foro aplicando una serie de cambios en el WMW:
Vamos a pasar por estos a su vez. 1 es lo suficientemente claro. Hay un poco más de 2 de lo que parece. Tiene un sentido intuitivo que los pares ordenados mal con una amplia separación deberían tener una mayor pérdida. Pero también está sucediendo algo interesante a medida que se acerca esa separación 0. La pérdida va a cero linealmente, en lugar de una función de paso. Así que nos hemos librado de la discontinuidad.
De hecho, si P fueran 1 y γ que fueran 0, la pérdida sería simplemente nuestro viejo amigo Relu (XY). Pero en este caso notamos un hipo, que revela la necesidad del exponente p . Relu no es diferenciable en 0. Eso no es un gran problema en el papel más acostumbrado de Relu como una función de activación, pero para nuestros propósitos la singularidad en 0 tierras directamente en lo que más estamos más interesados: los puntos donde los elementos blancos y negros cruzarse.
Afortunadamente, elevando a Relu a un poder soluciona esto. Relu^p con p> 1 es diferenciable en todas partes. Ok, entonces P> 1.
Ahora de vuelta a γ: γ proporciona un 'relleno' que se aplica entre dos puntos. Penalizamos no solo pares ordenados mal, sino también pares ordenados a la derecha que están demasiado cerca . Si un par ordenado a la derecha está demasiado cerca, sus elementos corren el riesgo de ser cambiados en el futuro por el sacudido aleatorio de una red neuronal estocástica. La idea es mantenerlos separados hasta que alcancen una distancia cómoda.
Y esa es la idea básica como se describe en el periódico. Ahora tenemos algunos refinamientos con respecto a γ y P.
Aquí rompemos un poco con el papel. Yan et. Al parecen un poco aprensivos sobre el tema de elegir γ y P, ofreciendo solo que una P = 2 o P = 3 parece buena y que γ debería estar en algún lugar entre 0.10 y 0.70. Yan esencialmente nos desea suerte con estos parámetros y se inclina.
Primero, solucionamos permanentemente P = 2, porque cualquier función de pérdida de autoestructuración debería ser una suma de cuadrados. (Una razón para esto es que asegura que la función de pérdida no solo sea diferenciable, sino también convexa )
En segundo y lo más importante, echemos un vistazo a γ. La heurística de 'en algún lugar de 0.10 a 0.70' parece extraña a la cara; Incluso si las predicciones se normalizaron para ser 0 <x <1, esta guía parece excesiva, indiferente a las distribuciones subyacentes, y simplemente extraña.
Vamos a derivar γ del conjunto de entrenamiento.
Considere el conjunto de entrenamiento y sus pares negros/blancos, B x W. Hay | B || W | pares en este conjunto. De estos, | B | | W | AUC está ordenado a la derecha. Entonces, el número de pares ordenados mal es (1-AUC) | B | | W |
Cuando γ es cero, solo estos pares ordenados mal están en movimiento (tienen una pérdida positiva). Un γ positivo expandiría el conjunto de pares móviles para incluir algunos pares que están bien ordenados, pero demasiado cercanos. En lugar de preocuparnos por el valor numérico de γ, especificaremos cuántos pares demasiado cerca queremos poner en movimiento:
Definimos un δ constante que fija la proporción de pares demasiado cerca para pares ordenados mal.
Arreglamos este δ durante el entrenamiento y actualizamos γ para ajustarse a él. Para dada δ, encontramos γ de tal manera que
En nuestros experimentos encontramos que δ puede variar de 0.5 a 2.0, y 1.0 es una buena opción predeterminada.
Entonces establecemos δ a 1, p a 2, y nos olvidamos de γ por completo,
Nuestra función de pérdida (1) parece terriblemente costosa de calcular. Requiere que escaneemos todo el conjunto de entrenamiento para cada predicción individual.
Evitamos este problema con un ajuste de rendimiento:
Supongamos que estamos calculando la función de pérdida para un punto de datos blanco dado, x . Para calcular (3), necesitamos comparar X con todo el conjunto de entrenamiento de predicciones negras, y . Tomamos un atajo y usamos una submuestra aleatoria de los puntos de datos negros. Si establecemos el tamaño de la submuestra para ser, digamos, 1000, obtenemos una aproximación muy (muy) cercana a la función de pérdida verdadera. [1]
Un razonamiento similar se aplica a la función de pérdida de un punto de datos negro; Utilizamos una submuestra aleatoria de todos los elementos de entrenamiento blanco.
De esta manera, las submuestras blancas y negras se ajustan fácilmente a la memoria de GPU. Al reutilizar la misma submuestra a lo largo de un lote dado, podemos paralelizar la operación en lotes. Terminamos con una función de pérdida que es tan rápida en BCE.
Aquí está la función de pérdida por lotes en Pytorch:
def roc_star_loss( _y_true, y_pred, gamma, _epoch_true, epoch_pred):
"""
Nearly direct loss function for AUC.
See article,
C. Reiss, "Roc-star : An objective function for ROC-AUC that actually works."
https://github.com/iridiumblue/articles/blob/master/roc_star.md
_y_true: `Tensor`. Targets (labels). Float either 0.0 or 1.0 .
y_pred: `Tensor` . Predictions.
gamma : `Float` Gamma, as derived from last epoch.
_epoch_true: `Tensor`. Targets (labels) from last epoch.
epoch_pred : `Tensor`. Predicions from last epoch.
"""
#convert labels to boolean
y_true = (_y_true>=0.50)
epoch_true = (_epoch_true>=0.50)
# if batch is either all true or false return small random stub value.
if torch.sum(y_true)==0 or torch.sum(y_true) == y_true.shape[0]: return torch.sum(y_pred)*1e-8
pos = y_pred[y_true]
neg = y_pred[~y_true]
epoch_pos = epoch_pred[epoch_true]
epoch_neg = epoch_pred[~epoch_true]
# Take random subsamples of the training set, both positive and negative.
max_pos = 1000 # Max number of positive training samples
max_neg = 1000 # Max number of positive training samples
cap_pos = epoch_pos.shape[0]
cap_neg = epoch_neg.shape[0]
epoch_pos = epoch_pos[torch.rand_like(epoch_pos) < max_pos/cap_pos]
epoch_neg = epoch_neg[torch.rand_like(epoch_neg) < max_neg/cap_pos]
ln_pos = pos.shape[0]
ln_neg = neg.shape[0]
# sum positive batch elements agaionst (subsampled) negative elements
if ln_pos>0 :
pos_expand = pos.view(-1,1).expand(-1,epoch_neg.shape[0]).reshape(-1)
neg_expand = epoch_neg.repeat(ln_pos)
diff2 = neg_expand - pos_expand + gamma
l2 = diff2[diff2>0]
m2 = l2 * l2
len2 = l2.shape[0]
else:
m2 = torch.tensor([0], dtype=torch.float).cuda()
len2 = 0
# Similarly, compare negative batch elements against (subsampled) positive elements
if ln_neg>0 :
pos_expand = epoch_pos.view(-1,1).expand(-1, ln_neg).reshape(-1)
neg_expand = neg.repeat(epoch_pos.shape[0])
diff3 = neg_expand - pos_expand + gamma
l3 = diff3[diff3>0]
m3 = l3*l3
len3 = l3.shape[0]
else:
m3 = torch.tensor([0], dtype=torch.float).cuda()
len3=0
if (torch.sum(m2)+torch.sum(m3))!=0 :
res2 = torch.sum(m2)/max_pos+torch.sum(m3)/max_neg
#code.interact(local=dict(globals(), **locals()))
else:
res2 = torch.sum(m2)+torch.sum(m3)
res2 = torch.where(torch.isnan(res2), torch.zeros_like(res2), res2)
return res2
Tenga en cuenta que hay algunos parámetros adicionales. Estamos pasando en el set de entrenamiento desde la última época . Dado que todo el conjunto de entrenamiento no cambia mucho de una época a la siguiente, la función de pérdida puede comparar cada predicción nuevamente un conjunto de entrenamiento ligeramente desactualizado. Esto simplifica la depuración, y parece beneficiar el rendimiento ya que la época de 'fondo' no está cambiando de un lote a otro.
Del mismo modo, γ es un cálculo costoso. Nuevamente usamos el truco de submuestreo, pero aumentamos el tamaño de las submuestras a ~ 10,000 para garantizar una estimación precisa. Para mantener el rendimiento recortando, recomputamos este valor solo una vez por época. Aquí está la función para hacer eso:
def epoch_update_gamma(y_true,y_pred, epoch=-1,delta=2):
"""
Calculate gamma from last epoch's targets and predictions.
Gamma is updated at the end of each epoch.
y_true: `Tensor`. Targets (labels). Float either 0.0 or 1.0 .
y_pred: `Tensor` . Predictions.
"""
DELTA = delta
SUB_SAMPLE_SIZE = 2000.0
pos = y_pred[y_true==1]
neg = y_pred[y_true==0] # yo pytorch, no boolean tensors or operators? Wassap?
# subsample the training set for performance
cap_pos = pos.shape[0]
cap_neg = neg.shape[0]
pos = pos[torch.rand_like(pos) < SUB_SAMPLE_SIZE/cap_pos]
neg = neg[torch.rand_like(neg) < SUB_SAMPLE_SIZE/cap_neg]
ln_pos = pos.shape[0]
ln_neg = neg.shape[0]
pos_expand = pos.view(-1,1).expand(-1,ln_neg).reshape(-1)
neg_expand = neg.repeat(ln_pos)
diff = neg_expand - pos_expand
ln_All = diff.shape[0]
Lp = diff[diff>0] # because we're taking positive diffs, we got pos and neg flipped.
ln_Lp = Lp.shape[0]-1
diff_neg = -1.0 * diff[diff<0]
diff_neg = diff_neg.sort()[0]
ln_neg = diff_neg.shape[0]-1
ln_neg = max([ln_neg, 0])
left_wing = int(ln_Lp*DELTA)
left_wing = max([0,left_wing])
left_wing = min([ln_neg,left_wing])
default_gamma=torch.tensor(0.2, dtype=torch.float).cuda()
if diff_neg.shape[0] > 0 :
gamma = diff_neg[left_wing]
else:
gamma = default_gamma # default=torch.tensor(0.2, dtype=torch.float).cuda() #zoink
L1 = diff[diff>-1.0*gamma]
ln_L1 = L1.shape[0]
if epoch > -1 :
return gamma
else :
return default_gamma
Aquí está la vista del helicóptero que muestra cómo usar las dos funciones a medida que avanzamos en épocas, luego en lotes:
train_ds = CatDogDataset(train_files, transform)
train_dl = DataLoader(train_ds, batch_size=BATCH_SIZE)
#initialize last epoch with random values
last_epoch_y_pred = torch.tensor( 1.0-numpy.random.rand(len(train_ds))/2.0, dtype=torch.float).cuda()
last_epoch_y_t = torch.tensor([o for o in train_tt],dtype=torch.float).cuda()
epoch_gamma = 0.20
for epoch in range(epoches):
epoch_y_pred=[]
epoch_y_t=[]
for X, y in train_dl:
preds = model(X)
# .
# .
loss = roc_star_loss(y,preds,epoch_gamma, last_epoch_y_t, last_epoch_y_pred)
# .
# .
epoch_y_pred.extend(preds)
epoch_y_t.extend(y)
last_epoch_y_pred = torch.tensor(epoch_y_pred).cuda()
last_epoch_y_t = torch.tensor(epoch_y_t).cuda()
epoch_gamma = epoch_update_gamma(last_epoch_y_t, last_epoch_y_pred, epoch)
#...
Se puede encontrar un ejemplo de trabajo completo aquí, ejemplo.py para una estrella de salto más rápida que puede bifurcar este núcleo en Kaggle: kernel
A continuación trazamos el rendimiento de ROC-Star contra el mismo modelo usando BCE. La experiencia muestra que ROC-Star a menudo se puede cambiar a cualquier modelo usando BCE con una buena oportunidad de aumentar el rendimiento.