Guia avançado do Inception v3 no Cloud TPU

Neste documento, discutimos alguns aspectos do modelo Inception e como eles se integram para que o modelo seja executado de maneira eficiente no Cloud TPU. Trata-se de uma visão avançada do guia de execução do Inception v3 no Cloud TPU. Também discutimos em mais detalhes as mudanças específicas no modelo que resultaram em melhorias significativas. Este documento complementa o tutorial do Inception v3.

O treinamento de TPU do Inception v3 executa curvas de precisão de correspondência produzidas por jobs de GPU com configuração semelhante. O modelo foi treinado com êxito nas configurações v2-8, v2-128 e v2-512. Ele atingiu uma acurácia superior a 78,1% em cerca de 170 épocas em cada uma das configurações.

Os exemplos de código contidos neste documento são ilustrativos, uma imagem de alto nível do que acontece em uma implementação real. O código de trabalho pode ser encontrado no GitHub.

Introdução

O Inception v3 é um modelo de reconhecimento de imagem amplamente usado que demonstrou atingir uma precisão superior a 78,1% no conjunto de dados ImageNet. Esse modelo é o auge de muitas ideias desenvolvidas por vários pesquisadores ao longo dos anos. Ele é baseado no documento original "Rethinking the Inception Architecture for Computer Vision", de Szegedy e outros autores.

O modelo em si é composto de componentes simétricos e assimétricos, incluindo convoluções, agrupamentos médios, agrupamentos máximos, concatenações, desistências e camadas totalmente conectadas. A normalização em lote é usada extensivamente em todo o modelo e aplicada às entradas de ativação. A perda é calculada por meio da função softmax.

Veja abaixo um diagrama geral do modelo:

imagem

O arquivo README do modelo Inception fornece mais informações sobre a arquitetura.

API Estimator

Para gravar a versão de TPU do Inception v3 é necessário usar a TPUEstimator, uma API projetada para facilitar o desenvolvimento, para que você possa se concentrar nos modelos em vez de nos detalhes do hardware subjacente. A API faz a maior parte do trabalho sujo de baixo nível necessário para executar os modelos nas TPUs em segundo plano, ao mesmo tempo em que automatiza funções comuns, como salvar e restaurar os pontos de verificação.

A API Estimator impõe a separação entre o modelo e as partes relativas à entrada no código. É necessário definir as funções model_fn e input_fn, que correspondem, respectivamente, aos estágios de definição do modelo e canal de entrada/pré-processamento no gráfico do TensorFlow. Veja abaixo um exemplo estrutural dessas funções:

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

Duas funções fundamentais fornecidas pela API são train() e evaluate() usadas, respectivamente, para treinar e avaliar. Elas geralmente são chamadas em algum lugar da função principal. Veja abaixo um exemplo:

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)

Conjunto de dados ImageNet

Antes de usar o modelo para reconhecer imagens, é necessário treiná-lo. Normalmente, fazemos isso por meio do aprendizado supervisionado usando um conjunto grande de imagens rotuladas. O Inception v3 pode ser treinado usando muitos conjuntos diferentes de imagens rotuladas, mas o ImageNet é um conjunto de dados usualmente escolhido para essa tarefa.

O ImageNet tem mais de dez milhões de URLs de imagens rotuladas. Cerca de um milhão de imagens também têm caixas delimitadoras que especificam um local mais preciso para os objetos rotulados.

Para esse modelo, o conjunto de dados ImageNet é composto de 1.331.167 imagens que são divididas em conjuntos de dados de treinamento e avaliação contendo, respectivamente, 1.281.167 e 50.000 imagens.

Os conjuntos de dados de treinamento e avaliação são intencionalmente mantidos separados. Usamos as imagens do conjunto de dados de treinamento para treinar o modelo e as do conjunto de dados de avaliação para avaliar a precisão dele.

Para o modelo, as imagens devem ser armazenadas como TFRecords. Para converter imagens em arquivos JPEG brutos em TFRecords, use o script de lote de código aberto: download_and_preprocess_imagenet.sh. O script produz uma série de arquivos, tanto para treinamento quanto para validação, neste formato:

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

em que DATA_DIR é o local onde o conjunto reside (por exemplo, DATA_DIR=$HOME/imagenet-data).

Consulte as instruções detalhadas sobre como compilar e executar esse script na seção "Getting Started" do arquivo README do modelo Inception.

Canal de entrada

Cada dispositivo do Cloud TPU tem oito núcleos e está conectado a um host (CPU). As partes maiores têm vários hosts. Outras configurações maiores interagem com vários hosts. Por exemplo, v2-256 se comunica com 16 hosts.

