📖 Teoría

Tensores en Deep Learning

Los tensores son la estructura de datos fundamental de todo el deep learning. En esta sección aprenderás qué son, cómo operar con ellos, cómo los frameworks calculan gradientes automáticamente, y por qué GPU y TPU son esenciales.

📐 ¿Qué es un tensor?

La perspectiva matemática

En matemáticas, un tensor es un objeto algebraico que generaliza los conceptos de escalar, vector y matriz a dimensiones arbitrarias. Formalmente, un tensor de rango \(n\) es un array multidimensional de números que transforma de cierta forma bajo cambios de coordenadas.

Definición formal $$T \in \mathbb{R}^{d_1 \times d_2 \times \cdots \times d_n}$$

Donde \(n\) es el rango (o número de ejes/dimensiones) del tensor, y cada \(d_i\) es el tamaño de la dimensión \(i\). Para acceder a un elemento necesitamos \(n\) índices: \(T_{i_1, i_2, \ldots, i_n}\).

📌

Nota sobre terminología: En física y matemáticas puras, «tensor» tiene una definición más estricta (incluyendo propiedades de transformación covariante/contravariante). En deep learning, usamos el término de forma más relajada: un tensor es simplemente un array multidimensional de números con un tipo de dato homogéneo.

La perspectiva del científico de datos

Para un científico de datos o ingeniero de ML, lo importante es esto: absolutamente todo en deep learning son tensores:

  • Las imágenes son tensores de 3 dimensiones (alto × ancho × canales)
  • Un batch de imágenes es un tensor de 4 dimensiones
  • Los pesos de cada capa de tu red neuronal son tensores
  • Los gradientes que calculamos son tensores del mismo shape que los pesos
  • El texto tokenizado se convierte en un tensor antes de entrar al modelo
  • Las señales de audio, series temporales, vídeos... todo son tensores
🔑

Concepto clave: Un tensor tiene tres propiedades fundamentales:

  • Shape (forma): las dimensiones del tensor, ej: (32, 3, 224, 224)
  • Dtype (tipo): el tipo de dato numérico, ej: float32, int64
  • Device (dispositivo): dónde vive en memoria, ej: cpu, cuda:0

🏗️ Rangos de tensores: del escalar al vídeo

El rango de un tensor indica cuántos índices necesitamos para acceder a un elemento. Si un tensor tiene rango 3, necesitamos tres números —por ejemplo \((i, j, k)\)— para localizar cualquier valor dentro de él.

En la práctica del deep learning, el rango de un tensor está directamente ligado al tipo de dato que representamos. Los datos tabulares clásicos suelen ser matrices (rango 2), una imagen en color es un tensor de rango 3, y un batch de imágenes —la unidad de trabajo habitual de una red convolucional— es un tensor de rango 4. A medida que aumenta la complejidad del dato (vídeo, vídeo en batches, secuencias de secuencias…), el rango crece. Comprender esta correspondencia es fundamental: cuando un framework te devuelve un error de dimensiones, lo primero es pensar «¿qué tipo de dato es y cuántos ejes debería tener?».

Veamos cada caso con ejemplos del mundo real:

🌡️
Rango 0 — Escalar
Un solo número
La temperatura actual: 23.5°C. El loss de una epoch. Un learning rate.
shape: ()
📊
Rango 1 — Vector
Lista de números
Predicciones para 10 clases: [0.1, 0.05, 0.7, ...]. El bias de una capa.
shape: (n,)
🔢
Rango 2 — Matriz
Tabla de números
Imagen en escala de grises 28×28. Pesos de una capa densa. Un dataset tabular.
shape: (filas, cols)
🖼️
Rango 3 — Tensor 3D
Cubo de números
Imagen RGB (224×224×3). Serie temporal multivariada. Secuencia de embeddings.
shape: (H, W, C)
📦
Rango 4 — Tensor 4D
Batch de imágenes
32 imágenes RGB de 224×224: el input típico de una CNN.
shape: (B, C, H, W)
🎬
Rango 5 — Tensor 5D
Batch de vídeos
8 vídeos de 30 frames, cada uno 3×224×224. Usado en Video Transformers.
shape: (B, T, C, H, W)

