Encontrando as chaves do Wally com uma ConvNet

Redes Neurais Convolucionais (também chamadas de Convolutional Neural Networks, CNN ou ConvNets) são aquelas em que os dados de uma camada são “escaneados” por diversas matrizes menores (chamadas de filtros, kernels ou neurônios). Cada filtro realiza várias multiplicações elemento a elemento com os dados da camada que ao final geram uma nova matriz, chamada de mapa de características (feature map). A união desses mapas forma uma nova camada da rede que pode, então, passar pelo mesmo processo.

Em azul, a matriz dos dados de entrada. Em verde, o filtro. Em vermelho, o feature map. Fonte: https://towardsdatascience.com/applied-deep-learning-part-4-convolutional-neural-networks-584bc134c1e2

O processo de treinamento de uma ConvNet tem o objetivo de obter os valores dos filtros que melhor desempenho dão a rede na tarefa em que ela está sendo empregada.

Em 2012, Alex Krizhevsky, Ilya Sutskever e Geoffrey E. Hinton utilizaram uma ConvNet (“AlexNet”) na ImageNet Large Scale Visual Recognition Challenge, famosa competição de visão computacional, e mudaram a História. Seu modelo superou em muito o desempenho dos vencedores anteriores e, a partir de então, ConvNets passaram a ser o modelo padrão aplicado em reconhecimento de imagens.

O sucesso das ConvNets na visão computacional se dá pelo fato de que, enquanto em uma rede neural tradicional os dados de entrada são tratados de forma isolada, os filtros de uma ConvNet permitem que características espaciais da imagem sejam abstraídas e levadas em consideração no treinamento. A profundidade da rede aumenta a complexidade dessa abstração. Por exemplo, as primeiras camadas de uma ConvNet identificam linhas e curvas. A próximas camadas identificam quadrados e círculos. As últimas camadas identificam rostos e objetos.

Fonte: https://www.nature.com/news/computer-science-the-learning-machines-1.14481

Maiores detalhes sobre ConvNets podem ser encontrados na excelente trilogia de artigos A Beginner’s Guide To Understanding Convolutional Neural Networks. Em português, recomendo Entendendo Redes Convolucionais (CNNs).

Construindo minha primeira ConvNet

Resolvi colocar em prática uma ConvNet para localizar as chaves do livro-jogo Onde está Wally? Essa série de livros, ilustrados por Martin Handford, é composta por diversos desenhos de cenários caóticos onde você deve achar um personagem perdido (o tal Wally). Um exemplo de um dos cenários dos livros pode ser visualizado na figura abaixo:

Sim, o Wally está aí em algum lugar…

Encontrar o Wally com técnicas de Machine Learning já foi descrito em vários artigos. Então, alterei a tarefa para um problema ainda mais difícil para humanos: achar uma pequena chave que também está perdida em cada um dos cenários dos livros (Handford desenhou diversos outros objetos e personagens além do Wally como missões paralelas).

No cenário que dei como exemplo acima, a chave é a seguinte:

Boa sorte tentando encontrar essa chave.

Os dados

A minha ideia era dividir cada cenário em pequenos quadrados e, então, passar esses quadrados para uma ConvNet que deveria responder se ali existia uma chave ou não. Eu precisaria então do maior número de cenários possíveis, recortá-los, anotar as imagens com chave e sem chave e passar as imagens resultantes para o treinamento da rede.

Para minha felicidade, algum bom samaritano escaneou e distribui todas as páginas de todos os livros (com as respostas!) em uma galeria do DevianArt, o que facilitou muito o meu trabalho. Eu apenas precisei fazer os recortes, tomando cuidado para que as divisões não cortassem alguma parte da chave. Seguem alguns exemplos dos “pedaços” resultantes.

A segunda e a sexta imagem contem uma chave. As demais não.

Uma importante observação é que os dados sofrem um grave problema de desbalanceamento. Existem muito mais figuras sem chave do que com chave, o que faz com que seja muito difícil treinar um modelo de classificação. Nessas condições, qualquer modelo que preveja que todas as imagens não possuem uma chave terá uma boa acurácia.

