# ═══════════════════════════════════════════════════════════════════════════════
# ANÁLISIS EXPLORATORIO DE DATOS — BASE I2C / CARTERA
# 14 variables | 341 027 registros
# Flujo: (1) Análisis general → (2) Variable por variable en orden original
# ═══════════════════════════════════════════════════════════════════════════════
#
# Variables (en orden original de la base):
# 1. Anon_Customer_ID → Identificador de cliente (ID)
# 2. Anon_Document_ID → Identificador de documento (ID)
# 3. Terms_of_Payment → Condición de pago (Categórica)
# 4. Document_Type → Tipo de documento (Categórica)
# 5. Document_Date → Fecha del documento (Fecha)
# 6. Payment_date → Fecha de pago (Fecha)
# 7. Net_due_date → Fecha límite de pago (Fecha)
# 8. Arrears_after_net_due_date → Días de atraso (Numérica)
# 9. Amount_in_local_currency → Monto en moneda local (Numérica)
# 10. Reason_code → Código de razón (Categórica/ID)
# 11. Clearing_date → Fecha de compensación (Fecha)
# 12. Year/month → Período año/mes (Categórica/Temporal)
# 13. Estado_Cartera → Abierta / Cerrada (Categórica binaria)
# 14. Bucket_Mora → Tramo de mora (Categórica ordenada)
# (Pago_Oportuno_Bin eliminada por solicitud del usuario)
# ═══════════════════════════════════════════════════════════════════════════════
# ───────────────────────────────────────────────────────────────────────────────
# SECCIÓN 0 ▸ INSTALACIÓN Y CARGA DE LIBRERÍAS
# ───────────────────────────────────────────────────────────────────────────────
paquetes <- c(
"tidyverse", "readxl", "janitor", "skimr", "moments",
"ggplot2", "patchwork", "lubridate", "scales", "writexl"
)
for (pkg in paquetes) {
if (!requireNamespace(pkg, quietly = TRUE))
install.packages(pkg, repos = "https://cran.r-project.org")
suppressPackageStartupMessages(library(pkg, character.only = TRUE))
}
## Warning: package 'ggplot2' was built under R version 4.5.3
## Warning: package 'dplyr' was built under R version 4.5.3
## Warning: package 'stringr' was built under R version 4.5.3
## Warning: package 'forcats' was built under R version 4.5.3
## Warning: package 'readxl' was built under R version 4.5.3
## Warning: package 'janitor' was built under R version 4.5.3
## Warning: package 'skimr' was built under R version 4.5.3
## Warning: package 'patchwork' was built under R version 4.5.3
## Warning: package 'scales' was built under R version 4.5.3
## Warning: package 'writexl' was built under R version 4.5.3
cat("✓ Librerías cargadas correctamente\n\n")
## ✓ Librerías cargadas correctamente
# ── Paleta visual consistente ──────────────────────────────────────────────────
C1 <- "#2C3E7A" # azul oscuro (principal)
C2 <- "#E84040" # rojo (alertas / secundario)
C3 <- "#F5A623" # naranja (acento)
C4 <- "#27AE60" # verde (positivo)
PALETA <- c(C1, C2, C3, C4, "#8E44AD", "#16A085", "#D35400", "#2980B9",
"#C0392B", "#7F8C8D", "#1ABC9C", "#F39C12", "#884EA0", "#2E86C1")
# ── Tema ggplot ────────────────────────────────────────────────────────────────
tema <- theme_minimal(base_size = 11) +
theme(
plot.title = element_text(face = "bold", size = 13, color = C1),
plot.subtitle = element_text(size = 10, color = "gray40"),
axis.title = element_text(size = 10),
legend.position = "bottom",
panel.grid.minor = element_blank()
)
# ── Funciones auxiliares ───────────────────────────────────────────────────────
moda_fn <- function(x) {
x <- x[!is.na(x)]
if (length(x) == 0) return(NA_character_)
ux <- unique(x); as.character(ux[which.max(tabulate(match(x, ux)))])
}
cv_fn <- function(x) round(sd(x, na.rm=TRUE) / mean(x, na.rm=TRUE) * 100, 2)
outliers_iqr <- function(x) {
q1 <- quantile(x, .25, na.rm=TRUE); q3 <- quantile(x, .75, na.rm=TRUE)
iqr <- q3 - q1
x[!is.na(x) & (x < q1 - 1.5*iqr | x > q3 + 1.5*iqr)]
}
lims_iqr <- function(x) {
q1 <- quantile(x, .25, na.rm=TRUE); q3 <- quantile(x, .75, na.rm=TRUE)
iqr <- q3 - q1; list(inf = q1 - 1.5*iqr, sup = q3 + 1.5*iqr)
}
separador <- function(titulo) {
cat("\n", strrep("═", 65), "\n", sep="")
cat(" ", toupper(titulo), "\n", sep="")
cat(strrep("═", 65), "\n\n", sep="")
}
sub_sep <- function(txt) {
cat("\n ── ", txt, " ──\n", sep="")
}
imprimir_tabla <- function(df, titulo = NULL) {
if (!is.null(titulo)) cat("\n ", titulo, "\n")
print(df, n = Inf)
}
# ═══════════════════════════════════════════════════════════════════════════════
# PARTE 1 ▸ ANÁLISIS GENERAL DE LA BASE DE DATOS
# ═══════════════════════════════════════════════════════════════════════════════
separador("PARTE 1 — ANÁLISIS GENERAL DE LA BASE DE DATOS")
##
## ═════════════════════════════════════════════════════════════════
## PARTE 1 — ANÁLISIS GENERAL DE LA BASE DE DATOS
## ═════════════════════════════════════════════════════════════════
# ── 1.1 Carga de datos ────────────────────────────────────────────────────────
RUTA <- "C:/Users/jcabia01/Downloads/Tabla anonimización Ok.xlsx"
cat("Cargando archivo...\n")
## Cargando archivo...
df_raw <- read_excel(RUTA, sheet = 1, guess_max = 10000, col_types = "text")
cat("✓ Archivo cargado\n\n")
## ✓ Archivo cargado
# ── 1.2 Eliminar Pago_Oportuno_Bin ANTES de limpiar nombres ──────────────────
# Se elimina por posición (columna 14) para evitar dependencia del nombre exacto
# que genera janitor según la versión instalada.
cat("Columnas originales en el Excel:\n")
## Columnas originales en el Excel:
print(names(df_raw))
## [1] "Anon_Customer_ID" "Anon_Document_ID"
## [3] "Terms_of_Payment" "Document_Type"
## [5] "Document_Date" "Payment_date"
## [7] "Net_due_date" "Arrears_after_net_due_date"
## [9] "Amount_in_local_currency" "Reason_code"
## [11] "Clearing_date" "Year/month"
## [13] "Estado_Cartera = Abierta / Cerrada" "Bucket_Mora"
# Identificar y eliminar la columna Pago_Oportuno_Bin de forma robusta
col_eliminar <- grep("pago|Pago|oportuno|Oportuno|Bin|bin", names(df_raw),
value = TRUE, ignore.case = TRUE)
if (length(col_eliminar) > 0) {
cat("\nEliminando columna(s):", paste(col_eliminar, collapse=", "), "\n")
df_raw <- df_raw %>% select(-all_of(col_eliminar))
} else {
# Fallback: eliminar por posición 14 si no se encontró por nombre
if (ncol(df_raw) >= 14) {
cat("\nEliminando columna en posición 14:", names(df_raw)[14], "\n")
df_raw <- df_raw[, -14]
}
}
##
## Eliminando columna en posición 14: Bucket_Mora
cat("Columnas restantes:", ncol(df_raw), "\n\n")
## Columnas restantes: 13
# ── 1.3 Limpieza de nombres de columnas ───────────────────────────────────────
df <- df_raw %>% clean_names()
cat("Nombres limpios generados:\n")
## Nombres limpios generados:
print(names(df))
## [1] "anon_customer_id" "anon_document_id"
## [3] "terms_of_payment" "document_type"
## [5] "document_date" "payment_date"
## [7] "net_due_date" "arrears_after_net_due_date"
## [9] "amount_in_local_currency" "reason_code"
## [11] "clearing_date" "year_month"
## [13] "estado_cartera_abierta_cerrada"
cat("\n")
# ── 1.4 Conversión controlada de tipos ───────────────────────────────────────
df <- df %>%
mutate(
# Identificadores → texto
anon_customer_id = as.character(anon_customer_id),
anon_document_id = as.character(anon_document_id),
reason_code = as.character(reason_code),
# Categóricas → texto
terms_of_payment = as.character(terms_of_payment),
document_type = as.character(document_type),
year_month = as.character(year_month),
estado_cartera = as.character(estado_cartera_abierta_cerrada),
# Numéricas
arrears = suppressWarnings(as.numeric(arrears_after_net_due_date)),
amount = suppressWarnings(as.numeric(amount_in_local_currency)),
# Fechas (número serial de Excel → Date)
document_date = suppressWarnings(
as.Date(as.numeric(document_date), origin = "1899-12-30")),
payment_date = suppressWarnings(
as.Date(as.numeric(payment_date), origin = "1899-12-30")),
net_due_date = suppressWarnings(
as.Date(as.numeric(net_due_date), origin = "1899-12-30")),
clearing_date = suppressWarnings(
as.Date(as.numeric(clearing_date), origin = "1899-12-30")),
# Bucket_Mora: recalculada desde arrears (venía como fórmula sin calcular)
bucket_mora = case_when(
arrears <= 0 ~ "Al dia",
arrears <= 30 ~ "1-30",
arrears <= 60 ~ "31-60",
arrears <= 90 ~ "61-90",
arrears <= 180 ~ "91-180",
arrears <= 360 ~ "181-360",
TRUE ~ ">360"
),
bucket_mora = factor(bucket_mora,
levels = c("Al dia","1-30","31-60","61-90","91-180","181-360",">360"),
ordered = TRUE)
) %>%
# Renombrar columnas a nombres limpios definitivos (mismo orden que original)
select(
anon_customer_id,
anon_document_id,
terms_of_payment,
document_type,
document_date,
payment_date,
net_due_date,
arrears,
amount,
reason_code,
clearing_date,
year_month,
estado_cartera,
bucket_mora
)
cat("✓ Tipos de variables asignados y Bucket_Mora recalculada\n")
## ✓ Tipos de variables asignados y Bucket_Mora recalculada
cat(" (Nota: en el Excel original Bucket_Mora contenía fórmulas IF sin\n")
## (Nota: en el Excel original Bucket_Mora contenía fórmulas IF sin
cat(" calcular; se reconstruyó directamente desde Arrears)\n\n")
## calcular; se reconstruyó directamente desde Arrears)
# ── 1.3 Dimensiones ───────────────────────────────────────────────────────────
sub_sep("DIMENSIONES")
##
## ── DIMENSIONES ──
cat(" Número de filas :", formatC(nrow(df), big.mark=","), "\n")
## Número de filas : 341,027
cat(" Número de columnas :", ncol(df), "\n")
## Número de columnas : 14
# ── 1.4 Nombres de variables ───────────────────────────────────────────────────
sub_sep("NOMBRES DE VARIABLES (en orden original)")
##
## ── NOMBRES DE VARIABLES (en orden original) ──
for (i in seq_along(names(df))) {
cat(sprintf(" %2d. %s\n", i, names(df)[i]))
}
## 1. anon_customer_id
## 2. anon_document_id
## 3. terms_of_payment
## 4. document_type
## 5. document_date
## 6. payment_date
## 7. net_due_date
## 8. arrears
## 9. amount
## 10. reason_code
## 11. clearing_date
## 12. year_month
## 13. estado_cartera
## 14. bucket_mora
# ── 1.5 Tipos de dato en R ────────────────────────────────────────────────────
sub_sep("TIPO DE DATO EN R POR VARIABLE")
##
## ── TIPO DE DATO EN R POR VARIABLE ──
tipos_df <- tibble(
`#` = seq_along(names(df)),
Variable = names(df),
Tipo_R = map_chr(df, ~ class(.x)[1]),
Categoria = case_when(
names(df) %in% c("anon_customer_id","anon_document_id") ~ "Identificador",
names(df) %in% c("document_date","payment_date",
"net_due_date","clearing_date") ~ "Fecha",
names(df) %in% c("arrears","amount") ~ "Numérica continua",
names(df) == "bucket_mora" ~ "Categórica ordenada",
TRUE ~ "Categórica nominal"
)
)
imprimir_tabla(tipos_df)
## # A tibble: 14 × 4
## `#` Variable Tipo_R Categoria
## <int> <chr> <chr> <chr>
## 1 1 anon_customer_id character Identificador
## 2 2 anon_document_id character Identificador
## 3 3 terms_of_payment character Categórica nominal
## 4 4 document_type character Categórica nominal
## 5 5 document_date Date Fecha
## 6 6 payment_date Date Fecha
## 7 7 net_due_date Date Fecha
## 8 8 arrears numeric Numérica continua
## 9 9 amount numeric Numérica continua
## 10 10 reason_code character Categórica nominal
## 11 11 clearing_date Date Fecha
## 12 12 year_month character Categórica nominal
## 13 13 estado_cartera character Categórica nominal
## 14 14 bucket_mora ordered Categórica ordenada
# ── 1.6 Vista inicial ─────────────────────────────────────────────────────────
sub_sep("PRIMERAS 6 FILAS (head)")
##
## ── PRIMERAS 6 FILAS (head) ──
print(head(df))
## # A tibble: 6 × 14
## anon_customer_id anon_document_id terms_of_payment document_type document_date
## <chr> <chr> <chr> <chr> <date>
## 1 CUST_8 XXXXXX2081 <NA> AB 2023-03-31
## 2 CUST_273 XXXXXX2080 <NA> AB 2023-03-31
## 3 CUST_158 XXXXXX1017 Z000 DA 2026-03-26
## 4 CUST_233 XXXXXX0970 Z914 DA 2026-03-25
## 5 CUST_280 XXXXXX0076 Z913 DA 2026-01-21
## 6 CUST_280 XXXXXX0077 Z913 DA 2026-01-21
## # ℹ 9 more variables: payment_date <date>, net_due_date <date>, arrears <dbl>,
## # amount <dbl>, reason_code <chr>, clearing_date <date>, year_month <chr>,
## # estado_cartera <chr>, bucket_mora <ord>
sub_sep("ESTRUCTURA (str)")
##
## ── ESTRUCTURA (str) ──
str(df)
## tibble [341,027 × 14] (S3: tbl_df/tbl/data.frame)
## $ anon_customer_id: chr [1:341027] "CUST_8" "CUST_273" "CUST_158" "CUST_233" ...
## $ anon_document_id: chr [1:341027] "XXXXXX2081" "XXXXXX2080" "XXXXXX1017" "XXXXXX0970" ...
## $ terms_of_payment: chr [1:341027] NA NA "Z000" "Z914" ...
## $ document_type : chr [1:341027] "AB" "AB" "DA" "DA" ...
## $ document_date : Date[1:341027], format: "2023-03-31" "2023-03-31" ...
## $ payment_date : Date[1:341027], format: "2019-10-25" "2017-11-24" ...
## $ net_due_date : Date[1:341027], format: "2019-10-25" "2017-11-24" ...
## $ arrears : num [1:341027] 2347 3047 3 -33 39 ...
## $ amount : num [1:341027] 78063189 212841936 -40372100 1470462 -37890 ...
## $ reason_code : chr [1:341027] "76" "76" "81" "81" ...
## $ clearing_date : Date[1:341027], format: NA NA ...
## $ year_month : chr [1:341027] "2023/03" "2023/03" "2026/03" "2026/03" ...
## $ estado_cartera : chr [1:341027] "Abierta" "Abierta" "Abierta" "Abierta" ...
## $ bucket_mora : Ord.factor w/ 7 levels "Al dia"<"1-30"<..: 7 7 2 1 3 3 3 2 2 3 ...
sub_sep("RESUMEN ESTADÍSTICO (summary)")
##
## ── RESUMEN ESTADÍSTICO (summary) ──
print(summary(df))
## anon_customer_id anon_document_id terms_of_payment document_type
## Length:341027 Length:341027 Length:341027 Length:341027
## Class :character Class :character Class :character Class :character
## Mode :character Mode :character Mode :character Mode :character
##
##
##
##
## document_date payment_date net_due_date
## Min. :2022-01-03 Min. :2007-08-17 Min. :2007-08-17
## 1st Qu.:2022-12-15 1st Qu.:2022-12-23 1st Qu.:2022-12-29
## Median :2023-12-10 Median :2023-12-19 Median :2023-12-22
## Mean :2024-01-12 Mean :2024-02-04 Mean :2024-02-08
## 3rd Qu.:2025-02-08 3rd Qu.:2025-02-25 3rd Qu.:2025-02-28
## Max. :2026-03-28 Max. :4025-04-22 Max. :4025-04-22
##
## arrears amount reason_code
## Min. :-28308.00 Min. :-2.080e+10 Length:341027
## 1st Qu.: 0.00 1st Qu.:-4.592e+05 Class :character
## Median : 6.00 Median : 2.899e+05 Mode :character
## Mean : 15.94 Mean : 5.062e+05
## 3rd Qu.: 21.00 3rd Qu.: 5.940e+06
## Max. : 5863.00 Max. : 2.080e+10
##
## clearing_date year_month estado_cartera bucket_mora
## Min. :2022-01-03 Length:341027 Length:341027 Al dia :111154
## 1st Qu.:2022-12-29 Class :character Class :character 1-30 :173943
## Median :2023-12-18 Mode :character Mode :character 31-60 : 32091
## Mean :2024-01-20 61-90 : 9629
## 3rd Qu.:2025-01-24 91-180 : 9867
## Max. :2026-04-01 181-360: 3432
## NA's :11974 >360 : 911
# ── 1.7 Resumen rápido con skimr ──────────────────────────────────────────────
sub_sep("RESUMEN SKIMR")
##
## ── RESUMEN SKIMR ──
print(skim(df))
## ── Data Summary ────────────────────────
## Values
## Name df
## Number of rows 341027
## Number of columns 14
## _______________________
## Column type frequency:
## character 7
## Date 4
## factor 1
## numeric 2
## ________________________
## Group variables None
##
## ── Variable type: character ────────────────────────────────────────────────────
## skim_variable n_missing complete_rate min max empty n_unique whitespace
## 1 anon_customer_id 0 1 6 8 0 319 0
## 2 anon_document_id 0 1 10 10 0 10000 0
## 3 terms_of_payment 58231 0.829 4 4 0 36 0
## 4 document_type 0 1 2 2 0 12 0
## 5 reason_code 305226 0.105 2 3 0 18 0
## 6 year_month 0 1 7 7 0 51 0
## 7 estado_cartera 0 1 7 7 0 2 0
##
## ── Variable type: Date ─────────────────────────────────────────────────────────
## skim_variable n_missing complete_rate min max median
## 1 document_date 0 1 2022-01-03 2026-03-28 2023-12-10
## 2 payment_date 0 1 2007-08-17 4025-04-22 2023-12-19
## 3 net_due_date 0 1 2007-08-17 4025-04-22 2023-12-22
## 4 clearing_date 11974 0.965 2022-01-03 2026-04-01 2023-12-18
## n_unique
## 1 1416
## 2 1640
## 3 1640
## 4 1172
##
## ── Variable type: factor ───────────────────────────────────────────────────────
## skim_variable n_missing complete_rate ordered n_unique
## 1 bucket_mora 0 1 TRUE 7
## top_counts
## 1 1-3: 173943, Al : 111154, 31-: 32091, 91-: 9867
##
## ── Variable type: numeric ──────────────────────────────────────────────────────
## skim_variable n_missing complete_rate mean sd p0
## 1 arrears 0 1 15.9 135. -28308
## 2 amount 0 1 506197. 148316687. -20800206706.
## p25 p50 p75 p100 hist
## 1 0 6 21 5863 ▁▁▁▁▇
## 2 -459152. 289884 5939778. 20800206706. ▁▁▇▁▁
# ── 1.8 Valores faltantes ─────────────────────────────────────────────────────
sub_sep("VALORES FALTANTES POR VARIABLE")
##
## ── VALORES FALTANTES POR VARIABLE ──
faltantes <- df %>%
summarise(across(everything(), ~ sum(is.na(.)))) %>%
pivot_longer(everything(), names_to="Variable", values_to="N_Faltantes") %>%
mutate(
Total = nrow(df),
Pct = round(N_Faltantes / Total * 100, 2),
Semaforo = case_when(
Pct == 0 ~ "✓ Sin faltantes",
Pct <= 5 ~ "▲ Bajo (<5%)",
Pct <= 20 ~ "● Moderado (5-20%)",
TRUE ~ "✖ Alto (>20%)"
)
) %>%
arrange(desc(Pct))
imprimir_tabla(faltantes)
## # A tibble: 14 × 5
## Variable N_Faltantes Total Pct Semaforo
## <chr> <int> <int> <dbl> <chr>
## 1 reason_code 305226 341027 89.5 ✖ Alto (>20%)
## 2 terms_of_payment 58231 341027 17.1 ● Moderado (5-20%)
## 3 clearing_date 11974 341027 3.51 ▲ Bajo (<5%)
## 4 anon_customer_id 0 341027 0 ✓ Sin faltantes
## 5 anon_document_id 0 341027 0 ✓ Sin faltantes
## 6 document_type 0 341027 0 ✓ Sin faltantes
## 7 document_date 0 341027 0 ✓ Sin faltantes
## 8 payment_date 0 341027 0 ✓ Sin faltantes
## 9 net_due_date 0 341027 0 ✓ Sin faltantes
## 10 arrears 0 341027 0 ✓ Sin faltantes
## 11 amount 0 341027 0 ✓ Sin faltantes
## 12 year_month 0 341027 0 ✓ Sin faltantes
## 13 estado_cartera 0 341027 0 ✓ Sin faltantes
## 14 bucket_mora 0 341027 0 ✓ Sin faltantes
cat("\n Total celdas faltantes :", formatC(sum(faltantes$N_Faltantes), big.mark=","), "\n")
##
## Total celdas faltantes : 375,431
cat(" Total celdas en base :", formatC(nrow(df) * ncol(df), big.mark=","), "\n")
## Total celdas en base : 4,774,378
cat(" % faltantes global :",
round(sum(faltantes$N_Faltantes) / (nrow(df)*ncol(df)) * 100, 2), "%\n")
## % faltantes global : 7.86 %
# Gráfico de faltantes
p_faltantes <- faltantes %>% filter(Pct > 0) %>%
ggplot(aes(x = reorder(Variable, Pct), y = Pct)) +
geom_col(fill = C2, alpha = 0.85) +
geom_text(aes(label = paste0(Pct, "%")), hjust = -0.1, size = 3.5) +
coord_flip() +
scale_y_continuous(expand = expansion(mult = c(0, 0.15))) +
labs(title = "Porcentaje de Valores Faltantes por Variable",
x = NULL, y = "% Faltantes") +
tema
print(p_faltantes)

