Handschrifterkennung mit neuronalen Netzen

Neuronale Netze – Teil 3
Kommentare

Nachdem wir uns in den ersten beiden Teilen dieser Artikelserie mit den Grundlagen und der Theorie hinter neuronalen Netzen vertraut gemacht haben, versuchen wir uns zum Abschluss an einem konkreten Projekt. Anhand einer Windows-Phone-Anwendung wagen wir uns in die Domäne der optischen Zeichenerkennung vor und verwenden neuronale Netze, um eine einfache Handschrifterkennung zu implementieren.

Handschriftenerkennung oder optische Zeichenerkennung (abgekürzt OCR von „Optical Character Recognition“) ist eine Thematik, die oft im gleichen Atemzug mit neuronalen Netzen genannt wird. Aus diesem Grund wagen auch wir uns an diese – alles andere als triviale –Thematik heran und entwickeln eine Anwendung, die zumindest die Ziffern von 0 bis 9 erkennen soll. Eine Windows-Phone-App bietet sich hierfür geradezu an, da der Touchscreen eines Smartphones ein ideales Eingabemedium für Handschriftenerkennung darstellt.

Überblick Artikelserie

Handschriftenerkennung oder optische Zeichenerkennung (abgekürzt OCR von „Optical Character Recognition“) ist eine Thematik, die oft im gleichen Atemzug mit neuronalen Netzen genannt wird. Aus diesem Grund wagen auch wir uns an diese – alles andere als triviale –Thematik heran und entwickeln eine Anwendung, die zumindest die Ziffern von 0 bis 9 erkennen soll. Eine Windows-Phone-App bietet sich hierfür geradezu an, da der Touchscreen eines Smartphones ein ideales Eingabemedium für Handschriftenerkennung darstellt.

Bei dieser App mit dem Titel „Simple OCR“ handelt es sich um eine Windows-Phone-Runtime-Anwendung und als Applikationsframework dient das Newport-Framework, das in der Windows-Developer-Ausgabe 4.2015 vorgestellt wurde. Der vollständige Quellcode der Applikation befindet sich außerdem im GitHub Repository SimpleOcr.

Von außen betrachtet …

Abbildung 1 zeigt den grundsätzlichen Aufbau der Anwendung. Das zentrale Element ist dabei das 10-x-10 Raster, das die Eingabefläche für die Handschriftenerkennung repräsentiert. Per Touchinteraktion (oder klassisch per Maus im Emulator) lassen sich die Ziffern, die später vom neuronalen Netz erkannt werden sollen, eingeben (Abb. 2).

Die beiden Radio-Buttons im oberen Teil der Anwendung mit den Bezeichnern Training und Live spiegeln die beiden Modi der Anwendung wider.

  • Im Modus Training kann aus der Combobox, die sich zwischen den Radio-Buttons und dem Raster befindet, eine Zahl zwischen 0 und 9 ausgewählt werden. Diese Zahl sollte nur per Handschrift auf das Raster gezeichnet werden. Ist man mit dem Resultat zufrieden, kann mit der OK-Schaltfläche der Datensatz aus Eingabemuster und ausgewählter Zahl übernommen werden. Die Schaltfläche Reset löscht das Raster vollständig und die Eingabe der Ziffer kann wiederholt werden.
  • Der Modus Live versucht mithilfe der zuvor angelegten Trainingsdatensätze eine Eingabe auf dem Raster als Ziffer zu erkennen. Dieser Vorgang wird ebenfalls mit der OK-Schaltfläche gestartet, und die erkannte Ziffer wird in der Combobox angezeigt. Die Reset-Schaltfläche wiederum kann auch in diesem Modus zum Löschen der Eingabe verwendet werden.

Der vollständige XAML-Code zur Erzeugung dieser Benutzerschnittstelle wird hier aus Platzgründen nicht abgedruckt, es sei aber noch einmal auf das GitHub Repository verwiesen.

Abb. 1: Die Windows-Phone-App „Simple OCR“

Abb. 1: Die Windows-Phone-App „Simple OCR“

Abb. 2: Eingabe von Ziffern auf dem Raster

Abb. 2: Eingabe von Ziffern auf dem Raster

Blick unter die Haube

Nachdem uns die Benutzerschnittstelle der App nun vor keine größeren Rätsel mehr stellen sollte, widmen wir uns jetzt dem eigentlich spannenden Teil und werfen einen Blick auf die Implementierung.

