TensorFlow · Referencia comparativa (única página)
TensorFlow, en contraste con lo que ya sabés de PyTorch
Esta es la única página de TensorFlow del curso, a propósito: no para aprenderlo con la misma profundidad, sino para poder leer código TF/Keras cuando aparezca (y va a aparecer) sin sentirte perdido, entendiendo qué decisión de diseño reemplaza a cuál de PyTorch.
Antes de leerPyTorch 3 §2, requires_grad · PyTorch 4 §4, torch.compile
🎯 Al terminar vas a poder
- Explicar con tus palabras por qué TensorFlow separa
tf.Tensor(inmutable) detf.Variable(mutable), y reconocer cuál corresponde a cada cosa en código ajeno. - Leer un bloque
with tf.GradientTape()y traducirlo mentalmente arequires_grad+loss.backward(), incluyendo por qué un gradiente puede venirNone. - Predecir cuándo una función con
@tf.functionse vuelve a tracear (y por qué unprintadentro sale una sola vez). - Traducir las siete operaciones básicas (crear tensor, parámetro, gradiente, modelo, loop, guardar, compilar) de PyTorch a TensorFlow/Keras y viceversa con la tabla de §5.
- Reconocer un modelo Keras 3 y saber que hoy puede correr sobre PyTorch o JAX sin cambiar una línea, y reconocer el estilo funcional de JAX cuando aparezca.
1. tf.Tensor es inmutable; tf.Variable existe para lo que cambia
Primera diferencia real de diseño: en PyTorch, un mismo torch.Tensor puede ser constante o
mutable (con requires_grad y operaciones in-place). En TensorFlow, la distinción es de
tipo, no de flag: tf.Tensor es inmutable siempre — para pesos de modelo o
cualquier estado que cambie con el tiempo, hace falta tf.Variable:
x = tf.constant([[1., 2., 3.]]) # inmutable
x[0, 0] = 9. # TypeError: 'EagerTensor' object does not support item assignment
y = x + 1 # cada operación devuelve un tensor NUEVO; x sigue igual
var = tf.Variable([0.0, 0.0, 0.0])
var.assign([1, 2, 3]) # mutación explícita, método propio
var.assign_add([1, 1, 1]) # → [2, 3, 4]
var + 1 # las operaciones sobre una Variable devuelven… un tf.Tensor
Ese último renglón es el que confunde al que viene de PyTorch: var + 1 no es una Variable, es
un Tensor inmutable. La Variable es un contenedor con estado; todo lo que se calcula a partir de
ella es un valor. Por eso en un training loop de TF los pesos se actualizan con
var.assign_sub(lr * grad) (o vía el optimizer), nunca con var = var - lr * grad,
que reemplazaría la Variable por un Tensor y rompería el modelo. En PyTorch el equivalente in-place es
w.sub_(lr * w.grad) dentro de torch.no_grad().
Ni mejor ni peor que la unificación de PyTorch (un solo tipo Tensor para todo) — es
simplemente una decisión de diseño distinta: separar el tipo por su mutabilidad en vez de por un flag
sobre el mismo tipo. La ventaja práctica de la versión de TF es que un grafo compilado (§3) sabe de
antemano qué es estado y qué es valor; la ventaja de PyTorch es que nunca tenés que preguntarte de qué
tipo es lo que tenés en la mano.
2. GradientTape: autograd explícito, no implícito
En PyTorch, cualquier operación sobre un tensor con requires_grad=True queda registrada
automáticamente en el grafo. TensorFlow lo hace explícito con un context manager:
x = tf.Variable(1.0)
with tf.GradientTape() as tape:
y = x**2 + 2*x - 5
g_x = tape.gradient(y, x) # dy/dx = 2x + 2 → 4.0 en x=1
Es el mismo concepto que loss.backward() de PyTorch (regla de la cadena sobre un grafo
registrado durante el forward), con una diferencia de superficie: en TF, el alcance de qué se registra
está delimitado explícitamente por el bloque with; en PyTorch, está delimitado por el flag
requires_grad de cada tensor. Misma idea, distinto mecanismo de scoping.
Tres reglas del tape que explican casi todos los None inesperados. Uno: el tape sigue
automáticamente a las tf.Variable entrenables, pero no a los tf.Tensor — para
derivar respecto de una constante hay que decirle tape.watch(c) (el equivalente de
requires_grad_(True)). Dos: solo se registra lo que pasa adentro del
with; un valor calculado antes de entrar es, para el tape, una constante. Tres: el tape se
consume al primer tape.gradient; para pedir dos gradientes del mismo forward hace falta
GradientTape(persistent=True), que es el retain_graph=True de PyTorch.
c = tf.constant(1.0)
with tf.GradientTape() as tape:
y = c**2
tape.gradient(y, c) # None: c es un Tensor, el tape no lo siguió
with tf.GradientTape() as tape:
tape.watch(c); y = c**2
tape.gradient(y, c) # 2.0
El loop de entrenamiento manual, que en PyTorch viste como zero_grad → backward → step, en
TF es tape → gradient → apply_gradients. No hay zero_grad: como el gradiente es
un valor que devuelve tape.gradient y no un atributo que se acumula en el parámetro, no hay
nada que limpiar:
GradientTape es traducir: el with es el requires_grad, tape.gradient es backward más leer .grad, y apply_gradients es step. La diferencia real es que TF devuelve el gradiente como valor y PyTorch lo acumula en el parámetro, y de ahí sale que uno tenga zero_grad y el otro no.with tf.GradientTape() as tape:
loss = loss_fn(model(xb), yb)
grads = tape.gradient(loss, model.trainable_variables) # una lista, alineada con las variables
opt.apply_gradients(zip(grads, model.trainable_variables)) # el optimizer hace los assign_sub
3. tf.function: el grafo estático, pero opcional
Acá está la diferencia histórica más importante, ya suavizada. El decorador @tf.function
convierte una función Python en un grafo TensorFlow optimizado la primera vez que se ejecuta
("tracing"), y las llamadas siguientes reusan ese grafo compilado en vez de reinterpretar Python:
@tf.function
def my_func(x):
print('Tracing.') # solo se imprime la PRIMERA vez con cada shape/dtype nuevo
return tf.reduce_sum(x)
my_func(tf.constant([1., 2.])) # imprime "Tracing." y devuelve 3.0
my_func(tf.constant([3., 4.])) # mismo shape y dtype: reusa el grafo, no imprime nada
my_func(tf.constant([1., 2., 3.])) # shape nuevo: "Tracing." otra vez, grafo nuevo
my_func(tf.constant([1, 2])) # dtype nuevo (int32): "Tracing." de nuevo
Lo que pasa en el tracing es que TF ejecuta la función una vez con tensores simbólicos, grabando
cada operación de TF que ve — y todo lo que es Python puro (un print, un
append a una lista, un if sobre un valor de Python) corre en ese momento y queda
fuera del grafo. Por eso el print sale una vez por trace y no una vez por llamada; si querés
imprimir desde adentro del grafo, la operación es tf.print. Y por eso el grafo se indexa por
la "firma" de la entrada: shape y dtype de cada tensor, y el valor de cada argumento que sea un
escalar de Python (pasarle n=1, n=2, n=3 son tres traces; pasarle
tf.constant(n) es uno). Cuando los shapes varían de verdad — secuencias de largo variable —
se declara una firma con dimensiones libres, input_signature=[tf.TensorSpec([None], tf.float32)],
o se usa reduce_retracing=True para que TF generalice sola.
print que aparece una vez, y el retracing constante cuando cada llamada trae un shape distinto.
Es, conceptualmente, el ancestro directo de torch.compile — capturar un grafo optimizado a
partir de código Python normal. La diferencia clave es cuándo se volvió opcional: en TensorFlow,
tf.function existe desde TF 2.0 (2019) como una optimización que hay que agregar a mano,
función por función. En PyTorch, torch.compile llegó después (2023) pero se aplicó de forma
mucho más transparente sobre modelos ya escritos en estilo eager puro, sin decorar nada dentro del
modelo mismo. Los dos comparten el mismo dolor, eso sí: el re-tracing (TF) o la recompilación (PyTorch,
que lo llama "recompile" y también tiene un límite de cuántas tolera) cuando los shapes cambian. Lo que
aprendiste sobre dynamic=True y shapes estables para torch.compile en
la página de producción de PyTorch
aplica tal cual acá.
📐 Compilación de grafos con XLA (HLO), fusión de kernels y polimorfismo dinámico
Cuando una función decorada con @tf.function o un modelo compilado con jit_compile=True se ejecuta, TensorFlow invoca el compilador XLA (Accelerated Linear Algebra). XLA no ejecuta las operaciones individualmente; en su lugar, transforma el grafo de cómputo a una representación intermedia de alto nivel denominada HLO (High-Level Optimizer).
A través del grafo HLO, el compilador realiza optimizaciones algebraicas globales:
- Fusión de operadores (Kernel Fusion): en lugar de emitir llamadas independientes a memoria global para operaciones sucesivas (por ejemplo, una multiplicación matricial seguida de un bias add y una activación ReLU: ), XLA emite un único kernel GPU fusionado en PTX/LLVM, conservando las activaciones intermedias directamente en los registros del procesador.
- Planificación estática de buffers: analiza el tiempo de vida (liveness) de cada tensor para reutilizar ubicaciones de memoria en VRAM, reduciendo el consumo pico de memoria durante el entrenamiento.
- Eliminación de dead-code y propagación de constantes: poda ramas condicionales que dependen de dimensiones estáticas conocidas en tiempo de compilación.
La desventaja estructural de XLA frente a PyTorch radica en su aversión al polimorfismo de dimensiones: si una dimensión varía (por ejemplo, longitud de secuencia variable sin padding homogéneo), XLA invalida el código de máquina generado y dispara una costosa recompilación en tiempo de ejecución (compilation bubble), requiriendo el uso mandatorio de input_signature=[tf.TensorSpec(shape=[None, ...], dtype=...)].
4. Keras: la razón por la que TensorFlow sigue siendo fácil
Keras es la capa de alto nivel que hace que gran parte del código TensorFlow real se vea casi como pseudocódigo:
model = tf.keras.Sequential([
tf.keras.layers.Flatten(input_shape=(28, 28)),
tf.keras.layers.Dense(128, activation='relu'),
tf.keras.layers.Dropout(0.2),
tf.keras.layers.Dense(10)
])
model.compile(optimizer='adam', loss=loss_fn, metrics=['accuracy'])
model.fit(x_train, y_train, epochs=5)
model.evaluate(x_test, y_test)
No hay training loop manual acá — compile() + fit() lo esconden por completo.
Es el equivalente funcional de Learner.fit() en fastai/miniai o del
Trainer de HF: una capa de alto nivel sobre el mismo mecanismo de gradientes que viste
expuesto a mano en la página de
autograd de PyTorch. La guía oficial de TensorFlow, de hecho, primero muestra el loop manual con
GradientTape (para enseñar el mecanismo) y después dice explícitamente: "considerá usar
Model.compile/Model.fit antes de escribir el tuyo propio" — el mismo consejo
pedagógico que fast.ai dio en su Lección 5 sobre usar un framework en vez de reescribir SGD a mano en
cada proyecto.
Vale la pena entender qué hace compile(), porque no compila nada en el sentido de §3: fija
el optimizer, la loss y las métricas, y arma el train_step (el bloque de GradientTape
de §2) que fit() va a envolver en un tf.function. Es la configuración del loop,
no la compilación del modelo. (Existe un jit_compile=True en compile() que sí
manda el grafo a XLA; ese es el equivalente honesto de torch.compile.)
🟢 Keras 3 (2023): multi-backend, y el mismo modelo corre sobre PyTorch
El snippet de arriba, con tf.keras y el input_shape en la primera capa, es la
forma de Keras 2. En noviembre de 2023 salió Keras 3, reescrito como librería
independiente que corre sobre JAX, TensorFlow o PyTorch a elección — el backend se
fija con la variable de entorno KERAS_BACKEND antes del import. La forma
actual de escribir el mismo modelo:
# KERAS_BACKEND=torch python entrenar.py (o "jax", o "tensorflow")
import keras
model = keras.Sequential([
keras.Input(shape=(28, 28)), # la entrada se declara aparte; input_shape= en una capa está deprecado
keras.layers.Flatten(),
keras.layers.Dense(128, activation='relu'),
keras.layers.Dropout(0.2),
keras.layers.Dense(10),
])
model.compile(optimizer='adam',
loss=keras.losses.SparseCategoricalCrossentropy(from_logits=True),
metrics=['accuracy'])
model.fit(x_train, y_train, epochs=5)
model(x_test[:4]) # con backend torch devuelve un torch.Tensor; con jax, un jax Array
Con KERAS_BACKEND=torch, model.fit corre autograd de PyTorch por debajo, un
keras.Model es también un torch.nn.Module y podés mezclarlo con tu propio
loop de PyTorch. Con jax, el mismo archivo se compila con XLA. En TensorFlow 2.16 y
posteriores, tf.keras es Keras 3 (la versión vieja sobrevive como el paquete
tf_keras). Consecuencia práctica para vos: "código Keras" ya no significa "código
TensorFlow" — hay que mirar el backend antes de asumir qué tensor hay abajo.
5. Tabla de equivalencias PyTorch ↔ TensorFlow
La tabla para leer código del otro lado. La columna de TensorFlow usa la API de bajo nivel donde existe y Keras donde es la forma real en que la gente lo escribe; la última columna es lo que cambia de verdad, no la sintaxis.
| Qué | PyTorch | TensorFlow / Keras | Diferencia real |
|---|---|---|---|
| Crear un tensor | torch.tensor([1., 2.]) | tf.constant([1., 2.]) | El de TF es inmutable; el de PyTorch admite x[0] = 5 y ops _ in-place. |
| Parámetro entrenable | nn.Parameter(t) o t.requires_grad_(True) | tf.Variable(t, trainable=True) | En TF es un tipo distinto; en PyTorch, un flag sobre el mismo tipo. |
| Gradiente | loss.backward(); w.grad | with tf.GradientTape() as tape: …; tape.gradient(loss, w) | PyTorch acumula en .grad (hay que hacer zero_grad); TF devuelve el gradiente como valor, sin acumular. |
| Definir un modelo | class Net(nn.Module) con forward | keras.Sequential([...]), API funcional, o class Net(keras.Model) con call | Keras infiere shapes en el primer call; en PyTorch declarás in_features a mano. |
| Loop de entrenamiento | opt.zero_grad(); loss.backward(); opt.step() | grads = tape.gradient(loss, vars); opt.apply_gradients(zip(grads, vars)) — o model.compile(...); model.fit(...) | Mismo mecanismo; TF tiene el loop de alto nivel incluido, PyTorch lo delega a Lightning / HF Trainer / fastai. |
| Guardar y cargar | torch.save(model.state_dict(), 'm.pt') / model.load_state_dict(torch.load('m.pt')) | model.save('m.keras') / keras.saving.load_model('m.keras'); model.export('dir') para SavedModel | PyTorch guarda pesos y necesita la clase para reconstruir; el SavedModel de TF es el grafo entero, servible sin el código Python. |
| Compilar / grafo | torch.compile(model) | @tf.function sobre la función, o compile(jit_compile=True) para XLA | En PyTorch es un envoltorio opcional posterior; en TF, fit() ya lo aplica por defecto. |
| Layout de imágenes | (N, C, H, W), channels first | (N, H, W, C), channels last por defecto | Un tensor que viaja entre los dos necesita permute(0,3,1,2) / tf.transpose(x, [0,2,3,1]). |
6. Por qué la industria migró a PyTorch — con honestidad
No es una superioridad técnica absoluta — es una combinación de factores que se reforzaron entre sí:
- Ergonomía de investigación primero: eager por defecto desde el día uno, sin necesitar sesiones ni placeholders para debuggear.
- Efecto de red académico: código de referencia de papers en PyTorch → más reproducciones, más tutoriales, más gente formada.
- Consolidación del ecosistema de LLMs: Hugging Face Transformers, que se convirtió en el estándar de facto para modelos preentrenados, eligió PyTorch como backend principal — y ese ecosistema arrastró todo lo que se construyó alrededor (vLLM, PEFT, TRL, Diffusers).
- TensorFlow 1.x llegó tarde a la ergonomía eager: para cuando TF 2.0 la adoptó (2019), la comunidad de investigación ya había migrado en gran parte.
Y un matiz que la versión corta de la historia se come: lo que ganó no fue "PyTorch el framework" sino "PyTorch el punto de encuentro". Keras 3 corriendo sobre PyTorch (§4) y Transformers retirando su soporte de TensorFlow son el mismo movimiento visto desde dos lados — el ecosistema converge en un formato de tensor y un autograd, y el resto se vuelve capas intercambiables encima. Para tu trabajo con vLLM y NVFP4 eso importa más que cualquier comparación de sintaxis: los kernels, los formatos de pesos y las herramientas de cuantización se escriben una vez, contra PyTorch.
7. Dónde TensorFlow (y JAX) siguen importando
🟢 No es una historia de "TensorFlow perdió"
TensorFlow sigue siendo dominante en producción móvil y embebida (TensorFlow Lite,
hoy renombrado LiteRT), despliegue web (TensorFlow.js) y en infraestructura de serving
madura (TensorFlow Serving), donde la exportación a SavedModel —un grafo
autocontenido, independiente del código Python que lo creó— sigue siendo una ventaja operativa real
frente al ecosistema de PyTorch, más fragmentado en esa capa (TorchScript, ONNX, ExecuTorch, cada uno
con su propio nicho). Y el dato más interesante para 2026: buena parte de la investigación de frontera
de Google DeepMind (Gemini incluido) hoy corre sobre JAX, no sobre TensorFlow clásico
— un framework distinto, con compilación XLA nativa y un estilo funcional muy alejado tanto de
TensorFlow como de PyTorch. El propio Google, en investigación, se movió lejos de TensorFlow — no hacia
PyTorch, sino hacia una tercera opción.
Para que ese tercer estilo no te resulte ilegible cuando lo cruces, esto es todo JAX en seis líneas: no
hay Variable, ni tape, ni .grad — hay funciones puras y transformaciones de
funciones. jax.grad(f) devuelve otra función que calcula el gradiente de
f respecto de su primer argumento, y jax.jit compila con XLA lo que le pases:
import jax, jax.numpy as jnp
def loss(w, x, y): return jnp.mean((x @ w - y)**2)
grad_fn = jax.jit(jax.grad(loss)) # grad: función → función gradiente; jit: función → función compilada
w = jnp.zeros(3)
for _ in range(100):
w = w - 0.1 * grad_fn(w, x, y) # sin estado: w es un array nuevo en cada paso, nada se muta
Los pesos son un argumento más de la función, el "modelo" es la función loss, y el training
loop es aritmética sobre arrays inmutables (el estilo que tf.Tensor de §1 tenía a medias).
Es lo que hace a JAX tan cómodo para paralelizar sobre cientos de TPUs — una función pura se puede
compilar y particionar sin preguntarse quién muta qué — y lo que lo hace incómodo para el prototipado
rápido que PyTorch resuelve bien. Con reconocer jax.grad, jax.jit y
jnp ya podés leer un repo de DeepMind sin perderte; no hace falta más para este curso.
📐 Primitivas de pureza funcional en JAX: PRNGKey, VJP, vmap y sharding SPMD
JAX no modela las redes neuronales como grafos con estado ni como objetos mutables orientados a objetos, sino como funciones puras matemáticas:
- PRNGKey (Generación pseudoaleatoria sin estado global): frameworks como PyTorch mantienen un estado mutable oculto en C++ (
torch.manual_seed). En entornos distribuidos y multiproceso, esto genera condiciones de carrera y no determinismo. JAX utiliza un generador criptográfico basado en contadores (Threefry-2x32) donde el estado es un tensor explícito e inmutable: Cada muestra estocástica requiere consumir una clave derivada explícita, garantizando reproducibilidad matemática bit a bit. - VJP (Vector-Jacobian Product): en lugar de escribir mutaciones en un atributo
.grad, la función de bajo niveljax.vjp(f, x)evalúa el forward y retorna la salida junto con una función de pullback , desacoplando la retención de memoria de la propagación del gradiente. - Vectorización automática con vmap:
jax.vmap(f)transforma una función matemática definida para operar sobre un único vector unidimensional en una función que procesa lotes enteros (batches) de forma paralela en hardware, empujando la dimensión del lote a las operaciones primitivas subyacentes sin escribir indexación manual. - Paralelismo SPMD mediante Sharding: el cómputo distribuido en clústeres de TPUs y GPUs se orquesta mediante
NamedShardingyshard_map, donde los tensores se particionan declarativamente a lo largo de los ejes de una malla física de dispositivos sin requerir código imperativo de comunicación punto a punto.
.grad es PyTorch, si ves assign es TensorFlow, y si ves jax.grad(f) devolviendo una función, es JAX y no hay nada que se mute.8. Errores comunes
Los cinco primeros son los que le pasan a alguien que viene de PyTorch; el sexto, al revés. Casi todos se ven en el primer print de tipos y shapes.
| Síntoma | Causa probable | Qué hacer |
|---|---|---|
Un tensor "no cambia": x[0] = 5 tira TypeError, o un x = x + 1 adentro de una función no se ve afuera. | tf.Tensor es inmutable; cada operación devuelve un tensor nuevo y el original queda igual (§1). | Para estado que cambia, tf.Variable con assign/assign_add; para "cambiar un elemento" de un tensor, tf.tensor_scatter_nd_update devuelve una copia modificada. |
Un print dentro de una función decorada sale una sola vez, o nunca en las llamadas siguientes. | tf.function tracea la función una vez y después ejecuta el grafo; el print es Python y solo corre durante el tracing (§3). | Usar tf.print para imprimir desde el grafo. Si el print era para debuggear, tf.config.run_functions_eagerly(True) desactiva el grafo temporalmente. |
| El entrenamiento es lentísimo y aparece el aviso "5 out of the last 5 calls triggered tf.function retracing". | Shapes dinámicas o argumentos de Python distintos en cada llamada: cada firma nueva dispara un trace (§3). | Padding a shapes fijos, input_signature con dimensiones None, reduce_retracing=True, y pasar tensores en vez de int/float de Python. |
tape.gradient devuelve None para un parámetro. | El tensor no era una tf.Variable y no se lo "watcheó", o se calculó fuera del bloque with (§2). | tape.watch(t) para tensores, y mover el cálculo adentro del tape. Verificá que la variable esté en model.trainable_variables. |
Errores de tipo al mezclar capas o modelos que vienen de dos paquetes distintos (TypeError/ValueError quejándose de que un objeto "no es una capa de Keras"), o pesos guardados con Keras 2 que no cargan. | Mezcla de keras (Keras 3) con tf_keras (Keras 2 legado), o de código escrito para tf.keras de un TF anterior a 2.16 (§4). | Elegir un solo paquete por proyecto: en TF ≥ 2.16, tf.keras ya es Keras 3, así que importá keras directamente. Para pesos viejos, cargalos con tf_keras y re-guardalos en formato .keras. |
Un modelo convolucional de TF recibe imágenes de PyTorch y falla por shape, o peor, "funciona" con resultados absurdos (cuando C == H). | TF usa channels_last (N, H, W, C) por defecto; PyTorch usa (N, C, H, W) (§5). | Transponer en la frontera: tf.transpose(x, [0, 2, 3, 1]) al entrar a TF, x.permute(0, 3, 1, 2) al volver; o fijar data_format='channels_first' en las capas de TF. |
9. Resumen — cierre del curso completo
tf.Tensorinmutable +tf.Variablemutable reemplaza al tensor unificado de PyTorch — misma capacidad, tipos separados. Toda operación devuelve un Tensor, incluso sobre una Variable; el estado solo cambia conassign*.GradientTapees autograd con scoping explícito por bloque, en vez de por flagrequires_graden cada tensor. Sigue Variables solas, Tensores conwatch, y devuelve el gradiente como valor — por eso no hayzero_grad.tf.functiones el ancestro conceptual detorch.compile— la diferencia real es que PyTorch logró hacerlo transparente años después. El tracing corre Python una vez por firma (shape, dtype, valores Python) y de ahí salen losprintúnicos y el re-tracing.- Keras (
compile+fit) es al training loop de TF lo queLearner.fit()/Trainerson en PyTorch — una capa de conveniencia sobre el mismo mecanismo de gradientes. Desde Keras 3 (2023) esa capa es independiente del backend: el mismo modelo corre sobre TensorFlow, JAX o PyTorch. - La tabla de §5 resume la traducción: mismas siete operaciones, distinta ubicación del estado (flag vs tipo, atributo vs valor, código vs grafo) y distinto layout de imágenes.
- La migración a PyTorch fue ergonomía + efecto de red + el ecosistema de HF, no una sola causa técnica — y TensorFlow sigue siendo relevante en mobile/web/serving, mientras la investigación de Google ya se movió a JAX, un tercer framework, funcional y sin estado:
jax.graddevuelve funciones,jax.jitlas compila.
Con esto se cierra el recorrido completo del curso: Hugging Face LLM Course (12 capítulos), fast.ai (Parte 1 + Parte 2), PyTorch y TensorFlow — de la atención en un transformer al framework que hace correr todo lo demás por debajo.
10. Ponete a prueba
Respondé antes de abrir cada respuesta. Si fallás una, volvé a la sección indicada.
1. w = tf.Variable(1.0); w = w - 0.1. ¿Qué tipo tiene w después, y por qué es un bug en un training loop?
Un tf.Tensor: las operaciones sobre una Variable devuelven Tensores inmutables. El nombre w ahora apunta a un valor suelto; la Variable original sigue en 1.0 y es la que el modelo (y el tape) conocen, así que el modelo nunca aprende. La forma correcta es w.assign_sub(0.1). (§1)
2. ¿Por qué el loop de TF no necesita zero_grad()?
Porque tape.gradient devuelve el gradiente como un valor nuevo cada vez, no lo escribe en un atributo del parámetro. En PyTorch, backward() acumula en .grad (una decisión útil para gradient accumulation), y por eso hay que limpiarlo a mano. (§2, §5)
3. Una función con @tf.function recibe bs=32 como int de Python y después bs=64. ¿Cuántos traces? ¿Y si recibe tf.constant(32) y tf.constant(64)?
Dos traces en el primer caso: el valor de un argumento Python forma parte de la firma. Uno solo en el segundo: dos tensores escalares int32 tienen el mismo shape y dtype, y el valor queda adentro del grafo. Regla: pasá tensores, no escalares, para todo lo que varíe. (§3)
4. Ves import keras y keras.Input(shape=(28, 28)) en un repo. ¿Podés saber si el código usa TensorFlow?
No. Es sintaxis de Keras 3, que corre sobre TensorFlow, JAX o PyTorch según KERAS_BACKEND. Hay que mirar la configuración (variable de entorno o keras.config) para saber qué tensor hay abajo — y con backend torch, model(x) devuelve directamente un torch.Tensor. (§4)
5. Un tensor de imágenes de PyTorch tiene shape (16, 3, 224, 224). ¿Qué le hacés antes de pasárselo a una Conv2D de Keras con configuración por defecto?
tf.transpose(x, [0, 2, 3, 1]) (o x.permute(0, 3, 1, 2) del lado de PyTorch al volver): Keras/TF espera channels_last, (16, 224, 224, 3). Si no lo hacés, con 3 canales falla por shape; con un caso donde C == H no fallaría y calcularía basura. (§5, §8)
6. En JAX, ¿qué devuelve jax.grad(loss) y respecto de qué deriva?
Devuelve una función que, llamada con los mismos argumentos que loss, calcula el gradiente respecto del primer argumento (por eso los pesos van primero en la firma). No hay tape ni .grad: el gradiente es un array nuevo, y el paso de optimización es w = w - lr * g, sin mutar nada. (§7)
11. Para practicar
✏️ Ejercicio 1 — Inmutabilidad, tape y tracing en un solo archivo
Necesitás pip install tensorflow (la versión CPU alcanza; es un paquete grande). El script recorre §1, §2 y §3 y te muestra cada comportamiento en vivo. Contá cuántas veces sale Tracing. antes de correrlo.
import os; os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3" # silenciar los logs de arranque
import tensorflow as tf
# §1: inmutable vs Variable
x = tf.constant([[1., 2., 3.]])
try: x[0, 0] = 9.
except TypeError as e: print("tf.Tensor:", str(e)[:60])
var = tf.Variable([0., 0., 0.])
var.assign([1, 2, 3]); var.assign_add([1, 1, 1])
print("var =", var.numpy(), "| var + 1 es un", type(var + 1).__name__)
# §2: tape, watch, None
w = tf.Variable(1.0); c = tf.constant(1.0)
with tf.GradientTape() as tape:
y = w**2 + 2*w - 5 + c**2
print("dy/dw =", tape.gradient(y, w).numpy())
with tf.GradientTape() as tape:
y = c**2
print("dy/dc sin watch =", tape.gradient(y, c))
with tf.GradientTape() as tape:
tape.watch(c); y = c**2
print("dy/dc con watch =", tape.gradient(y, c).numpy())
# §3: tracing
@tf.function
def f(x):
print("Tracing.") # Python: solo durante el trace
tf.print("ejecutando, shape", tf.shape(x)) # grafo: en cada llamada
return tf.reduce_sum(x)
f(tf.constant([1., 2.])); f(tf.constant([3., 4.]))
f(tf.constant([1., 2., 3.])); f(tf.constant([1, 2]))
@tf.function
def g(n): print("trace de g con", n); return n * 2
for n in (1, 2, 3): g(n) # ints de Python
for n in (1, 2, 3): g(tf.constant(n)) # tensores
Pista / qué esperar
var + 1 es un EagerTensor; dy/dw = 4.0; None sin watch y 2.0 con watch. Tracing. sale tres veces en cuatro llamadas a f (la segunda reusa el grafo; la tercera cambia el shape, la cuarta el dtype), mientras que el tf.print sale las cuatro. Para g: tres traces con ints (uno por valor) y uno solo con tensores. Extra: agregá input_signature=[tf.TensorSpec([None], tf.float32)] a f y verificá que los shapes distintos ya no re-tracean — y que pasarle un int32 ahora es un error, no un trace.
✏️ Ejercicio 2 — El mismo modelo Keras 3 sobre tres backends
Con pip install keras (y torch, que ya tenés; pip install jax si querés el tercero) corré este archivo tres veces cambiando solo la variable de entorno. Mirá el tipo de la salida de model(x) en cada caso.
# KERAS_BACKEND=torch python k3.py ; KERAS_BACKEND=jax python k3.py ; KERAS_BACKEND=tensorflow python k3.py
import keras, numpy as np
print("backend:", keras.backend.backend(), "| keras", keras.__version__)
model = keras.Sequential([
keras.Input(shape=(28, 28)),
keras.layers.Flatten(),
keras.layers.Dense(128, activation="relu"),
keras.layers.Dropout(0.2),
keras.layers.Dense(10),
])
model.compile(optimizer="adam",
loss=keras.losses.SparseCategoricalCrossentropy(from_logits=True),
metrics=["accuracy"])
rng = np.random.default_rng(0) # datos sintéticos, sin descargar MNIST
x = rng.random((512, 28, 28), dtype=np.float32)
y = (x.mean(axis=(1, 2)) * 10).astype("int64") % 10 # una etiqueta que sí depende de la imagen
model.fit(x, y, epochs=2, batch_size=64, verbose=0)
print("loss:", round(float(model.evaluate(x, y, verbose=0)[0]), 3))
print("model(x) devuelve:", type(model(x[:4])))
model.save("m.keras"); print("recargado:", type(keras.saving.load_model("m.keras")).__name__)
Pista / qué esperar
Tres corridas con la misma loss aproximada y tres tipos de salida distintos: torch.Tensor, un Array de JAX, y un EagerTensor de TF. El archivo m.keras que guarda una corrida lo puede cargar cualquiera de las otras — es lo que "multi-backend" significa en la práctica. Si el backend torch te falla al arrancar, verificá que la variable de entorno esté puesta antes del import keras: el backend se elige en el import y no se puede cambiar después.
✏️ Ejercicio 3 — Regresión lineal en JAX, para reconocer el estilo
Con pip install jax (CPU). El objetivo no es aprender JAX sino ver que no hay estado: los pesos entran y salen de funciones puras.
import jax, jax.numpy as jnp
def loss(w, x, y): return jnp.mean((x @ w - y)**2)
grad_fn = jax.jit(jax.grad(loss))
key = jax.random.PRNGKey(0) # el random también es explícito y sin estado
x = jax.random.normal(key, (64, 3))
w_true = jnp.array([1., -2., 0.5]); y = x @ w_true
w = jnp.zeros(3)
for _ in range(100): w = w - 0.1 * grad_fn(w, x, y)
print(w, float(loss(w, x, y)))
Pista / qué esperar
w ≈ [1, -2, 0.5] y una loss del orden de 1e-13. Probá w[0] = 5. sobre un array de JAX: mismo TypeError de inmutabilidad que tf.Tensor en §1 (la forma funcional es w.at[0].set(5.), que devuelve una copia). Y sacá el jax.jit: el resultado es idéntico, solo más lento — jit es puramente una optimización, como tf.function.
12. Fuentes y para seguir
- TensorFlow basics — la guía en la que se basa esta página: tensores, variables, tape,
tf.functiony el loop manual seguido del consejo de usarfit. - Introduction to Tensors y Introduction to Variables — el detalle de §1, incluida la lista de qué operaciones devuelven Tensor a partir de una Variable.
- Introduction to gradients and automatic differentiation —
GradientTapecompleto:watch,persistent, y la sección "Getting a gradient of None" que explica cada caso de §8. - Better performance with tf.function — tracing, firmas, retracing y efectos secundarios de Python; leela si alguna vez tenés que optimizar código TF de verdad.
- Keras 3 — keras.io — el anuncio y la guía de migración multi-backend; ahí está la lista de lo que cambió respecto de
tf.kerasy cómo elegir el backend. - JAX documentation — el "Quickstart" y "Key concepts" son suficientes para leer código JAX;
grad,jit,vmapy por qué los arrays son inmutables.