Saltar a contenido

Ejercicios — Módulo 03: Deep Learning

Bienvenido/a al cuaderno de ejercicios del Módulo 03: Deep Learning de AI Master Academy.

Aquí vas a poner a prueba todo lo aprendido en los 7 capítulos del módulo: desde la neurona artificial y el forward pass en NumPy, pasando por backpropagation, PyTorch, CNNs, RNNs y atención, hasta los transformers y el fine-tuning con LoRA.

Instrucciones

  1. Intenta resolver cada ejercicio por tu cuenta antes de mirar la solución. El aprendizaje real ocurre cuando te atascas y sales del atasco.
  2. Los ejercicios de código están pensados para ejecutarse en un entorno con numpy, torch, pandas, matplotlib y scikit-learn instalados. Todos los datasets son sintéticos y autocontenidos (con semilla fija), así que no necesitas descargar nada.
  3. Los cálculos a mano de la Sección B debes hacerlos con papel y calculadora primero, y solo después verificarlos con NumPy.
  4. Cada solución incluye el razonamiento paso a paso. No te limites a comparar el resultado final: compara tu proceso con el propuesto.
  5. La dificultad se indica con estrellas: (básico), (intermedio), (avanzado).

Nota

los resultados numéricos con decimales pueden variar ligeramente (±0.0001) según el redondeo intermedio que uses. Lo importante es el procedimiento.

Criterio de aprobación

Considera superado el módulo si resuelves correctamente al menos 25 de los 30 ejercicios (83 %). Si fallas más de 2 ejercicios de una misma sección, repasa los capítulos asociados antes de continuar con los laboratorios.

Índice de ejercicios

# Título Sección Capítulo Dificultad
1 Por qué la no-linealidad es imprescindible A — Conceptual 1
2 Diagnóstico de tres curvas de entrenamiento A — Conceptual 2
3 ¿BatchNorm o LayerNorm? A — Conceptual 2
4 El misterio de zero_grad() A — Conceptual 3
5 Jerarquía prompting → RAG → LoRA → fine-tuning A — Conceptual 7
6 BERT vs GPT: elige la arquitectura correcta A — Conceptual 6
7 Máscara causal y positional encoding A — Conceptual 6
8 ¿CNN propia o API de visión multimodal? A — Conceptual 4, 7
9 Forward pass a mano en una red 2-2-1 B — Cálculo 1
10 Un paso de backpropagation en una neurona B — Cálculo 2
11 Convolución 4×4 con kernel 2×2 a mano B — Cálculo 4
12 Max pooling sobre una matriz dada B — Cálculo 4
13 Self-attention completa para 2 tokens B — Cálculo 6
14 El efecto de la temperatura sobre unos logits B — Cálculo 6
15 Contar parámetros: MLP y bloque transformer B — Cálculo 1, 6
16 Tamaño de salida de una convolución (4 casos) B — Cálculo 4
17 Reparar 4 errores de shape en PyTorch C — PyTorch 3
18 Regresión lineal con autograd puro C — PyTorch 3
19 Clasificador MLP completo sobre make_moons C — PyTorch 3
20 Dropout y early stopping sobre el MLP anterior C — PyTorch 2, 3
21 Dataset custom desde un DataFrame C — PyTorch 3
22 CNN pequeña sobre imágenes sintéticas 8×8 C — PyTorch 4
23 Transfer learning: congelar y cambiar la cabeza C — PyTorch 4, 7
24 Clasificar frases con nn.Embedding + promediado C — PyTorch 5
25 Scaled dot-product attention en PyTorch C — PyTorch 6
26 Generar texto con temperatura desde logits C — PyTorch 6
27 Mini-GPT: completa los 5 huecos D — Integrador 6
28 Pipeline de fine-tuning: ordena los 10 pasos D — Integrador 7
29 Depura el training loop roto (5 bugs) D — Integrador 2, 3
30 Diseño: clasificador de documentos para una gestoría D — Integrador 5, 6, 7

Sección A — Ejercicios conceptuales

Esta sección no requiere código (aunque puedes usarlo para comprobar). Requiere entender. En una entrevista técnica de ingeniero/a de IA, estas son exactamente las preguntas que te van a hacer.


Ejercicio 1 — Por qué la no-linealidad es imprescindible

Dificultad: · Capítulo relacionado: 1 (La neurona y el MLP)

Un compañero de equipo te dice: "He quitado las funciones de activación de nuestro MLP de 5 capas para que entrene más rápido. Total, con 5 capas de pesos el modelo sigue siendo muy expresivo."

  1. Explica por qué está cometiendo un error grave.
  2. Demuestra matemáticamente que una red de N capas lineales sin activaciones colapsa en una única transformación lineal.
  3. Relaciona tu demostración con el problema XOR: ¿por qué esa red "de 5 capas" jamás podrá resolverlo?
Ver solución **1. El error.** Sin funciones de activación no lineales, apilar capas no añade **ninguna** capacidad expresiva. La red de 5 capas es matemáticamente equivalente a una regresión lineal (o logística, si hay sigmoid solo al final). Entrenará más rápido, sí… porque es un modelo muchísimo más simple del que se cree que tiene. **2. Demostración del colapso.** Sea una red de dos capas lineales sin activación:
h = W1 · x + b1          (capa 1)
y = W2 · h + b2          (capa 2)
Sustituyendo `h` en la segunda ecuación:
y = W2 · (W1 · x + b1) + b2
y = (W2 · W1) · x + (W2 · b1 + b2)
y = W' · x + b'
donde `W' = W2·W1` es una única matriz y `b' = W2·b1 + b2` un único vector. Es decir, **dos capas lineales = una capa lineal**. Por inducción: si N capas colapsan en una, N+1 capas también (la capa extra se multiplica con la matriz colapsada). Por tanto, una red de N capas lineales, sea N=2 o N=500, computa exactamente `y = W'x + b'`: una transformación lineal. **3. Conexión con XOR.** XOR no es linealmente separable: no existe ninguna recta (ni hiperplano) que separe los puntos `(0,0), (1,1)` de los puntos `(0,1), (1,0)`. Como la red sin activaciones solo puede computar fronteras de decisión lineales (hiperplanos), es **imposible** que resuelva XOR, tenga las capas que tenga. La no-linealidad (ReLU, sigmoid, tanh…) es lo que permite a las capas ocultas "doblar el espacio" y crear fronteras curvas o compuestas. **Consejo profesional:** esta demostración de 4 líneas es una pregunta clásica de entrevista. Apréndela hasta poder escribirla en una pizarra sin dudar: `W2(W1x + b1) + b2 = (W2W1)x + (W2b1 + b2)`.

Ejercicio 2 — Diagnóstico de tres curvas de entrenamiento

Dificultad: · Capítulo relacionado: 2 (Entrenamiento y regularización)

Eres el ingeniero/a de ML de una startup y tres compañeros te enseñan sus curvas de entrenamiento pidiendo ayuda. Diagnostica cada caso y prescribe al menos dos remedios concretos para cada uno.

  • Caso A: la loss de entrenamiento baja de 2.3 a 0.01 en 40 épocas. La loss de validación baja hasta la época 12 (llega a 0.45) y a partir de ahí sube de forma sostenida hasta 1.2.
  • Caso B: tanto la loss de entrenamiento como la de validación bajan muy despacio y se estancan ambas en torno a 1.8 (con 10 clases, la loss inicial era ~2.3). Llevan 100 épocas y apenas mejoran.
  • Caso C: la loss de entrenamiento oscila violentamente: 1.9 → 0.8 → 3.5 → 12.7 → NaN.
Ver solución **Caso A — Sobreajuste (overfitting) de libro.** La red memoriza el train (loss → 0.01) mientras pierde capacidad de generalizar (val sube tras la época 12). La brecha creciente train/val es la firma inequívoca. Remedios: 1. **Early stopping**: quedarse con el checkpoint de la época 12 (mínimo de validación). 2. **Regularización**: añadir dropout (p=0.2–0.5) y/o weight decay (AdamW con `weight_decay=0.01`). 3. **Más datos** o data augmentation si es visión. 4. Reducir la capacidad del modelo (menos capas/neuronas) si lo anterior no basta. **Caso B — Infraajuste (underfitting).** Ni siquiera el train baja de forma decente: el modelo no tiene capacidad, o el aprendizaje está mal configurado. Ojo: aquí regularizar sería **contraproducente** (empeoraría el problema). Remedios: 1. **Aumentar la capacidad**: más capas/neuronas, o una arquitectura más adecuada (p. ej. CNN en vez de MLP para imágenes). 2. **Subir el learning rate** (quizá es tan bajo que avanza a paso de tortuga) o cambiar a Adam si usaba SGD puro. 3. Entrenar más épocas si la curva aún desciende, y revisar que las features tengan señal (¿datos normalizados?, ¿labels correctas?). 4. Quitar regularización excesiva si la hubiera (dropout muy alto, weight decay muy agresivo). **Caso C — Divergencia: learning rate demasiado alto (o gradientes explotando).** Las oscilaciones crecientes que acaban en `NaN` indican que cada paso de gradiente "salta" por encima del mínimo cada vez más lejos, hasta el desbordamiento numérico. Remedios: 1. **Bajar el learning rate** un orden de magnitud (p. ej. de 1e-2 a 1e-3) — primera medida siempre. 2. **Gradient clipping** (`torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)`). 3. Revisar la **normalización de los datos de entrada** (entradas con magnitudes enormes producen gradientes enormes). 4. Añadir BatchNorm/LayerNorm y usar inicialización He/Xavier correcta. **Nota:** memoriza el triángulo de diagnóstico: *train baja + val sube = overfitting · ambas altas = underfitting · loss explota = learning rate/gradientes*. Cubre el 90 % de los problemas reales.

Ejercicio 3 — ¿BatchNorm o LayerNorm?

Dificultad: · Capítulo relacionado: 2 (Normalización)

Para cada escenario, elige BatchNorm o LayerNorm y justifica tu elección explicando sobre qué dimensión normaliza cada una y por qué eso importa en el caso concreto:

  1. Una CNN de clasificación de imágenes que entrena con batches de 256 imágenes.
  2. Un transformer de lenguaje que procesa secuencias de longitud variable.
  3. Un modelo que, por limitaciones de memoria GPU, solo puede entrenar con batch size = 2.
  4. Un modelo en producción que recibe peticiones de una en una (batch = 1 en inferencia) y quieres el mismo comportamiento en train y en serving.
Ver solución **Recordatorio clave:** - **BatchNorm** normaliza cada canal/feature usando la media y varianza **calculadas a través del batch** (necesita estadísticas de muchos ejemplos a la vez). En inferencia usa medias móviles guardadas del entrenamiento. - **LayerNorm** normaliza **dentro de cada ejemplo individual**, a lo largo de la dimensión de features. No depende del batch en absoluto. **1. CNN con batch 256 → BatchNorm.** Es su hábitat natural: batches grandes dan estadísticas estables por canal, acelera la convergencia y actúa como regularizador suave. Prácticamente todas las CNN clásicas (ResNet incluida) usan BatchNorm. **2. Transformer con secuencias variables → LayerNorm.** Con longitudes variables y padding, las estadísticas por batch se contaminan (mezclarías tokens reales con relleno y posiciones distintas). LayerNorm normaliza cada token de cada secuencia por separado, lo que es independiente de la longitud y del resto del batch. Por eso **todos los transformers usan LayerNorm** (o variantes como RMSNorm). **3. Batch size = 2 → LayerNorm.** Con 2 ejemplos, la media y varianza del batch son ruido puro: BatchNorm se vuelve inestable y puede degradar seriamente el entrenamiento. LayerNorm no se entera de que el batch es pequeño. **4. Inferencia con batch = 1 y consistencia train/serving → LayerNorm.** BatchNorm se comporta **distinto** en train (estadísticas del batch) y en eval (medias móviles), lo que es fuente clásica de bugs y de discrepancias train/serving. LayerNorm computa exactamente lo mismo siempre, con cualquier batch size, en train y en inferencia. **Advertencia:** si usas BatchNorm y olvidas llamar a `model.eval()` en inferencia, el modelo usará las estadísticas del "batch" de producción (¡a veces de un solo ejemplo!) y dará resultados erráticos. Es uno de los bugs más comunes en despliegues reales.

Ejercicio 4 — El misterio de zero_grad()

Dificultad: · Capítulo relacionado: 3 (PyTorch)

Un training loop típico de PyTorch contiene estas tres llamadas en cada iteración:

optimizer.zero_grad()
loss.backward()
optimizer.step()
  1. Explica qué hace exactamente cada una de las tres líneas.
  2. ¿Por qué PyTorch acumula gradientes por defecto en vez de sobrescribirlos?
  3. Describe con precisión qué le ocurriría al entrenamiento si eliminas optimizer.zero_grad(): ¿daría error?, ¿qué síntoma verías en la loss?
Ver solución **1. Las tres líneas:** - `optimizer.zero_grad()`: pone a cero el atributo `.grad` de todos los parámetros registrados en el optimizador. Es "limpiar la pizarra" antes del nuevo cálculo. - `loss.backward()`: recorre el grafo computacional hacia atrás desde `loss` y calcula ∂loss/∂θ para cada parámetro θ con `requires_grad=True`, **sumando** el resultado en `θ.grad`. - `optimizer.step()`: actualiza cada parámetro usando su `.grad` según la regla del optimizador (SGD: `θ ← θ − lr·grad`; Adam: con momentos adaptativos). **2. ¿Por qué acumula por defecto?** Porque la acumulación es útil en varios escenarios legítimos: - **Gradient accumulation**: simular un batch grande sumando gradientes de varios mini-batches antes de hacer un único `step()` (imprescindible cuando el batch deseado no cabe en la GPU). - Redes con **varias losses** que retropropagan sobre los mismos parámetros. - RNNs y arquitecturas donde un mismo parámetro recibe gradiente por varios caminos del grafo. PyTorch elige la semántica de suma porque sobrescribir sería destructivo y la suma es lo matemáticamente correcto cuando un parámetro aparece varias veces. **3. Qué pasa si falta `zero_grad()`:** - **No da ningún error.** El código ejecuta perfectamente — eso es lo peligroso. - Los gradientes de cada batch se **suman a los de todos los batches anteriores**. En la iteración k, el paso del optimizador usa `g1 + g2 + ... + gk` en lugar de `gk`. - Síntomas: la magnitud efectiva del paso crece sin control con cada iteración; la loss baja al principio y luego empieza a **oscilar de forma cada vez más violenta**, a menudo divergiendo o quedándose muy por encima de lo esperable. El entrenamiento se comporta como si el learning rate creciera indefinidamente. - Es un bug silencioso: el diagnóstico habitual es "la loss no converge y no sé por qué". **Consejo profesional:** desde PyTorch moderno puedes usar `optimizer.zero_grad(set_to_none=True)` (comportamiento por defecto en versiones recientes): en vez de rellenar con ceros, pone `grad=None`, lo que ahorra memoria y una operación de escritura.

Ejercicio 5 — Jerarquía prompting → RAG → LoRA → fine-tuning

Dificultad: · Capítulo relacionado: 7 (El camino a los LLMs)

Trabajas como consultor/a de IA. Cuatro clientes te plantean sus necesidades. Para cada uno, elige la técnica más barata que resuelva el problema dentro de la jerarquía prompting < RAG < LoRA < fine-tuning completo, y justifica por qué las opciones inferiores no bastan y las superiores son un desperdicio.

  1. Cliente A (asesoría legal): quiere un chatbot que responda preguntas sobre sus propios expedientes y normativa interna, que cambia cada semana. Las respuestas deben citar el documento fuente.
  2. Cliente B (e-commerce): quiere que el LLM responda emails de clientes siempre con el tono de marca (cercano, con ciertas fórmulas de cortesía concretas) y en un formato fijo de 3 párrafos. Tiene 800 ejemplos de emails bien escritos.
  3. Cliente C (agencia de marketing): necesita resumir notas de prensa en 3 bullets. Probaron una vez y "el modelo a veces se enrolla demasiado".
  4. Cliente D (hospital, proyecto de investigación): necesita un modelo que entienda jerga médica muy especializada en español y funcione on-premise sin conexión, sobre un corpus propio de 2 millones de informes clínicos anonimizados. Tienen equipo de ML y GPUs propias.
