Skip to content
Open
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
5 changes: 5 additions & 0 deletions src/gen_fraud_graph/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ class Config:
scale_factor: float = 1.0
num_fraud_rings: int | None = None
fraud_ring_depth_range: tuple[int, int] = (4, 7)
num_structuring_patterns: int | None = None
structuring_smurfs_range: tuple[int, int] = (3, 10)
structuring_amount_range: tuple[float, float] = (8_000.00, 9_900.00)
embedding_provider: Literal["fake", "local", "openai"] = "fake"
embedding_dim: int = 768
workers: int = 1
Expand All @@ -48,3 +51,5 @@ def __post_init__(self) -> None:
self.num_transactions = int(90_000_000 * self.scale_factor)
if self.num_fraud_rings is None:
self.num_fraud_rings = max(10, int(1000 * self.scale_factor))
if self.num_structuring_patterns is None:
self.num_structuring_patterns = max(10, int(500 * self.scale_factor))
36 changes: 28 additions & 8 deletions src/gen_fraud_graph/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from gen_fraud_graph.config import Config
from gen_fraud_graph.embeddings import EmbeddingGenerator
from gen_fraud_graph.exporters import get_headers
from gen_fraud_graph.typologies import FraudRingGenerator
from gen_fraud_graph.typologies import FraudRingGenerator, StructuringGenerator

