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):
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.
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:
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).
Self-loops allowed during rewiring — the observed chronnet contains them.
Each rewired edge keeps its weight: only the placement of the weights changes.
Metrics are computed on each replica, and the observed value is compared against that null distribution.
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):
The timeline is split into contiguous 7-day blocks.
Blocks are resampled with replacement until the horizon is covered (this keeps whole operational weeks: the weekly structure of laundering does not break).
The chronnet is rebuilt on the resampled sequence.
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 chronnetchron_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.