Ir para o conteúdo

Rede Neural para reconhecer números

De Wikiversidade

Introdução

[editar | editar código]

O funcionamento do aprendizado de máquina, aprendizado profundo e inteligência artificial são, muitas vezes, de difícil acesso e de difícil entendimento para pessoas leigas. Para isso, fizemos esse tutorial, para tentarmos mostrar para facilitar o acesso à uma tecnologia que está cada vez mais presente nas nossas vidas.

Esse tutorial tem como ideia não apresentar fórmulas das engrenagens das inteligências artificiais, somente introduzir a ideia de como é calculado. Dessa forma, facilitando o entendimento do leitor leigo.

Para podermos reconhecer números vamos utilizar o Database MNIST( Modified National Institute of Standards and Technology database ), que contém 70 mil imagens de números desenhados a mão. Um Database muito utilizado em aprendizado de máquina e aprendizado profundo para determinar a acurácia do modelo sendo usado.[1]

Instalando o PyTorch

[editar | editar código]

Para podermos fazer isso, precisamos de uma biblioteca: Pytorch.[2]

pip3 install torch torchvision

Nós estamos instalando o Pytorch GPU para o CUDA 12.6.

Caso não possamos ter acesso a GPU, precisamos rodar o seguinte comando:

pip3 install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu

Instalando o MNIST [3]

[editar | editar código]
import torch
import torchvision

train_loader = torch.utils.data.DataLoader(
  torchvision.datasets.MNIST('./ds/', train=True, download=True,
                             transform=torchvision.transforms.Compose([
                               torchvision.transforms.ToTensor(),
                               torchvision.transforms.Normalize(
                                 (0.1307,), (0.3081,))
                             ])),
  batch_size=batch_size_train, shuffle=True)

test_loader = torch.utils.data.DataLoader(
  torchvision.datasets.MNIST('./ds/', train=False, download=True,
                             transform=torchvision.transforms.Compose([
                               torchvision.transforms.ToTensor(),
                               torchvision.transforms.Normalize(
                                 (0.1307,), (0.3081,))
                             ])),
  batch_size=batch_size_test, shuffle=True)

Esse código vai baixar o nosso Dataset. Nas nossas funções, temos o tranforms.Compose. A função ToTensor vai converter a nossa imagem para um tensor, ou uma super matriz do jeito que o PyTorch gosta. As imagens, quando no computador, normalmente seguem a seguinte estrutura: "H x W x C", com seus significados sendo altura, largura e canais de cor, respectivamente. Porém, para o PyTorch, que tem uma preferência por perfomance em GPU, uma vez que praticamente todos os modelos atuais rodam em GPU, a seguinte estrutura é a melhor: "C x H x W". Além disso, o ToTensor aplica uma normalização, isso é, de [0, 255] vai para [0.0, 1.0]

Outra coisa que nos chama atenção é a transformação normalize. Ela ocorre, pois, se analisarmos os dados normalizados pelo ToTensor, vamos ver que a média dos pixels é de 0.1307 e o desvio padrão é 0.3081.Dessa forma, todas as imagens vão estar dentro do mesmo padrão, facilitando o aprendizado.

Vamos visualizar o nosso dataset

[editar | editar código]
examples = enumerate(test_loader)
batch_idx, (example_data, example_targets) = next(examples)

import matplotlib.pyplot as plt

fig = plt.figure()
for i in range(6):
  plt.subplot(2,3,i+1)
  plt.tight_layout()
  plt.imshow(example_data[i][0], cmap='gray', interpolation='none')
  plt.title("Ground Truth: {}".format(example_targets[i]))
  plt.xticks([])
  plt.yticks([])
fig
Visualizando o Dataset

Começando com Redes Neurais

[editar | editar código]

Redes neurais, atualmente, é o mecânismo de engrenagem das inteligências artificiais modernas. Para entendermos o que o nosso modelo está fazendo, precisamos, primeiro, entender o algoritmo por trás das redes neurais.

Algoritmos de Feedforward e Backpropagation