La progresión del escalar al tensor 5D no es caprichosa: refleja cómo crece la complejidad de la información que procesamos. Un escalar es un único dato numérico; un vector agrupa varios datos en una secuencia ordenada; una matriz los organiza en filas y columnas. A partir de rango 3, entramos en el terreno donde el deep learning realmente opera: tensores que codifican datos con estructura espacial, temporal o semántica. El shape de un tensor no es solo un detalle técnico —es la forma en que codificamos la estructura intrínseca de los datos.

Utiliza el explorador interactivo a continuación para ver cómo cambian el shape, el rango, la memoria necesaria y el uso típico según el tipo de dato. Observa especialmente cómo la cantidad de memoria crece exponencialmente: un batch de vídeos puede ocupar cientos de megabytes en un solo tensor.

🧪 Explorador de shapes de tensores

En la práctica, rara vez trabajarás con tensores de rango superior a 5. Las arquitecturas de deep learning suelen añadir como máximo una dimensión de batch al frente y, en algunos casos, dimensiones adicionales para secuencias temporales o cabezas de atención. Si alguna vez te encuentras con un tensor de rango 6 o superior, es probable que puedas reorganizarlo mediante un reshape o un view — operaciones que veremos en la siguiente sección.

⚠️

Orden de dimensiones — ¡cuidado! PyTorch usa (Batch, Canales, Alto, Ancho)channels-first, mientras que TensorFlow/Keras usa por defecto (Batch, Alto, Ancho, Canales)channels-last. Mezclar estos órdenes es una fuente muy común de bugs.

⚙️ Operaciones con tensores

Las operaciones entre tensores son el corazón de todo el deep learning. Cada capa de tu red neuronal ejecuta operaciones tensoriales: desde simples sumas y multiplicaciones elemento a elemento hasta productos matriciales que combinan millones de pesos con los datos de entrada. Dominar estas operaciones —y, sobre todo, entender cómo afectan al shape de los tensores— es la clave para poder construir, depurar y optimizar cualquier modelo.

Vamos a recorrer las operaciones más importantes, desde las más simples hasta las que requieren más cuidado con las dimensiones:

Operaciones elemento a elemento

Se aplican independientemente a cada elemento del tensor. Requieren que ambos tensores tengan el mismo shape (o que sean compatibles por broadcasting, que veremos después).

Elemento a elemento $$(A + B)_{ij} = A_{ij} + B_{ij} \qquad (A \odot B)_{ij} = A_{ij} \cdot B_{ij} \qquad f(A)_{ij} = f(A_{ij})$$

Incluyen: suma, resta, multiplicación (Hadamard \(\odot\)), división, potencia, y cualquier función aplicada elemento a elemento (ReLU, sigmoid, exp, log…).

🧪 Operaciones elemento a elemento

Producto escalar (dot product)

El dot product (producto punto) de dos vectores es la operación más fundamental del álgebra lineal y, por extensión, de las redes neuronales. Es lo que calcula cada neurona:

Dot product $$\mathbf{a} \cdot \mathbf{b} = \sum_{i=1}^{n} a_i \cdot b_i = a_1 b_1 + a_2 b_2 + \cdots + a_n b_n$$

Toma dos vectores del mismo tamaño y produce un escalar. Geométricamente, mide cuánto se «alinean» dos vectores. En una red neuronal, la operación de cada neurona es: output = dot(inputs, weights) + bias —exactamente la operación que estudiamos en detalle en el módulo de Perceptrón y MLP.

Producto matricial (matmul)

La multiplicación de matrices es la generalización del dot product a 2D. Es la operación que domina el coste computacional de toda red neuronal:

Matmul $$C = A \times B \quad \text{donde} \quad C_{ij} = \sum_{k=1}^{K} A_{ik} \cdot B_{kj}$$

Regla clave: Si \(A\) tiene shape \((M, \textcolor{orange}{K})\) y \(B\) tiene shape \((\textcolor{orange}{K}, N)\), el resultado \(C\) tendrá shape \((M, N)\). Las dimensiones internas (\(K\)) deben coincidir y se «contraen» (desaparecen).

