Saltar a contenido

Ejercicios — Módulo 02: Machine Learning

Bienvenido a la batería de ejercicios del Módulo 02. Aquí vas a consolidar todo lo aprendido en los 7 capítulos: desde el workflow de ML y las métricas hasta pipelines, ensembles y despliegue en producción.

Instrucciones

  • Intenta cada ejercicio durante al menos 15 minutos antes de mirar la solución. La lucha productiva es donde ocurre el aprendizaje real.
  • Teclea el código tú mismo. No copies y pegues: escribir cada línea fija la sintaxis en tu memoria muscular.
  • Todos los ejercicios con código usan datasets sintéticos autocontenidos (np.random con semilla o sklearn.datasets.make_*). No necesitas descargar nada.
  • Requisitos: numpy, pandas, scikit-learn, matplotlib. Para el Ejercicio 29 además fastapi y joblib.
  • En los ejercicios conceptuales, escribe tu respuesta en papel o en un documento antes de comparar con la solución. Razonar por escrito es parte del entrenamiento profesional.

Índice de ejercicios

# Ejercicio Sección Capítulo Dificultad
1 Elegir la métrica según el negocio (3 escenarios) A — Conceptual 2, 3
2 Diagnóstico: ¿overfitting o underfitting? A — Conceptual 1
3 Detectar data leakage en 3 proyectos A — Conceptual 5
4 Elegir algoritmo para 4 casos de negocio A — Conceptual 3, 6
5 Protocolo de validación para datos temporales A — Conceptual 5
6 Criticar un workflow defectuoso A — Conceptual 1, 5
7 ¿Batch o API en tiempo real? A — Conceptual 7
8 Diseñar una política de reentrenamiento A — Conceptual 7
9 Regresión lineal sobre datos sintéticos B — Código 2
10 Ridge vs regresión lineal con colinealidad B — Código 2
11 Interpretar MAE, RMSE, MAPE y R² comparando modelos B — Código 2
12 Matriz de confusión a mano: precision, recall y F1 B — Código 3
13 Mover el umbral según el coste de FP y FN B — Código 3
14 Árbol de decisión: max_depth y overfitting B — Código 3
15 KNN con y sin escalado B — Código 3
16 Interpretar un classification_report B — Código 3
17 ROC-AUC: logística vs árbol B — Código 3
18 class_weight en un dataset desbalanceado B — Código 3
19 K-means: método del codo y silueta C — Código 4
20 PCA y varianza explicada C — Código 4
21 Isolation Forest con anomalías inyectadas C — Código 4
22 Pipeline completo con ColumnTransformer C — Código 5
23 GridSearchCV sobre el pipeline C — Código 5
24 Random Forest con permutation importance C — Código 6
25 HistGradientBoosting con early stopping C — Código 6
26 RF vs boosting con cross_validate C — Código 6
27 Mini-proyecto: churn end-to-end D — Integrador 1–6
28 Detectar y corregir un leakage plantado D — Integrador 5
29 API FastAPI /predict para un pipeline guardado D — Integrador 7
30 Calcular e interpretar el PSI D — Integrador 7

Criterio de aprobación

Considera el módulo superado si resuelves correctamente al menos 25 de los 30 ejercicios (≈83 %). Si fallas varios de la misma sección, vuelve al capítulo correspondiente antes de continuar con los laboratorios.


Sección A — Ejercicios conceptuales

Los ingenieros de ML pasan más tiempo tomando decisiones que escribiendo código. Esta sección entrena el criterio profesional: no hay nada que ejecutar, solo pensar y justificar.


Ejercicio 1 — Elegir la métrica según el negocio (3 escenarios)

Dificultad: · Capítulos: 2 y 3

Eres consultor de ML y tres clientes te piden que definas la métrica principal de sus modelos. Para cada escenario, elige la métrica y justifica en 2-3 frases por qué las alternativas son peores.

Escenario 1 — Banco, detección de fraude en tarjetas. Solo el 0,3 % de las transacciones son fraudulentas. Bloquear una transacción legítima molesta al cliente (coste bajo); dejar pasar un fraude cuesta de media 900 €.

Escenario 2 — Inmobiliaria, tasación automática de viviendas. Los precios van de 60 000 € a 4 000 000 €. Al negocio le importa el error relativo: fallar 30 000 € en un piso de 100 000 € es grave; fallar 30 000 € en una mansión de 3 M€ es aceptable.

Escenario 3 — Hospital, cribado (screening) previo de cáncer. El modelo decide qué pacientes pasan a una prueba diagnóstica más cara. Un falso negativo puede costar una vida; un falso positivo solo implica una prueba extra.

Ver solución **Escenario 1 — Fraude: Recall de la clase fraude como métrica principal, vigilando precision (o directamente el coste esperado en €).** - La **accuracy es inútil**: un modelo que prediga "no fraude" siempre acierta el 99,7 % y no detecta nada. Con un desbalanceo de 0,3 % la accuracy no discrimina. - El coste de un falso negativo (900 €) es mucho mayor que el de un falso positivo (una llamada de verificación o un SMS). Por tanto queremos **maximizar recall** (capturar la mayor parte del fraude) aceptando una precision moderada. - La forma más profesional: definir una **matriz de costes** y minimizar el coste esperado `coste = 900·FN + coste_fp·FP`, ajustando el umbral (ver Ejercicio 13). Como métrica de comparación de modelos independiente del umbral, **PR-AUC** (average precision) es mejor que ROC-AUC en desbalanceo extremo. **Escenario 2 — Tasación: MAPE (error porcentual absoluto medio), o MAE sobre `log(precio)`.** - El enunciado dice literalmente que importa el **error relativo**: eso es la definición de MAPE. Un MAPE del 8 % significa "nos desviamos de media un 8 % del precio real", interpretable para negocio. - **RMSE sería mala elección**: eleva al cuadrado los errores, así que las mansiones de 3 M€ (donde los errores absolutos son grandes pero tolerables) dominarían la métrica y el modelo se optimizaría para los ricos. - Alternativa técnica elegante: entrenar sobre `log(precio)` y usar MAE/RMSE en escala logarítmica, que penaliza errores relativos de forma simétrica (MAPE penaliza más la sobreestimación que la subestimación). **Escenario 3 — Cribado médico: Recall (sensibilidad) con un mínimo obligatorio, p. ej. recall ≥ 0,95, y entre los modelos que lo cumplan, el de mayor precision.** - Un falso negativo es catastrófico (cáncer no detectado); un falso positivo es barato (una prueba extra). La métrica debe reflejar esa asimetría: **recall manda**. - **F1 sería mala elección** porque pondera precision y recall por igual, y aquí no son iguales en absoluto. - En la práctica se fija un **umbral de decisión muy bajo** para garantizar el recall objetivo, y se reporta la precision resultante como coste operativo (cuántas pruebas extra genera el sistema). **Nota:** la lección transversal: **la métrica no la elige el data scientist, la eligen los costes del negocio.** Tu trabajo es traducir esos costes a una métrica optimizable.

Ejercicio 2 — Diagnóstico: ¿overfitting o underfitting?

Dificultad: · Capítulo: 1

Cuatro compañeros te enseñan los resultados de sus modelos. Diagnostica cada caso (overfitting, underfitting o razonable) y propón una acción concreta para mejorarlo.

Caso Métrica en train Métrica en test Modelo
A R² = 0,99 R² = 0,41 Árbol de decisión sin limitar profundidad
B Accuracy = 0,62 Accuracy = 0,61 Regresión logística sobre 40 features con relaciones muy no lineales
C RMSE = 12,3 RMSE = 13,1 Random Forest con 300 árboles
D Accuracy = 1,00 Accuracy = 0,99 Logística para predecir "cliente moroso" usando la feature dias_de_impago
Ver solución **Caso A — Overfitting claro.** Gap enorme entre train (0,99) y test (0,41): el árbol sin limitar memoriza el ruido del entrenamiento. **Acción:** regularizar el árbol (`max_depth=3..8`, `min_samples_leaf≥20`) o cambiar a Random Forest, y elegir hiperparámetros con validación cruzada. **Caso B — Underfitting.** Train y test son casi iguales, pero ambos son bajos: el modelo es demasiado simple para relaciones no lineales (la logística solo dibuja fronteras lineales). No es problema de varianza sino de **sesgo (bias)**. **Acción:** aumentar la capacidad: features polinómicas/interacciones, o un modelo no lineal (árbol, gradient boosting, SVM con kernel RBF). **Caso C — Razonable.** Gap pequeño (12,3 → 13,1, ~6 %): generalización sana. Si el negocio necesita menos error, el camino no es "arreglar overfitting" (no lo hay) sino **mejores features o más datos**. **Caso D — ¡Trampa! No es overfitting: es data leakage.** Un 0,99 en test con gap mínimo parece perfecto, pero `dias_de_impago` **es consecuencia de ser moroso**, no una causa disponible en el momento de la predicción: cuando quieres predecir la morosidad futura, aún no conoces los días de impago. El modelo "predice" algo que ya sucedió. **Acción:** eliminar la feature y reconstruir el dataset respetando la línea temporal (solo información disponible *antes* del evento a predecir). **Advertencia:** cuando un resultado es demasiado bueno para ser verdad, casi nunca es que seas un genio: casi siempre es leakage. Sospecha primero de tus datos y después de tu talento.

Ejercicio 3 — Detectar data leakage en 3 proyectos

Dificultad: · Capítulo: 5

Lee las descripciones de estos tres proyectos reales. En cada uno hay (al menos) una fuente de leakage. Identifícala y explica cómo corregirla.

Proyecto 1 — Predicción de bajas hospitalarias. "Para predecir si un paciente será readmitido en 30 días, usamos todas las columnas de su historia clínica, incluida numero_de_visitas_totales calculada sobre todo el historial del paciente en la base de datos."

Proyecto 2 — Scoring de crédito. "Normalizamos todas las features con StandardScaler.fit(X) sobre el dataset completo, luego hicimos train_test_split y entrenamos. Accuracy en test: 0,88."

Proyecto 3 — Predicción de impago por cliente. "Cada cliente tiene entre 5 y 40 préstamos históricos, cada préstamo es una fila. Hicimos train_test_split(X, y, test_size=0.2, random_state=42) y validamos con KFold estándar. El modelo funciona genial en test pero fracasa con clientes nuevos."

Ver solución **Proyecto 1 — Leakage temporal (feature del futuro).** `numero_de_visitas_totales` se calculó sobre **todo el historial**, incluidas las visitas *posteriores* a la hospitalización que se quiere predecir. Si un paciente fue readmitido, su contador de visitas es mayor: la feature contiene la respuesta. **Corrección:** recalcular cada feature usando **solo datos anteriores a la fecha de predicción** (foto congelada en t₀). Regla de oro: pregúntate para cada columna "¿conocería este valor en el momento de hacer la predicción real?". **Proyecto 2 — Leakage de preprocesado (fit sobre el test).** Al hacer `scaler.fit(X)` **antes** del split, la media y la desviación estándar del test contaminan la transformación del train. Es un leakage "suave" (suele inflar poco las métricas), pero es metodológicamente incorrecto y en producción no tendrás el test para calcular estadísticas. **Corrección:** primero `train_test_split`, luego `scaler.fit(X_train)` y `scaler.transform(X_test)`. La forma profesional: meter el scaler en un `Pipeline`, que garantiza que el fit solo ocurre con datos de entrenamiento incluso dentro de la validación cruzada. **Proyecto 3 — Leakage de grupo (mismo cliente en train y test).** Con un split aleatorio por filas, los préstamos del cliente #123 acaban repartidos entre train y test. El modelo no aprende a predecir impago: aprende a **reconocer clientes** (memoriza sus patrones) y luego "acierta" sus otros préstamos en test. Por eso fracasa con clientes nuevos, que es justo el caso de uso real. **Corrección:** dividir **por cliente**, no por fila: `GroupShuffleSplit` para el split y `GroupKFold(groups=cliente_id)` para la validación cruzada. Así ningún cliente aparece a la vez en train y test. **Consejo profesional:** los tres leakages tienen la misma vacuna: reproduce en la validación **exactamente las condiciones de producción**. Si en producción el cliente será nuevo, en validación debe serlo; si en producción no conocerás el futuro, en validación tampoco.

Ejercicio 4 — Elegir algoritmo para 4 casos de negocio

Dificultad: · Capítulos: 3 y 6

Completa la tabla: para cada caso, elige el algoritmo más adecuado y justifica en una frase. Elige entre: regresión logística, KNN, árbol de decisión, Naive Bayes, SVM, Random Forest, gradient boosting (XGBoost/LightGBM).

Caso Descripción
1 Clasificar 500 000 emails como spam/no spam a partir de la frecuencia de palabras (bolsa de palabras, decenas de miles de columnas dispersas). Debe entrenar y predecir muy rápido.
2 Un banco necesita un modelo de concesión de crédito explicable ante el regulador: cada decisión debe poder justificarse con los coeficientes o reglas del modelo.
3 Competición interna: maximizar el AUC sobre un dataset tabular de 200 000 filas y 60 features mezcladas (numéricas y categóricas), sin restricciones de interpretabilidad.
4 Prototipo con 400 filas y 8 features numéricas ya escaladas; se busca una primera referencia (baseline) no lineal sin ajustar hiperparámetros.
Ver solución | Caso | Algoritmo recomendado | Justificación | |------|----------------------|----------------| | 1 | **Naive Bayes (MultinomialNB)** | Es el clásico para texto disperso: entrena en segundos sobre matrices enormes de conteos, maneja decenas de miles de features y su supuesto de independencia funciona sorprendentemente bien en bolsa de palabras. Una logística con regularización sería la alternativa digna. | | 2 | **Regresión logística** (o un árbol pequeño) | El regulador exige explicabilidad *intrínseca*: los coeficientes de la logística se traducen a "por cada punto de ratio de deuda, el riesgo sube X". Un boosting con SHAP explica a posteriori, pero muchas normativas exigen modelos directamente interpretables. | | 3 | **Gradient boosting (XGBoost/LightGBM)** | En tabular estructurado de tamaño medio-grande, el boosting domina los rankings desde hace una década: captura no linealidades e interacciones, maneja categóricas (LightGBM nativamente) y escala bien a 200 k filas. | | 4 | **KNN** | Sin entrenamiento real, cero hiperparámetros críticos más allá de `k`, no lineal por naturaleza y perfecto con pocas filas y features ya escaladas. Ideal como baseline: si tu modelo "serio" no supera a KNN, algo falla. | **Nota:** ningún algoritmo es "el mejor" en abstracto: la elección depende de tamaño de datos, tipo de features, latencia exigida e interpretabilidad requerida. Memoriza los *nichos*, no los rankings.

Ejercicio 5 — Protocolo de validación para datos temporales

Dificultad: · Capítulo: 5

Una cadena de supermercados te contrata para predecir la demanda diaria por producto de las próximas 2 semanas. Tienes 3 años de historia (2023-01 a 2025-12). Tu compañero propone: "hacemos train_test_split(test_size=0.2, shuffle=True) y KFold(n_splits=5, shuffle=True) para elegir hiperparámetros".

  1. Explica por qué esa propuesta está mal para este problema.
  2. Diseña el protocolo correcto: cómo dividir train/test, qué esquema de CV usar y qué precaución tomar con las features de tipo lag (ventas de días anteriores).
