Freigeben über



April 2018

Band 33, Nummer 4

Test Run: Informationen zum Verständnis von LSTM-Zellen mit C#

Von James McCaffrey

James McCaffreyEine LSTM-Zelle (Long Short-Term Memory, langes Kurzzeitgedächtnis) ist eine kleine Softwarekomponente, mit der sich ein rekurrentes neuronales Netz erstellen lässt, das Vorhersagen über Datensequenzen treffen kann. LSTM-Netzwerke haben in verschiedenen Bereichen von Machine Learning zu entscheidenden Durchbrüchen geführt. In diesem Artikel zeige ich, wie eine LSTM-Zelle mit C# implementiert wird. Obwohl es unwahrscheinlich ist, dass Sie jemals ein rekurrentes neuronales Netz von Grund auf neu erstellen müssen, ist es hilfreich, genau zu verstehen, wie LSTM-Zellen funktionieren, wenn Sie jemals ein LSTM-Netzwerk mit einer Codebibliothek wie Microsoft CNTK oder Google TensorFlow erstellen müssen.

Der Screenshot in Abbildung 1 zeigt, worauf es in diesem Artikel hinausläuft. Das Demoprogramm erstellt eine LSTM-Zelle, die einen Eingabevektor der Größe n = 2 akzeptiert und einen expliziten Ausgabevektor der Größe m = 3 sowie einen Zellzustandsvektor der Größe m = 3 generiert. Ein wesentliches Merkmal von LSTM-Zellen ist, dass sie einen Zustand verwalten.

Demo der Eingabe/Ausgabe einer LSTM-Zelle 
Abbildung 1: Demo der Eingabe/Ausgabe einer LSTM-Zelle

Eine LSTM-Zelle weist (4 * n * m) + (4 * m * m) Gewichtungen und (4 * m) Bias auf. Gewichtungen und Bias sind nur Konstanten (mit Werten wie 0,1234), die das Verhalten der LSTM-Zelle definieren. Die Demo verwendet 60 Gewichtungen und 12 Bias, die auf beliebige Werte festgelegt sind.

Die Demo sendet eine Eingabe (1,0, 2,0) an die LSTM-Zelle. Die berechnete Ausgabe ist (0,0629, 0,0878, 0,1143) und der neue Zellzustand (0,1143, 0,1554, 0,1973). Ich werde gleich erläutern, für was diese Zahlenwerte stehen könnten, aber zunächst geht es darum, dass eine LSTM-Zelle eine Eingabe akzeptiert, eine Ausgabe generiert und ihren Zustand aktualisiert. Die Werte der Gewichtungen und Bias haben sich nicht verändert. 

Als nächstes gibt die Demo die Werte (3,0, 4,0) in die LSTM-Zelle ein. Die neue berechnete Ausgabe ist (0,1282, 0,2066, 0,2883). Der neue Zustand der Zelle ist (0,2278, 0,3523, 0,4789). Obwohl es nicht offensichtlich ist, enthält der neue Zellzustandswert Informationen zu allen vorherigen Ein- und Ausgabewerten. Dies ist der „lange“ Teil des „langen Kurzzeitgedächtnisses“.

Dieser Artikel geht davon aus, dass Sie über mittlere bis fortgeschrittene Programmierkenntnisse verfügen, er setzt aber nicht voraus, dass Sie mit LSTM-Zellen vertraut sind. Die Demo wurde mit C# codiert, Sie sollten den Code jedoch leicht in eine andere Sprache (z.B. Python oder Visual Basic) umgestalten können. Der Democode ist zu lang, um ihn im Ganzen darzustellen. Der vollständige Code der Demo ist jedoch im begleitenden Dateidownload enthalten.

Informationen zum Verständnis von LSTM-Zellen

Es gibt drei Möglichkeiten, LSTM-Zellen zu beschreiben: Verwenden eines Architekturdiagramms wie in Abbildung 2 gezeigt, Verwenden mathematischer Gleichungen wie in Abbildung 3 gezeigt und Verwenden von Code wie in diesem Artikel geschehen. Ihr erster Eindruck des Architekturdiagramms in Abbildung 2 ist wahrscheinlich so etwas wie „Was zum Teufel ist das?“. Die wichtigsten Konzepte bestehen darin, dass eine LSTM-Zelle einen Eingangsvektor x(t) akzeptiert, wobei „t“ eine Zeitangabe darstellt. Die explizite Ausgabe ist h(t). Die ungewöhnliche Verwendung von „h“ (anstelle von „o“) zur Darstellung der Ausgabe ist historisch und ergibt sich aus der Tatsache, dass neuronale Systeme oft als Funktionen „g“ und „h“ beschrieben wurden.

