English · Español
02 — Linear y Sequential: las capas más simples posibles¶
🇪🇸
Linearcabe en 15 líneas y es la única "capa" que el currículo necesita hasta la Fase 13.Sequentialcabe en 10 líneas más y nos da la composición ergonómica. La única decisión real enLineares 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:
- Convención de PyTorch
(out, in)para la forma del peso, transpuesto en forward. Razón: hace que las claves destate_dictdeLinear("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. - 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.
- 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. - Magnitud de init
1/sqrt(in_features). Kaiming-uniform para ReLU. La derivación completa vive entheory/01-initialization.mdde 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.weightes 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
_modulesen orden de registro. Los dicts de Python 3.7+ preservan el orden de inserción, así que esto funciona sin unOrderedDictexplícito. - Sin
forward(self, *args).Sequentialencadena un único tensor a través. Capas multi-input no encajan; usa una subclaseModulepersonalizada 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?
SequentialrequiereModules. 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)¶
Sequentialcon un ítem que no es Module.Sequential(Linear(2, 3), lambda x: x.relu())lanza en__init__(lambda no es unModule). El mensaje de error debe ser claro y contundente.Linear.weight.shapeincorrecto.(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).- Olvidar
super().__init__()en unModulepersonalizado. Primera línea de cada__init__después del def. Crash en el primerself.X = Parameter(...)porque_parametersno existe. - 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. - Estabilidad numérica de
Sigmoid.(-x).exp()paraxmuy negativo (digamos-1000) hace overflow. PyTorch usa una forma estable. LaSigmoidde la Fase 9 usa la forma ingenua porque no necesitamos entradas extremas en la tarea gramatical; la Fase 18 (trucos de entrenamiento) revisa. - Mutación in-place de
tensor.data. Los helpers de inittensor.data[...] = ...están bien porque ocurren antes de que el tensor entre en ningún grafo. Mutardatadespué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:
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.mdde 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