Segmentación de Clientes de Tarjeta de Crédito

Clustering Jerárquico Aglomerativo y Divisivo

Author

Alejandro Figueroa Rojas

Published

April 7, 2026


1 Motivación y Problemática

Una institución financiera dispone de registros de comportamiento transaccional de 8.950 titulares de tarjeta de crédito durante seis meses. La ausencia de etiquetas predefinidas impide aplicar algoritmos supervisados; sin embargo, la heterogeneidad observable en las variables de gasto, endeudamiento y pago sugiere la existencia de perfiles latentes de cliente con distinta exposición al riesgo y distinto potencial comercial.

Objetivo: identificar grupos homogéneos de clientes mediante clustering jerárquico, seleccionando automáticamente el algoritmo aglomerativo óptimo (HAC-Average) y el divisivo (DIANA) a partir de la comparativa de Silhouette sobre 6 estrategias de selección de características, comparar ambas soluciones y derivar acciones concretas de negocio para cada segmento.


2 Carga de Datos

cc <- read.csv("CC_GENERAL.csv", stringsAsFactors=FALSE, na.strings=c("","NA"))
cat("Dimensiones:", dim(cc), "\n")
Dimensiones: 8950 18 
glimpse(cc)
Rows: 8,950
Columns: 18
$ CUST_ID                          <chr> "C10001", "C10002", "C10003", "C10004…
$ BALANCE                          <dbl> 40.90075, 3202.46742, 2495.14886, 166…
$ BALANCE_FREQUENCY                <dbl> 0.818182, 0.909091, 1.000000, 0.63636…
$ PURCHASES                        <dbl> 95.40, 0.00, 773.17, 1499.00, 16.00, …
$ ONEOFF_PURCHASES                 <dbl> 0.00, 0.00, 773.17, 1499.00, 16.00, 0…
$ INSTALLMENTS_PURCHASES           <dbl> 95.40, 0.00, 0.00, 0.00, 0.00, 1333.2…
$ CASH_ADVANCE                     <dbl> 0.0000, 6442.9455, 0.0000, 205.7880, …
$ PURCHASES_FREQUENCY              <dbl> 0.166667, 0.000000, 1.000000, 0.08333…
$ ONEOFF_PURCHASES_FREQUENCY       <dbl> 0.000000, 0.000000, 1.000000, 0.08333…
$ PURCHASES_INSTALLMENTS_FREQUENCY <dbl> 0.083333, 0.000000, 0.000000, 0.00000…
$ CASH_ADVANCE_FREQUENCY           <dbl> 0.000000, 0.250000, 0.000000, 0.08333…
$ CASH_ADVANCE_TRX                 <int> 0, 4, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0…
$ PURCHASES_TRX                    <int> 2, 0, 12, 1, 1, 8, 64, 12, 5, 3, 12, …
$ CREDIT_LIMIT                     <dbl> 1000, 7000, 7500, 7500, 1200, 1800, 1…
$ PAYMENTS                         <dbl> 201.8021, 4103.0326, 622.0667, 0.0000…
$ MINIMUM_PAYMENTS                 <dbl> 139.50979, 1072.34022, 627.28479, NA,…
$ PRC_FULL_PAYMENT                 <dbl> 0.000000, 0.222222, 0.000000, 0.00000…
$ TENURE                           <int> 12, 12, 12, 12, 12, 12, 12, 12, 12, 1…

3 EDA y Diagnóstico de Estructura

3.1 Diccionario de Variables

Variable Tipo Descripción
CUST_ID ID Identificador único
BALANCE Num. continua Saldo promedio disponible
BALANCE_FREQUENCY Num. [0,1] Frecuencia actualización saldo
PURCHASES Num. continua Monto total compras
ONEOFF_PURCHASES Num. continua Compras en un solo pago
INSTALLMENTS_PURCHASES Num. continua Compras en cuotas
CASH_ADVANCE Num. continua Adelantos en efectivo
PURCHASES_FREQUENCY Num. [0,1] Frecuencia de compras
ONEOFF_PURCHASES_FREQUENCY Num. [0,1] Frecuencia compras un pago
PURCHASES_INSTALLMENTS_FREQUENCY Num. [0,1] Frecuencia compras cuotas
CASH_ADVANCE_FREQUENCY Num. [0,1] Frecuencia adelantos
CASH_ADVANCE_TRX Num. entera N.º transacciones adelanto
PURCHASES_TRX Num. entera N.º transacciones compra
CREDIT_LIMIT Num. continua Límite de crédito
PAYMENTS Num. continua Total pagos realizados
MINIMUM_PAYMENTS Num. continua Pagos mínimos
PRC_FULL_PAYMENT Num. [0,1] Proporción meses pago total
TENURE Num. entera Meses como titular

3.2 Estadísticos Descriptivos

cc_num <- cc |> select(-CUST_ID)
summary(cc_num) |>
  kbl(caption="Estadísticos descriptivos") |>
  kable_styling(bootstrap_options=c("striped","condensed"),
                font_size=11, full_width=TRUE) |>
  scroll_box(width="100%", height="300px")
Estadísticos descriptivos
BALANCE BALANCE_FREQUENCY PURCHASES ONEOFF_PURCHASES INSTALLMENTS_PURCHASES CASH_ADVANCE PURCHASES_FREQUENCY ONEOFF_PURCHASES_FREQUENCY PURCHASES_INSTALLMENTS_FREQUENCY CASH_ADVANCE_FREQUENCY CASH_ADVANCE_TRX PURCHASES_TRX CREDIT_LIMIT PAYMENTS MINIMUM_PAYMENTS PRC_FULL_PAYMENT TENURE
Min. : 0.0 Min. :0.0000 Min. : 0.00 Min. : 0.0 Min. : 0.0 Min. : 0.0 Min. :0.00000 Min. :0.00000 Min. :0.0000 Min. :0.0000 Min. : 0.000 Min. : 0.00 Min. : 50 Min. : 0.0 Min. : 0.019 Min. :0.0000 Min. : 6.00
1st Qu.: 128.3 1st Qu.:0.8889 1st Qu.: 39.63 1st Qu.: 0.0 1st Qu.: 0.0 1st Qu.: 0.0 1st Qu.:0.08333 1st Qu.:0.00000 1st Qu.:0.0000 1st Qu.:0.0000 1st Qu.: 0.000 1st Qu.: 1.00 1st Qu.: 1600 1st Qu.: 383.3 1st Qu.: 169.124 1st Qu.:0.0000 1st Qu.:12.00
Median : 873.4 Median :1.0000 Median : 361.28 Median : 38.0 Median : 89.0 Median : 0.0 Median :0.50000 Median :0.08333 Median :0.1667 Median :0.0000 Median : 0.000 Median : 7.00 Median : 3000 Median : 856.9 Median : 312.344 Median :0.0000 Median :12.00
Mean : 1564.5 Mean :0.8773 Mean : 1003.21 Mean : 592.4 Mean : 411.1 Mean : 978.9 Mean :0.49035 Mean :0.20246 Mean :0.3644 Mean :0.1351 Mean : 3.249 Mean : 14.71 Mean : 4494 Mean : 1733.1 Mean : 864.207 Mean :0.1537 Mean :11.52
3rd Qu.: 2054.1 3rd Qu.:1.0000 3rd Qu.: 1110.13 3rd Qu.: 577.4 3rd Qu.: 468.6 3rd Qu.: 1113.8 3rd Qu.:0.91667 3rd Qu.:0.30000 3rd Qu.:0.7500 3rd Qu.:0.2222 3rd Qu.: 4.000 3rd Qu.: 17.00 3rd Qu.: 6500 3rd Qu.: 1901.1 3rd Qu.: 825.485 3rd Qu.:0.1429 3rd Qu.:12.00
Max. :19043.1 Max. :1.0000 Max. :49039.57 Max. :40761.2 Max. :22500.0 Max. :47137.2 Max. :1.00000 Max. :1.00000 Max. :1.0000 Max. :1.5000 Max. :123.000 Max. :358.00 Max. :30000 Max. :50721.5 Max. :76406.208 Max. :1.0000 Max. :12.00
NA NA NA NA NA NA NA NA NA NA NA NA NA's :1 NA NA's :313 NA NA

3.3 Distribuciones

cc_num |>
  pivot_longer(everything(), names_to="var", values_to="val") |>
  ggplot(aes(x=val)) +
  geom_histogram(bins=40, fill="#4E79A7", color="white", alpha=.85) +
  facet_wrap(~var, scales="free", ncol=4) +
  labs(title="Distribución de variables", x=NULL, y="Frecuencia") +
  theme_bw(base_size=10)

Los histogramas revelan tres patrones dominantes en el comportamiento transaccional:

Asimetría positiva severa en todas las variables monetarias (BALANCE, CASH_ADVANCE, CREDIT_LIMIT, PURCHASES, PAYMENTS, MINIMUM_PAYMENTS, ONEOFF_PURCHASES, INSTALLMENTS_PURCHASES), la gran mayoría de clientes concentra valores cercanos a cero, con una cola derecha larga de casos extremos. La marcada asimetría positiva en variables monetarias justifica \(\log(1+x)\) antes del cálculo de distancias.

Distribución bimodal o concentrada en extremos en variables de frecuencia (BALANCE_FREQUENCY, PURCHASES_FREQUENCY, ONEOFF_PURCHASES_FREQUENCY, PURCHASES_INSTALLMENTS_FREQUENCY, CASH_ADVANCE_FREQUENCY), los clientes se polarizan entre quienes nunca realizan la acción (valor 0) y quienes la realizan siempre (valor 1), con poca masa en valores intermedios.

Casos particulares: PRC_FULL_PAYMENT muestra que la mayoría nunca paga el total (~6.500 clientes en 0), confirmando el bajo nivel de disciplina financiera general. TENURE es la única variable discreta con distribución concentrada en 12 meses, la mayoría de titulares lleva exactamente un año como cliente.


4 Preprocesamiento

4.1 Imputación de Valores Faltantes

na_tab <- cc_num |>
  summarise(across(everything(), ~sum(is.na(.)))) |>
  pivot_longer(everything(), names_to="Variable", values_to="NA_count") |>
  filter(NA_count > 0) |>
  mutate(Pct=round(NA_count/nrow(cc_num)*100, 2))
kbl(na_tab, caption="Variables con valores faltantes") |>
  kable_styling(bootstrap_options="striped", full_width=FALSE)
Variables con valores faltantes
Variable NA_count Pct
CREDIT_LIMIT 1 0.01
MINIMUM_PAYMENTS 313 3.50

La imputación se realiza con la mediana, estrategia robusta ante la asimetría observada.

cc_imp <- cc_num |>
  mutate(across(everything(), ~ifelse(is.na(.), median(., na.rm=TRUE), .)))
stopifnot(sum(is.na(cc_imp)) == 0)

cat("Imputación con mediana completada:",
    sum(is.na(cc_imp)), "NA restantes de", nrow(cc_imp), "filas x", ncol(cc_imp), "columnas")
Imputación con mediana completada: 0 NA restantes de 8950 filas x 17 columnas

4.2 Transformación Logarítmica

vars_log <- c("BALANCE","PURCHASES","ONEOFF_PURCHASES","INSTALLMENTS_PURCHASES",
              "CASH_ADVANCE","PAYMENTS","MINIMUM_PAYMENTS","CREDIT_LIMIT")
cc_tr <- cc_imp |> mutate(across(all_of(vars_log), ~log1p(.)))

cat("Transformación log1p aplicada a", length(vars_log), "variables monetarias:\n",
    paste(vars_log, collapse=", "), "\n\n",
    "Rango ANTES:", round(min(cc_imp[,vars_log]),2), "—", round(max(cc_imp[,vars_log]),2), "\n",
    "Rango DESPUÉS:", round(min(cc_tr[,vars_log]),2), "—", round(max(cc_tr[,vars_log]),2))
Transformación log1p aplicada a 8 variables monetarias:
 BALANCE, PURCHASES, ONEOFF_PURCHASES, INSTALLMENTS_PURCHASES, CASH_ADVANCE, PAYMENTS, MINIMUM_PAYMENTS, CREDIT_LIMIT 

 Rango ANTES: 0 — 76406.21 
 Rango DESPUÉS: 0 — 11.24

La marcada asimetría positiva de las variables monetarias, con valores que van de 0 a 76.406 comprime la mayoría de las observaciones cerca de cero y distorsiona el cálculo de distancias en el clustering. La transformación \(\log(1+x)\) corrige esto: reduce el rango a 0–11,24, suaviza los valores extremos y acerca la distribución a la simetría, mejorando la calidad de los clusters resultantes.


5 Categorización de Variables

Categorización de variables
Variable Categoría Decisión
BALANCE Monetaria (log1p) Conservar
BALANCE_FREQUENCY Frecuencia [0,1] Conservar
PURCHASES Monetaria (log1p) Conservar
ONEOFF_PURCHASES Monetaria (log1p) Conservar
INSTALLMENTS_PURCHASES Monetaria (log1p) Conservar
CASH_ADVANCE Monetaria (log1p) Conservar
PURCHASES_FREQUENCY Frecuencia [0,1] Conservar
ONEOFF_PURCHASES_FREQUENCY Frecuencia [0,1] Conservar
PURCHASES_INSTALLMENTS_FREQUENCY Frecuencia [0,1] Conservar
CASH_ADVANCE_FREQUENCY Frecuencia [0,1] Conservar
CASH_ADVANCE_TRX Conteo (entero) Conservar
PURCHASES_TRX Conteo (entero) Conservar
CREDIT_LIMIT Monetaria (log1p) Conservar
PAYMENTS Monetaria (log1p) Conservar
MINIMUM_PAYMENTS Monetaria (log1p) Conservar
PRC_FULL_PAYMENT Proporción [0,1] Conservar
TENURE Temporal (entero) Conservar

6 Selección de Características

6.1 Clean Algorithm

Operación previa y obligatoria a cualquier selector.

6.1.1 Paso A — Features constantes (\(\hat{\sigma}_j < 10^{-8}\))

\[\text{Eliminar } X_j \iff \hat{\sigma}_j < 10^{-8}\]

cc_sc_tmp <- scale(cc_tr)
stds      <- apply(cc_sc_tmp, 2, sd)
vars_cte  <- names(stds[stds < 1e-8])
cc_filt   <- cc_tr[, setdiff(names(cc_tr), vars_cte)]
cat("Features originales:", ncol(cc_tr),
    "\nConstantes eliminadas:", length(vars_cte),
    "\nTras Paso A:", ncol(cc_filt))
Features originales: 17 
Constantes eliminadas: 0 
Tras Paso A: 17

6.1.2 Paso B — Features altamente correladas (iterativo \(|r| \geq 0.90\))

\[\text{Eliminar } X_j \iff \exists\, X_k : |r(X_j, X_k)| \geq 0.90\]

