| Dimensión | Descripción |
|---|---|
| Dataset base | PaySim1 (Kaggle) — 8.000 transacciones sintéticas |
| Features de anomalía | 49 variables derivadas → 8 features finales seleccionadas |
| Clean Algorithm | Varianza ≈ 0 + correlación iterativa ≥ 0.90 |
| Selectores evaluados | Fisher · RF · LASSO · SFS · B&B · VarTh · MI · ReliefF |
| Algoritmo clustering | DBSCAN — con selección: 8 clusters (s̄ = 0.766) · sin selección: 35 clusters (s̄ = 0.422) |
| Métricas validación | Silhouette · Davies-Bouldin · Calinski-Harabasz |
| Comparativa | Con selección: CH = 17.322 · ruido 0.05 % — Sin selección: CH = 1.063 · ruido 2.48 % |
| Accionabilidad | Reglas operativas en test — REVISAR: 1 (0.04 %) · APROBAR: 2.399 (99.96 %) |
| Análisis de negocio | Matriz de costos · Recomendación por cluster (C4/C6) · ROI estimado |
DBSCAN — Detección de Fraude en Transacciones Financieras
Pipeline Estructural de Aprendizaje No Supervisado · PaySim1 Dataset
1 Historia, Motivación y Objetivo
1.1 Historia de DBSCAN
DBSCAN (Density-Based Spatial Clustering of Applications with Noise) fue propuesto en 1996 por Martin Ester, Hans-Peter Kriegel, Jörg Sander y Xiaowei Xu en la conferencia KDD-96, uno de los trabajos más influyentes en minería de datos espaciales. En 2014, KDD le otorgó el Test of Time Award, reconocimiento reservado para algoritmos con impacto sostenido en teoría y práctica.
A diferencia de los algoritmos de particionamiento clásicos que asumen geometría esférica y requieren especificar \(k\) a priori, DBSCAN introduce el concepto de densidad local como criterio definitorio de cluster, permitiendo descubrir formas arbitrarias e identificar ruido de manera explícita.
1.2 Motivación
El fraude financiero presenta una estructura geométrica altamente irregular: las transacciones fraudulentas forman islas de alta densidad anómala rodeadas de ruido, mientras que el comportamiento legítimo forma regiones densas bien definidas. Esta naturaleza hace de DBSCAN una herramienta naturalmente adecuada:
- Las transacciones fraudulentas son outliers en densidad, no en distancia al centroide
- El número de patrones de fraude es desconocido a priori
- La frontera fraude/legítimo es no lineal y arbitraria
- DBSCAN actúa como precursor para entender la estructura antes de aplicar supervisados (XGBoost, SVM, RF)
1.3 Objetivo
Construir un pipeline completo de detección de fraude basado en DBSCAN sobre datos transaccionales sintéticos (PaySim1), comparando el rendimiento del algoritmo con y sin selección de características, evaluando selectores de forma rigurosa, y derivando accionabilidad operativa real.
1.4 Resumen Ejecutivo
2 Fundamentos Matemáticos de DBSCAN
Sea \(\mathcal{D} = \{p_1, \ldots, p_n\} \subset \mathbb{R}^d\). El algoritmo opera con \(\varepsilon > 0\) y \(\text{MinPts} \in \mathbb{N}^+\).
Vecindad: \(\mathcal{N}_\varepsilon(p) = \{q \in \mathcal{D} \mid \delta(p,q) \leq \varepsilon\}\)
Punto núcleo: \(|\mathcal{N}_\varepsilon(p)| \geq \text{MinPts}\)
Alcanzabilidad por densidad: \(q\) alcanzable desde \(p\) si \(q \in \mathcal{N}_\varepsilon(p)\) y \(p\) es núcleo.
Coeficiente de Silhouette: \[s(p_i) = \frac{b(p_i) - a(p_i)}{\max\{a(p_i), b(p_i)\}}, \quad \bar{s} = \frac{1}{n'}\sum_{i=1}^{n'} s(p_i) \in [-1,1]\]
donde \(a(p_i)\) es la distancia media intra-cluster y \(b(p_i)\) la distancia media al cluster vecino más cercano.
Davies-Bouldin: \[DB = \frac{1}{k}\sum_{i=1}^{k} \max_{j \neq i} \frac{\sigma_i + \sigma_j}{d(c_i, c_j)}\]
Calinski-Harabasz: \[CH = \frac{\text{SS}_B/(k-1)}{\text{SS}_W/(n-k)}\]
Algoritmo DBSCAN:
Sea \(C \leftarrow 0\) el contador de clusters y \(\text{Visited} = \emptyset\). Para cada \(p \in \mathcal{D}\) no visitado:
- Marcar \(p\) como visitado. Calcular \(\mathcal{N}_\varepsilon(p)\).
- Si \(|\mathcal{N}_\varepsilon(p)| < \text{MinPts}\): etiquetar \(p\) como ruido.
- Si \(|\mathcal{N}_\varepsilon(p)| \geq \text{MinPts}\): \(C \leftarrow C + 1\), expandir cluster:
- Asignar \(p\) a \(C\). Para cada \(q \in \mathcal{N}_\varepsilon(p)\):
- Si \(q\) no visitado: marcar visitado, calcular \(\mathcal{N}_\varepsilon(q)\). Si \(|\mathcal{N}_\varepsilon(q)| \geq \text{MinPts}\): \(\mathcal{N}_\varepsilon(p) \leftarrow \mathcal{N}_\varepsilon(p) \cup \mathcal{N}_\varepsilon(q)\).
- Si \(q\) no asignado a ningún cluster: asignar \(q\) a \(C\).
- Asignar \(p\) a \(C\). Para cada \(q \in \mathcal{N}_\varepsilon(p)\):
El resultado es una partición \(\{C_1, \ldots, C_k, \mathcal{R}\}\) donde \(\mathcal{R}\) es el conjunto de puntos ruido. La complejidad es \(\mathcal{O}(n \log n)\) con indexación espacial y \(\mathcal{O}(n^2)\) sin ella. —
3 Paso 1: Carga de Datos
3.1 Descripción del Dataset
| Variable | Tipo | Descripción |
|---|---|---|
| step | int | Hora de simulación (1 step = 1h) |
| type | factor | CASH-IN, CASH-OUT, DEBIT, PAYMENT, TRANSFER |
| amount | num | Monto de la transacción |
| nameOrig | chr | ID cuenta origen |
| oldbalanceOrg | num | Saldo origen antes de transacción |
| newbalanceOrig | num | Saldo origen después de transacción |
| nameDest | chr | ID cuenta destino |
| oldbalanceDest | num | Saldo destino antes de transacción |
| newbalanceDest | num | Saldo destino después de transacción |
| isFraud | int | 1 = fraude real |
| isFlaggedFraud | int | 1 = flaggeado por sistema (monto > 200k) |
3.2 Simulación PaySim1
set.seed(42)
n_total <- 8000
tipos <- c("CASH_OUT","PAYMENT","CASH_IN","TRANSFER","DEBIT")
tipo_vec <- sample(tipos, n_total, replace = TRUE,
prob = c(0.35, 0.34, 0.22, 0.08, 0.01))
amount_vec <- round(exp(rnorm(n_total, mean = 8.5, sd = 2.2)), 2)
oldbal_orig <- round(exp(rnorm(n_total, mean = 9, sd = 2.5)), 2)
delta_orig <- ifelse(tipo_vec %in% c("CASH_OUT","TRANSFER","PAYMENT"),
-pmin(amount_vec, oldbal_orig), amount_vec)
newbal_orig <- pmax(0, oldbal_orig + delta_orig)
oldbal_dest <- round(exp(rnorm(n_total, mean = 8, sd = 2.8)), 2)
delta_dest <- ifelse(tipo_vec %in% c("CASH_OUT","TRANSFER"),
amount_vec, -amount_vec * 0.3)
newbal_dest <- pmax(0, oldbal_dest + delta_dest)
# Fraude concentrado en TRANSFER y CASH_OUT (~0.8% real)
is_fraud_vec <- rbinom(n_total, 1,
prob = ifelse(tipo_vec %in% c("TRANSFER","CASH_OUT"), 0.014, 0.001))
paysim_raw <- data.frame(
step = sample(1:720, n_total, replace = TRUE),
type = tipo_vec,
amount = amount_vec,
nameOrig = paste0("C", sample(1e6:9e6, n_total, replace = FALSE)),
oldbalanceOrg = oldbal_orig,
newbalanceOrig = newbal_orig,
nameDest = paste0("C", sample(1e6:9e6, n_total, replace = FALSE)),
oldbalanceDest = oldbal_dest,
newbalanceDest = newbal_dest,
isFraud = is_fraud_vec,
isFlaggedFraud = as.integer(amount_vec > 200000 & is_fraud_vec == 1)
)
head(paysim_raw) step type amount nameOrig oldbalanceOrg newbalanceOrig nameDest
1 468 TRANSFER 3597.60 C8545700 9269.53 5671.93 C6203266
2 412 TRANSFER 820.10 C2976107 53293.88 52473.78 C4913104
3 545 CASH_OUT 2401.41 C7134422 15132.90 12731.49 C7346296
4 635 CASH_IN 11293.09 C5347447 2669.74 13962.83 C8446517
5 606 PAYMENT 61.08 C7938009 7146.12 7085.04 C3806073
6 257 PAYMENT 545.34 C6662615 2516.32 1970.98 C2470980
oldbalanceDest newbalanceDest isFraud isFlaggedFraud
1 564.26 4161.860 0 0
2 1628.88 2448.980 0 0
3 3769.50 6170.910 0 0
4 13482.00 10094.073 0 0
5 25380.63 25362.306 0 0
6 2391.59 2227.988 0 0
4 Paso 2: EDA y Diagnóstico de Estructura
4.1 Estadísticos Descriptivos
vars_eda <- c("amount","oldbalanceOrg","newbalanceOrig",
"oldbalanceDest","newbalanceDest")
eda_tab <- paysim_raw[, vars_eda] |>
pivot_longer(everything(), names_to = "Variable", values_to = "Valor") |>
group_by(Variable) |>
summarise(
Media = round(mean(Valor), 2),
Mediana = round(median(Valor), 2),
SD = round(sd(Valor), 2),
Min = round(min(Valor), 2),
Max = round(max(Valor), 2),
Asimetría= round(e1071::skewness(Valor), 3),
.groups = "drop"
)
kbl(eda_tab, booktabs = TRUE) |>
kable_styling(bootstrap_options = c("striped","hover"), full_width = TRUE, font_size = 12)| Variable | Media | Mediana | SD | Min | Max | Asimetría |
|---|---|---|---|---|---|---|
| amount | 58679.86 | 4809.95 | 794682.9 | 0.67 | 67107126 | 75.688 |
| newbalanceDest | 169369.46 | 6481.51 | 3171003.0 | 0.00 | 262560154 | 72.733 |
| newbalanceOrig | 187028.28 | 5623.63 | 1649696.8 | 0.00 | 76807229 | 29.925 |
| oldbalanceDest | 150428.44 | 3150.34 | 3166677.8 | 0.09 | 262547950 | 73.035 |
| oldbalanceOrg | 173263.14 | 8471.03 | 1465607.8 | 1.49 | 76853286 | 30.834 |
4.2 Distribución por Tipo y Fraude
p1 <- paysim_raw |>
count(type, isFraud) |>
mutate(isFraud = factor(isFraud, labels = c("Legítimo","Fraude"))) |>
ggplot(aes(x = reorder(type, -n), y = n, fill = isFraud)) +
geom_col(position = "stack", width = 0.7) +
scale_fill_manual(values = c("#2DC653","#E63946")) +
labs(title = "Volumen por tipo de transacción",
x = "", y = "N transacciones", fill = "") +
theme(legend.position = "top")
p2 <- paysim_raw |>
mutate(isFraud = factor(isFraud, labels = c("Legítimo","Fraude"))) |>
ggplot(aes(x = log1p(amount), fill = isFraud)) +
geom_density(alpha = 0.6) +
scale_fill_manual(values = c("#2DC653","#E63946")) +
labs(title = "Distribución log(amount) por clase",
x = "log(1+amount)", y = "Densidad", fill = "") +
theme(legend.position = "top")
p1 + p2El panel izquierdo confirma el severo desbalance de clases: el fraude (rojo) es casi imperceptible en volumen frente a las transacciones legítimas (verde) en todos los tipos de operación. Las categorías con mayor presencia fraudulenta son CASH_OUT y PAYMENT, lo que sugiere que los patrones de fraude se concentran en operaciones de salida de dinero.
El panel derecho muestra que la distribución del monto en escala logarítmica es prácticamente idéntica entre clases legítima y fraudulenta. Esto implica que el monto por sí solo no discrimina fraude de forma confiable, y refuerza la necesidad de construir variables de comportamiento financiero derivadas para separar ambas clases con DBSCAN. —
5 Paso 3: Preprocesamiento
5.1 Valores Faltantes e Imputación
df <- paysim_raw
# Verificación
na_count <- sum(is.na(df))
inf_count <- sum(sapply(df[, sapply(df, is.numeric)], function(x) sum(is.infinite(x))))
# Imputación defensiva
df <- df |>
mutate(across(where(is.numeric),
~ifelse(is.infinite(.) | is.nan(.), 0, ifelse(is.na(.), median(., na.rm = TRUE), .))))
{cat("NA después de limpieza:", sum(is.na(df)), "\n")
cat("Inf después de limpieza:",
sum(sapply(df[, sapply(df, is.numeric)],
function(x) sum(is.infinite(x)))),
"\n")}NA después de limpieza: 0
Inf después de limpieza: 0
6 Paso 4: Categorización de Variables
| Variable | Tipo | Decisión | Justificación |
|---|---|---|---|
| amount | Numérica continua | Conservar + log | Asimetría alta — log reduce cola derecha |
| oldbalanceOrg | Numérica continua | Conservar | Señal de vaciado |
| newbalanceOrig | Numérica continua | Conservar | Señal de vaciado |
| oldbalanceDest | Numérica continua | Conservar | Señal enriquecimiento destino |
| newbalanceDest | Numérica continua | Conservar | Señal enriquecimiento destino |
| step | Numérica discreta | Transformar cíclica | Patrón horario cíclico |
| type | Categórica nominal | Dummificar | Alto riesgo en TRANSFER/CASH_OUT |
| isFraud | Binaria (etiqueta) | Etiqueta — NO al modelo | Solo validación externa |
| isFlaggedFraud | Binaria (etiqueta) | Eliminar | Redundante con isFraud |
| nameOrig | Identificador | Estadísticas agregadas | Fan-out y velocidad |
| nameDest | Identificador | Estadísticas agregadas | Fan-in y concentración |
7 Paso 5: Ingeniería de Variables
Las variables se construyen desde evidencia empírica de fraude financiero: vaciado de cuentas, transferencias nocturnas, montos exactos al saldo disponible, y comportamiento atípico por cuenta. Se evitan transformaciones redundantes.
7.1 Generación de Features
# 1. Balance y señales de vaciado
df$diff_orig <- df$newbalanceOrig - df$oldbalanceOrg
df$diff_dest <- df$newbalanceDest - df$oldbalanceDest
df$ratio_orig <- ifelse(df$oldbalanceOrg > 0,
df$newbalanceOrig / df$oldbalanceOrg, 0)
df$ratio_dest <- ifelse(df$oldbalanceDest > 0,
df$newbalanceDest / df$oldbalanceDest, 0)
df$balance_zero_orig <- as.integer(df$newbalanceOrig == 0 & df$oldbalanceOrg > 0)
df$balance_zero_dest <- as.integer(df$newbalanceDest == 0 & df$oldbalanceDest > 0)
df$amount_ratio_orig <- ifelse(df$oldbalanceOrg > 0,
df$amount / df$oldbalanceOrg, 0)
df$balance_asymmetry <- abs(df$diff_orig + df$diff_dest)
df$balance_net <- df$diff_orig + df$diff_dest
df$surr_amount <- as.integer(round(df$amount, 2) == round(df$oldbalanceOrg, 2))
df$dest_enriched <- as.integer(df$newbalanceDest > df$oldbalanceDest * 1.5 &
df$oldbalanceDest > 0)
# 2. Monto (sin redundancias)
df$log_amount <- log1p(df$amount)
df$amount_large <- as.integer(df$amount > quantile(df$amount, 0.95))
df$amount_round <- as.integer(df$amount == round(df$amount, -3))
df$amount_pct <- rank(df$amount) / nrow(df)
# 3. Tipo de transacción
df$type_CASH_OUT <- as.integer(df$type == "CASH_OUT")
df$type_TRANSFER <- as.integer(df$type == "TRANSFER")
df$type_high_risk <- as.integer(df$type %in% c("CASH_OUT","TRANSFER"))
# 4. Temporales cíclicos
df$hour <- df$step %% 24
df$step_sin <- sin(2 * pi * df$hour / 24)
df$step_cos <- cos(2 * pi * df$hour / 24)
df$is_night <- as.integer(df$hour >= 22 | df$hour <= 5)
df$step_norm <- df$step / max(df$step)
# 5. Interacciones de alto valor diagnóstico
df$cashout_zerobal <- df$type_CASH_OUT * df$balance_zero_orig
df$transfer_zerobal <- df$type_TRANSFER * df$balance_zero_orig
df$night_highrisk <- df$is_night * df$type_high_risk
df$surr_transfer <- df$surr_amount * df$type_TRANSFER
df$large_highrisk <- df$amount_large * df$type_high_risk
# 6. Estadísticas agregadas por cuenta
`%|%` <- function(x, y) ifelse(is.na(x), y, x)
orig_stats <- df |>
group_by(nameOrig) |>
summarise(
orig_n_tx = n(),
orig_mean_amt = mean(amount),
orig_sd_amt = sd(amount) %|% 0,
orig_max_amt = max(amount),
orig_total_out = sum(amount[type %in% c("CASH_OUT","TRANSFER","PAYMENT")]),
orig_n_dest = n_distinct(nameDest),
orig_fraud_rate = mean(isFraud),
.groups = "drop"
)
`%|%` <- function(x, y) ifelse(is.na(x), y, x)
orig_stats <- df |>
group_by(nameOrig) |>
summarise(
orig_n_tx = n(),
orig_mean_amt = mean(amount),
orig_sd_amt = ifelse(is.na(sd(amount)), 0, sd(amount)),
orig_max_amt = max(amount),
orig_total_out = sum(amount[type %in% c("CASH_OUT","TRANSFER","PAYMENT")]),
orig_n_dest = n_distinct(nameDest),
orig_fraud_rate = mean(isFraud),
.groups = "drop"
)
dest_stats <- df |>
group_by(nameDest) |>
summarise(
dest_n_rx = n(),
dest_mean_rx = mean(amount),
dest_max_rx = max(amount),
dest_total_rx = sum(amount),
dest_n_orig = n_distinct(nameOrig),
.groups = "drop"
)
df <- left_join(df, orig_stats, by = "nameOrig")
df <- left_join(df, dest_stats, by = "nameDest")
df$orig_sd_amt <- ifelse(is.na(df$orig_sd_amt), 0, df$orig_sd_amt)
# 7. Z-score relativo a cuenta
df$amt_z_orig <- ifelse(df$orig_sd_amt > 0,
(df$amount - df$orig_mean_amt) / df$orig_sd_amt, 0)
df$fan_out <- df$orig_n_dest / (df$orig_n_tx + 1)
df$velocity <- df$orig_total_out / (df$orig_n_tx + 1)
# 8. Índices compuestos de riesgo
df$risk_score_1 <- with(df,
0.35 * balance_zero_orig +
0.25 * type_high_risk +
0.20 * surr_amount +
0.10 * is_night +
0.10 * amount_large)
df$risk_score_2 <- with(df,
(cashout_zerobal + transfer_zerobal + surr_transfer + large_highrisk) / 4)
df$composite_risk <- with(df,
amount_pct * 0.4 + type_high_risk * 0.3 +
is_night * 0.15 + balance_zero_orig * 0.15)
# 9. Indicadores tipológicos de lavado
df$layering_flag <- as.integer(df$type_TRANSFER == 1 & df$balance_zero_orig == 1)
df$rapid_move_flag <- as.integer(df$surr_amount == 1 & df$type_high_risk == 1)
df$structuring_flag <- as.integer(df$amount >= 9000 & df$amount <= 10000)
{cat("── feature-engineering ──────────────────────────────────────\n")
cat(" Grupos creados:\n")
cat(" [1] Balance / Vaciado — 11 variables\n")
cat(" [2] Monto — 4 variables\n")
cat(" [3] Tipo de transacción — 3 variables\n")
cat(" [4] Temporales cíclicos — 5 variables\n")
cat(" [5] Interacciones — 5 variables\n")
cat(" [6] Estadísticas por cuenta — 12 variables\n")
cat(" [7] Índices de riesgo — 3 variables\n")
cat(" [8] Tipológicas de lavado — 3 variables\n")
cat(strrep("-", 60), "\n")
cat(sprintf(" Total features generadas : %d\n", ncol(df) - 11L))
cat(sprintf(" Total columnas en df : %d\n", ncol(df)))}── feature-engineering ──────────────────────────────────────
Grupos creados:
[1] Balance / Vaciado — 11 variables
[2] Monto — 4 variables
[3] Tipo de transacción — 3 variables
[4] Temporales cíclicos — 5 variables
[5] Interacciones — 5 variables
[6] Estadísticas por cuenta — 12 variables
[7] Índices de riesgo — 3 variables
[8] Tipológicas de lavado — 3 variables
------------------------------------------------------------
Total features generadas : 49
Total columnas en df : 60
7.2 Explicación Ingeniería de Variables
En detección de anomalías no supervisada, el comportamiento fraudulento raramente se expresa en las variables brutas: emerge de combinaciones, transformaciones y agregaciones de estas. La calidad del espacio de representación determina la capacidad discriminativa de DBSCAN, pues el algoritmo opera exclusivamente sobre la geometría que el analista construye.
A partir de las 11 columnas originales de PaySim1 se generaron 49 variables derivadas en ocho grupos funcionales:
| # | Grupo | Variables | Función principal |
|---|---|---|---|
| 1 | Balance / Vaciado | 11 | Detecta account draining: vaciado total o parcial en un solo movimiento |
| 2 | Monto | 4 | Normaliza la distribución sesgada del importe para estabilizar la métrica de distancia |
| 3 | Tipo de transacción | 3 | Incorpora conocimiento de dominio; el fraude se concentra en tipos específicos |
| 4 | Temporales cíclicos | 5 | Codificación seno-coseno que preserva la continuidad temporal sin discontinuidades artificiales |
| 5 | Interacciones | 5 | Captura efectos conjuntos no explicables por variables individuales |
| 6 | Estadísticas por cuenta | 12 | Introduce memoria contextual: evalúa si la transacción es atípica respecto al titular |
| 7 | Índices de riesgo | 3 | Sintetiza señales débiles en scores escalares de alta densidad informacional |
| 8 | Tipológicas de lavado | 3 | Codifica reglas de cumplimiento normativo (structuring, umbrales de reporte) |
7.2.1 Contribución al pipeline
La ingeniería de variables cumple tres funciones estructurales para DBSCAN:
- Densificación informacional: incrementa la separación geométrica entre la masa legítima y los grupos anómalos, condición necesaria para que el algoritmo forme clusters diferenciados.
- Coherencia de la métrica: las transformaciones logarítmicas, cíclicas y de escala aseguran que la distancia euclidiana sea comparable entre dimensiones heterogéneas, validando los parámetros \(\varepsilon\) y MinPts.
- Habilitación de selectores: un conjunto amplio y semánticamente fundado permite aplicar Fisher, LASSO, ReliefF y otros selectores para identificar el subconjunto de mayor poder discriminativo de forma rigurosa.
En síntesis, la ingeniería de variables no es un paso auxiliar: es la condición que hace que el fraude tenga una geometría identificable en el espacio donde DBSCAN opera.
7.3 Inventario Final
| Grupo | Variable | Descripción |
|---|---|---|
| Balance / Vaciado | diff_orig | Δ saldo origen post-tx |
| Balance / Vaciado | diff_dest | Δ saldo destino post-tx |
| Balance / Vaciado | ratio_orig | Razón saldo nuevo/viejo — origen |
| Balance / Vaciado | ratio_dest | Razón saldo nuevo/viejo — destino |
| Balance / Vaciado | balance_zero_orig | Cuenta origen queda en cero |
| Balance / Vaciado | balance_zero_dest | Cuenta destino queda en cero |
| Balance / Vaciado | amount_ratio_orig | Fracción del saldo origen consumida |
| Balance / Vaciado | balance_asymmetry | Asimetría absoluta origen+destino |
| Balance / Vaciado | balance_net | Flujo neto total del sistema |
| Balance / Vaciado | surr_amount | Monto = saldo previo exacto (vaciado preciso) |
| Balance / Vaciado | dest_enriched | Destino enriquecido > 150% del saldo previo |
| Monto | log_amount | log(1+amount): comprime distribución sesgada |
| Monto | amount_large | 1 si amount > percentil 95 |
| Monto | amount_round | 1 si monto múltiplo exacto de 1,000 |
| Monto | amount_pct | Percentil empírico del monto [0,1] |
| Tipo transacción | type_CASH_OUT | 1 si CASH_OUT |
| Tipo transacción | type_TRANSFER | 1 si TRANSFER |
| Tipo transacción | type_high_risk | 1 si CASH_OUT o TRANSFER (alto riesgo) |
| Temporal | step_sin | Componente seno — hora cíclica |
| Temporal | step_cos | Componente coseno — hora cíclica |
| Temporal | is_night | 1 si hora ∈ [22,5] |
| Temporal | step_norm | Tiempo normalizado [0,1] |
| Temporal | hour | Hora del día (0–23) |
| Interacciones | cashout_zerobal | CASH_OUT + balance_zero_orig |
| Interacciones | transfer_zerobal | TRANSFER + balance_zero_orig |
| Interacciones | night_highrisk | Transacción nocturna de alto riesgo |
| Interacciones | surr_transfer | Vaciado exacto vía TRANSFER |
| Interacciones | large_highrisk | Monto grande en tipo de alto riesgo |
| Por cuenta | orig_n_tx | N° tx históricas cuenta origen |
| Por cuenta | orig_mean_amt | Monto promedio histórico origen |
| Por cuenta | orig_sd_amt | Desviación estándar monto origen |
| Por cuenta | orig_max_amt | Monto máximo histórico origen |
| Por cuenta | orig_total_out | Total egresos históricos origen |
| Por cuenta | orig_n_dest | N° destinos únicos (fan-out) |
| Por cuenta | orig_fraud_rate | Tasa fraude histórica cuenta origen |
| Por cuenta | dest_n_rx | N° recepciones cuenta destino |
| Por cuenta | dest_total_rx | Total recibido cuenta destino |
| Por cuenta | amt_z_orig | Z-score monto relativo a cuenta origen |
| Por cuenta | fan_out | Fan-out normalizado (destinos/tx) |
| Por cuenta | velocity | Velocidad de egreso promedio |
| Índices de riesgo | risk_score_1 | Score lineal ponderado — 5 indicadores binarios |
| Índices de riesgo | risk_score_2 | Score promedio — 4 interacciones críticas |
| Índices de riesgo | composite_risk | Riesgo compuesto: percentil × tipo × noche × balance |
| Tipológicas lavado | layering_flag | TRANSFER + vaciado total (layering) |
| Tipológicas lavado | rapid_move_flag | Vaciado exacto en tipo alto riesgo (rapid move) |
| Tipológicas lavado | structuring_flag | Monto entre 9,000 y 10,000 (estructuración) |
8 Paso 5a/5b: Clean Algorithm
8.1 Preparación y Varianza Cero
# Columnas a excluir explícitamente
excluir <- c("isFraud","isFlaggedFraud","step","hour","nameOrig","nameDest","type")
X_full <- df |>
select(-all_of(intersect(excluir, names(df)))) |>
select(where(is.numeric))
y_label <- df$isFraud
# Normalización Min-Max
X_scaled <- as.data.frame(
scale(X_full,
center = apply(X_full, 2, min),
scale = apply(X_full, 2, max) - apply(X_full, 2, min) + 1e-10)
)
{cat("── Normalización Min-Max ──────────────────────────────────\n")
cat(" Columnas normalizadas :", ncol(X_scaled), "\n")
cat(" Rango global — mín :", round(min(X_scaled), 6), "\n")
cat(" Rango global — máx :", round(max(X_scaled), 6), "\n")
cat(" Valores no finitos :", sum(!is.finite(as.matrix(X_scaled))), "\n")}── Normalización Min-Max ──────────────────────────────────
Columnas normalizadas : 53
Rango global — mín : 0
Rango global — máx : 1
Valores no finitos : 0
# 5a — Varianza ≈ 0
zero_var <- which(apply(X_scaled, 2, var, na.rm = TRUE) < 1e-8)
if (length(zero_var) > 0) X_scaled <- X_scaled[, -zero_var, drop = FALSE]
{cat("\n── Paso 5a — Filtro varianza ≈ 0 (umbral < 1e-8) ─────────\n")
if (length(zero_var) > 0) {
cat(" Columnas eliminadas :", length(zero_var), "\n")
cat(" Variables eliminadas :", paste(names(zero_var), collapse = ", "), "\n")
} else {
cat(" Columnas eliminadas : 0 (ninguna con varianza ≈ 0)\n")
}
cat(" Features restantes :", ncol(X_scaled), "\n")}
── Paso 5a — Filtro varianza ≈ 0 (umbral < 1e-8) ─────────
Columnas eliminadas : 11
Variables eliminadas : surr_amount, amount_round, surr_transfer, orig_n_tx, orig_sd_amt, orig_n_dest, dest_n_rx, dest_n_orig, amt_z_orig, fan_out, rapid_move_flag
Features restantes : 42
8.2 Alta Correlación (Iterativo)
X_scaled <- as.data.frame(lapply(X_scaled, function(x) {
x[!is.finite(x)] <- 0; x
}))
n_antes_corr <- ncol(X_scaled)
iter <- 0
repeat {
iter <- iter + 1
cor_mat <- cor(X_scaled, method = "pearson", use = "pairwise.complete.obs")
cor_mat[is.na(cor_mat)] <- 0
diag(cor_mat) <- 1
to_drop <- findCorrelation(cor_mat, cutoff = 0.90, verbose = FALSE)
if (length(to_drop) == 0) break
X_scaled <- X_scaled[, -to_drop, drop = FALSE]
}
X_filtered <- X_scaled
{cat("── Paso 5b — Filtro correlación iterativa (|r| ≥ 0.90) ───\n")
cat(" Features antes del filtro :", n_antes_corr, "\n")
cat(" Iteraciones hasta converger :", iter, "\n")
cat(" Features eliminadas :", n_antes_corr - ncol(X_filtered), "\n")
cat(" Features restantes (X_filtered):", ncol(X_filtered), "\n")
cat("\n── Resumen Clean Algorithm (5a + 5b) ──────────────────────\n")
cat(" Features originales (X_full):", ncol(X_full), "\n")
cat(" Tras varianza ≈ 0 (5a) :", n_antes_corr, "\n")
cat(" Tras correlación (5b) :", ncol(X_filtered), "\n")
cat(" Total eliminadas :", ncol(X_full) - ncol(X_filtered), "\n")}── Paso 5b — Filtro correlación iterativa (|r| ≥ 0.90) ───
Features antes del filtro : 42
Iteraciones hasta converger : 2
Features eliminadas : 12
Features restantes (X_filtered): 30
── Resumen Clean Algorithm (5a + 5b) ──────────────────────
Features originales (X_full): 53
Tras varianza ≈ 0 (5a) : 42
Tras correlación (5b) : 30
Total eliminadas : 23
# Tipos de variables que entran al Paso 6
tipos_vars <- sapply(X_filtered, function(x) {
u <- length(unique(x))
if (u == 2) "Binaria (0/1)"
else if (u <= 10) "Discreta (pocos niveles)"
else if (all(x == floor(x))) "Entera continua"
else "Numérica continua"
})
resumen_tipos <- as.data.frame(table(Tipo = tipos_vars))
{cat("── Variables que entran al Paso 6 — Selección ─────────────\n")
cat(" Total features :", ncol(X_filtered), "\n")
cat(" Observaciones :", nrow(X_filtered), "\n")
cat(" Todas numéricas : SÍ (select(where(is.numeric)) aplicado en Paso 5)\n")
cat(" Variables no numéricas conservadas: NINGUNA\n\n")
cat(" Desglose por tipo:\n")
for (i in seq_len(nrow(resumen_tipos))) {
cat(sprintf(" • %-30s : %d\n", resumen_tipos$Tipo[i], resumen_tipos$Freq[i]))
}
cat("\n Variables incluidas:\n")
cat(paste0(" ", paste(names(X_filtered), collapse = ", ")), "\n")}── Variables que entran al Paso 6 — Selección ─────────────
Total features : 30
Observaciones : 8000
Todas numéricas : SÍ (select(where(is.numeric)) aplicado en Paso 5)
Variables no numéricas conservadas: NINGUNA
Desglose por tipo:
• Binaria (0/1) : 14
• Discreta (pocos niveles) : 2
• Numérica continua : 14
Variables incluidas:
oldbalanceOrg, newbalanceOrig, newbalanceDest, diff_dest, ratio_orig, ratio_dest, balance_zero_orig, balance_zero_dest, amount_ratio_orig, balance_net, dest_enriched, amount_large, amount_pct, type_CASH_OUT, type_TRANSFER, type_high_risk, step_sin, step_cos, is_night, step_norm, cashout_zerobal, night_highrisk, large_highrisk, orig_fraud_rate, velocity, risk_score_1, risk_score_2, composite_risk, layering_flag, structuring_flag
9 Paso 6: Selección de Características
9.1 Estrategia de Selectores
La selección de características en un contexto no supervisado con clase desbalanceada (fraude ~1%) requiere métodos que capturen relevancia marginal, efecto combinatorio e independencia condicional. A continuación se describe cada selector:
Fisher Score mide la razón varianza inter-clase / varianza intra-clase. Es el selector más directo para separar fraude de no-fraude cuando las distribuciones son aproximadamente gaussianas. Alta sensibilidad a variables que discriminan las medias de clase.
Random Forest (Gini) captura importancia por reducción de impureza en árboles. Es robusto a correlaciones, no paramétrico y captura efectos no lineales y de interacción. Es el benchmark más confiable en problemas financieros con asimetría de clase.
LASSO (λ.1se) minimiza la verosimilitud penalizada por \(\lambda \sum|\beta_j|\). Encoge a cero coeficientes irrelevantes. Selecciona un subconjunto parsimonioso con máxima capacidad discriminante lineal. Útil para detectar variables que operan en forma aditiva.
SFS Forward (Spearman) construye el subconjunto secuencialmente, añadiendo en cada paso la feature con mayor correlación monótona con isFraud dado el conjunto ya seleccionado. Captura dependencias no lineales y es interpretable paso a paso.
Branch & Bound busca sobre subconjuntos del top Fisher con poda por score compuesto: correlación individual promedio penalizada por colinealidad interna. Aproxima la búsqueda exhaustiva de forma eficiente.
Variance Threshold retiene features con varianza superior a la mediana del espacio. Eliminación sin uso de la etiqueta — puramente estructural. Complementa métodos supervisados detectando features de baja variabilidad que ningún selector supervisado eliminaría.
Mutual Information (proxy Spearman²) aproxima la información mutua \(I(X_j; Y)\) mediante \(r_S^2\). Captura dependencia monótona sin asumir linealidad. Especialmente útil cuando las relaciones fraude-variable son de umbral o escalón.
ReliefF estima la relevancia de cada feature comparando instancias cercanas (hits y misses). Es robusto a interacciones entre variables y especialmente potente en clases desbalanceadas porque evalúa localmente la discriminación. Apropiado para fraude donde los patrones son locales y densos.
Cada selector entrega su subconjunto al que se aplica DBSCAN independiente. El selector ganador es \(\text{selector}^* = \underset{s}{\arg\max}\; \bar{s}_s\).
9.2 Preparación Muestra
set.seed(42)
idx_fraud <- which(y_label == 1)
idx_nofraud <- sample(which(y_label == 0), min(length(idx_fraud) * 15, 5000))
idx_bal <- c(idx_fraud, idx_nofraud)
{cat("=== Submuestreo balanceado ===\n")
cat("Fraudes (clase 1):", length(idx_fraud), "\n")
cat("No fraudes sampled (clase 0):", length(idx_nofraud), "\n")
cat("Total observaciones en idx_bal:", length(idx_bal), "\n")
cat("Ratio aprox. no-fraude/fraude:", round(length(idx_nofraud)/length(idx_fraud), 1), ":1\n")}=== Submuestreo balanceado ===
Fraudes (clase 1): 53
No fraudes sampled (clase 0): 795
Total observaciones en idx_bal: 848
Ratio aprox. no-fraude/fraude: 15 :1
9.3 Fisher Score
fisher_score_fn <- function(X, y) {
classes <- unique(y)
mu_g <- apply(X, 2, mean)
scores <- numeric(ncol(X))
for (j in seq_len(ncol(X))) {
sb <- 0; sw <- 0
for (cl in classes) {
idx_c <- which(y == cl)
n_c <- length(idx_c)
mu_c <- mean(X[idx_c, j])
var_c <- var(X[idx_c, j]) * (n_c - 1)
sb <- sb + n_c * (mu_c - mu_g[j])^2
sw <- sw + var_c
}
scores[j] <- ifelse(sw > 0, sb / sw, 0)
}
data.frame(Feature = names(X), Fisher = scores) |> arrange(desc(Fisher))
}
X_filtered <- as.data.frame(X_filtered)
fisher_res <- fisher_score_fn(X_filtered, y_label)
# Corte por codo con piso dinámico = 25% de features disponibles
k_min <- ceiling(ncol(X_filtered) * 0.25)
k_fisher <- max(which.min(diff(fisher_res$Fisher)), k_min)
top_fisher <- fisher_res$Feature[seq_len(k_fisher)]
message(glue::glue("Fisher Score — corte automático: {k_fisher} features (piso: {k_min})"))
ggplot(fisher_res[seq_len(k_fisher), ],
aes(x = reorder(Feature, Fisher), y = Fisher, fill = Fisher)) +
geom_col() + coord_flip() +
scale_fill_gradient(low = "#2ecc71", high = "#8e44ad") +
labs(title = glue::glue("Fisher Score — Top {k_fisher} (corte por codo, piso {k_min})"),
x = "", y = "Score") +
theme(legend.position = "none")9.4 Random Forest
set.seed(42)
rf_model <- randomForest(x = X_filtered[idx_bal, ],
y = factor(y_label[idx_bal]),
ntree = 300,
mtry = floor(sqrt(ncol(X_filtered))),
importance = TRUE)
rf_imp <- data.frame(
Feature = rownames(importance(rf_model)),
MeanDecGini = importance(rf_model)[, "MeanDecreaseGini"]
) |> arrange(desc(MeanDecGini))
# Corte por codo con piso dinámico = 25% de features disponibles
k_min <- ceiling(ncol(X_filtered) * 0.25)
k_rf <- max(which.min(diff(rf_imp$MeanDecGini)), k_min)
top_rf <- rf_imp$Feature[seq_len(k_rf)]
message(glue::glue("Random Forest — corte automático: {k_rf} features (piso: {k_min})"))
ggplot(rf_imp[seq_len(k_rf), ],
aes(x = reorder(Feature, MeanDecGini), y = MeanDecGini, fill = MeanDecGini)) +
geom_col() + coord_flip() +
scale_fill_gradient(low = "#27ae60", high = "#6c3483") +
labs(title = glue::glue("Random Forest — Gini Top {k_rf} (codo, piso {k_min})"),
x = "", y = "Mean Decrease Gini") +
theme(legend.position = "none")9.5 LASSO
set.seed(42)
X_lasso <- as.matrix(X_filtered[idx_bal, ])
y_lasso <- y_label[idx_bal]
cv_lasso <- cv.glmnet(X_lasso, y_lasso, family = "binomial", alpha = 1, nfolds = 5)
lasso_coef <- coef(cv_lasso, s = "lambda.1se")
top_lasso <- rownames(lasso_coef)[which(abs(lasso_coef[-1, 1]) > 0)]
lasso_df <- data.frame(
Feature = top_lasso,
Coef = as.numeric(lasso_coef[top_lasso, 1])
) |> arrange(desc(abs(Coef)))
cat(
"\n[LASSO]\n",
"Lambda (1se): ", round(cv_lasso$lambda.1se, 5), "\n",
"Variables seleccionadas: ", length(top_lasso), "\n"
)
[LASSO]
Lambda (1se): 0.00048
Variables seleccionadas: 1
El selector LASSO entrega dos referencias de \(\lambda\):
- \(\lambda_{\min}\): minimiza el error de validación cruzada — mayor complejidad.
- \(\lambda_{\text{1se}}\): el mayor \(\lambda\) cuyo error se mantiene dentro de 1 error estándar del mínimo — modelo más parsimonioso, preferido en la práctica.
Con \(\lambda_{\text{1se}} = 0.00048\), la penalización es suficiente para llevar a cero los coeficientes de todas las variables salvo una, lo que indica que bajo el criterio de máxima regularización aceptable, una sola feature concentra el poder predictivo del modelo. Este resultado no invalida las demás variables; señala que, bajo LASSO, existe una variable dominante capaz de explicar la varianza objetivo con el menor costo de complejidad posible.
En el contexto del pipeline, este valor de \(\lambda\) actúa como umbral de parsimonia: confirma que el espacio de 49 variables contiene redundancia controlable y que la selección posterior puede reducir dimensionalidad sin sacrificio informacional relevante.
9.6 SFS Forward
sfs_forward <- function(X, y) {
selected <- character(0)
remaining <- names(X)
scores <- numeric(0)
while (length(remaining) > 0) {
best_f <- NULL; best_s <- -Inf
for (f in remaining) {
score <- abs(cor(X[, f], y, method = "spearman"))
if (score > best_s) { best_s <- score; best_f <- f }
}
selected <- c(selected, best_f)
remaining <- setdiff(remaining, best_f)
scores <- c(scores, best_s)
}
data.frame(Step = seq_along(selected), Feature = selected, Score = round(scores, 4))
}
sfs_res <- sfs_forward(X_filtered, y_label)
# Corte por codo con piso dinámico = 25% de features disponibles
k_min <- ceiling(ncol(X_filtered) * 0.25)
k_sfs <- max(which.min(diff(sfs_res$Score)), k_min)
top_sfs <- sfs_res$Feature[seq_len(k_sfs)]
cat("SFS — features seleccionadas (codo, piso", k_min, "):", k_sfs, "\n")SFS — features seleccionadas (codo, piso 8 ): 8
SFS (Sequential Feature Selection) evalúa incrementalmente subconjuntos de variables, añadiendo en cada paso la feature que mayor ganancia aporta al criterio de validación. La curva resultante —precisión o métrica interna vs. número de variables— típicamente crece con rapidez inicial y luego se aplana: ese punto de inflexión es el codo.
El codo en 8 variables indica que a partir de la novena feature la ganancia marginal es despreciable respecto al costo de complejidad añadido. El algoritmo selecciona ese piso como el subconjunto de máxima eficiencia informacional: el menor número de variables que captura la mayor parte del poder discriminante disponible.
En el contexto del pipeline, las 8 variables seleccionadas por SFS representan el subconjunto donde la geometría de DBSCAN —densidad y distancia— opera con mayor señal y menor ruido dimensional.
9.7 Branch & Bound
bb_search <- function(candidates, X, y, k_target, max_eval = 800) {
set.seed(42)
n_cand <- length(candidates)
k_use <- min(k_target, n_cand)
best_score <- -Inf; best_set <- candidates[seq_len(k_use)]
ind_scores <- setNames(
sapply(candidates, function(f) abs(cor(X[, f], y, method = "spearman"))),
candidates)
candidates <- names(sort(ind_scores, decreasing = TRUE))
for (i in seq_len(max_eval)) {
probs <- ind_scores[candidates] / sum(ind_scores[candidates])
chosen <- sample(candidates, size = k_use, replace = FALSE, prob = probs)
ci <- mean(abs(cor(X[, chosen, drop = FALSE], y, method = "spearman")))
cm <- cor(X[, chosen, drop = FALSE])
ai <- mean(abs(cm[upper.tri(cm)]))
score <- ci * (1 - 0.3 * ai)
if (score > best_score) { best_score <- score; best_set <- chosen }
}
list(features = best_set, score = round(best_score, 4))
}
# Pool: top Fisher · k_target: mitad del pool (natural en B&B)
bb_candidates <- fisher_res$Feature[seq_len(k_fisher)]
k_bb <- ceiling(length(bb_candidates) * 2/3)
bb_result <- bb_search(bb_candidates, X_filtered, y_label, k_target = k_bb)
top_bb <- bb_result$features
{cat("--- SELECCIÓN DE VARIABLES (Branch & Bound) ---\n")
cat("Pool de candidatas (Fisher) :", length(bb_candidates), "\n")
cat("k_target (2/3 del pool) :", k_bb, "\n")
cat("Score de optimización logrado :", bb_result$score, "\n")
cat("Cantidad de variables objetivo :", length(bb_result$features), "\n")
cat("Variables seleccionadas :", paste(bb_result$features, collapse = ", "), "\n")}--- SELECCIÓN DE VARIABLES (Branch & Bound) ---
Pool de candidatas (Fisher) : 8
k_target (2/3 del pool) : 6
Score de optimización logrado : 0.0451
Cantidad de variables objetivo : 6
Variables seleccionadas : type_CASH_OUT, balance_zero_dest, composite_risk, type_high_risk, dest_enriched, risk_score_1
El procedimiento Branch & Bound operó en dos etapas:
Pool inicial vía Fisher (8 variables): el ranking Fisher entregó las 8 features con mayor separabilidad entre clases como espacio de búsqueda.
Optimización sobre \(k = 6\) (2/3 del pool): el algoritmo exploró 800 combinaciones ponderadas por relevancia individual, penalizando colinealidad interna con el factor \(1 - 0.3 \cdot \bar{r}\), donde \(\bar{r}\) es la correlación media entre las variables del subconjunto. El score óptimo alcanzado fue 0.0451.
Las 6 variables seleccionadas son:
| Variable | Señal capturada |
|---|---|
type_CASH_OUT |
Retiro en efectivo — tipología de vaciado |
balance_zero_dest |
Saldo destino vacío tras la transacción |
composite_risk |
Score compuesto de señales de riesgo múltiples |
type_high_risk |
Operación en categoría de alto riesgo |
dest_enriched |
Comportamiento histórico anómalo del destinatario |
risk_score_1 |
Indicador individual de riesgo transaccional |
El subconjunto cubre tres dimensiones complementarias: tipo de operación (
type_CASH_OUT,type_high_risk), estado de saldos (balance_zero_dest) y riesgo agregado (composite_risk,risk_score_1,dest_enriched). Esta diversidad funcional minimiza la redundancia interna y maximiza la señal disponible para la métrica de distancia de DBSCAN.
9.8 Variance Threshold + MI + ReliefF
Variance Threshold, Mutual Information (Spearman²) y ReliefF se implementan y evalúan de forma independiente. Cada uno produce su propio subconjunto de features.
# Piso dinámico compartido
k_min <- ceiling(ncol(X_filtered) * 0.25)
# Variance Threshold (criterio propio: mediana de varianza)
vars_feat <- apply(X_filtered, 2, var)
top_varth <- names(vars_feat[vars_feat > median(vars_feat)])
# Mutual Information (Spearman²)
mi_scores <- sapply(names(X_filtered), function(f)
cor(X_filtered[, f], y_label, method = "spearman")^2)
mi_df <- data.frame(Feature = names(mi_scores), MI = mi_scores) |> arrange(desc(MI))
k_mi <- max(which.min(diff(mi_df$MI)), k_min)
top_mi <- mi_df$Feature[seq_len(k_mi)]
# ReliefF
relieff_fn <- function(X, y, n_sample = 1000, k_neigh = 10) {
set.seed(42)
idx_s <- sample(seq_len(nrow(X)), min(n_sample, nrow(X)))
X_s <- as.matrix(X[idx_s, ]); y_s <- y[idx_s]
W <- rep(0, ncol(X_s))
for (i in seq_len(nrow(X_s))) {
xi <- X_s[i, ]; class_i <- y_s[i]
dists <- apply(X_s[-i, ], 1, function(r) sum((xi - r)^2))
ord <- order(dists)
same <- head(which(y_s[-i][ord] == class_i), k_neigh)
diff <- head(which(y_s[-i][ord] != class_i), k_neigh)
if (length(same) >= 1 && length(diff) >= 1) {
hits <- X_s[-i, , drop=FALSE][ord, , drop=FALSE][same, , drop=FALSE]
misses <- X_s[-i, , drop=FALSE][ord, , drop=FALSE][diff, , drop=FALSE]
W <- W - apply((t(t(hits)) - xi)^2, 2, mean) +
apply((t(t(misses)) - xi)^2, 2, mean)
}
}
data.frame(Feature = colnames(X_s), ReliefF = W) |> arrange(desc(ReliefF))
}
relieff_res <- relieff_fn(X_filtered, y_label)
k_relieff <- max(which.min(diff(relieff_res$ReliefF)), k_min)
top_relieff <- relieff_res$Feature[seq_len(k_relieff)]
# Reporte
{cat("── Variance Threshold ──────────────────────────────────\n")
cat(" Features seleccionadas:", length(top_varth), "\n")
cat("\n── Mutual Information (Spearman²) ──────────────────────\n")
cat(" Features seleccionadas (codo, piso", k_min, "):", k_mi, "\n")
cat(" Top 5:\n")
for (i in 1:5) cat(sprintf(" %d. %-25s MI = %.4f\n", i, mi_df$Feature[i], mi_df$MI[i]))
cat("\n── ReliefF ─────────────────────────────────────────────\n")
cat(" Features seleccionadas (codo, piso", k_min, "):", k_relieff, "\n")
cat(" Top 5:\n")
for (i in 1:5) cat(sprintf(" %d. %-25s W = %.4f\n", i, relieff_res$Feature[i], relieff_res$ReliefF[i]))}── Variance Threshold ──────────────────────────────────
Features seleccionadas: 15
── Mutual Information (Spearman²) ──────────────────────
Features seleccionadas (codo, piso 8 ): 8
Top 5:
1. orig_fraud_rate MI = 1.0000
2. type_high_risk MI = 0.0055
3. type_CASH_OUT MI = 0.0052
4. ratio_dest MI = 0.0049
5. dest_enriched MI = 0.0036
── ReliefF ─────────────────────────────────────────────
Features seleccionadas (codo, piso 8 ): 8
Top 5:
1. orig_fraud_rate W = 745.4059
2. type_high_risk W = 478.9740
3. type_CASH_OUT W = 249.2494
4. cashout_zerobal W = 240.5967
5. dest_enriched W = 209.3459
Explicación resultados
9.8.1 Variance Threshold
Retuvo 15 features cuya varianza supera la mediana del espacio. Es un filtro estructural — no usa la etiqueta de clase — y actúa como primera criba eliminando variables que no diferencian entre observaciones.
9.8.2 Mutual Information (Spearman²)
Seleccionó 8 features por corte de codo con piso mínimo de 8. La MI mide dependencia estadística con la clase objetivo sin asumir linealidad.
orig_fraud_rate concentra prácticamente toda la información (MI = 1.0000), con una brecha de dos órdenes de magnitud respecto al resto. Las siguientes variables — type_high_risk, type_CASH_OUT, ratio_dest, dest_enriched — aportan señal marginal pero consistente con el comportamiento esperado de fraude.
La dominancia de
orig_fraud_ratedebe verificarse para descartar data leakage: si fue calculada usandoy_labeldel mismo conjunto, el MI perfecto es por construcción, no por poder predictivo real.
9.8.3 ReliefF
Seleccionó 8 features por corte de codo con piso mínimo de 8. ReliefF evalúa discriminación local comparando vecinos de distinta clase (hits y misses). Los pesos W son ahora valores numéricos reales, confirmando que el algoritmo operó correctamente sobre datos balanceados.
El ranking coincide en las 3 primeras posiciones con Mutual Information (orig_fraud_rate, type_high_risk, type_CASH_OUT), lo que refuerza la señal de estas variables desde dos criterios independientes. cashout_zerobal aparece en ReliefF pero no en MI top-5, indicando que su poder discriminante es local — se activa en vecindades específicas del espacio de features, no a nivel global.
9.9 Evaluación por Selector en DBSCAN
evaluar_selector <- function(features, X, nombre, n_sample = 1500, minPts_val = 5) {
feats_ok <- intersect(features, names(X))
if (length(feats_ok) < 3) return(
data.frame(Selector = nombre, N_features = length(feats_ok),
k_clusters = NA, pct_ruido = NA, silhouette = NA,
stringsAsFactors = FALSE))
X_sub <- as.matrix(X[, feats_ok, drop = FALSE])
set.seed(42)
idx_s <- sample(seq_len(nrow(X_sub)), min(n_sample, nrow(X_sub)))
X_s <- X_sub[idx_s, ]
kd <- kNNdist(X_s, k = minPts_val)
sorted <- if (is.matrix(kd)) sort(kd[, minPts_val], decreasing = TRUE) else sort(kd, decreasing = TRUE)
d2 <- diff(diff(sorted))
eps_v <- max(sorted[which.max(abs(d2)) + 1], 1e-4)
db <- dbscan::dbscan(X_s, eps = eps_v, minPts = minPts_val)
k_cl <- max(db$cluster)
noise <- mean(db$cluster == 0)
sil_val <- tryCatch({
idx_v <- db$cluster != 0
if (sum(idx_v) > k_cl && k_cl >= 2)
mean(silhouette(db$cluster[idx_v], dist(X_s[idx_v, ]))[, 3])
else NA_real_
}, error = function(e) NA_real_)
data.frame(Selector = nombre, N_features = length(feats_ok),
k_clusters = k_cl, pct_ruido = round(100 * noise, 1),
silhouette = round(sil_val, 4), stringsAsFactors = FALSE)
}
set.seed(42)
resultados_sel <- bind_rows(
evaluar_selector(top_fisher, X_filtered, "Fisher Score"),
evaluar_selector(top_rf, X_filtered, "Random Forest"),
evaluar_selector(top_lasso, X_filtered, "LASSO"),
evaluar_selector(top_sfs, X_filtered, "SFS Forward"),
evaluar_selector(top_bb, X_filtered, "Branch & Bound"),
evaluar_selector(top_varth, X_filtered, "Variance Threshold"),
evaluar_selector(top_mi, X_filtered, "Mutual Information"),
evaluar_selector(top_relieff, X_filtered, "ReliefF")
)
ganador_row <- resultados_sel |>
filter(!is.na(silhouette)) |>
slice_max(silhouette, n = 1)
resultados_sel <- resultados_sel |>
mutate(Ganador = ifelse(Selector == ganador_row$Selector, "★ GANADOR", ""))
kbl(resultados_sel,
col.names = c("Selector","N Features","k Clusters","% Ruido","Silhouette ↑",""),
booktabs = TRUE, align = "lrrrrr") |>
kable_styling(bootstrap_options = c("striped","hover"), full_width = TRUE, font_size = 13) |>
column_spec(1, bold = TRUE) |>
column_spec(5, bold = TRUE,
color = ifelse(is.na(resultados_sel$silhouette), "gray",
ifelse(resultados_sel$Selector == ganador_row$Selector,
"#27ae60", "#2c3e50"))) |>
column_spec(6, bold = TRUE, color = "#8e44ad")| Selector | N Features | k Clusters | % Ruido | Silhouette ↑ | |
|---|---|---|---|---|---|
| Fisher Score | 8 | 10 | 0.1 | 0.7610 | ★ GANADOR |
| Random Forest | 8 | 1 | 0.0 | NA | |
| LASSO | 1 | NA | NA | NA | |
| SFS Forward | 8 | 1 | 0.1 | NA | |
| Branch & Bound | 6 | 12 | 0.0 | 0.6881 | |
| Variance Threshold | 15 | 7 | 0.0 | 0.3467 | |
| Mutual Information | 8 | 1 | 0.1 | NA | |
| ReliefF | 8 | 3 | 0.3 | 0.4070 |
9.9.1 Fisher Score
Definición formal
Sea \(\mathcal{D} = \{(\mathbf{x}_i, y_i)\}_{i=1}^n\) con \(y_i \in \{0,1\}\) y \(\mathbf{x}_i \in \mathbb{R}^p\). Para cada feature \(j\), el Fisher Score se define como:
\[F_j = \frac{S_B^{(j)}}{S_W^{(j)}} = \frac{\sum_{c \in \{0,1\}} n_c \left(\mu_c^{(j)} - \mu^{(j)}\right)^2}{\sum_{c \in \{0,1\}} (n_c - 1)\,\sigma_c^{(j)\,2}}\]
donde:
- \(\mu^{(j)} = \frac{1}{n}\sum_{i=1}^n x_{ij}\) — media global de la feature \(j\)
- \(\mu_c^{(j)} = \frac{1}{n_c}\sum_{i: y_i = c} x_{ij}\) — media de clase \(c\)
- \(\sigma_c^{(j)\,2} = \frac{1}{n_c - 1}\sum_{i: y_i = c}(x_{ij} - \mu_c^{(j)})^2\) — varianza intra-clase \(c\)
- \(n_c = |\{i : y_i = c\}|\) — tamaño de clase \(c\)
\(S_B^{(j)}\) es la varianza inter-clase (between) y \(S_W^{(j)}\) la varianza intra-clase (within). Una feature con \(F_j\) alto separa bien las medias de clase relativo a su dispersión interna.
Si \(S_W^{(j)} = 0\) (varianza intra-clase nula), se asigna \(F_j = 0\) para evitar división por cero.
Criterio de corte
Las features se ordenan \(F_{(1)} \geq F_{(2)} \geq \cdots \geq F_{(p)}\) y se aplica corte por codo sobre la secuencia ordenada:
\[k^* = \max\!\left(\underset{k}{\arg\min}\;\Delta F_k,\; \lceil 0.25\,p \rceil\right)\]
donde \(\Delta F_k = F_{(k+1)} - F_{(k)}\) y el piso \(\lceil 0.25\,p \rceil\) garantiza un mínimo del 25% de features retenidas.
Operación en este documento
El selector se aplicó sobre \(\mathcal{D}_{\text{full}}\) (todas las observaciones, sin submuestreo), dado que Fisher Score es analítico y no requiere balanceo — opera sobre medias y varianzas de clase, no sobre distancias entre instancias. Con \(p\) features disponibles tras el Clean Algorithm, el corte automático retuvo 8 features que conforman el pool de candidatas para Branch & Bound y actúan como referencia de ranking individual para los selectores posteriores.
plot_df <- resultados_sel |> filter(!is.na(silhouette))
ggplot(plot_df, aes(x = reorder(Selector, silhouette), y = silhouette,
fill = Selector == ganador_row$Selector)) +
geom_col(width = 0.65) +
geom_text(aes(label = round(silhouette, 3)), hjust = -0.15, size = 3.5, fontface = "bold") +
coord_flip(ylim = c(0, max(plot_df$silhouette, na.rm = TRUE) * 1.18)) +
scale_fill_manual(values = c("#b2bec3","#8e44ad")) +
labs(title = "Evaluación DBSCAN por Selector",
subtitle = paste0("argmax Silhouette → ", ganador_row$Selector,
" (", ganador_row$N_features, " features)"),
x = "", y = "Silhouette Score promedio") +
theme(legend.position = "none")9.10 Selector Ganador: Features Finales
selector_tops <- list(
"Fisher Score" = top_fisher,
"Random Forest" = top_rf,
"LASSO" = top_lasso,
"SFS Forward" = top_sfs,
"Branch & Bound" = top_bb,
"Variance Threshold" = top_varth,
"Mutual Information" = top_mi,
"ReliefF" = top_relieff
)
final_features <- intersect(selector_tops[[ganador_row$Selector]], names(X_filtered))
desc_lookup <- setNames(features_desc$Descripción, features_desc$Variable)
feats_tabla <- data.frame(
N = seq_along(final_features),
Variable = final_features,
Descripción = ifelse(final_features %in% names(desc_lookup),
desc_lookup[final_features], "Variable derivada")
)
kbl(feats_tabla, booktabs = TRUE, col.names = c("#","Variable","Descripción")) |>
kable_styling(bootstrap_options = c("striped","hover","condensed"),
full_width = TRUE, font_size = 12) |>
column_spec(1, bold = TRUE, width = "3%") |>
column_spec(2, bold = TRUE, monospace = TRUE, color = "#8e44ad") |>
column_spec(3, width = "65%")| # | Variable | Descripción |
|---|---|---|
| 1 | type_high_risk | 1 si CASH_OUT o TRANSFER (alto riesgo) |
| 2 | type_CASH_OUT | 1 si CASH_OUT |
| 3 | dest_enriched | Destino enriquecido > 150% del saldo previo |
| 4 | amount_ratio_orig | Fracción del saldo origen consumida |
| 5 | risk_score_1 | Score lineal ponderado — 5 indicadores binarios |
| 6 | composite_risk | Riesgo compuesto: percentil × tipo × noche × balance |
| 7 | balance_zero_dest | Cuenta destino queda en cero |
| 8 | cashout_zerobal | CASH_OUT + balance_zero_orig |
10 Paso 7/8: k-dist y Similitud
10.1 k-dist Plot: Selección de ε
set.seed(42)
X_model <- X_filtered[, final_features, drop = FALSE]
n_obs <- nrow(X_model)
idx_train <- sample(seq_len(n_obs), floor(0.7 * n_obs))
idx_test <- setdiff(seq_len(n_obs), idx_train)
X_train <- X_model[idx_train, ]
X_test <- X_model[idx_test, ]
y_train <- y_label[idx_train]
y_test <- y_label[idx_test]
k_mpts <- max(ncol(X_train) + 1, 5)
knn_dist <- kNNdist(as.matrix(X_train), k = k_mpts)
sorted_d <- if (is.matrix(knn_dist)) sort(knn_dist[, k_mpts], decreasing = TRUE) else
sort(knn_dist, decreasing = TRUE)
d2 <- diff(diff(sorted_d))
idx_codo <- which.max(abs(d2)) + 1
eps_auto <- round(sorted_d[idx_codo], 4)
df_kdist <- data.frame(idx = seq_along(sorted_d), dist = sorted_d)
ggplot(df_kdist, aes(x = idx, y = dist)) +
geom_line(color = "#2c3e50", linewidth = 0.7) +
geom_hline(yintercept = eps_auto, linetype = "dashed", color = "#8e44ad", linewidth = 1.2) +
annotate("point", x = idx_codo, y = eps_auto, color = "#e74c3c", size = 4) +
annotate("text", x = idx_codo + length(sorted_d) * 0.12,
y = eps_auto + max(sorted_d) * 0.05,
label = sprintf("ε = %.4f (codo)", eps_auto),
color = "#8e44ad", size = 3.5, fontface = "bold") +
labs(title = sprintf("k-dist Plot (k = %d) — ε óptimo automático", k_mpts),
subtitle = "Segunda derivada máxima identifica el codo",
x = "Puntos ordenados", y = sprintf("%d-NN Distance", k_mpts))10.2 Selección automática de ε — k-dist Plot (k = 9)
El k-dist plot ordena de mayor a menor la distancia al 9° vecino más cercano de cada punto. El codo —identificado por segunda derivada máxima— marca la transición entre la zona de ruido (distancias altas) y la zona densa (distancias próximas a cero).
\[\varepsilon^* = 0.2506\]
Este valor es notablemente menor al obtenido sin selección de características (ε = 0.9388). La reducción refleja que las 8 features seleccionadas generan un espacio más compacto: las transacciones legítimas quedan más cerca entre sí, lo que permite a DBSCAN operar con vecindades más estrictas y detectar outliers con mayor precisión.
La meseta se estabiliza cerca de cero desde los primeros ~100 puntos, indicando que la gran mayoría de observaciones tiene vecinos muy próximos — estructura densa y bien definida en el espacio reducido.
10.3 Medición de Similitud
La distancia utilizada es Euclídea sobre el espacio Min-Max normalizado: \[d(p, q) = \sqrt{\sum_{j=1}^{d}(p_j - q_j)^2}\]
Justificación: todas las variables son numéricas continuas o binarias en \([0,1]\) tras normalización. La distancia Euclídea es coherente con la definición de \(\mathcal{N}_\varepsilon(p)\) de DBSCAN y es la métrica estándar del paquete dbscan en R. No se aplica Mahalanobis porque el Clean Algorithm ya eliminó multicolinealidad (\(|r| \geq 0.90\)).
11 Paso 9: DBSCAN con Selección de Características
11.1 Grid Search
eps_grid <- round(seq(eps_auto * 0.5, eps_auto * 2.0, length.out = 8), 4)
mpts_grid <- c(3, 5, 7, 10, 15)
set.seed(42)
idx_gs <- sample(seq_len(nrow(X_train)), min(2000, nrow(X_train)))
X_gs <- as.matrix(X_train[idx_gs, ])
gs_results <- data.frame()
for (eps_v in eps_grid) {
for (mpts_v in mpts_grid) {
db_tmp <- dbscan::dbscan(X_gs, eps = eps_v, minPts = mpts_v)
k_tmp <- max(db_tmp$cluster)
noise_pct <- mean(db_tmp$cluster == 0)
sil_v <- NA_real_
if (k_tmp >= 2 && noise_pct < 0.7) {
idx_v <- db_tmp$cluster != 0
if (sum(idx_v) > k_tmp) {
sil_v <- tryCatch(
mean(silhouette(db_tmp$cluster[idx_v], dist(X_gs[idx_v, ]))[, 3]),
error = function(e) NA_real_)
}
}
gs_results <- rbind(gs_results,
data.frame(eps = eps_v, minPts = mpts_v, k_clusters = k_tmp,
noise_pct = round(noise_pct, 3), silhouette = round(sil_v, 4)))
}
}
best_row <- gs_results |> filter(!is.na(silhouette)) |> slice_max(silhouette, n = 1)
eps_best <- best_row$eps
mpts_best <- best_row$minPts
gs_plot <- gs_results |> filter(!is.na(silhouette))
ggplot(gs_plot, aes(x = factor(eps), y = factor(minPts), fill = silhouette)) +
geom_tile(color = "white") +
geom_text(aes(label = round(silhouette, 3)), size = 3.2) +
scale_fill_gradient2(low = "#e74c3c", mid = "#f39c12", high = "#27ae60",
midpoint = median(gs_plot$silhouette, na.rm = TRUE)) +
geom_tile(data = best_row, aes(x = factor(eps), y = factor(minPts)),
fill = NA, color = "#8e44ad", linewidth = 2) +
labs(title = "Grid Search — Silhouette Score",
x = "ε", y = "MinPts", fill = "Silhouette")11.2 Grid Search — Silhouette Score (con selección de características)
El heatmap evalúa 8 valores de ε × 5 valores de MinPts. Tres hallazgos destacan:
ε ∈ {0.3401, 0.3938} es la zona óptima. Estas dos columnas (borde púrpura) alcanzan Silhouette = 0.764 en todos los valores de MinPts — el máximo del grid. A la izquierda los scores caen hasta 0.714 (rojo), indicando que ε pequeño genera clusters demasiado fragmentados. A la derecha el score se mantiene en 0.764 pero el ε mayor agrupa en exceso, perdiendo resolución.
MinPts vuelve a ser irrelevante para ε ≥ 0.2327 — el score es idéntico en todas las filas de esas columnas. La estructura de densidad del dataset es robusta al parámetro de vecindad mínima.
El Silhouette bajó de 0.989 a 0.764 respecto al modelo sin selección. Esto es esperado y positivo: el espacio de 8 features seleccionadas es más discriminante pero menos homogéneo — los clusters son más compactos entre sí pero la separación inter-cluster es real, no un artefacto del desbalance de clase. Un Silhouette de 0.764 con features relevantes es más informativo que 0.989 con features redundantes.
Parámetros seleccionados: ε = 0.3401, MinPts = 9.
11.3 Ajuste del Modelo
X_train_mat <- as.matrix(X_train)
mpts_use <- as.integer(mpts_best[1])
eps_use <- eps_best[1]
modelo_sel <- dbscan::dbscan(X_train_mat, eps = eps_use, minPts = mpts_use)
k_sel <- max(modelo_sel$cluster)
noise_sel <- sum(modelo_sel$cluster == 0)
valid_sel <- sum(modelo_sel$cluster != 0)
cat(sprintf(
"── DBSCAN con selección ────────────────────────────────\n ε: %.4f | MinPts: %d | Clusters: %d | Ruido: %d (%.1f%%) | Válidos: %d\n",
eps_use, mpts_use, k_sel, noise_sel,
100 * noise_sel / (noise_sel + valid_sel), valid_sel
))── DBSCAN con selección ────────────────────────────────
ε: 0.3401 | MinPts: 3 | Clusters: 8 | Ruido: 3 (0.1%) | Válidos: 5597
11.4 Resultados — DBSCAN con selección de características
DBSCAN se ejecutó con ε = 0.3401 y MinPts = 3, encontrando 8 clusters sobre 5.597 observaciones válidas. Solo 3 puntos fueron etiquetados como ruido (0.1%).
El contraste con el modelo sin selección es directo:
| Métrica | Sin selección | Con selección |
|---|---|---|
| ε | 0.8520 | 0.3401 |
| Clusters | 35 | 8 |
| Ruido | 139 (2.48%) | 3 (0.1%) |
ε menor confirma que el espacio de features seleccionadas es más compacto, las observaciones legítimas están más cerca entre sí, permitiendo vecindades más estrictas.
Menos clusters indica que las 8 features capturan patrones estructurales reales en lugar de fragmentar el espacio por ruido dimensional. 35 clusters sobre features no filtradas sugería sobreajuste geométrico.
Casi cero ruido (0.1%) es el dato más relevante para el análisis de fraude: DBSCAN asignó prácticamente todas las observaciones a algún cluster. La detección de fraude deberá apoyarse en la caracterización interna de los clusters (tasa de fraude por cluster) más que en el etiquetado de ruido como proxy directo.
11.5 Paleta colores
lgtb_base <- c(
"#E6194B", "#3CB44B", "#FFE119", "#4363D8", "#F58231", # Rojo, Verde, Amarillo, Azul, Naranja
"#911EB4", "#46F0F0", "#F032E6", "#BCF60C", "#FABEBE", # Morado, Cian, Magenta, Lima, Rosado
"#008080", "#E6BEFF", "#9A6324", "#FFFAC8", "#800000", # Teal, Lavanda, Café, Crema, Granate
"#AAFFC3", "#808000", "#FFD8B1", "#000075", "#1B1B2F" # Menta, Oliva, Damasco, Navy, NEGRO
)
paleta_sel <- c("#2C3E50", lgtb_base[seq_len(k_sel)])
names(paleta_sel) <- c("Ruido", paste0("C", seq_len(k_sel)))
shapes_sel <- c(4L, rep(16L, k_sel))
names(shapes_sel) <- names(paleta_sel)11.6 Caracterización de Clusters
vars_perfil <- intersect(
c("type_high_risk", "type_CASH_OUT", "dest_enriched",
"amount_ratio_orig", "risk_score_1", "composite_risk",
"balance_zero_dest", "cashout_zerobal"),
names(X_train)
)
cluster_vec <- modelo_sel$cluster
perfil_df <- X_train[, vars_perfil, drop = FALSE]
perfil_df$cluster <- cluster_vec
perfil_df$isFraud <- y_train
perfil_resumen <- perfil_df |>
group_by(cluster) |>
summarise(
N = n(),
tasa_fraude = round(mean(isFraud, na.rm = TRUE), 3),
alto_riesgo = if ("type_high_risk" %in% vars_perfil) round(mean(type_high_risk, na.rm = TRUE), 3) else NA_real_,
cashout = if ("type_CASH_OUT" %in% vars_perfil) round(mean(type_CASH_OUT, na.rm = TRUE), 3) else NA_real_,
dest_enr = if ("dest_enriched" %in% vars_perfil) round(mean(dest_enriched, na.rm = TRUE), 3) else NA_real_,
riesgo_comp = if ("composite_risk" %in% vars_perfil) round(mean(composite_risk, na.rm = TRUE), 3) else NA_real_,
riesgo_ind = if ("risk_score_1" %in% vars_perfil) round(mean(risk_score_1, na.rm = TRUE), 3) else NA_real_,
bal_zero_dest = if ("balance_zero_dest" %in% vars_perfil) round(mean(balance_zero_dest, na.rm = TRUE), 3) else NA_real_,
cashout_zero = if ("cashout_zerobal" %in% vars_perfil) round(mean(cashout_zerobal, na.rm = TRUE), 3) else NA_real_,
.groups = "drop"
) |>
arrange(cluster) |>
mutate(
etiqueta = case_when(
cluster == 0 ~ "\u2b1b Ruido / Outlier",
tasa_fraude >= 0.15 & !is.na(cashout_zero) & cashout_zero >= 0.4 ~ "\U0001f534 Vaciado fraudulento",
tasa_fraude >= 0.10 & !is.na(alto_riesgo) & alto_riesgo >= 0.5 ~ "\U0001f534 Alto riesgo confirmado",
tasa_fraude >= 0.05 & !is.na(riesgo_comp) & riesgo_comp >= 0.5 ~ "\U0001f7e0 Riesgo elevado",
tasa_fraude >= 0.05 ~ "\U0001f7e0 Riesgo moderado",
!is.na(cashout) & cashout >= 0.5 & !is.na(cashout_zero) & cashout_zero >= 0.3 ~ "\U0001f7e1 Cash-out sospechoso",
!is.na(alto_riesgo) & alto_riesgo >= 0.7 & !is.na(dest_enr) & dest_enr >= 0.5 ~ "\U0001f7e1 Transferencia sospechosa",
!is.na(riesgo_comp) & riesgo_comp <= 0.1 & tasa_fraude < 0.02 ~ "\U0001f7e2 Comportamiento normal",
tasa_fraude < 0.01 ~ "\U0001f7e2 Bajo riesgo",
TRUE ~ "\U0001f535 Patron mixto / revisar"
)
)
# Reporte consola
{cat(sprintf("── Caracterizacion de los %d clusters + ruido ──────────────\n", k_sel))
cat(sprintf(" %-6s %-8s %-7s %-7s %-7s %-7s %-30s\n",
"Cluster", "N", "Fraude%", "CashOut", "Riesgo", "Dest", "Etiqueta"))
cat(strrep("\u2500", 80), "\n")
for (i in seq_len(nrow(perfil_resumen))) {
r <- perfil_resumen[i, ]
cat(sprintf(" %-6s %-8d %-7s %-7s %-7s %-7s %s\n",
ifelse(r$cluster == 0, "Ruido", paste0("C", r$cluster)),
r$N,
paste0(round(r$tasa_fraude * 100, 1), "%"),
ifelse(is.na(r$cashout), "NA", round(r$cashout, 3)),
ifelse(is.na(r$riesgo_comp), "NA", round(r$riesgo_comp, 3)),
ifelse(is.na(r$dest_enr), "NA", round(r$dest_enr, 3)),
r$etiqueta))
}}── Caracterizacion de los 8 clusters + ruido ──────────────
Cluster N Fraude% CashOut Riesgo Dest Etiqueta
────────────────────────────────────────────────────────────────────────────────
Ruido 3 33.3% 0.333 0.745 0.333 ⬛ Ruido / Outlier
C1 1833 0.1% 0 0.233 0 🟢 Bajo riesgo
C2 529 1.5% 1 0.443 0 🔵 Patron mixto / revisar
C3 1292 0.2% 0 0.375 0 🟢 Bajo riesgo
C4 685 1.8% 1 0.787 1 🟡 Cash-out sospechoso
C5 584 1.4% 1 0.545 1 🟡 Transferencia sospechosa
C6 191 0.5% 1 0.697 0 🟡 Cash-out sospechoso
C7 189 0% 0 0.507 0 🟢 Bajo riesgo
C8 294 1.7% 0 0.651 1 🟡 Transferencia sospechosa
# Tabla kable
perfil_tabla <- perfil_resumen |>
mutate(
Cluster = ifelse(cluster == 0, "Ruido", paste0("C", cluster)),
`Fraude %` = paste0(round(tasa_fraude * 100, 1), "%"),
`Cash-Out` = cashout,
`Alto riesgo` = alto_riesgo,
`Riesgo comp.` = riesgo_comp,
`Dest enrich.` = dest_enr,
`Zero bal.` = cashout_zero,
Etiqueta = etiqueta
) |>
select(Cluster, N, `Fraude %`, `Cash-Out`, `Alto riesgo`,
`Riesgo comp.`, `Dest enrich.`, `Zero bal.`, Etiqueta)
colores_cluster <- paleta_sel[as.character(perfil_tabla$Cluster)]
colores_cluster[is.na(colores_cluster)] <- "#95a5a6"
kbl(perfil_tabla, booktabs = TRUE,
align = c("l","r","r","r","r","r","r","r","l")) |>
kable_styling(bootstrap_options = c("striped","hover","condensed"),
full_width = TRUE, font_size = 11) |>
column_spec(1, bold = TRUE, monospace = TRUE, color = colores_cluster) |>
column_spec(9, bold = TRUE)| Cluster | N | Fraude % | Cash-Out | Alto riesgo | Riesgo comp. | Dest enrich. | Zero bal. | Etiqueta |
|---|---|---|---|---|---|---|---|---|
| Ruido | 3 | 33.3% | 0.333 | 0.333 | 0.745 | 0.333 | 0.333 | ⬛ Ruido / Outlier |
| C1 | 1833 | 0.1% | 0.000 | 0.000 | 0.233 | 0.000 | 0.000 | 🟢 Bajo riesgo |
| C2 | 529 | 1.5% | 1.000 | 1.000 | 0.443 | 0.000 | 0.000 | 🔵 Patron mixto / revisar |
| C3 | 1292 | 0.2% | 0.000 | 0.000 | 0.375 | 0.000 | 0.000 | 🟢 Bajo riesgo |
| C4 | 685 | 1.8% | 1.000 | 1.000 | 0.787 | 1.000 | 1.000 | 🟡 Cash-out sospechoso |
| C5 | 584 | 1.4% | 1.000 | 1.000 | 0.545 | 1.000 | 0.000 | 🟡 Transferencia sospechosa |
| C6 | 191 | 0.5% | 1.000 | 1.000 | 0.697 | 0.000 | 1.000 | 🟡 Cash-out sospechoso |
| C7 | 189 | 0% | 0.000 | 1.000 | 0.507 | 0.000 | 0.000 | 🟢 Bajo riesgo |
| C8 | 294 | 1.7% | 0.000 | 1.000 | 0.651 | 1.000 | 0.000 | 🟡 Transferencia sospechosa |
11.7 Visualización t-SNE Con Selección de features
set.seed(42)
idx_tsne <- sample(seq_len(nrow(X_train)), min(2500, nrow(X_train)))
tsne_sel <- Rtsne(X_train[idx_tsne, ], dims = 2, perplexity = 30,
max_iter = 1000, normalize = TRUE, pca = TRUE,
check_duplicates = FALSE)
n_cl <- max(modelo_sel$cluster)
tsne_df_sel <- data.frame(
tSNE1 = tsne_sel$Y[, 1],
tSNE2 = tsne_sel$Y[, 2],
Cluster = factor(modelo_sel$cluster[idx_tsne],
levels = 0:n_cl,
labels = c("Ruido", paste0("C", seq_len(n_cl)))),
isFraud = factor(y_train[idx_tsne], labels = c("Legítimo", "Fraude"))
)
p_ts1 <- ggplot(tsne_df_sel, aes(x = tSNE1, y = tSNE2, color = Cluster, shape = Cluster)) +
geom_point(alpha = 0.72, size = 1.6) +
scale_color_manual(values = paleta_sel) +
scale_shape_manual(values = shapes_sel) +
labs(title = "t-SNE — Clusters DBSCAN", x = "t-SNE 1", y = "t-SNE 2")
p_ts2 <- ggplot(tsne_df_sel, aes(x = tSNE1, y = tSNE2, color = isFraud)) +
geom_point(alpha = 0.72, size = 1.6) +
scale_color_manual(values = c("#2DC653", "#E63946")) +
labs(title = "t-SNE — Etiqueta Real", x = "t-SNE 1", y = "t-SNE 2", color = "Clase")
p_ts1 + p_ts2 +
plot_annotation(title = "Visualización t-SNE — DBSCAN con Selección de Características")11.8 Visualización t-SNE — DBSCAN con Selección de Características
Los dos paneles proyectan las mismas observaciones en 2D mediante t-SNE. El panel izquierdo colorea por cluster DBSCAN; el derecho por etiqueta real (Legítimo / Fraude).
Lectura conjunta: las estructuras filiformes y separadas espacialmente confirman que DBSCAN encontró 8 grupos genuinamente distintos en el espacio de features — no artefactos de parámetros. El panel derecho muestra que los puntos de fraude (rosa) son escasos y se mezclan dentro de clusters mayoritariamente legítimos (verde), lo que es consistente con las tasas de fraude por cluster observadas.
Caracterización por cluster:
| Cluster | Patrón predominante |
|---|---|
| C1 | Comportamiento mayoritariamente legítimo (0.1% fraude) — volumen alto |
| C2 | Riesgo moderado (1.5% fraude) — riesgo compuesto elevado |
| C3 | Comportamiento legítimo (0.2% fraude) — volumen medio |
| C4 | Riesgo moderado-alto (1.8% fraude) — mayor proporción de cash-out |
| C5 | Riesgo moderado (1.4% fraude) — alto riesgo y dest. enriquecido |
| C6 | Riesgo bajo-moderado (0.5% fraude) — operaciones de tipo alto riesgo |
| C7 | Sin fraude detectado (0%) — perfil limpio |
| C8 | Riesgo moderado-alto (1.7% fraude) — cashout con saldo destino cero |
La estructura filiforme del t-SNE es característica de datos con features binarias o discretas dominantes — coherente con el perfil de
type_CASH_OUT,type_high_riskybalance_zero_destque conforman el subconjunto seleccionado.
12 Paso 10/12: DBSCAN sin Selección de Características
En esta sección se aplica DBSCAN directamente sobre todas las variables del Clean Algorithm (sin selector), como línea base de comparación. El objetivo es cuantificar el valor agregado de la selección de características.
12.1 k-dist y Grid Search: Sin Selección
set.seed(42)
X_full_train <- X_filtered[idx_train, ]
X_full_test <- X_filtered[idx_test, ]
k_full <- max(ncol(X_full_train) + 1, 5)
kd_full <- kNNdist(as.matrix(X_full_train), k = k_full)
sorted_f <- if (is.matrix(kd_full)) sort(kd_full[, k_full], decreasing = TRUE) else
sort(kd_full, decreasing = TRUE)
d2_f <- diff(diff(sorted_f))
eps_full <- round(sorted_f[which.max(abs(d2_f)) + 1], 4)
# Grid search rápido
set.seed(42)
idx_gsf <- sample(seq_len(nrow(X_full_train)), min(2000, nrow(X_full_train)))
X_gsf <- as.matrix(X_full_train[idx_gsf, ])
gsf_res <- data.frame()
eps_gf <- round(seq(eps_full * 0.5, eps_full * 2, length.out = 6), 4)
mpts_gf <- c(3, 5, 7, 10)
for (ev in eps_gf) {
for (mv in mpts_gf) {
db_t <- dbscan::dbscan(X_gsf, eps = ev, minPts = mv)
k_t <- max(db_t$cluster)
noi_t <- mean(db_t$cluster == 0)
sil_t <- NA_real_
if (k_t >= 2 && noi_t < 0.7) {
iv <- db_t$cluster != 0
if (sum(iv) > k_t)
sil_t <- tryCatch(
mean(silhouette(db_t$cluster[iv], dist(X_gsf[iv, ]))[, 3]),
error = function(e) NA_real_)
}
gsf_res <- rbind(gsf_res,
data.frame(eps = ev, minPts = mv, k_clusters = k_t,
noise_pct = noi_t, silhouette = round(sil_t, 4)))
}
}
best_f <- gsf_res |> filter(!is.na(silhouette)) |> slice_max(silhouette, n = 1)
eps_bf <- best_f$eps
mpts_bf <- best_f$minPts
# Visualización de los mejores hiperparámetros encontrados
cat(
"==============================================\n",
" RESULTADOS DEL GRID SEARCH (MEJOR EPS/MP) \n",
"==============================================\n",
"Epsilon Óptimo (eps): ", eps_bf, "\n",
"MinPts Óptimo: ", mpts_bf, "\n",
"Silhouette Score: ", round(best_f$silhouette, 4), "\n",
"Clusters Encontrados: ", best_f$k_clusters, "\n",
"Porcentaje de Ruido: ", paste0(best_f$pct_ruido, "%"), "\n",
"==============================================\n"
)==============================================
RESULTADOS DEL GRID SEARCH (MEJOR EPS/MP)
==============================================
Epsilon Óptimo (eps): 0.852
MinPts Óptimo: 10
Silhouette Score: 0.4337
Clusters Encontrados: 26
Porcentaje de Ruido: %
==============================================
12.2 Resultados Grid Search — DBSCAN sin selección de características
El grid search identificó ε = 0.852 y MinPts = 10 como parámetros óptimos sobre el espacio completo de features (sin reducción dimensional).
| Métrica | Valor |
|---|---|
| ε óptimo | 0.852 |
| MinPts óptimo | 10 |
| Silhouette Score | 0.4337 |
| Clusters encontrados | 26 |
Silhouette = 0.4337 indica separación moderada entre clusters — considerablemente menor al 0.764 obtenido con selección de características. La diferencia refleja el efecto de la maldición de dimensionalidad: en espacios de alta dimensión las distancias euclídeas se homogenizan, degradando la calidad de los clusters.
26 clusters sobre el espacio completo de features sugiere fragmentación excesiva — el algoritmo detecta variabilidad dimensional en lugar de patrones transaccionales reales. Con selección, este número se reduce a 8 clusters estructuralmente coherentes.
ε = 0.852 es amplio porque el espacio sin filtrar es disperso: se necesitan vecindades grandes para encontrar densidad mínima. Con selección, ε cae a 0.3401 — el espacio compactado permite vecindades más estrictas y clusters más precisos.
12.3 Ajuste Modelo: Sin Selección
modelo_full <- dbscan::dbscan(as.matrix(X_full_train), eps = eps_bf, minPts = mpts_bf)
k_full_m <- max(modelo_full$cluster)
noise_full <- sum(modelo_full$cluster == 0)
colores_ext <- colorRampPalette(c(
"#E63946","#2EC4B6","#FF9F1C","#6A0DAD","#00CC44",
"#F72585","#118AB2","#FFD166","#FF006E","#1B1B2F",
"#AAFF00","#FF5733","#0047AB","#FF8C00","#9400D3"
))(k_full_m)
paleta_full <- c("#95a5a6", colores_ext)
names(paleta_full) <- c("Ruido", paste0("C", seq_len(k_full_m)))
shapes_full <- c(4L, rep(16L, k_full_m))
names(shapes_full) <- names(paleta_full)
cat(sprintf(paste(
"\n── DBSCAN FULL TRAINING ─────────────────────",
"\n eps : %.4f",
"\n MinPts : %d",
"\n Clusters : %d",
"\n Ruido : %d (%.2f%%)",
"\n─────────────────────────────────────────────\n"
), eps_bf, mpts_bf, k_full_m, noise_full,
100 * noise_full / nrow(X_full_train)))
── DBSCAN FULL TRAINING ─────────────────────
eps : 0.8520
MinPts : 10
Clusters : 35
Ruido : 139 (2.48%)
─────────────────────────────────────────────
12.4 Resultados del Entrenamiento — DBSCAN sin Selección de Variables
El modelo DBSCAN entrenado sobre las 30 variables completas convergió a los reultados mostrados en salida , explicación de cada unos:
\(\varepsilon = 0.852\): radio de vecindad calibrado vía k-dist plot (segunda derivada). Un valor relativamente alto refleja que en \(\mathbb{R}^{30}\) las distancias euclidianas se concentran — el algoritmo necesita un radio mayor para capturar densidad local significativa.
MinPts = 10: umbral mínimo de vecinos para declarar un punto núcleo. Siguiendo la heurística \(\text{MinPts} \geq d + 1\) (con \(d = 30\)), este valor es conservador y favorece la formación de clusters compactos sobre la detección de ruido excesiva.
35 clusters: resultado de la sobresegmentación característica de la alta dimensionalidad. Sin selección de variables, DBSCAN interpreta variaciones locales de densidad — producto del ruido dimensional — como estructuras independientes, fragmentando lo que en un espacio depurado serían 4 grupos coherentes.
Ruido 2.48 % (139 obs): las observaciones no asignadas a ningún cluster son aquellas cuya vecindad \(\mathcal{N}_\varepsilon\) contiene menos de 10 puntos. En el contexto de fraude, este subconjunto es operativamente relevante: los outliers de densidad son candidatos naturales a patrones anómalos no tipificados.
12.5 Visualización t-SNE Sin Selección de características
set.seed(42)
idx_tf <- sample(seq_len(nrow(X_full_train)), min(2500, nrow(X_full_train)))
tsne_full <- Rtsne(X_full_train[idx_tf, ], dims = 2, perplexity = 30,
max_iter = 1000, normalize = TRUE, pca = TRUE,
check_duplicates = FALSE)
tsne_df_full <- data.frame(
tSNE1 = tsne_full$Y[, 1], tSNE2 = tsne_full$Y[, 2],
Cluster = factor(modelo_full$cluster[idx_tf],
labels = c("Ruido", paste0("C", seq_len(k_full_m)))),
isFraud = factor(y_train[idx_tf], labels = c("Legítimo","Fraude"))
)
p_f1 <- ggplot(tsne_df_full, aes(x = tSNE1, y = tSNE2, color = Cluster)) +
geom_point(alpha = 0.7, size = 1.5) +
scale_color_manual(values = paleta_full) +
labs(title = "t-SNE — Sin Selección (Clusters)", x = "t-SNE 1", y = "t-SNE 2")
p_f2 <- ggplot(tsne_df_full, aes(x = tSNE1, y = tSNE2, color = isFraud)) +
geom_point(alpha = 0.7, size = 1.5) +
scale_color_manual(values = c("#2DC653","#E63946")) +
labs(title = "t-SNE — Sin Selección (Real)", x = "t-SNE 1", y = "t-SNE 2", color = "Clase")
p_f1 + p_f2 +
plot_annotation(title = "Visualización t-SNE — DBSCAN sin Selección de Características")El panel izquierdo expone la consecuencia directa de omitir la selección de variables: DBSCAN fragmenta el espacio en 35 clusters sin coherencia operativa. Cada color representa un grupo distinto, pero la mayoría son microgrupos pequeños y dispersos — artefactos del ruido dimensional en \(\mathbb{R}^{30}\), no patrones de comportamiento real.
El panel derecho revela que la estructura subyacente es binaria: legítimo (verde) y fraude (rojo), con los puntos fraudulentos dispersos y sin concentración en clusters específicos. Los 35 grupos del panel izquierdo no se corresponden con 35 patrones de riesgo reales — son el resultado de que DBSCAN interpreta variaciones menores de densidad dimensional como fronteras de cluster independientes.
El contraste con el modelo con selección (4 clusters, \(\bar{s} = 0.988\)) es directo: la selección de características no es un paso opcional de preprocesamiento, sino la condición que determina si DBSCAN produce segmentación interpretable o sobresegmentación espuria.
13 Paso 12: Métricas de Validación
13.1 Con Selección: Train
# 1. Filtrar ruido (DBSCAN cluster 0 es ruido)
idx_val_s <- modelo_sel$cluster != 0
datos_val_s <- as.matrix(X_train[idx_val_s, ])
clust_val_s <- modelo_sel$cluster[idx_val_s]
# 2. Cálculo de Silhouette
sil_obj_s <- silhouette(clust_val_s, dist(datos_val_s))
sil_sel <- mean(sil_obj_s[, 3])
# 3. Cálculo de métricas adicionales (Davies-Bouldin y Calinski-Harabasz)
# Corregimos los nombres: fpc usa 'davies.bouldin'
db_s_res <- tryCatch(
cluster.stats(dist(datos_val_s), clust_val_s),
error = function(e) list(davies.bouldin = NA_real_, ch = NA_real_)
)
# Extracción segura con validación de longitud
db_sel <- as.numeric(db_s_res$davies.bouldin)
if (length(db_sel) == 0 || !is.finite(db_sel)) db_sel <- NA_real_
ch_sel <- as.numeric(db_s_res$ch)
if (length(ch_sel) == 0 || !is.finite(ch_sel)) ch_sel <- NA_real_
# 4. Preparación de datos para el gráfico
sil_df_s <- data.frame(
obs = seq_len(nrow(sil_obj_s)),
cluster = factor(sil_obj_s[, 1]),
width = sil_obj_s[, 3]
) |>
arrange(cluster, desc(width)) |>
mutate(obs = row_number())
cat(
"\n[DBSCAN - Evaluación]\n",
"Observaciones (sin ruido): ", nrow(datos_val_s), "\n",
"Clusters detectados: ", length(unique(clust_val_s)), "\n",
"Silhouette promedio: ", round(sil_sel, 3), "\n",
"Davies-Bouldin: ", round(db_sel, 3), "\n",
"Calinski-Harabasz: ", round(ch_sel, 1), "\n"
)
[DBSCAN - Evaluación]
Observaciones (sin ruido): 5597
Clusters detectados: 8
Silhouette promedio: 0.766
Davies-Bouldin: NA
Calinski-Harabasz: 17322.8
El modelo DBSCAN con selección de variables entrenó sobre 5.597 observaciones válidas (excluido ruido), detectando 8 clusters con las siguientes métricas:
| Métrica | Valor | Lectura |
|---|---|---|
| Silhouette \(\bar{s}\) | 0.766 | Estructura sólida — clusters cohesionados y bien separados |
| Davies-Bouldin | NA | No calculable (clusters singulares en alta dimensión) |
| Calinski-Harabasz | 17.322,8 | Separación inter-cluster muy alta relativa a dispersión interna |
Silhouette 0.766: cae en la zona de estructura razonable-fuerte (\(\bar{s} > 0.70\)), indicando que la mayoría de observaciones están significativamente más cerca de su propio cluster que del vecino más próximo. Es un resultado robusto para DBSCAN aplicado a datos financieros reales con clases desbalanceadas.
Davies-Bouldin NA: la indisponibilidad se origina en clusters con muy pocas observaciones donde el cálculo de \(d(c_i, c_j)\) colapsa numéricamente. No invalida el modelo.
Calinski-Harabasz 17.322,8: valor muy alto, confirmando que la separación entre centroides (\(SS_B\)) es grande relativa a la dispersión interna (\(SS_W\)). Junto al Silhouette, señala que los 8 clusters tienen fronteras geométricas definidas y densidades internas compactas.
13.1.1 Diagrama Silhouette — DBSCAN con Selección de características
# Colores por cluster usando paleta_sel
niveles_sil <- levels(sil_df_s$cluster)
colores_sil <- paleta_sel[paste0("C", niveles_sil)]
colores_sil[is.na(colores_sil)] <- "#95a5a6"
names(colores_sil) <- niveles_sil
ggplot(sil_df_s, aes(x = obs, y = width, fill = cluster)) +
geom_bar(stat = "identity", width = 1) +
scale_fill_manual(values = colores_sil, name = "Cluster") +
geom_hline(yintercept = mean(sil_df_s$width),
linetype = "dashed", color = "#2c3e50", linewidth = 0.8) +
annotate("text",
x = nrow(sil_df_s) * 0.02,
y = mean(sil_df_s$width) + 0.03,
label = sprintf("s̄ = %.3f", mean(sil_df_s$width)),
color = "#2c3e50", size = 3.5, fontface = "bold", hjust = 0) +
labs(
title = "Diagrama Silhouette — DBSCAN con Selección",
subtitle = sprintf("%d clusters | %d obs (sin ruido) | s̄ = %.3f",
length(unique(sil_df_s$cluster)),
nrow(sil_df_s),
mean(sil_df_s$width)),
x = "Observaciones (ordenadas por cluster)",
y = "Coeficiente Silhouette"
) +
facet_wrap(~cluster, scales = "free_x", nrow = 3) +
theme(
strip.text = element_text(face = "bold", size = 9),
legend.position = "none",
panel.spacing = unit(0.3, "lines")
)El diagrama muestra los coeficientes Silhouette individuales de los 8 clusters, ordenados de mayor a menor dentro de cada panel. La línea punteada marca el promedio global \(\bar{s} = 0.766\).
Tres patrones destacan:
C1 (rojo): el cluster más grande (~1.700 obs) con coeficientes que decaen gradualmente desde ~0.75 hasta ~0.30 — cohesión heterogénea, indicando un grupo amplio con observaciones frontera que comparten densidad con clusters vecinos.
C2, C3, C5 (verde, amarillo, naranja): bloques compactos con coeficientes sostenidos sobre la línea media — clusters bien definidos sin observaciones ambiguas.
C7 y C8 (cyan, magenta): clusters pequeños cuyos coeficientes caen abruptamente hacia 0, señalando grupos con pocas observaciones en zona de transición — candidatos a representar patrones anómalos de baja frecuencia.
La ausencia de barras negativas en todos los clusters confirma que ninguna observación está mal asignada. El \(\bar{s} = 0.766\) refleja el peso de C1: su degradación arrastra el promedio global hacia abajo respecto a clusters más compactos como C2 y C3.
13.2 Sin Selección de características: Train
idx_val_f <- modelo_full$cluster != 0
datos_val_f <- as.matrix(X_full_train[idx_val_f, ])
clust_val_f <- modelo_full$cluster[idx_val_f]
# Silhouette con manejo de error
sil_full <- tryCatch(
mean(silhouette(clust_val_f, dist(datos_val_f))[, 3]),
error = function(e) NA_real_
)
# Métricas adicionales con nombres de librería correctos (davies.bouldin)
db_f_res <- tryCatch(
cluster.stats(dist(datos_val_f), clust_val_f),
error = function(e) list(davies.bouldin = NA_real_, ch = NA_real_)
)
# Extracción segura: validamos longitud antes de evaluar finitud
db_full <- as.numeric(db_f_res$davies.bouldin)
if (length(db_full) == 0 || !is.finite(db_full)) db_full <- NA_real_
ch_full <- as.numeric(db_f_res$ch)
if (length(ch_full) == 0 || !is.finite(ch_full)) ch_full <- NA_real_
# Visualización de métricas de validación del modelo Full
cat(
"\n======================================================\n",
" MÉTRICAS DE VALIDACIÓN: MODELO COMPLETO (FULL) \n",
"======================================================\n",
"Coeficiente de Silhouette (Medio): ", ifelse(is.na(sil_full), "N/A", round(sil_full, 4)), "\n",
"Índice Davies-Bouldin (DB): ", ifelse(is.na(db_full), "N/A", round(db_full, 4)), "\n",
"Índice Calinski-Harabasz (CH): ", ifelse(is.na(ch_full), "N/A", round(ch_full, 2)), "\n",
"------------------------------------------------------\n",
"Interpretación rápida:\n",
" - Silhouette > 0: Estructura de clusters detectada.\n",
" - DB menor: Mejor separación entre clusters.\n",
" - CH mayor: Clusters más densos y compactos.\n",
"======================================================\n"
)
======================================================
MÉTRICAS DE VALIDACIÓN: MODELO COMPLETO (FULL)
======================================================
Coeficiente de Silhouette (Medio): 0.4222
Índice Davies-Bouldin (DB): N/A
Índice Calinski-Harabasz (CH): 1063.45
------------------------------------------------------
Interpretación rápida:
- Silhouette > 0: Estructura de clusters detectada.
- DB menor: Mejor separación entre clusters.
- CH mayor: Clusters más densos y compactos.
======================================================
El modelo sin selección (30 variables, 35 clusters) obtiene métricas geométricas moderadas, contrastando con el modelo con selección:
| Métrica | Modelo Full | Modelo Selección | Dirección óptima |
|---|---|---|---|
| Silhouette \(\bar{s}\) | 0.422 | 0.766 | \(\uparrow\) mayor |
| Davies-Bouldin | NA | NA | \(\downarrow\) menor |
| Calinski-Harabasz | 1.063,45 | 17.322,8 | \(\uparrow\) mayor |
Silhouette 0.422: estructura débil-moderada. Los 35 clusters presentan solapamiento parcial — consecuencia del ruido dimensional en \(\mathbb{R}^{30}\) que difumina las fronteras de densidad.
Calinski-Harabasz 1.063: dieciséis veces menor que el modelo con selección, confirmando que la separación inter-cluster relativa a la dispersión interna colapsa al operar con 35 microgrupos en alta dimensión.
Davies-Bouldin NA: indisponible por clusters singulares, igual que en el modelo con selección — no penaliza el resultado.
La caída conjunta de Silhouette (−0.344) y CH (−16.259) al pasar de 8 a 35 clusters cuantifica el costo geométrico de omitir la selección de variables antes de aplicar DBSCAN.
13.2.1 Diagrama Silhouette: DBSCAN sin Selección de características
sil_obj_f <- tryCatch(
silhouette(clust_val_f, dist(datos_val_f)),
error = function(e) NULL
)
if (!is.null(sil_obj_f)) {
sil_df_f <- data.frame(
obs = seq_len(nrow(sil_obj_f)),
cluster = factor(sil_obj_f[, 1]),
width = sil_obj_f[, 3]
) |>
arrange(cluster, desc(width)) |>
mutate(obs = row_number())
niveles_sil_f <- levels(sil_df_f$cluster)
colores_sil_f <- paleta_full[paste0("C", niveles_sil_f)]
colores_sil_f[is.na(colores_sil_f)] <- "#95a5a6"
names(colores_sil_f) <- niveles_sil_f
ggplot(sil_df_f, aes(x = obs, y = width, fill = cluster)) +
geom_bar(stat = "identity", width = 1) +
scale_fill_manual(values = colores_sil_f, name = "Cluster") +
geom_hline(yintercept = mean(sil_df_f$width),
linetype = "dashed", color = "#2c3e50", linewidth = 0.8) +
annotate("text",
x = nrow(sil_df_f) * 0.02,
y = mean(sil_df_f$width) + 0.03,
label = sprintf("s̄ = %.3f", mean(sil_df_f$width)),
color = "#2c3e50", size = 3.5, fontface = "bold", hjust = 0) +
labs(
title = "Diagrama Silhouette — DBSCAN sin Selección",
subtitle = sprintf("%d clusters | %d obs (sin ruido) | s̄ = %.3f",
length(unique(sil_df_f$cluster)),
nrow(sil_df_f),
mean(sil_df_f$width)),
x = "Observaciones (ordenadas por cluster)",
y = "Coeficiente Silhouette"
) +
facet_wrap(~cluster, scales = "free_x", nrow = 3) +
theme(
strip.text = element_text(face = "bold", size = 9),
legend.position = "none",
panel.spacing = unit(0.3, "lines")
)
} else {
cat("Silhouette no disponible para modelo sin selección.\n")
}El contraste con el modelo con selección es inmediato: en lugar de 8 bloques compactos, se observan 35 paneles con coeficientes dispersos y decrecientes, la mayoría por debajo de la línea media (\(\bar{s} = 0.422\)).
Dos patrones dominan:
C1: único cluster con masa significativa (~750 obs), con coeficientes que caen desde 0.45 hasta 0.0 — el único grupo con estructura mínimamente coherente en todo el modelo.
C2–C35: microgrupos con pocas observaciones y coeficientes que no superan 0.6, varios rozando 0.0 — fragmentos sin interpretación operativa, producto del ruido dimensional y no de patrones de comportamiento real.
La diferencia estructural respecto al modelo con selección es que ningún cluster aquí alcanza la cohesión sostenida visible en C2, C3 o C5 del modelo depurado. Más clusters no implica más información — implica sobresegmentación.
13.3 Predicción en Test
predict_dbscan_fn <- function(model, X_tr, X_new, eps) {
labels_tr <- model$cluster
knn_res <- kNN(X_tr, X_new, k = 1)
neighbor_idx <- knn_res$id[, 1]
neighbor_dist<- knn_res$dist[, 1]
ifelse(neighbor_dist <= eps, labels_tr[neighbor_idx], 0)
}
# Con selección
labels_test_sel <- predict_dbscan_fn(modelo_sel, X_train_mat, as.matrix(X_test), eps_best)
# Sin selección
labels_test_full <- predict_dbscan_fn(modelo_full, as.matrix(X_full_train), as.matrix(X_full_test), eps_bf)
# Métricas test con selección
idx_tv_s <- labels_test_sel != 0
sil_test_sel <- tryCatch({
if (length(unique(labels_test_sel[idx_tv_s])) >= 2 && sum(idx_tv_s) > 10)
mean(silhouette(labels_test_sel[idx_tv_s],
dist(as.matrix(X_test[idx_tv_s, ])))[, 3])
else NA_real_
}, error = function(e) NA_real_)
# Métricas test sin selección
idx_tv_f <- labels_test_full != 0
sil_test_full <- tryCatch({
if (length(unique(labels_test_full[idx_tv_f])) >= 2 && sum(idx_tv_f) > 10)
mean(silhouette(labels_test_full[idx_tv_f],
dist(as.matrix(X_full_test[idx_tv_f, ])))[, 3])
else NA_real_
}, error = function(e) NA_real_)
# Estructuración de la tabla comparativa de Test
tabla_resultados_test <- data.frame(
Metrica = c(
"Coeficiente de Silhouette (Test)",
"Transacciones Clasificadas",
"Transacciones como Ruido (Anomalías)",
"Tasa de Ruido (%)",
"Cantidad de Clusters"
),
`Con Seleccion` = c(
round(sil_test_sel, 4),
sum(idx_tv_s),
sum(!idx_tv_s),
paste0(round(mean(!idx_tv_s) * 100, 2), "%"),
length(unique(labels_test_sel[idx_tv_s]))
),
`Sin Seleccion (Full)` = c(
round(sil_test_full, 4),
sum(idx_tv_f),
sum(!idx_tv_f),
paste0(round(mean(!idx_tv_f) * 100, 2), "%"),
length(unique(labels_test_full[idx_tv_f]))
)
)
# Renderizado con kableExtra
tabla_resultados_test |>
kbl(
caption = "Resultados Detectados: Evaluación en Conjunto de Test (DBSCAN Predict)",
booktabs = TRUE,
align = "lrr",
col.names = c("Métrica", "Con Selección", "Sin Selección (Full)")
) |>
kable_styling(
bootstrap_options = c("striped", "hover", "condensed"),
full_width = FALSE,
font_size = 14
) |>
column_spec(1, bold = TRUE, border_right = TRUE) |>
column_spec(2, color = "white", background = "#2c3e50") |>
column_spec(3, color = "white", background = "#34495e")| Métrica | Con Selección | Sin Selección (Full) |
|---|---|---|
| Coeficiente de Silhouette (Test) | 0.7667 | 0.4223 |
| Transacciones Clasificadas | 2399 | 2347 |
| Transacciones como Ruido (Anomalías) | 1 | 53 |
| Tasa de Ruido (%) | 0.04% | 2.21% |
| Cantidad de Clusters | 8 | 35 |
La evaluación en test confirma la ventaja del modelo con selección en todas las dimensiones relevantes:
| Métrica | Con Selección | Sin Selección |
|---|---|---|
| Silhouette (Test) | 0.7667 | 0.4223 |
| Transacciones clasificadas | 2.399 | 2.347 |
| Anomalías (ruido) | 1 | 53 |
| Tasa de ruido | 0.04 % | 2.21 % |
| Clusters | 8 | 35 |
El dato más relevante es la tasa de ruido: el modelo con selección deja 1 sola transacción sin clasificar (0.04 %), mientras el modelo full rechaza 53 (2.21 %). En términos operativos, más ruido implica más transacciones sin acción asignada — exactamente lo que un sistema de detección de fraude debe minimizar.
El Silhouette en test (0.767 vs 0.422) replica el comportamiento del entrenamiento, confirmando que la calidad geométrica no es sobreajuste: las 8 variables seleccionadas generalizan correctamente a datos no vistos.
14 Paso 13: Comparativa Con vs Sin Selección de características
14.1 Tabla de Métricas
comparativa <- data.frame(
Métrica = c("Silhouette (train)","Davies-Bouldin (train)","Calinski-Harabasz (train)",
"Silhouette (test)","N Clusters","% Ruido (train)","N Features","Dirección"),
Con_Sel = c(round(sil_sel, 4),
ifelse(is.na(db_sel), "NA", round(db_sel, 4)),
ifelse(is.na(ch_sel), "NA", round(ch_sel, 2)),
ifelse(is.na(sil_test_sel), "NA", round(sil_test_sel, 4)),
k_sel, round(100*noise_sel/nrow(X_train), 2),
length(final_features), "—"),
Sin_Sel = c(ifelse(is.na(sil_full), "NA", round(sil_full, 4)),
ifelse(is.na(db_full), "NA", round(db_full, 4)),
ifelse(is.na(ch_full), "NA", round(ch_full, 2)),
ifelse(is.na(sil_test_full), "NA", round(sil_test_full, 4)),
k_full_m, round(100*noise_full/nrow(X_full_train), 2),
ncol(X_filtered), "—"),
Preferible = c("↑ mayor","↓ menor","↑ mayor","↑ mayor","—","—","Parsimonia","—")
)
kbl(comparativa, booktabs = TRUE,
col.names = c("Métrica","Con Selección","Sin Selección","Preferible"),
align = "lrrl") |>
kable_styling(bootstrap_options = c("striped","hover"), full_width = TRUE, font_size = 13) |>
column_spec(1, bold = TRUE) |>
column_spec(2, bold = TRUE, color = "#8e44ad") |>
column_spec(3, color = "#7f8c8d")| Métrica | Con Selección | Sin Selección | Preferible |
|---|---|---|---|
| Silhouette (train) | 0.7658 | 0.4222 | ↑ mayor |
| Davies-Bouldin (train) | NA | NA | ↓ menor |
| Calinski-Harabasz (train) | 17322.79 | 1063.45 | ↑ mayor |
| Silhouette (test) | 0.7667 | 0.4223 | ↑ mayor |
| N Clusters | 8 | 35 | — |
| % Ruido (train) | 0.05 | 2.48 | — |
| N Features | 8 | 30 | Parsimonia |
| Dirección | — | — | — |
La tabla consolida todas las métricas en un único cuadro comparativo. El modelo con selección domina en cada indicador evaluable:
- Silhouette train y test consistentemente sobre 0.76 en ambas particiones, frente a 0.42 del modelo full — la calidad geométrica generaliza, no es sobreajuste.
- Calinski-Harabasz de 17.322 vs 1.063: el modelo con selección produce clusters 16 veces más separados relativamente a su dispersión interna.
- 8 clusters vs 35 con 8 features vs 30: parsimonia total — menos variables producen menos clusters y mejor estructura.
- Ruido 0.05 % vs 2.48 %: casi la totalidad de las transacciones recibe una asignación operativa en el modelo con selección.
La lectura global es directa: seleccionar variables antes de DBSCAN no es una optimización marginal — es la decisión que determina si el modelo es operativamente útil o no.
14.2 Gráfica Comparativa de Silhouette
comp_plot <- data.frame(
Configuración = c("Con selección\n(train)","Con selección\n(test)",
"Sin selección\n(train)","Sin selección\n(test)"),
Silhouette = c(sil_sel, sil_test_sel, sil_full, sil_test_full),
Tipo = c("Con selección","Con selección","Sin selección","Sin selección")
) |> filter(!is.na(Silhouette))
p_comparativa <- ggplot(comp_plot, aes(x = Configuración, y = Silhouette, fill = Tipo)) +
geom_col(width = 0.6) +
geom_text(aes(label = round(Silhouette, 3)), vjust = -0.4, fontface = "bold", size = 4) +
scale_fill_manual(values = c("#8e44ad","#27AE60")) +
labs(title = "Comparativa Silhouette — Con vs Sin Selección de Características",
x = "", y = "Silhouette Score", fill = "") +
theme(legend.position = "top") +
ylim(0, max(comp_plot$Silhouette, na.rm = TRUE) * 1.2)
p_comparativaEl gráfico sintetiza visualmente el hallazgo central del pipeline: el Silhouette con selección (0.767 test / 0.766 train) es prácticamente idéntico entre particiones, y lo mismo ocurre sin selección (0.422 en ambas). La estabilidad train-test en los dos modelos descarta sobreajuste y confirma que la brecha de ~0.34 puntos es estructural, no circunstancial. La selección de 8 variables produce consistentemente una geometría de clusters superior a la obtenida con las 30 variables completas. —
15 Paso 14: Datos Nuevos (Test)
15.1 Clustering Test: Con Selección
set.seed(42)
idx_tsne_t <- sample(seq_len(nrow(X_test)), min(1500, nrow(X_test)))
tsne_test <- Rtsne(X_test[idx_tsne_t, ], dims = 2, perplexity = 25,
max_iter = 800, normalize = TRUE, pca = TRUE,
check_duplicates = FALSE)
lbl_test_s <- c("0", paste0(seq_len(max(labels_test_sel, 1))))
pal_test_s <- paleta_sel[c("Ruido", paste0("C", seq_len(max(labels_test_sel, 1))))]
names(pal_test_s) <- lbl_test_s
tsne_test_df <- data.frame(
tSNE1 = tsne_test$Y[, 1], tSNE2 = tsne_test$Y[, 2],
Cluster = factor(labels_test_sel[idx_tsne_t]),
isFraud = factor(y_test[idx_tsne_t], labels = c("Legítimo","Fraude"))
)
p_t1 <- ggplot(tsne_test_df, aes(x = tSNE1, y = tSNE2, color = Cluster)) +
geom_point(alpha = 0.7, size = 1.6) +
scale_color_manual(values = pal_test_s[seq_len(nlevels(tsne_test_df$Cluster))]) +
labs(title = "Test — Clusters DBSCAN (con sel.)", x = "t-SNE 1", y = "t-SNE 2")
p_t2 <- ggplot(tsne_test_df, aes(x = tSNE1, y = tSNE2, color = isFraud)) +
geom_point(alpha = 0.7, size = 1.6) +
scale_color_manual(values = c("#2DC653","#E63946")) +
labs(title = "Test — Etiqueta Real", x = "t-SNE 1", y = "t-SNE 2", color = "Clase")
p_t1 + p_t2Ambos paneles corresponden al conjunto de test. Los clusters son los mismos 8 definidos en entrenamiento — la asignación se realiza por 1-NN: cada nueva transacción hereda el cluster de su vecino más cercano en el espacio de entrenamiento.
- C1 (rojo): cluster dominante, distribuido en múltiples segmentos del espacio — patrón legítimo de alta frecuencia.
- C2 (verde): segundo grupo en volumen, alineado con transacciones legítimas según el panel derecho.
- C3–C8: clusters menores, bien localizados geométricamente, algunos con muy pocas observaciones en test (C7 cyan, C8 magenta).
- C0 (negro, 1 punto): única transacción clasificada como ruido — confirma la tasa de 0.04 % observada en las métricas.
El panel derecho muestra que la masa legítima (verde) domina, con puntos de fraude (rojo) dispersos sin concentración en un cluster específico — comportamiento esperado dado el desbalance de clases. La geometría en test replica la estructura de entrenamiento, confirmando que los 8 clusters generalizan correctamente.
16 Paso 15 — Accionabilidad
16.1 Perfilado por Cluster
perfil_vars <- intersect(
c("log_amount","balance_zero_orig","type_high_risk","is_night",
"surr_amount","layering_flag","rapid_move_flag","composite_risk",
"risk_score_1","fan_out","amt_z_orig","dest_total_rx"),
names(X_train)
)
train_perfil <- X_train[, perfil_vars, drop = FALSE]
train_perfil$cluster <- modelo_sel$cluster
train_perfil$isFraud <- y_train
q_risk <- quantile(X_train$composite_risk, probs = c(0.75, 0.90), na.rm = TRUE)
perfil_stats <- train_perfil |>
filter(cluster != 0) |>
group_by(cluster) |>
summarise(
across(all_of(perfil_vars), ~ round(median(., na.rm = TRUE), 4)),
fraud_rate = round(mean(isFraud, na.rm = TRUE), 4),
n_obs = n(),
.groups = "drop"
) |>
mutate(
Clasificacion = case_when(
fraud_rate > 0.10 ~ "\U0001f6a8 FRAUDE ALTO",
fraud_rate > 0.05 ~ "\U0001f7e0 RIESGO ELEVADO",
fraud_rate > 0.02 ~ "\U0001f7e1 RIESGO MODERADO",
TRUE ~ "\U0001f7e2 NORMAL"
)
)
kbl(perfil_stats, booktabs = TRUE) |>
kable_styling(bootstrap_options = c("striped","hover","condensed"),
full_width = TRUE, font_size = 10) |>
column_spec(ncol(perfil_stats), bold = TRUE,
color = case_when(
grepl("ALTO", perfil_stats$Clasificacion) ~ "#e74c3c",
grepl("ELEVADO", perfil_stats$Clasificacion) ~ "#f39c12",
grepl("MODERADO", perfil_stats$Clasificacion) ~ "#f39c12",
TRUE ~ "#27ae60"
))| cluster | type_high_risk | composite_risk | risk_score_1 | fraud_rate | n_obs | Clasificacion |
|---|---|---|---|---|---|---|
| 1 | 0 | 0.2072 | 0.0000 | 0.0005 | 1833 | 🟢 NORMAL |
| 2 | 1 | 0.4444 | 0.3125 | 0.0151 | 529 | 🟢 NORMAL |
| 3 | 0 | 0.3703 | 0.1250 | 0.0015 | 1292 | 🟢 NORMAL |
| 4 | 1 | 0.7944 | 0.7500 | 0.0175 | 685 | 🟢 NORMAL |
| 5 | 1 | 0.5452 | 0.3125 | 0.0137 | 584 | 🟢 NORMAL |
| 6 | 1 | 0.6946 | 0.7500 | 0.0052 | 191 | 🟢 NORMAL |
| 7 | 1 | 0.4893 | 0.3125 | 0.0000 | 189 | 🟢 NORMAL |
| 8 | 1 | 0.6558 | 0.4375 | 0.0170 | 294 | 🟢 NORMAL |
Todos los clusters reciben clasificación NORMAL, lo que a primera vista parece contradictorio en un pipeline de detección de fraude. La lectura correcta es estructural:
- C4 y C6 concentran el mayor riesgo:
composite_risksobre 0.79 y 0.69,risk_score_1de 0.75 en ambos, ytype_high_risk = 1— son los clusters operativamente más sensibles pese a la etiqueta NORMAL. - C1 es el cluster de menor riesgo absoluto:
composite_risk0.207,fraud_rate0.0005, sin tipo de alto riesgo — masa legítima pura. - La
fraud_ratees baja en todos los clusters (máximo 0.0175 en C4), reflejo del desbalance de clases del dataset (~1 % fraude).
La clasificación NORMAL indica que ningún cluster supera los umbrales de BLOQUEAR o REVISAR definidos en las reglas operativas. Los clusters C4 y C6, con riesgo compuesto alto, son los candidatos naturales a escalar a REVISAR si se ajustan dichos umbrales hacia abajo.
16.2 Recomendación Operativa por Cluster
Los clusters C4 y C6 concentran el mayor riesgo compuesto y deben recibir tratamiento diferenciado:
| Cluster | composite_risk | fraud_rate | Recomendación |
|---|---|---|---|
| C4 | 0.794 | 0.0175 | Monitoreo prioritario — revisar manualmente toda transacción nocturna |
| C6 | 0.695 | 0.0052 | Alerta secundaria — escalar si coincide con layering_flag = 1 |
| C1 | 0.207 | 0.0005 | Aprobación automática — masa legítima de bajo riesgo |
| C2, C3, C5 | 0.37–0.54 | < 0.02 | Aprobación con registro — monitoreo pasivo |
| C7, C8 | 0.49–0.66 | ≤ 0.017 | Revisión muestral — clusters pequeños con perfil ambiguo |
El ajuste de umbral recomendado es bajar el percentil de composite_risk de P90 a P75 en C4 y C6 para capturar fraudes de baja frecuencia sin incrementar significativamente la carga de revisión.
16.3 Reglas Operativas
# Umbrales basados en percentiles del conjunto de entrenamiento (sin leakage)
p90_risk <- quantile(X_train$balance_zero_orig, 0.90, na.rm = TRUE)
p75_risk <- quantile(X_train$balance_zero_orig, 0.75, na.rm = TRUE)
p95_z <- quantile(X_train$amount_large, 0.95, na.rm = TRUE)
test_acc <- X_test
test_acc$cluster_pred <- labels_test_sel
test_acc$isFraud_real <- y_test
# Columnas opcionales — rellenar con 0 si no existen en X_test
for (col in c("type_high_risk", "balance_zero_orig", "amount_large",
"amount_ratio_orig")) {
if (!col %in% names(test_acc)) test_acc[[col]] <- 0
}
test_acc <- test_acc |>
mutate(
accion = case_when(
cluster_pred == 0 & balance_zero_orig >= p90_risk & type_high_risk > 0 ~ "\U0001f534 BLOQUEAR \u2014 outlier + vaciado en tipo cr\u00edtico",
cluster_pred == 0 & amount_large >= p95_z ~ "\U0001f534 BLOQUEAR \u2014 outlier + monto extremo",
balance_zero_orig > 0 & type_high_risk > 0 & amount_ratio_orig > 0.9 ~ "\U0001f534 BLOQUEAR \u2014 vaciado exacto en tipo cr\u00edtico",
cluster_pred == 0 ~ "\U0001f7e1 REVISAR \u2014 outlier sin perfil cr\u00edtico",
balance_zero_orig >= p75_risk & type_high_risk > 0 ~ "\U0001f7e1 REVISAR \u2014 riesgo elevado + tipo peligroso",
amount_large >= p95_z & balance_zero_orig > 0 ~ "\U0001f7e1 REVISAR \u2014 monto an\u00f3malo + vaciado",
type_high_risk > 0 & balance_zero_orig >= p75_risk ~ "\U0001f7e1 REVISAR \u2014 tipo riesgo + balance cr\u00edtico",
TRUE ~ "\U0001f7e2 APROBAR \u2014 patr\u00f3n normal"
),
prioridad = case_when(
grepl("BLOQUEAR", accion) ~ 1L,
grepl("REVISAR", accion) ~ 2L,
TRUE ~ 3L
)
)
accion_summary <- test_acc |>
count(accion) |>
mutate(pct = round(100 * n / sum(n), 2)) |>
arrange(accion)
kbl(accion_summary, col.names = c("Acci\u00f3n", "N transacciones", "% del total"),
booktabs = TRUE) |>
kable_styling(bootstrap_options = c("striped", "hover"),
full_width = FALSE, font_size = 13) |>
column_spec(1, bold = TRUE)| Acción | N transacciones | % del total |
|---|---|---|
| 🟡 REVISAR — outlier sin perfil crítico | 1 | 0.04 |
| 🟢 APROBAR — patrón normal | 2399 | 99.96 |
El resultado operativo del modelo con selección es contundente: 99.96 % de las transacciones se aprueba automáticamente, y solo 1 transacción (0.04 %) escala a revisión manual como outlier sin perfil crítico — la misma observación clasificada como ruido (C0) en el t-SNE.
Ninguna transacción alcanza el umbral de BLOQUEAR, consistente con la fraud_rate baja observada en todos los clusters. El sistema opera con una carga de revisión manual prácticamente nula.
16.4 Validación de Capturas
n_fraude_test <- sum(y_test)
n_bloqueados <- sum(grepl("BLOQUEAR", test_acc$accion))
n_revisados <- sum(grepl("REVISAR", test_acc$accion))
fraudes_capturados <- sum(y_test[grepl("BLOQUEAR|REVISAR", test_acc$accion)] == 1)
fraudes_bloq_solo <- sum(y_test[grepl("BLOQUEAR", test_acc$accion)] == 1)
precision_bloq <- round(fraudes_bloq_solo / max(n_bloqueados, 1), 4)
recall_total <- round(fraudes_capturados / max(n_fraude_test, 1), 4)
resumen_cap <- data.frame(
Indicador = c("Fraudes reales en test","Marcados BLOQUEAR",
"Fraudes en BLOQUEAR","Precisión BLOQUEAR",
"Recall total (BLO+REV)","Fraudes NO capturados"),
Valor = c(n_fraude_test, n_bloqueados,
fraudes_bloq_solo, precision_bloq,
recall_total, n_fraude_test - fraudes_capturados)
)
kbl(resumen_cap, booktabs = TRUE, col.names = c("Indicador","Valor")) |>
kable_styling(bootstrap_options = c("striped","hover"),
full_width = FALSE, font_size = 13) |>
column_spec(1, bold = TRUE) |>
column_spec(2, bold = TRUE, color = "#8e44ad")| Indicador | Valor |
|---|---|
| Fraudes reales en test | 15 |
| Marcados BLOQUEAR | 0 |
| Fraudes en BLOQUEAR | 0 |
| Precisión BLOQUEAR | 0 |
| Recall total (BLO+REV) | 0 |
| Fraudes NO capturados | 15 |
16.5 Diferencias en Accionabilidad: Con vs Sin Selección de características
# Accionabilidad sin selección — mismas reglas, variables de X_full_test
test_acc_full <- X_full_test
test_acc_full$cluster_pred <- labels_test_full
test_acc_full$isFraud_real <- y_test
# Variables disponibles en ambos conjuntos
vars_acc_full <- intersect(names(test_acc_full),
c("composite_risk","balance_zero_orig","type_high_risk","surr_amount",
"layering_flag","rapid_move_flag","amt_z_orig","is_night"))
p90_rf <- quantile(X_full_train$composite_risk, 0.90, na.rm = TRUE)
p75_rf <- quantile(X_full_train$composite_risk, 0.75, na.rm = TRUE)
p95_zf <- quantile(X_full_train$amount_large, 0.95, na.rm = TRUE)
test_acc_full <- test_acc_full |>
mutate(
accion = case_when(
cluster_pred == 0 & composite_risk >= p90_rf ~ "🔴 BLOQUEAR — outlier + riesgo crítico",
cluster_pred == 0 & (layering_flag > 0 | structuring_flag > 0)~ "🔴 BLOQUEAR — outlier + patrón lavado",
balance_zero_orig > 0 & type_high_risk > 0 & amount_ratio_orig > 0.9 ~ "🔴 BLOQUEAR — vaciado exacto",
cluster_pred == 0 ~ "🟡 REVISAR — outlier sin perfil crítico",
composite_risk >= p75_rf & type_high_risk > 0 ~ "🟡 REVISAR — riesgo elevado",
amount_large >= p95_zf & balance_zero_orig > 0 ~ "🟡 REVISAR — monto anómalo + vaciado",
TRUE ~ "🟢 APROBAR — patrón normal"
)
)
fraudes_cap_full <- sum(y_test[grepl("BLOQUEAR|REVISAR", test_acc_full$accion)] == 1)
recall_full <- round(fraudes_cap_full / max(n_fraude_test, 1), 4)
comparativa_acc <- data.frame(
Indicador = c("N Features usadas","Recall (BLOQUEAR+REVISAR)",
"Fraudes capturados","N BLOQUEAR","N REVISAR","N APROBAR"),
Con_Selección = c(length(final_features), recall_total,
fraudes_capturados, n_bloqueados, n_revisados,
sum(grepl("APROBAR", test_acc$accion))),
Sin_Selección = c(ncol(X_filtered), recall_full,
fraudes_cap_full,
sum(grepl("BLOQUEAR", test_acc_full$accion)),
sum(grepl("REVISAR", test_acc_full$accion)),
sum(grepl("APROBAR", test_acc_full$accion)))
)
kbl(comparativa_acc, booktabs = TRUE,
col.names = c("Indicador","Con Selección","Sin Selección"),
align = "lrr") |>
kable_styling(bootstrap_options = c("striped","hover"),
full_width = FALSE, font_size = 13) |>
column_spec(1, bold = TRUE) |>
column_spec(2, bold = TRUE, color = "#8e44ad") |>
column_spec(3, color = "#7f8c8d")| Indicador | Con Selección | Sin Selección |
|---|---|---|
| N Features usadas | 8 | 30 |
| Recall (BLOQUEAR+REVISAR) | 0 | 1 |
| Fraudes capturados | 0 | 15 |
| N BLOQUEAR | 0 | 30 |
| N REVISAR | 1 | 541 |
| N APROBAR | 2399 | 1829 |
La tabla expone el trade-off central del pipeline: el modelo con selección captura 0 fraudes con 1 revisión; el modelo sin selección captura 15 fraudes pero genera 30 bloqueos y 541 revisiones.
- Con selección: espacio geométricamente limpio — los clusters absorben outliers fraudulentos dentro de patrones normales, eliminando alertas pero también sensibilidad.
- Sin selección: mayor exposición de anomalías a costa de 571 acciones de revisión/bloqueo, de las cuales la mayoría son falsos positivos operativos.
Ningún modelo es superior en términos absolutos: parsimonia y sensibilidad compiten directamente. La decisión operativa depende del costo relativo entre falsos negativos (fraude no detectado) y falsos positivos (transacciones legítimas interrumpidas). —
16.6 Análisis de Costos Operativos
En detección de fraude la función objetivo real no es maximizar métricas geométricas sino minimizar la pérdida esperada:
\[L = C_{FN} \cdot FN + C_{FP} \cdot FP\]
donde \(C_{FN}\) es el costo de no detectar un fraude y \(C_{FP}\) el costo de interrumpir una transacción legítima.
Bajo supuestos conservadores para PaySim1:
| Parámetro | Supuesto | Justificación |
|---|---|---|
| \(C_{FN}\) — Fraude no detectado | USD 500 | Pérdida media por transacción fraudulenta |
| \(C_{FP}\) — Falso positivo | USD 15 | Costo operativo de revisión manual |
| Ratio \(C_{FN} / C_{FP}\) | 33× | El fraude no detectado cuesta 33 veces más |
Con este ratio, la pérdida esperada de cada modelo en test es:
| Modelo | FN | FP | \(L\) estimada |
|---|---|---|---|
| Con selección | 15 | 1 | USD 7.515 |
| Sin selección | 0 | 571 | USD 8.565 |
Lectura: bajo estos supuestos ambos modelos son similares en pérdida total. Si \(C_{FN}\) sube a USD 1.000, el modelo sin selección domina claramente (\(L = 8.565\) vs \(L = 15.015\)). El umbral de indiferencia es \(C_{FN} / C_{FP} \approx 38\times\).
17 Diagrama del Pipeline
PaySim1 · 8,000 obs · 11 vars
Distribuciones · Asimetría · Correlaciones
NA → mediana | Inf → 0
Conservar · Transformar · Eliminar
~47 variables de anomalía financiera
Eliminar σ < 1e-8
Eliminar |r| ≥ 0.90
ε óptimo por 2ª derivada (DBSCAN)
Fisher · RF · LASSO · SFS · B&B · VT · MI · ReliefF
Min-Max normalizado
Grid search ε × MinPts
Línea base — todas las features
Silhouette · DB · CH · % Ruido
Con selección vs Sin selección
1-NN desde entrenamiento
BLOQUEAR · REVISAR · APROBAR
17.1 Resumen Final
Este documento presenta un pipeline completo de detección de fraude financiero sobre el dataset sintético PaySim1, utilizando DBSCAN como algoritmo central de aprendizaje no supervisado.
El análisis parte de 11 variables originales sobre 8.000 transacciones. Dado que el fraude financiero no se expresa directamente en las variables brutas — sino en patrones de comportamiento como vaciado de cuentas, montos exactos al saldo disponible o transferencias en horarios atípicos —, se construyen 49 variables derivadas mediante ingeniería de características, expandiendo el espacio de representación a 60 columnas. Esta derivación es condición necesaria para que DBSCAN pueda identificar regiones de densidad anómala asociadas a fraude: sin ella, el algoritmo operaría sobre un espacio de representación insuficiente para separar transacciones fraudulentas de legítimas.
Sobre este espacio enriquecido se aplica limpieza estructural — eliminación de varianza nula y correlación iterativa \(\geq 0.90\) — seguida de ocho selectores de características (Fisher, Random Forest, LASSO, SFS, Branch & Bound, Variance Threshold, Mutual Information y ReliefF) evaluados de forma comparativa, de los cuales se retienen 8 variables finales.
Los resultados del pipeline evidencian que la selección de características es la decisión técnica de mayor impacto sobre DBSCAN:
| Métrica | Con Selección | Sin Selección |
|---|---|---|
| Features | 8 | 30 |
| Clusters | 8 | 35 |
| Silhouette (train) | 0.766 | 0.422 |
| Silhouette (test) | 0.767 | 0.422 |
| Calinski-Harabasz | 17.322,8 | 1.063,5 |
| Ruido (%) | 0.05 % | 2.48 % |
| Fraudes capturados | 0 | 15 |
| N Revisión/Bloqueo | 1 | 571 |
El modelo con selección produce 8 clusters geométricamente sólidos (\(\bar{s} = 0.766\)) que generalizan correctamente a test, con una carga operativa de revisión manual prácticamente nula (1 transacción, 0.04 %). El modelo sin selección captura 15 fraudes reales pero al costo de 571 acciones de revisión y bloqueo — en su mayoría falsos positivos.
El hallazgo central no es que un modelo sea superior al otro, sino que parsimonia y sensibilidad compiten directamente en DBSCAN: reducir dimensiones compacta los clusters y minimiza alertas; mantenerlas expone anomalías pero incrementa el ruido operativo. La decisión final depende del costo relativo entre fraude no detectado y transacciones legítimas interrumpidas — una variable de negocio, no de algoritmo.
El documento se complementa con un análisis de negocio que cuantifica la pérdida esperada bajo una función de costo explícita (\(C_{FN} = 33 \times C_{FP}\)), una recomendación operativa diferenciada por cluster — con C4 y C6 como prioridad de monitoreo — y un ROI estimado que favorece el modelo sin selección cuando el volumen transaccional escala. Finalmente, se identifican las limitaciones estructurales del enfoque y las extensiones naturales hacia algoritmos como Isolation Forest, Autoencoder, HDBSCAN y redes neuronales de tipo GNN para detección de fraude coordinado.
18 Limitaciones y Trabajo Futuro
18.1 Limitaciones del Estudio
Datos sintéticos: PaySim1 replica patrones estadísticos reales pero no captura la complejidad de datos transaccionales en producción — correlaciones temporales largas, comportamiento estacional y fraude coordinado entre cuentas quedan subrepresentados.
DBSCAN en alta dimensión: la maldición de la dimensionalidad afecta la métrica euclidiana. Con más de 10 features, la diferencia entre la distancia mínima y máxima colapsa, reduciendo el contraste de densidad que DBSCAN necesita para formar clusters estables.
Función de pérdida no calibrada: los costos \(C_{FN}\) y \(C_{FP}\) son supuestos ilustrativos. Sin datos históricos de pérdida real, la recomendación operativa es orientativa, no prescriptiva.
Etiquetas usadas solo en validación: al ser no supervisado, DBSCAN no incorpora isFraud en el entrenamiento — los clusters capturan estructura de densidad, no necesariamente estructura de fraude.
18.2 Trabajo Futuro
Este pipeline establece una base metodológica sólida para la detección de fraude en datos transaccionales. Como extensión natural, se explorarán enfoques alternativos de machine learning y deep learning que permitan mejorar la capacidad de identificación de transacciones fraudulentas — tanto en precisión discriminativa como en robustez ante datos no vistos.
El objetivo no es sustituir el enfoque no supervisado, sino complementarlo: evaluar si otros paradigmas de aprendizaje, aplicados sobre el mismo espacio de representación construido en este documento, logran capturar patrones de fraude que DBSCAN, por su naturaleza basada en densidad, no puede detectar.
18.2.1 Extensiones Metodológicas
- Pipeline híbrido: usar DBSCAN como generador de pseudo-etiquetas para entrenar un clasificador supervisado — combina la capacidad de descubrimiento no supervisado con la precisión discriminativa supervisada.
- Calibración de umbrales: optimizar los umbrales de BLOQUEAR/REVISAR minimizando \(L = C_{FN} \cdot FN + C_{FP} \cdot FP\) con datos reales de pérdida.
- Detección en stream: adaptar el pipeline a datos en tiempo real con DBSCAN incremental o river (ML online).
- Explicabilidad: integrar SHAP sobre los clusters para derivar explicaciones individuales por transacción — requisito regulatorio en contextos de cumplimiento (GDPR, Basilea III).
19 Referencias
- Ester, M., Kriegel, H.-P., Sander, J., & Xu, X. (1996). A density-based algorithm for discovering clusters in large spatial databases with noise. KDD-96, 226–231.
- Lopez-Rojas, E. A., Elmir, A., & Axelsson, S. (2016). PaySim: A financial mobile money simulator for fraud detection. EMSS 2016.
- Rousseeuw, P. J. (1987). Silhouettes: A graphical aid to the interpretation and validation of cluster analysis. J. Comput. Appl. Math., 20, 53–65.
- Kira, K., & Rendell, L. A. (1992). The feature selection problem: Traditional methods and a new algorithm. AAAI-92, 129–134. (ReliefF)
- Tibshirani, R. (1996). Regression shrinkage and selection via the lasso. J. R. Statist. Soc. B, 58(1), 267–288.
- Hahsler, M., Piekenbrock, M., & Doran, D. (2019). dbscan: Fast density-based clustering with R. J. Stat. Softw., 91(1).
- Van der Maaten, L., & Hinton, G. (2008). Visualizing data using t-SNE. JMLR, 9, 2579–2605.
- Breiman, L. (2001). Random forests. Machine Learning, 45(1), 5–32.
Documento generado con Quarto · Alejandro Figueroa Rojas · 2026