Módulo 4 — Comunidades y validación

En el módulo 3 medimos la estructura global. Ahora la dividimos en comunidades y validamos que esa división no es azar ni artefacto del tiempo.

Code
library(igraph); library(ggraph); library(dplyr); library(ggplot2); library(readr); library(scales); library(dendextend); library(RColorBrewer); library(tidyr); library(DT)
chronnet <- readRDS("../outputs/chronnet.rds")
comparacion <- read_csv("../outputs/comparacion_comunidades.csv", show_col_types = FALSE)
comunidad_df <- read_csv("../outputs/comunidades.csv", show_col_types = FALSE)
cen <- read_csv("../outputs/centralidad.csv", show_col_types = FALSE)
comunidades <- readRDS("../outputs/comunidades_objetos.rds")
test_null <- read_csv("../outputs/test_null_model.csv", show_col_types = FALSE)
boot_raw <- read_csv("../outputs/bootstrap_metricas.csv", show_col_types = FALSE)
g0 <- chronnet
g0u <- as_undirected(g0, mode = "collapse", edge.attr.comb = list(weight = "sum"))
knitr::opts_chunk$set(message = FALSE, warning = FALSE)

1. Detección de comunidades

  • Comunidad: grupo de vértices que se conectan mucho entre sí y poco con el resto.
  • Modularidad \(Q\): cuántas aristas caen dentro de comunidades respecto a lo que esperaría una red aleatoria con los mismos grados.
  • \(Q \to 1\) = comunidades nítidas; \(Q \approx 0\) = no hay estructura.

Interpretación:

  • En redes de lavado, las comunidades son circuitos operativos: grupos de entidades que operan en los mismos momentos, con redundancia y poca dependencia del exterior.
  • Un circuito absorbe la caída de un miembro sin dejar de operar.
  • Conocer los circuitos es previo a decidir dónde intervenir.

Comparamos los principales algoritmos sobre el chronnet (no dirigido colapsado cuando el método lo requiere), resolviendo el mismo problema desde ángulos distintos (agrupamiento greedy, difusión, caminos aleatorios, óptimo local). El edge-betweenness queda fuera: recalcula la intermediación de todas las aristas tras cada fusión, un coste inviable sobre las ~31k aristas del chronnet (y nunca ganó en modularidad):

Code
comparacion_fmt <- comparacion %>%
  mutate(modularidad = round(modularidad, 3),
         mejor = ifelse(row_number() == 1, "★ MEJOR", ""),
         n_comunidades = as.integer(n_comunidades))
knitr::kable(comparacion_fmt, col.names = c("Método", "Comunidades", "Modularidad", ""))
Método Comunidades Modularidad
fast_greedy 10 0.473 ★ MEJOR
louvain 11 0.473
infomap 84 0.437
walktrap 31 0.396
leiden 1800 0.235

Subrayado:

  • La fila ★ es la de mayor modularidad — el método que mejor separa los circuitos.
  • Ojo: modularidad muy alta con cientos de comunidades puede significar sobre-partición (cortar circuitos que operan juntos); por eso se reporta junto al número de comunidades.

Lectura criminal

  • Cada comunidad detectada es una hipótesis de investigación: “estas entidades operan como un circuito”.
  • Los puentes entre comunidades son donde la actividad cambia de circuito.
  • Ahí es donde una intervención corta el mayor flujo con el menor esfuerzo (módulo 6).

2. Tabla descriptiva por comunidad

  • La tabla completa (una fila por comunidad) no se puede leer.
  • Resumen correcto: estadísticos del tamaño + tabla interactiva filtrable de las más grandes:
Code
tam <- comunidad_df %>% count(comunidad, name = "n_bancos") %>% pull(n_bancos)
tibble(
  Estadístico = c("Comunidades", "Bancos en la comunidad más grande", "Mediana de tamaño", "Comunidades de 2-3 bancos", "Bancos en comunidades de 2-3"),
  Valor = c(length(tam), max(tam), median(tam),
            sum(tam <= 3), sum(tam[tam <= 3]))
) %>% knitr::kable()
Estadístico Valor
Comunidades 10.0
Bancos en la comunidad más grande 864.0
Mediana de tamaño 148.5
Comunidades de 2-3 bancos 0.0
Bancos en comunidades de 2-3 0.0

Lectura:

  • Comunidades pequeñas (2-3 bancos) = pares de facto: solo comparten ventanas temporales entre sí y con casi nadie más — típicamente un corresponsal y su cliente recurrente.
  • Comunidades grandes = circuitos de procesamiento con muchos participantes.
Code
comunidad_df %>%
  left_join(cen %>% select(bank_id, bank_name, country, fuerza, intermediacion), by = "bank_id") %>%
  group_by(comunidad) %>%
  summarise(n_bancos = n(),
            monto_total = sum(fuerza, na.rm = TRUE),
            banco_principal = bank_name[which.max(fuerza)],
            pais_principal = country[which.max(fuerza)],
            .groups = "drop") %>%
  mutate(monto_total = round(monto_total, 0)) %>%
  arrange(desc(monto_total)) %>%
  datatable(filter = "top", options = list(pageLength = 10),
            colnames = c("Comunidad", "Nº bancos", "Monto total (USD)", "Banco principal", "País principal"))

