Test Run

Neuronale Netzwerke

James McCaffrey

James McCaffreyBei einem künstlichen neuronalen Netzwerk (gewöhnlich kurz "neuronales Netzwerk" genannt) handelt es sich um eine sich an biologischen Neuronen und Synapsen orientierende Abstraktion. Obwohl neuronale Netzwerke bereits seit Jahrzehnten erforscht werden, sind meines Erachtens viele Implementierungen neuronalen Netzwerkcodes im Internet nicht sehr gut erklärt. Im Artikel für diesen Monat werde ich erläutern, was künstliche neuronale Netzwerke sind, außerdem werde ich C#-Code für die Implementierung eines neuronalen Netzwerks vorstellen.

Um einen besseren Eindruck davon zu bekommen, worum es in diesem Artikel genau geht, können Sie sich Abbildung 1 und Abbildung 2 ansehen. Sie können sich neuronale Netzwerke als numerische Eingabe-Ausgabemechanismen vorstellen. Das neuronale Netzwerk in Abbildung 1 umfasst drei Eingaben x0, x1 und x2 mit den jeweiligen Werten 1.0, 2.0 und 3.0. Das neuronale Netzwerk beinhaltet zwei Ausgaben y0 und y1 mit den Werten 0.72 bzw. -0.88. In dem in Abbildung 1 dargestellten neuronalen Netzwerk gibt es eine Schicht sogenannter verdeckter Neuronen. Dieses Netzwerk kann als dreischichtiges, vollständig verbundenes Feedforward-Netzwerk mit drei Eingaben, zwei Ausgaben und vier verdeckten Neuronen beschrieben werden. Leider gibt es keine einheitliche Terminologie zu neuronalen Netzwerken. In diesem Artikel werde ich im Allgemeinen - aber nicht immer - die auf der hervorragenden Webseite mit häufig gestellten Fragen zu neuronalen Netzwerken beschriebene Terminologie verwenden. Diese finden Sie unter bit.ly/wfikTI.

Neural Network Structure
Abbildung 1: Struktur des neuronalen Netzwerks

Neural Network Demo Program

Abbildung 2: Demoprogramm zu neuronalen Netzwerken

Abbildung 2 zeigt die Ausgabe des in diesem Artikel vorgestellten Demoprogramms. In dem neuronalen Netzwerk wird sowohl eine sigmoide Aktivierungsfunktion als auch eine Tanh-Aktivierungsfunktion verwendet. Diese Funktionen sind durch die beiden Gleichungen mit den griechischen Buchstaben phi in Abbildung 1 angedeutet. Die Ausgaben eines neuronalen Netzwerks hängen von den Werten einer Reihe numerischer Gewichtungen und Biase ab. In diesem Beispiel gibt es insgesamt 26 Gewichtungen und Biase mit den Werten 0.10, 0.20 ... -5.00. Nachdem die Gewichtungs- und Biaswerte in das neuronale Netzwerk geladen wurden, lädt das Demoprogramm die drei Eingabewerte (1.0, 2.0, 3.0) und führt dann entsprechend den Nachrichten zu den Summen "Eingabe-zu-Verdeckt" und "Verdeckt-zu-Ausgabe" eine Reihe von Berechnungen durch. Zum Schluss zeigt das Demoprogramm die beiden Ausgabewerte an (0.72, -0.88).

Ich werde Sie schrittweise durch das Programm führen, das die in Abbildung 2 gezeigte Ausgabe generiert hat. In diesem Artikel werden gewisse Programmierkenntnisse vorausgesetzt, es wird jedoch nicht davon ausgegangen, dass Sie sich mit neuronalen Netzwerken auskennen. Das Demoprogramm ist in der Programmiersprache C# codiert, es sollte jedoch problemlos möglich sein, den Democode in einer anderen Sprache wie z. B. Visual Basic .NET oder Python umzugestalten. Bei dem in diesem Artikel vorgestellten Programm handelt es sich im Wesentlichen um ein Lernprogramm und eine Plattform zum eigenen Experimentieren. Praktische Probleme lassen sich mit diesem Programm nicht direkt lösen, ich werde Ihnen daher erläutern, wie Sie den Code erweitern können, um wichtige Probleme zu beheben. Sie werden hier sicher sehr interessante Informationen finden, und vielleicht können Sie mit den hier beschriebenen Programmiertechniken Ihre Coding-Fähigkeiten ausbauen.

