Phase 01 - Lesson 12

Operaciones con Tensores

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

Los tensores son el lenguaje común entre los datos y el deep learning. Cada imagen, cada oración, cada gradiente fluye a través de ellos.

Tipo: Build Lenguaje: Python Requisitos previos: Fase 1, Lecciones 01 (Intuición de Álgebra Lineal), 02 (Vectores, Matrices y Operaciones) Tiempo: ~90 minutos

Objetivos de Aprendizaje

  • Implementar una clase de tensor con shape, strides, reshape, transpose y operaciones elemento a elemento desde cero
  • Aplicar reglas de broadcasting para operar en tensores de distintas formas sin copiar datos
  • Escribir expresiones einsum para productos punto, multiplicaciones de matrices, productos externos y operaciones por lotes
  • Rastrear las formas exactas de los tensores en cada paso de la atención multi-head

El Problema

Construyes un transformer. El forward pass se ve limpio. Lo ejecutas y obtienes: RuntimeError: mat1 and mat2 shapes cannot be multiplied (32x768 and 512x768). Miras fijamente las formas. Pruebas un transpose. Ahora dice Expected 4D input (got 3D input). Agregas un unsqueeze. Algo más se rompe.

Los errores de forma son el bug más común en código de deep learning. No son difíciles conceptualmente -- cada operación tiene un contrato de forma -- pero se multiplican rápido. Un transformer tiene docenas de reshapes, transposes y broadcasts encadenados. Un eje equivocado y el error se propaga en cascada. Peor aún, algunos errores de forma no lanzan errores en absoluto. Silenciosamente producen basura al hacer broadcasting sobre la dimensión equivocada o al sumar sobre el eje equivocado.

Las matrices manejan relaciones por pares entre dos conjuntos de cosas. Los datos reales no caben en dos dimensiones. Un lote de 32 imágenes RGB a 224x224 es un tensor 4D: (32, 3, 224, 224). La self-attention con 12 heads también es 4D: (batch, heads, seq_len, head_dim). Necesitas una estructura de datos que generalice a cualquier número de dimensiones, con operaciones que se compongan limpiamente en todas ellas. Esa estructura es el tensor. Domina sus operaciones y los errores de forma se vuelven trivialmente depurables.

El Concepto

Que es un tensor

Un tensor es un arreglo multidimensional de números con un tipo de dato uniforme. El número de dimensiones es el rank (u orden). Cada dimensión es un eje. El shape es una tupla que lista el tamaño a lo largo de cada eje.

graph LR
    S["Scalar<br/>rank 0<br/>shape: ()"] --> V["Vector<br/>rank 1<br/>shape: (3,)"]
    V --> M["Matrix<br/>rank 2<br/>shape: (2,3)"]
    M --> T3["3D Tensor<br/>rank 3<br/>shape: (2,2,2)"]
    T3 --> T4["4D Tensor<br/>rank 4<br/>shape: (B,C,H,W)"]

Total de elementos = producto de todos los tamaños. Un shape (2, 3, 4) contiene 2 * 3 * 4 = 24 elementos.

Shapes de tensores en deep learning

Distintos tipos de datos se mapean a shapes especificos de tensores por convención.

graph TD
    subgraph Vision
        V1["(B, C, H, W)<br/>32, 3, 224, 224"]
    end
    subgraph NLP
        N1["(B, T, D)<br/>16, 128, 768"]
    end
    subgraph Attention
        A1["(B, H, T, D)<br/>16, 12, 128, 64"]
    end
    subgraph Weights
        W1["Linear: (out, in)<br/>Conv2D: (out_c, in_c, kH, kW)<br/>Embedding: (vocab, dim)"]
    end

PyTorch usa NCHW (channels-first). TensorFlow usa NHWC (channels-last) por defecto. Los layouts incompatibles causan ralentizaciones silenciosas o errores.

Como funciona el layout de memoria

Un arreglo 2D en memoria es una secuencia 1D de bytes. Los strides te dicen cuantos elementos saltar para avanzar un paso a lo largo de cada eje.

graph LR
    subgraph "Row-major (C order)"
        R["a b c d e f<br/>strides: (3, 1)"]
    end
    subgraph "Column-major (F order)"
        C["a d b e c f<br/>strides: (1, 2)"]
    end

