Erweiterter Leitfaden zu Inception v3 auf Cloud TPU

In diesem Dokument werden Aspekte des Inception-Modells und deren Zusammenführung erläutert, damit das Modell auf Cloud TPU effizient ausgeführt werden kann. Es ist eine erweiterte Ansicht des Leitfadens zum Ausführen von Inception v3 auf Cloud TPU. Spezifische Änderungen am Modell, die zu erheblichen Verbesserungen geführt haben, werden ausführlicher erörtert. Dieses Dokument ergänzt die Anleitung zu Inception v3.

Inception v3-TPU-Trainingsläufe kommen Genauigkeitskurven gleich, die durch GPU-Jobs mit ähnlicher Konfiguration erzeugt wurden. Das Modell wurde erfolgreich mit v2-8-, v2-128- und v2-512-Konfigurationen trainiert. Es hat eine Genauigkeit von mehr als 78,1 % in ungefähr 170 Epochen mit jeder dieser Konfigurationen erreicht.

Die in diesem Dokument gezeigten Codebeispiele haben illustrativen Charakter und sollen ein allgemeines Bild von den Abläufen bei der konkreten Umsetzung vermitteln. Funktionstüchtigen Code finden Sie auf GitHub.

Einführung

Inception v3 ist ein weitverbreitetes Bilderkennungsmodell, das nachweislich eine Genauigkeit von mehr als 78,1 % mit dem ImageNet-Dataset erreicht. Das Modell ist das Ergebnis zahlreicher Ideen, die im Laufe der Jahre von verschiedenen Wissenschaftlern entwickelt wurden. Es basiert auf dem ursprünglichen Artikel Rethinking the Inception Architecture for Computer Vision von Szegedy et al.

Das Modell selbst besteht aus symmetrischen und asymmetrischen Bausteinen, darunter Faltungen, Average-Pooling, Max-Pooling, Konkatenationen, Dropouts und vollständig verbundenen Schichten. Batchnormalisierung wird im gesamten Modell in großem Umfang eingesetzt und auf Aktivierungseingaben angewendet. Verlust wird über Softmax berechnet.

Eine schematische Darstellung des Modells sehen Sie unten:

Bild

Die README-Datei des Inception-Modells enthält weitere Informationen zur Inception-Architektur.

Estimator API

Die TPU-Version von Inception v3 wurde mit TPUEstimator geschrieben. Diese API soll Ihnen die Entwicklung erleichtern, sodass Sie sich auf die Modelle selbst konzentrieren können, anstatt auf die Details der zugrunde liegenden Hardware. Die API erledigt die meisten mühseligen Arbeitsschritte zur Ausführung von Modellen auf TPUs im Hintergrund und automatisiert gleichzeitig gängige Funktionen wie das Speichern und Wiederherstellen von Prüfpunkten.

Die Estimator API erzwingt die Trennung der Modell- und Eingabeabschnitte des Codes. Sie müssen die Funktionen model_fn und input_fn entsprechend der Modelldefinitions- und Eingabepipeline-/Vorverarbeitungsphasen der TensorFlow-Grafik definieren. Hier ein Beispiel-Snippet dieser Funktionen:

def model_fn(features, labels, mode, params):
     …
  return tpu_estimator.TPUEstimatorSpec(mode=mode, loss=loss, train_op=train_op)

def input_fn(params):
    def parser(serialized_example):
          …
        return image, label

          …
   images, labels = dataset.make_one_shot_iterator().get_next()
   return images, labels

Zwei Schlüsselfunktionen, die von der API bereitgestellt werden, sind train() und evaluate(), die zum Trainieren bzw. Evaluieren verwendet werden. Diese werden normalerweise an beliebiger Stelle in der Funktion "main" aufgerufen. Ein Beispiel dafür ist unten zu sehen:

def main(unused_argv):
  …
  run_config = tpu_config.RunConfig(
      master=FLAGS.master,
      model_dir=FLAGS.model_dir,
      session_config=tf.ConfigProto(
          allow_soft_placement=True, log_device_placement=True),
      tpu_config=tpu_config.TPUConfig(FLAGS.iterations, FLAGS.num_shards),)

  estimator = tpu_estimator.TPUEstimator(
      model_fn=model_fn,
      use_tpu=FLAGS.use_tpu,
      train_batch_size=FLAGS.batch_size,
      eval_batch_size=FLAGS.batch_size,
      config=run_config)

  estimator.train(input_fn=input_fn, max_steps=FLAGS.train_steps)

  eval_results = inception_classifier.evaluate(
      input_fn=imagenet_eval.input_fn, steps=eval_steps)

ImageNet-Dataset

Bevor Sie das Modell zum Erkennen von Bildern verwenden können, muss es trainiert werden. Dies erfolgt normalerweise im Rahmen einer überwachten Lernphase mithilfe eines großen Satzes von beschrifteten Bildern. Obwohl Inception v3 mit vielen verschiedenen Sätzen von beschrifteten Bildern trainiert werden kann, fällt die Wahl üblicherweise auf das ImageNet-Dataset.

ImageNet enthält über zehn Millionen URLs von beschrifteten Bildern. Ungefähr eine Million der Bilder haben außerdem Markierungsrahmen, in denen der Ort der beschrifteten Objekte genauer spezifiziert wird.

Für dieses Modell umfasst das ImageNet-Dataset 1.331.167 Bilder, die in Trainings- und Evaluations-Datasets mit 1.281.167 bzw. 50.000 Bildern unterteilt sind.

Die Trainings- und Evaluations-Datasets werden bewusst getrennt gehalten. Zum Trainieren des Modells werden nur Bilder aus dem Trainings-Dataset verwendet und zum Evaluieren der Modellgenauigkeit nur Bilder aus dem Evaluations-Dataset.

