[PYTHON] [WIP] 1-Datei-Chainer erstellen

Überblick

In diesem Artikel beschreiben und lernen wir ein Netzwerk zum Klassifizieren handgeschriebener Zahlen, um ** "Define-by-Run" ** zu verstehen, das das charakteristischste Konzept von Chainer ist, einem neuronalen Netzwerk-Framework. Lassen Sie uns die Bibliothek "1f-chainer" implementieren, die nur die minimal erforderlichen Funktionen hat und nur NumPy verwendet. Alle Erklärungen, die in den Formeln enthalten sind, werden in den Anhang verschoben, und ich habe darauf geachtet, im Text nur den Code und die Sätze so weit wie möglich zu erläutern.

Der gesamte in diesem Artikel verwendete Code befindet sich unten: 1f-chainer. Als ich anfing zu schreiben, wollte ich verschiedene Dinge hinzufügen und konnte es nicht rechtzeitig schaffen, deshalb werde ich es bis Ende dieser Woche einzeln aktualisieren.

Darüber hinaus basiert der gesamte Inhalt dieses Artikels auf meiner persönlichen Meinung und meinem Verständnis und hat nichts mit der Organisation zu tun, zu der ich gehöre.

Angenommener Leser

Dieser Artikel ist für alle gedacht, die über Grundkenntnisse im Training neuronaler Netze mit Backpropagation und Erfahrung mit Python und NumPy verfügen. Bitte beachten Sie, dass der Autor ein Fan von Chainer ist, sodass seine Eindrücke von Chainer auf seinen persönlichen Gefühlen beruhen und keine offiziellen Ansichten sind.

Einführung

Chainer ist ein in Python geschriebenes Framework, das neuronale Netze aufbauen und lernen kann.

Ich denke, es ist eine Bibliothek, die auf solche Dinge abzielt.

Soweit ich weiß, das berühmte Framework / die Bibliothek zum Aufbau und Lernen eines neuronalen Netzwerks

Es gibt viele Dinge wie, aber soweit ich weiß, gibt es nur wenige Frameworks, die nur in Python geschrieben wurden, einschließlich des Backends. Auf der anderen Seite verwendet Chainer NumPy als Tensorbibliothek für CPU und CuPy, die ursprünglich für die GPU entwickelt wurden. Eines der Merkmale von Chainer ist jedoch, dass beide unabhängige Python-Bibliotheken sind, die unabhängig voneinander verwendet werden können. Und ich denke du bist.

CuPy wird hauptsächlich mit Cython geschrieben und verfügt über einen Mechanismus zum internen Kompilieren und Ausführen des CUDA-Kernels. Es dient auch als Wrapper für cuDNN, eine von NVIDIA entwickelte Bibliothek für neuronale Netze, die die Verwendung von NVIDIA-GPUs übernimmt. Das große Merkmal von CuPy ist, dass es über eine NumPy-kompatible API verfügt. Dies bedeutet, dass ** Code für CPUs, die mit NumPy geschrieben wurden, mit sehr wenigen Änderungen leicht für die GPU-Verarbeitung umgeschrieben werden kann ** (die Zeichenfolge numpy im Code Möglicherweise müssen Sie es nur durch cupy ersetzen). Da es viele NumPy-Funktionen und -Funktionen (erweiterte Indizierung usw. [^ cupy-PR]) gibt, die CuPy noch nicht unterstützt, gibt es verschiedene Einschränkungen und Ausnahmen, aber im Grunde ist das Ziel von CuPy der Code für CPU und GPU. Ich denke, es geht darum, den Code für Sie so nah wie möglich zu machen.

Ich habe nicht alle oben aufgeführten wichtigen Frameworks / Bibliotheken ausprobiert, aber Chainers internes Design von Berechnungsverfahren zum Aufbau und Lernen neuronaler Netze ist ** ". Die Idee von Define-by-Run "** wird verwendet, und ich denke, dies ist eine wichtige Funktion, die Chainer von anderen Frameworks unterscheidet. "Definieren durch Ausführen" bedeutet, wie der Name schon sagt, "Definieren durch Ausführen" der Struktur des neuronalen Netzwerks. Dies bedeutet, dass die Struktur des Netzwerks noch nicht bestimmt wurde, bevor es ausgeführt wird, und erst nachdem der Code ausgeführt wurde, wird bestimmt, wie jeder Teil des Netzwerks verbunden wird. Insbesondere wenn eine bestimmte Eingabevariable mit einer Funktion angewendet wird, ist die Ausgabe eine neue Variable, die sich merkt, "welche Art von Funktion angewendet wurde", so dass "die Berechnung tatsächlich ausgeführt wird". In diesem Fall können Sie so oft in die entgegengesetzte Richtung folgen, wie Sie möchten. Dies bedeutet, dass Sie tatsächlich eine "Laufzeitkonstruktion des Berechnungsdiagramms" durchführen können. Aus diesem Grund sollte der Benutzer "den Berechnungsprozess der Vorwärtsausbreitung des Netzwerks (einschließlich regelbasierter bedingter Verzweigung, probabilistischer bedingter Verzweigung oder Erzeugung einer neuen Schicht im Berechnungsprozess)" unter Verwendung von Python beschreiben. Mit anderen Worten, wenn Sie den "Code schreiben, der die Vorwärtsberechnung darstellt", haben Sie das Netzwerk definiert.

Mehrere Klassen, um diese Eigenschaften zu erreichen, bilden das Herzstück von Chainer. In diesem Artikel werde ich versuchen, nur den grundlegendsten Teil von Chainer selbst als Bibliothek zu implementieren, die aus einer Datei besteht (wie Pythons Webframework Bottle), und das Herzstück davon wird oberflächlich sein. Der Zweck ist zu verstehen.

Implementierung eines neuronalen Netzwerks, das nicht wie Chainer ist

Lassen Sie uns zunächst untersuchen, was passiert, wenn wir Code schreiben, der das neuronale Netzwerk so einfach wie möglich erstellt und lernt, ohne sich der "Chainer-ähnlichen" Implementierung bewusst zu sein. Der Code, der in diesem Abschnitt unten angezeigt wird, setzt voraus, dass "import numpy" am Anfang ausgeführt wird. Darüber hinaus wird grundsätzlich davon ausgegangen, dass die Parameter beim anschließenden Lernen von Neural Network durch Mini-Batch-SGD aktualisiert werden.