Filtros temporales:

  • La detección usa toda la serie (2000–2017); no hay filtro temporal “en vivo” dentro del algoritmo.
  • Para períodos — “¿cambió la estructura antes y después de 2012?” — el procedimiento es: filtrar tx por fecha → reconstruir el chronnet (R/03_chronnet.R) → re-ejecutar R/05_comunidades.R por ventana.
  • La temporalidad se introduce por diseño de la muestra, no por el algoritmo.

3. Dendrograma de la jerarquía

  • Fast Greedy fusiona iterativamente comunidades; el árbol muestra a qué altura se unen.
  • Fusiones a baja altura = comunidades gemelas.
  • Fusiones a gran altura = macro-circuitos que solo se tocan por puentes puntuales.
Code
hc <- as.hclust(comunidades$fast_greedy)
dendro <- color_branches(as.dendrogram(hc), k = 12, col = brewer.pal(12, "Paired"))
labels(dendro) <- ""
plot(dendro, ylab = "Altura de fusión", main = "Jerarquía de comunidades (Fast Greedy)")

4. ¿Son reales las estructuras o son azar? — Modelo nulo

  • Cualquiera puede calcular métricas; la pregunta seria es: ¿son sorprendentes?
  • Una red con hubs extremos tendrá caminos cortos y alguna modularidad por pura heterogeneidad de grado.
  • El modelo nulo de configuración compara lo observado contra esa explicación mínima del azar:
  1. Generamos \(N = 100\) réplicas aleatorias del chronnet, preservando exactamente los grados entrantes y salientes de cada vértice (doble intercambio de aristas, \(5|E|\) veces por réplica).
  2. Self-loops permitidos durante el rewiring — el chronnet observado los contiene.
  3. Cada arista re-cableada retiene su peso: solo cambia la colocación de los pesos.
  4. Sobre cada réplica se calculan las métricas, y el valor observado se compara contra esa distribución nula.

\[z = \frac{m_{obs} - \bar{m}_{null}}{\sigma_{null}}, \qquad p_{emp} = \frac{2\min\{|\{r : m_r \le m_{obs}\}|,\ |\{r : m_r \ge m_{obs}\}|\} + 1}{N + 1}\]

  • El \(+1\) en numerador y denominador es la corrección de North et al. (2002) para nunca reportar \(p = 0\) con réplicas finitas.
Code
knitr::kable(test_null %>% mutate(across(c(observado, nulo_media, nulo_sd, z, p), ~ round(.x, 4))),
             col.names = c("Métrica", "Observado", "Media nula", "SD nula", "z", "p"))
Métrica Observado Media nula SD nula z p
modularity 0.4733 0.1428 0.0014 242.1174 0.0099
transitivity_w 0.1178 0.0934 0.0009 25.8714 0.0099
reciprocity 0.2420 0.0882 0.0018 83.5304 0.0099
avg_path 2.9224 2.4975 0.0033 130.2270 0.0099
diameter 14.0000 4.0300 0.1714 58.1522 0.0099
Code
test_null %>%
  ggplot(aes(x = reorder(metrica, z), y = z)) +
  geom_col(fill = "#0D9488") +
  geom_hline(yintercept = c(-1.96, 1.96), linetype = "dashed", color = "#B45309") +
  coord_flip() +
  labs(x = "", y = "z-score vs. modelo nulo",
       caption = "Líneas discontinuas: umbrales ±1.96 (significancia 5%)") +
  theme_minimal()

Lectura criminal

  • Métricas con \(|z|\) por encima de 1.96 = propiedades intrínsecas de la red — ni el azar ni la sola heterogeneidad de grado las producen.
  • La organización en comunidades y la separación temporal (diámetro, caminos) son el terreno real para planear disrupción.
  • Si todo hubiera sido no significativo, la conclusión sería la opuesta: “no vemos estructura, solo ruido de reporte” — y no tendría sentido intervenir nada.

5. ¿Es estable en el tiempo? — Block bootstrap

Hilo conductor de esta sección:

  • Ya sabemos que la red tiene estructura (modelo nulo, sección 4).
  • La pregunta ahora: ¿esa estructura depende de los días concretos en que se registraron los datos?
  • Si reordenar el tiempo desmoronara la red, todo lo anterior sería un artefacto del calendario de reporte.
  • La actividad de un día depende de la del anterior (autocorrelación): un bootstrap sobre eventos independientes sería inválido.
  • Usamos bootstrap por bloques de 7 días con \(B = 50\) réplicas (parámetro de demo; el paper usa 200):
  1. Se parte la línea de tiempo en bloques contiguos de 7 días.
  2. Se remuestrean bloques con reemplazo hasta cubrir el horizonte (así se conservan las semanas operativas enteras: la estructura semanal del lavado no se rompe).
  3. Se reconstruye el chronnet sobre la secuencia remuestreada.
  4. Se recalculan las métricas.
  • Una métrica es estable si el observado cae dentro del IC 95% bootstrap — es decir, no depende de qué días específicos se interceptaron.
  • Bootstrap y métricas observadas corren sobre el mismo objeto (el chronnet), así que la comparación es directa.
