Modelo, scorecard y calibración¶
Esta guía cubre el corazón numérico del scorecard F1: cómo se ajusta el modelo logístico
de PD sobre las variables WoE seleccionadas, cómo esos coeficientes se escalan a un puntaje
entero y cómo la PD cruda se calibra a una tasa central de negocio. Son tres pasos
consecutivos del pipeline (… → selección → **modelo → scorecard → calibración** → desempeño →
estabilidad), cada uno gobernado por su sección del NikodymConfig. Ver
Conceptos para el modelo mental del pipeline completo.
Estabilidad (SemVer 1.x)
Las secciones scorecard y calibration son parte del pipeline de scorecard F1 y desde la 1.0
son API estable (no rompen hasta un 2.0). La sección model (estimador PD) sigue
experimental —las familias de estimador crecen aditivamente—, fuera de la garantía SemVer
1.x. Las tres son computacionales y entran al config_hash de la corrida.
Los valores concretos de esta guía provienen de una corrida de ejemplo: el preset estándar
F1 sobre el dataset sintético de consumo de comportamiento (fixtures/demo). No son cifras
inventadas ni benchmarks; son la salida de reejecutar ese preset. Para reproducirla:
from pathlib import Path
from tempfile import mkdtemp
import nikodym
from nikodym.core.config import NikodymConfig
from nikodym.ui.datasets import materialize
from nikodym.ui.presets import standard_preset
workdir = Path(mkdtemp(prefix="nikodym-guia-"))
preset = standard_preset()
data_path = materialize(preset["dataset_id"], workdir=workdir)
cfg_dict = preset["config"]
cfg_dict["data"]["load"]["source"] = str(data_path)
config = NikodymConfig.model_validate(cfg_dict)
study = nikodym.run(config)
assert study.run_context.status == "done"
Los tres pasos publican sus resultados como artefactos namespaced en
study.artifacts.get(<dominio>, <clave>).
1. Modelo logístico de PD¶
El paso model ajusta una regresión logística (statsmodels) sobre las columnas WoE que
sobrevivieron al binning y la selección. La probabilidad de incumplimiento es
PD = sigmoid(η), con η = β₀ + Σ βⱼ · WoEⱼ (el predictor lineal o log-odds). Trabajar sobre
WoE en vez de las variables crudas es lo que hace posible el escalado a puntaje del paso
siguiente: cada bin aporta una cantidad fija de log-odds.
Parámetros de ajuste (ModelConfig)¶
| Campo | Default | Rol |
|---|---|---|
engine |
logit |
logit (statsmodels Logit) o glm_binomial (solo si se ponderan observaciones). |
fit_intercept |
True |
Incluye el intercepto β₀ que captura la tasa base antes del scorecard. |
optimizer |
newton |
Newton-Raphson; bfgs/lbfgs solo si no converge. |
fit_maxiter |
100 |
Iteraciones máximas del ajuste (override deliberado sobre el maxiter=35 de statsmodels). |
tol |
1e-8 |
Tolerancia de convergencia. |
alpha |
0.05 |
Nivel para los intervalos de confianza de los coeficientes (solo inferencia; no afecta el ajuste). |
Política de signos de beta (sign_policy)¶
Con la convención de Nikodym WoE = ln(%Buenos / %Malos), una variable que realmente
discrimina riesgo debe tener coeficiente negativo: más WoE (más "bueno") reduce el
log-odds de incumplimiento. Por eso expected_beta_sign es una constante fija en negative
(no un valor a elegir): es una verdad económica del WoE, no un hiperparámetro.
Cuando una variable queda con beta positivo (signo invertido, económicamente absurdo), action
decide qué hacer:
exclude(default de la librería) — la saca del modelo.flag— la conserva pero la marca en el audit-trail.fail— detiene el ajuste.
fail_on_forced_inverted=True protege el caso en que una variable de force_include (impuesta
por negocio) termina invertida: en vez de aceptarla, el ajuste falla.
El preset relaja a flag
La corrida de ejemplo usa sign_policy.action="flag" e iv_contribution.action="flag" en
lugar del default exclude. Es una elección del preset para no descartar variables en un
dataset didáctico; en producción el default exclude es más conservador.
Stepwise (stepwise)¶
La selección iterativa dentro del ajuste entra/saca variables por significancia estadística:
direction—bidirectional(default; revisa entradas y salidas en cada ronda),forward,backwardonone(alias deenabled=False: usa todas las candidatas salvo exclusiones, signos e IV-contribution).criterion—wald_pvalue(default),lr_testoboth(más exigente, debe pasar ambos).entry_p_value/exit_p_value— umbrales de entrada y permanencia (default0.05). Bajarlos hace el modelo más selectivo.max_iter(default100) — rondas del algoritmo stepwise (distinto defit_maxiter, que son las iteraciones del optimizador estadístico).min_features(default1) — mínimo de variables finales para aceptar el modelo.
Guard de concentración de IV (iv_contribution)¶
Evita que el modelo dependa en exceso de una sola variable: threshold (default 0.90) es la
fracción máxima del IV total del modelo que puede aportar una variable individual, con la misma
tríada action (exclude/flag/fail). Complementa a force_include, force_exclude y
fail_if_no_features (aborta si la selección final queda vacía en vez de aceptar solo intercepto).
Interpretación con la corrida de ejemplo¶
El preset entrega 5 candidatas y conserva las 5 (el stepwise no descarta ninguna). Todas
quedan con beta negativo (sign_ok=True), coherente con la política de signos:
| Variable | β | p-value (Wald) | Aporte al IV del modelo |
|---|---|---|---|
intercept (β₀) |
−1.1921 | ~3e-190 | — |
utilizacion_linea |
−1.1154 | ~8e-12 | 0.103 |
antiguedad_meses |
−1.0810 | ~3e-07 | 0.070 |
deuda_ingreso |
−1.0783 | ~1e-28 | 0.276 |
ingreso_mensual |
−1.0517 | ~3e-42 | 0.514 |
mora_max_12m |
−0.9678 | ~3.6e-04 | 0.037 |
Todos los p-values quedan bajo 0.05 (el mayor, mora_max_12m, ≈ 3.6e-4). El ajuste convergió
en 6 iteraciones con pseudo-R² de McFadden ≈ 0.097 sobre n=3961 observaciones de Desarrollo
(924 eventos, tasa observada ≈ 0.233). ingreso_mensual concentra ~51% del IV del modelo: es el
factor dominante, y precisamente el tipo de concentración que vigila la política iv_contribution.
# DataFrame de coeficientes con inferencia (β, error estándar, z de Wald, p-value, IC).
coefs = study.artifacts.get("model", "coefficients")
print(coefs[["feature", "beta", "p_value", "sign_ok", "iv_contribution"]])
# Estadísticos de ajuste (log-verosimilitud, AIC/BIC, pseudo-R², convergencia).
fit_stats = study.artifacts.get("model", "fit_statistics")
2. Scorecard: escalado a puntaje¶
El paso scorecard convierte el log-odds del modelo en un puntaje entero por atributo,
determinista y auditable. La escala se ancla con tres parámetros de negocio (ScorecardConfig):
pdo(default20) — Points to Double the Odds: cuántos puntos separan un odds del doble.target_score(default600) — puntaje asignado a los odds objetivo.target_odds(default50) — odds buenos/malos de referencia (50 buenos por cada malo).score_direction(defaulthigher_is_lower_risk) — un puntaje mayor significa menor riesgo (convención habitual).
La fórmula de escalado¶
Nikodym deriva dos constantes de escala a partir de esos tres parámetros:
El intercepto β₀ se reparte de forma uniforme entre las k variables finales
(intercept_allocation="uniform"): intercept_share = β₀ / k y offset_share = offset / k. Los
puntos de cada bin (para higher_is_lower_risk) son:
El puntaje total de un registro es la suma de los puntos de sus bins. rounding_method
(default nearest_integer) redondea los puntos crudos a enteros publicables — afecta el puntaje
final, no solo su presentación. Otros controles: output_suffix/score_column (nombres de las
columnas de salida), min_score/max_score con clip (recorte auditado de puntajes fuera de
rango) y point_overrides (forzar el puntaje de un feature/bin con reason obligatoria para
auditoría).
Determinismo y overrides
Sin overrides, los puntos salen íntegramente de la fórmula. Un override manual queda trazado (variable, bin, puntos, justificación) y es la única forma de romper la derivación por fórmula.
Interpretación con la corrida de ejemplo¶
Con pdo=20, target_score=600, target_odds=50:
factor = 20 / ln(2) ≈ **28.85**offset = 600 − 28.85 · ln(50) ≈ **487.12**intercept_share = β₀ / 5 = −1.1921 / 5 ≈ **−0.2384**
La monotonía es la esperada para higher_is_lower_risk: en antiguedad_meses, el bin más
riesgoso (-inf, 12.50) recibe 94 puntos y el más seguro [114.50, inf) recibe 128 —
más antigüedad, más puntaje. Los puntos de ingreso_mensual van de 77 a 141 (el rango más
amplio, consistente con ser la variable dominante). En el dataset de ejemplo el puntaje total
observado cae en el rango 446–622.
# Tabla del scorecard: un registro por (variable, bin) con WoE, beta y puntos publicados.
card = study.artifacts.get("scorecard", "scorecard")
print(card[["feature", "bin_label", "woe", "raw_points", "points"]])
# DataFrame de puntaje total por registro.
score = study.artifacts.get("scorecard", "score")
3. Calibración de PD¶
La PD que sale del modelo reproduce los odds de la muestra de Desarrollo, pero su nivel no
tiene por qué coincidir con la tasa central que el banco quiere reconocer. El paso calibration
reancla la PD a una tasa aprobada, de forma determinista, sin volver a ajustar el orden de riesgo
cuando el método lo permite.
Método (method)¶
intercept_offset(default) — desplaza el intercepto en log-odds por un escalarδ:η_calibrado = η + δ. Preserva el ranking (transformación monótona) y no crea empates.δse resuelve numéricamente (búsqueda de raíz con bracketing) para que la media de la PD calibrada sobre Desarrollo iguale la tasa objetivo.platt_scaling/isotonic— métodos supervisados: reentrenan contra eltargetde Desarrollo (regresión logística sobre el logit crudo, o isotónica) y luego reanclan con unpost_offsetmonótono. Exigen ambas clases presentes en Desarrollo (require_both_classes_for_supervised=True).
El ancla: tasa objetivo, fuente y visión¶
anchor_source(defaultdevelopment_observed) — de dónde sale la tasa central:development_observed— se calcula sola como el promedio de largo plazo observado en Desarrollo. En este casotarget_pdse deja enNone.business_input,historical_default_rate,external_regulatory— la tasa no se deriva de los datos; entoncestarget_pdes obligatorio y explícito en(0, 1). Sintarget_pd, la configuración falla en vez de anclar a un número inventado.
anchor_kind(defaultthrough_the_cycle) — etiqueta si el ancla es una visión de largo plazo (TTC) o del momento actual (PIT). Debe ser coherente con la fuente: por ejemplo,point_in_timecondevelopment_observedes contradictorio (la media observada de Desarrollo es una tasa de largo plazo por definición) y la validación lo rechaza.
Fuentes explícitas exigen target_pd
Con anchor_source en business_input / historical_default_rate / external_regulatory,
omitir target_pd es un error de configuración, no un default silencioso. No existe la vieja
tasa "0.05 por defecto": o se ancla a un número declarado, o falla.
Otros guards: target_tolerance (default 1e-12, error máximo entre media calibrada y objetivo),
max_abs_offset (tope opcional al tamaño de δ; con None solo se audita el offset extremo),
min_fit_rows (default 30) y fit_partition fijo en desarrollo.
Through-the-cycle en la práctica¶
El preset no fija el ancla: la lee de la propia muestra de Desarrollo.
method = intercept_offset,anchor_source = development_observed,anchor_kind = through_the_cycletarget_pdsin fijar en el config; resuelto a 0.2333, la tasa observada en Desarrollo- media de PD cruda en Desarrollo ≈ 0.2333 — ya coincide con el ancla
- offset resuelto
δ ≈ **0**(del orden de 1e-16): no hay nivel que corregir - media de PD calibrada = 0.2333 (iguala el objetivo dentro de tolerancia)
ranking_preserved = True,ties_created = 0,n_fit = 3961
⚠️ Que el offset salga cero no es que la calibración sobre: es el resultado honesto de anclar a lo observado sobre la misma muestra con que se ajustó el modelo. El paso sigue siendo el que garantiza esa coincidencia y el que la deja medida y auditable — y en cuanto el ancla deja de ser la propia muestra, el offset deja de ser cero.
Un dataset sintético no tiene política de banco detrás, y por eso este ejemplo no ancla a una tasa
de negocio. Ahí es donde una calibración TTC se gana el nombre: la muestra de Desarrollo refleja
un momento del ciclo, y el banco reconoce una PD de largo plazo distinta. Para eso se declara
anchor_source = "business_input" con su target_pd, y intercept_offset traslada el nivel sin
tocar el orden de los deudores. El motor no elige por ti cuál de los dos corresponde.
# Parámetros de la calibración (método, ancla, offset, medias).
params = study.artifacts.get("calibration", "parameters")
print(params.method, params.anchor_kind, params.anchor_source)
print("offset (log-odds):", params.offset)
print("PD cruda media (dev):", params.raw_mean_pd_dev)
print("PD calibrada media (dev):", params.achieved_mean_pd_dev)
# Frame con la PD calibrada por registro (columna pd_calibrated, logit calibrado).
calibrated = study.artifacts.get("calibration", "calibrated_pd_frame")
Cómo encajan los tres pasos¶
- El modelo produce coeficientes WoE y la PD cruda (
η,pd_raw), con la política de signos garantizando relaciones económicamente sensatas. - El scorecard traduce esos coeficientes a puntos enteros interpretables por negocio, en una
escala anclada por
pdo/target_score/target_odds. - La calibración ajusta el nivel de la PD a la tasa central aprobada (TTC o PIT), preservando
el ranking cuando usa
intercept_offset.
La PD calibrada es la que consume el paso de desempeño (evaluation_source="pd_calibrated" en
el preset) y, en fases posteriores, los motores de provisiones CMF e IFRS 9/ECL. Como toda
la corrida es reproducible por construcción, reejecutar el mismo config con la misma semilla sobre
los mismos datos devuelve estos coeficientes, puntos y offset bit a bit.