[editar | editar código]

As redes neurais funcionam como uma linha de produção organizada para resolver problemas. Para entender como elas trabalham, vamos primeiro explorar a estrutura básica.

  • Camada de entrada (Input Layer): é onde inserimos os dados. No nosso caso, cada número do dataset entra aqui.
  • Camadas ocultas (Hidden Layers): são responsáveis por transformar os dados ao longo do caminho. Chamamos de "ocultas" porque o que acontece dentro delas não é visível diretamente.
  • Camada de saída (Output Layer): depois de passar por todas as transformações, aqui obtemos a resposta final, como a classificação da imagem.

Agora, vamos entender o feedforward, o processo em que os dados percorrem essa rede de forma progressiva. Imagine que sua rede neural é uma linha de produção de copos de vidro reciclado:

  1. Entrada dos materiais → O vidro quebrado chega à fábrica (equivalente à entrada da primeira camada oculta).
  2. Transformação inicial → Ele é derretido na fornalha, tornando-se vidro líquido (primeira transformação na rede).
  3. Segunda transformação → O vidro líquido é colocado em um molde para ganhar a forma de um copo (segunda camada oculta).
  4. Refinamento final → O copo recém-moldado entra na geladeira para endurecer e ficar pronto para uso (camada de saída da rede).
  5. Produto final → O resultado é um copo pronto (classificação da imagem no nosso caso).

Esse processo ilustra bem a função do feedforward: os dados são transformados camada por camada até chegarem à resposta final.

Representação do feedfoward

Agora, precisamos entender o backpropagation. O feedforward vai da esquerda para a direita, o backpropagation vai da direita para esquerda. O backpropagation vai calcular o erro do nosso modelo. Nossa entrada tem o input e uma label que representa a sua saída. Queremos que o output do nosso modelo seja o mesmo que essa label.

Para podermos ter o erro de computação do primeiro hidden layer, precisamos ter o erro de computação do segundo hidden layer, uma vez que a saida do primeiro, "p", serve como entrada do segundo, originando "r". Como "r" foi criado a partir de "p", para mexermos em "p", precisamos saber o erro de "r". Então, para mexermos nas layers mais a esquerda, precisamos dos erros das layers mais a direita.

Agora, entendendo o backpropagation no contexto na nossa fábrica de copos de vidro reciclado, podemos pensar que:

  • Inspeção do produto final → Depois de passar por todas as etapas da linha de produção, os copos são analisados para verificar se estão no formato e qualidade corretos (A output layer verificando se a saída foi a memsa que a label da entrada).
  • Identificação dos defeitos → Se um copo saiu torto ou com rachaduras, o sistema registra o erro e calcula o que foi feito errado. Na rede neural, isso seria o cálculo do erro da previsão em relação ao valor esperado.
  • Correção do processo → O sistema de produção envia informações para cada etapa anterior, dizendo onde ajustes são necessários. Na rede neural, isso é feito ajustando os pesos dos neurônios com base no erro detectado.
  • Ajuste das máquinas → Cada setor da produção recebe instruções para melhorar a forma como processa o vidro (equivalente às camadas ocultas recebendo ajustes nos seus pesos através do gradiente descendente).
  • Nova tentativa → O vidro reciclado passa novamente pelo processo, mas agora com ajustes para evitar os erros anteriores. O aprendizado da rede neural acontece dessa forma: os pesos são corrigidos e a rede tenta novamente até minimizar os erros.

Para ajustar os parâmetros e fazer o algoritmo funcionar, as redes neurais usam métodos de aprendizagem, como Gradiente Descendente, ADAMS, SGD, etc. O que eles fazem é: usar a informação do backpropagation e ajustar as hidden layers, ele faz isso somando o valor do backpropagation em cada nó.

Criando nossa Rede Neural usando PyTorch

[editar | editar código]

Preparando as nossas variáveis

[editar | editar código]

