A criação de redes neurais complexas e com diferentes arquiteturas em Python é essencial para quem tem interesse real de se aprofundar na área. Porém, isso envolve uma compreensão genuína sobre a implementação e o funcionamento de redes neurais.
Neste post, vamos executar um passo a passo para a criação de uma rede neural do zero. Ou seja, sem nenhuma biblioteca específica de machine learning ou deep learning. Usaremos apenas bibliotecas de computação numérica e manipulação de dados.
Procedimentos
A implementação de redes neurais possui algumas etapas gerais, descritas na imagem abaixo.
Para esse post, criaremos uma rede neural do zero para fazer a classificação do conjunto de dados iris.
O conjunto de dados de iris é tipicamente usado para problemas de classificação. Ele contém dados quantitativos sobre três tipos de flores iris e suas classificações correspondentes. A classificação dos tipos de iris com uma rede neural é feita com aprendizagem supervisionada. Neste tipo de aprendizagem, a rede é exposta aos dados e, para cada exposição, precisa mapear uma saída. A saída da rede é então comparada com valores-alvo. O resultado desta comparação é usado para atualizar os parâmetros internos da rede.
Os dados que serão usados podem ser baixados daqui.
As bibliotecas
import numpy as np
import pandas as pd
filename = 'path_to_data/iris/iris.data'
names = ["sepal length", "sepal width", "petal length", "petal width", "Class"]
df = pd.read_csv(filename, names = names)
Conhecendo os dados
O conjunto de dados iris contém 150 exemplos de flores pertencentes a 3 classes (50 exemplo de cada classe). Cada exemplo tem 4 atributos preditivos numéricos:
- sepal length em cm (comprimento das sépalas)
- sepal width em cm (largura das sépalas)
- petal length em cm (comprimento das pétalas)
- petal width em cm (largura das pétalas)
Além dos atributos, os dados também tem uma coluna com a classificação de cada um de seus exemplos em uma de três classes possíveis:
- Iris Setosa
- Iris Versicolour
- Iris Virginica
Antes de começar a escrever o código da rede neural, é preciso conhecer um pouco os dados para identificar quais pré-processamentos precisam ser feitos. Portanto, vamos olhar alguns exemplos:
print(df.head(5))
A execução do comando acima mostra que os dados (abaixo) não estão randomizados. A randomização dos dados é importante para garantir que o treinamento da rede não seja enviesado.
Além da ausência de randomização, os dados contêm strings e valores numéricos não normalizados. Esses pontos precisam ser tratados na etapa de pré-processamento.
sepal length sepal width petal length petal width Class
0 5.1 3.5 1.4 0.2 Iris-setosa
1 4.9 3.0 1.4 0.2 Iris-setosa
2 4.7 3.2 1.3 0.2 Iris-setosa
3 4.6 3.1 1.5 0.2 Iris-setosa
4 5.0 3.6 1.4 0.2 Iris-setosa
Para confirmar a suspeita de que os dados não estão normalizados, é importante analisar melhor algumas de suas características.
print(df.describe())
E, de fato, os resultados obtidos mostrados abaixo indicam que será necessário normalizá-los, pois os valores dos atributos variam bastante entre si.
sepal length sepal width petal length petal width
count 150.000000 150.000000 150.000000 150.000000
mean 5.843333 3.054000 3.758667 1.198667
std 0.828066 0.433594 1.764420 0.763161
min 4.300000 2.000000 1.000000 0.100000
25% 5.100000 2.800000 1.600000 0.300000
50% 5.800000 3.000000 4.350000 1.300000
75% 6.400000 3.300000 5.100000 1.800000
max 7.900000 4.400000 6.900000 2.500000
Também é fundamental garantir que não existe nenhum dado nulo. Essa verificação pode ser feita com o comando abaixo.
print(df.isnull().sum())
O resultado mostrado abaixo indica que não existem dados nulos.
5.1 0
3.5 0
1.4 0
0.2 0
Iris-setosa 0
dtype: int64
Pré-processamento dos dados
O primeiro ponto do pré-processamento dos dados é a separação das variáveis dependentes (y_init) e independentes (X). Uma forma de fazer isso com o pandas é usar o comando .iloc(). As variáveis independentes X incluem todas as linhas de todas as colunas dos dados menos a última. Já y_init consiste na última coluna. O método values retorna os valores de cada variável.
X = df.iloc[:,:-1].values
y_init = df.iloc[:,-1].values
Depois, precisamos salvar os nomes das variáveis. Esse procedimento é necessário para randomizá-las antes de treinar a rede.
cols = df.columns
atributos = cols[0:4]
targets = cols[4]
Frequentemente, dados brutos são compostos por atributos com escalas variadas. Para o treinamento de redes neurais, é comum normalizar os dados numéricos para que seus valores fiquem entre 0 e 1. No nosso exemplo, isso é feito sobre todos os atributos salvos em X com os comandos mostrados abaixo. Os dados y_init consistem em strings e serão tratados adiante.
for atributo in atributos:
df[atributo] = (df[atributo] - df[atributo].min()) / (df[atributo].max() - df[atributo].min())
No passo seguinte, fazemos a randomização do conjunto de dados.
dfNorm = pd.DataFrame(df)
indices = np.array(dfNorm.index)
np.random.shuffle(indices)
X = dfNorm.reindex(indices)[atributos]
y_init = dfNorm.reindex(indices)[targets]
Depois, é preciso transformar os valores de y_init em um NumPy array.
y_init = np.array(y_init).astype(object)
Os valores de y_init também devem ser convertidos para one hot encoding. Para essa conversão, inicializamos um novo array de zeros com 3 linhas. Cada flor será representada nesse array por um código do tipo 001, 010 ou 100. Essa transformação de y_init cria o novo array y.
y = np.zeros([3, y_init.shape[0]])
y[0,:] = np.where(y_init[:] == 'Iris-setosa', 1, 0)
y[1,:] = np.where(y_init[:] == 'Iris-versicolor', 1, 0)
y[2,:] = np.where(y_init[:] == 'Iris-virginica', 1, 0)
One hot encoding: One-hot encoding é um método de conversão de dados. Com ele, cada valor categórico é convertido em um valor inteiro representado como um vetor binário. Todos os valores são zero e o índice é marcado com 1.
Depois, transformamos X em um array NumPy e reposicionamos suas linhas e colunas calculando sua matriz transposta.
X = np.array(X).astype(np.float32)
X = X.T
Conjuntos de treino e teste
A etapa seguinte consiste na criação dos conjuntos de treino e teste para as variáveis dependentes e independentes. No código abaixo, separamos 70% dos exemplos do conjunto de dados para treino (train) e os 30% restantes para teste (test).
idx = int(X.shape[1] * 0.7)
X_train, X_test = X[:,:idx], X[:,idx:]
y_train, y_test = y[:,:idx], y[:,idx:]
Definição da estrutura da rede neural
Do ponto de vista matemático, uma rede neural é um algoritmo que visa mapear uma coleção de dados de entrada em uma saída. Para realizar essa tarefa, uma rede neural do tipo feedforward possui pelo menos os seguintes componentes:
- Uma camada de entrada: corresponde aos atributos do conjunto de dados que a rede deve aprender a mapear numa saída adequada.
- Camadas ocultas: camadas intermediárias entre a entrada da rede e sua saída.
- Uma camada de saída: usada para fazer a classificação.
As camadas ocultas e de saída consistem, cada uma, em um conjunto de neurônios artificiais. Por sua vez, cada neurônio contém um conjunto de pesos e bias.
A definição do número de camadas de uma rede neural depende do problema considerado. Como nosso problema é uma classificação simples, uma rede com uma camada oculta e uma de saída já será suficiente para classificá-lo.
Também precisamos determinar quantos neurônios serão inseridos em cada camada. A camada de saída tem o número de neurônios definido pelo número de categorias da nossa classificação (n_y). Já o número de neurônios da camada oculta é um parâmetro que pode variar. Como nosso problema é simples, vamos usar apenas quatro (n_h).
def layer_sizes(X, Y):
n_x = X.shape[0] # número de atributos
n_h = 4 # número de neurônios ocultos
n_y = Y.shape[0] # número de classes
return n_x, n_h, n_y
Inicializando os parâmetros da rede neural
É comum usar zero como valor inicial para os biases (b) e valores randômicos pequenos para os pesos (W). Note que nossa rede tem dois conjuntos de W e de b, um para cada camada. Cada conjunto é uma matriz com as dimensões correspondentes ao tamanho de cada camada.
def init_params(n_x, n_h, n_y):
W1 = np.random.rand(n_h, n_x) * 0.01
b1 = np.zeros((n_h, 1))
W2 = np.random.rand(n_y, n_h) * 0.01
b2 = np.zeros((n_y, 1))
return W1, b1, W2, b2
Neurônios
Na sequência, definimos as funções que descrevem os neurônios da rede.
A primeira função calcula o valor agregador Z.
def agregador(X, W, b):
Z = np.dot(W, X) + b
return Z
O valor agregador precisa passar por uma função de ativação. Para essa rede, definimos duas. A sigmoide será usada para a camada oculta e a função softmax será definida para a camada de saída.
def sigmoide(Z):
return 1/(1+np.exp(-Z))
def softmax(Z):
A = np.exp(Z) / sum(np.exp(Z))
return A
Propagação forward
Após a definição dos neurônios, eles são organizados nas camadas da rede com a função forward_prop. Ela é responsável por calcular o valor de Z para cada camada e passá-lo pela função de ativação correspondente. O valor resultante calculado para a camada oculta (A1) atua como estímulo da camada de saída (linha 4).
def forward_prop(X, W1, b1, W2, b2):
Z1 = agregador(X1, W, b1)
A1 = sigmoide(Z1)
Z2 = agregador(X2, A1, b2)
A2 = softmax(Z2)
return Z1, A1, Z2, A2
Predição e acurácia
As ativações dos neurônios na camada de saída são usados para fazer as predições da rede. Ou seja, eles precisam ser usados para identificar a qual categoria de iris um certo dado pertence. Isso é feito com a função abaixo que simplesmente identifica o maior valor de ativação na camada de saída.
def get_predictions(A2):
return np.argmax(A2, 0)
Também é preciso criar uma função para computar a acurácia da rede. Esse processo é feito comparando suas predições com os valores-alvo armazenados em y.
def get_accuracy(predictions, Y):
return np.sum(predictions == Y) / Y.size
Backpropagation e atualização dos parâmetros da rede
Na sequência, calculamos a backpropagation. Em essência, ela computa a diferença entre as saídas da rede e os valores-alvo de y e usa o resultado obtido para ajustar todos os parâmetros do modelo. Na prática, isso é feito com a regra da cadeia do cálculo. Ela é usada para computar um gradiente para cada parâmetro da rede neural. Para entender melhor os detalhes matemáticos envolvidos nessas operações, clique aqui.
def backward_prop(Z1, A1, Z2, A2, W1, W2, X, Y):
m = X.shape[1]
dZ2= A2 - Y
dW2 = 1 / m * np.dot(dZ2, A1.T)
db2 = 1 / m * np.sum(dZ2, axis = 1, keepdims = True)
dZ1 = np.dot(W2.T, dZ2) * A1 * (1 - A1)
dW1 = 1 / m * np.dot(dZ1, X.T)
db1 = 1 / m * np.sum(dZ1, axis = 1, keepdims = True)
return dW1, db1, dW2, db2
Os gradientes computados são usados para atualizar os parâmetros do modelo através da função mostrada abaixo. O parâmetro alpha é a taxa de aprendizagem (learning rate) do modelo. Seu valor pode ser variado para ajustar o modelo.
def update_params(W1, b1, W2, b2, dW1, db1, dW2, db2, alpha):
W1 = W1 - alpha * dW1
b1 = b1 - alpha * db1
W2 = W2 - alpha * dW2
b2 = b2 - alpha * db2
return W1, b1, W2, b2
O treinamento
O passo final para o treinamento é a implementação da função que realiza a otimização dos parâmetros da rede. Usaremos o gradient descent como método de otimização para esse post. Nossa função contém um loop no qual os parâmetros da rede neural são repetidamente atualizados com as funções definidas anteriormente. Esse procedimento faz a acurácia do modelo aumentar gradativamente.
def gradient_descent(X, Y, alpha, iterations):
n_x, n_h, n_y = layer_sizes(X, y)
W1, b1, W2, b2 = init_params(n_x, n_h, n_y)
for i in range(iterations):
Z1, A1, Z2, A2 = forward_prop(X, W1, b1, W2, b2)
dW1, db1, dW2, db2 = backward_prop(Z1, A1, Z2, A2, W1, W2, X, Y)
W1, b1, W2, b2 = update_params(W1, b1, W2, b2, dW1, db1, dW2, db2, alpha)
if i % 200 == 0:
predictions = get_predictions(A2)
print(get_accuracy(predictions, np.argmax(Y, 0)))
return W1, b1, W2, b2
alpha, iterations = 0.5, 1000
W1, b1, W2, b2 = gradient_descent(X_train, y_train, alpha, iterations)
A execução desse código treina a rede. A acurácia final que obtivemos variou entre 0.95 e 0.97. Essa variação é comum, pois os parâmetros iniciais da rede são definidos de forma randômica.
Fase de teste e predições
Após o treinamento, os conjuntos X e y de testes são usados para verificar a acurácia da rede sobre dados inéditos. Na função abaixo, fazemos predições com o modelo com o conjunto de dados de teste. Depois usamos os resultados obtidos para estimar novamente a acurácia da rede (linha 7).
def make_predictions(X, W1, b1, W2, b2):
_, _, _, A2 = forward_prop(X, W1, b1, W2, b2)
predictions = get_predictions(A2)
return predictions
predictions = make_predictions(X_test, W1, b1, W2, b2)
print(get_accuracy(predictions, np.argmax(y_test, 0)))
A acurácia que obtivemos nessa etapa foi de 0.977. Com isso, a rede neural está pronta para fazer previsões sobre dados novos. Abaixo, mostramos apenas uma forma como isso pode ser implementado.
def new_predictions(X, W1, b1, W2, b2, index):
current_image = X[:, index, None]
prediction = make_predictions(X[:, index, None], W1, b1, W2, b2)[0]
print(prediction)
index = 1
new_predictions(X_train, W1, b1, W2, b2, index)
Conclusão
Nesse post, fizemos a implementação de uma rede neural feedforward do zero para classificar o conjunto de dados iris. A implementação não é trivial para pessoas iniciantes na área e precisa ser treinada. Para quem tem interesse, um bom exercício é repetir essa implementação com outros dados e variando parâmetros como o número de camadas da rede neural para que suas etapas se tornem mais compreensíveis.