Aufbau eines Modells eines neuronalen Netzwerks

Künstliche neuronale Netzwerke sind auf dem Verhalten biologischer neuronaler Netzwerke basierend aufgebaut. Die Kreise in Abbildung 1 stellen Neuronen dar, in denen eine Verarbeitung erfolgt, und die Pfeile stehen für den Informationsfluss sowie für numerische Werte, die sogenannten Gewichtungen. In vielen Situationen werden Eingabewerte direkt in Eingabeneuronen kopiert, ohne dass eine Gewichtung erfolgt, und sie werden auch direkt ohne jegliche Verarbeitung ausgegeben. Die erste tatsächliche Aktion erfolgt somit in den Neuronen der verdeckten Schicht. Nehmen wir an, dass die Eingabewerte 1.0, 2.0 und 3.0 von den Eingabeneuronen ausgegeben werden. In Abbildung 1 sehen Sie einen Pfeil zur Darstellung eines Gewichtungswerts zwischen jedem der drei Eingabeneuronen und der vier verdeckten Neuronen. Nehmen wir an, die drei abgebildeten Gewichtungspfeile, die auf das oberste verdeckte Neuron zeigen, tragen die Bezeichnungen w00, w10 und w20. Bei dieser Notation stellt der erste Index den Index des Quelleneingabeneurons dar und der zweite Index den Index des verdeckten Zielneurons. Die Neuronenverarbeitung erfolgt in drei Schritten: Im ersten Schritt wird eine gewichtete Summe berechnet. Nehmen wir an, es gilt Folgendes: w00 = 0.1, w10 = 0.5 und w20 = 0.9. Die gewichtete Summe für das oberste verdeckte Neuron lautet wie folgt: (1.0)(0.1) + (2.0)(0.5) + (3.0)(0.9) = 3.8. Im zweiten Verarbeitungsschritt wird ein Biaswert hinzugefügt. Angenommen, der Biaswert beträgt -2.0. Die angepasste gewichtete Summe lautet dann: 3.8 + (-2.0) = 1.8. Im dritten Schritt wird auf die angepasste gewichtete Summe eine Aktivierungsfunktion angewandt. Nehmen wir an, bei der Aktivierungsfunktion handelt es sich um die durch 1.0 / (1.0 * Exp(-x)) definierte Sigmoidfunktion, wobei Exp für die Exponentialfunktion steht. Die Ausgabe des verdeckten Neurons lautet dann 1.0 / (1.0 * Exp(-1.8)) = 0.86. Diese Ausgabe wird nun Teil der gewichteten Summe, die in jedes Neuron der Ausgabeschicht eingegeben wird. In Abbildung 1 ist dieser drei Schritte umfassende Prozess durch die Gleichung mit dem griechischen Buchstaben phi angedeutet: Es werden gewichtete Summen (xw) berechnet, ein Bias (b) wird hinzugefügt, und eine Aktivierungsfunktion (phi) wird angewandt.

Nach Berechnung der Werte für alle verdeckten Neuronen werden die Werte für die Neuronen der Ausgabeschicht auf dieselbe Weise berechnet. Für die Berechnung der Werte der Ausgabeneuronen kann dieselbe Aktivierungsfunktion verwendet werden wie bei der Berechnung der Werte der verdeckten Neuronen, es kann aber auch eine andere Aktivierungsfunktion eingesetzt werden. In dem in Abbildung 2 dargestellten Demoprogramm wird als "Verdeckt-zu-Ausgabe"-Aktivierungsfunktion die hyperbolisch-tangente Funktion verwendet. Nachdem die Werte für alle Neuronen der Ausgabeschicht berechnet wurden, werden diese Werte in den meisten Situationen weder gewichtet noch verarbeitet, sondern einfach als endgültige Ausgabewerte des neuronalen Netzwerks ausgegeben.

Interne Struktur

