Phase 03 - Lesson 04

Funciones de Activación

This lesson includes a graded coding exercise that runs in your browser, unlocked with lifetime access.

Sin no linealidad, tu red de 100 capas no es más que una multiplicación de matrices sofisticada. Las activaciones son las compuertas que permiten que las redes neuronales piensen en curvas.

Tipo: Build Lenguajes: Python Prerrequisitos: Lección 03.03 (Backpropagation) Tiempo: ~75 minutos

Objetivos de Aprendizaje

  • Implementar sigmoid, tanh, ReLU, Leaky ReLU, GELU, Swish y softmax con sus derivadas desde cero
  • Diagnosticar el problema del gradiente que se desvanece midiendo las magnitudes de las activaciones a lo largo de más de 10 capas con distintas activaciones
  • Detectar neuronas muertas en una red ReLU y explicar por qué GELU evita ese modo de fallo
  • Seleccionar la función de activación correcta para una arquitectura dada (transformer, CNN, RNN, capa de salida)

El Problema

Apila dos transformaciones lineales: y = W2(W1x + b1) + b2. Expándela: y = W2W1x + W2b1 + b2. Eso es simplemente y = Ax + c -- una única transformación lineal. No importa cuántas capas lineales apiles, el resultado colapsa en una sola multiplicación de matrices. Tu red de 100 capas tiene el mismo poder de representación que una sola capa.

Esto no es una curiosidad teórica. Significa que una red lineal profunda literalmente no puede aprender XOR, no puede clasificar un conjunto de datos en espiral, no puede reconocer un rostro. Sin funciones de activación, la profundidad es una ilusión.

Las funciones de activación rompen la linealidad. Deforman la salida de cada capa a través de una función no lineal, dándole a la red la capacidad de curvar fronteras de decisión, aproximar funciones arbitrarias y de hecho aprender. Pero elige la activación equivocada y tus gradientes se desvanecen hasta cero (sigmoid en redes profundas), explotan hasta el infinito (activaciones no acotadas sin una inicialización cuidadosa), o tus neuronas mueren permanentemente (ReLU con grandes sesgos negativos). La elección de la función de activación determina directamente si tu red aprende o no.

El Concepto

Por Qué la No Linealidad Es Necesaria

La multiplicación de matrices es componible. Multiplicar un vector por la matriz A y luego por la matriz B es idéntico a multiplicar por AB. Esto significa que apilar diez capas lineales es matemáticamente equivalente a una sola capa lineal con una matriz grande. Todos esos parámetros, toda esa profundidad -- desperdiciados. Necesitas algo que rompa la cadena. Eso es lo que hacen las funciones de activación.

Aquí está la demostración. Una capa lineal computa f(x) = Wx + b. Apila dos:

Layer 1: h = W1 * x + b1
Layer 2: y = W2 * h + b2

Sustituye:

y = W2 * (W1 * x + b1) + b2
y = (W2 * W1) * x + (W2 * b1 + b2)
y = A * x + c

Una capa. Inserta una activación no lineal g() entre las capas:

h = g(W1 * x + b1)
y = W2 * h + b2

Ahora la sustitución se rompe. W2 * g(W1 * x + b1) + b2 no puede reducirse a una sola transformación lineal. La red puede representar funciones no lineales. Cada capa adicional con una activación añade capacidad de representación.

Sigmoid

La función de activación original para redes neuronales.

sigmoid(x) = 1 / (1 + e^(-x))

Rango de salida: (0, 1). Suave, diferenciable, mapea cualquier número real a un valor similar a una probabilidad.

La derivada:

sigmoid'(x) = sigmoid(x) * (1 - sigmoid(x))

El valor máximo de esta derivada es 0.25, y ocurre en x = 0. En backpropagation, los gradientes se multiplican a lo largo de las capas. Diez capas de sigmoid significan que el gradiente se multiplica por a lo sumo 0.25 diez veces:

0.25^10 = 0.000000953674

Menos de una millonésima parte de la señal original. Este es el problema del gradiente que se desvanece. Los gradientes en las primeras capas se vuelven tan pequeños que los pesos apenas se actualizan. La red parece aprender -- la pérdida disminuye en las capas posteriores -- pero las primeras capas están congeladas. Las redes sigmoid profundas simplemente no entrenan.

