Module 2 — Building the chronological network (chronnet)

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)

In module 1 we cleaned and explored the SAR reports. Now we turn them into a chronological network — the object every later module works on.

1. What is a chronnet?

  • A criminal network is not a photo: it is a movie. The same pair of banks may operate together in March and never again.
  • If we only ask “who is connected to whom?” we lose the order of events — and in money laundering the order is the evidence: money leaves A, reaches B, is split in C.
  • A chronnet (chronological network) turns a list of events with location and date into a network where an edge means “after operating here, the network operated there”.
  • Each event \(e_a\) has its location \(l_a = (x_a, y_a)\) and its time \(t_a\), ordered \(t_1 \le t_2 \le \dots \le t_{N_E}\).
  • Events here: the reported transactions (the dataset rows). Locations: the bank headquarters.
  • Built with transactions, not SAR forms: a SAR groups many transactions with its own date range (begin_date–end_date) — 534 events with imprecise dates would not give the one-day resolution.
  • Each row has its exact date: that is why the event count is 9,002 (two per row: originator and beneficiary) and not 534.

2. Step 1 — The vertices: the financial entities

  • The vertex is each bank, positioned at its headquarters coordinates (data/bancos_geo.csv).
  • Only banks that appear in at least one report become vertices.
Code
bancos <- geo %>% select(bank_id, bank_name, country, iso, lat, lon)
cat("Available vertices (georeferenced entities):", nrow(bancos), "\n")
Available vertices (georeferenced entities): 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() picks columns; head() shows the first rows.
  • The stable identifier is bank_id (a lowercase slug), not the name — names come with variants.

3. Step 2 — The events: each report generates two

  • Each dataset row is an originator → beneficiary flow.
  • We convert it into two events: one for the sending bank, one for the receiving bank.
Code
eventos_dual <- bind_rows(
  tx %>% transmute(t = fecha, node = originator_bank_id, rol = "origin"),
  tx %>% transmute(t = fecha, node = beneficiary_bank_id, rol = "destination")
) %>% arrange(t)

cat("Total events N_E =", nrow(eventos_dual), "\n")
Total events 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       origin     
2 2000-02-08 banco-de-comercio-lima-peru-per destination
3 2000-02-29 banco-de-credito-del-peru       origin     
4 2000-02-29 banco-de-comercio-lima-peru-per destination
5 2000-03-07 banco-de-credito-del-peru       origin     
  • bind_rows() stacks the two tables; transmute() creates new columns; arrange(t) sorts chronologically.
  • The time order is the heart of the chronnet.

4. Step 3 — Time windows: active days only

  • The period is divided into \(\Delta t = 1\) day windows, and only active windows enter (days with at least one event).
  • If no reports occurred between August 3 and 10, the chronnet connects the 3rd directly to the 10th: a hop joins two consecutive active days, not two consecutive calendar days.
  • Gaps in data do not break the sequence.
  • Let \(n_m^{(k)}\) be the number of events of entity \(m\) in active window \(k\).
Code
conteo_ventanas <- eventos_dual %>%
  mutate(ventana = as.Date(t)) %>%
  count(ventana, node, name = "n") %>%
  arrange(ventana)

cat("Active windows (days with at least one event):", n_distinct(conteo_ventanas$ventana), "\n")
Active windows (days with at least one event): 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) = how many events each bank had on each day.
  • From here come the \(n_m^{(k)}\) of the formula.

5. Step 4 — Edges and weights

  • A directed edge \(v_i \to v_j\) when entity \(i\) is active in one window and \(j\) in the next.
  • The weight accumulates the product of their activities across all consecutive window pairs:

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

  • Read it slowly: if bank \(i\) had 3 events on Monday and bank \(j\) had 2 on Tuesday, that contributes \(3 \times 2 = 6\) to the weight \(w_{ij}\).
  • Self-loops (\(w_{ii}\)): sustained activity within the same entity (active two consecutive active days).
  • Limiting case (one transaction per window):

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

  • and the general accounting identity:

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

Code
# Full function in 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)          # pairs: entities of day k × entities of day 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) produces the cartesian product “who acted today” × “who acted tomorrow”.
  • Multiplying n_i * n_j and summing implements the \(w_{ij}\) formula.

6. Directed or undirected?

  • The chronnet is directed: the edge \(i \to j\) means that the activity of \(j\) follows that of \(i\), not the reverse.
  • That direction is the temporal signal — it does not indicate a flow of money.
  • In modules 3–4 some metrics (communities, transitivity, paths) use the undirected version (collapsing A→B and B→A into a single connection) because the algorithms require it — we say so whenever it happens.

7. Reproducibility check

  • The accounting identity pins down the total weight from the event counts alone.
  • An aggregation error is caught numerically, not by eye.
Code
# Inline construction (chronnet_create was defined above in this module;
# the equivalent pipeline lives in R/03_chronnet.R)
eventos_dual <- bind_rows(
  tx %>% transmute(t = fecha, node = originator_bank_id, rol = "origin"),
  tx %>% transmute(t = fecha, node = beneficiary_bank_id, rol = "destination")
) %>% 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("Do they match?", sum(E(chronnet)$weight) == identidad, "✓\n")
Do they match? TRUE ✓

8. The result: grouping and components

  • The chronnet comes out connected as a single block: its rule joins every entity active on one day to every entity active on the next, so the largest connected component (LCC) absorbs the whole network.
  • We verify it: how many components, and of what size:
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 = "The 10 largest components of the chronnet",
               col.names = c("Rank", "Nº of nodes", "Cumulative % of nodes"))
The 10 largest components of the chronnet
Rank Nº of nodes Cumulative % of nodes
1 2275 1
Code
cat("Total:", comp_c$no, "components | LCC =", max(comp_c$csize), "nodes (",
    round(100 * max(comp_c$csize) / vcount(chronnet), 1), "% of the network)\n")
Total: 1 components | LCC = 2275 nodes ( 100 % of the network)

Vocabulary clarification:

  • LCC (largest connected component): the largest connected subgraph of a given network.
  • Here the LCC is the entire chronnet: the working network of the whole workshop.
  • Mind the interpretation: that full connectivity is an artifact of the construction — two banks are linked if they operated on consecutive days, even if they never transacted with each other.
  • Money does not live in the chronnet’s edges: it is computed from the reports and attached as an attribute (monto_total per bank, amount circulated per pair), as we will see in modules 3 to 6.

9. Why not prune edges

  • We keep ALL edges, even those with weight 1.
  • Criminal data is expensive to obtain: every record can be significant.
  • Pruning weak edges would remove infrequent but crucial records.
  • Keeping everything leaves the components complete.

10. The chronnet drawn

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")

How to read this figure:

  • Each dot is a bank; its size = its activity (how many temporal successions it participates in).
  • Each line is an edge \(i \to j\); thickness is \(w_{ij}\) (intensity of the succession).
  • Most nodes form a periphery with few connections; a handful of central nodes concentrate the successions.

Criminal reading

  • The strong edges trace operational chains: which entity “activates” which in the temporal sequence of the SAR reports.
  • If a suspicious operation at a bank in Dubai is systematically followed by one at a bank in the Virgin Islands, the chronnet draws it as a thick edge.
  • The line thickness is the frequency of that succession — and frequency is habit, and habit is the pattern an investigation looks for.