Site menu Redes neurais 101

Redes neurais 101

O livro "The Brain Makers" menciona a invenção das redes neurais nos anos 1940, e as razões pelas quais elas foram escanteadas pela komunidade de pesquisadores de inteligência artificial, só para voltar a ser objeto de interesse a partir dos anos 1990, e explodir a partir dos anos 2010. Tudo que é buzzword de IA (LLM, YOLO, ChatGPT, etc.) é movido a rede neural.

Minha intenção original era incluir uma pequena introdução sobre redes neurais na resenha do livro, mas seria um jabuti, e um bem grande, então vai num texto separado.

Disclaimer: não sou um pesquisador de IA, conheço sobre o assunto apenas o suficiente para ter uma ideia de como as coisas funcionam, e me convencer que é ciência, não macumba.

A inspiração

Conforme o nome sugere, as redes neurais foram inspiradas em tecidos nervosos de seres vivos, observados no microscópio. Os neurônios são simples células, conectadas por axônios e sinapses.

A conclusão do pesquisador foi que um neurônio individual não pode ser muito inteligente; ele recebe sinais de outros neurônios, faz algum cálculo simples com base nos sinais de entrada, e emite um sinal de saída. Além de burro, o neurônio vivo é extremamente lento, se comparado a um componente eletrônico.

Porém, um grande número de neurônios, conectados entre si de forma intrincada por inúmeras sinapses, poderiam constituir uma calculadora mais poderosa, que de alguma forma seria capaz de implementar funções bem mais complexas. (Considerando quão capazes são os seres vivos dotados de um cérebro, a hipótese prometia.)

Além disso, como todos os neurônios trabalham em paralelo, um grande número deles poderia formar um dispositivo computacional muito rápido, apesar dos componentes individuais serem lentos.

O neurônio artificial

O neurônio artificial é ainda mais simples:

Figura 1: O neurônio artificial. A saída é função das entradas e de um bias próprio do neurônio. A função de transferência é linear, enquanto a função de ativação é não-linear.

A função de transferência é apenas a combinação linear das entradas, ou seja, a soma das entradas multiplicadas por seus respectivos pesos. Mais o bias, que não depende de fatores externos.

Já a função de ativação faz com que a saída do neurônio esteja numa faixa previsível, independente da magnitude das entradas. Ela também introduz não-linearidade, que é o que permite a uma rede neural "aprender" funções mais complicadas que uma operação aritmética.

Na didática, uma função de ativação muito citada é a "curva sigmóide" ou "função logística" cuja fórmula é

𝜑(x) = 1 / (1 + e-x)

Figura 2: Curva da função logística. Crédito: geeksforgeeks.org.

O resultado fica sempre entre 0 e 1, para qualquer valor de x. Ela tem diversas características desejáveis: é monotônica, suave, diferenciável, e sua derivada possui uma fórmula simples e recursiva:

𝜑'(x) = 𝜑(x).(1 - 𝜑(x))

Muitas outras funções são boas candidatas a função de ativação, cada uma com suas vantagens e desvantagens. Hoje em dia, utiliza-se muito alguma variação da função ReLU, que é simplesmente max(0,x), muito fácil e rápida de calcular.

A função 𝜑(x) é assintótica, ou seja, nunca atinge realmente os valores extremos 0.0 ou 1.0. Quando desejamos que a saída do neurônio seja digital, ou seja, "verdadeira" ou "falsa", precisamos adotar uma convenção razoável, e.g. considerando abaixo de 0.1 como "falso" e acima de 0.9 como "verdadeiro".

Uma vez que o neurônio artificial é implementado em software, sua função de ativação tem de ser relativamente simples. (Neurônios vivos provavelmente implementam funções muito mais variadas e complexas. Como são "hardware", isto não causa prejuízo de velocidade.)

Como um neurônio "aprende"

Vamos começar com uma rede neural extremamente simples, com apenas um neurônio, uma entrada, uma saída, e função de ativação 𝜑(x):

Figura 3: Rede neural com apenas um neurônio, uma entrada, uma saída, e a missão de aprender uma simples função discriminadora. Os valores iniciais de peso e bias podem ser aleatórios.

