IFRS 9: Expected Credit Losses
Foundations & Data Analytics
🔵 Cluster 1 — Foundations & Data


Victor Miranda
Especialista en Riesgos Financieros — FRM & CFA Level II
Credit Risk Modeling · Machine Learning · Quantitative Finance
2025
© 2025 Victor Miranda. Todos los derechos reservados.
Queda prohibida la reproducción total o parcial sin autorización expresa del autor.
Contenido
Módulo 0 — Fundamentos IFRS 9
0.1 Introducción al Marco Regulatorio IFRS 9
0.2 Implementación en la UE y Modelo de Tres Etapas
0.3 SICR Assessment: Enfoques de Evaluación
0.4 Modelos de Expected Credit Loss (ECL)
0.5 Variabilidad de la PD bajo IFRS 9
0.6 Incorporación de Información Forward-Looking
0.7 Prácticas de Backtesting ECL
Módulo 1 — Análisis Exploratorio de Datos (EDA)
1.1 Fuentes de Datos y Revisión Inicial
1.2 Definición de la Variable Target y Horizonte Temporal
1.3 Técnicas de Sampling
1.4 Análisis Exploratorio Estadístico
1.5 Tratamiento de Missing Values (MICE)
1.6 Detección y Tratamiento de Outliers
1.7 Técnicas de Oversampling (SMOTE)
1.8 Ejercicio Integral: EDA en Python
Módulo 2 — Feature Engineering
2.1 Estandarización de Datos
2.2 Categorización de Variables (Binning)
2.3 Codificación WOE (Weight of Evidence)
2.4 Information Value (IV)
2.5 Selección y Optimización de Variables
2.6 Ejercicio Integral: Feature Engineering en Python
Módulo 6 — Desarrollo del Scorecard
6.1 Regresión Logística y Scorecard WOE
6.2 Rescaling: Factor y Offset
6.3 Técnicas de Reject Inference
6.4 Optimización de Cut-Points con Curvas ROC
6.5 Ejercicio Integral: Scorecard Completo en Python

Módulo 0: Fundamentos IFRS 9

0.1 Introducción al Marco Regulatorio IFRS 9

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".

🟢 Definición — IFRS 9

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.

0.2 Implementación en la UE y Modelo de Tres Etapas

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.

Tabla 0.1 — Clasificación por etapas bajo IFRS 9
StageCondiciónECL ReconocidoIntereses
Stage 1Sin incremento significativo de riesgo desde originaciónECL 12 mesesSobre importe bruto
Stage 2Incremento significativo del riesgo crediticio (SICR)ECL LifetimeSobre importe bruto
Stage 3Evidencia objetiva de deterioro (credit-impaired)ECL LifetimeSobre importe neto
🟠 Fórmula — Expected Credit Loss

La ECL se calcula como el valor presente de los flujos que la entidad espera no recibir:

$$ECL = \sum_{t=1}^{T} \frac{PD_t \times LGD_t \times EAD_t}{(1+r)^t} \tag{0.1}$$

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).

0.3 SICR Assessment: Enfoques de Evaluación

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:

$$\Delta PD = PD_{Lifetime,\text{reporting}} - PD_{Lifetime,\text{origination}} \tag{0.2}$$

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.

🟢 Definición — Low Credit Risk Exemption

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.

0.4 Modelos de Expected Credit Loss

Tabla 0.2 — Tipologías de modelos ECL
TipologíaDescripciónUso Típico
ModularEstima PD, LGD y EAD por separado, los integra en fórmula ECLRetail con datos granulares
Loss RateTasas de pérdida históricas ajustadas por forward-lookingCarteras con datos limitados
VintageCohortes de originación para proyectar patrones de pérdidaConsumo, tarjetas
Roll RateTransiciones entre buckets de morosidadRetail masivo
DCFFlujos esperados descontados bajo múltiples escenariosExposiciones 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.

0.5 Variabilidad de la PD bajo IFRS 9

🟠 Fuentes de Variabilidad

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.

0.5.1 PD Point-in-Time vs Through-the-Cycle

La IFRS 9 requiere estimaciones PIT. La conversión desde PD TTC constituye un desafío metodológico central:

$$PD_{PIT}(t) = \Phi\!\left(\frac{\Phi^{-1}(PD_{TTC}) - \sqrt{\rho}\cdot z(t)}{\sqrt{1-\rho}}\right) \tag{0.3}$$

donde $\Phi$ es la CDF normal estándar, $\rho$ la correlación de activos ASRF, y $z(t)$ el factor sistemático.

🔵 Ejemplo 0.1 — Conversión PD TTC → PIT

Cartera hipotecaria con $PD_{TTC}=1.5\%$, $\rho=0.15$. Escenario adverso $z(t)=-1.5$:

$$PD_{PIT} = \Phi\!\left(\frac{-2.170 + 0.581}{0.922}\right) = \Phi(-1.722) = 4.26\%$$