Für das neuronale Netz verwenden wir, wie in den letzten Teilen dieser Artikelserie das NuGet-Paket DotNeuralNet und im Speziellen dessen Klasse Network. Listing 1 zeigt wie diese Klasse in unsere Beispielanwendung initialisiert wird. Am Konstruktor von Network ist dabei gut zu erkennen, dass es sich hier um ein neuronales Netz mit hundert Eingabeknoten, sechzig Knoten der versteckten Schicht und zehn Ausgabeknoten handelt. Die Zahl der Eingabeknoten ergibt sich dabei aus den Dimensionen des 10 mal 10 Feldern großen Eingaberasters. Jede Zelle dieses Rasters repräsentiert somit einen Eingabewert in unser neuronales Netz. Eine „gefüllte“ Zelle repräsentiert den Wert 1; ansonsten liegt eine Eingabe mit dem Wert 0 vor. Auch die Anzahl der Ausgabeknoten ergibt sich unmittelbar aus der Problemstellung: Da wir die Ziffern von 0 bis 9 klassifizieren wollen, wird jeder dieser Ziffern ein Ausgabeknoten zugeordnet. Eine Ausgabe von 1 entspricht einer erkannten Ziffer, ansonsten sollte das Netz 0 liefern. Am interessantesten ist wohl die Anzahl der Knoten in der versteckten Schicht, da sich diese nicht direkt aus der Problemstellung ableiten lassen. Hier kann man nur auf empirische Erfahrungen zurückgreifen, die einen Wert zwischen der Anzahl der Eingabeknoten und der Anzahl der Ausgabeknoten nahelegen. Hierbei handelt es sich aber definitiv um eine „Stellschraube“, mit der man experimentieren kann, sofern man mit den gelieferten Ergebnissen nicht zufrieden ist.

Der zweite wesentliche Schritt bei der Initialisierung des neuronalen Netzes ist das Setzen seiner Gewichtungen, da sich in eben diesen Gewichtungen die gesamte „Erfahrung“ des Netzes befindet, die mit jedem Lernvorgang angepasst wird. Aus diesem Grund müssen diese Daten auch persistiert werden, was mit der Klasse IsolatedStorageHelper des Newport-Frameworks passiert. Konkret werden die Gewichtungen in der Datei weights.xml im Isolated Storage abgespeichert, was mit Werkzeugen wie den Windows Phone Power Tools nachvollzogen werden kann (Abb. 3).

private async void InitNeuralNetwork()
{
  _network = new Network(100, 60, 10);
  var weights = await IsolatedStorageHelper.Load<double[]>("weights.xml");
  if (weights != null)
  {
    _network.SetWeights(weights);
  }
}
Abb. 3: Mit Windows Phone Power Tools auf den Isolated Storage zugreifen

Abb. 3: Mit Windows Phone Power Tools auf den Isolated Storage zugreifen

Training ist alles!

Die entscheidende Frage ist nun, wie man zu den Werten für die Gewichtungen gelangt, nachdem diese das zentrale Element unseres neuronalen Netzes sind. Um das zu beantworten, müssen wir uns die Funktionsweise der Anwendung im Modus Training näher ansehen. Listing 2 zeigt den relevanten Teil des Codes, der ausgeführt wird, wenn die Schaltfläche OK im Modus Training betätigt wird. Zuerst wird die statische Methode Create der Klasse BackPropagationTrainingRow (Listing 3) aufgerufen, um eine Instanz eben dieser Klasse zu erhalten. Diese Klasse repräsentiert einen Trainingsdatensatz für das neuronale Netz und die Methode Create sorgt dafür, dass dessen Array-Eigenschaften Inputs und Outputs die korrekten Dimensionen besitzen. Anschließend wird über die Collection Cells iteriert (dabei handelt es sich um die ViewModel-Repräsentation der Zellen aus dem Raster der Benutzerschnittstelle) und falls eine Zelle gesetzt ist, enthält der entsprechende Input-Wert des BackPropagationTrainingRow-Datensatzes den Wert 1. Im nächsten Schritt werden auch die erwarteten Ausgabewerte (Outputs) aufgrund der ausgewählten Ziffer in der Combobox (Eigenschaft SelectedNumber) gesetzt. Der somit neu erzeugte Trainingsdatensatz wird abschließend der Liste trainingRows hinzugefügt.

Bis zu diesen Punkt hat das eigentliche Training und somit die Anpassung der Gewichtungen des neuronalen Netzes noch nicht stattgefunden. Erst beim Wechsel vom Modus Training in den Modus Live wird die Methode Train aus Listing 4 ausgeführt, um mithilfe des Backpropagation-Algorithmus das neuronale Netz zu trainieren. Die Verwendung des Schlüsselworts async und einer TaskFactory lässt bereits erahnen, dass es sich hierbei um eine aufwändige und potenziell lang laufende Operation handelt. Um die Benutzerschnittstelle nicht vollständig einzufrieren, kommen die Klassen BusyScope und das ProgressSpinner Control aus dem Newport-Framework zum Einsatz, um einen „Wartekringel“ anzuzeigen.

Das eigentliche Training in Form des Backpropagation-Algorithmus nimmt anschließend die Klasse BackPropagationTrainer und deren Methode Train vor. Diese Methode erwartet als ersten Parameter eine Liste der zuvor erstellten BackPropagationTrainingRow-Objekte. Der zweite Parameter vom Typ double steuert die Anpassungsrate der Gewichtungen und der dritte Parameter die Anzahl der Durchläufe des Backpropagation-Algorithmus. Die Kombination dieser beiden Parameter bestimmt ganz entscheidend die Qualität des Trainingsvorgangs. Eine zu aggressive Anpassungsrate kann schnell über das gewünschte Ziel hinausschießen und eine zu große Durchlaufzahl die Laufzeit des Algorithmus durchaus in den Bereich mehrerer Minuten treiben. Hier gilt es zu experimentieren, um die richtigen Werte zu finden.

