Módulo 3 — Caracterización estructural

En el módulo 2 construimos el chronnet. Ahora medimos su forma: cuán llena está, cuántos triángulos cierra, si la actividad regresa y si hay hubs que dominan la red.

Code
library(igraph); library(ggraph); library(dplyr); library(ggplot2); library(readr); library(scales); library(leaflet); library(plotly); library(RColorBrewer)
chronnet <- readRDS("../outputs/chronnet.rds")
knitr::opts_chunk$set(message = FALSE, warning = FALSE)

0. Sobre qué medimos

  • Toda la caracterización corre sobre el chronnet, la red de trabajo del taller: 2.275 bancos conectados por co-actividad temporal — la arista i→j significa que j estuvo activo el día activo siguiente a i, con peso igual al producto de sus eventos.
  • Al ser una sola pieza conexa no hay islas que excluir: las métricas globales (caminos, diámetro, transitividad) corren sobre la red completa.
  • El peso de la arista no es dinero: es intensidad de co-actividad. El dinero viaja como atributo calculado desde los reportes (monto_total por banco, monto circulado por par): lo usamos para tamaños y lecturas, nunca como peso estructural.
Code
g0 <- chronnet
g0u <- as_undirected(g0, mode = "collapse", edge.attr.comb = list(weight = "sum"))
cat("Chronnet:", vcount(g0), "nodos |", ecount(g0), "aristas |",
    components(g0)$no, "componente\n")
Chronnet: 2275 nodos | 31122 aristas | 1 componente

Vocabulario: colapsar a no dirigido = tratar i→j y j→i como la misma conexión, sumando pesos (algunos algoritmos lo requieren).

1. Densidad: qué tan llena está la red

La densidad es la fracción de aristas que existen respecto a todas las posibles:

\[d = \frac{|E|}{|V|(|V|-1)}\]

Code
cat("Densidad del chronnet =", round(edge_density(g0), 5), "\n")
Densidad del chronnet = 0.00602 

Explicación:

  • 0 = grafo sin aristas; 1 = grafo donde todo banco se conecta con todos.
  • Ojo con la interpretación: en el chronnet las aristas nacen de la co-actividad en días consecutivos, una regla que conecta mucho más que los flujos reales.
  • La densidad del chronnet mide cuán encadenada está la actividad en el tiempo — no cuán llena está la red de transacciones.

Lectura criminal

  • Una red de lavado de dinero real debe ser hueca: muchos bancos, pocas conexiones, caminos largos.
  • Esa huecosidad es la protección: si un eslabón cae, la red no se lleva puesta a nadie más.
  • El chronnet hereda una densidad mayor porque conecta por tiempo, no por flujo — precisamente por eso conviene medir la forma de la red aquí y el dinero como atributo aparte.

2. Transitividad ponderada — el “territorio”

Qué fracción de los tríos de bancos potenciales cierra en triángulos, ponderando por intensidad de co-actividad:

\[C^w_{global} = \frac{\sum_{i} (\text{triángulos ponderados alrededor de } i)}{\sum_{i} (\text{tríos ponderados centrados en } i)}\]

\[C_i^w = \frac{1}{s_i (k_i - 1)} \sum_{j,h} \frac{w_{ij} + w_{ih}}{2}\, a_{ij} a_{ih} a_{jh}\]

  • \(s_i\) = fuerza del vértice (intensidad de co-actividad), \(k_i\) = su grado, \(a_{xy}=1\) si existe arista entre \(x\) e \(y\).

Explicación de la transitividad global:

  • Imagina tres bancos A, B y C donde A es co-activo con B y B con C.
  • Si también A es co-activo con C, el trío “cerró” en triángulo.
  • La transitividad global es el porcentaje de tríos que cierran.
  • Alta = actividad concentrada en circuitos cerrados; baja = actividad en cadenas largas sin retorno.
Code
cw_global <- transitivity(g0u, type = "global", weights = E(g0u)$weight)
cw_local  <- transitivity(g0u, type = "local", weights = E(g0u)$weight)
resumen_cw <- tibble(
  Min = min(cw_local, na.rm = TRUE), Q1 = quantile(cw_local, 0.25, na.rm = TRUE),
  Mediana = median(cw_local, na.rm = TRUE), Media = mean(cw_local, na.rm = TRUE),
  Q3 = quantile(cw_local, 0.75, na.rm = TRUE), Max = max(cw_local, na.rm = TRUE)
)
cat("C^w global =", round(cw_global, 4), "\n")
C^w global = 0.1178 
Code
knitr::kable(resumen_cw, caption = "Coeficiente de agrupamiento local ponderado")
Coeficiente de agrupamiento local ponderado
Min Q1 Mediana Media Q3 Max
0 0.1814125 0.2894737 0.3435673 0.4761905 1

