Rede neural MLP simples construída no python
Nesse notebook contém uma rede neural do tipo multiple layer perceptron construída no python, contendo o algorítimo de backpropagation para o treinamento da mesma. A rede neural será treinada para a regressão de um modelo de tensão de um capacitor em um circuito de RC série, o modelo será equacionado posteriormente.
Referências:
# Importando as bibliotecas necessárias
import numpy as np # para lidar com calculos
import matplotlib.pyplot as plt # para a plotagem
from IPython.display import Image, display, HTML
import pandas as pd
plt.rcParams["figure.figsize"] = (10,6)
Redes neurais artificiais
De forma geral uma rede neural artificial (RNA) pode ser definida como um modelo computacional inspirado nas ligações nervosas de um cérebro, capaz de realizar aprendizado de máquina direcionado para diferentes aplicações. Essa estrutura utiliza de várias unidades de processamento, chamadas de neurônio artificial. Cada neurônio artificial tem entradas, pesos para cada uma das entradas, uma função de ativação e operador de somatória como mostrado abaixo:
# Função de ativação sigmoid
Image(filename = "imagens/neuron.png", width = 600, height = 300)
Por meio dessa estrutura o neurônio artificial pode concatenar diversas entradas através de um combinador linear e uma função de ativação. Os pesos do neurônio servem um fator de ponderamento das entradas, assim conforme esse neurônio e a rede neural forem treinados esses pesos vão mudando de valor e “aprendendo” como minimizar a função erro (ou custo) escolhida.
aaa# Função de ativação
def sigmoid(t):
return 1/(1+np.exp(-t))
args = np.linspace(-10,10,100)
values = sigmoid(args)
plt.plot(args,values)[<matplotlib.lines.Line2D at 0x7fa3206ba370>]
A função custo escolhida foi a mean squared error (MSE):
# Função para o calculo do erro médio quadratico de 2 arrays
def mse(y1,y2):
return np.mean(np.square(y1 - y2))
O algorítimo de backpropagation necessita também da derivada da função de ativação, o motivo dessa necessidade será explicado mais a frente nesse notebook. Essa derivada da função sigmoid pode ser escrita como: $$f’(x) = f(x) — (1- f(x))
$$
# Derivada da função de ativação
def sigmoid_derivative(p):
return sigmoid(p) * (1 - sigmoid(p))
Equação da tensão de um capacitor em um circuito RC série
A rede neural será posta a prova em um problema de regressão para testar a sua capacidade de aprendizado. O problema de regressão escolhido é a função da tensão de um capacitor em um circuito RC série com $t>0$, considerando o capacitor descarregado como condição inicial esta pode ser definida como: $$ V_c = Vin*(1-e^\tau)$$
Na qual, $Vin$ é a tensão de entrada do sistema e $\tau$ pode ser definido como: $$ \tau = \frac{-t}{R*C} $$ Onde $R$ é a resistência $C$ é a capacitância e $t$ é o tempo.
def Vc_RC(t,r=5,c=0.1,vin=1):
"""
Tensão de um capacitor em um circuito RC
"""
tau = -t/(r*c)
vc = vin*(1 - np.exp(tau))
return vc
Rede Neural multiple layer perceptron (MLP)
A rede neural que será criada nesse notebook é MLP com 3 camadas, sendo estas a camada de entrada, a camada escondida e a camada de saída. Para a criação da rede será criado um objeto em python, para facilitar o treinamento. No treinamento será utilizado o algorítimo de Backpropagation. Este algorítimo possui 2 fazes, na propagação para frente(feed forward) e a fase de retro propagação do erro (back-propagation).
# Função de ativação sigmoid
Image(filename = "imagens/rede_mlp.png", width = 600, height = 300)
Algorítimo backpropagation
Esse algorítimo se baseia na utilização da derivada parcial da função erro (ou custo) em relação aos pesos sinápticos de cada camada. Assim, primeiramente é importante notar que o essa derivada tem como característica apontar a direção na qual os pesos têm que ser mudados para minimizar assim a função custo. Dessa forma esse algorítimo primeiramente calcula a saída da rede na faze feed-forward, calcula o erro e então calcula essa derivada parcial na faze de backpropagation e gera o sinal de correção para os pesos de todas as camadas. Esse processo é repetido em ciclos denominados de épocas, e múltiplas épocas são necessárias para a convergência de uma rede neural. A imagem abaixo ilustra visualmente o processo de minimizar a função erro tendo como base os pesos de uma camada $j$.
# Representação visual da derivada parcial
Image(filename = "imagens/derivada_parcial.png", width = 600, height = 300)
Feed forward
O método de feed forward é o qual a rede neural irá produzir o sinal de saída, de forma que nesse método são utilizadas as entradas, os pesos, bias e a função de ativação de cada neurônio das camadas instanciadas. Dessa forma será propagado os sinais de entrada mediados pelos pesos, multiplicados pela função de ativação e ajustados pelo Bias de cada camada, nesse exemplo o bias é zero para simplificar o exemplo.
O sinal de saída $y_{ij}$ de um neurônio $i$ localizado na camada $j$ pode ser escrito da forma:
$$ y_{ij} = f(\sum_{i=0}^n {X_{j}*W_{ij}}) $$
Na qual, $W_{ij}$ a representa o vetor de pesos da camada $j$ associado ao neurônio de índex $i$ dessa camada, $X_j$ o vetor de entrada da camada $j$ e consequentemente do neurônio $ij$ e a função $f(x)$ é a função de ativação, nesse notebook será utilizado a função sigmoid. Nesse exemplo o vetor de entrada será um valor único, ou seja, o vetor $W_{ij}$ do neurônio de índex $ij$ é um valor único. Dessa forma, podemos equacionar o vetor de saída de cada camada $j$ da forma: $$ \hat{Y}_j = f(X_j.W_j) $$
Assim é possível achar a equação de saída para cada camada. Note que a camada de entrada só apresenta os valores para a rede e não tem valores de pesos associados, assim iniciaremos o equacionamento da camada escondida e após isso será equacionado a saída da camada de saída. Assim, falando que a camada escondida tem o índice $j=0$ temos: $$ \hat{Y_0} = f(X_0.W_0) $$
Onde $X_0$ são as entradas introduzidas pela camada de entrada da rede neural. Agora equacionaremos a camada de saída que possui o índice $j=1$: $$ \hat{Y_1} = f(X_1.W_1)$$ $$ \hat{Y_1} = f(\hat{Y}_0.W_1)$$ $$\hat{Y_1} = f(f(X_0.W_0).W_1)$$ Assim temos a saída total da rede.
Note que para camada escondida o vetor de entrada $X_j$ é a entrada da rede neural, e para camada de saída o vetor de entrada $X_1$ é igual à saída da camada escondida, ou seja, $X_1 = \hat{Y}_0$
Backpropagation
O último passo no algorítimo de backpropagation é a retro propagação do erro. Nesse passo a rede neural atualiza os valores dos pesos considerando o erro da saída gerada pela rede e da saída desejada. Para fazer isso o algorítimo calcula a derivada parcial da função custo em relação aos pesos, nominalmente o gradiente da função custo. Começaremos da última camada, $j = 1$ e calcular a derivada parcial da função custo em relação aos pesos $j$: $$ \frac{\partial{MSE(Y,\hat{Y})}}{\partial{W_{1}}} $$
Onde $Y$ é a label ou saída desejada e $\hat{Y}_1$ é a saída da rede.
Para calcular essa derivada parcial é necessário expandi-la utilizando a regra da cadeia:
$$\frac{\partial{MSE(Y,\hat{Y}1)}}{\partial{W{ij}}}= \frac{\partial{MSE(Y,\hat{Y}_1)}}{\partial{\hat{Y}_1}} \frac{\partial{\hat{Y}1}}{\partial{z_1}} \frac{\partial{{z_1}}}{\partial{W{1}}}$$
Onde $z_1$ representa $W_1.X_1$ que também pode ser chamado de potencial de ativação.
Por partes, temos: $$ \frac{\partial{MSE(Y,\hat{Y}_1)}}{\partial{\hat{Y}_1}} =\frac{\partial{(Y — \hat{Y}_1)²}}{\partial{\hat{Y}_1}}$$
Onde o MSE é a média do erro quadrático: $\frac{1}{n}\sum_{0}^{n-1} (Y -\hat{Y})$, como nesse exemplo a saída é unitária podemos retirar a somatória de termos de $Y$ e $\hat{Y}$. Assim a derivada do MSE em relação a $\hat{Y}$ fica: $$ \frac{\partial{MSE(Y,\hat{Y}_1)}}{\partial{\hat{Y}_1}} = 2(Y — \hat{Y}_1)$$
Para o segundo termo da regra da cadeia temos: $$\frac{\partial{\hat{Y}_1}}{\partial{z}_1} = \frac{\partial{f(z_1)}}{\partial{z_1}} = f(z_1)’$$
Ou seja, o resultado do segundo termo é a derivada da função de ativação, assim esta será a derivada da função sigmoid:
$$f(z_1)’ = f(z_1) — (1- f(z_1))$$ Para o último termo da regra da cadeia temos:
$$ \frac{\partial{z_1}}{\partial{W_1}} = \frac{\partial{W_1.X_1}}{\partial{W_1}} = X_1$$
Assim a solução dessa derivada parcial para a camada de saída fica como segue:
$$\frac{\partial{MSE(Y,\hat{Y_1})}}{\partial{W_1}} = 2(Y_1-\hat{Y_1}) . f(z_1)’ .X_1.$$
$$\frac{\partial{MSE(Y,\hat{Y_1})}}{\partial{W_1}} = 2(Y_1-\hat{Y_1}) . f(z_1)’ .Y_0.$$
Para a camada intermediária, com o índex $j=0$, a derivada parcial tem que ser em relação aos pesos $W_0$, assim de forma semelhante a equação da derivada parcial fica: $$ \frac{\partial{MSE(Y,\hat{Y_1})}}{\partial{W_0}} = \frac{\partial{MSE(Y,\hat{Y}_1)}}{\partial{\hat{Y}_1}} \frac{\partial{\hat{Y}1}}{\partial{z_1}} \frac{\partial{{z_1}}}{\partial{\hat{Y}{0}}} \frac{\partial{{\hat{Y}0}}}{\partial{z{0}}} \frac{\partial{{\hat{z}0}}}{\partial{W{0}}}.$$
Com o único termo inteiramente novo sendo $\frac{\partial{{z_1}}}{\partial{\hat{Y}{0}}}$, para soluciona-lo temos: $$\frac{\partial{{z_1}}}{\partial{\hat{Y}{0}}} = \frac{\partial{{W_1.Y_0}}}{\partial{\hat{Y}_{0}}} = W_1$$
Assim a solução da equação da derivada parcial para a camada intermediária fica: $$ \frac{\partial{MSE(Y,\hat{Y_1})}}{\partial{W_0}} = 2(Y_1-\hat{Y_1}) . f(z_1)’ .W_1 . f(z_0)’. X_0 $$
Por fim, após esses cálculos o algorítimo de backpropagation pode atualizar cada vetor peso $W_{j}$, onde $\alpha$ representa a taxa de aprendizagem, a qual ajuda na convergência da rede.
$$W_{j} = W_{j} — \alpha \frac{\partial{MSE(Y,\hat{Y}1)}}{\partial{W{j}}}.$$
# Class definition
class NeuralNetwork:
def __init__(self, x, y, n=15):
"""
Definição de um objeto de rede neural
argumentos:
x: a entrada de treino
y: a saída desejada no treino
n: Número de neurônios na camada escondida
"""
self.entrada = x
self.pesos_0 = np.random.rand(self.entrada.shape[1],n)
self.pesos_1 = np.random.rand(self.pesos_0.shape[1],1)
self.y = y
self.saida = np. zeros(y.shape)
def feedforward(self):
# Potencial de ativação ou termo z no equacionamento
self.pot_ativ_0 = np.dot(self.entrada, self.pesos_0)
# Saída da camada 0
self.camada_0 = sigmoid(self.pot_ativ_0)
# Equações da camada 1
self.pot_ativ_1 = np.dot(self.camada_0, self.pesos_1)
self.camada_1 = sigmoid(self.pot_ativ_1)
# Nota-se que a saída da camada 1 é a saída da rede
return self.camada_1
def backprop(self):
d_pesos_1 = np.dot(self.camada_0.T, 2*(self.y - self.saida)*sigmoid_derivative(self.pot_ativ_1))
d_pesos_0 = np.dot(self.entrada.T, np.dot(2*(self.y -self.saida)*sigmoid_derivative(self.pot_ativ_1), self.pesos_1.T)*sigmoid_derivative(self.pot_ativ_0))
self.pesos_0 += d_pesos_0*0.1
self.pesos_1 += d_pesos_1*0.1
def train(self):
self.saida = self.feedforward()
self.backprop()
def predict(self,x):
self.entrada = x
self.saida = self.feedforward()
return self.saida
Treinando a rede neural
Com a classe de rede neural MLP definida podemos gerar dados da equação da tenção do capacitor para treinar a rede. É importante notar que ciclo fechado do feedforward e backpropagation é chamado de época, e para treinar efetivamente uma rede neural é necessário várias épocas.
Para treinar essa rede vamos dividir 60% dos dados gerados pela função de tenção para treino e o restante para testar a acurácia de saída da rede.
# Definindo um modelo matemático
t = np.arange(0,3,0.1)
vc = Vc_RC(t)
t = t/np.amax(t)
# Dividindo os datasets de treino e teste
porcent_treino = 60
tam_treino = int(len(vc)*porcent_treino/100)
# Entrada e saída de treino
x_train = t[:tam_treino]
y_train = vc[:tam_treino]
# Entrada e saída de teste
x_test = t[tam_treino:]
y_test = vc[tam_treino:]
# Transformando os vetores entrada e saída em coluna
x_train = x_train.reshape(tam_treino,1)
y_train = y_train.reshape(tam_treino,1)
x_test = x_test.reshape(len(x_test),1)
# Definindo o objeto da rede neural
nn_vc_model = NeuralNetwork(x_train,y_train,n=15)
Vamos primeiramente checar o comportamento do dataset de treino e de teste da rede, para entender o que a rede tentará aprender e o que esta rede tentará prever.
plt.plot(x_train.flatten(),y_train.flatten(),'b', label="Treino")
plt.plot(x_test.flatten(),y_test.flatten(),'r', label="Test")
plt.legend()
plt.show()
Uma forma de notar que a rede neural está aprendendo é notar a variação dos valores dos pesos no inicio e após as epocas de treinamento, assim veja os valores de peso iniciais:
# Mostrando os pesos antes do treino
pesos_0 = np.array(nn_vc_model.pesos_0)
pesos_1 = np.array(nn_vc_model.pesos_1)
plt.scatter(np.arange(len(pesos_0[0])),pesos_0[0], label="Pesos W0")
plt.scatter(np.arange(len(pesos_1.flatten())),pesos_1.flatten(), label="Pesos W1")
plt.legend()<matplotlib.legend.Legend at 0x7fa3205c9820>
# Treinando a rede por 500 epocas
erro = list()
for i in range(500):
saida_rede = nn_vc_model.feedforward() # calculando a saida da rede
erro.append(mse(y_train, saida_rede)) # calculabdo o mse e guardando em um vetor
nn_vc_model.train() # utilizando um metodo do objeto rede neural para treinar
Agora note a diferença dos pesos inicais e depois de 500 epocas de treinamento.
# Mostrando os pesos das camadas
pesos_0 = np.array(nn_vc_model.pesos_0)
pesos_1 = np.array(nn_vc_model.pesos_1)
plt.scatter(np.arange(len(pesos_0[0])),pesos_0[0], label="Pesos W0")
plt.scatter(np.arange(len(pesos_1.flatten())),pesos_1.flatten(), label="Pesos W1")
plt.legend()<matplotlib.legend.Legend at 0x7fa32052cdc0>
Uma maneira de visualizar a convergencia da rede é visualizar a função do erro em relação as epocas:
fig = plt.figure(figsize=(10, 6), dpi=80)
plt.plot(erro,'r')
plt.xlabel("epoca")
plt.ylabel("erro quadratico médio")
plt.show()
Por fim vamos visualizar a saída da rede utilizando como entrada dados que ela não foi treinada:
# transformando as matrizes de entrada em vetores para a plotagem
t = t.flatten()
vc = vc.flatten()
# Transformando a saida da rede neural para a plotagem
saida_rede = nn_vc_model.predict(x_test)
fig = plt.figure( figsize=(10, 6), dpi=80)
plt.plot(x_test.flatten(), y_test, 'b', label="tensão VC calculada")
plt.plot(x_test.flatten(), saida_rede, 'r', label="tensão VC rede")
plt.legend()
plt.grid()
plt.xlabel("tempo")
plt.ylabel("tensão")
plt.show()
A uma primeira vista as curvas podem parecer estar longe uma da outra, assim é interessante ter noção do ambito geral. Podemos fazer isso colocando os dados de treinamento juntamente com a saída da rede e dados de teste.
fig = plt.figure( figsize=(10, 6), dpi=80)
plt.plot(x_test.flatten(), y_test, 'b', label="tensão VC calculada")
plt.plot(x_test.flatten(), saida_rede, 'r', label="tensão VC rede")
plt.plot(x_train.flatten(), y_train.flatten(), 'g', label="tensão VC de treino")
plt.legend()
plt.grid()
plt.xlabel("tempo")
plt.ylabel("tensão")
plt.show()
Considerações finais
Com isso temos o processo completo de criação de uma rede neural simples de 1 neurônio de entrada e saída, com a pissibilidade de vários neurônios na camada escondida. Assim podemos resolver problemas simplórios e analizar o comportamento do treinamento da rede de ponta a ponta, para aplicações mais robustas existem soluções mais utilizadas no mercado, como o tensorflow e o pytorch.