Módulo 2 — Construcción de la red cronológica (chronnet)

En el módulo 1 limpiamos y exploramos los reportes SAR. Ahora los convertimos en una red cronológica — el objeto sobre el que trabajan todos los módulos siguientes.

Code
library(igraph); library(ggraph); library(dplyr); library(readr); library(lubridate); library(tidyr)
tx <- read_csv("../outputs/tx_limpio.csv", show_col_types = FALSE)
geo <- read_csv("../data/bancos_geo.csv", show_col_types = FALSE)
knitr::opts_chunk$set(message = FALSE, warning = FALSE)

1. ¿Qué es un chronnet?

  • Una red criminal no es una foto: es una película. El mismo par de bancos puede operar juntos en marzo y no volver a hacerlo nunca.
  • Si solo preguntamos “¿quién está conectado con quién?” perdemos el orden de los eventos — y en lavado de activas el orden es la evidencia: el dinero sale de A, llega a B, se fracciona en C.
  • Un chronnet (chronological network) transforma una lista de eventos con lugar y fecha en una red donde una arista significa “después de operar aquí, la red operó allá”.
  • Cada evento \(e_a\) tiene su ubicación \(l_a = (x_a, y_a)\) y su instante \(t_a\), ordenados \(t_1 \le t_2 \le \dots \le t_{N_E}\).
  • Eventos aquí: las transacciones reportadas (las filas del dataset). Ubicaciones: las sedes de los bancos.
  • Se construye con transacciones, no con formularios SAR: un SAR agrupa muchas transacciones con rango de fechas propio (begin_date–end_date) — 534 eventos con fechas imprecisas no alcanzan la resolución de un día.
  • Cada fila tiene fecha exacta: por eso el conteo de eventos es 9.002 (dos por fila: originador y beneficiario) y no 534.

2. Paso 1 — Los vértices: las entidades financieras

  • El vértice es cada banco, posicionado en las coordenadas de su sede (data/bancos_geo.csv).
  • Solo los bancos que aparecen en al menos un reporte se convierten en vértices.
Code
bancos <- geo %>% select(bank_id, bank_name, country, iso, lat, lon)
cat("Vértices disponibles (entidades georreferenciadas):", nrow(bancos), "\n")
Vértices disponibles (entidades georreferenciadas): 2275 
Code
head(bancos, 5)
# A tibble: 5 × 6
  bank_id                     bank_name                country iso     lat   lon
  <chr>                       <chr>                    <chr>   <chr> <dbl> <dbl>
1 banco-de-credito-del-peru   Banco De Credito Del Pe… United… USA   -12.0 -77.0
2 banco-de-comercio           Banco De Comercio        Peru    PER   -12.0 -77.0
3 ukranian-post-international Ukranian Post Internati… United… USA    40.7 -74.0
4 rietumu-banka               Rietumu Banka            Latvia  LVA    56.9  24.1
5 city-national-bank          City National Bank       United… USA    40.7 -74.0
  • select() elige columnas; head() muestra las primeras filas.
  • El identificador estable es bank_id (slug en minúsculas), no el nombre — los nombres vienen con variantes.

3. Paso 2 — Los eventos: cada reporte genera dos

  • Cada fila del dataset es un flujo originador → beneficiario.
  • Lo convertimos en dos eventos: uno para el banco que envía y otro para el que recibe.
Code
eventos_dual <- bind_rows(
  tx %>% transmute(t = fecha, node = originator_bank_id, rol = "origen"),
  tx %>% transmute(t = fecha, node = beneficiary_bank_id, rol = "destino")
) %>% arrange(t)

cat("Eventos totales N_E =", nrow(eventos_dual), "\n")
Eventos totales N_E = 9002 
Code
head(eventos_dual, 5)
# A tibble: 5 × 3
  t          node                            rol    
  <date>     <chr>                           <chr>  
1 2000-02-08 banco-de-credito-del-peru       origen 
2 2000-02-08 banco-de-comercio-lima-peru-per destino
3 2000-02-29 banco-de-credito-del-peru       origen 
4 2000-02-29 banco-de-comercio-lima-peru-per destino
5 2000-03-07 banco-de-credito-del-peru       origen 
  • bind_rows() apila las dos tablas; transmute() crea columnas nuevas; arrange(t) ordena cronológicamente.
  • El orden temporal es el corazón del chronnet.

