La norma IFRS 9 Financial Instruments, emitida por el International Accounting Standards Board (IASB) en julio de 2014, sustituyó a la IAS 39 con el objetivo de abordar las deficiencias identificadas durante la crisis financiera global de 2007–2009. Bajo la IAS 39, el modelo de pérdidas incurridas (incurred loss model) permitía el reconocimiento de deterioro solo cuando existía evidencia objetiva de un evento de pérdida, generando un retraso sistemático — el fenómeno "too little, too late".
La IFRS 9 establece un modelo de pérdidas crediticias esperadas (Expected Credit Loss — ECL) que requiere a las entidades financieras reconocer las pérdidas de forma prospectiva, incorporando información histórica, actual y proyecciones forward-looking desde el reconocimiento inicial del instrumento financiero.
La IFRS 9 se estructura en tres pilares: (i) Clasificación y Medición, (ii) Deterioro bajo el modelo ECL, y (iii) Contabilidad de Coberturas. Este curso se centra en el pilar de deterioro, que establece un marco de tres etapas (stages) para la medición de pérdidas esperadas.
La implementación en la UE se hizo efectiva el 1 de enero de 2018. La pandemia de COVID-19, apenas dos años después, representó el primer stress test real, evidenciando desafíos significativos en prociclicidad, sensibilidad de clasificaciones SICR a shocks abruptos, y la necesidad de overlays. Eventos posteriores — conflicto Rusia-Ucrania, crisis energética, presiones inflacionarias — han reforzado la necesidad de modelos más robustos.
| Stage | Condición | ECL Reconocido | Intereses |
|---|---|---|---|
| Stage 1 | Sin incremento significativo de riesgo desde originación | ECL 12 meses | Sobre importe bruto |
| Stage 2 | Incremento significativo del riesgo crediticio (SICR) | ECL Lifetime | Sobre importe bruto |
| Stage 3 | Evidencia objetiva de deterioro (credit-impaired) | ECL Lifetime | Sobre importe neto |
La ECL se calcula como el valor presente de los flujos que la entidad espera no recibir:
donde $PD_t$ es la probabilidad de default marginal en $t$, $LGD_t$ la pérdida dado incumplimiento, $EAD_t$ la exposición al default, $r$ la tasa de descuento efectiva original, y $T$ el horizonte (12 meses Stage 1, vida residual Stages 2-3).
El SICR es el mecanismo que determina la migración Stage 1 → Stage 2. Los enfoques cuantitativos comparan la PD Lifetime al reconocimiento con la PD en fecha de reporte:
Si $\Delta PD$ excede un umbral, la exposición migra a Stage 2. Los enfoques incluyen umbrales absolutos (e.g., +100 bps), relativos (PD duplicada), y mixtos.
Si el riesgo crediticio a fecha de reporte es bajo (grado de inversión, BBB− o superior), la entidad puede asumir que no ha existido SICR desde el reconocimiento inicial.
Los criterios cualitativos complementarios incluyen: watchlists, forbearance, deterioro macroeconómico sectorial, y morosidad >30 días como indicador rebuttable presuntivo.
| Tipología | Descripción | Uso Típico |
|---|---|---|
| Modular | Estima PD, LGD y EAD por separado, los integra en fórmula ECL | Retail con datos granulares |
| Loss Rate | Tasas de pérdida históricas ajustadas por forward-looking | Carteras con datos limitados |
| Vintage | Cohortes de originación para proyectar patrones de pérdida | Consumo, tarjetas |
| Roll Rate | Transiciones entre buckets de morosidad | Retail masivo |
| DCF | Flujos esperados descontados bajo múltiples escenarios | Exposiciones individualmente significativas |
La pandemia evidenció las limitaciones de estos modelos ante eventos sin precedente. El uso de overlays (ajustes post-modelo basados en juicio experto) se incrementó sustancialmente, requiriendo documentación y justificación rigurosa según las directrices del EBA.
Definición de default: Diferencias en aplicación de 90 días, unlikely to pay, materialidad.
Diferenciación del riesgo: Variabilidad en modelos scoring/rating para segmentación.
Cuantificación: Diferencias en calibración, horizontes, ciclo económico (PIT vs TTC).
Defaults 2020-2021: Heterogeneidad en incorporación/exclusión de defaults pandémicos.
La IFRS 9 requiere estimaciones PIT. La conversión desde PD TTC constituye un desafío metodológico central:
donde $\Phi$ es la CDF normal estándar, $\rho$ la correlación de activos ASRF, y $z(t)$ el factor sistemático.
Cartera hipotecaria con $PD_{TTC}=1.5\%$, $\rho=0.15$. Escenario adverso $z(t)=-1.5$:
La PD PIT (4.26%) es casi 3× la PD TTC (1.5%), mostrando la sensibilidad al ciclo.
La IFRS 9 requiere incorporar múltiples escenarios macroeconómicos ponderados por probabilidad:
| Escenario | Probabilidad | ECL (€M) |
|---|---|---|
| Favorable (PIB +3.2%) | 20% | 45.2 |
| Base (PIB +1.5%) | 55% | 62.8 |
| Adverso (PIB −1.8%) | 25% | 98.4 |
$$ECL = 0.20 \times 45.2 + 0.55 \times 62.8 + 0.25 \times 98.4 = 68.18 \text{ €M}$$
El ECL ponderado (€68.18M) supera el escenario base (€62.8M), reflejando la no linealidad entre condiciones económicas y pérdidas.
Las variables macroeconómicas más utilizadas: PIB, tasa de desempleo, Euribor, índices inmobiliarios, inflación, spreads de crédito corporativo, e índices de confianza del consumidor.
Staging allocation: Migraciones proyectadas vs. observadas, estabilidad de proporciones por stage.
ECL measurement: Provisiones estimadas vs. pérdidas realizadas, por segmentos.
PD estimates: PD estimadas vs. tasas observadas (ODR), poder discriminante (Gini, AUC) y calibración.
Forward-looking: Evaluación retrospectiva de escenarios macroeconómicos.
El EBA Principle 5 on Validation requiere un marco de validación independiente que cubra todos los componentes del modelo ECL, incluyendo robustez ante cambios en supuestos y adecuación de overlays.
Datos internos: Sistema core bancario (saldos, transacciones), comportamiento de pago (morosidad, días de atraso), información demográfica, scoring interno previo.
Datos externos: Bureaus de crédito, datos macroeconómicos, registros públicos (insolvencias), datos sectoriales.
El EBA exige calidad, integridad y representatividad de datos. La ventana temporal debe capturar al menos un ciclo económico completo (mínimo 5–7 años).
Default cuando: (a) unlikely to pay, o (b) atraso >90 días en obligación crediticia material.
La construcción de la muestra requiere la ventana de características (feature window) y la ventana de performance (performance window), sin solapamiento que genere data leakage.
Random Sampling: Selección con igual probabilidad. Inadecuado con tasas de default muy bajas.
Stratified Sampling: Representatividad por producto, vintage, segmento, ratio de default.
Rebalanced Sampling: Modifica proporciones default/no-default. Requiere corrección posterior:
donde $\pi_s$ es la proporción de defaults en la muestra rebalanceada y $\pi_p$ la proporción poblacional.
Histogramas revelan asimetría, curtosis, multimodalidad. Q-Q plots diagnostican normalidad. Boxplots comparan distribuciones entre grupos default/no-default.
Normal: skewness = 0, kurtosis = 3. Exceso de kurtosis positivo → colas pesadas (leptocúrtica).
MCAR: Probabilidad de faltante independiente de todo. Caso favorable pero raro.
MAR: Depende de variables observadas pero no del valor faltante.
MNAR: Depende del propio valor no observado. Caso más problemático.
Itera secuencialmente sobre variables faltantes, actualizando imputaciones hasta convergencia.
Bajo normalidad multivariada, $D_M^2 \sim \chi^2_p$. Outliers: $D_M^2 > \chi^2_{p,0.975}$.
SMOTE genera observaciones sintéticas de la clase minoritaria interpolando entre vecinos:
# ============================================================================= # MÓDULO 1: ANÁLISIS EXPLORATORIO — CREDIT RISK # Autor: Victor Miranda | Especialista en Riesgos Financieros # Entorno: Google Colab # ============================================================================= # !pip install imbalanced-learn # Ejecutar en Colab si necesario import numpy as np import pandas as pd import matplotlib.pyplot as plt import seaborn as sns from scipy import stats from sklearn.experimental import enable_iterative_imputer from sklearn.impute import IterativeImputer from imblearn.over_sampling import SMOTE np.random.seed(42) sns.set_style("whitegrid") # --- GENERACIÓN DE DATOS SINTÉTICOS (10,000 clientes) --- n = 10000 default_rate = 0.035 n_def = int(n * default_rate) target = np.concatenate([np.ones(n_def), np.zeros(n - n_def)]) np.random.shuffle(target) data = pd.DataFrame() data['default'] = target.astype(int) # Edad: defaults más jóvenes data['edad'] = np.where(data['default']==1, np.random.normal(32,8,n), np.random.normal(42,12,n) ).clip(18,75).astype(int) # Ingreso anual (€) data['ingreso_anual'] = np.where(data['default']==1, np.random.lognormal(10.0,0.5,n), np.random.lognormal(10.5,0.45,n) ).round(2) # DTI (Debt-to-Income) data['dti'] = np.where(data['default']==1, np.random.beta(5,5,n)*0.6+0.2, np.random.beta(2,5,n)*0.5+0.05 ).clip(0.01,0.95).round(4) # Antigüedad laboral data['antiguedad_laboral'] = np.where(data['default']==1, np.random.exponential(3,n), np.random.exponential(7,n) ).clip(0,40).round(1) # Número de productos data['num_productos'] = np.where(data['default']==1, np.random.poisson(1.5,n), np.random.poisson(3.0,n)).clip(0,10) # Score bureau (300-850) data['score_bureau'] = np.where(data['default']==1, np.random.normal(520,80,n), np.random.normal(700,70,n) ).clip(300,850).astype(int) # Meses desde último atraso data['meses_ultimo_atraso'] = np.where(data['default']==1, np.random.exponential(6,n), np.random.exponential(24,n) ).clip(0,120).astype(int) # Tipo empleo (categórica) tipos = ['asalariado','autonomo','funcionario','temporal'] data['tipo_empleo'] = np.where(data['default']==1, np.random.choice(tipos, n, p=[.35,.25,.10,.30]), np.random.choice(tipos, n, p=[.45,.15,.25,.15])) # Introducir missings y outliers for col, pct in [('ingreso_anual',.05),('antiguedad_laboral',.08),('score_bureau',.03)]: data.loc[np.random.random(n) < pct, col] = np.nan data.loc[np.random.choice(n,15,replace=False), 'ingreso_anual'] = \ np.random.uniform(500000,2000000,15) print(f"Shape: {data.shape} | Default rate: {data['default'].mean():.2%}") print(f"Missing:\n{data.isnull().sum()}") # --- ESTADÍSTICAS DESCRIPTIVAS POR GRUPO --- vars_num = ['edad','ingreso_anual','dti','antiguedad_laboral', 'num_productos','score_bureau','meses_ultimo_atraso'] print("\n--- NO DEFAULT ---") print(data[data['default']==0][vars_num].describe().round(2)) print("\n--- DEFAULT ---") print(data[data['default']==1][vars_num].describe().round(2)) # --- ANÁLISIS DE MOMENTOS --- moments = pd.DataFrame({ 'Variable': vars_num, 'Skewness': [data[v].skew() for v in vars_num], 'Exc_Kurtosis': [data[v].kurtosis() for v in vars_num] }) print("\nMOMENTOS:"); print(moments.to_string(index=False)) # --- VISUALIZACIONES --- fig, axes = plt.subplots(2,3, figsize=(16,10)) fig.suptitle('Distribuciones Default vs No-Default', fontsize=14, fontweight='bold') for idx, var in enumerate(['edad','ingreso_anual','dti', 'antiguedad_laboral','score_bureau','meses_ultimo_atraso']): ax = axes[idx//3, idx%3] for lbl, clr in [(0,'#2196F3'),(1,'#F44336')]: ax.hist(data[data['default']==lbl][var].dropna(), bins=40, alpha=0.6, color=clr, density=True, label='Default' if lbl else 'No Default') ax.set_title(var, fontweight='bold'); ax.legend(fontsize=9) plt.tight_layout(); plt.show() # --- IMPUTACIÓN MICE --- imputer = IterativeImputer(max_iter=10, random_state=42) data[vars_num] = imputer.fit_transform(data[vars_num]) print(f"\nMissing post-MICE: {data[vars_num].isnull().sum().sum()}") # --- OUTLIERS: MAHALANOBIS --- X = data[vars_num].values mu = X.mean(axis=0) cov_inv = np.linalg.inv(np.cov(X, rowvar=False)) mahal = np.array([np.sqrt((x-mu) @ cov_inv @ (x-mu).T) for x in X]) threshold = np.sqrt(stats.chi2.ppf(0.975, len(vars_num))) print(f"Outliers Mahalanobis: {(mahal>threshold).sum()} ({(mahal>threshold).mean():.2%})") # Winsorización percentiles 1-99 for v in vars_num: q1, q99 = data[v].quantile([0.01,0.99]) data[v] = data[v].clip(q1, q99) # --- SMOTE --- smote = SMOTE(sampling_strategy=0.3, random_state=42, k_neighbors=5) X_res, y_res = smote.fit_resample(data[vars_num].values, data['default'].values) print(f"\nSMOTE: No-Def={(y_res==0).sum()}, Def={(y_res==1).sum()}") print(f"Ratio: {(y_res==1).sum()/(y_res==0).sum():.1%}")
Variable transformada con media 0 y desviación 1. Sensible a outliers.
Rango [0, 1]. Aplicar winsorización previamente.
Divide en $k$ grupos con igual número de observaciones basándose en percentiles. Más robusto ante distribuciones asimétricas.
Si $\chi^2 < \chi^2_{\text{crítico}}$, se fusionan categorías adyacentes. Produce bins con diferencias significativas en tasas de default.
El WOE mide la fuerza de separación de un bin respecto a la variable target:
WOE positivo → menor riesgo. WOE negativo → mayor concentración de defaults.
| Bin | Rango | No-Def | Default | %ND | %D | WOE |
|---|---|---|---|---|---|---|
| 1 | 300–500 | 850 | 95 | 8.81% | 27.14% | −1.126 |
| 2 | 500–600 | 1,620 | 110 | 16.79% | 31.43% | −0.627 |
| 3 | 600–700 | 2,950 | 85 | 30.57% | 24.29% | +0.230 |
| 4 | 700–780 | 2,680 | 40 | 27.77% | 11.43% | +0.888 |
| 5 | 780–850 | 1,550 | 20 | 16.06% | 5.71% | +1.033 |
Bin 1: $WOE_1 = \ln(0.0881/0.2714) = -1.126$
| IV | Poder Predictivo | Acción |
|---|---|---|
| < 0.02 | No predictivo | Excluir |
| 0.02 – 0.10 | Débil | Considerar con precaución |
| 0.10 – 0.30 | Medio | Buena candidata |
| 0.30 – 0.50 | Fuerte | Excelente candidata |
| > 0.50 | Sospechoso | Verificar data leakage |
Puede indicar data leakage, variable proxy directa de default, o problema en ventanas temporales.
Combina criterios estadísticos (IV, significancia), estabilidad (PSI), y negocio (interpretabilidad, disponibilidad operativa). La optimización busca categorización que maximice IV manteniendo monotonía WOE.
# ============================================================================= # MÓDULO 2: FEATURE ENGINEERING — WOE & IV # Autor: Victor Miranda # ============================================================================= # (Continuamos con 'data' del Módulo 1, post-imputación) from sklearn.preprocessing import StandardScaler # --- ESTANDARIZACIÓN --- vars_std = ['edad','ingreso_anual','dti','antiguedad_laboral', 'score_bureau','meses_ultimo_atraso'] scaler = StandardScaler() data_z = pd.DataFrame(scaler.fit_transform(data[vars_std]), columns=[f'{v}_z' for v in vars_std]) print("Z-Score (mean≈0, std≈1):") print(data_z.describe().loc[['mean','std']].round(4)) # --- FUNCIÓN WOE & IV --- def calculate_woe_iv(df, feature, target, n_bins=10): """Calcula WOE e IV para variable continua.""" temp = df[[feature, target]].dropna().copy() temp['bin'] = pd.qcut(temp[feature], q=n_bins, duplicates='drop') g = temp.groupby('bin', observed=True).agg( total=(feature,'count'), n_def=(target,'sum')).reset_index() g['n_nd'] = g['total'] - g['n_def'] tot_d, tot_nd = g['n_def'].sum(), g['n_nd'].sum() eps = 0.0001 g['pct_d'] = (g['n_def']/tot_d).clip(lower=eps) g['pct_nd'] = (g['n_nd']/tot_nd).clip(lower=eps) g['woe'] = np.log(g['pct_nd'] / g['pct_d']) g['iv_bin'] = (g['pct_nd'] - g['pct_d']) * g['woe'] g['def_rate'] = g['n_def'] / g['total'] return g, g['iv_bin'].sum() # Calcular IV para todas las variables iv_results, woe_tables = {}, {} for var in vars_std: tbl, iv = calculate_woe_iv(data, var, 'default') iv_results[var] = iv woe_tables[var] = tbl iv_rank = pd.DataFrame(sorted(iv_results.items(), key=lambda x: x[1], reverse=True), columns=['Variable','IV']) iv_rank['Power'] = iv_rank['IV'].apply( lambda x: 'No pred.' if x<.02 else 'Débil' if x<.10 else 'Medio' if x<.30 else 'Fuerte' if x<.50 else 'Sospechoso') print("\nRANKING POR IV:") print(iv_rank.to_string(index=False)) # --- VISUALIZACIÓN WOE --- fig, axes = plt.subplots(2,3, figsize=(16,10)) fig.suptitle('Weight of Evidence por Variable', fontsize=14, fontweight='bold') for idx, var in enumerate(vars_std): ax = axes[idx//3, idx%3] tbl = woe_tables[var] colors = ['#F44336' if w<0 else '#4CAF50' for w in tbl['woe']] ax.bar(range(len(tbl)), tbl['woe'], color=colors, edgecolor='white') ax.axhline(y=0, color='black', lw=0.5) ax.set_title(f'{var} (IV={iv_results[var]:.3f})', fontweight='bold') plt.tight_layout(); plt.show() # --- WOE ENCODING COMPLETO --- def apply_woe(df, feature, target, n_bins=10): """Aplica WOE encoding y retorna columna transformada.""" temp = df[[feature, target]].dropna().copy() temp['bin'] = pd.qcut(temp[feature], q=n_bins, duplicates='drop') g = temp.groupby('bin', observed=True)[target].agg(['sum','count']) g.columns = ['n_d','tot']; g['n_nd'] = g['tot']-g['n_d'] eps = 0.0001 g['woe'] = np.log((g['n_nd']/g['n_nd'].sum()).clip(lower=eps) / (g['n_d']/g['n_d'].sum()).clip(lower=eps)) temp['woe_val'] = temp['bin'].map(dict(g['woe'])) return temp['woe_val'] data_woe = data[['default']].copy() for var in vars_std: data_woe[f'{var}_woe'] = apply_woe(data, var, 'default').values # WOE categórica g = data.groupby('tipo_empleo')['default'].agg(['sum','count']) g.columns = ['n_d','tot']; g['n_nd'] = g['tot']-g['n_d'] eps = 0.0001 g['woe'] = np.log((g['n_nd']/g['n_nd'].sum()).clip(lower=eps) / (g['n_d']/g['n_d'].sum()).clip(lower=eps)) data_woe['tipo_empleo_woe'] = data['tipo_empleo'].map(dict(g['woe'])) print("\nDataset WOE:") print(data_woe.head(5).round(3))
Función $S: \mathbb{R}^p \to \mathbb{R}$ tal que $S(\mathbf{x}_i) > S(\mathbf{x}_j) \Rightarrow P(\text{Default}|\mathbf{x}_i) < P(\text{Default}|\mathbf{x}_j)$.
El modelo logístico establece:
Los coeficientes $\beta_j$ deben ser positivos para variables WOE. Un WOE más positivo asocia menor PD; $\beta_j > 0$ incrementa odds de no-default.
Transforma log-odds en escala interpretable. Convención: score doble odds cada $n$ puntos (típicamente $n=20$).
Score del cliente:
Score 600 a odds 50:1 (PD ≈ 1.96%), dobla odds cada 20 puntos:
$\text{Factor} = 20/\ln(2) = 28.854$
$\text{Offset} = 600 - 28.854 \times \ln(50) = 487.12$
PD = 5% (odds=19): Score = $487.12 + 28.854 \times \ln(19) = 572$
PD = 0.5% (odds=199): Score = $487.12 + 28.854 \times \ln(199) = 640$
Técnicas estadísticas para corregir el sesgo de selección, incorporando información inferida sobre clientes previamente rechazados.
Cut-off: Asigna todos los rechazados como defaults. Conservador, sobreestima.
Parceling: Asigna tasa de default proporcional al score que habrían obtenido.
Fuzzy Augmentation: Incorpora cada rechazo con peso probabilístico, evitando asignación binaria.
Gini 0.40–0.60: aceptable. 0.60–0.80: bueno. >0.80: excelente (verificar).
Cut-off óptimo por índice de Youden:
En la práctica, la optimización debe considerar: costo relativo de errores tipo I/II, objetivos de crecimiento, apetito de riesgo, y restricciones regulatorias de capital.
# ============================================================================= # MÓDULO 6: SCORECARD COMPLETO # Autor: Victor Miranda # ============================================================================= from sklearn.linear_model import LogisticRegression from sklearn.model_selection import train_test_split from sklearn.metrics import roc_auc_score, roc_curve, classification_report # --- PASO 1: TRAIN/TEST SPLIT --- woe_feats = [c for c in data_woe.columns if '_woe' in c] X = data_woe[woe_feats].values y = data_woe['default'].values X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=42, stratify=y) print(f"Train: {X_train.shape[0]} | Test: {X_test.shape[0]}") print(f"Default rate — Train: {y_train.mean():.2%}, Test: {y_test.mean():.2%}") # --- PASO 2: REGRESIÓN LOGÍSTICA --- model = LogisticRegression(C=1.0, solver='lbfgs', max_iter=1000, random_state=42) model.fit(X_train, y_train) coef_df = pd.DataFrame({'Variable': woe_feats, 'Coef': model.coef_[0]}).sort_values('Coef', ascending=False) print(f"\nIntercepto: {model.intercept_[0]:.4f}") print(coef_df.to_string(index=False)) # Verificar signos neg = coef_df[coef_df['Coef'] < 0] print(f"\n{'⚠ '+str(len(neg))+' coefs negativos' if len(neg)>0 else '✅ Todos positivos'}") # --- PASO 3: EVALUACIÓN --- y_prob_train = model.predict_proba(X_train)[:,1] y_prob_test = model.predict_proba(X_test)[:,1] auc_tr = roc_auc_score(y_train, y_prob_train) auc_te = roc_auc_score(y_test, y_prob_test) gini_tr, gini_te = 2*auc_tr-1, 2*auc_te-1 print(f"\nAUC — Train: {auc_tr:.4f} | Test: {auc_te:.4f}") print(f"Gini — Train: {gini_tr:.4f} | Test: {gini_te:.4f}") # Curva ROC fpr, tpr, thresholds = roc_curve(y_test, y_prob_test) fig, axes = plt.subplots(1,2, figsize=(14,6)) axes[0].plot(fpr, tpr, 'b-', lw=2, label=f'AUC={auc_te:.3f} (Gini={gini_te:.3f})') axes[0].plot([0,1],[0,1],'r--') axes[0].set_xlabel('FPR'); axes[0].set_ylabel('TPR') axes[0].set_title('Curva ROC — Test', fontweight='bold') axes[0].legend() # Youden optimal cutoff youden = tpr - fpr opt_idx = np.argmax(youden) opt_thresh = thresholds[opt_idx] print(f"\nCut-off (Youden): {opt_thresh:.4f}") print(f"Sensitivity: {tpr[opt_idx]:.3f} | Specificity: {1-fpr[opt_idx]:.3f}") # --- PASO 4: SCORECARD --- pdo = 20; score_ref = 600; odds_ref = 50 factor = pdo / np.log(2) offset = score_ref - factor * np.log(odds_ref) print(f"\nSCORECARD: PDO={pdo}, Ref={score_ref}@{odds_ref}:1") print(f"Factor={factor:.4f}, Offset={offset:.4f}") # Calcular scores log_odds = np.log((1-y_prob_test) / y_prob_test) scores = np.round(offset + factor * log_odds).astype(int) print(f"\nScores — Min: {scores.min()}, Max: {scores.max()}") print(f" Mean: {scores.mean():.0f}, Std: {scores.std():.0f}") # Score distribution by group axes[1].hist(scores[y_test==0], bins=40, alpha=0.6, color='#2196F3', density=True, label='No Default') axes[1].hist(scores[y_test==1], bins=40, alpha=0.6, color='#F44336', density=True, label='Default') axes[1].set_xlabel('Score'); axes[1].set_ylabel('Densidad') axes[1].set_title('Distribución de Scores', fontweight='bold') axes[1].legend() plt.tight_layout(); plt.show() # --- PASO 5: SCORECARD POR ATRIBUTO --- n_vars = len(woe_feats) intercept = model.intercept_[0] coefs = model.coef_[0] print("\nSCORECARD POR ATRIBUTO") print("="*60) for j, feat in enumerate(woe_feats): orig = feat.replace('_woe','') if orig in woe_tables: tbl = woe_tables[orig].copy() tbl['points'] = -(coefs[j] * tbl['woe'] * factor) + \ (offset - intercept * factor) / n_vars tbl['points'] = tbl['points'].round(0).astype(int) print(f"\n--- {orig} (β={coefs[j]:.4f}) ---") print(tbl[['bin','woe','def_rate','points']].to_string(index=False)) # --- PASO 6: ANÁLISIS POR BANDAS --- score_df = pd.DataFrame({'score': scores, 'default': y_test}) score_df['band'] = pd.cut(score_df['score'], bins=10) bands = score_df.groupby('band', observed=True).agg( n=('score','count'), n_def=('default','sum'), dr=('default','mean')).reset_index() bands['pct'] = bands['n'] / bands['n'].sum() bands['cum_dr'] = bands['n_def'].cumsum() / bands['n'].cumsum() print("\nANÁLISIS POR BANDAS DE SCORE") print(bands.to_string(index=False))
Criterios mínimos: Gini > 0.40 out-of-time, monotonía de tasa de default en bandas de score, PSI < 0.25 entre desarrollo y validación, y coherencia de signos. El EBA requiere validación independiente anual evaluando poder discriminante, calibración y estabilidad.
Basel Committee on Banking Supervision (2015). Guidance on credit risk and accounting for expected credit losses. Bank for International Settlements.
Chawla, N.V., Bowyer, K.W., Hall, L.O., & Kegelmeyer, W.P. (2002). SMOTE: Synthetic Minority Over-sampling Technique. Journal of Artificial Intelligence Research, 16, 321–357.
European Banking Authority (2021). Report on the application of the IFRS 9 framework. EBA/REP/2021/35.
European Banking Authority (2023). Report on the peer review on the application of the IFRS 9 framework. EBA/REP/2023/07.
IASB (2014). IFRS 9 Financial Instruments. International Accounting Standards Board.
Merton, R.C. (1974). On the pricing of corporate debt: The risk structure of interest rates. Journal of Finance, 29(2), 449–470.
Rubin, D.B. (1976). Inference and missing data. Biometrika, 63(3), 581–592.
Siddiqi, N. (2017). Intelligent Credit Scoring: Building and Implementing Better Credit Risk Scorecards. 2nd ed. Wiley.
Thomas, L.C. (2009). Consumer Credit Models: Pricing, Profit and Portfolios. Oxford University Press.
Vasicek, O. (2002). The distribution of loan portfolio value. Risk, 15(12), 160–162.
© 2025 Victor Miranda — Especialista en Riesgos Financieros
Todos los derechos reservados. Prohibida la reproducción sin autorización.