File size: 4,118 Bytes
176e552
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
"""Natural-language explanation of a scored transaction.

Tries an LLM through Hugging Face Inference Providers when an ``HF_TOKEN``
secret is configured on the Space; otherwise falls back to a deterministic
rule-based narrative built from the same SHAP values, so the app always works.
"""

import os

from model import FEATURE_FMT, FEATURE_LABELS

LLM_MODEL = os.environ.get("LLM_MODEL", "meta-llama/Llama-3.1-8B-Instruct")


def risk_band(p: float) -> tuple[str, str]:
    """Return (band name, recommended action)."""
    if p < 0.05:
        return "LOW", "Approve normally."
    if p < 0.30:
        return "ELEVATED", "Approve, but flag the account for passive monitoring."
    if p < 0.70:
        return "HIGH", "Trigger step-up authentication (3-D Secure / OTP) before approving."
    return "CRITICAL", "Decline and route to manual fraud review immediately."


def _ranked(shap_values: dict) -> list[tuple[str, float]]:
    return sorted(shap_values.items(), key=lambda kv: abs(kv[1]), reverse=True)


def _describe(feature: str, inputs: dict) -> str:
    return f"{FEATURE_LABELS[feature].lower()} = {FEATURE_FMT[feature](inputs[feature])}"


def template_explanation(result: dict) -> str:
    """Deterministic analyst-style narrative from the SHAP ranking."""
    p = result["probability"]
    band, action = risk_band(p)
    ranked = _ranked(result["shap_values"])
    inputs = result["inputs"]

    drivers = [(f, v) for f, v in ranked if v > 0.05][:3]
    mitigators = [(f, v) for f, v in ranked if v < -0.05][:2]

    lines = [
        f"**Verdict:** this transaction scores **{p:.1%}** fraud probability — **{band}** risk."
    ]
    if drivers:
        parts = [_describe(f, inputs) for f, _ in drivers]
        lines.append(
            "The score is driven mainly by "
            + (", ".join(parts[:-1]) + " and " + parts[-1] if len(parts) > 1 else parts[0])
            + "."
        )
    if mitigators:
        parts = [_describe(f, inputs) for f, _ in mitigators]
        lines.append(
            "Working in the customer's favour: "
            + (" and ".join(parts))
            + "."
        )
    lines.append(f"**Recommended action:** {action}")
    return "\n\n".join(lines)


def llm_explanation(result: dict) -> tuple[str, str]:
    """Return (markdown_text, source) where source is 'llm' or 'rules'."""
    token = os.environ.get("HF_TOKEN")
    if not token:
        return template_explanation(result), "rules"

    try:
        from huggingface_hub import InferenceClient

        p = result["probability"]
        band, action = risk_band(p)
        ranked = _ranked(result["shap_values"])
        inputs = result["inputs"]
        shap_lines = "\n".join(
            f"- {FEATURE_LABELS[f]} = {FEATURE_FMT[f](inputs[f])}: SHAP {v:+.3f}"
            for f, v in ranked
        )
        prompt = (
            f"A gradient-boosted fraud model scored a card transaction at "
            f"{p:.1%} fraud probability ({band} risk; policy action: {action}).\n"
            f"SHAP contributions in log-odds (positive pushes toward fraud):\n"
            f"{shap_lines}\n\n"
            "Write a fraud analyst's explanation in 3–5 sentences: state the "
            "verdict, explain the top risk drivers in plain language, mention "
            "any mitigating factors, and end with the recommended action. "
            "Do not mention SHAP or log-odds; talk about the transaction itself."
        )
        client = InferenceClient(api_key=token)
        out = client.chat_completion(
            model=LLM_MODEL,
            messages=[
                {
                    "role": "system",
                    "content": "You are a senior fraud analyst writing concise, factual case notes.",
                },
                {"role": "user", "content": prompt},
            ],
            max_tokens=350,
            temperature=0.3,
        )
        text = out.choices[0].message.content.strip()
        if not text:
            raise ValueError("empty completion")
        return text, "llm"
    except Exception:
        return template_explanation(result), "rules"