4. Paso 3 — Ventanas de tiempo: solo días activos

  • El período se divide en ventanas de \(\Delta t = 1\) día, y solo entran las ventanas activas (días con al menos un evento).
  • Si entre el 3 y el 10 de agosto no hubo reportes, el chronnet une directamente el 3 con el 10: un salto conecta dos días activos consecutivos, no dos días calendario.
  • Los huecos sin datos no rompen la secuencia.
  • Sea \(n_m^{(k)}\) el número de eventos de la entidad \(m\) en la ventana activa \(k\).
Code
conteo_ventanas <- eventos_dual %>%
  mutate(ventana = as.Date(t)) %>%
  count(ventana, node, name = "n") %>%
  arrange(ventana)

cat("Ventanas activas (días con al menos un evento):", n_distinct(conteo_ventanas$ventana), "\n")
Ventanas activas (días con al menos un evento): 1762 
Code
head(conteo_ventanas, 5)
# A tibble: 5 × 3
  ventana    node                                n
  <date>     <chr>                           <int>
1 2000-02-08 banco-de-comercio-lima-peru-per     1
2 2000-02-08 banco-de-credito-del-peru           1
3 2000-02-29 banco-de-comercio-lima-peru-per     1
4 2000-02-29 banco-de-credito-del-peru           1
5 2000-03-07 banco-de-comercio-lima-peru-per     1
  • count(ventana, node) = cuántos eventos tuvo cada banco en cada día.
  • De aquí salen los \(n_m^{(k)}\) de la fórmula.

5. Paso 4 — Aristas y pesos

  • Arista dirigida \(v_i \to v_j\) cuando la entidad \(i\) está activa en una ventana y la \(j\) en la siguiente.
  • El peso acumula el producto de sus actividades en todas las parejas de ventanas consecutivas:

\[w_{ij} = \sum_{k=1}^{K-1} n_i^{(k)} \cdot n_j^{(k+1)}\]

  • Léase con calma: si el banco \(i\) tuvo 3 eventos el lunes y el banco \(j\) tuvo 2 el martes, eso aporta \(3 \times 2 = 6\) al peso \(w_{ij}\).
  • Self-loops (\(w_{ii}\)): actividad sostenida en la misma entidad (opera dos días activos seguidos).
  • Caso límite (una transacción por ventana):

\[w_{ij} = \sum_{a=1}^{N_E-1} \mathbb{I}\bigl(\kappa(l_a)=i\bigr)\,\mathbb{I}\bigl(\kappa(l_{a+1})=j\bigr)\]

  • e identidad contable general:

\[\sum_{i,j} w_{ij} = \sum_{k=1}^{K-1} N^{(k)} N^{(k+1)}, \qquad N^{(k)} = \sum_m n_m^{(k)}\]

Code
# Función completa en R/03_chronnet.R (pipeline)
chronnet_create <- function(dataset, self_loops = TRUE, mode = "directed", dt_days = 1) {
  d <- dataset %>%
    mutate(ventana = floor(as.numeric(difftime(t, min(t, na.rm = TRUE),
                                               units = "days")) / dt_days)) %>%
    count(ventana, node, name = "n") %>%
    arrange(ventana)
  ventanas <- sort(unique(d$ventana))
  pesos <- list()
  for (k in seq_len(length(ventanas) - 1)) {
    a <- d %>% filter(ventana == ventanas[k])     %>% transmute(from = node, n_i = n)
    b <- d %>% filter(ventana == ventanas[k + 1]) %>% transmute(to = node, n_j = n)
    par <- crossing(a, b)          # parejas: entidades del día k × entidades del día k+1
    if (!self_loops) par <- par %>% filter(from != to)
    pesos[[k]] <- par %>% mutate(w = n_i * n_j) %>% select(from, to, w)
  }
  el <- bind_rows(pesos) %>%
    group_by(from, to) %>%
    summarise(weight = sum(w), .groups = "drop")
  g <- graph_from_data_frame(el, directed = (mode == "directed"))
  attr(g, "Nk") <- d %>% group_by(ventana) %>%
    summarise(N = sum(n), .groups = "drop") %>% pull(N)
  g
}
  • crossing(a, b) produce el producto cartesiano “quienes actuaron hoy” × “quienes actuaron mañana”.
  • Multiplicar n_i * n_j y sumar implementa la fórmula de \(w_{ij}\).

6. ¿Dirigido o no dirigido?

  • El chronnet es dirigido: la arista \(i \to j\) significa que la actividad de \(j\) sigue a la de \(i\), y no al revés.
  • La dirección es la señal temporal — no indica flujo de dinero.
  • En los módulos 3–4 algunas métricas (comunidades, transitividad, caminos) usan la versión no dirigida (colapsando A→B y B→A en una sola conexión) porque los algoritmos lo requieren — lo diremos siempre que ocurra.

