À propos de l'implémentation d'une bibliothèque d'apprentissage en profondeur dans Python

Les technologies d'apprentissage en profondeur ont parcouru un long chemin en peu de temps - des réseaux neuronaux simples aux architectures assez complexes. Pour soutenir la diffusion rapide de ces technologies, diverses bibliothèques et plates-formes d'apprentissage en profondeur ont été développées. L'un des principaux objectifs de ces bibliothèques est de fournir aux développeurs des interfaces simples pour créer et former des modèles de réseaux neuronaux. Ces bibliothèques permettent à leurs utilisateurs d'accorder plus d'attention aux tâches à résoudre et non aux subtilités de la mise en œuvre du modèle. Pour ce faire, vous devrez peut-être masquer l'implémentation des mécanismes de base derrière plusieurs niveaux d'abstraction. Et cela, à son tour, complique la compréhension des principes de base sur lesquels reposent les bibliothèques d'apprentissage en profondeur.



L'article, dont nous publions la traduction, vise à analyser les caractéristiques du dispositif des blocs de construction de bas niveau des bibliothèques d'apprentissage en profondeur. Tout d'abord, nous parlons brièvement de l'essence de l'apprentissage en profondeur. Cela nous permettra de comprendre les exigences fonctionnelles du logiciel respectif. Ensuite, nous envisageons de développer une bibliothèque d'apprentissage en profondeur simple mais fonctionnelle en Python à l'aide de NumPy. Cette bibliothèque est capable de fournir une formation de bout en bout pour les modèles de réseau neuronal simples. En cours de route, nous parlerons des différentes composantes des cadres d'apprentissage en profondeur. La bibliothèque que nous allons considérer est assez petite, moins de 100 lignes de code. Et cela signifie qu'il sera assez simple de le comprendre. Le code de projet complet, dont nous traiterons, se trouve ici .

informations générales


En règle générale, les bibliothèques d'apprentissage en profondeur (telles que TensorFlow et PyTorch) sont constituées des composants illustrés dans la figure suivante.


Composants du cadre d'apprentissage en profondeur

Analysons ces composants.

▍ Opérateurs


Les concepts d '«opérateur» et de «couche» (couche) sont généralement utilisés de manière interchangeable. Ce sont les éléments de base de tout réseau neuronal. Les opérateurs sont des fonctions vectorielles qui transforment les données. Parmi les opérateurs fréquemment utilisés, on peut distinguer comme les couches d'activation linéaires et convolutives, les couches de sous-échantillonnage (pooling), semi-linéaires (ReLU) et sigmoïdes (sigmoïdes).

▍Optimiseurs (optimiseurs)


Les optimiseurs sont le fondement des bibliothèques d'apprentissage en profondeur. Ils décrivent des méthodes d'ajustement des paramètres du modèle en utilisant certains critères et en tenant compte de l'objectif d'optimisation. Parmi les optimiseurs bien connus, on peut citer SGD, RMSProp et Adam.

▍ Fonctions de perte


Les fonctions de perte sont des expressions mathématiques analytiques et différenciables qui sont utilisées comme substitut à l'objectif d'optimisation lors de la résolution d'un problème. Par exemple, la fonction d'entropie croisée et la fonction linéaire par morceaux sont généralement utilisées dans les problèmes de classification.

▍ Initialiseurs


Les initialiseurs fournissent des valeurs initiales pour les paramètres du modèle. Ce sont ces valeurs que les paramètres ont au début de l'entraînement. Les initialiseurs jouent un rôle important dans la formation des réseaux de neurones, car des paramètres initiaux infructueux peuvent signifier que le réseau apprendra lentement ou peut-être pas du tout. Il existe de nombreuses façons d'initialiser les poids d'un réseau neuronal. Par exemple, vous pouvez leur attribuer de petites valeurs aléatoires à partir de la distribution normale. Voici une page où vous pourrez découvrir les différents types d'initialiseurs.

▍ Régularisateurs


Les régularisateurs sont des outils qui évitent le recyclage du réseau et aident le réseau à se généraliser. Vous pouvez gérer le recyclage du réseau de manière explicite ou implicite. Les méthodes explicites impliquent des limitations structurelles sur les poids. Par exemple, minimiser leurs normes L1 et L2, ce qui, en conséquence, rend les valeurs de poids mieux dispersées et réparties plus uniformément. Les méthodes implicites sont représentées par des opérateurs spécialisés qui effectuent la transformation des représentations intermédiaires. Cela se fait soit par une normalisation explicite, par exemple, en utilisant la technique de normalisation des paquets (BatchNorm), soit en modifiant la connectivité réseau à l'aide des algorithmes DropOut et DropConnect.