Ver solución **1. Por qué está mal.** Con `shuffle=True`, días de 2025 acaban en train y días de 2023 en test: el modelo **entrena con el futuro para predecir el pasado**. Además, las ventas de días consecutivos están fuertemente autocorrelacionadas: si el 14 de marzo está en train y el 15 en test, el modelo "acierta" el 15 casi por vecindad, inflando las métricas. En producción solo tendrás el pasado; la validación debe imitar esa restricción. Resultado típico de ignorarlo: métricas offline excelentes y un desastre al desplegar. **2. Protocolo correcto.** - **Split temporal (out-of-time):** train = 2023-01 a 2025-09, test = 2025-10 a 2025-12 (el último tramo, intacto, solo para la evaluación final). Nunca shuffle. - **CV para hiperparámetros: `TimeSeriesSplit`** sobre el tramo de train. Cada fold entrena con el pasado y valida con el bloque siguiente:
from sklearn.model_selection import TimeSeriesSplit

# 5 particiones que respetan el orden: train siempre es anterior a validación
tscv = TimeSeriesSplit(n_splits=5, test_size=60)  # valida con bloques de ~2 meses
# se pasa como cv=tscv a GridSearchCV / cross_validate
- **Gap si aplica:** como se predice a 14 días vista, conviene dejar un **hueco de 14 días** entre train y validación (`gap=14` en `TimeSeriesSplit`) para no usar información que en producción aún no existiría. - **Features lag:** las medias móviles y lags (`ventas_ultimos_7_dias`, etc.) deben calcularse con `shift()` de modo que para el día *t* solo usen datos hasta *t−14* (el horizonte de predicción). Un lag de 1 día es leakage si predices a 14 días: en el momento de predecir no conocerás las ventas de ayer respecto a la fecha objetivo. - **Métrica final:** evaluar en el test out-of-time una sola vez, al final, con la métrica de negocio (p. ej. MAE o un MAPE ponderado por volumen). **Consejo profesional:** en datos temporales, la pregunta que destruye el 90 % de los protocolos mal hechos es: *"¿este valor lo conocería el día que lanzo la predicción?"* Aplícala a cada feature y a cada fila de validación.

Ejercicio 6 — Criticar un workflow defectuoso

Dificultad: · Capítulos: 1 y 5

Un data scientist junior te presenta este plan de trabajo para un modelo de predicción de impago. Encuentra al menos 6 errores, clasifícalos por gravedad y propón la corrección de cada uno.

  1. Cargo los datos y elimino todas las filas con algún nulo (pierdo el 40 % de los datos, pero quedan limpios).
  2. Normalizo todo el dataset con StandardScaler.
  3. Selecciono las 10 features más correlacionadas con el target usando todo el dataset.
  4. Divido en train/test 80/20.
  5. Pruebo 12 modelos distintos y me quedo con el que tenga mejor accuracy en test.
  6. Como el dataset tiene 97 % de "no impago", la accuracy me sale 0,96: excelente.
  7. Ajusto hiperparámetros del modelo ganador probando combinaciones contra el test hasta que la accuracy sube a 0,97.
  8. Entrego el modelo: como funciona bien, no hace falta baseline ni documentar nada.
Ver solución **Errores críticos (invalidan las métricas):** 1. **(Paso 2) Escalar antes del split** → leakage de preprocesado: las estadísticas del test contaminan el train. *Corrección:* dividir primero; escalar dentro de un `Pipeline`. 2. **(Paso 3) Seleccionar features usando todo el dataset** → leakage de selección, más grave que el anterior: elegiste las columnas mirando la correlación con el target *también en el test*. Las métricas quedan infladas de forma irrecuperable. *Corrección:* la selección de features es un paso más del pipeline y se ajusta solo con train (o dentro de cada fold de CV). 3. **(Pasos 5 y 7) Usar el test para elegir modelo Y para ajustar hiperparámetros** → el test deja de ser una estimación honesta: has optimizado contra él, es un "overfitting al test" manual. *Corrección:* elegir modelo e hiperparámetros con **validación cruzada sobre train** (`GridSearchCV`); el test se toca **una sola vez**, al final. 4. **(Paso 6) Accuracy con 97 % de desbalanceo** → 0,96 es *peor* que predecir siempre "no impago" (0,97). La métrica elegida no mide nada. *Corrección:* recall/precision de la clase impago, F1, PR-AUC o coste esperado; y comparar siempre contra el baseline trivial. **Errores graves (degradan el modelo o el proceso):** 5. **(Paso 1) Eliminar el 40 % de filas por nulos** → pérdida masiva de datos y, peor, posible **sesgo de selección**: si los nulos no son aleatorios (p. ej. clientes sin historial → más riesgo), el modelo aprende sobre una población distinta a la real. *Corrección:* imputación (`SimpleImputer`) dentro del pipeline, y valorar un indicador binario "era_nulo". 6. **(Paso 8) Sin baseline** → sin un `DummyClassifier` o una regla de negocio simple, no sabes si el modelo aporta algo. *Corrección:* establecer el baseline **antes** de entrenar nada. 7. **(Paso 8) Sin documentación ni reproducibilidad** → sin semillas, versiones ni registro del experimento, el resultado no se puede auditar ni reproducir. *Corrección:* fijar `random_state`, registrar datos/parámetros/métricas (p. ej. con MLflow), documentar decisiones. **Nota:** el orden profesional del workflow es: split primero → baseline → pipeline (imputación+escalado+selección) → CV para modelos e hiperparámetros → evaluación final única en test → documentación. Cada desviación de ese orden suele pagarse con métricas ficticias.

Ejercicio 7 — ¿Batch o API en tiempo real?

Dificultad: · Capítulo: 7

Para cada caso, decide si el modelo debe servirse por inferencia batch (proceso programado que escribe predicciones en una base de datos) o por API en tiempo real (endpoint que responde a peticiones). Justifica con la latencia y la frescura de datos que exige cada caso.

  1. Score de propensión a la baja (churn) que el equipo de retención consulta cada lunes para llamar a clientes.
  2. Detección de fraude en el momento del pago con tarjeta.
  3. Recomendador de "productos similares" en la ficha de producto de un e-commerce con catálogo que cambia poco.
  4. Tasación de un coche usado dentro de un formulario web donde el usuario espera el precio al pulsar "Calcular".
Ver solución 1. **Batch.** El consumo es semanal: basta un job programado (p. ej. domingo por la noche) que escriba el score de todos los clientes en una tabla. Montar una API para esto añade coste, latencia operacional y superficie de fallo sin ningún beneficio. Regla: si nadie necesita la predicción en el segundo en que ocurre algo, batch. 2. **API en tiempo real (y con presupuesto de latencia estricto).** La decisión debe tomarse en los ~100-300 ms que dura la autorización del pago, con las features de *esa* transacción que acaba de suceder. Es imposible precalcular: la transacción no existía hace una hora. Este es el caso de uso arquetípico de inferencia online. 3. **Batch (precalculado).** Los "productos similares" dependen del catálogo, que cambia poco: se pueden precalcular las listas de similares para cada producto cada noche y servirlas desde una tabla/caché. La web solo hace una lectura, latencia mínima y coste bajísimo. Solo haría falta online si la recomendación dependiera del comportamiento del usuario en la sesión actual. 4. **API en tiempo real.** El input (marca, año, km…) lo teclea el usuario en ese momento y espera respuesta inmediata: no se puede precalcular la combinación de todos los coches posibles. Eso sí, la exigencia de latencia es suave (500 ms-1 s es aceptable en un formulario), así que una API sencilla con FastAPI + el pipeline en joblib sobra. **Consejo profesional:** la pregunta decisiva no es "¿qué es más moderno?" sino **"¿la predicción depende de información que solo existe en el instante de la petición?"**. Si la respuesta es no, batch es casi siempre más barato, más simple y más robusto.

Ejercicio 8 — Diseñar una política de reentrenamiento

Dificultad: · Capítulo: 7

Tu modelo de scoring de crédito lleva 6 meses en producción. Diseña su política de reentrenamiento respondiendo a estas preguntas:

  1. ¿Reentrenamiento por calendario, por disparador (trigger) o híbrido? ¿Por qué?
  2. ¿Qué señales monitorizarías para disparar un reentrenamiento? Nombra al menos 3 y el problema de la "etiqueta tardía" en crédito.
  3. ¿Qué validaciones debe pasar el modelo nuevo antes de reemplazar al viejo?
  4. ¿Qué guardarías de cada versión para poder hacer rollback?
Ver solución **1. Híbrido: calendario base + triggers de emergencia.** Un reentrenamiento programado (p. ej. trimestral, alineado con ciclos económicos del negocio) garantiza que el modelo no envejece indefinidamente, y los triggers cubren cambios bruscos (una crisis, un cambio regulatorio, un nuevo canal de captación). Solo calendario reacciona tarde a los shocks; solo triggers puede no dispararse nunca ante una degradación lenta y acumulativa. **2. Señales a monitorizar:** - **Drift de las features (datos de entrada):** PSI por variable entre la distribución de entrenamiento y la ventana reciente (ver Ejercicio 30). Umbral típico: PSI > 0,25 en una feature importante → alerta. - **Drift de las predicciones:** distribución de scores emitidos (si de repente el score medio sube 15 puntos, algo cambió aunque no tengas etiquetas aún). - **Rendimiento real (cuando llega la etiqueta):** AUC/recall sobre créditos ya madurados. - **Señales operacionales:** volumen de peticiones, tasa de nulos en la entrada, latencia. **Advertencia — etiqueta tardía:** en crédito, saber si alguien impaga tarda **meses**. No puedes esperar a la métrica real para reaccionar: por eso el monitoreo de drift de entrada y de scores es la primera línea de defensa, porque se calcula al instante, sin etiquetas. **3. Validaciones antes de promover el modelo nuevo:** - Supera al modelo actual en un **conjunto de validación out-of-time común** (mismos datos para ambos, del período más reciente etiquetado). - Pasa los **tests automáticos**: contrato de entrada/salida, rangos de score, ausencia de leakage, invariancias básicas (p. ej. subir los ingresos no debería empeorar el score, test de monotonía). - **Análisis por segmentos:** que no mejore en global a costa de degradarse en un segmento sensible (nuevos clientes, una región). - Idealmente, despliegue gradual: **shadow mode** (el nuevo puntúa en paralelo sin decidir) o canary con un % pequeño del tráfico. **4. Para poder hacer rollback, versionar y archivar:** el artefacto del pipeline completo (`joblib`), el hash/snapshot de los datos de entrenamiento, hiperparámetros y código (commit), métricas de validación, y las versiones de librerías (requirements). Con MLflow: cada run registrado y el modelo anterior en el registry con estadio "Production" → "Archived", de modo que el rollback sea cambiar un alias, no reconstruir nada. **Nota:** un modelo sin política de reentrenamiento no es un producto, es una foto. Los datos cambian; el plan para acompañarlos forma parte del entregable.

Sección B — Regresión y clasificación con código

Diez ejercicios prácticos. Todos los datos se generan en el propio código: copia el bloque de partida, complétalo y compara con la solución.


Ejercicio 9 — Regresión lineal sobre datos sintéticos

Dificultad: · Capítulo: 2

Una startup de logística quiere estimar el coste de un envío a partir del peso (kg) y la distancia (km). Genera los datos y entrena tu primer modelo.

Datos de partida:

import numpy as np

rng = np.random.default_rng(42)
n = 500
peso = rng.uniform(0.5, 30, n)          # kg
distancia = rng.uniform(5, 1200, n)     # km
# Coste real: 3€ fijos + 0.9€/kg + 0.04€/km + ruido
coste = 3 + 0.9 * peso + 0.04 * distancia + rng.normal(0, 2.5, n)

Se pide: 1. Construir X (peso, distancia) e y (coste), y dividir en train/test 80/20 con random_state=42. 2. Entrenar LinearRegression, mostrar intercepto y coeficientes, y compararlos con los valores reales (3, 0.9, 0.04). 3. Calcular MAE, RMSE y R² en test e interpretar cada número en una frase.

Ver solución
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score

# --- 1. Generar los datos sintéticos ---
rng = np.random.default_rng(42)                     # generador con semilla: reproducible
n = 500
peso = rng.uniform(0.5, 30, n)                      # 500 pesos entre 0.5 y 30 kg
distancia = rng.uniform(5, 1200, n)                 # 500 distancias entre 5 y 1200 km
coste = 3 + 0.9 * peso + 0.04 * distancia + rng.normal(0, 2.5, n)  # fórmula real + ruido gaussiano

X = np.column_stack([peso, distancia])              # matriz (500, 2): una columna por feature
y = coste                                           # vector objetivo (500,)

# División 80/20; random_state fija qué filas van a cada lado
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42
)

# --- 2. Entrenar y examinar coeficientes ---
modelo = LinearRegression()                         # mínimos cuadrados ordinarios
modelo.fit(X_train, y_train)                        # aprende intercepto y coeficientes SOLO con train

print(f"Intercepto: {modelo.intercept_:.3f}  (real: 3)")
print(f"Coef peso:  {modelo.coef_[0]:.3f}  (real: 0.9)")
print(f"Coef dist:  {modelo.coef_[1]:.4f} (real: 0.04)")
# Salida aproximada: intercepto ~2.9, coef peso ~0.90, coef dist ~0.040
# El modelo RECUPERA la fórmula generadora: eso es exactamente lo que hace la regresión lineal.

# --- 3. Evaluar en test ---
y_pred = modelo.predict(X_test)                     # predicciones sobre datos nunca vistos

mae = mean_absolute_error(y_test, y_pred)           # error medio en €, mismas unidades que y
rmse = np.sqrt(mean_squared_error(y_test, y_pred))  # raíz del error cuadrático: penaliza errores grandes
r2 = r2_score(y_test, y_pred)                       # proporción de varianza explicada (1 = perfecto)

print(f"MAE:  {mae:.2f} €")    # ~2.0 €: de media nos desviamos unos 2 € por envío
print(f"RMSE: {rmse:.2f} €")   # ~2.5 €: coherente con el ruido inyectado (σ=2.5)
print(f"R²:   {r2:.3f}")       # ~0.97: el modelo explica ~97% de la variabilidad del coste
**Interpretación:** - **MAE ≈ 2 €**: el error típico de una tarificación es de unos 2 €; negocio decide si es aceptable. - **RMSE ≈ 2,5 €**: prácticamente igual a la desviación del ruido que inyectamos (σ = 2,5). El modelo ha alcanzado el **error irreducible**: no se puede hacer mejor, porque el resto es ruido puro. - **R² ≈ 0,97**: casi toda la variación del coste queda explicada por peso y distancia. **Nota:** cuando conoces el proceso generador (aquí sí, porque lo escribimos nosotros), puedes verificar que el modelo recupera los parámetros. En la vida real no lo conoces, pero este ejercicio te muestra *qué* está estimando una regresión lineal.

Ejercicio 10 — Ridge vs regresión lineal con colinealidad

Dificultad: · Capítulo: 2

Tu dataset inmobiliario tiene dos features casi idénticas: superficie_m2 y superficie_pies2 (una es la otra por 10,76 más un pequeño error de registro). La colinealidad extrema desestabiliza los coeficientes de la regresión lineal.

Se pide: 1. Genera 200 viviendas donde el precio dependa solo de la superficie en m² (más ruido) e incluye la copia colineal en pies². 2. Entrena LinearRegression y Ridge(alpha=10) y compara los coeficientes de ambos. 3. Compara el RMSE en test y explica por qué Ridge produce coeficientes más razonables.

Ver solución
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LinearRegression, Ridge
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
from sklearn.metrics import mean_squared_error