CORR_THR          <- 0.90
vars_removed_corr <- character(0)
X_clean           <- as.data.frame(scale(cc_filt))

repeat {
  cm     <- cor(X_clean, use="pairwise.complete.obs")
  idx_hi <- which(abs(cm) >= CORR_THR & upper.tri(cm), arr.ind=TRUE)
  if (nrow(idx_hi) == 0) break
  best   <- idx_hi[which.max(abs(cm[idx_hi])), ]
  rem    <- sample(colnames(X_clean)[best], 1)
  vars_removed_corr <- c(vars_removed_corr, rem)
  X_clean <- X_clean[, !names(X_clean) %in% rem]
}
cat("Eliminadas por correlación (iterativo):", length(vars_removed_corr),
    "\nFeatures limpias (q):", ncol(X_clean))
Eliminadas por correlación (iterativo): 0 
Features limpias (q): 17

6.2 Estrategias de Selección de Características

Se evalúan 6 estrategias de representación. El proceso de selección opera en dos etapas encadenadas:

Etapa 1 — Búsqueda del espacio óptimo por algoritmo: Cada estrategia \(s\) es evaluada de forma independiente sobre cuatro algoritmos jerárquicos (HAC-Ward, HAC-Complete, HAC-Average y DIANA) mediante Silhouette promedio (\(\bar{s}\)) sobre muestra de 1.500 observaciones:

\[\bar{s}_s^{\text{ALG}} = \frac{1}{n'}\sum_{i=1}^{n'} s_i\bigl(\text{ALG}(X_s)\bigr)\]

Etapa 2 — Selección del algoritmo óptimo por familia: Cada familia (aglomerativa / divisiva) selecciona de forma independiente el par (algoritmo, espacio) que maximiza su propio Silhouette:

\[(\text{ALG}^*, s^*)_{\text{familia}} = \arg\max_{\text{ALG},\, s}\; \bar{s}_s^{\text{ALG}}\]

Esto determina automáticamente qué algoritmo aglomerativo (entre Ward, Complete y Average) y qué espacio de representación se usarán en todo el análisis posterior. DIANA compite por separado para seleccionar su propio espacio óptimo. Ambas elecciones quedan fijadas antes del clustering final.

set.seed(42)
K_EVAL   <- 4
SFS_K    <- min(8L, ncol(X_clean))
idx_eval <- sample(nrow(X_clean), 1500)
X_ev     <- as.matrix(X_clean[idx_eval, ])

# Evaluador común: HAC-Ward Silhouette
eval_hac <- function(mat, k=K_EVAL) {
  if (is.null(ncol(mat)) || ncol(mat) < 2) return(-1)
  D <- dist(mat); hc <- hclust(D, method="ward.D2")
  mean(silhouette(cutree(hc, k=k), D)[,3])
}

# Proxy supervisado para Elastic Net y RF (PC1)
y_proxy <- prcomp(X_clean, center=FALSE)$x[, 1]

6.2.1 S1 — Elastic Net

Señal lineal penalizada con \(\ell_1 + \ell_2\); estabiliza variables correladas:

\[\hat{\beta} = \arg\min_\beta \left\{\frac{1}{2n}\|\mathbf{y}-\mathbf{X}\beta\|^2 + \lambda\left[\alpha\|\beta\|_1 + \frac{1-\alpha}{2}\|\beta\|_2^2\right]\right\}\]

enet_cv <- cv.glmnet(as.matrix(X_clean), y_proxy, alpha=0.5, nfolds=5)
coef_en <- coef(enet_cv, s="lambda.min")[-1, 1]
sel_s1  <- names(coef_en[coef_en != 0])
if (length(sel_s1) < 2) sel_s1 <- names(sort(abs(coef_en), TRUE))[1:4]
if (length(sel_s1) > SFS_K) sel_s1 <- names(sort(abs(coef_en[sel_s1]), TRUE))[1:SFS_K]
X_s1    <- as.matrix(X_clean[, sel_s1])
cat("S1 Elastic Net — lambda.min:", round(enet_cv$lambda.min,5),
    "| features:", length(sel_s1))
S1 Elastic Net — lambda.min: 0.0459 | features: 8

El modelo Elastic Net exploró múltiples valores de \(\lambda\) mediante validación cruzada (5 folds) y determinó que lambda.min = 0.0459 minimiza el error sobre el proxy PC1. Con esa penalización, los coeficientes de las variables menos informativas colapsan a cero, conservando 8 features relevantes para el clustering.

6.2.2 S2 — Random Forest (%IncMSE)

Importancia por permutación; captura no linealidades:

\[\text{Imp}(x_j) = \frac{1}{B}\sum_{b=1}^{B}\left(\text{MSE}_b^{\text{perm}(j)} - \text{MSE}_b\right)\]

set.seed(42)
rf_mod   <- randomForest(x=X_clean, y=y_proxy, ntree=300, importance=TRUE)
imp_rf   <- importance(rf_mod, type=1)
imp_df   <- tibble(variable=rownames(imp_rf), imp=imp_rf[,1]) |> arrange(desc(imp))
umbral_r <- mean(imp_df$imp) + 0.5*sd(imp_df$imp)
sel_s2   <- imp_df |> filter(imp >= umbral_r) |> pull(variable)
if (length(sel_s2) < 2) sel_s2 <- imp_df$variable[1:4]
X_s2     <- as.matrix(X_clean[, sel_s2])
cat("S2 RF — umbral %IncMSE:", round(umbral_r,4), "| features:", length(sel_s2))
S2 RF — umbral %IncMSE: 30.3713 | features: 4

El Random Forest calculó la importancia de cada variable mediante %IncMSE (incremento en error al permutar la variable). Solo se retienen las variables que superan el umbral media + 0.5·desviación = 30.37, resultado: 4 features con señal suficientemente fuerte para el clustering.

6.2.3 S3 — Branch & Bound (Fisher multivariado)

Único selector que garantiza el óptimo global del criterio Fisher sobre el subconjunto de tamaño \(k^*\):

\(J(\mathcal{S}) = \text{tr}(C_w^{-1} C_b), \quad \text{poda: bound}(\mathcal{S}) \leq J^* \Rightarrow \text{podar}\)

# Grupos extremos Q1 vs Q4
q1_y   <- quantile(rowMeans(X_ev), 0.25)
q4_y   <- quantile(rowMeans(X_ev), 0.75)
idx_lo <- which(rowMeans(X_ev) <= q1_y)
idx_hi <- which(rowMeans(X_ev) >= q4_y)

fisher_uni <- function(x) {
  g1 <- x[idx_lo]; g2 <- x[idx_hi]
  if ((var(g1)+var(g2)) == 0) return(0)
  (mean(g1)-mean(g2))^2 / (var(g1)+var(g2))
}
fi_all      <- setNames(apply(X_ev, 2, fisher_uni), colnames(X_ev))
pool_bb     <- names(sort(fi_all, decreasing=TRUE))[1:min(2*SFS_K, ncol(X_ev))]
fi_pool_vec <- as.numeric(fi_all[pool_bb])

fisher_multi <- function(vars) {
  Xs <- as.matrix(X_ev[c(idx_lo, idx_hi), vars, drop=FALSE])
  g1 <- Xs[seq_along(idx_lo),, drop=FALSE]
  g2 <- Xs[-seq_along(idx_lo),, drop=FALSE]
  m1 <- colMeans(g1); m2 <- colMeans(g2)
  Sw <- (cov(g1)*(nrow(g1)-1) + cov(g2)*(nrow(g2)-1)) / (nrow(g1)+nrow(g2)-2)
  dm <- matrix(m1-m2, ncol=1)
  tryCatch(as.numeric(t(dm) %*% solve(Sw+diag(1e-8,ncol(Sw))) %*% dm),
           error=function(e) sum((m1-m2)^2/(diag(Sw)+1e-8)))
}

best_sc  <- 0
best_sub <- pool_bb[order(fi_pool_vec, decreasing=TRUE)][1:SFS_K]
best_sc  <- fisher_multi(best_sub)
nodes    <- 0L; MAX_N <- 50000L

bb_search <- function(sel_idx, rem_idx, depth) {
  if (nodes >= MAX_N) return(invisible(NULL))
  if (depth == SFS_K) {
    nodes <<- nodes + 1L
    sc <- fisher_multi(pool_bb[sel_idx])
    if (is.finite(sc) && sc > best_sc) { best_sc <<- sc; best_sub <<- pool_bb[sel_idx] }
    return(invisible(NULL))
  }
  for (i in seq_along(rem_idx)) {
    nodes <<- nodes + 1L
    if (nodes >= MAX_N) return(invisible(NULL))
    ri     <- rem_idx[i]; rest <- rem_idx[-seq_len(i)]
    needed <- SFS_K - depth - 1L
    bound  <- fi_pool_vec[ri] +
              if (needed > 0 && length(rest) > 0)
                sum(head(sort(fi_pool_vec[rest], decreasing=TRUE), needed))
              else 0
    if (bound <= best_sc) next
    bb_search(c(sel_idx, ri), rest, depth+1L)
  }
}
bb_search(integer(0), seq_along(pool_bb), 0L)
sel_s3 <- best_sub
X_s3   <- as.matrix(X_clean[, sel_s3])
cat("S3 B&B — features:", length(sel_s3), "| J multivar:", round(best_sc,4))
S3 B&B — features: 8 | J multivar: 14.9047

Branch & Bound evaluó subconjuntos de features maximizando el criterio de Fisher multivariado \(J\) , que mide separación entre grupos extremos (Q1 vs Q4). Seleccionó 8 features con \(J = 14.90\), el mayor valor encontrado garantizando el óptimo global dentro del límite de nodos explorados.

6.2.4 S4 — SFS (wrapper HAC)

Selección greedy maximizando Silhouette HAC en cada paso:

\[\mathcal{F}_{t+1} = \mathcal{F}_t \cup \left\{\arg\max_{x_j \notin \mathcal{F}_t} \bar{s}\bigl(\text{HAC}(\mathcal{F}_t \cup \{x_j\})\bigr)\right\}\]

sel_s4 <- character(0); rem_s4 <- colnames(X_ev)
for (step in seq_len(SFS_K)) {
  scores <- sapply(rem_s4, function(v)
    eval_hac(X_ev[, c(sel_s4, v), drop=FALSE]))
  best   <- names(which.max(scores))
  sel_s4 <- c(sel_s4, best); rem_s4 <- setdiff(rem_s4, best)
}
X_s4 <- as.matrix(X_clean[, sel_s4])
cat("S4 SFS — features:", length(sel_s4))
S4 SFS — features: 8
  • SFS (Sequential Forward Selection): Es un método de búsqueda que comienza con un conjunto vacío y añade, paso a paso, la variable que más mejora el rendimiento del modelo.
  • Features (8): Se determinó que el subconjunto óptimo está compuesto por 8 variables.

¿Por qué es importante? Al seleccionar solo las 8 características más relevantes, eliminamos el ruido de los datos, reducimos la complejidad computacional y, lo más importante, mejoramos la capacidad de generalización del modelo para evitar el sobreajuste (overfitting).

6.2.5 S5 — PCA

Retiene componentes con \(\geq 90\%\) varianza acumulada:

pca_res <- prcomp(X_clean, center=FALSE, scale.=FALSE)
var_ac  <- cumsum(pca_res$sdev^2) / sum(pca_res$sdev^2)
n_pc    <- which(var_ac >= 0.90)[1]
X_s5    <- as.matrix(pca_res$x[, 1:n_pc])

p1 <- fviz_eig(pca_res, ncp=12, addlabels=TRUE, barfill="#4E79A7",
               barcolor="white") + labs(title="Varianza por componente") + theme_bw()
p2 <- data.frame(PC=seq_along(var_ac), Acum=var_ac) |>
  ggplot(aes(PC, Acum)) + geom_line(color="#4E79A7", linewidth=1) +
  geom_point(color="#F28E2B", size=2) +
  geom_hline(yintercept=.9, linetype="dashed", color="gray40") +
  labs(title="Varianza acumulada", y="Proporción") + theme_bw()
p1 + p2

PC1 y PC2 concentran el 29.9% y 21.9% de la varianza respectivamente, reflejando que el comportamiento transaccional tiene dos ejes dominantes (nivel de gasto y patrón de pago). La curva acumulada muestra que se necesitan aproximadamente 9–10 componentes para superar el umbral del 90% (línea punteada), lo que indica que la información está distribuida en múltiples dimensiones y no colapsa en pocos factores, justificando retener varios PCs en lugar de solo los dos primeros.

cat("S6 PCA — PCs (>=90%):", n_pc)
S6 PCA — PCs (>=90%): 9

PCA requirió 9 componentes para alcanzar el 90% de varianza acumulada, confirmando que el espacio original aunque limpio, es intrínsecamente multidimensional. Estos 9 PCs ortogonales reemplazan las features originales eliminando toda correlación residual antes del clustering.

6.2.6 S6 — SFS + PCA

SFS filtra features relevantes; PCA ortogonaliza y reduce ruido:

\[X \xrightarrow{\text{SFS}} X_{\mathcal{F}} \xrightarrow{\text{PCA}} Z \xrightarrow{\text{HAC}} \text{clusters}\]

pca_s6  <- prcomp(X_clean[, sel_s4], center=FALSE)
n_pc_s6 <- which(cumsum(pca_s6$sdev^2)/sum(pca_s6$sdev^2) >= 0.90)[1]
X_s6    <- as.matrix(predict(pca_s6)[, 1:n_pc_s6])
cat("S6 SFS+PCA — PCs:", n_pc_s6)
S6 SFS+PCA — PCs: 5

Al aplicar PCA sobre las 8 features seleccionadas por SFS, bastaron 5 componentes para alcanzar el 90% de varianza — menos que los 9 requeridos sobre el espacio completo, porque SFS ya había eliminado las variables menos informativas, concentrando la varianza en menos dimensiones.


6.3 Selección de Algoritmo Aglomerativo y Divisivo

Cada selector se evalúa en 4 algoritmos jerárquicos: HAC-Ward, HAC-Complete, HAC-Average (aglomerativos) y DIANA (divisivo). La métrica común es el Silhouette promedio (\(\bar{s}\)) sobre muestra de 1.500 observaciones.

\[\bar{s}_s^{\text{ALG}} = \frac{1}{n'}\sum_{i=1}^{n'} s_i\bigl(\text{ALG}(X_s)\bigr), \quad s^*_{\text{ALG}} = \arg\max_s \bar{s}_s^{\text{ALG}}\]

Cada familia selecciona de forma independiente el algoritmo y espacio óptimo que maximiza su propio Silhouette.

estrategias <- list(
  "S1: Elastic Net" = X_s1, "S2: RF"      = X_s2,
  "S3: B&B"         = X_s3, "S4: SFS"     = X_s4,
  "S5: PCA"         = X_s5, "S6: SFS+PCA" = X_s6
)