Ver solución **Regla de oro de la jerarquía:** empieza siempre por lo más barato y sube solo cuando lo anterior demuestre ser insuficiente. - **Prompting** cambia *instrucciones*. - **RAG** cambia *el conocimiento accesible* (datos frescos, citables, actualizables sin reentrenar). - **LoRA** cambia *el comportamiento/estilo* con pocos recursos. - **Fine-tuning completo** cambia *el modelo en profundidad* (dominio nuevo, máximo coste). **1. Cliente A → RAG.** - ¿Por qué no prompting? El conocimiento (expedientes) no cabe en el prompt y **cambia cada semana**: no es un problema de instrucciones sino de acceso a datos. - ¿Por qué no LoRA/fine-tuning? Reentrenar cada semana sería carísimo y frágil; además el fine-tuning **no garantiza citas verificables** (el modelo "absorbe" el conocimiento pero no sabe de qué documento salió, y puede alucinar). RAG recupera el fragmento exacto y permite citar la fuente — requisito explícito del cliente. **2. Cliente B → LoRA (tras agotar el prompting).** - El prompting con ejemplos (few-shot) es lo primero a probar, y a veces basta. Pero cuando el tono debe ser **consistente al 100 %** en producción y hay fórmulas muy concretas, los prompts largos son frágiles y caros por petición. - RAG no aplica: no falta *conocimiento*, falta *estilo*. Recuperar documentos no cambia el tono. - LoRA con los 800 emails es ideal: ajusta el estilo con pocos parámetros entrenables, barato y suficiente. El fine-tuning completo sería matar moscas a cañonazos (mismo resultado, 100× el coste). **3. Cliente C → Prompting.** - "A veces se enrolla" es un problema de **instrucciones vagas**, no de conocimiento ni de estilo profundo. Un prompt bien diseñado ("Devuelve EXACTAMENTE 3 bullets de máximo 20 palabras cada uno. No añadas introducción ni cierre.") + 2 ejemplos few-shot lo resuelve. - Saltar a RAG/LoRA sin haber invertido ni una tarde en ingeniería de prompts sería tirar el dinero del cliente. **4. Cliente D → Fine-tuning completo (o continued pre-training) de un modelo open-source.** - Prompting/RAG no bastan: el problema es que el modelo base **no domina la jerga** — falla en la comprensión misma del vocabulario del dominio, no en el acceso a documentos. - LoRA se queda corto para absorber un dominio entero con 2M de informes: ajusta comportamiento, pero no inyecta eficazmente tanto conocimiento nuevo de vocabulario y distribución. - Se cumplen además las condiciones que justifican el máximo escalón: corpus masivo propio, requisito on-premise (obliga a modelo open-source local), equipo de ML y GPUs disponibles. Lo razonable: *continued pre-training* sobre el corpus clínico + fine-tuning de instrucciones, posiblemente complementado después con RAG para citar informes concretos. **Consejo profesional:** en consultoría real, el 80 % de los proyectos se resuelven en los dos primeros escalones. Desconfía de quien propone fine-tuning como primera opción: suele ser la solución más cara al problema equivocado.

Ejercicio 6 — BERT vs GPT: elige la arquitectura correcta

Dificultad: · Capítulo relacionado: 6 (Transformers)

Recuerda: BERT es un encoder bidireccional (ve toda la frase a la vez, ideal para entender), GPT es un decoder autoregresivo con máscara causal (genera token a token, ideal para producir texto). Para cada tarea, indica cuál usarías y por qué:

  1. Clasificar tickets de soporte en 12 categorías.
  2. Redactar respuestas automáticas a esos tickets.
  3. Extraer entidades (nombres de producto, fechas, importes) de facturas escaneadas y ya OCR-izadas.
  4. Autocompletar código mientras el programador escribe.
  5. Calcular embeddings de frases para un buscador semántico de documentación interna.
Ver solución **1. Clasificación de tickets → BERT (encoder).** Es una tarea de *comprensión* con salida discreta: el token `[CLS]` (o el promedio de embeddings) alimenta una cabeza de clasificación. La bidireccionalidad ayuda: para entender "no funciona *nada* desde la actualización" conviene ver la frase completa en ambas direcciones. Un GPT puede clasificar vía prompting, pero un BERT fine-tuneado es más barato, rápido y estable para 12 categorías fijas con datos etiquetados. **2. Redactar respuestas → GPT (decoder).** Es *generación* de texto libre: exactamente para lo que existe la arquitectura autoregresiva. BERT no puede generar texto de forma natural — está entrenado para rellenar huecos, no para continuar secuencias. **3. Extracción de entidades (NER) → BERT.** Es clasificación *por token* (cada token recibe una etiqueta: PRODUCTO, FECHA, IMPORTE, O). La bidireccionalidad es crítica: para etiquetar "1.250" como importe ayuda ver el "€" que viene *después*. BERT + cabeza de token classification es el estándar. (Hoy también se hace con LLMs generativos + salida estructurada, pero a igualdad de datos etiquetados, el encoder es más eficiente y determinista.) **4. Autocompletar código → GPT.** Autocompletar = predecir la continuación dada la izquierda del cursor: es literalmente el objetivo de entrenamiento de un decoder causal. Copilot y similares son decoders. BERT no sirve: necesitaría ver el "futuro" que precisamente hay que generar. **5. Embeddings para buscador semántico → BERT (variante sentence-transformers).** Se necesita un vector denso que represente la frase completa; los encoders bidireccionales producen representaciones contextuales ricas de toda la frase. Los sentence-transformers (SBERT) son BERTs afinados con objetivos contrastivos para que la similitud coseno refleje similitud semántica. Los decoders GPT puros generan, pero sus estados ocultos no están optimizados para similitud (aunque existen adaptaciones modernas, la opción canónica y eficiente es el encoder). **Nota:** regla mnemotécnica: **entender → encoder (BERT)** · **producir → decoder (GPT)**. Si la salida es una etiqueta, un vector o un span: BERT. Si la salida es texto nuevo: GPT.

Ejercicio 7 — Máscara causal y positional encoding

Dificultad: · Capítulo relacionado: 6 (Atención y transformers)

Dos preguntas de comprensión profunda del mecanismo de atención:

  1. Explica qué es la máscara causal, cómo se implementa sobre la matriz de scores (antes del softmax) y por qué es imprescindible durante el entrenamiento de un GPT (pista: piensa en qué "vería" el modelo sin ella y por qué eso invalidaría el aprendizaje).
  2. Demuestra con un mini-ejemplo por qué la self-attention sin positional encoding es incapaz de distinguir "perro muerde hombre" de "hombre muerde perro", y explica cómo lo soluciona el positional encoding.
Ver solución **1. La máscara causal.** En un decoder, la posición *t* solo puede atender a las posiciones 1…t (su pasado). Se implementa poniendo **−∞** en la matriz de scores `QKᵀ/√d` en todas las celdas donde la columna (posición atendida) es posterior a la fila (posición que atiende):
scores = [[ s11, -inf, -inf ],
          [ s21,  s22, -inf ],
          [ s31,  s32,  s33 ]]
Tras el softmax, `e^(−∞) = 0`: los pesos de atención hacia el futuro son exactamente cero, y las filas siguen sumando 1 repartiendo la probabilidad solo entre el pasado. **¿Por qué es imprescindible en entrenamiento?** GPT se entrena con *teacher forcing*: se le da la secuencia completa y debe predecir, en cada posición t, el token t+1. Todas las posiciones se entrenan **en paralelo en un solo forward**. Sin máscara, la posición t podría "mirar" el token t+1 dentro de la propia entrada… **¡que es exactamente la respuesta que debe predecir!** El modelo aprendería el atajo trivial de copiar el siguiente token, la loss bajaría a casi cero, y en inferencia (donde el futuro no existe) sería inútil. La máscara garantiza que la tarea de entrenamiento coincida con la de inferencia: predecir sin ver el futuro. **2. Por qué hace falta positional encoding.** La self-attention es una operación sobre **conjuntos**, no sobre secuencias: la salida para cada token es una suma ponderada de los values, y los pesos dependen solo de productos escalares entre embeddings. Si permutas el orden de los tokens de entrada, obtienes exactamente las mismas representaciones, permutadas. Mini-ejemplo: - Frase 1: `[v_perro, v_muerde, v_hombre]` - Frase 2: `[v_hombre, v_muerde, v_perro]` Los embeddings de "perro", "muerde" y "hombre" son los mismos vectores en ambas frases; solo cambia su posición. Como la atención no usa los índices para nada, la representación que recibe "muerde" (suma ponderada de perro y hombre con pesos calculados por productos escalares idénticos) es **exactamente la misma** en ambas frases. El modelo no puede saber quién muerde a quién: para la atención pura, ambas frases son la misma "bolsa de tokens". **La solución:** sumar a cada embedding un vector que codifica su posición (`x_t + PE_t`), sea con senos y cosenos de frecuencias distintas (encoding sinusoidal del paper original) o con embeddings de posición aprendidos (GPT). Ahora `v_perro + PE_1 ≠ v_perro + PE_3`: el mismo token en posiciones distintas produce queries/keys distintas, los scores cambian con el orden y "perro muerde hombre" deja de ser indistinguible de "hombre muerde perro". **Advertencia:** un error conceptual frecuente es creer que la máscara causal solo se usa en inferencia. Es al revés: en inferencia con generación token a token la máscara casi es redundante (el futuro literalmente no existe todavía); donde es vital es en el **entrenamiento paralelo**.

Ejercicio 8 — ¿CNN propia o API de visión multimodal?

Dificultad: · Capítulos relacionados: 4 (CNNs) y 7 (decisiones de ingeniería)

Para cada caso, decide entre (a) entrenar/fine-tunear una CNN propia (p. ej. transfer learning sobre ResNet) o (b) usar una API de visión multimodal (GPT-4V, Claude, Gemini…). Justifica considerando: coste por inferencia, latencia, volumen, privacidad, necesidad de datos etiquetados y tipo de tarea.

  1. Fábrica de tornillos: detectar defectos en 500 000 piezas/día fotografiadas en una cinta transportadora, con latencia máxima de 50 ms por pieza, cámara fija e iluminación controlada. Disponen de 20 000 fotos etiquetadas.
  2. Startup inmobiliaria: describir con texto rico las fotos que suben los usuarios (≈300 fotos/día) y detectar cualquier cosa rara (por ejemplo, una foto que no sea de una vivienda).
  3. Hospital: clasificar radiografías con datos que no pueden salir de sus servidores por normativa, con 50 000 imágenes etiquetadas por radiólogos.
Ver solución **1. Fábrica → CNN propia (transfer learning), sin duda.** - **Volumen y coste:** 500 000 inferencias/día por API a incluso 0.002 €/imagen serían ~1 000 €/día (~365 000 €/año). Una CNN propia en una GPU local cuesta céntimos de electricidad. - **Latencia:** 50 ms descarta llamadas de red a una API externa (solo el round-trip ya se lo come). Una ResNet pequeña o MobileNet local infiere en <10 ms. - **Tarea:** clasificación binaria/pocas clases en condiciones visuales controladas (cámara e iluminación fijas) — el escenario ideal donde una CNN pequeña brilla. Hay 20 000 fotos etiquetadas: más que suficiente para fine-tuning. - La API multimodal no aporta nada aquí: no se necesita razonamiento abierto ni lenguaje. **2. Inmobiliaria → API de visión multimodal.** - **Tarea abierta:** generar descripciones ricas en lenguaje natural es *exactamente* lo que hace un modelo multimodal y lo que una CNN de clasificación **no puede hacer** (una CNN da etiquetas, no prosa). - **Volumen minúsculo:** 300 fotos/día ≈ 1 €/día de API. Entrenar y mantener infraestructura propia costaría órdenes de magnitud más en horas de ingeniería. - **"Detectar cualquier cosa rara"** es una categoría abierta e indefinible a priori: imposible etiquetar un dataset de "cosas raras", trivial para un modelo multimodal con una instrucción. - Sin requisitos duros de latencia ni privacidad. **3. Hospital → CNN propia on-premise (transfer learning sobre ResNet/DenseNet).** - **La privacidad decide sola:** los datos no pueden salir de sus servidores, luego cualquier API externa queda descartada de raíz, sea cual sea su calidad. - Hay 50 000 imágenes etiquetadas por expertos: un dataset excelente para fine-tuning supervisado. - La tarea es clasificación con clases definidas (patologías), el terreno clásico de las CNN. Además, en medicina se exige control total del modelo, trazabilidad y validación clínica — incompatible con un modelo third-party que cambia por debajo sin aviso. **Consejo profesional:** la matriz de decisión que debes interiorizar: **alto volumen + latencia estricta + tarea cerrada + datos etiquetados → modelo propio**; **bajo volumen + tarea abierta/lenguaje + sin datos etiquetados → API**. Y la privacidad, cuando aparece como requisito duro, actúa de veto absoluto sobre las APIs.

Sección B — Cálculos a mano + NumPy

Papel, calculadora y después NumPy para verificar. Si sabes hacer estas cuentas, ninguna arquitectura te resultará una caja negra nunca más.


Ejercicio 9 — Forward pass a mano en una red 2-2-1

Dificultad: · Capítulo relacionado: 1 (Forward pass)

Una red con 2 entradas, una capa oculta de 2 neuronas con sigmoid y una neurona de salida con sigmoid. Datos:

Entrada:            x  = [1.0, 0.5]
Pesos capa oculta:  W1 = [[0.4, 0.3],     b1 = [0.1, -0.1]
                          [0.2, 0.6]]
  (fila i de W1 = pesos de la neurona oculta i)
Pesos salida:       W2 = [0.7, -0.5]      b2 = 0.2

Calcula, rellenando una tabla paso a paso: z1 (pre-activaciones ocultas), a1 (activaciones ocultas), z2, y la salida y_hat. Después verifica con NumPy. Usa sigmoid(z) = 1/(1+e^(−z)) y redondea a 4 decimales.

Ver solución **Paso a paso con la tabla:** | Paso | Operación | Cálculo | Resultado | |------|-----------|---------|-----------| | z1₁ | w·x + b | 0.4·1.0 + 0.3·0.5 + 0.1 = 0.4 + 0.15 + 0.1 | **0.65** | | z1₂ | w·x + b | 0.2·1.0 + 0.6·0.5 + (−0.1) = 0.2 + 0.3 − 0.1 | **0.40** | | a1₁ | sigmoid(0.65) | 1/(1+e^(−0.65)) = 1/(1+0.5220) | **0.6570** | | a1₂ | sigmoid(0.40) | 1/(1+e^(−0.40)) = 1/(1+0.6703) | **0.5987** | | z2 | W2·a1 + b2 | 0.7·0.6570 + (−0.5)·0.5987 + 0.2 = 0.4599 − 0.2993 + 0.2 | **0.3606** | | y_hat | sigmoid(0.3606) | 1/(1+e^(−0.3606)) = 1/(1+0.6973) | **0.5892** | La red predice **y_hat ≈ 0.5892** (interpretable como probabilidad de la clase positiva). **Verificación con NumPy:**
import numpy as np

def sigmoid(z):
    """Función sigmoide elemento a elemento."""
    return 1 / (1 + np.exp(-z))

x  = np.array([1.0, 0.5])                    # vector de entrada (2,)
W1 = np.array([[0.4, 0.3],                   # pesos de la capa oculta (2,2)
               [0.2, 0.6]])                  # fila i = neurona oculta i
b1 = np.array([0.1, -0.1])                   # sesgos ocultos (2,)
W2 = np.array([0.7, -0.5])                   # pesos de salida (2,)
b2 = 0.2                                     # sesgo de salida (escalar)

z1 = W1 @ x + b1                             # pre-activación oculta: producto matriz-vector
a1 = sigmoid(z1)                             # activación oculta: no-linealidad
z2 = W2 @ a1 + b2                            # pre-activación de salida (escalar)
y_hat = sigmoid(z2)                          # salida final en (0,1)

print("z1 =", np.round(z1, 4))               # [0.65 0.4 ]
print("a1 =", np.round(a1, 4))               # [0.657  0.5987]
print("z2 =", round(z2, 4))                  # 0.3606
print("y_hat =", round(y_hat, 4))            # 0.5892
**Nota:** fíjate en el patrón que se repite en TODA red neuronal, tenga 2 neuronas o 200 000 millones: *multiplicar por pesos → sumar sesgo → aplicar no-linealidad*. Un LLM es esto mismo, repetido y organizado con atención.