Die LSTM-Zelle verwendet auch h(t-1) und c(t-1) als Eingaben. Hier bedeutet t-1 den Wert aus dem vorherigen Zeitschritt. „c“ stellt den Zellzustand dar, sodass h(t-1) und c(t-1) die vorherigen Ausgabewerte und die vorherigen Zustandswerte sind. Das Innenleben der LSTM-Zellarchitektur sieht kompliziert aus. Und das ist es, aber nicht so kompliziert, wie Sie vielleicht vermuten.

Die mathematischen Gleichungen in Abbildung 3 definieren das Verhalten des Demoprogramms für die LSTM-Zelle. Wenn Sie nicht regelmäßig mit mathematischen Definitionen arbeiten, ist Ihre Reaktion auf Abbildung 3 wahrscheinlich schon wieder „Was zum Teufel ist das?“. Die Gleichungen (1), (2) und (3) definieren drei Tore: ein Vergessenstor, ein Eingabetor und ein Ausgabetor. Jedes Tor ist ein Vektor von Werten zwischen 0,0 und 1,0, die verwendet werden, um zu bestimmen, wie viele Informationen in jedem Eingabe-/Ausgabezyklus zu vergessen (oder äquivalent zu merken) sind. Gleichung (4) berechnet den neuen Zellzustand und Gleichung (5) die neue Ausgabe.

Architektur der LSTM-Zelle
Abbildung 2: Architektur der LSTM-Zelle

Mathematische Gleichungen für die LSTM-Zelle
Abbildung 3: Mathematische Gleichungen für die LSTM-Zelle

Diese Gleichungen sind einfacher, als sie erscheinen. Beispielsweise wird die Gleichung (1) durch den Democode wie folgt implementiert:

float[][] ft = MatSig(MatSum(MatProd(Wf, xt),
                 MatProd(Uf, h_prev), bf));

Hier ist MatSig eine vom Programm definierte Funktion, die die logistische Sigmoidfunktion auf jeden Wert in einer Matrize anwendet. MatSum fügt drei Matrizen hinzu. MatProd multipliziert zwei Matrizen. Sobald Sie die grundlegenden Matrizen- und Vektoroperationen verstanden haben, ist die Implementierung einer LSTM-Zelle recht einfach.

Gesamtstruktur des Demoprogramms

Die Struktur des Demoprogramms (mit ein paar kleinen Änderungen, um Platz zu sparen) ist in Abbildung 4 dargestellt. Die Demo verwendet einen statischen Methodenansatz statt eines OOP-Ansatzes, um die Hauptideen so klar wie möglich darzustellen.

Abbildung 4: Struktur des Demoprogramms

using System;
namespace LSTM_IO
{
  class LSTM_IO_Program
  {
    static void Main(string[] args)
    {
      Console.WriteLine("Begin LSTM IO demo");
      // Set up inputs
      // Set up weights and biases
      float[][] ht, ct;  // Outputs, new state
      float[][][] result;
      result = ComputeOutputs(xt, h_prev, c_prev,
        Wf, Wi, Wo, Wc, Uf, Ui, Uo, Uc, bf, bi, bo, bc);
      ht = result[0];  // Outputs
      ct = result[1];  // New state
      Console.WriteLine("Output is:");
      MatPrint(ht, 4, true);
      Console.WriteLine("New cell state is:");
      MatPrint(ct, 4, true);
      // Set up new inputs
      // Call ComputeOutputs again
      Console.WriteLine("End LSTM demo");
    }
    static float[][][] ComputeOutputs(float[][] xt,
      float[][] h_prev, float[][] c_prev,
      float[][] Wf, float[][] Wi, float[][] Wo, float[][] Wc,
      float[][] Uf, float[][] Ui, float[][] Uo, float[][] Uc,
      float[][] bf, float[][] bi, float[][] bo, float[][] bc)
    { . . }
    // Helper matrix functions defined here
  }
}