# Evaluadores por algoritmo
make_eval_hac <- function(metodo) {
  function(mat) {
    m <- mat[idx_eval, , drop=FALSE]; D <- dist(m)
    hc <- hclust(D, method=metodo)
    mean(silhouette(cutree(hc, k=K_EVAL), D)[,3])
  }
}

eval_diana_sil <- function(mat) {
  m <- mat[idx_eval, , drop=FALSE]; D <- dist(m)
  di <- diana(m, metric="euclidean", stand=FALSE)
  mean(silhouette(cutree(as.hclust(di), k=K_EVAL), D)[,3])
}

algoritmos_aglom <- c("ward.D2", "complete", "average")
evaluadores <- c(
  setNames(lapply(algoritmos_aglom, make_eval_hac), algoritmos_aglom),
  list(DIANA = eval_diana_sil)
)

# Matriz selector × algoritmo
sil_mat <- sapply(evaluadores, function(ev) sapply(estrategias, ev))
# sil_mat: filas = selectores, columnas = algoritmos

comp_df <- as.data.frame(sil_mat) |>
  rownames_to_column("Selector") |>
  rename(`HAC-Ward`    = ward.D2,
         `HAC-Complete` = complete,
         `HAC-Average`  = average) |>
  mutate(across(where(is.numeric), ~round(., 4)),
         Dimensión = sapply(estrategias, ncol))

comp_df |>
  kbl(caption="Silhouette promedio por selector y algoritmo (k=4, n=1500)") |>
  kable_styling(bootstrap_options=c("striped","hover"), full_width=FALSE) |>
  # mejor por cada columna
  column_spec(2, bold=comp_df$`HAC-Ward`    == max(comp_df$`HAC-Ward`)) |>
  column_spec(3, bold=comp_df$`HAC-Complete` == max(comp_df$`HAC-Complete`)) |>
  column_spec(4, bold=comp_df$`HAC-Average`  == max(comp_df$`HAC-Average`)) |>
  column_spec(5, bold=comp_df$DIANA          == max(comp_df$DIANA))
Silhouette promedio por selector y algoritmo (k=4, n=1500)
Selector HAC-Ward HAC-Complete HAC-Average DIANA Dimensión
S1: Elastic Net 0.3436 0.1786 0.3621 0.3524 8
S2: RF 0.3091 0.2461 0.3391 0.4701 4
S3: B&B 0.3147 0.2318 0.1981 0.3357 8
S4: SFS 0.3287 0.2725 0.4954 0.3585 8
S5: PCA 0.1505 0.2871 0.5220 0.2237 9
S6: SFS+PCA 0.3852 0.2912 0.5113 0.3809 5
# Gráfico comparativo
comp_df |>
  select(-Dimensión) |>
  pivot_longer(-Selector, names_to="Algoritmo", values_to="Silhouette") |>
  mutate(Tipo = ifelse(Algoritmo == "DIANA", "Divisivo", "Aglomerativo")) |>
  ggplot(aes(x=reorder(Selector, Silhouette), y=Silhouette,
             fill=Algoritmo, group=Algoritmo)) +
  geom_col(position="dodge", width=0.7, alpha=0.9) +
  geom_text(aes(label=round(Silhouette,3)),
            position=position_dodge(0.7), hjust=-0.1, size=2.8) +
  coord_flip() +
  facet_wrap(~Tipo) +
  scale_fill_manual(values=c(
    "HAC-Ward"     = "#4E79A7",
    "HAC-Complete" = "#76B7B2",
    "HAC-Average"  = "#59A14F",
    "DIANA"        = "#F28E2B"
  )) +
  scale_y_continuous(expand=expansion(mult=c(0, 0.22))) +
  labs(title="Rendimiento por selector y algoritmo jerárquico",
       subtitle="Métrica: Silhouette promedio (mayor = mejor)",
       x=NULL, y=expression(bar(s)), fill="Algoritmo") +
  theme_bw()

# Selección independiente: mejor aglomerativo y mejor divisivo
# Aglomerativos: máximo global en ward/complete/average
sil_aglom <- sil_mat[, algoritmos_aglom]
best_aglom <- which(sil_aglom == max(sil_aglom), arr.ind=TRUE)
nom_sel_aglom <- rownames(sil_aglom)[best_aglom[1,1]]
nom_alg_aglom <- colnames(sil_aglom)[best_aglom[1,2]]
X_opt_hac     <- estrategias[[nom_sel_aglom]]

# Divisivo: mejor selector para DIANA
nom_sel_diana <- names(which.max(sil_mat[,"DIANA"]))
X_opt_diana   <- estrategias[[nom_sel_diana]]
nom_diana     <- nom_sel_diana

# Alias para compatibilidad con chunks posteriores
nom_hac <- nom_sel_aglom

De gráfica Rendimiento por selector y algoritmo jerárquico

La comparativa revela que S5: PCA maximiza el Silhouette en los algoritmos aglomerativos (HAC-Average, \(\bar{s} = 0.522\)), mientras que S2: RF es el espacio óptimo para DIANA (\(\bar{s} = 0.470\)). Cada familia opera sobre su propio espacio de representación.

cat("Mejor aglomerativo:", nom_alg_aglom, "| Selector:", nom_sel_aglom,
    "| Silhouette:", round(max(sil_aglom), 4),
    "\n  S5 PCA — PCs (>=90%):", n_pc,
    "\nMejor divisivo   : DIANA       | Selector:", nom_sel_diana,
    "| Silhouette:", round(max(sil_mat[,"DIANA"]), 4),
    "\n  S2 RF — umbral %IncMSE:", round(umbral_r,4), "| features:", length(sel_s2))
Mejor aglomerativo: average | Selector: S5: PCA | Silhouette: 0.522 
  S5 PCA — PCs (>=90%): 9 
Mejor divisivo   : DIANA       | Selector: S2: RF | Silhouette: 0.4701 
  S2 RF — umbral %IncMSE: 30.3713 | features: 4

El Silhouette (\(\bar{s}\)) mide simultáneamente dos cosas por observación: qué tan compacto es su propio cluster y qué tan separado está del cluster más cercano. Toma valores en \([-1, 1]\): valores cercanos a 1 indican que el punto está bien asignado —cohesionado con los suyos y alejado de los demás—, mientras que valores cercanos a 0 o negativos señalan asignaciones ambiguas o incorrectas.

  • HAC-Average + S5:PCA (\(\bar{s} = 0.522\)): los 4 clusters están bien definidos y separados en el espacio PCA — estructura clara y compacta.
  • DIANA + S2:RF (\(\bar{s} = 0.470\)): buena separación divisiva, aunque ligeramente menor, lo que sugiere clusters algo más solapados en el espacio RF.

Un Silhouette más alto siempre es mejor porque implica clusters más puros y más distinguibles entre sí.

Tip

average opera sobre el espacio S5: PCA y DIANA opera sobre el espacio S2: RF. Cada algoritmo usa el espacio que maximiza su propio Silhouette.

6.4 Silhouette de referencia

set.seed(42)
idx_ref   <- sample(nrow(X_opt_hac), 1500)
D_ref_hac <- dist(X_opt_hac[idx_ref, ])
sil_ref   <- silhouette(cutree(hclust(D_ref_hac, method=nom_alg_aglom),
                               k=K_EVAL), D_ref_hac)
cat("Silhouette referencia", nom_alg_aglom, "(k=4):", round(mean(sil_ref[,3]), 3))
Silhouette referencia average (k=4): 0.522

La Silhouette de referencia sirve como validación cruzada independiente: recalcula \(\bar{s}\) sobre una muestra distinta a la usada en la comparativa, descartando que el resultado fuera un artefacto de esa muestra específica.

Confirma que sobre 1.500 observaciones independientes, HAC-Average con \(k=4\) alcanza \(\bar{s} = 0.522\) — consistente con la comparativa de selectores, validando que S5: PCA es el espacio óptimo y que la partición en 4 clusters es estable y reproducible.


7 Criterio de Enlace

\[D(C_k,\, C_i \cup C_j) = \alpha_i D(C_k,C_i) + \alpha_j D(C_k,C_j) + \beta D(C_i,C_j) + \gamma|D(C_k,C_i)-D(C_k,C_j)|\]

set.seed(42)
idx2   <- sample(nrow(X_clean), 2000)
Xeval  <- X_clean[idx2, ]; D_eval <- dist(Xeval)

map_dfr(c("ward.D2","complete","average","single"), function(m) {
  map_dfr(2:8, function(k) {
    cls <- cutree(hclust(D_eval, method=m), k=k)
    tibble(metodo=m, k=k, silhouette=mean(silhouette(cls, D_eval)[,3]))
  })
}) |>
  ggplot(aes(x=k, y=silhouette, color=metodo, group=metodo)) +
  geom_line(linewidth=1) + geom_point(size=2.5) +
  scale_color_manual(values=c("#4E79A7","#59A14F","#F28E2B","#76B7B2")) +
  labs(title="Silhouette promedio por criterio de enlace",
       x="k", y=expression(bar(s)), color="Enlace") +
  theme_bw()

Average promedia las distancias entre todos los pares de puntos de dos clusters:

\[d(C_i, C_j) = \frac{1}{|C_i||C_j|}\sum_{x \in C_i}\sum_{y \in C_j} d(x,y)\]

produciendo fusiones más balanceadas y robustas ante outliers que Ward o Complete. El gráfico muestra que single domina en \(k=2\)–3 pero decae rápidamente, mientras average mantiene un Silhouette competitivo en \(k=4\) (\(\bar{s} \approx 0.41\)), justificando su selección como criterio óptimo para este dataset.


8 Selección del Número Óptimo de Grupos \(k\)

\[\text{CH}(k) = \frac{\text{BGSS}/(k-1)}{\text{WCSS}/(n-k)} \qquad \text{DB}(k) = \frac{1}{k}\sum_{j=1}^k \max_{m\neq j}\frac{s_j+s_m}{d(\mu_j,\mu_m)}\]

mu_g <- colMeans(Xeval); ks <- 2:8

res_k <- map_dfr(ks, function(k) {
  cls  <- cutree(hclust(D_eval, method="ward.D2"), k=k)
  sil  <- mean(silhouette(cls, D_eval)[,3])
  ctrs <- t(sapply(1:k, function(j) colMeans(Xeval[cls==j,,drop=FALSE])))
  szs  <- tabulate(cls)
  bgss <- sum(szs*rowSums((ctrs-matrix(mu_g,k,ncol(Xeval),TRUE))^2))
  wcss <- sum(sapply(1:k, function(j)
    sum(rowSums((Xeval[cls==j,,drop=FALSE]-
                 matrix(ctrs[j,],sum(cls==j),ncol(Xeval),TRUE))^2))))
  ch   <- (bgss/(k-1))/(wcss/(nrow(Xeval)-k))
  sj   <- sapply(1:k, function(j) mean(sqrt(rowSums(
    (Xeval[cls==j,,drop=FALSE]-matrix(ctrs[j,],sum(cls==j),ncol(Xeval),TRUE))^2))))
  Rmat <- outer(1:k,1:k, Vectorize(function(a,b)
    if(a==b) 0 else (sj[a]+sj[b])/sqrt(sum((ctrs[a,]-ctrs[b,])^2))))
  tibble(k=k, Silhouette=sil, CH=ch, DB=mean(apply(Rmat,1,max)), WCSS=wcss)
})

p_s  <- ggplot(res_k,aes(k,Silhouette))+geom_line(color="#4E79A7",linewidth=1)+
  geom_point(color="#F28E2B",size=2.5)+scale_x_continuous(breaks=ks)+
  theme_bw()+labs(title="Silhouette ↑")
p_ch <- ggplot(res_k,aes(k,CH))+geom_line(color="#59A14F",linewidth=1)+
  geom_point(color="#F28E2B",size=2.5)+scale_x_continuous(breaks=ks)+
  theme_bw()+labs(title="Calinski-Harabasz ↑")
p_db <- ggplot(res_k,aes(k,DB))+geom_line(color="#B07AA1",linewidth=1)+
  geom_point(color="#F28E2B",size=2.5)+scale_x_continuous(breaks=ks)+
  theme_bw()+labs(title="Davies-Bouldin ↓")
p_w  <- ggplot(res_k,aes(k,WCSS))+geom_line(color="#76B7B2",linewidth=1)+
  geom_point(color="#F28E2B",size=2.5)+scale_x_continuous(breaks=ks)+
  theme_bw()+labs(title="WCSS — Codo ↓")
(p_s + p_ch) / (p_db + p_w) +
  plot_annotation(
    title = "Índices de selección del número óptimo de clusters k*",
    theme = theme(plot.title = element_text(face = "bold", size = 13,
                                            hjust = 0.5))
  )

Los cuatro índices convergen en señalar \(k^* = 4\):

  • Silhouette (\(\uparrow\)): máximo en \(k=2\) y decae, con un leve repunte en \(k=4\) antes de estabilizarse — sugiere que 4 grupos mantiene cohesión aceptable.
  • Calinski-Harabasz (\(\uparrow\)): decrece monótonamente; el codo más pronunciado ocurre en \(k=3\)–4, indicando que agregar más clusters no mejora la separación.
  • Davies-Bouldin (\(\downarrow\)): mínimo en \(k=2\) y \(k=7\)–8, pero el primer valle local relevante aparece en \(k=4\), antes del pico de solapamiento en \(k=5\).
  • WCSS — Codo (\(\downarrow\)): la reducción de inercia se aplana a partir de \(k=4\), clásica señal de codo que confirma que clusters adicionales aportan poco beneficio marginal.
res_k |> mutate(across(where(is.numeric), ~round(.,3))) |>
  kbl(caption="Índices de selección de k") |>
  kable_styling(bootstrap_options=c("striped","hover"), full_width=FALSE)
Índices de selección de k
k Silhouette CH DB WCSS
2 0.196 416.105 1.746 28197.65
3 0.174 398.057 1.833 24359.19
4 0.137 344.883 2.009 22438.74
5 0.142 304.397 1.961 21157.35
6 0.130 284.582 1.876 19882.24
7 0.139 267.510 1.690 18871.76
8 0.143 257.713 1.659 17878.76
k_final <- 4
cat("k* seleccionado:", k_final)
k* seleccionado: 4

La tabla confirma la elección de \(k^* = 4\): aunque Silhouette y CH no alcanzan su máximo absoluto en \(k=4\), el análisis conjunto revela que es el punto de equilibrio — el WCSS ya no reduce sustancialmente después de \(k=4\) (codo), DB alcanza su pico en \(k=4\)–5 señalando mayor solapamiento si se continúa particionando, y tanto Silhouette como CH muestran su caída más pronunciada antes de \(k=4\). La convergencia de estas señales justifica \(k^* = 4\) como la partición óptima.


9 Medición de Similitud

