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¶
- Intenta resolver cada ejercicio por tu cuenta antes de mirar la solución. El aprendizaje real ocurre cuando te atascas y sales del atasco.
- Los ejercicios de código están pensados para ejecutarse en un entorno con
numpy,torch,pandas,matplotlibyscikit-learninstalados. Todos los datasets son sintéticos y autocontenidos (con semilla fija), así que no necesitas descargar nada. - Los cálculos a mano de la Sección B debes hacerlos con papel y calculadora primero, y solo después verificarlos con NumPy.
- Cada solución incluye el razonamiento paso a paso. No te limites a comparar el resultado final: compara tu proceso con el propuesto.
- 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."
- Explica por qué está cometiendo un error grave.
- Demuestra matemáticamente que una red de N capas lineales sin activaciones colapsa en una única transformación lineal.
- 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: Sustituyendo `h` en la segunda ecuación: 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:
- Una CNN de clasificación de imágenes que entrena con batches de 256 imágenes.
- Un transformer de lenguaje que procesa secuencias de longitud variable.
- Un modelo que, por limitaciones de memoria GPU, solo puede entrenar con batch size = 2.
- 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:
- Explica qué hace exactamente cada una de las tres líneas.
- ¿Por qué PyTorch acumula gradientes por defecto en vez de sobrescribirlos?
- 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.
- 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.
- 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.
- Cliente C (agencia de marketing): necesita resumir notas de prensa en 3 bullets. Probaron una vez y "el modelo a veces se enrolla demasiado".
- 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é:
- Clasificar tickets de soporte en 12 categorías.
- Redactar respuestas automáticas a esos tickets.
- Extraer entidades (nombres de producto, fechas, importes) de facturas escaneadas y ya OCR-izadas.
- Autocompletar código mientras el programador escribe.
- 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:
- 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).
- 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): 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.
- 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.
- 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).
- 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
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:
- Forward: calcula
z,ayL. - Backward con la regla de la cadena: calcula
∂L/∂a,∂a/∂z,∂L/∂z,∂L/∂w,∂L/∂b. - Aplica un paso de SGD y da los nuevos valores de
wyb. - Verifica el gradiente de
wcon 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
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]]
- ¿Qué tamaño tendrá la salida? Justifícalo con la fórmula.
- Calcula las 9 posiciones mostrando la cuenta de al menos las 3 primeras.
- 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:** **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.]]
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:
- Calcula la salida a mano.
- ¿Cuántos valores de entrada "sobreviven"? ¿Qué porcentaje de la información posicional exacta se pierde?
- ¿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** **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.]]
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:
(fila 1 = token 1, fila 2 = token 2). Calcula todos los números de la atención softmax(QKᵀ/√d)·V:
- La matriz de scores
QKᵀ. - Los scores escalados por
√d = √2 ≈ 1.4142. - El softmax por filas (matriz de pesos de atención A).
- La salida
A·V. - 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** **2. Escalado** (÷√2 = 1.4142, para evitar que los productos escalares crezcan con la dimensión y saturen el softmax): **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]` **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]` **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.]]
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:
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:
- ¿Qué temperatura usarías para un asistente de código y cuál para un generador de ideas creativas?
- ¿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]
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:
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
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) ✅
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)
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:
- Genera 100 puntos con
torch.manual_seed(42), x ∈ [−1, 1], ruido gaussiano σ=0.1. - Crea
wybcomo tensores conrequires_grad=True. - Entrena 200 pasos: forward (MSE),
backward(), actualización manual dentro detorch.no_grad(), y puesta a cero de gradientes. - 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 ✅
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):
- Genera 1 000 puntos (
noise=0.2, random_state=42), divide 80/20 train/test y normaliza con media/desviación del train. - Define un MLP
2 → 32 → 32 → 2con ReLU como subclase denn.Module. - Entrena 100 épocas con
DataLoader(batch 32),CrossEntropyLossy Adam (lr=1e-3). - Evalúa en test con
model.eval()ytorch.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
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:
- Reduce el train a solo 80 puntos (para provocar overfitting) y usa el resto como validación.
- Añade
nn.Dropout(0.3)tras cada ReLU. - Implementa early stopping: si la loss de validación no mejora en 15 épocas (patience), detén el entrenamiento y restaura el mejor checkpoint.
- 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.
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):
- Genera el DataFrame sintético con las columnas
edad,meses_antiguedad,gasto_mensual,num_incidenciasybaja(0/1), 500 filas, semilla 42. - Implementa la clase
ChurnDataset(Dataset)con__init__(recibe el DataFrame y normaliza),__len__y__getitem__. - 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))
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ú.
- 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.
- 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). - Antes de entrenar, calcula a mano por qué la capa final recibe
16·2·2 = 64features. - 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
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):
- Congela todos los parámetros del backbone.
- Sustituye la cabeza
fcpor una nueva para 4 clases. - Verifica contando parámetros: ¿cuántos son entrenables antes y después de congelar?
- 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 ✅
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.
- Usa este dataset (12 frases, etiqueta 1 = positiva, 0 = negativa).
- 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. - Modelo:
nn.Embedding(vocab, 16)→ promedio de los embeddings de la frase (ignorando padding) →Linear(16, 2). - 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
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:
- Debe implementar
softmax(QKᵀ/√d + mask)·Vy devolver también la matriz de pesos de atención. - Debe soportar una máscara causal opcional (con −∞ en las posiciones prohibidas).
- 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
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", "."].
- Implementa
muestrear(logits, temperatura)usandotorch.multinomial. - Implementa
generar(token_inicial, n_pasos, temperatura): bucle autoregresivo que muestrea y realimenta. - 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))
--- 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
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) ✅
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 %+
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:
- Pipeline de entrada (¿qué haces con los PDF escaneados?).
- 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).
- Estrategia de entrenamiento y evaluación (splits, métricas, clase "otros").
- Manejo de los casos dudosos en producción.
- 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 |