Um das Demoprogramm zu erstellen, habe ich Visual Studio gestartet und eine neue C#-Konsolenanwendung mit dem Namen „LSTM_IO“ erstellt. Ich habe Visual Studio 2015 verwendet. Das Demoprogramm weist aber keine nennenswerten .NET Framework-Abhängigkeiten auf, sodass jede Version von Visual Studio geeignet sein sollte.

Nachdem der Vorlagencode im Fenster „Projektmappen-Explorer“ geladen wurde, habe ich die Datei „Program.cs“ in „LSTM_IO_Program.cs“ umbenannt. Visual Studio hat daraufhin die Klasse „Program“ automatisch für mich umbenannt. Oben im Editor-Fenster habe ich alle nicht benötigten Verweise auf Namespaces mit Ausnahme des Verweises auf den Namespace „System“ der obersten Ebene gelöscht. Alle Aufgaben werden durch die Funktion ComputeOutputs ausgeführt.

Matrizen unter Verwendung von C#

Um eine LSTM-Zelle zu implementieren, müssen Sie über ein fundiertes Verständnis für die Arbeit mit C#-Matrizen verfügen. In C# ist eine Matrize ein Array aus Arrays. In Machine Learning ist es üblich, den 32-Bit-Typ float anstelle des 64-Bit-Typs byte double zu verwenden.

Die Demo definiert ein Hilfsprogramm zum Erstellen einer Matrize folgendermaßen:

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

Die erste Anweisung erstellt ein Array mit der angegebenen Anzahl von Zeilen, wobei jede Zeile ein Array vom Typ float ist. Die for-Schleifenanweisung weist jeder Zeile ein Array mit der angegebenen Anzahl von Spalten zu. Beachten Sie, dass C# im Gegensatz zu den meisten Programmiersprachen einen echten Matrizentyp unterstützt, aber die Verwendung eines Ansatzes mit einem Array aus Arrays ist viel häufiger.

In Machine Learning bezieht sich der Begriff Spaltenvektor (kurz Vektor) auf eine Matrize mit einer Spalte. Der meiste Machine Leaning-Code arbeitet mit Vektoren und nicht mit eindimensionalen Arrays. Die Demo definiert eine Funktion zum Generieren einer Matrize/eines Vektors aus einem eindimensionalen Array:

static float[][] MatFromArray(float[] arr, int rows, int cols)
{
  float[][] result = MatCreate(rows, cols);
  int k = 0;
  for (int i = 0; i < rows; ++i)
    for (int j = 0; j < cols; ++j)
      result[i][j] = arr[k++];
  return result;
}

Die Funktion kann folgendermaßen aufgerufen werden, um einen 3x1-Vektor (3 Zeilen, 1 Spalte) zu erstellen:

float[][] v = MatFromArray(new float[] {1.0f, 9.0f, 5.0f}, 3,1);

Die Demo definiert eine MatCopy-Funktion, die eine Matrize duplizieren kann. MatCopy kann aufgerufen werden:

float[][] B = MatCopy(A);

Hier ist „B“ eine neue, unabhängige Matrize mit den gleichen Werten wie „A“. Lassen Sie Vorsicht mit Code wie dem folgenden walten:

float[][] B = A;

Dies erstellt „B“ als Verweis auf „A“, d.h. jede Änderung an einer Matrize wirkt sich auf die andere aus. Dies ist vielleicht das Verhalten, das Sie wünschen, aber möglicherweise auch nicht.

Elementweise Matrizenvorgänge

Eine LSTM-Zellenimplementierung verwendet mehrere elementweise Funktionen für Matrizen, wobei jeder Wert in einer Matrize verwendet oder geändert wird. Beispielsweise wird die Funktion MatTanh wie folgt definiert:

static float[][] MatTanh(float[][] m)
{
  int rows = m.Length; int cols = m[0].Length;
  float[][] result = MatCreate(rows, cols);
  for (int i = 0; i < rows; ++i) // Each row
    for (int j = 0; j < cols; ++j) // Each col
      result[i][j] = Tanh(m[i][j]);
  return result;
}

Die Funktion durchläuft ihre Eingabematrize „m“ und wendet den hyperbolischen Tangens (tanh) auf jeden Wert an. Die Hilfsfunktion Tanh ist wie folgt definiert:

static float Tanh(float x)
{
  if (x < -10.0) return -1.0f;
  else if (x > 10.0) return 1.0f;
  return (float)(Math.Tanh(x));
}

Die Demo definiert auch eine MatSigmoid-Funktion, die genau wie MatTanh ist, mit der Ausnahme, dass die logistische Sigmoidfunktion auf jeden Wert angewendet wird. Die logistische Sigmoidfunktion ist eng mit tanh verwandt und gibt einen Wert zwischen 0,0 und 1,0 statt zwischen -1,0 und +1,0 zurück.

Die Demo definiert eine Funktion MatSum, die die Werte in zwei Matrizen der gleichen Form addiert. Wenn Sie sich die mathematische Gleichung (1) in Abbildung 3 ansehen, erkennen Sie, dass ein LSTM drei Matrizen hinzufügt. Die Demo überlädt MatSum, um mit zwei oder drei Matrizen zu arbeiten.

Die Funktion MatHada multipliziert entsprechende Werte in zwei Matrizen, die die gleiche Form aufweisen:

static float[][] MatHada(float[][] a, float[][] b)
{
  int rows = a.Length; int cols = a[0].Length;
  float[][] result = MatCreate(rows, cols);
  for (int i = 0; i < rows; ++i)
    for (int j = 0; j < cols; ++j)
      result[i][j] = a[i][j] * b[i][j];
  return result;
}

Die elementweise Multiplikation wird manchmal als Hadamard-Funktion bezeichnet. In den mathematischen Gleichungen (4) und (5) in Abbildung 3 wird die Hadamard-Funktion durch das kleine Kreissymbol angegeben. Verwechseln Sie die elementweise Hadamard-Funktion nicht mit der Matrizenmultiplikation, die eine ganz andere Funktion ist.

Matrizenmultiplikation

Wenn Sie noch nie eine Matrizenmultiplikation gesehen haben, ist der Vorgang gar nicht offensichtlich. Angenommen, „A“ ist eine 3x2-Matrize:

1.0, 2.0
3.0, 4.0
5.0, 6.0

Nehmen Sie außerdem an, dass „B“ eine 2x4-Matrize ist:

10.0, 11.0, 12.0, 13.0
14.0, 15.0, 16.0, 17.0

Das Ergebnis von C = AB (Multiplikation von A und B) ist eine 3x4-Matrize:

38.0  41.0  44.0  47.0
 86.0  93.0 100.0 107.0
134.0 145.0 156.0 167.0

Die Demo implementiert die Matrizenmultiplikation als Funktion MatProd. Beachten Sie, dass Sie bei der Verwendung von C# für sehr große Matrizen die Anweisung Parallel.for in der Task „Parallel Library“ verwenden können.

Zusammenfassend lässt sich sagen, dass die Implementierung einer LSTM-Zelle mit C# mehrere Hilfsfunktionen erfordert, die Matrizen (Arrays aus Arrays) und Vektoren (Matrizen mit einer Spalte) erstellen und verarbeiten. Der Democode definiert die Funktionen MatCreate, MatFromArray, MatCopy(m), MatSig(m), MatTanh(m), MatHada(a, b), MatSum(a, b), MatSum(a, b, c) und MatProd(a, b). Obwohl für die Erstellung einer LSTM-Zelle nicht unbedingt erforderlich, ist es sinnvoll, über eine Funktion zur Anzeige einer C#-Matrize zu verfügen. Die Demo definiert eine MatPrint-Funktion.

Implementieren und Aufrufen der LSTM-Eingabe/-Ausgabe

Der Code für die Funktion ComputeOutputs ist in Abbildung 5 dargestellt. Die Funktion besitzt 15 Parameter, von denen 12 aber im Wesentlichen gleich sind.

Abbildung 5: Funktion ComputeOutputs