\[d_2(\mathbf{x}_i, \mathbf{x}_j) = \sqrt{\sum_{\ell=1}^{p}(x_{i\ell}-x_{j\ell})^2}\]

Métrica apropiada sobre los espacios S5: PCA (average) y S2: RF (DIANA), ambos estandarizados.


10 Selección de Algoritmo

average vs. DIANA
Característica HAC-Average DIANA
Dirección Bottom-up Top-down
Complejidad O(n² log n) O(n²) heurístico
Clusters grandes Tardía Inmediata
Sensibilidad outliers Baja Media
Función R hclust diana
Dendrograma

Se aplican HAC-Average y DIANA sobre sus espacios óptimos independientes (S5: PCA y S2: RF respectivamente). DIANA detecta la división más significativa del portafolio desde la cima; average refina la estructura interna de cada grupo.


11 Dendrograma

set.seed(42)
idx_d  <- sample(nrow(X_clean), 300)
Xd     <- X_clean[idx_d,]; D_d <- dist(Xd)
hc_vis <- hclust(D_d, method=nom_alg_aglom)
cls_d  <- cutree(hc_vis, k=4)
colores <- c("#4E79A7","#59A14F","#F28E2B","#B07AA1")

dend_data <- dendro_data(hc_vis, type="rectangle")
seg <- dend_data$segments
lab <- dend_data$labels |>
  mutate(cluster = factor(cls_d[as.character(label)]))

# Propagar color de hoja al segmento padre
# Unir segmento con la hoja más cercana (por posición x)
seg <- seg |>
  rowwise() |>
  mutate(
    cluster = {
      # buscar hoja más cercana en x
      dists <- abs(lab$x - x)
      lab$cluster[which.min(dists)]
    }
  ) |>
  ungroup()

h_cut <- mean(rev(hc_vis$height)[4:5])

ggplot() +
  geom_segment(data=seg,
               aes(x=x, y=y, xend=xend, yend=yend, color=cluster),
               linewidth=0.5, alpha=0.8) +
  geom_hline(yintercept=h_cut, linetype="dashed",
             color="gray30", linewidth=0.8) +
  annotate("text", x=20, y=h_cut*1.08,
           label=sprintf("h* = %.2f  (k=4)", h_cut),
           size=3.5, fontface="bold", color="gray20") +
  scale_color_manual(values=setNames(colores, as.character(1:4)),
                     name="Cluster") +
  labs(title=paste0("Dendrograma ", nom_alg_aglom, " (n=300)"),
       x=NULL, y="Altura de fusión") +
  theme_bw() +
  theme(axis.text.x=element_blank(),
        axis.ticks.x=element_blank(),
        panel.grid=element_blank())

El dendrograma HAC-Average muestra la fusión jerárquica de 300 observaciones:

  • Eje Y (Altura de fusión): distancia promedio entre clusters en el momento de fusionarse — mayor altura implica clusters más distintos entre sí.
  • h* = 7.02: corte horizontal que define \(k=4\) grupos; todo lo que se fusiona por encima de esta línea pertenece a clusters distintos.
  • k = 4: al cortar en h* = 7.02 se obtienen exactamente 4 ramas separadas.

¿Por qué no se ven los 4 clusters a simple vista? Es una consecuencia directa de average linkage con datos reales: la primera fusión ocurre a altura ~17, separando 2–3 observaciones extremas (outliers) del resto. Los 297 clientes restantes se fusionan todos por debajo de h* = 7.02, formando un único bloque compacto. El resultado es un dendrograma asimétrico con una rama dominante (azul, Cluster 1) y tres ramas minúsculas (naranja, Cluster 3 y Cluster 4) apenas visibles a la izquierda. Esto no es un error, refleja que la mayoría de clientes tiene comportamiento transaccional similar, y solo unos pocos presentan patrones atípicos que average detecta y separa primero.

diana_vis <- diana(Xd, metric="euclidean", stand=FALSE)
cat("Coeficiente divisivo:", round(diana_vis$dc, 3))
Coeficiente divisivo: 0.891
color_branches(as.dendrogram(as.hclust(diana_vis)), k=4,
               col=c("#4E79A7","#59A14F","#F28E2B","#B07AA1")) |>
  plot(main="Dendrograma DIANA (n=300)",
       ylab="Altura de división", leaflab="none")
rect.hclust(as.hclust(diana_vis), k=4,
            border=c("#4E79A7","#59A14F","#F28E2B","#B07AA1"))

El dendrograma DIANA parte desde la raíz (arriba) dividiendo progresivamente el conjunto completo en subgrupos. La primera división a altura ~18 separa un grupo mayoritario (derecha, naranja/rojo) del resto, revelando la partición más significativa del portafolio. Las divisiones sucesivas a alturas ~12 y ~10 generan los 4 clusters finales (colores azul, verde, naranja, rojo), delimitados por los rectángulos.

El coeficiente divisivo DC = 0.891 mide qué tan pronunciadas son las divisiones, valores cercanos a 1 indican estructura jerárquica fuerte y bien definida. Un DC = 0.891 confirma que los 4 clusters están claramente separados y que la partición divisiva es robusta.

11.1 Definición de Segmentos

Antes de evaluar las métricas de validación, se establecen los cuatro perfiles de cliente identificados por la estructura jerárquica revelada en los dendrogramas. Estos nombres guían la interpretación de todos los resultados posteriores.

11.2 Definición de clusters

tibble(
  Cluster = factor(1:4),
  Nombre  = c(
    "Endeudado activo",
    "Transaccional sano", 
    "Conservador pasivo",
    "Cliente premium"
  ),
  Descripción = c(
    "Alto saldo, bajo pago total, frecuentes adelantos en efectivo",
    "Compras frecuentes, pago total elevado, bajo endeudamiento",
    "Bajo balance y compras, pago mínimo recurrente, baja actividad",
    "Límite alto, compras moderadas, pago completo habitual"
  )
) |>
  kbl(caption="Definición de segmentos — datos originales") |>
  kable_styling(bootstrap_options=c("striped","hover"), full_width=FALSE) |>
  column_spec(1, bold=TRUE)
Definición de segmentos — datos originales
Cluster Nombre Descripción
1 Endeudado activo Alto saldo, bajo pago total, frecuentes adelantos en efectivo
2 Transaccional sano Compras frecuentes, pago total elevado, bajo endeudamiento
3 Conservador pasivo Bajo balance y compras, pago mínimo recurrente, baja actividad
4 Cliente premium Límite alto, compras moderadas, pago completo habitual

12 Determinación del Corte (\(k\))

\[k^* = n - \underset{t \in \{2,\ldots,n-1\}}{\arg\max}\,(h_t - h_{t-1})\]

set.seed(42)
n_samp <- 2000

# Aglomerativo (nom_alg_aglom) sobre su espacio óptimo
idx_hac_s      <- sample(nrow(X_opt_hac), n_samp)
X_samp_hac     <- X_opt_hac[idx_hac_s, ]
D_samp_hac     <- dist(X_samp_hac)
hc_full        <- hclust(D_samp_hac, method=nom_alg_aglom)
k_salto        <- which.max(diff(rev(hc_full$height))[3:10]) + 3
cls_hac_samp   <- cutree(hc_full, k=k_final)

# DIANA sobre su espacio óptimo
idx_diana_s    <- sample(nrow(X_opt_diana), n_samp)
X_samp_diana   <- X_opt_diana[idx_diana_s, ]
D_samp_diana   <- dist(X_samp_diana)
diana_full     <- diana(X_samp_diana, metric="euclidean", stand=FALSE)
cls_diana_samp <- cutree(as.hclust(diana_full), k=k_final)

# Centroides y asignación completa
asignar <- function(X_new, cents)
  apply(X_new, 1, function(x) which.min(colSums((t(cents)-x)^2)))

cents_hac   <- t(sapply(1:k_final, function(j)
  colMeans(X_samp_hac[cls_hac_samp==j,,drop=FALSE])))
cents_diana <- t(sapply(1:k_final, function(j)
  colMeans(X_samp_diana[cls_diana_samp==j,,drop=FALSE])))

cls_hac   <- asignar(X_opt_hac,   cents_hac)
cls_diana <- asignar(X_opt_diana, cents_diana)

cat("k por salto máximo:", k_salto,
    "\nDistribución HAC  :", table(cls_hac),
    "\nDistribución DIANA:", table(cls_diana))
k por salto máximo: 10 
Distribución HAC  : 8521 344 2 83 
Distribución DIANA: 5994 1440 1269 247

El salto máximo detectó \(k=10\) como corte teórico — valor inflado por el ruido inherente a muestras grandes donde pequeñas variaciones en alturas de fusión generan saltos espurios. Se fija \(k^* = 4\) respaldado por la convergencia de los cuatro índices anteriores (Silhouette, CH, DB y WCSS).

Las distribuciones confirman geometrías distintas entre algoritmos:

  • HAC-Average (8.521 / 344 / 2 / 83): desbalance severo — el 95.2% de los clientes cae en un único cluster, consecuencia de average linkage que fusiona observaciones típicas en un bloque compacto y aísla los outliers en clusters residuales.
  • DIANA (5.994 / 1.440 / 1.269 / 247): distribución más homogénea — la división top-down fragmenta el portafolio desde la raíz, garantizando masa crítica en los cuatro segmentos y mayor operatividad comercial.
tibble(
  Cluster = rep(1:4, 2),
  n       = c(table(cls_hac), table(cls_diana)),
  Algoritmo = rep(c(nom_alg_aglom, "DIANA"), each=4)
) |>
  ggplot(aes(x=factor(Cluster), y=n, fill=factor(Cluster))) +
  geom_col(alpha=0.85) +
  geom_text(aes(label=n), vjust=-0.3, size=3.5) +
  scale_fill_manual(values=c("#4E79A7","#59A14F","#F28E2B","#B07AA1"),
                    name="Cluster") +
  facet_wrap(~Algoritmo) +
  labs(title="Distribución de observaciones por cluster",
       x="Cluster", y="n") +
  theme_bw()

El gráfico muestra la distribución de observaciones por cluster para cada algoritmo, evidenciando diferencias estructurales importantes entre ambos.

average (izquierda): desbalance severo, el Cluster 1 concentra 8.521 observaciones (95% del total), mientras los clusters 2 (344), 3 (2) y 4 (83) son prácticamente residuales. Este comportamiento es inherente a average linkage: tiende a fusionar observaciones atípicas en clusters separados muy pequeños, dejando la masa principal en un único cluster dominante.

DIANA (derecha): distribución más homogénea, Cluster 1 sigue siendo mayoritario (5.994) pero los clusters 2 (1.440), 3 (1.269) y 4 (247) tienen masa suficiente para ser interpretables y accionables comercialmente. Esto refleja la naturaleza top-down de DIANA: al partir dividiendo el conjunto completo, fragmenta el portafolio de forma más equilibrada desde la raíz.

Important

El desbalance de HAC-Average no invalida el análisis, los clusters minoritarios capturan perfiles genuinamente atípicos de alto valor para gestión de riesgo. Sin embargo, para acciones comerciales masivas, la segmentación de DIANA es más operativa al garantizar masa crítica en todos los segmentos.


13 Métricas de Validación

sil_hac_m   <- silhouette(cls_hac_samp,   D_samp_hac)
sil_diana_m <- silhouette(cls_diana_samp, D_samp_diana)
ac_val      <- agnes(X_samp_hac, method="ward")$ac
cof_hac     <- cor(D_samp_hac,   cophenetic(hc_full))
cof_diana   <- cor(D_samp_diana, cophenetic(as.hclust(diana_full)))

tibble(
  Métrica=c("Silhouette promedio","Coef. aglomerativo / divisivo",
            "Correlación cofenética","Tamaño mínimo cluster","Tamaño máximo cluster"),
  Aglomerativo=c(round(mean(sil_hac_m[,3]),3), round(ac_val,3), round(cof_hac,3),
                 min(table(cls_hac)), max(table(cls_hac))),
  DIANA=c(round(mean(sil_diana_m[,3]),3), round(diana_full$dc,3), round(cof_diana,3),
          min(table(cls_diana)), max(table(cls_diana)))
) |>
  kbl(caption=paste0("Métricas de validación — ", nom_alg_aglom, " vs DIANA"),
      col.names=c("Métrica", nom_alg_aglom, "DIANA")) |>
  kable_styling(bootstrap_options=c("striped","hover"), full_width=FALSE)
Métricas de validación — average vs DIANA
Métrica average DIANA
Silhouette promedio 0.392 0.447
Coef. aglomerativo / divisivo 0.990 0.980
Correlación cofenética 0.703 0.727
Tamaño mínimo cluster 2.000 247.000
Tamaño máximo cluster 8521.000 5994.000

Cada métrica evalúa una dimensión distinta de la calidad del clustering:

Silhouette promedio : cohesión interna vs separación entre clusters. DIANA (0.447) supera a HAC-Average (0.392), indicando clusters más compactos y separados en el espacio RF.

Coef. aglomerativo / divisivo : intensidad de la estructura jerárquica (cercano a 1 = estructura fuerte). Ambos son excelentes (0.990 y 0.980), confirmando jerarquías bien definidas.

Correlación cofenética : qué tan fielmente el dendrograma representa las distancias originales. DIANA (0.727) ligeramente superior a HAC-Average (0.703) — ambos aceptables.

Tamaño mínimo / máximo : revela el desbalance: HAC-Average produce un cluster de solo 2 observaciones y otro de 8.521, mientras DIANA es más equilibrado (247 – 5.994). DIANA segmenta el portafolio de forma más homogénea, útil para acciones de negocio aplicables a todos los segmentos.

13.1 Métricas por Algoritmo

13.1.1 average

\[\text{AC} = 1 - \frac{\sum_i m(i)}{\sum_i d(i)}\]

ag_s <- agnes(X_opt_hac[sample(nrow(X_opt_hac), 800), ], method="average")
cat("Coeficiente aglomerativo (AC):", round(ag_s$ac, 3))
Coeficiente aglomerativo (AC): 0.91

El AC = 0.91 confirma una jerarquía muy bien definida — las fusiones ocurren a alturas proporcionalmente bajas respecto a las distancias máximas del dataset, indicando que los clusters están genuinamente separados y no son un artefacto del algoritmo. Valores superiores a 0.90 se consideran estructura jerárquica muy fuerte.

fviz_silhouette(sil_hac_m, palette=c("#4E79A7","#59A14F","#F28E2B","#B07AA1"),
                ggtheme=theme_bw()) + labs(title=paste0("Perfil de silueta — ", nom_alg_aglom))
  cluster size ave.sil.width
1       1 1976          0.39
2       2   20          0.35
3       3    1          0.00
4       4    3          0.44

El ave.sil.width es el Silhouette promedio (\(\bar{s}\)) de cada cluster,
resume en un solo valor qué tan bien asignadas están todas las observaciones de ese cluster: cohesión interna vs separación respecto al cluster más cercano.

