Estruturando um projeto de machine learning ou deep learning com PyTorch: Parte 1/3

Olá, pessoal. Tudo bem? Espero que sim.

No episódio anterior, eu escrevi sobre como utilizar o Docker como core no Visual Studio Code. Se você ainda não viu, dá uma passadinha aqui e confere. Sem dúvida será muito útil para você e, além disso, eu sempre vou utilizar da mesma base que iniciei lá para o desenvolvimento dos próximos posts.

Bom… Se eu tivesse que classificar o post anterior e este aqui, eu diria que eles estão classificados como base para os que estão por vir. Sim, sim: muitos outros estão por vir e com dicas que eu julgo legais. Vou escrever sobre alguns fundamentos da área de Visão Computacional e outros com maior complexidade, mas chegaremos lá.

Voltando ao foco…

Eu gosto bastante do PyTorch e se você navegar pela Internet em busca de algumas implementações bacanas de algoritmos de Deep Learning você “vai dar de cara” com ele também. Espero que você, amigo leitor, curta esse carinha ai e, também, busque contribuir com a comunidade. Tem muita gente bacana compartilhando conhecimento e é algo valoroso, acredite.

Bem… uma vez que já aprendemos que, utilizando o Docker, não precisamos “perder tanto tempo” preparando ambientes de desenvolvimento, agora iremos começar a estruturar os nossos projetos de Machine Learning e Deep Learning. Esta não é apenas a minha forma particular de estruturar os meus projetos, mas sim uma base comum a quem usa o PyTorch em seus projetos.

Eu vou dividir o assunto aqui em três post diferentes. O primeiro (este aqui mesmo) terá como objetivo fundamentar a estrutura base para o desenvolvimento do nosso projeto (e projetos futuros). Além da estrutura do projeto, eu vou fundamentar as principais etapas/divisões básicas que serão necessárias para a evolução do nosso projeto. Não vou me aprofundar em conceitos neste post; não é o objetivo agora. Mas não vou deixar a desejar: prometo voltar aos tópicos e escrever mais sobre eles.

O segundo post será sobre aprimoramento do projeto inicial. Irei adicionar melhorias estruturais ao projeto e, também, no próprio código. A ideia aqui será deixar o nosso projeto com “cara de framework”. Tecnicamente, o segundo post será um pouco mais curto do que este aqui… assim espero.

O terceiro e último da série de base será sobre avaliação e seleção do modelo. Falarei sobre métricas de avaliação, bem como utilizarei dessas métricas para escolher o melhor modelo a ser utilizado. Adicionarei a lógica junto ao projeto base e, também, mostrarei como dar continuidade ao treinamento de um modelo com base em um modelo previamente treinado (falarei sobre a teoria e a prática aqui, ok?).