# ── 1.9 Duplicados ────────────────────────────────────────────────────────────
sub_sep("REGISTROS DUPLICADOS")
##
## ── REGISTROS DUPLICADOS ──
n_dup_filas <- sum(duplicated(df))
n_dup_docs <- df %>% count(anon_document_id) %>% filter(n > 1) %>% nrow()
cat(" Filas completamente duplicadas :", n_dup_filas, "\n")
## Filas completamente duplicadas : 3405
cat(" Document IDs con más de 1 fila :", n_dup_docs, "\n")
## Document IDs con más de 1 fila : 10000
top_docs_dup <- df %>% count(anon_document_id, sort=TRUE) %>%
filter(n > 1) %>% slice_head(n=10)
if (nrow(top_docs_dup) > 0) {
cat("\n Top 10 document IDs más repetidos:\n")
print(top_docs_dup)
}
##
## Top 10 document IDs más repetidos:
## # A tibble: 10 × 2
## anon_document_id n
## <chr> <int>
## 1 XXXXXX2145 236
## 2 XXXXXX5881 223
## 3 XXXXXX3880 222
## 4 XXXXXX1107 220
## 5 XXXXXX1544 218
## 6 XXXXXX3807 217
## 7 XXXXXX0617 212
## 8 XXXXXX2836 212
## 9 XXXXXX5331 208
## 10 XXXXXX1914 197
# ── 1.10 Estadísticas descriptivas globales para numéricas ───────────────────
sub_sep("ESTADÍSTICAS DESCRIPTIVAS GLOBALES — VARIABLES NUMÉRICAS")
##
## ── ESTADÍSTICAS DESCRIPTIVAS GLOBALES — VARIABLES NUMÉRICAS ──
tab_num_global <- df %>%
select(arrears, amount) %>%
pivot_longer(everything(), names_to="Variable", values_to="Valor") %>%
group_by(Variable) %>%
summarise(
N = sum(!is.na(Valor)),
Faltantes = sum(is.na(Valor)),
Media = round(mean(Valor, na.rm=TRUE), 2),
Mediana = round(median(Valor, na.rm=TRUE), 2),
Moda = moda_fn(Valor),
Min = round(min(Valor, na.rm=TRUE), 2),
Max = round(max(Valor, na.rm=TRUE), 2),
Rango = round(max(Valor, na.rm=TRUE)-min(Valor, na.rm=TRUE), 2),
DesvStd = round(sd(Valor, na.rm=TRUE), 2),
CV_pct = cv_fn(Valor),
Asimetria = round(skewness(Valor, na.rm=TRUE), 3),
Curtosis = round(kurtosis(Valor, na.rm=TRUE), 3),
N_Outliers = length(outliers_iqr(Valor)),
.groups = "drop"
)
imprimir_tabla(tab_num_global)
## # A tibble: 2 × 14
## Variable N Faltantes Media Mediana Moda Min Max Rango
## <chr> <int> <int> <dbl> <dbl> <chr> <dbl> <dbl> <dbl>
## 1 amount 341027 0 506197. 289884 0 -20800206706. 2.08e10 4.16e10
## 2 arrears 341027 0 15.9 6 0 -28308 5.86e 3 3.42e 4
## # ℹ 5 more variables: DesvStd <dbl>, CV_pct <dbl>, Asimetria <dbl>,
## # Curtosis <dbl>, N_Outliers <int>
# ── 1.11 Tablas de frecuencias globales para categóricas ─────────────────────
sub_sep("TABLAS DE FRECUENCIAS GLOBALES — VARIABLES CATEGÓRICAS")
##
## ── TABLAS DE FRECUENCIAS GLOBALES — VARIABLES CATEGÓRICAS ──
vars_cat <- c("terms_of_payment","document_type","estado_cartera",
"year_month","bucket_mora","reason_code")
for (v in vars_cat) {
cat("\n [", v, "]\n", sep="")
tab <- df %>%
count(.data[[v]], name="Freq_Abs") %>%
rename(Categoria = 1) %>%
mutate(Categoria = as.character(Categoria),
Freq_Rel = round(Freq_Abs/sum(Freq_Abs)*100, 2),
Freq_Acum = cumsum(Freq_Rel)) %>%
arrange(desc(Freq_Abs))
print(head(tab, 15), n=15)
}
##
## [terms_of_payment]
## # A tibble: 15 × 4
## Categoria Freq_Abs Freq_Rel Freq_Acum
## <chr> <int> <dbl> <dbl>
## 1 Z914 122203 35.8 82.9
## 2 Z522 62352 18.3 30.0
## 3 <NA> 58231 17.1 100.0
## 4 Z526 29496 8.65 39.2
## 5 Z521 24874 7.29 11.7
## 6 Z913 19918 5.84 47.1
## 7 Z000 13488 3.96 4.08
## 8 Z540 4845 1.42 40.6
## 9 Z525 1947 0.57 30.6
## 10 Z672 1466 0.43 41.1
## 11 Z691 422 0.12 41.2
## 12 Z040 356 0.1 4.18
## 13 Z505 329 0.1 4.4
## 14 Z090 272 0.08 4.26
## 15 B045 215 0.06 0.08
##
## [document_type]
## # A tibble: 12 × 4
## Categoria Freq_Abs Freq_Rel Freq_Acum
## <chr> <int> <dbl> <dbl>
## 1 RV 141669 41.5 95.2
## 2 DZ 85530 25.1 39.1
## 3 NC 34420 10.1 49.2
## 4 DA 28093 8.24 14.0
## 5 AB 16506 4.84 4.84
## 6 ZV 16415 4.81 100.0
## 7 RU 15260 4.47 53.6
## 8 CC 3039 0.89 5.73
## 9 DR 62 0.02 14
## 10 DG 18 0.01 14.0
## 11 SA 11 0 95.2
## 12 ND 4 0 49.2
##
## [estado_cartera]
## # A tibble: 2 × 4
## Categoria Freq_Abs Freq_Rel Freq_Acum
## <chr> <int> <dbl> <dbl>
## 1 Cerrada 334952 98.2 100
## 2 Abierta 6075 1.78 1.78
##
## [year_month]
## # A tibble: 15 × 4
## Categoria Freq_Abs Freq_Rel Freq_Acum
## <chr> <int> <dbl> <dbl>
## 1 2026/03 10750 3.15 100.0
## 2 2023/09 8950 2.62 45.0
## 3 2022/06 8793 2.58 13.1
## 4 2022/09 8688 2.55 19.8
## 5 2023/12 8671 2.54 51.5
## 6 2022/03 8231 2.41 6.23
## 7 2023/06 8215 2.41 38.4
## 8 2022/12 8096 2.37 26.1
## 9 2022/05 7945 2.33 10.5
## 10 2023/03 7900 2.32 32.1
## 11 2025/09 7330 2.15 88.3
## 12 2024/09 7285 2.14 68.1
## 13 2024/06 7274 2.13 62.9
## 14 2024/03 7174 2.1 57.2
## 15 2022/08 7134 2.09 17.2
##
## [bucket_mora]
## # A tibble: 7 × 4
## Categoria Freq_Abs Freq_Rel Freq_Acum
## <chr> <int> <dbl> <dbl>
## 1 1-30 173943 51.0 83.6
## 2 Al dia 111154 32.6 32.6
## 3 31-60 32091 9.41 93.0
## 4 91-180 9867 2.89 98.7
## 5 61-90 9629 2.82 95.8
## 6 181-360 3432 1.01 99.7
## 7 >360 911 0.27 100
##
## [reason_code]
## # A tibble: 15 × 4
## Categoria Freq_Abs Freq_Rel Freq_Acum
## <chr> <int> <dbl> <dbl>
## 1 <NA> 305226 89.5 100.
## 2 50 11999 3.52 5.08
## 3 81 9942 2.92 10.5
## 4 62 7207 2.11 7.34
## 5 21 2171 0.64 0.85
## 6 403 2037 0.6 1.49
## 7 12 700 0.21 0.21
## 8 59 495 0.15 5.23
## 9 76 343 0.1 7.58
## 10 44 247 0.07 1.56
## 11 72 208 0.06 7.48
## 12 71 198 0.06 7.42
## 13 22 72 0.02 0.87
## 14 70 59 0.02 7.36
## 15 90 58 0.02 10.5
# ═══════════════════════════════════════════════════════════════════════════════
# PARTE 2 ▸ ANÁLISIS INDIVIDUAL POR VARIABLE (orden original de la base)
# ═══════════════════════════════════════════════════════════════════════════════
separador("PARTE 2 — ANÁLISIS INDIVIDUAL POR VARIABLE")
##
## ═════════════════════════════════════════════════════════════════
## PARTE 2 — ANÁLISIS INDIVIDUAL POR VARIABLE
## ═════════════════════════════════════════════════════════════════
# ─────────────────────────────────────────────────────────────────────────────
# Función genérica: análisis de variable IDENTIFICADOR / ID
# ─────────────────────────────────────────────────────────────────────────────
analizar_id <- function(df, var, numero) {
separador(paste0("VARIABLE ", numero, ": ", var, " [IDENTIFICADOR]"))
vec <- df[[var]]
cat(" Tipo en R :", class(vec)[1], "\n")
cat(" Tipo analítico : Identificador / Código único\n\n")
n_tot <- length(vec)
n_na <- sum(is.na(vec))
n_uni <- n_distinct(vec, na.rm=TRUE)
n_dup <- n_tot - n_uni
pct_u <- round(n_uni/n_tot*100, 2)
cat(" Total registros :", formatC(n_tot, big.mark=","), "\n")
cat(" Valores únicos :", formatC(n_uni, big.mark=","), "\n")
cat(" Duplicados :", formatC(n_dup, big.mark=","), "\n")
cat(" Faltantes :", n_na, "\n")
cat(" % Unicidad :", pct_u, "%\n")
cat(" Uso sugerido :",
ifelse(pct_u > 95, "Clave primaria / Join key",
"Agrupador / Segmentador (no es clave única)"), "\n")
# Frecuencia de repetición
freq_rep <- df %>% count(.data[[var]], name="n") %>%
count(n, name="cantidad_de_IDs") %>%
rename(veces_repetido = n) %>% arrange(veces_repetido)
cat("\n Distribución de frecuencia de repetición:\n")
print(head(freq_rep, 10))
# Top 15 más frecuentes
top15 <- df %>% count(.data[[var]], name="Registros", sort=TRUE) %>%
slice_head(n=15) %>% rename(ID=1)
cat("\n Top 15 valores más frecuentes:\n")
print(top15)
# Gráfico
p <- top15 %>%
ggplot(aes(x=reorder(ID, Registros), y=Registros)) +
geom_col(fill=C1, alpha=0.85) +
geom_text(aes(label=comma(Registros)), hjust=-0.1, size=3) +
coord_flip() +
scale_y_continuous(labels=comma, expand=expansion(mult=c(0,.15))) +
labs(title = paste("Top 15 —", var),
subtitle = paste("Total valores únicos:", formatC(n_uni, big.mark=",")),
x=NULL, y="Registros") +
tema
print(p)
}
# ─────────────────────────────────────────────────────────────────────────────
# Función genérica: análisis de variable CATEGÓRICA
# ─────────────────────────────────────────────────────────────────────────────
analizar_categorica <- function(df, var, numero, top_n=15) {
separador(paste0("VARIABLE ", numero, ": ", var, " [CATEGÓRICA]"))
vec <- as.character(df[[var]])
cat(" Tipo en R :", class(df[[var]])[1], "\n")
cat(" Tipo analítico : Cualitativa nominal/ordinal\n\n")
n_tot <- length(vec)
n_na <- sum(is.na(vec) | vec == "")
n_cat <- n_distinct(vec[!is.na(vec) & vec != ""])
tab <- sort(table(vec[!is.na(vec) & vec != ""]), decreasing=TRUE)
cat(" Registros totales :", formatC(n_tot, big.mark=","), "\n")
cat(" Valores faltantes :", n_na, "(", round(n_na/n_tot*100,2), "%)\n")
cat(" Categorías únicas :", n_cat, "\n")
cat(" Categoría más frec.:", names(tab)[1], "→", tab[1], "registros\n")
cat(" Categoría menos fr.:", names(tab)[length(tab)],
"→", tab[length(tab)], "registros\n")
# Tabla de frecuencias
freq_tabla <- tibble(
Categoria = names(tab),
Freq_Abs = as.integer(tab),
Freq_Rel = round(as.integer(tab)/sum(as.integer(tab))*100, 2),
Freq_Acum = cumsum(round(as.integer(tab)/sum(as.integer(tab))*100, 2))
)
cat("\n Tabla de frecuencias (top", min(top_n, nrow(freq_tabla)), "):\n")
print(head(freq_tabla, top_n))
cat("\n Categorías raras (<1% del total):",
sum(freq_tabla$Freq_Rel < 1), "\n")
# Gráfico de barras
top_df <- head(freq_tabla, top_n)
p_bar <- ggplot(top_df, aes(x=reorder(Categoria, Freq_Abs), y=Freq_Abs)) +
geom_col(fill=C1, alpha=0.85) +
geom_text(aes(label=paste0(Freq_Rel, "%")), hjust=-0.1, size=3.2) +
coord_flip() +
scale_y_continuous(labels=comma, expand=expansion(mult=c(0,.18))) +
labs(title = paste("Frecuencia:", var),
subtitle = paste("Top", min(top_n,n_cat), "de", n_cat, "categorías"),
x=NULL, y="Frecuencia absoluta") +
tema
# Gráfico circular (solo si ≤ 10 categorías)
if (n_cat <= 10) {
p_pie <- top_df %>%
mutate(Categoria = factor(Categoria, levels=rev(Categoria))) %>%
ggplot(aes(x="", y=Freq_Rel, fill=Categoria)) +
geom_col(width=1, color="white") +
coord_polar("y") +
scale_fill_manual(values=PALETA[seq_len(min(n_cat,14))]) +
geom_text(aes(label=paste0(Freq_Rel,"%")),
position=position_stack(vjust=0.5), size=3, color="white") +
labs(title=paste("Distribución:", var), x=NULL, y=NULL) +
theme_void(base_size=11) +
theme(plot.title=element_text(face="bold",color=C1,size=12),
legend.position="right")
print(p_bar / p_pie)
} else {
print(p_bar)
}
}
# ─────────────────────────────────────────────────────────────────────────────
# Función genérica: análisis de variable FECHA
# ─────────────────────────────────────────────────────────────────────────────
analizar_fecha <- function(df, var, numero) {
separador(paste0("VARIABLE ", numero, ": ", var, " [FECHA]"))
vec <- df[[var]]
cat(" Tipo en R :", class(vec)[1], "\n")
cat(" Tipo analítico : Variable de fecha/tiempo\n\n")
n_na <- sum(is.na(vec))
n_val <- sum(!is.na(vec))
f_min <- min(vec, na.rm=TRUE)
f_max <- max(vec, na.rm=TRUE)
rango_dias <- as.numeric(f_max - f_min)
cat(" Registros válidos :", formatC(n_val, big.mark=","), "\n")
cat(" Faltantes :", formatC(n_na, big.mark=","),
"(", round(n_na/length(vec)*100,2), "%)\n")
cat(" Fecha mínima :", as.character(f_min), "\n")
cat(" Fecha máxima :", as.character(f_max), "\n")
cat(" Rango en días :", formatC(rango_dias, big.mark=","), "\n")
cat(" Rango en años :", round(rango_dias/365, 1), "\n")
# Fechas fuera de rango razonable
n_muy_ant <- sum(vec < as.Date("2000-01-01"), na.rm=TRUE)
n_futuras <- sum(vec > Sys.Date() + 730, na.rm=TRUE)
cat(" Fechas antes 2000 :", n_muy_ant, "\n")
cat(" Fechas > hoy+2años :", n_futuras, "\n")
# Registros por año
df_anio <- df %>% filter(!is.na(.data[[var]])) %>%
mutate(Anio = year(.data[[var]])) %>% count(Anio)
cat("\n Registros por año:\n"); print(df_anio, n=Inf)
# Registros por mes del año (agregado)
df_mes <- df %>% filter(!is.na(.data[[var]])) %>%
mutate(Mes = month(.data[[var]], label=TRUE, abbr=FALSE)) %>%
count(Mes)
cat("\n Registros por mes (agregado de todos los años):\n")
print(df_mes, n=Inf)
# Gráfico: registros por año
p_anio <- ggplot(df_anio, aes(x=factor(Anio), y=n)) +
geom_col(fill=C1, alpha=0.85) +
geom_text(aes(label=comma(n)), vjust=-0.3, size=3) +
scale_y_continuous(labels=comma, expand=expansion(mult=c(0,.12))) +
labs(title=paste("Registros por año:", var), x="Año", y="Registros") +
tema + theme(axis.text.x=element_text(angle=45,hjust=1))
# Gráfico: tendencia mensual (últimos 5 años)
anio_corte <- year(Sys.Date()) - 5
df_mensual <- df %>%
filter(!is.na(.data[[var]]), year(.data[[var]]) >= anio_corte) %>%
mutate(Mes = floor_date(.data[[var]], "month")) %>%
count(Mes)
p_mensual <- ggplot(df_mensual, aes(x=Mes, y=n)) +
geom_line(color=C1, linewidth=1) +
geom_point(color=C2, size=1.5) +
scale_x_date(date_breaks="3 months", date_labels="%b %Y") +
scale_y_continuous(labels=comma) +
labs(title = paste("Tendencia mensual:", var),
subtitle = paste("Últimos 5 años (desde", anio_corte, ")"),
x=NULL, y="Registros") +
tema + theme(axis.text.x=element_text(angle=45,hjust=1))
print(p_anio / p_mensual)
}
# ─────────────────────────────────────────────────────────────────────────────
# Función genérica: análisis de variable NUMÉRICA
# ─────────────────────────────────────────────────────────────────────────────
analizar_numerica <- function(df, var, numero, nombre_largo=NULL) {
etiq <- if (!is.null(nombre_largo)) nombre_largo else var
separador(paste0("VARIABLE ", numero, ": ", var, " [NUMÉRICA]"))
vec <- df[[var]]
cat(" Tipo en R :", class(vec)[1], "\n")
cat(" Tipo analítico : Cuantitativa continua\n\n")
# ── Estadísticas descriptivas ──────────────────────────────────────────────
n_val <- sum(!is.na(vec)); n_na <- sum(is.na(vec))
media <- mean(vec, na.rm=TRUE); med <- median(vec, na.rm=TRUE)
moda_v <- moda_fn(vec)
mn <- min(vec, na.rm=TRUE); mx <- max(vec, na.rm=TRUE)
rng <- mx - mn
q1 <- quantile(vec,.25,na.rm=TRUE); q3 <- quantile(vec,.75,na.rm=TRUE)
iqr_v <- q3 - q1
desv <- sd(vec, na.rm=TRUE); varz <- var(vec, na.rm=TRUE)
cv <- if (media != 0) cv_fn(vec) else NA
asim <- skewness(vec, na.rm=TRUE); kurt <- kurtosis(vec, na.rm=TRUE)
p5 <- quantile(vec,.05,na.rm=TRUE); p10 <- quantile(vec,.10,na.rm=TRUE)
p90 <- quantile(vec,.90,na.rm=TRUE); p95 <- quantile(vec,.95,na.rm=TRUE)
p99 <- quantile(vec,.99,na.rm=TRUE)
out_v <- outliers_iqr(vec)
cat(" ─ CONTEO ─────────────────────────────────────────────\n")
cat(" Registros válidos :", formatC(n_val, big.mark=","), "\n")
cat(" Valores faltantes :", n_na, "(", round(n_na/length(vec)*100,2), "%)\n")
cat("\n ─ TENDENCIA CENTRAL ──────────────────────────────────\n")
cat(" Media :", formatC(round(media,2), big.mark=","), "\n")
cat(" Mediana :", formatC(round(med,2), big.mark=","), "\n")
cat(" Moda :", moda_v, "\n")
cat("\n ─ DISPERSIÓN ─────────────────────────────────────────\n")
cat(" Mínimo :", formatC(round(mn,2), big.mark=","), "\n")
cat(" Máximo :", formatC(round(mx,2), big.mark=","), "\n")
cat(" Rango :", formatC(round(rng,2), big.mark=","), "\n")
cat(" Varianza :", formatC(round(varz,2), big.mark=","), "\n")
cat(" Desv. estándar :", formatC(round(desv,2), big.mark=","), "\n")
cat(" Coef. variación :", if(!is.na(cv)) paste0(cv, "%") else "N/A", "\n")
cat(" IQR (Q3-Q1) :", formatC(round(iqr_v,2), big.mark=","), "\n")
cat("\n ─ CUARTILES Y PERCENTILES ────────────────────────────\n")
cat(" P5 :", formatC(round(p5,2), big.mark=","), "\n")
cat(" P10 :", formatC(round(p10,2), big.mark=","), "\n")
cat(" Q1 (P25) :", formatC(round(q1,2), big.mark=","), "\n")
cat(" Mediana (P50) :", formatC(round(med,2), big.mark=","), "\n")
cat(" Q3 (P75) :", formatC(round(q3,2), big.mark=","), "\n")
cat(" P90 :", formatC(round(p90,2), big.mark=","), "\n")
cat(" P95 :", formatC(round(p95,2), big.mark=","), "\n")
cat(" P99 :", formatC(round(p99,2), big.mark=","), "\n")
cat("\n ─ FORMA DE LA DISTRIBUCIÓN ───────────────────────────\n")
cat(" Asimetría :", round(asim,3), "\n")
cat(" Curtosis :", round(kurt,3), "\n")
cat(" Interpretación :", case_when(
abs(asim) < 0.5 ~ "Distribución aproximadamente simétrica",
asim > 0.5 ~ "Sesgo positivo (cola derecha): valores altos extremos",
asim < -0.5 ~ "Sesgo negativo (cola izquierda): valores bajos extremos"
), "\n")
cat(" Curtosis interp. :", case_when(
kurt > 3 ~ "Leptocúrtica: colas pesadas, más outliers que normal",
kurt < 3 ~ "Platicúrtica: colas ligeras, menos outliers",
TRUE ~ "Mesocúrtica: similar a distribución normal"
), "\n")
cat("\n ─ OUTLIERS IQR ───────────────────────────────────────\n")
lims <- lims_iqr(vec)
cat(" Límite inferior IQR:", formatC(round(lims$inf,2), big.mark=","), "\n")
cat(" Límite superior IQR:", formatC(round(lims$sup,2), big.mark=","), "\n")
cat(" N° outliers :", formatC(length(out_v), big.mark=","), "\n")
cat(" % de outliers :", round(length(out_v)/n_val*100,2), "%\n")
if (length(out_v) > 0) {
cat(" Resumen outliers : Min=", round(min(out_v),2),
"| Max=", round(max(out_v),2), "\n")
}
# ── Gráficos ──────────────────────────────────────────────────────────────
vec_plot <- vec[!is.na(vec) & vec >= lims$inf & vec <= lims$sup]
# Histograma
p_hist <- ggplot(data.frame(x=vec_plot), aes(x=x)) +
geom_histogram(fill=C1, color="white", bins=50, alpha=0.85) +
geom_vline(xintercept=media, color=C2, linetype="dashed", linewidth=1) +
geom_vline(xintercept=med, color=C3, linetype="dashed", linewidth=1) +
scale_x_continuous(labels=comma) +
scale_y_continuous(labels=comma) +
labs(title = paste("Histograma:", etiq),
subtitle = "Rojo = Media | Naranja = Mediana | Sin outliers IQR",
x=etiq, y="Frecuencia") +
tema
# Densidad
p_dens <- ggplot(data.frame(x=vec_plot), aes(x=x)) +
geom_density(fill=C1, color=C1, alpha=0.4) +
geom_vline(xintercept=media, color=C2, linetype="dashed") +
geom_vline(xintercept=med, color=C3, linetype="dashed") +
scale_x_continuous(labels=comma) +
labs(title="Densidad", x=etiq, y="Densidad") +
tema
# Boxplot completo (con outliers)
p_box <- ggplot(data.frame(x=vec), aes(y=x)) +
geom_boxplot(fill=C1, alpha=0.65,
outlier.color=C2, outlier.alpha=0.15, outlier.size=0.8) +
scale_y_continuous(labels=comma) +
labs(title = paste("Boxplot:", etiq),
subtitle = paste("Outliers IQR:", formatC(length(out_v), big.mark=",")),
y=etiq) +
tema
# Gráfico 4: distribución por rangos (deciles o percentiles únicos)
# Se usa unique() para evitar el error "breaks no son únicos" cuando
# hay muchos valores repetidos (ej: miles de ceros en arrears).
breaks_dec <- unique(quantile(vec, probs = seq(0, 1, .1), na.rm = TRUE))
if (length(breaks_dec) >= 3) {
# Hay suficientes breaks únicos → usar cut()
df_dec <- data.frame(x = vec) %>%
filter(!is.na(x)) %>%
mutate(decil = cut(x, breaks = breaks_dec, include.lowest = TRUE)) %>%
count(decil) %>%
filter(!is.na(decil))
p_dec <- ggplot(df_dec, aes(x = decil, y = n)) +
geom_col(fill = C3, alpha = 0.85, color = "white") +
scale_y_continuous(labels = comma) +
labs(title = "Distribución por rangos (deciles únicos)",
x = "Rango", y = "Registros") +
tema +
theme(axis.text.x = element_text(angle = 45, hjust = 1, size = 8))
} else {
# Pocos valores únicos (variable muy discreta) → barras simples por valor
df_dec <- data.frame(x = vec) %>%
filter(!is.na(x)) %>%
count(x) %>%
slice_head(n = 20) # máximo 20 valores distintos
p_dec <- ggplot(df_dec, aes(x = factor(x), y = n)) +
geom_col(fill = C3, alpha = 0.85, color = "white") +
scale_y_continuous(labels = comma) +
labs(title = "Distribución por valor (variable discreta)",
x = "Valor", y = "Registros") +
tema +
theme(axis.text.x = element_text(angle = 45, hjust = 1, size = 8))
}
# Panel 2×2
panel <- (p_hist | p_dens) / (p_box | p_dec)
panel <- panel + plot_annotation(
title = paste("Análisis gráfico:", etiq),
theme = theme(plot.title=element_text(face="bold",size=14,color=C1))
)
print(panel)
}
# ═══════════════════════════════════════════════════════════════════════════════
# ANÁLISIS INDIVIDUAL — VARIABLE POR VARIABLE EN ORDEN ORIGINAL
# ═══════════════════════════════════════════════════════════════════════════════
# ─────────────────────────────────────────────────────────────────────────────
# VAR 1: Anon_Customer_ID
# ─────────────────────────────────────────────────────────────────────────────
analizar_id(df, "anon_customer_id", 1)
##
## ═════════════════════════════════════════════════════════════════
## VARIABLE 1: ANON_CUSTOMER_ID [IDENTIFICADOR]
## ═════════════════════════════════════════════════════════════════
##
## Tipo en R : character
## Tipo analítico : Identificador / Código único
##
## Total registros : 341,027
## Valores únicos : 319
## Duplicados : 340,708
## Faltantes : 0
## % Unicidad : 0.09 %
## Uso sugerido : Agrupador / Segmentador (no es clave única)
##
## Distribución de frecuencia de repetición:
## # A tibble: 10 × 2
## veces_repetido cantidad_de_IDs
## <int> <int>
## 1 1 7
## 2 2 14
## 3 3 3
## 4 4 5
## 5 5 5
## 6 6 5
## 7 7 3
## 8 8 7
## 9 9 3
## 10 10 2
##
## Top 15 valores más frecuentes:
## # A tibble: 15 × 2
## ID Registros
## <chr> <int>
## 1 CUST_317 65778
## 2 CUST_95 24522
## 3 CUST_216 19360
## 4 CUST_76 9911
## 5 CUST_205 8936
## 6 CUST_70 7709
## 7 CUST_101 6612
## 8 CUST_263 6608
## 9 CUST_141 5638
## 10 CUST_270 5623
## 11 CUST_307 5400
## 12 CUST_17 5016
## 13 CUST_118 4704
## 14 CUST_233 4256
## 15 CUST_58 4012