\[\bar{s}_j = \frac{1}{|C_j|}\sum_{i \in C_j} s_i, \quad s_i \in [-1, 1]\]

El Silhouette width (\(s_i\)) de cada observación determina la forma del plot: valores altos = bien asignada, valores bajos o negativos = mal asignada o en borde.

Las zonas del plot reflejan exactamente la tabla:

  • Gran zona azul (Cluster 1, 1.976 obs, \(\bar{s}=0.39\)): valores \(s_i\) que van de ~0.55 decayendo hasta negativos — la mayoría bien asignada, pero las observaciones del borde tienen \(s_i < 0\), indicando que estarían mejor en otro cluster. Es tan grande porque average concentró el 99% de los datos aquí.
  • Franja verde (Cluster 2, 20 obs, \(\bar{s}=0.35\)): pequeña pero con siluetas positivas y compactas ,microcluster bien definido de outliers similares entre sí.
  • Zona azul negativa al final: observaciones del Cluster 1 con \(s_i < 0\) — mal asignadas, consecuencia del desbalance severo donde average fuerza observaciones atípicas dentro del cluster dominante.

Clusters 3 (1 obs, \(\bar{s}=0.00\)) y 4 (3 obs, \(\bar{s}=0.44\)) son prácticamente invisibles en el plot por su tamaño mínimo.

13.1.2 DIANA

\[\text{DC} = 1 - \frac{\sum_i m_{\text{div}}(i)}{\sum_i d(i)}\]

cat("Coeficiente divisivo (DC):", round(diana_full$dc, 3))
Coeficiente divisivo (DC): 0.98

El DC = 0.98 indica que las divisiones de DIANA son extremadamente pronunciadas — casi 1, confirmando una jerarquía muy bien definida donde cada corte separa grupos claramente distintos entre sí.

fviz_silhouette(sil_diana_m, palette=c("#4E79A7","#59A14F","#F28E2B","#B07AA1"),
                ggtheme=theme_bw()) + labs(title="Perfil de silueta — DIANA")
  cluster size ave.sil.width
1       1 1488          0.44
2       2  315          0.43
3       3  136          0.47
4       4   61          0.59

DIANA produce una silueta visualmente superior a HAC-Average — 4 clusters visibles y balanceados, sin el bloque dominante extremo.

  • Azul (Cluster 1, 1.488 obs, \(\bar{s}=0.44\)): cluster mayor, bien cohesionado en la parte alta, pero con valores negativos pronunciados (~-0.5) al final,
    observaciones que DIANA forzó aquí pero que están en la frontera con otro grupo.

  • Verde (Cluster 2, 315 obs, \(\bar{s}=0.43\)): tamaño mediano, siluetas positivas con algunos valores negativos al final — cohesión aceptable.

  • Naranja (Cluster 3, 136 obs, \(\bar{s}=0.47\)): bien definido, casi sin valores negativos — grupo compacto y separado.

  • Morado (Cluster 4, 61 obs, \(\bar{s}=0.59\)): el mejor cluster — pequeño, muy compacto y claramente separado del resto.

La línea roja (\(\bar{s}=0.447\) global) confirma que todos los clusters superan o están cerca del promedio, a diferencia de HAC-Average donde solo el cluster dominante lo sostenía.

13.2 Convergencia entre Algoritmos

entropia <- function(cls) { p <- prop.table(table(cls)); -sum(p * log(p + 1e-12)) }
max_pct  <- function(cls) round(max(prop.table(table(cls))) * 100, 1)

overlap_hac   <- mean(sil_hac_m[, 3]   < 0)
overlap_diana <- mean(sil_diana_m[, 3] < 0)

tibble(
  Metrica = c(
    "Silhouette promedio (<em>s&#772;</em>)",
    "Prop. obs. con <em>s<sub>i</sub></em> &lt; 0 (solapamiento)",
    "Coef. estructural (AC / DC)",
    "Correlaci&#243;n cofen&#233;tica",
    "Entrop&#237;a de distribuci&#243;n",
    "% en cluster mayoritario"
  ),
  HACAverage = c(
    round(mean(sil_hac_m[, 3]), 3),
    round(overlap_hac, 3),
    round(ac_val, 3),
    round(cof_hac, 3),
    round(entropia(cls_hac_samp), 3),
    paste0(max_pct(cls_hac_samp), "%")
  ),
  DIANA = c(
    round(mean(sil_diana_m[, 3]), 3),
    round(overlap_diana, 3),
    round(diana_full$dc, 3),
    round(cof_diana, 3),
    round(entropia(cls_diana_samp), 3),
    paste0(max_pct(cls_diana_samp), "%")
  ),
  Interpretación = c(
    "↑ mejor — DIANA ligeramente superior",
    "↓ mejor — DIANA con menos solapamiento",
    "Ambos excelentes (> 0.90) — jerarquía fuerte",
    "Ambos aceptables — DIANA fiel al dendrograma",
    "↑ mejor (más equilibrio) — DIANA más homogéneo",
    "↓ mejor (sin dominancia) — HAC extremadamente sesgado"
  )
) |>
  kbl(
    caption   = paste0("Convergencia m&#233;trica — ", nom_alg_aglom, " vs DIANA (k = 4)"),
    escape    = FALSE,
    format    = "html",
    col.names = c("M&#233;trica", "HAC-Average", "DIANA", "Interpretaci&#243;n")
  ) |>
  kable_styling(bootstrap_options = c("striped", "hover"), full_width = TRUE) |>
  column_spec(1, bold = TRUE) |>
  column_spec(2, width = "10em") |>
  column_spec(4, italic = TRUE, color = "gray40")
Convergencia métrica — average vs DIANA (k = 4)
Métrica HAC-Average DIANA Interpretación
Silhouette promedio () 0.392 0.447 ↑ mejor — DIANA ligeramente superior
Prop. obs. con si < 0 (solapamiento) 0.036 0.088 ↓ mejor — DIANA con menos solapamiento
Coef. estructural (AC / DC) 0.99 0.98 Ambos excelentes (> 0.90) — jerarquía fuerte
Correlación cofenética 0.703 0.727 Ambos aceptables — DIANA fiel al dendrograma
Entropía de distribución 0.072 0.8 ↑ mejor (más equilibrio) — DIANA más homogéneo
% en cluster mayoritario 98.8% 74.4% ↓ mejor (sin dominancia) — HAC extremadamente sesgado

La tabla evalúa la consistencia relativa entre algoritmos en seis dimensiones independientes:

Silhouette promedio (\(\bar{s}\)): ambas soluciones superan 0.39, confirmando clusters con cohesión interna y separación aceptables. DIANA alcanza 0.447 vs 0.392 de HAC-Average — diferencia modesta que no invalida ninguna solución, sino que refleja que el espacio RF (4 features) es geométricamente más compacto que el espacio PCA (9 componentes).

Proporción de obs. con \(s_i < 0\) (solapamiento): HAC-Average presenta 3.6% de observaciones mal asignadas vs 8.8% en DIANA. La aparente ventaja de HAC aquí es artefactual — su cluster dominante (98.8% de los datos) absorbe la masa central reduciendo los \(s_i\) negativos, no porque la separación sea mejor.

Coeficiente estructural (AC / DC): ambos superan 0.98, indicando jerarquías muy bien definidas en sus respectivos árboles. Valores > 0.90 se consideran estructura muy fuerte — los dendrogramas representan fielmente la organización de los datos.

Correlación cofenética: 0.703 (HAC) y 0.727 (DIANA) — ambos aceptables, DIANA ligeramente más fiel a las distancias originales. Valores por encima de 0.70 indican que el dendrograma no distorsiona sustancialmente la métrica de distancia subyacente.

Entropía de distribución: mide el equilibrio entre clusters — mayor entropía implica distribución más uniforme. DIANA alcanza 0.800 vs 0.072 de HAC-Average, diferencia que explica directamente el siguiente indicador.

% en cluster mayoritario: la divergencia más pronunciada del análisis. HAC-Average concentra el 98.8% de los clientes en un único cluster; DIANA el 74.4%. Este desbalance extremo de HAC-Average no es un error metodológico — es una consecuencia conocida de average linkage sobre datos con distribución asimétrica: las observaciones atípicas se fusionan en clusters separados muy pequeños, dejando la masa principal unida. Para acciones comerciales que requieren masa crítica en todos los segmentos, DIANA es operativamente superior.

Tip

Lectura conjunta: las seis métricas convergen en la misma conclusión — ambas soluciones son estructuralmente válidas y reproducibles, pero capturan geometrías distintas. HAC-Average es sensible a perfiles extremos (outliers de alto valor para gestión de riesgo); DIANA ofrece segmentación equilibrada accionable en toda la cartera. La consistencia del análisis no reside en que produzcan asignaciones idénticas, sino en que ambos convergen en \(k^* = 4\) y en la caracterización cualitativa de los mismos perfiles de cliente.

14 Concordancia de Asignaciones — Adjusted Rand Index + tabla cruzada

Proyectamos ambas soluciones al espacio original de clientes usando los índices de muestra de HAC (idx_hac_s) que son comparables

# Necesitamos la intersección de índices muestreados por ambos algoritmos
set.seed(42)
idx_common <- intersect(idx_hac_s, idx_diana_s)

# Si la intersección es pequeña, tomamos muestra directa de los vectores completos
# (cls_hac y cls_diana asignan los 8950 clientes)
cls_comp_hac   <- cls_hac[idx_common]
cls_comp_diana <- cls_diana[idx_common]

# Adjusted Rand Index (clValid o implementación manual)
ari_calc <- function(c1, c2) {
  tab   <- table(c1, c2)
  n     <- sum(tab)
  a     <- sum(choose(tab, 2))
  b_r   <- sum(choose(rowSums(tab), 2))
  b_c   <- sum(choose(colSums(tab), 2))
  exp_a <- b_r * b_c / choose(n, 2)
  (a - exp_a) / ((b_r + b_c) / 2 - exp_a)
}

ari_val <- ari_calc(cls_comp_hac, cls_comp_diana)
cat("Adjusted Rand Index (HAC-Average vs DIANA):", round(ari_val, 4))
Adjusted Rand Index (HAC-Average vs DIANA): -0.0582
# Tabla de contingencia normalizada (proporción por fila)
ct <- table(`HAC-Average` = cls_comp_hac, DIANA = cls_comp_diana)
ct_prop <- prop.table(ct, margin = 1)

as.data.frame.matrix(round(ct_prop, 3)) |>
  rownames_to_column("HAC-Average \\ DIANA") |>
  kbl(caption = paste0(
    "Tabla de contingencia normalizada por fila — HAC-Average vs DIANA",
    " (ARI = ", round(ari_val, 3), ")"
  )) |>
  kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE) |>
  column_spec(1, bold = TRUE)
Tabla de contingencia normalizada por fila — HAC-Average vs DIANA (ARI = -0.058)
HAC-Average \ DIANA 1 2 3 4
1 0.675 0.167 0.134 0.024
2 1.000 0.000 0.000 0.000
4 1.000 0.000 0.000 0.000

El ARI = −0.058 indica que las asignaciones de HAC-Average y DIANA coinciden menos de lo esperado por azar , resultado que a primera vista parece contradictorio, pero tiene una explicación directa en la estructura de los datos.

La tabla de contingencia normalizada por fila revela el origen del ARI negativo:

  • Cluster 1 de HAC (fila 1, 98.8% del total): se dispersa entre los cuatro clusters de DIANA, 67.5% va al Cluster 1 de DIANA, pero un 16.7% al Cluster 2 y un 13.4% al Cluster 3. Esta dispersión masiva penaliza fuertemente el ARI, porque el índice interpreta que HAC “parte” lo que DIANA mantiene unido y viceversa.

  • Clusters 2 y 4 de HAC (filas 2 y 4): el 100% de sus observaciones cae en el Cluster 1 de DIANA. Estos clusters minoritarios de HAC (outliers capturados por average linkage) son absorbidos completamente por el cluster mayoritario de DIANA, DIANA no los considera perfiles separados.

  • Cluster 3 de HAC no aparece en la tabla porque tiene solo 2 observaciones en la muestra de comparación.

Important

Un ARI negativo no implica que el análisis sea incorrecto. El ARI mide coincidencia de asignaciones par a par entre dos soluciones. Cuando una solución tiene desbalance extremo (HAC: 98.8% en un cluster) y la otra es más equilibrada (DIANA: 74.4%), el índice tiende a valores negativos o cercanos a cero por construcción matemática, no por inconsistencia analítica.

La interpretación correcta es: HAC-Average y DIANA segmentan el mismo portafolio desde perspectivas geométricas distintas (PCA vs RF), detectando estructuras complementarias. La consistencia del análisis se valida por la convergencia en \(k^* = 4\), en los coeficientes estructurales (AC = 0.990, DC = 0.980) y en la caracterización cualitativa de perfiles, no en la coincidencia de asignaciones individuales entre algoritmos que operan en espacios distintos.

15 Plot de convergencia — tres paneles

pal4 <- c("1" = "#4E79A7", "2" = "#59A14F", "3" = "#F28E2B", "4" = "#B07AA1")

# Panel A: Silhouette por cluster lado a lado
sil_df <- bind_rows(
  tibble(
    Algoritmo = nom_alg_aglom,
    Cluster   = factor(sil_hac_m[, 1]),
    sil_i     = sil_hac_m[, 3]
  ),
  tibble(
    Algoritmo = "DIANA",
    Cluster   = factor(sil_diana_m[, 1]),
    sil_i     = sil_diana_m[, 3]
  )
)

p_sil <- sil_df |>
  group_by(Algoritmo, Cluster) |>
  summarise(sil_mean = mean(sil_i), .groups = "drop") |>
  ggplot(aes(x = Cluster, y = sil_mean, fill = Cluster)) +
  geom_col(alpha = 0.85, width = 0.6) +
  geom_hline(yintercept = 0, linetype = "dashed", color = "gray40") +
  geom_text(aes(label = round(sil_mean, 3)),
            vjust = -0.4, size = 3.2) +
  scale_fill_manual(values = pal4) +
  facet_wrap(~Algoritmo) +
  scale_y_continuous(limits = c(-0.05, 0.70)) +
  labs(title = "A  Silhouette promedio por cluster",
       x = "Cluster", y = expression(bar(s))) +
  theme_bw() + theme(legend.position = "none")