Os hosts recuperam os dados do sistema de arquivos ou da memória local, fazem operações necessárias de pré-processamento de dados e, por fim, transferem os dados pré-processados para os núcleos da TPU. Essas três fases de gerenciamento de dados realizado pelo host são consideradas individualmente e recebem os nomes de: 1) armazenamento, 2) pré-processamento e 3) transferência. Veja na figura abaixo o diagrama geral das três fases:

imagem

Para que o desempenho seja bom, o sistema deve ser balanceado. Seja qual for o tempo que uma CPU host gasta para recuperar imagens, decodificá-las e realizar o pré-processamento necessário, o ideal é que esse tempo seja quase o mesmo ou um pouco abaixo do tempo gasto pela TPU nas operações computacionais. Se a CPU host demorar mais do que a TPU para concluir as três fases de gerenciamento de dados, a execução será limitada pelo host. (Observação: isso pode ser inevitável em modelos muito simples porque as TPUs são muito rápidas.) Ambos os casos são mostrados no diagrama abaixo.

imagem

A implementação atual do Inception v3 está na fronteira de ser limitada pela entrada. As imagens precisam ser recuperadas do sistema de arquivos, decodificadas e pré-processadas. Há tipos diferentes de estágios de pré-processamento disponíveis, de moderado a complexo. Se usarmos o mais complexo dos estágios de pré-processamento, o grande número de operações pesadas executadas fará com que o sistema ultrapasse essa fronteira e o canal de treinamento passará a ser limitado pelo pré-processamento. No entanto, não é necessário recorrer a esse nível de complexidade para conseguir uma precisão superior a 78,1%. Em vez disso, usamos um estágio de pré-processamento com complexidade moderada que inclina a balança na outra direção e mantém o modelo limitado pela TPU. Discutiremos isso em mais detalhes na próxima seção.

O modelo usa tf.data.Dataset para realizar todas as tarefas necessárias relacionadas ao canal de entrada. Consulte o guia de desempenho de conjuntos de dados para saber mais sobre como otimizar os canais de entrada usando a API tf.data.

É possível simplesmente definir uma função e transmiti-la à API Estimator. No entanto, no caso do modelo Inception, a classe de criação InputPipeline já contém toda a funcionalidade necessária e define um método __call__.

A API Estimator simplifica muito o uso dessa classe. Basta transmiti-la ao parâmetro input_fn das funções train() e evaluate(), conforme mostrado no snippet de código exemplificado abaixo:

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)

Os principais elementos da classe InputPipeline são mostrados no snippet de código abaixo, em que destacamos as três fases com cores diferentes. O método __call__ cria o conjunto de dados usando tf.data.Dataset e, em seguida, faz uma série de chamadas de API para utilizar os recursos internos de pré-busca, intercalação e embaralhamento do conjunto de dados.

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

A seção de armazenamento começa com a criação de um conjunto de dados e inclui a leitura dos TFRecords no armazenamento (usando tf.data.TFRecordDataset). As funções com finalidade especial repeat() e shuffle() são usadas conforme necessário. A função tf.contrib.data.parallel_interleave() mapeia a função prefetch_dataset() em toda a entrada para produzir conjuntos de dados aninhados e gera elementos intercalados. Ela coleta os elementos dos conjuntos de dados aninhados cycle_length em paralelo, o que aumenta a produtividade. O argumento sloppy relaxa a exigência de que as saídas sejam produzidas em uma ordem determinística e permite que a implementação pule os conjuntos de dados aninhados com elementos que não estão prontamente disponíveis quando solicitados.

A seção de pré-processamento chama dataset.map(parser) que, por sua vez, chama a função analisadora em que as imagens são pré-processadas. Discutiremos os detalhes do estágio de pré-processamento na próxima seção.

A seção de transferência, no final da função, inclui a linha return images, labels. A TPUEstimator pega os valores retornados e os transfere automaticamente para o dispositivo.

A figura abaixo mostra um exemplo de rastreamento do desempenho do Cloud TPU para o Inception v3. O tempo de computação da TPU, descontado de qualquer atraso na alimentação, atualmente está em cerca de 815 milésimos de segundo.

imagem

O armazenamento no host também é exibido no rastreamento, conforme mostrado abaixo:

imagem

O pré-processamento no host, que inclui a decodificação das imagens e uma série de funções de distorção de imagem, é mostrado abaixo:

imagem

A transferência entre o host e a TPU pode ser vista aqui:

imagem

Estágio de pré-processamento