Les composants ci-dessus appartiennent généralement à la partie interface de la bibliothèque. Ici, par «partie interface», j'entends les entités avec lesquelles l'utilisateur peut interagir. Ils lui donnent des outils pratiques pour concevoir efficacement une architecture de réseau neuronal. Si nous parlons des mécanismes internes des bibliothèques, ils peuvent fournir un support pour le calcul automatique des gradients de la fonction de perte, en tenant compte de divers paramètres du modèle. Cette technique est communément appelée différenciation automatique (AD).

Différenciation automatique


Chaque bibliothèque d'apprentissage en profondeur offre à l'utilisateur des capacités de différenciation automatique. Cela lui donne l'opportunité de se concentrer sur la description de la structure du modèle (graphique des calculs) et de transférer la tâche de calcul des gradients au module AD. Prenons un exemple qui nous permettra de savoir comment tout cela fonctionne. Supposons que nous voulons calculer les dérivées partielles de la fonction suivante par rapport à ses variables d'entrée X₁ et X₂:

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

La figure suivante, que j'ai empruntée ici , montre le graphique des calculs et le calcul des dérivés à l'aide d'une règle de chaîne.


Graphique de calcul et calcul des dérivées par une règle de chaîne

Ce que vous voyez ici est quelque chose comme un «mode inverse» de différenciation automatique. L'algorithme bien connu de rétro-propagation d'erreur est un cas particulier de l'algorithme décrit ci-dessus pour le cas où la fonction située en haut est une fonction de perte. AD exploite le fait que toute fonction complexe se compose d'opérations arithmétiques élémentaires et de fonctions élémentaires. Par conséquent, les dérivés peuvent être calculés en appliquant une règle de chaîne à ces opérations.

la mise en oeuvre


Dans la section précédente, nous avons examiné les composants nécessaires à la création d'une bibliothèque d'apprentissage en profondeur conçue pour créer et former de bout en bout des réseaux de neurones. Afin de ne pas compliquer l'exemple, j'imite ici le modèle de conception de la bibliothèque Caffe . Ici, nous déclarons deux classes abstraites - Functionet Optimizer. De plus, il existe une classe Tensor, qui est une structure simple contenant deux tableaux NumPy multidimensionnels. L'un d'eux est conçu pour stocker les valeurs des paramètres, l'autre - pour stocker leurs gradients. Tous les paramètres des différentes couches (opérateurs) seront de type Tensor. Avant d'aller plus loin, jetez un œil au plan général de la bibliothèque.


Diagramme UML de la bibliothèque

Au moment de la rédaction de ce document, cette bibliothèque contient une implémentation de la couche linéaire, de la fonction d'activation ReLU, de la couche SoftMaxLoss et de l'optimiseur SGD. En conséquence, il s'avère que la bibliothèque peut être utilisée pour former des modèles de classification composés de couches entièrement connectées et utilisant une fonction d'activation non linéaire. Voyons maintenant quelques détails sur les classes abstraites que nous avons.

Une classe abstraiteFunctionfournit une interface pour les opérateurs. Voici son code:

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

Tous les opérateurs sont implémentés via l'héritage d'une classe abstraite Function. Chaque opérateur doit fournir une mise en œuvre des méthodes forward()et backward(). Les opérateurs peuvent contenir une implémentation d'une méthode facultative getParams()qui renvoie leurs paramètres (le cas échéant). La méthode forward()reçoit les données d'entrée et renvoie le résultat de leur transformation par l'opérateur. De plus, il résout les problèmes internes nécessaires au calcul des gradients. Le procédé backward()accepte les dérivées partielles de la fonction de perte par rapport aux sorties de l'opérateur et met en œuvre le calcul des dérivées partielles de la fonction de perte par rapport aux données d'entrée de l'opérateur et aux paramètres (le cas échéant). Notez que la méthodebackward(), en substance, offre à notre bibliothèque la possibilité d'effectuer une différenciation automatique.

Afin de traiter tout cela avec un exemple spécifique, regardons l'implémentation de la fonction 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]

La méthode forward()implémente la transformation de la vue Y = X*W+bet renvoie le résultat. De plus, il enregistre la valeur d'entrée X, car il est nécessaire de calculer la dérivée partielle de dYla fonction de perte par rapport à la valeur de sortie Ydans la méthode backward(). La méthode backward()reçoit les dérivées partielles, calculées par rapport à la valeur d'entrée Xet aux paramètres Wet b. De plus, il renvoie les dérivées partielles calculées par rapport à la valeur d'entrée X, qui seront transférées à la couche précédente.