Assim como em muitos algoritmos, precisamos definir muitos parâmetros das nossas funções. Utilizando, ainda como exemplo, a fábrica de copos, vamos definir a velocidade das esteiras de produção, temperatura do forno de fundição, tipo de molde e essas coisas. No caso de redes neurais, vamos definir o tamanho dos subconjuntos do nosso Datasets, comumente chamado de batch. Vamos definir o learning rate, que vai ser multiplicado nos nossos gradientes. Quantidade de vezes que vamos fazer nosso treinamento, chamado de epochs.

n_epochs = 3
batch_size_train = 64
batch_size_test = 1000
learning_rate = 0.01
momentum = 0.5
log_interval = 10

random_seed = 1
torch.backends.cudnn.enabled = False
torch.manual_seed(random_seed)
import os
os.makedirs('./results', exist_ok=True)

O momentum é um parâmetro muito usados em métodos de aprendizado. O log_interval é simplesmente algo para nós podermos ver como está indo o código, ou seja, a cada dez iterações, vamos printar nossas informações.

Criação do nosso modelo

[editar | editar código]

Para isso, vamos usar uma classe nossa, usando as funções do pytorch.

Primeiro, vamos importar as nossas necessidades[1]

import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim

A classe torch.nn nos dará blocos para podermos construir nosso modelos. Ele nos dará funções de ativação, como ReLU, Convulação. Também nos dará modelos completos e maneiras simples de construirmos camadas.

A torch.nn.functional nos dará funções de ativações. Porém, a única diferença entre essa e torch.nn é que F não guarda parâmetros, só usa o que damos, calcula e devolve o valor. Porém, após devolver o valor, morre.

A torch.optim nos dará os métodos de aprendizagem, ou seja, Gradiente Descendente, SGD, Adams, entre outros que podemos usar.[1]

class Net(nn.Module):
    def __init__(self):
        super(Net, self).__init__()
        self.conv1 = nn.Conv2d(1, 10, kernel_size=5)
        self.conv2 = nn.Conv2d(10, 20, kernel_size=5)
        self.conv2_drop = nn.Dropout2d()
        self.fc1 = nn.Linear(320, 50)
        self.fc2 = nn.Linear(50, 10)

    def forward(self, x):
        x = F.relu(F.max_pool2d(self.conv1(x), 2))
        x = F.relu(F.max_pool2d(self.conv2_drop(self.conv2(x)), 2))
        x = x.view(-1, 320)
        x = F.relu(self.fc1(x))
        x = F.dropout(x, training=self.training)
        x = self.fc2(x)
        return F.log_softmax(x)
  • Para aprendermos algo, antes, nós precisamos nos preparar, seja tomando café, arrumando a mesa onde vamos estudar, os materias de estudo e o método. O nosso código faz a mesma coisa, ele está preparando o nosso modelo para o treinamento. self.conv1 criará 10 filtros que passaram sobre a imagem e analisaram buscando sempre pegar algo da nossa imagem. Esse filtro poderá encontrar bordas e formatos específicos e, dado esses formatos, o nosso modelo relacionará eles ao resultado final. Exemplo: se encontrarmos um formato similar a um circulo, o modelo atribuirá esses formatos aos numeros, 2, 6 e 9. Tudo isso de forma automatica. E para distinguir esses números, nosso modelo usará outras informações da imagem, que será adquirida pelos outros filtros do modelo.
  • self.conv2 criará 20 filtros que farão uma analise em cima dos resultados adquiridos pela self.conv1. Olhamos ainda que existe o parâmetro kernel_size=5, em ambos conv. Isso é o tamanho do filtro da nossa convulação, ou seja, ele olhará um quadrado de 5x5 em nossa imagem de 28x28.
  • self.conv2_drop vai desligar alguns filtros que aprendemos. Isso é importante para não podermos usar somente uma informação para definir os valores, vai forçar o nosso modelo a aprender outras propriedades para a solução do problema. Isso vai diminuir o viés do nosso modelo. Isso serve para evitarmos o seguinte caso: Estamos querendo usar um reconhecimento de imagem em tomografias, para saber se a pessoa com SARs precisa ser internada ou não. Temos os hospitais A, B, C e D, que nos forneceram imagens de seus pacientes, A é o hospital de campanha contra a doença, nos fornecendo 95% das imagens. Se passarmos as imagens, sem tomar cuidado com o viés, o nosso modelo só vai diagnosticar que o paciente só precisa ser internado se a tomografia vier do hospital A, pois 95% das imagens vem dele. Dessa forma, criamos um viés no nosso modelo, onde ele não usa a informação principal que seria a tomografia, mas sim de onde a imagem veio. Para isso que serve o Dropout, para evitar modelos.
  • O self.fc1 pega 320 informações do nosso modelo e agrupar em somente 50, e o self.fc2 pega essas 50 informações e agrupar em 10, que são as bases para definirmos qual número aquela imagem representa.

