Acerca de implementar una biblioteca de aprendizaje profundo en Python

Las tecnologías de aprendizaje profundo han recorrido un largo camino en un corto período de tiempo, desde redes neuronales simples hasta arquitecturas bastante complejas. Para apoyar la rápida difusión de estas tecnologías, se han desarrollado varias bibliotecas y plataformas de aprendizaje profundo. Uno de los objetivos principales de tales bibliotecas es proporcionar a los desarrolladores interfaces simples para crear y entrenar modelos de redes neuronales. Dichas bibliotecas permiten a sus usuarios prestar más atención a las tareas que se resuelven y no a las sutilezas de la implementación del modelo. Para hacer esto, es posible que deba ocultar la implementación de mecanismos básicos detrás de varios niveles de abstracción. Y esto, a su vez, complica la comprensión de los principios básicos en los que se basan las bibliotecas de aprendizaje profundo.



El artículo, cuya traducción estamos publicando, tiene como objetivo analizar las características del dispositivo de bloques de construcción de bajo nivel de bibliotecas de aprendizaje profundo. Primero, hablamos brevemente sobre la esencia del aprendizaje profundo. Esto nos permitirá comprender los requisitos funcionales para el software respectivo. Luego buscamos desarrollar una biblioteca de aprendizaje profundo simple pero funcional en Python usando NumPy. Esta biblioteca es capaz de proporcionar capacitación integral para modelos simples de redes neuronales. En el camino, hablaremos sobre los diversos componentes de los marcos de aprendizaje profundo. La biblioteca que consideraremos es bastante pequeña, menos de 100 líneas de código. Y esto significa que será bastante simple resolverlo. El código completo del proyecto, que trataremos, se puede encontrar aquí .

Información general


Por lo general, las bibliotecas de aprendizaje profundo (como TensorFlow y PyTorch) constan de los componentes que se muestran en la siguiente figura.


Componentes del marco de aprendizaje profundo

Analicemos estos componentes.

▍ Operadores


Los conceptos de "operador" y "capa" (capa) generalmente se usan indistintamente. Estos son los componentes básicos de cualquier red neuronal. Los operadores son funciones vectoriales que transforman datos. Entre los operadores de uso frecuente, se pueden distinguir como las capas de activación lineal y de convolución, capas de submuestreo (agrupación), semi-lineal (ReLU) y sigmoide (sigmoide).

▍Optimizadores (optimizadores)


Los optimizadores son la base de las bibliotecas de aprendizaje profundo. Describen métodos para ajustar los parámetros del modelo utilizando ciertos criterios y teniendo en cuenta el objetivo de la optimización. Entre los optimizadores conocidos, se pueden observar SGD, RMSProp y Adam.

▍ Funciones de pérdida


Las funciones de pérdida son expresiones matemáticas analíticas y diferenciables que se utilizan como sustituto del objetivo de optimización al resolver un problema. Por ejemplo, la función de entropía cruzada y la función lineal por partes se usan generalmente en problemas de clasificación.

▍ Inicializadores


Los inicializadores proporcionan valores iniciales para los parámetros del modelo. Son estos valores los que tienen los parámetros al comienzo del entrenamiento. Los inicializadores desempeñan un papel importante en el entrenamiento de las redes neuronales, ya que los parámetros iniciales fallidos pueden significar que la red aprenderá lentamente o que no podrá aprender en absoluto. Hay muchas formas de inicializar los pesos de una red neuronal. Por ejemplo, puede asignarles pequeños valores aleatorios de la distribución normal. Aquí hay una página donde puede aprender sobre los diferentes tipos de inicializadores.

▍ Regularizadores


Los regularizadores son herramientas que evitan el reentrenamiento de la red y ayudan a la red a generalizarse. Puede lidiar con el reentrenamiento de la red de manera explícita o implícita. Los métodos explícitos implican limitaciones estructurales en los pesos. Por ejemplo, minimizando su Norma L1 y la Norma L2, lo que, en consecuencia, hace que los valores de peso estén mejor dispersos y distribuidos de manera más uniforme. Los métodos implícitos están representados por operadores especializados que realizan la transformación de representaciones intermedias. Esto se hace a través de la normalización explícita, por ejemplo, usando la técnica de normalización de paquetes (BatchNorm), o cambiando la conectividad de red usando los algoritmos DropOut y DropConnect.