🧪 Visualiza la multiplicación matricial

Relación: dot product ↔ matmul

🔗

Conexión importante: El dot product de dos vectores es un caso particular del matmul. Si convertimos el vector \(\mathbf{a}\) en una matriz fila \((1, n)\) y \(\mathbf{b}\) en una columna \((n, 1)\), entonces:

\(\mathbf{a} \cdot \mathbf{b} = \mathbf{a}^T \mathbf{b}\) → resultado \((1, 1)\) = escalar

Y una capa densa con 128 neuronas es un matmul: Y = X @ W + b donde \(X\) es \((B, d_{in})\), \(W\) es \((d_{in}, 128)\), resultado \(Y\) es \((B, 128)\).

Transposición

Intercambia dos ejes de un tensor. Para una matriz, es intercambiar filas y columnas (\(A^T_{ij} = A_{ji}\)). Para tensores de mayor rango, se especifica qué ejes intercambiar con permute() o transpose().

Reshape y view

Cambia la forma (shape) del tensor sin modificar los datos. Los elementos se mantienen en el mismo orden en memoria; solo cambia la «interpretación» de sus dimensiones.

Un tensor con datos [1, 2, 3, 4, 5, 6] (shape (6,)) se puede reinterpretar como:

  • reshape(2, 3) → \(\begin{bmatrix} 1 & 2 & 3 \\ 4 & 5 & 6 \end{bmatrix}\)
  • reshape(3, 2) → \(\begin{bmatrix} 1 & 2 \\ 3 & 4 \\ 5 & 6 \end{bmatrix}\)
  • reshape(6, 1) → vector columna
  • reshape(1, 6) → vector fila
  • reshape(1, 2, 3) → tensor 3D (un «batch» de una matriz 2×3)

Regla: el producto de las nuevas dimensiones debe ser igual al número total de elementos. \(2 \times 3 = 6 \checkmark\), \(2 \times 4 = 8 \neq 6\) ✗.

En PyTorch: x.view(2, 3) o x.reshape(2, 3). En TensorFlow: tf.reshape(x, [2, 3]).

Concatenación y stacking

Concatenar une tensores a lo largo de un eje existente. Apilar (stack) los une creando un eje nuevo.

Concatenación (mismo rango, une a lo largo de un eje):

Si \(A\) tiene shape (3, 4) y \(B\) tiene shape (3, 4):

  • concat([A, B], axis=0) → shape (6, 4) — une por filas
  • concat([A, B], axis=1) → shape (3, 8) — une por columnas

Regla: todas las dimensiones excepto la del eje de concatenación deben coincidir.

Stacking (crea un nuevo eje):

Si \(A\) y \(B\) tienen shape (3, 4):

  • stack([A, B], axis=0) → shape (2, 3, 4) — nuevo eje delante
  • stack([A, B], axis=1) → shape (3, 2, 4) — nuevo eje en medio

Regla: todos los tensores deben tener exactamente el mismo shape.

Padding

Añade valores (típicamente ceros) alrededor de un tensor. Es fundamental en CNNs para controlar el tamaño de salida de las convoluciones, y en Transformers para igualar la longitud de secuencias en un batch:

  • Zero padding: rellena con ceros. El más común en CNNs y NLP.
  • Reflect padding: rellena reflejando los valores del borde. Útil en imágenes para evitar artefactos.
  • Replicate padding: repite el valor del borde. Usado en procesamiento de señales.
  • Circular padding: trata el tensor como si fuera periódico.

En CNNs: Conv2d(padding=1) con un kernel 3×3 mantiene el tamaño espacial.

En NLP: pad_sequence(sequences, padding_value=0) iguala las longitudes de las frases de un batch.

Slicing e indexación

Extraer subconjuntos de un tensor. Funciona igual que el slicing de NumPy: tensor[start:end, :, index]. Es una de las operaciones más usadas a diario.

Reducción

Operaciones que «colapsan» una o más dimensiones: sum, mean, max, min, argmax. Siempre hay que especificar sobre qué eje(s) se reduce.

🤔 ¿Qué pasa con el shape al reducir? → Click para ver

