Sobre a implementação de uma biblioteca de aprendizado profundo em Python

As tecnologias de aprendizado profundo percorreram um longo caminho em um curto período de tempo - desde redes neurais simples a arquiteturas bastante complexas. Para apoiar a rápida disseminação dessas tecnologias, várias bibliotecas e plataformas de aprendizado profundo foram desenvolvidas. Um dos principais objetivos dessas bibliotecas é fornecer aos desenvolvedores interfaces simples para criar e treinar modelos de redes neurais. Essas bibliotecas permitem que seus usuários prestem mais atenção nas tarefas que estão sendo resolvidas, e não nas sutilezas da implementação do modelo. Para fazer isso, pode ser necessário ocultar a implementação de mecanismos básicos por trás de vários níveis de abstração. E isso, por sua vez, complica a compreensão dos princípios básicos nos quais as bibliotecas de aprendizado profundo se baseiam.



O artigo, cuja tradução estamos publicando, tem como objetivo analisar os recursos do dispositivo de blocos de construção de baixo nível das bibliotecas de aprendizado profundo. Primeiro, falamos brevemente sobre a essência do aprendizado profundo. Isso nos permitirá entender os requisitos funcionais do respectivo software. Em seguida, analisamos o desenvolvimento de uma biblioteca de aprendizado profundo simples, mas funcional, em Python usando o NumPy. Essa biblioteca é capaz de fornecer treinamento completo para modelos simples de redes neurais. Ao longo do caminho, falaremos sobre os vários componentes das estruturas de aprendizado profundo. A biblioteca que consideraremos é muito pequena, com menos de 100 linhas de código. E isso significa que será bastante simples descobrir isso. O código completo do projeto, com o qual trataremos, pode ser encontrado aqui .

Informação geral


Normalmente, as bibliotecas de aprendizado profundo (como TensorFlow e PyTorch) consistem nos componentes mostrados na figura a seguir.


Componentes da estrutura de aprendizado profundo

Vamos analisar esses componentes.

▍ Operadores


Os conceitos de "operador" e "camada" (camada) são geralmente usados ​​de forma intercambiável. Estes são os elementos básicos de qualquer rede neural. Operadores são funções vetoriais que transformam dados. Entre os operadores usados ​​com freqüência, podemos distinguir como camadas lineares e de convolução, camadas de subamostragem (pooling), funções de ativação semi-lineares (ReLU) e sigmóides (sigmóides).

PtOtimizadores (otimizadores)


Otimizadores são a base das bibliotecas de aprendizado profundo. Eles descrevem métodos para ajustar os parâmetros do modelo usando certos critérios e levando em consideração o objetivo da otimização. Entre os otimizadores conhecidos, destacam-se SGD, RMSProp e Adam.

▍ Funções de perda


As funções de perda são expressões matemáticas analíticas e diferenciáveis ​​usadas como substitutas do objetivo de otimização na resolução de um problema. Por exemplo, a função de entropia cruzada e a função linear por partes são geralmente usadas em problemas de classificação.

▍ Inicializadores


Os inicializadores fornecem valores iniciais para os parâmetros do modelo. São esses valores que os parâmetros possuem no início do treinamento. Os inicializadores desempenham um papel importante no treinamento de redes neurais, pois parâmetros iniciais malsucedidos podem significar que a rede aprenderá lentamente ou poderá não aprender nada. Existem várias maneiras de inicializar os pesos de uma rede neural. Por exemplo - você pode atribuir a eles pequenos valores aleatórios da distribuição normal. Aqui está uma página onde você pode aprender sobre os diferentes tipos de inicializadores.

▍ Regularizadores


Regularizadores são ferramentas que evitam a reciclagem da rede e ajudam a rede a obter generalização. Você pode lidar com a reciclagem da rede de maneira explícita ou implícita. Métodos explícitos envolvem limitações estruturais nos pesos. Por exemplo, minimizar suas normas L1 e L2, o que, consequentemente, torna os valores de peso melhor dispersos e distribuídos de maneira mais uniforme. Métodos implícitos são representados por operadores especializados que executam a transformação de representações intermediárias. Isso é feito através da normalização explícita, por exemplo, usando a técnica de normalização de pacotes (BatchNorm) ou alterando a conectividade de rede usando os algoritmos DropOut e DropConnect.