O pré-processamento de imagens é uma parte essencial do sistema e pode influenciar profundamente a acurácia máxima atingida pelo modelo durante o treinamento. No mínimo, as imagens precisam ser decodificadas e redimensionadas para se ajustarem ao modelo. No caso do Inception, as imagens precisam ter 299 x 299 x 3 pixels.

No entanto, apenas decodificar e redimensionar não é o suficiente para conseguir uma boa precisão. O conjunto de dados de treinamento ImageNet contém 1.281.167 imagens. Uma passagem pelo conjunto de imagens de treinamento é chamada de uma época. Durante o treinamento, o modelo exigirá diversas passagens pelo conjunto de dados de treinamento para melhorar a capacidade de reconhecer imagens. No caso do Inception v3, o número de épocas necessárias está em algum ponto no intervalo entre 140 e 200, dependendo do tamanho de lote global.

É extremamente benéfico alterar continuamente as imagens antes de alimentá-las ao modelo. Isso deve ser feito de tal maneira que uma determinada imagem seja ligeiramente diferente em cada época. A melhor maneira de fazer esse pré-processamento de imagens pode ser considerada tanto arte quanto ciência. Por um lado, um estágio de pré-processamento bem projetado pode aumentar significativamente a capacidade de reconhecimento de um modelo. Por outro lado, um estágio de pré-processamento muito simples pode criar um teto artificial de acurácia máxima que o mesmo modelo pode atingir durante o treinamento.

O Inception v3 oferece opções diferentes para o estágio de pré-processamento, que variam do relativamente simples e econômico em recursos computacionais ao muito complexo e dispendioso. Você pode encontrar dois tipos distintos desses estágios nos arquivos vgg_preprocessing.py e inception_preprocessing.py.

O arquivo vgg_preprocessing.py define um estágio de pré-processamento que tem sido usado com êxito para treinar resnet em 75% de acurácia. No entanto, essa opção produz resultados abaixo do ideal quando aplicada ao Inception v3.

O arquivo inception_preprocessing.py contém um estágio de pré-processamento de várias opções com níveis diferentes de complexidade que tem sido usado com êxito para treinar o Inception v3 com acurácia entre 78,1% e 78,5% quando executado em TPUs. Nesta seção, discutimos o canal de pré-processamento.

O pré-processamento varia dependendo se o modelo está em treinamento ou é usado para inferência/avaliação.

No momento da avaliação, o pré-processamento é bastante direto: recorte uma região central da imagem e redimensione-a para o tamanho padrão de 299 x 299. Este é o snippet de código que faz isso:

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

Durante o treinamento, o corte é aleatório: uma caixa delimitadora é escolhida aleatoriamente para selecionar uma região da imagem que, em seguida, é redimensionada. Depois, a imagem redimensionada pode opcionalmente ser invertida e ter as cores distorcidas. Este é o snippet de código que faz isso:

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

A função distort_color é responsável pela alteração da cor. Ela oferece um modo rápido em que apenas o brilho e a saturação são modificados. O modo completo modifica o brilho, a saturação e o matiz, além de alterar aleatoriamente a ordem em que cada um desses atributos é modificado. Este é o snippet de código dessa função:

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)

A função distort_color é dispendiosa em termos computacionais, em parte devido às conversões de RGB para HSV e vice-versa não lineares que são necessárias para acessar o matiz e a saturação. Tanto o modo rápido quanto o completo requerem essas conversões. O modo rápido é mais econômico em termos computacionais, mas mesmo assim ele empurra o modelo para a região limitada por computação da CPU quando ativado.

Como alternativa, foi adicionada a função distort_color_fast à lista de opções. Essa função mapeia a imagem de RGB para YCrCb usando o esquema de conversão JPEG e altera aleatoriamente o brilho e os cromas Cr/Cb antes de mapear novamente para RGB. A função é mostrada no snippet de código abaixo:

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

Abaixo, temos um exemplo de imagem que foi submetida ao pré-processamento. Uma região da imagem foi selecionada aleatoriamente e as cores foram alteradas usando a função distort_color_fast.

imagem

A função distort_color_fast é eficiente em termos computacionais e permite que o treinamento seja limitado ao tempo de execução da TPU. Além disso, ela produz bons resultados e tem sido usada com êxito para treinar o modelo Inception v3 em uma precisão superior a 78,1%, usando tamanhos de lote no intervalo entre 1.024 e 16.384. Ela é a opção padrão para o Inception v3.

Otimizador

O modelo atual apresenta três opções de otimizador: SGD, momentum e RMSProp.

O tipo mais simples de atualização é Stochastic gradient descent (SGD): os pesos são empurrados na direção do gradiente negativo. Ainda que simples, esse tipo consegue bons resultados em alguns modelos. A dinâmica de atualização pode ser escrita como:

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

