[WIP] Créer un chaînage à 1 fichier

Aperçu

Dans cet article, afin de comprendre ** «Define-by-Run» **, qui est le concept le plus caractéristique de Chainer, qui est un framework de réseau neuronal, nous allons décrire et apprendre un réseau pour classer les nombres manuscrits. Implémentons la bibliothèque "1f-chainer" qui n'a que le minimum de fonctions nécessaires en utilisant uniquement NumPy. Toutes les explications qui apparaissent dans les formules sont poussées vers l'annexe, et j'ai pris soin d'expliquer dans le texte avec seulement du code et des phrases autant que possible.

Tout le code utilisé dans cet article se trouve ci-dessous: 1f-chainer. Quand j'ai commencé à écrire, je voulais ajouter diverses choses et je n'ai pas pu le faire à temps, donc je vais le mettre à jour un par un d'ici la fin de cette semaine.

De plus, tout le contenu de cet article est basé sur mon opinion et ma compréhension personnelles et n'a rien à voir avec l'organisation à laquelle j'appartiens.

Lecteur supposé

Cet article est écrit en supposant que vous avez une connaissance de base de la formation des réseaux de neurones avec la rétro-propagation et que vous avez de l'expérience avec Python et NumPy. Veuillez noter que l'auteur est un fan de Chainer, donc ses impressions de Chainer sont basées sur ses sentiments personnels et ne sont pas des opinions officielles.

introduction

Chainer est un framework écrit en Python qui a la capacité de créer et d'apprendre des réseaux de neurones.

Je pense que c'est une bibliothèque qui vise de telles choses.

Autant que je sache, le framework / bibliothèque bien connu pour la construction et l'apprentissage du réseau neuronal

Il y a beaucoup de choses comme, mais pour autant que je sache, il existe peu de frameworks écrits uniquement en Python, y compris le back-end. D'un autre côté, Chainer adopte NumPy comme bibliothèque de tenseurs pour CPU et CuPy initialement développée pour GPU, mais l'une des fonctionnalités de Chainer est que les deux sont des bibliothèques Python indépendantes qui peuvent être utilisées indépendamment. Et je pense que vous l'êtes.

CuPy est principalement écrit en Cython et dispose d'un mécanisme pour compiler et exécuter le noyau CUDA en interne. Il sert également de wrapper pour cuDNN, une bibliothèque de réseau neuronal développée par NVIDIA qui suppose l'utilisation de GPU NVIDIA. Et la grande caractéristique de CuPy est qu'il dispose d'une API compatible NumPy. Cela signifie que vous pouvez facilement réécrire le code du processeur écrit à l'aide de NumPy pour le traitement du GPU avec très peu de changements ** (la chaîne numpy dans le code Vous devrez peut-être simplement le remplacer par cupy). Puisqu'il existe de nombreuses fonctions et fonctions NumPy (indexation avancée, etc. [^ cupy-PR]) que CuPy ne prend pas encore en charge, il existe diverses restrictions et exceptions, mais fondamentalement, le but de CuPy est le code pour CPU et GPU. Je pense qu'il s'agit de rendre le code aussi proche que possible.

Je n'ai pas essayé tous les principaux frameworks / bibliothèques énumérés ci-dessus, mais la conception interne de Chainer des procédures de calcul pour la construction et l'apprentissage des réseaux de neurones est ** ". L'idée de Define-by-Run "** est utilisée, et je pense que c'est une fonctionnalité importante qui distingue Chainer des autres frameworks. «Définir par exécution», comme son nom l'indique, signifie «définir en exécutant» la structure du réseau neuronal. Cela signifie que la structure du réseau n'a pas encore été déterminée avant son exécution, et ce n'est qu'après l'exécution du code que la manière dont chaque partie du réseau sera connectée sera déterminée. Plus précisément, lorsqu'une certaine variable d'entrée est appliquée avec une fonction, la sortie est une nouvelle variable qui mémorise "quel type de fonction a été appliquée", de sorte que "le calcul réellement effectué" Si tel est le cas, il sera possible de suivre la direction opposée autant de fois que vous le souhaitez, ce qui signifie que vous pouvez effectivement effectuer une "construction d'exécution du graphe de calcul". Pour cette raison, l'utilisateur doit décrire "le processus de calcul de propagation avant du réseau (y compris le branchement conditionnel basé sur des règles, le branchement conditionnel probabiliste ou la génération d'une nouvelle couche dans le processus de calcul)" en utilisant Python. En d'autres termes, si vous écrivez le "code qui représente le calcul avant", vous avez défini le réseau.

Plusieurs classes pour atteindre ces caractéristiques sont au cœur de Chainer. Dans cet article, je vais essayer de n'implémenter que la partie la plus élémentaire de Chainer par moi-même en tant que bibliothèque composée d'un fichier (comme le framework Web de Python, Bottle), et le cœur de celui-ci sera superficiel. Le but est de comprendre.

Implémentation d'un réseau neuronal différent de Chainer

Commençons par regarder ce qui se passe si nous écrivons du code qui construit et apprend le réseau neuronal aussi simplement que possible, sans être au courant de l'implémentation «de type Chainer». Le code qui apparaît dans cette section ci-dessous suppose que ʻimport numpy` est fait au début. De plus, il est fondamentalement supposé que les paramètres seront mis à jour par SGD mini-batch lors de l'apprentissage ultérieur de Neural Network.

Réseau composé d'une couche linéaire et ReLU

Un perceptron à trois couches constitué de seulement deux couches, la couche linéaire et la fonction d'activation ReLU, peut être défini comme suit.

class Linear(object):

    def __init__(self, in_sz, out_sz):
        self.W = numpy.random.randn(out_sz, in_sz) * numpy.sqrt(2. / in_sz)
        self.b = numpy.zeros((out_sz,))

    def __call__(self, x):
        self.x = x
        return x.dot(self.W.T) + self.b

    def update(self, gy, lr):
        self.W -= lr * gy.T.dot(self.x)
        self.b -= lr * gy.sum(axis=0)
        return gy.dot(self.W)

class ReLU(object):

    def __call__(self, x):
        self.x = x
        return numpy.maximum(0, x)

    def update(self, gy, lr):
        return gy * (self.x > 0)

model = [
    Linear(784, 100),
    ReLU(),
    Linear(100, 100),
    ReLU(),
    Linear(100, 10)
]