Los componentes anteriores generalmente pertenecen a la parte de la interfaz de la biblioteca. Aquí, por "parte de la interfaz" me refiero a las entidades con las que el usuario puede interactuar. Le proporcionan herramientas convenientes para diseñar eficientemente una arquitectura de red neuronal. Si hablamos de los mecanismos internos de las bibliotecas, pueden proporcionar soporte para el cálculo automático de gradientes de la función de pérdida, teniendo en cuenta varios parámetros del modelo. Esta técnica se llama comúnmente diferenciación automática (AD).

Diferenciación automática


Cada biblioteca de aprendizaje profundo proporciona al usuario algunas capacidades de diferenciación automática. Esto le da la oportunidad de centrarse en la descripción de la estructura del modelo (gráfico de cálculos) y transferir la tarea de calcular los gradientes al módulo AD. Tomemos un ejemplo que nos permitirá saber cómo funciona todo. Supongamos que queremos calcular las derivadas parciales de la siguiente función con respecto a sus variables de entrada X₁ y X₂:

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

La siguiente figura, que tomé prestada de aquí , muestra la gráfica de cálculos y el cálculo de derivadas usando una regla de cadena.


Gráfico computacional y cálculo de derivados por una regla de cadena

Lo que ves aquí es algo así como un "modo inverso" de diferenciación automática. El conocido algoritmo de propagación de error de retorno es un caso especial del algoritmo anterior para el caso en que la función ubicada en la parte superior es una función de pérdida. AD explota el hecho de que cualquier función compleja consiste en operaciones aritméticas elementales y funciones elementales. Como resultado, los derivados se pueden calcular aplicando una regla de cadena a estas operaciones.

Implementación


En la sección anterior, examinamos los componentes necesarios para crear una biblioteca de aprendizaje profundo diseñada para crear y capacitar de extremo a extremo las redes neuronales. Para no complicar el ejemplo, imito el patrón de diseño de la biblioteca Caffe aquí . Aquí declaramos dos clases abstractas - Functiony Optimizer. Además, hay una clase Tensor, que es una estructura simple que contiene dos matrices NumPy multidimensionales. Uno de ellos está diseñado para almacenar valores de parámetros, el otro, para almacenar sus gradientes. Todos los parámetros en diferentes capas (operadores) serán de tipo Tensor. Antes de continuar, eche un vistazo al esquema general de la biblioteca.


Diagrama UML de la biblioteca

Al momento de escribir este material, esta biblioteca contiene una implementación de la capa lineal, la función de activación ReLU, la capa SoftMaxLoss y el optimizador SGD. Como resultado, resulta que la biblioteca se puede usar para entrenar modelos de clasificación que consisten en capas completamente conectadas y que usan una función de activación no lineal. Ahora veamos algunos detalles sobre las clases abstractas que tenemos.

Una clase abstractaFunctionproporciona una interfaz para operadores. Aquí está su código:

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

Todos los operadores se implementan a través de la herencia de una clase abstracta Function. Cada operador debe proporcionar una implementación de los métodos forward()y backward(). Los operadores pueden contener una implementación de un método opcional getParams()que devuelve sus parámetros (si los hay). El método forward()recibe datos de entrada y devuelve el resultado de su transformación por parte del operador. Además, resuelve los problemas internos necesarios para calcular gradientes. El método backward()acepta las derivadas parciales de la función de pérdida con respecto a las salidas del operador e implementa el cálculo de las derivadas parciales de la función de pérdida con respecto a los datos de entrada del operador y los parámetros (si los hay). Tenga en cuenta que el métodobackward(), en esencia, proporciona a nuestra biblioteca la capacidad de realizar diferenciaciones automáticas.

Para tratar todo esto con un ejemplo específico, echemos un vistazo a la implementación de la función 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]

El método forward()implementa la transformación de la vista Y = X*W+by devuelve el resultado. Además, guarda el valor de entrada X, ya que es necesario para calcular la derivada parcial de dYla función de pérdida con respecto al valor de salida Yen el método backward(). El método backward()recibe las derivadas parciales, calculadas con respecto al valor de entrada Xy los parámetros Wy b. Además, devuelve las derivadas parciales calculadas con respecto al valor de entrada X, que se transferirá a la capa anterior.

