La entidad financiera ABC presenta un crecimiento sostenido en
colocación de crédito, acompañado de un incremento en la morosidad a 30
días dentro de los primeros 12 meses de vida del crédito
(BGI_30_12). El objetivo de este análisis es (1) describir
la evolución histórica de la cartera, (2) perfilar el comportamiento de
mora por segmento y (3) identificar cruces de variables con morosidad
significativamente distinta al promedio de la cartera, como insumo para
ajustar las políticas de originación.
Fuente de datos: base_prueba_F.xlsx
(hojas prestamos, perfil_riesgo,
perfil_sociodemografico), unidas por ID +
FECHA_APER. Periodo disponible: enero 2022 – enero 2023 (13
meses, 20.551 créditos).
ruta <- "base_prueba_F.xlsx"
prestamos <- read_excel(ruta, sheet = "prestamos")
riesgo <- read_excel(ruta, sheet = "perfil_riesgo")
socio <- read_excel(ruta, sheet = "perfil_sociodemografico")
df <- prestamos %>%
left_join(riesgo, by = c("ID", "FECHA_APER")) %>%
left_join(socio, by = c("ID", "FECHA_APER")) %>%
mutate(
FECHA = as.Date(paste0(as.character(FECHA_APER), "01"), format = "%Y%m%d"),
PERFIL_RIESGO = factor(PERFIL_RIESGO, levels = sort(unique(PERFIL_RIESGO))),
EDAD = factor(EDAD, levels = sort(unique(EDAD))),
EXPERIENCIA = factor(EXPERIENCIA, levels = sort(unique(EXPERIENCIA))),
Ingreso_SMMLV = factor(Ingreso_SMMLV, levels = sort(unique(Ingreso_SMMLV))),
PRODUCTO = factor(PRODUCTO)
)
Nota de calidad de datos (transparencia metodológica):
VALOR tiene 126 registros faltantes (0,6%) y
PUNTAJE 28 (0,1%); no se imputan, se excluyen fila a fila
solo en los cálculos donde aplica (na.rm = TRUE), sin
afectar el conteo de créditos ni la mora.PRODUCTO trae “Lib” y “Libre” como
categorías separadas (no se unificaron). Sus perfiles son muy
distintos — “Lib” tiene ticket promedio de $34,6 millones y mora de
3,9%, “Libre” tiene ticket de $14,0 millones y mora de 19,3% —
consistente con Libranza (descuento por nómina, bajo
riesgo) vs. Libre Inversión (sin garantía de nómina,
mayor riesgo). Se tratan como productos distintos; se recomienda
validar esta interpretación con el área de producto."00_SinInfo",
"0.SIN_CALCULO" y "Sin Info" que agrupan
información faltante en variables sociodemográficas y de score; se
mantienen como una categoría explícita “Sin información” en vez de
eliminarse, porque su tasa de mora es en sí misma informativa (ver
secciones 2 y 3).mensual_tot <- df %>%
group_by(FECHA) %>%
summarise(
n_prestamos = n(),
valor_total = sum(VALOR, na.rm = TRUE),
valor_promedio = mean(VALOR, na.rm = TRUE),
tasa_mora = mean(BGI_30_12)
)
p1 <- ggplot(mensual_tot, aes(x = FECHA)) +
geom_col(aes(y = n_prestamos, text = paste0(format(FECHA, "%b-%Y"),
"<br>N créditos: ", n_prestamos)),
fill = col_vol, alpha = 0.85) +
geom_line(aes(y = tasa_mora * max(n_prestamos) / max(tasa_mora),
text = paste0(format(FECHA, "%b-%Y"), "<br>Mora: ", percent(tasa_mora, 0.1))),
color = col_mora, linewidth = 1.1, group = 1) +
geom_point(aes(y = tasa_mora * max(n_prestamos) / max(tasa_mora)), color = col_mora, size = 1.6) +
scale_y_continuous(
name = "N° de créditos",
sec.axis = sec_axis(~ . * max(mensual_tot$tasa_mora) / max(mensual_tot$n_prestamos),
name = "Tasa de mora (BGI_30_12)", labels = percent_format(accuracy = 1))
) +
scale_x_date(date_labels = "%b-%y", date_breaks = "1 month") +
labs(x = NULL, title = "Volumen de originación vs. tasa de mora (mensual, total cartera)") +
theme_minimal(base_size = 11) +
theme(axis.text.x = element_text(angle = 45, hjust = 1))
ggplotly(p1, tooltip = "text") %>% layout(margin = list(t = 60))
mensual_tot %>%
mutate(tasa_mora = percent(tasa_mora, accuracy = 0.1),
valor_total = comma(round(valor_total)),
valor_promedio = comma(round(valor_promedio))) %>%
rename(Fecha = FECHA, `N créditos` = n_prestamos, `Valor total ($miles)` = valor_total,
`Valor promedio ($miles)` = valor_promedio, `Tasa mora` = tasa_mora) %>%
datatable(rownames = FALSE, options = list(dom = "t", pageLength = 13))
mensual_prod <- df %>%
group_by(FECHA, PRODUCTO) %>%
summarise(n = n(), valor_prom = mean(VALOR, na.rm = TRUE), mora = mean(BGI_30_12), .groups = "drop")
p2 <- ggplot(mensual_prod, aes(x = FECHA, y = mora, color = PRODUCTO,
text = paste0(PRODUCTO, " · ", format(FECHA, "%b-%Y"),
"<br>Mora: ", percent(mora, 0.1), " · N: ", n))) +
geom_line(linewidth = 0.9) + geom_point(size = 1.3) +
scale_y_continuous(labels = percent_format(accuracy = 1)) +
scale_x_date(date_labels = "%b-%y") +
labs(x = NULL, y = "Tasa de mora", title = "Tasa de mora mensual por producto", color = "Producto") +
theme_minimal(base_size = 11)
ggplotly(p2, tooltip = "text")
resumen_prod <- df %>%
group_by(PRODUCTO) %>%
summarise(
n_prestamos = n(),
valor_total = sum(VALOR, na.rm = TRUE),
valor_promedio = mean(VALOR, na.rm = TRUE),
tasa_mora = mean(BGI_30_12)
) %>%
arrange(desc(tasa_mora))
resumen_prod %>%
mutate(across(c(valor_total, valor_promedio), ~comma(round(.))),
tasa_mora = percent(tasa_mora, 0.1)) %>%
rename(Producto = PRODUCTO, `N créditos` = n_prestamos, `Valor total ($miles)` = valor_total,
`Valor promedio ($miles)` = valor_promedio, `Tasa mora` = tasa_mora) %>%
kable(align = "lrrrr") %>%
kable_styling(bootstrap_options = c("striped", "hover", "condensed"), full_width = FALSE)
| Producto | N créditos | Valor total (\(miles) </th> <th style="text-align:right;"> Valor promedio (\)miles) | Tasa mora | |
|---|---|---|---|---|
| Libre | 6488 | 90,892,184 | 14,011 | 19.3% |
| Veh | 289 | 15,522,212 | 53,710 | 16.3% |
| TDC | 9348 | 48,854,669 | 5,296 | 13.7% |
| Micro | 2691 | 19,155,106 | 7,121 | 11.0% |
| Hipotecario | 335 | 40,371,863 | 120,513 | 6.9% |
| Lib | 1400 | 48,507,038 | 34,648 | 3.9% |
Para no repetir código, se construye una función que genera el perfilamiento (n y mora) para cualquier variable categórica, con volumen como barras y mora como línea en eje secundario.
plot_perfil <- function(data, var, top_n = NULL, titulo = NULL) {
tab <- data %>%
group_by(.data[[var]]) %>%
summarise(n = n(), mora = mean(BGI_30_12), .groups = "drop") %>%
arrange(desc(n))
if (!is.null(top_n) && nrow(tab) > top_n) {
tab <- tab %>% slice_max(n, n = top_n)
}
tab <- tab %>% mutate(across(1, ~fct_reorder(as.character(.x), n)))
p <- ggplot(tab, aes(x = .data[[var]])) +
geom_col(aes(y = n, text = paste0(.data[[var]], "<br>N: ", n)), fill = col_vol, alpha = 0.85) +
geom_line(aes(y = mora * max(n) / max(mora),
text = paste0(.data[[var]], "<br>Mora: ", percent(mora, 0.1)), group = 1),
color = col_mora, linewidth = 1) +
geom_point(aes(y = mora * max(n) / max(mora)), color = col_mora, size = 1.8) +
scale_y_continuous(name = "N° de créditos",
sec.axis = sec_axis(~ . * max(tab$mora) / max(tab$n),
name = "Tasa de mora", labels = percent_format(accuracy = 1))) +
coord_flip() +
labs(x = NULL, title = titulo %||% paste("Perfilamiento por", var)) +
theme_minimal(base_size = 11)
ggplotly(p, tooltip = "text") %>% layout(margin = list(t = 60))
}
`%||%` <- function(a, b) if (is.null(a)) b else a
plot_perfil(df, "PRODUCTO", titulo = "Volumen y mora por producto")
plot_perfil(df, "PERFIL_RIESGO", titulo = "Volumen y mora por perfil de riesgo (score)")
plot_perfil(df, "Actividad_Economica", titulo = "Volumen y mora por actividad económica")
plot_perfil(df, "DEPARTAMENTO", top_n = 15, titulo = "Volumen y mora por departamento (top 15 por volumen)")
plot_perfil(df, "EDAD", titulo = "Volumen y mora por rango de edad")
plot_perfil(df, "EXPERIENCIA", titulo = "Volumen y mora por experiencia crediticia (meses)")
plot_perfil(df, "Ingreso_SMMLV", titulo = "Volumen y mora por rango de ingreso (SMMLV)")
mens_riesgo <- df %>%
group_by(FECHA, PERFIL_RIESGO) %>%
summarise(n = n(), mora = mean(BGI_30_12), .groups = "drop")
p3 <- ggplot(mens_riesgo, aes(x = FECHA, y = mora, color = PERFIL_RIESGO,
text = paste0(PERFIL_RIESGO, " · ", format(FECHA, "%b-%Y"),
"<br>Mora: ", percent(mora, 0.1), " · N: ", n))) +
geom_line(linewidth = 0.9) + geom_point(size = 1.2) +
scale_color_manual(values = paleta_riesgo) +
scale_y_continuous(labels = percent_format(accuracy = 1)) +
labs(x = NULL, y = "Tasa de mora", title = "Mora mensual por perfil de riesgo", color = "Perfil") +
theme_minimal(base_size = 11)
ggplotly(p3, tooltip = "text")
p4 <- ggplot(mensual_prod, aes(x = FECHA, y = n, fill = PRODUCTO,
text = paste0(PRODUCTO, " · ", format(FECHA, "%b-%Y"), "<br>N: ", n))) +
geom_col(position = "stack") +
labs(x = NULL, y = "N° de créditos", title = "Composición mensual de la originación por producto",
fill = "Producto") +
theme_minimal(base_size = 11)
ggplotly(p4, tooltip = "text")
PERFIL_RIESGO es, de lejos, la variable con
mayor poder discriminante y de forma perfectamente monotónica:
de 6,3% de mora en “Muy bajo” a 45,1% en “Muy alto” (7 veces más). Esto
valida que el score actual sí ordena bien el riesgo — el problema no es
el score en sí, sino qué tan permisivo es el punto de corte de
aprobación que se aplica sobre ese score.Para cada cruce se calcula la mora por celda y se contrasta contra el
promedio del resto de la cartera mediante una prueba de
proporciones de dos muestras (prop.test), para
distinguir diferencias que son estadísticamente significativas (y no
solo ruido muestral). Se filtran celdas con n ≥ 30 para que
la prueba sea confiable.
matriz_cruce <- function(data, var1, var2, min_n = 30) {
n_total <- nrow(data)
mora_total <- sum(data$BGI_30_12)
tab <- data %>%
group_by(.data[[var1]], .data[[var2]]) %>%
summarise(n = n(), mora_n = sum(BGI_30_12), tasa = mean(BGI_30_12), .groups = "drop") %>%
filter(n >= min_n) %>%
rowwise() %>%
mutate(
resto_n = n_total - n,
resto_mora = mora_total - mora_n,
p_valor = tryCatch(
prop.test(c(mora_n, resto_mora), c(n, resto_n))$p.value,
error = function(e) NA_real_),
significativo = case_when(
is.na(p_valor) ~ "n/a",
p_valor < 0.05 & tasa > mora_total / n_total ~ "↑ Mayor riesgo (sig.)",
p_valor < 0.05 & tasa < mora_total / n_total ~ "↓ Menor riesgo (sig.)",
TRUE ~ "Sin diferencia significativa"
)
) %>%
ungroup() %>%
select(-resto_n, -resto_mora)
tab
}
heatmap_cruce <- function(tab, var1, var2, titulo) {
p <- ggplot(tab, aes(x = .data[[var2]], y = .data[[var1]], fill = tasa,
text = paste0(.data[[var1]], " × ", .data[[var2]],
"<br>Mora: ", percent(tasa, 0.1), "<br>N: ", n,
"<br>", significativo))) +
geom_tile(color = "white") +
scale_fill_gradient(low = "#EAF2F8", high = col_mora, labels = percent_format(accuracy = 1),
name = "Tasa mora") +
labs(x = var2, y = var1, title = titulo) +
theme_minimal(base_size = 10) +
theme(axis.text.x = element_text(angle = 40, hjust = 1))
ggplotly(p, tooltip = "text")
}
cruce1 <- matriz_cruce(df, "PERFIL_RIESGO", "PRODUCTO")
heatmap_cruce(cruce1, "PERFIL_RIESGO", "PRODUCTO", "Mora por Perfil de riesgo × Producto")
cruce1 %>%
arrange(desc(tasa)) %>%
mutate(tasa = percent(tasa, 0.1), p_valor = signif(p_valor, 3)) %>%
select(`Perfil riesgo` = PERFIL_RIESGO, Producto = PRODUCTO, N = n, `Tasa mora` = tasa,
`p-valor` = p_valor, Diagnóstico = significativo) %>%
datatable(rownames = FALSE, options = list(pageLength = 10))
cruce2 <- matriz_cruce(df, "EDAD", "Ingreso_SMMLV")
heatmap_cruce(cruce2, "EDAD", "Ingreso_SMMLV", "Mora por Edad × Rango de ingreso")
cruce2 %>%
arrange(desc(tasa)) %>%
mutate(tasa = percent(tasa, 0.1), p_valor = signif(p_valor, 3)) %>%
select(Edad = EDAD, Ingreso = Ingreso_SMMLV, N = n, `Tasa mora` = tasa,
`p-valor` = p_valor, Diagnóstico = significativo) %>%
datatable(rownames = FALSE, options = list(pageLength = 10))
top_deptos <- df %>% count(DEPARTAMENTO, sort = TRUE) %>% slice_max(n, n = 10) %>% pull(DEPARTAMENTO)
cruce3 <- matriz_cruce(df %>% filter(DEPARTAMENTO %in% top_deptos), "DEPARTAMENTO", "PERFIL_RIESGO", min_n = 20)
heatmap_cruce(cruce3, "DEPARTAMENTO", "PERFIL_RIESGO", "Mora por Departamento (top 10) × Perfil de riesgo")
cruce3 %>%
arrange(desc(tasa)) %>%
mutate(tasa = percent(tasa, 0.1), p_valor = signif(p_valor, 3)) %>%
select(Departamento = DEPARTAMENTO, `Perfil riesgo` = PERFIL_RIESGO, N = n, `Tasa mora` = tasa,
`p-valor` = p_valor, Diagnóstico = significativo) %>%
datatable(rownames = FALSE, options = list(pageLength = 10))
Libre Inversión en
perfil 1.MUY_ALTO tiene 51,9% de mora sobre 231 créditos, y
TDC en el mismo perfil tiene 39,2% sobre 181 créditos —
ambos estadísticamente significativos frente al resto de la cartera.
Esto indica que hoy se está originando crédito de consumo sin
garantía a clientes ya identificados como de muy alto riesgo por el
score, lo cual es el punto más claro de intervención inmediata:
endurecer o eliminar la aprobación de Libre Inversión y TDC para el
perfil Muy Alto.1.MUY_ALTO y 2.ALTO en Libre Inversión
y TDC (hoy son los que más castigan la cartera).Nota: todos los hallazgos cuantitativos de este documento se
recalculan directamente de base_prueba_F.xlsx al compilar
el .Rmd; ningún número está codificado a mano.