Rede Neural para reconhecer números
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.
Database
[editar | editar código]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

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:
- Entrada dos materiais → O vidro quebrado chega à fábrica (equivalente à entrada da primeira camada oculta).
- Transformação inicial → Ele é derretido na fornalha, tornando-se vidro líquido (primeira transformação na rede).
- Segunda transformação → O vidro líquido é colocado em um molde para ganhar a forma de um copo (segunda camada oculta).
- Refinamento final → O copo recém-moldado entra na geladeira para endurecer e ficar pronto para uso (camada de saída da rede).
- 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.

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:
- 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". - 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". - 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". - 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". - 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%.