Grosso modo, redes neurais são funções $f:\mathbb{R}^n \rightarrow \mathbb{R}^m$ com um determinado tipo de estrutura.
Nesse ponto, não são diferentes de polinômios, cuja estrutura é a de combinação linear de monômios: $f(x) = a_0 + a_1x + \ldots + a_k x^k$.
Ou de polinômios senoidais, cuja estrutura é a de combinação linear de senos e/ou cossenos com frequências e fases variadas, como na série de Fourier: $f(t) = a_1\sin(\theta_1 + \omega_1 t) + \ldots + a_k\sin(\theta_k + \omega_k t)$.
E de muitas outras classes de funções aproximantes (polinômios de Chebyshev, polinômios de Lagrange, aproximações de Padé por funções racionais, etc.)
Uma das redes neurais mais simples que podemos considerar é a rede pró-alimentada de camadas densas, formada pela composição de funções da forma
onde $g : \mathbb{R} \rightarrow \mathbb{R}$ é chamada de função de ativação, $W$ é uma matriz de pesos e $b$, de viés.
Podemos ter $x$, $W$ e $b$ escalares, mas, em geral, $x$ e $b$ são vetores e $W$, uma matriz (ou tensores de maior dimensão). Nesse caso, $g$ age em cada elemento do vetor $Wx + b$, gerando um vetor de mesma dimensão.
Há vários pacotes Julia para faciliar a construção e o treinamento de redes neurais. O mais conhecido e utilizado deles é o Flux.jl.
using LinearAlgebra: ⋅
using Flux
using Plots
using ChainPlots
Vamos começar com um percetron, o bloco-construtor da rede neural de perceptrons originalmente proposta por Frank Rosenblatt, nos anos 1950-1960.
Pra começar, um perceptron de duas entradas é uma função que recebe dois sinais (valores) $x_1$ e $x_2$ e que, de acordo com pesos $w_1, w_2$ e de um limiar $r$, retorna um valor $0$ ou $1$.
A regra associada a esses parâmetros é
Isso pode ser generalizado para um número arbitrário de sinais de entrada $x_1,\ldots,x_n$, com pesos $w_1, \ldots, w_n$.
E, atualmente, se usa $b=-r$, denominado viés, de forma que o sinal de saída fica sendo
julia
:n = 2 # número de entradas
W = [0.6 0.8] # pesos
b = 1.0 # viés
h(s) = ifelse(s > 0.0, 1.0, 0.0) # função de ativação
l(x, h, W, b) = h.(W * x .+ b) # perceptron
l (generic function with 1 method)
l
a escalares ou vetores e/ou matrizes.l(2, h, 0.6, 1)
1.0
l([1, 2], h, [0.6 0.2], [2, 2])
2-element Vector{Float64}: 1.0 1.0
l([1 2; 3 4], h, [-1 1], [1; 2])
2×2 Matrix{Float64}: 1.0 1.0 1.0 1.0
plot(-2:0.001:2, h, legend=false,
title="Gráfico da função de ativação s ↦ ifelse(s>0, 1, 0)", titlefont=11)
surface(-5:0.1:5, -5:0.1:5, (x,y) -> l([x,y], h, W, b)[1], c=:bluesreds,
title="Gráfico do sinal de saída de um perceptron com duas entradas",
titlefont=11)
Flux.jl
.Flux.jl
.NNlib.ACTIVATIONS
:NNlib.ACTIVATIONS
24-element Vector{Symbol}: :σ :hardσ :hardtanh :relu :leakyrelu :relu6 :rrelu :elu :gelu :swish ⋮ :logσ :logcosh :mish :tanhshrink :softshrink :trelu :lisht :tanh_fast :sigmoid_fast
plot(-10:0.1:10, NNlib.σ, legend=false,
title="Gráfico da sigmoid `σ(x) = 1 / (1 + exp(-x))`", titlefont=11)
ativacoes = hcat([getproperty(NNlib, ativacao).(-10:0.1:10) for ativacao in NNlib.ACTIVATIONS[1:end]]...)
nothing
ncols = 3
nlinhas = divrem(length(NNlib.ACTIVATIONS), ncols) |> drn -> drn[1] + sign(drn[2])
plot(ativacoes, layout = grid(nlinhas, ncols), legend=false, size=(600,1000),
title=hcat(NNlib.ACTIVATIONS...), titlefont=8)
Podemos compor uma função de ativação $f:\mathbb{R}\rightarrow \mathbb{R}$ qualquer...
... com a média ponderada $w_1x_1 + \ldots + w_nx_n$ dos sinais de entrada ...
... para formar um neurônio
Isso pode ser feito explicitamente como acima.
Ou com o Dense()
do Flux.jl
.
n = 2
m = Dense(n, 1)
Dense(2 => 1) # 3 parameters
plot(m, size=(400, 200))
Dense
¶Dense
é um conjunto de "coisas", de acordo com a filosofia de múltiplo despachos do julia.
Dense
é um struct (ou "tipo composto"), que armazena uma matriz de pesos, o viés e função de ativação, representando, assim, um tipo de neurônio.
Dense
são vários inner and outer constructors (ou "construtores internos e externos"), que servem para criar uma instância do struct Dense
de maneiras diferentes.
Dense
também acarreta na definição de um método que "avalia" a ação de uma instância do struct nos sinais de entrada (a ação do neurônio em si).
fieldnames(Dense) # nomes dos campos do tipo composto
(:weight, :bias, :σ)
methods(Dense) # métodos para a construção do tipo composto
methods(m) # métodos definidos
methodswith(Dense)
show(Docs.doc(Dense))
``` Dense(in => out, σ=identity; bias=true, init=glorot_uniform) Dense(W::AbstractMatrix, [bias, σ]) ``` Create a traditional fully connected layer, whose forward pass is given by: ``` y = σ.(W * x .+ bias) ``` The input `x` should be a vector of length `in`, or batch of vectors represented as an `in × N` matrix, or any array with `size(x,1) == in`. The out `y` will be a vector of length `out`, or a batch with `size(y) == (out, size(x)[2:end]...)` Keyword `bias=false` will switch off trainable bias for the layer. The initialisation of the weight matrix is `W = init(out, in)`, calling the function given to keyword `init`, with default [`glorot_uniform`](@doc Flux.glorot_uniform). The weight matrix and/or the bias vector (of length `out`) may also be provided explicitly. # Examples ```jldoctest julia> d = Dense(5 => 2) Dense(5 => 2) # 12 parameters julia> d(rand(Float32, 5, 64)) |> size (2, 64) julia> d(rand(Float32, 5, 1, 1, 64)) |> size # treated as three batch dimensions (2, 1, 1, 64) julia> d1 = Dense(ones(2, 5), false, tanh) # using provided weight matrix Dense(5 => 2, tanh; bias=false) # 10 parameters julia> d1(ones(5)) 2-element Vector{Float64}: 0.9999092042625951 0.9999092042625951 julia> Flux.params(d1) # no trainable bias Params([[1.0 1.0 … 1.0 1.0; 1.0 1.0 … 1.0 1.0]]) ```
@which Dense(2,1)
Flux.Dense
em src/basic.jl#L71, no repositório do Flux.jl
.l(x, h, W, b) = h.(W * x .+ b)
Dense
é (obtido de src/basic.jl#L71)function (a::Dense)(x::AbstractVecOrMat)
W, b, σ = a.weight, a.bias, a.σ
return σ.(W*x .+ b)
end
σ
é o nome do campo que guarda a função de ativação do neurônio, que pode ser qualquer uma, não apenas a sigmóide σ
, importada de NNLib.σ
.Em uma rede pró-alimentada, concatenamos uma série de camadas densas.
No Flux.jl
, essa concatenação, ou composição, é feita com a função Chain
.
m = Chain(Dense(2, 4, σ), Dense(4, 8, tanh), Dense(8, 1, relu))
Chain( Dense(2 => 4, σ), # 12 parameters Dense(4 => 8, tanh), # 40 parameters Dense(8 => 1, relu), # 9 parameters ) # Total: 6 arrays, 61 parameters, 628 bytes.
plot(m, title = "$m", titlefont = 10)
Há vários tipos de camadas: recorrentes, convolucionais, pooling, etc.
Cada uma com as suas aplicações.
Redes recorrentes, por exemplo, guardam estados anteriores e são úteis onde a "história recente" de um dado é relevantes para o contexto, como em processamento de linguagem (e.g. palavras seguidas dando sentido a uma frase).
Redes convolucionais fazem uma média ponderada de apenas algumas células vizinhas, importante quando as informações são localmente correlacionadas, como em processamento de imagens (e.g. uma imagem 2D onde cada pixel está, em geral, correlacionado a vários pixels vizinhos).
Mas não vamos explorar isso a fundo. Isso é feito em cursos específicos de redes neurais. A ideia, aqui, é dar uma motivação geral e desmistificar um pouco isso.
No próximo caderno, vamos nos concentrar em redes densas pró-alimentadas e fazer alguns ajustes de dados sintéticos.