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.randomcon semilla osklearn.datasets.make_*). No necesitas descargar nada. - Requisitos:
numpy,pandas,scikit-learn,matplotlib. Para el Ejercicio 29 ademásfastapiyjoblib. - 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".
- Explica por qué esa propuesta está mal para este problema.
- 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: - **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.
- Cargo los datos y elimino todas las filas con algún nulo (pierdo el 40 % de los datos, pero quedan limpios).
- Normalizo todo el dataset con StandardScaler.
- Selecciono las 10 features más correlacionadas con el target usando todo el dataset.
- Divido en train/test 80/20.
- Pruebo 12 modelos distintos y me quedo con el que tenga mejor accuracy en test.
- Como el dataset tiene 97 % de "no impago", la accuracy me sale 0,96: excelente.
- Ajusto hiperparámetros del modelo ganador probando combinaciones contra el test hasta que la accuracy sube a 0,97.
- 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.
- Score de propensión a la baja (churn) que el equipo de retención consulta cada lunes para llamar a clientes.
- Detección de fraude en el momento del pago con tarjeta.
- Recomendador de "productos similares" en la ficha de producto de un e-commerce con catálogo que cambia poco.
- 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:
- ¿Reentrenamiento por calendario, por disparador (trigger) o híbrido? ¿Por qué?
- ¿Qué señales monitorizarías para disparar un reentrenamiento? Nombra al menos 3 y el problema de la "etiqueta tardía" en crédito.
- ¿Qué validaciones debe pasar el modelo nuevo antes de reemplazar al viejo?
- ¿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
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.
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
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
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.
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
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.
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.
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
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)
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)
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
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)
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).
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)
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.
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.
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.")
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.
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 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}")
# 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"}
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
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 |