La PD PIT (4.26%) es casi 3× la PD TTC (1.5%), mostrando la sensibilidad al ciclo.

0.6 Incorporación de Información Forward-Looking

La IFRS 9 requiere incorporar múltiples escenarios macroeconómicos ponderados por probabilidad:

$$ECL = \sum_{s=1}^{S} w_s \cdot ECL_s, \quad \sum_{s=1}^{S} w_s = 1 \tag{0.4}$$
🔵 Ejemplo 0.2 — Ponderación de Escenarios
EscenarioProbabilidadECL (€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.

0.7 Prácticas de Backtesting ECL

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.

📘 Nota Regulatoria

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.


Módulo 1: Análisis Exploratorio de Datos (EDA)

1.1 Fuentes de Datos y Revisión Inicial

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.

⚠ Consideración Regulatoria

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).

1.2 Definición de la Variable Target y Horizonte Temporal

$$Y_i = \begin{cases} 1 & \text{si el cliente } i \text{ entra en default} \\ 0 & \text{en caso contrario} \end{cases} \tag{1.1}$$
🟢 Definición — Default (Art. 178 CRR)

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.

1.3 Técnicas de Sampling

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:

🟠 Corrección por Muestreo Rebalanceado
$$\hat{p}_{corrected} = \frac{\hat{p}_s \cdot \frac{\pi_p}{\pi_s}}{\hat{p}_s \cdot \frac{\pi_p}{\pi_s} + (1-\hat{p}_s) \cdot \frac{1-\pi_p}{1-\pi_s}} \tag{1.2}$$

donde $\pi_s$ es la proporción de defaults en la muestra rebalanceada y $\pi_p$ la proporción poblacional.

1.4 Análisis Exploratorio Estadístico

Histogramas revelan asimetría, curtosis, multimodalidad. Q-Q plots diagnostican normalidad. Boxplots comparan distribuciones entre grupos default/no-default.

🟠 Momentos Estadísticos
$$\mu = E[X], \quad \sigma^2 = E[(X-\mu)^2] \tag{1.3}$$
$$\text{Skewness} = \frac{E[(X-\mu)^3]}{\sigma^3}, \quad \text{Kurtosis} = \frac{E[(X-\mu)^4]}{\sigma^4} \tag{1.4}$$

Normal: skewness = 0, kurtosis = 3. Exceso de kurtosis positivo → colas pesadas (leptocúrtica).

1.5 Tratamiento de Missing Values

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.

1.5.1 MICE — Multiple Imputation by Chained Equations

$$X_j^{(t)} \sim f(X_j \mid X_{-j}^{(t)}, \theta_j) \tag{1.5}$$

Itera secuencialmente sobre variables faltantes, actualizando imputaciones hasta convergencia.

1.6 Detección y Tratamiento de Outliers

1.6.1 Winsorización

$$X_{i}^{win} = \begin{cases} q_{\alpha} & \text{si } X_i < q_{\alpha} \\ X_i & \text{si } q_{\alpha} \leq X_i \leq q_{1-\alpha} \\ q_{1-\alpha} & \text{si } X_i > q_{1-\alpha} \end{cases} \tag{1.6}$$

1.6.2 Distancia de Mahalanobis

$$D_M(\mathbf{x}_i) = \sqrt{(\mathbf{x}_i - \boldsymbol{\mu})^T \boldsymbol{\Sigma}^{-1} (\mathbf{x}_i - \boldsymbol{\mu})} \tag{1.7}$$

Bajo normalidad multivariada, $D_M^2 \sim \chi^2_p$. Outliers: $D_M^2 > \chi^2_{p,0.975}$.

1.7 Técnicas de Oversampling (SMOTE)

SMOTE genera observaciones sintéticas de la clase minoritaria interpolando entre vecinos:

$$\mathbf{x}_{new} = \mathbf{x}_i + \lambda \cdot (\mathbf{x}_{nn} - \mathbf{x}_i), \quad \lambda \sim U(0,1) \tag{1.8}$$

1.8 Ejercicio Integral: EDA en Python

Pythonmodulo1_eda_credit_risk.py
# =============================================================================
# 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%}")
Output Shape: (10000, 9) | Default rate: 3.50% Missing post-MICE: 0 Outliers Mahalanobis: 247 (2.47%) SMOTE: No-Def=9650, Def=2895 Ratio: 30.0%

Módulo 2: Feature Engineering

2.1 Estandarización de Datos

2.1.1 Z-Score

$$z_i = \frac{x_i - \bar{x}}{s} \tag{2.1}$$

Variable transformada con media 0 y desviación 1. Sensible a outliers.

2.1.2 Min-Max Scaling

$$x_i^{scaled} = \frac{x_i - x_{min}}{x_{max} - x_{min}} \tag{2.2}$$

Rango [0, 1]. Aplicar winsorización previamente.

2.2 Categorización de Variables (Binning)

