**IAIC**
Redes Neuronales
# Introducción
 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):

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):

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)):

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))$$
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}}}$

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.

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.
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)}$$