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.
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.
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.
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.
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 le
gy`, 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
gy
à la fonction backward
et mettez à jour tous les paramètres du réseauIl 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.
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?
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.
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 |
|
Function |
|
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 |
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,
creator
: fonction qui se produit
-- data
: valeur elle-même: Variable saisie en elle-même --
forward () : Une méthode qui reçoit des" entrées "et effectue des calculs --ʻOutput
: Résultat calculé par forward ()
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 **
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.
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 dans
self.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.
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.
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
.
grad
en arrière
--Tracez le processus de calcul dans la direction opposée de votre propre créateur
--Appelez la méthode backward
sur toutes les fonctions au milieuen arrière
--Calculer le dégradé gx
de l'entrée vers sa propre sortiegrad_output
pour la perte passée de la couche supérieure et renvoie le produit de gx
--Calculez le gradient des paramètres sur votre propre sortie, multipliez par grad_output
pour calculer gw
, et maintenezPlus 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.
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
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 partie
numpy.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 que
gy, 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`.
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 par
gy`.
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)
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:
À 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.
$ 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} $,
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,
Autrement dit, vous pouvez calculer les éléments suivants: Environ $ k = 1,2, \ dots, d_l $
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 $
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
Ce sera. Ici, quand le premier terme est $ i = k $ et le second terme est $ i \ neq k $. Quand il se transforme davantage,
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,
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.
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.
À 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