Aprendizaje Profundo: Creando una Red Neuronal con Capa Oculta desde Cero

Introducción: La importancia de implementar redes neuronales manualmente

En la actualidad, contamos con potentes marcos como TensorFlow y PyTorch que nos permiten constriur modelos complejos rápidamente. Sin embargo, si eres principiante o deseas comprender en profundidad los mecanismos internos de las redes neuronales, implementar manualmente una red neuronal simple es una forma excepcional de aprendizaje.

Este artículo te llevará a crear una red neuronal con una capa oculta para resolver problemas de clasificación de datos en un plano bidimensional (como datos no linealmente separables). Utilizaremos NumPy y Matplotlib para realizar todos los cálculos manualmente, sin depender de marcos avanzados.

Arquitectura general

Construiremos una red neuronal con la siguiente estructura:


Capa de entrada (2 características) → Capa oculta (n_h neuronas) → Capa de salida (1 neurona)
       ↓                  ↓                   ↓
     x₁, x₂             tanh(Z₁)           tanh(Z₂)
  • Entrada: cada muestra tiene dos características (coordenadas x e y)
  • Capa oculta: n_h neuronas con función de activación tanh
  • Capa de salida: 1 neurona con función de activación tanh (también podría ser sigmoid, aquí simplificamos)

(Nota: las elipsas rojas en el diagrama representan características de entrada, las flechas azules indican conexiones ponderadas)

Preparación del entorno y carga de datos


import matplotlib
matplotlib.use('TkAgg')  # Si tienes soporte GUI
import numpy as np
import matplotlib.pyplot as plt
import sklearn
import sklearn.linear_model

from planar_utils import plot_decision_boundary, sigmoid, load_planar_dataset, load_extra_datasets
from testCases import *

np.random.seed(1)

# Cargar datos de ejemplo
datos, etiquetas = load_planar_dataset()

# Visualizar datos iniciales
plt.scatter(datos[0,:], datos[1,:], c=etiquetas.ravel(), s=40, cmap=plt.cm.Spectral)
plt.title("Distribución original de datos")
plt.show()

Explicación:

  • load_planar_dataset() es una función auxiliar que genera un conjunto de puntos bidimensionales con etiquetas (usualmetne datos en forma de anillo o flor, no linealmente separables).
  • Usando scatter podemos visualizar los datos, observando que los puntos de dos clases diferentes no pueden separarse con una línea recta.
  • ¡Aquí es donde las redes neuronales demuestran su utilidad!

Inicialización de parámetros


def configurar_parametros(n_entradas, n_ocultas, n_salidas):
    np.random.seed(2)
    W1 = np.random.randn(n_ocultas, n_entradas) * 0.01
    b1 = np.zeros((n_ocultas, 1))
    W2 = np.random.randn(n_salidas, n_ocultas) * 0.01
    b2 = np.zeros((n_salidas, 1))

    parametros = {
        "W1": W1,
        "b1": b1,
        "W2": W2,
        "b2": b2
    }
    return parametros

Explicación:

  • n_entradas: dimensión de entrada (aquí 2)
  • n_ocultas: número de neuronas en la capa oculta (ajustable)
  • n_salidas: dimensión de salida (aquí 1)
  • Los pesos W se inicializan con pequeños números aleatorios (multiplicados por 0.01) para evitar la explosión de gradientes
  • Los sesgos b se inicializan en 0

Propagación hacia adelante (Forward Propagation)


def propagacion_adelante(X, parametros):
    m = X.shape[1]
    W1 = parametros['W1']
    b1 = parametros['b1']
    W2 = parametros['W2']
    b2 = parametros['b2']

    # Capa oculta
    Z1 = np.dot(W1, X) + b1
    A1 = np.tanh(Z1)
    
    # Capa de salida
    Z2 = np.dot(W2, A1) + b2
    A2 = np.tanh(Z2)

    almacenamiento = {"Z1": Z1, "A1": A1, "Z2": Z2, "A2": A2}
    return A2, almacenamiento

Fórmulas matemáticas:

  • A2 es la salida final, representando el valor predicho para cada muestra
  • almacenamiento guarda variables intermedias para su uso posterior en la propagación hacia atrás

Cálculo de la función de pérdida


def calcular_costo(A2, Y, parametros):
    m = Y.shape[1]
    
    # Calcular entropía cruzada binaria
    logprobabilidades = np.multiply(np.log(A2), Y) + np.multiply(1 - Y, np.log(1 - A2))
    costo = -np.sum(logprobabilidades) / m
    
    return costo

Nota importante:

Utilizamos la función de pérdida de **entropía cruzada binaria**, adecuada para tareas de clasificación binaria. Sin embargo, dado que usamos tanh como función de activación (con rango [-1, 1]) mientras que la entropía cruzada estándar requiere [0,1], en una implementación real deberíamos usar sigmoid o ajustar las etiquetas. En este ejercicio, nos centraremos en el flujo de trabajo más que en la precisión numérica.

Propagación hacia atrás (Backward Propagation)


