**IAIC** Redes Neuronales # Introducción ![](img/NN1.png width=40% align=right) El objetivo que se persigue con una red neuronal artificial es definir una función que tome un vector como entrada y produzca un vector como salida, siguiendo un flujo de cálculo tal como se muestra en la figura adyacente. Cuando analizamos el proceso de cálculo según el flujo que siguen los datos (aristas) y las operaciones concretas que se efectúan sobre ellos (nodos) decimos que estamos trabajando sobre un **Grafo de Computación**. En el grafo que se muestra en esa figura, los nodos indican números reales, las flechas indican dependencias y los rectángulos agrupan un conjunto de números en vectores. Entre los vectores de entrada y salida (nodos en azul) hay un conjunto de niveles (o **capas**, como se llaman en el lenguaje de las redes neuronales) que se llaman *ocultas* (porque no tienen contacto con el exterior, nodos en rojo) y que están dispuestas de forma que la capa de entrada influye solo en la primera capa oculta, cada capa oculta influye solo en la siguiente capa oculta, y solo la última capa oculta influye en la capa de salida (ha de tenerse en cuenta que, para simplificar la representación, no están representadas muchas de las dependencias que se dan entre capas). Gran parte de la analogía de las redes neuronales artificiales procede obviamente de las redes de neuronas biológicas y los axones del sistema nervioso y el cerebro, aunque las dependencias o conexiones implementadas por los axones son en general mucho más complicadas (y aún desconocidas en su mayor parte) que las que existen entre las capas indicadas aquí. Por supuesto, se pueden plantear dependencias más generales, y han sido investigadas en multitud de trabajos. De hecho, hoy en día es más habitual trabajar con estructuras más complejas que esta simple ordenación secuencial del flujo, aunque para la mayoría de los modelos sigue siendo común que se agrupen en capas. La razón computacional para ello es que hace que la configuración de la red neuronal sea mucho más fácil de ajustar que en el caso general, cuando se permiten, por ejemplo, dependencias cíclicas (que, en este caso, se denominan genéricamente **redes recurrentes**). Las redes neuronales se han convertido en uno de los ejes centrales del aprendizaje automático supervisado. [Como hemos visto](../Fundamentos_de_ML), el aprendizaje supervisado se basa en el ajuste de los parámetros del modelo usando explícitamente una relación conocida a priori entre los datos de entrada y los de salida, y que su relación funcional (posiblemente muy complicada) debe aproximarse teniendo en cuenta que los datos pueden ser ruidosos. El campo del aprendizaje profundo (**Deep Learning**) surge precisamente del estudio y manipulación de las redes neuronales profundas, es decir, aquellas que tienen muchas capas ocultas. En este capítulo veremos la estructura de las redes neuronales con más detalle, repasaremos un resultado importante de la teoría de las redes neuronales que nos asegura que su uso no es descabellado, veremos cómo se entrenan, y desarrollaremos una versión muy simple, pero completa, de una implementación en Julia que nos permitirá atacar un problema conocido, pero no por ello trivial ni falto de interés: el del reconocimiento de escritura de dígitos a mano. # Funcionamiento básico y representación matricial El funcionamiento básico de una red neuronal, como máquina de cálculo, con los elementos anteriores es el siguiente: 1. **Entrada de Datos:** La operación de la red neuronal comienza por la capa de entrada que recibe datos en forma de vector numérico. Cada nodo en esta capa representa una característica individual de los datos de entrada. 2. **Peso y Sesgo:** Cada conexión entre los nodos de una capa y la siguiente tiene un peso asociado. Además, cada nodo en la capa siguiente tiene un sesgo, que es una constante ajustable. 3. **Suma Ponderada:** En cada nodo consideramos la suma ponderada (usando los pesos de las conexiones como medio de ponderación) de los valores de los nodos entrantes hacia él, así como de su sesgo. 4. **Función de Activación:** Después de calcular la suma ponderada, se aplica una función de activación al resultado, que tiene como objetivo introducir no linealidad en la red, permitiendo así que la red pueda calcular funciones con una elevada complejidad. 5. **Capa de Salida:** Los valores calculados en la capa de salida representan el cálculo final realizado por la red neuronal, y que será usado para realizar las predicciones o clasificaciones de la misma, dependiendo del tipo de problema que esté resolviendo la red. De forma individual, cada uno de los nodos (neuronas) podría representar su funcionamiento de la siguiente forma (en la animación se muestra cómo el cambio de pesos y sesgos afecta al resultado que devuelve el nodo, que es la combinación del proceso de agregación y de la función de activación, como explicitaremos más adelante): ![](img/neurona.gif) La complejidad del modelo se pone de manifiesto cuando tenemos en cuenta que no disponemos de un solo nodo, sino de un conjunto de nodos en la misma capa que recibe simultáneamente todos los datos de la capa anterior y los procesa en paralelo devolviendo un vector de resultados. !!!note De esta forma, una sola capa se convierte en un tipo especial de función que transforma vectores en vectores. La formalización de este proceso entre capas se puede simplificar enormemente si se utiliza una representación adecuada. La siguiente imagen muestra cómo se forma y usa la matriz de pesos asociada a una sola capa (no se explicita la función de activación, que es posterior a la suma ponderada de valores de la capa anterior): ![](./img/FFNN.jpg width=80%) Esta representación muestra muchas ventajas. La primera de ellas es que permite una notación compacta en la que podemos aplicar todas las herramientas que conocemos del Álgebra Lineal. Además, permite paralelizar las operaciones sobre el vector de entrada y hacer el cálculo simultáneamente sobre un conjunto de datos de entrada haciendo uso del producto de matrices (una operación implementada paralelamente en muchos dispositivos de cálculo, como las [GPUs](https://es.wikipedia.org/wiki/GPGPU)): ![](./img/FFNN2.jpg width=80%) Trabajar con varias capas es tan sencillo como componer estos productos (y las diversas funciones de activación) de forma natural: ![](./img/FFNN3.jpg width=40%) ## Feed Forward Vamos a comenzar por el paso de evaluación de una red neuronal, es decir, una especificación completa y formal de cómo una red calcula valores de salida a partir de valores de entrada. Para ello, aprovechando la estructura en capas, nos centraremos en especificar cómo funciona capa a capa. Denotamos la salida vectorial de una capa cualquiera $k$ por $\mathbb{x}^{(k)}$. La acción de la capa $k$ viene dada por la aplicación de una función lineal (o más precisamente, una función afín) y, a continuación, una función no lineal aplicada elemento a elemento sobre el vector resultante: $$\mathbb{x}^{(k)} = σ(W^{(k)}\mathbb{x}^{(k-1)} + 𝐛^{(k)})$$ donde la matriz $W^{(k)}$ se denomina **matriz de pesos**, el vector $𝐛^{(k)}$ contiene los denominados **sesgos**, y la **función de activación** $σ∶ ℝ → ℝ$ se aplica elemento a elemento a su argumento, es decir: $$\sigma(y_1,\dots,y_n)=(\sigma(y_1),\dots, \sigma(y_n))$$ Una característica importante de las redes neuronales es que la función de activación no es lineal. Si fuera lineal, toda la red no sería más que una función lineal escrita como una complicada composición de funciones lineales. Hay dos consideraciones principales importantes para la elección de la función de activación: en primer lugar, debe facilitar el cálculo de la relación funcional entre los vectores de entrada y salida que tenemos y, en segundo lugar, debe ser adecuada para posibilitar y facilitar los cálculos necesarios para el aprendizaje que veremos más adelante. El primer requisito suele ser difícil de satisfacer a priori, ya que depende de la complejidad de la función que queremos aproximar. Un ejemplo lo encontraremos en el problema de reconocimiento de escritura a mano que veremos más adelante. El segundo requisito es especialmente importante, ya que una mala elección de la función de activación puede multiplicar la complejidad del ajuste de parámetros de la red hasta hacerlo imposible (un problema especialmente acuciante para redes neuronales profundas). Varias opciones populares de funciones de activación son las siguientes (la figura inferior muestra otras muchas opciones habituales): 1. Función *sigmoidea* o *función logística*: ${\displaystyle σ_1(x) =\frac{1}{1 + e^{−x}}}$ 2. *Leaky rectifier* (leaky RELU): $σ_2(x) = \begin{cases} x, & \text{si }x≥0\\ αx, & \text{si } x < 0 \end{cases}$, donde $0\leq \alpha << 1$. 3. *Rectificador* (RELU): $σ_3(x) = \max(x, 0)$ 4. *Rectificador suave*: $σ_4(x) = \ln(1 + e^x)$ 5. *Tangente hiperbólica*: ${\displaystyle σ_5(x) = \tanh(x) = \frac{e^x-e^{-x}}{e^x+e^{-x}}}$ ![](img/activationfunctions.png) Como se ve en la figura, estas funciones de activación y sus derivadas difieren sustancialmente en cuanto a su comportamiento lejos del cero. Por ejemplo, la derivada de la función sigmoidea, $σ_1$, se hace pequeña lejos de cero, lo que impide el aprendizaje por descenso de gradiente en redes profundas, ya que, como veremos, las derivadas pequeñas se multiplican, dando lugar a gradientes diminutos y, por tanto, a un aprendizaje lento. La leaky RELU, $σ_2$, elude este problema y todavía permite el aprendizaje para $x < 0$ (salvo que el parámetro $α$ sea demasiado pequeño), a diferencia de la RELU normal, $σ_3$. Por supuesto, también es posible utilizar diferentes funciones de activación en diferentes capas y utilizar capas con diferentes conexiones entre las neuronas. De hecho, esta posibilidad se ha aprovechado mucho en el diseño de redes neuronales, por ejemplo, en las **redes neuronales convolucionales**, pero será algo que evitaremos en este tema con el fin de simplificar los cálculos y la implementación. ## Interpretando la salida La salida de todas estas funciones de activación es de valor real. Por lo tanto, cuando se realiza una regresión (se aproxima una función que da valores númericos reales) utilizando una red neuronal, la capa de salida proporciona un resultado directo. Sin embargo, en los problemas de clasificación, donde la salida de la red debe ser una de las clases discretas, la salida debe discretizarse, y tenemos varias opciones para ello: * Si el número de clases es pequeño, es conveniente discretizar la salida de la función de activación de una sola neurona en la capa de salida. Por ejemplo, si la función de activación es $σ_1$ y el modelo debe diferenciar dos clases, entonces se sugiere la discretización $([0, \frac{1}{2}], (\frac{1}{2}, 1])$; si la función de activación es $tanh$ y hay que diferenciar tres clases, entonces se sugiere la discretización $([-1, -\frac{1}{3}], (-\frac{1}{3}, \frac{1}{3}], (\frac{1}{3}, 1])$. * Por otro lado, si el número de clases es relativamente grande, entonces es mucho mejor asignar una clase a cada neurona de la capa de salida y considerar que el índice de la neurona con el mayor valor de la función de activación es la clasificación del vector de entrada (así haremos en el ejemplo del reconocimiento de dígitos manuscritos: trabajamos con una red con $10$ neuronas en la capa de salida y el índice de la neurona con mayor activación indicará la clasificación de la imagen de entrada, de $0$ a $9$). Las redes neuronales que hacen uso de las activaciones que hemos visto son funciones diferenciables (en casi todos los puntos). Es una característica importante que los problemas discretos, como los de clasificación, se formulen de forma que el aprendizaje se convierta en la optimización de una función diferenciable, que es mucho más fácil de resolver computacionalmente que un problema de optimización discreto. El hecho de que una función de activación sea diferenciable solo en casi todas partes (como es el caso de la RELU) no plantea un problema en la práctica, ya que podemos simplemente utilizar el límite de la izquierda o de la derecha en su lugar en cualquier punto donde la derivada no esté definida. ## Implementación en Julia Desde el punto de vista de la implementación en Julia, vamos a definir un módulo llamado `NN` y una estructura de datos, `Activation`, con dos campos, `f` y `d`, donde `f` contiene la función de activación, y `d` la derivada de esa función. Como ejemplo, y para acortar los códigos necesarios, en el siguiente código definimos únicamente la función de activación *sigmoide*. El conjunto de paquetes que se importan son simplemente utilitarios, no afectan a la implementación, que será en Julia puro. ```julia module NN import LinearAlgebra import Random struct Activation f::Function d::Function end function sigma(x) 1/(1+exp(-x)) end const sigmoid = Activation( sigma, x -> sigma(x)*(1-sigma(x)) ) ``` La estructura de datos `Network` contiene los pesos y sesgos de todas las capas con información adicional. `weights` son matrices y `biases` son vectores. El vector `sizes` contiene el número de neuronas de cada capa. Los cuatro vectores finales registrarán el progreso durante el entrenamiento, como veremos más adelante. La función `Network` es un constructor personalizado y solo requiere los tamaños de las capas, pero toma tres argumentos clave. Llama a la función `new`, solo disponible en este contexto, para construir la instancia. Los pesos y los sesgos se inicializan con números aleatorios distribuidos normalmente. Si una matriz de pesos es grande, su producto con la salida de la capa anterior tiende a ser grande también. Esto dificulta el aprendizaje con funciones de activación cuyas derivadas son pequeñas para argumentos grandes. Por lo tanto, suele ser útil escalar las matrices de pesos de forma que los productos de las matrices de pesos con columnas de todo $1s$ sigan estando distribuidos normalmente con varianza $1$; esto se consigue mediante el factor de escalado `sqrt(j)`, que es la raíz cuadrada del tamaño de la capa anterior. Para simplificar la implementación, hemos obviado este proceso de escalado. ```julia mutable struct Network activation::Activation cost::Cost n_layers::Int sizes::Vector{Int} weights::Vector{Array{Float64,2}} biases::Vector{Vector{Float64}} function Network(sizes; activation = sigmoid, cost = quadratic_cost(activation), )::Network new(activation, cost, length(sizes), sizes, [randn(i, j) for (i,j) in zip(sizes[2:end], sizes[1:end-1])], [rand(i) for i in sizes[2:end]] ) end end ``` Una vez definidas estas estructuras de datos, podemos construir nuestra primera red neuronal evaluando, por ejemplo: ```julia Network([100,10,1]) ``` que genera una red con tres capas: una primera capa (de entrada) con $100$ neuronas, una capa oculta de $10$ neuronas, y una capa de salida con $1$ neurona. La evaluación de una red neuronal se denomina comúnmente **feed forward**, porque solo se sigue un proceso de avance en la propagación de los valores por la red. Para implementar este proceso, hacemos un bucle sobre todas las matrices de pesos y vectores de sesgo simultáneamente utilizando `zip`. En cada iteración, la activación de la capa anterior se transforma linealmente y, a continuación, se aplica la función de activación `nn.activation.f` elemento a elemento: ```julia function feed_forward(nn:Network, input::Vector)::Vector local a = input for (W,b) in zip(nn.weights, nn.biases) a = nn.activation.f.(W*a + b) end a end ``` # Teorema de Aproximación Universal Antes de entrenar nuestra red neuronal (es decir, ajustar los pesos y sesgos para adaptarlos a los datos que tengamos o aproximar la función que nos interese), debemos plantearnos la importante pregunta de si **las redes neuronales como las que estamos considerando pueden aproximar funciones arbitrarias para ser aprendidas o no**. Esta pregunta es fundamental: si las relaciones arbitrarias entre la entrada y la salida no pudieran representarse mediante tales funciones, sería absurdo intentar entrenar redes neuronales. Afortunadamente, la respuesta a esta pregunta es positiva. Como muestra el siguiente teorema, las redes neuronales sin capa oculta, pero cuya salida consiste en una combinación lineal de un número suficientemente grande de neuronas, ya son capaces de aproximar cualquier función continua dada arbitrariamente bien en subconjuntos compactos de $ℝ^𝑑$. Los supuestos sobre la función de activación son bastante permisivos. La restricción de que la función a aproximar debe ser continua es comprensible, ya que las redes neuronales son funciones continuas (pero si la función tuviera algunas discontinuidades, tampoco sería un gran problema). !!!def: Teorema de Aproximación Universal Supongamos que $K$ es un subconjunto compacto de $ℝ^𝑑$, que $𝑓$ es una función arbitraria en $𝐶(𝐾, ℝ)$, que $ϵ ∈ ℝ^+$ es arbitrario, y que $σ$ es una función continua no constante, acotada y monótonamente creciente. Entonces existe un número $𝑛\in \mathbb{N}$, $𝑏_𝑖 ∈ ℝ$, $𝑣_𝑖 ∈ ℝ$, y los vectores $𝐰_𝑖 ∈ ℝ^𝑑$ para $𝑖 ∈ \{1,\dots, 𝑑\}$ tales que se cumple la desigualdad $$\max_{x ∈ K}|φ(x)-f(x)| < ϵ$$ donde la aproximación $φ$ se define como $$\varphi(x) = \sum_{i=1}^n v_i σ(𝐰_i ⋅ x + b_i)$$ es decir, las funciones $\varphi$ son densas en $𝐶(𝐾)$. !!! alg:Prueba (esquema)
Mostrar En lugar de dar una prueba completa del teorema, vamos a esbozar un argumento visual e intuitivo para un resultado ligeramente más débil. El argumento intuitivo funciona para redes neuronales con dos capas ocultas y para funciones de activación sigmoideas, pero sería fácilmente generalizable y es suficiente para entender por qué funciona. En el primer paso, aproximamos funciones $𝑔 ∈ 𝐶([𝑎, 𝑏], ℝ)$, es decir, funciones continuas con entrada y salida unidimensionales. Comenzamos considerando una red neuronal con dos neuronas en una única capa oculta y una neurona de salida. Aumentar el peso de la primera neurona oculta hace que su salida se aproxime a una función escalón, y cambiar el sesgo de la primera neurona oculta cambia la posición del escalón en consecuencia. Por tanto, podemos aproximar la salida de la primera neurona oculta mediante una función escalón con una posición de escalón ajustable. Lo mismo ocurre con la segunda neurona oculta. A continuación, podemos combinar las salidas de las dos neuronas de la capa oculta, que son dos funciones escalón, de forma que la neurona de la capa de salida sea distinta de cero solo entre las dos posiciones escalón. Además, la altura del escalón también puede ajustarse arbitrariamente. Esto significa que la neurona en la capa de salida antes de aplicar la función de activación puede hacerse aproximadamente igual a $$φ_𝑗(𝑥) ∶= \begin{cases} 𝑐_𝑗 & \text{, si } 𝑥 ∈ [𝑎_𝑗, 𝑏_𝑗)\\ 0 & \text{, en caso contrario} \end{cases}$$ Utilizando una capa oculta con $2𝑚$ neuronas, podemos aproximar cualquier función constante a trozos en el intervalo $[𝑎, 𝑏]$: $$\varphi(x) = \sum_{j=1}^m \varphi_j(x) ≈ σ^{−1}◦𝑔(𝑥)$$ Estas funciones constantes a trozos aproximan $𝑔 ∈ 𝐶([𝑎, 𝑏])$ después de aplicar la función de activación $σ$ en la neurona de salida a medida que $𝑚$ aumenta. Esto concluye el primer paso, en el que aproximamos funciones continuas con entrada y salida unidimensionales. ![](img/stairstep.png width=30%) En el segundo paso, generalizamos esta idea para aproximar funciones $𝑓 ∈ 𝐶(ℝ^𝑑, ℝ)$. En el caso bidimensional, utilizamos dos neuronas para cada una de las dos dimensiones para construir funciones constantes a trozos de la forma: $$φ_{1𝑗} ((𝑥_1, 𝑥_2)) ∶= \begin{cases} 𝑐_{1𝑗}& \text{, si } 𝑥_1 ∈ [𝑎_{1𝑗}, 𝑏_{1𝑗})\\ 0 & \text{, en otro caso} \end{cases}$$ $$φ_{2k} ((𝑥_1, 𝑥_2)) ∶= \begin{cases} 𝑐_{2k}& \text{, si } 𝑥_1 ∈ [𝑎_{2k}, 𝑏_{2k})\\ 0 & \text{, en otro caso} \end{cases}$$ en la primera capa oculta. Estas cuatro neuronas se combinan en la segunda capa oculta para aproximar funciones de la forma: $$φ_{jk} ((𝑥_1, 𝑥_2)) ∶= \begin{cases} 𝑐_{jk}& \text{, si } (𝑥_1,x_2) ∈ ([a_{1j},b_{1j}), [𝑎_{2k}, 𝑏_{2k}))\\ 0 & \text{, en otro caso} \end{cases}$$ ![](img/3dbar.png align=right width=30%)que son distintas de cero solo en un rectángulo. Utilizando estas funciones $φ_{𝑗𝑘}$, podemos aproximar cualquier función continua $𝑓 ∈ 𝐶(ℝ^2, ℝ)$ por la función constante a trozos (sobre rectángulos): $$\varphi((𝑥_1, 𝑥_2)) ∶= \sum_{j=1}^{m_j} \sum_{k=1}^{m_k} \varphi_{𝑗𝑘} ((𝑥_1, 𝑥_2)) ≈ \sigma^{-1}◦𝑓((𝑥_1, 𝑥_2))$$ y finalmente aplicando la función de activación $σ$ en la capa de salida. Este argumento puede generalizarse de $2$ a $𝑑$ dimensiones. En el tercer y último paso, hacemos superflua la suposición de que las neuronas se aproximan mediante funciones escalonadas. En el caso unidimensional $𝐶(ℝ, ℝ)$, elegimos intervalos de la misma longitud. El error debido a las funciones escalonadas suavizadas se produce donde los intervalos se encuentran. Podemos hacer que este error sea arbitrariamente pequeño añadiendo un gran número $𝑁$ de aproximaciones desplazadas de la función $𝑓(𝑥)∕𝑁$, porque entonces cada punto $𝑥$ solo se ve afectado por el error debido a una aproximación desplazada, que disminuye a medida que aumenta $N$. Estas ideas pueden generalizarse al caso multidimensional.
Las funciones en $𝐶(𝐾, ℝ^𝐷)$ pueden aproximarse combinando $D$ redes neuronales, una red neuronal para cada una de las funciones componentes. Este Teorema de Aproximación Universal es de gran importancia teórica, ya que justifica ajustar redes neuronales para aproximar funciones arbitrarias. Sin embargo, sigue sin decirnos cómo deben elegirse los parámetros de la red neuronal, las funciones de activación, el número de capas y el número de neuronas en cada capa. Como en el resultado anterior solo se hace uso de una capa de neuronas, puede inducirnos a creer erróneamente que las redes neuronales poco profundas son suficientes en la práctica. Lo cierto es lo contrario: la ventaja de las redes profundas con muchas capas es su estructura jerárquica. Por ejemplo, en el reconocimiento de imágenes, existe una jerarquía (por niveles de profundidad) que se especializa en el reconocimiento de píxeles, bordes, formas geométricas simples, objetos más grandes formados por las formas simples e incluso escenas que contienen múltiples objetos. En los problemas del mundo real, las redes profundas son mucho más útiles que las superficiales. # Funciones de coste Para entrenar una red neuronal y ajustarla a unos datos concretos, debemos ser capaces de medir hasta qué punto su salida coincide con las etiquetas dadas de los datos. Las funciones que miden esta diferencia se denominan *funciones de coste*, *funciones de pérdida* o *funciones objetivo*, y existen muchas opciones (ya dimos un par de ellas en el código anterior). En lo siguiente, $𝐱$ denota el dato de entrada, $𝐲(𝐱)$ denota el dato de salida esperado (que está almacenado en la base de datos, $T$, o se deduce de ella, por ejemplo), y $𝐚(𝐱)$ sería la salida propuesta por la red neuronal. Una de las funciones de coste más populares es la *función de coste cuadrática*: $$𝐶_2(𝑊, 𝐛) ∶= \frac{1}{2|𝑇|}\sum_{𝐱∈𝑇} ||𝐲(𝐱) − 𝐚(𝐱)||_2^2$$ también llamado *error cuadrático medio*. La suma es sobre todos los elementos del conjunto de entrenamiento $𝑇$. La función de coste es una función de los parámetros de la red neuronal, que se denotan por $𝑊$ y $𝐛$ para la colección de todos los pesos y sesgos. Se introduce el factor $1∕|𝑇|$ para que los valores de la función de coste sean comparables aunque cambie el tamaño del conjunto de entrenamiento. Y el factor $\frac{1}{2}$ elimina el factor $2$ en la derivada de la función de coste, que utilizaremos en breve. Todos estos factores son, por supuesto, irrelevantes cuando se minimiza la función de coste. Se pueden utilizar, por supuesto, otras normas en lugar de la norma euclídea. Diferentes elecciones de funciones de coste conducen generalmente a diferentes redes neuronales, y una elección conveniente depende generalmente del problema en cuestión. Otra función de coste popular es la *función de coste de entropía cruzada*: $$𝐶_{CE}(𝑊, 𝐛) ∶= − \frac{1}{|𝑇|} \sum_{𝐱∈𝑇} (𝐲(𝐱) ⋅ \ln 𝐚(𝐱) + (𝟏 − 𝐲(𝐱)) ⋅ \ln(𝟏 − 𝐚(𝐱)))$$ donde el logaritmo se aplica elemento a elemento a su argumento vectorial. La función de coste, la función de activación de la capa de salida y la rapidez con la que aprende una red están estrechamente relacionadas. ## Implementación en Julia El siguiente tipo de datos `Cost` contiene la **función de coste** que se va a utilizar. Vamos a definir las dos funciones de coste que acabamos de ver para poder evaluar nuestra red neuronal. ```julia struct Cost f::Function delta::Function end function quadratic_cost(activation::Activation)::Cost Cost((a, y) -> 0.5 * LinearAlgebra.norm(a - y)^2, (z, a, y) -> (a - y) .* activation.d.(z)) end const cross_entropy_cost = Cost( (a, y) -> sum(- y .* log.(a) - (1 - y) .* log.(1 - a)), (z, a, y) -> a - y ) ``` # Descenso del Gradiente Estocástico Para mejorar una red neuronal, nuestro objetivo es encontrar pesos y sesgos que minimicen la función de coste. Como las redes neuronales suelen tener un gran número de pesos y sesgos, suele tratarse de un problema de optimización de gran dimensión. Para minimizar la función de coste, utilizamos una versión del descenso del gradiente llamada **descenso del gradiente estocástico**. Por supuesto, se pueden utilizar otros métodos de optimización dependiendo del tamaño del problema de optimización. ¿Cómo funciona el descenso de gradiente? Para simplificar la notación, recogemos todos los parámetros de la red, es decir, todos los pesos y sesgos, en el vector $𝐩$. Por tanto, el gradiente de la función de coste $𝐶$ es: $$\nabla(C)=\begin{pmatrix}\frac{\partial C}{\partial p_1}\\ \vdots \\ \frac{\partial C}{\partial p_n} \end{pmatrix}$$ La derivada direccional ${\displaystyle \frac{\partial C}{\partial 𝐞} (𝐩)}$, es la derivada de $𝐶$ en $𝐩$ en la dirección del vector unitario $𝐞$ y puede escribirse usando el gradiente de $𝐶$ en $𝐩$ como $$\frac{\partial C}{\partial 𝐞} (𝐩) = ∇𝐶(𝐩) ⋅ 𝐞$$ Partiendo del punto $𝐩$, nos gustaría dar un (pequeño) paso en la dirección que minimice $𝐶(𝐩)$ al máximo. ¿Cómo podemos encontrar una dirección $𝐞$ en la que la función $𝐶$ cambie lo máximo posible? Hay una famosa desigualdad, la de Cauchy-Bunyakovsky-Schwarz, de la que podemos sacar la siguiente información: $$|\frac{\partial C}{\partial 𝐞}(𝐩)| = |∇𝐶(𝐩) ⋅ 𝐞| ≤ ||∇𝐶(𝐩)||$$ ya que $||𝐞|| = 1$. Además, la igualdad en esta desigualdad se cumple si y solo si un vector es múltiplo del otro. Por tanto, la desigualdad significa que los múltiplos del gradiente son las direcciones en las que más cambia la derivada direccional: * Si la dirección es $𝐞 = ∇𝐶(𝐩)$, entonces la derivada direccional es $$\frac{\partial C}{\partial 𝐞}(𝐩)= ∇𝐶(𝐩) ⋅ ∇𝐶(𝐩) = ||∇𝐶(𝐩)||^2 ≥ 0$$ y dar un paso en esta dirección aumenta $𝐶$ (**ascenso de gradiente**). * Si la dirección es $𝐞 = -∇𝐶(𝐩)$, entonces la $$\frac{\partial C}{\partial 𝐞}(𝐩)= -∇𝐶(𝐩) ⋅ ∇𝐶(𝐩) = -||∇𝐶(𝐩)||^2 ≥ 0$$ y dar un paso en esta dirección disminuye $𝐶$ (**descenso de gradiente**). Como nuestro objetivo es minimizar la función $𝐶$, definimos el paso $Δ𝐩$ a tomar en el punto $𝐩$ como $Δ𝐩 ∶= -η∇𝐶(𝐩)$, donde $η ∈ ℝ^+$ se denomina **tasa de aprendizaje**. La derivada direccional implica que el cambio $Δ𝐶$ en el valor de la función viene dado aproximadamente por $$Δ𝐶 ≈ ∇𝐶(𝐩) ⋅ Δ𝐩$$ cuando $Δ𝐩$ es suficientemente pequeño, lo que produce $$Δ𝐶 ≈ -η ∇𝐶(𝐩) ⋅ ∇𝐶(𝐩) = -η ‖∇𝐶(𝐩)‖^2 ≤ 0$$ Es decir, el valor de la función efectivamente disminuye. Una vez analizados los métodos de ascenso y descenso de gradiente, ahora utilizaremos una variante denominada **descenso de gradiente estocástico** para ajustar los parámetros de la red neuronal. El descenso de gradiente estocástico es el método básico más común para minimizar la función de coste. Solo significa que los datos de entrenamiento se dividen aleatoriamente en lotes y que el descenso de gradiente se realiza para cada lote. Una razón para hacerlo así es que, en la práctica, el número de elementos de entrenamiento es muy grande, por lo que trabajar con lotes es más manejable. Además, los lotes razonablemente grandes suelen ser suficientes para obtener una buena aproximación del gradiente, y la naturaleza estocástica del descenso estocástico del gradiente ayuda a escapar de los mínimos locales. Otra ventaja es que los gradientes pueden calcularse en paralelo para todos los lotes, sacando provecho, por ejemplo, de computación GPU. ## Implementación en Julia El descenso de gradiente estocástico se implementa mediante la función `SGD`. El número de pasos en el descenso de gradiente estocástico se denomina comúnmente **número de épocas**. La función también puede controlar la función de coste y la precisión (es decir, cuántos elementos se clasifican correctamente) durante las épocas. Puede hacerlo utilizando los datos de entrenamiento que deben suministrarse, pero también puede utilizar datos de validación opcionales: ```julia function SGD(nn::Network, tr_data_x::Vector{Vector{Float64}}, tr_data_y::Vector{Vector{Float64}}, epochs::Int, batch_size::Int, eta::Float64 ) for epoch in 1:epochs local perm = Random.randperm(length(tr_data_x)) for k in 1:batch_size:length(tr_data_x) update!(nn, tr_data_x[perm[k:min(k+batch_size-1,end)]], tr_data_y[perm[k:min(k+batch_size-1,end)]], eta) end end nn end ``` !!! note:Algunos detalles adicionales Aunque no es estrictamente necesario, resulta interesante tener algún sistema de control que permita monitorizar el proceso de aproximación. Para ello, podríamos definir algunas funciones adicionales que calculan el coste y la precisión de una red neuronal (se debe recordar que estas funciones deben tener, al menos, dos métodos, uno para etiquetas que son números enteros y otro para etiquetas vectorizadas). Con ellas podríamos definir una versión más completa de la función SDG que, mientras genera los pasos de aproximación del algoritmo, monitoriza cómo se comporta la red neuronal respecto a los diversos conjuntos de datos. La función `update!` añade el gradiente de los pesos y sesgos multiplicado por la tasa de aprendizaje $\eta$ a los pesos y sesgos de la red para cada lote de elementos de entrenamiento. Los gradientes se calculan mediante la función `propagate_back`, que se discutirá en la siguiente sección. ```julia function update!(nn:Network, batch_x::Vector{Vector{Float64}}, batch_y::Vector{Vector{Float64}}, eta::Float64 )::Network local grad_W = [fill(0.0, size(W)) for W in nn.weights] local grad_b = [fill(0.0, size(b)) for b in nn.biases] for (x,y) in zip(batch_x, batch_y) (delta_grad_W, delta_grad_b) = propagate_back(nn, x, y) grad_W += delta_grad_W grad_b += delta_grad_b end nn.weights = nn.weights - (eta / length(batch_x)) * grad_W nn.biases -= (eta / length(batch_x)) * grad_b nn end ``` Observa que la actualización de los pesos y sesgos de la red (sus gradientes) se calculan por medio de una función auxiliar, `propagate_back`, que se explica en la siguiente sección. # Retropropagación En esta sección vamos a calcular el gradiente de una red neuronal. La función `propagate_back`, que calcula el gradiente de pesos y sesgos (o una aproximación de ellos), es la última pieza que nos falta para poder entrenar la red neuronal. Como una red neuronal es la composición de sus capas, que se traduce en una composición funcional, utilizamos la regla de la cadena para calcular las derivadas. Empezaremos por fijar la notación. Denotamos la activación de la capa $𝑙$-ésima por: $$𝐚^{(𝑙)} = σ(𝑊^{(𝑙)}𝐚^{(𝑙-1)} + 𝐛^{(𝑙)})$$ donde recordemos que la función $σ$ se aplica elemento a elemento a su argumento vectorial. Denotemos la entrada ponderada a las neuronas de la capa $𝑙$ por: $$𝐳^{(𝑙)} = 𝑊^{(𝑙)}𝐚^{(𝑙-1)} + 𝐛^{(𝑙)}$$ Por tanto, tenemos que se cumple la ecuación $𝐚^{(𝑙)} = σ(𝐳^{(𝑙)})$. !!!alg:Detalles del Cálculo
Expandir El algoritmo de retropropagación necesita calcular todas las derivadas parciales de la función de coste $𝐶$ con respecto a todos los elementos de las matrices de pesos $𝑊^{(𝑙)}$ y con respecto a todos los elementos de los vectores de sesgo $𝐛^{(𝑙)}$, con el fin de encontrar la dirección de mayor cambio: $$\frac{\partial C}{\partial w_{ij}^{(l)}}\quad \text{y} \quad \frac{\partial C}{\partial b_{i}^{(l)}}$$ Vamos a hacer dos suposiciones sobre la función de coste $𝐶$ (no son suposiciones muy extrañas, normalmente se satisfacen): 1. Puede escribirse como una media sobre todas las muestras de entrenamiento $𝐱$ en el conjunto de entrenamiento $𝑇$: $$𝐶(𝑊, 𝐛) = \frac{1}{|𝑇|} \sum_{𝐱∈𝑇} 𝐾(𝑊, 𝐛, 𝐱)$$ 2. Solo depende de la activación de la capa de salida de la red neuronal, es decir, $𝐶 = 𝐶(𝐚^{(𝑙)})$. Por ejemplo, las dos funciones de coste que definimos previamente, la función de coste cuadrática y la función de coste de entropía cruzada, satisfacen estas dos suposiciones. Para ayudar a aplicar la regla de la cadena, denotamos las derivadas parciales de la función de coste $𝐶$ con respecto a la entrada ponderada $𝐳^{(𝑙)}_𝑖$ a la neurona $𝑖$ en la capa $𝑙$ por $$\delta^{(𝑙)}_𝑖 = \frac{\partial C}{\partial z_{i}^{(l)}}$$ Este valor se llama a menudo **el error de la neurona $𝑖$ en la capa $𝑙$**. Comenzamos con la capa de salida $𝐿$. Aplicando la regla de la cadena a la definición anterior del error se obtiene: $$\forall i\quad \delta^{(𝐿)}_𝑖 = \sum_𝑘 \frac{\partial C}{\partial a_k^{(L)}}\frac{\partial a_k^{(L)}}{\partial z_{i}^{(L)}}$$ donde la suma es sobre todas las neuronas $𝑘$ en la capa de salida $𝐿$. Como la función de activación $\sigma$ se aplica elemento a elemento, la derivada parcial ${\displaystyle \frac{\partial a_k^{(L)}}{\partial z_{i}^{(L)}}}\neq 0$ solo si $𝑘 = 𝑖$. Por tanto, tenemos: $$\forall i\quad \delta_i^{(𝐿)} = \frac{\partial C}{\partial a_i^{(L)}} \sigma'(𝑧_i^{(𝐿)})$$ o escrito matricialmente: !!! alg $$\delta(𝐿) = \frac{\partial C}{\partial a^{(L)}} ⊙ \sigma'(𝑧^{(𝐿)})$$ una fórmula para el error en la capa de salida, donde $⊙$ denota el producto elemento a elemento, y $σ′$ se aplica elemento a elemento a su argumento vectorial. En segundo lugar, derivamos una ecuación para el error $\delta^{(𝑙)}$ en términos del error $\delta^{(𝑙+1)}$. La regla de la cadena da como resultado: $$\delta_i^{(l)} = \frac{\partial C}{\partial z_i^{(l)}}= \sum_{𝑘} \frac{\partial C}{\partial z_k^{(l+1)}}\frac{\partial z_k^{(l+1)}}{\partial z_i^{(l)}} = \sum_{𝑘} \delta_k^{(𝑙+1)}\frac{\partial z_k^{(l+1)}}{\partial z_i^{(l)}}$$ Como $$𝐳^{(𝑙+1)} = 𝑊^{(𝑙+1)}𝐚^{(𝑙)} + 𝐛^{(𝑙+1)} = 𝑊^{(𝑙+1)}σ(𝐳^{(𝑙)}) + 𝐛^{(𝑙+1)}$$ y por tanto $$𝑧_k^{(𝑙+1)} = \sum_𝑖 𝑤_{ki}^{(𝑙+1)} \sigma(𝑧_i^{(𝑙)}) + 𝑏_k^{(𝑙+1)}$$ se tiene por definición, llegamos a que: $$\frac{\partial z_k^{(l+1)}}{\partial z_i^{(l)}} = 𝑤^{(𝑙+1)}_{𝑘𝑖} \sigma'(𝑧^{(𝑙)}_𝑖)$$ y, por tanto: $$\delta_i^{(𝑙)} = \sum_𝑘 \delta^{(𝑙+1)}_𝑘 𝑤^{(𝑙+1)}_{𝑘𝑖} \sigma'(𝑧^{(𝑙)}_𝑖) = (\sum_𝑘 𝑤^{(𝑙+1)}_{𝑘𝑖} \delta^{(𝑙+1)}_𝑘) \sigma'(𝑧^{(𝑙)}_𝑖)$$ o escrito matricialmente: !!! alg $$\delta^{(𝑙)} = ((𝑊^{(𝑙+1)})^⊤ \delta^{(𝑙+1)}) ⊙ \sigma'(𝐳^{(𝑙)})$$ En tercer lugar, podemos encontrar las derivadas parciales de la función de coste $𝐶$ con respecto a todos los pesos y sesgos utilizando los errores $\delta^{(𝑙)}$. La regla de la cadena da como resultado: $$\frac{\partial C}{\partial w^{(l)}_{ij}}=\sum_k \frac{\partial C}{\partial z^{(l)}_{k}}\frac{\partial z^{(l)}_k}{\partial w^{(l)}_{ij}} \quad,\quad \frac{\partial C}{\partial b^{(l)}_{i}}=\sum_k \frac{\partial C}{\partial z^{(l)}_{k}}\frac{\partial z^{(l)}_k}{\partial b^{(l)}_{i}}$$ A partir de la definición: $𝑧^{(𝑙)}_𝑘 = ∑_𝑗 𝑤^{(𝑙)}_{𝑘𝑗} 𝑎_j^{(𝑙−1)} + 𝑏_k^{(𝑙)}$, y diferenciando, se obtiene que: $$\frac{\partial z_k^{(l)}}{\partial w^{(l)}_{ij}} = \begin{cases} 𝑎^{(𝑙−1)}_𝑗 & \text{, si } 𝑖 = 𝑘\\ 0 &\text{, si } 𝑖 ≠ 𝑘 \end{cases} \quad,\quad \frac{\partial z_k^{(l)}}{\partial b^{(l)}_{i}} = \begin{cases} 1 & \text{, si } 𝑖 = 𝑘\\ 0 &\text{, si } 𝑖 ≠ 𝑘 \end{cases}$$ y como ${\displaystyle \frac{\partial C}{\partial z^{(l)}_{k}}=\delta^{(l)}_k}$, obtenemos que: !!!alg $$\frac{\partial C}{\partial w^{(l)}_{ij}}= \delta_i^{(l)} a_j^{(l-1)} \quad,\quad \frac{\partial C}{\partial b^{(l)}_{i}}= \delta_i^{(l)}$$
Las ecuaciones destacadas constituyen las ecuaciones fundamentales de la retropropagación: !!! def: Ecuaciones Fundamentales de la Retropropagación \begin{equation} \label{retro1} \delta^{(𝐿)} = \frac{\partial C}{\partial a^{(L)}} ⊙ \sigma'(𝑧^{(𝐿)}) \end{equation} \begin{equation} \label{retro2} \delta^{(𝑙)} = ((𝑊^{(𝑙+1)})^⊤ \delta^{(𝑙+1)}) ⊙ \sigma'(𝐳^{(𝑙)}) \end{equation} \begin{equation} \label{retro3} \frac{\partial C}{\partial w^{(l)}_{ij}}= \delta_i^{(l)} a_j^{(l-1)} \quad,\quad \frac{\partial C}{\partial b^{(l)}_{i}}= \delta_i^{(l)} \end{equation} Todas las derivadas parciales de la función de coste $𝐶$ se calculan a través de los errores $\delta^{(𝑙)}$, así como las entradas ponderadas $𝐳^{(𝑙)}$ y las activaciones $𝐚^{(𝑙)}$. Las entradas ponderadas y las activaciones se encuentran simplemente alimentando la red hacia adelante. A continuación, calculamos los errores $\delta^{(𝑙)}$ comenzando con el error $\delta^{(L)}$ de la última capa $𝐿$ (usando eqn.[retro1]) y trabajando recursivamente hacia las capas inferiores (usando eqn.[retro2]) para obtener $\delta^{(𝑙)}$ a partir de $\delta^{(𝑙+1)}$. Por último, eqn.[retro3] produce las derivadas parciales $\frac{\partial C}{\partial w^{(l)}_{ij}}$ y $\frac{\partial C}{\partial b^{(l)}_{i}}$, ya que ahora se conocen los errores y las activaciones. El algoritmo de retropropagación es muy eficaz, ya que solo consta de dos pasadas por las capas para calcular todas las derivadas parciales: en el paso hacia delante se calculan las entradas ponderadas y las activaciones, mientras que en el paso hacia atrás se calculan los errores y las derivadas parciales. ## Implementación en Julia En nuestra librería, la retropropagación se implementa en Julia mediante la función `propagate_back`. En primer lugar, se asignan las dos variables `grad_W` y `grad_b` para las derivadas parciales, así como las dos variables `z` y `a` para las entradas y activaciones ponderadas. Se debe tener en cuenta que `a[l]` es igual a $𝐚^{(𝑙-1)}$ de modo que `a[1]` registra la entrada a la red neuronal. El primer bucle es el paso hacia adelante, donde se calculan todas las entradas y activaciones. Antes del segundo bucle, la variable `delta` se inicializa como $\delta^{(𝐿)}$ (eqn. [retro1]). Dado que la expresión depende de la función de coste, se calcula mediante la función almacenada en el campo `delta` del tipo `cost`. Los resultados `grad_W[end]` y `grab_b[end]` vienen dados por eqn. [retro3] para $𝑙 = 𝐿$. A continuación, en el segundo bucle, la variable `delta` se actualiza recursivamente utilizando eqn. [retro2] y los resultados `grad_W[l]` y `grad_b[l]` se calculan mediante eqn.[retro3]. De nuevo, debe tenerse en cuenta que `a[l]` es igual a $𝐚^{(𝑙-1)}$. ```julia function propagate_back(nn::Network, x::Vector{Float64}, y::Vector{Float64})::Tuple local grad_W = [fill(0.0, size(W)) for W in nn.weights] local grad_b = [fill(0.0, size(b)) for b in nn.biases] local z = Vector(undef, nn.n_layers - 1) local a = Vector(undef, nn.n_layers) a[1] = x for (i, (W,b)) in enumerate(zip(nn.weights, nn.biases)) z[i] = W * a[i] + b a[i+1] = nn.activation.f.(z[i]) end local delta = nn.cost.delta(z[end], a[end], y) grad_W[end] = delta * a[end-1]' grad_b[end] = delta for l in nn.n_layers-2:-1:1 delta = (nn.weights[l + 1]' * delta) .* nn.activation.d.(z[l]) grad_W[l] = delta * a[l]' grad_b[l] = delta end (grad_W, grad_b) end ``` En este punto, el código del módulo que implementa nuestra red neuronal está completo. # Reconocimiento de escritura Para ilustrar cómo aprenden las redes neuronales, consideraremos un ejemplo del mundo real: cómo reconocer dígitos escritos a mano. El reconocimiento de la escritura a mano es una tarea clásica y no trivial de la inteligencia artificial y el aprendizaje automático, que se ha demostrado muy adecuada para las redes neuronales, pero que durante mucho tiempo fue atacada (infructuosamente) por mecanismos clásicos de Visión por Computador (como los detectores de formas, funciones de densidad, detecciones de bordes, etc.). Afortunadamente, existen bases de datos de dígitos escritos a mano, como la base de datos **MNIST** (*Modified National Institute of Standards and Technology*), que contiene $60.000$ imágenes de entrenamiento y $10.000$ imágenes de prueba. Las imágenes de los dígitos vienen en escala de grises y con un tamaño de $28 × 28$ píxeles, y fueron digitalizadas a partir de dígitos escritos por empleados de la Oficina del Censo de Estados Unidos y estudiantes de secundaria estadounidenses. La siguiente figura muestra algunas de las primeras imágenes de la base de datos. En Julia, esta base de datos puede descargarse, por ejemplo, instalando el paquete [`MLDatasets`](https://juliaml.github.io/MLDatasets.jl) (un paquete que proporciona un acceso común a muchos conjuntos de datos de distintos ámbitos con el fin de ser usados como sistemas de comparación de algoritmos), y ejecutando `MLDatasets.MNIST.download()`, pero hay muchas otras opciones. ![](img/NN2.png width=50%) Para la manipulación efectiva de las imágenes de este problema, denotamos los elementos de los datos de entrenamiento como vectores $𝐱 ∈ ℝ^{784}$ (un vector de tamaño $28 ⋅ 28 = 784$ representa una imagen). Estos elementos sirven de entrada a la red neuronal. Las etiquetas correspondientes en los datos de entrenamiento se denotan por $𝑦(𝐱) ∈ ℝ^{10}$, donde cada uno de sus diez elementos, o neuronas, corresponde a un dígito posible de respuesta. En otras palabras, la función $𝐲 ∶ 𝑇 ⊂ ℝ^{784} → ℝ^{10}$ representa la relación funcional entre entradas y salidas de los datos de entrenamiento dados. Además, en el proceso de cálculo con la red neuronal, denotamos por $𝐚 ∶ ℝ^{784} → ℝ^{10}$, la función cuyo valor es la activación de la capa de salida (que esperamos que se parezca lo máximo posible a la salida real). ## Preparación de los Datos en Julia El primer paso es toda la base de datos y almacenarla en seis variables globales, que contendrán las imágenes y etiquetas de los conjuntos de datos de entrenamiento, validación, y prueba, respectivamente. Las imágenes MNIST se dividen en estos tres conjuntos de datos, y la razón para hacerlo se discutirá más adelante. Las variables globales `MNIST_n_rows` y `MNIST_n_cols` contienen el número de filas y columnas de las imágenes en escala de grises. Después de leer cada imagen en la función `MNIST_read_images`, sus píxeles se escalan al intervalo $[0, 1]$. Recordemos que una red neuronal devuelve vectores de números reales (`Float` en nuestro caso), por lo que hay que hacer algún ajuste para poder usarlos como clasificadores. El truco más habitual, y que ya hemos comentado, es el de situar en la capa de salida tantas neuronas como clases posibles tiene el problema, asociar cada neurona con cada una de las clases, y a continuación se entrena la red para que maximice la salida de la neurona de la clase correcta. Para realizar esta tarea, la función `vectorize` toma una etiqueta, es decir, un número entero $𝑛$ entre $0$ y $9$, y produce un vector nulo de longitud $10$ salvo en su $𝑛$-ésimo elemento, que es igual a $1$ (por ejemplo, la clase $2$ produce el vector `[0,1,0,...,0]`). La razón de ampliar la etiqueta en un vector ya se ha comentado: si el número de clases es grande, las redes neuronales aprenden mucho mejor cuando cada clase corresponde a una neurona designada en la capa de salida. La clase final asignada a una entrada será la correspondiente a la neurona de salida que tenga el mayor valor de activación: ```julia function vectorize(n::Integer) local result = zeros(10) result[n+1] = 1 result end ``` ## Entrenamiento y validación en Julia A continuación, ejecutamos un ejemplo completo de creación y entrenamiento de una red neuronal que se ajustará a los datos de MNIST. Siempre que un programa utilice números aleatorios, puede ser útil durante el desarrollo y la depuración establecer la semilla del generador de números aleatorios utilizando `Random.seed!`. Esto asegura que se genere siempre la misma secuencia de números aleatorios. ```julia import Random Random.seed!(0) NN.SGD(NN.Network([NN.MNIST_n_rows * NN.MNIST_c_cols, 30, 10]), NN.training_data_x, NN.training_data_y, 100, 10, 3.0, validation_data_x = NN.test_data_x, validation_data_x = NN.test_data_y) ``` Recuerda que utilizamos el nombre del paquete `NN` como prefijo para acceder a los elementos de este módulo. Tras unos minutos, observamos que el algoritmo clasifica correctamente más del $95\%$ de los dígitos de los datos de validación, mientras que la precisión en los datos de entrenamiento es mayor. La función de coste también es menor en los datos de entrenamiento que en los de validación. Las siguientes figuras muestran las precisiones y los costes típicos para cien iteraciones de entrenamiento. ![](img/NNacc.png)![](img/NNcost.png) # Hiperparámetros y sobreajuste El algoritmo de entrenamiento contiene varios parámetros, como la tasa de aprendizaje $η$ y se puede extender con algunos más, como el parámetro $\lambda$. Los parámetros que pertenecen al algoritmo de entrenamiento se denominan **hiperparámetros** para distinguirlos de los parámetros de la propia red neuronal, como su número de capas, sus pesos y sus sesgos. Así pues, surge de forma natural la pregunta de cómo deben elegirse estos parámetros para que el resultado sea lo mejor posible. Por desgracia, no existe una respuesta general a esta pregunta. Depende mucho del problema concreto y de la tarea que deba resolver la red neuronal. Siempre que haya (hiper)parámetros desconocidos, surge la idea de optimizarlos. Además de la optimización de parámetros continuos, como la tasa de aprendizaje, también existen problemas de optimización discretos, que en este caso sería aplicables al número de capas de la red neuronal y sus tamaños, las funciones de activación utilizadas en cada capa e incluso el tipo de capa (por ejemplo, en redes neuronales convolucionales). El mecanismo más habitual para encontrar los mejores valores de los hiperparámetros es mediante un algoritmo de optimización (ya sea manual o automático) que utiliza el conjunto de **datos de validación**. Pero este conjunto de datos también es útil para algo más: cuando se entrena la red utilizando el descenso de gradiente estocástico, como en el ejemplo que acabamos de ver, la precisión del entrenamiento es generalmente mayor que la precisión observada en un conjunto de datos de validación; y un efecto similar se observa en los valores de la función de coste: la función de coste es menor en el conjunto de entrenamiento que en el conjunto de datos de validación. Estas observaciones se explican fácilmente: los datos de entrenamiento se utilizan en el descenso gradiente y, por tanto, la función de coste disminuye y la precisión aumenta en el conjunto de datos de entrenamiento porque se ha diseñado específicamente para ajustarse sobre estos datos que *el algoritmo de ajuste ve*. La situación es diferente en el conjunto de datos de validación. Por ejemplo, en el ejemplo que muestran las gráficas anteriores, la precisión deja de aumentar después de unas $15$ iteraciones, y el coste deja de disminuir después del mismo número de iteraciones. Esto significa que cualquier mejora en los datos de entrenamiento después de este número de iteraciones es muy poco probable que se traduzca en una mejora apreciable sobre nuevos datos: el algoritmo ajusta el modelo a las características específicas de los datos con que se entrena, pero que no son útiles para predecir sobre datos nuevos. !!!def: Sobreajuste Este efecto se denomina **sobreajuste**: después de este punto, el entrenamiento ajusta la red neuronal solo a las características particulares y al ruido de los datos de entrenamiento, lo que no solo es un despilfarro de recursos computacionales, sino que, lo que es aún más importante, también es perjudicial para la generalización. !!!def:Generalización La **generalización** es el objetivo último del aprendizaje automático: significa que se aprende el patrón interno del fenómeno que ha generado los datos y que se descartan las paticularidades de los datos de entrenamiento. Como las redes neuronales contienen muchos parámetros y la cantidad de datos disponibles es siempre limitada, son una herramienta muy versátil, pero al mismo tiempo los parámetros están comparativamente mal especificados. De ahí que las redes neuronales sean propensas al sobreajuste, ya que los numerosos parámetros suelen ajustarse fácilmente a los datos de entrenamiento. Por lo tanto, hay que tener mucho cuidado. Una estrategia sencilla para evitar el sobreajuste consiste en detener el entrenamiento en cuanto la precisión deja de disminuir en el conjunto de datos de validación, es lo que se llama **detención temprana**. Desgraciadamente, no es fácil saber cuándo parar, ya que el SGD es estocástico por naturaleza (así que puede variar aleatoria y puntualmente de forma no controlada) y porque la precisión de una red neuronal puede alcanzar una meseta durante el entrenamiento y luego volver a mejorar. Está claro que el sobreajuste se reduce (y la generalización mejora) utilizando más datos de entrenamiento, pero la cantidad de datos de entrenamiento suele estar dada y no puede cambiarse fácilmente. También podemos reducir el tamaño de la red neuronal para reducir el número de parámetros que hay que determinar. En general, éste es un buen enfoque, pero hay que tener cuidado de no reducirlo demasiado y no tenga capacidad para aprender el patrón de los datos. Una vez elegidos los hiperparámetros y establecido el entrenamiento evitando el sobreajuste, hay que evaluar el éxito de todo el procedimiento en un tercer conjunto de datos nunca utilizado antes. Este tercer conjunto de datos se denomina conjunto de **datos de test (prueba)** y es el árbitro de la precisión alcanzada. En resumen, !!!def: División de los Datos Es prudente dividir todos los datos disponibles en tres conjuntos: el de **entrenamiento**, el de **validación** y el de **test**. - El conjunto de **datos de entrenamiento** se utiliza para minimizar la función de coste. - El conjunto de **datos de validación** se utiliza para encontrar los hiperparámetros y para evitar el sobreajuste (por ejemplo, utilizando la detención temprana). - El conjunto de **datos de test** se utiliza para evaluar el éxito de todo el procedimiento de entrenamiento utilizando datos intactos. Hay otros métodos para intentar reducir el efecto del sobreajuste. La más habitual es la que se conoce como **regularización**. La idea es añadir una restricción a los parámetros para que sean sencillos en cierto sentido y no permitirles que puedan ser absolutamente libres (es decir, se reduce el espacio de parámetros válidos y, por tanto, su capacidad para sobreajustarse). Las siguientes figuras muestran las precisiones y los costes evaluados en los datos de entrenamiento y validación con y sin regularización del ejemplo anterior. El sobreajuste continuado en los datos de entrenamiento se ha reducido mucho con la regularización y el rendimiento en los datos de validación también ha mejorado. También muestra que el coste no aumenta en los datos de validación a medida que continúa el entrenamiento. ![](img/NNregAcc.png) ![](img/NNregCost.png) Por tanto, incluso esta primera elección de hiperparámetros tiene efectos beneficiosos.