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.
# 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.
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
# 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 inseq_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+1if (!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.