Das Modell erwartet, dass Bilder als TFRecords gespeichert sind. Verwenden Sie das Open-Source-Batchskript download_and_preprocess_imagenet.sh, um Bilder aus RAW-JPEG-Dateien in TFRecords umzuwandeln. Das Skript müsste eine Reihe von Dateien (für Training und Validierung) in folgender Form erzeugen:

${DATA_DIR}/train-00000-of-01024
${DATA_DIR}/train-00001-of-01024
 ...
${DATA_DIR}/train-01023-of-01024
and
${DATA_DIR}/validation-00000-of-00128
S{DATA_DIR}/validation-00001-of-00128
 ...
${DATA_DIR}/validation-00127-of-00128

Dabei ist DATA_DIR der Speicherort, an dem sich das Dataset befindet, beispielsweise DATA_DIR=$HOME/imagenet-data.

Der Abschnitt "Getting Started" der README-Datei des Inception-Modells enthält eine detaillierte Anleitung zum Erstellen und Ausführen dieses Skripts.

Eingabe-Pipeline

Jedes Cloud TPU-Gerät hat acht Kerne und ist mit einem Host (CPU) verbunden. Größere Segmente haben mehrere Hosts. Andere größere Konfigurationen interagieren mit mehreren Hosts. Beispielsweise kommuniziert die v2-256-Konfiguration mit 16 Hosts.

Hosts rufen Daten aus dem Dateisystem oder lokalen Speicher ab, erledigen die erforderliche Datenvorverarbeitung und übertragen dann vorverarbeitete Daten an die TPU-Kerne. Diese drei Phasen der Datenverarbeitung durch den Host werden getrennt betrachtet und als Speicher, Vorverarbeitung und Übertragung bezeichnet. Diese Anordnung wird in der folgenden Abbildung schematisch dargestellt:

Bild

Damit das System eine gute Leistung erzielt, sollte es ausgewogen sein. Die von einer Host-CPU aufgewendete Zeit für den Abruf von Bildern, deren Decodierung und die relevante Vorverarbeitung sollte idealerweise etwas kürzer oder ungefähr gleich lang sein wie die Zeit, die die TPU für die Berechnungen benötigt. Wenn die Host-CPU für die drei Datenverarbeitungsphasen länger benötigt als die TPU für ihre Aufgaben, spricht man von einer hostgebundenen Ausführung. Aufgrund der extremen Schnelligkeit von TPUs ist dies bei einigen sehr einfachen Modellen unter Umständen unvermeidbar. Beide Fälle sind in der folgenden Abbildung dargestellt.

Bild

Die aktuelle Implementierung von Inception v3 bewegt sich an der Grenze, die ein System als eingabegebunden definiert. Bilder müssen aus dem Dateisystem abgerufen, decodiert und dann vorverarbeitet werden. Dafür stehen verschiedene Arten von Vorverarbeitungsphasen zur Verfügung, von moderat bis komplex. Bei Verwendung der komplexesten Vorverarbeitungsphase gerät das System durch die große Anzahl von aufwendigen Vorgängen in der Vorverarbeitungsphase aus der Balance und die Trainings-Pipeline ist entsprechend vorverarbeitungsgebunden. Allerdings ist dieser Grad an Komplexität nicht nötig, um eine höhere Genauigkeit als 78,1 % zu erzielen. Daher wird eine mäßig komplexe Vorverarbeitungsphase verwendet, die die Waage in die andere Richtung kippt und das Modell TPU-gebunden hält. Dies wird im nächsten Abschnitt näher erläutert.

Das Modell verwendet tf.data.Dataset um alle Anforderungen der Eingabepipeline zu erfüllen. Weitere Informationen zum Optimieren von Eingabepipelines mit der tf.data API finden Sie im Leitfaden zur Dataset-Leistung.

Sie können eine Funktion zwar einfach definieren und an die Estimator API übergeben, im Fall von Inception erstellt die Klasse InputPipeline jedoch alle erforderlichen Funktionen und definiert stattdessen eine __call__-Methode.

In der Estimator API ist die Verwendung dieser Klasse unkompliziert. Sie muss einfach an den input_fn-Parameter der Funktionen train() und evaluate() übergeben werden, wie im Beispiel-Code-Snippet unten gezeigt:

def main(unused_argv):

          …

  inception_classifier = tpu_estimator.TPUEstimator(
      model_fn=inception_model_fn,
      use_tpu=FLAGS.use_tpu,
      config=run_config,
      params=params,
      train_batch_size=FLAGS.train_batch_size,
      eval_batch_size=eval_batch_size,
      batch_axis=(batch_axis, 0))

          …

  for cycle in range(FLAGS.train_steps // FLAGS.train_steps_per_eval):
    tf.logging.info('Starting training cycle %d.' % cycle)
    inception_classifier.train(
        input_fn=InputPipeline(True), steps=FLAGS.train_steps_per_eval)

    tf.logging.info('Starting evaluation cycle %d .' % cycle)
    eval_results = inception_classifier.evaluate(
        input_fn=InputPipeline(False), steps=eval_steps, hooks=eval_hooks)
    tf.logging.info('Evaluation results: %s' % eval_results)

Die Hauptelemente der Klasse InputPipeline sind im Code-Snippet unten dargestellt, in dem die drei Phasen in unterschiedlichen Farben hervorgehoben wurden. Methode __call__ erstellt das Dataset mit tf.data.Dataset und führt dann eine Reihe von API-Aufrufen durch, um die integrierten Vorabruf-, Verschachtelungs- und Zufallsfunktionen des Datasets zu nutzen.

class InputPipeline(object):

  def __init__(self, is_training):
    self.is_training = is_training

  def __call__(self, params):
    # Storage
    file_pattern = os.path.join(
        FLAGS.data_dir, 'train-*' if self.is_training else 'validation-*')
    dataset = tf.data.Dataset.list_files(file_pattern)
    if self.is_training and FLAGS.initial_shuffle_buffer_size > 0:
      dataset = dataset.shuffle(
          buffer_size=FLAGS.initial_shuffle_buffer_size)
    if self.is_training:
      dataset = dataset.repeat()

    def prefetch_dataset(filename):
      dataset = tf.data.TFRecordDataset(
          filename, buffer_size=FLAGS.prefetch_dataset_buffer_size)
      return dataset

    dataset = dataset.apply(
        tf.contrib.data.parallel_interleave(
            prefetch_dataset,
            cycle_length=FLAGS.num_files_infeed,
            sloppy=True))
    if FLAGS.followup_shuffle_buffer_size > 0:
      dataset = dataset.shuffle(
          buffer_size=FLAGS.followup_shuffle_buffer_size)

    # Preprocessing
    dataset = dataset.map(
        self.dataset_parser,
        num_parallel_calls=FLAGS.num_parallel_calls)

    dataset = dataset.prefetch(batch_size)
    dataset = dataset.apply(
        tf.contrib.data.batch_and_drop_remainder(batch_size))
    dataset = dataset.prefetch(2)  # Prefetch overlaps in-feed with training
    images, labels = dataset.make_one_shot_iterator().get_next()

    # Transfer
    return images, labels

Der Abschnitt Storage (Speicher) beginnt mit der Erstellung eines Datasets und enthält das Lesen von TFRecords aus dem Speicher (mit tf.data.TFRecordDataset). Die Sonderfunktionen repeat() und shuffle() werden nach Bedarf verwendet. Die Funktion tf.contrib.data.parallel_interleave() ordnet ihrer Eingabe eine Funktion prefetch_dataset() zu, um verschachtelte Datasets zu erzeugen, und gibt ihre Elemente verschränkt aus. Sie ruft Elemente aus cycle_length verschachtelten Datasets parallel ab, wodurch der Durchsatz erhöht wird. Das Argument sloppy lockert die Vorgabe, dass die Ausgaben in einer deterministischen Reihenfolge zu erzeugen sind. Damit kann die Implementierung verschachtelte Datasets überspringen, deren Elemente beim Abruf nicht verfügbar sind.

Im Abschnitt Preprocessing (Vorverarbeitung) wird dataset.map(parser) aufgerufen, wodurch wiederum die Parser-Funktion aufgerufen wird, in der Bilder vorverarbeitet werden. Die Vorverarbeitungsphase wird im nächsten Abschnitt ausführlich erläutert.

Der Abschnitt Transfer (Übertragung) am Ende der Funktion enthält die Zeile return images, labels. TPUEstimator greift die zurückgegebenen Werte auf und überträgt sie automatisch an das Gerät.

Die folgende Abbildung zeigt ein Beispiel für einen Cloud TPU-Leistungs-Trace von Inception v3. Die TPU-Rechenzeit liegt derzeit ohne Berücksichtigung von Einspeiseverzögerungen bei etwa 815 ms.

Bild

Die Hostphase Speicher wird im Trace ebenfalls gezeigt und sieht so aus:

Bild

Die Hostphase Vorverarbeitung, die die Bilddecodierung und eine Reihe von Bildverzerrungsfunktionen einschließt, ist unten zu sehen:

Bild

Die Übertragung vom Host zur TPU sieht so aus:

Bild

Vorverarbeitungsphase

Die Bildvorverarbeitung ist ein wichtiger Bestandteil des Systems und kann die maximale Genauigkeit, die das Modell während des Trainings erzielt, stark beeinflussen. Bilder müssen zumindest decodiert und skaliert werden, um sie an das Modell anzupassen. Im Fall von Inception müssen Bilder 299 x 299 x 3 Pixel groß sein.

Allerdings lässt sich über Decodierung und Größenanpassung allein noch keine gute Genauigkeit erzielen. Das ImageNet-Trainings-Dataset enthält 1.281.167 Bilder. Wenn dieser Satz von Trainingsbildern einmal durchlaufen wird, spricht man von einer Epoche. Damit das Modell seine Bilderkennungsfähigkeiten verbessern kann, muss das Trainings-Dataset während des Trainings mehrmals durchlaufen werden. Bei Inception v3 liegt die Anzahl der erforderlichen Epochen im Bereich von 140 bis 200, je nach globaler Batchgröße.

Es bringt sehr viel, die Bilder kontinuierlich zu ändern, bevor sie in das Modell eingespeist werden. Dabei sollten die einzelnen Bilder in jeder Epoche etwas anders aussehen. Die beste Vorgehensweise für diese Vorverarbeitung der Bilder zu finden, ist sowohl Kunst als auch Wissenschaft. Auf der einen Seite lassen sich die Erkennungsfähigkeiten eines Modells mit einer gut gestalteten Vorverarbeitungsphase erheblich steigern. Auf der anderen Seite kann eine zu einfache Vorverarbeitungsphase der maximalen Genauigkeit, die dasselbe Modell während des Trainings erreichen kann, eine künstliche Obergrenze setzen.

Inception v3 bietet verschiedene Optionen für die Vorverarbeitungsphase – von relativ einfach und wenig rechenintensiv bis verhältnismäßig komplex und rechenintensiv. Zwei dieser verschiedenen Varianten finden Sie in den Dateien vgg_preprocessing.py und inception_preprocessing.py.

Die Datei vgg_preprocess.py definiert eine Vorverarbeitungsphase, die erfolgreich zum Trainieren von resnet mit einer Genauigkeit von 75 % verwendet wurde, aber bei Anwendung auf Inception v3 unzulängliche Ergebnisse liefert.

Die Datei inception_preprocessing.py enthält eine Vorverarbeitungsphase mit mehreren Optionen und unterschiedlichen Komplexitätsgraden, mit der Inception v3 bei Ausführung auf TPUs erfolgreich bis zu Genauigkeiten von 78,1 bis 78,5 % trainiert wurde. In diesem Abschnitt wird die Vorverarbeitungs-Pipeline erläutert.

Die Vorverarbeitung variiert in Abhängigkeit davon, ob das Modell trainiert oder für die Inferenz/Evaluation verwendet wird.

In der Evaluationsphase ist die Vorverarbeitung relativ einfach: Ein zentraler Bereich des Bildes wird zugeschnitten und dann an die Standardgröße von 299 x 299 Pixel angepasst. Das Code-Snippet, in dem dies umgesetzt wird, ist unten zu sehen:

def preprocess_for_eval(image, height, width, central_fraction=0.875):
  with tf.name_scope(scope, 'eval_image', [image, height, width]):
    if image.dtype != tf.float32:
      image = tf.image.convert_image_dtype(image, dtype=tf.float32)
    image = tf.image.central_crop(image, central_fraction=central_fraction)
    image = tf.expand_dims(image, 0)
    image = tf.image.resize_bilinear(image, [height, width], align_corners=False)
    image = tf.squeeze(image, [0])
    image = tf.subtract(image, 0.5)
    image = tf.multiply(image, 2.0)
    image.set_shape([height, width, 3])
    return image

In der Trainingsphase ist das Zuschneiden randomisiert: Ein Bereich des Bildes wird mit einem Markierungsrahmen zufällig ausgewählt und dann in der Größe geändert. Es folgt eine optionale Spiegelung des skalierten Bildes und eine Verzerrung seiner Farben. Das Code-Snippet, in dem dies umgesetzt wird, ist unten zu sehen:

def preprocess_for_train(image, height, width, bbox, fast_mode=True, scope=None):
  with tf.name_scope(scope, 'distort_image', [image, height, width, bbox]):
    if bbox is None:
      bbox = tf.constant([0.0, 0.0, 1.0, 1.0], dtype=tf.float32, shape=[1, 1, 4])
    if image.dtype != tf.float32:
      image = tf.image.convert_image_dtype(image, dtype=tf.float32)

    distorted_image, distorted_bbox = distorted_bounding_box_crop(image, bbox)
    distorted_image.set_shape([None, None, 3])

    num_resize_cases = 1 if fast_mode else 4
    distorted_image = apply_with_random_selector(
        distorted_image,
        lambda x, method: tf.image.resize_images(x, [height, width], method),
        num_cases=num_resize_cases)

    distorted_image = tf.image.random_flip_left_right(distorted_image)

    if FLAGS.use_fast_color_distort:
      distorted_image = distort_color_fast(distorted_image)
    else:
      num_distort_cases = 1 if fast_mode else 4
      distorted_image = apply_with_random_selector(
          distorted_image,
          lambda x, ordering: distort_color(x, ordering, fast_mode),
          num_cases=num_distort_cases)

    distorted_image = tf.subtract(distorted_image, 0.5)
    distorted_image = tf.multiply(distorted_image, 2.0)
    return distorted_image

Die Funktion distort_color ist für die Farbänderung zuständig. Sie umfasst einen Schnellmodus, in dem nur Helligkeit und Sättigung geändert werden. Im Vollmodus werden Helligkeit, Sättigung und Farbton geändert. In welcher Reihenfolge dies geschieht, wird zufällig entschieden. Ein Code-Snippet dieser Funktion ist unten zu sehen:

def distort_color(image, color_ordering=0, fast_mode=True, scope=None):
  with tf.name_scope(scope, 'distort_color', [image]):
    if fast_mode:
      if color_ordering == 0:
        image = tf.image.random_brightness(image, max_delta=32. / 255.)
        image = tf.image.random_saturation(image, lower=0.5, upper=1.5)
      else:
        image = tf.image.random_saturation(image, lower=0.5, upper=1.5)
        image = tf.image.random_brightness(image, max_delta=32. / 255.)
    else:
      if color_ordering == 0:
        image = tf.image.random_brightness(image, max_delta=32. / 255.)
        image = tf.image.random_saturation(image, lower=0.5, upper=1.5)
        image = tf.image.random_hue(image, max_delta=0.2)
        image = tf.image.random_contrast(image, lower=0.5, upper=1.5)
      elif color_ordering == 1:
        image = tf.image.random_saturation(image, lower=0.5, upper=1.5)
        image = tf.image.random_brightness(image, max_delta=32. / 255.)
        image = tf.image.random_contrast(image, lower=0.5, upper=1.5)
        image = tf.image.random_hue(image, max_delta=0.2)
      elif color_ordering == 2:
        image = tf.image.random_contrast(image, lower=0.5, upper=1.5)
        image = tf.image.random_hue(image, max_delta=0.2)
        image = tf.image.random_brightness(image, max_delta=32. / 255.)
        image = tf.image.random_saturation(image, lower=0.5, upper=1.5)
      elif color_ordering == 3:
        image = tf.image.random_hue(image, max_delta=0.2)
        image = tf.image.random_saturation(image, lower=0.5, upper=1.5)
        image = tf.image.random_contrast(image, lower=0.5, upper=1.5)
        image = tf.image.random_brightness(image, max_delta=32. / 255.)

    return tf.clip_by_value(image, 0.0, 1.0)

Die Funktion distort_color ist rechenintensiv, was teilweise auf die nichtlinearen Konvertierungen von RGB zu HSV und HSV zu RGB zurückzuführen ist, die für den Zugriff auf Farbton und Sättigung erforderlich sind. Diese Konvertierungen werden sowohl für den Schnell- als auch den Vollmodus benötigt. Obwohl der Schnellmodus weniger rechenintensiv ist, verschiebt er das Modell dennoch in den CPU-rechengebundenen Bereich, wenn er aktiviert wird.

Als Alternative wurde eine neue Funktion distort_color_fast zur Liste der Optionen hinzugefügt. Diese Funktion ordnet das Bild mithilfe des JPEG-Konvertierungsschemas von RGB zu YCrCb zu und ändert nach dem Zufallsprinzip die Helligkeit und die Cr/Cb-Farbtöne, bevor es wieder RGB zugeordnet wird. Die Funktion wird im folgenden Code-Snippet angezeigt:

def distort_color_fast(image, scope=None):
  with tf.name_scope(scope, 'distort_color', [image]):
    br_delta = random_ops.random_uniform([], -32./255., 32./255., seed=None)
    cb_factor = random_ops.random_uniform(
        [], -FLAGS.cb_distortion_range, FLAGS.cb_distortion_range, seed=None)
    cr_factor = random_ops.random_uniform(
        [], -FLAGS.cr_distortion_range, FLAGS.cr_distortion_range, seed=None)

    channels = tf.split(axis=2, num_or_size_splits=3, value=image)
    red_offset = 1.402 * cr_factor + br_delta
    green_offset = -0.344136 * cb_factor - 0.714136 * cr_factor + br_delta
    blue_offset = 1.772 * cb_factor + br_delta
    channels[0] += red_offset
    channels[1] += green_offset
    channels[2] += blue_offset
    image = tf.concat(axis=2, values=channels)
    image = tf.clip_by_value(image, 0., 1.)
    return image

Hier sehen Sie ein Beispielbild, das einer Vorverarbeitung unterzogen wurde. Mit der Funktion distort_color_fast wurden ein zufällig ausgewählter Bildbereich ausgewählt und die Farben geändert.

Bild

Die Funktion distort_color_fast ist recheneffizient und ermöglicht gleichzeitig, dass das Training an die TPU-Ausführungszeit gebunden ist. Sie liefert außerdem gute Ergebnisse und konnte erfolgreich zum Trainieren des Inception v3-Modells bis zu einer Genauigkeit von über 78,1 % mit Batchgrößen von 1.024 bis 16.384 verwendet werden. Die Funktion ist die Standardeinstellung für Inception v3.

Optimierung

Im aktuellen Modell kommen drei verschiedene Optimierungen zum Einsatz: SGD, Momentum und RMSProp.

Stochastic gradient descent (SGD) ist die einfachste Art der Aktualisierung: Die Gewichtungen werden in Richtung des negativen Farbverlaufs verschoben. Trotz der Einfachheit dieser Optimierung können bei einigen Modellen noch gute Ergebnisse erzielt werden. Die Aktualisierungsdynamik lässt sich formulieren als:

$$w_{k+1}=w_k-\alpha∇f(w_k)$$

Momentum ist ein beliebtes Optimierungstool, das häufig zu schnelleren Konvergenzen führt, als es von SGD erreicht werden kann. Dieses Optimierungsprogramm aktualisiert die Gewichtung ähnlich wie SGD, fügt aber auch eine Komponente in Richtung der vorherigen Aktualisierung hinzu. Die Dynamik der Aktualisierung ergibt sich aus:

$$z_{k+1}=\beta z_k+∇f(w_k)$$
$$w_{k+1}=w_k-\alpha z_{k+1}$$

Dies lässt sich formulieren als:

$$w_{k+1}=w_k-\alpha ∇f(w_k)+\beta \left(w_k-w_{k-1}\right)$$

Der letzte Term ist die Komponente in Richtung der vorherigen Aktualisierung. Dies wird in der folgenden Abbildung grafisch dargestellt:

Bild

Für das Momentum \({\beta}\) wird der gängige Wert 0,9 verwendet.

RMSprop ist eine weitverbreitete Optimierung, die zuerst von Geoff Hinton in einer seiner Vorlesungen vorgestellt wurde. Die Aktualisierungsdynamik ergibt sich aus:

$$g_{k+1}^{-2} = \alpha g_{k}^{-2} + (1-\alpha) g_{k}^2$$ $$w_{k+1}=\beta w_k + \frac{\eta}{\sqrt {g_{k+1^{\mathbf{+{\epsilon}}}}^{-2}}} ∇f(w_k)$$

Für Inception v3 zeigen Tests, dass RMSProp die besten Ergebnisse in Bezug auf die maximale Genauigkeit und die Zeit bis zum Erreichen dieser Genauigkeit liefert, dicht gefolgt von Momentum. Aus diesem Grund wird RMSprop als Standardoptimierung festgelegt. Die verwendeten Parameter sind: decay \({\alpha}\) = 0,9, momentum \({\beta}\) = 0,9 und \({\epsilon}\) = 1,0.

Das Code-Snippet mit den Optimierungsoptionen ist unten zu sehen:

if FLAGS.optimizer == 'sgd':
  tf.logging.info('Using SGD optimizer')
  optimizer = tf.train.GradientDescentOptimizer(
      learning_rate=learning_rate)
elif FLAGS.optimizer == 'momentum':
  tf.logging.info('Using Momentum optimizer')
  optimizer = tf.train.MomentumOptimizer(
      learning_rate=learning_rate, momentum=0.9)
elif FLAGS.optimizer == 'RMS':
  tf.logging.info('Using RMS optimizer')
  optimizer = tf.train.RMSPropOptimizer(
      learning_rate,
      RMSPROP_DECAY,
      momentum=RMSPROP_MOMENTUM,
      epsilon=RMSPROP_EPSILON)
else:
  tf.logging.fatal('Unknown optimizer:', FLAGS.optimizer)

Beim Ausführen auf TPUs und mithilfe der Estimator API muss das Optimierungstool in eine CrossShardOptimizer-Funktion eingebunden werden, um für die Synchronisierung zwischen den Replikaten zu sorgen (zusammen mit der erforderlichen Kommunikation). Das Code-Snippet, in dem dies in Inception v3 umgesetzt wird, ist unten zu sehen:

if FLAGS.use_tpu:
    optimizer = tpu_optimizer.CrossShardOptimizer(optimizer)

update_ops = tf.get_collection(tf.GraphKeys.UPDATE_OPS)
with tf.control_dependencies(update_ops):
  train_op = optimizer.minimize(loss, global_step=global_step)

Exponentieller gleitender Durchschnitt

Das Training läuft normalerweise so ab, dass trainierbare Parameter während der Rückpropagierung gemäß den Aktualisierungsregeln der Optimierung aktualisiert werden. Diese wurden im vorherigen Abschnitt besprochen und sind hier noch einmal aufgeführt:

$${\theta_{k+1}} = {\theta_k}-{\alpha ∇f(\theta_k)} \qquad(SGD)$$
$${\theta_{k+1}}={\theta_k}-{\alpha z_{k+1}} \qquad(momentum)$$
$${\theta_{k+1}}= {\beta \theta_k}+\frac{\eta}{\sqrt {g_{k+1^{\mathbf+{\epsilon}}}}^{-2}} ∇f(\theta_k) \qquad(RMSprop)$$

Der exponentielle gleitende Durchschnitt (auch exponentielle Glättung) ist ein optionaler Nachbearbeitungsschritt, der auf die aktualisierten Gewichtungen angewendet wird und manchmal zu merklichen Leistungsverbesserungen führen kann. Inception v3 profitiert enorm von diesem zusätzlichen Schritt. TensorFlow stellt die Funktion tf.train.ExponentialMovingAverage bereit, die den exponentiellen gleitenden Durchschnitt \({\hat{\theta}}\) der Gewichtung \({\theta}\) mithilfe der folgenden Formel berechnet:

$${\hat{\theta_t}}={\alpha {\hat{\theta}{_{t-1}}}}+{(1-\alpha)}{\theta_t}$$

Dabei ist \({\alpha}\) ein Abklingfaktor (nahe 1,0). Für Inception v3 ist \({\alpha}\) auf 0,995 festgelegt.

Obwohl dies ein Filter mit unendlicher Impulsantwort (Infinite Impulse Response, IIR) ist, ergibt sich aus dem Abklingfaktor ein effektives Fenster, in dem sich die meiste Energie bzw. die relevanten Stichproben befinden. Dies wird im folgenden Diagramm dargestellt:

Bild

Zur Verdeutlichung wird die Filtergleichung umformuliert als:

$${\hat{\theta}_{t+T+1}}={\alpha(1-\alpha)}({\theta_{t+T}}+{\alpha \theta_{t+T-1}}+...+{\alpha^{t+T}}{\theta_0})$$

Dabei wurde \({\hat\theta_{-1}}=0\) verwendet.

Die Werte \({\alpha}^k\) nehmen mit steigendem k ab, sodass letztendlich nur eine Teilmenge der Stichproben einen beträchtlichen Einfluss auf \(\hat{\theta}_{t+T+1}\) hat. Als Faustregel für die Dauer dieses Fensters gilt: \(\frac {1} {1-\alpha}\), was \({\alpha}\) = 200 für =0,995 entspricht.

Zuerst erhalten wir eine Sammlung trainierbarer Variablen und verwenden dann die Methode apply(), um Schattenvariablen für jede trainierte Variable zu erstellen (und entsprechende Vorgänge hinzuzufügen, um gleitende Durchschnitte für diese in ihren Schattenkopien zu verwalten). Das Code-Snippet, in dem dies für Inception v3 umgesetzt wird, ist unten zu sehen:

update_ops = tf.get_collection(tf.GraphKeys.UPDATE_OPS)
with tf.control_dependencies(update_ops):
  train_op = optimizer.minimize(loss, global_step=global_step)

if FLAGS.moving_average:
  ema = tf.train.ExponentialMovingAverage(
      decay=MOVING_AVERAGE_DECAY, num_updates=global_step)
  variables_to_average = (tf.trainable_variables() +
                          tf.moving_average_variables())
  with tf.control_dependencies([train_op]), tf.name_scope('moving_average'):
    train_op = ema.apply(variables_to_average)

Jetzt sollen die Variablen des exponentiellen gleitenden Durchschnitts bei der Evaluation verwendet werden. Dazu definieren wir die Klasse LoadEMAHook, die die Methode variables_to_restore() auf die Prüfpunktdatei anwendet, die mithilfe der Schattenvariablennamen ausgewertet wird:

class LoadEMAHook(tf.train.SessionRunHook):
  def __init__(self, model_dir):
    super(LoadEMAHook, self).__init__()
    self._model_dir = model_dir

  def begin(self):
    ema = tf.train.ExponentialMovingAverage(MOVING_AVERAGE_DECAY)
    variables_to_restore = ema.variables_to_restore()
    self._load_ema = tf.contrib.framework.assign_from_checkpoint_fn(
        tf.train.latest_checkpoint(self._model_dir), variables_to_restore)

  def after_create_session(self, sess, coord):
    tf.logging.info('Reloading EMA...')
    self._load_ema(sess)

Die Funktion hooks wird an evaluate() übergeben, wie im folgenden Snippet gezeigt:

if FLAGS.moving_average:
    eval_hooks = [LoadEMAHook(FLAGS.model_dir)]
else:
    eval_hooks = []

    …

eval_results = inception_classifier.evaluate(
    input_fn=InputPipeline(False), steps=eval_steps, hooks=eval_hooks)

Batchnormalisierung

Die Batchnormalisierung ist eine weitverbreitete Technik zur Normalisierung von Eingabemerkmalen in Modellen, die die Konvergenzzeit erheblich verringern kann. Sie zählt zu den gängigsten und wertvollsten algorithmischen Verbesserungen des maschinellen Lernens der letzten Jahre und wird in einer Vielzahl von Modellen, einschließlich Inception v3, verwendet.

Bei der Batchnormalisierung werden Aktivierungseingaben zuerst durch Subtrahieren des Batchmittelwerts und Dividieren durch die Batchstandardabweichung normalisiert, was aber noch nicht alles ist. Damit die Balance bei der Rückpropagierung gewahrt bleibt, werden zwei trainierbare Parameter in jeder Schicht hinzugefügt. Auf normalisierte Ausgaben \({\hat{x}}\) wird ein nachfolgender Vorgang \({\gamma\hat{x}}+\beta\) angewendet, wobei \({\gamma}\) und \({\beta}\) eine Art Standardabweichung und Mittelwert sind, die das Modell jedoch selbst lernt.

Der vollständige Satz von Gleichungen ist in diesem Artikel zu finden und wird hier noch einmal aufgeführt:

Eingabe: Werte von x über einem Minibatch: \(\Phi=\) { \({x_{1..m}\\} \) } Zu lernende Parameter: \({\gamma}\),\({\beta}\)

Ausgabe: { \({y_i}=BN_{\gamma,\beta}{(x_i)}\) }

\[{\mu_\phi} \leftarrow {\frac{1}{m}}{\sum_{i=1}^m}x_i \qquad \mathsf(mini-batch\ mean)\]

\[{\sigma_\phi}^2 \leftarrow {\frac{1}{m}}{\sum_{i=1}^m} {(x_i - {\mu_\phi})^2} \qquad \mathbf(mini-batch\ variance)\]

\[{\hat{x_i}} \leftarrow {\frac{x_i-{\mu_\phi}}{\sqrt {\sigma^2_\phi}+{\epsilon}}}\qquad \mathbf(normalize)\]

\[{y_i}\leftarrow {\gamma \hat{x_i}} + \beta \equiv BN_{\gamma,\beta}{(x_i)}\qquad \mathbf(scale \ and \ shift)\]

Dies ist der Ablauf der Normalisierung während des Trainings, bei der Evaluation soll sich das Modell jedoch deterministisch verhalten: Das Klassifizierungsergebnis eines Bildes sollte ausschließlich vom Eingabebild und nicht vom Satz der Bilder abhängen, die in das Modell eingespeist werden. Somit sind \({\mu}\) und \({\sigma}^2\) zu korrigieren. Diese müssen die Populationsstatistik des Bildes darstellen.

Zu diesem Zweck berechnet das Modell gleitende Durchschnitte des Mittelwerts und der Varianz über den Minibatches:

\[{\hat\mu_i} = {\alpha \hat\mu_{t-1}}+{(1-\alpha)\mu_t}\]

\[{\hat\sigma_t}^2 = {\alpha{\hat\sigma^2_{t-1}}} + {(1-\alpha) {\sigma_t}^2}\]

Für Inception v3 wurde ein vernünftiger Abklingfaktor per Hyperparameter-Abstimmung zur Verwendung auf GPUs ermittelt. Damit dieser Wert auch auf TPUs verwendet werden kann, müssen jedoch einige Anpassungen vorgenommen werden.

Sowohl der gleitende Mittelwert als auch die gleitende Varianz der Batchnormalisierung werden mithilfe eines Tiefpassfilters berechnet, dessen kanonische Gleichung unten zu sehen ist, wobei \({y_t}\) für den gleitenden Mittelwert oder die gleitende Varianz steht:

\[{y_t}={\alpha y_{t-1}}+{(1-\alpha)}{x_t} \]

(1)

In einem (synchronen) 8x1-GPU-Job liest jedes Replikat den aktuellen gleitenden Mittelwert und aktualisiert ihn anschließend. Die Aktualisierungen sind insofern sequenziell, als die neue gleitende Variable erst vom aktuellen Replikat geschrieben werden muss, bevor sie vom nächsten Replikat gelesen werden kann.

Bei acht Replikaten sieht der Satz von Vorgängen für eine komplette Aktualisierung so aus:

\[{y_t}={\alpha y_{t-1}}+{(1-\alpha)}{x_t} \]

\[{y_{t+1}}={\alpha y_{t}}+{(1-\alpha)}{x_{t+1}} \]

\[{y_{t+2}}={\alpha y_{t+1}}+{(1-\alpha)}{x_{t+2}} \]

\[{y_{t+3}}={\alpha y_{t+2}}+{(1-\alpha)}{x_{t+3}} \]

\[{y_{t+4}}={\alpha y_{t+3}}+{(1-\alpha)}{x_{t+4}} \]

\[{y_{t+5}}={\alpha y_{t+4}}+{(1-\alpha)}{x_{t+5}} \]

\[{y_{t+6}}={\alpha y_{t+5}}+{(1-\alpha)}{x_{t+6}} \]

\[{y_{t+7}}={\alpha y_{t+6}}+{(1-\alpha)}{x_{t+7}} \]

Dieser Satz von acht sequenziellen Aktualisierungen lässt sich formulieren als:

\[{y_{t+7}}={\alpha^8y_{t-1}}+(1-\alpha){\sum_{k=0}^7} {\alpha^{7-k}}{x_{t+k}}\]

(2)

Die Berechnung des gleitenden Mittelwerts und der gleitenden Varianz wird auf TPUs derzeit so umgesetzt, dass jeder Shard unabhängig Berechnungen durchführt und die Shards nicht miteinander kommunizieren. Batches werden auf die einzelnen Shards verteilt und jeder Shard verarbeitet ein Achtel aller Batches (wenn acht Shards vorhanden sind).

Obwohl jeder Shard den gleitenden Mittelwert und die gleitende Varianz berechnet, werden nur die Ergebnisse von Shard 0 an die Host-CPU zurückgegeben. Der gleitende Mittelwert und die gleitende Varianz werden also praktisch nur von einem einzigen Replikat aktualisiert:

\[{z_t}={\beta {z_{t-1}}}+{(1-\beta)u_t}\]

(3)

Diese Aktualisierung erfolgt mit einem Achtel der Rate des sequenziellen Gegenstücks.

Damit GPU- und TPU-Aktualisierungsgleichungen verglichen werden können, müssen die entsprechenden Zeitskalen ausgerichtet werden. Insbesondere sollte der Satz von Vorgängen, die einen Satz von acht aufeinanderfolgenden Aktualisierungen auf der GPU umfassen, mit einer einzelnen Aktualisierung auf der TPU verglichen werden. Dies wird im folgenden Diagramm veranschaulicht:

Bild

Im Folgenden sind die Gleichungen mit den geänderten Zeitindexen aufgeführt:

\[{y_t}={\alpha^8y_{t-1}}+(1-\alpha){\sum_{k=0}^7} {\alpha^{7-k}}{x_{t-k/8}} \qquad \mathsf(GPU)\]

\[{z_t}={\beta {z_{t-1}}}+{(1-\beta)u_t}\qquad \mathsf(TPU) \]

Unter der vereinfachenden Annahme, dass die über alle relevanten Dimensionen normalisierten acht Minibatches jeweils ähnliche Werte innerhalb der sequenziellen Aktualisierung mit acht Minibatches auf der GPU liefern, lassen sich diese Gleichungen so approximieren:

\[{y_t}={\alpha^8y_{t-1}}+(1-\alpha){\sum_{k=0}^7} {\alpha^{7-k}}{\hat{x_t}}={\alpha^8y_{t-1}+(1-\alpha^8){\hat{x_t}}} \qquad \mathsf(GPU)\]

\[{z_t}={\beta {z_{t-1}}}+{(1-\beta)u_t}\qquad \mathsf(TPU) \]

Damit die Auswirkung eines gegebenen Abklingfaktors auf der GPU erreicht wird, muss der Abklingfaktor auf der TPU entsprechend geändert werden. Insbesondere muss \({\beta}\)=\({\alpha}^8\) festgelegt werden.

Bei Inception v3 ist der auf der GPU verwendete Abklingwert \({\alpha}\)=0,9997, was einem TPU-Abklingwert von \({\beta}\)=0,9976 entspricht.

Anpassung der Lernrate

Je umfangreicher die Batchgrößen, desto schwieriger gestaltet sich das Training. Damit auch bei umfangreichen Batchgrößen ein effizientes Training möglich ist, werden fortlaufend neue Techniken vorgeschlagen (Beispiele finden Sie hier, hier und hier).

Mit einer dieser Techniken, bei der die Lernrate graduell ansteigt, wurde das Modell bis zu einer Genauigkeit von über 78,1 % mit Batchgrößen von 4.096 bis 16.384 trainiert. Für Inception v3 wird die Lernrate zuerst auf ungefähr 10 % der normalerweise verwendeten Ausgangslernrate festgelegt. Die Lernrate bleibt über eine bestimmte (kleine) Anzahl "kalter Epochen" konstant bei diesem niedrigen Wert und steigt dann über eine bestimmte Anzahl von "Aufwärm-Epochen" linear an, bis sie sich am Ende mit der Lernrate schneidet, die sich bei Verwendung eines normalen exponentiellen Abklingens ergeben hätte. Dies wird in der folgenden Abbildung veranschaulicht:

Bild

Das Code-Snippet, in dem dies umgesetzt wird, ist unten zu sehen:

initial_learning_rate = FLAGS.learning_rate * FLAGS.train_batch_size / 256
if FLAGS.use_learning_rate_warmup:
  warmup_decay = FLAGS.learning_rate_decay**(
    (FLAGS.warmup_epochs + FLAGS.cold_epochs) /
    FLAGS.learning_rate_decay_epochs)
  adj_initial_learning_rate = initial_learning_rate * warmup_decay

final_learning_rate = 0.0001 * initial_learning_rate

train_op = None
if training_active:
  batches_per_epoch = _NUM_TRAIN_IMAGES / FLAGS.train_batch_size
  global_step = tf.train.get_or_create_global_step()
  current_epoch = tf.cast(
    (tf.cast(global_step, tf.float32) / batches_per_epoch), tf.int32)

  learning_rate = tf.train.exponential_decay(
    learning_rate=initial_learning_rate,
    global_step=global_step,
    decay_steps=int(FLAGS.learning_rate_decay_epochs * batches_per_epoch),
    decay_rate=FLAGS.learning_rate_decay,
    staircase=True)

  if FLAGS.use_learning_rate_warmup:
    wlr = 0.1 * adj_initial_learning_rate
    wlr_height = tf.cast(
      0.9 * adj_initial_learning_rate /
      (FLAGS.warmup_epochs + FLAGS.learning_rate_decay_epochs - 1),
      tf.float32)
    epoch_offset = tf.cast(FLAGS.cold_epochs - 1, tf.int32)
    exp_decay_start = (FLAGS.warmup_epochs + FLAGS.cold_epochs +
                   FLAGS.learning_rate_decay_epochs)
    lin_inc_lr = tf.add(
      wlr, tf.multiply(
        tf.cast(tf.subtract(current_epoch, epoch_offset), tf.float32),
        wlr_height))
    learning_rate = tf.where(
      tf.greater_equal(current_epoch, FLAGS.cold_epochs),
      (tf.where(tf.greater_equal(current_epoch, exp_decay_start),
              learning_rate, lin_inc_lr)),
       wlr)

  # Set a minimum boundary for the learning rate.
  learning_rate = tf.maximum(
      learning_rate, final_learning_rate, name='learning_rate')