Si \(A\) tiene shape (3, 4, 5):

  • A.sum(axis=0) → shape (4, 5) — colapsó el eje 0
  • A.sum(axis=1) → shape (3, 5) — colapsó el eje 1
  • A.sum(axis=(0,2)) → shape (4,) — colapsó ejes 0 y 2
  • A.sum() → escalar — colapsó todo
  • A.sum(axis=1, keepdims=True) → shape (3, 1, 5) — mantiene el eje como 1

keepdims=True es útil para luego hacer broadcasting sin problemas.

📏 Reglas de compatibilidad de dimensiones

Una de las partes más confusas al empezar con tensores es entender cuándo dos tensores son compatibles para una operación. Estas son las reglas clave:

Regla para operaciones elemento a elemento

Dos tensores son compatibles para operaciones elemento a elemento si tienen exactamente el mismo shape... o si se puede aplicar broadcasting.

Broadcasting

Broadcasting es un mecanismo que permite operar con tensores de shapes diferentes sin copiar datos. Las reglas son:

  1. Se comparan los shapes de derecha a izquierda, dimensión por dimensión.
  2. Dos dimensiones son compatibles si son iguales, o si una de ellas es 1.
  3. Si un tensor tiene menos dimensiones, se le añaden 1s por la izquierda.

Un ejemplo clásico: si tienes un tensor de imágenes con shape (32, 3, 224, 224) y quieres normalizar cada canal restando su media, solo necesitas un vector de medias con shape (3, 1, 1). Broadcasting se encarga de «expandir» ese vector para que se aplique a cada imagen del batch y a cada píxel, sin crear copias en memoria. Esta eficiencia es lo que hace posible escribir código limpio y a la vez rápido.

Prueba la herramienta a continuación para experimentar con diferentes combinaciones de shapes y entender cuándo el broadcasting es posible y cuándo no:

🧪 Comprueba broadcasting

Regla para matmul

OperaciónShape AShape BReglaShape resultado
Dot product(n,)(n,)Mismo tamañoEscalar
Mat-vec(M, K)(K,)Cols A = tamaño B(M,)
Matmul 2D(M, K)(K, N)Cols A = Filas B(M, N)
Batched matmul(B, M, K)(B, K, N)Batch dims iguales + matmul rule(B, M, N)

Regla para concatenación

Todas las dimensiones deben coincidir excepto la del eje de concatenación. Ejemplo: para concatenar por eje 0, las dimensiones 1, 2, 3... deben ser idénticas.

Consejo práctico: cuando tu código da un error de shapes, imprime el .shape de todos los tensores involucrados. El 90% de los bugs en deep learning son errores de dimensiones, y se resuelven mirando los shapes.

🛠️

Herramienta interactiva: prueba a crear tensores de diferentes rangos, aplicar operaciones y visualizar los resultados en el Explorador de Tensores. Experimenta con sumas, productos matriciales, broadcasting y concatenación para afianzar lo aprendido en esta sección.

Gradientes de tensores

¿Qué es un gradiente?

El gradiente de una función escalar \(\mathcal{L}\) respecto a un tensor de parámetros \(\theta\) es un tensor del mismo shape que \(\theta\) donde cada elemento indica cuánto cambiaría \(\mathcal{L}\) si cambiásemos ese parámetro en particular:

Gradiente $$\nabla_\theta \mathcal{L} = \frac{\partial \mathcal{L}}{\partial \theta} = \begin{bmatrix} \frac{\partial \mathcal{L}}{\partial \theta_1} & \frac{\partial \mathcal{L}}{\partial \theta_2} & \cdots & \frac{\partial \mathcal{L}}{\partial \theta_n} \end{bmatrix}$$
💡

Intuición: el gradiente es un «mapa de sensibilidad». Para cada peso de tu red, te dice: «si aumentas este peso un poquito, ¿cuánto sube o baja el loss?». Si la derivada parcial es positiva, aumentar el peso aumenta el loss → debemos reducirlo. Si es negativa, lo contrario.

¿Por qué son necesarios en deep learning?