static float[][][] ComputeOutputs(float[][] xt,
  float[][] h_prev, float[][] c_prev,
  float[][] Wf, float[][] Wi, float[][] Wo, float[][] Wc,
  float[][] Uf, float[][] Ui, float[][] Uo, float[][] Uc,
  float[][] bf, float[][] bi, float[][] bo, float[][] bc)
{
  float[][] ft = MatSig(MatSum(MatProd(Wf, xt),
    MatProd(Uf, h_prev), bf));
  float[][] it = MatSig(MatSum(MatProd(Wi, xt),
    MatProd(Ui, h_prev), bi));
  float[][] ot = MatSig(MatSum(MatProd(Wo, xt),
    MatProd(Uo, h_prev), bo));
  float[][] ct = MatSum(MatHada(ft, c_prev),
    MatHada(it, MatTanh(MatSum(MatProd(Wc, xt),
      MatProd(Uc, h_prev), bc))));
  float[][] ht = MatHada(ot, MatTanh(ct));
  float[][][] result = new float[2][][];
  result[0] = MatCopy(ht);
  result[1] = MatCopy(ct);
  return result;
}

Der Vektor xt ist die Eingabe, z.B. (1,0, 2,0). Die Vektoren h_prev und c_prev sind der vorherige Ausgabevektor und der vorherige Zellzustand. Die vier W-Matrizen sind die den Eingangswerten zugeordneten Torgewichte, wobei „f“ ein Vergessenstor, „i“ ein Eingabetor und „o“ ein Ausgabetor ist. Die vier U-Matrizen sind die der Zellenausgabe zugeordneten Gewichtungen. Die vier b-Vektoren sind Bias. 

Im Textkörper der Funktion sind die ersten fünf Anweisungen eine 1:1-Zuordnung zu den fünf mathematischen Gleichungen in Abbildung 3. Beachten Sie, dass die ft-, it- und ot-Tore alle die MatSig-Funktion verwenden. Daher weisen alle drei Vektoren Werte zwischen 0,0 und 1,0 auf. Sie können sich diese als Filter vorstellen, die auf die Eingabe, die Ausgabe oder den Zustand angewendet werden, wobei der Wert im Tor der Prozentsatz ist, der beibehalten wird. Wenn beispielsweise einer der Werte in ft 0,75 ist, werden 75 Prozent des entsprechenden Werts im kombinierten Ein- und vorherigen Ausgabevektor beibehalten. Oder gleichwertig: 25 Prozent der Informationen werden vergessen.

Die Berechnung des neuen Zellzustands (ct) ist einfach zu implementieren, aber konzeptionell recht komplex. Auf einer hohen Ebene hängt der neue Zellzustand von einer durch das Tor gesteuerten Kombination aus dem Eingabevektor xt und dem vorherigen Ausgabevektor und Zellzustand h_prev und c_prev ab. Die neue Ausgabe (ht) ist abhängig vom neuen Zellzustand und dem Ausgabetor. Wirklich bemerkenswert.

Die Funktion gibt die neue Ausgabe und den neuen Zellzustand in einem Array zurück. Dies führt zu einem Rückgabetyp von float[][][], wobei result[0] eine Matrize mit einem Array aus Arrays ist, die die Ausgabe enthält, und result[1] enthält den neuen Zellzustand.

Der Aufruf von ComputeOutputs ist hauptsächlich eine Frage der Einrichtung der Parameterwerte. Die Demo beginnt die Vorbereitung folgendermaßen:

float[][] xt = MatFromArray(new float[] {
  1.0f, 2.0f }, 2, 1);
float[][] h_prev = MatFromArray(new float[] {
  0.0f, 0.0f, 0.0f }, 3, 1);
float[][] c_prev = MatFromArray(new float[] {
  0.0f, 0.0f, 0.0f }, 3, 1);

Sowohl die vorherige Ausgabe als auch der Zellzustand werden explizit mit Null initialisiert. Als nächstes werden zwei Sätze beliebiger Gewichtungswerte erstellt:

float[][] W = MatFromArray(new float[] {
  0.01f, 0.02f,
  0.03f, 0.04f,
  0.05f, 0.06f }, 3, 2);
float[][] U = MatFromArray(new float[] {
  0.07f, 0.08f, 0.09f,
  0.10f, 0.11f, 0.12f,
  0.13f, 0.14f, 0.15f }, 3, 3);

Beachten Sie, dass die beiden Matrizen unterschiedliche Formen aufweisen. Die Gewichtungswerte werden in die Eingabeparameter kopiert:

float[][] Wf = MatCopy(W); float[][] Wi = MatCopy(W);
float[][] Wo = MatCopy(W); float[][] Wc = MatCopy(W);
float[][] Uf = MatCopy(U); float[][] Ui = MatCopy(U);
float[][] Uo = MatCopy(U); float[][] Uc = MatCopy(U);

