Module 3 — Structural characterization

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)

In module 2 we built the chronnet. Now we measure its shape: how full it is, how many triangles it closes, whether activity comes back, and whether hubs dominate the network.

0. What we measure

  • All characterization runs on the chronnet, the workshop’s working network: 2,275 banks connected by temporal co-activity — the edge i→j means j was active on the active day after i, with weight equal to the product of their event counts.
  • Since it is a single connected piece, there are no islands to exclude: global metrics (paths, diameter, transitivity) run on the whole network.
  • The edge weight is not money, it is co-activity intensity. Money travels as an attribute computed from the reports (monto_total per bank, monto circulated per pair): we use it for sizes and readings, never as structural weight.
Code
g0 <- chronnet
g0u <- as_undirected(g0, mode = "collapse", edge.attr.comb = list(weight = "sum"))
cat("Chronnet:", vcount(g0), "nodes |", ecount(g0), "edges |",
    components(g0)$no, "component\n")
Chronnet: 2275 nodes | 31122 edges | 1 component

Vocabulary: collapsing to undirected = treating i→j and j→i as the same connection, summing weights (some algorithms require it).

1. Density: how full is the network

Density is the fraction of edges that exist out of all possible ones:

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

Code
cat("Density of the chronnet =", round(edge_density(g0), 5), "\n")
Density of the chronnet = 0.00602 

Explanation:

  • 0 = an edge-less graph; 1 = a graph where every bank connects to every other.
  • Mind the interpretation: in the chronnet, edges come from co-activity on consecutive days — a rule that connects far more than real flows.
  • The chronnet’s density measures how chained the activity is over time — not how full the transaction network is.

Criminal reading

  • A real money-laundering network must be hollow: many banks, few connections, long paths.
  • That hollowness is the protection: if one link falls, the rest of the network is not taken down with it.
  • The chronnet inherits a higher density because it connects by time, not by flow — which is why we measure the network’s shape here and money as a separate attribute.

2. Weighted transitivity — the “territory”

What fraction of potential bank triplets closes into triangles, weighted by co-activity intensity:

\[C^w_{global} = \frac{\sum_{i} (\text{weighted triangles around } i)}{\sum_{i} (\text{weighted triplets centered at } 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\) = vertex strength (co-activity intensity), \(k_i\) = its degree, \(a_{xy}=1\) if an edge exists between \(x\) and \(y\).

Explanation of global transitivity:

  • Imagine three banks A, B and C where A is co-active with B and B with C.
  • If A is also co-active with C, the triplet “closed” into a triangle.
  • Global transitivity is the percentage of triplets that close.
  • High = activity concentrated in closed circuits; low = activity in long chains without return.
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),
  Median = median(cw_local, na.rm = TRUE), Mean = 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 = "Local weighted clustering coefficient")
Local weighted clustering coefficient
Min Q1 Median Mean Q3 Max
0 0.1814125 0.2894737 0.3435673 0.4761905 1

Criminal reading

  • Triangles are consolidated temporal circuits: triplets of banks that operate one after another repeatedly — A passes activity to B, B to C and C back to A.
  • A strong triangle indicates sustained coordination: nobody operates out of the others’ sight.
  • A median near zero with higher mean and Q3 = a majority of temporally isolated banks + a cohesive core of direction.
  • That is exactly the structure of a professional organization.

3. Weighted reciprocity — the hierarchy

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

Explanation:

  • Compares how much co-activity is mutual (A precedes B as much as B precedes A) relative to the total.
  • Near 1 = a back-and-forth network (activity synchronized in both directions).
  • Near 0 = a one-directional network: activity flows in sequence, without feedback.
Code
W <- as_adjacency_matrix(g0, attr = "weight", sparse = FALSE)
R_w <- sum(pmin(W, t(W))) / sum(W)
cat("Reciprocity (unweighted) R =", round(reciprocity(g0), 4), "\n")
Reciprocity (unweighted) R = 0.242 
Code
cat("Weighted reciprocity R_w =", round(R_w, 4), "\n")
Weighted reciprocity R_w = 0.3651 

How the code works:

  • as_adjacency_matrix(g0, attr = "weight") builds the matrix \(W\) where \(W_{ij}\) = co-activity intensity from \(i\) to \(j\).
  • pmin(W, t(W)) takes the minimum against the transpose (the “returned” part of each sequence).
  • Dividing by the total gives \(R_w\).

