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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ Changelog = "https://github.com/SantanderAI/gen-fraud-graph/blob/main/CHANGELOG.

[project.scripts]
gen-fraud-graph = "gen_fraud_graph.cli:main"
gen-fraud-graph-evaluate = "gen_fraud_graph.evaluate:main"

[tool.setuptools.packages.find]
where = ["src"]
Expand Down
11 changes: 11 additions & 0 deletions src/gen_fraud_graph/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,16 @@ def main(argv: list[str] | None = None) -> None:
default=False,
help="Skip account generation (useful when resuming).",
)
parser.add_argument(
"--hardness",
type=str,
choices=["low", "medium", "high"],
default="low",
help="Difficulty preset controlling how hard the fraud data is to "
"separate by trivial heuristics. 'low' (default) is backward "
"compatible. 'medium'/'high' jitter fraud amounts, overlap rings, and "
"inject decoy legitimate high-value cycles. Default: low.",
)

args = parser.parse_args(argv)

Expand All @@ -91,6 +101,7 @@ def main(argv: list[str] | None = None) -> None:
output_format=args.format,
compress=args.compress,
output_dir=args.output,
hardness=args.hardness,
)

generator = FraudGraphGenerator(cfg)
Expand Down
38 changes: 38 additions & 0 deletions src/gen_fraud_graph/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,24 @@
from dataclasses import dataclass, field
from typing import Literal

Hardness = Literal["low", "medium", "high"]

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


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

scale_factor: float = 1.0
Expand All @@ -38,13 +63,26 @@ class Config:
output_format: Literal["csv", "neptune"] = "csv"
compress: bool = False
output_dir: str = "data"
hardness: Hardness = "low"

# Derived — computed in __post_init__
num_accounts: int = field(init=False)
num_transactions: int = field(init=False)
amount_jitter: float = field(init=False)
ring_overlap: float = field(init=False)
decoy_ratio: float = field(init=False)

def __post_init__(self) -> None:
self.num_accounts = int(10_000_000 * self.scale_factor)
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.hardness not in _HARDNESS_PRESETS:
raise ValueError(
f"hardness must be one of {sorted(_HARDNESS_PRESETS)}, got {self.hardness!r}"
)
preset = _HARDNESS_PRESETS[self.hardness]
self.amount_jitter = preset["amount_jitter"]
self.ring_overlap = preset["ring_overlap"]
self.decoy_ratio = preset["decoy_ratio"]
Loading
Loading