cat("\n ─ ANÁLISIS ADICIONAL: CUSTOMER ──────────────────────────\n")
##
## ─ ANÁLISIS ADICIONAL: CUSTOMER ──────────────────────────
cat(" Clientes únicos:", n_distinct(df$anon_customer_id), "\n")
## Clientes únicos: 319
# Top clientes por monto
top_cust_monto <- df %>% filter(!is.na(amount)) %>%
group_by(anon_customer_id) %>%
summarise(Monto_Total=sum(amount,na.rm=TRUE), N=n(), .groups="drop") %>%
arrange(desc(Monto_Total)) %>% slice_head(n=15) %>%
mutate(Pct_Monto=round(Monto_Total/sum(Monto_Total)*100,2))
cat("\n Top 15 clientes por monto total:\n")
##
## Top 15 clientes por monto total:
print(top_cust_monto)
## # A tibble: 15 × 4
## anon_customer_id Monto_Total N Pct_Monto
## <chr> <dbl> <int> <dbl>
## 1 CUST_317 20676237893. 65778 16.9
## 2 CUST_218 19482750020. 1065 15.9
## 3 CUST_215 17363198631. 2093 14.2
## 4 CUST_263 10633703698. 6608 8.68
## 5 CUST_281 9744251100 3214 7.95
## 6 CUST_216 7224419079. 19360 5.89
## 7 CUST_17 5564459842. 5016 4.54
## 8 CUST_95 5492939964. 24522 4.48
## 9 CUST_141 4723907294. 5638 3.85
## 10 CUST_133 4568406870. 335 3.73
## 11 CUST_165 4457047781. 2561 3.64
## 12 CUST_76 3671116620. 9911 3
## 13 CUST_258 3027124939. 1327 2.47
## 14 CUST_203 3026880498. 2561 2.47
## 15 CUST_131 2897084242. 1500 2.36
# Concentración (curva tipo Lorenz)
df_conc <- df %>% filter(!is.na(amount)) %>%
group_by(anon_customer_id) %>%
summarise(mt=sum(amount,na.rm=TRUE),.groups="drop") %>%
arrange(desc(mt)) %>%
mutate(x=row_number()/n(), y=cumsum(mt)/sum(mt))
p_lorenz <- ggplot(df_conc, aes(x=x,y=y)) +
geom_line(color=C1, linewidth=1.2) +
geom_abline(slope=1, intercept=0, linetype="dashed", color="gray60") +
geom_vline(xintercept=.20, linetype="dotted", color=C2) +
annotate("text",x=.22,y=.05,label="20% clientes",color=C2,size=3.5,hjust=0) +
scale_x_continuous(labels=percent) +
scale_y_continuous(labels=percent) +
labs(title="Concentración de monto por cliente (curva tipo Lorenz)",
x="% acumulado de clientes", y="% acumulado de monto") +
tema
print(p_lorenz)

# ─────────────────────────────────────────────────────────────────────────────
# VAR 2: Anon_Document_ID
# ─────────────────────────────────────────────────────────────────────────────
analizar_id(df, "anon_document_id", 2)
##
## ═════════════════════════════════════════════════════════════════
## VARIABLE 2: ANON_DOCUMENT_ID [IDENTIFICADOR]
## ═════════════════════════════════════════════════════════════════
##
## Tipo en R : character
## Tipo analítico : Identificador / Código único
##
## Total registros : 341,027
## Valores únicos : 10,000
## Duplicados : 331,027
## Faltantes : 0
## % Unicidad : 2.93 %
## Uso sugerido : Agrupador / Segmentador (no es clave única)
##
## Distribución de frecuencia de repetición:
## # A tibble: 10 × 2
## veces_repetido cantidad_de_IDs
## <int> <int>
## 1 14 13
## 2 15 44
## 3 16 89
## 4 17 123
## 5 18 201
## 6 19 267
## 7 20 286
## 8 21 265
## 9 22 230
## 10 23 271
##
## Top 15 valores más frecuentes:
## # A tibble: 15 × 2
## ID Registros
## <chr> <int>
## 1 XXXXXX2145 236
## 2 XXXXXX5881 223
## 3 XXXXXX3880 222
## 4 XXXXXX1107 220
## 5 XXXXXX1544 218
## 6 XXXXXX3807 217
## 7 XXXXXX0617 212
## 8 XXXXXX2836 212
## 9 XXXXXX5331 208
## 10 XXXXXX1914 197
## 11 XXXXXX1847 191
## 12 XXXXXX3280 177
## 13 XXXXXX0089 176
## 14 XXXXXX0692 170
## 15 XXXXXX1034 169

# ─────────────────────────────────────────────────────────────────────────────
# VAR 3: Terms_of_Payment
# ─────────────────────────────────────────────────────────────────────────────
analizar_categorica(df, "terms_of_payment", 3, top_n=15)
##
## ═════════════════════════════════════════════════════════════════
## VARIABLE 3: TERMS_OF_PAYMENT [CATEGÓRICA]
## ═════════════════════════════════════════════════════════════════
##
## Tipo en R : character
## Tipo analítico : Cualitativa nominal/ordinal
##
## Registros totales : 341,027
## Valores faltantes : 58231 ( 17.08 %)
## Categorías únicas : 36
## Categoría más frec.: Z914 → 122203 registros
## Categoría menos fr.: Z721 → 1 registros
##
## Tabla de frecuencias (top 15 ):
## # A tibble: 15 × 4
## Categoria Freq_Abs Freq_Rel Freq_Acum
## <chr> <int> <dbl> <dbl>
## 1 Z914 122203 43.2 43.2
## 2 Z522 62352 22.0 65.3
## 3 Z526 29496 10.4 75.7
## 4 Z521 24874 8.8 84.5
## 5 Z913 19918 7.04 91.5
## 6 Z000 13488 4.77 96.3
## 7 Z540 4845 1.71 98.0
## 8 Z525 1947 0.69 98.7
## 9 Z672 1466 0.52 99.2
## 10 Z691 422 0.15 99.4
## 11 Z040 356 0.13 99.5
## 12 Z505 329 0.12 99.6
## 13 Z090 272 0.1 99.7
## 14 B045 215 0.08 99.8
## 15 P030 151 0.05 99.9
##
## Categorías raras (<1% del total): 29

cat("\n ─ NOTA ANALÍTICA ─────────────────────────────────────────\n")
##
## ─ NOTA ANALÍTICA ─────────────────────────────────────────
cat(" Terms_of_Payment vacía ('') puede indicar facturas sin\n")
## Terms_of_Payment vacía ('') puede indicar facturas sin
cat(" condición asignada. Revisar si corresponde a un tipo\n")
## condición asignada. Revisar si corresponde a un tipo
cat(" específico de documento (ej: abonos o notas crédito).\n")
## específico de documento (ej: abonos o notas crédito).
tab_terms_tipo <- df %>%
mutate(terms_vacia = terms_of_payment == "" | is.na(terms_of_payment)) %>%
count(document_type, terms_vacia) %>%
arrange(document_type, terms_vacia)
cat("\n Terms_of_Payment vacía por Document_Type:\n")
##
## Terms_of_Payment vacía por Document_Type:
print(tab_terms_tipo)
## # A tibble: 16 × 3
## document_type terms_vacia n
## <chr> <lgl> <int>
## 1 AB FALSE 14879
## 2 AB TRUE 1627
## 3 CC FALSE 3039
## 4 DA FALSE 23074
## 5 DA TRUE 5019
## 6 DG FALSE 18
## 7 DR FALSE 62
## 8 DZ FALSE 45256
## 9 DZ TRUE 40274
## 10 NC FALSE 34420
## 11 ND FALSE 4
## 12 RU FALSE 15260
## 13 RV FALSE 141669
## 14 SA FALSE 11
## 15 ZV FALSE 5104
## 16 ZV TRUE 11311
# ─────────────────────────────────────────────────────────────────────────────
# VAR 4: Document_Type
# ─────────────────────────────────────────────────────────────────────────────
analizar_categorica(df, "document_type", 4, top_n=15)
##
## ═════════════════════════════════════════════════════════════════
## VARIABLE 4: DOCUMENT_TYPE [CATEGÓRICA]
## ═════════════════════════════════════════════════════════════════
##
## Tipo en R : character
## Tipo analítico : Cualitativa nominal/ordinal
##
## Registros totales : 341,027
## Valores faltantes : 0 ( 0 %)
## Categorías únicas : 12
## Categoría más frec.: RV → 141669 registros
## Categoría menos fr.: ND → 4 registros
##
## Tabla de frecuencias (top 12 ):
## # A tibble: 12 × 4
## Categoria Freq_Abs Freq_Rel Freq_Acum
## <chr> <int> <dbl> <dbl>
## 1 RV 141669 41.5 41.5
## 2 DZ 85530 25.1 66.6
## 3 NC 34420 10.1 76.7
## 4 DA 28093 8.24 85.0
## 5 AB 16506 4.84 89.8
## 6 ZV 16415 4.81 94.6
## 7 RU 15260 4.47 99.1
## 8 CC 3039 0.89 100.0
## 9 DR 62 0.02 100.0
## 10 DG 18 0.01 100.0
## 11 SA 11 0 100.0
## 12 ND 4 0 100.0
##
## Categorías raras (<1% del total): 5