Para resolver esse problema, recorri a técnicas de aumentação de dados (data augmentation) que permitem que se crie variações artificiais de dados. No meu caso, eu criei várias versões rotacionadas de todas as chaves, aumentando em 338 vezes a quantidade de imagens com chave disponíveis.

Exemplo de múltiplas versões de uma mesma chave obtidas por rotacionamento

A ConvNet

Utilizando Python 3.7, TensorFlow, Keras e Ergo, é bem fácil criar uma ConvNet.

Uma vez instalados os pré-requisitos citados, ao executar ergo create where-is-the-key, é criada uma pasta com três arquivos: prepare.py, model.py e train.py.

Em prepare.py, eu acrescentei o código de preparação dos dados que ficou assim:

import pandas as pd
import logging as log
import glob
import random
import numpy as np
import os.path as path
from scipy import misc


# this function is called whenever the
# 'ergo train <project> --dataset folder' command is executed
# the first argument is the dataset and it must return
# a pandas.DataFrame object.
def prepare_dataset(folder):
log.info("loading images from %s ...", folder)

# read all images paths into lists
# discard 85% of non-keys at random
# using the whole dataset can freeze your computer
    # images are in subfolders of the input data folder
all_paths = glob.glob(path.join(folder, '*/*.jpg'))
    # filename with 'k' means the image contains a key
keys_paths = glob.glob(path.join(folder, '*/*k*.jpg'))
non_keys_paths = [p for p in all_paths if p not in keys_paths]

discarded = random.sample(
range(len(non_keys_paths)),
int(0.85 * len(non_keys_paths)))

for index in sorted(discarded, reverse=True):
del non_keys_paths[index]

paths = list(set().union(keys_paths,non_keys_paths))

# read all chosen images into an array
images = [misc.imread(path) for path in paths]
images = np.asarray(images)
n_images = images.shape[0]

# normalize pixels to [0.0, 1.0]
images = images / 255

log.info("vectorializing %d samples ...", n_images)
    # get label from filename
# filename with 'k' means the image contains a key
labels = np.zeros(n_images)
for i in range(n_images):
filename = path.basename(paths[i])

if 'k' in filename:
labels[i] = 1

# create the flattened training matrix
dataset = []
for i in range(n_images):
X = images[i].flatten()
y = labels[i]
dataset.append(np.insert(X, 0, y, axis=0)) # label first

return pd.DataFrame(dataset)

Vamos agora ao modelo propriamente dito. No Keras, redes neurais são construídas encaixando-se as camadas uma na outra, como se fosse um Lego. Eu me baseei inicialmente no código usado no projeto ergo-planes-detector, porém adicionei mais camadas em busca de melhores resultados. O resultado final pode ser visto em model.py:

from keras.models import Sequential
from keras.layers import Dense, Dropout, Conv2D, MaxPooling2D, Reshape, Flatten

def build_model(is_train):
inputshape = (4800,)
imgshape = (40, 40, 3)
kernel = (3, 3)
pooling = (2, 2)

model = Sequential()
# our input vector is flat, but Conv2D wants a 3d
# shaped tensor (width, height, depth), reshape it.
model.add( Reshape(imgshape, input_shape=inputshape) )

# add some convolutional filters
model.add( Conv2D(32, kernel, activation='relu') )
model.add( Conv2D(64, kernel, activation='relu') )

# downsample from the convolutional filters
model.add( MaxPooling2D(pool_size=pooling) )

# add some convolutional filters
model.add( Conv2D(128, kernel, activation='relu') )
model.add( Conv2D(256, kernel, activation='relu') )

# downsample from the convolutional filters
model.add( MaxPooling2D(pool_size=pooling) )

# flatten results for the next dense layer
model.add( Flatten() )
# this layer is gonna learn how to classify planes
# according to the inputs that the convolutional
# and dropout layers activate.
model.add( Dense(512, activation='relu') )
# avoid overfitting
model.add( Dropout(0.5) )
# the output layer
model.add( Dense(2, activation='softmax') )

return model

Através desse procedimento, é criada uma ConvNet com o seguinte formato:

A camada Reshape apenas altera o formato dos dados de entrada para o formato esperado pela rede.

As camadas Conv2D são as que fazem as operações matriciais que eu citei no começo do artigo. São elas que percorrerão os filtros nas camadas anteriores em busca de abstrações úteis para a classificação do que é e não é uma chave.