# --- 1. Datos con colinealidad extrema ---
rng = np.random.default_rng(7)
n = 200
m2 = rng.uniform(40, 250, n)                          # superficie real en m²
pies2 = m2 * 10.76 + rng.normal(0, 1.0, n)            # casi la misma info: correlación ~0.9999
precio = 50_000 + 2_000 * m2 + rng.normal(0, 15_000, n)  # el precio SOLO depende de m²

X = np.column_stack([m2, pies2])
X_train, X_test, y_train, y_test = train_test_split(X, precio, test_size=0.2, random_state=7)

# --- 2. Entrenar ambos modelos (escalamos: Ridge penaliza coeficientes, necesita escalas comparables) ---
lin = make_pipeline(StandardScaler(), LinearRegression())
rid = make_pipeline(StandardScaler(), Ridge(alpha=10))   # alpha controla la fuerza de la penalización L2
lin.fit(X_train, y_train)
rid.fit(X_train, y_train)

coef_lin = lin.named_steps["linearregression"].coef_
coef_rid = rid.named_steps["ridge"].coef_
print("Coefs Lineal (estandarizados):", np.round(coef_lin, 0))
print("Coefs Ridge  (estandarizados):", np.round(coef_rid, 0))
# Lineal típico: [ 95000, +25000 ] o incluso signos opuestos gigantes según la semilla:
#   coeficientes enormes que se cancelan entre sí — inestables y no interpretables.
# Ridge: dos coeficientes similares y moderados (~60000 cada uno):
#   reparte el efecto entre las dos copias en vez de inflarlas.

# --- 3. Rendimiento en test ---
rmse_lin = np.sqrt(mean_squared_error(y_test, lin.predict(X_test)))
rmse_rid = np.sqrt(mean_squared_error(y_test, rid.predict(X_test)))
print(f"RMSE Lineal: {rmse_lin:,.0f} €")
print(f"RMSE Ridge:  {rmse_rid:,.0f} €")
# Los RMSE son parecidos (~15 000 €, el ruido irreducible): la predicción apenas cambia,
# pero la ESTABILIDAD y la interpretabilidad de los coeficientes mejoran drásticamente con Ridge.
**Por qué ocurre:** con dos columnas casi idénticas, infinitas combinaciones de coeficientes producen (casi) las mismas predicciones: `100·m2 + 0·pies2` ≈ `-500·m2 + 55.8·pies2`. Mínimos cuadrados elige una combinación arbitraria y extrema, muy sensible al ruido de la muestra (varianza alta). **Ridge añade la penalización L2** (`alpha·Σβ²`), que entre todas las soluciones equivalentes prefiere la de coeficientes pequeños y repartidos: coeficientes estables, interpretables y menos dependientes de la muestra. **Consejo profesional:** si al reentrenar con datos nuevos tus coeficientes bailan violentamente, sospecha colinealidad. Ridge (o eliminar la feature redundante) es la solución estándar. Y recuerda: **Ridge y Lasso siempre con features escaladas**, porque la penalización es sensible a la escala.

Ejercicio 11 — Interpretar MAE, RMSE, MAPE y R² comparando modelos

Dificultad: · Capítulo: 2

Tu equipo ha evaluado dos modelos de predicción de ventas diarias (en €) sobre el mismo test. Este es el código que genera la situación; ejecútalo y responde.

Código de partida:

import numpy as np

rng = np.random.default_rng(0)
y_test = rng.uniform(200, 5000, 300)                 # ventas reales diarias

# Modelo A: error moderado y constante en todas las tiendas
pred_a = y_test + rng.normal(0, 150, 300)

# Modelo B: casi perfecto en general, pero con 6 días catastróficos
pred_b = y_test + rng.normal(0, 60, 300)
pred_b[:6] += np.array([3000, -2800, 3200, -3000, 2900, -3100])

Se pide: 1. Calcula MAE, RMSE, MAPE y R² de ambos modelos. 2. ¿Qué modelo gana según MAE? ¿Y según RMSE? Explica por qué discrepan. 3. ¿Cuál elegirías si un error puntual enorme implica rotura de stock carísima? ¿Y si solo importa el error típico del día a día?

Ver solución
import numpy as np
from sklearn.metrics import (mean_absolute_error, mean_squared_error,
                             mean_absolute_percentage_error, r2_score)

rng = np.random.default_rng(0)
y_test = rng.uniform(200, 5000, 300)

pred_a = y_test + rng.normal(0, 150, 300)            # errores moderados SIEMPRE
pred_b = y_test + rng.normal(0, 60, 300)             # errores pequeños...
pred_b[:6] += np.array([3000, -2800, 3200, -3000, 2900, -3100])  # ...salvo 6 desastres

def informe(nombre, y, pred):
    mae = mean_absolute_error(y, pred)                       # media de |error|: robusta a outliers
    rmse = np.sqrt(mean_squared_error(y, pred))              # eleva al cuadrado: castiga errores grandes
    mape = mean_absolute_percentage_error(y, pred) * 100     # error relativo medio en %
    r2 = r2_score(y, pred)                                   # varianza explicada
    print(f"{nombre}:  MAE={mae:7.1f}  RMSE={rmse:7.1f}  MAPE={mape:5.1f}%  R²={r2:.3f}")

informe("Modelo A", y_test, pred_a)
informe("Modelo B", y_test, pred_b)
# Salida aproximada:
# Modelo A:  MAE=  121.9  RMSE=  152.5  MAPE=  8.9%  R²=0.988
# Modelo B:  MAE=  106.5  RMSE=  437.4  MAPE=  5.6%  R²=0.902
**2. ¿Quién gana?** - **Según MAE (y MAPE): gana B** (~107 € vs ~122 €). En el 98 % de los días, B es claramente mejor (σ 60 vs 150); los 6 desastres, divididos entre 300 días, apenas mueven la *media* del error absoluto. - **Según RMSE (y R²): gana A** con diferencia (152 vs 437). El cuadrado convierte un error de 3000 € en 9 000 000 de "unidades de castigo": seis outliers dominan por completo el RMSE de B. - **Por qué discrepan:** MAE trata todos los euros de error por igual; RMSE pondera cada error por su propio tamaño. La discrepancia MAE↔RMSE es en sí misma un diagnóstico: **si RMSE ≫ MAE, hay errores extremos escondidos**. En A, RMSE/MAE ≈ 1,25 (errores homogéneos, lo esperable en ruido gaussiano); en B, RMSE/MAE ≈ 4. **3. Decisión de negocio:** - **Rotura de stock carísima por errores puntuales → Modelo A.** El coste real es no lineal (un error de 3000 € cuesta muchísimo más que 20 errores de 150 €): RMSE representa mejor ese coste, y A no tiene catástrofes. - **Solo importa el error típico diario → Modelo B.** Su día normal es mucho más preciso; si los errores grandes no tienen coste desproporcionado, B optimiza el funcionamiento cotidiano. **Nota:** ninguna métrica es "la buena": cada una *codifica una función de coste implícita*. Elegir métrica = declarar cuánto te duele cada tipo de error. Por eso este ejercicio y el Ejercicio 1 son el mismo músculo.

Ejercicio 12 — Matriz de confusión a mano: precision, recall y F1

Dificultad: · Capítulo: 3

Tu modelo antispam ha clasificado 200 emails de test. Resultados: de los 40 emails que eran spam, el modelo marcó 32 como spam. De los 160 emails legítimos, marcó 12 como spam.

Se pide (primero a mano, con calculadora, y después verificándolo con sklearn): 1. Construye la matriz de confusión (TP, FP, FN, TN) tomando "spam" como clase positiva. 2. Calcula accuracy, precision, recall y F1. 3. En el contexto antispam, ¿qué error es más grave, un FP o un FN? ¿Qué métrica vigilarías?

Ver solución **1. Matriz de confusión (positivo = spam):** - **TP = 32** → spam reales marcados como spam (aciertos sobre la clase positiva). - **FN = 40 − 32 = 8** → spam que se coló en la bandeja de entrada. - **FP = 12** → emails legítimos enviados injustamente a la carpeta de spam. - **TN = 160 − 12 = 148** → legítimos correctamente dejados en paz. | | Predicho: spam | Predicho: no spam | |--------------------|:---:|:---:| | **Real: spam** | TP = 32 | FN = 8 | | **Real: no spam** | FP = 12 | TN = 148 | **2. Métricas a mano:** - **Accuracy** = (TP+TN)/total = (32+148)/200 = **0,90** → 90 % de emails bien clasificados. - **Precision** = TP/(TP+FP) = 32/(32+12) = 32/44 = **0,727** → cuando el modelo grita "¡spam!", acierta el 72,7 % de las veces. - **Recall** = TP/(TP+FN) = 32/(32+8) = 32/40 = **0,80** → captura el 80 % del spam real. - **F1** = 2·(P·R)/(P+R) = 2·(0,727·0,80)/(0,727+0,80) = 2·0,582/1,527 = **0,762** → media armónica: castiga que cualquiera de las dos sea baja. **Verificación con sklearn:**
import numpy as np
from sklearn.metrics import confusion_matrix, precision_score, recall_score, f1_score

# Reconstruimos los 200 emails como vectores de etiquetas (1 = spam)
y_true = np.array([1]*40 + [0]*160)                  # 40 spam reales, 160 legítimos
y_pred = np.array([1]*32 + [0]*8 + [1]*12 + [0]*148) # mismas cifras del enunciado, en orden

print(confusion_matrix(y_true, y_pred))
# [[148  12]     <- fila 0: reales negativos (TN, FP)
#  [  8  32]]    <- fila 1: reales positivos (FN, TP)
# ¡Ojo! sklearn ordena las filas/columnas por etiqueta (0, 1): TN arriba-izquierda.

print(f"Precision: {precision_score(y_true, y_pred):.3f}")  # 0.727
print(f"Recall:    {recall_score(y_true, y_pred):.3f}")     # 0.800
print(f"F1:        {f1_score(y_true, y_pred):.3f}")         # 0.762
**3. ¿FP o FN?** En antispam, **el FP es más grave**: mandar a spam el email de tu jefe o una factura tiene un coste alto e invisible (el usuario no lo ve). Un FN solo molesta un instante (borras el spam a mano). Por eso los filtros antispam se calibran para **precision alta** aunque el recall baje: mejor dejar pasar algo de spam que secuestrar correo legítimo. Aquí la precision de 0,727 sería inaceptable en un producto real (1 de cada 4 emails marcados como spam es legítimo). **Advertencia:** memoriza la lectura, no la fórmula: *precision = pureza de lo que marcas; recall = cobertura de lo que existe*. Con esa frase reconstruyes todo.

Ejercicio 13 — Mover el umbral según el coste de FP y FN

Dificultad: · Capítulo: 3

Una aseguradora usa un modelo para detectar siniestros fraudulentos que pasan a investigación manual. Costes: investigar un parte legítimo (FP) cuesta 50 €; pagar un fraude no detectado (FN) cuesta 2 000 €. El umbral por defecto (0,5) no tiene por qué ser el óptimo.

Datos de partida:

from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression

X, y = make_classification(n_samples=4000, n_features=12, n_informative=6,
                           weights=[0.9, 0.1], random_state=42)  # 10% de fraude
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, stratify=y, random_state=42)

modelo = LogisticRegression(max_iter=1000).fit(X_train, y_train)

Se pide: 1. Obtén las probabilidades con predict_proba y evalúa el coste total en test con el umbral 0,5. 2. Barre umbrales de 0,05 a 0,95 y encuentra el que minimiza coste = 50·FP + 2000·FN. 3. Compara precision y recall en ambos umbrales y explica el desplazamiento.

Ver solución
import numpy as np
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import confusion_matrix, precision_score, recall_score

# --- Datos y modelo del enunciado ---
X, y = make_classification(n_samples=4000, n_features=12, n_informative=6,
                           weights=[0.9, 0.1], random_state=42)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, stratify=y, random_state=42)   # stratify mantiene el 10% en ambos lados
modelo = LogisticRegression(max_iter=1000).fit(X_train, y_train)

# --- 1. Probabilidades y coste con umbral 0.5 ---
proba = modelo.predict_proba(X_test)[:, 1]              # columna 1: P(fraude) para cada siniestro

def coste_total(y_true, proba, umbral):
    pred = (proba >= umbral).astype(int)                # decisión binaria según el umbral
    tn, fp, fn, tp = confusion_matrix(y_true, pred).ravel()  # desempaqueta la matriz 2x2
    return 50 * fp + 2000 * fn, fp, fn                  # coste según la matriz de costes del negocio

c05, fp05, fn05 = coste_total(y_test, proba, 0.5)
print(f"Umbral 0.50 -> FP={fp05}, FN={fn05}, coste={c05:,} €")
# Típico: pocos FP pero bastantes FN -> coste alto, porque cada FN vale 2000 €

# --- 2. Barrido de umbrales ---
umbrales = np.arange(0.05, 0.96, 0.01)                  # 91 umbrales candidatos
costes = [coste_total(y_test, proba, u)[0] for u in umbrales]
mejor_u = umbrales[int(np.argmin(costes))]              # umbral de coste mínimo
c_opt, fp_opt, fn_opt = coste_total(y_test, proba, mejor_u)
print(f"Umbral óptimo {mejor_u:.2f} -> FP={fp_opt}, FN={fn_opt}, coste={c_opt:,} €")
# El óptimo cae MUY por debajo de 0.5 (típicamente ~0.05-0.15): conviene investigar
# ante la mínima sospecha, porque investigar es 40 veces más barato que pagar un fraude.

# --- 3. Precision y recall en ambos umbrales ---
for u in [0.5, mejor_u]:
    pred = (proba >= u).astype(int)
    print(f"u={u:.2f}  precision={precision_score(y_test, pred):.3f}"
          f"  recall={recall_score(y_test, pred):.3f}")
# u=0.50: precision alta (~0.9), recall modesto (~0.6-0.7)
# u óptimo: precision baja (~0.3-0.5), recall alto (~0.95): aceptamos investigar
# muchos partes legítimos a cambio de no dejar escapar casi ningún fraude.
**Explicación del desplazamiento.** El umbral teóricamente óptimo con costes asimétricos es aproximadamente `coste_FP / (coste_FP + coste_FN) = 50/2050 ≈ 0,024`: basta una probabilidad de fraude pequeña para que investigar ya sea rentable en esperanza. Bajar el umbral convierte FN (carísimos) en FP (baratos): el recall sube y la precision baja, y el **coste total** —la única métrica que le importa al CFO— cae. En la práctica el óptimo empírico no coincide exactamente con la fórmula porque las probabilidades del modelo no están perfectamente calibradas. **Consejo profesional:** entrenar el modelo y elegir el umbral son **dos decisiones separadas**. El modelo aprende probabilidades; el umbral codifica la economía del negocio. Nunca aceptes el 0,5 por defecto sin preguntar cuánto cuesta cada tipo de error. Y el umbral se elige con un conjunto de validación, no con el test final.

Ejercicio 14 — Árbol de decisión: max_depth y overfitting

Dificultad: · Capítulo: 3

Vas a ver el sobreajuste con tus propios ojos. Entrena árboles de decisión con max_depth ∈ {1, 2, 3, 5, 8, 12, None} sobre un dataset sintético y tabula accuracy en train y en test.

Datos de partida:

from sklearn.datasets import make_classification

X, y = make_classification(n_samples=1500, n_features=20, n_informative=8,
                           n_redundant=4, flip_y=0.08, random_state=42)