O peso inicial w1 começa igual a +0.5, enquanto o bias B começa em +1.0. Mas poderia começar com outros valores. Em geral, os pesos são iniciados com valores aleatórios pequenos.

Desejamos que esta rede "aprenda" a seguinte função que detecta números negativos menores que -10:

Agora vem a fase de treinamento da rede neural. Para isto, precisamos de um conjunto de treinamento, ou seja, amostras com valores de entrada acompanhados das saídas esperadas. Neste caso, como a função é simples e linear, o conjunto poderia ter apenas duas amostras:

O processo de treinamento consiste em

As amostras de treinamento podem ser aplicadas em seqüência ou aleatoriamente. Neste exemplo, vamos aplicá-las em seqüência. Não basta fazer isso uma vez. A cada epoch, a rede neural é exercitada novamente com as mesmas amostras.

O processo de treinamento prossegue até a rede neural demonstrar ter "aprendido" a função, o que é constatado pelo erro tendendo a zero para todas as amostras de treinamento.

Primeira rodada, amostra A

Aplicando o valor da amostra A na entrada, nossa rede neural produz a seguinte saída:

Figura 4: Rede neural exposta à primeira amostra de treinamento. A saída esperada era 0.9, bem diferente da `prevista` pela rede no estado atual.

Como é a primeira rodada do primeiro exemplo, vamos fazar os cálculos um a um.

transferência = bias + (peso × entrada)
transferência = 1.0 + (0.5 × -10) = -4.0
saída = 𝜑(x) = 1 / (1 + e-x)
saida = 1 / (1 + e-(-4.0)) = 0.017986
derivada da saída ou d(saida) = 𝜑(x).(1 - 𝜑(x))
d(saida) = 0.017986 × (1 - 0.017986) = 0.017662
d(erro) = saída esperada - saída prevista
d(erro) = 0.017986 - 0.9 = -0.882014

Nesta primeira rodada, a saída possui um grande erro (-0.882014). Precisamos ajustar o peso w1 e o bias B de modo a diminuir este erro. Vamos usar as fórmulas e mais adiante discutir de onde elas vêm:

δ = d(erro) × d(saida)
δ = -0.882014 × 0.017663
δ = -0.015579

O valor δ é o erro total atribuído a um neurônio, a ser distribuído aos pesos e ao bias.

Δw1 = - η × δ × entrada
Δw1 = -1.0 × -0.015579 × -10
Δw1 = -0.155788

Δw1 é o ajuste que faremos no peso w1.

Já η é a taxa de aprendizado, que regula a magnitude do ajuste a cada rodada.

ΔB = - η × δ
ΔB = - 1.0 × -0.015579
ΔB = +0.015579

ΔB é o ajuste que faremos no bias B.

Vemos que tanto o peso quanto o bias receberam ajustes. Esta técnica de backpropagation é denominada gradient descent pois procura reduzir o erro determinando em que direção o erro "desce", e ajusta os pesos nessa direção.

Em tese, o gradient descent pode aprender qualquer função, mas isso presume ajustes infinitesimais e infinitas rodadas de treinamento. Na prática, isto significa que os ajustes nos pesos e biases devem sempre ser os menores possíveis.

Se tentarmos acelerar o aprendizado fazendo grandes ajustes por rodada, a rede nunca vai aprender. Ou vai achar uma mínima local, ou seja, vai empacar num estado em que ela "quase" aprendeu, mas não consegue melhorar além desse ponto.

Segunda rodada, amostra B

Atualizando peso e bias, e aplicando a mostra B na entrada da rede neural, temos a seguinte situação:

Figura 5: Rede neural exposta à segunda amostra de treinamento. A saída esperada era 0.1.

Segue abaixo a memória de cálculo desta rodada, produzida por este script Python:

rodada 1 bias 1.015579 peso 0.344212
    entrada 0.000000
    esperada 0.100000 saída 0.734110 erro' 0.634110
    derivada saida: 0.195192
    delta: 0.123773
    ajuste peso: -0.000000
    ajuste bias: -0.123773

Vemos que nesta rodada o ajuste de peso é zero. Isto se justifica porque a entrada do neurônio é igual a zero, portanto seja qual for o peso w1, ele não tem influência na saída, e não faz sentido ajustá-lo. Todo o erro desta rodada é "culpa" do bias, que recebeu um grande ajuste para baixo.

