Guide avancé d'Inception v3 sur Cloud TPU

Ce document présente divers aspects du modèle Inception et la manière dont ils s'agencent pour que le modèle s'exécute efficacement sur Cloud TPU. Il s'agit d'une version avancée du guide d'utilisation d'Inception v3 sur Cloud TPU. Les modifications particulières du modèle qui ont abouti à des améliorations significatives sont abordées plus en détail. Ce document complète le Tutoriel Inception v3.

L'entraînement TPU Inception v3 donne des résultats comparables aux courbes de précision générées par des tâches GPU de configuration similaire. Le modèle a été entraîné avec succès sur les configurations v2-8, v2-128 et v2-512. Le modèle a atteint une précision supérieure à 78,1 % sur environ 170 époques pour chacune de ces configurations.

Les exemples de code présentés dans ce document visent à illustrer et donner une vue d'ensemble de ce qui se passe lors de la mise en œuvre réelle. Le code fonctionnel est disponible sur GitHub.

Présentation

Inception v3 est un modèle de reconnaissance d'images couramment utilisé qui a démontré, sur l'ensemble de données ImageNet, une justesse supérieure à 78,1 %. Ce modèle est l'aboutissement de nombreuses idées développées par plusieurs chercheurs au fil des ans. Il est basé sur l'article originel Rethinking the Inception Architecture for Computer Vision (Repenser l'architecture Inception pour la vision par ordinateur) de Szegedy, et. al.

Le modèle lui-même est constitué de composants de base symétriques et asymétriques incluant convolutions, pooling moyen, pooling maximal, concaténations, abandons et couches entièrement connectées. La normalisation par lots (batchnorm) est amplement utilisée dans le modèle et appliqué aux entrées d'activation. La perte est calculée via Softmax.

Un diagramme général du modèle est présenté ci-dessous :

image

Le Fichier README du modèle Inception contient davantage d'informations sur l'architecture Inception.

API Estimator

La version TPU de Inception v3 est codée à l'aide de TPUEstimator, une API conçue pour faciliter le développement et faire en sorte que vous puissiez vous concentrer sur les modèles eux-mêmes plutôt que sur les détails du matériel sous-jacent. L'API effectue en arrière-plan l'essentiel du travail de bas niveau nécessaire à l'exécution de modèles sur les TPU, tout en automatisant des fonctions courantes telles que l'enregistrement et la restauration des points de contrôle.

L'API Estimator fait respecter la séparation des sections de code relatives au modèle et aux données. Vous devez définir les fonctions model_fn et input_fn correspondant respectivement à la définition du modèle et aux étapes de prétraitement et de pipeline de données d'entrée du graphe TensorFlow. Voici un exemple de squelette pour ces fonctions :

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

Les deux fonctions clés fournies par l'API sont train() et evaluate(), et servent respectivement à entraîner et évaluer le modèle. Elles sont généralement appelées à l'intérieur de la fonction principale. En voici un exemple ci-dessous :

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)

Ensemble de données ImageNet

Avant de pouvoir être utilisé pour reconnaître des images, le modèle doit être entraîné. Cela se fait généralement grâce à un apprentissage supervisé utilisant un grand nombre d'images avec libellés. Bien qu'Inception v3 puisse être entraîné à partir d'un large éventail d'ensembles d'images avec libellés, ImageNet est un jeu de données commun de prédilection.

ImageNet contient plus de dix millions d'URL d'images avec libellés. Environ un million d'images sont également dotées de cadres englobants spécifiant plus précisément l'emplacement des objets avec libellés.

Pour ce modèle, l'ensemble de données ImageNet est composé de 1 331 167 images partagées en deux ensembles de données d'entraînement et d'évaluation, contenant respectivement 1 281 167 et 50 000 images.

La séparation entre ensembles de données d'entraînement et d’évaluation est intentionnelle. Seules les images de l'ensemble de données d'entraînement sont utilisées pour entraîner le modèle, et seules les images de l'ensemble de données d'évaluation sont utilisées pour évaluer la justesse du modèle.

