Para classificação binária. Todo mundo adora a área sob a métrica da curva (AUC), mas ninguém a atinge diretamente em sua função de perda. Em vez disso, as pessoas usam uma função de proxy como entropia cruzada binária (BCE).
Isso funciona razoavelmente bem, na maioria das vezes. Mas ficamos com uma pergunta incômoda: poderíamos obter uma pontuação mais alta com uma função de perda mais próxima da natureza da AUC?
Parece provável, já que o BCE realmente tem muito pouca relação com a AUC. Houve muitas tentativas de encontrar uma função de perda que tem como alvo mais diretamente a AUC. (Uma tática comum é alguma forma de função de perda de classificação, como a perda de dobradiça.) Na prática, no entanto, nenhum vencedor claro jamais surgiu. Não houve um desafio sério para o BCE.
Há também considerações além do desempenho. Como a BCE é essencialmente diferente da AUC, a BCE tende a se comportar mal no trecho final do treinamento, onde estamos tentando orientá -lo em direção à maior pontuação da AUC.
Uma boa parte da otimização da AUC acaba ocorrendo no ajuste de hiper-parâmetros. A parada precoce se torna uma necessidade desconfortável, pois o modelo pode divergir acentuadamente a qualquer momento de sua pontuação alta.
Gostaríamos de uma função de perda que nos dê pontuações mais altas e menos problemas.
Apresentamos essa função aqui.
Minha definição de trabalho favorita de AUC é a seguinte: vamos chamar de rótulos de classe binária de "preto" (0) e "branco" (1). Escolha um elemento preto aleatoriamente e deixe X ser seu valor previsto. Agora escolha um elemento branco aleatório com valor y . Então,
AUC = a probabilidade de que os elementos estejam na ordem certa. Isto é, x < y .
É isso. Para qualquer conjunto de pontos como o conjunto de treinamento, podemos obter essa probabilidade fazendo um cálculo da força bruta. Digitalize o conjunto de todos os pares pretos/brancos possíveis e conte a parte que é ordenada à direita.
Podemos ver que a pontuação da AUC não é diferenciável (uma curva suave em relação a qualquer X ou Y. ) Pegue um elemento (qualquer cor) e mova -o um pouco o suficiente para que não atinja um elemento vizinho. A AUC permanece a mesma. Uma vez que o ponto atravessa um vizinho, temos a chance de lançar uma das comparações x <y - que altera a AUC. Portanto, a AUC não faz transições suaves.
Isso é um problema para redes neurais, onde precisamos de uma função de perda diferenciável.
Por isso, decidimos encontrar uma função diferenciável que seja próxima possível da AUC.
Eu desenterrei a literatura existente e não encontrei nada que funcionasse na prática. Finalmente, me deparei com uma peça curiosa de código que alguém havia checado na base de código Tflearn.
Sem fanfarra, prometeu libertação diferenciável da BCE na forma de uma nova função de perda.
( Não tente, explode .): Http://tflearn.org/obndives/#roc-auc-score
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)
Não funciona nada. (Explodir é realmente o menor de seus problemas). Mas menciona o artigo em que se baseava.
Embora o artigo seja antigo , que remonta a 2003, descobri que, com um pouco de trabalho - alguma extensão da matemática e codificação cuidadosa -, ele realmente funciona. É Uber-Fast, com velocidade comparável ao BCE (e assim como vetorizável para uma GPU/MPP) *. Nos meus testes, ele fornece pontuações mais altas da AUC que a BCE, é menos sensível à taxa de aprendizado (evitando a necessidade de um agendador nos meus testes) e elimina inteiramente a necessidade de parar cedo.
OK, vamos recorrer ao artigo original: otimizando o desempenho do classificador por meio de uma aproximação à estatística Wilcoxon - Mann - Whitney .
Os autores, Yan et. Al , motive a discussão escrevendo a pontuação da AUC de uma forma específica. Lembre-se do nosso exemplo em que calculamos a AUC fazendo uma contagem de força bruta sobre o conjunto de possíveis pares de pretos/brancos para encontrar a parte ordenada à direita. Seja B o conjunto de valores pretos e o conjunto de valores brancos. Todos os pares possíveis são fornecidos pelo produto cartesiano b x w . Para contar os pares ordenados à direita, escrevemos:
Isso é realmente apenas forçando a notação matemática para dizer 'conte os pares ordenados à direita'. Se dividirmos essa soma pelo número total de pares, | B | * | W |, temos exatamente a métrica da AUC. (Historicamente, isso é chamado de estatística normalizada de Wilcoxon-Mann-Whitney (WMW).)
Para fazer uma função de perda a partir disso, poderíamos apenas virar a comparação x <y com x> y para penalizar os pares ordenados por erros. O problema, é claro, é o salto descontínuo quando X cruza Y.
Yan et. ALS SURVEYS - E depois rejeita - as arestas anteriores usando aproximações contínuas para a função Etapa (Heaviside), como uma curva sigmóide. Então eles tiram isso de um chapéu:
Yann conseguiu esta fórum, aplicando uma série de mudanças no WMW:
Vamos passar por isso por sua vez. 1 é claro o suficiente. Há um pouco mais de 2 do que encontra os olhos. Faz sentido intuitivo que pares ordenados por incorretamente com ampla separação devem receber uma perda maior. Mas algo interessante também está acontecendo à medida que a separação se aproxima de 0. A perda é zero linearmente, em vez de uma função de etapa. Então, nos livramos da descontinuidade.
De fato, se p fosse 1 e γ fosse 0, a perda seria simplesmente nosso velho amigo Relu (XY). Mas, neste caso, notamos um soluço, que revela a necessidade do expoente p . Relu não é diferenciável em 0. Isso não é um problema no papel mais acostumado de Relu como uma função de ativação, mas para nossos propósitos a singularidade em 0 terras diretamente sobre a coisa em que mais estamos interessados: os pontos em que os elementos brancos e pretos passam um ao outro.
Felizmente, elevar o Relu para um poder conserta isso. Relu^p com p> 1 é diferenciável em todos os lugares. Ok, então p> 1.
Agora, de volta a γ: γ fornece um 'preenchimento' que é imposto entre dois pontos. Penalizamos não apenas os pares ordenados errados, mas também pares ordenados à direita que estão muito próximos . Se um par ordenado à direita estiver muito próximo, seus elementos correm o risco de ser trocados no futuro pelo balanço aleatório de uma rede neural estocástica. A idéia é mantê -los separados até atingirem uma distância confortável.
E essa é a idéia básica, conforme descrito no artigo. Agora, como alguns refinamentos sobre γ e p.
Aqui quebramos um pouco com o papel. Yan et. Al parece um pouco sensível sobre o tema da escolha de γ e P, oferecendo apenas que P = 2 ou P = 3 parece bom e que γ deve estar entre 0,10 e 0,70. Yan essencialmente nos deseja sorte com esses parâmetros e se inclina.
Primeiro, corrigimos permanentemente P = 2, porque qualquer função de perda auto-respeitadora deve ser uma soma dos quadrados. (Uma razão para isso é que ele garante que a função de perda não seja apenas diferenciável, mas também convexa )
Segundo e mais importante, vamos dar uma olhada em γ. A heurística de 'de 0,10 a 0,70' parece estranha na face disso; Mesmo que as previsões tenham sido normalizadas em 0 <x <1, essa orientação parece excessiva, indiferente às distribuições subjacentes e apenas estranha.
Vamos derivar γ do conjunto de treinamento.
Considere o conjunto de treinamento e seus pares pretos/brancos, b x w . Existem | B || W | pares neste conjunto. Destes, | B | | W | AUC é ordenada à direita. Portanto, o número de pares ordenados é (1-AUC) | B | | W |
Quando γ é zero, apenas esses pares ordenados por erros estão em movimento (têm perda positiva.) Um γ positivo expandiu o conjunto de pares em movimento para incluir alguns pares que são ordenados à direita, mas muito próximos. Em vez de se preocupar com o valor numérico de γ, especificaremos quantos pares de perto que queremos desencadear:
Definimos uma constante δ que corrige a proporção de pares muito fechados em pares ordenados errados.
Corrigimos este δ durante todo o treinamento e atualizamos γ para estar em conformidade com ele. Para δ dado, encontramos γ de modo que
Em nossos experimentos, descobrimos que δ pode variar de 0,5 a 2,0 e 1,0 é uma boa opção padrão.
Então, definimos Δ a 1, p a 2 e esquecemos completamente γ,
Nossa função de perda (1) parece sem dúvida cara para calcular. Requer que digitalizemos todo o conjunto de treinamento para cada previsão individual.
Ignoramos esse problema com um ajuste de desempenho:
Suponha que estamos calculando a função de perda para um determinado ponto de dados branco, x . Para calcular (3), precisamos comparar x com todo o conjunto de treinamento de previsões negras, y . Tomamos um corte curto e usamos uma subamostra aleatória dos pontos de dados pretos. Se definirmos o tamanho da subamostra, digamos, 1000 - obtemos uma aproximação muito (muito) próxima à verdadeira função de perda. [1]
Raciocínio semelhante se aplica à função de perda de um ponto de dados preto; Usamos uma subamostra aleatória de todos os elementos de treinamento branco.
Dessa forma, subamostras brancas e pretas se encaixam facilmente na memória da GPU. Ao reutilizar a mesma subamostra em um determinado lote, podemos paralelizar a operação em lotes. Acabamos com uma função de perda que é tão rápida no BCE.
Aqui está a função de perda de lote em 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
Observe que existem alguns parâmetros extras. Estamos passando no conjunto de treinamento da última época . Como todo o conjunto de treinamento não muda muito de uma época para a seguinte, a função de perda pode comparar cada previsão novamente um conjunto de treinamento um pouco desatualizado. Isso simplifica a depuração e parece beneficiar o desempenho, pois a época do 'fundo' não está mudando de um lote para o outro.
Da mesma forma, γ é um cálculo caro. Novamente, usamos o truque de sub-amostragem, mas aumentamos o tamanho das subamostras para ~ 10.000 para garantir uma estimativa precisa. Para manter o recorte de desempenho, recompensamos esse valor apenas uma vez por época. Aqui está a função de fazer isso:
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
Aqui está a visão de helicóptero mostrando como usar as duas funções à medida que fazemos um loop em épocas e depois em 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)
#...
Um exemplo de trabalho completo pode ser encontrado aqui, exemplo.py para uma estrela de salto mais rápida, você pode bifurcar este kernel em kaggle: kernel
Abaixo, traçamos o desempenho do ROC-Star em relação ao mesmo modelo usando BCE. A experiência mostra que o ROC-Star geralmente pode ser trocado em qualquer modelo usando BCE com uma boa chance de aumentar o desempenho.