Problema adicional: las salidas de sigmoid son siempre positivas (0 a 1), lo que significa que los gradientes sobre los pesos tienen siempre el mismo signo. Esto provoca un zigzag durante el descenso de gradiente.

Tanh

La versión centrada de sigmoid.

tanh(x) = (e^x - e^(-x)) / (e^x + e^(-x))

Rango de salida: (-1, 1). Centrada en cero, lo que elimina el problema del zigzag.

La derivada:

tanh'(x) = 1 - tanh(x)^2

La derivada máxima es 1.0 en x = 0 -- cuatro veces mejor que sigmoid. Pero el problema del gradiente que se desvanece sigue existiendo. Para entradas positivas o negativas grandes, la derivada se acerca a cero. Diez capas siguen aplastando el gradiente, solo que de forma menos agresiva.

ReLU: El Gran Avance

Rectified Linear Unit (Unidad Lineal Rectificada). Popularizada para el deep learning por Nair y Hinton en 2010 (la función en sí se remonta al trabajo de Fukushima de 1969), lo cambió todo.

relu(x) = max(0, x)

Rango de salida: [0, infinito). La derivada es trivialmente simple:

relu'(x) = 1  if x > 0
            0  if x <= 0

Sin gradiente que se desvanece para entradas positivas. El gradiente es exactamente 1, pasado directamente. Por eso las redes profundas se volvieron entrenables -- ReLU preserva la magnitud del gradiente a lo largo de las capas.

Pero hay un modo de fallo: el problema de la neurona muerta. Si la entrada ponderada de una neurona es siempre negativa (debido a un gran sesgo negativo o a una inicialización desafortunada de los pesos), su salida es siempre cero, su gradiente es siempre cero y nunca se actualiza. Está permanentemente muerta. En la práctica, entre el 10 y el 40% de las neuronas en una red ReLU pueden morir durante el entrenamiento.

Leaky ReLU

La solución más simple para las neuronas muertas.

leaky_relu(x) = x        if x > 0
                alpha * x if x <= 0

Donde alpha es una constante pequeña, típicamente 0.01. El lado negativo tiene una pequeña pendiente en lugar de cero, así que las neuronas muertas siguen recibiendo una señal de gradiente y pueden recuperarse.

GELU: El Estándar Moderno

Gaussian Error Linear Unit (Unidad Lineal de Error Gaussiano). Introducida por Hendrycks y Gimpel en 2016. Activación por defecto en BERT, GPT y la mayoría de los transformers modernos.

gelu(x) = x * Phi(x)

Donde Phi(x) es la función de distribución acumulada de la distribución normal estándar. La aproximación usada en la práctica:

gelu(x) ~= 0.5 * x * (1 + tanh(sqrt(2/pi) * (x + 0.044715 * x^3)))

GELU es suave en todas partes, permite pequeños valores negativos (a diferencia de ReLU, que recorta de forma abrupta a cero) y tiene una interpretación probabilística: pondera cada entrada según qué tan probable es que sea positiva bajo una distribución gaussiana. Esta compuerta suave supera a ReLU en arquitecturas transformer porque proporciona un mejor flujo de gradiente y evita por completo el problema de la neurona muerta.

Swish / SiLU

Activación auto-compuerta descubierta por Ramachandran et al. en 2017 mediante búsqueda automatizada.

swish(x) = x * sigmoid(x)

Swish es formalmente x * sigmoid(x). Google la descubrió mediante una búsqueda automatizada en el espacio de funciones de activación -- una red neuronal diseñando partes de redes neuronales.

Al igual que GELU, es suave, no monótona y permite pequeños valores negativos. La diferencia es sutil: Swish usa sigmoid para la compuerta mientras que GELU usa la CDF gaussiana. En la práctica, el rendimiento es casi idéntico. Swish se usa en EfficientNet y en algunos modelos de visión. GELU domina en los modelos de lenguaje.

Softmax: La Activación de Salida

No se usa en capas ocultas. Softmax convierte un vector de puntuaciones crudas (logits) en una distribución de probabilidad.

softmax(x_i) = e^(x_i) / sum(e^(x_j) for all j)

Cada salida está entre 0 y 1. Todas las salidas suman 1. Esto la convierte en la activación final estándar para la clasificación multiclase. El logit más grande obtiene la probabilidad más alta, pero a diferencia de argmax, softmax es diferenciable y preserva información sobre la confianza relativa.