# ---------------------------------------------------------------------------
# Normal transaction descriptions
Expand Down Expand Up @@ -218,8 +218,7 @@ def _generate_transactions_chunk(
writer.writerows(final_rows)
if (i + chunk_count) % 50_000 == 0:
print(
f" Worker {worker_id} Batch {batch_id}: "
f"{i + chunk_count} transactions written"
f" Worker {worker_id} Batch {batch_id}: {i + chunk_count} transactions written"
)

return f"Worker {worker_id} Batch {batch_id}: Generated {count} transactions"
Expand Down Expand Up @@ -349,21 +348,42 @@ def _generate_transactions(self) -> None:

def _generate_fraud(self) -> None:
cfg = self.cfg
print("\n[Phase 3] Generating fraud rings...")
print("\n[Phase 3] Generating fraud patterns...")

embedder = EmbeddingGenerator(cfg.embedding_provider, dim=cfg.embedding_dim)
# cfg.num_fraud_rings is resolved to int in Config.__post_init__

# --- cyclic money-laundering rings ---
assert cfg.num_fraud_rings is not None
fraud_gen = FraudRingGenerator(
ring_gen = FraudRingGenerator(
num_rings=cfg.num_fraud_rings,
depth_range=cfg.fraud_ring_depth_range,
)
n_tx, _ = fraud_gen.generate(
n_ring_tx, next_tx_id = ring_gen.generate(
max_account_id=cfg.num_accounts,
start_tx_id=cfg.num_transactions,
embedder=embedder,
output_dir=cfg.output_dir,
fmt=cfg.output_format,
compress=cfg.compress,
)
print(f" Injected {n_tx:,} fraud transactions across {cfg.num_fraud_rings:,} rings")
print(f" Injected {n_ring_tx:,} ring transactions across {cfg.num_fraud_rings:,} rings")

# --- structuring / smurfing patterns ---
assert cfg.num_structuring_patterns is not None
struct_gen = StructuringGenerator(
num_patterns=cfg.num_structuring_patterns,
smurfs_range=cfg.structuring_smurfs_range,
amount_range=cfg.structuring_amount_range,
)
n_struct_tx, _ = struct_gen.generate(
max_account_id=cfg.num_accounts,
start_tx_id=next_tx_id,
embedder=embedder,
output_dir=cfg.output_dir,
fmt=cfg.output_format,
compress=cfg.compress,
)
print(
f" Injected {n_struct_tx:,} structuring transactions "
f"across {cfg.num_structuring_patterns:,} patterns"
)
157 changes: 156 additions & 1 deletion src/gen_fraud_graph/typologies.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import numpy as np

from gen_fraud_graph.embeddings import EmbeddingGenerator
from gen_fraud_graph.exporters import get_headers, write_output
from gen_fraud_graph.exporters import append_csv, get_headers, write_output

# ---------------------------------------------------------------------------
# Suspicious transaction descriptions used across typologies
Expand All @@ -28,6 +28,16 @@
"high-value cross-border wire",
]

# Description specific to structuring/smurfing patterns.
STRUCTURING_DESCRIPTIONS: list[str] = [
"cash deposit below reporting threshold",
"multiple small deposits same day",
"structured payment just under limit",
"smurfing deposit via branch teller",
"incremental cash deposit sub-threshold",
"repeated near limit ATM deposit",
"fragmented transfer to evade detection",
]

# ---------------------------------------------------------------------------
# Fraud ring generator (cyclic money-laundering patterns)
Expand Down Expand Up @@ -152,3 +162,148 @@ def generate(
write_output(file_cases, headers_cases, case_rows, compress=compress)

return len(tx_rows), current_tx_id


@dataclass
class StructuringGenerator:
"""Generate structuring (smurfing) fraud patterns.
In a structuring scheme a single coordinator account receives funds from
several "smurf" accounts, each sending amounts just below the BSA/FinCEN
Cash Transaction Report (CTR) threshold of $10,000.00. The coordinator
aggregates these deposits to move a larger sum without triggering a single
reportable event.

Graph shape::

smurf_0 -> coordinator
smurf_1 -> coordinator
...
smurf_N -> coordinator
Multiple sources converge on one node.
This is a structurally distinct from the cyclic ring produced by
:class:'FraudRingGenerator' and exercises different subgraph-detection
algorithms.

Args:
num_patterns: How many structuring patterns to create.
smurfs_range: ''(min_smurfs, mac_smurfs)'' - number of feeder
accounts per pattern. Mirrors the real world practice of using
3-10 smurfs to stay inconspicuous.
amount_range: ''(min_amount, max_amount)'' - each smurf transfer is
drawn uniformly form this range. Defaults to $8_000-$9_900,
deliberately sub-threshold.
"""

num_patterns: int = 100
smurfs_range: tuple[int, int] = (3, 10)
amount_range: tuple[float, float] = (8_000.00, 9_900.00)
_descriptions: list[str] = field(default_factory=lambda: STRUCTURING_DESCRIPTIONS)

def generate(
self,
max_account_id: int,
start_tx_id: int,
embedder: EmbeddingGenerator,
output_dir: str,
fmt: str = "csv",
compress: bool = False,
) -> tuple[int, int]:
"""Generate structuring patterns and append to fraud output files.

Output files are appended to the same ``fraud/`` directory used by
:class:`FraudRingGenerator` so a single pipeline run can inject both
typologies into one dataset.

Args:
max_account_id: Upper bound of account IDs already generated.
start_tx_id: First transaction ID to use (must not collide with
IDs already written by the ring generator or normal txs).
embedder: Embedding generator instance — same one used by the
ring generator so embedding provenance is consistent.
output_dir: Root output directory.
fmt: ``"csv"`` or ``"neptune"``.
compress: ZIP the output CSV files.

Returns:
``(num_fraud_transactions, next_tx_id)``
"""
import os

from tqdm import tqdm

fraud_dir = os.path.join(output_dir, "fraud")
os.makedirs(fraud_dir, exist_ok=True)

headers_tx = get_headers("transaction", fmt) # type: ignore[arg-type]
headers_cases = [
"pattern_id",
"start_acc_id",
"pattern_type",
"depth",
"involved_accounts",
]

tx_rows: list[list] = []
case_rows: list[list] = []
current_tx_id = start_tx_id

for pattern_id in tqdm(range(self.num_patterns), desc="Generating structuring patterns"):
min_s, max_s = self.smurfs_range
num_smurfs = random.randint(min_s, max_s)

# The coordinator sits at a random offset; smurfs occupy the
# num_smurfs slots immediately after it. We need num_smurfs + 1
# consecutive IDs so we guard against tiny account pools.
needed = num_smurfs + 1
if max_account_id < needed:
coordinator_idx = 0
else:
coordinator_idx = random.randint(0, max_account_id - needed)

coordinator = f"acc_{coordinator_idx}"
smurfs = [f"acc_{coordinator_idx + 1 + i}" for i in range(num_smurfs)]
involved = "|".join([coordinator] + smurfs)

batch_texts: list[str] = []
batch_rows: list[list] = []

for smurf in smurfs:
amount = round(random.uniform(*self.amount_range), 2)
desc = random.choice(self._descriptions)
batch_texts.append(desc)

row: list = [f"tx_{current_tx_id}", smurf, coordinator]
if fmt == "neptune":
row.append("TRANSFER")
row.extend([amount, "2024-01-01T12:00:00", desc])
batch_rows.append(row)
current_tx_id += 1

embeddings = embedder.generate(batch_texts)

for idx, r in enumerate(batch_rows):
if fmt == "neptune":
tx_rows.append(r)
else:
vec = embeddings[idx]
if isinstance(vec, np.ndarray):
vec = vec.tolist()
tx_rows.append(r + ["|".join(map(str, vec))])

case_rows.append(
[
f"struct_{pattern_id}",
coordinator,
"structuring",
num_smurfs, # depth = number of feeder hops
involved,
]
)

# Append to the same fraud files so both typologies land in one CSV.
file_tx = os.path.join(fraud_dir, "transactions_fraud")
file_cases = os.path.join(fraud_dir, "fraud_cases")
append_csv(file_tx + ".csv", headers_tx, tx_rows)
append_csv(file_cases + ".csv", headers_cases, case_rows)

return len(tx_rows), current_tx_id
27 changes: 18 additions & 9 deletions src/gen_fraud_graph/verify.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,19 +47,28 @@ def verify_fraud_patterns(
reader = csv.DictReader(fh)
for row in reader:
pattern_id = row["pattern_id"]
pattern_type = row.get("pattern_type", "cycle")
accounts = row["involved_accounts"].split("|")
depth = int(row["depth"])

# Check that the cycle edges exist
for k in range(depth):
src = accounts[k]
dst = accounts[(k + 1) % depth]
if dst not in edges.get(src, set()):
print(f" FAIL: {pattern_id} — missing edge {src} -> {dst}")
all_valid = False
break
if pattern_type == "cycle":
for k in range(depth):
src = accounts[k]
dst = accounts[(k + 1) % depth]
if dst not in edges.get(src, set()):
print(f" FAIL: {pattern_id} — missing edge {src} -> {dst}")
all_valid = False
break
elif pattern_type == "structuring":
coordinator = accounts[0]
smurfs = accounts[1:]
for smurf in smurfs:
if coordinator not in edges.get(smurf, set()):
print(f" FAIL: {pattern_id} — missing edge {smurf} -> {coordinator}")
all_valid = False
break
else:
continue
print(f" WARN: {pattern_id} — unknown pattern_type '{pattern_type}', skipping")

if all_valid:
print("All fraud patterns verified successfully.")
Expand Down
Loading
Loading