Lectura criminal

  • Los triángulos son circuitos temporales consolidados: tríos de bancos que operan uno tras otro de forma repetida — A pasa actividad a B, B a C y C de vuelta a A.
  • Un triángulo fuerte indica coordinación sostenida: nadie opera fuera de la vista de los otros dos.
  • Mediana cercana a cero con media y Q3 más altas = mayoría de bancos aislados en el tiempo + un núcleo cohesionado de dirección.
  • Esa es exactamente la estructura de una organización profesional.

3. Reciprocidad ponderada — la jerarquía

\[R_w = \frac{\sum_{i, j} \min(w_{ij}, w_{ji})}{\sum_{i, j} w_{ij}}\]

Explicación:

  • Compara cuánta co-actividad es mutua (A precede a B tanto como B precede a A) respecto al total.
  • Cercano a 1 = red de ida y vuelta (actividad sincronizada en ambos sentidos).
  • Cercano a 0 = red de una sola dirección: la actividad fluye en secuencia, no en retroalimentación.
Code
W <- as_adjacency_matrix(g0, attr = "weight", sparse = FALSE)
R_w <- sum(pmin(W, t(W))) / sum(W)
cat("Reciprocidad (sin pesos) R =", round(reciprocity(g0), 4), "\n")
Reciprocidad (sin pesos) R = 0.242 
Code
cat("Reciprocidad ponderada R_w =", round(R_w, 4), "\n")
Reciprocidad ponderada R_w = 0.3651 

Cómo funciona el código:

  • as_adjacency_matrix(g0, attr = "weight") crea la matriz \(W\) donde \(W_{ij}\) = intensidad de co-actividad de \(i\) a \(j\).
  • pmin(W, t(W)) toma el mínimo con la transpuesta (la parte “regresada” de cada secuencia).
  • Dividir entre la suma total da \(R_w\).

Lectura criminal

  • Un \(R_w\) bajo indica una secuencia unidireccional: la actividad va del coordinador al ejecutor y no regresa por el mismo canal — la firma de una cadena de mando.
  • Cuidado con la interpretación: un banco que aparece temprano en la secuencia no necesariamente está al tanto del origen ilícito de lo que sigue — puede ser corresponsalía legítima sirviendo a un cliente indebido.
  • La reciprocidad baja marca la forma de la red (jerárquica), no la culpabilidad de sus nodos.

4. La red sobre el mapa: fondo OSM e interruptor de aristas

  • Mapa mundial con tiles de OpenStreetMap de fondo; botón de capas (arriba a la derecha) para quitar y poner las aristas.
  • Tooltip al pasar el mouse: banco, país, dinero, transacciones, coordenadas y fechas.
  • Las líneas naranjas unen pares de bancos entre los que sí circuló dinero según los reportes — el chronnet conecta muchos más pares por tiempo, pero aquí solo dibujamos los enlaces financieros reales.
Code
# id = slug del banco (nombre interno del vértice); nombre = nombre bonito
nodos <- tibble(
  id = V(chronnet)$name,
  nombre = V(chronnet)$bank_name, pais = V(chronnet)$country,
  lon = V(chronnet)$lon, lat = V(chronnet)$lat,
  monto_total = V(chronnet)$monto_total, tx_total = V(chronnet)$tx_total,
  first = V(chronnet)$first, last = V(chronnet)$last
) %>% filter(!is.na(lat), !is.na(lon), monto_total > 0)

# as_long_data_frame ya trae from_lat/from_lon/to_lat/to_lon: sin joins
el <- as_long_data_frame(chronnet) %>%
  transmute(lat_o = from_lat, lon_o = from_lon, lat_d = to_lat, lon_d = to_lon,
            monto, from_n = from_bank_name, to_n = to_bank_name) %>%
  filter(monto > 0, !is.na(lat_o), !is.na(lat_d)) %>%
  arrange(desc(monto)) %>% slice_head(n = 500)

# Color del nodo según país (paleta extendida para muchos países)
n_paises <- length(unique(nodos$pais))
pal_pais <- colorFactor(colorRampPalette(brewer.pal(8, "Set2"))(n_paises), nodos$pais)

leaflet(nodos) %>%
  addProviderTiles(providers$OpenStreetMap) %>%
  addCircleMarkers(
    radius = ~scales::rescale(log1p(monto_total), to = c(3, 12)),
    color = ~pal_pais(pais), fillOpacity = 0.75, stroke = FALSE, group = "Bancos",
    label = lapply(paste0("<b>", nodos$nombre, "</b> (", nodos$pais, ")<br>",
                          "💰 ", dollar(nodos$monto_total), " | 🔢 ", comma(nodos$tx_total), " tx<br>",
                          "📍 ", round(nodos$lat,2), ", ", round(nodos$lon,2), "<br>",
                          "🗓️ ", nodos$first, " → ", nodos$last), htmltools::HTML)) %>%
  addPolylines(data = el, lat = ~c(lat_o, lat_d), lng = ~c(lon_o, lon_d),
               group = "Flujos", color = "#B45309", weight = 1.2, opacity = 0.35,
               label = ~paste0(from_n, " → ", to_n, "<br>💰 ", dollar(monto), " reportados")) %>%
  addLayersControl(overlayGroups = c("Bancos", "Flujos"),
                   options = layersControlOptions(collapsed = FALSE)) %>%
  setView(lng = 0, lat = 20, zoom = 2)