Abschließend werden die nun angepassten Gewichtungen mit der bereits bekannten Klasse IsolatedStorageHelper persistiert. Damit muss der aufwändige Trainingsprozess bei der nächsten Ausführung der Applikation nicht erneut durchgeführt werden, sondern das neuronale Netz kann direkt aus den abgespeicherten Gewichtungen initialisiert werden (Listing 1).

 var row = BackPropagationTrainingRow.Create(_network);
Cells.ForEach((i, c) => row.Inputs[i] = c.IsChecked ? 1 : 0);
for (var i = 0; i < 10; i++)
{
  row.Outputs[i] = (SelectedNumber == i) ? 1.0 : 0.0;
}
_trainingRows.Add(row);
 public class BackPropagationTrainingRow
{
  public BackPropagationTrainingRow(double[] inputs, double[] outputs)
  {
    Inputs = inputs;
    Outputs = outputs;
  }

  public double[] Inputs { get; private set; }

  public double[] Outputs { get; private set; }

  public static BackPropagationTrainingRow Create(Network network)
  {
    return new BackPropagationTrainingRow(
      new double[network.InputNodes.Count],
      new double[network.OutputNodes.Count]);
  }
}
private async void Train()
{
  if (_trainingRows.Count > 0)
  {
    using (BusyScope())
    {
      await Task.Factory.StartNew(() =>
      {
        var trainer = new BackPropagationTrainer(_network);
        trainer.Train(_trainingRows, 0.5, 50);
        IsolatedStorageHelper.Save("weights.xml", _network.GetWeights());
      });
    }
  }
}

Und Action!

Werfen wir zu guter Letzt einen Blick auf Listing 5. Dabei handelt es sich um den Code, der im Live-Modus zur Ausführung kommt, wenn die Schaltfläche OK betätigt wird. Ähnlich wie im Modus Training in Listing 2 wird auch hier die Collections Cells durchlaufen, um die gesetzten Zellen des Rasters zu ermitteln. Im Unterschied zum Trainingsmodus werden hier allerdings direkt die Werte der Eingabeschicht des neuronalen Netzes (_network.InputNodes) gesetzt. Anschließend wird jener Knoten der Ausgabeschicht ermittelt, dessen Wert ein Maximum aufweist. Dies ist nötig, da in der Praxis leider nie der Fall auftritt, dass ein Ausgabeknoten exakt den Wert 1 hat und die anderen den Wert 0 aufweisen. Zum Schluss wird die Eigenschaft SelectedNumber gesetzt, um in der Combobox die entsprechende Ziffer anzuzeigen, die das neuronale Netz ermittelt hat.

// Feed input data to neural network.
Cells.ForEach((i, c) => _network.InputNodes[i++].Value = c.IsChecked ? 1 : 0);
// Find node with highest activation.
var node = _network.OutputNodes.First(
  n => n.Value == _network.OutputNodes.Max(nn => nn.Value));
SelectedNumber = _network.OutputNodes.IndexOf(node);

Bewährungsprobe

Wer diese Anwendung nun einem konkreten Test unterzieht, wird feststellen, dass die gelieferten Ergebnisse durchaus akzeptabel sind, auch wenn es den einen oder anderen Ausreißer geben mag. Mit erstaunlich kleinem Aufwand haben wir also eine, zugegeben einfache Handschriftenerkennung implementiert, die durch die Lernfähigkeit neuronaler Netze zusätzlich mit jeder Benutzung potenziell noch zuverlässiger wird. Dieses kleine Beispiel lässt somit mehr als erahnen, welche Möglichkeiten in dieser Technologie stecken und liefert einen guten Grund, neuronale Netze in seine persönliche Werkzeugkiste aufzunehmen. Vielleicht bietet sich ja schon beim nächsten Projekt die Möglichkeit anstelle von klassischen Algorithmen auf neuronale Netze auszuweichen und damit für eine Überraschung zu sorgen.

Aber auch wenn die Anwendung im beruflichen Alltag noch auf sich warten lässt, kann hier durchaus der Weg das Ziel sein. Erst die Auseinandersetzung mit der Thematik neuronaler Netze anhand eines konkreten Beispiels fördert und vertieft das Verständnis dieser Thematik. Das Experimentieren mit den relevanten Parametern des Backpropagation-Algorithmus und das Ausprobieren neuer Kombinationen gibt uns Sicherheit und Erfahrung mit diesem neuen Werkzeug. Und wer weiß: Vielleicht gelingt der große Durchbruch schon mit der nächsten Kombination aus Anpassungsrate und Anzahl der Durchläufe? Das muss ich jetzt sofort ausprobieren …

Aufmacherbild: Neurons in the brain via Shutterstock.com / Urheberrecht: Leigh Prather

Unsere Redaktion empfiehlt:

Relevante Beiträge

Meinungen zu diesem Beitrag

X
- Gib Deinen Standort ein -
- or -