Une classe abstraite Optimizerfournit une interface pour les optimiseurs:

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.

Tous les optimiseurs sont implémentés en héritant de la classe de base Optimizer. Une classe décrivant une optimisation particulière devrait fournir une implémentation de la méthode step(). Cette méthode met à jour les paramètres du modèle en utilisant leurs dérivées partielles calculées par rapport à la valeur optimisée de la fonction de perte. Un lien vers divers paramètres du modèle est fourni dans la fonction __init__(). Veuillez noter que la fonctionnalité universelle de réinitialisation des valeurs de gradient est implémentée dans la classe de base elle-même.

Maintenant, pour mieux comprendre tout cela, considérons un exemple spécifique - la mise en œuvre de l'algorithme de descente de gradient stochastique (SGD) avec prise en charge de l'ajustement de la quantité de mouvement et de la réduction des poids:

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 solution au vrai problème


Nous avons maintenant tout le nécessaire pour former le modèle de réseau de neurones (profond) à l'aide de notre bibliothèque. Pour cela, nous avons besoin des entités suivantes:

  • Modèle: graphique de calcul.
  • Données et valeur cible: données pour la formation du réseau.
  • Fonction de perte: substitut à l'objectif d'optimisation.
  • Optimizer: un mécanisme de mise à jour des paramètres du modèle.

Le pseudo-code suivant décrit un cycle de test typique:

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() #  

Bien que cela ne soit pas nécessaire dans la bibliothèque d'apprentissage en profondeur, il peut être utile d'inclure les fonctionnalités ci-dessus dans une classe distincte. Cela nous permettra de ne pas répéter les mêmes actions lors de l'apprentissage de nouveaux modèles (cette idée correspond à la philosophie des abstractions de haut niveau de frameworks comme Keras ). Pour ce faire, déclarez une 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

Cette classe comprend les fonctionnalités suivantes:

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

Étant donné que cette classe n'est pas la composante de base des systèmes d'apprentissage en profondeur, je l'ai implémentée dans un module distinct utilities.py. Notez que la méthode fit()utilise une classe DataGeneratordont l'implémentation est dans le même module. Cette classe est juste un wrapper pour les données de formation et génère des mini-packages pour chaque itération de la formation.

Formation modèle


Considérons maintenant le dernier morceau de code dans lequel le modèle de réseau neuronal est formé à l'aide de la bibliothèque décrite ci-dessus. Je vais former un réseau multicouche sur des données disposées en spirale. J'ai été incité par cette publication. Le code pour générer ces données et pour les visualiser se trouve dans le fichier utilities.py.


Données avec trois classes disposées en spirale 

La figure précédente montre la visualisation des données sur lesquelles nous allons former le modèle. Ces données sont séparables de manière non linéaire. Nous pouvons espérer qu'un réseau avec une couche cachée puisse trouver correctement les frontières de décision non linéaires. Si vous assemblez tout ce dont nous avons parlé, vous obtenez le fragment de code suivant qui vous permet de former le modèle:

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)

L'image ci-dessous montre les mêmes données et les limites décisives du modèle entraîné.


Données et limites de décision du modèle formé

Sommaire


Étant donné la complexité croissante des modèles d'apprentissage en profondeur, il y a une tendance à augmenter les capacités des bibliothèques respectives et à augmenter la quantité de code nécessaire pour implémenter ces capacités. Mais les fonctionnalités les plus élémentaires de ces bibliothèques peuvent toujours être implémentées sous une forme relativement compacte. Bien que la bibliothèque que nous avons créée puisse être utilisée pour la formation de bout en bout de réseaux simples, elle est encore, à bien des égards, limitée. Nous parlons de limitations dans le domaine des capacités qui permettent aux cadres d'apprentissage profond d'être utilisés dans des domaines tels que la vision industrielle, la reconnaissance vocale et textuelle. Ceci, bien sûr, les possibilités de tels cadres ne sont pas limitées.

Je crois que tout le monde peut bifurquer le projet, dont nous avons examiné ici le code et, en tant qu'exercice, y introduisons ce qu'ils aimeraient y voir. Voici quelques mécanismes que vous pouvez essayer de mettre en œuvre vous-même:

  • Opérateurs: convolution, sous-échantillonnage.
  • Optimiseurs: Adam, RMSProp.
  • Régulateurs: BatchNorm, DropOut.

J'espère que ce matériel vous a permis au moins de voir du coin de l'œil ce qui se passe dans les entrailles des bibliothèques pour le deep learning.

Chers lecteurs! Quelles bibliothèques d'apprentissage en profondeur utilisez-vous?

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


All Articles