sábado, 19 de noviembre de 2022

Redes neuronales desde cero

Una red neuronal artificial (o red neuronal para abreviar) es un modelo predictivo motivado por la forma en que funciona el cerebro. Pensemos en el cerebro como una colección de neuronas conectadas entre sí. Cada neurona mira las salidas de las otras neuronas que la alimentan, hace un cálculo, y luego se dispara (si el cálculo excede algún umbral) o no (si no lo hace).

En consecuencia, las redes neuronales artificiales constan de neuronas artificiales, que funcionan de esta manera. Las redes neuronales pueden resolver una amplia variedad de problemas como reconocimiento de escritura a mano y detección de rostros, y se utilizan mucho en el aprendizaje profundo, uno de los subcampos más de moda de la ciencia de datos. Sin embargo, la mayoría de las redes neuronales son "cajas negras"- inspeccionar sus detalles no nos dará  mucha comprensión de cómo están resueltos los problemas. Y las grandes redes neuronales pueden ser difíciles de entrenar. 

Perceptrones

El equivalente electrónico de una neurona es el perceptrón, con n entradas binarias. Calcula una suma ponderada de sus entradas y "dispara" si esa suma ponderada es cero o mayor:

def funcion_escalon(x):

    return 1 if x >= 0 else 0

def psalida_perceptron(pesos, sesgo, x):

    """devuelve  1 si el perceptrón se 'activa' y 0 si no lo hace"""

    calcula = punto(pesos, x) + sesgo

    return funcion_escalon(calcula)

El perceptrón simplemente distingue entre los medios espacios separados por el hiperplano de puntos x para el cual:

   punto(pesos,x) + sesgo == 0

Con pesos elegidos correctamente, los perceptrones pueden resolver una serie de problemas simples ver figura.

Perceptrón

Por ejemplo, podemos crear una puerta AND (que devuelve 1 si ambas entradas son 1 pero devuelve 0 si una de sus entradas es 0) con:

#Definición de puerta AND

pesos = [2, 2]

sesgo = -3

Si ambas entradas son 1, el cálculo es igual a 2 + 2 - 3 = 1 y la salida es 1. Si solo una de las entradas es 1, el cálculo es igual a 2 + 0 - 3 = -1, y la salida es 0. Y si ambas las entradas son 0, el cálculo es igual a -3 y la salida es 0. Del mismo modo, podríamos construir una puerta OR con:

#Definición puerta OR

pesos = [2, 2]

sesgo = -1

Y podríamos construir una puerta NOT (que tiene una entrada y convierte 1 a 0 y 0 a 1) con:

#Definición de puerta NOT

pesos = [-2]

bias = 1

Sin embargo, hay algunos problemas que simplemente no pueden resolverse con un solo perceptrón. Por ejemplo, no importa cuánto lo intentemos, no podemos utilizar un perceptrón para construir una puerta XOR que genera 1 si exactamente una de sus entradas es 1 y 0 en caso contrario. Aquí es donde empezamos necesitando redes neuronales más complejas.

Por supuesto, no es necesario utilizar una neurona (perceptrón) para construir una puerta lógica lo podemos hacer fácilmente por programación tradicional.

puerta_and = min

puerta_or = max

puerta_xor = lambda x, y: 0 if x == y else 1

Al igual que las neuronas reales, las neuronas artificiales comienzan a volverse más interesantes cuando comenzamos a conectar muchas.

Redes neuronales de realimentación positiva (avance)

La topología del cerebro es enormemente complicada, por lo que es común aproximarla con una red neuronal de realimentación idealizada que consta de capas discretas de neuronas, cada uno conectado al siguiente. Esto normalmente implica una capa de entrada (que recibe entradas y las alimenta sin cambios), una o más "capas ocultas" (cada una de las cuales consta de neuronas que toman las salidas de la capa anterior, realizan algunos cálculos y pasan el resultado a la siguiente capa) y una capa de salida (que produce las salidas finales).