Le modèle s'attend à ce que les images soient enregistrées sous forme de fichiers TFRecords. Pour convertir des images à partir de fichiers JPEG bruts au format TFRecord, utilisez le script Open Source de traitement par lots : download_and_preprocess_imagenet.sh. Le script doit produire une série de fichiers (aussi bien pour l'entraînement que pour la validation) au format suivant :

${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

où DATA_DIR est l'emplacement hébergeant l'ensemble de données, par exemple : DATA_DIR=$HOME/imagenet-data

La section "Getting Started" du Fichier README du modèle Inception contient des instructions détaillées sur la compilation et l'exécution de ce script.

Pipeline d'entrée

Chaque appareil Cloud TPU possède huit cœurs et est connecté à un hôte (processeur). Les tranches plus conséquentes possèdent plusieurs hôtes. D'autres configurations plus conséquentes interagissent avec plusieurs hôtes. Ainsi, la v2-256 communique avec 16 hôtes.

Les hôtes récupèrent les données depuis le système de fichiers ou la mémoire locale, effectuent tout prétraitement requis sur les données, puis transfèrent les données prétraitées vers les cœurs de TPU. Nous considérons individuellement chacune de ces trois phases de traitement des données effectuées par l'hôte et nous les désignons par les termes : 1) stockage, 2) prétraitement, 3) transfert. Une vue d'ensemble du diagramme est illustrée à la figure ci-dessous :

image

Pour optimiser les performances, le système doit être équilibré. Le temps passé par un processeur hôte à récupérer des images, à les décoder et à effectuer les prétraitements adéquats devrait idéalement rester légèrement inférieur ou à peu près égal au temps passé par le TPU sur les calculs. Si le processeur hôte prend plus de temps que le TPU pour terminer les trois phases de manipulation des données, l'exécution est alors liée à l'hôte. (Remarque : Vu la rapidité extrême des TPU, ce cas de figure peut se révéler inévitable pour certains modèles très simples.) Ces deux cas sont illustrés dans le diagramme ci-dessous.

image

La mise en œuvre actuelle de Inception v3 est à la limite d'être subordonnée aux entrées. Les images doivent être extraites du système de fichiers, décodées, puis prétraitées. Différents types d'étapes de prétraitement sont disponibles, d'une complexité allant de moyenne à élevée. Utiliser les étapes de prétraitement les plus complexes conduit à un grand nombre d'opérations coûteuses exécutées à l'étape de prétraitement, ce qui pousse le système au delà de la limite : le pipeline d'apprentissage est alors subordonné au prétraitement. Toutefois, il n'est pas nécessaire de recourir à ce niveau de complexité pour atteindre une justesse supérieure à 78,1 %. Nous utilisons plutôt une étape de prétraitement de complexité moyenne, qui fait pencher la balance dans l'autre sens et garde le modèle subordonné au TPU. Ce point est discuté plus en détail à la section suivante.

Le modèle utilise tf.data.Dataset pour gérer tous les besoins liés au pipeline d'entrée. Pour en savoir plus sur l'optimisation des pipelines d'entrée à l'aide de l'API tf.data, reportez-vous au guide concernant les performances des ensembles de données.

Bien que vous puissiez simplement définir une fonction et la transmettre à l'API Estimator, dans le cas d'Inception, la classe InputPipeline encapsule toutes les fonctionnalités requises et définit une méthode __call__.

L'API Estimator rend l'utilisation de cette classe très simple. Il suffit de la transmettre au paramètre input_fn des fonctions train() et evaluate(), comme indiqué dans l'exemple d'extrait de code ci-dessous :

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)

Les principaux éléments de la classe InputPipeline sont illustrés dans l'extrait de code ci-dessous, où nous avons mis en évidence les trois phases avec des couleurs différentes. La méthode __call__ crée l'ensemble de données à l'aide de tf.data.Dataset, puis effectue une série d'appels d'API pour utiliser les fonctionnalités intégrées de prérécupération, d'entrelacement et de brassage de l'ensemble de données.

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

