Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: CI

on:
push:
branches: [main]
pull_request:
# Unpinned dependencies: a weekly run surfaces networkx/pandas/numpy drift
# before a user reports it.
schedule:
- cron: "0 6 * * 1"

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -e ".[dev]"
- run: ruff check .
- run: ruff format --check .
- run: mypy

test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- run: pip install -e ".[dev]"
- run: pytest
35 changes: 35 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# --- Private agent instructions --------------------------------------------
# Steering notes for Claude Code, deliberately not published. Every decision
# recorded there must also exist in docs/decisions/ or CONTRIBUTING.md -- if
# deleting this file would lose information, it was in the wrong file.
CLAUDE.md

# --- Python -----------------------------------------------------------------
__pycache__/
*.py[cod]
*.egg-info/
.eggs/
build/
dist/

# --- Virtual environments ---------------------------------------------------
# Keep these OUT of the repo directory: it lives in OneDrive, which would sync
# every file in site-packages. Prefer ~/.venvs/garg-aml.
.venv/
venv/
env/

# --- Tooling caches ---------------------------------------------------------
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
coverage.xml
htmlcov/
site/

# --- OS / editor ------------------------------------------------------------
.DS_Store
.idea/
.vscode/
*.swp
25 changes: 25 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.2
hooks:
- id: ruff-check
args: [--fix]
- id: ruff-format

- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.18.2
hooks:
- id: mypy
additional_dependencies: [numpy, pandas-stubs]

- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: end-of-file-fixer
- id: trailing-whitespace
- id: check-yaml
- id: check-toml
# Golden fixtures are small; this guards against a large file arriving by
# accident, not against them.
- id: check-added-large-files
args: [--maxkb=1024]
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Changelog