Os componentes acima geralmente pertencem à parte da interface da biblioteca. Aqui, por "parte da interface", quero dizer as entidades com as quais o usuário pode interagir. Eles oferecem ferramentas convenientes para projetar com eficiência uma arquitetura de rede neural. Se falarmos sobre os mecanismos internos das bibliotecas, eles podem fornecer suporte para o cálculo automático de gradientes da função de perda, levando em consideração vários parâmetros do modelo. Essa técnica é comumente chamada de Diferenciação automática (AD).

Diferenciação automática


Cada biblioteca de aprendizado profundo fornece ao usuário alguns recursos de diferenciação automática. Isso lhe dá a oportunidade de focar na descrição da estrutura do modelo (gráfico de cálculos) e transferir a tarefa de calcular os gradientes para o módulo AD. Vamos dar um exemplo que nos permitirá saber como tudo funciona. Suponha que desejamos calcular as derivadas parciais da função a seguir em relação às suas variáveis ​​de entrada X₁ e X₂:

Y = sin (x₁) + X₁ * X₂

A figura a seguir, que eu emprestei daqui , mostra o gráfico de cálculos e o cálculo de derivadas usando uma regra de cadeia.


Gráfico computacional e cálculo de derivadas por uma regra em cadeia

O que você vê aqui é algo como um "modo reverso" de diferenciação automática. O conhecido algoritmo de propagação de erro de retorno é um caso especial do algoritmo acima para o caso em que a função localizada na parte superior é uma função de perda. O AD explora o fato de que qualquer função complexa consiste em operações aritméticas elementares e funções elementares. Como resultado, os derivativos podem ser calculados aplicando uma regra de cadeia a essas operações.

Implementação


Na seção anterior, examinamos os componentes necessários para a criação de uma biblioteca de aprendizado profundo projetada para a criação e o treinamento completo de redes neurais. Para não complicar o exemplo, imito o padrão de design da biblioteca Caffe aqui . Aqui declaramos duas classes abstratas - Functione Optimizer. Além disso, há uma classe Tensor, que é uma estrutura simples que contém duas matrizes NumPy multidimensionais. Um deles é projetado para armazenar valores de parâmetros, o outro - para armazenar seus gradientes. Todos os parâmetros em diferentes camadas (operadores) serão do tipo Tensor. Antes de prosseguirmos, dê uma olhada no esboço geral da biblioteca.


Diagrama UML da biblioteca

No momento da redação deste material, esta biblioteca contém uma implementação da camada linear, da função de ativação ReLU, da camada SoftMaxLoss e do otimizador SGD. Como resultado, verifica-se que a biblioteca pode ser usada para treinar modelos de classificação que consistem em camadas totalmente conectadas e usando uma função de ativação não linear. Agora vamos ver alguns detalhes sobre as classes abstratas que temos.

Uma classe abstrataFunctionfornece uma interface para operadores. Aqui está o código dele:

class  Function(object):
    def forward(self): 
        raise NotImplementedError
    
    def backward(self): 
        raise NotImplementedError
    
    def getParams(self): 
        return []

Todos os operadores são implementados através da herança de uma classe abstrata Function. Cada operador deve fornecer uma implementação dos métodos forward()e backward(). Os operadores podem conter uma implementação de um método opcional getParams()que retorna seus parâmetros (se houver). O método forward()recebe dados de entrada e retorna o resultado de sua transformação pelo operador. Além disso, ele resolve os problemas internos necessários para o cálculo de gradientes. O método backward()aceita derivadas parciais da função de perda em relação às saídas do operador e implementa o cálculo das derivadas parciais da função de perda em relação aos dados de entrada do operador e aos parâmetros (se houver). Observe que o métodobackward(), em essência, fornece à nossa biblioteca a capacidade de executar diferenciação automática.

Para lidar com tudo isso com um exemplo específico, vamos dar uma olhada na implementação da função Linear:

class Linear(Function):
    def __init__(self,in_nodes,out_nodes):
        self.weights = Tensor((in_nodes,out_nodes))
        self.bias    = Tensor((1,out_nodes))
        self.type = 'linear'

    def forward(self,x):
        output = np.dot(x,self.weights.data)+self.bias.data
        self.input = x 
        return output

    def backward(self,d_y):
        self.weights.grad += np.dot(self.input.T,d_y)
        self.bias.grad    += np.sum(d_y,axis=0,keepdims=True)
        grad_input         = np.dot(d_y,self.weights.data.T)
        return grad_input

    def getParams(self):
        return [self.weights,self.bias]

O método forward()implementa a transformação da visualização Y = X*W+be retorna o resultado. Além disso, ele salva o valor de entrada X, pois é necessário calcular a derivada parcial da dYfunção de perda em relação ao valor de saída Yno método backward(). O método backward()recebe as derivadas parciais, calculadas com relação ao valor de entrada Xe aos parâmetros We b. Além disso, ele retorna as derivadas parciais calculadas em relação ao valor de entrada X, que serão transferidas para a camada anterior.

Uma classe abstrata Optimizerfornece uma interface para otimizadores:

class Optimizer(object):
    def __init__(self,parameters):
        self.parameters = parameters
    
    def step(self): 
        raise NotImplementedError

    def zeroGrad(self):
        for p in self.parameters:
            p.grad = 0.

Todos os otimizadores são implementados herdando da classe base Optimizer. Uma classe que descreve uma otimização específica deve fornecer uma implementação do método step(). Esse método atualiza os parâmetros do modelo usando suas derivadas parciais calculadas em relação ao valor otimizado da função de perda. Um link para vários parâmetros do modelo é fornecido na função __init__(). Observe que a funcionalidade universal para redefinir valores de gradiente é implementada na própria classe base.

Agora, para entender melhor tudo isso, considere um exemplo específico - a implementação do algoritmo de descida do gradiente estocástico (SGD) com suporte para ajustar o momento e reduzir pesos:

class SGD(Optimizer):
    def __init__(self,parameters,lr=.001,weight_decay=0.0,momentum = .9):
        super().__init__(parameters)
        self.lr           = lr
        self.weight_decay = weight_decay
        self.momentum     = momentum
        self.velocity     = []
        for p in parameters:
            self.velocity.append(np.zeros_like(p.grad))

    def step(self):
        for p,v in zip(self.parameters,self.velocity):
            v = self.momentum*v+p.grad+self.weight_decay*p.data
            p.data=p.data-self.lr*v

A solução para o problema real


Agora, temos todo o necessário para treinar o modelo de rede neural (profunda) usando nossa biblioteca. Para isso, precisamos das seguintes entidades:

  • Modelo: gráfico de cálculo.
  • Dados e valor-alvo: dados para treinamento em rede.
  • Função de perda: substitui a meta de otimização.
  • Otimizador: um mecanismo para atualizar os parâmetros do modelo.

O pseudo-código a seguir descreve um ciclo de teste típico:

model # 
data,target # 
loss_fn # 
optim #,         
Repeat:#   ,    ,     
   optim.zeroGrad() #    
   output = model.forward(data) #   
   loss   = loss_fn(output,target) # 
   grad   = loss.backward() #      
   model.backward(grad) #    
   optim.step() #  

Embora isso não seja necessário na biblioteca de aprendizado profundo, pode ser útil incluir a funcionalidade acima em uma classe separada. Isso nos permitirá não repetir as mesmas ações ao aprender novos modelos (essa idéia corresponde à filosofia das abstrações de alto nível de estruturas como Keras ). Para conseguir isso, declare uma classe Model:

class Model():
    def __init__(self):
        self.computation_graph = []
        self.parameters        = []

    def add(self,layer):
        self.computation_graph.append(layer)
        self.parameters+=layer.getParams()

    def __innitializeNetwork(self):
        for f in self.computation_graph:
            if f.type=='linear':
                weights,bias = f.getParams()
                weights.data = .01*np.random.randn(weights.data.shape[0],weights.data.shape[1])
                bias.data    = 0.

    def fit(self,data,target,batch_size,num_epochs,optimizer,loss_fn):
        loss_history = []
        self.__innitializeNetwork()
        data_gen = DataGenerator(data,target,batch_size)
        itr = 0
        for epoch in range(num_epochs):
            for X,Y in data_gen:
                optimizer.zeroGrad()
                for f in self.computation_graph: X=f.forward(X)
                loss = loss_fn.forward(X,Y)
                grad = loss_fn.backward()
                for f in self.computation_graph[::-1]: grad = f.backward(grad) 
                loss_history+=[loss]
                print("Loss at epoch = {} and iteration = {}: {}".format(epoch,itr,loss_history[-1]))
                itr+=1
                optimizer.step()
        
        return loss_history
    
    def predict(self,data):
        X = data
        for f in self.computation_graph: X = f.forward(X)
        return X

