Skip to content

Commit 67d2191

Browse files
feat(typologies): add structuring pattern generator
Adds StructuringGenerator — a fan-in star typology where multiple smurf accounts each send sub-threshold amounts to a single coordinator, modelling BSA/FinCEN structuring (smurfing) patterns. Changes: - typologies.py: add StructuringGenerator and STRUCTURING_DESCRIPTIONS - config.py: add num_structuring_patterns, structuring_smurfs_range, structuring_amount_range with auto-scaling defaults - generator.py: wire StructuringGenerator into Phase 3 pipeline - verify.py: dispatch verification on pattern_type so structuring fan-in patterns are validated correctly alongside cycle rings - test_generator.py: 8 new tests in TestStructuringGenerator
1 parent ac6cdfd commit 67d2191

5 files changed

Lines changed: 462 additions & 17 deletions

File tree

src/gen_fraud_graph/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ class Config:
3131
scale_factor: float = 1.0
3232
num_fraud_rings: int | None = None
3333
fraud_ring_depth_range: tuple[int, int] = (4, 7)
34+
num_structuring_patterns: int | None = None
35+
structuring_smurfs_range: tuple[int, int] = (3, 10)
36+
structuring_amount_range: tuple[float, float] = (8_000.00, 9_900.00)
3437
embedding_provider: Literal["fake", "local", "openai"] = "fake"
3538
embedding_dim: int = 768
3639
workers: int = 1
@@ -48,3 +51,5 @@ def __post_init__(self) -> None:
4851
self.num_transactions = int(90_000_000 * self.scale_factor)
4952
if self.num_fraud_rings is None:
5053
self.num_fraud_rings = max(10, int(1000 * self.scale_factor))
54+
if self.num_structuring_patterns is None:
55+
self.num_structuring_patterns = max(10, int(500 * self.scale_factor))

src/gen_fraud_graph/generator.py

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from gen_fraud_graph.config import Config
1717
from gen_fraud_graph.embeddings import EmbeddingGenerator
1818
from gen_fraud_graph.exporters import get_headers
19-
from gen_fraud_graph.typologies import FraudRingGenerator
19+
from gen_fraud_graph.typologies import FraudRingGenerator, StructuringGenerator
2020

2121
# ---------------------------------------------------------------------------
2222
# Normal transaction descriptions
@@ -327,23 +327,46 @@ def _generate_transactions(self) -> None:
327327
for f in tqdm(futures, total=len(futures), desc="Transaction batches"):
328328
f.result()
329329

330+
331+
330332
def _generate_fraud(self) -> None:
331333
cfg = self.cfg
332-
print("\n[Phase 3] Generating fraud rings...")
334+
print("\n[Phase 3] Generating fraud patterns...")
333335

