Module 4 — Communities and validation

In module 3 we measured the global structure. Now we split it into communities and validate that the split is neither chance nor a time artifact.

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. Community detection

  • Community: a group of vertices that connect a lot among themselves and little with the rest.
  • Modularity \(Q\): how many edges fall inside communities relative to what a random network with the same degrees would produce.
  • \(Q \to 1\) = crisp communities; \(Q \approx 0\) = no structure.

Interpretation:

  • In laundering networks, communities are operational circuits: groups of entities that operate at the same times, with redundancy and little dependence on the outside.
  • A circuit absorbs the fall of a member without stopping.
  • Knowing the circuits precedes deciding where to intervene.

We compare the leading algorithms on the chronnet (collapsed to undirected where the method requires it), solving the same problem from different angles (greedy clustering, diffusion, random walks, local optimization). Edge-betweenness is left out: it recomputes the betweenness of every edge after each merge — infeasible on the chronnet’s ~31k edges (and it never won on modularity):

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

Highlighted:

  • The ★ row has the highest modularity — the method that best separates the circuits.
  • Note: a very high modularity with hundreds of communities can mean over-partitioning (splitting circuits that actually operate together); that is why it is reported together with the number of communities.

Criminal reading

  • Each detected community is an investigative hypothesis: “these entities operate as a circuit”.
  • The bridges between communities are where activity changes circuits.
  • That is where an intervention cuts the most flow with the least effort (module 6).

2. Descriptive table by community

  • The full table (one row per community) cannot be read.
  • The right summary: size statistics + filterable interactive table of the largest ones:
Code
tam <- comunidad_df %>% count(comunidad, name = "n_bancos") %>% pull(n_bancos)
tibble(
  Statistic = c("Communities", "Banks in the largest community", "Median size", "Communities of 2-3 banks", "Banks in communities of 2-3"),
  Value = c(length(tam), max(tam), median(tam),
            sum(tam <= 3), sum(tam[tam <= 3]))
) %>% knitr::kable()
Statistic Value
Communities 10.0
Banks in the largest community 864.0
Median size 148.5
Communities of 2-3 banks 0.0
Banks in communities of 2-3 0.0

Reading:

  • Small communities (2-3 banks) = de facto pairs: they only share time windows among themselves and with almost nobody else — typically one correspondent bank and its recurring client.
  • Large communities = processing circuits with many participants.
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("Community", "Nº banks", "Total amount (USD)", "Main bank", "Main country"))

Temporal filters:

  • Detection uses the whole series (2000–2017); there is no “live” temporal filter inside the algorithm.
  • For periods — “did the structure change before and after 2012?” — the procedure is: filter tx by date → rebuild the chronnet (R/03_chronnet.R) → re-run R/05_comunidades.R per window.
  • Temporality enters through sample design, not the algorithm.

3. Dendrogram of the hierarchy

  • Fast Greedy iteratively merges communities; the tree shows at what height they join.
  • Merges at low height = twin communities.
  • Merges at great height = macro-circuits that only touch through specific bridges.
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 = "Merge height", main = "Community hierarchy (Fast Greedy)")

4. Are the structures real or chance? — Null model

  • Anyone can compute metrics; the serious question is: are they surprising?
  • A network with extreme hubs will have short paths and some modularity from pure degree heterogeneity.
  • The configuration null model compares what we observe against that minimal chance explanation:
  1. We generate \(N = 100\) random replicas of the chronnet, preserving exactly the in- and out-degrees of every vertex (double edge swap, \(5|E|\) times per replica).
  2. Self-loops allowed during rewiring — the observed chronnet contains them.
  3. Each rewired edge keeps its weight: only the placement of the weights changes.
  4. Metrics are computed on each replica, and the observed value is compared against that null distribution.

\[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}\]

  • The \(+1\) in numerator and denominator is the North et al. (2002) correction to never report \(p = 0\) with finite replicas.
Code
knitr::kable(test_null %>% mutate(across(c(observado, nulo_media, nulo_sd, z, p), ~ round(.x, 4))),
             col.names = c("Metric", "Observed", "Null mean", "Null SD", "z", "p"))