Comparación de Formas

graph LR
    subgraph "Funciones de Activación"
        S["Sigmoid<br/>Rango: (0,1)<br/>Satura en ambos extremos"]
        T["Tanh<br/>Rango: (-1,1)<br/>Centrada en cero"]
        R["ReLU<br/>Rango: [0,inf)<br/>Neuronas muertas"]
        G["GELU<br/>Rango: ~(-0.17,inf)<br/>Compuerta suave"]
    end
    S -->|"Gradiente que se desvanece"| Problem["Las redes profundas<br/>no entrenan"]
    T -->|"Menos severo, pero<br/>aún se desvanece"| Problem
    R -->|"Gradiente = 1<br/>para x > 0"| Solution["Las redes profundas<br/>entrenan rápido"]
    G -->|"Gradiente suave<br/>en todas partes"| Solution

Comparación de Flujo de Gradiente

graph TD
    Input["Señal de Entrada"] --> L1["Capa 1"]
    L1 --> L5["Capa 5"]
    L5 --> L10["Capa 10"]
    L10 --> Output["Salida"]

    subgraph "Gradiente en la Capa 1"
        SigGrad["Sigmoid: ~0.000001"]
        TanhGrad["Tanh: ~0.001"]
        ReluGrad["ReLU: ~1.0"]
        GeluGrad["GELU: ~0.8"]
    end

Qué Activación y Cuándo

flowchart TD
    Start["¿Qué estás construyendo?"] --> Hidden{"¿Capas ocultas<br/>o salida?"}

    Hidden -->|"Capas ocultas"| Arch{"¿Arquitectura?"}
    Hidden -->|"Capa de salida"| Task{"¿Tipo de tarea?"}

    Arch -->|"Transformer / NLP"| GELU["Usa GELU"]
    Arch -->|"CNN / Visión"| ReLU["Usa ReLU o Swish"]
    Arch -->|"RNN / LSTM"| Tanh["Usa Tanh"]
    Arch -->|"MLP simple"| ReLU2["Usa ReLU"]

    Task -->|"Clasificación binaria"| Sigmoid["Usa Sigmoid"]
    Task -->|"Clasificación multiclase"| Softmax["Usa Softmax"]
    Task -->|"Regresión"| Linear["Usa Lineal (sin activación)"]

Constrúyelo

Paso 1: Implementar Todas las Funciones de Activación con Derivadas

Cada función recibe un único float y devuelve un float. Cada función de derivada recibe la misma entrada y devuelve el gradiente.

import math

def sigmoid(x):
    x = max(-500, min(500, x))
    return 1.0 / (1.0 + math.exp(-x))

def sigmoid_derivative(x):
    s = sigmoid(x)
    return s * (1 - s)

def tanh_act(x):
    return math.tanh(x)

def tanh_derivative(x):
    t = math.tanh(x)
    return 1 - t * t

def relu(x):
    return max(0.0, x)

def relu_derivative(x):
    return 1.0 if x > 0 else 0.0

def leaky_relu(x, alpha=0.01):
    return x if x > 0 else alpha * x

def leaky_relu_derivative(x, alpha=0.01):
    return 1.0 if x > 0 else alpha

def gelu(x):
    return 0.5 * x * (1 + math.tanh(math.sqrt(2 / math.pi) * (x + 0.044715 * x ** 3)))

def gelu_derivative(x):
    phi = 0.5 * (1 + math.erf(x / math.sqrt(2)))
    pdf = math.exp(-0.5 * x * x) / math.sqrt(2 * math.pi)
    return phi + x * pdf

def swish(x):
    return x * sigmoid(x)

def swish_derivative(x):
    s = sigmoid(x)
    return s + x * s * (1 - s)

def softmax(xs):
    max_x = max(xs)
    exps = [math.exp(x - max_x) for x in xs]
    total = sum(exps)
    return [e / total for e in exps]

Paso 2: Visualizar Dónde Mueren los Gradientes

Computa el gradiente en 100 puntos espaciados uniformemente de -5 a 5. Imprime un histograma de texto que muestre dónde el gradiente de cada activación se acerca a cero.