Ejercicio 10 — Un paso de backpropagation en una neurona

Dificultad: · Capítulo relacionado: 2 (Backpropagation)

Una sola neurona: z = w·x + b, a = sigmoid(z), con pérdida MSE L = (a − y)². Valores concretos:

x = 2.0    w = 0.5    b = 0.1    y = 1.0 (etiqueta)    lr = 0.1
  1. Forward: calcula z, a y L.
  2. Backward con la regla de la cadena: calcula ∂L/∂a, ∂a/∂z, ∂L/∂z, ∂L/∂w, ∂L/∂b.
  3. Aplica un paso de SGD y da los nuevos valores de w y b.
  4. Verifica el gradiente de w con gradient checking numérico (ε = 1e−4).
Ver solución **1. Forward:** - `z = 0.5·2.0 + 0.1 = 1.1` - `a = sigmoid(1.1) = 1/(1+e^(−1.1)) = 1/(1+0.3329) = 0.7503` - `L = (0.7503 − 1.0)² = (−0.2497)² = 0.0624` **2. Backward (regla de la cadena, eslabón a eslabón):** - `∂L/∂a = 2(a − y) = 2(0.7503 − 1.0) = −0.4994` - `∂a/∂z = a(1 − a) = 0.7503 · 0.2497 = 0.1874` ← derivada de la sigmoide - `∂L/∂z = ∂L/∂a · ∂a/∂z = −0.4994 · 0.1874 = −0.0936` - `∂L/∂w = ∂L/∂z · ∂z/∂w = −0.0936 · x = −0.0936 · 2.0 = −0.1872` - `∂L/∂b = ∂L/∂z · ∂z/∂b = −0.0936 · 1 = −0.0936` **3. Paso de SGD** (`θ ← θ − lr·grad`; el gradiente es negativo, así que los parámetros **suben**, lo que subirá `a` hacia `y=1`: tiene sentido): - `w ← 0.5 − 0.1·(−0.1872) = 0.5 + 0.0187 = 0.5187` - `b ← 0.1 − 0.1·(−0.0936) = 0.1 + 0.0094 = 0.1094` **4. Gradient checking y verificación completa en NumPy:**
import numpy as np

def sigmoid(z):
    return 1 / (1 + np.exp(-z))

def loss_fn(w, b, x, y):
    """Calcula la pérdida MSE para unos parámetros dados (forward completo)."""
    a = sigmoid(w * x + b)                   # forward: lineal + sigmoide
    return (a - y) ** 2                      # MSE de un solo ejemplo

x, w, b, y, lr = 2.0, 0.5, 0.1, 1.0, 0.1

# --- Forward analítico ---
z = w * x + b                                # z = 1.1
a = sigmoid(z)                               # a = 0.7503
L = (a - y) ** 2                             # L = 0.0624

# --- Backward analítico (regla de la cadena) ---
dL_da = 2 * (a - y)                          # -0.4994
da_dz = a * (1 - a)                          # 0.1874  (derivada de sigmoid)
dL_dz = dL_da * da_dz                        # -0.0936
dL_dw = dL_dz * x                            # -0.1872
dL_db = dL_dz * 1.0                          # -0.0936

# --- Gradient checking: derivada numérica central ---
eps = 1e-4
grad_w_num = (loss_fn(w + eps, b, x, y) - loss_fn(w - eps, b, x, y)) / (2 * eps)

print(f"Gradiente analítico dL/dw = {dL_dw:.6f}")   # -0.187181
print(f"Gradiente numérico  dL/dw = {grad_w_num:.6f}")  # -0.187181 -> coinciden
print(f"w nuevo = {w - lr * dL_dw:.4f}")     # 0.5187
print(f"b nuevo = {b - lr * dL_db:.4f}")     # 0.1094
El gradiente numérico coincide con el analítico hasta ~7 decimales: la derivación es correcta. **Consejo profesional:** el gradient checking numérico (`(L(θ+ε) − L(θ−ε)) / 2ε`) es la herramienta definitiva para validar backprop implementado a mano. Es lento (dos forwards por parámetro), así que se usa solo para *verificar*, nunca para entrenar.

Ejercicio 11 — Convolución 4×4 con kernel 2×2 a mano

Dificultad: · Capítulo relacionado: 4 (Convolución)

Calcula a mano la salida de la convolución (correlación cruzada, como hace PyTorch) con stride 1 y sin padding:

Entrada I (4×4):          Kernel K (2×2):
[[1, 2, 0, 1],            [[ 1,  0],
 [3, 1, 1, 0],             [ 0, -1]]
 [0, 2, 4, 1],
 [1, 0, 2, 3]]
  1. ¿Qué tamaño tendrá la salida? Justifícalo con la fórmula.
  2. Calcula las 9 posiciones mostrando la cuenta de al menos las 3 primeras.
  3. Verifica con NumPy (sin usar librerías de deep learning).
Ver solución **1. Tamaño de salida:** `O = (N − K + 2P)/S + 1 = (4 − 2 + 0)/1 + 1 = 3` → salida **3×3**. **2. El kernel `[[1,0],[0,−1]]` toma la esquina superior-izquierda de cada ventana 2×2 y le resta la esquina inferior-derecha.** Posición a posición (fila, col): - (0,0): ventana `[[1,2],[3,1]]` → `1·1 + 2·0 + 3·0 + 1·(−1) = 1 − 1 = **0**` - (0,1): ventana `[[2,0],[1,1]]` → `2 − 1 = **1**` - (0,2): ventana `[[0,1],[1,0]]` → `0 − 0 = **0**` - (1,0): ventana `[[3,1],[0,2]]` → `3 − 2 = **1**` - (1,1): ventana `[[1,1],[2,4]]` → `1 − 4 = **−3**` - (1,2): ventana `[[1,0],[4,1]]` → `1 − 1 = **0**` - (2,0): ventana `[[0,2],[1,0]]` → `0 − 0 = **0**` - (2,1): ventana `[[2,4],[0,2]]` → `2 − 2 = **0**` - (2,2): ventana `[[4,1],[2,3]]` → `4 − 3 = **1**` **Salida:**
[[ 0,  1,  0],
 [ 1, -3,  0],
 [ 0,  0,  1]]
**3. Verificación en NumPy puro:**
import numpy as np

I = np.array([[1, 2, 0, 1],
              [3, 1, 1, 0],
              [0, 2, 4, 1],
              [1, 0, 2, 3]], dtype=float)   # imagen de entrada 4x4

K = np.array([[1, 0],
              [0, -1]], dtype=float)        # kernel 2x2

kh, kw = K.shape                             # alto y ancho del kernel (2, 2)
oh = I.shape[0] - kh + 1                     # alto de salida: 4-2+1 = 3
ow = I.shape[1] - kw + 1                     # ancho de salida: 3
out = np.zeros((oh, ow))                     # matriz de salida 3x3

for i in range(oh):                          # deslizamos la ventana por filas...
    for j in range(ow):                      # ...y por columnas
        ventana = I[i:i+kh, j:j+kw]          # recorte 2x2 de la imagen
        out[i, j] = np.sum(ventana * K)      # producto elemento a elemento + suma

print(out)
# [[ 0.  1.  0.]
#  [ 1. -3.  0.]
#  [ 0.  0.  1.]]
**Nota:** este kernel concreto responde fuerte (en valor absoluto) donde hay **cambios en diagonal**: es un detector de gradiente diagonal rudimentario. Los kernels de una CNN real hacen esto mismo, pero sus valores se **aprenden** por backprop en lugar de diseñarse a mano.

Ejercicio 12 — Max pooling sobre una matriz dada

Dificultad: · Capítulo relacionado: 4 (Pooling)

Aplica max pooling 2×2 con stride 2 a esta matriz y responde a las preguntas:

[[1, 3, 2, 4],
 [5, 6, 1, 2],
 [7, 2, 8, 3],
 [0, 1, 4, 9]]
  1. Calcula la salida a mano.
  2. ¿Cuántos valores de entrada "sobreviven"? ¿Qué porcentaje de la información posicional exacta se pierde?
  3. ¿Qué ventaja aporta esta pérdida deliberada de información en una CNN? Verifica con NumPy.
Ver solución **1. Cuatro ventanas 2×2 sin solaparse (stride 2 = la ventana salta de 2 en 2):** - Ventana superior-izquierda `[[1,3],[5,6]]` → max = **6** - Ventana superior-derecha `[[2,4],[1,2]]` → max = **4** - Ventana inferior-izquierda `[[7,2],[0,1]]` → max = **7** - Ventana inferior-derecha `[[8,3],[4,9]]` → max = **9**
Salida (2×2):
[[6, 4],
 [7, 9]]
**2.** Sobreviven 4 de 16 valores: se descarta el **75 %** de los números. Además, dentro de cada ventana se pierde la posición exacta del máximo (solo se conserva "hubo un 6 en algún lugar de esta región"). **3. ¿Por qué es una ventaja?** - **Invarianza local a traslaciones:** si el rasgo detectado (p. ej. un borde) se desplaza un píxel dentro de la ventana, la salida del pooling no cambia. La CNN se vuelve robusta a pequeños desplazamientos — un gato 2 píxeles a la izquierda sigue siendo un gato. - **Reducción de cómputo y memoria:** cada pooling 2×2 divide el área por 4, permitiendo redes más profundas. - **Crecimiento del campo receptivo:** las capas posteriores "ven" regiones cada vez mayores de la imagen original.
import numpy as np

X = np.array([[1, 3, 2, 4],
              [5, 6, 1, 2],
              [7, 2, 8, 3],
              [0, 1, 4, 9]], dtype=float)   # entrada 4x4

out = np.zeros((2, 2))                       # salida 2x2 (4/2 = 2 por lado)
for i in range(2):                           # recorremos las 2 filas de ventanas
    for j in range(2):                       # y las 2 columnas de ventanas
        ventana = X[2*i:2*i+2, 2*j:2*j+2]    # ventana 2x2 SIN solape (stride 2)
        out[i, j] = ventana.max()            # nos quedamos solo con el máximo

print(out)                                   # [[6. 4.] [7. 9.]]
**Advertencia:** no confundas stride del pooling con stride de la convolución. En max pooling 2×2 el stride habitual es 2 (ventanas disjuntas); con stride 1 las ventanas se solaparían y apenas reducirías el tamaño.

Ejercicio 13 — Self-attention completa para 2 tokens

Dificultad: · Capítulo relacionado: 6 (Self-attention Q/K/V)

Tras aplicar las proyecciones a una secuencia de 2 tokens (dimensión d=2), obtienes:

Q = [[1, 0],      K = [[1, 1],      V = [[1, 2],
     [0, 2]]           [0, 1]]           [3, 0]]

(fila 1 = token 1, fila 2 = token 2). Calcula todos los números de la atención softmax(QKᵀ/√d)·V:

  1. La matriz de scores QKᵀ.
  2. Los scores escalados por √d = √2 ≈ 1.4142.
  3. El softmax por filas (matriz de pesos de atención A).
  4. La salida A·V.
  5. Interpreta: ¿a quién atiende cada token y cómo se refleja en su salida? Verifica con NumPy.
Ver solución **1. Scores `QKᵀ`** (elemento (i,j) = producto escalar entre la query del token i y la key del token j): - q1·k1 = [1,0]·[1,1] = 1·1 + 0·1 = **1** - q1·k2 = [1,0]·[0,1] = 0 + 0 = **0** - q2·k1 = [0,2]·[1,1] = 0 + 2 = **2** - q2·k2 = [0,2]·[0,1] = 0 + 2 = **2**
QKᵀ = [[1, 0],
       [2, 2]]
**2. Escalado** (÷√2 = 1.4142, para evitar que los productos escalares crezcan con la dimensión y saturen el softmax):
QKᵀ/√2 = [[0.7071, 0.0000],
          [1.4142, 1.4142]]
**3. Softmax por filas:** *Fila 1:* `e^0.7071 = 2.0281`, `e^0 = 1.0000`, suma = 3.0281 → `A₁ = [2.0281/3.0281, 1.0000/3.0281] = [0.6698, 0.3302]` *Fila 2:* ambos scores son iguales (1.4142), así que el softmax reparte a partes iguales → `A₂ = [0.5, 0.5]`
A = [[0.6698, 0.3302],
     [0.5000, 0.5000]]
**4. Salida `A·V`** (cada fila = mezcla ponderada de los values): *Token 1:* `0.6698·[1,2] + 0.3302·[3,0] = [0.6698 + 0.9906, 1.3396 + 0] = [1.6604, 1.3396]` *Token 2:* `0.5·[1,2] + 0.5·[3,0] = [2.0, 1.0]`
Salida = [[1.6604, 1.3396],
          [2.0000, 1.0000]]
**5. Interpretación:** el token 1 atiende un 67 % a sí mismo y un 33 % al token 2, así que su salida se parece más a `v1=[1,2]` pero arrastrada hacia `v2=[3,0]`. El token 2 atiende exactamente al 50/50 (sus scores empataron), y su salida es el punto medio exacto entre ambos values. **Eso es la atención: cada token reconstruye su representación como media ponderada de la información (values) de toda la secuencia, con pesos decididos por la afinidad query-key.**
import numpy as np

Q = np.array([[1., 0.], [0., 2.]])           # queries (2 tokens, d=2)
K = np.array([[1., 1.], [0., 1.]])           # keys
V = np.array([[1., 2.], [3., 0.]])           # values

scores = Q @ K.T                             # (2,2): afinidad de cada query con cada key
scaled = scores / np.sqrt(Q.shape[-1])       # escalado por raíz de d=2

exp = np.exp(scaled)                         # exponencial elemento a elemento
A = exp / exp.sum(axis=1, keepdims=True)     # softmax por FILAS (cada fila suma 1)

salida = A @ V                               # mezcla ponderada de values

print("QK^T =\n", scores)                    # [[1. 0.] [2. 2.]]
print("A =\n", np.round(A, 4))               # [[0.6698 0.3302] [0.5 0.5]]
print("Salida =\n", np.round(salida, 4))     # [[1.6604 1.3396] [2. 1.]]
**Consejo profesional:** guarda estos números — en el Ejercicio 25 implementarás esta misma operación en PyTorch y la validarás contra este resultado. Verificar una implementación contra un cálculo a mano es práctica profesional estándar.

Ejercicio 14 — El efecto de la temperatura sobre unos logits

Dificultad: · Capítulo relacionado: 6 (Generación y temperatura)

Un modelo de lenguaje produce estos logits para 3 tokens candidatos:

logits = [2.0, 1.0, 0.5]     (tokens: "gato", "perro", "loro")

El muestreo aplica softmax(logits / T). Calcula la distribución de probabilidad resultante para T = 0.5, T = 1.0 y T = 2.0 (4 decimales), y explica:

  1. ¿Qué temperatura usarías para un asistente de código y cuál para un generador de ideas creativas?
  2. ¿Hacia qué distribuciones tienden los límites T→0 y T→∞?
Ver solución **T = 1.0 (softmax estándar):** `e^2 = 7.389`, `e^1 = 2.718`, `e^0.5 = 1.649`; suma = 11.756 | Token | Cálculo | Probabilidad | |-------|---------|--------------| | gato | 7.389/11.756 | **0.6285** | | perro | 2.718/11.756 | **0.2312** | | loro | 1.649/11.756 | **0.1403** | **T = 0.5 (logits/0.5 = [4, 2, 1]):** `e^4 = 54.598`, `e^2 = 7.389`, `e^1 = 2.718`; suma = 64.705 | Token | Cálculo | Probabilidad | |-------|---------|--------------| | gato | 54.598/64.705 | **0.8438** | | perro | 7.389/64.705 | **0.1142** | | loro | 2.718/64.705 | **0.0420** | **T = 2.0 (logits/2 = [1, 0.5, 0.25]):** `e^1 = 2.718`, `e^0.5 = 1.649`, `e^0.25 = 1.284`; suma = 5.651 | Token | Cálculo | Probabilidad | |-------|---------|--------------| | gato | 2.718/5.651 | **0.4810** | | perro | 1.649/5.651 | **0.2918** | | loro | 1.284/5.651 | **0.2272** | **Lectura:** bajar la temperatura **afila** la distribución (T=0.5: "gato" pasa de 63 % a 84 %); subirla la **aplana** (T=2: "gato" cae a 48 % y "loro" casi se triplica de 14 % a 23 %). **1. Elección práctica:** - **Asistente de código → T baja (0–0.3):** el código exige precisión y determinismo; no quieres "creatividad" en un nombre de variable o una sintaxis. - **Ideas creativas → T alta (0.8–1.2):** quieres explorar tokens improbables que produzcan combinaciones originales. **2. Límites:** - **T → 0:** la distribución tiende a un *one-hot* sobre el token de mayor logit → **greedy decoding** (siempre "gato", determinista). - **T → ∞:** todos los logits divididos por T tienden a 0 → distribución **uniforme** (1/3, 1/3, 1/3): ruido puro sin relación con el modelo.
import numpy as np

