Inteligência Artificial

Aprendizagem com Redes Neuronais

O tópico destas aulas é a construção, treino e validação de redes neuronais, usando o TensorFlow.
Para tal iremos usar o Colab da Google, que nos permite escrever e correr código Python diretamente no browser.

Configurar Colab e importar bibliotecas necessárias

Comece por abrir um novo notebook Colab.
Para isso, basta fazer login na sua conta Google e depois aceder a https://colab.research.google.com
Deve criar um novo notebook. Depois, a primeira coisa a fazer é permitir o acesso ao Google Drive, de modo a que possa facilmente ter acesso a ficheiros que aí se encontrem.
Para tal, coloque o seguinte código:

----
from google.colab import drive
drive.mount('/content/drive')
----		
Quando esta célula é executada (usando o botão no canto superior esquerdo da célula ou usando Ctrl+Enter), abre um dialogo que deve confirmar. Isto monta a Google Drive em /content/drive/My Drive
Basta fazer isto uma vez no início da sessão.

Em todos os passos seguintes, deve usar uma nova célula executável que pode criar usando o botão (+ code).
Sempre que quiser adicionar texto com comentários ao código que vai escrevendo, pode criar células de texto usando o botão (+ text).

O segundo passo consiste em criar uma pasta de trabalho dentro da sua Google Drive onde irá guardar os ficheiros de dados.
Depois de a criar, use o seguinte código, que permite mudar para essa pastar para facilitar o acesso aos ficheiros.
------
%cd "drive/My Drive/caminho-para-a-pasta"
%pwd
-----		

Finalmente, irá importar as bibliotecas de Python necessárias para esta aula.

----
import numpy as np
import tensorflow as tf
from tensorflow import keras
from tensorflow.keras.utils import to_categorical
from tensorflow.keras import layers
import matplotlib.pyplot as plt
import pandas as pd
import random
----		

1) Um exemplo simples de ajustamento

Considere o seguinte ficheiro de dados (curva.txt), que em cada linha tem um par de valores.
Cada par (x,y) indica que ao valor de entrada x corresponde o valor de saída y.
Queremos criar e treinar um modelo que se ajuste a estes dados. Deve colocar este ficheiro na pasta Drive que criou no início (no resto da ficha, deve fazer isto para todos os ficheiros que precisar aceder).
Depois disso, podemos então começar por ler e preparar os dados do ficheiro.

----
mat = np.loadtxt('curva.txt')
xs = mat[:,:1]
ys = mat[:,1:]
----		
Repare que tanto x como y são transformados em colunas.
Vamos agora criar e treinar um modelo que se ajuste aos dados.
Para isso iremos usar o Keras, que é um API de alto nível do TensorFlow. Este permite criar e treinar modelos de aprendizagem profunda.
----
keras.backend.clear_session()
model = keras.Sequential()
model.add(layers.Dense(1, activation='sigmoid'))
opt = keras.optimizers.SGD(momentum = 0.9,learning_rate = 0.05)
model.compile(optimizer=opt,loss='mse')
model.fit(xs,ys,epochs=200, batch_size=16)
----		
Na primeira linha apagamos possíveis modelos anteriores existentes.
Na segunda linha criamos um novo modelo sequencial (sequência de camadas de neurónios), ao qual, na terceira linha, acrescentamos uma camada com um neurónio de activação sigmóide.
Criamos depois o optimizador, neste caso Stochastic Gradient Descent (SGD), e indicamos a taxa/ritmo de aprendizagem (learning rate) e o momento (momentum).
Compilamos o modelo usando Mean Squared Error (mse) como função de erro.
Finalmente, treinamos o modelo, dando para isso os valores de entrada, correspondentes valores de saída, número de épocas e tamanho do batch.
Recorde que o número de épocas indica quantas vezes usamos o conjunto completo de treino, enquanto que o tamanho do batch é o número de exemplos que vão ser usados em cada atualização dos pesos da rede.
Observe a evolução do treino ao longo das 200 épocas.

Depois de o modelo estar treinado, pode visualizar o ajuste do modelo aos dados de treino.