O feedforward vai seguir vários passos sequenciais:

  1. Primeira camada convolucional e ativação: A imagem passa pela camada self.conv1, que aplica filtros para extrair padrões básicos. Em seguida, o resultado é processado pela função de ativação ReLU, que ajuda a enfatizar os sinais importantes e gera um conjunto de valores, que chamamos de "p".
  2. Segunda camada convolucional e ativação: O conjunto "p" é então levado para a camada self.conv2. Essa camada refina ainda mais os padrões extraídos, identificando características mais complexas. Novamente, a função ReLU é aplicada, resultando em um novo conjunto de valores denominado "r".
  3. Aplicação do Dropout: Para evitar que a rede se torne muito "viciada" em determinados padrões dos dados de treinamento (um problema chamado overfitting), o valor "r" passa pelo Dropout (self.conv2_drop). Essa técnica desativa aleatoriamente alguns neurônios durante o treinamento, gerando assim o conjunto "w".
  4. Camadas totalmente conectadas: O conjunto "w" é encaminhado para a camada self.fc1, que transforma os dados para uma nova dimensão, produzindo o conjunto "z". Em seguida, "z" passa por outra camada, self.fc2, que gera o conjunto final "h".
  5. Função Softmax para classificação: Por fim, a função Softmax é aplicada sobre "h". Ela converte esses valores em probabilidades, determinando qual é a classe (por exemplo, qual número) que melhor representa aquela imagem.

Agora que definimos o nosso modelo, vamos treinar ele.

Treinando nossa Rede Neural

[editar | editar código]

Primeiro, vamos inicializar o nosso modelo.

network = Net()
optimizer = optim.SGD(network.parameters(), lr=learning_rate,momentum=momentum)
train_losses = []
train_counter = []
test_losses = []
test_counter = [i*len(train_loader.dataset) for i in range(n_epochs + 1)]

Agora, vamos criar uma função para o treinamento, isso é, juntar o feedforward e backpropagation.[1]

def train(epoch):
  network.train()
  for batch_idx, (data, target) in enumerate(train_loader):
    optimizer.zero_grad()
    output = network(data)
    loss = F.nll_loss(output, target)
    loss.backward()
    optimizer.step()
    if batch_idx % log_interval == 0:
      print('Train Epoch: {} [{}/{} ({:.0f}%)]\tLoss: {:.6f}'.format(
        epoch, batch_idx * len(data), len(train_loader.dataset),
        100. * batch_idx / len(train_loader), loss.item()))
      train_losses.append(loss.item())
      train_counter.append(
        (batch_idx*64) + ((epoch-1)*len(train_loader.dataset)))
      torch.save(network.state_dict(), './results/model.pth')
      torch.save(optimizer.state_dict(), './results/optimizer.pth')

O forward ja foi implementado no nosso modelo, e quando chamamos o network.train(), nós ja estamos chamando essa função.