Tendo treinado nossa rede com todas as amostras disponíveis, fechamos um "epoch". (Nosso script conta uma rodada por amostra, então um epoch = duas rodadas.)

Próximas rodadas

A cada rodada, o ajuste vai diminuindo o erro com sucesso, embora o treinamento seja bem longo:

...
rodada 10 bias 0.441433 peso -0.164474
    entrada -10.000000
    esperada 0.900000 saída 0.889552 erro' -0.010448
    derivada saida: 0.098249
    delta: -0.001027
    ajuste peso: -0.010265
    ajuste bias: 0.001027
...
rodada 99 bias -1.486970 peso -0.368157
    entrada 0.000000
    esperada 0.100000 saída 0.184377 erro' 0.084377
    derivada saida: 0.150382
    delta: 0.012689
    ajuste peso: -0.000000
    ajuste bias: -0.012689
...
rodada 327 bias -2.023769 peso -0.422061
    entrada 0.000000
    esperada 0.100000 saída 0.116730 erro' 0.016730
    derivada saida: 0.103104
    delta: 0.001725
    ajuste peso: -0.000000
    ajuste bias: -0.001725
rodada 328 bias -2.025494 peso -0.422061
    entrada -10.000000
    esperada 0.900000 saída 0.899810 erro' -0.000190
    derivada saida: 0.090152
    delta: -0.000017
    ajuste peso: -0.000171
    ajuste bias: 0.000017

Note que o gradient descent realiza uma tarefa relativamente difícil, que é ajustar duas variáveis de entrada a fim de atingir uma certa curva na saída, sem nenhuma dica além de um par de amostras.

Lembrando novamente que você pode rodar o script Python que gerou o log acima, e constatar os resultados por si mesmo.

O final do processo

O estado final da rede neural, depois de centenas ou até milhares de rodadas, é o seguinte:

Figura 6: Rede neural com o treinamento completo. O valor de saída é exatamente o esperado.
rodada 9999 bias -2.197225 peso -0.439445
    entrada 0.000000
    esperada 0.100000 saída 0.100000 erro' 0.000000
    derivada saida: 0.090000
    delta: 0.000000
    ajuste peso: -0.000000
    ajuste bias: -0.000000

A rede neural realmente aprende uma função a partir de amostras. Porém o treinamento é um processo custoso em recursos computacionais.

Uma vez completado o treinamento, a rede neural deve ser testada com um conjunto de conferência, ou seja, com amostras que a rede neural nunca viu antes, para confirmar que o aprendizado foi bem-sucedido, e que a rede neural é capaz de extrapolar resultados.

O estado final de uma rede já treinada, com a "memória" dos neurônios (bias e peso), mais os hiperparâmetros da rede (função de ativação, topologia da rede neural, etc.) é chamado de modelo. Alguns materiais também o chamam de hipótese.

Um modelo pode ser transplantado para outra rede neural e posto em uso imediatamente, sem treinamento adicional. Também pode ser compartilhado, comprado ou vendido, é uma propriedade intelectual por si mesmo.

As bibliotecas modernas de machine learning têm métodos para importar e exportar modelos, de forma simples e portável. Quem utiliza um YOLO da vida, provavelmente não vai treinar a rede por si mesmo; vai utilizar um modelo pronto fornecido pelo projeto, que reconhece algumas dezenas de objetos cotidianos e é suficiente para muitas aplicações amadoras.

Parentesco com lógica difusa

O modelo que produzimos acima fornece respostas inequívocas para números maiores que 0 ou menores que -10. Para entradas entre -10 e 0, a saída será uma resposta "nem sim nem não", maior que 0.1 e menor que 0.9. Neste caso, isto foi de propósito.

Em muitas aplicações, uma resposta desse tipo é útil, como por exemplo um modelo treinado para reconhecer gatos. A rede pode dizer que há e.g. 60% de probabilidade de uma imagem conter um gato, o que imita o que um humano faz (nós também somos vítimas de ilusões de ótica, vemos olhos em asas de borboletas, etc.).

Se é preciso que a rede neural produza resultados realmente digitais, pode-se usar uma função de ativação digital, como o passo unitário.

A origem matemática das fórmulas