----
x = np.linspace(0,1,200).reshape((-1,1))
y = model.predict(x)
plt.plot(x,y)
plt.plot(xs,ys,'.')
----		
Num modelo sequencial, a última camada é designada de camada de saída e as anteriores são designadas camadas escondidas. A camada de entrada está implícita, e é construída internamente na primeira vez que treinamos o modelo, com base na forma do conjunto de treino dado.
Neste exemplo temos apenas uma camada, que é por isso a camada de saída, e não temos camadas escondidas. Repare como a ativação sigmóide da camada de saída faz com que o valor de saída do modelo varie apenas entre 0 e 1, não se ajustando por isso bem à curva.

Experimente construir e treinar um modelo semelhante, mas com ativação linear, permitindo assim que o output não esteja limitado a valores entre 0 e 1.
Para isso deve usar (activation='linear') em vez de (activation='sigmoid').
Compile, treine e visualize o resultado. O que aconteceu?

Adicione ao modelo anterior uma camada escondida com activação linear (activation='linear') e com um neurónio.
Compile, treine e visualize o resultado. Houve melhorias?

Altere agora nesta camada escondida o neurónio para activação sigmóide. Compile, treine, visualize o resultado, e compare com os resultados anteriores.

Adicione mais um neurónio na camada com activação sigmóide já existente. Compile, treine, visualize o resultado, e compare com os resultados anteriores.

Finalmente, adicione mais dois neurónios na camada com activação sigmóide. Compare o tempo de treino com os exemplos anteriores.

Recorde que o treino de uma rede neuronal é um processo estocástico, e que, por isso, cada vez que treina uma rede, os pesos da rede resultante são diferentes.
Se tiver um modelo já treinado (com nome "model") que queira guardar, deve usar o código:
----
model.save('nome_ficheiro.h5')
----
Este código guardará o modelo "model" no ficheiro nome_ficheiro.h5 na sua pasta drive. Para voltar a usar o modelo deve fazer:
----
new_model = keras.models.load_model('nome_ficheiro.h5')
----
e o modelo que estava guardado no ficheiro ficará na variável "new_model".

2) Casos diários COVID-19

Neste exemplo iremos considerar uma possível curva do número de casos diários confirmados de COVID-19 em Portugal.
Para tal vamos usar o seguinte ficheiro de dados (confirmados.txt).
Cada exemplo deste conjunto de dados é constituído pelo número do dia (a contar do primeiro dia em que houve casos confirmados) e o número de casos nesse dia.
Note que redimensionar os dados de entrada, de modo a os tornar pequenos, é uma importante técnica na área das redes neuronais. Isto evita processos de treino lentos e instáveis, que poderiam ocorrer quando os valores de entrada são elevados.
Tendo isso em conta, vamos mudar a escala dos dados de entrada para o intervalo [0,1].

----
mat = np.loadtxt('confirmados.txt')
scale = np.max(mat,axis=0)
mat = mat / scale
xs = mat[:,:1]
ys = mat[:,1:]
----

Para redes mais profundas, devemos usar Rectified Linear Unit (ReLU) como função de ativação das camadas escondidas.
Para isso usamos (activation='relu') em cada uma das camadas escondidas.
Construa um modelo com várias camadas com activação ReLU e no fim uma camada linear.
Experimente diferentes arquiteturas da rede e compare os resultados obtidos.
Pode visualizar o ajustamento do modelo aos dados usando código semelhante ao apresentado antes, mas tendo em conta a escala original dos dados:

----
x = np.linspace(0,mat[-1,0],200).reshape((-1,1))
y = model.predict(x)
plt.plot(x*scale[0],y*scale[1])
plt.plot(xs*scale[0],ys*scale[1],'.') 
----

3) Classificação de tipos de lírios

Vamos agora explorar um exemplo simples de classificação. Ao contrário dos problemas de regressão, como era o caso nos dois exemplos anteriores, em que se tentava prever uma quantidade, nos problemas de classificação o objectivo é prever uma classe.
O conjunto de dados utilizado neste exemplo é chamado de Fisher's Iris dataset, em memória do biólogo inglês Ronald Fisher, que em 1936 classificou três diferentes tipos de lírios (género Iris): Setosa, Versicolor e Virginica.
Este conjunto de dados consiste em 50 amostras de cada uma das três espécies, em que, para cada amostra, quatro características foram medidas: largura e comprimento das pétalas e das sépalas.
O ficheiro de dados pode ser encontrado aqui (iris.txt).
Cada exemplo deste conjunto de dados é composto por cinco elementos: os valores dos quatro atributos e a classe a que pertence (0-Setosa, 1-Versicolor, 2-Virginica).
Queremos treinar um modelo de modo a que seja possível, dado a valor dos quatro atributos de um lírio, prever a que classe este pertence.