Agora, nós iremos trabalhar nessa estrutura e iremos criar um exemplo básico, só para vermos como isso tudo funciona. Tudo bem pra você? Eu já disponibilizei o código em meu GitHub (https://github.com/adrianosantospb/estrutura_base_pytorch).

  1. Organização do projeto

O nosso projeto está dividido em três arquivos e, consequentemente, duas classes e um arquivo para execução do projeto. Elas são: train.py, model.py e dataset.py. Começaremos pela classe DataSet (utilizaremos este nome para diferenciar do torch.utils.data.Dataset). Para facilitar o nosso processo de aprendizado, sugiro que você faça o download do projeto utilizando o link já disponibilizado.

1.1 Classe DataSet

A classe DataSet será responsável por gerenciar os dados. Além disso, em projetos futuros, nós a utilizaremos para operações de transformações em nossos dados com o intuito de otimizar os nossos resultados. Ela está dividida em três funções reservadas: init, getitem e len (Figura 1). Nós utilizaremos em nosso exemplo o MNIST dataset (banco de dados do Instituto Nacional de Padrões e Tecnologia modificado). Trata-se de um conjunto de dados de dígitos manuscritos que é comumente usado para treinar sistemas de processamento de imagem. Existem vários exemplos na Internet com esse banco de dados e nós o utilizaremos porque ele é, pra mim, como o hello world da área de Visão Computacional.

Perceba que existe um diretório chamado de utils e que o arquivo dataset.py está dentro dele.

Perceba que a nossa classe DataSet herda da classe torch.utils.data.Dataset. Nós também importaremos o datasets do torchvision.datasets. Existe um conjunto bem interessante de exemplos “nesse carinha” que você pode utilizar para desenvolver as suas habilidades.

A função __init__() é chamada quando criamos um objeto do tipo de classe em questão. Em nosso exemplo, essa função receberá dois parâmetros: train e transform. O parâmetro train será utilizado para separarmos os dados de treinamento e os dados de validação. Aqui eu vou abrir um parêntese: Existe uma diferença conceitual entre dados de validação e dados de teste. Muita gente comete esse pequeno erro; claro que isso não vai mudar os resultados dos seus experimentos, mas é importante saber que existe diferença entre eles. O conjunto de validação é usado no processo de seleção do modelo, e o conjunto de testes para o erro de previsão do modelo final (o modelo que foi selecionado pelo processo de seleção). Falaremos sobre isso em futuros posts, quando eu estiver falando sobre como realizar a seleção e preparação dos dados.

Criamos uma variável self.dataset que receberá os dados do MNIST dataset e, nas demais funções, nós a utilizaremos; seja para seleção de um elemento do dataset ou, apenas, para sabermos quantos elementos existem no dataset.

O datasets.MNIST recebe os parâmetros de onde os dados serão armazenados (root), se os dados em questão serão os dados de treinamento ou de validação/teste (train), se os dados serão “baixado” ou não (download) e, por fim, se os dados receberão alguma transformação (transform).

A função __getitem__() é de simples entendimento. De acordo com o índice (index), a função retornará o item do dataset. No caso em questão, a imagem (data) e a que classe essa imagem pertence (target). O MNST dataset é dividido em dez classes que vão de 0 a 9.

Essa função será utilizada no momento do treinamento e no processo de validação. Lembre-se que estamos treinando um modelo supervisionado e que o erro do modelo é de acordo com a comparação do valor predito com o valor esperado.

A última função é a função __len__(). Ela simplesmente retornará o “tamanho” do nosso dataset. Não subestime essa função… ela é bastante importante em todo o processo. Sem ela, por exemplo, como conseguiríamos estabelecer batches?

1.2 Classe Model

A classe Model (no arquivo model.py) é onde a mágica acontece :)

Nós a utilizaremos para definir os nossos modelos, ou melhor, para definir a arquitetura da rede utilizada. No nosso exemplo atual, nós criaremos uma rede neural de arquitetura simples: uma camada de entrada, uma camada intermediária e uma camada de saída. E “só”. Na sua composição precisamos importar o torch.nn (classe base para todas as redes neurais) e a torch.relu (função de ativação não-linear).

Na verdade, eu acho bastante importante observar como a construção de um modelo funciona por aqui. É um conjunto de entradas, processamentos e saídas interligadas. Trata-se de uma sequência de etapas mesmo. Observe a Figura 2. Criaremos uma classe Model que é do tipo nn.Module. Na função __init__ temos quatro parâmetros: input_size, hidden_1, hidden_2, e output_size. Esses parâmetros serão utilizados na composição do nosso modelo.

Se você não tem muita base de como funcionam as redes neurais, eu aconselho que você leia isso aqui. Mas, em linha geral e sendo bem objetivo, é executada uma transformação linear entre uma determinada entrada vezes um conjunto de pesos mais um bias (Ax+b). Essa representação também pode ser representada com uma relação entre matrizes: A.T*B (A transposta da matriz A, sendo A o conjunto de pesos, multiplicada pela matriz B). O resultado dessa operação servirá como entrada para uma função de ativação não linear (relu). Geralmente todas as camadas das arquiteturas são iniciadas dentro da função __init__, porém é na função forward que nós iremos conectar as camadas. Essa função deve produzir variáveis Torch (torch.autograd.Variable), de forma que se possa computar as derivadas de maneira automática para o backpropagation.

A função forward é sobrescrita de acordo com a arquitetura da nossa rede. No nosso exemplo, eu tive o cuidado de nomear de forma clara a ordem das conexões entre as camadas que criamos. Perceba que nós temos uma variável chamada out que recebe o resultado de uma transformação linear que é entrada para uma função de ativação não-linear relu. Depois essa mesma variável out será a entrada de uma segunda transformação linear (nn.Linear) e, o seu resultado, será entrada para uma nova função de ativação (não linear novamente — Relu). Esse processo se repete três vezes, sendo a saída da última camada (out) o retorno da função.

