10. Redes Neuronales y Deep Learning#
10.1. Gradiente descendente#
El método de gradiente descendente es uno de los mas ampliamente usados para la minimización iterativa de una función de costo diferenciable, \(J(\boldsymbol{\theta}),~\boldsymbol{\theta}\in\mathbb{R}^{l}\). Como cualquier otra técnica iterativa, el método parte de una estimación inicial, \(\boldsymbol{\theta}^{(0)}\), y genera una sucesión \(\boldsymbol{\theta}^{(i)},~i=1,2,\dots,\) tal que:
\[ \boldsymbol{\theta}^{(i)}=\boldsymbol{\theta}^{(i-1)}+\mu_{i}\Delta\boldsymbol{\theta}^{(i)},~ i >0,~\mu_{i}>0. \]La diferencia entre cada método radica en la forma que \(\mu_{i}\) y \(\Delta\boldsymbol{\theta}^{(i)}\) son seleccionados. \(\Delta\boldsymbol{\theta}^{(i)}\) es conocido como la dirección de actualización o de búsqueda. La sucesión \(\mu_{i}\) es conocida como el tamaño o longitud de paso en la \(i\)-ésima iteración, estos valores pueden ser constantes o cambiar. En el método de gradiente descendente, la selección de \(\Delta\boldsymbol{\theta}^{(i)}\) es realizada para garantizar que \(J(\boldsymbol{\theta}^{(i)})<J(\boldsymbol{\theta}^{(i-1)})\), excepto en el minimizador \(\boldsymbol{\theta}_{\star}\).

Fig. 10.1 Función de coste en el espacio de parámetros bidimensional.#
Suponga que en la iteración \(i-1\) el valor \(\boldsymbol{\theta}^{(i-1)}\) ha sido obtenido
Nótese que seleccionando la dirección tal que \(\nabla^{T}J(\boldsymbol{\theta}^{(i-1)})\Delta\boldsymbol{\theta}^{(i)}<0\), garantizará que \(J(\boldsymbol{\theta}^{(i-1)}+\mu_{i}\Delta\boldsymbol{\theta}^{(i)})<J(\boldsymbol{\theta}^{(i-1)})\). Tal selección de \(\Delta\boldsymbol{\theta}^{(i)}\) y \(\nabla J(\boldsymbol{\theta}^{(i-1)})\) debe formar un ángulo obtuso. Las curvas de nivel asociadas a \(J(\boldsymbol{\theta})\) pueden tomar cualquier forma, la cual va a depender de como está definido \(J(\boldsymbol{\theta})\).
\(J(\boldsymbol{\theta})\) se supone diferenciable, por lo tanto, las curvas de nivel o contornos deben ser suaves y aceptar un plano tangente en cualquier punto. Además, de los cursos de cálculo sabemos que el vector gradiente \(\nabla J(\boldsymbol{\theta})\) es perpendicular al plano tangente (recta tangente) a la correspondiente curva de nivel en el punto \(\boldsymbol{\theta}\). Nótese que seleccionando la dirección de búsqueda \(\Delta\boldsymbol{\theta}^{(i)}\) que forma un angulo obtuso con el gradiente, se coloca a \(\boldsymbol{\theta}^{(i-1)}+\mu_{i}\Delta\boldsymbol{\theta}^{(i)}\) en un punto sobre el contorno el cual corresponde a un valor menor que \(J(\boldsymbol{\theta})\).
Dos problemas surgen ahora:
Escoger la mejor dirección de búsqueda
Calcular que tan lejos es aceptable un movimiento a traves de esta dirección.

Fig. 10.2 El vector gradiente en un punto \(\boldsymbol{\theta}\) es perpendicular al plano tangente (línea punteada) en la curva de nivel que cruza \(\boldsymbol{\theta}\). La dirección de descenso forma un ángulo obtuso, \(\phi\), con el vector gradiente.#
Nótese que si \(\mu_{i}\|\Delta\boldsymbol{\theta}^{(i)}\|\) es demasiado grande, entonces el nuevo punto puede ser colocado en un contorno correspondiente a un valor mayor al del actual contorno.

Fig. 10.3 Las correspondientes curvas de nivel para la función de coste, en el plano bidimensional. Nótese que a medida que nos alejamos del valor óptimo, \(\boldsymbol{\theta}_{\star}\), los valores de \(c\) aumentan.#
Para abordar (1), supongamos que \(\mu_{i}=1\) y buscamos todos los vectores \(\boldsymbol{z}\) con norma Euclidiana unitaria, con inicio (cola) en \(\boldsymbol{\theta}^{(i-1)}\). Entonces, para todas las posibles direcciones, la que entrega el valor más negativo del producto interno, \(\nabla^{T}J(\boldsymbol{\theta}^{(i-1)})z\), es aquella de gradiente negativo
Centrando \(\boldsymbol{\theta}^{(i-1)}\) en la bola con norma Euclideana uno. De todos los vectores con norma unitaria y origen en \(\boldsymbol{\theta}^{(i-1)}\), seleccionamos aquel que apunta en la dirección negativa del gradiente. Por lo tanto, para todos los vectores con norma Euclidiana 1, la dirección de descenso mas pronunciada coincide con la dirección del gradiente descendente, negativo, y la correspondiente actualización recursiva se convierte en

Fig. 10.4 Representación del gradiente negativo el cual conduce a la máxima disminución de la función de coste.#
La selección de \(\mu_{i}\) debe ser realizada de tal forma que garantice convergencia de la secuencia de minimización. Nótese que el algoritmo puede oscilar en torno al mínimo sin converger, si no seleccionamos la dirección correcta. La selección de \(\mu_{i}\) dependerá de la convergencia a cero del error entre \(\boldsymbol{\theta}^{(i)}\) y el mínimo real en forma de serie geométrica.
Ejercicio
Por ejemplo, para el caso de la función de coste del error cuadrático medio, la longitud de paso está dada por: \(0<\mu<2/\lambda_{\max}\), donde \(\lambda_{\max}\) el máximo eigenvalor de la matriz de covarianza \(\Sigma_{x}=\mathbb{E}[\boldsymbol{x}\boldsymbol{x}^{T}]\), donde \(J(\boldsymbol{\theta})=E[(y-\boldsymbol{\theta}^{T}\boldsymbol{x})^{2}]\) (ver Sección 5.3 [Theodoridis, 2020]).
10.2. Redes neuronales#
Introducción
Las redes neuronales son sistemas de aprendizaje compuestos por neuronas conectadas en capas que ajustan sus conexiones para aprender. Tras un período de 25 años desde su inicio, las redes neuronales se convirtieron en la norma en el aprendizaje automático. En un principio, dominaron durante una década, pero luego fueron superadas por máquinas de vectores de soporte.
Sin embargo, desde 2010, las redes neuronales profundas se han vuelto populares gracias a mejoras en la tecnología y la disponibilidad de grandes conjuntos de datos, impulsando el campo del aprendizaje automático.
10.3. El perceptrón#
Nuestro punto de partida será considerar el problema simple de una tarea de clasificación conformada por dos clases linealmente separables. En otras palabras, dado un conjunto de muestras de entrenamiento, \((y_{n}, \boldsymbol{x}_{n})\), \(n=1,2,\dots,N\), con \(y_{n}\in\{-1,+1\},~\boldsymbol{x}_{n}\in\mathbb{R}^{l}\), suponemos que existe un hiperplano
\[ \boldsymbol{\theta}_{\star}^{T}\boldsymbol{x}=0, \]tal que,
\[\begin{split} \begin{cases} \boldsymbol{\theta}_{\star}^{T}\boldsymbol{x}&>0,\quad\text{si}\quad\boldsymbol{x}\in\omega_{1}\\ \boldsymbol{\theta}_{\star}^{T}\boldsymbol{x}&<0,\quad\text{si}\quad\boldsymbol{x}\in\omega_{2} \end{cases} \end{split}\]En otras palabras, dicho hiperplano clasifica correctamente todos los puntos del conjunto de entrenamiento. Para simplificar, el término de sesgo del hiperplano ha sido absorbido en \(\boldsymbol{\theta}_{\star}\) después de extender la dimensionalidad del espacio de entrada en uno. El objetivo ahora es desarrollar un algoritmo que calcule iterativamente un hiperplano que clasifique correctamente todos los patrones de ambas clases. Para ello, se adopta una función de costo.
Sea \(\boldsymbol{\theta}\) la estimación del vector de parámetros desconocidos, disponible en la actual iteración. Entonces hay dos posibilidades. La primera es que todos los puntos estén clasificados correctamente; esto significa que se ha obtenido una solución. La otra alternativa es que \(\boldsymbol{\theta}\) clasifique correctamente algunos de los puntos y el resto estén mal clasificados.
Costo perceptrón
Sea \(\mathcal{Y}\) el conjunto de todas las muestras mal clasificadas. La función de costo, perceptrón
se define como
donde
Nótese que la función la función de costo es no negativa. En efecto, dado que la suma es sobre los puntos mal clasificados, si \(\boldsymbol{x}_{n}\in\omega_{1}~(\omega_{2}),~\) entonces \(\boldsymbol{\theta}^{T}\boldsymbol{x}_{n}\leq (\geq)~0\), entregando así un producto \(-y_{n}\boldsymbol{\theta}^{T}\boldsymbol{x}_{n}\geq0\).
La función de costo es cero, si no existen puntos mal clasificados, esto es, \(\mathcal{Y}=\emptyset\). La función de costo perceptrón no es diferenciable en todos los puntos, es lineal por tramos. Si reescribimos \(J(\boldsymbol{\theta})\) en una forma ligeramente diferente:
Nótese que esta es una función lineal con respeto a \(\boldsymbol{\theta}\), siempre que el conjunto de puntos mal clasificados permanezca igual. Además, nótese que ligeros cambios del valor \(\boldsymbol{\theta}\) corresponden a cambios de posición del respectivo hiperplano. Como consecuencia, existirá un punto donde el número de muestras mal clasificadas en \(\mathcal{Y}\), repentinamente cambia; este es el tiempo donde una muestra en el conjunto de entrenamiento cambia su posición relativa con respecto a el hiperplano en movimiento, y en consecuencia, el conjunto \(\mathcal{Y}\) es modificado. Después de este cambio, el conjunto, \(J(\boldsymbol{\theta})\), corresponderá a una nueva función lineal.
El algoritmo perceptrón
A partir del método de subgradientes se puede verificar fácilmente que, iniciando desde un punto arbitrario, \(\boldsymbol{\theta}^{(0)}\), el siguiente método iterativo,
converge después de un número finito de pasos. La sucesión de parámetros \(\mu_{i}\) es seleccionada adecuadamente para garantizar convergencia.
Nótese que usando el método de subgradiente (ver apéndice) o gradiente descendente se tiene que
Otra versión del algoritmo considera una muestra por iteración en un esquema cíclico, hasta que el algoritmo converge. Denotemos por \(y_{(i)}\), \(\boldsymbol{x}_{i},~i\in\{1,2,\dots,N\}\) los pares de entrenamiento presentados al algoritmo en la iteración \(i\)-ésima. Entonces, la iteración de actualización se convierte en:
Esto es, partiendo de una estimación inicial de forma random, inicializando \(\boldsymbol{\theta}^{(0)}\) con algunos valores pequeños, testeamos cada una de las muestras, \(\boldsymbol{x}_{n},~n=1,2,\dots,N\). Cada vez que una muestra es mal clasificada, se toma acción por medio de la regla perceptrón para una corrección. En otro caso, ninguna acción es requerida.
Una vez que todas las muestras han sido consideradas, decimos que una
época (epoch)
ha sido completada. Si no se obtiene convergencia, todas las muestras son reconsideradas en una segunda época, y así sucesivamente. La versión de este algoritmo es conocida como esquemapattern-by-pattern
. Algunas veces también es referido como el algoritmo online. Nótese que el número total de datos muestrales es fijo, y que el algoritmo las considera en forma cíclica, época por época (epoch-by-epoch).
Observación
Después de un número finito de épocas, se garantiza que el algoritmo es convergente (convexidad de \(J\) y clases linealmente separables). Nótese que para obtener dicha convergencia, la sucesión \(\mu_{i}\) debe ser seleccionada apropiadamente. Sin embargo, para el caso del algoritmo perceptrón, la convergencia es garantizada, aun cuando \(\mu_{i}\) es una constante positiva, \(\mu_{i}=\mu>0\), usualmente tomado igual a uno.
La formulación en (10.1) es conocida también como la filosofía de aprendizaje
reward-punishment
. Si la actual estimación es exitosa en la predicción de la clase del respectivo patrón, ninguna acción es tomada (reward), en otro caso, el algoritmo es obligado a realizar una actualización (punishment).

Fig. 10.5 El punto \(x\) está mal clasificado por la línea roja. La regla perceptrón gira el hiperplano hacia el punto \(x\), para intentar incluirlo en el lado correcto del nuevo hiperplano y clasificarlo correctamente.#
La Fig. 10.5 ofrece una interpretación geométrica de la regla del perceptrón. Supongamos que la muestra \(\boldsymbol{x}\) está mal clasificada por el hiperplano, \(\boldsymbol{\theta}^{(i-1)}\). Como sabemos, por geometría analítica, \(\boldsymbol{\theta}^{(i-1)}\) corresponde a un vector que es perpendicular al hiperplano que está definido por este vector. Como \(\boldsymbol{x}\) se encuentra en el lado \((-)\) del hiperplano y está mal clasificado, pertenece a la clase \(\omega_{1}\); asumiendo \(\mu = 1\), la corrección aplicada por el algoritmo es
\[ \boldsymbol{\theta}^{(i)}=\boldsymbol{\theta}^{(i-1)}+\boldsymbol{x}, \]y su efecto es girar el hiperplano en dirección a \(\boldsymbol{x}\) para colocarlo en el lado \((+)\) del nuevo hiperplano, que está definido por la estimación actualizada \(\boldsymbol{\theta^{(i)}}\). El algoritmo perceptrón en su modo de funcionamiento patrón por patrón (pattern-by-pattern) se resume en el siguiente algoritmo:
Algorithm 10.1 (Algoritmo perceptrón pattern-by-pattern)
Inicialización
Inicializar \(\boldsymbol{\theta}^{(0)}\); usualmente, de forma random, pequeño
Seleccionar \(\mu\); usualmente establecido como uno
\(i=1\)
Repeat Cada iteración corresponde a un epoch
counter = 0; Contador del número de actualizaciones por epoch
For \(n=1,2,\dots,N\) Do Para cada epoch, todas las muestras son presentadas una vez
If(\(y_{n}\boldsymbol{x}_{n}^{T}\leq0\)) Then
\(\boldsymbol{\theta}^{(i)}=\boldsymbol{\theta}^{(i-1)}+\mu y_{n}\boldsymbol{x}_{n}\)
\(i=i+1\)
counter = counter + 1
End For
Until counter = 0
Una vez que el algoritmo perceptrón se ha ejecutado y converge, tenemos los pesos, \(\theta_{i},~i = 1,2,\dots,l\), de las sinapsis de la neurona/perceptrón asociada, así como el término de sesgo \(\theta_{0}\). Ahora se pueden utilizar para clasificar patrones desconocidos. Las características \(x_{i}, i = 1, 2,\dots,l\), se aplican a los nodos de entrada. A su vez, cada característica se multiplica por la sinapsis respectiva (peso), y luego se añade el término de sesgo en su combinación lineal.
El resultado de esta operación pasa por una función no lineal, \(f\), conocida como función de activación (ver Activation function). Dependiendo de la forma de la no linealidad, se producen diferentes tipos de neuronas. La mas clásica conocida como neurona McCulloch-Pitts, la función de activación es la de Heaviside, es decir,

Fig. 10.6 Arquitectura básica de neuronas/perceptrones.#
En la arquitectura básica de neuronas/perceptrones, las características de entrada se aplican a los nodos de entrada y se ponderan por los respectivos pesos que definen las sinapsis. A continuación se añade el término de sesgo en su combinación lineal y el resultado es empujado a través de la no linealidad. En la neurona McCulloch-Pitts, la salida es 1 para los patrones de la clase \(\omega_{1}\) o 0 para la clase \(\omega_{2}\). La suma y la operación no lineal se unen para simplificar el gráfico.
Para las capas ocultas (
input layer
), la función de activación tangente hiperbólica suele funcionar mejor que la sigmoidea logística. Tanto la funciónsigmoid
comotanh
pueden hacer que el modelo sea mássusceptible a los problemas durante el entrenamiento, a través del llamado problema de los gradientes desvanecientes
. Los modelos modernos de redes neuronales con arquitecturas comunes, comoMLP
yCNN
, harán uso de la función de activaciónReLU
, o extensiones.Las redes recurrentes suelen utilizar funciones de activación
tanh
osigmoid
, o incluso ambas. Por ejemplo, la LSTM suele utilizar la activaciónsigmoid
para las conexiones recurrentes y la activacióntanh
para la salida.

Fig. 10.8 Selección de función de activación para hidden layers. (Fuente [Brownlee and Mastery, 2017]).#
Si su problema es de regresión, debería utilizar una función de activación lineal en el
output layer
. Si su problema es de clasificación, el modelo predice la probabilidad de pertenencia a una clase, que se puede convertir en una etiqueta de clase mediante redondeo (para sigmoid) oargmax
(para softmax). Usualmente,softmax
es usada en clasificación múltiple cuando las clases son mutuamente excluyentes en caso contrario puede usar la función de activaciónsigmoid
para cada output.
10.4. Redes Neuronales Multicapa Feed-Forward#
Una sola neurona está asociada a un hiperplano
\[ H: \theta_{1}x_{1}+\theta_{2}x_{2}+\cdots+\theta_{l}x_{l}+\theta_{0}=0, \]en el espacio de entrada, y la clasificación se basa en una función no lineal que produce un resultado de uno o cero según en qué lado del hiperplano \(H\) se encuentre un punto. A continuación, se mostrará cómo combinar varias neuronas en capas para crear clasificadores no lineales. Seguiremos un enfoque constructivo simple que será útil al abordar aspectos de las redes neuronales, especialmente en el contexto de arquitecturas profundas de aprendizaje profundo (deep learning).
Como punto de partida, consideramos el caso en el que las clases del espacio de características están formadas por uniones de regiones poliédricas

Fig. 10.9 Clases formadas por uniones de regiones poliédricas. Las regiones se etiquetan según el lado en el que se encuentran, con respecto a las tres líneas, \(H_{1}, H_{2}\) y \(H_{3}\).#
Las regiones poliédricas se forman como intersecciones de semiespacios, cada uno de ellos asociado a un hiperplano. En la Fig. 10.9, hay tres hiperplanos (líneas rectas en \(\mathbb{R}^2\)), indicados como \(H_{1}, H_{2}, H_{3}\), que dan lugar a siete regiones poliédricas. Para cada hiperplano se indican los lados \((+)\) y \((-)\) (semiespacios).
Cada una de las regiones se etiqueta con un triplete de números binarios, según el lado que se encuentra con respecto a \(H_{1}, H_{2}, H_{3}\). Por ejemplo, la región etiquetada como \((101)\) se encuentra en el lado \((+)\) de \(H_{1}\), el lado \((-)\) de \(H_{2}\) y el lado \((+)\) de \(H_{3}\).

Fig. 10.10 (A) Las neuronas de la primera capa oculta son activadas por los valores de las características aplicadas en los nodos de entrada y forman las regiones poliédricas. (B) Las neuronas de la segunda capa tienen como entradas las salidas de la primera capa, y así forman las clases.#
La Fig. 10.10 muestra tres neuronas, correspondientes a los tres hiperplanos, \(H_{1}, H_{2}\) y \(H_{3}\), de la Fig. 10.9, respectivamente. Las salidas asociadas, denotadas como \(y_{1}, y_{2}\), y \(y_{3}\), forman la etiqueta de la región a la que el patrón de entrada correspondiente pertenece. De hecho, si los pesos de las sinapsis se han fijado adecuadamente, entonces, si un patrón se origina en la región, digamos, \((010)\), la primera neurona de la izquierda asigna un cero \((y_{1} = 0)\), la del medio un uno \((y_{2} = 1)\), y la de la derecha un cero \((y_{3} = 0)\).
En otras palabras, combinando las tres neuronas, hemos logrado un mapeo del espacio de características de entrada en el espacio tridimensional. Más concretamente, el mapeo se realiza en los vértices del cubo unitario en \(\mathbb{R}^{3}\), como se muestra en la Fig. 10.11

Fig. 10.11 Las neuronas de la primera capa oculta realizan un mapeo del espacio de características de entrada a los vértices de un hipercubo unitario. El vértice 110, denotado como un círculo sin sombrear, no corresponde a ninguna región.#
Ahora utilizaremos esta nueva representación, proporcionada por las salidas de las neuronas de la primera capa oculta, como entrada que alimenta las neuronas de una segunda capa oculta, la cual se construye de la siguiente forma. Elegimos todas las regiones que pertenecen a una clase. Para nuestro ejemplo de la Fig. 10.11, seleccionamos las dos regiones que corresponden a la clase \(\omega_{1}\), es decir, \((000)\) y \((111)\). Recordemos que todos los puntos de estas regiones se mapean a los respectivos vértices del cubo unitario en \(\mathbb{R}^{3}\). Sin embargo, en este nuevo espacio transformado, cada uno de los vértices es linealmente separable del resto.
Observe que la salida \(z_{1}\) de la neurona izquierda arroja un 1 sólo si el patrón de entrada se origina en la región \(000\) y 0 para todos los demás patrones. Para la neurona de la derecha, la salida \(z_{2}\) será 1 para todos los patrones procedentes de la región \((111)\) y cero para el resto (ver Fig. 10.11). Nótese que esta segunda capa de neuronas ha realizado un segundo mapeo, esta vez a los vértices del rectángulo unitario en \(\mathbb{R}^{2}\).

Fig. 10.12 Los patrones de la clase \(\omega_{1}\) se asignan a (01) o a (10) y los patrones de la clase \(\omega_{2}\) se asignan a (00). Clases ahora linealmente separables a través de una línea recta realizada por una neurona.#
Este mapeo proporciona una nueva representación de los patrones de entrada, y esta representación codifica la información relacionada con las clases de las regiones. La Fig. 10.12 muestra el mapeo a los vértices del rectángulo unitario en el espacio \((z_{1}, z_{2})\). Nótese que todos los puntos procedentes de la clase \(\omega_{2}\) están mapeados a \((00)\) y los puntos de la clase \(\omega_{1}\) están asignados a \((10)\) o a \((01)\). Esto es, mediante mapeos sucesivos, hemos transformado nuestra tarea, originalmente, linealmente no separable a una que es linealmente separable.
En efecto, el punto \((00)\) puede separarse linealmente de \((01)\) y \((10)\), y esto puede realizarse mediante una neurona adicional que opera en el espacio \((z_{1}, z_{2})\); la cual se conoce como la neurona de salida, porque proporciona la decisión final de clasificación.

Fig. 10.13 Red neuronal feed-forward de tres capas. Comprende la capa de entrada (no de procesamiento), dos capas ocultas y una capa de salida de neuronas#
La red final resultante se muestra en la Fig. 10.13. Llamamos a esta red feed-forward, porque la información fluye hacia adelante desde la capa de entrada a la de salida. Se compone de la capa de entrada, que es una capa no procesadora, dos capas ocultas, y una capa de salida. Llamamos a esta red neuronal, red de tres capas, sin contar la capa de entrada de nodos no procesadores. Esta red neuronal de tres capas puede resolver cualquier tarea de clasificación, en la que las clases están formadas por uniones de regiones poliédricas.
Considerando esta estructura de la red multicapa. Nuestro interés ahora se centrará en buscar formas de estimar los pesos desconocidos de las sinapsis y los sesgos de las neuronas. Sin embargo, desde un punto de vista conceptual, debemos recordar que cada capa realiza un mapeo en un nuevo espacio, y cada mapeo proporciona una representación diferente de los datos de entrada, hasta la última capa, donde la tarea se ha transformado en una que es fácil de resolver.
10.5. Redes Totalmente Conectadas#
Para resumir de manera más formal el tipo de operaciones que tienen lugar en una red totalmente conectada, centrémonos en, por ejemplo, la capa \(r\) de una red neuronal multicapa y supongamos que está formada por \(k_{r}\) neuronas. El vector de entrada a esta capa está formado por las salidas de los nodos de la capa anterior, que se denomina \(\boldsymbol{y}^{r-1}\).
Sea \(\boldsymbol{\theta}_{j}^{r}\) el vector de los pesos sinápticos, incluido el término de sesgo, asociado a la neurona \(j\) de la capa \(r\), donde \(j = 1,2,\dots, k_{r}\). La dimensión respectiva de este vector es \(k_{r-1} + 1\), donde \(k_{r-1}\) es el número de neuronas de la capa anterior, \(r-1\), y el aumento en 1 representa el término de sesgo. Entonces las operaciones realizadas, antes de la no linealidad, son los productos internos
Colocando todos los valores de salida en un vector \(\boldsymbol{z}^{r}=[z_{1}^{r}, z_{2}^{r},\dots,z_{k_{r}}^{r}]^{T}\), y agrupando todos los vectores sinápticos como filas, una debajo de la otra, en una matriz, podemos escribir colectivamente
El vector de las salidas de la \(r\) th capa oculta, después de empujar cada \(z_{i}^{r}\) a través de la no linealidad \(f\), está finalmente dado por
La siguiente figura describe como es creada la \(j\)-ésima capa de la red neuronal full conectada

Fig. 10.14 \(j\)-ésimo elemento de la capa \(r\) de la red totalmente conectada.#
Observación
La notación anterior significa que \(f\) actúa sobre cada uno de los respectivos componentes del vector, individualmente, y la extensión del vector en uno es para dar cuenta de los términos de sesgo en la práctica estándar. Para redes grandes, con muchas capas y muchos nodos por capa, este tipo de conectividad resulta ser muy costoso en términos del número de parámetros (pesos), que es del orden de \(k_{r}k_{r-1}\).
Por ejemplo, si \(k_{r-1} = 1000\) y \(k_{r} = 1000\), esto equivale a un orden de 1 millón de parámetros. Tenga en cuenta que este número es la contribución de los parámetros de una sola de las capas. Sin embargo, un gran número de parámetros hace que una red sea vulnerable al sobreajuste, cuando se trata de entrenamiento
Se pueden emplear las llamadas técnicas de reparto de pesos, en las que un conjunto de parámetros es compartido entre un número de conexiones, a través de restricciones adecuadamente incorporadas. Las redes neuronales recurrentes y convolucionales pertenecen a esta familia de redes de peso compartido. En una red convolucional, las convoluciones sustituyen a las operaciones de producto interno, lo que permite un reparto de pesos importante que conduce a una reducción sustancial del número de parámetros.
10.6. El Algoritmo De Backpropagation#
Una red neuronal considera una función paramétrica no lineal, \(\hat{y} = f_{\boldsymbol{\theta}}(\boldsymbol{x})\), donde \(\boldsymbol{\theta}\) representa todos los pesos/sesgo presentes en la red. Por lo tanto, el entrenamiento de una red neuronal no parece ser diferente del entrenamiento de cualquier otro modelo de predicción paramétrica.
Todo lo que se necesita es (a) un conjunto de muestras de entrenamiento, (b) una función de pérdida \(\mathcal{L}(y, \hat{y})\), y (c) un esquema iterativo, por ejemplo, el gradiente descendente, para realizar la optimización de la función de coste asociada (pérdida empírica).
La dificultad del entrenamiento de las redes neuronales radica en su estructura multicapa que complica el cálculo de los gradientes, que intervienen en la optimización. Además, la neurona McCulloch-Pitts se basa en la función de activación discontinua Heaviside no diferenciable.
Neurona sigmoidea logística
: Una posibilidad es adoptar la función sigmoidea logística, es decir,
Nótese que cuanto mayor sea el valor del parámetro \(a\), la gráfica correspondiente se acerca más a la de la función de Heaviside (ver Fig. 10.15).

Fig. 10.15 Función sigmoidea logística
para diferentes valores del parámetro \(a\).#
Otra posibilidad sería utilizar la función,
\[ f(z)=a\tanh\left(\frac{cz}{2}\right), \]donde \(c\) y \(a\) son parámetros de control. El gráfico de esta función se muestra en la Fig. 10.16. Nótese que a diferencia de la sigmoidea logística, esta es una función no simétrica, es decir, \(f(-z)=-f(z)\). Ambas son también conocidas como funciones de reducción, porque limitan la salida a un rango finito de valores.

Fig. 10.16 Función de reducción de la tangente hiperbólica para \(a = 1.7\) y \(c = 4/3\).#
Recordemos que la regla de actualización del algoritmo gradiente descendente, en su versión unidimensional se convierte en
\[ \theta(new)=\theta(old)-\mu\left.\frac{d J}{d\theta}\right|_{\theta(old)}, \]y las iteraciones parten de un punto inicial arbitrario, \(\theta^{(0)}\).
Si en la iteración actual el algoritmo está digamos, en el punto \(\theta(old) = \theta_{1}\), entonces se moverá hacia el mínimo local, \(\theta_{l}\). Esto se debe a que la derivada del coste en \(\theta_{1}\) es igual a la tangente \(\phi_{1}\) (ver Fig. 10.17), que es negativa (el ángulo es obtuso) y la actualización, \(\theta(new)\), se moverá a la derecha, hacia el mínimo local, \(\theta_{l}\).

Fig. 10.17 Función no convexa global, con mínimos locales y puntos de silla.#
Observación
La elección del tamaño del paso, \(\mu\), es crítica para la convergencia del algoritmo. En problemas reales en espacios multidimensionales, el número de mínimos locales puede ser grande, por lo que el algoritmo puede converger a uno local. Sin embargo, esto no es necesariamente una mala noticia. Si este mínimo local es lo suficientemente profundo, es decir, si el valor de la función de coste en este punto, por ejemplo, \(J(\theta_{l})\), no es mucho mayor que el alcanzado en el mínimo global, es decir, \(J(\theta_{g})\), la convergencia a dicho mínimo local puede corresponder a una buena solución.
Una opción para evitar caer en mínimos locales es utilizar un método de optimización diferente, por ejemplo, Adam, RMSprop o AdaGrad que pueden adaptarse mejor a la topografía del error y ayudar a escapar de los mínimos locales. Explorar otras funciones de coste también es otra opción, o utilizar diferentes métodos de regularización (\(L1, L2\)), dado que, si el modelo es muy complejo, puede ser más propenso a caer en mínimos locales.
10.7. El Esquema De Backpropagation Para Gradiente Descendente#
Habiendo adoptado una función de activación diferenciable, estamos listos para proceder a desarrollar el esquema iterativo de gradiente descendente para la minimización de la función de coste
. Formularemos la tarea en un marco general.
Sea \((\boldsymbol{y}_{n}, \boldsymbol{x}_{n}), n = 1, 2,\dots, N\), el conjunto de muestras de entrenamiento. Nótese que hemos asumido múltiples variables output, como vectores. Suponemos que la red consta de \(L\) capas, \(L-1\) capas ocultas y una capa de salida. Cada capa consta de \(k_{r}, r = 1, 2,\dots, L\), neuronas. Así, los vectores de salida (objetivo/deseado) son
Para ciertas derivaciones matemáticas, también denotamos el número de nodos de entrada como \(k_{0}\); es decir, \(k_{0} = l\), donde \(l\) es la dimensionalidad del espacio de características de entrada.
Sea \(\boldsymbol{\theta}_{j}^{r}\) el vector de los pesos sinápticos asociados a la \(j\)-th neurona de la \(r\)-th capa, con \(j = 1, 2,\dots, k_{r}\) y \(r = 1, 2,\dots,L\), donde el término de sesgo se incluye en \(\boldsymbol{\theta}_{j}^{r}\), es decir,
Los pesos sinápticos en la capa \(r\) enlazan la neurona respectiva con todas las neuronas de la capa \(k_{r-1}\) (véase la Fig. 10.18). El paso iterativo básico para el esquema de gradiente descendente se escribe como
El parámetro \(\mu\) es el tamaño de paso definido por el usuario (también puede depender de la iteración) y \(J\) denota la función de coste.

Fig. 10.18 Enlaces y las variables asociadas de la \(j\) th neurona en la \(r\) th capa. \(y_{k}^{r-1}\) es la salida de la \(k\) th neurona de la \((r - 1)\) th capa y \(\theta_{jk}^{r}\) es el peso respectivo que conecta estas dos neuronas.#
Las ecuaciones de actualización (10.3) comprenden el par del esquema de gradiente descendente para la optimización. Como se ha dicho anteriormente, la dificultad de las redes neuronales feed-forward surge de su estructura multicapa. Para calcular los gradientes en la Ecuación (10.3), para todas las neuronas, en todas las capas, se deben seguir dos pasos en su cálculo
Forward computations
: Para un vector de entrada dado \(\boldsymbol{x}_{n}, n = 1, 2,\dots, N\), se utilizan las estimaciones actuales de los parámetros (pesos sinápticos) (\(\boldsymbol{\theta}_{j}^{r}(old)\)) y calcula todas las salidas de todas las neuronas en todas las capas, denotadas como \(y_{nj}^{r}\); en la Fig. 10.18, se ha suprimido el índice \(n\) para no afectar la notación.Backward computations
: Utilizando las salidas neuronales calculadas anteriormente junto con los valores objetivos conocidos, \(y_{nk}\), de la capa de salida, se calculan los gradientes de la función de coste. Esto implica \(L\) pasos, es decir, tantos como el número de capas. La secuencia de los pasos algorítmicos se indica a continuación:Calcular el gradiente de la función de coste con respecto a los parámetros de las neuronas de la última capa, es decir, \(\partial J/\partial\boldsymbol{\theta}_{j}^{L}, j = 1, 2,\dots, k_{L}\).
For \(r = L-1\) to \(1\), Do
Calcular los gradientes con respecto a los parámetros asociados a las neuronas de la \(r\) th capa, es decir, \(\partial J/\partial\boldsymbol{\theta}_{k}^{r}, k= 1, 2,\dots, k_{r}\) basado en todos los gradientes \(\partial J/\partial\boldsymbol{\theta}_{j}^{r+1}, j= 1, 2,\dots, k_{r+1}\), con respecto a los parámetros de la capa \(r + 1\) que se han calculado en el paso anterior.
End For
Ejemplo
Analicemos un ejemplo concreto de forward computations, seguido de un enfoque backward computations. El ejemplo de entrenamiento será (\(x=2.1\), \(y=4\)), el peso inicial será \(w:=\theta_{1}=1\), el sesgo será \(b:=\theta_{0}=0\) y \(\textsf{Loss}=L\). \(\hat{y}\) representa la salida de la neurona y el valor predicho, mientras que la pérdida se refiere a la pérdida por error cuadrático

Fig. 10.19 Ejemplo de red neuronal para backpropagation
#
Forward computaion
:

Backward computation
: Ahora podemos retroceder (backpropagation
) y calcular las derivadas parciales aplicando la regla de la cadena para obtener
Luego, actualizamos los parámetros en la dirección opuesta al gradiente para disminuir la pérdida, con una tasa de aprendizaje de \(\textsf{lr}:=\mu=0.01\)
Para evaluar el rendimiento de nuestros parámetros ajustados, realizaremos una pasada adicional
La pérdida total disminuyó de 3.61 a 2.87, lo que representa una reducción de 0.74. ¡La pérdida ha bajado!
10.8. Entrenamiento de MLP#
Observación
Los pesos de una red neuronal se ajustan mediante algoritmos de optimización basados en gradiente, como el gradiente descendente estocástico, que minimiza iterativamente la función de pérdida \(L\). Para tareas de regresión, se emplean el error cuadrático medio (MSE) y el error absoluto medio (MAE); para clasificación, se utilizan pérdidas logarítmicas binarias o categóricas.
El algoritmo backpropagation se comprende a través de grafos computacionales, útiles para calcular en redes neuronales. En una red simple con una capa oculta de dos neuronas con activación sigmoidea y salida lineal, alimentada por dos entradas \([y_1, y_2]\), los pesos se distribuyen en los bordes de la red.
La Fig. 10.21 ilustra este proceso mediante un grafo computacional para la entrada \([-1, 2]\), generando salidas intermedias \(p_i\). En particular, \(p_7\) y \(p_8\) corresponden a las salidas de las neuronas ocultas \(g_1\) y \(g_2\). Durante este paso también se calcula la función de pérdida \(L\) usada en el entrenamiento.
En este punto, se aplica el algoritmo de backpropagation para calcular las derivadas parciales entre dos nodos conectados por una arista. El recorrido hacia atrás en el grafo para calcular la derivada parcial también se conoce como paso hacia atrás (backward pass). El operador de diferenciación parcial se aplica en cada nodo y las derivadas parciales se asignan a las respectivas aristas que conectan el nodo descendente a lo largo del grafo computacional.
Siguiendo la regla de la cadena la derivada parcial \(\partial_{\omega} L\) se calcula multiplicando las derivadas parciales en todas las aristas que conectan el nodo de peso y el nodo de pérdida. Si existen varios caminos entre un nodo de peso y el nodo de pérdida, las derivadas parciales a lo largo de cada camino se suman para obtener la derivada parcial total de la pérdida con respecto al peso. Esta técnica gráfica de implementar los pasos hacia delante y hacia atrás es la técnica computacional subyacente utilizada en potentes bibliotecas de aprendizaje profundo. El paso hacia atrás se ilustra en Fig. 10.22
Las derivadas parciales de la función de pérdida con respecto a los pesos se obtienen aplicando la regla de la cadena:
Durante el entrenamiento, los pesos se inicializan con números aleatorios comúnmente muestreados a partir de una distribución uniforme con límites superior e inferior en \([-1, 1]\) o una distribución normal que tiene media cero y varianza unitaria. Estos esquemas de inicialización aleatoria tienen algunas variantes que mejoran la convergencia de la optimización. En este caso, vamos a suponer que los pesos son inicializados a partir de una distribución aleatoria uniforme y, por tanto, \(w_{1} = -0.33, w_{2} = -0.33, w_{3} = 0.57, w_{4} = -0.01, w_{5}=0.07\), y \(w_{6} = 0.82\).
Con estos valores, vamos a realizar pasos hacia adelante y hacia atrás sobre el grafo computacional. Actualizamos la figura anterior con los valores calculados durante la pasada hacia adelante en azul y los gradientes calculados durante la pasada hacia atrás en rojo. Para este ejemplo, fijamos el valor real de la variable objetivo como \(y = 1\)
Una vez calculados los gradientes a lo largo de las aristas, las derivadas parciales con respecto a los pesos no son más que una aplicación de la regla de la cadena, de la que ya hemos hablado. Los valores de las derivadas parciales son los siguientes:
El siguiente paso consiste en actualizar los pesos mediante el algoritmo de gradiente descendiente. Así, con una tasa de aprendizaje de \(\alpha = 0.01\), el nuevo valor de
El resto de pesos también pueden actualizarse utilizando una regla de actualización similar. El proceso de actualización iterativa de los pesos se repite varias veces. El número de veces que se actualizan los pesos se conoce como número de épocas o pasadas sobre los datos de entrenamiento. Normalmente, un criterio de tolerancia sobre el cambio de la función de pérdida en comparación con la época anterior controla el número de épocas.
Para determinar los pesos de una red neuronal se utiliza el algoritmo backpropagation junto con un optimizador basado en el gradiente. Afortunadamente, existen potentes bibliotecas de aprendizaje profundo, como
Tensorflow, Theano
yCNTK
que implementan gráficos computacionales para entrenar redes neuronales de cualquier arquitectura y complejidad. Estas bibliotecas vienen con soporte para ejecutar los cálculos como operaciones matemáticas en matrices multidimensionales y también pueden aprovechar lasGPU
para realizar cálculos más rápidos.
Observación
El esquema de cálculo hacia atrás backpropagation es una aplicación directa de la regla de la cadena para las derivadas, y comienza con el paso inicial de calcular las derivadas asociadas a la última capa (de salida), que resulta ser sencillo.
A continuación, el algoritmo “fluye” hacia atrás en la jerarquía de capas. Esto se debe a la naturaleza de la red multicapa, donde las salidas, capa tras capa, se forman como funciones de funciones.
El gradiente en la última capa es simplemente la derivada de la función de pérdida con respecto a la salida de esa capa. Como no hay capas posteriores que influyan en su gradiente, el cálculo es más directo y no requiere aplicar la regla de la cadena a través de varias capas.
En efecto, centrémonos en la salida \(y_{k}^{r}\) de la neurona \(k\) en la capa \(r\). Entonces tenemos
\[ y_{k}^{r}=f(\boldsymbol{\theta}_{k}^{r^T}\boldsymbol{y}^{r-1}),\quad k=1,2,\dots, k_{r}, \]donde \(\boldsymbol{y}^{r-1}\) es el vector (ampliado) que comprende todas las salidas de la capa anterior, \(r-1\), y \(f\) denota la no-linealidad.
De acuerdo con lo anterior, la salida de la \(j\)-th neurona en la siguiente capa viene dada por
\[\begin{split} y_{j}^{r+1}=f(\boldsymbol{\theta}_{j}^{r+1^T}\boldsymbol{y}^{r})= f\left(\boldsymbol{\theta}_{j}^{r+1^{T}} \begin{bmatrix} 1\\ f(\Theta^{r}\boldsymbol{y}^{r-1}) \end{bmatrix} \right)= f\left(\boldsymbol{\theta}_{j}^{r+1^{T}} \begin{bmatrix} 1\\ f(\boldsymbol{y}^{r}) \end{bmatrix} \right), \end{split}\]donde \(\Theta^{r}:=[\boldsymbol{\theta}_{1}^{r}, \boldsymbol{\theta}_{2}^{r},\dots,\boldsymbol{\theta}_{k_{r}}]^{T}\) denota la matriz cuyas columnas corresponden al vector de pesos en el layer \(r\).
Nótese que obtuvimos evaluación de “una función interna bajo una función externa”. Claramente, esto continúa a medida que avanzamos en la jerarquía. Esta estructura de evaluación de funciones internas por funciones externas, es el subproducto de la naturaleza multicapa de las redes neuronales, la cual es una operación altamente no lineal, que da lugar a la dificultad de calcular los gradientes, a diferencia de otros modelos, como por ejemplo SVM.
Sin embargo, se puede observar fácilmente que el cálculo de los gradientes con respecto a los parámetros que definen la
capa de salida
no plantea ninguna dificultad. En efecto, la salida de la \(j\)-th neurona de la última capa (que es en realidad la respectiva estimación de salida actual) se escribe como:
Dado que \(\boldsymbol{y}^{L-1}\) es conocido, después de los cálculos durante el paso adelante (
forward computaion
), tomando la derivada con respecto a \(\boldsymbol{\theta}_{j}^{L}\) es sencillo; no hay ninguna operación de función sobre función. Por esto es que empezamos por la capa superior y luego nos movemos hacia atrás. Debido a su importancia histórica, se dará la derivación completa del algoritmo backpropagation.
Para la derivación detallada del algoritmo backpropagation, se adopta como ejemplo la función de pérdida del error cuadrático, es decir
(10.4)#\[ J(\boldsymbol{\theta})=\sum_{n=1}^{N}J_{n}(\boldsymbol{\theta})\quad\text{y}\quad J_{n}(\boldsymbol{\theta})=\frac{1}{2}\sum_{k=1}^{k_{L}}(\hat{y}_{nk}-y_{nk})^{2}, \]donde \(\hat{y}_{nk},~k=1,2,\dots,k_{L}\), son las estimaciones proporcionadas en los correspondientes nodos de salida de la red. Las consideraremos como los elementos de un vector correspondiente, \(\hat{\boldsymbol{y}}_{n}\).
10.9. Cálculo de gradientes#
Sea \(z_{nj}^{r}\) la salida del combinador lineal de la \(j\)-th neurona en la capa \(r\) en el instante de tiempo \(n\), cuando se aplica el patrón \(\boldsymbol{x}_{n}\) en los nodos de entrada (véase la Fig. 10.18). Entonces, para \(n, j\) fijos, podemos escribir
(10.5)#\[ z_{nj}^{r}=\sum_{m=1}^{k_{r-1}}\theta_{jm}^{r}y_{nm}^{r-1}+\theta_{j0}^{r}=\sum_{m=0}^{k_{r-1}}\theta_{jm}^{r}y_{nm}^{r-1}=\boldsymbol{\theta}_{j}^{r^{T}}\boldsymbol{y}_{n}^{r-1}, \]donde por definición
\[ \boldsymbol{y}_{n}^{r-1}:=[1, y_{n1}^{r-1},\dots, y_{nk_{r-1}}^{r-1}]^{T}, \]y \(y_{n0}^{r}\equiv 1,~\forall~r, n\) y \(\theta_{j}^{r}\) ha sido definido en la Ecuación (10.2).
Para las neuronas de la capa de salida \(r=L,~y_{nm}^{L}=\hat{y}_{nm},~m=1,2,\dots, k_{L}\), y para \(r=1\), tenemos \(y_{nm}^{1}=x_{nm},~m=1,2,\dots, k_{1}\); esto es, \(y_{nm}^{1}\) se fija igual a los valores de las características de entrada.
Por lo tanto, teniendo en cuenta que \(z_{j}^{r}=\boldsymbol{\theta}_{j}^{rT}\boldsymbol{y}^{r-1},~j=1,2,\dots,k_{r}\) y \(\hat{y}_{j}:=y_{j}^{r}=f(\boldsymbol{\theta}_{j}^{r^{T}}\boldsymbol{y}^{r-1})=f(z_{j}^{r})\), con base en la Ecuación (10.4) podemos escribir las derivadas parciales
Definamos
Entonces la Ecuación (10.3) puede escribirse como
10.10. Cálculo de \(\delta_{nj}^{r}\)#
Este es el cálculo principal del algoritmo backpropagation. Para el cálculo de los gradientes, \(\delta_{nj}^{r}\), se comienza en la última capa, \(r = L\), y se procede hacia atrás, hacia \(r = 1\); esta “filosofía” justifica el nombre dado al algoritmo.
\(r=L\): Tenemos que
\[ \delta_{nj}^{L}:=\frac{\partial J_{n}}{\partial z_{nj}^{L}}. \]Para la función de pérdida del error al cuadrado,
\[ J_{n}=\frac{1}{2}\sum_{k=1}^{k_{L}}\left(\hat{y}_{nk}-y_{nk}\right)^{2}=\frac{1}{2}\sum_{k=1}^{k_{L}}\left(f(z_{nk}^{L})-y_{nk}\right)^{2}. \]Por lo tanto,
(10.7)#\[\begin{split} \begin{align*} \delta_{nj}^{L}=\frac{\partial}{\partial z_{nj}^{L}}\left(\frac{1}{2}\sum_{k=1}^{k_{L}}\left(f(z_{nk}^{L})-y_{nk}\right)^{2}\right)&=(f(z_{nj}^{L})-y_{nj})f'(z_{nj}^{L})\\ &=(\hat{y}_{nj}-y_{nj})f'(z_{nj}^{L})=e_{nj}f'(z_{nj}^{L}), \end{align*} \end{split}\]donde \(j=1,2,\dots, k_{L}\), \(f'\) denota la derivada de \(f\) y \(e_{nj}\) es el error asociado con el \(j\)-th output en el tiempo \(n\). Nótese que para el último layer, el cálculo del gradiente, \(\delta_{nj}^{L}\) es sencillo.
\(r<L\): Debido a la dependencia sucesiva entre las capas, el valor de \(z_{nj}^{r-1}\) influye en todos los valores \(z_{nk}^{r},~k = 1, 2,\dots, k_{r}\), de la capa siguiente. Empleando la regla de la cadena para la diferenciación, obtenemos, para \(r=L, L-1,\dots, 2\):
(10.8)#\[ \delta_{nj}^{r-1}=\frac{\partial J_{n}}{\partial z_{nj}^{r-1}}=\sum_{k=1}^{k_{r}}\frac{\partial J_{n}}{\partial z_{nk}^{r}}\frac{\partial z_{nk}^{r}}{\partial z_{nj}^{r-1}}, \]o
(10.9)#\[ \delta_{nj}^{r-1}=\sum_{k=1}^{k_{r}}\delta_{nk}^{r}\frac{\partial z_{nk}^{r}}{\partial z_{nj}^{r-1}}. \]Además, usando la Ecuación (10.5) se tiene,
\[ \frac{\partial z_{nk}^{r}}{\partial z_{nj}^{r-1}}=\frac{\partial}{\partial z_{nj}^{r-1}}\left(\sum_{m=0}^{k_{r-1}}\theta_{km}^{r}y_{nm}^{r-1}\right), \]donde \(\textcolor{blue}{y_{nm}^{r-1}=f(z_{nm}^{r-1})}=f(\boldsymbol{\theta}_{m}^{r-1^{T}}\boldsymbol{y}_{n}^{r-2})\). Entonces,
\[ \frac{\partial z_{nk}^{r}}{\partial z_{nj}^{r-1}}=\theta_{kj}^{r}f'(z_{nj}^{r-1}), \]y combinando las Ecuaciones (10.8) y (10.9), obtenemos la regla recursiva
\[ \delta_{nj}^{r-1}=\left(\sum_{k=1}^{k_{r}}\delta_{nk}^{r}\theta_{kj}^{r}\right)f'(z_{nj}^{r-1}). \]
Manteniendo la misma notación en la Ecuación (10.7), definimos
\[ e_{nj}^{r-1}:=\sum_{k=1}^{k_{r}}\delta_{nk}^{r}\theta_{kj}^{r}, \]y finalmente obtenemos,
(10.10)#\[ \delta_{nj}^{r-1}=e_{nj}^{r-1}f'(z_{nj}^{r-1}). \]
El único cálculo que queda es la derivada de \(f\). Para el caso de la función sigmoidea logística se demuestra fácilmente que es igual a (
verifíquelo
)
La derivación se ha completado y el esquema backpropagation neural network se resume en el siguiente algoritmo
Algorithm 10.2 (Algoritmo Backpropagation Gradiente Descendente)
Inicialización
Inicializar todos los pesos y sesgos sinápticos al azar con valores pequeños, pero no muy pequeños.
Seleccione el tamaño del paso \(\mu\).
Fije \(y_{nj}^{1}=x_{nj},\quad j=1,2,\dots,k_{1}:=l,\quad n=1,2,\dots,N\)
Repeat Cada repetición completa un epoch
For \(n=1,2,\dots,N\) Do
For \(r=1,2,\dots,L\) Do Cálculo Forward
For \(j=1,2,\dots,k_{r}\) Do
Calcule \(z_{nj}^{r}\) a partir de la Ecuación (10.5) Calcule \(y_{nj}^{r}=f(z_{nj}^{r})\)
End For
End For
For \(j = 1, 2,\dots, k_{L}\), Do; Cálculo Backward (output layer)
Calcule \(\delta_{nj}^{L}\) a partir de la Ecuación (10.10)
End For
For \(r=L, L-1,\dots, 2\), Do; Cálculo Backward (hidden layers)
For \(j=1,2,\dots, k_{r}\), Do
Calcule \(\delta_{nj}^{r-1}\) a partir de la Ecuación (10.10)
End For
End For
End For
For \(r=1,2,\dots,L\), Do: Actualice los pesos
For \(j=1,2,\dots,k_{r}\), Do
Calcule \(\Delta\boldsymbol{\theta}_{j}^{r}\) a partir de la Ecuación (10.6)
\(\boldsymbol{\theta}_{j}^{r}=\boldsymbol{\theta}_{j}^{r}+\Delta\boldsymbol{\theta}_{j}^{r}\)
End For
End For
Until Un criterio de parada se cumpla.
El algoritmo de backpropagation puede reivindicar una serie de padres. La popularización del algoritmo se asocia con el artículo clásico [Rumelhart et al., 1986], donde se proporciona la derivación del algoritmo. La idea de backpropagation también aparece en [Bryson Jr et al., 1963] en el contexto del control óptimo.
Existen diferentes variaciones del algoritmo backpropagation, tales como: Gradiende descendente con término de momento, Algoritmo de momentos de Nesterov’s, Algoritmo AdaGrad, RMSProp con momento de Nesterov, Algortimo de estimación de momentos adaptativo los cuales pueden ser utlizados para resolver la tarea de optimización (ver [Theodoridis, 2020]).
10.11. Tuning de Redes Neuronales#
Profundizaremos en la mecánica del Perceptrón Multicapa (MLP) empleando el
MLPClassifier()
en el conjunto de datostwo_moons
que se utilizó anteriormente en esta sección
from sklearn.neural_network import MLPClassifier
from sklearn.datasets import make_moons
from sklearn.model_selection import train_test_split
import mglearn
import matplotlib.pyplot as plt
X, y = make_moons(n_samples=100, noise=0.25, random_state=3)
X_train, X_test, y_train, y_test = train_test_split(X, y, stratify=y, random_state=42)
mlp = MLPClassifier(solver='adam', random_state=0, max_iter=10000).fit(X_train, y_train)
adam
Optimizer:adam
es un acrónimo de Adaptive Moment Estimation y es uno de los algoritmos de optimización más populares y efectivos utilizados en el entrenamiento de redes neuronales (ver [Kingma and Ba, 2014]). Es una variante avanzada del gradiente descendente que combina las ideas de los métodos de momentum y RMSProp para adaptarse mejor a diferentes problemas de optimización.
Características de Adam
Adaptative Learning Rate: Calcula tasas de aprendizaje adaptativas para cada parámetro utilizando estimaciones de primer y segundo orden de los momentos (media y varianza) de los gradientes.
Momentum:Utiliza el concepto de momentum para acumular una media móvil exponencial de los gradientes pasados (primer momento) y una media móvil exponencial de los cuadrados de los gradientes pasados (segundo momento).
mglearn.plots.plot_2d_separator(mlp, X_train, fill=True, alpha=.3)
mglearn.discrete_scatter(X_train[:, 0], X_train[:, 1], y_train)
plt.xlabel("Feature 0");
plt.ylabel("Feature 1");

Evidentemente, la red neuronal ha adquirido un límite de decisión decisivamente no lineal pero relativamente gradual
mlp = MLPClassifier(solver='lbfgs', random_state=0, max_iter=400, hidden_layer_sizes=[10])
Se empleó el algoritmo
'lbfgs'
(ver Broyden–Fletcher–Goldfarb–Shanno algorithm (L-BFGS), optimizador de la familia de los métodos cuasi-Newton.). En particular, el MLP emplea 100 nodos ocultos por defecto, un número considerable para este conjunto de datos compacto. Sin embargo, incluso con un número reducido de nodos, se puede lograr un resultado satisfactorio
Optimizador L-BFGS
L-BFGS es una variante del algoritmo BFGS, que es un método de optimización de segundo orden. A diferencia de los métodos de primer orden, que utilizan únicamente el gradiente de la función de pérdida, los métodos de segundo orden también utilizan la información de la segunda derivada (la matriz Hessiana) para mejorar la dirección de los pasos de actualización. L-BFGS ajusta internamente las tasas de aprendizaje basadas en la información de segundo orden.
mlp.fit(X_train, y_train)
MLPClassifier(hidden_layer_sizes=[10], max_iter=400, random_state=0, solver='lbfgs')In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
MLPClassifier(hidden_layer_sizes=[10], max_iter=400, random_state=0, solver='lbfgs')
mglearn.plots.plot_2d_separator(mlp, X_train, fill=True, alpha=.3)
mglearn.discrete_scatter(X_train[:, 0], X_train[:, 1], y_train)
plt.xlabel("Feature 0");
plt.ylabel("Feature 1");

Al reducir las unidades ocultas a 10 hace que el límite de decisión tenga un aspecto algo irregular. La no linealidad por defecto empleada es la
"relu"
import numpy as np
line = np.linspace(-3, 3, 100)
plt.plot(line, np.tanh(line), label="tanh")
plt.plot(line, np.maximum(line, 0), label="relu")
plt.legend(loc="best")
plt.xlabel("x")
plt.ylabel("relu(x), tanh(x)");

En el caso de una sola capa oculta, esto implica que la función de decisión comprende 10 segmentos de línea recta. Para conseguir un límite de decisión más gradual, las alternativas incluyen aumentar las unidades ocultas, introducir una segunda capa oculta o emplear la no linealidad
"tanh"
.
Utilizando dos capas ocultas, con 10 unidades cada una
mlp = MLPClassifier(solver='lbfgs', random_state=0, hidden_layer_sizes=[10, 10])
mlp.fit(X_train, y_train)
MLPClassifier(hidden_layer_sizes=[10, 10], random_state=0, solver='lbfgs')In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
MLPClassifier(hidden_layer_sizes=[10, 10], random_state=0, solver='lbfgs')
mglearn.plots.plot_2d_separator(mlp, X_train, fill=True, alpha=.3)
mglearn.discrete_scatter(X_train[:, 0], X_train[:, 1], y_train)
plt.xlabel("Feature 0")
plt.ylabel("Feature 1");

mlp = MLPClassifier(solver='lbfgs', activation='tanh', max_iter=400, random_state=0, hidden_layer_sizes=[10, 10])
mlp.fit(X_train, y_train)
MLPClassifier(activation='tanh', hidden_layer_sizes=[10, 10], max_iter=400, random_state=0, solver='lbfgs')In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
MLPClassifier(activation='tanh', hidden_layer_sizes=[10, 10], max_iter=400, random_state=0, solver='lbfgs')
mglearn.plots.plot_2d_separator(mlp, X_train, fill=True, alpha=.3)
mglearn.discrete_scatter(X_train[:, 0], X_train[:, 1], y_train)
plt.xlabel("Feature 0")
plt.ylabel("Feature 1");

Además, podemos influir en la complejidad de una red neuronal incorporando una penalización \(L2\), similar al enfoque de la regresión de ridge y los clasificadores lineales. El parámetro responsable de esto en el
MLPClassifier
es'alpha'
, análogo a los modelos de regresión lineal. Por defecto, asume un valor mínimo (regularización limitada). El siguiente experimento incluye dos capas ocultas, cada una de ellas compuesta por 10 o 100 unidades y diferentes valores dealpha
fig, axes = plt.subplots(2, 4, figsize=(20, 8))
for axx, n_hidden_nodes in zip(axes, [10, 100]):
for ax, alpha in zip(axx, [0.0001, 0.01, 0.1, 1]):
mlp = MLPClassifier(solver='lbfgs', random_state=0, max_iter=1000, hidden_layer_sizes=[n_hidden_nodes, n_hidden_nodes], alpha=alpha)
mlp.fit(X_train, y_train)
mglearn.plots.plot_2d_separator(mlp, X_train, fill=True, alpha=.3, ax=ax)
mglearn.discrete_scatter(X_train[:, 0], X_train[:, 1], y_train, ax=ax)
ax.set_title("n_hidden=[{}, {}]\nalpha={:.4f}".format(n_hidden_nodes, n_hidden_nodes, alpha))

Observación
Como habrá podido comprobar, existen multitud de métodos para gestionar la complejidad de una red neuronal, como el número de capas ocultas, las unidades en cada capa oculta y la regularización (
alpha
), entre otros. Un rasgo fundamental de las redes neuronales reside en suconfiguración aleatoria inicial de pesos antes de comenzar el aprendizaje
.Esta aleatoriedad influye en el modelo aprendido resultante. Así, el empleo de parámetros idénticos puede dar lugar a modelos distintos debido a semillas aleatorias diferentes. Mientras que este efecto es más pronunciado para redes pequeñas, es menos significativo para redes de tamaño adecuado donde la complejidad se elige apropiadamente.
fig, axes = plt.subplots(2, 4, figsize=(20, 8))
for i, ax in enumerate(axes.ravel()):
mlp = MLPClassifier(solver='lbfgs', random_state=i, max_iter=2000, hidden_layer_sizes=[100, 100])
mlp.fit(X_train, y_train)
mglearn.plots.plot_2d_separator(mlp, X_train, fill=True, alpha=.3, ax=ax)
mglearn.discrete_scatter(X_train[:, 0], X_train[:, 1], y_train, ax=ax)

Para una comprensión más completa de las redes neuronales en escenarios prácticos, implementaremos el MLPClassifier en el conjunto de datos Breast Cancer. Nuestro paso inicial consiste en utilizar los parámetros por defecto. Queda como tarea para el estudiante, utilizar
GridSearchCV
yPipeline
.
from sklearn.datasets import load_breast_cancer
cancer = load_breast_cancer()
print("Cancer data per-feature maxima:\n{}".format(cancer.data.max(axis=0)))
Cancer data per-feature maxima:
[2.811e+01 3.928e+01 1.885e+02 2.501e+03 1.634e-01 3.454e-01 4.268e-01
2.012e-01 3.040e-01 9.744e-02 2.873e+00 4.885e+00 2.198e+01 5.422e+02
3.113e-02 1.354e-01 3.960e-01 5.279e-02 7.895e-02 2.984e-02 3.604e+01
4.954e+01 2.512e+02 4.254e+03 2.226e-01 1.058e+00 1.252e+00 2.910e-01
6.638e-01 2.075e-01]
X_train, X_test, y_train, y_test = train_test_split(cancer.data, cancer.target, random_state=0)
mlp = MLPClassifier(random_state=42)
mlp.fit(X_train, y_train)
MLPClassifier(random_state=42)In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
MLPClassifier(random_state=42)
print("Accuracy on training set: {:.2f}".format(mlp.score(X_train, y_train)))
print("Accuracy on test set: {:.2f}".format(mlp.score(X_test, y_test)))
Accuracy on training set: 0.94
Accuracy on test set: 0.92
El MLP muestra una precisión notable, aunque no a la par de otros modelos. Al igual que en el caso anterior del
SVC
, esta discrepancia se debe probablemente al escalado de los datos. Las redes neuronales exigen una varianza uniforme entre las características de entrada, idealmente con una media de 0 y una varianza de 1.Para satisfacer estos criterios, los datos deben reescalarse. Mientras que aquí estamos implementando este proceso manualmente, en el capítulo Evaluación de modelos y Pipelines se usa
StandardScaler
para el manejo automatizado de este procedimiento
Calculamos el valor medio por característica en el conjunto de entrenamiento
mean_on_train = X_train.mean(axis=0)
Calculamos la desviación estándar de cada característica en el conjunto de entrenamiento
std_on_train = X_train.std(axis=0)
Procedemos con el proceso de estandarización a media 0 y desviación 1
X_train_scaled = (X_train - mean_on_train) / std_on_train
X_test_scaled = (X_test - mean_on_train) / std_on_train
mlp = MLPClassifier(random_state=0, max_iter=400)
mlp.fit(X_train_scaled, y_train)
MLPClassifier(max_iter=400, random_state=0)In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
MLPClassifier(max_iter=400, random_state=0)
print("Accuracy on training set: {:.3f}".format(mlp.score(X_train_scaled, y_train)))
print("Accuracy on test set: {:.3f}".format(mlp.score(X_test_scaled, y_test)))
Accuracy on training set: 1.000
Accuracy on test set: 0.972
Dado que se aprecia una disparidad entre el rendimiento de entrenamiento y el de prueba, se puede intentar mejorar el rendimiento de generalización reduciendo la complejidad del modelo. En este escenario, optamos por intensificar el parámetro
"alpha"
(de 0.0001 a 1) para imponer una regularización más potente de los pesos
mlp = MLPClassifier(max_iter=1000, alpha=1, random_state=0)
mlp.fit(X_train_scaled, y_train)
MLPClassifier(alpha=1, max_iter=1000, random_state=0)In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
MLPClassifier(alpha=1, max_iter=1000, random_state=0)
print("Accuracy on training set: {:.3f}".format(mlp.score(X_train_scaled, y_train)))
print("Accuracy on test set: {:.3f}".format(mlp.score(X_test_scaled, y_test)))
Accuracy on training set: 0.988
Accuracy on test set: 0.972
Descubrir lo que ha aprendido una red neuronal suele ser todo un reto. Una técnica para comprender mejor los conocimientos adquiridos consiste en analizar los pesos del modelo. Puede ver un ejemplo en la galería de
scikit-learn
. Sin embargo, para el conjunto de datos de Cáncer de Mama, la comprensión puede ser algo desafiante.La siguiente rutina muestra los pesos aprendidos, conectando la entrada a la primera capa oculta. Las filas corresponden a las 30 características de entrada, mientras que las columnas corresponden a las 100 unidades ocultas.
import seaborn as sns
plt.figure(figsize=(20, 15))
sns.set(font_scale=1.8)
plt.imshow(mlp.coefs_[0], interpolation='none', cmap='viridis', aspect='auto')
plt.yticks(range(30), cancer.feature_names)
plt.xlabel("Columns in weight matrix")
plt.ylabel("Input feature")
plt.colorbar();

Una posible inferencia que podemos hacer es que las
características que tienen pesos muy pequeños para todas las unidades ocultas son "menos importantes" para el modelo
. Podemos ver que “mean smoothness” y “mean compactness”, además de las características encontradas entre “smoothness error” y “fractal dimension error”, tienen pesos relativamente bajos comparados con otras características. Esto podría significar que se trata de rasgos menos relevantes o, posiblemente, que no las representamos de forma que la red neuronal pudiera utilizarlas.También podemos visualizar los pesos que conectan la capa oculta con la capa de salida, pero son aún más difíciles de interpretar. Aunque
MLPClassifier
yMLPRegressor
proporcionan interfaces fáciles de usar para las arquitecturas de redes neuronales más comunes, solo capturan un pequeño subconjunto de lo que es posible con las redes neuronales. Si está interesado en trabajar con modelos más flexibles o modelos más grandes, se recomienda mirar más allá de scikit-learn en las fantásticas bibliotecas de aprendizaje profundo.Para los usuarios de
Python
, las más conocidas sonkeras, lasagna
y `tensor-flow. Estas bibliotecas proporcionan una interfaz mucho más flexible para construir redes neuronales y seguir el rápido progreso en la investigación del aprendizaje profundo, también permiten el uso de unidades de procesamiento gráfico (GPU) de alto rendimiento que scikit-learn no soporta.
10.12. Análisis de Malware por API calls#
El siguiente conjunto de datos forma parte de una investigación sobre la detección y clasificación de Malware mediante Deep Learning (ver Oliveira, Angelo; Sassi, Renato José (2019)). Contiene 42.797 secuencias de llamadas a la API de malware y 1.079 secuencias de llamadas a la API de goodware. Cada secuencia de llamadas a la API se compone de las 100 primeras llamadas a la API consecutivas no repetidas asociadas al proceso principal, extraídas de los elementos “calls” de los informes de Cuckoo Sandbox.
Características
Nombre de la columna
: hashDescripción
: El hash MD5 del ejemploTipo
: Cadena de 32 bytes
Nombre de columna
: t_0, t_1,…, t99Descripción
: Llamada a la APITipo
: Entero (0-306)
Nombre de columna
: malwareDescripción
: ClaseTipo
: Entero: 0 (Goodware) o 1 (Malware)

Fig. 10.24 Gráfico de comportamiento del malware troyano con hash MD5 ac65ce897a1f0dc273e8dc54fe3768ec.#
import warnings
warnings.filterwarnings('ignore')
import numpy as np
import pylab as pl
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
import random
from sklearn.utils import shuffle
from sklearn.svm import SVC
from sklearn.metrics import confusion_matrix,classification_report
from sklearn.model_selection import cross_val_score, GridSearchCV
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.neural_network import MLPClassifier
from sklearn.model_selection import train_test_split
import pickle
import glob
from sklearn.metrics import f1_score
from sklearn.metrics import classification_report
from sklearn.metrics import roc_curve
from sklearn.metrics import precision_recall_curve
from time import process_time
data = pd.read_csv("https://raw.githubusercontent.com/lihkir/Data/main/api_call_sequence_per_malware.csv")
data.shape
(43876, 102)
La siguiente es una construida en el presente curso, la cual puede ser útil para quienes solo desean presentar unas cuantas columnas al inicio y al final de cada
pandas
. Solo requiere del nombre delpandas
y el número de columnas que deseamos visualziar al inicio y al final. Es bastante util, para utilizar en losJupyter Books
, donde el espacio horizontal es reducido.
def idx_lr(data, n_new_cols):
n_data_cols = len(data.columns)
idx_l = sorted(list(range(n_new_cols - 1, -1, -1)))
idx_r = sorted(list(range(n_data_cols - 1, n_data_cols - n_new_cols - 1, -1)))
return idx_l + idx_r
Para imprimir por ejemplo las tres primeras columnas al inicio y al final del pandas data utilizamos la orden
data.iloc[:, idx_lr(data, 4)]
. Nótese que no contamos con datos faltantes que requieran de un procedimiento de imputación (ver Análisis con datos faltantes). Además, nótese que todas las columnas con la excepción dehash
corresponde a datos numericos discretos.
data.iloc[:, idx_lr(data, 4)].head()
hash | t_0 | t_1 | t_2 | t_97 | t_98 | t_99 | malware | |
---|---|---|---|---|---|---|---|---|
0 | 071e8c3f8922e186e57548cd4c703a5d | 112 | 274 | 158 | 208 | 56 | 71 | 1 |
1 | 33f8e6d08a6aae939f25a8e0d63dd523 | 82 | 208 | 187 | 171 | 215 | 35 | 1 |
2 | b68abd064e975e1c6d5f25e748663076 | 16 | 110 | 240 | 65 | 113 | 112 | 1 |
3 | 72049be7bd30ea61297ea624ae198067 | 82 | 208 | 187 | 302 | 228 | 302 | 1 |
4 | c9b3700a77facf29172f32df6bc77f48 | 82 | 240 | 117 | 260 | 141 | 260 | 1 |
print("# NaN values:", data.isna().sum().sum())
# NaN values: 0
data.dtypes
hash object
t_0 int64
t_1 int64
t_2 int64
t_3 int64
...
t_96 int64
t_97 int64
t_98 int64
t_99 int64
malware int64
Length: 102, dtype: object
Pasamos ahora a eliminar aquellas variables (columnas) irrelevantes en el análisis predictivo, a saber la columna
hash
del pandasdata
y creamos uno nuevo al cual nombraremosdata_new
.
data_new = data.drop(columns=['hash'], axis=1)
data_new.shape
(43876, 101)
Verifiquemos si nuestros datos están desbalanceados respecto a las clases de interés 0 (Goodware) o 1 (Malware)
import matplotlib
matplotlib.rc_file_defaults()
cnt_pro = data_new['malware'].value_counts()
sns.barplot(x=cnt_pro.index, y=cnt_pro.values, alpha=0.8)
plt.ylabel('Number of data', fontsize=10)
plt.xlabel('Malware Type', fontsize=10);

Claramente, existe un desbalance entre los tipos de Malware. Por otro lado, verifiquemos para algunos Hash, como se distribuyen la frecuencia de cada una de sus API calls. Las columnas representan las frecuencias de cada API call representado por los valores
t_0, t_1,..., t99
.
fig, axs = plt.subplots(nrows=2, ncols=2, figsize=(15, 12))
plt.subplots_adjust(hspace=0.2)
fig.suptitle("Malware API Calls by Hash", fontsize=14, y=0.92)
for i, ax in zip(range(1, 5), axs.ravel()):
plt.subplot(2, 2, i)
n_hash = random.randint(0, len(data_new))
data_new.iloc[n_hash,:-1].value_counts().plot(kind='bar', xlabel='Hash: '+ data['hash'][n_hash], ylabel='API Call');

Procedemos a hacer uso de
make_pipeline
yGridSearchCV
para obtener parámetros del mejor modelo. En este caso utilizaremos el clasificadorMLPClassifier
, el cual se estudió en clase de forma analítica.
El algoritmo clasificador
MLPClassifier
denota el valor de la longitud de paso \(\mu_{i}\) comolearning_rate
, el cual asume los valores constant, invscaling, adaptive. En este ejemplo se ha probado con los tres parámetros, obteniendo siempre el mejor score cuandolearning_rate = constant
. Por otro lado,hidden_layer_sizes
representa el número de neuronas en el \(i\)th layer. En este problema de clasificación optamos por seleccionar desde una a tres capas escondidas (hidden layers), pero siempre el mejor score entregado porGridSearchCV
, fue obtenido considerando una sola capa. Por lo tanto, en este problema hacemos solo un grid search sobre el número de neuronas sobre una sola capa.Dado que nuestro problema es de clasificación binaria, también consideramos en el
param_grid
, soloactivation = logistic
. Las opciones del clasificador sonidentity, logistic, tanh, relu
, peroGridSearchCV
entregó el mejor score usandologistic
como era de esperarse. El parámetroalpha
es la fuerza del término de regularización \(L^2\), similar al utilizado en la regresiónridge
cuando deseamos reducir la complejidad de un modelo. Puede revisar la documentación del clasificador para ver todos los parámetros asociados, y aquellos que son por defecto (ver MLPClassifier).

Fig. 10.25 Red neuronal feed-forward de tres capas.#
La capa de entrada
En general las ANNs tienen exactamente una capa de entrada. Con respecto al número de neuronas que componen esta capa, este parámetro se determina de forma completa y única, una vez que se conoce la forma de los datos de entrenamiento. En concreto, el número de neuronas que componen esa capa es igual al número de características (columnas) de sus datos. Algunas configuraciones de ANNs añaden un nodo adicional para un término de sesgo.
La capa de salida
Al igual que la capa de entrada, cada ANN tiene exactamente una capa de salida. Determinar su tamaño (número de neuronas) es sencillo; está completamente determinado por la configuración del modelo elegido. Si la ANN es un regresor, la capa de salida tiene un solo nodo (Time Series Forecasting). Si la ANN es un clasificador, entonces también tiene un solo nodo, a menos que se utilice
softmax
, en cuyo caso la capa de salida tiene un nodo por cada etiqueta de clase en su modelo.
Las capas ocultas
¿Cuántas capas ocultas?. Si los datos son linealmente separables (lo que a menudo se sabe cuando se empieza a codificar una ANN, SVM puede servir de test), entonces no se necesita ninguna capa oculta. Por supuesto, tampoco se necesita una ANN para resolver los datos, pero está seguirá haciendo su trabajo.
Sobre la configuración de las capas ocultas en las ANNs, existe un consenso dentro de este tema, y es la diferencia de rendimiento al añadir capas ocultas adicionales: las situaciones en las que el rendimiento mejora con una segunda (o tercera, etc.) capa oculta son muy pocas. Una capa oculta es suficiente para la gran mayoría de los problemas.
Entonces, ¿qué pasa con el tamaño de la(s) capa(s) oculta(s), cuántas neuronas?. Existen algunas reglas empíricas; de ellas, la más utilizada es ‘The optimal size of the hidden layer is usually between the size of the input and size of the output layers’. Jeff Heaton, the author of Introduction to Neural Networks in Java.
Hay una regla empírica adicional que ayuda en los problemas de aprendizaje supervisado. Normalmente se puede evitar el sobreajuste si se mantiene el número de neuronas por debajo de:
\[ N_{h}=\frac{N_{s}}{(\alpha\cdot(N_{i}+N_{o}))} \]\(N_{i}=\) número de neuronas de entrada
\(N_{o}=\) número de neuronas de salida
\(N_{s}=\) número de muestras en el conjunto de datos de entrenamiento
\(\alpha=\) un factor de escala arbitrario, normalmente 2-10
Un valor de \(\alpha=2\) suele funcionar sin sobreajustar. Se puede pensar en \(\alpha\) como el factor de ramificación efectivo o el número de pesos distintos de cero para cada neurona. Las capas de salida harán que el factor de ramificación “efectivo” sea muy inferior al factor de ramificación medio real de la red. Para profundizar mas en el diseño de redes neuronales, ver el siguiente texto de Martin Hagan.
En resumen, para la mayoría de los problemas, probablemente se podría obtener un rendimiento decente (incluso sin un segundo paso de optimización) estableciendo la configuración de la capa oculta utilizando sólo dos reglas:
el número de capas ocultas es igual a uno
el número de neuronas de esa capa es la media de las neuronas de las capas de entrada y salida. ¡Nótese que en el ejemplo de esta sección el número de columnas para \(X\) es 100!.
Pasamos ahora a implementar un modelo de clasificación para el conjunto de datos relacionados con: Análisis de Malware por API calls. Utilizaremos la clase
MLPClassifier
y como preprocesamiento, estandarizaremos nuestros datos usando la claseStandardScaler
. Para encontrar los mejores parámetros y evita problemas de data leakage, utilizaremosPipeline
yGridSearchCV
tal como se explicó en secciones anteriores.
pipe = make_pipeline(StandardScaler(), MLPClassifier(max_iter=10000, random_state=42))
param_grid = {'mlpclassifier__alpha': [0.1, 0.01, 0.001],
'mlpclassifier__hidden_layer_sizes': [(10,), (100,), (1000,)],
'mlpclassifier__activation': ['logistic'],
'mlpclassifier__learning_rate': ['constant']}
Construimos nuestra matriz de caracteristicas, o variable explicativa X y el vector de clases o la variable respuesta y
y = data_new['malware']
X = data_new.drop(['malware'], axis=1)
Realizamos la división de nuestros datos a priori, con el objetivo de evitar data leakage. Consideramos el mismo
random_state
utilizado en el clasificador, con el objetivo de que los resutlados sean reproducibles.
X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=42)
El siguiente ciclo if ha sido implementado en este curso para evitar reentrenar nuestro modelo clasificador. Nótese que si el algoritmo no detecta que existe un archivo de extensión
.pkl
, reentrena el modelo, de lo contrario, utiliza el modelo entrenado a priori, el cual ha sido guardado en el archivo.pkl
. Los archivoPickle
de extensión.pkl
en simples palabras se encargan de serializar un objeto, esto es transformar el mismo en una cadena de bytes única que puede ser guardada en un archivo (de extensión .pkl), archivo que podemos desempaquetar después y trabajar con su contenido. Para poder utlizarlo necesitamos importarlo con la ordenimport pickle
, e instalar la librería (Pickel viene por defecto desde Python 3.9) si así se requiere utilizando:
pip install pickle
from joblib import dump, load
grid = None
if (len(glob.glob("grid_mlp.joblib")) != 0):
grid = load('grid_mlp.joblib')
else:
time_start = process_time()
grid = GridSearchCV(pipe, param_grid, cv=5, n_jobs=-1, scoring='roc_auc')
grid.fit(X_train, y_train)
time_stop = process_time()
str_cpu_time = "GridSearchCV CPU time: " + str((time_stop-time_start)*0.6) + " minutes"
print(str_cpu_time)
with open('cpu_time.txt', 'w', encoding='utf-8') as f:
f.write(str_cpu_time)
dump(grid, 'grid_mlp.joblib')
print("Best CV score = %0.3f:" % grid.best_score_)
print("Best parameters:\n{}".format(grid.best_params_))
Best CV score = 0.958:
Best parameters:
{'mlpclassifier__activation': 'logistic', 'mlpclassifier__alpha': 0.01, 'mlpclassifier__hidden_layer_sizes': (100,), 'mlpclassifier__learning_rate': 'constant'}
Utilizamos el
f1_score
, el cual, de acuerdo a lo estudiado en secciones previas, calcula la media armonica entreprecision
yrecall
print(f"F1 Score of the classifier is: {f1_score(y_test, grid.predict(X_test))}")
F1 Score of the classifier is: 0.9937633808061063
Además podemos usar
classification_report
y la matriz de confusión, para identificar aquellas clases que fueron incorrectamente clasificadas por nuestro modelo
print(classification_report(y_test, grid.predict(X_test)))
precision recall f1-score support
0 0.94 0.56 0.70 283
1 0.99 1.00 0.99 10686
accuracy 0.99 10969
macro avg 0.96 0.78 0.85 10969
weighted avg 0.99 0.99 0.99 10969
mlp_cm = confusion_matrix(y_test, grid.predict(X_test))
f, ax = plt.subplots(figsize=(5,5))
sns.heatmap(mlp_cm, annot=True, linewidth=0.7, linecolor='black', fmt='g', ax=ax, cmap="BuPu")
plt.title('MLP Confusion Matrix')
plt.xlabel('Y predict')
plt.ylabel('Y test')
plt.show()

Nótese que la
clase 0
, presenta el mayor número de clasificaciones erroneas(FP)
, obtenidas por nuestra ANN. Justamente esto se debe al desbalance notorio en nuestros datos, el cual se inclina hacia la clases 1, de mayor frecuencia. Si se tiene clara una métrica de negocio o un punto de operación, podríamos ajustar el número de prediccion incorrectas a favor de este punto de operación, tal como en el problema de detección de cancer, donde el interés era reducir (FN). Para analizar el comportamiento del clasificador a diferentes umbrales, utilizamos la curva ROC y precision-recall
fpr_mlp, tpr_mlp, thresholds_mlp = roc_curve(y_test, grid.predict_proba(X_test)[:, 1])
plt.plot(fpr_mlp, tpr_mlp, label="ROC Curve MLP")
plt.xlabel("FPR")
plt.ylabel("TPR (recall)")
close_default_mlp = np.argmin(np.abs(thresholds_mlp - 0.5))
plt.plot(fpr_mlp[close_default_mlp], tpr_mlp[close_default_mlp], '^',
markersize=10, label="threshold 0.5 MLP", fillstyle="none", c='k', mew=2)
plt.legend(loc=4);

Nótese que la tasa TPR se mantiene aproximadamente contante para valores de FPR>0.2, esto es, para esta tasas de FPR no se está sacrificando la tasa recall. Si deseamos mantener un recall alto, aproximadamente, mayor que 0.8, podemos considerar por ejemplo una tasa FPR en torno a 0.1, con la que no estaríamos sacrificando recall y además, obtendriamos un tasa de falsos positivos FPR relativamente baja.
precision_mlp, recall_mlp, thresholds_mlp = precision_recall_curve(y_test, grid.predict_proba(X_test)[:, 1])
plt.plot(precision_mlp, recall_mlp, label="MLP")
close_default_mlp = np.argmin(np.abs(thresholds_mlp - 0.5))
plt.plot(precision_mlp[close_default_mlp], recall_mlp[close_default_mlp], '^', c='k',
markersize=10, label="threshold 0.5 MLP", fillstyle="none", mew=2)
plt.xlabel("Precision")
plt.ylabel("Recall")
plt.legend(loc="best");

Nótese que con el umbral por defecto, la curva precision-recall muestra una clasificación con scores bastante buenos para cada una de estas métricas, incluso, si movemos el umbral un poco, para favorecer la clase 0 que es la menos frecuente, vamos a obtener de igual manera un rango de valores altos para precision y recall. Sólo valores de precision muy elevados, esto es, valores cuya distancia 1 tiende a cero, estaríamos sacrificando recall.
10.13. Aplicación: Predicción de series temporales#
Series de tiempo
Uno de los enfoques clásicos para la predicción de series temporales es el modelo autorregresivo de orden \(p\), denotado como \(AR(p)\), el cual modela el valor actual \(y_t\) como una combinación lineal de sus \(p\) valores pasados:
\[ y_t = \omega_0 + \sum_{i=1}^{p} \omega_i y_{t-i} + \varepsilon_t, \]donde \(\omega_i\) son coeficientes del modelo y \(\varepsilon_t\) es un término de error con media cero y varianza constante.
Este modelo puede generalizarse mediante una función no lineal \(f\):
\[ y_t = f(y_{t-1}, y_{t-2}, \dots, y_{t-p}), \]donde \(f\) puede ser aprendida utilizando redes neuronales artificiales. En este capítulo se consideran tres arquitecturas neuronales para aproximar \(f\), cada una definida por su número de capas ocultas, neuronas por capa y función de activación. El entrenamiento de los modelos se realiza mediante el algoritmo de backpropagation del error o sus variantes.
En esta sección, utilizaremos
MLP
para desarrollar modelos de predicción de series temporales. El conjunto de datos utilizado para estos ejemplos es sobre la contaminación atmosférica medida por la concentración de partículas (PM) de diámetro inferior o igual a 2.5 micrómetros.Hay otras variables, como la presión atmosférica, temperatura del aire, punto de rocío, etc. Se han desarrollado un par de modelos de series temporales, uno sobre la presión atmosférica
PRES
y otro sobrepm 2.5
. El conjunto de datos se ha descargado del repositorio de aprendizaje automático de la UCI.
import warnings
from matplotlib import pyplot as plt
import seaborn as sns
warnings.filterwarnings("ignore")
sns.set_style("darkgrid")
import sys
import pandas as pd
import numpy as np
import datetime
from sklearn.preprocessing import MinMaxScaler
import tensorflow as tf
from tensorflow import keras
from keras.layers import Dense, Input, Dropout
from keras.optimizers import SGD
from keras.models import Model
from keras.models import load_model
from keras.callbacks import ModelCheckpoint
import os
from sklearn.metrics import r2_score
from sklearn.metrics import mean_absolute_error
2025-04-05 19:28:06.564687: I tensorflow/core/util/port.cc:153] oneDNN custom operations are on. You may see slightly different numerical results due to floating-point round-off errors from different computation orders. To turn them off, set the environment variable `TF_ENABLE_ONEDNN_OPTS=0`.
2025-04-05 19:28:06.860171: E external/local_xla/xla/stream_executor/cuda/cuda_fft.cc:485] Unable to register cuFFT factory: Attempting to register factory for plugin cuFFT when one has already been registered
2025-04-05 19:28:06.972003: E external/local_xla/xla/stream_executor/cuda/cuda_dnn.cc:8454] Unable to register cuDNN factory: Attempting to register factory for plugin cuDNN when one has already been registered
2025-04-05 19:28:06.992936: E external/local_xla/xla/stream_executor/cuda/cuda_blas.cc:1452] Unable to register cuBLAS factory: Attempting to register factory for plugin cuBLAS when one has already been registered
2025-04-05 19:28:07.141852: I tensorflow/core/platform/cpu_feature_guard.cc:210] This TensorFlow binary is optimized to use available CPU instructions in performance-critical operations.
To enable the following instructions: AVX2 AVX512F AVX512_VNNI AVX512_BF16 FMA, in other operations, rebuild TensorFlow with the appropriate compiler flags.
2025-04-05 19:28:08.310045: W tensorflow/compiler/tf2tensorrt/utils/py_utils.cc:38] TF-TRT Warning: Could not find TensorRT
df = pd.read_csv('https://raw.githubusercontent.com/lihkir/Data/refs/heads/main/PRSA_data_2010.1.1-2014.12.31.csv', usecols=lambda column: column not in ['DEWP', 'TEMP', 'cbwd', 'Iws', 'Is', 'Ir'])
devices = tf.config.list_physical_devices()
print("Available devices:")
for device in devices:
print(device)
gpus = tf.config.list_physical_devices('GPU')
if gpus:
print("TensorFlow is using GPU.")
for gpu in gpus:
gpu_details = tf.config.experimental.get_device_details(gpu)
print(f"GPU details: {gpu_details}")
else:
print("TensorFlow is not using GPU.")
Available devices:
PhysicalDevice(name='/physical_device:CPU:0', device_type='CPU')
PhysicalDevice(name='/physical_device:GPU:0', device_type='GPU')
TensorFlow is using GPU.
GPU details: {'compute_capability': (8, 9), 'device_name': 'NVIDIA GeForce RTX 4070'}
WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
I0000 00:00:1743899291.679266 1786 cuda_executor.cc:1001] could not open file to read NUMA node: /sys/bus/pci/devices/0000:01:00.0/numa_node
Your kernel may have been built without NUMA support.
I0000 00:00:1743899292.553202 1786 cuda_executor.cc:1001] could not open file to read NUMA node: /sys/bus/pci/devices/0000:01:00.0/numa_node
Your kernel may have been built without NUMA support.
I0000 00:00:1743899292.553247 1786 cuda_executor.cc:1001] could not open file to read NUMA node: /sys/bus/pci/devices/0000:01:00.0/numa_node
Your kernel may have been built without NUMA support.
I0000 00:00:1743899292.554421 1786 cuda_executor.cc:1001] could not open file to read NUMA node: /sys/bus/pci/devices/0000:01:00.0/numa_node
Your kernel may have been built without NUMA support.
print('Shape of the dataframe:', df.shape)
Shape of the dataframe: (43824, 7)
df.head()
No | year | month | day | hour | pm2.5 | PRES | |
---|---|---|---|---|---|---|---|
0 | 1 | 2010 | 1 | 1 | 0 | NaN | 1021.0 |
1 | 2 | 2010 | 1 | 1 | 1 | NaN | 1020.0 |
2 | 3 | 2010 | 1 | 1 | 2 | NaN | 1019.0 |
3 | 4 | 2010 | 1 | 1 | 3 | NaN | 1019.0 |
4 | 5 | 2010 | 1 | 1 | 4 | NaN | 1018.0 |
Para asegurarse de que las filas están en el orden correcto de fecha y hora de las observaciones, se crea una nueva columna datetime a partir de las columnas relacionadas con la fecha y la hora del DataFrame. La nueva columna se compone de objetos
datetime.datetime de Python
. El DataFrame se ordena en orden ascendente sobre esta columna
df['datetime'] = df[['year', 'month', 'day', 'hour']].apply(lambda row: datetime.datetime(year=row['year'], month=row['month'], day=row['day'],
hour=row['hour']), axis=1)
df.sort_values('datetime', ascending=True, inplace=True)
df.head()
No | year | month | day | hour | pm2.5 | PRES | datetime | |
---|---|---|---|---|---|---|---|---|
0 | 1 | 2010 | 1 | 1 | 0 | NaN | 1021.0 | 2010-01-01 00:00:00 |
1 | 2 | 2010 | 1 | 1 | 1 | NaN | 1020.0 | 2010-01-01 01:00:00 |
2 | 3 | 2010 | 1 | 1 | 2 | NaN | 1019.0 | 2010-01-01 02:00:00 |
3 | 4 | 2010 | 1 | 1 | 3 | NaN | 1019.0 | 2010-01-01 03:00:00 |
4 | 5 | 2010 | 1 | 1 | 4 | NaN | 1018.0 | 2010-01-01 04:00:00 |
Dibujamos un diagrama de cajas para visualizar la tendencia central y la dispersión de por ejemplo la columna
PRES
plot_fontsize = 18;
import matplotlib.pyplot as plt
import seaborn as sns
plt.figure(figsize=(15, 8))
plt.subplot(1, 2, 1)
g1 = sns.boxplot(x=df['PRES'], orient="h") # Removed palette
g1.set_title('Box plot of Air Pressure', fontsize=plot_fontsize)
g1.set_xlabel('Air Pressure readings in hPa', fontsize=plot_fontsize)
plt.subplot(1, 2, 2)
g2 = sns.boxplot(x=df['pm2.5'], orient="h") # Removed palette
g2.set_title('Box plot of PM2.5', fontsize=plot_fontsize)
g2.set_xlabel('PM2.5 readings in µg/m³', fontsize=plot_fontsize)
plt.show()

plt.figure(figsize=(15, 8))
plt.subplot(1, 2, 1)
g1 = sns.lineplot(x=df.index, y=df['PRES']) # Using x and y parameters instead of data
g1.set_title('Time series of Air Pressure', fontsize=plot_fontsize)
g1.set_xlabel('Index', fontsize=plot_fontsize)
g1.set_ylabel('Air Pressure readings in hPa', fontsize=plot_fontsize)
plt.subplot(1, 2, 2)
g2 = sns.lineplot(x=df.index, y=df['pm2.5']) # Using x and y parameters instead of data
g2.set_title('Time series of PM2.5', fontsize=plot_fontsize)
g2.set_xlabel('Index', fontsize=plot_fontsize)
g2.set_ylabel('PM2.5 readings in µg/m³', fontsize=plot_fontsize)
plt.show()

Los algoritmos de gradiente descendente funcionan mejor (por ejemplo, convergen más rápido) si las variables están dentro del intervalo \([-1, 1]\). Muchas fuentes relajan el límite hasta \([-3, 3]\). La variable
PRES
es escalada conminmax
para limitar la variable transformada dentro de \([0,1]\).
Observación
Antes de entrenar el modelo, el conjunto de datos se divide en dos partes: el conjunto de entrenamiento y el conjunto de validación. La red neuronal se entrena en el conjunto de entrenamiento. Esto significa que el cálculo de la función de pérdida, la propagación hacia atrás y los pesos actualizados mediante un algoritmo de gradiente descendente se realizan en el conjunto de entrenamiento.
El conjunto de validación se utiliza para evaluar el modelo y determinar el número de épocas en su entrenamiento. Aumentar el número de épocas reducirá aún más la función de pérdida en el conjunto de entrenamiento, pero no necesariamente tendrá el mismo efecto en el conjunto de validación debido al sobreajuste en el conjunto de entrenamiento, por lo que el número de épocas se controla manteniendo una evaluación y verificación sobre la función de pérdida calculada para el conjunto de validación.
Utilizamos
Keras
con el backendTensorflow
para definir y entrenar el modelo. Todos los pasos implicados en el entrenamiento y validación del modelo se realizan llamando a las funciones apropiadas de laAPI
deKeras
.
Los cuatro primeros años, de 2010 a 2013, se utilizan como entrenamiento y 2014 se utiliza para la validación
from sklearn.preprocessing import MinMaxScaler
import datetime
split_date = datetime.datetime(year=2014, month=1, day=1, hour=0)
df_train = df.loc[df['datetime'] < split_date].copy()
df_val = df.loc[df['datetime'] >= split_date].copy()
scaler_pres = MinMaxScaler(feature_range=(0, 1))
df_train['scaled_PRES'] = scaler_pres.fit_transform(df_train[['PRES']])
df_val['scaled_PRES'] = scaler_pres.transform(df_val[['PRES']])
print('Shape of train:', df_train.shape)
print('Shape of val:', df_val.shape)
Shape of train: (35064, 9)
Shape of val: (8760, 9)
df_train.head()
No | year | month | day | hour | pm2.5 | PRES | datetime | scaled_PRES | |
---|---|---|---|---|---|---|---|---|---|
0 | 1 | 2010 | 1 | 1 | 0 | NaN | 1021.0 | 2010-01-01 00:00:00 | 0.545455 |
1 | 2 | 2010 | 1 | 1 | 1 | NaN | 1020.0 | 2010-01-01 01:00:00 | 0.527273 |
2 | 3 | 2010 | 1 | 1 | 2 | NaN | 1019.0 | 2010-01-01 02:00:00 | 0.509091 |
3 | 4 | 2010 | 1 | 1 | 3 | NaN | 1019.0 | 2010-01-01 03:00:00 | 0.509091 |
4 | 5 | 2010 | 1 | 1 | 4 | NaN | 1018.0 | 2010-01-01 04:00:00 | 0.490909 |
df_val.head()
No | year | month | day | hour | pm2.5 | PRES | datetime | scaled_PRES | |
---|---|---|---|---|---|---|---|---|---|
35064 | 35065 | 2014 | 1 | 1 | 0 | 24.0 | 1014.0 | 2014-01-01 00:00:00 | 0.418182 |
35065 | 35066 | 2014 | 1 | 1 | 1 | 53.0 | 1013.0 | 2014-01-01 01:00:00 | 0.400000 |
35066 | 35067 | 2014 | 1 | 1 | 2 | 65.0 | 1013.0 | 2014-01-01 02:00:00 | 0.400000 |
35067 | 35068 | 2014 | 1 | 1 | 3 | 70.0 | 1013.0 | 2014-01-01 03:00:00 | 0.400000 |
35068 | 35069 | 2014 | 1 | 1 | 4 | 79.0 | 1012.0 | 2014-01-01 04:00:00 | 0.381818 |
Restablecemos los índices del conjunto de validación
df_val.reset_index(drop=True, inplace=True)
df_val.head()
No | year | month | day | hour | pm2.5 | PRES | datetime | scaled_PRES | |
---|---|---|---|---|---|---|---|---|---|
0 | 35065 | 2014 | 1 | 1 | 0 | 24.0 | 1014.0 | 2014-01-01 00:00:00 | 0.418182 |
1 | 35066 | 2014 | 1 | 1 | 1 | 53.0 | 1013.0 | 2014-01-01 01:00:00 | 0.400000 |
2 | 35067 | 2014 | 1 | 1 | 2 | 65.0 | 1013.0 | 2014-01-01 02:00:00 | 0.400000 |
3 | 35068 | 2014 | 1 | 1 | 3 | 70.0 | 1013.0 | 2014-01-01 03:00:00 | 0.400000 |
4 | 35069 | 2014 | 1 | 1 | 4 | 79.0 | 1012.0 | 2014-01-01 04:00:00 | 0.381818 |
También se grafican las series temporales de entrenamiento y validación normalizadas para PRES.
plt.figure(figsize=(15, 8))
plt.subplot(1, 2, 1)
g1 = sns.lineplot(df_train['PRES'], color='b')
g1.set_title('Time series of scaled Air Pressure in train set', fontsize=plot_fontsize)
g1.set_xlabel('Index', fontsize=plot_fontsize)
g1.set_ylabel('Scaled Air Pressure readings', fontsize=plot_fontsize);
plt.subplot(1, 2, 2)
g2 = sns.lineplot(df_val['PRES'], color='r')
g2.set_title('Time series of scaled Air Pressure in validation set', fontsize=plot_fontsize)
g2.set_xlabel('Index', fontsize=plot_fontsize)
g2.set_ylabel('Scaled Air Pressure readings', fontsize=plot_fontsize);
plt.show()

Regresores y Variable Objetivo
Ahora necesitamos generar los regresores (\(X\)) y la variable objetivo (\(y\)) para el entrenamiento y la validación. La matriz bidimensional de regresores y la matriz unidimensional objetivo se crean a partir de la matriz unidimensional original de la columna standardized_PRES en el DataFrame.
Para el modelo de predicción de series temporales, de este ejemplo, se utilizan las observaciones de los últimos siete días para predecir el día siguiente. Esto equivale a un modelo \(AR(7)\). Definimos una función que toma la serie temporal original y el número de pasos temporales en regresores como entrada para generar las matrices \(X\) e \(y\)
def makeXy(ts, nb_timesteps):
"""
Input:
ts: original time series
nb_timesteps: number of time steps in the regressors
Output:
X: 2-D array of regressors
y: 1-D array of target
"""
X = []
y = []
for i in range(nb_timesteps, ts.shape[0]):
if i-nb_timesteps <= 4:
print(i-nb_timesteps, i-1, i)
X.append(list(ts.loc[i-nb_timesteps:i-1])) #Regressors
y.append(ts.loc[i]) #Target
X, y = np.array(X), np.array(y)
return X, y
X_train, y_train = makeXy(df_train['scaled_PRES'], 7)
0 6 7
1 7 8
2 8 9
3 9 10
4 10 11
print('Shape of train arrays:', X_train.shape, y_train.shape)
Shape of train arrays: (35057, 7) (35057,)
X_val, y_val = makeXy(df_val['scaled_PRES'], 7)
0 6 7
1 7 8
2 8 9
3 9 10
4 10 11
print('Shape of validation arrays:', X_val.shape, y_val.shape)
Shape of validation arrays: (8753, 7) (8753,)
Ahora definimos la red
MLP
utilizando laAPI
funcional deKeras
. En este enfoque una capa puede ser declarada como la entrada de la siguiente capa en el momento de definir la siguiente
input_layer = Input(shape=(7,), dtype='float32')
En este caso,
Input
es una función que se utiliza para crear una capa de entrada en un modelo de red neuronal.shape=(7,)
específica la forma de los datos de entrada. En este caso, significa que los datos de entrada tendrán 7 dimensiones.dtype='float32'
especifica el tipo de datos de los elementos de la capa de entrada. En este caso, son números de punto flotante de 32 bits.
Las capas densas las definimos en esta caso con activación
tanh
. Puede utilizar unGridSearch
tal como se hizo en el curso de Machine Learning para encontrar hiperparámetros adecuados minimizando las métricas de regresión.
dense1 = Dense(32, activation='tanh')(input_layer)
dense2 = Dense(16, activation='tanh')(dense1)
dense3 = Dense(16, activation='tanh')(dense2)
I0000 00:00:1743899294.415427 1786 cuda_executor.cc:1001] could not open file to read NUMA node: /sys/bus/pci/devices/0000:01:00.0/numa_node
Your kernel may have been built without NUMA support.
I0000 00:00:1743899294.415492 1786 cuda_executor.cc:1001] could not open file to read NUMA node: /sys/bus/pci/devices/0000:01:00.0/numa_node
Your kernel may have been built without NUMA support.
I0000 00:00:1743899294.415514 1786 cuda_executor.cc:1001] could not open file to read NUMA node: /sys/bus/pci/devices/0000:01:00.0/numa_node
Your kernel may have been built without NUMA support.
I0000 00:00:1743899294.573292 1786 cuda_executor.cc:1001] could not open file to read NUMA node: /sys/bus/pci/devices/0000:01:00.0/numa_node
Your kernel may have been built without NUMA support.
I0000 00:00:1743899294.573359 1786 cuda_executor.cc:1001] could not open file to read NUMA node: /sys/bus/pci/devices/0000:01:00.0/numa_node
Your kernel may have been built without NUMA support.
2025-04-05 19:28:14.573367: I tensorflow/core/common_runtime/gpu/gpu_device.cc:2112] Could not identify NUMA node of platform GPU id 0, defaulting to 0. Your kernel may not have been built with NUMA support.
I0000 00:00:1743899294.573404 1786 cuda_executor.cc:1001] could not open file to read NUMA node: /sys/bus/pci/devices/0000:01:00.0/numa_node
Your kernel may have been built without NUMA support.
2025-04-05 19:28:14.573961: I tensorflow/core/common_runtime/gpu/gpu_device.cc:2021] Created device /job:localhost/replica:0/task:0/device:GPU:0 with 9558 MB memory: -> device: 0, name: NVIDIA GeForce RTX 4070, pci bus id: 0000:01:00.0, compute capability: 8.9
Dense e input_layer
Dense
: Correspone a una capa totalmente conectada (fully connected).Unidades (Neurons): 32 neuronas.
dense1
: Esta capa toma como entradainput_layer
, que puede ser la capa de entrada del modelo u otra capa anterior.dense2
: Esta capa toma como entrada la salida dedense1
. Esto significa que los 32 valores de salida dedense1
se usan como entrada paradense2
. Similarmente, ocurre condense3
Observación
Las múltiples capas ocultas y el gran número de neuronas en cada capa oculta le dan a las redes neuronales la capacidad de modelar la compleja no linealidad de las relaciones subyacentes entre los regresores y el objetivo. Sin embargo, las redes neuronales profundas también pueden sobreajustar los datos de entrenamiento y dar malos resultados en el conjunto de validación o prueba. La función
Dropout
se ha utilizado eficazmente para regularizar las redes neuronales profundas.
En este ejemplo, se añade una capa
Dropout
antes de la capa de salida. Dropout aleatoriamente establece \(p\) fracción de neuronas de entrada a cero antes de pasar a la siguiente capa. La eliminación aleatoria de entradas actúa esencialmente como un tipo de ensamblaje de modelos de agregaciónbootstrap
. Al apagar neuronas aleatoriamente,Dropout
está forzando a la red a no depender excesivamente de ninguna unidad específica, mejorando la generalización.Por ejemplo, el bosque aleatorio utiliza el ensamblaje mediante la construcción de árboles en subconjuntos aleatorios de características de entrada. Utilizamos \(p=0.2\) para descartar el 20% de las características de entrada seleccionadas aleatoriamente.
dropout_layer = Dropout(0.2)(dense3)
Por último, la capa de salida predice la presión atmosférica del día siguiente
output_layer = Dense(1, activation='linear')(dropout_layer)
Las capas de entrada, densa y de salida se empaquetarán ahora dentro de un modelo, que es una clase envolvente para entrenar y hacer predicciones. Como función de pérdida se utiliza el error cuadrático medio (MSE). Los pesos de la red se optimizan mediante el algoritmo
Adam
.Adam
significa Estimación Adaptativa de Momentos y ha sido una opción popular para el entrenamiento de redes neuronales profundas.A diferencia del Gradiente descendente Estocástico, Adam utiliza diferentes tasas de aprendizaje para cada peso y las actualiza por separado a medida que avanza el entrenamiento. La tasa de aprendizaje de un peso se actualiza basándose en medias móviles ponderadas exponencialmente de los gradientes del peso y los gradientes al cuadrado.
ts_model = Model(inputs=input_layer, outputs=output_layer)
ts_model.compile(loss='mean_squared_error', optimizer='adam')
ts_model.summary()
Model: "functional"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓ ┃ Layer (type) ┃ Output Shape ┃ Param # ┃ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩ │ input_layer (InputLayer) │ (None, 7) │ 0 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ dense (Dense) │ (None, 32) │ 256 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ dense_1 (Dense) │ (None, 16) │ 528 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ dense_2 (Dense) │ (None, 16) │ 272 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ dropout (Dropout) │ (None, 16) │ 0 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ dense_3 (Dense) │ (None, 1) │ 17 │ └─────────────────────────────────┴────────────────────────┴───────────────┘
Total params: 1,073 (4.19 KB)
Trainable params: 1,073 (4.19 KB)
Non-trainable params: 0 (0.00 B)
Observación
En este caso,
Params #
es calculado mediante la fórmula:Param # = #Entradas x #Neuronas + #Neuronas
. Esto incluye los pesos de cada conexión entre las entradas y las neuronas y un bias para cada neurona. En este casoParams # = 7x32 + 32 = 256
.El modelo se entrena llamando a la función
fit()
en el objeto modelo y pasándoleX_train
yy_train
. El entrenamiento se realiza para un número predefinido de épocas. Además,batch_size
define el número de muestras del conjunto de entrenamiento que se utilizarán para una instancia de backpropagation.
El conjunto de datos de validación también se pasa para evaluar el modelo después de cada
epoch
completa. Un objetoModelCheckpoint
rastrea la función de pérdida en el conjunto de validación y guarda el modelo para la época en la que la función de pérdida ha sido mínima.
save_weights_at = os.path.join('keras_models', 'PRSA_data_Air_Pressure_MLP_weights.{epoch:02d}-{val_loss:.4f}.keras')
save_best = ModelCheckpoint(save_weights_at, monitor='val_loss', verbose=0,
save_best_only=True, save_weights_only=False, mode='min', save_freq='epoch');
Aquí
val_loss
es el valor de la función de coste para los datos de validación cruzada yloss
es el valor de la función de coste para los datos de entrenamiento. Converbose=0
, no se imprime ningún mensaje en la consola durante el proceso de guardado del modelo.period=1
indica que el modelo se evaluará y potencialmente se guardará después de cada época.
import os
from joblib import dump, load
history_airp = None
if os.path.exists('history_airp.joblib'):
history_airp = load('history_airp.joblib')
print("El archivo 'history_airp.joblib' ya existe. Se ha cargado el historial del entrenamiento.")
else:
history_airp = ts_model.fit(x=X_train, y=y_train, batch_size=16, epochs=20,
verbose=2, callbacks=[save_best], validation_data=(X_val, y_val),
shuffle=True);
dump(history_airp.history, 'history_airp.joblib')
print("El entrenamiento se ha completado y el historial ha sido guardado en 'history_airp.joblib'.")
El archivo 'history_airp.joblib' ya existe. Se ha cargado el historial del entrenamiento.
En este caso, el modo
verbose=2
muestra una barra de progreso por cada época. Los modos posibles son:0
para no mostrar nada,1
para mostrar la barra de progreso,2
para mostrar una línea por época. Las muestras se mezclan aleatoriamente antes de cada época (shuffle=True
).
Se hacen predicciones para la presión atmosférica a partir del mejor modelo guardado. Las predicciones del modelo sobre la presión atmosférica escalada, se transforman inversamente para obtener predicciones sobre la presión atmosférica original. También se calcula la bondad de ajuste o
R cuadrado
.
import os
import re
from tensorflow.keras.models import load_model
model_dir = 'keras_models'
files = os.listdir(model_dir)
pattern = r"PRSA_data_Air_Pressure_MLP_weights\.(\d+)-([\d\.]+)\.keras"
best_val_loss = float('inf')
best_model_file = None
best_model = None
for file in files:
match = re.match(pattern, file)
if match:
epoch = int(match.group(1))
val_loss = float(match.group(2))
if val_loss < best_val_loss:
best_val_loss = val_loss
best_model_file = file
if best_model_file:
best_model_path = os.path.join(model_dir, best_model_file)
print(f"Cargando el mejor modelo: {best_model_file} con val_loss: {best_val_loss}")
best_model = load_model(best_model_path)
else:
print("No se encontraron archivos de modelos que coincidan con el patrón.")
Cargando el mejor modelo: PRSA_data_Air_Pressure_MLP_weights.03-0.0001.keras con val_loss: 0.0001
preds = best_model.predict(X_val)
pred_PRES = scaler_pres.inverse_transform(preds)
pred_PRES = np.squeeze(pred_PRES)
WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
I0000 00:00:1743899295.688420 4510 service.cc:146] XLA service 0x7f473c005c30 initialized for platform CUDA (this does not guarantee that XLA will be used). Devices:
I0000 00:00:1743899295.688447 4510 service.cc:154] StreamExecutor device (0): NVIDIA GeForce RTX 4070, Compute Capability 8.9
2025-04-05 19:28:15.700143: I tensorflow/compiler/mlir/tensorflow/utils/dump_mlir_util.cc:268] disabling MLIR crash reproducer, set env var `MLIR_CRASH_REPRODUCER_DIRECTORY` to enable.
2025-04-05 19:28:15.853743: I external/local_xla/xla/stream_executor/cuda/cuda_dnn.cc:531] Loaded cuDNN version 8907
257/274 ━━━━━━━━━━━━━━━━━━━━ 0s 590us/step
I0000 00:00:1743899296.107701 4510 device_compiler.h:188] Compiled cluster using XLA! This line is logged at most once for the lifetime of the process.
274/274 ━━━━━━━━━━━━━━━━━━━━ 1s 1ms/step
r2 = r2_score(df_val['PRES'].loc[7:], pred_PRES)
print('R-squared for the validation set:', round(r2,4))
R-squared for the validation set: 0.9955
plt.figure(figsize=(5.5, 5.5))
plt.plot(range(50), df_val['PRES'].loc[7:56], linestyle='-', marker='*', color='r')
plt.plot(range(50), pred_PRES[:50], linestyle='-', marker='.', color='b')
plt.legend(['Actual','Predicted'], loc=2)
plt.title('Actual vs Predicted Air Pressure')
plt.ylabel('Air Pressure')
plt.xlabel('Index');

10.14. Redes Neuronales Convolucionales#
Introducción
Hasta ahora, las redes neuronales se han considerado como receptoras de vectores de características en su capa de entrada, similar a otros clasificadores discutidos anteriormente. Estos vectores se derivan de datos brutos para condensar información relevante para la tarea de aprendizaje automático. Sin embargo, a finales de los años 80, surgió un enfoque alternativo: integrar la generación de características en el entrenamiento de la red neuronal.
Así nacieron las Redes Neuronales Convolucionales (CNN), que aprenden características directamente de los datos junto con los parámetros de la red. Su éxito inicial fue en el reconocimiento de dígitos OCR [LeCun et al., 1989]. El término “convolucional” se refiere a que la primera capa realiza convoluciones en lugar de productos internos, usados en redes totalmente conectadas.
10.14.1. La necesidad de convoluciones#
Asegurémonos primero de que entendemos la razón por la que no podemos, en la práctica, alimentar la entrada de una red neuronal directamente con datos brutos, por ejemplo, una matriz de imágenes o las muestras de una versión digitalizada de un segmento de voz, y por qué es necesario un preprocesamiento para generar características. Lo mismo ocurre para cualquier predictor/aprendiz y no sólo para las redes neuronales. En muchas aplicaciones, trabajar directamente con los datos en bruto hace que la tarea sea sencillamente inmanejable.

Una imagen de \(256 \times 256\) se vectoriza como \(\boldsymbol{x} \in \mathbb{R}^{65000}\). Si la primera capa tiene \(k_1 = 1000\) nodos, una red totalmente conectada requeriría aproximadamente 65 millones de parámetros \(\theta_{jk}\), con \(j = 1, \dots, 65000\) y \(k = 1, \dots, 1000\). Esta cantidad crece considerablemente con imágenes de mayor resolución (\(1000 \times 1000\)) o a color, donde la entrada se triplica al emplear el modelo RGB.
A medida que se incrementan las capas ocultas, también crece el número de parámetros, lo que eleva la carga computacional y compromete la capacidad de generalización, requiriendo grandes volúmenes de datos para evitar el sobreajuste. Además, vectorizar matrices de imágenes implica pérdida de información, al ignorar relaciones espaciales entre píxeles. Por ello, los métodos de generación de características buscan extraer dependencias estadísticas que capturen dichas correlaciones y permitan codificar eficazmente la información relevante para el aprendizaje.
Al emplear convoluciones, se pueden abordar simultáneamente ambos problemas, es decir, el de la explosión de parámetros y el de la extracción de información estadística útil. Los pasos básicos de cualquier red convolucional son:
la etapa de convolución,
el paso de no linealidad,
el paso de agrupación.
10.14.2. La etapa de convolución#
Una forma de reducir el número de parámetros es mediante el reparto de pesos, como se ha comentado al final de la sección Redes Totalmente Conectadas. Ahora tomaremos prestada esta idea del reparto de pesos y la utilizaremos de una forma más sofisticada. Para ello, nos centraremos en el caso en que la entrada de la red esté formada por imágenes. La imagen matricial de entrada se denomina \(I\).

Fig. 10.26 (A) Imagen matricial de entrada de \(3\times 3\). (B) Nodos de la capa oculta.#
Para el caso de la Fig. 10.26, se trata de una matriz de 3 × 3; nótese que la entrada es no vectorizada. Para enfatizar que nos apartaremos de la lógica de las operaciones de multiplicar-añadir (producto interno) de las redes feed-forward completamente conectadas, utilizaremos un símbolo diferente \(h\) en lugar de \(\theta\), para denotar los parámetros asociados.
Introducimos ahora el concepto de reparto de pesos. Recordemos que en una red totalmente conectada, cada nodo está asociado con un vector de parámetros, \(\boldsymbol{\theta}_{i}\), para el nodo \(i\)th cuya dimensionalidad es igual al número de nodos de la capa anterior. En cambio, ahora cada nodo estará asociado a un único parámetro. Para ello, dispondremos los nodos en forma de matriz bidimensional, como se muestra en Fig. 10.26 (B). Para el caso de la figura, hemos supuesto cuatro nodos dispuestos en una matriz de 2 × 2, \(H\).
Observación
El primer nodo se caracteriza por \(h(1, 1)\), el segundo por \(h(1, 2)\), y así sucesivamente. En otras palabras, cualquier conexión que termine en el primer nodo se multiplicará por el mismo peso \(h(1, 1)\), y un argumento similar es válido para el resto de los nodos. Este razonamiento reduce drásticamente el número de parámetros. Sin embargo, para que esto tenga sentido, tenemos que alejarnos de la lógica de las operaciones de producto interior de las redes totalmente conectadas.
Para entender por qué, supongamos que utilizamos un único parámetro por nodo en una red totalmente conectada. Entonces, la salida del combinador lineal asociado al primer nodo sería \(O(1, 1) = h(1, 1)a\), donde \(a\) es la suma de todas las entradas al nodo recibidas de la capa anterior. La salida respectiva del segundo nodo sería \(O(1, 2) = h(1, 2)a\), y así sucesivamente. Por lo tanto, todos los nodos proporcionarían básicamente la misma información con respecto a los valores de entrada; la única diferencia serían los distintos pesos que actúan sobre la misma información de entrada de la capa anterior.
Pasemos ahora a introducir un concepto diferente, en el que mantenemos un único parámetro por nodo, aunque cada una de las salidas de una capa oculta transmite información diferente con respecto a las distintas entradas que se reciben de la capa anterior.
Para ello, introduciremos las convoluciones. En este contexto, los nodos de la capa oculta se interpretan como elementos de una matriz \(H\), y convolucionamos \(H\) con la matriz de entrada entrada \(I\). El primer valor de salida de la capa oculta será
El resultado anterior se obtiene si colocamos la matriz \(H\) de \(2\times2\) sobre \(I\), empezando por la esquina superior izquierda (el cuadrado rojo de Fig. 10.26(A) indica la posición de la matriz \(H\)). A continuación, multiplicamos los elementos en las partes superpuestas de las dos matrices y los sumamos. Desde un punto de vista físico, el valor \(O(1, 1)\) resultante es una media ponderada sobre un área local dentro de la matriz \(I\).
En la operación anterior, el área correspondiente de la imagen está formada por los píxeles de la parte superior izquierda 2 × 2 de la matriz \(I\). Para obtener el segundo valor de salida, \(O(1, 2)\), deslizamos \(H\) un píxel hacia la derecha, como indica el recuadro rojo punteado, y repetimos las operaciones, es decir
Siguiendo el mismo razonamiento, deslizamos \(H\) para “escanear” toda la imagen matricial; así, se obtienen otros dos valores de salida \(O(2, 1)\) y \(O(2, 2)\). Las cuatro posiciones posibles de \(H\) encima de \(I\) se indican en la Fig. 10.26(A) por los cuadros cuadrados de color rojo completo, rojo punteado, gris oscuro y gris punteado. Para cada posición se obtiene un valor de salida. Por lo tanto, bajo el escenario descrito anteriormente, las salidas de la primera capa oculta forman una matriz de \(2\times 2\), \(O\). Cada uno de los elementos de la matriz de salida codifica información de un área diferente de la imagen de entrada.
Convolución
En el entorno más general, la operación de convolución entre dos matrices, \(H\in R^{m\times m}\) e \(I\in\mathbb{R}^{l\times l}\), es otra matriz, definida como
En otras palabras, \(O(i, j)\) contiene información en un área de ventana de la matriz de entrada. De acuerdo con la definición de la Eq. (10.11) el elemento \(I(i, j)\) es el elemento superior izquierdo de esta área de la ventana. El tamaño de la ventana depende del valor de \(m\). El tamaño de la matriz de salida depende de las suposiciones que se adopten sobre cómo tratar los elementos/píxeles en los bordes de \(I\).
En sentido estricto, en la jerga del procesamiento de señales, la Eq. (10.11) se conoce como operación de correlación cruzada. Para la operación de convolución, primero hay que invertir los índices. Sin embargo, este es el nombre que ha “sobrevivido” en la comunidad del aprendizaje automático y bien nos adherimos a él. Al fin y al cabo, ambas son operaciones ponderadas sobre los píxeles dentro de un área de ventana de una imagen.
Observation 10.1
La discusión anterior nos “obliga” a pensar en una capa oculta como una colección de nodos uno al lado del otro. En cambio, en una CNN, cada capa oculta corresponde a una (o a más de una, como pronto veremos) matriz \(H\). Además, \(H\) se utiliza para realizar convoluciones.
Desde el punto de vista del tratamiento de señales, esta matriz es un
filtro que actúa sobre la entrada para proporcionar la salida.
En la jerga del aprendizaje de maquinas, también se denomina matriz kernel en lugar de filtro. La matriz de salida suele denominarse la matriz del mapa de características.
En resumen, al realizar convoluciones, en lugar de operaciones de producto interno, hemos conseguido
Los parámetros que componen la capa oculta son compartidos por todos los píxeles de entrada y no tenemos un conjunto dedicado de parámetros por elemento de entrada (píxel)
Las salidas de la capa oculta
codifican la información de correlación de vecindad local de las distintas zonas de la imagen de entrada
.Además, como la salida de la capa oculta también es una matriz de imágenes, se puede considerar como la entrada a una segunda capa oculta y construir así una red con muchas capas, cada una de las cuales realiza convoluciones.
De hecho, estas operaciones de filtrado se han utilizado tradicionalmente para generar características a partir de imágenes. La diferencia era que los elementos de la matriz de filtrado se preseleccionaban. Tomemos ejemplo, la siguiente matriz:
El filtro anterior se conoce como detector de bordes. Convolucionando una matriz de imagen, \(I\) , con la matriz anterior, \(H\),detecta los bordes de una imagen. La Fig. 10.27A muestra la imagen de un barco y la Fig. 10.27B muestra la salida después del filtrado de la imagen de la izquierda con la matriz de filtrado \(H\) anterior.

Fig. 10.27 (A) Imagen original y (B) Bordes de la imagen extraídos tras filtrar la matriz de la imagen original con el filtro \(H\) de la ecuación Eq. (10.12). Fuente [Theodoridis, 2020].#
La detección de bordes es de gran importancia en la comprensión de imágenes. Además, cambiando adecuadamente los valores en \(H\), se pueden detectar bordes en diferentes orientaciones, por ejemplo, diagonal, vertical, horizontal; en otras palabras, cambiando los valores de \(H\) se pueden generar diferentes tipos de características.
from skimage.color import rgb2gray
from skimage.io import imread
import numpy as np
from scipy import signal
import matplotlib.pylab as pylab
im = rgb2gray(imread('imgs/cameraman.jpg')).astype(float)
print(im.shape, np.max(im))
(256, 256) 0.9921568627450982
blur_box_kernel = np.ones((3,3)) / 9
edge_laplace_kernel = np.array([[0,1,0],[1,-4,1],[0,1,0]])
edge_laplace_kernel
array([[ 0, 1, 0],
[ 1, -4, 1],
[ 0, 1, 0]])
im_blurred = signal.convolve2d(im, blur_box_kernel)
im_edges = np.clip(signal.convolve2d(im, edge_laplace_kernel), 0, 1)
fig, axes = pylab.subplots(ncols=3, sharex=True, sharey=True, figsize=(18, 6))
axes[0].imshow(im, cmap=pylab.cm.gray)
axes[0].set_title('Original Image', size=20)
axes[1].imshow(im_blurred, cmap=pylab.cm.gray)
axes[1].set_title('Box Blur', size=20)
axes[2].imshow(im_edges, cmap=pylab.cm.gray)
axes[2].set_title('Laplace Edge Detection', size=20)
for ax in axes:
ax.axis('off')

Nos hemos acercado a la idea que subyace a las CNN
En lugar de utilizar una matriz de filtro/núcleo fija, como en el ejemplo del detector de bordes,
deje el cálculo de los valores de la matriz de filtro
, \(H\) ,para la fase de entrenamiento
. En otras palabras, hacemos que \(H\) se adapte a los datos y no preseleccionada.En lugar de utilizar una única matriz de filtros, empleamos más de una. Cada una de ellas generará un tipo diferente de características. Por ejemplo, una puede generar bordes diagonales, la otra horizontales, etc. Por lo tanto, cada capa oculta comprenderá más de una matriz de filtrado. Los valores de los elementos de cada una de
las matrices de filtrado se calcularán durante la fase de entrenamiento, optimizando algún criterio
. En otras palabras, cada capa oculta de una CNN genera un conjunto de características de forma óptima.

Fig. 10.28 Ilustración de tres filtros/núcleos. Profundidad de la capa oculta (número de filtro) es tres. Fuente [Theodoridis, 2020].#
La Fig. 10.28 ilustra la entrada y la primera capa oculta de una CNN. La entrada comprende una matriz imagen. La capa oculta consta de tres matrices de filtrado, a saber, \(H_{1}, H_{2}, H_{3}\). Nótese que cada matriz es el resultado de convolucionar una matriz de filtros diferente sobre la imagen de entrada. Cuantos más filtros se empleen, más mapas de características se extraerán y, en principio, mejor será el rendimiento de la red.
Sin embargo, cuantos más filtros utilicemos, más parámetros habrá que aprender, lo que plantea problemas computacionales y de sobreajuste. Nótese que cada píxel de una matriz de mapeo de características de salida codifica información dentro del área de la ventana que está definida por la posición correspondiente de la respectiva matriz de filtro.
10.14.3. Ejemplo de uso de Múltiples Kernels en una CNN#
En una Red Neuronal Convolucional (CNN), cada kernel se aplica simultáneamente a la imagen de entrada, generando un mapa de características (feature map).
Proceso de Aplicación
Cada kernel detecta distintos patrones como bordes o texturas.
Cada filtro genera su propio mapa de características, reduciendo dimensiones según el tamaño del kernel, stride y padding.
El resultado es un volumen de salida de tamaño \(H' \times W' \times N\), donde \(N\) es el número de filtros (kernels).
Ejemplo con Tres Kernels: Si una imagen RGB de \(32 \times 32 \times 3\) se procesa con tres kernels de \(3 \times 3 \times 3\), la salida será: \(30 \times 30 \times 3\)
Conclusión: Cada kernel opera en paralelo, generando su propio mapa de características. El número de filtros define la profundidad de la salida. Las dimensiones dependen de los hiperparámetros de convolución.
Observation 10.2
Una característica importante de una CNN es que la invarianza de traslación (
reconoce patrones en una imagen sin importar dónde se encuentren esos patrones
) está integrada de forma natural en la red y es un subproducto de las circunvoluciones implicadas. De hecho, estas últimas se realizan deslizando la misma matriz de filtros sobre toda la imagen.Así, si un objeto presente en una imagen se coloca en otra posición, la única diferencia sería que la contribución de este objeto a la salida también se desplazará la misma cantidad en el número de píxeles.
Es interesante señalar que existen pruebas sólidas en el campo de la neurociencia visual de que en el ser humano se realizan cálculos similares. (ver [Hubel and Wiesel, 1962, Serre et al., 2007]). La noción de convoluciones fue usada inicialmente por [Fukushima et al., 1983] en el contexto del aprendizaje no supervisado.
A continuación, presentamos algunos términos de la jerga utilizada en relación con las CNN:
Profundidad
: La profundidad de una capa es el número de matrices de filtro que se emplean en esta capa. No debe confundirse profundidad de la red, que corresponde al número total de capas ocultas utilizadas. A veces, nos referimos al número de filtros como el número de canales.Campo receptivo
: Cada píxel de una matriz de características de salida resulta como una media ponderada de los píxeles dentro de un área específica de la matriz imagen de entrada (o de la salida de la capa anterior). El área específica que corresponde a un píxel se conoce como su campo receptivo (ver Fig. 10.28).Deslizamiento
(stride): En la práctica, en lugar de deslizar la matriz de filtros de píxel en píxel, se puede deslizar, por ejemplo, \(s\) píxeles. Este valor se conoce como stride. Para valores de \(s > 1\), se obtienen matrices de mapas de características de menor tamaño. Esto se ilustra en Fig. 10.29A y B.

Fig. 10.29 Matriz de entrada y filtro de tamaños 5 × 5 y 3 × 3 respectivamente. En (A), el paso es igual a \(s = 1\) y en (B) es igual a \(s = 2\). En (A) la salida es una matriz de tamaño 3 × 3 y en (B) de tamaño 2 × 2. Fuente [Theodoridis, 2020].#
Relleno de ceros
(padding): A veces, se utilizan ceros para rellenar la matriz de entrada alrededor de los píxeles del borde. De esta forma, la dimensión de la matriz aumenta. Si la matriz original tiene dimensiones \(l\times l\), después de expandirla con \(p\) columnas y filas, las nuevas dimensiones pasan a ser (\(l + 2p\)). Esto se muestra en la Fig. 10.30.

Fig. 10.30 Ejemplo en el que la matriz original es de 5 × 5 y tras rellenarla con \(p = 2\) filas y columnas, su tamaño pasa a ser de 9 × 9. Fuente [Theodoridis, 2020].#
Término de sesgo
: Tras cada convolución que genera un píxel en el mapa de características, se añade un sesgo común a todos los píxeles de dicho mapa, calculado durante el entrenamiento. Esto sigue la lógica de compartir parámetros, como ocurre con los filtros en la imagen de entrada..
Se puede ajustar el tamaño de una matriz de mapa de características de salida ajustando el valor del stride, \(s\), y el número de columnas y filas cero adicionales en el relleno. En general, se puede comprobar fácilmente que si \(I\in\mathbb{R}^{l\times l}, H\in\mathbb{R}^{m\times m}\), y \(p\) es el número de filas y columnas adicionales para el relleno, entonces el mapa de características tiene dimensiones \(k\times k\), donde
(10.13)#\[ k=\lfloor\frac{l+2p-m}{s}+1\rfloor, \]y \(\lfloor\cdot\rfloor\) es el operador suelo, i.e \(\lfloor2.7\rfloor=2\).
Por ejemplo, si \(l = 5, m = 3, p = 0\) y \(s = 1\), entonces \(k = 3\). Por otro lado, si \(l = 5, m = 3, p = 0\) y \(s = 2\), entonces \(k = 2\) (ver Fig. 10.29A y B). Nótese que si los valores de \(l, m, p\) y \(s\) son tales que la matriz de filtrado, al deslizarse sobre \(I\), cae fuera de \(I\), estas operaciones no se realizan. Solo realizamos operaciones mientras la matriz filtro esté contenida dentro de \(I\).
10.14.4. El paso de la no linealidad#
Una vez que se han realizado las convoluciones y se ha añadido el término de sesgo a todos los valores del mapa de características, el siguiente paso es
aplicar una no linealidad (función de activación) a cada uno de los píxeles de cada matriz
de mapas de características.Se puede emplear cualquiera de las no linealidades que se han comentado anteriormente. Actualmente, la función de activación lineal rectificada,
ReLU
, es la más popular.
Show code cell source
import numpy as np
import matplotlib.pyplot as plt
def relu(x):
return np.maximum(0, x)
x = np.linspace(-10, 10, 100)
y = relu(x)
plt.plot(x, y, label='ReLU', color='b')
plt.axhline(0, color='black', linewidth=0.5)
plt.axvline(0, color='black', linewidth=0.5)
plt.grid(True, linestyle='--', alpha=0.6)
plt.legend()
plt.show()

Importancia de ReLU
La función de activación más utilizada en CNN es ReLU (Rectified Linear Unit) por las siguientes razones:
Evita el gradiente desvaneciente: A diferencia de sigmoide o tangente hiperbólica, mantiene gradientes significativos en redes profundas, facilitando el entrenamiento.
Eficiencia computacional: Su cálculo \(\max(0, x)\) es más simple que funciones exponenciales, reduciendo la carga de procesamiento.
Favorece la esparsidad: Al asignar cero a valores negativos, desactiva neuronas, mejorando eficiencia y generalización.
Acelera el entrenamiento: Su linealidad en valores positivos y bajo costo computacional permiten una convergencia más rápida.
No obstante, puede generar neuronas muertas cuando ciertos pesos anulan permanentemente la activación. Para evitarlo, existen variantes como Leaky ReLU y PReLU.
La Fig. 10.31A muestra la imagen obtenida después de filtrar la imagen original del barco con el detector de bordes en la (10.12) y la Fig. 10.31B muestra el resultado que se obtiene tras la aplicación de la no linealidad en cada píxel individual.

Fig. 10.31 (A) Imagen donde se han extraído los bordes y (B) imagen resultante tras aplicar el en cada uno de los píxeles. Fuente [Theodoridis, 2020].#
10.14.5. La etapa de agrupación#
Pooling espacial
: Este proceso reduce la dimensionalidad de las matrices de mapas de características. Se emplea una ventana deslizante, cuyo desplazamiento se define mediante un parámetro stride. La operación selecciona un único valor que representa todos los píxeles dentro de la ventana.La operación más común es la agrupación máxima (max pooling), donde se selecciona el píxel de mayor valor dentro de la ventana. Otra opción es la agrupación promedio (average pooling), que toma el valor medio de los píxeles, también conocida como pooling de suma. En la ilustración, una matriz de 6 × 6 se reduce aplicando una ventana de 2 × 2 con un stride de 2.

Fig. 10.32 (A) Matriz original de tamaño de 6 × 6. Agrupación mediante una ventana de 2 × 2 y un stride \(s = 2\). Valor máximo por ubicación de la ventana se indica en negrita. (B) Matriz 3 × 3 resultante tras la agrupación máxima. Fuente [Theodoridis, 2020].#
La misma fórmula de la Eq. (10.13) permite calcular el tamaño de la matriz resultante en el caso general. La agrupación reduce la dimensionalidad y el tamaño de las matrices, lo que es crucial, ya que cada salida se convierte en la entrada de la siguiente capa. Controlar el tamaño es esencial para gestionar el número de parámetros sin comprometer excesivamente la información.

Fig. 10.33 (A) Bordes de la imagen del barco tras la aplicación de ReLu
(B) Imagen resultante tras aplicar max-pooling
utilizando una ventana de 8 × 8. Fuente [Theodoridis, 2020].#
Observación
Fig. 10.33 muestra el efecto de aplicar el pooling a la imagen de la izquierda. Sin duda, los bordes se vuelven más gruesos, pero la información relacionada con los bordes puede extraerse. Nótese que después de la agrupación, el tamaño de la matriz imagen es reducido.
Desde otro punto de vista, el polling resume las estadísticas dentro del área pooling. El pooling puede considerarse un tipo especial de
filtrado, en el que, en lugar de la convolución, se selecciona el valor máximo (o medio) de la imagen
. El pooling ayuda a que la representación sea aproximadamente invariante a pequeñas traslaciones de la entrada.
10.14.6. Convolución sobre volúmenes#
Observación
En la Fig. 10.28, la primera capa oculta genera tres matrices de imágenes como entrada para la siguiente capa, similar a la representación RGB de imágenes en color, donde cada matriz indica la intensidad de un color en cada píxel con valores de 0 a 255.
Rojo (R) |
Verde (G) |
Azul (B) |
Color resultante |
---|---|---|---|
255 |
0 |
0 |
🔴 Rojo puro |
0 |
255 |
0 |
🟢 Verde puro |
0 |
0 |
255 |
🔵 Azul puro |
255 |
255 |
0 |
🟡 Amarillo (Rojo + Verde) |
0 |
255 |
255 |
🔵 Cian (Verde + Azul) |
255 |
0 |
255 |
🟣 Magenta (Rojo + Azul) |
255 |
255 |
255 |
⚪ Blanco (Máxima intensidad) |
0 |
0 |
0 |
⚫ Negro (Sin intensidad) |
Las entradas no son matrices bidimensionales, sino conjuntos de matrices bidimensionales, conocidos en matemáticas como tensores tridimensionales o volúmenes. Se adopta este último término por su relación con la geometría. El desafío radica en definir cómo realizar convoluciones en estos volúmenes.
import numpy as np
import matplotlib.pyplot as plt
imagen = np.array([
[[255, 0, 0], [0, 255, 0], [0, 0, 255]], # Rojo, Verde, Azul
[[255, 255, 0], [0, 255, 255], [255, 0, 255]], # Amarillo, Cian, Magenta
[[255, 255, 255], [128, 128, 128], [0, 0, 0]] # Blanco, Gris, Negro
], dtype=np.uint8)
canal_rojo = imagen[:, :, 0] # Matriz de valores R
canal_verde = imagen[:, :, 1] # Matriz de valores G
canal_azul = imagen[:, :, 2] # Matriz de valores B
plt.imshow(imagen)
plt.show()

print("Matriz del canal Rojo (R):\n", canal_rojo)
print("Matriz del canal Verde (G):\n", canal_verde)
print("Matriz del canal Azul (B):\n", canal_azul)
Matriz del canal Rojo (R):
[[255 0 0]
[255 0 255]
[255 128 0]]
Matriz del canal Verde (G):
[[ 0 255 0]
[255 255 0]
[255 128 0]]
Matriz del canal Azul (B):
[[ 0 0 255]
[ 0 255 255]
[255 128 0]]

Fig. 10.34 Se apilan \(d\) matrices de tamaño \(h\times w\) cada una para formar un volumen de tamaño \(h \times w\times d\). En este caso, \(h = w = 5\) y \(d = 3\). Fuente [Theodoridis, 2020].#
Por convención, las tres dimensiones de un volumen se representarán como \(h\) para la altura, \(w\) para la anchura y \(d\) para la profundidad. Nótese que la profundidad \(d\) corresponde al número de imágenes implicadas. Así pues, si tenemos tres imágenes de 256 × 256, entonces \(h = w = 256\) y \(d = 3\) y diremos que el volumen es de tamaño (dimensión) 256 × 256 × 3. Fig. 10.34 ilustra la geometría asociada a las respectivas definiciones.
Convolución de volúmenes
En una capa con entrada de dimensiones \(h \times w \times d\), los filtros de las capas ocultas también son volúmenes, pero deben tener la misma profundidad \(d\) que la entrada, aunque su altura y anchura pueden variar.
Si la entrada es un volumen \(\boldsymbol{I}\) de \(l \times l \times d\), este consta de \(d\) imágenes \(I_r\) de \(l \times l\). Un filtro \(\boldsymbol{H}\) de \(m \times m \times d\) también se compone de \(d\) imágenes \(H_r\) de \(m \times m\). La convolución se define a partir de estos elementos.
Convolucionar las correspondientes matrices de imágenes bidimensionales para generar \(d\) matrices bidimensionales de salida, es decir
La convolución de los dos volúmenes, \(\boldsymbol{I}\) y \(\boldsymbol{H}\), se define como
\[ O=\sum_{r=1}^{d}O_{r}. \]En otras palabras, la
convolución (denotada por
\(\star\))de dos volúmenes es una matriz bidimensional
, es decir,\[ \text{3D volume}\star\text{3D volume}=\text{2D array}. \]

Fig. 10.35 Convolución \(\text{3D volume}\star\text{3D volume}=\text{2D array}\). \(h = w = l, h = w = m\) y \(d = 3\).#
La operación se ilustra en la Fig. 10.35. Las matrices correspondientes (mostradas por diferentes colores y tipos de líneas). Las tres matrices de salida (\(d = 3\)) se suman posteriormente para formar la convolución de los dos volúmenes. La dimensión \(k\) de la salida depende de los valores de \(l\) y \(m\), del stride \(s\) y del padding \(p\), si se utiliza, según la Eq. (10.13).
Observación
En la práctica, cada capa de una CNN comprende varios de estos volúmenes de filtrado. Por ejemplo, si la entrada a una capa es un volumen \(l\times l\times d\), y hay, digamos, \(c\) volúmenes de núcleo, cada uno de dimensiones \(m\times m\times d\), la salida de la capa será un volumen \(k\times k\times c\), donde \(k\) se determina la Eq. (10.13).
10.14.7. Red en red y convolución 1 × 1#
Observación
La convolución 1 × 1 no tiene sentido cuando se trata de matrices bidimensionales. En efecto una matriz de filtro 1 × 1 es un escalar. Convolucionar una matriz \(I\) de \(l\times l\) con un escalar \(a\) equivale a deslizar el valor escalar sobre todos los píxeles y multiplicar cada uno de ellos por \(a\). El resultado es la trivial \(aI\) .
Sin embargo, cuando se trata de volúmenes, la convolución 1 × 1 tiene sentido. En este caso, el filtro correspondiente, \(\boldsymbol{H}\), es un volumen de tamaño \(1\times 1\times d\). Geométricamente, se trata de un “tubo”, con \(h = w = 1\) y \(d\) elementos en profundidad, \(h(1, 1, r), r = 1, 2,\dots,d\). Por lo tanto, el resultado de la convolución de un volumen \(\boldsymbol{I}\) de \(l\times l\times d\) con un \(1\times 1\times d\) volumen \(H\) es la media ponderada,
\[ O=\boldsymbol{I}\star\boldsymbol{H}=\sum_{r=1}^{d}h(1, 1, r)I_{r} \]donde \(I_{r},~r=1,2,\dots,d,\) son las \(d\) matrices, cada una de dimensiones \(l\times l\), que comprende \(\boldsymbol{I}\).
Convolución \(1\times 1\)
Ahora bien, cabe preguntarse por qué necesitamos una operación de este tipo en la práctica. La respuesta está relacionada con el tamaño de los volúmenes implicados; mediante el uso de convoluciones \(1\times 1\), se puede
controlar y cambiar su tamaño para adaptarlo a las necesidades de la red
.Supongamos que en una etapa/capa de una red profunda hemos obtenido un volumen \(\boldsymbol{I}\) de dimensiones \(k\times k\times d\). Para cambiar la profundidad de \(d\) a \(c\), conservando el mismo tamaño \(k\), para la altura y la anchura, empleamos \(c\) volúmenes, \(H_{t}, t = 1, 2,\dots,c\), cada uno de dimensiones \(1\times 1\times d\). Al realizar las \(c\) convoluciones obtenemos
\[ O_{t}=\boldsymbol{I}\star\boldsymbol{H}_{t}=\sum_{r=1}^{d}h_{t}(1, 1, r)I_{r},~t=1,2,\dots,c. \]Apilando \(O_{t}, t = 1, 2,\dots,c\), obtenemos el volumen \(\boldsymbol{O}\) de dimensión \(k\times k\times c\) (ver Fig. 10.36).
La información original se sigue conservando en el nuevo volumen, de forma promediada. A menudo, una vez obtenido el nuevo volumen \(\boldsymbol{O}\), sus elementos se “empujan” a través de una no linealidad, por ejemplo,
ReLU
.La convolución \(1\times 1\) seguida de la no linealidad se denomina
operación de red en red
y su finalidad es añadir una etapa de no linealidad adicional en el flujo de operaciones a través de la red. Por lo tanto, en este contexto, si \(c<d\), la operación de red en red puede considerarse una técnica de reducción de la dimensionalidad no lineal.

Fig. 10.36 Convolución \(1\times 1\), con \(c\) tubos de dimensión \(1\times 1\times d\), donde \(d\) es la profundidad del volumen de entrada. Fuente [Theodoridis, 2020].#
Ejemplo
Consideremos que la entrada de una capa es un volumen \(\boldsymbol{I}\) de 28 × 28 × 192. El objetivo es producir a la salida de la capa un volumen, \(O\), de dimensión 28 × 28 × 32. Para ello emplear 5 × 5 convoluciones iguales y un volumen intermedio \(\boldsymbol{O}'\) de dimensiones \(28\times 28\times 16\). Nótese que en la última capa se usó padding de 2 píxeles en cada borde.
Observe que, rellenando todas las matrices apiladas en \(\boldsymbol{I}\) con \(p\) cero columnas y filas, se tiene que el número de multiplicaciones y adiciones (MADS) es \(3.7\times 10^{6}\times 32\approx 120~\) millones MADS. Además, usando convoluciones \(1\times 1\), este número se reduce a \(28^{2}\times 25\times 16\times 32\approx 10\) millones MADS (ver [Theodoridis, 2020]).
A menudo, el volumen intermedio, \(\boldsymbol{O}'\), se conoce como capa
cuello de botella (bottleneck layer)
; su función es “reducir” primero el tamaño del volumen de entrada, antes de obtener el volumen de salida final (ver Fig. 10.37).

Fig. 10.37 Capa cuello de botella. Fuente [Theodoridis, 2020].#
10.14.8. Arquitectura CNN completa#
La forma típica de una red convolucional completa consiste en una
secuencia de capas convolucionales
, cada una de las cuales que comprende los tres pasos básicos, a saber,convolución (Conv.), no linealidad (ReLU) y agrupación (Pooling)
, como se describe al principio de esta sección. Dependiendo de la aplicación,se pueden apilar tantas capas como sea necesario
, donde la salida de una capa se convierte en la entrada de la siguiente. Las entradas y salidas de cada capa son volúmenes, como se ha descrito anteriormente.

Fig. 10.38 Arquitectura CNN completa. Fuente [Theodoridis, 2020].#
Procedimiento de una CNN completa
En la primera capa, se aplican filtros para realizar convoluciones seguidas de una transformación no lineal. Luego, la operación de pooling reduce las dimensiones de la salida, que sirve como entrada para la siguiente capa. Este proceso se repite hasta que el volumen final se vectoriza, en un paso conocido como flattening.
El resultado es un vector de características obtenido a partir de las transformaciones aplicadas en cada capa convolucional. Este vector se utiliza como entrada para un modelo de aprendizaje, como una red neuronal totalmente conectada o una máquina de vectores de soporte.
Observación
La estrategia consiste en reducir dimensiones mientras se incrementa la profundidad, aumentando filtros por etapa para extraer más características. La cantidad de capas convolucionales y totalmente conectadas depende de la aplicación, sin un método formal para su determinación, por lo que se elige mediante evaluación. Se sugiere partir de arquitecturas existentes, y el aprendizaje Bayesiano facilita la selección óptima de nodos o filtros.
10.14.9. Características en una CNN#
Las características (features) en una Red Neuronal Convolucional (CNN) son patrones visuales que la red aprende a reconocer en las imágenes. Se extraen automáticamente a través de capas convolucionales aplicadas en diferentes niveles de abstracción. ¿Cuáles son las características en una CNN? Las características son representaciones numéricas que describen el contenido visual de una imagen. Se generan a través de filtros convolucionales que detectan patrones en distintos niveles:
Características de bajo nivel
(Primeras capas)
Se detectan patrones simples como:
Bordes
Texturas
Esquinas
Gradientes de color
Ejemplo: Un filtro Sobel detecta los bordes en una imagen.
Características de nivel intermedio (Capas medias)
Se combinan los bordes y texturas detectados antes para formar estructuras más complejas, como:
Formas básicas (círculos, líneas curvas)
Contornos de objetos
Partes de objetos (ojos, nariz, ruedas)
Ejemplo: En una CNN que detecta rostros, esta capa podría identificar una nariz o un ojo.
Características de alto nivel (Últimas capas)
Se forman representaciones abstractas y semánticas de la imagen, como:
Objetos completos (caras, coches, animales)
Relaciones espaciales entre partes
Categorización (perro vs. gato)
Ejemplo: En una CNN entrenada para clasificar animales, las capas profundas pueden reconocer la silueta completa de un gato.
Ejemplo Visual
: Si la CNN clasifica gatos y perros:
Bordes → Detecta líneas y curvas.
Formas intermedias → Identifica orejas, ojos y hocico.
Características de alto nivel → Distingue un gato de un perro.
10.14.10. Arquitecturas de Redes Neuronales Convolucionales#
Introducción
El diseño de una CNN sigue pasos básicos con múltiples variantes arquitectónicas y optimizaciones para mejorar la eficiencia computacional (ver Fig. 10.38). Implementarlas requiere un gran esfuerzo técnico para su aprendizaje y aplicación.
A continuación, se presentan redes convolucionales clásicas, cuya comprensión es clave para profundizar en el tema, aunque algunos de sus métodos ya no se utilicen. Los estudios citados ofrecen una base sólida para el aprendizaje de las CNN.

10.14.11. LeNet-5#
LeNet-5
Este es un ejemplo típico de la primera generación de CNNs y fue construido para reconocer dígitos de números (ver [LeCun et al., 1998]). Por razones históricas, comentemos un poco su arquitectura. La entrada de la red consiste en imágenes en escala de grises de tamaño 32 × 32 × 1. La red emplea dos capas de convolución. En la primera capa, el volumen de salida tiene un tamaño de 28 × 28 × 6, que tras la agrupación se convierte en 14 × 14 × 6.
Las dimensiones del volumen en la segunda capa eran 10 × 10 × 16 y, tras la agrupación, 5 × 5 × 16. La no linealidad utilizada entonces era de tipo
sigmoid
. Nótese que laaltura y la anchura de los volúmenes disminuyen y la profundidad aumenta
, como se ha señalado antes. El número de elementos del último volumen es igual a 400. Estos elementos se apilan en un vector y alimentan los correspondientes nodos de entrada de una red totalmente conectada.Esta última consta de dos capas ocultas con 120 nodos en la primera y 84 nodos en la segunda. Hay 10 nodos de salida, uno por dígito, que utilizan una no linealidad
softmax
. El número total de parámetros implicados es del orden de 60.000.

Fig. 10.40 Arquitectura LeNet-5. Fuente [LeCun et al., 1998].#
10.14.12. AlexNet#
AlexNet
Esta red también es histórica ya que, demostró que el punto crucial para hacer grandes redes es la disponibilidad de grandes conjuntos de entrenamiento [Krizhevsky et al., 2012]. El artículo relacionado es el que realmente hizo volver a las CNN y actuó como catalizador para su adopción mucho más allá de la tarea de reconocimiento de dígitos. La Alexnet es un desarrollo de la LeNet-5, pero es mucho más grande e implica aproximadamente 60 millones de parámetros.
Las entradas a la red son imágenes RGB de tamaño 227 × 227 × 3. Comprende cinco capas ocultas y el volumen final consta de 9216 elementos que alimentan una red totalmente conectada con dos capas ocultas de 4096 unidades cada una. La salida consta de 1000 nodos
softmax
(uno por clase) para reconocer imágenes del conjunto de datos ImageNet para el reconocimiento de objetos.ReLU
se ha utilizado como no linealidad en las capas ocultas.

Fig. 10.41 Arquitectura LeNet-5. Fuente [Krizhevsky et al., 2012].#
10.14.13. VGG-16#
VGG-16
Esta red [Simonyan and Zisserman, 2014] es mucho mayor que AlexNet. Implica un total de aproximadamente 140 millones de parámetros. La principal característica de esta red es su regularidad. Involucra filtros 3 × 3 para realizar las mismas convoluciones utilizando padding y stride \(s = 1\) y ventanas 2 × 2 para maxpooling con stride \(s = 2\). Cada vez que se realiza un pooling, la altura y la anchura de los volúmenes se reducen a la mitad y la profundidad se multiplica por dos.
Partiendo de 224 × 224 × 3 imagen de entrada y después de 13 capas el volumen final tiene un tamaño de 7 × 7 × 512, un total de 25088 elementos, que tras su vectorización alimenta a una red totalmente conectada con 2 capas ocultas de 4096 nodos cada una. Los 1000 nodos de salida están construidos en torno a la no linealidad
softmax
y se ha utilizadoReLU
para las unidades ocultas en toda la red.

Fig. 10.42 Arquitectura VGG-16. Fuente [Simonyan and Zisserman, 2014].#
El diseño de
VGG-16
se basó en la hipótesis de que aumentar la profundidad de una red convolucional, mientras se usan pequeños filtros, conduciría a un mejor rendimiento en la tarea de clasificación de imágenes. La naturaleza homogénea de la arquitectura con filtros 3x3 repetidos permitió que la red aprendiera características jerárquicas más profundas, conservando al mismo tiempo una buena eficiencia computacional.El uso de filtros pequeños y capas profundas fue una decisión clave porque:
Filtros más pequeños capturan características locales, pero apilados sucesivamente tienen un campo receptivo mayor.
Mayor profundidad permite extraer características más abstractas y complejas a medida que las capas avanzan.
10.14.14. GoogleNet y la red Inception#
GoogleNet y la red Inception
La arquitectura utilizada en esta red se desvía del “arquetípico” que se muestra en Fig. 10.38. En el corazón de esta red se encuentra el llamado módulo de inicio [Szegedy et al., 2015]. Un módulo de inicio consta de filtros de diferentes tamaños y profundidades, así como de una ruta de agrupación diferente.
La arquitectura
Inception
toma la salida de una capa y la divide en varias rutas: una usa convolución 1x1 para reducir la profundidad, otra hace pooling seguido de convolución 5x5, y dos rutas realizan convoluciones separadas con filtros 3x3 y 5x5. Se usa una capa de cuello de botella (convolución 1x1) para reducir la carga computacional antes de las convoluciones.En el módulo de inicio, se concatenan los volúmenes de salida de varias trayectorias, permitiendo que la red, durante el entrenamiento, elija las mejores operaciones para cada capa. A medida que se avanza hacia capas superiores, se incrementa la proporción de convoluciones 3 × 3 y 5 × 5, ya que captan rasgos más abstractos. La red tiene 22 capas y 6 millones de parámetros.


Fig. 10.44 Arquitectura Inception. Fuente [Szegedy et al., 2015].#
10.14.15. Redes residuales (ResNets)#
Redes residuales (ResNets)
Ya hemos hablado de las ventajas de diseñar redes profundas. También abordamos la forma de hacer frente al problema de los gradientes que se desvanecen/explotan mediante una combinación de métodos y trucos que permiten que el algoritmo backpropagation converja con suficiente rapidez. Sin embargo, una vez que empezamos a construir redes muy profundas (del orden de decenas o incluso centenares de capas) nos encontramos con el siguiente comportamiento “poco ortodoxo”.
Cabría esperar que, añadiendo más y más capas, el error de entrenamiento mejore o al menos no aumenta. Sin embargo, lo que se observa en la práctica es que
a partir de un cierto número de capas, el error de entrenamiento empieza a aumentar
. Esto se ilustra gráficamente en la Fig. 10.45. Este fenómeno no tiene nada que ver con el sobreajuste. Después de todo, estamos hablando del error de entrenamiento y no del de generalización. Parece que esto puede deberse ala tarea de optimización que se vuelve más y más difícil a medida que se añaden más y más capas
.

Fig. 10.45 Aumento en lugar de disminución del error cuando el número de capas supera cierto número, en una red muy profunda. (curva roja) como se esperaba a partir de la teoría.#
Interpretación Matemática de una Capa en la Red Neuronal
Cada capa de una red neuronal aplica una función \(H\) que transforma la salida anterior \(\boldsymbol{y}^{r-1}\) en una nueva salida \(\boldsymbol{y}^{r}\):
¿Por qué Añadir Más Capas No Debería Aumentar el Error de Entrenamiento?
Si una capa ha extraído toda la información útil, una capa adicional, en el peor caso, solo replicaría la salida anterior aplicando la función identidad:
\[ \boldsymbol{y}^{r} = H(\boldsymbol{y}^{r-1}) = \boldsymbol{y}^{r-1} \]Es decir, si no hay nada nuevo que aprender, la capa extra solo copiaría la información.
Problema de redes profundas: saturación y dificultades de optimización
En redes profundas, el rendimiento se estanca porque más capas no mejoran la precisión y la optimización enfrenta dificultades para aprender el mapeo de identidad \(H(y^{r-1}) = y^{r-1}\), generando errores por gradientes pequeños o desajustes en los pesos.
Solución propuesta por He et al. (2016): Redes Residuales (ResNets)
En lugar de aprender directamente \(H(y^{r-1})\), se propone un mapeo residual \(F(y^{r-1}) = H(y^{r-1}) - y^{r-1}\), donde la capa solo ajusta la diferencia necesaria en vez de replicar la entrada. Así, la salida se obtiene sumando este ajuste residual:
El uso de la representación residual no es nuevo y ya se ha utilizado anteriormente en el contexto de la cuantificación vectorial. La esencia del aprendizaje residual es introducir el llamado bloque de construcción residual, que se muestra en la Fig. 10.46.

Fig. 10.46 Bloque de construcción residual. Fuente [Theodoridis, 2020].#
De esta manera, un número de capas, digamos, dos, como en el caso de la Fig. 10.46, se apilan y dejamos que estas capas se ajusten explícitamente al mapeo residual, a través de las llamadas conexiones de atajo u omisión. Cada capa de pesos realiza una transformación sobre su entrada, por ejemplo, convoluciones. Si \(\boldsymbol{y}^{r}\) e \(\boldsymbol{y}^{r-1}\) son de dimensiones diferentes, el atajo de mapeo de identidad se modifica a \(W\boldsymbol{y}^{r-1}\), donde \(W\) es una matriz de dimensiones apropiadas.
10.14.16. U-Net: Redes Convolucionales para la Segmentación de Imágenes Biomédicas#

Fig. 10.47 Image segmentation vs Instance segmentation. Fuente v7labs#
En imágenes médicas, la segmentación semántica clasifica cada píxel según una categoría anatómica o patológica, mientras que la segmentación de instancias diferencia objetos individuales dentro de la misma clase. Estas técnicas son esenciales para el diagnóstico automatizado y la planificación clínica.
La arquitectura U-Net, diseñada para segmentación biomédica, permite una segmentación precisa mediante un diseño en forma de U que combina información de contexto con detalles espaciales, mejorando la detección de estructuras complejas en imágenes médicas.

Fig. 10.48 La arquitectura U-Net representa mapas de características con cajas, indicando canales y tamaño, mientras que las flechas señalan las operaciones.#
Arquitectura U-Net
La arquitectura U-Net es una red neuronal convolucional diseñada para la segmentación de imágenes biomédicas. Se basa en una estructura de encoder-decoder con una forma en “U”.
Encoder (contracción): Extrae características mediante convoluciones y capas de pooling para reducir la dimensionalidad.
Bottleneck: Conecta el encoder con el decoder, capturando la información más relevante.
Decoder (expansión): Usa convoluciones transpuestas para recuperar la resolución original de la imagen y refinar la segmentación.
Skip connections: Conectan capas del encoder con el decoder, permitiendo una mejor preservación de detalles.
Esta arquitectura es eficiente con pocos datos y logra segmentaciones precisas, lo que la hace ideal para aplicaciones médicas. Considera
conv 3x3, max pool 2x2, up-conv 2x2, conv 1x1, padding = 0, stride = 1
fijos. En elencoder
se reduce alto y ancho de los mapeos de caracteristicas mientras que se aumenta la profundidad. Lo contrario ocurre en eldecoder
.Al final se aplican 2 convoluciones de
1x1x64
(tubos) como las vistas en secciones anteriores, para obtener el último kernel de388x388x2
en el que, cada canal representaria una clase diferente a segmentar, y despues de aplicar funciones de activación, lo que resulta en el mapeo de características es, la probabilidad de que un pixel en una respectiva posición pertenezca a la clase representada por cada canal, por ejemplo a la clase (black
) o la clase (blue
) (clasificación binaria).
La arquitectura U-Net aprende a segmentar imágenes asignando cada píxel a una clase específica.
Entrada (X): Imagen original (ej. un gato en fondo blanco).
Salida esperada (Y): Máscara binaria (1 = gato, 0 = fondo).
El modelo ajusta sus parámetros hasta que pueda predecir correctamente la máscara a partir de una nueva imagen.
¿Cómo se maneja la correspondencia de píxeles?
U-Net genera una salida de menor tamaño que la imagen de entrada debido a la reducción espacial provocada por las capas de max pooling en la fase de contracción y el uso de valid convolutions sin padding. Aunque las up-convolutions intentan recuperar la resolución original, la salida sigue siendo más pequeña.
¿Cómo mantener la correspondencia de píxeles?
Same padding: Mantiene el tamaño original sin postprocesamiento, pero puede introducir valores artificiales en los bordes.
Superposición de parches: Divide la imagen en fragmentos solapados para reconstruir la segmentación completa, útil en imágenes médicas de alta resolución.
Interpolación o recorte: Ajusta el tamaño de salida cuando se usan valid convolutions, aunque puede generar imprecisiones.
Recomendación:
Para conservar el tamaño original, usar same padding en todas las convoluciones.
En imágenes grandes, aplicar superposición de parches para optimizar memoria y evitar artefactos.
Si la red ya está entrenada con valid convolutions, usar interpolación como ajuste final.
10.14.17. Aplicación: Reconocimiento facial de emociones#
Los datos para el siguiente ejemplo pueden ser descargados del siguiente link: Face Sentiment Detection
import warnings
warnings.filterwarnings('ignore')
import tensorflow as tf
devices = tf.config.list_physical_devices()
print("Available devices:")
for device in devices:
print(device)
gpus = tf.config.list_physical_devices('GPU')
if gpus:
print("TensorFlow is using GPU.")
for gpu in gpus:
gpu_details = tf.config.experimental.get_device_details(gpu)
print(f"GPU details: {gpu_details}")
else:
print("TensorFlow is not using GPU.")
Available devices:
PhysicalDevice(name='/physical_device:CPU:0', device_type='CPU')
PhysicalDevice(name='/physical_device:GPU:0', device_type='GPU')
TensorFlow is using GPU.
GPU details: {'compute_capability': (8, 9), 'device_name': 'NVIDIA GeForce RTX 4070'}
I0000 00:00:1743824747.984472 2101 cuda_executor.cc:1001] could not open file to read NUMA node: /sys/bus/pci/devices/0000:01:00.0/numa_node
Your kernel may have been built without NUMA support.
import numpy as np
import pandas as pd
import os
from tensorflow.keras.preprocessing.image import ImageDataGenerator
from tensorflow.keras.utils import load_img
from keras.layers import Conv2D, Dense, BatchNormalization, Activation, Dropout, MaxPool2D, Flatten, MaxPooling2D
from tensorflow.keras.metrics import AUC
from tensorflow.keras.optimizers import Adam, RMSprop, SGD
from keras.callbacks import ModelCheckpoint,EarlyStopping
import datetime
from keras import regularizers
import matplotlib.pyplot as plt
from tensorflow.keras.utils import plot_model
train_dir = '/home/lihkir/Data/face_sentiment_detection/train/'
test_dir = '/home/lihkir/Data/face_sentiment_detection/test/'
def count_exp(path, set_):
dict_ = {}
for expression in os.listdir(path):
dir_ = path + expression
dict_[expression] = len(os.listdir(dir_))
df = pd.DataFrame(dict_, index=[set_])
return df
train_count = count_exp(train_dir, 'train')
test_count = count_exp(test_dir, 'test')
print(train_count)
print(test_count)
surprise disgust happy fear angry neutral sad
train 3171 436 7215 4097 3995 4965 4830
surprise disgust happy fear angry neutral sad
test 831 111 1774 1024 958 1233 1247
train_count.transpose().plot(kind='bar');

test_count.transpose().plot(kind='bar');

plt.figure(figsize=(14,22))
i = 1
for expression in os.listdir(train_dir):
img = load_img((train_dir + expression +'/'+ os.listdir(train_dir + expression)[5]))
plt.subplot(1,7,i)
plt.imshow(img)
plt.title(expression)
plt.axis('off')
i += 1

Creamos los conjuntos de datos de entrenamiento, prueba y validación
train_datagen = ImageDataGenerator(rescale=1.0/255.0,
horizontal_flip=True,
validation_split=0.2)
rescale=1./255
: Normaliza las imágenes escalando los valores de los píxeles de un rango de [0, 255] a [0, 1]. Esto es una práctica común para mejorar la eficiencia del entrenamiento de redes neuronales.horizontal_flip=True
: Permite aplicar una transformación de “flip” horizontal (volteo) de manera aleatoria a las imágenes, una técnica de data augmentation para hacer que el modelo generalice mejor.validation_split=0.2
: Define una división de los datos, reservando el 20% de ellos para validación, lo que significa que el 80% de los datos será usado para entrenamiento.
training_set = train_datagen.flow_from_directory(train_dir,
batch_size=64,
target_size=(48,48),
shuffle=True,
color_mode='grayscale',
class_mode='categorical',
subset='training')
Found 22968 images belonging to 7 classes.
train_dir
: Es la ruta del directorio que contiene las imágenes de entrenamiento organizadas en subdirectorios según la clase.batch_size=64
: Carga 64 imágenes a la vez (en lotes) para ser procesadas en cada paso del entrenamiento.target_size=(48,48)
: Redimensiona todas las imágenes al tamaño de 48x48 píxeles. Esto es útil para mantener un tamaño uniforme de las imágenes que ingresan al modelo.shuffle=True
: Aleatoriza las imágenes en cada epoch, lo que evita que el modelo se adapte a un orden particular de los datos.color_mode='grayscale'
: Convierte las imágenes en escala de grises (1 canal en lugar de 3 para imágenes en color RGB).class_mode='categorical'
: Las etiquetas se asignan como categóricas, es decir, cada imagen pertenece a una clase específica (por ejemplo, clasificación multiclase).subset='training'
: Utiliza el 80% de los datos (definidos anteriormente convalidation_split=0.2
) para entrenamiento.
validation_set = train_datagen.flow_from_directory(train_dir,
batch_size=64,
target_size=(48,48),
shuffle=True,
color_mode='grayscale',
class_mode='categorical',
subset='validation')
Found 5741 images belonging to 7 classes.
subset='validation'
: Selecciona el 20% de los datos reservados para la validación (definidos anteriormente convalidation_split=0.2
)
test_datagen = ImageDataGenerator(rescale=1.0/255.0,
horizontal_flip=True)
test_set = test_datagen.flow_from_directory(test_dir,
batch_size=64,
target_size=(48,48),
shuffle=True,
color_mode='grayscale',
class_mode='categorical')
Found 7178 images belonging to 7 classes.
training_set.class_indices
{'angry': 0,
'disgust': 1,
'fear': 2,
'happy': 3,
'neutral': 4,
'sad': 5,
'surprise': 6}
Arquitectura del modelo. En este caso,
padding='same'
indica que se realiza padding para que la salida tiene el mismo tamaño que la entrada.
weight_decay = 1e-4
num_classes = 7
model = tf.keras.models.Sequential()
model.add(Conv2D(64, (4,4), padding='same', kernel_regularizer=regularizers.l2(weight_decay), input_shape=(48,48,1)))
model.add(Activation('elu'))
model.add(BatchNormalization())
model.add(Conv2D(64, (4,4), padding='same', kernel_regularizer=regularizers.l2(weight_decay)))
model.add(Activation('elu'))
model.add(BatchNormalization())
model.add(MaxPooling2D(pool_size=(2,2)))
model.add(Dropout(0.2))
model.add(Conv2D(128, (4,4), padding='same', kernel_regularizer=regularizers.l2(weight_decay)))
model.add(Activation('elu'))
model.add(BatchNormalization())
model.add(MaxPooling2D(pool_size=(2,2)))
model.add(Dropout(0.3))
model.add(Conv2D(128, (4,4), padding='same', kernel_regularizer=regularizers.l2(weight_decay)))
model.add(Activation('elu'))
model.add(BatchNormalization())
model.add(Conv2D(128, (4,4), padding='same', kernel_regularizer=regularizers.l2(weight_decay)))
model.add(Activation('elu'))
model.add(BatchNormalization())
model.add(MaxPooling2D(pool_size=(2,2)))
model.add(Dropout(0.4))
model.add(Flatten())
model.add(Dense(128, activation="linear"))
model.add(Activation('elu'))
model.add(Dense(num_classes, activation='softmax'))
model.compile(loss='categorical_crossentropy', optimizer=Adam(0.0001), metrics=[AUC(name='auc')])
model.summary()
Model: "sequential_7"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓ ┃ Layer (type) ┃ Output Shape ┃ Param # ┃ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩ │ conv2d_24 (Conv2D) │ (None, 48, 48, 64) │ 1,088 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ activation_28 (Activation) │ (None, 48, 48, 64) │ 0 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ batch_normalization_24 │ (None, 48, 48, 64) │ 256 │ │ (BatchNormalization) │ │ │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ conv2d_25 (Conv2D) │ (None, 48, 48, 64) │ 65,600 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ activation_29 (Activation) │ (None, 48, 48, 64) │ 0 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ batch_normalization_25 │ (None, 48, 48, 64) │ 256 │ │ (BatchNormalization) │ │ │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ max_pooling2d_12 (MaxPooling2D) │ (None, 24, 24, 64) │ 0 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ dropout_19 (Dropout) │ (None, 24, 24, 64) │ 0 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ conv2d_26 (Conv2D) │ (None, 24, 24, 128) │ 131,200 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ activation_30 (Activation) │ (None, 24, 24, 128) │ 0 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ batch_normalization_26 │ (None, 24, 24, 128) │ 512 │ │ (BatchNormalization) │ │ │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ max_pooling2d_13 (MaxPooling2D) │ (None, 12, 12, 128) │ 0 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ dropout_20 (Dropout) │ (None, 12, 12, 128) │ 0 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ conv2d_27 (Conv2D) │ (None, 12, 12, 128) │ 262,272 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ activation_31 (Activation) │ (None, 12, 12, 128) │ 0 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ batch_normalization_27 │ (None, 12, 12, 128) │ 512 │ │ (BatchNormalization) │ │ │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ conv2d_28 (Conv2D) │ (None, 12, 12, 128) │ 262,272 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ activation_32 (Activation) │ (None, 12, 12, 128) │ 0 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ batch_normalization_28 │ (None, 12, 12, 128) │ 512 │ │ (BatchNormalization) │ │ │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ max_pooling2d_14 (MaxPooling2D) │ (None, 6, 6, 128) │ 0 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ dropout_21 (Dropout) │ (None, 6, 6, 128) │ 0 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ flatten_4 (Flatten) │ (None, 4608) │ 0 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ dense_36 (Dense) │ (None, 128) │ 589,952 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ activation_33 (Activation) │ (None, 128) │ 0 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ dense_37 (Dense) │ (None, 7) │ 903 │ └─────────────────────────────────┴────────────────────────┴───────────────┘
Total params: 1,315,335 (5.02 MB)
Trainable params: 1,314,311 (5.01 MB)
Non-trainable params: 1,024 (4.00 KB)
plot_model(model, to_file='model_cnn.png', show_shapes=True, show_layer_names=True)

epochs = 60
checkpointer = [
EarlyStopping(
monitor='val_auc',
verbose=1,
restore_best_weights=True,
mode='max',
patience=10
),
ModelCheckpoint(
filepath='model.weights.best.keras',
monitor='val_auc',
verbose=1,
save_best_only=True,
mode='max'
)
]
import os
from joblib import dump, load
history_cnn = None
if os.path.exists('history_cnn.joblib'):
history_cnn = load('history_cnn.joblib')
print("El archivo 'history_cnn.joblib' ya existe. Se ha cargado el historial del entrenamiento.")
else:
history_cnn = model.fit(x = training_set,
epochs = epochs, callbacks=checkpointer,
validation_data = validation_set,
verbose = 2)
dump(history_cnn.history, 'history_cnn.joblib')
print("El entrenamiento se ha completado y el historial ha sido guardado en 'history_cnn.joblib'.")
Epoch 1/60
Epoch 1: val_auc improved from -inf to 0.67143, saving model to model.weights.best.keras
359/359 - 11s - 29ms/step - auc: 0.6803 - loss: 1.9372 - val_auc: 0.6714 - val_loss: 2.0760
Epoch 2/60
Epoch 2: val_auc improved from 0.67143 to 0.76404, saving model to model.weights.best.keras
359/359 - 5s - 14ms/step - auc: 0.7530 - loss: 1.6691 - val_auc: 0.7640 - val_loss: 1.6854
Epoch 3/60
Epoch 3: val_auc improved from 0.76404 to 0.80314, saving model to model.weights.best.keras
359/359 - 5s - 13ms/step - auc: 0.7803 - loss: 1.5911 - val_auc: 0.8031 - val_loss: 1.5313
Epoch 4/60
Epoch 4: val_auc improved from 0.80314 to 0.81094, saving model to model.weights.best.keras
359/359 - 5s - 13ms/step - auc: 0.8020 - loss: 1.5235 - val_auc: 0.8109 - val_loss: 1.5168
Epoch 5/60
Epoch 5: val_auc improved from 0.81094 to 0.83368, saving model to model.weights.best.keras
359/359 - 5s - 13ms/step - auc: 0.8205 - loss: 1.4616 - val_auc: 0.8337 - val_loss: 1.4306
Epoch 6/60
Epoch 6: val_auc improved from 0.83368 to 0.84118, saving model to model.weights.best.keras
359/359 - 5s - 13ms/step - auc: 0.8351 - loss: 1.4098 - val_auc: 0.8412 - val_loss: 1.3964
Epoch 7/60
Epoch 7: val_auc improved from 0.84118 to 0.84737, saving model to model.weights.best.keras
359/359 - 5s - 13ms/step - auc: 0.8458 - loss: 1.3690 - val_auc: 0.8474 - val_loss: 1.3709
Epoch 8/60
Epoch 8: val_auc improved from 0.84737 to 0.85888, saving model to model.weights.best.keras
359/359 - 5s - 13ms/step - auc: 0.8547 - loss: 1.3334 - val_auc: 0.8589 - val_loss: 1.3216
Epoch 9/60
Epoch 9: val_auc improved from 0.85888 to 0.86304, saving model to model.weights.best.keras
359/359 - 5s - 13ms/step - auc: 0.8641 - loss: 1.2943 - val_auc: 0.8630 - val_loss: 1.3112
Epoch 10/60
Epoch 10: val_auc improved from 0.86304 to 0.86898, saving model to model.weights.best.keras
359/359 - 5s - 13ms/step - auc: 0.8712 - loss: 1.2645 - val_auc: 0.8690 - val_loss: 1.2803
Epoch 11/60
Epoch 11: val_auc improved from 0.86898 to 0.87462, saving model to model.weights.best.keras
359/359 - 5s - 13ms/step - auc: 0.8779 - loss: 1.2349 - val_auc: 0.8746 - val_loss: 1.2529
Epoch 12/60
Epoch 12: val_auc did not improve from 0.87462
359/359 - 5s - 13ms/step - auc: 0.8843 - loss: 1.2048 - val_auc: 0.8728 - val_loss: 1.2734
Epoch 13/60
Epoch 13: val_auc improved from 0.87462 to 0.88140, saving model to model.weights.best.keras
359/359 - 5s - 13ms/step - auc: 0.8893 - loss: 1.1819 - val_auc: 0.8814 - val_loss: 1.2270
Epoch 14/60
Epoch 14: val_auc improved from 0.88140 to 0.88157, saving model to model.weights.best.keras
359/359 - 5s - 13ms/step - auc: 0.8935 - loss: 1.1611 - val_auc: 0.8816 - val_loss: 1.2274
Epoch 15/60
Epoch 15: val_auc improved from 0.88157 to 0.88174, saving model to model.weights.best.keras
359/359 - 5s - 13ms/step - auc: 0.8993 - loss: 1.1305 - val_auc: 0.8817 - val_loss: 1.2370
Epoch 16/60
Epoch 16: val_auc improved from 0.88174 to 0.88516, saving model to model.weights.best.keras
359/359 - 5s - 13ms/step - auc: 0.9041 - loss: 1.1067 - val_auc: 0.8852 - val_loss: 1.2197
Epoch 17/60
Epoch 17: val_auc improved from 0.88516 to 0.89153, saving model to model.weights.best.keras
359/359 - 5s - 13ms/step - auc: 0.9074 - loss: 1.0887 - val_auc: 0.8915 - val_loss: 1.1922
Epoch 18/60
Epoch 18: val_auc improved from 0.89153 to 0.89536, saving model to model.weights.best.keras
359/359 - 5s - 13ms/step - auc: 0.9117 - loss: 1.0657 - val_auc: 0.8954 - val_loss: 1.1660
Epoch 19/60
Epoch 19: val_auc did not improve from 0.89536
359/359 - 5s - 13ms/step - auc: 0.9145 - loss: 1.0491 - val_auc: 0.8946 - val_loss: 1.1730
Epoch 20/60
Epoch 20: val_auc improved from 0.89536 to 0.89640, saving model to model.weights.best.keras
359/359 - 5s - 13ms/step - auc: 0.9188 - loss: 1.0241 - val_auc: 0.8964 - val_loss: 1.1618
Epoch 21/60
Epoch 21: val_auc improved from 0.89640 to 0.89986, saving model to model.weights.best.keras
359/359 - 5s - 13ms/step - auc: 0.9226 - loss: 1.0025 - val_auc: 0.8999 - val_loss: 1.1463
Epoch 22/60
Epoch 22: val_auc did not improve from 0.89986
359/359 - 5s - 13ms/step - auc: 0.9253 - loss: 0.9858 - val_auc: 0.8986 - val_loss: 1.1517
Epoch 23/60
Epoch 23: val_auc improved from 0.89986 to 0.90138, saving model to model.weights.best.keras
359/359 - 5s - 13ms/step - auc: 0.9287 - loss: 0.9642 - val_auc: 0.9014 - val_loss: 1.1429
Epoch 24/60
Epoch 24: val_auc did not improve from 0.90138
359/359 - 5s - 13ms/step - auc: 0.9319 - loss: 0.9433 - val_auc: 0.8994 - val_loss: 1.1586
Epoch 25/60
Epoch 25: val_auc did not improve from 0.90138
359/359 - 5s - 13ms/step - auc: 0.9343 - loss: 0.9273 - val_auc: 0.8987 - val_loss: 1.1773
Epoch 26/60
Epoch 26: val_auc improved from 0.90138 to 0.90340, saving model to model.weights.best.keras
359/359 - 5s - 13ms/step - auc: 0.9376 - loss: 0.9059 - val_auc: 0.9034 - val_loss: 1.1351
Epoch 27/60
Epoch 27: val_auc improved from 0.90340 to 0.90473, saving model to model.weights.best.keras
359/359 - 5s - 13ms/step - auc: 0.9405 - loss: 0.8848 - val_auc: 0.9047 - val_loss: 1.1375
Epoch 28/60
Epoch 28: val_auc did not improve from 0.90473
359/359 - 5s - 13ms/step - auc: 0.9436 - loss: 0.8633 - val_auc: 0.9007 - val_loss: 1.1595
Epoch 29/60
Epoch 29: val_auc did not improve from 0.90473
359/359 - 5s - 13ms/step - auc: 0.9448 - loss: 0.8552 - val_auc: 0.9046 - val_loss: 1.1446
Epoch 30/60
Epoch 30: val_auc did not improve from 0.90473
359/359 - 5s - 13ms/step - auc: 0.9474 - loss: 0.8350 - val_auc: 0.9017 - val_loss: 1.1689
Epoch 31/60
Epoch 31: val_auc did not improve from 0.90473
359/359 - 5s - 13ms/step - auc: 0.9508 - loss: 0.8097 - val_auc: 0.9023 - val_loss: 1.1735
Epoch 32/60
Epoch 32: val_auc did not improve from 0.90473
359/359 - 5s - 13ms/step - auc: 0.9532 - loss: 0.7912 - val_auc: 0.8993 - val_loss: 1.1985
Epoch 33/60
Epoch 33: val_auc did not improve from 0.90473
359/359 - 5s - 13ms/step - auc: 0.9551 - loss: 0.7748 - val_auc: 0.9043 - val_loss: 1.1604
Epoch 34/60
Epoch 34: val_auc did not improve from 0.90473
359/359 - 5s - 13ms/step - auc: 0.9575 - loss: 0.7554 - val_auc: 0.9046 - val_loss: 1.1811
Epoch 35/60
Epoch 35: val_auc improved from 0.90473 to 0.90527, saving model to model.weights.best.keras
359/359 - 5s - 13ms/step - auc: 0.9586 - loss: 0.7457 - val_auc: 0.9053 - val_loss: 1.1777
Epoch 36/60
Epoch 36: val_auc did not improve from 0.90527
359/359 - 5s - 13ms/step - auc: 0.9610 - loss: 0.7247 - val_auc: 0.9021 - val_loss: 1.2007
Epoch 37/60
Epoch 37: val_auc did not improve from 0.90527
359/359 - 5s - 13ms/step - auc: 0.9627 - loss: 0.7094 - val_auc: 0.9047 - val_loss: 1.1869
Epoch 38/60
Epoch 38: val_auc did not improve from 0.90527
359/359 - 5s - 13ms/step - auc: 0.9653 - loss: 0.6857 - val_auc: 0.9025 - val_loss: 1.2195
Epoch 39/60
Epoch 39: val_auc did not improve from 0.90527
359/359 - 5s - 13ms/step - auc: 0.9662 - loss: 0.6781 - val_auc: 0.9027 - val_loss: 1.2086
Epoch 40/60
Epoch 40: val_auc did not improve from 0.90527
359/359 - 5s - 13ms/step - auc: 0.9679 - loss: 0.6612 - val_auc: 0.9052 - val_loss: 1.2024
Epoch 41/60
Epoch 41: val_auc did not improve from 0.90527
359/359 - 5s - 13ms/step - auc: 0.9701 - loss: 0.6400 - val_auc: 0.9039 - val_loss: 1.2260
Epoch 42/60
Epoch 42: val_auc improved from 0.90527 to 0.90562, saving model to model.weights.best.keras
359/359 - 5s - 13ms/step - auc: 0.9715 - loss: 0.6244 - val_auc: 0.9056 - val_loss: 1.1978
Epoch 43/60
Epoch 43: val_auc did not improve from 0.90562
359/359 - 5s - 13ms/step - auc: 0.9733 - loss: 0.6056 - val_auc: 0.9028 - val_loss: 1.2416
Epoch 44/60
Epoch 44: val_auc did not improve from 0.90562
359/359 - 5s - 13ms/step - auc: 0.9744 - loss: 0.5929 - val_auc: 0.9024 - val_loss: 1.2413
Epoch 45/60
Epoch 45: val_auc did not improve from 0.90562
359/359 - 5s - 13ms/step - auc: 0.9761 - loss: 0.5754 - val_auc: 0.9032 - val_loss: 1.2518
Epoch 46/60
Epoch 46: val_auc did not improve from 0.90562
359/359 - 5s - 13ms/step - auc: 0.9772 - loss: 0.5634 - val_auc: 0.9013 - val_loss: 1.2659
Epoch 47/60
Epoch 47: val_auc did not improve from 0.90562
359/359 - 5s - 13ms/step - auc: 0.9778 - loss: 0.5560 - val_auc: 0.9024 - val_loss: 1.2741
Epoch 48/60
Epoch 48: val_auc did not improve from 0.90562
359/359 - 5s - 13ms/step - auc: 0.9796 - loss: 0.5339 - val_auc: 0.9046 - val_loss: 1.2549
Epoch 49/60
Epoch 49: val_auc did not improve from 0.90562
359/359 - 5s - 13ms/step - auc: 0.9807 - loss: 0.5215 - val_auc: 0.9052 - val_loss: 1.2472
Epoch 50/60
Epoch 50: val_auc did not improve from 0.90562
359/359 - 5s - 13ms/step - auc: 0.9813 - loss: 0.5111 - val_auc: 0.9037 - val_loss: 1.2770
Epoch 51/60
Epoch 51: val_auc did not improve from 0.90562
359/359 - 5s - 13ms/step - auc: 0.9827 - loss: 0.4967 - val_auc: 0.9030 - val_loss: 1.2908
Epoch 52/60
Epoch 52: val_auc did not improve from 0.90562
359/359 - 5s - 13ms/step - auc: 0.9833 - loss: 0.4875 - val_auc: 0.9029 - val_loss: 1.3039
Epoch 52: early stopping
Restoring model weights from the end of the best epoch: 42.
El entrenamiento se ha completado y el historial ha sido guardado en 'history_cnn.joblib'.
fig, ax = plt.subplots(1, 2)
fig.set_size_inches(12, 4)
ax[0].plot(history_cnn.history['auc'])
ax[0].plot(history_cnn.history['val_auc'])
ax[0].set_title('AUC en Entrenamiento vs Validación')
ax[0].set_ylabel('AUC')
ax[0].set_xlabel('Época')
ax[0].legend(['Entrenamiento', 'Validación'], loc='lower right')
ax[1].plot(history_cnn.history['loss'])
ax[1].plot(history_cnn.history['val_loss'])
ax[1].set_title('Pérdida en Entrenamiento vs Validación')
ax[1].set_ylabel('Pérdida')
ax[1].set_xlabel('Época')
ax[1].legend(['Entrenamiento', 'Validación'], loc='upper right')
plt.tight_layout()
plt.show()

train_loss, train_auc = model.evaluate(training_set)
test_loss, test_auc = model.evaluate(validation_set)
print("final train accuracy = {:.2f} , validation accuracy = {:.2f}".format(train_auc*100, test_auc*100))
359/359 ━━━━━━━━━━━━━━━━━━━━ 2s 7ms/step - auc: 0.9908 - loss: 0.4052
90/90 ━━━━━━━━━━━━━━━━━━━━ 1s 6ms/step - auc: 0.9010 - loss: 1.2412
final train accuracy = 99.03 , validation accuracy = 90.65
Matriz de confusión del conjunto de entrenamiento
y_pred = model.predict(training_set)
y_pred = np.argmax(y_pred, axis=1)
class_labels = test_set.class_indices
class_labels = {v:k for k,v in class_labels.items()}
from sklearn.metrics import classification_report, confusion_matrix
cm_train = confusion_matrix(training_set.classes, y_pred)
print('Confusion Matrix')
print(cm_train)
print('Classification Report')
target_names = list(class_labels.values())
print(classification_report(training_set.classes, y_pred, target_names=target_names))
plt.figure(figsize=(8,8))
plt.imshow(cm_train, interpolation='nearest')
plt.colorbar()
tick_mark = np.arange(len(target_names))
_ = plt.xticks(tick_mark, target_names, rotation=90)
_ = plt.yticks(tick_mark, target_names)
359/359 ━━━━━━━━━━━━━━━━━━━━ 2s 7ms/step
Confusion Matrix
[[ 458 51 403 820 568 560 336]
[ 43 6 37 79 71 80 33]
[ 461 45 366 814 635 601 356]
[ 776 68 699 1536 1113 961 619]
[ 519 68 495 1030 760 667 433]
[ 529 54 493 958 729 694 407]
[ 344 30 337 627 493 443 263]]
Classification Report
precision recall f1-score support
angry 0.15 0.14 0.14 3196
disgust 0.02 0.02 0.02 349
fear 0.13 0.11 0.12 3278
happy 0.26 0.27 0.26 5772
neutral 0.17 0.19 0.18 3972
sad 0.17 0.18 0.18 3864
surprise 0.11 0.10 0.11 2537
accuracy 0.18 22968
macro avg 0.14 0.14 0.14 22968
weighted avg 0.18 0.18 0.18 22968

Matriz de confusión en el conjunto de datos de validación
y_pred = model.predict(validation_set)
y_pred = np.argmax(y_pred, axis=1)
cm_val = confusion_matrix(validation_set.classes, y_pred)
print('Confusion Matrix')
print(cm_val)
print('Classification Report')
target_names = list(class_labels.values())
print(classification_report(validation_set.classes, y_pred, target_names=target_names))
plt.figure(figsize=(8,8))
plt.imshow(cm_train, interpolation='nearest')
plt.colorbar()
tick_mark = np.arange(len(target_names))
_ = plt.xticks(tick_mark, target_names, rotation=90)
_ = plt.yticks(tick_mark, target_names)
90/90 ━━━━━━━━━━━━━━━━━━━━ 1s 9ms/step
Confusion Matrix
[[106 4 86 207 165 161 70]
[ 10 0 12 22 20 14 9]
[121 11 71 199 170 166 81]
[183 12 134 381 313 250 170]
[114 7 114 240 228 206 84]
[121 13 121 245 202 168 96]
[ 77 12 73 178 114 108 72]]
Classification Report
precision recall f1-score support
angry 0.14 0.13 0.14 799
disgust 0.00 0.00 0.00 87
fear 0.12 0.09 0.10 819
happy 0.26 0.26 0.26 1443
neutral 0.19 0.23 0.21 993
sad 0.16 0.17 0.16 966
surprise 0.12 0.11 0.12 634
accuracy 0.18 5741
macro avg 0.14 0.14 0.14 5741
weighted avg 0.17 0.18 0.18 5741

Matriz de confusión del conjunto de datos de prueba
y_pred = model.predict(test_set)
y_pred = np.argmax(y_pred, axis=1)
cm_test = confusion_matrix(test_set.classes, y_pred)
print('Confusion Matrix')
print(cm_test)
print('Classification Report')
target_names = list(class_labels.values())
print(classification_report(test_set.classes, y_pred, target_names=target_names))
plt.figure(figsize=(8,8))
plt.imshow(cm_test, interpolation='nearest')
plt.colorbar()
tick_mark = np.arange(len(target_names))
_ = plt.xticks(tick_mark, target_names, rotation=90)
_ = plt.yticks(tick_mark, target_names)
113/113 ━━━━━━━━━━━━━━━━━━━━ 1s 7ms/step
Confusion Matrix
[[139 10 96 227 189 194 103]
[ 17 0 15 25 20 24 10]
[116 9 109 260 212 204 114]
[234 21 175 437 383 328 196]
[176 10 115 326 258 218 130]
[176 10 133 319 249 225 135]
[125 8 77 230 169 142 80]]
Classification Report
precision recall f1-score support
angry 0.14 0.15 0.14 958
disgust 0.00 0.00 0.00 111
fear 0.15 0.11 0.12 1024
happy 0.24 0.25 0.24 1774
neutral 0.17 0.21 0.19 1233
sad 0.17 0.18 0.17 1247
surprise 0.10 0.10 0.10 831
accuracy 0.17 7178
macro avg 0.14 0.14 0.14 7178
weighted avg 0.17 0.17 0.17 7178

Grafico de predicciones
import numpy as np
x_test, y_test = next(test_set)
predict = model.predict(x_test)
figure = plt.figure(figsize=(20, 8))
for i, index in enumerate(np.random.choice(x_test.shape[0], size=24, replace=False)):
ax = figure.add_subplot(4, 6, i + 1, xticks=[], yticks=[])
ax.imshow(np.squeeze(x_test[index]))
predict_index = class_labels[np.argmax(predict[index])]
true_index = class_labels[np.argmax(y_test[index])]
ax.set_title(f"{predict_index} ({true_index})",
color=("green" if predict_index == true_index else "red"))
2/2 ━━━━━━━━━━━━━━━━━━━━ 0s 56ms/step

10.14.18. Aplicación: Segmentación de Imágenes Biomédicas con U-Net#
El cáncer de mama es una de las principales causas de mortalidad en mujeres, y su detección temprana reduce muertes prematuras. Este conjunto de datos de ultrasonido mamario incluye 780 imágenes de 600 pacientes (25-75 años), recopiladas en 2018, clasificadas en normales, benignas y malignas. Las imágenes, en formato PNG (500×500 píxeles), incluyen referencias (ground truth) para mejorar su análisis mediante aprendizaje automático en clasificación, detección y segmentación (ver Breast Ultrasound Images Dataset).
import tensorflow as tf
from glob import glob
import numpy as np
from sklearn.model_selection import train_test_split
import cv2
import matplotlib.pyplot as plt
from keras.models import Model
from keras.layers import Input, Conv2D, MaxPooling2D, Conv2DTranspose, concatenate
from keras.optimizers import Adam
from tensorflow.keras.metrics import *
2025-03-18 21:14:42.262119: I tensorflow/core/util/port.cc:153] oneDNN custom operations are on. You may see slightly different numerical results due to floating-point round-off errors from different computation orders. To turn them off, set the environment variable `TF_ENABLE_ONEDNN_OPTS=0`.
2025-03-18 21:14:42.534674: E external/local_xla/xla/stream_executor/cuda/cuda_fft.cc:485] Unable to register cuFFT factory: Attempting to register factory for plugin cuFFT when one has already been registered
2025-03-18 21:14:42.635125: E external/local_xla/xla/stream_executor/cuda/cuda_dnn.cc:8454] Unable to register cuDNN factory: Attempting to register factory for plugin cuDNN when one has already been registered
2025-03-18 21:14:42.663016: E external/local_xla/xla/stream_executor/cuda/cuda_blas.cc:1452] Unable to register cuBLAS factory: Attempting to register factory for plugin cuBLAS when one has already been registered
2025-03-18 21:14:42.834720: I tensorflow/core/platform/cpu_feature_guard.cc:210] This TensorFlow binary is optimized to use available CPU instructions in performance-critical operations.
To enable the following instructions: AVX2 AVX512F AVX512_VNNI AVX512_BF16 FMA, in other operations, rebuild TensorFlow with the appropriate compiler flags.
2025-03-18 21:14:44.051401: W tensorflow/compiler/tf2tensorrt/utils/py_utils.cc:38] TF-TRT Warning: Could not find TensorRT
paths = glob('/home/lihkir/datasets/Dataset_BUSI_with_GT/*/*')
print(f'\033[92m')
print(f"'normal' class has {len([i for i in paths if 'normal' in i and 'mask' not in i])} images and {len([i for i in paths if 'normal' in i and 'mask' in i])} masks.")
print(f"'benign' class has {len([i for i in paths if 'benign' in i and 'mask' not in i])} images and {len([i for i in paths if 'benign' in i and 'mask' in i])} masks.")
print(f"'malignant' class has {len([i for i in paths if 'malignant' in i and 'mask' not in i])} images and {len([i for i in paths if 'malignant' in i and 'mask' in i])} masks.")
print(f"\nThere are total of {len([i for i in paths if 'mask' not in i])} images and {len([i for i in paths if 'mask' in i])} masks.")
'normal' class has 133 images and 133 masks.
'benign' class has 437 images and 454 masks.
'malignant' class has 210 images and 211 masks.
There are total of 780 images and 798 masks.
sorted(glob('/home/lihkir/datasets/Dataset_BUSI_with_GT/benign/*'))[4:7]
['/home/lihkir/datasets/Dataset_BUSI_with_GT/benign/benign (100).png',
'/home/lihkir/datasets/Dataset_BUSI_with_GT/benign/benign (100)_mask.png',
'/home/lihkir/datasets/Dataset_BUSI_with_GT/benign/benign (100)_mask_1.png']
Cargue de datos
def load_image(path, size):
"""
Carga una imagen desde la ruta especificada, la redimensiona y la convierte a escala de grises.
Parámetros:
path (str): Ruta de la imagen.
size (int): Tamaño deseado para la imagen (size x size).
Retorna:
np.array: Imagen en escala de grises normalizada.
"""
image = cv2.imread(path) # Cargar la imagen
image = cv2.resize(image, (size, size)) # Redimensionar la imagen
image = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY) # Convertir a escala de grises (shape: (size, size, 3) -> (size, size, 1))
image = image / 255. # Normalizar la imagen (valores entre 0 y 1)
return image
def load_data(root_path, size):
"""
Carga imágenes y máscaras desde una carpeta, asegurando que múltiples máscaras para una misma imagen se combinen correctamente.
Parámetros:
root_path (str): Ruta donde se encuentran las imágenes y máscaras.
size (int): Tamaño deseado para las imágenes y máscaras.
Retorna:
tuple: Dos arrays de NumPy, uno con las imágenes y otro con las máscaras.
"""
images = [] # Lista para almacenar las imágenes
masks = [] # Lista para almacenar las máscaras
x = 0 # Variable auxiliar para identificar imágenes con más de una máscara
for path in sorted(glob(root_path)): # Iterar sobre las imágenes y máscaras en la carpeta
img = load_image(path, size) # Cargar la imagen o máscara
if 'mask' in path: # Verificar si el archivo es una máscara
if x: # Si la imagen ya tiene una máscara previa
masks[-1] += img # Sumar la nueva máscara a la última almacenada
# Al sumar dos máscaras, los valores pueden variar entre 0 y 2, por lo que se normaliza nuevamente al rango 0-1.
masks[-1] = np.array(masks[-1] > 0.5, dtype='float64')
else:
masks.append(img) # Agregar la máscara a la lista
x = 1 # Marcar que esta imagen tiene una máscara
else:
images.append(img) # Agregar la imagen a la lista
x = 0 # Reiniciar el indicador para la siguiente imagen
return np.array(images), np.array(masks) # Convertir listas a arrays de NumPy y retornar
X, y = load_data(root_path='/home/lihkir/datasets/Dataset_BUSI_with_GT/*/*', size = 128)
EDA
fig, ax = plt.subplots(1, 3, figsize=(10, 5))
# Rango de índices para cada categoría de imágenes en el conjunto de datos:
# X[0:437] - Benigno
# X[437:647] - Maligno
# X[647:780] - Normal
# Selecciona un índice aleatorio dentro del rango de imágenes normales
i = np.random.randint(647, 780)
# Muestra la imagen original en escala de grises
ax[0].imshow(X[i], cmap='gray')
ax[0].set_title('Imagen')
# Muestra la máscara correspondiente
ax[1].imshow(y[i], cmap='gray')
ax[1].set_title('Máscara')
# Superpone la máscara sobre la imagen original con transparencia
ax[2].imshow(X[i], cmap='gray')
ax[2].imshow(tf.squeeze(y[i]), alpha=0.5, cmap='jet')
ax[2].set_title('Superposición')
# Título general de la figura
fig.suptitle('Clase Normal', fontsize=16)
# Muestra la figura con las imágenes
plt.show()
WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
I0000 00:00:1742351308.174658 1910 cuda_executor.cc:1001] could not open file to read NUMA node: /sys/bus/pci/devices/0000:01:00.0/numa_node
Your kernel may have been built without NUMA support.
I0000 00:00:1742351308.925600 1910 cuda_executor.cc:1001] could not open file to read NUMA node: /sys/bus/pci/devices/0000:01:00.0/numa_node
Your kernel may have been built without NUMA support.
I0000 00:00:1742351308.925692 1910 cuda_executor.cc:1001] could not open file to read NUMA node: /sys/bus/pci/devices/0000:01:00.0/numa_node
Your kernel may have been built without NUMA support.
I0000 00:00:1742351308.934129 1910 cuda_executor.cc:1001] could not open file to read NUMA node: /sys/bus/pci/devices/0000:01:00.0/numa_node
Your kernel may have been built without NUMA support.
I0000 00:00:1742351308.934177 1910 cuda_executor.cc:1001] could not open file to read NUMA node: /sys/bus/pci/devices/0000:01:00.0/numa_node
Your kernel may have been built without NUMA support.
I0000 00:00:1742351308.934196 1910 cuda_executor.cc:1001] could not open file to read NUMA node: /sys/bus/pci/devices/0000:01:00.0/numa_node
Your kernel may have been built without NUMA support.
I0000 00:00:1742351309.072419 1910 cuda_executor.cc:1001] could not open file to read NUMA node: /sys/bus/pci/devices/0000:01:00.0/numa_node
Your kernel may have been built without NUMA support.
I0000 00:00:1742351309.072509 1910 cuda_executor.cc:1001] could not open file to read NUMA node: /sys/bus/pci/devices/0000:01:00.0/numa_node
Your kernel may have been built without NUMA support.
2025-03-18 21:28:29.072547: I tensorflow/core/common_runtime/gpu/gpu_device.cc:2112] Could not identify NUMA node of platform GPU id 0, defaulting to 0. Your kernel may not have been built with NUMA support.
I0000 00:00:1742351309.072612 1910 cuda_executor.cc:1001] could not open file to read NUMA node: /sys/bus/pci/devices/0000:01:00.0/numa_node
Your kernel may have been built without NUMA support.
2025-03-18 21:28:29.073281: I tensorflow/core/common_runtime/gpu/gpu_device.cc:2021] Created device /job:localhost/replica:0/task:0/device:GPU:0 with 9558 MB memory: -> device: 0, name: NVIDIA GeForce RTX 4070, pci bus id: 0000:01:00.0, compute capability: 8.9

fig, ax = plt.subplots(1,3, figsize=(10,5))
i = np.random.randint(437)
ax[0].imshow(X[i], cmap='gray')
ax[0].set_title('Image')
ax[1].imshow(y[i], cmap='gray')
ax[1].set_title('Mask')
ax[2].imshow(X[i], cmap='gray')
ax[2].imshow(tf.squeeze(y[i]), alpha=0.5, cmap='jet')
ax[2].set_title('Union')
fig.suptitle('Benign class', fontsize=16)
plt.show()

fig, ax = plt.subplots(1,3, figsize=(10,5))
i = np.random.randint(437,647)
ax[0].imshow(X[i], cmap='gray')
ax[0].set_title('Image')
ax[1].imshow(y[i], cmap='gray')
ax[1].set_title('Mask')
ax[2].imshow(X[i], cmap='gray')
ax[2].imshow(tf.squeeze(y[i]), alpha=0.5, cmap='jet')
ax[2].set_title('Union')
fig.suptitle('Malignant class', fontsize=16)
plt.show()

Preprocesamiento
# Eliminar la clase "normal" porque no tiene máscara (usar las primera 647)
X = X[:647]
y = y[:647]
print(f"Forma de X: {X.shape} | Forma de y: {y.shape}")
# Preparar los datos para el modelado. Agregar número de canales
X = np.expand_dims(X, -1)
y = np.expand_dims(y, -1)
print(f"\nForma de X: {X.shape} | Forma de y: {y.shape}")
Forma de X: (647, 128, 128) | Forma de y: (647, 128, 128)
Forma de X: (647, 128, 128, 1) | Forma de y: (647, 128, 128, 1)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.1)
print(f'\033[92m')
print('X_train shape:',X_train.shape)
print('y_train shape:',y_train.shape)
print('X_test shape:',X_test.shape)
print('y_test shape:',y_test.shape)
X_train shape: (582, 128, 128, 1)
y_train shape: (582, 128, 128, 1)
X_test shape: (65, 128, 128, 1)
y_test shape: (65, 128, 128, 1)
Construcción de la Arquitectura U-Net
Conv block
def conv_block(input, num_filters):
conv = Conv2D(num_filters, (3, 3), activation="relu", padding="same", kernel_initializer='he_normal')(input)
conv = Conv2D(num_filters, (3, 3), activation="relu", padding="same", kernel_initializer='he_normal')(conv)
return conv
Encoder block
def encoder_block(input, num_filters):
conv = conv_block(input, num_filters)
pool = MaxPooling2D((2, 2))(conv)
return conv, pool
Decoder block
def decoder_block(input, skip_features, num_filters):
uconv = Conv2DTranspose(num_filters, (2, 2), strides=2, padding="same")(input)
con = concatenate([uconv, skip_features])
conv = conv_block(con, num_filters)
return conv
Modelo U-Net
def build_model(input_shape):
input_layer = Input(input_shape)
s1, p1 = encoder_block(input_layer, 64)
s2, p2 = encoder_block(p1, 128)
s3, p3 = encoder_block(p2, 256)
s4, p4 = encoder_block(p3, 512)
b1 = conv_block(p4, 1024)
d1 = decoder_block(b1, s4, 512)
d2 = decoder_block(d1, s3, 256)
d3 = decoder_block(d2, s2, 128)
d4 = decoder_block(d3, s1, 64)
output_layer = Conv2D(1, 1, padding="same", activation="sigmoid")(d4)
model = Model(input_layer, output_layer, name="U-Net")
return model
model = build_model(input_shape=(size, size, 1))
model.compile(loss="binary_crossentropy", optimizer="Adam", metrics=["accuracy"])
tf.keras.utils.plot_model(model, show_shapes=True)

model.summary()
Model: "U-Net"
┏━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━┓ ┃ Layer (type) ┃ Output Shape ┃ Param # ┃ Connected to ┃ ┡━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━┩ │ input_layer │ (None, 128, 128, │ 0 │ - │ │ (InputLayer) │ 1) │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ conv2d (Conv2D) │ (None, 128, 128, │ 640 │ input_layer[0][0] │ │ │ 64) │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ conv2d_1 (Conv2D) │ (None, 128, 128, │ 36,928 │ conv2d[0][0] │ │ │ 64) │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ max_pooling2d │ (None, 64, 64, │ 0 │ conv2d_1[0][0] │ │ (MaxPooling2D) │ 64) │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ conv2d_2 (Conv2D) │ (None, 64, 64, │ 73,856 │ max_pooling2d[0]… │ │ │ 128) │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ conv2d_3 (Conv2D) │ (None, 64, 64, │ 147,584 │ conv2d_2[0][0] │ │ │ 128) │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ max_pooling2d_1 │ (None, 32, 32, │ 0 │ conv2d_3[0][0] │ │ (MaxPooling2D) │ 128) │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ conv2d_4 (Conv2D) │ (None, 32, 32, │ 295,168 │ max_pooling2d_1[… │ │ │ 256) │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ conv2d_5 (Conv2D) │ (None, 32, 32, │ 590,080 │ conv2d_4[0][0] │ │ │ 256) │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ max_pooling2d_2 │ (None, 16, 16, │ 0 │ conv2d_5[0][0] │ │ (MaxPooling2D) │ 256) │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ conv2d_6 (Conv2D) │ (None, 16, 16, │ 1,180,160 │ max_pooling2d_2[… │ │ │ 512) │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ conv2d_7 (Conv2D) │ (None, 16, 16, │ 2,359,808 │ conv2d_6[0][0] │ │ │ 512) │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ max_pooling2d_3 │ (None, 8, 8, 512) │ 0 │ conv2d_7[0][0] │ │ (MaxPooling2D) │ │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ conv2d_8 (Conv2D) │ (None, 8, 8, │ 4,719,616 │ max_pooling2d_3[… │ │ │ 1024) │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ conv2d_9 (Conv2D) │ (None, 8, 8, │ 9,438,208 │ conv2d_8[0][0] │ │ │ 1024) │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ conv2d_transpose │ (None, 16, 16, │ 2,097,664 │ conv2d_9[0][0] │ │ (Conv2DTranspose) │ 512) │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ concatenate │ (None, 16, 16, │ 0 │ conv2d_transpose… │ │ (Concatenate) │ 1024) │ │ conv2d_7[0][0] │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ conv2d_10 (Conv2D) │ (None, 16, 16, │ 4,719,104 │ concatenate[0][0] │ │ │ 512) │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ conv2d_11 (Conv2D) │ (None, 16, 16, │ 2,359,808 │ conv2d_10[0][0] │ │ │ 512) │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ conv2d_transpose_1 │ (None, 32, 32, │ 524,544 │ conv2d_11[0][0] │ │ (Conv2DTranspose) │ 256) │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ concatenate_1 │ (None, 32, 32, │ 0 │ conv2d_transpose… │ │ (Concatenate) │ 512) │ │ conv2d_5[0][0] │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ conv2d_12 (Conv2D) │ (None, 32, 32, │ 1,179,904 │ concatenate_1[0]… │ │ │ 256) │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ conv2d_13 (Conv2D) │ (None, 32, 32, │ 590,080 │ conv2d_12[0][0] │ │ │ 256) │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ conv2d_transpose_2 │ (None, 64, 64, │ 131,200 │ conv2d_13[0][0] │ │ (Conv2DTranspose) │ 128) │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ concatenate_2 │ (None, 64, 64, │ 0 │ conv2d_transpose… │ │ (Concatenate) │ 256) │ │ conv2d_3[0][0] │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ conv2d_14 (Conv2D) │ (None, 64, 64, │ 295,040 │ concatenate_2[0]… │ │ │ 128) │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ conv2d_15 (Conv2D) │ (None, 64, 64, │ 147,584 │ conv2d_14[0][0] │ │ │ 128) │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ conv2d_transpose_3 │ (None, 128, 128, │ 32,832 │ conv2d_15[0][0] │ │ (Conv2DTranspose) │ 64) │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ concatenate_3 │ (None, 128, 128, │ 0 │ conv2d_transpose… │ │ (Concatenate) │ 128) │ │ conv2d_1[0][0] │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ conv2d_16 (Conv2D) │ (None, 128, 128, │ 73,792 │ concatenate_3[0]… │ │ │ 64) │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ conv2d_17 (Conv2D) │ (None, 128, 128, │ 36,928 │ conv2d_16[0][0] │ │ │ 64) │ │ │ ├─────────────────────┼───────────────────┼────────────┼───────────────────┤ │ conv2d_18 (Conv2D) │ (None, 128, 128, │ 65 │ conv2d_17[0][0] │ │ │ 1) │ │ │ └─────────────────────┴───────────────────┴────────────┴───────────────────┘
Total params: 31,030,593 (118.37 MB)
Trainable params: 31,030,593 (118.37 MB)
Non-trainable params: 0 (0.00 B)
from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint
import os
from joblib import dump, load
checkpointer = [
EarlyStopping(monitor='val_accuracy', verbose=1, restore_best_weights=True, mode='max', patience=10),
ModelCheckpoint(
filepath='model.weights.best.keras',
monitor='val_accuracy',
verbose=1,
save_best_only=True,
mode='max'
)
]
history_unet = None
if os.path.exists('history_unet.joblib'):
history_unet = load('history_unet.joblib')
print("El archivo 'history_unet.joblib' ya existe. Se ha cargado el historial del entrenamiento.")
else:
history_unet = model.fit(
X_train, y_train,
epochs=100,
validation_data=(X_test, y_test),
callbacks=checkpointer,
verbose=2
)
dump(history_unet.history, 'history_unet.joblib')
print("El entrenamiento se ha completado y el historial ha sido guardado en 'history_unet.joblib'.")
Epoch 1/100
Epoch 1: val_accuracy improved from -inf to 0.94092, saving model to model.weights.best.keras
19/19 - 4s - 213ms/step - accuracy: 0.9921 - loss: 0.0152 - val_accuracy: 0.9409 - val_loss: 0.4926
Epoch 2/100
Epoch 2: val_accuracy did not improve from 0.94092
19/19 - 2s - 118ms/step - accuracy: 0.9921 - loss: 0.0153 - val_accuracy: 0.9372 - val_loss: 0.4994
Epoch 3/100
Epoch 3: val_accuracy did not improve from 0.94092
19/19 - 2s - 118ms/step - accuracy: 0.9925 - loss: 0.0143 - val_accuracy: 0.9393 - val_loss: 0.5038
Epoch 4/100
Epoch 4: val_accuracy did not improve from 0.94092
19/19 - 2s - 119ms/step - accuracy: 0.9929 - loss: 0.0132 - val_accuracy: 0.9393 - val_loss: 0.5313
Epoch 5/100
Epoch 5: val_accuracy did not improve from 0.94092
19/19 - 2s - 119ms/step - accuracy: 0.9929 - loss: 0.0132 - val_accuracy: 0.9401 - val_loss: 0.5307
Epoch 6/100
Epoch 6: val_accuracy did not improve from 0.94092
19/19 - 2s - 118ms/step - accuracy: 0.9930 - loss: 0.0134 - val_accuracy: 0.9386 - val_loss: 0.5150
Epoch 7/100
Epoch 7: val_accuracy did not improve from 0.94092
19/19 - 2s - 118ms/step - accuracy: 0.9929 - loss: 0.0136 - val_accuracy: 0.9378 - val_loss: 0.4967
Epoch 8/100
Epoch 8: val_accuracy did not improve from 0.94092
19/19 - 2s - 118ms/step - accuracy: 0.9928 - loss: 0.0133 - val_accuracy: 0.9387 - val_loss: 0.4966
Epoch 9/100
Epoch 9: val_accuracy did not improve from 0.94092
19/19 - 2s - 118ms/step - accuracy: 0.9934 - loss: 0.0122 - val_accuracy: 0.9389 - val_loss: 0.5372
Epoch 10/100
Epoch 10: val_accuracy did not improve from 0.94092
19/19 - 2s - 118ms/step - accuracy: 0.9933 - loss: 0.0124 - val_accuracy: 0.9394 - val_loss: 0.5180
Epoch 11/100
Epoch 11: val_accuracy did not improve from 0.94092
19/19 - 2s - 118ms/step - accuracy: 0.9934 - loss: 0.0121 - val_accuracy: 0.9396 - val_loss: 0.5614
Epoch 11: early stopping
Restoring model weights from the end of the best epoch: 1.
El entrenamiento se ha completado y el historial ha sido guardado en 'history_unet.joblib'.
fig, ax = plt.subplots(1, 2, figsize=(10,3))
ax[0].plot(history.epoch, history.history["loss"], label="Train loss")
ax[0].plot(history.epoch, history.history["val_loss"], label="Validation loss")
ax[0].legend()
ax[1].plot(history.epoch, history.history["accuracy"], label="Train accuracy")
ax[1].plot(history.epoch, history.history["val_accuracy"], label="Validation accuracy")
ax[1].legend()
fig.suptitle('Loss and Accuracy', fontsize=16)
plt.show()

Evaluación
fig, ax = plt.subplots(5,3, figsize=(10,18))
j = np.random.randint(0, X_test.shape[0], 5)
for i in range(5):
ax[i,0].imshow(X_test[j[i]], cmap='gray')
ax[i,0].set_title('Image')
ax[i,1].imshow(y_test[j[i]], cmap='gray')
ax[i,1].set_title('Mask')
ax[i,2].imshow(model.predict(np.expand_dims(X_test[j[i]],0),verbose=0)[0], cmap='gray')
ax[i,2].set_title('Prediction')
fig.suptitle('Results', fontsize=16)
plt.show()

print(f'\033[93m')
y_pred=model.predict(X_test,verbose=0)
y_pred_thresholded = y_pred > 0.5
# mean Intersection-Over-Union metric
IOU_keras = MeanIoU(num_classes=2)
IOU_keras.update_state(y_pred_thresholded, y_test)
print("Mean IoU =", IOU_keras.result().numpy())
prec_score = Precision()
prec_score.update_state(y_pred_thresholded, y_test)
p = prec_score.result().numpy()
print('Precision Score = %.3f' % p)
recall_score = Recall()
recall_score.update_state(y_pred_thresholded, y_test)
r = recall_score.result().numpy()
print('Recall Score = %.3f' % r)
f1_score = 2*(p*r)/(p+r)
print('F1 Score = %.3f' % f1_score)
Mean IoU = 0.7645979
Precision Score = 0.680
Recall Score = 0.818
F1 Score = 0.743
Evaluación del Algoritmo de Segmentación
En un algoritmo de segmentación, estas métricas evalúan la calidad de la segmentación comparando la máscara predicha con la máscara real (ground truth).
1. Mean IoU (Intersection over Union) = 0.7593
Mide la superposición entre la segmentación predicha y la real.
Se calcula como:
Donde:
Área de intersección: Es la cantidad de píxeles correctamente segmentados en la imagen, es decir, los píxeles que coinciden entre la predicción y la verdad (ground truth). Ejemplo: Si la segmentación real marca 40 píxeles como “gato” y la segmentación predicha marca 50 píxeles, pero 30 de ellos coinciden, entonces la intersección es 30 píxeles.
Área de unión: Es la cantidad total de píxeles marcados como objeto en cualquiera de las dos máscaras (predicha o real). Ejemplo: Siguiendo el caso anterior: 40 píxeles están en la máscara real. 50 píxeles están en la máscara predicha. 30 píxeles coinciden (intersección). La unión es: 40 + 50 − 30 = 60 píxeles
Un IoU de 0.7593 indica que, en promedio, el 75.93% del área segmentada coincide con la verdad.
2. Precision Score = 0.695
Evalúa cuántos de los píxeles predichos como positivos realmente lo son.
Se calcula como:
Un valor de 0.695 significa que el 69.5% de las predicciones positivas fueron correctas.
3. Recall Score = 0.786
Mide cuántos de los píxeles verdaderamente positivos fueron detectados.
Se calcula como:
Un recall de 0.786 indica que el 78.6% de los píxeles reales fueron correctamente detectados.
4. F1 Score = 0.738
Es la media armónica entre precisión y recall, equilibrando ambos valores.
Se calcula como:
Un F1 de 0.738 indica un buen equilibrio entre precisión y recall.
Interpretación Global
El modelo segmenta bien las imágenes (IoU alto).
El recall es mayor que la precisión, lo que indica que el modelo detecta la mayoría de los píxeles relevantes, pero a costa de generar algunos falsos positivos.
El F1-score de 0.738 sugiere que el modelo tiene un rendimiento sólido pero puede mejorarse.
10.15. Redes Neuronales Recurrentes#
Introducción
Recordemos de la sección anterior que en el corazón de las redes convolucionales se encuentra el concepto de peso compartido. Es decir, la misma matriz de filtro se desliza sobre una matriz de imágenes en lugar de dedicar un peso específico a cada píxel de la imagen. De este modo, una red neuronal puede escalarse fácilmente a imágenes de diferentes dimensiones.
Nuestro interés en esta sección se centra en el caso de los datos secuenciales. Es decir, los vectores de entrada no son independientes, sino que aparecen en secuencia. Además, el orden específico en que se producen encierra información importante. Por ejemplo, este tipo de secuencias se dan en el reconocimiento del habla y en el procesamiento del lenguaje, como la traducción automática, así como también el pronostico de series de tiempo financieras. Sin duda, la secuencia en la que se producen las palabras es de suma importancia.
El reparto de pesos mediante convoluciones también podría ser y ha sido utilizado para estos casos [Lang et al., 1990]. Tales redes se conocen como redes neuronales de retardo temporal. Sin embargo, deslizar un filtro a través del tiempo para formar convoluciones es una operación de naturaleza local. La salida es una función de las muestras de entrada dentro de una ventana temporal que abarca la longitud de la respuesta al impulso del filtro, que por razones prácticas no puede ser muy larga.
Redes Neuronales Recurrentes
Las variables que intervienen en una RNN son:
Vector de estado en el tiempo \(n\), denotado como \(\boldsymbol{h}_{n}\). El símbolo nos recuerda que \(\boldsymbol{h}\) es un vector de variables ocultas (capa oculta en la jerga de las redes neuronales); el vector de estado constituye la memoria del sistema,
Vector de entrada en el momento \(n\), denominado \(\boldsymbol{x}_{n}\),
Vector de salida en el momento \(n\), \(\hat{\boldsymbol{y}}_{n}\), y el vector de salida objetivo, \(\boldsymbol{y}_{n}\).
El modelo se describe mediante un conjunto de matrices y vectores de parámetros desconocidos, a saber, \(U, W, V , \boldsymbol{b}\) y \(\boldsymbol{c}\), que deben aprenderse durante el entrenamiento.
Las ecuaciones que describen un modelo RNN son
(10.14)#\[\begin{split} \begin{align*} \boldsymbol{h}_{n}&=f(U\boldsymbol{x}_{n}+W\boldsymbol{h}_{n-1}+\boldsymbol{b})\\ \hat{\boldsymbol{y}}_{n}&=g(V\boldsymbol{h}_{n}+\boldsymbol{c}). \end{align*} \end{split}\]donde las funciones no lineales \(f\) y \(g\)*** actúan elemento a elemento (element-wise)*** y se aplican individualmente a cada elemento de sus argumentos vectoriales.
En otras palabras, una vez que se ha observado un nuevo vector de entrada, se actualiza el vector de estado. Su nuevo valor depende de la información más reciente, transmitida por la entrada \(\boldsymbol{x}_{n}\) así como de la historia pasada, ya que ésta se ha acumulado en \(\boldsymbol{h}_{n-1}\). La salida depende del vector de estado actualizado, \(\boldsymbol{h}_{n}\). Es decir, depende de la “historia” hasta el instante actual \(n\), tal y como se expresa en \(\boldsymbol{h}_{n}\).
Las opciones típicas para \(f\) son la tangente hiperbólica, tanh, o las no linealidades ReLU. El valor inicial \(\boldsymbol{h}_{0}\) suele ser igual al vector cero. La no linealidad de salida, \(g\), se elige a menudo para ser la función softmax.

Fig. 10.49 Arquitectura de una Red Neuronal Recurrente. Fuente [Theodoridis, 2020].#
De las ecuaciones anteriores se deduce que las matrices y vectores de parámetros se comparten en todos los instantes temporales. Durante el entrenamiento, se inicializan mediante números aleatorios. El modelo gráfico asociado con Eq. (10.14) se muestra en la Fig. 10.49A. En la Fig. 10.49B, el gráfico se despliega sobre los distintos instantes de tiempo para los que se dispone de observaciones. Por ejemplo, si la secuencia de interés es una frase de 10 palabras, entonces \(N\) se establece igual a 10, mientras que \(\boldsymbol{x}_{n}\) es el vector que codifica las respectivas palabras de entrada.
10.15.1. Backpropagation en tiempo#
El entrenamiento de las RNN sigue una lógica similar a la del algoritmo backpropagation para el entrenamiento de redes neuronales de avance. Después de todo, una RNN puede verse como una red feed-forward con \(N\) capas. La capa superior es la del instante de tiempo \(N\) y la primera capa corresponde al instante de tiempo \(n = 1\). Una diferencia radica en que las capas ocultas en una RNN también producen salidas, es decir, \(\hat{\boldsymbol{y}}_{n}\), y se alimentan directamente con entradas. Sin embargo, en lo que respecta al entrenamiento, estas diferencias no afectan al razonamiento principal.
El aprendizaje de las matrices y vectores de parámetros desconocidos se consigue mediante un esquema de gradiente descendente, de acuerdo con Eq. (10.3). Resulta que los gradientes requeridos de la función de coste, con respecto a los parámetros desconocidos, tienen lugar recursivamente, comenzando en el último instante de tiempo, \(N\) , y retrocediendo en el tiempo, \(n = N-1, N-2,\dots,1\). Esta es la razón por la que el algoritmo se conoce como bakpropagation a traves del tiempo (BPTT).
La función de coste es la suma a lo largo del tiempo, \(n\), de las correspondientes contribuciones a la función de pérdida, que dependen de los valores respectivos de \(\boldsymbol{h}_{n}, \boldsymbol{x}_{n}\), es decir,
Por ejemplo, para el caso de la función de pérdida de entropía cruzada,
\[ J_{n}(U, W, V, \boldsymbol{b}, \boldsymbol{c}):=-\sum_{k}y_{nk}\ln\hat{y}_{nk}, \]donde la suma es sobre la dimensionalidad de \(\boldsymbol{y}\), y
\[ \hat{\boldsymbol{y}}_{n}=g(\boldsymbol{h}_{n}, V, \boldsymbol{c})~\text{y}~\boldsymbol{h}_{n}=f(\boldsymbol{x}_{n}, \boldsymbol{h}_{n-1}, U, W, \boldsymbol{b}). \]
En el corazón del cálculo de los gradientes de la función de coste con respecto a las diversas matrices y vectores de parámetros se encuentra el cálculo de los gradientes de \(J\) con respecto a los vectores de estado, \(\boldsymbol{h}_{n}\). Una vez calculados estos últimos, el resto de los gradientes, con respecto a las matrices y vectores de parámetros desconocidos, es una tarea sencilla. Para ello, nótese que cada \(h_{n},~n=1, 2,\dots,N-1\), afecta a \(J\) de dos maneras:
Directamente, a traves de \(J_{n}\)
Indirectamente, a través de la cadena que impone la estructura RNN, es decir,
\[ \boldsymbol{h}_{n}\rightarrow\boldsymbol{h}_{n+1}\rightarrow\cdots\rightarrow\boldsymbol{h}_{N}. \]Es decir, \(\boldsymbol{h}_{n}\), además de \(J_{n}\), también afecta a todos los valores de coste posteriores, \(J_{n+1},\dots, J_{N}\). Nótese que, a traves de la cadena \(\boldsymbol{h}_{n+1}=f(\boldsymbol{x}_{n+1}, \boldsymbol{h}_{n}, U, W, \boldsymbol{b}).\)
Empleando la regla de la cadena para las derivadas, las dependencias anteriores conducen al siguiente cálculo recursivo:
(10.15)#\[ \frac{\partial J}{\partial\boldsymbol{h}_{n}}=\underbrace{{\left(\frac{\partial\boldsymbol{h}_{n+1}}{\partial\boldsymbol{h}_{n}}\right)^{T}}\frac{\partial J}{\partial\boldsymbol{h}_{n+1}}}_{\text{parte recursiva indirecta}}+\underbrace{\left(\frac{\partial\hat{\boldsymbol{y}}_{n}}{\partial\boldsymbol{h}_{n}}\right)^{T}\frac{\partial J}{\partial\hat{\boldsymbol{y}}_{n}}}_{\text{parte directa}}, \]donde, por definición, la derivada de un vector, digamos, \(\boldsymbol{y}\), con respecto a otro vector, digamos, \(\boldsymbol{x}\), se define como la matriz
\[ \left[\frac{\partial\boldsymbol{y}}{\partial\boldsymbol{x}}\right]_{ij}:=\frac{\partial y_{i}}{\partial x_{j}}. \]
Nótese que el gradiente de la función de coste, con respecto a los parámetros ocultos (vector de estado) en la capa “\(n\)”, se da como una función del gradiente respectivo en la capa anterior, es decir, con respecto al vector de estado en el tiempo \(n + 1\). Las dos pasadas requeridas por backpropagation en el tiempo se resumen a continuación.
Pasadas de Backpropagation en Tiempo
Paso hacia adelante:
Iniciando en \(n=1\) y utilizando las estimaciones actuales de las matrices y vectores de parámetros implicados, calcular en secuencia,
\[ (\boldsymbol{h}_{1}, \hat{\boldsymbol{y}}_{1})\rightarrow(\boldsymbol{h}_{2}, \hat{\boldsymbol{y}}_{2})\rightarrow\cdots\rightarrow(\boldsymbol{h}_{N}, \hat{\boldsymbol{y}}_{N}). \]Paso hacia atrás:
Empezando en \(n = N\), calcular en secuencia,
\[ \frac{\partial J}{\partial\boldsymbol{h}_{N}}\rightarrow\frac{\partial J}{\partial\boldsymbol{h}_{N-1}}\rightarrow\cdots\rightarrow\frac{\partial J}{\partial\boldsymbol{h}_{1}}. \]
Nótese que el cálculo del gradiente \(\partial J/\partial\boldsymbol{h}_{N}\) es sencillo, y solo involucra la parte directa en Eq. (10.15).
Para la implementación de la BPTT, se procede a
inicializar aleatoriamente las matrices y vectores desconocidos implicados,
calcular todos los gradientes requeridos, siguiendo los pasos indicados anteriormente, y
realizar las actualizaciones según el esquema de gradiente descendente.
Los pasos (2) y (3) se realizan de forma iterativa hasta que se cumple un criterio de convergencia, de forma análoga al algoritmo estándar de backpropagation.
10.15.2. Desvanecimiento y explosión de gradientes#
La tarea de desvanecimiento y explosión de gradientes se ha introducido y discutido en secciones anteriores, en el contexto del algoritmo de backpropagation. Los mismos problemas se presentan en el algoritmo BPTT, dado que, este último es una forma específica del concepto de backpropagation y, como se ha dicho, una RNN puede considerarse como una red multicapa, donde cada instante de tiempo corresponde a una capa diferente. De hecho en las RNN, el fenómeno de desvanecimiento/explosión de gradiente aparece de una forma bastante “agresiva”, teniendo en cuenta que \(N\) puede alcanzar valores grandes.
La naturaleza multiplicativa de la propagación de gradientes puede verse fácilmente en la Eq. (10.15). Para ayudar a comprender el concepto principal, simplifiquemos el escenario y supongamos que sólo interviene una variante de estado. Entonces los vectores de estado se convierten en escalares, \(h_{n}\) , y la matriz \(W\) en un escalar \(w\). Además, supongamos que las salidas también son escalares. Entonces la recursión en la Eq. (10.15) se simplifica como:
Suponiendo en la Eq. (10.14) que \(f\) es la función \(\tanh(\cdot)\) estándar, teniendo en cuenta que, \(\text{sech}^2(\cdot)=1-\tanh^2(\cdot)\) y \(|\tanh(\cdot)|<1\), sobre su dominio, se ve fácilmente que
Escribiendo la recursión para dos pasos sucesivos, repitiendo el proceso en Eq. (10.16), por ejemplo, para \(\partial h_{n+2}/\partial h_{n+1}\) obtenemos que,
No es difícil ver que la multiplicación de los términos menores que uno puede llevar a valores de desvanecimiento, sobre todo si tenemos en cuenta que, en la práctica, las secuencias pueden ser bastante grandes, por ejemplo, \(N = 100\). Por lo tanto para instantes de tiempo cercanos a \(n = 1\), la contribución al gradiente del primer término del lado derecho en la Eq. (10.17) implicará un gran número de productos de números menores que uno en magnitud. Por otra parte, el valor de \(w\) estará contribuyendo en \(w^{n}\) potencia. Por lo tanto, si su valor es mayor que uno, puede conducir a valores explosivos de los gradientes respectivos.
En varios casos, se puede truncar el algoritmo de backpropagation a unos pocos pasos de tiempo. Otra forma, es sustituir la no linealidad tanh por la ReLU. Para el caso del valor explosivo, se puede introducir una técnica que recorte los valores a un umbral predeterminado, una vez que los valores superen ese umbral. Sin embargo, otra técnica que suele emplearse en la práctica es sustituir la formulación RNN estándar descrita anteriormente por una estructura alternativa, que puede hacer mejor frente a estos fenómenos causados por la dependencia a largo plazo de la RNN.
Observation 10.3
RNN profundas
: Además de la red RNN básica que comprende una sola capa de estados, se han propuesto extensiones que implican múltiples capas de estados, una encima de otra (ver [Pascanu et al., 2013]).RNN bidireccionales
: Como su nombre indica, en las RNN bidireccionales hay dos variables de estado, es decir, una denotada como \(\overset{\rightarrow}{\boldsymbol{h}}\), que se propaga hacia adelante, y otra, \(\overset{\leftarrow}{\boldsymbol{h}}\), que se propaga hacia atrás. De este modo, las salidas dependen tanto del pasado como del futuro (ver [Graves et al., 2013]).
Ejemplo
Ejemplo de BPTT con dos secuencias de texto. Este ejemplo utiliza dos secuencias de texto (una positiva y una negativa) para demostrar cómo funciona el proceso de Backpropagation Through Time (BPTT) en una red recurrente. Además, mostramos la actualización de los pesos mediante gradiente descendente.
Paso 1: Definición de los datos y parámetros
Secuencias de Texto
Secuencia 1 (Positiva):
"I love this movie"
Secuencia 2 (Negativa):
"This movie is terrible"
Embeddings de las Palabras
Cada palabra se representa por un vector embedding (
Word2Vec
) de 3 dimensiones:"I"
= \([0.1, 0.2, 0.1]\)"love"
= \([0.4, 0.5, 0.6]\)"this"
= \([0.3, 0.1, 0.3]\)"movie"
= \([0.5, 0.1, -0.3]\)"is"
= \([0.2, 0.3, 0.2]\)"terrible"
= \([-0.5, -0.6, -0.7]\)
Etiquetas de Salida (Clases)
Secuencia 1: clase 1 (positiva)
Secuencia 2: clase 0 (negativa)
Definir los Datos y Parámetros Iniciales (tres filas en las matrices significa que hay tres unidades en la capa oculta)
Sesgos
Paso 2: Propagación hacia Adelante
Vamos a recalcular las salidas de los estados ocultos \(h_n\) y la salida final \(y_n\), incluyendo la función de activación en la salida (
softmax
).Tiempo \(n = 1\) (
"I"
)Para la palabra
"I"
, \(x_1 = [0.1, 0.2, 0.1]\):
Calcular la propagación hacia adelante del estado oculto \(h_1\):
\[\begin{split} U \cdot x_1 = U \cdot [0.1, 0.2, 0.1]^\top = \begin{bmatrix} (0.1 \times 0.1) + (-0.2 \times 0.2) + (0.3 \times 0.1) \\ (0.4 \times 0.1) + (0.1 \times 0.2) + (-0.3 \times 0.1) \\ (0.3 \times 0.1) + (0.2 \times 0.2) + (-0.1 \times 0.1) \end{bmatrix} = \begin{bmatrix} 0.01 + (-0.04) + 0.03 \\ 0.04 + 0.02 + (-0.03) \\ 0.03 + 0.04 + (-0.01) \end{bmatrix} = \begin{bmatrix} 0.02 \\ 0.05 \\ 0.07 \end{bmatrix} \end{split}\]Como \(h_0 = [0, 0, 0]\):
\[ h_1 = \tanh([0.02, 0.05, 0.07] + b) = \tanh([0.12, 0.15, 0.17]) \approx [0.119, 0.149, 0.168] \]Tiempo \(n = 2\) (
"love"
)Para la palabra
"love"
, \(x_2 = [0.4, 0.5, 0.6]\):
Calcular la propagación hacia adelante del estado oculto \(h_2\):
\[\begin{split} U \cdot x_2 = \begin{bmatrix} 0.22 \\ 0.13 \\ 0.15 \end{bmatrix} \end{split}\]Calcular el estado recurrente con \(W \cdot h_1\):
\[\begin{split} W \cdot h_1 = \begin{bmatrix} 0.065 \\ 0.096 \\ 0.074 \end{bmatrix} \end{split}\]Propagación total del estado oculto \(h_2\):
\[ h_2 = \tanh([0.22, 0.13, 0.15] + [0.065, 0.096, 0.074] + b) = \tanh([0.385, 0.376, 0.394]) \approx [0.367, 0.359, 0.374] \]
Paso 3: Cálculo de la Salida con Activación Softmax
La salida se calcula utilizando la matriz \(V\), aplicando después la función de activación
softmax
para obtener las probabilidades de las dos clases.
Calculamos la salida \(\hat{y}_{2}\) antes de la activación:
\[\begin{split} \hat{y}_{2} = V \cdot h_2 = \begin{bmatrix} 0.2 & -0.3 \\ 0.4 & 0.1 \\ -0.2 & 0.3 \end{bmatrix} \cdot [0.367, 0.359, 0.374]^\top = \begin{bmatrix} 0.0516 \\ 0.1108 \end{bmatrix} \end{split}\]Aplicamos la función de activación
softmax
para convertir la salida en probabilidades:\[\begin{split} \text{softmax}(\hat{y}_{2}) = \frac{e^{\hat{y}_{2}}}{\sum e^{\hat{y}_{2}}} = \frac{\begin{bmatrix} e^{0.0516} \\ e^{0.1108} \end{bmatrix}}{e^{0.0516} + e^{0.1108}} = \begin{bmatrix} 0.4852 \\ 0.5148 \end{bmatrix} \end{split}\]
Paso 4: Retropropagación del Error
Ahora calculamos el error y el gradiente con respecto a los pesos \(V\), \(W\), \(U\), y los sesgos, usando el método de cross-entropy y BPTT
Cálculo del Error
La pérdida es:
\[ \text{Loss} = - \left[ y_{\text{real}} \cdot \log(\hat{y}) + (1 - y_{\text{real}}) \cdot \log(1 - \hat{y}) \right] \]“Nota: aquí, 0.5148 es la probabilidad asignada a la clase considerada correcta (la etiqueta real), y 0.4852 es la probabilidad para la clase incorrecta”. Si la clase verdadera es 1 (positiva) el error es:
\[ \text{Loss} = - \log(0.5148) \approx 0.663 \]Gradientes
Gradiente para \(V\):
\[ \frac{\partial L}{\partial V} = (y_{\text{pred}} - y_{\text{real}}) \cdot h_2 \]\[\begin{split} \frac{\partial L}{\partial V} = \left( \begin{bmatrix} 0.4852 \\ 0.5148 \end{bmatrix} - \begin{bmatrix} 0 \\ 1 \end{bmatrix} \right) \cdot \begin{bmatrix} 0.367 \\ 0.359 \\ 0.374 \end{bmatrix}^\top \end{split}\]\[\begin{split} \frac{\partial L}{\partial V} = \begin{bmatrix} 0.4852 & 0.4852 \\ -0.4852 & -0.4852 \\ 0.5148 & -0.5148 \end{bmatrix} \end{split}\]Gradiente para \(W\) y \(U\): Usamos la retropropagación a través del tiempo (BPTT). Los gradientes para \(W\) y \(U\) se calculan usando la regla de la cadena considerando las derivadas del estado oculto.
Paso 5: Actualización de Pesos
Usamos gradiente descendente para actualizar los pesos. Dado un valor de \(\eta = 0.01\):
\[ V \leftarrow V - \eta \frac{\partial L}{\partial V} \]\[ W \leftarrow W - \eta \frac{\partial L}{\partial W}, \quad U \leftarrow U - \eta \frac{\partial L}{\partial U} \]Actualización de los pesos para \(V\):
\[\begin{split} V_{\text{nuevo}} = V - 0.01 \times \begin{bmatrix} 0.4852 & 0.4852 \\ -0.4852 & -0.4852 \\ 0.5148 & -0.5148 \end{bmatrix}=\begin{bmatrix} 0.495148 & 0.595148 \\ 0.404852 & 0.304852 \\ -0.005148 & 0.005148 \end{bmatrix} \end{split}\]Realiza las actualizaciones análogas para \(W\) y \(U\).
10.16. Red de memoria a largo plazo (LSTM)#
Observation 10.4
La idea clave de la red LSTM, propuesta en el artículo seminal [Hochreiter and Schmidhuber, 1997], es el llamado estado de celda, que ayuda a superar los problemas asociados con los fenómenos de desvanecimiento/explosión que son causados por las dependencias largo plazo dentro de la red.
Las redes LSTM tienen la capacidad incorporada de controlar el flujo de información que entra y sale de la memoria del sistema mediante algoritmos no lineales. Estas puertas se implementan mediante la no linealidad sigmoidea y un multiplicador.
Desde un punto de vista algorítmico, las puertas equivalen a aplicar una ponderación al flujo de información correspondiente. Los pesos se sitúan en el intervalo \([0,1]\) y dependen de los valores de las variables implicadas que activan la no linealidad sigmoidea.
En otras palabras, la ponderación (control) de la información tiene lugar en el contexto. Según este razonamiento, la red tiene la agilidad de olvidar la información que ya ha sido utilizada y ya no es necesaria. La célula/unidad LSTM básica se se muestra en la Fig. 10.50. Se construye en torno a dos conjuntos de variables, apiladas en el vector \(\boldsymbol{s}\), que se conoce como el estado de la célula o unidad, y el vector \(\boldsymbol{h}\), que se conoce como el vector de variables ocultas. Una red LSTM se construye a partir de la concatenación sucesiva de esta unidad básica. La unidad correspondiente al tiempo \(n\), además del vector de entrada, \(\boldsymbol{x}_{n}\), recibe \(\boldsymbol{s}_{n-1}\) y \(\boldsymbol{h}_{n-1}\) de la etapa anterior y pasa \(\boldsymbol{s}_{n}\) y \(\boldsymbol{h}_{n}\) a la siguiente. A continuación se resumen las ecuaciones de actualización asociadas

Fig. 10.50 Arquitectura de unidad LSTM. Fuente [Theodoridis, 2020].#
donde \(\circ\) denota el producto elemento a elemento entre vectores o matrices (producto de Hadamard), es decir, \((s\circ f)_{i} = s_{i}f_{i}\), y \(\sigma\) denota la función sigmoidea logística.
Observation 10.5
Nótese que el estado de la célula, \(\boldsymbol{s}\), pasa información directa del instante anterior al siguiente. Esta información es controlada primero por la primera puerta, según los elementos en \(f\), que toman valores en el rango \([0, 1]\), dependiendo de la entrada actual y de las variables ocultas que se reciben de la etapa anterior. Esto es lo que decíamos antes, es decir, que la ponderación se ajusta en “contexto”. A continuación, se añade nueva información, es decir, \(\tilde{\boldsymbol{s}}\), a \(\boldsymbol{s}_{n-1}\), que también está controlada por la segunda red de puertas sigmoidales (es decir, \(\boldsymbol{i}\)). Así se garantiza que la información del pasado se transmita al futuro de forma directa, lo cual, ayuda a la red a memorizar información..
Resulta que este tipo de memoria explota mejor las dependencias de largo alcance en los datos, en comparación con la estructura RNN básica. El vector de variables ocultas \(\boldsymbol{h}\) está controlado tanto por el estado de la célula como por los valores actuales de las variables de entrada y de estados anteriores. Todas las matrices y vectores implicados se aprenden en la fase de entrenamiento. Nótese que hay dos líneas asociadas a \(\boldsymbol{h}_{n}\). La de la derecha conduce a la siguiente etapa y la de la parte superior se utiliza para proporcionar la salida, \(\hat{\boldsymbol{y}}_{n}\), en el tiempo \(n\), a través de, digamos, la no linealidad softmax, como en las RNN estándar en Eq. (10.14).
Variantes y Aplicaciones
Además de la estructura LSTM ya comentada, se han propuesto diversas variantes. Un extenso estudio comparativo entre diferentes arquitecturas LSTM y RNN se puede encontrar en [Greff et al., 2016, Jozefowicz et al., 2015]. Las RNNs y las LSTMs se han utilizado con éxito en una amplia gama de aplicaciones, tales como:
el modelado del lenguaje [Sutskever et al., 2011],
traducción de máquinas [Liu et al., 2014],
reconocimiento del habla [Graves and Jaitly, 2014],
visión artificial para la generación de descriptores de imágenes [Karpathy and Fei-Fei, 2015],
análisis de datos de fMRI para comprender la dinámica temporal de las redes cerebrales asociadas [Seo et al., 2019].
pronostico de volatilidad de Bitcoin [Pratas et al., 2023]
Por ejemplo, en el procesamiento del lenguaje la entrada suele ser una secuencia de palabras, que se codifican como números (son punteros al diccionario disponible). La salida es la secuencia de palabras que hay que predecir. Durante el entrenamiento, se establece \(\boldsymbol{y}_{n} = \boldsymbol{x}_{n+1}\). Es decir, la red se entrena como un predictor no lineal.
10.17. Aplicación: Procesamiento del Lenguaje Natural#
import warnings
warnings.filterwarnings('ignore')
import sys
import tensorflow.keras
import pandas as pd
import sklearn as sk
import tensorflow as tf
Iniciamos verificando que
Tensorflow
está correctamente instalado, y que, además, puede utilizar la GPU disponible en su computadora. En este caso corresponde a una tarjeta de video dedicadaGTX 4070 Maxq 12G
print(f"Tensor Flow Version: {tf.__version__}");
print();
print(f"Python {sys.version}");
print(f"Pandas {pd.__version__}");
print(f"Scikit-Learn {sk.__version__}");
gpu = len(tf.config.list_physical_devices('GPU'))>0
print("GPU is", "available" if gpu else "NOT AVAILABLE");
Tensor Flow Version: 2.17.0
Python 3.10.12 (main, Sep 11 2024, 15:47:36) [GCC 11.4.0]
Pandas 2.2.3
Scikit-Learn 1.5.2
GPU is available
La librería
gensim
utilizada en la presente implementación, debe ser instalada en suversión 3.8.3
usando la orden. Además, debe instalargraphviz
del siguiente sitio web (ver GraphViz)
pip install gensim==3.8.3
import pandas as pd
import numpy as np
from tqdm import tqdm
from tensorflow.keras.preprocessing.text import Tokenizer
tqdm.pandas(desc="progress-bar")
from gensim.models import Doc2Vec
from sklearn import utils
from sklearn.model_selection import train_test_split
from keras_preprocessing.sequence import pad_sequences
import gensim
from sklearn.linear_model import LogisticRegression
from gensim.models.doc2vec import TaggedDocument
import re
import seaborn as sns
import matplotlib.pyplot as plt
df = pd.read_csv('https://raw.githubusercontent.com/lihkir/Data/main/spam_text_class.csv',delimiter=',',encoding='latin-1')
df = df[['Category','Message']]
df = df[pd.notnull(df['Message'])]
df.rename(columns = {'Message':'Message'}, inplace = True)
df.head()
Category | Message | |
---|---|---|
0 | ham | Go until jurong point, crazy.. Available only ... |
1 | ham | Ok lar... Joking wif u oni... |
2 | spam | Free entry in 2 a wkly comp to win FA Cup fina... |
3 | ham | U dun say so early hor... U c already then say... |
4 | ham | Nah I don't think he goes to usf, he lives aro... |
df.shape
(5572, 2)
df.index = range(5572)
df['Message'].apply(lambda x: len(x.split(' '))).sum()
87265
import matplotlib
matplotlib.rc_file_defaults()
cnt_pro = df['Category'].value_counts()
sns.barplot(x=cnt_pro.index, y=cnt_pro.values)
plt.ylabel('Number of Occurrences', fontsize=12)
plt.xlabel('Category', fontsize=12)
plt.xticks(rotation=90)
plt.show();

def print_message(index):
example = df[df.index == index][['Message', 'Category']].values[0]
if len(example) > 0:
print(example[0])
print('Message:', example[1])
print_message(12)
URGENT! You have won a 1 week FREE membership in our £100,000 Prize Jackpot! Txt the word: CLAIM to No: 81010 T&C www.dbuk.net LCCLTD POBOX 4403LDNW1A7RW18
Message: spam
print_message(0)
Go until jurong point, crazy.. Available only in bugis n great world la e buffet... Cine there got amore wat...
Message: ham
Preprocesamiento de Texto: A continuación, establecemos una función para transformar el texto a minúsculas y eliminar signos de puntuación/símbolos de las palabras.
import lxml
from bs4 import BeautifulSoup
import re
BeautifulSoup
: Se usa para navegar y manipular la estructura de documentos HTML o XML.re
: Permite buscar y manipular texto dentro de esos documentos o cualquier otro texto, usando patrones específicos de expresiones regulares.
def cleanText(text):
text = BeautifulSoup(text, "html.parser").text
text = re.sub(r'\|\|\|', r' ', text)
text = re.sub(r'http\S+', r'<URL>', text)
text = text.lower()
text = text.replace('x', '')
return text
BeautifulSoup(text, "html.parser").text
:BeautifulSoup
en este caso elimina etiquetas HTML del texto. Conviertetext
en un objetoBeautifulSoup
, y luego el método.text
extrae solo el texto visible, eliminando cualquier HTML o XML.text = re.sub(r'\|\|\|', r' ', text)
: Aquíre.sub
reemplaza cualquier secuencia de caracteres"|||"
por un espacio" "
. Esto es útil para limpiar delimitadores o caracteres específicos del texto.text = re.sub(r'http\S+', r'<URL>', text)
: Reemplaza cualquier URL (que comience con “http” y continúe sin espacios) con el marcador de posición “” . Esto ayuda a evitar que URLs específicas queden en el texto sin ser normalizadas.text = text.replace('x', '')
: En tareas de clasificación de texto o en análisis de sentimientos, algunos caracteres pueden no ser útiles para el modelo. Por ejemplo, si “x” se usa como símbolo decorativo o separador, eliminarlo mejora la calidad de las representaciones de texto sin afectar el contenido.
df['Message'] = df['Message'].apply(cleanText)
df['Message'] = df['Message'].apply(cleanText)
train, test = train_test_split(df, test_size=0.20, random_state=42)
import nltk
from nltk.corpus import stopwords
nltk.data.path.append('/home/lihkir/nltk_data')
nltk.download('punkt_tab')
[nltk_data] Downloading package punkt_tab to /home/lihkir/nltk_data...
[nltk_data] Package punkt_tab is already up-to-date!
True
El módulo
nltk
y, en particular,nltk.corpus.stopwords
se usan en Procesamiento de Lenguaje Natural (NLP) para trabajar con “stopwords”, es decir, palabras de relleno o comunes en un idioma que suelen eliminarse en el análisis de texto porque no aportan significado específico. Estas palabras pueden ser artículos, preposiciones, pronombres y otros términos que no añaden valor semántico relevante (por ejemplo, “el”, “la”, “un”, “de”, “y” en español; o “the”, “is”, “in”, “and” en inglés).
def tokenize_text(text):
tokens = []
for sent in nltk.sent_tokenize(text):
for word in nltk.word_tokenize(sent):
if len(word) <= 0:
continue
tokens.append(word.lower())
return tokens
La función
tokenize_text
toma un texto como entrada y lo divide en una lista de palabras o “tokens” en minúsculas. Esta función usanltk.sent_tokenize
ynltk.word_tokenize
para descomponer el texto en oraciones y palabras.
test_message = "Are you unique enough? Find out from 30th August. www.areyouunique.co.uk"
print(tokenize_text(test_message))
['are', 'you', 'unique', 'enough', '?', 'find', 'out', 'from', '30th', 'august', '.', 'www.areyouunique.co.uk']
train_tagged = train.apply(lambda r: TaggedDocument(words=tokenize_text(r['Message']), tags=[r.Category]), axis=1)
test_tagged = test.apply(lambda r: TaggedDocument(words=tokenize_text(r['Message']), tags=[r.Category]), axis=1)
La función de arriba, aplica una transformación a cada fila del
DataFrame
train
, convirtiendo el contenido de cada mensaje en un objetoTaggedDocument
, un formato utilizado en NLP para entrenar modelos de aprendizaje de texto comoDoc2Vec
de la libreríaGensim
.
max_features
depende de cuántas palabras únicas hay en todo el corpus, no de la longitud de cada mensaje. Puedes ver cuántas palabras únicas tienes así:
from collections import Counter
import itertools
all_words = list(itertools.chain.from_iterable(df['Message'].apply(lambda x: x.split())))
word_counts = Counter(all_words)
print(f"Total de palabras únicas: {len(word_counts)}")
Y luego decidir cuántas palabras mantener. Por ejemplo, si tienes 20.000 palabras únicas, puedes limitar a las 5.000 más frecuentes (
max_features = 5000
), lo cual es típico. El resto son palabras raras, errores tipográficos, tecnicismos o formas muy específicas que aportan poco al modelo y pueden introducir ruido¨. De esta forma mejoramos la eficiencia, rendimiento, y además, evitamos overfitting.
max_features = 500000
Puede usar la siguiente función, para definir
MAX_SEQUENCE_LENGTH
:
df['Message'].apply(lambda x: len(x.split())).describe()
Por ejemplo, si el resultado del .describe() es algo como:
count 10000.000000
mean 12.340000
std 8.567890
min 1.000000
25% 6.000000
50% 10.000000
75% 15.000000
max 78.000000
Entonces podrías definir
MAX_SEQUENCE_LENGTH = 20
oMAX_SEQUENCE_LENGTH = 25
, ya que se cubre más del 75% de tus datos (percentil 75 = 15), y evitas que muchos mensajes se recorten. Otra opción es escogerMAX_SEQUENCE_LENGTH = 50
se cubre más del 99% de los mensajes sin necesidad de truncamiento.
MAX_SEQUENCE_LENGTH = 50
tokenizer = Tokenizer(num_words=max_features, split=' ', filters='!"#$%&()*+,-./:;<=>?@[$$^_`{|}~', lower=True)
tokenizer.fit_on_texts(df['Message'].values)
X = tokenizer.texts_to_sequences(df['Message'].values)
X = pad_sequences(X, maxlen=MAX_SEQUENCE_LENGTH)
print('Found %s unique tokens.' % len(X))
print('Shape of data tensor:', X.shape)
Found 5572 unique tokens.
Tokenizer
: Es un objeto deKeras
que convierte texto en secuencias de números enteros, donde cada número representa un token (una palabra o símbolo) en el vocabulario.num_words=max_features
: Establece el número máximo de palabras que se considerarán. Solo se tomarán en cuenta las palabras más frecuentes hastamax_features
.filters='!"#$%&()*+,-./:;<=>?@[$$^_{|}~'
: Especifica los caracteres que se eliminarán del texto, como puntuación y símbolos.pad_sequences
: Este método se utiliza para hacer que todas las secuencias enX
tengan la misma longitud. Si una secuencia es más corta queMAX_SEQUENCE_LENGTH
, se rellenará con ceros (o con un valor especificado); si es más larga, se truncará.
train_tagged.values
array([TaggedDocument(words=['reply', 'to', 'win', 'â£100', 'weekly', '!', 'where', 'will', 'the', '2006', 'fifa', 'world', 'cup', 'be', 'held', '?', 'send', 'stop', 'to', '87239', 'to', 'end', 'service'], tags=['spam']),
TaggedDocument(words=['hello', '.', 'sort', 'of', 'out', 'in', 'town', 'already', '.', 'that', '.', 'so', 'dont', 'rush', 'home', ',', 'i', 'am', 'eating', 'nachos', '.', 'will', 'let', 'you', 'know', 'eta', '.'], tags=['ham']),
TaggedDocument(words=['how', 'come', 'guoyang', 'go', 'n', 'tell', 'her', '?', 'then', 'u', 'told', 'her', '?'], tags=['ham']),
...,
TaggedDocument(words=['prabha', '..', 'i', "'m", 'soryda', '..', 'realy', '..', 'frm', 'heart', 'i', "'m", 'sory'], tags=['ham']),
TaggedDocument(words=['nt', 'joking', 'seriously', 'i', 'told'], tags=['ham']),
TaggedDocument(words=['did', 'he', 'just', 'say', 'somebody', 'is', 'named', 'tampa'], tags=['ham'])],
dtype=object)
d2v_model = Doc2Vec(dm=1, dm_mean=1, vector_size=20, window=8, min_count=1, workers=12, alpha=0.065, min_alpha=0.065)
El modelo
Doc2Vec
es útil para convertir documentos en representaciones numéricas (vectores) de tamaño fijo. Esto es esencial porque las LSTM requieren representaciones numéricas para trabajar.dm=1
: Especifica que se usará el método Distributed Memory (DM) (en lugar de dm=0, que indica Distributed Bag of Words). Usar DM tiene textos más largos y complejos donde el orden de las palabras es importante y es crucial capturar el contexto para tareas de NLP que requieren una comprensión profunda del documento, como análisis de sentimientos o clasificación temática.dm_mean=1
: Utiliza la media de los vectores de contextos de palabras para representar los documentos. Esto suele mejorar la precisión en comparación con otros métodos.vector_size=20
: Define el tamaño del vector de salida, lo que significa que cada documento se representará como un vector de 20 dimensiones. Para textos simples o tareas básicas, un tamaño pequeño como 20 puede ser suficiente. Para análisis de texto más detallados, el tamaño del vector puede incrementarse para capturar mejor las relaciones entre palabras y documentos, generalmente entre 100 y 300 dimensiones.window=8
: Define el tamaño de la ventana de contexto, es decir, el número máximo de palabras a considerar alrededor de la palabra objetivo en cada documento. En este caso, se consideran hasta 8 palabras a cada lado.min_count=1
: Establece el mínimo de veces que una palabra debe aparecer en el corpus para ser incluida en el modelo. Un valor de 1 significa que todas las palabras en el corpus se incluirán.workers=12
: Utiliza un solo núcleo de procesamiento para el entrenamiento. Si se incrementa este valor, se pueden usar varios núcleos para acelerar el proceso.alpha=0.065
: Establece la tasa de aprendizaje inicial.Doc2Vec
reduce gradualmente esta tasa durante el entrenamiento.min_alpha=0.065
: Fija el límite inferior de la tasa de aprendizaje para que no disminuya más allá de este valor durante el entrenamiento.
d2v_model.build_vocab([x for x in tqdm(train_tagged.values)])
100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 4457/4457 [00:00<00:00, 7435963.77it/s]
d2v_model.build_vocab([x for x in tqdm(train_tagged.values)])
: Crea el vocabulario para el modeloDoc2Vec
en función de los documentos de entrada entrain_tagged
%%time
for epoch in range(30):
d2v_model.train(utils.shuffle([x for x in tqdm(train_tagged.values)]), total_examples=len(train_tagged.values), epochs=1)
d2v_model.alpha -= 0.002
d2v_model.min_alpha = d2v_model.alpha
100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 4457/4457 [00:00<00:00, 7251362.66it/s]
100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 4457/4457 [00:00<00:00, 8978872.68it/s]
100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 4457/4457 [00:00<00:00, 8817930.63it/s]
100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 4457/4457 [00:00<00:00, 9375131.86it/s]
100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 4457/4457 [00:00<00:00, 8991829.21it/s]
100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 4457/4457 [00:00<00:00, 8793044.65it/s]
100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 4457/4457 [00:00<00:00, 9204339.21it/s]
100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 4457/4457 [00:00<00:00, 9231611.32it/s]
100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 4457/4457 [00:00<00:00, 9365737.94it/s]
100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 4457/4457 [00:00<00:00, 9127936.00it/s]
100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 4457/4457 [00:00<00:00, 9177227.75it/s]
100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 4457/4457 [00:00<00:00, 9026563.46it/s]
100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 4457/4457 [00:00<00:00, 9455747.56it/s]
100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 4457/4457 [00:00<00:00, 9403427.03it/s]
100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 4457/4457 [00:00<00:00, 9328349.76it/s]
100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 4457/4457 [00:00<00:00, 9119030.70it/s]
100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 4457/4457 [00:00<00:00, 9474917.86it/s]
100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 4457/4457 [00:00<00:00, 9105705.27it/s]
100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 4457/4457 [00:00<00:00, 8780654.26it/s]
100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 4457/4457 [00:00<00:00, 7931274.05it/s]
100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 4457/4457 [00:00<00:00, 8202726.16it/s]
100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 4457/4457 [00:00<00:00, 9035288.99it/s]
100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 4457/4457 [00:00<00:00, 8451181.25it/s]
100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 4457/4457 [00:00<00:00, 9268226.54it/s]
100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 4457/4457 [00:00<00:00, 9119030.70it/s]
100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 4457/4457 [00:00<00:00, 9231611.32it/s]
100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 4457/4457 [00:00<00:00, 8872336.46it/s]
100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 4457/4457 [00:00<00:00, 8290027.91it/s]
100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 4457/4457 [00:00<00:00, 9087998.51it/s]
100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 4457/4457 [00:00<00:00, 9356362.83it/s]
CPU times: user 2.26 s, sys: 0 ns, total: 2.26 s
Wall time: 2.35 s
print(d2v_model)
Doc2Vec<dm/m,d20,n5,w8,s0.001>
dm/m
: Indica el tipo de modelo que se está utilizando. dm significa que se está utilizando el enfoque de Distributed Memory (DM). El “m” se refiere a que se está usando la estrategia de “mean” (media) para la representación de los contextos de palabras.d20
: Este parámetro indica el tamaño del vector de salida, que es de 20 dimensiones. Es decir, cada documento se representará como un vector de 20 dimensiones.n5
: Este número se refiere al número de palabras mínimas requeridas para que una palabra sea incluida en el vocabulario. En este caso, las palabras que aparecen al menos 5 veces serán consideradas.w8
: Este valor representa el tamaño de la ventana de contexto, que es de 8 palabras. Esto significa que al entrenar el modelo, se considera un contexto de 8 palabras antes y después de la palabra objetivo.s0.001
: Este número indica la tasa de aprendizaje inicial (alpha
), que es de 0.001 en este caso. Esta tasa determina cuánto se ajustan los pesos del modelo durante el entrenamiento.
num_words = len(d2v_model.wv.key_to_index)
print(num_words)
8266
words = d2v_model.wv.index_to_key
print(words[-10:])
['phone750', 'weaseling', 'connected', 'ltd.', 'gmw', 'wrongly', 'bless.get', 'gained', 'money.i', '.so']
embedding_matrix = np.zeros((len(d2v_model.wv.key_to_index)+ 1, 20))
embedding_matrix = np.zeros((len(d2v_model.wv.key_to_index)+ 1, 20))
: Inicializa una matriz de embeddings (representaciones vectoriales) de tamaño (número de palabras en el vocabulario + 1, 20) llena de ceros. Esta matriz se puede utilizar posteriormente para almacenar las representaciones vectoriales de cada palabra del vocabulario aprendidas por el modeloDoc2Vec
for i in range(len(d2v_model.dv)):
vec = d2v_model.dv[i]
if vec is not None and len(vec) <= 1000:
print(i)
print(d2v_model.dv)
embedding_matrix[i] = vec
print(vec)
0
KeyedVectors<vector_size=20, 2 keys>
[ -3.2668898 -3.1738384 -8.505668 4.5485306 2.692948
3.1493063 -4.462352 -5.3937817 -7.1809254 4.7018075
2.7759285 6.317104 -4.7875314 -7.075199 -3.3227246
1.9363822 4.517679 6.162188 -14.190054 -0.26278985]
-3.2668898
1
KeyedVectors<vector_size=20, 2 keys>
[ 1.2465329 1.5963961 0.8784475 2.2543192 2.5438132 -2.474216
-2.310085 -0.6934201 2.6299498 -3.745987 1.3307427 0.41213423
-3.2673807 -1.8536966 0.9749198 1.6373183 -1.6213127 -2.8041255
-0.5990004 0.34091252]
1.5963961
Este bucle itera a través de los vectores de documento en el modelo Doc2Vec, verifica que cada vector sea válido y su longitud adecuada, imprime información relevante, y almacena los vectores en una matriz de embeddings.
d2v_model.wv.most_similar(positive=['urgent'], topn=10)
[('resend', 0.7344722747802734),
('lower', 0.7169529795646667),
('probs', 0.7099040150642395),
('08718738034', 0.7082372307777405),
('clothes', 0.6989055275917053),
('tuition', 0.6945768594741821),
('curtsey', 0.6848364472389221),
('11mths+', 0.6828041672706604),
('having', 0.6789382696151733),
('deleted', 0.6787389516830444)]
d2v_model.wv.most_similar(positive=['urgent'], topn=10)
: Se utiliza para encontrar las palabras más similares al término “urgent” en el modeloDoc2Vec
d2v_model.wv.most_similar(positive=['cherish'], topn=10)
[('image', 0.8254433870315552),
('intrepid', 0.7505639791488647),
('okors', 0.7467709183692932),
('count', 0.7441601753234863),
('mojibiola', 0.7431001663208008),
('ultimatum', 0.7118896245956421),
('bâ\x80\x98ham', 0.7115652561187744),
('cantdo', 0.7105881571769714),
('abiola', 0.7102086544036865),
('thank', 0.6918621063232422)]
Pasamos a implementar el modelo
LSTM
from keras.models import Sequential
from keras.layers import LSTM, Dense, Embedding
from tensorflow.keras.optimizers.legacy import Adam
Crear un modelo secuencial, es decir, un modelo LSTM donde las capas se apilan una detrás de otra, en orden
model = Sequential()
Vectores de palabras
Embedding
model.add(Embedding(len(d2v_model.wv.key_to_index) + 1, 20, input_length=X.shape[1], weights=[embedding_matrix], trainable=True))
weights=[embedding_matrix]
: Al pasar esto como un argumento deweights
, se le indica aKeras
que use estos vectores iniciales para la capa de incrustación.
LSTM(50)
agrega una capa LSTM con 50 unidades (neuronas).return_sequences=False
: Esto indica que la capa devuelve solo la última salida de la secuencia, no toda la secuencia de salidas.
model.add(LSTM(50,return_sequences=False))
model.add(Dense(2,activation="softmax"))
Usamos
summary()
para mostrar un resumen de la arquitectura del modelo.
model.summary()
Model: "sequential_4"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓ ┃ Layer (type) ┃ Output Shape ┃ Param # ┃ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩ │ embedding_4 (Embedding) │ ? │ 165,340 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ lstm_4 (LSTM) │ ? │ 0 (unbuilt) │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ dense_4 (Dense) │ ? │ 0 (unbuilt) │ └─────────────────────────────────┴────────────────────────┴───────────────┘
Total params: 165,340 (645.86 KB)
Trainable params: 165,340 (645.86 KB)
Non-trainable params: 0 (0.00 B)
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Embedding, LSTM, Dense
from tensorflow.keras.optimizers import Adam
Queda como ejercicio para el estudiante, cambiar la métrica de validación a AUC, tal como se hizo en el ejercicio de reconocimiento facial de emociones usando CNNs
model.compile(optimizer=Adam(), loss="binary_crossentropy", metrics=['accuracy'])
Y = pd.get_dummies(df['Category']).values
X_train, X_test, Y_train, Y_test = train_test_split(X,Y, test_size = 0.15, random_state = 42)
print(X_train.shape,Y_train.shape)
print(X_test.shape,Y_test.shape)
(4736, 50) (4736, 2)
(836, 50) (836, 2)
batch_size = 32
import os
from joblib import dump, load
history_lstm = None
if os.path.exists('history_lstm.joblib'):
history_lstm = load('history_lstm.joblib')
print("El archivo 'history_lstm.joblib' ya existe. Se ha cargado el historial del entrenamiento.")
else:
history_lstm = model.fit(X_train, Y_train, epochs=50, batch_size=batch_size, verbose=2)
dump(history_lstm.history, 'history_lstm.joblib')
print("El entrenamiento se ha completado y el historial ha sido guardado en 'history_lstm.joblib'.")
Epoch 1/50
148/148 - 2s - 11ms/step - accuracy: 0.9246 - loss: 0.2294
Epoch 2/50
148/148 - 1s - 7ms/step - accuracy: 0.9844 - loss: 0.0612
Epoch 3/50
148/148 - 1s - 7ms/step - accuracy: 0.9939 - loss: 0.0287
Epoch 4/50
148/148 - 1s - 6ms/step - accuracy: 0.9956 - loss: 0.0200
Epoch 5/50
148/148 - 1s - 7ms/step - accuracy: 0.9973 - loss: 0.0117
Epoch 6/50
148/148 - 1s - 7ms/step - accuracy: 0.9975 - loss: 0.0083
Epoch 7/50
148/148 - 1s - 7ms/step - accuracy: 0.9987 - loss: 0.0045
Epoch 8/50
148/148 - 1s - 7ms/step - accuracy: 0.9992 - loss: 0.0019
Epoch 9/50
148/148 - 1s - 7ms/step - accuracy: 0.9996 - loss: 0.0013
Epoch 10/50
148/148 - 1s - 7ms/step - accuracy: 0.9996 - loss: 8.9813e-04
Epoch 11/50
148/148 - 1s - 7ms/step - accuracy: 1.0000 - loss: 6.4990e-04
Epoch 12/50
148/148 - 1s - 6ms/step - accuracy: 1.0000 - loss: 5.0027e-04
Epoch 13/50
148/148 - 1s - 7ms/step - accuracy: 1.0000 - loss: 3.9275e-04
Epoch 14/50
148/148 - 1s - 6ms/step - accuracy: 1.0000 - loss: 3.0832e-04
Epoch 15/50
148/148 - 1s - 7ms/step - accuracy: 1.0000 - loss: 2.5960e-04
Epoch 16/50
148/148 - 1s - 7ms/step - accuracy: 1.0000 - loss: 2.1151e-04
Epoch 17/50
148/148 - 1s - 7ms/step - accuracy: 1.0000 - loss: 1.7948e-04
Epoch 18/50
148/148 - 1s - 7ms/step - accuracy: 1.0000 - loss: 1.5452e-04
Epoch 19/50
148/148 - 1s - 6ms/step - accuracy: 1.0000 - loss: 1.3186e-04
Epoch 20/50
148/148 - 1s - 7ms/step - accuracy: 1.0000 - loss: 1.1505e-04
Epoch 21/50
148/148 - 1s - 7ms/step - accuracy: 1.0000 - loss: 1.0080e-04
Epoch 22/50
148/148 - 1s - 6ms/step - accuracy: 1.0000 - loss: 8.8754e-05
Epoch 23/50
148/148 - 1s - 7ms/step - accuracy: 1.0000 - loss: 7.9170e-05
Epoch 24/50
148/148 - 1s - 6ms/step - accuracy: 1.0000 - loss: 6.9984e-05
Epoch 25/50
148/148 - 1s - 6ms/step - accuracy: 1.0000 - loss: 6.2725e-05
Epoch 26/50
148/148 - 1s - 6ms/step - accuracy: 1.0000 - loss: 5.6174e-05
Epoch 27/50
148/148 - 1s - 6ms/step - accuracy: 1.0000 - loss: 5.2045e-05
Epoch 28/50
148/148 - 1s - 6ms/step - accuracy: 1.0000 - loss: 4.5325e-05
Epoch 29/50
148/148 - 1s - 6ms/step - accuracy: 1.0000 - loss: 4.1309e-05
Epoch 30/50
148/148 - 1s - 6ms/step - accuracy: 1.0000 - loss: 3.7269e-05
Epoch 31/50
148/148 - 1s - 6ms/step - accuracy: 1.0000 - loss: 3.4046e-05
Epoch 32/50
148/148 - 1s - 7ms/step - accuracy: 1.0000 - loss: 3.0869e-05
Epoch 33/50
148/148 - 1s - 7ms/step - accuracy: 1.0000 - loss: 2.8360e-05
Epoch 34/50
148/148 - 1s - 7ms/step - accuracy: 1.0000 - loss: 2.5935e-05
Epoch 35/50
148/148 - 1s - 7ms/step - accuracy: 1.0000 - loss: 2.3781e-05
Epoch 36/50
148/148 - 1s - 8ms/step - accuracy: 1.0000 - loss: 2.1791e-05
Epoch 37/50
148/148 - 1s - 7ms/step - accuracy: 1.0000 - loss: 2.0589e-05
Epoch 38/50
148/148 - 1s - 7ms/step - accuracy: 1.0000 - loss: 1.8341e-05
Epoch 39/50
148/148 - 1s - 7ms/step - accuracy: 1.0000 - loss: 1.7263e-05
Epoch 40/50
148/148 - 1s - 7ms/step - accuracy: 1.0000 - loss: 1.5659e-05
Epoch 41/50
148/148 - 1s - 7ms/step - accuracy: 1.0000 - loss: 1.4416e-05
Epoch 42/50
148/148 - 1s - 7ms/step - accuracy: 1.0000 - loss: 1.3237e-05
Epoch 43/50
148/148 - 1s - 7ms/step - accuracy: 1.0000 - loss: 1.2342e-05
Epoch 44/50
148/148 - 1s - 7ms/step - accuracy: 1.0000 - loss: 1.1210e-05
Epoch 45/50
148/148 - 1s - 7ms/step - accuracy: 1.0000 - loss: 1.0379e-05
Epoch 46/50
148/148 - 1s - 6ms/step - accuracy: 1.0000 - loss: 9.6437e-06
Epoch 47/50
148/148 - 1s - 7ms/step - accuracy: 1.0000 - loss: 8.8003e-06
Epoch 48/50
148/148 - 1s - 7ms/step - accuracy: 1.0000 - loss: 8.0948e-06
Epoch 49/50
148/148 - 1s - 7ms/step - accuracy: 1.0000 - loss: 7.7207e-06
Epoch 50/50
148/148 - 1s - 7ms/step - accuracy: 1.0000 - loss: 6.9844e-06
El entrenamiento se ha completado y el historial ha sido guardado en 'history_lstm.joblib'.
plt.plot(history_lstm.history['accuracy'])
plt.title('model accuracy')
plt.ylabel('acc')
plt.xlabel('epochs')
plt.legend(['train', 'test'], loc='upper left');

plt.plot(history_lstm.history['loss']);
plt.title('model loss');
plt.ylabel('loss');
plt.xlabel('epochs');
plt.legend(['train', 'test'], loc='upper left');

_, train_acc = model.evaluate(X_train, Y_train, verbose=2)
_, test_acc = model.evaluate(X_test, Y_test, verbose=2)
print('Train: %.3f, Test: %.4f' % (train_acc, test_acc))
148/148 - 0s - 3ms/step - accuracy: 1.0000 - loss: 6.5603e-06
27/27 - 0s - 6ms/step - accuracy: 0.9856 - loss: 0.1570
Train: 1.000, Test: 0.9856
Las siguientes líneas están utilizando el modelo entrenado para predecir la probabilidad de que cada entrada en
X_test
pertenezca a cada clase. Luego, se determina la clase más probable para cada entrada y se imprime tanto las probabilidades como las clases predichas.np.argmax(yhat_probs, axis=1)
toma la clase con mayor probabilidad en cada fila.
yhat_probs = model.predict(X_test, verbose=0)
print(yhat_probs)
[[1.0000000e+00 1.0334940e-12]
[1.0000000e+00 1.2381419e-11]
[1.0000000e+00 1.5744090e-12]
...
[1.0000000e+00 1.0007380e-12]
[1.0000000e+00 7.4370523e-13]
[1.0000000e+00 1.3020209e-12]]
yhat_classes = np.argmax(yhat_probs, axis=1)
print(yhat_classes)
[0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 1 0 0 0 1 0 0 0 0 0 0 0 0 1 0 1 0 0 0 0 0 0 0 1 0 1 1
0 1 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 1 0 0 0 1 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1 0 0 0 0 0 0 0 0 0 1
0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 1 0 1 0 0 1 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0
1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 1 0 0 0 1 1 0 0 0
0 1 0 1 0 0 0 0 0 0 0 1 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0
0 0 0 0 1 1 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 1 0 0 0 0 0 0 0 0 0 1 0
0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1 0 0 0 0 0 0 1 0 0 0 1 0 0 0
1 0 0 0 0 0 0 1 0 0 1 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0
1 0 0 0 0 0 0 0 1 0 0 0 0 1 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 1 0 1 1 1 0 0 0 0 0 1 1 0 0
0 0 0 0 0 0 1 0 1 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 1 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 1 1 0 0 0 0 0 0 0 0
0 1 0 0 0 0 0 0 0 0 0 0 0 1 0 1 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 1 0 0 0
0 0 0 1 0 1 0 1 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 1 1 0 0 0 0 0 0 0 0 0 0 0
0 1 1 1 0 1 0 0 0 0 0 0 0 0 0 0 1 1 0 0 0 0]
Cada número en este arreglo representa la clase predicha para cada muestra en el conjunto de prueba. Por ejemplo, si el valor es 0, significa que la muestra pertenece a la clase 0, y si es 1, significa que la muestra pertenece a la clase 1.
yhat_probs = yhat_probs[:, 0]
import numpy as np
rounded_labels=np.argmax(Y_test, axis=1)
rounded_labels
array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0,
0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0,
0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0,
0, 0, 0, 0, 1, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0,
0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 1, 0, 1, 0,
0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0,
0, 1, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1,
0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1,
0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0,
1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1,
0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0,
0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 1, 1, 1, 0, 0, 0, 0,
1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0,
0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0,
0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0,
0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0,
0, 0, 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 1, 1, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0])
from sklearn.metrics import confusion_matrix
cm = confusion_matrix(rounded_labels, yhat_classes)
cm
array([[727, 2],
[ 10, 97]])
from sklearn.metrics import confusion_matrix
import seaborn as sns
lstm_val = confusion_matrix(rounded_labels, yhat_classes)
f, ax = plt.subplots(figsize=(5,5))
sns.heatmap(lstm_val, annot=True, linewidth=0.7, linecolor='cyan', fmt='g', ax=ax, cmap="BuPu")
plt.title('LSTM Classification Confusion Matrix')
plt.xlabel('Y predict')
plt.ylabel('Y test')
plt.show()

validation_size = 200
X_validate = X_test[-validation_size:]
Y_validate = Y_test[-validation_size:]
X_test = X_test[:-validation_size]
Y_test = Y_test[:-validation_size]
score,acc = model.evaluate(X_test, Y_test, verbose = 1, batch_size = batch_size)
print("score: %.2f" % (score))
print("acc: %.2f" % (acc))
20/20 ━━━━━━━━━━━━━━━━━━━━ 0s 3ms/step - accuracy: 0.9849 - loss: 0.1506
score: 0.17
acc: 0.98
model.save('Mymodel.keras')
Pruebas con conjuntos de datos nuevos y diferentes de los datos para construir el modelo.
message = ['Congratulations! you have won a $1,000 Walmart gift card. Go to http://bit.ly/123456 to claim now.']
seq = tokenizer.texts_to_sequences(message)
padded = pad_sequences(seq, maxlen=X.shape[1], dtype='int32', value=0)
pred = model.predict(padded)
labels = ['ham','spam']
print(pred, labels[np.argmax(pred)])
1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 11ms/step
[[5.0022042e-11 1.0000000e+00]] spam
message = ['thanks for accepting my request to connect']
seq = tokenizer.texts_to_sequences(message)
padded = pad_sequences(seq, maxlen=X.shape[1], dtype='int32', value=0)
pred = model.predict(padded)
labels = ['ham','spam']
print(pred, labels[np.argmax(pred)])
1/1 ━━━━━━━━━━━━━━━━━━━━ 0s 10ms/step
[[1.0000000e+00 1.5881167e-12]] ham
10.18. Aplicación: LSTM para la predicción de series de tiempo#
Continuaremos usando el conjunto de datos sobre contaminación del aire (
pm2.5
) usando LSTM. La lectura y el preprocesamiento de los datos se mantienen igual que en los ejemplos deMLPs
. El conjunto de datos original se divide en dos conjuntos: entrenamiento y validación, que se usan para el entrenamiento y validación del modelo respectivamente.
La función
makeXy
se utiliza para generar arreglos de regresores y objetivos:X_train, X_val, y_train
yy_val
.X_train y X_val
, generados por la funciónmakeXy
, son arreglos 2D de forma (número de muestras, número de pasos de tiempo). Sin embargo, la entrada a las capas RNN debe ser de forma (número de muestras, número de pasos de tiempo, número de características por paso de tiempo).En este caso, estamos tratando solo con
pm2.5
, por lo tanto, el número de características por paso de tiempo es uno. El número de pasos de tiempo es siete y el número de muestras es el mismo que el número de muestras enX_train
yX_val
, que se transforman a arreglos 3D
import pandas as pd
from sklearn.preprocessing import MinMaxScaler
import datetime
import matplotlib.pyplot as plt
import seaborn as sns
import numpy as np
import warnings
warnings.filterwarnings('ignore')
plot_fontsize = 18;
df = pd.read_csv('https://raw.githubusercontent.com/lihkir/Data/refs/heads/main/PRSA_data_2010.1.1-2014.12.31.csv', usecols=lambda column: column not in ['DEWP', 'TEMP', 'cbwd', 'Iws', 'Is', 'Ir'])
print('Shape of the dataframe:', df.shape)
Shape of the dataframe: (43824, 7)
df.head()
No | year | month | day | hour | pm2.5 | PRES | |
---|---|---|---|---|---|---|---|
0 | 1 | 2010 | 1 | 1 | 0 | NaN | 1021.0 |
1 | 2 | 2010 | 1 | 1 | 1 | NaN | 1020.0 |
2 | 3 | 2010 | 1 | 1 | 2 | NaN | 1019.0 |
3 | 4 | 2010 | 1 | 1 | 3 | NaN | 1019.0 |
4 | 5 | 2010 | 1 | 1 | 4 | NaN | 1018.0 |
df.dropna(subset=['pm2.5'], axis=0, inplace=True)
df.reset_index(drop=True, inplace=True)
df['datetime'] = df[['year', 'month', 'day', 'hour']].apply(lambda row: datetime.datetime(year=row['year'], month=row['month'], day=row['day'],
hour=row['hour']), axis=1)
df.sort_values('datetime', ascending=True, inplace=True)
plt.figure(figsize=(15, 8))
plt.subplot(1, 2, 1)
g1 = sns.boxplot(df['pm2.5'], orient="h", palette="Set2")
g1.set_title('Box plot of pm2.5', fontsize=plot_fontsize);
g1.set_xlabel(xlabel='pm2.5', fontsize=plot_fontsize)
g1.set(yticklabels=[])
g1.tick_params(left=False);
plt.subplot(1, 2, 2)
g2 = sns.lineplot(df['pm2.5'])
g2.set_title('Time series of pm2.5', fontsize=plot_fontsize)
g2.set_xlabel('Index', fontsize=plot_fontsize)
g2.set_ylabel('pm2.5 readings');
plt.show()

plt.figure(figsize=(15, 8))
plt.subplot(1, 2, 1)
g1 = sns.lineplot(df['pm2.5'].loc[df['datetime']<=datetime.datetime(year=2010,month=6,day=30)], color='g')
g1.set_title('pm2.5 during 2010', fontsize=plot_fontsize)
g1.set_xlabel('Index', fontsize=plot_fontsize)
g1.set_ylabel('pm2.5 readings', fontsize=plot_fontsize);
plt.subplot(1, 2, 2)
g = sns.lineplot(df['pm2.5'].loc[df['datetime']<=datetime.datetime(year=2010,month=1,day=31)], color='g')
g.set_title('Zoom in on one month: pm2.5 during Jan 2010', fontsize=plot_fontsize)
g.set_xlabel('Index', fontsize=plot_fontsize)
g.set_ylabel('pm2.5 readings', fontsize=plot_fontsize);
plt.show()

split_date = datetime.datetime(year=2014, month=1, day=1, hour=0)
df_train = df.loc[df['datetime']<split_date]
df_val = df.loc[df['datetime']>=split_date]
print('Shape of train:', df_train.shape)
print('Shape of test:', df_val.shape)
Shape of train: (33096, 8)
Shape of test: (8661, 8)
df_train.head()
No | year | month | day | hour | pm2.5 | PRES | datetime | |
---|---|---|---|---|---|---|---|---|
0 | 25 | 2010 | 1 | 2 | 0 | 129.0 | 1020.0 | 2010-01-02 00:00:00 |
1 | 26 | 2010 | 1 | 2 | 1 | 148.0 | 1020.0 | 2010-01-02 01:00:00 |
2 | 27 | 2010 | 1 | 2 | 2 | 159.0 | 1021.0 | 2010-01-02 02:00:00 |
3 | 28 | 2010 | 1 | 2 | 3 | 181.0 | 1022.0 | 2010-01-02 03:00:00 |
4 | 29 | 2010 | 1 | 2 | 4 | 138.0 | 1022.0 | 2010-01-02 04:00:00 |
df_val.head()
No | year | month | day | hour | pm2.5 | PRES | datetime | |
---|---|---|---|---|---|---|---|---|
33096 | 35065 | 2014 | 1 | 1 | 0 | 24.0 | 1014.0 | 2014-01-01 00:00:00 |
33097 | 35066 | 2014 | 1 | 1 | 1 | 53.0 | 1013.0 | 2014-01-01 01:00:00 |
33098 | 35067 | 2014 | 1 | 1 | 2 | 65.0 | 1013.0 | 2014-01-01 02:00:00 |
33099 | 35068 | 2014 | 1 | 1 | 3 | 70.0 | 1013.0 | 2014-01-01 03:00:00 |
33100 | 35069 | 2014 | 1 | 1 | 4 | 79.0 | 1012.0 | 2014-01-01 04:00:00 |
df_val.reset_index(drop=True, inplace=True)
scaler_pm25 = MinMaxScaler(feature_range=(0, 1))
df_train['scaled_pm2.5'] = scaler_pm25.fit_transform(df_train[['pm2.5']])
df_val['scaled_pm2.5'] = scaler_pm25.transform(df_val[['pm2.5']])
print('Shape of train:', df_train.shape)
print('Shape of val:', df_val.shape)
Shape of train: (33096, 9)
Shape of val: (8661, 9)
plt.figure(figsize=(15, 8))
plt.subplot(1, 2, 1)
g1 = sns.lineplot(df_train['scaled_pm2.5'], color='b')
g1.set_title('Time series of scaled pm2.5 in train set', fontsize=plot_fontsize)
g1.set_xlabel('Index', fontsize=plot_fontsize)
g1.set_ylabel('Scaled pm2.5 readings', fontsize=plot_fontsize);
plt.subplot(1, 2, 2)
g2 = sns.lineplot(df_val['scaled_pm2.5'], color='r')
g2.set_title('Time series of scaled pm2.5 in validation set', fontsize=plot_fontsize)
g2.set_xlabel('Index', fontsize=plot_fontsize)
g2.set_ylabel('Scaled pm2.5 readings', fontsize=plot_fontsize);
plt.show()

def makeXy(ts, nb_timesteps):
"""
Input:
ts: original time series
nb_timesteps: number of time steps in the regressors
Output:
X: 2-D array of regressors
y: 1-D array of target
"""
X = []
y = []
for i in range(nb_timesteps, ts.shape[0]):
if i-nb_timesteps <= 4:
print(i-nb_timesteps, i-1, i)
X.append(list(ts.loc[i-nb_timesteps:i-1])) #Regressors
y.append(ts.loc[i]) #Target
X, y = np.array(X), np.array(y)
return X, y
X_train, y_train = makeXy(df_train['scaled_pm2.5'], 7)
0 6 7
1 7 8
2 8 9
3 9 10
4 10 11
print('Shape of train arrays:', X_train.shape, y_train.shape)
Shape of train arrays: (33089, 7) (33089,)
X_val, y_val = makeXy(df_val['scaled_pm2.5'], 7)
0 6 7
1 7 8
2 8 9
3 9 10
4 10 11
print('Shape of validation arrays:', X_val.shape, y_val.shape)
Shape of validation arrays: (8654, 7) (8654,)
X_train, X_val = X_train.reshape((X_train.shape[0], X_train.shape[1], 1)), X_val.reshape((X_val.shape[0], X_val.shape[1], 1))
print('Shape of 3D arrays:', X_train.shape, X_val.shape)
Shape of 3D arrays: (33089, 7, 1) (8654, 7, 1)
X_train
yX_val
se han convertido en matrices 3D y sus nuevas formas se ven en la salida de la instrucción de impresión anterior
from keras.layers import Dense, Input, Dropout
from keras.layers import LSTM
from keras.optimizers import SGD
from keras.models import Model
from keras.models import load_model
from keras.callbacks import ModelCheckpoint
2025-04-05 20:36:42.217116: I tensorflow/core/util/port.cc:153] oneDNN custom operations are on. You may see slightly different numerical results due to floating-point round-off errors from different computation orders. To turn them off, set the environment variable `TF_ENABLE_ONEDNN_OPTS=0`.
2025-04-05 20:36:42.224213: E external/local_xla/xla/stream_executor/cuda/cuda_fft.cc:485] Unable to register cuFFT factory: Attempting to register factory for plugin cuFFT when one has already been registered
2025-04-05 20:36:42.230760: E external/local_xla/xla/stream_executor/cuda/cuda_dnn.cc:8454] Unable to register cuDNN factory: Attempting to register factory for plugin cuDNN when one has already been registered
2025-04-05 20:36:42.232587: E external/local_xla/xla/stream_executor/cuda/cuda_blas.cc:1452] Unable to register cuBLAS factory: Attempting to register factory for plugin cuBLAS when one has already been registered
2025-04-05 20:36:42.241650: I tensorflow/core/platform/cpu_feature_guard.cc:210] This TensorFlow binary is optimized to use available CPU instructions in performance-critical operations.
To enable the following instructions: AVX2 AVX512F AVX512_VNNI AVX512_BF16 FMA, in other operations, rebuild TensorFlow with the appropriate compiler flags.
2025-04-05 20:36:42.712103: W tensorflow/compiler/tf2tensorrt/utils/py_utils.cc:38] TF-TRT Warning: Could not find TensorRT
La red neuronal para desarrollar el modelo de predicción de series temporales tiene una capa de entrada, que alimenta a la capa LSTM. La capa LSTM tiene siete pasos temporales, que es el mismo número de observaciones históricas tomadas para hacer la predicción de la presión atmosférica para el día siguiente. Solo el último paso temporal de la LSTM devuelve una salida.
Hay sesenta y cuatro neuronas ocultas en cada paso temporal de la capa LSTM. Por lo tanto, la salida de la LSTM tiene sesenta y cuatro características:
input_layer = Input(shape=(7,1), dtype='float32')
Aquí
shape=(7,1)
forma de la entrada a la LSTM.7
: Número de pasos de tiempo (timesteps).1
: Número de características (features) por cada paso de tiempo.return_sequences=True
se usa cuando se necesita pasar la secuencia completa de salidas a la siguiente capa de la red, que también puede ser una capa recurrente como otra LSTM
lstm_layer1 = LSTM(64, input_shape=(7,1), return_sequences=True)(input_layer)
lstm_layer2 = LSTM(32, input_shape=(7,64), return_sequences=False)(lstm_layer1)
WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
I0000 00:00:1743903403.138819 30821 cuda_executor.cc:1001] could not open file to read NUMA node: /sys/bus/pci/devices/0000:01:00.0/numa_node
Your kernel may have been built without NUMA support.
I0000 00:00:1743903403.168822 30821 cuda_executor.cc:1001] could not open file to read NUMA node: /sys/bus/pci/devices/0000:01:00.0/numa_node
Your kernel may have been built without NUMA support.
I0000 00:00:1743903403.168868 30821 cuda_executor.cc:1001] could not open file to read NUMA node: /sys/bus/pci/devices/0000:01:00.0/numa_node
Your kernel may have been built without NUMA support.
I0000 00:00:1743903403.171752 30821 cuda_executor.cc:1001] could not open file to read NUMA node: /sys/bus/pci/devices/0000:01:00.0/numa_node
Your kernel may have been built without NUMA support.
I0000 00:00:1743903403.171786 30821 cuda_executor.cc:1001] could not open file to read NUMA node: /sys/bus/pci/devices/0000:01:00.0/numa_node
Your kernel may have been built without NUMA support.
I0000 00:00:1743903403.171805 30821 cuda_executor.cc:1001] could not open file to read NUMA node: /sys/bus/pci/devices/0000:01:00.0/numa_node
Your kernel may have been built without NUMA support.
I0000 00:00:1743903403.296983 30821 cuda_executor.cc:1001] could not open file to read NUMA node: /sys/bus/pci/devices/0000:01:00.0/numa_node
Your kernel may have been built without NUMA support.
I0000 00:00:1743903403.297036 30821 cuda_executor.cc:1001] could not open file to read NUMA node: /sys/bus/pci/devices/0000:01:00.0/numa_node
Your kernel may have been built without NUMA support.
2025-04-05 20:36:43.297044: I tensorflow/core/common_runtime/gpu/gpu_device.cc:2112] Could not identify NUMA node of platform GPU id 0, defaulting to 0. Your kernel may not have been built with NUMA support.
I0000 00:00:1743903403.297078 30821 cuda_executor.cc:1001] could not open file to read NUMA node: /sys/bus/pci/devices/0000:01:00.0/numa_node
Your kernel may have been built without NUMA support.
2025-04-05 20:36:43.297094: I tensorflow/core/common_runtime/gpu/gpu_device.cc:2021] Created device /job:localhost/replica:0/task:0/device:GPU:0 with 9558 MB memory: -> device: 0, name: NVIDIA GeForce RTX 4070, pci bus id: 0000:01:00.0, compute capability: 8.9
A continuación, la salida de LSTM pasa a una capa de exclusión que elimina aleatoriamente el 20% de la entrada antes de pasar a la capa de salida, que tiene una única neurona oculta con una función de activación lineal
dropout_layer = Dropout(0.2)(lstm_layer2)
output_layer = Dense(1, activation='linear')(dropout_layer)
Finalmente, todas las capas se envuelven en un
keras.models.Model
y se entrenan durante veinte epochs para minimizar el MSE utilizando el optimizador Adam
ts_model = Model(inputs=input_layer, outputs=output_layer)
ts_model.compile(loss='mean_absolute_error', optimizer='adam') #SGD(lr=0.001, decay=1e-5))
ts_model.summary()
Model: "functional"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓ ┃ Layer (type) ┃ Output Shape ┃ Param # ┃ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩ │ input_layer (InputLayer) │ (None, 7, 1) │ 0 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ lstm (LSTM) │ (None, 7, 64) │ 16,896 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ lstm_1 (LSTM) │ (None, 32) │ 12,416 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ dropout (Dropout) │ (None, 32) │ 0 │ ├─────────────────────────────────┼────────────────────────┼───────────────┤ │ dense (Dense) │ (None, 1) │ 33 │ └─────────────────────────────────┴────────────────────────┴───────────────┘
Total params: 29,345 (114.63 KB)
Trainable params: 29,345 (114.63 KB)
Non-trainable params: 0 (0.00 B)
import os
from joblib import dump, load
from keras.callbacks import EarlyStopping, ModelCheckpoint
save_weights_at = os.path.join('keras_models', 'PRSA_data_PM2.5_LSTM_weights.{epoch:02d}-{val_loss:.4f}.keras')
checkpointer = [
EarlyStopping(
monitor='val_loss',
verbose=1,
restore_best_weights=True,
mode='min',
patience=10
),
ModelCheckpoint(
filepath=save_weights_at,
monitor='val_loss',
verbose=1,
save_best_only=True,
save_weights_only=False,
mode='min',
save_freq='epoch'
)
]
history_pm25_LSTM = None
if os.path.exists('history_pm25_LSTM.joblib'):
history_pm25_LSTM = load('history_pm25_LSTM.joblib')
print("El archivo 'history_pm25_LSTM.joblib' ya existe. Se ha cargado el historial del entrenamiento.")
else:
history_pm25_LSTM = ts_model.fit(
x=X_train,
y=y_train,
batch_size=16,
epochs=120,
verbose=2,
callbacks=checkpointer,
validation_data=(X_val, y_val),
shuffle=True
)
dump(history_pm25_LSTM.history, 'history_pm25_LSTM.joblib')
print("El entrenamiento se ha completado y el historial ha sido guardado en 'history_pm25_LSTM.joblib'.")
Epoch 1/120
2025-04-05 20:36:44.774182: I external/local_xla/xla/stream_executor/cuda/cuda_dnn.cc:531] Loaded cuDNN version 8907
Epoch 1: val_loss improved from inf to 0.01209, saving model to keras_models/PRSA_data_PM2.5_LSTM_weights.01-0.0121.keras
2069/2069 - 16s - 8ms/step - loss: 0.0197 - val_loss: 0.0121
Epoch 2/120
Epoch 2: val_loss did not improve from 0.01209
2069/2069 - 15s - 7ms/step - loss: 0.0153 - val_loss: 0.0122
Epoch 3/120
Epoch 3: val_loss improved from 0.01209 to 0.01193, saving model to keras_models/PRSA_data_PM2.5_LSTM_weights.03-0.0119.keras
2069/2069 - 14s - 7ms/step - loss: 0.0150 - val_loss: 0.0119
Epoch 4/120
Epoch 4: val_loss did not improve from 0.01193
2069/2069 - 15s - 7ms/step - loss: 0.0149 - val_loss: 0.0123
Epoch 5/120
Epoch 5: val_loss improved from 0.01193 to 0.01174, saving model to keras_models/PRSA_data_PM2.5_LSTM_weights.05-0.0117.keras
2069/2069 - 15s - 7ms/step - loss: 0.0149 - val_loss: 0.0117
Epoch 6/120
Epoch 6: val_loss did not improve from 0.01174
2069/2069 - 15s - 7ms/step - loss: 0.0149 - val_loss: 0.0121
Epoch 7/120
Epoch 7: val_loss did not improve from 0.01174
2069/2069 - 16s - 8ms/step - loss: 0.0147 - val_loss: 0.0127
Epoch 8/120
Epoch 8: val_loss did not improve from 0.01174
2069/2069 - 15s - 7ms/step - loss: 0.0148 - val_loss: 0.0120
Epoch 9/120
Epoch 9: val_loss did not improve from 0.01174
2069/2069 - 15s - 7ms/step - loss: 0.0147 - val_loss: 0.0122
Epoch 10/120
Epoch 10: val_loss improved from 0.01174 to 0.01161, saving model to keras_models/PRSA_data_PM2.5_LSTM_weights.10-0.0116.keras
2069/2069 - 15s - 7ms/step - loss: 0.0147 - val_loss: 0.0116
Epoch 11/120
Epoch 11: val_loss did not improve from 0.01161
2069/2069 - 14s - 7ms/step - loss: 0.0148 - val_loss: 0.0128
Epoch 12/120
Epoch 12: val_loss did not improve from 0.01161
2069/2069 - 15s - 7ms/step - loss: 0.0148 - val_loss: 0.0123
Epoch 13/120
Epoch 13: val_loss did not improve from 0.01161
2069/2069 - 15s - 7ms/step - loss: 0.0146 - val_loss: 0.0126
Epoch 14/120
Epoch 14: val_loss did not improve from 0.01161
2069/2069 - 16s - 8ms/step - loss: 0.0147 - val_loss: 0.0119
Epoch 15/120
Epoch 15: val_loss did not improve from 0.01161
2069/2069 - 15s - 7ms/step - loss: 0.0146 - val_loss: 0.0116
Epoch 16/120
Epoch 16: val_loss did not improve from 0.01161
2069/2069 - 15s - 7ms/step - loss: 0.0146 - val_loss: 0.0117
Epoch 17/120
Epoch 17: val_loss did not improve from 0.01161
2069/2069 - 15s - 7ms/step - loss: 0.0146 - val_loss: 0.0129
Epoch 18/120
Epoch 18: val_loss did not improve from 0.01161
2069/2069 - 15s - 7ms/step - loss: 0.0147 - val_loss: 0.0132
Epoch 19/120
Epoch 19: val_loss did not improve from 0.01161
2069/2069 - 15s - 7ms/step - loss: 0.0147 - val_loss: 0.0121
Epoch 20/120
Epoch 20: val_loss did not improve from 0.01161
2069/2069 - 15s - 7ms/step - loss: 0.0147 - val_loss: 0.0121
Epoch 20: early stopping
Restoring model weights from the end of the best epoch: 10.
El entrenamiento se ha completado y el historial ha sido guardado en 'history_pm25_LSTM.joblib'.
fig, ax = plt.subplots()
fig.set_size_inches(8, 4)
ax.plot(history_pm25_LSTM.history['loss'], label='Entrenamiento')
ax.plot(history_pm25_LSTM.history['val_loss'], label='Validación')
ax.set_title('Pérdida (MAE) en Entrenamiento vs Validación')
ax.set_ylabel('MAE')
ax.set_xlabel('Época')
ax.legend(loc='upper right')
plt.tight_layout()
plt.show()

import re
model_dir = 'keras_models'
files = os.listdir(model_dir)
pattern = r"PRSA_data_PM2.5_LSTM_weights\.(\d+)-([\d\.]+)\.keras"
best_val_loss = float('inf')
best_model_file = None
best_model = None
for file in files:
match = re.match(pattern, file)
if match:
epoch = int(match.group(1))
val_loss = float(match.group(2))
if val_loss < best_val_loss:
best_val_loss = val_loss
best_model_file = file
if best_model_file:
best_model_path = os.path.join(model_dir, best_model_file)
print(f"Cargando el mejor modelo: {best_model_file} con val_loss: {best_val_loss}")
best_model = load_model(best_model_path)
else:
print("No se encontraron archivos de modelos que coincidan con el patrón.")
Cargando el mejor modelo: PRSA_data_PM2.5_LSTM_weights.107-0.0114.keras con val_loss: 0.0114
preds = best_model.predict(X_val)
pred_pm25 = scaler_pm25.inverse_transform(preds)
pred_pm25 = np.squeeze(pred_pm25)
271/271 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step
from sklearn.metrics import mean_absolute_error
mae = mean_absolute_error(df_val['pm2.5'].loc[7:], pred_pm25)
print('MAE for the validation set:', round(mae, 4))
MAE for the validation set: 11.3706
Hemos utilizado
keras.callbacks.ModelCheckpoint
como callback para rastrear elMSE
del modelo en el conjunto de validación y guardar los pesos de la epoch que da el mínimo MSE. ElR-cuadrado
del mejor modelo para el conjunto de validación es de 0.9959. Los primeros cincuenta valores reales y predichos se muestran en la siguiente figura
plt.figure(figsize=(5.5, 5.5))
plt.plot(range(50), df_val['pm2.5'].loc[7:56], linestyle='-', marker='*', color='r')
plt.plot(range(50), pred_pm25[:50], linestyle='-', marker='.', color='b')
plt.legend(['Actual','Predicted'], loc=2)
plt.title('Actual vs Predicted pm2.5')
plt.ylabel('pm2.5')
plt.xlabel('Index');

10.19. Atención y Memoria#
Introducción
El uso histórico de esquemas de atención en redes neuronales se inspira en el mecanismo de atención humana. En la visión humana, nos centramos en información contextual importante cuando observamos escenas. En el aprendizaje automático, los modelos de atención implementan este concepto asignando pesos a las variables que influyen en la salida.
Por ejemplo, en una RNN, la salida \(\hat{\boldsymbol{y}}_{n}\) depende del vector de estado \(\boldsymbol{h}_{n}\), pero no siempre es la información más importante para tareas como la traducción de idiomas (aunque el estado codifica la memoria hasta el tiempo más reciente, \(n\).).
Observation 10.6 (Caso típico)
En la traducción del japonés al inglés, la última palabra de una frase japonesa puede influir mucho en la primera palabra de la traducción inglesa. Del mismo modo, en la toma de decisiones, nuestras acciones dependen de las experiencias pasadas acumuladas, y algunas experiencias pasadas específicas ejercen más influencia que las recientes.
Para solucionar este problema, los mecanismos de atención asignan pesos a los vectores de estado anteriores, lo que permite que la salida dependa de una combinación ponderada de información previa. Estas ponderaciones se aprenden durante el entrenamiento, lo que permite al sistema dar prioridad a detalles contextuales significativos a la hora de tomar decisiones o generar resultados.
Por ejemplo, el vector de salida puede modificarse para que dependa de todos los vectores de estado calculados anteriormente
\[ \hat{\boldsymbol{y}}_{n}=f\left(\sum_{i=1}^{n}\alpha_{ni}\boldsymbol{h}_{i}+\boldsymbol{c}\right), \]donde \(\alpha_{ni}\) son los correspondientes pesos en el tiempo \(n\).
La idea anterior de combinar todos los vectores de estado ha sido empleada, en una formulación algo diferente, en el sistema de traducción automática (ver [Bahdanau et al., 2014])

Fig. 10.51 Valores de los pesos de atención en escala de grises. Example 10.1#
Example 10.1
La Fig. 10.51 demuestra el razonamiento que subyace a la utilización de un mecanismo de ponderación. La entrada está representada por las palabras en francés, mientras que las secuencias de salida correspondientes están representadas por las palabras en inglés.
La visualización representa los pesos de atención como píxeles, donde los pesos más altos se representan como píxeles más blancos. En particular, el término
"produce"
en la salida es consecuencia de asignar peso a la información de tres puntos temporales consecutivos, vinculados con las palabras"peut plus produire"
, mientras que el término"destruction"
está asociado con dos palabras, a saber,"La destruction"
.
Observation 10.7
Una ventaja notable de incorporar un mecanismo de atención al modelo es la transparencia que aporta a las acciones del modelo y a la formación de la información de salida. Esto resulta especialmente valioso cuando la interpretabilidad de la red es una preocupación, ya que permite comprender el “por qué” y el “cómo” del proceso de toma de decisiones de la red.
10.20. Entrenamiento adversario#
Introducción
Las redes neuronales profundas lideran en la obtención de un rendimiento y precisión sobresalientes, frecuentemente comparables e incluso en algunos casos superiores a las habilidades humanas. Sin embargo, persiste el desafío de afirmar que estos modelos realmente “comprenden” las tareas que han “aprendido” a realizar, a pesar de su habilidad para predecir etiquetas precisas en tareas de clasificación con una confianza considerablemente alta.
Es viable
generar ejemplos adversos que sistemáticamente engañan a los modelos de aprendizaje automático
. Aquí, “adversario” denota la introducción intencional de pequeñas perturbaciones en los patrones dentro del conjunto de datos de entrada, lo que resulta en una alta probabilidad de clasificación incorrecta. Notablemente, estas sutiles perturbaciones de ruido son apenas detectables para los sentidos humanos, ya sea en forma de imágenes o música.
La Fig. 10.52 muestra una serie de nueve imágenes. Se ha entrenado una red neuronal (AlexNet) para discernir los contenidos de las imágenes. El trío izquierdo de imágenes, tomadas del respectivo conjunto de pruebas, fueron identificadas con precisión. Las imágenes en el centro son versiones ruidosas añadidas a las originales de la izquierda. Las composiciones resultantes se presentan a la derecha. Mientras los seres humanos anticipan sin dificultad las etiquetas correctas, AlexNet catalogó de forma errónea las tres imágenes como “
avestruz, struthio camelus
”.

Fig. 10.52 Imágenes de la izquierda se han clasificado correctamente. Todas las imágenes de la derecha han sido clasificadas como “avestruz, Struthio camelus”. Fuente [Theodoridis, 2020].#
La causa de este comportamiento aparentemente “peculiar” parece estar vinculada a la naturaleza altamente dimensional del espacio de entrada. Por lo general, en un contexto de aprendizaje, se cumple el supuesto de suavidad. Esto implica que para un valor positivo suficientemente pequeño \(\epsilon\) y un patrón de entrada \(\boldsymbol{x}\), se espera que el patrón
\[ \boldsymbol{x}':= \boldsymbol{x} + \boldsymbol{v},~\text{donde}~\boldsymbol{v} : \|\boldsymbol{v}\|\leq\epsilon, \]se clasifique en la misma categoría que \(\boldsymbol{x}\) con una alta probabilidad.
El impacto de la alta dimensionalidad (número de características igual o superior al número de observaciones) en la condición de suavidad se hace evidente, sobre todo en el contexto de un clasificador lineal. Consideremos un clasificador entrenado descrito por sus parámetros, \(\boldsymbol{\theta}\). Dado un patrón de entrada, \(\boldsymbol{x}\), la etiqueta se determina basándose en el signo del producto interior, \(\boldsymbol{\theta}^{T}\boldsymbol{x}\). Para el escenario en el que interviene \(\boldsymbol{x}'\), el producto interior se expresa como
Si establecemos intencionadamente \(\boldsymbol{v} = \pm\epsilon\cdot\text{sgn}(\boldsymbol{\theta})\), donde la operación signo se aplica elemento a elemento, se obtiene un resultado digno de mención. Entonces
Por lo tanto, cuando la dimensionalidad de la entrada \(l\) es alta, es probable que se produzcan diferencias significativas en los valores del producto interno, lo que podría causar etiquetas de predicción distintas tanto para \(\boldsymbol{x}\) como para \(\boldsymbol{x}'\). En términos sencillos, la interacción de la linealidad y la alta dimensionalidad rompe el supuesto de suavidad.
Observation 10.8
A pesar de que los ejemplos adversos son poco frecuentes en los datos de entrada (tanto en los conjuntos de entrenamiento como en los de prueba), su existencia sigue siendo desconcertante. Además, pueden aprovecharse para engañar deliberadamente a las redes. En consecuencia, han surgido varias técnicas para reforzar la resistencia de las redes frente a adversarios.
Uno estudio descrito en [Szegedy et al., 2013], consiste en generar ejemplos adversos e incorporarlos de nuevo al conjunto de entrenamiento. Esta estrategia funciona como una forma de regularización a través del aumento de datos. Sin embargo, una perspectiva alternativa presentada en [Kereliuk et al., 2015] argumenta que este método no mejoró notablemente el rendimiento cuando se aplicó a datos musicales.
En [Goodfellow et al., 2014], la función de pérdida \(J\) se modifica adecuadamente como
\[ J'(\boldsymbol{\theta}, \boldsymbol{x}, \boldsymbol{y})=\alpha J(\boldsymbol{\theta}, \boldsymbol{x}, \boldsymbol{y})+(1-\alpha)J(\boldsymbol{\theta}, \boldsymbol{x}+\Delta\boldsymbol{x}, \boldsymbol{y}),~0<\alpha<1, \]donde
\[ \Delta\boldsymbol{x}=\epsilon\cdot\text{sgn}\left(\frac{\partial}{\partial\boldsymbol{x}}J(\boldsymbol{\theta}, \boldsymbol{x}, \boldsymbol{y})\right),~\epsilon>0, \]es una dirección para la perturbación adversarial.
Es importante señalar que los ejemplos adversarios siguen siendo un foco de investigación en evolución dinámica. Se subrayan los peligros potenciales, como la manipulación de vehículos autónomos utilizando muestras adversarios, entre otros (ver [Kurakin et al., 2018, Papernot et al., 2017, Papernot et al., 2016]).
10.21. Modelos Generativos Profundos#
10.21.1. Máquina de Boltzmann Restringida#
Introducción
Una máquina de Boltzmann restringida (RBM) es una red neuronal artificial estocástica generativa que puede aprender una distribución de probabilidad sobre su conjunto de entradas bajo ajuste de parámetros. Las RBM fueron estudiadas inicialmente con el nombre de Harmonium por Paul Smolensky en 1986, y adquirieron prominencia después de que Geoffrey Hinton y sus colaboradores propusieron para estas, algoritmos de aprendizaje rápido a mediados de 2000.
Las RBM han encontrado aplicaciones en la
reducción de la dimensionalidad, clasificación, filtrado, aprendizaje de funciones, modelado de temas e incluso en muchas mecánicas cuánticas corporales
. Pueden ser entrenados en forma supervisada o no supervisada, dependiendo de la tarea.
El modelo gráfico de la Fig. 10.53 ilustra una máquina de Boltzmann restringida (RBM). La máquina de Boltzmann utiliza la siguiente distribución de probabilidad conjunta de Boltzmann definida como
\[ p(x_{1}, \dots, x_{l}):=p(\boldsymbol{x})=\frac{1}{Z}\exp\left(-\sum_{i}\left(\sum_{j>i}\theta_{ij}x_{i}x_{j}+\theta_{i0}x_{i}\right)\right), \]donde cada variable aleatoria toma valores binarios en \(\{-1, 1\}\), \(\theta_{ij}=0\) si los nodos respectivos no están conectados y la constante \(Z\) se conoce como función de partición y es la constante normalizadora para garantizar que \(p(x_{1}, \dots, x_{l})\) es una distribución de probabilidad.
Es importante destacar que los nodos dentro de la misma capa carecen de interconexiones. Esta arquitectura abarca nodos visibles en la capa inferior que reciben observaciones, mientras que la capa superior contiene nodos vinculados a variables ocultas. Cabe destacar que sólo los nodos de la capa inferior reciben observaciones. Es posible construir RBM profundos apilando múltiples RBM unos sobre otros.
De acuerdo con la definición general de una máquina de Boltzmann, la distribución conjunta de las variables aleatorias implicadas puede expresarse como:
\[ P(v_{1}, \dots, v_{J}, h_{1}, \dots, h_{I})=\frac{1}{Z}\exp(-E(\boldsymbol{v}, \boldsymbol{h})), \]donde hemos utilizado símbolos diferentes para las \(J\) variables visibles (\(v_{j},~j = 1, 2,\dots, J\)) y las \(I\) variables ocultas (\(h_{i},~i = 1, 2,\dots, I\)).
La función de energía no es la salida de la RBM, por lo contrario, es una métrica para medir la calidad de un modelo. Produce un valor escalar que corresponde básicamente a la configuración del modelo y es un indicador de la probabilidad de que el modelo esté en esa configuración. Si el modelo está configurado para favorecer una energía baja, las configuraciones que conduzcan a una energía baja tendrán una probabilidad más alta.

Fig. 10.53 Arquitectura de modelo (RBM). Fuente [Theodoridis, 2020].#
La energía se define en función de un conjunto de parámetros desconocidos, es decir,
La constante de normalización se obtiene como
Nuestro enfoque será en variables discretas, lo que implica que las distribuciones relacionadas son de carácter probabilístico. Específicamente, nos centraremos en variables de naturaleza binaria, es decir, aquellas que poseen únicamente dos posibles valores, \(v_{j}, h_{i}\in\{0, 1\},\) \(j=1,\dots,J\), \(i=1,\dots,I\).
El objetivo al entrenar una Máquina de Boltzmann Restringida (RBM) consiste en adquirir el conjunto de parámetros desconocidos \(\theta_{ij}, b_{i}, c_{j}\), los cuales serán designados colectivamente como, \(\boldsymbol{\Theta},\boldsymbol{b}\) y \(\boldsymbol{c}\) respectivamente. Un enfoque primordial para lograr esto es maximizar la log-verosimilitud, utilizando \(N\) observaciones de las variables visibles, representadas como \(v_{n},~n=1,\dots, N\), donde
\[ \boldsymbol{v}_{n}=[v_{1n}, \dots, v_{Jn}]^{T}, \]es el vector que contiene las observaciones correspondientes en el momento \(n\).
Diremos que los nodos visibles están fijados en las observaciones respectivas. La correspondiente log-verosimilitud (promedio) se expresa como sigue:
\[\begin{split} \begin{align*} L(\boldsymbol{\Theta}, \boldsymbol{b}, \boldsymbol{c})&=\frac{1}{N}\sum_{n=1}^{N}\ln P(\boldsymbol{v}_{n};\boldsymbol{\Theta}, \boldsymbol{b}, \boldsymbol{c})\\ &=\frac{1}{N}\sum_{n=1}^{N}\ln\left(\frac{1}{Z}\sum_{\boldsymbol{h}}\exp(-E(\boldsymbol{v}_{n}, \boldsymbol{h}; \boldsymbol{\Theta}, \boldsymbol{b}, \boldsymbol{c}))\right)\\ &=\frac{1}{N}\sum_{n=1}^{N}\ln\left(\sum_{\boldsymbol{h}}\exp(-E(\boldsymbol{v}_{n}, \boldsymbol{h}; \boldsymbol{\Theta}, \boldsymbol{b}, \boldsymbol{c}))\right)-\ln\sum_{\boldsymbol{v}}\sum_{\boldsymbol{h}}\exp\left(-E(\boldsymbol{v}, \boldsymbol{h})\right), \end{align*} \end{split}\]donde el índice “\(n\)” en la energía se refiere a las observaciones respectivas en las cuales los nodos visibles han sido fijados, y el símbolo “\(\Theta\)” ha sido incluido explícitamente en la notación.
Tomando la derivada de \(L(\boldsymbol{\Theta}, \boldsymbol{b}, \boldsymbol{c})\) respecto a \(\theta_{ij}\) (similar es el caso de las derivadas respecto a respecto a \(b_{i}\) y \(c_{j}\)) y aplicando las propiedades estándar de las derivadas, se tiene que (verifíquelo)
(10.18)#\[ \frac{\partial L(\boldsymbol{\Theta}, \boldsymbol{b}, \boldsymbol{c})}{\partial\theta_{ij}}=\frac{1}{N}\sum_{n=1}^{N}\left(\sum_{\boldsymbol{h}}P(\boldsymbol{h}|\boldsymbol{v}_{n})h_{i}v_{jn}\right)-\sum_{\boldsymbol{v}}\sum_{\boldsymbol{h}}P(\boldsymbol{v}, \boldsymbol{h})h_{i}v_{j}, \]donde
\[ P(\boldsymbol{h}|\boldsymbol{v})=\frac{P(\boldsymbol{v}|\boldsymbol{h})}{\sum_{\boldsymbol{h}'}P(\boldsymbol{v}, \boldsymbol{h}')}. \]
El gradiente en la Eq. (10.18) consta de dos términos. El primero puede ser calculado una vez que \(P(\boldsymbol{h}|\boldsymbol{v})\) está disponible. Básicamente, este término es la tasa media de disparo o correlación cuando la Máquina de Boltzmann Restringida (RBM) está operando en su fase fijada; a menudo, nos referimos a esto como la fase positiva (regla de aprendizaje de Hebb [Hebb, 2005]), y el término se denota como \(\langle h{i}v_{j}\rangle^{+}\). El segundo término es la correlación correspondiente cuando la RBM está funcionando en su llamada fase libre o negativa, y se denota como \(\langle h_{i}v_{j}\rangle^{-}\). Por lo tanto, un esquema de aumento de gradiente para maximizar la log-verosimilitud tendrá la forma:
10.22. Autoencoders#

Los autoencoders han sido propuestos como métodos para la reducción de dimensionalidad. Un autoencoder consta de dos partes, el codificador y el decodificador. La salida del
codificador es la representación reducida del patrón de entrada
, y se define en términos de una función vectorial\[ \boldsymbol{g}_{\phi}:~\boldsymbol{x}\in\mathbb{R}^{l}\longmapsto\boldsymbol{z}\in\mathbb{R}^{m}, \]donde
\[ z_{i}:=g_{\phi, i}(\boldsymbol{x})=\phi_{e}(\boldsymbol{\theta}_{i}^{T}\boldsymbol{x}+b_{i}),\quad i=1,2,\dots,m, \]con \(\phi_{e}\) función de activación; esta última suele tomarse como la función sigmoidea logística \(\phi_{e}(\cdot)=\sigma(\cdot)\). En otras palabras, el codificador es una red neuronal feed-forward de una sola capa oculta
El
decodificador
es otra función \(\boldsymbol{f}_{\theta}\).\[ \boldsymbol{f}_{\theta}:~\boldsymbol{z}\in\mathbb{R}^{m}\longmapsto\boldsymbol{x}'\in\mathbb{R}^{l}, \]donde
\[ \hat{x}_{j}=f_{\theta, j}(\boldsymbol{z})=\phi_{d}(\boldsymbol{\theta}'^{T}\boldsymbol{z}+b_{j}'),\quad j=1,\dots,l. \]La función de activación \(\phi_{d}\) suele ser la identidad (reconstrucción lineal) o la sigmoidea logística.
La tarea de entrenamiento consiste en estimar los parámetros
Es habitual suponer que \(\Theta'=\Theta^{T}\). Los parámetros se estiman para que el error de reconstrucción \(\boldsymbol{e}=\boldsymbol{x}-\boldsymbol{x}'\), sobre las muestras de entrada disponibles sea mínimo. Normalmente, se utiliza el coste por mínimos cuadrados pero también son posibles otras opciones.
Las versiones regularizadas, que implican una norma de los parámetros, también es una posibilidad. Si se elige que la activación \(\phi_{e}\) sea la identidad (representación lineal) y \(m < l\) (para evitar la trivialidad), el autoencoder es equivalente a la técnica PCA.
10.23. Aplicación: Autoencoders para MNIST data#
import numpy as np
import matplotlib.pyplot as plt
from tensorflow.keras.datasets import mnist
from tensorflow.keras.models import Model
from tensorflow.keras.layers import Input, Dense
import warnings
warnings.filterwarnings('ignore')

A continuación
Se cargan los datos del conjunto MNIST, que contiene imágenes de dígitos escritos a mano.
Cada muestra es una imagen en escala de grises de 28×28 píxeles.
Se ignoran las etiquetas de clase (
_
) porque el objetivo es reconstruir las imágenes (no clasificarlas), tal como se requiere en tareas de autoencoders.
(x_train, _), (x_test, _) = mnist.load_data()
print("Forma original de x_train:", x_train.shape)
print("Forma original de x_test:", x_test.shape)
Forma original de x_train: (60000, 28, 28)
Forma original de x_test: (10000, 28, 28)
Se normalizan los valores de los píxeles de las imágenes, originalmente en el rango [0, 255], para que estén en el intervalo [0, 1]. La normalización facilita el entrenamiento del modelo al mejorar la estabilidad numérica y acelerar la convergencia del optimizador. Se convierte el tipo de dato a
float32
para asegurar compatibilidad con las operaciones enTensorFlow/Keras
.
x_train = x_train.astype('float32') / 255.
x_test = x_test.astype('float32') / 255.
Cada imagen del conjunto de datos MNIST tiene dimensiones 28×28, es decir, 784 píxeles. Estas imágenes se transforman en vectores unidimensionales de 784 elementos para que puedan ser procesadas por capas densas (
Dense
) del autoencoder. Esta transformación no altera la información de la imagen, solo su forma (reshape) para adaptarse a la entrada del modelo.
x_train = x_train.reshape((len(x_train), 784))
x_test = x_test.reshape((len(x_test), 784))
print("Forma después del reshape de x_train:", x_train.shape)
print("Forma después del reshape de x_test:", x_test.shape)
Forma después del reshape de x_train: (60000, 784)
Forma después del reshape de x_test: (10000, 784)
input_img = Input(shape=(784,))
:
Se define la capa de entrada del modelo, correspondiente a una imagen aplanada de 784 píxeles.encoded = Dense(32, activation='relu')(input_img)
:
Esta capa es el codificador, que transforma la imagen de entrada en una representación latente de 32 dimensiones utilizando una función de activación ReLU.decoded = Dense(784, activation='sigmoid')(encoded)
:
Esta capa actúa como decodificador, intentando reconstruir la imagen original a partir de la representación latente.sigmoid
se adapta bien cuando se usa la función de pérdidabinary_crossentropy
, ya que esa pérdida espera que la salida esté entre 0 y 1. Asegura que los valores de salida no sean negativos ni mayores a 1, lo que tendría poco sentido en términos de imágenes normalizadas.autoencoder = Model(input_img, decoded)
:
Se define el modelo completo del autoencoder, que aprende a mapear una imagen a su reconstrucción.
input_img = Input(shape=(784,))
encoded = Dense(32, activation='relu')(input_img)
decoded = Dense(784, activation='sigmoid')(encoded)
autoencoder = Model(input_img, decoded)
autoencoder.compile(...)
:
Se compila el modelo autoencoder, especificando el optimizador y la función de pérdida.optimizer='adam'
:
Se utiliza el optimizador Adam, ampliamente usado en redes neuronales por su eficiencia y capacidad de adaptación del aprendizaje.loss='binary_crossentropy'
:
Se emplea la entropía cruzada binaria como función de pérdida, apropiada en tareas de reconstrucción donde los valores de salida están en el rango [0, 1].
autoencoder.compile(optimizer='adam', loss='binary_crossentropy')
autoencoder.fit(...)
:
Entrena el modelo autoencoder utilizando como entrada y objetivo los mismos datos, dado que la tarea es de reconstrucción.x_train, x_train
:
Se entrena el modelo para que aprenda a reproducir su entrada como salida.epochs=50
:
El entrenamiento se realiza durante 50 épocas completas sobre el conjunto de datos.batch_size=256
:
El tamaño del mini-batch para cada actualización de parámetros es de 256 muestras.shuffle=True
:
Se mezclan aleatoriamente las muestras en cada época para mejorar la generalización.validation_data=(x_test, x_test)
:
Se utiliza el conjunto de prueba para evaluar la pérdida de validación y así monitorear el sobreajuste.
autoencoder.fit(x_train, x_train,
epochs=50,
batch_size=256,
shuffle=True,
validation_data=(x_test, x_test))
Epoch 1/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 3s 8ms/step - loss: 0.3830 - val_loss: 0.1906
Epoch 2/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 1s 2ms/step - loss: 0.1813 - val_loss: 0.1540
Epoch 3/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.1492 - val_loss: 0.1339
Epoch 4/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.1317 - val_loss: 0.1216
Epoch 5/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.1203 - val_loss: 0.1134
Epoch 6/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.1132 - val_loss: 0.1079
Epoch 7/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.1080 - val_loss: 0.1037
Epoch 8/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.1041 - val_loss: 0.1005
Epoch 9/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.1009 - val_loss: 0.0978
Epoch 10/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.0984 - val_loss: 0.0960
Epoch 11/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.0968 - val_loss: 0.0948
Epoch 12/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.0957 - val_loss: 0.0940
Epoch 13/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.0952 - val_loss: 0.0935
Epoch 14/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.0949 - val_loss: 0.0932
Epoch 15/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.0944 - val_loss: 0.0929
Epoch 16/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.0943 - val_loss: 0.0927
Epoch 17/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.0938 - val_loss: 0.0926
Epoch 18/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.0937 - val_loss: 0.0924
Epoch 19/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.0937 - val_loss: 0.0923
Epoch 20/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.0935 - val_loss: 0.0923
Epoch 21/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.0935 - val_loss: 0.0921
Epoch 22/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.0933 - val_loss: 0.0921
Epoch 23/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.0933 - val_loss: 0.0920
Epoch 24/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.0932 - val_loss: 0.0920
Epoch 25/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.0931 - val_loss: 0.0919
Epoch 26/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.0932 - val_loss: 0.0919
Epoch 27/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.0931 - val_loss: 0.0919
Epoch 28/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.0927 - val_loss: 0.0919
Epoch 29/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 2ms/step - loss: 0.0930 - val_loss: 0.0919
Epoch 30/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.0931 - val_loss: 0.0918
Epoch 31/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.0928 - val_loss: 0.0918
Epoch 32/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.0930 - val_loss: 0.0917
Epoch 33/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.0931 - val_loss: 0.0917
Epoch 34/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.0929 - val_loss: 0.0917
Epoch 35/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.0928 - val_loss: 0.0917
Epoch 36/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.0928 - val_loss: 0.0917
Epoch 37/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.0929 - val_loss: 0.0917
Epoch 38/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.0930 - val_loss: 0.0917
Epoch 39/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.0927 - val_loss: 0.0917
Epoch 40/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.0927 - val_loss: 0.0916
Epoch 41/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.0926 - val_loss: 0.0917
Epoch 42/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.0929 - val_loss: 0.0916
Epoch 43/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.0927 - val_loss: 0.0916
Epoch 44/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.0929 - val_loss: 0.0916
Epoch 45/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.0927 - val_loss: 0.0917
Epoch 46/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.0928 - val_loss: 0.0916
Epoch 47/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.0928 - val_loss: 0.0916
Epoch 48/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.0927 - val_loss: 0.0916
Epoch 49/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.0926 - val_loss: 0.0916
Epoch 50/50
235/235 ━━━━━━━━━━━━━━━━━━━━ 0s 1ms/step - loss: 0.0928 - val_loss: 0.0916
<keras.src.callbacks.history.History at 0x7fb01ba215a0>
Define el modelo encoder que transforma una imagen de entrada (784 dimensiones) en su representación latente de 32 dimensiones. Este modelo extrae la parte de codificación del autoencoder original.
Crea un nuevo modelo decoder tomando como entrada una representación latente de 32 dimensiones y aplicando la última capa del autoencoder (la capa de decodificación). Esto permite reconstruir imágenes directamente desde sus representaciones codificadas, de forma independiente.
autoencoder.layers[-1]
accede a la última capa del modelo autoencoder (elDense(784, activation='sigmoid')
)
encoder = Model(input_img, encoded)
encoded_input = Input(shape=(32,))
decoder_layer = autoencoder.layers[-1]
decoder = Model(encoded_input, decoder_layer(encoded_input))
Codifica las imágenes de prueba transformándolas desde el espacio original (784 dimensiones) al espacio latente (64 dimensiones) mediante el modelo
encoder
.Reconstruye las imágenes a partir de sus representaciones latentes usando el modelo
decoder
.Este paso permite evaluar qué tan bien el autoencoder logra aproximar las imágenes originales.
encoded_imgs = encoder.predict(x_test)
decoded_imgs = decoder.predict(encoded_imgs)
313/313 ━━━━━━━━━━━━━━━━━━━━ 0s 632us/step
313/313 ━━━━━━━━━━━━━━━━━━━━ 0s 607us/step
Selecciona 10 imágenes del conjunto de prueba y configura el tamaño de la figura para mostrar una comparación clara entre las imágenes originales y sus reconstrucciones.
Primera fila: imágenes originales. Se toma la \(i\)-ésima imagen original, se remodela a su forma 2D (28×28) y se muestra en escala de grises sin ejes para mayor claridad.
Segunda fila: imágenes reconstruidas. Se visualiza la imagen generada por el autoencoder a partir de la codificación latente.
n = 10
plt.figure(figsize=(20, 4))
for i in range(n):
ax = plt.subplot(2, n, i + 1)
plt.imshow(x_test[i].reshape(28, 28), cmap="gray")
plt.axis('off')
ax = plt.subplot(2, n, i + 1 + n)
plt.imshow(decoded_imgs[i].reshape(28, 28), cmap="gray")
plt.axis('off')
plt.show()

Reconstrucción de imágenes
Se utiliza el modelo entrenado para generar reconstrucciones del conjunto de prueba, es decir, aproximaciones de las imágenes originales.Cálculo del Error Cuadrático Medio (MSE)
Se calcula el error cuadrático medio entre las imágenes originales y sus reconstrucciones. Este valor indica qué tan lejos están, en promedio, las imágenes reconstruidas de las originales.Estimación de la varianza total
Se computa la varianza de los datos originales del conjunto de prueba. Representa la cantidad total de información (variabilidad) presente en los datos sin comprimir.Cálculo de la variabilidad explicada
Se emplea la fórmula:\[ \text{Variabilidad explicada} = 1 - \frac{\text{MSE}}{\text{Varianza total}} \]Esta expresión indica qué fracción de la variación total en los datos originales ha sido retenida por el modelo de autoencoder. Es una métrica análoga al coeficiente de determinación (\(R^2\)) utilizado en modelos de regresión.
Interpretación del resultado
Un valor cercano a 1 sugiere que el autoencoder captura gran parte de la estructura de los datos. Un valor bajo indica que el modelo no ha logrado representar eficazmente la información original.
import numpy as np
decoded_imgs = autoencoder.predict(x_test)
mse = np.mean(np.square(x_test - decoded_imgs))
var_total = np.var(x_test)
var_explicada = 1 - (mse / var_total)
print("Variabilidad explicada por el autoencoder:", var_explicada)
313/313 ━━━━━━━━━━━━━━━━━━━━ 0s 705us/step
Variabilidad explicada por el autoencoder: 0.8997409120202065
En un autoencoder, el número de neuronas en la capa latente (bottleneck layer) representa la dimensión comprimida o el número de “componentes” aprendidos. Aunque no son componentes lineales como en PCA, son equivalentes en el sentido de que capturan una representación reducida de los datos. Por ejemplo, en el modelo:
encoded = Dense(32, activation='relu')(input_img)
Aquí el autoencoder utiliza 32 dimensiones latentes, lo que se puede comparar directamente con las 32 primeras componentes principales en PCA.
¿Cómo hacer una comparación justa con PCA?
Entrena un PCA con el mismo número de componentes:
from sklearn.decomposition import PCA pca = PCA(n_components=32) X_pca = pca.fit_transform(x_test) X_reconstructed = pca.inverse_transform(X_pca)
Calcula el MSE y la variabilidad explicada del PCA, de la misma forma que hiciste con el autoencoder:
mse_pca = np.mean(np.square(x_test - X_reconstructed)) var_total = np.var(x_test) var_explicada_pca = 1 - (mse_pca / var_total)
Compara valores:
Variabilidad explicada por el Autoencoder
Variabilidad explicada por el PCA
Esto te dirá cuál técnica preserva mejor la estructura de los datos bajo la misma cantidad de compresión (32 dimensiones, por ejemplo).
from sklearn.decomposition import PCA
pca = PCA(n_components=32)
X_pca = pca.fit_transform(x_test)
X_reconstructed = pca.inverse_transform(X_pca)
mse_pca = np.mean(np.square(x_test - X_reconstructed))
var_total = np.var(x_test)
var_explicada_pca = 1 - (mse_pca / var_total)
print("Variabilidad explicada por el PCA:", var_explicada_pca)
Variabilidad explicada por el PCA: 0.8270216286182404