13 ene 2017

Red neuronal artificial simple



El objetivo en esta ocasión es introducirnos en el mundo de las redes neuronales. Para empezar comentaremos de una manera general y quizás un poco atrevida el concepto de neurona artificial, para finalmente construir un perceptrón digital simple empleando python 3.

¿Qué es un sistema neuronal artificial?

La idea de los sistemas neuronales artificiales fue inspirada por los sistemas neuronales biológicos. De acuerdo con Ramón y Cajal (1888), un sistema neuronal biológico está compuesto por una red de células individuales, ampliamente interconectadas entre sí. Estas células son denominadas neuronas y son como pequeños procesadores de información. Estos procesadores se encuentra compuesto principalmente por:
  1. Las dendritas, son el canal receptor de la información.
  2. El soma, es el órgano encargado de procesar la información.
  3. El axón, es el canal de emisión de información a otras neuronas.
El cerebro humano tiene cerca 90.000.000.000 neuronas, además cada neurona recibe información de aproximadamente 10.000 neuronas y envía impulsos a cientos de ellas. También hay neuronas que reciben información directamente de exterior. Es importante observar que el cerebro se modela durante el desarrollo del ser vivo, por lo tanto, algunas cualidades no son innatas, sino adquiridas por la influencia de la información que del medio externo recibe.

El objetivo de las redes será construir un gran conjunto de neuronas artificiales para simular un comportamiento similar al del cerebro humano.

El modelo estándar de una neurona artificial

Según los principios descritos por Rumelhart y McClelland (1986). Una neurona artificial estandar tiene los siguientes componentes:

  1. Las dendritas artificiales. Las dentritas artificiales son un conjunto de parámetros $x_{_i}(t)$ con los cuales se codifica la información de un problema que se quiere resolver. Las variables de entrada y salida pueden ser binarias (digitales) o continuas (analógicas), dependiendo del modelo y la aplicación. Por ejemplo en un perceptrón multicapa (MLP), por lo general las salidas son señales digitales representadas por $1$ y $-1$, en el caso de las salidas analógicas, la señal se da en un cierto intervalo.
  2. Los pesos sinápticos $w_{_{ij}}(t)$ de la neurona $i$ son variables relacionadas a la sinapsis o conexión entre neuronas, los cuales representan la intensidad de interacción entre la neurona presináptica $j$ y la postsináptica $i$. Dada una entrada positiva (puede ser una señal proveniente de una neurona), si el peso es positivo tenderá a excitar a la neurona postsináptica, si el peso es negativo tenderá a inhibirla.
  3. La función de potencial, permite obtener a partir de las entradas (dendritas) y los pesos sinápticos, el valor de potencial postsináptico $h_{_i}$ de la neurona $i$ en función de los pesos y entradas \begin{equation} h_{_i}(t)=\sigma_{_i}(w_{_{ij}}(t), x_{_j}(t)). \end{equation} La función más habitual es de tipo lineal, y se basa en la suma ponderada de las entradas con los pesos sinápticos, es decir, \begin{equation} h_{_i}(t) = \sum_{j}w_{_{ij}}(t)x_{_j}(t). \end{equation} Habitualmente se agrega al conjunto de pesos de la neurona un parámetro adicional $\theta_{_i}$, que se denomina umbral de excitación, el cual se acostumbra a restar al potencial postsináptico. Es decir: \begin{equation} h_{_i}(t) = \sum_{j}w_{_{ij}}(t)x_{_j}(t)-\theta_{_i}. \end{equation} Si se tiene un número finito de dendritas y hacemos que los índices $i$ y $j$ comiencen en cero, y denotamos por $w_{_{i0}}=\theta_{_i}$ y $x_{_0}=-1$, la función de potencial lineal se puede expresar como \begin{equation} h_{_i}(t) = \sum_{j=0}^{n}w_{_{ij}}(t)x_{_j}(t)=\pmb{w}^{\top}_{_i}(t)\cdot \pmb{x}(t), \end{equation} con $\pmb{w}_{_i}(t) = (w_{_{i0}}(t),\dots,w_{_{in}}(t))$ y $\pmb{x}(t)=(x_{_0}(t),\dots,x_{_n}(t))$.
  4. La función de activación $f_{_i}$ de la neurona $i$ proporciona el estado de activación actual $a_{_i}(t)$ a partir del potencial postsináptico $h_{_i}(t)$ y del propio estado de activación anterior, $a_{_{i}}(t-1)$, es decir, \begin{equation} a_{_i}(t)=f_{_i}(a_{_i}(t-1), h_{_i}(t)). \end{equation} Sin embargo, en muchos modelos de redes artificiales se considera que el estado actual de la neurona no depende de su estado anterior, sino unicamente del actual, por lo tanto, \begin{equation} a_{_i}(t)=f_{_i}(h_{_i}(t)). \end{equation}
  5. La función de emisión. Esta función proporciona la salida global $y_{_i}(t)$ y es el componente principal del axón artificial de la neurona $i$ en función de su estado de activación actual. Muy frecuentemente la función de emisión es simplemente la identidad $F(x) = x$ de tal modo que el estado de activación de la neurona se considera como la propia señal de la neurona.