c'est tout. Vous pouvez voir qu'il peut être écrit très court. Une description détaillée de la couche linéaire ci-dessus est donnée plus loin dans l'annexe: [Bases de la couche linéaire](#Linear Layer Basics). Il y a aussi une brève explication supplémentaire sur la couche ReLU ([About the ReLU layer](#About the ReLU layer)).

Ensuite, nous préparerons les fonctions forward et ʻupdate qui sont nécessaires pour entraîner ce perceptron à trois couches en utilisant les données x et la bonne réponse t`.

def forward(model, x):
    for layer in model:
        x = layer(x)
    return x

def update(model, gy, lr=0.0001):
    for layer in reversed(model):
        gy = layer.update(gy, lr)

En utilisant ces deux fonctions, le perceptron à trois couches ci-dessus peut être formé. Dans la fonction «avant», le contenu du «modèle» donné sous forme de liste de couches de configuration est examiné dans l'ordre, et les données sont propagées vers l'avant. Au contraire, dans la fonction ʻupdate, les couches du modelsont visualisées ** dans l'ordre inverse **, et legy`, qui est la puissance totale de la partie de la multiplication des gradients qui apparaît dans la règle de la chaîne, avant que le soi ne soit calculé. Il se propage en arrière. En résumé, la procédure d'apprentissage est

--Inférence: donnez à la fonction forward les données x et mettez la sortie résultante comme y

Il y a 3 étapes. Le code qui répète réellement ces processus à l'aide du jeu de données MNIST et forme le réseau peut être écrit comme suit. Où «td» représente un «numpy.ndarray» sous la forme de «(60000, 784)» avec des données vectorielles dimensionnelles de 60000 $ alignées, et «tl» est un vecteur unidimensionnel à 10 $ (bonne réponse). Supposons que vous vouliez représenter un numpy.ndarray de la forme (60000, 10) avec $ 60000 $ (vecteurs où seule la dimension correspondant au numéro de classe est $ 1 $ et toutes les autres dimensions sont 0). Le code qui crée réellement ces tableaux se trouve dans l'annexe: Load Dataset (#Read Dataset).

def softmax_cross_entropy_gy(y, t):
    return (numpy.exp(y.T) / numpy.exp(y.T).sum(axis=0)).T - t

#Apprentissage
for epoch in range(30):
    for i in range(0, len(td), 128):
        x, t = td[i:i + 128], tl[i:i + 128]
        y = forward(model, x)
        gy = softmax_cross_entropy_gy(y, t)
        update(model, gy)

Vous pouvez voir que le flux d'apprentissage de base avec le SGD mini-batch est également très simple. Puisque le réseau est défini comme une liste en Python, le calcul avant peut être effectué simplement en donnant une variable d'entrée au premier élément de la liste et en donnant la sortie obtenue comme entrée de l'élément suivant de la liste. Je vais. Dans la fonction ʻupdate qui met à jour les paramètres, vous pouvez "regarder cette liste dans l'ordre inverse", calculer le gy dans l'ordre depuis l'arrière, et le passer à la méthode ʻupdate de chaque couche.

Notez que la fonction softmax_cross_entropy_gy renvoie le gradient de l'entrée à la valeur de perte, pas la valeur de perte elle-même. J'ai écrit en annexe sur la valeur de la fonction de perte Softmax Cross Entropy et son gradient: Calcul et différenciation de Softmax Cross Entropy.

Après avoir exécuté la boucle d'apprentissage ci-dessus, examinons la précision de classification de l'ensemble de données de validation de MNIST.

y = forward(model, vd)
n_correct = numpy.sum(vl[numpy.arange(len(vl)), y.argmax(axis=1)])
acc = n_correct / float(len(vl))

print(acc)

Ici, «vd» représente les données de validation et «vl» représente le libellé correspondant aux données de validation, et comment les créer est écrit dans l'annexe ainsi que les données de formation: [Read Dataset](#Read Dataset). Après avoir effectué un apprentissage d'époque de 30 $ et vérifié l'exactitude des données de validation avec le code ci-dessus, il a été constaté que la précision de 0,9674 $, c'est-à-dire 96,74 $ % $, était atteinte.

L'ensemble du code se trouve à droite: minimum.py. Lorsqu'il est exécuté avec python minimum.py, il s'entraîne, affiche la précision dans le jeu de données de validation et se termine.

Implémentation de type chaîne de Neural Network

Ensuite, c'est le sujet principal. Nous avons constaté que la mise en œuvre des couches de base qui composent le réseau neuronal est très facile. Alors, quel type de mise en œuvre Chainer fait-il pour définir et former des réseaux similaires?

Signification du calcul à terme dans Chainer

Chainer définit la structure du réseau en se basant sur l'idée de ** "Define-by-Run" ** comme mentionné au début. En revanche, la méthode de mise en œuvre décrite ci-dessus est appelée ** "Define-and-Run" **, où la structure du réseau est ** fixée à l'avance ** et ensuite le processus d'apprentissage (en avant). C'était un mécanisme pour exécuter des calculs, des calculs en arrière, des mises à jour de paramètres, etc.). Par conséquent, il est très gênant ou peu pratique d'essayer de changer la structure du réseau en fonction des données (il y a un point de branchement au milieu et la branche vers laquelle se dirige le transfert dépend du contenu des données, etc.). Cela peut être possible [^ branch]. Cependant, dans "Define-by-Run", ** décrire le calcul avant et définir la structure du réseau sont synonymes **, et comme le calcul avant peut être décrit en Python, il inclut des éléments de branchement et probabilistes. Il est également très facile d'écrire sur le réseau.

Examinons étape par étape comment cette flexibilité est obtenue.

Une partie des principales classes qui composent Chainer

Premièrement, Chainer a plusieurs classes principales pour atteindre les fonctionnalités les plus fondamentales.

nom de la classe Aperçu des fonctionnalités
Variable
  • Une variable qui peut enregistrer les transformations appliquées à elle-même
  • Non seulement les variables d'entrée du réseau, mais aussi les entrées / sorties de la couche intermédiaire, les paramètres de chaque couche, etc. sont tous des objets de cette classe.
  • La variable représentant un paramètre estgradContient un dégradé dans une variable membre
