Skip to content

English · Español

Lab 01 — Módulos Linear y de activación

Objetivo: implementa Linear, ReLU, Tanh, Sigmoid y Softmax como subclases del Module que construiste en el Lab 00. Cada una es un envoltorio delgado alrededor de ops de minitorch.Tensor; juntas te dan todo lo necesario para el MLP del Lab 03. ~50 LOC por Borja.

Tiempo estimado: 90–120 minutos.

Prereqs: Lab 00 cerrado (Parameter y Module en verde). Teoría 02 leída.


🇪🇸 Una vez que Module descubre parámetros, una Linear cabe en quince líneas y las activaciones en cuatro cada una. La única decisión interesante es la inicialización (Kaiming-uniform por defecto, la Fase 10 la derivará) y la estabilidad numérica del softmax (restar el máximo antes del exp). Todo lo demás es plomería sobre Tensor.

Qué produces

Un nuevo conjunto de ficheros en src/minimodel/nn/:

  • linear.pyLinear(in_features, out_features, bias=True).
  • activations.pyReLU, Tanh, Sigmoid, Softmax(dim=-1).
  • __init__.py re-exporta actualizado para incluir los nuevos símbolos.

Y tests/test_linear_and_activations.py con tests de shape, gradcheck y state-dict.

TODOs

Bloque A — Linear

En src/minimodel/nn/linear.py:

  • Importa math, numpy as np, Tensor desde minitorch, y Parameter, Module desde .module.
  • Define Linear(Module):
class Linear(Module):
    """Affine layer: y = x @ W.T + b. Stores weight as (out, in) per PyTorch."""

    def __init__(self, in_features: int, out_features: int, bias: bool = True) -> None:
        super().__init__()
        self.in_features = in_features
        self.out_features = out_features
        # Kaiming-uniform default; Phase 10 will derive the magnitude.
        bound = 1.0 / math.sqrt(in_features)
        # TODO: initialize self.weight as Parameter of shape (out_features, in_features)
        #       uniform in [-bound, +bound].
        raise NotImplementedError
        # TODO: if bias, set self.bias = Parameter(np.zeros(out_features));
        #       else set self.bias = None (do NOT register as Parameter).

    def forward(self, x: Tensor) -> Tensor:
        # Input x has shape (..., in_features). Output has shape (..., out_features).
        # TODO: y = x @ self.weight.transpose((1, 0))
        # TODO: if self.bias is not None, y = y + self.bias
        raise NotImplementedError
  • Nota el caso sin bias: self.bias = None debe evitar que Module.__setattr__ lo registre (no es un Parameter ni un Module, así que cae al almacenamiento plano de atributo).

Bloque B — ReLU, Tanh, Sigmoid

En src/minimodel/nn/activations.py:

  • Cada uno es un Module de un método. Sin parámetros; parameters() no produce nada.
class ReLU(Module):
    def forward(self, x: Tensor) -> Tensor:
        # TODO: return x.relu() (or x.maximum(0) if relu() lives elsewhere).
        raise NotImplementedError


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


class Sigmoid(Module):
    def forward(self, x: Tensor) -> Tensor:
        # TODO: return x.sigmoid()
        raise NotImplementedError

Si minitorch.Tensor carece de alguno de relu, tanh, sigmoid, anota el hueco en tu diario y añade un TODO en minitorch — NO implementes la op dentro de activations.py. Las activaciones son módulos; la matemática vive en la librería de tensores.

Bloque C — Softmax

  • Softmax(dim: int = -1) necesita el truco de restar el máximo para estabilidad numérica:
class Softmax(Module):
    def __init__(self, dim: int = -1) -> None:
        super().__init__()
        self.dim = dim

    def forward(self, x: Tensor) -> Tensor:
        # Numerical-stability trick: subtract the per-row max BEFORE exp.
        # x_shifted = x - x.max(dim=self.dim, keepdim=True)
        # exp_x    = x_shifted.exp()
        # return exp_x / exp_x.sum(dim=self.dim, keepdim=True)
        raise NotImplementedError
  • El "restar el máximo" no es una optimización — previene que exp(large_number) haga overflow en FP32 (≈ 88.7 es el techo). Sin ello, un logit de 100 devuelve inf y el gradiente es nan.

Bloque D — re-exports de __init__.py

En src/minimodel/nn/__init__.py:

  • Añade from .linear import Linear.
  • Añade from .activations import ReLU, Tanh, Sigmoid, Softmax.
  • Mantén __all__ ordenado y explícito.

Tests

En tests/test_linear_and_activations.py:

Bloque E — shape y parámetros de Linear

  • test_linear_shapes_batched:

    layer = Linear(4, 3)
    x = Tensor(np.random.randn(2, 4))   # (B=2, in=4)
    y = layer(x)
    assert y.data.shape == (2, 3)
    

  • test_linear_parameters_count:

    layer = Linear(4, 3)
    params = list(layer.parameters())
    assert len(params) == 2                     # weight, bias
    assert layer.weight.data.shape == (3, 4)    # (out, in)
    assert layer.bias.data.shape == (3,)
    

  • test_linear_no_bias:

    layer = Linear(4, 3, bias=False)
    params = list(layer.parameters())
    assert len(params) == 1
    assert layer.bias is None
    

  • test_linear_higher_rank_input: Entrada con shape (B, T, in) → salida (B, T, out). Verifica que el transpose + matmul hace broadcast en la dim final.