Grundlegend für das Verständnis der hier dargestellten Implementierung eines neuronalen Netzes ist eine genaue Betrachtung von Abbildung 3. Auf den ersten Blick mag diese Abbildung extrem kompliziert erscheinen. Sie können mir aber glauben, das Ganze ist nicht annähernd so kompliziert, wie es zunächst scheint. In Abbildung 3 sind insgesamt acht Arrays und zwei Matrizen dargestellt. Der erste Array trägt die Bezeichnung "this.inputs". In diesem Array sind die Eingabewerte des neuronalen Netzwerks, in diesem Beispiel 1.0, 2.0 und 3.0 enthalten. Als Nächstes kommt die Gruppe der Gewichtungswerte, mit denen die Werte in der sogenannten verdeckten Schicht berechnet werden. Diese Gewichtungen sind in einer 3 x 4-Matrix mit der Bezeichnung "i-h weights" (E-V-Gewichtungen) gespeichert. "E-V" steht dabei für "Eingabe-zu-Verdeckt". Beachten Sie in Abbildung 1, dass die Demoversion des neuronalen Netzwerks vier verdeckte Neuronen umfasst. Die Zeilenanzahl in der Matrix "i-h weights" (E-V-Gewichtungen) entspricht der Eingabenanzahl und die Spaltenanzahl der Anzahl verdeckter Neuronen.


Neural Network Internal Structure
Abbildung 3: Interne Struktur des neuronalen Netzwerks

Das Array mit der Bezeichnung "i-h sums" (E-V-Summen) ist ein Arbeitsarray für die Berechnung. Die Länge des Arrays "i-h sums" (E-V-Summen) entspricht immer der Anzahl verdeckter Neuronen (vier in diesem Beispiel). Das nächste Array trägt die Bezeichnung "i-h-biases" (E-V-Biase). Biase in neuronalen Netzen sind zusätzliche Gewichtungen zur Berechnung der Neuronen in den verdeckten Schichten und Ausgabeschichten. Die Länge des Arrays "i-h biases" (E-V-Biase) entspricht der Länge des Arrays "i-h-sums" (E-V-Summen), die wiederum der Anzahl verdeckter Neuronen entspricht.

Das Array mit der Bezeichnung "i-h outputs" (E-V-Ausgaben) stellt ein Zwischenergebnis dar. Die Werte in diesem Array werden als Eingaben für die nächste Schicht verwendet. Die Länge des Arrays "i-h sums" (E-V-Summe) entspricht der Anzahl verdeckter Neuronen.

Als Nächstes kommt eine Matrix mit der Bezeichnung "h-o weights" (V-A-Gewichtungen). Dabei steht "V-A" für "Verdeckt-zu-Ausgabe". Die Matrix "h-o weights" (V-A-Gewichtungen) hat hier die Größe 4 x 2, da es vier verdeckte Neuronen und zwei Ausgaben gibt. Die Länge der Arrays "h-o sums", "h-o biases" und "this.outputs" entspricht der Ausgabenanzahl (zwei in diesem Beispiel).

Im Array mit der Bezeichnung "weights" (Gewichtungen) unten in Abbildung 3 sind alle Eingabe-zu-Verdeckt- und Verdeckt-zu-Ausgabe-Gewichtungen und -Biase aufgeführt. In diesem Beispiel weist das Gewichtungsarray die folgende Länge auf: (3 * 4) + 4 + (4 * 2) + 2 = 26. Allgemein gilt: Ist Ni die Anzahl der Eingabewerte (number of input values), Nh die Anzahl verdeckter Neuronen (number of hidden neurons) und No die Anzahl der Ausgaben (number of outputs), lautet die Länge des Gewichtungsarrays wie folgt: Nw = (Ni * Nh) + Nh + (Nh * No) + No.

Berechnung der Ausgaben

Nachdem die im vorherigen Abschnitt beschriebenen acht Arrays und zwei Matrizen erstellt wurden, kann ein neuronales Netz anhand der Eingaben, Gewichtungen und Biase die Ausgabe berechnen. Im ersten Schritt werden die Eingabewerte in das Array "this.inputs" kopiert. Im nächsten Schritt werden dem Gewichtungsarray Werte zugewiesen. Für die Demo können Sie beliebige Gewichtungswerte verwenden. Als Nächstes werden die Werte aus dem Gewichtungsarray in die Matrix "i-h-weights" (E-V-Gewichtungen), das Array "i-h-biases" (E-V-Biase), die Matrix "h-o-weights" (V-A-Gewichtungen) und das Array "h-o-biases" (V-A-Biase) kopiert. Diese Beziehung soll in Abbildung 3 veranschaulicht werden.

