Até o momento, as aplicações de rede neurais utilizavam conjuntos pequenos de entrada. Não apenas isto, como também tal conjunto já representava diretamente a grandeza que servia de entrada. Entretanto, na maioria das aplicações reais, o conjunto de dados bruto deve ser convertido no vetor de características, que alimentará a rede neural \cite{yamanaka}.
Em ocasiões será necessário converter categorias em estruturas binárias, por exemplo, para que tal valor não afete o resultado das multiplicações. Técnica esta conhecida como One-hot encoding. Nela, as categorias 1, 2 e 3 seriam convertidas em colunas 1 0 0, 0 1 0 e 0 0 1, respectivamente.
Em outras, pode ser necessário que os dados sejam normalizados antes de servirem como entradas da rede neural. Isto ocorre quando as grandezas das variáveis são muito diferentes. Assim, pode-se aplicar a técnica do Zscore (eq. eq1), que desloca os dados para a média e faz o desvio padrão ser igual a 1, a do MinMax (eq. eq2), na qual os novos valores variam entre 0 e 1, ou outras, como a da equação eq3, por exemplo, que faz os dados variarem entre y_min e y_max.
\begin{equation} z = \frac{x - \operatorname{mean}(x)}{\operatorname{stdev}(x)} \end{equation}
\begin{equation} z = \frac{x - \operatorname{min}(x)}{\operatorname{max}(x) - \operatorname{min}(x)} \end{equation}
\begin{equation} z = \frac{(x - \operatorname{min}(x))(ymax - ymin)}{\operatorname{max}(x) - \operatorname{min}(x)} + ymin \end{equation}
Outro aspecto interessante a apontar em aplicações reais é a utilização de conjuntos de treinamento, de validação e de teste, para melhorar a capacidade de generalização da rede neural. Assim, o modelo é treinado utilizando o conjunto de treinamento e avaliado utilizando o conjunto de validação, periodicamente. Enquanto apresentar melhorias na avaliação, o treinamento continua. Quando ocorre a convergência com o conjunto de validação, o conjunto de teste é aplicado na rede.
- Aprimorar o conhecimento sobre Redes Neurais Artificiais e obter experiência prática na implementação das mesmas.
- Treinar um perceptron com função de ativação logística (sigmóide binária) para classificar sinais de um sonar.
Para implementação da rede neural foi utilizada a linguagem de programação Common Lisp, compilando-a com o SBCL (Steel Bank Common Lisp). Como interface de desenvolvimento, foi utilizado o Emacs em Org Mode, configurado com a plataforma SLIME (The Superior Lisp Interaction Mode for Emacs) para melhor comunicação com o SBCL. Foi utilizada uma abordagem bottom-up para o desenvolvimento. O código produzido segue majoritariamente o paradigma funcional, sendo este trabalho como um todo uma obra de programação literária. Parte das funções já foram implementadas em Regra de Hebb, Perceptron e Adaline, Regressão Linear e Multilayer Perceptron.
O desenvolvimento a seguir utiliza a base de dados da UCI - Machine
Learning Repository (sonar.csv). Busca-se identificar se um sinal
corresponde à uma mina ou a uma rocha, de acordo com 60
variáveis. Assim, faz-se necessário a implementação de algumas funções
para trabalhar com tal arquivo. São elas: read-csv
, que deve
transformar cada linha do arquivo em uma lista, parse-double
, que
deve converter uma string para um número de ponto flutuante,
shuffle
, para embaralhar as listas, split-at-last
, para separar as
entradas das saídas e percentual-split
, para que, dada uma lista,
consiga dividi-la de forma percentual.
No momento da leitura do arquivo, deseja-se converter a string para ponto flutuante. Portanto, esta será implementada primeiro.
(defun parse-double (string)
(declare (optimize (speed 3)))
(let ((*read-eval* nil))
(with-input-from-string (str string)
(read str nil nil))))
Para o primeiro elemento do arquivo, temos:
(parse-double "0.0200")
Assim, a função de leitura do arquivo é implementada:
(defun read-csv (filename &key (separator '(#\,)) (key #'parse-double))
(with-open-file (stream filename)
(loop for line = (read-line stream nil)
while line
collect (mapcar key (uiop::split-string line :separator separator)))))
Tendo o arquivo em formato de lista, é interessante que a mesma seja embaralhada e depois dividida entre conjunto de treinamento, de validação e de teste. Para isto, tem-se que o embaralhamento segue o algoritmo de Fisher-Yates, da seguinte forma:
(defun shuffle (list)
(do ((result (copy-seq list) (swap result i j))
(j 0 (random i))
(i (1- (length list)) (1- i)))
((zerop i) result)))
(defun swap (list i j)
(rotatef (nth i list) (nth j list))
list)
A divisão da lista em parcelas ocorre da seguinte forma:
(defun percentual-split (list &rest percentages)
(if (not (= (reduce #'+ percentages) 1))
(error "Percentages must add to 1")
(let ((lt (length list)))
(labels ((rec (lst rates result)
(let ((rest (cdr rates)))
(if rest
(let ((size (floor (* lt (car rates)))))
(rec (nthcdr size lst)
rest
(cons (subseq lst 0 size) result)))
(values-list (nreverse (cons lst
result)))))))
(rec list percentages nil)))))
Os tamanhos de cada lista dependem da porcentagem da lista inicial. Portanto, em caso de frações, arrendonda-se para baixo, e o último elemento sempre recebe todo o resto (evitando que informações sejam perdidas). No geral, apresenta uma boa aproximação da divisão percentual.
E por último, a função utilitária split-at-last
:
(defun split-at-last (list)
(list (butlast list)
(last list)))
(defun rock-mine (item)
(if (string= item "R")
0
1))
(defun multiple-split-at-last (list)
(loop for i in list
for (inputs output) = (split-at-last i)
collecting inputs into source
collecting (rock-mine (car output)) into target
finally (return (values source target))))
Assim, a leitura do arquivo é seguida pela conversão das strings em ponto flutuante, um embaralhamento das linhas e um agrupamento entre os três tipos de conjuntos (treinamento, validação e teste), que ficam salvos nas variáveis a seguir.
(defvar *training-set*)
(defvar *validation-set*)
(defvar *test-set*)
(defvar *data* (shuffle (read-csv #p"c:/home/ufu/amaq/feature-engineering/data/sonar.csv")))
(multiple-value-setq (*training-set* *validation-set* *test-set*)
(percentual-split *data* 0.7 0.15 0.15))
Assim, para aplicação da função iterative-retropropagation
ao conjunto de
teste, é necessário apenas implementar binary-sigmoid
e sua
derivada, binary-sigmoid^1
:
(defun binary-sigmoid (x)
(/ (1+ (exp (- x)))))
(defun binary-sigmoid^1 (x)
(let ((f (binary-sigmoid x)))
(* f (- 1 f))))
O treinamento é então realizado da seguinte forma (devido ao tamanho da lista de pesos, a saída não será mostrada):
(multiple-value-bind (inputs outputs)
(multiple-split-at-last *training-set*)
(multiple-value-bind (weights err)
(iterative-retropropagation
(random-mlnn-list -0.5 0.5 (length inputs) 6 1)
inputs
outputs
#'binary-sigmoid
#'binary-sigmoid^1
0.05 10000 0.0001)
(scatter-plot "plots/training-quadratic-error.png" err nil)
weights))
Para a etapa de validação, será utilizada a seguinte estratégia:
Primeiro a rede é treinada, utilizando a chamada acima. Após a
conclusão, será executada a função mlnn-output
com cada um dos
elementos do conjunto de teste, e a taxa de acerto será
calculada. Este processo (treino e avaliação) se repetirá até que a
taxa de acerto seja maior que certo valor, ou o número de repetições
estoure certo limite.
(defun mlnn-train-validation (training-set validation-set fn fn^1
output-fn threshold learning-rate
training-cycles training-tolerance
validation-cycles validation-tolerance
min max &rest configs)
(multiple-value-bind (validation-inputs validation-outputs)
(multiple-split-at-last validation-set)
(multiple-value-bind (training-inputs training-outputs)
(multiple-split-at-last training-set)
(do ((hit-rate-validation (- 1 validation-tolerance))
(best-hit-rate 0)
(best-err 0)
(best-weights nil)
(i 0 (1+ i)))
((or (>= i validation-cycles)
(> best-hit-rate hit-rate-validation))
(values best-weights best-err best-hit-rate))
(multiple-value-bind (weights err)
(iterative-retropropagation (apply #'random-mlnn-list min
max (length (car
training-inputs))
configs)
training-inputs
training-outputs fn
fn^1 learning-rate
training-cycles
training-tolerance)
(let ((hit-rate (mlnn-hit-rate weights validation-inputs
validation-outputs fn output-fn threshold)))
(format t "Training #~a Hit Rate: ~a~%" i hit-rate)
(when (> hit-rate best-hit-rate)
(setf best-weights weights
best-err err
best-hit-rate hit-rate))))))))
(defun binary-activation (net threshold)
(if (>= net threshold) 1 0))
(defun mlnn-hit-rate (mlnn-list inputs outputs activation-fn output-fn threshold)
(let ((hits (mapcar #'(lambda (in out)
(if (= (funcall output-fn
(mlnn-output in mlnn-list activation-fn)
threshold)
out)
1 0))
inputs outputs)))
(/ (reduce #'+ hits) (length hits))))
Assim, a função training-validation-test
é definida:
(defun mlnn-train-validation-test (training-set validation-set
test-set fn fn^1 output-fn
threshold learning-rate
training-cycles training-tolerance
validation-cycles
validation-tolerance min max &rest
configs)
(multiple-value-bind (weights err hit-rate)
(apply #'mlnn-train-validation training-set validation-set fn
fn^1 output-fn threshold learning-rate training-cycles
training-tolerance validation-cycles validation-tolerance
min max configs)
(multiple-value-bind (test-inputs test-outputs)
(multiple-split-at-last test-set)
(let ((test-hit-rate (mlnn-hit-rate weights test-inputs test-outputs fn output-fn threshold)))
(format t "Test Hit Rate: ~a~%" test-hit-rate)
(values weights err hit-rate test-hit-rate)))))
Para evitar que este documento fique poluído com as informações de peso, a função abaixo grava os mesmos em um arquivo:
(defun save-weights (filename weights)
(with-open-file (stream filename :direction :output :if-exists
:supersede :if-does-not-exist :create)
(write weights :stream stream)))
(defun load-weights (filename)
(with-open-file (stream filename)
(read stream)))
Finalmente, a chamada da função é:
(multiple-value-bind (weights err hit-rate-validation hit-rate-test)
(mlnn-train-validation-test *training-set*
*validation-set*
*test-set*
#'binary-sigmoid
#'binary-sigmoid^1
#'binary-activation 0.5
0.1 5000 0.001
10 0 -0.5 0.5 12 1)
(scatter-plot "plots/training-quadratic-error.png" err nil)
(save-weights "data/weights.list" weights)
(format t "Best Hit Rate On Validation: ~$~%Best Hit Rate On Test: ~$~%"
hit-rate-validation hit-rate-test))
Training #0 Hit Rate: 24/31 Training #1 Hit Rate: 25/31 Training #2 Hit Rate: 25/31 Training #3 Hit Rate: 26/31 Training #4 Hit Rate: 27/31 Training #5 Hit Rate: 25/31 Training #6 Hit Rate: 24/31 Training #7 Hit Rate: 26/31 Training #8 Hit Rate: 26/31 Training #9 Hit Rate: 26/31 Test Hit Rate: 27/32 Best Hit Rate On Validation: 0.87 Best Hit Rate On Test: 0.84
Alguns detalhes interessantes de se observar são:
- O limiar de ativação foi colocado em 0.5.
- Os pesos iniciais variam de -0.5 a 0.5, aleatoriamente.
- São treinadas 10 redes neurais, e a que se desempenhar melhor na etapa de validação é escolhida.
- A rede neural é composta de 1 camada escondida, com 12 neurônios.
- Várias execuções foram feitas como forma de teste. A taxa de acerto ficou quase sempre bem próxima a 81%, sendo o máximo valor alcançado de 87%.
- Em algumas situações, foi melhor diminuir a tolerância, e o número de ciclos de treinamento, para que a rede não ficasse excessivamente treinada, indo mal no conjunto de teste.
- A rede acima colocada apresentou taxa de acerto de 87% no conjunto de validação e 84% no de teste.
\bibliographystyle{plain} \bibliography{../references}