El transpose no mueve datos. Intercambia los strides, haciendo que el tensor sea no contiguo -- los elementos de una fila ya no están adyacentes en memoria.

Reglas de broadcasting

El broadcasting te permite operar en tensores de distintas formas sin copiar datos. Alinea las formas desde la derecha. Dos dimensiones son compatibles cuando son iguales o una de ellas es 1. Las dimensiones faltantes se rellenan con 1s a la izquierda.

Tensor A:     (8, 1, 6, 1)
Tensor B:        (7, 1, 5)
Padded B:     (1, 7, 1, 5)
Result:       (8, 7, 6, 5)

Einsum: la operación universal de tensores

La suma de Einstein etiqueta cada eje con una letra. Los ejes en la entrada pero no en la salida se suman. Los ejes en ambos se conservan.

graph LR
    subgraph "matmul: ik,kj -> ij"
        A["A(I,K)"] --> |"sum over k"| C["C(I,J)"]
        B["B(K,J)"] --> |"sum over k"| C
    end

Patrones clave: i,i-> (producto punto), i,j->ij (producto externo), ii-> (traza), ij->ji (transpose), bij,bjk->bik (matmul por lotes), bhtd,bhsd->bhts (scores de atención).

Construye

El código vive en code/tensors.py. Cada paso hace referencia a la implementación alli.

Paso 1: Almacenamiento de tensores y strides

Un tensor almacena una lista plana de números más metadatos de shape. Los strides le dicen a la lógica de indexación como mapear índices multidimensionales a posiciones planas.

class Tensor:
    def __init__(self, data, shape=None):
        if isinstance(data, (list, tuple)):
            self._data, self._shape = self._flatten_nested(data)
        elif isinstance(data, np.ndarray):
            self._data = data.flatten().tolist()
            self._shape = tuple(data.shape)
        else:
            self._data = [data]
            self._shape = ()

        if shape is not None:
            total = reduce(lambda a, b: a * b, shape, 1)
            if total != len(self._data):
                raise ValueError(
                    f"Cannot reshape {len(self._data)} elements into shape {shape}"
                )
            self._shape = tuple(shape)

        self._strides = self._compute_strides(self._shape)

    @staticmethod
    def _compute_strides(shape):
        if len(shape) == 0:
            return ()
        strides = [1] * len(shape)
        for i in range(len(shape) - 2, -1, -1):
            strides[i] = strides[i + 1] * shape[i + 1]
        return tuple(strides)

Para el shape (3, 4), los strides son (4, 1) -- salta 4 elementos para avanzar una fila, salta 1 elemento para avanzar una columna.

Paso 2: Reshape, squeeze, unsqueeze

El reshape cambia la forma sin cambiar el orden de los elementos. El número total de elementos debe permanecer igual. Usa -1 en una dimensión para inferir su tamaño.

t = Tensor(list(range(12)), shape=(2, 6))
r = t.reshape((3, 4))
r = t.reshape((-1, 3))

El squeeze elimina ejes de tamaño 1. El unsqueeze inserta uno. El unsqueezing es crítico para el broadcasting -- un vector de bias (D,) agregado a un lote (B, T, D) necesita unsqueezing a (1, 1, D).

t = Tensor(list(range(6)), shape=(1, 3, 1, 2))
s = t.squeeze()
v = Tensor([1, 2, 3])
u = v.unsqueeze(0)

Paso 3: Transpose y permute

El transpose intercambia dos ejes. El permute reordena todos los ejes. Así es como conviertes entre NCHW y NHWC.

mat = Tensor(list(range(6)), shape=(2, 3))
tr = mat.transpose(0, 1)

t4d = Tensor(list(range(24)), shape=(1, 2, 3, 4))
perm = t4d.permute((0, 2, 3, 1))

Después de un transpose o permute, el tensor es no contiguo en memoria. En PyTorch, view falla en tensores no contiguos -- usa reshape o llama a .contiguous() primero.

Paso 4: Operaciones elemento a elemento y reducciones

Las operaciones elemento a elemento (add, multiply, subtract) se aplican independientemente a cada elemento y preservan la forma. Las reducciones (sum, mean, max) colapsan uno o más ejes.