Die Werte im Array "i-h sums" (E-V-Summen) werden in zwei Schritten berechnet. Im ersten Schritt werden die gewichteten Summen berechnet. Zu diesem Zweck werden die Werte im Eingabearray (inputs) mit den Werten in der entsprechenden Spalte der Matrix "i-h weights" (E-V-Gewichtungen) multipliziert. So werden beispielsweise für die gewichtete Summe für das verdeckte Neuron [3] (bei dem ich eine nullbasierte Indexierung verwende) jeder Eingabewert und die Werte in Spalte [3] der Matrix "i-h weights" (E-V-Gewichtungen) verwendet: (1.0)(0.4) + (2.0)(0.8) + (3.0)(1.2) = 5.6. Im zweiten Schritt bei der Berechnung von E-V-Summenwerten wird dem aktuellen E-V-Summenwert jeder einzelne Biaswert hinzugefügt. Ein Beispiel: Da "i-h biases" (E-V-Biase) [3] den Wert -7.0 hat, lautet der Wert von "i-h sums" (E-V-Summen) [3] 5.6 + (-7.0) = -1.4.

Nachdem alle Werte im Array "i-h sums" (E-V-Summen) berechnet wurden, wird die Eingabe-zu-Verdeckt-Aktivierungsfunktion auf die Summen angewandt. Daraus ergeben sich die Eingabe-zu-Verdeckt-Ausgabewerte. Es gibt viele mögliche Aktivierungsfunktionen. Die einfachste Aktivierungsfunktion wird als Schrittfunktion bezeichnet. Bei dieser Funktion wird einfach für jeden Eingabewert größer Null 1.0 und für jeden Eingabewert kleiner oder gleich Null 0.0 zurückgegeben. Eine andere häufig eingesetzte Aktivierungsfunktion, die auch in diesem Artikel verwendet wird, ist die Sigmoidfunktion. Diese ist wie folgt definiert: f(x) = 1.0 / (1.0 * Exp(-x)). Die Kurve der Sigmoidfunktion ist in Abbildung 4 dargestellt.

The Sigmoid Function
Abbildung 4 – Die Sigmoidfunktion

Beachten Sie, dass die Sigmoidfunktion einen Wert im Bereich größer Null und kleiner Eins zurückgibt. In unserem Beispiel gilt Folgendes: Wenn der Wert für "i-h sums" (E-V-Summen) [3] nach Hinzufügen des Biaswerts -1.4 lautet, erhält " i-h outputs" (E-V-Ausgaben) [3] den Wert 1.0 / (1.0 * Exp(-(-1.4))) = 0.20.

Nachdem alle Eingabe-zu-Verdeckt-Ausgabeneuronenwerte berechnet wurden, werden diese Werte als Eingaben für die Berechnung der Neuronen der Verdeckt-zu-Ausgabe-Schicht verwendet. Diese Berechnungen funktionieren wie die Eingabe-zu-Verdeckt-Berechnungen: Es werden vorab gewichtete Summen berechnet, Biase hinzugefügt und anschließend wird eine Aktivierungsfunktion angewandt. In diesem Beispiel verwende ich eine hyperbolische Tangentenfunktion, kurz Tanh-Funktion genannt, für die Verdeckt-zu-Ausgabe-Aktivierungsfunktion. Die Tanh-Funktion ist der Sigmoidfunktion sehr ähnlich. Die Kurve der Tanh-Funktion weist eine ähnliche S-Form wie die Kurve der Sigmoidfunktion auf, die Tanh-Funktion gibt jedoch einen Wert im Bereich (-1,1) und nicht im Bereich (0,1) zurück.

Kombinieren von Gewichtungen und Biasen

Keine der Implementierungen neuronaler Netzwerke, die ich im Internet gesehen habe, wahrt getrennte Gewichtungs- und Biasarrays. Stattdessen werden die Gewichtungen und Biase in der Gewichtungsmatrix kombiniert. Wie ist dies möglich? Denken Sie daran, dass die Berechnung des Werts des Eingabe-zu-Verdeckt-Neurons [3] (i0 * w03) + (i1 * w13) + (i2 * w23) + b3 entsprach, wobei i0 den Eingabewert [0] hat, w03 die Gewichtung für die Eingabe [0] und Neuron [3] und b3 der Biaswert für das Verdeckt-Neuron [3] ist. Wenn Sie eine zusätzliche, gefälschte Eingabe [4] mit einem Platzhalterwert von 1,0 und eine zusätzliche Gewichtungszeile für die Biaswerte erstellen, ändert sich die zuvor beschriebene Berechnung folgendermaßen: (i0 * w03) + (i1 * w13) + (i2 * w23) + (i3 * w33), wobei i3 der Platzhaltereingabewert 1.0 und w33 der Bias ist. Das Argument für diesen Ansatz besteht darin, dass dieser das neuronale Netzwerkmodell vereinfacht. Ich bin anderer Meinung. Meiner Meinung nach ist ein neuronales Netzwerkmodell durch die Kombination von Gewichtungen und Biasen schwieriger zu verstehen, und die Implementierung ist fehleranfälliger. Anscheinend bin ich jedoch der einzige, der diese Meinung vertritt. Sie sollten daher Ihre eigene Entwurfsentscheidung treffen.

