HarryDataScience's picture
Publish SENTINEL
0655115 verified
Raw History Blame Contribute Delete
2.29 kB
"""Cost-based decision threshold and risk bands."""
import numpy as np
def optimal_threshold(y_true, proba, cost_fn: float, cost_fp: float) -> dict:
"""Pick the threshold that minimises FN*cost_fn + FP*cost_fp.
A transaction is flagged when proba >= threshold. Candidate thresholds are
the boundaries between distinct score values (midpoints), so the chosen
threshold is not glued to a single validation score.
"""
y = np.asarray(y_true).astype(int)
p = np.asarray(proba, dtype=float)
if y.shape != p.shape or y.size == 0:
raise ValueError("y_true and proba must be non-empty and the same length")
n_pos = int(y.sum())
baseline = n_pos * cost_fn # cost of flagging nothing
order = np.argsort(-p, kind="stable")
ps, ys = p[order], y[order]
tp = np.cumsum(ys)
fp = np.cumsum(1 - ys)
last_of_group = np.where(np.r_[ps[1:] != ps[:-1], True])[0]
cost = (n_pos - tp[last_of_group]) * cost_fn + fp[last_of_group] * cost_fp
best = int(np.argmin(cost))
if cost[best] >= baseline:
# Flagging anything costs more than it saves: flag nothing.
return {"threshold": 1.0, "expected_cost": float(baseline), "baseline_cost": float(baseline)}
idx = last_of_group[best]
if idx + 1 < len(ps):
threshold = float((ps[idx] + ps[idx + 1]) / 2.0)
else:
threshold = float(ps[idx] / 2.0)
return {"threshold": threshold, "expected_cost": float(cost[best]), "baseline_cost": float(baseline)}
def expected_cost(y_true, flagged, cost_fn: float, cost_fp: float) -> float:
y = np.asarray(y_true).astype(int)
f = np.asarray(flagged).astype(bool)
fn = int(((y == 1) & ~f).sum())
fp = int(((y == 0) & f).sum())
return float(fn * cost_fn + fp * cost_fp)
def risk_band_edges(threshold: float) -> dict:
"""Risk bands derive from the decision threshold t:
LOW < t/2 <= MEDIUM < t <= HIGH. HIGH is exactly the 'flagged as fraud' set.
"""
return {"medium_from": threshold * 0.5, "high_from": threshold}
def risk_level(probability: float, threshold: float) -> str:
edges = risk_band_edges(threshold)
if probability >= edges["high_from"]:
return "HIGH"
if probability >= edges["medium_from"]:
return "MEDIUM"
return "LOW"