revai-api / app /services /benchmarking.py
Shaankar39's picture
Deploy RevAI API
3d1332e
Raw
History Blame Contribute Delete
7.04 kB
import json
import uuid
import datetime
import numpy as np
from typing import List, Dict, Any, Optional
from sqlalchemy.orm import Session
from app.models.benchmark import Benchmark
BUCKET_SIZE = 10 # 0-10, 10-20, ..., 90-100
def _scores_to_histogram(scores: List[float]) -> Dict[str, int]:
"""Convert a list of scores to a bucketed histogram."""
hist = {}
for s in scores:
bucket = min(90, int(s // BUCKET_SIZE) * BUCKET_SIZE)
key = f"{bucket}-{bucket + BUCKET_SIZE}"
hist[key] = hist.get(key, 0) + 1
return hist
def _merge_histograms(existing: Dict[str, int], new: Dict[str, int]) -> Dict[str, int]:
"""Merge new histogram counts into existing."""
merged = existing.copy()
for k, v in new.items():
merged[k] = merged.get(k, 0) + v
return merged
def _histogram_to_stats(histogram: Dict[str, int], total: int) -> dict:
"""Compute avg, median, percentiles from bucketed histogram."""
if total == 0:
return {"avg_score": 0, "median_score": 0, "p25_score": 0, "p75_score": 0,
"high_risk_pct": 0, "medium_risk_pct": 0, "low_risk_pct": 0}
# Expand to approximate flat list (midpoint of each bucket)
scores = []
for bucket, count in histogram.items():
lo, hi = bucket.split("-")
midpoint = (int(lo) + int(hi)) / 2
scores.extend([midpoint] * count)
arr = np.array(scores)
high = int((arr >= 70).sum())
med = int(((arr >= 40) & (arr < 70)).sum())
low = int((arr < 40).sum())
return {
"avg_score": round(float(np.mean(arr)), 1),
"median_score": round(float(np.median(arr)), 1),
"p25_score": round(float(np.percentile(arr, 25)), 1),
"p75_score": round(float(np.percentile(arr, 75)), 1),
"high_risk_pct": round(high / total * 100, 1),
"medium_risk_pct": round(med / total * 100, 1),
"low_risk_pct": round(low / total * 100, 1),
}
def update_benchmarks(db: Session, scores: List[float], model_type: str):
"""Anonymously merge new scores into global benchmarks."""
if not scores:
return
benchmark = db.query(Benchmark).filter(Benchmark.id == "global").first()
if not benchmark:
benchmark = Benchmark(id="global")
db.add(benchmark)
db.flush()
new_hist = _scores_to_histogram(scores)
if model_type == "churn":
existing = benchmark.get_churn_data().get("histogram", {})
merged = _merge_histograms(existing, new_hist)
total = benchmark.total_churn_scored + len(scores)
data = {
"histogram": merged,
**_histogram_to_stats(merged, total),
"total_records_scored": total,
}
benchmark.set_churn_data(data)
benchmark.total_churn_scored = total
benchmark.unique_churn_companies += 1 # each predict call = 1 company's data
elif model_type == "lead":
existing = benchmark.get_lead_data().get("histogram", {})
merged = _merge_histograms(existing, new_hist)
total = benchmark.total_lead_scored + len(scores)
data = {
"histogram": merged,
**_histogram_to_stats(merged, total),
"total_records_scored": total,
}
benchmark.set_lead_data(data)
benchmark.total_lead_scored = total
benchmark.unique_lead_companies += 1
benchmark.updated_at = datetime.datetime.utcnow()
db.commit()
def get_benchmarks(db: Session) -> dict:
"""Get current global benchmarks with privacy floor."""
benchmark = db.query(Benchmark).filter(Benchmark.id == "global").first()
if not benchmark:
return {"churn": None, "lead": None, "privacy_notice": "Not enough data yet. Benchmarks available when ≥3 companies contribute."}
result = {}
for mt in ("churn", "lead"):
raw = benchmark.get_churn_data() if mt == "churn" else benchmark.get_lead_data()
companies = benchmark.unique_churn_companies if mt == "churn" else benchmark.unique_lead_companies
if companies < 3:
result[mt] = None
else:
result[mt] = {
**raw,
"unique_companies": companies,
"privacy_safe": True,
}
return result
def compare_to_benchmark(user_scores: List[float], model_type: str, db: Session) -> dict:
"""Compare a user's score distribution against global benchmarks."""
benchmark = db.query(Benchmark).filter(Benchmark.id == "global").first()
if not benchmark:
return {"available": False, "reason": "No benchmarks yet"}
data = benchmark.get_churn_data() if model_type == "churn" else benchmark.get_lead_data()
companies = benchmark.unique_churn_companies if model_type == "churn" else benchmark.unique_lead_companies
if companies < 3 or not data:
return {"available": False, "reason": f"Need ≥3 companies ({companies} so far)"}
arr = np.array(user_scores)
user_avg = float(np.mean(arr))
user_median = float(np.median(arr))
user_high = round(float((arr >= 70).sum() / len(arr) * 100), 1)
user_med = round(float(((arr >= 40) & (arr < 70)).sum() / len(arr) * 100), 1)
user_low = round(float((arr < 40).sum() / len(arr) * 100), 1)
bench_avg = data.get("avg_score", 0)
bench_median = data.get("median_score", 0)
bench_high = data.get("high_risk_pct", 0)
bench_med = data.get("medium_risk_pct", 0)
bench_low = data.get("low_risk_pct", 0)
# Percentile: where user_avg sits in benchmark distribution (approximate)
percentile = round(min(99, max(1, (user_avg / max(bench_avg, 1)) * 50)), 0)
# Status
if user_avg < bench_avg * 0.8:
status = "excellent" # significantly below industry avg
elif user_avg < bench_avg * 1.1:
status = "average"
else:
status = "needs_attention"
return {
"available": True,
"companies_compared": companies,
"user": {
"avg_score": round(user_avg, 1),
"median_score": round(user_median, 1),
"high_risk_pct": user_high,
"medium_risk_pct": user_med,
"low_risk_pct": user_low,
"n_scored": len(user_scores),
},
"industry_benchmark": {
"avg_score": bench_avg,
"median_score": bench_median,
"high_risk_pct": bench_high,
"medium_risk_pct": bench_med,
"low_risk_pct": bench_low,
"total_records": data.get("total_records_scored", 0),
"companies": companies,
},
"comparison": {
"percentile": percentile,
"vs_industry_avg": round(user_avg - bench_avg, 1),
"status": status,
"status_label": {"excellent": "Better than industry — your churn is below average",
"average": "In line with industry average",
"needs_attention": "Above industry average — review risk factors"}.get(status, ""),
},
}