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_hneuronas con función de activacióntanh - 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
scatterpodemos 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
Wse inicializan con pequeños números aleatorios (multiplicados por 0.01) para evitar la explosión de gradientes - Los sesgos
bse 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:
A2es la salida final, representando el valor predicho para cada muestraalmacenamientoguarda 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 - Yproviene 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_boundarydibuja 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
- Selección de función de activación: Se recomienda cambiar
tanhporsigmoidoReLUpara aplicaciones prácticas - Función de pérdida: Debería usarse
sigmoid + entropía cruzada binariapara estabilidad numérica - Regularización: Se puede agregar un término L2 para evitar sobreajuste
- Optimizadores: Se pueden probar algoritmos modernos como Adam o RMSProp
- 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.