Introdução à AdaNet

AdaNet (Adaptive Structural Learning of Artificial Neural Networks) foi anunciada pelo Google em seu blog como uma nova framework de AutoML. Ela é desenvolvida com Tensorflow e seu objetivo é criar ensembles de redes em busca de uma melhor performance.

AdaNet

A AdaNet utiliza o conceito de ensemble, isto é, o modelo gerado é um conjunto composto por outros mais simples. Isso torna o modelo final mais complexo, mas pode garantir uma melhor acurácia.

Para gerar o ensemble, a cada iteração o algoritmo testa as redes candidatas e avalia qual delas produz a menor perda, que então é adicionada ao ensemble. A arquitetura de cada uma das redes candidatas deve ser definida pelo usuário.

Exemplo das iterações da AdaNet. A cada iteração é gerado um conjunto de redes candidatas, a que gera a menor perda é selecionada para fazer parte do ensemble. Fonte: https://ai.googleblog.com/2018/10/introducing-adanet-fast-and-flexible.html

A fim de evitar que o ensemble gerado fique enviesado para os dados de treinamento, o algoritmo tenta manter um balanço entre a redução do erro e a capacidade de generalização do modelo. Para isso, podemos estabelecer qual a complexidade de cada uma das sub-redes e, ainda, utilizar um dos hiperparâmetros da AdaNet para penalizar essas.

Após essa breve descrição sobre o funcionamento da AdaNet, vamos construir um modelo utilizando essa framework.

AdaNet na prática

Neste exemplo vamos utilizar o conjunto de dados do CIFAR-10 e o objetivo será realizar a classificação das imagens em cada uma das 10 classes possíveis.

Como dito anteriormente, a AdaNet é constituída utilizando o Tensorflow, portanto nossa dependências são:

  • tensorflow[-gpu];
  • adanet.

A implementação é feita sobre a API Estimators do Tensorflow, por esse motivo nosso caso base (para comparação) vai utilizar essa API.

Dataset

Os dados do CIFAR-10 podem ser baixados utilizando a função do Keras:

import tensorflow as tf
(x_train, labels_train), (x_test, labels_test) =
tf.keras.datasets.cifar10.load_data()

O dataset é composto de 50.000 imagens com dimensões (32, 32, 3) para treinamento e 10.000 para testes.

Vamos normalizar as imagens, mantendo os valores entre 0 e 1.

x_train = x_train / 255 # map values between 0 and 1
x_test = x_test / 255 # map values between 0 and 1

x_train = x_train.astype(np.float32) # cast values to float32
x_test = x_test.astype(np.float32) # cast values to float32

labels_train = labels_train.astype(np.int32) # cast values to int32
labels_test = labels_test.astype(np.int32) # cast values to int32

Como estamos utilizando a API Estimators, as entradas devem ser providenciadas como funções. A maneira mais simples de transformar nosso dataset nessas funções é utilizando a função tf.estimator.inputs.numpy_input_fn, mas existem outras formas de gerar essas funções.

EPOCHS = 10
BATCH_SIZE = 32
train_input_fn = tf.estimator.inputs.numpy_input_fn(
x={"x": x_train},
y=labels_train,
batch_size=BATCH_SIZE,
num_epochs=EPOCHS,
shuffle=False)

adanet_input_fn = tf.estimator.inputs.numpy_input_fn(
x={"x": x_train},
y=labels_train,
batch_size=BATCH_SIZE,
num_epochs=1,
shuffle=False)

test_input_fn = tf.estimator.inputs.numpy_input_fn(
x={"x": x_test},
y=labels_test,
batch_size=BATCH_SIZE,
num_epochs=1,
shuffle=False)

Agora podemos definir a arquitetura da nossa rede.

Modelo base

Nosso modelo base é uma CNN (Convolutional Neural Network) composta de:

  • uma camada convolucional com 32 filtros de dimensões (7, 7, 3) e ativação ReLU;
  • uma camada de Max Pooling que vai reduzir as dimensões da entrada pela metade;
  • uma camada Flatten;
  • uma camada fully connected com 100 neurônios e ativação ReLU;
  • e uma camada fully connected com 10 unidades e ativação Softmax.
from tensorflow.keras.layers import Conv2D, MaxPooling2D, Flatten,
Dense
def cnn_model(features, labels, mode, params):
images = list(features.values())[0] # get values from dict

x = tf.keras.layers.Conv2D(32,
kernel_size=7,
activation='relu')(images)
x = tf.keras.layers.MaxPooling2D(strides=2)(x)
x = tf.keras.layers.Flatten()(x)
x = tf.keras.layers.Dense(100, activation='relu')(x)
logits = tf.keras.layers.Dense(10)(x)
...

Vamos instanciar nosso Estimator e iniciar o treinamento:

classifier = tf.estimator.Estimator(model_fn=cnn_model)
results, _ = tf.estimator.train_and_evaluate(
classifier,
train_spec=tf.estimator.TrainSpec(
input_fn=train_input_fn,
max_steps=MAX_STEPS),
eval_spec=tf.estimator.EvalSpec(
input_fn=test_input_fn,
steps=None))
print("Accuracy:", results["accuracy"])
print("Loss:", results["loss"])