logits = np.array([2.0, 1.0, 0.5])           # salidas crudas del modelo

def softmax_T(logits, T):
    """Softmax con temperatura: divide los logits por T antes de exponenciar."""
    z = logits / T                           # T<1 separa los logits; T>1 los junta
    e = np.exp(z - z.max())                  # restar el máximo: estabilidad numérica
    return e / e.sum()                       # normalizar para que sumen 1

for T in [0.5, 1.0, 2.0]:
    print(f"T={T}: {np.round(softmax_T(logits, T), 4)}")
# T=0.5: [0.8438 0.1142 0.042 ]
# T=1.0: [0.6285 0.2312 0.1403]
# T=2.0: [0.481  0.2918 0.2272]
**Nota:** la resta del máximo (`z - z.max()`) no cambia el resultado del softmax (es invariante a desplazamientos) pero evita el overflow de `np.exp` con logits grandes. Hazlo siempre.

Ejercicio 15 — Contar parámetros: MLP y bloque transformer

Dificultad: · Capítulos relacionados: 1 y 6

Contar parámetros es una habilidad básica para estimar memoria, coste y riesgo de sobreajuste.

Parte 1: un MLP para MNIST con arquitectura 784 → 128 → 64 → 10 (capas totalmente conectadas con bias). ¿Cuántos parámetros tiene en total?

Parte 2: un bloque transformer pequeño con d_model = 64, 4 cabezas de atención, y FFN interna d_ff = 256. Cuenta los parámetros de: (a) las 4 proyecciones de atención (Wq, Wk, Wv, Wo, todas 64×64 con bias), (b) el FFN (64→256→64, con bias), (c) las 2 LayerNorm (cada una con gamma y beta de dimensión 64). Total del bloque.

Ver solución **Parte 1 — MLP.** Regla: una capa `entrada→salida` tiene `entrada × salida` pesos + `salida` biases. | Capa | Pesos | Biases | Total | |------|-------|--------|-------| | 784 → 128 | 784·128 = 100 352 | 128 | **100 480** | | 128 → 64 | 128·64 = 8 192 | 64 | **8 256** | | 64 → 10 | 64·10 = 640 | 10 | **650** | | **Total** | | | **109 386** | Observa que la primera capa concentra el **92 %** de los parámetros: el cuello de botella de los MLP sobre imágenes es conectar cada píxel con cada neurona (motivo por el que existen las CNN, que comparten pesos). **Parte 2 — Bloque transformer (d_model=64, 4 cabezas, d_ff=256).** *(a) Atención.* Las 4 cabezas no añaden parámetros extra: son la misma matriz 64×64 "troceada" en 4 subespacios de dimensión 16. Cada proyección: `64·64 + 64 = 4 160`. | Matriz | Parámetros | |--------|------------| | Wq | 4 160 | | Wk | 4 160 | | Wv | 4 160 | | Wo (proyección de salida) | 4 160 | | **Subtotal atención** | **16 640** | *(b) FFN.* | Capa | Cálculo | Parámetros | |------|---------|------------| | 64 → 256 | 64·256 + 256 | 16 640 | | 256 → 64 | 256·64 + 64 | 16 448 | | **Subtotal FFN** | | **33 088** | *(c) LayerNorms.* Cada una: `gamma (64) + beta (64) = 128`. Dos: **256**. **Total del bloque: 16 640 + 33 088 + 256 = 49 984 ≈ 50 K parámetros.** Dos observaciones que escalan a los LLM reales: 1. **El FFN pesa el doble que la atención** (33 K vs 17 K). En GPT-3 y sucesores la proporción es similar: la mayoría de los parámetros de un transformer están en los FFN, no en la atención. 2. Un GPT apila docenas de estos bloques: 12 bloques como este ≈ 600 K parámetros + embeddings. GPT-2 small son 12 bloques con d_model=768 → 124 M. La receta es la misma, solo cambian los números. **Consejo profesional:** memoria aproximada de un modelo = nº parámetros × 4 bytes (float32), o × 2 (float16). Los 109 386 parámetros del MLP ocupan ~0.4 MB; un LLM de 7B en float16, ~14 GB. Así se estima si un modelo cabe en tu GPU antes de escribir una línea de código.

Ejercicio 16 — Tamaño de salida de una convolución (4 casos)

Dificultad: · Capítulo relacionado: 4 (CNNs)

La fórmula del tamaño espacial de salida de una convolución es:

O = ⌊(N − K + 2P) / S⌋ + 1

donde N = tamaño de entrada, K = tamaño del kernel, P = padding, S = stride. Calcula O para:

Caso N K P S
a 32 3 0 1
b 32 3 1 1
c 28 5 0 1
d 224 7 3 2

Además: ¿qué tiene de especial la configuración (b)? ¿Y de dónde crees que sale la (d)?

Ver solución | Caso | Cálculo | Salida | |------|---------|--------| | a | (32 − 3 + 0)/1 + 1 = 29 + 1 | **30** | | b | (32 − 3 + 2·1)/1 + 1 = 31 + 1 | **32** | | c | (28 − 5 + 0)/1 + 1 = 23 + 1 | **24** | | d | ⌊(224 − 7 + 2·3)/2⌋ + 1 = ⌊223/2⌋ + 1 = 111 + 1 | **112** | **Caso (b) — convolución "same":** con kernel 3 y padding 1, la salida tiene **exactamente el mismo tamaño que la entrada** (32→32). Es la configuración más usada en las CNN modernas (regla general: `P = (K−1)/2` con stride 1 conserva el tamaño). Permite apilar decenas de capas sin que la imagen "se encoja" hasta desaparecer. **Caso (d) — la primera capa de ResNet:** `conv 7×7, stride 2, padding 3` sobre entrada 224×224 → 112×112. Es literalmente la capa de entrada (stem) de ResNet-18/34/50: reduce la resolución a la mitad de un plumazo mientras extrae 64 canales de features. Fíjate en el papel del stride 2: divide el tamaño espacial entre 2 (con el floor cuando no es exacto). **Verificación rápida en PyTorch:**
import torch
import torch.nn as nn

casos = [(32, 3, 0, 1), (32, 3, 1, 1), (28, 5, 0, 1), (224, 7, 3, 2)]
for N, K, P, S in casos:
    conv = nn.Conv2d(1, 1, kernel_size=K, padding=P, stride=S)  # 1 canal para simplificar
    x = torch.zeros(1, 1, N, N)                                 # batch=1, canal=1, NxN
    print(f"N={N}, K={K}, P={P}, S={S} -> {conv(x).shape[-1]}")
# 30, 32, 24, 112
**Nota:** interioriza los tres casos típicos: `K=3, P=1, S=1` conserva tamaño · `S=2` divide entre 2 · `P=0` recorta `K−1` píxeles. Con eso lees cualquier arquitectura CNN de un vistazo.

Sección C — PyTorch con código

Abre tu editor. Estos ejercicios se resuelven ejecutando código de verdad. Todos usan datos sintéticos con semilla fija para que tus resultados sean reproducibles.


Ejercicio 17 — Reparar 4 errores de shape en PyTorch

Dificultad: · Capítulo relacionado: 3 (Tensores y shapes)

Los errores de shape son el pan de cada día en PyTorch. Aquí tienes 4 fragmentos rotos con el mensaje de error real que producen. Repara cada uno y explica la causa.

import torch
import torch.nn as nn

# --- Fragmento 1 ---
x = torch.randn(32, 10)
W = torch.randn(20, 5)
y = x @ W
# RuntimeError: mat1 and mat2 shapes cannot be multiplied (32x10 and 20x5)

# --- Fragmento 2 ---
logits = torch.randn(32, 4)          # batch de 32, 4 clases
labels = torch.randint(0, 4, (64,))  # ¡64 etiquetas!
loss = nn.CrossEntropyLoss()(logits, labels)
# ValueError: Expected input batch_size (32) to match target batch_size (64).

# --- Fragmento 3 ---
a = torch.randn(8, 3)
b = torch.randn(8, 4)
c = a + b
# RuntimeError: The size of tensor a (3) must match the size of tensor b (4)
# at non-singleton dimension 1

# --- Fragmento 4 ---
conv = nn.Conv2d(in_channels=1, out_channels=8, kernel_size=3)
img = torch.randn(28, 28)            # una imagen en escala de grises
out = conv(img)
# RuntimeError: Expected 3D (unbatched) or 4D (batched) input to conv2d,
# but got input of size: [28, 28]
Ver solución **Fragmento 1 — dimensiones internas incompatibles.** Para `A @ B`, la última dimensión de A debe coincidir con la primera de B: `(32,10) @ (10,5)` , pero `(32,10) @ (20,5)` . La matriz de pesos debe tener tantas filas como features de entrada:
x = torch.randn(32, 10)              # 32 ejemplos, 10 features
W = torch.randn(10, 5)               # CORREGIDO: 10 entradas -> 5 salidas
y = x @ W                            # (32,10)@(10,5) = (32,5) ✅
**Fragmento 2 — batch de logits y batch de labels desalineados.** `CrossEntropyLoss` exige un logit por ejemplo y una etiqueta por ejemplo: mismos batch sizes. El bug real típico: cargar labels de otro DataLoader o duplicar el batch por error.
logits = torch.randn(32, 4)          # 32 ejemplos, 4 clases
labels = torch.randint(0, 4, (32,))  # CORREGIDO: 32 etiquetas, una por ejemplo
loss = nn.CrossEntropyLoss()(logits, labels)  # ✅ (nota: labels son índices, NO one-hot)
**Fragmento 3 — broadcasting imposible.** El broadcasting solo alinea dimensiones si son iguales o si una de ellas es 1. Aquí la dimensión 1 vale 3 en `a` y 4 en `b`: ni iguales ni ampliables. Hay que decidir la intención; si era concatenar features:
a = torch.randn(8, 3)
b = torch.randn(8, 4)
c = torch.cat([a, b], dim=1)         # CORREGIDO: concatenar -> (8, 7)
# (si la intención era sumar, ambos tensores deben tener la MISMA shape de features)
**Fragmento 4 — a la convolución le faltan las dimensiones de batch y canal.** `Conv2d` espera `(batch, canales, alto, ancho)`. Una imagen suelta 28×28 necesita dos dimensiones extra de tamaño 1:
conv = nn.Conv2d(in_channels=1, out_channels=8, kernel_size=3)
img = torch.randn(28, 28)
img = img.unsqueeze(0).unsqueeze(0)  # CORREGIDO: (28,28) -> (1,1,28,28)
out = conv(img)                      # (1, 8, 26, 26) ✅  [26 = 28-3+1]
**Consejo profesional:** ante cualquier error de shape, imprime `tensor.shape` de cada operando justo antes de la línea que falla, y escribe en un comentario la shape esperada de cada tensor del modelo (`# (B, C, H, W)`). Los ingenieros senior anotan shapes; los junior las adivinan.

Ejercicio 18 — Regresión lineal con autograd puro

Dificultad: · Capítulo relacionado: 3 (Autograd)

Sin nn.Module, sin optim: solo tensores y autograd. Genera datos sintéticos y = 3x − 2 + ruido y ajusta w y b a mano:

  1. Genera 100 puntos con torch.manual_seed(42), x ∈ [−1, 1], ruido gaussiano σ=0.1.
  2. Crea w y b como tensores con requires_grad=True.
  3. Entrena 200 pasos: forward (MSE), backward(), actualización manual dentro de torch.no_grad(), y puesta a cero de gradientes.
  4. Comprueba que recuperas aproximadamente w≈3 y b≈−2.
Ver solución
import torch

torch.manual_seed(42)                            # reproducibilidad total

# --- 1. Datos sintéticos: y = 3x - 2 + ruido ---
x = torch.rand(100, 1) * 2 - 1                   # 100 puntos uniformes en [-1, 1]
y = 3 * x - 2 + 0.1 * torch.randn(100, 1)        # recta verdadera + ruido gaussiano

# --- 2. Parámetros entrenables (inicializados aleatoriamente) ---
w = torch.randn(1, requires_grad=True)           # requires_grad: autograd los vigilará
b = torch.randn(1, requires_grad=True)

lr = 0.1                                         # learning rate

# --- 3. Training loop manual ---
for paso in range(200):
    y_pred = w * x + b                           # forward: predicción de la recta actual
    loss = ((y_pred - y) ** 2).mean()            # MSE: error cuadrático medio

    loss.backward()                              # backward: calcula w.grad y b.grad

    with torch.no_grad():                        # apagamos autograd para actualizar:
        w -= lr * w.grad                         # descenso de gradiente sobre w
        b -= lr * b.grad                         # y sobre b
        # (sin no_grad, estas restas entrarían al grafo y romperían el siguiente backward)

    w.grad.zero_()                               # ¡a cero! si no, los gradientes
    b.grad.zero_()                               # se ACUMULARÍAN paso tras paso

    if paso % 50 == 0:
        print(f"paso {paso:3d} | loss={loss.item():.4f} | w={w.item():.3f} | b={b.item():.3f}")

print(f"\nFinal: w = {w.item():.4f} (esperado ~3), b = {b.item():.4f} (esperado ~-2)")
# Final: w ≈ 2.99, b ≈ -2.00  ✅
**Puntos clave del razonamiento:** 1. **`requires_grad=True`** marca los tensores como "hojas entrenables": cada operación que los toque se registra en el grafo computacional. 2. **`loss.backward()`** recorre ese grafo hacia atrás y deposita ∂loss/∂w en `w.grad` y ∂loss/∂b en `b.grad`. 3. **`torch.no_grad()`** en la actualización es obligatorio: la actualización de parámetros no es parte del modelo, es "cirugía externa"; si autograd la registrara, el grafo crecería sin fin y el siguiente `backward()` fallaría o calcularía gradientes erróneos. 4. **`grad.zero_()`** es la versión manual de `optimizer.zero_grad()`: sin ella, el paso 2 usaría gradientes del paso 1 + paso 2 sumados (revisa el Ejercicio 4). **Nota:** acabas de implementar a mano lo que `torch.optim.SGD` hace por ti. Cuando en el siguiente ejercicio uses `optimizer.step()`, ya sabrás exactamente qué hay dentro: `param -= lr * param.grad` bajo `no_grad`.

Ejercicio 19 — Clasificador MLP completo sobre make_moons

Dificultad: · Capítulo relacionado: 3 (nn.Module, DataLoader, training loop)

Construye el pipeline PyTorch completo y canónico sobre el dataset sintético make_moons (dos medialunas entrelazadas, no separables linealmente):

  1. Genera 1 000 puntos (noise=0.2, random_state=42), divide 80/20 train/test y normaliza con media/desviación del train.
  2. Define un MLP 2 → 32 → 32 → 2 con ReLU como subclase de nn.Module.
  3. Entrena 100 épocas con DataLoader (batch 32), CrossEntropyLoss y Adam (lr=1e-3).
  4. Evalúa en test con model.eval() y torch.no_grad(). Deberías superar el 95 % de accuracy.
Ver solución
import torch
import torch.nn as nn
from torch.utils.data import DataLoader, TensorDataset
from sklearn.datasets import make_moons
from sklearn.model_selection import train_test_split

torch.manual_seed(42)                            # semillas para reproducibilidad

# --- 1. Datos sintéticos y preparación ---
X, y = make_moons(n_samples=1000, noise=0.2, random_state=42)  # 2 medialunas
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.2, random_state=42)

