A Semana que Passei Caçando um NaN
Um dia minha loss de treinamento deixou de ser um número. Não estava alta, não estava instável — apenas NaN, a pequena bandeira que um computador levanta quando lhe pedem para fazer uma aritmética que não tem resposta. E então levei a maior parte de uma semana para descobrir o porquê, porque o lugar onde o sintoma apareceu não estava nem perto do lugar onde o problema realmente vivia.
A falha mais desinformativa que existe
Um NaN é, de certa forma, quase a pior mensagem de erro possível, porque mal chega a ser uma mensagem. Um crash ao menos aponta para uma linha. Uma exceção carrega um tipo e um rastreamento. Um NaN apenas envenena silenciosamente tudo o que toca e segue em frente: some-o a qualquer coisa e você obtém NaN, multiplique-o por qualquer coisa e você obtém NaN, e com a agregação comum de ponto flutuante, calcule a média de um milhão de números perfeitamente bons com um NaN no meio deles e a média inteira sai como NaN.
Então, quando finalmente surgiu na minha loss — alta, definitiva, interrompendo o treinamento — ela me disse quase nada sobre onde o valor ruim de fato havia entrado. O sintoma estava o mais distante possível da causa, com uma longa cadeia de aritmética entre os dois, cada elo da qual passava fielmente o veneno adiante sem reclamar.
Depurar um NaN é arqueologia
Neste caso, não havia maneira limpa de depurá-lo para a frente. O NaN que você consegue ver é o fim de uma história; o valor ruim que a iniciou aconteceu antes, em algum lugar a montante, e desde então foi lavado através de camada após camada de computação. Então você trabalha para trás. Você adiciona verificações em cada estágio — o valor é finito aqui? aqui? aqui? — percorrendo o pipeline ao contrário, procurando o primeiro lugar onde um número real se tornou um número irreal.
É um trabalho lento, sem glamour, e parece menos programação do que escavação: cavar de volta através de estratos de transformações para encontrar a camada exata onde as coisas deram errado. Uma semana da minha vida foi para essa escavação.
O culpado: uma transformação aplicada duas vezes
O que acabei desenterrando era quase ofensivamente pequeno. Em algum lugar do meu preprocessing, uma transformação estava sendo aplicada a um valor — e então, por causa de uma refatoração que eu havia feito semanas antes, aplicada a ele uma segunda vez. Cada aplicação estava perfeitamente correta por si só. A primeira fazia exatamente o que deveria. A segunda pegava o valor já transformado e o empurrava para algum lugar que a matemática não conseguia mais acompanhar, para uma região onde a operação simplesmente não tem resultado definido. Saía dali algo que não era um número, e lá ia ele, a jusante, para emergir uma semana depois como uma loss morta.
O bug não era uma transformação errada. Era uma transformação certa que rodou uma vez a mais, porque duas partes diferentes do código acreditavam, cada uma, ser responsável por ela.
Por que o preprocessing gera exatamente esse bug
Esse tipo de coisa não é azar; é o que pipelines de preprocessing tendem a produzir quando você os deixa se espalhar. A preparação de dados tem um jeito de se espalhar por uma base de código — um pouco de reshaping aqui, um pouco de reescalonamento ali, aplicados em lugares diferentes por funções diferentes escritas em momentos diferentes. E no momento em que a mesma transformação pode ser alcançada por dois caminhos diferentes, cresce o risco de que algum valor percorra ambos e seja transformado duas vezes.
Nada em nenhum dos caminhos está errado isoladamente. É isso que torna tão difícil de enxergar. O bug não vive em nenhum pedaço isolado de código; ele vive na relação entre dois pedaços, nenhum dos quais sabe do outro.
A correção: um lugar, uma vez
O conserto não foi um patch na linha ruim. Foi estrutural. Tirei as transformações dos lugares espalhados onde haviam se acumulado e as coloquei em um único estágio central com uma regra clara: cada valor é transformado aqui, exatamente uma vez, e em nenhum outro lugar. Depois disso, não havia segundo caminho para nada percorrer. O NaN específico desapareceu, mas o mais importante é que isso reduziu o número de caminhos onde a aplicação inconsistente podia acontecer — não apenas consertando este bug, mas encolhendo o espaço no qual seus irmãos poderiam aparecer.
Esse é o tipo de correção que vale a pena perseguir. Aplicar um patch no único valor ruim teria feito este NaN sumir. Centralizar a transformação tornou muitas versões futuras deste NaN muito menos prováveis também.
A lição de verdade: cuide da distância entre causa e sintoma
O que tornou esse bug caro não foi sua dificuldade. Foi a distância. A falha apareceu em um lugar, a causa estava sentada em um lugar completamente diferente, e nada os conectava visivelmente exceto uma longa e silenciosa cadeia de matemática que diligentemente carregou um valor ruim de uma ponta à outra.
As defesas que de fato ajudam contra esse tipo de distância não são esperteza. São estrutura e verificações: centralize as operações perigosas para que haja apenas um lugar onde possam dar errado, e valide os valores nas fronteiras — verifique que um número é finito perto do momento em que é produzido, não uma semana depois, quando ele finalmente detona. Um valor ruim capturado onde nasce pode custar minutos. O mesmo valor ruim capturado onde explode pode custar uma semana.
— Sem sinais, sem retornos, isto não é aconselhamento de investimento.