La section concernant le stockage commence par la création d'un ensemble de données et comprend la lecture des fichiers TFRecord à partir de l'espace de stockage (à l'aide de tf.data.TFRecordDataset). Les fonctions spéciales repeat() et shuffle() sont utilisées si nécessaire. La fonction tf.contrib.data.parallel_interleave() mappe la fonction prefetch_dataset() sur son entrée pour produire des ensembles de données imbriqués et renvoie leurs éléments entrelacés. Elle récupère les éléments des ensembles de données imbriqués cycle_length en parallèle, ce qui augmente le débit. L'argument sloppy assouplit la contrainte exigeant que les sorties soient produites dans un ordre déterministe et permet à la mise en œuvre de sauter les ensembles de données imbriqués dont les éléments ne sont pas immédiatement disponibles sur demande.

La section concernant le prétraitement appelle dataset.map(parser), qui à son tour appelle la fonction d'analyseur dans laquelle les images sont prétraitées. Les détails de la phase de prétraitement sont abordés à la section suivante.

La section concernant le transfert (à la fin de la fonction) comporte la ligne return images, labels. TPUEstimator prend les valeurs renvoyées et les transmet automatiquement à l'appareil.

La figure ci-dessous montre un exemple de suivi des performances Cloud TPU d'Inception v3. Le temps de calcul TPU, excluant d'éventuels ralentissements d’alimentation, est actuellement d'environ 815 ms.

image

Les performances du stockage hôte sont également visibles et illustrées ci-dessous :

image

Les performances du prétraitement hôte, qui comprend le décodage des images et une série de fonctions de distorsion d'images, sont illustrées ci-dessous :

image

Les performances de transfert hôte/TPU sont visibles ici :

image

Étape de prétraitement

Le prétraitement des images est un élément crucial du système et peut fortement influer sur la justesse maximale atteinte par le modèle pendant l'entraînement. Les images doivent être au moins décodées et redimensionnées pour s'adapter au modèle. Dans le cas d'Inception, les dimensions des images doivent être de 299x299x3 pixels.

Cependant, un simple décodage et redimensionnement ne suffira pas pour atteindre une bonne justesse. L'ensemble de données d'entraînement ImageNet contient 1 281 167 images. Une passe effectuée sur l'ensemble des images d'apprentissage correspond à une époque. Pendant la phase d'entraînement, le modèle devra faire plusieurs passes sur l'ensemble de données d'entraînement pour améliorer ses capacités de reconnaissance d'images. Dans le cas d'Inception v3, le nombre d'époques nécessaires se situera entre 140 et 200 suivant la taille globale du lot.

Il est particulièrement judicieux de modifier continuellement les images avant d'en alimenter le modèle, de manière à ce qu'une image donnée soit légèrement différente lors de chaque époque. La meilleure manière de réaliser ce prétraitement des images relève autant de l’art que de la science. D'un côté, une phase de prétraitement bien conçue peut considérablement améliorer les capacités de reconnaissance d'un modèle. De l'autre, une étape de prétraitement trop simple peut créer un plafond artificiel sur la justesse maximale atteignable par un même modèle pendant l'entraînement.

Inception v3 offre différentes options pour la phase de prétraitement, allant d'une complexité relativement faible et peu coûteuse en calcul à une complexité assez élevée et intensive en calcul. Les fichiers vgg_preprocessing.py et inception_preprocessing.py illustrent deux scénarios de prétraitement distincts.

Le fichier vgg_preprocessing.py définit une étape de prétraitement qui a été utilisée avec succès pour entraîner resnet avec une justesse de 75 %, mais qui produit des résultats sous-optimaux lorsqu'elle est appliquée à Inception v3.

Le fichier inception_preprocessing.py contient une étape de prétraitement à plusieurs options et comportant plusieurs niveaux de complexité, qui a été utilisée avec succès pour entraîner Inception v3 avec une justesse de l'ordre de 78,1-78,5 % sur des TPU. Cette section traite du pipeline de prétraitement.

Le prétraitement diffère selon que le modèle est en cours d'entraînement ou utilisé pour l'inférence ou l'évaluation.

Pour l'évaluation, le prétraitement est assez simple : recadrez une zone centrale de l'image, puis redimensionnez-la à la taille par défaut de 299x299 pixels. L'extrait de code réalisant cette opération est visible ci-dessous :

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

Pendant l'entraînement, le recadrage est aléatoire : un cadre englobant est choisi de manière aléatoire pour sélectionner une région de l'image qui est ensuite redimensionnée. L'image redimensionnée est ensuite éventuellement retournée et ses couleurs sont soumises à une distorsion. L'extrait de code réalisant cette opération est visible ci-dessous :

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

La fonction distort_color est chargée d'altérer les couleurs. Elle propose un mode rapide modifiant seulement la luminosité et la saturation. Le mode complet modifie la luminosité, la saturation et la teinte, et applique ces différentes modifications dans un ordre aléatoire. Un extrait du code de cette fonction est illustré ci-dessous :

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)