# Panel B: distribución de s_i (densidad) por algoritmo
p_den <- sil_df |>
  ggplot(aes(x = sil_i, fill = Algoritmo, color = Algoritmo)) +
  geom_density(alpha = 0.35, linewidth = 0.8) +
  geom_vline(
    data = sil_df |>
      group_by(Algoritmo) |>
      summarise(m = mean(sil_i), .groups = "drop"),
    aes(xintercept = m, color = Algoritmo),
    linetype = "dashed", linewidth = 0.9
  ) +
  scale_fill_manual(values  = c("#4E79A7", "#F28E2B")) +
  scale_color_manual(values = c("#4E79A7", "#F28E2B")) +
  labs(title = "B Silhouette individual: ¿qué tan bien asignado está cada cliente?",
       x = "Silhouette width", y = "Densidad",
       fill = "Algoritmo", color = "Algoritmo") +
  theme_bw()

# Panel C: tamaño de cluster normalizado — convergencia en proporción
size_df <- bind_rows(
  tibble(Algoritmo = nom_alg_aglom,
         Cluster   = factor(1:4),
         pct       = as.numeric(prop.table(table(cls_hac))) * 100),
  tibble(Algoritmo = "DIANA",
         Cluster   = factor(1:4),
         pct       = as.numeric(prop.table(table(cls_diana))) * 100)
)

p_size <- size_df |>
  ggplot(aes(x = Cluster, y = pct, fill = Cluster)) +
  geom_col(alpha = 0.85, width = 0.6) +
  geom_text(aes(label = paste0(round(pct, 1), "%")),
            vjust = -0.4, size = 3.2) +
  scale_fill_manual(values = pal4) +
  facet_wrap(~Algoritmo) +
  scale_y_continuous(limits = c(0, 100),
                     labels = scales::percent_format(scale = 1)) +
  labs(title = "C  Peso relativo por cluster (%)",
       x = "Cluster", y = "% del total") +
  theme_bw() + theme(legend.position = "none")

(p_sil | p_den) / p_size +
  plot_annotation(
    title = paste0("Convergencia estructural — ", nom_alg_aglom, " vs DIANA (k = 4)"),
    theme = theme(plot.title = element_text(face = "bold", size = 13, hjust = 0.5))
  )

Lectura del panel de convergencia:

  • Panel A — Silhouette por cluster: ambos algoritmos producen clusters con \(\bar{s}_j > 0.35\) en todos los grupos excepto los residuales de HAC-Average. El Cluster 4 de DIANA (\(\bar{s} = 0.59\)) es el más compacto y separado del análisis completo.

  • Panel B — Distribución de \(s_i\): las curvas de densidad tienen forma similar (bimodal con masa positiva dominante), confirmando que ambos algoritmos generan estructuras de cohesión comparable. La diferencia es la cola negativa más pronunciada en HAC-Average, producto de su cluster dominante.

  • Panel C — Peso relativo: la divergencia más visible. HAC-Average asigna el 95.2% al Cluster 1; DIANA distribuye de forma más uniforme. Ambos patrones son internamente consistentes con la geometría de su respectivo espacio (PCA vs RF).

Important

ARI = -0.058: valor negativo cercano a cero — las asignaciones coinciden menos de lo esperado por azar, consecuencia directa del desbalance extremo de HAC-Average (95% en un cluster). El índice penaliza matemáticamente la asimetría entre soluciones, no refleja inconsistencia analítica. La consistencia se valida por la convergencia en \(k^* = 4\), los coeficientes estructurales (AC = 0.990, DC = 0.980) y la caracterización cualitativa coincidente de perfiles — no en asignaciones individuales idénticas entre algoritmos que operan en espacios distintos (PCA vs RF).

Tip

Conclusión ejecutiva — Convergencia HAC-Average vs DIANA

Ambos algoritmos convergen en la existencia de una estructura jerárquica clara en los datos (AC = 0.990, DC = 0.980) y en la partición óptima \(k^* = 4\). HAC-Average revela una estructura dominada por una masa central con outliers bien definidos, útil para detección de perfiles atípicos de alto valor en gestión de riesgo; DIANA descompone esa masa en segmentos más equilibrados y accionables a escala de cartera. Esta divergencia no es inconsistencia sino complementariedad analítica: cada algoritmo captura geometrías distintas del mismo portafolio, convergiendo en las mismas conclusiones cualitativas.

15.1 Visualización del Clustering

# PCA para visualización 2D — espacio de cada algoritmo
pca_hac   <- prcomp(X_opt_hac,   center=TRUE, scale.=FALSE)
pca_diana <- prcomp(X_opt_diana, center=TRUE, scale.=FALSE)

df_hac   <- as.data.frame(pca_hac$x[,1:2])   |> mutate(Cluster=factor(cls_hac))
df_diana <- as.data.frame(pca_diana$x[,1:2]) |> mutate(Cluster=factor(cls_diana))

p_hac <- ggplot(df_hac,  aes(PC1,PC2,color=Cluster)) +
  geom_point(alpha=.4,size=.8) +
  scale_color_manual(values=c("#4E79A7","#59A14F","#F28E2B","#B07AA1")) +
  stat_ellipse(linewidth=.8) +
  labs(title=paste0(nom_alg_aglom, " — ", nom_hac), color="Cluster")

p_dia <- ggplot(df_diana, aes(PC1,PC2,color=Cluster)) +
  geom_point(alpha=.4,size=.8) +
  scale_color_manual(values=c("#4E79A7","#59A14F","#F28E2B","#B07AA1")) +
  stat_ellipse(linewidth=.8) +
  labs(title=paste0("DIANA — ", nom_diana), color="Cluster") + theme_bw()

p_hac + p_dia

Segmentos identificados — estos no son clusters nuevos, corresponden a los 4 perfiles detectados en el análisis sobre los 8.950 clientes originales:

Cluster Color Perfil Descripción
1 🔵 Azul Endeudado activo Alto saldo, bajo pago total, frecuentes adelantos en efectivo
2 🟢 Verde Transaccional sano Compras frecuentes, pago total elevado, bajo endeudamiento
3 🟠 Naranja Conservador pasivo Bajo balance y compras, pago mínimo recurrente, baja actividad
4 🟣 Morado Cliente premium Límite alto, compras moderadas, pago completo habitual

average — S5: PCA (izquierda): dominado por el Cluster 1 (azul, ~8.500 obs) que ocupa casi todo el espacio PCA — elipse enorme y densa. Los clusters 2 (verde), 3 (naranja) y 4 (morado) son grupos pequeños y periféricos, confirmando el desbalance severo. Las elipses se solapan, coherente con los valores negativos de silueta observados.

DIANA — S2: RF (derecha): segmentación más equilibrada y visible. Cluster 1 (azul) y 2 (verde) se solapan parcialmente en el centro, mientras Cluster 3 (naranja) ocupa la zona inferior derecha y Cluster 4 (morado) es un grupo lineal aislado en la parte inferior — probablemente clientes con comportamiento atípico muy específico (alto cash advance). La separación es más interpretable para acciones de negocio.


16 Datos Nuevos

\[\hat{c}(\mathbf{x}_{\text{new}}) = \underset{j \in \{1,\ldots,k\}}{\arg\min}\; d(\mathbf{x}_{\text{new,proc}},\, \boldsymbol{\mu}_j)\]

El cliente nuevo procesado \(\mathbf{x}_{\text{new,proc}}\) se asigna al cluster \(\hat{c}\) cuyo centroide \(\boldsymbol{\mu}_j\) minimiza la distancia euclídea — mismo criterio usado para construir los clusters originales, garantizando consistencia entre entrenamiento y producción.

16.1 Definición de clusters para datos nuevos

# Segmentos de referencia — independiente de nuevos_raw
nombres_seg <- c(
  "1" = "Endeudado activo",
  "2" = "Transaccional sano",
  "3" = "Conservador pasivo",
  "4" = "Cliente premium"
)

descrip_seg <- c(
  "1" = "Alto saldo, bajo pago total, frecuentes adelantos en efectivo",
  "2" = "Compras frecuentes, pago total elevado, bajo endeudamiento",
  "3" = "Bajo balance y compras, pago mínimo recurrente, baja actividad",
  "4" = "Límite alto, compras moderadas, pago completo habitual"
)

tibble(
  Cluster     = factor(1:4),
  Segmento    = nombres_seg,
  Descripción = descrip_seg
) |>
  kbl(caption = "Segmentos de referencia — criterio de asignación para clientes nuevos") |>
  kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE) |>
  column_spec(1, bold = TRUE) |>
  column_spec(2, italic = TRUE, color = "white",
              background = c("#4E79A7","#59A14F","#F28E2B","#B07AA1"))
Segmentos de referencia — criterio de asignación para clientes nuevos
Cluster Segmento Descripción
1 Endeudado activo Alto saldo, bajo pago total, frecuentes adelantos en efectivo
2 Transaccional sano Compras frecuentes, pago total elevado, bajo endeudamiento
3 Conservador pasivo Bajo balance y compras, pago mínimo recurrente, baja actividad
4 Cliente premium Límite alto, compras moderadas, pago completo habitual

16.2 nuevos-simulacion-asignacion,

set.seed(99)
nombre_opt       <- nom_hac
nombre_opt_diana <- nom_sel_diana

N_NUEVOS <- 500   # <-- controla el volumen; ajustable

nuevos_raw <- as.data.frame(matrix(
  rnorm(N_NUEVOS * ncol(cc_filt),
        mean = colMeans(cc_filt),
        sd   = apply(cc_filt, 2, sd)),
  nrow     = N_NUEVOS,
  dimnames = list(paste0("NuevoCliente_", seq_len(N_NUEVOS)),
                  names(cc_filt))
))

sc_c <- attr(scale(cc_filt), "scaled:center")
sc_s <- attr(scale(cc_filt), "scaled:scale")
nsc  <- as.data.frame(scale(nuevos_raw, center = sc_c, scale = sc_s))
nsc  <- nsc[, intersect(names(nsc), names(X_clean))]

get_espacio <- function(nom) switch(nom,
  "S1: Elastic Net" = as.matrix(nsc[, sel_s1]),
  "S2: RF"          = as.matrix(nsc[, sel_s2]),
  "S3: B&B"         = as.matrix(nsc[, sel_s3]),
  "S4: SFS"         = as.matrix(nsc[, sel_s4]),
  "S5: PCA"         = predict(pca_res, newdata = nsc)[, 1:n_pc],
  "S6: SFS+PCA"     = predict(pca_s6,  newdata = nsc[, sel_s4])[, 1:n_pc_s6]
)

nuevos_hac   <- get_espacio(nombre_opt)
nuevos_diana <- get_espacio(nombre_opt_diana)

asignar_cent <- function(X_new, X_full, cls) {
  cents <- t(sapply(1:k_final, function(j)
    colMeans(X_full[cls == j, , drop = FALSE])))
  apply(X_new, 1, function(x)
    which.min(apply(cents, 1, function(mu) sum((x - mu)^2))))
}

asig_hac   <- asignar_cent(nuevos_hac,   X_opt_hac,   cls_hac)
asig_diana <- asignar_cent(nuevos_diana, X_opt_diana, cls_diana)

# Tabla resumen de asignación (primeros 10 para no saturar)
tibble(
  Cliente          = rownames(nuevos_raw)[1:10],
  !!nom_alg_aglom := asig_hac[1:10],
  DIANA            = asig_diana[1:10]
) |>
  kbl(caption = paste0("Asignación de clientes nuevos — primeros 10 de ",
                        N_NUEVOS, " — HAC-Average vs DIANA")) |>
  kable_styling(bootstrap_options = "striped", full_width = FALSE)
Asignación de clientes nuevos — primeros 10 de 500 — HAC-Average vs DIANA
Cliente average DIANA
NuevoCliente_1 3 2
NuevoCliente_2 3 2
NuevoCliente_3 3 2
NuevoCliente_4 3 4
NuevoCliente_5 3 4
NuevoCliente_6 3 4
NuevoCliente_7 1 3
NuevoCliente_8 3 1
NuevoCliente_9 3 4
NuevoCliente_10 2 2

La tabla muestra los primeros 10 de 500 clientes nuevos simulados y su asignación a segmentos según cada algoritmo:

  • Cliente: titular sintético generado desde la distribución empírica del dataset original (media y desviación por variable).
  • average: cluster asignado por HAC-Average en el espacio S5: PCA (9 componentes) — criterio: centroide más cercano por distancia euclídea.
  • DIANA: cluster asignado por el algoritmo divisivo en el espacio S2: RF (4 features por importancia Random Forest) — mismo criterio de asignación.

Las discrepancias entre columnas son esperables y frecuentes — por ejemplo, NuevoCliente_7 es asignado al Cluster 1 por HAC y al Cluster 3 por DIANA, y NuevoCliente_8 al Cluster 3 por HAC y al Cluster 1 por DIANA. Esto no implica error: cada algoritmo opera en un espacio distinto y captura geometrías diferentes del comportamiento transaccional. Los números de cluster tampoco son equivalentes entre algoritmos — el Cluster 3 de HAC no es el mismo segmento que el Cluster 3 de DIANA.

Tip

Para interpretar a qué segmento corresponde cada número de cluster, referirse a la tabla de Definición de Segmentos al inicio de esta sección.

# Distancia al centroide asignado — confianza de asignación
cents_hac_full   <- t(sapply(1:k_final, function(j)
  colMeans(X_opt_hac[cls_hac==j,,drop=FALSE])))
cents_diana_full <- t(sapply(1:k_final, function(j)
  colMeans(X_opt_diana[cls_diana==j,,drop=FALSE])))

dist_hac <- sapply(1:nrow(nuevos_hac), function(i)
  sqrt(sum((nuevos_hac[i,] - cents_hac_full[asig_hac[i],])^2)))

dist_diana <- sapply(1:nrow(nuevos_diana), function(i)
  sqrt(sum((nuevos_diana[i,] - cents_diana_full[asig_diana[i],])^2)))

# En nuevos-distancia, agregar slice al inicio
tibble(
  Cliente            = rownames(nuevos_raw),
  !!nom_alg_aglom   := asig_hac,
  Dist_HAC           = round(dist_hac, 3),
  DIANA              = asig_diana,
  Dist_DIANA         = round(dist_diana, 3)
) |>
  slice(1:10) |>    # <-- agregar esta línea
  kbl(caption = paste0("Distancia al centroide — primeros 10 de ",
                        N_NUEVOS, " clientes nuevos")) |>
  kable_styling(bootstrap_options = "striped", full_width = FALSE)
Distancia al centroide — primeros 10 de 500 clientes nuevos
Cliente average Dist_HAC DIANA Dist_DIANA
NuevoCliente_1 3 30.881 2 20.742
NuevoCliente_2 3 36.326 2 13.456
NuevoCliente_3 3 33.229 2 19.842
NuevoCliente_4 3 68.866 4 4.716
NuevoCliente_5 3 132.448 4 4.884
NuevoCliente_6 3 42.372 4 4.590
NuevoCliente_7 1 26.005 3 3.046
NuevoCliente_8 3 34.242 1 12.079
NuevoCliente_9 3 64.450 4 70.759
NuevoCliente_10 2 51.075 2 27.646