Implementierung

Ich habe das in den Abbildungen 1, 2 und 3 gezeigte neuronale Netzwerk mittels Visual Studio 2010 implementiert. Ich habe eine C#-Konsolenanwendunge namens NeuralNetworks erstellt. Im Projektmappen-Explorer-Fenster habe ich mit der rechten Maustaste auf die Datei Program.cs geklickt und diese zu NeuralNetworksProgram.cs umbenannt. Dadurch wurde auch der vorlagengenerierte Klassenname zu NeuralNetworksProgram geändert. Die Gesamtstruktur des Programms, in dem die meisten WriteLine-Anweisungen entfernt sind, wird in Abbildung 5 dargestellt.

Abbildung 5: Struktur des neuronalen Netzwerkprogramms

using System;
namespace NeuralNetworks
{
  class NeuralNetworksProgram
  {
    static void Main(string[] args)
    {
      try
      {
        Console.WriteLine("\nBegin Neural Network demo\n");
        NeuralNetwork nn = new NeuralNetwork(3, 4, 2);
        double[] weights = new double[] {
          0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.1, 1.2,
          -2.0, -6.0, -1.0, -7.0,
          1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 2.0,
          -2.5, -5.0 };
        nn.SetWeights(weights);
        double[] xValues = new double[] { 1.0, 2.0, 3.0 };
        double[] yValues = nn.ComputeOutputs(xValues);
        Helpers.ShowVector(yValues);
        Console.WriteLine("End Neural Network demo\n");
      }
      catch (Exception ex)
      {
        Console.WriteLine("Fatal: " + ex.Message);
      }
    }
  }
  class NeuralNetwork
  {
    // Class members here
    public NeuralNetwork(int numInput, int numHidden, int numOutput) { ... }
    public void SetWeights(double[] weights) { ... }
    public double[] ComputeOutputs(double[] xValues) { ... }
    private static double SigmoidFunction(double x) { ... }
    private static double HyperTanFunction(double x) { ... }
  }
  public class Helpers
  {
    public static double[][] MakeMatrix(int rows, int cols) { ... }
    public static void ShowVector(double[] vector) { ... }
    public static void ShowMatrix(double[][] matrix, int numRows) { ... }
  }
} // ns

Ich habe die vorlagengenerierten using-Anweisungen gelöscht, mit Ausnahme der Anweisung, die auf den System-Namespace verweist. In der Main-Funktion instantiiere ich nach der Anzeige einer begin-Meldung ein NeuralNetwork-Objekt namens "nn" mit drei Eingaben, vier verdeckten Neuronen und zwei Ausgaben. Als Nächstes weise ich einem Array namens "weights" 26 willkürliche Gewichtungen und Biase zu. Ich lade die Gewichtungen in das NeuralNetwork-Objekt, indem ich eine Methode namens SetWeights verwende. Ich weise einem Array namens xValues die Werte 1.0, 2.0 und 3.0 zu. Ich verwende die Methode ComputeOutputs, um die Eingabewerte in das neuronale Netzwerk zu laden und lege die entsprechenden Ausgaben fest, die ich in ein Array namens yValues einlese. Zum Schluss zeigt das Demoprogramm die Ausgabewerte an.

Die Klasse NeuralNetwork

Die Definition der Klasse NeuralNetwork beginnt folgendermaßen:

class NeuralNetwork
{
  private int numInput;
  private int numHidden;
  private int numOutput;
...

Wie im vorherigen Abschnitt erklärt, wird die Struktur des neuronalen Netzwerks durch die Anzahl der Eingabewerte, die Anzahl der Neuronen in der verdeckten Schicht und der Anzahl der Ausgabewerte festgelegt. Die Definition der Klasse wird folgendermaßen fortgesetzt:

private double[] inputs;
private double[][] ihWeights; // input-to-hidden
private double[] ihSums;
private double[] ihBiases;
private double[] ihOutputs;
private double[][] hoWeights;  // hidden-to-output
private double[] hoSums;
private double[] hoBiases;
private double[] outputs;
...

Diese sieben Arrays und zwei Matrizen entsprechen den in Abbildung 3 gezeigten. Ich verwende das Präfix "ih" für Eingabe-zu-Verdeckt-Daten und das Präfix "ho" für Verdeckt-zu-Ausgabe-Daten. Denken Sie daran, dass die Werte im Array ihOutputs als Eingaben für die Berechnungen der Ausgabeschicht dienen. Nachdem alle Eingabe-zu-Verdeckt-Ausgabeneuronenwerte berechnet wurden, werden diese Werte als Eingaben für die Berechnung der Neuronen der Verdeckt-zu-Ausgabe-Schicht verwendet. Daher ist die präzise Benennung etwas schwierig.

Abbildung 6 zeigt, wie der Konstruktor der Klasse NeuralNetwork definiert ist.

Abbildung 6 Der Konstruktor der Klasse NeuralNetwork

public NeuralNetwork(int numInput, int numHidden, int numOutput)
{
  this.numInput = numInput;
  this.numHidden = numHidden;
  this.numOutput = numOutput;
  inputs = new double[numInput];
  ihWeights = Helpers.MakeMatrix(numInput, numHidden);
  ihSums = new double[numHidden];
  ihBiases = new double[numHidden];
  ihOutputs = new double[numHidden];
  hoWeights = Helpers.MakeMatrix(numHidden, numOutput);
  hoSums = new double[numOutput];
  hoBiases = new double[numOutput];
  outputs = new double[numOutput];
}

Nach dem Kopieren der Eingabeparameterwerte numInput, numHidden und numOutput in die entsprechenden Klassenfelder werden den neun Elementarrays und den Matrizen die Größen zugewiesen, die ich an früherer Stelle beschrieben habe. Ich implementiere Matrizen als Arrays von Arrays und nicht mittels des mehrdimensionalen C#-Arraytyps, sodass Sie meinen Code leichter in eine Sprache übertragen können, die keine mehrdimensionalen Arraytypen unterstützt. Da jede Zeile meiner Matrizen zugeteilt werden muss, ist die Verwendung einer Hilfsmethode wie MakeMatrix von Vorteil.

Die Methode SetWeights empfängt ein Array von Gewichtungs- und Biaswerten und füllt ihWeights, ihBiases, hoWeights und hoBiases aus. Die Methode beginnt folgendermaßen:

public void SetWeights(double[] weights)
{
  int numWeights = (numInput * numHidden) +
    (numHidden * numOutput) + numHidden + numOutput;
  if (weights.Length != numWeights)
    throw new Exception("xxxxxx");
  int k = 0;
...

Wie ich an früherer Stelle erklärt habe, beträgt die Gesamtzahl der Gewichtungen und Biase (Nw) in vollständig verbundenen neuronalen Feedforward-Netzwerken (Ni * Nh) + (Nh * No) + Nh + No. Ich führe eine einfache Prüfung durch, um zu sehen, ob der Gewichtungsarrayparameter die korrekte Länge hat. Hier ist "xxxxxx" Platzhalter für eine beschreibende Fehlermeldung. Als Nächstes initialisiere ich die Indexvariable k am Beginn des Gewichtungsarrayparameters. Method SetWeights wird folgendermaßen abgeschlossen:

for (int i = 0; i < numInput; ++i)
  for (int j = 0; j < numHidden; ++j)
    ihWeights[i][j] = weights[k++];
for (int i = 0; i < numHidden; ++i)
  ihBiases[i] = weights[k++];
for (int i = 0; i < numHidden; ++i)
  for (int j = 0; j < numOutput; ++j)
    hoWeights[i][j] = weights[k++];
for (int i = 0; i < numOutput; ++i)
  hoBiases[i] = weights[k++]
}

Jeder Wert im Gewichtungsarrayparameter wird sequenziell zu ihWeights, ihBiases, hoWeights und hoBiases kopiert. Beachten Sie, dass keine Werte zu ihSums oder hoSums kopiert werden, da diese beiden Arbeitsarrays für die Berechung verwendet werden.

Berechnung der Ausgaben

Den Kern der Klasse NeuralNetwork bildet die Methode ComputeOutputs. Die Methode ist überraschend kurz und einfach. Sie beginnt folgendermaßen:

public double[] ComputeOutputs(double[] xValues)
{
  if (xValues.Length != numInput)
    throw new Exception("xxxxxx");
  for (int i = 0; i < numHidden; ++i)
    ihSums[i] = 0.0;
  for (int i = 0; i < numOutput; ++i)
    hoSums[i] = 0.0;
...

Als Erstes prüfe ich, ob die Länge des Arrays für die Eingabe der x-Werte für das NeuralNetwork-Objekt korrekt ist. Dann setze ich die ihSums- und hoSums-Arrays auf Null. Wenn ComputeOutputs nur einmal aufgerufen wird, ist die explizite Initialisierung nicht notwendig. Wenn ComputeOutputs jedoch mehr als einmal aufgerufen wird, weil ihSums und hoSums und akkumulierte Werte darstellen, dann ist die explizite Initialisierung unbedingt erforderlich. Ein alternativer Entwurfsansatz besteht darin, ihSums und hoSums nicht als Klassenelemente zu deklarieren und zuzuteilen, sondern sie stattdessen der Methode ComputeOutputs lokal zuzuweisen. Die Methode ComputeOutputs wird folgendermaßen fortgesetzt:

for (int i = 0; i < xValues.Length; ++i)
  this.inputs[i] = xValues[i];
for (int j = 0; j < numHidden; ++j)
  for (int i = 0; i < numInput; ++i)
    ihSums[j] += this.inputs[i] * ihWeights[i][j];
...

Die Werte im xValues-Arrayparameter werden in das Eingabearrayelement der Klasse kopiert. In einigen Szenarien für neuronale Netzwerke werden die Eingabeparameterwerte normalisiert, indem beispielsweise eine lineare Umwandlung durchgeführt wird, sodass alle Eingabewerte zwischen -1.0 und +1.0 liegen. Hier wird jedoch keine Normalisierung durchgeführt. Als Nächstes berechnet eine verschachtelte Schleife die gewichteten Summen wie in den Abbildungen 1 und 3 gezeigt. Beachten Sie, dass sich j in der äußeren Schleife befinden muss, um ihWeights im Standardformat zu indexieren, wobei der Index i der Zeilenindex und der Index j der Spaltenindex ist. Die Methode ComputeOutputs wird folgendermaßen fortgesetzt:

for (int i = 0; i < numHidden; ++i)
  ihSums[i] += ihBiases[i];
for (int i = 0; i < numHidden; ++i)
  ihOutputs[i] = SigmoidFunction(ihSums[i]);
...

Jede gewichtete Summe wird durch Hinzufügen des entsprechenden Biaswerts modifiziert. An diesem Punkt verwendete ich die Methode Helpers.ShowVector, um die aktuellen Werte im ihSums-Array anzuzeigen und so die in Abbildung 2 gezeigte Ausgabe zu generieren. Als Nächstes wende ich die Sigmoidfunktion auf die einzelnen Werte in ihSums an und weise die Ergebnisse dem Array ihOutputs zu. Ich zeige Ihnen weiter unten den Code für die Methode SigmoidFunction. Die Methode ComputeOutputs wird folgendermaßen fortgesetzt:

for (int j = 0; j < numOutput; ++j)
  for (int i = 0; i < numHidden; ++i)
    hoSums[j] += ihOutputs[i] * hoWeights[i][j];
for (int i = 0; i < numOutput; ++i)
  hoSums[i] += hoBiases[i];
...

Ich verwende die gerade berechneten Werte in ihOutputs und die Gewichtungen in hoWeights, um die Werte für hoSums zu berechnen. Anschließend füge ich die entsprechenden Verdeckt-zu-Ausgabe-Biaswerte hinzu. Ich rufe erneut die Methode Helpers.ShowVector auf, um die in Abbildung 2 gezeigte Ausgabe zu generieren. Die Methode ComputeOutputs wird folgendermaßen abgeschlossen:

for (int i = 0; i < numOutput; ++i)
    this.outputs[i] = HyperTanFunction(hoSums[i]);
  double[] result = new double[numOutput];
  this.outputs.CopyTo(result, 0);
  return result;
}

Ich wende die Methode HyperTanFunction auf hoSums an, um die endgültigen Ausgaben zu privaten Arrayelementausgaben der Klasse zu generieren. Ich kopiere diese Ausgaben in ein lokales Ergebnisarray und verwende dieses Array als Rückgabewert. Ein alternativer Entwurfsansatz bestünde in der Implementierung von ComputeOutputs ohne einen Rückgabewert. Stattdessen würde die öffentliche Methode GetOutputs implementiert werden, sodass die Ausgaben des Objekts des neuronalen Netzwerks abgerufen werden können.

Die Aktivierungsfunktionen und Hilfsmethoden

Im Folgenden zeige ich Ihnen den Code für die Sigmoidfunktion, die für die Berechnung der Eingabe-zu-Verdeckt-Ausgaben verwendet wird:

private static double SigmoidFunction(double x)
{
  if (x < -45.0) return 0.0;
  else if (x > 45.0) return 1.0;
  else return 1.0 / (1.0 + Math.Exp(-x));
}

Da einige Implementierungen der Funktion Math.Exp zu einem arithmetischen Overflow führen können, wird in der Regel der Wert des Eingabeparameters überprüft. Der Code für die tanh-Funktion, die für die Berechnung der Verdeckt-zu-Ausgabe-Ergebnisse verwendet wird, sieht folgendermaßen aus:

private static double HyperTanFunction(double x)
{
  if (x < -10.0) return -1.0;
  else if (x > 10.0) return 1.0;
  else return Math.Tanh(x);
}

Die hyperbolische Tangensfunktion gibt Werte zwischen -1 und +1 zurück. Daher stellt der arithmetische Overflow kein Problem dar. In diesem Fall wird der Eingabewert lediglich überprüft, um die Leistung zu verbessern.

Die statischen Hilfsmethoden in der Klasse Helpers erleichtern lediglich die Kodierung. Die Methode MakeMatrix, die für die Zuteilung von Matrizen im NeuralNetwork-Konstruktor verwendet wird, teilt jede Zeile einer Matrix als Implementierung eines Arrays von Arrays zu:

public static double[][] MakeMatrix(int rows, int cols)
{
  double[][] result = new double[rows][];
  for (int i = 0; i < rows; ++i)
    result[i] = new double[cols];
  return result;
}

Die Methoden ShowVector und ShowMatrix zeigen der Konsole die Werte in einem Array oder einer Matrix an. Sie finden den Code für diese beiden Methoden im Codedownload für diesen Artikel (unter archive.msdn.microsoft.com/mag201205TestRun).

Nächste Schritte

Der hier vorgestellte Code sollte Ihnen eine solide Basis bieten, um neuronale Netzwerke zu verstehen und mit diesen zu experimentieren. Sie sollten die Auswirkungen mittels verschiedener Aktivierungsfunktionen und einer unterschiedlichen Anzahl von Eingaben, Ausgaben und Neuronen in der verdeckten Schicht untersuchen. Sie können das neuronale Netzwerk modifzieren, indem Sie die Neuronen nur teilweise miteinander verbinden, sodass es für einige Neuronen keine logische Verbindung mit den Neuronen der nächsten Schicht gibt. Das in diesem Artikel behandelte neuronale Netzwerk hat nur eine verdeckte Schicht. Sie können komplexere neuronale Netzwerke erstellen, die über zwei oder mehr verdeckte Schichten verfügen. Um ein solches neuronales Netzwerk zu implementieren, müssen Sie den hier vorgestellten Code erweitern.

Neuronale Netzwerke können zur Lösung zahlreicher praktischer Probleme verwendet werden, darunter Klassifizierungsproblemen. Die Lösung solcher Probleme bietet mehrere Herausforderungen. Sie müssen beispielsweise wissen, wie nicht numerische Daten codiert werden und wie ein neuronales Netzwerk trainiert wird, um den besten Satz von Gewichtungen und Biasen zu ermitteln. In einem zukünftigen Artikel werde ich ein Beispiel für die Verwendung neuronaler Netzwerke für die Lösung von Klassifizierungsaufgaben zeigen.

Dr. James McCaffrey arbeitet bei Volt Information Sciences Inc. Er leitet technische Schulungen für Softwareentwickler, die auf dem Campus von Microsoft in Redmond, USA arbeiten. Er hat an verschiedenen Microsoft-Produkten mitgearbeitet, unter anderem an Internet Explorer und MSN Search. Dr. McCaffrey ist der Autor von ".NET Test Automation Recipes" (Rezepte für die .NET-Testautomatisierung, Apress 2006) und kann unter jammc@microsoft.com erreicht werden.

Unser Dank gilt den folgenden technischen Experten von Microsoft für die Durchsicht dieses Artikels: Dan Liebling und Anne Loomis Thompson