Al igual que el perceptrón, cada neurona (no las de entrada) tiene un peso correspondiente a cada una de sus entradas y un sesgo. Para simplificar nuestra representación, agregaremos el sesgo al final de nuestro vector ponderado y daremos a cada neurona una entrada de sesgo que siempre es igual a 1.

Al igual que con el perceptrón, para cada neurona resumiremos los productos de sus entradas y sus pesos. Pero aquí, en lugar de generar una función escalón, generaremos una aproximación suave de la función de paso. En particular, utilizaremos el sigmoide :

#Función sigmoide

def sigmoide(t):

    return 1 / (1 + math.exp(-t))

¿Por qué utilizar un sigmoide en lugar de una función de escalón más simple? Para entrenar una red neuronal, necesitaremos utilizar cálculo diferencial, y para esto, necesitamos funciones derivables. La función de paso ni siquiera es continua y por tanto no es derivable, la función sigmoide es una buena  aproximación derivable de la misma.

La función sigmoide, también se llama función logística. Técnicamente "sigmoide" se refiere a la forma de la función, "logística" a esta función en particular.

Luego calculamos la salida como:

def salida_neurona(pesos, entradas):

    return sigmoide(punto(pesos, entradas))

Dada esta función, podemos representar una neurona simplemente como una lista de pesos cuya longitud es uno más que el número de entradas a esa neurona (debido al peso del sesgo). Entonces nosotros podemos representar una red neuronal como una lista de capas (sin entrada), donde cada capa es el número  de neuronas en esa capa. Es decir, representaremos una red neuronal como una lista (capas) de listas (neuronas) de listas (pesos).

Dada tal representación, utilizaremos una red neuronal es bastante simple:

def red_secuencial(red_neuronal, vector_entrada):

    """toma una red neuronal

    (representada como una lista de listas de listas de    pesos)

    y devuelve la salida desde la entrada propagada hacia adelante"""

    salidas = []

    # procesa una capa cada vez

    for capa in red_neuronal:

        entrada_con_sesgo = vector_entrada + [1] # añade el sesgo de entrada

        salida = [salida_neurona(neurona, entrada_con_sesgo) # calcula la salida

            for neurona in capa] # para cada neurona en la capa

        salidas.append(salida) # la recuerda

        # entonces, la entrada a la siguiente capa es la salida de cada una

        vector_entrada = salida

    return salidas

Ahora es fácil construir la puerta XOR que no podríamos construir con un solo perceptrón. Nosotros solo necesitamos escalar los pesos para que las salidas de cada neurona estén realmente cerca de 0 o muy cerca de 1:

red_xor = [# capa oculta

    [[20, 20, -30], # neurona'and'

    [20, 20, -10]], # neurona 'or'

    # capa de salida

    [[-60, 60, -30]]] # ' segunda entrada pero no primera entrada en una neurona

for x in [0, 1]:

    for y in [0, 1]:

        # red_secuencial produce las salidas de cada neurona

        # red_secuencial[-1] son las salidas de las neuronas de las capas de salida

       # print (x, y, red_secuencial(red_xor,[x, y])[-1]) Esta línea no funciona, si alguien nos puede ayudar...

         print (x, y)     

# 0 0 [9.38314668300676e-14]

# 0 1 [0.9999999999999059]

# 1 0 [0.9999999999999059]

# 1 1 [9.383146683006828e-14]

El print nos da error, así que lo hemos dejado en un print más sencillo, si alguien sabe como evitar dicho error, que lo indique.

Al utilizar una segunda capa (capa oculta), podemos alimentar la entrada de una neurona "y" y la entrada de una neurona "o" con la salida de la neurona “y” lo que la convierte en en una neurona de "segunda capa o capa oculta”. El resultado es una red que realiza "o, pero no y", que es precisamente XOR 


Red neuronal XOR

Por lo general, no construimos redes neuronales a mano. Esto se debe en parte a que las utilizamos  para resolver problemas mucho mayores: un problema de reconocimiento de imágenes puede involucrar cientos o miles de neuronas. Y es en parte porque normalmente no podemos "razonar" lo que deberían hacer todas y cada una de estas neuronas. En vez de eso, “entrenamos” la red  con datos. Un enfoque popular es un algoritmo llamado retropropagación que tiene similitudes con el algoritmo de descenso de gradiente.  