cat("\n ─ NOTA ANALÍTICA ─────────────────────────────────────────\n")
##
## ─ NOTA ANALÍTICA ─────────────────────────────────────────
cat(" Tipos comunes en bases SAP/I2C:\n")
## Tipos comunes en bases SAP/I2C:
cat(" DA = Débito estándar (factura)\n")
## DA = Débito estándar (factura)
cat(" AB = Abono / Nota crédito\n")
## AB = Abono / Nota crédito
cat(" DZ = Pago\n")
## DZ = Pago
cat(" KG = Nota crédito proveedor\n")
## KG = Nota crédito proveedor
# Monto promedio por tipo de documento
tab_monto_tipo <- df %>% filter(!is.na(amount)) %>%
group_by(document_type) %>%
summarise(
N=n(),
Monto_Total=round(sum(amount,na.rm=TRUE),0),
Monto_Promedio=round(mean(amount,na.rm=TRUE),0),
Monto_Mediana=round(median(amount,na.rm=TRUE),0),
.groups="drop"
) %>% arrange(desc(N))
cat("\n Monto por tipo de documento:\n")
##
## Monto por tipo de documento:
print(tab_monto_tipo)
## # A tibble: 12 × 5
## document_type N Monto_Total Monto_Promedio Monto_Mediana
## <chr> <int> <dbl> <dbl> <dbl>
## 1 RV 141669 3.28e12 23118025 6131832
## 2 DZ 85530 -2.66e12 -31103126 12000
## 3 NC 34420 -1.98e11 -5762334 -372867
## 4 DA 28093 1.31e11 4673336 7250
## 5 AB 16506 -5.75e 9 -348472 22591
## 6 ZV 16415 -2.83e11 -17227663 -4057161
## 7 RU 15260 -1.66e10 -1089410 -269124
## 8 CC 3039 -7.18e10 -23616576 -5425696
## 9 DR 62 1.43e 9 23022870 2565299
## 10 DG 18 -1.49e 8 -8256665 -2731239
## 11 SA 11 4.79e 8 43508945 53582052
## 12 ND 4 2.97e 6 742010 1484019
# ─────────────────────────────────────────────────────────────────────────────
# VAR 5: Document_Date
# ─────────────────────────────────────────────────────────────────────────────
analizar_fecha(df, "document_date", 5)
##
## ═════════════════════════════════════════════════════════════════
## VARIABLE 5: DOCUMENT_DATE [FECHA]
## ═════════════════════════════════════════════════════════════════
##
## Tipo en R : Date
## Tipo analítico : Variable de fecha/tiempo
##
## Registros válidos : 341,027
## Faltantes : 0 ( 0 %)
## Fecha mínima : 2022-01-03
## Fecha máxima : 2026-03-28
## Rango en días : 1,545
## Rango en años : 4.2
## Fechas antes 2000 : 0
## Fechas > hoy+2años : 0
##
## Registros por año:
## # A tibble: 5 × 2
## Anio n
## <dbl> <int>
## 1 2022 89113
## 2 2023 86797
## 3 2024 73546
## 4 2025 69380
## 5 2026 22191
##
## Registros por mes (agregado de todos los años):
## # A tibble: 12 × 2
## Mes n
## <ord> <int>
## 1 enero 30024
## 2 febrero 34189
## 3 marzo 36199
## 4 abril 25342
## 5 mayo 28335
## 6 junio 27057
## 7 julio 26664
## 8 agosto 27734
## 9 septiembre 29484
## 10 octubre 25967
## 11 noviembre 24725
## 12 diciembre 25307

# ─────────────────────────────────────────────────────────────────────────────
# VAR 6: Payment_date
# ─────────────────────────────────────────────────────────────────────────────
analizar_fecha(df, "payment_date", 6)
##
## ═════════════════════════════════════════════════════════════════
## VARIABLE 6: PAYMENT_DATE [FECHA]
## ═════════════════════════════════════════════════════════════════
##
## Tipo en R : Date
## Tipo analítico : Variable de fecha/tiempo
##
## Registros válidos : 341,027
## Faltantes : 0 ( 0 %)
## Fecha mínima : 2007-08-17
## Fecha máxima : 4025-04-22
## Rango en días : 7.369e+05
## Rango en años : 2019
## Fechas antes 2000 : 0
## Fechas > hoy+2años : 7
##
## Registros por año:
## # A tibble: 15 × 2
## Anio n
## <dbl> <int>
## 1 2007 1
## 2 2013 4
## 3 2017 5
## 4 2018 17
## 5 2019 2
## 6 2020 1
## 7 2021 27
## 8 2022 86867
## 9 2023 86691
## 10 2024 73162
## 11 2025 69321
## 12 2026 24922
## 13 2202 3
## 14 2204 1
## 15 4025 3
##
## Registros por mes (agregado de todos los años):
## # A tibble: 12 × 2
## Mes n
## <ord> <int>
## 1 enero 25793
## 2 febrero 28972
## 3 marzo 35418
## 4 abril 31990
## 5 mayo 26957
## 6 junio 27870
## 7 julio 27566
## 8 agosto 27492
## 9 septiembre 28213
## 10 octubre 26906
## 11 noviembre 25521
## 12 diciembre 28329

cat("\n ─ ANÁLISIS ADICIONAL ─────────────────────────────────────\n")
##
## ─ ANÁLISIS ADICIONAL ─────────────────────────────────────
# Diferencia entre document_date y payment_date
df_dif_pago <- df %>%
filter(!is.na(document_date), !is.na(payment_date)) %>%
mutate(dias_hasta_pago = as.numeric(payment_date - document_date))
cat(" Días desde emisión hasta pago:\n")
## Días desde emisión hasta pago:
cat(" Media :", round(mean(df_dif_pago$dias_hasta_pago,na.rm=TRUE),1), "\n")
## Media : 22.8
cat(" Mediana :", round(median(df_dif_pago$dias_hasta_pago,na.rm=TRUE),1), "\n")
## Mediana : 3
cat(" Min :", min(df_dif_pago$dias_hasta_pago,na.rm=TRUE), "\n")
## Min : -5705
cat(" Max :", max(df_dif_pago$dias_hasta_pago,na.rm=TRUE), "\n")
## Max : 730534
cat(" Pagos antes de emisión (días negativos):",
sum(df_dif_pago$dias_hasta_pago < 0, na.rm=TRUE), "\n")
## Pagos antes de emisión (días negativos): 23132
# ─────────────────────────────────────────────────────────────────────────────
# VAR 7: Net_due_date
# ─────────────────────────────────────────────────────────────────────────────
analizar_fecha(df, "net_due_date", 7)
##
## ═════════════════════════════════════════════════════════════════
## VARIABLE 7: NET_DUE_DATE [FECHA]
## ═════════════════════════════════════════════════════════════════
##
## Tipo en R : Date
## Tipo analítico : Variable de fecha/tiempo
##
## Registros válidos : 341,027
## Faltantes : 0 ( 0 %)
## Fecha mínima : 2007-08-17
## Fecha máxima : 4025-04-22
## Rango en días : 7.369e+05
## Rango en años : 2019
## Fechas antes 2000 : 0
## Fechas > hoy+2años : 7
##
## Registros por año:
## # A tibble: 15 × 2
## Anio n
## <dbl> <int>
## 1 2007 1
## 2 2013 4
## 3 2017 5
## 4 2018 17
## 5 2019 2
## 6 2020 1
## 7 2021 26
## 8 2022 86089
## 9 2023 86387
## 10 2024 73565
## 11 2025 69338
## 12 2026 25585
## 13 2202 3
## 14 2204 1
## 15 4025 3
##
## Registros por mes (agregado de todos los años):
## # A tibble: 12 × 2
## Mes n
## <ord> <int>
## 1 enero 27446
## 2 febrero 26362
## 3 marzo 35394
## 4 abril 32121
## 5 mayo 27831
## 6 junio 27348
## 7 julio 27666
## 8 agosto 27621
## 9 septiembre 27787
## 10 octubre 27320
## 11 noviembre 25558
## 12 diciembre 28573

cat("\n ─ ANÁLISIS ADICIONAL ─────────────────────────────────────\n")
##
## ─ ANÁLISIS ADICIONAL ─────────────────────────────────────
# Inconsistencias lógicas
n_fecha_inv <- df %>%
filter(!is.na(document_date), !is.na(net_due_date)) %>%
summarise(n = sum(net_due_date < document_date)) %>%
pull(n)
cat(" Registros donde net_due_date < document_date (error lógico):",
n_fecha_inv, "\n")
## Registros donde net_due_date < document_date (error lógico): 17710
# ─────────────────────────────────────────────────────────────────────────────
# VAR 8: Arrears_after_net_due_date → arrears
# ─────────────────────────────────────────────────────────────────────────────
analizar_numerica(df, "arrears", 8, "Arrears after net due date (días de atraso)")
##
## ═════════════════════════════════════════════════════════════════
## VARIABLE 8: ARREARS [NUMÉRICA]
## ═════════════════════════════════════════════════════════════════
##
## Tipo en R : numeric
## Tipo analítico : Cuantitativa continua
##
## ─ CONTEO ─────────────────────────────────────────────
## Registros válidos : 341,027
## Valores faltantes : 0 ( 0 %)
##
## ─ TENDENCIA CENTRAL ──────────────────────────────────
## Media : 15.94
## Mediana : 6
## Moda : 0
##
## ─ DISPERSIÓN ─────────────────────────────────────────
## Mínimo : -2.831e+04
## Máximo : 5,863
## Rango : 3.417e+04
## Varianza : 1.834e+04
## Desv. estándar : 135.4
## Coef. variación : 849.82%
## IQR (Q3-Q1) : 21
##
## ─ CUARTILES Y PERCENTILES ────────────────────────────
## P5 : -17
## P10 : -12
## Q1 (P25) : 0
## Mediana (P50) : 6
## Q3 (P75) : 21
## P90 : 45
## P95 : 78
## P99 : 197
##
## ─ FORMA DE LA DISTRIBUCIÓN ───────────────────────────
## Asimetría : -175.962
## Curtosis : 36362.8
## Interpretación : Sesgo negativo (cola izquierda): valores bajos extremos
## Curtosis interp. : Leptocúrtica: colas pesadas, más outliers que normal
##
## ─ OUTLIERS IQR ───────────────────────────────────────
## Límite inferior IQR: -31.5
## Límite superior IQR: 52.5
## N° outliers : 34,206
## % de outliers : 10.03 %
## Resumen outliers : Min= -28308 | Max= 5863

cat("\n ─ ANÁLISIS ADICIONAL ─────────────────────────────────────\n")
##
## ─ ANÁLISIS ADICIONAL ─────────────────────────────────────
cat(" Arrears ≤ 0 (pagó a tiempo o antes):",
formatC(sum(df$arrears <= 0, na.rm=TRUE), big.mark=","), "\n")
## Arrears ≤ 0 (pagó a tiempo o antes): 111,154
cat(" Arrears > 0 (pago tardío) :",
formatC(sum(df$arrears > 0, na.rm=TRUE), big.mark=","), "\n")
## Arrears > 0 (pago tardío) : 229,873
cat(" Arrears negativos (adelanto) :",
formatC(sum(df$arrears < 0, na.rm=TRUE), big.mark=","), "\n")
## Arrears negativos (adelanto) : 65,224
# Distribución por rangos de mora
tab_rango_arr <- df %>%
filter(!is.na(arrears)) %>%
mutate(Rango = case_when(
arrears < 0 ~ "Negativo (adelanto)",
arrears == 0 ~ "Cero (exacto)",
arrears <= 30 ~ "1 - 30 días",
arrears <= 60 ~ "31 - 60 días",
arrears <= 90 ~ "61 - 90 días",
arrears <= 180 ~ "91 - 180 días",
arrears <= 360 ~ "181 - 360 días",
TRUE ~ "Más de 360 días"
),
Rango = factor(Rango, levels=c("Negativo (adelanto)","Cero (exacto)",
"1 - 30 días","31 - 60 días","61 - 90 días",
"91 - 180 días","181 - 360 días","Más de 360 días"))) %>%
count(Rango) %>%
mutate(Pct=round(n/sum(n)*100,2))
cat("\n Distribución por rangos de atraso:\n")
##
## Distribución por rangos de atraso:
print(tab_rango_arr, n=Inf)
## # A tibble: 8 × 3
## Rango n Pct
## <fct> <int> <dbl>
## 1 Negativo (adelanto) 65224 19.1
## 2 Cero (exacto) 45930 13.5
## 3 1 - 30 días 173943 51.0
## 4 31 - 60 días 32091 9.41
## 5 61 - 90 días 9629 2.82
## 6 91 - 180 días 9867 2.89
## 7 181 - 360 días 3432 1.01
## 8 Más de 360 días 911 0.27
colores_arr <- c("Negativo (adelanto)"=C4, "Cero (exacto)"="#A8D5A2",
"1 - 30 días"=C3, "31 - 60 días"="#E8A930",
"61 - 90 días"="#E07020", "91 - 180 días"=C2,
"181 - 360 días"="#A00000", "Más de 360 días"="#5B0000")
p_arr_rango <- ggplot(tab_rango_arr, aes(x=Rango, y=n, fill=Rango)) +
geom_col(alpha=0.9, show.legend=FALSE) +
geom_text(aes(label=paste0(Pct,"%")), vjust=-0.3, size=3.5) +
scale_fill_manual(values=colores_arr) +
scale_y_continuous(labels=comma, expand=expansion(mult=c(0,.12))) +
labs(title="Distribución por rangos de días de atraso",
x=NULL, y="Registros") +
tema + theme(axis.text.x=element_text(angle=35,hjust=1))
print(p_arr_rango)

# ─────────────────────────────────────────────────────────────────────────────
# VAR 9: Amount_in_local_currency → amount
# ─────────────────────────────────────────────────────────────────────────────
analizar_numerica(df, "amount", 9, "Amount in local currency (monto)")
##
## ═════════════════════════════════════════════════════════════════
## VARIABLE 9: AMOUNT [NUMÉRICA]
## ═════════════════════════════════════════════════════════════════
##
## Tipo en R : numeric
## Tipo analítico : Cuantitativa continua
##
## ─ CONTEO ─────────────────────────────────────────────
## Registros válidos : 341,027
## Valores faltantes : 0 ( 0 %)
##
## ─ TENDENCIA CENTRAL ──────────────────────────────────
## Media : 5.062e+05
## Mediana : 2.899e+05
## Moda : 0
##
## ─ DISPERSIÓN ─────────────────────────────────────────
## Mínimo : -2.08e+10
## Máximo : 2.08e+10
## Rango : 4.16e+10
## Varianza : 2.2e+16
## Desv. estándar : 1.483e+08
## Coef. variación : 29300.2%
## IQR (Q3-Q1) : 6.399e+06
##
## ─ CUARTILES Y PERCENTILES ────────────────────────────
## P5 : -3.146e+07
## P10 : -9.21e+06
## Q1 (P25) : -4.592e+05
## Mediana (P50) : 2.899e+05
## Q3 (P75) : 5.94e+06
## P90 : 2.628e+07
## P95 : 5.276e+07
## P99 : 1.864e+08
##
## ─ FORMA DE LA DISTRIBUCIÓN ───────────────────────────
## Asimetría : -7.993
## Curtosis : 5362.047
## Interpretación : Sesgo negativo (cola izquierda): valores bajos extremos
## Curtosis interp. : Leptocúrtica: colas pesadas, más outliers que normal
##
## ─ OUTLIERS IQR ───────────────────────────────────────
## Límite inferior IQR: -1.006e+07
## Límite superior IQR: 1.554e+07
## N° outliers : 83,272
## % de outliers : 24.42 %
## Resumen outliers : Min= -20800206706 | Max= 20800206706

cat("\n ─ ANÁLISIS ADICIONAL ─────────────────────────────────────\n")
##
## ─ ANÁLISIS ADICIONAL ─────────────────────────────────────
n_neg <- sum(df$amount < 0, na.rm=TRUE)
n_pos <- sum(df$amount > 0, na.rm=TRUE)
n_cer <- sum(df$amount == 0, na.rm=TRUE)
cat(" Montos positivos :", formatC(n_pos, big.mark=","),
"(", round(n_pos/nrow(df)*100,2), "%)\n")
## Montos positivos : 214,710 ( 62.96 %)
cat(" Montos negativos :", formatC(n_neg, big.mark=","),
"(", round(n_neg/nrow(df)*100,2), "%) → notas crédito / abonos\n")
## Montos negativos : 124,475 ( 36.5 %) → notas crédito / abonos
cat(" Montos = 0 :", formatC(n_cer, big.mark=","), "\n")
## Montos = 0 : 1,842
cat(" Monto total neto :", formatC(round(sum(df$amount,na.rm=TRUE),0),
big.mark=","), "\n")
## Monto total neto : 1.726e+11
# Monto por estado de cartera
tab_monto_estado <- df %>% filter(!is.na(amount)) %>%
group_by(estado_cartera) %>%
summarise(
N=n(),
Monto_Total =round(sum(amount,na.rm=TRUE),0),
Monto_Prom =round(mean(amount,na.rm=TRUE),0),
Monto_Med =round(median(amount,na.rm=TRUE),0),
.groups="drop"
)
cat("\n Monto por estado de cartera:\n")
##
## Monto por estado de cartera:
print(tab_monto_estado)
## # A tibble: 2 × 5
## estado_cartera N Monto_Total Monto_Prom Monto_Med
## <chr> <int> <dbl> <dbl> <dbl>
## 1 Abierta 6075 114448362999 18839237 2097018
## 2 Cerrada 334952 58178418073 173692 269892
p_monto_estado <- ggplot(
df %>% filter(!is.na(amount), !is.na(estado_cartera)),
aes(x=estado_cartera, y=amount, fill=estado_cartera)) +
geom_boxplot(alpha=0.7, outlier.alpha=0.08, show.legend=FALSE) +
scale_fill_manual(values=c("Abierta"=C2, "Cerrada"=C4)) +
scale_y_continuous(labels=comma) +
labs(title="Distribución de monto por estado de cartera",
x=NULL, y="Monto") +
tema
print(p_monto_estado)

# ─────────────────────────────────────────────────────────────────────────────
# VAR 10: Reason_code
# ─────────────────────────────────────────────────────────────────────────────
analizar_categorica(df, "reason_code", 10, top_n=15)
##
## ═════════════════════════════════════════════════════════════════
## VARIABLE 10: REASON_CODE [CATEGÓRICA]
## ═════════════════════════════════════════════════════════════════
##
## Tipo en R : character
## Tipo analítico : Cualitativa nominal/ordinal
##
## Registros totales : 341,027
## Valores faltantes : 305226 ( 89.5 %)
## Categorías únicas : 18
## Categoría más frec.: 50 → 11999 registros
## Categoría menos fr.: 61 → 3 registros
##
## Tabla de frecuencias (top 15 ):
## # A tibble: 15 × 4
## Categoria Freq_Abs Freq_Rel Freq_Acum
## <chr> <int> <dbl> <dbl>
## 1 50 11999 33.5 33.5
## 2 81 9942 27.8 61.3
## 3 62 7207 20.1 81.4
## 4 21 2171 6.06 87.5
## 5 403 2037 5.69 93.2
## 6 12 700 1.96 95.1
## 7 59 495 1.38 96.5
## 8 76 343 0.96 97.5
## 9 44 247 0.69 98.2
## 10 72 208 0.58 98.7
## 11 71 198 0.55 99.3
## 12 22 72 0.2 99.5
## 13 70 59 0.16 99.6
## 14 90 58 0.16 99.8
## 15 31 55 0.15 100.0
##
## Categorías raras (<1% del total): 11