Esta classe inclui a seguinte funcionalidade:

  • : add() , . computation_graph.
  • : , , , .
  • : fit() . , .
  • : predict() , , .

Como essa classe não é o alicerce básico dos sistemas de aprendizado profundo, eu a implementei em um módulo separado utilities.py. Observe que o método fit()usa uma classe DataGeneratorcuja implementação está no mesmo módulo. Essa classe é apenas um invólucro para dados de treinamento e gera minipacotes para cada iteração de treinamento.

Modelo de treinamento


Agora considere o último pedaço de código no qual o modelo de rede neural é treinado usando a biblioteca descrita acima. Vou treinar uma rede multicamada em dados organizados em espiral. Fui solicitado por esta publicação. Código para gerar esses dados e para a visualização pode ser encontrada no arquivo utilities.py.


Dados com três classes dispostas em espiral 

A figura anterior mostra a visualização dos dados nos quais iremos treinar o modelo. Esses dados não são linearmente separáveis. Podemos esperar que uma rede com uma camada oculta possa encontrar corretamente limites de decisão não lineares. Se você reunir tudo o que falamos, você obtém o seguinte fragmento de código que permite treinar o modelo:

import dl_numpy as DL
import utilities

batch_size        = 20
num_epochs        = 200
samples_per_class = 100
num_classes       = 3
hidden_units      = 100
data,target       = utilities.genSpiralData(samples_per_class,num_classes)
model             = utilities.Model()
model.add(DL.Linear(2,hidden_units))
model.add(DL.ReLU())
model.add(DL.Linear(hidden_units,num_classes))
optim   = DL.SGD(model.parameters,lr=1.0,weight_decay=0.001,momentum=.9)
loss_fn = DL.SoftmaxWithLoss()
model.fit(data,target,batch_size,num_epochs,optim,loss_fn)
predicted_labels = np.argmax(model.predict(data),axis=1)
accuracy         = np.sum(predicted_labels==target)/len(target)
print("Model Accuracy = {}".format(accuracy))
utilities.plot2DDataWithDecisionBoundary(data,target,model)

A imagem abaixo mostra os mesmos dados e os limites decisivos do modelo treinado.


Dados e limites de decisão do modelo treinado

Sumário


Dada a crescente complexidade dos modelos de aprendizado profundo, há uma tendência de aumentar os recursos das respectivas bibliotecas e aumentar a quantidade de código necessária para implementar esses recursos. Mas a funcionalidade mais básica dessas bibliotecas ainda pode ser implementada de uma forma relativamente compacta. Embora a biblioteca que criamos possa ser usada para o treinamento de ponta a ponta de redes simples, ela ainda é, de muitas maneiras, limitada. Estamos falando de limitações no campo de recursos que permitem que estruturas de aprendizado profundo sejam usadas em áreas como visão de máquina, reconhecimento de fala e texto. Isso, é claro, as possibilidades de tais estruturas não são limitadas.

Acredito que todos possam participar do projeto, o código do qual examinamos aqui e, como exercício, introduza nele o que eles gostariam de ver. Aqui estão alguns mecanismos que você pode tentar se implementar:

  • Operadores: convolução, subamostragem.
  • Otimizadores: Adam, RMSProp.
  • Reguladores: BatchNorm, DropOut.

Espero que esse material tenha permitido que você veja pelo menos com o canto do olho o que está acontecendo nas entranhas das bibliotecas para aprendizado profundo.

Queridos leitores! Quais bibliotecas de aprendizado profundo você usa?

Source: https://habr.com/ru/post/undefined/


All Articles