File size: 3,876 Bytes
c510329
b247ec7
 
 
d7b4e58
c510329
b363b47
b247ec7
038589c
c510329
 
 
 
 
 
 
 
 
 
 
 
 
d7b4e58
c510329
 
 
d7b4e58
 
 
c510329
d7b4e58
c510329
 
 
d7b4e58
 
f8bd732
c510329
 
d7b4e58
c802754
 
 
c510329
 
6624b3c
d7b4e58
c510329
c802754
 
 
6624b3c
c802754
4283c81
6624b3c
c802754
 
c510329
 
c802754
b247ec7
 
 
 
 
b363b47
 
 
 
b247ec7
c510329
b247ec7
 
 
 
a41a0d6
c510329
 
a41a0d6
c510329
 
 
b247ec7
c510329
 
 
 
 
d7b4e58
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
"""
SHAP Explainer Module
- Uses XGBoost's native pred_contribs (no shap library dependency at runtime)
- Cached DMatrix construction
- top_n parameter
"""
import numpy as np
import xgboost as xgb
from src.value_coercion import coerce_float

FEATURE_DESCRIPTIONS = {
    'tx_count_total': 'Total number of outgoing transactions',
    'tx_count_7d': 'Transaction count in the last 7 days',
    'amount_sent_total': 'Total amount of funds sent',
    'amount_sent_7d': 'Amount of funds sent in the last 7 days',
    'amount_received_total': 'Total amount of funds received',
    'amount_received_7d': 'Amount of funds received in the last 7 days',
    'forward_ratio': 'Percentage of received funds immediately forwarded',
    'avg_tx_amount': 'Average transaction amount sent',
    'amount_std': 'Consistency of transaction amounts',
    'in_out_ratio': 'Ratio of received to sent funds',
    'pagerank_score': 'Network influence of this account',
    'betweenness_score': 'Bridge importance — how often this account lies on shortest paths',
    'in_degree': 'Number of accounts sending funds to this account',
    'out_degree': 'Number of accounts receiving funds from this account',
    'fan_in_ratio': 'Concentration of incoming vs outgoing connections',
    'community_encoded': 'Fraud rate of the network community this account belongs to',
    'cycle_length': 'Length of circular transaction loop this account is part of (0 if none)',
    'cycle_max_amount': 'Peak amount transacted in the detected circular loop',
    'account_age_days': 'Age of the account based on transaction history',
    'days_since_last_tx': 'Days elapsed since the most recent transaction',
    'currency_diversity': 'Number of distinct currencies used',
    'channel_diversity': 'Number of distinct payment channels used',
    'bank_diversity': 'Number of distinct destination banks used',
    'velocity_ratio_7d': 'Proportion of all-time activity concentrated in last 7 days',
    'gnn_fraud_score': 'Graph Neural Network fraud probability from neighbourhood analysis',
    'hybrid_score': 'Topological fraud risk based on directional graph flow analysis.',
}


from functools import lru_cache

@lru_cache(maxsize=100)
def explain_prediction(
    account_id: str,
    feature_df_hash: int,  # hash so cache clears when features change
    top_n: int = 5,
) -> list[dict]:
    from src.state import AppState
    fba = AppState.features_by_account
    bundle = AppState.xgb_bundle

    if not bundle or not fba:
        return []

    feat = fba.get(account_id)
    if not feat:
        return []

    feature_cols = [c for c in bundle['feature_cols'] if c in feat]

    # Build a guaranteed float64 numpy array and wrap in DMatrix directly.
    # This bypasses both pandas dtype inference AND the shap library's
    # TreeExplainer internals (which on some shap/xgboost version combos
    # tries to float()-cast model metadata stored as '[8.754906E-1]').
    vals = np.array(
        [coerce_float(feat.get(col, 0.0)) for col in feature_cols],
        dtype=np.float64,
    )
    dmat = xgb.DMatrix(vals.reshape(1, -1), feature_names=feature_cols)

    # XGBoost's native SHAP contributions: shape (1, n_features + 1).
    # Last column is the bias term (base score), not a feature contribution.
    contribs = bundle['model'].get_booster().predict(dmat, pred_contribs=True)
    sv_row = contribs[0, :-1]  # drop bias column

    results = []
    for i, col in enumerate(feature_cols):
        sv = float(sv_row[i])
        results.append({
            'feature_name': col,
            'shap_value': sv,
            'feature_value': float(vals[i]),
            'direction': 'increases risk' if sv > 0 else 'decreases risk',
            'description': FEATURE_DESCRIPTIONS.get(col, col),
        })

    results.sort(key=lambda x: abs(x['shap_value']), reverse=True)
    return results[:top_n]