Gráficamente, una neuronal artificial se puede representar de la siguiente forma:




Figura 1. Esquema de una neurona artificial.

Los pesos sinápticos, la función de potencial y la función de activación son los componentes que definen el soma artificial de la neurona.

Ejemplo de una red neuronal simple: el perceptrón

En los 50's Frank Rosenblatt propuso una red neuronal denominada perceptrón digital simple. Éste consiste de una o varias neuronas, donde la función de activación para cada neurona es \begin{equation} y = F\left(\sum_{i=1}^{n}w_{_{i}}x_{_i}+\theta(t)\right). \end{equation} Generalmente la función de activación $F$ puede ser lineal, y se dice por lo tanto que la conexión es de lineal, aunque puede ser no lineal. Para los objetivos ilustrativos, vamos a considerar la siguiente función: \begin{equation} F(s) = \left\{\begin{array}{cc} 1 & \mbox{ si } s > 0 \\ -1 & \mbox{ si } s \leq 0 \end{array}\right. \end{equation} Para simplificar la exposición de las ideas, suponga que las señal emitida por la neurona sera $1$ o $-1$, aunque puede ser cualquier otra cosa. Este tipo de neurona se puede usar para tareas de clasificación, es decir, se puede usar para decir si una patrón de entrada pertenece a alguna de las clases definidas por los valores $1$ o $-1$. Si el potencial de entrada es positivo, entonces el patrón se le asignará la etiqueta $1$, sino por el contrario es cero o menor que cero se le asignará la etiqueta de $-1$. Observe que los patrones de entrada siempre se pueden identificar con algún vector de $\mathbb{R}^{n}$, de manera que esta neurona separa a $\mathbb{R}^{n}$ en dos clases mediante un hiperplano dado por la ecuación: \begin{equation} x_{_n} = -\frac{w_{_1}}{w_{_n}}x_{_1}-\frac{w_{_2}}{w_{_n}}x_{_1}-\cdots-\frac{w_{_{n-1}}}{w_{_n}}x_{_1}+\frac{\theta}{w_{_n}}. \end{equation} La anterior función se denomina, función discriminante.

En el caso en que el espacio de patrones de entrada se pueda identificar con $\mathbb{R}^{2}$, la situación se puede representar gráficamente, en este caso, el hiperplano que define las dos clases es la linea recta dada por \begin{equation} w_{_1}x_{_1}+w_{_2}x_{_2}-\theta = 0, \end{equation} la cual se puede escribir como \begin{equation} x_{_2}=\frac{w_{_1}}{w_{_2}}x_{_1}+\frac{\theta}{w_{_2}} = 0, \end{equation} observe que el cociente $\frac{w_{_1}}{w_{_2}}$ determina la pendiente de la recta y $\frac{\theta}{w_{_2}}$ su bias. Note también que el vector $(w_{_1}, w_{_2})$ es siempre perpendicular a recta.


Figura 2. Función discriminante de un perceptrón simple.

