razorshield-api / data.py
Vedant Sanjay Jadhav
feat: complete RazorShield AI risk platform
10ec54c
Raw
History Blame Contribute Delete
46 kB
"""
data.py
-------
End-to-end data acquisition + dataset construction for RazorShield.
Creates:
data/
raw/
ieee_cis/
processed/
dataset_a_model.parquet
dataset_b_scenarios.parquet
scenario_specs.json
metadata.json
Dataset A:
Public IEEE-CIS transaction data, normalized into a leakage-aware
transaction-level model dataset with a chronological train/val/test split.
Dataset B:
Defensive synthetic merchant scenarios. NVIDIA's hosted OpenAI-compatible
API generates ABSTRACT scenario specifications in parallel; Python/Numpy
generates the actual numeric transaction rows deterministically.
Important:
We deliberately do NOT ask the LLM to generate millions of transaction rows.
The LLM proposes bounded scenario parameters; the deterministic generator
creates the rows. This is more reproducible, cheaper, and easier to audit.
Environment:
KAGGLE_API_TOKEN / Kaggle credentials for IEEE-CIS download
NVIDIA_API_KEY for hosted NVIDIA inference
Typical usage:
python data.py --download-public
python data.py --build-model
python data.py --generate-scenarios
python data.py --all
Useful overrides:
--workers 4
--scenarios 60
--batch-size 5
--rows-per-minute-cap 50
--seed 42
"""
from __future__ import annotations
import argparse
import hashlib
import json
import logging
import math
import os
import re
import time
import zipfile
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
import numpy as np
import pandas as pd
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
ROOT = Path(__file__).resolve().parent
DATA_DIR = ROOT / "data"
RAW_DIR = DATA_DIR / "raw" / "ieee_cis"
PROCESSED_DIR = DATA_DIR / "processed"
KAGGLE_COMPETITION = "ieee-fraud-detection"
NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1"
DEFAULT_NVIDIA_MODEL = os.getenv(
"NVIDIA_MODEL",
"openai/gpt-oss-20b",
)
DEFAULT_WORKERS = int(os.getenv("NVIDIA_WORKERS", "4"))
DEFAULT_SCENARIOS = int(os.getenv("SYNTHETIC_SCENARIOS", "60"))
DEFAULT_BATCH_SIZE = int(os.getenv("NVIDIA_BATCH_SIZE", "5"))
DEFAULT_SEED = int(os.getenv("DATA_SEED", "42"))
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)s | %(message)s",
)
LOGGER = logging.getLogger("razorshield-data")
def load_environment() -> None:
"""Load environment variables from .env or api_keys.txt if present."""
env_file = ROOT / ".env"
if env_file.exists():
try:
import dotenv
dotenv.load_dotenv(env_file)
except ImportError:
pass
api_keys_file = ROOT / "api_keys.txt"
if api_keys_file.exists():
try:
text = api_keys_file.read_text(encoding="utf-8")
if not os.getenv("KAGGLE_API_TOKEN"):
m = re.search(r"kaggle api token:\s*(\S+)", text, re.IGNORECASE)
if m:
os.environ["KAGGLE_API_TOKEN"] = m.group(1).strip()
if not os.getenv("NVIDIA_API_KEY"):
m = re.search(r"api_key\s*=\s*[\"'](nvapi-\S+)[\"']", text)
if not m:
m = re.search(r"nvidia api token:\s*(\S+)", text, re.IGNORECASE)
if m:
os.environ["NVIDIA_API_KEY"] = m.group(1).strip()
except Exception as exc:
LOGGER.warning("Could not parse api_keys.txt: %s", exc)
# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------
@dataclass
class ScenarioSpec:
scenario_id: str
scenario_type: str
duration_minutes: int
spike_start_minute: int
spike_duration_minutes: int
baseline_txn_per_minute: float
spike_txn_multiplier: float
baseline_fraud_rate: float
spike_fraud_rate: float
amount_mean: float
amount_std: float
customer_count: int
device_count: int
new_device_rate: float
seed: int
ALLOWED_SCENARIOS = {
"normal",
"fraud_spike",
"volume_only_spike",
"amount_shift",
}
# ---------------------------------------------------------------------------
# Utility functions
# ---------------------------------------------------------------------------
def ensure_dirs() -> None:
RAW_DIR.mkdir(parents=True, exist_ok=True)
PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
def stable_id(value: Any, prefix: str = "") -> str:
digest = hashlib.sha1(str(value).encode("utf-8")).hexdigest()[:12]
return f"{prefix}{digest}"
def clamp(value: Any, low: float, high: float, default: float) -> float:
try:
value = float(value)
except (TypeError, ValueError):
return default
return float(np.clip(value, low, high))
def clamp_int(value: Any, low: int, high: int, default: int) -> int:
try:
value = int(float(value))
except (TypeError, ValueError):
return default
return int(np.clip(value, low, high))
def parse_json_from_text(text: str) -> Any:
"""
Robustly parse JSON from an LLM response that may contain:
- plain JSON
- ```json ... ```
- explanatory text surrounding JSON
"""
text = (text or "").strip()
# Remove markdown fences.
text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.IGNORECASE)
text = re.sub(r"\s*```$", "", text)
try:
return json.loads(text)
except json.JSONDecodeError:
pass
# Find the first JSON array/object.
candidates = []
first_array = text.find("[")
last_array = text.rfind("]")
if first_array >= 0 and last_array > first_array:
candidates.append(text[first_array:last_array + 1])
first_object = text.find("{")
last_object = text.rfind("}")
if first_object >= 0 and last_object > first_object:
candidates.append(text[first_object:last_object + 1])
for candidate in candidates:
try:
return json.loads(candidate)
except json.JSONDecodeError:
continue
raise ValueError("Could not parse JSON from NVIDIA response.")
# ---------------------------------------------------------------------------
# Public data acquisition
# ---------------------------------------------------------------------------
def download_ieee_cis(force: bool = False) -> tuple[Path, Path]:
"""
Download only the two training files needed from IEEE-CIS.
Kaggle competition rules must be accepted on the competition page before
the API can download the data.
"""
ensure_dirs()
tx_path = RAW_DIR / "train_transaction.csv"
id_path = RAW_DIR / "train_identity.csv"
if tx_path.exists() and id_path.exists() and not force:
LOGGER.info("IEEE-CIS files already exist. Skipping download.")
return tx_path, id_path
try:
import kagglehub
except ImportError as exc:
raise RuntimeError(
"Install kagglehub first: pip install kagglehub"
) from exc
try:
LOGGER.info("Downloading IEEE-CIS train_transaction.csv ...")
downloaded_tx = kagglehub.competition_download(
KAGGLE_COMPETITION,
path="train_transaction.csv",
output_dir=str(RAW_DIR),
force_download=force,
)
LOGGER.info("Downloading IEEE-CIS train_identity.csv ...")
downloaded_id = kagglehub.competition_download(
KAGGLE_COMPETITION,
path="train_identity.csv",
output_dir=str(RAW_DIR),
force_download=force,
)
except Exception as exc:
err_msg = str(exc)
if "403" in err_msg or "permission" in err_msg.lower() or "rules" in err_msg.lower():
raise RuntimeError(
"\n============================================================"
"\nKAGGLE ACCESS DENIED / COMPETITION RULES NOT ACCEPTED"
"\n============================================================"
"\nTo download the IEEE-CIS Fraud Detection dataset:"
"\n1. Ensure KAGGLE_API_TOKEN environment variable is set."
"\n2. Visit: https://www.kaggle.com/competitions/ieee-fraud-detection/rules"
"\n and click 'I Understand and Accept' on Kaggle."
"\n3. Re-run the command once competition rules are accepted."
"\n============================================================"
) from exc
elif not os.getenv("KAGGLE_API_TOKEN") and not (Path.home() / ".kaggle" / "kaggle.json").exists():
raise RuntimeError(
"\n============================================================"
"\nMISSING KAGGLE CREDENTIALS"
"\n============================================================"
"\nKAGGLE_API_TOKEN or Kaggle credentials (~/.kaggle/kaggle.json) not found."
"\nPlease set the KAGGLE_API_TOKEN environment variable."
"\n============================================================"
) from exc
raise
tx_path = Path(downloaded_tx)
id_path = Path(downloaded_id)
LOGGER.info("Transaction file: %s", tx_path)
LOGGER.info("Identity file: %s", id_path)
return tx_path, id_path
# ---------------------------------------------------------------------------
# Dataset A — Model dataset
# ---------------------------------------------------------------------------
TRANSACTION_COLS = [
"TransactionID",
"TransactionDT",
"TransactionAmt",
"ProductCD",
"card1",
"card2",
"card3",
"card4",
"card5",
"card6",
"addr1",
"addr2",
"P_emaildomain",
"R_emaildomain",
"isFraud",
]
IDENTITY_COLS = [
"TransactionID",
"DeviceType",
"DeviceInfo",
]
def load_ieee_cis(
transaction_path: Path,
identity_path: Path,
) -> pd.DataFrame:
LOGGER.info("Reading IEEE-CIS transaction data ...")
tx_comp = "zip" if zipfile.is_zipfile(transaction_path) else None
tx = pd.read_csv(
transaction_path,
usecols=lambda c: c in TRANSACTION_COLS,
compression=tx_comp,
low_memory=False,
)
LOGGER.info(
"Transaction rows=%s columns=%s",
f"{len(tx):,}",
len(tx.columns),
)
if identity_path.exists():
LOGGER.info("Reading IEEE-CIS identity data ...")
id_comp = "zip" if zipfile.is_zipfile(identity_path) else None
identity = pd.read_csv(
identity_path,
usecols=lambda c: c in IDENTITY_COLS,
compression=id_comp,
low_memory=False,
)
df = tx.merge(
identity,
on="TransactionID",
how="left",
)
else:
df = tx.copy()
df["DeviceType"] = "unknown"
df["DeviceInfo"] = "unknown"
return df
def build_model_dataset(
transaction_path: Path,
identity_path: Path,
seed: int = DEFAULT_SEED,
) -> Path:
"""
Build Dataset A.
The public dataset does not expose a merchant_id. Therefore Dataset A
is explicitly a transaction-level fraud model dataset. Merchant-level
temporal behavior is covered by Dataset B.
We create privacy-preserving proxy identifiers only for modeling:
customer_proxy_id
device_proxy_id
No raw IP addresses, names, emails, or other direct identifiers are added.
"""
df = load_ieee_cis(transaction_path, identity_path)
# Relative TransactionDT is converted to a synthetic reference timestamp.
# It is not claimed to be the original real-world timestamp.
origin = pd.Timestamp("2017-12-01", tz="UTC")
df["event_time"] = origin + pd.to_timedelta(
pd.to_numeric(df["TransactionDT"], errors="coerce"),
unit="s",
)
df["amount"] = pd.to_numeric(
df["TransactionAmt"],
errors="coerce",
).fillna(0.0)
df["amount_log1p"] = np.log1p(np.clip(df["amount"], 0, None))
# Privacy-preserving deterministic proxies.
customer_key = (
df["card1"].astype("string").fillna("NA")
+ "|"
+ df["addr1"].astype("string").fillna("NA")
+ "|"
+ df["P_emaildomain"].astype("string").fillna("NA")
)
device_key = (
df["DeviceType"].astype("string").fillna("NA")
+ "|"
+ df["DeviceInfo"].astype("string").fillna("NA")
)
df["customer_proxy_id"] = (
pd.util.hash_pandas_object(customer_key, index=False)
.astype("uint64")
.astype("string")
)
df["device_proxy_id"] = (
pd.util.hash_pandas_object(device_key, index=False)
.astype("uint64")
.astype("string")
)
# Time features.
df["hour"] = df["event_time"].dt.hour.astype("int8")
df["day_of_week"] = df["event_time"].dt.dayofweek.astype("int8")
df["is_weekend"] = (df["day_of_week"] >= 5).astype("int8")
# Simple missingness indicators are useful for IEEE-CIS.
df["identity_available"] = (
df["DeviceInfo"].notna() | df["DeviceType"].notna()
).astype("int8")
# Remove obvious raw columns that are not needed in the canonical dataset.
keep = [
"TransactionID",
"event_time",
"amount",
"amount_log1p",
"ProductCD",
"card1",
"card2",
"card3",
"card4",
"card5",
"card6",
"addr1",
"addr2",
"P_emaildomain",
"R_emaildomain",
"DeviceType",
"DeviceInfo",
"customer_proxy_id",
"device_proxy_id",
"hour",
"day_of_week",
"is_weekend",
"identity_available",
"isFraud",
]
df = df[keep].copy()
df = df.sort_values("event_time").reset_index(drop=True)
# Chronological split: no random mixing of future observations into train.
n = len(df)
train_end = int(n * 0.70)
val_end = int(n * 0.85)
df["split"] = "test"
df.loc[:train_end - 1, "split"] = "train"
df.loc[train_end:val_end - 1, "split"] = "validation"
df["isFraud"] = pd.to_numeric(
df["isFraud"],
errors="coerce",
).fillna(0).astype("int8")
output = PROCESSED_DIR / "dataset_a_model.parquet"
df.to_parquet(output, index=False)
LOGGER.info(
"Dataset A written: %s | rows=%s | fraud=%s",
output,
f"{len(df):,}",
f"{df['isFraud'].sum():,}",
)
return output
# ---------------------------------------------------------------------------
# NVIDIA synthetic scenario specification generation
# ---------------------------------------------------------------------------
def nvidia_client():
try:
from openai import OpenAI
except ImportError as exc:
raise RuntimeError(
"Install the OpenAI client: pip install openai"
) from exc
api_key = os.getenv("NVIDIA_API_KEY")
if not api_key:
raise RuntimeError(
"NVIDIA_API_KEY is not set. Create an NVIDIA Build API key "
"and export it before running --generate-scenarios."
)
return OpenAI(
base_url=NVIDIA_BASE_URL,
api_key=api_key,
timeout=120.0,
max_retries=0,
)
def scenario_prompt(count: int, seed: int) -> str:
return f"""
You are generating DEFENSIVE synthetic data specifications for a fintech
fraud-spike detection benchmark.
This is strictly defensive. Do not provide attack instructions, exploit
instructions, evasion strategies, credential abuse, or operational fraud
guidance. Only generate abstract statistical parameters for simulation.
Return EXACTLY a JSON array with {count} objects and no markdown.
Allowed scenario_type:
- normal
- fraud_spike
- volume_only_spike
- amount_shift
Required fields for every object:
scenario_type
duration_minutes
spike_start_minute
spike_duration_minutes
baseline_txn_per_minute
spike_txn_multiplier
baseline_fraud_rate
spike_fraud_rate
amount_mean
amount_std
customer_count
device_count
new_device_rate
seed
Constraints:
duration_minutes: 120 to 360
spike_start_minute: 30 to duration_minutes-60
spike_duration_minutes: 15 to 60
baseline_txn_per_minute: 3 to 30
spike_txn_multiplier: 1.0 to 10.0
baseline_fraud_rate: 0.002 to 0.03
spike_fraud_rate: 0.002 to 0.30
amount_mean: 100 to 5000
amount_std: 20 to 2500
customer_count: 100 to 5000
device_count: 50 to 3000
new_device_rate: 0.0 to 0.25
Scenario semantics:
- normal: no material fraud-rate increase
- fraud_spike: fraud rate increases during the spike window
- volume_only_spike: transaction volume increases but fraud rate remains
approximately at baseline; this is a HARD NEGATIVE
- amount_shift: amount distribution changes without requiring a fraud-rate
increase; this is another HARD NEGATIVE
Keep values statistically plausible. Use seed values derived from {seed}.
"""
def normalize_spec(raw: dict[str, Any], index: int, base_seed: int) -> ScenarioSpec:
scenario_type = str(raw.get("scenario_type", "normal")).strip().lower()
if scenario_type not in ALLOWED_SCENARIOS:
scenario_type = "normal"
duration = clamp_int(
raw.get("duration_minutes"),
120,
360,
240,
)
spike_start = clamp_int(
raw.get("spike_start_minute"),
30,
max(31, duration - 60),
90,
)
spike_duration = clamp_int(
raw.get("spike_duration_minutes"),
15,
min(60, duration - spike_start),
30,
)
baseline_fraud = clamp(
raw.get("baseline_fraud_rate"),
0.002,
0.03,
0.01,
)
spike_fraud = clamp(
raw.get("spike_fraud_rate"),
0.002,
0.30,
0.08 if scenario_type == "fraud_spike" else baseline_fraud,
)
if scenario_type != "fraud_spike":
spike_fraud = baseline_fraud
multiplier = clamp(
raw.get("spike_txn_multiplier"),
1.0,
10.0,
1.0,
)
if scenario_type == "normal":
multiplier = 1.0
elif scenario_type == "volume_only_spike":
multiplier = max(multiplier, 2.0)
return ScenarioSpec(
scenario_id=f"S{index:05d}",
scenario_type=scenario_type,
duration_minutes=duration,
spike_start_minute=spike_start,
spike_duration_minutes=spike_duration,
baseline_txn_per_minute=clamp(
raw.get("baseline_txn_per_minute"),
3,
30,
10,
),
spike_txn_multiplier=multiplier,
baseline_fraud_rate=baseline_fraud,
spike_fraud_rate=spike_fraud,
amount_mean=clamp(
raw.get("amount_mean"),
100,
5000,
1000,
),
amount_std=clamp(
raw.get("amount_std"),
20,
2500,
500,
),
customer_count=clamp_int(
raw.get("customer_count"),
100,
5000,
1000,
),
device_count=clamp_int(
raw.get("device_count"),
50,
3000,
500,
),
new_device_rate=clamp(
raw.get("new_device_rate"),
0,
0.25,
0.05,
),
seed=clamp_int(
raw.get("seed"),
1,
2_000_000_000,
base_seed + index,
),
)
def request_scenario_batch(
client,
batch_count: int,
batch_index: int,
base_seed: int,
retries: int = 3,
) -> list[dict[str, Any]]:
prompt = scenario_prompt(
count=batch_count,
seed=base_seed + batch_index * 10_000,
)
for attempt in range(retries):
try:
response = client.chat.completions.create(
model=DEFAULT_NVIDIA_MODEL,
messages=[
{
"role": "system",
"content": (
"You are a strict JSON generator for defensive "
"financial ML simulation."
),
},
{"role": "user", "content": prompt},
],
temperature=0.2,
top_p=0.8,
max_tokens=2500,
stream=False,
)
content = response.choices[0].message.content
parsed = parse_json_from_text(content)
if isinstance(parsed, dict):
parsed = [parsed]
if not isinstance(parsed, list):
raise ValueError("NVIDIA response is not a JSON list.")
return parsed
except Exception as exc:
wait = 2 ** attempt
LOGGER.warning(
"NVIDIA batch %s failed (attempt %s/%s): %s; retrying in %ss",
batch_index,
attempt + 1,
retries,
exc,
wait,
)
time.sleep(wait)
raise RuntimeError(
f"NVIDIA batch {batch_index} failed after {retries} attempts."
)
def generate_scenario_specs(
count: int = DEFAULT_SCENARIOS,
workers: int = DEFAULT_WORKERS,
batch_size: int = DEFAULT_BATCH_SIZE,
seed: int = DEFAULT_SEED,
offline: bool = False,
) -> list[ScenarioSpec]:
"""
Generate bounded scenario specifications.
workers controls concurrent NVIDIA requests, NOT raw transaction-row
generation. Raw rows are generated locally and deterministically.
"""
if offline:
LOGGER.warning(
"OFFLINE mode: using deterministic fallback specifications; "
"NVIDIA API is not called."
)
return make_offline_specs(count, seed)
client = nvidia_client()
batches = []
remaining = count
batch_index = 0
while remaining > 0:
n = min(batch_size, remaining)
batches.append((batch_index, n))
remaining -= n
batch_index += 1
LOGGER.info(
"Generating %s scenario specs using NVIDIA model=%s workers=%s",
count,
DEFAULT_NVIDIA_MODEL,
workers,
)
results: list[dict[str, Any]] = []
with ThreadPoolExecutor(max_workers=max(1, workers)) as executor:
futures = {
executor.submit(
request_scenario_batch,
client,
batch_count,
batch_idx,
seed,
): batch_idx
for batch_idx, batch_count in batches
}
for future in as_completed(futures):
batch_idx = futures[future]
try:
batch = future.result()
results.extend(batch)
LOGGER.info(
"Completed NVIDIA batch %s: %s specs",
batch_idx,
len(batch),
)
except Exception as exc:
LOGGER.error(
"NVIDIA batch %s failed permanently: %s",
batch_idx,
exc,
)
if not results:
raise RuntimeError(
"No NVIDIA scenario specifications were generated."
)
specs = []
for i, raw in enumerate(results[:count]):
specs.append(normalize_spec(raw, i, seed))
# Ensure all four classes exist when enough scenarios are requested.
required = ["normal", "fraud_spike", "volume_only_spike", "amount_shift"]
for i, required_type in enumerate(required):
if i < len(specs):
specs[i].scenario_type = required_type
if required_type == "fraud_spike":
specs[i].spike_fraud_rate = max(
specs[i].spike_fraud_rate,
0.08,
)
elif required_type != "fraud_spike":
specs[i].spike_fraud_rate = specs[i].baseline_fraud_rate
if required_type == "volume_only_spike":
specs[i].spike_txn_multiplier = max(
specs[i].spike_txn_multiplier,
2.0,
)
if required_type == "normal":
specs[i].spike_txn_multiplier = 1.0
return specs
def make_offline_specs(count: int, seed: int) -> list[ScenarioSpec]:
"""
Local deterministic fallback for development/testing.
It is not the final NVIDIA-generated dataset.
"""
rng = np.random.default_rng(seed)
types = ["normal", "fraud_spike", "volume_only_spike", "amount_shift"]
specs = []
for i in range(count):
scenario_type = types[i % len(types)]
duration = int(rng.integers(180, 301))
start = int(rng.integers(45, max(46, duration - 45)))
duration_spike = int(rng.integers(20, 51))
baseline = float(rng.uniform(5, 20))
base_fraud = float(rng.uniform(0.005, 0.02))
if scenario_type == "fraud_spike":
spike_fraud = float(rng.uniform(0.08, 0.20))
multiplier = float(rng.uniform(1.5, 4.0))
elif scenario_type == "volume_only_spike":
spike_fraud = base_fraud
multiplier = float(rng.uniform(2.5, 7.0))
elif scenario_type == "amount_shift":
spike_fraud = base_fraud
multiplier = 1.0
else:
spike_fraud = base_fraud
multiplier = 1.0
specs.append(
ScenarioSpec(
scenario_id=f"S{i:05d}",
scenario_type=scenario_type,
duration_minutes=duration,
spike_start_minute=start,
spike_duration_minutes=min(
duration_spike,
duration - start,
),
baseline_txn_per_minute=baseline,
spike_txn_multiplier=multiplier,
baseline_fraud_rate=base_fraud,
spike_fraud_rate=spike_fraud,
amount_mean=float(rng.uniform(300, 2500)),
amount_std=float(rng.uniform(100, 1000)),
customer_count=int(rng.integers(500, 3000)),
device_count=int(rng.integers(200, 1500)),
new_device_rate=float(rng.uniform(0.01, 0.15)),
seed=seed + i,
)
)
return specs
# ---------------------------------------------------------------------------
# Deterministic synthetic transaction generation
# ---------------------------------------------------------------------------
def generate_scenario_rows(spec: ScenarioSpec) -> pd.DataFrame:
rng = np.random.default_rng(spec.seed)
merchant_id = f"M_{spec.scenario_id}"
start_time = pd.Timestamp("2026-01-01", tz="UTC") + pd.Timedelta(
days=int(spec.scenario_id[1:]) % 180
)
rows = []
for minute in range(spec.duration_minutes):
in_spike = (
spec.spike_start_minute
<= minute
< spec.spike_start_minute + spec.spike_duration_minutes
)
# Volume behavior.
multiplier = (
spec.spike_txn_multiplier
if in_spike
else 1.0
)
# Amount behavior.
amount_mean = spec.amount_mean
amount_std = spec.amount_std
if spec.scenario_type == "amount_shift" and in_spike:
amount_mean *= 2.5
amount_std *= 1.8
expected = spec.baseline_txn_per_minute * multiplier
n_transactions = int(
np.clip(
rng.poisson(expected),
1,
50,
)
)
# Fraud behavior.
fraud_rate = (
spec.spike_fraud_rate
if (
spec.scenario_type == "fraud_spike"
and in_spike
)
else spec.baseline_fraud_rate
)
for _ in range(n_transactions):
customer_idx = int(
rng.integers(0, spec.customer_count)
)
device_idx = int(
rng.integers(0, spec.device_count)
)
amount = float(
max(
1.0,
rng.normal(
amount_mean,
max(1.0, amount_std),
),
)
)
is_fraud = int(rng.random() < fraud_rate)
# New-device signal is probabilistic and becomes more common
# during suspicious periods, but remains abstract/synthetic.
new_device_prob = spec.new_device_rate
if spec.scenario_type == "fraud_spike" and in_spike:
new_device_prob = min(
0.5,
new_device_prob * 2.5,
)
is_new_device = int(
rng.random() < new_device_prob
)
event_time = (
start_time
+ pd.Timedelta(minutes=minute)
+ pd.Timedelta(
seconds=int(rng.integers(0, 60))
)
)
rows.append(
{
"scenario_id": spec.scenario_id,
"scenario_type": spec.scenario_type,
"merchant_id": merchant_id,
"event_time": event_time,
"customer_id": f"C_{customer_idx:05d}",
"device_id": f"D_{device_idx:05d}",
"amount": round(amount, 2),
"payment_method": str(
rng.choice(
["card", "upi", "wallet", "netbanking"]
)
),
"transaction_type": "purchase",
"is_new_device": is_new_device,
"is_fraud": is_fraud,
"spike_window": int(in_spike),
"fraud_spike": int(
spec.scenario_type == "fraud_spike"
and in_spike
),
}
)
df = pd.DataFrame(rows)
if df.empty:
return df
df = df.sort_values("event_time").reset_index(drop=True)
# Minute bucket.
df["minute_bucket"] = df["event_time"].dt.floor("min")
# Merchant temporal features.
per_minute = (
df.groupby("minute_bucket", as_index=False)
.agg(
minute_txn_count=("transaction_id_temp", "count")
if "transaction_id_temp" in df.columns
else ("amount", "size"),
minute_fraud_count=("is_fraud", "sum"),
minute_amount_sum=("amount", "sum"),
)
)
per_minute["rolling_txn_15m"] = (
per_minute["minute_txn_count"]
.rolling(15, min_periods=1)
.sum()
)
per_minute["rolling_fraud_15m"] = (
per_minute["minute_fraud_count"]
.rolling(15, min_periods=1)
.sum()
)
per_minute["rolling_fraud_rate_15m"] = (
per_minute["rolling_fraud_15m"]
/ per_minute["rolling_txn_15m"].clip(lower=1)
)
# Baseline from the first 30 minutes. This avoids using future spike data
# to define the baseline.
baseline_window = per_minute.iloc[
: min(30, len(per_minute))
]
baseline_txn_15m = float(
baseline_window["minute_txn_count"].mean() * 15
)
baseline_fraud_rate = float(
baseline_window["minute_fraud_count"].sum()
/ max(1, baseline_window["minute_txn_count"].sum())
)
per_minute["baseline_txn_15m"] = max(
1.0,
baseline_txn_15m,
)
per_minute["baseline_fraud_rate"] = baseline_fraud_rate
per_minute["velocity_ratio"] = (
per_minute["rolling_txn_15m"]
/ per_minute["baseline_txn_15m"]
)
per_minute["fraud_rate_deviation"] = (
per_minute["rolling_fraud_rate_15m"]
- per_minute["baseline_fraud_rate"]
)
# Amount anomaly relative to baseline.
baseline_amount = float(
baseline_window["minute_amount_sum"].mean()
/ baseline_window["minute_txn_count"].clip(lower=1).mean()
)
per_minute["baseline_amount"] = max(
1.0,
baseline_amount,
)
# Map minute-level features back to transactions.
df = df.merge(
per_minute[
[
"minute_bucket",
"rolling_txn_15m",
"rolling_fraud_rate_15m",
"baseline_txn_15m",
"baseline_fraud_rate",
"velocity_ratio",
"fraud_rate_deviation",
"baseline_amount",
]
],
on="minute_bucket",
how="left",
)
df["amount_deviation"] = (
df["amount"] / df["baseline_amount"].clip(lower=1)
)
df["merchant_txn_count_15m"] = (
df["rolling_txn_15m"].round().astype("int32")
)
# Stable transaction ID.
df.insert(
0,
"transaction_id",
[
f"T_{spec.scenario_id}_{i:07d}"
for i in range(len(df))
],
)
# Remove helper column.
df = df.drop(columns=["minute_bucket"])
return df
def generate_synthetic_dataset(
specs: list[ScenarioSpec],
) -> Path:
frames = []
for i, spec in enumerate(specs, start=1):
frame = generate_scenario_rows(spec)
frames.append(frame)
if i % 10 == 0 or i == len(specs):
LOGGER.info(
"Generated %s/%s synthetic scenarios",
i,
len(specs),
)
df = pd.concat(frames, ignore_index=True)
# Scenario-level chronological split.
scenario_ids = sorted(df["scenario_id"].unique())
n = len(scenario_ids)
train_ids = set(scenario_ids[: int(n * 0.70)])
val_ids = set(
scenario_ids[
int(n * 0.70): int(n * 0.85)
]
)
df["split"] = np.where(
df["scenario_id"].isin(train_ids),
"train",
np.where(
df["scenario_id"].isin(val_ids),
"validation",
"test",
),
)
output = PROCESSED_DIR / "dataset_b_scenarios.parquet"
df.to_parquet(output, index=False)
LOGGER.info(
"Dataset B written: %s | rows=%s | scenarios=%s",
output,
f"{len(df):,}",
df["scenario_id"].nunique(),
)
return output
def save_specs(specs: list[ScenarioSpec]) -> Path:
path = PROCESSED_DIR / "scenario_specs.json"
with path.open("w", encoding="utf-8") as f:
json.dump(
[asdict(s) for s in specs],
f,
indent=2,
)
return path
def write_metadata(
model_path: Path | None,
scenario_path: Path | None,
specs_path: Path | None,
) -> Path:
metadata = {
"project": "RazorShield",
"purpose": "Defensive fraud-spike detection",
"dataset_a": {
"name": "IEEE-CIS Fraud Detection",
"source": (
"https://www.kaggle.com/competitions/"
"ieee-fraud-detection"
),
"local_path": str(model_path) if model_path else None,
"split": "chronological 70/15/15",
},
"dataset_b": {
"name": "RazorShield Defensive Synthetic Scenarios",
"local_path": str(scenario_path) if scenario_path else None,
"split": "scenario-level 70/15/15",
"scenario_types": sorted(ALLOWED_SCENARIOS),
"nvidia_model": DEFAULT_NVIDIA_MODEL,
"nvidia_endpoint": NVIDIA_BASE_URL,
"scenario_specs": (
str(specs_path) if specs_path else None
),
},
"principles": [
"No offensive fraud instructions are generated.",
"LLM generates bounded scenario parameters, not raw transaction rows.",
"Numeric synthetic rows are generated deterministically with NumPy.",
"Future observations are not used for Dataset A chronological split.",
"Dataset B is split by scenario, not by random transaction rows.",
],
}
path = PROCESSED_DIR / "metadata.json"
with path.open("w", encoding="utf-8") as f:
json.dump(metadata, f, indent=2)
return path
def generate_validation_report(
model_path: Path | None = None,
scenario_path: Path | None = None,
) -> Path:
"""Generate validation_report.json summarizing dataset metrics and data quality checks."""
report: dict[str, Any] = {
"dataset_a": None,
"dataset_b": None,
"validation_timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
if model_path is None:
model_path = PROCESSED_DIR / "dataset_a_model.parquet"
if scenario_path is None:
scenario_path = PROCESSED_DIR / "dataset_b_scenarios.parquet"
if model_path.exists():
df_a = pd.read_parquet(model_path)
missing_pct = (df_a.isna().mean() * 100).round(2).to_dict()
split_counts = df_a["split"].value_counts().to_dict()
fraud_per_split = {
str(k): int(v)
for k, v in df_a.groupby("split")["isFraud"].sum().to_dict().items()
}
fraud_pct_per_split = {
str(k): round(float(v * 100), 3)
for k, v in df_a.groupby("split")["isFraud"].mean().to_dict().items()
}
train_df = df_a[df_a["split"] == "train"]
val_df = df_a[df_a["split"] == "validation"]
test_df = df_a[df_a["split"] == "test"]
broken_split = False
if not train_df.empty and not val_df.empty:
if train_df["event_time"].max() > val_df["event_time"].min():
broken_split = True
if not val_df.empty and not test_df.empty:
if val_df["event_time"].max() > test_df["event_time"].min():
broken_split = True
dup_ids = int(df_a["TransactionID"].duplicated().sum())
invalid_targets = int((~df_a["isFraud"].isin([0, 1])).sum())
negative_amounts = int((df_a["amount"] < 0).sum())
missing_times = int(df_a["event_time"].isna().sum())
num_cols = df_a.select_dtypes(include=[np.number]).columns
inf_values = (
int(np.isinf(df_a[num_cols]).sum().sum())
if len(num_cols) > 0
else 0
)
report["dataset_a"] = {
"total_rows": len(df_a),
"total_columns": len(df_a.columns),
"fraud_count": int(df_a["isFraud"].sum()),
"fraud_percentage": round(float(df_a["isFraud"].mean() * 100), 3),
"missing_percentage_per_column": missing_pct,
"duplicate_transaction_ids": dup_ids,
"min_event_time": str(df_a["event_time"].min()),
"max_event_time": str(df_a["event_time"].max()),
"train_rows": int(split_counts.get("train", 0)),
"validation_rows": int(split_counts.get("validation", 0)),
"test_rows": int(split_counts.get("test", 0)),
"fraud_count_per_split": fraud_per_split,
"fraud_percentage_per_split": fraud_pct_per_split,
"checks": {
"duplicate_ids": dup_ids == 0,
"valid_targets": invalid_targets == 0,
"no_negative_amounts": negative_amounts == 0,
"no_missing_event_time": missing_times == 0,
"valid_chronological_split": not broken_split,
"no_infinite_values": inf_values == 0,
},
}
if scenario_path.exists():
df_b = pd.read_parquet(scenario_path)
scenario_summaries = []
for scenario_id, group in df_b.groupby("scenario_id"):
s_type = group["scenario_type"].iloc[0]
baseline_rows = group[group["spike_window"] == 0]
spike_rows = group[group["spike_window"] == 1]
base_fraud = (
float(baseline_rows["is_fraud"].mean())
if not baseline_rows.empty
else 0.0
)
spk_fraud = (
float(spike_rows["is_fraud"].mean())
if not spike_rows.empty
else 0.0
)
base_vol = len(baseline_rows)
spk_vol = len(spike_rows)
max_vel = (
float(group["velocity_ratio"].max())
if "velocity_ratio" in group.columns
else 1.0
)
label = int(group["fraud_spike"].max())
scenario_summaries.append(
{
"scenario_id": str(scenario_id),
"scenario_type": str(s_type),
"rows": len(group),
"baseline_fraud_rate": round(base_fraud, 4),
"spike_fraud_rate": round(spk_fraud, 4),
"baseline_volume": base_vol,
"spike_volume": spk_vol,
"max_velocity_ratio": round(max_vel, 2),
"fraud_spike_label": label,
}
)
scenario_splits = df_b.groupby("scenario_id")["split"].nunique()
scenario_leakage = int((scenario_splits > 1).sum())
dup_b_ids = (
int(df_b["transaction_id"].duplicated().sum())
if "transaction_id" in df_b.columns
else 0
)
missing_b_times = int(df_b["event_time"].isna().sum())
negative_b_amounts = int((df_b["amount"] < 0).sum())
report["dataset_b"] = {
"total_rows": len(df_b),
"total_scenarios": int(df_b["scenario_id"].nunique()),
"scenario_summary_table": scenario_summaries,
"split_counts": {
str(k): int(v) for k, v in df_b["split"].value_counts().to_dict().items()
},
"checks": {
"no_duplicate_ids": dup_b_ids == 0,
"no_missing_event_time": missing_b_times == 0,
"no_negative_amounts": negative_b_amounts == 0,
"no_scenario_leakage": scenario_leakage == 0,
},
}
report_path = PROCESSED_DIR / "validation_report.json"
with report_path.open("w", encoding="utf-8") as f:
json.dump(report, f, indent=2)
return report_path
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="RazorShield data acquisition and synthetic scenario pipeline."
)
parser.add_argument(
"--download-public",
action="store_true",
help="Download IEEE-CIS training files from Kaggle.",
)
parser.add_argument(
"--build-model",
action="store_true",
help="Build Dataset A from IEEE-CIS.",
)
parser.add_argument(
"--generate-scenarios",
action="store_true",
help="Generate Dataset B using NVIDIA + deterministic simulation.",
)
parser.add_argument(
"--offline-synthetic",
action="store_true",
help="Use local fallback scenario specs instead of NVIDIA.",
)
parser.add_argument(
"--all",
action="store_true",
help="Run download + Dataset A + Dataset B.",
)
parser.add_argument(
"--workers",
type=int,
default=DEFAULT_WORKERS,
help="Concurrent NVIDIA requests. Keep conservative for hosted APIs.",
)
parser.add_argument(
"--scenarios",
type=int,
default=DEFAULT_SCENARIOS,
help="Number of synthetic scenarios.",
)
parser.add_argument(
"--batch-size",
type=int,
default=DEFAULT_BATCH_SIZE,
help="Scenario specs requested per NVIDIA API call.",
)
parser.add_argument(
"--seed",
type=int,
default=DEFAULT_SEED,
)
parser.add_argument(
"--force-download",
action="store_true",
)
return parser.parse_args()
def main() -> None:
load_environment()
args = parse_args()
ensure_dirs()
if not any(
[
args.download_public,
args.build_model,
args.generate_scenarios,
args.all,
]
):
print(
"Nothing selected. Use --all or one of "
"--download-public / --build-model / --generate-scenarios."
)
return
tx_path = RAW_DIR / "train_transaction.csv"
id_path = RAW_DIR / "train_identity.csv"
model_path = None
scenario_path = None
specs_path = None
if args.all or args.download_public or args.build_model:
tx_path, id_path = download_ieee_cis(
force=args.force_download
)
if args.all or args.build_model:
model_path = build_model_dataset(
tx_path,
id_path,
seed=args.seed,
)
if args.all or args.generate_scenarios:
specs = generate_scenario_specs(
count=args.scenarios,
workers=args.workers,
batch_size=args.batch_size,
seed=args.seed,
offline=args.offline_synthetic,
)
specs_path = save_specs(specs)
scenario_path = generate_synthetic_dataset(specs)
metadata_path = write_metadata(
model_path,
scenario_path,
specs_path,
)
val_report_path = generate_validation_report(
model_path,
scenario_path,
)
LOGGER.info("Metadata written: %s", metadata_path)
LOGGER.info("Validation report written: %s", val_report_path)
LOGGER.info("Pipeline complete.")
if __name__ == "__main__":
main()