From eb6fa7fda124942b951acffb6658e0816eae0863 Mon Sep 17 00:00:00 2001 From: B-Deprez Date: Fri, 18 Sep 2026 14:40:14 +0200 Subject: [PATCH] Phase 4: public API, synthetic generator and documentation site --- .github/workflows/ci.yml | 4 +- .pre-commit-config.yaml | 3 +- CHANGELOG.md | 11 ++ docs/guide/alerts.md | 58 ++++++++ docs/guide/data.md | 60 +++++++++ docs/guide/features.md | 68 ++++++++++ docs/guide/interpreting.md | 75 +++++++++++ docs/guide/preprocessing.md | 74 +++++++++++ docs/how-it-works.md | 85 ++++++++++++ docs/index.md | 61 ++++++--- docs/install.md | 34 +++++ docs/limitations.md | 65 +++++++++ docs/quickstart.md | 69 ++++++++++ docs/reference/index.md | 35 +++++ docs/reproducing.md | 40 ++++++ docs/scaling.md | 80 +++++++++++ mkdocs.yml | 24 +++- pyproject.toml | 16 ++- src/garg_aml/__init__.py | 64 ++++++++- src/garg_aml/api.py | 257 ++++++++++++++++++++++++++++++++++++ src/garg_aml/estimator.py | 101 ++++++++++++++ src/garg_aml/measures.py | 18 ++- src/garg_aml/scores.py | 4 +- src/garg_aml/synthetic.py | 131 ++++++++++++++++++ tests/test_api.py | 120 +++++++++++++++++ tests/test_estimator.py | 47 +++++++ tests/test_synthetic.py | 64 +++++++++ 27 files changed, 1635 insertions(+), 33 deletions(-) create mode 100644 docs/guide/alerts.md create mode 100644 docs/guide/data.md create mode 100644 docs/guide/features.md create mode 100644 docs/guide/interpreting.md create mode 100644 docs/guide/preprocessing.md create mode 100644 docs/how-it-works.md create mode 100644 docs/install.md create mode 100644 docs/limitations.md create mode 100644 docs/quickstart.md create mode 100644 docs/reference/index.md create mode 100644 docs/reproducing.md create mode 100644 docs/scaling.md create mode 100644 src/garg_aml/api.py create mode 100644 src/garg_aml/estimator.py create mode 100644 src/garg_aml/synthetic.py create mode 100644 tests/test_api.py create mode 100644 tests/test_estimator.py create mode 100644 tests/test_synthetic.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 531cd33..d909389 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" - - run: pip install -e ".[dev]" + - run: pip install -e ".[dev,sklearn,parallel,progress]" - run: ruff check . - run: ruff format --check . - run: mypy @@ -33,5 +33,5 @@ jobs: - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - run: pip install -e ".[dev]" + - run: pip install -e ".[dev,sklearn,parallel,progress]" - run: pytest diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3cc79e2..5577b48 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.14.2 + # Keep in step with the ruff constraint in pyproject.toml's dev extra. + rev: v0.16.8 hooks: - id: ruff-check args: [--fix] diff --git a/CHANGELOG.md b/CHANGELOG.md index 10b2312..9733ee8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,17 @@ is `0.x` the public API may change with a minor bump, always with an entry here. latter needs the research repository present and is removed once the extraction is complete. +- Public API: `score`, `score_edges`, `block_measures_frame`, and the + `GargAmlScorer` scikit-learn wrapper (optional, loaded on first use so that + scikit-learn stays out of the import path). `__all__` now lists exactly what + is supported. +- `smurfing_graph`, a networkx generator of graphs with known patterns, so the + documentation and tests run with no data download. +- `n_jobs` on the scoring entry points, and `progress` for a progress bar. +- A documentation site: quickstart, how it works, five guide pages, scaling, + limitations, API reference and the decision log. Every `>>>` example in the + docs is executed by the test suite. + ### Changed - PEP 8 names throughout, with the two per-node measure functions merged into diff --git a/docs/guide/alerts.md b/docs/guide/alerts.md new file mode 100644 index 0000000..3ee0169 --- /dev/null +++ b/docs/guide/alerts.md @@ -0,0 +1,58 @@ +# Turning scores into alerts + +A score per account is not an alert list. Two decisions turn one into the other, +and both are yours. + +## Threshold or top-K? + +**Top-K** — take the K highest-scoring accounts — matches how alerting actually +works: a team can review a fixed number of cases per day, and K is that number. +It is also what the evaluation metrics in the paper are built around. + +**A fixed threshold** — everything above 0.7, say — is tempting but brittle. The +score distribution shifts with the graph, the preprocessing and the period, so +a threshold tuned in March is a different alert volume in June. + +Start with top-K sized to your review capacity. Read +[the note on ties](interpreting.md#ties) first — at the top of the distribution +they are common enough to make a naive top-K partly arbitrary. + +## The imbalance + +Laundering labels are extremely rare: **under 0.1%** of accounts in the paper's +datasets. Three consequences worth internalising before reading any metric: + +- **Accuracy is meaningless.** Flagging nothing scores over 99.9%. +- **A "low" precision may be excellent.** At a 0.1% base rate, precision of 5% + is a fifty-fold lift over random review. +- **AUC-ROC flatters everything.** With this much imbalance it is dominated by + the easy negatives. Prefer precision@K, recall@K, and average precision. + +## Measuring + +If you have labels, measure at the K you will actually use: + +```python +scores = ga.score(graph)["GARGAML"] +ranked = scores.sort_values(ascending=False, kind="stable") + +k = 100 +flagged = ranked.head(k).index +hits = int(labels.reindex(flagged).sum()) + +print(f"precision@{k}: {hits / k:.1%}") +print(f"recall@{k}: {hits / labels.sum():.1%}") +print(f"lift@{k}: {(hits / k) / labels.mean():.1f}x") +``` + +Lift is the honest headline: how much better than reviewing accounts at random. + +## Using it alongside what you have + +GARG-AML is a structural signal, not a complete system. It is at its most useful +as one feature among several, not as a standalone alert generator — a high score +plus an unusual amount profile is far more actionable than either alone. See +[using scores as features](features.md). + +The paper positions it the same way: the score is strong on its own, and +stronger when a model combines it with neighbourhood statistics. diff --git a/docs/guide/data.md b/docs/guide/data.md new file mode 100644 index 0000000..507d22a --- /dev/null +++ b/docs/guide/data.md @@ -0,0 +1,60 @@ +# Preparing your data + +## What GARG-AML reads + +Two columns: who paid, and who was paid. That is all. + +Amounts, timestamps, currencies and transaction counts are **not** used. The +method reads the shape of the network. Repeated transactions between the same +pair of accounts collapse to a single edge, and self-transfers are dropped. + +This is a real limitation as well as a strength — see +[Limitations](../limitations.md). + +## From a table + +```python +scores = ga.score_edges(transactions, "payer_account", "payee_account") +``` + +## From a graph + +If you already build a networkx graph, pass it directly: + +```python +scores = ga.score(graph) +``` + +Node ids come back exactly as you supplied them — strings, integers, tuples — +as the index of the returned frame, so you can join the scores straight back +onto your own tables. + +## What counts as an account + +Whatever you make a node. That choice matters more than any parameter in this +package: + +- **One node per account** is the usual choice and what the paper evaluates. +- **One node per customer**, merging their accounts, will find schemes that + spread across accounts of the same person — and will hide schemes that use + several accounts of one customer as the mules. +- **One node per bank** is too coarse; the pattern disappears into aggregate. + +## Directed or undirected? + +Both are supported. `score()` follows the graph you give it: a `DiGraph` gets +the directed analysis, a `Graph` the undirected one. + +Start with **undirected**. In the paper's experiments the undirected score +outperforms the directed one, which is counter-intuitive given that +one-directional flow is definitional for smurfing. The directed variant is +stricter about which accounts count as senders and receivers, and that +strictness appears to cost more than the extra information gains. + +If you have direction, it is still worth trying both on your own data. + +## Scale + +Nothing here needs the whole graph in one piece conceptually, but networkx does +hold it in memory. For rough numbers and what to do about a graph that does not +fit, see [Scaling](../scaling.md). diff --git a/docs/guide/features.md b/docs/guide/features.md new file mode 100644 index 0000000..0ca9430 --- /dev/null +++ b/docs/guide/features.md @@ -0,0 +1,68 @@ +# Using scores as features + +The score is one number per account. The paper's stronger results come from +feeding it, plus a summary of the neighbourhood around it, into an ordinary +classifier. + +## The feature table + +```python +scores = ga.score(graph) +features = ga.build_features(graph, scores) +``` + +Ten columns: + +| Column | Meaning | +|---|---| +| `GARGAML` | the account's own score | +| `GARGAML_min/mean/max/std` | the same over its direct counterparties | +| `degree` | its number of counterparties | +| `degree_min/mean/max/std` | the same over its counterparties | + +The neighbourhood statistics are what let a model distinguish an account that is +*in* a pattern from one that merely *touches* one. A mule sits among other +high-scoring accounts; a legitimate business that happens to pay one mule does +not. + +!!! warning "Use the same graph" + Pass `build_features` the graph the scores were computed on. If you scored a + reduced graph, the degrees must come from the reduced graph too, or the + features describe two different networks. + +## With scikit-learn + +```python +from garg_aml import GargAmlScorer + +features = GargAmlScorer(reduce=True, resolution=10).fit_transform(graph) +``` + +Needs the `sklearn` extra. Nothing is learned — GARG-AML is closed-form, so +`fit` has no parameters to estimate. The estimator exists so the step composes +in a pipeline, not because there is a model inside it. + +## A caution on evaluation + +Training a classifier on these features is **transductive**: every account was +scored as part of one graph, so a train/test split on the feature table does not +separate the test accounts from the training ones structurally — they were +neighbours when the scores were computed. + +That is not wrong, but it is not the same as a held-out evaluation, and a +split-on-the-feature-table number will be optimistic relative to scoring a +genuinely unseen period. Say which one you are reporting. + +## Building your own + +The pieces are public if the ten columns are not what you want: + +```python +from garg_aml import neighbour_score_stats, neighbour_degree_stats + +stats = neighbour_score_stats(graph, "account", scores["GARGAML"].to_dict()) +``` + +And the raw block densities are available through +`ga.score(graph, return_measures=True)` if you would rather let a model see the +components than the aggregated score. diff --git a/docs/guide/interpreting.md b/docs/guide/interpreting.md new file mode 100644 index 0000000..39c747f --- /dev/null +++ b/docs/guide/interpreting.md @@ -0,0 +1,75 @@ +# Interpreting a score + +## The range + +| Score | Structure | +|---|---| +| **1** | a textbook pattern: on-diagonal blocks empty, off-diagonal full | +| **~0.5** | clearly smurfing-shaped, with some background activity mixed in | +| **~0** | ordinary: a group that trades among itself scores here | +| **-1** | no neighbourhood at all — nothing to measure | + +The score is a **structural resemblance, not a probability**. 0.8 does not mean +80% likely to be laundering. It means this account's neighbourhood looks 80% of +the way towards the idealised pattern, on a scale where a clique sits near 0. + +## Scores are comparable within a run, not across runs + +Two things change the scale underneath you: the preprocessing +([`reduce_graph`](preprocessing.md) changes which neighbourhoods exist) and the +`score_type`. Comparing a score computed on a reduced graph against one computed +on the full graph is meaningless. Fix both before comparing anything. + +## Everyone in the pattern scores high + +A mule's neighbourhood has the same block structure as the source's: its +counterparties (source and target) do not deal with each other. So the mules +score as high as the account that organised the scheme, sometimes higher. + +Read a high score as **"this account sits in a smurfing-shaped subgraph"**, and +expect to investigate the subgraph rather than the single account. Pull the +account's second-order neighbourhood and look at it. + +## Ties + +**The score ties heavily**, especially at the top. Many accounts land on exactly +1.0 — in the paper's bank-level analysis, 522 of one institution's 2,639 +customers shared exactly 1.0. + +This matters the moment you rank. `sort_values().head(50)` will silently break +those ties by whatever order the frame happens to be in, which is an artefact of +your data loading, not a signal. If you take a top-K, check how many accounts +sit at the boundary score first: + +```python +scores = ga.score(graph)["GARGAML"] +cutoff = scores.nlargest(50).min() +print((scores == cutoff).sum(), "accounts tied at the cut-off") +``` + +If that number is large, the top-50 is not 50 accounts — it is an arbitrary +50 drawn from a bigger tied set. Break the tie on something you trust +(transaction volume, exposure, customer risk rating) rather than on sort order. + +## Why did this account score high? + +Ask for the block measures: + +```python +detailed = ga.score(graph, return_measures=True) +detailed.loc["account_of_interest"] +``` + +`measure_2` is the density of the off-diagonal block — how completely the +counterparties connect through. `measure_1` and `measure_3` are the on-diagonal +blocks that should be empty. A high score with a tiny `size_2` means the +pattern is real but tiny, and probably not interesting. + +That decomposition is the point of the method: the score is always reducible to +a handful of interpretable densities. + +## What a low score does not mean + +A low score is not evidence of legitimacy. It means *this particular structure* +is absent. Laundering that does not route through mules — cash, trade +mis-invoicing, a single large transfer — has no reason to score high. diff --git a/docs/guide/preprocessing.md b/docs/guide/preprocessing.md new file mode 100644 index 0000000..5a43845 --- /dev/null +++ b/docs/guide/preprocessing.md @@ -0,0 +1,74 @@ +# Preprocessing + +Two optional steps. Neither is applied unless you ask. + +## Community reduction + +```python +reduced = ga.reduce_graph(graph, resolution=10) +scores = ga.score(reduced) + +# or, equivalently +scores = ga.score(graph, reduce=True, resolution=10) +``` + +`reduce_graph` partitions the graph with Louvain and **keeps only the edges +whose endpoints are in the same community**. This is Algorithm 1 of the paper, +and it is how the published results were produced. + +!!! warning "This is lossy, and the loss is large" + On the paper's IBM dataset this removes the large majority of edges. It does + not merely thin the graph — it changes which second-order neighbourhoods + exist at all, and therefore which patterns *can* be found. + +Why do it anyway? Cost. Scoring is driven by neighbourhood size, and in a dense +transaction network a handful of hub-adjacent accounts have enormous +neighbourhoods. Cutting between-community edges makes those tractable. + +### Choosing a resolution + +Higher resolution means smaller communities and so more edges cut. + +```pycon +>>> import networkx as nx +>>> import garg_aml as ga +>>> graph = nx.disjoint_union(nx.complete_graph(4), nx.complete_graph(4)) +>>> graph.add_edge(0, 4) +>>> ga.reduce_graph(graph, resolution=1).number_of_edges() +12 +>>> ga.reduce_graph(graph, resolution=10).number_of_edges() +0 + +``` + +Two cliques joined by one edge. At resolution 1 the bridge is cut and both +cliques survive. At the published resolution of 10 the communities are smaller +than a clique, so **every** edge goes and all eight accounts are left isolated. + +Nothing is dropped — isolated accounts remain in the output and score -1 — but +they can no longer be scored meaningfully. If a large share of your accounts +come back at -1, the resolution is too high for your data. + +The default is 10 because that is what the paper used. It is not a +recommendation for your network. Sweep it and look at how many accounts survive +with a neighbourhood. + +## Hub removal + +```python +pruned = ga.drop_hubs(graph, quantile=0.01) +``` + +Removes the highest-degree accounts — payment processors, exchanges, salary +accounts — which have huge neighbourhoods and are rarely what you are looking +for. Not part of the published pipeline, but useful when a few accounts +dominate the runtime. + +Note that accounts at the threshold degree are all removed, so ties can push the +number removed above the nominal fraction. + +## Reproducibility + +Both steps are deterministic given a seed. `reduce_graph` takes `seed=1997` by +default — the value used throughout the paper — because Louvain is otherwise +not reproducible run to run. Keep it fixed when comparing runs. diff --git a/docs/how-it-works.md b/docs/how-it-works.md new file mode 100644 index 0000000..ee1ee73 --- /dev/null +++ b/docs/how-it-works.md @@ -0,0 +1,85 @@ +# How it works + +## The pattern + +In a pure smurfing scheme, one account moves money to another through +intermediaries. The source pays each mule; each mule pays the target. The source +and target never transact directly, and the mules have no reason to deal with +each other. + +That absence is the signal. Look at the second-order neighbourhood of the source +— itself, its direct counterparties (the mules), and everything two steps away +(the target) — and order the accounts as: + +``` +[ source and its second-order neighbours | its direct counterparties ] +``` + +The adjacency matrix then splits into blocks: + +| | source + 2nd order | 1st order | +|---|---|---| +| **source + 2nd order** | empty | full | +| **1st order** | full | empty | + +The **on-diagonal** blocks are empty: the source does not pay the target, and +the mules do not pay each other. The **off-diagonal** blocks are full: everyone +in the first group deals with everyone in the second. + +Ordinary banking looks nothing like this. A group of businesses that trade with +one another fills the on-diagonal blocks. A retail account with unrelated +counterparties fills neither. + +## The score + +GARG-AML measures each block's density over its *free* entries — excluding what +is structurally fixed, like the diagonal and the account's own edges — and takes +the contrast between the blocks that should be full and the blocks that should +be empty. + +**Undirected** (paper §3.2, Eq. 8), with `block2` the off-diagonal block and +`block1`, `block3` the two on-diagonal ones: + +``` +score = density(block2) − weighted_mean(density(block1), density(block3)) +``` + +weighted by the number of free entries in each block. Range **[-1, 1]**. + +**Directed** (§3.3, Eq. 14). Money flowing one way is definitional for smurfing, +so the second-order neighbours split by role: **level 0** senders, **level 1** +mules, **level 2** receivers. An account two steps away sits at level 0 when no +directed path of length two reaches it in either direction. That gives a 3×3 +grid of blocks, of which two should be dense: + +``` +score = mean(score01, score12) − mean(the other seven) +``` + +## Two stages + +Scoring happens in two steps, and they are worth keeping apart: + +```python +measures = ga.block_measures_frame(graph) # expensive: touches the graph +scores = ga.scores_from_measures(measures) # cheap: pure arithmetic +``` + +The measures are the per-account block densities and sizes. Computing them is +the whole cost; deriving a score from them is arithmetic. Persist the measures +and you can change the aggregation, compare score variants, or build features +without touching the graph again. `ga.score()` is just these two composed. + +## Why it scales + +Each account's score depends only on its own second-order neighbourhood. There +is no global optimisation, no matrix factorisation, and no iteration to +convergence — so accounts can be scored independently and in parallel, and +memory is bounded by the largest neighbourhood rather than by the graph. See +[Scaling](scaling.md). + +## Reading further + +The paper is [arXiv:2506.04292](https://arxiv.org/abs/2506.04292); §3 has the +full derivation, the structural corrections applied to each block, and worked +examples in Appendix A. This page is the intuition, not a substitute. diff --git a/docs/index.md b/docs/index.md index 0d57617..3d58dfd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,26 +1,53 @@ # garg-aml -Graph-based detection of **smurfing** patterns in transaction networks. +Find **smurfing** in a transaction network: one score per account, computed from +local structure, with nothing to train and no labels required. -!!! warning "Pre-release" - The scoring core is being extracted from the - [research repository](https://github.com/B-Deprez/GARG-AML). The public API - and the rest of this documentation land in 0.1.0. +```bash +pip install garg-aml +``` -## What it does +```python +import garg_aml as ga + +scores = ga.score(graph) # a networkx graph of accounts and transactions +scores.sort_values("GARGAML", ascending=False).head(20) +``` + +## What it looks for Smurfing moves money from one account to another through intermediate mules, so -that source and target never transact directly. Order the nodes of an account's -second-order neighbourhood as `[account + 2nd-order neighbours | 1st-order -neighbours]` and the adjacency matrix splits into blocks: for a pure smurfing -pattern the on-diagonal blocks are empty and the off-diagonal blocks are dense. +that the source and the target never transact directly. Order the accounts in +someone's second-order neighbourhood as *[them and their second-order +neighbours | their direct counterparties]* and the adjacency matrix falls into +blocks. For a pure smurfing pattern the on-diagonal blocks are empty — the +mules do not deal with each other — and the off-diagonal blocks are full. + +GARG-AML scores that contrast, from **-1** (nothing like smurfing) to **1** (a +textbook pattern). + +That is the whole method. No training, no hyperparameters to tune, and every +score traceable back to the block densities it came from — you can always answer +"why did this account come up?". + +## Where to go next + +| | | +|---|---| +| [Quickstart](quickstart.md) | a working example in under a minute, no data download | +| [How it works](how-it-works.md) | the block intuition, and the two equations | +| [Preparing your data](guide/data.md) | edge lists, directedness, what counts as an account | +| [Interpreting a score](guide/interpreting.md) | what 0.8 means, and what it does not | +| [Turning scores into alerts](guide/alerts.md) | thresholds, top-K, and the imbalance problem | +| [Limitations](limitations.md) | read before you rely on it | +| [API reference](reference/index.md) | every public function | -GARG-AML scores every account by that contrast. One number in [-1, 1], computed -from local structure alone — no training, no labels, and every score traceable -back to the block densities it came from. +## Citing -## Links +This package implements the method described in +[arXiv:2506.04292](https://arxiv.org/abs/2506.04292). If you use it in +published work, please cite the paper — the BibTeX entry is in the +[README](https://github.com/VerbekeLab/garg-aml#citation). -- Paper: [arXiv:2506.04292](https://arxiv.org/abs/2506.04292) -- [Design decisions](decisions/index.md) -- [Experiments and paper reproduction](https://github.com/B-Deprez/GARG-AML) +The experiments, baselines and paper reproduction live in a separate repository: +[B-Deprez/GARG-AML](https://github.com/B-Deprez/GARG-AML). diff --git a/docs/install.md b/docs/install.md new file mode 100644 index 0000000..445a211 --- /dev/null +++ b/docs/install.md @@ -0,0 +1,34 @@ +# Installation + +```bash +pip install garg-aml +``` + +Python 3.10 or newer. The core depends only on numpy, pandas, networkx and +scipy — no deep-learning stack, nothing that needs compiling. + +## Optional extras + +| Extra | Install | What it adds | +|---|---|---| +| `progress` | `pip install 'garg-aml[progress]'` | `progress=True` progress bars (tqdm) | +| `parallel` | `pip install 'garg-aml[parallel]'` | `n_jobs` other than 1 (joblib) | +| `sklearn` | `pip install 'garg-aml[sklearn]'` | the `GargAmlScorer` estimator | + +Ask for several at once with `pip install 'garg-aml[progress,parallel]'`. + +Each is genuinely optional: the package imports and scores without any of them, +and reaching for a feature you have not installed raises an error that names the +extra to install. + +## Verifying + +```python +import garg_aml as ga + +graph, labels = ga.smurfing_graph(n_nodes=100, n_patterns=2, seed=1) +print(ga.score(graph).head()) +``` + +If that prints a table of scores, you are set. See the +[quickstart](quickstart.md) for what to do with them. diff --git a/docs/limitations.md b/docs/limitations.md new file mode 100644 index 0000000..1e8ecde --- /dev/null +++ b/docs/limitations.md @@ -0,0 +1,65 @@ +# Limitations + +Read this before relying on the output. + +## What it cannot see + +**Only structure.** Amounts, timestamps, currencies and transaction counts are +not used. Two accounts that transact once and two that transact a thousand times +are the same edge. Smurfing that is obvious from amounts — hundreds of transfers +just under a reporting threshold — is invisible here unless the *shape* is also +there. + +**Only this pattern.** The method targets gather-scatter and scatter-gather +structures. Laundering that does not route through intermediaries — cash, trade +mis-invoicing, a single large transfer to a shell company — has no reason to +score high. **A low score is not evidence of legitimacy.** + +**Only within the graph you supply.** A bank sees its own customers' +transactions. In the paper's own analysis, a single institution's view preserved +every first-order relationship but only about 7% of second-order ones, and more +than half its customers lost their entire second-order neighbourhood. Worse, the +loss **biases scores upward** rather than adding noise: a missing neighbourhood +empties the penalty blocks, so partial visibility produces *more* false +positives, not just worse ranking. + +## Where the numbers mislead + +**Ties.** Many accounts share exactly the same score, especially 1.0. A raw +top-K is partly an artefact of sort order. See +[Interpreting a score](guide/interpreting.md#ties). + +**Extreme imbalance.** Under 0.1% of accounts are labelled in the reference +datasets. Accuracy and AUC-ROC are close to useless at that base rate; use +precision@K, recall@K and lift. + +**Transductive evaluation.** If you train a model on +[the feature table](guide/features.md), every account was scored as part of one +graph. A train/test split on that table does not separate test accounts +structurally from training ones — they were neighbours when the scores were +computed. Such a number is optimistic relative to scoring an unseen period. + +**Preprocessing changes the scale.** Scores from a reduced graph and a full +graph are not comparable. After `reduce_graph` at a high resolution, many +accounts have no neighbourhood left and score -1 — which is a statement about +the preprocessing, not about the account. + +## Where the method is uncertain + +**The directed score underperforms the undirected one**, which is +counter-intuitive: one-directional flow is definitional for smurfing, so the +directed variant should carry more information. Why it does not is an open +question — the level-assignment rule may be too strict, or bidirectional edges +may be over-penalised. Start with undirected. + +**Labels are propensity-based.** In the reference datasets an account counts as +laundering when a share of its transactions are flagged. That cut-off is a +choice, and results move with it. + +## On claims + +This package implements a method that performs competitively with published +baselines on the datasets it was evaluated on, at lower computational cost. +It is not established as state of the art, and the reduction in false positives +relative to alternatives is dataset-specific rather than a general property. +Evaluate it on your own data before relying on either claim. diff --git a/docs/quickstart.md b/docs/quickstart.md new file mode 100644 index 0000000..57a900f --- /dev/null +++ b/docs/quickstart.md @@ -0,0 +1,69 @@ +# Quickstart + +No data download: `smurfing_graph` builds a network with known patterns so you +can see the score work before pointing it at anything of your own. + +## Score a synthetic network + +```pycon +>>> import garg_aml as ga +>>> graph, labels = ga.smurfing_graph(n_nodes=100, n_patterns=2, seed=1) +>>> graph.number_of_nodes(), int(labels.sum()) +(109, 13) + +``` + +109 accounts, 13 of them part of an injected smurfing pattern. Score them all: + +```pycon +>>> scores = ga.score(graph)["GARGAML"] +>>> top10 = scores.sort_values(ascending=False, kind="stable").head(10).index +>>> int(labels.reindex(top10).sum()) +8 + +``` + +Eight of the ten highest-scoring accounts are in a pattern, out of 13 among 109. +No training, no labels, no tuning. + +## Score your own data + +Most people have a table, not a graph: + +```pycon +>>> import pandas as pd +>>> transactions = pd.DataFrame( +... { +... "payer": ["alice", "alice", "mule_a", "mule_b"], +... "payee": ["mule_a", "mule_b", "bob", "bob"], +... "amount": [900, 950, 890, 940], +... } +... ) +>>> scores = ga.score_edges(transactions, "payer", "payee") +>>> float(scores.loc["alice", "GARGAML"]) +1.0 + +``` + +Only the two account columns are read. Amounts, timestamps and counts are +ignored — GARG-AML reads structure, and repeated transactions between the same +pair collapse to one edge. + +## What the score means + +Between -1 and 1, higher being more smurfing-like. A score of 1 is a textbook +pattern; a tightly-knit group whose members all trade with each other scores +around 0; an account with no neighbours left scores -1. + +**Every account in a pattern scores high, not just the one sending the money.** +A mule's own neighbourhood has the same shape as the source's. Read a high +score as "this account sits in a smurfing-shaped subgraph". + +See [Interpreting a score](guide/interpreting.md) before acting on any of this, +and [Limitations](limitations.md) before relying on it. + +## Where next + +- Large graph? [Preprocessing](guide/preprocessing.md) and [Scaling](scaling.md). +- Feeding a model? [Using scores as features](guide/features.md). +- Want alerts, not scores? [Turning scores into alerts](guide/alerts.md). diff --git a/docs/reference/index.md b/docs/reference/index.md new file mode 100644 index 0000000..e150252 --- /dev/null +++ b/docs/reference/index.md @@ -0,0 +1,35 @@ +# API reference + +Everything importable from the top-level `garg_aml` namespace. Anything not +listed here is private and may change without notice. + +## Scoring + +::: garg_aml.score +::: garg_aml.score_edges +::: garg_aml.block_measures_frame + +## The two stages + +::: garg_aml.block_measures +::: garg_aml.scores_from_measures +::: garg_aml.score_from_measures + +## Preprocessing + +::: garg_aml.reduce_graph +::: garg_aml.drop_hubs + +## Features + +::: garg_aml.build_features +::: garg_aml.neighbour_score_stats +::: garg_aml.neighbour_degree_stats + +## Test data + +::: garg_aml.smurfing_graph + +## scikit-learn + +::: garg_aml.estimator.GargAmlScorer diff --git a/docs/reproducing.md b/docs/reproducing.md new file mode 100644 index 0000000..16c0aa0 --- /dev/null +++ b/docs/reproducing.md @@ -0,0 +1,40 @@ +# Reproducing the paper + +The experiments, baselines, figures and tables live in a separate repository: +**[B-Deprez/GARG-AML](https://github.com/B-Deprez/GARG-AML)**. + +This package holds the method. The research repository holds everything that +surrounds it — dataset loaders for the IBM AMLworld data, label construction, +the evaluation grid, the FlowScope, AutoAudit and GraphSAGE baselines, and the +notebooks producing the figures. + +## Matching a published run + +- **Preprocessing:** Louvain, `resolution=10`, `seed=1997`, intra-community + edges only — `ga.reduce_graph(graph)` with its defaults. +- **Score type:** `weighted_average`, the package default. +- **Direction:** the paper reports both. + +## Two things this package does not reproduce + +**The synthetic datasets.** `smurfing_graph` is a networkx reimplementation for +documentation and tests. The paper's 66 synthetic datasets were generated with +python-igraph under its own RNG and are not reproduced bit for bit. Use the +research repository's generator. See +[decision 0005](decisions/0005-synthetic-generator-differs.md). + +**The transpose-max directed variant.** The pre-extraction code contained a +`max(score, transposed_score)` step with no counterpart in the paper, reachable +only through a function no experiment called. It is not ported. See +[decision 0007](decisions/0007-directed-score-is-equation-14.md). + +## Version pinning + +Pin an exact version when reproducing published numbers: + +```bash +pip install garg-aml==0.1.0 +``` + +Each release is tagged in the repository and archived with a DOI, so a paper can +cite the exact code that produced its results. diff --git a/docs/scaling.md b/docs/scaling.md new file mode 100644 index 0000000..4529378 --- /dev/null +++ b/docs/scaling.md @@ -0,0 +1,80 @@ +# Scaling + +## What drives the cost + +Each account is scored from its own second-order neighbourhood, independently of +every other account. So the cost is **not** a function of graph size directly — +it is the sum over accounts of the cost of their neighbourhood, and that is +dominated by the few accounts with very large ones. + +For each account the work is: extract the second-order ego graph, build its +dense adjacency matrix, and sum over blocks. An account with *n* second-order +neighbours costs roughly *n²* to score. One account with 10,000 neighbours costs +as much as a million accounts with ten. + +**This is why the tail matters more than the size.** A graph of ten million +ordinary retail accounts is easier than a graph of one million that includes a +payment processor. + +## What to do about it + +In order of how much they help: + +1. **[Remove hubs](guide/preprocessing.md#hub-removal).** `drop_hubs` deletes + the highest-degree accounts. A processor with a million counterparties is not + the smurf you are looking for, and it single-handedly dominates the runtime. + +2. **[Reduce the graph](guide/preprocessing.md#community-reduction).** + `reduce_graph` cuts between-community edges, which shrinks the large + neighbourhoods most. This is what makes the paper's larger dataset tractable. + It is lossy — read that page before turning it on. + +3. **Parallelise.** `n_jobs` spreads accounts across processes: + + ```python + scores = ga.score(graph, n_jobs=-1) # needs the `parallel` extra + ``` + + Because each account is independent this scales close to linearly in CPU. The + catch is memory: every worker gets its own copy of the graph, so peak memory + is roughly `n_jobs` times the graph. On a large graph that, not CPU, is the + binding constraint — raise `n_jobs` until memory says stop, not until cores + run out. + +4. **Split the work.** The expensive stage is separable: + + ```python + measures = ga.block_measures_frame(graph, n_jobs=-1) + measures.to_parquet("measures.parquet") # the expensive part, done once + scores = ga.scores_from_measures(measures) # seconds, any time after + ``` + + Worth doing on anything large: re-deriving scores under a different + `score_type`, or rebuilding features, then costs nothing. + +## Memory + +networkx holds the whole graph in memory, and that is usually the first wall. +The per-account working set is small — one dense matrix the size of the +neighbourhood — so if the graph fits, scoring generally fits. + +If the graph does not fit, build it more cheaply than networkx does. The +research repository constructs the edge set directly with pandas for exactly +this reason, which was several times faster and made a 176-million-edge graph +possible at all. + +## Measuring before optimising + +Neighbourhood sizes are cheap to check, and they tell you immediately whether +you have a tail problem: + +```python +import pandas as pd + +degrees = pd.Series(dict(graph.degree())) +print(degrees.describe()) +print(degrees.nlargest(10)) +``` + +If the top of that list is orders of magnitude above the median, hub removal +will do more for you than anything else on this page. diff --git a/mkdocs.yml b/mkdocs.yml index 9a370bc..3c4c2bf 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -32,4 +32,26 @@ markdown_extensions: nav: - Home: index.md - - Decisions: decisions/index.md + - Install: install.md + - Quickstart: quickstart.md + - How it works: how-it-works.md + - Guide: + - Preparing your data: guide/data.md + - Preprocessing: guide/preprocessing.md + - Interpreting a score: guide/interpreting.md + - Turning scores into alerts: guide/alerts.md + - Using scores as features: guide/features.md + - Scaling: scaling.md + - Limitations: limitations.md + - API reference: reference/index.md + - Reproducing the paper: reproducing.md + - Decisions: + - Overview: decisions/index.md + - 0001 weighted average default: decisions/0001-weighted-average-default.md + - 0002 Louvain is explicit: decisions/0002-louvain-is-explicit.md + - 0003 Frozen edge cases: decisions/0003-frozen-edge-cases.md + - 0004 Two-stage pipeline: decisions/0004-two-stage-pipeline.md + - 0005 Synthetic generator differs: decisions/0005-synthetic-generator-differs.md + - 0006 No PyTorch: decisions/0006-no-torch-dependency.md + - 0007 Directed score is Eq. 14: decisions/0007-directed-score-is-equation-14.md + - 0008 Node identity preserved: decisions/0008-node-identity-is-preserved.md diff --git a/pyproject.toml b/pyproject.toml index 5b27d52..b1f5b56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,11 @@ dependencies = ["numpy", "pandas", "networkx>=3.0", "scipy"] progress = ["tqdm"] parallel = ["joblib"] sklearn = ["scikit-learn"] -dev = ["ruff", "mypy", "pytest", "pytest-cov", "pre-commit", "pandas-stubs"] +# ruff is constrained to one minor: it formats Python inside Markdown as well as +# in .py files, and its formatter changes between minors. Bump this and the +# `rev` in .pre-commit-config.yaml together, never one alone -- otherwise the +# hook formats one way and CI checks another. +dev = ["ruff>=0.16,<0.17", "mypy", "pytest", "pytest-cov", "pre-commit", "pandas-stubs"] docs = ["mkdocs-material", "mkdocstrings[python]"] [project.urls] @@ -95,14 +99,18 @@ disallow_untyped_defs = true warn_redundant_casts = true warn_unused_ignores = true +# None of these ship py.typed, and the optional ones may not be installed at +# all. Either way mypy should treat them as untyped rather than fail. [[tool.mypy.overrides]] -module = ["networkx.*", "scipy.*"] +module = ["networkx.*", "scipy.*", "sklearn.*", "joblib.*", "tqdm.*"] ignore_missing_imports = true # --- Tests ------------------------------------------------------------------ [tool.pytest.ini_options] -testpaths = ["tests", "src/garg_aml"] -addopts = """--strict-markers --doctest-modules \ +# docs/ is in here so that every `>>>` example in the documentation is executed: +# a stale example is a failing build, not something a reader discovers. +testpaths = ["tests", "src/garg_aml", "docs"] +addopts = """--strict-markers --doctest-modules --doctest-glob=*.md \ --cov=garg_aml --cov-report=term-missing --cov-fail-under=90""" markers = [ "requires_data: needs the IBM AMLworld dataset, which is not redistributable", diff --git a/src/garg_aml/__init__.py b/src/garg_aml/__init__.py index 0db6619..9087990 100644 --- a/src/garg_aml/__init__.py +++ b/src/garg_aml/__init__.py @@ -3,10 +3,24 @@ Smurfing moves money from one account to another through intermediate mules, so that source and target never transact directly. In the second-order -neighbourhood of such a node the adjacency matrix splits into blocks whose +neighbourhood of such an account the adjacency matrix splits into blocks whose on-diagonal parts are empty and whose off-diagonal parts are dense. GARG-AML -scores every node by exactly that contrast, in [-1, 1], with higher meaning more -smurfing-like. +scores every account by exactly that contrast, in [-1, 1], with higher meaning +more smurfing-like. Nothing is trained and no labels are needed. + +A synthetic graph of 109 accounts, 13 of them in an injected smurfing pattern. +Eight of the ten highest-scoring accounts are among those 13 -- with no +training, no labels and no tuning: + +>>> import garg_aml as ga +>>> graph, labels = ga.smurfing_graph(n_nodes=100, n_patterns=2, seed=1) +>>> scores = ga.score(graph)["GARGAML"] +>>> top10 = scores.sort_values(ascending=False, kind="stable").head(10).index +>>> int(labels.reindex(top10).sum()) +8 + +``GargAmlScorer`` is a scikit-learn compatible wrapper and needs the ``sklearn`` +extra; it is imported only when you reach for it. References ---------- @@ -16,11 +30,51 @@ """ import logging +from typing import Any + +from .api import block_measures_frame, score, score_edges +from .features import ( + FEATURE_COLUMNS, + build_features, + neighbour_degree_stats, + neighbour_score_stats, +) +from .measures import block_measures +from .preprocess import drop_hubs, reduce_graph +from .scores import score_from_measures, scores_from_measures +from .synthetic import smurfing_graph __version__ = "0.1.0.dev0" # A library attaches no handlers of its own; the application decides. logging.getLogger(__name__).addHandler(logging.NullHandler()) -# Populated in Phase 4. Anything absent here is private and unstable. -__all__: list[str] = [] +__all__ = [ + "FEATURE_COLUMNS", + "GargAmlScorer", + "block_measures", + "block_measures_frame", + "build_features", + "drop_hubs", + "neighbour_degree_stats", + "neighbour_score_stats", + "reduce_graph", + "score", + "score_edges", + "score_from_measures", + "scores_from_measures", + "smurfing_graph", +] + + +def __getattr__(name: str) -> Any: + """Load the optional scikit-learn wrapper on first use.""" + if name == "GargAmlScorer": + try: + from .estimator import GargAmlScorer + except ImportError as exc: # pragma: no cover - depends on the environment + raise ImportError( + "GargAmlScorer needs scikit-learn: pip install 'garg-aml[sklearn]'" + ) from exc + return GargAmlScorer + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/garg_aml/api.py b/src/garg_aml/api.py new file mode 100644 index 0000000..f87ae07 --- /dev/null +++ b/src/garg_aml/api.py @@ -0,0 +1,257 @@ +""" +The functions most callers need: score a graph, or score an edge list. + +Everything here is a thin composition of the two stages -- block measures, then +aggregation -- so that scoring a whole graph and scoring one node give the same +numbers by construction. See ``docs/decisions/0004``. +""" + +from collections.abc import Hashable, Iterable +from typing import Any + +import networkx as nx +import pandas as pd + +from .measures import DIRECTED_COLUMNS, UNDIRECTED_COLUMNS, block_measures +from .preprocess import SEED, reduce_graph +from .scores import DEFAULT_SCORE_TYPE, scores_from_measures + +__all__ = ["block_measures_frame", "score", "score_edges"] + + +def _progress(nodes: list, show: bool) -> Iterable: + if not show: + return nodes + try: + from tqdm import tqdm + except ImportError as exc: # pragma: no cover - depends on the environment + raise ImportError( + "progress=True needs tqdm: pip install 'garg-aml[progress]'" + ) from exc + return tqdm(nodes) + + +def _rows( + graph: nx.Graph, nodes: list, directed: bool, n_jobs: int, progress: bool +) -> list[tuple[float, ...]]: + """Block measures for every node, serially or across processes.""" + views: dict[str, Any] = {} + if directed: + # Built once here rather than per node: deriving them inside + # block_measures would dominate the run on any real graph. + views = { + "undirected": graph.to_undirected(), + "reverse": graph.reverse(copy=True), + } + + def measure(node: Hashable) -> tuple[float, ...]: + return block_measures( + graph, node, directed=directed, include_sizes=True, **views + ) + + if n_jobs == 1: + return [measure(node) for node in _progress(nodes, progress)] + + try: + from joblib import Parallel, delayed + except ImportError as exc: # pragma: no cover - depends on the environment + raise ImportError( + "n_jobs other than 1 needs joblib: pip install 'garg-aml[parallel]'" + ) from exc + + # Each worker receives its own copy of the graph, so the memory cost scales + # with n_jobs. On a large graph that is the binding constraint, not the CPU. + return list(Parallel(n_jobs=n_jobs)(delayed(measure)(node) for node in nodes)) + + +def block_measures_frame( + graph: nx.Graph, + directed: bool | None = None, + *, + n_jobs: int = 1, + progress: bool = False, +) -> pd.DataFrame: + """ + Block measures for every node of a graph. + + The expensive stage. Persist this and you can re-derive any score variant + from it with :func:`garg_aml.scores.scores_from_measures` without touching + the graph again. + + Parameters + ---------- + graph : networkx.Graph or networkx.DiGraph + The graph to measure. + directed : bool, optional + Which analysis to run. Taken from the graph's own type when omitted. + n_jobs : int, default 1 + Processes to use. ``-1`` means all cores, and anything other than 1 + needs the ``parallel`` extra. + progress : bool, default False + Show a progress bar; needs the ``progress`` extra. + + Returns + ------- + pandas.DataFrame + One row per node, with a ``node`` column, the block densities and the + matching free-entry counts. + + Examples + -------- + >>> import networkx as nx + >>> graph = nx.Graph([("a", "m1"), ("a", "m2"), ("m1", "b"), ("m2", "b")]) + >>> frame = block_measures_frame(graph) + >>> list(frame.columns) + ['node', 'measure_1', 'measure_2', 'measure_3', 'size_1', 'size_2', 'size_3'] + """ + if directed is None: + directed = nx.is_directed(graph) + + nodes = list(graph.nodes) + columns = DIRECTED_COLUMNS if directed else UNDIRECTED_COLUMNS + + frame = pd.DataFrame( + _rows(graph, nodes, directed, n_jobs, progress), columns=columns + ) + frame.insert(0, "node", nodes) + return frame + + +def score( + graph: nx.Graph, + directed: bool | None = None, + *, + score_type: str = DEFAULT_SCORE_TYPE, + reduce: bool = False, + resolution: float = 10, + seed: int = SEED, + n_jobs: int = 1, + progress: bool = False, + return_measures: bool = False, +) -> pd.DataFrame: + """ + Score every account in a transaction graph. + + Parameters + ---------- + graph : networkx.Graph or networkx.DiGraph + Accounts as nodes, transactions as edges. Node identity is preserved: + whatever your ids are, they come back as the index. + directed : bool, optional + Which analysis to run. Taken from the graph's own type when omitted. + score_type : {"weighted_average", "basic"}, default "weighted_average" + Weight each block by its free entries, or take an unweighted mean. + reduce : bool, default False + Apply :func:`garg_aml.preprocess.reduce_graph` first. Off by default + because it is lossy and the choice is yours to make -- read + ``docs/decisions/0002`` before turning it on. + resolution, seed : float, int + Passed to ``reduce_graph`` when ``reduce`` is True. + n_jobs : int, default 1 + Processes to use; needs the ``parallel`` extra when not 1. + progress : bool, default False + Show a progress bar; needs the ``progress`` extra. + return_measures : bool, default False + Also return the block densities and sizes the score was built from. + + Returns + ------- + pandas.DataFrame + Indexed by node, with a ``GARGAML`` column in [-1, 1]. Directed graphs + also get ``GARGAML_transposed`` and ``GARGAML_max``, which are reported + for reference and are **not** the score (``docs/decisions/0007``). + + See Also + -------- + score_edges : the same thing, starting from a pandas edge list. + garg_aml.features.build_features : neighbourhood statistics on top of these. + + Notes + ----- + Every account *in* a pattern scores high, not just the one sending the + money: a mule's own neighbourhood has the same block structure as the + source's. Treat a high score as "this account sits in a smurfing-shaped + subgraph", not as "this account is the originator". + + Scores are comparable within one run. A node with no neighbours scores -1, + and after ``reduce=True`` there are usually many of those. + + References + ---------- + Deprez et al. (2025), Eq. (8) and Eq. (14). + + Examples + -------- + A smurfing pattern beside a group of accounts that all trade with each + other. The pattern scores at the top of the range; the tight group, whose + on-diagonal blocks are full rather than empty, scores 0. + + >>> import networkx as nx + >>> graph = nx.Graph([("a", "m1"), ("a", "m2"), ("a", "m3"), + ... ("m1", "b"), ("m2", "b"), ("m3", "b"), + ... ("x", "y"), ("y", "z"), ("z", "x"), + ... ("x", "w"), ("y", "w"), ("z", "w")]) + >>> scores = score(graph) + >>> float(scores.loc["a", "GARGAML"]) + 1.0 + >>> float(scores.loc["x", "GARGAML"]) + 0.0 + """ + if directed is None: + directed = nx.is_directed(graph) + + if reduce: + graph = reduce_graph(graph, resolution=resolution, seed=seed) + + measures = block_measures_frame(graph, directed, n_jobs=n_jobs, progress=progress) + scores = scores_from_measures(measures, directed, score_type=score_type) + + if return_measures: + return scores.join(measures.set_index("node")) + return scores + + +def score_edges( + edges: pd.DataFrame, + source: str = "source", + target: str = "target", + *, + directed: bool = False, + **kwargs: Any, +) -> pd.DataFrame: + """ + Score accounts from a table of transactions. + + A convenience wrapper for the common case where the data is a DataFrame and + not a graph. Parallel edges collapse -- GARG-AML reads structure, not + volume -- and self-loops are dropped. + + Parameters + ---------- + edges : pandas.DataFrame + One row per transaction. + source, target : str + Column names holding the paying and receiving account. + directed : bool, default False + Build a directed graph and run the directed analysis. + **kwargs + Passed to :func:`score`. + + Returns + ------- + pandas.DataFrame + As :func:`score`. + + Examples + -------- + >>> import pandas as pd + >>> edges = pd.DataFrame({"source": ["a", "a", "m1", "m2"], + ... "target": ["m1", "m2", "b", "b"]}) + >>> float(score_edges(edges).loc["a", "GARGAML"]) + 1.0 + """ + graph: nx.Graph = nx.DiGraph() if directed else nx.Graph() + graph.add_edges_from(zip(edges[source], edges[target], strict=True)) + graph.remove_edges_from(nx.selfloop_edges(graph)) + + return score(graph, directed, **kwargs) diff --git a/src/garg_aml/estimator.py b/src/garg_aml/estimator.py new file mode 100644 index 0000000..03543fa --- /dev/null +++ b/src/garg_aml/estimator.py @@ -0,0 +1,101 @@ +""" +A scikit-learn compatible wrapper, for callers who work in pipelines. + +Needs the ``sklearn`` extra. Import it as ``garg_aml.GargAmlScorer``; the +top-level package loads this module only when you ask for it, so scikit-learn +stays optional. +""" + +import networkx as nx +import pandas as pd +from sklearn.base import BaseEstimator, TransformerMixin + +from .api import score +from .features import build_features +from .preprocess import SEED, reduce_graph +from .scores import DEFAULT_SCORE_TYPE + +__all__ = ["GargAmlScorer"] + + +class GargAmlScorer(BaseEstimator, TransformerMixin): + """ + Turn a transaction graph into a GARG-AML feature table. + + Parameters + ---------- + directed : bool, optional + Which analysis to run. Taken from the graph's own type when omitted. + score_type : {"weighted_average", "basic"}, default "weighted_average" + Weight each block by its free entries, or take an unweighted mean. + reduce : bool, default False + Apply :func:`garg_aml.preprocess.reduce_graph` first. + resolution : float, default 10 + Louvain resolution, used only when ``reduce`` is True. + seed : int, default 1997 + Louvain seed, used only when ``reduce`` is True. + n_jobs : int, default 1 + Processes to use; needs the ``parallel`` extra when not 1. + + Attributes + ---------- + scores_ : pandas.DataFrame + The scores from the last call to :meth:`fit`. + features_ : pandas.DataFrame + The feature table from the last call to :meth:`fit`. + + Notes + ----- + **Nothing is learned.** GARG-AML is a closed-form structural score, so + ``fit`` has no parameters to estimate -- it scores the graph you hand it and + keeps the result for convenience. ``transform`` scores whatever graph it is + given, fitted or not. The method is transductive: a node can only be scored + as part of a graph, so there is no meaningful "apply the fitted model to + unseen accounts" step. + + Examples + -------- + >>> from garg_aml.synthetic import smurfing_graph + >>> graph, labels = smurfing_graph(n_nodes=60, n_patterns=1, seed=3) + >>> features = GargAmlScorer().fit_transform(graph) + >>> "GARGAML" in features.columns + True + """ + + def __init__( + self, + directed: bool | None = None, + score_type: str = DEFAULT_SCORE_TYPE, + reduce: bool = False, + resolution: float = 10, + seed: int = SEED, + n_jobs: int = 1, + ) -> None: + self.directed = directed + self.score_type = score_type + self.reduce = reduce + self.resolution = resolution + self.seed = seed + self.n_jobs = n_jobs + + def _features(self, graph: nx.Graph) -> tuple[pd.DataFrame, pd.DataFrame]: + if self.reduce: + graph = reduce_graph(graph, resolution=self.resolution, seed=self.seed) + + scores = score( + graph, + self.directed, + score_type=self.score_type, + n_jobs=self.n_jobs, + ) + return scores, build_features(graph, scores) + + def fit(self, X: nx.Graph, y: None = None) -> "GargAmlScorer": + """Score ``X`` and keep the result. Estimates nothing.""" + self.scores_, self.features_ = self._features(X) + return self + + def transform(self, X: nx.Graph) -> pd.DataFrame: + """Return the feature table for ``X``.""" + _scores, features = self._features(X) + return features diff --git a/src/garg_aml/measures.py b/src/garg_aml/measures.py index 7122a98..2c0394b 100644 --- a/src/garg_aml/measures.py +++ b/src/garg_aml/measures.py @@ -30,7 +30,23 @@ ) from ._ordering import node_order -__all__ = ["block_measures"] +__all__ = ["DIRECTED_COLUMNS", "UNDIRECTED_COLUMNS", "block_measures"] + +#: Column names for the three undirected blocks, in the order +#: :func:`block_measures` returns them with ``include_sizes=True``. +UNDIRECTED_COLUMNS = [ + "measure_1", + "measure_2", + "measure_3", + "size_1", + "size_2", + "size_3", +] + +#: Column names for the nine directed blocks, row-major. +DIRECTED_COLUMNS = [f"measure_{i}{j}" for i in range(3) for j in range(3)] + [ + f"size_{i}{j}" for i in range(3) for j in range(3) +] def _undirected_measures( diff --git a/src/garg_aml/scores.py b/src/garg_aml/scores.py index 965a074..8de2c62 100644 --- a/src/garg_aml/scores.py +++ b/src/garg_aml/scores.py @@ -75,8 +75,8 @@ def _directed_score(row: MeasureRow, score_type: str) -> tuple[float, float]: if score_type == "basic": return ( - np.mean(dense) - np.mean(sparse), - np.mean(dense_t) - np.mean(sparse_t), + float(np.mean(dense) - np.mean(sparse)), + float(np.mean(dense_t) - np.mean(sparse_t)), ) size_00 = row["size_00"] diff --git a/src/garg_aml/synthetic.py b/src/garg_aml/synthetic.py new file mode 100644 index 0000000..0f8025d --- /dev/null +++ b/src/garg_aml/synthetic.py @@ -0,0 +1,131 @@ +""" +Synthetic transaction graphs with known smurfing patterns. + +For documentation examples and tests: it needs no data download, and the ground +truth is known, so an example can show that the score actually separates the +injected pattern from the background. + +It does **not** reproduce the synthetic datasets used in the paper. Those were +generated with python-igraph under its own RNG; reproducing them bit for bit +would mean a second core dependency for no user-facing gain. Use the research +repository's generator for that. See ``docs/decisions/0005``. +""" + +import random +from collections.abc import Hashable + +import networkx as nx +import pandas as pd + +__all__ = ["MODELS", "smurfing_graph"] + +#: Background models, and the networkx generator each one uses. +MODELS = ("barabasi-albert", "erdos-renyi", "watts-strogatz") + +SEED = 1997 + + +def _background(model: str, n_nodes: int, seed: int) -> nx.Graph: + if model == "barabasi-albert": + return nx.barabasi_albert_graph(n_nodes, 2, seed=seed) + if model == "erdos-renyi": + return nx.erdos_renyi_graph(n_nodes, 0.02, seed=seed) + if model == "watts-strogatz": + return nx.watts_strogatz_graph(n_nodes, 4, 0.1, seed=seed) + raise ValueError(f"unknown model {model!r}; expected one of {MODELS}") + + +def smurfing_graph( + n_nodes: int = 200, + *, + model: str = "barabasi-albert", + n_patterns: int = 3, + mules: tuple[int, int] = (2, 10), + directed: bool = False, + seed: int = SEED, +) -> tuple[nx.Graph, pd.Series]: + """ + Build a background graph with smurfing patterns injected into it. + + Each pattern takes two existing accounts as source and target and routes + money between them through fresh mule accounts, so source and target never + transact directly -- the structure GARG-AML looks for. + + Parameters + ---------- + n_nodes : int, default 200 + Size of the background graph, before mules are added. + model : {"barabasi-albert", "erdos-renyi", "watts-strogatz"} + Which background model to use. + n_patterns : int, default 3 + How many smurfing patterns to inject. + mules : tuple of int, default (2, 10) + Inclusive range for the number of mules per pattern, drawn uniformly. + directed : bool, default False + Return a ``DiGraph``, with the smurfing edges oriented source to mule + to target. Background edges keep the generator's own orientation. + seed : int, default 1997 + Seeds the background graph and the injection. + + Returns + ------- + graph : networkx.Graph or networkx.DiGraph + Background plus injected patterns. Mules are named ``"mule_"``. + labels : pandas.Series + Boolean, indexed by node, True for every account in a pattern -- + sources, targets and mules alike. + + Raises + ------ + ValueError + If ``model`` is unknown, or the graph is too small to place the + requested patterns without reusing an account. + + Examples + -------- + >>> graph, labels = smurfing_graph(n_nodes=100, n_patterns=2, seed=1) + >>> int(labels.sum()) > 0 + True + >>> bool(labels.loc["mule_0"]) + True + """ + background = _background(model, n_nodes, seed) + + graph: nx.Graph = nx.DiGraph() if directed else nx.Graph() + graph.add_nodes_from(background.nodes) + graph.add_edges_from(background.edges) + + rng = random.Random(seed) + + # Each pattern needs its own source and target: reusing an account across + # patterns would blur the ground truth this generator exists to provide. + needed = 2 * n_patterns + if needed > graph.number_of_nodes(): + raise ValueError( + f"{n_patterns} patterns need {needed} distinct accounts, " + f"but the background graph has {graph.number_of_nodes()}" + ) + + endpoints = rng.sample(sorted(graph.nodes, key=str), needed) + laundering: set[Hashable] = set() + mule_index = 0 + + for pattern in range(n_patterns): + source = endpoints[2 * pattern] + target = endpoints[2 * pattern + 1] + + for _ in range(rng.randint(*mules)): + mule = f"mule_{mule_index}" + mule_index += 1 + graph.add_edge(source, mule) + graph.add_edge(mule, target) + laundering.add(mule) + + laundering.update((source, target)) + + labels = pd.Series( + [node in laundering for node in graph.nodes], + index=pd.Index(list(graph.nodes), name="node"), + name="laundering", + ) + return graph, labels diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..81cd41f --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,120 @@ +""" +The public API: composition, not new arithmetic. + +`score` must agree with calling the two stages by hand, or the convenience +layer has become a second implementation -- the thing docs/decisions/0004 +exists to prevent. +""" + +import networkx as nx +import pandas as pd +import pytest + +import garg_aml as ga +from garg_aml.measures import block_measures +from garg_aml.preprocess import reduce_graph +from garg_aml.scores import scores_from_measures + +SMURFING = [ + ("a", "m1"), + ("a", "m2"), + ("a", "m3"), + ("m1", "b"), + ("m2", "b"), + ("m3", "b"), +] +CLIQUE = [("x", "y"), ("y", "z"), ("z", "x"), ("x", "w"), ("y", "w"), ("z", "w")] + + +@pytest.fixture +def graph(): + return nx.Graph(SMURFING + CLIQUE) + + +@pytest.mark.parametrize("directed", [False, True]) +def test_score_matches_the_two_stage_path(directed): + graph = nx.DiGraph(SMURFING) if directed else nx.Graph(SMURFING) + + by_hand = scores_from_measures(ga.block_measures_frame(graph), directed) + pd.testing.assert_frame_equal(ga.score(graph), by_hand) + + +def test_direction_is_inferred_from_the_graph(): + edges = nx.DiGraph(SMURFING) + pd.testing.assert_frame_equal(ga.score(edges), ga.score(edges, directed=True)) + + +def test_reduce_is_the_same_as_reducing_first(graph): + pd.testing.assert_frame_equal( + ga.score(graph, reduce=True, resolution=1), + ga.score(reduce_graph(graph, resolution=1)), + ) + + +def test_return_measures_appends_the_block_columns(graph): + plain = ga.score(graph) + detailed = ga.score(graph, return_measures=True) + + assert list(detailed.columns[: len(plain.columns)]) == list(plain.columns) + assert "measure_2" in detailed.columns + assert "size_2" in detailed.columns + pd.testing.assert_frame_equal(detailed[plain.columns], plain) + + +def test_block_measures_frame_agrees_with_one_node(graph): + frame = ga.block_measures_frame(graph).set_index("node") + + for node in ("a", "x"): + row = frame.loc[node].tolist() + assert row == pytest.approx( + list(block_measures(graph, node, include_sizes=True)) + ) + + +def test_score_edges_matches_score(graph): + edges = pd.DataFrame(SMURFING + CLIQUE, columns=["source", "target"]) + pd.testing.assert_frame_equal(ga.score_edges(edges), ga.score(graph)) + + +def test_score_edges_takes_custom_column_names(): + edges = pd.DataFrame(SMURFING, columns=["from_account", "to_account"]) + scores = ga.score_edges(edges, "from_account", "to_account") + assert float(scores.loc["a", "GARGAML"]) == 1.0 + + +def test_score_edges_drops_self_loops(): + edges = pd.DataFrame([*SMURFING, ("a", "a")], columns=["source", "target"]) + pd.testing.assert_frame_equal(ga.score_edges(edges), ga.score(nx.Graph(SMURFING))) + + +@pytest.mark.parametrize( + "relabel", + [str, int, lambda n: (n, n)], + ids=["string", "integer", "tuple"], +) +def test_node_identity_survives(relabel): + graph = nx.convert_node_labels_to_integers(nx.Graph(SMURFING)) + graph = nx.relabel_nodes(graph, {n: relabel(n) for n in graph}) + + scores = ga.score(graph) + assert set(scores.index) == set(graph.nodes) + + +def test_parallel_matches_serial(graph): + pytest.importorskip("joblib") + pd.testing.assert_frame_equal(ga.score(graph, n_jobs=2), ga.score(graph, n_jobs=1)) + + +def test_unknown_score_type_is_rejected(graph): + with pytest.raises(ValueError, match="unknown score_type"): + ga.score(graph, score_type="geometric") + + +def test_public_api_is_importable(): + missing = [name for name in ga.__all__ if not hasattr(ga, name)] + assert missing == [] + + +def test_unknown_attribute_still_raises(): + with pytest.raises(AttributeError, match="no attribute"): + _ = ga.definitely_not_a_function diff --git a/tests/test_estimator.py b/tests/test_estimator.py new file mode 100644 index 0000000..7c019ae --- /dev/null +++ b/tests/test_estimator.py @@ -0,0 +1,47 @@ +"""The optional scikit-learn wrapper.""" + +import networkx as nx +import pandas as pd +import pytest + +import garg_aml as ga +from garg_aml.features import FEATURE_COLUMNS + +sklearn = pytest.importorskip("sklearn") + +SMURFING = [("a", "m1"), ("a", "m2"), ("m1", "b"), ("m2", "b")] + + +@pytest.fixture +def graph(): + return nx.Graph(SMURFING) + + +def test_fit_transform_returns_the_feature_table(graph): + features = ga.GargAmlScorer().fit_transform(graph) + + assert list(features.columns) == FEATURE_COLUMNS + assert set(features.index) == set(graph.nodes) + + +def test_fit_exposes_scores_and_features(graph): + estimator = ga.GargAmlScorer().fit(graph) + + pd.testing.assert_frame_equal(estimator.scores_, ga.score(graph)) + pd.testing.assert_frame_equal(estimator.features_, estimator.transform(graph)) + + +def test_parameters_round_trip(): + estimator = ga.GargAmlScorer(reduce=True, resolution=3, score_type="basic") + assert estimator.get_params()["resolution"] == 3 + + estimator.set_params(resolution=7) + assert estimator.get_params()["resolution"] == 7 + + +def test_reduce_flows_through(graph): + reduced = ga.reduce_graph(graph, resolution=1) + pd.testing.assert_frame_equal( + ga.GargAmlScorer(reduce=True, resolution=1).fit(graph).scores_, + ga.score(reduced), + ) diff --git a/tests/test_synthetic.py b/tests/test_synthetic.py new file mode 100644 index 0000000..8b31e90 --- /dev/null +++ b/tests/test_synthetic.py @@ -0,0 +1,64 @@ +"""The generator that makes the documentation runnable without a data download.""" + +import networkx as nx +import pytest + +from garg_aml.synthetic import MODELS, smurfing_graph + + +@pytest.mark.parametrize("model", MODELS) +def test_every_model_builds(model): + graph, labels = smurfing_graph(n_nodes=60, model=model, n_patterns=1, seed=2) + + assert graph.number_of_nodes() == len(labels) + assert set(labels.index) == set(graph.nodes) + assert labels.sum() > 0 + + +def test_generation_is_reproducible(): + first, first_labels = smurfing_graph(n_nodes=80, seed=7) + second, second_labels = smurfing_graph(n_nodes=80, seed=7) + + assert sorted(map(str, first.edges)) == sorted(map(str, second.edges)) + assert first_labels.equals(second_labels) + + +def test_different_seeds_differ(): + first, _ = smurfing_graph(n_nodes=80, seed=7) + second, _ = smurfing_graph(n_nodes=80, seed=8) + assert sorted(map(str, first.edges)) != sorted(map(str, second.edges)) + + +def test_mules_route_between_source_and_target_without_a_direct_edge(): + graph, _labels = smurfing_graph(n_nodes=60, n_patterns=1, mules=(3, 3), seed=5) + + mules = [n for n in graph.nodes if str(n).startswith("mule_")] + assert len(mules) == 3 + + # Every mule has exactly the source and the target as neighbours, and those + # two do not transact directly -- that is the pattern being injected. + endpoints = {frozenset(graph.neighbors(m)) for m in mules} + assert len(endpoints) == 1 + source, target = endpoints.pop() + assert not graph.has_edge(source, target) + + +def test_directed_orients_source_to_mule_to_target(): + graph, _ = smurfing_graph( + n_nodes=60, n_patterns=1, mules=(2, 2), directed=True, seed=5 + ) + + assert isinstance(graph, nx.DiGraph) + for mule in (n for n in graph.nodes if str(n).startswith("mule_")): + assert graph.in_degree(mule) == 1 + assert graph.out_degree(mule) == 1 + + +def test_unknown_model_is_rejected(): + with pytest.raises(ValueError, match="unknown model"): + smurfing_graph(model="preferential-attachment") + + +def test_graph_too_small_for_the_patterns_is_rejected(): + with pytest.raises(ValueError, match="distinct accounts"): + smurfing_graph(n_nodes=4, n_patterns=5)