Todo el entrenamiento de redes neuronales se basa en el gradiente. El algoritmo es:

  1. Forward pass: calcula la predicción y el loss
  2. Backward pass: calcula \(\nabla_\theta \mathcal{L}\) para todos los parámetros
  3. Update: ajusta cada peso en la dirección que reduce el loss: \(\theta \leftarrow \theta - \eta \nabla_\theta \mathcal{L}\)

Este proceso de actualización iterativa de los pesos es lo que conocemos como descenso del gradiente, y lo estudiamos en profundidad en su propio módulo. Aquí nos centraremos en cómo los frameworks calculan esos gradientes de forma automática.

Un modelo como GPT-4 tiene cientos de miles de millones de parámetros. Calcular la derivada parcial de cada uno a mano sería imposible. Por eso necesitamos la diferenciación automática.

Diferenciación automática (Autograd)

Los frameworks de deep learning implementan diferenciación automática (autodiff): registran cada operación que se aplica a los tensores en un grafo computacional, y luego recorren este grafo «hacia atrás» (backpropagation) aplicando la regla de la cadena para calcular todos los gradientes automáticamente.

Regla de la cadena $$\frac{\partial \mathcal{L}}{\partial x} = \frac{\partial \mathcal{L}}{\partial y} \cdot \frac{\partial y}{\partial x}$$

Esto se aplica recursivamente: si \(\mathcal{L}\) depende de \(z\), que depende de \(y\), que depende de \(x\):

Cadena completa $$\frac{\partial \mathcal{L}}{\partial x} = \frac{\partial \mathcal{L}}{\partial z} \cdot \frac{\partial z}{\partial y} \cdot \frac{\partial y}{\partial x}$$

🧪 Diferenciación automática paso a paso

Observa cómo se construye el grafo computacional y cómo se propagan los gradientes «hacia atrás»:

Tensores con requires_grad

En PyTorch, para que un tensor participe en autograd, se marca con requires_grad=True. Solo los tensores «hoja» (que creamos nosotros) almacenan gradientes; los intermedios se calculan al vuelo.

import torch

# Crear tensores con requires_grad
x = torch.tensor([2.0, 3.0], requires_grad=True)
w = torch.tensor([0.5, -1.0], requires_grad=True)

# Forward pass
y = (x * w).sum()       # dot product = 2*0.5 + 3*(-1) = -2
loss = y ** 2            # loss = (-2)² = 4

# Backward pass — calcula TODOS los gradientes automáticamente
loss.backward()

print(x.grad)   # tensor([-2., 4.])  → ∂loss/∂x
print(w.grad)   # tensor([-8., -12.]) → ∂loss/∂w

# Verificación: ∂loss/∂x₁ = 2y · w₁ = 2(-2)(0.5) = -2 ✓
import tensorflow as tf

# En TensorFlow se usa GradientTape como contexto
x = tf.Variable([2.0, 3.0])
w = tf.Variable([0.5, -1.0])

with tf.GradientTape() as tape:
    y = tf.reduce_sum(x * w)   # dot product
    loss = y ** 2

# Calcula gradientes respecto a x y w
grads = tape.gradient(loss, [x, w])

print(grads[0])  # tf.Tensor([-2.  4.], ...)  → ∂loss/∂x
print(grads[1])  # tf.Tensor([-8. -12.], ...) → ∂loss/∂w

# GradientTape graba las operaciones; .gradient() recorre el grafo

🧰 Frameworks de deep learning

Hasta ahora hemos hablado de tensores y sus operaciones de forma abstracta. En la práctica, necesitas un framework de software que implemente estas operaciones de forma eficiente, que las ejecute en GPU, y que sea capaz de calcular gradientes automáticamente para entrenar modelos con millones de parámetros. En esta sección veremos por qué los frameworks son imprescindibles, qué aportan exactamente, y cómo se compara el enfoque «manual» con el que ofrecen herramientas como PyTorch o TensorFlow.

¿Por qué necesitamos frameworks?