Se pide: 1. Divide 70/30 y entrena un árbol por cada profundidad. 2. Construye una tabla profundidad | acc_train | acc_test | gap. 3. Identifica la zona de underfitting, la profundidad óptima y la zona de overfitting.

Ver solución
import pandas as pd
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier
from sklearn.metrics import accuracy_score

# --- 1. Datos y split ---
X, y = make_classification(n_samples=1500, n_features=20, n_informative=8,
                           n_redundant=4, flip_y=0.08, random_state=42)
# flip_y=0.08: un 8% de etiquetas aleatorias = ruido irreducible que NADIE puede acertar
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, stratify=y, random_state=42)

# --- 2. Un árbol por profundidad ---
filas = []
for depth in [1, 2, 3, 5, 8, 12, None]:
    arbol = DecisionTreeClassifier(max_depth=depth, random_state=42)
    arbol.fit(X_train, y_train)                              # entrena con train
    acc_tr = accuracy_score(y_train, arbol.predict(X_train)) # rendimiento sobre lo memorizado
    acc_te = accuracy_score(y_test, arbol.predict(X_test))   # rendimiento sobre lo nunca visto
    filas.append({"max_depth": str(depth), "acc_train": round(acc_tr, 3),
                  "acc_test": round(acc_te, 3), "gap": round(acc_tr - acc_te, 3)})

print(pd.DataFrame(filas).to_string(index=False))
# Salida típica:
# max_depth  acc_train  acc_test    gap
#         1      0.766     0.749  0.017   <- underfitting: demasiado simple
#         2      0.802     0.780  0.022
#         3      0.836     0.809  0.027
#         5      0.883     0.816  0.067   <- zona dulce aproximada
#         8      0.952     0.800  0.152   <- empieza el sobreajuste
#        12      0.997     0.784  0.213
#      None      1.000     0.771  0.229   <- memorización total del train
**3. Lectura de la tabla (curva clásica de complejidad):** - **Underfitting (depth 1-2):** train y test bajos y pegados. El árbol de profundidad 1 es literalmente una sola pregunta ("stump"): no tiene capacidad para el patrón. Error dominado por **sesgo**. - **Óptimo (depth ≈ 3-5):** el accuracy de **test** alcanza su máximo (~0,81-0,82). Fíjate: no es donde train es máximo, sino donde test deja de mejorar. - **Overfitting (depth ≥ 8):** train sigue subiendo hasta 1,0 (el árbol crece hasta memorizar cada fila, incluido el 8 % de etiquetas ruidosas), pero test **empeora**: el árbol aprende ruido que no se repite. Error dominado por **varianza**. El gap (0,15 → 0,23) es el termómetro del sobreajuste. **Nota:** con `flip_y=0.08`, el techo teórico de accuracy en test ronda 0,92 por el ruido de etiquetas… pero el techo *práctico* de un árbol solo aquí es menor. Random Forest (Ejercicio 24) ataca exactamente esta debilidad: promedia muchos árboles profundos para bajar la varianza sin subir el sesgo.

Ejercicio 15 — KNN con y sin escalado

Dificultad: · Capítulo: 3

KNN decide por distancias, y las distancias se las come la feature con la escala más grande. Vas a demostrarlo con un caso donde la feature útil está en escala pequeña y una feature casi inútil en escala enorme.

Datos de partida:

import numpy as np