Espero que você já tenha percebido que a saída da camada entry é o mesmo valor da entrada da camada hidden, bem como a saída da camada hidden tem o mesmo valor da entrada da camada out. Como estamos utilizando variáveis para a definição do tamanho das entradas, isso significa que a nossa arquitetura está flexível.

1.3 Arquivo Train

Agora que nós já temos a classe de dados e o nosso modelo definido, implementaremos o nosso “módulo” de treinamento. Nós o dividiremos em quatro etapas: dados, rede, treinamento e validação, e teste.

Na etapa de dados nós instanciaremos a nossa classe de dados duas vezes: a primeira com dados de treinamento e a segunda com dados para a validação. De acordo com como definimos a nossa classe DataSet (Figura 3), perceba que a instância train recebe os parâmetros True (para informar que serão os dados carregados serão os de treinamento) e transforms.ToTensor() (será executada uma transformação dos dados do nosso dataset para tensores). Existem várias transformações possíveis e disponíveis, e que podemos utilizar em nossos projetos. Em projetos futuros nós exploraremos mais sobre este tópico.

Perceba, também, que existem duas instâncias de DataLoader — uma para os dados de treinamento e outra para os dados de validação. O DataLoader é uma ferramenta poderosa do PyTorch que oferece uma solução para paralelizar o processo de carregamento de dados com o suporte de lote automático. Em outras palavras, você não precisa (e nem deve) carregar todos os seus dados em memória, pois isso acarretaria problemas de desempenho e, até, poderia impossibilitar o treinamento dos modelos. Você poderá obter mais informações sobre todas as opções de uso do DataLoader aqui ou aqui. Em nosso exemplo, nós apenas definiremos o dataset para cada DataLoader, o batch_size (em quantos grupos os nosso dataset será dividido por vez) e, no DataLoader de treinamento, nós também definiremos o parâmetro shuffle para que os dados sejam misturados.

A próxima etapa será a definição da nossa rede (ou modelo). Nós definiremos a nossa função de custo (loss function), ou função que calcula quão assertivo é o nosso modelo de acordo com os valores preditos e os valores esperados e que em nosso exemplo será a CrossEntropyLoss(), o nosso otimizador optim.SGD (stochastic gradient descent) e o nosso modelo.

Perceba que o nosso modelo recebe os valores 784 (input_size), 50 (hidden_1), 50 (hidden_2) e 10 (output_size). O valor 784 é definido de acordo com o tipo de nossa entrada. O MNST dataset é formado por um conjunto de imagens com dimensões 28x28x1, sendo altura X largura X canais_de_cores. Como se tratam de imagens em cinza, temos apenas um canal. Uma imagem nada mais é do que uma matriz numérica. Cada ponto da matriz será uma entrada em nosso modelo (Figura 4).

Em nosso exemplo, uma imagem A é uma matriz A(n,m,c), sendo n linhas, m colunas, e c canais de cores. Ela será transformada em uma nova matriz A(1, n*m*c). Se a imagem A (28, 28, 1) em sua forma original, então ela será transformada em A (1, 784). Este processo também é conhecido como flatten.

O nosso otimizador recebe os parâmetros do modelo (model.parameters()) que será otimizados a cada rodada de treinamento e o learning rate.

A penúltima etapa do nosso pequeno projeto é a etapa de treinamento. Bem, esta aqui é uma etapa em que colocamos tudo que definimos até aqui para funcionar. Na Figura 4 você poderá perceber que se trata de uma estrutura programática simples. Nós temos uma estrutura de repetição na qual definiremos quantas vezes os processos internos de treinamento e validação serão repetidos. Em nosso exemplo, nós definiremos que o processo de evolução ocorrerá por 50 épocas.