Netzwerk bestehend aus linearer Schicht und ReLU

Ein dreischichtiges Perzeptron, das nur aus zwei Schichten besteht, der linearen Schicht und der Aktivierungsfunktion ReLU, kann wie folgt definiert werden.

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)
]

das ist alles. Sie können sehen, dass es sehr kurz geschrieben werden kann. Eine detaillierte Beschreibung der obigen linearen Ebene finden Sie weiter unten im Anhang: [Grundlagen der linearen Ebene](# Grundlagen der linearen Ebene). Es gibt auch eine kurze ergänzende Erklärung zur ReLU-Ebene ([Über die ReLU-Ebene](#Über die ReLU-Ebene)).

Als nächstes bereiten wir die Funktionen "Vorwärts" und "Aktualisierung" vor, die erforderlich sind, um dieses dreischichtige Perzeptron unter Verwendung der Daten "x" und der richtigen Antwort "t" zu trainieren.

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)

Mit diesen beiden Funktionen kann das obige dreischichtige Perzeptron trainiert werden. In der "Vorwärts" -Funktion wird der Inhalt des "Modells", der als Liste von Konfigurationsebenen angegeben ist, der Reihe nach untersucht, und die Daten werden vorwärts weitergegeben. Im Gegenteil, die Funktion "Aktualisieren" betrachtet die Ebenen im "Modell" in umgekehrter Reihenfolge ** und das "Gy", das die Gesamtleistung des Teils der Multiplikation der Gradienten ist, der in der Kettenregel vor Ihnen erscheint. Es ist rückwärts verbreitet. Zusammenfassend ist das Lernverfahren

--Inferenz: Geben Sie der "Vorwärts" -Funktion die Daten "x" und setzen Sie die resultierende Ausgabe als "y"

Es gibt 3 Schritte. Der Code, der diese Prozesse mithilfe des MNIST-Datensatzes wiederholt und das Netzwerk trainiert, kann wie folgt geschrieben werden. Hier repräsentiert "td" "numpy.ndarray" in Form von "(60000, 784)" mit angeordneten $ 60000 $ dimensionalen Vektordaten, und "tlist ein $ 10 $ dimensionaler eindimensionaler Vektor (richtige Antwort). Angenommen, Sie möchten einenumpy.ndarray der Form (60000, 10)` mit $ 60000 $ darstellen (Vektoren, bei denen nur die der Klassennummer entsprechende Dimension $ 1 $ und alle anderen Dimensionen 0 sind). Der Code, der diese Arrays tatsächlich erstellt, befindet sich im Anhang: Dataset laden (#Read Dataset).

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

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

Sie sehen, dass der grundlegende Lernablauf mit dem Mini-Batch-SGD ebenfalls sehr einfach ist. Da das Netzwerk in Python als Liste definiert ist, kann die Vorwärtsberechnung einfach durchgeführt werden, indem dem ersten Element der Liste eine Eingabevariable zugewiesen wird und die erhaltene Ausgabe als Eingabe des nächsten Elements der Liste angegeben wird. Ich werde. In der Funktion "Aktualisieren", mit der die Parameter aktualisiert werden, können Sie "diese Liste in umgekehrter Reihenfolge anzeigen", die "Gy" in der Reihenfolge von hinten berechnen und an die "Aktualisierungs" -Methode jeder Ebene übergeben.

Beachten Sie, dass die Funktion softmax_cross_entropy_gy für den Gradienten der Eingabe zum Verlustwert zurückkehrt, nicht zum Verlustwert selbst. Ich habe im Anhang über den Wert der Softmax Cross Entropy-Verlustfunktion und ihren Gradienten geschrieben: Berechnung und Differenzierung der Softmax Cross Entropy.

Lassen Sie uns nach dem Ausführen der obigen Trainingsschleife die Klassifizierungsgenauigkeit des Validierungsdatensatzes von MNIST untersuchen.

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)

Hier repräsentiert "vd" die Validierungsdaten und "vl" die Bezeichnung, die den Validierungsdaten entspricht, und wie diese erstellt werden, ist im Anhang sowie in den Trainingsdaten beschrieben: [Datensatz lesen](# Datensatz lesen). Nach dem Durchführen eines $ 30 $ -Epochenlernens und dem Überprüfen der Genauigkeit von Validierungsdaten mit dem obigen Code wurde festgestellt, dass die Genauigkeit von $ 0,9674 $, dh 96,74 $ % $, erreicht wurde.

Der gesamte Code befindet sich rechts: Minimum.py. Bei Ausführung mit python minimum.py wird trainiert, die Genauigkeit im Validierungsdatensatz angezeigt und beendet.

Kettenartige Implementierung des neuronalen Netzes

Dann ist es das Hauptthema. Wir haben festgestellt, dass die Implementierung der grundlegenden Schichten, aus denen das neuronale Netz besteht, sehr einfach ist. Welche Art von Implementierung unternimmt Chainer, um ähnliche Netzwerke zu definieren und zu trainieren?

Bedeutung der Vorwärtsberechnung in Chainer

Chainer definiert die Netzwerkstruktur basierend auf der Idee von ** "Define-by-Run" **, wie zu Beginn erwähnt. Im Gegensatz dazu wird die oben beschriebene Implementierungsmethode ** "Define-and-Run" ** genannt, wobei die Netzwerkstruktur ** im Voraus festgelegt ** und dann der Lernprozess (vorwärts) festgelegt wird. Es war ein Mechanismus zum Ausführen von Berechnungen, Rückwärtsberechnungen, Parameteraktualisierungen usw.). Daher ist es sehr mühsam oder unpraktisch, zu versuchen, die Struktur des Netzwerks abhängig von den Daten zu ändern (es gibt einen Verzweigungspunkt in der Mitte und welcher Zweig, an den die Weiterleitung geht, hängt vom Inhalt der Daten usw. ab). Es kann möglich sein [^ branch]. In "Define-by-Run" sind ** die Beschreibung der Vorwärtsberechnung und die Definition der Netzwerkstruktur synonym **, und da die Vorwärtsberechnung in Python beschrieben werden kann, enthält sie Verzweigungs- und Wahrscheinlichkeitselemente. Es ist auch sehr einfach, im Netzwerk zu schreiben.