Code
# El bootstrap reconstruye el chronnet: los observados salen del chronnet real
chron_u <- as_undirected(g0, mode = "collapse")
obs_vals <- c(V = vcount(g0), E = ecount(g0),
              transitivity_w = transitivity(chron_u, type = "global"),
              reciprocity = reciprocity(g0),
              modularity = modularity(cluster_fast_greedy(chron_u)),
              diameter = diameter(chron_u, directed = FALSE))
boot_raw %>%
  summarise(across(everything(), list(mediana = median, q025 = ~ quantile(.x, .025),
                                      q975 = ~ quantile(.x, .975), iqr = IQR),
                   .names = "{.col}__{.fn}")) %>%
  pivot_longer(everything(), names_to = c("metrica", ".value"), names_sep = "__") %>%
  mutate(observado = obs_vals[metrica],
         estable = ifelse(observado >= q025 & observado <= q975, "✓ estable", "✗ fuera")) %>%
  knitr::kable(digits = 3, col.names = c("Métrica", "Mediana", "IC 2.5%", "IC 97.5%", "IQR", "Observado", "Estabilidad"))
Métrica Mediana IC 2.5% IC 97.5% IQR Observado Estabilidad
V 1701.500 1611.025 1792.300 65.500 2275.000 ✗ fuera
E 20401.000 18208.825 21982.200 1327.500 31122.000 ✗ fuera
transitivity_w 0.123 0.118 0.127 0.005 0.118 ✓ estable
reciprocity 0.230 0.219 0.243 0.008 0.242 ✓ estable
modularity 0.526 0.481 0.576 0.031 0.473 ✗ fuera
diameter 73.000 39.350 177.125 39.500 14.000 ✗ fuera
Code
boot_raw %>%
  select(transitivity_w, reciprocity, modularity, diameter) %>%
  pivot_longer(everything(), names_to = "metrica", values_to = "valor") %>%
  mutate(obs = obs_vals[metrica]) %>%
  ggplot(aes(valor)) +
  geom_histogram(bins = 30, fill = "#94A3B8", color = "white") +
  geom_vline(aes(xintercept = obs), color = "#B45309", linewidth = 1) +
  facet_wrap(~ metrica, scales = "free") +
  labs(x = "Distribución bootstrap (B = 50, bloques de 7 días)", y = "Frecuencia",
       caption = "Línea naranja: valor observado en la red completa") +
  theme_minimal()

Detalle esperado:

  • \(|V|\) y \(|E|\) caen fuera del IC — remuestrear días reduce la diversidad de bancos.
  • El diámetro también: las remuestras encadenan bloques repetidos y alargan las cadenas temporales.
  • Transitividad y reciprocidad quedan dentro: la forma local de la red no depende del calendario de reporte.
  • La modularidad observada queda justo bajo el IC — las remuestras concentran la co-actividad en menos bancos y eso infla \(Q\).
  • Lectura conjunta: la estructura local es estable; los tamaños globales y la escala de la jerarquía, no.

6. La red coloreada por comunidad

Qué estamos viendo:

  • El chronnet completo; cada color = una comunidad de la sección 1.
  • El tamaño del punto sigue al dinero (monto_total del banco).
  • Sin leyenda de cientos de colores a propósito: lo importante no es el número de comunidad, sino cuántas hay y cuán separadas están.
  • Colores en manchas nítidas = la división en circuitos es real; colores mezclados = un solo circuito difuso.
Code
ggraph(chronnet, layout = "stress") +
  geom_edge_link(alpha = 0.1, color = "grey70") +
  geom_node_point(aes(color = as.factor(comunidad), size = monto_total), alpha = 0.85) +
  scale_size(range = c(0.5, 7)) +
  theme_graph() +
  theme(legend.position = "none")

7. Geografía × comunidad en 3D

Qué estamos viendo:

  • Bancos proyectados en sus coordenadas reales (lat/lon) y elevados según su comunidad.
  • Combina las dos vistas anteriores — grafo (sección 6) y mapa — en una sola.
  • Pasa el mouse sobre los nodos para ver sus datos.
Code
library(plotly); library(htmlwidgets)
readRDS("../outputs/widgets/red_3d.rds")

Lectura criminal

  • Comunidades vecinas en el grafo pero lejanas en el mapa = coordinación transnacional (un circuito operando a distancia).
  • Comunidades superpuestas en el mapa pero separadas en el grafo = circuitos rivales que comparten ciudad — competidores que no se tocan.
  • Ambos patrones son inteligencia accionable.