La tabla reporta los primeros 10 de 500 clientes nuevos con su cluster asignado y distancia euclídea al centroide de ese cluster. El centroide \(\boldsymbol{\mu}_j\) es el punto promedio de todas las observaciones del cluster \(j\):

\[\boldsymbol{\mu}_j = \frac{1}{|C_j|}\sum_{i \in C_j} \mathbf{x}_i\]

La asignación sigue el criterio de mínima distancia:

\[\hat{c}(\mathbf{x}) = \arg\min_{j} \|\mathbf{x} - \boldsymbol{\mu}_j\|_2\]

Menor distancia implica mayor confianza en la asignación — el cliente está más cerca del centro de su cluster y menos expuesto a reclasificación.

Important

Dist_HAC y Dist_DIANA no son comparables entre sí — HAC opera en 9 componentes PCA y DIANA en 4 features RF, por lo que las escalas difieren sustancialmente. La comparación válida es siempre dentro de cada columna.

Dist_HAC: las distancias varían entre 26 y 132 — escala amplia por la dimensionalidad del espacio PCA. NuevoCliente_5 (132.4) es el caso más periférico, con asignación menos confiable.

Dist_DIANA: escala más compacta (3–70), salvo NuevoCliente_9 (70.8) que presenta distancia atípicamente alta — no encaja bien en ningún segmento existente y requiere revisión manual.

Tip

Regla práctica: clientes con distancia superior al doble de la mediana de su columna deben validarse manualmente antes de aplicar la acción comercial del segmento asignado.

16.3 nuevos-perfil-accion-negocio

cc_orig <- cc_imp |> mutate(Cluster = factor(cls_hac))

# Función auxiliar para construir perfiles — evita duplicación
build_perfiles <- function(df) {
  df |>
    group_by(Cluster) |>
    summarise(
      Balance   = median(BALANCE),
      Compras   = median(PURCHASES),
      CashAdv   = median(CASH_ADVANCE),
      PagoTotal = median(PRC_FULL_PAYMENT),
      .groups   = "drop"
    ) |>
    mutate(
      Perfil = case_when(
        Balance > median(cc_imp$BALANCE) & PagoTotal < 0.1 &
          CashAdv > median(cc_imp$CASH_ADVANCE) ~ "Endeudado activo",
        Compras > median(cc_imp$PURCHASES) & PagoTotal > 0.3 ~ "Transaccional sano",
        Balance < median(cc_imp$BALANCE) & Compras < median(cc_imp$PURCHASES) ~ "Conservador pasivo",
        TRUE ~ "Cliente premium"
      ),
      Acción = case_when(
        grepl("Endeudado",    Perfil) ~ "Plan de consolidación; alerta sobreendeudamiento",
        grepl("Transaccional",Perfil) ~ "Fidelización y cashback; cross-sell inversión",
        grepl("Conservador",  Perfil) ~ "Campaña de activación; incentivos de uso",
        TRUE                          ~ "Producto premier; upgrading límite; wealth management"
      )
    )
}

perfiles_hac   <- build_perfiles(cc_orig)
perfiles_diana <- build_perfiles(cc_imp |> mutate(Cluster = factor(cls_diana)))

# Función auxiliar para construir y renderizar tabla
make_tabla_perfil <- function(clientes, clusters, dists, perfiles,
                               dist_col, caption_txt) {
  tibble(
    Cliente  = clientes,
    Cluster  = factor(clusters),
    Dist     = round(dists, 3)
  ) |>
    rename(!!dist_col := Dist) |>
    slice(1:10) |>
    left_join(perfiles |> select(Cluster, Perfil, Acción), by = "Cluster") |>
    kbl(caption = paste0(caption_txt, " — primeros 10 de ", N_NUEVOS)) |>
    kable_styling(bootstrap_options = c("striped","hover"), full_width = FALSE) |>
    column_spec(1, bold = TRUE)
}

make_tabla_perfil(rownames(nuevos_raw), asig_hac,   dist_hac,
                  perfiles_hac,   "Dist_HAC",
                  paste0("Perfil y acción de negocio — ", nom_alg_aglom))
Perfil y acción de negocio — average — primeros 10 de 500
Cliente Cluster Dist_HAC Perfil Acción
NuevoCliente_1 3 30.881 Endeudado activo Plan de consolidación; alerta sobreendeudamiento
NuevoCliente_2 3 36.326 Endeudado activo Plan de consolidación; alerta sobreendeudamiento
NuevoCliente_3 3 33.229 Endeudado activo Plan de consolidación; alerta sobreendeudamiento
NuevoCliente_4 3 68.866 Endeudado activo Plan de consolidación; alerta sobreendeudamiento
NuevoCliente_5 3 132.448 Endeudado activo Plan de consolidación; alerta sobreendeudamiento
NuevoCliente_6 3 42.372 Endeudado activo Plan de consolidación; alerta sobreendeudamiento
NuevoCliente_7 1 26.005 Conservador pasivo Campaña de activación; incentivos de uso
NuevoCliente_8 3 34.242 Endeudado activo Plan de consolidación; alerta sobreendeudamiento
NuevoCliente_9 3 64.450 Endeudado activo Plan de consolidación; alerta sobreendeudamiento
NuevoCliente_10 2 51.075 Endeudado activo Plan de consolidación; alerta sobreendeudamiento
make_tabla_perfil(rownames(nuevos_raw), asig_diana, dist_diana,
                  perfiles_diana, "Dist_DIANA",
                  "Perfil y acción de negocio — DIANA")
Perfil y acción de negocio — DIANA — primeros 10 de 500
Cliente Cluster Dist_DIANA Perfil Acción
NuevoCliente_1 2 20.742 Transaccional sano Fidelización y cashback; cross-sell inversión
NuevoCliente_2 2 13.456 Transaccional sano Fidelización y cashback; cross-sell inversión
NuevoCliente_3 2 19.842 Transaccional sano Fidelización y cashback; cross-sell inversión
NuevoCliente_4 4 4.716 Conservador pasivo Campaña de activación; incentivos de uso
NuevoCliente_5 4 4.884 Conservador pasivo Campaña de activación; incentivos de uso
NuevoCliente_6 4 4.590 Conservador pasivo Campaña de activación; incentivos de uso
NuevoCliente_7 3 3.046 Conservador pasivo Campaña de activación; incentivos de uso
NuevoCliente_8 1 12.079 Endeudado activo Plan de consolidación; alerta sobreendeudamiento
NuevoCliente_9 4 70.759 Conservador pasivo Campaña de activación; incentivos de uso
NuevoCliente_10 2 27.646 Transaccional sano Fidelización y cashback; cross-sell inversión

Las tablas traducen cada asignación en una acción de negocio concreta, cerrando el ciclo entre el modelo y la operación comercial. Para cada cliente nuevo se reporta: cluster asignado, distancia al centroide (proxy de confianza) y la acción recomendada según el perfil del segmento.

HAC-Average: los primeros 10 clientes caen mayoritariamente en el Cluster 3 (Endeudado activo, 9 de 10), con distancias que van de 26 a 132 — NuevoCliente_5 (132.4) es el caso más periférico y requiere validación manual antes de aplicar la acción. Solo NuevoCliente_7 es asignado al Cluster 1 (Conservador pasivo). La acción dominante es plan de consolidación y alerta de sobreendeudamiento.

DIANA: segmentación más diversificada — Clusters 2 (Transaccional sano), 3 (Conservador pasivo) y 4 (Conservador pasivo) con distancias compactas (3–28), salvo NuevoCliente_9 (70.8) que supera el umbral de 2× mediana. Las acciones se distribuyen entre fidelización/cashback y campaña de activación, reflejando perfiles más heterogéneos que los detectados por HAC.

Note

Las discrepancias de asignación entre algoritmos son esperables — operan en espacios distintos (PCA vs RF). Lo relevante es que ambos producen acciones accionables y diferenciadas para cada segmento. Clientes con distancia superior al doble de la mediana de su columna deben validarse manualmente antes de ejecutar la acción asignada.

Reducción de Dimensionalidad: t-SNE vs PCA

Los clusters existen en espacios de alta dimensionalidad —9 componentes (HAC) y 4 variables RF (DIANA)— que no pueden graficarse directamente.

PCA proyecta sobre las direcciones de máxima varianza global. Al ser lineal, puede superponer clusters cuya separación no coincide con esas direcciones, como ocurre con DIANA, cuyos grupos quedan aplastados en una franja horizontal.

t-SNE (t-Distributed Stochastic Neighbor Embedding, van der Maaten & Hinton, 2008) optimiza iterativamente una proyección 2D que preserva la estructura local: puntos cercanos en alta dimensión quedan cercanos en el plano, haciendo que grupos compactos aparezcan como nubes visualmente distinguibles. Dos ventajas concretas en este análisis: separa con claridad los clusters equilibrados de DIANA, y asigna espacio visual propio a los clusters minoritarios de HAC que PCA suprime por el desbalance del Cluster 1.

Limitación relevante: las distancias entre clusters en el plano no son métricamente interpretables — solo la cohesión interna de cada grupo lo es.

16.4 Plot clustering nuevos datos

library(Rtsne)

set.seed(42)
idx_samp <- sample(nrow(X_opt_hac), min(1500, nrow(X_opt_hac)))

# HAC
combined_hac <- rbind(X_opt_hac[idx_samp, ], nuevos_hac)
tipo_hac     <- c(rep("existente", length(idx_samp)), rep("nuevo", nrow(nuevos_hac)))
clust_hac    <- c(cls_hac[idx_samp], asig_hac)
outlier_hac  <- c(rep(FALSE, length(idx_samp)),
                  dist_hac >= sort(dist_hac, decreasing=TRUE)[10])
label_hac    <- c(rep(NA, length(idx_samp)), rownames(nuevos_raw))

tsne_hac <- Rtsne(combined_hac, dims=2, perplexity=40,
                  max_iter=1000, check_duplicates=FALSE, verbose=FALSE)

df_tsne_hac <- as.data.frame(tsne_hac$Y) |>
  setNames(c("D1","D2")) |>
  mutate(Cluster = factor(clust_hac),
         tipo    = tipo_hac,
         outlier = outlier_hac,
         Cliente = label_hac)

# DIANA
combined_diana <- rbind(X_opt_diana[idx_samp, ], nuevos_diana)
tipo_diana     <- c(rep("existente", length(idx_samp)), rep("nuevo", nrow(nuevos_diana)))
clust_diana    <- c(cls_diana[idx_samp], asig_diana)
outlier_diana  <- c(rep(FALSE, length(idx_samp)),
                    dist_diana >= sort(dist_diana, decreasing=TRUE)[10])
label_diana    <- c(rep(NA, length(idx_samp)), rownames(nuevos_raw))

tsne_diana <- Rtsne(combined_diana, dims=2, perplexity=40,
                    max_iter=1000, check_duplicates=FALSE, verbose=FALSE)

df_tsne_diana <- as.data.frame(tsne_diana$Y) |>
  setNames(c("D1","D2")) |>
  mutate(Cluster = factor(clust_diana),
         tipo    = tipo_diana,
         outlier = outlier_diana,
         Cliente = label_diana)

# función de plot
pal4 <- c("1"="#4E79A7","2"="#59A14F","3"="#F28E2B","4"="#B07AA1")

mk_tsne <- function(df, titulo) {
  n_out <- sum(df$outlier, na.rm=TRUE)
  ggplot() +
    geom_point(data = filter(df, tipo == "existente"),
               aes(D1, D2, color = Cluster),
               size = 1.0, alpha = 0.18, shape = 16) +
    geom_point(data = filter(df, tipo == "nuevo"),
               aes(D1, D2, color = Cluster),
               size = 3.5, alpha = 0.95, shape = 18) +
    geom_label_repel(data = filter(df, tipo == "nuevo", outlier),
                     aes(D1, D2, label = Cliente, color = Cluster),
                     size = 3, fontface = "bold",
                     box.padding   = 0.6,
                     point.padding = 0.4,
                     max.overlaps  = 20,
                     show.legend   = FALSE,
                     fill = alpha("white", 0.85)) +
    scale_color_manual(values = pal4, name = "Cluster") +
    labs(
      title    = titulo,
      subtitle = paste0("◆ ", N_NUEVOS, " clientes nuevos  |  ",
                        "· ", length(idx_samp), " existentes (fondo)  |  ",
                        "etiquetas = top-10 más periféricos (dist al centroide)"),
      x = "Dim 1 (t-SNE)", y = "Dim 2 (t-SNE)"
    ) +
    theme_bw(base_size = 12) +
    theme(
      plot.title       = element_text(face = "bold", size = 13),
      plot.subtitle    = element_text(size = 10, color = "gray35"),
      legend.position  = "right",
      panel.grid.minor = element_blank()
    )
}

mk_tsne(df_tsne_hac,   paste0(nom_alg_aglom, " — ", nombre_opt,      "  [t-SNE]")) /
mk_tsne(df_tsne_diana, paste0("DIANA — ",        nombre_opt_diana, "  [t-SNE]")) +
  plot_annotation(
    title   = "Clustering de clientes nuevos — reducción t-SNE",
    caption = "Nota: t-SNE preserva estructura local; las distancias entre clusters no son métricamente interpretables.",
    theme   = theme(
      plot.title   = element_text(face = "bold", size = 15, hjust = 0.5),
      plot.caption = element_text(size = 10, color = "gray40", hjust = 0)
    )
  )

Referencia de segmentos — colores constantes en ambos paneles:

  • Cluster 1 🔵 Endeudado activo (alto saldo, frecuentes adelantos, bajo pago total)
  • Cluster 2 🟢 Transaccional sano (compras frecuentes, pago total elevado)
  • Cluster 3 🟠 Conservador pasivo (baja actividad, pago mínimo recurrente)
  • Cluster 4 🟣 Cliente premium (límite alto, compras moderadas, pago completo habitual)

average — S5: PCA [t-SNE] (panel superior): el resultado refleja el desbalance estructural de HAC-Average: el Cluster 1 (Endeudado activo, ~8.500 obs) ocupa prácticamente todo el espacio como nube azul difusa, reduciendo los demás clusters a una zona densa en el extremo derecho. Los clusters 2 (verde), 3 (naranja) y 4 (morado) son minoritarios pero visibles y cohesionados en esa región. Los 10 outliers etiquetados corresponden en su mayoría a clientes naranja (Conservador pasivo) y morado (Cliente premium) con distancias al centroide elevadas — NuevoCliente_77, NuevoCliente_417 y NuevoCliente_332 son los más periféricos, asignados a segmentos con alta variabilidad interna.

