¿Qué Es Realmente un Tensor?
Un tensor no es simplemente un arreglo multidimensional — es un delgado envoltorio de metadatos alrededor de un bloque plano de memoria. Entender esta distinción es la clave para comprender por qué algunas operaciones son gratuitas (solo cambian los metadatos) mientras que otras requieren copiar potencialmente gigabytes de datos.
Un tensor tiene dos partes:
- Almacenamiento: un bloque contiguo y unidimensional de memoria que contiene los números reales.
- Metadatos: forma (shape), zancadas (strides), tipo de dato (dtype), desplazamiento (offset) — todo lo que la biblioteca necesita para interpretar esa memoria plana como una estructura multidimensional.
Veamos esto de forma concreta. Crearemos una matriz de 2×3 e inspeccionaremos su estructura interna — la forma, las zancadas, el tipo de dato y el diseño de memoria plana subyacente.
import numpy as np
# Create a 2×3 matrix
x = np.array([[1, 2, 3],
[4, 5, 6]], dtype=np.float32)
print(f"Shape: {x.shape}") # (2, 3)
print(f"Strides: {x.strides}") # (12, 4) — bytes, not elements
print(f"Dtype: {x.dtype}") # float32 (4 bytes per element)
print(f"Size: {x.nbytes} bytes") # 24 bytes total (6 elements × 4 bytes)
# The underlying memory is a flat sequence of bytes
flat = x.tobytes()
print(f"\nRaw memory ({len(flat)} bytes):")
print(f" {[x.flat[i] for i in range(x.size)]}")
print(f"\nThe 2×3 'shape' is just our interpretation of these 6 numbers.")
En PyTorch, el equivalente se ve casi idéntico — pero con una diferencia importante que exploraremos en un momento:
import torch
x = torch.tensor([[1, 2, 3],
[4, 5, 6]], dtype=torch.float32)
print(x.shape) # torch.Size([2, 3])
print(x.stride()) # (3, 1) — in elements, not bytes
print(x.dtype) # torch.float32
print(x.device) # cpu (or cuda:0)
print(x.storage()) # flat storage: [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]
Zancadas: Cómo la Forma se Mapea a la Memoria
Las zancadas (strides) son la cantidad de elementos que hay que saltar en memoria para avanzar un paso a lo largo de cada dimensión. Son la forma en que la biblioteca traduce un índice multidimensional como
[i, j]
en un desplazamiento en la memoria plana:
Aquí,
index[k]
es la posición a lo largo de la dimensión $k$,
stride[k]
es cuántos elementos saltar por paso en esa dimensión, y la suma da la posición en el almacenamiento plano. Para un tensor 2D con zancadas
(3, 1)
, el elemento
[1, 2]
está en el desplazamiento $1 \times 3 + 2 \times 1 = 5$.
Verifiquemos esto con un ejemplo concreto — calcularemos los desplazamientos manualmente y comprobaremos que coincidan con los valores reales del arreglo.
import numpy as np
x = np.array([[10, 20, 30, 40],
[50, 60, 70, 80],
[90, 100, 110, 120]], dtype=np.int32)
print(f"Shape: {x.shape}") # (3, 4)
print(f"Strides: {x.strides}") # (16, 4) — 16 bytes = 4 elements × 4 bytes
# Manual offset calculation (in elements)
strides_elem = (4, 1) # strides in elements for (3, 4) row-major
for i in range(3):
for j in range(4):
offset = i * strides_elem[0] + j * strides_elem[1]
flat_val = x.flat[offset]
assert flat_val == x[i, j], "Mismatch!"
print("All offsets verified!")
print()
# Show the mapping — collect rows for aligned output
indices = [(0,0), (0,3), (1,0), (1,2), (2,3)]
rows = []
for i, j in indices:
offset = i * strides_elem[0] + j * strides_elem[1]
rows.append((i, j, offset, x[i,j]))
w_off = max(len(str(r[2])) for r in rows)
w_val = max(len(str(r[3])) for r in rows)
print("Index → Offset → Value")
for i, j, offset, val in rows:
print(f" [{i},{j}] → offset {offset:>{w_off}} → {val:>{w_val}}")
Por esto
reshape
y
transpose
a veces pueden ser "gratuitos" — solo cambian las zancadas sin tocar los datos. Una matriz de 3×4 y una de 4×3 pueden compartir el mismo bloque de almacenamiento de 12 elementos; solo difieren las zancadas.
Las Convenciones de -1 y None
PyTorch y numpy sobrecargan
-1
y
None
para significar cosas completamente diferentes según el contexto. Esto confunde a casi todos en algún momento, así que desglosemos cada uso explícitamente.
import numpy as np
x = np.arange(12).reshape(3, 4)
print(f"Original: shape {x.shape}")
print(x)
print()
# -1 in reshape: "infer this dimension"
# Total elements = 12. If one dim is 4, the other must be 12/4 = 3
a = x.reshape(-1, 4) # → (3, 4)
b = x.reshape(3, -1) # → (3, 4)
c = x.reshape(-1) # → (12,) — flatten
d = x.reshape(2, -1) # → (2, 6) — 12/2 = 6
print(f"reshape(-1, 4): {a.shape}")
print(f"reshape(3, -1): {b.shape}")
print(f"reshape(-1): {c.shape}")
print(f"reshape(2, -1): {d.shape}")
print()
# -1 in indexing: "last element"
print(f"x[-1] (last row): {x[-1]}")
print(f"x[0, -1] (last col): {x[0, -1]}")
print()
# None in indexing: "add a dimension" (like unsqueeze)
e = x[:, :, None] # (3, 4) → (3, 4, 1)
f = x[None, :, :] # (3, 4) → (1, 3, 4)
g = x[:, None, :] # (3, 4) → (3, 1, 4)
print(f"x[:, :, None]: {e.shape} (added dim at end)")
print(f"x[None, :, :]: {f.shape} (added dim at start)")
print(f"x[:, None, :]: {g.shape} (added dim in middle)")
En PyTorch, estas convenciones se mantienen exactamente, con la adición de
.unsqueeze()
como alternativa explícita a la indexación con
None
:
# In PyTorch:
x = torch.arange(12).reshape(3, 4)
# -1 in reshape/view: same as numpy
x.reshape(-1, 4) # (3, 4)
x.view(2, -1) # (2, 6)
# None in indexing: same as numpy
x[:, :, None] # (3, 4, 1)
# .unsqueeze() is the explicit version:
x.unsqueeze(-1) # (3, 4, 1) — same as x[:, :, None]
x.unsqueeze(0) # (1, 3, 4) — same as x[None, :, :]
view vs reshape
Tanto
.view()
como
.reshape()
cambian la forma de un tensor, pero difieren en un aspecto crítico.
.view()
reinterpreta las zancadas. Requiere que el tensor sea
contiguo
en memoria — los elementos deben estar dispuestos en el orden exacto que implican las zancadas actuales. Si no lo están (por ejemplo, después de una transposición),
view
lanza un error.
.reshape()
intenta hacer un view primero (gratuito, sin copia). Si el tensor no es contiguo, recurre a copiar los datos en un nuevo bloque contiguo y luego aplica view sobre eso. Así que
reshape
es estrictamente más permisivo — siempre funciona, pero puede copiar silenciosamente.
Podemos observar esta distinción en numpy, que tiene el mismo concepto de contigüidad:
import numpy as np
x = np.arange(12).reshape(3, 4)
print(f"Original: shape {x.shape}, strides {x.strides}")
print(f"Contiguous (C-order): {x.flags['C_CONTIGUOUS']}")
print()
# Transpose changes strides but NOT the data
t = x.T # (4, 3)
print(f"Transposed: shape {t.shape}, strides {t.strides}")
print(f"Contiguous (C-order): {t.flags['C_CONTIGUOUS']}")
print()
# reshape on non-contiguous → creates a copy
r = t.reshape(12)
print(f"reshape(12) on transposed: {r}")
print(f" (this required a copy because the data wasn't contiguous)")
print()
# To make contiguous explicitly:
t_contig = np.ascontiguousarray(t)
print(f"After ascontiguousarray: strides {t_contig.strides}")
print(f"Contiguous: {t_contig.flags['C_CONTIGUOUS']}")
En PyTorch, la distinción se hace explícita:
.view()
lanzará un
RuntimeError
si el tensor no es contiguo, mientras que
.reshape()
copia silenciosamente cuando es necesario:
# In PyTorch:
x = torch.arange(12).reshape(3, 4)
t = x.T # shape (4, 3), but NOT contiguous
# This works — reshape copies if needed:
t.reshape(12) # OK
# This fails — view requires contiguity:
t.view(12) # RuntimeError: view size is not compatible with
# input tensor's size and stride
# Fix: make contiguous first
t.contiguous().view(12) # OK
expand vs repeat
Ambos crean un tensor más grande repitiendo datos a lo largo de una dimensión, pero funcionan de maneras fundamentalmente diferentes.
.expand()
usa el
truco de la zancada cero
: establece la zancada en 0 a lo largo de la dimensión expandida, de modo que cada índice apunta a la misma memoria. No se copian datos — el tensor simplemente "finge" ser más grande. La dimensión expandida debe tener actualmente tamaño 1.
.repeat()
copia físicamente los datos, creando un nuevo bloque de almacenamiento más grande. Siempre reserva nueva memoria proporcional al tamaño repetido.
import numpy as np
x = np.array([[1, 2, 3]]) # shape (1, 3)
print(f"Original: {x}, shape {x.shape}")
print()
# broadcast_to is numpy's equivalent of torch.expand
expanded = np.broadcast_to(x, (4, 3))
print(f"Expanded (broadcast_to): shape {expanded.shape}")
print(expanded)
print(f" Strides: {expanded.strides}")
print(f" Note: stride[0] = 0 (every row points to same memory!)")
print(f" Shares memory with original: {np.shares_memory(x, expanded)}")
print()
# np.tile is numpy's equivalent of torch.repeat
tiled = np.tile(x, (4, 1))
print(f"Tiled (np.tile): shape {tiled.shape}")
print(tiled)
print(f" Strides: {tiled.strides}")
print(f" Shares memory with original: {np.shares_memory(x, tiled)}")
print()
print("Key difference:")
print(f" Expanded memory: {expanded.nbytes} bytes (virtual — no extra memory)")
print(f" Tiled memory: {tiled.nbytes} bytes (real copy)")
Los equivalentes en PyTorch se mapean directamente:
x = torch.tensor([[1, 2, 3]]) # (1, 3)
# expand: stride-zero trick, no copy
e = x.expand(4, 3) # (4, 3), stride (0, 1)
e = x.expand(-1, -1) # -1 means "keep this dimension"
# repeat: physical copy
r = x.repeat(4, 1) # (4, 3), new storage
gather y scatter: Acceso Basado en Índices
Cuando necesitas seleccionar elementos específicos de posiciones específicas — no recortar un bloque contiguo, sino elegir entradas individuales — usas
.gather()
(lectura) y
.scatter()
(escritura). Estas operaciones son esenciales en muchos flujos de trabajo de ML: seleccionar predicciones top-k, construir funciones de pérdida personalizadas, o enrutar tokens a expertos en arquitecturas de mezcla de expertos.
Veamos
gather
en acción seleccionando predicciones top-k de una matriz de logits simulada:
import numpy as np
# Simulated logits: 4 samples, 5 classes
np.random.seed(42)
logits = np.round(np.random.randn(4, 5), 2)
print("Logits (4 samples × 5 classes):")
print(logits)
print()
# Top-2 class indices per sample
top_k = 2
indices = np.argsort(logits, axis=1)[:, -top_k:][:, ::-1]
print(f"Top-{top_k} indices per sample:")
print(indices)
print()
# gather: pick the logit values at those indices
# np.take_along_axis is numpy's equivalent of torch.gather
gathered = np.take_along_axis(logits, indices, axis=1)
print(f"Gathered top-{top_k} logit values:")
print(gathered)
print()
# Verify manually for sample 0
sample_0 = logits[0]
idx_0 = indices[0]
print(f"Sample 0 logits: {sample_0}")
print(f"Top-2 indices: {idx_0}")
print(f"Gathered values: {[sample_0[i] for i in idx_0]}")
En PyTorch,
torch.gather
proporciona la misma operación, y su inversa
scatter_
escribe valores de vuelta en posiciones específicas:
# torch.gather(input, dim, index)
gathered = torch.gather(logits, dim=1, index=indices)
# torch.scatter(dim, index, src) — write values to specific positions
output = torch.zeros(4, 5)
output.scatter_(dim=1, index=indices, src=gathered)
Reglas de Difusión (Broadcasting)
La difusión (broadcasting) te permite realizar operaciones entre tensores de diferentes formas sin copiar datos explícitamente. Sigue tres reglas, aplicadas desde la dimensión más a la derecha:
- Regla 1: Si las dimensiones difieren en tamaño, la dimensión de tamaño 1 se estira para coincidir.
- Regla 2: Si un tensor tiene menos dimensiones, se rellena con 1s por la izquierda.
- Regla 3: Las dimensiones deben coincidir o ser 1 — de lo contrario es un error.
Estas reglas son idénticas en numpy y PyTorch. Veamos cada una en acción:
import numpy as np
# Rule 1: dimension of size 1 is stretched
a = np.array([[1], [2], [3]]) # (3, 1)
b = np.array([[10, 20, 30, 40]]) # (1, 4)
result = a + b # (3, 4)
print("(3,1) + (1,4) → (3,4):")
print(f" a = {a.T[0]}")
print(f" b = {b[0]}")
print(result)
print()
# Rule 2: fewer dimensions → pad with 1s on left
c = np.array([10, 20, 30, 40]) # (4,) → treated as (1, 4)
d = np.arange(12).reshape(3, 4) # (3, 4)
result2 = c + d # (3, 4)
print("(4,) + (3,4) → (3,4):")
print(f" (4,) is treated as (1,4), then stretched to (3,4)")
print(result2)
print()
# Rule 3: mismatch → error
e = np.array([1, 2, 3]) # (3,)
f = np.arange(12).reshape(3, 4) # (3, 4)
try:
result3 = e + f # tries (3,) as (1,3) + (3,4) → 3≠4, error!
except ValueError as err:
print(f"(3,) + (3,4) → ERROR:")
print(f" {err}")
print(f" (3,) becomes (1,3). Right dims: 3 vs 4 — no match!")
print(f" Fix: reshape to (3,1) first: e.reshape(3,1) + f works")
La difusión no es simplemente una conveniencia — está profundamente conectada con el truco de la zancada cero que vimos con
.expand()
. Cuando numpy o PyTorch difunde una dimensión de tamaño 1, efectivamente establece la zancada en 0 a lo largo de esa dimensión. Los datos no se copian; la biblioteca simplemente lee el mismo elemento repetidamente. Por eso la difusión es esencialmente gratuita en términos de memoria — es un expand por debajo.
Quiz
Pon a prueba tu comprensión de los internos de los tensores — diseño de memoria, zancadas y las operaciones que los manipulan.
¿Cuáles son los dos componentes de un tensor?
En la fórmula de zancadas offset = Σ index[k] × stride[k], ¿qué representa stride[k]?
¿Qué sucede cuando llamas a .view() en un tensor no contiguo en PyTorch?
¿Cómo evita .expand() copiar datos?
¿Por qué (3,) + (3,4) falla con broadcasting, mientras que (3,1) + (3,4) tiene éxito?