Function
  • Représente une transformation qui n'a pas de paramètres ou est effectuée à l'aide de paramètres passés avec des variables d'entrée
  • Ce qui a été entréinputsLa variable membre que vous sortezoutputsTenir dans la variable membre
Link Couche avec paramètres
Chain Classe pour gérer plusieurs liens à la fois
Optimizer Reçoit Chain ou Link, explore et met à jour les paramètres qu'ils contiennent

Variable et fonction

Personnellement, je pense que cette classe (et classe Function) appelée Variable est au centre de l'implémentation unique de Chainer. Afin de réaliser "Define-by-Run", il est nécessaire de pouvoir ** retracer quel calcul avant a été réellement effectué ** après avoir exécuté le calcul **. Parce que ** cela devient la définition du réseau en lui-même **. Cette classe Variable en est la base. Cette classe est, en termes très approximatifs, une ** variable qui se souvient comment elle a été créée **.

Une variable doit toujours être ** sortie d'une fonction ** sauf si elle est la racine du réseau, la variable qui représente les données d'entrée. Par conséquent, nous allons ajouter une fonction pour stocker ** quel type de fonction a été généré ** dans une variable membre appelée creator.

Cependant, avec cela seul, même si vous regardez creator, vous ne pouvez voir que la fonction qui vous a généré, et avant cela, ** générer la fonction qui a généré l'entrée variable pour cette fonction **, et générer l'entrée avant cette fonction. Il n'est pas possible de retracer l'historique de la fonction qui a été effectuée. Par conséquent, pour rendre cela possible, assurez-vous que la ** classe Function qui effectue réellement le calcul sur la variable contient à la fois la variable d'entrée et la variable de sortie **. En faisant cela, vous pouvez tracer de l '«entrée» de la fonction qui a généré la variable au «créateur» qui l'a générée, ce qui vous permet de remonter de n'importe quelle variable à n'importe quel nœud feuille qui y est connecté. Ce sera possible.

En outre, les variables, bien sûr, doivent pouvoir avoir des valeurs. Par conséquent, nous laisserons la variable membre data contenir le tableau. Puisque la variable racine à la racine représente des données, nous mettrons «None» dans le membre «creator». Pour résumer jusqu'à présent,

Vous pouvez voir qu'au moins la fonction est nécessaire. En ajoutant ces fonctions à Variable et Function, il est possible ** d'obtenir l'historique des calculs effectués jusqu'à présent à partir de la variable de sortie ** comme suit.

x = Variable(data)

f_1 = Function()  #Créer un objet Function
y_1 = f_1(x)      #Jeu de variables en interne_le créateur est appelé
                  #Sortie y en passant self_1 est f_1
                  #Dans le membre créateur
f_2 = Function()
y_2 = f_2(y_1)

y_2.creator                      # => f_2
y_2.creator.input                # => y_1
y_2.creator.input.creator        # => f_1
y_2.creator.input.creator.input  # => x

À partir du "y_2" obtenu à la suite du calcul, ** en traçant alternativement le "créateur" qui l'a généré et l '"entrée" détenue par ce "créateur" **, la variable la plus enracinée, "x" J'ai pu atteindre. Le flux ci-dessus peut être représenté par un schéma simple comme suit.

** Flux de calcul direct et restauration du graphe de calcul par référence inverse ** 1f-Chainer_forward.gif

Jusqu'à présent, la destination où se déroulait le calcul sur le réseau était exprimée comme la "couche supérieure", mais sur cette figure, elle est écrite sous la forme que le calcul se déroule de haut en bas pour plus de commodité. S'il vous plaît soyez prudente.

Ensuite, si vous regardez les cases dans l'ordre du haut vers le bas de cette figure, vous pouvez voir comment les données d'entrée sont appliquées à la fonction dans l'ordre et une nouvelle variable est générée sous la forme correspondant au code ci-dessus. .. D'autre part, la flèche bleue montre le mouvement réel des données dans chaque classe, et la flèche rouge montre comment la variable de sortie finale peut être retracée jusqu'au processus de calcul précédent.

Code pouvant effectuer simultanément le calcul du transfert et la construction du réseau (exp_1.py)

Tout d'abord, écrivons le code de la classe Variable et de la classe Function afin que le calcul avant réel puisse être effectué selon la flèche bleue dans la figure ci-dessus.

class Variable(object):

    def __init__(self, data):
        self.data = data
        self.creator = None

    def set_creator(self, gen_func):
        self.creator = gen_func

class Function(object):

    def __call__(self, in_var):
        in_data = in_var.data
        output = self.forward(in_data)
        ret = Variable(output)
        ret.set_creator(self)
        self.input = in_var
        self.output = ret
        return ret

    def forward(self, in_data):
        return in_data

En utilisant ceux-ci, après avoir effectué le calcul avant plus tôt, essayez de tracer en arrière de la variable de sortie finale à la sortie intermédiaire et à toutes les fonctions intermédiaires.

data = [0, 1, 2, 3]
x = Variable(data)

f_1 = Function()
y_1 = f_1(x)
f_2 = Function()
y_2 = f_2(y_1)

print(y_2.data)
print(y_2.creator)                           # => f_2
print(y_2.creator.input)                     # => y_1
print(y_2.creator.input.creator)             # => f_1
print(y_2.creator.input.creator.input)       # => x
print(y_2.creator.input.creator.input.data)  # => data

>>> [0 1 2 3]
>>> <__main__.Function object at 0x1021efcf8>
>>> <__main__.Variable object at 0x1021efd30>
>>> <__main__.Function object at 0x1021efcc0>
>>> <__main__.Variable object at 0x1023204a8>
>>> [0 1 2 3]

Tout d'abord, les données au format numpy.ndarray sont transmises au constructeur Variable. Cet objet de format variable «x» est l'entrée du réseau.

f_1 = Function()

Ici, la fonction que vous souhaitez utiliser comme une couche du réseau est matérialisée. Cette fonction est un mappage constant et n'a pas de paramètres, il n'y a donc aucune information à donner au constructeur, donc il n'y a pas d'arguments.

y_1 = f_1(x)

Cette ligne applique la fonction «f_1» aux données d'entrée «x» et affecte sa sortie à «y_1». La sortie doit également être au format Variable, donc y_1 est une instance de la classe Variable. Lorsque f_1 est appelé en tant que fonction, le __call__ interne est appelé, donc dans cette ligne, x est passé à la méthode __call__ de la classe Function. Regardons d'abord le contenu de la méthode __call__.