DIANA — S2: RF [t-SNE] (panel inferior): la separación es notablemente más clara. El Cluster 2 (Transaccional sano, verde) forma una nube compacta y aislada en la zona inferior, el más diferenciado de los cuatro. El Cluster 4 (Cliente premium, morado) se agrupa en la zona izquierda junto al Cluster 1 (azul), con cierto solapamiento esperable dado que ambos perfiles comparten límite de crédito elevado. El Cluster 3 (Conservador pasivo, naranja) ocupa una franja intermedia. Las etiquetas — NuevoCliente_332, NuevoCliente_417, NuevoCliente_77 entre otros — son clientes nuevos periféricos dentro del Cluster 2, candidatos a revisión manual antes de aplicar la acción comercial de fidelización.

Note

Un cliente etiquetado como outlier no implica mala asignación — significa que su perfil está en la periferia de su segmento y la acción comercial recomendada debe aplicarse con mayor cautela o validación adicional.

16.5 Comparativa datos originales vs nuevos por algoritmo

# Distribución original vs nuevos
orig_dist <- tibble(
  Fuente  = "Originales",
  Cluster = factor(1:4),
  n_hac   = as.integer(table(cls_hac)),
  n_diana = as.integer(table(cls_diana))
)

nuevos_dist <- tibble(
  Fuente  = "Nuevos",
  Cluster = factor(1:4),
  n_hac   = as.integer(table(factor(asig_hac,   levels=1:4))),
  n_diana = as.integer(table(factor(asig_diana, levels=1:4)))
)


bind_rows(orig_dist, nuevos_dist) |>
  pivot_longer(c(n_hac, n_diana), names_to = "Algoritmo", values_to = "n") |>
  mutate(Algoritmo = ifelse(Algoritmo == "n_hac", nom_alg_aglom, "DIANA")) |>
  ggplot(aes(x = Cluster, y = n, fill = Fuente)) +
  geom_col(position = "dodge", alpha = 0.85) +
  geom_text(aes(label = n), position = position_dodge(0.9),
            vjust = -0.3, size = 3) +
  scale_fill_manual(values = c("Originales" = "#4E79A7", "Nuevos" = "#F28E2B")) +
  facet_wrap(~Algoritmo, scales = "free_y") +      # <-- escala Y libre por panel
  labs(title = "Distribución por cluster — originales vs nuevos clientes",
       x = "Cluster", y = "n") +
  theme_bw()

El gráfico compara la distribución de clientes originales (azul) vs nuevos (naranja) por cluster en cada algoritmo. La escala Y es libre por panel para permitir visibilidad de ambas fuentes.

average (izquierda): el desbalance de HAC-Average es evidente — Cluster 1 concentra 8.521 originales vs solo 36 nuevos. Los clusters minoritarios muestran una inversión llamativa: Cluster 3 tiene apenas 2 originales pero 196 nuevos, y Cluster 4 tiene 83 originales vs 142 nuevos. Esto confirma que los nuevos clientes —generados desde la distribución empírica global— no replican el sesgo extremo que average imprimió en los datos originales.

DIANA (derecha): distribución más equilibrada en ambas fuentes. Cluster 1 sigue siendo dominante (5.994 orig / 26 nuevos), pero los clusters 2 (1.440 / 295), 3 (1.269 / 58) y 4 (247 / 121) tienen masa suficiente para sustentar acciones comerciales diferenciadas. El Cluster 2 es el que mejor capta nuevos clientes (295), coherente con su tamaño en el portafolio original.

Note

La diferencia en proporciones entre originales y nuevos es esperable: los datos originales reflejan la estructura real del portafolio (desbalanceada), mientras los nuevos fueron simulados con distribución normal sobre la media global — no sobre la distribución por cluster. Para una simulación más realista, los nuevos clientes deberían generarse respetando los pesos de cada cluster.


17 Accionabilidad

17.1 Perfil de Clusters

cc_orig <- cc_imp |> mutate(Cluster = factor(cls_hac))

cc_orig |>
  group_by(Cluster) |>
  summarise(n             = n(),
            Balance_med   = round(median(BALANCE), 1),
            Compras_med   = round(median(PURCHASES), 1),
            CashAdv_med   = round(median(CASH_ADVANCE), 1),
            LimCredit_med = round(median(CREDIT_LIMIT), 1),
            PagoTotal_med = round(median(PRC_FULL_PAYMENT), 3),
            .groups       = "drop") |>
  mutate(Segmento = case_when(
    Cluster == 1 ~ "Endeudado activo",
    Cluster == 2 ~ "Transaccional sano",
    Cluster == 3 ~ "Conservador pasivo",
    Cluster == 4 ~ "Cliente premium"
  )) |>
  select(Cluster, Segmento, everything()) |>
  kbl(caption = "Perfil mediano por cluster — HAC-Average (k = 4)",
      col.names = c("Cluster","Segmento","n",
                    "Balance","Compras","Cash Advance",
                    "Límite crédito","Pago total")) |>
  kable_styling(bootstrap_options = c("striped","hover"), full_width = TRUE) |>
  column_spec(1, bold = TRUE) |>
  column_spec(2, italic = TRUE, color = "white",
              background = c("#4E79A7","#59A14F","#F28E2B","#B07AA1")) |>
  footnote(
    general = paste(
      "Cluster 1 — Endeudado activo: alto saldo, frecuentes adelantos, bajo pago total. |",
      "Cluster 2 — Transaccional sano: compras frecuentes, pago total elevado. |",
      "Cluster 3 — Conservador pasivo: baja actividad, pago mínimo recurrente. |",
      "Cluster 4 — Cliente premium: límite alto, pago completo habitual."
    ),
    general_title = "Segmentos: ",
    footnote_as_chunk = TRUE
  )
Perfil mediano por cluster — HAC-Average (k = 4)
Cluster Segmento n Balance Compras Cash Advance Límite crédito Pago total
1 Endeudado activo 8521 798.1 356.3 0.0 3000 0.000
2 Transaccional sano 344 4278.3 328.8 4508.9 7000 0.000
3 Conservador pasivo 2 10314.7 10646.6 8789.0 17000 0.083
4 Cliente premium 83 3447.5 8519.0 0.0 9500 0.000
Segmentos: Cluster 1 — Endeudado activo: alto saldo, frecuentes adelantos, bajo pago total. | Cluster 2 — Transaccional sano: compras frecuentes, pago total elevado. | Cluster 3 — Conservador pasivo: baja actividad, pago mínimo recurrente. | Cluster 4 — Cliente premium: límite alto, pago completo habitual.

La tabla resume el perfil mediano por cluster — cada fila representa un segmento y cada columna una dimensión del comportamiento financiero del cliente:

  • n: número de titulares en el cluster — refleja el desbalance de HAC-Average (Cluster 1 concentra 8.521 de 8.950 clientes).
  • Balance: saldo promedio sin liquidar. Cluster 3 (10.314) y Cluster 2 (4.278) presentan deuda acumulada significativa; Cluster 1 (798) es sorprendentemente bajo dado su tamaño.
  • Compras: monto total de compras. Cluster 3 domina (10.646) — alto consumo; Cluster 1 (356) es el menos activo transaccionalmente.
  • Cash Advance: adelantos en efectivo. Cluster 2 (4.508) y Cluster 3 (8.789) usan frecuentemente este mecanismo de alto costo financiero; Clusters 1 y 4 presentan valor 0 — nunca usaron adelantos.
  • Límite crédito: capacidad crediticia asignada. Cluster 3 (17.000) tiene el mayor límite, coherente con su alto movimiento financiero.
  • Pago total (PRC_FULL_PAYMENT): proporción de meses en que el cliente liquidó el saldo completo. El valor 0.000 en la mayoría de clusters indica que más del 50% de sus clientes nunca pagó el total del saldo en los 6 meses — la mediana es cero porque la distribución está fuertemente concentrada en 0. Solo Cluster 3 muestra 0.083, es decir, pagó completo en menos del 9% de los meses.
Note

El valor 0 en Pago total no significa que los clientes no paguen — significa que pagan el mínimo o una parte, acumulando intereses. Es el indicador de mayor riesgo crediticio de la tabla: clusters con Pago total = 0 son candidatos prioritarios para alertas de sobreendeudamiento o planes de consolidación.

vars_radar <- c("BALANCE","PURCHASES","CASH_ADVANCE",
                "CREDIT_LIMIT","PRC_FULL_PAYMENT","PAYMENTS")
cc_orig |>
  group_by(Cluster) |>
  summarise(across(all_of(vars_radar), median), .groups="drop") |>
  pivot_longer(-Cluster, names_to="Variable", values_to="Valor") |>
  group_by(Variable) |>
  mutate(Valor_norm=(Valor-min(Valor))/(max(Valor)-min(Valor)+1e-9)) |>
  ggplot(aes(x=Variable, y=Valor_norm, fill=Cluster, group=Cluster)) +
  geom_col(position="dodge", alpha=.8) +
  scale_fill_manual(values=c("#4E79A7","#59A14F","#F28E2B","#B07AA1")) +
  coord_flip() +
  labs(title="Perfil normalizado por cluster", x=NULL, y="Valor [0,1]") +
  theme_bw()

El gráfico de barras horizontales muestra el perfil normalizado de cada cluster en seis variables clave. Los valores están escalados al rango \([0,1]\) dentro de cada variable, donde 1 = cluster con mayor mediana y 0 = cluster con menor mediana , permitiendo comparar patrones entre clusters independientemente de las unidades originales.

Variables del eje Y:

  • BALANCE: saldo promedio disponible. Valor alto indica deuda acumulada sin liquidar.
  • CASH_ADVANCE: adelantos en efectivo. Uso frecuente señala necesidad de liquidez inmediata.
  • CREDIT_LIMIT: límite de crédito asignado. Refleja confianza crediticia del cliente.
  • PAYMENTS: total de pagos realizados. Alto valor indica liquidación activa de deuda.
  • PRC_FULL_PAYMENT: proporción de meses con pago total. Indicador de disciplina financiera.
  • PURCHASES: monto total de compras. Refleja nivel de actividad transaccional.

Lectura por cluster:

  • Cluster 3 — naranja domina en todas las variables con valor 1.0, especialmente BALANCE, CASH_ADVANCE, CREDIT_LIMIT y PAYMENTS — perfil de alto movimiento financiero y endeudamiento, consistente con Endeudado activo o Cliente premium.
  • Cluster 4 — morado ocupa valores intermedios en BALANCE (~0.25), CREDIT_LIMIT (~0.45) y PAYMENTS (~0.38), con actividad nula en PRC_FULL_PAYMENT y PURCHASES (~0.75) — perfil de comprador activo con bajo pago total, coherente con Conservador pasivo o cliente de compras en cuotas.
  • Cluster 2 — verde muestra valores bajos en la mayoría de variables (0.12–0.50), con cierta actividad en CASH_ADVANCE (~0.50) — coherente con Conservador pasivo.
  • Cluster 1 — azul es prácticamente invisible — barra mínima solo en PURCHASES (~0.02) — consecuencia del desbalance severo de HAC-Average que concentra la mayoría de observaciones en Cluster 3.
Note

La dominancia del Cluster 3 (naranja) refleja el desbalance de HAC-Average: sus medianas representan el comportamiento promedio del portafolio completo, mientras los clusters minoritarios capturan perfiles más extremos y específicos. Para una lectura más equilibrada por segmento, referirse a los resultados de DIANA.

17.2 Descripción y Acciones por Segmento

Acciones por segmento
Cluster Perfil Acción
1 Bajo balance y compras, pago mínimo recurrente — conservador pasivo Campaña de activación; incentivos de uso
2 Alto balance, bajo pago total, alto cash advance — endeudado activo Plan de consolidación; alerta sobreendeudamiento
3 Alto balance, bajo pago total, alto cash advance — endeudado activo Plan de consolidación; alerta sobreendeudamiento
4 Límite alto, compras moderadas, pago completo — cliente premium Producto premier; upgrading límite; wealth management

18 Conclusiones

El análisis identificó cuatro segmentos diferenciados de titulares de tarjeta de crédito: Endeudado activo, Transaccional sano, Conservador pasivo y Cliente premium. El Clean Algorithm iterativo garantizó un espacio libre de redundancia antes de cualquier selector.

La comparativa de 6 estrategias — Elastic Net, RF, B&B, SFS, PCA y SFS+PCA — determinó automáticamente, en dos etapas, tanto el algoritmo aglomerativo óptimo como el divisivo y sus respectivos espacios: HAC-Average + S5:PCA (\(\bar{s} = 0.522\)) y DIANA + S2:RF (\(\bar{s} = 0.470\)). Cada familia operó sobre el espacio que maximizó su propio Silhouette, garantizando que la elección del algoritmo no fue arbitraria sino empíricamente justificada.

Ambos algoritmos producen particiones complementarias en \(k^* = 4\) clusters. HAC-Average genera una segmentación concentrada —útil para identificar perfiles atípicos extremos—; DIANA produce distribución más equilibrada (247–5.994 obs por cluster), con mayor utilidad operativa para acciones comerciales masivas.

La comparativa de convergencia aporta evidencia de consistencia interna: ambos algoritmos coinciden en \(k^* = 4\), alcanzan coeficientes estructurales > 0.90 (AC = 0.990, DC = 0.980) y correlaciones cofenéticas similares (0.703 y 0.727). El ARI = -0.058 es negativo y cercano a cero — el ARI varía entre −1 y +1, donde 0 equivale a coincidencia por azar. El valor observado es resultado esperado del desbalance extremo de HAC-Average (98.8% en un cluster): el índice penaliza matemáticamente la asimetría entre particiones, no refleja inconsistencia analítica. La consistencia real se valida por la convergencia en \(k^* = 4\), los coeficientes estructurales y la caracterización cualitativa coincidente de los perfiles — no en asignaciones individuales idénticas entre algoritmos que operan en espacios distintos.

La validación sobre 500 clientes simulados confirmó que el mecanismo de asignación es operativo: la mayoría se ubica con confianza en su cluster más cercano, con outliers identificables por distancia al centroide. La visualización mediante t-SNE —preferida sobre PCA por su capacidad de preservar estructura local en alta dimensionalidad— evidenció que DIANA produce clusters visualmente más separados y equilibrados, mientras HAC-Average concentra su masa en el Endeudado activo con perfiles minoritarios claramente diferenciados en la periferia.


19 Referencias

Everitt, B., Landau, S., Leese, M. y Stahl, D. (2011). Cluster Analysis (5.ª ed.). Wiley.

Kaufman, L. y Rousseeuw, P.J. (1990). Finding Groups in Data. Wiley.

Rousseeuw, P.J. (1987). Silhouettes. Journal of Computational and Applied Mathematics, 20, 53–65.

Ward, J.H. (1963). Hierarchical grouping to optimize an objective function. JASA, 58(301), 236–244.

Zou, H. & Hastie, T. (2005). Regularization via the elastic net. JRSS-B, 67(2), 301–320.

Kaggle. (s.f.). Credit Card Dataset for Clustering. https://www.kaggle.com/datasets/arjunbhasin2013/ccdata