**Redes Neuronales**
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**.
!!!side:1
Ha de tenerse en cuenta que, para simplificar la representación, en la figura no están representadas muchas de las dependencias que se dan entre capas.
 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 [1].
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 ricas y 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](https://julialang.org/) que nos permitirá plantear cómo se podría 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
!!!side:2
Ver [esta entrada](../Fundamentos_de_ML) para los detalles sobre ML que se tratarán aquí.
!!!def:Computación Neuronal
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. Constituyen los parámetros modificables del espacio de hipótesis [2].
3. **Suma Ponderada:** En cada nodo calculamos, en una primera fase de su funcionamiento, 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:** Sobre la suma ponderada se aplica una función de activación que tiene como objetivo introducir no linealidad en la red, permitiendo así que esta 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.
!!!side:3
En la animación se muestra cómo el cambio de pesos y sesgos afecta al resultado que devuelve el nodo que, como hemos indicado, es la composición del proceso de agregación y de la función de activación.
De forma individual, cada uno de los nodos (neuronas) podría representar su funcionamiento de la siguiente forma [3]:

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.
!!!warn
De esta forma, una sola capa se convierte en un tipo especial de función que transforma vectores en vectores.
!!!side:4
Para facilitar su comprensión, no se explicita la función de activación, que es posterior a la suma ponderada de valores de la capa anterior.
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 [4]:

!!!side:5
Una operación implementada paralelamente en muchos dispositivos de cálculo, como las [GPUs](https://es.wikipedia.org/wiki/GPGPU).
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 [5]:

Trabajar con varias capas es tan sencillo como componer estos productos (y las diversas funciones de activación) de forma natural:

## 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))$$
!!!side:6
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.
Una característica importante de las redes neuronales es que la función de activación no es lineal [6]. Hay dos consideraciones principales importantes para la elección de la función de activación:
1. en primer lugar, debe facilitar el cálculo de la relación funcional entre los vectores de entrada y salida que tenemos y,
2. 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.
!!!side:7
Un problema de relevancia especial para redes neuronales profundas.
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 [7].
!!!side:8
La figura adyacente muestra otras opciones habituales (haz clic sobre ella para obtener una versión mayor).
Varias opciones populares de funciones de activación son las siguientes [8]:
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}}}$
!!!side:9
Como veremos, el proceso de entrenamiento (aprendizaje) depende del producto de derivadas de capas sucesivas, por tanto, si se multiplican derivadas pequeñas, dará lugar a gradientes diminutos y, consecuentemente, a un aprendizaje excesivamente lento (que puede precisar una cantidad prohibitiva de pasos para proporcionar unos pesos útiles).
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 [9]. 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 frecuentemente 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:
!!!side:10
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])$.
* 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 [10].
!!!side:11
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$).
* 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 [11].
!!!side:12
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.
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 [12].
!!!alg: Implementación en Julia
Desde el punto de vista de la implementación en Julia, vamos a comenzar definiendo 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))
)
```
Añadimos una estructura de datos, `Network`, que 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. La función `Network` es un constructor personalizado y solo requiere los tamaños de las capas, pero toma dos argumentos clave adicionales. El constructor 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.
También podemos proporcionar una función que permita la evaluación de la red neuronal, lo que se denomina comúnmente como **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
!!!side:13
Recordemos que entendemos por *entrenar* el proceso de ajustar los pesos y sesgos para adaptarlos a los datos que tengamos o para aproximar la función que nos interese.
Antes de entrenar nuestra red neuronal [13], 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 para conseguir esta aproximación. Afortunadamente, la respuesta a esta pregunta es positiva.
!!!side:14
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.
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 $ℝ^𝑑$ [14].
!!!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}$, $𝑏 ∈ ℝ^n$, $𝑣 ∈ ℝ^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.

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}$$
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.
!!!side:15
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.
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 [15].
# 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 presentes en los datos. Las funciones que miden esta diferencia se denominan *funciones de coste*, *funciones de pérdida* o *funciones objetivo*, y existen muchas opciones.
En lo que sigue, $𝐱$ 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.
!!!def:Coste Cuadrático
Una de las funciones de coste más populares es la *función de coste cuadrática*:
$$𝐶_2(𝑊, 𝐛) ∶= \frac{1}{2|T|}\sum_{𝐱∈T} ||𝐲(𝐱) − 𝐚(𝐱)||_2^2$$
también llamado *error cuadrático medio*.
La función de coste depende 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 $\frac{1}{|T|}$ 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.
!!!def:Coste de Entropía Cruzada
Otra función de coste popular es la *función de coste de entropía cruzada*:
$$𝐶_{CE}(𝑊, 𝐛) ∶= − \frac{1}{|T|} \sum_{𝐱∈T} (𝐲(𝐱) ⋅ \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.
!!!alg: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, utilizaremos 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 ($W$) y sesgos ($b$), 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 $𝐶(𝐩)$ lo máximo posible. ¿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 del gradiente**).
* Si la dirección es $𝐞 = -∇𝐶(𝐩)$, entonces la $$\frac{\partial C}{\partial 𝐞}(𝐩)= -∇𝐶(𝐩) ⋅ ∇𝐶(𝐩) = -||∇𝐶(𝐩)||^2 ≥ 0$$ y dar un paso en esta dirección disminuye $𝐶$ (**descenso del 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.
!!!side:16: 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.
!!!alg:Implementación en Julia [16]
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
```
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
```
Obsérvese 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 explicará en la siguiente sección.
# Retropropagación
!!!side:17
Aunque se podría calcular la derivada *a mano* de la red completa, y después implementarla por medio de una función en Julia, sería una solución de poca utilidad, porque significaría que tendríamos que tener una función para cada posible arquitectura de red imaginable (además de lo pesado que resultaría ese cáculo previo). Por ello, el gran paso en la historia de éxito de las redes neuronales fue encontrar un procedimiento eficiente para realizar ese cálculo propagando las variaciones en cada peso de la red de forma dinámica de forma similar a como se propaga el cálculo de la función por *feed-forward*... aunque en sentido inverso: de la última capa a la primera.
En esta sección vamos a calcular el gradiente de una red neuronal [17]. 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:
$$\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:
$$\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 han calculado en el proceso de *feed-forward* de 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.
!!!alg: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**.
!!!side:18
Como los detectores de formas, funciones de densidad, detecciones de bordes, etc.
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 [18].
!!!side:19
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.
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 [19].

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).
!!!side:20
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.
!!!alg:Preparación de los Datos en Julia
El primer paso es leer 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 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 sus píxeles se escalan al intervalo $[0, 1]$, que es el rango adecuado en el que las funciones de activación más habituales se desenvuelve cómodamente.
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, entrenar la red para que maximice la salida de la neurona de la clase correcta.
Para realizar esta tarea, definimos una función `vectorize` que toma una etiqueta, es decir, un número entero $0\leq 𝑛\leq 9$, y produce un vector nulo de longitud $10$ salvo en su $𝑛$-ésimo elemento, que es igual a $1$ (por ejemplo, la clase $1$ produce el vector `[0,1,0,...,0]`) [20].
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
```
!!!side:21
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 y el experimento será reproducible.
!!!alg: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 [21].
```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.

# 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**.
!!!side:22
Además de los algoritmos de optimización de parámetros continuos, como la tasa de aprendizaje, también existen algoritmos 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).
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 [22].
!!!side:23
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.
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 [23].
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, 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.
!!!warn
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.
!!!side:24
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.
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** [24].
!!!side:25
Además, en la mayoría de los problemas del mundo real, conseguir más datos es un proceso caro, porque suele intervenir mano de obra humana en su generación, que es lenta y costosa.
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 [25].
!!!side:26
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.
También podemos reducir el tamaño de la red neuronal para reducir el número de parámetros que hay que determinar [26].
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 final 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 (modificando los pesos de la red).
- 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.
!!!side:27
Es decir, se reduce el espacio de parámetros válidos y, por tanto, su capacidad para sobreajustarse.
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 [27]. Veremos algo en esta dirección en la siguiente sección.
# Mejorando el entrenamiento
En esta sección analizamos dos métodos para mejorar el entrenamiento de las redes neuronales. El primero es la **regularización**, que ya hemos aplicado informalmente, pero que aún debe explicarse. El segundo es la **elección de la función de coste** y cómo afecta al entrenamiento, que veremos muy superficialmente.
## Regularización
!!!def: Regularización
La **regularización** es un método para reducir el sobreajuste. 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.
!!!side:28
En este caso, se limita el espacio de parámetros a un entorno alrededor del origen.
En la **regularización por caída de peso**, los parámetros grandes sufren una penalización para que sigan siendo sencillos, es decir, pequeños [28]. Esto tiene la ventaja añadida de evitar que los parámetros se desborden. Para aplicar la penalización, se añade a la función de coste un término de regularización, que suele ser un múltiplo de una *norma* de los pesos (una forma de medir su tamaño).
!!!def:Coste $l_2$-regularizado
Si $𝐶_0$ es la función de coste original y consideramos todos los pesos $𝐰=𝑊^{(𝑙)}_{𝑖𝑗}$ , entonces la función de coste $l_2$-regularizada es:
$$𝐶_{l_2}(𝑊, 𝐛, \lambda) = 𝐶_0(𝑊, 𝐛) + \frac{\lambda}{2|T|} ||𝐰||_2^2 = 𝐶_0(𝑊, 𝐛) + \frac{\lambda}{2|T|} \sum_{l,i,j} (𝑊^{(𝑙)}_{𝑖𝑗})^2$$
donde $\lambda ∈ ℝ^+$ se denomina **parámetro de regularización**.
El factor $1∕|T|$ se introduce ya que también forma parte de $𝐶_0$. Los sesgos no se ven afectados por la regularización como veremos a continuación.
Por supuesto, se puede utilizar cualquier otra norma para los pesos, como la $l_p$-norma, lo que resulta en la definición más general:
$$𝐶_{l_𝑝}(𝑊, 𝐛, \lambda) = 𝐶_0(𝑊, 𝐛) + \frac{\lambda}{𝑝|T|}||𝐰||_p^p = 𝐶_0(𝑊, 𝐛) + \frac{\lambda}{𝑝|T|} \sum_{l,i,j} (𝑊^{(𝑙)}_{𝑖𝑗})^p$$
Esta modificación de la función de coste significa que, en igualdad de condiciones, se prefieren pesos más pequeños. El tamaño del parámetro de regularización, $\lambda$, determina la importancia relativa de minimizar la función de coste original $𝐶_0$ y minimizar los pesos.
Pero la pregunta que nos debemos hacer es, **¿por qué la regularización reduce el sobreajuste?**
Las redes neuronales regularizadas tienden a contener pesos más pequeños, lo que implica que la salida de la red no cambia mucho cuando se añaden pequeñas perturbaciones a la entrada, en contraste con las redes neuronales no regularizadas con pesos más grandes. Esto implica que a las redes neuronales regularizadas les resulta más difícil aprender la aleatoriedad en los datos de entrenamiento. En otras palabras, los pesos más grandes de una red no regularizada pueden facilitar el ajustarse mejor al ruido y, en consecuencia, el sobreajuste.
Esta explicación también justifica porqué no se incluyen los sesgos en la regularización: los sesgos grandes no afectan a la sensibilidad de la red neuronal a las perturbaciones o al ruido, porque no dependen de los datos de entrada.
Veamos que introducir la regularización como modificación de la función de coste se aplica de forma sencilla en la retropropagación. Debido a las derivadas:
$$\frac{\partial 𝐶_{l_2}}{\partial 𝐰} =\frac{\partial 𝐶_{0}}{\partial 𝐰} + \frac{\lambda}{|T|}𝐰
\quad , \quad
\frac{\partial 𝐶_{l_2}}{\partial 𝐛} = \frac{\partial 𝐶_{0}}{\partial 𝐛}$$
los pasos del descenso de gradiente se convierten en
$$Δ𝐰 ∶= −\eta \frac{\partial 𝐶_{0}}{\partial 𝐰}− \frac{\eta\lambda}{|T|}𝐰
\quad , \quad
Δ𝐛 ∶= −\eta \frac{\partial 𝐶_{0}}{\partial 𝐛}$$
Después de añadir el paso Δ𝐰 a 𝐰, el nuevo peso es
$$(1 − \frac{\eta \lambda}{|T|}) 𝐰 − \frac{\partial 𝐶_{0}}{\partial 𝐰}$$
que en su momento introdujimos (sin explicación) al final de la función `update!`.
!!!alg
Por último, consideramos un ejemplo numérico. Se trata del mismo ejemplo anterior, pero ahora utilizamos la regularización con $\lambda = 1$.
```julia
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, 1.0,
validation_data_x = NN.text_data_x,
validation_data_x = NN.text_data_y)
```
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.
 
Por tanto, incluso esta primera elección de hiperparámetros tiene efectos beneficiosos.
## Funciones de coste y activación
Un efecto debido a la elección de la función de activación es la ralentización del aprendizaje.
Como se ve en eqn. [retro2], los errores $\delta^{(𝑙)}$ y, por lo tanto, los gradientes utilizados al minimizar la función de coste utilizando el descenso de gradiente estocástico se vuelven pequeños cuando $\sigma'(𝐳^{(𝑙)})$ se hace pequeño. Dicho factor $\sigma'(𝐳^{(𝑙)})$ se produce en cada uso recursivo de la eqn. [retro2] en retropropagación, lo que implica que las redes neuronales profundas se vuelven más difíciles de entrenar cuando los factores $\sigma'(𝐳^{(𝑙)})$ se hacen pequeños.
Por tanto, la activación leaky RELU, $\sigma_2$, por ejemplo, es ventajosa en comparación con la tangente hiperbólica, $\sigma_5$, especialmente en redes neuronales profundas.
La función de coste también puede ser una causa de ralentización del aprendizaje, de nuevo debido al factor $\sigma'(𝐳^{(𝑙)})$ en el error $\delta^{(𝐿)}$ en la eqn. [retro1]. Por lo tanto, esta interacción entre la función de coste y la función de activación en la capa de salida puede ser perjudicial para el aprendizaje.
!!!alg: Eligiendo la función de coste adecuada: un ejemplo
Expandir
Podemos remediar la situación eligiendo la función de coste $𝐶$ de forma que este factor desaparezca. En este caso, considerando las derivadas con respecto a los sesgos, nos gustaría conseguir que:
$$\frac{\partial C}{\partial 𝑏} = 𝑎 - 𝑦$$
Para simplificar la notación, eliminamos por ahora la suma de todos los elementos de entrenamiento en la función de coste $𝐶$. También denotamos un elemento $𝑏_i^{(𝐿)}$ del vector de sesgo $𝐛^{(𝐿)}$ en la capa de salida $𝐿$ por $𝑏$ (sin subíndice), el elemento $𝑎^{(𝐿)}_i$ por $𝑎$, y el elemento $𝑦_𝑖$ por $𝑦$. La regla de la cadena da como resultado:
$$\frac{\partial C}{\partial 𝑏} = \frac{\partial C}{\partial a} \sigma'(z)$$
Para la función de activación $\sigma_1$, tenemos
$\sigma_1'(𝑧) = \sigma_1(𝑧)(1 − \sigma_1(𝑧)) = 𝑎(1 − 𝑎)$. Por tanto:
$$𝑎 − 𝑦 = \frac{\partial C}{\partial 𝑏} = \frac{\partial C}{\partial a} 𝑎(1 − 𝑎)$$
lo que se traduce en la ecuación diferencial ordinaria para $\frac{\partial C}{\partial a}$:
$$\frac{\partial C}{\partial a} = \frac{𝑎 − 𝑦}{𝑎(1 − 𝑎)} = −\frac{𝑦}{𝑎} + \frac{1 − 𝑦}{1 − 𝑎}$$
que puede resolverse usando el método de descomposición en fracciones parciales para dar como resultado:
$$𝐶 = −𝑦 \ln 𝑎 − (1 − 𝑦) \ln(1 − 𝑎) + const$$
Cuando en vez de hacerse sobre un único elemento se realiza sobre todo el vector, la solución anterior es equivalente a la función de coste de entropía cruzada $𝐶_{CE}$ definida anteriormente.
Existe una interacción similar entre la función de coste cuadrática $𝐶_2$ y las llamadas neuronas lineales en la capa de salida (aquellas que usan como función de activación de la capa de salida $𝐿$ la función identidad). En este caso, $𝐚^{(𝐿)}_𝑗 = 𝐳^{(𝐿)}_𝑗$ y $\delta^{(𝐿)} = 𝐚^{(𝐿)}$, y se puede probar que no hay ningún factor perjudicial en la capa de salida $𝐿$ para esta elección combinada de función de coste y función de activación.
Estas consideraciones implican que la función de coste y las funciones de activación no deben elegirse de forma independiente. Merece la pena estudiar sus interacciones para llegar a algoritmos de entrenamiento más eficaces.
(insert ../menu.md.html here)