cat("\n ─ ANÁLISIS ADICIONAL ─────────────────────────────────────\n")
##
## ─ ANÁLISIS ADICIONAL ─────────────────────────────────────
# Monto promedio por reason_code
tab_rc_monto <- df %>% filter(!is.na(amount)) %>%
group_by(reason_code) %>%
summarise(N=n(),
Monto_Total=round(sum(amount,na.rm=TRUE),0),
Monto_Prom=round(mean(amount,na.rm=TRUE),0),
.groups="drop") %>%
arrange(desc(N)) %>% slice_head(n=10)
cat(" Monto por reason_code (top 10 más frecuentes):\n")
## Monto por reason_code (top 10 más frecuentes):
print(tab_rc_monto)
## # A tibble: 10 × 4
## reason_code N Monto_Total Monto_Prom
## <chr> <int> <dbl> <dbl>
## 1 <NA> 305226 169514303215 555373
## 2 50 11999 -109805154363 -9151192
## 3 81 9942 113246747190 11390741
## 4 62 7207 5186821008 719692
## 5 21 2171 -1004290526 -462594
## 6 403 2037 -11481240591 -5636348
## 7 12 700 151448452 216355
## 8 59 495 618991409 1250488
## 9 76 343 7998226778 23318445
## 10 44 247 -1941621185 -7860815
# ─────────────────────────────────────────────────────────────────────────────
# VAR 11: Clearing_date
# ─────────────────────────────────────────────────────────────────────────────
analizar_fecha(df, "clearing_date", 11)
##
## ═════════════════════════════════════════════════════════════════
## VARIABLE 11: CLEARING_DATE [FECHA]
## ═════════════════════════════════════════════════════════════════
##
## Tipo en R : Date
## Tipo analítico : Variable de fecha/tiempo
##
## Registros válidos : 329,053
## Faltantes : 11,974 ( 3.51 %)
## Fecha mínima : 2022-01-03
## Fecha máxima : 2026-04-01
## Rango en días : 1,549
## Rango en años : 4.2
## Fechas antes 2000 : 0
## Fechas > hoy+2años : 0
##
## Registros por año:
## # A tibble: 5 × 2
## Anio n
## <dbl> <int>
## 1 2022 83077
## 2 2023 86560
## 3 2024 73436
## 4 2025 69649
## 5 2026 16331
##
## Registros por mes (agregado de todos los años):
## # A tibble: 12 × 2
## Mes n
## <ord> <int>
## 1 enero 23892
## 2 febrero 25337
## 3 marzo 33067
## 4 abril 25995
## 5 mayo 25749
## 6 junio 29097
## 7 julio 27245
## 8 agosto 25996
## 9 septiembre 31602
## 10 octubre 25914
## 11 noviembre 24521
## 12 diciembre 30638

cat("\n ─ ANÁLISIS ADICIONAL ─────────────────────────────────────\n")
##
## ─ ANÁLISIS ADICIONAL ─────────────────────────────────────
# Clearing_date NA según estado de cartera
tab_clear_estado <- df %>%
mutate(clearing_na = is.na(clearing_date)) %>%
count(estado_cartera, clearing_na) %>%
mutate(Pct=round(n/sum(n)*100,2))
cat(" Faltantes en clearing_date por estado de cartera:\n")
## Faltantes en clearing_date por estado de cartera:
print(tab_clear_estado)
## # A tibble: 4 × 4
## estado_cartera clearing_na n Pct
## <chr> <lgl> <int> <dbl>
## 1 Abierta FALSE 88 0.03
## 2 Abierta TRUE 5987 1.76
## 3 Cerrada FALSE 328965 96.5
## 4 Cerrada TRUE 5987 1.76
cat("\n NOTA: clearing_date nula en 'Abierta' es ESPERADO\n")
##
## NOTA: clearing_date nula en 'Abierta' es ESPERADO
cat(" (no se ha compensado). Solo es problema si es 'Cerrada'.\n")
## (no se ha compensado). Solo es problema si es 'Cerrada'.
n_cerrada_sin_clearing <- df %>%
filter(estado_cartera == "Cerrada", is.na(clearing_date)) %>% nrow()
cat(" Registros Cerrados sin clearing_date:", n_cerrada_sin_clearing, "\n")
## Registros Cerrados sin clearing_date: 5987
# ─────────────────────────────────────────────────────────────────────────────
# VAR 12: Year/month → year_month
# ─────────────────────────────────────────────────────────────────────────────
separador("VARIABLE 12: year_month [CATEGÓRICA TEMPORAL]")
##
## ═════════════════════════════════════════════════════════════════
## VARIABLE 12: YEAR_MONTH [CATEGÓRICA TEMPORAL]
## ═════════════════════════════════════════════════════════════════
cat(" Tipo en R :", class(df$year_month)[1], "\n")
## Tipo en R : character
cat(" Tipo analítico : Categórica de periodo (Año/Mes)\n\n")
## Tipo analítico : Categórica de periodo (Año/Mes)
n_na_ym <- sum(is.na(df$year_month) | df$year_month == "")
n_cat_ym <- n_distinct(df$year_month, na.rm=TRUE)
cat(" Períodos únicos :", n_cat_ym, "\n")
## Períodos únicos : 51
cat(" Faltantes :", n_na_ym, "\n")
## Faltantes : 0
tab_ym <- df %>%
filter(!is.na(year_month), year_month != "") %>%
count(year_month, name="Registros") %>%
arrange(year_month) %>%
mutate(Pct=round(Registros/sum(Registros)*100,2))
cat("\n Registros por período (Year/Month):\n")
##
## Registros por período (Year/Month):
print(tab_ym, n=Inf)
## # A tibble: 51 × 3
## year_month Registros Pct
## <chr> <int> <dbl>
## 1 2022/01 6223 1.82
## 2 2022/02 6808 2
## 3 2022/03 8231 2.41
## 4 2022/04 6659 1.95
## 5 2022/05 7945 2.33
## 6 2022/06 8793 2.58
## 7 2022/07 7059 2.07
## 8 2022/08 7134 2.09
## 9 2022/09 8688 2.55
## 10 2022/10 6719 1.97
## 11 2022/11 6701 1.96
## 12 2022/12 8096 2.37
## 13 2023/01 5912 1.73
## 14 2023/02 6596 1.93
## 15 2023/03 7900 2.32
## 16 2023/04 6223 1.82
## 17 2023/05 7110 2.08
## 18 2023/06 8215 2.41
## 19 2023/07 6714 1.97
## 20 2023/08 6916 2.03
## 21 2023/09 8950 2.62
## 22 2023/10 6817 2
## 23 2023/11 6546 1.92
## 24 2023/12 8671 2.54
## 25 2024/01 5592 1.64
## 26 2024/02 6823 2
## 27 2024/03 7174 2.1
## 28 2024/04 5998 1.76
## 29 2024/05 6010 1.76
## 30 2024/06 7274 2.13
## 31 2024/07 5256 1.54
## 32 2024/08 5428 1.59
## 33 2024/09 7285 2.14
## 34 2024/10 5514 1.62
## 35 2024/11 4888 1.43
## 36 2024/12 6078 1.78
## 37 2025/01 4327 1.27
## 38 2025/02 5476 1.61
## 39 2025/03 6962 2.04
## 40 2025/04 5053 1.48
## 41 2025/05 5193 1.52
## 42 2025/06 6608 1.94
## 43 2025/07 5629 1.65
## 44 2025/08 5847 1.71
## 45 2025/09 7330 2.15
## 46 2025/10 5567 1.63
## 47 2025/11 5129 1.5
## 48 2025/12 6482 1.9
## 49 2026/01 5075 1.49
## 50 2026/02 6653 1.95
## 51 2026/03 10750 3.15
# Gráfico: tendencia por período
p_ym <- ggplot(tab_ym, aes(x=year_month, y=Registros)) +
geom_col(fill=C1, alpha=0.85) +
scale_y_continuous(labels=comma) +
labs(title="Registros por período Year/Month",
x="Período", y="Registros") +
tema + theme(axis.text.x=element_text(angle=60, hjust=1, size=7))
print(p_ym)

# Monto por período
tab_ym_monto <- df %>%
filter(!is.na(year_month), year_month != "", !is.na(amount)) %>%
group_by(year_month) %>%
summarise(Monto_Total=sum(amount,na.rm=TRUE), .groups="drop") %>%
arrange(year_month)
p_ym_monto <- ggplot(tab_ym_monto, aes(x=year_month, y=Monto_Total)) +
geom_line(aes(group=1), color=C3, linewidth=1) +
geom_point(color=C2, size=1.5) +
scale_y_continuous(labels=comma) +
labs(title="Monto total por período Year/Month",
x="Período", y="Monto total") +
tema + theme(axis.text.x=element_text(angle=60,hjust=1,size=7))
print(p_ym_monto)

# ─────────────────────────────────────────────────────────────────────────────
# VAR 13: Estado_Cartera
# ─────────────────────────────────────────────────────────────────────────────
analizar_categorica(df, "estado_cartera", 13, top_n=10)
##
## ═════════════════════════════════════════════════════════════════
## VARIABLE 13: ESTADO_CARTERA [CATEGÓRICA]
## ═════════════════════════════════════════════════════════════════
##
## Tipo en R : character
## Tipo analítico : Cualitativa nominal/ordinal
##
## Registros totales : 341,027
## Valores faltantes : 0 ( 0 %)
## Categorías únicas : 2
## Categoría más frec.: Cerrada → 334952 registros
## Categoría menos fr.: Abierta → 6075 registros
##
## Tabla de frecuencias (top 2 ):
## # A tibble: 2 × 4
## Categoria Freq_Abs Freq_Rel Freq_Acum
## <chr> <int> <dbl> <dbl>
## 1 Cerrada 334952 98.2 98.2
## 2 Abierta 6075 1.78 100
##
## Categorías raras (<1% del total): 0

cat("\n ─ ANÁLISIS ADICIONAL ─────────────────────────────────────\n")
##
## ─ ANÁLISIS ADICIONAL ─────────────────────────────────────
# Estado por tipo de documento
tab_estado_tipo <- df %>%
count(estado_cartera, document_type) %>%
arrange(estado_cartera, desc(n))
cat(" Estado de cartera por tipo de documento:\n")
## Estado de cartera por tipo de documento:
print(tab_estado_tipo)
## # A tibble: 19 × 3
## estado_cartera document_type n
## <chr> <chr> <int>
## 1 Abierta RV 3695
## 2 Abierta DZ 878
## 3 Abierta DA 552
## 4 Abierta RU 477
## 5 Abierta NC 249
## 6 Abierta ZV 222
## 7 Abierta AB 2
## 8 Cerrada RV 137974
## 9 Cerrada DZ 84652
## 10 Cerrada NC 34171
## 11 Cerrada DA 27541
## 12 Cerrada AB 16504
## 13 Cerrada ZV 16193
## 14 Cerrada RU 14783
## 15 Cerrada CC 3039
## 16 Cerrada DR 62
## 17 Cerrada DG 18
## 18 Cerrada SA 11
## 19 Cerrada ND 4
# Monto total abierta vs cerrada
tab_estado_monto <- df %>% filter(!is.na(amount)) %>%
group_by(estado_cartera) %>%
summarise(Monto_Total=round(sum(amount,na.rm=TRUE),0),
N=n(), .groups="drop") %>%
mutate(Pct_Monto=round(Monto_Total/sum(Monto_Total)*100,2))
cat("\n Monto total por estado de cartera:\n")
##
## Monto total por estado de cartera:
print(tab_estado_monto)
## # A tibble: 2 × 4
## estado_cartera Monto_Total N Pct_Monto
## <chr> <dbl> <int> <dbl>
## 1 Abierta 114448362999 6075 66.3
## 2 Cerrada 58178418073 334952 33.7
p_estado_monto <- ggplot(
df %>% filter(!is.na(estado_cartera), !is.na(amount)),
aes(x=estado_cartera, y=amount, fill=estado_cartera)) +
geom_boxplot(alpha=0.7, outlier.alpha=0.1, show.legend=FALSE) +
scale_fill_manual(values=c("Abierta"=C2, "Cerrada"=C4)) +
scale_y_continuous(labels=comma) +
labs(title="Monto por estado de cartera",
subtitle="Abierta = pendiente de cobro | Cerrada = compensada",
x=NULL, y="Monto") +
tema
print(p_estado_monto)

# ─────────────────────────────────────────────────────────────────────────────
# VAR 14: Bucket_Mora
# ─────────────────────────────────────────────────────────────────────────────
separador("VARIABLE 14: bucket_mora [CATEGÓRICA ORDINAL]")
##
## ═════════════════════════════════════════════════════════════════
## VARIABLE 14: BUCKET_MORA [CATEGÓRICA ORDINAL]
## ═════════════════════════════════════════════════════════════════
cat(" Tipo en R :", class(df$bucket_mora)[1], "\n")
## Tipo en R : ordered
cat(" Tipo analítico : Cualitativa ordinal (tramos de mora ordenados)\n")
## Tipo analítico : Cualitativa ordinal (tramos de mora ordenados)
cat(" Orden de niveles : Al dia < 1-30 < 31-60 < 61-90 < 91-180 < 181-360 < >360\n\n")
## Orden de niveles : Al dia < 1-30 < 31-60 < 61-90 < 91-180 < 181-360 < >360
n_na_bm <- sum(is.na(df$bucket_mora))
tab_bm <- df %>%
filter(!is.na(bucket_mora)) %>%
count(bucket_mora, name="Freq_Abs") %>%
mutate(Freq_Rel = round(Freq_Abs/sum(Freq_Abs)*100,2),
Freq_Acum = cumsum(Freq_Rel))
cat(" Faltantes :", n_na_bm, "\n")
## Faltantes : 0
cat(" Categorías :", n_distinct(df$bucket_mora, na.rm=TRUE), "\n")
## Categorías : 7
cat(" Bucket más frec. :", as.character(tab_bm$bucket_mora[1]), "\n\n")
## Bucket más frec. : Al dia
cat(" Tabla de frecuencias:\n")
## Tabla de frecuencias:
print(tab_bm, n=Inf)
## # A tibble: 7 × 4
## bucket_mora Freq_Abs Freq_Rel Freq_Acum
## <ord> <int> <dbl> <dbl>
## 1 Al dia 111154 32.6 32.6
## 2 1-30 173943 51.0 83.6
## 3 31-60 32091 9.41 93.0
## 4 61-90 9629 2.82 95.8
## 5 91-180 9867 2.89 98.7
## 6 181-360 3432 1.01 99.7
## 7 >360 911 0.27 100
# Semáforo de salud de cartera
pct_al_dia <- tab_bm %>% filter(bucket_mora == "Al dia") %>% pull(Freq_Rel)
pct_critico <- tab_bm %>% filter(bucket_mora %in% c("91-180","181-360",">360")) %>%
summarise(pct=sum(Freq_Rel)) %>% pull(pct)
cat("\n ─ SEMÁFORO DE SALUD DE CARTERA ──────────────────────────\n")
##
## ─ SEMÁFORO DE SALUD DE CARTERA ──────────────────────────
cat(" % Al día :", if(length(pct_al_dia)==0) 0 else pct_al_dia, "%\n")
## % Al día : 32.59 %
cat(" % Mora crítica (>90d) :", pct_critico, "%\n")
## % Mora crítica (>90d) : 4.17 %
cat(" Diagnóstico :", case_when(
pct_al_dia > 80 ~ "✓ Cartera SANA — mayoría al día",
pct_al_dia > 60 ~ "▲ Cartera MODERADA — seguimiento recomendado",
TRUE ~ "✖ Cartera DETERIORADA — gestión urgente requerida"
), "\n")
## Diagnóstico : ✖ Cartera DETERIORADA — gestión urgente requerida
# Monto por bucket
tab_bm_monto <- df %>% filter(!is.na(amount), !is.na(bucket_mora)) %>%
group_by(bucket_mora) %>%
summarise(
N=n(),
Monto_Total=round(sum(amount,na.rm=TRUE),0),
Monto_Prom =round(mean(amount,na.rm=TRUE),0),
Monto_Med =round(median(amount,na.rm=TRUE),0),
.groups="drop"
)
cat("\n Monto por tramo de mora:\n")
##
## Monto por tramo de mora:
print(tab_bm_monto, n=Inf)
## # A tibble: 7 × 5
## bucket_mora N Monto_Total Monto_Prom Monto_Med
## <ord> <int> <dbl> <dbl> <dbl>
## 1 Al dia 111154 570037689212 5128360 2065602
## 2 1-30 173943 -418469992452 -2405788 209260
## 3 31-60 32091 6319537450 196926 41055
## 4 61-90 9629 9721353862 1009591 32249
## 5 91-180 9867 -106352134 -10779 23600
## 6 181-360 3432 -846591818 -246676 27251
## 7 >360 911 5971136951 6554486 -60000
# Gráficos bucket
colores_bm <- c("Al dia"=C4,"1-30"="#A8D5A2","31-60"=C3,
"61-90"="#E8A930","91-180"="#E07020",
"181-360"=C2,">360"="#8B0000")
p_bm_n <- ggplot(tab_bm, aes(x=bucket_mora, y=Freq_Abs, fill=bucket_mora)) +
geom_col(alpha=0.9, show.legend=FALSE) +
geom_text(aes(label=paste0(Freq_Rel,"%")), vjust=-0.3, size=3.5) +
scale_fill_manual(values=colores_bm) +
scale_y_continuous(labels=comma, expand=expansion(mult=c(0,.12))) +
labs(title="Frecuencia por tramo de mora", x="Bucket", y="Registros") +
tema
p_bm_m <- ggplot(tab_bm_monto, aes(x=bucket_mora, y=Monto_Total, fill=bucket_mora)) +
geom_col(alpha=0.9, show.legend=FALSE) +
scale_fill_manual(values=colores_bm) +
scale_y_continuous(labels=comma) +
labs(title="Monto total por tramo de mora", x="Bucket", y="Monto total") +
tema
panel_bm <- p_bm_n | p_bm_m
print(panel_bm + plot_annotation(
title = "Análisis: Bucket de Mora",
theme = theme(plot.title=element_text(face="bold",size=14,color=C1))
))