Na etapa de treinamento (Figura 5), perceba que nós será executada a função model.train() e que na etapa de validação será executada a função model.eval(). Isso aqui é um detalhe MUITO importante do processo. Tentarei explicar de uma forma bem objetiva aqui… Quando a função train() é chamada, estamos informando ao modelo que os seus parâmetros podem ser atualizados, bem como outras operações podem ser executadas. Quando a função eval() é chamada, estamos dizendo ao modelo que ele utilize o que foi aprendido em teste. É como se o nosso modelo se preparasse para uma prova, estudando os dados e realizando algumas estratégias de aprendizado e, depois, fosse submetido a uma prova.

Nós temos mais duas estruturas de repetição presentes. Na estrutura de treinamento, perceba que nós estamos utilizando o nosso train_loader (DataLoader). Como já informado, o MNST dataset possui 60000 images. Nós definimos que o nosso train_loader com batch_size=2000, sendo assim, serão 30 iterações, sendo cada uma com 2000 imagens por vez.

Perceba agora que o nosso otimizador faz a chamada à funcão zero_grad(). No PyTorch, precisamos definir os gradientes como zero antes de começar a fazer o backpropagation para que o PyTorch não acumule os gradientes nas passagens posteriores subsequentes. Sendo assim, antes de qualquer execução devemos zerar os gradientes.

A próxima etapa é obter os valores preditos pelo modelo. Perceba que chamamos o modelo e passamos como parâmetro os valores de X, sendo estes transformados pela função view(-1, 28*28). Em nosso exemplo, se você imprimir o valor de X.view(-1, 28*28).shape você obterá o valor torch.Size([2000, 784]), sendo 2000 linhas (imagens) com 784 colunas.

A variável yPred receberá, em nosso exemplo, 2000 predições por rodada. Essas predições serão colocadas como parâmetros da nossa função de custo junto com o “gabarito” dos valores esperados. Os erros serão atribuídos ao error e a função backward() é chamada para computar todos os valores dos gradientes. E em seguida, o otimizador atualiza os parâmetros da rede.

O processo de validação também ocorre em uma estrutura de repetição, porém os dados utilizados são os de validação definidos anteriormente. Em nosso exemplo, esta etapa está incompleta — por agora. Aqui, exatamente aqui, no próximo post eu vou adicionar as etapas de métricas e de armazenamento do modelo.

1.4 Etapa de teste

Vamos para a última etapa do nosso primeiro post: etapa de teste.

Em projetos de Machine Learning e Deep Learning, a etapa de teste é bastante importante para a avaliação dos nossos modelos. Como já comentei anteriormente, este aqui

é o nosso primeiro de três posts e eu não vou me aprofundar muito em cada etapa — por agora.

O nosso teste será muito simples. Vamos selecionar uma imagem do nosso dataset de validação simplesmente para avaliarmos como o nosso modelo está se comportando. Geralmente, os dados de treinamento, validação e testes devem ser independentes. Eu vou escrever um post exclusivamente sobre o processo de seleção dos dados e os possíveis problemas e soluções que encontramos ao longo dos nossos projetos. Espere e confie.

Na Figura 6 temos a nosso estrutura básica de teste. Perceba que fazemos a mesma chamada à função eval() do modelo e que também chamamos o torch.no_grand(). O motivo é o mesmo que o anterior: o nosso modelo não está em treinamento; aliás, muito mais aqui.

A etapa de teste é, na maioria das vezes, a última etapa do processo de avaliação do modelo.

Vamos ao código. Perceba que nós iremos selecionar apenas uma instância do nosso dataset de validação e de forma aleatória. A instância selecionada é atribuída ao modelo e o resultado predito é atribuído à variável yPred. Com o uso da função max() obtemos o valor predito e comparamos com o valor esperado. O resultado aparecerá no seu terminal.

Pronto: terminamos o nosso primeiro post dessa série de base.

Perceba que existe um conjunto de melhorias que iremos realizar no código, tais como utilizar GPU no treinamento, avaliação do modelo, salvar os pesos treinados, continuar um treinamento de um modelo de uma fase intermediária etc.

Eu espero que você tenha gostado do conteúdo e que esteja ansioso para o próximo post.

Abraço.

Senior Computer Vision Data Scientist at Conception Ro-Main (Quebec — CA). DSc in Computer Science. MTAC Brazil. https://github.com/adrianosantospb

Senior Computer Vision Data Scientist at Conception Ro-Main (Quebec — CA). DSc in Computer Science. MTAC Brazil. https://github.com/adrianosantospb