Vamos entender o que cada parte do código faz:

  • batch_idx, (data, target) é para pegarmos os batchs, ou os subconjuntos do data_set, para treinarmos. o batch_idx é para podermos visualizar o avanço do nosso Dataset. O data é a nossa entrada e o target é o valor que queremos, vai ser a base para arrumar o nosso modelo
  • optimizer.zero_grad() é para zerarmos o gradiente e esquecermos o nossos erros das ultimas iterações.
  • output vai ser o resultado do feedforward na nossa entrada.
  • loss vai ser o erro da nosso modelo, ou seja, o quão longe estamos do resultado que queremos. aplicando o loss.backward() é o que falamos mais cedo sobre backpropagation, ou seja, vamos passar o nosso erro para todas as camadas arrumare.
  • optimizer.step() vai mexer o nosso modelo para a direção contraria ao gradiente, ou seja, valor contrario dos dados do backward.
  • De resto, é somente para guardarmos as informações do nosso modelo, ou seja, um relatório a cada 5 passos.


Agora, precisamos verificar os nosso resultados, isso é, comparar os valores reais com os valores da predição do nosso modelo.[1]

def test():
  network.eval()
  test_loss = 0
  correct = 0
  with torch.no_grad():
    for data, target in test_loader:
      output = network(data)
      test_loss += F.nll_loss(output, target, size_average=False).item()
      pred = output.data.max(1, keepdim=True)[1]
      correct += pred.eq(target.data.view_as(pred)).sum()
  test_loss /= len(test_loader.dataset)
  test_losses.append(test_loss)
  print('\nTest set: Avg. loss: {:.4f}, Accuracy: {}/{} ({:.0f}%)\n'.format(
    test_loss, correct, len(test_loader.dataset),
    100. * correct / len(test_loader.dataset)))

Vamos entender o que o nosso código faz.

  • network.eval vai dizer para o nosso modelo que queremos fazer verificação, então, não é para utilizar o Dropout ou outras técnicas de treinamente que temos. [2]
  • O torch.no_grad() vai retirar o calculo do gradiente, assim, deixando o modelo mais eficiente.
  • As outras partes do modelo é para podermos fazer uma verificação posterior e uma análise visual também.
def teste():
  test()
  for epoch in range(1, n_epochs + 1):
    train(epoch)
    test()
    
%time teste()

A saída na minha máquina foi:


Train Epoch: 1 [56960/60000 (95%)]	Loss: 0.325593
Train Epoch: 1 [57600/60000 (96%)]	Loss: 0.589285
Train Epoch: 1 [58240/60000 (97%)]	Loss: 0.564995
Train Epoch: 1 [58880/60000 (98%)]	Loss: 0.516619
Train Epoch: 1 [59520/60000 (99%)]	Loss: 0.456940

Test set: Avg. loss: 0.1885, Accuracy: 9423/10000 (94%)
...
Train Epoch: 2 [56960/60000 (95%)]	Loss: 0.328317
Train Epoch: 2 [57600/60000 (96%)]	Loss: 0.526884
Train Epoch: 2 [58240/60000 (97%)]	Loss: 0.522199
Train Epoch: 2 [58880/60000 (98%)]	Loss: 0.302361
Train Epoch: 2 [59520/60000 (99%)]	Loss: 0.332582

Test set: Avg. loss: 0.1273, Accuracy: 9636/10000 (96%)
...
Train Epoch: 3 [56960/60000 (95%)]	Loss: 0.400336
Train Epoch: 3 [57600/60000 (96%)]	Loss: 0.393511
Train Epoch: 3 [58240/60000 (97%)]	Loss: 0.162489
Train Epoch: 3 [58880/60000 (98%)]	Loss: 0.305968
Train Epoch: 3 [59520/60000 (99%)]	Loss: 0.287951

Test set: Avg. loss: 0.0963, Accuracy: 9703/10000 (97%)

CPU times: user 1min 57s, sys: 1.26 s, total: 1min 58s
Wall time: 1min 59s

Ao final da execução do nosso treinamento tivemos uma acurácia de 97%.

Referência

[editar | editar código]
  1. 1,0 1,1 1,2 1,3 https://nextjournal.com/gkoehler/pytorch-mnist
  2. https://stackoverflow.com/questions/60018578/what-does-model-eval-do-in-pytorch