# ═══════════════════════════════════════════════════════════════════════════════
# PARTE 3 ▸ CONCLUSIÓN GENERAL AUTOMÁTICA
# ═══════════════════════════════════════════════════════════════════════════════
separador("PARTE 3 — CONCLUSIÓN GENERAL AUTOMÁTICA")
##
## ═════════════════════════════════════════════════════════════════
## PARTE 3 — CONCLUSIÓN GENERAL AUTOMÁTICA
## ═════════════════════════════════════════════════════════════════
# Calcular insumos para la conclusión
pct_clear_na <- round(sum(is.na(df$clearing_date)) / nrow(df) * 100, 1)
pct_payment_na <- round(sum(is.na(df$payment_date)) / nrow(df) * 100, 1)
n_out_arr <- length(outliers_iqr(df$arrears))
n_out_amt <- length(outliers_iqr(df$amount))
pct_out_arr <- round(n_out_arr / sum(!is.na(df$arrears)) * 100, 1)
pct_out_amt <- round(n_out_amt / sum(!is.na(df$amount)) * 100, 1)
n_clientes <- n_distinct(df$anon_customer_id)
n_docs <- n_distinct(df$anon_document_id)
pct_mora_crit <- tab_bm %>%
filter(bucket_mora %in% c("91-180","181-360",">360")) %>%
summarise(s=sum(Freq_Rel)) %>% pull(s)
pct_al_dia_v <- tab_bm %>% filter(bucket_mora=="Al dia") %>%
pull(Freq_Rel) %>% {if(length(.)==0) 0 else .}
conclusion <- paste0(
"
╔══════════════════════════════════════════════════════════════════╗
║ CONCLUSIÓN GENERAL DEL ANÁLISIS EXPLORATORIO ║
╚══════════════════════════════════════════════════════════════════╝
Generado automáticamente el: ", format(Sys.time(), "%d/%m/%Y %H:%M"), "
─── ESTRUCTURA DE LA BASE ───────────────────────────────────────────
• La base contiene ", formatC(nrow(df), big.mark=","),
" registros y 14 variables (se eliminó Pago_Oportuno_Bin).
• Representa ", formatC(n_clientes, big.mark=","), " clientes únicos y ",
formatC(n_docs, big.mark=","), " documentos únicos.
• Se identificaron variables de 4 tipos:
- 2 Identificadores: Anon_Customer_ID, Anon_Document_ID
- 4 Fechas : Document_Date, Payment_date, Net_due_date, Clearing_date
- 2 Numéricas : Arrears_after_net_due_date, Amount_in_local_currency
- 6 Categóricas : Terms_of_Payment, Document_Type, Reason_code,
Year/month, Estado_Cartera, Bucket_Mora
─── CALIDAD DE DATOS ────────────────────────────────────────────────
• Faltantes críticos:
- clearing_date : ", pct_clear_na,
"% faltante — NORMAL en cartera abierta (sin compensar)
- payment_date : ", pct_payment_na,
"% faltante — revisar si cartera cerrada sin fecha de pago
• Terms_of_Payment tiene valores vacíos ('') en parte de los registros.
Posiblemente corresponde a abonos (AB) o documentos sin condición.
• Bucket_Mora y Pago_Oportuno_Bin venían como fórmulas IF sin calcular
en Excel. Se reconstruyeron desde Arrears en este script.
• Duplicados por fila completa : ", n_dup_filas, "
• Document IDs con duplicados : ", n_dup_docs, "
─── VARIABLES NUMÉRICAS ─────────────────────────────────────────────
• Arrears (días de atraso):
- Media ", round(mean(df$arrears,na.rm=TRUE),1), " días | Mediana ",
round(median(df$arrears,na.rm=TRUE),1), " días
- Valores negativos = pagos anticipados (comportamiento válido)
- Outliers IQR: ", formatC(n_out_arr,big.mark=","),
" registros (", pct_out_arr, "%)
- Distribución fuertemente sesgada a la derecha: muchos registros
en mora muy alta (>360 días)
• Amount (monto):
- Montos negativos = notas crédito / abonos contables (válidos)
- Outliers IQR: ", formatC(n_out_amt,big.mark=","),
" registros (", pct_out_amt, "%)
- Alta variabilidad entre clientes: revisar concentración
─── VARIABLES CATEGÓRICAS ───────────────────────────────────────────
• Document_Type: identificar cuáles son facturas (ej: DA) vs abonos
(AB) es clave para análisis de cartera bruta vs neta.
• Estado_Cartera: discrimina documentos activos vs compensados.
• Bucket_Mora: indicador clave de salud de cartera.
- % Al día : ", pct_al_dia_v, "%
- % Mora crítica >90d : ", pct_mora_crit, "%
- Diagnóstico: ", case_when(
pct_al_dia_v > 80 ~ "Cartera SANA",
pct_al_dia_v > 60 ~ "Cartera MODERADA — seguimiento necesario",
TRUE ~ "Cartera DETERIORADA — gestión urgente"
), "
─── VARIABLES FECHA ─────────────────────────────────────────────────
• Document_Date cubre un rango amplio de años: revisar registros
extremos que podrían ser errores de carga.
• Inconsistencias: revisar registros donde net_due_date < document_date.
• Clearing_date nula en cartera abierta es estructural, no un error.
─── RECOMENDACIONES PRIORITARIAS ────────────────────────────────────
1. Recalcular Bucket_Mora directamente desde Arrears (ya hecho aquí).
2. Separar montos negativos (notas crédito) para análisis de saldo neto.
3. Crear flag de outliers en Amount y Arrears para modelos.
4. Validar registros donde Document_Date > Net_due_date (error lógico).
5. Investigar registros Cerrados sin Clearing_date (", n_cerrada_sin_clearing, " casos).
6. Evaluar concentración de cartera: aplicar regla 80/20 por cliente.
7. Revisar Terms_of_Payment vacíos por tipo de documento.
══════════════════════════════════════════════════════════════════════
")
cat(conclusion)
##
## ╔══════════════════════════════════════════════════════════════════╗
## ║ CONCLUSIÓN GENERAL DEL ANÁLISIS EXPLORATORIO ║
## ╚══════════════════════════════════════════════════════════════════╝
##
## Generado automáticamente el: 10/04/2026 03:23
##
## ─── ESTRUCTURA DE LA BASE ───────────────────────────────────────────
## • La base contiene 341,027 registros y 14 variables (se eliminó Pago_Oportuno_Bin).
## • Representa 319 clientes únicos y 10,000 documentos únicos.
## • Se identificaron variables de 4 tipos:
## - 2 Identificadores: Anon_Customer_ID, Anon_Document_ID
## - 4 Fechas : Document_Date, Payment_date, Net_due_date, Clearing_date
## - 2 Numéricas : Arrears_after_net_due_date, Amount_in_local_currency
## - 6 Categóricas : Terms_of_Payment, Document_Type, Reason_code,
## Year/month, Estado_Cartera, Bucket_Mora
##
## ─── CALIDAD DE DATOS ────────────────────────────────────────────────
## • Faltantes críticos:
## - clearing_date : 3.5% faltante — NORMAL en cartera abierta (sin compensar)
## - payment_date : 0% faltante — revisar si cartera cerrada sin fecha de pago
## • Terms_of_Payment tiene valores vacíos ('') en parte de los registros.
## Posiblemente corresponde a abonos (AB) o documentos sin condición.
## • Bucket_Mora y Pago_Oportuno_Bin venían como fórmulas IF sin calcular
## en Excel. Se reconstruyeron desde Arrears en este script.
## • Duplicados por fila completa : 3405
## • Document IDs con duplicados : 10000
##
## ─── VARIABLES NUMÉRICAS ─────────────────────────────────────────────
## • Arrears (días de atraso):
## - Media 15.9 días | Mediana 6 días
## - Valores negativos = pagos anticipados (comportamiento válido)
## - Outliers IQR: 34,206 registros (10%)
## - Distribución fuertemente sesgada a la derecha: muchos registros
## en mora muy alta (>360 días)
## • Amount (monto):
## - Montos negativos = notas crédito / abonos contables (válidos)
## - Outliers IQR: 83,272 registros (24.4%)
## - Alta variabilidad entre clientes: revisar concentración
##
## ─── VARIABLES CATEGÓRICAS ───────────────────────────────────────────
## • Document_Type: identificar cuáles son facturas (ej: DA) vs abonos
## (AB) es clave para análisis de cartera bruta vs neta.
## • Estado_Cartera: discrimina documentos activos vs compensados.
## • Bucket_Mora: indicador clave de salud de cartera.
## - % Al día : 32.59%
## - % Mora crítica >90d : 4.17%
## - Diagnóstico: Cartera DETERIORADA — gestión urgente
##
## ─── VARIABLES FECHA ─────────────────────────────────────────────────
## • Document_Date cubre un rango amplio de años: revisar registros
## extremos que podrían ser errores de carga.
## • Inconsistencias: revisar registros donde net_due_date < document_date.
## • Clearing_date nula en cartera abierta es estructural, no un error.
##
## ─── RECOMENDACIONES PRIORITARIAS ────────────────────────────────────
## 1. Recalcular Bucket_Mora directamente desde Arrears (ya hecho aquí).
## 2. Separar montos negativos (notas crédito) para análisis de saldo neto.
## 3. Crear flag de outliers en Amount y Arrears para modelos.
## 4. Validar registros donde Document_Date > Net_due_date (error lógico).
## 5. Investigar registros Cerrados sin Clearing_date (5987 casos).
## 6. Evaluar concentración de cartera: aplicar regla 80/20 por cliente.
## 7. Revisar Terms_of_Payment vacíos por tipo de documento.
##
## ══════════════════════════════════════════════════════════════════════
# ═══════════════════════════════════════════════════════════════════════════════
# FIN DEL SCRIPT
# ═══════════════════════════════════════════════════════════════════════════════
cat("\n✅ ANÁLISIS COMPLETADO\n")
##
## ✅ ANÁLISIS COMPLETADO
cat(" Revisa los gráficos generados en el panel de RStudio (Plots).\n")
## Revisa los gráficos generados en el panel de RStudio (Plots).
cat(" Para exportar gráficos, usa ggsave() o el botón Export en Plots.\n\n")
## Para exportar gráficos, usa ggsave() o el botón Export en Plots.
# ═══════════════════════════════════════════════════════════════════════════════
# EDA AVANZADO — GRÁFICOS COMPLEMENTARIOS | BASE I2C / CARTERA
# Complementa: EDA_I2C_Variable_por_Variable.R
# Secciones:
# 1. Análisis bivariado
# 2. Análisis temporal
# 3. Correlación (heatmap)
# 4. Distribuciones avanzadas
# 5. Calidad de datos (nulos)
# 6. Segmentación por cliente
# ═══════════════════════════════════════════════════════════════════════════════
#
# REQUISITO: ejecutar primero EDA_I2C_Variable_por_Variable.R
# para tener el dataframe `df` en el entorno de R.
# Si no lo tienes cargado, ejecuta el bloque de carga al final
# de este script (marcado con "CARGA INDEPENDIENTE").
# ═══════════════════════════════════════════════════════════════════════════════
# ───────────────────────────────────────────────────────────────────────────────
# 0 ▸ LIBRERÍAS Y CONFIGURACIÓN VISUAL
# ───────────────────────────────────────────────────────────────────────────────
paquetes <- c("tidyverse", "readxl", "janitor", "ggplot2", "patchwork",
"scales", "ggcorrplot", "moments", "lubridate")
for (pkg in paquetes) {
if (!requireNamespace(pkg, quietly = TRUE))
install.packages(pkg, repos = "https://cran.r-project.org")
suppressPackageStartupMessages(library(pkg, character.only = TRUE))
}
## Warning: package 'ggcorrplot' was built under R version 4.5.3
# ── Paleta y tema (idénticos al script principal) ─────────────────────────────
C1 <- "#2C3E7A"; C2 <- "#E84040"; C3 <- "#F5A623"; C4 <- "#27AE60"
PALETA_BUCKET <- c(
"Al dia" = C4,
"1-30" = "#85C17E",
"31-60" = C3,
"61-90" = "#E8A323",
"91-180" = "#D4691E",
"181-360" = C2,
">360" = "#7B0000"
)
ORDEN_BUCKET <- c("Al dia","1-30","31-60","61-90","91-180","181-360",">360")
tema <- theme_minimal(base_size = 11) +
theme(
plot.title = element_text(face = "bold", size = 13, color = C1),
plot.subtitle = element_text(size = 10, color = "gray40"),
plot.caption = element_text(size = 8, color = "gray55"),
axis.title = element_text(size = 10),
legend.position = "bottom",
panel.grid.minor = element_blank()
)
separador <- function(titulo) {
cat("\n", strrep("═", 65), "\n", sep = "")
cat(" ", toupper(titulo), "\n", sep = "")
cat(strrep("═", 65), "\n\n", sep = "")
}
cat("✓ Configuración lista\n\n")
## ✓ Configuración lista
# ═══════════════════════════════════════════════════════════════════════════════
# ▸ CARGA INDEPENDIENTE (omitir si df ya está en el entorno)
# ═══════════════════════════════════════════════════════════════════════════════
# Descomenta este bloque si ejecutas este script de forma independiente:
# RUTA <- "C:/Users/jcabia01/Downloads/Tabla anonimización Ok.xlsx"
# df_raw <- read_excel(RUTA, sheet = 1, guess_max = 10000, col_types = "text")
# col_elim <- grep("pago|oportuno|Bin", names(df_raw), value=TRUE, ignore.case=TRUE)
# if (length(col_elim) > 0) df_raw <- df_raw %>% select(-all_of(col_elim))
# df <- df_raw %>%
# clean_names() %>%
# mutate(
# anon_customer_id = as.character(anon_customer_id),
# anon_document_id = as.character(anon_document_id),
# reason_code = as.character(reason_code),
# terms_of_payment = as.character(terms_of_payment),
# document_type = as.character(document_type),
# year_month = as.character(year_month),
# estado_cartera = as.character(estado_cartera_abierta_cerrada),
# arrears = suppressWarnings(as.numeric(arrears_after_net_due_date)),
# amount = suppressWarnings(as.numeric(amount_in_local_currency)),
# document_date = suppressWarnings(as.Date(as.numeric(document_date), origin="1899-12-30")),
# payment_date = suppressWarnings(as.Date(as.numeric(payment_date), origin="1899-12-30")),
# net_due_date = suppressWarnings(as.Date(as.numeric(net_due_date), origin="1899-12-30")),
# clearing_date = suppressWarnings(as.Date(as.numeric(clearing_date), origin="1899-12-30")),
# bucket_mora = factor(
# case_when(
# arrears <= 0 ~ "Al dia", arrears <= 30 ~ "1-30",
# arrears <= 60 ~ "31-60", arrears <= 90 ~ "61-90",
# arrears <= 180 ~ "91-180", arrears <= 360 ~ "181-360",
# TRUE ~ ">360"),
# levels = c("Al dia","1-30","31-60","61-90","91-180","181-360",">360"),
# ordered = TRUE)
# ) %>%
# select(anon_customer_id, anon_document_id, terms_of_payment, document_type,
# document_date, payment_date, net_due_date, arrears, amount,
# reason_code, clearing_date, year_month, estado_cartera, bucket_mora)
# cat("✓ Datos cargados:", nrow(df), "filas x", ncol(df), "columnas\n")
# ── Verificar que df existe ────────────────────────────────────────────────────
if (!exists("df")) stop("⚠ El objeto 'df' no existe. Carga primero el script principal o descomenta el bloque CARGA INDEPENDIENTE.")
# ── Preparar datos de trabajo ─────────────────────────────────────────────────
# Datos sin outliers extremos de amount (para scatter/density legibles)
lim_amt <- list(
inf = quantile(df$amount, .01, na.rm = TRUE),
sup = quantile(df$amount, .99, na.rm = TRUE)
)
lim_arr <- list(
inf = quantile(df$arrears, .01, na.rm = TRUE),
sup = quantile(df$arrears, .99, na.rm = TRUE)
)
df_clean <- df %>%
filter(!is.na(amount), !is.na(arrears),
amount >= lim_amt$inf, amount <= lim_amt$sup,
arrears >= lim_arr$inf, arrears <= lim_arr$sup)
df_bkt <- df %>%
filter(!is.na(bucket_mora)) %>%
mutate(bucket_mora = factor(bucket_mora, levels = ORDEN_BUCKET, ordered = TRUE))
cat("Registros totales :", formatC(nrow(df), big.mark = ","), "\n")
## Registros totales : 341,027
cat("Registros sin outliers (p1-p99):", formatC(nrow(df_clean), big.mark = ","), "\n\n")
## Registros sin outliers (p1-p99): 327,619
# ═══════════════════════════════════════════════════════════════════════════════
# SECCIÓN 1 ▸ ANÁLISIS BIVARIADO
# ═══════════════════════════════════════════════════════════════════════════════
separador("SECCIÓN 1 — ANÁLISIS BIVARIADO")
##
## ═════════════════════════════════════════════════════════════════
## SECCIÓN 1 — ANÁLISIS BIVARIADO
## ═════════════════════════════════════════════════════════════════
# ── 1.1 Boxplot: Amount vs Bucket_Mora ────────────────────────────────────────
cat(" Generando 1.1: Boxplot Amount vs Bucket_Mora...\n")
## Generando 1.1: Boxplot Amount vs Bucket_Mora...
# Estadísticas medianas para etiquetar
medians_amt <- df_bkt %>%
filter(!is.na(amount)) %>%
group_by(bucket_mora) %>%
summarise(med = median(amount, na.rm = TRUE), .groups = "drop")
p1_1 <- df_bkt %>%
filter(!is.na(amount),
amount >= lim_amt$inf, amount <= lim_amt$sup) %>%
ggplot(aes(x = bucket_mora, y = amount, fill = bucket_mora)) +
geom_boxplot(alpha = 0.75, outlier.alpha = 0.08,
outlier.size = 0.6, show.legend = FALSE) +
geom_text(data = medians_amt,
aes(x = bucket_mora, y = med,
label = paste0("Med:\n", comma(round(med, 0)))),
inherit.aes = FALSE,
size = 2.8, vjust = -0.5, color = "gray20", fontface = "bold") +
scale_fill_manual(values = PALETA_BUCKET) +
scale_y_continuous(labels = comma) +
labs(
title = "Monto por Tramo de Mora",
subtitle = "Distribución de Amount_in_local_currency según Bucket_Mora (p1-p99)",
x = "Tramo de mora (Bucket)",
y = "Monto en moneda local",
caption = "Sin outliers extremos (p1-p99)"
) +
tema
print(p1_1)

# ── 1.2 Boxplot: Arrears vs Bucket_Mora ───────────────────────────────────────
cat(" Generando 1.2: Boxplot Arrears vs Bucket_Mora...\n")
## Generando 1.2: Boxplot Arrears vs Bucket_Mora...
medians_arr <- df_bkt %>%
filter(!is.na(arrears)) %>%
group_by(bucket_mora) %>%
summarise(med = median(arrears, na.rm = TRUE), .groups = "drop")
p1_2 <- df_bkt %>%
filter(!is.na(arrears),
arrears >= lim_arr$inf, arrears <= lim_arr$sup) %>%
ggplot(aes(x = bucket_mora, y = arrears, fill = bucket_mora)) +
geom_boxplot(alpha = 0.75, outlier.alpha = 0.08,
outlier.size = 0.6, show.legend = FALSE) +
geom_text(data = medians_arr,
aes(x = bucket_mora, y = med,
label = paste0("Med: ", round(med, 0), "d")),
inherit.aes = FALSE,
size = 2.8, vjust = -0.6, color = "gray20", fontface = "bold") +
scale_fill_manual(values = PALETA_BUCKET) +
scale_y_continuous(labels = comma) +
labs(
title = "Días de Atraso por Tramo de Mora",
subtitle = "Arrears_after_net_due_date según Bucket_Mora (p1-p99)",
x = "Tramo de mora (Bucket)",
y = "Días de atraso",
caption = "Validación: cada bucket debe contener el rango de días que representa"
) +
tema
print(p1_2)

# ── 1.3 Boxplot: Arrears vs Document_Type ─────────────────────────────────────
cat(" Generando 1.3: Boxplot Arrears vs Document_Type...\n")
## Generando 1.3: Boxplot Arrears vs Document_Type...
# Ordenar tipos por mediana descendente, mostrar solo top 10
orden_dt <- df %>%
filter(!is.na(arrears), !is.na(document_type)) %>%
group_by(document_type) %>%
summarise(med = median(arrears, na.rm = TRUE),
n = n(), .groups = "drop") %>%
arrange(desc(med)) %>%
slice_head(n = 10) %>%
pull(document_type)
p1_3 <- df %>%
filter(!is.na(arrears), document_type %in% orden_dt,
arrears >= lim_arr$inf, arrears <= lim_arr$sup) %>%
mutate(document_type = factor(document_type, levels = orden_dt)) %>%
ggplot(aes(x = document_type, y = arrears, fill = document_type)) +
geom_boxplot(alpha = 0.75, outlier.alpha = 0.1,
outlier.size = 0.6, show.legend = FALSE) +
scale_fill_brewer(palette = "Set2") +
scale_y_continuous(labels = comma) +
labs(
title = "Días de Atraso por Tipo de Documento",
subtitle = "Top 10 tipos de documento, ordenados por mediana de arrears",
x = "Tipo de documento",
y = "Días de atraso",
caption = "p1-p99 para legibilidad"
) +
tema +
theme(axis.text.x = element_text(angle = 30, hjust = 1))
print(p1_3)
## Warning in RColorBrewer::brewer.pal(n, pal): n too large, allowed maximum for palette Set2 is 8
## Returning the palette you asked for with that many colors