def propagacion_atras(parametros, almacenamiento, X, Y):
    m = X.shape[1]
    W1 = parametros['W1']
    W2 = parametros['W2']
    A1 = almacenamiento['A1']
    A2 = almacenamiento['A2']

    # Calcular gradientes para la capa de salida
    dZ2 = A2 - Y
    dW2 = (1 / m) * np.dot(dZ2, A1.T)
    db2 = (1 / m) * np.sum(dZ2, axis=1, keepdims=True)
    
    # Calcular gradientes para la capa oculta
    dZ1 = np.multiply(np.dot(W2.T, dZ2), 1 - np.power(A1, 2))
    dW1 = (1 / m) * np.dot(dZ1, X.T)
    db1 = (1 / m) * np.sum(dZ1, axis=1, keepdims=True)

    gradientes = {
        "dW1": dW1,
        "db1": db1,
        "dW2": dW2,
        "db2": db2
    }
    return gradientes

Resumen de la derivación matemática:

  • El cálculo de gradientes se basa en la regla de la cadena y la derivada de la función de pérdida
  • Para la capa de salida, dZ2 = A2 - Y proviene de la derivada de la entropía cruzada
  • Para la capa oculta, se aplica la derivada de la función tanh: dZ1 = dZ2 * W2.T * (1 - A1^2)

Actualización de parámetros (Descenso de gradiente)


def actualizar_parametros(parametros, gradientes, tasa_aprendizaje=1.2):
    W1 = parametros['W1'] - tasa_aprendizaje * gradientes['dW1']
    b1 = parametros['b1'] - tasa_aprendizaje * gradientes['db1']
    W2 = parametros['W2'] - tasa_aprendizaje * gradientes['dW2']
    b2 = parametros['b2'] - tasa_aprendizaje * gradientes['db2']

    parametros = {
        "W1": W1,
        "b1": b1,
        "W2": W2,
        "b2": b2
    }
    return parametros

Explicación:

  • Utilizamos el método estándar de descenso de gradiente para actualizar los parámetros
  • La tasa de aprendizaje se establece en 1.2, pero puede ajustarse según el rendimiento del entrenamiento
  • Los parámetros se actualizan en la dirección opuesta al gradiente para minimizar la pérdida

Integración del proceso de entrenamiento


def modelo_red_neuronal(X, Y, n_ocultas, num_iteraciones=10000, imprimir_costo=False):
    np.random.seed(3)
    n_entradas = X.shape[0]
    n_salidas = Y.shape[0]

    # Inicializar parámetros
    parametros = configurar_parametros(n_entradas, n_ocultas, n_salidas)

    # Bucle de entrenamiento
    for i in range(num_iteraciones):
        # Propagación hacia adelante
        A2, almacenamiento = propagacion_adelante(X, parametros)
        
        # Calcular costo
        costo = calcular_costo(A2, Y, parametros)
        
        # Propagación hacia atrás
        gradientes = propagacion_atras(parametros, almacenamiento, X, Y)
        
        # Actualizar parámetros
        parametros = actualizar_parametros(parametros, gradientes)

        # Imprimir costo cada 1000 iteraciones
        if imprimir_costo and i % 1000 == 0:
            print(f"Iteración {i}: costo = {costo:.4f}")

    return parametros

Funcionalidad:

  • Encapsula todo el proceso de entrenamiento
  • Realiza múltiples iteraciones para optimizar los parámetros
  • Muestra la evolución del costo durante el entrenamiento

Pruebas y visualización


# Cargar datos de prueba
X_prueba, Y_prueba = datos_prueba_modelo_red_neuronal()
parametros_entrenados = modelo_red_neuronal(X_prueba, Y_prueba, 4, num_iteraciones=10000, imprimir_costo=False)

# Visualizar la frontera de decisión
def predecir(X, parametros):
    A2, _ = propagacion_adelante(X, parametros)
    return A2 > 0

plot_decision_boundary(lambda x: predecir(x.T, parametros_entrenados), X, Y)
plt.title("Frontera de decisión después del entrenamiento")
plt.show()

Resultado:

  • La función plot_decision_boundary dibuja las regiones clasificadas por el modelo
  • Se observa que incluso para datos no linealmente separables, la red neuronal puede crear fronteras curvas para la clasificación

Resumen de conceptos clave

Concepto Función
Propagación hacia adelante Calcular valores predichos
Función de pérdida Medir error de predicción
Propagación hacia atrás Calcular gradientes
Descenso de gradiente Actualizar parámetros para minimizar pérdida
Capa oculta Proporcionar capacidad de representación no lineal

Preguntas frecuentes y direcciones de mejora

  1. Selección de función de activación: Se recomienda cambiar tanh por sigmoid o ReLU para aplicaciones prácticas
  2. Función de pérdida: Debería usarse sigmoid + entropía cruzada binaria para estabilidad numérica
  3. Regularización: Se puede agregar un término L2 para evitar sobreajuste
  4. Optimizadores: Se pueden probar algoritmos modernos como Adam o RMSProp
  5. Redes más profundas: Se puede extender a redes con más capas ocultas

Conclusión

Mediante este artículo, has implementado manualmente una red neuronal completa con capa oculta. Aunque es simple, contiene los conceptos fundamentales del aprendizaje profundo:

Propagación hacia adelante → Cálculo de pérdida → Propagación hacia atrás → Actualización de parámetros

Esta es la lógica subyacente en todos los marcos de aprendizaje profundo.

Recuerda: la verdadera comprensión proviene de la codificación manual. Cuando puedas escribir este código y entender el significado de cada línea, dejas de ser un "usuario de cajas negras" para convertirte en un verdadero explorador de IA.

Etiquetas: redes-neuronales aprendizaje-profundo Python NumPy backpropagation

Publicado el 6-9 18:22