En teoría, podrías implementar una red neuronal con Python puro y NumPy. Pero en la práctica, un framework te proporciona:

  • Tensores optimizados con operaciones que corren en GPU/TPU, no solo en CPU
  • Diferenciación automática (autograd): no tienes que derivar a mano ni escribir el backward pass
  • Capas predefinidas: Conv2d, LSTM, MultiheadAttention, etc. — código probado y optimizado
  • Optimizadores: SGD, Adam, AdamW... solo eliges y configuras
  • Data loaders: carga y batching eficiente de datasets enormes
  • Ecosistema: modelos pretrained, transfer learning, herramientas de debugging, visualización

Sin framework: implementar backpropagation para un Transformer manualmente requeriría miles de líneas de derivadas parciales y sería extremadamente propenso a errores. Con PyTorch, son literalmente 2 líneas: loss.backward() + optimizer.step().

Lo que hace un framework por ti

Sin framework (NumPy)Con framework
Calculas gradientes a manoloss.backward() calcula todo automáticamente
Solo CPU.to('cuda') y se ejecuta en GPU
Escribes cada capa desde ceronn.Linear(784, 128)
Implementas tu propio SGDoptim.Adam(params, lr=3e-4)
Gestionas batches manualmenteDataLoader(dataset, batch_size=32)
Debug = sufrimientoHooks, profilers, TensorBoard, etc.

Red neuronal: NumPy puro vs PyTorch

En NumPy (manual):

  • Forward: multiplicas manualmente matrices, aplicas activaciones
  • Loss: calculas MSE o cross-entropy
  • Backward: derivas cada capa con regla de la cadena
  • Update: restas η × gradiente a cada peso
  • → ~100 líneas para un MLP de 2 capas, fácil equivocarse

En PyTorch:

  • Defines: model = nn.Sequential(nn.Linear(784,128), nn.ReLU(), nn.Linear(128,10))
  • Forward: output = model(x)
  • Loss: loss = criterion(output, target)
  • Backward: loss.backward()
  • Update: optimizer.step()
  • → ~10 líneas. Y funciona en GPU, con cualquier arquitectura.

El framework no solo ahorra código: elimina fuentes de error y permite escalar a modelos con billones de parámetros.

🚀 Computación eficiente: GPU, TPU y más

¿Por qué la CPU no basta?

Las operaciones de deep learning son masivamente paralelas: una multiplicación de matrices \((1024 \times 1024) \times (1024 \times 1024)\) implica más de mil millones de operaciones de multiplicación-suma. Una CPU moderna tiene 8-32 cores; una GPU tiene miles.

Dato clave: Una NVIDIA A100 puede ejecutar hasta 312 TFLOPS (trillones de operaciones de coma flotante por segundo) en FP16. Una CPU de alta gama alcanza ~1-2 TFLOPS. Eso es más de 150× más rápido en las operaciones que dominan el deep learning.

Tipos de aceleradores

HardwareFabricanteCoresUso principalFramework principal
CPUIntel, AMD8-128Preprocesamiento, modelos pequeñosTodos
GPU (CUDA)NVIDIA~10,000+Training & inference generalPyTorch, TF
TPUGoogleSystolic arrayTraining a escala masivaJAX, TF
Apple SiliconAppleGPU + Neural EngineOn-device ML, desarrollo localPyTorch (MPS)
GPU (ROCm)AMD~10,000+Alternativa a NVIDIAPyTorch

¿Cómo funciona la computación en GPU?

La idea clave es el paralelismo SIMT (Single Instruction, Multiple Threads): la misma operación se ejecuta simultáneamente sobre miles de elementos del tensor. Cuando haces C = A @ B en GPU:

  1. Los tensores \(A\) y \(B\) se transfieren a la memoria de la GPU (VRAM)
  2. La GPU lanza miles de threads, cada uno calculando una parte de \(C\)
  3. Se usan bibliotecas optimizadas como cuBLAS, cuDNN y Tensor Cores
  4. El resultado \(C\) queda en VRAM, listo para la siguiente operación

El paso 4 es crucial: una vez que los datos están en la GPU, las operaciones sucesivas no necesitan transferirlos de vuelta a la CPU. Esto es lo que hace que mantener tensores en el mismo device sea tan importante. Cada transferencia CPU↔GPU tiene una latencia significativa, y un error frecuente en principiantes es mover datos innecesariamente entre dispositivos en cada iteración del bucle de entrenamiento.

