Em texto anterior comentei que era importante definir corretamente o número de camadas e o número de nós neurais em cada camada oculta. Disse até que era o “pulo do gato”. Gostaria de comentar a respeito de algumas regras básicas para isso. Confesso que ao planejar o que escreveria aqui, fui tomado por algumas dúvidas. A principal delas: por que um usuário de tecnologia se interessaria por isso? Afinal, na maioria das vezes não precisamos saber como as coisas funcionam. A aspirina, por exemplo, foi descoberta em 1897 e ainda assim ninguém explicou de forma convincente como funcionava até 1995.
Essa abordagem de descoberta – baseada em respostas primeiro, explicações depois – acumula o que é chamado de dívida intelectual [1]. É possível descobrir algo que funciona sem saber porque funciona e, em seguida, colocar esse entendimento intuitivo em uso, assumindo que o mecanismo oculto que a faz funcionar será descoberto mais tarde. Em alguns casos, pagamos essa dívida intelectual rapidamente. Mas, em outros, como no caso da aspirina, deixamos que essa “dívida” se prolongue durante décadas, contando com um conhecimento que não é totalmente entendido.
No entanto, à medida que novas técnicas em inteligência artificial – especificamente as ligadas à machine learning – aumentam nossa “linha de crédito” intelectual coletiva, a conta pode não fechar. Os sistemas baseados em machine learning funcionam identificando padrões em “oceanos” de dados. Usando esses padrões, esses sistemas arriscam respostas para perguntas vagas e abertas. Alimente uma deep learning com imagens rotuladas de gatos e outros objetos “não felinos”, e ela aprenderá a distinguir os gatos de tudo o mais; dê a esse mesmo sistema acesso a registros médicos, e ele pode tentar prever a probabilidade de morte de um novo paciente em um hospital. E, no entanto, a maioria dos sistemas baseados em machine learning não revela mecanismos causais. Eles são “motores” de correlação estatística. Não podem explicar porque acham que alguns pacientes têm maior probabilidade de morrer, porque não “pensam” em nenhum sentido coloquial da palavra – apenas respondem. À medida que começamos a integrar o entendimento intuitivo de sistemas baseados em machine learning em nossas vidas, iremos, coletivamente, acumular mais e mais dívidas intelectuais.
Se a maioria dos modelos de machine learning não podem oferecer razões para seus resultados, então é muito difícil para um não-especialista (por exemplo, um usuário de tecnologia) saber se eles falharam. Erros podem até ser raros em um sistema bem treinado, como os sistemas deep learning corretamente ajustados, mas é importante saber como eles são configurados e depurados. Isso porque seus resultados podem ser estimulados intencionalmente por alguém que saiba exatamente que tipo de dados inserir. Se você alimentar um sistema desses com dados enviesados, qual será o tipo de resultado esperado?
Estas são as minhas considerações a respeito do motivo pelo qual você deveria continuar lendo. Se você achar que vale a pena, vamos em frente. Bom, primeiro vamos a uma rápida recapitulação.
Deep learning é uma rede neural artificial, que, por sua vez, possui dois hiperparâmetros principais que controlam a sua arquitetura, os já citados número de camadas e número de nós em cada camada oculta. Para clarificar, hiperparâmetro é um parâmetro cujo valor é usado para controlar o aprendizado do computador em um modelo de machine learning. Embora não influenciem o desempenho do modelo, afetam a velocidade e a qualidade do processo de aprendizagem.
Quando você configura a sua rede neural, precisa especificar valores para esses parâmetros. Quando estamos trabalhando em uma rede de camada única (single-layer network), esta configuração não tem importância (afinal, trata-se de uma única camada). Mas em uma rede de múltiplas camadas (multiple-layer network), o caso já não é bem esse. De maneira geral, uma rede de multicamadas é composta por três tipos de camadas: de entrada, oculta e de saída [2]. A camada de entrada (Input Layer), é responsável pelas variáveis de entrada. Estas variáveis são os dados utilizados para alimentar a rede. Muitas vezes, chamamos a Input Layer de camada visível. A camada oculta (Hidden Layer) é a camada de nós neurais entre as camadas de entrada e saída. Pode haver uma ou mais dessas camadas. Por fim, a camada de saída (Output Layer), é a camada de nós que produz as variáveis de saída.
Outras definições importantes são a dos termos usados para descrever a forma e a capacidade de uma rede neural, a saber: tamanho, o número de nós no modelo; largura, o número de nós em uma camada específica; profundidade, o número de camadas em uma rede neural; capacidade, o tipo ou estrutura de funções que podem ser aprendidas por uma configuração de rede – por vezes chamada de “capacidade representacional”; e arquitetura, o arranjo específico das camadas e nós na rede.
Após esse misto de recapitulação e definições iniciais, vamos ao que interessa: como contar as camadas?
Tradicionalmente, há certa discordância a respeito de como fazer isso. A discordância gira em torno do seguinte ponto: contar ou não a camada de entrada? Há argumento que pondera que ela não deveria ser contada porque as entradas (a.k.a. os dados) não são ativas. Portanto, a camada lida com variáveis que são simplesmente usadas como entrada de dados. Usaremos esta convenção, também recomendada por Reed e Marks no livro “Neural Smithing” [2]. Desse modo, uma rede de multicamadas que possui uma camada de entrada, uma camada oculta e uma camada de saída é uma rede de 2 camadas.
A estrutura da rede de multicamadas pode ser resumida usando uma notação simples, que convenientemente resume o número de camadas e o número de nós em cada camada. O número de nós em cada camada é especificado como um número inteiro, na ordem da camada de entrada para a camada de saída, com o tamanho de cada camada separado por um caractere de barra (“/”). Apesar de não considerarmos a camada de entrada na contagem das camadas da rede, suas variáveis precisam ser consideradas na notação. Voltando ao exemplo da nossa rede de 2 camadas, se ela possuísse duas variáveis na camada de entrada, uma camada oculta com nove nós neurais e uma camada de saída com dois nós, ela seria descrita pela notação como 2/9/2.
Antes de examinarmos quantas camadas especificar, é importante refletir sobre porque desejaríamos ter várias camadas. Uma rede de multicamadas pode ser usada para representar regiões convexas. Isso quer dizer que ela pode aprender a desenhar formas em torno de exemplos (os dados de entrada) em um espaço geométrico de alta dimensão. Como resultado, a rede consegue separar e classificar esses exemplos, superando a limitação da separabilidade linear, como no caso do problema XOR visto no texto anterior.
Uma importante descoberta teórica feita por Lippmann é descrita como teorema matemático em seu artigo de 1987 [3]. Ela mostra que uma rede com duas camadas ocultas já é suficiente para criar regiões de classificação em qualquer forma geométrica desejada. Apesar de nos fornecer uma informação relevante, deve-se observar que nenhuma indicação a respeito da quantidade de nós a serem usados em cada camada ou como a rede deve aprender os pesos é fornecida. Posteriormente, outra descoberta teórica e a sua prova matemática mostraram que as redes neurais são aproximadores universais e que bastava uma camada oculta para aproximar qualquer função desejada [3].
Novamente não temos ideia de quantos nós neurais usar na única camada oculta para um determinado problema, nem como aprender ou definir seus pesos de maneira eficaz. Além disso, muitos contra-exemplos foram apresentados de funções que não podem ser aprendidas diretamente por meio de uma única camada oculta ou que requerem um número infinito de nós neurais [2]. Mesmo para as funções que podem ser aprendidas por meio de uma camada oculta suficientemente grande, pode ser mais eficiente fazer isso com duas (ou mais) camadas ocultas.
É preciso ter em mente que modelos de deep learning são estocásticos por natureza, ou seja, usam aleatoriedade enquanto se ajustam a um conjunto de dados, com pesos iniciais aleatórios e embaralhamento aleatório de dados durante cada período de treinamento (chamado de epoch). Isso significa que, cada vez que o mesmo modelo se ajusta aos mesmos dados, ele pode fornecer resultados diferentes em cada epoch.
A seguir, comento a respeito de quatro abordagens usadas para resolver essa sinuca-de-bico. Poderia ser cinco, mas não indico configurar uma rede via intuição. Acredite, é uma abordagem considerada por muitos. Estes, defendem que essa intuição pode vir da experiência com o domínio, experiência com problemas de modelagem com redes neurais ou alguma mistura dos dois. Pela minha experiência, intuições frequentemente são invalidadas por meio de experimentos. Por isso, sugiro que se faça essa definição heurísticamente por meio de experimentação sistemática. Uma heurística, ou técnica heurística, é qualquer abordagem para solução de problemas que usa um método prático (ou vários atalhos) para produzir soluções que podem não ser ótimas, mas são suficientes para resolver o problema.
A primeira forma de experimentação sistemática, é estimar a habilidade geral do modelo por meio do controle da sua variância. Uma forma de se fazer isso é dividindo os dados em duas partes, ajustar um modelo ou configuração de modelo específico na primeira parte dos dados e aplicar o modelo ajustado no resto, avaliando a sua habilidade por meio de testes estatísticos. Esse procedimento é chamado de divisão treino-teste (train-test split). Os resultados estatísticos (por exemplo, a acuracidade do modelo) são usados como uma estimativa de quão bem ajustado ele está e indica se o mesmo terá um desempenho similar ao ser aplicado a novos dados. Um teste estatístico bastante usado para se fazer o controle de variância é o teste de validação cruzada k-fold (k-fold cross validation). Esta é uma técnica que divide sistematicamente os dados disponíveis em dobras (k-folds), que são subconjuntos mutuamente exclusivos do mesmo tamanho e, a partir daí, um subconjunto é utilizado para teste e os subconjuntos restantes são utilizados para estimar os parâmetros. Este processo é realizado k vezes, alternando de forma circular o subconjunto de teste. O número correspondente ao k é definido pela pessoa que configura a rede, por exemplo, se escolher um teste k-5 (k = 5), o processo é conduzido em 5 dobras, como na Figura 1:
O segundo tipo de experimentação sistemática, é fazer um controle de estabilidade do modelo. Uma boa maneira de se fazer isso é adicionando uma nova fonte de aleatoriedade, como por exemplo, usar a mesma aleatoriedade sempre que o modelo for ajustado por meio de uma técnica chamada “semente aleatória” (Random Seed). Ela é feita fixando-se o que chamamos de uma “semente” de número aleatório. Traduzindo, é definido um número inteiro que é usado pelo sistema como um “pontapé inicial” para os cálculos. A partir daí, vai-se avaliando os resultados e ajustando o modelo. É muito usado quando se precisa de resultados reproduzíveis, como por exemplo em experimentos científicos que precisam ser avaliados por outros pesquisadores. Outra abordagem, esta mais robusta, é criar um modelo não-estocástico, incorporá-lo à rede deep learning, e testar o procedimento em repetidas epochs (pelo menos 30 delas). A cada epoch, calcula-se a média grande do modelo para estimar a sua habilidade.
O terceiro tipo de experimentação sistemática é o aprofundamento da rede deep learning. Este tipo de experimentação é sugerida por Goodfellow, Bengio e Courville [4], em que argumentam que é benéfico a escolha de usar redes neurais profundas como um argumento estatístico nos casos em que tanta profundidade não é necessária. A ideia aqui é aumentar o número de camadas ocultas como abordagem heurística até encontrar o melhor resultado possível. Para saber se chegou lá, basta que não haja variação visível de melhora com a inclusão de uma nova camada.
O quarto (e último) tipo de experimentação pode ser resumido pela frase “pedir ideias emprestadas”. É uma abordagem simples mas demorada, porque envolve levantar na literatura artigos científicos que descrevem o uso de redes multicamadas em instâncias semelhantes ao problema que se quer resolver. A ideia é observar a configuração das redes usadas nesses papers e usá-las como ponto de partida para as próprias configurações.
Há outros tipos de experimentações possíveis, mas as quatro citadas são as que prefiro e mais recomendo. Vejamos agora o que se pode fazer quando é preciso fazer um debugging (também chamada de depuração, em português) na rede neural. Este, é o processo de se identificar e eliminar erros dos códigos escritos. Depois de algum tempo lidando com algumas redes neurais “temperamentais”, coletei alguns métodos que me ajudam a fazer o debugging com um pouco menos de sofrimento. Como comentei anteriormente, são métodos heurísticos que, a longo prazo, ajudaram a diminuir o tempo que gasto depurando redes neurais.
Muitas vezes, o gradiente é a causa do problema. Gradiente é um termo comumente usado em otimização e aprendizado de máquina. Mas para entender o que é um gradiente, é necessário entender o que é uma derivada, incluindo como calcular uma derivada e interpretar o valor. A compreensão da derivada é diretamente aplicável à compreensão de como calcular e interpretar gradientes usados em machine learning. Por isso, vou deixar para abordar esse assunto em um outro momento. No entanto, existem outros métodos úteis para a depuração da rede neural.
O primeiro deles é checar constantemente o progresso do treinamento da sua rede. Isto ajudará a economizar o seu tempo. Por exemplo, suponha que você esteja treinando uma rede para jogar o jogo Snake. Em vez de treinar a rede por alguns dias seguidos e depois verificar se ela aprendeu alguma coisa, é mais interessante eleger um período bem menor (digamos, a cada dez ou quinze minutos) para executar o jogo com os pesos aprendidos até o momento. Depois de algumas horas nesse processo, se notar que a cobrinha (ou como falamos em IA, o agente) está fazendo a mesma coisa todas as vezes e recebendo recompensa zero, pode ter certeza de que há algo errado e que é preciso revisar os códigos da rede.
O segundo método pode ser resumido pela frase “não confie tanto em resultados quantitativos”. Explico, se você olhar apenas para resultados quantitativos, corre o risco de perder informações úteis de depuração. Por exemplo, ao treinar uma rede para fazer a tradução de idiomas, é preciso levar em consideração se a palavra (ou texto) traduzida(o) realmente faz sentido e não apenas verificar se a função de avaliação está diminuindo. É preciso eleger também parâmetros qualitativos. A razão pela qual não devemos confiar apenas em resultados quantitativos é dupla. Primeiro, pode haver um erro na sua função de avaliação. Se você apenas se basear no número gerado pela função de avaliação defeituosa, pode levar semanas até perceber que há algo errado. Em segundo lugar, você pode encontrar padrões de erros na saída de sua rede neural que não são relatados quantitativamente. No caso de uma rede neural que faz traduções, pode-se perceber que uma determinada palavra está sempre sendo mal traduzida. As observações de parâmetros qualitativos, por sua vez, auxiliam a encontrar bugs no seu código, que de outra forma passariam despercebidos.
Outra maneira de determinar se seu código tem um bug ou se os dados são difíceis de treinar, é ajustar primeiro a rede a um conjunto de dados menor. Por exemplo, em vez de ter 100.000 exemplos de treinamento em seu conjunto de dados, reduza-o para que haja apenas 100 ou até mesmo 1 exemplo de treinamento. No aprendizado de máquina supervisionado, o modelo é “treinado” em um conjunto predefinido de “exemplos de treinamento”, o que facilita a sua capacidade (a do modelo) de chegar a uma conclusão precisa ao receber novos dados. Nesses casos, é esperado que a rede neural seja capaz de ajustar os dados extremamente bem, especialmente no caso de 1 exemplo de treinamento. Se a sua rede ainda apresenta muitos erros de teste mesmo com poucos exemplos, certamente algo está errado com o código. No caso do aprendizado de máquina não supervisionado, este método não faz muito sentido porque o modelo recebe “um monte de dados” e deve encontrar padrões e relacionamentos neles por conta própria. Aqui, minha sugestão é usar o segundo método, descrito no parágrafo anterior. Há ainda um terceiro paradigma básico em machine learning chamado aprendizagem por reforço (reinforcement learning), em que agentes são treinados em um determinado ambiente com base na noção de recompensa cumulativa (basicamente o modelo recebe pontos se agir corretamente e perde pontos se falhar). Neste caso, é preciso redobrar os cuidados ao usar o segundo método porque a rede pode encontrar caminhos para ganhar mais recompensas que não foram levados em consideração. O exemplo clássico disto é o da rede treinada para jogar CoastRunners, que ao invés de completar o circuito percebeu que era mais recompensada se agisse de maneira destrutiva (veja o vídeo aqui).
Perceba que o barco está completamente fora do circuito, indo na direção errada e pegando fogo repetidamente porque bate no pier e em outros barcos continuamente. A brecha encontrada pela IA foi permitida pelos powerups (que elevam a pontuação), colocados pelos desenvolvedores ao longo do circuito para estimular os jogadores a seguirem a rota. Nenhum ser-humano percebeu que os powerups no meio do lago eram suficientes para vencer o jogo e nessa parte da rota, a IA está recebendo 3 powerups durante cada ciclo. Com isso, os desenvolvedores da rede decidiram recompensá-la apenas com base nesse tipo de upgrade. Não adicionaram nenhum incentivo para que a IA seguisse na pista, ficasse à frente na competição ou mesmo completasse a corrida em um tempo mínimo. Com essa estratégia louca, a IA era tão bem-sucedida que batia os jogadores humanos de forma consistente com 20% ou mais pontos [5]. O erro foi tão sério, que se extrapolarmos o conceito, o mundo pode, literalmente, acabar desta forma. Para minimizar esse problema, tenho me animado com uma estratégia alternativa chamada aprendizagem por reforço inverso (inverse reinforcement learning – IRL). Os sistemas IRL são projetados não para otimizar uma determinada métrica, mas sim para aprender seu próprio modelo do que os humanos valorizam e, em seguida, otimizar o comportamento. Normalmente, isto é feito inferindo preferências humanas com base na observação do nosso comportamento. O loophole, nesse caso, são os tipos de comportamentos usados para alimentar a rede.
O quarto método é reduzir a profundidade da rede, ou seja, reduzir o número de camadas. Isso tem o benefício adicional de acelerar o tempo de treinamento. Se a rede menos profunda for bem-sucedida onde a rede original falhou, isso sugere que a arquitetura da rede original é muito complicada. Se tanto a rede mais simples quanto a original falharem, então pode-se concluir que há um bug no código.
Por fim, há o método de se comparar códigos escritos do zero com códigos pré-formatados de uma estrutura de aprendizado de máquina (machine learning framework). Uma estrutura de aprendizado de máquina é uma interface que permite aos desenvolvedores criarem e implantarem modelos de aprendizado de máquina com mais rapidez e facilidade. Duas estruturas bastante usadas por quem programa em Python são TensorFlow e PyTorch. A ideia é verificar o código escrito do zero programando-se a mesma arquitetura de rede em uma estrutura de aprendizado de máquina. Em seguida, coloca-se instruções de impressão na implementação original e na versão da estrutura e compara-se as saídas até descobrir onde a diferença nas instruções de impressão começa a ocorrer. É aí que está o erro. Para tangibilizar, digamos que você tenha uma rede com dez camadas e o erro esteja na sétima camada. Quando a saída da primeira camada da sua rede é imprimida e comparada com a saída da primeira camada na implementação da estrutura, se perceberá que elas são iguais. Então, passa-se para a comparação das saídas da segunda camada. Verifica-se a mesma coisa. Em seguida, passa-se para a terceira camada, e assim por diante, até ver as diferenças começarem a ocorrer na sétima camada. Com isso, pode-se inferir que a sétima camada é o problema. Observe que este método só funcionará para a primeira iteração da rede, porque a partir da segunda iteração os pontos de partida serão diferentes (lembre-se que comentei anteriormente que modelos de deep learning são estocástico por natureza e usam embaralhamento aleatório).
O exemplo acima assume que o erro ocorre na passagem para a frente (forward passing) do aprendizado. A mesma ideia pode ser usada se o erro ocorrer durante a retropropagação (backpropagation). Neste caso, imprime-se os gradientes para os pesos camada por camada, começando da última camada, até se ver uma diferença nos gradientes da estrutura e nos gradientes da sua implementação.
Sei que um texto como este não vai ajudar ninguém a sair por aí configurando ou depurando redes de deep learning. Espero apenas que contribua um pouco para aliviar a dívida intelectual da IA. É claro que reconheço que nem toda dívida é igualmente problemática. Se uma IA desenvolver uma nova receita de bolo, faz mais sentido sentar e apreciar o bolo. Mas, no caso de uma IA começar a fazer previsões e recomendações de saúde (ou destruir os barcos à volta), creio que você vai preferir estar totalmente informada(o) para poder avaliar o resultado.
[1] Kaiser, Gary. Intellectual Debt: The Hidden Costs of Machine Learning. Dynatrace News, https://www.dynatrace.com/news/blog/intellectual-debt-the-hidden-costs-of-machine-learning/. December 2019.
[2] Reed, Russell D. and Marks, Robert J.. Neural Smithing: Supervised Learning in Feedforward Artificial Neural Networks. : MIT Press, 1999.
[3] Lippmann, Richard P. An introduction to computing with neural nets. in IEEE ASSP Magazine, vol. 4, no. 2, pp. 4-22, Apr 1987, doi: 10.1109/MASSP.1987.1165576.
[4] Ian Goodfellow, Yoshua Bengio and Aaron Courville: Deep Learning (Adaptive Computation and Machine Learning), MIT Press, Cambridge (USA), 2016.[5] Clark, Jack and Amodei, Dario. Faulty Reward Functions in the Wild. OpenAI, https://openai.com/blog/faulty-reward-functions/ . December 2016.
[5] Clark, Jack and Amodei, Dario. Faulty Reward Functions in the Wild. OpenAI, https://openai.com/blog/faulty-reward-functions/ . December 2016.