Momentum é um otimizador muito usado que frequentemente resulta em uma convergência mais rápida do que a conseguida pelo SGD. Esse otimizador atualiza pesos muito parecidos, assim como o SGD, mas também adiciona um componente na direção da atualização anterior. A dinâmicas da atualização é dada por:

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

que pode ser escrita como:

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

O último termo é o componente na direção da atualização anterior. Ele é mostrado graficamente na figura abaixo:

imagem

Para o momentum \({\beta}\), utilizamos o valor comumente usado de 0,9.

RMSprop é um otimizador muito usado proposto pela primeira vez por Geoff Hinton em uma de suas palestras. A dinâmica de atualização é dada por:

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

No caso do Inception v3, os testes mostram que o RMSProp gera os melhores resultados em termos de precisão máxima e tempo para atingi-la, com momentum em segundo lugar. Portanto, o RMSprop é configurado como o otimizador padrão. Os parâmetros utilizados são: decay \({\alpha}\) = 0.9, momentum \({\beta}\) = 0.9 e \({\epsilon}\) = 1.0.

Este é o snippet de código com as opções de otimizador:

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)

Quando executado em TPUs e com uso da API Estimator, o otimizador precisa ser encapsulado em uma função CrossShardOptimizer para garantir a sincronização entre as réplicas, além de qualquer comunicação cruzada necessária. Este é o snippet de código em que isso é feito no Inception v3:

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)

Média móvel exponencial

A linha de ação normal durante o treinamento é que os parâmetros treináveis sejam atualizados durante a retropropagação, conforme as regras de atualização do otimizador. Já falamos sobre isso na seção anterior e repetimos aqui para fins de praticidade:

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

A média móvel exponencial, também conhecida como suavização exponencial, é uma etapa de pós-processamento opcional aplicada aos pesos atualizados. Às vezes, ela resulta em melhorias perceptíveis no desempenho. Essa etapa extra é extremamente útil para o Inception v3. O TensorFlow fornece a função tf.train.ExponentialMovingAverage que calcula a média móvel exponencial \({\hat{\theta}}\) do peso \({\theta}\) usando a fórmula:

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

em que \({\alpha}\) é um fator de decaimento (próximo a 1,0). No caso do Inception v3, \({\alpha}\) é definido como 0,995.

Ainda que esse seja um filtro de resposta ao impulso infinita (IIR, na sigla em inglês), o fator de decaimento estabelece uma janela efetiva em que reside a maior parte da energia (ou amostras relevantes), como mostrado no diagrama a seguir:

imagem

Para ver isso mais claramente, reescrevemos a equação do filtro da seguinte maneira:

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

em que usamos\({\hat\theta_{-1}}=0\).

Os valores de \({\alpha}^k\) decaem com o aumento de k. Portanto, apenas um subconjunto de amostras de fato terá influência considerável sobre \(\hat{\theta}_{t+T+1}\). A regra geral para a duração dessa janela é: \(\frac {1} {1-\alpha}\), que corresponde a \({\alpha}\) = 200 para =0.995.

Primeiro, conseguimos uma coleção de variáveis treinável. Em seguida, usamos o método apply() para criar variáveis sombra para cada variável treinável. Também adicionamos as operações correspondentes para manter as médias móveis das variáveis nas cópias sombra. Este é o snippet de código que faz isso no Inception v3:

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)

Queremos usar as variáveis de média móvel exponencial durante a avaliação. Para tanto, definimos a classe LoadEMAHook, que aplica o método variables_to_restore() ao arquivo de ponto de verificação a ser avaliado usando os nomes de variável sombra:

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)

A função hooks é passada para evaluate() conforme mostrado no snippet abaixo:

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)

Normalização em lote

A normalização em lote é uma técnica amplamente usada para normalizar as características de entrada em modelos, podendo resultar na redução substancial do tempo de convergência. Essa é uma das melhorias algorítmicas mais úteis e utilizadas no aprendizado de máquina nos últimos anos. Ela é aplicada a uma ampla gama de modelos, incluindo o Inception v3.

As entradas de ativação são normalizadas primeiro pela subtração da média do lote e dividindo o valor pelo desvio padrão do lote. Mas a normalização em lote faz mais do que isso. Para manter tudo equilibrado em vista da retropropagação, dois parâmetros treináveis são introduzidos em cada camada. As saídas normalizadas \({\hat{x}}\) são submetidas à operação subsequente \({\gamma\hat{x}}+\beta\), em que \({\gamma}\) e \({\beta}\) são um tipo de desvio padrão e média, mas esses valores são aprendidos pelo próprio modelo.