La fonction distort_color est coûteuse en termes de calculs, en partie en raison des conversions non linéaires de RVB vers HSV et de HSV vers RVB. Celles-ci sont nécessaires pour accéder à la teinte et à la saturation. Les modes rapide et complet ont tous les deux besoin de ces conversions et bien que le mode rapide soit moins coûteux en calcul, lorsqu'il est activé, il pousse toujours le modèle vers la région subordonnée aux calculs processeur.

À la place, une nouvelle fonction distort_color_fast a été ajoutée à la liste d'options. Cette fonction mappe l'image RVB en YCrCb en utilisant le schéma de conversion JPEG, et modifie de manière aléatoire la luminosité et les chromas Cr/Cb avant de revenir en RVB. Cette fonction est illustrée dans l'extrait de code ci-dessous :

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

Voici un exemple d'image ayant subi un prétraitement. Une région de l'image a été sélectionnée au hasard et ses couleurs ont été modifiées à l'aide de la fonction distort_color_fast.

image

La fonction distort_color_fast est efficace en termes de calcul et permet de garder l'entraînement subordonné au temps d’exécution du TPU. En outre, elle donne de bons résultats et a été utilisée avec succès pour entraîner le modèle Inception v3 avec une justesse supérieure à 78,1 % en utilisant des lots de taille comprise entre 1 024 et 16 384. C'est le choix par défaut pour Inception v3.

Optimiseur

Le modèle actuel présente trois types d'optimiseurs : SGD (méthode du gradient stochastique), Momentum (moment) et RMSProp.

Stochastic gradient descent (SGD) est le type de mise à jour le plus simple : les pondérations sont déplacées en direction du gradient négatif. Malgré sa simplicité, il permet d'obtenir de bons résultats sur certains modèles. La dynamique des mises à jour peut être décrite comme suit :

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

Momentum est un optimiseur populaire assurant fréquemment une convergence plus rapide que celle obtenue par SGD. Il met à jour les pondérations de façon très semblable à SGD, mais ajoute également un composant suivant la direction de la mise à jour précédente. La dynamique de la mise à jour est donnée par :

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

que l'on peut écrire comme suit :

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

Le dernier terme est le composant suivant la direction de la mise à jour précédente. La figure ci-dessous explique ce point de manière graphique :

image

Pour le moment \({\beta}\), nous adoptons la valeur 0,9 utilisée couramment.

RMSprop est un optimiseur populaire initialement proposé par Geoff Hinton durant l'une de ses conférences. La dynamique de mise à jour est donnée par :

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

Pour Inception v3, les tests montrent que RMSProp donne les meilleurs résultats en termes de justesse maximale et de temps requis pour l'atteindre, suivi de près par Momentum. Ainsi, RMSprop est défini comme l'optimiseur par défaut. Les paramètres utilisés sont : decay \({\alpha}\) = 0,9, momentum \({\beta}\) = 0,9 et \({\epsilon}\) = 1,0.

Un extrait de code avec choix de l'optimiseur est présenté ci-dessous :

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)

Lorsqu'il s'exécute sur des TPU et à l'aide de l'API Estimator, l'optimiseur doit être encapsulé dans une fonction CrossShardOptimizer afin d'assurer la synchronisation entre les instances dupliquées (ainsi que toute communication nécessaire entre les instances). L'extrait de code mettant cela en œuvre dans Inception v3 est présenté ci-dessous :

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)

Moyenne mobile exponentielle

Pendant l’entraînement, la ligne de conduite normale consiste à mettre à jour les paramètres entraînables au cours de la rétropropagation en suivant les règles de mise à jour de l’optimiseur. Celles-ci ont été évoquées à la section précédente et nous les répétons ici pour plus de commodité :

