Ultimamente, muito tem-se falado sobre “machine learning” e redes neurais, então resolvi tentar trazer à luz alguns conceitos que eu tenho aprendido e que tem pouca (ou nenhuma) informação fácil na internet. A primeira coisa a se pensar é que todo o conceito de Redes Neurais, SVM, e outras técnicas de Machine Learning são áreas da matemática, portanto tudo o que for processado numa Rede Neural tem que, de alguma forma, ser convertido para números (o que não é exatamente um problema na maioria dos casos).

As redes neurais podem ser usadas para prever determinados valores, mas são principalmente usadas no processo de classificação de algo (por exemplo, eu tenho um conjunto de sintomas e quero classificar esse conjunto em uma doença conhecida) ou clusterização/agrupamento de valores (da mesma forma, eu tenho um conjunto de características de um país e quero separá-lo em conjuntos). Existem vários modelos de redes neurais, e neste primeiro post vou falar da rede perceptron. Este post está dividido em duas partes, a primeira (este post) será a montagem de uma rede neural, e a segunda, será sobre o treinamento da rede.

Neural

Os problemas de rede neural perceptron são ditos supervisionados, isto é, precisamos de um conjunto de dados no qual conhecemos qual a classificação de cada conjunto. Um bom conjunto de dados para praticarmos é o conjunto de dados de flores iris, contido neste arquivo (UPDATE: no momento de edição deste post, o site estava com alguns problemas, por vezes não conseguindo baixar o arquivo). Neste post, estudaremos a montagem da estrutura da rede neural, e num post futuro, faremos o treinamento da rede, isto é, prepararemos-a para classificar corretamente os dados deste conjunto Iris.

Uma rede neural perceptron é semelhante à imagem acima: ela possui neurônios de entrada, de saída, e neurônios intermediários, que chamaremos de “camada oculta”. É possível ter tantas camadas ocultas quanto faz-se necessário, porém já foi provado que o que uma rede neural com “n” camadas ocultas é capaz de fazer é possível de reproduzir numa rede perceptron com apenas uma camada. Para a escolha do “número de neurônios”, é muito simples: os neurônios de “entrada” são o número de características que temos em nosso conjunto de dados. O de “saída”, o número de saídas (classes) possíveis que possuímos. Por fim, o número de neurônios “ocultos”, bom… é meio arbitrário, então o melhor é testar vários números e ver o que ficou melhor.