Supongamos que tenemos un conjunto de entrenamiento que consta de vectores de entrada y el resultado correspondiente de vectores de salida. Por ejemplo, en nuestro ejemplo anterior,  la red XOR, el vector de entrada [1, 0] correspondía a la salida de destino [1]. Imaginemos que nuestra red tiene un conjunto de pesos. Luego ajustamos los pesos usando el siguiente algoritmo:

Ejecutamos el algoritmo de realimentación positiva feed_forward en un vector de entrada para producir las salidas de todas las neuronas en la red.

Esto da como resultado un error para cada neurona de salida: la diferencia entre su salida y su objetivo.

Calculamos el gradiente de este error en función de los pesos de las neuronas y ajustamos sus pesos en la dirección que más disminuye el error.

 "Propagamos" estos errores de salida hacia atrás para inferir errores para la capa oculta.

Calculamos los gradientes de estos errores y ajustamos los pesos de la capa oculta de la misma manera.

Por lo general, ejecutamos este algoritmo muchas veces para todo nuestro conjunto de entrenamiento hasta que la red converge:


def retropropagacion(red, vector_entrada, objetivos):

     salidas_ocultas, salidas = red_secuencial(red, vector_entrada)

    # la salida * (1 - salida) es de la derivada o sigmoide

    salidas_incrementales = [salida * (1 - salida) * (salida - objetivo)

        for salida, objetivo in zip(salidas, objetivos)]

    # ajustamos los pesos para la capa de salida, una neurona cada vez

    for i, neurona_de_salida in enumerate(red[-1]):

        # nos centramos en la i ésima salida de la capa neuronal

        for j, salida_oculta in enumerate(salidas_ocultas + [1]):

            # ajustamos el j ésimo peso basado en ambas:

            # el incremental de la neurona y su j ésima entrada

            neurona_de_salida[j] -= salidas_incrementale[i] * salida_oculta         

    # propagamos hacia atrás los errores de la capa oculta

    incrementales_ocultas = [salida_oculta * (1 - salida_oculta) *

            dot(salidas_incrementales , [n[i] for n in capa_de_salida])

            for i, salida_oculta in enumerate(salidas_ocultas)]  

    # ajustamos los pesos de la capa oculta, una neurona cada vez

    for i, neurona_oculta in enumerate(red[0]):

        for j, input in enumerate(vector_entrada + [1]):

            neurona_oculta[j] -= incrementales_ocultas[i] * input

Esto es prácticamente lo mismo que si escribiéramos explícitamente el error al cuadrado como una función de los pesos y usáremos la función minimo_estocastico