O resultado desse treinamento é uma acurácia de ~37,15% no conjunto de teste e uma perda de ~4,91.

AdaNet ensemble

Agora vamos criar um modelo utilizando a AdaNet, para isso precisamos estender duas classes abstratas:

  • adanet.subnetwork.Builder;
  • adanet.subnetwork.Generator.

A classe Generator cria o conjunto de redes candidatas para o ensemble utilizado o Builder.

class CNNBuilder(adanet.subnetwork.Builder):
def __init__(self, n_convs):
self._n_convs = n_convs

def build_subnetwork(self,
features,
logits_dimension,
training,
iteration_step,
summary,
previous_ensemble=None):
"""See `adanet.subnetwork.Builder`."""

images = list(features.values())[0]
x = images

for i in range(self._n_convs):
x = Conv2D(32, kernel_size=7, activation='relu')(x)
x = MaxPooling2D(strides=2)(x)

x = Flatten()(x)
x = Dense(100, activation='relu')(x)

logits = Dense(10)(x)

complexity = tf.constant(1)

persisted_tensors = {'n_convs': tf.constant(self._n_convs)}

return adanet.Subnetwork(
last_layer=x,
logits=logits,
complexity=complexity,
persisted_tensors=persisted_tensors)

def build_subnetwork_train_op(self,
subnetwork,
loss,
var_list,
labels,
iteration_step,
summary,
previous_ensemble=None):
"""See `adanet.subnetwork.Builder`."""

optimizer = tf.train.RMSPropOptimizer(learning_rate=0.001,
decay=0.0)
# NOTE: The `adanet.Estimator` increments the global step.
return optimizer.minimize(loss=loss, var_list=var_list)

def build_mixture_weights_train_op(self,
loss,
var_list,
logits,
labels,
iteration_step, summary):
"""See `adanet.subnetwork.Builder`."""
return tf.no_op("mixture_weights_train_op")

@property
def name(self):
"""See `adanet.subnetwork.Builder`."""
return f'cnn_{self._n_convs}'

O método para criar a sub-rede tem um parâmetro chamado complexity que é passado para a adanet.Subnetwork().Essa variável é utilizada pelo algoritmo para fazer o balanço entre a complexidade da rede e a redução causada na perda.

A classe builder tem um método que calcula pesos para cada sub-rede do ensemble, assim o modelo pode ter influência maior de determinada rede. Aqui esses pesos são iguais para todas as redes.

Por fim, a propriedade name representa o nome dessa sub-rede, que nesse caso é composto por "cnn_"+nº convoluções.

O código abaixo implementa a classe Generator`:

class CNNGenerator(adanet.subnetwork.Generator):
    def __init__(self):
self._cnn_builder_fn = CNNBuilder
    def generate_candidates(self,
previous_ensemble,
iteration_number,
previous_ensemble_reports,
all_reports):

n_convs = 0
if previous_ensemble:
n_convs = tf.contrib.util.constant_value(
previous_ensemble.weighted_subnetworks[-1]
.subnetwork
.persisted_tensors['n_convs'])
        return [
self._cnn_builder_fn(n_convs=n_convs),
self._cnn_builder_fn(n_convs=n_convs + 1)
]

A função generate_candidates retorna o conjunto de redes candidatas a cada iteração. Neste exemplo, são duas redes com a diferença no número de convoluções em cada uma delas. Na primeira iteração (0) temos o caso particular onde n_conv = 0, ou seja, não há convoluções.

Neste exemplo, usamos três iterações da AdaNet, isso significa que serão avaliados três conjuntos com duas redes cada.

head = tf.contrib.estimator.multi_class_head(10)
estimator = adanet.Estimator(
head=head,
subnetwork_generator=CNNGenerator(),
max_iteration_steps=max_iteration_steps,
evaluator=adanet.Evaluator(
input_fn=adanet_input_fn,
steps=None),
adanet_loss_decay=.99)

A variável head representa a ativação Softmax sobre a saída do ensemble.

Depois de treinado, esse modelo alcançou uma acurácia de ~41,56% com uma perda de 1,79.

results, _ = tf.estimator.train_and_evaluate(
estimator,
train_spec=tf.estimator.TrainSpec(
input_fn=train_input_fn,
max_steps=MAX_STEPS),
eval_spec=tf.estimator.EvalSpec(
input_fn=test_input_fn,
steps=None))
print("Accuracy:", results["accuracy"])
print("Loss:", results["average_loss"])
print(ensemble_architecture(results))

A arquitetura final do modelo é composta por:

  • uma rede sem convoluções;
  • duas redes com uma convolução.

Conclusão

Neste artigo criamos um modelo utilizando a framework AdaNet que conseguiu uma melhor performance do que uma CNN simples. Essa framework é uma boa solução em busca de modelos não triviais com menor necessidade de um conhecimento extenso do usuário sobre arquiteturas dos modelos.

Um ponto negativo, é a utilização da API Estimators que é bem menos intuitiva que o Keras, por exemplo. Mas por ser tratar de um projeto muito novo, podemos esperar um grande desenvolvimento em suas funcionalidades e interface.