in_data = in_var.data

Le code actuel n'exécute aucune vérification de type, mais en supposant que l'argument passé est Variable, nous prenons l'élément data de cette variable et le mettons dans ʻin_data`. Ce sont les données elles-mêmes nécessaires pour le calcul à terme réel.

output = self.forward(in_data)

Ici, la méthode forward du propre objet reçoit un tableau de type numpy.ndarray extrait de la variable d'entrée dans la ligne précédente, et la valeur de retour est placée dans ʻoutput`.

ret = Variable(output)

Dans cette ligne, en supposant que le résultat du calcul avant, ʻoutput, est un tableau de type numpy.ndarray, nous supposons qu'il est à nouveau de type Variable. Cela signifie que la méthode forward elle-même doit être une fonction qui reçoit un tableau de type numpy.ndarray et retourne un tableau de type numpy.ndarray`.

ret.set_creator(self)

Maintenant, dans cette ligne qui suit, ** se souvient que vous êtes le créateur ** du résultat avant ret enveloppé dans Variable **. Jetons maintenant un œil à la méthode set_creator de la classe Variable.

def set_creator(self, gen_func):
    self.creator = gen_func

Ici, l'objet de classe Function reçu est stocké dans sa propre variable membre self.creator. Cela permet à cette variable de contenir une référence à la fonction qui la produit.

self.input = in_var

Ensuite, la variable d'entrée: ʻin_varpassée à cette fonction est stockée et stockée dansself.input` afin que la fonction appelée avant vous puisse être tracée à partir d'ici plus tard.

self.output = ret