Começamos por carregar os dados de treino. Como estes estão ordenados por classes, é conveniente mudar, de forma aleatória, a sua ordem.
Para além disso, para garantir que todas as características são consideradas de igual modo, é usual normalizar o conjunto de entrada, subtraindo todos os valores pela média e dividindo pelo desvio padrão.
Nos problemas de classificação, uma passo importante a fazer é recodificar as classes.
Para isso, como neste caso temos três classes, consideramos três neurónios, e usamos uma técnica usualmente denominada one-hot encoding.
Neste exemplo, a classe 0 é codificada no vector (1,0,0), a classe 1 é codificada no vector (0,1,0) e a classe 2 é codificada no vector (0,0,1).
O Keras permite fazer isso de uma forma simples com a função to_categorical.
O seguinte código permite fazer tudo isto.

----
mat = np.loadtxt('iris.txt')
np.random.shuffle(mat)
x = mat[:,:-1]
x = (x-np.mean(x,axis=0))/np.std(x,axis=0)
y_orig = mat[:,-1]
y = to_categorical(y_orig)
----
Ao contrário dos problemas de regressão anteriores, nos quais usámos MSE como função de erro, no caso de problemas de classificação com múltiplas classes, iremos usar Categorical Cross Entropy.
Para tal, na compilação do modelo deve usar loss='categorical_crossentropy'.
Para além disso, a última camada deverá ter três neurónios, correspondentes a cada uma das classes.
A função de activação desta última camada deverá ser softmax, que permite que o valor de cada neurónio de saída possa ser visto como a probabilidade de pertencer a essa classe.
Desse modo, a classe a que cada exemplo pertence será identificada pelo neurónio da camada de saída que tiver maior activação.
Nos problemas de classificação, para além do erro (loss), podemos considerar a exatidão (accuracy), que é a proporção de exemplos bem classificados.
Para poder observar a variação da exatidão no conjunto de treino e de validação ao longo do treino, deve, na compilação do modelo, acrescentar metrics=['accuracy'].
----
model.compile(optimizer=opt,loss='categorical_crossentropy',metrics='accuracy')
----
Comece por construir um modelo com apenas uma camada com três neurónios e activação softmax. Treine durante 400 épocas e com 16 como tamanho do batch.

Depois poderá testar a adição de mais camadas escondidas com activação relu, antes da camada de saída softmax e comparar com o anterior.

Recorde agora que, para além do conjunto de treino, podemos também considerar um conjunto de validação.
O Keras permite, de uma maneira simples, indicar que parte dos dados de treino serão usados para validação.
Para isso basta que na fase de treino do modelo, se indique que fracção do conjunto de treino será usada como conjunto de validação.
Para além disso, pode ser útil visualizar o gráfico da evolução do erro e da exatidão, tanto de treino como de validação, ao longo das várias épocas do treino da rede.
O seguinte fragmento de código mostra como indicar a fracção do conjunto de treino que será usado para validação, e como gerar o gráfico com a evolução do erro e validação.
----
hist = model.fit(x,y, validation_split=0.2, epochs=400, batch_size=16)

plt.figure()
plt.plot(hist.history['loss'], label='train_loss')
plt.plot(hist.history['val_loss'], label='val_loss')
plt.plot(hist.history['accuracy'], label='train_acc')
plt.plot(hist.history['val_accuracy'], label='val_acc')
plt.title('Training Loss and Accuracy')
plt.xlabel('Epoch #')
plt.ylabel('Loss/Accuracy')
plt.margins(x=0)
plt.margins(y=0)
plt.legend()
plt.show()
----
Ao usar validation_split=0.2, estamos a indicar que dois décimos dos dados de treino vão ser usados como dados de validação.
Se em vez de usar uma parte do conjunto de treino para validação, tivermos um conjunto de dados (x_v,y_v) que queremos usar para validação, então, em vez de usar validation_split, devemos usar a opção validation_data=(x_v,y_v).
Recorde que a visualização conjunta da evolução dos erros e exatidão no conjunto de treino e de validação é muito importante, pois permite identificar casos de sobreajustamento (overfitting).