Metric Observed Null mean Null SD 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. null model",
       caption = "Dashed lines: ±1.96 thresholds (5% significance)") +
  theme_minimal()

Criminal reading

  • Metrics with \(|z|\) above 1.96 = intrinsic properties of the network — neither chance nor degree heterogeneity alone produce them.
  • Community organization and temporal separation (diameter, paths) are the real terrain for planning disruption.
  • If nothing had been significant, the conclusion would be the opposite: “we see no structure, only reporting noise” — and there would be no point intervening anywhere.

5. Is it stable over time? — Block bootstrap

Thread of this section:

  • We already know the network has structure (null model, section 4).
  • The question now: does that structure depend on the specific days the data was recorded in?
  • If shuffling time crumbled the network, everything above would be an artifact of the reporting calendar.
  • One day’s activity depends on the previous day’s (autocorrelation): bootstrapping independent events would be invalid.
  • We use a 7-day block bootstrap with \(B = 50\) replicas (demo parameter; the paper uses 200):
  1. The timeline is split into contiguous 7-day blocks.
  2. Blocks are resampled with replacement until the horizon is covered (this keeps whole operational weeks: the weekly structure of laundering does not break).
  3. The chronnet is rebuilt on the resampled sequence.
  4. Metrics are recomputed.
  • A metric is stable if the observed value falls inside the 95% bootstrap CI — i.e., it does not depend on which specific days were intercepted.
  • Bootstrap and observed metrics run on the same object (the chronnet), so the comparison is direct.
Code
# The bootstrap rebuilds the chronnet: observed values come from the real chronnet
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, "✓ stable", "✗ outside")) %>%
  knitr::kable(digits = 3, col.names = c("Metric", "Median", "CI 2.5%", "CI 97.5%", "IQR", "Observed", "Stability"))
Metric Median CI 2.5% CI 97.5% IQR Observed Stability
V 1701.500 1611.025 1792.300 65.500 2275.000 ✗ outside
E 20401.000 18208.825 21982.200 1327.500 31122.000 ✗ outside
transitivity_w 0.123 0.118 0.127 0.005 0.118 ✓ stable
reciprocity 0.230 0.219 0.243 0.008 0.242 ✓ stable
modularity 0.526 0.481 0.576 0.031 0.473 ✗ outside
diameter 73.000 39.350 177.125 39.500 14.000 ✗ outside
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 = "Bootstrap distribution (B = 50, 7-day blocks)", y = "Frequency",
       caption = "Orange line: observed value in the full network") +
  theme_minimal()

Expected detail:

  • \(|V|\) and \(|E|\) fall outside the CI — resampling days reduces the diversity of banks.
  • So does the diameter: resamples chain repeated blocks and lengthen the temporal chains.
  • Transitivity and reciprocity stay inside: the network’s local shape does not depend on the reporting calendar.
  • The observed modularity sits just below the CI — resamples concentrate co-activity in fewer banks, which inflates \(Q\).
  • Joint reading: the local structure is stable; the global sizes and the scale of the hierarchy are not.

6. The network colored by community

What we are seeing:

  • The full chronnet; each color = a community from section 1.
  • Point size follows money (monto_total of the bank).
  • Deliberately no legend with hundreds of colors: what matters is not which community number each one is, but how many there are and how separated they are.
  • Crisp color blobs = the circuit division is real; mixed colors = one diffuse circuit.
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. Geography × community in 3D

What we are seeing:

  • Banks projected on their real coordinates (lat/lon) and lifted by community.
  • It combines the two previous views — graph (section 6) and map — into one.
  • Hover over the nodes to see their data.
Code
library(plotly); library(htmlwidgets)
readRDS("../../outputs/widgets/red_3d.rds")

Criminal reading

  • Communities that are neighbors in the graph but far apart on the map = transnational coordination (a circuit operating at a distance).
  • Communities overlapping on the map but separate in the graph = rival circuits sharing a city — competitors that do not touch.
  • Both patterns are actionable intelligence.