All notable changes to this project are documented here, following
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project adheres
to [Semantic Versioning](https://semver.org/spec/v2.0.0.html); while the version
is `0.x` the public API may change with a minor bump, always with an entry here.

## [Unreleased]

### Added

- Project skeleton: packaging, lint/type/test configuration, CI and docs scaffold.
- Frozen-behaviour fixtures under `tests/golden/`, generated from the
pre-extraction implementation, with the tooling that built them in `tools/`.
- Initial architecture decision records under `docs/decisions/`.
33 changes: 33 additions & 0 deletions CITATION.cff
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
cff-version: 1.2.0
title: garg-aml
message: >-
If you use this software, please cite both the software and the paper it
implements.
type: software
authors:
- given-names: Bruno
family-names: Deprez
email: bruno.deprez@kuleuven.be
affiliation: KU Leuven
repository-code: "https://github.com/VerbekeLab/garg-aml"
license: MIT
version: 0.1.0.dev0
preferred-citation:
type: article
title: >-
GARG-AML against Smurfing: A Scalable and Interpretable Graph-Based
Framework for Anti-Money Laundering
authors:
- given-names: Bruno
family-names: Deprez
- given-names: Bart
family-names: Baesens
- given-names: Tim
family-names: Verdonck
- given-names: Wouter
family-names: Verbeke
year: 2025
journal: arXiv
identifiers:
- type: other
value: "arXiv:2506.04292"
91 changes: 91 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Contributing and maintaining

This file is the maintainer's manual. If you have inherited this package and
read nothing else, read this and `docs/decisions/`.

## Setup

Keep the virtual environment **outside** the repository — the working clone sits
in a OneDrive folder, which would otherwise sync all of `site-packages`.

```bash
python -m venv ~/.venvs/garg-aml
source ~/.venvs/garg-aml/bin/activate
pip install -e ".[dev,docs,progress,parallel,sklearn]"
pre-commit install
```

## The rules that matter

**1. `tests/golden/` is frozen behaviour, not test scaffolding.** Those files
were generated from the implementation that produced the published paper
results. Never edit one to make a test pass — a diff means the code changed.
Regenerating them is a separate commit with its own justification, and it
invalidates comparability with everything published before it.

**2. Some edge cases look like bugs and are not.** An off-diagonal block with no
free entries has density 1; a node whose block sizes are all zero scores -1;
isolated nodes get zero for every neighbour statistic. Each has an ADR in
`docs/decisions/`. Changing any of them changes every published number.

**3. The library does no I/O and prints nothing.** Objects in, objects out. No
file paths, no `os.chdir`, no `print`. Use `logging.getLogger("garg_aml")`.

**4. Core dependencies are numpy, pandas, networkx and scipy.** Everything else
is an optional extra behind a guarded import. In particular the package must
never depend on PyTorch.

## Checks

```bash
ruff check . && ruff format --check .
mypy
pytest
```

`pre-commit run --all-files` runs the first two. CI runs all three on Python
3.10-3.13, plus a weekly scheduled run against unpinned dependencies so that
upstream drift surfaces before a user reports it.

Coverage must stay at or above 90 %, and that is a hard failure rather than a
badge.

## Definition of done

1. Golden tests pass unchanged.
2. `ruff` and `mypy` are clean.
3. Public API has a NumPy-style docstring with a paper reference and a runnable
doctest.
4. `CHANGELOG.md` updated in the same commit as the change.
5. Any non-obvious decision recorded as an ADR in `docs/decisions/`.

## Releasing

Publishing uses **PyPI Trusted Publishing** from GitHub Actions. There is no API
token anywhere, so nothing has to be rotated or handed over.

1. Update `CHANGELOG.md`: move `[Unreleased]` entries under the new version.
2. Bump `__version__` in `src/garg_aml/__init__.py` and `version:` in
`CITATION.cff`.
3. Commit, then tag: `git tag v0.1.0 && git push origin main --tags`.
4. The `release` workflow builds and publishes; Zenodo mints a DOI from the
GitHub release.

### If you cannot publish to PyPI

The `garg-aml` name has a single owner. If that account is unreachable, the code
is still safe — it lives in the `VerbekeLab` organisation — but the name is not
reclaimable quickly. Publish under a new name instead: change `name` in
`pyproject.toml`, register Trusted Publishing for the new project, and release
as above. The Zenodo DOI and git tags remain the citable artefacts either way,
so nothing published in a paper depends on PyPI being writable.

## Where things live

| | |
|---|---|
| `src/garg_aml/` | the library; leading-underscore modules are private |
| `tests/golden/` | frozen fixtures — see `tests/golden/README.md` |
| `tools/` | the fixture generator; runs in the research repo, not here |
| `docs/decisions/` | architecture decision records |
| [B-Deprez/GARG-AML](https://github.com/B-Deprez/GARG-AML) | experiments, baselines, paper reproduction |
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# garg-aml

Graph-based detection of **smurfing** patterns in transaction networks.

Smurfing moves money from one account to another through intermediate mules, so
source and target never transact directly. In the second-order 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 account
by exactly that contrast — one number in [-1, 1], computed from local structure
alone, with no training and no labels.

> **Status: pre-release.** The scoring core is being extracted from the
> [research repository](https://github.com/B-Deprez/GARG-AML). The public API
> lands in 0.1.0.

## Install

```bash
pip install garg-aml
```

## Citation

If you use this package, please cite the paper:

```bibtex
@article{deprez2025gargaml,
title = {{GARG-AML} against Smurfing: A Scalable and Interpretable
Graph-Based Framework for Anti-Money Laundering},
author = {Deprez, Bruno and Baesens, Bart and Verdonck, Tim and
Verbeke, Wouter},
journal = {arXiv preprint arXiv:2506.04292},
year = {2025}
}
```

## Links

- Paper: [arXiv:2506.04292](https://arxiv.org/abs/2506.04292)
- Experiments and paper reproduction: [B-Deprez/GARG-AML](https://github.com/B-Deprez/GARG-AML)
- Contributing and release process: [CONTRIBUTING.md](CONTRIBUTING.md)

## Licence

MIT — see [LICENSE](LICENSE).
17 changes: 17 additions & 0 deletions docs/decisions/0001-weighted-average-default.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# 0001 — `weighted_average` is the default score type

**Decision.** `score(...)` defaults to `score_type="weighted_average"`.

**Context.** Two aggregations exist. `basic` takes an unweighted mean over the
penalty blocks; `weighted_average` weights each block by its number of free
entries — the `l1`, `l3` sizes of Eq. 8 and their directed analogues. The
pre-extraction code defaulted to `basic`, but every experiment in the paper
passed `weighted_average` explicitly.

**Why.** A default that no published result uses is a trap. Weighting by block
size is also the better estimator: an unweighted mean lets a two-entry block
count as much as a two-hundred-entry one.

**Consequence.** Anyone porting code that relied on the old default gets
different numbers. This is called out in the changelog and the migration notes;
`score_type="basic"` remains available.
19 changes: 19 additions & 0 deletions docs/decisions/0002-louvain-is-explicit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# 0002 — Louvain reduction is a separate, opt-in step

**Decision.** `reduce_graph(graph, resolution=10, seed=1997)` is its own
function. `score()` does not reduce unless asked (`score(graph, reduce=True)`).

**Context.** The published pipeline partitions the graph with Louvain and keeps
only intra-community edges before scoring. On the IBM HI-Small data that removes
the large majority of edges.

**Why.** The step is **lossy** and it is a modelling choice, not an
implementation detail — it changes which neighbourhoods exist at all, and
reviewers of the paper questioned it directly. Burying it inside the scorer
would hide the single most consequential preprocessing decision from the person
interpreting a score, which is the opposite of what an interpretable method
should do.

**Consequence.** Users must decide. The documentation explains the trade-off
rather than choosing for them, and `resolution` has to be passed explicitly in
code that reproduces the paper, so a sweep over it is visible in the diff.
26 changes: 26 additions & 0 deletions docs/decisions/0003-frozen-edge-cases.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# 0003 — Three degenerate cases return fixed values

**Decision.** These are preserved exactly as the pre-extraction code computed
them:

| Case | Value |
|---|---|
| Off-diagonal block with no free entries | density **1** |
| All block sizes zero (undirected) | score **-1** |
| Node with no neighbours | **0** for all four neighbour statistics |

**Context.** All three arise on tiny or isolated neighbourhoods. The third was
implemented as a bare `except:` around a numpy reduction over an empty list; it
is now an explicit branch with the same result.

**Why.** They produced the published numbers. Each is also defensible on its own
terms: an empty penalty block should not penalise, a node with no structure is
maximally far from smurfing, and a node with no neighbours has no neighbourhood
statistics to report. But the reason they are *frozen* is reproducibility, not
elegance.

**Consequence.** Changing any of them changes every published result. They look
like bugs and will attract cleanup; the golden fixtures under `tests/golden/`
fail loudly if one is touched. Louvain reduction leaves many isolated nodes
behind — 18 of the 24 in the toy fixture — so these paths are exercised far more
often than their obscurity suggests.
17 changes: 17 additions & 0 deletions docs/decisions/0004-two-stage-pipeline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# 0004 — Scoring is two-stage, and there is only one implementation

**Decision.** The canonical path is `block_measures()` → `score_from_measures()`.
`score()` is a thin wrapper over both, never a parallel implementation.

**Context.** The pre-extraction code had two paths that computed the same thing:
a serial `GARG_AML(G)` convenience function, and the two-stage route that every
experiment actually used. They had drifted — see
[0007](0007-directed-score-is-equation-14.md).

**Why.** Two implementations of one formula is how the drift happened. The split
is also genuinely useful: block measures are the expensive part and are worth
persisting, and the cheap aggregation can then be re-run under a different
`score_type` without recomputing anything.

**Consequence.** A new aggregation or a new score variant extends stage two and
must never introduce a second stage one.
17 changes: 17 additions & 0 deletions docs/decisions/0005-synthetic-generator-differs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# 0005 — The synthetic generator does not reproduce the paper's datasets

**Decision.** `garg_aml.synthetic` is a fresh networkx implementation. The
igraph-based generator that produced the paper's 66 synthetic datasets stays in
the research repository.

**Context.** The original generator depends on igraph, and its graphs are
reproducible only under specific igraph versions and RNG seeds.

**Why.** Requiring igraph for a tutorial is a poor trade, and the package's
generator exists to serve documentation examples and tests — it needs to be
*reproducible*, not *identical to the paper's*. Chasing bit-for-bit parity would
have pinned an extra core dependency for no user-facing benefit.

**Consequence.** This is the one deliberate non-equivalence in the extraction.
Anything reproducing the paper's synthetic results must use the research
repository's generator. Do not "unify" the two.
Loading
Loading