As camadas MaxPooling2D aplicam uma técnica chamada pooling, que nada mais é do que uma maneira sistemática de descartar as ativações dos filtros que menos contribuem para rede. Esse processo torna o modelo mais simples e mais fácil de ser treinado. Também auxilia a incrementar sua robustez, tornando-o menos suscetível ao overfitting.

As últimas camadas (Flatten, Dense e Dropout) fazem parte de uma rede neural tradicional (Fully Connected Neural Network). Na criação de ConvNets, é comum conectar ao final da rede uma rede neural tradicional para terminar o trabalho de classificação.

No nosso caso, a camada Flatten prepara os dados para serem processados, de forma semelhante ao que foi feito na camada Reshape. Dense representa as camadas de neurônios completamente conectadas. Finalmente, a camada Dropout remove aleatoriamente algumas ativações para evitar overfitting.

O código em train.py, utilizado para o treinamento, é exatamente igual ao utilizado no ergo-planes-detector. Esse arquivo contém o seguinte código:

import time
import os
import logging as log

from keras.callbacks import EarlyStopping, TensorBoard

def train_model(model, dataset):
log.info("training model (train on %d samples, validate on %d) ..." % ( \
len(dataset.Y_train),
len(dataset.Y_val) ) )

loss = 'binary_crossentropy'
optimizer = 'adam'
metrics = ['accuracy']

model.compile(loss = loss, optimizer = optimizer, metrics = metrics)

earlyStop = EarlyStopping(monitor = 'val_acc', min_delta=0.0001, patience = 5, mode = 'auto')

log_dir = os.path.join(dataset.path, "logs/{}".format(time.time()))
tensorboard = TensorBoard( \
log_dir = log_dir,
histogram_freq = 1,
write_graph = True,
write_grads = True,
write_images = True)

tensorboard.set_model(model)

return model.fit( dataset.X_train, dataset.Y_train,
batch_size = 64,
epochs = 50,
verbose = 2,
validation_data = (dataset.X_val, dataset.Y_val),
callbacks = [tensorboard, earlyStop])

Todos esses códigos e imagens estão no repositório GitHub where-is-the-key.

Resultados

Após o treinamento, que levou por volta de 30 minutos em uma Nvidia GTX 1080 Ti, o Ergo retornou os seguintes resultados:

Training --------------------------------------------
precision recall f1-score support

0 1.00 1.00 1.00 23789
1 1.00 1.00 1.00 12040

micro avg 1.00 1.00 1.00 35829
macro avg 1.00 1.00 1.00 35829
weighted avg 1.00 1.00 1.00 35829


confusion matrix:

[[23787 2]
[ 3 12037]]

Validation ------------------------------------------
precision recall f1-score support

0 1.00 1.00 1.00 5030
1 0.99 1.00 1.00 2647

micro avg 1.00 1.00 1.00 7677
macro avg 1.00 1.00 1.00 7677
weighted avg 1.00 1.00 1.00 7677


confusion matrix:

[[5011 19]
[ 1 2646]]

Test ------------------------------------------------
precision recall f1-score support

0 1.00 1.00 1.00 5085
1 0.99 1.00 1.00 2592

micro avg 1.00 1.00 1.00 7677
macro avg 1.00 1.00 1.00 7677
weighted avg 1.00 1.00 1.00 7677


confusion matrix:

[[5060 25]
[ 0 2592]]

Pela matriz de confusão (confusion matrix), podemos ver que, aparentemente, a rede se saiu muito bem. No conjunto de dados de teste, não houve nenhum falso negativo e apenas um pequeno número de falsos positivos.

Porém, é importante olhar esses números com cuidado. Apesar da pequena quantidade de falso positivos (< 1%), em qualquer cenário do livro será necessário classificar milhares de imagens para achar a chave, o que significa que é possível que o modelo retorne mais de uma chave encontrada por cenário. Isso não é o ideal.

Além disso, a rede foi treinada com imagens com a mesma escala. É bem possível que em cenários com escalas diferentes, o modelo tenha um desempenho muito inferior.

De qualquer forma, esse simples exercício comprova o potencial de aplicação em problemas de visão computacional das ConvNets.