De plus, la variable: ret du résultat du calcul avant est stockée dans self.output. En effet, vous aurez besoin d'un dégradé dans la couche supérieure suivante pour une rétro-propagation ultérieure. Cela n'a aucun sens lorsque l'on considère la loi de différenciation en chaîne. Référence: [Pente des paramètres d'une couche par rapport à la perte](#Slope des paramètres d'une couche par rapport à la perte)

return ret

Enfin, il renvoie ret. Par conséquent, si vous appelez un objet de la classe Function en tant que fonction et passez Variable, le résultat obtenu en appliquant la méthode forward aux données contenus sera renvoyé dans Variable à nouveau. Sera.

Code pour calculer le dégradé (exp_2.py)

Jusqu'à présent, les fonctions dans le code n'avaient aucun paramètre, nous ne pouvions donc construire qu'un réseau sans rien à mettre à jour. Cependant, si la fonction «avant» effectue une transformation qui est déterminée par un paramètre, alors on veut calculer la valeur optimale pour le paramètre afin de minimiser cette transformation à une certaine échelle de perte. Dans le cas de Neural Network, ce paramètre est souvent optimisé à l'aide d'une méthode basée sur la méthode de descente de gradient, qui nécessite de trouver le gradient pour tous les paramètres de la fonction de perte pour laquelle l'échelle de perte est calculée. La rétropropagation était une méthode pour faire cela pour un réseau multicouche qui est considéré comme un mappage composite de nombreuses fonctions.

Étant donné que la mise en œuvre de la loi de différenciation en chaîne par rétropropagation est très simple, il existe différentes manières de le faire, mais ici nous utiliserons le fait que "l'historique des calculs peut être tracé dans le sens opposé à la variable de sortie" basé sur l'implémentation de la variable et de la fonction décrite ci-dessus. Je vais expliquer la méthode de mise en œuvre en utilisant du code.

Tout d'abord, définissez les fonctions nécessaires et effectuez le calcul avant. Considérons ici un réseau dans lequel le calcul des pertes est effectué après avoir appliqué deux fonctions aux données.

f1 = Function()  #Définition de la première fonction
f2 = Function()  #Définition de la deuxième fonction
f3 = Function()  #Définition de la fonction de perte

y0   = Variable(data)  #Des données d'entrée
y1   = f1(y0)          #Appliquer la première fonction
y2   = f2(y1)          #Appliquer la deuxième fonction
y3   = f3(y2)          #Application de la fonction de perte

Maintenant, à partir de cette Variable de sortie finale (y3), nous allons calculer la pente de chaque couche dans l'ordre tout en traçant les fonctions appliquées aux données dans l'ordre inverse [^ Quelle est la pente de chaque couche]. Le gradient calculé est stocké dans le membre grad de la variable d'entrée de chaque fonction. En faisant cela, puisque «entrée» d'une certaine couche = «sortie» de la couche inférieure suivante, ce gradient peut être référencé à partir de la couche inférieure suivante.

f3 = y3.creator                      # 0.Tout d'abord, suivez la fonction de perte à partir de la valeur de la perte

gx = f3.backward()                   # 1.Gradient de fonction de perte
f3.input.grad = gx                   # 2.Stocker en entrée grad

f2 = f3.input.creator                # 3.Suivez la fonction une couche ci-dessous

gx = f2.backward()                   # 4.Dégradé du calque actuel(sortie d/d entrée)
f2.input.grad = f2.output.grad * gx  # 5. f.Gradient de la fonction de perte par rapport à l'entrée

f1 = f2.input.creator                # 3.Suivez la fonction une couche ci-dessous

gx = f1.backward()                   # 4.Dégradé du calque actuel(sortie d/d entrée)
f1.input.grad = f1.output.grad * gx  # 5. f.Gradient de la fonction de perte par rapport à l'entrée

En 5., le calcul est «d fonction de perte / d entrée = (d fonction de perte / d sortie) * (d sortie / d entrée)» selon la loi en chaîne de différenciation. De plus, si vous passez à 5, vous pouvez voir qu'il se répète à partir de 3.

De cette façon, nous avons constaté qu'à partir de la variable de sortie finale, y3, nous pouvions calculer la pente de perte pour les entrées de toutes les couches. En outre, c'est le même que le code suivant si vous l'écrivez brièvement à l'aide de la variable de sortie intermédiaire qui a été temporairement utilisée pour le calcul avant sous une forme nommée afin qu'il soit facile à comprendre sans séparer les lignes inutilement au moment de l'affectation. est.

#À partir de y3
f3 = y3.creator

y2.grad = f3.backward() * 1        #Gradient de f3 par rapport à y2*Gradient de f3 pour y3
y1.grad = f2.backward() * y2.grad  #Gradient de f2 par rapport à y1*Gradient de f3 par rapport à y2
y0.grad = f1.backward() * y1.grad  #Gradient de f1 par rapport à y0*Gradient de f3 par rapport à y1

De plus, si vous écrivez après f3 = y3.creator sur une ligne,

y0.grad = 1 * f3.backward() * f2.backward() * f1.backward()

Ce sera. Cela correspond exactement à la chaîne de différenciation suivante.

\frac{\partial \mathcal{f_3}}{\partial y_0} = \frac{\partial \mathcal{f_3}}{\partial y_3} \frac{\partial \mathcal{y_3}}{\partial y_2} \frac{\partial \mathcal{y_2}}{\partial y_1} \frac{\partial \mathcal{y_1}}{\partial y_0}

Puisque y0 est une entrée dans le réseau, il peut être nommé x, f3 est une fonction de perte, donc il devrait être nommé l, et y3 est une perte, donc il peut être nommé perte, etc., mais ici, il est en arrière pour trouver le gradient. Afin de souligner que le calcul est effectué en répétant le même calcul de la couche supérieure à la couche inférieure, nous avons adopté une notation dans laquelle seul l'indice change. De plus, la différenciation de «f3» par «y3» est de 1 $ car cela signifie la différenciation en soi.

Cependant, cela n'a pas encore calculé le gradient nécessaire pour mettre à jour les paramètres de la fonction. Afin de mettre à jour les paramètres, nous avons besoin d'un ** gradient sur les paramètres ** pour la fonction de perte. Pour obtenir cela, dans chaque méthode backward, calculez d'abord le ** paramètre gradient ** pour votre sortie, puis multipliez-le par le gradient transmis depuis la couche supérieure pour calculer le gw. Tout ce que tu dois faire est.

Par exemple, si la fonction «f2» au milieu a le paramètre «w» et que la conversion «w * y1» est effectuée pour l'entrée «y1», le gradient de «f2» pour «w» est «y1. Puisqu'il s'agit de «et c'est l'entrée» f2.input »de« f2 »,« gw »devient:

gw = f2.input.data * f2.output.grad

Les informations de la couche supérieure sont agrégées par la variable «f2.output», et les informations de la couche inférieure sont agrégées par la variable «f2.input», de sorte que le gradient de la fonction de perte pour les paramètres de la couche peut être calculé à l'aide de celles-ci. C'est devenu comme.

Ce gw est utilisé pour mettre à jour le paramètre w. Les règles de mise à jour elles-mêmes varient en fonction de la méthode d'optimisation. L'optimiseur de patrouille et de mise à jour des paramètres du réseau sera décrit plus loin.

Maintenant, ajoutez les fonctions suivantes à Variable et Function afin que le processus de calcul de rétropropagation ci-dessus puisse être effectué dans la variable de sortie finale avec une méthode appelée en arrière.

Plus précisément, cela ressemble à ceci.

class Variable(object):

    def __init__(self, data):
        self.data = data
        self.creator = None
        self.grad = 1

    def set_creator(self, gen_func):
        self.creator = gen_func

    def backward(self):
        if self.creator is None:  # input data
            return
        func = self.creator
        while func:
            gy = func.output.grad
            func.input.grad = func.backward(gy)
            func = func.input.creator

class Function(object):

    def __call__(self, in_var):
        in_data = in_var.data
        output = self.forward(in_data)
        ret = Variable(output)
        ret.set_creator(self)
        self.input = in_var
        self.output = ret
        return ret

    def forward(self, in_data):
        NotImplementedError()

    def backward(self, grad_output):
        NotImplementedError()

Ici, ce n'est pas intéressant si la fonction n'est qu'un mappage constant, donc la fonction n'est que la définition d'interface afin que diverses fonctions puissent être définies, et la classe appelée Mul qui a en fait en avant et en arrière est cette fonction. Est hérité et défini.

class Mul(Function):

    def __init__(self, init_w):
        self.w = init_w  # Initialize the parameter

    def forward(self, in_var):
        return in_var * self.w

    def backward(self, grad_output):
        gx = self.w * grad_output
        self.gw = self.input
        return gx

Il s'agit simplement d'une fonction qui multiplie l'entrée et renvoie les paramètres donnés lors de l'initialisation. Dans le contenu de "backward", le gradient de sa propre conversion et le gradient du paramètre sont obtenus respectivement, et le gradient du paramètre est conservé dans "self.gw".

Faisons un calcul avant en utilisant ces variables et fonctions étendues.

data = xp.array([0, 1, 2, 3])

f1 = Mul(2)
f2 = Mul(3)
f3 = Mul(4)

y0 = Variable(data)
y1 = f1(y0)          # y1 = y0 * 2
y2 = f2(y1)          # y2 = y1 * 3
y3 = f3(y2)          # y3 = y2 * 4

print(y0.data)
print(y1.data)
print(y2.data)
print(y3.data)

>>> [0 1 2 3]
>>> [0 2 4 6]
>>> [ 0  6 12 18]
>>> [ 0 24 48 72]

Vous pouvez voir que chaque fois que vous appliquez une fonction, la valeur est multipliée par la valeur initiale donnée à chaque fonction. Ici, lorsque y3.backward () est exécuté, les fonctions appliquées jusqu'à présent sont retracées à partir de y3 dans l'ordre inverse, le" arrière "de chaque fonction est appelé dans l'ordre, et le" arrière "de la variable d'entrée / sortie intermédiaire est appelé. La variable membre grad` contiendra le dégradé pour sa sortie finale.

y3.backward()

print(y3.grad)  # df3 / dy3 = 1
print(y2.grad)  # df3 / dy2 = (df3 / dy3) * (dy3 / dy2) = 1 * 4
print(y1.grad)  # df3 / dy1 = (df3 / dy3) * (dy3 / dy2) * (dy2 / dy1) = 1 * 4 * 3
print(y0.grad)  # df3 / dy0 = (df3 / dy3) * (dy3 / dy2) * (dy2 / dy1) * (dy1 / dy0) = 1 * 4 * 3 * 2

>>> 1
>>> 4
>>> 12
>>> 24

print(f3.gw)
print(f2.gw)
print(f1.gw)

>>> [ 0  6 12 18]  # f3.gw = y2
>>> [0 2 4 6]      # f2.gw = y1
>>> [0 1 2 3]      # f1.gw = y0

Ce qui suit est un diagramme simple de ce qui se passe dans chaque objet dans la série de flux d'écriture du calcul avant par vous-même, puis en appelant le backward () de la variable de sortie finale.

Backward.gif

Link

Le lien appelle Function en interne et transmet les paramètres requis pour la conversion effectuée par Function en Function à ce moment-là. Ces paramètres sont conservés en tant que variables membres de l'objet Link et sont mis à jour par l'Optimizer pendant la formation du réseau.

(to be continued)

Chain

La chaîne peut contenir n'importe quel nombre de liens à l'intérieur, ce qui est utile pour regrouper les paramètres, etc. que vous souhaitez mettre à jour, ou pour décrire des sous-unités faciles à comprendre d'un grand réseau.

(to be continued)

Appendix

Principes de base des calques linéaires

Si nous disons avec force que Neural Network est une cartographie composite qui comprend plusieurs applications alternées de transformations linéaires et non linéaires, l'une des transformations linéaires qui la composent peut être la transformation Affin. La transformation affine ici signifie que lorsqu'un vecteur de valeur réelle est défini comme $ {\ bf x} \ in \ mathbb {R} ^ {d_ {in}} $, la matrice de poids $ {\ bf W} \ En multipliant dans \ mathbb {R} ^ {d_ {in} \ times d_ {out}} $ et en ajoutant le vecteur de biais $ {\ bf b} \ in \ mathbb {R} ^ {d_ {out}} $ Il fait référence aux transformations qui sont effectuées, géométriquement, telles que «la rotation, la mise à l'échelle, le cisaillement et la translation».

Envisagez de l'implémenter comme une couche qui constitue un réseau neuronal appelé linéaire. Une couche peut avoir ou non des paramètres entraînables, mais la couche linéaire a des paramètres $ {\ bf W} $ et $ {\ bf b} $ pour effectuer des transformations affines. Est un paramètre apprenable car il sera mis à jour avec celui qui effectue la transformation souhaitée.

Maintenant, exprimons la couche Linear comme une classe écrite en Python.

Implémentons la fonction à faire.

class Linear(object):

    def __init__(self, in_sz, out_sz):
        self.W = numpy.random.randn(out_sz, in_sz) * numpy.sqrt(2. / in_sz)
        self.b = numpy.zeros((out_sz,))

    def __call__(self, x):
        self.x = x
        return x.dot(self.W.T) + self.b

    def update(self, gy, lr):
        self.W -= lr * gy.T.dot(self.x)
        self.b -= lr * gy.sum(axis=0)
        return gy.dot(self.W)

Dans cette classe Linear, tout d'abord, les paramètres ($ {\ bf W}, {\ bf b} $) que la couche Linear a dans le constructeur sont en moyenne 0, et l'écart type est $ \ sqrt {2 \ / \ {\ rm in \. Initialisé en utilisant un nombre aléatoire normal de _sz}} $.

self.W = numpy.random.randn(out_sz, in_sz) * numpy.sqrt(2. / in_sz)
self.b = numpy.zeros((out_sz,))

Cette méthode d'initialisation est appelée HeNormal [^ HeNormal]. ʻIn_sz est la taille d'entrée, c'est-à-dire la dimension $ d_ {in} $ du vecteur d'entrée, et ʻout_sz est la taille de sortie, c'est-à-dire la dimension $ d_ {out} $ du vecteur de sortie converti.

Ensuite, la méthode __call__ correspond au calcul avant, où $ {\ bf W} {\ bf x} + {\ bf b} $ est calculé.

self.h = x.dot(self.W.T) + self.b

Le calcul du gradient pour les paramètres vers la sortie (= vers l'arrière) est très simple pour le calque linéaire, il n'est donc pas fourni en tant que méthode indépendante dans le code ci-dessus. Plus précisément, $ \ partial {\ bf y} \ / \ \ partial {\ bf W} = {\ bf x}, \ partial {\ bf y} \ / \ \ partial {\ bf b} = {\ bf 1} $ ($ {\ bf 1} $ est un vecteur dimensionnel $ d $ dont les éléments sont tous $ 1 $), qui est utilisé comme connu dans la méthode ʻupdate. La première ligne de la partie suivante, self.x, correspond à $ \ partial {\ bf y} \ / \ \ partial {\ bf W} $. Du côté droit de la deuxième ligne, gy.sum (axis = 0), le même calcul que gy.T.dot (numpy.ones ((gy.shape [0],)))est effectué. Je vais. La partienumpy.ones ((gy.shape [0],))` de ceci correspond à $ \ partial {\ bf y} \ / \ \ partial {\ bf b} $.

self.W -= lr * gy.T.dot(self.x)
self.b -= lr * gy.sum(axis=0)

Si le calcul du gradient est plus compliqué, il est préférable de préparer une méthode "en arrière" etc. afin que la partie qui calcule le gradient pour chaque paramètre soit séparée de "mise à jour".

Les mises à jour de paramètres auraient dû être abstraites dans le processus de mise à jour ou découpées comme une classe distincte pour accueillir diverses variantes de méthode de gradient [^ optimizers], mais ici c'est la plus simple. Considérant uniquement la mise à jour des paramètres utilisant la méthode probabiliste de descente de gradient (parfois appelée Vanilla SGD), la classe Linear elle-même qui contient les paramètres a une méthode ʻupdate`.

Ce qui est fait avec la méthode ʻupdateest simple. Tout d'abord, selon la règle de la chaîne dans la différenciation de la fonction composite, le produit du gradient pour chaque entrée pour chaque couche de sortie dans la couche supérieure multiplié par toutes les couches est passé en tant quegy, donc c'est le paramètre $ pour la sortie de cette couche. Calculé en multipliant le dégradé pour {\ bf W}, {\ bf b} $. Ensuite, c'est le gradient pour les paramètres $ {\ bf W}, {\ bf b} $ pour la fonction objectif, multipliez donc cela par le taux d'apprentissage lr` pour calculer le montant de la mise à jour, et en fait à partir des paramètres Nous soustrayons et mettons à jour.

La méthode ʻupdate` renvoie la puissance totale du gradient passé de la couche supérieure, multipliée par le gradient $ {\ bf W} $ pour l'entrée vers sa propre sortie. Ceci est utilisé comme «gy» dans les couches inférieures.

Le calcul du gradient nécessaire à la rétropropagation est très simple à mettre en œuvre grâce à la loi des chaînes. Chaque couche passe le dégradé $ \ partial f \ / \ \ partial {\ bf x} $ pour l'entrée $ {\ bf x} $ à la transformation $ f $ qu'elle fait aux couches inférieures en tant que gy, et à chaque couche Vous pouvez mettre à jour les paramètres en multipliant le gy passé de la couche supérieure par le gradient des paramètres de votre transformation $ f $ et en l'utilisant.

Si vous définissez une classe qui implémente les fonctions ci-dessus, vous pouvez créer un calque linéaire de n'importe quelle taille d'entrée / sortie. Lors de la transmission d'une valeur à la couche linéaire, appelez l'objet en tant que fonction et passez l'objet numpy.ndarray comme argument, et lors de la mise à jour des paramètres internes, le gy est passé de la couche supérieure et de l'expression de mise à jour Cela signifie que le taux d'apprentissage lr utilisé dans est passé à la méthode ʻupdate`.

À propos de la couche ReLU

Au début de l'appendice sur les bases de la couche linéaire ci-dessus, j'ai dit que le réseau neuronal "applique de manière alternée des transformations linéaires et non linéaires", je veux donc appliquer des transformations non linéaires à la sortie de la couche linéaire. Je vais. Le Neural Network propose une grande variété de transformations non linéaires appelées fonctions d'activation. L'un des plus courants à l'heure actuelle est ReLU, mais sa transformation non linéaire peut s'écrire comme suit.

class ReLU(object):

    def __call__(self, x):
        self.x = x
        return numpy.maximum(0, x)

    def update(self, gy, lr):
        return gy * (self.x > 0)

Puisque la fonction d'activation est une conversion sans paramètre, ʻupdatene met à jour aucun paramètre. Au lieu de cela, il calcule son propre sous-gradient et le multiplie pargy`.

Lecture de l'ensemble de données

Une bibliothèque appelée Scikit-learn facilite le téléchargement et le chargement des ensembles de données MNIST.

from sklearn.datasets import fetch_mldata

#Lire l'ensemble de données MNIST
mnist  = fetch_mldata('MNIST original', data_home='.')
td, tl = mnist.data[:60000] / 255.0, mnist.target[:60000]

# 1-Faites-en un vecteur chaud
tl     = numpy.array([tl == i for i in range(10)]).T.astype(numpy.int)

#mélanger
perm   = numpy.random.permutation(len(td))
td, tl = td[perm], tl[perm]

Les données de validation ont été créées de la même manière.

vd, vl = mnist.data[60000:] / 255.0, mnist.target[60000:]
vl = numpy.array([vl == i for i in range(10)]).T.astype(numpy.int)

Calcul et différenciation de l'entropie croisée Softmax

Lorsque la sortie du réseau est $ {\ bf y} \ in \ mathbb {R} ^ {d_ {l}} $, la valeur convertie en vecteur de probabilité à l'aide de la fonction Softmax est $ \ hat {\ bf y} $ Si tel est le cas, il est calculé comme suit:

\hat{y}\_{i} = \frac{\exp(y_i)}{\sum_j \exp(y_j)} \hspace{1em} (i=1,2,\dots,d_l)

À ce moment, $ \ hat {y} \ _ {i} \ (i = 1,2, \ dots, d_l) $ représente la probabilité, donc $ 0 \ leq \ hat {y} \ _ {i} \ leq 1 $ Ce sera.

Maintenant, le signal du professeur est aussi un vecteur unidimensionnel unidimensionnel $ d_l $ (un vecteur dans lequel un seul des éléments est $ 1 $ et tous les autres éléments sont $ 0 $) $ {\ bf t} = [t_1, t_2, \ dots, t_ {d_l}] ^ {\ rm T} $ S'il est représenté par $ \ hat {\ bf y} = {\ bf t} $, la vraisemblance $ L ( \ hat {\ bf y} = {\ bf t}) $ peut être défini comme suit.

L(\hat{\bf y} = {\bf t}) = \prod_i \hat{y}\_i^{t_i}

$ t_i $ est $ 1 $ uniquement lorsque $ i $ est l'index de la classe correcte, et tout le reste est $ 0 $, donc si la classe correcte est $ i = 5 $, la formule ci-dessus est $ 1 \ cdot 1 \ cdots \ hat {y} \ _ {5} \ cdots 1 = \ hat {y} \ _ {5} $. En d'autres termes, ce $ L $ peut être interprété comme signifiant "dans quelle mesure et avec un degré élevé de certitude pourriez-vous prédire la bonne réponse?" Ensuite, ce serait bien si cette valeur pouvait être augmentée, mais en général, prenez le logarithme de ce $ L $ et inversez le signe $ - \ log (L) $ est ** minimisé * *Faire. Puisque $ \ log $ augmente de façon monotone, $ L $ est également maximum lorsque $ \ log (L) $ est maximum, et le signe est inversé lorsque $ \ log (L) $ est maximum $ - \ log (L) $ Doit être le plus petit. En conséquence, en minimisant $ - \ log (L) $, la vraisemblance exprimée par l'équation ci-dessus est maximisée [^ SoftmaxCrossEntropy_derivation]. Ce $ - \ log (L) $ est appelé «vraisemblance log négative» car il prend le log de la vraisemblance et inverse le signe, mais dans le contexte du réseau neuronal, il est plus souvent appelé entropie croisée. Je le pense. Maintenant, si vous remettez cette entropie croisée sous la forme $ \ mathcal {L} $,

\mathcal{L} = - \log \prod_i \hat{y}\_i^{t_i} = - \sum_i t_i \log \hat{y}\_i

est. Nous utiliserons cela comme une fonction de perte pour apprendre le réseau neuronal qui résout le problème de classification et viserons à le minimiser.

Maintenant, dans la définition de couche utilisant la classe Python comme décrit ci-dessus, si "$ 1 $ est toujours passé au" gy "de la méthode ʻupdate", la fonction de perte peut être considérée comme une couche. Étant donné que la fonction de perte elle-même n'a pas de paramètres à mettre à jour, le calcul à effectuer par la méthode ʻupdate consiste à trouver le gradient pour "entrée à la fonction de perte = sortie réseau" pour la sortie = valeur de perte. C'est,

\frac{\partial \mathcal{L}}{\partial {\bf y}}

Autrement dit, vous pouvez calculer les éléments suivants: Environ $ k = 1,2, \ dots, d_l $

\begin{eqnarray} \frac{\partial \mathcal{L}}{\partial y_k} &=& - \sum_i \frac{\partial \mathcal{L}}{\partial \hat{y}\_i}\frac{\partial \hat{y}\_i}{\partial y\_k} \\\\ &=& - \sum_i \frac{t_i}{\hat{y}\_i} \frac{\partial \hat{y}\_i}{\partial y\_k} \hspace{1em}\cdots(1) \end{eqnarray}

La marque de somme ne disparaît pas ici car la fonction Softmax a des valeurs de toutes les dimensions dans le dénominateur, c'est donc une fonction pour tous les indices. Maintenant, le gradient de la fonction Softmax est

Quand $ k \ neq i $ $ \begin{eqnarray} \frac{\partial \hat{y}\_i}{\partial y_k} &=& -\frac{\exp(y_i)\exp(y_k)}{\sum_j \exp(y_j)} \\\\ &=& - \hat{y}\_i \hat{y}_k \end{eqnarray} $

Quand $ k = i $ $$ \begin{eqnarray} \frac{\partial \hat{y}_i}{\partial y_k} &=& \frac{\exp(y_i)}{\sum_j \exp(y_j)}

Par conséquent, nous pouvons l'utiliser pour décomposer l'expression $ (1) $ en termes séparés lorsque le contenu du symbole somme est $ i = k $ et lorsque $ i \ neq k $. Quand tu fais

\begin{eqnarray} \frac{\partial \mathcal{L}}{\partial y_k} &=& - \sum_i \frac{t_i}{\hat{y}\_i} \frac{\partial \hat{y}\_i}{\partial y\_k} \\\\ &=& - t_k (1 - \hat{y}\_k) + \sum_{i \neq k} t_i \hat{y}\_k \end{eqnarray}

Ce sera. Ici, quand le premier terme est $ i = k $ et le second terme est $ i \ neq k $. Quand il se transforme davantage,

\begin{eqnarray} &=& - t_k + \hat{y}\_k t_k + \hat{y}\_k \sum_{i \neq k} t_i \\\\ &=& - t_k + \hat{y}\_k \sum_i t_i \\\\ &=& \hat{y}\_k - t_k \end{eqnarray}

Ce sera. Ici, la propriété du vecteur one-hot ($ \ sum_i t_i = 1 $) est utilisée pour la transformation finale. Si vous réécrivez le résultat ici,

\frac{\partial \mathcal{L}}{\partial y_k} = \hat{y}\_k - t_k

Il s'est avéré être. En d'autres termes, c'est le gy renvoyé par la méthode ʻupdate` de la classe Softmax Cross Entropy.

Gradient des paramètres d'une couche par rapport à la perte

Si la fonction de perte est $ \ mathcal {L} $, le gradient de la fonction de perte pour le paramètre $ {\ bf W} _l $ dans la couche $ l $ est $ l + 1, l + 2 pour les couches au-dessus. Comme, \ dots, L $, cela devient comme suit par la loi de la chaîne de différenciation.

\frac{\partial \mathcal{L}}{\partial {\bf W}\_l} = \frac{\partial \mathcal{L}}{\partial y_L} \frac{\partial y_L}{\partial y_{L-1}} \cdots \frac{\partial y_{l+1}}{\partial y_l} \frac{\partial y_l}{\partial {\bf W}_l}

À ce stade, tous les dégradés de $ \ partial y_ {l + 1} \ / \ \ partial y_l $ à $ \ partial y_ {L} \ / \ \ partial y_ {L-1} $ sont dans la couche $ l $. Il y a un gradient ** sur l'entrée à la ** sortie de la couche supérieure menant à. Appelons cela le gradient d'entrée / sortie. Ensuite, pour le dernier $ \ partial \ mathcal {L} \ / \ \ partial y_L $, si vous pensez à $ \ mathcal {L} $ comme la couche de perte de la couche $ L + 1 $, la sortie (perte) de la couche de perte est la même. C'est un gradient d'entrée / sortie car il s'agit d'un gradient pour l'entrée (valeur prédite du réseau) à. En d'autres termes, ** le produit de tous les gradients d'entrée / sortie de chaque couche reliant votre propre couche et la perte multipliée par le gradient des paramètres pour la sortie de votre propre couche ** est ce que vous voulez calculer par calcul à rebours. Par conséquent, le gradient d'entrée / sortie de chaque couche est passé à toutes les fonctions qui ont passé la variable à lui-même, et le côté passé transmet le produit du gradient d'entrée / sortie de chaque couche à la couche inférieure. Vous devez juste faire cela.

[^ cupy-PR]: les RP associés comprennent: "Prise en charge de l'indexation avancée avec un tableau booléen pour getitem": https://github.com/pfnet/chainer/pull/1840 [^ optimiseurs]: Chainer implémente AdaDelta, AdaGrad, Adam, MomentumSGD, NesterovAG, RMSprop, RMSpropGraves, SGD (= Vanilla SGD), SMORMS3. Il n'y a pas de RProp et Eve a un PR (https://github.com/pfnet/chainer/pull/1847) qui n'a pas encore été fusionné. Je veux réunir Adam et Eve le plus tôt possible (?).

Recommended Posts

[WIP] Créer un chaînage à 1 fichier
Créer un fichier de données factice
Créer un fichier xlsx avec XlsxWriter
Créer un fichier binaire en Python
Créer un fichier de nombres aléatoires de 1 Mo
Comment créer un fichier de configuration
Créer un téléchargeur de fichiers avec Django
Créez rapidement un fichier Excel avec Python #python
Créer un gros fichier texte avec shellscript
Créer une machine virtuelle avec un fichier YAML (KVM)
Créer un fichier Excel avec Python + matrice de similarité
Créer un fichier deb à partir d'un package python
[GPS] Créer un fichier kml avec Python
Script pour créer un fichier de dictionnaire Mac
Créez un fichier msi évolutif avec cx_Freeze