media, desv = X_tr.mean(axis=0), X_tr.std(axis=0)  # estadísticas SOLO del train
X_tr = (X_tr - media) / desv                     # normalizar train
X_te = (X_te - media) / desv                     # test con las stats del train (¡nunca las suyas!)

# Convertir a tensores: float32 para features, long para etiquetas de clase
X_tr = torch.tensor(X_tr, dtype=torch.float32)
y_tr = torch.tensor(y_tr, dtype=torch.long)
X_te = torch.tensor(X_te, dtype=torch.float32)
y_te = torch.tensor(y_te, dtype=torch.long)

train_ds = TensorDataset(X_tr, y_tr)             # empareja (X[i], y[i])
train_dl = DataLoader(train_ds, batch_size=32, shuffle=True)  # shuffle en train SIEMPRE

# --- 2. El modelo como subclase de nn.Module ---
class MLP(nn.Module):
    def __init__(self):
        super().__init__()                       # inicializa la maquinaria de nn.Module
        self.red = nn.Sequential(
            nn.Linear(2, 32),                    # 2 features de entrada -> 32 neuronas
            nn.ReLU(),                           # no-linealidad (ver Ejercicio 1)
            nn.Linear(32, 32),                   # capa oculta intermedia
            nn.ReLU(),
            nn.Linear(32, 2),                    # 2 logits de salida (uno por clase)
        )                                        # SIN softmax: CrossEntropyLoss lo incluye

    def forward(self, x):
        return self.red(x)                       # define el flujo de datos

model = MLP()
criterion = nn.CrossEntropyLoss()                # combina log-softmax + NLL loss
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)  # Adam: buen default

# --- 3. Training loop canónico ---
model.train()                                    # modo entrenamiento
for epoca in range(100):
    loss_epoca = 0.0
    for xb, yb in train_dl:                      # itera mini-batches de 32
        optimizer.zero_grad()                    # 1) limpiar gradientes previos
        logits = model(xb)                       # 2) forward
        loss = criterion(logits, yb)             # 3) loss del batch
        loss.backward()                          # 4) backward: calcular gradientes
        optimizer.step()                         # 5) actualizar parámetros
        loss_epoca += loss.item()                # .item(): float suelto, sin grafo

    if (epoca + 1) % 25 == 0:
        print(f"Época {epoca+1:3d} | loss media = {loss_epoca/len(train_dl):.4f}")

# --- 4. Evaluación ---
model.eval()                                     # modo evaluación (afecta a dropout/BN)
with torch.no_grad():                            # sin grafo: más rápido y sin memoria extra
    logits = model(X_te)                         # forward sobre TODO el test de una vez
    preds = logits.argmax(dim=1)                 # clase predicha = logit mayor
    acc = (preds == y_te).float().mean().item()  # proporción de aciertos

print(f"\nAccuracy en test: {acc:.2%}")          # ~97 % típicamente
**Por qué cada pieza está donde está:** - **Normalizar con stats del train:** usar las del test sería *data leakage* — información del test filtrándose al pipeline. - **Sin softmax en el modelo:** `CrossEntropyLoss` espera logits crudos (aplica log-softmax internamente, más estable numéricamente). Softmax + CrossEntropyLoss es un bug clásico. - **El orden `zero_grad → forward → loss → backward → step`** es el latido de todo entrenamiento en PyTorch. Interiorízalo hasta que lo escribas dormido/a. - **make_moons no es separable linealmente**, así que este ejercicio también demuestra empíricamente el Ejercicio 1: sin las ReLU, este modelo se quedaría en ~85 % (frontera recta); con ellas supera el 95 % (frontera curva). ¡Pruébalo quitándolas!

Ejercicio 20 — Dropout y early stopping sobre el MLP anterior

Dificultad: · Capítulos relacionados: 2 y 3

Parte del código del Ejercicio 19 y conviértelo en un entrenamiento profesional con protección contra el sobreajuste:

  1. Reduce el train a solo 80 puntos (para provocar overfitting) y usa el resto como validación.
  2. Añade nn.Dropout(0.3) tras cada ReLU.
  3. Implementa early stopping: si la loss de validación no mejora en 15 épocas (patience), detén el entrenamiento y restaura el mejor checkpoint.
  4. Entrena la versión con y sin regularización (máx. 500 épocas) y compara las accuracies de validación.
Ver solución
import copy
import torch
import torch.nn as nn
from torch.utils.data import DataLoader, TensorDataset
from sklearn.datasets import make_moons

torch.manual_seed(42)

# --- 1. Pocos datos de train a propósito: caldo de cultivo del overfitting ---
X, y = make_moons(n_samples=1000, noise=0.25, random_state=42)
X_tr, y_tr = X[:80], y[:80]                      # SOLO 80 puntos para entrenar
X_va, y_va = X[80:], y[80:]                      # 920 para validar

media, desv = X_tr.mean(axis=0), X_tr.std(axis=0)
X_tr = torch.tensor((X_tr - media) / desv, dtype=torch.float32)
X_va = torch.tensor((X_va - media) / desv, dtype=torch.float32)
y_tr = torch.tensor(y_tr, dtype=torch.long)
y_va = torch.tensor(y_va, dtype=torch.long)

train_dl = DataLoader(TensorDataset(X_tr, y_tr), batch_size=16, shuffle=True)

# --- 2. MLP parametrizado: con o sin dropout ---
class MLP(nn.Module):
    def __init__(self, p_dropout=0.0):
        super().__init__()
        self.red = nn.Sequential(
            nn.Linear(2, 64), nn.ReLU(),
            nn.Dropout(p_dropout),               # apaga neuronas al azar SOLO en train
            nn.Linear(64, 64), nn.ReLU(),
            nn.Dropout(p_dropout),               # (con p=0.0 es una identidad: no hace nada)
            nn.Linear(64, 2),
        )
    def forward(self, x):
        return self.red(x)

def entrenar(p_dropout, patience=15, max_epocas=500):
    """Entrena con early stopping y devuelve la mejor accuracy de validación."""
    torch.manual_seed(0)                         # misma init para comparar en igualdad
    model = MLP(p_dropout)
    opt = torch.optim.Adam(model.parameters(), lr=1e-3)
    crit = nn.CrossEntropyLoss()

    mejor_loss = float("inf")                    # mejor loss de validación vista
    mejor_pesos = None                           # checkpoint del mejor modelo
    sin_mejora = 0                               # contador de paciencia

    for epoca in range(max_epocas):
        model.train()                            # activa dropout
        for xb, yb in train_dl:
            opt.zero_grad()
            crit(model(xb), yb).backward()
            opt.step()

        model.eval()                             # DESACTIVA dropout para validar
        with torch.no_grad():
            val_loss = crit(model(X_va), y_va).item()

        if val_loss < mejor_loss - 1e-4:         # ¿mejora significativa?
            mejor_loss = val_loss
            mejor_pesos = copy.deepcopy(model.state_dict())  # guardar checkpoint
            sin_mejora = 0                       # reiniciar paciencia
        else:
            sin_mejora += 1                      # una época más sin mejorar
            if sin_mejora >= patience:           # paciencia agotada:
                break                            # detener el entrenamiento

    model.load_state_dict(mejor_pesos)           # RESTAURAR el mejor modelo (¡clave!)
    model.eval()
    with torch.no_grad():
        acc = (model(X_va).argmax(1) == y_va).float().mean().item()
    return acc, epoca + 1

acc_sin, ep_sin = entrenar(p_dropout=0.0)
acc_con, ep_con = entrenar(p_dropout=0.3)
print(f"Sin regularización: acc val = {acc_sin:.2%} (paró en época {ep_sin})")
print(f"Con dropout 0.3   : acc val = {acc_con:.2%} (paró en época {ep_con})")
# Resultado típico: la versión con dropout generaliza igual o mejor con 80 puntos,
# y el early stopping evita en ambos casos seguir memorizando el train.
**Los tres detalles que separan esta implementación de una amateur:** 1. **`model.train()` / `model.eval()` en el lugar correcto.** Dropout se comporta distinto en cada modo: en train apaga neuronas con probabilidad p (y reescala el resto); en eval no hace nada. Validar con dropout activo daría losses ruidosas e infladas y estropearía la decisión de early stopping. 2. **Restaurar el mejor checkpoint.** Early stopping sin restauración te deja con el modelo de la última época (que ya empeoró durante `patience` épocas). Guardar `state_dict()` del mejor momento y recargarlo es lo que hace que "parar a tiempo" sirva de algo. 3. **Umbral de mejora (1e-4).** Sin un margen mínimo, fluctuaciones de ruido de ±0.00001 reiniciarían la paciencia eternamente. **Advertencia:** `copy.deepcopy(model.state_dict())` es necesario: `state_dict()` devuelve *referencias* a los tensores vivos, no copias. Sin el deepcopy, tu "checkpoint" se seguiría actualizando con cada `opt.step()` y guardarías... el modelo final.

Ejercicio 21 — Dataset custom desde un DataFrame

Dificultad: · Capítulo relacionado: 3 (Dataset/DataLoader)

En la empresa los datos nunca llegan como tensores: llegan como tablas. Crea un Dataset de PyTorch a partir de un DataFrame de pandas con datos de clientes (sintéticos) para predecir bajas (churn):

  1. Genera el DataFrame sintético con las columnas edad, meses_antiguedad, gasto_mensual, num_incidencias y baja (0/1), 500 filas, semilla 42.
  2. Implementa la clase ChurnDataset(Dataset) con __init__ (recibe el DataFrame y normaliza), __len__ y __getitem__.
  3. Envuélvelo en un DataLoader (batch 64, shuffle) y demuestra que un batch sale con las shapes correctas.
Ver solución
import numpy as np
import pandas as pd
import torch
from torch.utils.data import Dataset, DataLoader

rng = np.random.default_rng(42)                  # generador con semilla

# --- 1. DataFrame sintético de clientes ---
n = 500
df = pd.DataFrame({
    "edad": rng.integers(18, 80, n),                     # años
    "meses_antiguedad": rng.integers(1, 120, n),         # meses como cliente
    "gasto_mensual": rng.normal(50, 20, n).clip(5),      # euros/mes (mínimo 5)
    "num_incidencias": rng.poisson(2, n),                # tickets de soporte
})
# La baja depende (con ruido) de pocas antigüedad y muchas incidencias: señal realista
logit = -0.03 * df["meses_antiguedad"] + 0.6 * df["num_incidencias"] - 0.5
prob = 1 / (1 + np.exp(-logit))                  # sigmoide -> probabilidad de baja
df["baja"] = (rng.random(n) < prob).astype(int)  # muestreo de la etiqueta 0/1

print(df.head(3))
print("Tasa de bajas:", df["baja"].mean().round(3))

# --- 2. Dataset custom: el adaptador tabla -> tensores ---
class ChurnDataset(Dataset):
    """Convierte un DataFrame de clientes en ejemplos (features, etiqueta) para PyTorch."""

    def __init__(self, df, cols_features, col_target):
        X = df[cols_features].values.astype(np.float32)  # ndarray float32 (n, 4)
        # Normalización z-score: cada feature a media 0 y desviación 1
        self.media = X.mean(axis=0)              # se guardan para poder reutilizarlas
        self.desv = X.std(axis=0) + 1e-8         # +epsilon: evitar división por cero
        self.X = torch.from_numpy((X - self.media) / self.desv)  # tensor de features
        self.y = torch.tensor(df[col_target].values, dtype=torch.long)  # etiquetas

    def __len__(self):
        return len(self.X)                       # nº total de ejemplos (obligatorio)

    def __getitem__(self, idx):
        return self.X[idx], self.y[idx]          # devuelve UN ejemplo (obligatorio)

features = ["edad", "meses_antiguedad", "gasto_mensual", "num_incidencias"]
ds = ChurnDataset(df, features, "baja")

# --- 3. DataLoader: batching + shuffle automáticos ---
dl = DataLoader(ds, batch_size=64, shuffle=True)

xb, yb = next(iter(dl))                          # sacar el primer batch
print("Batch X:", xb.shape, xb.dtype)            # torch.Size([64, 4]) torch.float32
print("Batch y:", yb.shape, yb.dtype)            # torch.Size([64])    torch.int64
print("Media por feature del batch (≈0):", xb.mean(dim=0).round(decimals=2))
**El contrato de `Dataset` tiene solo dos cláusulas:** - `__len__` → cuántos ejemplos hay (el DataLoader lo usa para saber cuándo termina la época y para barajar índices). - `__getitem__(idx)` → devuelve el ejemplo `idx` como tupla `(features, etiqueta)`. El DataLoader llama a esto 64 veces (batch_size) y **apila automáticamente** los resultados en tensores con dimensión de batch. **Detalles profesionales del código:** - Guardar `media` y `desv` como atributos no es casual: en producción necesitarás normalizar los clientes nuevos **con las estadísticas del entrenamiento**, no con las suyas. - `float32` para features (PyTorch trabaja en float32 por defecto; pandas suele dar float64 y mezclar tipos da errores), `long` (int64) para etiquetas de clasificación (lo exige `CrossEntropyLoss`). - En este ejemplo pre-convertimos todo a tensores en `__init__` (dataset pequeño, cabe en RAM). Con datos gigantes (imágenes en disco), `__getitem__` cargaría y transformaría cada ejemplo bajo demanda — esa es la verdadera potencia del patrón. **Consejo profesional:** la pareja Dataset/DataLoader es la frontera limpia entre "el mundo de los datos" (pandas, archivos, SQL) y "el mundo del modelo" (tensores, GPU). Mantén toda la lógica sucia de datos dentro del Dataset y tu training loop quedará impoluto.

Ejercicio 22 — CNN pequeña sobre imágenes sintéticas 8×8

Dificultad: · Capítulo relacionado: 4 (CNNs)

Vas a entrenar una CNN sin descargar ningún dataset: las imágenes las generas tú.

  1. Genera 600 imágenes 8×8 de dos clases: barras horizontales (clase 0: una fila aleatoria encendida) y barras verticales (clase 1: una columna aleatoria encendida), con ruido gaussiano σ=0.3 y semilla 0.
  2. Define una CNN: Conv2d(1→8, k=3, padding=1) → ReLU → MaxPool2d(2) → Conv2d(8→16, k=3, padding=1) → ReLU → MaxPool2d(2) → Flatten → Linear(16·2·2 → 2).
  3. Antes de entrenar, calcula a mano por qué la capa final recibe 16·2·2 = 64 features.
  4. Entrena 15 épocas y evalúa (deberías rozar el 100 %).
Ver solución **3 (primero). Seguimiento de shapes a mano** — la pregunta más importante del ejercicio:
Entrada:              (1, 8, 8)    1 canal, 8x8
Conv1 (k=3, p=1):     (8, 8, 8)    padding 1 conserva tamaño (Ejercicio 16b); 8 filtros
MaxPool(2):           (8, 4, 4)    el pooling divide alto y ancho entre 2
Conv2 (k=3, p=1):     (16, 4, 4)   conserva tamaño de nuevo; 16 filtros
MaxPool(2):           (16, 2, 2)   otra división entre 2
Flatten:              16·2·2 = 64  -> por eso Linear(64, 2)
import torch
import torch.nn as nn
from torch.utils.data import DataLoader, TensorDataset

torch.manual_seed(0)

# --- 1. Generador de imágenes sintéticas ---
def generar_datos(n_por_clase=300):
    imgs, labels = [], []
    for clase in [0, 1]:                         # 0 = horizontal, 1 = vertical
        for _ in range(n_por_clase):
            img = torch.zeros(8, 8)              # lienzo negro 8x8
            pos = torch.randint(0, 8, (1,)).item()  # fila/columna aleatoria
            if clase == 0:
                img[pos, :] = 1.0                # encender una FILA completa
            else:
                img[:, pos] = 1.0                # encender una COLUMNA completa
            img = img + 0.3 * torch.randn(8, 8)  # ruido: que no sea trivial
            imgs.append(img)
            labels.append(clase)
    X = torch.stack(imgs).unsqueeze(1)           # (600, 1, 8, 8): añadimos dim de canal
    y = torch.tensor(labels)                     # (600,)
    return X, y

X, y = generar_datos()
perm = torch.randperm(len(X))                    # barajar antes de dividir
X, y = X[perm], y[perm]
X_tr, y_tr, X_te, y_te = X[:480], y[:480], X[480:], y[480:]  # split 80/20
train_dl = DataLoader(TensorDataset(X_tr, y_tr), batch_size=32, shuffle=True)

