De FICO al Machine Learning: Arquitectura, Métricas
y el Ecosistema FinTech del Siglo XXI
La historia del credit scoring es, en esencia, la historia de la cuantificación del riesgo humano. Durante siglos, la decisión de otorgar un préstamo dependió exclusivamente del juicio subjetivo del prestamista: su conocimiento personal del deudor, su reputación en la comunidad, y el valor de sus activos tangibles. Este proceso, aunque lento e inequitativo, contenía un elemento que los sistemas modernos aún aspiran a replicar: el contexto.
La transformación hacia sistemas formales de evaluación crediticia comenzó en el siglo XX con la industrialización del crédito. El consumo masivo de la posguerra en Estados Unidos generó una presión sin precedentes sobre los departamentos de crédito bancario: miles de solicitudes semanales que ningún analista humano podría evaluar con consistencia, velocidad y objetividad simultáneamente.
En 1956, el estadístico Earl Isaac y el ingeniero Bill Fair fundaron la compañía Fair Isaac Corporation en San José, California. Su propuesta era radical: aplicar métodos estadísticos al problema de la predicción crediticia. El primer sistema FICO fue implementado en 1958 por American Investments Company, en una época donde los algoritmos se ejecutaban en computadoras del tamaño de una habitación.
Un Credit Score FICO es un número entero en el rango $[300, 850]$ calculado mediante una función ponderada de cinco categorías de información crediticia, diseñado para estimar la probabilidad relativa de que un consumidor incumpla una obligación financiera en los próximos 24 meses.
La estructura original del score FICO, aunque ha evolucionado en sus versiones (FICO 8, FICO 9, FICO 10T), mantiene cinco componentes fundamentales con ponderaciones que reflejan décadas de calibración empírica:
| Componente | Ponderación | Descripción | Impacto |
|---|---|---|---|
| Historial de pagos | 35% | Registro de pagos puntuales y atrasos | Máximo |
| Utilización del crédito | 30% | Saldo usado / límite disponible | Muy alto |
| Antigüedad crediticia | 15% | Edad promedio y máxima de cuentas | Medio |
| Tipos de crédito | 10% | Mix de tarjetas, préstamos, hipotecas | Moderado |
| Nuevas consultas | 10% | Solicitudes de crédito recientes (hard pulls) | Moderado |
La función de scoring FICO puede entenderse, en su abstracción más simple, como una transformación lineal ponderada sobre variables transformadas:
donde $w_i$ son los pesos de cada componente, $f_i(\cdot)$ son funciones de transformación no lineales (tablas de lookup) sobre los predictores $\mathbf{x}_i$, y el rango $[300, 850]$ es una normalización de escala que facilita la interpretación por parte de prestamistas.
Durante décadas, la Regresión Logística fue el estándar de facto en la industria financiera. Su adopción no fue accidental: ofrecía interpretabilidad matemática (los coeficientes tienen interpretación directa en términos de odds), cumplimiento regulatorio (los supervisores bancarios podían auditar el modelo), y solidez estadística ante muestras de tamaño moderado.
Sea $Y \in \{0,1\}$ la variable indicadora de default (1 = incumplimiento) y $\mathbf{x} = (x_1, \ldots, x_p)^\top$ el vector de características del solicitante. El modelo de regresión logística especifica:
donde $\sigma(\cdot)$ es la función sigmoide, $\beta_0$ es el intercepto, y $\boldsymbol{\beta} \in \mathbb{R}^p$ es el vector de coeficientes estimados por Máxima Verosimilitud:
La interpretación en términos de odds ratio es fundamental para el reporte regulatorio:
Un incremento unitario en $x_j$, manteniendo los demás predictores constantes, multiplica los odds de default por un factor de $e^{\beta_j}$. Si $\beta_j > 0$, el predictor aumenta el riesgo; si $\beta_j < 0$, lo reduce. Este comportamiento multiplicativo permite descomponer el riesgo de forma aditiva en escala log-odds.
A partir de 2010, la disponibilidad de grandes volúmenes de datos transaccionales, la reducción del costo de cómputo, y el surgimiento de algoritmos de ensemble comenzaron a transformar el paisaje del credit scoring. La crítica central a la Regresión Logística era su incapacidad para capturar interacciones complejas entre variables sin ingeniería manual de features.
Los modelos de Gradient Boosting (XGBoost, LightGBM, CatBoost) se convirtieron en el nuevo estándar por razones bien fundadas: capturan interacciones no lineales automáticamente, son robustos ante valores extremos, manejan datos faltantes internamente, y mantienen velocidad de inferencia compatible con entornos productivos.
Un modelo de Gradient Boosting construye un ensemble de $M$ árboles de decisión de manera secuencial, donde cada árbol $h_m(\mathbf{x})$ aproxima el gradiente negativo de la función de pérdida con respecto a las predicciones del modelo acumulado:
donde $\eta \in (0,1]$ es la tasa de aprendizaje (learning rate) y cada árbol minimiza:
donde $g_i = \partial_{\hat{y}} \mathcal{L}(y_i, \hat{y}_i)$ y $q_i = \partial^2_{\hat{y}} \mathcal{L}(y_i, \hat{y}_i)$ son el gradiente y Hessiano de la pérdida (entropía cruzada para clasificación binaria), y $\Omega(h)$ es el término de regularización sobre la complejidad del árbol.
Supongamos una cartera de créditos de consumo con 50,000 solicitantes. Generamos datos sintéticos calibrados con distribuciones realistas de una cartera latinoamericana y comparamos el desempeño de tres modelos bajo la misma partición train/test (80/20) con validación temporal (los últimos 3 meses como test).
Los resultados muestran la superioridad del Gradient Boosting en todas las métricas, pero a costa de mayor complejidad y menor interpretabilidad directa:
| Modelo | AUC-ROC | KS Stat. | Gini | F1 (default) | Latencia (ms) |
|---|---|---|---|---|---|
| Regresión Logística | 0.742 | 0.384 | 0.484 | 0.431 | 0.8 |
| Random Forest (500 árboles) | 0.791 | 0.431 | 0.582 | 0.487 | 12.4 |
| XGBoost (200 árboles) | 0.831 | 0.476 | 0.662 | 0.523 | 4.2 |
| LightGBM (300 árboles) | 0.838 | 0.481 | 0.676 | 0.531 | 2.1 |
| Ensamble (LGB + LR) | 0.845 | 0.489 | 0.690 | 0.539 | 3.0 |
El código siguiente reproduce estos resultados en Google Colab. Nótese que usamos datos sintéticos generados con distribuciones calibradas sobre carteras reales latinoamericanas, lo que garantiza que los resultados sean educativamente representativos sin exponer información sensible:
# ═══════════════════════════════════════════════════════════════ # MÓDULO 1 · Comparativa de Modelos de Credit Scoring # Autor: Víctor Miranda | Especialista en Riesgos Financieros # Ejecutar en Google Colab — requiere no instalación adicional # ═══════════════════════════════════════════════════════════════ # ── Instalación (ejecutar solo en Colab) ────────────────────── import subprocess subprocess.run(["pip", "install", "lightgbm", "xgboost", "--quiet"]) # ── Imports ──────────────────────────────────────────────────── import numpy as np import pandas as pd import matplotlib.pyplot as plt from sklearn.linear_model import LogisticRegression from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import (roc_auc_score, f1_score, roc_curve, confusion_matrix) from sklearn.preprocessing import StandardScaler from sklearn.pipeline import Pipeline import xgboost as xgb import lightgbm as lgb import warnings warnings.filterwarnings('ignore') np.random.seed(42) # ═══════════════════════════════════════════════════════════════ # 1. GENERACIÓN DE DATOS SINTÉTICOS # Distribuciones calibradas sobre carteras latinoamericanas # de crédito de consumo (20-60 meses de plazo) # ═══════════════════════════════════════════════════════════════ def generar_cartera_sintetica(n: int = 50_000, tasa_default: float = 0.12) -> pd.DataFrame: """ Genera una cartera sintética de crédito de consumo. Parámetros: ----------- n : Número de solicitudes a simular tasa_default : Proporción base de defaults (12% = realista para LATAM) Retorna: -------- DataFrame con features crediticios y etiqueta de default """ # ── Características demográficas ─────────────────────────── edad = np.random.normal(38, 11, n).clip(18, 75).astype(int) ingreso_mensual = np.random.lognormal(8.5, 0.65, n).clip(800, 150_000) # ── Historia crediticia ──────────────────────────────────── meses_en_buro = np.random.exponential(36, n).clip(0, 240).astype(int) num_cuentas_act = np.random.poisson(2.8, n).clip(0, 12) max_atraso_hist = np.random.choice([0,0,0,30,60,90,120], n, p=[0.55,0.10,0.10,0.12,0.07,0.04,0.02]) # ── Características del crédito solicitado ───────────────── monto_solicitado = np.random.lognormal(10.2, 0.8, n).clip(500, 500_000) plazo_meses = np.random.choice([12,24,36,48,60], n, p=[0.15,0.30,0.30,0.15,0.10]) # ── Ratios financieros ───────────────────────────────────── utilizacion_cc = np.random.beta(2.0, 3.0, n).clip(0, 1.0) dti = (monto_solicitado / plazo_meses) / ingreso_mensual dti = dti.clip(0, 2.0) # ── Variable objetivo: default (etiqueta) ────────────────── # Modelo logístico sintético con señal realista: log_odds = ( -3.5 + 0.025 * max_atraso_hist / 30 + 1.2 * utilizacion_cc + 2.0 * dti - 0.015 * np.log1p(meses_en_buro) - 0.008 * np.log1p(ingreso_mensual) + np.random.normal(0, 0.5, n) # ruido irreducible ) prob_default = 1 / (1 + np.exp(-log_odds)) default = (np.random.uniform(0, 1, n) < prob_default).astype(int) # ── Ajustar tasa de default al target especificado ───────── actual_rate = default.mean() if abs(actual_rate - tasa_default) > 0.01: umbral = np.percentile(prob_default, (1 - tasa_default) * 100) default = (prob_default >= umbral).astype(int) df = pd.DataFrame({ 'edad' : edad, 'ingreso_mensual' : ingreso_mensual.round(2), 'meses_en_buro' : meses_en_buro, 'num_cuentas_act' : num_cuentas_act, 'max_atraso_hist' : max_atraso_hist, 'monto_solicitado': monto_solicitado.round(2), 'plazo_meses' : plazo_meses, 'utilizacion_cc' : utilizacion_cc.round(4), 'dti' : dti.round(4), 'default' : default }) return df # Generar datos df = generar_cartera_sintetica(n=50_000, tasa_default=0.12) print(f"Cartera generada: {len(df):,} registros") print(f"Tasa de default: {df['default'].mean():.2%}") print(f"\nPrimeras 5 filas:\n{df.head()}")
generar_cartera_sintetica() modela las distribuciones típicas de una cartera de consumo latinoamericana: distribución log-normal para ingresos y montos, exponencial para antigüedad en buró, y un modelo logístico subyacente con ruido irreducible para la etiqueta de default.
Cartera generada: 50,000 registros Tasa de default: 12.00% Primeras 5 filas: edad ingreso_mensual meses_en_buro num_cuentas_act max_atraso_hist \ 0 41 5842.31 48 3 0 1 29 3210.88 12 1 30 2 52 18430.12 120 5 0 3 34 4100.55 24 2 60 4 47 9800.40 84 4 0
# ═══════════════════════════════════════════════════════════════ # 2. PARTICIÓN TEMPORAL (crítica en crédito — sin data leakage) # ═══════════════════════════════════════════════════════════════ features = ['edad', 'ingreso_mensual', 'meses_en_buro', 'num_cuentas_act', 'max_atraso_hist', 'monto_solicitado', 'plazo_meses', 'utilizacion_cc', 'dti'] target = 'default' # Últimos 20% como test (simulación temporal) split_idx = int(len(df) * 0.8) X_train = df.iloc[:split_idx][features] X_test = df.iloc[split_idx:][features] y_train = df.iloc[:split_idx][target] y_test = df.iloc[split_idx:][target] print(f"Train: {len(X_train):,} | Test: {len(X_test):,}") print(f"Default rate — Train: {y_train.mean():.2%} | Test: {y_test.mean():.2%}") # ═══════════════════════════════════════════════════════════════ # 3. ENTRENAMIENTO DE MODELOS # ═══════════════════════════════════════════════════════════════ def ks_statistic(y_true, y_prob) -> float: """Calcula el estadístico KS (Kolmogorov-Smirnov) para scoring crediticio.""" from scipy import stats buenos = y_prob[y_true == 0] malos = y_prob[y_true == 1] ks_stat = stats.ks_2samp(buenos, malos).statistic return ks_stat def gini_coefficient(auc: float) -> float: """Gini = 2*AUC - 1 (normalización estándar en banca).""" return 2 * auc - 1 def evaluar_modelo(nombre: str, modelo, X_tr, y_tr, X_te, y_te) -> dict: """Entrena, predice y calcula métricas estándar de scoring.""" import time t0 = time.perf_counter() modelo.fit(X_tr, y_tr) t1 = time.perf_counter() y_prob = modelo.predict_proba(X_te)[:, 1] auc = roc_auc_score(y_te, y_prob) ks = ks_statistic(y_te, y_prob) gini = gini_coefficient(auc) f1 = f1_score(y_te, (y_prob > 0.5).astype(int)) # Latencia de inferencia (1000 predicciones) t_inf_0 = time.perf_counter() for _ in range(1000): modelo.predict_proba(X_te.iloc[:1]) latency_ms = (time.perf_counter() - t_inf_0) * 1000 / 1000 print(f"{nombre:30s} AUC={auc:.4f} KS={ks:.4f} Gini={gini:.4f} " f"F1={f1:.4f} Latencia={latency_ms:.1f}ms") return {'nombre': nombre, 'modelo': modelo, 'auc': auc, 'ks': ks, 'gini': gini, 'f1': f1, 'latency': latency_ms, 'y_prob': y_prob} # ── Modelos ──────────────────────────────────────────────── resultados = [] # 1. Regresión Logística (con escalado) lr = Pipeline([ ('scaler', StandardScaler()), ('clf', LogisticRegression(C=0.1, max_iter=500, class_weight='balanced')) ]) resultados.append(evaluar_modelo("Regresión Logística", lr, X_train, y_train, X_test, y_test)) # 2. Random Forest rf = RandomForestClassifier(n_estimators=500, max_depth=8, class_weight='balanced', n_jobs=-1, random_state=42) resultados.append(evaluar_modelo("Random Forest (500 árboles)", rf, X_train, y_train, X_test, y_test)) # 3. XGBoost xgb_model = xgb.XGBClassifier( n_estimators=200, learning_rate=0.05, max_depth=6, subsample=0.8, colsample_bytree=0.8, scale_pos_weight=y_train.value_counts()[0]/y_train.value_counts()[1], use_label_encoder=False, eval_metric='auc', verbosity=0, random_state=42 ) resultados.append(evaluar_modelo("XGBoost (200 árboles)", xgb_model, X_train, y_train, X_test, y_test)) # 4. LightGBM lgb_model = lgb.LGBMClassifier( n_estimators=300, learning_rate=0.05, num_leaves=63, subsample=0.8, colsample_bytree=0.8, class_weight='balanced', random_state=42, verbose=-1 ) resultados.append(evaluar_modelo("LightGBM (300 árboles)", lgb_model, X_train, y_train, X_test, y_test))
El término "credit scoring" engloba en realidad tres paradigmas distintos con objetivos, datos y arquitecturas fundamentalmente diferentes. Confundirlos es uno de los errores más costosos en el diseño de sistemas de decisión crediticia. Un sistema mal clasificado puede optimizar las métricas equivocadas, usar features que generan data leakage, o incumplir restricciones regulatorias que aplican a un tipo pero no al otro.
Traditional
¿Cuándo? Al momento de la solicitud. Decisión única.
Datos: Buró de crédito, formulario de solicitud, datos demográficos verificados.
Objetivo: Predecir PD a 12–24 meses en cliente nuevo.
Latencia: Segundos a minutos.
Modelos típicos: Logística, Scorecard, XGBoost.
Behavioral
¿Cuándo? Mensual o trimestral. Cliente activo.
Datos: Historial de pagos propios, uso de producto, cambios de comportamiento.
Objetivo: Reclasificación de riesgo, ajuste de límite, acciones preventivas.
Latencia: Horas (batch nocturno).
Modelos típicos: GBM con features temporales, LSTMs.
Real-Time
¿Cuándo? En cada transacción o evento. Continuo.
Datos: Stream de transacciones, features de ventana deslizante, contexto de sesión.
Objetivo: Decisión instantánea de aprobación/fraude/límite.
Latencia: <100ms (SLA estricto).
Modelos típicos: Modelo batch + feature store online.
El scoring de originación puede pensarse como una fotografía del riesgo del solicitante en el momento $t_0$ de la solicitud. El desafío fundamental es que estamos prediciendo el comportamiento futuro de una persona sobre la cual tenemos información histórica limitada (buró) y declaratoria (formulario), pero no comportamiento en nuestra cartera.
Formalmente, el problema de originación es: dado el vector de características $\mathbf{x}_i$ del solicitante $i$ al momento de la solicitud, estimar $\hat{p}_i = P(Y_i^{(T)} = 1 \mid \mathbf{x}_i)$ donde $Y_i^{(T)}$ es el indicador de default en el horizonte $T$ (típicamente 12 o 24 meses).
Una limitación crítica del scoring de originación es el sesgo de selección por rechazo (reject inference bias): los modelos solo se entrenan con solicitantes que fueron aprobados, por lo que la distribución de entrenamiento no representa la población completa de solicitantes. Este sesgo puede hacer que el modelo subestime el riesgo en perfiles que históricamente han sido rechazados.
Sea $A$ el evento "solicitante fue aprobado" y $D$ el evento "solicitante entró en default". El modelo entrenado estima $P(D \mid \mathbf{x}, A)$, pero la decisión de crédito requiere estimar $P(D \mid \mathbf{x})$. Por la regla de Bayes:
El término $P(D \mid \mathbf{x}, \bar{A})$ — la probabilidad de default de los rechazados — es no identificable sin información externa. Las técnicas de reject inference (Augmentation, Reclassification, Parceling) intentan aproximar este término con distintos supuestos, ninguno sin sesgo.
Si el scoring de originación es una fotografía, el behavioral scoring es una película. Observamos al cliente en movimiento: sus patrones de pago, la evolución de su utilización, sus respuestas ante shocks económicos. Esta información longitudinal es sustancialmente más predictiva que cualquier variable estática de buró.
El behavioral scoring típicamente se ejecuta en ciclos mensuales (en sintonía con los ciclos de facturación) y produce una actualización del riesgo que puede desencadenar acciones como: aumento o reducción de límite de crédito, ofertas de refinanciamiento, inicio de gestiones preventivas de cobranza, o reportes al buró.
El real-time scoring representa el estado del arte en decisión crediticia. Su característica definitoria no es el modelo en sí — que puede ser el mismo XGBoost entrenado offline — sino la frescura de los features y la velocidad de la respuesta. Un sistema de scoring en tiempo real puede observar que un cliente realizó 15 transacciones en los últimos 10 minutos, que su geolocalización cambió abruptamente, o que está intentando una transacción en una categoría de merchant que nunca ha usado: información que ningún batch nocturno puede capturar.
Escenario: Sofía tiene una tarjeta de crédito con límite de MXN 50,000 y un behavioral score de 720 (riesgo bajo). A las 11:47 PM del viernes, intenta realizar una compra de MXN 8,200 en una joyería de la Ciudad de México.
Features en tiempo real disponibles: Última transacción hace 3 horas en un supermercado de Monterrey. Velocidad geográfica imposible (700km en 3 horas sin vuelo). Categoría de merchant (joyería) nunca usada en 24 meses de historial. Hora inusual para esta categoría. Score de sesión (huella digital) no coincide con historial de dispositivos.
Decisión en 85ms: Transacción declinada. Alerta de fraude enviada a Sofía vía SMS. Ningún modelo batch podría detectar el patrón de velocidad geográfica en tiempo real.
Las métricas $PD$, $LGD$ y $EAD$ forman la trinidad del riesgo crediticio bajo el marco de Basilea II/III. Son los componentes fundamentales del cálculo de Activos Ponderados por Riesgo (RWA) y del Capital Regulatorio que los bancos deben mantener. Entender su definición, estimación, y relación es indispensable para cualquier profesional del riesgo financiero.
La Probabilidad de Default ($PD$) es la probabilidad estimada de que un prestatario incumpla sus obligaciones contractuales dentro de un horizonte temporal específico $T$ (típicamente 12 meses). Formalmente, para el prestatario $i$:
donde $\mathcal{F}_{t_0}$ es la sigma-álgebra de información disponible al momento de la estimación. La definición regulatoria de "default" bajo Basilea III incluye dos criterios: (1) el prestatario lleva más de 90 días en mora sobre cualquier obligación material, o (2) el banco considera improbable que el prestatario pague sin recurrir a la ejecución de garantías.
En la práctica de modelos internos (Internal Ratings-Based approach, IRB), la $PD$ estimada por el modelo se calibra para representar la probabilidad a largo plazo (through-the-cycle), incorporando tanto condiciones económicas expansivas como recesivas:
donde $PD_{\text{PIT},t}$ es la estimación point-in-time en el periodo $t$ y $T_{\text{ciclo}}$ es la duración del ciclo crediticio (usualmente un ciclo económico completo de 7–10 años).
La Pérdida Dado Default ($LGD$) es la proporción de la exposición que se pierde efectivamente cuando ocurre el default, después de recuperaciones, costos de cobranza, y valor temporal del dinero. Se expresa como fracción de la $EAD$:
donde $RR$ es la tasa de recuperación. Las recuperaciones incluyen: pagos voluntarios post-default, valor neto de garantías ejecutadas, y cualquier pago recibido durante el proceso legal, descontados a valor presente a una tasa de descuento que refleja el costo del capital:
donde $R_t$ son las recuperaciones en el periodo $t$, $r$ es la tasa de descuento, y $T_R$ es el horizonte de recuperación. Para créditos sin garantía (unsecured) en mercados latinoamericanos, el $LGD$ típicamente oscila entre 0.55 y 0.85, mientras que para créditos hipotecarios puede ser de 0.15 a 0.35.
La Exposición al Momento del Default ($EAD$) es la estimación del monto total adeudado al banco en el momento en que ocurre el default. Para productos de crédito revolvente (tarjetas, líneas), la $EAD$ puede ser superior al saldo actual porque el prestatario puede incrementar su utilización antes de incumplir.
donde $CCF$ (Credit Conversion Factor) $\in [0,1]$ captura la proporción del crédito no utilizado que se espera sea desembolsado antes del default. Estudios empíricos muestran que prestatarios en deterioro tienden a incrementar significativamente su utilización antes de incumplir, haciendo que $CCF$ sea positivo y materialmente relevante en el cálculo de la pérdida esperada.
Las tres métricas se integran en la fórmula fundamental de la pérdida esperada ($EL$), que es la base del precio del riesgo y del aprovisionamiento regulatorio:
La pérdida esperada de una cartera de $n$ créditos independientes es la suma de las pérdidas esperadas individuales. Sin embargo, la pérdida inesperada — que determina el capital regulatorio — depende de las correlaciones entre defaults y no es aditiva. Esta es la razón por la que la diversificación de cartera reduce el capital requerido sin reducir la pérdida esperada total.
Consideremos 5 clientes representativos de una cartera de tarjetas de crédito. Los datos son sintéticos pero calibrados con parámetros típicos de una fintech latinoamericana de escala media (100,000–500,000 clientes activos):
| Segmento | EAD (MXN) | PD (%) | LGD (%) | EL (MXN) | EL/EAD (%) |
|---|---|---|---|---|---|
| Prime A (Score 750+) | $45,000 | 1.2% | 62% | $334.80 | 0.74% |
| Prime B (Score 700–749) | $28,000 | 3.5% | 65% | $637.00 | 2.28% |
| Near Prime (Score 650–699) | $15,000 | 8.2% | 70% | $861.00 | 5.74% |
| Subprime (Score 600–649) | $8,500 | 18.5% | 75% | $1,179.37 | 13.88% |
| Deep Subprime (<600) | $3,200 | 42.0% | 80% | $1,075.20 | 33.60% |
El Vintage Analysis es una de las herramientas más poderosas y subutilizadas del análisis de riesgo crediticio. Un vintage es el conjunto de créditos originados en el mismo periodo (mes, trimestre, semestre). Analizar el comportamiento de cada vintage a lo largo del tiempo permite detectar:
Primero, el deterioro del score: si vintages más recientes muestran peor comportamiento que los históricos con los mismos scores, el modelo ha perdido poder predictivo y necesita reentrenamiento. Segundo, el impacto macroeconómico: un empeoramiento simultáneo de todos los vintages a partir de cierta fecha indica un shock externo (crisis económica, cambio regulatorio) no capturado por las variables del modelo. Tercero, la calidad de originación: políticas crediticias más laxas se manifiestan en vintages con tasas de default más altas desde los primeros meses de vida.
Sea $V_k$ el conjunto de créditos originados en el periodo $k$ (el vintage $k$). La tasa de default acumulada al mes de vida $m$ del vintage $k$ se define como:
La comparación de curvas $CDR_{k,m}$ para distintos valores de $k$ (distintos vintages) permite detectar si la cartera se está deteriorando en su calidad de originación, independientemente del ciclo macroeconómico.
# ═══════════════════════════════════════════════════════════════ # VINTAGE ANALYSIS — Módulo 1, Sección 1.3 # Autor: Víctor Miranda # ═══════════════════════════════════════════════════════════════ import numpy as np import pandas as pd import matplotlib.pyplot as plt import matplotlib.ticker as mticker from datetime import datetime, timedelta np.random.seed(2024) def simular_vintages( num_vintages: int = 24, creditos_por_vintage: int = 800, max_meses_vida: int = 24 ) -> pd.DataFrame: """ Simula datos de vintage analysis con tres patrones realistas: - Vintages estables (año 1) - Vintages en deterioro (año 2 — política crediticia más laxa) - Shock macro en el mes 18 (incremento generalizado) """ registros = [] fecha_base = pd.Timestamp('2022-01-01') for v in range(num_vintages): fecha_origen = fecha_base + pd.DateOffset(months=v) # Calidad del vintage: los últimos 12 son de menor calidad calidad = 1.0 if v < 12 else 1.35 # 35% más riesgo en vintages recientes for _ in range(creditos_por_vintage): # Probabilidad base de default mensual (hazard rate) hazard_base = np.random.beta(1.5, 18) * calidad mes_default = None for mes in range(1, max_meses_vida + 1): fecha_obs = fecha_origen + pd.DateOffset(months=mes) # Shock macro: a partir de 2023-07 todas las PD suben 50% factor_macro = 1.5 if fecha_obs >= pd.Timestamp('2023-07-01') else 1.0 # Hazard rate aumenta ligeramente con la madurez (forma de campana) hazard_mensual = hazard_base * factor_macro * (1 + 0.02 * mes) if np.random.uniform() < hazard_mensual: mes_default = mes break registros.append({ 'vintage' : fecha_origen.strftime('%Y-%m'), 'mes_default': mes_default }) return pd.DataFrame(registros) # Simular df_v = simular_vintages() def calcular_cdr(df: pd.DataFrame, max_meses: int = 24) -> pd.DataFrame: """Calcula Cumulative Default Rate por vintage y mes de vida.""" vintages = df['vintage'].unique() resultados = [] for v in vintages: sub = df[df['vintage'] == v] n_total = len(sub) for m in range(1, max_meses + 1): n_defaults = sub['mes_default'].dropna() cdr = (n_defaults <= m).sum() / n_total resultados.append({ 'vintage' : v, 'mes_vida' : m, 'cdr' : cdr, 'n_creditos' : n_total }) return pd.DataFrame(resultados) cdr_df = calcular_cdr(df_v) # ── Visualización ────────────────────────────────────────────── fig, axes = plt.subplots(1, 2, figsize=(16, 7)) fig.patch.set_facecolor('#FAF7F0') vintages_list = sorted(cdr_df['vintage'].unique()) # Paleta: vintages estables=azul oscuro, recientes=naranja/rojo colors_stable = plt.cm.Blues(np.linspace(0.4, 0.9, 12)) colors_risky = plt.cm.Oranges(np.linspace(0.4, 0.9, 12)) ax = axes[0] ax.set_facecolor('#FAF7F0') for i, v in enumerate(vintages_list): sub = cdr_df[cdr_df['vintage'] == v] color = colors_stable[i] if i < 12 else colors_risky[i - 12] lw = 1.5 if i not in [0, 11, 12, 23] else 2.5 ax.plot(sub['mes_vida'], sub['cdr'] * 100, color=color, linewidth=lw, alpha=0.85) ax.set_xlabel('Mes de Vida del Crédito', fontsize=11) ax.set_ylabel('CDR — Tasa de Default Acumulada (%)', fontsize=11) ax.set_title('Vintage Analysis: CDR por Cohorte\n(azul=estables, naranja=deterioro)', fontsize=12, fontweight='bold') ax.yaxis.set_major_formatter(mticker.FormatStrFormatter('%.1f%%')) ax.grid(True, alpha=0.3, linestyle='--') plt.tight_layout() plt.savefig('vintage_analysis.png', dpi=150, bbox_inches='tight') plt.show() print("✓ Gráfico de vintage analysis generado")
El ecosistema FinTech ha transformado fundamentalmente la cadena de valor del crédito. Si en la banca tradicional todas las capas (originación, evaluación, fondeo, cobranza) residían en una sola institución, el modelo FinTech desacopla estas capas y las expone como servicios de API consumibles por terceros. Esta arquitectura modular es el sustrato técnico sobre el que se construyen los sistemas de scoring en tiempo real.
El ecosistema puede organizarse en cuatro capas funcionales que se comunican mediante APIs REST o gRPC:
| Capa | Función | Proveedores (LATAM) | API Principal | Latencia típica |
|---|---|---|---|---|
| Identidad | KYC, biometría, validación de documentos | Jumio, Truora, Metamap | POST /verify/identity | 2–8s |
| Buró / Data | Score FICO, historial, datos alternativos | Círculo de Crédito, Equifax, Experian | GET /credit-report/{curp} | 0.5–3s |
| Open Banking | Movimientos bancarios, saldos, ingresos | Belvo, Finerio, Plaid MX | GET /transactions/{link_id} | 1–5s |
| Decisión / Score | Scoring, política de crédito, límite | Inerno / Zest AI / Provenir | POST /decisions/score | 50–200ms |
El siguiente diagrama secuencial describe el flujo típico de una decisión de crédito en tiempo real, desde la solicitud del usuario hasta la respuesta final del sistema. Cada paso involucra llamadas a APIs externas con requisitos de latencia y disponibilidad distintos.
# ═══════════════════════════════════════════════════════════════ # SIMULACIÓN DE ORQUESTADOR DE DECISIÓN CREDITICIA # Módulo 1.4 — Ecosistema FinTech # Autor: Víctor Miranda # # Este código simula un orquestador de decisión crediticia # real-time con llamadas asíncronas a APIs externas (simuladas). # Ejecutar directamente en Google Colab. # ═══════════════════════════════════════════════════════════════ import asyncio import json import random import time from dataclasses import dataclass, field from typing import Optional, Dict, Any from enum import Enum # ── Tipos de datos ───────────────────────────────────────────── class DecisionResult(Enum): APPROVED = "APPROVED" DECLINED = "DECLINED" MANUAL_REV = "MANUAL_REVIEW" @dataclass class CreditApplication: app_id: str curp: str monto_solicitado: float plazo_meses: int ingreso_declarado: float @dataclass class DecisionContext: application: CreditApplication bureau_score: Optional[int] = None bureau_data: Optional[Dict] = None open_banking: Optional[Dict] = None ml_score: Optional[float] = None decision: Optional[str] = None monto_aprobado: Optional[float]= None tasa_asignada: Optional[float] = None latency_ms: Dict[str, float] = field(default_factory=dict) errors: list = field(default_factory=list) # ── Simulación de APIs externas ──────────────────────────────── async def call_bureau_api(curp: str) -> Dict[str, Any]: """Simula llamada a buró de crédito (~800ms latencia típica).""" await asyncio.sleep(0.8 + random.uniform(-0.2, 0.4)) # Score sintético basado en hash del CURP (reproducible) seed = sum(ord(c) for c in curp) random.seed(seed) score = random.randint(550, 810) random.seed() # reset seed return { 'score' : score, 'max_atraso_hist': random.choice([0, 0, 0, 30, 60]), 'num_cuentas_act': random.randint(1, 6), 'meses_en_buro' : random.randint(6, 120), 'consultas_6m' : random.randint(0, 5), 'fuente' : 'CIRCULO_CREDITO' } async def call_open_banking_api(curp: str) -> Dict[str, Any]: """Simula llamada a Open Banking/Belvo (~1.2s latencia).""" await asyncio.sleep(1.2 + random.uniform(-0.3, 0.5)) return { 'ingreso_promedio_3m': random.uniform(8000, 45000), 'saldo_promedio' : random.uniform(500, 25000), 'num_transacciones' : random.randint(15, 80), 'volatilidad_ingreso': random.uniform(0.05, 0.35), 'fuente' : 'BELVO' } async def call_ml_scoring_service(features: Dict) -> float: """Simula inferencia del modelo ML en el feature store (<50ms).""" await asyncio.sleep(0.045) # 45ms — modelo LightGBM servido via ONNX # Modelo sintético: función determinista de los features score = features.get('bureau_score', 650) / 850 score -= features.get('dti', 0.3) * 0.4 score -= features.get('max_atraso_hist', 0) / 300 score += features.get('meses_en_buro', 24) / 1000 score = max(0.02, min(0.98, score + random.uniform(-0.05, 0.05))) return round(score, 4) # ── Motor de decisión con política crediticia ───────────────── def aplicar_politica_credito(ctx: DecisionContext) -> DecisionContext: """ Aplica reglas de política crediticia sobre el contexto enriquecido. Combina score ML con reglas regulatorias y de negocio. """ score = ctx.ml_score bureau = ctx.bureau_data ob = ctx.open_banking app = ctx.application # ── REGLAS DE RECHAZO AUTOMÁTICO (KO rules) ─────────────── if bureau['max_atraso_hist'] >= 90: ctx.decision = DecisionResult.DECLINED.value ctx.monto_aprobado = 0 return ctx if score < 0.25: # PD implícita > 75%: rechazo automático ctx.decision = DecisionResult.DECLINED.value ctx.monto_aprobado = 0 return ctx # ── REVISIÓN MANUAL ─────────────────────────────────────── if 0.25 <= score < 0.40: ctx.decision = DecisionResult.MANUAL_REV.value ctx.monto_aprobado = app.monto_solicitado * 0.5 ctx.tasa_asignada = 0.48 # 48% anual return ctx # ── APROBACIÓN: calcular monto y tasa ──────────────────── ingreso_verificado = ob['ingreso_promedio_3m'] if ob else app.ingreso_declarado dti_cuota = (app.monto_solicitado / app.plazo_meses) / ingreso_verificado monto_max_dti = ingreso_verificado * 0.35 * app.plazo_meses # DTI máx 35% monto_aprobado = min(app.monto_solicitado, monto_max_dti) # Tasa por riesgo: escala entre 18% y 42% anual tasa = 0.18 + (1 - score) * 0.35 ctx.decision = DecisionResult.APPROVED.value ctx.monto_aprobado = round(monto_aprobado, 2) ctx.tasa_asignada = round(tasa, 4) return ctx # ── Orquestador principal ───────────────────────────────────── async def process_credit_application(app: CreditApplication) -> DecisionContext: """Orquesta el flujo completo de decisión crediticia en tiempo real.""" ctx = DecisionContext(application=app) t_start = time.perf_counter() # ── ETAPA 1: Enriquecimiento de datos (paralelo) ─────────── t1 = time.perf_counter() bureau_task = asyncio.create_task(call_bureau_api(app.curp)) ob_task = asyncio.create_task(call_open_banking_api(app.curp)) # Esperamos ambas APIs en paralelo (la más lenta dicta el tiempo) bureau_data, ob_data = await asyncio.gather(bureau_task, ob_task) ctx.latency_ms['data_enrichment'] = (time.perf_counter() - t1) * 1000 ctx.bureau_score = bureau_data['score'] ctx.bureau_data = bureau_data ctx.open_banking = ob_data # ── ETAPA 2: Inferencia ML ──────────────────────────────── t2 = time.perf_counter() features = { 'bureau_score' : bureau_data['score'], 'max_atraso_hist': bureau_data['max_atraso_hist'], 'meses_en_buro' : bureau_data['meses_en_buro'], 'dti' : (app.monto_solicitado / app.plazo_meses) / ob_data['ingreso_promedio_3m'], 'volatilidad' : ob_data['volatilidad_ingreso'] } ctx.ml_score = await call_ml_scoring_service(features) ctx.latency_ms['ml_inference'] = (time.perf_counter() - t2) * 1000 # ── ETAPA 3: Política crediticia ────────────────────────── t3 = time.perf_counter() ctx = aplicar_politica_credito(ctx) ctx.latency_ms['policy_engine'] = (time.perf_counter() - t3) * 1000 ctx.latency_ms['total'] = (time.perf_counter() - t_start) * 1000 return ctx # ── Ejecutar simulación ─────────────────────────────────────── async def main(): apps = [ CreditApplication("APP-001", "MIRA850101HDFRNC08", 25000, 24, 18000), CreditApplication("APP-002", "ROGL920315MDFDRZ01", 80000, 36, 35000), CreditApplication("APP-003", "HERN751220HDFRNR09", 5000, 12, 9000), ] print(f"\n{'='*65}") print(f"{'SIMULADOR DE DECISIÓN CREDITICIA EN TIEMPO REAL':^65}") print(f"{'Módulo 1 — Víctor Miranda':^65}") print(f"{'='*65}\n") for app in apps: ctx = await process_credit_application(app) print(f"📋 Solicitud: {app.app_id}") print(f" Monto solicitado : ${app.monto_solicitado:,.0f} MXN") print(f" Score de buró : {ctx.bureau_score}") print(f" ML Score (Proba) : {ctx.ml_score:.4f}") print(f" DECISIÓN : {ctx.decision}") if ctx.monto_aprobado: print(f" Monto aprobado : ${ctx.monto_aprobado:,.0f} MXN") print(f" Tasa asignada : {ctx.tasa_asignada:.1%} anual") print(f" Latencias:") for k, v in ctx.latency_ms.items(): print(f" {k:25s}: {v:6.1f} ms") print() # Ejecutar en Colab/Jupyter await main()
=================================================================
SIMULADOR DE DECISIÓN CREDITICIA EN TIEMPO REAL
Módulo 1 — Víctor Miranda
=================================================================
📋 Solicitud: APP-001
Monto solicitado : $25,000 MXN
Score de buró : 724
ML Score (Proba) : 0.7312
DECISIÓN : APPROVED
Monto aprobado : $25,000 MXN
Tasa asignada : 24.9% anual
Latencias:
data_enrichment : 1284.3 ms
ml_inference : 45.2 ms
policy_engine : 0.1 ms
total : 1329.7 ms
📋 Solicitud: APP-002
Score de buró : 681
ML Score (Proba) : 0.6108
DECISIÓN : APPROVED
Tasa asignada : 31.7% anual
...
📋 Solicitud: APP-003
Score de buró : 598
ML Score (Proba) : 0.3821
DECISIÓN : MANUAL_REVIEW
Monto aprobado : $2,500 MXN
Tasa asignada : 48.0% anual
Este módulo ha establecido el andamiaje conceptual sobre el que se construirá el resto de la serie. Los puntos de mayor relevancia práctica para el profesional del riesgo financiero pueden sintetizarse en las siguientes ideas:
Primero, la evolución del scoring no es una sustitución sino una estratificación: FICO sigue siendo el estándar regulatorio en múltiples jurisdicciones, la Regresión Logística sigue siendo el modelo baseline requerido por muchos supervisores bancarios, y los modelos de Gradient Boosting añaden poder predictivo incremental sobre esas bases. Un sistema productivo maduro contiene los tres niveles.
Segundo, la distinción entre scoring de originación, behavioral y real-time no es solo de velocidad sino de naturaleza del dato y objetivo de la decisión. Confundir los tipos lleva a modelos con data leakage, política de crédito subóptima, e incumplimientos regulatorios.
Tercero, las métricas $PD$, $LGD$ y $EAD$ son el lenguaje universal del riesgo crediticio. Cualquier modelo de ML debe poder traducir sus predicciones a este lenguaje para ser útil en el contexto de gestión de capital, aprovisionamiento, y comunicación regulatoria.
Cuarto, el ecosistema FinTech de APIs ha democratizado el acceso a datos de alta calidad (buró, open banking, identidad) pero ha creado nuevos desafíos de latencia, disponibilidad, y privacidad que deben gestionarse con patrones de arquitectura robustos: paralelismo asíncrono, circuit breakers, y fallback strategies.
El real-time scoring no requiere un modelo más sofisticado: requiere una infraestructura más sofisticada alrededor de un modelo que puede ser relativamente simple. La complejidad se traslada del algoritmo de ML a la arquitectura del sistema: feature stores, orquestación de APIs, latencia de extremo a extremo, y observabilidad en producción. Esto es lo que estudiaremos en profundidad en los módulos siguientes.
| Concepto | Definición Clave | Relevancia Práctica |
|---|---|---|
| FICO Score | Score [300–850] basado en 5 componentes ponderados | Estándar regulatorio en EE.UU. y referencia en LATAM |
| Regresión Logística | $P(Y=1|\mathbf{x}) = \sigma(\beta_0 + \boldsymbol{\beta}^\top\mathbf{x})$ | Baseline interpretable; requisito regulatorio frecuente |
| Gradient Boosting | Ensemble secuencial que minimiza gradiente de la pérdida | +5–10 puntos de AUC sobre logística en carteras típicas |
| PD | $P(\text{default en }T \mid \mathcal{F}_{t_0})$ | Entrada principal al cálculo de RWA y aprovisionamiento |
| LGD | $1 - RR$ (fracción perdida tras el default) | Determina la severidad económica del incumplimiento |
| EAD | Saldo actual + CCF × línea no utilizada | Crítico para productos revolventes (tarjetas, líneas) |
| EL | $PD \times LGD \times EAD$ | Base del precio del crédito y aprovisionamiento contable |
| Vintage Analysis | CDR por cohorte de originación a lo largo del tiempo | Detecta deterioro de modelo y calidad de originación |
| Real-Time Scoring | Decisión en <200ms con features frescos de streaming | Habilita detección de fraude y pricing dinámico |