Una clase abstracta Optimizerproporciona una interfaz para optimizadores:

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 los optimizadores se implementan heredando de la clase base Optimizer. Una clase que describe una optimización particular debe proporcionar una implementación del método step(). Este método actualiza los parámetros del modelo utilizando sus derivadas parciales calculadas en relación con el valor optimizado de la función de pérdida. Se proporciona un enlace a varios parámetros del modelo en la función __init__(). Tenga en cuenta que la funcionalidad universal para restablecer los valores de gradiente se implementa en la propia clase base.

Ahora, para comprender mejor todo esto, considere un ejemplo específico: la implementación del algoritmo de descenso de gradiente estocástico (SGD) con soporte para ajustar el impulso y reducir los 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

La solución al problema real.


Ahora tenemos todo lo necesario para entrenar el modelo de red neuronal (profunda) utilizando nuestra biblioteca. Para esto necesitamos las siguientes entidades:

  • Modelo: gráfico de cálculo.
  • Datos y valor objetivo: datos para la formación de redes.
  • Función de pérdida: sustituto del objetivo de optimización.
  • Optimizer: un mecanismo para actualizar los parámetros del modelo.

El siguiente pseudocódigo describe un ciclo de prueba 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() #  

Aunque esto no es necesario en la biblioteca de aprendizaje profundo, puede ser útil incluir la funcionalidad anterior en una clase separada. Esto nos permitirá no repetir los mismos pasos al aprender nuevos modelos (esta idea corresponde a la filosofía de abstracciones de alto nivel de marcos como Keras ). Para lograr esto, declare una clase 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 clase incluye la siguiente funcionalidad:

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

Como esta clase no es el componente básico de los sistemas de aprendizaje profundo, la implementé en un módulo separado utilities.py. Tenga en cuenta que el método fit()utiliza una clase DataGeneratorcuya implementación está en el mismo módulo. Esta clase es solo un contenedor para los datos de entrenamiento y genera mini paquetes para cada iteración de entrenamiento.

Entrenamiento modelo


Ahora considere el último fragmento de código en el que se entrena el modelo de red neuronal utilizando la biblioteca descrita anteriormente. Voy a entrenar una red multicapa en datos dispuestos en espiral. Me impulsó esta publicación. El código para generar estos datos y para visualizarlos se puede encontrar en el archivo utilities.py.


Datos con tres clases dispuestas en espiral 

La figura anterior muestra la visualización de los datos en los que entrenaremos el modelo. Estos datos son no linealmente separables. Podemos esperar que una red con una capa oculta pueda encontrar correctamente límites de decisión no lineales. Si reúne todo lo que mencionamos, obtendrá el siguiente fragmento de código que le permite entrenar el 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)

La siguiente imagen muestra los mismos datos y los límites decisivos del modelo entrenado.


Datos y límites de decisión del modelo entrenado

Resumen


Dada la creciente complejidad de los modelos de aprendizaje profundo, existe una tendencia a aumentar las capacidades de las bibliotecas respectivas y a aumentar la cantidad de código necesario para implementar estas capacidades. Pero la funcionalidad más básica de tales bibliotecas todavía se puede implementar en una forma relativamente compacta. Aunque la biblioteca que creamos se puede utilizar para la capacitación integral de redes simples, todavía es, en muchos sentidos, limitada. Estamos hablando de limitaciones en el campo de las capacidades que permiten que los marcos de aprendizaje profundo se utilicen en áreas como la visión artificial, el reconocimiento de voz y texto. Esto, por supuesto, las posibilidades de tales marcos no son limitadas.

Creo que todos pueden bifurcar el proyecto, cuyo código examinamos aquí y, como ejercicio, introducimos en él lo que les gustaría ver en él. Aquí hay algunos mecanismos que puede intentar implementar usted mismo:

  • Operadores: convolución, submuestreo.
  • Optimizadores: Adam, RMSProp.
  • Reguladores: BatchNorm, DropOut.

Espero que este material le haya permitido al menos ver de reojo lo que está sucediendo en las entrañas de las bibliotecas para el aprendizaje profundo.

¡Queridos lectores! ¿Qué bibliotecas de aprendizaje profundo utilizas?

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


All Articles