Aprendendo TensorFlow 2.0 (#2)— Classificação Binária com tf.keras

Source: Deep Learning on Medium


Na Parte 1, aprendemos as regras e truques para resolver diferentes tipos de regressão. Nesse post, nós vamos mudar um pouquinho o nosso tipo de problema. Agora, vamos aprender a resolver problemas de Classificação Binária.

Para quem não lembra, Classificação é um tipo de problema de Aprendizagem Supervisionada onde a saída é um valor discreto; já o termo Binária diz respeito a essa saída ser binária — por exemplo: positivo/negativo, cachorro/gato, mulher/homem, etc… No próximo post, nós vamos aprender a resolver problema de classificação multiclasse, onde a quantidade de classes é ≥ 2.

E aí, pronto?


2 Clusters

O primeiro problema que vamos resolver é o problema de 2 clusters. Isto é, vamos ter um conjunto de pontos representados por suas coordenadas (x1, x2) onde cada ponto pode pertencer à uma de duas classes (y) possíveis: 0 (azul) e 1 (vermelho). Para gerar esses dados, vamos utilizar o método make_blobs do módulo sklearn.datasets:

x, y = make_blobs(n_samples=100, n_features=2, centers=2, random_state=1234)
y = y.reshape(-1, 1)
print(x.shape, y.shape)
plt.scatter(x[:,0], x[:,1], c=list(np.array(y).ravel()), s=15, cmap=’bwr’)

Com os dados em mãos, chegou a hora de projetar e treinar o nosso modelo. Diferentemente dos problemas de regressão, na última camada precisamos colocar apenas 1 neurônio com função de ativação sigmoid. Se você não conhece a função sigmoid, ela é representada pela seguinte fórmula:

fórmula da função sigmoid

Á título de informação, a derivada da função sigmoid é:

derivada da função sigmoid

Repare que a derivada da função sigmoid é igual a própria saída da função sigmoid, σ(x), multiplicado por (1-saída)

Graficamente, a função sigmoid e sua derivada são representadas da seguinte forma:

Em azul, o gráfico da função sigmoid. A derivada é representada em vermelho

Um fato interessante sobre a sigmoid é que obrigatoriamente a saída estará entre 0 e 1. Devido a essa propriedade, é comum interpretarem a saída como a probabilidade da amostra pertencer a derterminada classe. Por exemplo, se damos uma amostra X para rede e a saída da sigmoid é 0.25, essa saída é interpretada como: “a amostra X tem 25% de probabilidade de pertencer a classe Y”.

Entretanto, vale destacar que, matematicamente, a saída da sigmoid não dá realmente a probabilidade. Porém, como históricamente gostamos de interpretar valores entre 0 e 1 como probabilidade, é comum analisar a saída dessa forma.

Outra diferença em relação aos problemas de regressão é que agora a nossa função de perda (loss) será a Entropia Cruzada Binária (Binary Cross-Entropy), dada pela seguinte fórmula:

Binary Cross-Entropy

Onde y representa a saída real e ŷ representa a saída predita pela rede.

Você pode ler uma explicação detalhada sobre a entropia cruzada binária nesse repositório.

Finalmente, convertendo tudo isso em código, teremos:

model = tf.keras.models.Sequential()
model.add(tf.keras.layers.Dense(10, activation=tf.nn.relu, input_shape=(x.shape[1], )))
model.add(tf.keras.layers.Dense(1, activation=tf.nn.sigmoid))
model.summary()
model.compile(optimizer='adam', loss='binary_crossentropy', metrics=['accuracy'])
hist = model.fit(x, y, batch_size=32, epochs=100, verbose=0)
plot_hist_and_predictions(hist.history, x, y, model)

Repare também que agora temos um novo parâmetro no método compile: metrics=["accuracy"]. Isso indica que, ao final de cada epoch, queremos que o TensorFlow calcule a acurácia atual do modelo. Obviamente, esperamos que a acurácia aumente à medida que a rede é treinada.

Finalmente, após o treinamento dessa rede, obtive o seguinte resultado:

Analisando os resultados, podemos observar que a rede atinge 100% de acurácia mais ou menos na 50ª epoch. Tal resultado é observado no gráfico, onde todos os pontos são separados corretamente.

É importante salientar que apenas um neurônio em uma única camada seria capaz de resolver o nosso problema, já que os pontos são facilmente separados por uma única reta. Todavia, eu resolvi utilizar 10 só para o treinamento ser mais rápido e a rede aprender em menos tempo. Deixo como exercício você treinar a mesma rede com apenas um neurônio.

Pronto! Terminamos o nosso primeiro problema. Que tal complicarmos mais um poquinho?


4 Clusters

No nosso segundo problema, vamos fazer algo parecido ao problema anterior, só que agora teremos 4 clusters de 2 classes diferentes:

x, y = make_blobs(n_samples=500, n_features=2, cluster_std=0.9, centers=[(-3, -3), (3, 3), (-3, 3), (3, -3)], random_state=1234)
y = y.reshape(-1, 1)
y = np.where(y >= 2, 1, 0)
print(x.shape, y.shape)
plt.scatter(x[:,0], x[:,1], c=list(np.array(y).ravel()), s=15, cmap='bwr')

Repare que os nossos dados lembram, de certa forma, a porta XOR. Do mesmo modo que na porta lógica, não conseguiremos separar os dados com apenas uma reta. Será que a mesma rede que desenvolvemos no problema anterior consegue resolver esse? Vamos ver:

model = tf.keras.models.Sequential()
model.add(tf.keras.layers.Dense(10, activation=tf.nn.relu, input_shape=(x.shape[1], )))
model.add(tf.keras.layers.Dense(1, activation=tf.nn.sigmoid))
model.summary()
model.compile(optimizer='adam', loss='binary_crossentropy', metrics=['accuracy'])
hist = model.fit(x, y, batch_size=32, epochs=100, verbose=0)
plot_hist_and_predictions(hist.history, x, y, model)

Pelo gráfico acima, podemos ver que sim! Observe o padrão que a rede fez. Bem legal né? Além disso, apesar do problema ser mais complicado, a nossa rede chegou mais rápido à 100% de acurácia (antes da 20ª epoch). Isso pode indicar que, por sorte, os pesos iniciais da rede (definidos de forma aleatória) facilitaram a otimização da rede.


Círculos

E se a gente tivesse 2 clusters, sendo que um dentro do outro? 🤔

x, y = make_circles(n_samples=500, noise=0.1, factor=0.4, random_state=1234)
y = y.reshape(-1, 1)
print(x.shape, y.shape)
plt.scatter(x[:,0], x[:,1], c=list(np.array(y).ravel()), s=15, cmap='bwr')
model = tf.keras.models.Sequential()
model.add(tf.keras.layers.Dense(10, activation=tf.nn.relu, input_shape=(x.shape[1], )))
model.add(tf.keras.layers.Dense(10, activation=tf.nn.relu))
model.add(tf.keras.layers.Dense(1, activation=tf.nn.sigmoid))
model.summary()
model.compile(optimizer='adam', loss='binary_crossentropy', metrics=['accuracy'])
hist = model.fit(x, y, batch_size=32, epochs=100, verbose=0)
plot_hist_and_predictions(hist.history, x, y, model)

Legal, né? Com apenas 21 neurônios (10 em cada camada escondida + 1 na saída) a gente conseguir separar esses dados.


Moons

Todos os dados que utilizamos até agora eram separáveis, ou seja, não havia sobreposição entre as classes. Que tal a gente mudar esse cenário?

x, y = make_moons(200, noise=0.20)
y = y.reshape(-1, 1)
print(x.shape, y.shape)
plt.scatter(x[:,0], x[:,1], c=list(np.array(y).ravel()), s=15, cmap='bwr')

Repare que agora nossos dados não são mais facilmente separáveis. Como será que a rede vai se comportar agora?

model = tf.keras.models.Sequential()
model.add(tf.keras.layers.Dense(30, activation=tf.nn.relu, input_shape=(x.shape[1], )))
model.add(tf.keras.layers.Dense(30, activation=tf.nn.relu))
model.add(tf.keras.layers.Dense(30, activation=tf.nn.relu))
model.add(tf.keras.layers.Dense(1, activation=tf.nn.sigmoid))
model.summary()
model.compile(optimizer='adam', loss='binary_crossentropy', metrics=['accuracy'])
hist = model.fit(x, y, batch_size=32, epochs=200, verbose=0)
plot_hist_and_predictions(hist.history, x, y, model)

Uma coisa importante de se destacar é que tivemos que usar um pouquinho mais de neurônios e camadas por conta disso. Uma dica importante sobre o treinamento de redes neurais é:

Quanto mais complexo o seu problema é, mais camadas e/ou neurônios você vai ter que usar.

Vale salientar, entretanto, que o Teorema da Aproximação Universal diz que:

Uma rede neural com apenas uma camada é suficiente para representar qualquer função, mas a camada pode ser muito grande e pode falhar em aprender e generalizar corretamente.

— Ian Goodfellow, DLB

Por conta disso, é mais fácil (e melhor) colocar mais camadas e neurônios numa rede do que estimar quantos neurônios ela vai precisar para resolver um problema.


Espiral

Deixei o meu problema preferido de classificação binária pro final! O famoso problema do espiral:

x, y = make_spiral(n_samples=100, n_class=2, radius=5, laps=1.75)
y = y.reshape(-1, 1)
print(x.shape, y.shape)
plt.scatter(x[:,0], x[:,1], c=list(np.array(y).ravel()), s=15, cmap='bwr')
model = tf.keras.models.Sequential()
model.add(tf.keras.layers.Dense(100, activation=tf.nn.relu, input_shape=(x.shape[1],)))
model.add(tf.keras.layers.Dense(100, activation=tf.nn.relu))
model.add(tf.keras.layers.Dense(100, activation=tf.nn.relu))
model.add(tf.keras.layers.Dense(1, activation=tf.nn.sigmoid))
model.summary()
model.compile(optimizer='adam', loss='binary_crossentropy', metrics=['accuracy'])
hist = model.fit(x, y, batch_size=32, epochs=100, verbose=0)
plot_hist_and_predictions(hist.history, x, y, model)

Duas coisas legais de se destacar da minha solução são: primeira, eu utilizei 3 camadas escondidas com 100 neurônios cada. Eu utilizei esse quantidade de neurônios pra que a fronteira de decisão ficasse bem suave — como pode ser vista no gráfico; segunda, repare que nas primeiras 50 epochs a rede não passou de 75% de acurácia. Porém, de repente, a acurácia subiu para aproximadamente 100%. Provavelmente, a nossa rede ficou presa em algum mínimo local por algum tempo, mas por conta do momentum presente no otimizador Adam ela conseguiu sair desse mínimo e ir em busca do mínimo global.


Se liga aí que é hora da revisão!

Em problemas de classificação binária, as regras de ouro são:

  • Você só precisa de 1 neurônio na camada de saída. Tal neurônio vai ser responsável por nos dar a “probabilidade” da amostra de entrada pertencer à uma das classes (A ou B, por exemplo). Novamente, nas camadas escondidas, você pode utilizar quantos neurônios desejar;
  • A função de ativação da última camada deve ser a sigmoid. Nas camadas escondidas, você pode utilizar qualquer uma;
  • A função de custo deve ser a binary_crossentropy.

Existem pessoas que utilizam 2 neurônios na camada de saída onde o primeiro neurônio prediz a “probabilidade” da amostra em relação à classe A e o segundo neurônio prediz a probabilidade da classe B. Nesse caso, a função de ativação da última camada deve ser a softmax e a função de custo deve ser a categorical_crossentropy. Entretanto, sabemos que prob(a) = 1-prob(b). Por isso, muitas pessoas, assim como eu, preferem utilizar o esquema que aplicamos aqui: sigmoid + binary_crossentropy. Mas, não precisa se preocupar com isso agora. Nós vamos aprender mais sobre a softmax e a categorical_crossentropy no próximo post.

Além disso, também vimos casos que precisamos adicionar mais camadas e/ou mais neurônios à uma arquitetura de rede neural; e como o otimizador certo pode te ajudar a sair de um mínimo local e chegar numa solução melhor para o seu problema.

Gimme the code, please!

O código desse notebook encontra-se neste link. Para conseguir rodar o código, você só precisa clicar em File ➡️ Save a copy in Drive. Com uma cópia do notebook, que tal testar novas arquiteturas, funções de ativação, otimizadores, epochs, etc…? Compartilha aí nos comentários se você achar alguma solução legal. Tanto faz se melhor ou pior as que encontrei aqui.

Por fim, se você gostou do post, segura o símbolo da palminha aqui do lado para deixar umas 👏👏👏. E não esquece: no próximo post vamos aprender a resolver problemas de classificação multiclasse com TensorFlow 2.0. Fica ligado!