Skip to content

English · Español

02 — Linear y Sequential: las capas más simples posibles

🇪🇸 Linear cabe en 15 líneas y es la única "capa" que el currículo necesita hasta la Fase 13. Sequential cabe en 10 líneas más y nos da la composición ergonómica. La única decisión real en Linear es la inicialización de los pesos: Kaiming/He para preactivaciones que pasarán por ReLU; Xavier para tanh/sigmoid. La Fase 9 implementa una versión mínima; la Fase 10 la deriva en detalle.


Linear(in_features, out_features): la capa afín

La matemática es una línea: y = x @ W.T + b (o y = x @ W + b — elige una convención y mantenla).

La clase:

class Linear(Module):
    """Affine layer: y = x @ W.T + b."""

    def __init__(self, in_features: int, out_features: int, bias: bool = True) -> None:
        super().__init__()
        # Kaiming-uniform init (Phase 10 derives why this magnitude).
        bound = 1.0 / math.sqrt(in_features)
        self.weight = Parameter(
            np.random.uniform(-bound, bound, size=(out_features, in_features))
        )
        if bias:
            self.bias = Parameter(np.zeros(out_features))
        else:
            self.bias = None  # registered as a non-Parameter, so not in self._parameters

    def forward(self, x: Tensor) -> Tensor:
        # x: (B, in_features) or (..., in_features)
        # Returns: (B, out_features) or (..., out_features)
        y = x @ self.weight.transpose((1, 0))   # (B, out)
        if self.bias is not None:
            y = y + self.bias                     # broadcast (out,) + (B, out) → (B, out)
        return y

Veinte líneas. Decisiones horneadas:

  1. Convención de PyTorch (out, in) para la forma del peso, transpuesto en forward. Razón: hace que las claves de state_dict de Linear ("weight", "bias") coincidan con las de PyTorch para que los checkpoints transfieran (Fase 18). La alternativa es almacenamiento (in, out) sin transposición — ahorra una op pero rompe la interoperabilidad con PyTorch.
  2. Bias por defecto a cero, no aleatorio. Práctica estándar: los biases inicializados a cero están bien porque la simetría se rompe por los pesos aleatorios. La Fase 10 lo confirma.
  3. Bandera bias: bool. El bias opcional es útil para la última capa de un clasificador softmax (el bias es redundante bajo la invariancia de desplazamiento de softmax). El transformer de la Fase 17 tiene linears con y sin bias.
  4. Magnitud de init 1/sqrt(in_features). Kaiming-uniform para ReLU. La derivación completa vive en theory/01-initialization.md de la Fase 10; aquí usamos el resultado como un default sensato.

¿Por qué Linear acepta (..., in_features)?

El forward usa @ que es el matmul de NumPy. matmul hace broadcast sobre las dims líderes: (B, T, D) @ (D, H) → (B, T, H). Así que Linear funciona en cualquier entrada de rango ≥1, tratando el último eje como el eje de features. Esto es crítico para transformers (Fase 17 donde la entrada es (B, T, D)) — obtenemos el comportamiento correcto gratis.

Comprobación de cuenta de parámetros

Linear(23, 16) tiene 23 · 16 + 16 = 384 parámetros. Linear(16, 5) tiene 16 · 5 + 5 = 85. Total para TenseMLP: 469. Cabe en 4 KB a FP32. Verificar la cuenta de parámetros se vuelve un reflejo a partir de la Fase 9.

Sequential([m1, m2, ...]): composición

class Sequential(Module):
    """Apply modules in order. Auto-registers each module."""

    def __init__(self, *modules: Module) -> None:
        super().__init__()
        for i, m in enumerate(modules):
            # Register each module under its index as a string.
            setattr(self, str(i), m)

    def forward(self, x):
        for i in range(len(self._modules)):
            x = self._modules[str(i)](x)
        return x