Schauen wir uns Schritt für Schritt an, wie diese Flexibilität erreicht wird.

Teil der Hauptklassen, aus denen Chainer besteht

Erstens hat Chainer mehrere Hauptklassen, um die grundlegendste Funktionalität zu erreichen.

Name der Klasse Funktionsübersicht
Variable
  • Eine Variable, die die auf sich selbst angewendeten Transformationen aufzeichnen kann
  • Nicht nur die Eingabevariablen in das Netzwerk, sondern auch die Eingabe / Ausgabe der Zwischenschicht, die Parameter jeder Schicht usw. sind Objekte dieser Klasse.
  • Variable, die einen Parameter darstellt, istgradEnthält einen Verlauf in einer Elementvariablen
Function
  • Stellt eine Transformation dar, die keine Parameter enthält oder mit Parametern ausgeführt wird, die mit Eingabevariablen übergeben werden
  • Was wurde eingegebeninputsDie Mitgliedsvariable, die Sie ausgebenoutputsHalten Sie die Mitgliedsvariable gedrückt
Link Ebene mit Parametern
Chain Klasse für die gleichzeitige Behandlung mehrerer Links
Optimizer Empfängt Chain oder Link und crawlt und aktualisiert die darin enthaltenen Parameter

Variable und Funktion

Persönlich denke ich, dass diese Klasse (und Funktionsklasse) namens Variable im Zentrum der einzigartigen Implementierung von Chainer steht. Um "Define-by-Run" zu realisieren, muss nach Ausführung der Berechnung ** nachverfolgt werden können, ** welche Vorwärtsberechnung tatsächlich durchgeführt wurde **. Weil ** es die Definition des Netzwerks an sich wird **. Diese Variablenklasse ist die Basis dafür. Diese Klasse ist in groben Zügen eine ** Variable, die sich daran erinnert, wie sie erstellt wurde **.

Eine Variable sollte immer ** von einer Funktion ausgegeben ** werden, es sei denn, es ist die Wurzel des Netzwerks, die Variable, die die Eingabedaten darstellt. Daher werden wir eine Funktion hinzufügen, um zu speichern, ** welche Art von Funktion ausgegeben wurde ** in einer Mitgliedsvariablen namens "Creator".

Selbst wenn Sie sich "Ersteller" ansehen, können Sie allein damit nur die Funktion sehen, die Sie ausgegeben hat, und davor ** die Funktion generieren, die die variable Eingabe für diese Funktion generiert hat **, und die Eingabe vor dieser Funktion generieren. Es ist nicht möglich, den Verlauf der ausgeführten Funktion zu verfolgen. Um dies zu ermöglichen, stellen Sie daher sicher, dass die ** Funktionsklasse, die die Berechnung für die Variable tatsächlich ausführt, sowohl die Eingangsvariable als auch die Ausgangsvariable ** enthält. Auf diese Weise können Sie von der Eingabe der Funktion, die die Variable generiert hat, bis zum Ersteller, der sie generiert hat, zurückverfolgen, sodass Sie von jeder Variablen zu jedem damit verbundenen Blattknoten zurückverfolgen können. Es wird möglich sein.

Variablen müssen natürlich auch Werte haben können. Daher lassen wir die Mitgliedsvariable "data" das Array enthalten. Da die Stammvariable an der Wurzel Daten darstellt, setzen wir "Keine" in das "Ersteller" -Mitglied. Zusammenfassend,

Sie sehen, dass zumindest die Funktion notwendig ist. Durch Hinzufügen dieser Funktionen zu Variable und Funktion ist es möglich, den Verlauf der bisher durchgeführten Berechnungen von der Ausgabevariablen ** wie folgt abzurufen.

x = Variable(data)

f_1 = Function()  #Erstellen eines Funktionsobjekts
y_1 = f_1(x)      #Variable intern eingestellt_Schöpfer heißt
                  #Geben Sie y aus, indem Sie sich selbst übergeben_1 ist f_1
                  #Im Ersteller Mitglied
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

Aus dem als Ergebnis der Berechnung erhaltenen "y_2" ** durch abwechselndes Verfolgen des "Erstellers", der es erzeugt hat, und der "Eingabe" dieses "Erstellers" **, der am stärksten verwurzelten Variablen "x" Ich konnte erreichen. Der obige Fluss kann durch ein einfaches Diagramm wie folgt dargestellt werden.

** Vorwärtsberechnungsablauf und Wiederherstellung des Berechnungsdiagramms durch Rückwärtsreferenz ** 1f-Chainer_forward.gif

Bisher wurde das Ziel, an dem die Berechnung im Netzwerk durchgeführt wurde, als "obere Schicht" ausgedrückt. In dieser Abbildung ist es jedoch in der Form geschrieben, dass die Berechnung der Einfachheit halber von oben nach unten verläuft. Bitte seien Sie vorsichtig.

Wenn Sie sich dann die Felder in der Reihenfolge von oben nach unten in dieser Abbildung ansehen, können Sie sehen, wie die Eingabedaten der Reihe nach auf die Funktion angewendet werden, und eine neue Variable wird in der Form generiert, die dem obigen Code entspricht. .. Andererseits zeigt der blaue Pfeil die tatsächliche Bewegung der Daten in jeder Klasse und der rote Pfeil zeigt, wie die endgültige Ausgabevariable auf den vorherigen Berechnungsprozess zurückgeführt werden kann.

Code, der gleichzeitig die Vorwärtsberechnung und den Netzwerkaufbau durchführen kann (exp_1.py)

Schreiben wir zunächst den Code der Variablenklasse und der Funktionsklasse, damit die eigentliche Vorwärtsberechnung gemäß dem blauen Pfeil in der obigen Abbildung durchgeführt werden kann.

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