Da sich die Gewichtungen nicht ändern, könnte die Demo auch durch Verweis statt mit MatCopy zugewiesen werden. Die Bias werden nach dem gleichen Muster eingerichtet:

float[][] b = MatFromArray(new float[] {
  0.16f, 0.17f, 0.18f }, 3, 1);
float[][] bf = MatCopy(b); float[][] bi = MatCopy(b);
float[][] bo = MatCopy(b); float[][] bc = MatCopy(b);

Die Funktion ComputeOutputs wird folgendermaßen aufgerufen:

float[][] ht, ct;
float[][][] result;
result = ComputeOutputs(xt, h_prev, c_prev,
  Wf, Wi, Wo, Wc, Uf, Ui, Uo, Uc,
  bf, bi, bo, bc);
ht = result[0];  // Output
ct = result[1];  // New cell state

Der ganze Sinn einer LSTM-Zelle besteht darin, eine Folge von Eingabevektoren einzulesen. Die Demo richtet daher einen zweiten Eingabevektor ein und sendet ihn:

h_prev = MatCopy(ht);
c_prev = MatCopy(ct);
xt = MatFromArray(new float[] {
  3.0f, 4.0f }, 2, 1);
result = ComputeOutputs(xt, h_prev, c_prev,
  Wf, Wi, Wo, Wc, Uf, Ui, Uo, Uc,
  bf, bi, bo, bc);
ht = result[0];
ct = result[1];

Beachten Sie, dass die Demo explizit die vorherigen Ausgabe- und Zustandsvektoren an ComputeOutputs sendet. Eine Alternative ist, nur den neuen Eingabevektor einzulesen, da der bisherige Ausgabe- und Zellzustand noch in ht und ct gespeichert ist.

Verbinden der einzelnen Aspekte

Also, was soll das Ganze? Eine LSTM-Zelle kann verwendet werden, um ein rekurrentes neuronales LSTM-Netzwerk zu erstellen: eine LSTM-Zelle mit einigen zusätzlichen Installationsarbeiten. Diese Netzwerke sind verantwortlich für große Fortschritte bei Prognosesystemen, die mit Sequenzdaten arbeiten. Angenommen, Sie würden gebeten, das nächste Wort im folgenden Satz vorherzusagen: „2017 wurde die Meisterschaft gewonnen von __“. Mit dieser Information wäre es schwer, eine Vorhersage zu treffen. Aber nehmen wir an, Ihr System würde einen Zustand verwalten und sich daran erinnern, dass dies ein Teil eines früheren Satzes war: „Die NBA hat seit 1947 die Meisterschaftsspiele ausgetragen“. Sie wären jetzt in der Lage, eines der 30 NBA-Teams vorherzusagen.

Es gibt Dutzende von Varianten von LSTM-Architekturen. Da LSTM-Zellen zudem komplex sind, gibt es Dutzende von Implementierungsvarianten für jede Architektur. Aber wenn Sie den grundlegenden LSTM-Zellmechanismus verstehen, können Sie die Varianten leicht verstehen.

Das Demoprogramm legt die LSTM-Gewichtungen und -Bias auf beliebige Werte fest. Die Gewichtungen und Bias für ein reales LSTM-Netzwerk werden durch Training des Netzwerks bestimmt. Sie würden in diesem Fall einen Satz Trainingsdaten mit bekannten Eingabewerten und bekannten, richtigen Ausgabewerten abrufen. Dann würden Sie einen Algorithmus wie Zurückverteilung verwenden, um Werte für die Gewichtungen und Bias zu finden, die den Fehler zwischen berechneten Ausgaben und richtigen Ausgaben minimieren.


Dr. James McCaffreyist in Redmond (Washington) für Microsoft Research tätig. Er hat an verschiedenen Microsoft-Produkten mitgearbeitet, unter anderem an Internet Explorer und Bing. Dr. McCaffrey erreichen Sie unter jamccaff@microsoft.com.

Unser Dank gilt den folgenden technischen Experten von Microsoft für die Durchsicht dieses Artikels: Ricky Loynd und Adith Swaminathan


Diesen Artikel im MSDN Magazine-Forum diskutieren