Fundamentos del Deep Learning
El deep learning no es una tecnología misteriosa; en esencia es un aproximador de funciones matemáticas: dale una gran cantidad de datos de entrada y salida, y automáticamente encuentra las relaciones complejas entre ellos.
Por ejemplo: le das un millón de imágenes de gatos, cada imagen con la etiqueta "gato". El deep learning encuentra automáticamente qué combinación de píxeles se ve como un gato.
Otro ejemplo: le das mil millones de frases de lenguaje humano, con la siguiente palabra de cada frase como objetivo. El deep learning aprende automáticamente, dado el contexto anterior, cuál es la siguiente palabra más probable.
La ventaja principal del deep learning es: a medida que aumenta la cantidad de datos, su rendimiento mejora continuamenteEsto es algo que el machine learning tradicional no puede lograr.
En este capítulo, desde cero, iremos desglosando paso a paso cada componente del deep learning:
- Fundamentos matemáticos de las redes neuronales
- Proceso de cálculo de la propagación hacia adelante
- El papel de las funciones de activación
- Diseño de la función de pérdida
- Principio de la retropropagación
- Selección del optimizador
- Técnicas de regularización
- Métodos de normalización
- Programación de la tasa de aprendizaje
Finalmente, implementamos un perceptrón multicapa (MLP) completo con PyTorch, conectando todos los conceptos.
Este capítulo involucra algo de matemáticas, pero no te preocupes: explicaremos cada fórmula de forma intuitiva, no para que la memorices. El énfasis está en entender por qué se diseña así, no en cómo calcular.
Fundamentos matemáticos de las redes neuronales
El deep learning se basa en tres ramas de las matemáticas: álgebra lineal, cálculo y probabilidad.
No necesitas ser un experto en matemáticas, pero debes comprender algunos conceptos clave.
Álgebra lineal: multiplicación de matrices
La operación más básica de una red neuronal es la multiplicación de matrices.
¿Por qué matrices? Porque representan de forma concisa el proceso de "múltiples entradas transformadas por múltiples neuronas".
Veamos primero un ejemplo intuitivo:
Ejemplo
# Comprensión intuitiva de la multiplicación de matrices
# ============================================
import numpy as np
# Supongamos que la entrada es un vector de 3 dimensiones: [altura, peso, edad]
# Las unidades son: centímetros, kilogramos, años
x = np.array([175, 70, 25]) # Vector de entrada, forma (3,)
print(f"Entrada x: {x}")
print(f"Forma de la entrada: {x.shape}")
# Matriz de pesos W: 2 neuronas, cada neurona recibe 3 entradas
# La forma es (dimensión de salida, dimensión de entrada) = (2, 3)
W = np.array([
[0.1, 0.2, 0.3], # Pesos de la primera neurona
[0.4, 0.5, 0.6], # Pesos de la segunda neurona
])
print(f"\nMatriz de pesos W:\n{W}")
print(f"Forma de los pesos: {W.shape}")
# Sesgo b: un sesgo por neurona
b = np.array([0.1, 0.2]) # Forma (2,)
print(f"\n"Sesgo b: {b}")
print(f"Forma del sesgo: {b.shape}")
# Multiplicación de matrices: y = W · x + b
# Nota: el operador @ de numpy denota multiplicación de matrices
y = W @ x + b
print(f"\n"Salida y: {y}")
print(f"Forma de la salida: {y.shape}")
# Verifiquemos con un cálculo manual
y1 = 0.1 * 175 + 0.2 * 70 + 0.3 * 25 + 0.1 # Primera neurona
y2 = 0.4 * 175 + 0.5 * 70 + 0.6 * 25 + 0.2 # Segunda neurona
print(f"\n"Verificación por cálculo manual: [{y1}, {y2}]")
El significado geométrico de la multiplicación de matrices es la "transformación lineal": puede rotar, escalar y estirar el espacio vectorial.
Pero solo las transformaciones lineales no son suficientes.
Si cada capa es solo una multiplicación de matrices, sin importar cuán profunda sea la red, toda la red equivale a una red de una sola capa, porque la composición de múltiples transformaciones lineales sigue siendo una transformación lineal.
Es por esto que necesitamos funciones de activación: para introducir no linealidad.
Cálculo: derivadas y regla de la cadena
El núcleo del entrenamiento de redes neuronales es el "descenso de gradiente": encontrar la dirección de los parámetros que minimiza la pérdida.
El gradiente es la "derivada de una función multivariable": nos dice cuánto cambia la pérdida cuando cada parámetro cambia un poco.
Veamos primero un ejemplo sencillo:
Ejemplo
# Comprensión intuitiva de la derivada
# ============================================
def f(x):
"""Una función simple: f(x) = x²"""
return x ** 2
def numerical_derivative(f, x, h=1e-6):
"""Derivada numérica: aproximar la derivada con un pequeño incremento h
Definición de derivada: f'(x) = lim(h→0) [f(x+h) - f(x)] / h
"""
return (f(x + h) - f(x)) / h
# Calcula la derivada en x=3
x = 3.0
df_dx = numerical_derivative(f, x)
print(f"f({x}) = {f(x)}")
print(f"f'({x}) ≈ {df_dx}")
print(f"Solución analítica (valor exacto): 2 * {x} = {2 * x}")
# Entendiendo el significado de la derivada:
# La derivada 6 significa: en x=3, si x aumenta 1, f(x) aumenta aproximadamente 6
x_new = x + 0.01
f_new = f(x_new)
print(f"\n"x aumenta de {x} a {x_new}")
print(f"f(x) cambia de {f(x)} a {f_new}")
print(f"Aumento real: {f_new - f(x)}")
print(f"Predicción de la derivada: {df_dx * 0.01}")
Para funciones multivariables, necesitamos calcular la derivada parcial de cada variable y luego combinarlas en un vector; ese es el gradiente.
La regla de la cadena es la base matemática de la retropropagación. Nos permite "descomponer capa por capa" las derivadas de funciones compuestas.
Ejemplo
# Comprensión intuitiva de la regla de la cadena
# ============================================
# Considera la función compuesta: y = f(g(x))
# Donde: g(x) = x², f(z) = z³
# Entonces: y = (x²)³ = x⁶
def g(x):
return x ** 2
def f(z):
return z ** 3
def y(x):
return f(g(x))
# Cálculo manual de la derivada (regla de la cadena):
# dy/dx = df/dz * dz/dx = 3*z² * 2*x = 3*(x²)² * 2*x = 6*x⁵
x = 2.0
z = g(x) # z = 4
dy_dx_chain = 3 * (z ** 2) * 2 * x # Cálculo con la regla de la cadena
print(f"Cálculo con regla de la cadena: dy/dx = {dy_dx_chain}")
# Verificación con derivada numérica
def numerical_derivative_y(x, h=1e-6):
return (y(x + h) - y(x)) / h
dy_dx_numerical = numerical_derivative_y(x)
print(f"Verificación con derivada numérica: dy/dx ≈ {dy_dx_numerical}")
# Solución analítica: 6*x^5 = 6*32 = 192
print(f"Solución analítica: 6 * {x}^5 = {6 * (x ** 5)}")
La idea central de la regla de la cadena es:Descomponer una función compleja en funciones simples, derivar cada una y luego multiplicarlas.。
La retropropagación es la aplicación de la regla de la cadena en las redes neuronales: desde la salida, se transmite el gradiente hacia atrás capa por capa.
Probabilidad: probabilidad condicional
En problemas de clasificación, la salida de una red neuronal suele ser una "distribución de probabilidad": dada una entrada, cuál es la probabilidad de cada categoría.
La probabilidad condicional P(Y|X) representa "la probabilidad de que ocurra Y, dado que X ha ocurrido".
:.2%}"
:.2%}"
Ejemplo
# Usar una red neuronal para generar la distribución de probabilidad
# ============================================
import numpy as np
def softmax(x):
"""Función Softmax: convierte cualquier número real en una distribución de probabilidad
La suma de todos los valores de salida es 1, cada valor está entre [0, 1]
"""
# Restar el máximo para evitar desbordamiento numérico
exp_x = np.exp(x - np.max(x))
return exp_x / np.sum(exp_x)
# Supongamos que la última capa de la red neuronal generó 3 "puntuaciones" (logits)
# Corresponden a "gato", "perro", "pájaro"
logits = np.array([2.0, 1.0, 0.5])
print(f"Salida de la red neuronal (logits): {logits}")
# Convertir a probabilidades con Softmax
probabilities = softmax(logits)
print(f"Distribución de probabilidad: {probabilities}")
print(f"Suma de probabilidades: {np.sum(probabilities)}")
# Interpretación:
# P(gato|imagen de entrada) ≈ 0.67
# P(perro|imagen de entrada) ≈ 0.24
# P(pájaro|imagen de entrada) ≈ 0.09
print("\nProbabilidades de clase: ")
print(f" Gato: {probabilities)
print(f" Perro: {probabilities)
print(f" Pájaro: {probabilities)
Puntos clave de los tres fundamentos matemáticos:
| Rama de las matemáticas | Concepto principal | Uso en el aprendizaje profundo |
|---|---|---|
| Álgebra lineal | Multiplicación de matrices, vectores, tensores | Representar la estructura de la red y calcular eficientemente la propagación hacia adelante |
| Cálculo | Derivadas, regla de la cadena, gradientes | Retropropagación, actualización de parámetros |
| Teoría de la probabilidad | Probabilidad condicional, distribución de probabilidad | Modelar la incertidumbre, diseñar funciones de pérdida |
Propagación hacia adelante
La propagación hacia adelante es el proceso de cálculo en el que los datos de entrada "fluyen" a través de la red neuronal, desde la capa de entrada hasta la capa oculta y luego a la capa de salida.
Grafo de cálculo
Representar la red neuronal como un gráfico computacional hace que la lógica de la propagación hacia adelante y hacia atrás sea más clara.
El gráfico computacional se compone de nodos (operaciones) y aristas (flujo de datos).
Gráfico computacional de una red neuronal simple de dos capas:
Ejemplo
# Comprender la propagación hacia adelante con un gráfico computacional
# ============================================
import numpy as np
def relu(x):
"""Función de activación ReLU: max(0, x)"""
return np.maximum(0, x)
# Una red neuronal simple de dos capas
# Capa de entrada (2) → Capa oculta (3) → Capa de salida (2)
# Entrada: x = [x1, x2]
x = np.array([1.0, 2.0])
print(f"Entrada x: {x}")
# Primera capa: entrada → capa oculta
W1 = np.array([
[0.1, 0.2], # Neurona oculta 1
[0.3, 0.4], # Neurona oculta 2
[0.5, 0.6], # Neurona oculta 3
])
b1 = np.array([0.1, 0.2, 0.3])
# Segunda capa: capa oculta → capa de salida
W2 = np.array([
[0.1, 0.2, 0.3], # Neurona de salida 1
[0.4, 0.5, 0.6], # Neurona de salida 2
])
b2 = np.array([0.1, 0.2])
# Gráfico computacional de propagación hacia adelante
# Paso 1: Transformación lineal de la capa oculta
z1 = W1 @ x + b1
print(f"\n"Transformación lineal de la capa oculta z1: {z1}")
# Paso 2: Función de activación de la capa oculta
a1 = relu(z1)
print(f"Capa oculta después de la activación a1: {a1}")
# Paso 3: Transformación lineal de la capa de salida
z2 = W2 @ a1 + b2
print(f"Transformación lineal de la capa de salida z2: {z2}")
# Paso 4: Activación de la capa de salida (si es un problema de clasificación, normalmente se usa Softmax)
a2 = softmax(z2) # Usar la función softmax definida anteriormente
print(f"Capa de salida después de la activación a2: {a2}")
La ventaja del gráfico computacional es que cada paso es muy claro; durante la retropropagación solo hay que calcular los gradientes en el orden inverso.
Redes neuronales desde la perspectiva de la multiplicación de matrices
Desde el punto de vista de la multiplicación de matrices, una red neuronal es una secuencia de "transformación lineal + activación no lineal".
Cada capa se puede abstraer como:
aᵢ = activation(Wᵢ · aᵢ₋₁ + bᵢ)
donde a₀ es la entrada x.
Esta fórmula es simple pero poderosa: puede representar casi todas las estructuras de redes neuronales, desde la regresión logística simple hasta el complejo Transformer.
Observa la coincidencia de dimensiones en la multiplicación de matrices: si el vector de entrada es de n dimensiones y la siguiente capa tiene m neuronas, entonces la forma de la matriz de pesos W debe ser m×n. Así, después de W·x se obtiene un vector de m dimensiones.
El papel de las funciones de activación
Mencionamos antes: sin funciones de activación, una red profunda equivale a una red de una sola capa.
Demostremos esto con un ejemplo simple:
Ejemplo
# Por qué se necesitan las funciones de activación
# ============================================
import numpy as np
# Supongamos que hay una red de dos capas, pero sin funciones de activación
# Entrada
x = np.array([1.0, 2.0])
# Pesos y sesgos de la primera capa
W1 = np.array([[0.1, 0.2], [0.3, 0.4]])
b1 = np.array([0.1, 0.2])
# Pesos y sesgos de la segunda capa
W2 = np.array([[0.5, 0.6], [0.7, 0.8]])
b2 = np.array([0.3, 0.4])
# Calcular las dos capas por separado
z1 = W1 @ x + b1
z2 = W2 @ z1 + b2
print(f"Resultado del cálculo por separado de las dos capas: {z2}")
# Combinar en una sola capa y calcular
# Matemáticamente se puede demostrar: z2 = (W2·W1)·x + (W2·b1 + b2)
W_combined = W2 @ W1
b_combined = W2 @ b1 + b2
z_combined = W_combined @ x + b_combined
print(f"Resultado del cálculo combinado en una sola capa: {z_combined}")
# ¡El resultado es exactamente el mismo!
print(f"\n"Los dos resultados son iguales: {np.allclose(z2, z_combined)}")
print("Conclusión: sin funciones de activación, una red profunda = red de una sola capa")
La función de activación es ese componente clave que da sentido a las redes profundas: introduce no linealidad y permite que la red aprenda patrones complejos.
Explicación detallada de las funciones de activación
La función de activación determina cómo responde la salida de una neurona a la entrada.
Una buena función de activación debe cumplir varios requisitos:
- No linealidad: es imprescindible
- Diferenciable: así se pueden calcular los gradientes
- Cálculo simple: tanto la propagación hacia adelante como la hacia atrás deben ser rápidas
- No satura: el gradiente no desaparece ni explota
Veamos las funciones de activación más utilizadas.
Sigmoid: saturación y desvanecimiento del gradiente
Sigmoid es una de las funciones de activación utilizadas más tempranamente. Comprime cualquier número real al intervalo (0, 1).
Fórmula: σ(x) = 1 / (1 + e⁻ˣ)
Ejemplo
# Función de activación Sigmoid
# ============================================
import numpy as np
import matplotlib.pyplot as plt
def sigmoid(x):
"""Función Sigmoid"""
return 1 / (1 + np.exp(-x))
def sigmoid_derivative(x):
"""Derivada de Sigmoid: σ'(x) = σ(x) * (1 - σ(x))"""
s = sigmoid(x)
return s * (1 - s)
# Probar algunos valores
x_values = [-10, -5, -2, 0, 2, 5, 10]
print("x | sigmoid(x) | sigmoid'(x)")
print("-" * 40)
for x in x_values:
s = sigmoid(x)
d = sigmoid_derivative(x)
print(f"{x:4} | {s:10.6f} | {d:12.6f}")
# Observación: cuando x es muy grande o muy pequeño, la derivada se acerca a 0
# Este es el problema de la "desaparición del gradiente"
x_big = 10.0
print(f"\n"Cuando x = {x_big}:")
print(f" sigmoid(x) = {sigmoid(x_big):.10f} (casi 1)")
print(f" sigmoid'(x) = {sigmoid_derivative(x_big):.10f} (casi 0)")
print("¡El gradiente desapareció! Durante la retropropagación, el gradiente no puede propagarse.")
Los problemas de Sigmoid son evidentes:
- Cuando |x| > 6, el gradiente es casi 0: desaparición del gradiente
- La salida no está centrada en 0, lo que afecta la optimización
- Calcular la exponencial es relativamente lento
Debido a estos problemas, Sigmoid rara vez se usa en capas ocultas en el aprendizaje profundo moderno.
ReLU y sus variantes
ReLU (Unidad Lineal Rectificada) es actualmente la función de activación más utilizada.
Fórmula: ReLU(x) = max(0, x)
Simple pero muy efectiva.
Ejemplo
# ReLU y sus variantes
# ============================================
import numpy as np
def relu(x):
"""ReLU:max(0, x)"""
return np.maximum(0, x)
def relu_derivative(x):
"""Derivada de ReLU: 1 cuando x > 0, de lo contrario 0"""
return (x > 0).astype(float)
def leaky_relu(x, alpha=0.01):
"""Leaky ReLU: x cuando x > 0, de lo contrario alpha*x"""
return np.where(x > 0, x, alpha * x)
def gelu(x):
"""GELU:Gaussian Error Linear Unit
Esta es una función de activación comúnmente utilizada en Transformer
Fórmula aproximada: 0.5 * x * (1 + tanh(sqrt(2/pi) * (x + 0.044715*x^3)))
"""
cdf = 0.5 * (1.0 + np.tanh(np.sqrt(2 / np.pi) * (x + 0.044715 * np.power(x, 3))))
return x * cdf
# Prueba
x_values = [-5, -2, -1, 0, 1, 2, 5]
print("x | ReLU | Leaky | GELU")
print("-" * 45)
for x in x_values:
r = relu(x)
lr = leaky_relu(x)
g = gelu(x)
print(f"{x:4} | {r:4.2f} | {lr:5.2f} | {g:5.2f}")
# Ventajas de ReLU:
print("\n"Ventajas de ReLU:")
print("1. Cálculo simple: solo requiere la operación max")
print("2. No satura (intervalo positivo): el gradiente siempre es 1")
print("3. Dispersión: algunas neuronas generan 0, lo que aumenta la robustez del modelo")
# Problema de ReLU: "ReLU muerta"
print("\n"Problema de ReLU: ReLU muerta")
print("Si la entrada de una neurona siempre es negativa, entonces:")
print(- La salida siempre es 0)
print(- El gradiente siempre es 0)
print(- Los parámetros nunca se actualizan)
print(- Esta neurona está 'muerta')
Comparación de varias funciones de activación comunes:
| Función de activación | Fórmula | Ventajas | Desventajas | Escenarios de aplicación |
|---|---|---|---|---|
| Sigmoid | 1/(1+e⁻ˣ) | Salida en (0,1), interpretable como probabilidad | Desaparición del gradiente, no centrada en cero, cálculo lento | Capa de salida de clasificación binaria (rara vez usada en capas ocultas) |
| ReLU | max(0, x) | Cálculo rápido, no satura, dispersión | ReLU muerta, salida no centrada en cero | La mayoría de las capas ocultas (opción predeterminada) |
| Leaky ReLU | max(αx, x) | Resuelve el problema de ReLU muerta | α debe seleccionarse manualmente | Alternativa cuando ReLU falla |
| GELU | x·Φ(x) | Suave, buen rendimiento en Transformers | Cálculo ligeramente más complejo | Transformer, BERT, GPT, etc. |
| Swish/SwiGLU | x·σ(βx) | Superior a ReLU en redes profundas | Cálculo complejo | Modelos grandes como PaLM, LLaMA, etc. |
Principios de selección
Recomendaciones para elegir funciones de activación:
- Capas ocultas: prioriza ReLU, simple y efectivo
- Si encuentras ReLU muerta: prueba Leaky ReLU o GELU
- Transformer: GELU o SwiGLU son la opción estándar
- Capa de salida de clasificación binaria: Sigmoid (salida de probabilidad)
- Capa de salida de clasificación múltiple: Softmax (distribución de probabilidad de salida)
- Capa de salida de regresión: sin activación (salida de cualquier número real)
No te preocupes demasiado por la elección de la función de activación. Normalmente ReLU es suficiente. Cuando confirmes que ReLU es el cuello de botella, no es tarde para cambiar a otra.
Función de pérdida
La función de pérdida mide la diferencia entre la 'predicción del modelo' y la 'respuesta real'.
El objetivo del entrenamiento es hacer que esta diferencia sea lo más pequeña posible.
Pérdida de entropía cruzada (clasificación)
La función de pérdida más utilizada en problemas de clasificación es la pérdida de entropía cruzada (Cross-Entropy Loss).
Su significado intuitivo es: 'cuánta diferencia hay entre la distribución de probabilidad predicha por el modelo y la distribución real'.
Ejemplo
# Pérdida de entropía cruzada
# ============================================
import numpy as np
def cross_entropy_loss(probs, target_one_hot):
"""Pérdida de entropía cruzada
probs: distribución de probabilidad predicha por el modelo, forma (batch_size, num_classes)
target_one_hot: codificación one-hot de las etiquetas reales, forma igual que arriba
"""
# Añadir epsilon para evitar log(0)
epsilon = 1e-12
probs = np.clip(probs, epsilon, 1.0 - epsilon)
# Entropía cruzada = -sum(target * log(pred))
return -np.sum(target_one_hot * np.log(probs)) / len(probs)
def softmax(x):
"""Función Softmax"""
exp_x = np.exp(x - np.max(x, axis=-1, keepdims=True))
return exp_x / np.sum(exp_x, axis=-1, keepdims=True)
# Ejemplo: problema de clasificación triple, las clases son 'gato', 'perro', 'pájaro'
# Supongamos que la etiqueta real es 'gato' (índice 0), codificación one-hot
target = np.array([[1, 0, 0]]) # La respuesta correcta es la clase 0
# Escenario 1: el modelo predice 'gato' con gran confianza
logits1 = np.array([[5.0, 0.5, 0.1]]) # La puntuación de 'gato' es la más alta
probs1 = softmax(logits1)
loss1 = cross_entropy_loss(probs1, target)
print(Escenario 1: el modelo predice correctamente con confianza)
print(fDistribución de probabilidad: {probs1)
print(fPérdida: {loss1:.6f} (muy pequeño))
# Escenario 2: el modelo está inseguro
logits2 = np.array([[1.0, 0.9, 0.8]]) # Las puntuaciones de las tres clases son similares
probs2 = softmax(logits2)
loss2 = cross_entropy_loss(probs2, target)
print("\nEscenario 2: el modelo está inseguro)
print(fDistribución de probabilidad: {probs2)
print(fPérdida: {loss2:.6f} (medio))
# Escenario 3: el modelo predice incorrectamente
logits3 = np.array([[0.1, 5.0, 0.5]]) # La puntuación de 'perro' es la más alta
probs3 = softmax(logits3)
loss3 = cross_entropy_loss(probs3, target)
print("\nEscenario 3: el modelo predice incorrectamente)
print(fDistribución de probabilidad: {probs3)
print(fPérdida: {loss3:.6f} (muy grande))
Una versión simplificada de la pérdida de entropía cruzada: cuando las etiquetas son índices de clase en lugar de codificación one-hot, se puede usar un método de cálculo más eficiente:
loss = -log(probs[target_class])
Por eso en PyTorch, CrossEntropyLoss acepta directamente índices de clase como etiquetas.
Pérdida MSE (regresión)
El error cuadrático medio (Mean Squared Error, MSE) es la pérdida más utilizada en problemas de regresión (predicción de valores continuos).
Fórmula: MSE = mean((pred - target)²)
Ejemplo
# Pérdida MSE
# ============================================
import numpy as np
def mse_loss(pred, target):
"""Pérdida del error cuadrático medio"""
return np.mean((pred - target) ** 2)
def mae_loss(pred, target):
"""Pérdida del error absoluto medio"""
return np.mean(np.abs(pred - target))
# Ejemplo: predicción del precio de la vivienda (unidad: diez mil yuanes)
# Precio real de la vivienda
target = np.array([100, 200, 150])
# Escenario 1: predicción muy precisa
pred1 = np.array([102, 195, 148])
mse1 = mse_loss(pred1, target)
mae1 = mae_loss(pred1, target)
print(Escenario 1: predicción precisa)
print(fPredicción: {pred1})
print(fReal: {target})
print(f" MSE: {mse1:.2f}")
print(f" MAE: {mae1:.2f}")
# Escenario 2: la predicción tiene un error grande
pred2 = np.array([102, 280, 148]) # El precio del segundo piso se predijo 800 000 más alto
mse2 = mse_loss(pred2, target)
mae2 = mae_loss(pred2, target)
print("\nEscenario 2: hay un error grande)
print(fPredicción: {pred2})
print(fReal: {target})
print(fMSE: {mse2:.2f} (amplificado en gran medida por el error grande))
print(f" MAE: {mae2:.2f}")
# MSE vs MAE
print("\nMSE vs MAE:")
print(- MSE es más sensible a errores grandes (debido al cuadrado))
print(- MAE es más robusto ante valores atípicos)
print(- En general, se usa más MSE)
Pérdida de predicción de la siguiente palabra en modelos de lenguaje
El objetivo de entrenamiento de los grandes modelos de lenguaje (como GPT) es simple: dado el contexto anterior, predecir la siguiente palabra.
Esto es esencialmente un problema de clasificación múltiple: cada palabra del vocabulario es una clase.
Ejemplo
# Función de pérdida del modelo de lenguaje
# ============================================
import numpy as np
def language_model_loss(logits, targets, vocab_size):
"""Pérdida del modelo de lenguaje
Esencialmente es la pérdida de entropía cruzada para cada posición
"""
batch_size, seq_len, _ = logits.shape
total_loss = 0.0
# Calcular la pérdida para cada posición de la secuencia
for i in range(seq_len):
# Puntuación predicha en esta posición
step_logits = logits[:, i, :]
# Convertir a probabilidad
step_probs = softmax(step_logits)
# Índice de la palabra objetivo
step_target = targets[:, i]
# Tomar solo la probabilidad de la palabra objetivo
for b in range(batch_size):
target_idx = step_target[b]
target_prob = step_probs[b, target_idx]
# Pérdida = -log(p)
total_loss += -np.log(target_prob + 1e-12)
return total_loss / (batch_size * seq_len)
# Un ejemplo simple
vocab_size = 10000 # Supongamos que el tamaño del vocabulario es 10,000
batch_size = 2
seq_len = 3
# Salida del modelo: (batch_size, seq_len, vocab_size)
logits = np.random.randn(batch_size, seq_len, vocab_size)
# Objetivo real: el índice de la palabra que se debe predecir en cada posición
targets = np.array([
[123, 456, 789], # Palabra objetivo de la primera secuencia
[234, 567, 890], # Palabra objetivo de la segunda secuencia
])
loss = language_model_loss(logits, targets, vocab_size)
print(fPérdida del modelo de lenguaje: {loss:.4f})
print("\nEl significado de esta pérdida:)
print(- Para cada posición en la secuencia)
print(- Predecir la siguiente palabra)
print(- Calcular la entropía cruzada promedio de todas las posiciones)
Resumen de funciones de pérdida comunes:
| Tipo de tarea | Función de pérdida | Activación de la capa de salida | Formato de etiqueta |
|---|---|---|---|
| Clasificación binaria | Binary Cross-Entropy | Sigmoid | 0 o 1 |
| Clasificación múltiple | Cross-Entropy | Softmax | Índice de clase |
| Clasificación de múltiples etiquetas | Binary Cross-Entropy | Sigmoid | Múltiples 0/1 |
| Regresión | MSE / MAE | Ninguno | Valor continuo |
| Modelo de lenguaje | Cross-Entropy | Softmax | Índice de palabra |
Retropropagación
La retropropagación (Backpropagation) es el algoritmo central para entrenar redes neuronales.
Su objetivo es: calcular el gradiente de la función de pérdida con respecto a cada parámetro y luego actualizar los parámetros mediante el descenso de gradiente.
Derivación mediante la regla de la cadena
Usemos un ejemplo simple para derivar la retropropagación paso a paso.
Ejemplo
# Implementación manual de la retropropagación
# ============================================
import numpy as np
def relu(x):
return np.maximum(0, x)
def relu_derivative(x):
return (x > 0).astype(float)
def mse_loss(pred, target):
return np.mean((pred - target) ** 2)
# Una red neuronal muy simple: una capa de transformación lineal
# y = w * x + b
# Pérdida = (y - target)^2
# Entrada y objetivo
x = np.array(2.0)
target = np.array(7.0)
# Inicializar parámetros
w = np.array(1.0)
b = np.array(0.0)
print(fParámetros iniciales: w = {w}, b = {b})
print(fObjetivo: {target})
# ============================================
# Primer paso: propagación hacia adelante
# ============================================
y = w * x + b
loss = (y - target) ** 2
print(f"\nPropagación hacia adelante:)
print(f" y = {y}")
print(f" loss = {loss}")
# ============================================
# Segundo paso: retropropagación (cálculo manual de gradientes)
# ============================================
# Necesitamos calcular: d_loss/d_w, d_loss/d_b
# Usemos la regla de la cadena paso a paso:
# loss = (y - target)^2
# d_loss/d_y = 2 * (y - target)
d_loss_d_y = 2 * (y - target)
# y = w * x + b
# d_y/d_w = x
# d_y/d_b = 1
d_y_d_w = x
d_y_d_b = 1.0
# Regla de la cadena:
# d_loss/d_w = d_loss/d_y * d_y/d_w
# d_loss/d_b = d_loss/d_y * d_y/d_b
d_loss_d_w = d_loss_d_y * d_y_d_w
d_loss_d_b = d_loss_d_y * d_y_d_b
print(f"\nRetropropagación:)
print(f" d_loss/d_y = {d_loss_d_y}")
print(f" d_loss/d_w = {d_loss_d_w}")
print(f" d_loss/d_b = {d_loss_d_b}")
# ============================================
# Tercer paso: actualizar parámetros con descenso de gradiente
# ============================================
learning_rate = 0.1
w_new = w - learning_rate * d_loss_d_w
b_new = b - learning_rate * d_loss_d_b
print(f"\nActualización de parámetros:)
print(f" w: {w} -> {w_new}")
print(f" b: {b} -> {b_new}")
# Verificación: propagación hacia adelante con los nuevos parámetros
y_new = w_new * x + b_new
loss_new = (y_new - target) ** 2
print(f"\nVerificación:)
print(f"Predicción anterior: {y}, pérdida anterior: {loss}")
print(f"Nueva predicción: {y_new}, nueva pérdida: {loss_new}")
print(f"¡La pérdida disminuyó!")
Aunque este ejemplo es simple, contiene todas las ideas centrales de la retropropagación:
- Propagación hacia adelante: calcular todas las variables intermedias
- Retropropagación: comenzando desde la salida, calcular los gradientes capa por capa usando la regla de la cadena
- Actualización de parámetros: actualizar los parámetros con descenso de gradiente
Flujo del gradiente en el grafo de cálculo
Para redes neuronales multicapa, los gradientes fluyen hacia atrás en el grafo computacional.
El gradiente de cada capa depende del gradiente transmitido por la capa posterior.
Ejemplo
# Retropropagación para una red de dos capas
# ============================================
import numpy as np
def relu(x):
return np.maximum(0, x)
def relu_derivative(x):
return (x > 0).astype(float)
# Una red de dos capas
# Entrada (2) → Capa oculta (3) → Salida (1)
# Datos
x = np.array([1.0, 2.0]) # Entrada
target = np.array([5.0]) # Salida objetivo
# Inicialización de parámetros
W1 = np.array([[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]]) # (3, 2)
b1 = np.array([0.1, 0.2, 0.3]) # (3,)
W2 = np.array([[0.7, 0.8, 0.9]]) # (1, 3)
b2 = np.array([0.4]) # (1,)
print("Parámetros iniciales establecidos")
# ============================================
# Propagación hacia adelante
# ============================================
print("\n"=== Propagación hacia adelante ===")
# Primera capa
z1 = W1 @ x + b1 # (3,)
a1 = relu(z1) # (3,)
print(f"z1 = {z1}")
print(f"a1 = {a1}")
# Segunda capa
z2 = W2 @ a1 + b2 # (1,)
a2 = z2 # Tarea de regresión, la capa de salida no usa activación
print(f"z2 = {z2}")
print(f"a2 = {a2}")
# Pérdida
loss = np.mean((a2 - target) ** 2)
print(f"loss = {loss}")
# ============================================
# Retropropagación
# ============================================
print("\n"=== Retropropagación ===")
# Gradiente de la capa de salida
d_loss_d_a2 = 2 * (a2 - target) # (1,)
d_a2_d_z2 = 1.0 # Sin función de activación
d_loss_d_z2 = d_loss_d_a2 * d_a2_d_z2 # (1,)
print(f"d_loss_d_z2 = {d_loss_d_z2}")
# Gradiente de los parámetros de la segunda capa
d_z2_d_W2 = a1 # (3,), porque z2 = W2@a1 + b2
d_z2_d_b2 = 1.0 # (1,)
d_z2_d_a1 = W2[0] # (3,)
# Calcular el gradiente de los parámetros
d_loss_d_W2 = d_loss_d_z2.reshape(-1, 1) @ d_z2_d_W2.reshape(1, -1) # (1, 3)
d_loss_d_b2 = d_loss_d_z2 * d_z2_d_b2 # (1,)
print(f"d_loss_d_W2 = {d_loss_d_W2}")
print(f"d_loss_d_b2 = {d_loss_d_b2}")
# Gradiente transmitido a la capa oculta
d_loss_d_a1 = d_loss_d_z2 @ d_z2_d_a1 # (3,)
print(f"d_loss_d_a1 = {d_loss_d_a1}")
# Gradiente de la capa oculta
d_a1_d_z1 = relu_derivative(z1) # (3,)
d_loss_d_z1 = d_loss_d_a1 * d_a1_d_z1 # (3,)
print(f"d_loss_d_z1 = {d_loss_d_z1}")
# Gradiente de los parámetros de la primera capa
d_z1_d_W1 = x # (2,)
d_z1_d_b1 = 1.0 # (3,)
# Calcular el gradiente de los parámetros
d_loss_d_W1 = d_loss_d_z1.reshape(-1, 1) @ d_z1_d_W1.reshape(1, -1) # (3, 2)
d_loss_d_b1 = d_loss_d_z1 * d_z1_d_b1 # (3,)
print(f"d_loss_d_W1 = {d_loss_d_W1}")
print(f"d_loss_d_b1 = {d_loss_d_b1}")
# ============================================
# Actualización de parámetros
# ============================================
print("\n"=== Actualización de parámetros ===")
learning_rate = 0.01
W1_new = W1 - learning_rate * d_loss_d_W1
b1_new = b1 - learning_rate * d_loss_d_b1
W2_new = W2 - learning_rate * d_loss_d_W2
b2_new = b2 - learning_rate * d_loss_d_b2
print("Parámetros actualizados")
Este ejemplo muestra el flujo completo de la retropropagación.
La regla central es:
- Para una capa lineal y = Wx + b: dL/dW = (dL/dy)·xᵀ, dL/db = dL/dy
- Para una función de activación y = f(z): dL/dz = dL/dy ⊙ f'(z) (⊙ es multiplicación elemento a elemento)
- Los gradientes se transmiten de atrás hacia adelante, cada capa «acumula» gradientes usando la regla de la cadena
La buena noticia es: los marcos modernos de aprendizaje profundo (como PyTorch, TensorFlow) calculan automáticamente la retropropagación por ti. Solo necesitas definir la propagación hacia adelante, y el marco se encargará de todos los cálculos de gradientes mediante diferenciación automática (autograd).
Optimizadores de descenso de gradiente
Después de calcular los gradientes, necesitamos usar un optimizador para actualizar los parámetros.
El optimizador más simple es el descenso de gradiente estocástico (SGD), pero hay muchos optimizadores más avanzados.
SGD: descenso de gradiente estocástico
Fórmula: θ = θ - η·∇L(θ)
donde η es la tasa de aprendizaje.
Ejemplo
# Optimizador SGD
# ============================================
import numpy as np
def f(x):
"""La función que queremos minimizar: f(x) = x²"""
return x ** 2
def f_grad(x):
"""Gradiente: f'(x) = 2x"""
return 2 * x
# Optimización con SGD
x = 10.0 # Valor inicial
learning_rate = 0.1 # Tasa de aprendizaje
steps = 20
print(f"x inicial = {x}, f(x) = {f(x)}")
print("-" * 40)
for step in range(steps):
grad = f_grad(x)
x = x - learning_rate * grad # Actualización con SGD
print(f"Step {step+1}: x = {x:.6f}, f(x) = {f(x):.6f}")
print("-" * 40)
print(f"x final = {x:.6f}, f(x) = {f(x):.6f}")
print("Óptimo teórico: x = 0, f(x) = 0")
SGD es simple pero tiene desventajas:
- Usa la misma tasa de aprendizaje para todos los parámetros
- La convergencia puede ser muy lenta
- Es fácil quedarse atascado en óptimos locales o puntos de silla
- Es sensible al ruido
Momentum: aceleración por momento
Momentum introduce el concepto de «velocidad»: hace que la actualización del gradiente tenga inercia.
Fórmula:
v = β·v + (1-β)·∇L(θ) θ = θ - η·v
β normalmente se establece en 0,9.
Ejemplo
# Optimizador Momentum
# ============================================
import numpy as np
def f(x):
"""Función objetivo"""
return x ** 2
def f_grad(x):
"""Gradiente"""
return 2 * x
def sgd_optimize(x_init, learning_rate, steps):
"""SGD puro"""
x = x_init
history = [x]
for _ in range(steps):
grad = f_grad(x)
x = x - learning_rate * grad
history.append(x)
return np.array(history)
def momentum_optimize(x_init, learning_rate, beta, steps):
"""Momentum"""
x = x_init
v = 0.0 # Velocidad inicial
history = [x]
for _ in range(steps):
grad = f_grad(x)
v = beta * v + (1 - beta) * grad
x = x - learning_rate * v
history.append(x)
return np.array(history)
# Comparación
x_init = 10.0
learning_rate = 0.1
beta = 0.9
steps = 20
sgd_history = sgd_optimize(x_init, learning_rate, steps)
momentum_history = momentum_optimize(x_init, learning_rate, beta, steps)
print("Step | SGD | Momentum")
print("-" * 35)
for i in range(steps + 1):
print(f"{i:4} | {sgd_history[i]:9.6f} | {momentum_history[i]:9.6f}")
print("\nObservación: ¡Momentum converge más rápido!")
print("Porque acumula los gradientes pasados y tiene inercia.")
Adam: tasa de aprendizaje adaptativa
Adam (Adaptive Moment Estimation) es uno de los optimizadores más utilizados actualmente.
Combina las ideas de Momentum (momento de primer orden) y RMSProp (momento de segundo orden), y mantiene una tasa de aprendizaje adaptativa para cada parámetro.
Fórmula:
m = β₁·m + (1-β₁)·∇L(θ) v = β₂·v + (1-β₂)·(∇L(θ))² m̂ = m / (1-β₁ᵗ) v̂ = v / (1-β₂ᵗ) θ = θ - η·m̂ / (√v̂ + ε)
Valores predeterminados de los hiperparámetros: β₁=0.9, β₂=0.999, ε=1e-8, η=1e-3.
Ejemplo
# Optimizador Adam
# ============================================
import numpy as np
def adam_optimize(x_init, learning_rate, beta1, beta2, epsilon, steps):
"""Optimización con Adam"""
x = x_init
m = 0.0 # Momento de primer orden
v = 0.0 # Momento de segundo orden
history = [x]
for t in range(1, steps + 1):
grad = 2 * x # Gradiente de f(x) = x²
# Actualizar el momento de primer y segundo orden
m = beta1 * m + (1 - beta1) * grad
v = beta2 * v + (1 - beta2) * (grad ** 2)
# Corrección de sesgo
m_hat = m / (1 - beta1 ** t)
v_hat = v / (1 - beta2 ** t)
# Actualización de parámetros
x = x - learning_rate * m_hat / (np.sqrt(v_hat) + epsilon)
history.append(x)
return np.array(history)
# Parámetro
x_init = 10.0
learning_rate = 0.5
beta1 = 0.9
beta2 = 0.999
epsilon = 1e-8
steps = 20
# Optimización
history = adam_optimize(x_init, learning_rate, beta1, beta2, epsilon, steps)
print("Proceso de optimización con Adam:")
print("Step | x | f(x)")
print("-" * 35)
for i in range(steps + 1):
print(f"{i:4} | {history[i]:9.6f} | {history[i]**2:9.6f}")
AdamW: corrección de la decadencia de pesos
AdamW es una versión mejorada de Adam: separa el decaimiento de pesos (Weight Decay) del gradiente.
Esto es útil para la regularización.
Comparación de optimizadores:
| Optimizador | Fórmula | Ventajas | Desventajas | Escenarios adecuados |
|---|---|---|---|---|
| SGD | θ = θ - η·∇L | Simple y estable | Convergencia lenta, sensible a la tasa de aprendizaje | Grandes volúmenes de datos, experiencia en ajuste de hiperparámetros |
| SGD + Momentum | v = βv + (1-β)∇L θ = θ - ηv | Acelera la convergencia, reduce las oscilaciones | Requiere ajustar β | Generalmente mejor que SGD puro |
| Adam | Tasa de aprendizaje adaptativa | Converge rápido, requiere menos ajuste de hiperparámetros | Puede generalizar peor que SGD | La mayoría de los escenarios (opción predeterminada) |
| AdamW | Adam + decaimiento de pesos | Mejor regularización | Requiere ajustar el coeficiente de decaimiento de pesos | Transformers, modelos grandes |
Recomendación para elegir optimizador: pruebe primero Adam, con una tasa de aprendizaje de 1e-3 (3e-4 es una opción más segura). Si el resultado no es satisfactorio, intente ajustar los hiperparámetros o cambie a otro optimizador.
Técnicas de regularización
El objetivo de la regularización es prevenir el sobreajuste: permitir que el modelo funcione bien también en datos no vistos.
Regularización L1/L2
La regularización L1 y L2 son los métodos de regularización más básicos.
Añaden una penalización sobre los parámetros a la función de pérdida:
L_total = L + λ·L_reg
Regularización L1 (Lasso): L_reg = |w|
Regularización L2 (Ridge): L_reg = w²
Ejemplo
# Regularización L1/L2
# ============================================
import numpy as np
def l1_regularization(weights, lambda_l1):
"""Regularización L1: devuelve el término de regularización y el gradiente"""
reg = lambda_l1 * np.sum(np.abs(weights))
grad = lambda_l1 * np.sign(weights)
return reg, grad
def l2_regularization(weights, lambda_l2):
"""Regularización L2: devuelve el término de regularización y el gradiente"""
reg = 0.5 * lambda_l2 * np.sum(weights ** 2)
grad = lambda_l2 * weights
return reg, grad
# Ejemplo
weights = np.array([1.0, -2.0, 3.0, 0.5])
print(f"Pesos: {weights}")
lambda_l1 = 0.1
lambda_l2 = 0.1
l1_reg, l1_grad = l1_regularization(weights, lambda_l1)
print(f"\n"Término de regularización L1: {l1_reg:.4f}")
print(f"Gradiente L1: {l1_grad}")
l2_reg, l2_grad = l2_regularization(weights, lambda_l2)
print(f"\n"Término de regularización L2: {l2_reg:.4f}")
print(f"Gradiente L2: {l2_grad}")
print("\nL1 vs L2:")
print(- L1: tiende a producir soluciones dispersas (muchos parámetros se vuelven 0), se puede usar para selección de características)
print(- L2: tiende a hacer que todos los parámetros sean muy pequeños, pero no llegan a 0)
print(- En el aprendizaje profundo, L2 es más común (o el decaimiento de pesos de AdamW))
Dropout
Dropout es una técnica de regularización simple pero efectiva: durante el entrenamiento, «apaga» aleatoriamente una parte de las neuronas.
Esto obliga al modelo a aprender características más robustas: no puede depender de ninguna neurona específica.
Ejemplo
# Dropout
# ============================================
import numpy as np
def dropout_forward(x, dropout_rate, training=True):
"""Propagación hacia adelante con Dropout"""
if not training:
# En pruebas no se aplica dropout, se devuelve directamente
return x
# Generar máscara: seleccionar aleatoriamente una parte de las neuronas y ponerlas a 0
mask = (np.random.rand(*x.shape) > dropout_rate).astype(float)
# Escalado: en pruebas no se necesita escalar, por lo que en entrenamiento se divide entre (1 - dropout_rate)
scale = 1.0 / (1.0 - dropout_rate)
# Aplicar dropout
out = x * mask * scale
return out, mask
# Prueba
x = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
dropout_rate = 0.4
print(f"Entrada x: {x}")
print(f"Tasa de dropout: {dropout_rate}")
# Ejecutar varias veces para observar la aleatoriedad
print("\n"Resultados de Dropout múltiple:")
for i in range(3):
out, mask = dropout_forward(x, dropout_rate, training=True)
print(fIteración {i+1}: mask={mask}, out={out})
# Modo de prueba
out_test = dropout_forward(x, dropout_rate, training=False)
print(f"\nModo de prueba (sin Dropout): {out_test}")
Aumento de datos
El aumento de datos es uno de los métodos de regularización más efectivos: crea más muestras de entrenamiento transformando los datos existentes.
Para imágenes, los aumentos de datos comunes incluyen: volteo, rotación, escalado, recorte, cambio de color, etc.
Para texto, los aumentos de datos comunes incluyen: reemplazo de sinónimos, inserción aleatoria, eliminación aleatoria, retro-traducción, etc.
Resumen de técnicas de regularización:
| Método | Principio | Ventajas | Desventajas |
|---|---|---|---|
| Regularización L2 | Penaliza pesos grandes | Simple, estable | Efecto limitado |
| Dropout | Apagar neuronas aleatoriamente | Simple y efectivo | Ralentiza el entrenamiento |
| Aumento de datos | Aumenta la diversidad de los datos de entrenamiento | Efecto notable | Requiere conocimiento del dominio |
| Parada temprana (Early Stopping) | Detenerse si el conjunto de validación no mejora | Simple y efectivo | Requiere monitorear el conjunto de validación |
| Normalización por lotes | Normaliza la entrada de la capa | Acelera el entrenamiento, regulariza | Aumenta el costo computacional |
Normalización por lotes vs. normalización por capas
Las técnicas de normalización pueden acelerar el entrenamiento y hacer que la optimización sea más estable.
Desplazamiento covariado interno
Al entrenar redes profundas, la distribución de entrada de cada capa cambia según cambian los parámetros de la capa anterior. Esto se conoce como "desplazamiento covariado interno" (Internal Covariate Shift).
Esto hace que el entrenamiento sea más lento porque cada capa debe adaptarse constantemente a la nueva distribución.
El objetivo de las técnicas de normalización es mantener estable la distribución de entrada de cada capa.
Normalización por lotes (Batch Normalization)
La normalización por lotes (BatchNorm) normaliza a lo largo de la dimensión del batch.
Fórmula:
μ = 1/N · sum(xᵢ) σ² = 1/N · sum((xᵢ - μ)²) x̂ᵢ = (xᵢ - μ) / √(σ² + ε) yᵢ = γ·x̂ᵢ + β
Donde γ y β son parámetros aprendibles.
Ejemplo
# Normalización por lotes
# ============================================
import numpy as np
def batch_norm(x, gamma, beta, epsilon=1e-5, training=True, running_mean=None, running_var=None, momentum=0.1):
"""Normalización por lotes
x: entrada, forma (batch_size, features)
gamma: parámetro de escala
beta: parámetro de desplazamiento
"""
if training:
# Modo entrenamiento: usar estadísticas del batch actual
batch_mean = np.mean(x, axis=0)
batch_var = np.var(x, axis=0)
# Actualizar estadísticas móviles (running) (para uso en prueba)
if running_mean is not None and running_var is not None:
running_mean[:] = momentum * running_mean + (1 - momentum) * batch_mean
running_var[:] = momentum * running_var + (1 - momentum) * batch_var
# Normalizar
x_normalized = (x - batch_mean) / np.sqrt(batch_var + epsilon)
else:
# Modo prueba: usar estadísticas running
x_normalized = (x - running_mean) / np.sqrt(running_var + epsilon)
# Escalar y desplazar
out = gamma * x_normalized + beta
return out
# Prueba
batch_size = 4
features = 3
# Datos de un batch
x = np.array([
[1.0, 2.0, 3.0],
[2.0, 3.0, 4.0],
[3.0, 4.0, 5.0],
[4.0, 5.0, 6.0],
])
print(f"Entrada x:\n{x}")
# Parámetros iniciales
gamma = np.ones(features) # Inicialmente sin escala
beta = np.zeros(features) # Inicialmente sin desplazamiento
print(f"\ngamma: {gamma}")
print(f"beta: {beta}")
# Aplicar normalización por lotes
out = batch_norm(x, gamma, beta, training=True)
print(f"\nDespués de la normalización por lotes:\n{out}")
# Verificar: la media de cada característica debería estar cerca de 0, la varianza cerca de 1
print(f"\nMedia después de la normalización: {np.mean(out, axis=0)}")
print(f"Varianza después de la normalización: {np.var(out, axis=0)}")
Ventajas de la normalización por lotes:
- Acelera el entrenamiento
- Permite tasas de aprendizaje más altas
- Reduce la sensibilidad a la inicialización
- Tiene un ligero efecto de regularización
Desventajas de la normalización por lotes:
- Depende del tamaño del batch: si el batch es demasiado pequeño, el efecto no es bueno.
- El comportamiento difiere entre entrenamiento y prueba
- No es adecuada para modelos secuenciales (como RNN, Transformer)
Normalización por capas (Layer Normalization)
La normalización de capa (LayerNorm) normaliza a lo largo de la dimensión de características, no a lo largo de la dimensión del batch.
Esto hace que no dependa del tamaño del batch, por lo que es muy adecuada para modelos secuenciales.
Ejemplo
# Normalización de capa
# ============================================
import numpy as np
def layer_norm(x, gamma, beta, epsilon=1e-5):
"""Normalización de capa
x: entrada, forma (batch_size, features) o (batch_size, seq_len, features)
"""
# Calcular la media y la varianza en la última dimensión (dimensión de características)
mean = np.mean(x, axis=-1, keepdims=True)
var = np.var(x, axis=-1, keepdims=True)
# Normalizar
x_normalized = (x - mean) / np.sqrt(var + epsilon)
# Escalar y desplazar
out = gamma * x_normalized + beta
return out
# Prueba
batch_size = 2
seq_len = 3
features = 4
# Simular entrada en Transformer: (batch_size, seq_len, features)
x = np.array([
[[1.0, 2.0, 3.0, 4.0],
[2.0, 3.0, 4.0, 5.0],
[3.0, 4.0, 5.0, 6.0]],
[[4.0, 5.0, 6.0, 7.0],
[5.0, 6.0, 7.0, 8.0],
[6.0, 7.0, 8.0, 9.0]],
])
print(f"Forma de la entrada x: {x.shape}")
# Parámetros iniciales
gamma = np.ones(features)
beta = np.zeros(features)
# Aplicar normalización de capa
out = layer_norm(x, gamma, beta)
print(f"Forma después de la normalización de capa: {out.shape}")
# Verificar: la media de cada posición está cerca de 0, la varianza cerca de 1
mean_check = np.mean(out, axis=-1)
var_check = np.var(out, axis=-1)
print(f"\nMedia de cada posición:\n{mean_check}")
print(f"Varianza de cada posición:\n{var_check}")
Comparación de los dos métodos de normalización:
| Método | Dimensión de normalización | Depende del batch | Escenarios de aplicación | Modelos representativos |
|---|---|---|---|---|
| BatchNorm | Dimensión del batch | Sí | CNN, tamaño de batch fijo | ResNet、VGG |
| LayerNorm | Dimensión de características | no | Transformer, RNN, secuencias de longitud variable | GPT、BERT、LLaMA |
Para Transformer y modelos de lenguaje grandes, LayerNorm es la opción estándar. Calcula estadísticas de forma independiente en cada posición, no depende del tamaño del batch y no requiere mantener estadísticas running.
Programación de la tasa de aprendizaje
La tasa de aprendizaje es uno de los hiperparámetros más importantes: si es demasiado grande, no converge; si es demasiado pequeña, converge demasiado lento.
La programación de la tasa de aprendizaje (Learning Rate Scheduling) consiste en ajustar dinámicamente la tasa de aprendizaje durante el entrenamiento.
Calentamiento (Warmup)
Al inicio del entrenamiento, se usa primero una tasa de aprendizaje pequeña como "calentamiento" para estabilizar el modelo, y luego se aumenta hasta la tasa de aprendizaje objetivo.
Esto es especialmente importante para Transformer.
Recocido por coseno
El recocido por coseno (Cosine Annealing) hace que la tasa de aprendizaje disminuya gradualmente siguiendo la forma de una función coseno.
Ejemplo
# Programación de la tasa de aprendizaje
# ============================================
import numpy as np
def warmup_schedule(step, warmup_steps, base_lr):
"""Programación de calentamiento lineal"""
if step < warmup_steps:
return base_lr * (step + 1) / warmup_steps
return base_lr
def cosine_annealing_schedule(step, total_steps, base_lr, min_lr=0.0):
"""Programación de recocido por coseno"""
return min_lr + 0.5 * (base_lr - min_lr) * (1 + np.cos(np.pi * step / total_steps))
def warmup_cosine_schedule(step, warmup_steps, total_steps, base_lr, min_lr=0.0):
"""Calentamiento + recocido por coseno"""
if step < warmup_steps:
return base_lr * (step + 1) / warmup_steps
return min_lr + 0.5 * (base_lr - min_lr) * (1 + np.cos(np.pi * (step - warmup_steps) / (total_steps - warmup_steps)))
# Parámetros
total_steps = 100
warmup_steps = 10
base_lr = 1e-3
min_lr = 1e-5
print("Diferentes programaciones de tasa de aprendizaje:")
print("Step | Warmup | Cosine | Warmup+Cosine")
print("-" * 55)
for step in range(0, total_steps + 1, 10):
lr_warmup = warmup_schedule(step, warmup_steps, base_lr)
lr_cosine = cosine_annealing_schedule(step, total_steps, base_lr, min_lr)
lr_combined = warmup_cosine_schedule(step, warmup_steps, total_steps, base_lr, min_lr)
print(f"{step:4} | {lr_warmup:.6f} | {lr_cosine:.6f} | {lr_combined:.6f}")
Impacto de la tasa de aprendizaje en el entrenamiento
Efectos de la tasa de aprendizaje:
- Demasiado grande: entrenamiento inestable, puede divergir
- Demasiado pequeña: convergencia lenta, puede quedarse atascado en un óptimo local
- Adecuada: convergencia rápida y estable
Una estrategia común es la "prueba de rango de tasa de aprendizaje" (Learning Rate Finder): primero se entrena unos pocos pasos con tasas de aprendizaje de menor a mayor, para ver con cuál la pérdida disminuye más rápido.
Resumen de métodos de programación de la tasa de aprendizaje:
| Método de programación | Características | Escenarios de aplicación |
|---|---|---|
| Tasa de aprendizaje fija | La más simple | Modelos pequeños, tareas simples |
| Step Decay | Reducir cada pocos pasos | CNN, etc. |
| Cosine Annealing | Disminución suave | La mayoría de los escenarios |
| Warmup + Cosine | Primero subir y luego bajar | Transformer, modelos grandes (recomendado) |
| Reduce on Plateau | Reducir si el conjunto de validación no mejora | Requiere monitorear el conjunto de validación |
Práctica: implementación de un MLP desde cero con PyTorch
Ahora integremos todos los conocimientos anteriores y usemos PyTorch para implementar un perceptrón multicapa (MLP) completo, y entrenarlo en una tarea de clasificación simple.
Ejemplo
# Flujo de entrenamiento completo de MLP con PyTorch
# ============================================
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
import numpy as np
# ============================================
# 1. Preparar los datos
# ============================================
class SimpleDataset(Dataset):
"""Un conjunto de datos simple de clasificación binaria"""
def __init__(self, n_samples=1000, n_features=10):
# Generar datos sintéticos
np.random.seed(42)
self.X = np.random.randn(n_samples, n_features).astype(np.float32)
# Regla de clasificación simple: y = 1 si sum(x[:5]) > 0 else 0
self.y = (np.sum(self.X[:, :5], axis=1) > 0).astype(np.int64)
def __len__(self):
return len(self.X)
def __getitem__(self, idx):
return torch.tensor(self.X[idx]), torch.tensor(self.y[idx])
# Crear el conjunto de datos y el data loader
train_dataset = SimpleDataset(n_samples=800)
val_dataset = SimpleDataset(n_samples=200)
train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True)
val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False)
print(f"Tamaño del conjunto de entrenamiento: {len(train_dataset)}")
print(f"Tamaño del conjunto de validación: {len(val_dataset)}")
# ============================================
# 2. Definir el modelo
# ============================================
class MLP(nn.Module):
"""Perceptrón multicapa"""
def __init__(self, input_dim, hidden_dims, output_dim, dropout_rate=0.1):
super().__init__()
layers = []
prev_dim = input_dim
# Capa oculta
for hidden_dim in hidden_dims:
layers.append(nn.Linear(prev_dim, hidden_dim))
layers.append(nn.ReLU())
layers.append(nn.LayerNorm(hidden_dim)) # Normalización de capa
layers.append(nn.Dropout(dropout_rate)) # Dropout
prev_dim = hidden_dim
# Capa de salida
layers.append(nn.Linear(prev_dim, output_dim))
self.model = nn.Sequential(*layers)
def forward(self, x):
return self.model(x)
# Crear el modelo
model = MLP(
input_dim=10,
hidden_dims=[64, 32],
output_dim=2,
dropout_rate=0.1
)
print("\nEstructura del modelo:")
print(model)
# ============================================
# 3. Definir la función de pérdida y el optimizador
# ============================================
criterion = nn.CrossEntropyLoss() # Pérdida de entropía cruzada
optimizer = optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) # Optimizador AdamW
# Programación de la tasa de aprendizaje: calentamiento + recocido coseno
total_epochs = 20
warmup_epochs = 2
total_steps = len(train_loader) * total_epochs
warmup_steps = len(train_loader) * warmup_epochs
def lr_lambda(step):
if step < warmup_steps:
return (step + 1) / warmup_steps
else:
progress = (step - warmup_steps) / (total_steps - warmup_steps)
return 0.5 * (1 + np.cos(np.pi * progress))
scheduler = optim.lr_scheduler.LambdaLR(optimizer, lr_lambda=lr_lambda)
print("\nConfiguración de entrenamiento:")
print(f" Epochs: {total_epochs}")
print(f" Warmup epochs: {warmup_epochs}")
print(f" Total steps: {total_steps}")
print(f" Warmup steps: {warmup_steps}")
# ============================================
# 4. Bucle de entrenamiento
# ============================================
print("\nComenzando entrenamiento...")
print("-" * 50)
global_step = 0
best_val_acc = 0.0
for epoch in range(total_epochs):
# Fase de entrenamiento
model.train()
train_loss = 0.0
train_correct = 0
train_total = 0
for batch_X, batch_y in train_loader:
# Propagación hacia adelante
logits = model(batch_X)
loss = criterion(logits, batch_y)
# Propagación hacia atrás
optimizer.zero_grad()
loss.backward()
optimizer.step()
scheduler.step()
# Estadísticas
train_loss += loss.item()
predicted = torch.argmax(logits, dim=1)
train_total += batch_y.size(0)
train_correct += (predicted == batch_y).sum().item()
global_step += 1
train_loss /= len(train_loader)
train_acc = train_correct / train_total
# Fase de validación
model.eval()
val_loss = 0.0
val_correct = 0
val_total = 0
with torch.no_grad():
for batch_X, batch_y in val_loader:
logits = model(batch_X)
loss = criterion(logits, batch_y)
val_loss += loss.item()
predicted = torch.argmax(logits, dim=1)
val_total += batch_y.size(0)
val_correct += (predicted == batch_y).sum().item()
val_loss /= len(val_loader)
val_acc = val_correct / val_total
# Guardar el mejor modelo
if val_acc > best_val_acc:
best_val_acc = val_acc
# Aquí se puede guardar el checkpoint del modelo
# Imprimir registro
current_lr = optimizer.param_groups[0]['lr']
print(f"Epoch {epoch+1:2d} | lr: {current_lr:.6f} | "
f"Train Loss: {train_loss:.4f} | Train Acc: {train_acc:.4f} | "
f"Val Loss: {val_loss:.4f} | Val Acc: {val_acc:.4f}")
print("-" * 50)
print(f"¡Entrenamiento completado! Mejor precisión de validación: {best_val_acc:.4f}")
# ============================================
# 5. Ejemplo de inferencia
# ============================================
print("\nEjemplo de inferencia:")
model.eval()
# Algunas muestras de prueba
test_samples = [
np.array([1.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0], dtype=np.float32), # Debería ser 1
np.array([-1.0, -1.0, -1.0, -1.0, -1.0, 0.0, 0.0, 0.0, 0.0, 0.0], dtype=np.float32), # Debería ser 0
]
for i, sample in enumerate(test_samples):
with torch.no_grad():
input_tensor = torch.tensor(sample).unsqueeze(0)
logits = model(input_tensor)
probs = torch.softmax(logits, dim=1)
predicted_class = torch.argmax(probs, dim=1).item()
print(f" Muestra {i+1}: {sample[:5]}...")
print(f" Clase predicha: {predicted_class}")
print(f" Probabilidad: clase0={probs[0,0]:.4f}, clase1={probs[0,1]:.4f}")
Este ejemplo incluye todos los componentes estándar del entrenamiento de aprendizaje profundo:
- Procesamiento de datos: Dataset, DataLoader
- Definición del modelo: MLP con LayerNorm y Dropout
- Función de pérdida: CrossEntropyLoss
- Optimizador: AdamW
- Programación de la tasa de aprendizaje: Warmup + Cosine
- Bucle de entrenamiento: fase de entrenamiento + fase de validación
- Guardado del modelo e inferencia
Este flujo se puede extender directamente a tareas y modelos más complejos.
Otras extensiones