def gradient_scan(name, derivative_fn, start=-5, end=5, n=100):
    step = (end - start) / n
    near_zero = 0
    healthy = 0
    for i in range(n):
        x = start + i * step
        g = derivative_fn(x)
        if abs(g) < 0.01:
            near_zero += 1
        else:
            healthy += 1
    pct_dead = near_zero / n * 100
    print(f"{name:15s}: {healthy:3d} healthy, {near_zero:3d} near-zero ({pct_dead:.0f}% dead zone)")

gradient_scan("Sigmoid", sigmoid_derivative)
gradient_scan("Tanh", tanh_derivative)
gradient_scan("ReLU", relu_derivative)
gradient_scan("Leaky ReLU", leaky_relu_derivative)
gradient_scan("GELU", gelu_derivative)
gradient_scan("Swish", swish_derivative)

Paso 3: Experimento del Gradiente que Se Desvanece

Haz el paso hacia adelante (forward pass) de una señal a través de N capas usando sigmoid vs ReLU. Mide cómo cambia la magnitud de la activación.

import random

def vanishing_gradient_experiment(activation_fn, name, n_layers=10, n_inputs=5):
    random.seed(42)
    values = [random.gauss(0, 1) for _ in range(n_inputs)]

    print(f"\n{name} through {n_layers} layers:")
    for layer in range(n_layers):
        weights = [random.gauss(0, 1) for _ in range(n_inputs)]
        z = sum(w * v for w, v in zip(weights, values))
        activated = activation_fn(z)
        magnitude = abs(activated)
        bar = "#" * int(magnitude * 20)
        print(f"  Layer {layer+1:2d}: magnitude = {magnitude:.6f} {bar}")
        values = [activated] * n_inputs

vanishing_gradient_experiment(sigmoid, "Sigmoid")
vanishing_gradient_experiment(relu, "ReLU")
vanishing_gradient_experiment(gelu, "GELU")

Paso 4: Detector de Neuronas Muertas

Crea una red ReLU, pasa entradas aleatorias por ella y cuenta cuántas neuronas nunca se activan.

