Deep Learning com Tensorflow - Aula 1
Classificando Dígitos da base de dados MNIST com Redes Neurais
- Preprocessamento dos dados
- Formatação dos dados
- Treinamento Perceptron Simples
- Perceptron Várias Camadas
- Comparando diferentes modelos
import numpy as np
import tensorflow as tf
import matplotlib.pyplot as plt
from sklearn.metrics import accuracy_score
from sklearn.preprocessing import OneHotEncoder
# Estética dos plots
plt.rcParams['mathtext.fontset'] = 'custom'
plt.rcParams['mathtext.rm'] = 'Bitstream Vera Sans'
plt.rcParams['mathtext.it'] = 'Bitstream Vera Sans:italic'
plt.rcParams['mathtext.bf'] = 'Bitstream Vera Sans:bold'
plt.rcParams['font.size'] = 16
plt.rcParams['mathtext.fontset'] = 'stix'
plt.rcParams['font.family'] = 'STIXGeneral'
Preprocessamento dos dados
Começamos por pre-processar os dados. Para tanto, utilisaremos o próprio Tensorflow para fazer o carregamento dos dados. À partir da biblioteca Keras, carregamos os dados de treino e de teste usando a chamada
tf.keras.datasets.mnist.load_data()
Na verdade, o Tensorflow possui vários datasets comuns em machine learning. Uma lista completa pode ser encontrada no seguinte link
(x_train, y_train), (x_test, y_test) = tf.keras.datasets.mnist.load_data()
Note que dividimos os dados em 4 arrays. Estes arrays correspondem aos dados de treino e teste. Os dados de treino correspondem àqueles que serão utilizados durante a otimização do modelo, e o de teste será usado para avaliar o modelo. Fazemos essa divisão por dois motivos:
- Queremos simular a situação em que o nosso modelo é treinado num conjunto de dados fixo, e depois é utilisado na prática com dados novos, os quais o modelo não viu durante a fase de treino. Para o caso do MNIST, imagine que treinamos a rede numa base de dados local, e utilisamos o modelo para a predição em tempo real de dígitos numa aplicação remota. Os dados obtidos em tempo real não foram vistos pela rede neural durante treinamento.
- As estatísticas obtidas com os dados de treinamento são geralmente mais otimistas do que em dados não vistos. Imagine o caso em que uma pessoa estuda para uma prova à partir de uma lista de exercícios. Quem você acha que teria o melhor desempenho? (1) um aluno que faz uma prova com as questões retiradas da lista, ou (2) um aluno que faz uma prova com questões inteiramente novas?
Além de dividir os dados em treino/teste, iremos também dividí-los entre características (array X) e rótulos (array y).
Formatação dos dados
Iremos começar analizando os dados como vieram no dataset da biblioteca tensorflow. Dado que as aplicações são, via de regra, para redes neurais convolucionais, os dados vem como matrizes.
Visualização imagens como matrizes
fig, ax = plt.subplots()
ax.imshow(x_train[0], cmap='gray')
_ = ax.set_xticks([])
_ = ax.set_yticks([])
print("Formato da matriz de dados: {}".format(x_train.shape))
note que os dados estão salvos como imagens. Portanto, a faixa de valores para seus pixels está entre 0 e 255. Além disso, os rótulos estão salvos em formato categórico, ou seja, $y_{i} \in \{1, \cdots, K\}$, onde $K$ é o número de classes. Particularmente, $K = 10$ para o dataset MNIST.
print("Faixa de valores de X: [{}, {}]".format(x_train.min(), x_train.max()))
print("Tipos de dados das matrizes: X {}, y {}".format(x_train.dtype, x_test.dtype))
print("Codificação dos rótulos: {}".format(y_train[0]))
Para converter a matriz de caracteríticas, tomaremos 2 passos:
- converter de int para float,
- converter da faixa [0, 255] para [0, 1]
Note que podemos aplicar a seguinte transformação,
$$ x \leftarrow \dfrac{x - x_{min}}{x_{max}-x_{min}}, $$Como discutido anteriormente, $x_{min} = 0$ e $x_{max} = 255$, portanto,
$$ x \leftarrow \dfrac{x}{255} $$Xtr = x_train.astype(float) / 255.0
Xts = x_test.astype(float) / 255.0
print("Nova faixa de valores de X: [{}, {}]".format(Xtr.min(), Xtr.max()))
Precisamos ainda transformar o formato dos dados. Para tanto, queremos converter cada imagem-matriz em imagem-vetor através da notação Row-Major. Isso é particularmente simples em Python. Utilizamos o método $.reshape$ da classe $ndarray$,
# OBS: o uso de -1 numa das dimensões do reshape faz com que numpy infira o valor
# da dada dimensão.
Xtr = Xtr.reshape(-1, 28 * 28)
Xts = Xts.reshape(-1, 28 * 28)
print("Novo formato de X: {}".format(Xtr.shape))
Além disso, iremos também transformar a notação categórica dos rótulos na notação One Hot. Isso é simples utilizando a biblioteca scikit-learn do Python, através da classe OneHotEncoder. O exemplo abaixo fornece uma ilustração para 3 classes e 3 amostras:
$$ y^{cat} = [1, 2, 3] \iff y^{OneHot} = \begin{bmatrix} 1 & 0 & 0\\ 0 & 1 & 0\\ 0 & 0 & 1 \end{bmatrix} $$# OBS1: o objeto OneHotEncoder espera um array de 2 dimensões.
# Porém y_train só possui 1 dimensão (observe os prints
# abaixo). Para convertê-lo num array 2D, utilisaremos a
# função reshape, que muda o formato do array.
# OBS2: .reshape(-1, ...) faz com que a biblioteca numpy faça
# uma inferência do valor adequado para a dimensão especificada
# como -1. No caso, como utilisamos .reshape(-1, 1), teremos uma
# transformação de formatação (N, ) -> (N, 1)
print("Formato de y_train antes de usar .reshape: {}".format(y_train.shape))
print("Formato de y_train após usar .reshape: {}".format(y_train.reshape(-1, 1).shape))
enc = OneHotEncoder(sparse=False)
ytr = enc.fit_transform(y_train.reshape(-1, 1))
yts = enc.fit_transform(y_test.reshape(-1, 1))
print("Formato da matriz de rótulos após a aplicação da nova codificação: {}".format(ytr.shape))
Treinamento Perceptron Simples
Aqui treinaremos uma rede Perceptron simples com uma camada. Para tanto, utilisaremos as seguintes classes da biblioteca Keras,
Definição da rede
Aqui, temos apenas 2 camadas: a camada de entrada, que recebe uma matriz $(N, d)$, onde $N$ é o número de amostras e $d$ é o número de características.
Temos uma segunda camada, chamada de camada de output, que toma como entrada o o objeto simbólico de ouptut da camada de entrada, e tem como saída o objeto simbólico
$$ \mathbf{y} = \varphi(\mathbf{Wx} + \mathbf{b}). $$Iremos portanto por estes conceitos dentro de uma função, que irá ter como saída um objeto $tf.keras.models.Model$.
def perceptron_mnist(input_shape=(784,), n_classes=10):
x = tf.keras.layers.Input(shape=input_shape)
y = tf.keras.layers.Dense(units=n_classes, activation='sigmoid')(x)
return tf.keras.models.Model(x, y)
model1 = perceptron_mnist()
# Print do modelo construído
model1.summary()
Compilação do modelo
Para compilar o modelo, precisaremos definir:
em especial, iremos utilisar o erro quadrático médio, definido por,
$$ \mathcal{L}(\mathbf{W}, \mathbf{b}) = \dfrac{1}{2N}||\mathbf{y} - \hat{\mathbf{y}}||_{2}^{2},\\ \mathcal{L}(\mathbf{W}, \mathbf{b}) = \dfrac{1}{2N}\sum_{i=1}^{N}(\mathbf{y}_{i} - \varphi(\mathbf{Wx}_{i} + \mathbf{b}))^{2} $$e definiremos o otimizador Stochastic Gradient Descent (SGD), que atualizará os parâmetros da rede neural através da regra:
$$ \mathbf{W}^{\ell + 1} \leftarrow \mathbf{W}^{\ell} - \eta\dfrac{\partial\mathcal{L}}{\partial\mathbf{W}},\\ \mathbf{b}^{\ell + 1} \leftarrow \mathbf{b}^{\ell} - \eta\dfrac{\partial\mathcal{L}}{\partial\mathbf{b}}. $$onde $\eta$ é um parâmetro escolhido previamente, que define quão longo é o passo de otimização tomado na direção do gradiente. No contexto de machine learning, $\eta$ é chamado de Learning Rate.
O que faz a compilação do modelo? A compilação de um modelo Keras faz o seguinte:
- Para cada operação no grafo computacional (construído anteriormente pela função que define o modelo), calcula os gradientes.
- Define a regra para a atualização dos parâmetros
- Inicializa cada variável no modelo.
Basicamente a compilação prepara o modelo para duas tarefas: inferência (feed-forward) e aprendizado (backpropagation).
# Passo à passo:
# 1. Instancie a função de custo (num primeiro momento, use MeanSquaredError)
# 2. Instancie o otimizador (SGD, ou Stochastic Gradient Descent)
# 3. Compile o modelo.
# 1. Instanciação do custo
loss_obj = tf.keras.losses.MeanSquaredError()
# 2. Instanciação do otimizador
optimizer_obj = tf.keras.optimizers.SGD(learning_rate=1e-1)
# 3. Compilação do modelo
model1.compile(
loss=loss_obj,
optimizer=optimizer_obj,
metrics=['accuracy']
)
Uma vez que o modelo foi compilado, podemos lançar seu aprendizado. fazemos isso com a função .fit. Em especial, definiremos,
- A matriz de treino (caracteríticas), x, que em nossa notação é Xtr,
- A matriz de treino (rótulos), y, que em nossa notação é ytr,
- O tamanho dos minibatches, _batch_size_, que definiremos como 1024,
- O número de épocas, _n_epochs_, que definiremos como 150,
- Os dados de validação, _validation_data_, que na nossa notação é a dupla $(Xtr, ytr)$,
- O _batch_size_ dos dados de validação, que utilisaremos 128.
Podemos ainda salvar o histórico de treinamento, que contém um dicionário com várias métricas por época de treinamento.
hist1 = model1.fit(x=Xtr,
y=ytr,
batch_size=1024,
epochs=150,
validation_data=(Xts, yts),
validation_batch_size=128)
fig, axes = plt.subplots(1, 2, figsize=(15, 5))
axes[0].plot(100 * np.array(hist1.history['accuracy']), label='Treino')
axes[0].plot(100 * np.array(hist1.history['val_accuracy']), label='Teste')
axes[0].set_ylabel('Percentual de Acerto')
axes[0].set_xlabel('Época')
axes[0].legend()
axes[1].plot(100 * np.array(hist1.history['loss']), label='Treino')
axes[1].plot(100 * np.array(hist1.history['val_loss']), label='Teste')
axes[1].set_ylabel('Função de Erro')
axes[1].set_xlabel('Época')
axes[1].legend()
Visualização do modelo
Podemos ter um olhar mais profundo sobre a rede neural ao visualisarmos seus pesos. Especialmente, nossa atenção será voltada para a matriz de pesos $W$. Note que $W$ é uma matriz $d \times K$, onde $d$ é o número de características, e $K$ é o número de classes.
Podemos dizer que cada valor $W_{ij}$ dá a relevância de cada pixel $ij$ da matriz. Além disso, note que podemos dividir $W \in \mathbb{R}^{d \times K}$, como $K$ vetores $d-$dimensionais,
$$ W = [W_{1}, \cdots, W_{K}] $$Note ainda que como nós definimos $d = 28\times 28$, podemos re-transformar os pesos em uma imagem através do método $.reshape$. Isso nos permitirá uma visualização concreta das matrizes de peso. Atente que as zonas em vermelho mostram valores positivos, em azul mostram valores negativos, e próximos ao verde mostram valores próximos de zero.
W = model1.layers[1].weights[0].numpy()
# b = model.layers[1].weights[1].numpy()
fig, axes = plt.subplots(3, 3, figsize=(10, 10))
for i, ax in enumerate(axes.flatten()):
ax.imshow(W[:, i].reshape(28, 28), cmap='jet')
ax.set_xticks([])
ax.set_yticks([])
plt.savefig('single_layer_weights.pdf')
Há ainda muito ruído nos pesos da rede. Mas de maneira um pouco forçosa podemos dizer que cada peso corresponde à um "protótipo" de um dígito. Ou seja, cada neurônio se especializa no aprendizado de um único dígito. A próxima seção vai tratar da aprendizagem com regularização, que vai tornar esta última afirmação mais evidente.
Treinamento Perceptron Simples + Regularização
Regularização é uma técnica de combate ao overfitting, um fenômeno que acontece em modelos preditivos onde o modelo aprende "bem demais" os dados de treinamento: o modelo é tão complexo que consegue decorar os exemplos de entrada. Para novos dados, o modelo tem performance inferior.
Apesar de o exemplo anterior não demonstrar overfitting, sua matriz de pesos contém bastante ruído pois seus pesos não são limitados à um intervalo. Uma maneira de eliminar esse ruído e obter uma visualização melhor é através da regularização. Isso consiste em adiconar uma penalização à função de custo,
$$ \mathcal{L}_{reg}(\mathbf{W}, \mathbf{b}) = \mathcal{L}(\mathbf{W}, \mathbf{b}) + \lambda\Omega(\mathbf{W}) $$Nessa prática iremos demonstrar o uso da penalidade $\ell^{2}$, definida através da fórmula,
$$ \Omega(\mathbf{W}) = \dfrac{1}{2}||\mathbf{W}||^{2}_{2},\\ \Omega(\mathbf{W}) = \dfrac{1}{2}\sum_{i=1}^{d}\sum_{j=1}^{K}W_{ij}^{2} $$Outros termos de regularização existem. Nós utilisaremos o termo $l2$
def perceptron_mnist_l2_reg(input_shape=(784,), n_classes=10, penalty=1e-3):
x = tf.keras.layers.Input(shape=input_shape)
y = tf.keras.layers.Dense(units=n_classes,
kernel_regularizer=tf.keras.regularizers.l2(penalty),
activation='sigmoid')(x)
return tf.keras.models.Model(x, y)
# Definição do modelo
model2 = perceptron_mnist_l2_reg()
# 1. Instanciação do custo
loss_obj = tf.keras.losses.MeanSquaredError()
# 2. Instanciação do otimizador
optimizer_obj = tf.keras.optimizers.SGD(learning_rate=1e-1)
# 3. Compilação do modelo
model2.compile(
loss=loss_obj,
optimizer=optimizer_obj,
metrics=['accuracy']
)
# 4. Treinamento
hist2 = model2.fit(x=Xtr, y=ytr, batch_size=1024, epochs=150, validation_data=(Xts, yts), validation_batch_size=128)
Aqui, fica mais claro a afirmação anterior: cada neurônio se especializa no reconhecimento de um tipo específico de dígito.
fig, axes = plt.subplots(1, 2, figsize=(15, 5))
axes[0].plot(100 * np.array(hist2.history['accuracy']), label='Treino')
axes[0].plot(100 * np.array(hist2.history['val_accuracy']), label='Teste')
axes[0].set_ylabel('Percentual de Acerto')
axes[0].set_xlabel('Época')
axes[0].legend()
axes[1].plot(100 * np.array(hist2.history['loss']), label='Treino')
axes[1].plot(100 * np.array(hist2.history['val_loss']), label='Teste')
axes[1].set_ylabel('Função de Erro')
axes[1].set_xlabel('Época')
axes[1].legend()
W = model2.layers[1].weights[0].numpy()
# b = model.layers[1].weights[1].numpy()
fig, axes = plt.subplots(3, 3, figsize=(10, 10))
for i, ax in enumerate(axes.flatten()):
ax.imshow(W[:, i].reshape(28, 28), cmap='jet')
ax.set_xticks([])
ax.set_yticks([])
plt.savefig('single_layer_weights_reg.pdf')
Exercícios
- Substitua a definição da função de ativação de 'sigmoid' para 'softmax'. Quais as implicações práticas? O que acontece com o treinamento?
- Tente substituir a função de custo. Troque 'MeanSquaredError' pela 'CategoricalCrossEntropy' (por que não 'BinaryCrossEntropy'?). Avalie os resultados obtidos.
def mlp_mnist(input_shape=(784,), n_classes=10, penalty=1e-3):
x = tf.keras.layers.Input(shape=input_shape)
y = tf.keras.layers.Dense(units=196, activation='relu',
kernel_regularizer=tf.keras.regularizers.l2(penalty))(x)
y = tf.keras.layers.Dense(units=49, activation='relu',
kernel_regularizer=tf.keras.regularizers.l2(penalty))(y)
y = tf.keras.layers.Dense(units=n_classes, activation='softmax',
kernel_regularizer=tf.keras.regularizers.l2(penalty))(y)
return tf.keras.models.Model(x, y)
model3 = mlp_mnist()
# Print do modelo construído
model3.summary()
# Passo à passo:
# 1. Instancie a função de custo (num primeiro momento, use MeanSquaredError)
# 2. Instancie o otimizador (SGD, ou Stochastic Gradient Descent)
# 3. Compile o modelo.
# 1. Instanciação do custo
loss_obj = tf.keras.losses.MeanSquaredError()
# 2. Instanciação do otimizador
optimizer_obj = tf.keras.optimizers.SGD(learning_rate=1e-1)
# 3. Compilação do modelo
model3.compile(
loss=loss_obj,
optimizer=optimizer_obj,
metrics=['accuracy']
)
hist3 = model3.fit(x=Xtr, y=ytr, batch_size=1024, epochs=150, validation_data=(Xts, yts), validation_batch_size=128)
fig, axes = plt.subplots(1, 2, figsize=(15, 5))
axes[0].plot(100 * np.array(hist3.history['accuracy']), label='Treino')
axes[0].plot(100 * np.array(hist3.history['val_accuracy']), label='Teste')
axes[0].set_ylabel('Percentual de Acerto')
axes[0].set_xlabel('Época')
axes[0].legend()
axes[1].plot(100 * np.array(hist3.history['loss']), label='Treino')
axes[1].plot(100 * np.array(hist3.history['val_loss']), label='Teste')
axes[1].set_ylabel('Função de Erro')
axes[1].set_xlabel('Época')
axes[1].legend()
fig, axes = plt.subplots(14, 14, figsize=(16, 16))
for k, ax in enumerate(axes.flatten()):
w = model3.layers[1].weights[0].numpy()
ax.imshow(w[:, k].reshape(28, 28), cmap='jet')
ax.set_xticks([])
ax.set_yticks([])
plt.savefig('mlp_weights.pdf')
fig, axes = plt.subplots(7, 7, figsize=(16, 16))
for k, ax in enumerate(axes.flatten()):
w = model3.layers[2].weights[0].numpy()
ax.imshow(w[:, k].reshape(14, 14), cmap='jet')
ax.set_xticks([])
ax.set_yticks([])
O resultado anterior mostra que os neurônios continuam especializados na primeira camada oculta. Isso mostra uma limitação fundamental das redes neurais rasas: o seu conhecimento é concentrado. Especialmente, se a engenharia de características não é boa, os resultados adquiridos também não são satisfatórios. Essas limitações serão superadas ao utilisarmos modelos convolucionais.
Exercícios
Teste os resultados anteriores utilisando outros números de camadas, outras funções de ativação, outros parâmetros de regularização. Qual a taxa de acerto máxima obtida para o conjunto de teste?
Comparando diferentes modelos
Para podermos comparar modelos diferentes, precisamos ser criteriosos no treinamento destes. Note que o desempenho de um modelo nas épocas iniciais é muito diferente do desempenho após convergência. Portanto, precisamos assegurar o seguinte:
- Que o modelo convergiu,
- Caso a convergência não seja assegurada para os diferentes modelos, fixa-se o batch_size e o número de épocas.
Note que os dois pontos são assegurados para os nossos experimentos.
fig, axes = plt.subplots(1, 2, figsize=(15, 5))
axes[0].plot(100 * np.array(hist1.history['accuracy']), label='Uma Camada')
axes[0].plot(100 * np.array(hist2.history['accuracy']), label='Uma Camada (regularizado)')
axes[0].plot(100 * np.array(hist3.history['accuracy']), label='Várias Camadas')
axes[0].set_ylabel('Percentual de Acerto')
axes[0].set_xlabel('Época')
axes[0].legend()
axes[1].plot(100 * np.array(hist1.history['loss']), label='Uma Camada')
axes[1].plot(100 * np.array(hist2.history['loss']), label='Uma Camada (regularizado)')
axes[1].plot(100 * np.array(hist3.history['loss']), label='Várias Camadas')
axes[1].set_ylabel('Função de Erro')
axes[1].set_xlabel('Época')
axes[1].legend()
yp1 = model1(Xts).numpy().argmax(axis=1)
yp2 = model2(Xts).numpy().argmax(axis=1)
yp3 = model3(Xts).numpy().argmax(axis=1)
print("Taxa de precisão (Uma Camada): {}".format(100 * accuracy_score(y_test, yp1)))
print("Taxa de precisão (Uma Camada, regularizado): {}".format(100 * accuracy_score(y_test, yp2)))
print("Taxa de precisão (Várias Camadas): {}".format(100 * accuracy_score(y_test, yp3)))