7. Chequeo de reproducibilidad

  • La identidad contable fija el peso total desde los conteos de eventos solos.
  • Un error de agregación se detecta numéricamente, no a ojo.
Code
# Construcción inline (la función chronnet_create se definió arriba en este módulo;
# el pipeline equivalente vive en R/03_chronnet.R)
eventos_dual <- bind_rows(
  tx %>% transmute(t = fecha, node = originator_bank_id, rol = "origen"),
  tx %>% transmute(t = fecha, node = beneficiary_bank_id, rol = "destino")
) %>% arrange(t)
chronnet <- chronnet_create(eventos_dual, self_loops = TRUE, mode = "directed", dt_days = 1)
Nk <- attr(chronnet, "Nk")
identidad <- sum(Nk[-length(Nk)] * Nk[-1])
cat("sum(w_ij)             =", sum(E(chronnet)$weight), "\n")
sum(w_ij)             = 51944 
Code
cat("sum_k N^(k) * N^(k+1) =", identidad, "\n")
sum_k N^(k) * N^(k+1) = 51944 
Code
cat("¿Coinciden?", sum(E(chronnet)$weight) == identidad, "✓\n")
¿Coinciden? TRUE ✓

8. El resultado: agrupación y componentes

  • El chronnet sale conectado de un solo bloque: su regla une toda entidad activa en un día con toda entidad activa el día siguiente, así que el componente gigante (LCC) absorbe toda la red.
  • Lo comprobamos: cuántos componentes hay y de qué tamaño:
Code
comp_c <- components(chronnet)
tibble(
  componente = seq_len(comp_c$no),
  n_nodos = sort(comp_c$csize, decreasing = TRUE)
) %>%
  mutate(acumulado = cumsum(n_nodos) / sum(n_nodos)) %>%
  slice_head(n = 10) %>%
  knitr::kable(caption = "Los 10 componentes más grandes del chronnet",
               col.names = c("Ranking", "Nº de nodos", "% acumulado de nodos"))
Los 10 componentes más grandes del chronnet
Ranking Nº de nodos % acumulado de nodos
1 2275 1
Code
cat("Total:", comp_c$no, "componentes | LCC =", max(comp_c$csize), "nodos (",
    round(100 * max(comp_c$csize) / vcount(chronnet), 1), "% de la red )\n")
Total: 1 componentes | LCC = 2275 nodos ( 100 % de la red )

Aclaración de vocabulario:

  • LCC (largest connected component, componente gigante): el subgrafo conectado más grande de una red dada.
  • Aquí el LCC es el chronnet completo: la red de trabajo de todo el taller.
  • Ojo a la interpretación: esa conectividad total es propia de la construcción — dos bancos quedan unidos si operaron en días consecutivos, aunque jamás hayan transado entre sí.
  • El dinero no vive en las aristas del chronnet: se calcula desde los reportes y se pega como atributo (monto_total por banco, monto circulado por par), como veremos en los módulos 3 a 6.

9. Por qué no podar aristas

  • Conservamos TODAS las aristas, incluso las de peso 1.
  • Los datos criminales son caros de obtener: cada registro puede ser significativo.
  • Podar aristas débiles eliminaría registros infrecuentes pero cruciales.
  • Conservar todo mantiene los componentes completos.

10. El chronnet dibujado

Code
V(chronnet)$actividad <- strength(chronnet, mode = "all", weights = E(chronnet)$weight)
ggraph(chronnet, layout = "stress") +
  geom_edge_link(aes(width = weight), alpha = 0.15, color = "grey55") +
  geom_node_point(aes(size = actividad), color = "#0D9488", alpha = 0.8) +
  scale_size(range = c(0.5, 7)) +
  scale_edge_width(range = c(0.1, 2), guide = "none") +
  theme_graph() +
  theme(legend.position = "none")

Cómo leer esta figura:

  • Cada punto es un banco; su tamaño = su actividad (en cuántas sucesiones temporales participa).
  • Cada línea es una arista \(i \to j\); el grosor es \(w_{ij}\) (intensidad de la sucesión).
  • La mayoría forma una periferia con pocas conexiones; unos pocos nodos centrales concentran las sucesiones.

Lectura criminal

  • Las aristas fuertes trazan cadenas operativas: qué entidad “activa” a cuál en la secuencia temporal de los reportes SAR.
  • Si después de cada operación sospechosa en un banco de Dubai sigue sistemáticamente una en un banco de las Islas Vírgenes, el chronnet lo dibuja como una arista gruesa.
  • El grosor de la línea es la frecuencia de esa sucesión — y la frecuencia es la costumbre, y la costumbre es el patrón que la investigación busca.