a = Tensor([[1, 2], [3, 4]])
b = Tensor([[10, 20], [30, 40]])
c = a + b
d = a * 2
s = a.sum(axis=0)

Global average pooling en una CNN: (B, C, H, W).mean(axis=[2, 3]) produce (B, C). Sequence mean pooling en NLP: (B, T, D).mean(axis=1) produce (B, D).

Paso 5: Broadcasting con NumPy

La función demo_broadcasting_numpy() en tensors.py muestra los patrones centrales.

activations = np.random.randn(4, 3)
bias = np.array([0.1, 0.2, 0.3])
result = activations + bias

images = np.random.randn(2, 3, 4, 4)
scale = np.array([0.5, 1.0, 1.5]).reshape(1, 3, 1, 1)
result = images * scale

a = np.array([1, 2, 3]).reshape(-1, 1)
b = np.array([10, 20, 30, 40]).reshape(1, -1)
outer = a * b

Distancia por pares via broadcasting: haz reshape de (M, 2) a (M, 1, 2) y de (N, 2) a (1, N, 2), resta, eleva al cuadrado, suma a lo largo del último eje, toma la raíz cuadrada. Resultado: (M, N).

Paso 6: Operaciones einsum

Las funciones demo_einsum() y demo_einsum_gallery() recorren cada patron común.

a = np.array([1.0, 2.0, 3.0])
b = np.array([4.0, 5.0, 6.0])
dot = np.einsum("i,i->", a, b)

A = np.array([[1, 2], [3, 4], [5, 6]], dtype=float)
B = np.array([[7, 8, 9], [10, 11, 12]], dtype=float)
matmul = np.einsum("ik,kj->ij", A, B)

batch_A = np.random.randn(4, 3, 5)
batch_B = np.random.randn(4, 5, 2)
batch_mm = np.einsum("bij,bjk->bik", batch_A, batch_B)

El costo computacional de una contracción es el producto de todos los tamaños de índice (conservados y sumados). Para bij,bjk->bik con B=32, I=128, J=64, K=128: 32 * 128 * 64 * 128 = 33,554,432 multiplicaciones-sumas.

Paso 7: Mecanismo de atención via einsum

La función demo_attention_einsum() implementa la atención multi-head de extremo a extremo.

B, H, T, D = 2, 4, 8, 16
E = H * D

X = np.random.randn(B, T, E)
W_q = np.random.randn(E, E) * 0.02

Q = np.einsum("bte,ek->btk", X, W_q)
Q = Q.reshape(B, T, H, D).transpose(0, 2, 1, 3)

scores = np.einsum("bhtd,bhsd->bhts", Q, K) / np.sqrt(D)
weights = softmax(scores, axis=-1)
attn_output = np.einsum("bhts,bhsd->bhtd", weights, V)

concat = attn_output.transpose(0, 2, 1, 3).reshape(B, T, E)
output = np.einsum("bte,ek->btk", concat, W_o)

Cada paso es una operación de tensor: proyección (matmul via einsum), división de las heads (reshape + transpose), scores de atención (matmul por lotes via einsum), suma ponderada (matmul por lotes via einsum), fusión de las heads (transpose + reshape), proyección de salida (matmul via einsum).

Usalo

Scratch vs NumPy

Operation Scratch (Tensor class) NumPy
Create Tensor([[1,2],[3,4]]) np.array([[1,2],[3,4]])
Reshape t.reshape((3,4)) a.reshape(3,4)
Transpose t.transpose(0,1) a.T or a.transpose(0,1)
Squeeze t.squeeze(0) np.squeeze(a, 0)
Sum t.sum(axis=0) a.sum(axis=0)
Einsum N/A np.einsum("ij,jk->ik", a, b)

Scratch vs PyTorch

import torch

t = torch.tensor([[1, 2, 3], [4, 5, 6]], dtype=torch.float32)
t.shape
t.stride()
t.is_contiguous()

t.reshape(3, 2)
t.unsqueeze(0)
t.transpose(0, 1)
t.transpose(0, 1).contiguous()

torch.einsum("ik,kj->ij", A, B)

PyTorch agrega autograd, soporte para GPU y kernels BLAS optimizados. La semántica de shape es idéntica. Si entiendes la versión scratch, los errores de forma de PyTorch se vuelven legibles.