$${\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)$$

La moyenne mobile exponentielle (également appelée lissage exponentiel) est une étape de post-traitement facultative appliquée aux pondérations mises à jour, qui peut aboutir à des gains de performances notables. Inception  v3 bénéficie grandement de cette étape supplémentaire. TensorFlow fournit la fonction tf.train.ExponentialMovingAverage qui calcule la moyenne mobile exponentielle \({\hat{\theta}}\) de la pondération \({\theta}\) à l'aide de la formule suivante :

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

où \({\alpha}\) est un facteur de décroissance (proche de 1,0). Dans le cas d'Inception v3, la valeur définie pour \({\alpha}\) est de 0,995.

Même s'il s'agit d'un filtre à réponse impulsionnelle infinie (infinite impulse response ou IIR), le facteur de décroissance définit une fenêtre efficace dans laquelle réside la majeure partie de l'énergie (ou des échantillons pertinents), comme illustré dans le diagramme suivant :

image

Pour voir cela plus clairement, nous réécrivons l'équation du filtre comme suit :

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

où nous avons utilisé \({\hat\theta_{-1}}=0\).

Les valeurs \({\alpha}^k\) décroissent à mesure que k augmente. Ainsi, seul un sous-ensemble des échantillons aura une influence notable sur \(\hat{\theta}_{t+T+1}\). La règle empirique pour la durée de cette fenêtre est la suivante : \(\frac {1} {1-\alpha}\), ce qui correspond à \({\alpha}\) = 200 pour =0,995.

Nous obtenons d'abord un ensemble de variables entraînables, puis nous utilisons la méthode apply() afin de créer des variables ombrées pour chaque variable entraînée, et d'ajouter les opérations correspondantes pour conserver les moyennes mobiles de ces opérations dans leurs copies ombrées). Un extrait de code mettant cela en œuvre dans Inception v3 est présenté ci-dessous :

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)

Nous aimerions utiliser les variables de moyenne mobile exponentielle lors de l'évaluation. Pour ce faire, nous définissons la classe LoadEMAHook qui applique la méthode variables_to_restore() au fichier de point de contrôle à évaluer à l'aide des noms de variables ombrées :

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)

La fonction hooks est transmise à evaluate(), comme indiqué dans l'extrait ci-dessous :

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)

Normalisation par lots

La normalisation par lots est une technique couramment utilisée pour normaliser les caractéristiques en entrée des modèles, ce qui peut réduire considérablement le temps de convergence. Il s’agit de l’une des améliorations algorithmiques les plus populaires et les plus utiles en matière de machine learning de ces dernières années. Elle est utilisée dans un large éventail de modèles, tels qu'Inception v3.

Les entrées d'activation sont d'abord normalisées en soustrayant la moyenne de lot, puis en divisant par l'écart type du lot, mais la normalisation par lots va plus loin que cela. Pour préserver l'équilibre lorsqu'il y a rétropropagation, deux paramètres pouvant être entraînés sont introduits au niveau de chaque couche. Les sorties normalisées \({\hat{x}}\) subissent une opération ultérieure \({\gamma\hat{x}}+\beta\), où \({\gamma}\) et \({\beta}\) sont respectivement une sorte d'écart type et de moyenne, mais apprises par le modèle lui-même.

L'ensemble complet des équations est disponible dans l'article et nous le reproduisons ici pour plus de commodité :

Entrée  : Valeurs de x sur un mini-lot : \(\Phi=\) { \({x_{1..m}\\} \) } Paramètres à apprendre : \({\gamma}\),\({\beta}\)

Sortie : { \({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)\]

La normalisation se passe bien pendant l'entraînement, mais au moment de l'évaluation, nous aimerions que le modèle se comporte de manière déterministe : le résultat de la classification d'une image ne devrait dépendre que de l'image d'entrée et non de l'ensemble des images transmises au modèle. Ainsi, nous devons corriger \({\mu}\) et \({\sigma}^2\), qui doivent représenter les statistiques relatives à l'ensemble d'images.

Pour ce faire, le modèle calcule les moyennes mobiles de la moyenne et de la variance sur les mini-lots :

\[{\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}\]

Dans le cas particulier d'Inception v3, nous avions obtenu (via un réglage d'hyperparamètres) un facteur de décroissance raisonnable pour une utilisation sur GPU. Nous aimerions utiliser cette valeur également sur TPU, mais pour cela, nous devons procéder à des ajustements.