# --- 2. La CNN ---
class MiniCNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(1, 8, kernel_size=3, padding=1),   # (1,8,8) -> (8,8,8)
            nn.ReLU(),
            nn.MaxPool2d(2),                             # (8,8,8) -> (8,4,4)
            nn.Conv2d(8, 16, kernel_size=3, padding=1),  # (8,4,4) -> (16,4,4)
            nn.ReLU(),
            nn.MaxPool2d(2),                             # (16,4,4) -> (16,2,2)
        )
        self.clasificador = nn.Sequential(
            nn.Flatten(),                                # (16,2,2) -> (64,)
            nn.Linear(16 * 2 * 2, 2),                    # 64 features -> 2 logits
        )

    def forward(self, x):
        return self.clasificador(self.features(x))      # extraer features + clasificar

model = MiniCNN()
crit = nn.CrossEntropyLoss()
opt = torch.optim.Adam(model.parameters(), lr=1e-3)

# --- 4. Entrenamiento y evaluación ---
for epoca in range(15):
    model.train()
    for xb, yb in train_dl:
        opt.zero_grad()                          # el ciclo canónico de siempre
        crit(model(xb), yb).backward()
        opt.step()

model.eval()
with torch.no_grad():
    acc = (model(X_te).argmax(1) == y_te).float().mean().item()
print(f"Accuracy en test: {acc:.2%}")            # ~99-100 %

# Bonus: mirar qué aprendió el primer filtro convolucional
print("Filtro 0 de conv1:\n", model.features[0].weight[0, 0].detach().round(decimals=2))
# Verás pesos con estructura orientada: detectores de bordes horizontales/verticales
**Por qué una CNN y no un MLP aquí:** la clase depende de la **estructura espacial** (fila vs columna), no de píxeles individuales. La convolución detecta el patrón "línea" en cualquier posición con el mismo kernel de 9 pesos (pesos compartidos + equivarianza a traslación), mientras que un MLP tendría que aprender cada posición de la barra por separado. Es el mismo motivo, en miniatura, por el que las CNN dominaron la visión por computador. **Nota:** el bonus final es un hábito excelente: **inspeccionar los filtros aprendidos**. En la primera capa de CNNs reales entrenadas con imágenes naturales emergen detectores de bordes y colores casi idénticos a los del córtex visual V1.

Ejercicio 23 — Transfer learning: congelar capas y cambiar la cabeza

Dificultad: · Capítulos relacionados: 4 y 7

Te dan el esqueleto de un "modelo preentrenado" (simulado, para no descargar pesos) y debes aplicarle la receta estándar de transfer learning:

import torch.nn as nn