Vamos revisitar as fórmulas de backpropagation por um momento:

δ = d(erro) × d(saida)
Δw1 = -η × δ × entrada
ΔB = -η × δ

Essas fórmulas têm origem na "regra da cadeia" do cálculo.

Para ajustar um peso ou um bias, primeiro precisamos saber qual o impacto que esse peso tem no erro de saída. Em particular, precisamos descobrir em que direção o ajuste deve ser feito, a fim de minimizar o erro. Para isso, precisamos calcular a derivada parcial do erro em relação ao peso.

O erro observado E(w) — erro em função do peso — é na verdade uma função composta L(A(T(entrada,w))) sendo que L() é a funcão de perda que estima o erro, A() é a função de ativação, e T() é a função de transferência, que finalmente depende do valor de entrada e do peso "w".

Por ser uma função composta, podemos calcular facilmente a derivada parcial ∂E/∂w com base nas derivadas de L(), A() e T(). Por isso era importante que a função de ativação tivesse uma derivada fácil de calcular.

A variável δ contém parte da regra da cadeia, com as derivadas de L() e A(). Já a derivada parcial ∂T/∂w é igual ao valor de entrada, por isso "entrada" aparece no cálculo de Δw1. O bias pode ser considerado como o peso de uma entrada de valor constante 1.0.

O mesmo raciocínio vale para uma rede neural multicamadas. Sempre é possível determinar a relação entre o erro da saída e o peso de uma sinapse qualquer, mesmo que ela esteja atrás de múltiplos neurônios. A única diferença é que a função composta E(w) possui mais níveis de composição, e a regra da cadeia tem de ser aplicada mais vezes.

Por último, devo esclarecer uma mentira que temos contado. Temos tratado o erro e d(erro) como se fossem a mesma coisa. Calculamos d(erro) como sendo a diferença entre a saída prevista pela rede e a saída esperada. Mas d(erro) já é a derivada da função de perda, não é a função original.

Na didática, redes neurais usam o erro quadrático como função de perda:

L(previsto, esperado) = 1/2 × (previsto - esperado)2

Esta função tem uma característica conveniente. Considerando que o valor esperado é uma constante, a derivada é

L'(previsto, esperado) = previsto - esperado

que é justamente o que temos usado como métrica de erro.

O perceptron

O perceptron original era algo semelhante ao nosso exemplo acima. Apenas um neurônio com inúmeras entradas, cada entrada conectada a um pixel de um sensor de imagem. O objetivo era chegar a um modelo capaz de distinguir números impressos ou manuscritos.

Infelizmente, um neurônio solitário não é capaz de executar esta tarefa. Por conta desse fracasso, as redes neurais foram desprezadas por longo tempo, até que se pôde provar que a limitação do perceptron não se aplicava a toda rede neural.

Um perceptron só é capaz de aprender funções linearmente separáveis. Informalmente, são funções onde é possível onde é possível achar uma correlação linear entre as entradas.

Se as possíveis entradas forem plotadas num gráfico bidimensional, é possível discriminá-las com apenas um corte. Num gráfico tridimensional, com apenas um plano. (O conceito continua valendo para mais de 3 dimensões, mas pode ser difícil visualizar.)

A função que ensinamos ao nosso neurônio é desse tipo, apesar de ser uma relação inversa (a saída é ativada quando o valor é negativo).

Figura 7: Gráfico unidimensional da função `detectar número negativo` com as amostras em azul. É possível separar as amostras `altas` das `baixas` com apenas um corte.

Outro exemplo de função linearmente separável é a função lógica OR, bem conhecida por quem é da profissión. A tabela-verdade para uma porta lógica OR com duas entradas é a seguinte:

Figura 8: Gráfico bidimensional da função OR com as amostras em azul.

Como se pode ver acima, conseguimos separar as regiões de saída alta (>0.9) e saída baixa (<0.1) com apenas um corte. Quando as entradas são inequivocamente 0 ou 1, a saída possui valor garantidamente correto. Se as entradas possuírem valores ambíguos, em torno de 0.5, a saída também possui uma região de ambiguidade, no estilo da lógica difusa.

A função XOR

A função lógica XOR (eXclusive-OR) é quase igual à OR. Sua tabela-verdade para duas entradas é a seguinte:

Este é um exemplo elementar de função que não é linearmente separável. Não existe uma correlação linear válida entre as entradas e a saída. Para separar graficamente as amostras com saídas "baixas" daquelas com saídas "altas", precisamos de dois cortes.

Figura 9: Gráfico bidimensional da função XOR com as amostras em azul. A região de saida `alta`, em vermelho, não é contínua.

O perceptron não pode aprender a função XOR, e, como foi dito antes, isto foi falsamente usado como evidência que nenhuma rede neural poderia.

Para aprender esta função, precisamos de uma rede neural com camadas ocultas, ou seja, há mais de um neurônio entre cada entrada e cada saída. A rede a seguir é suficiente para a função XOR:

Figura 10: Rede neural com camada oculta para aprender a função XOR.

O teorema da aproximação universal prova que redes neurais com uma camada oculta podem aprender um vasto conjunto de funções, e redes com duas camadas ocultas podem aprender qualquer função.

Infelizmente, o teorema apenas prova que há uma rede neural capaz de aprender qualquer função, mas não diz como montar essa rede neural, não diz quantos neurônios são necessários, nem quanto tempo vai levar para treinar a rede.

Backpropagation com camadas ocultas

Como vimos antes, para realizar a backpropagation e determinar o ajuste dos pesos, calculamos um valor intermediário δ, que contém o erro total de um neurônio, a ser distribuído aos seus pesos e biases:

δ = d(erro) × d(saida)

Na rede XOR, a fórmula acima ainda vale para o neurônio de saída. Porém, como agora há neurônios em camadas ocultas, precisamos de uma fórmula mais geral:

δP = Σ δQ × wP..Q × (𝜑P)'

onde

A figura a seguir procura ilustrar essa difusão de δ de frente para trás:

Figura 11: Backpropagation do delta, em vermelho. O delta de cada neurônio é função do(s) delta(s) do(s) neurônio(s) da camada seguinte a que estiver diretamente conectado, ponderados pelo peso da sinapse.

Essa definição recursiva de δ tem origem matemática na regra da cadeia, mencionada antes.

Dentro de cada neurônio, o ajuste dos pesos de entrada e do bias acontece com base em δ, exatamente do mesmo jeito que fizemos na rede de apenas um neurônio:

Δw = - η × δ × entrada
ΔB = - η × δ

Novamente, conhecer a derivada parcial da saída em relação a um peso qualquer da rede permite inferir a direção correta de cada ajuste, mas não nos permite resolver o problema em apenas uma rodada, ou mesmo em poucas rodadas. (Se fosse tão fácil, não precisaríamos de redes neurais.)

Aprendendo a função XOR

A rede neural com 3 neurônios é implementada por este script Python. Alguns comentários:

Esta versão do script utiliza pesos e biases iniciais aleatórios a cada sessão de treinamento, usando random.random(). Ela consegue aprender a função XOR em 75%-80% das execuções. Quando ela aprende, o número de epochs necessários fica mais ou menos entre 8000 e 11000.

O modelo da rede neural treinada com sucesso fica aproximadamente como a figura abaixo:

Figura 12: Rede neural XOR depois do treinamento completo. Os pesos e biases podem ser diferentes se o treinamento partir de um estado inicial diferente.

Mesmo a segunda versão do script tende a produzir modelos muito parecidos com o da figura acima. De vez em quando, ela produz modelos realmente diferentes.

Uma queixa tanto científica quanto filosófica a respeito das redes neurais é que é difícil explicar "como" elas aprendem, e qual a função de cada neurônio individual dentro do modelo. Mas, pelo menos no caso da função XOR, ainda conseguimos inferir qual a colaboração de cada neurônio:

Figura 13: Circuito lógico realizado pelo modelo da rede neural XOR. Crédito: logic.ly

O circuito lógico acima é equivalente ao modelo da figura anterior. Os neurônios A e B implementam portas lógicas NAND e NOR. O neurônio S implementa uma porta lógica AND com um inversor na entrada vinda da porta NOR.

Sim, existe uma complexidade desnecessária nesse circuito: seria melhor usar OR em vez de NOR, o que dispensaria o NOT parasita. Mas o que você queria? São apenas 3 neurônios...