Versuchen Sie mit diesen, nachdem Sie die Vorwärtsberechnung zuvor durchgeführt haben, von der endgültigen Ausgabevariablen bis zur Zwischenausgabe und allen Zwischenfunktionen rückwärts zu verfolgen.

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]

Zunächst werden Daten im Format "numpy.ndarray" an den Variablenkonstruktor übergeben. Dieses Objekt x im variablen Format ist die Eingabe in das Netzwerk.

f_1 = Function()

Hier wird die Funktion materialisiert, die Sie als eine Schicht des Netzwerks verwenden möchten. Diese Funktion ist eine konstante Zuordnung und hat keine Parameter. Daher gibt es keine Informationen, die dem Konstruktor gegeben werden müssen. Daher gibt es keine Argumente.

y_1 = f_1(x)

Diese Zeile wendet die Funktion "f_1" auf die Eingabedaten "x" an und weist ihre Ausgabe "y_1" zu. Die Ausgabe sollte auch im Variablenformat vorliegen, sodass "y_1" eine Instanz der Variablenklasse ist. Wenn f_1 als Funktion aufgerufen wird, wird das interne __call__ aufgerufen, sodass in dieser Zeile x an die __call__ -Methode der Function-Klasse übergeben wird. Schauen wir uns zunächst den Inhalt der Methode call an.

in_data = in_var.data

Der aktuelle Code führt keine Typprüfung durch. Unter der Annahme, dass das übergebene Argument Variable ist, nehmen wir das Element "data" dieser Variablen und fügen es in "in_data" ein. Dies sind die Daten selbst, die für die eigentliche Vorwärtsberechnung benötigt werden.

output = self.forward(in_data)

Hier wird der "forward" -Methode des eigenen Objekts ein Array vom Typ "numpy.ndarray" übergeben, das aus der Eingabevariablen in der vorherigen Zeile abgerufen wurde, und der Rückgabewert wird in die "output" eingegeben.

ret = Variable(output)

In dieser Zeile wird unter der Annahme, dass die "Ausgabe", die das Ergebnis der Vorwärtsberechnung ist, ein Array vom Typ "numpy.ndarray" ist, angenommen, dass es wieder vom Typ "Variable" ist. Daher sollte die "forward" -Methode selbst eine Funktion sein, die ein Array vom Typ "numpy.ndarray" empfängt und ein Array vom Typ "numpy.ndarray" zurückgibt.

ret.set_creator(self)

In dieser nächsten Zeile wird ** daran erinnert, dass Sie der Ersteller ** für das in Variable ** verpackte Forward-Ergebnis ret sind. Schauen wir uns nun die Methode "set_creator" der Variablenklasse an.

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

Hier wird das empfangene Funktionsklassenobjekt in seiner eigenen Mitgliedsvariablen "self.creator" gespeichert. Dadurch kann diese "Variable" einen Verweis auf die Funktion enthalten, die sie ausgibt.

self.input = in_var

Als nächstes wird die an diese Funktion übergebene Eingabevariable "in_var" gespeichert und in "self.input" gespeichert, damit die zuvor aufgerufene Funktion später von hier aus verfolgt werden kann.

self.output = ret

Zusätzlich wird die Variable: "ret" des Ergebnisses der Vorwärtsberechnung in "self.output" gespeichert. Dies liegt daran, dass Sie einen Gradienten in der nächsthöheren Schicht für die spätere Rückausbreitung benötigen. Dies macht unter Berücksichtigung des Kettengesetzes der Differenzierung keinen Sinn. Referenz: [Steigung für die Parameter einer Ebene in Bezug auf Verlust](# Steigung für die Parameter einer Ebene in Bezug auf Verlust)

return ret

Schließlich wird "ret" zurückgegeben. Wenn Sie ein Objekt der Funktionsklasse als Funktion aufrufen und Variable übergeben, wird das Ergebnis, das durch Anwenden der "forward" -Methode auf die Inhaltsdaten "erhalten wird, erneut in Variable zurückgegeben. Wird sein.

Code zur Berechnung des Gradienten (exp_2.py)

Die Funktionen im Code hatten bisher keine Parameter, so dass wir nur ein Netzwerk aufbauen konnten, in dem nichts aktualisiert werden musste. Wenn jedoch die Vorwärtsfunktion der Funktion eine Transformation durchführt, die durch einen Parameter bestimmt wird, möchte man den optimalen Wert für den Parameter berechnen, um diese Transformation auf eine Verlustskala zu minimieren. Im Fall eines neuronalen Netzes wird dieser Parameter häufig unter Verwendung einer Methode optimiert, die auf der Gradientenabstiegsmethode basiert, bei der der Gradient für alle Parameter für die Verlustfunktion ermittelt werden muss, für die die Verlustskala berechnet wird. Die Backpropagation war eine Methode, um dies für ein mehrschichtiges Netzwerk zu tun, das als zusammengesetzte Abbildung vieler Funktionen betrachtet wird.

Da die Implementierung des Kettengesetzes der Differenzierung durch Backpropagation sehr einfach ist, gibt es verschiedene Möglichkeiten, dies zu tun. Hier werden wir jedoch die Tatsache verwenden, dass "der Berechnungsverlauf in der entgegengesetzten Richtung von der Ausgabevariablen verfolgt werden kann", basierend auf der oben beschriebenen Implementierung von Variable und Funktion. Ich werde die Implementierungsmethode mit Code erklären.

Definieren Sie zunächst die erforderlichen Funktionen und führen Sie eine Vorwärtsberechnung durch. Stellen Sie sich hier ein Netzwerk vor, in dem die Verlustberechnung durchgeführt wird, nachdem zwei Funktionen auf die Daten angewendet wurden.

f1 = Function()  #Definition der ersten Funktion
f2 = Function()  #Definition der zweiten Funktion
f3 = Function()  #Definition der Verlustfunktion

y0   = Variable(data)  #Eingabedaten
y1   = f1(y0)          #Wenden Sie die erste Funktion an
y2   = f2(y1)          #Wenden Sie die zweite Funktion an
y3   = f3(y2)          #Anwendung der Verlustfunktion

Unter Verwendung dieser endgültigen Ausgabevariablen (y3) als Ausgangspunkt berechnen wir nun den Gradienten jeder Ebene in der angegebenen Reihenfolge, während wir die auf die Daten angewendeten Funktionen in umgekehrter Reihenfolge verfolgen [^ Was ist der Gradient jeder Ebene]? Der berechnete Gradient wird im "grad" -Mitglied der Eingangsvariablen jeder Funktion gespeichert. Auf diese Weise kann auf diesen Gradienten von der nächstniedrigeren Schicht Bezug genommen werden, da "Eingabe" einer bestimmten Schicht = "Ausgabe" der nächstniedrigeren Schicht ist.

f3 = y3.creator                      # 0.Folgen Sie zunächst der Verlustfunktion aus dem Verlustwert

gx = f3.backward()                   # 1.Verlustfunktionsgradient
f3.input.grad = gx                   # 2.In Eingabegrad speichern

f2 = f3.input.creator                # 3.Folgen Sie der Funktion eine Schicht darunter

gx = f2.backward()                   # 4.Gradient der aktuellen Schicht(d Ausgabe/d Eingabe)
f2.input.grad = f2.output.grad * gx  # 5. f.Gradient der Verlustfunktion in Bezug auf die Eingabe

f1 = f2.input.creator                # 3.Folgen Sie der Funktion eine Schicht darunter

gx = f1.backward()                   # 4.Gradient der aktuellen Schicht(d Ausgabe/d Eingabe)
f1.input.grad = f1.output.grad * gx  # 5. f.Gradient der Verlustfunktion in Bezug auf die Eingabe

In 5. lautet die Berechnung "d Verlustfunktion / d Eingabe = (d Verlustfunktion / d Ausgabe) * (d Ausgabe / d Eingabe)" gemäß dem Kettengesetz der Differenzierung. Wenn Sie zu 5 gehen, können Sie auch sehen, dass es sich von 3 wiederholt.

Auf diese Weise haben wir festgestellt, dass wir aus der endgültigen Ausgabevariablen "y3" die Steigung für den Verlust für die Eingaben aller Schichten berechnen können. Dies entspricht auch dem folgenden Code, wenn Sie ihn in Kürze mit der Zwischenausgangsvariablen schreiben, die vorübergehend für die Vorwärtsberechnung in einer benannten Form verwendet wurde, damit er leicht zu verstehen ist, ohne zum Zeitpunkt der Zuweisung unnötig Zeilen zu trennen. ist.

#Ab y3
f3 = y3.creator

y2.grad = f3.backward() * 1        #Gradient von f3 in Bezug auf y2*Gradient von f3 für y3
y1.grad = f2.backward() * y2.grad  #Gradient von f2 in Bezug auf y1*Gradient von f3 in Bezug auf y2
y0.grad = f1.backward() * y1.grad  #Gradient von f1 in Bezug auf y0*Gradient von f3 in Bezug auf y1

Wenn Sie außerdem in einer Zeile nach "f3 = y3.creator" schreiben,

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

Es wird sein. Dies entspricht genau der folgenden Differenzierungskette.

\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}