class ModeloPreentrenado(nn.Module):
    """Simula un backbone preentrenado de visión con su cabeza original de 1000 clases."""
    def __init__(self):
        super().__init__()
        self.backbone = nn.Sequential(
            nn.Conv2d(3, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
            nn.Conv2d(16, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
            nn.Flatten(),
        )
        self.fc = nn.Linear(32 * 8 * 8, 1000)   # cabeza original: 1000 clases de ImageNet

    def forward(self, x):                        # entrada esperada: (B, 3, 32, 32)
        return self.fc(self.backbone(x))

Tu tarea (tu problema real tiene 4 clases):

  1. Congela todos los parámetros del backbone.
  2. Sustituye la cabeza fc por una nueva para 4 clases.
  3. Verifica contando parámetros: ¿cuántos son entrenables antes y después de congelar?
  4. Crea el optimizador solo con los parámetros entrenables y demuestra con un paso de entrenamiento que el backbone no cambia y la cabeza sí.
Ver solución
import torch
import torch.nn as nn

torch.manual_seed(42)
model = ModeloPreentrenado()                     # en la vida real: models.resnet18(weights=...)

def contar(model):
    """Cuenta parámetros totales y entrenables."""
    total = sum(p.numel() for p in model.parameters())
    entrenables = sum(p.numel() for p in model.parameters() if p.requires_grad)
    return total, entrenables

print("Antes :", contar(model))                  # (2 057 000 aprox, todos entrenables)

# --- 1. Congelar el backbone: sus pesos ya saben "ver" ---
for param in model.backbone.parameters():
    param.requires_grad = False                  # autograd los ignorará: ni gradiente ni update

# --- 2. Sustituir la cabeza: 1000 clases de ImageNet -> nuestras 4 clases ---
model.fc = nn.Linear(32 * 8 * 8, 4)              # capa NUEVA: se inicializa aleatoria
                                                 # y nace con requires_grad=True (entrenable)

total, entrenables = contar(model)
print("Después:", (total, entrenables))
# Backbone: conv1 = 3·16·9+16 = 448 ; conv2 = 16·32·9+32 = 4 640  -> 5 088 congelados
# Cabeza nueva: 2048·4 + 4 = 8 196 entrenables
# Entrenamos el 0.4 % del total original: esa es la gracia del transfer learning

# --- 3. Optimizador SOLO con lo entrenable (buena práctica: explícito y eficiente) ---
optimizer = torch.optim.Adam(
    filter(lambda p: p.requires_grad, model.parameters()),  # filtra los congelados
    lr=1e-3,
)

# --- 4. Demostración: un paso de entrenamiento ---
x = torch.randn(8, 3, 32, 32)                    # batch falso de 8 imágenes 32x32 RGB
y = torch.randint(0, 4, (8,))                    # etiquetas falsas de 4 clases

peso_backbone_antes = model.backbone[0].weight.clone()   # foto del backbone antes
peso_cabeza_antes = model.fc.weight.clone()              # foto de la cabeza antes

optimizer.zero_grad()
loss = nn.CrossEntropyLoss()(model(x), y)        # forward + loss
loss.backward()                                  # backward: solo la cabeza recibe grad
optimizer.step()                                 # update: solo la cabeza cambia

print("¿Backbone cambió?", not torch.equal(peso_backbone_antes, model.backbone[0].weight))
print("¿Cabeza cambió?  ", not torch.equal(peso_cabeza_antes, model.fc.weight))
# ¿Backbone cambió? False   <- congelado ✅
# ¿Cabeza cambió?   True    <- entrenando ✅
**La receta de transfer learning, destilada:** 1. **Congelar** (`requires_grad=False`): el backbone aprendió features genéricas (bordes → texturas → partes) con millones de imágenes; con tus pocos datos solo las estropearías. 2. **Reemplazar la cabeza**: la única parte específica de la tarea original. Al asignar un `nn.Linear` nuevo, nace con pesos aleatorios y entrenable por defecto. 3. **Entrenar solo la cabeza**: pocos parámetros → entrena en minutos, con pocos datos y sin sobreajuste severo. 4. (Opcional, no pedido) *Fine-tuning fino*: tras converger la cabeza, descongelar las últimas capas del backbone con lr muy bajo (10-100× menor). **Consejo profesional:** con el backbone congelado puedes además envolver su forward en `torch.no_grad()` o precalcular sus salidas una sola vez (*feature extraction*): si el backbone no entrena, ¿para qué recomputarlo cada época? Precalcular features convierte horas de GPU en segundos de CPU.

Ejercicio 24 — Clasificar frases con nn.Embedding + promediado

Dificultad: · Capítulo relacionado: 5 (Embeddings y NLP)

Construye el clasificador de texto más simple que funciona de verdad: embedding + promedio + capa lineal, sobre un mini-dataset inline de reseñas en español.

  1. Usa este dataset (12 frases, etiqueta 1 = positiva, 0 = negativa).
  2. Tokeniza (minúsculas + separación por espacios), construye el vocabulario con tokens especiales <pad> y <unk>, y convierte cada frase en índices con padding a longitud fija.
  3. Modelo: nn.Embedding(vocab, 16) → promedio de los embeddings de la frase (ignorando padding) → Linear(16, 2).
  4. Entrena 200 épocas y comprueba que clasifica bien una frase nueva.
datos = [
    ("me encanta este producto es fantastico", 1),
    ("calidad excelente lo recomiendo mucho", 1),
    ("funciona de maravilla estoy feliz", 1),
    ("muy buena compra repetire seguro", 1),
    ("servicio rapido y producto perfecto", 1),
    ("increible relacion calidad precio", 1),
    ("es horrible no funciona nada bien", 0),
    ("una estafa total quiero mi dinero", 0),
    ("pesima calidad se rompio al dia siguiente", 0),
    ("muy decepcionado no lo recomiendo", 0),
    ("el peor producto que he comprado", 0),
    ("llego roto y el servicio es malo", 0),
]
Ver solución
import torch
import torch.nn as nn

torch.manual_seed(42)

# --- 2. Tokenización y vocabulario ---
def tokenizar(frase):
    return frase.lower().split()                 # tokenizador mínimo: espacios

vocab = {"<pad>": 0, "<unk>": 1}                 # 0 reservado a padding, 1 a desconocidos
for frase, _ in datos:
    for tok in tokenizar(frase):
        if tok not in vocab:
            vocab[tok] = len(vocab)              # asignar el siguiente índice libre
print(f"Vocabulario: {len(vocab)} tokens")

MAX_LEN = max(len(tokenizar(f)) for f, _ in datos)  # longitud de la frase más larga

def codificar(frase):
    """Frase -> lista de índices, con <unk> para palabras nuevas y <pad> hasta MAX_LEN."""
    idxs = [vocab.get(t, vocab["<unk>"]) for t in tokenizar(frase)]
    idxs = idxs[:MAX_LEN]                        # truncar si se pasa
    idxs += [vocab["<pad>"]] * (MAX_LEN - len(idxs))  # rellenar si falta
    return idxs

X = torch.tensor([codificar(f) for f, _ in datos])   # (12, MAX_LEN) de índices
y = torch.tensor([lab for _, lab in datos])          # (12,) etiquetas

# --- 3. Modelo: embedding -> promedio (sin padding) -> lineal ---
class ClasificadorBolsa(nn.Module):
    """'Bolsa de embeddings': el orden se pierde, el significado medio se conserva."""
    def __init__(self, vocab_size, dim=16):
        super().__init__()
        self.emb = nn.Embedding(vocab_size, dim, padding_idx=0)  # fila 0 (<pad>) fija a ceros
        self.fc = nn.Linear(dim, 2)              # del vector medio a 2 logits

    def forward(self, x):                        # x: (B, L) índices de tokens
        e = self.emb(x)                          # (B, L, 16): un vector por token
        mascara = (x != 0).unsqueeze(-1).float() # (B, L, 1): 1 en tokens reales, 0 en pads
        suma = (e * mascara).sum(dim=1)          # sumar SOLO los embeddings reales
        cuenta = mascara.sum(dim=1).clamp(min=1) # nº de tokens reales (evita dividir por 0)
        media = suma / cuenta                    # (B, 16): promedio correcto sin padding
        return self.fc(media)                    # (B, 2): logits

model = ClasificadorBolsa(len(vocab))
opt = torch.optim.Adam(model.parameters(), lr=0.01)
crit = nn.CrossEntropyLoss()

# --- 4. Entrenamiento (dataset diminuto: entrenamos con todo de una vez) ---
for epoca in range(200):
    opt.zero_grad()
    loss = crit(model(X), y)
    loss.backward()
    opt.step()
print(f"Loss final: {loss.item():.4f}")

# --- Prueba con frases NUEVAS (con palabras conocidas y desconocidas) ---
model.eval()
pruebas = ["este producto es excelente lo recomiendo",
           "una compra horrible el peor servicio"]
with torch.no_grad():
    for frase in pruebas:
        logits = model(torch.tensor([codificar(frase)]))
        probs = torch.softmax(logits, dim=1)[0]  # ahora sí softmax: queremos probabilidades
        pred = "POSITIVA" if probs[1] > 0.5 else "NEGATIVA"
        print(f"'{frase}' -> {pred} (p_pos={probs[1]:.2f})")
# 'este producto es excelente lo recomiendo' -> POSITIVA
# 'una compra horrible el peor servicio' -> NEGATIVA
**Las ideas que este ejercicio deja fijadas:** 1. **`nn.Embedding` es una tabla de consulta entrenable:** una matriz `(vocab_size, 16)` donde la fila i es el vector del token i. `emb(x)` no multiplica nada: *indexa*. Los vectores empiezan aleatorios y el backprop los organiza — verás que tras entrenar, "excelente" y "fantastico" acaban cerca, y lejos de "horrible". 2. **`padding_idx=0`** hace dos cosas: la fila del `` queda a ceros y **no recibe gradiente**. Aún así, promediamos con máscara para que las frases cortas no diluyan su significado entre pads. 3. **El promedio destruye el orden** ("no funciona bien" ≈ "funciona no bien"): esta es exactamente la limitación que motivó las RNN (capítulo 5) y después la atención (capítulo 6). Este modelo es el punto de partida contra el que todo lo demás se compara. **Advertencia:** el manejo del `` en `codificar` es lo que evita que el modelo explote con palabras nuevas en producción. Un `KeyError` por palabra desconocida en un sistema desplegado es un clásico absoluto de los sistemas NLP caseros.

Ejercicio 25 — Scaled dot-product attention en PyTorch

Dificultad: · Capítulo relacionado: 6 (Self-attention)

Implementa la función attention(Q, K, V, mask=None) en PyTorch y verifícala contra los números que calculaste a mano en el Ejercicio 13. Requisitos:

  1. Debe implementar softmax(QKᵀ/√d + mask)·V y devolver también la matriz de pesos de atención.
  2. Debe soportar una máscara causal opcional (con −∞ en las posiciones prohibidas).
  3. Verifica: (a) que con las Q, K, V del Ejercicio 13 reproduce exactamente aquellos números; (b) que con la máscara causal la fila 1 solo atiende al token 1; (c) que coincide con torch.nn.functional.scaled_dot_product_attention.
Ver solución
import torch
import torch.nn.functional as F

def attention(Q, K, V, mask=None):
    """Scaled dot-product attention: softmax(QK^T / sqrt(d) + mask) V.

    Q, K, V: (..., seq_len, d)  |  mask: broadcastable a (..., seq_len, seq_len)
    Devuelve (salida, pesos_de_atencion).
    """
    d = Q.shape[-1]                              # dimensión de las queries/keys
    scores = Q @ K.transpose(-2, -1)             # (.., L, L): afinidades query-key
    scores = scores / (d ** 0.5)                 # escalar por sqrt(d): estabiliza el softmax
    if mask is not None:
        scores = scores + mask                   # sumar -inf donde está prohibido mirar
    pesos = torch.softmax(scores, dim=-1)        # normalizar por filas (última dim)
    return pesos @ V, pesos                      # mezcla ponderada de values + los pesos

# --- (a) Verificación contra el Ejercicio 13 ---
Q = torch.tensor([[1., 0.], [0., 2.]])           # exactamente los datos del Ejercicio 13
K = torch.tensor([[1., 1.], [0., 1.]])
V = torch.tensor([[1., 2.], [3., 0.]])

salida, pesos = attention(Q, K, V)
print("Pesos:\n", pesos.round(decimals=4))
# [[0.6698, 0.3302],
#  [0.5000, 0.5000]]      <- idéntico al cálculo a mano ✅
print("Salida:\n", salida.round(decimals=4))
# [[1.6604, 1.3396],
#  [2.0000, 1.0000]]      <- idéntico al cálculo a mano ✅

# --- (b) Con máscara causal ---
L = Q.shape[0]                                   # longitud de secuencia (2)
mask = torch.triu(torch.full((L, L), float("-inf")), diagonal=1)
# triu con diagonal=1: -inf ESTRICTAMENTE por encima de la diagonal
# [[0., -inf],
#  [0.,   0.]]   -> el token 1 no puede mirar al token 2; el 2 puede mirar a ambos

salida_c, pesos_c = attention(Q, K, V, mask)
print("Pesos causales:\n", pesos_c.round(decimals=4))
# [[1.0000, 0.0000],      <- el token 1 SOLO se atiende a sí mismo ✅
#  [0.5000, 0.5000]]      <- el token 2 sin cambios (todo su pasado es visible)
print("Salida causal fila 1:", salida_c[0])      # = V[0] = [1., 2.] exactamente

# --- (c) Verificación contra la implementación oficial de PyTorch ---
oficial = F.scaled_dot_product_attention(Q, K, V)            # sin máscara
oficial_causal = F.scaled_dot_product_attention(Q, K, V, is_causal=True)
print("¿Coincide sin máscara?", torch.allclose(salida, oficial, atol=1e-6))    # True
print("¿Coincide causal?    ", torch.allclose(salida_c, oficial_causal, atol=1e-6))  # True
**Razonamiento paso a paso:** 1. **`K.transpose(-2, -1)`** y no `K.T`: transponer solo las dos últimas dimensiones hace la función válida también para tensores con batch y cabezas `(B, h, L, d)` — así es como se usa en un transformer real. 2. **La máscara se suma antes del softmax.** Sumar −∞ y luego exponenciar da exactamente 0: la probabilidad de atender al futuro es cero *matemáticamente*, no aproximadamente. Por eso la máscara es aditiva y no multiplicativa. 3. **Fila 1 causal = `V[0]` exacto:** si solo puedes atenderte a ti mismo, tu salida es tu propio value con peso 1. Es un test unitario perfecto para cualquier implementación de atención causal. 4. La verificación en tres frentes (a mano, contra la referencia oficial, casos límite) es el patrón profesional para validar cualquier componente numérico que escribas. **Consejo profesional:** en producción usa siempre `F.scaled_dot_product_attention`: despacha automáticamente a kernels optimizados (FlashAttention) que son mucho más rápidos y eficientes en memoria. Implementarla a mano es para entenderla y para depurar — que es exactamente lo que acabas de hacer.

Ejercicio 26 — Generar texto con temperatura desde logits

Dificultad: · Capítulo relacionado: 6 (Generación)

Vas a implementar el bucle de generación autoregresivo con un "modelo de lenguaje" de juguete: una matriz de bigramas que da los logits del siguiente token en función del actual. El vocabulario es ["el", "gato", "perro", "duerme", "come", "."].

  1. Implementa muestrear(logits, temperatura) usando torch.multinomial.
  2. Implementa generar(token_inicial, n_pasos, temperatura): bucle autoregresivo que muestrea y realimenta.
  3. Genera 5 secuencias con T=0.1 y 5 con T=1.5 (semilla 123) y comenta las diferencias.
import torch

vocab = ["el", "gato", "perro", "duerme", "come", "."]
# logits[i, j] = tendencia de que al token i le siga el token j (nuestro "modelo")
logits_bigrama = torch.tensor([
    #  el  gato perro duer  come   .
    [-9.0, 3.0,  3.0, -9.0, -9.0, -9.0],   # tras "el": gato o perro
    [-9.0,-9.0, -9.0,  2.5,  2.0, -1.0],   # tras "gato": duerme/come (o punto)
    [-9.0,-9.0, -9.0,  2.0,  2.5, -1.0],   # tras "perro": come/duerme (o punto)
    [ 1.0,-9.0, -9.0, -9.0, -9.0,  3.0],   # tras "duerme": punto (o "el" de nuevo)
    [ 1.0,-9.0, -9.0, -9.0, -9.0,  3.0],   # tras "come": punto (o "el")
    [ 3.0,-9.0, -9.0, -9.0, -9.0, -9.0],   # tras ".": empieza frase con "el"
])
Ver solución
import torch

torch.manual_seed(123)                           # reproducibilidad del muestreo

def muestrear(logits, temperatura):
    """Convierte logits en probabilidades (con temperatura) y muestrea un token."""
    probs = torch.softmax(logits / temperatura, dim=-1)  # T reescala ANTES del softmax
    return torch.multinomial(probs, num_samples=1).item()  # sorteo según probs

def generar(token_inicial, n_pasos, temperatura):
    """Bucle autoregresivo: cada token muestreado se convierte en la siguiente entrada."""
    idx = vocab.index(token_inicial)             # token actual como índice
    secuencia = [token_inicial]
    for _ in range(n_pasos):
        logits = logits_bigrama[idx]             # "forward" del modelo de juguete:
                                                 # logits del siguiente token dado el actual
        idx = muestrear(logits, temperatura)     # elegir el siguiente token
        secuencia.append(vocab[idx])             # añadirlo a la salida...
        # ...y REALIMENTAR: en la próxima vuelta, idx es la nueva entrada.
        # Esto es la autoregresión: la salida de hoy es la entrada de mañana.
    return " ".join(secuencia)

print("--- T = 0.1 (casi determinista) ---")
for _ in range(5):
    print(generar("el", 4, temperatura=0.1))

print("--- T = 1.5 (aventurero) ---")
for _ in range(5):
    print(generar("el", 4, temperatura=1.5))
**Salida típica (los sorteos exactos dependen del orden de llamadas):**
--- T = 0.1 (casi determinista) ---
el gato duerme . el
el perro come . el
el gato duerme . el
el gato duerme . el
el perro come . el
--- T = 1.5 (aventurero) ---
el perro duerme . el
el gato come . el
el gato . el gato       <- eligió la opción rara (punto tras "gato")
el perro come el gato   <- "come el": transición improbable
el gato duerme el perro
**Análisis:** 1. **Con T=0.1** los logits divididos por 0.1 se multiplican ×10 antes del softmax: la diferencia entre 3.0 y 2.5 se convierte en un abismo, y el muestreo se comporta casi como argmax. Las frases son correctas, repetitivas y predecibles: `gato/perro` (empatados a 3.0) es la única variación real. 2. **Con T=1.5** la distribución se aplana: opciones con logit −1.0 (punto prematuro) o incluso transiciones raras reciben probabilidad apreciable. Más variedad, más "creatividad"… y más frases rotas. 3. **La estructura del bucle es exactamente la de GPT:** `forward → logits del último token → softmax con temperatura → multinomial → append → repetir`. En GPT el "modelo" es un transformer de miles de millones de parámetros en lugar de una tabla 6×6, y la entrada es toda la secuencia (con KV cache para no recomputar el pasado) — pero el bucle de generación es literalmente este. **Nota:** `torch.multinomial(probs, 1)` es el corazón del *sampling*: sortea un índice según las probabilidades dadas. Sustituirlo por `probs.argmax()` te da *greedy decoding* (equivalente al límite T→0 del Ejercicio 14).

Sección D — Ejercicios integradores

Cuatro ejercicios finales que combinan todo el módulo. Son los más parecidos al trabajo real: completar código ajeno, ordenar un proyecto, depurar y diseñar.


Ejercicio 27 — Mini-GPT: completa los 5 huecos

Dificultad: · Capítulo relacionado: 6 (Mini-GPT)

Te incorporas a un equipo que dejó un mini-GPT a medio escribir, con 5 huecos marcados como # TODO. Complétalos. El modelo debe poder hacer un forward sin errores y generar tokens.

import torch
import torch.nn as nn
import torch.nn.functional as F

class Bloque(nn.Module):
    def __init__(self, d, n_heads):
        super().__init__()
        self.ln1 = nn.LayerNorm(d)
        self.attn = nn.MultiheadAttention(d, n_heads, batch_first=True)
        self.ln2 = nn.LayerNorm(d)
        self.ffn = nn.Sequential(nn.Linear(d, 4 * d), nn.GELU(), nn.Linear(4 * d, d))

    def forward(self, x, mask):
        h = self.ln1(x)
        att, _ = self.attn(h, h, h, attn_mask=mask)
        x = None    # TODO 1: conexión residual de la atención
        x = None    # TODO 2: LayerNorm + FFN + conexión residual
        return x

class MiniGPT(nn.Module):
    def __init__(self, vocab_size, d=64, n_heads=4, n_bloques=2, max_len=32):
        super().__init__()
        self.tok_emb = nn.Embedding(vocab_size, d)
        self.pos_emb = nn.Embedding(max_len, d)
        self.bloques = nn.ModuleList([Bloque(d, n_heads) for _ in range(n_bloques)])
        self.ln_final = nn.LayerNorm(d)
        self.cabeza = nn.Linear(d, vocab_size)

    def forward(self, idx):                      # idx: (B, L) índices de tokens
        B, L = idx.shape
        pos = torch.arange(L, device=idx.device)
        x = None    # TODO 3: combinar embedding de token y de posición
        mask = None # TODO 4: máscara causal LxL (True/-inf sobre la diagonal superior)
        for bloque in self.bloques:
            x = bloque(x, mask)
        return self.cabeza(self.ln_final(x))     # (B, L, vocab): logits por posición

    @torch.no_grad()
    def generar(self, idx, n_nuevos, temperatura=1.0):
        for _ in range(n_nuevos):
            logits = self(idx)[:, -1, :]         # logits del ÚLTIMO token
            # TODO 5: aplicar temperatura, softmax, muestrear y concatenar a idx
        return idx
Ver solución
import torch
import torch.nn as nn
import torch.nn.functional as F

class Bloque(nn.Module):
    def __init__(self, d, n_heads):
        super().__init__()
        self.ln1 = nn.LayerNorm(d)
        self.attn = nn.MultiheadAttention(d, n_heads, batch_first=True)
        self.ln2 = nn.LayerNorm(d)
        self.ffn = nn.Sequential(nn.Linear(d, 4 * d), nn.GELU(), nn.Linear(4 * d, d))

    def forward(self, x, mask):
        h = self.ln1(x)                          # pre-norm: normalizar ANTES de atender
        att, _ = self.attn(h, h, h, attn_mask=mask)  # self-attention: Q=K=V=h
        # TODO 1 RESUELTO: residual — la salida de la atención se SUMA a la entrada.
        x = x + att                              # sin esto, gradientes se desvanecen (cap. 2/4)
        # TODO 2 RESUELTO: norm + FFN + residual (segunda mitad del bloque transformer)
        x = x + self.ffn(self.ln2(x))            # mismo patrón: transformar y sumar
        return x

class MiniGPT(nn.Module):
    def __init__(self, vocab_size, d=64, n_heads=4, n_bloques=2, max_len=32):
        super().__init__()
        self.tok_emb = nn.Embedding(vocab_size, d)   # QUÉ es cada token
        self.pos_emb = nn.Embedding(max_len, d)      # DÓNDE está cada token
        self.bloques = nn.ModuleList([Bloque(d, n_heads) for _ in range(n_bloques)])
        self.ln_final = nn.LayerNorm(d)
        self.cabeza = nn.Linear(d, vocab_size)       # de vuelta al vocabulario

    def forward(self, idx):
        B, L = idx.shape
        pos = torch.arange(L, device=idx.device)     # [0, 1, ..., L-1]
        # TODO 3 RESUELTO: token + posición se SUMAN (Ejercicio 7: sin esto,
        # "perro muerde hombre" = "hombre muerde perro")
        x = self.tok_emb(idx) + self.pos_emb(pos)    # (B,L,d) + (L,d) -> broadcast a (B,L,d)
        # TODO 4 RESUELTO: máscara causal — True (o -inf) donde está PROHIBIDO mirar.
        # torch.triu con diagonal=1: triángulo superior estricto = el futuro.
        mask = torch.triu(torch.ones(L, L, device=idx.device, dtype=torch.bool), diagonal=1)
        for bloque in self.bloques:
            x = bloque(x, mask)
        return self.cabeza(self.ln_final(x))

    @torch.no_grad()
    def generar(self, idx, n_nuevos, temperatura=1.0):
        for _ in range(n_nuevos):
            logits = self(idx)[:, -1, :]             # solo interesa la última posición
            # TODO 5 RESUELTO: el ritual de muestreo (Ejercicios 14 y 26):
            logits = logits / temperatura            # 1) reescalar con temperatura
            probs = F.softmax(logits, dim=-1)        # 2) logits -> probabilidades
            siguiente = torch.multinomial(probs, 1)  # 3) sortear el siguiente token (B,1)
            idx = torch.cat([idx, siguiente], dim=1) # 4) realimentar: crece la secuencia
        return idx

# --- Prueba de humo: forward y generación sin errores ---
torch.manual_seed(0)
model = MiniGPT(vocab_size=50)
entrada = torch.randint(0, 50, (2, 10))          # batch de 2 secuencias de 10 tokens
logits = model(entrada)
print("Logits:", logits.shape)                   # torch.Size([2, 10, 50]) ✅
salida = model.generar(entrada[:, :3], n_nuevos=5, temperatura=0.8)
print("Generado:", salida.shape)                 # torch.Size([2, 8])  (3 + 5) ✅
**Justificación de cada hueco:** 1. **TODO 1 — residual de atención (`x = x + att`):** las skip connections (capítulo 4, ResNet) dan a los gradientes una autopista directa hacia las primeras capas. Sin residuales, un transformer de más de un par de bloques no entrena. 2. **TODO 2 — segunda subcapa (`x = x + self.ffn(self.ln2(x))`):** el patrón *pre-norm* (LayerNorm antes de la subcapa, residual después) se repite dos veces por bloque: atención (mezclar información entre tokens) y FFN (procesar cada token por separado). 3. **TODO 3 — suma de embeddings:** posición y contenido comparten espacio vectorial y se suman. El broadcast de `(L,d)` sobre `(B,L,d)` aplica las mismas posiciones a todas las secuencias del batch. 4. **TODO 4 — máscara causal:** `nn.MultiheadAttention` acepta una máscara booleana donde `True` = "prohibido atender". `triu(..., diagonal=1)` marca el futuro estricto (Ejercicios 7 y 25). Sin ella, el modelo haría trampas en entrenamiento. 5. **TODO 5 — bucle de muestreo:** temperatura → softmax → multinomial → concat. Idéntico a los Ejercicios 14 y 26; nota el `[:, -1, :]`: aunque el forward computa logits para todas las posiciones, para generar solo importa la última. **Consejo profesional:** fíjate en que este mini-GPT recomputa toda la secuencia en cada paso de generación (coste cuadrático creciente). La optimización que lo evita es el **KV cache**: guardar las K y V de los tokens ya procesados y computar solo las del token nuevo. Es la diferencia entre un juguete y un motor de inferencia.

Ejercicio 28 — Pipeline de fine-tuning: ordena los 10 pasos

Dificultad: · Capítulo relacionado: 7 (Fine-tuning)

Tu empresa quiere fine-tunear (con LoRA) un modelo open-source para clasificar y responder reclamaciones de clientes. Un becario ha escrito los 10 pasos del proyecto... en una pizarra que alguien desordenó. Ordénalos y justifica la posición de cada uno (en especial: qué pasos deben ocurrir necesariamente antes que otros y por qué).

Pasos desordenados:

  • (A) Entrenar con el conjunto de train, monitorizando la loss de validación cada época.
  • (B) Definir el objetivo de negocio y la métrica de éxito (p. ej. F1 macro ≥ 0.85).
  • (C) Desplegar el modelo y monitorizar su rendimiento con datos reales (data drift).
  • (D) Evaluar UNA sola vez sobre el conjunto de test y comparar con la métrica objetivo.
  • (E) Recopilar y etiquetar los datos (reclamaciones históricas con su categoría y respuesta).
  • (F) Elegir el modelo base preentrenado y verificar su licencia para uso comercial.
  • (G) Dividir en train / validación / test de forma estratificada.
  • (H) Configurar LoRA (rango r, alpha, módulos objetivo) y los hiperparámetros de entrenamiento.
  • (I) Tokenizar y formatear los datos según la plantilla que espera el modelo elegido.
  • (J) Analizar los errores del modelo en validación e iterar (datos, hiperparámetros o prompt-template).
Ver solución **Orden correcto: B → E → G → F → I → H → A → J → D → C** | # | Paso | Por qué va aquí | |---|------|-----------------| | 1 | **B** — Objetivo y métrica | Todo lo demás depende de esto: sin métrica de éxito no puedes elegir datos, modelo, ni saber cuándo parar. Los proyectos que empiezan por el modelo y no por la métrica son los que nunca llegan a producción. | | 2 | **E** — Recopilar y etiquetar datos | El activo más caro y lento. Debe empezar cuanto antes y define qué es posible: sin datos suficientes, la conversación sobre fine-tuning se acaba (y toca RAG o prompting, Ejercicio 5). | | 3 | **G** — Split train/val/test | **Antes de tocar nada más con los datos.** El test se sella ahora y no se mira hasta el paso D. Si exploras o ajustas con datos que luego estarán en test, tu evaluación queda contaminada (leakage). Estratificado para que las clases raras aparezcan en los tres conjuntos. | | 4 | **F** — Elegir modelo base y licencia | Antes de tokenizar (paso I), porque **el tokenizador y la plantilla de formato dependen del modelo elegido**. La verificación de licencia es bloqueante: descubrir tras entrenar que la licencia no permite uso comercial es un desastre evitable. | | 5 | **I** — Tokenizar y formatear | Requiere el modelo ya elegido (su tokenizador, su plantilla de chat). Convierte los datos crudos del paso E/G en el formato exacto de entrenamiento. | | 6 | **H** — Configurar LoRA e hiperparámetros | Última decisión antes de entrenar: rango r, alpha, qué matrices adaptar (típicamente las proyecciones de atención), lr, batch size. Depende del modelo (F) y del volumen de datos (E). | | 7 | **A** — Entrenar monitorizando validación | El entrenamiento propiamente dicho, con la validación como brújula (early stopping, comparación de configuraciones — capítulo 2). | | 8 | **J** — Análisis de errores e iterar | El bucle A↔J es el corazón del proyecto: mirar **en qué** falla el modelo en validación (¿una categoría concreta? ¿reclamaciones largas?) e iterar sobre datos, hiperparámetros o formato. Siempre contra validación, **nunca contra test**. | | 9 | **D** — Evaluación única en test | Solo cuando el bucle anterior converge. El test se usa **una vez** para estimar el rendimiento real sin sesgo: si evaluaras en test repetidamente y eligieras según el resultado, el test se convertiría en un segundo conjunto de validación y su estimación quedaría inflada. | | 10 | **C** — Despliegue y monitorización | El final del entrenamiento es el principio de la vida real: las reclamaciones de dentro de 6 meses no se parecerán a las del dataset (data drift), y la monitorización decide cuándo re-entrenar. | **Las tres restricciones de precedencia más importantes (lo que se pregunta en entrevista):** 1. **G antes que cualquier procesamiento o decisión basada en datos** → previene leakage. 2. **F antes que I** → el formato de los datos depende del tokenizador/plantilla del modelo. 3. **J itera contra validación; D ocurre una sola vez al final** → mantiene el test como estimador honesto. **Advertencia:** el error más común en proyectos reales no es técnico sino de proceso: iterar mirando el test "solo esta vez, para ver cómo vamos". Cada mirada al test durante el desarrollo devalúa su veredicto final.

Ejercicio 29 — Depura el training loop roto (5 bugs)

Dificultad: · Capítulos relacionados: 2 y 3

Un compañero te pasa este script: "la loss hace cosas raras, la accuracy de validación baila, y el proceso acaba comiéndose toda la RAM. Llevo dos días con esto". Hay 5 bugs plantados. Encuéntralos, explica el síntoma que causa cada uno y arregla el script.

import torch
import torch.nn as nn
from torch.utils.data import DataLoader, TensorDataset

torch.manual_seed(0)

# Datos sintéticos: 2 clases con features en escalas MUY distintas
n = 2000
X = torch.randn(n, 4)
X[:, 2] = X[:, 2] * 1000 + 5000        # feature 2: rango ~[2000, 8000]
X[:, 3] = X[:, 3] * 500                # feature 3: rango ~[-1500, 1500]
w_real = torch.tensor([2.0, -1.0, 0.001, 0.002])
y = ((X @ w_real) > X @ w_real).long() if False else ((X @ w_real + torch.randn(n)) > 0).long()

X_tr, y_tr, X_va, y_va = X[:1600], y[:1600], X[1600:], y[1600:]
train_dl = DataLoader(TensorDataset(X_tr, y_tr), batch_size=64, shuffle=True)

model = nn.Sequential(nn.Linear(4, 32), nn.ReLU(), nn.Dropout(0.3), nn.Linear(32, 2))
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=5.0)

historico = []
for epoca in range(20):
    model.train()
    total_loss = 0
    for xb, yb in train_dl:
        logits = model(xb)
        loss = criterion(logits, yb)
        loss.backward()
        optimizer.step()
        total_loss = total_loss + loss

    with torch.no_grad():
        val_logits = model(X_va)
        val_acc = (val_logits.argmax(1) == y_va).float().mean()
    historico.append(total_loss)
    print(f"época {epoca}: loss={total_loss:.2f}, val_acc={val_acc:.2%}")
Ver solución **Los 5 bugs, su síntoma y su arreglo:** **Bug 1 — Falta `optimizer.zero_grad()`.** No hay puesta a cero de gradientes en el bucle interno: los gradientes de todos los batches se **acumulan** (Ejercicio 4). *Síntoma:* la loss puede bajar un poco y luego oscilar/diverger de forma caótica, como si el lr creciera solo. *Arreglo:* `optimizer.zero_grad()` como primera línea del bucle interno. **Bug 2 — Learning rate absurdo: `lr=5.0`.** Adam funciona típicamente con lr entre 1e-4 y 1e-2. Con lr=5.0 cada paso catapulta los pesos. *Síntoma:* loss que explota u oscila violentamente desde la primera época (Ejercicio 2, caso C), a veces `NaN`. *Arreglo:* `lr=1e-3`. **Bug 3 — `total_loss = total_loss + loss` sin `.item()`.** `loss` es un tensor **con grafo computacional colgando**. Al acumularlo en `total_loss`, cada suma retiene el grafo de ese batch en memoria: cientos de grafos vivos por época, y encima se guardan en `historico`. *Síntoma:* el consumo de RAM crece sin parar hasta reventar (la queja del compañero). *Arreglo:* `total_loss += loss.item()` — `.item()` extrae el float y suelta el grafo. **Bug 4 — Falta `model.eval()` en la validación.** El modelo tiene `Dropout(0.3)` y se valida en modo train: el dropout sigue apagando neuronas al azar durante la evaluación. *Síntoma:* la accuracy de validación "baila" entre ejecuciones y épocas aunque el modelo no cambie apenas (la otra queja). *Arreglo:* `model.eval()` antes de validar (el `torch.no_grad()` ya está, pero controla el grafo, **no** el modo del modelo: son cosas independientes). **Bug 5 — Datos sin normalizar con features en escalas brutales.** La feature 2 vive en ~[2000, 8000] y la 0 en ~[−3, 3]. La primera capa recibe entradas 3 órdenes de magnitud dispares: gradientes desequilibrados, activaciones saturadas y un paisaje de loss horrible. *Síntoma:* convergencia lentísima o nula incluso arreglando todo lo demás. *Arreglo:* estandarizar con media/desviación **del train**. **Script corregido:**
import torch
import torch.nn as nn
from torch.utils.data import DataLoader, TensorDataset

torch.manual_seed(0)

n = 2000
X = torch.randn(n, 4)
X[:, 2] = X[:, 2] * 1000 + 5000
X[:, 3] = X[:, 3] * 500
w_real = torch.tensor([2.0, -1.0, 0.001, 0.002])
y = ((X @ w_real + torch.randn(n)) > 0).long()

X_tr, y_tr, X_va, y_va = X[:1600], y[:1600], X[1600:], y[1600:]

media, desv = X_tr.mean(0), X_tr.std(0)          # FIX 5: normalizar
X_tr = (X_tr - media) / desv                     # con estadísticas del train
X_va = (X_va - media) / desv                     # aplicadas también a validación

train_dl = DataLoader(TensorDataset(X_tr, y_tr), batch_size=64, shuffle=True)

model = nn.Sequential(nn.Linear(4, 32), nn.ReLU(), nn.Dropout(0.3), nn.Linear(32, 2))
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)   # FIX 2: lr razonable