Diez líneas. Notas:

  • Los módulos se registran bajo índices de string ("0", "1", ...). PyTorch también lo hace — model.0.weight es el peso del primer submódulo. Los state dicts se ven como "0.weight", "0.bias", "2.weight", ... (con activaciones en los índices 1 y 3 sin contribuir parámetros).
  • El forward itera sobre _modules en orden de registro. Los dicts de Python 3.7+ preservan el orden de inserción, así que esto funciona sin un OrderedDict explícito.
  • Sin forward(self, *args). Sequential encadena un único tensor a través. Capas multi-input no encajan; usa una subclase Module personalizada para esas.

Ejemplo de composición:

mlp = Sequential(
    Linear(23, 16),
    ReLU(),
    Linear(16, 5),
)
# mlp.parameters() yields: fc1.weight, fc1.bias, fc2.weight, fc2.bias  (4 tensors)
# mlp(x) computes Linear(ReLU(Linear(x)))

Módulos de activación

Envoltorios delgados alrededor de métodos de Tensor. Cada uno no tiene parámetros.

class ReLU(Module):
    def forward(self, x: Tensor) -> Tensor:
        return x.relu()

class Tanh(Module):
    def forward(self, x: Tensor) -> Tensor:
        return x.tanh()

class Sigmoid(Module):
    def forward(self, x: Tensor) -> Tensor:
        return Tensor(1.0) / (Tensor(1.0) + (-x).exp())

class GELU(Module):
    def forward(self, x: Tensor) -> Tensor:
        return x.gelu()

Tres líneas cada uno. ¿Por qué módulos en lugar de simples funciones?

  • Sequential requiere Modules. Las funciones no tienen .parameters() y no encajan en _modules.
  • Paridad con PyTorch. Usuarios que vienen de PyTorch esperan que nn.ReLU() funcione.

Para uso no-Sequential, x.relu() directamente es equivalente y más corto. Ambos estilos coexisten.

Inicialización: el mínimo

La Fase 9 implementa dos helpers de init en nn/init.py:

def kaiming_uniform_(tensor: Tensor, a: float = 0.0) -> None:
    """Kaiming-uniform initialization for ReLU networks."""
    fan_in = tensor.data.shape[1]
    gain = math.sqrt(2.0 / (1 + a ** 2))
    bound = gain * math.sqrt(3.0 / fan_in)
    tensor.data[...] = np.random.uniform(-bound, bound, size=tensor.shape)

def xavier_normal_(tensor: Tensor, gain: float = 1.0) -> None:
    """Xavier-normal initialization for tanh/sigmoid networks."""
    fan_in, fan_out = tensor.data.shape[1], tensor.data.shape[0]
    std = gain * math.sqrt(2.0 / (fan_in + fan_out))
    tensor.data[...] = np.random.normal(0.0, std, size=tensor.shape)

In-place (tensor.data[...] = ...) por convención — estos helpers mutan el tensor pasado, igual que el sufijo _ de PyTorch. El lab implementa Linear con kaiming_uniform_ como default.

¿Por qué importa la magnitud de la init?

Si cada peso es demasiado grande: las preactivaciones tienen varianza ≫ 1, ReLU satura (la mayoría de las salidas son lineales en la entrada, pero el gradiente explota a través de muchas capas). La pérdida es NaN en 10 pasos.

Si cada peso es demasiado pequeño: las preactivaciones tienen varianza ≪ 1, ReLU produce salidas diminutas, los gradientes desaparecen a través de las capas. La pérdida es constante durante muchos pasos.

La derivación de Kaiming (Fase 10) es: "para una capa ReLU, la varianza del peso 2 / fan_in mantiene la varianza de la preactivación igual a la varianza de la entrada". El factor de 2 corrige por ReLU matando la mitad de las salidas en expectativa.

La Fase 9 usa Kaiming como default porque cada capa en el MLP de §A13 es seguida por una ReLU (excepto la salida). La Fase 10 amplía; la Fase 17 usa Xavier para capas tanh-internas de transformer.

El bucle de entrenamiento usando Module

Juntándolo todo:

model = Sequential(
    Linear(23, 16),
    ReLU(),
    Linear(16, 5),
)
optim = SGD(list(model.parameters()), lr=0.05)

for epoch in range(n_epochs):
    for x_batch, y_batch in train_batches:
        optim.zero_grad()
        logits = model(x_batch)
        loss = cross_entropy(logits, y_batch)
        loss.backward()
        optim.step()

El bucle de entrenamiento de cinco líneas que cada red neuronal desde 2015 ha usado. Esto es lo que entrega la Fase 9.

Escollos (morderán en el lab)

  1. Sequential con un ítem que no es Module. Sequential(Linear(2, 3), lambda x: x.relu()) lanza en __init__ (lambda no es un Module). El mensaje de error debe ser claro y contundente.
  2. Linear.weight.shape incorrecto. (out_features, in_features)no (in, out). Un error off-by-one durante __init__ produce desajuste de dims de matmul en el primer forward. Test: Linear(3, 5).weight.shape == (5, 3).
  3. Olvidar super().__init__() en un Module personalizado. Primera línea de cada __init__ después del def. Crash en el primer self.X = Parameter(...) porque _parameters no existe.
  4. Inicializar pesos como np.zeros. Todas las neuronas computan lo mismo; los gradientes son idénticos; el entrenamiento nunca rompe la simetría. La pérdida decrece brevemente (el bias aún entrena) y luego se estanca. Bug difícil de detectar porque nada revienta — solo se queda atascado en pérdida alta.
  5. Estabilidad numérica de Sigmoid. (-x).exp() para x muy negativo (digamos -1000) hace overflow. PyTorch usa una forma estable. La Sigmoid de la Fase 9 usa la forma ingenua porque no necesitamos entradas extremas en la tarea gramatical; la Fase 18 (trucos de entrenamiento) revisa.
  6. Mutación in-place de tensor.data. Los helpers de init tensor.data[...] = ... están bien porque ocurren antes de que el tensor entre en ningún grafo. Mutar data después de que el tensor haya sido usado en un forward pass está prohibido (rompe el DAG — antigoal de la Fase 8).

Ancla temática (§A13)

El TenseMLP de la Fase 9 es:

class TenseMLP(Module):
    def __init__(self):
        super().__init__()
        self.fc1 = Linear(23, 16)   # one-hot(verb) ⊕ one-hot(person)
        self.fc2 = Linear(16, 5)    # logits over 5 tenses

    def forward(self, x):
        h = self.fc1(x).relu()
        return self.fc2(h)

Equivalente en forma Sequential:

mlp = Sequential(
    Linear(23, 16),
    ReLU(),
    Linear(16, 5),
)

Ambos entrenan idénticamente. Borja elige uno en el Lab 03; la pregunta de reflexión del lab pregunta cuál se siente más natural y por qué.

Lo que esta página NO cubre

  • nn.Embedding. Fase 11 (embeddings). Por ahora, representamos verbos/personas como vectores one-hot.
  • nn.BatchNorm, nn.LayerNorm. Fase 10.
  • nn.Dropout. Fase 18.
  • Forward multi-input (p.ej., forward(x, mask)). Fase 14 (atención de transformer).
  • La derivación de Kaiming en detalle. theory/01-initialization.md de la Fase 10.

Recapitulación de un párrafo

Linear(in, out) son ~20 líneas: almacena weight: (out, in) y bias: (out,) como Parameters y computa x @ weight.T + bias en forward. Sequential(*modules) son ~10 líneas: registra cada módulo bajo un índice de string y los encadena en forward. Las activaciones son envoltorios Module de 3 líneas alrededor de métodos de Tensor. La inicialización por defecto es Kaiming-uniform (la Fase 10 deriva). Con estas piezas, un MLP completo cabe en 7 líneas y el bucle de entrenamiento cabe en 5 líneas. El valor añadido de la Fase 9 es la ergonomía, no nuevas matemáticas.


Siguiente: 03-optimizers.md