Experimente treinar os modelos anteriores com conjunto de validação, e visualize a evolução do erro e da exatidão.

4) Classificação de comboios - o Explainable Abstract Trains Dataset

Neste exercício iremos usar uma versão simplificada do dataset Explainable Abstract Trains Dataset, que contém um conjunto de imagens de representações de comboios.
Cada imagem tem dimensão 20 (altura) por 115 (largura) pixeis e contém um comboio com três carruagens, cada uma delas contendo até três figuras geométricas.
As seguintes imagens são exemplos do dataset.
Classe 0
Classe 1
Classe 2
Classe 3
Iremos considerar quatro classes de comboios:

EmptyTrain - todas as carruagens estão vazias;
HalfFullTrain - algumas carruagens vazias, mas nem todas;
FullTrain - todas as carruagens têm exatamente uma figura;
OverloadedTrain - não há carruagens vazias e há pelo menos uma carruagem que tem mais do que uma figura.

A representação de cada uma das imagens é uma matriz 20x115 correspondente aos seus pixeis. Como as imagens são coloridas, cada pixel é representado pelos valores dos seus três canais RGB, i.e, é um triplo (r,g,b) de valores entre 0 e 255.
A ideia é que, dado o conjunto destes 6900=20x115x3 valores associados a uma imagem, i.e., o nível de vermelho (r), verde (g) e azul (b) de cada pixel da imagem, seja possível inferir a que classe de comboio essa imagem corresponde.

Deve começar por descarregar e descompactar o seguinte ficheiro trains_dataset_ia.zip, e colocar todos os ficheiros desta pasta comprimida na sua pasta da Drive.

Depois de os ficheiros estarem na sua pasta, o seguinte código permite ler e preparar o conjunto de treino e validação:
----
def _preprocess_images(images):
    # Scale the raw pixel intensities to the range [-1, 1]
    images = images / 127.5
    images = images - 1.0
    images.astype(np.float32)
    return images
    
# Start by loading the training dataset
# The dataset is made of pictures and their labels (EmptyTrain, HalfFullTrain, FullTrain, OverloadedTrain)
training_data_labels_frame = pd.read_csv('training_trains_labels.csv')
data_labels = training_data_labels_frame.columns.values[1:]
training_data_labels = training_data_labels_frame[data_labels].to_numpy()
original_training_data_images = np.load('training_images.npz')['images']
training_data_images = _preprocess_images(original_training_data_images)

# Load the validation dataset
validation_data_labels_frame = pd.read_csv('validation_trains_labels.csv')
validation_data_labels = validation_data_labels_frame[data_labels].to_numpy()
validation_data_images = _preprocess_images(np.load('validation_images.npz')['images'])    
----
Repare que os valores dos pixeis (entre 0 e 255) são normalizados e transformados em valores no intervalo [-1,1].
Depois desta leitura e pre-processamento dos dados, os pares (training_data_images, training_data_labels) e (validation_data_images, validation_data_labels) contêm o conjunto de treino e validação, respectivamente.
Podemos visualizar um pouco mais de informação sobre o dataset usando o seguinte código:
----
# Lets explore our training dataset
# Print the shape of the training images (#images, height, width, #channels)
# Note: Each channel represents one of the RGB color values
print('Image data shape:', training_data_images.shape)

# Print our labels
print('Data labels:', data_labels)

# Visualize a few images from the training dataset
for i in range(3):
  plt.imshow(random.choice(original_training_data_images))
  plt.show()
----
Este código permite verificar o formato do conjunto de treino: 1200 exemplos, cada um correspondente à representação de uma imagem: 20(altura) x 115(largura) x 3(canaisRGB).
Podemos ainda ver quais os labels das classes que vamos usar para classificar as imagens: Empty, HalfFull, Full, e Overloaded. Para além disso, podemos visualizar três imagens escolhidas aleatoriamente do conjunto.