# ── 1.4 Barras apiladas: Bucket_Mora vs Estado_Cartera ────────────────────────
cat(" Generando 1.4: Barras apiladas Bucket_Mora vs Estado_Cartera...\n")
## Generando 1.4: Barras apiladas Bucket_Mora vs Estado_Cartera...
tab_bkt_estado <- df_bkt %>%
filter(!is.na(estado_cartera)) %>%
count(bucket_mora, estado_cartera) %>%
group_by(bucket_mora) %>%
mutate(pct = round(n / sum(n) * 100, 1)) %>%
ungroup()
# Panel A: frecuencias absolutas apiladas
p1_4a <- ggplot(tab_bkt_estado,
aes(x = bucket_mora, y = n, fill = estado_cartera)) +
geom_col(alpha = 0.9, position = "stack") +
scale_fill_manual(values = c("Abierta" = C2, "Cerrada" = C4),
name = "Estado cartera") +
scale_y_continuous(labels = comma) +
labs(title = "Bucket_Mora × Estado_Cartera (absoluto)",
x = "Tramo de mora", y = "Registros") +
tema +
theme(axis.text.x = element_text(angle = 30, hjust = 1))
# Panel B: distribución porcentual (100% apilado)
p1_4b <- ggplot(tab_bkt_estado,
aes(x = bucket_mora, y = pct, fill = estado_cartera)) +
geom_col(alpha = 0.9, position = "fill") +
geom_text(aes(label = ifelse(pct > 5, paste0(pct, "%"), "")),
position = position_fill(vjust = 0.5),
size = 3, color = "white", fontface = "bold") +
scale_fill_manual(values = c("Abierta" = C2, "Cerrada" = C4),
name = "Estado cartera") +
scale_y_continuous(labels = percent) +
labs(title = "Bucket_Mora × Estado_Cartera (100% apilado)",
subtitle = "Proporción de cartera abierta y cerrada por tramo",
x = "Tramo de mora", y = "Proporción") +
tema +
theme(axis.text.x = element_text(angle = 30, hjust = 1))
panel_1_4 <- p1_4a / p1_4b +
plot_annotation(
title = "Relación: Tramo de Mora vs Estado de Cartera",
theme = theme(plot.title = element_text(face = "bold", size = 14, color = C1))
)
print(panel_1_4)

# ── 1.5 Scatter: Amount vs Arrears (muestra aleatoria para rendimiento) ────────
cat(" Generando 1.5: Scatter Amount vs Arrears...\n")
## Generando 1.5: Scatter Amount vs Arrears...
# Con 341k filas un scatter completo es ilegible → muestra estratificada
set.seed(42)
df_sample <- df_clean %>%
filter(!is.na(bucket_mora)) %>%
group_by(bucket_mora) %>%
slice_sample(prop = 1) %>%
slice_head(n = 500) %>% # hasta 500 por bucket
ungroup()
cat(" Muestra para scatter:", nrow(df_sample), "puntos\n")
## Muestra para scatter: 3000 puntos
# 1.5a Sin color por bucket
p1_5a <- ggplot(df_sample, aes(x = arrears, y = amount)) +
geom_point(alpha = 0.25, size = 1.2, color = C1) +
geom_smooth(method = "lm", color = C2, linewidth = 1,
se = TRUE, linetype = "dashed") +
scale_x_continuous(labels = comma) +
scale_y_continuous(labels = comma) +
labs(
title = "Amount vs Arrears — Scatter general",
subtitle = paste0("Muestra estratificada: ", nrow(df_sample),
" puntos (p1-p99)"),
x = "Días de atraso (Arrears)",
y = "Monto (Amount)",
caption = "Línea roja = tendencia lineal (OLS)"
) +
tema
# 1.5b Coloreado por Bucket_Mora
p1_5b <- ggplot(df_sample,
aes(x = arrears, y = amount, color = bucket_mora)) +
geom_point(alpha = 0.35, size = 1.3) +
scale_color_manual(values = PALETA_BUCKET, name = "Bucket mora") +
scale_x_continuous(labels = comma) +
scale_y_continuous(labels = comma) +
guides(color = guide_legend(override.aes = list(size = 3, alpha = 1))) +
labs(
title = "Amount vs Arrears — coloreado por Bucket_Mora",
subtitle = "Permite ver si los tramos de mora se distribuyen de forma distinta por monto",
x = "Días de atraso (Arrears)",
y = "Monto (Amount)"
) +
tema
panel_scatter <- p1_5a / p1_5b +
plot_annotation(
title = "Scatter: Monto vs Días de Atraso",
theme = theme(plot.title = element_text(face = "bold", size = 14, color = C1))
)
print(panel_scatter)
## `geom_smooth()` using formula = 'y ~ x'

# ── Tabla de correlación Amount-Arrears por bucket (complemento del scatter) ──
tab_cor_bucket <- df_clean %>%
filter(!is.na(bucket_mora)) %>%
group_by(bucket_mora) %>%
summarise(
n = n(),
cor = round(cor(amount, arrears, use = "pairwise.complete.obs"), 3),
.groups = "drop"
)
cat("\n Correlación Amount vs Arrears por Bucket_Mora:\n")
##
## Correlación Amount vs Arrears por Bucket_Mora:
print(tab_cor_bucket)
## # A tibble: 6 × 3
## bucket_mora n cor
## <ord> <int> <dbl>
## 1 Al dia 105111 -0.269
## 2 1-30 170224 0.105
## 3 31-60 31864 -0.033
## 4 61-90 9593 -0.01
## 5 91-180 9858 0.001
## 6 181-360 969 0.014
# ═══════════════════════════════════════════════════════════════════════════════
# SECCIÓN 2 ▸ ANÁLISIS TEMPORAL (Year/month)
# ═══════════════════════════════════════════════════════════════════════════════
separador("SECCIÓN 2 — ANÁLISIS TEMPORAL")
##
## ═════════════════════════════════════════════════════════════════
## SECCIÓN 2 — ANÁLISIS TEMPORAL
## ═════════════════════════════════════════════════════════════════
# Preparar: convertir year_month "YYYY/MM" a fecha real para ordenar bien
df_temp <- df %>%
filter(!is.na(year_month), year_month != "",
!is.na(amount), !is.na(arrears)) %>%
mutate(
periodo = ym(str_replace(year_month, "/", "-")), # "2023/03" → 2023-03-01
) %>%
filter(!is.na(periodo)) %>%
group_by(periodo, year_month) %>%
summarise(
monto_total = sum(amount, na.rm = TRUE),
mora_promedio = mean(arrears, na.rm = TRUE),
n_documentos = n(),
.groups = "drop"
) %>%
arrange(periodo)
cat(" Períodos con datos:", nrow(df_temp), "\n")
## Períodos con datos: 51
cat(" Desde:", as.character(min(df_temp$periodo, na.rm=TRUE)),
"hasta:", as.character(max(df_temp$periodo, na.rm=TRUE)), "\n\n")
## Desde: 2022-01-01 hasta: 2026-03-01
# ── 2.1 Línea: Monto total por mes ────────────────────────────────────────────
cat(" Generando 2.1: Monto total por mes...\n")
## Generando 2.1: Monto total por mes...
p2_1 <- ggplot(df_temp, aes(x = periodo, y = monto_total)) +
geom_area(fill = C1, alpha = 0.15) +
geom_line(color = C1, linewidth = 1) +
geom_point(color = C1, size = 1.8, alpha = 0.8) +
# Marcar máximo y mínimo
geom_point(data = df_temp %>% filter(monto_total == max(monto_total)),
aes(x = periodo, y = monto_total),
color = C4, size = 3.5, shape = 17, inherit.aes = FALSE) +
geom_point(data = df_temp %>% filter(monto_total == min(monto_total)),
aes(x = periodo, y = monto_total),
color = C2, size = 3.5, shape = 25, fill = C2,
inherit.aes = FALSE) +
scale_x_date(date_breaks = "3 months", date_labels = "%b\n%Y") +
scale_y_continuous(labels = comma) +
labs(
title = "Monto Total por Período (Year/Month)",
subtitle = "▲ verde = máximo | ▼ rojo = mínimo",
x = NULL,
y = "Monto total (moneda local)"
) +
tema +
theme(axis.text.x = element_text(size = 8))
print(p2_1)

# ── 2.2 Línea: Mora promedio por mes ──────────────────────────────────────────
cat(" Generando 2.2: Mora promedio por mes...\n")
## Generando 2.2: Mora promedio por mes...
# Media global para referencia
mora_global <- mean(df$arrears, na.rm = TRUE)
p2_2 <- ggplot(df_temp, aes(x = periodo, y = mora_promedio)) +
geom_hline(yintercept = mora_global, linetype = "dashed",
color = "gray60", linewidth = 0.8) +
annotate("text", x = min(df_temp$periodo), y = mora_global,
label = paste0("Media global: ", round(mora_global, 1), "d"),
hjust = 0, vjust = -0.5, size = 3, color = "gray50") +
geom_area(fill = C2, alpha = 0.12) +
geom_line(color = C2, linewidth = 1) +
geom_point(color = C2, size = 1.8, alpha = 0.8) +
scale_x_date(date_breaks = "3 months", date_labels = "%b\n%Y") +
scale_y_continuous(labels = comma) +
labs(
title = "Mora Promedio por Período (Year/Month)",
subtitle = "Línea gris = promedio global de Arrears_after_net_due_date",
x = NULL,
y = "Días de atraso promedio"
) +
tema +
theme(axis.text.x = element_text(size = 8))
print(p2_2)

# ── 2.3 Barras: Número de documentos por mes ──────────────────────────────────
cat(" Generando 2.3: Documentos por mes...\n")
## Generando 2.3: Documentos por mes...
p2_3 <- ggplot(df_temp, aes(x = periodo, y = n_documentos)) +
geom_col(fill = C1, alpha = 0.85, width = 20) +
geom_text(aes(label = comma(n_documentos)),
vjust = -0.4, size = 2.5, color = "gray30") +
scale_x_date(date_breaks = "3 months", date_labels = "%b\n%Y") +
scale_y_continuous(labels = comma,
expand = expansion(mult = c(0, 0.12))) +
labs(
title = "Número de Documentos por Período (Year/Month)",
subtitle = "Cantidad de registros ingresados cada mes",
x = NULL,
y = "N° de documentos"
) +
tema +
theme(axis.text.x = element_text(size = 8))
print(p2_3)

# ── Panel temporal unificado ───────────────────────────────────────────────────
panel_temporal <- p2_1 / p2_2 / p2_3 +
plot_annotation(
title = "Panel Temporal — Evolución de la Cartera por Mes",
caption = paste("Períodos:", nrow(df_temp),
" | Desde:", format(min(df_temp$periodo), "%b %Y"),
"hasta:", format(max(df_temp$periodo), "%b %Y")),
theme = theme(
plot.title = element_text(face = "bold", size = 14, color = C1),
plot.caption = element_text(size = 8, color = "gray50")
)
)
print(panel_temporal)

# ═══════════════════════════════════════════════════════════════════════════════
# SECCIÓN 3 ▸ CORRELACIÓN — HEATMAP
# ═══════════════════════════════════════════════════════════════════════════════
separador("SECCIÓN 3 — CORRELACIÓN (HEATMAP)")
##
## ═════════════════════════════════════════════════════════════════
## SECCIÓN 3 — CORRELACIÓN (HEATMAP)
## ═════════════════════════════════════════════════════════════════
cat(" Generando matriz de correlación...\n")
## Generando matriz de correlación...
# Variables numéricas disponibles + derivadas útiles
df_num <- df %>%
mutate(
bucket_num = as.numeric(bucket_mora), # ordinal → numérico
estado_num = if_else(estado_cartera == "Abierta", 1L, 0L, NA_integer_),
doc_type_num = as.numeric(factor(document_type))
) %>%
select(
`Arrears` = arrears,
`Amount` = amount,
`Bucket (ord)` = bucket_num,
`Estado (bin)` = estado_num,
`Doc Type` = doc_type_num
) %>%
drop_na()
mat_cor <- cor(df_num, use = "pairwise.complete.obs", method = "pearson")
cat("\n Matriz de correlación de Pearson:\n")
##
## Matriz de correlación de Pearson:
print(round(mat_cor, 3))
## Arrears Amount Bucket (ord) Estado (bin) Doc Type
## Arrears 1.000 -0.002 0.287 0.005 -0.062
## Amount -0.002 1.000 -0.010 0.017 0.074
## Bucket (ord) 0.287 -0.010 1.000 -0.009 -0.134
## Estado (bin) 0.005 0.017 -0.009 1.000 0.047
## Doc Type -0.062 0.074 -0.134 0.047 1.000
# Heatmap con ggcorrplot
p3_heat <- ggcorrplot(
mat_cor,
method = "square",
type = "lower",
lab = TRUE,
lab_size = 4.5,
colors = c(C2, "white", C1),
outline.color = "white",
tl.cex = 10,
title = "Matriz de Correlación — Variables Numéricas y Derivadas",
ggtheme = theme_minimal(base_size = 11)
) +
theme(
plot.title = element_text(face = "bold", size = 13, color = C1),
legend.title = element_text(size = 9)
)
## Warning: `aes_string()` was deprecated in ggplot2 3.0.0.
## ℹ Please use tidy evaluation idioms with `aes()`.
## ℹ See also `vignette("ggplot2-in-packages")` for more information.
## ℹ The deprecated feature was likely used in the ggcorrplot package.
## Please report the issue at <https://github.com/kassambara/ggcorrplot/issues>.
## This warning is displayed once per session.
## Call `lifecycle::last_lifecycle_warnings()` to see where this warning was
## generated.
print(p3_heat)

# ── Interpretación automática de correlaciones fuertes ────────────────────────
cat("\n Correlaciones con |r| > 0.3:\n")
##
## Correlaciones con |r| > 0.3:
cor_long <- as.data.frame(as.table(mat_cor)) %>%
rename(Var1 = Var1, Var2 = Var2, Correlacion = Freq) %>%
filter(as.character(Var1) < as.character(Var2),
abs(Correlacion) > 0.3) %>%
mutate(Correlacion = round(Correlacion, 3)) %>%
arrange(desc(abs(Correlacion)))
if (nrow(cor_long) > 0) {
print(cor_long)
} else {
cat(" No se encontraron correlaciones con |r| > 0.3\n")
}
## No se encontraron correlaciones con |r| > 0.3
# ═══════════════════════════════════════════════════════════════════════════════
# SECCIÓN 4 ▸ DISTRIBUCIONES AVANZADAS
# ═══════════════════════════════════════════════════════════════════════════════
separador("SECCIÓN 4 — DISTRIBUCIONES AVANZADAS")
##
## ═════════════════════════════════════════════════════════════════
## SECCIÓN 4 — DISTRIBUCIONES AVANZADAS
## ═════════════════════════════════════════════════════════════════
# ── 4.1 Density plot global de Amount ─────────────────────────────────────────
cat(" Generando 4.1: Density plot Amount (global)...\n")
## Generando 4.1: Density plot Amount (global)...
media_amt <- mean(df_clean$amount, na.rm = TRUE)
med_amt <- median(df_clean$amount, na.rm = TRUE)
p4_1 <- ggplot(df_clean, aes(x = amount)) +
geom_density(fill = C1, color = C1, alpha = 0.40, linewidth = 1) +
geom_vline(xintercept = media_amt, color = C2,
linetype = "dashed", linewidth = 1) +
geom_vline(xintercept = med_amt, color = C3,
linetype = "dashed", linewidth = 1) +
annotate("text", x = media_amt, y = 0,
label = paste0("Media\n", comma(round(media_amt, 0))),
hjust = -0.1, vjust = 0, color = C2, size = 3.2, fontface = "bold") +
annotate("text", x = med_amt, y = 0,
label = paste0("Mediana\n", comma(round(med_amt, 0))),
hjust = 1.1, vjust = 0, color = C3, size = 3.2, fontface = "bold") +
scale_x_continuous(labels = comma) +
labs(
title = "Densidad de Amount_in_local_currency",
subtitle = "Distribución global (p1-p99) | Rojo = Media | Naranja = Mediana",
x = "Monto (moneda local)",
y = "Densidad"
) +
tema
print(p4_1)

# ── 4.2 Density plot de Amount segmentado por Bucket_Mora ────────────────────
cat(" Generando 4.2: Density plot Amount por Bucket_Mora...\n")
## Generando 4.2: Density plot Amount por Bucket_Mora...
p4_2 <- df_clean %>%
filter(!is.na(bucket_mora)) %>%
mutate(bucket_mora = factor(bucket_mora, levels = ORDEN_BUCKET)) %>%
ggplot(aes(x = amount, fill = bucket_mora, color = bucket_mora)) +
geom_density(alpha = 0.30, linewidth = 0.8) +
scale_fill_manual(values = PALETA_BUCKET, name = "Bucket mora") +
scale_color_manual(values = PALETA_BUCKET, name = "Bucket mora") +
scale_x_continuous(labels = comma) +
guides(
fill = guide_legend(nrow = 1, override.aes = list(alpha = 0.7)),
color = guide_legend(nrow = 1)
) +
labs(
title = "Densidad de Amount por Tramo de Mora",
subtitle = "Cada curva representa la distribución de montos en ese bucket (p1-p99)",
x = "Monto (moneda local)",
y = "Densidad",
caption = "Solapamiento de curvas revela si los tramos de mora tienen montos similares"
) +
tema
print(p4_2)

# ── 4.2b Facetas: un panel por bucket ─────────────────────────────────────────
cat(" Generando 4.2b: Density facetado por Bucket_Mora...\n")
## Generando 4.2b: Density facetado por Bucket_Mora...
p4_2b <- df_clean %>%
filter(!is.na(bucket_mora)) %>%
mutate(bucket_mora = factor(bucket_mora, levels = ORDEN_BUCKET)) %>%
ggplot(aes(x = amount, fill = bucket_mora)) +
geom_histogram(bins = 40, alpha = 0.85, color = "white") +
facet_wrap(~ bucket_mora, scales = "free_y", ncol = 2) +
scale_fill_manual(values = PALETA_BUCKET, guide = "none") +
scale_x_continuous(labels = comma) +
scale_y_continuous(labels = comma) +
labs(
title = "Distribución de Amount por Bucket_Mora (facetas)",
subtitle = "Eje Y libre para comparar formas de distribución dentro de cada tramo",
x = "Monto (p1-p99)",
y = "Frecuencia"
) +
tema +
theme(strip.text = element_text(face = "bold", color = C1))
print(p4_2b)

# ── 4.3 Violin: Arrears por Bucket (complemento elegante del boxplot) ─────────
cat(" Generando 4.3: Violin Arrears por Bucket_Mora...\n")
## Generando 4.3: Violin Arrears por Bucket_Mora...
p4_3 <- df_bkt %>%
filter(!is.na(arrears),
arrears >= lim_arr$inf, arrears <= lim_arr$sup) %>%
ggplot(aes(x = bucket_mora, y = arrears, fill = bucket_mora)) +
geom_violin(alpha = 0.65, trim = TRUE, scale = "width",
show.legend = FALSE) +
geom_boxplot(width = 0.12, fill = "white", alpha = 0.8,
outlier.shape = NA, show.legend = FALSE) +
scale_fill_manual(values = PALETA_BUCKET) +
scale_y_continuous(labels = comma) +
labs(
title = "Distribución de Arrears por Bucket_Mora (Violin + Boxplot)",
subtitle = "El ancho del violín indica densidad de datos en ese rango de días",
x = "Tramo de mora",
y = "Días de atraso",
caption = "p1-p99 para legibilidad"
) +
tema
print(p4_3)