Los modelos modernos usan mixed precision: calculan el forward/backward en FP16 (media precisión, 16 bits) pero mantienen una copia de los pesos en FP32 para las actualizaciones.

Ventajas:

  • ×2 velocidad en los Tensor Cores de NVIDIA
  • ×2 menos memoria VRAM → puedes usar batch sizes más grandes
  • La calidad del modelo es prácticamente idéntica

En PyTorch: torch.cuda.amp.autocast() + GradScaler()

En TensorFlow: tf.keras.mixed_precision.set_global_policy('mixed_float16')

Mover tensores entre dispositivos

# Comprobar si hay GPU
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

# Crear tensor directamente en GPU
x = torch.randn(3, 4, device=device)

# Mover modelo y datos a GPU
model = model.to(device)
inputs = inputs.to(device)

# ¡OJO! Ambos tensores deben estar en el mismo device
# x_cpu + x_gpu → RuntimeError
# TF detecta y usa la GPU automáticamente
print(tf.config.list_physical_devices('GPU'))

# Forzar un device específico
with tf.device('/GPU:0'):
    x = tf.random.normal([3, 4])
    y = tf.matmul(x, tf.transpose(x))

# Para multi-GPU: tf.distribute.MirroredStrategy

⚔️ PyTorch vs TensorFlow

Breve historia

La evolución de los frameworks de deep learning es una historia de compromiso entre expresividad (facilidad para experimentar) y eficiencia (velocidad de ejecución). Los primeros frameworks apostaron por grafos estáticos: el usuario definía la arquitectura completa antes de ejecutarla, lo que permitía optimizaciones agresivas pero hacía el debugging muy difícil. La aparición de PyTorch popularizó los grafos dinámicos, y desde entonces la tendencia general ha sido combinar la flexibilidad de la ejecución dinámica con la velocidad de la compilación.

2007
Theano (Universidad de Montreal)
Primer framework de deep learning, desarrollado en el laboratorio de Yoshua Bengio. Grafos computacionales estáticos, compilación simbólica. Revolucionario pero lento de iterar. Sentó las bases conceptuales de todos los frameworks posteriores.
2015
TensorFlow 1.x (Google Brain)
Hereda la filosofía de grafos estáticos de Theano. Define el grafo primero, ejecuta después («define and run»). Potente pero verboso y difícil de depurar.
2015
Keras (François Chollet)
API de alto nivel que simplificaba TF/Theano. Popularizó el deep learning entre no-expertos. Eventualmente integrado en TensorFlow como su API principal.
2016
PyTorch 0.1 (Facebook/Meta AI)
Basado en Torch (Lua). Grafos dinámicos: «define by run». Debugging natural con Python. La comunidad investigadora lo adoptó rápidamente gracias a su API pythónica e intuitiva.
2019
Gran reescritura: Eager execution por defecto (como PyTorch), Keras como API principal, @tf.function para compilar cuando necesitas velocidad.
2020+
JAX (Google Research)
Aceleración de NumPy con autograd + JIT compilation + vmap. Usado por DeepMind para AlphaFold y Gemini. No es un framework completo, pero muy potente para investigación.
2023+
PyTorch 2.x + torch.compile
Compilación JIT transparente. Hasta 2× speedup sin cambiar código. TorchDynamo, Triton. PyTorch domina tanto investigación como producción.

Comparación directa

A día de hoy, PyTorch y TensorFlow+Keras son los dos ecosistemas dominantes, y ambos son opciones excelentes. Sin embargo, han convergido desde filosofías opuestas: PyTorch nació como una herramienta de investigación centrada en la flexibilidad y el debugging, mientras que TensorFlow se diseñó como una plataforma de producción end-to-end. Con el tiempo, PyTorch ha añadido herramientas de deployment (torch.compile, TorchServe, ExecuTorch) y TensorFlow ha adoptado la ejecución dinámica (eager mode). El resultado es que hoy se parecen más que nunca, pero cada uno conserva sus fortalezas históricas.

Las siguientes tarjetas resumen los puntos fuertes de cada framework:

PyTorch
«De investigación a producción»
  • Grafos dinámicos nativos — debug con pdb/print
  • API Pythonica e intuitiva
  • Domina investigación (~85% de papers en 2025)
  • torch.compile para velocidad de producción
  • Ecosistema: HuggingFace, Lightning, torchvision
  • Soporte CUDA excepcional
  • TorchScript / ONNX para deployment
TensorFlow + Keras
«Machine Learning para todos»
  • Keras: API de alto nivel, ideal para empezar
  • TF Serving: deployment en producción maduro
  • TFLite: modelos en móviles y edge
  • TPU: soporte nativo (Google Cloud)
  • TensorBoard: visualización integrada
  • SavedModel: formato portable
  • Ecosystem: TFX, TF Hub, TF.js

Más allá de las características individuales, la diferencia más significativa en 2025 es el ecosistema. La inmensa mayoría de los modelos punteros de Hugging Face —incluyendo LLMs como LLaMA, Mistral o Gemma— se publican primero (y a veces exclusivamente) en PyTorch. Por otro lado, TensorFlow sigue siendo muy fuerte en despliegue en producción, especialmente en el ecosistema de Google Cloud, y TFLite es la opción más madura para modelos en dispositivos móviles y edge.

La siguiente tabla resume las diferencias clave punto por punto:

AspectoPyTorchTensorFlow / Keras
Filosofía«Python-first», investigación«End-to-end platform»
EjecuciónEager (dinámico)Eager + @tf.function
DebuggingPython nativo (pdb, print)Eager OK; @tf.function más difícil
InvestigaciónDominante (~85% papers)Minoritario pero activo
IndustriaCreciendo rápido (Meta, OpenAI)Fuerte (Google, muchas empresas)
Mobile/EdgePyTorch Mobile, ExecuTorchTFLite (más maduro)
Curva de aprendizajeMedia (más control manual)Baja con Keras, alta con TF bajo nivel
GPUCUDA (NVIDIA), ROCm (AMD), MPS (Apple)CUDA, TPU (nativo), ROCm
Comunidad (2025)~82K stars GitHub~186K stars GitHub
🎯

¿Cuál elegir?

  • Investigación / estado del arte: PyTorch (la mayoría de papers, HuggingFace)
  • Aprender rápido / prototipar: Keras (con TF backend)
  • Producción en Google Cloud / TPU: TF o JAX
  • Modelos en móviles: TFLite o PyTorch Mobile
  • En general para un científico de datos: aprende PyTorch primero, Keras como complemento

De la teoría a la práctica: tutoriales paso a paso

Hasta aquí hemos cubierto la teoría detrás de los tensores, las operaciones, los gradientes y las diferencias entre frameworks. Pero la verdadera comprensión solo llega cuando escribes código. Los dos tutoriales que encontrarás a continuación te guían paso a paso en la construcción de tu primer modelo con cada framework: desde la creación de tensores y la definición de capas, hasta el entrenamiento completo con backpropagation.

Te recomendamos empezar por el que más te interese —o hacer ambos para comparar—. Cada tutorial es autocontenido y asume que has leído esta sección de teoría. Al terminarlos, tendrás las herramientas necesarias para abordar los módulos más avanzados de esta plataforma, como redes neuronales (Perceptrón y MLP) o descenso del gradiente.

🚀

Pon en práctica lo aprendido

Construye tu primer MLP paso a paso con cada framework:

Crear un tensor: 3 frameworks

NumPy:

import numpy as np
x = np.array([[1, 2, 3], [4, 5, 6]], dtype=np.float32)
x.shape # (2, 3)

PyTorch:

import torch
x = torch.tensor([[1, 2, 3], [4, 5, 6]], dtype=torch.float32)
x = torch.randn(2, 3) # aleatorio normal
x = torch.zeros(2, 3) # todo ceros
x = x.to('cuda') # mover a GPU

TensorFlow:

import tensorflow as tf
x = tf.constant([[1, 2, 3], [4, 5, 6]], dtype=tf.float32)
x = tf.random.normal([2, 3])
x = tf.zeros([2, 3])
# GPU automático si disponible

La API es casi idéntica entre los tres. Aprender uno hace facilísimo usar los demás.