Cada capa de red neuronal como una operación de tensor

Operation Tensor Form Einsum
Linear layer Y = X @ W.T + b "bd,od->bo" + bias
Attention QKV Q = X @ W_q "btd,dh->bth"
Attention scores Q @ K.T / sqrt(d) "bhtd,bhsd->bhts"
Attention output softmax(scores) @ V "bhts,bhsd->bhtd"
Batch norm (X - mu) / sigma * gamma element-wise + broadcast
Softmax exp(x) / sum(exp(x)) element-wise + reduction

Entregalo

Esta lección produce dos prompts reutilizables:

  1. outputs/prompt-tensor-shapes.md -- Un prompt sistemático para depurar discrepancias de forma de tensores. Incluye tablas de decisión para cada operación común (matmul, broadcast, cat, Linear, Conv2d, BatchNorm, softmax) y una tabla de consulta de correcciones.

  2. outputs/prompt-tensor-debugger.md -- Un prompt de depuración paso a paso que pegas en cualquier asistente de IA cuando un error de forma te está bloqueando. Dale el mensaje de error y tus formas de tensores, recibe de vuelta la corrección exacta.

Ejercicios

  1. Fácil -- Round-trip de reshape. Toma un tensor de shape (2, 3, 4). Haz reshape a (6, 4), luego a (24,), luego de vuelta a (2, 3, 4). Verifica que el orden de los elementos se preserve en cada paso imprimiendo los datos planos.

  2. Medio -- Implementa broadcasting. Extiende la clase Tensor con un método broadcast_to(shape) que expanda dimensiones de tamaño 1 para coincidir con un shape objetivo. Luego modifica _elementwise_op para hacer broadcast automáticamente antes de operar. Prueba con shapes (3, 1) y (1, 4) produciendo (3, 4).

  3. Difícil -- Construye einsum desde cero. Implementa una función básica einsum(subscripts, *tensors) que maneje al menos: producto punto (i,i->), multiplicación de matrices (ij,jk->ik), producto externo (i,j->ij) y transpose (ij->ji). Haz el parse del string de subscripts, identifica los índices contraídos e itera sobre todas las combinaciones de índices. Compara tus resultados con np.einsum.

  4. Difícil -- Rastreador de forma de atención. Escribe una función que reciba batch_size, seq_len, embed_dim y num_heads como entradas e imprima la forma exacta en cada paso de la atención multi-head: entrada, proyección Q/K/V, división de las heads, scores de atención, pesos del softmax, suma ponderada, fusión de las heads, proyección de salida. Verifica contra la salida de demo_attention_einsum().

Términos Clave

Term What people say What it actually means
Tensor "Una matriz pero con más dimensiones" Un arreglo multidimensional con tipo uniforme y shape, strides y operaciones definidos
Rank "El número de dimensiones" El número de ejes. Una matriz tiene rank 2, no rank igual a su rango matricial
Shape "El tamaño del tensor" Una tupla que lista el tamaño a lo largo de cada eje. (2, 3) significa 2 filas, 3 columnas
Stride "Como está dispuesta la memoria" El número de elementos a saltar para avanzar una posición a lo largo de cada eje
Broadcasting "Simplemente funciona cuando las formas difieren" Un conjunto estricto de reglas: alinea desde la derecha, las dimensiones deben ser iguales o una debe ser 1
Contiguous "El tensor esta normal" Elementos almacenados secuencialmente en memoria sin huecos ni reordenamiento respecto al layout lógico
Einsum "Una forma elegante de escribir matmul" Una notación general que expresa cualquier contracción de tensor, producto externo, traza o transpose en una línea
View "Igual que reshape" Un tensor que comparte el mismo buffer de memoria pero con metadatos de shape/stride distintos. Falla en datos no contiguos
Contraction "Sumar sobre un índice" La operación general donde un índice compartido entre tensores se multiplica y suma, produciendo un resultado de rank menor
NCHW / NHWC "Formato PyTorch vs TensorFlow" Convenciones de layout de memoria para tensores de imagen. NCHW pone los canales antes de las dimensiones espaciales, NHWC los pone después

Lectura Adicional

0 lifetime access. Curriculum based on AI Engineering from Scratch by Rohit Ghumare (MIT, used under attribution).