Da "y0" eine Eingabe in das Netzwerk ist, kann es als "x" bezeichnet werden, "f3" ist eine Verlustfunktion, daher sollte es als "l" bezeichnet werden, und "y3" ist ein Verlust, daher kann es als "Verlust" usw. bezeichnet werden, aber hier ist es rückwärts, um den Gradienten zu finden. Um zu betonen, dass die Berechnung durch Wiederholen derselben Berechnung von der oberen zur unteren Schicht durchgeführt wird, haben wir eine Notation verwendet, in der sich nur der Index ändert. Außerdem beträgt die Differenzierung von "f3" durch "y3" $ 1 $, da dies die Differenzierung für sich bedeutet.

Dies hat jedoch noch nicht den Gradienten berechnet, der zum Aktualisieren der Parameter der Funktion erforderlich ist. Um die Parameter zu aktualisieren, benötigen wir einen ** Gradienten über die Parameter ** für die Verlustfunktion. Um dies zu erhalten, berechnen Sie bei jeder "Rückwärts" -Methode zuerst den ** Parametergradienten ** für Ihre Ausgabe und multiplizieren Sie ihn dann mit dem von der obersten Ebene übertragenen Gradienten, um das "gw" zu berechnen. Alles was du tun musst, ist.

Wenn zum Beispiel die Funktion "f2" in der Mitte den Parameter "w" hat und die Umwandlung "w * y1" für den Eingang "y1" durchgeführt wird, ist der Gradient von "f2" für "w" "y1". Da es "und dies ist die Eingabe" f2.input "für" f2 "ist, wird" gw "zu:

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

Informationen aus der oberen Schicht werden durch die Variable "f2.output" aggregiert, und Informationen aus der unteren Schicht werden durch die Variable "f2.input" aggregiert, sodass der Gradient für die Verlustfunktion für die Parameter in der Schicht unter Verwendung dieser Variablen berechnet werden kann. Es ist wie geworden.

Dieses gw wird verwendet, um den Parameter w zu aktualisieren. Die Aktualisierungsregeln selbst variieren je nach Optimierungsmethode. Das Optimierungsprogramm für die Überwachung und Aktualisierung von Parametern im Netzwerk wird später beschrieben.

Fügen Sie nun die folgenden Funktionen zu Variable und Funktion hinzu, damit der obige Backpropagation-Berechnungsprozess in der endgültigen Ausgabevariablen mit einer Methode namens "rückwärts" ausgeführt werden kann.

Insbesondere sieht es so aus.

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

Hier ist es nicht interessant, wenn die Funktion nur eine konstante Zuordnung ist, so dass die Funktion nur die Schnittstellendefinition ist, damit verschiedene Funktionen definiert werden können, und die Klasse namens "Mul", die tatsächlich "vorwärts" und "rückwärts" hat, ist diese Funktion. Wird geerbt und definiert.

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

Dies ist nur eine Funktion, die die Eingabe multipliziert und die während der Initialisierung angegebenen Parameter zurückgibt. In den Inhalten von "rückwärts" werden der Gradient seiner eigenen Umwandlung bzw. der Gradient des Parameters erhalten, und der Gradient des Parameters wird in "self.gw" gehalten.