historico = []
for epoca in range(20):
    model.train()                                # modo train para entrenar (dropout ON)
    total_loss = 0.0
    for xb, yb in train_dl:
        optimizer.zero_grad()                    # FIX 1: limpiar gradientes CADA batch
        logits = model(xb)
        loss = criterion(logits, yb)
        loss.backward()
        optimizer.step()
        total_loss += loss.item()                # FIX 3: float suelto, sin grafo

    model.eval()                                 # FIX 4: dropout OFF para validar
    with torch.no_grad():
        val_acc = (model(X_va).argmax(1) == y_va).float().mean().item()
    historico.append(total_loss)
    print(f"época {epoca}: loss={total_loss:.2f}, val_acc={val_acc:.2%}")
# Ahora: loss desciende suavemente y val_acc sube estable hasta ~95 %+
**Consejo profesional:** este ejercicio es tu checklist de depuración para siempre: (1) ¿zero_grad?, (2) ¿lr razonable?, (3) ¿.item() al acumular?, (4) ¿eval()/train() donde toca?, (5) ¿datos normalizados? Cinco preguntas que resuelven la mayoría de los "la loss hace cosas raras" de la vida real.

Ejercicio 30 — Diseño: clasificador de documentos para una gestoría

Dificultad: · Capítulos relacionados: 5, 6 y 7

Contexto empresarial. Una gestoría española recibe ~3 000 documentos/día por email: nóminas, facturas, contratos, requerimientos de Hacienda, escrituras y "otros". Hoy, dos empleados los clasifican a mano en carpetas (≈4 horas/día entre ambos). La dirección te contrata para automatizarlo. Dispones de: 45 000 documentos históricos ya clasificados (PDF, mezcla de digitales y escaneados), un servidor con una GPU modesta (8 GB), presupuesto ajustado y un requisito estricto: los documentos contienen datos personales y no pueden enviarse a APIs externas.

Diseña el sistema completo y justifica cada pieza:

  1. Pipeline de entrada (¿qué haces con los PDF escaneados?).
  2. Modelo de clasificación: ¿qué arquitectura y por qué? (considera las alternativas del módulo: bolsa de embeddings, RNN/LSTM, BERT fine-tuneado, LLM generativo con prompting, API multimodal).
  3. Estrategia de entrenamiento y evaluación (splits, métricas, clase "otros").
  4. Manejo de los casos dudosos en producción.
  5. Estimación del beneficio y plan de monitorización.
Ver solución Una solución de referencia (hay variantes defendibles; lo importante es la justificación de cada decisión): **1. Pipeline de entrada.** - **PDFs digitales:** extracción directa de texto (pdfplumber/PyMuPDF). Gratis y sin errores. - **PDFs escaneados:** OCR local (p. ej. Tesseract con modelo en español, o PaddleOCR) — *local* porque la restricción de privacidad también aplica a los OCR en la nube. - Normalización: minúsculas, colapsar espacios, truncar a los primeros ~512 tokens. **Justificación clave:** en documentos administrativos, la cabecera (membrete, título, primeras líneas) contiene casi toda la señal de clasificación; "NÓMINA", "FACTURA Nº", "AGENCIA TRIBUTARIA" aparecen arriba. No se necesita el documento completo. **2. Modelo: BERT en español fine-tuneado (p. ej. un RoBERTa/BETO base de Hugging Face) con cabeza de clasificación de 6 clases.** Descarte razonado de las alternativas: - **API externa (GPT-4/Claude/multimodal):** vetada de raíz por la restricción de privacidad (Ejercicio 8, caso hospital). No hay debate. - **Bolsa de embeddings + lineal (Ejercicio 24):** excelente *baseline* — de hecho, se debe construir primero (un día de trabajo) para tener un suelo de comparación. Pero pierde el orden y matices; con 6 clases y documentos ambiguos ("otros") se quedará por debajo. - **RNN/LSTM:** superada por los transformers en calidad y en ecosistema; no hay razón para elegirla en 2026 teniendo encoders preentrenados en español. - **LLM generativo local con prompting:** viable, pero un 7B cuantizado en una GPU de 8 GB clasificando 3 000 docs/día es lento y caro comparado con un encoder de 110 M; además la salida generativa es menos determinista que una cabeza softmax. Para *clasificar con clases fijas y 45 000 ejemplos etiquetados*, el encoder fine-tuneado es la herramienta correcta (Ejercicio 6: entender → encoder). - **A favor de BERT:** es la tarea exacta para la que se diseñó ([CLS] + cabeza), cabe holgadamente en 8 GB (fine-tuning completo o LoRA si hiciera falta), infiere en ~10 ms/doc en GPU (3 000 docs/día = trivial), y con 45 000 ejemplos etiquetados el fine-tuning tiene datos de sobra (7 500/clase de media). **3. Entrenamiento y evaluación.** - Split estratificado 80/10/10 (Ejercicio 28: el test se sella y se usa una vez). Estratificado porque las clases estarán desbalanceadas (muchas facturas, pocas escrituras). - **Métrica: F1 macro** (no accuracy: con desbalanceo, un modelo que ignore "escrituras" tendría accuracy alta igualmente) + matriz de confusión para ver qué pares se confunden. - **La clase "otros" es el punto delicado:** es un cajón de sastre heterogéneo. Vigilar su recall y, sobre todo, el coste asimétrico de errores: archivar mal un *requerimiento de Hacienda* (con plazos legales) es mucho más grave que confundir una nómina con una factura. Considerar pesos por clase en la CrossEntropyLoss. - Entrenamiento: 3-5 épocas, lr ~2e-5 (los encoders preentrenados se afinan con lr pequeños), early stopping por F1 macro de validación (Ejercicio 20), AdamW con weight decay. **4. Casos dudosos en producción: clasificación con umbral + cola humana.** - Si `max(softmax) ≥ 0.9` → archivado automático. - Si no → cola de revisión humana, con las 2 clases más probables pre-marcadas (revisar es un clic, no una lectura). - **Justificación:** convierte el sistema de "reemplazo arriesgado" a "automatización del 85-90 % fácil + humano en el 10-15 % difícil". Los dos empleados pasan de 4 h/día a ~30 min/día, y cada corrección humana se **guarda como dato etiquetado nuevo** (el sistema mejora con el uso). - Regla especial defendible: los "requerimientos de Hacienda" detectados siempre generan además una alerta, por su criticidad legal. **5. Beneficio y monitorización.** - **Beneficio:** ~3.5 h/día liberadas ≈ 75 h/mes ≈ >1 500 €/mes en coste laboral, más la reducción de errores de archivado y de riesgos por plazos perdidos. El proyecto (unas 3-4 semanas de ingeniería) se paga en pocos meses. - **Monitorización:** panel semanal con (a) % de docs auto-archivados vs enviados a revisión, (b) tasa de correcciones humanas por clase (proxy de precisión real), (c) distribución de confianzas — si la confianza media baja con el tiempo, hay *drift* (nuevos formatos de documento, nuevo cliente grande) y toca re-entrenar con las correcciones acumuladas. Re-entrenamiento trimestral o al detectar drift, evaluando siempre contra un test refrescado. **Nota:** observa la arquitectura de la decisión: la **privacidad** eliminó las APIs (veto), la **tarea** (clases fijas + datos etiquetados) eligió encoder sobre generativo, el **hardware** confirmó la viabilidad, y el **umbral + humano** gestionó el riesgo residual. Un buen diseño de sistema de IA es una cadena de descartes justificados, no una elección de moda. **Consejo profesional:** en la entrevista o ante el cliente, presenta siempre el *baseline barato* (bolsa de embeddings, un día de trabajo) junto a la propuesta principal. Demuestra que eliges por ingeniería y no por hype — y a veces el baseline resulta suficiente, que también es una victoria.

Cierre del cuaderno

Si has llegado hasta aquí resolviendo (no solo leyendo), tienes algo que muy poca gente tiene: has calculado una atención a mano y luego la has verificado en PyTorch; has visto colapsar una red sin activaciones y divergir un training loop sin zero_grad(); has completado un GPT con tus propias manos. El deep learning ha dejado de ser una caja negra.

Puntuación:

  • 28-30 correctos: dominio sobresaliente. Pasa a los laboratorios y no mires atrás.
  • 25-27 correctos: aprobado. Revisa los ejercicios fallados y sus capítulos antes de los laboratorios.
  • 20-24 correctos: repasa los capítulos de las secciones donde más fallaste y reintenta esos ejercicios en una semana.
  • < 20 correctos: vuelve a los capítulos 1-3 y rehaz los ejercicios de las secciones A y B. Los cimientos primero.

Advertencia

el criterio de "correcto" en los ejercicios de código no es "se parece a la solución", es "lo ejecuté, funciona y entiendo cada línea". Sé honesto/a contigo: es tu carrera la que se construye aquí.


Anterior Módulo Siguiente
Capítulo 7: Transfer learning y el camino a los LLMs 03-DEEP-LEARNING Laboratorios