Module 1 — Data and exploratory analysis

Code
library(readr); library(dplyr); library(ggplot2); library(lubridate); library(scales); library(DT); library(tidyr); library(igraph)
tx <- read_csv("../../outputs/tx_limpio.csv", show_col_types = FALSE)
resumen <- read_csv("../../outputs/resumen_datos.csv", show_col_types = FALSE)
chronnet <- readRDS("../../outputs/chronnet.rds")
knitr::opts_chunk$set(message = FALSE, warning = FALSE)

1. What is this data?

  • SAR (Suspicious Activity Report): a report of a suspicious operation filed by banks with FinCEN (the U.S. financial intelligence unit) and leaked by the ICIJ in the FinCEN Files (2020).
  • Each row = a flow that a bank judged suspicious between an originating bank and a beneficiary bank, with dates and amounts.

⚠️ Suspicion is not crime: a bank’s presence in this data means it reported (or appeared in) movements that triggered its anti-money-laundering controls, not that criminal participation was proven. The network shows where suspicious activity was detected, with the reporting bias that implies.

2. Vocabulary

  • SAR: suspicious activity report. A single SAR (a single form) can contain several rows of transactions.
  • Originator: bank that sends the money. Beneficiary: bank that receives it.
  • Chronnet: the working network of the whole workshop (module 2). Node = bank; directed edge = co-activity on consecutive days (weight = product of event counts). Money does not live in the edges: it is computed from the reports and attached as an attribute (monto_total per bank, amount circulated per pair).

3. EDA — how much data and of what type?

Basic counts. Mind the difference between rows and SARs:

Code
tibble(
  Concept = c("Rows (reported transactions)", "Unique SARs (forms)",
              "Distinct banks", "Distinct countries",
              "Total reported amount (USD)"),
  Value = c(nrow(tx), n_distinct(tx$icij_sar_id),
            n_distinct(c(tx$originator_bank_id, tx$beneficiary_bank_id)),
            n_distinct(c(tx$originator_iso, tx$beneficiary_iso), na.rm = TRUE),
            dollar(sum(tx$amount_transactions, na.rm = TRUE)))
) %>% knitr::kable()
Concept Value
Rows (reported transactions) 4501
Unique SARs (forms) 534
Distinct banks 2275
Distinct countries 129
Total reported amount (USD) $35,620,551,620

Reading:

  • n_filas ≠ n_sars: one SAR form lists many transactions — 4,501 rows but only 534 forms (~8 transactions per SAR).
  • When counting “cases” we must say whether we mean transactions or forms — they are different things.
  • Variable types: dates (begin_date, end_date), categoricals (banks, countries, ISO codes) and numerics (number_transactions, amount_transactions).
  • id is just a row counter; the real entity identifier is the *_bank_id.

4. EDA — Top banks by reported amount (as originators)

Interactive table with column filters (boxes under the headers). Top 50:

Code
tx %>%
  group_by(originator_bank, originator_bank_country) %>%
  summarise(
    amount = sum(amount_transactions, na.rm = TRUE),
    n_transactions = sum(number_transactions, na.rm = TRUE),
    n_reports = n(),
    .groups = "drop"
  ) %>%
  arrange(desc(amount)) %>%
  slice_head(n = 50) %>%
  mutate(amount = dollar(amount)) %>%
  datatable(filter = "top", options = list(pageLength = 10),
            colnames = c("Originating bank", "Country", "Reported amount", "Nº transactions", "Nº SAR reports"))

Criminal reading

  • The top originators are where the reported-as-suspicious money leaves.
  • Fractionation pattern: many reports of small amounts (movements below reporting thresholds).
  • Consolidation pattern: few reports, very large amounts (money is pooled before moving on).
  • Neither pattern alone implies illegality — both also occur in legitimate commerce.
  • What an investigation prioritizes: the combination of both extremes in the same network.

5. EDA — Top countries in the flows

  • Each country sums the amount where it appears as originator or beneficiary.
  • Labels show amount | number of transactions:
Code
paises <- bind_rows(
  tx %>% transmute(pais = originator_bank_country, monto = amount_transactions, n = number_transactions),
  tx %>% transmute(pais = beneficiary_bank_country, monto = amount_transactions, n = number_transactions)
) %>%
  group_by(pais) %>%
  summarise(monto = sum(monto, na.rm = TRUE),
            n_transacciones = sum(n, na.rm = TRUE),
            .groups = "drop") %>%
  mutate(pais = ifelse(is.na(pais), "(No country recorded)", pais)) %>%
  slice_max(monto, n = 15)

ggplot(paises, aes(x = reorder(pais, monto), y = monto / 1e9)) +
  geom_col(fill = "#0D9488") +
  geom_text(aes(label = paste0(round(monto / 1e9, 1), " B | ", scales::comma(n_transacciones), " tx")),
            hjust = -0.05, size = 3, color = "#334155") +
  coord_flip(clip = "off") +
  scale_y_continuous(expand = expansion(mult = c(0, 0.25))) +
  labs(x = "", y = "Reported amount (billions USD)") +
  theme_minimal()

5.1 The country NAs: what they are and how we treat them

Code
n_sin <- sum(is.na(tx$originator_bank_country) | is.na(tx$beneficiary_bank_country))
n_bancos_sin <- tx %>%
  filter(is.na(tx$originator_bank_country) | is.na(tx$beneficiary_bank_country)) %>%
  summarise(n = n_distinct(c(originator_bank_id, beneficiary_bank_id), na.rm = TRUE)) %>% pull(n)
cat("Rows with at least one end lacking a country:", n_sin, "(", round(100*n_sin/nrow(tx),1), "%)\n")
Rows with at least one end lacking a country: 0 ( 0 %)
Code
cat("Distinct affected banks:", n_bancos_sin, "\n")
Distinct affected banks: 0 

Reading:

  • Origin of the NA: the country field was empty in the ICIJ’s original file.
  • Dropping them is not an option: those banks would leave the network along with all their connections, artificially fragmenting it.
  • Strategy: keep them connected — the node enters the graph with its bank_id and name; only the country attribute stays NA.
  • Consequence: they do not show on maps (no coordinates), but they count normally for degree, centrality and communities in the network.
  • Maintenance: when an analyst identifies the real country, data/bancos_geo.csv is updated and the node appears on the map on the next run.

Criminal reading

  • A bank with no recorded country can be an entity whose jurisdiction the filer did not know (or chose not) to identify.
  • These “stateless” nodes are often the most interesting: they operate in the network but off the map.
  • They are kept in the graph and investigated separately.

6. EDA — Distribution of amounts per record

Log scale on X to see the whole tail; Y complete up to the maximum frequency:

Code
ggplot(tx, aes(x = amount_transactions)) +
  geom_histogram(bins = 60, fill = "#D4883A") +
  scale_x_log10(labels = dollar) +
  labs(x = "Amount per record (USD, log scale)", y = "Frequency") +
  theme_minimal()

How to read it:

  • Most reports pile up on the left (thousands or millions of dollars); a long tail on the right reaches tens of billions.
  • Heavy right tail = the typical laundering pattern: many small movements (fractionation, smurfing) and few giant ones (consolidation).
  • If it were symmetric (bell-shaped), we would suspect the data is truncated or censored.

Criminal reading

  • The right tail holds the flows an analyst wants to see first: amounts so large that only a handful of banks in the world can move them routinely.
  • The left mountain is the small movements that, together, move the same volume.
  • The curve is the picture of the phenomenon: many small movements, few large ones.

7. EDA — Timeline by year-quarter

  • Bars: number of SAR reports.
  • Line: total amount of the quarter (right axis):
Code
tl <- tx %>%
  mutate(trimestre = floor_date(fecha, "quarter")) %>%
  group_by(trimestre) %>%
  summarise(n = n(), monto_bn = sum(amount_transactions, na.rm = TRUE) / 1e9, .groups = "drop")
factor_escala <- max(tl$n) / max(tl$monto_bn)

ggplot(tl, aes(trimestre)) +
  geom_col(aes(y = n), fill = "#0D9488", alpha = 0.85) +
  geom_line(aes(y = monto_bn * factor_escala), color = "#D4883A", linewidth = 1) +
  scale_y_continuous(name = "Nº SAR reports",
    sec.axis = sec_axis(~ . / factor_escala, name = "Amount (billions USD)")) +
  labs(x = "", caption = "Bars: number of reports | Line: total quarterly amount") +
  theme_minimal()