Lassen Sie uns eine Vorwärtsberechnung mit diesen erweiterten Variablen und Funktionen durchführen.

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]

Sie können sehen, dass jedes Mal, wenn Sie eine Funktion anwenden, der Wert mit dem Anfangswert multipliziert wird, der jeder Funktion zugewiesen wurde. Wenn hier "y3.backward ()" ausgeführt wird, werden die bisher angewendeten Funktionen von "y3" in umgekehrter Reihenfolge zurückverfolgt, das "Rückwärts" jeder Funktion wird nacheinander aufgerufen und die dazwischen liegende Eingangs- / Ausgangsvariable " Die grad`-Mitgliedsvariable enthält den Gradienten für die endgültige Ausgabe.

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

Das Folgende ist ein einfaches Diagramm dessen, was in jedem Objekt in der Reihe des Ablaufs passiert, in dem Sie die Vorwärtsberechnung selbst schreiben und dann das "Rückwärts ()" der endgültigen Ausgabevariablen aufrufen.

Backward.gif

Link

Link ruft Function intern auf und übergibt die Parameter, die für die von Function zu diesem Zeitpunkt durchgeführte Konvertierung erforderlich sind. Diese Parameter werden als Mitgliedsvariablen des Link-Objekts beibehalten und vom Optimierer während des Netzwerktrainings aktualisiert.

(to be continued)

Chain

Die Kette kann eine beliebige Anzahl von Links enthalten. Dies ist nützlich, um Parameter usw. zu gruppieren, die Sie aktualisieren möchten, oder um leicht verständliche Untereinheiten eines großen Netzwerks zu beschreiben.

(to be continued)

Appendix

Grundlagen der linearen Schicht

Wenn wir mit Nachdruck sagen, dass das Neuronale Netzwerk eine zusammengesetzte Abbildung ist, die mehrere abwechselnde Anwendungen linearer und nichtlinearer Transformationen umfasst, kann eine der linearen Transformationen, aus denen dies besteht, die Affin-Transformation sein. Die affine Transformation bedeutet hier, dass, wenn ein reeller Wertvektor als $ {\ bf x} \ in \ mathbb {R} ^ {d_ {in}} $ gesetzt wird, die Gewichtsmatrix $ {\ bf W} \ Durch Multiplizieren von \ mathbb {R} ^ {d_ {in} \ times d_ {out}} $ und Addieren des Bias-Vektors $ {\ bf b} \ in \ mathbb {R} ^ {d_ {out}} $ Es bezieht sich auf die Transformation, die geometrisch durchgeführt wird: "Drehen, Skalieren, Scheren und Verschieben".

Erwägen Sie, dies als eine Schicht zu implementieren, aus der ein neuronales Netzwerk namens Linear besteht. Eine Schicht kann trainierbare Parameter haben oder nicht, aber die lineare Schicht hat $ {\ bf W} $ - und $ {\ bf b} $ -Parameter zum Durchführen affiner Transformationen. Ist ein lernbarer Parameter, da er auf den Parameter aktualisiert wird, der die gewünschte Konvertierung durchführt.

Lassen Sie uns nun die lineare Ebene als eine in Python geschriebene Klasse ausdrücken.

Lassen Sie uns die zu erledigende Funktion implementieren.

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)

In dieser linearen Klasse werden zunächst die Parameter ($ {\ bf W}, {\ bf b} $), die die lineare Ebene im Konstruktor hat, auf 0 gemittelt, und die Standardabweichung beträgt $ \ sqrt {2 \ / \ {\ rm in \. Initialisiert mit einer normalen Zufallszahl von _sz}} $.

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

Diese Initialisierungsmethode heißt HeNormal [^ HeNormal]. in_sz ist die Eingabegröße, dh die Dimension $ d_ {in} $ des Eingabevektors, und out_sz ist die Ausgabegröße, dh die Dimension $ d_ {out} $ des konvertierten Ausgabevektors.

Als nächstes entspricht die Methode call der Vorwärtsberechnung, bei der $ {\ bf W} {\ bf x} + {\ bf b} $ berechnet wird.

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

Die Berechnung des Gradienten für die Parameter zur Ausgabe (= rückwärts) ist für die lineare Ebene sehr einfach, daher wird sie im obigen Code nicht als unabhängige Methode bereitgestellt. Insbesondere ist $ \ partiell {\ bf y} \ / \ \ partiell {\ bf W} = {\ bf x}, \ partiell {\ bf y} \ / \ \ partiell {\ bf b} = {\ bf 1} $ ($ {\ bf 1} $ ist ein $ d $ dimensionaler Vektor, dessen Elemente alle $ 1 $ sind), der wie in der "update" -Methode bekannt verwendet wird. Die erste Zeile des folgenden Teils, "self.x", entspricht $ \ partiell {\ bf y} \ / \ \ partiell {\ bf W} $. Auf der rechten Seite der zweiten Zeile, "gy.sum (axis = 0)", wird dieselbe Berechnung wie "gy.T.dot (numpy.ones ((gy.shape [0],))") durchgeführt. Ich werde. Der Teil numpy.ones ((gy.shape [0],)) entspricht $ \ partiell {\ bf y} \ / \ \ partiell {\ bf b} $.

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

Wenn die Berechnung des Gradienten komplizierter ist, ist es besser, eine Rückwärtsmethode usw. vorzubereiten, damit der Teil, der den Gradienten für jeden Parameter berechnet, vom "Update" getrennt ist.

Parameteraktualisierungen sollten im Aktualisierungsprozess abstrahiert oder als separate Klasse ausgeschnitten worden sein, um verschiedene Varianten der Gradientenmethode [^ Optimierer] zu berücksichtigen, aber hier ist dies die einfachste. Die lineare Klasse selbst, die die Parameter enthält, verfügt über eine "Update" -Methode, bei der nur die Aktualisierung der Parameter mithilfe der probabilistischen Gradientenabstiegsmethode (manchmal auch als Vanilla SGD bezeichnet) in Betracht gezogen wird.