Bloque F — Gradcheck

  • test_relu_gradcheck: Gradiente de diferencia finita numérico vs autograd para ReLU. Muestrea 10 entradas aleatorias en [-1, 1]; finite-diff (f(x+ε) - f(x-ε)) / (2ε) con ε = 1e-4; compara con x.grad de y.sum().backward(). Tolerancia 1e-4. Salta puntos donde |x| < ε — ReLU es no-diferenciable en 0.

  • test_tanh_gradcheck, test_sigmoid_gradcheck: misma forma, sin salto.

  • test_softmax_gradcheck: Softmax + sum es diferenciable en todas partes. Usa una entrada de 4 dimensiones. Tolerancia 1e-4.

  • test_linear_gradcheck: Gradcheck en ambos weight y bias para un Linear(2, 3) con un batch de entrada (2, 2).

Bloque G — Estabilidad numérica de Softmax

  • test_softmax_large_logits_no_nan:

    x = Tensor(np.array([[100.0, 100.5, 101.0]]))
    y = Softmax(dim=-1)(x)
    assert not np.isnan(y.data).any()
    assert np.allclose(y.data.sum(axis=-1), 1.0)
    
    Sin el truco de restar el máximo este test falla con nan.

  • test_softmax_sums_to_one: Para una entrada aleatoria (4, 7), cada fila de la salida suma 1 dentro de 1e-6.

Bloque H — Round-trip de serialización

  • test_linear_state_dict_keys:

    state = Linear(4, 3).state_dict()
    assert set(state.keys()) == {"weight", "bias"}
    

  • test_linear_load_state_dict_roundtrip: Construye dos Linear(4, 3) con semillas aleatorias distintas; copia el estado de uno al otro; comprueba que weight.data y bias.data del segundo son iguales elemento a elemento a los del primero.

  • test_sequential_of_linear_activation: Construye (vía una lista ad-hoc tipo Sequential diminuta, o con el Sequential real si el Bloque I está hecho) una pila Linear(4, 8) → ReLU → Linear(8, 3). Verifica que tiene 4 parámetros en orden: weight, bias, weight, bias. El forward pass en (2, 4) devuelve (2, 3).

Restricciones

  • Sin PyTorch en src/minimodel/. Valores de referencia de PyTorch (si los hay) viven solo en fixtures de tests, y solo para cross-checks — el Lab 02 usa uno de esos cross-checks, no este lab.
  • Sin nuevas ops en Tensor. Si a minitorch le falta relu/tanh/sigmoid/max/exp/sum, arregla minitorch primero y escribe la nota de op faltante en tu diario.
  • Alcance A13 sin cambios. Sin código específico de verbos en este lab; las activaciones y Linear son agnósticas al dominio.
  • Sin nn.Module de PyTorch colándose. Nuestro Module viene de minimodel.nn.module.

Escollos

  • Las ops in-place rompen autograd. x.data -= ... dentro de un forward pass corrompe el tensor guardado y el backward pass devuelve gradientes incorrectos. Produce siempre un nuevo Tensor desde ops.
  • Falta de propagación de requires_grad. Si envuelves valores intermedios en llamadas frescas a Tensor(np.array(...)) (p.ej. para el truco de restar el máximo), puedes cortar el grafo de autograd. Usa ops de tensor; no construyas nuevos tensores hoja a mitad de forward.
  • Softmax sin el truco del máximo. exp(100.0) hace overflow en FP32. Resta el máximo por fila antes del exp.
  • Shape del peso de Linear. PyTorch almacena (out, in) y transpone en forward. Lo igualamos — los checkpoints transfieren a PyTorch en la Fase 18.
  • Linear sin bias registrando None como Parameter. Test: bias=False debe dejar _parameters sin clave "bias". Module.__setattr__ ya maneja esto si pones self.bias = None; verifica.
  • __init__ de activación olvidando super().__init__(). La base Module necesita _parameters y _modules inicializados, incluso para activaciones sin parámetros. Sin super().__init__(), list(ReLU().parameters()) revienta.

Condiciones de parada

Hecho cuando:

  1. Linear es ≤ 25 líneas incluyendo type hints.
  2. Cada activación es ≤ 6 líneas.
  3. Todos los tests en Bloques E–H en verde.
  4. mypy --strict src/minimodel/nn/ limpio.
  5. ruff check src/minimodel/nn/ limpio.
  6. Puedes explicar por qué weight tiene forma (out, in) y no (in, out).
  7. Puedes explicar por qué Softmax resta el máximo antes del exp (numérico, no matemático).

Cuándo consultar solutions/

Tras pasar todos los tests. solutions/01-linear-and-activations-ref.md (en la apertura de fase) compara tus módulos contra la implementación canónica, con notas sobre inicializaciones alternativas (Xavier vs Kaiming) y el debate bias-vs-no-bias.


Siguiente lab: lab/02-optimizers.md.