Skip to content

Commit 725d1eb

Browse files
fabio-rovaiclaude
andcommitted
Add evaluation harness and hardness knob for detector benchmarking
The README frames this generator as a way to benchmark fraud detectors, but it ships no scoring and the default data is easy to separate by trivial heuristics (single sentinel fraud amount, disjoint rings, no legitimate cycles). This change is additive and backward compatible (low hardness reproduces the original behaviour exactly): - evaluate.py + gen-fraud-graph-evaluate CLI: precision/recall/F1 at account and ring level against fraud_cases.csv, with a confusion summary. - --hardness {low,medium,high}: jitters fraud amounts, overlaps rings, and injects decoy legitimate high-value cycles so amount-thresholding and cycle-topology each fail. - tests/test_evaluate.py: 12 tests (exact metrics on a fixture, ring threshold, loaders, hardness presets, generator smoke test). Full suite 54 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ac6cdfd commit 725d1eb

7 files changed

Lines changed: 776 additions & 2 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ Changelog = "https://github.com/SantanderAI/gen-fraud-graph/blob/main/CHANGELOG.
7070

7171
[project.scripts]
7272
gen-fraud-graph = "gen_fraud_graph.cli:main"
73+
gen-fraud-graph-evaluate = "gen_fraud_graph.evaluate:main"
7374

7475
[tool.setuptools.packages.find]
7576
where = ["src"]

src/gen_fraud_graph/cli.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,16 @@ def main(argv: list[str] | None = None) -> None:
7979
default=False,
8080
help="Skip account generation (useful when resuming).",
8181
)
82+
parser.add_argument(
83+
"--hardness",
84+
type=str,
85+
choices=["low", "medium", "high"],
86+
default="low",
87+
help="Difficulty preset controlling how hard the fraud data is to "
88+
"separate by trivial heuristics. 'low' (default) is backward "
89+
"compatible. 'medium'/'high' jitter fraud amounts, overlap rings, and "
90+
"inject decoy legitimate high-value cycles. Default: low.",
91+
)
8292

8393
args = parser.parse_args(argv)
8494

@@ -91,6 +101,7 @@ def main(argv: list[str] | None = None) -> None:
91101
output_format=args.format,
92102
compress=args.compress,
93103
output_dir=args.output,
104+
hardness=args.hardness,
94105
)
95106

96107
generator = FraudGraphGenerator(cfg)

src/gen_fraud_graph/config.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,24 @@
66
from dataclasses import dataclass, field
77
from typing import Literal
88

9+
Hardness = Literal["low", "medium", "high"]
10+
11+
# Difficulty presets controlling how hard the data is to separate by trivial
12+
# heuristics. ``low`` reproduces the original behaviour exactly (a single
13+
# sentinel fraud amount, disjoint rings, no decoy cycles), so defaults are
14+
# backward compatible.
15+
_HARDNESS_PRESETS: dict[str, dict[str, float]] = {
16+
# amount_jitter: relative +/- jitter applied to the fraud sentinel amount
17+
# (0.0 means every fraud edge keeps the exact sentinel).
18+
# ring_overlap: probability that a new ring reuses (shares) an account
19+
# from an existing ring, creating overlapping rings.
20+
# decoy_ratio: number of decoy legitimate high-value cycles to inject,
21+
# expressed as a fraction of the fraud-ring count.
22+
"low": {"amount_jitter": 0.0, "ring_overlap": 0.0, "decoy_ratio": 0.0},
23+
"medium": {"amount_jitter": 0.25, "ring_overlap": 0.25, "decoy_ratio": 0.5},
24+
"high": {"amount_jitter": 0.5, "ring_overlap": 0.5, "decoy_ratio": 1.0},
25+
}
26+
927

1028
@dataclass
1129
class Config:
@@ -26,6 +44,13 @@ class Config:
2644
bulk-load headers).
2745
compress: Whether to ZIP the output CSV files.
2846
output_dir: Destination directory for generated files.
47+
hardness: Difficulty preset (``"low"``, ``"medium"`` or ``"high"``)
48+
controlling how hard the fraud data is to separate by trivial
49+
heuristics. ``"low"`` (the default) is backward compatible: fraud
50+
edges keep a single sentinel amount, rings are disjoint, and no
51+
decoy cycles are injected. Higher levels jitter fraud amounts,
52+
overlap rings, and inject legitimate high-value (decoy) cycles so
53+
that pure amount-thresholding and pure cycle-topology each fail.
2954
"""
3055

3156
scale_factor: float = 1.0
@@ -38,13 +63,26 @@ class Config:
3863
output_format: Literal["csv", "neptune"] = "csv"
3964
compress: bool = False
4065
output_dir: str = "data"
66+
hardness: Hardness = "low"
4167

4268
# Derived — computed in __post_init__
4369
num_accounts: int = field(init=False)
4470
num_transactions: int = field(init=False)
71+
amount_jitter: float = field(init=False)
72+
ring_overlap: float = field(init=False)
73+
decoy_ratio: float = field(init=False)
4574

4675
def __post_init__(self) -> None:
4776
self.num_accounts = int(10_000_000 * self.scale_factor)
4877
self.num_transactions = int(90_000_000 * self.scale_factor)
4978
if self.num_fraud_rings is None:
5079
self.num_fraud_rings = max(10, int(1000 * self.scale_factor))
80+
81+
if self.hardness not in _HARDNESS_PRESETS:
82+
raise ValueError(
83+
f"hardness must be one of {sorted(_HARDNESS_PRESETS)}, got {self.hardness!r}"
84+
)
85+
preset = _HARDNESS_PRESETS[self.hardness]
86+
self.amount_jitter = preset["amount_jitter"]
87+
self.ring_overlap = preset["ring_overlap"]
88+
self.decoy_ratio = preset["decoy_ratio"]

0 commit comments

Comments
 (0)