Was mit der "Update" -Methode gemacht wird, ist einfach. Zunächst wird gemäß der Kettenregel bei der Differenzierung der zusammengesetzten Funktion das Produkt des Gradienten für jede Eingabe für jede Schichtausgabe in der oberen Schicht multipliziert mit allen Schichten als "gy" übergeben, so dass dies der Parameter $ für die Ausgabe dieser Schicht ist. Berechnet durch Multiplikation des Gradienten für {\ bf W}, {\ bf b} $. Dies ist dann der Gradient für die Parameter $ {\ bf W}, {\ bf b} $ für die Zielfunktion. Multiplizieren Sie diesen mit der Lernrate "lr", um den Aktualisierungsbetrag zu berechnen, und tatsächlich aus den Parametern Wir subtrahieren und aktualisieren.

Die update -Methode gibt die Gesamtleistung des von der oberen Schicht übergebenen Gradienten zurück, multipliziert mit dem Gradienten $ {\ bf W} $ für die Eingabe in die eigene Ausgabe. Dies wird in den unteren Schichten als "gy" verwendet.

Die für die Backpropagation erforderliche Gradientenberechnung ist dank des Kettengesetzes sehr einfach zu implementieren. Jede Ebene übergibt den Gradienten $ \ partiell f \ / \ \ partiell {\ bf x} $ für die Eingabe $ {\ bf x} $ an die Transformation $ f $, die sie als "gy" an die unteren Ebenen durchführt, und an jede Ebene Sie können die Parameter aktualisieren, indem Sie das von der oberen Ebene übergebene gy mit dem Gradienten der Parameter für Ihre Konvertierung $ f $ multiplizieren und diesen verwenden.

Wenn Sie eine Klasse definieren, die die oben genannten Funktionen implementiert, können Sie eine lineare Ebene mit einer beliebigen Eingabe- / Ausgabegröße erstellen. Wenn Sie einen Wert an die lineare Ebene übergeben, rufen Sie das Objekt als Funktion auf und übergeben Sie das Objekt "numpy.ndarray" als Argument. Wenn Sie die internen Parameter aktualisieren, wird das "gy" von der oberen Ebene und dem Aktualisierungsausdruck übergeben Dies bedeutet, dass die in verwendete Lernrate "lr" an die "update" -Methode übergeben wird.

Über die ReLU-Ebene

Zu Beginn des Anhangs zu den Grundlagen der obigen linearen Schicht habe ich gesagt, dass das Neuronale Netzwerk zwangsweise "abwechselnd lineare und nichtlineare Transformationen anwendet", daher möchte ich nichtlineare Transformationen auf die Ausgabe der linearen Schicht anwenden. Ich werde. Das Neuronale Netz schlägt eine Vielzahl nichtlinearer Transformationen vor, die als Aktivierungsfunktionen bezeichnet werden. Eine der derzeit am häufigsten verwendeten ist ReLU, aber ihre nichtlineare Transformation kann wie folgt geschrieben werden.

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)

Da die Aktivierungsfunktion eine parameterlose Konvertierung ist, aktualisiert update keine Parameter. Stattdessen berechnet es seinen eigenen Subgradienten und multipliziert ihn mit "gy".

Datensatz lesen

Eine Bibliothek namens Scikit-learn erleichtert das Herunterladen und Laden von MNIST-Datensätzen.

from sklearn.datasets import fetch_mldata

#MNIST-Datensatz lesen
mnist  = fetch_mldata('MNIST original', data_home='.')
td, tl = mnist.data[:60000] / 255.0, mnist.target[:60000]

# 1-Machen Sie es zu einem heißen Vektor
tl     = numpy.array([tl == i for i in range(10)]).T.astype(numpy.int)

#Mischen
perm   = numpy.random.permutation(len(td))
td, tl = td[perm], tl[perm]

Validierungsdaten wurden auf die gleiche Weise erstellt.

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

Softmax Cross Entropy Berechnung und Differenzierung

Wenn die Ausgabe des Netzwerks $ {\ bf y} \ in \ mathbb {R} ^ {d_ {l}} $ ist, ist die mit der Softmax-Funktion in einen Wahrscheinlichkeitsvektor konvertierte $ \ hat {\ bf y} $ Wenn ja, wird dies wie folgt berechnet:

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

Zu diesem Zeitpunkt repräsentiert $ \ hat {y} \ _ {i} \ (i = 1,2, \ dots, d_l) $ die Wahrscheinlichkeit, also $ 0 \ leq \ hat {y} \ _ {i} \ leq 1 $ Es wird sein.

Das Lehrersignal ist nun auch ein eindimensionaler $ d_l $ -Dimensionalvektor (ein Vektor, in dem nur eines der Elemente $ 1 $ und alle anderen Elemente $ 0 $ sind) $ {\ bf t} = [t_1, t_2, \ dots, t_ {d_l}] ^ {\ rm T} $ Wenn es durch $ \ hat {\ bf y} = {\ bf t} $ dargestellt wird, ist die Wahrscheinlichkeit $ L ( \ hat {\ bf y} = {\ bf t}) $ kann wie folgt definiert werden.

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

$ t_i $ ist nur dann $ 1 $, wenn $ i $ der Index der richtigen Klasse ist und alles andere $ 0 $. Wenn also die richtige Klasse $ i = 5 $ ist, lautet die obige Formel $ 1 \ cdot 1 \ cdots \ hat {y} \ _ {5} \ cdots 1 = \ hat {y} \ _ {5} $. Mit anderen Worten, dieses $ L $ kann so interpretiert werden, dass es bedeutet: "Wie gut und mit einem hohen Maß an Sicherheit können Sie die richtige Antwort vorhersagen?" Dann wäre es schön, wenn dieser Wert erhöht werden könnte, aber im Allgemeinen nehmen Sie den Logarithmus dieses $ L $ und invertieren Sie das Vorzeichen $ - \ log (L) $ ist ** minimiert * *Machen. Da $ \ log $ monoton ansteigt, ist $ L $ auch maximal, wenn $ \ log (L) $ maximal ist, und das Vorzeichen wird invertiert, wenn $ \ log (L) $ maximal $ - \ log (L) ist. $ Sollte der kleinste sein. Infolgedessen wird durch Minimieren von $ - \ log (L) $ die durch die obige Gleichung ausgedrückte Wahrscheinlichkeit maximiert [^ SoftmaxCrossEntropy_derivation]. Dieses $ - \ log (L) $ wird als "negative Log-Wahrscheinlichkeit" bezeichnet, da es das Log der Wahrscheinlichkeit nimmt und das Vorzeichen invertiert. Im Kontext des Neuronalen Netzes wird es jedoch häufiger als Kreuzentropie bezeichnet. Ich glaube schon. Wenn Sie diese Kreuzentropie erneut als $ \ mathcal {L} $ setzen,

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

