DBSCAN — Detección de Fraude en Transacciones Financieras

Pipeline Estructural de Aprendizaje No Supervisado · PaySim1 Dataset

Autor/a

Alejandro Figueroa Rojas | Ingeniero Comercial — Data & Business Intelligence

Fecha de publicación

5 de mayo de 2026


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

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

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:

  1. Marcar \(p\) como visitado. Calcular \(\mathcal{N}_\varepsilon(p)\).
  2. Si \(|\mathcal{N}_\varepsilon(p)| < \text{MinPts}\): etiquetar \(p\) como ruido.
  3. 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\).

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 + p2

El 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:

  1. Pool inicial vía Fisher (8 variables): el ranking Fisher entregó las 8 features con mayor separabilidad entre clases como espacio de búsqueda.

  2. 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_rate debe verificarse para descartar data leakage: si fue calculada usando y_label del 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.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_risk y balance_zero_dest que 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")
Resultados Detectados: Evaluación en Conjunto de Test (DBSCAN Predict)
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_comparativa

El 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_t2

Ambos 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_risk sobre 0.79 y 0.69, risk_score_1 de 0.75 en ambos, y type_high_risk = 1 — son los clusters operativamente más sensibles pese a la etiqueta NORMAL.
  • C1 es el cluster de menor riesgo absoluto: composite_risk 0.207, fraud_rate 0.0005, sin tipo de alto riesgo — masa legítima pura.
  • La fraud_rate es 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

Paso 1 — Carga de datos
PaySim1 · 8,000 obs · 11 vars
Paso 2 — EDA
Distribuciones · Asimetría · Correlaciones
Paso 3 — Preprocesamiento
NA → mediana | Inf → 0
Paso 4 — Categorización de variables
Conservar · Transformar · Eliminar
Paso 5 — Ingeniería de Features
~47 variables de anomalía financiera
Paso 5a — Varianza ≈ 0
Eliminar σ < 1e-8
Paso 5b — Correlación iterativa
Eliminar |r| ≥ 0.90
Pasos 5a + 5b = Clean Algorithm — reducción estructural previa a selección
Paso 6 — k-dist Plot
ε óptimo por 2ª derivada (DBSCAN)
Paso 7 — Selectores de Features
Fisher · RF · LASSO · SFS · B&B · VT · MI · ReliefF
Paso 8 — Distancia Euclídea
Min-Max normalizado
Paso 9a — DBSCAN con selección
Grid search ε × MinPts
Paso 9b — DBSCAN sin selección
Línea base — todas las features
Paso 12 — Métricas de Validación
Silhouette · DB · CH · % Ruido
Paso 13 — Comparativa
Con selección vs Sin selección
Paso 14 — Datos Nuevos (Test)
1-NN desde entrenamiento
Paso 15 — Accionabilidad
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