2.2.1 Equal Interval Binning

$$h = \frac{x_{max} - x_{min}}{k}, \quad \text{Intervalo}_j = \bigl[x_{min} + (j{-}1)h,\; x_{min} + jh\bigr) \tag{2.3}$$

2.2.2 Equal Frequency Binning

Divide en $k$ grupos con igual número de observaciones basándose en percentiles. Más robusto ante distribuciones asimétricas.

2.2.3 Chi-Square Binning

$$\chi^2 = \sum_{i=1}^{r}\sum_{j=1}^{c} \frac{(O_{ij} - E_{ij})^2}{E_{ij}} \tag{2.4}$$

Si $\chi^2 < \chi^2_{\text{crítico}}$, se fusionan categorías adyacentes. Produce bins con diferencias significativas en tasas de default.

2.3 Codificación WOE (Weight of Evidence)

🟢 Definición — Weight of Evidence

El WOE mide la fuerza de separación de un bin respecto a la variable target:

$$WOE_i = \ln\!\left(\frac{\%\text{Non-defaults}_i}{\%\text{Defaults}_i}\right) = \ln\!\left(\frac{D_i^{(0)}/D^{(0)}}{D_i^{(1)}/D^{(1)}}\right) \tag{2.5}$$

WOE positivo → menor riesgo. WOE negativo → mayor concentración de defaults.

🔵 Ejemplo 2.1 — WOE para Score Bureau
BinRangoNo-DefDefault%ND%DWOE
1300–500850958.81%27.14%−1.126
2500–6001,62011016.79%31.43%−0.627
3600–7002,9508530.57%24.29%+0.230
4700–7802,6804027.77%11.43%+0.888
5780–8501,5502016.06%5.71%+1.033

Bin 1: $WOE_1 = \ln(0.0881/0.2714) = -1.126$

2.4 Information Value (IV)

🟠 Fórmula — Information Value
$$IV = \sum_{i=1}^{k}\bigl(\%\text{ND}_i - \%\text{D}_i\bigr) \times WOE_i \tag{2.6}$$
Tabla 2.1 — Interpretación del Information Value
IVPoder PredictivoAcción
< 0.02No predictivoExcluir
0.02 – 0.10DébilConsiderar con precaución
0.10 – 0.30MedioBuena candidata
0.30 – 0.50FuerteExcelente candidata
> 0.50SospechosoVerificar data leakage
⚠ Alerta — IV > 0.50

Puede indicar data leakage, variable proxy directa de default, o problema en ventanas temporales.

2.5 Selección y Optimización de Variables

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.

2.6 Ejercicio Integral: Feature Engineering en Python

Pythonmodulo2_feature_engineering.py
# =============================================================================
# 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))

Módulo 6: Desarrollo del Scorecard

6.1 Regresión Logística y Scorecard WOE

🟢 Definición — Scorecard

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:

$$\ln\!\left(\frac{p}{1-p}\right) = \beta_0 + \sum_{j=1}^{p}\beta_j \cdot WOE_j \tag{6.1}$$

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.

6.2 Rescaling: Factor y Offset

Transforma log-odds en escala interpretable. Convención: score doble odds cada $n$ puntos (típicamente $n=20$).

🟠 Fórmulas — Factor y Offset
$$\text{Factor} = \frac{n}{\ln(2)} \tag{6.2}$$
$$\text{Offset} = \text{Score}_{\text{ref}} - \text{Factor} \times \ln(\text{Odds}_{\text{ref}}) \tag{6.3}$$

Score del cliente:

$$\text{Score} = \text{Offset} + \text{Factor} \times \ln\!\left(\frac{1-p}{p}\right) \tag{6.4}$$
🔵 Ejemplo 6.1 — Calibración del Scorecard

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$

6.2.1 Puntos por Atributo

$$\text{Points}_{ji} = -\bigl(\beta_j \times WOE_{ji}\bigr) \times \text{Factor} + \frac{\text{Offset} - \beta_0 \times \text{Factor}}{p} \tag{6.5}$$

6.3 Técnicas de Reject Inference

🟢 Definición — Reject Inference

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.

6.4 Optimización de Cut-Points con Curvas ROC

🟠 Métricas ROC
$$TPR = \frac{TP}{TP + FN}, \quad FPR = \frac{FP}{FP + TN} \tag{6.6}$$
$$\text{Gini} = 2 \times AUC - 1 \tag{6.7}$$

Gini 0.40–0.60: aceptable. 0.60–0.80: bueno. >0.80: excelente (verificar).

Cut-off óptimo por índice de Youden:

$$J = \max_c \bigl\{ TPR(c) - FPR(c) \bigr\} \tag{6.8}$$

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.

6.5 Ejercicio Integral: Scorecard en Python

Pythonmodulo6_scorecard.py
# =============================================================================
# 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))
📘 Nota — Validación del Scorecard

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.


Referencias

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.