ist. Wir werden dies als Verlustfunktion zum Erlernen des neuronalen Netzwerks verwenden, das das Klassifizierungsproblem löst, und versuchen, es zu minimieren.

Wenn nun in der Ebenendefinition unter Verwendung der Python-Klasse wie oben beschrieben "$ 1 $ immer an den" gy "der" update "-Methode übergeben wird", kann die Verlustfunktion als eine Ebene betrachtet werden. Da die Verlustfunktion selbst keine zu aktualisierenden Parameter hat, besteht die mit der "Aktualisierungs" -Methode durchzuführende Berechnung darin, den Gradienten für "Eingabe in die Verlustfunktion = Netzwerkausgabe" für die Ausgabe = Verlustwert zu finden. Das ist,

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

Das heißt, wir wissen, dass wir berechnen können: Über $ 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}

Die Summenmarke wird hier nicht entfernt, da die Softmax-Funktion Werte aller Dimensionen im Nenner enthält und somit eine Funktion für alle Indizes ist. Nun ist der Gradient der Softmax-Funktion

Wenn $ 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} $

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

Daher können wir dies verwenden, um den Ausdruck $ (1) $ in separate Begriffe zu zerlegen, wenn der Inhalt des Summensymbols $ i = k $ ist und wenn $ i \ neq k $. Wenn Sie das tun

\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}

Es wird sein. Hier, wenn der erste Term $ i = k $ ist und der zweite Term $ i \ neq k $ ist. Wenn es sich weiter verwandelt,

\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}

Es wird sein. Hier wird die Eigenschaft des One-Hot-Vektors ($ \ sum_i t_i = 1 $) für die endgültige Transformation verwendet. Wenn Sie das Ergebnis hier erneut schreiben,

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

Es stellte sich heraus. Mit anderen Worten, dies ist das "gy", das von der "update" -Methode der Softmax Cross Entropy-Klasse zurückgegeben wird.

Farbverlauf für die Parameter einer Ebene in Bezug auf den Verlust

Wenn die Verlustfunktion $ \ mathcal {L} $ ist, beträgt der Gradient der Verlustfunktion für den Parameter $ {\ bf W} _l $ in der Ebene $ l $ $ l + 1, l + 2 für die darüber liegenden Ebenen. Als, \ dots, L $ wird es durch das Kettengesetz der Differenzierung wie folgt.

\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}

Zu diesem Zeitpunkt befinden sich alle Gradienten von $ \ partiell y_ {l + 1} \ / \ \ partiell y_l $ bis $ \ partiell y_ {L} \ / \ \ partiell y_ {L-1} $ in der $ l $ -Schicht. Es gibt einen Gradienten ** um die Eingabe in die ** Ausgabe der oberen Schicht, die zu führt. Nennen wir dies den Eingabe- / Ausgabegradienten. Wenn Sie sich für das letzte $ \ partielle \ mathcal {L} \ / \ \ partielle y_L $ $ \ mathcal {L} $ als Verlustschicht der $ L + 1 $ -Schicht vorstellen, ist die Ausgabe (Verlust) der Verlustschicht dieselbe. Es ist ein Eingabe- / Ausgabegradient, da es sich um einen Gradienten für die Eingabe (vorhergesagter Wert des Netzwerks) handelt. Mit anderen Worten, ** das Produkt aller Eingabe- / Ausgabegradienten jeder Ebene, die zwischen Ihrer eigenen Ebene und dem Verlust multipliziert mit dem Gradienten der Parameter für die Ausgabe Ihrer eigenen Ebene ** verbunden sind, möchten Sie durch Rückwärtsberechnung berechnen. Daher wird der Eingabe- / Ausgabegradient jeder Ebene an alle Funktionen übergeben, die Variable an sich selbst übergeben haben, und die übergebene Seite leitet das Produkt des Eingabe- / Ausgabegradienten jeder Ebene an die untere Ebene weiter. Das musst du nur tun.

[^ cupy-PR]: Verwandte PRs umfassen: "Unterstützung der erweiterten Indizierung mit booleschem Array für getitem": https://github.com/pfnet/chainer/pull/1840 [^ Optimierer]: Chainer implementiert AdaDelta, AdaGrad, Adam, MomentumSGD, NesterovAG, RMSprop, RMSpropGraves, SGD (= Vanilla SGD), SMORMS3. Es gibt kein RProp und Eve hat eine PR (https://github.com/pfnet/chainer/pull/1847), die noch nicht zusammengeführt wurde. Ich möchte Adam und Eva so schnell wie möglich zusammenbringen (?).

Recommended Posts

[WIP] 1-Datei-Chainer erstellen
Erstellen Sie eine Dummy-Datendatei
Erstellen Sie eine Xlsx-Datei mit XlsxWriter
Erstellen Sie eine Binärdatei in Python
Erstellen Sie eine 1-MByte-Zufallszahlendatei
So erstellen Sie eine Konfigurationsdatei
Erstellen Sie mit Django einen Datei-Uploader
Erstellen Sie schnell eine Excel-Datei mit Python #python
Erstellen Sie eine große Textdatei mit Shellscript
VM mit YAML-Datei (KVM) erstellen
Erstellen Sie eine Excel-Datei mit Python + Ähnlichkeitsmatrix
Erstellen Sie eine Deb-Datei aus einem Python-Paket
[GPS] Erstellen Sie eine kml-Datei mit Python
Skript zum Erstellen einer Mac-Wörterbuchdatei
Erstellen Sie mit cx_Freeze eine aktualisierbare MSI-Datei