Para fijar el zoom:

  • El estado inicial lo fija setView(lng, lat, zoom) (en este mapa: mundo completo, zoom 2).
  • Si al cambiar de pestaña el navegador recarga el widget, vuelve a ese estado inicial.
  • Para conservar un zoom específico: comparte el HTML (el estado queda grabado) o fija otro setView en el código.

Lectura criminal

  • El mapa muestra la geografía del lavado: corredores entre centros financieros (Londres, Nueva York, Hong Kong, Zúrich) y jurisdicciones de paso (Caribe, Islas del Canal, Letonia, Chipre).
  • Las líneas naranjas son los 500 flujos más grandes — cada una un par de bancos entre los que un SAR documentó dinero.
  • El tamaño del punto sigue el dinero total que tocó cada banco.

5. Distribución de grado y fuerza

  • Grado \(k_i\) = conexiones temporales del chronnet (con cuántos bancos comparte ventanas consecutivas).
  • Fuerza \(s_i\) = dinero total que movió el banco según los reportes (atributo, no peso del grafo).
Code
knitr::include_graphics("figuras/distribucion_grados.png")

Lectura:

  • La mayoría de los bancos tiene pocas conexiones temporales; unos pocos comparten ventanas con muchos otros.
  • Esa asimetría es la señal de una red con hubs de actividad.

6. Ley de potencias — ¿la red es libre de escala?

  • Si la cola de la distribución de grados sigue \(P(k) \sim k^{-\gamma}\), existen hubs que dominan la conectividad.
  • Ajuste por máxima verosimilitud (poweRlaw), estimando conjuntamente \(\gamma\) y el grado mínimo \(x_{min}\) a partir del cual vale la ley:
Code
knitr::include_graphics("figuras/ley_potencias.png")

Code
d_in <- unname(degree(g0, mode = "in")); d_out <- unname(degree(g0, mode = "out")); d_all <- unname(degree(g0, mode = "all"))
tibble(
  Grado = c("in", "out", "total"),
  Min. = c(min(d_in), min(d_out), min(d_all)),
  Q1 = c(quantile(d_in, .25), quantile(d_out, .25), quantile(d_all, .25)),
  Mediana = c(median(d_in), median(d_out), median(d_all)),
  Media = c(round(mean(d_in), 2), round(mean(d_out), 2), round(mean(d_all), 2)),
  Q3 = c(quantile(d_in, .75), quantile(d_out, .75), quantile(d_all, .75)),
  Max = c(max(d_in), max(d_out), max(d_all))
) %>% knitr::kable(caption = "Distribución de grado del chronnet")
Distribución de grado del chronnet
Grado Min. Q1 Mediana Media Q3 Max
in 2 4 6 13.68 13 554
out 2 4 6 13.68 13 565
total 4 8 13 27.36 25 1119

Lectura criminal

  • γ ≈ 2.13 con \(x_{min}\) = 12.
  • El 51.4% de los bancos supera \(x_{min}\); el 25.8% supera \(2x_{min}\); solo el 16.1% supera \(3x_{min}\).
  • Ese último grupo son los pivotes: conectividad temporal extrema cuya caída desarticula la secuencia de la red entera.
  • En una red libre de escala, intervenir esos pocos nodos vale más que perseguir cientos de ejecutores.

7. Tiempos de la red: caminos mínimos

  • \(\ell_{i,j}\) = mínimo número de saltos entre dos vértices; \(\langle\ell\rangle\) y diámetro \(D = \max_{i,j}\ell_{i,j}\) sobre el chronnet no dirigido.

Explicación:

  • Cuántos intermediarios temporales separan a cualquier par de bancos de la red.
  • Caminos cortos = la actividad de un banco alcanza a otro en pocos días de retransmisión.
  • Caminos largos = cadenas de varios días que dan anonimato pero multiplican puntos de intervención.
Code
cat("Caminos mínimos promedio <l> =", round(mean_distance(g0u, directed = FALSE), 2), "saltos\n")
Caminos mínimos promedio <l> = 2.92 saltos
Code
cat("Diámetro D =", diameter(g0u, directed = FALSE), "saltos\n")
Diámetro D = 14 saltos

Lectura criminal

  • Cada salto es una capa de separación temporal entre quien origina la actividad y quien la recibe.
  • Con pocos saltos de promedio, el origen queda a varios bancos de distancia: suficiente para dificultar la trazabilidad, no tanto para hacer la red lenta.
  • El diámetro marca el peor caso: la cadena temporal más larga que una investigación tendría que reconstruir.