# ═══════════════════════════════════════════════════════════════════════════════
# SECCIÓN 5 ▸ CALIDAD DE DATOS — NULOS POR VARIABLE
# ═══════════════════════════════════════════════════════════════════════════════
separador("SECCIÓN 5 — CALIDAD DE DATOS (VALORES NULOS)")
##
## ═════════════════════════════════════════════════════════════════
## SECCIÓN 5 — CALIDAD DE DATOS (VALORES NULOS)
## ═════════════════════════════════════════════════════════════════
cat(" Generando gráfico de nulos por variable...\n")
## Generando gráfico de nulos por variable...
tab_nulos <- df %>%
summarise(across(everything(), ~ sum(is.na(.)))) %>%
pivot_longer(everything(),
names_to = "Variable",
values_to = "N_Nulos") %>%
mutate(
Pct_Nulos = round(N_Nulos / nrow(df) * 100, 2),
N_Completos = nrow(df) - N_Nulos,
Severidad = case_when(
Pct_Nulos == 0 ~ "Sin nulos",
Pct_Nulos <= 5 ~ "Bajo (<5%)",
Pct_Nulos <= 20 ~ "Moderado (5-20%)",
Pct_Nulos <= 50 ~ "Alto (20-50%)",
TRUE ~ "Crítico (>50%)"
),
Severidad = factor(Severidad,
levels = c("Sin nulos","Bajo (<5%)","Moderado (5-20%)",
"Alto (20-50%)","Crítico (>50%)"))
) %>%
arrange(desc(Pct_Nulos))
cat("\n Tabla de nulos por variable:\n")
##
## Tabla de nulos por variable:
print(tab_nulos, n = Inf)
## # A tibble: 14 × 5
## Variable N_Nulos Pct_Nulos N_Completos Severidad
## <chr> <int> <dbl> <int> <fct>
## 1 reason_code 305226 89.5 35801 Crítico (>50%)
## 2 terms_of_payment 58231 17.1 282796 Moderado (5-20%)
## 3 clearing_date 11974 3.51 329053 Bajo (<5%)
## 4 anon_customer_id 0 0 341027 Sin nulos
## 5 anon_document_id 0 0 341027 Sin nulos
## 6 document_type 0 0 341027 Sin nulos
## 7 document_date 0 0 341027 Sin nulos
## 8 payment_date 0 0 341027 Sin nulos
## 9 net_due_date 0 0 341027 Sin nulos
## 10 arrears 0 0 341027 Sin nulos
## 11 amount 0 0 341027 Sin nulos
## 12 year_month 0 0 341027 Sin nulos
## 13 estado_cartera 0 0 341027 Sin nulos
## 14 bucket_mora 0 0 341027 Sin nulos
col_severidad <- c(
"Sin nulos" = C4,
"Bajo (<5%)" = "#85C17E",
"Moderado (5-20%)" = C3,
"Alto (20-50%)" = C2,
"Crítico (>50%)" = "#7B0000"
)
# ── 5.1 Barras: cantidad de nulos por variable ─────────────────────────────────
p5_1 <- ggplot(tab_nulos,
aes(x = reorder(Variable, N_Nulos),
y = N_Nulos,
fill = Severidad)) +
geom_col(alpha = 0.9) +
geom_text(aes(label = ifelse(N_Nulos > 0,
paste0(comma(N_Nulos), "\n(", Pct_Nulos, "%)"),
"0")),
hjust = -0.05, size = 3.2, color = "gray20") +
coord_flip() +
scale_fill_manual(values = col_severidad, name = "Severidad") +
scale_y_continuous(labels = comma,
expand = expansion(mult = c(0, 0.25))) +
labs(
title = "Valores Nulos por Variable",
subtitle = paste0("Base: ", formatC(nrow(df), big.mark = ","),
" registros | ",
sum(tab_nulos$N_Nulos == 0), " variables sin nulos"),
x = NULL,
y = "Cantidad de nulos"
) +
tema +
theme(legend.position = "right")
print(p5_1)

# ── 5.2 Heatmap de completitud: variable × muestra de filas ───────────────────
cat(" Generando 5.2: Heatmap de completitud...\n")
## Generando 5.2: Heatmap de completitud...
set.seed(99)
df_miss_sample <- df %>%
slice_head(n = 300) %>%
# Convertir todo a character antes de pivotar: evita el error
# "Can't combine <character> and <date>" de pivot_longer
mutate(across(everything(), as.character)) %>%
mutate(fila = row_number()) %>%
pivot_longer(-fila, names_to = "Variable", values_to = "Valor") %>%
mutate(Es_NA = is.na(Valor) | Valor == "NA")
p5_2 <- ggplot(df_miss_sample, aes(x = Variable, y = fila, fill = Es_NA)) +
geom_tile() +
scale_fill_manual(values = c("FALSE" = C1, "TRUE" = C2),
labels = c("Completo", "Nulo"),
name = "Estado") +
scale_y_continuous(expand = c(0, 0)) +
labs(
title = "Heatmap de Completitud (muestra 300 filas)",
subtitle = "Rojo = nulo | Azul = dato presente",
x = "Variable",
y = "Fila (muestra)"
) +
tema +
theme(
axis.text.x = element_text(angle = 45, hjust = 1, size = 8),
axis.text.y = element_blank(),
axis.ticks.y = element_blank(),
panel.grid = element_blank()
)
print(p5_2)

# ── 5.3 Resumen: completitud global en barra horizontal ───────────────────────
p5_3 <- tab_nulos %>%
mutate(
Completos_pct = 100 - Pct_Nulos,
Variable = factor(Variable, levels = rev(tab_nulos$Variable))
) %>%
pivot_longer(cols = c(Completos_pct, Pct_Nulos),
names_to = "Tipo", values_to = "Pct") %>%
mutate(Tipo = if_else(Tipo == "Completos_pct", "Completo", "Nulo")) %>%
ggplot(aes(x = Variable, y = Pct, fill = Tipo)) +
geom_col(alpha = 0.9) +
geom_text(data = tab_nulos %>%
filter(Pct_Nulos > 0) %>%
mutate(Variable = factor(Variable, levels = rev(tab_nulos$Variable))),
aes(x = Variable, y = 100 - Pct_Nulos / 2,
label = paste0(Pct_Nulos, "%")),
inherit.aes = FALSE,
size = 3, color = "white", fontface = "bold") +
scale_fill_manual(values = c("Completo" = C1, "Nulo" = C2), name = NULL) +
scale_y_continuous(labels = function(x) paste0(x, "%")) +
coord_flip() +
labs(
title = "Completitud por Variable (100% apilado)",
subtitle = "Azul = dato presente | Rojo = nulo",
x = NULL,
y = "% de registros"
) +
tema
print(p5_3)

# ═══════════════════════════════════════════════════════════════════════════════
# SECCIÓN 6 ▸ SEGMENTACIÓN POR CLIENTE
# ═══════════════════════════════════════════════════════════════════════════════
separador("SECCIÓN 6 — SEGMENTACIÓN POR CLIENTE")
##
## ═════════════════════════════════════════════════════════════════
## SECCIÓN 6 — SEGMENTACIÓN POR CLIENTE
## ═════════════════════════════════════════════════════════════════
N_TOP <- 10 # número de top clientes a mostrar
# ── 6.1 Top N clientes por monto total ────────────────────────────────────────
cat(" Generando 6.1: Top", N_TOP, "clientes por monto total...\n")
## Generando 6.1: Top 10 clientes por monto total...
tab_top_cust <- df %>%
filter(!is.na(amount)) %>%
group_by(anon_customer_id) %>%
summarise(
monto_total = sum(amount, na.rm = TRUE),
n_docs = n(),
mora_prom = round(mean(arrears, na.rm = TRUE), 1),
pct_mora_crit = round(
mean(arrears > 90, na.rm = TRUE) * 100, 1),
.groups = "drop"
) %>%
arrange(desc(monto_total)) %>%
slice_head(n = N_TOP) %>%
mutate(
pct_monto = round(monto_total / sum(df$amount, na.rm = TRUE) * 100, 2),
monto_lbl = paste0("$", comma(round(monto_total / 1e6, 1)), "M")
)
cat("\n Top", N_TOP, "clientes por monto total:\n")
##
## Top 10 clientes por monto total:
print(tab_top_cust %>%
select(anon_customer_id, monto_total, pct_monto,
n_docs, mora_prom, pct_mora_crit))
## # A tibble: 10 × 6
## anon_customer_id monto_total pct_monto n_docs mora_prom pct_mora_crit
## <chr> <dbl> <dbl> <int> <dbl> <dbl>
## 1 CUST_317 20676237893. 12.0 65778 26.1 6.2
## 2 CUST_218 19482750020. 11.3 1065 5 0.2
## 3 CUST_215 17363198631. 10.1 2093 36.4 13.2
## 4 CUST_263 10633703698. 6.16 6608 25.6 4.1
## 5 CUST_281 9744251100 5.64 3214 1.9 0.6
## 6 CUST_216 7224419079. 4.18 19360 14.2 3.9
## 7 CUST_17 5564459842. 3.22 5016 14.3 3.3
## 8 CUST_95 5492939964. 3.18 24522 25.9 8.1
## 9 CUST_141 4723907294. 2.74 5638 17 4.8
## 10 CUST_133 4568406870. 2.65 335 -3.9 0
p6_1 <- ggplot(tab_top_cust,
aes(x = reorder(anon_customer_id, monto_total),
y = monto_total)) +
geom_col(aes(fill = pct_mora_crit), alpha = 0.9) +
geom_text(aes(label = paste0(monto_lbl, "\n(", pct_monto, "%)")),
hjust = -0.05, size = 3.2, color = "gray20") +
scale_fill_gradient(
low = C4, high = C2,
name = "% mora crítica\n(arrears > 90d)"
) +
scale_y_continuous(labels = comma,
expand = expansion(mult = c(0, 0.25))) +
coord_flip() +
labs(
title = paste0("Top ", N_TOP, " Clientes por Monto Total"),
subtitle = "Color = % de documentos en mora crítica (>90 días)",
x = NULL,
y = "Monto total (moneda local)"
) +
tema +
theme(legend.position = "right")
print(p6_1)

# ── 6.2 Boxplot de mora por cliente (top N) ───────────────────────────────────
cat(" Generando 6.2: Boxplot de mora por cliente (Top", N_TOP, ")...\n")
## Generando 6.2: Boxplot de mora por cliente (Top 10 )...
top_ids <- tab_top_cust$anon_customer_id
df_top_cust <- df %>%
filter(anon_customer_id %in% top_ids, !is.na(arrears)) %>%
mutate(anon_customer_id = factor(anon_customer_id,
levels = rev(top_ids)))
# Verificar que hay suficientes datos por cliente
n_por_cliente <- df_top_cust %>%
count(anon_customer_id) %>%
filter(n >= 5)
if (nrow(n_por_cliente) >= 2) {
df_top_box <- df_top_cust %>%
filter(anon_customer_id %in% n_por_cliente$anon_customer_id,
arrears >= lim_arr$inf, arrears <= lim_arr$sup)
medianas_cli <- df_top_box %>%
group_by(anon_customer_id) %>%
summarise(med = median(arrears, na.rm = TRUE), .groups = "drop")
p6_2 <- ggplot(df_top_box,
aes(x = anon_customer_id, y = arrears,
fill = anon_customer_id)) +
geom_boxplot(alpha = 0.70, outlier.alpha = 0.15,
outlier.size = 0.7, show.legend = FALSE) +
geom_text(data = medianas_cli,
aes(x = anon_customer_id, y = med,
label = paste0(round(med, 0), "d")),
inherit.aes = FALSE,
hjust = -0.2, size = 3, color = "gray20", fontface = "bold") +
geom_hline(yintercept = 0, linetype = "dashed",
color = C4, linewidth = 0.8) +
coord_flip() +
scale_fill_manual(values = setNames(
colorRampPalette(c(C1, "#8E44AD", C2))(nrow(n_por_cliente)),
n_por_cliente$anon_customer_id
)) +
scale_y_continuous(labels = comma) +
labs(
title = paste0("Distribución de Días de Atraso — Top ", N_TOP, " Clientes"),
subtitle = "Línea verde = 0 días (pago puntual) | Etiqueta = mediana",
x = NULL,
y = "Días de atraso (Arrears) — p1-p99",
caption = paste0("Solo clientes con ≥5 registros | n = ",
nrow(df_top_box), " documentos")
) +
tema
print(p6_2)
} else {
cat(" ⚠ Pocos clientes con ≥5 registros en el top", N_TOP,
"— se omite boxplot por cliente\n")
}

# ── 6.3 Segmentación: bucket_mora por top cliente ─────────────────────────────
cat(" Generando 6.3: Bucket_Mora por top cliente...\n")
## Generando 6.3: Bucket_Mora por top cliente...
tab_cust_bucket <- df %>%
filter(anon_customer_id %in% top_ids, !is.na(bucket_mora)) %>%
mutate(
anon_customer_id = factor(anon_customer_id, levels = rev(top_ids)),
bucket_mora = factor(bucket_mora, levels = ORDEN_BUCKET)
) %>%
count(anon_customer_id, bucket_mora) %>%
group_by(anon_customer_id) %>%
mutate(pct = round(n / sum(n) * 100, 1)) %>%
ungroup()
p6_3 <- ggplot(tab_cust_bucket,
aes(x = anon_customer_id, y = pct, fill = bucket_mora)) +
geom_col(alpha = 0.9, position = "fill") +
geom_text(aes(label = ifelse(pct > 6, paste0(pct, "%"), "")),
position = position_fill(vjust = 0.5),
size = 2.8, color = "white", fontface = "bold") +
scale_fill_manual(values = PALETA_BUCKET, name = "Bucket mora") +
scale_y_continuous(labels = percent) +
coord_flip() +
labs(
title = paste0("Perfil de Mora por Cliente — Top ", N_TOP),
subtitle = "Distribución porcentual de Bucket_Mora dentro de cada cliente",
x = NULL,
y = "% de documentos"
) +
tema
print(p6_3)

# ═══════════════════════════════════════════════════════════════════════════════
# PANEL RESUMEN FINAL — todos los hallazgos clave en una sola vista
# ═══════════════════════════════════════════════════════════════════════════════
separador("PANEL RESUMEN EJECUTIVO")
##
## ═════════════════════════════════════════════════════════════════
## PANEL RESUMEN EJECUTIVO
## ═════════════════════════════════════════════════════════════════
cat(" Generando panel ejecutivo...\n")
## Generando panel ejecutivo...
# Mini versiones de los gráficos más importantes
mini_tema <- tema +
theme(plot.title = element_text(size = 10),
plot.subtitle = element_blank(),
axis.text = element_text(size = 7),
axis.title = element_text(size = 8),
legend.text = element_text(size = 7),
legend.title = element_text(size = 7))
# A: Estado cartera
tab_e <- df %>% count(estado_cartera) %>%
mutate(pct = round(n/sum(n)*100,1))
pA <- ggplot(tab_e, aes(x="", y=n, fill=estado_cartera)) +
geom_col(width=1, color="white") +
coord_polar("y") +
geom_text(aes(label=paste0(pct,"%")),
position=position_stack(vjust=0.5),
color="white", fontface="bold", size=4) +
scale_fill_manual(values=c("Abierta"=C2,"Cerrada"=C4), name=NULL) +
labs(title="Estado Cartera") +
theme_void(base_size=10) +
theme(plot.title=element_text(face="bold",color=C1,size=11),
legend.position="bottom")
# B: Bucket mora
tab_b <- df %>% count(bucket_mora) %>%
mutate(pct=round(n/sum(n)*100,1),
bucket_mora=factor(bucket_mora,levels=ORDEN_BUCKET))
pB <- ggplot(tab_b, aes(x=bucket_mora, y=n, fill=bucket_mora)) +
geom_col(alpha=0.9, show.legend=FALSE) +
geom_text(aes(label=paste0(pct,"%")), vjust=-0.3, size=3) +
scale_fill_manual(values=PALETA_BUCKET) +
scale_y_continuous(labels=comma, expand=expansion(mult=c(0,.12))) +
labs(title="Distribución Bucket Mora", x=NULL, y="Registros") +
mini_tema +
theme(axis.text.x=element_text(angle=30,hjust=1))
# C: Monto total por mes (mini)
pC <- ggplot(df_temp, aes(x=periodo, y=monto_total)) +
geom_area(fill=C1, alpha=0.2) +
geom_line(color=C1, linewidth=0.8) +
scale_x_date(date_labels="%y") +
scale_y_continuous(labels=comma) +
labs(title="Monto mensual", x=NULL, y="Monto") +
mini_tema
# D: Top 5 clientes
pD <- tab_top_cust %>% slice_head(n=5) %>%
ggplot(aes(x=reorder(anon_customer_id,monto_total), y=monto_total)) +
geom_col(fill=C1, alpha=0.85) +
geom_text(aes(label=monto_lbl), hjust=-0.1, size=3) +
coord_flip() +
scale_y_continuous(labels=comma, expand=expansion(mult=c(0,.3))) +
labs(title="Top 5 Clientes", x=NULL, y="Monto") +
mini_tema
# E: Nulos
pE <- tab_nulos %>% filter(N_Nulos>0) %>%
ggplot(aes(x=reorder(Variable,N_Nulos), y=Pct_Nulos, fill=Severidad)) +
geom_col(alpha=0.9) +
coord_flip() +
scale_fill_manual(values=col_severidad, guide="none") +
scale_y_continuous(labels=function(x) paste0(x,"%")) +
labs(title="% Nulos por Variable", x=NULL, y="%") +
mini_tema
# F: Scatter mini
pF <- df_sample %>% slice_head(n = 1000) %>%
ggplot(aes(x=arrears, y=amount, color=bucket_mora)) +
geom_point(alpha=0.3, size=0.8) +
scale_color_manual(values=PALETA_BUCKET, guide="none") +
scale_x_continuous(labels=comma) +
scale_y_continuous(labels=comma) +
labs(title="Scatter Amount vs Arrears", x="Arrears", y="Amount") +
mini_tema
panel_ejecutivo <- (pA | pB | pC) / (pD | pE | pF) +
plot_annotation(
title = "Panel Ejecutivo — EDA Avanzado Base I2C",
subtitle = paste0(formatC(nrow(df), big.mark=","),
" registros | 14 variables | ",
n_distinct(df$anon_customer_id), " clientes"),
caption = paste("Generado:", format(Sys.time(), "%d/%m/%Y %H:%M")),
theme = theme(
plot.title = element_text(face="bold", size=16, color=C1),
plot.subtitle = element_text(size=11, color="gray40"),
plot.caption = element_text(size=8, color="gray55")
)
)
print(panel_ejecutivo)

# ═══════════════════════════════════════════════════════════════════════════════
# FIN DEL SCRIPT
# ═══════════════════════════════════════════════════════════════════════════════
cat("\n", strrep("═", 65), "\n", sep="")
##
## ═════════════════════════════════════════════════════════════════
cat(" ✅ ANÁLISIS AVANZADO COMPLETADO\n\n")
## ✅ ANÁLISIS AVANZADO COMPLETADO
cat(" Gráficos generados:\n")
## Gráficos generados:
cat(" SEC 1 — Bivariado : 5 gráficos (boxplots, barras apiladas, scatter)\n")
## SEC 1 — Bivariado : 5 gráficos (boxplots, barras apiladas, scatter)
cat(" SEC 2 — Temporal : 3 gráficos + panel unificado\n")
## SEC 2 — Temporal : 3 gráficos + panel unificado
cat(" SEC 3 — Correlación: heatmap + tabla de correlaciones fuertes\n")
## SEC 3 — Correlación: heatmap + tabla de correlaciones fuertes
cat(" SEC 4 — Avanzadas : density, density por bucket, violin, facetas\n")
## SEC 4 — Avanzadas : density, density por bucket, violin, facetas
cat(" SEC 5 — Calidad : barras nulos, heatmap completitud, 100% apilado\n")
## SEC 5 — Calidad : barras nulos, heatmap completitud, 100% apilado
cat(" SEC 6 — Clientes : top monto, boxplot mora, perfil bucket\n")
## SEC 6 — Clientes : top monto, boxplot mora, perfil bucket
cat(" PANEL EJECUTIVO : resumen 6 gráficos clave en una vista\n")
## PANEL EJECUTIVO : resumen 6 gráficos clave en una vista
cat("\n Para exportar cualquier gráfico:\n")
##
## Para exportar cualquier gráfico:
cat(" ggsave('nombre.png', plot = last_plot(), width=14, height=8, dpi=150)\n")
## ggsave('nombre.png', plot = last_plot(), width=14, height=8, dpi=150)
cat(strrep("═", 65), "\n", sep="")
## ═════════════════════════════════════════════════════════════════