Suponga ahora que se tiene un conjunto de datos $S\subset\mathbb{R}^{2}$, y un vector $\pmb{x}\in S$ para el cual se desea obtener la señal $\hat{y}(x)$. Como se ha dicho anteriormente $\hat{y}(x)$ es usualmente es un vector donde cada entrada es $+1$ o $-1$. ¿Cómo aprende el perceptrón a clasificar adecuadamente? Para eso el perceptrón sigue la siguiente rutina de aprendizaje:

  1. Iniciar con un conjunto aleatorio de pesos sinápticos.
  2. Seleccionar un patrón de entrada $x\in S$.
  3. Si $y(x) \neq \hat{y}(x)$, entonces los pesos se modifican de acuerdo a la regla: \begin{equation} \Delta w_{_i}= \hat{y}(x)x_{_i}; \end{equation}.
  4. Volver al paso 2.

El lector podrá verificar que este procedimiento es muy similar a la regla de aprendizaje de Hebb, la única diferencia es que cuando la neurona responde correctamente, los pesos sinápticos no son modificados. Por otro otro lado, $\theta$ como es considerado es el peso sináptico $w_{_0}$ que siempre recibe por la dendrita $x_{0}$ el valor de $-1$. Para el caso de $\theta$, la regla de aprendizaje viene dada por: \begin{equation} \Delta \theta = \left\{\begin{array}{cc} 0 & \mbox{ si el perceptron responde correctamente} \\ \hat{y}(x) & \mbox{ si el perceptron responde incorrectamente} \end{array}\right. \end{equation} Por ahora no entraremos en más detalles teóricos y vamos a ver como tener nuestro propio perceptrón con Python 3.

¿Cómo construir un perceptrón con Python?

El perceptron que vamos a construir consiste de una capa de $n$ neuronas artificiales, cada una para reconocer un único patrón. El objetivo de la operación del perceptrón es aprender una tranformación dada de la forma $\hat{y}:\{1,-1\}^{m}\to \{1,-1\}^{m}$ usando un conjunto de muestras donde cada elemtento es de la forma $(\pmb{x}, \pmb{y})$ donde $\pmb{x},\pmb{y}\in \{1,-1\}^{m}$ y además se le indican cuales son los vectores $\pmb{x}, \pmb{y}$, para esto se usará la función $tanh\,\theta$, de la siguiente forma: \begin{equation} f(t)=tanh(\pmb{w}\cdot \pmb{x}), \end{equation}

Tomaremos como bias para el criterio de desición el valor $\theta = 0.9999999999$ y además vamos a considerar que $\pmb{w}=\pmb{y}$, la razón de esta consideración es debido a que la función $tanh\,\theta$ nos permite decir que tan diferentes son dos vectores, así, si cuando se da un valor de entrada $\pmb{x}$ y la neurona artificial nos entrega el valor correcto de $\pmb{y}$ es porque el patrón $\pmb{x}$ está muy cercado al vector $w$, es decir, que el valor de $tanh,\theta$ es muy cercano a uno. No se preocupen por esto, en otra ocasión explicaré como hacer el entrenamiento de la neurona. Por ahora solo veamos como construir un ejemplo completamente funcional.

Lo primero que haremos en importar las librerías necesarias para nuestro algoritmo.

import numpy as np
import math
import pickle
from itertools import product
import tkinter as tk
from typing import Callable

Se define la clase Neuron con los siguientes métodos:

1. El constructor de la neurona:

    def __init__(self, dendrite: np.array, sensitivity:
                 int=2, dilatation: float=0.5) -> object:

        sensitivity = '9' * int(sensitivity)

        # Attributes of class.
        self.sensitivity = 1 - 100 / int(sensitivity)
        self.dilatation = 1/dilatation
        self.n_weight = len(dendrite)
        self.synaptic_weight = np.ones(self.n_weight)

        # We define the memory of neuron.
        pickle.dump([], open('memory.mem', 'wb'))
        self.memory = pickle.load(open('memory.mem', 'rb'))

2. El soma de la neurona:

    def soma(self, dendrite: np.array, potential_function: Callable=np.dot,
        potential = potential_function(dendrite, self.synaptic_weight)

        potential = potential / self.dilatation
        order = active_function(potential)
        return order

3. Toda neurona necesita una método de aprendizaje. Esto se programa a continuación:

    def learn(self, dendrite: np.array) -> None:

        self.memory.append(dendrite)
        self.memory.reverse()
        print('Signal learned')

3. Y también deba saber olvidar:

    def forget(self) -> None:

        self.memory = []
        pickle.dump(self.memory, open("memory.mem", 'wb'))
        print('Memory deleted')

4. Y por último se programa el axón o función de activación:

    def axon(self, dendrite: np.array) -> np.array:

        candidate_signals = {}

        # Se identifican todas las señales posibles.
        for mem in self.memory:
            self.synaptic_weight = mem
            weight = self.soma(dendrite)
            if weight > self.sensitivity:
                candidate_signals[weight] = mem

        if not candidate_signals:
            predict_signal = None
        else:
            # Se selecciona la mejor señal.
            predict_weight = max(candidate_signals.keys())
            print(predict_weight)
            predict_signal = candidate_signals[predict_weight]
        return predict_signal

Con la anterior se ha programado el cerebro de nuestro percetrón. Veamos ahora como implementarlo, para esto se debe construir una clase Perceptron con los siguientes métodos:

1. El constructor de la clase Perceptrón con una instancia de la clase Neuron. Aquí también se implementa la interfaz gráfica:

    def __init__(self, sqrt_n_receptors: int=5, sensitivity: int=17, focus:
                 float=1) -> object:

        self.sqrt_n_receptors = sqrt_n_receptors
        self.sensitivity = sensitivity
        self.focus = focus

        # Atributos de la clase.
        self.signal = np.array([-1 for _ in range(self.sqrt_n_receptors ** 2)])
        self.neuron = Neuron(self.signal, self.sensitivity, self.focus)

        # Ventana principal con botones.
        main_window = tk.Tk()
        main_window.title('Perceptron')
        main_window.resizable(width=False, height=False)

        # Botones de la ventana principal.
        n_row = range(sqrt_n_receptors)
        self.buttons = [[None]*sqrt_n_receptors for _ in n_row]
        self.buttons = np.array(self.buttons)
        self.values = np.zeros((self.sqrt_n_receptors, self.sqrt_n_receptors))
        self.coordinates = {}

        kwargs = dict(text=' ', bg='gray', relief='flat', width=2, height=2)

        for row, col in product(n_row, n_row):
            self.buttons[row, col] = tk.Button(main_window, **kwargs)
            self.buttons[row, col].grid(row=row, column=col, padx=2, pady=2)
            self.coordinates[self.buttons[row, col]] = [row, col]

        # Se detectan los eventos de cada uno de los botones.
        for button in self.buttons.flat:
            button.bind("", self.button_pressed)

        # Ventana secundaria.
        second_window = tk.Tk()
        second_window.title('')
        second_window.geometry("110x110")
        second_window.resizable(width=False, height=False)

        # Botones de la ventana secundaria.
        kwargs_memorize = dict(text='learn', command=self.memorize, width=455)
        button_memorize = tk.Button(second_window, **kwargs_memorize)
        kwargs_analyze = dict(text='Analyze', command=self.id_signal, width=455)
        button_analyze = tk.Button(second_window, **kwargs_analyze)
        kwargs_reset = dict(text='Reset', command=self.reset, width=455)
        button_reset = tk.Button(second_window, **kwargs_reset)
        kwargs_forget = dict(text='Forget', command=self.forget, width=455)
        button_forget = tk.Button(second_window, **kwargs_forget)

        # La ventana secundaria se construye con un pack.
        button_memorize.pack()
        button_analyze.pack()
        button_reset.pack()
        button_forget.pack()

        main_window.mainloop()
        second_window.mainloop()

2. El siguiente método define que ocurre cuando se presiona algún botón de la ventana principal.

    def button_pressed(self, event: Callable) -> None:
        # Se identifican las coordenadas del evento.
        row, col = self.coordinates[event.widget]
        n = self.sqrt_n_receptors * row + col

        if self.signal[n] == -1:
            self.signal[n] = 1
            self.buttons[row][col]['bg'] = '#5BADFF'
        else:
            self.signal[n] = -1
            self.buttons[row][col]['bg'] = 'gray'

3. Método para reconocer la señal:

    def id_signal(self) -> None:

        signal = self.neuron.axon(self.signal)
        try:
            n = len(signal)
        except TypeError:
            print('I do not know is this')
        else:
            for i in range(n):
                if signal[i] == 1:
                    row = i // self.sqrt_n_receptors
                    col = i % self.sqrt_n_receptors
                    self.buttons[row][col]['bg'] = '#01D826'

4. Método para resetear el estado de los botones en la ventana principal.

    def reset(self) -> None:

        self.signal = -1 * np.ones(len(self.signal))
        size = range(self.sqrt_n_receptors)
        for row, col in product(size, size):
            self.buttons[row][col]['bg'] = 'gray'
                    self.buttons[row][col]['bg'] = '#01D826'

5. Método para aprender nuevas señales.

    def memorize(self) -> None:

        self.neuron.learn(self.signal)
        self.reset()

5. Método para olvidar todas las señales.

    def forget(self) -> None:

        self.neuron.forget()

Finalmente instanciamos la clase del perceptrón.

if __name__ == "__main__":
    Perceptron()

El lector debe notar, cuando tenga funcionando el perceptrón, que cada que ingresa un nuevo patrón de aprendizaje, se está entrenando una nueva neurona. En otras palabras, para cada patrón se tiene un vector de pesos que permite caracterizar cada nueva neurona entrenada. Estos pesos, se almacenan en memoria.mem. Así, que el lector, puede imaginar el funcionamiento del perceptrón como se muestra en la siguiente figura:



Figura 3. Estructura global del perceptrón.


Otra observación importante, es que el perceptron aprende adecuadamente los pesos sinápticos en un tipo finito. Teóricamente, esto es:

Teorema. Se tiene un perceptrón con un conjunto adecuado de pesos sinápticos $w^*$ para el resultado $\hat{y}(x) = y$. Entonces el perceptrón converge en un tiempo finito, sin importar quién sea $w^*$ inicial.
En efecto, si consideramos que $w^*$ es una solución adecuada, entonces $||w^*|| = 1$ (esto si se considera como criterio de desición a la función $sgn$, hacer esta consideración no representa ninguna perdida de generalidad). Ahora si calculamos $|w^*\cdot x|$, entonces tenemos dos posibilidades o que el resultado sea cero, o que existe un $\delta > 0$ tal que $|w^*\cdot x|>0$ para la entrada $x$. Si ahora se considera \begin{equation} \cos \alpha = \frac{w\cdot w^*}{||w||}, \end{equation} entonces de acuerdo a las reglas de aprendizaje del perceptrón se tiene que $\Delta w = \hat{y}x$, y por lo tanto la modificación a los pesos sería $w' = w +\Delta w$. De esto se sigue que: $$w'\cdot w^* = w\cdot w^*+\hat{y}w^*\cdot x = w\cdot w^*+sgn(w^*\cdot x)w^*\cdot x > w\cdot w^* +\delta$$ por otro lado se tiene: $$||w'||^2=w^2+2\hat{y}w\cdot x + x^2 < w^2 + x^2 = w^2+ M$$ dado que $\hat{y}=-sgn(w\cdot x)$. Después de estás modificaciones, entonces es puede concluir que: $$\cos\alpha > \frac{w^*\cdot w + \delta}{\sqrt{w^2+tM}}.$$ De esta última expresión se concluye que el tiempo de convergencia debe ser finito, dado que $cos \alpha \leq 1$. Con algunas modificaciones, se puede considerar como el tiempo máximo a $t_{máx}=\frac{M}{\delta^2}$.

Conclusiones

  1. Lo que se aprendió hoy fue que una neurona artificial tiene gran parecido a la neurona biológica tanto en su estructura como en su funcionalidad. Así como la unión de las neuronas dan origen al cerebro, las neuronas artificiales dan lugar a arreglos de neuronas llamadas redes neurales, que intentan emular el funcionamiento del cerebro humano, tarea que todavía no se consigue. La neurona artificial al igual que la neurona biológica, maneja diferentes tipos de señales, como se mencionó con antelación estas pueden ser continuas o digitales.
  2. El codigo completo del perceptron lo encuentras aquí.

Referencias