def dead_neuron_detector(n_inputs=5, hidden_size=20, n_samples=1000):
    random.seed(0)
    weights = [[random.gauss(0, 1) for _ in range(n_inputs)] for _ in range(hidden_size)]
    biases = [random.gauss(0, 1) for _ in range(hidden_size)]

    fire_counts = [0] * hidden_size

    for _ in range(n_samples):
        inputs = [random.gauss(0, 1) for _ in range(n_inputs)]
        for neuron_idx in range(hidden_size):
            z = sum(w * x for w, x in zip(weights[neuron_idx], inputs)) + biases[neuron_idx]
            if relu(z) > 0:
                fire_counts[neuron_idx] += 1

    dead = sum(1 for c in fire_counts if c == 0)
    rarely_fire = sum(1 for c in fire_counts if 0 < c < n_samples * 0.05)
    healthy = hidden_size - dead - rarely_fire

    print(f"\nDead Neuron Report ({hidden_size} neurons, {n_samples} samples):")
    print(f"  Dead (never fired):     {dead}")
    print(f"  Barely alive (<5%):     {rarely_fire}")
    print(f"  Healthy:                {healthy}")
    print(f"  Dead neuron rate:       {dead/hidden_size*100:.1f}%")

    for i, c in enumerate(fire_counts):
        status = "DEAD" if c == 0 else "WEAK" if c < n_samples * 0.05 else "OK"
        bar = "#" * (c * 40 // n_samples)
        print(f"  Neuron {i:2d}: {c:4d}/{n_samples} fires [{status:4s}] {bar}")

dead_neuron_detector()

Paso 5: Comparación de Entrenamiento -- Sigmoid vs ReLU vs GELU

Entrena la misma red de dos capas en el conjunto de datos del círculo (puntos dentro de un círculo = clase 1, fuera = clase 0) con tres activaciones distintas. Compara la velocidad de convergencia.

def make_circle_data(n=200, seed=42):
    random.seed(seed)
    data = []
    for _ in range(n):
        x = random.uniform(-2, 2)
        y = random.uniform(-2, 2)
        label = 1.0 if x * x + y * y < 1.5 else 0.0
        data.append(([x, y], label))
    return data


class ActivationNetwork:
    def __init__(self, activation_fn, activation_deriv, hidden_size=8, lr=0.1):
        random.seed(0)
        self.act = activation_fn
        self.act_d = activation_deriv
        self.lr = lr
        self.hidden_size = hidden_size

        self.w1 = [[random.gauss(0, 0.5) for _ in range(2)] for _ in range(hidden_size)]
        self.b1 = [0.0] * hidden_size
        self.w2 = [random.gauss(0, 0.5) for _ in range(hidden_size)]
        self.b2 = 0.0

    def forward(self, x):
        self.x = x
        self.z1 = []
        self.h = []
        for i in range(self.hidden_size):
            z = self.w1[i][0] * x[0] + self.w1[i][1] * x[1] + self.b1[i]
            self.z1.append(z)
            self.h.append(self.act(z))

        self.z2 = sum(self.w2[i] * self.h[i] for i in range(self.hidden_size)) + self.b2
        self.out = sigmoid(self.z2)
        return self.out

    def backward(self, target):
        error = self.out - target
        d_out = error * self.out * (1 - self.out)

        for i in range(self.hidden_size):
            d_h = d_out * self.w2[i] * self.act_d(self.z1[i])
            self.w2[i] -= self.lr * d_out * self.h[i]
            for j in range(2):
                self.w1[i][j] -= self.lr * d_h * self.x[j]
            self.b1[i] -= self.lr * d_h
        self.b2 -= self.lr * d_out

    def train(self, data, epochs=200):
        losses = []
        for epoch in range(epochs):
            total_loss = 0
            correct = 0
            for x, y in data:
                pred = self.forward(x)
                self.backward(y)
                total_loss += (pred - y) ** 2
                if (pred >= 0.5) == (y >= 0.5):
                    correct += 1
            avg_loss = total_loss / len(data)
            accuracy = correct / len(data) * 100
            losses.append(avg_loss)
            if epoch % 50 == 0 or epoch == epochs - 1:
                print(f"    Epoch {epoch:3d}: loss={avg_loss:.4f}, accuracy={accuracy:.1f}%")
        return losses


data = make_circle_data()

configs = [
    ("Sigmoid", sigmoid, sigmoid_derivative),
    ("ReLU", relu, relu_derivative),
    ("GELU", gelu, gelu_derivative),
]

results = {}
for name, act_fn, act_d_fn in configs:
    print(f"\n=== Training with {name} ===")
    net = ActivationNetwork(act_fn, act_d_fn, hidden_size=8, lr=0.1)
    losses = net.train(data, epochs=200)
    results[name] = losses

print("\n=== Final Loss Comparison ===")
for name, losses in results.items():
    print(f"  {name:10s}: start={losses[0]:.4f} -> end={losses[-1]:.4f} (improvement: {(1 - losses[-1]/losses[0])*100:.1f}%)")

Úsalo

PyTorch proporciona todas ellas tanto en forma funcional como en forma de módulo:

import torch
import torch.nn as nn
import torch.nn.functional as F

x = torch.randn(4, 10)

relu_out = F.relu(x)
gelu_out = F.gelu(x)
sigmoid_out = torch.sigmoid(x)
swish_out = F.silu(x)

logits = torch.randn(4, 5)
probs = F.softmax(logits, dim=1)

model = nn.Sequential(
    nn.Linear(10, 64),
    nn.GELU(),
    nn.Linear(64, 32),
    nn.GELU(),
    nn.Linear(32, 5),
)

Capas ocultas en un transformer: GELU. Capas ocultas en una CNN: ReLU. Capa de salida para clasificación: softmax. Capa de salida para regresión: ninguna (lineal). Capa de salida para probabilidades: sigmoid. Eso es todo. Empieza con estos valores por defecto. Cámbialos solo cuando tengas evidencia.

Las RNN y las LSTM usan tanh para el estado oculto y sigmoid para las compuertas, pero si estás construyendo desde cero hoy, probablemente no estés usando RNN. Si las neuronas están muriendo en tu red ReLU, cambia a GELU. No recurras a Leaky ReLU a menos que tengas una razón específica -- GELU resuelve el problema de la neurona muerta y da un mejor flujo de gradiente.

Entrégalo

Esta lección produce:

  • outputs/prompt-activation-selector.md -- un prompt reutilizable que te ayuda a elegir la función de activación correcta para cualquier arquitectura

Ejercicios

  1. Implementa la Parametric ReLU (PReLU), donde la pendiente negativa alpha es un parámetro aprendible. Entrénala en el conjunto de datos del círculo y compárala con la Leaky ReLU fija.

  2. Ejecuta el experimento del gradiente que se desvanece con 50 capas en lugar de 10. Grafica la magnitud en cada capa para sigmoid, tanh, ReLU y GELU. ¿En qué capa la señal de cada activación llega efectivamente a cero?

  3. Implementa la ELU (Exponential Linear Unit): elu(x) = x si x > 0, alpha * (e^x - 1) si x <= 0. Compara su tasa de neuronas muertas con la de ReLU en la misma red.

  4. Construye un "monitor de salud del gradiente" que se ejecute durante el entrenamiento: en cada época, computa la magnitud media del gradiente en cada capa. Imprime una advertencia cuando el gradiente de cualquier capa caiga por debajo de 0.001 o supere 100.

  5. Modifica la comparación de entrenamiento para usar el conjunto de datos XOR de la Lección 01 en lugar de círculos. ¿Qué activación converge más rápido en XOR? ¿Por qué esto difiere de los resultados del círculo?

Términos Clave

Término Lo que dice la gente Lo que realmente significa
Función de activación "La parte no lineal" Una función aplicada a la salida de cada neurona que rompe la linealidad, permitiendo que la red aprenda mapeos no lineales
Gradiente que se desvanece "Los gradientes desaparecen en redes profundas" Los gradientes se encogen exponencialmente a lo largo de las capas cuando la derivada de la activación es menor que 1, haciendo que las primeras capas no se puedan entrenar
Gradiente que explota "Los gradientes se disparan" Los gradientes crecen exponencialmente a lo largo de las capas cuando el multiplicador efectivo supera 1, causando un entrenamiento inestable
Neurona muerta "Una neurona que dejó de aprender" Una neurona ReLU cuya entrada es permanentemente negativa, produciendo salida cero y gradiente cero
Sigmoid "Comprime valores a 0-1" La función logística 1/(1+e^-x), históricamente importante pero que causa gradientes que se desvanecen en redes profundas
ReLU "Recorta los negativos a cero" max(0, x) -- la activación que hizo práctico el deep learning al preservar la magnitud del gradiente
GELU "La activación del transformer" Gaussian Error Linear Unit, una activación suave que pondera las entradas según su probabilidad de ser positivas
Swish/SiLU "ReLU auto-compuerta" x * sigmoid(x), descubierta mediante búsqueda automatizada, usada en EfficientNet
Softmax "Convierte puntuaciones en probabilidades" Normaliza un vector de logits en una distribución de probabilidad donde todos los valores están en (0,1) y suman 1
Leaky ReLU "ReLU que no muere" max(alpha*x, x) donde alpha es pequeño (0.01), previniendo neuronas muertas al permitir pequeños gradientes negativos
Saturación "La parte plana de sigmoid" Regiones donde la derivada de una activación se acerca a cero, bloqueando el flujo de gradiente
Logit "La puntuación cruda antes de softmax" La salida no normalizada de la capa final antes de aplicar softmax o sigmoid

Lecturas Adicionales

  • Nair & Hinton, "Rectified Linear Units Improve Restricted Boltzmann Machines" (2010) -- el artículo que introdujo ReLU y permitió el entrenamiento de redes profundas
  • Hendrycks & Gimpel, "Gaussian Error Linear Units (GELUs)" (2016) -- introdujo la función de activación que se convirtió en el estándar para los transformers
  • Ramachandran et al., "Searching for Activation Functions" (2017) -- usó búsqueda automatizada para descubrir Swish, mostrando que el diseño de activaciones puede automatizarse
  • Glorot & Bengio, "Understanding the difficulty of training deep feedforward neural networks" (2010) -- el artículo que diagnosticó los gradientes que se desvanecen/explotan y propuso la inicialización Xavier
  • Goodfellow, Bengio, Courville, "Deep Learning" Capítulo 6.3 (https://www.deeplearningbook.org/) -- tratamiento riguroso de las unidades ocultas y las funciones de activación
0 lifetime access. Curriculum based on AI Engineering from Scratch by Rohit Ghumare (MIT, used under attribution).