Sobre os “neurônios”, na verdade na rede neural perceptron os neurônios apenas somam os valores obtidos pelos neurônios da camada anterior e aplicam uma função (chamada de “função de ativação”), que tem como objetivo limitar o valor obtido para algo entre 0 e 1, ou entre -1 e 1. As setas, na rede neural, são os chamados “pesos sinápticos”, e servem para amplificar ou atenuar o valor que vem daquele neurônio e vai para outro neurônio. Vamos pensar, no desenho acima, no primeiro neurônio da camada de saída (o que está marcado como “y1”): este neurônio vai receber dados das quatro camadas ocultas (ou seja, das 3 somadas com o Bias). O cálculo que será feito será:

  • Neurônio 1 da camada oculta * peso
  • Neurônio 2 da camada oculta * peso
  • Neurônio 3 da camada oculta * peso
  • Neurônio 4 da camada oculta * peso
  • Soma-se todos estes valores
  • Aplicando tudo isso, no nosso conjunto de dados “Iris” temos quatro dados de entrada (tamanho e largura do caule e pétalas) e três possíveis classes (Setosa, Versicolor ou Virginica). Logo, temos quatro neurônios de entrada, três de saída. Para o número de camadas ocultas, vamos escolher oito neurônios (só para ficar um número fixo, por hora). No desenho acima, temos uns neurônios marcados com “1”. Estes são chamados de bias, e possuem um papel muito importante (e são muitas vezes esquecidos nas implementações): a grosso modo, eles “puxam a classificação” para uma determinada direção (digamos que estamos querendo prever o preço de um automóvel: se temos automóveis que custam, no mínimo, 20.000 e no máximo, 200.000, o “bias” vai garantir que a “curva” que prevê o preço do automóvel vá, no mínimo, surgir a partir do ponto 20.000, e não do 0).

    Ou seja, se temos, no exemplo da Iris, 4 neurônios de entrada + bias (ou seja, 5), 8 na camada oculta + bias (ou seja, 9), só os pesos da camada de entrada serão 9 para cada neurônio. Como temos 5 na entrada, temos 45 pesos. Para fazer este tipo de cálculo, podemos ou fazer manualmente (com um “for”) ou podemos fazer isso com multiplicação de matrizes (o método mais comum, por ser, estranhamente, mais simples). Para quem não se lembra, há o artigo da Wikipedia linkado anteriormente, mas simplificando, se eu multiplicar uma matriz 1×4 por uma matriz 4×1, é a mesma coisa que eu multiplicar todos os elementos da primeira matriz e somar todos os resultados. Logo, se eu multiplicar uma matrix 2×4 por uma 4×1, a matriz resultante é o resultado da multiplicação e soma de todos os elementos da primeira linha da matriz (1) com todos os elementos da matriz (2), e na segunda linha da matriz resultante, a multiplicação e soma de todos os elementos da segunda linha da matriz (1) com todos os elementos da matriz (2).

    require 'matrix'
    #Você pode fazer assim:
    a1 = 1*2 + 2*3 + 3*4 # => 20
    a2 = 2*2 + 4*3 + 2*4 # => 24
    #Ou assim:
    Matrix[[1, 2, 3], [2, 4, 2] ] * Matrix[[2],[3],[4]] # => Matrix[[20], [24]]
    

    Por fim, temos 3 resultados possíveis na rede neural: Cetosa, Versicolor, ou Virginica. Cada um dos neurônios da saída será (1) se a flor pertence àquela classe: ou seja, y1 = 1 se Cetosa, y2 = 1 se Versicolor, ou y3 = 1 se Virginica. É importante ressaltar que, na verdade, a saída da rede provavelmente não dará EXATAMENTE 1 para cada flor, então vamos precisar pegar o valor maior dos 3 neurônios de saída para nosso classificador. Outra coisa importante, é que vamos precisar tratar um pouco nosso conjunto de dados, preparando-o para receber nossa rede neural.

    Bom, com isso em mente, vamos montar o código para a rede neural. Em primeiro lugar, precisamos decidir como serão as matrizes de peso: temos 5 neurônios de entrada (com o Bias), 9 ocultos (com o Bias), e três de saída (não temos Bias na saída). Digamos que vamos montar as matrizes assim: na linha, temos os neurônios “anteriores”, e na coluna, os “posteriores”: ou seja, a matriz de pesos de entrada será uma matriz 5×8, e a oculta 9×3. Repare que não há o “bias” na coluna. Isso porque, como é possível ver no desenho, o “bias” é sempre (1), e não recebe o valor que vem anteriormente. Nossa matriz de entrada conterá, em cada linha, um exemplo, e em cada coluna, uma característica da flor. Por fim, a matriz de saída conterá, em cada linha, um exemplo (igual ao de entrada) e em cada coluna, um número indicando quanto aquela flor “pertence” àquela categoria. Parece estranho, mas vamos lá.

      Entrada:
    | <tam_caule>  <larg_caule>  <tam_petala>  <larg_petala> | #Exemplo 1
    | <tam_caule>  <larg_caule>  <tam_petala>  <larg_petala> | #Exemplo 2
    | <tam_caule>  <larg_caule>  <tam_petala>  <larg_petala> | #Exemplo 3
    
      Saída:
    |  1  0  0 | #Exemplo 1, supondo que a flor é uma Cetosa
    |  0  0  1 | #Exemplo 2, supondo que a flor é uma Virgínica
    |  0  1  0 | #Exemplo 3, supondo que a flor é uma Versicolor
    

    A primeira coisa que precisamos fazer é ler o arquivo. Basicamente, é um arquivo separado por vírgulas, então vamos lê-lo facilmente usando “read” e “split”. Porém, tudo vem como texto, então vamos transformar os primeiros valores em número:

    string = File.read('iris.data').strip.split("\n")
    
    data = string.map do |lin
      line = line.split(",")
      line[0..3].map { |e| e.to_f }
    end
    
    #data será um array tipo: [[5.1, 3.5, 1.4, 0.2], [4.9, 3.0, 1.4, 0.2], ...]
    

    Teremos então agora que normalizar os tipos de dados da “Iris”. Só para deixar claro, essa é nossa “matriz de entrada”. Se você leu a documentação do conjunto de dados, saberá que cada linha contém quatro valores: tamanho do caule, largura do caule, tamanho da pétala, e largura da pétala, tudo em centímetros. Na nossa saída, queremos que se um modelo for “Iris Setosa”, nosso array seja: [1, 0, 0], e assim por diante. Vamos fazer isso também usando um simples “case”. Como é um caso simples, vou aproveitar e converter o resultado para matrizes, assim vai ficar mais fácil montar nossa rede neural depois:

    require 'matrix'
    string = File.read('iris.data').strip.split("\n")
    
    output = []
    data = string.map do |line|
      line = line.split(",")
      output << case(line.last)
        when /Setosa/i then [1, 0, 0]
        when /Versicolor/i then [0, 1, 0]
        else [0, 0, 1]
      end
      line[0..3].map { |e| e.to_f } 
    end
    
    input_matrix = Matrix[*data]
    output_matrix = Matrix[*output]
    

    Certo, agora temos nossa entrada, e nossa saída. Agora, vamos montar nossa rede. Para isto, precisamos de duas coisas: uma delas, é definir qual é a “função de ativação”. As duas funções mais comuns são a “sigmoide“, e a “tangente hiperbólica“. Vou usar a tangente hiperbólica, principalmente porque ela está presente na biblioteca padrão do Ruby, então fica mais fácil de implementar.

    A segunda coisa que precisamos definir é os “pesos iniciais”. Por padrão, escolhemos um número randômico pequeno, a menos que a rede já esteja treinada (treinaremos esta rede no próximo post).

    def activation_function(x)
      Math.tanh(x)
    end
    
    def initial_weights(rows, cols)
      weights = 1.upto(rows).map do
        1.upto(cols).map do
          rand / 10 - 0.05
        end         
      end           
      Matrix[*weights]
    end             
                    
    input_weights = initial_weights(5, 8)
    hidden_weights = initial_weights(9, 3)
    

    Certo, temos nossos pesos e nossa função. Agora, basta criar um código para que façamos o seguinte:

  • Para cada dado que temos (a linha do input_matrix):
    • Adicionamos “1” no começo do input_matrix (Bias)
    • Cada um destes valores vai num neurônio de entrada
    • Para cada valor dos neurônios de entrada:
    • Multiplica-o pelo “peso” que liga o neurônio ao primeiro neurônio oculto
    • Multiplica-o pelo “peso” que liga o neurônio ao segundo neurônio oculto
    • etc…
  • Cada “neurônio oculto”, agora, possui pelo menos 5 valores (4 + bias). Soma-se tudo isso
  • Aplica-se a “função de ativação”. Este valor restante é o valor do neurônio oculto
  • Repete-se o passo de multiplicar + somar, mas desta vez usando os neurônios ocultos como entrada e os de saída como saída
  • Para isso, é possível fazer com multiplicação de matrizes. Não vou entrar em detalhes agora, mas já dá pra perceber que fazer todos estes “for” seria bem chatinho. Para mais detalhes, visite o Khan Academy ou algum outro site que ensine multiplicação de matrizes. A idéia é que, ao multiplicar minha matriz de entrada pela matriz de pesos, eu tenha uma matriz de saída que cada linha represente um dos meus exemplos de entrada, e as colunas de cada linha representem o valor de cada neurônio da próxima camada:

    def calculate_neuron_activation(input, weights)               
      input_with_bias = input.to_a.map { |x| [1] + x }            
      input_with_bias = Matrix[*input_with_bias]                  
      output = input_with_bias * weights                          
      output.map { |element| activation_function(element) }       
    end                                                           
                                                            
    hidden_layer_input = calculate_neuron_activation(input_matrix, input_weights)
    output = calculate_neuron_activation(hidden_layer_input, hidden_weights)
    

    Pois é, só isso. Com isso, sua rede neural já está montada, por incrível que pareça. Mas ela não faz muita coisa, ainda, já que os pesos sinápticos não estão treinados. Para isso, vamos usar o próximo post, mas para poder já testar a rede, dá pra usar o código abaixo, já com todo o conteúdo deste post em um lugar só, e com os pesos sinápticos treinados em formato YAML.

    require 'matrix'
    string = File.read('iris.data').strip.split("\n")
    
    output = []
    data = string.map do |line|
      line = line.split(",")
      output << case(line.last)
        when /Setosa/i then [1, 0, 0]
        when /Versicolor/i then [0, 1, 0]
        else [0, 0, 1]
      end
      line[0..3].map { |e| e.to_f }
    end
    
    input_matrix = Matrix[*data]
    output_matrix = Matrix[*output]
    
    def activation_function(x)
      Math.tanh(x)
    end
    
    def initial_weights(rows, cols)
      weights = 1.upto(rows).map do
        1.upto(cols).map do
          rand / 10 - 0.05
        end
      end
      Matrix[*weights]
    end
    
    input_weights = initial_weights(5, 8)
    hidden_weights = initial_weights(9, 3)
    
    def calculate_neuron_activation(input, weights)
      input_with_bias = input.to_a.map { |x| [1] + x }
      input_with_bias = Matrix[*input_with_bias]
      output = input_with_bias * weights
      output.map { |element| activation_function(element) }
    end
    
    def predict(input, input_weights, hidden_weights)
      hidden_layer_input = calculate_neuron_activation(input, input_weights)
      calculate_neuron_activation(hidden_layer_input, hidden_weights)
    end
    
    require "yaml"
    input_weights, hidden_weights = YAML.load(DATA.read)
    p predict(Matrix[data[20], data[50], data[120]], input_weights, hidden_weights)
    __END__
    ---
    - !ruby/object:Matrix
      rows:
      - - 0.0372849864702664
        - -0.0156332546645624
        - -0.0238715699261054
        - 0.032925457914512
        - -0.0293450622003801
        - 0.0332862933637725
        - 0.0851313338479441
        - 0.0817098275533929
      - - 0.0578476643374862
        - 0.245408807762434
        - -0.144603392827376
        - -0.119034260220734
        - -0.117469285311963
        - 0.126364894284633
        - -0.0153780357491309
        - -0.0143117853465741
      - - -0.0537338700114575
        - 0.216495275678505
        - -0.234675866434178
        - -0.068851403971613
        - -0.0254312231298397
        - 0.0614834603596514
        - 0.139199329989965
        - 0.227390494679163
      - - 0.052277608231477
        - -1.36157450251504
        - 0.29580417758629
        - 0.108371795505585
        - 0.122294777238661
        - -0.119984996241055
        - -0.0196898769641822
        - -0.216884349412461
      - - -0.00455635133668578
        - -0.716129211446016
        - 0.123897432893519
        - 0.0815044763149724
        - 0.0980147567643464
        - -0.0832768350631975
        - 0.00691973018422009
        - -0.115184522466829
    - !ruby/object:Matrix
      rows:
      - - 0.582001635381689
        - -0.0400105289830053
        - 0.272629096594075
      - - 0.0209608553445131
        - 0.1031675804211
        - 0.0519094513635103
      - - 0.640839517848823
        - -0.288920225062354
        - -0.230782856675196
      - - -0.445825560108867
        - -0.093877874236867
        - 0.229445031498775
      - - -0.201269158997671
        - -0.19130100204671
        - 0.0405384099226772
      - - -0.0902282875235574
        - 0.00573217477423034
        - 0.204233649761845
      - - 0.228719255821766
        - 0.208272037014373
        - -0.0153617567913742
      - - 0.13776668378643
        - -0.0700846390805183
        - 0.0366580677724069
      - - 0.260008797946042
        - -0.21067919811452
        - -0.151547681552086
    

    Na linha 48, fazemos uma predição com os dados 20, 50, e 120. O resultado será:

    Matrix[[0.814113178663225, 0.289778394491365, 0.0233395341548452], [0.167390001014268, 0.49575921493875, 0.45418190502506], [-0.170353587165746, 0.449190550215624, 0.587967228446383]]
    

    Repare que, na primeira linha da matriz, o valor mais alto é 0.8, da primeira coluna. Na segunda, o valor mais alto é 0.49, na segunda. E por último, o valor mais alto é 0.58, na terceira. Logo, o resultado é que o primeiro dado pertence à primeira classe, a família “Setosa”, o segundo, à família “Versicolor”, etc. Outra coisa que a rede neural nos mostrou também é que diferenciar uma “Setosa” das outras é simples, mas a “Versicolor” da “Virginica” não é tanto assim.

    No próximo post, vamos usar esse mesmo conjunto de dados e treinar a rede neural. Nesse caso, eu usei para treinamento apenas 3 dados, então provavelmente a rede vai errar bastante nas classificações. Vamos corrigir isto no próximo post.


    3 Comments

    Matheus Ra · 2014-11-21 at 13:09

    Cara, eu ainda nâo entendi como voce multiplicou uma matrix 5×8 por uma 9X3. Nao deveria ser uma matrix 5X9 ja que voce esta usando o bias para ambas ?

    Carlos · 2017-03-10 at 09:39

    A carga dado YAML da erro. Copiei o programa da pagina de forma integral. Esta faltando alguma coisa?

    Luis Siqueira · 2017-06-12 at 11:22

    Não usa-se a bias para as colunas no cálculo das matrizes. Ele citou isso acima.

    Comments are closed.