O importante aqui é entender que cada neurônio individual aprendeu uma função linearmente separável (NAND, NOR, AND), e a rede neural aprendeu a fazer a composição delas para chegar à função XOR.

Também é importante lembrar que, se os pesos e biases iniciais forem aleatórios, cada sessão de treinamento pode realizar um circuito lógico equivalente porém diferente da figura acima. Nos meus testes, observei o aparecimento esporádico de pelo menos uma variante, com portas lógicas A=AND, B=OR e S=OR, todas com inversor na segunda entrada.

Usando bibliotecas Python

Nos exemplos anteriores, implementamos as redes neurais do zero absoluto, sem usar bibliotecas de machine learning. A ideia era "sujar as mãos" mesmo, e demonstrar que os princípios de funcionamento são básicos e acessíveis.

Porém, uma vez que você se convenceu, é infinitamente melhor usar bibliotecas prontas e consagradas como o TensorFlow. Aqui estão o primeiro exemplo e a função XOR implementadas em Tensorflow. Ambos são baseadas neste tutorial.

Numa comparação direta, além do código ficar muito mais curto e mais claro, é muito mais fácil testar diferentes hiperparâmetros, diferentes estratégias de aprendizagem, etc. Não é preciso tomar conhecimento dos detalhes matemáticos.

Além do mais, como Tensorflow é utilizado por quase todo pesquisador de ML, amador ou profissional, é quase uma lingua franca da komunidade, assim como o Pandas ou R são as linguagens dos cientistas de dados.

Mas não é por usar TensorFlow que os scripts passam a fazer milagre! Para começar, são até mais lentos que os feitos "na unha". Às vezes o treinamento "empaca" na versão Tensorflow, igual acontece na versão manual, porque o problema não está no algoritmo, e sim nos hiperparâmetros, que são os mesmos em ambas as versões.

Ainda sobre o uso pervasivo de TensorFlow, é surpreendente que os modelos mais famosos sejam quase todos "open source", ou seja, os modelos "crus" (sem treinamento) das redes neurais são de amplo conhecimento público, não se faz mistério a respeito deles. Se a implementação oficial não for em TensorFlow, vai haver uma implementação alternativa em TensorFlow para fins didáticos.

Lidando com dados não-numéricos

Uma rede neural pode aprender qualquer função, porém redes neurais só podem lidar com números, não com símbolos. Até podemos imaginar como uma rede neural poderia interpretar uma imagem, cada pixel sendo interpretado como um valor numérico, como o Perceptron fazia.

E quanto a vídeos, sons, palavras, linguagem natural? Bem, esta é a fronteira atual da pesquisa em IA — fazer esta conversão de mídias diversas para números que possam ser processados (de forma eficiente) por uma rede neural:

Semelhanças com DSP e rádio

Bem mais acima, citamos a função ReLU como uma função de ativação eficiente e muito popular. Ela é conceitualmente igual a um detector de envelope, utilizado em receptores de rádio AM. Alguns materiais inclusive chamam-na de "função detectora". Isso autoriza pensar que a rede neural é, num certo sentido, um demodulador de sinal.

Em geral, uma função de ativação tem de ser não-linear. Isso tem outro paralelo com rádio: no processo de modulação ou demodulação AM, o "coração" do processo é modulador. O ideal é o modulador balanceado, caro e difícil de construir. Mas qualquer componente não-linear pode atuar como modulador, desde que os subprodutos indesejados sejam removidos por filtragem. É mais um paralelo entre rede neural, rádio e DSP.

A implementação FFT da transformada de Fourier opera de forma semelhante a um banco de moduladores em paralelo e em cascata, e sua estrutura interna é muito semelhante a uma rede neural. Este texto afirma mesmo que a transformada de Fourier "é" uma rede neural, e inclusive "ensina" uma rede neural a calcular FFT.

Esse parentesco não é por acaso. A rede neural é um algoritmo de detecção de correlações. É a ferramenta mais avançada dentre outras com o mesmo fim: correlação, autocorrelação, convolução, modulação, análise espectral, todas elas utilizadas em DSP e estatística.

Outros materiais

Este artigo me inspirou a escrever este texto.

Este canal do YouTube, cujo autor é brasileiro, é bastante didático. Em particular os vídeos sobre backpropagation são muito bons e divertidos.