Proyectos del Módulo 02 — Machine Learning¶
AI Master Academy · Módulo 02: Machine Learning Dos proyectos para consolidar todo lo aprendido: un mini proyecto guiado (4-6 h) y un proyecto profesional de nivel portfolio (12-20 h). Prerrequisitos: capítulos 1-7 del módulo (sklearn, regresión, clasificación, aprendizaje no supervisado, pipelines y validación cruzada, boosting, y despliegue básico con FastAPI + Docker).
Índice¶
- Cómo trabajar estos proyectos
- MINI PROYECTO — Predictor de churn para una suscripción (NubeCRM)
- 2.1 Contexto de negocio
- 2.2 Descripción de los datos, campo a campo
- 2.3 Generador del dataset sintético (script completo)
- 2.4 Requisitos funcionales
- 2.5 Arquitectura de la solución
- 2.6 Esqueleto de código con TODOs
- 2.7 Código de referencia completo
- 2.8 Criterios de aceptación
- 2.9 Rúbrica de autoevaluación
- PROYECTO PROFESIONAL — Sistema de scoring en producción (FinanCredit)
- 3.1 Especificación empresarial y contexto regulatorio
- 3.2 Generador de datos sintéticos
- 3.3 Requisitos del sistema
- 3.4 Arquitectura detallada
- 3.5 Estructura de carpetas del repositorio
- 3.6 Plan de trabajo por fases
- 3.7 Esqueleto de cada archivo
- 3.8 Monitorización: PSI
- 3.9 Plantilla de README profesional
- 3.10 Criterios de aceptación exhaustivos
- 3.11 Rúbrica de autoevaluación
- 3.12 Cómo presentar este proyecto en una entrevista
- 3.13 Errores típicos
- Qué has demostrado: mapa proyecto → habilidad de empleo
- Conexión con lo que viene
1. Cómo trabajar estos proyectos¶
Estos proyectos no son ejercicios de clase: están diseñados para simular encargos reales con requisitos de negocio, restricciones y entregables verificables. Trabájalos así:
| Regla | Por qué |
|---|---|
| Lee la especificación completa antes de escribir código | En una empresa, empezar a programar sin entender el problema es el error nº 1 |
| Genera los datos con el script incluido y no lo modifiques (salvo la semilla si se indica) | Todos los alumnos trabajamos sobre el mismo problema; los resultados son comparables |
| Intenta primero el esqueleto con TODOs; consulta el código de referencia solo al atascarte | El aprendizaje ocurre en el intento, no en la lectura |
| Versiona con Git desde el minuto uno, con commits pequeños y descriptivos | El historial de commits es parte del entregable profesional |
| Autoevalúate con la rúbrica antes de dar el proyecto por terminado | Es exactamente lo que hará un revisor técnico |
Nota
los dos proyectos usan datos sintéticos generados por script. Esto es deliberado: (1) no dependes de descargas externas, (2) conoces la "verdad" plantada en los datos y puedes verificar si tu modelo la descubre, y (3) es una técnica profesional real — los equipos de ML generan datos sintéticos para tests, demos y desarrollo sin exponer datos de clientes.
Advertencia
resiste la tentación de mirar el generador de datos para "hacer trampa" (saber qué variables importan). En el mundo real no tienes acceso al proceso generador. Úsalo solo al final, para validar tus conclusiones.
2. MINI PROYECTO — Predictor de churn para una suscripción (NubeCRM)¶
Duración estimada: 4-6 horas · Dificultad: · Entregable: notebook o scripts + informe en Markdown
2.1 Contexto de negocio¶
Caso empresarial
NubeCRM es una empresa SaaS española que vende un CRM por suscripción mensual a pymes. Tiene ~4.500 clientes activos y tres planes: Básico (29 €/mes), Pro (79 €/mes) y Enterprise (199 €/mes). Su problema: cada mes pierde en torno al 1,5 % de su base de clientes (churn), y captar un cliente nuevo le cuesta unas 5 veces más que retener uno existente.
El equipo de Customer Success dispone de un presupuesto limitado: puede lanzar acciones de retención (llamada personalizada + descuento del 20 % durante 3 meses) sobre como máximo el 25 % de la base cada trimestre. El coste de la acción es de ~40 € por cliente contactado; el valor medio que se pierde cuando un cliente se da de baja (LTV restante) es de ~600 €.
Tu encargo como ingeniero/a de ML: construir un modelo que, con los datos de uso, facturación y soporte de cada cliente, estime la probabilidad de baja en los próximos 3 meses, para que Customer Success priorice a quién contactar.
Preguntas de negocio que tu informe final debe responder:
- ¿Qué clientes tienen mayor riesgo de baja y con qué confianza?
- ¿Qué factores están más asociados al churn? (accionable para producto)
- ¿Qué umbral de decisión maximiza el beneficio económico de la campaña de retención?
- ¿Cuánto dinero estimado ahorra el modelo frente a (a) no hacer nada y (b) contactar clientes al azar?
Nota
fíjate en que el encargo no dice "consigue el mejor F1". La métrica la eliges y justificas tú a partir de los costes del negocio (40 € por contacto, 600 € por baja evitada). Esa traducción negocio→métrica es la habilidad que se evalúa.
2.2 Descripción de los datos, campo a campo¶
El script de la sección 2.3 genera data/clientes_nubecrm.csv con 800 clientes (una muestra de la base para el piloto) y estas columnas:
| Columna | Tipo | Descripción | Ejemplo |
|---|---|---|---|
cliente_id |
str | Identificador único NC-00001 … |
NC-00042 |
antiguedad_meses |
int | Meses desde el alta del cliente | 14 |
plan |
categórica | Plan contratado: basico, pro, enterprise |
pro |
num_usuarios |
int | Licencias/asientos activos en la cuenta | 7 |
logins_ultimo_mes |
int | Nº de inicios de sesión en los últimos 30 días (toda la cuenta) | 55 |
pct_uso_funcionalidades |
float | % de las funcionalidades del plan que la cuenta usa (0-100) | 43.2 |
dias_desde_ultimo_login |
int | Días desde el último acceso de cualquier usuario | 3 |
tickets_soporte_6m |
int | Tickets abiertos en los últimos 6 meses | 2 |
tiempo_medio_resolucion_h |
float | Horas medias de resolución de sus tickets (NaN si no tiene tickets) | 18.5 |
nps_ultimo |
int | Última puntuación NPS del cliente, 0-10 (NaN si no respondió, ~25 %) | 8 |
retraso_pagos_12m |
int | Nº de recibos pagados con retraso en 12 meses | 0 |
metodo_pago |
categórica | tarjeta, domiciliacion, transferencia |
tarjeta |
facturacion_mensual_eur |
float | Cuota mensual actual (plan × usuarios, con descuentos) | 553.00 |
churn |
int | Target. 1 = se dio de baja en los 3 meses siguientes | 0 |
Detalles importantes que un buen EDA debería descubrir por sí mismo:
- La tasa global de churn ronda el 18 % (la muestra del piloto sobre-representa bajas a propósito; en producción sería menor).
- Hay valores faltantes reales:
nps_ultimo(no todo el mundo responde encuestas) ytiempo_medio_resolucion_h(clientes sin tickets). Ambos requieren decisión de imputación razonada, no automática. - Hay relaciones no lineales y de interacción plantadas (p. ej., el efecto de los tickets depende del tiempo de resolución).
Advertencia
cliente_id no es una feature. Y facturacion_mensual_eur está correlacionada con plan × num_usuarios (es casi una combinación lineal de ambas): decide de forma argumentada si la mantienes, la eliminas o construyes algo mejor con ella.
2.3 Generador del dataset sintético (script completo)¶
Guarda esto como generar_datos.py y ejecútalo una vez (python generar_datos.py). Crea data/clientes_nubecrm.csv.
"""
generar_datos.py — Generador del dataset sintético de NubeCRM.
Crea data/clientes_nubecrm.csv con 800 clientes y churn ~18 %.
Uso: python generar_datos.py
"""
import numpy as np # generación numérica aleatoria
import pandas as pd # construcción del DataFrame final
from pathlib import Path # manejo de rutas multiplataforma
# ---------------------------------------------------------------- constantes
SEMILLA = 42 # semilla fija => dataset reproducible
N = 800 # número de clientes de la muestra
rng = np.random.default_rng(SEMILLA) # generador moderno de NumPy
# ------------------------------------------------------------ 1) demografía
# Antigüedad en meses: mezcla de clientes nuevos y veteranos.
# Usamos una exponencial truncada: muchos clientes recientes, cola de antiguos.
antiguedad = np.clip(rng.exponential(scale=18, size=N), 1, 72).astype(int)
# Plan contratado con probabilidades realistas (la mayoría en básico/pro).
planes = rng.choice(["basico", "pro", "enterprise"], size=N, p=[0.50, 0.38, 0.12])
# Número de usuarios: depende del plan (enterprise tiene más asientos).
base_usuarios = np.select( # media de usuarios según plan
[planes == "basico", planes == "pro", planes == "enterprise"],
[2.0, 6.0, 18.0],
)
num_usuarios = np.clip(rng.poisson(base_usuarios), 1, 60) # Poisson >= 1
# ------------------------------------------------------------ 2) uso producto
# "Engagement" latente: variable oculta 0-1 que gobierna cuánto usa el producto
# cada cliente. NO se exporta: es la causa común que tu modelo debe aproximar.
engagement = rng.beta(a=2.5, b=1.8, size=N) # sesgada hacia uso medio-alto
# Logins del último mes: proporcionales a usuarios y engagement, con ruido.
logins = rng.poisson(lam=np.maximum(num_usuarios * engagement * 9, 0.5))
# % de funcionalidades usadas: crece con engagement y con la antigüedad
# (los clientes veteranos han explorado más el producto).
pct_uso = np.clip(
engagement * 70 # componente principal
+ np.minimum(antiguedad, 24) * 0.8 # aprendizaje, satura a 2 años
+ rng.normal(0, 8, N), # ruido gaussiano
1, 100,
).round(1)
# Días desde el último login: inverso al engagement (poco uso => más días).
dias_ult_login = np.clip(
rng.exponential(scale=(1.2 - engagement) * 22, size=N), 0, 90
).astype(int)
# ------------------------------------------------------------ 3) soporte
# Nº de tickets en 6 meses: más usuarios => más tickets; clientes desenganchados
# generan algo menos (ya ni se quejan, señal peligrosa que el EDA puede captar).
tickets = rng.poisson(lam=0.4 + num_usuarios * 0.15 + (1 - engagement) * 0.8)
tickets = np.clip(tickets, 0, 15)
# Tiempo medio de resolución (horas): lognormal; NaN si no hay tickets.
t_resol = np.round(rng.lognormal(mean=2.8, sigma=0.6, size=N), 1) # ~16-25 h
t_resol = np.where(tickets == 0, np.nan, t_resol) # sin tickets => NaN
# ------------------------------------------------------------ 4) facturación
precio_plan = np.select( # precio unitario por asiento y plan
[planes == "basico", planes == "pro", planes == "enterprise"],
[29.0, 79.0, 199.0],
)
# Descuento aleatorio (0-15 %) que rompe la colinealidad perfecta.
descuento = rng.uniform(0, 0.15, N)
facturacion = np.round(precio_plan * num_usuarios * (1 - descuento) / 3 + precio_plan, 2)
# Retrasos de pago: raros en general; más probables con transferencia.
metodo_pago = rng.choice(["tarjeta", "domiciliacion", "transferencia"],
size=N, p=[0.45, 0.40, 0.15])
lam_retraso = np.where(metodo_pago == "transferencia", 1.2, 0.25)
retrasos = np.clip(rng.poisson(lam_retraso), 0, 8)
# ------------------------------------------------------------ 5) NPS
# NPS 0-10 correlacionado con engagement y con soporte lento; 25 % de NaN.
nps_cont = 3 + engagement * 7 - np.nan_to_num(t_resol, nan=20) * 0.03 \
+ rng.normal(0, 1.2, N) # componente continua
nps = np.clip(np.round(nps_cont), 0, 10) # discretizamos a 0-10
mask_sin_nps = rng.random(N) < 0.25 # 25 % no respondió
nps = np.where(mask_sin_nps, np.nan, nps) # inyectamos los NaN
# ------------------------------------------------------------ 6) target: churn
# Modelo de riesgo "verdadero" (log-odds). Relaciones plantadas:
# - engagement bajo y muchos días sin login => MÁS churn
# - interacción: muchos tickets Y resolución lenta => MÁS churn
# - antigüedad alta => MENOS churn (fidelidad)
# - retrasos de pago => MÁS churn
# - plan enterprise => algo MENOS churn (contratos)
log_odds = (
-1.1 # intercepto base
- engagement * 3.2 # efecto protector del uso
+ dias_ult_login * 0.030 # abandono silencioso
+ (tickets >= 4) * np.nan_to_num(t_resol, nan=0) * 0.028 # interacción
- np.log1p(antiguedad) * 0.55 # fidelidad (log => no lineal)
+ retrasos * 0.42 # problemas de pago
- (planes == "enterprise") * 0.6 # contratos anuales
+ rng.normal(0, 0.55, N) # ruido irreducible
)
p_churn = 1 / (1 + np.exp(-log_odds)) # sigmoide => probabilidad
churn = (rng.random(N) < p_churn).astype(int) # muestreo Bernoulli
# ------------------------------------------------------------ 7) exportación
df = pd.DataFrame({
"cliente_id": [f"NC-{i:05d}" for i in range(1, N + 1)],
"antiguedad_meses": antiguedad,
"plan": planes,
"num_usuarios": num_usuarios,
"logins_ultimo_mes": logins,
"pct_uso_funcionalidades": pct_uso,
"dias_desde_ultimo_login": dias_ult_login,
"tickets_soporte_6m": tickets,
"tiempo_medio_resolucion_h": t_resol,
"nps_ultimo": nps,
"retraso_pagos_12m": retrasos,
"metodo_pago": metodo_pago,
"facturacion_mensual_eur": facturacion,
"churn": churn,
})
Path("data").mkdir(exist_ok=True) # crea data/ si no existe
df.to_csv("data/clientes_nubecrm.csv", index=False) # sin columna de índice
print(f"Generado data/clientes_nubecrm.csv | filas={len(df)} "
f"| churn={df['churn'].mean():.1%}") # sanity check en consola
Consejo profesional
ejecuta el script y comprueba en consola que la tasa de churn está entre el 15 % y el 21 %. Verificar los datos antes de modelar (sanity checks) es un hábito que distingue a un profesional: en producción, la mitad de los incidentes de ML son datos rotos, no modelos rotos.
2.4 Requisitos funcionales¶
Numerados para que puedas referenciarlos en tu informe y en los commits (feat: RF-3 comparación de modelos).
| ID | Requisito | Detalle |
|---|---|---|
| RF-1 | EDA documentado | Análisis exploratorio con: distribución del target, análisis de nulos con decisión de imputación justificada, al menos 4 visualizaciones relevantes (no "gráficos por rellenar"), análisis de correlaciones y detección de la relación colineal facturacion↔plan×usuarios. Cada gráfico con una conclusión escrita de 1-3 líneas. |
| RF-2 | Baseline | Antes de cualquier modelo "serio": DummyClassifier (estrategia justificada) y una regresión logística simple. Toda mejora posterior se reporta relativa al baseline. |
| RF-3 | Tres modelos comparados con CV | Mínimo: regresión logística (con preprocesado adecuado), Random Forest y un boosting (HistGradientBoosting, XGBoost o LightGBM). Comparación con validación cruzada estratificada (k=5), misma partición para todos, media ± desviación estándar. Todo preprocesado dentro de un Pipeline (cero fugas de datos). |
| RF-4 | Métrica justificada por coste de negocio | Elige y justifica la métrica principal usando los costes: 40 € por contacto de retención, 600 € de LTV perdido por baja no evitada, presupuesto para contactar máx. 25 % de la base. Pista: precisión/recall a un % fijo de la base, AUC-PR o beneficio esperado son mejores candidatas que accuracy. Debes descartar accuracy explícitamente y explicar por qué. |
| RF-5 | Umbral de decisión óptimo | Con el mejor modelo, barre umbrales de 0.05 a 0.95 y calcula el beneficio económico de la campaña en un conjunto de test intocado. Gráfico beneficio-vs-umbral y umbral recomendado con su justificación. Asume: contactar a un cliente que iba a irse evita la baja con probabilidad 0.4 (efectividad de la campaña). |
| RF-6 | Informe de conclusiones en Markdown | INFORME.md de 1-2 páginas dirigido al director de Customer Success (no técnico): problema, enfoque, resultados en euros, factores de churn detectados (importancias), recomendación operativa y limitaciones. Sin jerga innecesaria; toda cifra con su incertidumbre. |
Nota sobre RF-5, el cálculo del beneficio por cliente de test
- Verdadero positivo (contactas a alguien que iba a irse): beneficio esperado =
0.4 × 600 − 40 = +200 €. - Falso positivo (contactas a alguien que se quedaba):
−40 €. - Falso negativo (no contactas y se va):
0 €en la campaña, pero pierdes los 600 € igualmente — decide si lo contabilizas como coste de oportunidad y sé coherente. - Verdadero negativo:
0 €. La gracia del ejercicio es que el umbral que maximiza F1 casi nunca coincide con el que maximiza el beneficio.
2.5 Arquitectura de la solución¶
flowchart LR
subgraph datos["Datos"]
G[generar_datos.py] --> CSV[(clientes_nubecrm.csv)]
end
subgraph analisis["Análisis"]
CSV --> EDA[01_eda.ipynb<br/>EDA + decisiones de imputación]
end
subgraph modelado["Modelado"]
EDA --> SPLIT[Split estratificado<br/>train 80 / test 20]
SPLIT --> PIPE[Pipeline sklearn<br/>ColumnTransformer:<br/>imputación + escalado + OHE]
PIPE --> CV{CV estratificada k=5}
CV --> M1[Logística]
CV --> M2[Random Forest]
CV --> M3[Boosting]
M1 & M2 & M3 --> COMP[Tabla comparativa<br/>media ± std]
end
subgraph decision["Decisión"]
COMP --> BEST[Mejor modelo<br/>reentrenado en train completo]
BEST --> THR[Barrido de umbral<br/>beneficio en test]
THR --> INF[INFORME.md<br/>para Customer Success]
end
2.6 Esqueleto de código con TODOs¶
Estructura mínima sugerida del repo:
mini-proyecto-churn/
├── generar_datos.py # (dado, sección 2.3)
├── data/
│ └── clientes_nubecrm.csv
├── 01_eda.ipynb # o eda.py — RF-1
├── 02_modelado.py # RF-2 a RF-5
├── INFORME.md # RF-6
└── requirements.txt
Esqueleto de 02_modelado.py:
"""02_modelado.py — Churn NubeCRM: baseline, comparación de modelos y umbral óptimo."""
import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split, StratifiedKFold, cross_validate
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.ensemble import RandomForestClassifier, HistGradientBoostingClassifier
from sklearn.dummy import DummyClassifier
from sklearn.metrics import average_precision_score, confusion_matrix
# --- 1. Carga y split ---------------------------------------------------------
df = pd.read_csv("data/clientes_nubecrm.csv")
# TODO: separa X (sin cliente_id ni churn) e y (churn).
# TODO: train_test_split 80/20 estratificado, random_state=42.
# El test NO se toca hasta el paso 5.
# --- 2. Preprocesado ----------------------------------------------------------
NUMERICAS = [...] # TODO: lista de columnas numéricas
CATEGORICAS = [...] # TODO: lista de columnas categóricas
# TODO: ColumnTransformer con:
# - numéricas: SimpleImputer(strategy=?) + StandardScaler
# (¿mediana? ¿constante? justifica según lo visto en el EDA para
# tiempo_medio_resolucion_h y nps_ultimo — NaN aquí SIGNIFICA algo)
# - categóricas: OneHotEncoder(handle_unknown="ignore")
# Considera añadir una feature booleana "sin_tickets" / "sin_nps" antes
# de imputar: convierte la ausencia en información.
# --- 3. Baseline (RF-2) -------------------------------------------------------
# TODO: DummyClassifier — ¿qué strategy tiene sentido con 18 % de churn?
# TODO: evalúalo con la MISMA CV que usarás después. Guarda los números.
# --- 4. Comparación de modelos (RF-3) -----------------------------------------
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
modelos = {
"logistica": ..., # TODO: Pipeline(preproc, LogisticRegression(max_iter=1000, class_weight=?))
"random_forest": ..., # TODO
"boosting": ..., # TODO: HistGradientBoostingClassifier (ojo: NO necesita OHE denso ni escalado — puedes darle un preproc distinto y explicarlo)
}
# TODO: para cada modelo, cross_validate con scoring múltiple
# (["average_precision", "roc_auc", "recall"]) y construye una tabla
# media ± std. Elige el mejor según TU métrica de RF-4 y justifica.
# --- 5. Umbral óptimo (RF-5) --------------------------------------------------
COSTE_CONTACTO = 40 # € por cliente contactado
LTV_PERDIDO = 600 # € que se pierden si el cliente se va
EFECTIVIDAD = 0.4 # prob. de retener a un churner contactado
def beneficio_campana(y_true, y_prob, umbral):
"""Beneficio total en € de contactar a todos los clientes con prob >= umbral."""
# TODO: calcula TP y FP con el umbral y devuelve
# TP * (EFECTIVIDAD * LTV_PERDIDO - COSTE_CONTACTO) - FP * COSTE_CONTACTO
...
# TODO: reentrena el mejor pipeline en TODO el train, predice probabilidades
# en test, barre umbrales np.arange(0.05, 0.96, 0.01), grafica y elige.
# TODO: comprueba la restricción de negocio: ¿el umbral elegido contacta
# a más del 25 % de la base? Si sí, ajusta y coméntalo en el informe.
# --- 6. Insights para el informe (RF-6) ----------------------------------------
# TODO: importancias (coeficientes de la logística sobre datos escalados y/o
# permutation_importance del mejor modelo) → top 5 factores de churn.
2.7 Código de referencia completo¶
Advertencia
intenta resolverlo por tu cuenta antes de abrir esto. Consultar la solución antes de pelearte 30 minutos con un problema te roba el aprendizaje que viniste a buscar.
Ver solución de referencia completa de 02_modelado.py (comentada línea a línea)
"""Solución de referencia — Mini proyecto churn NubeCRM.
No es LA solución: es UNA solución sólida. La tuya puede diferir en
decisiones (imputación, métrica) siempre que estén justificadas.
"""
import numpy as np # cálculo numérico
import pandas as pd # manejo tabular
from sklearn.model_selection import ( # utilidades de validación
train_test_split, StratifiedKFold, cross_validate,
)
from sklearn.compose import ColumnTransformer # preproc por tipo de columna
from sklearn.pipeline import Pipeline # encadenar preproc + modelo
from sklearn.impute import SimpleImputer # imputación de NaN
from sklearn.preprocessing import StandardScaler, OneHotEncoder, OrdinalEncoder
from sklearn.linear_model import LogisticRegression # modelo lineal interpretable
from sklearn.ensemble import ( # modelos de árbol
RandomForestClassifier, HistGradientBoostingClassifier,
)
from sklearn.dummy import DummyClassifier # baseline trivial
from sklearn.inspection import permutation_importance # importancias agnósticas
# ----------------------------------------------------------- 1. carga y split
df = pd.read_csv("data/clientes_nubecrm.csv") # dataset generado
# Ingeniería mínima ANTES del split (solo transformaciones fila a fila,
# que no aprenden nada del conjunto => no hay fuga de datos):
df["sin_tickets"] = df["tickets_soporte_6m"].eq(0).astype(int) # NaN estructural
df["sin_nps"] = df["nps_ultimo"].isna().astype(int) # no respondió
X = df.drop(columns=["cliente_id", "churn"]) # id fuera: no es señal
y = df["churn"] # target binario
# Split estratificado: conserva el 18 % de churn en train y test.
X_tr, X_te, y_tr, y_te = train_test_split(
X, y, test_size=0.20, stratify=y, random_state=42, # test intocable
)
# ----------------------------------------------------------- 2. preprocesado
NUMERICAS = [
"antiguedad_meses", "num_usuarios", "logins_ultimo_mes",
"pct_uso_funcionalidades", "dias_desde_ultimo_login",
"tickets_soporte_6m", "tiempo_medio_resolucion_h", "nps_ultimo",
"retraso_pagos_12m", "facturacion_mensual_eur",
"sin_tickets", "sin_nps", # flags ya numéricas
]
CATEGORICAS = ["plan", "metodo_pago"]
# Preprocesado para modelos LINEALES: imputar + escalar + one-hot.
preproc_lineal = ColumnTransformer([
("num", Pipeline([
# Mediana: robusta a colas (facturación, resolución de tickets).
# La "señal" del NaN ya está capturada en sin_tickets / sin_nps,
# así que la imputación solo rellena el hueco sin inventar señal.
("imputar", SimpleImputer(strategy="median")),
("escalar", StandardScaler()), # la logística lo agradece
]), NUMERICAS),
("cat", OneHotEncoder(handle_unknown="ignore"), CATEGORICAS),
])
# Preprocesado para ÁRBOLES: los árboles no necesitan escalado y
# HistGradientBoosting maneja NaN nativamente => solo codificamos categorías.
preproc_arboles = ColumnTransformer(
[("cat", OrdinalEncoder(handle_unknown="use_encoded_value",
unknown_value=-1), CATEGORICAS)],
remainder="passthrough", # numéricas tal cual (con NaN)
)
# ----------------------------------------------------------- 3. baseline
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) # misma CV para todos
SCORING = ["average_precision", "roc_auc", "recall"] # AP = métrica principal
# 'stratified' predice al azar respetando el 18/82: baseline honesto para AP
# (con 'most_frequent' la AP ni se define bien: nunca predice positivos).
dummy = DummyClassifier(strategy="stratified", random_state=42)
res_dummy = cross_validate(dummy, X_tr[NUMERICAS], y_tr, cv=cv, scoring=SCORING)
print(f"Baseline dummy — AP: {res_dummy['test_average_precision'].mean():.3f}")
# Con 18 % de positivos, la AP del azar ≈ 0.18: ese es el suelo a batir.
# ----------------------------------------------------------- 4. comparación
modelos = {
"logistica": Pipeline([
("prep", preproc_lineal),
# class_weight='balanced' compensa el 18/82 reponderando la pérdida.
("clf", LogisticRegression(max_iter=1000, class_weight="balanced")),
]),
"random_forest": Pipeline([
("prep", preproc_lineal), # RF no maneja NaN => imputamos
("clf", RandomForestClassifier(
n_estimators=400, # suficientes para estabilizar
min_samples_leaf=5, # regulariza con solo 640 filas
class_weight="balanced", random_state=42, n_jobs=-1)),
]),
"boosting": Pipeline([
("prep", preproc_arboles), # NaN nativos, sin escalar
("clf", HistGradientBoostingClassifier(
max_iter=300, learning_rate=0.06, # lento y regularizado
max_leaf_nodes=15, # árboles pequeños: pocos datos
random_state=42)),
]),
}
tabla = {} # nombre -> métricas
for nombre, pipe in modelos.items():
res = cross_validate(pipe, X_tr, y_tr, cv=cv, scoring=SCORING)
tabla[nombre] = {
m: f"{res[f'test_{m}'].mean():.3f} ± {res[f'test_{m}'].std():.3f}"
for m in SCORING
}
print(pd.DataFrame(tabla).T) # tabla comparativa media±std
# Resultado típico con la semilla 42: el boosting gana en AP por poco a la
# logística; el RF queda tercero. Si la diferencia cae dentro de ±1 std,
# di explícitamente que es un empate técnico y elige por otros criterios.
# ----------------------------------------------------------- 5. umbral óptimo
mejor = modelos["boosting"].fit(X_tr, y_tr) # reentrena en train completo
prob_te = mejor.predict_proba(X_te)[:, 1] # probabilidades en test
COSTE_CONTACTO, LTV_PERDIDO, EFECTIVIDAD = 40, 600, 0.4
def beneficio_campana(y_true, y_prob, umbral):
"""Beneficio (€) de contactar a todo cliente con prob >= umbral."""
contactado = y_prob >= umbral # máscara de contactados
tp = int(np.sum(contactado & (y_true == 1))) # churners contactados
fp = int(np.sum(contactado & (y_true == 0))) # fieles contactados (gasto)
# TP: con prob 0.4 evitamos perder 600 €, y siempre pagamos 40 € de acción.
return tp * (EFECTIVIDAD * LTV_PERDIDO - COSTE_CONTACTO) - fp * COSTE_CONTACTO
umbrales = np.arange(0.05, 0.96, 0.01) # barrido fino
beneficios = [beneficio_campana(y_te.values, prob_te, u) for u in umbrales]
pct_contactado = [(prob_te >= u).mean() for u in umbrales] # restricción 25 %
# Nos quedamos con el mejor umbral QUE RESPETA el presupuesto (<= 25 % base).
validos = [i for i, p in enumerate(pct_contactado) if p <= 0.25]
i_best = max(validos, key=lambda i: beneficios[i]) # argmax restringido
print(f"Umbral óptimo: {umbrales[i_best]:.2f} | "
f"beneficio test: {beneficios[i_best]:+.0f} € | "
f"contacta al {pct_contactado[i_best]:.0%} de la base")
# Nota clave para el informe: como (0.4*600-40)=+200 € por TP y -40 € por FP,
# contactar "compensa" incluso con precisión modesta (~1 acierto por cada
# 5 contactos ya es rentable) => el umbral óptimo es más BAJO que 0.5.
# ----------------------------------------------------------- 6. importancias
# Permutation importance sobre TEST: mide cuánto empeora la AP al barajar
# cada columna => importancia agnóstica al modelo y honesta (no sesgo train).
imp = permutation_importance(mejor, X_te, y_te, n_repeats=20,
scoring="average_precision", random_state=42)
ranking = pd.Series(imp.importances_mean, index=X_te.columns).sort_values(ascending=False)
print(ranking.head(8))
# Debería emerger: dias_desde_ultimo_login, logins/pct_uso (proxies del
# engagement oculto), antiguedad_meses y retraso_pagos_12m. Si tu ranking
# coincide, tu modelo ha recuperado la estructura plantada en el generador.
Consejo profesional
en el informe, traduce el resultado del umbral a una frase que un directivo entienda: "Contactando al 22 % de la base que el modelo señala, la campaña genera un beneficio estimado de X € por cada 160 clientes, frente a Y € contactando al azar". Los modelos no se venden con AUCs; se venden con euros.
2.8 Criterios de aceptación¶
Marca cada casilla solo si puedes demostrarla enseñando el código o el informe:
- [ ]
python generar_datos.pyseguido de mis scripts/notebooks reproduce todos los resultados sin editar nada (semillas fijadas en todas partes). - [ ] El EDA incluye análisis de nulos con decisión de imputación escrita y justificada (no "imputé con la media porque sí").
- [ ] El EDA detecta y comenta la relación
facturacion_mensual_eur≈ f(plan,num_usuarios). - [ ] Existe un baseline
DummyClassifiery sus métricas aparecen en la tabla comparativa. - [ ] Los 3 modelos se comparan con la misma CV estratificada (k=5) y la tabla muestra media ± std.
- [ ] Todo el preprocesado vive dentro de
Pipeline/ColumnTransformer; no hay ningúnfitde imputador/escalador sobre datos de test. - [ ] La métrica principal está justificada con los costes de negocio y se descarta accuracy explícitamente.
- [ ] El conjunto de test se usa una sola vez, al final, para el umbral y el informe.
- [ ] El barrido de umbral respeta la restricción del 25 % de la base y hay un gráfico beneficio-vs-umbral.
- [ ]
INFORME.mdexiste, es legible por un no técnico, incluye cifras en euros y una sección de limitaciones. - [ ] El repo tiene
requirements.txty un historial de commits con mensajes descriptivos (mínimo 8 commits).
2.9 Rúbrica de autoevaluación¶
Puntúa cada dimensión de 1 a 5. Objetivo: ≥ 4 en todas antes de pasar al proyecto B.
| Dimensión | 1 (insuficiente) | 3 (correcto) | 5 (profesional) |
|---|---|---|---|
| EDA y datos | Gráficos sin conclusiones; nulos imputados sin pensar | Nulos tratados con criterio; visualizaciones relevantes comentadas | Descubre las relaciones plantadas (interacción tickets×resolución, no linealidad de antigüedad) y crea flags de ausencia informativos |
| Rigor metodológico | Fugas de datos; test usado varias veces; sin CV | Pipeline correcto, CV estratificada, test intocado | Además: preprocesado diferenciado por familia de modelo y justificado; incertidumbre (±std) usada para declarar empates técnicos |
| Conexión con negocio | Optimiza accuracy/F1 sin mirar costes | Métrica justificada con los costes; umbral por beneficio | Análisis de sensibilidad (¿y si la efectividad fuera 0.3?), restricción del 25 % integrada, comparación vs contactar al azar |
| Calidad del código | Un notebook monolítico sin funciones | Código organizado, funciones con docstrings, semillas fijadas | Scripts reutilizables, constantes de negocio parametrizadas, ejecutable de cero con 2 comandos |
| Comunicación | Informe técnico ilegible para negocio, o inexistente | Informe claro con resultados y recomendación | Informe que un director firmaría: euros, riesgos, limitaciones, próximo paso propuesto |
3. PROYECTO PROFESIONAL — Sistema de scoring en producción (FinanCredit)¶
Duración estimada: 12-20 horas · Dificultad: · Entregable: repositorio completo listo para enseñar en una entrevista
3.1 Especificación empresarial y contexto regulatorio¶
Caso empresarial
FinanCredit es una entidad financiera ficticia que concede préstamos personales de 1.000 a 30.000 € a través de su web. Hoy, un equipo de 12 analistas revisa cada solicitud a mano (~20 min por solicitud). El negocio quiere un servicio de scoring automático que asigne a cada solicitud una probabilidad de impago (default) para: (a) auto-aprobar las de riesgo muy bajo, (b) auto-denegar las de riesgo muy alto, y (c) enviar la zona gris a los analistas.
Contexto regulatorio (esto condiciona TODO el diseño):
- El regulador y la normativa de crédito al consumo exigen que las decisiones automatizadas sobre solvencia sean explicables: ante una denegación, la entidad debe poder indicar los principales factores que motivaron la decisión. El Reglamento europeo de IA clasifica los sistemas de scoring crediticio como de alto riesgo, lo que refuerza las obligaciones de documentación, trazabilidad y supervisión humana.
- Toda predicción debe quedar auditada: qué entró, qué salió, con qué versión del modelo y cuándo. Auditoría interna puede pedir reconstruir cualquier decisión de los últimos 5 años.
- Está prohibido usar variables protegidas (género, nacionalidad, etc.) — por eso el dataset no las contiene — y el sistema debe monitorizarse para detectar deriva de datos (drift): si la población de solicitantes cambia, el modelo puede degradarse silenciosamente.
Consecuencia de diseño: no basta con "el modelo con mejor AUC". Debes comparar un modelo interpretable (regresión logística) contra un boosting y emitir una recomendación argumentada que pese puntos de AUC contra explicabilidad y obligaciones regulatorias. Ambas conclusiones son defendibles; lo que se evalúa es el argumento.
Tu encargo: construir el servicio completo — modelo + API + auditoría + contenedores + tests + monitorización — tal que otro ingeniero pueda clonarlo, levantar docker compose up y hacer scoring en menos de 5 minutos.
3.2 Generador de datos sintéticos¶
Guarda como scripts/generar_datos.py. Genera data/solicitudes_financredit.csv con 3.000 solicitudes históricas etiquetadas (default ~14 %).
"""scripts/generar_datos.py — Datos sintéticos de solicitudes de FinanCredit.
Genera data/solicitudes_financredit.csv (3000 filas, default ~14 %).
Uso: python scripts/generar_datos.py
"""
import numpy as np # aleatoriedad y vectorización
import pandas as pd # DataFrame de salida
from pathlib import Path # rutas portables
SEMILLA, N = 7, 3000 # reproducibilidad y tamaño
rng = np.random.default_rng(SEMILLA) # generador NumPy moderno
# --------------------------------------------------- 1) perfil del solicitante
edad = np.clip(rng.normal(41, 12, N), 21, 75).astype(int) # 21-75 años
# Ingresos mensuales netos (€): lognormal => cola derecha realista.
ingresos = np.round(np.clip(rng.lognormal(7.45, 0.42, N), 900, 12000), 0)
situacion_laboral = rng.choice( # empleo
["indefinido", "temporal", "autonomo", "pensionista", "desempleado"],
size=N, p=[0.48, 0.18, 0.16, 0.12, 0.06])
antiguedad_empleo = np.where( # años en el empleo
situacion_laboral == "desempleado", 0, # parado => 0
np.clip(rng.exponential(6, N), 0, 40)).round(1)
vivienda = rng.choice(["propiedad", "hipoteca", "alquiler", "familiar"],
size=N, p=[0.28, 0.32, 0.30, 0.10])
# --------------------------------------------------- 2) historial crediticio
# Score latente de "salud financiera" (0-1). Variable OCULTA: no se exporta.
salud = rng.beta(3.2, 1.6, N) # sesgo a sanos
num_creditos_activos = np.clip(rng.poisson(1.2 + (1 - salud) * 2.5), 0, 9)
# Impagos previos: muy dependientes de la salud financiera latente.
impagos_previos = np.clip(rng.poisson((1 - salud) ** 2 * 3.5), 0, 6)
# Ratio deuda/ingresos actual (0-0.8): peor cuanto menor salud.
ratio_endeudamiento = np.clip(
rng.beta(2, 5, N) * 0.5 + (1 - salud) * 0.35, 0, 0.80).round(3)
# Años de historial en el bureau de crédito; NaN ~8 % ("thin files", jóvenes).
anios_historial = np.clip(edad - 20 - rng.exponential(4, N), 0, 45).round(1)
anios_historial = np.where(rng.random(N) < 0.08, np.nan, anios_historial)
# --------------------------------------------------- 3) el préstamo solicitado
importe = np.round(np.clip(rng.lognormal(8.9, 0.55, N), 1000, 30000), -2)
plazo_meses = rng.choice([12, 24, 36, 48, 60], size=N,
p=[0.10, 0.22, 0.30, 0.23, 0.15])
finalidad = rng.choice(["consumo", "vehiculo", "reforma", "consolidacion", "otros"],
size=N, p=[0.30, 0.24, 0.20, 0.16, 0.10])
# Cuota mensual aproximada (interés simple ~9 % TAE, para el ratio esfuerzo).
cuota = np.round(importe * (1 + 0.09 * plazo_meses / 12) / plazo_meses, 2)
ratio_cuota_ingresos = np.round(cuota / ingresos, 3) # esfuerzo
# --------------------------------------------------- 4) target: default
# Log-odds "verdaderos" con relaciones plantadas y documentadas:
log_odds = (
-2.0 # base: default minoritario
- salud * 3.0 # salud financiera protege
+ impagos_previos * 0.55 # historial manda (señal top)
+ ratio_cuota_ingresos * 5.5 # esfuerzo excesivo => riesgo
+ ratio_endeudamiento * 2.2 # ya endeudado => riesgo
+ (situacion_laboral == "desempleado") * 1.1 # sin ingresos estables
+ (situacion_laboral == "temporal") * 0.35 # precariedad moderada
- np.log1p(np.nan_to_num(anios_historial, nan=1.0)) * 0.30 # historial largo protege
- (vivienda == "propiedad") * 0.30 # colchón patrimonial
+ (finalidad == "consolidacion") * 0.45 # refinanciar deudas: señal amarilla
+ rng.normal(0, 0.5, N) # ruido irreducible
)
p_default = 1 / (1 + np.exp(-log_odds)) # sigmoide
default = (rng.random(N) < p_default).astype(int) # Bernoulli por solicitud
# --------------------------------------------------- 5) exportación
df = pd.DataFrame({
"solicitud_id": [f"FC-{i:06d}" for i in range(1, N + 1)],
"edad": edad,
"ingresos_mensuales": ingresos,
"situacion_laboral": situacion_laboral,
"antiguedad_empleo_anios": antiguedad_empleo,
"vivienda": vivienda,
"num_creditos_activos": num_creditos_activos,
"impagos_previos": impagos_previos,
"ratio_endeudamiento": ratio_endeudamiento,
"anios_historial_crediticio": anios_historial,
"importe_solicitado": importe,
"plazo_meses": plazo_meses,
"finalidad": finalidad,
"ratio_cuota_ingresos": ratio_cuota_ingresos,
"default": default,
})
Path("data").mkdir(exist_ok=True)
df.to_csv("data/solicitudes_financredit.csv", index=False)
print(f"OK: {len(df)} solicitudes | default={df['default'].mean():.1%}")
Nota
el generador incluye ratio_cuota_ingresos, que se deriva de importe, plazo_meses e ingresos. En producción, tu API recibirá los campos crudos y deberá recalcular esta feature en el servidor — nunca confíes en features derivadas enviadas por el cliente. Este detalle (feature engineering consistente entre entrenamiento y servicio) es uno de los puntos que más se valoran del proyecto.
3.3 Requisitos del sistema¶
| ID | Área | Requisito |
|---|---|---|
| RS-1 | Modelado | Pipeline sklearn con ColumnTransformer (imputación, escalado, codificación) entrenable con un solo comando: python -m src.train. Serializa el pipeline completo con joblib junto a un metadata.json (versión, fecha, métricas, hash del dataset, lista de features). |
| RS-2 | Modelado | Comparación logística interpretable vs boosting con CV estratificada, y un documento docs/decision_modelo.md con la recomendación argumentada (rendimiento vs explicabilidad regulatoria). La decisión debe citar números concretos. |
| RS-3 | Explicabilidad | permutation_importance sobre el conjunto de test incluida en el informe, con lectura en lenguaje de riesgos ("los impagos previos son el principal factor…"). Si eliges la logística, añade tabla de coeficientes con signo e interpretación. |
| RS-4 | API | FastAPI con: POST /predict (validación Pydantic estricta: rangos, categorías cerradas con Literal, rechazo de campos extra), GET /health (estado + modelo cargado sí/no) y GET /model-info (versión del modelo, fecha de entrenamiento, métricas de validación, features esperadas). |
| RS-5 | API | /predict devuelve: probabilidad de default, decisión sugerida en 3 tramos (aprobar < 0.08, revisar 0.08-0.30, denegar > 0.30 — umbrales en config, no hardcodeados) y los 3 factores principales de la decisión (para la carta de denegación). |
| RS-6 | Auditoría | Todas las predicciones se registran en SQLite (audit/predicciones.db): timestamp UTC, payload de entrada completo (JSON), probabilidad, decisión, versión del modelo y latencia. La escritura no debe romper la respuesta si falla (log del error y seguir). |
| RS-7 | Infra | Dockerfile multi-etapa (o al menos limpio y con usuario no-root) + docker-compose.yml con dos servicios: api y redis (caché de respuestas: misma solicitud → respuesta cacheada con TTL de 1 h). Si Redis está caído, la API funciona igualmente (caché best effort). |
| RS-8 | Calidad | Tests con pytest + TestClient: mínimo 5 (detallados en 3.7.7). Ejecutables con pytest -v sin necesidad de Docker ni Redis. |
| RS-9 | Monitorización | Script python -m src.monitor que calcula el PSI (Population Stability Index) por feature entre los datos de entrenamiento y las últimas N peticiones registradas en la BD de auditoría, e imprime una tabla con alertas (PSI > 0.2). |
| RS-10 | Documentación | index.md profesional según la plantilla de 3.9: cualquier ingeniero levanta el sistema en < 5 min siguiendo solo el README. |
3.4 Arquitectura detallada¶
flowchart TB
subgraph offline["OFFLINE — Entrenamiento (src/train.py)"]
RAW[(data/solicitudes_financredit.csv)] --> PREP[ColumnTransformer<br/>imputación + escalado + OHE]
PREP --> CVX{CV estratificada k=5}
CVX --> LOG[Regresión logística]
CVX --> GBM[HistGradientBoosting]
LOG & GBM --> DEC[docs/decision_modelo.md<br/>recomendación argumentada]
DEC --> ART[models/model_vX.Y.joblib<br/>+ metadata.json<br/>+ train_stats.json para PSI]
end
subgraph online["ONLINE — Servicio (docker compose)"]
CLI[Cliente HTTP<br/>web / analistas] -->|POST /predict|API[FastAPI<br/>validación Pydantic estricta]
API -->|hash del payload|REDIS[(Redis<br/>caché TTL 1h)]
REDIS -->|hit|API
API -->|miss|MODEL[Pipeline joblib<br/>en memoria]
MODEL --> DECIDE[Umbrales de negocio<br/>aprobar / revisar / denegar]
DECIDE --> EXPL[Top-3 factores<br/>explicabilidad]
API -->|async, best effort|AUDIT[(SQLite<br/>audit/predicciones.db)]
CLI -->|GET /health|API
CLI -->|GET /model-info|API
end
subgraph monitor["MONITORIZACIÓN"]
ART -.->|train_stats.json|PSI[src/monitor.py<br/>PSI por feature]
AUDIT -.->|últimas N peticiones|PSI
PSI --> ALERT[Tabla de alertas<br/>PSI > 0.2 ]
end
ART ==>|se monta en el contenedor|MODEL
Flujo de una petición a /predict, paso a paso:
sequenceDiagram
participant C as Cliente
participant A as FastAPI
participant R as Redis
participant M as Modelo (joblib)
participant S as SQLite auditoría
C->>A: POST /predict {solicitud}
A->>A: Validación Pydantic (rangos, Literal, extra=forbid)
A->>A: Recalcular features derivadas (ratio_cuota_ingresos)
A->>R: GET hash(payload)
alt cache hit
R-->>A: respuesta cacheada
else cache miss
A->>M: predict_proba(features)
M-->>A: p_default
A->>A: aplicar umbrales -> decisión + top-3 factores
A->>R: SETEX hash, 3600, respuesta
end
A->>S: INSERT (timestamp, payload, prob, decisión, versión, latencia)
A-->>C: 200 {probabilidad, decision, factores, model_version}
3.5 Estructura de carpetas del repositorio¶
financredit-scoring/
├── index.md # plantilla en 3.9
├── requirements.txt # dependencias con versiones fijadas
├── .gitignore # data/, models/, audit/, __pycache__/...
├── Dockerfile # imagen de la API
├── docker-compose.yml # api + redis
├── config.yaml # umbrales de decisión, rutas, TTL caché
│
├── data/ # (gitignored) CSV generado
├── models/ # (gitignored) joblib + metadata + stats
├── audit/ # (gitignored) predicciones.db
│
├── scripts/
│ └── generar_datos.py # generador sintético (3.2)
│
├── src/
│ ├── __init__.py
│ ├── config.py # carga y valida config.yaml
│ ├── features.py # feature engineering compartido train/serve
│ ├── train.py # entrenamiento, comparación, artefactos
│ ├── schemas.py # modelos Pydantic (entrada/salida)
│ ├── predict.py # carga del modelo + lógica de scoring
│ ├── audit.py # registro en SQLite
│ ├── cache.py # cliente Redis best-effort
│ ├── monitor.py # cálculo de PSI (3.8)
│ └── api.py # aplicación FastAPI
│
├── tests/
│ ├── conftest.py # fixtures: app de test, modelo dummy
│ ├── test_api.py # tests de endpoints
│ ├── test_features.py # consistencia train/serve
│ └── test_monitor.py # PSI con casos conocidos
│
└── docs/
├── decision_modelo.md # RS-2: logística vs boosting
└── informe_modelo.md # métricas, importancias, limitaciones
Consejo profesional
que src/features.py sea el único lugar donde se derivan features es la decisión de arquitectura más importante del proyecto. El bug clásico de producción es el training/serving skew: la feature se calcula de una forma al entrenar y de otra (sutilmente distinta) al servir. Un módulo compartido + un test que lo verifica lo elimina de raíz.
3.6 Plan de trabajo por fases¶
| Fase | Duración | Qué haces | Entregable verificable |
|---|---|---|---|
| F0 — Setup | 0.5 h | Repo, venv, requirements.txt, .gitignore, generar datos |
python scripts/generar_datos.py funciona; primer commit |
| F1 — EDA rápido + features | 1.5-2 h | EDA dirigido (nulos, distribución del target, rangos para Pydantic), src/features.py |
Documento breve de EDA; rangos de validación decididos con datos |
| F2 — Entrenamiento | 2-3 h | src/train.py: pipeline, CV, comparación logística vs boosting, artefactos (joblib + metadata.json + train_stats.json) |
python -m src.train deja los 3 artefactos en models/ |
| F3 — Decisión de modelo | 1 h | docs/decision_modelo.md + permutation importance en docs/informe_modelo.md |
Recomendación argumentada con números |
| F4 — API | 3-4 h | schemas.py, predict.py, api.py: los 3 endpoints con validación estricta |
uvicorn src.api:app responde; /docs muestra el esquema |
| F5 — Auditoría + caché | 2 h | audit.py (SQLite) y cache.py (Redis best-effort) integrados en /predict |
Cada petición inserta fila en la BD; con Redis caído la API sigue viva |
| F6 — Tests | 2-3 h | pytest + TestClient: los 5+ tests de 3.7.7 |
pytest -v en verde, sin Docker |
| F7 — Docker | 1.5-2 h | Dockerfile + docker-compose.yml |
docker compose up → curl /health OK desde fuera |
| F8 — Monitorización | 1.5-2 h | src/monitor.py con PSI + test con drift simulado |
python -m src.monitor imprime tabla con alertas |
| F9 — README y pulido | 1-1.5 h | README plantilla 3.9, revisión con criterios 3.10 | Un compañero levanta el proyecto solo con el README |
Advertencia
respeta el orden F2 → F4. Un error común es empezar por la API "que es lo divertido" y acabar con un modelo metido con calzador. La API sirve al modelo, no al revés.
3.7 Esqueleto de cada archivo¶
A continuación, el esqueleto de los archivos clave con TODOs. Es tu mapa; el territorio lo recorres tú.
3.7.1 src/features.py — feature engineering compartido¶
"""Feature engineering ÚNICO para entrenamiento y servicio (evita skew)."""
import pandas as pd
TASA_ANUAL = 0.09 # TAE aproximada usada para la cuota
# Columnas que el modelo espera, EN ORDEN. Única fuente de verdad.
FEATURES_NUMERICAS = [
"edad", "ingresos_mensuales", "antiguedad_empleo_anios",
"num_creditos_activos", "impagos_previos", "ratio_endeudamiento",
"anios_historial_crediticio", "importe_solicitado", "plazo_meses",
"ratio_cuota_ingresos",
]
FEATURES_CATEGORICAS = ["situacion_laboral", "vivienda", "finalidad"]
def calcular_cuota(importe: float, plazo_meses: int) -> float:
"""Cuota mensual con interés simple (misma fórmula que el generador)."""
# TODO: return round(importe * (1 + TASA_ANUAL * plazo_meses / 12) / plazo_meses, 2)
...
def construir_features(df: pd.DataFrame) -> pd.DataFrame:
"""Deriva features a partir de columnas crudas. Se usa en train Y en serve.
- Recalcula SIEMPRE ratio_cuota_ingresos (ignora el valor entrante si existe).
- Devuelve el DataFrame con FEATURES_NUMERICAS + FEATURES_CATEGORICAS.
"""
# TODO: implementa y devuelve df[FEATURES_NUMERICAS + FEATURES_CATEGORICAS]
...
3.7.2 src/train.py — entrenamiento y artefactos¶
"""Entrena, compara logística vs boosting y serializa artefactos versionados."""
import hashlib, json
from datetime import datetime, timezone
from pathlib import Path
import joblib
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, cross_validate, train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
from sklearn.inspection import permutation_importance
from src.features import construir_features, FEATURES_NUMERICAS, FEATURES_CATEGORICAS
MODEL_VERSION = "1.0.0" # súbela en cada reentrenamiento (semver)
def main() -> None:
df = pd.read_csv("data/solicitudes_financredit.csv")
# TODO 1: X = construir_features(df); y = df["default"]
# TODO 2: split 80/20 estratificado (test para métricas de metadata y PSI-baseline)
# TODO 3: preproc = ColumnTransformer(imputer mediana+scaler para numéricas,
# imputer moda+OHE(handle_unknown="ignore") para categóricas)
# TODO 4: CV k=5 de los dos candidatos con scoring
# ["roc_auc", "average_precision", "recall", "brier_score_loss"*(-1)]
# => imprime tabla comparativa (te hará falta en docs/decision_modelo.md)
# TODO 5: entrena el modelo ELEGIDO en todo el train; evalúa UNA vez en test
# TODO 6: permutation_importance en test => top factores para el informe
# TODO 7: guarda artefactos:
# - models/model.joblib (pipeline completo)
# - models/metadata.json (versión, fecha UTC, métricas test,
# hash SHA256 del CSV, listas de features, tipo de modelo)
# - models/train_stats.json (para el PSI: por cada feature numérica,
# los bordes de 10 bins de train y la proporción de train en cada bin;
# por cada categórica, la distribución de frecuencias)
...
if __name__ == "__main__":
main()
Nota
train_stats.json es la pieza que la gente olvida. El PSI de la fase F8 necesita la distribución de entrenamiento como referencia; si no la guardas al entrenar, luego no puedes monitorizar. En producción esto se llama "guardar el perfil de referencia del modelo".
3.7.3 src/schemas.py — contratos Pydantic estrictos¶
"""Contratos de entrada/salida de la API. La validación ES la primera defensa."""
from typing import Literal
from pydantic import BaseModel, Field, ConfigDict
class SolicitudPrestamo(BaseModel):
"""Payload de /predict. Estricto: campos extra => 422."""
model_config = ConfigDict(extra="forbid") # rechaza campos no declarados
edad: int = Field(ge=21, le=75) # rango legal/negocio
ingresos_mensuales: float = Field(gt=0, le=50_000)
situacion_laboral: Literal["indefinido", "temporal", "autonomo",
"pensionista", "desempleado"]
antiguedad_empleo_anios: float = Field(ge=0, le=60)
vivienda: Literal["propiedad", "hipoteca", "alquiler", "familiar"]
num_creditos_activos: int = Field(ge=0, le=20)
impagos_previos: int = Field(ge=0, le=20)
ratio_endeudamiento: float = Field(ge=0, le=1)
anios_historial_crediticio: float | None = Field(default=None, ge=0, le=60)
importe_solicitado: float = Field(ge=1_000, le=30_000)
plazo_meses: Literal[12, 24, 36, 48, 60]
finalidad: Literal["consumo", "vehiculo", "reforma", "consolidacion", "otros"]
# OJO: ratio_cuota_ingresos NO se acepta del cliente: se calcula en servidor.
# TODO: @model_validator que rechace combinaciones absurdas, p. ej.
# desempleado con antiguedad_empleo_anios > 0.
class RespuestaScoring(BaseModel):
probabilidad_default: float # 0-1, redondeada a 4 decimales
decision: Literal["aprobar", "revisar", "denegar"]
factores_principales: list[str] # top-3 explicabilidad (RS-5)
model_version: str
cached: bool = False # ¿vino de Redis?
class ModelInfo(BaseModel):
# TODO: version, fecha_entrenamiento, tipo_modelo, metricas (dict),
# features_esperadas (list[str])
...
3.7.4 src/predict.py, src/audit.py, src/cache.py¶
# ----------------------------- src/predict.py --------------------------------
"""Carga del modelo y lógica de scoring + explicación local sencilla."""
import joblib, json
import pandas as pd
from src.features import construir_features
class Scorer:
def __init__(self, model_path: str, metadata_path: str):
self.pipeline = joblib.load(model_path) # pipeline completo
self.metadata = json.load(open(metadata_path)) # versión, métricas...
def score(self, payload: dict) -> tuple[float, list[str]]:
# TODO 1: df = pd.DataFrame([payload]); X = construir_features(df)
# TODO 2: prob = float(self.pipeline.predict_proba(X)[0, 1])
# TODO 3: factores = self._top_factores(payload) -> top-3 strings
# Estrategia simple y defendible: compara cada feature del solicitante
# con los percentiles de train (de train_stats.json) y reporta las 3
# desviaciones más asociadas a riesgo según el ranking global de
# permutation importance. (SHAP es opcional/bonus, no requisito.)
...
def decidir(prob: float, umbrales: dict) -> str:
"""Traduce probabilidad a decisión según config.yaml (NO hardcodear)."""
# TODO: aprobar / revisar / denegar
...
# ----------------------------- src/audit.py ----------------------------------
"""Auditoría en SQLite: cada predicción queda registrada (RS-6)."""
import json, sqlite3
from datetime import datetime, timezone
DDL = """CREATE TABLE IF NOT EXISTS predicciones (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts_utc TEXT NOT NULL, -- timestamp ISO-8601 en UTC
payload_json TEXT NOT NULL, -- entrada completa (reconstruible)
probabilidad REAL NOT NULL,
decision TEXT NOT NULL,
model_version TEXT NOT NULL,
latencia_ms REAL NOT NULL
);"""
def registrar(db_path: str, payload: dict, prob: float,
decision: str, version: str, latencia_ms: float) -> None:
# TODO: INSERT con try/except amplio: si falla, log.error y NO relanzar
# (la auditoría no debe tumbar la respuesta al cliente — pero deja
# constancia del fallo: un sistema regulado sin auditoría es incidente).
...
# ----------------------------- src/cache.py ----------------------------------
"""Caché Redis best-effort: si Redis no está, el servicio sigue (RS-7)."""
import hashlib, json
def clave_cache(payload: dict) -> str:
"""Hash estable del payload (ordenar claves antes de serializar)."""
# TODO: sha256 de json.dumps(payload, sort_keys=True)
...
class Cache:
def __init__(self, url: str, ttl: int = 3600):
# TODO: intenta conectar (redis.from_url, socket_timeout corto);
# si falla => self.cliente = None (modo degradado)
...
def get(self, k: str) -> dict | None: ... # TODO: None si no hay cliente/hit
def set(self, k: str, valor: dict) -> None: ... # TODO: SETEX, ignora errores
3.7.5 src/api.py — la aplicación FastAPI¶
"""API de scoring de FinanCredit (RS-4, RS-5, RS-6, RS-7)."""
import time
from fastapi import FastAPI, HTTPException
from src.schemas import SolicitudPrestamo, RespuestaScoring, ModelInfo
from src.predict import Scorer, decidir
from src.audit import registrar
from src.cache import Cache, clave_cache
from src.config import CONFIG # umbrales, rutas, TTL
app = FastAPI(title="FinanCredit Scoring API", version="1.0.0")
# Carga en el arranque (lifespan/startup): modelo, caché, BD de auditoría.
# TODO: usa el patrón lifespan de FastAPI; guarda scorer/cache en app.state.
@app.get("/health")
def health():
# TODO: {"status": "ok", "model_loaded": bool, "cache": "up"|"degraded"}
...
@app.get("/model-info", response_model=ModelInfo)
def model_info():
# TODO: devolver metadata.json del modelo cargado (versión, métricas...)
...
@app.post("/predict", response_model=RespuestaScoring)
def predict(solicitud: SolicitudPrestamo):
t0 = time.perf_counter() # para la latencia auditada
payload = solicitud.model_dump()
# TODO 1: k = clave_cache(payload); hit = cache.get(k) -> return con cached=True
# TODO 2: prob, factores = scorer.score(payload)
# TODO 3: decision = decidir(prob, CONFIG["umbrales"])
# TODO 4: cache.set(k, respuesta)
# TODO 5: registrar(...) SIEMPRE (también en cache hit: la auditoría
# registra peticiones, no cómputos)
...
3.7.6 Dockerfile y docker-compose.yml¶
# Dockerfile — imagen de la API de scoring
FROM python:3.12-slim AS base # imagen ligera y actual
WORKDIR /app # directorio de trabajo
# Instalamos dependencias ANTES de copiar el código:
# así Docker cachea esta capa y los rebuilds son rápidos.
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ src/ # código de la aplicación
COPY config.yaml . # configuración
COPY models/ models/ # artefactos del modelo
# (Alternativa más pura: montar models/ como volumen en compose.)
RUN useradd --create-home appuser && chown -R appuser /app
USER appuser # nunca root en producción
EXPOSE 8000
CMD ["uvicorn", "src.api:app", "--host", "0.0.0.0", "--port", "8000"]
# docker-compose.yml — API + Redis
services:
api:
build: . # usa el Dockerfile de arriba
ports:
- "8000:8000" # host:contenedor
environment:
REDIS_URL: redis://redis:6379/0 # DNS interno de compose
volumes:
- ./audit:/app/audit # la BD de auditoría persiste en el host
depends_on:
- redis # arranca Redis primero (no espera "ready")
redis:
image: redis:7-alpine # caché ligera
# Sin puerto publicado: solo la API necesita hablar con Redis.
3.7.7 tests/ — los 5 tests mínimos (con pytest + TestClient)¶
| # | Test | Qué verifica | Pista de implementación |
|---|---|---|---|
| 1 | test_health_ok |
/health devuelve 200 y model_loaded: true |
TestClient(app); fixture que entrena/carga un modelo pequeño |
| 2 | test_predict_solicitud_valida |
Payload válido → 200, probabilidad ∈ [0,1], decisión ∈ {aprobar, revisar, denegar}, model_version presente |
Payload de ejemplo en conftest.py como fixture |
| 3 | test_predict_validacion_estricta |
(a) edad=17 → 422; (b) finalidad="vacaciones" → 422; (c) campo extra genero="F" → 422 (crítico: regulatorio) |
Parametriza con @pytest.mark.parametrize los 3 casos |
| 4 | test_prediccion_queda_auditada |
Tras un POST exitoso, existe una fila nueva en SQLite con el payload y la versión correctos | Fixture con BD temporal (tmp_path); cuenta filas antes/después |
| 5 | test_consistencia_train_serve |
construir_features sobre una fila cruda produce exactamente las columnas (nombre y orden) que espera el pipeline entrenado |
Compara con FEATURES_NUMERICAS + FEATURES_CATEGORICAS |
| 6* | test_api_sin_redis (bonus) |
Con Redis inaccesible, /predict sigue devolviendo 200 y cached=False |
Monkeypatch de Cache para que get/set fallen silenciosamente |
| 7* | test_psi_detecta_drift (bonus) |
El PSI ≈ 0 con la misma distribución y > 0.2 con una desplazada | Genera dos muestras normales, una desplazada +2σ |
# tests/conftest.py — esqueleto de fixtures
import pytest
from fastapi.testclient import TestClient
@pytest.fixture(scope="session")
def modelo_entrenado(tmp_path_factory):
"""Entrena un modelo mínimo sobre una muestra pequeña para los tests."""
# TODO: genera 200 filas con el generador (semilla fija), entrena rápido
# y guarda artefactos en un tmp_path de sesión.
...
@pytest.fixture()
def client(modelo_entrenado, tmp_path, monkeypatch):
"""TestClient con rutas de modelo/BD apuntando a temporales."""
# TODO: monkeypatch de CONFIG para usar tmp_path; yield TestClient(app)
...
@pytest.fixture()
def solicitud_valida() -> dict:
return {
"edad": 38, "ingresos_mensuales": 2400.0,
"situacion_laboral": "indefinido", "antiguedad_empleo_anios": 6.5,
"vivienda": "hipoteca", "num_creditos_activos": 1,
"impagos_previos": 0, "ratio_endeudamiento": 0.22,
"anios_historial_crediticio": 12.0, "importe_solicitado": 9000,
"plazo_meses": 36, "finalidad": "vehiculo",
}
3.8 Monitorización: PSI¶
El Population Stability Index mide cuánto ha cambiado la distribución de una variable entre dos poblaciones (aquí: datos de entrenamiento vs peticiones recientes de la API). Es el estándar de facto en riesgo de crédito precisamente por su simplicidad auditable.
Fórmula, para una variable dividida en $B$ bins:
$$PSI = \sum_{b=1}^{B} (p_b^{actual} - p_b^{esperado}) \cdot \ln\frac{p_b^{actual}}{p_b^{esperado}}$$
| PSI | Lectura | Acción |
|---|---|---|
| < 0.10 | Sin cambio relevante | Nada |
| 0.10 – 0.20 | Cambio moderado | Vigilar; investigar la causa |
| > 0.20 | Cambio significativo | Alerta: evaluar reentrenamiento |
Esqueleto de src/monitor.py:
"""Cálculo de PSI entre train (train_stats.json) y peticiones recientes (SQLite)."""
import json, sqlite3
import numpy as np
import pandas as pd
EPS = 1e-4 # evita log(0) y divisiones por cero en bins vacíos
def psi_numerico(esperado_prop: np.ndarray, actual: np.ndarray,
bordes: np.ndarray) -> float:
"""PSI de una variable numérica.
esperado_prop: proporción de TRAIN en cada bin (de train_stats.json).
actual: valores crudos de las peticiones recientes.
bordes: bordes de bins calculados EN TRAIN (deciles) — nunca
recalcules los bins con los datos actuales: el PSI dejaría
de ser comparable entre ejecuciones.
"""
# TODO 1: cuenta 'actual' en los bins (np.histogram con bordes de train;
# extiende el primer/último borde a ±inf para capturar outliers).
# TODO 2: convierte a proporciones y aplica clip(EPS) a ambas.
# TODO 3: return float(np.sum((act - esp) * np.log(act / esp)))
...
def psi_categorico(esperado_freq: dict, actual: pd.Series) -> float:
"""PSI para categóricas: bins = categorías (con EPS para las no vistas)."""
# TODO: alinear categorías de train y actuales, proporciones, misma fórmula.
...
def main(n_recientes: int = 200) -> None:
stats = json.load(open("models/train_stats.json")) # referencia de train
con = sqlite3.connect("audit/predicciones.db") # peticiones reales
filas = pd.read_sql(
"SELECT payload_json FROM predicciones ORDER BY id DESC LIMIT ?",
con, params=(n_recientes,))
recientes = pd.DataFrame([json.loads(p) for p in filas["payload_json"]])
# TODO: para cada feature en stats, calcula su PSI y construye una tabla:
# feature | PSI | estado (OK / VIGILAR / ⚠ ALERTA)
# Imprime la tabla ordenada por PSI descendente y termina con exit code 1
# si alguna feature supera 0.2 (así se integra en cron/CI).
...
if __name__ == "__main__":
main()
Consejo profesional
para la demo del proyecto, escribe un pequeño script que bombardee la API con solicitudes sesgadas (p. ej., todo el mundo con ratio_endeudamiento alto) y enseña cómo src.monitor pasa de "todo OK" a " ALERTA" en dos features. Esa demo de 60 segundos vale más en una entrevista que cualquier explicación teórica del PSI.
3.9 Plantilla de README profesional¶
Tu index.md debe seguir esta plantilla (rellena los huecos, elimina lo que no aplique):
# FinanCredit — Servicio de scoring de riesgo crediticio
Servicio de ML en producción que estima la probabilidad de impago de
solicitudes de préstamo personal y sugiere una decisión (aprobar /
revisar / denegar), con auditoría completa, explicabilidad y
monitorización de drift.
  
