PythonJogaPong — Parte 3 Criando e treinando uma Neural Network
Esse post faz parte da série PythonJogaPong, no qual explico como criar um sistema que aprende com dados de treino a jogar o jogo pong sozinho. Veja também as outras partes: Parte 1 Parte 2 Parte 4
Nesta etapa criaremos uma rede neural utilizando tensorflow e treinaremos ela com os dados obtidos da gravação dos nossos movimentos (assunto tratado na parte 2 dessa série). Com a neural network treinada, podemos então dar entrada de novos dados para que ela nos diga qual ação tomar no jogo. O código desta parte está em:
https://github.com/pilotorobo/pongplay/blob/master/neural_net.py
Vamos ao código:
import tensorflow as tf
import numpy as np
from collections import Counter
from sklearn.model_selection import train_test_split
Para esta etapa precisamos de algumas bibliotecas:
- tensorflow é a biblioteca que usaremos para criar e treinar as neural networks
- numpy é usada para tratar vetores e matrizes
- Counter é uma classe utilizada para contar objetos
- train_test_split é uma função importada da biblioteca scikit-learn para misturar e separar nossos dados em dados de treino e dados de teste
A maioria das funcionalidades dessa parte está contida na classe NeuralNetwork
e como ela acaba sendo um pouco extensa, vamos dividir os códigos da classe em três partes.
Aqui no construtor do objeto criamos nossa rede neural. Importante notar que temos 6 features:
- Posição horizontal da bola
- Posição vertical da bola
- Posição horizontal da barra 1
- Posição vertical da barra 1
- Posição horizontal da barra 1
- Posição vertical da barra 1
Porém, no código definimos que temos 8, isso é devido pelo fato que temos que adicionar mais duas features para que a rede aprenda corretamente: a velocidade horizontal e vertical da bola. Mais na frente mostramos como fazemos isso.
Quando captamos os dados, preenchemos o valor corresponde as teclas apertadas com 0 (nenhuma tecla), 1 (tecla cima) e 2 (tecla baixo). Como precisamos de variáveis categoricas, fazemos isso com a função tf.one_hot
.
Poderíamos criar as conexões da rede no “braço”, multiplicando e adicionando matrizes, mas ao invés disso utilizamos a função tf.layer.dense
que já faz tudo isso por nós, bastando passar os parâmetros de cada camada da rede. Utilizamos a camada de entrada com 8 unidades, 2 intermediárias com 20 unidades cada uma e a camada de saída com 3 unidades. Embora não tenha feito muitos experimentos, esse tamanho se mostrou suficiente para bons resultados.
Criamos um tensor para calcular o erro (cost) com cross-entropy para utilizarmos para otimizar a rede e outro de performance (accuracy) para medir o desempenho. O algoritmo de otimização Adam se mostrou melhor que o tradicional Gradient Descent então o utilizamos com um learning rate de 0.01 para treinar a rede.
Criamos a função fit
para treinarmos nossa neural network. Utilizamos a função train_test_split
para separamos os inputs e outputs coletados em inputs/ouputs de treino (para ensinarmos a rede) e inputs/outputs de teste (para medirmos a performance da rede). Iremos utilizar 25000 epochs, isso é, iremos treinar nossa rede com os mesmo dados de treino 25 mil vezes. Criamos a nossa sessão para executar as operações com tf.Session
e iniciamos todas as variáveis declaradas (basicamente os weights e biases criados por tf.layers.dense
) com a função tf.global_variables_initializer
. Executamos as operações de otimização da rede (optimizer
) e cálculo de erro (cost
) com os dados de treino ao mesmo tempo que medimos a performance da rede com a operação accuracy
com os dados de validação. A cada 500 epochs, exibimos a situação atual da rede (performance e erro). Por fim, verificamos a performance da rede com a mesma operação accuracy
mas dessa vez com os dados de teste.
Aqui temos três funções:
predict
: Utilizamos para inferência. Executa a operaçãologits
na rede treinada (utilizando a mesma sessão) para retornar a ação que deve ser tomada para o conjunto de dados X passado.save
: Salva o estado atual da sessão da rede (utilizando a classetf.train.Saver
), salvando várias informações, inclusive o valor dos pesos (weights) que foram calculados quando fizemos o treinamento. Utilizamos isso para não ter que treinar a rede toda vez que o projeto for fechado e aberto.load
: Carrega uma rede neural previamente salva utilizando a funçãosave
.
Aqui utilizamos a função set_ball_speed
para inserir no dataset a velocidade da bola em cada instante. Por que precisamos desta informação? Vejamos a imagem abaixo:
Como saber se devemos subir ou não nossa barra para defender? Se a bola estiver vindo em nossa direção, devemos subir, caso contrário podemos ficar parados. Logo precisamos saber para onde a bola está se movimentando, isto é sua atual variação no espaço, informação conhecida como velocidade.
Como cada ponto dos dados de treino contém a posição atual da bola, basta para cada frame verificarmos a posição anterior da bola e subtrair da atual: speed_datapoints = dataset[1:,2] — dataset[0:-1,2]
. Como para o primeiro frame não temos a posição anterior, começamos a contar a partir do segundo datapoint e teremos um datapoint a menos, o que não influencia em nada. Por fim, inserimos os dados calculados em novo dataset e retornamos ele.
Chegando ao final do arquivo, carregamos os dados de treino gravados no post anterior e verificamos a frequência de cada label do dataset, isso é, quantas vezes apertamos para cima, para baixo ou ficamos parados nos dados de treino gravados. Isso é importante pois ficamos muito mais tempo parados do que movimentando a barra, logo se ficarmos 70% do tempo parado e nossa rede tiver um desempenho de 70%, existem grandes chances dela ter decidido simplesmente ficar parada o tempo todo, então o ideal é que tenhamos um desempenho maior que 70%. Calculamos então a velocidade da bola com set_ball_speed
, separamos os dados de treino em inputs e output e finalmente treinamos e salvamos nossa rede para uso posterior utilizando as funções comentadas anteriormente.