En este caso, escribir explícitamente la función de gradiente resulta ser una molestia. Si conocemos el cálculo y la regla de la cadena, los detalles  matemáticos son relativamente sencillos, pero manteniendo la notación correcta ("la derivada parcial de la función de error con respecto al peso que la neurona i asigna a la entrada procedente de la neurona j ”).


sábado, 12 de noviembre de 2022

Descenso de gradiente desde cero

Con frecuencia, en machinelearning, intentaremos encontrar el mejor modelo para una determinada situación. Y, por lo general, "mejor" significa minimizar el error del modelo o maximizar la probabilidad de los datos. En otras palabras, representará la solución a algún tipo de problema de optimización.

Esto significa que tendremos que resolver una serie de problemas de optimización. Y en particular, tendremos que resolverlos desde cero. El enfoque habitual es una técnica llamada descenso de gradiente, que se presta bastante bien a un tratamiento desde cero. Supongamos que tenemos alguna función f que toma como entrada un vector de números reales y como salida

un solo número real. Una simple función de este tipo sería así:

def suma_de_cuadrados(v):

    """calcula la suma de cuadrados en v"""

    return sum(v_i ** 2 for v_i in v)

 

Con frecuencia, necesitaremos maximizar (o minimizar) dichas funciones. Es decir, tenemos que encontrar la entrada v que produce el valor más grande (o más pequeño) posible.

Para funciones como esta, el gradiente (el vector de derivadas parciales) da la dirección de entrada en la que la función aumenta más rápidamente. Por tanto para maximizar una función, elegimos un punto de partida aleatorio, calculamos el gradiente, y damos un pequeño paso en la dirección del gradiente (es decir, la dirección que hace que la función aumente más), y repetimos con el nuevo punto de partida. Del mismo modo, podemos intentar minimizar una función dando pequeños pasos en el sentido contrario.

Es importante tener en cuenta que si una función tiene un mínimo global único, es probable que este procedimiento lo encuentre. Pero si una función tiene múltiples mínimos (locales), este procedimiento puede "encontrar" el incorrecto, en cuyo caso podemos volver a ejecutar el procedimiento desde diversos puntos de partida. Si una función no tiene mínimo, entonces es posible que este procedimiento continúe para siempre.

Estimando el gradiente

Si f es una función de una variable, su derivada en un punto x mide cómo cambia f (x), cuando hacemos un cambio muy pequeño a x. Se define como el límite de la diferencia de cocientes cuando h se acerca a cero.

def diferencia_de_cocientes(f, x, h):

    return (f(x + h) - f(x)) / h

La derivada es la pendiente de la recta tangente a la función (azul) , mientras que el cociente de diferencias es la pendiente de la recta no tangente que la atraviesa (rojo). 

Concepto gráfico de derivada para descenso de gradiente

Si h se hace cada vez más pequeña, la línea no tangente (roja)  se acerca cada vez más a la tangente (azul).

Para muchas funciones, es fácil calcular exactamente las derivadas. Por ejemplo, para la función cuadrado:

def cuadrado(x):

    return x * x

Tiene derivada

def derivada(x):

    return 2 * x

Aunque no podemos calcular límites en Python, podemos estimar derivadas evaluando el cociente de la diferencia para un pequeño e.

from functools import partial

derivative_estimate = partial(diferencia_de_cocientes, cuadrado, h=0.00001)

x = range(-10,10)

Cuando f es una función de muchas variables, tiene múltiples derivadas parciales, cada una indicando cómo cambia f cuando hacemos pequeños cambios en solo una de las variables de entrada. Calculamos su i-ésima derivada parcial tratándola como una función solo de su i-ésima variable,

manteniendo las otras variables fijas:

def cociente_derivada_parcial(f, v, i, h):

    """calcula el cociente de la i-ésima diferencia de f a v"""

    w = [v_j + (h if j == i else 0) # suma h al i-ésimo elemento de v

    for j, v_j in enumerate(v)]

    return (f(w) - f(v)) / h

después de lo cual podemos estimar el gradiente de la misma manera: 

def gradiente_estimado(f, v, h=0.00001):

    return [cociente_derivada_parcial(f, v, i, h)

    for i, _ in enumerate(v)]

 Un inconveniente de este enfoque de "estimación utilizando cocientes de diferencias" es que es computacionalmente costoso. Si v tiene una longitud n, el gradiente_estimado debe evaluar f en 2n entradas diferentes. Si estimamos gradientes repetidamente, estamos obligando al procesador ha de realizar un montón de trabajo adicional.

Usando el gradiente

Es fácil ver que la función suma_de_cuadrados es más pequeña cuando su entrada v es un vector de ceros. Pero imaginemos que no lo sabemos. Utilizaremos gradientes para encontrar el mínimo entre todos los vectores tridimensionales. Simplemente elegiremos un punto de partida aleatorio y luego tomaremos pequeños pasos en la dirección opuesta del gradiente hasta llegar a un punto donde el gradiente es muy pequeño:

#definimos las funciones auxiliares previas

import math

def magnitud(v):

    return math.sqrt(suma_de_cuadrados(v)) # math.sqrt es la función raiz cuadrada

   

 def resta_de_vectores(v, w):

    """resta los elementos correspondientes"""

    return [v_i - w_i

    for v_i, w_i in zip(v, w)]  

 

def distancia(v, w):

    return magnitud(resta_de_vectores(v, w))

import random

 

def paso(v, direccion, tamaño_paso):

    """mueve el tamaño del paso en la dirección a v"""

    return [v_i + tamaño_paso * direccion_i

        for v_i, direccion_i in zip(v, direccion)]

 

def suma_de_gradientes_cuadrados(v):

    return [2 * v_i for v_i in v]

# toma un punto aleatorio para comenzar

v = [random.randint(-10,10) for i in range(3)]

 

tolerancia = 0.0000001

 

while True:

    gradiente = suma_de_gradientes_cuadrados(v) # calcula el gradiente de v

    siguiente_v = paso(v, gradiente, -0.01) # toma un paso negativo de gradiente

    if distancia(next_v, v) < tolerancia: # para si convergen

        break

    v = siguiente_v # continua en caso contrario


Si ejecutamos esto, encontraremos que siempre terminamos con una v muy cercana a [0,0,0]. Cuanto menor sea la tolerancia, más se acercará.

Elegir el tamaño de paso correcto

Aunque la razón fundamental para moverse en contra del gradiente es clara, la distancia a la que se debemos movernos no lo es. De hecho, elegir el tamaño de paso correcto es más un arte que una ciencia. Opciones populares consisten en:

Utilizar un tamaño de paso fijo 

Reducir gradualmente el tamaño del paso con el tiempo 

En cada paso, elegir el tamaño del paso que minimiza el valor de la función objetivo 

El último suena óptimo pero, en la práctica, tiene un cálculo costoso. Podemos aproximarlo probando una variedad de tamaños de paso y elegir el que resulte en el valor más pequeño de la función objetiva:

Tamaño_paso = [100, 10, 1, 0.1, 0.01, 0.001, 0.0001, 0.00001] Es posible que ciertos tamaños de paso den como resultado entradas no válidas para nuestra función. Así, pues tenemos la necesidad de crear una función de "aplicación segura" que devuelva infinito (que nunca debería ser el mínimo de cualquier cosa) para entradas no válidas:

def segura(f):

    """devuelve una funcion igual a f,

    excepto que sus salidas no devuelven error si tienden a infinito"""

 

    def f_segura(*args, **kwargs):

        try:

            return f(*args, **kwargs)

        except:

            return float('inf') # 'inf' sugnifica infinito en Python

    return safe_f

Poniéndolo todo junto

En el caso general, tenemos una función objetivo objetivo_fn que queremos minimizar, y también tenemos su gradiente_fn. Por ejemplo, objetivo_fn podría representar los errores en un modelo como una función de sus parámetros, y es posible que deseemos encontrar los parámetros que cometan los errores tan pequeño como sea posible.

Además, digamos que hemos elegido (de alguna manera) un valor inicial para los parámetros theta_0. Entonces podemos implementar el descenso de gradiente como:

def minimiza_lote(objetivo_fn, gradiente_fn, theta_0, tolerancia=0.000001):

    """utiliza descenso de gradiente para encontrar el theta que minimiza la función objetivo"""

    tamaño_paso = [100, 10, 1, 0.1, 0.01, 0.001, 0.0001, 0.00001]

    theta = theta_0 # establece theta a su valor inicial

    target_fn = segura(objetivo_fn) # devuelve la versión segura de la función objetivo_fn

    valor = objetivo_fn(theta) # valor que estamos minimizando

    while True:

        gradiente = gradiente_fn(theta)

        siguientes_thetas = [paso(theta, gradiente, -tamaño_paso)

            for tamaño_paso in tamaño_pasos]

        # elije el valor que minimiza la funcion de error

        siguiente_theta = min(siguientes_thetas, key=objetivo_fn)

        siguiente_valor = objetivo_fn(siguiente_theta)

        # se detiene si está convergiendo

       

        if abs(valor - siguiente_valor) < tolerancia:

            return theta

        else:

            theta, valor = siguiente_theta, siguiente_valor

Lo llamamos minimizar_lote porque, para cada paso de gradiente, analiza el conjunto de datos completo (porque objetivo_fn devuelve el error en todo el conjunto de datos). En la siguiente sección, veremos un enfoque alternativo que solo analiza un punto de datos a la vez.

A veces, en cambio, queremos maximizar una función, lo que podemos hacer minimizando su negativo (que tiene un gradiente negativo correspondiente):

def niega(f):

    """devuelve una función que por cada entrada x devuelve -f(x)"""

    return lambda *args, **kwargs: -f(*args, **kwargs)

 

def niega_todo(f):

    """lo mismo cuando f devuelve una lista de números"""

    return lambda *args, **kwargs: [-y for y in f(*args, **kwargs)]

 

def maximiza_lote(objetivo_fn, gradiente_fn, theta_0, tolerancia=0.000001):

    return minimiza_lote(niega(objetivo_fn),

        niega_todo(gradiente_fn),

        theta_0,

        tolerancia)

Descenso de gradiente estocástico

Como mencionamos antes, a menudo usaremos el descenso de gradiente para elegir los parámetros de un modelo de una manera que minimice alguna noción de error. Usando el enfoque por lotes anterior, cada paso del gradiente requiere que hagamos una predicción y calculemos el gradiente para el conjunto de datos, lo que hace que cada paso lleve mucho tiempo. Por lo general, estas funciones de error son aditivas, lo que significa que el error predictivo en todo el conjunto de datos es simplemente la suma de los errores predictivos para cada punto de datos. Cuando este es el caso, podemos aplicar una técnica llamada descenso de gradiente estocástico, que calcula el gradiente (y da un paso) solo para un punto a la vez. Se repiten los datos hasta llegar a un punto de parada. Durante cada ciclo, queremos iterar a través de nuestros datos en un orden aleatorio:

def en_orden_aleatorio(dato):

    """generador que devuelve los elementos de los datos en un orden aleatorio"""

    indices = [i for i, _ in enumerate(dato)] # crea una lista de indices

    random.shuffle(indices) # los baraja

    for i in indices: # devuelve los datos en este orden

        yield dato[i]

Y queremos dar un paso de gradiente para cada punto de datos. Este enfoque deja el posibilidad de que podamos dar vueltas cerca de un mínimo para siempre, por lo que cada vez que nos detenemos obteniendo mejoras, disminuiremos el tamaño del paso y, finalmente, abandonaremos: 

def minimiza_estocastico(objetivo_fn, gradient_fn, x, y, theta_0, alpha_0=0.01):

    dato = zip(x, y)

    theta = theta_0 # valor inicial

    alpha = alpha_0 # tañaño de paso inicial

    min_theta, min_valor = None, float("inf") # el valor mínimo más lejano

    iteracciones_sin_mejora = 0

    # si tenemos 100 iteracciones sin mejora paramos

    while iteracciones_sin_mejora < 100:

        valor = sum( objetivo_fn(x_i, y_i, theta) for x_i, y_i in dato )

       

        if valor < min_valor:

            # si encontramos un mínimo lo recordamos

            # y regresamos al tamaño de paso original

            min_theta, min_valor = theta, valor

            iteracciones_sin_mejora = 0

            alpha = alpha_0

        else:

            # en otro caso no tenemos mejora, así que intentamos disminuir el tamaño del paso

            iteracciones_sin_mejora += 1

            alpha *= 0.9

           

            # y tomamos un paso de gradiente para cada uno de los puntos de datos

            for x_i, y_i in en_orden_aleatorio(dato):

                gradiente_i = gradiente_fn(x_i, y_i, theta)

                theta = resta_de_vectores(theta, multiplicacion_escalar(alpha, gradiente_i))

        return min_theta

La versión estocástica suele ser mucho más rápida que la versión por lotes. Por supuesto, queremos hacerlo con una versión que maximice también:

def maximiza_estocastico(objetivo_fn, gradiente_fn, x, y, theta_0, alpha_0=0.01):

    return minimiza_estocastico(niega(objetivo_fn),

    niega_todo(gradiente_fn),

    x, y, theta_0, alpha_0)