Criminal reading

  • A low \(R_w\) indicates a unidirectional sequence: activity flows from coordinator to executor and does not return through the same channel — the signature of a chain of command.
  • Caution: a bank that appears early in the sequence is not necessarily aware of the illicit origin of what follows — it may be legitimate correspondent banking serving an unworthy client.
  • Low reciprocity marks the network’s shape (hierarchical), not the guilt of its nodes.

4. The network on the map: OSM background and edge toggle

  • World map with OpenStreetMap tiles; layer button (top right) to toggle the edges on and off.
  • Hover for tooltips: bank, country, money, transactions, coordinates and dates.
  • The orange lines connect pairs of banks between which money actually circulated according to the reports — the chronnet connects many more pairs by time, but here we only draw the real financial links.
Code
# id = bank slug (internal vertex name); nombre = display name
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 already carries from_lat/from_lon/to_lat/to_lon: no 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)

# Node color by country (extended palette for many countries)
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 = "Banks",
    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 = "Flows", color = "#B45309", weight = 1.2, opacity = 0.35,
               label = ~paste0(from_n, " → ", to_n, "<br>💰 ", dollar(monto), " reported")) %>%
  addLayersControl(overlayGroups = c("Banks", "Flows"),
                   options = layersControlOptions(collapsed = FALSE)) %>%
  setView(lng = 0, lat = 20, zoom = 2)

To pin the zoom:

  • The initial state is set by setView(lng, lat, zoom) (here: whole world, zoom 2).
  • If the browser reloads the widget when switching tabs, it returns to that initial state.
  • To keep a specific zoom: share the HTML (the state is baked in) or set another setView in the code.

Criminal reading

  • The map shows the geography of laundering: corridors between financial centers (London, New York, Hong Kong, Zurich) and pass-through jurisdictions (Caribbean, Channel Islands, Latvia, Cyprus).
  • The orange lines are the 500 largest flows — each a pair of banks between which a SAR documented money.
  • Point size follows the total money each bank touched.

5. Degree and strength distribution

  • Degree \(k_i\) = chronnet connections (banks sharing consecutive windows with it).
  • Strength \(s_i\) = total money the bank moved according to the reports (attribute, not a graph weight).
Code
knitr::include_graphics("../figuras/distribucion_grados.png")

Reading:

  • Most banks have few temporal connections; a few share windows with many others.
  • That asymmetry is the signature of a network with hubs of activity.

6. Power laws — is the network scale-free?

  • If the tail of the degree distribution follows \(P(k) \sim k^{-\gamma}\), there are hubs dominating connectivity.
  • Fit by maximum likelihood (poweRlaw), jointly estimating \(\gamma\) and the minimum degree \(x_{min}\) from which the law holds:
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(
  Degree = 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)),
  Median = c(median(d_in), median(d_out), median(d_all)),
  Mean = 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 = "Degree distribution of the chronnet")
Degree distribution of the chronnet
Degree Min. Q1 Median Mean 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

Criminal reading

  • γ ≈ 2.13 with \(x_{min}\) = 12.
  • 51.4% of banks exceed \(x_{min}\); 25.8% exceed \(2x_{min}\); only 16.1% exceed \(3x_{min}\).
  • That last group are the pivots: extreme temporal connectivity whose fall disrupts the network’s entire sequence.
  • In a scale-free network, targeting those few nodes is worth more than chasing hundreds of executors.

7. Network times: shortest paths

  • \(\ell_{i,j}\) = minimum number of hops between two vertices; \(\langle\ell\rangle\) and diameter \(D = \max_{i,j}\ell_{i,j}\) on the undirected chronnet.

Explanation:

  • How many temporal intermediaries separate any pair of banks in the network.
  • Short paths = one bank’s activity reaches another within days of relay.
  • Long paths = multi-day chains that give anonymity but multiply intervention points.
Code
cat("Average shortest path <l> =", round(mean_distance(g0u, directed = FALSE), 2), "hops\n")
Average shortest path <l> = 2.92 hops
Code
cat("Diameter D =", diameter(g0u, directed = FALSE), "hops\n")
Diameter D = 14 hops

Criminal reading

  • Each hop is a layer of temporal separation between whoever originates the activity and whoever receives it.
  • With few hops on average, the origin stays several banks away: enough to hinder tracing, not enough to slow the network.
  • The diameter marks the worst case: the longest temporal chain an investigation would have to reconstruct.