Spaces:
Running on Zero
Running on Zero
| """ | |
| benchmark.py | |
| ------------ | |
| RazorShield Zero-Shot Hugging Face SLM Benchmark Suite. | |
| Generates 300+ deterministic evidence examples and gold expectations, | |
| evaluates candidate models across JSON validity, schema validity, numeric grounding, | |
| decision consistency, severity consistency, campaign consistency, signal coverage, | |
| hallucination rate, output length, latency (Load, Avg, P50, P95, P99), and memory usage. | |
| Outputs: | |
| - data/explanation/evidence_dataset.jsonl | |
| - data/explanation/benchmark_results.json | |
| - data/explanation/benchmark_results.csv | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import logging | |
| from pathlib import Path | |
| import random | |
| import time | |
| from typing import Any | |
| import numpy as np | |
| import pandas as pd | |
| import torch | |
| from src.explanation.explainer import RazorShieldExplainer | |
| from src.explanation.model_loader import SLMModelLoader | |
| from src.explanation.schemas import ExplanationInput, GoldExpectation | |
| ROOT = Path(__file__).resolve().parents[2] | |
| DATA_DIR = ROOT / "data" / "explanation" | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s | %(levelname)s | %(message)s", | |
| ) | |
| LOGGER = logging.getLogger("slm-benchmark") | |
| def generate_benchmark_dataset(num_examples: int = 300) -> list[dict[str, Any]]: | |
| """Generates a deterministic dataset of 300+ evidence examples with gold expectations.""" | |
| DATA_DIR.mkdir(parents=True, exist_ok=True) | |
| dataset_path = DATA_DIR / "evidence_dataset.jsonl" | |
| random.seed(42) | |
| np.random.seed(42) | |
| categories = [ | |
| "NORMAL", "INVESTIGATE", "ALERT", "VOLUME_ONLY_SPIKE", | |
| "AMOUNT_SHIFT", "FRAUD_DURING_CAMPAIGN", "CAMPAIGN_WITHOUT_FRAUD" | |
| ] | |
| dataset = [] | |
| per_cat = (num_examples // len(categories)) + 1 | |
| for cat_idx, cat in enumerate(categories): | |
| for i in range(per_cat): | |
| m_id = f"M_{100 + ((cat_idx * per_cat + i) % 50)}" | |
| if cat == "NORMAL": | |
| inc_state = "NORMAL" | |
| sev = "LOW" | |
| score = round(random.uniform(0.01, 0.20), 4) | |
| spike_p = round(random.uniform(0.01, 0.15), 4) | |
| fe_ratio = round(random.uniform(0.8, 1.2), 2) | |
| vel_ratio = round(random.uniform(0.9, 1.2), 2) | |
| susp_win = 0 | |
| camp = False | |
| action = "Maintain standard automated processing." | |
| signals = [{"name": "velocity_ratio", "value": vel_ratio, "direction": "normal"}] | |
| elif cat == "INVESTIGATE": | |
| inc_state = "INVESTIGATE" | |
| sev = "MEDIUM" | |
| score = round(random.uniform(0.35, 0.55), 4) | |
| spike_p = round(random.uniform(0.25, 0.45), 4) | |
| fe_ratio = round(random.uniform(1.8, 2.8), 2) | |
| vel_ratio = round(random.uniform(1.5, 2.5), 2) | |
| susp_win = 1 | |
| camp = False | |
| action = "Monitor merchant stream closely and apply selective verification." | |
| signals = [ | |
| {"name": "spike_probability", "value": spike_p, "direction": "elevated"}, | |
| {"name": "fraud_excess_ratio", "value": fe_ratio, "direction": "elevated"}, | |
| ] | |
| elif cat == "ALERT": | |
| inc_state = "ALERT" | |
| sev = "HIGH" | |
| score = round(random.uniform(0.68, 0.95), 4) | |
| spike_p = round(random.uniform(0.50, 0.92), 4) | |
| fe_ratio = round(random.uniform(3.5, 12.0), 2) | |
| vel_ratio = round(random.uniform(2.0, 5.0), 2) | |
| susp_win = random.randint(2, 5) | |
| camp = False | |
| action = "Initiate immediate merchant review and enforce step-up authentication." | |
| signals = [ | |
| {"name": "spike_probability", "value": spike_p, "direction": "elevated"}, | |
| {"name": "fraud_excess_ratio", "value": fe_ratio, "direction": "elevated"}, | |
| {"name": "consecutive_suspicious_windows", "value": susp_win, "direction": "persistent"}, | |
| ] | |
| elif cat == "VOLUME_ONLY_SPIKE": | |
| inc_state = "NORMAL" | |
| sev = "LOW" | |
| score = round(random.uniform(0.10, 0.25), 4) | |
| spike_p = round(random.uniform(0.05, 0.20), 4) | |
| fe_ratio = round(random.uniform(0.8, 1.2), 2) | |
| vel_ratio = round(random.uniform(3.5, 6.0), 2) | |
| susp_win = 0 | |
| camp = (i % 2 == 0) | |
| action = "Normal promotional volume surge. Maintain standard processing." | |
| signals = [{"name": "velocity_ratio", "value": vel_ratio, "direction": "normal" if camp else "elevated"}] | |
| elif cat == "AMOUNT_SHIFT": | |
| inc_state = "NORMAL" | |
| sev = "LOW" | |
| score = round(random.uniform(0.12, 0.28), 4) | |
| spike_p = round(random.uniform(0.05, 0.22), 4) | |
| fe_ratio = round(random.uniform(0.9, 1.3), 2) | |
| vel_ratio = round(random.uniform(1.0, 1.5), 2) | |
| susp_win = 0 | |
| camp = False | |
| action = "Bulk order shift observed. No fraud excess detected." | |
| signals = [{"name": "amount_deviation", "value": round(random.uniform(3.0, 7.0), 2), "direction": "elevated"}] | |
| elif cat == "FRAUD_DURING_CAMPAIGN": | |
| inc_state = "ALERT" | |
| sev = "HIGH" | |
| score = round(random.uniform(0.70, 0.94), 4) | |
| spike_p = round(random.uniform(0.45, 0.88), 4) | |
| fe_ratio = round(random.uniform(3.0, 9.0), 2) | |
| vel_ratio = round(random.uniform(4.0, 6.5), 2) | |
| susp_win = random.randint(2, 4) | |
| camp = True | |
| action = "Flash sale active with elevated fraud excess. Enforce step-up verification." | |
| signals = [ | |
| {"name": "fraud_excess_ratio", "value": fe_ratio, "direction": "elevated"}, | |
| {"name": "velocity_ratio", "value": vel_ratio, "direction": "suppressed"}, | |
| {"name": "consecutive_suspicious_windows", "value": susp_win, "direction": "persistent"}, | |
| ] | |
| else: # CAMPAIGN_WITHOUT_FRAUD | |
| inc_state = "NORMAL" | |
| sev = "LOW" | |
| score = round(random.uniform(0.08, 0.22), 4) | |
| spike_p = round(random.uniform(0.04, 0.18), 4) | |
| fe_ratio = round(random.uniform(0.8, 1.1), 2) | |
| vel_ratio = round(random.uniform(4.0, 6.0), 2) | |
| susp_win = 0 | |
| camp = True | |
| action = "Active flash sale with normal fraud excess. Maintain standard processing." | |
| signals = [{"name": "velocity_ratio", "value": vel_ratio, "direction": "suppressed"}] | |
| inp = ExplanationInput( | |
| merchant_id=m_id, | |
| incident_state=inc_state, | |
| severity=sev, | |
| incident_score=score, | |
| spike_probability=spike_p, | |
| fraud_excess_ratio=fe_ratio, | |
| velocity_ratio=vel_ratio, | |
| suspicious_windows=susp_win, | |
| total_suspicious_windows=susp_win, | |
| campaign_active=camp, | |
| policy_mode="BALANCED", | |
| signals=signals, | |
| recommended_action=action, | |
| ) | |
| gold = GoldExpectation( | |
| expected_incident_state=inc_state, | |
| expected_severity=sev, | |
| required_numeric_values=["fraud_excess_ratio", "velocity_ratio"], | |
| required_signals=[s["name"] for s in signals], | |
| campaign_status=camp, | |
| allowed_actions=["monitor", "review", "verification", "processing", "maintain"], | |
| forbidden_claims=["$50,000", "IP address", "phishing", "confirmed fraud"], | |
| ) | |
| dataset.append({ | |
| "example_id": len(dataset) + 1, | |
| "category": cat, | |
| "input": inp.model_dump(), | |
| "gold": gold.model_dump(), | |
| }) | |
| if len(dataset) >= num_examples: | |
| break | |
| if len(dataset) >= num_examples: | |
| break | |
| with dataset_path.open("w", encoding="utf-8") as f: | |
| for item in dataset: | |
| f.write(json.dumps(item) + "\n") | |
| LOGGER.info("Generated %d benchmark dataset examples to %s", len(dataset), dataset_path) | |
| return dataset | |
| def evaluate_candidate_model( | |
| model_name: str, | |
| dataset: list[dict[str, Any]], | |
| device_str: str = "cuda" if torch.cuda.is_available() else "cpu", | |
| ) -> dict[str, Any]: | |
| """Evaluates a candidate SLM across all dataset examples.""" | |
| LOGGER.info("--- Benchmarking Candidate SLM: %s ---", model_name) | |
| loader = SLMModelLoader(model_name=model_name, device=device_str, max_new_tokens=160, temperature=0.1) | |
| t_load_start = time.perf_counter() | |
| load_success = loader.load_model() | |
| t_load_sec = round(time.perf_counter() - t_load_start, 2) | |
| if not load_success: | |
| return { | |
| "model": model_name, | |
| "device": device_str, | |
| "load_success": False, | |
| "model_load_time_sec": t_load_sec, | |
| "overall_score": 0.0, | |
| "note": "Model failed to load", | |
| } | |
| explainer = RazorShieldExplainer(model_loader=loader) | |
| json_valid_count = 0 | |
| schema_valid_count = 0 | |
| numeric_grounded_count = 0 | |
| dec_consistent_count = 0 | |
| sev_consistent_count = 0 | |
| camp_consistent_count = 0 | |
| signal_cov_count = 0 | |
| hallucination_free_count = 0 | |
| word_counts = [] | |
| latencies_ms = [] | |
| model_outputs = [] | |
| for item in dataset: | |
| inp_data = ExplanationInput(**item["input"]) | |
| gold = GoldExpectation(**item["gold"]) | |
| t_start = time.perf_counter() | |
| out, val_res = explainer.generate_explanation(inp_data, expectation=gold) | |
| t_ms = (time.perf_counter() - t_start) * 1000.0 | |
| latencies_ms.append(t_ms) | |
| word_counts.append(val_res["word_count"]) | |
| if not val_res.get("used_fallback", True): | |
| json_valid_count += 1 | |
| schema_valid_count += 1 | |
| if val_res["numeric_grounded"]: | |
| numeric_grounded_count += 1 | |
| if val_res["decision_consistent"]: | |
| dec_consistent_count += 1 | |
| if val_res["severity_consistent"]: | |
| sev_consistent_count += 1 | |
| if val_res["campaign_consistent"]: | |
| camp_consistent_count += 1 | |
| if not val_res["hallucination_detected"]: | |
| hallucination_free_count += 1 | |
| # Signal coverage check | |
| signals_in_text = 0 | |
| text_lower = f"{out.summary} {' '.join(out.key_signals)}".lower() | |
| for s_name in gold.required_signals: | |
| if s_name.lower().replace("_", " ") in text_lower or s_name.lower() in text_lower: | |
| signals_in_text += 1 | |
| if not gold.required_signals or signals_in_text >= max(1, len(gold.required_signals) // 2): | |
| signal_cov_count += 1 | |
| model_outputs.append({ | |
| "example_id": item["example_id"], | |
| "input": inp_data.model_dump(), | |
| "output": out.model_dump(), | |
| "validation": val_res, | |
| }) | |
| n_total = len(dataset) | |
| json_validity = round(json_valid_count / n_total, 4) | |
| schema_validity = round(schema_valid_count / n_total, 4) | |
| numeric_grounding = round(numeric_grounded_count / n_total, 4) | |
| decision_consistency = round(dec_consistent_count / n_total, 4) | |
| severity_consistency = round(sev_consistent_count / n_total, 4) | |
| campaign_consistency = round(camp_consistent_count / n_total, 4) | |
| signal_coverage = round(signal_cov_count / n_total, 4) | |
| hallucination_rate = round(1.0 - (hallucination_free_count / n_total), 4) | |
| avg_words = round(float(np.mean(word_counts)), 1) | |
| avg_lat = round(float(np.mean(latencies_ms)), 2) | |
| p50_lat = round(float(np.median(latencies_ms)), 2) | |
| p95_lat = round(float(np.percentile(latencies_ms, 95)), 2) | |
| p99_lat = round(float(np.percentile(latencies_ms, 99)), 2) | |
| # Formula specified in prompt: | |
| # quality_score = 0.25*json_validity + 0.20*numeric_grounding + 0.20*decision_consistency + 0.10*severity_consistency + 0.10*campaign_consistency + 0.10*signal_coverage + 0.05*(1 - hallucination_rate) | |
| quality_score = ( | |
| (0.25 * json_validity) | |
| + (0.20 * numeric_grounding) | |
| + (0.20 * decision_consistency) | |
| + (0.10 * severity_consistency) | |
| + (0.10 * campaign_consistency) | |
| + (0.10 * signal_coverage) | |
| + (0.05 * (1.0 - hallucination_rate)) | |
| ) | |
| mem_usage_mb = round(torch.cuda.memory_allocated() / (1024 * 1024), 2) if torch.cuda.is_available() else 0.0 | |
| res = { | |
| "model": model_name, | |
| "load_success": True, | |
| "device": device_str, | |
| "model_load_time_sec": t_load_sec, | |
| "json_validity": json_validity, | |
| "schema_validity": schema_validity, | |
| "numeric_grounding": numeric_grounding, | |
| "decision_consistency": decision_consistency, | |
| "severity_consistency": severity_consistency, | |
| "campaign_consistency": campaign_consistency, | |
| "signal_coverage": signal_coverage, | |
| "hallucination_rate": hallucination_rate, | |
| "avg_words": avg_words, | |
| "avg_latency_ms": avg_lat, | |
| "p50_latency_ms": p50_lat, | |
| "p95_latency_ms": p95_lat, | |
| "p99_latency_ms": p99_lat, | |
| "memory_usage_mb": mem_usage_mb, | |
| "quality_score": round(quality_score, 4), | |
| } | |
| # Save model outputs log | |
| out_dir = DATA_DIR / "model_outputs" | |
| out_dir.mkdir(parents=True, exist_ok=True) | |
| sanitized_name = model_name.replace("/", "_").replace("-", "_") | |
| with (out_dir / f"{sanitized_name}_outputs.json").open("w", encoding="utf-8") as f: | |
| json.dump(model_outputs, f, indent=2) | |
| # Clean memory | |
| del loader | |
| del explainer | |
| if torch.cuda.is_available(): | |
| torch.cuda.empty_cache() | |
| return res | |
| def run_slm_benchmark() -> dict[str, Any]: | |
| dataset = generate_benchmark_dataset(num_examples=300) | |
| candidate_models = [ | |
| "Qwen/Qwen2.5-0.5B-Instruct", | |
| "Qwen/Qwen2.5-1.5B-Instruct", | |
| "HuggingFaceTB/SmolLM2-1.7B-Instruct", | |
| ] | |
| results = [] | |
| for model_name in candidate_models: | |
| res = evaluate_candidate_model(model_name, dataset) | |
| results.append(res) | |
| # Save benchmark_results.json and benchmark_results.csv | |
| json_path = DATA_DIR / "benchmark_results.json" | |
| with json_path.open("w", encoding="utf-8") as f: | |
| json.dump(results, f, indent=2) | |
| df_res = pd.DataFrame(results) | |
| csv_path = DATA_DIR / "benchmark_results.csv" | |
| df_res.to_csv(csv_path, index=False) | |
| LOGGER.info("Benchmark complete. Results saved to %s and %s", json_path, csv_path) | |
| return {"benchmark_results": results} | |
| if __name__ == "__main__": | |
| run_slm_benchmark() | |