Você pode ver o conjunto completo de equações neste artigo. Para fins de praticidade, também as repetimos aqui:

Entrada: valores de x em um minilote: \(\Phi=\{ {x_{1..m}\} }\) Parâmetros a serem aprendidos: \({\gamma}\),\({\beta}\)

Saída: { \({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)$$

A normalização acontece tranquilamente durante o treinamento. No entanto, na hora da avaliação, queremos que o modelo se comporte de maneira determinística: o resultado da classificação de uma imagem deve depender unicamente da imagem de entrada, e não do conjunto de imagens que estão sendo alimentadas no modelo. Portanto, precisamos corrigir \({\mu}\) e \({\sigma}^2\). Além disso, esses valores precisam representar as estatísticas de população da imagem.

Para tanto, o modelo calcula as médias móveis da média e a variância dos minilotes:

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

No caso específico do Inception v3, um fator de decaimento coerente foi conseguido, por meio do ajuste do hiperparâmetro, para ser usado nas GPUs. Queremos usar esse valor nas TPUs também. Mas para tanto, precisamos fazer alguns ajustes.

A média móvel e a variância da normalização em lote são calculadas usando um filtro de passagem de perda com a equação canônica que é mostrada abaixo, sendo que aqui \({y_t}\) representa a média móvel ou a variância:

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

Em um job em GPU 8x1 (síncrona), cada réplica lê a média móvel atual, atualizando-a em seguida. As atualizações são sequenciais, no sentido de que a nova variável móvel precisa ser escrita primeiro pela réplica atual antes que a próxima a leia.

Quando há oito réplicas, o conjunto de operações para uma atualização combinada é:

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

Esse conjunto de oito atualizações sequenciais pode ser escrito como:

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

Na atual implementação do cálculo do momento móvel em TPUs, cada fragmento executa cálculos de maneira independente, sem comunicação entre os fragmentos. Os lotes são distribuídos para cada fragmento. Cada um deles processa 1/8 do número total de lotes (quando há oito fragmentos).

Cada fragmento executa as tarefas e calcula os momentos móveis, isto é, a média e a variância. Apenas os resultados do fragmento 0 são comunicados para a CPU host. Então, de fato, apenas uma réplica faz a atualização da média móvel/variância:

$${z_t}={\beta {z_{t-1}}}+{(1-\beta)u_t}$$ (3)

e essa atualização ocorre a 1/8 da taxa da contraparte sequencial da réplica.

Para comparar as equações de atualização da GPU e da TPU, precisamos alinhar as respectivas escalas de tempo. Especificamente, o conjunto de operações que compõem um conjunto de oito atualizações sequenciais na GPU deve ser comparado com uma única atualização na TPU. Isso está ilustrado no diagrama abaixo:

imagem

Estas são as equações com os índices de tempo modificados:

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

Se fizermos uma suposição simplista de que os oito minilotes (normalizados em todas as dimensões relevantes) geram valores semelhantes na atualização sequencial de oito minilotes da GPU, podemos aproximar essas equações da seguinte maneira:

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

Portanto, para ter um efeito equivalente àquele do fator de decaimento na GPU, precisamos modificar o fator de decaimento na TPU de maneira correspondente. Especificamente, precisamos definir \({\beta}\)=\({\alpha}^8\).

No caso do modelo Inception v3, o valor de decaimento usado na GPU é \({\alpha}\)=0.9997, que se traduz em um valor de decaimento na TPU de \({\beta}\)=0.9976.

Adaptação da taxa de aprendizado

À medida que os tamanhos dos lotes aumentam, o treinamento fica mais difícil. Diferentes técnicas continuam a ser propostas para proporcionar um treinamento eficiente de lotes grandes (consulte aqui, aqui e aqui, por exemplo).

Uma dessas técnicas é a aceleração gradual da taxa de aprendizado, que foi usada para treinar o modelo com acurácia superior a 78,1% e tamanhos de lotes entre 4.096 e 16.384. No caso do Inception v3, a taxa de aprendizado é definida primeiramente em cerca de 10% do que normalmente seria a taxa inicial. A taxa de aprendizado permanece constante nesse valor baixo para um número especificado (pequeno) de "épocas frias". Em seguida, ela aumenta de maneira linear para um número especificado de "épocas de aquecimento" no final, quando intercepta o seria a taxa de aprendizado se um decaimento exponencial normal fosse usado. Isso é ilustrado na imagem abaixo:

imagem

O trecho do código que faz isso está no snippet abaixo:

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')
Esta página foi útil? Conte sua opinião sobre:

Enviar comentários sobre…