rng = np.random.default_rng(42)
n = 800
# Feature útil: separa las clases, pero vive en [0, 4]
util = np.concatenate([rng.normal(1.0, 0.6, n//2), rng.normal(2.8, 0.6, n//2)])
# Feature casi inútil: apenas separa, pero vive en [0, 100000]
inutil = np.concatenate([rng.normal(50_000, 20_000, n//2), rng.normal(51_000, 20_000, n//2)])
X = np.column_stack([util, inutil])
y = np.array([0]*(n//2) + [1]*(n//2))

Se pide: 1. Divide 70/30 estratificado y entrena KNeighborsClassifier(n_neighbors=5) sin escalar. Mide accuracy. 2. Repite con un Pipeline(StandardScaler + KNN). Mide accuracy. 3. Explica la diferencia calculando qué feature domina la distancia euclídea sin escalado.

Ver solución
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.neighbors import KNeighborsClassifier
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import make_pipeline
from sklearn.metrics import accuracy_score

# --- Datos del enunciado ---
rng = np.random.default_rng(42)
n = 800
util = np.concatenate([rng.normal(1.0, 0.6, n//2), rng.normal(2.8, 0.6, n//2)])
inutil = np.concatenate([rng.normal(50_000, 20_000, n//2), rng.normal(51_000, 20_000, n//2)])
X = np.column_stack([util, inutil])
y = np.array([0]*(n//2) + [1]*(n//2))

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, stratify=y, random_state=42)

# --- 1. KNN sin escalar ---
knn_crudo = KNeighborsClassifier(n_neighbors=5)
knn_crudo.fit(X_train, y_train)
acc_crudo = accuracy_score(y_test, knn_crudo.predict(X_test))
print(f"KNN sin escalar: {acc_crudo:.3f}")     # ~0.52: ¡prácticamente una moneda al aire!

# --- 2. KNN con escalado dentro de un pipeline ---
knn_escalado = make_pipeline(StandardScaler(), KNeighborsClassifier(n_neighbors=5))
# El pipeline garantiza que el scaler hace fit SOLO con train (sin leakage)
knn_escalado.fit(X_train, y_train)
acc_esc = accuracy_score(y_test, knn_escalado.predict(X_test))
print(f"KNN con escalado: {acc_esc:.3f}")      # ~0.92: la feature útil por fin cuenta

# --- 3. ¿Quién domina la distancia? ---
# Distancia euclídea entre dos puntos: sqrt((Δutil)² + (Δinutil)²)
# Δutil típico ~ 1.8 (separación entre clases); Δinutil típico ~ 20 000 (ruido)
print(f"Contribución típica de 'util'  al cuadrado de la distancia: {1.8**2:,.0f}")
print(f"Contribución típica de 'inutil' al cuadrado de la distancia: {20_000**2:,.0f}")
# 3.24 contra 400 000 000: la feature inútil pesa cien millones de veces más.
# Sin escalar, los "vecinos más cercanos" se eligen SOLO por la feature ruidosa.
**Conclusión:** sin escalado, la distancia euclídea es en la práctica `|Δinutil|`: la feature informativa aporta un 0,000001 % de la distancia y KNN clasifica al azar (~0,52). Tras `StandardScaler`, ambas features tienen desviación 1, la separación real de la feature útil (≈3σ) domina y el accuracy salta a ~0,92. **Advertencia:** esta obligación de escalar aplica a **todo algoritmo basado en distancias o en magnitud de coeficientes**: KNN, SVM, k-means, PCA, regresión regularizada (Ridge/Lasso) y redes neuronales. Los árboles y sus ensembles (Random Forest, boosting) son la gran excepción: parten cada feature por separado y las escalas no les afectan.

Ejercicio 16 — Interpretar un classification_report

Dificultad: · Capítulo: 3

Te pasan este classification_report de un modelo de triaje de tickets de soporte en tres categorías. Sin ver el código, respóndelo todo a partir del informe.

              precision    recall  f1-score   support

     factura       0.91      0.94      0.92       420
     tecnico       0.78      0.83      0.80       310
        baja       0.45      0.28      0.35        70

    accuracy                           0.84       800
   macro avg       0.71      0.68      0.69       800
weighted avg       0.82      0.84      0.83       800

Se pide: 1. ¿Qué significa exactamente el recall de 0,28 de la clase baja? ¿Cuántos tickets de baja se están perdiendo? 2. La accuracy es 0,84: ¿por qué es engañosa aquí? ¿Qué revela la diferencia entre macro avg y weighted avg? 3. Si baja es la clase más importante para el negocio (retención de clientes), propón 3 acciones concretas para mejorar su detección.

Ver solución **1. El recall 0,28 de `baja`.** De los **70 tickets reales** de solicitud de baja (columna `support`), el modelo solo identifica el 28 %: `0,28 × 70 ≈ 20` detectados y **50 tickets de baja perdidos**, que se enrutan como si fueran facturas o problemas técnicos. Además su precision es 0,45: de los tickets que el modelo etiqueta como `baja` (unos 20/0,45 ≈ 44), más de la mitad no lo son. Para un equipo de retención, el modelo es casi inútil en la clase que más le importa. **2. Accuracy engañosa y macro vs weighted.** El 0,84 global está sostenido por las clases grandes: `factura` (420 casos, f1 0,92) y `tecnico` (310, f1 0,80) concentran el 91 % del soporte, así que dominan la media. Las dos medias lo delatan: - **weighted avg (0,83)** pondera cada clase por su tamaño → se parece a la accuracy y esconde a la minoritaria. - **macro avg (0,69)** da el mismo peso a cada clase → cae 14 puntos, revelando que al menos una clase va muy mal. **Regla práctica: un hueco grande entre weighted y macro = hay clases minoritarias abandonadas.** Con clases desbalanceadas, reporta siempre macro-F1 o las métricas por clase, nunca solo accuracy. **3. Tres acciones para mejorar `baja`:** 1. **Reponderar el entrenamiento:** `class_weight='balanced'` (o pesos manuales agresivos para `baja`) para que cada error en la clase minoritaria duela más en la función de pérdida (ver Ejercicio 18). 2. **Bajar el umbral de decisión para `baja`:** en multiclase, en lugar del `argmax` de probabilidades, marcar `baja` si `P(baja) > 0,25`, sacrificando precision por recall — el coste de revisar un falso aviso de baja es mínimo comparado con perder un cliente. 3. **Más y mejores datos de la clase minoritaria:** recolectar más ejemplos reales de bajas (o sobremuestrear/aumentar), y añadir features específicas (menciones a "cancelar", "portabilidad", "competencia" si es texto). Con 70 ejemplos, el modelo apenas ha visto la clase. **Consejo profesional:** ante cualquier `classification_report`, lee en este orden: (1) `support` para entender el desbalanceo, (2) el recall de la clase crítica para negocio, (3) el hueco macro-weighted. La accuracy se lee la última, si acaso.

Ejercicio 17 — ROC-AUC: logística vs árbol

Dificultad: · Capítulo: 3

Compara una regresión logística y un árbol de decisión por su capacidad de ordenar (ranking de probabilidades), no por su accuracy. La métrica adecuada es ROC-AUC.

Datos de partida:

from sklearn.datasets import make_classification

X, y = make_classification(n_samples=3000, n_features=15, n_informative=10,
                           weights=[0.8, 0.2], flip_y=0.05, random_state=42)

Se pide: 1. Divide 70/30 estratificado. Entrena LogisticRegression(max_iter=1000) y DecisionTreeClassifier(max_depth=4). 2. Calcula el ROC-AUC de ambos con predict_proba y también la accuracy. ¿Coinciden los rankings de las dos métricas? 3. Explica qué mide exactamente el AUC y por qué se calcula con probabilidades y no con predict.

Ver solución
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.tree import DecisionTreeClassifier
from sklearn.metrics import roc_auc_score, accuracy_score

# --- 1. Datos, split y modelos ---
X, y = make_classification(n_samples=3000, n_features=15, n_informative=10,
                           weights=[0.8, 0.2], flip_y=0.05, random_state=42)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, stratify=y, random_state=42)

log = LogisticRegression(max_iter=1000).fit(X_train, y_train)
arbol = DecisionTreeClassifier(max_depth=4, random_state=42).fit(X_train, y_train)

# --- 2. AUC (con probabilidades) y accuracy (con clases) ---
for nombre, m in [("Logística", log), ("Árbol d=4", arbol)]:
    proba = m.predict_proba(X_test)[:, 1]     # score continuo de la clase positiva
    pred = m.predict(X_test)                  # clase dura con umbral 0.5
    print(f"{nombre}:  AUC={roc_auc_score(y_test, proba):.3f}"
          f"  accuracy={accuracy_score(y_test, pred):.3f}")
# Salida típica:
# Logística:  AUC=0.932  accuracy=0.884
# Árbol d=4:  AUC=0.860  accuracy=0.869
# Las accuracies son casi iguales, pero el AUC separa claramente a los modelos:
# la logística ORDENA mucho mejor, aunque con umbral 0.5 acierte parecido.
**3. Qué mide el AUC.** El ROC-AUC es la probabilidad de que, tomando al azar un positivo y un negativo, el modelo asigne **mayor score al positivo**. AUC = 0,5 es ordenar al azar; 1,0 es ordenación perfecta. Propiedades clave: - **Es independiente del umbral:** evalúa el ranking completo, resumiendo todas las parejas (recall, tasa de FP) que obtendrías al mover el umbral de 0 a 1. Por eso es la métrica natural cuando el umbral aún no está decidido (como en el Ejercicio 13) o cuando lo que consumes es el propio score (priorizar una cola de revisión, ordenar leads). - **Por qué `predict_proba` y no `predict`:** `predict` ya aplicó el umbral 0,5 y aplasta el score a {0, 1}: destruye la información de orden. Calcular "AUC" sobre clases duras equivale a evaluar un único punto de la curva. Además, un árbol de profundidad 4 solo puede emitir ~16 probabilidades distintas (una por hoja), mientras la logística da un continuo: otra razón por la que su ranking es más fino. - **Matiz profesional:** con desbalanceo fuerte, el AUC puede verse "bien" aunque la precisión sobre la minoritaria sea pobre, porque los TN abundantes infla la especificidad; en esos casos complementa con **PR-AUC** (`average_precision_score`). **Nota:** accuracy responde "¿acierta con ESTE umbral?"; AUC responde "¿sabe distinguir, en general?". Dos preguntas distintas, dos métricas distintas.

Ejercicio 18 — class_weight en un dataset desbalanceado

Dificultad: · Capítulo: 3

Una fintech detecta transacciones fraudulentas que son solo el 2 % del total. Vas a comprobar qué aporta class_weight='balanced' frente al modelo por defecto.

Datos de partida:

from sklearn.datasets import make_classification

X, y = make_classification(n_samples=10_000, n_features=16, n_informative=8,
                           weights=[0.98, 0.02], flip_y=0.01, random_state=42)

Se pide: 1. Divide 70/30 estratificado. Entrena dos LogisticRegression(max_iter=1000): una por defecto y otra con class_weight='balanced'. 2. Para cada una: accuracy, precision, recall y F1 de la clase fraude, y matriz de confusión. 3. Explica qué hace exactamente class_weight='balanced' internamente y cuándo NO lo usarías.

Ver solución
import numpy as np
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (accuracy_score, precision_score, recall_score,
                             f1_score, confusion_matrix)

# --- 1. Datos y modelos ---
X, y = make_classification(n_samples=10_000, n_features=16, n_informative=8,
                           weights=[0.98, 0.02], flip_y=0.01, random_state=42)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, stratify=y, random_state=42)   # ~60 fraudes en test

sin_peso = LogisticRegression(max_iter=1000).fit(X_train, y_train)
con_peso = LogisticRegression(max_iter=1000, class_weight="balanced").fit(X_train, y_train)

# --- 2. Comparación ---
for nombre, m in [("Sin class_weight", sin_peso), ("class_weight=balanced", con_peso)]:
    pred = m.predict(X_test)
    tn, fp, fn, tp = confusion_matrix(y_test, pred).ravel()
    print(f"\n{nombre}")
    print(f"  accuracy={accuracy_score(y_test, pred):.3f}"
          f"  precision={precision_score(y_test, pred):.3f}"
          f"  recall={recall_score(y_test, pred):.3f}"
          f"  F1={f1_score(y_test, pred):.3f}")
    print(f"  TP={tp}  FP={fp}  FN={fn}  TN={tn}")
# Salida típica:
# Sin class_weight:      accuracy=0.988  precision=0.83  recall=0.45  F1=0.58
#   -> accuracy altísima, pero deja escapar MÁS DE LA MITAD del fraude (FN altos)
# class_weight=balanced: accuracy=0.94   precision=0.23  recall=0.90  F1=0.37
#   -> la accuracy baja, pero ahora captura ~90% del fraude a cambio de más FP
**Lectura:** el modelo por defecto optimiza la log-loss donde el 98 % de los ejemplos son legítimos: la solución cómoda es acercar la frontera a la clase minoritaria y sacrificarla (recall 0,45), manteniendo una accuracy espectacular e inútil. Con `balanced`, el recall del fraude se dispara al precio de bajar la precision: el clásico intercambio, y con estos costes de negocio (el fraude es caro, revisar es barato) suele compensar. **3. Qué hace `class_weight='balanced'` por dentro.** Asigna a cada clase el peso `n_muestras / (n_clases · n_muestras_clase)`. Aquí: clase 0 → 10 000/(2·9 800) ≈ **0,51**; clase 1 → 10 000/(2·200) ≈ **25**. Cada fraude mal clasificado pesa ~50 veces más en la función de pérdida, lo que equivale a entrenar como si las clases estuvieran equilibradas. Es un reequilibrio *en la pérdida*, sin duplicar ni inventar datos (a diferencia de oversampling/SMOTE). **Cuándo NO usarlo:** - Si vas a **ajustar el umbral con costes reales** (Ejercicio 13): a menudo es más limpio dejar el modelo sin pesos, bien calibrado, y mover el umbral; combinar ambas cosas a ciegas sobre-corrige. - Si necesitas **probabilidades calibradas** (pricing, riesgo): los pesos distorsionan las probabilidades, que dejan de reflejar la frecuencia real; habría que recalibrar (p. ej. `CalibratedClassifierCV`). - Si el desbalanceo es leve (60/40): no hay problema que resolver. **Consejo profesional:** `class_weight`, mover el umbral y el resampling son tres palancas para el mismo problema. Empieza por la más simple e interpretable (umbral con costes); usa `class_weight` cuando quieras que el *entrenamiento* preste atención a la minoritaria; deja SMOTE para cuando las dos anteriores se queden cortas.

Sección C — No supervisado, pipelines y ensembles con código

Ocho ejercicios que cubren clustering, reducción de dimensionalidad, detección de anomalías, pipelines profesionales y los ensembles que ganan las competiciones.


Ejercicio 19 — K-means: método del codo y silueta

Dificultad: · Capítulo: 4

Marketing quiere segmentar clientes, pero nadie sabe cuántos segmentos hay. Genera blobs sintéticos con un número "secreto" de grupos y descúbrelo con el codo y la silueta.

Datos de partida:

from sklearn.datasets import make_blobs

X, _ = make_blobs(n_samples=900, centers=5, cluster_std=1.1, random_state=42)

Se pide: 1. Escala los datos y ejecuta k-means para k de 2 a 10, guardando la inercia y el coeficiente de silueta de cada k. 2. Imprime la tabla k | inercia | silueta y decide el k óptimo con cada criterio. 3. Explica qué mide cada criterio y por qué la silueta suele ser más decisiva que el codo.

Ver solución
import numpy as np
import pandas as pd
from sklearn.datasets import make_blobs
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score

# --- 1. Datos, escalado y barrido de k ---
X, _ = make_blobs(n_samples=900, centers=5, cluster_std=1.1, random_state=42)
X_esc = StandardScaler().fit_transform(X)         # k-means usa distancias -> siempre escalar
# (aquí no hay train/test: el clustering es no supervisado y se ajusta sobre todo X)

filas = []
for k in range(2, 11):
    km = KMeans(n_clusters=k, n_init=10, random_state=42)  # n_init=10: 10 arranques, se queda el mejor
    etiquetas = km.fit_predict(X_esc)                       # ajusta y asigna cluster a cada punto
    filas.append({
        "k": k,
        "inercia": round(km.inertia_, 1),                   # suma de distancias² de cada punto a su centroide
        "silueta": round(silhouette_score(X_esc, etiquetas), 3),  # cohesión vs separación, en [-1, 1]
    })

print(pd.DataFrame(filas).to_string(index=False))
# Salida típica:
#  k  inercia  silueta
#  2   1806.3    0.531
#  3   1140.4    0.588
#  4    724.5    0.652
#  5    338.9    0.694   <- máximo claro de silueta
#  6    311.2    0.577   <- añadir un 6º cluster EMPEORA la estructura
#  7    285.0    0.510
# ...la inercia sigue bajando siempre (matemáticamente no puede subir)
**2. Decisión.** - **Codo:** la inercia cae con fuerza hasta k=5 (1806→339) y después el descenso se vuelve marginal (339→311→285). El "codo" está en **k=5**. - **Silueta:** máximo nítido en **k=5** (0,69), con caída clara en k=6. Ambos criterios coinciden con los 5 centros que plantamos. **3. Qué mide cada uno y por qué la silueta decide más:** - La **inercia** solo mide compacidad interna y **siempre decrece al aumentar k** (con k=n sería 0): nunca da un óptimo por sí misma, hay que juzgar visualmente dónde se "dobla" la curva, lo cual es subjetivo y a veces no hay codo claro. - La **silueta** compara, para cada punto, su distancia media a su propio cluster (a) contra la del cluster vecino más próximo (b): `s = (b−a)/max(a,b)`. Premia clusters compactos **y separados**, y castiga partir un grupo natural en dos: por eso tiene un máximo interpretable y sirve como criterio objetivo. **Advertencia:** con datasets grandes, `silhouette_score` es O(n²): usa `sample_size=5000` o una muestra. Y recuerda que k-means presupone clusters convexos y de tamaño similar: si la silueta es baja para todo k, quizá el problema no es k sino el algoritmo (prueba DBSCAN).

Ejercicio 20 — PCA y varianza explicada

Dificultad: · Capítulo: 4

Tienes un dataset de sensores industriales con 30 columnas, pero sospechas que la información real vive en muchas menos dimensiones (los sensores son redundantes). Compruébalo con PCA.

Datos de partida:

import numpy as np

rng = np.random.default_rng(42)
n = 1000
# 4 señales físicas reales e independientes (temperatura, presión, caudal, vibración)
señales = rng.normal(0, 1, (n, 4))
# 30 sensores: cada uno es una mezcla lineal de las 4 señales + ruido propio
mezcla = rng.normal(0, 1, (4, 30))
X = señales @ mezcla + rng.normal(0, 0.3, (n, 30))

Se pide: 1. Escala y ajusta un PCA completo. Imprime la varianza explicada por los 8 primeros componentes y la acumulada. 2. ¿Cuántos componentes hacen falta para retener el 95 % de la varianza? ¿Coincide con las 4 señales plantadas? 3. Transforma X al espacio reducido y verifica la nueva forma. ¿Qué has ganado y qué has perdido?

Ver solución
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA

# --- Datos del enunciado ---
rng = np.random.default_rng(42)
n = 1000
señales = rng.normal(0, 1, (n, 4))                 # la dimensión intrínseca REAL es 4
mezcla = rng.normal(0, 1, (4, 30))
X = señales @ mezcla + rng.normal(0, 0.3, (n, 30)) # 30 columnas observadas, muy redundantes

# --- 1. Escalar y PCA completo ---
X_esc = StandardScaler().fit_transform(X)          # PCA maximiza varianza: sin escalar,
                                                   # la columna de mayor escala secuestra los componentes
pca = PCA()                                        # sin n_components: calcula los 30
pca.fit(X_esc)

var = pca.explained_variance_ratio_                # fracción de varianza de cada componente
acum = np.cumsum(var)                              # acumulada
for i in range(8):
    print(f"PC{i+1}: {var[i]:.3f}  (acumulada: {acum[i]:.3f})")
# Salida típica:
# PC1: 0.372  (acumulada: 0.372)
# PC2: 0.279  (acumulada: 0.651)
# PC3: 0.203  (acumulada: 0.854)
# PC4: 0.114  (acumulada: 0.968)   <- ¡salto! con 4 componentes ya hay ~97%
# PC5: 0.003  (acumulada: 0.971)   <- del 5º en adelante, solo ruido residual
# PC6: 0.003  (acumulada: 0.974)

# --- 2. Componentes para el 95% ---
k95 = int(np.argmax(acum >= 0.95)) + 1             # primer índice que alcanza 0.95
print(f"Componentes para retener el 95%: {k95}")   # 4: PCA RECUPERA la dimensión intrínseca

# --- 3. Transformar al espacio reducido ---
pca4 = PCA(n_components=0.95)                      # forma directa: pide el 95% de varianza
X_red = pca4.fit_transform(X_esc)
print(f"Forma original: {X_esc.shape} -> reducida: {X_red.shape}")  # (1000, 30) -> (1000, 4)
**Qué ganas y qué pierdes:** - **Ganas:** 30 → 4 columnas (−87 % de dimensiones) reteniendo ~97 % de la información; menos ruido (los componentes descartados eran mayormente el ruido de sensor de σ=0,3); modelos posteriores más rápidos y menos propensos a overfitting; posibilidad de visualizar en 2D con PC1-PC2. - **Pierdes:** interpretabilidad directa — "PC1" no es ningún sensor, es una combinación lineal de los 30 (los pesos están en `pca.components_`); el ~3 % de varianza descartada; y PCA solo captura estructura **lineal**. **Consejo profesional:** este ejercicio reproduce un fenómeno real: muchos datasets de alta dimensión tienen una **dimensión intrínseca baja** (sensores redundantes, encuestas con preguntas correlacionadas, píxeles vecinos). Antes de entrenar sobre cientos de columnas, mira la curva de varianza acumulada: dos minutos que a menudo cambian el proyecto. Y en un pipeline supervisado, el PCA se ajusta **dentro** del pipeline (solo con train), como cualquier transformador.

Ejercicio 21 — Isolation Forest con anomalías inyectadas

Dificultad: · Capítulo: 4

Operas la telemetría de una flota de máquinas. El 3 % de las lecturas son fallos con un patrón distinto al régimen normal. Vas a inyectarlos tú mismo y comprobar si Isolation Forest los encuentra sin usar etiquetas.

Datos de partida:

import numpy as np

rng = np.random.default_rng(42)
normales = rng.normal(loc=[50, 12, 300], scale=[4, 1.5, 25], size=(970, 3))   # temp, presión, rpm
anomalias = rng.normal(loc=[75, 4, 480], scale=[6, 1.0, 40], size=(30, 3))    # régimen de fallo
X = np.vstack([normales, anomalias])
y_real = np.array([0]*970 + [1]*30)   # SOLO para evaluar al final; el modelo no las ve

Se pide: 1. Entrena IsolationForest(contamination=0.03, random_state=42) sobre X (sin etiquetas). 2. Convierte su salida (−1/1) a (1 = anomalía / 0 = normal) y evalúa contra y_real con precision y recall. 3. Usa score_samples para listar las 10 lecturas más anómalas y explica la intuición del algoritmo.

Ver solución
import numpy as np
from sklearn.ensemble import IsolationForest
from sklearn.metrics import precision_score, recall_score, confusion_matrix

# --- Datos del enunciado ---
rng = np.random.default_rng(42)
normales = rng.normal(loc=[50, 12, 300], scale=[4, 1.5, 25], size=(970, 3))
anomalias = rng.normal(loc=[75, 4, 480], scale=[6, 1.0, 40], size=(30, 3))
X = np.vstack([normales, anomalias])
y_real = np.array([0]*970 + [1]*30)

# --- 1. Entrenar SIN etiquetas ---
iso = IsolationForest(
    contamination=0.03,     # fracción esperada de anomalías: fija el umbral de decisión
    n_estimators=200,       # nº de árboles aleatorios
    random_state=42,
)
iso.fit(X)                  # no se le pasa y: aprende qué es "normal" de la propia estructura

# --- 2. Predicción y evaluación ---
pred_bruta = iso.predict(X)                 # convención de sklearn: -1 = anomalía, +1 = normal
pred = (pred_bruta == -1).astype(int)       # lo pasamos a 1 = anomalía, 0 = normal

print(f"Precision: {precision_score(y_real, pred):.3f}")   # ~0.93-1.0
print(f"Recall:    {recall_score(y_real, pred):.3f}")      # ~0.93-1.0
print(confusion_matrix(y_real, pred))
# Detecta prácticamente las 30 anomalías con muy pocos falsos positivos:
# el régimen de fallo (temp alta, presión baja, rpm altas) vive lejos de la nube normal.

# --- 3. Ranking de anomalía ---
scores = iso.score_samples(X)               # cuanto MÁS NEGATIVO, más anómalo
top10 = np.argsort(scores)[:10]             # los 10 índices con peor score
print("Índices más anómalos:", top10)
print("¿Eran anomalías reales?:", y_real[top10])   # casi todos 1: el ranking funciona
**Intuición del algoritmo.** Isolation Forest construye árboles con **cortes aleatorios** (feature al azar, punto de corte al azar). Un punto anómalo, al estar solo en una región poco densa, queda **aislado en muy pocos cortes**; un punto normal, rodeado de miles de vecinos, necesita muchos. La profundidad media a la que se aísla cada punto se convierte en el score: aislar fácil = anómalo. Ventajas prácticas: no asume ninguna distribución, escala bien (`O(n log n)`), funciona en alta dimensión y no requiere etiquetas. **Advertencia:** `contamination` no lo aprende el modelo: **se lo dices tú** y determina cuántos puntos marcará. Si no conoces la tasa real, trabaja con el ranking de `score_samples` y decide el corte con criterio de negocio (p. ej. "el equipo de mantenimiento puede revisar 20 máquinas al día"). En producción real, además, entrena solo con datos que creas mayormente normales y aplica a los nuevos.

Ejercicio 22 — Pipeline completo con ColumnTransformer

Dificultad: · Capítulo: 5

El dataset de RR. HH. de una empresa mezcla numéricas con nulos y categóricas con nulos. Vas a construir el pipeline profesional completo: imputación + escalado + one-hot + modelo, todo en un solo objeto.

Datos de partida:

import numpy as np
import pandas as pd

rng = np.random.default_rng(42)
n = 1200
df = pd.DataFrame({
    "edad": rng.integers(20, 65, n).astype(float),
    "salario": rng.normal(35_000, 12_000, n),
    "antiguedad": rng.uniform(0, 20, n),
    "departamento": rng.choice(["ventas", "ingenieria", "rrhh", "marketing"], n),
    "modalidad": rng.choice(["remoto", "hibrido", "presencial"], n),
})
# Inyectar nulos: 8% en salario, 5% en modalidad
df.loc[rng.choice(n, int(n*0.08), replace=False), "salario"] = np.nan
df.loc[rng.choice(n, int(n*0.05), replace=False), "modalidad"] = np.nan
# Target sintético: abandono, influido por salario bajo, poca antigüedad y departamento
logit = (-0.00008*(df["salario"].fillna(35_000)) - 0.10*df["antiguedad"]
         + 0.8*(df["departamento"] == "ventas") + 3.2)
df["abandono"] = (rng.uniform(0, 1, n) < 1/(1+np.exp(-logit))).astype(int)

Se pide: 1. Define las listas de columnas numéricas y categóricas y construye un ColumnTransformer: numéricas → imputación por mediana + StandardScaler; categóricas → imputación por moda + OneHotEncoder(handle_unknown="ignore"). 2. Encadénalo con LogisticRegression en un Pipeline, entrena sobre train (75/25) y evalúa con ROC-AUC en test. 3. Explica por qué este diseño elimina el leakage de preprocesado y qué pasa cuando en producción llega una categoría nueva.

Ver solución
import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score

# --- Datos del enunciado (idéntico) ---
rng = np.random.default_rng(42)
n = 1200
df = pd.DataFrame({
    "edad": rng.integers(20, 65, n).astype(float),
    "salario": rng.normal(35_000, 12_000, n),
    "antiguedad": rng.uniform(0, 20, n),
    "departamento": rng.choice(["ventas", "ingenieria", "rrhh", "marketing"], n),
    "modalidad": rng.choice(["remoto", "hibrido", "presencial"], n),
})
df.loc[rng.choice(n, int(n*0.08), replace=False), "salario"] = np.nan
df.loc[rng.choice(n, int(n*0.05), replace=False), "modalidad"] = np.nan
logit = (-0.00008*(df["salario"].fillna(35_000)) - 0.10*df["antiguedad"]
         + 0.8*(df["departamento"] == "ventas") + 3.2)
df["abandono"] = (rng.uniform(0, 1, n) < 1/(1+np.exp(-logit))).astype(int)

# --- 1. ColumnTransformer: cada tipo de columna con su tratamiento ---
num_cols = ["edad", "salario", "antiguedad"]
cat_cols = ["departamento", "modalidad"]

pre_num = Pipeline([                                   # sub-pipeline para numéricas
    ("imputar", SimpleImputer(strategy="median")),     # mediana: robusta a outliers de salario
    ("escalar", StandardScaler()),                     # necesario para la logística regularizada
])
pre_cat = Pipeline([                                   # sub-pipeline para categóricas
    ("imputar", SimpleImputer(strategy="most_frequent")),      # moda para los nulos de modalidad
    ("onehot", OneHotEncoder(handle_unknown="ignore")),        # dummies; tolera categorías nuevas
])

preprocesador = ColumnTransformer([
    ("num", pre_num, num_cols),                        # aplica pre_num SOLO a las numéricas
    ("cat", pre_cat, cat_cols),                        # aplica pre_cat SOLO a las categóricas
])                                                     # (remainder="drop" por defecto)

# --- 2. Pipeline completo y evaluación ---
pipe = Pipeline([
    ("pre", preprocesador),
    ("modelo", LogisticRegression(max_iter=1000)),
])

X = df.drop(columns="abandono")
y = df["abandono"]
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.25, stratify=y, random_state=42)

pipe.fit(X_train, y_train)                             # UN solo fit: imputadores, scaler,
                                                       # encoder y modelo aprenden SOLO de train
proba = pipe.predict_proba(X_test)[:, 1]
print(f"ROC-AUC en test: {roc_auc_score(y_test, proba):.3f}")   # ~0.66-0.72 (señal moderada plantada)
**3. Por qué no hay leakage y qué pasa con categorías nuevas:** - **Sin leakage por construcción:** al llamar `pipe.fit(X_train, y_train)`, la mediana del salario, la moda de modalidad, las medias del scaler y las categorías del encoder se calculan **exclusivamente con train**. Al predecir sobre test (o producción), solo se les aplica `transform` con esos valores congelados. Si además usas el pipeline dentro de `cross_val_score` o `GridSearchCV`, sklearn repite el fit del preprocesado **dentro de cada fold**: la garantía se mantiene también en CV, cosa imposible si preprocesas "a mano" antes. - **Categorías nuevas en producción:** con `handle_unknown="ignore"`, si llega `modalidad="4dias"` (no vista en train), el encoder no explota: genera un vector de ceros para esas dummies y el modelo predice con el resto de información. Sin ese parámetro, la API de producción devolvería un error 500 la primera vez que negocio invente una categoría — y la inventará. - **Bonus operativo:** el pipeline es UN objeto serializable (`joblib.dump(pipe, ...)`): la API de producción (Ejercicio 29) recibe el DataFrame crudo y el pipeline reproduce el preprocesado exacto. Cero riesgo de "en producción escalamos distinto que en entrenamiento" — el bug más caro del ML aplicado. **Consejo profesional:** la regla de oro: **todo lo que aprenda de los datos (imputadores, scalers, encoders, selectores, PCA) va dentro del pipeline**. Fuera del pipeline solo queda lo que no aprende nada (renombrar columnas, parsear fechas).

Ejercicio 23 — GridSearchCV sobre el pipeline

Dificultad: · Capítulo: 5

Continúa el ejercicio anterior: en lugar de aceptar los hiperparámetros por defecto, busca sistemáticamente la mejor combinación del pipeline completo, incluyendo alternativas de preprocesado.

Se pide (partiendo del pipe y los datos del Ejercicio 22): 1. Define una malla que combine: C de la logística ∈ {0.01, 0.1, 1, 10}, estrategia de imputación numérica ∈ {media, mediana}, y modelo alternativo RandomForestClassifier con max_depth ∈ {4, 8}. 2. Ejecuta GridSearchCV con CV estratificada de 5 folds optimizando ROC-AUC. 3. Reporta la mejor combinación, su AUC de CV y el AUC final en test. Explica por qué la sintaxis paso__parametro y por qué el test se usa una sola vez.

Ver solución
import numpy as np
from sklearn.model_selection import GridSearchCV, StratifiedKFold
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
# (se reutilizan pipe, X_train, X_test, y_train, y_test del Ejercicio 22)

# --- 1. Malla de búsqueda: dos "sub-mallas", una por tipo de modelo ---
# Cada dict es una familia de combinaciones; GridSearchCV explora la unión de ambas.
malla = [
    {   # Familia 1: regresión logística, variando C e imputación
        "modelo": [LogisticRegression(max_iter=1000)],
        "modelo__C": [0.01, 0.1, 1, 10],                       # fuerza inversa de regularización
        "pre__num__imputar__strategy": ["mean", "median"],     # ¡el preprocesado también se optimiza!
    },
    {   # Familia 2: random forest, variando profundidad e imputación
        "modelo": [RandomForestClassifier(n_estimators=200, random_state=42)],
        "modelo__max_depth": [4, 8],
        "pre__num__imputar__strategy": ["mean", "median"],
    },
]
# Total: 4*2 + 2*2 = 12 combinaciones x 5 folds = 60 entrenamientos

# --- 2. Búsqueda con CV estratificada ---
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)  # mantiene la proporción de clases
busqueda = GridSearchCV(
    pipe,                     # el pipeline COMPLETO es el estimador a optimizar
    param_grid=malla,
    scoring="roc_auc",        # métrica de selección (independiente del umbral)
    cv=cv,
    n_jobs=-1,                # paraleliza en todos los núcleos
    refit=True,               # al acabar, reentrena la mejor combinación con TODO el train
)
busqueda.fit(X_train, y_train)

# --- 3. Resultados ---
print("Mejores parámetros:", busqueda.best_params_)
print(f"AUC medio en CV:   {busqueda.best_score_:.3f}")
proba_test = busqueda.predict_proba(X_test)[:, 1]      # usa automáticamente el mejor pipeline reentrenado
print(f"AUC en test final: {roc_auc_score(y_test, proba_test):.3f}")
# Es normal que el AUC de test sea ligeramente inferior al de CV: la selección
# del "mejor" siempre tiene un pequeño sesgo optimista (ganó también por suerte).
**Sintaxis `paso__parametro`:** el doble guion bajo navega la jerarquía de objetos anidados: `pre__num__imputar__strategy` significa "en el paso `pre` (ColumnTransformer) → transformador `num` (sub-pipeline) → paso `imputar` (SimpleImputer) → parámetro `strategy`". Esto permite tratar **cada decisión del pipeline como un hiperparámetro**, incluida la elección del modelo entero (nótese `"modelo": [...]` con instancias como valores). **Por qué el test se usa una sola vez:** durante la búsqueda, cada combinación se evaluó en los folds de validación; la ganadora está ligeramente sobreajustada a esos folds (entre 12 candidatas, la mejor lo es en parte por azar). Si usaras el test para decidir entre candidatas, lo contaminarías igual y te quedarías **sin ninguna estimación honesta** del rendimiento futuro. El test es el "estreno ante el público": una función, una sola vez, al final. **Nota:** con mallas grandes, `GridSearchCV` explota combinatoriamente. A partir de ~50-100 combinaciones, pásate a `RandomizedSearchCV` (muestrea la malla al azar, `n_iter` controla el presupuesto) o a optimización bayesiana (Optuna): casi el mismo resultado por una fracción del coste.

Ejercicio 24 — Random Forest con permutation importance

Dificultad: · Capítulo: 6

Dirección pregunta: "vale, el modelo funciona, pero ¿QUÉ variables importan?". Entrena un Random Forest y responde con permutation_importance, que mide la importancia sobre datos de test.

Datos de partida:

from sklearn.datasets import make_classification

X, y = make_classification(n_samples=2500, n_features=10, n_informative=4,
                           n_redundant=2, shuffle=False, random_state=42)
# Con shuffle=False: columnas 0-3 informativas, 4-5 redundantes, 6-9 ruido puro
nombres = [f"f{i}_{'info' if i < 4 else 'redund' if i < 6 else 'ruido'}" for i in range(10)]

Se pide: 1. Divide 70/30, entrena RandomForestClassifier(n_estimators=300, random_state=42) y reporta el AUC en test. 2. Calcula permutation_importance sobre test (10 repeticiones) y lista las features ordenadas. 3. Verifica que las features de ruido quedan al fondo y explica por qué la importancia por permutación es más fiable que feature_importances_.

Ver solución
import numpy as np
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from sklearn.inspection import permutation_importance
from sklearn.metrics import roc_auc_score

# --- Datos del enunciado ---
X, y = make_classification(n_samples=2500, n_features=10, n_informative=4,
                           n_redundant=2, shuffle=False, random_state=42)
nombres = [f"f{i}_{'info' if i < 4 else 'redund' if i < 6 else 'ruido'}" for i in range(10)]

# --- 1. Entrenar y evaluar ---
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, stratify=y, random_state=42)
rf = RandomForestClassifier(n_estimators=300, random_state=42, n_jobs=-1)
rf.fit(X_train, y_train)
auc = roc_auc_score(y_test, rf.predict_proba(X_test)[:, 1])
print(f"AUC test: {auc:.3f}")                       # ~0.97: problema separable, RF lo borda

# --- 2. Permutation importance SOBRE TEST ---
# Idea: baraja una columna (rompe su relación con y) y mide cuánto cae el AUC.
# Caída grande = el modelo DEPENDÍA de esa feature para generalizar.
perm = permutation_importance(
    rf, X_test, y_test,
    scoring="roc_auc",       # misma métrica que nos importa
    n_repeats=10,            # baraja cada columna 10 veces -> media y desviación
    random_state=42,
    n_jobs=-1,
)

orden = np.argsort(perm.importances_mean)[::-1]     # de mayor a menor caída
print(f"{'feature':<12} {'caída AUC':>10} {'±std':>8}")
for i in orden:
    print(f"{nombres[i]:<12} {perm.importances_mean[i]:>10.4f} {perm.importances_std[i]:>8.4f}")
# Salida típica:
# f1_info        0.19    ±0.01
# f3_info        0.11    ±0.01
# f4_redund      0.08    ±0.01
# f0_info        0.05    ±0.01
# ...
# f7_ruido       0.0006  ±0.0008   <- las 4 de ruido: caída ~0 (barajarlas no afecta)
**3. Lectura y comparación con `feature_importances_`:** - **Las features de ruido (f6-f9) quedan al fondo con importancia ≈ 0** (a veces incluso ligeramente negativa: barajar una columna inútil puede, por azar, "mejorar" una pizca la métrica). Las informativas y las redundantes reparten el resto. Ojo con las redundantes: al estar correlacionadas con las informativas, cuando barajas una el modelo se apoya en su gemela, así que la importancia individual de features correlacionadas se **subestima** — limitación conocida del método. - **Por qué es más fiable que `feature_importances_` (impurity-based):** la importancia de impureza (1) se calcula sobre **train**, así que premia features que ayudaron a memorizar aunque no generalicen; (2) está **sesgada hacia features de alta cardinalidad** (una columna de valores casi únicos ofrece muchos puntos de corte y acumula impureza aunque sea ruido); y (3) no tiene unidades interpretables. La permutación se evalúa **en test, en la métrica que te importa** ("si esta columna llegara rota, ¿cuánto AUC pierdo?"), y da incertidumbre (±std). Es más cara computacionalmente, pero es la que enseñarías a dirección. **Consejo profesional:** para explicar predicciones *individuales* ("¿por qué a ESTE cliente le disteis score 0,9?"), el siguiente paso es SHAP. Permutation importance responde al "qué importa en general"; SHAP al "qué pesó en este caso".

Ejercicio 25 — HistGradientBoosting con early stopping

Dificultad: · Capítulo: 6

El boosting mejora iteración a iteración… hasta que empieza a sobreajustar. El early stopping detecta ese punto automáticamente con un conjunto de validación interno.

Datos de partida:

from sklearn.datasets import make_classification

X, y = make_classification(n_samples=6000, n_features=25, n_informative=10,
                           flip_y=0.1, random_state=42)

Se pide: 1. Divide 80/20. Entrena HistGradientBoostingClassifier con max_iter=1000 y early stopping activado (validación interna del 15 %, n_iter_no_change=20). 2. Reporta en cuántas iteraciones paró (n_iter_) y el AUC en test. 3. Entrena otro SIN early stopping con las 1000 iteraciones y compara AUC y tiempo. Explica qué hace exactamente el early stopping y por qué es la regularización "gratis" del boosting.

Ver solución
import time
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.metrics import roc_auc_score

# --- Datos y split ---
X, y = make_classification(n_samples=6000, n_features=25, n_informative=10,
                           flip_y=0.1, random_state=42)   # 10% de ruido de etiqueta: sobreajustable
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, stratify=y, random_state=42)

# --- 1. Con early stopping ---
gb_es = HistGradientBoostingClassifier(
    max_iter=1000,              # tope máximo de árboles (iteraciones de boosting)
    early_stopping=True,        # activa la parada temprana
    validation_fraction=0.15,   # aparta un 15% del train como validación INTERNA
    n_iter_no_change=20,        # para si la validación no mejora en 20 iteraciones seguidas
    learning_rate=0.1,
    random_state=42,
)
t0 = time.perf_counter()
gb_es.fit(X_train, y_train)
t_es = time.perf_counter() - t0
auc_es = roc_auc_score(y_test, gb_es.predict_proba(X_test)[:, 1])
print(f"Early stopping: paró en {gb_es.n_iter_} iteraciones"
      f" | AUC={auc_es:.3f} | {t_es:.1f}s")
# Típico: para en ~100-200 iteraciones de las 1000 posibles

# --- 3. Sin early stopping: las 1000 completas ---
gb_full = HistGradientBoostingClassifier(
    max_iter=1000, early_stopping=False, learning_rate=0.1, random_state=42)
t0 = time.perf_counter()
gb_full.fit(X_train, y_train)
t_full = time.perf_counter() - t0
auc_full = roc_auc_score(y_test, gb_full.predict_proba(X_test)[:, 1])
print(f"Sin early stop: 1000 iteraciones | AUC={auc_full:.3f} | {t_full:.1f}s")
# Típico: AUC IGUAL O PEOR (sobreajuste suave a partir del punto óptimo)
# y varias veces más tiempo de entrenamiento.
**Qué hace el early stopping.** En cada iteración, el boosting añade un árbol que corrige los errores residuales del conjunto anterior. Sobre el *train*, la pérdida baja siempre; sobre datos frescos, baja hasta un punto y luego empieza a subir: el modelo comienza a modelar el ruido (aquí, el 10 % de `flip_y`). Early stopping aparta una fracción del train como validación, monitoriza la pérdida ahí tras cada iteración y **se detiene cuando lleva `n_iter_no_change` iteraciones sin mejorar**, quedándose con el número de árboles del mejor punto. **Por qué es la regularización "gratis":** - Convierte el hiperparámetro más delicado del boosting (`n_estimators`/`max_iter`) en **automático**: pones un tope generoso y el algoritmo encuentra el óptimo solo. - Ahorra el tiempo de todas las iteraciones inútiles (aquí, ~80 % del entrenamiento). - Interactúa con `learning_rate`: tasa más baja = mejora más suave = necesita más iteraciones pero suele alcanzar mejor óptimo. La receta profesional: `learning_rate` bajo (0,05-0,1), `max_iter` alto y early stopping vigilando. **Nota:** XGBoost y LightGBM tienen el mismo mecanismo (`early_stopping_rounds` + un `eval_set` explícito que le pasas tú). `HistGradientBoosting` es la implementación nativa de sklearn inspirada en LightGBM: rapidísima, soporta nulos de serie y es la opción por defecto sensata cuando no quieres dependencias extra.

Ejercicio 26 — RF vs boosting con cross_validate

Dificultad: · Capítulo: 6

Debate clásico del equipo: ¿Random Forest o gradient boosting? Zanjalo con datos: compara ambos con validación cruzada multi-métrica y decide con criterio.

Datos de partida:

from sklearn.datasets import make_classification

X, y = make_classification(n_samples=5000, n_features=30, n_informative=12,
                           weights=[0.75, 0.25], flip_y=0.06, random_state=42)

Se pide: 1. Compara RandomForestClassifier(n_estimators=300) y HistGradientBoostingClassifier(max_iter=300) con cross_validate, 5 folds estratificados, midiendo a la vez ROC-AUC y F1, y capturando el tiempo de entrenamiento por fold. 2. Presenta media ± desviación de cada métrica y modelo. 3. Decide cuál despliegas y qué otros factores (además de la media) considerarías.

Ver solución
import numpy as np
from sklearn.datasets import make_classification
from sklearn.model_selection import cross_validate, StratifiedKFold
from sklearn.ensemble import RandomForestClassifier, HistGradientBoostingClassifier

# --- Datos del enunciado ---
X, y = make_classification(n_samples=5000, n_features=30, n_informative=12,
                           weights=[0.75, 0.25], flip_y=0.06, random_state=42)

# --- 1. Comparación honesta: mismos folds para ambos modelos ---
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
# Usar el MISMO objeto cv garantiza que ambos ven exactamente las mismas particiones:
# cualquier diferencia es del modelo, no de la suerte del split.

modelos = {
    "RandomForest": RandomForestClassifier(n_estimators=300, random_state=42, n_jobs=-1),
    "HistGradBoost": HistGradientBoostingClassifier(max_iter=300, random_state=42),
}

resultados = {}
for nombre, modelo in modelos.items():
    res = cross_validate(
        modelo, X, y,
        cv=cv,
        scoring=["roc_auc", "f1"],   # multi-métrica: sklearn evalúa ambas en cada fold
        return_train_score=True,     # útil para diagnosticar overfitting por el gap
        n_jobs=-1,
    )
    resultados[nombre] = res

# --- 2. Tabla resumen: media ± desviación por métrica ---
for nombre, res in resultados.items():
    print(f"\n{nombre}")
    print(f"  AUC  test: {res['test_roc_auc'].mean():.3f} ± {res['test_roc_auc'].std():.3f}"
          f"   (train: {res['train_roc_auc'].mean():.3f})")
    print(f"  F1   test: {res['test_f1'].mean():.3f} ± {res['test_f1'].std():.3f}")
    print(f"  Tiempo fit por fold: {res['fit_time'].mean():.1f}s")
# Salida típica:
# RandomForest    AUC test: 0.933 ± 0.006  (train: 1.000)   F1: 0.80 ± 0.01   ~3-6s
# HistGradBoost   AUC test: 0.943 ± 0.005  (train: 0.999)   F1: 0.82 ± 0.01   ~2-4s
# El boosting suele ganar por un margen pequeño pero consistente en los 5 folds.
**3. Decisión y factores adicionales:** - **Ganador aquí: el boosting**, por margen pequeño (~0,01 AUC) pero **consistente entre folds** (gana en los 5, no solo en media). Para confirmar que la diferencia no es azar con márgenes tan finos, compara fold a fold (misma partición) e incluso repite con varias semillas de CV. - **La desviación estándar importa tanto como la media:** un modelo con 0,94 ± 0,02 puede ser peor apuesta que uno con 0,93 ± 0,005 si el negocio penaliza la variabilidad. Aquí ambos son estables. - **Train score = 1,0 en RF no es alarma automática:** RF casi siempre memoriza el train; lo que importa es el test. En boosting, un gap creciente sí pediría más regularización o early stopping. - **Factores de despliegue que la CV no mide:** latencia de inferencia (RF con 300 árboles profundos es más pesado en memoria; HGB suele predecir más rápido), coste de reentrenamiento, sensibilidad a hiperparámetros (RF funciona decente "out of the box"; el boosting mal ajustado puede ser peor que RF), manejo nativo de nulos (HGB sí, RF de sklearn no) y facilidad de explicación (empate: ambos necesitan SHAP/permutación). **Consejo profesional:** si la diferencia entre dos modelos es menor que la desviación entre folds, **no hay ganador**: elige por criterios operativos (simplicidad, latencia, mantenimiento). Perseguir la tercera cifra decimal del AUC es de competición; elegir el modelo que el equipo puede operar es de ingeniería.

Sección D — Ejercicios integradores

Cuatro ejercicios que combinan todo el módulo: un proyecto completo, una caza de leakage, una API de producción y monitoreo de drift. Son los más parecidos a tu trabajo real.


Ejercicio 27 — Mini-proyecto: churn end-to-end

Dificultad: · Capítulos: 1–6

Una operadora de telecomunicaciones quiere predecir qué clientes se darán de baja (churn). Escribe un único script que ejecute el proyecto completo: datos → baseline → pipeline → búsqueda de hiperparámetros → evaluación final → conclusiones.

Requisitos del script: 1. Generar el dataset sintético de clientes (código de partida abajo). 2. Split estratificado 75/25 y baseline con DummyClassifier. 3. Pipeline con ColumnTransformer (numéricas y categóricas) + modelo. 4. RandomizedSearchCV comparando al menos logística y Random Forest. 5. Evaluación final en test: ROC-AUC, classification_report y matriz de confusión. 6. Un bloque final de conclusiones impresas: ¿supera al baseline? ¿qué recomendarías a negocio?

Datos de partida:

import numpy as np
import pandas as pd

rng = np.random.default_rng(42)
n = 4000
df = pd.DataFrame({
    "antiguedad_meses": rng.integers(1, 72, n),
    "factura_mensual": rng.uniform(15, 110, n).round(2),
    "num_incidencias": rng.poisson(1.2, n),
    "contrato": rng.choice(["mensual", "anual", "dos_años"], n, p=[0.5, 0.3, 0.2]),
    "metodo_pago": rng.choice(["tarjeta", "domiciliado", "manual"], n),
})
# Churn real: más probable con contrato mensual, muchas incidencias y poca antigüedad
logit = (1.2*(df["contrato"] == "mensual") + 0.45*df["num_incidencias"]
         - 0.04*df["antiguedad_meses"] + 0.012*df["factura_mensual"] - 1.4)
df["churn"] = (rng.uniform(0, 1, n) < 1/(1+np.exp(-logit))).astype(int)
Ver solución
"""Proyecto churn end-to-end: de los datos crudos a la recomendación de negocio."""
import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split, RandomizedSearchCV, StratifiedKFold
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.dummy import DummyClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import (roc_auc_score, classification_report,
                             confusion_matrix)

# ========== 1. DATOS (código del enunciado) ==========
rng = np.random.default_rng(42)
n = 4000
df = pd.DataFrame({
    "antiguedad_meses": rng.integers(1, 72, n),
    "factura_mensual": rng.uniform(15, 110, n).round(2),
    "num_incidencias": rng.poisson(1.2, n),
    "contrato": rng.choice(["mensual", "anual", "dos_años"], n, p=[0.5, 0.3, 0.2]),
    "metodo_pago": rng.choice(["tarjeta", "domiciliado", "manual"], n),
})
logit = (1.2*(df["contrato"] == "mensual") + 0.45*df["num_incidencias"]
         - 0.04*df["antiguedad_meses"] + 0.012*df["factura_mensual"] - 1.4)
df["churn"] = (rng.uniform(0, 1, n) < 1/(1+np.exp(-logit))).astype(int)
print(f"Tasa de churn: {df['churn'].mean():.1%}")          # ~25-30%: desbalanceo moderado

# ========== 2. SPLIT + BASELINE ==========
X = df.drop(columns="churn")
y = df["churn"]
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.25, stratify=y, random_state=42)     # estratificado: misma tasa en ambos lados

# Baseline: predice siempre la clase mayoritaria. Es el listón mínimo a superar.
dummy = DummyClassifier(strategy="most_frequent").fit(X_train, y_train)
auc_dummy = roc_auc_score(y_test, dummy.predict_proba(X_test)[:, 1])
print(f"Baseline (mayoritaria): accuracy={dummy.score(X_test, y_test):.3f}, AUC={auc_dummy:.3f}")
# AUC del dummy = 0.5 por definición: no ordena nada.

# ========== 3. PIPELINE ==========
num_cols = ["antiguedad_meses", "factura_mensual", "num_incidencias"]
cat_cols = ["contrato", "metodo_pago"]

pre = ColumnTransformer([
    ("num", StandardScaler(), num_cols),                        # escala numéricas (para la logística)
    ("cat", OneHotEncoder(handle_unknown="ignore"), cat_cols),  # dummies robustas a categorías nuevas
])
pipe = Pipeline([("pre", pre), ("modelo", LogisticRegression(max_iter=1000))])

# ========== 4. BÚSQUEDA DE MODELO E HIPERPARÁMETROS ==========
distribuciones = [
    {"modelo": [LogisticRegression(max_iter=1000, class_weight="balanced")],
     "modelo__C": np.logspace(-2, 2, 20)},                      # C entre 0.01 y 100
    {"modelo": [RandomForestClassifier(random_state=42, class_weight="balanced")],
     "modelo__n_estimators": [100, 200, 400],
     "modelo__max_depth": [4, 6, 8, 12, None],
     "modelo__min_samples_leaf": [1, 5, 20]},
]
busqueda = RandomizedSearchCV(
    pipe, distribuciones,
    n_iter=25,                                # presupuesto: 25 combinaciones muestreadas al azar
    scoring="roc_auc",
    cv=StratifiedKFold(5, shuffle=True, random_state=42),
    random_state=42, n_jobs=-1,
)
busqueda.fit(X_train, y_train)
print("Mejor combinación:", busqueda.best_params_)
print(f"AUC en CV: {busqueda.best_score_:.3f}")

# ========== 5. EVALUACIÓN FINAL EN TEST (una sola vez) ==========
mejor = busqueda.best_estimator_                       # pipeline ganador ya reentrenado con todo el train
proba = mejor.predict_proba(X_test)[:, 1]
pred = mejor.predict(X_test)
auc_final = roc_auc_score(y_test, proba)
print(f"\nAUC final en test: {auc_final:.3f}")
print(classification_report(y_test, pred, target_names=["se queda", "churn"]))
print("Matriz de confusión:\n", confusion_matrix(y_test, pred))

# ========== 6. CONCLUSIONES ==========
print("\n===== CONCLUSIONES =====")
print(f"1. El modelo (AUC={auc_final:.3f}) supera con claridad al baseline (AUC=0.5):")
print("   ordena a los clientes por riesgo real de baja, no por azar.")
print("2. Con class_weight='balanced', el recall de churn sube: detectamos a la mayoría")
print("   de los que se irán, a costa de contactar a algunos que se quedarían.")
print("3. Recomendación a negocio: usar el score para priorizar la campaña de retención.")
print("   Llamar primero al decil de mayor score concentra las bajas evitables.")
print("4. Siguientes pasos: elegir umbral con costes reales (coste llamada vs valor cliente),")
print("   añadir features de comportamiento (uso, quejas recientes) y monitorizar drift.")
**Puntos que debe tener tu solución para darse por buena:** - El baseline aparece **antes** que cualquier modelo (si no, no sabes qué estás mejorando). - Todo el preprocesado vive **dentro** del pipeline (cero leakage, un solo objeto desplegable). - La búsqueda usa **CV sobre train** y el test se toca **una única vez** al final. - Las conclusiones hablan **el idioma del negocio** (a quién llamar, qué coste), no solo de métricas. **Consejo profesional:** esta estructura de 6 bloques es una plantilla reutilizable: datos → baseline → pipeline → búsqueda → evaluación → conclusiones. Interiorízala; el 80 % de los proyectos tabulares reales siguen exactamente este esqueleto.

Ejercicio 28 — Detectar y corregir un leakage plantado

Dificultad: · Capítulo: 5

Un compañero presume de un AUC de 0,99 en su modelo de impago. Aquí está su código con (al menos) tres fuentes de leakage. Encuéntralas, explica cada una y reescribe el código corregido.

Código roto (NO lo imites):

import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.feature_selection import SelectKBest, f_classif
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score

rng = np.random.default_rng(42)
n = 3000
df = pd.DataFrame(rng.normal(0, 1, (n, 8)), columns=[f"f{i}" for i in range(8)])
df["impago"] = (rng.uniform(0, 1, n) < 1/(1+np.exp(-(1.5*df["f0"] - df["f1"])))).astype(int)
# Feature "resultado_gestion_cobro": se rellena DESPUÉS de saber si el cliente impagó
df["resultado_gestion_cobro"] = df["impago"] * rng.normal(5, 1, n) + rng.normal(0, 1, n)

X = df.drop(columns="impago")
y = df["impago"]

# Preprocesado sobre TODO el dataset
X_esc = StandardScaler().fit_transform(X)
X_sel = SelectKBest(f_classif, k=5).fit_transform(X_esc, y)

X_train, X_test, y_train, y_test = train_test_split(X_sel, y, test_size=0.25, random_state=42)
modelo = LogisticRegression(max_iter=1000).fit(X_train, y_train)
print("AUC:", roc_auc_score(y_test, modelo.predict_proba(X_test)[:, 1]))  # 0.99 :-O
Ver solución **Los tres leakages, del más grave al más sutil:** 1. **Feature del futuro (leakage de target):** `resultado_gestion_cobro` se construye literalmente como `impago * algo`: es una consecuencia del impago, registrada *después* del evento que se quiere predecir. En producción, al puntuar un crédito nuevo, esa columna no existe todavía. Es la fuente del 0,99: el modelo lee la respuesta. **Corrección: eliminar la columna** (y en la vida real, auditar el diccionario de datos: cualquier campo escrito después del evento objetivo está prohibido). 2. **Escalado antes del split:** `StandardScaler().fit_transform(X)` usa media y desviación de TODO el dataset, test incluido. **Corrección:** el scaler entra en un pipeline que solo hace fit con train. 3. **Selección de features antes del split (y usando `y`):** `SelectKBest(...).fit_transform(X_esc, y)` elige las 5 columnas más correlacionadas con el target **mirando también las filas de test**. Este es el leakage más traicionero: infla métricas de forma consistente y silenciosa. **Corrección:** `SelectKBest` como paso del pipeline, ajustado solo con train dentro de cada fold. **Código corregido:**
import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split, cross_val_score
from sklearn.preprocessing import StandardScaler
from sklearn.feature_selection import SelectKBest, f_classif
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
from sklearn.metrics import roc_auc_score

# --- Mismos datos sintéticos ---
rng = np.random.default_rng(42)
n = 3000
df = pd.DataFrame(rng.normal(0, 1, (n, 8)), columns=[f"f{i}" for i in range(8)])
df["impago"] = (rng.uniform(0, 1, n) < 1/(1+np.exp(-(1.5*df["f0"] - df["f1"])))).astype(int)
df["resultado_gestion_cobro"] = df["impago"] * rng.normal(5, 1, n) + rng.normal(0, 1, n)

# FIX 1: fuera la feature del futuro — no estará disponible al predecir
X = df.drop(columns=["impago", "resultado_gestion_cobro"])
y = df["impago"]

# FIX 2 y 3: split PRIMERO; escalado y selección DENTRO del pipeline
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.25, stratify=y, random_state=42)

pipe = Pipeline([
    ("escalar", StandardScaler()),               # fit solo con el train de cada fold
    ("seleccionar", SelectKBest(f_classif, k=5)),# elige columnas mirando SOLO train
    ("modelo", LogisticRegression(max_iter=1000)),
])

# CV honesta sobre train para estimar el rendimiento
auc_cv = cross_val_score(pipe, X_train, y_train, cv=5, scoring="roc_auc")
print(f"AUC en CV: {auc_cv.mean():.3f} ± {auc_cv.std():.3f}")

# Evaluación final única en test
pipe.fit(X_train, y_train)
auc_test = roc_auc_score(y_test, pipe.predict_proba(X_test)[:, 1])
print(f"AUC honesto en test: {auc_test:.3f}")
# De 0.99 a ~0.80: ESTE es el rendimiento real del modelo.
# La caída no es un fallo: es la verdad saliendo a la luz.
**Advertencia:** la señal de alarma inicial era el propio 0,99. En problemas de riesgo de crédito, los AUC reales viven entre 0,70 y 0,85; un 0,95+ casi garantiza leakage. Conoce los rangos plausibles de tu dominio: son tu detector de humo. **Consejo profesional:** checklist anti-leakage antes de presumir de métrica: (1) ¿cada feature existiría en el momento de la predicción?, (2) ¿todo fit de preprocesado/selección ocurre dentro del pipeline?, (3) ¿el split respeta tiempo y grupos?, (4) ¿el test se usó una sola vez? Cuatro preguntas, dos minutos, carreras salvadas.

Ejercicio 29 — API FastAPI /predict para un pipeline guardado

Dificultad: · Capítulo: 7

El modelo de churn del Ejercicio 27 pasa a producción. Escribe dos scripts: uno que entrene y guarde el pipeline con joblib, y una API FastAPI con un endpoint POST /predict que reciba un cliente en JSON, valide la entrada con Pydantic y devuelva la probabilidad de churn. Añade un GET /health.

Se pide: 1. entrenar_y_guardar.py: entrena un pipeline mínimo (puedes simplificar el del Ejercicio 27) y lo persiste como modelo_churn.joblib junto a metadatos (versión, fecha, AUC). 2. api.py: carga el artefacto al arrancar, valida el JSON de entrada (tipos y rangos), devuelve probabilidad, prediccion y version_modelo. 3. Indica cómo lanzar la API y un ejemplo de petición con curl.

Ver solución **`entrenar_y_guardar.py`:**
"""Entrena el pipeline de churn y lo persiste con metadatos."""
import json
from datetime import date

import joblib
import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import roc_auc_score

# --- Datos sintéticos (mismo generador que el Ejercicio 27) ---
rng = np.random.default_rng(42)
n = 4000
df = pd.DataFrame({
    "antiguedad_meses": rng.integers(1, 72, n),
    "factura_mensual": rng.uniform(15, 110, n).round(2),
    "num_incidencias": rng.poisson(1.2, n),
    "contrato": rng.choice(["mensual", "anual", "dos_años"], n, p=[0.5, 0.3, 0.2]),
    "metodo_pago": rng.choice(["tarjeta", "domiciliado", "manual"], n),
})
logit = (1.2*(df["contrato"] == "mensual") + 0.45*df["num_incidencias"]
         - 0.04*df["antiguedad_meses"] + 0.012*df["factura_mensual"] - 1.4)
df["churn"] = (rng.uniform(0, 1, n) < 1/(1+np.exp(-logit))).astype(int)

X, y = df.drop(columns="churn"), df["churn"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25,
                                                    stratify=y, random_state=42)

# --- Pipeline completo: preprocesado + modelo en UN objeto ---
pipe = Pipeline([
    ("pre", ColumnTransformer([
        ("num", StandardScaler(), ["antiguedad_meses", "factura_mensual", "num_incidencias"]),
        ("cat", OneHotEncoder(handle_unknown="ignore"), ["contrato", "metodo_pago"]),
    ])),
    ("modelo", RandomForestClassifier(n_estimators=200, max_depth=8,
                                      class_weight="balanced", random_state=42)),
])
pipe.fit(X_train, y_train)
auc = roc_auc_score(y_test, pipe.predict_proba(X_test)[:, 1])

# --- Persistir artefacto + metadatos (auditar y hacer rollback exige saber QUÉ desplegaste) ---
joblib.dump(pipe, "modelo_churn.joblib")               # el pipeline entero, preprocesado incluido
metadatos = {"version": "1.0.0", "fecha": str(date.today()), "auc_test": round(auc, 4)}
with open("metadatos_modelo.json", "w") as f:
    json.dump(metadatos, f, indent=2)
print(f"Guardado modelo_churn.joblib | AUC test: {auc:.3f}")
**`api.py`:**
"""API de inferencia: POST /predict devuelve la probabilidad de churn."""
import json

import joblib
import pandas as pd
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from typing import Literal

app = FastAPI(title="API Churn", version="1.0.0")

# --- Carga ÚNICA al arrancar el proceso (no por petición: sería lentísimo) ---
pipeline = joblib.load("modelo_churn.joblib")
with open("metadatos_modelo.json") as f:
    METADATOS = json.load(f)

class Cliente(BaseModel):
    """Contrato de entrada: Pydantic valida tipos y rangos ANTES de tocar el modelo."""
    antiguedad_meses: int = Field(ge=0, le=600)            # entre 0 y 600 meses
    factura_mensual: float = Field(gt=0, lt=10_000)        # positiva y con tope de cordura
    num_incidencias: int = Field(ge=0, le=100)
    contrato: Literal["mensual", "anual", "dos_años"]      # solo valores conocidos
    metodo_pago: Literal["tarjeta", "domiciliado", "manual"]

class Prediccion(BaseModel):
    """Contrato de salida: la API promete siempre esta forma."""
    probabilidad: float
    prediccion: str
    version_modelo: str

@app.get("/health")
def health():
    """Sonda para el balanceador/orquestador: ¿el servicio y el modelo están vivos?"""
    return {"status": "ok", "modelo": METADATOS["version"]}

@app.post("/predict", response_model=Prediccion)
def predict(cliente: Cliente):
    try:
        # Pydantic ya validó; convertimos el JSON a un DataFrame de 1 fila
        # con las MISMAS columnas que vio el pipeline en entrenamiento.
        entrada = pd.DataFrame([cliente.model_dump()])
        proba = float(pipeline.predict_proba(entrada)[0, 1])   # P(churn) del único cliente
        return Prediccion(
            probabilidad=round(proba, 4),
            prediccion="churn" if proba >= 0.5 else "se_queda",  # umbral: decisión de negocio
            version_modelo=METADATOS["version"],
        )
    except Exception as exc:                                   # error inesperado -> 500 controlado
        raise HTTPException(status_code=500, detail=f"Error de inferencia: {exc}")
**3. Lanzar y probar:**
# 1) Entrenar y generar el artefacto
python entrenar_y_guardar.py

# 2) Arrancar la API (desarrollo)
uvicorn api:app --reload --port 8000
# Documentación interactiva automática: http://localhost:8000/docs

# 3) Petición de ejemplo
curl -X POST http://localhost:8000/predict \
  -H "Content-Type: application/json" \
  -d '{"antiguedad_meses": 4, "factura_mensual": 79.9, "num_incidencias": 5,
       "contrato": "mensual", "metodo_pago": "manual"}'
# Respuesta: {"probabilidad": 0.87, "prediccion": "churn", "version_modelo": "1.0.0"}
**Nota:** puntos de diseño que separan un juguete de un servicio serio: (1) el artefacto es el **pipeline completo** — la API no preprocesa nada a mano; (2) **Pydantic rechaza entradas inválidas con un 422** explicativo antes de llegar al modelo; (3) la respuesta incluye la **versión del modelo** para trazabilidad; (4) `/health` permite a Docker/Kubernetes saber si reiniciar el contenedor. El paso siguiente natural: un `Dockerfile` con `python:3.12-slim`, `pip install -r requirements.txt` y `CMD ["uvicorn", "api:app", "--host", "0.0.0.0"]`. **Advertencia:** fija las versiones de `scikit-learn` en `requirements.txt`: un artefacto joblib entrenado con una versión puede fallar (o peor, comportarse distinto) al cargarse con otra.

Ejercicio 30 — Calcular e interpretar el PSI

Dificultad: · Capítulo: 7

Tu modelo de scoring lleva 8 meses desplegado. Sospechas que la variable ingresos de los solicitantes ya no se distribuye como en entrenamiento. Calcula el PSI (Population Stability Index) entre ambas distribuciones y decide si hay que actuar.

Datos de partida:

import numpy as np

rng = np.random.default_rng(42)
# Distribución en entrenamiento (hace 8 meses)
ingresos_train = rng.normal(30_000, 8_000, 5_000)
# Distribución actual: la media ha subido y hay más dispersión (nuevo canal de captación)
ingresos_actual = rng.normal(34_000, 10_000, 5_000)

Se pide: 1. Implementa calcular_psi(esperado, observado, n_bins=10) usando bins por cuantiles de la distribución de entrenamiento. 2. Calcula el PSI de ingresos_actual vs ingresos_train e interprétalo con los umbrales estándar (0,1 / 0,25). 3. Como control, calcula el PSI de una muestra nueva de la MISMA distribución de train y verifica que sale bajo. ¿Qué acción recomiendas en cada caso?

Ver solución
import numpy as np

# --- Datos del enunciado ---
rng = np.random.default_rng(42)
ingresos_train = rng.normal(30_000, 8_000, 5_000)     # población de referencia (entrenamiento)
ingresos_actual = rng.normal(34_000, 10_000, 5_000)   # población actual (producción)

# --- 1. Implementación del PSI ---
def calcular_psi(esperado, observado, n_bins=10):
    """PSI = sum((p_obs - p_esp) * ln(p_obs / p_esp)) sobre bins comunes.

    esperado:  distribución de referencia (datos de entrenamiento)
    observado: distribución actual (datos de producción)
    """
    # Bins por CUANTILES de la referencia: cada bin contiene ~10% del train.
    # Ventaja sobre bins de ancho fijo: robustos a outliers y sin bins vacíos en la referencia.
    cortes = np.quantile(esperado, np.linspace(0, 1, n_bins + 1))
    cortes[0], cortes[-1] = -np.inf, np.inf           # extremos abiertos: capturan valores fuera de rango

    # Proporción de cada población que cae en cada bin
    p_esp = np.histogram(esperado, bins=cortes)[0] / len(esperado)
    p_obs = np.histogram(observado, bins=cortes)[0] / len(observado)

    # Evitar log(0) y división por 0: reemplazar proporciones nulas por un epsilon pequeño
    eps = 1e-4
    p_esp = np.where(p_esp == 0, eps, p_esp)
    p_obs = np.where(p_obs == 0, eps, p_obs)

    # Contribución de cada bin: (diferencia de proporciones) x (log de su cociente).
    # Ambos factores comparten signo, así que cada bin suma >= 0: el PSI nunca es negativo.
    return float(np.sum((p_obs - p_esp) * np.log(p_obs / p_esp)))

# --- 2. PSI del caso sospechoso ---
psi_drift = calcular_psi(ingresos_train, ingresos_actual)
print(f"PSI train vs actual: {psi_drift:.3f}")
# Típico: ~0.30-0.40 -> por encima de 0.25: cambio SEVERO de población

# --- 3. Control: misma distribución, muestra nueva ---
ingresos_control = rng.normal(30_000, 8_000, 5_000)   # otra muestra de la distribución ORIGINAL
psi_control = calcular_psi(ingresos_train, ingresos_control)
print(f"PSI de control:      {psi_control:.3f}")
# Típico: ~0.001-0.01 -> el ruido de muestreo apenas genera PSI: la métrica no da falsas alarmas
**Interpretación con los umbrales estándar (heredados de la industria del credit scoring):** | PSI | Diagnóstico | Acción | |-----|-------------|--------| | < 0,10 | Población estable | Nada: seguir monitorizando | | 0,10 – 0,25 | Cambio moderado | Investigar la causa; vigilar métricas de negocio de cerca | | > 0,25 | Cambio severo | Investigar YA y, casi seguro, reentrenar con datos recientes | - **Caso sospechoso (PSI ≈ 0,3-0,4):** cambio severo. Los solicitantes actuales ganan más y son más heterogéneos que los de entrenamiento — coherente con el "nuevo canal de captación". El modelo está extrapolando sobre una población que no vio: sus scores pueden estar descalibrados aunque *parezcan* razonables. **Acción:** confirmar el drift en otras variables, analizar el rendimiento en los créditos recientes ya madurados y reentrenar incluyendo datos del nuevo canal. - **Caso control (PSI ≈ 0,01):** estable, sin acción. Este control es importante en la práctica: te dice cuánto PSI genera el puro azar muestral con tu tamaño de muestra, para no perseguir fantasmas. **Nota:** el PSI compara **distribuciones de entrada**, no necesita etiquetas: por eso es la primera alarma disponible cuando la etiqueta tarda meses (recuerda el Ejercicio 8). Pero drift de entrada no implica automáticamente degradación del modelo (y a la inversa): es un detector de humo, no de fuego. Calcúlalo por feature y también sobre la distribución de los **scores** del modelo. **Consejo profesional:** automatiza esto: un job semanal que calcule el PSI de cada feature y del score, lo registre (MLflow o una tabla) y alerte si supera 0,25. Diez líneas de código que evitan descubrir el drift seis meses tarde, cuando ya costó dinero.

Cierre

Si has llegado hasta aquí resolviendo (no leyendo) los 30 ejercicios, dominas el ciclo completo: plantear el problema con métricas de negocio, entrenar sin engañarte (leakage, validación honesta), elegir y ajustar modelos con criterio, y llevarlos a producción con monitoreo. Los laboratorios del módulo te esperan para aplicar todo esto sobre proyectos guiados de mayor envergadura.

Nota

si no alcanzaste los 25/30, no es un fracaso: es un diagnóstico. La tabla índice te dice exactamente qué capítulo repasar según los ejercicios fallados.


Anterior Módulo Siguiente
Capítulo 7: ML en producción 02-MACHINE-LEARNING Laboratorios