## ✅ Problema de negocio
<2-4 frases: quién lo usa, qué decisión soporta, qué restricción
regulatoria condiciona el diseño.>
## Arquitectura
<diagrama Mermaid o imagen + 3-5 líneas explicando el flujo>
## Quickstart (< 5 minutos)
```bash
git clone <repo> && cd financredit-scoring
python scripts/generar_datos.py # 1. datos sintéticos
python -m src.train # 2. entrenar y crear artefactos
docker compose up --build # 3. levantar API + Redis
curl -X POST localhost:8000/predict -H "Content-Type: application/json" \
-d @docs/ejemplo_solicitud.json # 4. primera predicción
```
## API
| Método | Ruta | Descripción |
|--------|------|-------------|
| POST | `/predict` | Scoring de una solicitud (validación estricta) |
| GET | `/health` | Liveness + estado del modelo y la caché |
| GET | `/model-info` | Versión, fecha, métricas y features del modelo |
<ejemplo de request y de response en JSON>
## Modelo
- Modelo elegido: <logística | boosting> — ver [decisión argumentada](docs/decision_modelo.md)
- Métricas en test (n=600): AUC-ROC <x.xx> · AP <x.xx> · Brier <x.xx>
- Top factores (permutation importance): <lista>
- Limitaciones conocidas: <honestidad aquí = puntos en la entrevista>
## Auditoría y monitorización
- Toda predicción se registra en SQLite (`audit/predicciones.db`).
- Drift: `python -m src.monitor` calcula el PSI por feature vs train.
## Tests
```bash
pytest -v # <N> tests, sin necesidad de Docker/Redis
```
## Estructura del repo
<árbol comentado>
## ⚠ Descargo
Datos 100 % sintéticos. Proyecto educativo de AI Master Academy; no es
un sistema real de decisión crediticia.
3.10 Criterios de aceptación exhaustivos¶
Reproducibilidad y datos
- [ ] python scripts/generar_datos.py && python -m src.train funciona en un clon limpio y deja model.joblib, metadata.json y train_stats.json en models/.
- [ ] Todas las semillas fijadas; dos ejecuciones dan las mismas métricas.
- [ ] data/, models/ y audit/ están en .gitignore (los artefactos no se versionan; el código para regenerarlos, sí).
Modelado y explicabilidad
- [ ] La comparación logística vs boosting usa la misma CV estratificada y reporta media ± std de al menos 3 métricas.
- [ ] docs/decision_modelo.md recomienda un modelo citando números y el requisito regulatorio; discute el trade-off en ≥ 10 líneas.
- [ ] Permutation importance calculada en test e interpretada en docs/informe_modelo.md.
- [ ] El test set se usa exactamente una vez para las métricas de metadata.json.
API
- [ ] /predict valida rangos y categorías; un campo extra en el payload devuelve 422 (probado con test).
- [ ] ratio_cuota_ingresos se recalcula en servidor y no puede inyectarse desde el cliente.
- [ ] /predict responde probabilidad, decisión en 3 tramos (umbrales leídos de config.yaml) y top-3 factores.
- [ ] /health y /model-info responden lo especificado; /docs (OpenAPI) es coherente.
Auditoría, caché e infra
- [ ] Cada llamada a /predict (incluidos los cache hits) inserta una fila en SQLite con timestamp UTC, payload, probabilidad, decisión, versión y latencia.
- [ ] Si SQLite falla, la API responde igualmente y el error queda logueado.
- [ ] La segunda petición idéntica responde desde Redis (cached: true) y más rápido.
- [ ] Con el contenedor de Redis parado (docker compose stop redis), /predict sigue devolviendo 200.
- [ ] docker compose up --build levanta todo; el contenedor corre como usuario no-root.
Calidad y monitorización
- [ ] pytest -v pasa con ≥ 5 tests, sin Docker ni Redis en marcha.
- [ ] python -m src.monitor imprime la tabla de PSI y devuelve exit code ≠ 0 si hay alerta.
- [ ] Demostración de drift: existe (script o instrucciones) una forma de provocar una alerta de PSI y está documentada en el README o en docs/.
- [ ] El README sigue la plantilla y el quickstart funciona tal cual está escrito.
3.11 Rúbrica de autoevaluación¶
| Dimensión | 1 (insuficiente) | 3 (correcto) | 5 (profesional) |
|---|---|---|---|
| Ingeniería del modelo | Modelo entrenado en notebook, sin artefactos versionados | src.train reproducible con joblib + metadata |
Además: train_stats.json para monitorización, hash del dataset, semver del modelo, features como única fuente de verdad |
| Decisión y explicabilidad | "Elegí boosting porque tiene mejor AUC" | Comparación con números y mención al requisito regulatorio | Trade-off cuantificado (¿cuántos puntos de AUC "compra" la explicabilidad?), permutation importance interpretada en lenguaje de riesgos, top-3 factores por predicción |
| API y validación | Endpoints que aceptan cualquier cosa | Pydantic con rangos y Literal; 3 endpoints completos | extra="forbid", validadores cruzados, features derivadas solo en servidor, errores 422 informativos |
| Fiabilidad (auditoría/caché) | Sin auditoría o auditoría que puede tumbar la API | SQLite funcionando; Redis con TTL | Degradación elegante probada con tests (Redis caído, BD que falla), auditoría también en cache hits, latencia registrada |
| Testing | 0-2 tests triviales | Los 5 tests mínimos en verde | 7+ tests incluyendo degradación y drift; fixtures limpias; CI-ready (exit codes correctos) |
| DevOps | Dockerfile que no construye o corre como root | Compose con api+redis funcional | Multi-stage o imagen slim, usuario no-root, volumen para auditoría, capas cacheadas correctamente |
| Monitorización | Sin PSI o PSI incorrecto (bins recalculados) | PSI correcto con bins de train y tabla de alertas | Demo de drift reproducible + integración pensada (exit codes para cron/CI) |
| Documentación | README de 5 líneas | Plantilla completa y quickstart real | Un desconocido lo levanta en < 5 min; decisión de modelo digna de un comité de riesgos; limitaciones honestas |
3.12 Cómo presentar este proyecto en una entrevista¶
Este proyecto está diseñado para sostener 30-45 minutos de conversación técnica. Guion recomendado: 60 segundos de elevator pitch (problema → restricción regulatoria → arquitectura → resultado) y después dejar que pregunten. Preguntas que te harán casi seguro, y cómo responderlas:
| Pregunta probable | Qué están evaluando | Cómo responder bien |
|---|---|---|
| "¿Por qué elegiste [logística/boosting]?" | Si tomas decisiones por criterio o por moda | Da los números de la CV y el argumento regulatorio. Frase ganadora: "El boosting ganaba por 0.0X de AUC, pero cuantifiqué qué me costaba la explicabilidad y decidí que…". Nunca digas "porque es el mejor modelo". |
| "¿Cómo sabes que el modelo sigue funcionando dentro de 6 meses?" | Mentalidad de producción | PSI sobre las peticiones auditadas vs perfil de train, umbral 0.2, exit codes para integrarlo en cron/CI. Bonus: reconoce que el PSI detecta drift de entrada, no de rendimiento — para eso necesitarías etiquetas reales (defaults observados), que llegan con meses de retraso. |
| "¿Qué pasa si te mandan un payload con un campo que no existe?" | Robustez y seguridad | extra="forbid" → 422. Explica por qué importa en regulado: un campo inesperado podría ser una variable prohibida colándose. |
| "¿Cómo garantizas que la feature X se calcula igual en entrenamiento y en producción?" | Conocen el training/serving skew | src/features.py como módulo único compartido + test de consistencia (test 5). Es la respuesta que más sube tu nivel percibido. |
| "¿Por qué SQLite para auditoría? ¿Escala?" | Honestidad técnica y criterio | "Para este alcance, sí; identifiqué el límite: escrituras concurrentes. El diseño aísla la auditoría en audit.py, así que migrar a Postgres es cambiar un módulo". Reconocer límites con plan de salida > fingir que todo escala. |
| "¿Qué harías con más tiempo?" | Visión | Ten 3 preparadas y priorizadas: p. ej. (1) calibración de probabilidades (curva de calibración + CalibratedClassifierCV — en crédito las probabilidades SON el producto), (2) SHAP para explicaciones locales más finas, (3) reentrenamiento automatizado con validación de campeón vs retador. |
| "¿El modelo es justo? ¿Cómo lo sabrías?" | Sensibilidad ética/regulatoria | El dataset no tiene variables protegidas, pero eso no garantiza equidad (proxies). Explica que en un caso real harías análisis de disparidad por grupos con datos de validación etiquetados. No exageres: di lo que hiciste y lo que haría falta. |
| "Demuéstralo." | Que sea real | Ten la demo engrasada: docker compose up, un curl de aprobación, uno de denegación con sus 3 factores, la fila en SQLite, y el monitor detectando drift. 3 minutos, ensayados. |
Consejo profesional
sube el repo a GitHub con un buen README antes de ponerlo en el CV, y ensaya la demo en una máquina que no sea la tuya (o en un clon limpio). "En mi máquina funcionaba" en mitad de una entrevista es una herida que no cierra.
3.13 Errores típicos¶
Advertencia
esta lista sale de revisar decenas de proyectos de alumnos. Repásala al terminar cada fase.
- Fuga de datos en el preprocesado — ajustar imputadores/escaladores sobre todo el dataset antes del split. Síntoma: métricas de CV sospechosamente estables. Solución: todo dentro del
Pipeline, siempre. - Training/serving skew — recalcular
ratio_cuota_ingresoscon otra fórmula (o no recalcularlo) en la API. Por eso existefeatures.pyy el test 5. - Umbrales hardcodeados —
if prob > 0.3:esparcido por el código. Los umbrales de decisión son política de negocio: viven enconfig.yamly cambian sin tocar código. - Auditoría que tumba la API — un
INSERTque lanza excepción y convierte una predicción válida en un 500. La auditoría es crítica, pero la degradación debe ser controlada y logueada, no un crash. - Caché sin versión de modelo — cachear respuestas y reentrenar el modelo: Redis sigue sirviendo predicciones del modelo viejo durante el TTL. Incluye
model_versionen la clave de caché. - PSI con bins recalculados — calcular los deciles con los datos actuales en cada ejecución. El PSI pierde su referencia y nunca alerta. Los bordes de bins se fijan en entrenamiento y se guardan en
train_stats.json. - Tests que dependen de Docker/Redis/archivos globales —
pytestdebe correr aislado con fixtures temporales. Si tus tests necesitandocker compose up, no son tests unitarios ni de API: son una demo frágil. models/versionado en Git — binarios de 50 MB en el historial. Se versiona el código que genera el artefacto y su metadata, no el binario (en un equipo real: registry de modelos o almacenamiento de artefactos).- Validación Pydantic decorativa — declarar el schema pero con
extrapermisivo, o aceptarratio_cuota_ingresosdel cliente "porque ya viene calculado". En un sistema regulado la frontera de entrada es parte del control. - Métricas sin contexto — "AUC 0.87" en el README sin baseline, sin ±std, sin tamaño del test set. Toda métrica se reporta con su incertidumbre y su punto de comparación.
- README aspiracional — documentar features que no existen ("incluye reentrenamiento automático") o un quickstart que no se ha probado en limpio. En una revisión técnica esto destruye la confianza en todo lo demás.
- Ignorar la clase minoritaria en la evaluación — con 14 % de defaults, accuracy del 86 % se consigue prediciendo "todo el mundo paga". Si tu tabla comparativa no incluye AP/recall, no has evaluado el problema real.
4. Qué has demostrado: mapa proyecto → habilidad de empleo¶
Esta tabla es, literalmente, munición para tu CV y tu perfil de LinkedIn. Cada fila es una frase que ahora puedes defender en una entrevista con código en la mano:
| Qué has hecho | Proyecto | Habilidad de empleo demostrada |
|---|---|---|
| EDA con decisiones de imputación justificadas y flags de ausencia | A | Análisis de datos orientado a decisiones, no a gráficos |
| Baseline + comparación de 3 modelos con CV estratificada y ±std | A | Rigor experimental; sabes cuándo una mejora es real |
| Elección de métrica a partir de costes (40 €/600 €) y umbral por beneficio | A | Traducción negocio ↔ ML: la habilidad más escasa del mercado |
| Informe en Markdown para un director no técnico | A | Comunicación con stakeholders; los modelos se venden en euros |
Pipeline ColumnTransformer serializado con metadata versionada |
B | MLOps básico: artefactos reproducibles y trazables |
| Recomendación logística vs boosting bajo restricción regulatoria | B | Criterio técnico bajo restricciones reales (AI Act, explicabilidad) |
| API FastAPI con validación Pydantic estricta y features en servidor | B | Ingeniería de software aplicada a ML; defensa del perímetro de datos |
| Auditoría de predicciones en SQLite con degradación controlada | B | Trazabilidad y cumplimiento; diseño tolerante a fallos |
| Docker + compose con Redis best-effort | B | Contenedores y dependencias degradables: mentalidad de servicio |
| Suite pytest con TestClient, fixtures y tests de degradación | B | Testing de sistemas de ML, no solo de funciones |
| Monitorización de drift con PSI y perfil de referencia | B | Operación de modelos post-despliegue: lo que separa junior de mid |
| README con quickstart verificado y limitaciones honestas | B | Documentación profesional; onboarding de compañeros |
Consejo profesional
en el CV, no escribas "hice un proyecto de churn". Escribe resultados: "Diseñé un sistema de scoring de crédito end-to-end (sklearn, FastAPI, Docker, Redis) con auditoría de predicciones y monitorización de drift por PSI; validación con pytest y decisión de modelo documentada bajo criterios de explicabilidad regulatoria". Misma verdad, otro impacto.
5. Conexión con lo que viene¶
→ Módulo 03 (Deep Learning). Todo lo que has construido aquí es transferible: en el módulo 03 cambiará el modelo (redes neuronales con PyTorch), pero no cambiará el método. Seguirás necesitando baselines (¡tu primer reflejo ante un problema de deep learning será compararlo contra el boosting de este módulo!), splits sin fugas, métricas justificadas, y el mismo esqueleto de servicio para desplegar. De hecho, una de las lecciones más valiosas que llevas: para datos tabulares como los de NubeCRM y FinanCredit, el boosting sigue siendo hoy el campeón a batir — sabrás reconocer cuándo el deep learning aporta (imágenes, texto, audio, secuencias) y cuándo es matar moscas a cañonazos.
→ Proyecto CRM con IA (módulos posteriores). El predictor de churn de NubeCRM no es un ejercicio desechable: es la primera pieza del gran proyecto integrador de la academia, un CRM potenciado con IA. Más adelante le añadirás: embeddings y LLMs para analizar los tickets de soporte en texto libre (módulo de NLP), un agente que redacta la propuesta de retención personalizada para cada cliente en riesgo (módulo de LLMs y agentes), y el despliegue completo con la arquitectura que acabas de practicar en FinanCredit. Guarda ambos repositorios con cariño: vas a volver a ellos.
Nota
antes de pasar al módulo 03, verifica que puntúas ≥ 4 en todas las dimensiones de las dos rúbricas. Si alguna cojea, es mejor invertir dos horas ahora que arrastrar la carencia a los módulos siguientes, donde todo se apoya en estas bases.
| Anterior | Módulo | Siguiente |
|---|---|---|
| Laboratorios | 02-MACHINE-LEARNING | Módulo 03: Deep Learning |