La moyenne mobile et la variance de la normalisation par lots sont toutes les deux calculées à l'aide d'un filtre passe-bas, dont l'équation canonique est présentée ci-dessous (ici, \({y_t}\) représente la moyenne ou la variance mobiles) :

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

(1)

Dans une tâche GPU 8x1 (synchrone), chaque instance dupliquée lit la moyenne mobile courante et la met à jour. Les mises à jour sont séquentielles, en ce sens que la nouvelle variable mobile doit d'abord être écrite par l'instance dupliquée actuelle avant de pouvoir être lue par la suivante.

Lorsqu'il y a huit instances dupliquées, le jeu d'opérations requis pour une mise à jour d'ensemble est le suivant :

\[{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}} \]

Cet ensemble de huit mises à jour séquentielles peut être écrit synthétiquement sous la forme :

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

(2)

Dans la mise en œuvre actuelle du calcul du moment mobile sur les TPU, chaque partition effectue les calculs de manière indépendante et il n'y a pas de communication entre partitions. Les lots sont distribués à chaque partition et chacune traite 1/8e du nombre total de lots (lorsqu'il y a huit partitions).

Bien que chaque partition fonctionne machinalement et calcule les moments mobiles (c'est-à-dire la moyenne et la variance), seuls les résultats de la partition 0 sont communiqués au processeur hôte. Donc, dans les faits, une seule instance dupliquée effectue la mise à jour de la moyenne/variance mobile :

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

(3)

et cette mise à jour se produit à un taux huit fois inférieur à son équivalent séquentiel.

Pour comparer les équations de mise à jour GPU et TPU, nous devons aligner les échelles de temps respectives. Plus précisément, l'ensemble d'opérations comprenant un ensemble de huit mises à jour séquentielles sur GPU doit être comparé à une seule mise à jour sur TPU. Cette approche est illustrée dans le diagramme ci-dessous :

image

Voyons les équations avec les index temporels modifiés :

\[{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) \]

Si nous faisons l'hypothèse simplificatrice que les huit mini-lots (normalisés pour toutes les dimensions pertinentes) génèrent chacun des valeurs similaires durant la mise à jour séquentielle des huit mini-lots sur GPU, nous pouvons approximer ces équations de la manière suivante :

\[{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) \]

De ce fait, pour correspondre à l'effet d'un facteur de décroissance donné sur GPU, nous devons modifier le facteur de décroissance sur TPU en conséquence. Plus précisément, nous devons définir \({\beta}\)=\({\alpha}^8\).

Pour Inception v3, la valeur de décroissance utilisée sur GPU est de \({\alpha}\)=0,9997, ce qui se traduit par une valeur de décroissance sur TPU de \({\beta}\)=0,9976.

Adaptation du taux d'apprentissage

À mesure que la taille des lots augmente, l'entraînement devient plus difficile. Différentes techniques continuent d'être proposées pour permettre un entraînement efficace pour les lots de grande taille (consultez les exemples ici, ici et ici).

L’une des techniques mentionnées, à savoir l’accélération progressive du taux d’apprentissage, a été utilisée pour entraîner le modèle avec une justesse supérieure à 78,1 % pour des lots de taille comprise entre 4 096 et 16 384. Dans le cas d'Inception v3, le taux d’apprentissage est initialement établi à environ 10 % de ce que serait un taux d’apprentissage initial normal. Le taux d’apprentissage reste constant à cette valeur basse pour un (faible) nombre spécifié d'époques dites "froides". Une augmentation linéaire commence ensuite pour un nombre spécifié d'époques "de réchauffement". À la fin de ce cycle, le taux d'apprentissage croise la valeur qu'il aurait atteinte si nous avions utilisé une décroissance exponentielle normale. Vous en trouverez l'illustration dans la figure ci-dessous.

image

La section de code réalisant cette opération est visible dans l'extrait ci-dessous :

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