Como a representação de cada imagem tem o formato 20x115x3, temos de transformar esta representação numa só lista. O Keras permite fazer isso facilmente usando uma camada Flatten no início da rede:
----
...
model = keras.Sequential()
model.add(layers.Flatten())
...
----
Para problemas mais complexos, como é este o caso, podemos usar o optimizador Adam, que é uma versão melhorada do SGD. Para tal, quando compilar o modelo deve usar:
----
model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['accuracy'])
----
Deve agora encontrar um modelo e definir parâmetros adequados, de modo a garantir o melhor valor possível de exatidão no conjunto de validação.
Para isso deverá testar vários modelos com arquiteturas diferentes e diferentes parâmetros.
Para poder comparar modelos treinados, não se esqueça de ir guardando os valores do erro (loss) final no conjunto de treino (L), erro final no conjunto de validação (L_V), a exatidão (accuracy) final no conjunto de treino (A), e a exatidão final no conjunto de validação (A_V).
Pode obter estes valores finais de erro e validação, se depois de treinar um modelo usando hist=model.fit(...), usar o seguinte código:

----
dados_finais_de_treino=[(l,hist.history[l][-1]) for l in hist.history]
print(dados_finais_de_treino)
----
Recorde também que pode ir guardando e reutilizando modelos treinados usando as funções model.save('nome_ficheiro.h5'), e new_model = keras.models.load_model('nome_ficheiro.h5'), tal como descrito no início da aula.
Não se deve esquecer de apontar também a arquitetura da rede (número de camadas e neurónios em cada camada). Finalmente, a seguinte função recebe o nome de um modelo e o nome de um ficheiro contendo um conjunto de exemplos, e imprime uma lista com a classe prevista pelo modelo para cada uma das imagens do conjunto.
----
def _print_predictions_example(model,file_name):
  test_data_images = _preprocess_images(np.load(file_name)['images'])
  predictions = np.argmax(model.predict(test_data_images), axis=-1)
  print(predictions)
----
Aplicando esta função ao conjunto de exemplos no ficheiro 'test_images.npz' que colocou na sua pasta Drive, obtém a uma lista com a previsão que o seu modelo treinado (model) faz da classe a que cada um dos 800 exemplos do conjunto pertence. Note que usamos a correspondência 'Empty' -> 0, 'HalfFull' -> 1, 'Full' -> 2, 'Overloaded' -> 3.
----
_print_predictions_example(model,'test_images.npz')
----
Para garantir que o resultado da função é mostrado na consola, mesmo quando o número de exemplos é mais elevado, basta que use o seguinte código uma vez.
----
np.set_printoptions(threshold=np.inf)
----
Se quiser visualizar a n-ésima imagem do conjunto de exemplos, juntamente com a previsão que um modelo dado faz sobre a classe a que a imagem pertence, pode usar a seguinte função:
----
def _visualize_predictions_example(model,file_name,n):
  test_images=np.load(file_name)['images']
  test_data_images = _preprocess_images(np.load(file_name)['images'])
  predictions = np.argmax(model.predict(test_data_images), axis=-1)
  plt.imshow(test_images[n])
  labels=['Empty','HalfFull', 'Full', 'Overloaded']
  print('A classe prevista é:',labels[predictions[n]])
----

5) (Extra) Classificação de dígitos manuscritos - o dataset MNIST

Neste exercício irá utilizar o dataset MNIST, que é um conjunto de imagens de dígitos (entre 0 e 9) manuscritos em escala de cinza e com tamanho de 28x28 pixeis. Este dataset contém 60000 imagens de treino e 10000 imagens de teste. A representação de cada uma das imagens é uma matriz 28x28 de inteiros entre 0 e 225, correspondentes à escala de cinza de cada pixel da imagem. A ideia é que, dados estes valores de cada um dos 784 pixeis de uma imagem, seja possível inferir a que dígito manuscrito essa imagem corresponde. A Keras permite descarregar este dataset.

----
mnist = keras.datasets.mnist
(x_t,y_t), (x_v,y_v) = mnist.load_data()
x_t = x_t/255
x_v = x_v/255
plt.imshow(x_t[0],cmap='gray')
plt.figure()
plt.imshow(x_t[1],cmap='gray')
y_t_cats = to_categorical(y_t)
y_v_cats = to_categorical(y_v)
----
Depois de descarregar o dataset, é feito a normalização dos dados de input para o intervalo [0,1], dividindo pelo maior valor possível (255).
É depois possível visualizar duas imagens do conjunto de treino. Finalmente, usamos a técnica one-hot-encoding para as classes existentes usando a função to_categorical.

Deve agora encontrar um modelo e definir parâmetros adequados, de modo a garantir o melhor valor possível exatidão no conjunto de validação.
Para isso deverá testar vários modelos com arquiteturas diferentes e diferentes parâmetros.