334336
embedder = EmbeddingGenerator(cfg.embedding_provider, dim=cfg.embedding_dim)
335-
# cfg.num_fraud_rings is resolved to int in Config.__post_init__
337+
338+
# --- cyclic money-laundering rings ---
336339
assert cfg.num_fraud_rings is not None
337-
fraud_gen = FraudRingGenerator(
340+
ring_gen = FraudRingGenerator(
338341
num_rings=cfg.num_fraud_rings,
339342
depth_range=cfg.fraud_ring_depth_range,
340343
)
341-
n_tx, _ = fraud_gen.generate(
344+
n_ring_tx, next_tx_id = ring_gen.generate(
342345
max_account_id=cfg.num_accounts,
343346
start_tx_id=cfg.num_transactions,
344347
embedder=embedder,
345348
output_dir=cfg.output_dir,
346349
fmt=cfg.output_format,
347350
compress=cfg.compress,
348351
)
349-
print(f" Injected {n_tx:,} fraud transactions across {cfg.num_fraud_rings:,} rings")
352+
print(f" Injected {n_ring_tx:,} ring transactions across {cfg.num_fraud_rings:,} rings")
353+
354+
# --- structuring / smurfing patterns ---
355+
assert cfg.num_structuring_patterns is not None
356+
struct_gen = StructuringGenerator(
357+
num_patterns=cfg.num_structuring_patterns,
358+
smurfs_range=cfg.structuring_smurfs_range,
359+
amount_range=cfg.structuring_amount_range,
360+
)
361+
n_struct_tx, _ = struct_gen.generate(
362+
max_account_id=cfg.num_accounts,
363+
start_tx_id=next_tx_id,
364+
embedder=embedder,
365+
output_dir=cfg.output_dir,
366+
fmt=cfg.output_format,
367+
compress=cfg.compress,
368+
)
369+
print(
370+
f" Injected {n_struct_tx:,} structuring transactions "
371+
f"across {cfg.num_structuring_patterns:,} patterns"
372+
)

src/gen_fraud_graph/typologies.py

Lines changed: 156 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import numpy as np
1212

1313
from gen_fraud_graph.embeddings import EmbeddingGenerator
14-
from gen_fraud_graph.exporters import get_headers, write_output
14+
from gen_fraud_graph.exporters import append_csv, get_headers, write_output
1515

1616
# ---------------------------------------------------------------------------
1717
# Suspicious transaction descriptions used across typologies
@@ -28,6 +28,16 @@
2828
"high-value cross-border wire",
2929
]
3030

31+
#Description specififc to structuring/smurfing patterns.
32+
STRUCTURING_DESCRIPTIONS: list[str] = [
33+
"cash deposit below reporting threshold",
34+
"multiple small deposits same day",
35+
"structured payment just under limit",
36+
"smurfing deposit via branch teller",
37+
"incremental cash deposit sub-threshold",
38+
"repeated near limit ATM deposit",
39+
"fragmented transfer to evade detection",
40+
]
3141

3242
# ---------------------------------------------------------------------------
3343
# Fraud ring generator (cyclic money-laundering patterns)
@@ -142,3 +152,148 @@ def generate(
142152
write_output(file_cases, headers_cases, case_rows, compress=compress)
143153

144154
return len(tx_rows), current_tx_id
155+
156+
@dataclass
157+
class StructuringGenerator:
158+
"""Generate structuring (smurfing) fraud patterns.
159+
In a structuring scheme a single coordinator account receives funds from
160+
several "smurf" accounts, each sending amounts just below the BSA/FinCEN
161+
Cash Transaction Report (CTR) threshold of $10,000.00. The coordinator
162+
aggregates these deposits to move a larger sum without triggering a single
163+
reportable event.
164+
165+
Graph shape::
166+
167+
smurf_0 -> coordinator
168+
smurf_1 -> coordinator
169+
...
170+
smurf_N -> coordinator
171+
Multiple sources converge on one node.
172+
This is a structurally distinct from the cyclic ring produced by
173+
:class:'FraudRingGenerator' and exercises different subgraph-detection
174+
algorithms.
175+
176+
Args:
177+
num_patterns: How many structuring patterns to create.
178+
smurfs_range: ''(min_smurfs, mac_smurfs)'' - number of feeder
179+
accounts per pattern. Mirrors the real world practice of using
180+
3-10 smurfs to stay inconspicuous.
181+
amount_range: ''(min_amount, max_amount)'' - each smurf transfer is
182+
drawn uniformly form this range. Defaults to $8_000-$9_900,
183+
deliberately sub-threshold.
184+
"""
185+
186+
187+
num_patterns: int = 100
188+
smurfs_range: tuple[int, int] = (3, 10)
189+
amount_range: tuple[float, float] = (8_000.00, 9_900.00)
190+
_descriptions: list[str] = field(default_factory=lambda: STRUCTURING_DESCRIPTIONS)
191+
192+
def generate(
193+
self,
194+
max_account_id: int,
195+
start_tx_id: int,
196+
embedder: EmbeddingGenerator,
197+
output_dir: str,
198+
fmt: str = "csv",
199+
compress: bool = False,
200+
) -> tuple[int, int]:
201+
"""Generate structuring patterns and append to fraud output files.
202+
203+
Output files are appended to the same ``fraud/`` directory used by
204+
:class:`FraudRingGenerator` so a single pipeline run can inject both
205+
typologies into one dataset.
206+
207+
Args:
208+
max_account_id: Upper bound of account IDs already generated.
209+
start_tx_id: First transaction ID to use (must not collide with
210+
IDs already written by the ring generator or normal txs).
211+
embedder: Embedding generator instance — same one used by the
212+
ring generator so embedding provenance is consistent.
213+
output_dir: Root output directory.
214+
fmt: ``"csv"`` or ``"neptune"``.
215+
compress: ZIP the output CSV files.
216+
217+
Returns:
218+
``(num_fraud_transactions, next_tx_id)``
219+
"""
220+
import os
221+
222+
from tqdm import tqdm
223+
224+
fraud_dir = os.path.join(output_dir, "fraud")
225+
os.makedirs(fraud_dir, exist_ok=True)
226+
227+
headers_tx = get_headers("transaction", fmt) # type: ignore[arg-type]
228+
headers_cases = [
229+
"pattern_id",
230+
"start_acc_id",
231+
"pattern_type",
232+
"depth",
233+
"involved_accounts",
234+
]
235+
236+
tx_rows: list[list] = []
237+
case_rows: list[list] = []
238+
current_tx_id = start_tx_id
239+
240+
for pattern_id in tqdm(range(self.num_patterns), desc="Generating structuring patterns"):
241+
min_s, max_s = self.smurfs_range
242+
num_smurfs = random.randint(min_s, max_s)
243+
244+
# The coordinator sits at a random offset; smurfs occupy the
245+
# num_smurfs slots immediately after it. We need num_smurfs + 1
246+
# consecutive IDs so we guard against tiny account pools.
247+
needed = num_smurfs + 1
248+
if max_account_id < needed:
249+
coordinator_idx = 0
250+
else:
251+
coordinator_idx = random.randint(0, max_account_id - needed)
252+
253+
coordinator = f"acc_{coordinator_idx}"
254+
smurfs = [f"acc_{coordinator_idx + 1 + i}" for i in range(num_smurfs)]
255+
involved = "|".join([coordinator] + smurfs)
256+
257+
batch_texts: list[str] = []
258+
batch_rows: list[list] = []
259+
260+
for smurf in smurfs:
261+
amount = round(random.uniform(*self.amount_range), 2)
262+
desc = random.choice(self._descriptions)
263+
batch_texts.append(desc)
264+
265+
row: list = [f"tx_{current_tx_id}", smurf, coordinator]
266+
if fmt == "neptune":
267+
row.append("TRANSFER")
268+
row.extend([amount, "2024-01-01T12:00:00", desc])
269+
batch_rows.append(row)
270+
current_tx_id += 1
271+
272+
embeddings = embedder.generate(batch_texts)
273+
274+
for idx, r in enumerate(batch_rows):
275+
if fmt == "neptune":
276+
tx_rows.append(r)
277+
else:
278+
vec = embeddings[idx]
279+
if isinstance(vec, np.ndarray):
280+
vec = vec.tolist()
281+
tx_rows.append(r + ["|".join(map(str, vec))])
282+
283+
case_rows.append(
284+
[
285+
f"struct_{pattern_id}",
286+
coordinator,
287+
"structuring",
288+
num_smurfs, # depth = number of feeder hops
289+
involved,
290+
]
291+
)
292+
293+
# Append to the same fraud files so both typologies land in one CSV.
294+
file_tx = os.path.join(fraud_dir, "transactions_fraud")
295+
file_cases = os.path.join(fraud_dir, "fraud_cases")
296+
append_csv(file_tx + ".csv", headers_tx, tx_rows)
297+
append_csv(file_cases + ".csv", headers_cases, case_rows)
298+
299+
return len(tx_rows), current_tx_id

src/gen_fraud_graph/verify.py

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,19 +47,28 @@ def verify_fraud_patterns(
4747
reader = csv.DictReader(fh)
4848
for row in reader:
4949
pattern_id = row["pattern_id"]
50+
pattern_type = row.get("pattern_type", "cycle")
5051
accounts = row["involved_accounts"].split("|")
5152
depth = int(row["depth"])
5253

53-
# Check that the cycle edges exist
54-
for k in range(depth):
55-
src = accounts[k]
56-
dst = accounts[(k + 1) % depth]
57-
if dst not in edges.get(src, set()):
58-
print(f" FAIL: {pattern_id} — missing edge {src} -> {dst}")
59-
all_valid = False
60-
break
54+
if pattern_type == "cycle":
55+
for k in range(depth):
56+
src = accounts[k]
57+
dst = accounts[(k + 1) % depth]
58+
if dst not in edges.get(src, set()):
59+
print(f" FAIL: {pattern_id} — missing edge {src} -> {dst}")
60+
all_valid = False
61+
break
62+
elif pattern_type == "structuring":
63+
coordinator = accounts[0]
64+
smurfs = accounts[1:]
65+
for smurf in smurfs:
66+
if coordinator not in edges.get(smurf, set()):
67+
print(f" FAIL: {pattern_id} — missing edge {smurf} -> {coordinator}")
68+
all_valid = False
69+
break
6170
else:
62-
continue
71+
print(f" WARN: {pattern_id} — unknown pattern_type '{pattern_type}', skipping")
6372

6473
if all_valid:
6574
print("All fraud patterns verified successfully.")

0 commit comments

Comments
 (0)